20 · Matrix3:基底提取与法线矩阵
本章代码默认 import * as THREE from 'three';。法线虽然也用 Vector3 保存,却有不同于位移的几何职责:它必须与变换后的表面保持垂直。这条约束决定了它的变换公式。
extractBasis(xAxis, yAxis, zAxis)
三个参数都是事先创建的 Vector3,方法把矩阵第一、第二、第三列分别写入这些向量。修改三个输出向量,不修改矩阵,返回矩阵自身。
const basis = new THREE.Matrix3().set(
2, 1, 0,
0, 3, 0,
0, 0, 1
);
const axisX = new THREE.Vector3();
const axisY = new THREE.Vector3();
const axisZ = new THREE.Vector3();
basis.extractBasis(axisX, axisY, axisZ);
// axisX=(2,0,0),axisY=(1,3,0),axisZ=(0,0,1)
它提取的是实际列向量,不会归一化,也不会正交化。上面的 X、Y 点积为 2,存在剪切。若只做 normalize(),长度会变成 1,但两轴仍不垂直。三个输出参数应为不同对象;传同一 Vector3 会被最后一次写入覆盖。二维齐次矩阵的第三列可能是平移,此时不要把它当成三维朝向 Z。
setFromMatrix4(matrix4)
接受 Matrix4,复制其左上角 3×3 到接收者,修改接收者并返回 this,不修改输入。对普通三维仿射矩阵而言,得到包含旋转、缩放、剪切的线性部分,去掉平移。
const world = new THREE.Matrix4().makeScale(2, 3, 4);
world.setPosition(10, 20, 30);
const linear = new THREE.Matrix3().setFromMatrix4(world);
console.log(new THREE.Vector3(1, 1, 1).applyMatrix3(linear).toArray());
// [2, 3, 4],不包含 (10,20,30) 的平移。
它不是“提取纯旋转”,也不是“计算法线矩阵”。对投影矩阵直接截取更不能代替完整投影,因为透视除法依赖第四分量。
法线为什么用逆转置
设表面切向量为 t,法线为 n,二者满足 nᵀt=0。位置差经过线性部分 A 后,切向量变成 At。要求变换后仍垂直,可取:
平移不影响位置差,所以不进入公式。纯旋转 R 满足 R⁻ᵀ=R,这解释了为什么只旋转模型时,错误地用普通方向矩阵处理法线也常常看不出问题;非均匀缩放会揭露错误。
getNormalMatrix(matrix4)
接受描述目标空间变换的 Matrix4,计算其左上角线性部分的逆转置,写入接收者,返回 this。等价于 setFromMatrix4(matrix4).invert().transpose()。它只构造矩阵,不会替你变换或归一化某条法线。
const transform = new THREE.Matrix4().makeScale(2, 1, 1);
const tangent = new THREE.Vector3(1, -1, 0);
const normal = new THREE.Vector3(1, 1, 0).normalize();
const linear = new THREE.Matrix3().setFromMatrix4(transform);
const normalMatrix = new THREE.Matrix3().getNormalMatrix(transform);
const transformedTangent = tangent.clone().applyMatrix3(linear);
const wrongNormal = normal.clone().applyMatrix3(linear).normalize();
const correctNormal = normal.clone().applyNormalMatrix(normalMatrix);
console.log(transformedTangent.dot(wrongNormal)); // ≈ 1.3416,不再垂直。
console.log(transformedTangent.dot(correctNormal)); // ≈ 0
忽略初始单位长度因子,正确法线方向是 (0.5,1,0),错误方向是 (2,1,0)。Vector3.applyNormalMatrix() 最后会归一化;单独 applyMatrix3() 不会。
如果传 object.matrixWorld,结果把局部法线变到世界空间;如果传 view * model,结果把局部法线变到相机空间。光照点积中的光方向必须在同一个空间。零缩放会令矩阵不可逆,r185 得到零矩阵;此时没有正常的三维逆变换,应该修正模型状态,而不是依赖归一化补救。
Shader 中的完整责任链
标准 ShaderMaterial 中,Three.js 提供的 normalMatrix 基于模型观察矩阵,产生相机空间法线:
// 顶点着色器:normal attribute 为几何体局部法线。
varying vec3 vViewNormal;
void main() {
vViewNormal = normalMatrix * normal;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// 片元着色器:uniform 必须也是相机空间方向。
uniform vec3 uLightDirectionView;
varying vec3 vViewNormal;
void main() {
vec3 normalView = normalize(vViewNormal);
vec3 lightView = normalize(uLightDirectionView);
float diffuse = max(dot(normalView, lightView), 0.0);
gl_FragColor = vec4(vec3(0.15 + 0.85 * diffuse), 1.0);
}
这里光方向约定为“从表面朝向光源”。varying 在三角形内插值后通常不再是单位向量,所以片元阶段需要再次归一化。RawShaderMaterial 需要自行声明对应输入;蒙皮和顶点位移还需要与其变形一致的法线处理,单独一个对象级法线矩阵不能修复所有局部变形。
镜像变换还会改变三角形绕序;逆转置保持垂直关系,但背面剔除、法线定向和双面材质是另外需要一致处理的问题。
练习
- 用上面数值计算验证正确法线与两条独立切线都垂直。
- 将非均匀缩放换成统一缩放,对比错误方案归一化后的表现,并解释原因。
- 对带剪切的矩阵提取基底,检查长度与两两点积,判断是否能直接作为纯旋转矩阵。
- 把光方向从世界空间转换到相机空间,旋转相机后验证光照仍固定在世界方向。