25 · Matrix4、Object3D 与父子层级
代码默认 import * as THREE from 'three';。Matrix3 对三维线性部分十分合适,而位置还需要平移。Matrix4 用齐次坐标统一表达三维缩放、旋转、平移及投影;Object3D 把这些数学操作组织为可维护的场景层级。
TRS 与四维齐次坐标
点写为 (x,y,z,1),方向写为 (x,y,z,0)。普通仿射矩阵的最后一行为 (0,0,0,1),其第四列包含平移。Object3D 的局部矩阵按以下顺序构造:
因此先缩放局部模型、再旋转、最后平移。position 是相对父节点的位置;rotation 与 quaternion 是同一局部旋转的两种同步表示;scale 也是局部轴上的倍率。
const position = new THREE.Vector3(10, 0, 0);
const orientation = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 0, 1), Math.PI / 2
);
const scale = new THREE.Vector3(2, 1, 1);
const matrix = new THREE.Matrix4().compose(position, orientation, scale);
console.log(new THREE.Vector3(1, 0, 0).applyMatrix4(matrix).toArray());
// ≈ [10, 2, 0]:先变为 (2,0,0),再到 (0,2,0),最后加平移。
compose(position,quaternion,scale) 写入接收者并返回 Matrix4,输入保持不变。decompose(position,quaternion,scale) 把结果写入三个输出对象;它适用于可分解 TRS,不能保证任意剪切矩阵精确往返。
世界矩阵如何从父节点传下来
根节点 matrixWorld=matrix,子节点满足:
const vehicle = new THREE.Group();
vehicle.position.set(10, 0, 0);
vehicle.rotation.y = Math.PI / 2;
const sensor = new THREE.Object3D();
sensor.position.set(0, 0, 2);
vehicle.add(sensor);
vehicle.updateWorldMatrix(true, true);
const sensorWorld = new THREE.Vector3();
sensor.getWorldPosition(sensorWorld);
console.log(sensorWorld.toArray()); // ≈ [12, 0, 0]
传感器的 position 仍然是 (0,0,2),意思是车辆前方两单位。不能简单把父子 position 相加,因为父旋转和缩放会改变这段偏移。
点、方向和法线分别如何转换
object.localToWorld(point) 原地把局部点变到世界,并返回该点;worldToLocal(point) 做相反操作。两者会更新所需世界矩阵,但对象关闭自动矩阵管理时仍需正确维护状态。
const localMount = new THREE.Vector3(0, 0.5, 0);
const worldMount = sensor.localToWorld(localMount.clone());
const recovered = sensor.worldToLocal(worldMount.clone());
方向使用矩阵线性部分。direction.transformDirection(object.matrixWorld) 去掉平移并归一化,适合只关心朝向;如果向量代表带长度的速度,这个方法会丢失长度,应使用提取的 Matrix3 变换,或根据业务单独控制世界速度。法线则使用 getNormalMatrix()。
getWorldQuaternion(target)、getWorldScale(target) 会通过世界矩阵分解得到结果;在含剪切层级中,不要把这些结果重新 compose 后当成精确原矩阵。
矩阵什么时候更新
默认 matrixAutoUpdate=true,updateMatrix() 根据 position/quaternion/scale 更新局部 matrix,并标记世界矩阵需要更新。它本身不完成整个层级的世界矩阵更新。
updateMatrixWorld(force) 更新当前节点及后代;它不主动递归更新祖先,所以要么从根调用,要么确认父节点已经最新。
updateWorldMatrix(updateParents,updateChildren,force=false) 在 r185 支持第三个强制参数,可以选择更新祖先、后代。立即读取直接的 matrixWorld.elements 前,可使用 object.updateWorldMatrix(true,false);需要后代全部最新时从相关根节点调用 updateWorldMatrix(true,true)。
matrixWorldAutoUpdate=false 表示由你管理世界矩阵,关闭它后强制更新也不会替你自动重算该节点的 matrixWorld。对于普通应用,保留默认管理最容易正确。
手动矩阵模式
如果变换完全由外部矩阵提供,可以关闭局部 TRS 自动更新:
object.matrixAutoUpdate = false;
object.matrix.copy(externalMatrix); // externalMatrix 为已有 Matrix4。
object.matrixWorldNeedsUpdate = true;
object.updateWorldMatrix(true, true, true);
此时 position 等属性可能不再与 matrix 一致,也不会自动驱动渲染。不要一会儿写 matrix、一会儿写 position,却没有明确同步策略;自动更新重新开启后,会从 TRS 覆盖手写矩阵。
非均匀缩放、旋转与剪切
考虑父节点沿 X 放大两倍,子节点旋转 45°。父缩放发生在子旋转的输出侧,子物体的世界基向量可能不再垂直。其矩阵是合法仿射变换,却不一定能由一个旋转加三个独立倍率精确表达。
const parentScale = new THREE.Matrix4().makeScale(2, 1, 1);
const childRotation = new THREE.Matrix4().makeRotationZ(Math.PI / 4);
const world = parentScale.clone().multiply(childRotation);
const axisX = new THREE.Vector3().setFromMatrixColumn(world, 0);
const axisY = new THREE.Vector3().setFromMatrixColumn(world, 1);
console.log(axisX.dot(axisY)); // ≈ -1.5,已经不是正交基。
这影响 decompose()、getWorldQuaternion()、保持世界变换的重新挂接及某些 lookAt() 场景。Object3D.attach(child) 用于换父节点并尽量保持世界变换,但官方明确不支持含非均匀缩放节点的相关层级。镜像和零缩放也需要额外考虑,尤其是可逆性和法线方向。
工程上可以把“运动层级”保持为平移和旋转,把外观非均匀缩放放到最终 Mesh;这样子传感器、相机和操作控件不受缩放污染。若确实需要剪切,使用矩阵作为权威数据,并接受部分 TRS 便捷接口不适用。
实践:把世界目标写入嵌套模型的位置
拖拽通常算出世界坐标,而 object.position 需要父局部坐标:
function setWorldPosition(object, desiredWorldPosition, scratch) {
scratch.copy(desiredWorldPosition);
if (object.parent) object.parent.worldToLocal(scratch);
object.position.copy(scratch);
}
scratch 是预先分配的 Vector3。这里父矩阵必须可逆,object 采用默认 TRS 管理;若直接把世界坐标赋值给嵌套 position,就会让父变换再次作用,产生拖拽偏移或跳动。
练习
- 给车载传感器增加父缩放,先手算它的世界位置,再验证结果。
- 在渲染前修改父位置并直接读取 matrixWorld,比较更新前后值,解释状态何时变旧。
- 构造带剪切世界矩阵,做 decompose/compose 往返,比较元素误差与基向量夹角。
- 为现有模型建立“运动 Group → 外观 Mesh”的层级,让相机挂点不受外观缩放影响。