1. 程式人生 > >Geometry Instancing(幾何體例項化)

Geometry Instancing(幾何體例項化)

這裡是通過一個迴圈呼叫了NUM個DrawCall(DrawCall在DrawArchitecture裡,當然,那時候都是用glBegin/glEnd的,但是這裡看做一個glDrawXX好了),在呼叫前可以設定這次渲染的各種狀態(不僅GL狀態,還包括上述的各種矩陣變換、配色等等的狀態)。

把一次DrawCall作為一個Batch,這樣做相當於我們在本地客戶端(我們的程式所在)向顯示卡(OpenGL的“服務端”)連續傳輸同一份頂點資料共NUM次,這NUM個Batch不同之處僅在於一些頂點屬性(attribute)之類的。對於更大的建築群,或者說廣闊的草簇群、NPC群,這樣的NUM可能就是成千上萬之巨了。顯示卡不會對這種重複資料多次傳輸做優化,所以記憶體和GPU的資料傳輸負載隨著DrawCall的呼叫次數增多而增大,當程式的效率更多地損失在資料傳輸上之時,就造成了渲染瓶頸,FPS慘不忍睹。

Geometry Instancing技術就是為了這樣的場合而產生的。這時候,我們可以只調用一次DrawCall,把該份頂點資料(VBO所維護的)傳輸到顯示卡,並告訴顯示卡需要繪製多少次(或者說,執行多少次Vertex Shader)。這就是Insatncing所謂的“一次提交,多次渲染”。對於OpenGL來說,這樣的操作只需要簡單地呼叫Draw函式的Intanced版本就可以了:

C++程式碼
  1. void glDrawArraysInstanced(GLenum mode, GLint first, GLsizei count  GLsizei primcount);   
  2. void glDrawElementsInstanced(GLenum mode, GLsizei count, GLenum type, 
    constvoid *indicies,  GLsizei primcount);  

對於VBO([學一學,VBO] [索引頂點的VBO與多重紋理下的VBO] )有了解的話,對上述DrawCall函式的原生版本也不會陌生:glDrawArrays和glDrawElements。這裡的Instanced版本也就在最後加了個primcount的引數指明需要繪製的次數而已。當然還有其他的變式函式(OpenGL的DrawCall函式的某些變式的名字那可是很讓人驚歎的東西),就不一一列舉。

呼叫該函式後,對於傳入流水線的每個頂點,其Vertex Shader會執行primcount次當然包括後面的對應的流水線階段了,都是執行

primcount次),每一次就作為一次例項化,亦即一個Instance。在Vertex Shader或者Geometry Shader([亂彈紀錄I:Geometry Shader裡,可以使用gl_InstanceID這個attribute變數來獲悉當前的Shader是該DrawCall的第幾次執行(當前處理的是第幾個Instance)。慢著!這樣說的話,Instanced版本的Draw函式下,所有頂點的所有Instance都用同一個Vertex Shader,同一套流水線操作,那豈不最終的結果就是一模一樣的?!primcount個物件豈不完全重疊在一起?

恩。當然咯。

那麼我們以前是怎樣做的呢?多個DrawCall下,我們可以在DrawCall之前設定好該DrawCall的所有屬性。考慮一個簡單的情況:讓各個物件的位置各不相同,那就在呼叫DrawCall前傳入不同的模型矩陣作為Vertex Shader的uniform。那在Geometry Instancing下,我們只有一個DrawCall,怎樣做到上述的效果呢?

我們還有另一種方法向Vertex Shader輸入資料:Attribute變數。我們可以把模型矩陣作為頂點的attribute變數,那麼每個頂點就有它的一份模型矩陣了。等等,你說這有啥用?是的,這本身沒啥改變:因為我們需要的是該頂點的每個Instance有不同的模型矩陣,反而是同一個Instance的所有頂點的模型矩陣都應該是相同的。這裡要說的是,我們可以對每個Instance做同樣的事情——我們可以把模型矩陣作為頂點的attribute變數,讓每個例項(Instance)有它的一份模型矩陣。

C++程式碼  (OpenGL Instanced VAO Attribute Setup)
  1. glGenVertexArrays(1, &m_nFloorVAO);
  2. glBindVertexArray(m_nFloorVAO);
  3. //......
  4. glGenBuffers(1, &nFloorLVBO);  
  5. glBindBuffer(GL_ARRAY_BUFFER, nFloorLVBO);  
  6. glBufferData(GL_ARRAY_BUFFER, floorLocations.size() * sizeof(ZWVector3), floorLocations.data(), GL_STATIC_DRAW);  
  7. glEnableVertexAttribArray(FLOOR_ATTRIB);  
  8. glVertexAttribPointer(FLOOR_ATTRIB, 3, GL_FLOAT, GL_FALSE, 0, NULL);  
  9. glVertexAttribDivisor(FLOOR_ATTRIB, 1);  

這裡都是司空見慣的程式碼了(見[AB是一家?VAO與VBO] ),我們直接使用一個位置向量作為attribute(當然你也可以使用矩陣,但就要多使用幾個attribute location來劃分了。事實上我只需要“不同的位置”,那直接使用位置向量,在Shader裡再結合進一個單位模型矩陣豈不更好)。但不同之處在於FLOOR_ATTRIB這個shader attribute location的設定方法,有兩點:第一點是資料本身。

C++程式碼
  1. std::vector<ZWVector3> floorLocations;  
  2. for (int i = 0; i < m_nInstanceCount; ++i)  
  3. {  
  4.     floorLocations.push_back(..floorLocation[i]);  
  5. }  

上述交代了資料大致是怎麼定義的。注意到了嗎,總數是m_nInstanceCount,也就是說它不是按頂點個數來組織的,而是以Instance個數來組織的——它不是頂點的Attribute而是Instance的Attribute。如果單純從資料量來改變,這是沒有效果的(預設還是把這資料當做頂點的資料,一般如果資料個數小於頂點數,那渲染結果就是後半的頂點要悲劇了 - -),真正讓它成為Instance專屬資料的是glVertexAttribDivisor這個函式——這是第二點。

glVertexAttribDivisor第一個引數也還是attribute location,第二個引數指明當前的資料(floorLocations)是每多少個Instance變更一次。這裡1的意思是每一個Instance(例項)變更一次,所以渲染時第一個Instance的vertex shader中的FLOOR_ATTRIB對應的attribute都將全是floorLocations[0]這個資料,第二個Insatnce則是對應floorLocations[1]這個資料……第m_nInstanceCount個Instance則是對應floorLocations[m_nInstanceCount - 1]這個資料:

C++程式碼 (OpenGL Instanced VAO Attribute Render)
  1. glBindVertexArray(m_nFloorVAO);  
  2. glDrawElementsInstanced(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, NULL, m_nInstanceCount);  

亂彈紀錄III:Geometry Instancing - www.zwqxin.com

這就是我們需要的。接下來就是在Vertex Shader里根據該attribute去構建模型矩陣,把頂點移到floorLocations[i]指定的位置了。無論是變換矩陣、配色還是其他任何特定於各個Instance的特性,都可以通過這種方法去實現。回到開頭的那段程式碼,應用Geometry Instancing的話:

C++程式碼
  1. //Setup VAO
  2. glGenVAO(..., m_nVAO);  
  3. glBindVAO(..., m_nVAO);  
  4. glGenVBO(...);  
  5. glBindVBO(...);  
  6. glBufferData(...InstanceData...);  
  7. glEnableVertexAttrib(...);  
  8. glVertexAttribPointer(..);  
  9. glVertexAttribDivisor(.., 1);  
  10. //.....and so on for every instance property 
  11. //and Vertex Data VBO
  12. glBindVAO(..., NULL);  
  13. ////
  14. //Render
  15. glBindVAO(..., m_nVAO);  
  16. DrawArchitecture(..., NUM);    

這裡只有一個DrawCall,而且所有例項Attribute都用VAO儲存好。渲染的時候就簡單很多了,“一次提交,多次渲染”。

再提一下,glVertexAttribDivisor的第二個引數,如果是2的話,那就是每兩個Instance變更一次instance attribute……如此類推。那如果是0呢?那就是跟以前一樣,資料“退化”變成頂點Attribute了,呵呵。

還有沒有其他方法呢?

再回頭看一看Uniform這種型別的輸入引數。Uniform一般是針對每個DrawCall的,目前是無法“降格”到針對每個Insatnce(與此相對,attribute一般是針對每個頂點的,可以“升格”到針對每個Instance,如上所述)。但是我們也可以把所有的Instance資料打包成一個Array,作為uniform傳入vertex shader——上面不是提及gl_InstanceID這個東西的作用了麼?用它來檢索這個Array不就OK了麼!當然了這個方法需要在DrawCall前傳入一個或許很“重”的unifom變數(使用UBO或許可以減小GLSL對uniform變數佔寬的限制),Vettex Shader裡也得多個檢索。至於什麼方法更好,就看應用場合+見仁見智了。像如果每個例項需要不同的紋理,那最好的方案是傳入一個texture Array([學一學, Texture Array紋理陣列] ),然後使用gl_InstanceID來檢索(注意它是個int值,傳入fragment shader裡的時候要指定flat來避免柵格化插值)。像一個天空盒SkyBox,六個面都是矩形,模型矩陣和紋理不一樣,就可以這樣做。

glsl程式碼 (fragment shader, texture for each instance)
  1. #version 330
  2. #extension GL_EXT_gpu_shader4 : enable
  3. uniform sampler2DArray   basetexArray;  
  4. in vec2 varying_texcoord;  
  5. flat in int varying_InstanceID;  
  6. layout(location = 0) out vec4 fragColor;  
  7. void main(void)  
  8. {  
  9.    vec4 texCol = texture2DArray(basetexArray, vec3(varying_texcoord, varying_InstanceID));  
  10.    fragColor = texCol;   
  11. }  

再談到Geometry Shader的缺點,其中一個就是對於CPU端的視錐體剔除(在渲染前設立條件,視錐體外的物體都不渲染)。因為只有一個DrawCall,你將無法根據預先判斷把不在視錐體內的Insatnce剔除渲染陣列——所有流水線操作都將執行,這樣對於大場景的大批量渲染的場景管理策略失效,會造成效率的負向影響,甚至Geometry Instancing這應用也得不償失了。

在往後的文章,我將會談及另一種針對Instanced Objects的剔除方法,也就是在[亂彈紀錄I:Geometry Shader]中提及的利用Geometry Shader進行幾何元剔除的方式,通過額外的一個簡單Pass判定可見性,剔除並FeedBack到第二個Pass渲染視錐體可見的物件。這種方式可以一定程度減小Instancing的上述負向影響。

本文到此結束。隨著GPU圖形技術的發展,以及大批量物件渲染的需要,過去使用範圍很受限的Geometry Instancing如今也越來越重要了,OpenGL對這類技術的支援也越來越豐富,也將越來越更豐富。