9.1 值噪声与 Perlin

站内那篇讲的是大地形 LOD、分块与流式加载的工程实现,本节把噪声函数本身拆成可逐值断言的整数。值噪声与 Perlin 共用同一个整数哈希,只在格点上放标量值还是梯度向量:128×128 实测两者均值 32138 与 98,相邻差分均值 4703.5 与 7464.1,功率谱峰值分别落在第 0 与第 2 个频带。

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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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