Three.js 3D渲染基础二-Three.js中的坐标系

828 阅读10分钟

坐标系是一个用于描述三维空间中点和对象位置的数学工具。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)(x,\,y,\,z,\,w)(x/w,y/w,z/w,1) (x/w, y/w, z/w, 1)说代表同一个“实际点”——前提是w0w \neq 0)。此时的坐标系通常被称为 “裁剪坐标系”

裁剪坐标系

  • 顶点在这个坐标系中,将被执行视锥裁剪(或正交裁剪)。也就是说,会根据以下规则判断顶点(或图元)是否在可视范围内:

wxw,  wyw,  wzw-w \leq x \leq w, \; -w \leq y \leq w, \; -w \leq z \leq w

若完全超出该范围,则图元会被丢弃或进行部分裁剪(或生成新的顶点等)。

  • 之所以要用四维齐次坐标 (x, y, z, w),是为了更好地表示透视投影、以及便于进行裁剪操作。只有通过这样的齐次坐标系,才能用线性方式表达“视锥”或“正交体”的边界。

  • 归一化 设备坐标((Normalized Device Coordinates)

    • 很多教程会把[1,1]3 [-1, 1]^3立方体称作“裁剪空间立方体”,并说“超出该范围就会被丢弃”。

    • 更严谨的做法是:

      • 先在四维齐次坐标系(也就是裁剪坐标系)中做初步裁剪,检查wxw-w \le x \le w等条件。
      • 然后对保留的顶点进行透视除法(Perspective Divide):
    •    xndc=xw,yndc=yw,zndc=zw. x_{ndc} = \frac{x}{w},\quad y_{ndc} = \frac{y}{w},\quad z_{ndc} = \frac{z}{w}.

    •    得到的(xndc,yndc,zndc) (x_{ndc}, y_{ndc}, z_{ndc}) 就落在[1,1]3 [-1, 1]^3 的空间内,这才是 NDC归一化 设备坐标)。

      • 之所以常把 NDC([1,1]3 [-1, 1]^3 区域)也称作“裁剪空间”,主要是为了教学和概念简化——在很多实践中,最终能不能被渲染出来,往往就是看顶点是否落在这个[1,1]3 [-1, 1]^3 的区域里。

裁剪坐标系是渲染管线的一个阶段性坐标系,它的核心作用是:在顶点进入屏幕之前,对其可视范围做初步过滤或分割,从而减少后续的运算开销并确定哪些顶点可以进入最终的可见区域。

屏幕(视口)坐标系

屏幕坐标系用于将三维世界中的点映射到二维屏幕上。这是我们通常所说的“像素坐标”,也就是浏览器的窗口或Canvas上的显示坐标系。其坐标原点通常位于屏幕的左上角,X 轴向右,Y 轴向下。

  • 屏幕坐标系是二维的,使用像素作为单位。
  • 原点通常为 (0, 0),位于屏幕(或Canvas)的左上角。
  • 用来表示屏幕上的图形位置、鼠标点击位置等。

视口变换公式

默认情况下,裁剪区域是一个以原点为中心的 2x2 的正方形(XY平面,Z 轴主要用于深度缓冲区和绘制顺序,不直接影响屏幕坐标的平面映射),其坐标范围是从 (-1, -1) 到 (1, 1)。

NDC屏幕坐标,常用公式(原点在左上角)是:

xscreen=xndc+12×canvasWidth,yscreen=yndc+12×canvasHeight.x_{\text{screen}} = \frac{x_{\text{ndc}} + 1}{2} \times \mathrm{canvasWidth}, \quad y_{\text{screen}} = \frac{-\,y_{\text{ndc}} + 1}{2} \times \mathrm{canvasHeight}.

也有些环境中(如传统 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 、点击拾取等功能时非常常用。