坐标系是一个用于描述三维空间中点和对象位置的数学工具。Three.js 坐标系采用右手坐标系,并内置了一些特殊的约定和规则,用于表示 3D 世界中的对象和视图。
右手坐标系
在三维空间中确定了X轴、Y轴位置方向的同时,根据Z轴正方向的不同分为左手坐标系和右手坐标系。
右手坐标系这个名词是由右手定则而来的。先将右手展开,手掌与手指伸直(手指和手掌保持在一个平面上),然后让中指垂直于手掌,指向手掌正对(掌背->掌心)的方向,与食指呈直角关系。再将大拇指往上指去,与中指、食指都呈直角关系(下图左), 则大拇指、食指与中指分别表示了右手坐标系的x轴、y轴与z轴。同样地,用左手也可以表示出左手坐标系。
右手坐标系不仅定义了坐标的方向,同时也定义了旋转的方向。右手坐标系下的旋转方向是通过右手定则确定的。右手定则用于描述旋转方向的规则:将右手的大拇指指向某一轴的正方向(+号),将剩下的四根手指弯曲(呈握拳状)手指弯曲的方向表示绕该轴旋转的正方向(下图右) 。在右手坐标系下,旋转的正方向是逆时针的,因此在threejs中旋转的正方向是逆时针方向。
需要说明的是左、右手坐标系三个轴的指向并不是绝对固定的(比如X轴不一定是向上),具体指向依赖于约定以及使用场景。在不同的应用场景中,可以根据需求重新定义坐标轴的方向。重要的是,定义坐标轴的相对关系要一致(如下图):
- 在右手坐标系中,x、y、z 三个轴按照右手定则排列。
- 在左手坐标系中,x、y、z 三个轴按照左手定则排列。
在Three.js 中使用右手坐标系,三个轴的方向如下:
- X轴:水平轴,正方向向右。
- Y轴:垂直轴,正方向向上。
- Z轴:深度轴,正方向指向观察者(屏幕外),负方向指向屏幕内部。
在 three.js 中,理解不同坐标系之间的关系和使用场景非常重要。Three.js 使用了多个坐标系来处理不同类型的变换,如世界坐标系、局部坐标系、相机坐标系、屏幕坐标系等。每种坐标系的功能和作用各有不同。下面详细解释一下三维空间中的这些坐标系。
局部坐标系
局部坐标系是相对于物体自身定义的,但它的位置、旋转和缩放的值是相对于物体的父对象(或者在没有父对象时,相对于世界坐标系)来定义的。
局部坐标系的定义
局部坐标系是相对于物体自身的坐标系,它是独立于世界坐标系的。局部坐标系的原点通常是物体几何体的中心,物体所有顶点和几何信息都是相对于这个局部原点定义的。局部坐标系通常用于定义和控制对象的旋转、缩放和位置,使得操作变得更加直观和模块化。
举个例子:
- 球体的球心是球体在其局部坐标系下的原点。
- 假设我们有一个物体,在其局部坐标系下,它的坐标是 (1, 0, 0),这意味着它相对于物体的中心沿着 x 轴移动了 1 个单位。
局部坐标系与父对象的关系
当物体有父对象时,它的局部坐标系的位置、旋转、缩放等变换是相对于父对象的坐标系来定义的。也就是说,局部坐标系的原点是相对于父物体的位置、旋转、缩放的变换结果。即使物体的局部坐标是相对于它自己的几何中心来定义的,但父物体的变换会影响物体的位置、旋转等属性的最终值。
举个例子:
如果父物体的位置是 (5, 0, 0),旋转了 45 度,并且子物体的局部坐标是 (1, 0, 0),那么子物体的世界坐标就会根据父物体的变换(这个例子中为旋转)来计算,而不再是简单的 (1, 0, 0)。如果父物体没有变换,子物体的世界坐标将是 (6, 0, 0)。
const parent = new THREE.Object3D();
const child = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 0x00ff00 }));
// 物体B(父物体)的坐标
parent.position.set(5, 0, 0); // 父物体的位置是 (2, 0, 0)
parent.rotation.y = THREE.MathUtils.degToRad(45); // 将角度转换为弧度
// 物体A(子物体)的局部坐标
child.position.set(1, 0, 0); // 子物体相对于父物体的位置是 (1, 0, 0)
parent.add(child);
scene.add(parent);
// 计算子物体的世界坐标
const worldPosition = new THREE.Vector3();
child.getWorldPosition(worldPosition);
//如果父物体没有旋转(parent.rotation.y = 0),世界坐标将是:Vector3 {x: 6, y: 0, z: 0}
console.log('子物体的世界坐标:', worldPosition);
没有父物体时的局部坐标系
如果物体没有父物体,那么它的局部坐标系直接对应于世界坐标系。也就是说,在这种情况下,物体的局部坐标就是物体在世界坐标系中的位置。
世界坐标系
为什么需要世界坐标系?
考虑这样一个场景:假如一个场景中有一个房子和一个人,它们可能有各自的局部坐标系。如果没有统一的参考系(世界坐标系),就无法明确房子和人之间的相对距离和位置。
世界坐标系是整个三维场景的全局坐标系,它用于描述场景中所有对象的位置、旋转和缩放。所有对象都可以通过设置相对于世界坐标系的变换(如位置、旋转、缩放)来定位。
- 特点:全局坐标系,所有对象的位置最终都可以转换到这个坐标系中。
- 原点:通常在 (0, 0, 0)。
- 用途:用来表示场景中所有物体在整个世界中的位置和方向。
相机坐标系
相机坐标系是一种以相机为原点的局部坐标系,用于描述场景中物体相对于相机的位置和方向。它在3D图形学和渲染中至关重要,特别是在投影、裁剪、光线追踪和可视化计算等方面。
在3D场景中,渲染的内容由相机的视角决定:
- 相机坐标系以相机为原点,提供了一种简单的方式来定义“从相机(模拟人眼)看到的世界”:
- 相机坐标系的-Z轴(Z轴负半轴):表示相机的观察方向。
- 相机坐标系的+X和+Y轴:定义了相机视野的左右和上下方向。
- 通过相机坐标系,可以用直观的方式描述物体在相机视野中的位置。
例子:假如在世界坐标系上有一个点位置为(0, 0, 1),相机位于(0, 0, 2),那这个点转换到相机坐标系(观察方向沿-Z轴)的坐标为(0, 0, -1)。
相机坐标系的主要作用是简化3D渲染过程,提供一个基于观察者(相机)位置的参考框架。它让投影、裁剪、渲染更高效,并且模拟了人类视觉的自然方式,使场景开发更加直观。
裁剪坐标系与 归一化 设备坐标系
裁剪坐标系是对视图坐标系的一种补充和约束,用于模拟人眼的有限视觉范围,就像我们通过窗户去观察世界,只有位于可视空间内(通过窗户观察能看到)的实体会被绘制,超出范围的部分会被裁剪,这便是裁剪坐标系的由来。
在 OpenGL/WebGL/Three.js 等常见的图形学渲染管线中,经过模型变换(将物体从局部坐标变换到世界坐标)、视图变换(将世界坐标变换到相机坐标)、以及投影变换(将三维坐标映射到视锥或正交空间)之后,顶点会被表示为四维齐次坐标 (x, y, z, w)(所谓“齐次”,意思是当把齐次坐标的前几位除以最后一个分量 w 时,能得到原本的 2D/3D 坐标。也就是说和说代表同一个“实际点”——前提是)。此时的坐标系通常被称为 “裁剪坐标系” 。
裁剪坐标系
- 顶点在这个坐标系中,将被执行视锥裁剪(或正交裁剪)。也就是说,会根据以下规则判断顶点(或图元)是否在可视范围内:
若完全超出该范围,则图元会被丢弃或进行部分裁剪(或生成新的顶点等)。
- 之所以要用四维齐次坐标 (x, y, z, w),是为了更好地表示透视投影、以及便于进行裁剪操作。只有通过这样的齐次坐标系,才能用线性方式表达“视锥”或“正交体”的边界。
-
归一化 设备坐标((Normalized Device Coordinates)
-
很多教程会把 的立方体称作“裁剪空间立方体”,并说“超出该范围就会被丢弃”。
-
但更严谨的做法是:
- 先在四维齐次坐标系(也就是裁剪坐标系)中做初步裁剪,检查等条件。
- 然后对保留的顶点进行透视除法(Perspective Divide):
-
-
得到的 就落在 的空间内,这才是 NDC(归一化 设备坐标)。
-
- 之所以常把 NDC( 区域)也称作“裁剪空间”,主要是为了教学和概念简化——在很多实践中,最终能不能被渲染出来,往往就是看顶点是否落在这个 的区域里。
-
裁剪坐标系是渲染管线的一个阶段性坐标系,它的核心作用是:在顶点进入屏幕之前,对其可视范围做初步过滤或分割,从而减少后续的运算开销并确定哪些顶点可以进入最终的可见区域。
屏幕(视口)坐标系
屏幕坐标系用于将三维世界中的点映射到二维屏幕上。这是我们通常所说的“像素坐标”,也就是浏览器的窗口或Canvas上的显示坐标系。其坐标原点通常位于屏幕的左上角,X 轴向右,Y 轴向下。
- 屏幕坐标系是二维的,使用像素作为单位。
- 原点通常为 (0, 0),位于屏幕(或Canvas)的左上角。
- 用来表示屏幕上的图形位置、鼠标点击位置等。
视口变换公式
默认情况下,裁剪区域是一个以原点为中心的 2x2 的正方形(XY平面,Z 轴主要用于深度缓冲区和绘制顺序,不直接影响屏幕坐标的平面映射),其坐标范围是从 (-1, -1) 到 (1, 1)。
从 NDC 到 屏幕坐标,常用公式(原点在左上角)是:
也有些环境中(如传统 OpenGL)默认把 (0,0) 放在左下角,这时映射公式会略有差异。
Three.js 并不会自动给你物体的“屏幕坐标”,但可以用内置方法 ( Vector3.project(camera) ) 或自己手动做数学运算来得到。典型做法是:
function getScreenPosition(object, camera, renderer) {
// 1. 取物体在世界空间中的位置
const pos = new THREE.Vector3();
pos.setFromMatrixPosition(object.matrixWorld);
// 2. 用 camera 的投影将该向量转换到 NDC
pos.project(camera); // 等效于手动做 裁剪坐标 -> 透视除法
// 3. 把 NDC 映射到 [0, width], [0, height] 屏幕坐标
const width = renderer.domElement.width;
const height = renderer.domElement.height;
// x: ( [-1,1] -> [0, width] )
const x = ( pos.x + 1 ) / 2 * width;
// y: ( [-1,1] -> [0, height] ) 但要注意 Y 方向的翻转
const y = ( -pos.y + 1 ) / 2 * height;
return { x, y };
}
这样你就能得到与浏览器实际像素对应的 (x, y)。这在制作 3D 中的 2D label、 UI 、点击拾取等功能时非常常用。