9.3 程序化纹理的整数化

噪声的终点是可用的纹理。本节把 fBm 与距离场组合成云、木纹、石纹三张 64×64 贴图,全部走整数:距离用 fsqrt、环带与脉线用整数取模、颜色是 8 位通道的整数混合。判据是像素缓冲的 sha256——Python 与 TypeScript 合并缓冲同为 4ef471f3…,共 36864 字节逐字节相同。

9.3 程序化纹理的整数化

站内 /graphics-terrain-large-world/ 那篇讨论的是大地形纹理怎么分块、怎么按 LOD 换分辨率;本节退一步,问这些纹理本身怎么造出来。9.1 与 9.2 造出了噪声,噪声不是纹理——它是「一团会流动的数」。把它变成云、木纹、石纹,要做的是把噪声接到几何量上:距离、环带、脉线、颜色梯度。

这一节要证明的东西比前两节更硬:同一份整数代码在 Python 与 TypeScript 下必须产出逐字节相同的纹理缓冲。判据是像素缓冲的 sha256,不是 PNG 容器字节(PIL 与任何编码器的压缩参数都不同,PNG 字节本来就不可能相等)。

三张贴图,一个整数噪声库

三张贴图共用 cg_shade_09_noise,全部走定点。坐标是 Q16.16,没有一次浮点、没有一次 random:

{
  "name": "lantern-procedural-texture",
  "size": 64,
  "scaleQ16": 32768,
  "latticeShift": 18,
  "seed": 24285,
  "gainQ16": 32768,
  "textures": ["cloud", "wood", "stone"]
}

三张贴图的区别只在「噪声接到哪个几何量上」:

  • 云:5 层 fBm 直接当灰度 g,再映射到冷色调 (30+g·200/255, 40+g·190/255, 70+g·180/255)。没有距离、没有取模,纯噪声。
  • 木纹:d = fsqrt(fx²+fz²) 是到中心的半径,加上 fBm 湍流(幅度 2.0 世界单位)得到 ring,再对环带周期 3.0 取模、折成三角波。距离给了「环」,湍流给了「木纹的不规则」。
  • 石纹:两个 fBm 之和取小数部分当「脉线场」,到脉线中心越近越暗。这是一个大理石式的等高线图案。

整数化的三处关键

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_09_3_texture.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_09_3_texture.py
"""9.3 程序化纹理的整数化:把噪声变成可用的云、木纹、石纹,并证明跨语言逐字节一致。

三张贴图共用同一个整数噪声库(`cg_shade_09_noise`),全部走定点:坐标 Q16.16、
距离用 `fsqrt`、环带/脉线用整数取模、颜色是 8 位通道的整数混合。**没有一次浮点**,
也没有 `random`——同一种子在 Python 与 TypeScript 下必须产出**逐字节相同**的缓冲。

  * 云   :5 层 fBm -> 灰阶 -> 冷色调渐变;
  * 木纹 :到中心的距离 + fBm 湍流 -> 环带三角波 -> 木色明暗;
  * 石纹 :两个 fBm 之和取小数部分 -> 距脉线中心的距离 -> 压暗成石脉。

判据对象是**像素缓冲**(uint8 三元组流)的 sha256,不是 PNG 容器字节。

产物:out/cg_shade_09_3_{cloud,wood,stone}.png、out/cg_shade_09_3_texture.json。
"""

import hashlib
import json
import os
import sys
import time

import numpy as np

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

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SPEC = os.path.join(ROOT, "config", "cg_shade_09_3_texture.json")

SIZE = 64
SCALE = cglib.ONE // 2
SHIFT = 18
GAIN = cglib.ONE // 2
SEED = 24285


def tex_cloud(fx, fz):
    """云:5 层 fBm 当灰度,映射到冷色调的云灰。"""
    h = nz.fbm(fx, fz, SEED + 1, 5, SHIFT, GAIN)          # [0, ONE)
    g = (h * 255) >> 16                                    # 0..255
    return (30 + ((g * 200) >> 8), 40 + ((g * 190) >> 8), 70 + ((g * 180) >> 8))


def tex_wood(fx, fz):
    """木纹:到中心的距离 + fBm 湍流 -> 环带三角波 -> 木色明暗。"""
    d = cglib.fsqrt(fx * fx + fz * fz)                     # Q16.16 半径
    turb = nz.fbm(fx, fz, SEED + 2, 4, SHIFT, GAIN)        # [0, ONE)
    ring = d + cglib.fmul(turb, 2 * cglib.ONE)             # 湍流幅度 2.0 世界单位,非负
    period = 3 * cglib.ONE
    band = ring - (ring // period) * period                # [0, period)
    t = (band << 8) // period                              # 0..255
    u = t * 2 if t < 128 else (255 - t) * 2                # 三角波 0..255
    return (90 + ((100 * u) // 255), 60 + ((80 * u) // 255), 35 + ((50 * u) // 255))


def tex_stone(fx, fz):
    """石纹:两个 fBm 之和取小数部分,到脉线中心越近越暗。"""
    v = nz.fbm(fx, fz, SEED + 3, 4, SHIFT - 1, GAIN)       # 更细的底噪
    veil = nz.fbm(fx, fz, SEED + 4, 3, SHIFT, GAIN)
    s = v + veil                                           # [0, 2*ONE)
    band = s - (s // cglib.ONE) * cglib.ONE                # [0, ONE)
    dv = band if band < (cglib.ONE >> 1) else cglib.ONE - band
    k = (dv << 10) // cglib.ONE                            # 0..512 -> 钳到 255(窄脉线)
    k = 255 if k > 255 else k
    gbase = (nz.fbm(fx, fz, SEED + 5, 3, SHIFT, GAIN) * 120) >> 16
    g2 = ((115 + gbase) * k) // 255
    return (g2, g2, (g2 * 245) // 255)


BUILDERS = {"cloud": tex_cloud, "wood": tex_wood, "stone": tex_stone}


def build(name):
    fn = BUILDERS[name]
    tex = np.zeros((SIZE, SIZE, 3), dtype=np.int64)
    for y in range(SIZE):
        fz = (y - SIZE // 2) * SCALE
        for x in range(SIZE):
            tex[y, x] = fn((x - SIZE // 2) * SCALE, fz)
    return tex


def main():
    spec = json.load(open(SPEC, encoding="utf-8"))
    names = spec["textures"]
    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)

    per, combined = {}, bytearray()
    t_all = time.perf_counter()
    for name in names:
        t0 = time.perf_counter()
        tex = build(name)
        ms = (time.perf_counter() - t0) * 1000.0
        b = tex.astype(np.uint8).tobytes()
        combined.extend(b)
        cglib.save_png(os.path.join(out, f"cg_shade_09_3_{name}.png"), tex)
        arr = tex.astype(np.int64)
        per[name] = {"sha256": hashlib.sha256(b).hexdigest(), "bytes": len(b),
                     "ms": round(ms, 3), "mean": float(arr.mean()),
                     "std": float(arr.std()), "min": int(arr.min()), "max": int(arr.max()),
                     "golden": [int(tex[0, 0, 0]), int(tex[0, 0, 1]), int(tex[0, 0, 2]),
                                int(tex[SIZE // 2, SIZE // 2, 0]), int(tex[SIZE // 2, SIZE // 2, 1]),
                                int(tex[SIZE // 2, SIZE // 2, 2]),
                                int(tex[SIZE - 1, SIZE - 1, 0]), int(tex[SIZE - 1, SIZE - 1, 1]),
                                int(tex[SIZE - 1, SIZE - 1, 2])]}
    total_ms = (time.perf_counter() - t_all) * 1000.0
    comb_sha = hashlib.sha256(bytes(combined)).hexdigest()

    stats = {"size": SIZE, "scaleQ16": SCALE, "latticeShift": SHIFT, "seed": SEED,
             "gainQ16": GAIN, "textures": names, "perTexture": per,
             "combinedBytes": len(combined), "combinedSha256": comb_sha,
             "totalMs": round(total_ms, 3)}
    with open(os.path.join(out, "cg_shade_09_3_texture.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)

    print(f"贴图 {SIZE}x{SIZE} x {len(names)}  坐标步长 {SCALE / cglib.ONE:.3f} 世界单位  "
          f"种子 {SEED}")
    for name in names:
        p = per[name]
        print(f"  {name:6s} mean {p['mean']:6.1f}  std {p['std']:5.1f}  [{p['min']:3d}, {p['max']:3d}]  "
              f"sha256 {p['sha256'][:16]}…  {p['ms']:6.1f} ms")
    print(f"合并缓冲 {len(combined)} 字节  sha256 {comb_sha}")
    print(f"总计 {total_ms:.1f} ms")
    print("png/json: out/cg_shade_09_3_*.{png,json}")
    return 0


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

距离用 fsqrt,不用 math.sqrt。 cglib.fsqrt(x) 是 math.isqrt(x << 16),TS 侧 fsqrt 是同一套 BigInt 牛顿迭代。两者对任意 x 给出逐位相同的整数结果,而 math.sqrt 返回浮点、再取整会在边界上差 1。

环带用整数取模,且分子保证非负。 band = ring - (ring // period) * period 是显式写出的取模——ring ≥ 0(半径非负、湍流幅度非负),所以 Python 的 //(floor)与 JS BigInt 的 /(向零截断)在这里一致。这正是 BRIEF 铁律允许用除法的那类场合:分子已知非负。

颜色是 8 位通道的整数混合。 (100*u)//255、(g2*245)//255 全是非负分子除 255。整个管线里没有任何一步产生负数,所以「Python 与 JS 的除法语义不同」这个坑根本不会触发——这是刻意的设计选择,不是巧合。

实测:摘要与耗时

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_shade_09_3_texture.py
贴图 64x64 x 3  坐标步长 0.500 世界单位  种子 24285
  cloud  mean  137.4  std  27.2  [ 50, 236]  sha256 c20580d7669b81f9…    59.8 ms
  wood   mean   98.9  std  39.7  [ 35, 189]  sha256 941282c22d2f9b36…    44.8 ms
  stone  mean   99.2  std  60.0  [  0, 222]  sha256 a8dd30eab5cf4f9a…   104.3 ms
合并缓冲 36864 字节  sha256 4ef471f38d75159727b142b536707c092d76b32f93022324935425a29ae8505a
总计 259.6 ms

Python 侧三张贴图合计 259.6 ms。TS 侧用 node core/tools/cg_shade_09_3_tex_ts.ts 跑同一套 BigInt 内核(20 次平均、含预热):

cloud    6.77 ms  c20580d7669b81f93c822b27608cf09c59d81de137ea33781d16f549bfbc3267
wood     8.03 ms  941282c22d2f9b36b7ad2f476934a15b71012f22026e8aaca98b0b00a0ca36d9
stone   13.70 ms  a8dd30eab5cf4f9a9e12f1f8b7655701e50ddbdfd7581f698a49696ebaf272eb
合并   36864 字节  4ef471f38d75159727b142b536707c092d76b32f93022324935425a29ae8505a
三张合计 27.90 ms(20 次平均,逐张和 28.50 ms)

TS 快约 9.3 倍(27.90 vs 259.6 ms)。差距来自两件事:Python 侧逐像素调用 Python 函数(每次都要做字典查找与 BigInt 式的对象分配),TS 侧是编译后的 BigInt 原生运算;另外 TS 侧本次用的是 node 直接跑 .ts(类型剥离),没有额外的解释开销。

摘要才是判据。 三张贴图的逐张 sha256 与合并缓冲的 sha256 在两侧完全相同:

cloud  c20580d7669b81f93c822b27608cf09c59d81de137ea33781d16f549bfbc3267
wood   941282c22d2f9b36b7ad2f476934a15b71012f22026e8aaca98b0b00a0ca36d9
stone  a8dd30eab5cf4f9a9e12f1f8b7655701e50ddbdfd7581f698a49696ebaf272eb
合并   4ef471f38d75159727b142b536707c092d76b32f93022324935425a29ae8505a  (36864 字节)

36864 = 64×64×3×3 字节,逐字节相同。这不是「看起来一样」——任何一处舍入顺序、任何一位移位方向不同,sha256 立刻变。三个黄金像素(左上角、中心、右下角)也一并钉进断言,便于定位。

跨语言核对

// file: lantern/core/test/cg_shade_09_3_texture.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_09_3_texture.test.ts'
// 9.3 的跨语言断言:TypeScript 必须产出与 Python 侧 sim/cg_shade_09_3_texture.py
// **逐字节相同**的像素缓冲(sha256 相等),并用负例证明 digest 有区分力。

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";

import { ONE } from "../src/lantern/fixed.ts";
import { buildAll, buildTexture, texCloud, texWood } from "../src/lantern/cg_shade_09_texture.ts";

const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(
  readFileSync(join(root, "out", "cg_shade_09_3_texture.json"), "utf8"),
) as {
  size: number;
  textures: string[];
  combinedBytes: number;
  combinedSha256: string;
  perTexture: Record<string, { sha256: string; bytes: number; golden: number[] }>;
};

const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");

test("【核心】合并缓冲与 Python 侧逐字节相同(sha256)", () => {
  const buf = buildAll(cfg.textures, cfg.size);
  assert.equal(buf.length, cfg.combinedBytes);
  assert.equal(sha(buf), cfg.combinedSha256);
});

test("逐张贴图的 sha256 与黄金像素一致", () => {
  for (const name of cfg.textures) {
    const buf = buildTexture(name, cfg.size);
    const p = cfg.perTexture[name];
    assert.equal(buf.length, p.bytes, `${name} 字节数`);
    assert.equal(sha(buf), p.sha256, `${name} sha256`);
    // 三个黄金像素:左上角、中心、右下角
    assert.deepEqual([...buf.subarray(0, 3)], p.golden.slice(0, 3), `${name} 左上角`);
    const c = (cfg.size >> 1) * cfg.size * 3 + (cfg.size >> 1) * 3;
    assert.deepEqual([...buf.subarray(c, c + 3)], p.golden.slice(3, 6), `${name} 中心`);
    const e = buf.length - 3;
    assert.deepEqual([...buf.subarray(e)], p.golden.slice(6, 9), `${name} 右下角`);
  }
});

test("负例:坐标步长差一倍 -> digest 必须不同", () => {
  const good = buildTexture("wood", cfg.size);
  // 故意写错:坐标步长用 ONE 而不是 ONE/2
  const size = cfg.size;
  const bad = new Uint8Array(size * size * 3);
  let o = 0;
  for (let y = 0; y < size; y += 1) {
    const fz = (BigInt(y) - BigInt(size >> 1)) * ONE;
    for (let x = 0; x < size; x += 1) {
      const c = texWood((BigInt(x) - BigInt(size >> 1)) * ONE, fz);
      bad[o] = Number(c[0]);
      bad[o + 1] = Number(c[1]);
      bad[o + 2] = Number(c[2]);
      o += 3;
    }
  }
  assert.notEqual(sha(bad), sha(good), "改错步长后 digest 未变化,判据没有区分力");
});

test("云与木纹的通道都落在 [0,255] 且不是常量图", () => {
  for (const name of ["cloud", "wood"]) {
    const buf = buildTexture(name, cfg.size);
    let lo = 255;
    let hi = 0;
    for (const v of buf) {
      if (v < lo) lo = v;
      if (v > hi) hi = v;
    }
    assert.ok(lo >= 0 && hi <= 255, `${name} 通道越界 [${lo},${hi}]`);
    assert.ok(hi - lo > 60, `${name} 动态范围过小 ${hi - lo},可能是常量图`);
  }
  // texCloud 的通道关系:蓝 >= 绿 >= 红(冷色调)
  const c = texCloud(0n, 0n);
  assert.ok(c[2] >= c[1] && c[1] >= c[0], "云不是冷色调");
});

第三条是负例:把坐标步长从 ONE/2 改成 ONE(一个很容易犯的错),digest 必须改变——实测确实变了。判据不被负例拒绝,则 PASS 不可信:如果随便改一个参数 digest 都不变,那说明这段代码根本没读到那个参数。

第四条检查动态范围:三张图的通道都落在 [0,255] 且极差大于 60(不是常量图),并断言云的三个通道满足 蓝 ≥ 绿 ≥ 红——冷色调是云这张图的定义特征,把它写成断言,任何把颜色映射写反的改动都会被拒。

一个必须写明的边界:这里的一致性是「像素缓冲逐字节相同」,不是「渲染结果逐字节相同」。纹理是纯 CPU 的确定性整数运算,两侧可以逐位对齐;而一旦这张纹理被送进 GPU 采样(双线性过滤、mip 层级、浮点插值),就只能按声明容差比对——那是第 12 章与第 15 章的主题。本机 headless 的 WebGL2 后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU,任何跨宿主比对都不得归因于真实硬件。

阅读导航:上一节:9.2 分形噪声与 fBm · 下一节:10.1 卷积与模糊 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配