自制软3D渲染程序
0.介绍
很久之前就开始写CPU 3D渲染程序了。一开始的打算是使用EGE(Easy Graphics Engine)或者EasyX,
因为接触比较多,并且也使用这两个绘图工具做了一个斜45度伪3D游戏引擎( 自制45度2D引擎之坐标转换),
这个虽然是网页版本的(Github),但是后来抽空将它移植到了EGE上,点击这儿Github。
EasyX版本暂时还没有做,因为EasyX和EGE在IMAGE的内存操作、以及图片读取和缩放上有很大不同。
有了这些经验,可以说做3D是顺其自然,得心应手。首先看一下成果,点这儿Github或者Gitee。
制作过程持续了3-4年,期间断断续续,忙于工作和找工作(笑)。目前还在不断完善中。
最后的效果不是很理想,虽然我做了很多优化工作,但是最后在三角形面数过多的时候会比较卡。
主要原因还是没有使用GPU,纯CPU渲染,因此在性能上肯定不如当前流行的Vulkan、OpenGL或者DirectX。
虽然如此,但是对于我这个初学者来说,能通过编写和使用这个程序来了解3D的世界,也还是不错的。
谨以此文献给出入3D之门初学者。
目前正在研究CUDA,打算将数据结构移植到CUDA上。如果成功,想测试一下性能怎么样。
可能往CUDA上移植的时候,因为GPU架构的关系,数据结构会有很大改变。
但是现在只介绍一下CPU下面的数据结构,以及随之衍生出来的一些效果。
1.数据结构
具体的数据结构还是沿用之前自己写的一个双向循环型多链接链表。
双向循环链表是最完备的链表。而我在此基础上加上了多连接。
也就是它不仅是一条链表,而是有可能成为多条链表,而不用再去申请多余的内存。
这个数据结构在自己写操作系统(一步步写操作系统)上应用与内存管理、任务管理,并且测试没有任何问题。
该数据结构一共四个版本:
Javascript版本:这个忘记说了,是最早的版本,使用js的prototype来进行类管理。应用在网页版斜45度伪3D游戏引擎。
纯C版本:主要用于操作系统等没有基础库的地方。
C++版本:使用类的构造函数和析构函数,简化了很多操作,但是不适合底层。使用在斜45度伪3D游戏引擎中。
Java版本:这是从网页版斜45度伪3D游戏引擎移植到Android版本时,对Javascript版本的数据结构进行的移植。(Github)
另外一提,纯C版本的也被应用到我的另一个项目,倒叙词典里面(Github),因为使用JNI所以使用纯C而不是使用基础库。
支持>200000词汇的载入,时间大概为30秒。
在本3D渲染程序中也继续使用这个数据结构。
这个数据结构很原理很简单,就是在原来的链表里面,将prev和next扩展成了prev[]和next[],
然后外面套一层管理器,管理器里面在构造的时候指定了使用的下标,对元素进行遍历、增删时
使用对应的下标对prev[]和next[]进行操作。
上图每一条线代表一个管理器管理的prev和next链接,指定下标为0的管理器只对prev[0]和next[0]进行操作。
对于3D程序来说,有很多点,三角形物体,都可以使用这个结构进行管理和渲染。使用下标0进行管理,
使用下标1创建另一个管理器,在渲染时,可以对所有三角形进行裁剪、背面消隐等,
这时管理链接就会跳过被忽略的三角形(如图中弯曲的线所示),而不会对下标0的管理器有任何影响。
并且从我的后面的一些代码编写中还发现,多链接链表很适合在多线程下进行操作。
如果要多个线程对所有物体进行操作,那么将线程id作为下标创建管理器,就可以互不影响,是不是很方便?
有了管理器结构,后面就省事了,我按照从小到大、由基本到具体的顺序介绍其他数据结构。
可以参考我的3D渲染起草项目,这里面只有基础数据结构,和一个简单的渲染器, 点这里Gitee。
2.点
点是最基础的数据结构。除了基础的+-*/运算符重载外,还需要一些其他函数,比如
自身角度旋转、绕点旋转、归一化、是否在矩形内等等函数,这些函数都比较基本,不再赘述。
3.顶点
顶点的基本类型就是含有(x, y, z, w)的四元矩阵。为了简化编码书写,需要定义一些基本运算符重载函数。
class Vert3D{
DOUBLE x, y, z, w;
}
之所以要使用DOUBLE类型是因为FLOAT精度不足,到后期会看到光线追踪渲染时噪点会比较多。您可以定义宏DOUBLE。
为了支持向量操作,一些操作符已经失去了原来的含义,分别定义如下:
*操作: 为向量叉乘,用于计算面法线。所得结果是一个顶点,但解释为原点在(0, 0, 0, 0)处的向量,
方向使用右手法则判定。一般计算了面法线以后,需要使用normalize()对其长度进行归一化。
^操作:为向量点乘,结果为a*b=|a||b|cos<a,b>,其结果因为包含cos<a,b>,所以可以用于在Phong模型中
使用法线和光线夹角计算光照系数,但一般不用求解出具体的<a,b>角度值。
&操作:同^操作,不过结果为cos<a,b>,即出去了|a||b|部分,当需要使用准确的cos值时使用。
但因为有除法运算,效率大大降低。计算Phone模型的照度系数时,一般使用该函数,而不用含有干扰参数
|a||b|的^操作。
另外一些运算符重载涉及到矩阵。
4.矩阵
设有一个顶点v,做一个变换,就是乘以一个矩阵M,
如图,令M第一列为mx,第二列为my,第三列为mz,第四列为mw,则顶点变换后就是
| x*mx.x + y*mx.y+ z*mx.z + w*mx.w |T
| x*my.x+ y*my.y + z *my.z+ w*my.w |
| x*mz.x + y*mz.y+ z*mz.z + w*mz.w |
| x*mw.x + y*mx.y+ z*mw.z + w*mw.w |
因为矩阵乘积结果是一维横向矩阵,为了便于书写为竖向,所以加上了"T"代表转置。
这样,就可以将数据结构分解成这样的形式:
class Mat {
DOUBLE x, y, z, w;
};
class Mat3D {
MAT mx, my, mz, mw;
}
另外,我们已知有矩阵M=[mx, my, mz, mw]T和顶点V=[x, y, z, w]T,以及另一些变换矩阵,
如M1,M2...。我们知道顶点V为顶点的初始坐标,这个坐标是以(0, 0, 0, 0)为原点的原点坐标,
假设M为顶点V的世界坐标变换,Mn为顶点V的后续变换(比如要将V变换到相机坐标或者
逆变换回世界坐标或原点坐标),则有
M*M1*M2*V = (M*M1*M2)*V
加个括号有设么用?对了,可以将矩阵合并,令MM=M*M1*M2,那么上述变换可以写成
M*M1*M2*V =MM*V
不过如果同时又平移和缩放以及旋转变换,根据想象您也应该能猜到,应该先进行旋转或缩放,
然后最后进行平移,否则先平移,再按照原点来旋转就成了将对象绕圆周运动,而不是单纯的转向动作。
那么根据上述MM*V又可以得到什么?对了,就是优化。
通过计算出MM并保存起来,以后只要没有涉及到矩阵变化,都可以直接使用MM*V来得到变换后的坐标。
另外,矩阵操作无外乎旋转、缩放、平移,令旋转矩阵为Mr,缩放矩阵为Ms,平移矩阵为Mm,
则对于一个已经进行MM变换的顶点V来说,再进行旋转缩放平移变换,就是在其MM上左乘旋转缩放矩阵
或者右乘平移矩阵,即:
Ms*Mr*MM*Mm = MM'
对于这个新矩阵,可以直接替代原来的MM矩阵,直接使用MM*V来得到变换后的坐标。
矩阵的运算符重载,一般只希望在Mat3D上操作,而不是Mat上,Mat上只有赋值=和+-,
以及*一个浮点数的操作。
Mat3D上的运算符重载:
*操作:即上面说到的矩阵乘法。矩阵乘法比较复杂,对于矩阵
M=[mx, my, mz, mw]和M1=[mx1, my1, mz1, mw1]有:
+-*操作:对mx、my、mz、mw分别进行+-*操作,其中*操作只针对一个浮点数,如果小于1则实际为除操作。
另外一些线性代数上常用的操作比如转置、单位对角矩阵化等操作,
现在我可以向您打包票,这些都不需要,看,是不是简单?
对于顶点,有了矩阵以后当然需要增加矩阵操作,上面的操作是针对两个矩阵之间的,
下面则是对于顶点数据结构Vert3D的:
*操作:对顶点进行矩阵变换的操作。参照4.矩阵一开始给出的图片。
最后,在整个引擎的设计中,我们需要将上述操作(缩放、渲染、平移)保存起来,并针对具体操作进行更新,
同时保存和更新其逆变换,因此产生了Matrix3D这个类。
这个类里面包含多个Mat3D,分别为:
M/Mm/Ms/Mrx/Mry/Mrz:正变换操作矩阵,其中M为所有变换的乘积,而Ms为缩放变换,Mr为绕三个轴方向的旋转变换,Mm为平移变换。
M_1/Mm_1/Ms_1/Mrx_1/Mry_1/Mrz_1:逆变换操作矩阵,记录上述所有操作的逆变换,当需要对变换后的物体变换回来取其材质坐标时,就可以直接使用。
当进行任意操作时,首先对Ms/Mrx/Mry/Mrz/Mm进行计算,然后对Ms_1/Mrx_1/Mry_1/Mrz_1/Mm_1
进行相应的逆变换计算,最后刷新全变换矩阵M和全逆变换矩阵M_1。
之所以要把Mr分解为Mrx、Mry和Mrz是因为考虑到旋转轴的问题。前面也提到,旋转以后才进行平移是因为,
不这样的话,平移之后,再按轴旋转是圆周旋转而不是自身旋转。对于旋转自身也是一样,
绕xyz轴哪个先哪个后会影响到最终的效果,所以将他们分开,以便之后进行设置。
5.物体
在3D引擎中,一般是由一系列的顶点构成三角形,然后一些列三角形构成一个物体。
这种三角形有各种不同的构成方法。本引擎中使用了一下三种构成:
三种不同的三角形构成,加入的顶点为红色,遍历方式为下面的->号。圆圈箭头代表点的顺序,
可以根据右手法则找到法线的方向。
第一种,Triangle Loop,这是不在任何现有流行的3D引擎里面使用的,我称之为懒惰模式,
也就是简单按照加入的顶点的顺序首尾相接进行遍历,但第二个[2 3 4]进行遍历时,法线相反,
如果进行了背面消隐,则看不到这个三角形,因此需要在加入顶点的时候指定法线是否反向,
所以途中圆圈箭头旁边出现了一个-1。也就是说,在编写模型顶点时,需要考虑法线方向是否一致,
如果不一致,需要明确法线反向。所以我称之为懒惰模式。
第二种,Triangle Strip,这是和现有3D引擎里面一致的,就是按照奇正偶反的规则计算遍历顶点的。
对于当前遍历的第n个点,
如果n为偶数,则使用第[n-1 n-2 n]个顶点构成三角形,比如n=4,则使用[3 2 4]构成三角形,称为偶反。
如果n为奇数,则使用第[n-2 n-1 n]个顶点构成三角形,比如n=5,则使用[3 4 5]构成三角形,称为奇正。
第三种,Triangle。这是不管重复点,只管加入顶点即可。如图红色字体中234都会被重复加入三角形中。
但是因为指定了确定的三个顶点作为三角形,所以法线固定,因此也不存在遍历顺序的问题。
在一些导出文件如3ds或者obj文件中,所有的模型顶点都是按照Triangle的模式来进行导出的,
所以读取解析它们也需要使用Triangle模式进行顶点加入。
6.链表
前面提到使用双循环多链接链表,但是文至此还没有出现过这个相关。现在就要正式加入链表了。
要对定点进行管理,首先定义一个类来作为链表的元素:
class VObj {
VOBJ() {
initialize();
}
// for multilinklist
#define MAX_VOBJ_LINK 4
void initialize() {
for (INT i = 0; i < MAX_VOBJ_LINK; i++)
{
this->prev[i] = NULL;
this->next[i] = NULL;
}
}
INT uniqueID;
VObj * prev[MAX_VOBJ_LINK];
VObj * next[MAX_VOBJ_LINK];
void operator delete(void * _ptr){
if (_ptr == NULL)
{
return;
}
for (INT i = 0; i < MAX_VOBJ_LINK; i++)
{
if (((VObj*)_ptr)->prev[i] != NULL || ((VObj*)_ptr)->next[i] != NULL)
{
return;
}
}
delete(_ptr);
}
}
这个是作为多链接链表元素的必要结构,包含构造、初始化和删除。对于底层如何操作,
感兴趣的可以从这里下载源码Github。
之后所有的多链接链表结构都使用这些元素,比如之后的灯光、相机,当然也包括物体
本身也是多链接链表结构,用于更上层管理。链表元素添加完成以后,就可以随意添加数据元素了,
不用去管链表怎么使用和操作,很是方便。因为是使用的C++版本,所以对于VObj来说,只要继承
自Vert3D就可以拥有顶点的所有数据。
我们想要对定点进行更高效和优化的管理,最好是对顶点的一些坐标进行缓存,而不是按照上面说的方式,
每次需要某个变换的坐标都进行一次矩阵运算。最好的方式是,有用户操作时,如果有物体的操作,
则对每个顶点的矩阵进行变换,然后通过变换计算出世界坐标、相机坐标、法线坐标,
甚至其他一些坐标,比如AABB包围盒坐标,并将这些存起来。
当没有物体操作时,比如只是转动摄像机,则只计算物体的相机坐标并更新保存。
当顶点链表元素定义好以后,就可以定义物体链表元素了。
物体管理者顶点,所以需要有几个多链接链表管理器,就是
MultiLinkList<VObj> verts;
MultiLinkList<VObj> verts_r;
MultiLinkList<VObj> verts_f;
其中verts保存所有的顶点,verts_r保存当前的渲染顶点,verts_f保存所有的反射顶点。
渲染顶点在每次用户操作以后,都对每个顶点进行变换,变换过程中,通过相机的投影函数,可以得到
顶点是否剪切,而在后续变换中,可以得到顶点的法线,并且根据法线和相机的位置关系标记是否背面消隐。
如果上述都完成,则在渲染顶点中,因此将它键入到verts_r中。
反射顶点为什么要单独拿出来,因为反射不能在正常位置上去做,而是要将摄像机切换到反射面的镜像
的摄像机位置,对所有反射顶点做变换,然后绘制反射顶点,最后回到原来的摄像机位置。
如图,右下角的摄像机在视域范围内是看不到球背面的红色点的,但是镜面反射时,将摄像机移动到其
镜像位置,然后就能观察到红色点,并且到点的距离和原摄像机到镜面再到点的距离是一致的,所以虚拟
摄像机渲染的图像就是镜面反射的图像。对于反射物体,上面的每个三角形法线可能各不一样,因此就
需要将反射顶点单独拿出来对每个三角形进行这种变换,渲染三角形上面的图像,最后合成到最终渲染图中。
这种方法因为对每个三角形都涉及到坐标变换,所以实际做出来效果是,对于只含两个三角形的平面,可以
实时渲染,对于多个三角形的平面,效率降低了几个档次。然而对于含多个三角形的球面来说,帧率小于1,
不能做到实时渲染。但是总体来说,效果已经出来了。
另外有一个特别重要的函数,就是渲染函数。虽然不能叫渲染,因为通过这个函数并不能把物体直接显示到
屏幕上,只是按照上面说的,做一些内部的处理,比如变换、保存等。
渲染函数首先利用自身的变换矩阵M和相机的变换矩阵cam->M计算出物体的变换矩阵CM,
然后对每个顶点做V*CM的变换,变换到相机坐标,然后通过相机的参数进行裁剪,并通过相机的投影矩阵
进行投影变换。
7.相机
在了解相机之前,首先要确保了解了矩阵运算。如果没有问题,那么可以往下继续阅读。
前面说到相机和灯光以及物体都是链表元素,因此相机也有链表元素应有的成员数据结构,这里不再赘述。
主要说一下相机的投影矩阵。这里涉及到的投影都是透视投影,而不涉及正交投影。
这里也使用上面说到的方法,记录一个投影矩阵和投影逆矩阵。投影矩阵用于将相机坐标投影,
而投影逆矩阵用于将投影逆变换到相机坐标。在网络上都可以找到投影矩阵的公式,但是投影逆矩阵并没有
涉及到。本文将做一个投影逆矩阵的推导。
投影矩阵:
如图,推导不再赘述。下式是在投影在原点中心对称的情况下的化简。
投影逆矩阵:
首先添加单位对角矩阵,进行逆矩阵求解:
第一步,每行分别除以2n/w、2n/h、-(f+n)/(f-n)
第二步,对第4列和第8列进行交换
第三步,第3列加上第4列
那么可以得到如图最下右边四列就是逆矩阵。
在进行投影时,首先将顶点V=[x y z w]和投影矩阵相乘。另外,标准3D解决方案中还有一个叫做
规范视域体的概念,也就是从(-1, -1, 0)到(1, 1, 1)的盒子。如果所有的物体都投影到这个盒子中,
则由于z轴是从0~1的,所以很方便的就可以对z进行深度测试。因此将投影后的坐标全部除以z得到
Vp=[xp/zp, yp/zp, 1, wp/zp]。
那xy可能超出(-1,-1)到(1,1)吗?标准的3D中是有一个裁剪的,就是对于超过视椎体的三角形部分进行剪切。
但是我做的只做一些简单的操作,将超过的xyz坐标强制改变到far-near和width和height中。
但无论用裁剪还是强制改变,都可以确保投影以后除以z落在规范视域体内。
最后需要做的就是在规范视域体坐标中,对x和y进行遍历,如果物体落在这里,那么在屏幕上渲染出物体。
8.灯光
前面已经提到,在计算好物体的变换后,所有的参数都是保存在物体以及物体上每一个顶点的数据结构上的。
因此,在渲染时,对渲染顶点进行遍历的循环中,不仅可以得到其在标准视域体中的投影,进行渲染,
还可以得到顶点在世界坐标下的顶点坐标和线坐标,此时如果添加光线处理,可以说是非常容易的。
光线处理使用Phong模型,即
I=KaIa + KdId(N*L) + KsIs(R*V)^2
其中a表示环境光,d表示漫反射,s表示镜面反射
Ka表示环境光系数,Ia表示全局照明光颜色
Kd表示漫反射系数,Id表示漫反射颜色,N就是上面说的顶点的法线,而L则是光线,也就是连接
光源点和顶点的的向量。
Ks表示镜面反射系数,V表示顶点法线,R表示镜面反射,其方程为
2*(L*N)*N-L
因此,如果对于拥有固定颜色的顶点可以通过光线处理,得到光线系数
f=ka + Kd(N*L) + Ks(R*V)^2
然后用这个系数和颜色进行乘积处理,就能得到渲染像素的颜色值。
9.颜色合成
颜色合成分为颜色叠加和颜色系数乘。
颜色叠加:通过rgb和RGB,以及混色系数s进行颜色混合叠加
r' = max(min(r * s + R *(1-s), 255), 0);
g' = max(min(g*s + G*(1-s), 255), 0);
b' = max(min(b*s + B*(1-s), 255), 0);
颜色系数乘:通过系数s对rgb进行进行加深或减淡处理。适合于阴影。
r' = max(min(r *s, 255), 0);
g' = max(min(g * s, 255), 0);
b' = max(min(b * s, 255), 0);
10.屏幕显示
本来是要在EGE/EasyX上做的,但为了图简单和通用,先在WindowsGDI上起草。因此,对于显示
库的平台支撑性要足够好。这里使用了数组进行缓存。分别定义了几个DWORD的数组,大小为
当前窗口的几个大小,用于保存渲染的结果。然后通过这个矩阵,对不同的库平台进行绘制。
11.效果图
12.接下来
接下来将会看到:如何使用投影逆变换将标准视域体的任意点还原到相机坐标,
以及如何依赖这一特性,在三角形渲染中,获取材质贴图颜色。