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/,可能需要翻牆。裁剪完了做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
最后貼一下我寫的光柵化渲染器的效果圖