How to write a simple software rasterizer
Posted on 2013-03-16 00:14 hustruan 阅读(5150) 评论(4) 编辑 收藏 举报How to write a simple software rasterizer
Why to write a software rasterizer
众所周知,已经存在硬件加速的光栅化渲染器,如OpenGL, Direct3D。一些专业书如红宝书《OpenGL编程宝典》也介绍了如何使用OpenGL的渲染管线。但自己写软件光栅化渲染器,不仅可以增加对渲染管线的理解,还能掌握一些书上没讲到的知识,一些有OpenGL编程经验的人可能也不知道的,比如Perspective-Correct Interpolation等。这次要不是做图形学课程的大作业,也不知道什么时候我会去实现个这东西。
Features of our software rasterizer
主要模拟一下整个渲染管线,主要包括 Vertex shading, Primitive Assembly, Perspective transformation, Rasterization, Fragment shading等,这些是最基本的,做完这些至少能简单的渲染一张Image出来了。现在写software rasterizer没有必要再写个类似OpenGL的fixed pipeline,直接写个programmalbe pipeline反正更加简单。利用面向对象语言里的继承、多态,完全可以让用户自己写各种Shader代码。当然还可以有更高级的做法,如空明流转的SALVIA,完全可以自己首先整个Shader编译器,定义一套类似GLSL, HLSL的shader语言。反正目前我是没那能耐去折腾编译器,所以就实现了个把shader写死在C++代码里的版本。
How to write
1. Vertex Process
Vertex shading就不多说了,写过shader的都知道干嘛的。既然要实现一个programmable pipeline,那么至少也得知道shader写在哪,怎么写。不像GLSL、HLSL,Shader代码写个单独的文件里。最简单的方法就是利用C++的继承和多态,定义一套shader的基类,以后要写新的shader时,继承一下就好了。这种方法的缺点就是Shader被写死在C++里了,当然你也可以用Shader做成dll,动态导入。
Base Class:
class VertexShader : public Shader { public: VertexShader(); virtual ~VertexShader(); virtual void Execute(const VS_Input* input, VS_Output* output) = 0; }; class PixelShader : public Shader { public: PixelShader(); virtual ~PixelShader(); /** * return false if discard current pixel */ virtual bool Execute(const VS_Output* input, PS_Output* output, float* pDepthIO) = 0; };
Derived Class
class SimpleVertexShader : public VertexShader { public: void Bind() { DeclareVarying(InterpolationModifier::Linear, float4, oPosW, 0); DeclareVarying(InterpolationModifier::Linear, float4, oNormal, 1); DeclareVarying(InterpolationModifier::Linear, float2, oTex, 2); } void Execute(const VS_Input* input, VS_Output* output) { DefineAttribute(float4, iPos, 0); DefineAttribute(float4, iNormal, 1); DefineAttribute(float2, iTex, 2); DefineVaryingOutput(float4, oPosW, 0); DefineVaryingOutput(float4, oNormal, 1); DefineVaryingOutput(float2, oTex, 2); oPosW = iPos * World; oNormal = float4(iNormal.X(), iNormal.Y(), iNormal.Z(), 0.0) * World; oTex = iTex; output->Position = oPosW * View * Projection; } uint32_t GetOutputCount() const { return 4; } public: float44 World; float44 View; float44 Projection; };
上面只是个简单的示例,可以看到Shader参数现在可以直接简单地定义为成员变量,使用起来相当方便。
2 Primitive Assembly
Vertex Process相当于做了顶点变换,以及保存一些varying变量,插值后给Fragment Shader使用。Vertex Shader输出的顶点定义在Clip Space,我们可以一次性把View Frustum的上下左右前后6个面都做Clip,也可以只做近平面和远平面,后面扫描线的时候,还可以在屏幕空间裁减。根据《3D游戏编程大师技巧》的说法,后面屏幕空间的裁剪可能速度更快,所以我的实现也只裁剪了近、远平面。但是,近平面是一定要Clip的,原因看这篇文章,http://www.altdevblogaday.com/2012/04/14/software-rasterizer-part-1/,可能需要FQ。裁剪完了做perspective divide,之后便是viewpot transform。这里很重要的一点就是perspective correct interpolation。我不介绍,大致看这篇文章吧,http://www.cnblogs.com/ArenAK/archive/2008/03/13/1103532.html。所以要把1/z给保存下来,所有的Vertex Shader输出,varying变量都要乘上1/z。但是经过Vertex Shader后,顶点已经在Clip Space,怎么获得Z值呢,这个根据Projection矩阵自己倒推一下就可以了。Primitive Assembly就是把一个个顶点组成三角形光栅化。
3 Rasterization
光栅化这里可大有文章,光栅化主要干的事就是确定哪些像素块属于三角形内部。这里有两点要先介绍一下,Top-Left填充规则和顶点属性的插值计算方法。Top-Left填充规则大致看这篇文章,http://blog.csdn.net/damenhanter/article/details/6388934,讲得比较详细。顶点属性的插值,主要是利用三角形Barycentric coordinate,参考《Fundamentals of Computer Graphics》第三版,2.7节。可以自己推公式,大致就是选择三个顶点中的一个点作为base点,计算沿着两条边的Difference,写成ddx, ddy的形式,插值时,只要计算相对于base点的offsetX, offsetY,根据ddx, ddy就能计算插值后的值。关于BarryCentric Coordinate插值,还可以参考这篇Gamedev的文章,http://www.gamedev.net/topic/457998-software-rendering---vertex-attribute-interpolation/。
光栅化主要介绍两种方法,经典的扫描线Scanline算法和Tiled-based的算法。扫描线算法比较经典,就是把一个三角形分成平底三角形和平顶三角形,按行扫描就好了,可以参考《3D游戏编程大师技巧》第九章。Tiled-based的算法参考Advanced Rasterization。我不多做介绍,这两篇文章都介绍的很详细了。另外在提供两篇文章,Rasterization on Larrabee 和 A modern approach to software rasterization。前面一篇是Intel Larrabee架构下的一个Rasterizer,空明流转的SALVIA好像就是用的这个算法。后面一篇我觉得也不错,实现起来也相对简单。另外还有一篇GPU上cuda实现的,我觉得应该是最高效的方法,可以参考论文“High-Performance Software Rasterization on GPUs”。扫描线算法主要不太适合多线程,主要是后面Fragment Shading的时候,肯定会有好几个线程同时更新同一个像素backbuffer的问题,但gameKnife大神的多线程版扫描线效率很高,我也不知道他具体是怎么做的。而Tiled-based的算法则对多线程比较友好,不过我简单的实现了一个Tiled-based的half-space算法,好像也没快到哪里去。主要感觉虽然多线程了,但每个CPU core的使用率不是很高,可能Cahce没用好。第一次写多线程的程序,没什么经验。
4 Fragment Shading
Fragmene Shader也可以模仿上面Vertex Shader的方法,通过继承实现。
class SimplePixelShader : public PixelShader { public: float3 LightPos; DefineTexture(0, DiffuseTex); DefineSampler(0, LinearSampler); bool Execute(const VS_Output* input, PS_Output* output, float* pDepthIO) { DefineVaryingInput(float3, iPosW, 0); DefineVaryingInput(float3, iNormal, 1); DefineVaryingInput(float2, iTex, 2); float3 L = Normalize(LightPos - iPosW); float3 N = Normalize(iNormal); float NdotL = Dot(N, L); ColorRGBA diffuse = Sample(DiffuseTex, LinearSampler, iTex.X(), iTex.Y()); output->Color[0] = Saturate(diffuse * NdotL); return true; } uint32_t GetOutputCount() const { return 1; } };
跑Fragment Shader前,先对当前像素用插值方法计算Vertex Shader过来的顶点属性,插值的方法上面介绍了,计算挺方便的。Fragment Shader这块最大的问题是,很难计算屏幕空间的导数,这个在做mipmap的时候需要用到。具体可以参考空明流转的《开源光栅化渲染器SALVIA的漫长五年(准·干货)》。我觉得要做这个的话,就应该像空明流转说的,用硬件的方式来执行,一次让2x2的像素块一起执行,所以如果还是使用用C++写shader的话,肯定不能再像上面一样,一个Execute函数,让一个fragment执行,必须要四个像素一起执行。这方面可以参考A modern approach to software rasterization的shader实现。Fragment Shader完了后,就是Z-Test,Blend等了,这个应该比较好写,完全可以自己代码控制。
另外再附加几个链接吧,可以参考一下
A trip through the Graphics Pipeline 2011 http://fgiesen.wordpress.com/2011/07/09/a-trip-through-the-graphics-pipeline-2011-index/ 这个系列的文章相当的高级,介绍了Direct3D 11管线的各个方面,真的值得一读。
Perspective Texture Mapping http://chrishecker.com/Miscellaneous_Technical_Articles
最后贴一下我写的光栅化渲染器的效果图