23 · 变形之后如何修正法线

返回系列目录

顶点位置控制几何轮廓,法线控制表面朝向。GPU 修改 position 后不会重新执行 geometry.computeVertexNormals(),所以一块起伏的水面可能仍用“处处朝上”的旧法线计算光照。修正法线要来自变形后的曲面,而不是随便给 normal 加同样偏移。

1. 从切线推导水面法线

在 XZ 平面上定义高度场:

p(x,z)=(x,h(x,z),z),h=Asin⁡(kx+ωt) p(x,z)=(x,h(x,z),z),\quad h=A\sin(kx+\omega t)

沿 x 和 z 的两条切线为:

tx=(1,hx,0),tz=(0,hz,1) t_x=(1,h_x,0),\quad t_z=(0,h_z,1)

选择朝上的叉积顺序,tz×tx=(−hx,1,−hz)t_z\times t_x=(-h_x,1,-h_z)。因此:

n=normalize⁡(−Akcos⁡(kx+ωt),1,0) n=\operatorname{normalize}(-Ak\cos(kx+\omega t),1,0)

幅度 A 决定高低,频率 k 也影响坡度。即便振幅不变,加密波纹也会让法线偏得更厉害,因此视觉上高光更破碎。这是公式与视觉效果的直接联系。

2. r185 中法线先于位置计算

Standard/Physical 顶点主函数的相关顺序是:

#include <beginnormal_vertex>
#include <morphnormal_vertex>
#include <skinbase_vertex>
#include <skinnormal_vertex>
#include <defaultnormal_vertex>
#include <normal_vertex>
#include <begin_vertex>

在 beginnormal_vertex 后,objectNormal 已声明,但 transformed 尚未声明。若在这里使用 transformed.x,将产生编译错误。反过来,在 begin_vertex 后改 objectNormal 又太晚:transformedNormal 和输出 varying 已经计算完成。

对本章没有 morph、skinning 的平面,应在 beginnormal_vertex 后用原始 position 计算法线,在 begin_vertex 后用相同 position 对应的函数计算位移。

// 紧接 beginnormal_vertex,仍是局部空间法线。
float demoSlope = uDemoAmplitude * uDemoFrequency *
  cos(uDemoFrequency * position.x + uDemoSpeed * uDemoTime);
objectNormal = normalize(vec3(-demoSlope, 1.0, 0.0));

保留 defaultnormal_vertex,让 Three.js 执行实例/批次相关处理、normalMatrix 变换及背面处理。不要再手动乘一次 normalMatrix,否则会重复变换。

完整安装函数在 deformation.js,可运行场景在 water-waves.js。这些函数明确只适用于相应平面参数化,不是任意网格通用法线修正器。

3. 法线如何到达片元

顶点阶段的局部 objectNormal 经过 normalMatrix 进入观察空间,再作为 varying 送到片元。GPU 对三角形内部插值,插值后的长度通常不为 1。内置 normal_fragment_begin 会建立片元法线,后续 normal_fragment_maps 还可能叠加 normalMap 或 bumpMap。

因此“我已经在顶点 normalize 过”并不能取消片元归一化。也不能把世界空间法线直接赋给片元中的观察空间 normal。

如果存在显式 tangent attribute,顶点变形后 tangent 也可能需要更新。只更新 objectNormal 而保留原切线,会让切线空间法线贴图方向失真。本系列基础波面不附切线属性或法线贴图;添加它们时必须扩展整套切线基底,而不只是复制一个 normalMap 参数。

4. 复杂函数:有限差分

解析求导太复杂时,可以对同一个变形函数 D 采样邻近点。以 XZ 参数化平面为例:

vec3 demoCenter = demoDeform(position);
vec3 demoTx = demoDeform(position + vec3(uDemoEpsilon, 0.0, 0.0)) - demoCenter;
vec3 demoTz = demoDeform(position + vec3(0.0, 0.0, uDemoEpsilon)) - demoCenter;
objectNormal = normalize(cross(demoTz, demoTx));

epsilon 的单位是局部长度。太大漏掉细节,太小会因浮点相减损失精度;把模型尺寸、波长和数值范围纳入选择。中心差分使用正负两侧采样更准确,但函数调用更多。复杂噪声的每次调用都可能很贵,还要把阴影 pass 的位置变形成本计入预算。

任意网格不能固定使用局部 X、Z 作为表面切线。应从原表面切线基底取两个方向,或针对特定变形推导雅可比 JJ,用 J−TnJ^{-T}n 更新法线;J 奇异时法线也可能无法稳定定义。

5. 片元导数的作用与限制

dFdx、dFdy 可以从观察空间表面位置导出几何法线,适合获得三角面法线;内置 flatShading 分支便使用相关思想。它反映的是实际三角形,不会凭空补出顶点网格没有表示的波面细节,也不能让轮廓变细腻。导数位于片元阶段,应避免在非一致控制流里计算依赖导数的表达式。

验证与练习

用单盏斜向 DirectionalLight 和低粗糙度材质观察高光;幅度设 0 时应恢复平面法线。再将 Mesh 做非均匀缩放,正确保留的 normalMatrix 链路应该继续提供相容结果。

  1. 推导 h=Asin⁡(kx+ωt)+Bcos⁡(qz)h=A\sin(kx+\omega t)+B\cos(qz) 的法线。
  2. 将解析法线换为有限差分,比较三个 epsilon 的结果和开销。
  3. 删除法线补丁,只保留位移,解释哪种观察角度最容易看出错误。
  4. 找出 transformed 在法线阶段不可用的原因,并设计不依赖它的共享计算函数。