35 · 修复模型缩放后的光照异常
为什么位置矩阵不能直接变换法线
模型经过非均匀缩放后,表面切线方向改变。法线的职责不是“和物体一起转一下”,而是始终与变换后的切线垂直。
设线性变换为 A,原切线 t 与法线 n 满足 nᵀt = 0。希望变换后满足:
取 n' = A^{-T}n 就能保留垂直关系。A 在本例中来自模型到相机空间的 modelViewMatrix 左上 3×3 部分。Matrix3.getNormalMatrix(modelViewMatrix) 求它的逆转置;ShaderMaterial 的内建 normalMatrix 由 Three.js 在绘制对象时提供。
例子:t=(1,1,0)、n=(1,-1,0),A 把 x 放大 2 倍。错误法线 (2,-1,0) 与新切线 (2,1,0) 点积为 3;正确法线 (0.5,-1,0) 与它点积为 0。最后再归一化,不会改变垂直关系。
完整可比较示例
左边球使用错误矩阵,右边球使用法线矩阵。灯光方向在 JavaScript 中从世界空间转换到相机空间,确保片元中的两个向量处于同一坐标空间。
import { createDemo } from './runtime.js';
const { THREE, scene, camera, onFrame } = createDemo();
const worldLightDirection = new THREE.Vector3(-1, 1, 2).normalize();
const viewLightDirection = new THREE.Vector3();
const geometry = new THREE.SphereGeometry(1, 32, 24);
const materials = [];
function makeMaterial(correct) {
const material = new THREE.ShaderMaterial({
uniforms: {
uCorrect: { value: correct },
uLightDirectionView: { value: new THREE.Vector3() }
},
vertexShader: `
uniform bool uCorrect;
varying vec3 vNormalView;
void main() {
vec3 transformedNormal;
if (uCorrect) transformedNormal = normalMatrix * normal;
else transformedNormal = mat3(modelViewMatrix) * normal;
vNormalView = normalize(transformedNormal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uLightDirectionView;
varying vec3 vNormalView;
void main() {
vec3 normalView = normalize(vNormalView);
float diffuse = max(dot(normalView, normalize(uLightDirectionView)), 0.0);
vec3 color = vec3(0.18, 0.5, 0.8) * (0.12 + diffuse * 0.88);
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`
});
materials.push(material);
return material;
}
const wrong = new THREE.Mesh(geometry, makeMaterial(false));
const correct = new THREE.Mesh(geometry, makeMaterial(true));
wrong.position.x = -2.3;
correct.position.x = 2.3;
wrong.scale.set(1.8, 0.7, 1);
correct.scale.copy(wrong.scale);
scene.add(wrong, correct);
onFrame(() => {
camera.updateWorldMatrix(true, false);
viewLightDirection.copy(worldLightDirection).transformDirection(camera.matrixWorldInverse);
materials.forEach((material) => {
material.uniforms.uLightDirectionView.value.copy(viewLightDirection);
});
});
数据流与插值
SphereGeometry 创建 position 和 normal BufferAttribute,每个顶点有一组局部坐标与局部法线。渲染器把它们提供给顶点着色器;每个对象对应的 modelViewMatrix、normalMatrix 也在绘制时更新。顶点着色器把法线写入 vNormalView。
三角形内部的片元没有独立顶点法线,它接收光栅化阶段插值得到的 varying。默认透视校正插值产生的是向量分量的加权结果,不保证长度为 1,即使三个顶点法线都已归一化。因此片元阶段必须再次 normalize(),然后与从表面指向光源的单位方向做点积。max(..., 0) 排除背光面的负值。
本例 bool uniform 仅用来切换实验方案,实际材质通常直接采用正确路径。由于整次绘制的 uniform 相同,它不是每个片元随机选择不同分支的情形。
验证与局限
恢复等比例缩放,左右明暗应接近;增大 x/y 缩放差异,错误应更明显。旋转相机时,固定世界方向的灯光不能跟着相机漂移。把缩放设为零会使矩阵不可逆,零面积表面也没有可靠法线,不能靠归一化修复。
Shader 改变顶点位置时,原始几何法线可能不再适用,需要解析求导、邻域采样或屏幕导数等方法重建。镜像缩放与双面材质还涉及绕序、正反面法线翻转;本例只用正缩放、单面闭合网格。
练习(暂不提供答案)
- 用代码验证上述切线和法线的数值例子。
- 删除片元中的归一化,降低球体分段,观察差别。
- 让光源方向随时间旋转,保持光照计算处于相机空间。
- 将计算改到世界空间,列出需要修改的矩阵和 uniform。