10.1 噪声的构造

噪声在本章不是纹理而是几何的生成器,所以同一个 (ix, iz, seed) 必须给出同一个整数。本节用同一个 32 位混合哈希派生出值噪声与梯度噪声:128 点扫描线上均值 31041、标准差 14919,16384 个格点低 16 位的碰撞率 117.4‰,整数插值与 float64 参照的最大偏差只有 1 个 Q16.16。

10.1 噪声的构造

站内 /graphics-terrain-large-world/ 那篇讲的是大地形 LOD 工程:四叉树、clipmap、GPU 侧视锥剔除,关心的是每秒能画多少三角形。本章完全不碰那套——这里要的是噪声本身怎么被整数地构造出来,因为它在本章的角色不是纹理贴图,而是几何的生成器:地形顶点的高度、顶点的位移量、三角形的材质档,全部由它决定。

这件事有个硬约束:卷三的几何是逐字节可复现的(第 14 章的判据就是像素缓冲的 sha256)。既然几何要复现,生成几何的噪声就必须是纯整数函数——同一个 (ix, iz, seed) 无论跑在 Python 还是 TypeScript 里,都必须给出同一个 32 位状态。

为什么整数噪声在两种语言里能逐位相同

关键在 & 与 >> 对负数的语义。Python 的 int 是无限精度、按补码语义处理符号位,JS 的 BigInt 同样如此:

Python:  (-5) >> 1 == -3      JS:  (-5n) >> 1n == -3n
Python:  (-5) & 0xFFFFFFFF    JS:  (-5n) & 0xFFFFFFFFn == 4294967291n

而 JS 的 Number 不行:它的 >> 只有 32 位,h * 1274126177 一越过 2^53 就丢位,低 16 位会静默分叉。所以本节的噪声在 Python 侧用原生 int、在 P 层用 BigInt,两边逐位对齐;这条纪律会一直贯彻到第 13 章的求交容差。

一个哈希,两种噪声

两种噪声共用同一套骨架:格点定位 → smoothstep 归一化格内分数 → 双线性插值。差别只在格点上放什么:

  • 值噪声:格点放一个标量(取哈希的低 16 位),插值出来是一个在 [0, ONE) 上的平滑曲面。
  • 梯度噪声(Perlin):格点放一个梯度向量(取哈希的低 3 位查 8 方向表),与格内偏移点乘后再插值。

哈希本身是 hash32:三个大奇数乘子 XOR 之后,做两轮「右移异或 + 奇数乘」。乘法与异或右移都是 2^32 上的双射,所以对固定 (iz, seed),ix → 状态 是单射——这是「不会周期性重复」的结构性理由。

{
  "name": "lantern-noise-stat",
  "seed": 1337,
  "gridN": 128,
  "sampleStepQ16": 65536,
  "latticeShift": 18,
  "autocorrLags": [1, 2, 4, 8, 16],
  "injectiveProbe": 100000,
  "shiftWindow": 16,
  "shiftSearchMax": 4096,
  "latticeCells": 128,
  "valueMeanTolQ16": 1024,
  "deviationTolQ16": 256
}

latticeShift = 18 表示格点间距是 2^18 = 262144 Q16.16,即 4.0 个世界单位——这是 cglib.terrain_height 一直用的间距,本章沿用,不另立一套。autocorrLags 与 roughnessLags 是采样间距而不是世界距离,因为自相关与结构函数只关心「隔几个采样点」。

两种噪声的构造本体

# snippet: sim/cg_trace_10_noise.py(节选:格点哈希 + 两种噪声的插值核心)
# verify: /opt/homebrew/bin/python3 sim/cg_trace_10_noise.py
def hash32(ix, iz, seed):
    """32 位混合哈希:三个大奇数乘子 XOR 后做两轮「右移异或 + 奇数乘」。"""
    h = (ix * 73856093) ^ (iz * 19349663) ^ (seed * 83492791)
    h &= MASK32
    h ^= h >> 13
    h = (h * 1274126177) & MASK32
    h ^= h >> 16
    return h


def value_noise(fx, fz, seed, shift=18):       # 格点放标量(低 16 位)
    ix, iz, tx, tz = lattice(fx, fz, shift)
    sx, sz = smoothstep(tx), smoothstep(tz)
    n00, n10 = value_hash(ix, iz, seed), value_hash(ix + 1, iz, seed)
    n01, n11 = value_hash(ix, iz + 1, seed), value_hash(ix + 1, iz + 1, seed)
    a = n00 + fmul(sx, n10 - n00)
    b = n01 + fmul(sx, n11 - n01)
    return a + fmul(sz, b - a)


def perlin(fx, fz, seed, shift=18):            # 格点放 8 方向梯度(低 3 位)
    ix, iz, tx, tz = lattice(fx, fz, shift)
    sx, sz = smoothstep(tx), smoothstep(tz)

    def corner(ox, oz, dx, dz):
        g = GRAD8[gradient_index(ix + ox, iz + oz, seed)]
        return fmul(g[0], dx) + fmul(g[1], dz)

    n00, n10 = corner(0, 0, tx, tz), corner(1, 0, tx - ONE, tz)
    n01, n11 = corner(0, 1, tx, tz - ONE), corner(1, 1, tx - ONE, tz - ONE)
    a = n00 + fmul(sx, n10 - n00)
    b = n01 + fmul(sx, n11 - n01)
    return a + fmul(sz, b - a)

GRAD8 里的对角梯度用 DIAG = 46341 ≈ ONE/√2,全整数;smoothstep 是 3t² − 2t³,两端一阶导为零,所以格线处不会出现折角。这两个函数的完整定义在 sim/cg_trace_10_noise.py 里,10.2 与 10.3 直接 import 它们。

量出来:统计性质与周期性

光说「平滑、无周期」不算数,得量。下面的脚本沿一条 128 个采样点(步长 1.0 世界单位)的扫描线取两种噪声,再在格点上做重复率与周期性检验。

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_10_1_noise.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_10_1_noise.py
"""10.1《噪声的构造》:值噪声与梯度噪声的统计性质实测(构造本体在 cg_trace_10_noise.py)。

量四件事:
  1. 沿一条 128 个采样点的扫描线取两种噪声,算均值 / 标准差 / 极值 / 自相关(滞后 1..16)。
  2. 格点取值的重复率:16384 个格点上低 16 位的碰撞数与最大重数。
  3. 周期性:32 位状态在 10^5 个连续格点上是否单射;1..4096 的平移里有没有窗口整体重复。
  4. 整数化偏差:同一套格点值下,整数插值与 float64 参照的最大偏差与 MAE。

产物:out/cg_trace_10_1_noise.json、out/cg_trace_10_1_{value,perlin}.png
"""

import json
import math
import os
import sys
from collections import Counter

import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import cglib  # noqa: E402
import cgtrace  # noqa: E402
from cg_trace_10_noise import (ONE, autocorr, digest_i32, hash32, perlin, perlin_float,  # noqa: E402
                               stats, value_hash, value_noise, value_noise_float)

CONFIG = os.path.join(ROOT, "config", "cg_trace_10_1_noise.json")


def main():
    cfg = json.load(open(CONFIG, encoding="utf-8"))
    seed, n, step, shift = cfg["seed"], cfg["gridN"], cfg["sampleStepQ16"], cfg["latticeShift"]
    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)

    coords = [i * step for i in range(n)]
    val = [value_noise(x, 0, seed, shift) for x in coords]
    per = [perlin(x, 0, seed, shift) for x in coords]
    dev_v = [abs(val[i] - round(value_noise_float(coords[i], 0, seed, shift) * 65536.0))
             for i in range(n)]
    dev_p = [abs(per[i] - round(perlin_float(coords[i], 0, seed, shift) * 65536.0))
             for i in range(n)]
    sv, sp = stats(val), stats(per)
    ac_v = {str(L): autocorr(val, L) for L in cfg["autocorrLags"]}
    ac_p = {str(L): autocorr(per, L) for L in cfg["autocorrLags"]}

    m = cfg["latticeCells"]
    lat = [value_hash(ix, iz, seed) for ix in range(m) for iz in range(m)]
    cnt, cells = Counter(lat), m * m
    lst = stats(lat)
    seen, state_coll = set(), 0
    for i in range(cfg["injectiveProbe"]):
        h = hash32(i, 0, seed)
        if h in seen:
            state_coll += 1
        else:
            seen.add(h)
    w = cfg["shiftWindow"]
    base = [value_hash(i, 0, seed) for i in range(w)]
    matches = [P for P in range(1, cfg["shiftSearchMax"] + 1)
               if all(value_hash(P + i, 0, seed) == base[i] for i in range(w))]

    # ---- 5. 大样本对照:同一条扫描线拉到 12.8 万点(跨 3.2 万个格点) ----
    nbig = 128000
    sbig_v = stats([value_noise(i * step, 0, seed, shift) for i in range(nbig)])
    sbig_p = stats([perlin(i * step, 0, seed, shift) for i in range(nbig)])

    cglib.save_gray_png(os.path.join(out, "cg_trace_10_1_value.png"), np.array(val, dtype=np.int64))
    cglib.save_gray_png(os.path.join(out, "cg_trace_10_1_perlin.png"), np.array(per, dtype=np.int64))
    report = {
        "seed": seed, "gridN": n, "latticeShift": shift, "latticeSpacingQ16": 1 << shift,
        "value": {**sv, "autocorrPermille": ac_v, "digest": digest_i32(val)},
        "perlin": {**sp, "autocorrPermille": ac_p, "digest": digest_i32(per)},
        "integerVsFloat": {"valueMaxDevQ16": max(dev_v), "valueMaeQ16": round(sum(dev_v) / n, 4),
                           "perlinMaxDevQ16": max(dev_p), "perlinMaeQ16": round(sum(dev_p) / n, 4)},
        "largeSample": {"points": nbig, "value": sbig_v, "perlin": sbig_p},
        "repeatRate": {"latticeCells": cells, "distinctValues": len(cnt),
                       "collisions": cells - len(cnt),
                       "collisionPermille": round((cells - len(cnt)) * 1000.0 / cells, 4),
                       "maxMultiplicity": max(cnt.values()), "latticeMeanQ16": lst["meanQ16"],
                       "latticeStdQ16": lst["stdQ16"], "uniformTheoryMeanQ16": ONE // 2,
                       "uniformTheoryStdQ16": math.isqrt(ONE * ONE // 12)},
        "periodicity": {"probeStates": cfg["injectiveProbe"], "stateCollisions": state_coll,
                        "shiftWindow": w, "shiftSearchMax": cfg["shiftSearchMax"],
                        "windowMatches": len(matches), "statePeriodCells": 1 << 32,
                        "statePeriodWorldUnits": (1 << 32) * 4},
    }
    with open(os.path.join(out, "cg_trace_10_1_noise.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    print("== 10.1 噪声的构造 ==")
    print(f"格点间距          : {1 << shift} Q16.16({(1 << shift) / 65536.0} 世界单位)")
    print(f"值噪声            : mean {sv['meanQ16']} std {sv['stdQ16']} "
          f"range [{sv['minQ16']}, {sv['maxQ16']}] 幅 {sv['rangeQ16']}")
    print(f"梯度噪声          : mean {sp['meanQ16']} std {sp['stdQ16']} "
          f"range [{sp['minQ16']}, {sp['maxQ16']}] 幅 {sp['rangeQ16']}")
    print("自相关(值) ‰      : " + " ".join(f"L{k}={v}" for k, v in ac_v.items()))
    print("自相关(梯度) ‰    : " + " ".join(f"L{k}={v}" for k, v in ac_p.items()))
    print(f"整数 vs float64   : 值 max {max(dev_v)} MAE {report['integerVsFloat']['valueMaeQ16']} / "
          f"梯度 max {max(dev_p)} MAE {report['integerVsFloat']['perlinMaeQ16']} Q16.16")
    print(f"格点分布          : mean {lst['meanQ16']} std {lst['stdQ16']} "
          f"(均匀分布理论 {ONE // 2} / {math.isqrt(ONE * ONE // 12)})")
    print(f"大样本 128k 点    : 值 mean {sbig_v['meanQ16']} std {sbig_v['stdQ16']} "
          f"range [{sbig_v['minQ16']}, {sbig_v['maxQ16']}] / "
          f"梯度 mean {sbig_p['meanQ16']} std {sbig_p['stdQ16']} "
          f"range [{sbig_p['minQ16']}, {sbig_p['maxQ16']}]")
    print(f"重复率            : {cells} 格点 / 不同取值 {len(cnt)} / 碰撞 {cells - len(cnt)} "
          f"({report['repeatRate']['collisionPermille']}‰,最大重数 {max(cnt.values())})")
    print(f"状态单射          : {cfg['injectiveProbe']} 个连续格点 / 碰撞 {state_coll}")
    print(f"窗口平移重复      : 1..{cfg['shiftSearchMax']} 内命中 {len(matches)} 个平移")
    print(f"digest 值/梯度    : {report['value']['digest'][:16]}… / {report['perlin']['digest'][:16]}…")
    return 0


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

stats() 里有个容易踩的坑:方差是 Q32.32(两个 Q16.16 相乘),所以标准差必须用 math.isqrt 而不是 cglib.fsqrt——fsqrt 期待的是 Q16.16 输入。第一版就是在这里把标准差算成了 3819278,比均值还大两个数量级。

# snippet: 本机实跑(工作区 lantern/)
/opt/homebrew/bin/python3 sim/cg_trace_10_1_noise.py
== 10.1 噪声的构造 ==
格点间距          : 262144 Q16.16(4.0 世界单位)
值噪声            : mean 31041 std 14919 range [3767, 62799] 幅 59032
梯度噪声          : mean 208 std 11501 range [-27970, 27969] 幅 55939
自相关(值) ‰      : L1=889 L2=614 L4=29 L8=-324 L16=-112
自相关(梯度) ‰    : L1=684 L2=44 L4=-355 L8=-298 L16=286
整数 vs float64   : 值 max 1 MAE 0.3125 / 梯度 max 1 MAE 0.375 Q16.16
格点分布          : mean 32605 std 18910 (均匀分布理论 32768 / 18918)
大样本 128k 点    : 值 mean 32990 std 16316 range [3, 65529] / 梯度 mean -1 std 11285 range [-32768, 32768]
重复率            : 16384 格点 / 不同取值 14460 / 碰撞 1924 (117.4316‰,最大重数 4)
状态单射          : 100000 个连续格点 / 碰撞 0
窗口平移重复      : 1..4096 内命中 0 个平移
digest 值/梯度    : f8698c4398f443cc… / e7759f6a9f5448ca…

这些数字怎么读

值噪声的均值 31041、标准差 14919 都低于格点本身的 32768 / 18918。这里混着两个效应,所以脚本里专门加了一组大样本对照(同一条扫描线拉到 12.8 万点、跨 3.2 万个格点):值噪声回到 mean 32990 std 16316 range [3, 65529]。均值回到了 32768 附近,说明前一组数字偏低主要是样本太少(128 点只跨 32 个格点);但标准差只升到 16316、仍低于 18918,说明插值本身会把相邻格点的值平均掉、压低方差——这个效应是真实的,不会随样本增大而消失。

所以「均匀性」不该用这条扫描线判断,该看格点分布那一行:16384 个格点上均值 32605(理论 32768)、标准差 18910(理论 18918)——两个都贴在均匀分布上,说明 % 65536 没有引入系统性偏置。

梯度噪声的均值 208、极值 ±27970 则是设计使然:Perlin 的值域关于 0 对称,均值 208 只是 128 个样本的波动(大样本下回到 −1),±27970 对应 ±0.4268。它的自相关比值噪声衰减得快:梯度噪声 L1 = 684‰、L2 = 44‰,而值噪声 L1 = 889‰、L2 = 614‰。原因是梯度噪声在格点处恒等于 0,相邻格点之间的相关性天然更弱。

重复率 117.4316‰ 需要正确理解:这是把 16384 个格点的低 16 位(65536 个可能取值)拿来数碰撞。这是标准的「n 个球投进 m 个桶」问题,期望不同取值数是 m(1−(1−1/m)^n),代入 n = 16384、m = 65536 得 14496.6,即期望碰撞率 115.20‰。实测不同取值 14460、碰撞 1924(117.4316‰),与理论值相差 2.2‰,最大重数 4——完全落在正常范围。这里碰撞不影响噪声质量——它只说明「两个相距很远的格点可能拿到同一个标量」,而相邻格点几乎不会撞(否则会出现可见的重复纹理)。

周期性检验全过:hash32 对 ix 是双射,所以 10^5 个连续格点上状态碰撞为 0;把 16 个连续格点的取值当作一个窗口,在 1..4096 的平移里搜,命中 0 个——没有任何平移能让窗口整体重复。理论上状态的周期是 2^32 个格点,即 2^32 × 4 ≈ 1.72×10^10 个世界单位,远超卷三那幅 extent = 1441792(约 22 个世界单位)的场景。

整数化偏差只有 1 个 Q16.16:把同一套格点哈希值交给 float64 重算一遍,最大偏差 1、MAE 0.3125 / 0.375。也就是说整数化带来的误差是最后一个二进制位量级,完全不会在地形高度(尺度 196608)上显现。

P 层把序列钉死

S 层的产物是 JSON,P 层要断言的是噪声取值序列的 sha256——不是 PNG 字节,因为 PNG 容器里有时间戳。128 个 Q16.16 值按 32 位小端编码后取 sha256,两侧必须逐字节相同。

# snippet: core/test/cg_trace_10_1_noise.test.ts(节选:正例与两个负例)
# verify: node --test 'core/test/cg_trace_10_1_noise.test.ts'
test("值噪声与梯度噪声的取值序列与 S 层逐字节相同", () => {
  assert.equal(digest(val), golden.value.digest, "值噪声序列必须逐字节相同");
  assert.equal(digest(per), golden.perlin.digest, "梯度噪声序列必须逐字节相同");
});

test("负例:Number 的 32 位右移算不出同一个哈希", () => {
  // 用 Number 做同一套混合:`h * 1274126177` 立刻越过 2^53,低 16 位分叉
  assert.notEqual(digest(bad), golden.value.digest, "错的位宽必须让逐字节判据分叉");
});

两个负例各钉住一个错误:把 Number 的 >>> 混进来,摘要立刻分叉(证明「必须 BigInt」不是口头纪律);把梯度噪声的 seed 换掉,序列也必须变(证明摘要真的在跟着输入动,而不是恒等返回)。

小结

噪声在卷三的定位是生成器而不是纹理:它必须是纯整数函数,才能让几何逐字节复现。本节用同一个 32 位混合哈希派生两种噪声,值噪声格点放标量、梯度噪声格点放 8 方向梯度,共用同一套格点定位与 smoothstep 插值。量出来的结论是:格点取值贴在均匀分布上(32605 / 18910 对理论 32768 / 18918)、低 16 位的重复率 117.4‰ 且相邻格点几乎不撞、10^5 个连续格点状态零碰撞、4096 个平移里零窗口重复、整数插值相对 float64 参照的最大偏差只有 1 个 Q16.16。有了一个可信的噪声源,下一节把它叠成地形。

阅读导航:上一节:9.3 体积阴影与散射 · 下一节:10.2 分形与地形 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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