论简化三维流水线和逼近真实流水线快速构造引擎-程序员宅基地

技术标签: struct  算法  引擎  vector  技术理论  float  其它文章  图形  

Loserwang保留版权,转载请注明出处:http://hi.baidu.com/loserwang
电子邮件:dreamcasttop   at163.com

《简化三维流水线和逼近真实流水线》

目的:通过简化流水线可以在相当短的实践周期内逼近真实流水线。
工具:VC7.1、DirextX、几何画板、MathType公式编辑器、科学计算器、3DS MAX 7.0、MilkShape 3D演示版等
周期:2007年8月23日至2007年8月28日
日期:2007年8月29日

图示:在实验中用到的真实流水线绘制的人物头部线框模型和简单渲染

本材料并不是一份入门参考,主要用于交流和提高,并且阐述一些比较值得关注的观点。

第一部分、基础设施
一、提出问题:怎样找到一种简单的工具来验证三维空间的真相
大概是1年多前,2005年11月的时候,因为对图像渲染的速度要求,放弃了当时正在使用的.Net设计平台而转入VC图形开发。对于二维图形来说,高中的代数和几何知识已经足够了,所以当时并没有多少障碍就能够成熟地驾驭二维世界。对于这种“不合时宜”的做法并没有持续很久,开始将目光投入三维图形世界。
这一去就是1年多,这个阶段并没有累积图形学方面的任何成功经验,甚至连基本的DirectX应用程序都没有写过。仅在Windows API、Standard C++、COM、WinSocket、MFC等程序设计方面有一定的积累。在实践中,依靠成功的程序设计能力并不总是很有效,我们总是被各种实际运算的问题所困扰。
进入三维世界,如果不是马上能够在实践中演练,那要实现它几乎不可想象,因为它包含的各种内容都有一定的抽象高度,比如说矩阵这种实用工具。矩阵真实的面目是什么?行列里的若干数字?没有应用它们是不可能体会到的:

图1、一个矩阵

困扰的一个直接问题是,三维图形的呈现有一个渲染流水线,没有经过这个流水线而直接表达出三维图形,比如说用约定的方式,有没有这样的一种可能?所有的三维图形学书籍都迫不及待地开始了他们的理论长征,最终再简要地给出流水线的构造步骤。或者换个角度讲,这些书籍并没有用一种简单的模型让我们马上在实践当中理解和应用大多数的理论基础。比如说,仅仅是要呈现三维空间中的一个线段,并且初学者对三维空间理论的匮乏也仅仅能提出类似的要求。不幸的是,这些书籍都把呈现放在了真实的三维渲染流水线的模型中。在接触到这个真实流水线之前,所有的呈现和实践任务看起来是做不到了。而不能理解和应用这些前提要素,构造一个真实流水线的机会便变得更加遥遥无期。典型的克服这种问题的做法有二:1、纯粹理论探讨,列出所有的数学公式并推导他们,这种方法被所有的大学教学系统所采纳,但学习者必须付出2年的时间和接受极可能出现的失败命运。2、在商业三维流水线系统内完成,如Direct3D和OpenGL,这种书籍充斥了广泛的市场并且打造了大量肤浅的劣质的玩具。

D3DXMATRIX Rx, Ry;
// 构建绕x轴旋转的矩阵,一个定值45度
D3DXMatrixRotationX(&Rx, 3.14f / 4.0f);
// 变化的绕y轴旋转的矩阵
static float y = 3.14f / 8.0f;
D3DXMatrixRotationY(&Ry, y);
y += timeDelta;
// 标准化弧度值
if( y >= 6.28f )
y = 0.0f;
// 组合矩阵
D3DXMATRIX p = Rx * Ry;
// 旋转了世界坐标而不是物体
Device->SetTransform(D3DTS_WORLD, &p);

如上在Direct3D中利用矩阵工具来表达三维物体的转换是非常清晰和简洁的。对于类似的任务,实验中用到的代码段:

static float angle=0;
angle+=1;
MATRIX m=MI; //对象变换矩阵
MATRIX o=MI; //流水线变换矩阵
//旋转运动y
m=m*rotationy(angle);
//旋转运动x
m=m*rotationx(-30);

m=m*o;
v0=v0*m;

这时候问题又来了,矩阵是什么?它们的乘法定义又该是如何的?能够使对象旋转的矩阵是怎样工作的?当学习者问起这些问题的时候,通常的答复就是对它们合理解释一遍,如此往复。但是要幸运地遇到这样的提问者和遇到回答者一样困难。在盲目的实践中追求理论的解答总是不够利索和片面。

二、迫不及待地要在三维空间呈现,怎么做?
实践证明,三角学、解析几何、立体几何、线性代数是有效的,它们是通往三维图形学真理方向的唯一路途。在朝空间几何和三维图形学的城堡迈进之前,我先详细地回顾了这些知识,大约是2007年3月到8月间,我所作的一切事情就是它们。当然还包含了数学分析的函数、极限、微积分初步等。以至到后来,我几乎认定整个高中数学课程就是为三维数学作准备的。比如选读部分的矩阵行列式。其实在数学描述方面,三维数学和工程、电子等学科有着密不可分的联系。它们几乎可以用同一套数学描述系统来解决各种实际问题。

图2、空间中的点A,表现为xy二维平面上的点A’在z维度上的位移

如上图所示,我们可以把z方向的位移特殊处理,比如说按和x轴一定的倾斜方向表示出来,这是我们在二维图形上直观表示z纵深的方式。其实,这就是我们的简化流水线的基本模型。公式如下:

公式1、简化模型

它们几乎就是简化流水线的全部生命。当然,为了使图形具备空间感,还需要为图形添置对应坐标系。
这是一个非常简单的流水线模型,但是它可以完成的任务比我们想象的要多更多。因为我们可以对三维空间中的点或向量做一系列操作,然后利用这个模型投射出结果,这样我们就可以了解这一系列操作达到的目的以及它们是否正确。

三、GDI或者DirectDraw,基础图形描述工具。
Windows API提供一系列实用函数用于绘制图形,GDI是抽象图形设备接口,负责直接和硬件打交道。

图3、从窗口图形设备创建兼容设备,并附加指定大小位图以便绘制图形

为了呈现动画效果,是不能直接在窗口的抽象设备作图的,这样会产生闪烁现象。因为计算机用户会看到GDI在窗口上绘图的全过程。这里用到了一个概念——内存兼容设备。它允许我们在内存中画好一桢图形,然后一次性写入到窗口中。

HDC hDC=GetDC(hWnd);
HDC hMemDC=CreateCompatibleDC(hDC);
HBITMAP hBmp=CreateCompatibleBitmap(hDC,800,600);
SelectBitmap(hMemDC,hBmp);
ReleaseDC(hWnd,hDC);

上面代码表示了兼容设备创建的过程。之后就可以相当于利用窗口设备的方式操作它,只是没有立即显示而已。

//以下函数用于带缩放地写入目标窗口设备
StretchBlt(
destHDC, //目标设备
destRC.left, //目标设备区域
destRC.top,
destRC.right,
destRC.bottom,
srcHDC, //内存设备
srcRC.left, //内存设备区域
srcRC.top,
srcRC.right,
srcRC.bottom,
SRCCOPY //传送模式);

DirectDraw用了类似的手段来完成图形呈现,但是它具备更佳的速度表现。但因为它的广泛和实用性,加入了大量的结构用于描述各种参数和进行对应参数或者行为的操作函数而造成一定的使用难度。究其原理是相当简单的。一份合适的DirectDraw程序样本足够帮我们节省很多的思考时间。我们只要能够创建两个表面,进行表面复制,构造一些画点画线乃至写文本函数就可以满足需求了。

//lpsurmain是主表面指针,lpsurback是副表面指针
lpsurmain->Blt(&rectmain,lpsurback,&rectback,DDBLT_WAIT,NULL);

//DirectDraw包含有GDI模拟接口,可以像操作GDI那样操作这个接口
int VDText(int x,int y,TCHAR* buf)
{
lpsurback->GetDC(&hMemDC);
SetBkMode(hMemDC,TRANSPARENT);
SetTextColor(hMemDC,0x00FF00);
TextOut(hMemDC,x,y,buf,(int)_tcslen(buf));
lpsurback->ReleaseDC(hMemDC);
return 0;
}

至此,我们找到了2个工具都可以帮我们完成创建绘图表面和绘制像素。

四、开始绘制经过正交投影的线框图形

图4、简化模型

上图是利用简化模型呈现的向量直观图,包含两个三维向量,以及它们的法线和一个向量到另一个向量的投影线,当然,还包括坐标轴。
原理正如公式所表述的。它的真实代码如下:

//以窗口中心为原点,memrc.right/(2*d)是通用的缩放因子,在真实的流水线中,输出的图形往往不是正常屏幕大小的,这跟相机设定的投影面z=d有关系。根据90度的视角来说,投影面大小的一半正等于
POINT V(VECTOR3 vector3)
{
POINT pt;
VECTOR3 v=vector3;
v.x=v.x/v.w;
v.y=v.y/v.w;
v.z=v.z/v.w;

pt.x=(long int)(memrc.right/2+(v.x+v.z*cos(AngToRad(x_z_angle)))*memrc.right/(2*d));
pt.y=(long int)(memrc.bottom/2-(v.y+v.z*sin(AngToRad(x_z_angle)))*memrc.right/(2*d));
return pt;
}

视口的大小是受D影响的,为了“还原”屏幕的真实尺寸,必须计算出屏幕尺寸跟当前D值的缩放比。

图5、利用相似三角形表现透视投影中投影面和d的关系

回到简化模型的讨论,计算出合适的x跟y值之后,就可以利用GDI或者DirectDraw的相关描点画线函数来表达。呈现的结果如图4所示。
接下来我们将引入一个更为有意义的图形线框模型。

typedef struct
{
float x;
float y;
float z;
float w;
}VECTOR3;

上面的结构表现一个三维向量或点。其中的w分量我们先不考虑,只要知道它为1。

//顶点列表
static VECTOR3 vertex[]=
{
{0,0,0,1},
{10,0,0,1},
{10,10,0,1},
{0,10,0,1},
{0,0,10,1},
{10,0,10,1},
{10,10,10,1},
{0,10,10,1},

{12,0,0,1},
{20,0,0,1},
{16,0,6,1},
{16,5,3,1}
};
//三角形列表
int triang[]={0,3,7,6,4,5,0,1,3,2,6,5,-1,8,10,9,11,8,10};

我们用顶点索引列表来表达三角形带,这里我用了一个小技巧,-1区隔不同的三角形带。当然可以对三角形带进行分组,甚至把它们切割为彼此独立的三角形组。使用什么策略就看实际情况。例如这边的顶点索引并不是很多。

定义了顶点,接下来就要定义它们的传输带了。

//对顶点进行枚举
for(int i=2;i<sizeof(triang)/sizeof(int);i++)
{
if(triang[i]==-1)
{
         i+=2;
         continue;
}
VMove(hMemDC,vertex[triang[i-2]]*m);
VLineTo(hMemDC,vertex[triang[i-1]]*m);
VLineTo(hMemDC,vertex[triang[i]]*m);
VLineTo(hMemDC,vertex[triang[i-2]]*m);
}

传输带和线框存放结构是有适应关系的。
VMove函数和VlineTo函数获取一个三维数据,通过简化模型将他们转化为合适的坐标之后调用GDI的MoveToEx和LineTo函数来执行对应操作。
结果如下(我在例程中对它们作了一定的旋转操作):

图6、通过简化模型也能够表达复杂的物体

你会很快发现它并不具备透视特性,没错,这正是我们想要的。
五、点积公式的证明

图7、转移坐标,使U和x平行

 

六、矩阵——真实流水线的生命
假如世界上从来不曾存在过矩阵,我们的生活会是怎样?对矩阵的溢美之词可以写一本专著。但是我们现在不打算立即描述它在三维流水线所发挥的作用。立刻就可以开始把它用于变换物体的空间位置和形变等。

typedef struct
{
float _11;float _12;float _13;float _14;
float _21;float _22;float _23;float _24;
float _31;float _32;float _33;float _34;
float _41;float _42;float _43;float _44;
}MATRIX;

代码所示的是这样一个矩阵:


矩阵乘法法则:

用于乘于三维向量的3*3矩阵每行可以理解为转换后的基向量。初始基向量为
,分别对应x、y、z轴的标准向量。转换后的基向量为
,这为我们利用变换后形态逆向构造变换矩阵提供了可能性。
以下列出绕一个轴旋转的代码,及其矩阵形式:

MATRIX rotationz(float angle)
{
MATRIX m;
initmatrix(m);
m._11=cos(AngToRad(angle));
m._12=sin(AngToRad(angle));
m._21=-sin(AngToRad(angle));
m._22=cos(AngToRad(angle));
m._33=1;
return m;
}

图7、绕z旋转的矩阵形式

由以上式子可以验证“用于乘于三维向量的3*3矩阵每行可以理解为转换后的基向量”这样的结论。这里列出的关于矩阵的实用操作技巧只是很少的一部分。但不论如何,通过一定的矩阵形式变换物体坐标系里的点坐标则一点问题都没有。这意味着经过变换的依然是有效的三维向量数据。但是它确实产生了一定的旋转、平移、镜像、切变等线性变化。我们的简化模型则对此一点也不关心。因为所有的数据变化都是在输入到流水线之前的。

图8、旋转等操作都是在进入流水线之前的事

七、传说的延续,在简化模型之上构建真实流水线,第一步是讨论方位
要逼近真实流水线,就是在去除所有约定元素之后,重新把它们构建起来。下一步可以假设整个物体空间就是世界的原点,然后对其进行透视投影。或者可以假设立体空间看到的物体就像简化模型表现的那样,然后对其进行物体到世界坐标系的转换。这个转换过程涉及到一个问题,就是物体的方位,所谓方位就是表示物体的三个坐标轴的面向的方向。
核心问题是对欧拉角(Euler)的正确理解和它是如何转化为矩阵并最终更新物体在惯性坐标系中的朝向的。
注意欧拉角的定义:它包含3个方位信息,Heading、Patch、Bank,物体一开始位于和惯性坐标一致的方位。并分别绕自身的y、x、z轴作HPB旋转。这相当于在惯性坐标系中改变物体坐标系的方位。其旋转的角度和物体旋转的角度相反。

图9、物体在惯性坐标系中绕自身坐标系分别做了HPB旋转

欧拉角的直观性在于贴近人类观察和描述事物的方式,实际上真正理解它并不是很轻松,尤其是用数值表示出来。
(理解1)首先,假设物体始终是不动的,这样它对于惯性坐标系或者物体坐标系来说都是不变的,根据欧拉角,定义,使物体分别绕物体坐标系旋转一定角度,为了保持物体本身和惯性坐标系之间的不变性,以及物体和物体坐标系之间的变化关系,可以直接旋转物体坐标系,这样,物体和物体坐标系的相对位置改变了,但是物体和惯性坐标系的相对位置没有改变。即,坐标系进行了HPB旋转,而实际我们需要的是点的旋转。即(H-)(P-)(B-),这样便得到惯性坐标系到物体坐标系的改变。
(理解2)上面的理解有些牵强,因为并不符合我们思考的习惯。欧拉角是依据人对物体所在区域方位的认知,故对欧拉角的定义无法单独在物体坐标系或者惯性坐标系中讨论。定义中,欧拉角HPB的旋转均依据自身物体坐标系的轴向改变。用点的角度理解欧拉角:
  

以上是物体到惯性坐标系方位进行变换的推导过程。而惯性坐标系到物体坐标系的变化就是,跟理解1的结论一致,但显然要合理得多。
八、三维世界的语言,三角形——顶点列表和顶点索引
九、对视平面的定义以及视平面到屏幕空间的缩放因子

图10、视场大小和缩放的关系


图11、维持屏幕宽高比的视平面调整

  

十、Z深度排序算法以及相关优化——索引拷贝、顶点拷贝以及内置排序。
排序算法测试结果:
1、 顶点拷贝

图10、顶点拷贝二叉树排序

采用二叉树进行快速排序,但因为这种排序是位于vector之上进行的,没有得到很好的速度支持。
2、内置排序算法以及一次性流水线(而不是每次开关GDI状态)优化

图11、内置排序

采用编译器环境内置的排序算法qsort,它的速度可以得到很好地发挥。不过只能传递数组进去,这意味着数组大小要预先设置、引擎一个常数量的资源占用,所支持的最大多边形的大小以及速度之间的取舍。

测试结果是内置排序比架构于vector之上的快速排序快了5倍左右。

qsort(&trianglist,index+1,sizeof(TRIANGLE),tricompare);

int tricompare(const void* a,const void* b)
{
TRIANGLE t0=*((TRIANGLE*)a);
TRIANGLE t1=*((TRIANGLE*)b);
float z0avg=0.33333f*(t0.v0.z+t0.v1.z+t0.v2.z);
float z1avg=0.33333f*(t1.v0.z+t1.v1.z+t1.v2.z);
if(z0avg>z1avg)
         return -1;
if(z0avg<z1avg)
         return 1;
if(z0avg>z1avg)
         return 0;
}

图12a、排序前的渲染结果

图12b、排序后的渲染结果

经过排序后的渲染形体得于正常显示。

 

第二部分、光照和Gouraud着色

图2-1、执行了光照运算和Gouraud着色的示例

一、 灯光类型
点光源:具有颜色、位置、强度。并且随着距离变化二次反比衰减。我在模拟模型中使用一次反比衰减和固定衰减一次常量。

typedef struct
{
VECTOR3 P; //position
COLORF C; //color
float I; //intensity
}POINTLIGHT;

平行光:具有颜色、方向、强度。没有衰减距离。强度始终为常量。

typedef struct
{
VECTOR3 V; //vector
COLORF C; //color
float I; //intensity
}INFINITELIGHT;

环境光:具有颜色、强度,没有方向和衰减距离。强度始终为常量。

typedef struct
{
COLORF C; //color
float I; //intensity
}GLOBALLIGHT;

聚光灯:具有平行光的方向和点光源的距离衰减,以及根据照射方向发生的角度衰减。(没有实现)

灯光列表:和多边形流水线同级别的结构体,用于存储有关的灯光信息。

typedef struct
{
vector<POINTLIGHT> ptls;
vector<INFINITELIGHT> infls;
GLOBALLIGHT global;
}LIGHTS;

环境光只有一盏,可以这样理解,所谓的环境光是某区域场景的混色光线的简单模拟。

二、光线加法、光线调制、Alpha混合、线性插值
光线具有红绿蓝三个分量。GDI对分量的存储是蓝绿红分量排序。这不符合广泛的约定。因此对颜色重新定义如下:

Typedef DWORD COLORREF3;
#define RGB3(r,g,b) ((DWORD) (((r) << 16) | ((g) << 8) | (b)))
typedef struct
{
float r;
float g;
float b;
}COLORF;

以上添置了标记“3”以示区别。接下来描述的结构体用于存储浮点类型的颜色分量。取值从0.0f到1.0f,之所以取这个范围是当分量之间进行乘法运算时,颜色值始终不会越界。这使得颜色乘法有意义。类似的有镜面反射区域的余弦值的指数运算。正方向的余弦值始终在0到1之间,无论进行多少次次方运算都不会越界。
可以对光线分量简单累加,这普遍适用于同性质颜色复合(如都是光线或者都是材质,注意光线和材质相加没有意义,不过对材质的加法是假设材质信息即将被作为光线处理,如自发光的材质等)的多数情况,当颜色值越界时简单地截断,因为设备无法表现更宽广的亮度值域。如果是光线对某种材质进行照射的情况,因为材质是对光线的影响情况,而不是自发射的光源,所以没有可以累加的量,这时候调制运算就可以获得有效的数据。浅色系的物体材质对光线的反射能力更强,进行调制乘法的结果也正说明这一点。
对颜色三个分量同时乘于一个相同的标量,这会改变颜色亮度。如果一个固定配额。数个颜色根据该配额比进行调制,最后累加,得出的结果是物体呈现透明化。这就是Alpha混合。
线性插值,即在一个指定的着色区域,从一种颜色变化到另一种颜色,需要对每个步进值计算颜色变化率。平滑着色和纹理仿射等均使用了线性插值算法。

三、物体材质
我们理解的物体色,是指物体表面反射白色光线后呈现的颜色。具有以下几个分量:
环境光反射:材质的环境光反射分量决定了环境光对物体的影响程度。经常使用灰度值表示。
散射:即漫反射,物体的漫发射光线值不随观察角度变化。但是和光线射入方向和物体表面朝向关系密切。

图2-2、散射强度受光线照射方向和法向量夹角影响

镜面反射:镜面反射是受物体表面朝向、观察者视线、光线入射方向三方面影响的。当观察者视线位于光线反射方向上时,镜面反射强度最强,并依次余弦递减。典型的Blinn算法是根据这样的事实:当观察者位于光线反射方向上,观察者视线方向跟光线入射方向的中间量为表面法线,并依次按中间值的偏移递减。计算两个向量的中间量的运算量要比计算投影线直到计算出反射方向的运算量少。缺点是变化率和准确的Phong模型并不一致。但看上去足够可信。
另外镜面反射材质还有一个镜面反射率的指数位标记。越大的反射率,对夹角余弦的次方运算更为高次,衰减的速度快速下降,这样将会得到一个足够集中的反射区域,正如我们在现实生活中感受的那样。

图2-2a、k=13

图2-2b、k=138

自发光:和环境光线类似,不过实现要更加简单,只是一个物体发射光的参照量,如前所述,对于实体光线,需要做的运算就是光线加法。

typedef struct
{
float r;
float g;
float b;
float a;
}AMBIENT;
typedef struct
{
float r;
float g;
float b;
float a;
}DIFFUSE;
typedef struct
{
float r;
float g;
float b;
float k;
float a;
}SPECULAR;
typedef struct
{
float r;
float g;
float b;
float a;
}EMISSIVE;

Ambient:环境光反射、Diffuse:散射、Specular:镜面反射、Emissive:自发光。


光照计算公式:

公式2、光照计算

对光照的理解重点在于对光线加法和调制等操作的实际使用上。当然,对向量运算的背景知识是必须的,这不是问题,不是吗?最后,用不多的几个光源进行合理搭配,就可以创造出良好的游戏氛围。

 

图2-3、镜面反射模型

   

图2-4a、打开了环境光

图2-4b、打开了平行光

图2-4c、加入了点光源


四、执行程序结构
在讨论丰富的渲染模式之前,有必要对程序的结构有一个整体清晰的认识。并且,对着色、插值、纹理、光栅化、裁剪、深度缓冲等问题的讨论将不再允许有时间对这方面的问题做一个全面而具体的阐述。

图2-5、程序执行框图

主程序承载窗口和调度各个部件的协作。引擎负责像素级图形数据的绘制和呈现工作。流水线因为其重要性,所以从主引擎结构中独立出来,当然主观上我们依然可以认为它们是一体的。流水线处理线上的数据,并最终转化为可用的多边形和色彩值,调用引擎的绘制函数写入后备缓存。最后,主程序调用引擎的呈现功能,把内存中的图像拷贝到屏幕上。

初始化:
相机储存有裁剪面、视场角等信息
typedef struct
{
EULER E;
VECTOR3 pos;
float nearclip;
float farclip;
float portang;
}CAMERA;

这些信息被用于设定相机坐标转换相关矩阵和屏幕缩放因子。
物体模型信息是通过外部文件导入的,存在着各种各样的文件格式。我们目前对文件格式有如下需求:1、ASCII形式存储,因为我们有时候需要打开修改数据。2、材质描述,这在光照和着色渲染的时候必须用到。3、可选的顶点法线,因为顶点法线可以在模型载入的时候再计算,对于有限的多边形数,这种开销是值得的,所以顶点法线不是必须的。4、嵌入的或者引用的纹理信息。在ASCII文本中嵌入二进制信息是可行的。幸运的是,我第一次接触存储文件的时候,使用的msh支持以上各种情况。

MSHX1
GROUPS 1
MATERIAL 1      ; Cube
NONORMAL
GEOM 20 12
-2.060000 2.040001 2.033892 0.000000 0.000000

0 1 2

MATERIALS 1
White Matte
MATERIAL White Matte
0.941177 0.941177 0.941177 1.000000
0.745098 0.745098 0.745098 1.000000
1.000000 1.000000 1.000000 1.000000 1.280000
0.000000 0.000000 0.000000 1.000000
TEXTURES 0

该种文件格式可以在MilkShape 3D的导出功能中找到,另外,它不支持顶点色和线框色,颜色是在材质中描述的。我写了这种文件格式的加载函数,读取了有关的顶点材质信息,以及预先计算了顶点法线。并存放在OBJ结构体中。(我已经尽量避免了大量的代码引用,但是这些结构体信息是必须的)

typedef struct
{
AMBIENT ambient;
DIFFUSE diffuse;
SPECULAR specular;
EMISSIVE emissive;
}MATERIAL;

typedef struct
{
vector<VECTOR3> vbuf; //顶点列表
vector<VECTOR3> nbuf; //法线列表
vector<COLORF> cbuf; //法线光照
vector<int> ibuf; //顶点索引
MATERIAL material; //材质
}MODEL;

typedef struct
{
vector<MODEL> models;
EULER E;
VECTOR3 pos;
MATRIX m;
}OBJ;

上面列出了物体、模型组、材质等的结构体。值得注意的是:1、法线光照会存入-1.0f,直到该法线被计算的时候才被填充有意义的数值,这样每次流水线周期,法线光照都应该仅计算一次。2、物体的变换矩阵MATRIX m,之所以使用这个矩阵的理由是:无论如何这个矩阵都会被计算一次,在进入流水线之前可以对这个自变换矩阵进行操作,而不是实际数据,在流水线的开始点处该矩阵就会被执行。这样,修改的将是流水线上拷贝的物体数据而不是原始的数据。因为整个流水线环节主程序是无权干涉的,因此,传入这个矩阵是有意义的。
流水线:

typedef struct
{
vector<POINTLIGHT> ptls;
vector<INFINITELIGHT> infls;
GLOBALLIGHT global;
}LIGHTS;
typedef struct
{
vector<OBJ> objects;
TRIANGLE trianglist[30000];
}PIPELINE;

当前不支持聚光灯,所以灯光列表没有这个项目。流水线结构体放置了物体列表和多边形数组。这是可以理解的,因为数组排序速度更快。

SetPos(teapot.pos,0,0,20);
SetEuler(teapot.E,0,-10,0);
teapot.m=MI*rotationy(angle);
pipeline.objects.push_back(teapot);

这是放置物体到流水线的示例

POINTLIGHT ptl;
EMISSIVE& emi=ball.models[0].material.emissive;
COLORF c={emi.r,emi.g,emi.b};
ptl.C=c;
ptl.I=30.0f;
ptl.P=ball.pos;
lights.ptls.push_back(ptl);

放置点光源到光源表的示例。其中较为有趣的部分是:我读取了场景中一个自发光球体的自发光信息,并把它用于光源上,球体的位置信息也被用来描述点光源的位置,这样,我们得到了一个“真正发光”的自发光体,至少看起来是:

图2-6、运动的球体“发射”了光线

最后用一段流水线代码来结束程序结构的讨论,用语言完整描述它们的方式是很糟糕的。

int pipelinerender(const CAMERA& cam)
{
    MATRIX cm=MI; //相机变换矩阵
    VECTOR3 campos=cam.pos;
    cm=cm*translation(-campos.x,-campos.y,-campos.z);
    cm=cm*MT(objecttoinertial(cam.E));
    //透视投影
    cm=cm*cameratoview();

    VECTOR3 v0,v1,v2,u,v,n,vavg,normal0,normal1,normal2;
    VERTEX a,b,c;
    COLORF color0,color1,color2;
    MATRIX m; //物体到世界变换矩阵
    MATRIX mi=MI; //物体到惯性变换矩阵
    MATRIX mw=MI; //惯性到世界变换矩阵
    int index=0;
    for(int p=0;p<pipeline.objects.size();p++)
    {
     OBJ& obj=pipeline.objects[p];
     vector<MODEL>& models=obj.models;
     //物体自变换和转换到世界坐标
     mi=obj.m*objecttoinertial(obj.E);
     mw=translation(obj.pos);
     m=mi*mw;
     for(int g=0;g<obj.models.size();g++)
     {
      for(int i=0;i<models[g].ibuf.size();i+=3)
      {

       v0=models[g].vbuf[models[g].ibuf[i]]*m;
       v1=models[g].vbuf[models[g].ibuf[i+1]]*m;
       v2=models[g].vbuf[models[g].ibuf[i+2]]*m;
       u=v1-v0;
       v=v2-v0;
       n=VCross(u,v);
       n=VNormal(n);
       //背面消除
       if(backfaceclear(n,v0,cam.pos))
        continue;
       //对法向向量只旋转,不位移
       normal0=models[g].nbuf[models[g].ibuf[i]]*mi;
       normal1=models[g].nbuf[models[g].ibuf[i+1]]*mi;
       normal2=models[g].nbuf[models[g].ibuf[i+2]]*mi;
       //执行光照
       /*
       vavg=(v0+v1+v2)/3;
       color0=GetFaceColor(n,vavg,cam.pos,models[g].material,lights);
       color1=GetFaceColor(n,vavg,cam.pos,models[g].material,lights);
       color2=GetFaceColor(n,vavg,cam.pos,models[g].material,lights);
       */
       if(models[g].cbuf[models[g].ibuf[i]].r==-1.0f)
        models[g].cbuf[models[g].ibuf[i]]=GetFaceColor(normal0,v0,cam.pos,models[g].material,lights);
       if(models[g].cbuf[models[g].ibuf[i+1]].r==-1.0f)
        models[g].cbuf[models[g].ibuf[i+1]]=GetFaceColor(normal1,v1,cam.pos,models[g].material,lights);
       if(models[g].cbuf[models[g].ibuf[i+2]].r==-1.0f)
        models[g].cbuf[models[g].ibuf[i+2]]=GetFaceColor(normal2,v2,cam.pos,models[g].material,lights);
       color0=models[g].cbuf[models[g].ibuf[i]];
       color1=models[g].cbuf[models[g].ibuf[i+1]];
       color2=models[g].cbuf[models[g].ibuf[i+2]];
       //到相机坐标的转换
       v0=v0*cm;
       v1=v1*cm;
       v2=v2*cm;
       //相机空间裁剪,这步操作可以提前到光照计算之前,但需要保留变换前向量
       if(cliptriangle(v0,v1,v2))
        continue;
       pipeline.trianglist[index].v0.v=v0;
       pipeline.trianglist[index].v1.v=v1;
       pipeline.trianglist[index].v2.v=v2;
       pipeline.trianglist[index].v0.color=color0;
       pipeline.trianglist[index].v1.color=color1;
       pipeline.trianglist[index].v2.color=color2;
       index++;
      }
     }
    }
    //排序
    qsort(&pipeline.trianglist,index,sizeof(TRIANGLE),tricompare);
    LockSurface();
    for(int i=0;i<index;i++)
    {
     a.v=pipeline.trianglist[i].v0.v;
     b.v=pipeline.trianglist[i].v1.v;
     c.v=pipeline.trianglist[i].v2.v;
     a.color=pipeline.trianglist[i].v0.color;
     b.color=pipeline.trianglist[i].v1.color;
     c.color=pipeline.trianglist[i].v2.color;
     VDTriang(a,b,c);
     drawcount++;
    }
    UnlockSurface();
    return index;
}
int tricompare(const void* a,const void* b)
{
    TRIANGLE t0=*((TRIANGLE*)a);
    TRIANGLE t1=*((TRIANGLE*)b);
    float z0avg=0.33333f*(t0.v0.v.z+t0.v1.v.z+t0.v2.v.z);
    float z1avg=0.33333f*(t1.v0.v.z+t1.v1.v.z+t1.v2.v.z);
    if(z0avg>z1avg)
     return -1;
    if(z0avg<z1avg)
     return 1;
    if(z0avg>z1avg)
     return 0;
}

五、平滑着色,Phong或者Gouraud
最近我一直在考虑一个问题,如果把平滑着色的代码粘贴到文档编辑器中,它将占据几页的篇幅?答案是13页。我花了几天时间慢慢完整这个算法。第一步我使用浮点斜率和Alpha混合插值,每秒可以执行62000多个多边形。第二步我修改了多边形横向插值的内循环算法。使它不必执行乘法运算,而仅进行光线加法。为此必须给它指定浮点颜色值。每秒计算的多边形量提高很有限,仅为63000个。第三步我放弃了浮点颜色增量,而采用类似Bresenham画线算法的误差累积量修正。对于颜色值大于步进值的计算我置入了循环累加。这次表现得很不错,有90000个多边形。后来我对浮点的斜率修正很不满意,决定用类似的做法修改它,于是第四步我完成了整个函数的定点值计算,它处理了102000个多边行。我还发现了一些优化的地方,显存位置可以通过累加计算获得,对特殊多边形区别处理。

图2-7、对顶点色进行插值计算

我打算对这种算法起了一个别名,uvw三角形光栅化算法。因为整个过程都是围绕三角形三个顶点的位置关系来进行的。除此以外都是标准的插值算法。我总是假定U是最上端的顶点。V和W的位置关系在进行x横向颜色插值的时候起作用,我们总是从x值较小的位置开始光栅化,但是较大的V侧的x值使得我们必须从W侧的颜色开始插值。
Bresenham画线算法测试x、y方向的步进距离,并以较大的步进方向作为累加误差的轴向,而误差值和较小的步进方向关联起来。

图2-8、利用x和y的步进值计算误差

如图所示,我们在x方向上持续画点,初始y方向的值为0,每在x方向上绘制一个像素,采用一个误差值存储y步进量的累计值,当误差值大于x步进值,y方向的数值增加1个像素单位,并扣除。这样,当我们绘制完x方向的全过程,在y方向也进行了等比例的长度修正。可能是如下的代码:

if(lx>=ly){
err=0;
for(i=0;i<=lx;i++){
    DrawPixel(curx,cury,0x000000);
    if(err>=ly){
     err-=lx;
     cury+=y_inc;}
    err+=ly;
    curx+=x_inc;}}

图2-9、y步进值比x步进值大的情况

考虑这样一种情况,如果y方向的步进值大于x方向的步进值,这时候需要对y方向执行步进操作,利用误差值修正x方向的值。在画线任务中,这不是问题。但是在光栅化三角形的时候,情况有什么变化?

图2-10、光栅化三角形需要顾及V侧和W侧的斜率变化

我们可以使用类似的算法完成U到W的x方向偏移,因为x方向步进量相对于y方向的要小。但是U到V的斜率变化则超出了我们的预计,每移动y方向一个像素,x方向都会有几个单位像素的变化。可以逆向思考,如果在x方向偏移了这几个像素,导致了y发生一个像素的偏移,则可以置入内循环来叠加误差,并在误差达到y发生偏移的量度之前退出,如此往复。代码可能如下:

if(Vloop==false)
{
if(Verr>Ylen1)
{
    xV+=Vinc;
    Verr-=Ylen1;
}
Verr+=Vstep_;
}
else
{
while(Verr<Vstep_)
{
    xV+=Vinc;
    Verr+=Ylen1;
}
Verr-=Vstep_;
}

前半段是典型的Bresenham算法,后半段使用了一小段的Bresenham算法倒置。循环的条件不再是主动轴的跨度,而是引起被动轴发生修正的主动轴跨度。当然这个内置的倒置算法的主被动关系是相反的。即主动轴依然是y轴而不是Bresenham算法认为的x轴。
在进行这个光栅化算法的完善过程中,遇到的唯一障碍是,当V侧或者W侧发生斜率变化时,x的当前点已经可能产生了1个像素的误差,若对该x值再进行计算,则以下的点均可能往某个方向偏移了一个像素。其导致的实际效应如图所示:

图2-11、糟糕的像素误差

我相信每个或多或少遇到此类问题的人都会马上取消他们的周末行程。因为对于误差的出现我们已经熟视无睹了,特别是定点运算。但是有时候多边形最终呈现的结果很小,几个像素大小而已,这种误差必须避免。唯一的做法就是使用斜率变化处的顶点坐标作为起点坐标。并清除之前的偏移误差量。

if(minY==Yuv)
{
xV=v.x;
Ylen1=abs(Yvw);
Vstep=Xvw;
Vstep_=abs(Vstep);
Vinc=Vstep>0?1:-1;
Vloop=false;
Verr=0;
if(Vstep_>Ylen1)
    Vloop=true;
}

我将此称为“xV=v.x”启示。

图2-12、xV=v.x启示的结局

颜色插值有一致的算法,差别只是,xV到xW的变化量为主动轴向,像素色的变化为被动轴向。像素色分量从0到FF,每个像素增量单位为1,对应每个颜色的增量单位1,并进行轴向长度比较。
了解了基本的插值算法后,应该开始了解Gouraud如何在多边形渲染中工作的了。
如前所述,Gouraud采用顶点颜色插值。这需要我们计算顶点的颜色值。有一个问题是,一个顶点通常被几个多边形共用,而多边形经常是不共面的,因此顶点处的法向量应该是共点的几个多边形平面的面法线的均值,这样才能返回可信的光照结果。
向量的位置没有意义,只有它的方向才有意义。当然,所谓的方向固定意味着向量起点和终点之间位置关系的不变性。因此,事先计算各个顶点的法向量,在流水线周期内,利用旋转变换矩阵对法向量进行操作,便可以送入光照管道。光照的位置信息是和顶点相对坐标原点的位移联系的。这就提出了一个要求,光照管道内的所有向量信息都应该是归一化的。

图2-13、顶点法线受周围多边形面法线影响

用于计算顶点法线的代码段(Code Section):

vector<VECTOR3> normals;
VECTOR3 u,v,n,v0,v1,v2;
COLORF c;
for(int i=0;i<model.vbuf.size();i++)
{
normals.clear();
//寻找具备该顶点的多边形
for(int j=0;j<model.ibuf.size();j+=3)
{
    if((model.ibuf[j]!=i)&&(model.ibuf[j+1]!=i)&&(model.ibuf[j+2]!=i))
     continue;
    //计算多边形面法线
    v0=model.vbuf[model.ibuf[j]];
    v1=model.vbuf[model.ibuf[j+1]];
    v2=model.vbuf[model.ibuf[j+2]];
    u=v1-v0;
    v=v2-v0;
    n=VCross(u,v);
    normals.push_back(n);
}
//所有法线相加
for(int j=0;j<normals.size();j++)
    n=n+normals[j];
n=VNormal(n/normals.size());
model.nbuf.push_back(n);
SetColorf(c,-1.0f,-1.0f,-1.0f);
model.cbuf.push_back(c);
}

最后说明的一个原则是:在设计算法的时候,如果有进行迭代,应在迭代数据产生增量前使用数据,在使用数据前计算溢出和裁剪。次序上的混乱经常是未知错误的根源。如上面的斜率变化应该属于溢出状态,即y扫描线已经到达了限界,必须在使用数据前修改部分数据。而进行增量前使用数据则容易理解。我们不应该在0扫描线未绘制的情况下把扫描线移动到了1,这样我们将缺乏0扫描线和增加了n+1扫描线。

 

图2-14、在没有纹理贴图参与的情况下,人物外形已经很逼真了

六、一个示例代码
使用为数不多的自定义代码可以创建高效灵活的执行程序,这是引擎的主要功用。它提供了大量的按约定模式组织的重用代码段和函数集。特别是,经过封装,可以利用显见的和容易理解的主程序逻辑来驱动引擎,达到预想的效果。
示例代码仅提供这些必须的信息:模型文件位置、相机信息、环境灯光,渲染器则提供:物体位置,光源信息。此外的一切任务全部在引擎中执行。在使代码简约、可读性强以及格式命名等方面进行了各种权衡和取舍。为了使结构体名尽量简单,我几乎都是使用它们直接的明文形式,这可能会导致重名问题,在某种必要的情况下,可以加入名称空间(Namespace)。

CAMERA cam;
OBJ wall,robit;
OBJ ball;
OBJ ball1;

int CustomInit(HWND hWnd)
{
//设定欧拉相机位置
SetPos(cam.pos,0,50,-90);
//设定欧拉相机方位
SetEuler(cam.E,0,30,0);
cam.nearclip=20;
cam.farclip=300;
cam.portang=90;
SetCamera(cam);
Load_msh(_T("彩色墙面.msh"),wall);
Load_msh(_T("机器人-武器.msh"),robit);
Load_msh(_T("ball.msh"),ball);
Load_msh(_T("ball1.msh"),ball1);
//设定环境光
setupgloballight(rgb3tofloat(0xFFFFFF),0.42f);
return 0;
}
int CustomRender(HWND hWnd)
{
static float starttime=(float)GetTickCount();
static float timedelta=0.0f;
timedelta=GetTickCount()-starttime;

//变换角度
static float angle=0;
angle=ANormal(timedelta/1000.0f*100.0f);

SetPos(wall.pos,0,0,0);
SetEuler(wall.E,0,0,0);
wall.m=MI*rotationy(-5);
pipeline.objects.push_back(wall);

SetPos(robit.pos,0,10,0);
robit.m=MI*rotationy(-angle);
SetEuler(robit.E,0,0,0);
pipeline.objects.push_back(robit);

SetPos(ball.pos,0,0,0);
SetPos(ball1.pos,0,0,0);
ball.pos=ball.pos*translation(30,0,0)*rotationy(angle);
ball1.pos=ball1.pos*translation(0,30,0)*rotationx(angle)*translation(0,8,0);
SetEuler(ball.E,0,0,0);
SetEuler(ball1.E,0,0,0);
ball.m=MI;
ball1.m=MI;
pipeline.objects.push_back(ball);
pipeline.objects.push_back(ball1);

POINTLIGHT ptl;
EMISSIVE& emi=ball.models[0].material.emissive;
COLORF c={emi.r,emi.g,emi.b};
ptl.C=c;
ptl.I=80.0f;
ptl.P=ball.pos;
lights.ptls.push_back(ptl);

EMISSIVE& emi1=ball1.models[0].material.emissive;
COLORF c1={emi1.r,emi1.g,emi1.b};
ptl.C=c1;
ptl.I=80.0f;
ptl.P=ball1.pos;
lights.ptls.push_back(ptl);

INFINITELIGHT infl;
infl.V=VSet(0.0f,0.0f,1.0f);
SetColorf(infl.C,1.0f,1.0f,1.0f);
infl.I=0.3f;
lights.infls.push_back(infl);

int trianglecount=pipelinerender(cam);
VDEnterGDI();
TCHAR buf[55];
_stprintf(buf,_T("%.3fFPS"),ShowFps());
VDText(50,50,buf);
_stprintf(buf,_T("多边形:%d"),trianglecount);
VDText(50,70,buf);
_stprintf(buf,_T("每秒多边形:%d"),drawcountpersecond);
VDText(50,90,buf);
VDLeaveGDI();
return 0;
}


七、对示例代码的维护
随着理解程度的深入和内容的持续扩充,之前一些演示代码的格式可能过时的,最简单的做法是,每个阶段的引擎系统和相关演示对应,并作为一个整体存储。这样就不必为了修改那些过时的结构体和编写风格而写了很多不必要的维护代码。事实证明这是合理而必要的。在进行最新内容的扩充时将尽量避免对结构体的改变,若不得不这样做则将在阶段代码中统一维护。

八、实用的工具集合
三维建模、格式转换:MilkShape 3D 1.710
几何图形绘制:几何画板4.07
电子书制作:pdfFactory Pro 3.17
图标制作:Microangelo Toolset 6
VC助手(提供完整的智能提示):Visual AssistX 10.1.1418
公式编辑:MathType 5.2
以上工具均可在网络上获取。

 

第三部分、UV仿射贴图、材质混合、文件操作

图3-1、将光照和贴图进行了材质混合

图3-2、使用z缓冲来执行像素级排序,使物体得于正确渲染

目前还没有涉及裁剪,因此对于一些离相机过近以至发生负投影的多边形被简单地剔除了。视景体的其它5个面则被简单地利用光栅化函数过滤了。这类似于硬件的工作模式,因为它足够简单并且可以假设我们可以在以后事先执行了裁剪,这样光栅化函数只是提供了判断的开销,这对于不执行这样的任务的灾难性后果来讲是值得的。
一、Bmp位图格式和读取位图信息
为了把贴图放置到网格上,首先要解决的问题是使用什么图像格式和定义它的内存镜像格式。当前最简单的图像格式是bmp。我使用了它的8位和24位版本。8位因为信息量不足(仅仅256个定义长度),所以引入了调色板的形式。而24位色版本则提供了全部的RGB信息。执行贴图运算的时候,为了要立即得到有用的RGB信息,调色板索引应该在读取位图之后进行转换,和24位色版本一致。这是当前的硬件条件所允许和显示模式所决定的。因此,所有的各种图像格式最终都要转化成一致的内存镜像格式。
对于文件操作,我使用了fstream流,这是C++的标准文件IO操作流,是STL(标准模板库)的一部分,现代编程需要程序员更快地解决问题,模板化为这一切作了大量的工作,C++并不意味着总是使用类结构,事实上,模板化范型编程正在成为C++编程的事实标准。设想,一个数组可能是包容字符的或者是浮点类型的数据集,而vector则替代了数组,没有指针操作,它使用了迭代器。原始的C++语言对于类内部类型是严格定义好的,假设有一个Cvector类,它可能只接受整型数据,但是vector接受任何你想要存放的类型,使用vector<type>作为类型符号。并且可以使用和指针相同的偏移模式进行迭代,它的size()子函数提供迭代限制。

fstream f;
f.open(filename.c_str(),ios_base::in|ios_base::binary);
if(!f)
{
f.close();
return -1;
}
//读取文件头
f.read((char*)&bmf.bmfh,sizeof(BITMAPFILEHEADER));
f.close();

ios_base枚举了fstream可能会用到的各种限定符,open()第二个参数需要指定打开类型,有一些有用的枚举:
ios_base::in 读操作    ios_base::out 写操作 ios_base::app 追加写入
ios_base::ate 添入 ios_base::binary 二进制 ios_base::trunc 截断
读写操作子函数:
write() 写数据     read()读数据     get() 获取字符/字符串
getline() 获取一行字符串

位图格式:Bitmap File Format
1、镜像文件:
#define PALETTEENTRYS 256
typedef struct
{
BITMAPFILEHEADER bmfh;
BITMAPINFOHEADER bmih;
PALETTEENTRY palette[PALETTEENTRYS];
BYTE* bits;
int size;
int width;
int height;
int bitcount;
int bpp;
int pitch;
}BITMAPFILE;

真实文件数据不包含BYTE* bits后面的数据,那是为了访问方便而人为加上的。
2、文件头BITMAPFILEHEADER

typedef struct tagBITMAPFILEHEADER {
          WORD      bfType;
          DWORD     bfSize;
          WORD      bfReserved1;
          WORD      bfReserved2;
          DWORD     bfOffBits;
} BITMAPFILEHEADER, FAR *LPBITMAPFILEHEADER, *PBITMAPFILEHEADER;

有用的数据是bfType,应该为0x4d42,即“BM”,bfSize,指出文件头结构大小,bfOffBits:缓冲区数据的起始处相对于文件开头的偏移量
3、文件信息BITMAPINFOHEADER

typedef struct tagBITMAPINFOHEADER{
          DWORD        biSize;
          LONG         biWidth;
          LONG         biHeight;
          WORD         biPlanes;
          WORD         biBitCount;
          DWORD        biCompression;
          DWORD        biSizeImage;
          LONG         biXPelsPerMeter;
          LONG         biYPelsPerMeter;
          DWORD        biClrUsed;
          DWORD        biClrImportant;
} BITMAPINFOHEADER, FAR *LPBITMAPINFOHEADER, *PBITMAPINFOHEADER;

biSize:该结构的大小,用于指出当前结构版本,biWidth:图像宽度,biHeight:图像高度,biPlanes:包含的调色板数,biBitCount:色深度,biSizeImage:图像数据区长度,biClrUsed:使用的调色板色数。biClrImportant:主要调色板色数。

4、调色板单元PALETTEENTRY结构

typedef struct tagPALETTEENTRY {
      BYTE          peRed;
      BYTE          peGreen;
      BYTE          peBlue;
      BYTE          peFlags;
} PALETTEENTRY, *PPALETTEENTRY, FAR *LPPALETTEENTRY;

peRed、peGreen、peBlue,8位长度的RGB信息,因为约定的关系,以上信息跟GDI的RGB次序是一致的,但是和显示屏RGB次序颠倒,为了使用方便,需要调整R和B的位置。最后一个标志符是向系统提示调色板的使用方式:

#define PC_RESERVED       0x01      /* palette index used for animation */
#define PC_EXPLICIT       0x02      /* palette index is explicit to device */
#define PC_NOCOLLAPSE     0x04      /* do not match color to system palette */

以上三个标志符分别指定为:可变的、系统默认、自定义。基本上,我们总是使用自定义调色板。这个标志字符文件没有给出,作为保留段。所以由导入函数指定。
缓冲区没有什么好神秘的,不同的位深的图像有不一致的像素占位。8位图像占据1个位长度用于指定调色板索引。24位图像占据3个位长度。主要的问题在于,由于历史原因,很多图像的y方向扫描线是颠倒的,也就是图像是自底向上指定的。

图3-3、图像自底向上指定

当图像文件内定的高度信息是正值,便声明为以上形式,反之亦然。

//翻转位图
if(bmf.height==Abs(bmf.height))
{
BYTE* buf=(BYTE*)malloc(bmf.size);
memcpy(buf,bmf.bits,bmf.size);
memset(bmf.bits,0,bmf.size);
for(int y=0;y<bmf.height;y++)
    memcpy(&bmf.bits[y*bmf.pitch],&buf[(bmf.height-y-1)*bmf.pitch],bmf.pitch);
free(buf);
}

图3-4、图像最终呈现在窗口上

二、仿射纹理贴图
对物体网格表面进行贴图可以简化成对单个多边形执行贴图的任务,附图呈现的是一个多边形,使用的采样对象的坐标值均转化为0-1的浮点数值,这意味着我们可以使用任何尺寸的位图作为采样对象,因为多边形使用的采样对象坐标都是一致的。当然,在最终光栅化阶段,我们需要对事实上的坐标进行插值。这一切对于网格操作和图像选取任务都是透明的。

图3-5、使用归一化坐标进行纹理采样

当前考虑的是图像空间的采样,因此标准的插值算法是有效的。该插值过程和Gouraud着色渲染插值同步进行,我几乎是将Gouraud的插值代码重新拷贝了一遍,它就开始正常工作了,当然了,我修改了变量名,以便于它同采样点对应起来。注意,光栅化阶段的采样使用的并非归一化坐标,而是事实坐标,这样才能有足够的定点数进行插值。并且避免正式采样的时候还要转化一次坐标。
仿射纹理映射和Gouraud着色的插值计算存在的同样一个问题是,插值过程是在图像空间进行的,而事实上,图像空间上的坐标点是经过透视变换的,在图像空间线性变化的数值,在实际相机空间中受z方向的数值变化影响而呈非线性变化,变换如图所示:

图3-6、透视变换中,z值得区别使得在视平面线性变化的点在实际空间非线性变化

图形学有一句至理名言“如果它看起来是,那它就是了”。而事实上,这种情况是可以得到更正的,根据公式y=y’(d/z’),我们可以对d/z’进行插值计算,这样就能够得到y值变化在图像空间“扭曲的”而在相机视景体空间是线性的插值过程。后面将会接触到的z缓冲算法的一个变种就是基于这样的事实。
尽管如此,目前我仍然使用了看起来笨拙但是更容易实现和高效的图像空间插值。当多边形在深度上相对于视平面平行面的跨度并不是很大,即多边形平面和视平面的倾斜夹角较小时,插值过程越精确。

图3-7、进行仿射纹理映射的结果图,就如我们在示意图假想的那样

还有其它问题,第一、起点误差,这是扩展Bresenham算法引入的起点误差计算,我现在要处理的事情是如此之多,因此我一直没有对它进行额外编码。第二、双线性过滤,对纹理采样的时候,为了避免锯齿现象,应当在2D空间对当前采样点的周边进行加权计算以确定最终像素色值,很明显,这会占用处理周期。


三、流水线层次结构
基本层次结构:


对应关系:

其中,若法线和顶点列表同步,而不是独立到多边形面序列,则通过顶点索引访问法线列表和光照列表。最终权衡,我决定放弃对3DS MAX的法线多边形面序列的兼容,对顶点法线做统一处理,使得每个顶点的光照只需计算一次来提高效率。


修改的对应关系:

上例中将法线和光照和顶点列表对齐,这样每个顶点光照将只计算一次。但这种格式需要对多边形网格有一个约定,对于由平面组成的图形,需要分割位置一致但法向量不一致的顶点。
四、Alpha混合查找策略
在讨论光线调制和Alpha混合的时候,我曾经提到,0-1的浮点取值使得光线乘法有意义并且不会导致越界。事实上,浮点运算有时候并不是我们想要的,而且,RGB的分量只有256的精度范围,在32位色格式中的A值也是使用了8位存储,使用浮点计算显然有些夸张。实践证明,光线乘法并不是单纯的乘法计算,而是一种类似将信息置入载波的调制过程。在对0-1的取值进行计算的时候隐含了除于1的降0位操作。由此可知,当对两个8位数值进行调制运算时,需要对其进行降8位操作,结果数值范围保持在8位以内。

如上式所示,一种更容易理解的方式是,将分量转化成0-1的浮点数值,再将它逆乘,得到调制的结果RGB显示格式:
R=(int)(((float)R1/(float)0xFF)*((float)R2/(float)0xFF)*(float)0xFF)=R1*R2/0xFF
显然,得出的结论一致,但是0-1浮点数值的相关运算是基于先验性的假设,这是一种不确定的推导。

for(int i=0;i<=0xFF;i++)
for(int j=0;j<=0xFF;j++)
    alphatable[i][j]=i*j>>8;

上述查找表将用于支持Alpha混合计算。例如,讨论纹理映射的时候,我忽略了这样一个细节,如何将光照的结果同贴图色混合起来。下面就是使用该查找表进行混合的实例代码:

bmpB=alphatable[bmpB][Bbase];
bmpG=alphatable[bmpG][Gbase];
bmpR=alphatable[bmpR][Rbase];

五、ASCII网格存储结构,msh和ase
Msh是从MilkShape 3D导出的一种简易而高效的文本存储格式,但是它并不支持动画网格和骨骼。而MilkShape 3D的默认文本导出格式则支持。另外,3DS MAX导出的3DS格式导入到MilkShape 3D的时候,会出现坐标系错误,支持的顶点数有限,贴图坐标破坏以及其它未发现的问题,再者,直接使用3DS MAX的一些特性选项有时候会成为一种需求,总之,有必要直接从3DS MAX导出的文件中读取信息。
但是3DS MAX导出的ASE文件有几个问题,第一,它仅支持三角形面级别的法线和贴图坐标,而通常我们均把法线、顶点光照、贴图坐标等同顶点列表对齐,这个问题在上面讨论流水线层次结构的时候已经提到。第二,它使用标签描述格式而非流格式,当然我们可以使用流格式的处理手段来读取它,但是这意味着随着对此格式文件的内部信息的需求的增加,将使得代码越来越混乱,最终我考虑了这样的策略,开发一种自定义的二进制文件版本,用于支持对任意输入格式的导出和文件级缓存工作,甚至到后来直接作为外部工具对所有开发包使用到的模型进行转换。这可以极大缓解处理ASE格式文本信息低效的现状。在实现上它应当没有问题,因为这种二进制格式是我们任意给定的,为了完成这项任务,它将同流水线的层次结构对齐起来,实现广泛的兼容性。这意味着,任何导入格式在这种二进制格式没有体现出来的特性,在流水线层次结构中也没有对应元素。一旦确定需要某种元素,这种特性立刻就会在流水线层次结构和二进制文本格式中同步更新。这样,我们使用了所有我们要求的外部信息而没有降低处理速度。另一方面,即使为了速度考虑而优化了ASE等文本格式的导入工作,上述的开发依然是必须的。只不过我们把它提前了,以便于我们不必在文本格式的低效性上大费脑筋。
Msh文件结构:

关于这种格式,优点是简单易行,缺点是它没有支持更多的特性。当然,对于场景编辑,这种格式已经可以满足需求,而桢动画则使用多文件或者单文件多段存储,如果坚持要使用这种文件格式的话。
3DS MAX标准文本导出格式ASE的文件结构:

如果你正在寻找它的几何体存放在哪的话,事实上,这不是描述具体信息层级关系的图示,而是类似XML的一种存储规范,它具有独立标签和嵌套标签,独立标签有名称和值表,而嵌套标签不仅可以嵌套子标签,还可以嵌套“子嵌套标签”,如此递归下去。这种格式固然带来很大的灵活性,但是非常浪费时间。需要对它们全部解套然后利用一致的算法,提供一个匹配标签名进行标签取出和迭代。
*3DSMAX_ASCIIEXPORT 200
*COMMENT "AsciiExport 版本    2.00 - Wed Sep 19 22:16:39 2007"
*SCENE {
*SCENE_FILENAME "box.max"
*SCENE_FIRSTFRAME 0
*SCENE_LASTFRAME 100
*SCENE_FRAMESPEED 30
*SCENE_TICKSPERFRAME 160
*SCENE_BACKGROUND_STATIC 0.00000000 0.00000000 0.00000000
*SCENE_AMBIENT_STATIC 0.00000000 0.00000000 0.00000000
}
第二行使用了双引号对值进行了限定,避免被分割成子单元。我们可以假设*SCENE块还可以内嵌子块,假如存在,便可将此子块作为独立单元进行递归分解。直到所有嵌套子块全部被遍历。
算法很优雅,优雅的代价是慢。我可以开发一种不太优雅的较为快速的流式提取法,但是最后我决定使用文件级缓存和转换工具。在没有转换的情况下对所有导入格式进行二进制文件级缓存。缓存尽量写在当前文件夹的某个子文件夹中,仅当子文件夹不可访问的时候才写入系统临时文件夹。
但是目前我还是把该解析算法列出来。作为一种参考和改进的基础。


元素定义:

typedef struct
{
string name;
vector<string> values;
string itembody;
}ASEITEM;

struct ASEBLOCK
{
string tag;
vector<string> values;
vector<ASEBLOCK> blocks;
vector<ASEITEM> items;
vector<string> blockbody;
};

进行数据递归分解:

ASEBLOCK ase_getblock(const vector<string>& lines)
{
ASEBLOCK block;
ASEBLOCK subblock;
ASEITEM item;
vector<string> strs;
vector<string> inlinebody;
int step=0;
bool bracket=false;
bool headerline=false;
vector<string> headerinfo;
for(int i=0;i<lines.size();i++)
{
    strs=split_space(lines[i]);
    //检查行类型
    //单元模式
    if(step==0&&strs[strs.size()-1]!=_T("{"))
    {
     item.values.clear();
     item.itembody=lines[i];
     for(int j=0;j<strs.size();j++)
     {
      if(j==0)
       item.name=strs[0];
      else
       item.values.push_back(trimquot(strs[j]));
     }
     block.items.push_back(item);
    }
    //块模式
    headerline=false;
    if(strs[strs.size()-1]==_T("{"))
    {
     if(step==0)
     {
      headerline=true;
      headerinfo=strs;
     }
     bracket=true;
     step++;
    }
    if(strs[strs.size()-1]==_T("}"))
    {
     bracket=true;
     step--;
     if(step==0)
     {
      //subblock=ase_getblock(inlinebody);
      subblock.blockbody=inlinebody;
      for(int j=0;j<headerinfo.size();j++)
      {
       if(j==0)
        subblock.tag=headerinfo[0];
       if(j>0&&j!=headerinfo.size()-1)
        subblock.values.push_back(headerinfo[j]);
      }
      block.blocks.push_back(subblock);
      inlinebody.clear();
      bracket=false;
     }
    }
    if(bracket&&(!headerline))
     inlinebody.push_back(lines[i]);
}
return block;
}

六、自定义二进制储存格式

typedef struct
{
TCHAR sourcefile[64];
int modelcount;
int materialcount;
int texturecount;
}LOSFILEHEADER;

typedef struct
{
TCHAR modelname[64];
int vertexcount;
int indexcount;
int tvertexcount;
int tindexcount;
int textureindex;
int materialindex;
}LOSMODELINFO;


这是一种非常紧凑的储存格式,并涵盖了每个阶段引擎所需的任何细节,因为它和OBJ结构体的描述基本上保持一一对应的关系。导出和导入工作都可以非常快速而简洁地完成。

性能测试比较

由图表可见,两者完全没有可比性,即使是读取10兆的文件,LOS也可以在1秒钟左右完成全部读取工作,相比较而言,同样的信息量,ASE要占据50兆左右的空间和漫长的读取时间。

第四部分、实时渲染技术
该主题将包含以下内容:标准视景体的构造、1/W线性插值、AABB包围盒、碰撞检测、Alpha通道、雾化、基础物理模型,以及一个用于演示以上内容的例子。
我阅读了各部分当前已经积累的一些概念性东西,并着手完成了所有细节工作。其中Alpha通道、全局顶点雾、基础物理模型是在考察了相关需求之后,完成从最初的构思到最终呈现,并且尽量提供较高的性能,以期取得良好的结果。Alpha通道和顶点雾都使用了256分辨率的Alpha深度值,这个约定使得Alpha运算过程能够获得最高效的实时性能。否则,实现顶点雾的效果将变得不可行,这样从图像呈现的角度,游戏的可玩性将大打折扣。


  
图4-1、第二个场景不采用全局顶点雾,场景突然中止在远裁剪面

 

最近我在思索很多的主题,其一、对多边形的利用,对贴图和效果的呈现,应该要简化到什么程度,才能体现出足够宏大的场景,比如说,也许多边形数可能不多,但是却占据了广大的空间,或者物体很小,但多边形数很多,应该是用多少多边形才足够并且不会影响速度。其二、或者立刻停止软件渲染的研究工作,使用硬件来加速。计算机的内核处理速度将会越来越快,软件渲染将会在未来被重新考虑并工作得越来越好,并且有利于表达出一些奇异的物体组织模式和渲染效果,而不必借助于硬件的升级。通常,硬件将只满足大多数的工业需求。其三、底层的数学演算和基于充满激情的情感逻辑推演之间应该架起怎样的一座桥梁。比如,如果利用可行的一套数学模型描述关于什么是幽默和悲伤的感受。我知道它们属于不同的概念层次。但是为什么不能说:5是个很有趣的家伙。我是说,在游戏开发过程当中,什么将占据主导作用?真实光照模型的数学公式或者是“把颜色的数值降下来,它们太远了”这样的描述情节,碰撞物相互之间的反馈力到真实物理世界的等比例建模或者“碰上了,让物体退回5个像素吧”。我们旨在建立一个有趣的,带有幽默感的可预期的假想世界还是建立一个现实生活空间的缩影?最终,我通过玩大量的游戏时发现,细节的感动通常更能让玩家沉迷。比如《格兰蒂亚》里面的小型物体因为玩家的碰撞而摆动,但是小型物体只是播放了摆动的动画和声音,而没有真正和玩家的身体各部件建立碰撞关系。而在射击游戏中(第一人称或者第三人称),简单而高效的操作按钮将使玩家立刻判断出它是否值得玩,而不是给更多的自由度,这种毫无节制地膨胀的技术把戏。
上述的论调看似我一直在力求逃避严谨的数学演算的问题,有一点。在我看来,数学演算系统在能够用5到10个公式计算出“幽默指数”之前,我大量地使用了情感机制。
一、标准视景体的建立
很多的书籍试图将我们将要获得的视景体空间描述成一个立方体,这实际上是不可行的,甚至到多边形描绘阶段,如果这个多边形附带有贴图坐标,也是未经透视的。
标准视景体依然是个锥形体,只不过它在Z方向上的每个截面都同相应的W值对应了起来。W=z/d,有时候我使用w作为“未经变化的”z值使用,计算顶点雾的时候我就是通过这个数值乘于d来和全局雾区间进行比较的。
另外一个需要关注的问题是d的取值,大量的研究总是假定d的取值始终为1,理由有二:其一、w=z/d=z,这样w和x、y、z的分量的比较工作将更为直接,当前视景体视场总是以90度为基准,这使得通过缩放到基准的最大x、y值和xy截面的z值相等


尽管如此,我依然使用了可变的d值,因为它使得代码在逻辑上讲是准确的,这样在所有的向量分量都要采用d作为分母。


上述式子我透露了一个信息,z方向的xy截面区域将和相应的w取值进行比较,以确定是否位于视景体内。Xmax应该是x可能的最大取值。

 

 


图4-2、不同的xy截面,所确定的x或者y方向的视景体区间是不同的

 

我曾经对于标准化视景体是否必要表示怀疑。直到我将它用于物体包围盒地剔除工作的时候才打消了这个念头。基本上,它将视景体的一切信息都存放在了W分量上了。其它分量为了和W对齐都做了一定的缩放工作。



Zoomy和Zoomx都可以理解,关键在于Zoomz,每个截面的w取值都是不等的。但是以下事实可以作为比较工具:

 

 


图4-3、关于变换后的z和w比较的问题

 

如图,当per=0时,w和近裁剪面一致(不考虑d的影响),而z值为0,当per=1.0时,w和远裁剪面一致,相应的z值为w,当per落在0到1.0的内区间时,
z-w=nc*per-nc=nc*(per-1)<0,即z<0。而当nc=0且z=0时w=z,或者z=fc时,per=1.0,w=z。
这是一个有趣的现象,尽管标准化视景体没有给出z和w的直接大小对应关系,但是它在客观上维护了这种大小对应关系。正确理解它在更深入的使用中将会帮助很大。
没有一本书籍写出比以上验证过程更接近真相的描述了,最后给出了期望的视景体变换矩阵:

二、透视纹理修正
当我完成了对纹理进行透视修正的代码工作之后,我再也不想看到以前那样子的纹理映射效果了,我竟然忍受了它们数天的时间。并且我觉得如果有人重新发明纹理映射算法的话,它首先考虑的是经过透视变换的纹理映射坐标而不是直接对它进行图像空间插值。只是我对Gouraud着色概念的“剽窃”影响了个人的思维。

 


图4-4、对透视投影进行线性插值造成空间扭曲,两个面产生交叠

 


图4-5、z缓存使用线性插值得到的深度信息,必然造成部分平行面交叠

 


图4-6、通过正确的深度信息获取,该问题被解决(使用1/w作为插值对象,后面提到)

 

因为纹理贴图是定义在视景体空间的,而x、y方向的值都被透视变换了,因此只需将纹理贴图进行同样的透视变换,就能够得到纹理贴图坐标在图像空间的线性插值坐标,并回乘关联的透视因子w就能够得到实际采样的纹理坐标。问题是,怎么得到关联的w的数值。这利用了1/w是关于图像空间线性变化的事实。现在变成了验证1/w在图像空间是否线性变化的问题了。
如前面的所有难题一样,我首先采用了数学的办法对其进行证明,并把式子写得越来越复杂,但这并不奏效。后来我经过反复论证,使用了一种极简的办法,即认为1/w是关于空间直线y=1在图像空间的投影,论证如下:

 

 

 

通过以上事实,就获得了投影到图像空间的纹理贴图坐标的线性插值过程,以及对应的1/w线性插值过程,该坐标除于1/w就得到了视景体空间的坐标。
唯一值得注意的问题是,检验1/w是否落在近裁剪面以外的区间。这需要和d/nearclip进行比较,当d=1时,nearclip小于1将导致1/w可行的区间大于1,这本来不是什么问题,但是实践中我们通常设置了1/w深度缓冲的偏移量为1,这样前一个循环节的深度缓冲信息可能无法使当前循环节正常通过检测。这个时候只需nearclip设置不小于1的情况。假如d=100或者更大的数值,为了保持深度缓冲区间为0到1的特性,nearclip将需要大于100!这是不可接受的,因此固然引入d值在逻辑上有效,但是基于这个原因,不应当更改这个数值。或者使用更小的取值,如0.5,这样nearclip就可以推移到0.5,但是有谁会关心这半个单位量的距离呢?

 


图4-7、因为过大的d(这里是100)值导致后续桢的一部分像素无法通过检测

 

三、经过改良的流水线,对象表映射和Alpha通道
随着对物体个数的量的需求的增加,对象拷贝传入流水线开始产生瓶颈,因为必然有大部分对象因为剔除的关系而完全不会被访问到细节信息。之前总是假定渲染对象都是在视景体内的,但是现在的引擎引入了包围盒层次,就不应该再这样做。况且,以后还要使用到多次渲染呢(在一个桢缓存里面渲染多个子画面或者多个层次画面,用于不同视景区间的物体呈现,这广泛用于图形界面和其他类似应用)。
但是什么属性需要放在对象表映射结构上呢?直接的回答就是:在流水线深入对象细节之前的一切必须信息。如包围盒区间、位置、方位等,在我试图把它们全部枚举出来之前赶快来看一下这个动人的结构:

typedef struct
{
OBJ* obj;
EULER E;
VECTOR3 pos;
VECTOR3 move;
MATRIX m;
COLLIDEBOX collidebox;
COLLIDEBOX aabb;
int models_size;
int randid;
float fallspeed;
float speed;
bool landing;
}REFOBJ;

有一个move向量,这用于简单的物理系统,当判断动作不可行或者需要额外动作(物体下落、被迫回退等)时进行修正,最后附加在pos上,这时候物体才真正做出反应。Collidebox存放对象模型的碰撞盒,这是读入模型之后就定义好的了,aabb在游戏逻辑需要的情况下,将重新计算当前旋转量下的轴对齐包围盒(Axis Align Bound Box)。为了避免过度的运算开支,我使用了变换后的Collidebox顶点计算一个AABB而不是针对所有顶点,这在控制对象是人物并只做水平方向的旋转的情况底下很可靠。毕竟很少有人为一个横着放的长杆子着手编写游戏。如果它不是人物或者拟人的什么其他东西的话,则可以在游戏逻辑上建议实时转换顶点的aabb,为了性能考虑,可以加入一个忙碌拒绝策略,即每个循环将只处理部分物体的aabb转换。旋转量过大的物体获得最高的优先权。而至于场景对象的包围盒,为了开启动态的地形跟踪算法,包围盒将进行细分,在加上场景对象绝大多数情况下不做旋转的动作,这样通过初始的Collidebox顶点转换的AABB就可以直接拿来用了。Randid标识符用于碰撞排除,即物体A在遍历所有可能碰撞对象的时候将不考虑自身。Fallspeed是用在物理模型的下落速度。Speed是物体的运动速度,landing标记物体是否处于着陆状态,若是则拒绝为Fallspeed添加重力加速度并使其为0。
当前我使用STL的vector用于仿造数组编程,我可不想在指针上面懂什么脑筋,而且该映射结构已经很简短了,无需考虑将对象映射结构的指针放入流水线渲染表里。不过其映射的对象则使用了指针,当写到这里的时候我想到我完成了一个模型统一读取模块,该模块将对各个放置到对象表的对象进行命名,并利用map进行键值映射,但是因为这个命名可以由用户编程界面定义,可能存在的重名问题将导致不可知的结果。
经过修改的流水线体:

typedef struct
{
map<string,OBJ> objects;
vector<REFOBJ> renders;
vector<BITMAPFILE> bitmaps;
TRIANGLE alphachannel[30000];
}PIPELINE;

其中包含了对象表,渲染表,位图表和Alpha通道。对象表和位图表在一个合适的关卡中应当只加载一次,渲染表将每桢清空然后投入新的对象映射。Alpha通道存放了所有透明多边形,避免和普通多边形混杂在一起,把普通多边形的渲染拒绝掉,采用这种策略的一个显而易见的用场是,绘制完所有的普通多边形,排序Alpha通道的多边形,最后再绘制透明多边形。如果一个透明多边形可见,那么它要么直接在所有普通多边形的前面,要么可能被其它透明多边形遮挡,对于前者,这没有问题,所有的多边形都被绘制完毕了,而对于后者,这应该也不是问题,因为已经对Alpha通道执行排序了。当然这种多边形排序内在的交叠情况导致排序很难完全正确完成,除非执行的是像素级别的排序或者类似的其他扫描线检测技术。但是它们是如此的透明,以至效果还不错。
对透明光栅化没有什么值得说的,因为它使用了256的分辨率,和颜色分辨率对齐起来,一切运算结果都存放到了一张二维表格上。再也没有比这速度更快的办法了,要么就不要用它。

bmpB=alphatable[bmpB][alpha]+alphatable[*surbuf][invalpha];
bmpG=alphatable[bmpG][alpha]+alphatable[*(surbuf+1)][invalpha];
bmpR=alphatable[bmpR][alpha]+alphatable[*(surbuf+2)][invalpha];

上面这段Alpha混合的代码段我曾经打算利用它来炫耀,但是为了避免被别人指责无知我就立刻放弃了该想法。
注意一个事实:插值过程永远不会令类似色彩或者位移或者Alpha或者其他任意数值越界,若存在,则一定是代码写错了,任何优秀的输入数据和错误过滤都无法挽救糟糕的代码错误,所以放弃这种不必要的检测。

 


图4-8、经典回归:一个透明材质的茶壶

 

最后,有一种方式可以适当补救深度排序引起的多边形交叠情况(这是我在刚完成这篇文档的大部分的时候想到的),因为多边形交叠可能性的存在,更后面渲染的多边形因为比之前的多边形深度要大,所以被拒绝渲染,而事实上因为透明材质的特性,这些像素信息应该被渲染到屏幕上的。这时候可以考虑这样一种策略,不更新深度缓存,而是渲染所有比不透明物体近的像素,因为产生交叠的多边形通常属于同一个物体所有,它们拥有相同的透明值,而不同物体间的交叠仅在两个物体都拥有透明材质时才发生交叠,但是这种情况可以在关卡设计时避免。还有就是当同一个物体的多边形交叠,且交叠的多边形的光照色彩差异较大,这也会有问题,原本是事实在后部的透明多边形遗留更少的色彩信息,现在则是事实位于前部的透明多边形遗留更少的色彩信息,但是没有什么比什么都不争取就放弃更令人沮丧了。

 


图4-9、深度缓存更新和不更新渲染的差异,第二图近似地实现了真实效果

 

四、起点误差
我曾经试着利用扩展Bresenham算法对起点误差进行估价,但是在多边形光栅化内无法完成,因为它结合了两个轴的修正逻辑,而不是分开处理。最后我仅仅是在画第一个点之前就累积了一个标准误差,使得它在合适的时刻就修正被动变化量,而不会等到所有扫描线循环完毕,少放了一个误差值。扩展Bresenham则对首尾段进行平分。如上面所述,这在没有分支判断的情况下无法完成,要知道,这需要双倍的代码。
五、包围盒层次和碰撞检测
在大量的书籍中,这两者都是分别被分作两章并展开漫长的讨论的,而在这里,仅仅是一个小节的信息量。这说明了两个问题:一、我使用了极为简练的语言试图描述各种错综复杂的技术和逻辑关系。二、为了对付游戏编程,我们是否被日益膨胀的技术信息蒙蔽了双眼。也许,希望就在转角?
这方面的工作都是我在需求的驱使下阅读一些概念性的讲述后完成全部细节的(其实,这句话也适用于之前论述到的所有方面),但已经足够满足当前的一部分需求。如果可能开发一个应有尽有的引擎系统,那么就不会再有层出不穷的A引擎B引擎诞生。除非为了逗趣。
之所以细节上的建立可以重头做起,关键是这些概念描述的主体是否是公开的客观的,如果是的话,重新观察和思量一番也无妨。比如包围盒层次用于物体剔除是基于这样一个事实:我看着眼前的桌子和椅子,还有上面的水果篮,我知道柜子底下还躲着一只猫,可是柜子在哪呢?它在我身后,我必定看不到它,更别提那只行事诡秘的猫了。

 


图4-10、可视化的包围盒

我在物体模型加载的时候处理了模型的包围盒和内部几何体的包围盒,关于这方面的细节没什么可说的。它是2D画面中包围框的扩展,依然是轴对齐的,只不过多了一个维度。对这种类型数据的处理并没有随着新的维度的增加而难度有所加大,它依然可以被压缩到三个一维的区间进行处理。仅当多个轴向之间需要互动的时候这种难度上的变化才体现出来。一个例子是:当物体A部分和物体B在垂直于x轴的面上相交,那么它也应该在y轴向和z轴向和物体B都相交,这样它除了沿反方向退出外什么也做不了。

 


图4-11、物体A和物体B的某个面浅相交,玩家期望它能在B表面漫游

 

当物体A和物体B浅相交的时候我们会认为它们之间碰撞是那么地不明显,就好像根本没发生一样,我们不期望继续往前冲撞过去,至少可以让我们绕开,这意味着除了相交面的法线负方向我们不指望外,其他的方向我们还应该能够保留自由度。这样的需求使得三个原本逻辑分离的一维事件重新统一在立体空间了。
注意:我在引擎中加入了浅相交的滑行策略,这对于提高可玩性提供了极大的帮助。回想我们在玩一款游戏,我们会尝试四处跑动,并在撞上墙壁后还期望能够从碰撞的地方缓缓地改变朝向接着往更好的地方跑去。缺失的滑行策略将导致这样的问题:玩家撞上墙壁,在cpu中高速运转的碰撞系统反馈给对象控制器,不可以往前,因为在这个方向撞上了,不可以往左往右往上往下,理由一样,只能往后退了。
从原理上来讲,上述过于严肃的判断策略是正确的,但是我们还是需要滑行策略,关键在于我们并不想在物体撞上表面后给一个回退量,第一、需要额外的代码,但是目前的碰撞系统的代码简洁而高效。第二、假如玩家不幸处在两个碰撞盒之间,那么就会产生无休止的回退过程。第三、是突然的回退量还是缓慢供给的回退量,突然的回退量要给多少,往哪个矢量方向,陷入另一个碰撞盒怎么办,缓慢的回退量看似可行,但考虑这样一个问题:玩家碰上墙壁了,试图沿着墙壁缓慢斜行,然后绕开它,这个时候就会产生回退、碰撞、回退、碰撞的过程,如果相机此刻定位在玩家身上,那么整个画面就开始颤动了。如果游戏角色的跑动不是基于统一的地图平面而是基于地形系统的话,这个问题就会更让人难于接受了。角色时时刻刻都处在回退值的干扰之下而不得安宁。
另一个问题是梯度爬行策略。注意,关于碰撞检测和物理系统的大量用词都是我捏造的,因为我没有也不必去考察其它的实现方案,所以使用自己定义的名称并不奇怪。

 


图4-12、汽车试图沿着梯度上升,结果,它做到了!

 

不应该让玩家处于永恒不变的平面,梯度爬行策略是一个简单的地形跟踪模型,它需要满足以下条件:



1、A半高位超过B顶面
2、A在B的垂直面上处于滑行状态
3、A底部在B顶部以下
其中条件1可以指定一个期望的阀值,默认情况下为0.5,条件2对滑行状态的判定也可以指定小于0.5的阀值,默认情况下为0.2,太大的阀值看起来不真实(A陷入B太深了,根本动弹不得)。

 

碰撞检测过程分解:

①、A半高和B半高比较。
If(A>=B)
正方向反馈力。
If(A<B)
负方向反馈力。

 


②、A区间和B区间是否交叠
If(Min(A,B)的顶点位于Max(A,B)区间)
区间交叠
If(区间交叠)
使用①力反馈
Else
退出

 


④、A相对于两端点n分面和B的两端点的比较
If(A底分面在B顶端点之上||A顶分面在B底端点之下)
浅相交

 


⑤、滑行策略:浮动平面
If(浅相交在Y轴向)
浮动平面;消除X、Z轴向力反馈


⑥、滑动策略:边接触
If(浅相交在X、Z轴向)
边接触;消除非浅相交轴向力反馈

 



⑦、梯度爬行策略(梯度跳跃)
If(A半高大于B顶端点&&A对B在Y轴向边接触&&A底端点小于B顶端点)
梯度跳跃(信息传递给力反馈W分量)


⑧、Y滑行策略
If((!梯度跳跃)&&边接触)
Y滑行策略

 


⑨、浮动修正
If(A半高>B半高&&A底端点<B顶端点&&A顶端点>B顶端点&&(!边接触))
浮动修正(信息传递给力反馈W分量)

 


⑩、高度上升
If(梯度跳跃||浮动修正)
y++

六、全局顶点雾

 

图4-13、富于诗意的全局顶点雾世界
①顶点雾浓度



全局雾浓度等于z在fog.length区间的占位(256分辨率)
fog.length=(f-n)*fog.percnet
fog.alpha=max(min((z-fog.length)/fog.length,1),0)*0xFF

②雾化混合
Gouraud::fog=Alpha(fog.color,vertex.color,fog.alpha)
GouraudAlpha::fog=Alpha(vertex.color,screen.color,vertex.alpha)
       ->Gouraud::fog
Texture::fog=Mix(vertex.color,bmf.color)
     ->Gouraud::fog
TextureAlpha::fog=Modulate(vertex.color,bmf.color)
     ->GouraudAlpha::fog

③“贴图->雾化”和“雾化->贴图”的区别
雾化后贴图,贴图信息将不受雾化影响。
应该使用“贴图->雾化”过程

④“Alpha->雾化”和“雾化->Alpha”的区别
先雾化再Alpha将导致雾颜色透明化,若雾颜色和背景色一致,则没有问题。
Gouraud不处理贴图信息,可事先计算顶点雾颜色,为避免GouraudAlpha为了先处理Alpha而对雾浓度插值的开销,雾颜色和背景色将一致化。
TextureAlpha无论如何都要进行雾浓度插值,故应该给出合适的雾化顺序,即“Alpha->雾化”

⑤雾化和光照的区别
雾化使用Alpha混合,有限定域
光照使用逐一材质调制和求和,无限定域,最终渲染时被截断。

七、使用玩具车在趣味的场景中进行跳跃和跑动的演示程序
打开Release目录下的JumpCar.exe开始玩。

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/pizi0475/article/details/5504108

智能推荐

艾美捷Epigentek DNA样品的超声能量处理方案-程序员宅基地

文章浏览阅读15次。空化气泡的大小和相应的空化能量可以通过调整完全标度的振幅水平来操纵和数字控制。通过强调超声技术中的更高通量处理和防止样品污染,Epigentek EpiSonic超声仪可以轻松集成到现有的实验室工作流程中,并且特别适合与表观遗传学和下一代应用的兼容性。Epigentek的EpiSonic已成为一种有效的剪切设备,用于在染色质免疫沉淀技术中制备染色质样品,以及用于下一代测序平台的DNA文库制备。该装置的经济性及其多重样品的能力使其成为每个实验室拥有的经济高效的工具,而不仅仅是核心设施。

11、合宙Air模块Luat开发:通过http协议获取天气信息_合宙获取天气-程序员宅基地

文章浏览阅读4.2k次,点赞3次,收藏14次。目录点击这里查看所有博文  本系列博客,理论上适用于合宙的Air202、Air268、Air720x、Air720S以及最近发布的Air720U(我还没拿到样机,应该也能支持)。  先不管支不支持,如果你用的是合宙的模块,那都不妨一试,也许会有意外收获。  我使用的是Air720SL模块,如果在其他模块上不能用,那就是底层core固件暂时还没有支持,这里的代码是没有问题的。例程仅供参考!..._合宙获取天气

EasyMesh和802.11s对比-程序员宅基地

文章浏览阅读7.7k次,点赞2次,收藏41次。1 关于meshMesh的意思是网状物,以前读书的时候,在自动化领域有传感器自组网,zigbee、蓝牙等无线方式实现各个网络节点消息通信,通过各种算法,保证整个网络中所有节点信息能经过多跳最终传递到目的地,用于数据采集。十多年过去了,在无线路由器领域又把这个mesh概念翻炒了一下,各大品牌都推出了mesh路由器,大多数是3个为一组,实现在面积较大的住宅里,增强wifi覆盖范围,智能在多热点之间切换,提升上网体验。因为节点基本上在3个以内,所以mesh的算法不必太复杂,组网形式比较简单。各厂家都自定义了组_802.11s

线程的几种状态_线程状态-程序员宅基地

文章浏览阅读5.2k次,点赞8次,收藏21次。线程的几种状态_线程状态

stack的常见用法详解_stack函数用法-程序员宅基地

文章浏览阅读4.2w次,点赞124次,收藏688次。stack翻译为栈,是STL中实现的一个后进先出的容器。要使用 stack,应先添加头文件include<stack>,并在头文件下面加上“ using namespacestd;"1. stack的定义其定义的写法和其他STL容器相同, typename可以任意基本数据类型或容器:stack<typename> name;2. stack容器内元素的访问..._stack函数用法

2018.11.16javascript课上随笔(DOM)-程序员宅基地

文章浏览阅读71次。<li> <a href = "“#”>-</a></li><li>子节点:文本节点(回车),元素节点,文本节点。不同节点树:  节点(各种类型节点)childNodes:返回子节点的所有子节点的集合,包含任何类型、元素节点(元素类型节点):child。node.getAttribute(at...

随便推点

layui.extend的一点知识 第三方模块base 路径_layui extend-程序员宅基地

文章浏览阅读3.4k次。//config的设置是全局的layui.config({ base: '/res/js/' //假设这是你存放拓展模块的根目录}).extend({ //设定模块别名 mymod: 'mymod' //如果 mymod.js 是在根目录,也可以不用设定别名 ,mod1: 'admin/mod1' //相对于上述 base 目录的子目录}); //你也可以忽略 base 设定的根目录,直接在 extend 指定路径(主要:该功能为 layui 2.2.0 新增)layui.exten_layui extend

5G云计算:5G网络的分层思想_5g分层结构-程序员宅基地

文章浏览阅读3.2k次,点赞6次,收藏13次。分层思想分层思想分层思想-1分层思想-2分层思想-2OSI七层参考模型物理层和数据链路层物理层数据链路层网络层传输层会话层表示层应用层OSI七层模型的分层结构TCP/IP协议族的组成数据封装过程数据解封装过程PDU设备与层的对应关系各层通信分层思想分层思想-1在现实生活种,我们在喝牛奶时,未必了解他的生产过程,我们所接触的或许只是从超时购买牛奶。分层思想-2平时我们在网络时也未必知道数据的传输过程我们的所考虑的就是可以传就可以,不用管他时怎么传输的分层思想-2将复杂的流程分解为几个功能_5g分层结构

基于二值化图像转GCode的单向扫描实现-程序员宅基地

文章浏览阅读191次。在激光雕刻中,单向扫描(Unidirectional Scanning)是一种雕刻技术,其中激光头只在一个方向上移动,而不是来回移动。这种移动方式主要应用于通过激光逐行扫描图像表面的过程。具体而言,单向扫描的过程通常包括以下步骤:横向移动(X轴): 激光头沿X轴方向移动到图像的一侧。纵向移动(Y轴): 激光头沿Y轴方向开始逐行移动,刻蚀图像表面。这一过程是单向的,即在每一行上激光头只在一个方向上移动。返回横向移动: 一旦一行完成,激光头返回到图像的一侧,准备进行下一行的刻蚀。

算法随笔:强连通分量-程序员宅基地

文章浏览阅读577次。强连通:在有向图G中,如果两个点u和v是互相可达的,即从u出发可以到达v,从v出发也可以到达u,则成u和v是强连通的。强连通分量:如果一个有向图G不是强连通图,那么可以把它分成躲个子图,其中每个子图的内部是强连通的,而且这些子图已经扩展到最大,不能与子图外的任一点强连通,成这样的一个“极大连通”子图是G的一个强连通分量(SCC)。强连通分量的一些性质:(1)一个点必须有出度和入度,才会与其他点强连通。(2)把一个SCC从图中挖掉,不影响其他点的强连通性。_强连通分量

Django(2)|templates模板+静态资源目录static_django templates-程序员宅基地

文章浏览阅读3.9k次,点赞5次,收藏18次。在做web开发,要给用户提供一个页面,页面包括静态页面+数据,两者结合起来就是完整的可视化的页面,django的模板系统支持这种功能,首先需要写一个静态页面,然后通过python的模板语法将数据渲染上去。1.创建一个templates目录2.配置。_django templates

linux下的GPU测试软件,Ubuntu等Linux系统显卡性能测试软件 Unigine 3D-程序员宅基地

文章浏览阅读1.7k次。Ubuntu等Linux系统显卡性能测试软件 Unigine 3DUbuntu Intel显卡驱动安装,请参考:ATI和NVIDIA显卡请在软件和更新中的附加驱动中安装。 这里推荐: 运行后,F9就可评分,已测试显卡有K2000 2GB 900+分,GT330m 1GB 340+ 分,GT620 1GB 340+ 分,四代i5核显340+ 分,还有写博客的小盒子100+ 分。relaybot@re...

推荐文章

热门文章

相关标签