【问题标题】:Double Buffering for Game objects, what's a nice clean generic C++ way?游戏对象的双缓冲,什么是干净的通用 C++ 方式?
【发布时间】:2011-01-01 19:27:23
【问题描述】:

这是用 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...
}

我现在拥有的是返回指向旧缓冲区和新缓冲区的指针的函数,我猜任何使用它们的类都必须意识到这一点。有没有更好的办法?

【问题讨论】:

  • 为了清晰起见进行小改动
  • Gary:运算符重载是一个糟糕的指称选择。您不是在做一个公认的运算符的变体,而是在做一些相当独特的事情。您需要一个记录解决方案唯一性的函数调用。
  • 当人们问他们是否应该使用运算符重载时,通常是因为他们有一个非常聪明的想法。真正聪明的想法通常涉及将它们用于明显用途之外的其他用途,这通常意味着无法维护的脆弱代码。因此,通常的答案是“不!!”。 Stroustrup 成功地将 &lt;&lt;&gt;&gt; 用于 I/O 流,但那是很久以前的事了。
  • Gary:我很欣赏你的所作所为,但 operator+() 并不是最好的选择。您没有以任何方式“添加”。
  • @David Thornley - 他是否侥幸逃脱尚有争议;由于过度使用&lt;&lt;&gt;&gt;,我避免像瘟疫一样使用iostream。

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


【解决方案1】:

如果我是你,我不会对运算符重载做任何“聪明”的事情。将它用于完全不令人惊讶的事情,这些事情尽可能接近原生操作员所做的事情,仅此而已。

不太清楚您的方案是否特别有助于多个写入线程 - 当多个线程读取旧状态并写入相同的新状态时,您如何知道哪个“获胜”,覆盖任何先前的写入?

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

【讨论】:

  • 谢谢,是的,我稍后必须解决多个写作线程。当我遇到疯狂的物理学之类的事情时,我只想让它变得简单,但是这个问题不是独立于这个数据组织吗?
【解决方案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 方法的空类作为基类,而不是像上面那样,所以编译器会保持代码正确。

这为您提供了一个只读版本的状态,该版本仅在您调用 swap 时才会改变,以及一个干净的接口,调用者可以在处理状态时忽略双缓冲区问题(所有不需要了解旧和新状态可以处理 MyRealState“接口”),或者如果您关心之前和之后的状态(这可能是恕我直言),您可以向下转换/要求 DoubleBufferedState 接口。

干净的代码更容易被理解(包括你在内的每个人)并且更容易测试,所以我个人会避免运算符重载。

对于任何 c++ 语法错误,我很抱歉,我现在有点 java 人了。

【讨论】:

  • 谢谢,我想这就是我正在寻找的东西,也许是为了节省一些重复,我可以使用来自我所有各种状态对象的模板继承。
  • 不过,我遇到的问题是我想要一种通用的方法来做到这一点。我所有的数据结构可能不一定有 getdata1() 函数。
【解决方案3】:

我最近通过“快照”一个在后台使用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 之上使用了一些技巧来做到这一点。它们通过 (->) 区分 const 和非 const 访问,这样从 const 对象读取不会触发写入机制的复制。

【讨论】:

  • ooo,很有趣,我一定会调查的,谢谢。
  • 听起来也可以节省一些内存
  • 您可以通过多种方式进行写时复制,但如果您对使用 QSharedDataPointer 的线程安全解决方案感兴趣,我正在建设的项目是开源的。一些细节中的魔鬼在 snapshottable.h:gitorious.org/thinker-qt/thinker-qt/blobs/master/include/…
  • 谢谢,我去看看。我没有用过 Qt,但它肯定在我的清单上 :-)。
  • 我能想到的与真正的双/三缓冲相比,这种方法的唯一缺点是由于旧的分配状态被丢弃而不是重用而导致频繁的分配和释放。也许 QSharedData 或 QSharedDataPointer 的衍生物可以解决这个问题,也许使用placement new,是合适的。
【解决方案4】:

您的游戏状态越大,保持两个副本同步的成本就越高。每次为渲染线程创建一个游戏状态副本同样简单;无论如何,您都必须将所有数据从前端缓冲区复制到后端缓冲区,因此您不妨即时执行此操作。

您总是可以尽量减少缓冲区之间的复制量,但是您需要跟踪哪些字段已更改,以便知道要复制什么。在性能非常关键的视频游戏引擎的核心中,这将是一个不太出色的解决方案。

【讨论】:

  • 不必明确地保持它们同步。例如,我可以让我的物理引擎从所有旧缓冲区中读取......执行它的操作,然后写入新缓冲区。在我的游戏循环中更改对象的第一件事应该是进行足够的计算以使它们保持同步。
【解决方案5】:

也许您甚至想在每个滴答声中创建一个新的渲染状态。这样,您的游戏逻辑是生产者,而您的渲染器是渲染状态的消费者。旧状态是只读的,可以作为渲染和新状态的参考。渲染后,您将其处理掉。

对于小对象,Flyweight 模式可能比较合适。

【讨论】:

  • 嗯,这是一个有趣的想法。现在我的主要数据结构是场景图,节点拥有数据。如果我理解正确,您的建议将需要将数据分离到不同的数据结构中并将节点链接到其中,但这也可能带来一些优势。我没有考虑过。
【解决方案6】:

你需要做两件事:

  1. 分离对象自身的状态及其与其他对象的关系
  2. 将 COW 用于对象自身的状态

为什么?

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

简而言之:我认为运算符重载的选择与这个问题完全正交。这只是sintatic糖。是否编写 += 或 setNewState 完全无关紧要,因为两者都使用相同的 CPU 时间。

【讨论】:

  • 我参加聚会有点晚了,但你确定对象关系不会影响渲染吗?那么可变的场景图呢?假设渲染线程遍历场景图以确定所需的渲染操作,图中的关系可以在帧之间更改(例如添加/删除对象)并因此影响渲染。唯一的替代方法是更新线程在一个后缓冲列表中枚举可渲染的场景对象,通过将渲染工作转移到更新线程来降低并行度。
  • 好点。这真的取决于它是一种什么样的游戏以及对象之间可能存在的关系。
【解决方案7】:

通常,您应该只在自然的情况下使用运算符重载。如果您正在为某些功能寻找合适的运算符,那么这是一个好兆头,表明您不应该强迫运算符重载您的问题。

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

虽然您可以有两个 -&gt; 重载,由 const-ness 区分,但我会提醒您不要这样做,因为这对于读取操作是有问题的。重载的选择取决于对象是通过 const 引用还是非常量引用,而不是操作是实际读取还是写入。这一事实使该方法容易出错。

您可以做的是从存储中分离访问并创建一个多缓冲区类模板和一个访问适当成员的缓冲区访问器模板,使用operator-&gt; 以简化语法。

此类存储模板参数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,因此您必须保证它的生命周期比它使用的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;
};

将它们与测试类放在一起,注意通过重载-&gt;,我们可以轻松地从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;
}

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-12-14
    • 2019-12-19
    • 1970-01-01
    • 2015-02-21
    • 2020-06-28
    • 1970-01-01
    相关资源
    最近更新 更多