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。要求变换后仍垂直,可取:

n′=A−Tn,(n′)T(At)=nTA−1At=nTt=0. n'=A^{-T}n, \qquad (n')^T(At)=n^TA^{-1}At=n^Tt=0.

平移不影响位置差,所以不进入公式。纯旋转 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 需要自行声明对应输入;蒙皮和顶点位移还需要与其变形一致的法线处理,单独一个对象级法线矩阵不能修复所有局部变形。

镜像变换还会改变三角形绕序;逆转置保持垂直关系,但背面剔除、法线定向和双面材质是另外需要一致处理的问题。

练习

  1. 用上面数值计算验证正确法线与两条独立切线都垂直。
  2. 将非均匀缩放换成统一缩放,对比错误方案归一化后的表现,并解释原因。
  3. 对带剪切的矩阵提取基底,检查长度与两两点积,判断是否能直接作为纯旋转矩阵。
  4. 把光方向从世界空间转换到相机空间,旋转相机后验证光照仍固定在世界方向。