自制软3D渲染程序 之一 3D起草程序

自制软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.接下来

接下来将会看到:如何使用投影逆变换将标准视域体的任意点还原到相机坐标,

以及如何依赖这一特性,在三角形渲染中,获取材质贴图颜色。

  • 6
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值