一、前言
在3D游戲中,制作者希望能夠繪制越來(lái)越多的場(chǎng)景物體,比如場(chǎng)景中大量的植被(樹(shù)木,草,花等),能夠給玩家?guī)?lái)更加逼真的體驗(yàn)。但是這對(duì)對(duì)于設(shè)備(尤其是移動(dòng)端)的性能是個(gè)極大的考驗(yàn),如果使用傳統(tǒng)的技術(shù),大批量渲染會(huì)導(dǎo)致drawcall增加,fps下降。unity推出了Gpu Instancing技術(shù),這對(duì)于大量相同物體的繪制提供了一個(gè)新的方案,我們嘗試在unity中使用gpu instance 技術(shù)。
二、靜態(tài)物體使用Gpu Instance
Gpu Instance是一種用來(lái)提高渲染大量物體效率的技術(shù),在場(chǎng)景里繪制越來(lái)越多的物體,這里面主要涉及兩個(gè)方面的性能瓶頸,一是cpu對(duì)gpu提交數(shù)據(jù)的次數(shù)(包括設(shè)置數(shù)據(jù)buffer,渲染狀態(tài)以及調(diào)用對(duì)渲染原語(yǔ)的繪制即drawcall),二是gpu上的繪制(包括頂點(diǎn)處理和像素繪制),隨著場(chǎng)景物體的提升,cpu和gpu的壓力都會(huì)上升。目前在一些典型的3D游戲的制作中,我們的經(jīng)驗(yàn)值是全屏不超過(guò)10萬(wàn)個(gè)頂點(diǎn)和200個(gè)draw call左右,不然對(duì)中端機(jī)器會(huì)有一定壓力。
為了解決場(chǎng)景繪制效率這個(gè)問(wèn)題,主要有以下幾種優(yōu)化方案:
static batching: 即靜態(tài)合批,靜態(tài)合批的原理即化整為零,將多個(gè)場(chǎng)景物體預(yù)先合成一個(gè)大的物體進(jìn)行繪制,unity5的實(shí)現(xiàn)就是整合成一個(gè)大的vbo,而不整合IBO,一次性提交vbo給gpu,然后并不是把整個(gè)vbo都繪制,而是每次需要繪制其中某個(gè)某些物體時(shí)改變IBO,選擇大vbo上的某一段進(jìn)行繪制。靜態(tài)合批可以將多個(gè)小物體的繪制合并成一個(gè)大物體的繪制,減少對(duì)渲染狀態(tài)的改變,它一次并行繪制多個(gè)物體,理論上是最快的繪制方法,不過(guò)最大的缺點(diǎn)是因?yàn)楹铣尚碌拇髒bo需要耗費(fèi)額外的大量?jī)?nèi)存,同時(shí)不能渲染動(dòng)態(tài)物體,因?yàn)楹喜bo的時(shí)候已經(jīng)確定頂點(diǎn)數(shù)據(jù)了,頂點(diǎn)數(shù)據(jù)不能更改(例如unity5對(duì)LOD合批的實(shí)現(xiàn)也是講所有層次的lod都預(yù)先合并進(jìn)去),另外一個(gè)vbo的大小是有限制的,如果物體數(shù)量過(guò)多,也會(huì)被拆成多個(gè)繪制。
dynamic batching:動(dòng)態(tài)合批,可以解決對(duì)頂點(diǎn)數(shù)據(jù)有變化的物體的合批,它動(dòng)態(tài)的合并vbo進(jìn)行提交,組建vbo的時(shí)間有消耗,為了減少這個(gè)消耗,unity對(duì)動(dòng)態(tài)合批的vbo大小有限制,以致于很小頂點(diǎn)數(shù)的物體才有可能被動(dòng)態(tài)合批。
vertex constant instancing:Instancing 是不同于batching的另一種方案,它的原理是對(duì)于模型一致的物體,只提交原始的模型的vbo給gpu,然后將每個(gè)物體不同的屬性單獨(dú)抽出來(lái)組成buffer發(fā)給gpu,在顯卡中根據(jù)這一份vbo和每個(gè)物體不同的屬性來(lái)繪制多個(gè)物體,即一次提交,在gpu上繪制多個(gè),對(duì)于大量同樣模型的物體繪制是一個(gè)很好的方案。vertex constant的instancing是利用頂點(diǎn)常量屬性來(lái)存儲(chǔ)這些per instance attributes,但是也需要一個(gè)大的vbo存儲(chǔ)所有未經(jīng)頂點(diǎn)變換的相同的n個(gè)原頂點(diǎn)數(shù)據(jù),在shader里面讀取不同的vertex constant內(nèi)容繪制不同的instance
gpu instancing:這是最新渲染api提供的一種技術(shù),如果繪制1000個(gè)物體,它將一個(gè)模型的vbo提交給一次給顯卡,至于1000個(gè)物體不同的位置,狀態(tài),顏色等等將他們整合成一個(gè)per instance attribute的buffer給gpu,在顯卡上區(qū)別繪制,它大大減少提交次數(shù),它在不同平臺(tái)的實(shí)現(xiàn)有差異,例如gles是將per instance attribute也當(dāng)成一個(gè)vbo提交,然后gles3.0支持一種per instance步進(jìn)讀取的vbo特性,來(lái)實(shí)現(xiàn)不同的instance得到不同的頂點(diǎn)數(shù)據(jù),這種技術(shù)對(duì)于繪制大量的相同模型的物體由于有硬件實(shí)現(xiàn),所以效率最高,最為靈活,避免合批的內(nèi)存浪費(fèi),并且原則上可以做gpu skinning來(lái)實(shí)現(xiàn)骨骼動(dòng)畫(huà)的instancing。
Unity5中實(shí)現(xiàn)instance
unity中提供了兩種使用gpu instance的機(jī)制,自動(dòng)和手動(dòng):
自動(dòng):需要使用unity 標(biāo)準(zhǔn)的standar 或surfaceshader,然后在mat下面的instacne那里打勾,然后unity在條件合適的情況下自動(dòng)instance,但是注意這種限制非常多,如不能static batch,不能liaghtmap,不能改變mat,不能帶動(dòng)作,不能cull,等等,非常難,詳見(jiàn)https://docs.unity3d.com/Manual/GPUInstancing.html
手動(dòng):通過(guò)使用Graphics.DrawMeshInstanced或者Graphics.DrawMeshInstancedIndirect這些底層api。
由于unity自動(dòng)的instance不穩(wěn)定且不能lightmap等等,于是我們的實(shí)現(xiàn)方案是自己用底層api去實(shí)現(xiàn)instance,并且自己去實(shí)現(xiàn)了支持lightmap和culling的instance。
三、帶骨骼動(dòng)畫(huà)的物體使用Gpu Instance
gpuinstancing可以很好的工作在靜態(tài)物體上,例如草,樹(shù)。但遺憾的是暫時(shí)還無(wú)法對(duì)骨骼動(dòng)畫(huà)使用這個(gè)特性。而我們游戲經(jīng)常使用上百個(gè)小兵單位作戰(zhàn),如果可以讓小兵使用這個(gè)特性,那么對(duì)于性能的提升無(wú)疑是很可觀的。于是有人提出了將動(dòng)畫(huà)信息烘焙到貼圖中,在shader里面根據(jù)貼圖設(shè)置頂點(diǎn)位置,也就是我們的頂點(diǎn)動(dòng)畫(huà)。這樣的話,模型就既可以像骨骼動(dòng)畫(huà)那樣播放動(dòng)作,又可以使用gpuinstancing合批了。做法也非常簡(jiǎn)單,就是把Skinmesh變成MeshRender,然后把骨骼和動(dòng)畫(huà)信息,記錄在一張貼圖上,然后把動(dòng)畫(huà)的運(yùn)算放到shader里。 本來(lái)這樣就可以了,但實(shí)際使用過(guò)程中卻發(fā)現(xiàn)了幾個(gè)問(wèn)題。
1.烘焙的貼圖過(guò)大,因?yàn)闉榱舜鎯?chǔ)浮點(diǎn)數(shù),必須使用rgbahalf的格式,這個(gè)格式每個(gè)像素有64個(gè)字節(jié),是真彩色的兩倍。假設(shè)一個(gè)小兵有1000個(gè)頂點(diǎn),那么1s的動(dòng)作就需要1000*64,也就是64000個(gè)字節(jié),而正常情況下,我們小兵在2000個(gè)頂點(diǎn)左右,動(dòng)畫(huà)在5s以上,那么每個(gè)動(dòng)畫(huà)貼圖大概就在2M以上,甚至有可能是4M。而我們有60多個(gè)兵種,這樣一算竟然有240M。雖然小米超神使用了RGMB來(lái)減少每個(gè)像素的大小,但那也高達(dá)120M的動(dòng)畫(huà)貼圖了。而我們知道,原始的骨骼動(dòng)畫(huà)數(shù)據(jù)其實(shí)只有幾百k左右。
2.無(wú)法計(jì)算光照,因?yàn)榉ň€始終保持T-pos形態(tài),在shader里面改變頂點(diǎn)位置的時(shí)候,無(wú)法重新計(jì)算法線。為了能夠使用正常的光照計(jì)算,必須將法線也一起烘焙。幸運(yùn)的是法線都是單位向量,可以采用rgba存儲(chǔ),但也需要大概1M左右的空間。
3.沒(méi)有動(dòng)畫(huà)之間的blend,為了實(shí)現(xiàn)blend,必須對(duì)兩個(gè)動(dòng)作的貼圖進(jìn)行采樣,然后lerp。這樣會(huì)導(dǎo)致shader里放兩張4M的貼圖,對(duì)手游來(lái)說(shuō)還是不小的開(kāi)銷。
綜上所述,我最終還是采納了M神的建議,使用了烘焙骨骼信息的方案。
來(lái)看看原理,烘焙頂點(diǎn)很好理解,就是把位置的值存到貼圖中。那么如何烘焙骨骼信息,然后得到頂點(diǎn)位置呢?首先我們要理解骨骼動(dòng)畫(huà)的原理,這里引用UWA博客里面的一段話:
當(dāng)然上面的描述很簡(jiǎn)單,如果想要了解更加詳細(xì)的推倒過(guò)程,可以看Milo大神的書(shū)《游戲引擎架構(gòu)xxx》里面的蒙皮的數(shù)學(xué)這一章。
總之,結(jié)論就是從當(dāng)前骨骼的bindpos一直左乘到根骨骼。
代碼也非常簡(jiǎn)單:
for (int j = 0; j < bones.Length; j++)
{
GPUSkinningBone currentBone = bones[j];
Matrix4x4 lastMat = currentBone.bindpose;
while (true)
{
if (currentBone.parentBoneIndex == -1)
{
Matrix4x4 mat = Matrix4x4.TRS(currentBone.transform.localPosition, currentBone.transform.localRotation, currentBone.transform.localScale);
if(rootBone.transform != go.transform)
{
mat = Matrix4x4.TRS(Vector3.zero, Quaternion.identity, go.transform.localScale) * mat;
}
lastMat = mat * lastMat;
break;
}
else
{
Matrix4x4 mat = Matrix4x4.TRS(currentBone.transform.localPosition, currentBone.transform.localRotation, currentBone.transform.localScale);
lastMat = mat * lastMat;
currentBone = bones[currentBone.parentBoneIndex];
}
}
animMap.SetPixel(j * 3, k + 1, new Color(lastMat.m00, lastMat.m01, lastMat.m02, lastMat.m03));
animMap.SetPixel(j * 3 + 1, k + 1, new Color(lastMat.m10, lastMat.m11, lastMat.m12, lastMat.m13));
animMap.SetPixel(j * 3 + 2, k + 1, new Color(lastMat.m20, lastMat.m21, lastMat.m22, lastMat.m23));
if (k == startFrame)
{
animMap.SetPixel(j * 3, k, new Color(lastMat.m00, lastMat.m01, lastMat.m02, lastMat.m03));
animMap.SetPixel(j * 3 + 1, k, new Color(lastMat.m10, lastMat.m11, lastMat.m12, lastMat.m13));
animMap.SetPixel(j * 3 + 2, k, new Color(lastMat.m20, lastMat.m21, lastMat.m22, lastMat.m23));
}
else if(k == curClipFrame1 + startFrame - 3)
{
animMap.SetPixel(j * 3, k + 2, new Color(lastMat.m00, lastMat.m01, lastMat.m02, lastMat.m03));
animMap.SetPixel(j * 3 + 1, k + 2, new Color(lastMat.m10, lastMat.m11, lastMat.m12, lastMat.m13));
animMap.SetPixel(j * 3 + 2, k + 2, new Color(lastMat.m20, lastMat.m21, lastMat.m22, lastMat.m23));
}
}
最重要的部分就是生成矩陣的那里。這里有幾個(gè)注意點(diǎn),一個(gè)是根骨骼可能有多個(gè),那么你只需要將他們共同的父親放到根節(jié)點(diǎn),把這個(gè)其實(shí)沒(méi)有骨骼的節(jié)點(diǎn)處理成默認(rèn)矩陣的情況就可以。第二個(gè)是因?yàn)橘N圖采樣有可能采樣到邊緣,為了防止精確度不夠引起動(dòng)畫(huà)抖動(dòng),我前后各多增加了一幀,防止抖動(dòng)。
然后是shader部分:
v2f vert(appdata v)
{
UNITY_SETUP_INSTANCE_ID(v);
float start = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimStart);
float end = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimEnd);
float off = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimOff);
float speed = UNITY_ACCESS_INSTANCED_PROP(Props, _Speed);
float _AnimLen = (end - start);
float f = (off + _Time.y * speed) / _AnimLen;
f = fmod(f, 1.0);
float animMap_x1 = (v.uv2.x * 3 + 0.5) * _AnimMap_TexelSize.x;
float animMap_x2 = (v.uv2.x * 3 + 1.5) * _AnimMap_TexelSize.x;
float animMap_x3 = (v.uv2.x * 3 + 2.5) * _AnimMap_TexelSize.x;
float animMap_y = (f * _AnimLen + start) / _AnimAll;
float4 row0 = tex2Dlod(_AnimMap, float4(animMap_x1, animMap_y, 0, 0));
float4 row1 = tex2Dlod(_AnimMap, float4(animMap_x2, animMap_y, 0, 0));
float4 row2 = tex2Dlod(_AnimMap, float4(animMap_x3, animMap_y, 0, 0));
float4 row3 = float4(0, 0, 0, 1);
float4x4 mat = float4x4(row0, row1, row2, row3);
float4 pos = mul(mat, v.vertex);
float3 normal = mul(mat, float4(v.normal, 0)).xyz;
v2f o;
UNITY_TRANSFER_INSTANCE_ID(v, o);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.vertex = UnityObjectToClipPos(pos);
o.color = float4(0, 0, 0, 0);
o.worldNormal = UnityObjectToWorldNormal(normal);
float3 normalDir = normalize(mul(float4(normal, 0.0), unity_WorldToObject).xyz);
float frezz = UNITY_ACCESS_INSTANCED_PROP(Props, _Frezz);
float3 normalWorld = o.worldNormal;
fixed dotProduct = dot(normalWorld, fixed3(0, 1, 0)) / 2;
dotProduct = max(0, dotProduct);
o.color = dotProduct.xxxx * frezz;
return o;
}
主要就是頂點(diǎn)著色器部分,我們把4x4的骨骼旋轉(zhuǎn)偏移矩陣存在貼圖里,因?yàn)樽詈笠恍惺莊laot4(0,0,0,1),為了節(jié)省空間,我們只存了3x4大小的矩陣,最后一行在shader里補(bǔ)上。然后直接將矩陣和頂點(diǎn)相乘,就可以得到蒙皮后的頂點(diǎn)位置。而且我們看到,法線也可以這么處理,就可以得到蒙皮后正確的法線。這里還有一個(gè)我沒(méi)有做的功能,就是骨骼權(quán)重,其實(shí)我將骨骼權(quán)重存進(jìn)了頂點(diǎn)的uv2中,uv2.xy是第一根骨骼的索引和權(quán)重,uv2.zw是第二根骨骼的索引和權(quán)重,理論上需要將兩個(gè)骨骼結(jié)算的結(jié)果加權(quán)平均一下,但因?yàn)槲覝y(cè)試發(fā)現(xiàn)精度夠了,就少采樣一次,節(jié)省點(diǎn)消耗。如果有需要,可以自己加上這個(gè)加權(quán)平均。
還有一個(gè)未來(lái)需要做的,就是動(dòng)畫(huà)之間的blend,需要額外增加一個(gè)變量控制blend的程度,對(duì)兩個(gè)時(shí)刻的動(dòng)作分別采樣計(jì)算,然后lerp一下就可以了。
我們看看用貼圖存儲(chǔ)骨骼需要的大小,假設(shè)一個(gè)小兵有25個(gè)骨骼,那么一個(gè)骨骼需要4x3個(gè)浮點(diǎn)數(shù),也就是3個(gè)像素,那么需要75個(gè)像素,一個(gè)1s的動(dòng)畫(huà),也只需要75*64,大概4800字節(jié)而已。而且重要的是我們不受到頂點(diǎn)數(shù)的限制,而一個(gè)小兵的骨骼正常情況下就是30以內(nèi),我們得到了一個(gè)可控的合理的結(jié)果。
四、總結(jié)
使用Gpuinstance技術(shù)能極大的提示游戲的渲染性能,讓游戲能夠渲染更多的植被和動(dòng)態(tài)物體,提高玩家的游戲樂(lè)趣。
更多關(guān)于unity游戲培訓(xùn)的問(wèn)題,歡迎咨詢千鋒教育在線名師。千鋒教育擁有多年IT培訓(xùn)服務(wù)經(jīng)驗(yàn),采用全程面授高品質(zhì)、高體驗(yàn)培養(yǎng)模式,擁有國(guó)內(nèi)一體化教學(xué)管理及學(xué)員服務(wù),助力更多學(xué)員實(shí)現(xiàn)高薪夢(mèng)想。