尝试理解WebGL中透视矩阵背后的数学原理

12

所有WebGL的矩阵库都会有一些可以调用的perspective函数,用于获取场景的透视矩阵。
例如,在gl-matrixmat4.js文件中,perspective方法的代码如下:

mat4.perspective = function (out, fovy, aspect, near, far) {
    var f = 1.0 / Math.tan(fovy / 2),
        nf = 1 / (near - far);
    out[0] = f / aspect;
    out[1] = 0;
    out[2] = 0;
    out[3] = 0;
    out[4] = 0;
    out[5] = f;
    out[6] = 0;
    out[7] = 0;
    out[8] = 0;
    out[9] = 0;
    out[10] = (far + near) * nf;
    out[11] = -1;
    out[12] = 0;
    out[13] = 0;
    out[14] = (2 * far * near) * nf;
    out[15] = 0;
    return out;
};

我真的尝试着理解这个方法中所有数学上实际在做什么,但是有几个点让我困扰。

首先,如果我们有一个宽高比为4:3的画布,则该方法的aspect参数实际上将是4/3,对吗?

4:3 aspect ratio

我还注意到45°似乎是常见的视场角。如果是这样,那么fovy参数将是π/4弧度,对吗?

所有这些都说完后,方法中的f变量是什么意思,以及它的目的是什么?
我试图想象实际的情况,我想象出了以下情况:

Side view of [perspective in 3D scene

像这样思考,我可以理解为什么要将fovy除以2,以及为什么要取该比率的正切值,但为什么将其倒数存储在f中?我仍然非常难以理解f实际代表什么。

接下来,我理解nearfar是沿z轴的剪切点,所以没问题,但如果我使用上述图片中的数字(即π/44/310100)并将它们插入到perspective方法中,则我得到如下矩阵:

enter image description here

其中f等于:

enter image description here

所以我留下以下问题:

  1. f是什么?
  2. 分配给out[10]的值(即110/-90)代表什么?
  3. 分配给out[11]-1有何作用?
  4. 分配给out[14]的值(即2000/-90)代表什么?

最后,我应该注意到,我已经阅读了Gregg Tavares关于透视矩阵的解释,但是在所有这些之后,我仍然感到困惑。


也许这个链接能有所帮助。虽然它参考了过时的固定功能GL,但数学仍然是有效的。 - derhass
抱歉,derhass,但那个链接比我之前看过的所有链接都更加令人困惑。我想我需要的不仅是一个数学解释,还需要一个概念性的解释,说明在实际情况下发生了什么以及矩阵是如何形成的。 - HartleySan
2个回答

25
让我们看看我是否能够解释清楚这个问题,或者在阅读完之后你能想出更好的解释方式。
首先要意识到的是WebGL需要剪辑空间坐标。它们在x、y和z方向上为-1 <-> +1。因此,透视矩阵基本上是设计用来将截锥体内部的空间转换为剪辑空间。
如果您查看此图示:
https://istack.dev59.com/nRgAD.webp 我们知道正切 = 对边(y)/ 邻边(z),因此如果我们知道z,我们可以计算出在给定fovY的情况下位于截锥体边缘的y。
tan(fovY / 2) = y / -z

将两边都乘以-z

y = tan(fovY / 2) * -z

如果我们定义
f = 1 / tan(fovY / 2)

我们获取
y = -z / f

请注意,我们还没有完成从相机空间到剪辑空间的转换。我们所做的只是计算在相机空间中给定z时视野边缘的y值。视野边缘也是剪辑空间的边缘。由于剪辑空间只是+1到-1,我们可以通过将相机空间的y值除以-z / f来获得剪辑空间。
这样说清楚了吗?再看一下图表。假设蓝色的z是-5,并且对于某个给定的视野,y的结果为+2.34。我们需要将+2.34转换为+1的剪辑空间。通用版本如下:
clipY = cameraY * f / -z
看一下`makePerspective`函数。
function makePerspective(fieldOfViewInRadians, aspect, near, far) {
  var f = Math.tan(Math.PI * 0.5 - 0.5 * fieldOfViewInRadians);
  var rangeInv = 1.0 / (near - far);

  return [
    f / aspect, 0, 0, 0,
    0, f, 0, 0,
    0, 0, (near + far) * rangeInv, -1,
    0, 0, near * far * rangeInv * 2, 0
  ];
};

我们可以看到,这种情况下的f
tan(Math.PI * 0.5 - 0.5 * fovY)

这实际上与

1 / tan(fovY / 2)

为什么要这样写?我猜是因为如果你采用第一种方式,当tan的结果为0时,你会除以0,导致程序崩溃。而如果你采用这种方式,就不会有除法,也就不会出现除以0的情况。
看到-1matrix[11]位置上,意味着我们完成后...
matrix[5]  = tan(Math.PI * 0.5 - 0.5 * fovY)
matrix[11] = -1

clipY = cameraY * matrix[5] / cameraZ * matrix[11]

对于clipX,我们基本上进行相同的计算,只是针对纵横比进行了缩放。
matrix[0]  = tan(Math.PI * 0.5 - 0.5 * fovY) / aspect
matrix[11] = -1

clipX = cameraX * matrix[0] / cameraZ * matrix[11]

最后,我们需要将相机Z在-zNear <-> -zFar范围内转换为-1 <-> +1范围内的剪辑Z。标准透视矩阵使用倒数函数完成这个过程,使得靠近相机的z值比离相机远的z值获得更高的分辨率。该公式为:

Multiplicative_inverse

clipZ = something / cameraZ + constant

让我们使用s代表 something, 使用c代表 constant。

clipZ = s / cameraZ + c;

并求解sc。在我们的情况下,我们知道

s / -zNear + c = -1
s / -zFar  + c =  1

所以,将 `c' 移到另一侧。
s / -zNear = -1 - c
s / -zFar  =  1 - c

乘以-zXXX

s = (-1 - c) * -zNear
s = ( 1 - c) * -zFar

这两个东西现在相等了,所以
(-1 - c) * -zNear = (1 - c) * -zFar

扩展数量
(-zNear * -1) - (c * -zNear) = (1 * -zFar) - (c * -zFar)

简化
zNear + c * zNear = -zFar + c * zFar

zNear向右移动

c * zNear = -zFar + c * zFar - zNear

c * zFar向左移动。

c * zNear - c * zFar = -zFar - zNear

简化
c * (zNear - zFar) = -(zFar + zNear)

除以(zNear - zFar)

c = -(zFar + zNear) / (zNear - zFar)

解决s的问题

s = (1 - -((zFar + zNear) / (zNear - zFar))) * -zFar

简化
s = (1 + ((zFar + zNear) / (zNear - zFar))) * -zFar

1 改为 (zNear - zFar)

s = ((zNear - zFar + zFar + zNear) / (zNear - zFar)) * -zFar

简化

s = ((2 * zNear) / (zNear - zFar)) * -zFar

进一步简化

s = (2 * zNear * zFar) / (zNear - zFar)

我希望StackExchange能像他们的数学网站一样支持数学:(

回到正题。我们的公式是

s / cameraZ + c

现在我们知道了 sc

clipZ = (2 * zNear * zFar) / (zNear - zFar) / -cameraZ -
        (zFar + zNear) / (zNear - zFar)

让我们把-z移到外面

clipZ = ((2 * zNear * zFar) / zNear - ZFar) +
         (zFar + zNear) / (zNear - zFar) * cameraZ) / -cameraZ

我们可以将/(zNear - zFar)改为* 1 /(zNear - zFar),这样

rangeInv = 1 / (zNear - zFar)
clipZ = ((2 * zNear * zFar) * rangeInv) +
         (zFar + zNear) * rangeInv * cameraZ) / -cameraZ

回顾一下makeFrustum函数,我们可以看到它最终会生成...
clipZ = (matrix[10] * cameraZ + matrix[14]) / (cameraZ * matrix[11])

看一下上面适用的公式

rangeInv = 1 / (zNear - zFar)
matrix[10] = (zFar + zNear) * rangeInv
matrix[14] = 2 * zNear * zFar * rangeInv
matrix[11] = -1
clipZ = (matrix[10] * cameraZ + matrix[14]) / (cameraZ * matrix[11])

我希望这有意义。注意:大部分内容只是我对这篇文章的改写。


自从您几天前发布了回答以来,我一直在绞尽脑汁地思考您的答案,最终,我开始理解了,虽然我承认我还有点困惑。我也非常仔细地查看了您的帖子以及关于透视矩阵的解释... - HartleySan
Math.tan(fovy / 2) 实际上描述了 yz 之间的关系。(毕竟,这只是正切的基本三角函数定义。) 因此,它的倒数等于 maxZ / maxY。因此,当您将顶点的 y 部分乘以该值时,除以 maxY 的效果实际上是将 y 规格化为从 0 到 1。从那里开始,Z 越大,y 值也就越大。我明白为什么您为 x 做同样的事情,但也要考虑纵横比。那没问题。现在,我的困惑点在于 z 部分。首先... - HartleySan
添加了一些内容,但我自己也有点困惑:p - gman
我根据找到并链接的一篇文章重新编写了整个程序。看看它是否有帮助。 - gman
我真的很难理解这行代码:“如果我们定义f = 1 / tan(fovY / 2)”,为什么要这样做? - JHRS
显示剩余4条评论

0

f是一个因子,它可以缩放y轴,使得在透视除法后,您的视锥体顶部平面上的所有点都具有y坐标为1,底部平面上的点具有y坐标为-1。尝试插入其中一个平面上的点(例如:0, 2.41, 12, 7.24, 3),您就可以看到为什么会发生这种情况:因为最终得到的预除法y等于齐次w。


Sneftel,谢谢你的回答。你所说的“right plane”和“left plane”是什么意思?另外,你的答案听起来像是f只是相对于y值进行归一化,但我不明白为什么以及这与齐次w有什么关系。此外,请你提供一些关于上述问题2-4的见解好吗?非常感谢。 - HartleySan
抱歉,我应该说 “顶部或底部” 平面。考虑所有在空间中的点,在渲染时会被绘制为屏幕的极端顶部或底部像素。它们形成了世界上的平坦表面。 - Sneftel
至于第2点和第4点,就像ff/aspect将x和y缩放到(-1,1)范围一样,它们将z缩放到(-1,1)范围内。第3点是设置透视除法。我认为你可能需要在纸上玩弄一下,以了解这里正在发生的事情。特别是,请看看您是否可以弄清楚为什么具有较大z坐标的点比具有较小z坐标的点更接近绘制。 - Sneftel

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接