代码之家  ›  专栏  ›  技术社区  ›  gtrak

双缓冲的游戏对象,什么是干净整洁的通用C++方式?

  •  8
  • gtrak  · 技术社区  · 15 年前

    这是在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 回复  |  直到 15 年前
        1
  •  6
  •   HostileFork says dont trust SE    15 年前

    我最近以一种广义的方式处理了类似的需求,即“快照”一种使用 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() 不会触发副本…使用访问时 writable() 触发副本。

    我在qt的基础上使用了一些技巧 QSharedDataPointer 这样做。它们通过(->)区分const和non-const访问,这样从const对象读取就不会触发写时复制机制。

        2
  •  5
  •   Will Dean    15 年前

    如果我是你,我不会对操作符重载做任何“聪明”的事情。使用它来处理完全不令人惊讶的事情,这些事情尽可能接近本地操作人员所做的,而不是其他任何事情。

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

    但如果这是一个有用的技术在你的应用程序中,那么我会有“getoldstate”和“getnewstate”方法,使它完全清楚发生了什么。

        3
  •  2
  •   DaveC    15 年前

    我不确定有效地拥有两个状态将意味着您在访问可写状态时不需要任何同步(如果您有多个线程正在写入),但是…

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

    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接口,如果您关心状态前后(可能是imho)。

    干净的代码更容易被理解(包括您在内的每个人),也更容易测试,所以我个人会避开操作符过载。

    对不起,任何C++语法错误,我现在有点爪哇人了。

        4
  •  2
  •   user229044    15 年前

    游戏状态越大,保持两个副本同步的成本就越高。为渲染线程创建一个游戏状态的副本同样简单;您必须将所有数据从前端复制到后端缓冲区Ayways,所以您也可以在运行中进行复制。

    您可以一直尝试最小化缓冲区之间的复制量,但这样您就有了跟踪哪些字段已更改的开销,从而知道要复制什么。这将是一个不太出色的解决方案,在一个视频游戏引擎的核心,性能是相当关键的。

        5
  •  1
  •   Fozi    15 年前

    也许您甚至想在每个勾选中创建一个新的渲染状态。这样,游戏逻辑就是生产者,渲染器就是渲染状态的消费者。旧状态是只读的,可以用作呈现和新状态的引用。一旦渲染,就处理它。

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

        6
  •  1
  •   Milan BabuÅ¡kov    15 年前

    你需要做两件事:

    1. 分离对象的自身状态及其与其他对象的关系
    2. 使用Cow表示对象的自身状态

    为什么?

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

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

        7
  •  1
  •   CB Bailey    15 年前

    通常,只有在运算符重载是自然的情况下才应使用它。如果您正在寻找适合某些功能的运算符,那么这是一个很好的迹象,表明您不应该在问题上强制使用运算符重载。

    尽管如此,您要做的是拥有一个代理对象,它将读写事件分派给一对对象中的一个。代理对象经常重载 -> 提供指针式语义的运算符。(你不能超载 . )

    你可能有两个超负荷 -gt; 区别于 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 因此,您必须保证其寿命短于 多缓冲器 它使用的。它有自己的偏移量,添加到 多缓冲器 偏移使不同 BufferAccess 可以引用数组的不同成员(例如,前缓冲区访问的模板参数n=0,后缓冲区访问的模板参数n=1)。

    请注意 缓冲访问 偏移量是成员而不是模板参数,因此 缓冲访问 对象并不仅仅局限于在一个特定的偏移量上工作,或者必须是模板本身。我已经将对象计数为模板参数,因为从您的描述来看,它可能是一个配置选项,这为编译器提供了最大的优化机会。

    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; 我们可以很容易地从 缓冲访问 没有的实例 缓冲访问 需要任何关于测试班成员的知识。

    也不是在单缓冲和双缓冲之间切换。如果您可以找到对三重缓冲的需求,那么实现三重缓冲也很简单。

    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;
    }
    
    推荐文章