基於Compute Shader的生命遊戲
來自專欄蒸汽機車53 人贊了文章
非常抱歉重新發布這篇文章,今天早上我起床之後重新閱讀了自己的代碼——
這什麼傻逼東西!
最主要的是代碼寫的有問題,沒有解釋明白,而且還沒有附上參考資料。故下午接SDK測試之餘抽空把演示、線程組重新理了一次,再次寫一次這篇文章。污染大家的時間線,真是不好意思。
什麼是生命遊戲
生命遊戲是英國數學家約翰·何頓·康威在1970年發明的細胞自動機。在二維的矩形世界中,每個方格被視為一個細胞,該細胞的下一個狀態取決於周圍鄰居的當前狀態。假如鄰居活著的細胞過多,那麼這個細胞下一刻會因為資源匱乏而死;假如活著的鄰居過少,則細胞會因為過度孤獨而死;而適當數量的鄰居又能繁衍出新的細胞。
本文使用的規則是:
1.假如本細胞在這一刻活著,那麼若鄰居細胞存活數少於2或大於等於4,則本細胞下一刻死亡。
2.假如本細胞在這一刻死亡,那麼若鄰居細胞存活數等於3,則本細胞下一刻為活著。
通過簡單的規則,二維矩形上會演變出各式各樣的生命群落。但是大部分情況下,它們最終會維持穩定,只有少數經過精心設計的圖形才能長期變幻、擴張,形成宏觀上令人震撼的群落。具體內容參見【1】
C#版本的生命遊戲
我們根據生命遊戲的概念,可以很快編寫出生命遊戲的原型。演示可以在工程中名為cpu的場景上找到。不過,其運行效率著實令人頭大。
Compute Shader版本的生命遊戲
當我們思索生命遊戲的邏輯的時候,不難發現,生命遊戲大部分的運算量實際上都是一個並行的任務,而且該並行任務並沒有順序要求,每個像素需要遍歷其周圍的鄰居,對於什麼時候遍歷並不關心。因此我們會敏銳想到Compute Shader,通過將計算任務轉嫁到GPU,利用GPU的針對並行任務的設計來快速完成計算任務。
但這裡又存在一個問題,那就是生命遊戲中存在一個串列的邏輯——狀態的變化必須發生在統計鄰居之後——這個邏輯表明我們需要將生命遊戲拆分成兩個流程,也就是兩個Kernel。
Compute Shader在Unity中的使用,可以參考【2】中所述,在此不再贅述。
// kernel is GameOfLifeUpdate#pragma kernel GameOfLifeUpdate#pragma kernel UpdateNextstruct Cell{ float4 xynn; //四通道信息為:x軸位置、y軸位置、當前是否活著,下一刻是否活著(活著=1)};RWStructuredBuffer<Cell> Cells;//用來輸出給C#進行展示RWTexture2D<float4> GameTex;//定義活著和死了的顏色float4 AliveColor;float4 DeadColor;//定義邊界float4 BorderVec;bool IsCellAlive(int x, int y){ if (x < BorderVec.x || x >= BorderVec.y || y < BorderVec.z || y >= BorderVec.w) return false; return Cells[x + y * BorderVec.y].xynn.z == 1;}//展示並統計[numthreads(32, 32, 1)]void GameOfLifeUpdate (uint3 id : SV_DispatchThreadID){ int product = id.x + id.y * BorderVec.y; Cell curCell = Cells[product]; //展示上一回合的生命結果 if (curCell.xynn.z == 1) GameTex[id.xy] = AliveColor; else GameTex[id.xy] = DeadColor; //計算鄰居存活數 int liveNum = 0; if (IsCellAlive(id.x-1, id.y-1)) liveNum++; if (IsCellAlive(id.x-1, id.y)) liveNum++; if (IsCellAlive(id.x-1, id.y+1)) liveNum++; if (IsCellAlive(id.x+1, id.y-1)) liveNum++; if (IsCellAlive(id.x+1, id.y)) liveNum++; if (IsCellAlive(id.x+1, id.y+1)) liveNum++; if (IsCellAlive(id.x, id.y-1)) liveNum++; if (IsCellAlive(id.x, id.y+1)) liveNum++; //生命遊戲規則 if (curCell.xynn.z == 1) { if (liveNum < 2) curCell.xynn.w = 0; else if (liveNum < 4) curCell.xynn.w = curCell.xynn.z; else curCell.xynn.w = 0; } else if (liveNum == 3) { curCell.xynn.w = 1; } Cells[product] = curCell;}//當前狀態變更[numthreads(32, 32, 1)]void UpdateNext(uint3 id : SV_DispatchThreadID){ int product = id.x + id.y * BorderVec.y; Cells[product].xynn.z = Cells[product].xynn.w;}
將遊戲的邏輯轉移到Compute Shader中之後,我們在C#中只需要做兩件事:初始化、以及每幀調用一次。
using System.Collections;using System.Collections.Generic;using UnityEngine;public struct GPUCell{ public Vector4 xynn; public GPUCell(float x, float y, float z, float w) { xynn = new Vector4(x, y, z, w); }}public class SendTex : MonoBehaviour { public ComputeShader GoLComputeShader; public Texture InputTex; public Color AliveColor; public Color DeadColor; public Renderer Shower; [SerializeField] private RenderTexture GameTex; private ComputeBuffer CellBuffer; private bool needRendering = false; int kernelIdx; int updateIdx; #region Mono void OnGUI() { if (GUILayout.Button("開始生命遊戲")) { Init(); needRendering = true; } } void Update () { if (!needRendering) return; //運算 GoLComputeShader.Dispatch(kernelIdx, Mathf.CeilToInt(InputTex.width / 32f), Mathf.CeilToInt(InputTex.height / 32f), 1); GoLComputeShader.Dispatch(updateIdx, Mathf.CeilToInt(InputTex.width / 32f), Mathf.CeilToInt(InputTex.height / 32f), 1); } #endregion #region Logic private void Init() { //找到核心地址 kernelIdx = GoLComputeShader.FindKernel("GameOfLifeUpdate"); updateIdx = GoLComputeShader.FindKernel("UpdateNext"); InitTexture(); InitColor(); InitBuffer(); Shower.material.mainTexture = GameTex; } private void InitColor() { GoLComputeShader.SetVector("AliveColor", AliveColor); GoLComputeShader.SetVector("DeadColor", DeadColor); } private void InitTexture() { //傳入展示和邏輯用紋理 GameTex = new RenderTexture(InputTex.width, InputTex.height, 24); GameTex.enableRandomWrite = true; GameTex.filterMode = FilterMode.Point; GameTex.Create(); GoLComputeShader.SetTexture(kernelIdx, "GameTex", GameTex); //傳入邊界 GoLComputeShader.SetVector("BorderVec", new Vector4(0, InputTex.width, 0, InputTex.height)); } private void InitBuffer() { int count = InputTex.width * InputTex.height; CellBuffer = new ComputeBuffer(count, 16); GPUCell[] values = new GPUCell[count]; for (int y = 0 ; y < InputTex.height; y++) for (int x = 0; x < InputTex.width; x++) { Color c = ((Texture2D)InputTex).GetPixel(x, y); GPUCell cell = new GPUCell(x, y, c.a != 0 ? 1 : 0, c.a != 0 ? 1 : 0); values[x + y * InputTex.width] = cell; } //裝填數據 CellBuffer.SetData(values); GoLComputeShader.SetBuffer(kernelIdx, "Cells", CellBuffer); GoLComputeShader.SetBuffer(updateIdx, "Cells", CellBuffer); } #endregion}
不得不提到之前走的一個彎路,那就是C#里的Dispatch所傳入的XYZ值,是線程組的數量,Shader中的numthreads的XYZ值,是每組的線程數量。為了保證線程們能遍歷到每個像素和數據,必須在C#里予以自適應。
關於線程和線程組更多的內容,請參考【3】。
通過轉嫁計算任務,我們在面對8192*8192規模的圖片時,亦能得到70幀的高效率運行——當然,除了最開始向GPU輸出數據時的卡頓。
https://www.zhihu.com/video/1019292992821149696
結語
本文嘗試利用Compute Shader解決了生命遊戲規模上升時帶來的效率問題。除此之外,Compute Shader還有更多的應用,比如加速粒子計算等。
當然本文的可視化還略顯粗糙,若想對遊戲做更多風格客制化,例如顯示時附帶細線網格、細胞被展示為小球或立方體等,可以配合Geometry Shader,實現效果的同時,保證運算的高效率。
Github地址:
https://github.com/noobdawn/GameOfLife-Unity-ComputeShader參考文獻
【1】生命遊戲:5類最簡單而又嘆為觀止的圖形_生命遊戲吧_百度貼吧
【2】Unity3D Compute Shader Introduction & Tutorial
【3】numthreads
推薦閱讀: