|
Professor Seleznov
|
 Scene not Graph. Место для запятой выбирайте сами. Scene Graph как концепция появился в академической и промышленной среде, где компьютерная графика использовалась для CAD-систем, научной визуализации и инженерного проектирования, а потом уже пришел в игры. Задачи отрисовки мира и его объектов в CAD были совсем другие и нужно было описывать, например, сложные сборки из деталей, с шестерней в редукторе, редукторе в двигателе, двигателе в машине, и такая модель отражала физическую реальность, которая играм была нужна с приставкой "не". Причины, по которым Scene Graph пришел и остается в играх довольно банальные, этой концепции учат в университете, и многие кто пришел делать игры, естественно знакомились с ней раньше других. На курсе компьютерной графики ИТМО эту модель давали уже на втором месяце и объясняли её полгода, а остальные пять или шесть техник давали всего месяц и в конце года. Но проблема была в том, что в CAD иерархия объектов это буквальное описание устройства изделия, и перенос этой модели в игры в целом и в игровую графику в частности был концептуальной ошибкой с самого начала. Поняли это достаточно поздно, чтобы эта модель успела поселиться в мозгах целого поколения, выпуск OpenGL в 1992 году с принципиально другой моделью (immediate mode) стал первым сигналом что играм надо двигаться в другую сторону, но инерция Scene Graph в движковой архитектуре сохраняется до сих пор.-Если вы пробуете организовать работу с миром в игровом движке, неважно это рендеринг или обновление свойств, то почти наверняка начнете использовать иерархию объектов сцены, потому что эта идея кажется совершенно естественной, исходя из опыта работы с окружением в быту. У нас есть мир, в мире есть объекты, объекты вложены друг в друга и можно сделать некоторое дерево вложенности, когда одни объекты принадлежат другим, и всё что нужно делать это обходить это дерево сверху вниз, рисуя или обрабатывая каждый узел по очереди. Т.е. будет что-то похожее в коде:
class GameObject { public: virtual void preRender(); virtual void render(); virtual void postRender(); hvector children; }; void GameObject::renderTree() { preRender(); // настроить состояние render(); // нарисовать объект for (auto* child : children) child->renderTree(); postRender(); // восстановить состояние }
И это схема прекрасно работала годов эдак до 2010-х, как в голове, так и в железе. Она все также неплохо работает для простых игры и проектов, но довольно быстро перестаёт работать в реальном проекте, потому что реальный рендеринг устроен принципиально не как дерево, и чем сложнее становится проект тем очевиднее этот разрыв между построеной в голове моделью и требованиями GPU. Почему дерево ломается
 Первая проблема называется батчинг, и суть её в том, что GPU работает эффективно когда получает однородные команды подряд. Одинаковый шейдер, одинаковый материал, одинаковые текстуры будут работать быстро, но переключение состояния между командами стоит дорого, и если в дереве сцены два объекта с одинаковым материалом разделены узлом с другим материалом, то мы будем платить два лишних переключения состояния в одно и тоже состояни только потому, что иерархия расположила их не рядом.
// дерево сцены: // Root // Wall (material: stone) // Player (material: character) // Floor (material: stone) <-- тот же материал что у Wall, но рисуется третьим // обход дает нам такую последовательность вызовов: // bind stone shader // draw wall // bind character shader <-- переключение // draw player // bind stone shader <-- ещё одно переключение // draw floor // 3 переключения конвейера отрисовки
Если бы мы могли отсортировать объекты по материалу перед отправкой на видеокарту, то получили бы два вызова вместо четырёх, но дерево не даёт такой возможности, потому что порядок обхода определяется иерархией, а не оптимальностью для GPU.
// дерево сцены: Root Wall (material: stone) Player (material: character) Floor (material: stone) <-- тот же материал что у Wall sort objects by material // обход дает нам такую последовательность вызовов: bind stone shader draw wall draw floor bind character shader <-- переключение draw player 2 переключения конвейера отрисовки
Вторая проблема это прозрачные объекты, которые нужно рисовать строго сзади наперёд относительно камеры, иначе смешение цветов даст неверный результат. Тут дерево тоже активно мешает, потому что порядок в иерархии и порядок по глубине это совершенно разные вещи, и один и тот же объект может находиться одновременно перед персонажем с точки зрения глубины и быть дочерним узлом корня сцены выше персонажа в иерархии.
 (Nvidia, Order Independent Transparency https://my.eng.utah.edu/~cs5610/lectures/OrderIndependentTransparency.pdf)
// правильный порядок отрисовки прозрачных объектов: // сначала дальние, потом ближние к камере struct TransparentObject { Mesh* mesh; float distanceToCamera; }; // нужна сортировка по дистанции, но дерево её не предоставляет std::sort(transparents.begin(), transparents.end(), [](const auto& a, const auto& b) { return a.distanceToCamera > b.distanceToCamera; });
Прозрачность в реальном времени это один из самых неудобных моментов в рендеринге, потому что правильное решение требует знать порядок всех фрагментов вдоль каждого луча зрения, а эта информация в общем случае недоступна до самого момента растеризации.
 Самый распространённый способ это собрать все прозрачные объекты в отдельный список, отсортировать по дистанции от камеры и нарисовать строго сзади наперёд после того как все непрозрачные объекты уже отрисованы в сцене. Т.е. будет как минимум еще один проход по дереву, и еще один проход рендера.
struct TransparentDrawCall { Mesh* mesh; Material* material; glm::mat4 transform; float distanceToCamera; }; void Renderer::renderTransparents(const Camera& camera) { std::vector transparents; for (auto* obj : scene.transparentObjects) { glm::vec3 center = obj->getWorldBounds().center(); float dist = glm::length(center - camera.position); transparents.push_back({obj->mesh, obj->material, obj->transform, dist}); } // сортируем сзади наперёд std::sort(transparents.begin(), transparents.end(), [](const auto& a, const auto& b) { return a.distanceToCamera > b.distanceToCamera; }); // рисуем с выключенной записью в depth buffer // но с чтением из него — чтобы непрозрачная геометрия // корректно перекрывала прозрачную setDepthWrite(false); setDepthTest(true); for (auto& call : transparents) draw(call); setDepthWrite(true); }
Но такой способ (по центру объекта, как на картинке выше с чайниками) даёт неверный результат когда два прозрачных объекта перекрывают друг друга или один находится внутри другого, потому что центр это не то же самое что ближайшая/дальняя точка меша. Все сортировочные подходы имеют принципиальный изъян и работают некорректно, когда прозрачные объекты пересекаются в пространстве, потому что никакой порядок отрисовки объектов целиком не даст правильного результата. Чтобы решить проблему с пересечением сцена рендерится несколько раз, каждый проход отбирает следующий по глубине слой фрагментов и накапливает результат. Вот тут интересная статья (Алгоритм Order-Independent Transparency c использованием связных списков на Direct3D 11 и OpenGL 4) с подробным описанием различных техник OIT.
 Я не буду вдаваться в детали рисования прозрачных объектов, потому что это отдельная большая тема рендеринга, которая выходит за рамки статьи про Scene Graph. Скажу только, что на практике движки используют несколько техник одновременно в зависимости от типа объекта: листва и решётки идут отдельным проходом, частицы и эффекты обычно еще одним проходом, но через простую сортировку, стекло и вода через отдельные специализированные проходы, а UI рисуется последним поверх всего с простым альфа-смешением. В итоге это выглядит как-то так:
void Renderer::renderFrame() { renderOpaquePass(); // непрозрачная геометрия renderAlphaTestPass(); // листва, решётки — без сортировки renderTransparentPass(); // WBOIT или сортировка по глубине renderParticlePass(); // аддитивное смешение — без сортировки renderPostProcess(); // постпроцессинг renderUIPass(); // UI поверх всего }
Третья проблема это тени, которые требуют еще одного прохода по сцене из позиции источника света с другим набором шейдеров, и этот проход никак не укладывается в однократный обход дерева, потому что мы должны посетить каждый объект дважды и в разном порядке. Если добавить сюда render-to-texture для зеркал или миникарты, постпроцессинг, UI поверх мира и разные слои прозрачности, то типичный Scene Graph начинает обрастать флагами, специальными полями и хаками:
class GameObject { bool castsShadow = true; bool receivesTransparency = false; bool renderInMirror = true; int renderLayer = 0; // ... ещё десяток флагов };
И в какой-то момент становится понятно, что мы в конец сломали Scene Graph, который перестал быть источником правды для рендеринга и превратился в коллекцию исключений, хаков и костылей. Переход к спискам команд Старые API вроде раннего OpenGL работали в режиме immediate mode, где каждый вызов что-то делал, под что-то надо понимать отрисовку эффекта или смену состояния конвейера отрисовки:
glBindTexture(GL_TEXTURE_2D, texture1); glDrawArrays(GL_TRIANGLES, 0, 36); glBindTexture(GL_TEXTURE_2D, texture2); glDrawArrays(GL_TRIANGLES, 0, 24);
Это простая и понятная модель, но у неё нет никакой гибкости и команды выполняются в том порядке в котором вызываются, мы не можем их переупорядочить, закешировать или выполнить в нескольких потоках. Современные API, Vulkan, DirectX 12 и Metal, устроены иначе и сначала записываются команды в буфер, потом буфер отправляется на GPU, который тоже может его изменить, а потом уже происходит реальное выполнение.
vkCmdBindPipeline(cmdBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); vkCmdBindVertexBuffers(cmdBuffer, 0, 1, &vertexBuffer, offsets); vkCmdDraw(cmdBuffer, vertexCount, 1, 0, 0); // тут еще можно пройтись по буферу и чтото изменить ... // отправка произойдёт позже, когда мы вызовем vkQueueSubmit
Как и в 90-е, когда игровые движки начали перенимать идеи immediate mode из графических API и настраивать над ними свою логику, так и сейчас мы понемного перетаскиваем эти уже новые иде и строим собственный слой команд поверх API, позволяя отложить момент между "собрать все данные" и "отправить на GPU", а в промежутке можно делать что угодно. Первое и самое очевидное что дает список команд - это сортировка. Когда все команды собраны в один список, их можно отсортировать любым нужным способом перед отправкой на GPU, и для непрозрачных объектов сортировка по материалу даст нам минимум переключений состояния, а для прозрачных объектов сортировка по глубине даст правильный порядок смешения.
struct DrawCommand { uint64_t sortKey; // закодированные приоритеты Mesh* mesh; Material* material; vec4 transform; }; // сортируем перед отправкой std::sort(opaqueCommands.begin(), opaqueCommands.end(), [](const auto& a, const auto& b) { return a.sortKey < b.sortKey; }); // sortKey можно строить упаковывая разные поля в биты: // [shader_id: 16 bits][material_id: 16 bits][depth: 24 bits][...]
Второе это кеширование, которое важно для статических объектов мира, потому что если стена никуда не движется и её материал не меняется, то нет смысла пересчитывать команды для неё каждый кадр, и движки вроде Unreal Engine создают отдельные буферы команд для статических объектов один раз при загрузке уровня, а потом просто копируют их в финальный список, чтобы разгрузить CPU от выполнения ненужной работы.
class StaticMesh { hvector cachedCommands; bool dirty = true; public: void buildCommands() { if (!dirty) return; cachedCommands.clear(); // заполняем один раз dirty = false; } void submitTo(RenderQueue& queue) { buildCommands(); queue.append(cachedCommands); // просто копируем } };
Третье это многопоточность, которая стала необходимостью когда CPU оказался узким местом в рендеринге, и список команд позволяет строить его параллельно в нескольких потоках, собирая части из разных подсистем независимо, а потом объединять в один финальный список перед отправкой на GPU. В разных движках это делается по разному, но общая идея сводится к тому, чтобы обрабатывать объекты (npc, тайлы, модели) без необходимости привязки к главному апдейту. В какой-то момент в своем пет-проекте Akhenaten я тоже уперся в скорость обработки тайлов карты, и при масштабировании фпс падал до 11-12, так что это не воображаемая проблема больших игр и движков. Решил я её тем, что убрал последовательный рендер тайлов, и сделал буфер команд отрисовки, который заполняется в N потоков, а потом уже буфер сортируется и отправляется в видеокарту, что позволило рисовать всю карту в 120 фпс, на моем достаточно слабом ноутбуке. Если кому интересна реализация, то смотреть тут. (https://github.com/dalerank/Akhenaten/blob/master/src/widget/widget_city.cpp:370)
Вот там в углу в дебаге показывает 74, а было что-то около 15.
 Это тоже не дается бесплатно и приходится вводить ещё слой абстракции между игрой и железом. Rendering Hardware Interface Между движком и конкретным API вводится слой абстракции, который принято называть RHI или Rendering Hardware Interface, и его задача скрывать различия между DirectX, Vulkan и Metal за единым интерфейсом так, чтобы остальной движок не знал с каким именно API он работает в данный момент.
class RHI { public: virtual RHIBuffer* createBuffer(size_t size, BufferUsage usage) = 0; virtual RHITexture* createTexture(TextureDesc desc) = 0; virtual void draw(RHICommandList* cmd, uint32_t vertexCount) = 0; }; class VulkanRHI : public RHI { void draw(RHICommandList* cmd, uint32_t vertexCount) override { auto* vkCmd = static_cast(cmd); vkCmdDraw(vkCmd->handle, vertexCount, 1, 0, 0); } }; class D3D12RHI : public RHI { void draw(RHICommandList* cmd, uint32_t vertexCount) override { auto* d3dCmd = static_cast(cmd); d3dCmd->list->DrawInstanced(vertexCount, 1, 0, 0); } };
Это также позволяет адаптировать поведение под конкретную платформу не меняя код движка и iOS получает более последовательный однопоточный вариант, потому что там другая модель памяти, Android получает чуть больше потоков, PC получает агрессивный батчинг потому что железо позволяет, а консоль получает специализированные пулы, которые используют особенности конкретного GPU. Как выглядит современный пайплайн рендера Игровые объекты в современном движке не рисуют себя сами, а теперь только поставляют данные в рендерер, а рендерер уже сам решает что с этими данными делать и в каком порядке.
// объект не знает как его нарисуют, он только сообщает о себе class MeshComponent { public: void collectRenderData(RenderScene& scene) { scene.submitMesh( NMesh{ mesh }, NMaterial{ material }, NTransform{ getWorldTransform() }, NCastsShadow{ true }, NRenderLayer{ RenderLayer::Opaque } }); } };
Рендер принимает все эти данные и распределяет их по очередям в зависимости от типа и свойств объекта, после чего каждая очередь сортируется по своим правилам и превращается в список команд, который потом объединяется в финальный командный буфер и отправляется на GPU. Scene Graph при этом никуда не исчезает, он продолжает управлять трансформом, анимациями и логикой сцены, но он больше не управляет рендерингом напрямую и превращается в один из источников данных для рендерера наравне с системой частиц, UI-слоем и отладочными примитивами. В этом и есть главная идея революции, которая случилась с рендеринг в начале 10-х, когда рендер перестал быть обходом дерева и стал просто обработкой потока данных, где на входе находятся разрозненные сведения об объектах мира, а на выходе последовательность команд для GPU. Что посмотреть из современных реализаций sokol (https://github.com/floooh/sokol) sokol написан Андре Вайсфлогом (Andre Weissflog, псевдоним floooh) и распространяется под лицензией zlib/libpng с 2018 года. Мотивация проекта описывается автором как разочарование в сложности и многословности D3D12 и Vulkan, которые требуют отдельной команды внутри команды движка (подтверждаю его слова, ибо у нас в студии над каждым ренедером работают выделенные люди) и нацелены исключительно на нишы ААА-игр для PC, а также наблюдение что старые API живут намного дольше, чем принято считать, и будут актуальны ещё десятилетия, и по мнению Андре закат OpenGL 2 мы увидим лет эдак через десять. Либа покрывает GL/GLES3/WebGL2, Metal, D3D11 и WebGPU единым C API. Применяется в основном в небольших студиях и инди-проектах (Solar Storm и Spanking Runners), вышедших в Steam, а также в эмуляторах и демо-сценовых проектах. На что стоит обратать внимание: Vulkan бэкенд меньше GL-бэкенда, это очень нехарактерно для API в игровых движках, где бойлеркод для вулкана составляет x2-x3 к коду opengl, и заставляет задуматься, что можно делать меньше и лучше. Большинство разработчиков ожидают, что «более низкоуровневый» API должен требовать больше кода, и часто это так и есть, но как показывает этот проект бэкенд на Vulkan может получиться короче, чище и местами даже понятнее, чем GL-обертка. bgfx (https://github.com/bkaradzic/bgfx)
 bgfx написан Бранимиром Караджичем (Branimir Karadžić), бывшим участником демо-сцены, первые комиты датируются 2010 годом, что делает его одним из старейших живых проектов в этой нише. Ключевое решение bgfx - это декларативный API с понятием "view" как обобщённого рендер-бакета, где пользователь определяет назначение и порядок отрисовки, что ставит его по уровню абстракции выше чем сырой Vulkan/D3D12, но ниже чем движковые системы рендеринга. Из инетересных возомжностей, на которые сторит обратить внимание: встроенная сортировка draw-команд, многопоточное заполнение командного буфера, собственный компилятор шейдеров с GLSL-подобным языком. Применяется в ioquake3, MAME, Rotwood от Klei Entertainment, Braid, Tomb4Plus (открытая реализация движка Tomb Raider IV), а Unity использовала bgfx в своём экспериментальном Project Tiny, хотя проект был закрыт. Тут интересным будет посмотреть как работает двухпоточный режим (буфер + рендер): API-поток и render-поток исполняются параллельно и пока render-поток отрабатывает GPU-команды текущего кадра, API-поток уже собирает команды следующего. Это базовая структурная двухбуферная модель фрейма, со сменой активного (у вас фактически живет два инстанса кадра, следующий и текущий), и она сильно отличается от существующих решений, где традиционно вся логика привязана к контексту одного потока (рисуем текущий в несколько потоков + рендерим). Diligent Engine (https://github.com/DiligentGraphics/DiligentEngine)
 Автор Egор Юсов, работавший в Intel и Google, публикует статьи об игровой разработке и движке с 2018 года. Движок отличается от sokol и bgfx тем, что изначально проектировался под современные explicit API как первичные, а старые GL и D3D11 поддерживаются в ограниченном виде. В последнее время были добавлены ray tracing через D3D12 и Vulkan с единым API, mesh shaders, и поддержка WebGPU и Emscripten, и биндинги к другим языкам. Применяется в основном в научных и визуализационных проектах. В этой либе можно подсмотреть как правильно спроектировать свой рендер под D3D12 и Vulkan, потому что движок делался без оглядки на старые GAPI и это противоположность bgfx, который рос из GL-мира и тащит его устаревшие модели в современных обертках. Плюс здесь также можно подсмотреть как реализовать автоматическое управление состоянием ресурсов, когда движок сам отслеживает состояния текстур и буферов и вставляет барьеры. И вероятно, это единственная из представленных библиотек, где ray tracing, шейдерная модель, и асинхронные загрузки, реализованы как полноценные части API, а не как экспериментальные расширения или абстракции поверх GL-семантики. The Forge (https://github.com/confettifx/the-forge)
 Разработан командой ConfettiFX и Вольфгангом Энгелем (Wolfgang Engel), графическим программистом Rockstar Games и редактором серий книг ShaderX, GPU Pro и GPU Zen. Первый публичный релиз состоялся в 2018 году. Тут интересно, что Bethesda встроила The Forge как рендер-слой в Creation Engine c 2019 года, и именно на нём работает Starfield. Также рендер слой движка используется в Forza Motorsport и No Man's Sky на macOS. Call of Duty Warzone Mobile использует движок как Android/Vulkan прослойку между своим рендером и нативными API, и по некоторым данным часть кода рендера еще используют Hades, Star Citizen, Mafia 3 и Dirt 4. The Forge поддерживает Xbox, PS4, PS5, Switch и Quest 2, но консольный код закрыт и доступен только под NDA. Это очень нетипичная для опенсорса модель, но она логична для библиотеки, которая позиционирует себя как production-ready инструмент для коммерческих студий. bgfx и sokol такой поддержки вообще не предоставляют в явном виде, да и у Diligent тоже не нет консольного бэкенда. Т.е. при желании можно получить доступ и посмотреть как реализованы GAPI консолей. Эта либа будет интересна, если вы хотите посмотреть на AAA-движок, но лезть в дебри Unreal'a неохота. Мир - не дерево Попытка использовать Scene Graph, который весь мир помещает в большую структуру дерева или графа, давно перестала работать. И проблема не в реализации, а в самой модели. Кружка на столе не является дочерним объектом стола, соседом других кружек, частью дома или родителем кофе внутри неё, она просто стоит на столе и ничего не вложено ни во что другое. Игровой мир это набор объектов с разными отношениями, и разные системы движка описывают эти отношения по-разному и совершенно независимо друг от друга. Большинство реализаций Scene Graph начинаются с красивой архитектуры, а заканчиваются гигантским списком костылей, хаков и специальных случаев, которые позволяют всему этому работать. Я не утверждаю что Scene Graph совершенно бесполезен, но я так до сих пор так и не нашёл причин, которые делали бы его хорошим выбором для чего-то кроме скелета и анимаций. В 70-80-х придумали много отличных вещей которые мы используем до сих пор, но Scene Graph для рендера и центральной структуры движка скорее из тех идей, которые были продуктом своего времени и которые давно уж пора хоронить.-Источник
|