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° 回すと、立方体は視点に 1 本の稜と、そこで接する 2 つの面を見せます。

よくある作業

基本形状のメッシュを作る

どの生成関数も原点を中心とする閉じたメッシュを返します。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 球には、極の 2 頂点と 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 は、linear-algebra の演算で作った行列や射影行列を含め、任意の 4×4 の @la.Matrix[Double] を受け付けます。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 パッケージはオブジェクトごとに Mesh と Transform3 を SceneObject で受け取り、カメラ変換、投影、カリング、シェーディングを行います。frontend チュートリアルを見てください。

性能

ベクトルの演算はそのたびに新しい @la.Vector[Double] を割り当て、apply_mesh は頂点ごとに 1 つ割り当てます。デモの数千頂点なら問題ありません。大きなメッシュでは、同じ頂点に apply_point を何度も呼ぶのではなく、フレームごとに一度変換して結果を再利用してください。

よくある落とし穴

  • 合成の順序。 a.compose(b) は a を先に適用します。行列は b.matrix * a.matrix で、行列積で考えると逆に見えます。
  • 2 つの異なる長さ。 @core.vec_length(v) はユークリッドノルムで、linear-algebra の v.length() は成分の個数です。
  • ラジアン。 角度はすべてラジアンです。rotation_matrix(α, β, γ) はワールド軸の xx、yy、zz の順に回転し、β=±90°\beta = \pm 90° で自由度を 1 つ失います(ジンバルロック)。
  • 光の方向。 face_intensity は表面から光源へ向かう単位ベクトルを期待します。逆向きのベクトルでは物体の裏側が照らされ、正規化されていないベクトルでは輝度が 1 を超えます。
  • 鏡映変換。 負の係数を奇数個含む拡大縮小は面を裏返します。法線が内側を向き、背面判定が間違った側を隠します。
  • メッシュを手で作る。 Mesh と QuadFace のフィールドは core の外からは読み取り専用なので、{ vertices, faces } のようなレコードリテラルは自分のパッケージではコンパイルできません。生成関数から始めて、変換で形を変えてください。
  • 絶対的な許容誤差。 DEPTH_EPSILON はワールド単位で 10−910^{-9} です。単位の大きさよりはるかに大きい、または小さいシーンでは、思わぬところでこれに引っかかることがあります。

次のステップ

  • core API にはすべての関数が正確なシグネチャとともに載っています。
  • 同次変換、オイラー角の行列、面の向き、ランバートシェーディングは core の設計で導出しています。
  • view チュートリアルに進み、メッシュの前にカメラを置きましょう。
  • ベクトルと行列の型は Luna-Flow/linear-algebra のドキュメントにあります:https://lunaflow.cn/en/linear-algebra/。