前言:以前写了第一版,有些初学者反映许多地方还不是很明白。一来是因为说得比较琐碎,二来内容太多不容易理解。于是乎我就把原文改写以更加的明白直观,并且从建模、美工技术的角度开始讲述矩阵、坐标变换等知识,希望可以更好的帮助初学者理解。如果有错误,敬请不吝赐教。如果转载请附上我的联系方式谢谢。
一、符号以及命名风格约定
Object Coordinates = LC,以模型内部的“原点”为原点的坐标系。
Local Coordinates = OC,以每个顶点处的向量Normal为Z轴,正切Tangent向量为X(或者Y)轴,以Binormal为Y(或者X)轴的一个坐标系。
World Coordinates = WC,应用程序自己维护的一个坐标系统,主要作用是储存各个模型的位置,在处理的流水线中把模型正确的在假想的世界中变换。
Camera Space = Eye Space = ES,真正的视空间。
ModelView矩阵 = MV,把模型从模型空间直接变换到Camera空间的矩阵。
以此类推,ModelViewProject = MVP。
ModelViewInverseTranspose = MVit,MV矩阵做过线性代数IT操作后的矩阵,用来把OC空间每个顶点的向量变换到ES中。
以此类推,MVi = ModelViewInverse,MVt = ModelViewTranspose,以及MVPit = ModelViewProjectionInverseTranspose,Wiv = WorldInverseTranspose
二、从美工说起
让每个艺术设计系毕业的美工都如同我们一样精通线性代数高等数学是几乎不可能的事情,尤其是中国四年本科的学生,会熟练使用3dsmax、maya的都很少,更别提让他们阐述光照模型等CG基本概念了。于是我们需要首先了解美工的工作,以及他们制作出来的素材。这是一幅来自3dsmax中Perspective视图的截图,大家注意左下角画白 色双环的部分,我把3dsmax内部的坐标轴给标示了出来。
嗯……看似还不错的样子,一个简单的室内模型。里大家可能有疑问,这有什么特殊的么?有,而且很大。如果是玩过《翡翠帝国》或者《鬼武者》就知道,这些游戏是使用3dsmax建模的,但是它的BINK动画和游戏场景所使用的模型是一样的,这里面就牵涉到的一个模型的导出问题。对于从3dsmax导出的模型来说,遵守我们日常数学上的坐标系(注意我没有使用任何左右手的术语),Z向上,XY表示水平面。所以,如果你想使用3DS模型,并且和我一样使用它的贴图、材质,而且还想就在这个世界中进行坐标平移等等工作,请准备两样神兵利器 —— Deep Exploration和lib3ds,如何使用我后面会举例说明。
这里,如果把这个场景导出,那么,这个室内场景其实就表达了一个世界坐标系WC。因为这个坐标系是由3dsmax自己维护的。我们再从3dsmax中导出一个模型。注意左下角,坐标系我用双圆着重了。
如果假如这个玫瑰花要被放入上面的室内场景,为了精确起见,首先在3dsmax中要把这个玫瑰花对齐到它自己的XYZ坐标原点。然后在OpenGL中,如果从头开始绘制,我们可以这样写,
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
gluLookAt(/*Somewhere*/,/*Somewhere*/,/*must be vec3(0,0,1)*/);
DrawScene();
glPushMatrix();
glTranslatef(/*Somewhere*/);
DrawWhiteRose();
glPopMatrix();
为什么绘制Scene室内场景的时候没有使用glTranslatef做变换?
因为我把场景当作了一个“世界”。
为什么绘制Rose的时候使用了glTranslatef?
因为Rose自己有一个LC,为了把它放到世界内的某个角落,需要做平移变换。
所以说对于一个成熟的CG Coder来说,他肯定是使用导出的模型,使用它里面的灯光、场景、摄像机参数,而不是将一些参数硬编码。当然随着编程能力的提高,你一定也会这样,尤其是对于大型成熟的GAME、3D软件,都有自己内部的模型格式,比如,WarCraft3。
三、看一下
按照《Writing RenderMan Shaders Siggraph 1992 Course 21》这篇文章中的教程PPT,一切都简化为一下几个公式,
我想上面的三个式子已经解决了99%的问题。式子中的M就相当于GL中的MV矩阵,转换后的空间就是ES。对于变换顶点,整个过程可以如下图所示,
在这里,为什么把Scale、Rotate、Translate放在一起,这是因为,只要你知道你在做什么,你就可以调整顺序。
很多初学者在写GL程序的时候总会被Rotate、Translate变换弄的头大,其实,对于我也一样^_^。但是我知道有些好方法可以控制复杂度,少出错。
先平移还是先旋转,看了上图估计就明白了八九分,至于要什么效果可是心随你动。但是要注意,这个圆柱本身的LC可以一点都没有变化,每个顶点相对于自己的原点都没有任何变化。实时上,这种情况在实际编程中不会非常复杂。
随便打开一个游戏,场景大部分是不动的,相应的,它们的表面Normal也是不变的。运动的东西有,玩家角色本身,场景中的一些运动物体比如街上的汽车,夜总会门口拉客的女人。为什么我使用了两个这两个运动范例,因为这两个例子牵涉到2样东西,一样是只变换静态的顶点和向量,一样是变换动态的向量。
一辆车,开始建模,以XYZ为平面,车轮正好贴着在XY平面。如果我们以“米”为单位建立世界模型,假设汽车先在( 0,0,0 )处出现,然后直线移动到( 6,8,0 )处,那么就相当于是平移,程序我们可以这样写,
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
gluLookAt(/*Somewhere*/,/*Somewhere*/,/*must be vec3(0,0,1)*/);
DrawScene();
float delta_X = Speed*TimeSlice*0.6;//X方向的位移累加量
float delta_Y = Speed*TimeSlice*0.8;//Y方向的位移累加量
float S_X = 0.0;
if( S_X < 6.0 ){
glPushMatrix();
glTranslatef( delta_X,delta_Y,0 );
DrawCar();
glPopMatrix();
S_X += delta_X;
}else{
glPushMatrix();
glLoadIdentity();
glTranslatef( 6,8,0 );
DrawCar();
glPopMatrix();
}
哈,一切都很简单,模拟了一辆车的移动。在这里,首先让它一步一步的移动,到了目的地后程序判断是不是应该停下来了,如果到了目的地那么只需要在目的地绘制它就可以了。所以说对于大量物体运动的场景,CPU的负担是很大的。我们想象一下,对于一个直线运动的物体来说,它的表面Normal会变化么?答案是,不会。但是假如车辆拐弯了,也就是牵涉到了旋转,Normal肯定是变化了。所以对于一般情况下,向量是根本不需要变换的,如果物体仅仅是平移。
我们已经知道,变换Normal是通过MVit矩阵。假设我们的MV只是通过glTranslatef( 6,8,0 )得到,那么变换顶点就是这个过程。假设车的中心在(0,0,0),向量为(0,0,1)指向天空。
变换向量就是这个样子,
其实变换向量只需要MV矩阵的左上3x3的it,也就是成了这个样子,
很明显,对于只在WC中Translate平移变换来说,向量其实是不需要变换的。但是旋转了,怎么办?好办,动手计算Wit就是了。怎么算?两种方法。
第一种:先清空当前的MV,然后反相变换,然后把它弄出来再Transpose一下就可以了。比如绘制车的方式是这个样子,
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
gluLookAt(/*Somewhere*/,/*Somewhere*/,/*must be vec3(0,0,1)*/);
glPushMatrix();
glTranslatef( 6,8,0 );
glRotatef(45,0,0,1);
DrawCar();
glPopMatrix();
那么下面的代码得到Wit储存在数组中
GLdouble WCit[16];
glMatrixMode(GL_MODELVIEW);
glPushMatrix();
glLoadIdentity();
glTranslatef(-6,-8,0);
glRotatef(-45,0,0,1);
glGetDoublev(GL_TRANSPOSE_MODELVIEW_MATRIX_ARB,WCit);
glPopMatrix();
第二种方法其实更加的简便,使用现成的库,比如NVIDIA SDK中附带的nv_math库、MathGL++等。比如上面的变化,用nv_math改写后就是这样,
mat4 wc(1,0,0,0,
0,1,0,0,
0,0,1,0,
0,0,0,1);
vec3 t(6,8,0);
vec3 r(0,0,1);
wc.set_translation(t);
wc.set_rot(nv_two_pi*30.0/360.0,r);注意这里的set_rot接受弧度
for( int i =0 ; i<4; i++){
cout<<wc.mat_array[4*i+0]<<'\t'<<wc.mat_array[4*i+1]<<'\t'<<wc.mat_array[4*i+2]<<'\t'<<wc.mat_array[4*i+3]<<endl;
}
如果是用MathGL++就是这个样子,
GLMatrix<double> mat;
mat.identity();
mat.loadTranslate(6,8,0);
mat.applyRotateZ(30);//这里就方便一些可以直接输入角度
for( int i =0 ; i<4; i++){
cout<<mat[4*i+0]<<'\t'<<mat[4*i+1]<<'\t'<<mat[4*i+2]<<'\t'<<mat[4*i+3]<<endl;
}
他们都会输出下列矩阵的形式,但是注意!没有被转置!所以在载入GL的时候需要转置,对于这个简单的变换来说,也就是保证第四列是你的Translate值。
你觉得这样的方法怎么样?如果变换太多,自己根本不清楚的。所以,我推荐用第二种方法,手动计算绝对不会出错,而且可以与自己的程序框架结合在一起。
四、我好想见到她
上面说的都是WC的事情,现在呢,让我们结合OpenGL程序来谈如何做到准确的场景变换,同时自己又不会迷糊。我上篇一文章用3dsmax做了个CornelBox然后导了出来,在Deep Exploration中观察它,
哈,注意看Scene Tree,我这个里面有个FreeSpot灯光,ITVIRGEN也就是雕塑,还有CornelBox的5个平板。让我们看一下那个雕塑的属性,
哇塞,东西挺详细,包括这个模型的位置旋转角度都有了,下面还有一个挺复杂的变换矩阵。在这里有些朋友可能会想,“当我载入这个3DS的时候,是不是也要将属于这个雕塑MESH的每个顶点都用这个矩阵去变换一下呢?”,哈,如果你想了这个问题说明你已经登堂入室了。我的回答是:有好心人帮我们做过了这个事情,但是是谁这么好心呢?应该是3dsmax,因为在lib3ds的中我没有找到任何有关于矩阵变换的代码。也就是说,从3dsmax导出的场景可以被当作是一个WC来看,所有的模型都已经各就各位,所以我们就不需要读取mesh的顶点后再用这个矩阵去变换。
下一个问题就是,我们把“眼睛”放在什么地方?如果是一个酷酷的动态摄像机,我们又该如何处理,最重要的是,VIEW变换是一个什么东西?
从另外一个角度说,VIEW变换是,一个Translate和一个Rotate。
在GLU中有一个极其好用的函数叫做gluLookAt,MSDN中它的定义是,
“The gluLookAt function creates a viewing matrix derived from an eye point, a reference point indicating the center of the scene, and an up vector. The matrix maps the reference point to the negative z-axis and the eye point to the origin, so that when you use a typical projection matrix, the center of the scene maps to the center of the viewport. Similarly, the direction described by the up vector projected onto the viewing plane is mapped to the positive y-axis so that it points upward in the viewport. The up vector must not be parallel to the line of sight from the eye to the reference point.”
gluLookAt函数使用3个参数,Eye Position、Reference Position、Up Vector去构造了一个View矩阵。它是如何计算的呢?
假如我们有了上面三个量,分别叫做e、r、u。我们先获得视线的方向d=normalize(r-e),我们用D与U的叉乘得到一个量c=cross(d,u),最后重新计算一下u'=cross(c,d),然后我们就得到了一个矩阵o,让它与一个T矩阵相乘,就得到了与gluLookAt生成的矩阵相同的VIEW矩阵,过程如下,
详情可以去找那本《OpenGL® Distilled》,这本书的其他部分都不怎么样,就这个地方很有价值^_^
让我们开始使用刚刚学到的知识了。如何读取3DS模型是一个老生常谈的故事,几乎所有新手都会问这个问题。首先,3DS是最经典的模型,AutoCAD等工具都支持,但是它的缺点也很明显,非文本而是二进制,处理比较麻烦。下面我将展示如何使用lib3ds去读取一个模型文件中的所有的顶点、向量、纹理坐标、材质、贴图,并有效的组合在一起渲染。
首先我们定义一些基本的结构,比如这个,
typedef struct _RenderModel
{
ushort* m_TriangleIndex;
uint m_Triangles;
float* m_VertexPtr;
uint m_Indexs;
float* m_NormalPtr;
uint m_Normals;float* m_TexCoordPtr;
uint m_Texels;
float m_BoundBoxMin[3];
float m_BoundBoxMax[3];float m_XformMatrix[16];
std::string m_ModelName;
std::string m_MatName;
}RenderModel;typedef struct _RenderMesh
{
uint m_Models;
RenderModel* m_ModelPtr;
}RenderMesh;typedef struct _Phong
{
float emission[4];
float ambient[4];
float diffuse[4];
float specular[4];
float shininess;}Phong;
typedef struct _Camera
{
float m_Position[3];
float m_Direction[3];
float m_Center[3];
float m_Up[3];
float m_Fovy;
float m_Near;
float m_Far;
}Camera;static map<string, Phong> MaterialMap;
typedef pair<string,Phong> materialpair;
static vector<RenderModel> Models;
但是要知道,有不少成员是根本用不到的。如何处理呢?这个函数,
static void Init3DSModel(const char* filename)
{
Lib3dsFile* file = 0;
file = lib3ds_file_load(filename);
if ( !file ){
printf("Error On Open File!\n");
system("PAUSE");
exit(-1);
}
Lib3dsMesh* mesh = 0;
Lib3dsMaterial* mat = 0;
typedef vector<float> scalarvec;
typedef vector<uint> uintvec;
vector<scalarvec> TriSoups;
printf("INFO : Processing the 3ds file...\n");
for( mesh=file->meshes; mesh!=0; mesh=mesh->next ){
scalarvec Object[3];
uintvec IDVector;
RenderModel Model;
Lib3dsVector * normalL = new Lib3dsVector[3*sizeof(Lib3dsVector)*mesh->faces];
lib3ds_mesh_calculate_normals(mesh,normalL);for( Lib3dsDword i = 0; i < mesh->faces; i++ ){
//取得索引
Lib3dsWord _0= mesh->faceL[i].points[0];
Lib3dsWord _1= mesh->faceL[i].points[1];
Lib3dsWord _2= mesh->faceL[i].points[2];//取得顶点
vec3 V0(mesh->pointL[_0].pos[0],mesh->pointL[_0].pos[1],mesh->pointL[_0].pos[2]);
vec3 V1(mesh->pointL[_1].pos[0],mesh->pointL[_1].pos[1],mesh->pointL[_1].pos[2]);
vec3 V2(mesh->pointL[_2].pos[0],mesh->pointL[_2].pos[1],mesh->pointL[_2].pos[2]);//取得法向量
Object[1].push_back( normalL[3*i+0][0] );Object[1].push_back( normalL[3*i+0][1] );Object[1].push_back( normalL[3*i+0][2] );
Object[1].push_back( normalL[3*i+1][0] );Object[1].push_back( normalL[3*i+1][1] );Object[1].push_back( normalL[3*i+1][2] );
Object[1].push_back( normalL[3*i+2][0] );Object[1].push_back( normalL[3*i+2][1] );Object[1].push_back( normalL[3*i+2][2] );//取得纹理坐标
Object[2].push_back( mesh->texelL[_0][0] );Object[2].push_back( mesh->texelL[_0][1] );
Object[2].push_back( mesh->texelL[_1][0] );Object[2].push_back( mesh->texelL[_1][1] );
Object[2].push_back( mesh->texelL[_2][0] );Object[2].push_back( mesh->texelL[_2][1] );//储存起来
Object[0].push_back( V0.x );Object[0].push_back( V0.y );Object[0].push_back( V0.z );
Object[0].push_back( V1.x );Object[0].push_back( V1.y );Object[0].push_back( V1.z );
Object[0].push_back( V2.x );Object[0].push_back( V2.y );Object[0].push_back( V2.z );
}//分配内存
delete [] normalL;
Model.m_Triangles = mesh->faces*3;
Model.m_VertexPtr = new float[Object[0].size()];
Model.m_Normals = mesh->faces*3;
Model.m_NormalPtr = new float[Object[1].size()];
Model.m_Texels = mesh->texels*3;
Model.m_TexCoordPtr = new float[Object[2].size()];//拷贝进内存
copy(Object[0].begin(),Object[0].end(),Model.m_VertexPtr);
copy(Object[1].begin(),Object[1].end(),Model.m_NormalPtr);
copy(Object[2].begin(),Object[2].end(),Model.m_TexCoordPtr);
Model.m_MatName = mesh->faceL[0].material;
Model.m_ModelName = mesh->name;
Models.push_back(Model);
}//这个函数一定要注意,这是个小小的修补程序,比如用lib3ds计算得到的Bottom的向量是向下的,我们需要翻转一下
for(vector<RenderModel>::iterator itr = Models.begin(); itr !=Models.end(); itr++){
if( itr->m_ModelName == string("Bottom01") ){
for( unsigned int i =2; i<itr->m_Normals*3;i+=3 )
itr->m_NormalPtr[i] *= -1.0f;
}
}
printf("INFO : Processing the material...\n");
for( mat = file->materials; mat != 0; mat=mat->next ){
string _MatName( mat->name );
Phong _Mat;
//载入来自3ds模型文件的材质
for(int i = 0; i<4; i++){
_Mat.ambient[i] = mat->ambient[i];
_Mat.diffuse[i] = mat->diffuse[i];
_Mat.specular[i] = mat->specular[i];
}
_Mat.shininess = mat->shininess;
MaterialMap.insert( materialpair( _MatName , _Mat ) );
}
}
那么我们现在有了Models与MaterialMap这两个容器,如何渲染呢?
glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_NORMAL_ARRAY);
glEnableClientState(GL_TEXTURE_COORD_ARRAY);
for( vector<RenderModel>::iterator i=Models.begin(); i!=Models.end(); i++ ){
map<string, Phong>::iterator matItr = MaterialMap.find( i->m_MatName );
glMaterialfv(GL_FRONT_AND_BACK,GL_AMBIENT,&matItr->second.ambient[0]);
glMaterialfv(GL_FRONT_AND_BACK,GL_DIFFUSE,&matItr->second.ambient[0]);
glMaterialfv(GL_FRONT_AND_BACK,GL_SPECULAR,&matItr->second.specular[0]);
glMaterialf(GL_FRONT_AND_BACK,GL_SHININESS,matItr->second.shininess);
glVertexPointer(3,GL_FLOAT,0,i->m_VertexPtr);
glNormalPointer(GL_FLOAT,0,i->m_NormalPtr);
glTexCoordPointer(2,GL_FLOAT,0,i->m_TexCoordPtr);
glDrawArrays(GL_TRIANGLES,0,i->m_Triangles);
}
glDisableClientState(GL_TEXTURE_COORD_ARRAY);
glDisableClientState(GL_NORMAL_ARRAY);
glDisableClientState(GL_VERTEX_ARRAY);
我相信些代码是很简单的,通过mesh储存到的material名称,载入GL,然后渲染就可以了。思路就是这么明显。
不知道你有没有注意,在glDrawArrays时,我没有使用任何的glTranslatef、glRotatef等函数,原因我已经解释过了,我将整个场景当作了整个世界,那么我就没有必要再多此一举。
那么此时我的Reshape函数呢?
static void Reshape(int w,int h)
{
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
gluLookAt(Eye.m_Position[0],Eye.m_Position[1],Eye.m_Position[2],Eye.m_Center[0],Eye.m_Center[1],Eye.m_Center[2],Eye.m_Up[0],Eye.m_Up[1],Eye.m_Up[2]);
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
gluPerspective(46,(double)w/(double)h,0.5,100);glViewport(0,0,w,h);
glMatrixMode(GL_MODELVIEW);
}
是不是也极其简单呢?那个Eye我是直接从lib3ds读取得到的CAMERA的位置载入的,具体的实现很简单。同样,那个Spot的位置与方向也是这样获得的。
五、Im Stillen lass ich ab von Dir
到现在我们说的都是MV变换,没有说到透视变换Projection Transform。
对比MV变换,PT就显得简单了许多,它的根本作用是,将ES中的所有点“尽量”都转换到X与Y方向上都是[-1,1]的这个屏幕区间里面去。那我们应该如何理解呢?
打开你的Shader Designer,渲染busto人头。在Fragment Shader中输入,
void main()
{
gl_FragColor = gl_TexCoord[0];
}
在Vertex Shader中输入,
void main()
{
gl_TexCoord[0] = gl_MultiTexCoord0;
gl_Position = vec4(gl_TexCoord[0].st,0,1.0);
}
这里估计很多朋友就迷糊了,这是什么意思啊,为什么gl_Position不是写入ftransform()或者是gl_ModelViewProjectionMatrix*gl_Vertex,而是怪模怪样的给它赋值为UV坐标?
先不提这叫做什么,反正你将看到这个样子,
我们再把Vertex Shader改成
void main()
{
gl_TexCoord[0] = gl_MultiTexCoord0;
gl_Position = vec4(gl_TexCoord[0].st*2.0-1.0,0,1.0);
}
这个时候就塞满了整个屏幕,
其实这就是Render To Texture Space,NVIDIA在用Cheating的方式处理SSS的时候做的那个“渲染到纹理空间”。
我们当然知道,UV空间是[0,1],我如果给它乘以2减去1就是变换到[-1,1]的空间中去。但是在这里我们可以明白一个道理,顶点经过MVP处理后,要么被裁减掉,要么将得到在[-1,1]中间的一对数值。
其实当我们明白了这个道理后,就可以完全的发挥GPGPU的功能,因为我们已经明白了数值的Framebuffer的准确写入位置。这样就可以随心所欲的在Framebuffer中写入数据,读取想要的数据,甚至做遍历。
我们可以用glFrustum或者是gluPerspective函数获得一个透视矩阵,gluPerspective更常用一些。gluPerspective定义的了一个针孔摄像机模型,它有fovy、aspect、zNear、zFar组成。这4个参数定义了一个平截头体,这个平截头体就是摄像机,在这个Volumn中的东西才是可见的,其他都是不可见的。示意图如下,
但是我们又知道,GL其实是把人眼放在了(0,0,0,0)位置,每一个象素都遵循(0,0,-1,0)这个方向。其实这就是(x,y,z,w)的最后一个分量的作用,当我们写入gl_Position后,硬件会自动的除以w分量以获得裁减坐标系,然后再使用Viewport参数将这些玩意与屏幕象素对应在一起。
但是我们一定一定要知道:无论是OpenGL、Direct3D,甚至是RenderMan、mental ray都有一些这样的问题,那就是,它们其实都是针孔摄像机模型。RenderMan和mental ray有些特殊,它们即有光栅化又有光线跟踪模块,所以处理摄像机就比较灵活,可是对于GL和DX来说,模拟一些摄像机效果如DOF就只能用Trick。
再来一个纹理投射Projection Texture。
纹理投射是个很经常的问题,比如在做Shadow Mapping的时候,还有就是做光照的时候,还有比如模拟一个电影放映机的过程,都是纹理投射。它的根本意义是:“假想我们从投射物体比如光源去看场景,我们希望得到世界里的每个顶点在那个虚拟摄像机屏幕上的XY位置”。写成连续矩阵的形式就是,
glMatrixMode(GL_TEXTURE);
glLoadIdentity();
glTranslatef(.5f, .5f, .5f);
glScalef(.5f, .5f, .5f);
gluPerspective(/*LIght Shape*/);
gluLookAt(/*Light Position,direction,up vector*/)
这个时候,GL的纹理矩阵其实就是我们想要的那个矩阵:它将世界顶点变换到光源摄像机空间去,在Shader中我们可以直接使用texture2DProj去做纹理采样。比如这个聚光灯效果,
六、结束
在这篇文章中,我把GL中的矩阵处理流程,手动生成矩阵的方式,结合了真实的场景教了大家如何去认识矩阵变换问题,后面还说了一些与矩阵变换相关的应用,如果你觉得有错可以联系我谢谢,如果你觉得本文对你有帮助、帮助大多数新手进步、分享知识是我的责任。转载时请附上我的联系方式,谢谢。
周波 Bo Schwarzstein
Mailbox 242,Nanjing Forestry University,Jiangsu,China
jedimaster.cnblogs.com
zhoubo22 'at' hotmail.com