GPU Gems 1 · Chapter 5. Implementing Improved Perlin Noise
从晶格梯度、五次插值与环面平铺出发,把 Improved Perlin Noise 落到像素着色器和 bump mapping。
学习目标
- 能解释 Noise 在三维空间中为什么要同时满足可重复、带限和视觉各向同性
- 能推导五次 fade 函数的端点条件,并说明它为什么比三次插值更适合 bump mapping
- 能修改一段像素着色器伪代码,用均匀梯度和多频噪声生成不易出现平铺伪影的纹理
- 能回答:为什么 Improved Perlin Noise 同时改插值函数、梯度分布和硬件实现路径?
直觉引入:一块会呼吸的材质
想象一块岩石表面:它不应该像掷骰子一样每个像素都跳变,也不应该像一张被拉伸的照片那样露出接缝。我们希望在空间中移动时,颜色或高度连续变化;同一个位置再次计算时,还能得到同一个结果。
没有这种信号,程序材质只能依赖手工绘制的图像,换分辨率会模糊,换方向会暴露 UV 接缝,做凹凸光照时还会把采样网格直接照出来。本章把“可控的随机感”拆成可计算的目标,再逐个修掉原始算法的缺陷。
1. Noise 的目标:可重复的连续随机
这里的 ↡在三维位置上返回稳定伪随机值的函数;同一位置和参数会得到同一结果 不是白噪声,而是一个定义在三维空间上的伪随机信号。它应该满足三件事:输入位置不变时输出可重复;大部分能量集中在有限的空间频率范围;旋转观察方向时,统计外观不应偏向某个轴。
第二个目标可以称为 ↡能量主要集中在有限空间频率附近,不把无限细的变化塞进每个像素。第三个目标是 ↡从不同方向观察时统计纹理特征近似一致,不偏爱水平、垂直或某条轴。这三个词不是装饰:它们分别约束重复性、细节尺度和方向偏差。
对于材质,Noise 的输入通常是对象空间坐标 (x, y, z),输出可以驱动颜色、位移、透明度或法线扰动。因为它是体积函数,所以不需要先把一张二维图像摊到模型表面;同一个函数也可以自然地穿过多个相邻物体。
2. 原始算法:八个角点与七次插值
先把空间位置拆成整数格点和小数部分:
这个式子在说:点落在由 (i,j,k) 和相邻格点围成的一个单位立方体里,u、v、w 是它离三个低坐标面的相对距离。
立方体有八个角。每个整数格点通过哈希得到一个伪随机 ↡附着在晶格点上的方向向量,Noise 用它与点到角点的位移做点积 方向;点与该角点的位移向量做点积,得到角点对当前采样的贡献。接着沿 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 函数和梯度集合。
3. 两个缺陷:边界二阶跳变与方向聚团
原始实现使用三次 ↡把 0 到 1 的小数坐标映射为平滑权重的插值曲线:
这个式子在说:当采样点从一个晶格角移动到另一个晶格角时,权重从 0 平滑过渡到 1,并且端点的一阶导数为零。问题是它的二阶导数 f_3''(t)=6-12t 在两端不为零。对高度函数求导后,这个跳变就会变成 bump 光照里的规则伪影。
第二个问题来自梯度方向的选择。原始方式从球面上较大的伪随机集合里取方向;方向在局部聚团时,邻近区域会偏爱某些朝向,结果看起来像斑驳的高频污点。视觉系统对这种统计异常很敏感,哪怕方向总数很多,也不代表纹理更自然。
4. 改进一:五次插值抹平二阶不连续
改进版使用 ↡端点的一阶和二阶导数都为零的五次插值曲线:
这个式子在说:t=0 和 t=1 时,函数值仍分别是 0 和 1,同时一阶、二阶导数都为零,所以相邻晶格单元连接时不会把二阶跳变传给 bump mapping。
直接验算端点条件即可建立信心:
在 t=0 与 t=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 值;额外的四个方向是重复项,不是为了增加视觉方向,而是为了简化查表控制逻辑。
不要把“12”背成新的魔法数字。真正要验收的是:方向分布不要局部聚团、点积路径要便宜、同一份实现跨 CPU 和 GPU 能返回一致的值。若自定义梯度表,先画球面分布或做方向直方图,再决定是否替换。
6. 像素着色器中的 fake noise:小体积加相位
完整的体积 Noise 直接在像素着色器中计算,指令数可能偏高;把整个大范围都预采样成三维纹理,又会遇到显存按边长立方增长的问题。折中的 ↡把小体积纹理的边界首尾相接,使它能无限重复而不在砖块边缘断裂 先让一个小体积样本无缝循环,再用低频样本改变高频样本的相位。
原书给出的工程路线可以压缩成两层:一个 8×8×8 级别的细节体积负责局部纹理;同样尺度但频率更低的体积提供相位,让每个砖块里的细节方向不断变化。这里的“相位”可以看作旋转复数样本,而不是简单地把一张纹理平移几格。
Improved Noise Lab
先预测:频率和插值曲线会怎样改变纹理?
曲线是确定性的:相同位置和参数会得到相同结果;梯度和相位控制的是结构,不是随机掷骰。
实验中的曲线不是完整 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)。要估计它在当前位置的变化方向,不必访问相邻网格顶点,可以使用 ↡用当前位置两侧的小距离采样差近似函数导数:
这个式子在说:沿三个轴各取一对很近的样本,用中心差分估计高度变化;ε 太大就抹平细节,太小又会放大精度噪声。
把估计出的变化从原始法线中减掉,再归一化:
这个式子在说:法线朝向随高度梯度倾斜,并重新恢复单位长度;因此不需要修改网格顶点,就能得到凹凸、皱褶或大理石纹理的光照变化。
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^3 在 t=0 和 t=1 处的一阶、二阶导数都为零,并说明这对 bump mapping 有什么意义。
问题 2 改 Demo 代码:在实验中切换三次和五次 fade,把频率从 1 调到 4,再打开 bump normal。记录曲线边界、法线方向和动画时闪烁风险的变化。
问题 3 独立实现题:不用预生成大体积纹理,写出一个由小型环面平铺高频样本和低频复数相位组成的 fakeNoise(p),并说明你会如何测试它是否暴露砖块边界。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- Noise
- 给定三维位置就能稳定返回一个平滑伪随机值的函数。
- 带限信号
- 细节集中在有限频率范围内的信号,避免出现像素无法表示的无限细碎变化。
- 视觉各向同性
- 从不同方向观察时,纹理的统计外观大致一致,不偏向某条轴。
- 梯度场
- 每个晶格点附着一个方向向量,Noise 用它计算当前位置的方向贡献。
- fade 函数
- 把晶格内的小数坐标变成平滑插值权重的函数。
- 五次 fade 函数
- 端点一阶和二阶导数都为零的插值曲线,能减少 bump mapping 的边界伪影。
- 环面平铺
- 把体积纹理的相对两面无缝接起来,让它可以无限重复。
- 像素着色器
- 对每个屏幕像素执行的程序阶段,适合直接计算材质颜色和法线扰动。
- 有限差分
- 用当前位置两侧的采样差近似函数导数的方法。