15 Noise 顶点位移后的几何法线

前置知识:第 3 章的坐标空间、第 10 章的顶点位移、第 14 章的 fBm。
本章示例:位移与法线对照,完整源码。

学完本章能做什么

你将能说明顶点移动后原来的 normal 为什么失效,从高度场求出新法线,并正确把局部空间法线交给观察空间光照。示例还会展示网格分段数和差分步长怎样分别影响几何形状与法线质量。

先看现象

打开示例,平面沿局部 z 方向起伏。选择“原始法线”时,轮廓仍高低变化,但明暗像一块未起伏的板;选择“重算法线”后,斜坡接收的光不同,凸起更易辨认。选择“法线颜色”可直接看法线方向。把横向分段降至很少,轮廓变成明显的折面:法线计算再精细也不会凭空增加几何顶点。

1. 先约定高度场和坐标

示例几何体是局部宽 3.0、高 2.0 的 PlaneGeometry,原顶点在局部 z = 0,原法线为局部 +z。用局部 x、y 单位定义高度:

// 顶点阶段;localXY 是模型局部空间的平面坐标,返回局部 z 高度。
float heightAt( vec2 localXY ) {
  vec2 samplePoint = ( localXY + vec2( uTime * 0.15, 0.0 ) ) * uFrequency;
  return uAmplitude * 2.0 * ( fbm( samplePoint ) - 0.5 );
}

fbm 沿用第 14 章的四层 value noise;归一化输出在 [0,1),所以上式高度在大约 [-uAmplitude, uAmplitude)。实际值通常不会碰到极端。uFrequency 的单位是每局部长度单位的基础 noise 格数;uAmplitude 的单位是局部 z 长度。uTime 平移查询坐标,只让已有二维 noise 场移动,不是三维 noise 在演化。固定输入时值稳定,但教学用浮点 hash 不保证不同设备逐位一致;坐标非常大时也会遇到精度问题,第 17 章会专门处理。

顶点位移直接写成:

// 顶点阶段;displaced 仍是模型局部空间的位置。
vec3 displaced = position;
displaced.z = heightAt( position.xy );
vec4 viewPosition = modelViewMatrix * vec4( displaced, 1.0 );
gl_Position = projectionMatrix * viewPosition;

这里的 position、normal 来自几何体,并由普通 ShaderMaterial 在顶点阶段提供声明。若使用自定义几何体,应确认它确实具有 position、normal、uv 属性。

2. 原法线为何不再代表新表面

原平面每个顶点的法线都是 (0,0,1)。位移后斜坡有了 x、y 方向的倾角,但改变 gl_Position 不会自动更新几何体的 normal 属性。光照仍按未位移的平面计算,常见结果是轮廓起伏而受光面没有相应的明暗变化。

把表面写成 S(x,y) = (x, y, h(x,y))。沿 x、y 的局部切向量分别是:

Tx = ∂S/∂x = (1, 0, ∂h/∂x)
Ty = ∂S/∂y = (0, 1, ∂h/∂y)
Tx × Ty = (-∂h/∂x, -∂h/∂y, 1)

叉积方向遵循右手系,结果朝局部 +z。若把叉积顺序反过来,法线朝下,光照可能突然变暗。最终要归一化长度。这个推导只适用于高度沿局部 z 方向位移的平面;球体沿法线膨胀时要用其实际参数化表面的两个切向量,不能原样套入这个公式。

3. 用中心差分估计高度的斜率

教学例子没有展开 fBm 的解析导数,而是在同一个 heightAt 函数的附近多采样几次:

// 顶点阶段;epsilon 是模型局部 x/y 单位,必须大于 0。
float hx = ( heightAt( position.xy + vec2( uEpsilon, 0.0 ) ) -
             heightAt( position.xy - vec2( uEpsilon, 0.0 ) ) ) /
           ( 2.0 * uEpsilon );
float hy = ( heightAt( position.xy + vec2( 0.0, uEpsilon ) ) -
             heightAt( position.xy - vec2( 0.0, uEpsilon ) ) ) /
           ( 2.0 * uEpsilon );
vec3 displacedNormalLocal = normalize( vec3( -hx, -hy, 1.0 ) );

这与计算两条有限差分切线再做叉积等价。例子每个顶点会为新法线额外查询四次高度,并为位置再查询一次;四层 fBm 与四角 hash 的成本要乘上这些次数。若已有可信的 noise 解析导数,可减少重复采样,但必须把它从 noise 输入坐标换算到局部 x/y 坐标。heightAt 中的 uFrequency 会影响导数,不能漏掉链式法则。

uEpsilon 太大,会把局部斜率平均得过于平滑;太小,浮点精度和高频层会使差值不稳定。示例提供步长滑块供比较。步长属于局部长度,不是 UV 或屏幕像素;若把平面宽度从 3 改成 30 而保持相同 noise 频率,合适的步长也要重新判断。

4. 把法线与光线放到同一空间

示例让光方向固定在观察空间,所以法线也要转到观察空间。顶点阶段:

// 顶点阶段;局部法线 → 观察空间法线。
vCorrectedNormalView = normalize( normalMatrix * displacedNormalLocal );
vOriginalNormalView = normalize( normalMatrix * normal );

normalMatrix 由 Three.js 为 ShaderMaterial 提供,等价于 modelViewMatrix 左上角 3×3 部分的逆转置,负责非等比缩放时的法线变换。法线是方向,不用 vec4(..., 1.0) 加上平移。varying 在三角形内部插值后,片元阶段还应再次 normalize,否则长度通常不再是 1。

// 片元阶段;normalView 和 uLightView 均在观察空间。
vec3 normalView = normalize( vCorrectedNormalView );
vec3 lightView = normalize( uLightView );
float diffuse = max( dot( normalView, lightView ), 0.0 );
vec3 color = baseColor * ( 0.18 + 0.82 * diffuse );

这是一种简化的 Lambert 漫反射,0.18 是人为加入的环境亮度。示例还加了一个观察方向相关的高光,用来放大法线差别。这里的 uLightView 是我们自己给 ShaderMaterial 的 uniform;它不是 Three.js 场景灯光自动注入的值。若光源位置在世界空间,应先把光方向变换到观察空间,或把法线转到世界空间,不能直接把两种空间的向量相点乘。

5. 顶点数和法线精度是两道限制

示例默认横向 96 段、纵向 64 段;几何体共约 97×65 个顶点。将横向降到 2,许多高频波峰没有顶点采到,表面轮廓就会消失或严重失真。片元内插值的法线只能影响明暗,不能补出缺失的轮廓。

上述差分法线是连续高度函数在顶点处的斜率近似,而 GPU 实际画出的网格面仍由顶点间的平面三角形构成。低分段时,差分法线不一定等于每个三角形的真实面法线;高分段时两者更接近。若项目要求严格匹配三角网格面,应根据位移后的顶点和三角形连接关系求面法线,或采用合适的离线/CPU 几何更新流程。这个差别也是低分段时看见“平滑亮暗盖在折面上”的原因。

法线可视化把观察空间 [-1,1] 分量映射到 [0,1] 作为 RGB,只是调试编码。转动网格后,同一局部法线会变成不同的观察空间颜色;要比较局部法线,应在变换之前显示。三种模式使用相同位移网格,唯一改变的是用于着色的法线。

常见问题

本章小结

顶点位移改变真实几何,旧法线不会自动更新。对局部高度场 z=h(x,y),新法线由 (-hx,-hy,1) 归一化得到;中心差分可估计斜率。计算出的局部法线要通过 normalMatrix 进入观察空间,再与同空间的光方向计算光照。网格分段决定能否画出形状,差分步长决定法线近似质量。

本章练习