实际应用中,场景里的模型会越来越多,这时我们不可避免地需要一些交互效果,比如当点击某一个模型的时候,做出一些反馈,这需要我们能够通过鼠标的点击位置推导出点击到的模型,即距离我们最近的模型,这种技术称为 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 画布上的坐标位置,这里以一个铺满屏幕的画布举例:
var x = e.clientX;
var y = e.clientY;
将屏幕坐标转化为NDC 坐标
//屏幕坐标转换为 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 坐标求出该点在投影平面上的坐标
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 维,还记得我们从相机坐标到投影坐标的转换步骤吗?首先是设定了一个近裁剪平面:
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
。
因此,顶点在相机坐标系下的坐标可以通过如上方法求得:
// 投影坐标转化为相机坐标
function getViewFromPro(pro,near) {
var point = new Vector3(pro.x, pro.y, -near);
return point;
}
以上就是将鼠标点击屏幕坐标转变到相机空间坐标的推导过程,代码如下:
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 的源码,其中有这些算法的实现。