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%
    ),
  )
}

位于立方体上前方的眼睛能看到正面和顶面;来自同一象限的光以 cos⁡45°≈71%\cos 45° \approx 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 会执行齐次除法,因此最后一行把 zz 复制到 ww 的矩阵会除以深度:

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(α, β, γ) 依次绕世界坐标轴 xx、yy、zz 旋转,并在 β=±90°\beta = \pm 90° 时失去一个自由度(万向节锁)。
  • 光照方向。 face_intensity 期望的是从表面指向光源的单位向量。方向相反的向量会照亮物体的背面;未归一化的向量会给出大于 1 的亮度。
  • 镜像变换。 含奇数个负因子的缩放会把面内外翻转:法向量指向内部,背面测试会隐藏错误的一侧。
  • 手工构建网格。 Mesh 和 QuadFace 的字段在 core 之外是只读的,因此像 { vertices, faces } 这样的记录字面量在你的包中无法编译。请从生成器开始,再用变换改变其形状。
  • 绝对容差。 DEPTH_EPSILON 是以世界单位计的 10−910^{-9}。比单位尺度大得多或小得多的场景可能会意外触及它。

下一步