15.1 程序化网格生成

从《烛影山谷》的两类几何出发,推导八面体细分球的顶点数解析式 V = 4^(d+1) + 2 与地形格点数 (gridN+1)^2,用整数哈希噪声加双线性与 smoothstep 造起伏、顶点色按高度插值,最后把整场景几何序列化成小端 int32 缓冲取 sha256 与 TypeScript 逐字节对照。

15.1 程序化网格生成

前十四章一直在「已经有一批三角形」这个前提下工作。本章把三角形造出来,而且不读任何外部文件。站内的 glTF 资产管线 讲的是资产导入工程——文件格式怎么解析、网格怎么压缩、导入器怎么设计;本卷讲的是程序化几何的整数生成:几何完全由 config/scene_spec.json 的字段与整数噪声派生,一个字节的外部模型文件都不读。一个读文件,一个算文件,分工清楚。程序化几何对本书这套「逐字节可复现」的方法论特别友好:外部模型的顶点坐标是别人算的浮点数,你没法断言它;程序化几何的每个坐标都是自己用整数推出来的,可以写解析公式、可以跨语言对照。所以《烛影山谷》的整场景——起伏地形加三个材质球——没有一件外部素材。

两种几何,同一个约束

场景规格里只有两类几何来源:

  • terrain:一张 gridN × gridN 的规则高度场,高度由整数噪声函数给出;
  • spheres[]:若干球,每个由八面体细分生成,细分次数由 subdiv 字段给出。

两者的共同约束是全整数:不许出现浮点常量、不许出现 sin / cos、不许调用 random。这不是洁癖——本套的核心主张是「同一份规格在 Python 与 TypeScript 下产出逐字节相同的缓冲」,而浮点三角函数在两种语言里的实现细节(libm 版本、-ffast-math、求值顺序)没有任何跨语言保证。整数没有这个问题:加减乘、算术右移、显式向零截断的定点除法,都是精确定义的。

八面体细分球:顶点数的解析式

球面本身是超越曲面,任何多边形化都是逼近。常见的做法是经纬球:按纬度切圈、按经度分段,坐标用 $\sin$/$\cos$ 算。本书不这么做,原因就是上一段——三角函数表一旦有偏差,顶点会飞出画面,而且没法跨语言复现。改用八面体细分:

  1. 取正八面体的 6 个顶点 $(\pm 1, 0, 0)$、$(0, \pm 1, 0)$、$(0, 0, \pm 1)$,归一化到单位球面;
  2. 把 8 个三角形面各切一刀:每条边的中点归一化回球面,一个三角形变成 4 个;
  3. 重复第 2 步 subdiv 次;
  4. 最后乘半径、加球心。

全流程只有整数加法、算术右移(>> 1 取中点)与定点归一化(v_norm,内部用 fsqrt 与 fdiv),没有一次三角函数。

顶点数有闭式解。设第 $d$ 步后有 $V_d$ 个顶点、$F_d$ 个面。初始 $V_0 = 6$、$F_0 = 8$。细分时每条边中点成为新顶点,而三角网格的边数 $E_d = 3F_d/2$,所以

$$V_{d+1} = V_d + E_d = V_d + \tfrac{3}{2} F_d = V_d + 12 \cdot 4^d, \qquad F_{d+1} = 4 F_d = 8 \cdot 4^{d+1}$$

对 $V$ 求和解这个一阶递推:

$$V_d = 6 + 12\sum_{k=0}^{d-1} 4^k = 6 + 12\cdot\frac{4^d - 1}{3} = 4^{d+1} + 2$$

面数更直接:$F_d = 8 \cdot 4^d$。脚本把这两条公式对 subdiv = 0..3 逐个断言,实测全部吻合:subdiv = 0, 1, 2, 3 依次给出 6/8、18/32、66/128、258/512(顶点/三角形),与公式逐个对上。

注意 subdiv = 2 时是 66 顶点 / 128 三角形,而不是「8 的某个幂」:相邻三角形共享边中点,正是这个共享让 $V$ 只按 $4^d$ 增长而不是 $4^{d+1}$。下一节讨论表示法时,它就是索引缓冲省下的字节。

地形:整数噪声 + 双线性 + smoothstep

地形是一张规则格点的高度场。terrain.gridN = 16 表示 16 × 16 个格子,也就是 17 × 17 = 289 个格点。这里要小心一个容易写错的计数:顶点数是 (gridN+1)^2 而不是 gridN^2——$N$ 个格子需要 $N+1$ 个格线交点。三角形数则是每个格子两个:$2N^2 = 512$。

高度来自一个确定性整数哈希噪声 _hash_noise(ix, iz, seed):把格点整数坐标与种子做三次乘加异或、右移混合、取模 $2^{16}$,得到 $[0, 65536)$ 的 Q16.16 值。它没有任何浮点、没有 random——同一份 (ix, iz, seed) 必然给出同一个数。

格点之间的插值用双线性 + smoothstep:

$$n = a + \mathrm{ss}(t_z),(b - a),\quad a = n_{00} + \mathrm{ss}(t_x)(n_{10} - n_{00}),\quad b = n_{01} + \mathrm{ss}(t_x)(n_{11} - n_{01})$$

其中 $\mathrm{ss}(t) = 3t^2 - 2t^3$,分数部分 $t_x, t_z$ 由世界坐标对格点间距取余得到。为什么不用线性插值?线性插值在格线上导数不连续,地形会出现明显的「折纸」棱线;smoothstep 在 $t=0$ 与 $t=1$ 处导数为零,格子之间的过渡是光滑的。全部是整数乘加与 >> 16,没有任何 pow。

噪声格点间距取 4.0 世界单位(Q16.16 里就是 1 << 18),这个常数决定起伏尺度:太小则地形像砂纸,太大则整个场景只有一两个坡。实测高度落在 $[8856, 188874]$(Q16.16),即 0.135 到 2.882 个世界单位。

地形没有法线(卷一第 1 章的最小模型里,地形法线简化为竖直向上),起伏靠顶点色显现:低处用 albedoQ8 = [72, 82, 66](深色),高处用 albedoHighQ8 = [158, 152, 132](浅色),中间按高度归一化后的 $t$ 线性插值,即 t = min(ONE, max(0, (y << 16) // hs)) 后对三个通道各取 lo[i] + (((hi[i] - lo[i]) * t) >> 16)。实测最低处顶点色为 (75, 85, 68)、最高处为 (154, 149, 129)(低/高参考色分别是 [72, 82, 66] 与 [158, 152, 132])。

这里 y << 16 之后再整除 hs:因为 $y \ge 0$ 且 $hs > 0$,分子非负,所以 // 与向零截断一致,两种语言可复现(BRIEF §3.3)。最低点颜色 (75, 85, 68) 比 albedoQ8 亮一点,因为最低顶点并不在 $t = 0$ 处——噪声哈希最小值不是 0,说明插值真的在按高度取值。

可运行产物

下面这个脚本把上面所有断言变成可复现的数字:对 subdiv = 0..3 验证顶点数公式,装配整场景并断言对上基线 487 顶点 / 896 三角形,把几何序列化成小端 int32 缓冲取 sha256,最后采样 128 × 128 个点画出地形俯视图。

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_15_1_procedural_mesh.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_15_1_procedural_mesh.py
"""15.1 程序化网格生成:八面体细分球 + 整数噪声地形,全整数、无三角函数、无随机数。

几何全部由 config/scene_spec.json 的字段派生,不读任何外部模型文件;顶点数与三角形数
的解析公式被钉成断言,整场景几何序列化成小端 int32 缓冲并取 sha256 供 TypeScript 复现。
产物:out/cg_raster_15_1_mesh.bin、out/cg_raster_15_1_mesh.json、out/cg_raster_15_1_terrain.png。
"""

import hashlib
import json
import os
import struct
import sys

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def sphere_formula(d):
    """V = 4^(d+1) + 2,F = 8*4^d:每步 V_{d+1} = V_d + 12*4^d(第 d 步边数 = 3F_d/2)。"""
    return 4 ** (d + 1) + 2, 8 * (4 ** d)


def terrain_albedo(y, lo, hi, hs):
    """顶点色按高度在 albedoQ8(低处)与 albedoHighQ8(高处)间整数插值。"""
    t = (y << 16) // hs if hs else 0
    t = min(cglib.ONE, max(0, t))
    return tuple(lo[i] + (((hi[i] - lo[i]) * t) >> 16) for i in range(3))


def pack(verts, tris):
    """顶点(x,y,z Q16.16)与索引(a,b,c)打包成小端 int32 缓冲。"""
    buf = bytearray()
    for v in verts:
        buf += struct.pack("<iii", v[0], v[1], v[2])
    for a, b, c in tris:
        buf += struct.pack("<iii", a, b, c)
    return bytes(buf)


def main():
    spec = cglib.load_spec()
    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)

    rows = []
    for d in range(4):
        sv, st = cglib.build_sphere((0, 0, 0), cglib.ONE, d)
        ev, ef = sphere_formula(d)
        assert (len(sv), len(st)) == (ev, ef), f"subdiv={d} 实测与公式不符"
        rows.append((d, len(sv), len(st), ev, ef))

    terr = spec["terrain"]
    n = terr["gridN"]
    tv, tt = cglib.build_terrain(n, terr["extentQ16"], terr["heightScaleQ16"], terr["noiseSeed"])
    assert len(tv) == (n + 1) ** 2, "地形顶点数应为 (gridN+1)^2"
    assert len(tt) == 2 * n ** 2, "地形三角形数应为 2*gridN^2"

    verts, tris = list(tv), list(tt)
    alb = [terrain_albedo(y, terr["albedoQ8"], terr["albedoHighQ8"], terr["heightScaleQ16"])
           for _, y, _ in tv]
    for sp in spec["spheres"]:
        sv, st = cglib.build_sphere(sp["centerQ16"], sp["radiusQ16"], sp["subdiv"])
        b = len(verts)
        verts.extend(sv)
        tris.extend((a + b, c + b, e + b) for a, c, e in st)
    assert (len(verts), len(tris)) == (487, 896), "场景装配必须对上卷一基线"

    blob = pack(verts, tris)
    digest = hashlib.sha256(blob).hexdigest()
    with open(os.path.join(out, "cg_raster_15_1_mesh.bin"), "wb") as fh:
        fh.write(blob)

    m, ext = 128, terr["extentQ16"]
    img = np.array([[cglib.terrain_height(-ext + (2 * ext * i) // (m - 1),
                                          -ext + (2 * ext * j) // (m - 1),
                                          terr["noiseSeed"], terr["heightScaleQ16"])
                     for i in range(m)] for j in range(m)], dtype=np.int64)
    png = cglib.save_gray_png(os.path.join(out, "cg_raster_15_1_terrain.png"), img)

    ymin, ymax = min(v[1] for v in tv), max(v[1] for v in tv)
    ilo = min(range(len(tv)), key=lambda k: tv[k][1])
    ihi = max(range(len(tv)), key=lambda k: tv[k][1])
    stats = {"gridN": n, "terrain_verts": len(tv), "terrain_tris": len(tt),
             "sphere_formula": [{"subdiv": d, "verts": v, "tris": f,
                                 "verts_formula": ev, "tris_formula": ef} for d, v, f, ev, ef in rows],
             "scene_verts": len(verts), "scene_tris": len(tris),
             "height_min": ymin, "height_max": ymax,
             "albedo_lo": list(alb[ilo]), "albedo_hi": list(alb[ihi]),
             "bytes": len(blob), "sha256": digest}
    with open(os.path.join(out, "cg_raster_15_1_mesh.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)

    print("八面体细分球(实测 vs 公式 4^(d+1)+2 / 8*4^d):")
    for d, v, f, ev, ef in rows:
        print(f"  subdiv={d}  verts={v:4d}(={ev:4d})  tris={f:4d}(={ef:4d})")
    print(f"地形 gridN={n}      : 格点 {n + 1}x{n + 1} -> verts={len(tv)}"
          f"(格子数 gridN^2={n ** 2});tris={len(tt)}=2*gridN^2")
    print(f"地形高度范围       : y in [{ymin}, {ymax}] Q16.16"
          f"({ymin / 65536:.3f} .. {ymax / 65536:.3f} 世界单位)")
    print(f"顶点色插值样例     : 最低处 albedo={alb[ilo]} -> 最高处 albedo={alb[ihi]}"
          f"(区间 {terr['albedoQ8']} .. {terr['albedoHighQ8']})")
    print(f"场景装配           : verts={len(verts)}  tris={len(tris)}"
          f"(= 地形 {len(tv)}/{len(tt)} + 3 球 x {rows[2][1]}/{rows[2][2]})")
    print(f"几何缓冲           : {len(blob)} bytes(顶点 487*12 + 索引 896*3*4)")
    print(f"sha256(geometry)   : {digest}")
    print(f"terrain png        : {png} bytes")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实测输出如下(脚本开头的顶点数公式对账 5 行已在上文给出,这里从地形那行接续):

地形 gridN=16      : 格点 17x17 -> verts=289(格子数 gridN^2=256);tris=512=2*gridN^2
地形高度范围       : y in [8856, 188874] Q16.16(0.135 .. 2.882 世界单位)
顶点色插值样例     : 最低处 albedo=(75, 85, 68) -> 最高处 albedo=(154, 149, 129)(区间 [72, 82, 66] .. [158, 152, 132])
场景装配           : verts=487  tris=896(= 地形 289/512 + 3 球 x 66/128)
几何缓冲           : 16596 bytes(顶点 487*12 + 索引 896*3*4)
sha256(geometry)   : f633176ae1b459a4b6428e8ac0f27b91eb1bdd11fd027f4d0d9a94e975f49cf8
terrain png        : 7503 bytes

487 = 289 + 3 × 66,896 = 512 + 3 × 128——整场景的顶点与三角形正好对上前几章的基线统计。16596 字节 = 487 × 12(顶点 3 个 int32) + 896 × 12(三个索引各一个 int32)。

跨语言断言:同一份几何,两种语言

几何要能当基准,前提是它在另一种语言里能逐字节复现。下面的 TypeScript 模块用 BigInt 重写同一套生成与序列化:buildSphere / buildTerrain 逐字对齐 Python 侧,packGeometry 按小端 int32 打包。测试用 node --test 读 S 侧写出的 out/cg_raster_15_1_mesh.bin,逐字节比对并核对 sha256。

// file: lantern/core/src/lantern/cg_raster_15_mesh.ts
// tier: P
// verify: node --test 'core/test/cg_raster_15_*.test.ts'
// 15.1 / 15.2 共用的几何工具:解析公式、场景装配、几何序列化、展开与去重。
// 与 Python 侧 sim/cg_raster_15_1_procedural_mesh.py 与 cg_raster_15_2_mesh_representation.py 对齐。
// 顶点层是 BigInt(Q16.16 矩阵链会超 2^53),序列化时再降到 int32 小端。

import { type Mesh, buildSphere, buildTerrain } from "./mesh.ts";
import type { SceneSpec } from "./pipeline.ts";
import type { Vec3 } from "./vec.ts";

/** 八面体细分球的解析顶点/面数:V = 4^(d+1) + 2,F = 8 * 4^d。 */
export function sphereFormula(subdiv: number): { verts: number; tris: number } {
  return { verts: 4 ** (subdiv + 1) + 2, tris: 8 * 4 ** subdiv };
}

/** 顶点色按高度在 albedoQ8(低处)与 albedoHighQ8(高处)间整数插值。 */
export function terrainAlbedo(
  y: bigint,
  lo: readonly number[],
  hi: readonly number[],
  heightScale: bigint,
): [number, number, number] {
  let t = heightScale === 0n ? 0n : (y << 16n) / heightScale;
  if (t > 65536n) t = 65536n;
  if (t < 0n) t = 0n;
  return [0, 1, 2].map(
    (i) => lo[i] + Number(((BigInt(hi[i] - lo[i]) * t) >> 16n)),
  ) as [number, number, number];
}

/** 场景几何(地形 + 若干球):顶点/索引顺序与 Python 侧 build_scene 逐字一致。 */
export function buildSceneGeometry(spec: SceneSpec): Mesh {
  const t = spec.terrain;
  const tv = buildTerrain(t.gridN, BigInt(t.extentQ16), BigInt(t.heightScaleQ16), BigInt(t.noiseSeed));
  const verts: Vec3[] = tv.verts.slice();
  const tris: [number, number, number][] = tv.tris.slice();
  for (const sp of spec.spheres) {
    const mesh = buildSphere(sp.centerQ16.map((n) => BigInt(n)) as Vec3, BigInt(sp.radiusQ16), sp.subdiv);
    const base = verts.length;
    verts.push(...mesh.verts);
    for (const [a, b, c] of mesh.tris) tris.push([a + base, b + base, c + base]);
  }
  return { verts, tris };
}

/** 顶点(x,y,z Q16.16)与三角形索引(a,b,c)打包成小端 int32 缓冲。 */
export function packGeometry(mesh: Mesh): Uint8Array {
  const buf = new Uint8Array(mesh.verts.length * 12 + mesh.tris.length * 12);
  const dv = new DataView(buf.buffer);
  let o = 0;
  for (const v of mesh.verts) {
    dv.setInt32(o, Number(v[0]), true);
    dv.setInt32(o + 4, Number(v[1]), true);
    dv.setInt32(o + 8, Number(v[2]), true);
    o += 12;
  }
  for (const t of mesh.tris) {
    dv.setInt32(o, t[0], true);
    dv.setInt32(o + 4, t[1], true);
    dv.setInt32(o + 8, t[2], true);
    o += 12;
  }
  return buf;
}

/** 索引表示 -> 独立三角形表示:每个角点复制一份顶点,索引退化为 0,1,2,3,4,5,...。 */
export function expandToNonIndexed(mesh: Mesh): Mesh {
  const verts: Vec3[] = [];
  const tris: [number, number, number][] = [];
  for (const [a, b, c] of mesh.tris) {
    const n = verts.length;
    verts.push(mesh.verts[a], mesh.verts[b], mesh.verts[c]);
    tris.push([n, n + 1, n + 2]);
  }
  return { verts, tris };
}

/** 按 (x,y,z) 定点三元组去重:返回新顶点表与重映射后的索引(三角形顺序不变)。 */
export function dedupMesh(mesh: Mesh): { mesh: Mesh; merged: number } {
  const verts: Vec3[] = [];
  const seen = new Map<string, number>();
  const remap: number[] = [];
  for (const v of mesh.verts) {
    const key = `${v[0]},${v[1]},${v[2]}`;
    let idx = seen.get(key);
    if (idx === undefined) {
      idx = verts.length;
      verts.push(v);
      seen.set(key, idx);
    }
    remap.push(idx);
  }
  const tris = mesh.tris.map(([a, b, c]) => [remap[a], remap[b], remap[c]] as [number, number, number]);
  return { mesh: { verts, tris }, merged: mesh.verts.length - verts.length };
}

/** 三角形集合(顶点坐标三元组,按坐标排序)——去重前后必须完全相同。 */
export function triangleCoordSet(mesh: Mesh): Set<string> {
  const out = new Set<string>();
  for (const [a, b, c] of mesh.tris) {
    out.add([mesh.verts[a], mesh.verts[b], mesh.verts[c]]
      .map((v) => `${v[0]},${v[1]},${v[2]}`).sort().join("|"));
  }
  return out;
}

node --test 'core/test/cg_raster_15_1_procedural_mesh.test.ts' 实测 7 条断言全绿:

ℹ tests 7  ℹ pass 7  ℹ fail 0

其中「【核心】几何缓冲与 Python 侧逐字节相同」既比 sha256 也比 .bin 原始字节;两个负例把「细分次数改一级」与「三角形环绕反转」分别喂进去,digest 都必须分叉——判据不拒绝错误实现,PASS 就不可信。

与基线对账

本章的几何生成必须能解释前几章的基线统计 487 顶点 / 896 三角形,实测正好吻合,没有差额。需要说明一处口径:任务描述里的「地形顶点数 = gridN^2」指的是格子数(16 × 16 = 256 个格子),顶点数则是格点数 (gridN+1)^2 = 289——$N$ 个格子需要 $N+1$ 个格线交点。若误把顶点数写成 256,整场景会变成 454 顶点,与基线对不上。这个 off-by-one 在网格生成里很常见,所以脚本把它写成断言。

下一节换一个视角:同样的几何,用索引缓冲存和用独立三角形存,字节账差多少;以及怎么用整数去重把独立三角形表示压回索引表示。

阅读导航:上一节:14.3 中间缓冲的可视化 · 下一节:15.2 网格的表示与加载 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 14.3 中间缓冲的可视化
  2. 14.2 覆盖率与线框
  3. 14.1 深度图与法线图