Подтвердить что ты не робот

Доступ к данным для нескольких индексированных массивов данных

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

Моя идея до сих пор заключается в том, что каждый компонент системы отвечает за определенную часть игровой логики, скажем, что компонент Gravity Compal заботится о расчете сил в каждом кадре в зависимости от массы, скорости и т.д. и других компонентов. другие вещи. Следовательно, каждый компонент заинтересован в разных наборах данных. Компонент Gravity может интересоваться массой и скоростью, в то время как компонент Collision может интересоваться ограничивающими прямоугольниками и позицией и т.д.

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

weightarray ->   [0,1,2,3]
positionarray -> [0,1,2,3]
velocityarray -> [0,1,2,3]

Этот подход работает хорошо, если все объекты имеют каждый из атрибутов. Однако, если только объекты 0 и 2 имеют все атрибуты дерева, а другие - объекты типа, которые не перемещаются, они не будут иметь скорость, и данные будут выглядеть:

weightarray ->   [0,1,2,3]
positionarray -> [0,1,2,3]
velocityarray -> [0,2]     //either squash it like this
velocityarray -> [0  ,2  ]     //or leave "empty gaps" to keep alignment

Вдруг это не так легко повторить. Компонент, интересующийся только итерацией и манипулирование скоростью, должен был либо как-то пропустить пустые пробелы, если бы пошел вторым путем. Первый подход к поддержанию короткого массива не будет хорошо работать ни в более сложных ситуациях. Скажем, есть ли у меня один объект 0 со всеми тремя атрибутами, другой объект 1, имеющий только вес и положение, и объект 2, который имеет только положение и скорость. Наконец, есть одна последняя сущность 3, которая имеет только вес. Сжатые массивы выглядят так:

weightarray ->   [0,1,3]
positionarray -> [0,1,2]
velocityarray -> [0,2]

Другой подход оставил бы такие пробелы:

weightarray ->   [0,1, ,3]
positionarray -> [0,1,2, ]
velocityarray -> [0, ,2, ]

Обе эти ситуации нетривиальны для итерации, если вас интересует только итерация по множеству объектов, у которых есть только несколько атрибутов. Например, данный компонент X был бы заинтересован в обработке объектов с положением и скоростью, например. Как я могу извлечь итеративные указатели массива, чтобы дать этому компоненту выполнить его вычисления? Я хотел бы дать ему массив, в котором элементы находятся рядом друг с другом, но это кажется невозможным.

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

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

Спасибо.

4b9b3361

Ответ 1

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

Сначала посмотрим на цель. Цель состоит не в том, чтобы поместить все данные в линейные массивы, это просто средства для достижения цели. Целью является оптимизация производительности путем минимизации промахов в кэше. Все это. Если вы используете OOP-объекты, ваши данные Entities будут окружены данными, которые вам не обязательно нужны. Если ваша архитектура имеет размер кеша размером 64 байта, и вам нужен только вес (float), позиция (vec3) и скорость (vec3), вы используете 28 байт, но остальные 36 байтов будут загружены в любом случае. Хуже того, когда эти 3 значения не бок о бок в памяти или ваша структура данных перекрывает границу строки кэша, вы загружаете несколько строк кэша всего за 28 байтов фактически используемых данных.

Теперь это не так плохо, когда вы делаете это несколько раз. Даже если вы это сделаете сто раз, вы вряд ли заметите это. Однако если вы делаете это тысячи раз в секунду, это может стать проблемой. Поэтому введите DOD, где вы оптимизируете использование кэша, обычно создавая линейные массивы для каждой переменной, в ситуациях, когда есть линейные шаблоны доступа. В вашем случае массивы для веса, положения, скорости. Когда вы загружаете позицию одного объекта, вы снова загружаете 64 байта данных. Но поскольку данные о местоположении находятся рядом друг с другом в массиве, вы не загружаете 1 значение позиции, вы загружаете данные для 5 смежных объектов. Следующей итерации цикла обновления, вероятно, потребуется следующее значение позиции, которое уже было загружено в кеш, и так далее, пока только на 6-й объект не потребуется загружать новые данные из основной памяти.

Таким образом, цель DOD заключается не в использовании линейных массивов, а в максимизации использования кеша, путем размещения данных, к которым осуществляется доступ (примерно) в то же время, что и в памяти. Если вы почти всегда получаете доступ к 3 переменным одновременно, вам не нужно создавать 3 массива для каждой переменной, вы можете так же легко создать структуру, которая содержит только эти 3 значения, и создать массив этих структур. Лучшее решение всегда зависит от способа использования данных. Если ваши шаблоны доступа являются линейными, но вы не всегда используете все переменные, переходите к отдельным линейным массивам. Если ваши шаблоны доступа более нерегулярны, но вы всегда используете все переменные одновременно, объедините их в структуру и создайте массив этих структур.

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

Вы можете хранить большинство доступных данных в непрерывном массиве. Например, позиция часто используется многими различными компонентами, поэтому она является основным кандидатом для непрерывного массива. Вес, с другой стороны, используется только гравитационной составляющей, поэтому здесь могут быть пробелы. Вы оптимизированы для наиболее часто используемого случая и получите меньшую производительность для данных, которые используются реже. Тем не менее, я не являюсь большим поклонником этого решения по ряду причин: он все еще неэффективен, вы загружаете слишком много пустых данных, чем меньше отношение # конкретных компонентов /# итоговых объектов, тем хуже. Если только один из 8 объектов имеет компоненты силы тяжести, и эти сущности распределены равномерно по всем массивам, вы по-прежнему получаете один кеш-промах для каждого обновления. Он также предполагает, что все объекты будут иметь позицию (или что-то другое), трудно добавлять и удалять объекты, они негибкие и простые уродливые (imho в любом случае). Это может быть самое простое решение.

Другим способом решения этой проблемы является использование индексов. Каждый массив для компонента будет упакован, но есть два дополнительных массива: один, чтобы получить идентификатор объекта из индекса массива компонентов, а второй - для получения индекса массива компонентов из идентификатора объекта. Пусть говорят, что позиция разделяется всеми сущностями, а вес и скорость используются только гравитацией. Теперь вы можете перебирать массивы весов и скоростей и получать/устанавливать соответствующую позицию, вы можете получить значение gravityIndex → entityID, перейти к компоненту Position, использовать его entityID → positionIndex, чтобы получить правильный индекс в Массив позиции. Преимуществом является то, что ваш доступ к весу и скорости больше не даст вам пропусков в кеше, но вы по-прежнему получаете пропуски кэша для позиций, если соотношение между # компонентами силы тяжести /# позицией низкое. Вы также получаете дополнительные 2 массива, но 16-разрядный индекс unsigned int должен быть достаточным в большинстве случаев, поэтому эти массивы будут хорошо вписываться в кеш, а это означает, что в большинстве случаев это может быть не очень дорогостоящей операцией. Тем не менее, профиль профиля профиля, чтобы быть в этом уверен!

Третий вариант - дублирование данных. Теперь, я уверен, что это не будет стоить усилий в случае вашего компонента Gravity, я думаю, что это более интересно в сложных вычислительных ситуациях, но пусть все равно возьмет его в качестве примера. В этом случае компонент Gravity имеет 3 упакованных массива для веса, скорости и положения. Он также имеет аналогичную таблицу индексов для того, что вы видели во втором варианте. Когда вы запускаете обновление компонента Gravity, сначала обновляете массив позиций из исходного массива позиций в компоненте Position, используя таблицу индексов, как в примере 2. Теперь у вас есть 3 упакованных массива, которые вы можете выполнять ваши вычисления линейно с максимальным кешем использование. Когда вы закончите, скопируйте позицию обратно в исходный компонент Position с помощью индексной таблицы. Теперь это не будет быстрее (на самом деле, вероятно, медленнее), чем второй вариант, если вы используете его для чего-то вроде Gravity, потому что вы только читаете и записываете позицию один раз. Однако предположим, что у вас есть компонент, где объекты взаимодействуют друг с другом, причем каждый проход обновления требует многократного чтения и записи, это может быть быстрее. Тем не менее, все зависит от шаблонов доступа.

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

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

Надеюсь, это поможет!

Ответ 2

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

Решение проблемы разрыва приведет к появлению следующего:

  • Если операторы (ветки) обрабатывают исключения данных (объекты, которым не хватает компонента).
  • Представляем отверстия, которые вы можете, а также перечислить списки случайным образом. Сила DoD заключается в том, что все данные плотно упакованы и упорядочены так, как они будут обрабатываться.

Что вы можете сделать:

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

Имея следующие упрощенные списки и их атрибуты:

  • жесткое тело (сила, скорость, преобразование)
  • столкновение (boundingbox, transform)
  • drawable (texture_id, shader_id, transform)
  • rigidbody_to_collision (rigidbody_index, collision_index)
  • collision_to_rigidbody (collision_index, rigidbody_index)
  • rigidbody_to_drawable (rigidbody_index, drawable_index)

и т.д...

Для процессов/заданий вам может понадобиться следующее:

  • RigidbodyApplyForces (...), применяйте силы (например, силы тяжести) к скоростям
  • RigidbodyIntegrate (...), применяйте скорости к преобразованиям.
  • RigidbodyToCollision (...), копирование жесткого тела преобразуется в преобразования конфликтов только для объектов, имеющих компонент столкновения. В списке "rigidbody_to_collision" содержатся индексы, из которых должен быть скопирован идентификатор жесткого лица, к которому относится идентификатор столкновения. Это держит список столкновений плотно упакованным.
  • RigidbodyToDrawable (...), скопируйте hardidbody, преобразует в drawable transforms для объектов, у которых есть компонент рисования. В списке "rigidbody_to_drawable" содержатся индексы, из которых должен быть скопирован идентификатор жесткого лица, к которому можно получить идентификатор. Это держит список drawabkl плотно упакованным.
  • CollisionUpdateBoundingBoxes (...), обновляйте ограничивающие поля с помощью новых преобразований.
  • CollisionRecalculateHashgrid (...), обновить hashgrid с помощью ограничивающих полей. Вы можете выполнить это разделение на несколько кадров для распределения нагрузки.
  • CollisionBroadphaseResolve (...), рассчитать возможные столкновения с использованием hashgrid и т.д.
  • CollisionMidphaseResolve (...), вычислить столкновение, используя ограничивающие поля для расширенной фазы и т.д.
  • CollisionNarrowphaseResolve (...), вычислить столкновение с использованием полигонов из midphase и т.д.
  • CollisionToRigidbody (...), добавьте реактивные силы сталкивающихся объектов в жесткие силы. В списке "collision_to_rigidbody" содержатся индексы, из которых идентификатор столкновения должен быть добавлен к тому жесткому идентификатору. Вы также можете создать другой список под названием "reactive_forces_to_be_added". Вы можете использовать это, чтобы отложить добавление сил.
  • RenderDrawable (...), отображает чертежи на экран (визуализатор просто упрощен).

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

Когда объект создается, данные компонента также будут созданы вместе и сохранены в том же порядке. Значение списков будет оставаться в основном в том же порядке.

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

Ответ 3

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

В игровом движке есть список менеджеров, ответственных за различные системы в игре (InputManager, PhysicsManager, RenderManager и т.д.).

Большинство вещей в 3D-мире представлены классом Object, и каждый объект может иметь любое количество компонентов. Каждый компонент отвечает за различные аспекты поведения объекта (RenderComponent, PhysicsComponent и т.д.).

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

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

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

Что касается структуры данных, вы можете иметь упомянутую выше систему и структурировать ее несколькими способами. Как правило, объекты хранятся либо в векторе, либо в Карте, а компоненты находятся в векторе или в списке объектов. Что касается физики, то у PhysManager есть список всех объектов физики, которые могут храниться в массиве/векторе, а у PhysComponent есть копия его положения, скорости и других данных, чтобы он мог делать все, что он должен иметь данные, управляемые физическим менеджером. Например, если вы хотите изменить скорость объекта, который вы только что скажете PhysicsComponent, он изменит его значение скорости, а затем уведомит об этом PhysicalManager.

Я больше расскажу о предмете структуры движка объектов/компонентов: https://gamedev.stackexchange.com/a/23578/12611

Ответ 4

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

введите описание изображения здесь

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

Вы можете использовать меньший размер блока, чем 512, поскольку это может быть довольно огромным для определенного типа компонента. Что-то вроде 32 может быть разумным или вы можете настроить размер блока "на лету" на основе sizeof(ComponentType). С этим вы можете просто связать свои компоненты параллельно с вашими объектами, не вдувая память, используя слишком много из незанятых пространств, хотя я не использую их таким образом (я использую вертикальный тип представления, но моя система имеет много типов компонентов - - если у вас есть только несколько, вы можете просто сохранить все параллельно).

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

введите описание изображения здесь

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

В качестве бонуса он также позволяет вам выполнять набор пересечений в наилучшем сценарии Log(N)/Log(64) (например: возможность найти заданное пересечение между двумя плотными наборами индексов, содержащими миллион элементов каждый в 3-4 итерациях), если вам нужны быстрые пересечения, которые часто могут быть очень удобными для ECS.

Эти две структуры являются основой моего двигателя ECS. Они довольно быстры, так как я могу обрабатывать 2 миллиона частиц (доступ к двум различным компонентам) без кэширования запроса для объектов с обоими компонентами всего чуть менее 30 FPS. Конечно, это дрянная частота кадров всего за 2 миллиона частиц, но при представлении их как целых сущностей с двумя компонентами, прикрепленными друг к другу (движение и спрайт) с системой частиц, выполняющей запрос, каждый отдельный кадр, без доступа - то, что люди обычно никогда не будут do (лучше использовать как компонент ParticleEmitter, который представляет собой много частиц для данного объекта, а не делает частицу самой отдельной сущностью).

Надеемся, что диаграммы достаточно ясны, чтобы реализовать вашу собственную версию, если вы заинтересованы.