Software Renderer

正文


过年把以前写的那个自娱自乐的破烂Software Renderer又写了一遍,加了一些新的Features。代码依旧稀烂,以至于拿出去给人看都嫌丢脸——虽然差不多算是明了设计模式到底是个什么玩意儿,但这跟能写出好的代码完全是两码事情——不过好在是代码虽然是一坨,但还是能跑起来的。

这里对比一下,姑且称之为版本一和版本二吧。

数学

两次的数学运算基本一致,版本二由于添加了近裁剪面裁剪,因此增加了对三角形进行切割的方法。

数学的内容基本都在前面图形相关的文里面谈到了,当然碰撞检测以及物理相关都没有涉及(较早的时候有参考过《Real Time Collision Detection》写过一些碰撞检测的代码,但时间太久,换电脑把代码倒腾丢了,具体内容也忘得差不多了,基本上就是OBB,AABB,Capsule,Sphere,Plane,Triangle,Ray,Segment之间的各种关系判定,求解特定交点等等)。

两个版本都不包含四元数相关运算。

总览

我们先按下光栅化部分关于Z-Buffer的话题,把它稍微放后一些。

版本一是真的写死了,只能贴一张纹理,也不支持光照,说白了整个Renderer就是靠一个光栅化函数撑起来的(drawTriangleWithTexture),当然不使用纹理也可以。不包含三角形裁剪与剔除,没有对每次提交的三角形列表进行任何修改,将三角形的顶点信息放进绘制列表之后直接就进行World->View->Perspective->ViewPort变换,然后交给光栅化函数进行绘制处理。由于不裁剪,因此穿过nearZ的坐标会被错误投影,必须保证要绘制的物体的顶点坐标都在nearZ之前。

版本二比版本稍微强了一丁点,但是还不够好。第二个版本加了近裁剪平面裁剪的功能,穿过近裁剪平面的三角形会被裁剪,位于裁剪面前面的三角形会传递到后续的三角形列表中,这里使用了两个vector来完成这个工作,但不好,一个Queue就行。裁剪是在View-Space中进行的,意味着必须先将World-Sapce中的顶点进行一次View-Space变换;光照则是直接在World-Space中进行,计算出来光对顶点颜色产生的影响,添加到顶点颜色中(只是用了Diffuse,也就是Normal与LightDir的夹角的cos值,整个过程属于Vertex光照而非Phong光照);裁剪后的三角形经过投影和视口变换,最后被光栅化。

版本一的顶点缓冲处理只有一种方式,钉死的:

1
2
3
4
5
6
struct Vertex
{
float x,y,z;
DWORD color;
float u,v;
};

版本二则稍微灵活了一点,模仿了GL,用了VertexArray、NormalArray、ColorArray、TexCoordArray*2来进行处理,至于过程,咱这写得自然比不上GL。但说老实话,现在觉得这不算是个好方案:至少我写的代码里面,每个绘制批次提交的数据最少两块(即便只有VertexArray的提交,但最终后台还是给加了个ColorArray,这个确实不好),多则四块,数据的局部性不能保证,而且不同属性的Array的Stride也不一样,写代码的时候一不注意就给了Bug……

下次准备用VertexDeclaration,如果还写的话。

从前面的描述中也能看出来版本二增加了一个Texture纹理,这样就可以对一个Surface使用两个2D纹理了,纹理之间的颜色运算是写死的Add,没有使用其他的。

纹理采样没有什么好说的,版本一采用的是POINT和LINEAR。版本二是采用POINT 和 0.2 * (LEFT + RIGHT + UP + BOT) + 0.8 * POINT,这么写的理由……想到了拉普拉斯算子,算个理由么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
关于POINT 与 LINEAR:

例如算出来实际的u = 78.4, v = 30.8

那么最近的点POINT就是mc = [78, 31],

取包含POINT的四个点mc0 = [78, 30], mc1 = [79, 31],mc2 = [79, 30],

rc1 = InterpColor(mc, mc1, 0.4);
rc2 = InterpColor(mc0, mc2, 0.4);

双线性插值的结果:final = InterpColor(rc1, rc2, 0.2);

追加:其实双线性过滤的百度百科讲得蛮好 :)

上几张图:

这是两个纹理叠加的表面,可以看到纹理投影是正确的。
SR-T2

上面的结果是下面两张纹理叠加的:

SR-T2.1
SR-T2.2

被近裁剪面裁剪之后的表面线框:

ClippedFrame

方向光照,设置的不好,结果看起来像是FLAT模式的光照……

DirLighting

Alpha混合没做,实际上知道怎么混合不同的纹理,也就知道怎么处理Alpha混合了。

1/Z-Buffer深度缓冲,基于Pixel的画家算法:

Z-Buffer

还有后续的小结

不满意,瑕疵很多,是不能投入使用的一个Renderer:比如图中三角形相接部分的黑线,实际上是没有填充入颜色,原因应该在于颜色区块的填充没有设置好,导致Float->Int的时候有的大一点,有的小一点,不连贯;比如当表面充满整个视野的时候,视口右下角会有一部分无法得到光栅化结果…两个版本多多少少都有一点这个问题;比如3D坐标齐次化并没有得到很有效的处理;再比如光照效果,从上面就能看出来没有能注意到Ambient的影响;然后就是效率有点惨……

哎,这个轮子造的甚是……

后面把重心放到Shader上面吧,终归是要跟毕业课题挂钩的……

深度缓冲、透视纹理与1/Z

其实基于Pixel的画家算法就是对每一个Pixel都保存一个深度值,每次会执行的像素的时候是对比新像素的深度值与旧像素的深度值,如果通过比较,则可以绘制新像素,否则不会绘制新像素。对于Z缓存来讲,深度值小的在前面,需要绘制,对于1/Z缓存来讲,则是相反的。

主要的问题是,光栅化插值是在2D空间中进行的线性插值,得到的深度数据并不符合3D中的实际情况,观察下图:

1/Z

稍微有点乱……

在投影平面上的线性插值是以Δx和Δy为基准的,图中可以看到,虽然沿着X进行了线性插值,但是反应到3D空间中的Z坐标上却并非是线性的结果(红色的点),实际的正确结果在投影平面的的插值如绿色点和线条所示,在X方向上并非线性的。

前面的文章中我们有提到过投影矩阵的生成过程,

1
2
X’ = (d*X0)/Z0
Y’ = (d*Y0)/Z0

并且也知道线性插值是根据X’和Y’的Δx和Δy进行的,也就是说,屏幕插值是受到1/Z的影响的,只有在包含1/Z的时候线性插值才是正确的。

事实上根本不用重新计算Z值,直接使用1/Z就可以了。

1/Z的影响对于越接近于平行投影平面的表面影响越小,反之越大,这个情况只需要调整一下上图中的红色线段的位置就知道了(如果是垂直于Z轴的话,确实是一点影响都没有的)。

理解了1/Z的作用之后,透视纹理也就不是一个大问题了:透视纹理的纹理坐标其实可以视作纹理在3D空间中的坐标位置——Z坐标一样,在屏幕坐标系中的插值计算同样受到1/Z的影响,因此需要对u/z和v/z进行线性插值,最后再将结果换算为正确的uv即可,当然在换算的过程中免不了使用到同步插值得到的1/Z。

版本一中是在投影后,由[0~1.0]的Z重新计算了实际的Z,进而得到1/Z的,版本而则是在投影之前保存了实际的Z,省去了一些步骤。

突然想到由于自己对于Shader并不是很熟练,以至于忽略的一个信息:在使用Shader来绘制深度信息到RTT的时候,大部分时间是在Pixel Shader中把插值得到的Z写入RTT的,这个时候需要注意一下写入的是实际的Z还是位于[0,1.0]的Z还是1/Z,这对重新计算实际的Z值有很大影响。