想来编程也有一段时间,什么都很明白就是对于坐标变换不是很理解,总是在关键的时候迷乱不已,胡乱的写一些变换代码,得到的结果当然让自己云里雾里。仔细的看了一下好几本书关于3D变换的篇章,总结了一下,希望对大家有帮助。末了声明以下,可能我说得也有错误的地方,敬请局内人明鉴指正,我只是一个在校学生没有实际的工作经验。恳请大家提出宝贵的意见,打造一个Matrix Bible,让更多的初学者不要走弯路。谢谢大家。
矩阵变换是个相当重要的要点,难度应该仅次于数据结构部分。倒不是因为本身掌握知识对能力的要求有多么高,而是因为从来没有人说明白过在实际情况中如何应用。
在现代游戏的制作过程中,肯定是先由美工制作好要用到的模型,比如人物车辆地形等等,我们称之为基本模型。而诸如3dsmax maya等等建模工具产生的二进制文件是Application特有的格式,所以一般需要导出,各大论坛上无数人曾经提问过如何载入3ds模型。成熟的3D引擎都有自己的一套Util工具用来把模型导出为引擎特有的数据格式,比如Doom3引擎开源论坛上就提供3dsmax maya等使用的导出插件,用来输出为MD5格式的模型文件。其中会用到一种叫做Data Chunk的概念,不再多说。
当美工制作模型的时候,肯定以建模工具提供的那个坐标系为基本坐标系进行建模。模型的顶点都是相对于各自基本的坐标系,我们称之为Local坐标系统或者Object坐标系。
美工把这些数据交给程序员。程序员需要在场景中安放这些模型,比如在地图上放置建筑车辆人物等等。可是程序员面对的这些模型的坐标,数字可能是一样的,因为都是相对于Local坐标系统,就这样一股脑的载入,肯定都是在“世界”的中心位置进行绘制,根本不可能分开。于是我们需要对各个物体,也就是各个独立的坐标系统进行Transform(包括Translate Rotate Scale操作)。
这里我们以GL为概念。当我们输入glutSolidCube(4)的时候,它会产生这样的数据:glVertex3f(2,2,2),glVertex3f(-2,-2,-2)等等,也就是长度为4的一个立方体。注意我们画的这个立方体的位置,肯定是出现在“世界”的中心位置。如果我们希望它移动到其他的位置呢?只能先glTranslatef(),再glutSolidCube。这个glTranslatef作用在MODELVIEW_MATRIX上,具体的形式请到OpenGL Wiki上看,那里连载了RedBook。
比如我们输入glTranslatef(1.0,0,0);glutSolidCube(4),其实它产生的“真正顶点”是,(3,2,2),(-1,-2,-2),统统向x方向移动了一个位置。如果你在自己的范例程序里看不到是因为perspective中的far near planes没有设置好。这样我们就仿佛实现了平移以及旋转等等操作,注意,是仿佛。
我们把变换顶点的矩阵一般称为MV(Model View)矩阵,把和在一起一步到位的矩阵称为MVP矩阵,在GLSL中就有gl_ModelViewMatirx和gl_ModelViewProjectionMatrix这两个Uniform Matirx。我们输入一个顶点,希望把它放到这个世界的正确位置上,就需要乘以适合它自己的MV,因为不一样的模型当然需要不一样的世界位置。矩阵乘法就可以完成这项神奇的工作。可是向量的概念则很大不同。
一个正方体,只要它在我们的映像中从头到尾都是方方正正的立在场景中,它的向量,无论朝上朝下都应该是相同的,比如(1,0,0)左边的面,只要我们不旋转这个正方体,它在Local坐标系还是变换后也应该是(1,0,0),这个时候我们用哪个矩阵呢?用MV显然不同,就需要用MV的Inverse Transpose。在线性代数中,求一个矩阵的Inverse然后Transpose,与先求Transpose再求Inverse,这两边是完全相等的。在GLSL中,其实gl_Normal*gl_NormalMatrix等同于gl_Normal*gl_ModelViewInverseTransposeMatrix。REDBOOK是这样说的:
In other words, normal vectors are transformed by the inverse transpose of the transformation that transforms points.
为什么有这样的变化呢?我们用V(x,y,z,w)代表顶点,P(a,b,c,d)代表一个平面。相应的平面方程可以写作,PV = 0,也就是ax + by + cz = 0,有个向量垂直于顶点所在的那个面。
这是个万用公式么?还早呢?如果我们要把这个模型的位置改变掉,我们就一定需要把顶点乘以ModelView矩阵,为了方便我就用M代表MV。这里写作:
PMV = ?
可是这个式子右边等于什么呢?我也不知道。为了这个式子依旧让右边等于0,符合基本的几何代数式,我们需要再给左边乘以M-1,就是M的逆矩阵。
P M-1 M V = 0
有一个向量垂直于这个平面。于是引入n一个我们真正意义上的面向量,和平面内的任意一个向量都应该是正交的。那个任意向量如何获得呢?最简单的就是,那个顶点和原点构成的向量 —— 因为在我们最初的式子里面,默认这个平面就是通过原点的。我们想让等式依旧成立,式子变成:
N T V = 0
注意上式的T。如果单纯的N矩阵乘以V,得到的结果还是一个向量而不是数字,所以需要一个Transpose变换。综合后,式子变换为:
N T M-1 M V = 0
(如果我没有理解错的话,V应该隐含着用了2次)
好的,我们开始用矩阵运算法则去分解上述的式子。MV不变,剩下的也就是(M-1)T N,也就是需要ModelView矩阵的Inverse Transpose矩阵乘以向量。
只要这些明白了,高级变换也就没有什么难得了。
Use Case
古老的bump mapping
在BumpMapping里面有个很重要的过程,就是把光源位置转换到以每个顶点处的向量为Z轴的空间中去,求向量的方法我不多说,为什么这样做也没有必要讲,最关键的就是可能很多人不明白为什么要乘以以N B T为元素的矩阵。
其实这里很多书籍要么没有解释,要么一笔带过。我来尝试的解释通透,可能有错误,希望大家指正。
GL的Matrix是Column-Major的形势。我们以N B T为元素的矩阵为例。
Nx Bx Tx
Ny By Ty
Nz Bz Tz
这里隐含的意思是:把顶点变换到以N B T为3个坐标轴的空间中,无论这个坐标系是不是和世界坐标系统“倾斜”的。
再次写成4x4的形式:
Nx Bx Tx 0
Ny By Ty 0
Nz Bz Tz 0
0 0 0 1
注意第四列的连续三个0。代表的意义是:每个顶点变换到以这个N B T为坐标轴的坐标系后,需要Translate到的位置。因为在Bump Mapping中我们不需要对顶点的位置变换,所以隐含着写成3x3的Matrix就足够了。
这里的这个3x3矩阵,其实就是对于每个顶点来说的MV矩阵,转换的就是那个LightPosition。可是这里又有一个问题,如果转换的不止一个LightPosition,还有Normal怎么办?因为这是个“斜”的坐标系,原来的(1,0,0)可不是变换后的(1,0,0)。记起来了么?Inverse Transpose!我们只要把原来的向量乘以这个以NBT为正交坐标轴向量的MV的Inverse Transpose,就可以得到正确的结果了。(我说得对么?)其实,因为这个矩阵3个向量都已经normalized,它的Inverse = Transpose,所以这个NBT矩阵的IT矩阵就是它自己!
微软DirectX SDK Oct里面有个Shadow Mapping的Sample,代码中有一部分详细的说明了这个过程。它需要变换光源的向量,如果当我们把光源绑定到车上,就需要更改矩阵中w行的元素的。有兴趣的朋友可以看看。
gl_LightPosition提供的光源参数是针对Eye Space,也就是所有的顶点已经经过MV变换的空间中的那个点。如果你有自己的光源安排一定要在空间中互相转换,头疼。GLSL用Uniform3f自己指定变换后空间中光源位置,比较方便,适合完成以场景为单位的光照计算。
不再新潮的Shadow Mapping
Shadow mapping,包括后来的Variance SM,PCF等,有个关键的步骤,就是把场景转换到以光源为摄像机的空间LightCamera中,获得深度。这里,场景中所有的顶点需要变换到以LightCamera的NBT为坐标轴的坐标系中,向量的正确变换则需要乘以NBT的Inverse Transpose矩阵。(我推测的,希望大家指正)。接下来的事情么,在Shader中爱做什么做什么。
那么如何传入所需要的矩阵呢?其实相当简单。功夫厉害的,直接把数组通过glUniform4fmatrix(),或者cgSetParameter传入。功夫弱一些的,老老实实的gl_MatrixModel(GL_MODELVIEW);glLoadIndentity();glMultMatrix();//乘以需要的矩阵到单位矩阵上,然后后再传入Shader。
有的时候我们需要自己独立求逆矩阵,如何办到呢?这可不是纸上的线性代数考试可以用初等变换计算。
矩阵的变换和逆变换就那么3种,Translate,Rotate,Scale。
我们知道MV = T * R。(T R代表为了实现Translate和Rotate相应的矩阵)
则Inv(MV) = Inv(T) * Inv(R)
也就是说,假设MV是
( R R R P)
( R R R P)
( R R R P)
( 0001)
则它所代表的 R T矩阵就是
( R R R 0)
(R R R 0)
( R R R 0)
( 0001)
和
( 100 P)
( 010 P)
( 001 P)
( 0001)
计算相应子矩阵的逆矩阵,Inv(T)就是
( 100 -P)
( 010 -P)
( 001 -P)
( 000 1)
逆旋转矩阵可能复杂一些,不过依旧可以计算出来,也就是它的Transpose。然后乘一下,逆矩阵就出来了。
目前我所想到的关键就这么多,更多的恳请大家添加,谢谢。
既然是SM的DEMO,在LightSpace和CameraSpace之间进行变换肯定是少不了的。当然,也应用到了多通道的思想。
glPushMatrix();
glRotatef( - 90 , 1 , 0 , 0 );
glScalef( 4 , 4 , 4 );
glBegin(GL_QUADS);
glNormal3f( 0 , 0 , 1 );
glVertex2f( - 1 , - 1 );
glVertex2f( - 1 , 1 );
glVertex2f( 1 , 1 );
glVertex2f( 1 , - 1 );
glEnd();
glPopMatrix();
quad.end_list();
wirecube.new_list(GL_COMPILE);
glutWireCube( 2 );
wirecube.end_list();
geometry.new_list(GL_COMPILE);
glPushMatrix();
glTranslatef( 0 , .4f, 0 );
glutSolidTeapot(.5f);
glPopMatrix();
geometry.end_list();
首选我们新建了3个显示列表,可以看出,quad的意义是,处在世界平面的x z平面的尺寸为4x4的一个平面(先画xy平面内的点,不过又旋转了90度)。geometry么,就是那个著名的nurbs茶壶,我们想象为在世界平面y向上的0.4f处。注意每次绘制前都会调用glPushMatrix把MV矩阵推入Stack,这个步骤相当重要,因为我们还不知道前面的坐标系,究竟在哪里,不过后面我们又看到了如何解决这个问题。
{
glColor3f( 1 , 1 , 1 );
glPushMatrix();
view.apply_inverse_transform();
glPushMatrix();
object .apply_transform();
render_quad();
glEnable(GL_LIGHTING);
geometry.call_list();
glDisable(GL_LIGHTING);
glPopMatrix();
glPopMatrix();
}
通篇代码阅读完毕,发现这个函数最重要。参数view,我的理解是,它是View变换矩阵,也就是储存了3个正交单位向量,有可能包括眼睛的位置(注意是有可能),无论这个眼睛是摄像机,还是光源。
不过这个view.apply_inverse_transform(),它究竟代表了哪些操作呢?让我们在nvidia自己写的glh文件里面探寻一下吧。
{
translator.apply_transform();
trackball.apply_transform();
}
void apply_inverse_transform()
{
trackball.apply_inverse_transform();
translator.apply_inverse_transform();
}
如果要调用apply_transform()进行坐标变换,那么是 先位移,再旋转。如果要返回到最初的坐标系,那么就应该是 先旋转回来,再位移回去。知道为什么么?
我们默认的位移其实应该是相对于World Coordinate,也就是说,我们意义上的向xyz方向移动几个单位其实是在那个最初的平面世界中的,而不是应该在摄像机空间中的位移 —— 因为最初世界坐标系里面的三个正交方向向量其实也已经旋转过了,也就是说,如果我们先旋转再位移,得到的轨迹相对于我们脑海中的世界坐标系是一条斜直线 —— 虽然说它对于摄像机坐标系来说是坐标轴直线。
如果用线性代数的性质也很好解释,本来正确的transform顺序(原因在上面)就是I*T*R,如果要回到I,就必须I*T*R*R-1*T-1 = I。OpenGL的matrix操作是右结合的。
这里的 view.apply_inverse_transform()就好理解了。不管我渲染什么,我总是要先把坐标系放回到世界坐标系中的原点处,保存好当前矩阵,然后再调用显示列表。不过我们又发现那个render_quad(),好,我们再把它揪出来。
{
glActiveTextureARB(GL_TEXTURE0_ARB);
obj_linear_texgen();
texgen( true );
glMatrixMode(GL_TEXTURE);
glLoadIdentity();
glScalef( 4 , 4 , 1 );
glMatrixMode(GL_MODELVIEW);
glDisable(GL_LIGHTING);
decal.bind();
decal.enable();
quad.call_list();
decal.disable();
glEnable(GL_LIGHTING);
texgen( false );
glMatrixMode(GL_TEXTURE);
glLoadIdentity();
glMatrixMode(GL_MODELVIEW);
}

{
//放置灯光
glPushMatrix();
glLoadIdentity();
glLightfv(GL_LIGHT0, GL_POSITION, & vec4f( 0 , 0 , 0 , 1 )[ 0 ]);
glPopMatrix();
//为什么这里光源是(0,0,0)呢?gl的光源坐标是在object coordinates中,也就是它要被I矩阵转换,结果依旧是EyeSpace中的(0,0,0)
// spot image
glActiveTextureARB(GL_TEXTURE1_ARB);
glPushMatrix();
eye_linear_texgen();
texgen( true );
glPopMatrix();
glMatrixMode(GL_TEXTURE);
glLoadIdentity();
glTranslatef(.5f, .5f, .5f);
glScalef(.5f, .5f, .5f);
gluPerspective(lightshaper.fovy, 1 , lightshaper.zNear, lightshaper.zFar);
//这里生成的是一个生成纹理坐标的矩阵,它的形式是I*T*S*P,提供给处于以光源为原点的场景坐标使用。
glMatrixMode(GL_MODELVIEW);
light_image.bind();
light_image.enable();
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
glActiveTextureARB(GL_TEXTURE0_ARB);
lightshaper.apply();
if (display_funcs[current_display_func] == render_scene_from_light_view)
largest_square_power_of_two_viewport();
render_scene(spotlight);//让思路回到上面的那个函数,仔细体会
glActiveTextureARB(GL_TEXTURE1_ARB);
light_image.disable();
glActiveTextureARB(GL_TEXTURE0_ARB);
}
再把这个函数贴出来,请自己仔细推敲变换过程。
{
// place light
glPushMatrix();
glLoadIdentity();
camera.apply_inverse_transform();
spotlight.apply_transform();
glLightfv(GL_LIGHT0, GL_POSITION, & vec4f( 0 , 0 , 0 , 1 )[ 0 ]);
glPopMatrix();
// spot image
glActiveTextureARB(GL_TEXTURE1_ARB);
glPushMatrix();
camera.apply_inverse_transform();
eye_linear_texgen();
texgen( true );
glPopMatrix();
glMatrixMode(GL_TEXTURE);
glLoadIdentity();
glTranslatef(.5f, .5f, .5f);
glScalef(.5f, .5f, .5f);
gluPerspective(lightshaper.fovy, 1 , lightshaper.zNear, lightshaper.zFar);
spotlight.apply_inverse_transform();
glMatrixMode(GL_MODELVIEW);
light_image.bind();
light_image.enable();
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
glActiveTextureARB(GL_TEXTURE0_ARB);
reshaper.apply();
render_scene(camera);
glActiveTextureARB(GL_TEXTURE1_ARB);
light_image.disable();
glActiveTextureARB(GL_TEXTURE0_ARB);
render_light_frustum();
}
看哪,天梯!
说了这么多的东西,贴了这么多代码,我们究竟应该把握住哪些东西呢?
1、计算出自己需要的View变换矩阵,从此告别gluLookAt或者D3DXMatrixLookAtLH
首先选择Eye所在世界中的位置,比如说在 (4,4,4)处。选择目光所看的点,比如原点 O(0,0,0),或者一个方向向量 D(-4,-4,-4)。
选择一个世界坐标系中Up向量,在GL中就是 UpTmp(0,1,0)。
得到一个新向量 C = cross(D,UpTmp)。注意是D叉乘UpTmp。
仍掉那个UpTmp。 U(Up)= cross(C,D)。
完成了大半工作了!让我们继续。
D.normalize();C.normalize();D.normalize();把向量缩放为单位长度。
构造这个矩阵。你可以理解为一个定义在原点的旋转矩阵:
matrix4f v( c[0],c[1],c[2],0,
u[0],u[1],u[2],0,
-d[0],-d[1],-d[2],0,
0,0,0,1
);
再次引用Eye的位置(4,4,4),构造一个translate矩阵:
matrix4f t(1,0,0,-4,
0,1,0,-4,
0,0,1,-4,
0,0,0,1
);//注意是负的,因为这是用center - eyepos得到的
有了这两个矩阵,一切就都好办了。我们就可以得到一个View Transform的完整矩阵:
matrix4f ViewTransformMatrix = v.mult_right(t);注意是右乘,它的效果等同于:
glMatrixMode(GL_MODELVIEW);
glLoadIndentity();
glMultMatrixf(v);//这里只是比喻一下
glTranslatef(-4,-4,-4);
有了这个变换矩阵后,我们还需要它的逆矩阵。
matrix4f ViewTransformInverseMatrix = ViewTransformMatrix.inverse();
接下来把数据放到2个数组中去。
for ( j = 0 ;j < 4 ;j ++ ){
ViewTransformMatrixArray[i * 4 + j] = ViewTransformMatrix .element(j,i);
ViewTransformInverseMatrixArray[i * 4 + j] = ViewTransformInverseMatrix .element(j,i);
}
注意,OpenGL的矩阵是Colunm - Major的顺序,所以载入数组的时候需要把i j位置替换下。
display( void )
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// gluLookAt(4,4,4,0,0,0,0,1,0);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
glMultMatrixf(ViewTransformMatrixArray);
glMultMatrixf(ViewTransformInverseMatrixArray);
glMultMatrixf(LightViewTransformMatrix);//我生成了2套矩阵,分别用于Eye和Camera
/*
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
gluLookAt(4,4,4,0,0,0,0,1,0);
*/
glPushMatrix();
glPointSize( 4.0f );
glBegin(GL_LINES);
glColor3f( 0 , 1.0 , 0 );
glVertex3f( 0 , 0 , 0 );
glVertex3f( 1 , 0 , 0 );
glVertex3f( 0 , 0 , 0 );
glVertex3f( 0 , 0 , 1 );
glVertex3f( 0 , 0 , 0 );
glVertex3f( 0 , 1 , 0 );
glEnd();
glPopMatrix();
glPushMatrix();
glTranslatef( 0 ,ypos, 0 );
glutSolidSphere( 0.5 , 32 , 32 );
glPopMatrix();
glutSwapBuffers();
}
是不是觉得我多此一举?为什么要乘来乘去的,不就是回到单位矩阵么?事实上我曾经调试了很多次,通过比较输出gluLookAt(4,4,4,0,0,0,0,1,0)生成的矩阵和自己生成的矩阵是否相同,结果正确的变换到了LightView空间。
对光源位置的转换
这个问题讨论已久,仿佛久久没有标准,总是有初学者不断提问,而我们回答的也总是一个子集,治标不治本。
在上文中,我们已经生成了用于转换Object Space Coordinates的2个MV矩阵以及相应的逆矩阵。我们先从固定管线的Phone光照模型的GL入手,看看如何正确的转换光源。我们先看看gl manual怎么定义那个GL_POSITION的。



意思是,我们指定的坐标是Object Space空间的坐标,然后被MV转换。W是作为齐次缩放系数使用的,0代表无限远好象太阳光束。
我们上面已经提到光源的位置在(-2,4,2)。这里我们写成无限远的(-2,4,2,0)。为了测试起见,我的显示函数写成了切换视点的模式。


















































注意看switch开关。如果我切换到Camera,我将看到这样。
如果切换到光源视图,是这样的。
下面让我们来看看为什么,还有注释掉的矩阵乘法代码。
第一个case:我们用载入ViewTransformMatrix,下面声明LightPosition,是(-2,4,2,0),这个坐标是Object Space的坐标,在我们的想象中,就是相对于世界坐标系的位置,也就是每次我绘制一个Sphere所产生的位置。
第二个case:载入LightViewTransformMatrix,依旧传入(-2,4,2,0),得到的结果依旧正确。
最好自己向自己复述一遍,注意一定要联系我们上面计算矩阵的算式。
然后我们把case0代码改一下。













我们要好好剖析第二个PushMatrix,LoadIndentity后的那两个连续的矩阵乘法,还有为什么光源成了(0,0,0,1)。NVIDIA的那个render_scene_from_camera也是这样放置光源的。让我们看看为什么。
这个V(0,0,0,1)是Object Space中的点。我们先用Mvt代表ViewTransformMatrix,再用Mlvti代表LightViewTransformInverseMatrix。写成完整的算式应该是
Mvt(Mlvti * V)
想起来了么?矩阵乘法的结合形式,意思是,“ vertex V under transformed by Matrix Mlvt”。这里产生光源的过程如下:
Object Space中的(0,0,0,1)被Mlvti转换到Object空间,是多少呢?(-2,4,2,1),就是光源的相对于世界的位置。其实你也可以通过vec4f new = LightViewTransformInverseMatrix.mult_matrix_vec(vec4f(0,0,0,1))自己验证。
由于转换到LightView空间后,产生的是,世界空间和模型空间中的(-2,4,2,1) —— GL没有世界坐标,而且我们一般认为Object Space是和世界空间重合的。即使在D3D中,一般情况下初始化世界矩阵也都是载入单位矩阵。
(-2,4,2,1)再乘以Mlvt,又被转换到了 —— 其实我不知道它在哪里!相对于转换后的CAMERA坐标系,它的位置我可以手动求出来,得到的是光栅化坐标。但是它的位置的确是正确的,效果等同于直接在glLightv中传入(-2,4,2,1)
总结:
对于一个成熟的3D引擎来说,矩阵都是自己计算出来的,绝非调用API自己的指令。在NVIDIA SDK的DEMO中包含了大量成熟的基础代码,在不侵犯原作者权益的情况下应该合理的采用,省下诸多开发调试时间。我引用的HEADER文件和代码。
这里下载