Introductionhtml
如今不少遊戲引擎都在使用一種稱爲「多線程渲染渲染器」的特殊渲染系統。多線程在一段時間內已經變得很是的普及了,可是究竟什麼是多線程渲染器,它又是如何工做的呢?在這篇文章裏,我將解釋這些問題,並將實現一個簡單的多線程渲染的框架。ios
Why Use Threads at All算法
其實這是一個比較簡單的問題,假如你是一個飯店的老闆,你的飯店有15名僱員,然而你僅僅只給其中的一個僱員分配工做,這個僱員必須接待顧客,等待顧客點餐,準備食物,打掃衛生等等,而其餘的14名員工只是坐在周圍等着發工資。另外一面,顧客們不得不爲食物等待好久,因此很是的失望。做爲飯店的老闆,這既浪費了金錢,又浪費了時間。奇怪的是,不少軟件工程師都是在以這種方式寫代碼的。windows
大部分的現代CPU都包含4到8個核,每一個核又包含api
然而大部分的軟件工程設計的代碼都是單核運行的,剩下的3-7個CPU核什麼都不作。爲了獲得更高的性能,咱們須要思考如何把咱們的解決方案分割成多個並行的任務,這樣就可讓每一個任務跑在不一樣的核上。這會是一個不小的挑戰,要作到科學分割任務,你須要對算法所需的輸入和輸出數據瞭如指掌,同時你也要思考如何將這些在各個核上獨立計算的結果最終合併成你想要的東西。數組
What is a Multi-threaded Renderer緩存
一個多線程渲染器一般至少由兩個線程組成。一個線程稱爲「仿真線程」,它負責處理gameplay邏輯,物理等。基於更新的遊戲狀態,圖形API命令被放入一個隊列,而後被另外一個稱爲「渲染線程」的線程使用。渲染線程一般擁有圖形設備和上下文,並負責底層圖形API命令的調用,從而提交工做給GPU去完成。安全
How can a multi-threaded renderer increase performance數據結構
當你每次調用底層圖形API時,顯卡驅動都有不少工做要作,驅動必須驗證各類你傳進圖形API的參數,避免非法值致使GPU崩潰。它還要負責加載紋理貼圖,頂點buffers,還有其餘送往或者來自GPU的資源。全部這些驅動乾的工做都須要花費時間,這就意味着顯卡驅動必須阻塞執行圖形API命令的線程直到當前命令執行結束。多線程
然而,你即將渲染的就是一些改變的遊戲狀態。換句話說,你要常常處理新的輸入狀態,好比遊戲控制器,AI更新數據,物理更新數據,聲音數據等,而後渲染一些東西來反映這些遊戲狀態的變化。不少時候,你的AI代碼不須要知道你的GPU正在渲染什麼,這些AI,物理,聲音以及所有的遊戲狀態相對於renderer都是獨立的,它們都是以輸入的方式供renderer使用。
因此說,更新一些AI邏輯,而後當即阻塞線程去等待GPU完成渲染是一種極大的浪費,而將渲染命令排列進一個隊列中而後與仿真線程並行的執行是一種更高效的方式。這樣咱們就能夠在等待上一幀畫面渲染到屏幕時,並行地開始下一幀數據的仿真。
然而,若是你不當心,仿真線程和渲染線程很快就會不一樣步。假如你在玩第一人稱射擊遊戲,做爲玩家,你的大腦是根據屏幕上渲染的最後一幀畫面來決定你須要操做那個按鍵,若是這幀場景內容很是複雜,那能夠確定的是渲染線程比仿真線程須要花費更多的時間,這種狀況下,遊戲AI則會有更多的時間來把你幹倒,由於仿真線程執行的比渲染線程更快。因此,當使用多線程渲染時,須要某種同步機制來防止仿真線程比渲染線程快出來不少。Unity的機制是仿真第N幀數據時並行地渲染第N幀畫面,而後Unity會當即仿真第N+1幀數據,緊接着Unity將會等待第N幀徹底渲染完成後再繼續執行。因此,確保優化你的渲染算法以及shader,使它們運行的足夠快,從而減小它們拖仿真線程的後腿是很是重要的。
How is a multi-threaded renderer implemented
一般一個支持多個圖形API(DirectX11/Vulkan/OpenGL/etc)的跨平臺的遊戲引擎,都會有一個抽象的上層圖形API,這些上層的圖形API看起來跟DirectX device context的APIs很像,這些抽象圖形API的調用最終會轉化爲底層的圖形API調用。這裏有一點注意,底層API一旦調用就直接執行了,而使用多線程渲染器以後,全部的底層API的調用都會被延遲。
不管仿真線程跑在哪一個CPU核上,該核都被認爲是主CPU核,咱們能夠用其餘的子CPU核去執行圖形API的代碼。仿真線程會把圖形渲染相關的工做放入隊列中,讓子CPU核去執行。而且,子CPU核直到執行完前一個任務以後纔會去執行新的任務。這個將圖形渲染相關工做放入和取出隊列的操做,通常是由一個稱之爲Ring Buffer或者Circular Buffer的數據結構來管理的。Ring Buffer是用一個常規的循環數組實現的隊列,當數組沒有空間再存放信息時,只需循環回數組的第一個元素便可。因此你永遠不須要分配更多的內存。在寫多線程代碼時,Ring Buffer是一個很是有用的數據結構。它容許你以一種安全的方式從不一樣的線程插入和彈出隊列。這是由於仿真線程操做的是數組的一個獨有的索引,而渲染線程操做的是數組的另外一個索引。並且你也能夠寫出一個線程安全的無鎖Ring Buffer,無鎖的Ring Buffer能夠進一步提高程序的性能。當一個上層圖形API在仿真線程被調用時,一個圖形命令數據包就會被插入Ring Buffer。當渲染線程完成它前一個渲染指令後,它會從Ring Buffer中取出一個新的指令並執行它。
下面就是一個多線程渲染的大體框架,它沒有包含所有的代碼,只是示意了一個多線程渲染系統時如何工做的:
#include <iostream> #include <thread> #include <atomic> #include <vector>
using namespace std; // Check out the following links for more information on ring buffers. //http://www.mathcs.emory.edu/~cheung/Courses/171/Syllabus/8-List/array-queue2.html //http://wiki.c2.com/?CircularBuffer
//https://preshing.com/20130618/atomic-vs-non-atomic-operations/
//https://www.daugaard.org/blog/writing-a-fast-and-versatile-spsc-ring-buffer/
template <typename T>
class RingBuffer { private: int maxCount; T* buffer; atomic<int> readIndex; atomic<int> writeIndex; public: RingBuffer() : maxCount(51), readIndex(0), writeIndex(0) { buffer = new T[maxCount]; memset(buffer, 0, sizeof(buffer[0]) * maxCount); } RingBuffer(int count) : maxCount(count+1), buffer(NULL), readIndex(0), writeIndex(0) { buffer = new T[maxCount]; memset(buffer, 0, sizeof(buffer[0]) * maxCount); } ~RingBuffer() { delete[] buffer; buffer = 0x0; } inline void Enqueue(T value) { // We don't want to overwrite old data if the buffer is full // and the writer thread is trying to add more data. In that case, // block the writer thread until data has been read/removed from the ring buffer.
while (IsFull()) { this_thread::sleep_for(500ns); } buffer[writeIndex] = value; writeIndex = (writeIndex + 1) % maxCount; } inline bool Dequeue(T* outValue) { if (IsEmpty()) return false; *outValue = buffer[readIndex]; readIndex = (readIndex + 1) % maxCount; return true; } inline bool IsEmpty() { return readIndex == writeIndex; } inline bool IsFull() { return readIndex == ((writeIndex + 1) % maxCount); } inline void Clear() { readIndex = writeIndex = 0; memset(buffer, 0, sizeof(buffer[0]) * maxCount); } inline int GetSize() { return abs(writeIndex - readIndex); } inline int GetMaxSize() { return maxCount; } }; struct GfxCmd { public: virtual void Invoke() {}; }; struct GfxCmdSetRenderTarget : public GfxCmd { public: void* resourcePtr; GfxCmdSetRenderTarget(void* resource) : resourcePtr(resource) {} void Invoke() { // Invoke ID3D11DeviceContext::OMSetRenderTargets method here... //https://docs.microsoft.com/en-us/windows/desktop/api/d3d11/nf-d3d11- id3d11devicecontext-omsetrendertargets
printf("%s(%p);\n", name, resourcePtr); } private: const char* name = "GfxCmdSetRenderTarget"; }; struct GfxCmdClearRenderTargetView : public GfxCmd { public: int r, g, b; GfxCmdClearRenderTargetView(int _r, int _g, int _b) : r(_r), g(_g), b(_b) {} void Invoke() { // Invoke ID3D11DeviceContext::ClearRenderTargetView method method here... //https://docs.microsoft.com/en-us/windows/desktop/api/d3d11/nf-d3d11-id3d11devicecontext-clearrendertargetview
printf("%s(%d, %d, %d);\n", name, r, g, b); // Pretend this command is requiring the render thread // to do a lot of work.
this_thread::sleep_for(250ms); } private: const char* name = "GfxCmdClearRenderTargetView"; }; struct GfxCmdDraw : public GfxCmd { public: int topology; int vertCount; GfxCmdDraw(int _topology, int _vertCount) : topology(_topology), vertCount(_vertCount) {} void Invoke() { // Invoke ID3D11DeviceContext::DrawIndexed method method here... //https://docs.microsoft.com/en-us/windows/desktop/api/d3d11/nf-d3d11- id3d11devicecontext-drawindexed
printf("%s(%d, %d);\n", name, topology, vertCount); } private: const char* name = "GfxCmdDraw"; }; void UpdateSimulationThread(RingBuffer<GfxCmd*>& gfxCmdList) { // Update gameplay here. // Determine what to draw based on the new game state below. // The graphics commands will be queued up on the render thread // which will execute the graphics API (I.E. OpenGL/DirectX/Vulcan/etc) calls.
gfxCmdList.Enqueue(new GfxCmdSetRenderTarget{ (void*)0x1 }); gfxCmdList.Enqueue(new GfxCmdClearRenderTargetView{ 255, 0, 245 }); gfxCmdList.Enqueue(new GfxCmdDraw{ 1, 10 }); } void UpdateRenderThread(RingBuffer<GfxCmd*>& gfxCmdList) { GfxCmd* gfxCmd = 0x0; if (gfxCmdList.Dequeue(&gfxCmd)) { gfxCmd->Invoke(); delete gfxCmd; } } void GameLoop() { RingBuffer<GfxCmd*> gfxCmdList(3); atomic<int> counter = 0; atomic<bool> quit = false; // Run this indefinitely...
while (1) { quit = false; counter = 0; gfxCmdList.Clear(); thread simulationThread = thread([&gfxCmdList, &counter, &quit] { UpdateSimulationThread(gfxCmdList); quit = true; }); thread renderThread = thread([&gfxCmdList, &quit] { // Continue to read data from the ring buffer until it is both empty // and the simulation thread is done submitting new items into the ring buffer.
while (!(gfxCmdList.IsEmpty() && quit)) { UpdateRenderThread(gfxCmdList); } }); // Ensure that both the simulation and render threads have completed their work.
simulationThread.join(); renderThread.join(); cout << "---\n"; } } int main(int argc, char** argv[]) { GameLoop(); return 0; }
原文連接:http://xdpixel.com/how-a-multi-threaded-renderer-works/