Оптимизация Minecraft или "Почему лагает даже на RTX 5090"? | День 73
Недавно я начал углубляться в тему рендеринга Vulkan (да и OpenGL), а также изучил подходы к рендерингу в популярных движках вроде Unity или Unreal Engine 5. В связи с этим хочу рассказать про подход Minecraft (да и Hytale в том числе). Пост большой и отношения к HollowEngine практически не имеет, но возможно кому-то будет интересно почитать :)
Думаю все заметили, что при 32 чанках уже начинаются просадки FPS, причём даже на топовом железе, которое легко тянет киберпанк в 165 fps, но Minecraft без теней, отражений, реалистичной воды едва-едва вывозит 80 кадров, но почему?
Прежде всего, при прогрузке мира все чанки упаковываются в уникальные VAO - это что-то вроде большого массива с вершинами, координатами текстур, цветом, освещением и т.п. После чего для каждого чанка вызваются команды вроде "Установить текущий VAO" и "Нарисовать текущий VAO". Вроде бы всё просто и логично? - Но в этом и заключается проблема, когда таких чанков 100-200 штук, это не так уж и страшно, но на деле их тысячи. И проблема даже не в том, что железо слабое. Проблема в том, что эти команды идут примерно по следующему пути: Java (JNI) -> OpenGL -> CPU -> GPU, то есть для каждого чанка приходится постоянно спамить от процессора к видеокарте по одной команде: "Нарисуй этот чанк". И даже если у тебя видеокарта легко справится с сотнями миллионов вершин, ей эти данные будут поступать такими маленькими группами, грубо говоря забивая шину.
И что же с этим делать и почему в других играх такой проблемы нет? - Прежде всего, в других играх всё куда проще, если в Minecraft нужно зарендерить город, видеокарта получает условные 500 команд "Нарисуй этот чанк", а в большинстве других игр это просто 1 команда "Нарисуй мне город целиком". Логично было бы сделать также в Minecraft, но тут возникает нюанс: в Minecraft вы легко можете сломать любой блок в мире, а в других играх - нет, максимум только некоторые допустимые объекты, вроде тех же деревьев. А после ломания блока придётся целиком обновить всю карту и отправить новую на GPU.
Но решения тут всё же есть, по сути их 2: LOD'ы и Indirect-рендеринг. Плюс их можно комбинировать между собой.
Про LOD'ы расскажу лишь кратко: они просто упрощают и объединяют те чанки, что находятся далеко от игрока, тем самым вместо того, чтобы рисовать 9 чанков, может быть нарисован всего один, но упрощённый. Игрок всё равно не заметит разницы, ведь он сильно далеко, но у такого подхода есть 2 больших минуса. Он требует очень много оперативки, ведь нужно хранить геометрию не только для обычных чанков, к тому же и сам мир приходится прогружать на тысячи блоков вперёд. А ещё перестраивать эти LOD'ы очень дорого, поскольку обычно есть где-то 6-8 уровней детализации и объединения нескольких чанков и получается - игрок сломал блок, и теперь придётся все эти LOD'ы строить заново. Тут конечно много хитрых ходов, огромные БД, генерация чанков на GPU и т.п. Но я сам в это дело пока не лез, так что строить теорий не буду.
Второй метод, Indirect-рендеринг, на деле куда интереснее: Вместо того, чтобы делать отдельный VAO под каждый чанк, создаётся один большой массив для всех прогруженных чанков и в этом массиве размечается пространство (грубо говоря, каждые 1024 байта - новый чанк), при добавлении новых чанков они записываются в этот массив и сохраняется индекс этого чанка в массиве, а при удалении этот индекс просто удаляется (при этом сам чанк может и дальше висеть в памяти, со временем его просто заменят другие чанки), а вместо 500 команд "Нарисуй мне этот чанк", создаётся всего одна: "Нарисуй мне объекты из {массив} с индексами [0, 1, 3, 6, 7, ...]".
Появляется логичный вопрос - моджанг нанимают на работу говнокодеров почему в Minecraft сразу так не сделали? А всё просто, такие возможности появились только в OpenGL 4.0, а Minecraft держит совместимость c 3.2 (он вышел в 2009), поэтому никакой оптимизации под современное железо там и не делают.
Разве что с одним уточнением - Sodium использует схожий подход, но с небольшими упрощениями (опять же, для совместимости).