Синхронизация между потоком логики игры и потоком рендеринга

16

Как разделить игровую логику и рендеринг? Я знаю, что здесь, похоже, уже есть вопросы, задающие именно это, но ответы меня не устраивают.

Из того, что я понимаю до сих пор, смысл разделять их на разные потоки состоит в том, что игровая логика может сразу же начать работать для следующего тика, а не ждать следующего vsync, когда рендеринг, наконец, возвращается из вызова swapbuffer, который был заблокирован.

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

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

user782220
источник
1
Я ненавижу просто спамить ссылку, но я думаю, что это очень хорошее чтение, и оно должно ответить на все ваши вопросы: altdevblogaday.com/2011/07/03/threading-and-your-game-loop
Рой Т.
Другая ссылка: software.intel.com/en-us/articles/…
Chewy Gumball
1
Эти ссылки дают типичный конечный результат, который хотелось бы, но не уточняйте, как это сделать. Вы бы скопировали весь граф сцены каждый кадр или что-то еще? Дискуссии слишком высокого уровня и расплывчатые.
user782220
Я думал, что ссылки были довольно явными о том, сколько состояния копируется в каждом случае. например. (из 1-й ссылки) «Пакет содержит всю информацию, необходимую для рисования фрейма, но не содержит никакого другого игрового состояния». или (из 2-й ссылки) «Тем не менее, данные по-прежнему необходимо передавать, но теперь вместо того, чтобы каждая система имела доступ к общему местоположению данных, чтобы сказать, получить данные о положении или ориентации, каждая система имеет свою собственную копию» (см., в частности, 3.2.2 - Состояние Менеджер)
DMGregory
Кто бы ни писал эту статью Intel, похоже, не знает, что многопоточность верхнего уровня - очень плохая идея. Никто не делает глупостей. Внезапно все приложение должно связываться по специализированным каналам, и повсюду возникают блокировки и / или огромные согласованные обмены состояниями. Не говоря уже о том, что неизвестно, когда будут отправлены отправленные данные, поэтому очень сложно рассуждать о том, что делает код. Гораздо проще скопировать соответствующие данные сцены (неизменяемые как указатели ref.counts, изменяемые - по значению) в одной точке и позволить подсистеме отсортировать их так, как они захотят.
snake5

Ответы:

1

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

В итоге я создал последовательность потоков, которые реализовали определенный интерфейс, и загрузил их в стек объекта управления. Когда этот объект получит сигнал на завершение игры, он будет проходить через каждый поток, вызывать реализованный метод ceaseOperations () и ждать, пока они закроются, прежде чем самому закрыться. Универсальные данные, которые могут иметь отношение к воспроизведению звука, графики или любых других данных, хранятся в последовательности объектов, которые являются энергозависимыми или универсально доступны для всех потоков, но никогда не хранятся в памяти потоков. Там есть небольшое снижение производительности, но при правильном использовании это позволило мне гибко назначать звук одному потоку, графику другому, физику другому и т. Д., Не привязывая их к традиционному (и страшному) «игровому циклу».

Таким образом, как правило, все вызовы OpenGL проходят через поток Graphics, все OpenAL - через поток Audio, весь ввод - через поток Input, и все, о чем нужно беспокоиться организующему потоку управления, - это управление потоками. Состояние игры хранится в классе GameState, на который они могут посмотреть все, что им нужно. Если я когда-нибудь решу, что, скажем, JOAL устарел, и я вместо этого захочу использовать новую версию JavaSound, я просто реализую другой поток для Audio.

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

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

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

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

Это зависит от того, какую платформу вы используете. Например:

  • если вы делаете это на большинстве платформ, связанных с Open GL ( GLUT для C / C ++ , JOLG для Java , Android-действие, связанное с OpenGL ES ), они обычно предоставляют вам метод / функцию, которая периодически вызывается потоком рендеринга и которую вы может интегрироваться в ваш игровой цикл (не делая итерации игрового цикла зависимыми от того, когда вызывается этот метод). Для GLUT, использующего C, вы делаете что-то вроде этого:

    glutDisplayFunc (myFunctionForGraphicsDrawing);

    glutIdleFunc (myFunctionForUpdatingState);

  • в JavaScript вы можете использовать веб-работников так как нет многопоточности (которую вы можете достичь программно) , вы также можете использовать механизм requestAnimationFrame, чтобы получать уведомления о том, когда будет запланирован новый рендеринг графики, и соответственно обновлять состояние игры. ,

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

Дракон Шиван
источник
0

В Java есть ключевое слово «synchronized», которое блокирует переменные, которые вы передаете ему, чтобы сделать их потокобезопасными. В C ++ вы можете достичь того же, используя Mutex. Например:

Джава:

synchronized(a){
    //code using a
}

C ++:

mutex a_mutex;

void f(){
    a_mutex.lock();
    //code using a
    a_mutex.unlock();
}

Блокировка переменных гарантирует, что они не изменятся во время выполнения кода, следующего за ним, поэтому переменные не изменяются вашим потоком обновления во время их рендеринга (на самом деле они меняются, но с точки зрения вашего потока рендеринга они не т). Вы должны остерегаться с ключевым словом synchronized в Java, так как он только гарантирует, что указатель на переменную / Object не изменится. Атрибуты все еще могут меняться без изменения указателя. Чтобы подумать об этом, вы можете скопировать объект самостоятельно или вызвать синхронизированный для всех атрибутов объекта, который вы не хотите изменять.

zedutchgandalf
источник
1
Мьютексы не обязательно являются ответом здесь, потому что OP должен был бы не только отделить игровую логику и рендеринг, но они также хотят избежать любого замедления способности одного потока двигаться вперед в своей обработке независимо от того, где другой поток может в данный момент находиться в своей обработке. петля.
Нарос
0

То, что я обычно видел для обработки потоков логики / рендеринга, - это тройной буфер ваших данных. Таким образом, поток рендеринга скажет интервал 0, из которого он читает. Логический поток использует сегмент 1 в качестве входного источника для следующего кадра и записывает данные кадра в сегмент 2.

В точках синхронизации индексы того, что означает каждое из трех сегментов, меняются местами, так что данные следующего кадра передаются в поток рендеринга, и логический поток может продолжаться вперед.

Но не обязательно есть причина делить рендеринг и логику на соответствующие потоки. Фактически вы можете сохранить последовательность игрового цикла и отделить частоту кадров рендеринга от логического шага, используя интерполяцию. Чтобы воспользоваться преимуществами многоядерных процессоров, использующих этот тип настройки, у вас будет пул потоков, который работает с группами задач. Этими задачами могут быть просто такие вещи, как итерация списка объектов от 0 до 100, вы итерируете список в 5 сегментов по 20 в 5 потоках, эффективно повышая производительность, но не усложняя основной цикл.

Naros
источник
0

Это старый пост, но он все еще появляется, поэтому я хотел добавить свои 2 цента здесь.

Первый список данных, которые должны храниться в потоке пользовательского интерфейса / отображения, а не в логическом потоке. В потоке пользовательского интерфейса вы можете включить 3d-сетку, текстуры, информацию о свете и копию данных о положении / повороте / направлении.

В игровой логике вам может понадобиться размер игрового объекта в 3d, ограничивающие примитивы (сфера, куб), упрощенные данные трехмерной сетки (например, для подробных столкновений), все атрибуты, влияющие на движение / поведение, такие как скорость объекта, коэффициент поворота и т. Д., а также данные положения / вращения / направления.

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

Как вы это сделаете, зависит от того, на каком языке вы работаете. В Scala вы можете использовать Software Transactional Memory, в Java / C ++ - какую-то блокировку / синхронизацию. Мне нравятся неизменные данные, поэтому я склонен возвращать новый неизменный объект для каждого обновления. Это пустая трата памяти, но с современными компьютерами это не такая уж большая проблема. Тем не менее, если вы хотите заблокировать общие структуры данных, вы можете сделать это. Проверьте класс Exchanger в Java, использование двух или более буферов может ускорить процесс.

Прежде чем приступить к обмену данными между потоками, определите, сколько данных вам действительно нужно передать. Если у вас есть октри, разделяющее ваше трехмерное пространство, и вы можете видеть 5 игровых объектов из 10 объектов в целом, даже если вашей логике нужно обновить все 10, вам нужно перерисовать только те 5, которые вы видите. Дополнительную информацию можно найти в этом блоге: http://gameprogrammingpatterns.com/game-loop.html Речь идет не о синхронизации, а о том, как игровая логика отделена от отображения и какие задачи вам необходимо преодолеть (FPS). Надеюсь это поможет,

отметка

Марк Гражданин
источник