17 Noise 的跨设备表现与性能

前置知识:第 12 章的 hash 与 value noise、第 14 章的 fBm、第 15 章和第 16 章的法线计算。
本章示例:精度与高频采样对照,完整源码。

学完本章能做什么

你将能区分数值精度差异、屏幕采样造成的闪烁与计算量过高,知道怎样缩小问题范围,并为程序化 noise 选择可验证的降成本方法。这里的“跨设备一致”指画面结构和用途足够稳定,不承诺每个像素逐位相等。

先看现象

示例默认把同一个 fBm 画在左右两半。左侧计算全部层;右侧按照像素足迹逐渐减弱过密的层。提高基础频率和层数,再降低渲染分辨率或开启动画,左侧更容易出现跳动和错误条纹。右侧会更平滑,但这只是简化过滤,不能保证完全没有混叠。

切换到“大坐标”视图:左侧是 fract(p),右侧是 fract(p + 整数偏移)。两者在数学上相同。偏移达到很大时,右侧却可能变成大片相同颜色,说明小数部分在有限精度浮点数中丢失。这个视图刻意避开 hash,便于直接观察坐标精度。

1. 先问:差异出现在哪一层

同样叫“noise 不一样”,原因可能完全不同:

观察到的现象 优先检查 不能据此直接断定
固定画面在两台设备上有细微像素差 浮点精度、hash、三角函数、颜色输出 一台设备的驱动一定有错误
镜头移动时细节闪烁或出现摩尔纹 最高频率相对屏幕像素的大小 hash 的随机性差
远离原点后图案块状、停住 noise 输入坐标的浮点粒度 增加网格分段就能修复
帧率在高分辨率下降得很快 片元数量、层数、法线的重复采样 只要换一个 hash 就足够

先把图案直接输出为灰度,再关闭动画、固定参数,确认输入坐标是否一致;接着分别显示单层 noise、每层 fBm、最终颜色。若用了法线,还要显示局部或观察空间法线颜色。这样才能知道差异始于输入、noise 本身、过滤、光照还是色彩输出。

2. 为什么浮点 hash 不能保证逐位一致

前几章使用的 hash21(cell) 先做小数乘法、fract 和点积,再把结果当格点随机值。输入是二维实数格点编号,理想输出在 [0,1);相同输入在同一实现中通常稳定,但该算法没有跨 GPU 的逐位一致性保证。浮点运算可能在精度、舍入、重排和编译器优化上不同。若 hash 用 sin 加大常数,不同设备的三角函数近似和大数精度还会放大差异。

WebGL 的 highp、mediump 是精度等级;可以用 gl.getShaderPrecisionFormat 查询浏览器报告的范围和精度,示例诊断栏显示片元 highp、mediump 的精度字段。该数字不是最终画面会一致的证明,也不是 GPU 耗时。Three.js r174 的普通 ShaderMaterial 在本教程环境中由渲染器处理着色器前缀与精度声明;不要在代码中猜测所有设备都以相同的内部计算路径执行。

当项目要求不同平台精确复现同一随机图案时,可考虑用固定种子在 CPU 或构建阶段生成数据,再作为纹理或缓冲区传给 GPU;还要固定纹理格式、过滤、色彩空间和采样约定。WebGL2 的整数运算可用于定义更明确的整数 hash,但必须核对位运算、溢出、版本和输出映射,不能把“改成整数”当作所有显示差异的自动解法。移植 Simplex、OpenSimplex2、psrdnoise 等现成实现时也应检查具体源码与许可、维度、返回范围、周期、导数、精度要求和目标设备,而不能仅凭算法名称推断一致性。

3. 大坐标为什么会吃掉小数

有限位数的浮点数越大,相邻可表示数之间的距离越大。示例在片元阶段构造局部二维 noise 坐标 p,它随半幅平面的位置连续变化。令偏移 K 是整数,则数学上:

fract(p + K) = fract(p)

但在浮点计算中,先执行 p + K 再执行 fract;若 K 远大于 p 的小数变化,前一步已经把这些变化舍入掉了。比如典型的 32 位浮点数在 2²⁴ 附近已不能表示所有相邻整数,更不可能保留每个小数。不同设备和精度路径的转折点可能不同。示例选择 16,777,216 = 2²⁴ 作为强压力测试,不代表游戏世界到这个坐标才会出问题。

修复的关键是让送进 shader 的查询坐标保持在适当的小范围:例如从世界坐标中减去附近的块原点,在局部块内求 noise;若需要无缝接续,则用明确的整数块编号、周期算法或预计算纹理管理跨块关系。单纯把 uFrequency 调低,可能遮住症状,却没有恢复已经丢失的小数精度。也不要先在片元里把两个巨大浮点数相减:低位若在传入时已丢失,无法靠后续相减找回。

4. 高频闪烁是屏幕采样问题

程序化 noise 可以在任意实数位置求值,但屏幕只有离散像素。若某层的 noise 单元宽度接近或小于一个像素,相邻帧中采样点轻微移动就可能落到完全不同的局部位置,形成跳动、摩尔纹或错误的低频条纹。这叫混叠。更高的网格分段不能修复片元图案的屏幕采样不足。

示例按每半幅平面的比例构造基础坐标,并在片元阶段估计一个像素覆盖多少基础 noise 坐标:

// 片元阶段;p 是 noise 输入坐标,每一维以 noise 格为单位。
float footprint = max( length( dFdx( p ) ), length( dFdy( p ) ) );

dFdx、dFdy 利用邻近片元估计屏幕方向的变化量。第 i 层把基础坐标乘以 2ⁱ,所以它每像素跨过的格数约为 footprint × 2ⁱ。示例在它接近一个格/像素时淡出该层:

// 片元阶段;frequency 为当前层相对基础层的频率。
float cellsPerPixel = footprint * frequency;
float visibility = 1.0 - smoothstep( 0.35, 1.0, cellsPerPixel );
if ( visibility > 0.0 ) {
  sum += amplitude * visibility * valueNoise( pAtThisOctave );
  weight += amplitude * visibility;
}

最后用 sum / weight 保持输出范围和大致亮度;所有层都被跳过时返回中性值 0.5。valueNoise 的输出在 [0,1),加权归一化仍在此范围内。其格线处的值和一阶导数连续,但高频仍可能超出屏幕采样能力。导数计算放在分支前:GPU 通常以相邻片元组估计导数,跨越不连续坐标或非一致分支时结果可能不可靠;本例左右中缝附近也是一个局部边界。

这种方法只是逐层衰减,没有真正积分像素覆盖的 noise,也未处理各向异性足迹。若需要更稳定的远景图案,常用方案是预生成纹理并使用合适的 mipmap 与纹理过滤,或选用经过验证的解析过滤方法。程序化函数不会因为 Three.js 自动获得 mipmap。片元法线依赖高度斜率:只过滤颜色而继续从未过滤的高频高度求法线,光照依然可能闪烁;应对高度和法线使用一致的细节策略。

5. 算清每个像素做了多少事

本章的二维 valueNoise 每次调用查询四个格点 hash。八层 fBm 最多查询约 8 × 4 = 32 次 hash,尚未算插值和颜色。若像第 16 章那样用中心差分求片元法线,四次邻近高度查询都执行八层 fBm,粗略上限就变成 4 × 8 × 4 = 128 次 hash 每片元;若额外计算中心高度或做域扭曲,还要继续加。编译器可能合并部分工作,也可能因分支和寄存器压力导致实际代价不同,因此这些数只是成本估算。

片元成本还随实际绘制像素数增长。把宽高都乘以 2,像素数约乘以 4;设备像素比也会改变绘制缓冲区大小。示例的“渲染分辨率”滑块会改变 renderer.setPixelRatio,诊断栏显示真实绘制缓冲区尺寸。左侧未过滤路径始终计算选中的层;右侧会跳过 visibility = 0 的层,但实际提速要看 GPU 和 shader 编译结果。

优化时先删掉看不见的高频层,再考虑降低渲染分辨率、减少重复高度采样、预计算纹理或在确实可用时采用解析导数。顶点位移的计算量主要随顶点数变,片元细节法线的计算量主要随覆盖像素数变。移动计算阶段会改变形状和质量,不能只比较函数调用次数。

6. 在目标设备上验证

记录设备、浏览器、实际画布像素数、noise 函数版本、坐标范围、层数、是否开启动画与过滤。先固定画面比较视觉结果,再做性能测量;一次只改一个参数。用浏览器 GPU 工具或可用的 EXT_disjoint_timer_query_webgl2 计时,并在查询发生 disjoint 时丢弃结果。JavaScript 在 renderer.render() 前后取 performance.now() 主要量到 CPU 提交和同步开销,不能当作 GPU 执行时间。测量还会受着色器首次编译、缓存、散热与后台程序影响,需预热并重复。

若现象只在某一设备出现,先用诊断视图把问题缩到输入坐标、单层 hash、高频层、法线或颜色输出,再更换算法或精度策略。不要只凭一张静态截图判断动画是否稳定。

常见问题

本章小结

noise 的跨设备问题应分开处理:浮点精度和 hash 决定数值稳定性,像素足迹决定高频细节是否会混叠,调用次数与覆盖像素数决定成本。把查询坐标维持在合理范围、按屏幕尺寸控制细节,并在目标设备上分别检查画面与 GPU 耗时,比单独追求更多层数可靠。

本章练习