9.1 值噪声与 Perlin
站内 /graphics-terrain-large-world/ 那篇讲的是大地形工程:分块、LOD、流式加载、显存预算怎么排。它把「高度从哪来」当既有的输入,本卷追问的正是这个输入——噪声函数本身。这一节把值噪声与 Perlin 拆成可逐值断言的整数运算:同一种子、同一份算法,Python 与 TypeScript 必须给出同一个 Q16.16 数值,而不是两张「看起来差不多」的图。
两种噪声的骨架一模一样:整数格点 + 整数插值。差别只有一处——格点上放的是标量值,还是梯度向量。
同一个整数哈希,两种格点内容
噪声的第一步是「给每个整数格点一个确定的值」。卷一底座的 cglib._hash_noise 已经是一个可用的整数哈希:三个乘子按位异或,再走一轮 xorshift-multiply 混合。本节把它整个 32 位都取出来用:
# snippet: 节选自 lantern/sim/cg_shade_09_noise.py 的哈希层
def mix32(ix, iz, seed):
h = ((ix * 73856093) ^ (iz * 19349663) ^ (seed * 83492791)) & 0xFFFFFFFF
h ^= h >> 13
h = (h * 1274126177) & 0xFFFFFFFF
h ^= h >> 16
return h
def value_hash(ix, iz, seed): # 低 16 位:格点标量值
return mix32(ix, iz, seed) % ONE
def gradient_hash(ix, iz, seed): # 低 3 位:梯度索引
return mix32(ix, iz, seed) & 7
value_hash 取低 16 位,与 cglib._hash_noise 对任意 (ix, iz, seed) 逐值相同(脚本开头就断言这一点,不成立直接 raise SystemExit);gradient_hash 取低 3 位,从同一个混合值里挑 8 个方向之一。于是「值噪声」与「Perlin」共用同一个哈希,唯一区别是格点上放的是标量还是向量。
为什么必须同源而不是随便换一个哈希:只要两侧的混合函数有任何一位不同,逐位一致立刻失守。BRIEF 的禁用清单写着「禁 Math.random() / Python random」,这里正是它的用武之地——噪声的可复现性完全押在这个确定性整数哈希上。
{
"name": "lantern-noise-sweep",
"grid": 128,
"stepQ16": 65536,
"latticeShift": 18,
"seed": 24285,
"spectrumGrid": 256,
"spectrumStepQ16": 16384,
"spectrumBins": 32,
"diffBins": 16,
"probes": [[0, 0], [1, 0], [2, 3], [5, 7], [11, 13], [0, 1], [31, 17], [64, 64],
[100, 50], [127, 127], [3, 64], [64, 3], [9, 9], [40, 80], [80, 40], [127, 0]]
}
值噪声与 Perlin 的整数实现
值噪声:四个角各取一个标量值,用 smoothstep(tx)、smoothstep(tz) 做双线性插值。Perlin:四个角各取一个梯度向量 (gx, gz),先与「角点到采样点的偏移」做整数点积,再同样用 smoothstep 插值。整条链上只有 fmul(乘完算术右移 16 位)与加法,没有一次浮点、没有一次除法。
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_09_noise.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_09_1_value_perlin.py
"""9 章噪声库:整数哈希 -> 格点值 / 梯度 -> 整数插值 -> 值噪声 / Perlin -> fBm。
与卷一底座的关系:**格点值哈希与 `cglib._hash_noise` 完全同源**——同一组乘子
`73856093 / 19349663 / 83492791`、同一轮 `xorshift-multiply` 混合(`h ^= h >> 13`
然后 `h * 1274126177` 再 `h ^= h >> 16`)。差别只在**取哪几位**:
* 格点值 `value_hash` 取低 16 位(`mix32 % ONE`),与 `cglib._hash_noise` 逐值相同;
* 梯度索引 `gradient_hash` 取低 3 位(`mix32 & 7`),挑 8 个方向梯度之一。
于是「值噪声」与「Perlin」共用同一个哈希,唯一区别是格点上放的是**标量值**还是
**梯度向量**。全程整数:`fmul` 走算术右移,插值权重是 Q16.16,无浮点、无 `random`。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
ONE = cglib.ONE
MASK32 = 0xFFFFFFFF
# 8 个方向梯度(Q16.16):四轴向 + 四对角;S = sqrt(2)/2 * 65536 的整数近似
S = 46341
GRADS = ((ONE, 0), (0, ONE), (-ONE, 0), (0, -ONE),
(S, S), (-S, S), (S, -S), (-S, -S))
def mix32(ix, iz, seed):
"""与 cglib._hash_noise 同一混合函数,返回完整 32 位(未取模)。"""
h = ((ix * 73856093) ^ (iz * 19349663) ^ (seed * 83492791)) & MASK32
h ^= h >> 13
h = (h * 1274126177) & MASK32
h ^= h >> 16
return h
def value_hash(ix, iz, seed):
"""格点值哈希:返回 [0, ONE) 的 Q16.16 值(与 cglib._hash_noise 逐值相同)。"""
return mix32(ix, iz, seed) % ONE
def gradient_hash(ix, iz, seed):
"""梯度索引:返回 [0, 8),从同一 32 位混合值取低 3 位。"""
return mix32(ix, iz, seed) & 7
def smoothstep(t):
"""Q16.16 的 3t^2 - 2t^3,t in [0, ONE]。"""
t2 = cglib.fmul(t, t)
return cglib.fmul(t2, 3 * ONE - 2 * t)
def lattice(fx, fz, shift):
"""把世界坐标拆成格点索引 (ix, iz) 与格内分数 (tx, tz)(都是整数)。
格点间距 = 2^shift(Q16.16)。分数归一化到 Q16.16:`shift <= 16` 时左移补齐,
否则算术右移(floor,两侧一致)。
"""
ix = fx >> shift
iz = fz >> shift
rx = fx - (ix << shift)
rz = fz - (iz << shift)
if shift <= 16:
tx, tz = rx << (16 - shift), rz << (16 - shift)
else:
tx, tz = rx >> (shift - 16), rz >> (shift - 16)
return ix, iz, tx, tz
def value_noise(fx, fz, seed, shift=18):
"""值噪声:格点标量值 + smoothstep 双线性插值,返回 [0, ONE)。"""
ix, iz, tx, tz = lattice(fx, fz, shift)
sx, sz = smoothstep(tx), smoothstep(tz)
n00 = value_hash(ix, iz, seed)
n10 = value_hash(ix + 1, iz, seed)
n01 = value_hash(ix, iz + 1, seed)
n11 = value_hash(ix + 1, iz + 1, seed)
a = n00 + cglib.fmul(sx, n10 - n00)
b = n01 + cglib.fmul(sx, n11 - n01)
return a + cglib.fmul(sz, b - a)
def perlin(fx, fz, seed, shift=18):
"""Perlin 梯度噪声:梯度哈希 + 整数点积 + smoothstep 插值,返回 Q16.16(有符号)。"""
ix, iz, tx, tz = lattice(fx, fz, shift)
sx, sz = smoothstep(tx), smoothstep(tz)
def corner(ox, oz):
gx, gz = GRADS[gradient_hash(ix + ox, iz + oz, seed)]
dx = tx - (ox << 16)
dz = tz - (oz << 16)
return cglib.fmul(gx, dx) + cglib.fmul(gz, dz)
n00, n10 = corner(0, 0), corner(1, 0)
n01, n11 = corner(0, 1), corner(1, 1)
a = n00 + cglib.fmul(sx, n10 - n00)
b = n01 + cglib.fmul(sx, n11 - n01)
return a + cglib.fmul(sz, b - a)
def fbm(fx, fz, seed, octaves=4, shift=18, gain=ONE >> 1):
"""fBm:octaves 层倍频叠加,lacunarity = 2(格点间距每层减半),gain 为幅度衰减。
每层的格点间距是 2^(shift-o),幅度是 `gain^o`;最后按幅度和归一化回 [0, ONE)。
归一化用 `fdiv`(分子非负,故与截断一致)。
"""
total, amp, norm = 0, ONE, 0
for o in range(octaves):
n = value_noise(fx, fz, seed + o, shift - o)
total += cglib.fmul(n, amp)
norm += amp
amp = cglib.fmul(amp, gain)
return cglib.fdiv(total, norm)
def warp(fx, fz, seed, strength, octaves=4, shift=18):
"""域扭曲:用两个 fBm 分别偏移 x 与 z,返回扭曲后的世界坐标 (fx', fz')。
偏移量以 ONE/2 为中心(有正有负),幅度由 `strength` 控制;无除法。
"""
ox = fbm(fx, fz, seed + 101, octaves, shift) - (ONE >> 1)
oz = fbm(fx, fz, seed + 202, octaves, shift) - (ONE >> 1)
return fx + cglib.fmul(ox, strength), fz + cglib.fmul(oz, strength)
三处细节值得单独拎出来:
偏移量对 ox = 1 是负数。 dx = tx - (ox << 16),而 tx ∈ [0, ONE),所以右上、左下、右下三个角的偏移分量都落在 [-ONE, 0]。这直接决定了 Perlin 的输出有符号,而值噪声恒非负——后面所有统计差异都从这里长出来。
分数归一化对两种 shift 都成立。 格点间距是 2^shift。shift > 16 时分数用算术右移取低 16 位(floor,两侧一致);shift ≤ 16 时改左移补齐。这样 9.2 的 fBm 把 shift 一路降到 11(0.03125 世界单位)也不会越界。
8 个梯度是整数常量。 四轴向 (±ONE, 0)、(0, ±ONE),四对角 (±S, ±S),S = 46341 是 sqrt(2)/2 × 65536 的整数近似。没有归一化步骤,所以两侧不可能因为浮点 sqrt 差一位。
四组实测数字
在 128×128 网格上按 1.0 世界单位步长逐点求值(每基准格 4 点),跑一次:
# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_shade_09_1_value_perlin.py
同源(value_hash == cglib._hash_noise): True
差分网格 128x128 步长 1.000 世界单位 格点间距 4.0
值噪声 mean 32138 var 199322122 [32, 65534] 耗时 36.431 ms
Perlin mean 98 var 198930126 [-46342, 42947] 耗时 57.009 ms
相邻差分均值 值 4703.5 Perlin 7464.1 (比 0.63x)
相邻差分最大 值 21698 Perlin 25384 零差分 值 2 / Perlin 640
差分直方图 值 [17932, 9007, 3365, 1686, 478, 44, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
差分直方图 Perlin [9489, 10343, 6595, 3972, 1776, 330, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0]
功率谱网格 256x256 步长 0.2500 峰值 bin 值 0 / Perlin 2
功率谱 值 0.5649 0.3394 0.0826 0.0098 0.0015 0.0006 0.0003 0.0002
功率谱 Perlin 0.1176 0.3323 0.4426 0.0956 0.0090 0.0015 0.0005 0.0003
均值 32138 与 98。 值噪声的格点值是均匀分布在 [0, ONE) 的哈希输出,插值不改变均值,理论值就是 ONE/2 = 32768,实测 32138(128×128 = 16384 个样本的采样偏差)。Perlin 的梯度点积在四个角上符号相反、期望为零,实测 98——两个数差了 300 多倍,这是「有偏」与「零均值」的差别,不是精度问题。
方差 199322122 与 198930126。 两者几乎相等(差 0.2%)。这个结果有点反直觉:教科书常说 Perlin 更「平滑」所以方差更小。但在同一个 smoothstep 插值器下,值噪声(峰峰 1.0)与 Perlin(峰峰约 1.42)的方差恰好接近。真正的差别不在方差,在能量落在哪个频段——见下。
相邻差分:值 4703.5 / Perlin 7464.1,Perlin 反而大 1.59 倍。 又一处反直觉:Perlin 的局部变化更陡。原因还是振幅——Perlin 的峰峰是 1.42 倍于值噪声,而两者的平滑度接近,于是「每单位距离的变化」Perlin 更大。零差分的像素数也印证了这一点:值噪声只有 2 个(插值曲面几乎处处在变),Perlin 有 640 个(在格点附近梯度点积恰好抵消)。
差分直方图是最直观的一张:值噪声有 17932 个相邻差落在第一个 bin(< ONE/16),Perlin 只有 9489 个。值噪声的分布更集中在 0 附近,Perlin 明显右移——与上面的均值一致。
功率谱:值噪声压在低频,Perlin 峰值在高频
差分统计只在「1 单位步长」上成立,会受采样率影响。要看清两种噪声的固有差别,得用充分采样的功率谱:256×256 网格、0.25 单位步长(每基准格 16 点),对去均值后的场做 2D FFT,按半径分 32 个 bin 取平均功率。
功率谱(归一化,前 8 个 bin,左 = 低频)
值噪声 0.5649 0.3394 0.0826 0.0098 0.0015 0.0006 0.0003 0.0002 峰值 bin 0
Perlin 0.1176 0.3323 0.4426 0.0956 0.0090 0.0015 0.0005 0.0003 峰值 bin 2
这才是两者的分水岭。值噪声 56% 的能量压在最最低的频带上(bin 0),单调衰减;Perlin 的峰值在 bin 2,即格点频率附近,bin 0 只有 12%。换成视觉语言:值噪声「成团」——大片大片的渐变;Perlin 把细节集中在格点尺度上,观感更「有纹理」。这也解释了为什么程序化地形常用 Perlin 而非值噪声:同样几层叠加,Perlin 的细节尺度更贴近采样尺度。
顺带一个方法学提醒:如果只在 1 单位步长上算功率谱,会得到相反的结论(实测 Perlin 的高频占比 0.0097 反而高于值噪声的 0.0015)。那不是噪声的谱形,是欠采样把高频折回低频的混叠。判据必须在充分采样的网格上做,否则测的是采样器不是信号。
跨语言核对
P 层用 BigInt 重算同一份探针表,与 S 侧的 JSON 逐值比对:
// file: lantern/core/test/cg_shade_09_1_value_perlin.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_09_1_value_perlin.test.ts'
// 9.1 的跨语言断言:TypeScript 必须逐值复现 Python 侧 sim/cg_shade_09_1_value_perlin.py
// 的探针表,并证明 valueHash 与卷一 noise.ts 的 hashNoise 同源。
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { hashNoise } from "../src/lantern/noise.ts";
import { gradientIndex, perlin, valueHash, valueNoise } from "../src/lantern/cg_shade_09_noise.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const noise = JSON.parse(
readFileSync(join(root, "out", "cg_shade_09_1_noise.json"), "utf8"),
) as {
latticeShift: number;
seed: number;
sameOriginAsCglib: boolean;
probes: { x: number; z: number; fx: number; fz: number; value: number; perlin: number }[];
};
const SHIFT = BigInt(noise.latticeShift);
const SEED = BigInt(noise.seed);
test("valueHash 与卷一 hashNoise 逐值同源", () => {
assert.equal(noise.sameOriginAsCglib, true);
for (let ix = -3; ix < 6; ix += 1) {
for (let iz = -3; iz < 6; iz += 1) {
assert.equal(
valueHash(BigInt(ix), BigInt(iz), SEED),
hashNoise(BigInt(ix), BigInt(iz), SEED),
`(${ix},${iz}) 格点值`,
);
}
}
});
test("【核心】探针表逐值一致:值噪声与 Perlin 的 Q16.16", () => {
for (const p of noise.probes) {
const fx = BigInt(p.fx);
const fz = BigInt(p.fz);
assert.equal(valueNoise(fx, fz, SEED, SHIFT), BigInt(p.value), `(${p.x},${p.z}) 值噪声`);
assert.equal(perlin(fx, fz, SEED, SHIFT), BigInt(p.perlin), `(${p.x},${p.z}) Perlin`);
}
});
test("值噪声恒非负且 < ONE;Perlin 可取负", () => {
let sawNegative = false;
for (let k = 0; k < 200; k += 1) {
const fx = BigInt(k * 3731);
const fz = BigInt(k * 7919);
const v = valueNoise(fx, fz, SEED, SHIFT);
assert.ok(v >= 0n && v < 65536n, `值噪声越界 ${v}`);
if (perlin(fx, fz, SEED, SHIFT) < 0n) sawNegative = true;
}
assert.ok(sawNegative, "Perlin 在 200 个采样点上从未为负,说明有符号路径没生效");
});
test("梯度索引落在 [0,8) 且不是常量", () => {
const seen = new Set<number>();
for (let ix = 0; ix < 64; ix += 1) {
const g = Number(gradientIndex(BigInt(ix), 3n, SEED));
assert.ok(g >= 0 && g < 8, `梯度索引越界 ${g}`);
seen.add(g);
}
assert.equal(seen.size, 8, "8 个梯度未被全部用到");
});
三个断言各自钉住一件事:valueHash 与卷一 hashNoise 同源(否则后面的数都不作数)、16 个探针的值噪声与 Perlin 逐值相等(跨语言)、值噪声恒在 [0, ONE) 而 Perlin 在 200 个采样点上出现过负值(证明有符号路径真的生效,而不是被某个隐式钳制抹平)。梯度索引落在 [0,8) 那条是防退化的:如果 gradient_hash 恒返回同一个值,Perlin 会退化成一张平面,而逐值断言反而「通过」——判据必须能拒绝这种退化实现。
阅读导航:上一节:8.3 各向异性过滤 · 下一节:9.2 分形噪声与 fBm 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。