39 · csm_Normal 与 csm_FragNormal:两种不同层次的法线

返回系列目录 · 水面实例

法线表示表面朝向,而不只是一个配合位置使用的装饰变量。光照中的点积依赖它;位置变了但法线没变,轮廓和明暗便可能互相矛盾。本章依据 CSM 6.4.0 实现,特别纠正该版本 README 的类型笔误:csm_FragNormal 是 vec3,不是 float。

1. 两个输出的职责

输出 阶段与空间 默认值 实际接管位置
csm_Normal 顶点,物体局部空间 attribute normal beginnormal_vertex 中的 objectNormal 初值
csm_FragNormal 片元,观察空间 平滑法线 normalize(vNormal),或平面着色的导数法线 normal_fragment_maps 执行后覆盖 normal

csm_Normal 的替换还保留了 USE_TANGENT 下 objectTangent = tangent.xyz 的声明。它没有根据新法线重新生成切线;如果几何形变同时改变了切线基底,不能假设使用旧 tangent 的法线贴图仍准确。

支持范围也不能只看 README 的“有 normalMap 的材质”:Normal、Phong、Standard、Physical 等带对应法线流程的材质可以使用片元法线,即使没有提供 normalMap。Basic/Points 没有本章的光照法线消费链路,不应拿它们演示这个输出。

2. 顶点法线会继续参与内置变换

CSM 输出进入 objectNormal 后,还会通过 morph 法线、骨骼、实例/模型变换,再传到片元阶段。对于普通 Mesh,法线从局部到观察空间使用模型观察矩阵的逆转置(normalMatrix):

nview∝(MV)−Tnobject n_{view}\propto (MV)^{-T}n_{object}

非均匀缩放下不能直接乘普通位置矩阵,否则不再与切平面垂直。片元接收的是顶点 varying 的插值结果,它通常不再是单位长度;内置法线流程会再次归一化。

手动写 objectNormal = ... 的对应 CSM 写法是 csm_Normal = ...。它发生在法线变换前,所以不要先乘 normalMatrix,否则会重复转换。

3. 从水面偏导得到 csm_Normal

第 37 章曲面为 p(x,z)=(x,h(x,z),z)p(x,z)=(x,h(x,z),z)。两个切向量分别为:

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

为了朝向局部 +Y,取 tz×tx=(−hx,1,−hz)t_z\times t_x=(-h_x,1,-h_z)。水面代码由此得到:

float slopeX = uCsmWaveAmplitude * 0.65 * 1.7 * cos(phaseX);
float slopeZ = uCsmWaveAmplitude * 0.35 * 1.2 * cos(phaseZ);
csm_Normal = normalize(vec3(-slopeX, 1.0, -slopeZ));

这是解析法线:无需额外纹理采样,也不用每帧在 CPU 调用 computeVertexNormals()。后者只知道 CPU 几何,无法看到纯 GPU 位移。

解析法线表达连续曲面的切面,低分段网格的实际三角形仍是折线近似。法线再平滑也不能修复粗糙的轮廓;应根据波长与观察距离选细分数。

4. csm_FragNormal 覆盖的是最终普通表面法线

实际补丁为:

#include <normal_fragment_maps>
normal = csm_FragNormal;

这意味着原本的 normalMap/bumpMap 已经计算,但会被这里的值覆盖。自定义 main 在原片元主逻辑前运行,不能写 csm_FragNormal = normal 来读取尚未计算的内置贴图结果。

默认 csm_FragNormal 由 vNormal 或 vViewPosition 的导数构造,并非 normalMap 的结果。源码默认对平滑双面法线乘 gl_FrontFacing ? 1.0 : -1.0;自己全量赋值时,要自己处理所需的朝向逻辑。

覆写后没有替你保证单位长度,因此请输出归一化结果。该输出也不等于清漆层法线,Physical 的 clearcoat 有独立流程。

5. 实例拓展:给平面添加解析细波光照

在水面例子的顶点代码全局声明 varying vec3 vCsmSurfaceNormal;,并在其 main 尾部增加:

vCsmSurfaceNormal = normalMatrix * csm_Normal;

再给 CSM 传入以下 fragmentShader,便得到一个不使用 normalMap 的观察空间法线入口:

varying vec3 vCsmSurfaceNormal;
void main() {
  vec3 surfaceNormal = normalize(vCsmSurfaceNormal);
  #ifdef DOUBLE_SIDED
    surfaceNormal *= gl_FrontFacing ? 1.0 : -1.0;
  #endif
  csm_FragNormal = surfaceNormal;
}

此变体适用于本例的普通无骨骼 Mesh。它重新做了内置 varying 已能做的事,目的是验证空间;生产代码保留 csm_Normal 即可。若要增加更细的程序化凹凸,可把局部 xz 通过 varying 传到片元,在片元计算额外高度偏导,再将所得局部法线转换到观察空间。不得把局部法线直接写给 csm_FragNormal。

对于蒙皮或实例对象,上面直接 normalMatrix * csm_Normal 的自定义 varying 不包含全部内置法线变换,不能照搬。需要保留原法线贴图并在它之后加工时,手动补丁或精确的 patchMap 往往比这个覆盖输出更适合。

练习(暂不提供答案)

  1. 对水面施加非均匀 scale,比较 csm_Normal 与错误的观察空间提前转换结果。
  2. 将叉积顺序反转,解释法线与双面渲染所呈现的变化。
  3. 为片元增加局部高频高度函数,同时明确它只改变光照、不改变轮廓。
  4. 给材质添加 normalMap,再分别使用与不使用 csm_FragNormal,分析贴图结果为何被覆盖。