Godot game engine: оптимизация

Различные принципы, которые должен понимать разработчик, чтобы повысить производительность игры. Многое из перечисленного касается не только движка Godot.

Godot game engine: оптимизация
Godot game engine: оптимизация

Код

1)

Первая вещь - следует кэшировать ссылки на объекты. То есть вместо какого-нибудь $Camera.translation = $Hero.translation стоит заранее объявить ссылки на камеру и героя, и далее использовать уже их. То есть, пишем в коде до функции _ready: onready var camera = $Camera и onready var hero = $Hero

а далее в коде оперируем уже этими ссылками

camera.translation = hero.translation

В Godot 4 форма записи не сильно отличается: @onready var camera = $Camera

Вместо явного задания имени объекта можно экспортировать поле ссылки в редактор и затем копировать туда путь до нужного объекта:

export var path_to_camera : NodePath или export (NodePath) var path_to_camera в Godot 3х

@export var path_to_camera : NodePath в Godot 4

Правда так мы получаем только строку, обозначающую путь к объекту и далее в коде нужно будет завести отдельную переменную var camera = null и присвоить ей ссылку на объект уже внутри первичной функции _ready() - camera = get_node(path_to_camera).

Однако, в Godot 4 можно сделать, чтобы в поле экспорта сразу можно было добавлять сцену/узел, минуя шаг предварительного получения пути:

@export var camera : Node

Почему не стоит обращаться к объектам через $ ($Camera, $Target, $StartMenu/start_button) в основном коде? Потому, что это некая упрощённая форма записи команды поиска по иерархии, при вызове перебирающая объекты сцены, чтобы найти нужный. То есть эта форма записи на деле вызывает функцию поиска по имени, которая занимает некоторое время и в зависимости от количества объектов в сцене и прочих факторов это время может быть значительным. Поначалу трата времени на обработку поиска через $ не так заметна, но когда сцена становится сложнее, объектов и скрипотв становится много и вызов $ происходит в циклах - потери уже становятся значительными.

2)

Минимизировать тяжелые вычисления в циклах. То есть, внутри функций _process(delta) и _physics_process(delta) не должно высчитываться слишком много лишнего. Например, мы хотим закончить уровень, когда все враги умерли, и вместо того чтобы каждый тик цикла проверять состояние всех врагов (к тому же обращаясь к ним в некэшированном виде), как тут:

func _process(delta): if $Level1/Enemy1.health < 1 and $Level1/Enemy2.health < 1 and ... and $Level1/EnemyN.health < 1: doGameOver()

мы, допустим, пишем внутри кода самих врагов проверку на Game Over (в данном случае обращение к отдельно заведённому синглтону Global_script), когда они получают урон и в том случае, если этот урон убивает их:

func getDamage(amount): health -= amount if health < 1: Global_script.tryGameOver()

Вместо Global_script тут мог бы быть, допустим, родительский узел для каждого Enemy, ссылку на который они получали бы через уровень иерархии или в коде самого родительского объекта. А сама функция tryGameOver() могла бы просто считать, сколько раз её вызвали и сравнивать с количеством врагов на уровне, заканчивая игру при выполнении условий.

В то же время объявлять в цикле разные временные переменные для дальнейшего использования - это нормально. Например всякое такое:

var string = ""

или

var translation = hero.translation

или

var place = Vector3.ZERO

Делать постоянные проверки в цикле (if ... :) - это тоже само по себе нормально, просто желательно с ними не перебарщивать. Опять же, если какие-то проверки или части кода не обязательно должны срабатывать слишком часто, то может иметь смысл перенести из цикла _process() в цикл _physics_process(), который "тикает" медленнее или вовсе написать таймер, чтобы делать какие-то сложные проверки с ещё меньшей частотой. Некоторые вещи из цикла _process() наоборот, вытаскивать нежелательно, например, какое-нибудь следование камеры за персонажем, иначе оно будет срабатывать уже не так плавно.

3)

Довольно классическая вещь - следить за количеством отдельных объектов и памятью. Да, каждый байт памяти выделять руками в Godot не придётся (хотя харкорщикам никто не мешает заняться подобным), но лишние объекты всегда стоит удалять из памяти, вызвав для них в нужный момент команду queue_free(), которая натравит на данный объект сборщик мусора. Естественно, следует отследить, чтобы код в принципе не создавал объекты бесконтрольно и бесконечно, а какие-то объекты лучше динамически переиспользовать чем каждый раз создавать новые (подобная практика обычно называется "пул объектов"). Например, у нас в сцене всегда есть только пять одинаковых эффектов взрыва, и вместо создания/удаления новых каждый раз мы будем брать последний использованный, помещать его в новую точки и проигрывать его анимацию, как будто это очередной "новый" взрыв.

Godot game engine: оптимизация

2D

1)

Всё что может быть в атласах - должно быть в атласах (если это будет ещё и квадрат со сторонами равными степеням двойки - совсем отлично). То есть не стоит лепить отдельную картинку на каждый отдельный 2д элемент - желательно собирать отдельные картинки в общий атлас. Собственно, движок может склеивать отдельные изображения, но лучше собирать картинки в атласы заранее. Ещё стоит разделять изображения с прозрачностью (какие-то элементы интерфейса) и непрозрачные (атлас с квадратными картинками персонажей, например, или тайлами) в разные атласы.

2)

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

Godot game engine: оптимизация

3D

Трёхмерная графика съедает основную долю производительности в играх, поэтому оптимизировать в первую очередь приходится именно её. Желательно смотреть на показатели профайлера - fps, draw calls - запуская игру в редакторе и тестируя её.

Темы lod'ов и запечки света казаться не буду - это область скорее для крупных вылизанных проектов, для разработки под консоли/мобилки, и пайплайнов по которым работают большие команды, чтобы выжать максимально красивую и правильную картинку, а не для большинства инди-разработчиков. Опять же, далеко не всем проектам это нужно исходя из выбранной перспективы, жанра, платформы. Например, когда игра с видом сверху, и высота камеры не меняется, то и lod'ить особо нечего. Или когда игра не для телефона, и без запечки света всё и так выглядит более менее, да и весит меньше.

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

1)

Всё что может быть в атласах - должно быть в атласах, дубль два. Так как чем меньше материалов в сцене - тем лучше: меньше draw calls, выше fps. То есть, на одной текстуре желательно расположить развёртки нескольких моделей, а не одной. Правда это не всегда удобно, допустим, развёртка каждого отдельного игрового персонажа чаще всего занимает целую текстуру. Здесь опять же есть масса нюансов с тем, какой у конкретной игры сценарий использования материалов в каждый момент времени - если у вас всегда один действующий персонаж игрока в сцене, то почему бы ему не иметь свой отдельный материал, а если таких персонажей всегда сразу 10, то вот здесь уже можно задуматься о том, не совместить ли их материалы каким-то образом.

Отдельный способ покраски, почти бесплатный с точки зрения производительности - vertex painting, то есть древний способ покраски вертексов модели. "Бесплатный" он потому, что формат самой 3д-модели обычно уже содержит информацию о rgb цвете всех вершин и вертексной покраской вы всего-лишь меняете уже лежащие там дефолтные цифры с белого на цветной вариант. Развёртка и текстура для покраски по вертексам не требуется, но и красить можно только сами вертексы. Если говорить про Godot, то в 3х через давний вариант формата .obj вертексный цвет не передавался, зато его переносил collada-формат .dae, например. В обновлённом формате .obj может переносить вертексную покраску из того же Blender, но Godot 3x новый .obj вроде не читает (по крайней мере если в нём включена передача вертексной покраски), а в Godot 4 уже перешли на gltf (хотя прошлые форматы в нём поддерживались и пока поддерживаются, но с определённой версии на них ругается редактор).

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

Также следует понимать, что ключевой момент - это количество материалов, а не самих текстур (хотя они тоже важны). Так как материал - понятие составное и может включать в себя несколько текстур, а в то же время из одной текстуры можно сделать кучу разных материалов, чуть другого цвета, с чуть иными настройками, и так далее. Если у вас 20 материалов в сцене из всего одной текстуры в качестве основы - это 20 РАЗНЫХ материалов.

Кстати, с введением поддержки gltf всё более учащаются случаи переноса мешей из Blender в Godot сразу целыми сценами (да и .blend файлы перекидывают). Для графики, проекта по визуализации - это нормально, но для игры, и игры оптимизированной, как правило, уже нет. Так как тут велик шанс, что у нас как раз образуется куча разных материалов и лишней уникальной непереиспользуемой геометрии (правда по дефолту те же материалы переносятся как пустые слоты, частично сохраняя шейдер в виде чего-то похожего на vertex paint, то есть цвет материала всегда виден, пока в пустой слот материала не назначен какой-то нормальный материал). Требуется хотя бы понимание, как именно приготовить сцену и как внутри движка растащить её элементы по отдельным мешам/префабам.

В Godot 3 можно было отдельные меши закидывать в формате .obj, чтобы затем закидывать их в любые MeshInstance. В прочих форматах меш загружается внутри дополнительной обёртки, из которой его надо было ещё вытащить и просто так загрузить его в MeshInstance на разных сценах было бы нельзя (если не преобразовать в префаб и передавать префаб и тиражировать уже его), если не пересохранить тот отдельный меш в какой-нибудь формат .tres.

В Godot 4 .obj остались, но уже не рекомендуются с определённой версии, а при импорте .glb файла получается обёртка из которой даже единичную сетку нужно вынимать (если не планируется целиком превратить это в префаб, или это меш с костями, который разбирать обычно не нужно). Правда можно пересохранить такой файл через реимпорт в формат .res и загружать в любой MeshInstance уже его. Просто с .obj файлами процесс был более очевидным и простым для понимания, был наглядно виден принцип. Зато через .glb можно закинуть сразу много мешей, разобрав их потом отдельно на префабы (или в пару кликов выдрать из него все меши в виде .res отдельно) и не используя в сцене сам "донорский" .glb файл - только далеко не всем очевиден такой пайплайн работы.

2)

Лучший свет - это когда его нет. Поэтому в играх крупных студиях принято запекать лайтмапы, даже если динамичный свет и тени тоже в игре будут. Но так как у нас разговор не про это, то следует просто уменьшать количество источников света в сцене и отключать тени у всего чего можно. Допустим, использовать один Directional light или даже точечный Omni для всей сцены. Или Directional light может лишь подсвечивать сцену, не генерируя тени, в то время как один или несколько Omni как раз могут создавать тени, располагаясь, допустим, в разных частях уровня или когда один источник света следует конкретно за персонажем. В принципе можно раскидать по уровню какие-то точечные источники не отрасывающие теней, чисто как подсветку - это садит производительность не так мощно, как свет, отбрасывающий тени. Ещё можно выключить отбрасывание тени у тех объектов, которым тень не требуется. Вот, например, у вас на уровне есть пол - зачем ему отрасывать тень? Незачем. Значит для пола можно отключить отбрасывание теней. А ещё можно сделать сам материал unshaded, чтобы на него вобще не влиял свет и тени.

3)

Камера. Стоит отрегулировать дальность отрисовки так, чтобы в видимость не попадало слишком большое пространство. В Godot 3 и 4 камера из коробки умеет в алгоритм frustrum culling, который отсекает из отрисовки то, чего камера не видит. Важно понимать, что камера нарисует ВСЁ, что попало в область её видимости, даже если один объект заслонён другими, или его размеры ужасно малы - камера его видит. Другой важный момент - если хотя бы мизерная часть объекта попадает на камеру, он весь целиком будет отрисован, для того чтобы показать камере лишь свой пиксель. Что из этого следует? Слишком большие объекты нужно разбивать на части, чтобы на камеру чаще отрисовывался лишь фрагмент/фрагменты объекта, а не весь он целиком. Что обычно может быть таким большим объектом? Да тот же самый terrain, которого в Godot по умолчанию нет (а большинству игр оно и не нужно), но он подключается плагинами. Если же делать terrain самостоятельно, то как минимум следует разбить его на части. В иных движках terrain также побит на фрагменты-чанки хотя это может быть неочевидно, плюс ему добавлен алгоритм генерации на основе редактируемой карты высот, и могут быть написаны какие-то дополнительные оптимизации или поддержка всяких фич, вроде динамической "разрушаемости" и всякого подобного. В некоторых проектах можно применить окклюдеры, имеющиеся в движке - это некие области, через которые взгляд камеры не проникает.

В каких-то движках для камеры написан полноценный продвинутый алгоритм occlusion culling, чтобы отсортировать объекты и убрать те, которых не видно за другими. Тем не менее он может, наоборот, замедлять процесс в играх определённого типа. В Godot 4 добавлена возможность автоматически "запекать" статичные окклюдеры для 3d-мешей, помимо расстановки окклюзий вручную.

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

4)

Геометрия. Чем меньше треугольников - тем лучше. Вся геометрия, которая может быть сшита в один объект - должна быть сшита в один объект. НО, не забываем про frustrum culling - не сшивай в один меш слишком уж много геометрии, иначе камера не сможет её эффективно отсекать. И, не забываем про то, что в один объект мы обычно можем сшить ТОЛЬКО объекты на которых подразумевается один и тот же материал (хотя с этим есть нюансы, но лучше исходить из парадигмы, что на одном объекте лишь один базовый материал + возможные проходы с дополнительными, которые не меняют сути). Собственно, в разных движках всякие алгоритмы динамического/статического батчинга сами сшивают объекты с одинаковым материалом и не далее определённого расстояния в цельный меш, но ничто не мешает делать модели таким образом изначально.

В общем, хорошо когда на сцене мало ОТДЕЛЬНЫХ объектов и они не слишком большие. Условно - вот у нас в игре есть меши-столы и меши-стулья, которые всегда одинаково расставлены рядом с теми столами. Материал у них один и тот же. Для пользователя визуально нет разницы - сколько фактических мешей на сцене, количество треугольников ведь одинаковое, что объединяй их все в единую сетку, что располагай вот так - пообъектно. Но для отрисовки разница есть, поэтому если объединить столы с расположенными вокруг них стульями в цельные объекты - то отрисовка ускорится.

Следует также думать над удалением из объектов тех треугольников, которые не будут видны пользователю, над плотностью сетки и разрешением текстуры. Нет смысла делать сверхдетализированную модель с 2к текстурой, если мы смотрим на неё все время сверху и издалека.

Хорошо когда объекты свёрнуты в заготовки, которые можно переиспользовать в разных местах и клонировать по сцене - в Godot это всё сохранённые сцены, сцены внутри сцен. А говоря языком Unity - префабы. Клонированная таким образом геометрия считается быстрее, чем если бы это всё были бы отдельные уникальные сетки.

Если требуется расположить очень много одинаковых объектов каким-то хаотическим образом (распределяя в контурах определённой сетки с заданной плотностью) и при этом максимально оптимизированно, то в Godot для этого имеется специальный узел - MultiMesh, который позволяет это сделать. В некоторых движках, например, Unigine, расположить много много однотипных инстансов, которые будут более оптимизированны чем обычная сетка, можно более контролируемо.

5)

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

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

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

Godot game engine: оптимизация

Физика

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

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

Для сложной поверхности можно нарисовать сложную форму коллайдера, в случае 2д, или преобразовать сетку в коллайдер, в случае 3д, но здесь уже всё сильно зависит от игры. Где-то такое нужно, и требуется высокая точность поверхности (например, коллайдер для вручную смоделированного террейна). Где-то лучше будет сформировать коллайдер из примитивов (стены подземелья, комнаты, края карты).

6.1K6.1K показов
1.7K1.7K открытий
16 комментариев

Огромное полотно текста для совсем уж новичков. Столько воды даже Толстой не писал.
Скажу за $, но это сокращенная запись get_node, а оно не перебирает рекурсивно все объекты сцены, а идёт по пути до нужного, что в структуре дерева происходит быстро. Но да, использовать в рантайме все равно не стоит

Ответить

Познавательно, вот к вопросу о циклах. Допустим есть простые крестики нолики. Нам нужно проверить по сути два условия победа и ничья. Если ничью, на мой взгляд, проще проверить по кол-ву ходов, мол если ходов 9, а условий победы нет, то ничья. То вот проверка условий победы может быть совершенно разной.
Вариант 1: просто проверяем 8 комбинаций через if...ilif...else
Вариант 2: делаем матрицу и проверяем в каждую сторону от клетки последнего хода, Кол-во совпадающих элементов.

Какой вариант наиболее предпочтителен с точки зрения оптимизации? Если не брать во внимание простоту и лёгкость игры.

Ответить

Вариант 2 конечно же лучше, чем 1. Если захочется увеличить поле на несколько клеток, то не универсальный алгоритм из варианта 1 уже не будет работать и его придётся переделывать. Да и читаться он будет лучше, чем куча if.

Ответить

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

Ответить

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

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

Подписались на child_entered_tree (когда заспавнили моба и он на сцене) и child_exiting_tree (убили моба и удалили со сцены). Обнулили счётчик мобов на сцене, вышли на геймовер.

Ссылки на объекты через $ кажется вообще не очень хорошая штука.

Ответить

При спавне объекта у тебя конечно возникает ссылка на него, и ты можешь в этот момент его на что-то подписать. Или пробросить какие-то сигналы от фиксированных элементов руками, в редакторе.
Тем не менее, заводить ссылки на разные объекты через $ так или иначе приходится, так как вряд ли человек используя визуальный редактор хочет спавнить вобще всё вслепую, как в "доредакторную" эпоху игроделания. Поэтому большая часть элементов будет уже расставлена в сцене заранее и какому-либо скрипту сцены понадобятся ссылки на эти элементы, чтобы двигать их, включать/выключать, показывать /скрывать, передать кому-то ещё и так далее.

Ответить

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

Ответить