初探Stage3D(三) 深入研究透视投影矩阵
关于本文
本文主要讲解从数学的角度如何推导出Stage3D中用到的两个投影矩阵
perspectiveLH
public function perspectiveLH(width:Number,height:Number,zNear:Number,zFar:Number):void { this.copyRawDataFrom(Vector.<Number>([ 2.0 * zNear / width, 0.0, 0.0, 0.0, 0.0, 2.0 * zNear / height, 0.0, 0.0, 0.0, 0.0, zFar / (zFar - zNear), 1.0, 0.0, 0.0, zNear * zFar / (zNear - zFar), 0.0 ])); }
perspectiveFieldOfViewLH
public function perspectiveFieldOfViewLH(fieldOfViewY:Number,aspectRatio:Number,zNear:Number,zFar:Number):void { var yScale:Number = 1.0 / Math.tan(fieldOfViewY / 2.0); var xScale:Number = yScale / aspectRatio; this.copyRawDataFrom(Vector.<Number>([ xScale, 0.0, 0.0, 0.0, 0.0, yScale, 0.0, 0.0, 0.0, 0.0, zFar / (zFar - zNear), 1.0, 0.0, 0.0, (zNear * zFar) / (zNear - zFar), 0.0 ])); }
参考资料
- Twinsen 写的两篇文章 深入探索透视投影变换&深入探索透视投影变换(续) http://blog.csdn.net/popy007/article/details/1797121
在此感谢Twinsen的帮助和点化 呵呵
关于线性插值的解释
Twinsen文章中已经给出了线性插值的公式,包括Wiki里面也给出了推导。之前对这个公式很困惑,感觉似曾相识却又不是很好理解。当完全搞透矩阵转换后,返回来再看线性插值,发现这完全就是初中时候学的直线方程嘛!
线性插值的描述:
给一个x属于[a, b],找到y属于[c, d],使得x与a的距离比上ab长度所得到的比例,等于y与c的距离比上cd长度所得到的比例,用数学表达式描述很容易理解:
这样,从a到b的每一个点都与c到d上的唯一一个点对应。有一个x,就可以求得一个y。
此外,如果x不在[a, b]内,比如x < a或者x > b,则得到的y也是符合y < c或者y > d,比例仍然不变,插值同样适用。
这个是直接复制Twinsen上面的原话,现在让我们换个角度考虑这句话,看Wiki上面这张图
其中x0,x1就是对应的a,b. y0,y1就是对应的c,d。换成我们比较熟悉的表述方法就是,已知直线上的两点求该直线方程。这样大家就明白应该怎么做了吧
线性插值的作用
在矩阵推导过程中线性插值有何用处?其实线性插值解决的就是一个压缩坐标点区域的作用,比如p点属于区域A(0,600),希望将其压缩成区域B(0,400).使得在区域B中的p’和区域A中的p存在一一对应的关系。
解决方式就是画图,注意横纵坐标的名称,分别为压缩前的区域和压缩后的区域。而那条红线的数学表示方法也就是线性插值
二维平面上面的投影
首先让我们抛开z值,先推导x,y方向上面的投影 (具体的推导过程请参考Twinsen的文章)
x和y两个方向的最终投影为
换句话表述就是,根据公式 x'=N*(x/z) 也就可以得到世界坐标系中的x点在投影平面上面的x’点。
关于CCV(Canonical View Volume)和NDC(Normalized Device Coordinates)
在Adobe的Working with Stage3D and perspective projection文章中,介绍过NDC的概念。文章在介绍该概念的那段最后一句话提到
Stage3D and the GPU use the data(此时data指的是NDC后的顶点数据) from the output of your Shader in clip space form to carry on internally with the perspective divide.
也就是在后续的渲染管道中Stage3D和GPU都认为你已经通过矩阵转化将x,y坐标(目前先不讨论z),转化到了(-1,1)这个范围内。
回头再来看之前推导出的x'点(x'=N*(x/z)),该点的区域在没压缩前是投影面的(left,right),
而我们需要做的也就是将这个区域A(left,right)压缩到区域(-1,1)
具体的数学推导可以看Twinsen的文章,通过推导得出特殊形式下(投影平面的中心和x-y平面的中心重合) 新的x,y坐标方程为
此时 right-left 也就是width ,top-bottom 就是height.
再来看perspectiveLH中前两行里面不为0的值,就是推导公式中的结果,其中Twinsen公式中的N对应的是perspectiveLH公式中的zNear
public function perspectiveLH(width:Number,height:Number,zNear:Number,zFar:Number):void { this.copyRawDataFrom(Vector.<Number>([ 2.0 * zNear / width, 0.0, 0.0, 0.0, 0.0, 2.0 * zNear / height, 0.0, 0.0, 0.0, 0.0, zFar / (zFar - zNear), 1.0, 0.0, 0.0, zNear * zFar / (zNear - zFar), 0.0 ])); }
注意投影平面的left,right可能为任何值,不一定非要为-1,1。只有在压缩后新的x'值的取值范围才从left,right压缩到了(-1,1)
关于width 和 height 取值的进一步说明
因为Shader最后需要将点范围缩至(-1,1)这个范围,所以perspectiveLH中的width参数也就一定为1-(-1)=2,但是height参数为2么?这个就要看你屏幕区域是否为正方形。
context3D.configureBackBuffer(500, 500, 1, true); //方形区域 context3D.configureBackBuffer(800, 600, 1, true); //普屏4:3 context3D.configureBackBuffer(1440, 900, 1, true); //宽屏16:9
在Stage3D被使用时候都需要设置背景缓冲区大小,这个函数同时也就设置了宽高比.
我以前错误的以为Stage3D会很智能,在configureBackBuffer函数中设置好宽高比后,代码中用到的投影函数都应该把x,y方向转化成(-1,1)范围内。后续模块处理完这些数据时候,光栅化时候自动根据宽高比显示在屏幕上。
后来做了一个小Demo测试发现,Stage3D并没有处理这件事情。也就是需要使用者自己处理这个问题。
所以,如果你选择使用perspectiveLH作为投影矩阵,configureBackBuffer时候选择的宽高比是4:3(不用关心具体的像素值,只关心比例),
那么真正的height区域就应该是(-1/aspectRatio,1/aspectRatio). 其中aspectRatio=4/3
关于Z值
当理解了x,y如何转换为x'和y'并且完成了线性插值,现在可以让我们来看看关于z值的问题了, 这个问题我琢磨了很久,但是到目前为止还是有些疑惑。
总体来说,对于透视投影这件事,其实是不需要z值。因为最终已经投影到了一个平面上面,一个平面只关心x,y两个坐标。对于z值,其实是无关紧要的。那什么地方需要用到?
1·排序
2·纹理映射
关于这些又是另外两个比较大块的区域,我查阅了一些资料但理解的还是不太透彻。可以得出的大概思想是
在世界坐标系中可以使用线性插值,但是由于做了投影计算,如果对变换后的点z'(假设就是直接复制z值的变换),使用线性插值其结果是错误的
可以认为图中蓝色区域的线段为投影后的区域,红色为投影前,蓝色区域每一小块是相等的,但是红色区对应却不相等。
这个问题同时会影响排序,纹理映射,所以要选择1/z 而不是直接使用z值。但使用1/z 并不是直接把原来的z值变成1/z而是
要变成z= a(1/z’)+b 这种形式,也就是一般的直线方程形式。
关于为何要使用1/z 而不是使用z值 可以参考Twinsen的另外两篇文章 深入探索透视纹理映射(上,下),还有 《3D游戏大师编程技巧》书中的第11章 深度缓存和可见性。
关于第二个问题,为何不直接使用1/z 而要用 a(1/z')+b的形式,我还是没太明白,所以就不做共多介绍了,如果有人比较懂的话,麻烦告诉我一声 谢谢。
如果可以理解为何将z变成 a(1/z')+b这件事之后,后续的数学推论Twinsen的文章中已经写得非常清楚了,大体思想就是同x,y一样。将变换后的新z值也线性插值到(-1,1) 或者(0,1) 空间内
如何理解perspectiveFieldOfViewLH里面的矩阵
perspectiveFieldOfViewLH和perspectiveLH其实是同一个东西,要理解这个问题我们还要看一下如下两张图
首先先看第一张图,如何确定p'的坐标,其实是看是看np平面的位置,和其他变量没有任何关系。也就是np平面越靠近fp平面,p'点的位置就越往外。
或者换句话说p'点的x值越大,显示在平面上则该图形最大。
另外还有一个已知条件就是np平面的宽度为1-(-1)=2.
然后再让我们来看第二张图,黄线和绿线对应的就是np平面。一种方式是给定zNear值,也就是直接指定该平面距离原点的偏移值。
另外一种方式是指定角度FOV(Field Of View)也就是指定黑色或者蓝色那个角度有多少
然后 根据三角函数可以得到 1/zNear= Math.tan(fieldOfViewY / 2.0);
其中 1/zNear中的1 是因为黄线或者绿线的总宽度为2,一半就是1。fieldOfViewY/2 就是一半的角度.
此时让我们回过头再来看perspectiveFieldOfViewLH中的矩阵
public function perspectiveFieldOfViewLH(fieldOfViewY:Number,aspectRatio:Number,zNear:Number,zFar:Number):void { var yScale:Number = 1.0 / Math.tan(fieldOfViewY / 2.0); var xScale:Number = yScale / aspectRatio; this.copyRawDataFrom(Vector.<Number>([ xScale, 0.0, 0.0, 0.0, 0.0, yScale, 0.0, 0.0, 0.0, 0.0, zFar / (zFar - zNear), 1.0, 0.0, 0.0, (zNear * zFar) / (zNear - zFar), 0.0 ])); }
其中yScale也就是上图解释的问题,而xScale是根据宽高比得出来的,详情可以参考 关于width 和 height 取值的进一步说明 这一小节的内容。
总结
到此我应该已经阐述清楚了perspectiveLH和perspectiveFieldOfViewLH是如何推导出来的,具体推导过程还请参考Twinsen的两篇文章。我只是换个角度阐述了一下对两个公式的理解,在此感谢Twinsen对我之前问题热心的解答,谢谢。
关于Stage3D渲染管道的一点疑惑
目前我对Stage3D的渲染管道还是有些疑惑的,Stage3D和GPU到底做了什么又没做什么?根据我对3D渲染管道的理解,要渲染一个物体,首先需要将物体从自身的坐标系转化到世界坐标系,
然后再进行摄像头的旋转平移等操作,这些操作完成后应该做一系列测试以便剔除没有必要的图形。
比如如果一个世界上面有1000个图形,先根据可见度(BPS树?)剔除大部分物体,然后对剩余物体做AABB测试,保留那些全部或者一部分在视景体内部的物体。对剩余物体进行背面消除,然后在对部分在视口内部的物体进行3D剪裁。
将最终的顶点结果传入GPU,进行后续的处理(打光和加入纹理),最后光栅化到屏幕上。
上面那张图就是Stage3D的渲染管道示意图,我本来以为Stage3D会很“聪明”,在Vertex Shader中的矩阵(也就是上面文章在介绍的,注:此矩阵仅包含投影,不是最终使用的矩阵),只要将所有坐标点全部转换为NDC形式的,Stage3D会做
剩下所有的操作,其中包括:
1·剔除不在屏幕上面显示的
2·自动将屏幕缩放为正常尺寸(详情见 关于width 和 height 取值的进一步说明 小节)
第二点根据测试,我发现Stage3D没有这么做,所以也就是需要编程者自己考虑这个问题。如果是这样将所有坐标缩放到NDC后其实Stage3D应该仅仅做了“渲染”这件事,至于和剔除相关的,也就是第一点,Stage3D应该没有涉及。
但是比较疑惑的是,上图中明显包含一个 “Viewport clipping”模块,这个模块的目的是进行2D剪裁么?并且Context3D中有setCulling这个函数,也就是说Stage3D可以根据设置参数进行背面消除。
这让我对Stage3D到底做了什么,没有做什么很是困惑。
可不可以这么说:如果我们需要做一个3D引擎,那引擎部分需要涉及到的部分有
1·根据BSP树进行剔除(剔除根本不可见的物体,比如一面墙后面的物体,或者说自己视角后方的物体)
2·进行AABB测试(剔除那些不在视景体内部的物体)
3·背面消除(由Stage3D处理,引擎不需要考虑)
4·3D剪裁(Stage3D会做2D剪裁,引擎也不需要考虑。 但是那些z值小于zNear的点怎么办?)
不知道我理解的是否正确?希望得到高手的解答,谢谢