使用 GPU 加速計算


U-n-i-t-y 提供了 [Compute Shader][link1] 來使得我們可以將大量的復雜重復的計算交給並行的 GPU 來處理,正是由於並行原因,這樣就可以大大加快計算的速度,相比在 CPU 的線程中有着巨大的優勢。類似 OpenglES 3.0 的 [Transform Feedback][link2] 和 Metal 的 [Data-Parallel Compute Processing][link3] 都是用來達到同樣的目的的。但是很遺憾 Compute Shader 基本和移動設備無緣了,而 也未能提供給開發者直接駕馭圖形接口的能力,([GL.IssuePluginEvent][link4] 似乎可以做到,但是這意味着需要自己處理很多跨平台跨設備的問題,感覺有點得不償失)。在 [Unity Roadmap][link5] 中也未看到任何類似的跡象。

[link1]: https://docs.unity3d.com/Manual/ComputeShaders.html
[link2]: https://www.opengl.org/wiki/Transform_Feedback
[link3]: https://developer.apple.com/library/content/documentation/Miscellaneous/.... html#//apple_ref/doc/uid/TP40014221-CH6-SW1
[link4]: https://docs.unity3d.com/ScriptReference/GL.IssuePluginEvent.html
[link5]: https://unity3d.com/cn/unity/roadmap

於是乎一種曲線救國的方式就產生了,當然這里介紹的方法並不能替代 Compute Shader,只是某些特定情況下解決問題的方法,下面我們來仔細看這種方法的實現細節。

使用 GPU 加速計算

其實說來也簡單,唯一要用到的就是 RenderTexture。我們可以在 fragment shader 中輸出一個顏色值,這個顏色值就是經過一系列復雜計算得到的結果,而這些計算本來只能在 CPU 中進行,再通過某種方式(Texture、Mesh、Uniform Value)傳給 GPU(這個傳遞的過程意味着一次 overhead,在移動設備上雖然沒有硬件的物理因素,即使是內存共享也意味着內存的拷貝以及圖形對象消耗),這個顏色值最終會被寫入到相機所綁定的 RenderTexture 上,這樣就得到了一張包含了很多數據的 RenderTexture,只是這些數據表現為顏色。然后將這張 RenderTexture 傳給真正用來渲染的着色器,着色器從 RenderTexture 對應位置取出已經計算好的結果值,使用即可。

使用 GPU 加速計算

這是一張尺寸為 128x128 的 RenderTexture,一共存儲了 16384(128x128)個復雜運算的結果,如果將這個計算量交給 CPU 來做的話將會是很大一筆開銷,這些值在每一幀中重新計算出新的結果,就形成了上圖的效果。或許會有這樣的疑問,為什么需要將結果存入 RenderTexture,而不是在 Shader 的需要用到的時候直接計算得到呢。這是因為如果是每次都在 Shader 中直接計算出新的結果,就必須有一個符合其數值變化規律的函數:

y = f(x)

(x,y) = f(t)

不管自變量和結果如何,必須要找到函數 f()。可是我們無法找到這個函數,因為渲染的結果是和場景有交互的,不可能一個公式搞定所有:

粒子碰撞
粒子碰撞

毛發模擬
毛發模擬

 

於是就想到了是否能直接在 GPU 中計算,即加快了計算速度,又避免了傳輸數據的消耗。但又由於 Compute Shader 在移動平台的無效,所以就有了使用 RenderTexture 的這種方法。

大致的概念都說清楚了,下面來看一下實現的細節。也就是如何將數據編碼到 RenderTexture 中的某個像素中,並且如何從對應的像素中讀取數據。

首先當然是創建一個 RenderTexture:

// C#
// 這里使用的是 HALF_FLOAT 格式,因為我需要在這張 RenderTexture 中存儲一個三維空間的坐標
// 如果你不需要,可以設置為 RGB24 即可
rt = new RenderTexture(rtSize, rtSize, 0, RenderTextureFormat.ARGBHalf);
// 關閉 mipmap,使用點采樣,這樣紋理采樣的時候不會受 filter 的影響
rt.useMipMap = false;
rt.generateMips = false;
rt.filterMode = FilterMode.Point;
rt.anisoLevel = 0;
rt.wrapMode = TextureWrapMode.Clamp;
rt.Create();

對創建的 RenderTexture 進行初始化:
// C#
// 新創建的 RenderTexture 中會包含顯存中的垃圾數據,所以我們使用想要的數據對其初始化
initTex = new Texture2D(rtSize, rtSize, TextureFormat.ARGB32, false);
Color[] colors = initTex.GetPixels();
int numColor = colors.Length;
for (int i = 0; i < numColor; ++i)
{
colors[i] = new Color(0, 0, 0);
}
initTex.SetPixels(colors);
initTex.Apply();

Graphics.Blit(initTex, positionsRT);

創建一個相機,用來向 RenderTexture 中填充數據:
// C#
GameObject camGo = new GameObject();
camGo.hideFlags = HideFlags.HideAndDontSave;
Camera cam = camGo.AddComponent();
// 相機默認處於關閉狀態,我們會手動調用相機的渲染
cam.enabled = false;
// 相機的渲染目標為我們剛才創建的 RenderTexture
cam.targetTexture = rt;
// 不要讓相機清除 RenderTexture 上數據,因為每一幀的數據對於下一幀都是有意義的
cam.clearFlags = CameraClearFlags.Nothing;
// 這個相機不需要渲染所有的東西,只渲染我們需要即可
cam.cullingMask = LayerMask.GetMask("MyCullingLayer");

當然要向 RenderTexture 中渲染些東西,並不一定要通過相機:
// C#
// 使用這種方式也是可以的,消耗要比使用相機渲染小一點
// 兩種方法根據需要進行選擇
RenderTexture.active = rt;
Graphics.DrawMeshNow

這里就要開始創建需要渲染 Mesh 了,Mesh 中的每個頂點上的數據都是非常關鍵的,我們會通過程序代碼來創建 Mesh 而不是建模軟件,因為頂點中的數據都有其獨特的意義,比如說 normal 屬性里存儲的並不是真正的法線信息,而是我們自己定義的數據。當然如果 Mesh 中每個頂點屬性中存儲的數據類型完全確定好之后,在 Unity 中實現一個筆刷來讓美術刷出這些數據也就並非是難事,這是后話先不說了。
// C#
List vertices = new List();
List colors = new List();
List uv = new List();
List tangents = new List();
List triangles = new List();

// 這里開始填充頂點數據
// 具體填充什么數據呢,這個根據要渲染成什么模型有關,每個人都會做出不同的選擇
// 比如我們上文中的兩個 Demo,一個是粒子碰撞,一個是毛發模擬,這兩個 Demo 中在 Mesh 中填充的數據都不一樣
// 所以這里暫時跳過,但是通過下面的說明,應該會讓我們更清楚這里需要什么數據

mesh = new Mesh();
mesh.vertices = vertices.ToArray();
mesh.colors = colors.ToArray();
mesh.uv = uv.ToArray();
mesh.tangents = tangents.ToArray();
mesh.triangles = triangles.ToArray();
// 設定新的包圍盒很重要,相機的視錐體裁切都靠它了,我偷了個懶,直接設置為一個很大的包圍盒,實際情況需要根據模型的大小來設置
mesh.bounds = new Bounds(Vector3.zero, new Vector3(9999, 9999, 9999));

在應用程序中所有需要的東西都創建好了,下面開始渲染:
// C#
// 這是 MainCamera 的回調,在 MainCamera 渲染之前,Unity 會自動回調這個函數
void OnPreRender()
{
// 上文說過,要渲染一個模型(向 RenderTexture 填充數據)有兩種方法
// 方法1
// cam 是上文中有代碼創建的相機
// rt 是上文中用代碼創建的 RenderTexture
cam.targetTexture = rt;
cam.RenderWithShader(Shader.Find("FillRenderTextureShader"), null);
// 方法2
if(mat == null)
{
mat = new Material(Shader.Find("FillRenderTextureShader"));
}
RenderTexture oldRT = RenderTexture.active;
RenderTexture.active = rt;
mat.SetPass(0);
Graphics.DrawMeshNow(mesh, Matrix.Identify); // 這里的矩陣,因為我們在 Shader 中並沒有用到,所以直接設置為單位(任意)矩陣
RenderTexture.active = oldRT;
}

至此,所有必要的准備都已經完成,然后就是 Shader,前面所做的一切都是為一步在做准備:
// FillRenderTextureShader
// vertex shader
v2f vert(appdata v)
{
// o.vertex 直接決定了將當前 fragment 輸出到 RenderTexture 上的哪個像素中
// o.vertex.xy 經過投影變換后的值都是在 -1 到 1 之間
// 我們需要知道當前應該輸出到 [-1,1] 之間的哪個值上,這就需要在上文中創建 Mesh 時填充頂點數據時指定好,這里直接讀取即可
// o.vertex.z 這個值我們其實用不到,但是不能隨便設置,因為 OpenGL 是 [-1,1],而 DirectX 是 [0,1],
// 超出這個范圍會被裁切掉,所以要同時兼顧到,設置為 0
// o.vertex.w 這是用來做齊次坐標變換的,將頂點轉換到 Canonical View Volume。簡單來說最終的會將 o.vertex.xy 除以 w,來轉換到齊次裁剪空間坐標系,
// 但是我們不希望進行這個操作,以免破壞了精心計算的 o.vertex.xy,所以設置為 1
o.vertex = ......;

// 這是用來解決平台差異的
// 因為 OpenGL 的紋理坐標 (0,0) 點在左下角,而 DirectX 的紋理坐標 (0,0) 點在左上角
#if UNITY_UV_STARTS_AT_TOP
float scale = -1.0;
#else
float scale = 1.0;
#endif
o.vertex.y *= scale;

// 在這里進行一系列的計算,將計算結果存放到 color 中
// i.color = ...... 這里寫錯了 感謝 hh1551229943 指正
o.color = ......
}

// 注意這里的返回值類型,因為用它表示三維空間中的坐標,所以使用 float
// 同樣 v2f 結構中 color 的類型也要注意
float4 frag(v2f i) : SV_Target
{
return i.color;
}

數據填充好后,如何在 MainCamera 渲染模型的時候將頂點對應的數據從 RenderTexture 中取出來呢,這就非常簡答了:
// Shader
// uv 也是在創建 Mesh 的時候就已經指定好了,直接從頂點數據中拿來用即可
tex2D(_RenderTexture, uv);

以上就是要用到的所有關鍵代碼和主要思路了。粒子碰撞和毛發模擬就是使用的這種方式實現的,讓並行的 GPU 進行了大量的計算,使得 CPU 為零消耗,而 GPU 也只發揮了大概 25% 不到的能力,也就是說 GPU 還能處理很多其他的事情。FPS 也達到了滿幀。 以上數據是在 Instruments 的 GPU Driver 中查看的,使用的測試設備是 Iphone6S。並且在 Metal、OpenGLES3.0、OpenGLES2.0 這三個 Graphics API 下都表現正常。注意實際開發時需要在更多設備上進行測試,並且做好設備不支持時的備選方案。

最后說一個容易被忽視的細節,而這個細節有可能會導致最終出現我們不想要的結果。使用以上方法就意味着對於一張 RenderTexture 的 Texel,同時既從中讀取數據又向其寫入數據,根據我的理解,這就是 [Sampling and Rendering to the Same Texture][link6] 和 [Feedback Loops][link7] 所指的情況。而我的測試中並沒有遇到過,所以先忽略的,可能是測試設備使用了類似文中所提到的 texture_barrier 技術。當然如果真的出現了這種情況也是有方法解決的,我們可以使用兩張 RenderTexture 交替使用,在第一幀中從 A 讀取,寫入 B,第二幀中從 B 讀取寫入 A,類似雙緩沖一樣就能解決這個問題了。

[link6]: https://www.opengl.org/wiki/GLSL_:_common_mistakes
[link7]: https://www.opengl.org/wiki/Framebuffer_Object#Feedback_Loops

來源:游戲蠻牛


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM