我目前正在为我的 C++ 多线程游戏引擎开发世界管理系统。我现在面临的问题是在主线程和渲染线程之间同步世界数据。
目前我的世界管理架构如下:
主线程(20hz):
WorldManager
└─ worlds
└─ World
└─ objects
└─ Object
├─ id
├─ position
├─ rotation
└─ Mesh
└─ path to .obj
渲染线程(160hz):
RenderableWorldManager
└─ renderableWorlds
└─ RenderableWorld
├─ objects
│ └─ RenderableObject
│ ├─ id
│ ├─ position
│ ├─ rotation
│ └─ Mesh
│ └─ mesh data
├─ vertex buffer
└─ index buffer
所有逻辑都存储在主线程中,所有用于渲染的数据都存储在渲染线程中。对象数据可以在每次更新时发生变化。我的目标是创建线程安全的世界管理系统,并将部分数据存储在主线程和渲染线程上。我的问题是如何在主线程和渲染线程之间正确同步数据而不复制世界或使用
std::mutex
?
我不想使用
std::mutex
,因为我使用 Vulkan
进行渲染,所以我需要手动管理我的顶点和索引数据。问题是 std::mutex
锁定主线程,直到渲染线程完成更新缓冲区,这破坏了多线程的整个目的。
我当前的方法是三重缓冲请求系统。这种方法没有什么大问题。
std::swap
不是线程安全的。我需要使用 std::shared_ptr
,以便请求不会超出范围。我正在使用 World*
,它不是线程安全的。
实现我的目标的最佳方法是什么?游戏行业通常是如何实现的?
我不确定这是否能解决您的整个问题,但我喜欢使用一个技巧来为两个线程提供对对象的合理线程安全访问(其中一个线程正在写入对象,另一个线程正在从中读取) )是将对象包装在无锁环形缓冲区容器中,如下所示:
#include <atomic>
/** This class is useful as a safe, lock-free way to set a non-trivial value in
* one thread and read that value from a different thread.
*
* It works by storing newly passed-in values to different locations, so that when the
* value is updated by the writing-thread, it writes to a different memory location
* than the one that the reading-thread might be in the middle of reading from.
* That way, the reading-thread's "old" copy of the value is in no danger of being
* modified while the reading-thread is in the middle of using it.
*
* @tparam T the type of object to allow atomic access to.
* @tparam ATOMIC_BUFFER_SIZE the number of slots we should keep in our buffer to use for n-way buffering.
* Defaults to 8. Must be a power of two.
*/
template<typename T, uint32_t ATOMIC_BUFFER_SIZE=8> class AtomicValue
{
public:
/** Default constructor. Our value will be default-initialized. */
AtomicValue() : _readIndex(0), _writeIndex(0) {/* empty */}
/** Explicit constructor.
* @param val the initial value for our held value
*/
AtomicValue(const T & val) : _readIndex(0), _writeIndex(0) {_buffer[_readIndex] = val;}
/** Returns a copy of the current state of our held value */
T GetValue() const {return _buffer[_readIndex];} // & ATOMIC_BUFFER_MASK isn't necessary here!
/** Returns a read-only reference to the current state of our held value.
* @note that this reference may not remain valid for long, so if you call this
* method, be sure to read any data you need from the reference quickly.
*/
const T & GetValueRef() const {return _buffer[_readIndex];}
/** Attempts to set our held value to a new value in a thread-safe fashion.
* @param newValue the new value to set
* @returns true on success, or false if we couldn't perform the set because our internal buffer-queue lost too many races in a row.
*/
bool SetValue(const T & newValue)
{
uint32_t oldReadIndex = _readIndex;
while(1)
{
const uint32_t newWriteIndex = (++_writeIndex & ATOMIC_BUFFER_MASK);
if (newWriteIndex == oldReadIndex) return false; // out of buffer space!
_buffer[newWriteIndex] = newValue;
const bool casSucceeded = _readIndex.compare_exchange_strong(oldReadIndex, newWriteIndex, std::memory_order_release, std::memory_order_relaxed);
if (casSucceeded) break;
}
return true;
}
/** Returns the size of our internal values-array, as specified by our template argument */
uint32_t GetNumValues() const {return ATOMIC_BUFFER_SIZE;}
private:
static const uint32_t ATOMIC_BUFFER_MASK = ATOMIC_BUFFER_SIZE-1;
std::atomic<uint32_t> _readIndex; // cycles from 0 through (ATOMIC_BUFFER_SIZE-1)
std::atomic<uint32_t> _writeIndex; // increments continuously towards max uint32_t value, then rolls over to zero and repeats
enum {TestPowerOfTwoValue = ATOMIC_BUFFER_SIZE && !(ATOMIC_BUFFER_SIZE&(ATOMIC_BUFFER_SIZE-1))};
static_assert(TestPowerOfTwoValue, "AtomicValue template's ATOMIC_BUFFER_SIZE template-parameter must be a power of two");
T _buffer[ATOMIC_BUFFER_SIZE];
};
您需要决定在对象树中的哪个位置值得用这些对象包装对象;在一种极端情况下,您可以只实例化一个
AtomicValue<World>
而不更改任何其他内容,但缺点是每次需要进行更改时都会复制整个世界。
在另一个极端,您可以将层次结构中的每个对象包装在其自己单独的
AtomicValue<TheObjectType>
中,并且您可以独立更新层次结构中的任何值,但这可能太冗长,也可能意味着您的读取线程看到“撕裂”,其中树中的某些对象已(安全)更新到新状态,而其他对象仍处于旧状态。撕裂是否有问题取决于程序的特定逻辑。