游戏对象的双缓冲,什么是一个很好的干净的通用C ++方式?

时间:2010-01-05 20:41:37

标签: c++ oop class operators operator-overloading

这是在C ++中。

所以,我从头开始编写游戏引擎,以获得乐趣并从头开始学习。我想实现的一个想法是让游戏对象状态(一个结构)是双缓冲的。例如,通过保证游戏对象中存储的一致状态(来自上次的数据),我可以让子系统更新新游戏对象数据,同时渲染线程从旧数据渲染。渲染完旧并更新完成后,我可以交换缓冲区并再次执行。

问题是,在尝试尽可能隐藏实现细节的同时,将这个暴露给我的类是什么是一个很好的前瞻性和通用的OOP方式?想知道你的想法和考虑。

我认为可以使用运算符重载,但是如何在缓冲类中为模板化类的成员重载赋值?

例如,我认为这是我想要的一个例子:

doublebuffer<Vector3> data;
data.x=5; //would write to the member x within the new buffer
int a=data.x; //would read from the old buffer's x member
data.x+=1; //I guess this shouldn't be allowed

如果可以,我可以选择启用或禁用双缓冲结构,而无需更改太多代码。

这就是我在考虑的事情:

template <class T>
class doublebuffer{
    T T1;
    T T2;
    T * current=T1;
    T * old=T2;
public:
    doublebuffer();
    ~doublebuffer();
    void swap();
    operator=()?...
};

游戏对象就像这样:

struct MyObjectData{
    int x;
    float afloat;
}

class MyObject: public Node {
    doublebuffer<MyObjectData> data;

    functions...
}

我现在所拥有的是返回指向旧缓冲区和新缓冲区的指针的函数,我猜任何使用它们的类都必须知道这一点。还有更好的方法吗?

7 个答案:

答案 0 :(得分:6)

我最近通过“快照”在引擎盖下使用Copy-On-Write的数据结构以一种通用的方式处理了类似的愿望。我喜欢这个策略的一个方面是,如果你需要它,你可以制作许多快照,或者只是一次一个快照来获得你的“双缓冲区”。

没有出汗过多的实现细节,这里有一些伪代码:

snapshottable<Vector3> data;
data.writable().x = 5; // write to the member x

// take read-only snapshot
const snapshottable<Vector3>::snapshot snap (data.createSnapshot());

// since no writes have happened yet, snap and data point to the same object

int a = snap.x; //would read from the old buffer's x member, e.g. 5

data.writable().x += 1; //this non-const access triggers a copy

// data & snap are now pointing to different objects in memory
// data.readable().x == 6, while snap.x == 5

在您的情况下,您将快照状态并将其传递给渲染。然后,您将允许您的更新对原始对象进行操作。通过readable()使用const访问来读取它不会触发副本...使用writable() 访问时会触发副本。

我在Qt QSharedDataPointer之上使用了一些技巧来做到这一点。它们通过( - &gt;)区分const和非const访问,这样从const对象读取不会触发写入机制上的副本。

答案 1 :(得分:5)

如果我是你,我不会做任何'聪明'的操作员重载。将它用于完全不令人惊讶的东西,尽可能接近本机操作员所做的事情,而不是别的。

不清楚你的方案特别有助于多个写入线程 - 当几个线程读取旧状态并写入相同的新状态,覆盖任何早期的写入时,你怎么知道哪个'获胜'?

但如果它在你的应用程序中是一种有用的技术,那么我将拥有'GetOldState'和'GetNewState'方法,这些方法可以完全清楚发生了什么。

答案 2 :(得分:2)

如果您有多个线程写入,我不确定有效两个状态是否意味着在访问可写状态时不需要任何同步,但是...

我认为以下是一个简单明了的(维护和理解)模式,你可以用很少的开销。

class MyRealState {
  int data1;
  ... etc

  protected:
      void copyFrom(MyRealState other) { data1 = other.data1; }

  public:
      virtual int getData1() { return data1; }
      virtual void setData1(int d) { data1 = d; }
}

class DoubleBufferedState : public MyRealState {
  MyRealState readOnly;
  MyRealState writable;

  public:
      // some sensible constructor

      // deref all basic getters to readOnly
      int getData1() { return readOnly.getData1(); }

      // if you really need to know value as changed by others
      int getWritableData1() { return writable.getData1(); }

      // writes always go to the correct one
      void setData1(int d) { writable.setData1(d); }

      void swap() { readOnly.copyFrom(writable); }
      MyRealState getReadOnly() { return readOnly; }
}

基本上我已经做了类似于你的建议但使用重载的东西。如果你想要小心/偏执我有一个空类,其中虚拟getter / setter方法作为基类,而不是如上所述,因此编译器保持代码正确。

这给你一个readOnly版本的状态,它只会在你调用swap和一个干净的接口时改变,这样调用者在处理状态时可以忽略双缓冲区问题(所有不需要知道旧的和新状态可以处理MyRealState“接口”)或者如果您关心状态之前和之后(可能是imho),您可以向下/需要DoubleBufferedState接口。

清洁代码更容易理解(包括你在内的每个人)并且更容易测试,所以我个人避免操作员超载。

抱歉,对于任何c ++语法错误,我现在都是一个java人。

答案 3 :(得分:2)

游戏状态越大,保持两个副本同步的成本就越高。为每个刻度线创建渲染线程的游戏状态副本同样简单;你将不得不将所有数据从前面的缓冲区复制到后面的缓冲区,所以你不妨在运行时这样做。

您总是可以尝试最小化缓冲区之间的复制量,但是您需要跟踪哪些字段已更改,因此您知道要复制的内容。对于视频游戏引擎的核心而言,这将是一个不那么出色的解决方案,其中性能非常关键。

答案 4 :(得分:1)

也许你甚至想在每个tick中创建一个新的渲染状态。这样你的游戏逻辑就是制作人,你的渲染器就是渲染状态的消费者。旧状态是只读的,可用作渲染和新状态的参考。渲染完成后,将其丢弃。

对于小对象,Flyweight模式可能是合适的。

答案 5 :(得分:1)

你需要做两件事:

  1. 分离对象自身的状态及其与其他对象的关系
  2. 将COW用于对象自己的状态
  3. 为什么?

    出于渲染目的,您只需要“反向版本化”影响渲染的对象属性(如位置,方向等),但不需要对象关系。这将使您摆脱悬空指针,并允许更新游戏状态。 COW(写时复制)应该是1级深度,因为你只需要一个“其他”缓冲区。

    简而言之:我认为运营商重载的选择与此问题完全正交。这只是sintatic糖。无论你编写+ =还是setNewState都是完全不相关的,因为它们占用相同的CPU时间。

答案 6 :(得分:1)

作为一项规则,您应该只在自然时使用运算符重载。如果您正在寻找适合某些功能的操作员,那么这是一个好的迹象,表明您不应该强迫操作员对您的问题进行过载。

话虽如此,您要做的是拥有一个代理对象,该对象将读取和写入事件分派给一对对象中的一个。代理对象经常重载->运算符以提供类似指针的语义。 (你不能超载.。)

虽然你可以将->的两个重载区分为const,但我会提醒您注意这一点,因为它对于读取操作是有问题的。通过const或非const引用引用对象而不是动作是实际读取还是写入来选择重载。这一事实使得该方法容易出错。

您可以做的是从存储中拆分访问并创建一个多缓冲类模板和一个访问相应成员的缓冲区访问器模板,使用operator->来理解语法。

此类存储模板参数T的多个实例并存储偏移量,以便各种访问者可以通过相对偏移量检索前/活动缓冲区或其他缓冲区。使用n == 1的模板参数意味着只有一个T实例,并且有效禁用了多缓冲。

template< class T, std::size_t n >
struct MultiBuffer
{
    MultiBuffer() : _active_offset(0) {}

    void ChangeBuffers() { ++_active_offset; }
    T* GetInstance(std::size_t k) { return &_objects[ (_active_offset + k) % n ]; }

private:
    T _objects[n];
    std::size_t _active_offset;
};

该类抽象缓冲区选择。它引用了MultiBuffer via引用,因此您必须保证其生命周期短于它使用的MultiBuffer。它有自己的偏移量,它被添加到MultiBuffer偏移量,以便不同的BufferAccess可以引用数组的不同成员(例如,模板参数n = 0用于前缓冲区访问,1用于后缓冲区访问)。

请注意,BufferAccess偏移量是成员而不是模板参数,因此对BufferAccess个对象进行操作的方法不仅仅依赖于某个特定偏移量或必须是模板本身。我已经将对象计数为模板参数,因为从您的描述中它可能是一个配置选项,这为编译器提供了最佳的优化机会。

template< class T, std::size_t n >
class BufferAccess
{
public:
    BufferAccess( MultiBuffer< T, n >& buf, std::size_t offset )
        : _buffer(buf), _offset(offset)
    {
    }

    T* operator->() const
    {
        return _buffer.GetInstance(_offset);
    }

private:
    MultiBuffer< T, n >& _buffer;
    const std::size_t _offset;
};

将所有内容与测试类放在一起,请注意,通过重载->,我们可以轻松地从BufferAccess实例调用测试类的成员,而BufferAccess不需要任何知识测试班有哪些成员。

单个和双重缓冲之间的单个更改也不会切换。如果您能找到需要,三重缓冲也很容易实现。

class TestClass
{
public:
    TestClass() : _n(0) {}

    int get() const { return _n; }
    void set(int n) { _n = n; }

private:
    int _n;
};

#include <iostream>
#include <ostream>

int main()
{
    const std::size_t buffers = 2;

    MultiBuffer<TestClass, buffers> mbuf;

    BufferAccess<TestClass, buffers> frontBuffer(mbuf, 0);
    BufferAccess<TestClass, buffers> backBuffer(mbuf, 1);

    std::cout << "set front to 5\n";
    frontBuffer->set(5);

    std::cout << "back  = " << backBuffer->get() << '\n';

    std::cout << "swap buffers\n";
    ++mbuf.offset;

    std::cout << "set front to 10\n";
    frontBuffer->set(10);

    std::cout << "back  = " << backBuffer->get() << '\n';
    std::cout << "front = " << frontBuffer->get() << '\n';

    return 0;
}