多线程渲染框架

Introduction

现在很多游戏引擎都在使用一种称为“多线程渲染渲染器”的特殊渲染系统。多线程在一段时间内已经变得非常的普及了,但是究竟什么是多线程渲染器,它又是如何工作的呢?在这篇文章里,我将解释这些问题,并将实现一个简单的多线程渲染的框架。

 

Why Use Threads at All

其实这是一个比较简单的问题,假如你是一个饭店的老板,你的饭店有15名雇员,然而你仅仅只给其中的一个雇员分配工作,这个雇员必须接待顾客,等待顾客点餐,准备食物,打扫卫生等等,而其他的14名员工只是坐在周围等着发工资。另一面,顾客们不得不为食物等待很久,所以非常的失望。作为饭店的老板,这既浪费了时间,又浪费了时间。奇怪的是,很多软件工程师都是在以这种方式写代码的。

大部分的现代CPU都包含4到8个核,每个核又包含

  • 一个ALU
    • 处理整数数学计算
  • 一个FPU
    • 处理浮点数数据计算
  • L1和L2级缓存
    • 提供快速访问内存

然而大部分的软件工程设计的代码都是单核运行的,剩下的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 or 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/