Двойная буферизация для объектов Game, каков хороший чистый общий способ C ++? - PullRequest
8 голосов
/ 05 января 2010

Это в C ++.

Итак, я начинаю с нуля писать игровой движок для развлечения и обучения с нуля. Одна из идей, которую я хочу реализовать, состоит в том, чтобы состояние игрового объекта (структура) было двойной буферизацией. Например, у меня могут быть подсистемы, обновляющие новые данные игрового объекта, в то время как поток рендеринга рендерит из старых данных, гарантируя, что в игровом объекте хранится согласованное состояние (данные последнего времени). После рендеринга старого и обновления нового я могу поменять буферы и сделать это снова.

Вопрос в том, что является хорошим перспективным и универсальным способом ООП, чтобы показать это моим классам, пытаясь максимально скрыть детали реализации? Хотелось бы узнать ваши мысли и соображения.

Я думал, что можно использовать перегрузку операторов, но как мне перегрузить присваивание для члена шаблонного класса внутри моего буферного класса?

например, я думаю, что это пример того, что я хочу:

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 ]

6 голосов
/ 06 января 2010

Недавно я рассмотрел аналогичное стремление в обобщенном виде, сделав «снимок» структуры данных, которая использовала 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 , чтобы сделать это. Они различают константный и неконстантный доступ через (->), так что чтение из константного объекта не вызовет копирование в механике записи.

5 голосов
/ 05 января 2010

Я бы не стал делать что-нибудь «умное» с перегрузкой операторов на вашем месте. Используйте его для совершенно неудивительных вещей, максимально приближенных к тому, что будет делать нативный оператор, и ничего больше.

Не совсем понятно, что ваша схема особенно помогает с несколькими потоками записи - как узнать, какой из них «выигрывает», когда несколько потоков читают старое состояние и записывают в одно и то же новое состояние, перезаписывая все предыдущие записи?

Но если это полезный метод в вашем приложении, то у меня будут методы GetOldState и GetNewState, которые полностью прояснят, что происходит.

2 голосов
/ 06 января 2010

Чем больше становится ваше игровое состояние, тем дороже будет поддерживать синхронизацию двух копий. Было бы так же просто создать копию состояния игры для потока рендеринга каждый тик; вам придется скопировать все данные из передней части в задний буфер в любом случае, так что вы можете просто сделать это на лету.

Вы всегда можете попытаться свести к минимуму объем копирования между буферами, но тогда у вас есть накладные расходы на отслеживание того, какие поля были изменены, чтобы вы знали, что копировать. Это будет менее чем звездное решение в ядре движка видеоигр, где производительность довольно важна.

2 голосов
/ 06 января 2010

Я не уверен, что фактически наличие двух состояний будет означать, что вам не нужна синхронизация при доступе к состоянию записи, если у вас несколько потоков, но ...

Я думаю, что это простой и очевидный (для поддержания и понимания) шаблон, который вы могли бы использовать с небольшими накладными расходами.

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; }
}

По сути, я сделал нечто похожее на ваше предложение, но использовал перегрузку. Если вы хотите быть осторожным / параноиком, у меня будет пустой класс с виртуальными методами получения / установки в качестве базового класса, а не как указано выше, поэтому компилятор сохраняет код корректным.

Это дает вам версию состояния readOnly, которая будет изменяться только при вызове swap, и чистый интерфейс, при котором вызывающая сторона может игнорировать проблему двойного буфера при работе с состоянием (все, что не требует знания старого и новые состояния могут иметь дело с «интерфейсом» MyRealState), или вы можете уменьшить или потребовать интерфейс DoubleBufferedState, если вы заботитесь о состояниях до и после (что, вероятно, imho).

Чистый код, скорее всего, будет понятен (всеми, включая вас), и его будет проще тестировать, поэтому я бы избежал перегрузки оператора лично.

Извините за любые синтаксические ошибки в c ++, теперь я немного Java-человек.

1 голос
/ 06 января 2010

Как правило, вы должны использовать перегрузку оператора только тогда, когда это естественно. Если вы ищете подходящего оператора для какой-то функциональности, это хороший знак того, что вам не следует заставлять оператора перегружать свою проблему.

Сказав это, вы пытаетесь создать прокси-объект, который отправляет события чтения и записи одному из пары объектов. Прокси-объект часто перегружает оператор -> для получения семантики, подобной указателю. (Вы не можете перегрузить ..)

Хотя вы можете иметь две перегрузки ->, дифференцированные по const -ness, я бы предостерег от этого, так как это затрудняет чтение. Перегрузка выбирается тем, на какой объект ссылается постоянная или неконстантная ссылка, а не тем, является ли действие действительным чтением или записью. Этот факт делает ошибку подхода подходящей.

Что вы можете сделать, это разделить доступ из хранилища и создать шаблон мультибуферного класса и шаблон буфера доступа, который обращается к соответствующему члену, используя 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 по ссылке, поэтому вы должны гарантировать, что его срок службы меньше, чем 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;
}
1 голос
/ 06 января 2010

Вам нужно сделать две вещи:

  1. разделяет собственное состояние объекта и его связь с другими объектами
  2. использовать COW для собственного состояния объекта

Почему?

Для целей рендеринга вам нужны только свойства объекта «back-version», которые влияют на рендеринг (например, положение, ориентация и т. Д.), Но вам не нужны отношения объекта. Это освободит вас от висящих указателей и позволит обновить состояние игры. COW (copy-on-write) должен иметь глубину 1 уровень, потому что вам нужен только один «другой» буфер.

Короче говоря : Я думаю, что выбор перегрузки операторов полностью ортогональн к этой задаче. Это просто синтетический сахар. Пишете ли вы + = или setNewState совершенно не имеет значения, так как оба используют одно и то же время процессора.

1 голос
/ 06 января 2010

Может быть, вы даже хотели бы создать новое состояние рендеринга в каждом тике. Таким образом, ваша игровая логика является производителем, а ваш рендер - потребителем состояний рендеринга. Старое состояние доступно только для чтения и может использоваться как ссылка как для рендеринга, так и для нового состояния. После рендеринга вы утилизируете его.

Что касается мелких предметов, может подойти шаблон Flyweight .

...