⚡ 可編程渲染管線實現,全中文註釋,幫助初學者學習渲染原理。
mkdir build
cmake -S . -B ./build
cmake --build ./build --config Release注意複製res文件夾到可執行文件所在路徑。
隨便找個sample_開頭的例子文件直接gcc 單文件編譯即可:
gcc -O2 sample_07_specular.cpp -o sample_07_specular -lstdc++在Mac 下好像要加個-std=c++17 ,我應該沒用啥17 的東西,不過沒環境不太確定。某些平台下可能要加一個-lm ,顯示聲明一下鏈接數學庫。
運行:
./sample_07_specular然後得到一個圖片文件output.bmp :
本項目的模型使用的是tinyrender 裡面的開源模型。
主要使用一個ShaderContext 的結構體,用於VS->PS 之間傳參,裡面都是一堆各種類型的varying。
// 着色器上下文,由 VS 设置,再由渲染器按像素逐点插值后,供 PS 读取
struct ShaderContext {
std::map< int , float > varying_float; // 浮点数 varying 列表
std::map< int , Vec2f> varying_vec2f; // 二维矢量 varying 列表
std::map< int , Vec3f> varying_vec3f; // 三维矢量 varying 列表
std::map< int , Vec4f> varying_vec4f; // 四维矢量 varying 列表
};外層需要提供給渲染器VS 的函數指針,並在渲染器的DrawPrimitive函數進行頂點初始化時對三角形的三個頂點依次調用:
// 顶点着色器:因为是 C++ 编写,无需传递 attribute,传个 0-2 的顶点序号
// 着色器函数直接在外层根据序号读取响应数据即可,最后需要返回一个坐标 pos
// 各项 varying 设置到 output 里,由渲染器插值后传递给 PS
typedef std::function<Vec4f( int index, ShaderContext &output)> VertexShader;每次調用時,渲染器會依次將三個頂點的編號0 , 1 , 2通過index字段傳遞給VS 程序,方便從外部讀取頂點數據。
渲染器對三角形內每個需要填充的點調用像素著色器:
// 像素着色器:输入 ShaderContext,需要返回 Vec4f 类型的颜色
// 三角形内每个点的 input 具体值会根据前面三个顶点的 output 插值得到
typedef std::function<Vec4f(ShaderContext &input)> PixelShader;像素著色程序返回的顏色會被繪製到Frame Buffer 的對應位置。
調用下面接口可以繪製一個三角形:
bool RenderHelp::DrawPrimitive ()該函數是渲染器的核心,先依次調用VS 初始化頂點,獲得頂點坐標,然後進行齊次空間裁剪,歸一化後得到三角形的屏幕坐標。
然後兩層for 循環迭代屏幕上三角形外接矩形的每個點,判斷在三角形範圍內以後就調用VS 程序計算該點具體是什麼顏色。
現在你想寫個D3D 12 的三角形繪製,沒有一千行你搞不定,但是現在我們只需要下面幾行:
# include " RenderHelp.h "
int main ( void )
{
// 初始化渲染器和帧缓存大小
RenderHelp rh ( 800 , 600 );
const int VARYING_COLOR = 0 ; // 定义一个 varying 的 key
// 顶点数据,由 VS 读取,如有多个三角形,可每次更新 vs_input 再绘制
struct { Vec4f pos; Vec4f color; } vs_input[ 3 ] = {
{ { 0.0 , 0.7 , 0.90 , 1 }, { 1 , 0 , 0 , 1 } },
{ { - 0.6 , - 0.2 , 0.01 , 1 }, { 0 , 1 , 0 , 1 } },
{ { + 0.6 , - 0.2 , 0.01 , 1 }, { 0 , 0 , 1 , 1 } },
};
// 顶点着色器,初始化 varying 并返回坐标,
// 参数 index 是渲染器传入的顶点序号,范围 [0, 2] 用于读取顶点数据
rh. SetVertexShader ([&] ( int index , ShaderContext& output) -> Vec4f {
output. varying_vec4f [VARYING_COLOR] = vs_input[ index ]. color ;
return vs_input[ index ]. pos ; // 直接返回坐标
});
// 像素着色器,返回颜色
rh. SetPixelShader ([&] (ShaderContext& input) -> Vec4f {
return input. varying_vec4f [VARYING_COLOR];
});
// 渲染并保存
rh. DrawPrimitive ();
rh. SaveFile ( " output.bmp " );
return 0 ;
}運行結果:
| 文件名 | 說明 |
|---|---|
| RenderHelp.h | 渲染器的實現文件,使用時include 它就夠了 |
| Model.h | 加載模型 |
| sample_01_triangle.cpp | 繪製三角形的例子 |
| sample_02_texture.cpp | 如何使用紋理,如何設置攝像機矩陣等 |
| sample_03_box.cpp | 如何繪製一個盒子 |
| sample_04_gouraud.cpp | 對盒子進行簡單高洛德著色 |
| sample_05_model.cpp | 如何加載和繪製模型 |
| sample_06_normal.cpp | 使用法向貼圖增強模型細節 |
| sample_07_specular.cpp | 繪製高光 |
十多年前我寫了個軟渲染器教程mini3d,比較清晰的說明了軟件渲染器的核心原理,這是標準軟渲染器的實現方法,主要是基於Edge Walking 和掃描線算法。
而本項目的實現方式是仿照GPU 的Edge Equation 實現法,以mini3d 代表的實現方法其實相對比較複雜,但是很快,適合做CPU 實時渲染。而本項目模擬GPU 的實現方式相對簡單直觀,但是計算量很大,不適合CPU 實時,卻適合GPU 粗暴的並行處理。
網上有很多可編程渲染管線的實現教程,但是很多都做的有問題,諸如屏幕坐標他們取的是像素方格左上角的點,其實應該取像素方格中心的那個點,不然模型動起來三角形邊緣會有跳變的感覺;比如臨接三角形的邊該怎麼處理,基本我沒見到幾個處理正確的;再比如紋理採樣時整數坐標換算應該要四捨五入的,不然紋理旋轉起來幾個頂點位置不夠穩定,會有微動的跡象;還有一些軟件渲染器連紋理都不是透視正確的,還在用著仿式紋理映射。 。 。 。
渲染器實現有很多非常細節的地方,如果注意不到,其實渲染結果是不准確的,本項目使用標準模型,不錯繪一個點,不算錯一個坐標。
再一個是易讀性,某些項目為了刻意減少代碼量,砍了不少細節處理不說,很多運算都是一大堆矩陣套矩陣,連個出處和說明都沒有,這對於初學者來講是十分費解的,你連公式或者概念的名字都不知道,搜都沒得搜。
本項主文件RenderHelp.h一共一千多行,三分之一都是中文註釋,複雜運算我全部展開了,並不一味為了節省代碼尺寸犧牲可讀性,某些計算其實可以提取到外層這樣性能更快一些,但是為了可讀性,我還是寫到了和它相關的位置上,這樣閱讀理解更輕鬆。
基本原理,我在下面文章裡解釋過:
閱讀時,代碼前面基本都是一些工具庫,可以從最後200 行閱讀即可,每個公式我都寫了出處,基本半個小時拿筆推導下,你不但能理解渲染器的原理是啥,還多了一個方便隨時調試shader 驗證想法的工具。
代碼不理解可以在issue 裡提問,這樣該問題經過回答放在那裡也對後來的人有幫助,歡迎PR 增強功能,補充各類高級渲染效果。