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/