目錄
- 前言
- 要求
- 場景概覽
- 機器人層級模型
- 為立方體部件貼紋理
- 關鍵幀影片
- 關鍵幀影片回圈
- 體素建模
- 場景布局
- 添加光影特效
- 延遲渲染管線
- 立方體貼圖
- 環境映射
- Phong光照
- 陰影映射
- 體積光
- debug著色器
前言
這次大作業算是做的比較認真的了,記錄一下,全文幾乎 完全參考 Learn OpenGL 的教程,感謝大佬 Orz
要求
學生可以通過層級建模( 實驗補充1和2)的方式建立多個虛擬物體,由多個虛擬物體組成一個虛擬場景,要求在程式中顯示該虛擬場景,場景可以是室內或者室外場景;場景應包含地面,
-
場景設計和顯示
-
添加紋理
-
添加光照、材質、陰影效果
-
用戶互動實作視角切換完成對場景的任意角度瀏覽
-
通過互動控制物體
場景概覽
這是一個我十分喜歡的場景,出自游戲守望先鋒的 CG 電影《最后的堡壘》,在智械危機大戰之后,沉睡的戰爭機器 “堡壘”,在艾興瓦爾德旁的原始森林中蘇醒……

該場景分為四個部分:
- 樹木
- 機器人
- 地面
- 環境(比如遠景和天空)
其中樹木和地面我們使用obj檔案+紋理的方式進行渲染,因為這些物體是靜態的,而環境我們則使用立方體貼圖(cubeMap)來進行繪制,
而機器人我們使用層級建模的方式來描述其每一個組件,層級建模分為 3 層,第一層是身體層,我們將所有肢體都附著到身體上,第二層是主肢體層,它包括了頭,大腿,大臂,和機器人機槍炮臺,最后第三層是次肢體層,它包括了腳和手,機器人機槍槍管,下面是我們機器人的概覽圖:

機器人層級模型
機器人的所有肢體均采用立方體組成,一個立方體對應一個 TriMesh 物件,我們定義一個 Robot 類,其中包含一個 map 以根據名字,快速查詢對應的組件,

我們定義如下的幾個組件名稱:body, head, back, gun, left_arm, right_arm, left_hand, right_hand, left_leg, right_leg, left_foot, right_foot
此外,在建構式中,加入對應的組件,下面以加入head組件為例:

注:這里我大改了 TriMesh 和 MeshPainter 的實作,在 TriMesh 中添加 bindData 方法以單獨系結資料,實作模型和著色器物件分離,
texture_path 會在 TriMesh 的 bindData 中被利用為紋理貼圖路徑從而進行紋理的加載,而 rotatePoint 則是部件的旋轉點,用以描述部件的旋轉軸,
為立方體部件貼紋理
我們通過手動指定紋理坐標的方式,為立方體 TriMesh 的每一個面片貼上對應的紋理,我們將一個立方體的紋理描述為 6 張正方形圖片的拼接,于是我們用一張圖就可以描述立方體的 6 個面,以機槍炮臺組件為例:

我們改動 TriMesh 類的 generateCube 函式,手動系結 36 個頂點的紋理坐標(這里列出部分):

因為一個一個貼實在是太累人了,我給出我實作的一種貼圖方案:
// 立方體生成12個三角形的頂點索引
void TriMesh::generateCube(vec3 _color, vec3 _scale)
{
// 創建頂點前要先把那些vector清空
cleanData();
for (int i = 0; i < 8; i++)
{
vertex_positions.push_back(cube_vertices[i] * _scale);
if (_color[0] == -1){
vertex_colors.push_back(basic_colors[i]);
}
else{
vertex_colors.push_back( _color );
}
}
// 每個三角面片的頂點下標
// 每個三角面片的頂點下標
faces.push_back(vec3i(0, 3, 1));
faces.push_back(vec3i(0, 2, 3));
faces.push_back(vec3i(1, 5, 4));
faces.push_back(vec3i(1, 4, 0));
faces.push_back(vec3i(4, 2, 0));
faces.push_back(vec3i(4, 6, 2));
faces.push_back(vec3i(5, 6, 4));
faces.push_back(vec3i(5, 7, 6));
faces.push_back(vec3i(2, 6, 7));
faces.push_back(vec3i(2, 7, 3));
faces.push_back(vec3i(1, 7, 5));
faces.push_back(vec3i(1, 3, 7));
// 顏色下標,讓一個面的顏色都一樣
for (int i = 0; i < 6; i++) {
color_index.push_back(vec3i(i, i, i));
color_index.push_back(vec3i(i, i, i));
}
texture_index = faces;
normal_index = faces;
storeFacesPoints();
textures.clear();
// 頂點紋理坐標,只是自己想的一種貼圖方式而已
// 031
textures.push_back(vec2(0.25, 0.25));
textures.push_back(vec2(0.5, 0.5));
textures.push_back(vec2(0.5, 0.25));
// 023
textures.push_back(vec2(0.25, 0.25));
textures.push_back(vec2(0.25, 0.5));
textures.push_back(vec2(0.5, 0.5));
// 154
textures.push_back(vec2(0.5, 0.25));
textures.push_back(vec2(0.5, 0.0));
textures.push_back(vec2(0.25, 0.0));
// 140
textures.push_back(vec2(0.5, 0.25));
textures.push_back(vec2(0.25, 0.0));
textures.push_back(vec2(0.25, 0.25));
// 420
textures.push_back(vec2(0.0, 0.25));
textures.push_back(vec2(0.25, 0.5));
textures.push_back(vec2(0.25, 0.25));
// 462
textures.push_back(vec2(0.0, 0.25));
textures.push_back(vec2(0.0, 0.5));
textures.push_back(vec2(0.25, 0.5));
// 564
textures.push_back(vec2(0.75, 0.25));
textures.push_back(vec2(1.0, 0.5));
textures.push_back(vec2(1.0, 0.25));
// 576
textures.push_back(vec2(0.75, 0.25));
textures.push_back(vec2(0.75, 0.5));
textures.push_back(vec2(1.0, 0.5));
// 267
textures.push_back(vec2(0.25, 0.5));
textures.push_back(vec2(0.25, 0.75));
textures.push_back(vec2(0.5, 0.75));
// 273
textures.push_back(vec2(0.25, 0.5));
textures.push_back(vec2(0.5, 0.75));
textures.push_back(vec2(0.5, 0.5));
// 175
textures.push_back(vec2(0.5, 0.25));
textures.push_back(vec2(0.75, 0.5));
textures.push_back(vec2(0.75, 0.25));
// 137
textures.push_back(vec2(0.5, 0.25));
textures.push_back(vec2(0.5, 0.5));
textures.push_back(vec2(0.75, 0.5));
normals.clear();
// 正方形的法向量不能靠之前頂點法向量的方法直接計算,因為每個四邊形平面是正交的,不是連續曲面
for (int i = 0; i < faces.size(); i++)
{
normals.push_back( face_normals[i] );
normals.push_back( face_normals[i] );
normals.push_back( face_normals[i] );
}
}
關鍵幀影片
我們通過關鍵幀的形式,讓機器人動起來,關鍵幀代表了一個動作的關鍵點,以長方體繞點轉動為例,我們只需要兩個引數就可以描述這個運動,即:
- 起始狀態旋轉角度
- 結束狀態旋轉角度
我們根據時間,在兩個關鍵幀之間進行插值,即可得到當前時間下的旋轉角:

比如有一個動作,持續時間是 1s,起始旋轉角是 30,結束旋轉角是 120,當前時間是 0.5s,那么我們可以得到當前的旋轉角是 (120+30)*0.5 = 75°
我們定義機器人的關鍵幀狀態:一個關鍵幀即為一組關節狀態,這些狀態描述了該時刻各個關節的旋轉,平移,縮放引數,我們手動在 Robot 類內部指定關鍵幀,通過 map 尋址,以定義名為 stay 的關鍵幀為例,我們使用二重 map 進行定義:


注:
這里我們定義的縮放引數并沒有作用,因為這些縮放是不等軸的!對于不等軸的縮放,我們在將其法線變換到世界空間下時,不能夠使用模型矩陣,而是應該使用模型矩陣的逆矩陣的轉置,
嚴格來說助教師兄師姐的模板代碼,在該情況下會得到一個錯誤的法線方向,但是在大多數時候都是正確的,因為等軸縮放時,模型矩陣是一個正交矩陣,其逆矩陣等于轉置,我們直接使用模型矩陣對法線進行變換即可!
我們的angle.h又沒有計算逆矩陣的方法,于是為了解決縮放的問題,我們在generateCube的時候,就應該在c++中直接進行縮放!即讓cpu將這些頂點進行縮放,從而解決縮放后法向量錯誤的問題,,,
重回到關鍵幀影片問題上,對于一個關鍵幀影片的播放,我們必須指定三個引數:
- 起始關鍵幀
- 結束關鍵幀
- 持續時間
我們在 Robot 類中,利用 timer 記錄當前關鍵幀播放的進度,當 timer 減小到 0 之后,我們認為影片播放完成,

然后我們撰寫 playMotion 函式,將機器人的動作在兩個關鍵幀之間,根據時間進度進行插值,其中 setMotion 是將機器人的狀態調整到當前關鍵幀 currentMotion:


然后我們定義一個函式 changeState,控制機器人的變形動作,我們指定兩個關鍵幀,分別是 drive 和 stay,表示變形狀態和人形狀態:

我們為鍵盤的 p 按鍵系結該事件,并且執行 robot 的 changeState 方法,即可實作機器人的變形影片,我們讓機器人在站立和變形之間切換:

關鍵幀影片回圈
我們僅實作了關鍵幀影片的播放,我們還需要回圈播放關鍵幀影片,以使得機器人完成走路的動作,參照串列回圈的原則,我們建立關鍵幀串列,不斷回圈播放關鍵幀串列里面的影片:

同時修改我們的 playMotion 更新函式


最后我們在鍵盤回呼函式中,通過按鍵判斷來進行播放回圈影片,我們指定兩個關鍵幀,分別是 run1 和 run2,對應跑步影片的兩個關鍵幀:

如圖,我們實作了簡單跑步影片的播放,下面是兩個關鍵幀的詳情:

體素建模
使用 MagicaVoxel 軟體進行體素建模,并且匯出結果到 obj 檔案,方便我們讀取,我們建立兩顆不同的樹的模型,并且匯出對應的 obj 檔案:

場景布局
我們生成一個正方形平面,并且為其貼上草地的紋理,這就是我們的地面了,


而樹是重復的,我們無需建立多個 TriMesh 物件,相反地,我們創建兩個 TriMesh 即可,我們通過改變其位移 + 多次呼叫 draw call 的方式實作樹木的重復繪制:


添加光影特效
注意到我們的場景十分單調:

我們需要為其添加一些光影特效,這里我簡單的實作了如下的渲染效果:
- 延遲渲染管線
- phong光照
- 立方體貼圖
- 環境映射
- 陰影映射
- 體積光
在正式開始為場景添加特效之前,我們必須實作一些比較規范的東西,
延遲渲染管線
我們的所有實驗都是使用前向渲染,但是大作業我打算實作一個簡單的延遲渲染管線,延遲渲染管線能夠有效的減少片元著色器的開銷,因為我們無需對那些被遮擋的像素運行片段著色器!
我們的延遲渲染管線分為三個階段:
- shadowMap階段
- gbuffer階段
- 后處理階段
在 shadowMap 階段我們從光源方向進行一次渲染,獲取光源方向的場景的深度圖 shadowTexture,
在 gbuffer 階段,我們只渲染必要的資訊,比如顏色,法線,世界坐標和場景深度,我們把這些資訊存盤到幀緩沖的多個顏色(和深度)附件中,他們分別是由兩個幀緩沖和 3 個顏色附件,2 個深度附件組成:

在后處理階段,我們利用 gbuffer 階段和 shadowMap 階段繪制的幀緩沖資訊(就是那5張紋理的資料),對最終輸出的片元進行計算,比如光斬訓者是陰影等開銷比較大的特效,
下圖描述了我的簡易延遲渲染管線及其三個階段之間的緩沖區與順序關系:

我們使用 5 組著色器進行繪制:

其中 shadow 著色器負責從光源視角渲染深度紋理,而 skybox 和 gbuffer 負責生成 gbuffer 階段的顏色,法線,世界坐標,深度紋理,其中 skybox 是天空盒專用繪制著色器,composite 著色器負責最終的特效繪制,而 debug 著色器負責輸出 5 張紋理的內容,方便我改 bug,
值得注意的是,composite 和 debug 著色器的繪制物件都是一個正方形,它鋪滿了整個螢屏,我們只是把 gbuffer 的紋理資料取出來并且貼上去而已,
從 shadowMap 階段開始,我們首先創建光源方向上的陰影貼圖:

在 display 中,我們呼叫一次 draw call 以完成光源方向的繪制:

gbuffer 階段也是類似,首先我們創建紋理,我們的紋理都是 RGBA32 格式,這樣不容易發生截斷或者是溢位的例外情況:


然后我們如法炮制進行繪制即可

gbuffer 階段的著色器也十分簡單,我們根據傳入的資料,將片元資料輸出到對應的紋理即可,下面是 gbuffer 頂點著色器:

片元著色器也是一樣的,注意這里我們將反射系數存入 w 分量:

值得注意的是,gl_FragData[] 陣列指向的正是我們在init中呼叫的附件紋理的繪制順序:

注:這里我們修改了 MeshPainter,一個 TriMesh 在繪制的時候,傳遞它自己的模型矩陣和紋理,我們將模型矩陣和紋理(也包括一些其他的OpenGL物件,比如vao,vbo等)視為 TriMesh 自己的成員變數:

后處理階段則稍微簡單,我們繪制一個正方形,然后把紋理貼上去即可:

這里就顯示出延遲渲染管線的優勢:不管場景多么復雜,片元數目都是螢屏解析度,這意味著片元著色器被更少的執行,
隨后我們傳遞對應的 5 個紋理進去,并且執行 draw call 即可:


我們在 composite 的片段著色器中,直接采樣 gbuffer 階段傳遞的顏色紋理的值,即可輸出我們 gbuffer 階段繪制的基本畫面:


立方體貼圖
我們直接輸出 gbuffer 階段繪制的顏色紋理,我們很快發現這個場景十分單調,天空是黑色的,于是我們準備添加天空,這意味著天空的繪制也發生在 gbuffer 階段,
我們決定使用立方體貼圖來貼上天空與環境,立方體貼圖本質上是利用一個長寬高為 2 的立方體包住我們的攝像機,然后將黑色的背景改寫為立方體貼圖的顏色:

如圖這是一張立方體貼圖,它由 6 張圖片組成,我們將把它貼到一個立方體上,以包圍我們的相機:

我們撰寫 liadCubeMap 函式以快速加載我們的立方體貼圖,我們以 GL_TEXTURE_CUBE_MAP 的形式加載該紋理:

隨后我們創建并且加載立方體貼圖:


然后我們在繪制物體的同時,利用 skybox.fsh 和 skybox.vsh 兩個著色器,對立方體貼圖進行渲染,這里我們將立方體貼圖的位置設定到相機的 eye 位置,此外,我們關閉深度測驗以在背景處繪制天空貼圖:

我們撰寫 skybox 著色器,下面是頂點著色器:

片段著色器則更加簡單,我們直接利用頂點坐標取立方體貼圖顏色即可:

注:這里我們對世界坐標緩沖直接輸出非常遠的距離(比如1000),法線緩沖隨意,同時我們往w坐標里面輸出一個-1以標記天空,
現在我們應該能夠在 gbuffer 的顏色緩沖中,查看到立方體貼圖的繪制:

gbuffer 階段的繪制到此結束,后面的都是后處理階段的繪制,并且發生在 composite 著色器中進行,所需要的所有資訊,都存盤在 gbuffer 和 shadowMap 階段 pass 過來的 5 張紋理中:

環境映射
機器人身上的金屬部件會反光,我們需要收集其反射的顏色,收集的方法也很簡單,根據視線方向和法線,計算反射光線方向,并且到環境立方圖里面取值即可,我們撰寫函式,根據片元的世界坐標和法向量,取天空盒的顏色:

然后在 main 函式中,我們根據反射率,對原像素進行混色即可:


Phong光照
我們帶入 phong 光照模型的公式計算光照的分量,我們以參考的形式回傳資料,此外,因為我們沒有材質資料,我們默認三個光分量的材質都是 1.0 即可:

陰影映射
相比于使用投影矩陣,我們使用更加通用的陰影映射方法進行陰影繪制,我們利用 shadowMap 階段繪制的深度紋理,和光源坐標系的變換矩陣 shadowVP 即可實作繪制,我們通過比較采樣最近深度和當前深度,以判斷點是否在陰影中,撰寫 shadowMapping 函式以實作陰影的繪制,回傳值為 1 則表示在陰影中,

我們將 phong 光照和陰影結合,在有陰影的地方,我們只繪制環境光,其他地方,我們直接繪制所有的 phong 光照:

我們可以看到,在陽光之下的地方和陰影的明顯區別:

體積光
體積光用于模擬丁達爾效應,可以大大提升場景的美觀程度,

體積光是一個后處理階段的特效,利用光線追蹤方法,從相機視角出發向世界空間投射光線并且沿途記錄資訊,直到發生碰撞或者達到最大迭代次數,如果當前點不在陰影之中,那么我們累積顏色,否則我們不做處理,光線繼續前進:

為了記錄世界空間下一點是否和物體發生碰撞,我們需要利用深度緩沖的資料,我們將世界空間的位置,通過視圖,投影,視口變換,轉換到螢屏坐標系,然后查詢深度緩沖中的資料并且進行比對即可,該程序和陰影映射類似,我們撰寫兩個輔助函式,他們分別是螢屏深度轉線性深度,和碰撞測驗函式:

注:這里因為我們相機的 zFar 高達 100,如果直接讀取深度緩沖中的資料,那么會是一片全白,因為他們的數值幾乎非常接近 1,而且我們的硬體比較遠遠達不到精度要求,我們要做一次透視投影的逆變換,將深度重新映射回 0~1 的區間,以方便 FPU 進行比較
然后我們正式開始撰寫光線行程序序,我們從相機原點出發,沿途積累亮度直到碰撞或者達到最大迭代次數,當當前采樣點不在陰影中時,我們積累亮度,表示碰到體積光,否則我們不做處理:

緊接著我們直接將獲取的顏色附加到最終的輸出:

可以看到最終效果還可以

debug著色器
debug 著色器負責輸出 gbuffer 階段繪制的紋理,因為要可視化深度緩沖,我們還是得轉線性深度,此外我通過一個 uniform 變數名叫 mode 來控制 debug 模式:

注:因為光源方向的深度緩沖用的是正交投影,所以深度不用線性化,
和后處理階段一致,我們直接繪制一張四方形,然后將紋理貼上去即可:

在場景中我們系結按鍵事件:按下 y 即可呼出除錯界面,按下 u 可以切換除錯模式,除錯模式使用 viewport,開了一個小視窗在左下角:

轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/239587.html
標籤:其他
上一篇:坦克大戰!
下一篇:java實作貪吃蛇游戲
