上一篇,笔者留下了一个问题,three.js内置的THREE.Vector3.project方法算出来的结果对于超出屏幕可见范围的点来说错得相当离谱。
three.js+WebGL踩坑经验合集(4.1):THREE.Line2的射线检测问题(注意本篇说的是Line2,同样也不是阈值方面的问题)-CSDN博客
从代码上看,project方法和常规shader的实现原理并无太大差异,但是为什么错得这么离谱的算法被使用了这么多年都没人去管的呢?
原因很简单,不管怎么错,它都已经出界,不可见了,所以结果的正确与否对于显示来说并不重要。
我们回顾一下上一篇错得很离谱的那个数值,它的xyz都不在0~1的范围内。
但要是想拿它来进行计算的话就完犊子了。就拿上一篇的线来说,假设有且只有一个端点出界,一个点在(0.5,0.1),另一个点在(-1.2, -1.3),按照预期,连线向量应该为(1.7, 1.4),但如果反了,变成(1.2, 1.3),那连线向量就会被误算成(-0.7, -1.2),导致后续的结果全部不正确。THREE.Line2射线检测的bug就是这样子来的了。
所以现在我们就来探讨下project方法算不正确的原因。
Vector3的project方法实现代码如下:
javascript
project(camera) {
return this.applyMatrix4(camera.matrixWorldInverse).applyMatrix4(camera.projectionMatrix);
}
然后我们再来研读下applyMatrix4的代码
javascript
applyMatrix4(m) {
const x = this.x, y = this.y, z = this.z;
const e = m.elements;
const w = 1 / (e[3] * x + e[7] * y + e[11] * z + e[15]);
this.x = (e[0] * x + e[4] * y + e[8] * z + e[12]) * w;
this.y = (e[1] * x + e[5] * y + e[9] * z + e[13]) * w;
this.z = (e[2] * x + e[6] * y + e[10] * z + e[14]) * w;
return this;
}
大体上,它是拿矩阵m跟3D点进行矩阵乘法运算,但是它还多了一个步骤,就是在xyz以外还算了一个w值,并且把w值分别乘到xyz上。
这一步是个什么原理呢?
透视成像近大远小的特性,在数学上大致为反比例的关系。不严谨的说法,同一个物体,我们肉眼所见的大小(s)跟物到我们的距离(z)成反比,即s=1/z。
z在分母,已经无法用矩阵的线性运算进行表达。为了让开发者在处理3D变换的过程中尽可能只跟矩阵打交道,GPU渲染底层在xyz的基础上新增一个w变量,用于处理透视变换,然后称(x, y, z, w)这样的坐标为齐次坐标,最终呈现到屏幕上的,是(x/w, y/w, z/w)这样的值。
可见,THREE.js的Vector3.applyMatrix4中的w跟齐次坐标中的w互为倒数。
笔者不打算给大家探讨投影矩阵的推导过程,网上文章一抓一大把。笔者本人也没啥特别的想法,这里随便给大家搜个两篇:
笔者只打算给大家列个大纲,说明一下GPU渲染的一些数据结构和工作流程
1 大多数时候,3D点基础变换的计算都基于矩阵乘法。
2 3D点按道理是应该跟3*3矩阵相乘,但是平移是典型的1*3矩阵相加,为了统一,这两种变换合并为1*4和4*4矩阵相乘。并且扩充的行填充单位矩阵的数值[0,0,0,1],1*4矩阵的最后一列填1
至于为什么合并后会从3*3变成4*4,笔者以前有写过2D矩阵的文章,那里展示了从2*2变成3*3的过程,3*3到4*4可类推。
【原创】《矩阵的史诗级玩法》连载五:45度地图砖块所蕴含的矩阵基础知识(下)_45度地图深度排序-CSDN博客
3 既然合并过程中,点的坐标新增了个1,那么GPU渲染底层就把它拿过来做透视变换了,并且赋予变量w。
4 3D点为齐次坐标(x, y, z, w),物体自身的变换Model,相机变换都各自有一个矩阵View,投影到屏幕/画布的NDC坐标系Projection会先后作用于该坐标上得到最终结果(x/w, y/w, z/w),3个矩阵合并称为MVP矩阵。
5 齐次坐标的w值初始为1,前两个矩阵不会修改w,之后P矩阵会修改w从而产生透视效果。
这个大纲还是有点啰嗦了,下面再列一下透视投影矩阵的关键点。
1 w跟z通常成正比,可能是w=z或者w=-z。
2 最终呈现的结果变量,w在分母中。因此,如果w的绝对值很小,甚至等于0,那么这些位置的坐标绝对值将会非常地大,甚至是无穷大。
这么一顿操作之后我们发现,问题点似乎就出现在w上。视锥体的边缘会不会正是w=0的边界?
下面我们不妨就以此为切入点,去看看project方法在视锥体边缘的表现。
既然GPU渲染使用的是齐次坐标,那么three.js也会配套定义了对应的类,Vector4。我们用它来研究将会更加方便。不过Vector4只有applyMatrix4方法,没有project,我们照着Vector3的抄一个。
这里我们单独用一个简单点的相机来测试(顺带加上Vector4的project方法):
javascript
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>three_cameraProject</title>
<style>
body {
margin: 0;
overflow: hidden;
}
</style>
<script src="three/build/three.js"></script>
<script src="three/examples/js/controls/OrbitControls.js"></script>
</head>
<body>
<script>
function v4Project(v4, camera){
return v4.clone().applyMatrix4(camera.matrixWorldInverse).applyMatrix4(camera.projectionMatrix);
}
var testCamera = new THREE.PerspectiveCamera(60, 1, 1, 20000);
testCamera.position.set(0, 0, 100);
testCamera.updateMatrixWorld();
testCamera.updateProjectionMatrix();
for(var i = 95; i <= 105; i ++){
var proj = v4Project(new THREE.Vector4(2, 3, i), testCamera)
console.log("z=" + i + "时,坐标为(" + proj.x + "," + proj.y + "." + proj.z + "," + proj.w + ")");
}
</script>
</body>
</html>
这段代码创建了一个z坐标为100的透视相机,然后测试一个点坐标在100附近(95~105)变化时的投影结果。控制台输出如下:
我们看到,变化的只有z和w,并且均为单调递减,变化过程还比较平缓。但因为齐次坐标中,xyz最终都会除以w,所以最终的呈现结果就完全不一样了。分母越接近0,结果的绝对值越大,越趋向于无穷大,并且当分母从正数过渡到负数时,坐标值都会从正无穷突变到负无穷从而形成断层(数学上这叫无穷间断点)。
我们把区间取小一点,步长设短一点,并且改用Vector3的project方法进行测试
javascript
for(var i = 99; i <= 101; i +=0.125){
var proj = new THREE.Vector3(2, 3, i).project(testCamera);
console.log("z=" + i + "时,坐标为(" + proj.x + "," + proj.y + "." + proj.z + ")");
}
可以看到,从99到100,xyz的绝对值都飙升得很快。等于100的时候直接等于无穷大,因为此时的w等于0了。超过100就立马来个断层,变成负无穷大,然后绝对值又急剧下降回来。
从这里我们也可以看出,问题的本质
100是相机的z坐标,z=100时,物体刚好贴着相机,完全没距离,z>100时,物体在相机背面,完全不可见,所以这时候不管算出来的是啥值都不能说它错。但与此同时,它也是个不可用的数值。用我们技术大佬的话讲,这个结果只能用在逻辑的终点,而不能是中间的某个环节。
当然了,正交相机无需考虑这个问题,没透视变换的话,w始终等于1,不存在断层的情况。
来小结一下:
1 GPU渲染底层使用包含w的齐次坐标让开发者仅通过线性变换就能实现近大远小的非线性透视效果,非线性部分藏到了底层,w是分母。
2 project方法出问题的位置是在透视相机背面,跟视锥体范围无关,因为一个物体从相机正面到背面的移动过程中,分母w从一个很小的正数缓慢过渡到一个很小的负数,产生了断层,断层后的结果不可用。
3 一个坐标点在透视相机背面,是不会有一个正确的NDC坐标值,因此这个时候只能用来判断该点是否可见或出界,但不能再继续用它进行后续的计算。
4 THREE.Line2的shader通过trimSegment规避了越界的坐标点,但是射线检测没有做类似的处理,从而导致出界的线条存在射线检测错误的bug。
5 把LineMaterial上的trimSegment方法实现到LineSegments2的raycast方法中,问题就得到解决了。
THREE.Line2不是three.js主包的内容,而是在examples文件夹里面,尽管它也是官方提供的类,但是这样的目录规划不得不让笔者感觉到THREE.Line2更像个土八路,没入正编的样子。所以,这玩意儿还存在别的问题,下一篇我会给大家讲讲THREE.Line2的镜像问题,这又是一个大坑,请大家做好心理准备,嘿嘿!