core 教程
本教程教你构建网格,用变换把它们放到世界中,并提出每个渲染器都会对面提出的问题:它在哪里,朝向哪里,眼睛能否看到它,以及它被照得有多亮。这些都不需要相机或屏幕;相机和屏幕在 view 教程中登场。
快速开始
添加本模块以及 core 所用向量类型所在的 linear-algebra:
moon add Luna-Flow/geometry3d@0.5.1
moon add Luna-Flow/linear-algebra@0.4.2
在你的 moon.pkg 中导入该包:
import {
"moonbitlang/core/math",
"Luna-Flow/geometry3d/core",
"Luna-Flow/linear-algebra/mutable" @la,
}
然后把一个立方体绕竖直轴旋转 45°,并数一数位于它前方的眼睛能看到几个面:
fn main {
let cube = @core.cube_mesh(1.0)
let turn = @core.Transform3::rotation(0.0, @math.PI / 4.0, 0.0)
let turned = turn.apply_mesh(cube)
let eye = @core.vec3(0.0, 0.0, -4.5)
let mut visible = 0
for face in turned.faces {
if @core.face_is_visible(turned.vertices, face, eye) {
visible += 1
}
}
println("faces: \{turned.faces.length()}, visible: \{visible}")
}
输出:
faces: 6, visible: 2
旋转 45° 后,立方体向眼睛展示一条棱以及在此相交的两个面。
常见任务
构建基本网格
每个生成器都返回一个以原点为中心的封闭网格。小于 3 的分辨率参数会被提升到 3。
test "primitive meshes" {
let sphere = @core.sphere_mesh(1.0, 8, 12)
inspect(sphere.vertices.length(), content="86")
inspect(sphere.faces.length(), content="96")
let cylinder = @core.cylinder_mesh(0.5, 2.0, 16)
inspect(cylinder.faces.length(), content="48")
let pyramid = @core.triangular_pyramid_mesh(1.0, 1.5)
inspect(pyramid.vertices.length(), content="4")
let torus = @core.torus_mesh(2.0, 0.5, 24, 12)
inspect(torus.vertices.length(), content="288")
}
具有 rings 个纬圈和 segments 个分段的 UV 球,有两个极点顶点加上 rings - 1 个纬线圆,以及 rings * segments 个面;两极处的面是三角形,存储为最后一个下标重复第一个的四边形。
把物体放到世界中
模型变换通常先缩放,再旋转,最后平移。compose 以下一步作为参数,因此这条链按此顺序阅读:
test "model transform" {
let model = @core.Transform3::scale(2.0, 1.0, 1.0)
.compose(@core.Transform3::rotation(0.0, 0.0, @math.PI / 2.0))
.compose(@core.Transform3::translation(0.0, 0.0, 5.0))
// (1, 0, 0) -> scale -> (2, 0, 0) -> rotate -> (0, 2, 0) -> move -> (0, 2, 5)
let p = model.apply_point(@core.vec3(1.0, 0.0, 0.0))
inspect(p[0].abs() < 1.0e-12, content="true")
inspect(p[1], content="2")
inspect(p[2], content="5")
// the same transform applied to a whole mesh keeps its faces
let box = model.apply_mesh(@core.cube_mesh(0.5))
inspect(box.faces.length(), content="6")
}
区分点与方向
方向(例如光线或速度)在物体被平移时不应移动。对它使用 apply_direction:
test "points and directions" {
let t = @core.Transform3::translation(10.0, 0.0, 0.0)
let up = @core.vec3(0.0, 1.0, 0.0)
inspect(t.apply_point(up)[0], content="10")
inspect(t.apply_direction(up)[0], content="0")
inspect(t.apply_direction(up)[1], content="1")
}
为可见面着色
face_normal 给出朝外的单位法向量,face_is_visible 进行背面测试,face_intensity 给出某光照方向下的朗伯项。光照方向从表面指向光源,并且必须是单位向量:
fn shade_report(mesh : @core.Mesh, eye : @la.Vector[Double]) -> Array[String] {
let light = @core.normalize_vec(@core.vec3(0.0, 1.0, -1.0))
let lines = []
for face in mesh.faces {
if @core.face_is_visible(mesh.vertices, face, eye) {
let n = @core.face_normal(mesh.vertices, face)
let i = @core.face_intensity(mesh.vertices, face, light)
let pct = (i * 100.0).round().to_int()
lines.push("normal (\{n[0]}, \{n[1]}, \{n[2]}) intensity \{pct}%")
}
}
lines
}
test "shade the visible faces" {
let cube = @core.cube_mesh(1.0)
let eye = @core.vec3(0.0, 3.0, -4.0)
inspect(
shade_report(cube, eye).join("\n"),
content=(
#|normal (0, 0, -1) intensity 71%
#|normal (0, 1, 0) intensity 71%
),
)
}
位于立方体上前方的眼睛能看到正面和顶面;来自同一象限的光以 照亮这两个面。
为光栅化器三角化
光栅化器绘制的是三角形。triangulate_quad 把四边形拆成保持其环绕方向的扇形:
test "triangulate" {
let pyramid = @core.triangular_pyramid_mesh(1.0, 1.0)
let mut triangles = 0
for face in pyramid.faces {
for t in @core.triangulate_quad(face) {
// the second triangle of a degenerate quad repeats a vertex
if t.a != t.b && t.b != t.c && t.c != t.a {
triangles += 1
}
}
}
inspect(triangles, content="4")
}
进阶
使用自己的矩阵
Transform3::from_matrix 接受任意 4×4 的 @la.Matrix[Double],包括用 linear-algebra 运算构造的矩阵以及射影矩阵。apply_point 会执行齐次除法,因此最后一行把 复制到 的矩阵会除以深度:
test "projective matrix" {
let divide_by_z = @la.Matrix::from_2d_array([
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
])
let p = @core.Transform3::from_matrix(divide_by_z).apply_point(
@core.vec3(2.0, 4.0, 2.0),
)
inspect(p[0], content="1")
inspect(p[1], content="2")
// composing with the matrix product of linear-algebra
let twice = @core.Transform3::from_matrix(
@core.rotation_z(0.25) * @core.rotation_z(0.25),
)
let q = twice.apply_point(@core.vec3(1.0, 0.0, 0.0))
inspect((q[0] - @math.cos(0.5)).abs() < 1.0e-12, content="true")
}
把结果交给管线的其余部分
core 止步于世界空间。frontend 包在 SceneObject 中为每个对象接收一个 Mesh 和一个 Transform3,并替你完成相机变换、投影、剔除和着色;见 frontend 教程。
性能
每次向量运算都会分配一个新的 @la.Vector[Double],apply_mesh 为每个顶点分配一个。对演示中几千个顶点来说这没有问题。对于大型网格,请每帧只变换一次并复用结果,而不是对同一些顶点反复调用 apply_point。
常见陷阱
- 组合顺序。
a.compose(b)先应用a。其矩阵为b.matrix * a.matrix,如果你按矩阵乘积思考,会觉得顺序是反的。 - 两种不同的长度。
@core.vec_length(v)是欧几里得范数;linear-algebra的v.length()是分量个数。 - 弧度。 所有角度都以弧度为单位。
rotation_matrix(α, β, γ)依次绕世界坐标轴 、、 旋转,并在 时失去一个自由度(万向节锁)。 - 光照方向。
face_intensity期望的是从表面指向光源的单位向量。方向相反的向量会照亮物体的背面;未归一化的向量会给出大于 1 的亮度。 - 镜像变换。 含奇数个负因子的缩放会把面内外翻转:法向量指向内部,背面测试会隐藏错误的一侧。
- 手工构建网格。
Mesh和QuadFace的字段在core之外是只读的,因此像{ vertices, faces }这样的记录字面量在你的包中无法编译。请从生成器开始,再用变换改变其形状。 - 绝对容差。
DEPTH_EPSILON是以世界单位计的 。比单位尺度大得多或小得多的场景可能会意外触及它。
下一步
- core API 列出了每个函数及其精确签名。
- core 设计推导了齐次变换、欧拉角矩阵、面的定向和朗伯着色。
- 继续阅读 view 教程,在网格前放一台相机。
Luna-Flow/linear-algebra记录了向量和矩阵类型:https://lunaflow.cn/en/linear-algebra/。