Three.js-Raycaster.setFromCamera源码分析

516 阅读2分钟

Raycaster.setFromCamera(coords, camera)

这个方式是根据相机和“屏幕”上的一个位置,设置射线对象(类型为Ray)。需要设置的属性有两个:

  1. 射线的起点origin: Vector3
  2. 射线的方向direction: Vector3

首先,要明白的是setFromCamera接收的coords是一个二维向量,表示一个NDC(标准设备坐标)。现在,我们需要根据这个坐标和相机,计算出一条场景世界坐标中的一条射线。

Three.jsWebGL)中,相机可视区域变换到裁剪空间的坐标范围是[1,1]3[-1, 1]^3,而NDC坐标的范围是[1,1]2[-1, 1]^2,因此,裁剪空间和NDC坐标的x分量和y分量是一致的。

首先,我们要明确一点,射线的方向平行于裁剪空间的ZZ轴。这对于正交相机和透视相机会有很大的不同。

1. 正交相机

对于正交相机来说,它射出的射线方向始终都是相机“看向”的方向,也就是相机空间中的(0,0,1)(0, 0, -1)表示的方向,现在我们需要将这个方向从相机坐标变换到世界坐标。

怎么变换一个方向

我们常见的顶点位置变换为:P=MPP'=M \cdot P。其中PP是变换前的坐标,PP'是变换后的坐标,MM是变换矩阵,我们这里变换的是向量表示的位置。

但是,如果向量PP表示一个方向,使用同样的变换MM,变换后的方向是什么?

向量PP表示一个方向,其实隐含的意思就是,起点为原点OO,终点为PP的向量V=POV=P-O。那么,我们将这两个点都进行变换,则为变换后的方向V=POV'=P'-O'

V=PO=MPMO=M(PO)=M([PxPyPz1][0001])=[ixjxkxtxiyjykytyizjzkztz0001][PxPyPz0]=[ixPx+jxPy+kxPz+0iyPy+jyPy+kyPz+0izPz+jzPy+kzPz+00+0+0+0]\begin{aligned} V'&= P'-O' \\ &= M \cdot P - M \cdot O \\ &= M \cdot (P - O) \\ &= M \cdot (\begin{bmatrix} P_{x}\\ P_{y}\\ P_{z}\\ 1\\ \end{bmatrix} - \begin{bmatrix} 0\\ 0\\ 0\\ 1\\ \end{bmatrix} ) \\ &= \begin{bmatrix} i_{x}&j_{x}&k_{x}&t{x}\\ i_{y}&j_{y}&k_{y}&t{y}\\ i_{z}&j_{z}&k_{z}&t{z}\\ 0&0&0&1\\ \end{bmatrix} \cdot \begin{bmatrix} P_{x}\\ P_{y}\\ P_{z}\\ 0\\ \end{bmatrix} \\ &= \begin{bmatrix} i_{x} \cdot P_{x} + j_{x} \cdot P_{y} + k_{x} \cdot P{z} + 0\\ i_{y} \cdot P_{y} + j_{y} \cdot P_{y} + k_{y} \cdot P{z} + 0\\ i_{z} \cdot P_{z} + j_{z} \cdot P_{y} + k_{z} \cdot P{z} + 0\\ 0+0+0+0\\ \end{bmatrix} \end{aligned}

可以看到,其实就是变换矩阵左上角的子矩阵和三维向量相乘。 在Three.js中可以找到相关源码:

// src/math/Vector3.js
transformDirection( m ) {

  // input: THREE.Matrix4 affine matrix
  // input: 一个仿射变换矩阵m

  // vector interpreted as a direction
  // 将当前三维向量当作方向
  // 相当于四维向量[x, y, z, 0]

  const x = this.x, y = this.y, z = this.z;
  const e = m.elements;

  // 矩阵是按列存储的,矩阵元素和下表的关系为
  // 0  4  8  12
  // 1  5  9  13
  // 2  6  10 14
  // 3  7  11 15

  // 这里就是我们上边推导的公式
  this.x = e[ 0 ] * x + e[ 4 ] * y + e[ 8 ] * z;
  this.y = e[ 1 ] * x + e[ 5 ] * y + e[ 9 ] * z;
  this.z = e[ 2 ] * x + e[ 6 ] * y + e[ 10 ] * z;

  // 向量归一化
  return this.normalize();

}

计算正交相机发射的射线方向

就像上面我们说的,(0,0,1)(0, 0, -1)是射线在相机空间中的方向。现在需要将射线在相机空间中的方向,变换到世界空间。我们只需要知道相机的世界变换矩阵matrixWorldcameramatrixWorld_{camera},然后变换即可。

// camera已知

const direction = new Vector3(0, 0, -1).transformDirection(camera.matrixWorld)

计算正交相机发射的射线起点

当从某一个NDC坐标发射一条射线时,我们已经知道了NDC的坐标,也就是射线起点在裁剪空间中的xxyy坐标(等于NDC坐标),现在要计算未知的zz坐标。

我们知道,相机的投影变换在ZZ轴上将相机空间的nearnearfarfar变换为了1-111。而相机在相机空间的原点,所以我们关心的是在这个过程中,00变换后的结果是什么?

即:

M2×2[near1]=[11]\begin{aligned} M_{2 \times 2} \begin{bmatrix} near \\ 1 \\ \end{bmatrix} &= \begin{bmatrix} -1 \\ 1 \\ \end{bmatrix} \end{aligned}
M2×2[far1]=[11]\begin{aligned} M_{2 \times 2} \begin{bmatrix} far \\ 1 \\ \end{bmatrix} &= \begin{bmatrix} 1 \\ 1 \\ \end{bmatrix} \end{aligned}

我们现在需要求zz,可以写为:

M2×2[01]=[z1]\begin{aligned} M_{2 \times 2} \begin{bmatrix} 0 \\ 1 \\ \end{bmatrix} &= \begin{bmatrix} z \\ 1 \\ \end{bmatrix} \end{aligned}

关键点就是求出M2×2M_{2 \times 2}

,M=[abcd]设,M=\begin{bmatrix} a&b\\c&d \end{bmatrix}

则可列方程

{anear+b=1cnear+d=1afar+b=1cfar+d=1\begin{cases} a \cdot near + b = -1 \\ c \cdot near + d = 1 \\ a \cdot far + b = 1 \\ c \cdot far + d = 1 \end{cases}

解得:

M=[2farnearnear+farnearfar01]M=\begin{bmatrix} \frac{2}{far-near} & \frac{near+far}{near-far}\\0&1 \end{bmatrix}

则:

[z1]=[2farnearnear+farnearfar01][01]\begin{bmatrix} z \\ 1 \end{bmatrix}=\begin{bmatrix} \frac{2}{far-near} & \frac{near+far}{near-far}\\0&1 \end{bmatrix} \begin{bmatrix} 0 \\ 1 \end{bmatrix}
z=near+farnearfarz=\frac{near+far}{near-far}

现在,我们已经知道了射线起点在裁剪空间得坐标origincliporigin_{clip},现在需要将它变换为世界坐标:

originworld=MatrixWolrdcameraMatrixprojection1origincliporigin_{world}=MatrixWolrd_{camera}Matrix_{projection}^{-1}origin_{clip}

先通过投影矩阵的逆矩阵,从裁剪空间变换到相机空间,再通过相机的世界变换矩阵变换到世界坐标。这个方法在Vector3中的实现如下:

// src/math/Vector3

unproject( camera ) {

  return this.applyMatrix4( camera.projectionMatrixInverse ).applyMatrix4( camera.matrixWorld );
}

最终,求射线起点的代码如下:

// coords和camera已知
const z = (camera.near + camera.far) / (camera.near - camera. far);
const origin = new Vector3(coords.x, coords.y, z).unproject(camera);

小结

我们直接看一下setFromCamera的源码:

// src/core/Raycaster
setFromCamera( coords, camera ) {

  if ( camera.isPerspectiveCamera ) {

    // 透视相机我们下面讨论

  } else if ( camera.isOrthographicCamera ) {

    this.ray.origin.set( coords.x, coords.y, ( camera.near + camera.far ) / ( camera.near - camera.far ) ).unproject( camera ); // set origin in plane of camera
    this.ray.direction.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );
    this.camera = camera;

  } else {

    console.error( 'THREE.Raycaster: Unsupported camera type: ' + camera.type );

  }

}

2. 透视相机

对于透视相机来说,它的射线的起点就是相机的世界坐标。

那怎么求射线的方向呢?我们目前已经知道了射线的起点,只要再随便知道一个射线上的点,就可以计算出射线的方向。

我们已经知道,在裁剪空间中的xxyy了(NDC坐标),因为射线是平行于裁剪空间的,所以我们随便在裁剪空间中取一个[1,1][-1, 1]之间的zz即可,Three.js源码中的取值是0.5

为什么取[1,1][-1, 1]的范围?因为在相机空间中的原点(相机位置)不在[near,far][near, far]的范围内;经过投影变换后[near,far][1,1][near, far] \rightarrow [-1, 1],也就是说在相机位置的zz坐标,在投影空间中一定不在[1,1][-1, 1]的范围内。这样,我们在把在裁剪空间中随便选的一点变换到世界空间后,一定不会和起点(即,相机在世界空间的位置)重合。

直接看源码:

setFromCamera( coords, camera ) {
  // src/core/Raycaster
  if ( camera.isPerspectiveCamera ) {

    this.ray.origin.setFromMatrixPosition( camera.matrixWorld ); // 将相机的世界坐标作为起点

    // 在裁剪空间中的射线上取一点转换到世界坐标 然后和起点相减求出方向
    this.ray.direction.set( coords.x, coords.y, 0.5 ).unproject( camera ).sub( this.ray.origin ).normalize();
    this.camera = camera;

  } else if ( camera.isOrthographicCamera ) {
    // 上面已经介绍过了
  } else {

    console.error( 'THREE.Raycaster: Unsupported camera type: ' + camera.type );

  }
}