{T}

3D模型拾取

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

然后将该矩阵传递到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}{sWidth} - 1 Yp4 = 1 - \frac{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)}](https://juejin.cn/equation?tex=%5Cfrac%7BYp4%20-%20(-1)%7D%20%7B%20(1%20-%20(-1)%7D%20%3D%20%5Cfrac%7BYp3%20-%20Pb%7D%20%7B%20(Pt%20-%20Pb)%7D)

所以,可以推断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 画布上的坐标位置,这里以一个铺满屏幕的画布举例:
code
var x = e.clientX;
var y = e.clientY;
将屏幕坐标转化为NDC 坐标
code
//屏幕坐标转换为 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 坐标求出该点在投影平面上的坐标
code
function getProFromCVV(cvv, near, viewRadians, aspect) {
 //投影盒上边坐标(viewRadians 为度数,需转换为弧度)
 var top = near * Math.tan(viewRadians * 0.5 * Math.PI / 180);
 //投影盒高度
 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 维,还记得我们从相机坐标到投影坐标的转换步骤吗?首先是设定了一个近裁剪平面:

code
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

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

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

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

code
 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, canvas.width / canvas.height);
 // 投影坐标转化为观察坐标
 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 的源码,其中有这些算法的实现。

补充:射线-AABB 包围盒相交检测

原文仅提及包围盒优化策略而未给出实现,此处补充最常用的 Axis-Aligned Bounding Box (AABB) 射线相交检测算法。

AABB 是最常用的包围盒类型,它与坐标轴对齐,只需 6 个数值( minX, minY, minZ, maxX, maxY, maxZ )即可定义。射线与 AABB 的相交检测使用 slab method,核心思想是计算射线穿过每对平行平面(slab)的进入/离开时间,取所有进入时间的最大值和离开时间的最小值,判断是否相交。

图表渲染中…
javascript
/**
 * 射线与 AABB 包围盒相交检测(Slab Method)
 * @param {Ray} ray - 射线对象 {origin, direction}
 * @param {AABB} box - 包围盒对象 {min: Vector3, max: Vector3}
 * @returns {boolean} 是否相交
 */
function intersectRayAABB(ray, box) {
    let tMin = -Infinity;
    let tMax = Infinity;

    // 逐轴检测
    for (let i = 0; i < 3; i++) {
        const origin = ray.origin[i];     // origin.x, origin.y, origin.z
        const direction = ray.direction[i]; // direction.x, direction.y, direction.z
        const bMin = box.min[i];
        const bMax = box.max[i];

        if (Math.abs(direction) < 1e-8) {
            // 射线与该轴平行 — 检查起点是否在 slab 内
            if (origin < bMin || origin > bMax) {
                return false; // 起点不在 slab 内,不相交
            }
        } else {
            // 计算射线进入/离开 slab 的时间
            const t1 = (bMin - origin) / direction;
            const t2 = (bMax - origin) / direction;

            // 确保 t1 是进入时间,t2 是离开时间
            let tNear = t1 < t2 ? t1 : t2;
            let tFar = t1 < t2 ? t2 : t1;

            // 更新全局的进入/离开时间
            tMin = Math.max(tMin, tNear);
            tMax = Math.min(tMax, tFar);

            // 如果进入时间已超过离开时间,不相交
            if (tMin > tMax) return false;
        }
    }

    // 射线需要向前方延伸(tMax >= 0),且进入时间不超过离开时间
    return tMax >= 0;
}

/**
 * 从一组顶点中计算 AABB 包围盒
 * @param {Float32Array} positions - 顶点位置数组 [x,y,z, x,y,z, ...]
 * @returns {Object} {min: Vector3, max: Vector3}
 */
function computeAABB(positions) {
    let minX = Infinity, minY = Infinity, minZ = Infinity;
    let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;

    for (let i = 0; i < positions.length; i += 3) {
        minX = Math.min(minX, positions[i]);
        minY = Math.min(minY, positions[i + 1]);
        minZ = Math.min(minZ, positions[i + 2]);
        maxX = Math.max(maxX, positions[i]);
        maxY = Math.max(maxY, positions[i + 1]);
        maxZ = Math.max(maxZ, positions[i + 2]);
    }

    return {
        min: new Vector3(minX, minY, minZ),
        max: new Vector3(maxX, maxY, maxZ)
    };
}

[使用方式] 在拾取时,先对每个模型计算 AABB,然后用 intersectRayAABB 进行粗筛。只有通过 AABB 测试的模型,才需要进一步做射线-三角形精确检测。当场景有数百个模型时,AABB 粗筛可以将检测量减少 90% 以上。

拾取流程无优化AABB 粗筛
100 个模型检测 100 × N 个三角形先检测 100 个 AABB → 仅通过的部分做精确检测
平均检测量全量~10-20%
误判率00(AABB 不会漏掉相交)
误报率0有(AABB 可能比模型大,导致"相交"但实际不交三角形)

AABB 粗筛的核心价值:绝不会漏掉相交(保守估计),仅可能多检测一些模型,但远比全量检测高效。