GPU Gems 1 · Chapter 5. Implementing Improved Perlin Noise

从晶格梯度、五次插值与环面平铺出发,把 Improved Perlin Noise 落到像素着色器和 bump mapping。

学习目标

  • 能解释 Noise 在三维空间中为什么要同时满足可重复、带限和视觉各向同性
  • 能推导五次 fade 函数的端点条件,并说明它为什么比三次插值更适合 bump mapping
  • 能修改一段像素着色器伪代码,用均匀梯度和多频噪声生成不易出现平铺伪影的纹理
  • 能回答:为什么 Improved Perlin Noise 同时改插值函数、梯度分布和硬件实现路径?

直觉引入:一块会呼吸的材质

想象一块岩石表面:它不应该像掷骰子一样每个像素都跳变,也不应该像一张被拉伸的照片那样露出接缝。我们希望在空间中移动时,颜色或高度连续变化;同一个位置再次计算时,还能得到同一个结果。

没有这种信号,程序材质只能依赖手工绘制的图像,换分辨率会模糊,换方向会暴露 UV 接缝,做凹凸光照时还会把采样网格直接照出来。本章把“可控的随机感”拆成可计算的目标,再逐个修掉原始算法的缺陷。

1. Noise 的目标:可重复的连续随机

这里的 不是白噪声,而是一个定义在三维空间上的伪随机信号。它应该满足三件事:输入位置不变时输出可重复;大部分能量集中在有限的空间频率范围;旋转观察方向时,统计外观不应偏向某个轴。

第二个目标可以称为 。第三个目标是 。这三个词不是装饰:它们分别约束重复性、细节尺度和方向偏差。

从晶格梯度到自然纹理整数晶格hash → 梯度方向平滑插值连续但不抹掉结构用途岩石云雾凹凸可重复 · 带限 · 视觉各向同性
Noise 不是白噪声:它保留可控的随机感,同时让空间变化连续、可重复。

对于材质,Noise 的输入通常是对象空间坐标 (x, y, z),输出可以驱动颜色、位移、透明度或法线扰动。因为它是体积函数,所以不需要先把一张二维图像摊到模型表面;同一个函数也可以自然地穿过多个相邻物体。

2. 原始算法:八个角点与七次插值

先把空间位置拆成整数格点和小数部分:

(x,y,z)=(i+u,j+v,k+w),0u,v,w1.(x,y,z)=(i+u,j+v,k+w),\quad 0\le u,v,w\le 1.

这个式子在说:点落在由 (i,j,k) 和相邻格点围成的一个单位立方体里,u、v、w 是它离三个低坐标面的相对距离。

立方体有八个角。每个整数格点通过哈希得到一个伪随机 方向;点与该角点的位移向量做点积,得到角点对当前采样的贡献。接着沿 x 方向插值四次,沿 y 方向插值两次,最后沿 z 方向插值一次,总共七次一维插值。

这个流程的直觉是“先在八个角上放方向,再让角的影响平滑地流过立方体”。它比直接生成独立随机值更连续,也为后面的硬件化留下了清晰的阶段边界。

float cornerContribution(vec3 offset, vec3 gradient) {
  return dot(offset, gradient);
}
 
float oldFade(float t) {
  return t * t * (3.0 - 2.0 * t);
}
 
// 先沿 x,再沿 y,最后沿 z;lerp 只是线性插值。
float latticeNoise(vec3 cell, vec3 fraction) {
  vec3 g000 = gradient(hash(cell + vec3(0, 0, 0)));
  // 其余七个角以同样方式取梯度并参与三层 lerp。
  return trilerp(cell, fraction, g000, oldFade);
}

代码中的 hash 只负责把格点映射到梯度表,trilerp 负责七次插值;把两者拆开,之后才能分别替换 fade 函数和梯度集合。

为什么换成五次插值?fade(t) 从 0 到 1t=0t=1三次:3t²−2t³五次:6t⁵−15t⁴+10t³边界条件两者:f′(0)=f′(1)=0五次:f″(0)=f″(1)=0旧问题二阶导数跳变bump 光照出现网格感
五次 fade 曲线把二阶不连续也消掉;改进点不是让曲线更弯,而是让跨晶格边界更平顺。

3. 两个缺陷:边界二阶跳变与方向聚团

原始实现使用三次

f3(t)=3t22t3.f_3(t)=3t^2-2t^3.

这个式子在说:当采样点从一个晶格角移动到另一个晶格角时,权重从 0 平滑过渡到 1,并且端点的一阶导数为零。问题是它的二阶导数 f_3''(t)=6-12t 在两端不为零。对高度函数求导后,这个跳变就会变成 bump 光照里的规则伪影。

第二个问题来自梯度方向的选择。原始方式从球面上较大的伪随机集合里取方向;方向在局部聚团时,邻近区域会偏爱某些朝向,结果看起来像斑驳的高频污点。视觉系统对这种统计异常很敏感,哪怕方向总数很多,也不代表纹理更自然。

4. 改进一:五次插值抹平二阶不连续

改进版使用

f5(t)=6t515t4+10t3.f_5(t)=6t^5-15t^4+10t^3.

这个式子在说:t=0t=1 时,函数值仍分别是 0 和 1,同时一阶、二阶导数都为零,所以相邻晶格单元连接时不会把二阶跳变传给 bump mapping。

直接验算端点条件即可建立信心:

f5(t)=30t460t3+30t2,f5(t)=120t3180t2+60t.f_5'(t)=30t^4-60t^3+30t^2,\quad f_5''(t)=120t^3-180t^2+60t.

t=0t=1 代入,两个导数都为零。这也是一个适合写成单元测试的契约:改动实现后,先测端点,再看整张纹理。

在旧硬件上,一维查表可以把 f_5(t) 近似成纹理读取;256 个表项和 16 bit 精度已经足够覆盖常见实时场景。高阶函数不必自动等于更贵,关键是看硬件是否能把插值放进已有的采样路径。

5. 改进二:用均匀梯度代替杂乱方向

改进版把方向集合换成正方体各条边中心对应的 12 个向量:(0,±1,±1)(±1,0,±1)(±1,±1,0)。它们的方向彼此分得更均匀,且很多点积可以简化成加减法,例如与 (1,1,0) 的点积就是 x+y

为了让哈希更适合硬件,实际表里准备 16 个结果,让哈希只需返回一个随机 4 bit 值;额外的四个方向是重复项,不是为了增加视觉方向,而是为了简化查表控制逻辑。

方向分布与平铺伪影256 个杂乱方向局部聚团 → 斑驳高频12 个均匀方向均匀分布 → 少斑驳、点积更便宜8³ 环面平铺 + 低频复数相位 = 隐藏重复边界
方向分布均匀比方向数量更多更重要;小体积纹理配合低频相位可以隐藏平铺边界。

不要把“12”背成新的魔法数字。真正要验收的是:方向分布不要局部聚团、点积路径要便宜、同一份实现跨 CPU 和 GPU 能返回一致的值。若自定义梯度表,先画球面分布或做方向直方图,再决定是否替换。

6. 像素着色器中的 fake noise:小体积加相位

完整的体积 Noise 直接在像素着色器中计算,指令数可能偏高;把整个大范围都预采样成三维纹理,又会遇到显存按边长立方增长的问题。折中的 先让一个小体积样本无缝循环,再用低频样本改变高频样本的相位。

原书给出的工程路线可以压缩成两层:一个 8×8×8 级别的细节体积负责局部纹理;同样尺度但频率更低的体积提供相位,让每个砖块里的细节方向不断变化。这里的“相位”可以看作旋转复数样本,而不是简单地把一张纹理平移几格。

Improved Noise Lab

先预测:频率和插值曲线会怎样改变纹理?

可交互
Noise slice空间位置 →N = normalize(N − dF)5th-degree fade

曲线是确定性的:相同位置和参数会得到相同结果;梯度和相位控制的是结构,不是随机掷骰。

实验中的曲线不是完整 GPU 实现,而是把关键决策显形:切换三次/五次 fade 会改变跨格点的平顺方式;调整频率会增加空间起伏次数;打开梯度和 bump normal 可以看见“方向贡献”和“法线修正”是不同阶段。每次调整前先预测,再点击改变参数,最后用重置恢复基线。

float fakeNoise(vec3 p) {
  vec2 low = sampleToroidalVolume(p / 9.0);
  vec2 high = sampleToroidalVolume(p);
  float angle = low.x * 6.2831853;
  vec2 rotated = vec2(
    cos(angle) * high.x - sin(angle) * high.y,
    sin(angle) * high.x + cos(angle) * high.y
  );
  return rotated.x;
}

这里用两个复数分量表达“旋转”,低频层不直接替代高频层,而是让高频层的重复结构失去固定相位。实现时仍要检查采样坐标、边界和精度,不能因为图案看起来不重复就跳过一致性测试。

7. bump mapping:不读取邻居顶点也能改法线

噪声函数可以直接作为像素阶段的高度函数 F(x,y,z)。要估计它在当前位置的变化方向,不必访问相邻网格顶点,可以使用

dF=(F(x+ϵ)F(xϵ)2ϵ,F(y+ϵ)F(yϵ)2ϵ,F(z+ϵ)F(zϵ)2ϵ).dF=\left(\frac{F(x+\epsilon)-F(x-\epsilon)}{2\epsilon},\frac{F(y+\epsilon)-F(y-\epsilon)}{2\epsilon},\frac{F(z+\epsilon)-F(z-\epsilon)}{2\epsilon}\right).

这个式子在说:沿三个轴各取一对很近的样本,用中心差分估计高度变化;ε 太大就抹平细节,太小又会放大精度噪声。

把估计出的变化从原始法线中减掉,再归一化:

N=normalize(NdF).N'=normalize(N-dF).

这个式子在说:法线朝向随高度梯度倾斜,并重新恢复单位长度;因此不需要修改网格顶点,就能得到凹凸、皱褶或大理石纹理的光照变化。

vec3 finiteDifference(vec3 p, float epsilon) {
  vec3 dx = vec3(epsilon, 0.0, 0.0);
  vec3 dy = vec3(0.0, epsilon, 0.0);
  vec3 dz = vec3(0.0, 0.0, epsilon);
  return vec3(
    (noise(p + dx) - noise(p - dx)) / (2.0 * epsilon),
    (noise(p + dy) - noise(p - dy)) / (2.0 * epsilon),
    (noise(p + dz) - noise(p - dz)) / (2.0 * epsilon)
  );
}
 
vec3 bumpedNormal(vec3 normal, vec3 p) {
  return normalize(normal - finiteDifference(p, 0.01));
}

bumpedNormal 的效果仍受像素采样率限制。过高频率的 Noise 已经超过一个像素能分辨的尺度,只会在动画或镜头移动时闪烁;“更多细节”不等于“更多真实感”。

8. 工程取舍:频率、精度与硬件路径

程序纹理的优势是分辨率无关:靠近模型时可以加入更高频的层,远处则减少层数。常见的 turbulence 会让频率逐层翻倍,同时把每层振幅按频率衰减;实际循环上限应由图像宽度、像素采样率和材质运动速度共同决定。

但每增加一层,采样、带宽或指令成本都会增加。对实时渲染,可以采用以下顺序:先验证五次 fade 的端点条件;再确认梯度方向没有聚团;最后测量 3D 采样、相位组合和有限差分对帧时间的影响。只有在目标硬件上测过,才能决定是完整计算、查表,还是小体积 fake noise。

小结:三个改进要同时成立

  • Noise 用稳定的三维信号表达自然变化,而不是逐像素白噪声。
  • 五次 fade 消除端点二阶导数跳变,改善 bump mapping 的连续性。
  • 均匀梯度分布减少斑驳;小体积环面平铺配低频相位隐藏重复。
  • 有限差分可在像素阶段估计高度梯度,但高频必须服从采样率。
  • 算法选择最终由画质、指令、显存和目标硬件共同决定。

练习

问题 1 推导题:验证 f_5(t)=6t^5-15t^4+10t^3t=0t=1 处的一阶、二阶导数都为零,并说明这对 bump mapping 有什么意义。

问题 2 改 Demo 代码:在实验中切换三次和五次 fade,把频率从 1 调到 4,再打开 bump normal。记录曲线边界、法线方向和动画时闪烁风险的变化。

问题 3 独立实现题:不用预生成大体积纹理,写出一个由小型环面平铺高频样本和低频复数相位组成的 fakeNoise(p),并说明你会如何测试它是否暴露砖块边界。

名词解释

本章出现的专业名词,用大白话再讲一遍。

Noise
给定三维位置就能稳定返回一个平滑伪随机值的函数。
带限信号
细节集中在有限频率范围内的信号,避免出现像素无法表示的无限细碎变化。
视觉各向同性
从不同方向观察时,纹理的统计外观大致一致,不偏向某条轴。
梯度场
每个晶格点附着一个方向向量,Noise 用它计算当前位置的方向贡献。
fade 函数
把晶格内的小数坐标变成平滑插值权重的函数。
五次 fade 函数
端点一阶和二阶导数都为零的插值曲线,能减少 bump mapping 的边界伪影。
环面平铺
把体积纹理的相对两面无缝接起来,让它可以无限重复。
像素着色器
对每个屏幕像素执行的程序阶段,适合直接计算材质颜色和法线扰动。
有限差分
用当前位置两侧的采样差近似函数导数的方法。

资料与写作方式声明

本章以GPU Gems 1 · Chapter 5. Implementing Improved Perlin Noise公开完整正文核定章节范围、事实坐标与时代语境,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…