15 · Vector3:旋转、矩阵变换与相机投影
本章接口全部修改接收者并返回 this,但对数据语义的假定各不相同。点受平移影响,位移不受平移影响,法线需要维持与切面的垂直关系。Vector3 没有类型系统替你区分这些语义,因此选择接口前先写出输入空间和输出空间。
applyEuler(euler)
用 THREE.Euler 描述的旋转变换当前向量,Euler 不修改。内部先转换为 Quaternion 再旋转;角度是弧度,旋转顺序由 euler.order 决定。
const v = new THREE.Vector3(1, 0, 0);
v.applyEuler(new THREE.Euler(0, 0, Math.PI / 2, 'XYZ')); // 约 (0,1,0)
对位置调用是绕坐标原点转,不是绕模型自身中心。改变旋转顺序可能产生不同结果;这个接口不会自动把局部旋转解释成世界旋转,空间含义来自输入数据。
applyAxisAngle(axis, angle)
围绕单位 axis 按 angle 弧度旋转,遵循右手定则。(1,0,0) 围绕 (0,1,0) 转 π/2 得约 (0,0,-1)。axis 不会自动归一化,零轴没有有效旋转含义。适合绕明确轴旋转一个偏移,若每帧给大量向量使用同一旋转,可先构造 Quaternion 并复用。
applyQuaternion(q)
使用单位 Quaternion 旋转当前向量,q 不修改。单位旋转保持长度和两向量间夹角;输入非单位 q 不满足该保证,因为 r185 实现明确假定其为单位四元数。
const rotation = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0), Math.PI / 2
);
const direction = new THREE.Vector3(0, 0, 1).applyQuaternion(rotation); // 约 (1,0,0)
对局部方向应用物体世界四元数可得到纯旋转后的世界方向。但若问题要求完整线性变换对切线的影响,非均匀缩放和剪切也应参与,不能只取四元数。
applyMatrix3(m)
计算列向量乘法 v'=Mv,不归一化、不做透视除法。矩阵列表示输入各基向量变换后的结果。
const scale = new THREE.Matrix3().set(2, 0, 0, 0, 3, 0, 0, 0, 1);
const v = new THREE.Vector3(1, 2, 3).applyMatrix3(scale); // (2,6,3)
对于三维向量,Matrix3 可以旋转、缩放、剪切但不能表达一般三维平移。对于二维齐次坐标 (u,v,1),同一尺寸矩阵却能表示二维平移,因为第三分量承担齐次角色。这种用法下如需一般投影映射,调用者还需自行除以变换后的第三分量;普通仿射 UV 变换保持它为 1。
applyNormalMatrix(m)
先调用 applyMatrix3,再 normalize。参数应是为相应空间变换计算的法线矩阵,而不是任意 Matrix3。例如模型局部法线转世界法线:
object.updateWorldMatrix(true, false);
normalMatrix.getNormalMatrix(object.matrixWorld);
worldNormal.copy(localNormal).applyNormalMatrix(normalMatrix);
若线性部分为 A,法线使用 (A⁻¹)ᵀ,因为这样才能让变换后的法线与切线仍点积为零。A 为 diag(2,1,1),法线 (1,1,0) 经逆转置后方向为 (0.5,1,0),归一化约 (0.447,0.894,0),不是普通变换后的 (0.894,0.447,0)。零缩放导致不可逆,没有有效法线矩阵可恢复坍缩的表面方向。
applyMatrix4(m)
把 Vector3 当作隐含 w=1 的点,乘 Matrix4 后执行透视除法。若 q=M(x,y,z,1),结果为 (q.x/q.w,q.y/q.w,q.z/q.w)。
const m = new THREE.Matrix4().makeTranslation(5, 0, 0);
new THREE.Vector3(1, 2, 3).applyMatrix4(m); // (6,2,3)
仿射矩阵的输出 w=1,因此平移正常生效。投影矩阵的 w 通常不同,此方法直接得到除法后的三分量,无法保留 clip.w;需要裁剪空间四维数据时应使用 Vector4。w=0 会产生 Infinity 或 NaN,不代表合法屏幕位置。不要拿这个接口直接变换速度或方向,否则会误加平移。
transformDirection(m)
参数是仿射 Matrix4,仅取左上 3×3 作用于方向,忽略平移,并归一化结果。例如平移矩阵不会改变 (1,0,0);缩放 diag(2,1,1) 会把 (1,1,0) 变为单位方向约 (0.894,0.447,0)。
所以它既不是“只提取旋转”,也不保留原长度。若要变换带长度的位移,提取 Matrix3 后使用 applyMatrix3;若要变换法线,用 applyNormalMatrix。它也不适合拿投影矩阵处理相机透视方向。
project(camera)
把世界空间点转换为相机 NDC:先乘 camera.matrixWorldInverse,再乘 camera.projectionMatrix。输入必须是世界点;返回值仍是当前 Vector3。对位于原点、看向 -Z、视锥对称的相机,世界点 (0,0,-5) 的 NDC x、y 均为 0。
camera.updateWorldMatrix(true, false);
const ndc = worldPoint.clone().project(camera);
const cssX = rect.left + (ndc.x + 1) * 0.5 * rect.width;
const cssY = rect.top + (1 - ndc.y) * 0.5 * rect.height;
rect 来自 canvas.getBoundingClientRect(),CSS Y 向下所以需翻转。更改 fov、aspect、near、far 等投影参数后必须调用 updateProjectionMatrix。WebGL 的 NDC 可见深度为 [-1,1];NDC 范围检查不包含场景遮挡判断,相机背面的点也要单独按观察空间或 clip.w 检查。
unproject(camera)
把 NDC 点转换到世界空间,依次使用 projectionMatrixInverse 与 matrixWorld。三个分量都参与,尤其 z 不是世界高度,也不是线性相机距离。
const nearPoint = new THREE.Vector3(ndcX, ndcY, -1).unproject(camera);
const farPoint = new THREE.Vector3(ndcX, ndcY, 1).unproject(camera);
const direction = farPoint.clone().sub(nearPoint).normalize();
这是 WebGL 相机的近远裁剪面两点,可以构造射线。鼠标只有 x、y,不足以唯一确定世界点,必须再与地面、模型或其他约束求交。透视相机的射线汇聚到相机位置;正交相机的射线起点随屏幕位置变化,方向平行。常规拾取优先使用 Raycaster.setFromCamera。
上述几何解释默认相机层级仅含旋转和平移。r185 更新视图矩阵时会剔除相机世界缩放,但 unproject 仍使用 matrixWorld,因此带缩放相机下 project/unproject 不保证互为逆操作。详见相机空间;日常通过 fov、zoom 或相机距离调整取景,不依赖相机 scale。
实践:围绕任意世界中心旋转一个点
import * as THREE from 'three';
const center = new THREE.Vector3(2, 0, 0);
const point = new THREE.Vector3(3, 0, 0);
const unitAxis = new THREE.Vector3(0, 1, 0);
point.sub(center).applyAxisAngle(unitAxis, Math.PI / 2).add(center);
console.log(point.toArray()); // 约 [2,0,-1]
减中心把旋转中心暂时变为原点;旋转后加回恢复世界坐标。这只更新点的位置,不会自动旋转模型朝向。若写回有父节点的模型,需要将世界点转换到父节点局部空间。
练习(暂不附答案)
- 用包含平移的矩阵分别调用 applyMatrix4 和 transformDirection,解释结果差异。
- 构造一个非均匀缩放后的切线和法线,验证普通矩阵与法线矩阵对垂直关系的影响。
- 将世界点 project 后再 unproject,测量误差;再故意漏掉相机矩阵更新观察结果。
- 用近远面两点构造鼠标射线,与
y=0平面求交,并处理射线平行和交点在反方向的情况。