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 卷积与模糊 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。