Skip to content

实际应用中,场景里的模型会越来越多,这时我们不可避免地需要一些交互效果,比如当点击某一个模型的时候,做出一些反馈,这需要我们能够通过鼠标的点击位置推导出点击到的模型,即距离我们最近的模型,这种技术称为 3D 拾取

3D 拾取原理

先透露一下 3D 拾取原理的关键点,其实是一个几何知识: 求射线与三角形的交点。

为什么这么说呢?要理解这一点,大家还需要理解坐标变换流水线

让我们回顾下坐标变换流水线,看它是如何将一个点一步步地呈现在屏幕上的。

坐标变换流水线

让我们回顾一下坐标变换流水线,看一下模型空间中一点 P0 到屏幕空间的转换步骤。

从模型空间到屏幕空间

在第十七节,我为大家讲述了点从模型空间到屏幕空间的变换步骤,文章里有如下一张图:

1、模型空间坐标转换成世界空间坐标

模型空间经过平移、缩放、旋转后,来到世界空间,该点在世界空间中的坐标为P1。 假设模型空间到世界空间的变换矩阵为 M,则有: $ P1 = M \times P0 $

2、世界空间坐标转换成观察空间坐标

将摄像机放在世界空间中的某个位置,以摄像机为原点建立观察坐标系,P0 在该空间下的坐标为 P2。

假设世界空间到观察空间的变换矩阵为 V,则有:

$ P2 = V \times P1 $

3、观察空间坐标转换到裁剪空间坐标

该阶段以透视投影为例,P0 在该空间下的坐标为 P3。 假设观察空间到裁剪空间的变换矩阵为 P,则有: $ P3 = P \times P2 $

MVP 的由来

以上三个空间变换矩阵一般情况都是在 CPU 中进行运算,通常我们将其按照顺序相乘得到一个混合变换矩阵 MVP。

$ MVP = P \times M \times V $

然后将该矩阵传递到GPU 中的顶点着色器程序中,将其与模型空间坐标相乘,得到裁剪空间坐标 P3。

4、裁剪空间坐标转换成 NDC 空间坐标

裁剪空间坐标 P3 是一个 4 维齐次坐标,GPU 会自动将各个分量同时除以齐次坐标 w,得到 NDC 空间下的坐标 P4,P4 此时处于边长为 2 的立方中。

5、NDC 空间坐标转换成屏幕坐标

NDC 空间坐标处于边长为 2 的立方体中,它的坐标原点在屏幕正中央,Y 轴正向朝上,X 轴向右为正。这和屏幕坐标有所区别,屏幕坐标以屏幕左上角为原点,X 轴向右为正,Y 轴向下为正,所以此处仍然需要转换。

需要特别记住的是,坐标流水线的第一步到第三步,通常由开发者去运算得出。第四步到第五步,通常是由 GPU 自行运算,运算规则也比较简单。

接下来我们反向执行该流水线操作,将屏幕上一点,反推到模型空间。

屏幕空间到模型空间

当我们在屏幕上点击时,得到一个屏幕坐标系(视口)上的二维坐标 $P_5$,接下来,我们需要沿坐标变换流水线的逆序进行操作。

1、屏幕空间到 NDC 空间。

屏幕坐标 P5 经过变换到达 NDC 空间 P4。假设 P5 坐标为 【Xp5,Yp5】,屏幕宽度为sWidth,高度为 sHeight,那么有如下等式:

$ \frac{Xp5 }{ sWidth} = \frac{Xp4 + 1}{ 2} $ $ \frac{Yp5}{sHeight} = \frac{- (Yp4 - 1)} {2} $

所以,可以推出 P4 坐标:

$ Xp4 = \frac{2 \times Xp5}{sWdith - 1} $ $ Yp4 = \frac{1- (2 \times Yp5) }{ sHeight} $

2、NDC 空间坐标转换到投影空间坐标

我们在屏幕上点击的坐标位置,可以理解为在投影空间的近裁剪平面上的点 P3。假设近裁剪平面 X 轴最小坐标为 Pl,最大坐标为 Pr,Y 轴最大坐标为 Yt,最小坐标为 Yb,则有如下等式:

$ \frac{Xp4 - (-1)} { 1 - (-1)} = \frac{Xp3 - Pl} { (Pr - Pl)} $

$ \frac{Yp4 - (-1)} { (1 - (-1)} = \frac{Yp3 - Pb} { (Pt - Pb)} $

所以,可以推断NDC 空间坐标在投影面上的坐标为:

$ Xp3 = \frac{Xp4 + 1} { 2 \times (Pr - Pl)} + Pl $

$ Yp3 = \frac{Yp4 + 1} { 2} \times (Pt - Pb) + Pb $

推断出 NDC 空间坐标在投影面上的 X、Y 轴坐标之后,我们就开始考虑为其扩展 3D 坐标 Z 值了。

因为近裁剪平面,Z 值是我们做投影变换矩阵的运算时选取的,所以,我们可以采用近裁剪平面的 Z 值作为 P3 的 Z 轴坐标。

至此,我们得到了投影空间坐标,投影空间坐标转换到观察空间坐标,只需一步,将 Z 轴坐标反向即可。

投影空间坐标到观察空间坐标

转换到投影空间坐标为 P2,那么P2与 P3 的关系如下:

$ Xp2 = Xp3 $ $ Yp2 = Yp3 $

$ Zp2 = - Zp3 $

$ Wp2 = 1; $

3、观察空间转换到模型空间

观察空间转到到模型空间只需要乘以观察矩阵的逆矩阵,以及世界矩阵的逆矩阵即可。

3D 拾取步骤

以上就是流水线的正向与逆向转换过程,那么,我们只能对屏幕上一点进行逆向变换,无法对拿到物体的 Z 轴信息,那么如何进行检测呢?

你或许会发现,我们将摄像机原点与近裁剪平面的一点连起来,形成一条射线,指向视椎体,射线如果穿过视椎体中的距离近裁剪屏幕最近的模型,那么代表鼠标点选了该模型。

所以,我们需要构造这么一条射线,射线和模型检测时,需要在同一个坐标系,为了计算方便,我们将射线选取在观察坐标系中。

一、将选取射线与物体转换到同一个坐标系中

首先射线的起点 E 在观察坐标系中代表原点,即 E = (0,0,0)。所以,我们只需要求出点击位置在观察坐标系的坐标即可求出射线。

首先我们算出点在canvas 画布上的坐标位置,这里以一个铺满屏幕的画布举例:
javascript
var x = e.clientX;
var y = e.clientY;
将屏幕坐标转化为NDC 坐标
javascript
//屏幕坐标转换为 CVV 坐标
function getCVVFromScreen(mouse) {
    var viewWidth = canvas.width;
    var viewHeight = canvas.height;
    var x = mouse.x;
    var y = mouse.y;
    var cvv = {x: 0, y: 0};
    cvv.x = x / (viewWidth / 2) - 1;
    cvv.y = 1 - y / (viewHeight / 2);
    return cvv;
}
// 屏幕坐标转化为NDC坐标
var cvv = getCVVFromScreen({x: x, y: y});
根据 NDC 坐标求出该点在投影平面上的坐标
javascript
function getProFromCVV(cvv, near, viewRadians) {
    //投影盒上边坐标
    var top = near * Math.tan(Math.PI / 180) * 0.5 * viewRadians;
    //投影盒高度
    var height = 2 * top;
    //投影盒宽度
    var width = aspect * height;
    //投影盒左边界坐标
    var left = -0.5 * width;

    var pro = {x: 0, y: 0};
    pro.x = ((cvv.x + 1) / 2) * width + left;
    pro.y = ((cvv.y + 1) / 2) * height + top - height;
    return pro;
}
求出该点在相机坐标系的坐标

投影面上的坐标仅仅是 2 维坐标,我们需要经 2 维转化为 3 维,还记得我们从相机坐标到投影坐标的转换步骤吗?首先是设定了一个近裁剪平面:

javascript
var near = 0.5;
var projectionMatrix = matrix.perspective(
    fieldOfViewRadians,
    aspect,
    near,
    2000
);

perspective 方法中的第三个参数 0.5 就是近裁剪平面,在上一步中求得的投影平面坐标所在的投影面在相机坐标系中的 Z 轴坐标即为 0.5。所以,我们把 2维投影平面坐标转化为 3 维相机坐标,仅需要增加一个 Z 轴坐标即可,Z 轴坐标为近裁剪平面坐标 0.5

大家不要忘记了,投影空间是左手坐标系,相机空间是右手坐标系,所以,近裁剪平面的 Z 轴坐标在相机空间下要取相反数,即为 -0.5

因此,顶点在相机坐标系下的坐标可以通过如上方法求得:

javascript
// 投影坐标转化为相机坐标
function getViewFromPro(pro,near) {
    var point = new Vector3(pro.x, pro.y, -near);
    return point;
}

以上就是将鼠标点击屏幕坐标转变到相机空间坐标的推导过程,代码如下:

javascript
    canvas.addEventListener('click', function(e) {
        var x = e.clientX;
        var y = e.clientY;
        // 屏幕坐标转化为NDC坐标
        var cvv = getCVVFromScreen({x: x, y: y});
        // NDC 坐标转化为投影坐标
        cvv = getProFromCVV(cvv, 0.5, 60);
        // 投影坐标转化为观察坐标
        cvv = getViewFromPro(cvv);
        // 构造观察坐标系下的射线
        var newRay = new Ray(
          new Vector3(0, 0, 0),
          new Vector3(cvv.x, cvv.y, cvv.z)
        );
        // 求出三角形的顶点 A 在观察坐标系中的坐标
        var a = new Vector4(positions[0], positions[1], positions[2]);
        a = matrix.applyMatrix(a, viewMatrix);
        a = V4toV3(a);
        // 求出三角形的顶点 B 在观察坐标系中的坐标
        var b = new Vector4(positions[3], positions[4], positions[5]);
        b = matrix.applyMatrix(b, viewMatrix);
        b = V4toV3(b);
        // 求出三角形的顶点 C 在观察坐标系中的坐标
        var c = new Vector4(positions[6], positions[7], positions[8]);
        c = matrix.applyMatrix(c, viewMatrix);
        c = V4toV3(c);
        // 求出射线与三角形相交交点
        var result = newRay.intersectTriangle(a, b, c);
        
        positions1[0] = result.x;
        positions1[1] = result.y;
        positions1[2] = result.z + 0.1;
        render(gl);
        renderPoint(gl);
        return cvv;
      });

接下来,我们还需要将模型坐标也转变到相机空间下,只有在同一个空间下坐标才能得到统一。模型坐标转变到相机空间比较简单,只需要将模型顶点乘以世界变换矩阵和相机变换矩阵即可,此处就不贴代码了。

二、求出射线与三角形的交点

上面代码中有一段是射线与三角形相交算法,该类算法比较多,我们采用的是效率相对高一些的Möller–Trumbore_intersection_algorithm 算法,感兴趣的同学可以点此查看

算法实现可以参照 ThreeJS 的实现,此处就不贴了。

一些优化

以上就是最简单的拾取原理,但实际上,场景模型比较多的时候,我们需要考虑如何高效的实现选取操作,这里面涉及很多策略,比如射线与包围球/盒相交的检测,模型按深度排序,按区域划分屏幕等等,这些优化方式的本质都是避免一些不必要的检测,本文不再一一介绍这些内容,感兴趣的同学可以翻看 ThreeJS 的源码,其中有这些算法的实现。