05 · Clip Space、NDC 与屏幕坐标
本章针对项目的 WebGLRenderer 默认坐标约定,不把 WebGPU 的深度范围混入同一套公式。
1. 投影先生成四维裁剪坐标
透视投影让近处物体显得大、远处物体显得小。它通过投影矩阵和后续透视除法完成:
常规透视矩阵中 w_clip = -z_view。因此相机前方越远的点 w 越大,除法后横纵坐标越靠近中心。正交投影中通常 w=1,同样大小的物体不会随距离缩小。
import * as THREE from 'three';
const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100);
camera.updateWorldMatrix(true, false);
const clip = new THREE.Vector4(1, 0, -5, 1).applyMatrix4(camera.projectionMatrix);
const ndc = new THREE.Vector3(clip.x, clip.y, clip.z).divideScalar(clip.w);
console.log(clip.w); // 5
console.log(ndc.x); // 约 0.3464
将 view.z 改为 -10,ndc.x 约减半。这就是透视缩小,不是模型在世界空间里真的缩放了。
2. 裁剪与 NDC 的边界
WebGL 的标准齐次裁剪体满足 -w ≤ x,y,z ≤ w。GPU 在透视除法前处理图元裁剪;一个三角形的某个顶点在外面,不等于整个三角形都要丢弃。
透视除法后的可见范围通常是 x、y、z 各在 [-1,1]。NDC 的 z 不是线性世界距离,深度缓冲值通常再映射到 [0,1]。不能用 ndc.z * far 恢复真实深度。
w 接近零时不要直接用投影结果布局 DOM;相机背后的点还可能投影到看似合理的横纵坐标。显示标签时应同时检查 view.z 和 NDC 范围。
3. 从 NDC 到浏览器 client 坐标
NDC 原点在视口中心、Y 向上;浏览器 client 坐标原点在浏览器内容视口左上、Y 向下。对于没有旋转、边框和内边距的画布:
function ndcToClient(ndc, canvas) {
const rect = canvas.getBoundingClientRect();
return {
x: rect.left + (ndc.x + 1) * rect.width / 2,
y: rect.top + (1 - ndc.y) * rect.height / 2
};
}
getBoundingClientRect() 与 event.clientX/clientY 都使用 CSS 像素,因此这里不乘 devicePixelRatio。只有读取渲染缓冲或设置以实际像素为单位的区域时,才需要转换成 drawing buffer 坐标,并确认 Y 原点。
如果 DOM 标签相对于父容器绝对定位,应再减去该容器的 client 原点。若画布采用 scissor 或多个 viewport,公式需使用对应子视口的矩形,而不是整个 canvas。
4. 改变窗口尺寸时的职责
透视相机要同步 camera.aspect 并调用 updateProjectionMatrix();renderer 要更新尺寸。仅改画布 CSS 会拉伸画面,且鼠标拾取可能不再与画面匹配。正交相机则应根据业务更新 left/right/top/bottom。
near 应大于 0(透视相机),并尽量避免 near 极小而 far 极大;这会浪费深度精度,产生表面闪烁。修改投影参数后必须更新投影矩阵。
练习(不附答案)
- 给定 rect 为 left=100、top=50、width=800、height=400,计算 NDC 四角和中心的 client 坐标。
- 用相同 view 点对比透视与正交投影,改变深度后观察 x 的变化。
- 解释为什么高 DPI 屏幕下,DOM 标签位置不应该再乘 pixelRatio。
- 找一个位于相机背后的点,检验仅检查 NDC.x/y 是否会误判。