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 的局部矩阵按以下顺序构造:

Mlocal=T(position)R(quaternion)S(scale). M_{local}=T(position)R(quaternion)S(scale).

因此先缩放局部模型、再旋转、最后平移。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,子节点满足:

Mworld,child=Mworld,parentMlocal,child. M_{world,child}=M_{world,parent}M_{local,child}.
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,就会让父变换再次作用,产生拖拽偏移或跳动。

练习

  1. 给车载传感器增加父缩放,先手算它的世界位置,再验证结果。
  2. 在渲染前修改父位置并直接读取 matrixWorld,比较更新前后值,解释状态何时变旧。
  3. 构造带剪切世界矩阵,做 decompose/compose 往返,比较元素误差与基向量夹角。
  4. 为现有模型建立“运动 Group → 外观 Mesh”的层级,让相机挂点不受外观缩放影响。