06 · 坐标转换完整链路与逆向转换

返回目录 · 上一章 · 下一章

1. 正向链路:一个设备挂点如何落到页面

本章假定相机及父层级无缩放和剪切,使用默认 WebGL 深度约定、完整画布视口,画布没有 CSS 旋转、边框或内边距。标签示例还假定显示区域位于相机前方;正交相机若特意配置跨越相机平面的裁剪区,应按自己的视体定义可见性。

把局部点补成 (x,y,z,1),依次乘模型、视图和投影矩阵:

pclip=PVMplocal p_{clip}=PVMp_{local}

透视除法后再映射到画布矩形。下面函数返回 client 坐标以及粗略可见性,不负责遮挡测试:

import * as THREE from 'three';

function localPointToClient(localPoint, object, camera, canvas) {
  object.updateWorldMatrix(true, false);
  camera.updateWorldMatrix(true, false);
  const world = object.localToWorld(localPoint.clone());
  const view = world.clone().applyMatrix4(camera.matrixWorldInverse);
  const clip = new THREE.Vector4(view.x, view.y, view.z, 1)
    .applyMatrix4(camera.projectionMatrix);
  if (Math.abs(clip.w) < 1e-8) return null;
  const ndc = new THREE.Vector3(clip.x, clip.y, clip.z).divideScalar(clip.w);
  const rect = canvas.getBoundingClientRect();
  return {
    x: rect.left + (ndc.x + 1) * rect.width / 2,
    y: rect.top + (1 - ndc.y) * rect.height / 2,
    visible: view.z < 0 && Math.abs(ndc.x) <= 1 &&
      Math.abs(ndc.y) <= 1 && Math.abs(ndc.z) <= 1
  };
}

也可以将世界点 clone().project(camera) 直接得到 NDC;拆开写是为了保留 camera space 和 clip.w,方便教学和排错。投影参数改变后,调用者要先执行 camera.updateProjectionMatrix()。

2. 逆向链路需要保留深度

若已经知道完整 NDC 三分量,则可以反投影:

qworld=CP−1(xndc,yndc,zndc,1)T q_{world}=C P^{-1}(x_{ndc},y_{ndc},z_{ndc},1)^T

最后除以得到的齐次 w。Vector3.unproject(camera) 封装了这条路径。

这里 C 必须与视图矩阵互逆。r185 的相机视图矩阵会剔除世界缩放,而 unproject() 仍使用 camera.matrixWorld;存在相机缩放时,不应假定 project/unproject 能直接精确往返。保持相机层级为刚体变换是本系列采用的解决方式,详见Camera Space。

const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100);
camera.position.set(0, 0, 5);
camera.updateWorldMatrix(true, false);
const world = new THREE.Vector3(1, 2, -3);
const ndc = world.clone().project(camera);
const restored = ndc.clone().unproject(camera);
console.log(restored.distanceTo(world)); // 接近 0,而非要求逐分量完全相等

这里能恢复,是因为 ndc.z 没有丢失。鼠标只有 x、y,通常不知道深度,所以不能唯一确定一个世界点。

3. 鼠标确定的是一条射线

同一个屏幕点对应视线上的许多世界位置。先把 client 坐标变到 NDC,再构造射线:

function rayFromPointer(event, canvas, camera) {
  const rect = canvas.getBoundingClientRect();
  if (rect.width === 0 || rect.height === 0) return null;
  const pointer = new THREE.Vector2(
    (event.clientX - rect.left) / rect.width * 2 - 1,
    1 - (event.clientY - rect.top) / rect.height * 2
  );
  camera.updateWorldMatrix(true, false);
  const raycaster = new THREE.Raycaster();
  raycaster.setFromCamera(pointer, camera);
  return raycaster.ray.clone();
}

透视相机的射线从相机位置向外发散;正交相机的射线方向平行,起点随屏幕点变化。不能把“从相机位置指向 unproject 结果”无条件用于正交相机。

用射线与地面相交,就为缺少的深度增加了几何约束:

const ground = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
// ray 为上面函数返回的 THREE.Ray,处于世界空间。
function hitGround(ray) {
  return ray ? ray.intersectPlane(ground, new THREE.Vector3()) : null;
}

射线平行于地面、或交点位于射线反方向时,可能返回 null。真实拖拽必须保留这种分支,而不是把 null 当坐标赋给模型。

4. 逆向转换不是撤销所有视觉过程

投影逆矩阵只能撤销几何投影,不能恢复已丢失的深度、遮挡层次、抗锯齿、后处理变形或镜头畸变。若画面经过扭曲后处理,直接使用原始 NDC 拾取还需额外逆映射。

练习(不附答案)

  1. 对一组世界点执行 project → unproject,记录最大距离误差,并改变 near/far。
  2. 找出位于同一视线上的两个不同世界点,验证其 NDC.x/y 相同但 z 不同。
  3. 将地面改为 y=2,使用 Plane 的 normal 和 constant 正确表示它。
  4. 实现“点击屏幕,在墙面 z=-3 上放置标记”,处理无交点情况。