image

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

Оптимизация ради игроков и собственного психического здоровья


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

Основы оптимизации кода


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

Минимизация влияния объектов за пределами экрана


Часто этим занимаются движки, а иногда даже сами GPU. Минимизация объёма вычислений для объектов за пределами экрана чрезвычайно важна. В собственной архитектуре лучше разделять объекты на два «слоя» — первый будет графическим представлением объекта, второй — данными и функциями (например, его местоположением). Когда объект находится за пределами экрана, нам больше не нужно тратить ресурсы на его рендеринг и достаточно заниматься его отслеживанием. Отслеживание с помощью переменных таких параметров, как позиция и состояние, значительно снижает потребности в ресурсах.

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

Вот пример псевдокода класса объекта, использующего флаги и ограничения местоположения:

Object NPC {
    Int locationX, locationY; //текущая позиция объекта на 2d-плоскости

	Function drawObject() {
		//функция отрисовки объекта, вызываемая в цикле обновления экрана
	}

	//функция, проверяющая, находится ли объект в текущем вьюпорте
	Function pollObjectDraw(
        array currentViewport[minX,minY,maxX,maxY]
        ) {

	//если он находится внутри вьюпорта, сообщаем, что его нужно отрисовывать
		If (this.within(currentViewport)) {
			Return true;
		}
		Else {
			Return false;
		}
	}
	
}

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

Независимость от обновления кадров


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

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

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

Аналогично упомянутой выше оптимизации, в начальной итерации нашего кода используется простой опрос:

Object NPC {
    boolean hasChanged; //флаг имеет значение true, когда в объект внесены изменения

	//функция, возвращающая флаг
	Function pollObjectChanged(
		return hasChanged();
	}
}

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

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

Непосредственные вычисления и поиск значений


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

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

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

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

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

Использование времени простоя процессора


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

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

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

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

Для реализации такой возможности нужно использовать функционал, имеющийся в выбранном движке, фреймворке или языке, позволяющий определять, насколько сильно используется процессор. Задавайте в своём коде флаги, позволяющие с лёгкостью определить объём «лишних» вычислительных ресурсов и настраивайте подсистемы таким образом, чтобы они проверяли эти флаги и вели себя соответственно.

Сочетание оптимизаций


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

Комментарии (7)


  1. Wolf4D
    10.05.2018 19:28

    О! Я как раз писал для Unity ассет, который (по-умному) отключает объекты за пределами области видимости. По опыту — совет дельный, производительность подскакивает довольно круто — однако, существуют проблемы с увязкой логики и с интеграцией Occlusion Culling. Другое дело, что реализовывать это лучше централизованным менеджером, так достигается экономия и на числе вызовов update.

    Скрин из профайлера на тестовой сцене
    image


    1. Feelnside
      11.05.2018 08:26

      А какие именно объекты отключаете? Ведь меши и так не отрисовываются, если они не попадают в кадр (или скрыты другими, более громоздкими объектами при настроенном Occlusion Culling). Вроде бы только Animator разумно отключать вне поля видимости, но ведь там и так есть способы, при которых Animator не будет жрать ресурсов за пределами кадра.


      1. Wolf4D
        11.05.2018 13:37

        В первую очередь, я отключаю объекты со сложными и/или ресурсоёмкими скриптами — это, в основном, персонажи и противники, особо мудрёные интерактивные объекты уровня, даже интерактивные двери и тому подобное. Пробую отключать объекты со сложной постоянно обсчитываемой физикой, но пока однозначного ответа, стоит ли это делать, не могу дать. Отключать сложные для рендера объекты реального смысла нет — с этим хорошо справляются встроенные механизмы движка, отсечение невидимых граней и OC — с ними не попадающее в кадр просто не рендерится.
        Но не всё так просто — например, если враг зашёл за спину героя, то он должен напроситься на него сзади, а не остановиться, пропав из поля зрения камеры. С этим пришлось потрудиться, но решение нашлось :)


        1. Feelnside
          11.05.2018 14:35

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


          1. Wolf4D
            11.05.2018 17:20

            Можно отключить объект целиком. Тогда не идёт его отрисовка, не осуществляется обновление для скриптов поведения, не рассчитывается физика, и так далее. Идея с отключение отдельных компонентов — хорошая, но с ней очень просто запутаться при настройке (хотя я и думал о таком функционале). Потому я сделал ассет так, чтобы объект деактивировать целиком. Условия деактивации задаёт левел-дизайнер: например, монстры могут деактивироваться чтобы при достаточном удалении игрока от комнаты, где они спавнятся, а разрушаемые предметы — при выходе их из зоны видимости игрока. Соответственно, активируется они при приближении игрока и возвращении в зону видимости соответственно. Это просто и довольно надёжно.
            P.S. Если есть интерес, напишите в личку — могу сбросить посмотреть сам ассет :)


            1. Feelnside
              11.05.2018 22:48

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


  1. perfect_genius
    11.05.2018 21:11

    Можно также коллизии просчитывать не каждый кадр.
    Если объектов крайне много, то можно проверять на коллизию только тех, кто в той же области, что и игрок, а не всех существующих.
    А вообще вот рендеринг видимого в Horizon:
    image