08 · 顶点法线与片元法线
本章依据 Three.js r185。修改顶点位置后,光照不会自动知道新的曲面方向。法线是光照计算的几何输入,而不是从最终屏幕轮廓随时反推的附属数据。内置材质先走顶点法线链路,再走位置链路,这是选择注入点时尤其容易忽略的顺序。
1. 法线为什么需要另一套变换
若曲面切向量 t 与法线 n 满足 dot(n,t)=0,线性变换 A 后切线为 At。要保持垂直,法线应变为 transpose(inverse(A)) * n,因为:
因此方向和法线在有非均匀缩放时不能使用同一变换。Three.js 的 normalMatrix 是模型视图矩阵线性部分的逆转置,输出法线处于观察空间;不要把它与世界空间太阳方向直接点乘。
2. beginnormal_vertex:原始法线入口
它在顶点 main 中创建 objectNormal = vec3(normal),若 USE_TANGENT 开启,还创建 objectTangent = tangent.xyz。输入 normal/tangent 来自几何 attribute,输出仍处于模型局部空间。
可以在 include 后给 objectNormal 设置解析值,保留后续内置空间变换。不能在此使用 transformed,因为位置链路还未声明它。如果需要曲面当前位置,用 position 或与后续位移共享的函数重新求值。
3. morphnormal_vertex:形态目标法线
当 USE_MORPHNORMALS 开启,模块先应用 morphTargetBaseInfluence,再累计 getMorph(gl_VertexID, i, 1) 乘目标权重。它要求 morph 声明和权重数据已建立,与位置目标使用对应的权重。
在它之前覆盖 objectNormal,后面仍会继续形态混合;若希望法线完全来自自定义曲面,需要选择它之后的位置,并明确这样会取代模型已有的 morph normal 语义。只删除法线 morph、保留位置 morph,表情或膨胀时就可能出现不随形状变化的高光。
4. skinbase_vertex:读取骨骼矩阵
这个名字虽然出现在法线流程中,但它同时服务位置流程。它根据 skinIndex 读取 boneMatX/Y/Z/W,供后面的 skinnormal 和 skinning 使用。它本身不改变法线。
不能因为“我的效果只改位置”就删除这段。四个骨骼矩阵是后续变量依赖。对于没有 USE_SKINNING 的材质,宏会使其无实际代码。
5. skinnormal_vertex:蒙皮后的局部法线
它按 skinWeight 合成 skinMatrix,并通过 bindMatrixInverse、bindMatrix 处理绑定空间,使用 vec4(objectNormal, 0.0) 变换法线;tangent 在启用时也变换。w=0 排除平移。
这里采用骨骼蒙皮的内置实现,不能从中推断“任意含非均匀缩放的法线都可以直接乘 mat4”。一般法线规则仍是逆转置;复杂骨骼缩放的行为需要单独验证。修改骨骼后再追加自定义变形时,法线也必须描述同一个组合后的曲面。
6. defaultnormal_vertex:进入观察空间
模块从 objectNormal 创建 transformedNormal,处理 batching/instancing,再乘 normalMatrix,最后处理 FLIP_SIDED。实例矩阵部分会按列长度平方校正缩放,源码明确不支持实例矩阵中的 shear。若启用 tangent,也生成观察空间 transformedTangent。
这通常是自定义局部法线的最终交接点:在它之前完成 objectNormal 修改,让原逻辑负责实例、模型和相机变换。直接在它之后写一个局部空间常量给 transformedNormal,会让光照随着相机旋转异常变化。
7. normal_vertex:发送法线与切线基底
非 FLAT_SHADED 情况下,它写入 vNormal = normalize(transformedNormal);若有 tangent,则输出 vTangent 和由叉积、tangent.w 构造的 vBitangent。输出在观察空间,通过光栅化插值到片元。
单位向量的线性插值通常不再是单位长度,所以顶点 normalize 并不意味着片元可以省略 normalize。修改 transformedNormal 应在此模块之前;之后改它,vNormal 已经取走旧值。
8. normal_fragment_begin:建立片元几何法线
片元输入包括 vNormal 或 vViewPosition,以及正反面信息。平滑着色分支归一化 vNormal;平面着色分支用 dFdx(vViewPosition) 与 dFdy(vViewPosition) 的叉积构造逐面法线。双面材质根据 faceDirection 翻转相应方向。
它还在需要切线空间法线、clearcoat 法线或 anisotropy 时构造 tbn;没有 tangent attribute 时借助位置、UV 的屏幕导数构造切线框架。最后保存 nonPerturbedNormal = normal,供几何粗糙度和 clearcoat 等流程使用。
因此在它之后仅修改 normal,不一定同步 tbn 或 nonPerturbedNormal。需要改变“基础几何方向”时,优先在顶点链路处理;如果只想加表面微小扰动,可以在法线贴图之后修改片元 normal。
9. normal_fragment_maps:表面细节法线
它按互斥分支应用对象空间 normalMap、切线空间 normalMap 或 bumpMap。对象空间贴图会覆盖几何法线,并用 normalMatrix 转到观察空间;切线空间贴图从 [0,1] 解码到 [-1,1],以 normalScale 缩放 XY,再乘 tbn;bump 则从高度变化扰动法线。
normalMap 是数据纹理,不应标记为 sRGBColorSpace。vec3(0.5,0.5,1.0) 编码的是切线空间近似 (0,0,1)。把两张法线贴图的 RGB 直接混合仅是受限近似;需要至少在解码后处理并归一化,更复杂叠层通常使用专门的法线混合方法。
若追加 normal = normalize(normal + vec3(...)),这个增量必须是观察空间量,而且可能破坏与 clearcoat 法线的关系。应说明是艺术扰动,不能把任意相加当成严格的曲面法线推导。
10. 实例:解析水面法线
设已把 PlaneGeometry 的顶点旋转到局部 XZ 平面,朝向 +Y,且没有 morph、skinning、displacementMap:
切线分别是 (1, hx, 0)、(0, hz, 1);选择产生 +Y 的叉积顺序得到 (-hx,1,-hz)。以下注入同时定义高度与斜率:
shader.vertexShader = shader.vertexShader.replace(
'#include <common>',
`#include <common>
uniform float uDemoTime;
uniform float uDemoAmplitude;
uniform float uDemoFrequency;`
);
shader.vertexShader = shader.vertexShader.replace(
'#include <beginnormal_vertex>',
`#include <beginnormal_vertex>
float demoSlope = uDemoAmplitude * uDemoFrequency
* cos(position.x * uDemoFrequency + uDemoTime);
objectNormal = normalize(vec3(-demoSlope, 1.0, 0.0));`
);
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
`#include <begin_vertex>
transformed.y += uDemoAmplitude
* sin(position.x * uDemoFrequency + uDemoTime);`
);
uniform 的 JavaScript 注入与动画更新见后续章节。若只是把 mesh.rotation 旋转到水平而没有旋转 geometry,position 仍在原始 XY 平面,上式便不适用。观察时使用侧向光和有粗糙度的材质;关掉法线修正做对照,最容易发现“轮廓动了、高光不动”。
这里已解决主材质的局部表面方向,但阴影深度程序必须应用相同位移,CPU 拾取仍不知道 GPU 波面。不要把一段法线修复代码描述为完整水系统。
练习(暂不提供答案)
- 推导同时含 x 波与 z 波时的法线,并验证振幅为零时法线回到 +Y。
- 对水面施加非均匀缩放,比较覆盖 transformedNormal 与覆盖 objectNormal 的结果。
- 开启 flatShading,解释为什么仅修改 objectNormal 不再直接决定片元基础法线。
- 开启 clearcoatNormalMap,追踪 normal、nonPerturbedNormal、clearcoatNormal 三个变量为何可能不同。