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


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

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

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

Почему бы не использовать Canvas?


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

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

  • Определять, какие из врагов находятся на экране, и выделять каждому из них из пула полоску UI.
  • Проецировать позицию врага в камеру, чтобы расположить полоску.
  • Изменять размер «заливки» части полоски, вероятно, как Image.
  • Скорее всего изменять размер полосок в соответствии с типом врагов; например, у крупных врагов должны быть большие полоски, чтобы это не выглядело глупо.

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

Вкратце о моём решении


Краткое описание процесса моей работы:

  • Прикрепляем объекты полосок энергии к врагам в 3D.
    • Это позволяет автоматически располагать и усекать полоски.
    • Позицию/размер полоски можно настраивать в соответствии с типом врага.
    • Полоски мы направим на камеру в коде с помощью transform, который всё равно есть.
    • Шейдер гарантирует, что они всегда будут рендериться поверх всего.
  • Используем Instancing для рендеринга всех полосок за один вызов отрисовки.
  • Используем простые процедурные UV-координаты для отображения уровня заполненности полоски.

А теперь давайте рассмотрим решение подробнее.

Что такое Instancing?


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

Можно делать это вручную, дублируя данные вершин меша X раз в одном буфере, где X — максимальное количество копий, которое может быть отрендерено, а затем использовав массив параметров шейдера для преобразования/окраски/варьирования каждой копии. Каждая копия должна хранить знание о том, каким нумерованным экземпляром она является, чтобы использовать это значение как индекс массива. Затем мы можем использовать индексированный вызов рендера, который приказывает «рендерить только до N», где N — это число экземпляров, которое на самом деле нужно в текущем кадре, меньшее, чем максимальное количество X.

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

Движок Unity тоже поддерживает instancing, в нём есть собственный API и набор макросов шейдеров, помогающие в его реализации. Он использует определённые допущения, например, о том, что в каждом экземпляре требуется полное 3D-преобразование. Строго говоря, для 2D-полосок оно нужно не полностью — мы можем обойтись упрощениями, но поскольку они есть, мы будем использовать их. Это упростит наш шейдер, а также обеспечит возможность использования 3D-индикаторов, например, кругов или дуг.

Класс Damageable


У наших врагов будет компонент под названием Damageable, дающий им здоровье и позволяющий им получать урон от коллизий. В нашем примере он довольно прост:

public class Damageable : MonoBehaviour {
    public int MaxHealth;
    public float DamageForceThreshold = 1f;
    public float DamageForceScale = 5f;

    public int CurrentHealth { get; private set; }

    private void Start() {
        CurrentHealth = MaxHealth;
    }

    private void OnCollisionEnter(Collision other) {
        // Collision would usually be on another component, putting it all here for simplicity
        float force = other.relativeVelocity.magnitude;
        if (force > DamageForceThreshold) {
            CurrentHealth -= (int)((force - DamageForceThreshold) * DamageForceScale);
            CurrentHealth = Mathf.Max(0, CurrentHealth);
        }
    }
}

Объект HealthBar: позиция/поворот


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



Мы используем scale этого объекта, чтобы сделать полоску длинной и тонкой, и разместим её прямо над врагом. Не беспокойтесь о её повороте, мы исправим его с помощью кода, прикреплённого к объекту в HealthBar.cs:

    private void AlignCamera() {
        if (mainCamera != null) {
            var camXform = mainCamera.transform;
            var forward = transform.position - camXform.position;
            forward.Normalize();
            var up = Vector3.Cross(forward, camXform.right);
            transform.rotation = Quaternion.LookRotation(forward, up);
        }
    }

Этот код всегда направляет quad в сторону камеры. Мы можем выполнять изменение размера и поворота в шейдере, но я реализую их здесь по двум причинам.

Во-первых, instancing в Unity всегда использует полный transform каждого объекта, и поскольку мы всё равно передаём все данные, можно их и использовать. Во-вторых, задание масштаба/поворота здесь гарантирует, что ограничивающий параллелограмм для усечения полоски всегда будет верным. Если бы мы сделали задание размера и поворота обязанностью шейдера, то Unity мог бы усекать полоски, которые должны быть видимы, когда они находятся близко к краям экрана, потому что размер и поворот их ограничивающего параллелограмма не будут соответствовать тому, что мы собираемся рендерить. Разумеетсяя, мы могли бы реализовать свой собственный способ усечения, но обычно при возможности лучше использовать то, что у нас есть (код Unity является нативным и имеет доступ к большему количеству пространственных данных, чем мы).

Я объясню, как рендерится полоска, после того, как мы рассмотрим шейдер.

Шейдер HealthBar


В этой версии мы создадим простую классическую красно-зелёную полоску.

Я используют текстуру размером 2x1, с одним зелёным пикселем слева и одним красным справа. Естественно, я отключил mipmapping, фильтрацию и сжатие, а для параметра addressing mode задал значение Clamp — это значит, что пиксели нашей полоски всегда будут идеально зелёными или красными, а не растекутся по краям. Это позволит нам изменять в шейдере координаты текстуры для сдвига линии, разделяющей красный и зелёный пиксели вниз и вверх по полоске.

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

Для начала мы объявим необходимые нам свойства:

Shader "UI/HealthBar" {
    Properties {
        _MainTex ("Texture", 2D) = "white" {}
        _Fill ("Fill", float) = 0
    }

_MainTex — это красно-зелёная текстура, а _Fill — значение от 0 до 1, где 1 — это полное здоровье.

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

    SubShader {
        Tags { "Queue"="Overlay" }

        Pass {
            ZTest Off

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

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma multi_compile_instancing
    #include "UnityCG.cginc"

По большей мере это стандартный bootstrap, за исключением #pragma multi_compile_instancing, которая сообщает компилятору Unity, что нужно компилировать для Instancing.

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

    struct appdata {
        float4 vertex : POSITION;
        float2 uv : TEXCOORD0;
        UNITY_VERTEX_INPUT_INSTANCE_ID
    };

Также нам нужно задать, что именно будет находиться в данных экземляров, кроме того, что за нас обрабатывает Unity (transform):

    UNITY_INSTANCING_BUFFER_START(Props)
    UNITY_DEFINE_INSTANCED_PROP(float, _Fill)
    UNITY_INSTANCING_BUFFER_END(Props)

Так мы сообщаем, что Unity должен создать буфер под названием «Props» для хранения данных каждого экземпляра, и внутри него мы будем использовать по одному float на экземпляр для свойства под названием _Fill.

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

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

Также нем нужно изменять UV, чтобы менять расположение точки перехода из красного в зелёный в соответствии со свойством _Fill. Вот соответствующий фрагмент кода:

    UNITY_SETUP_INSTANCE_ID(v);
    float fill = UNITY_ACCESS_INSTANCED_PROP(Props, _Fill);
    // generate UVs from fill level (assumed texture is clamped)
    o.uv = v.uv;
    o.uv.x += 0.5 - fill;

UNITY_SETUP_INSTANCE_ID и UNITY_ACCESS_INSTANCED_PROP выполняют всю магию, осуществляя доступ к нужной версии свойства _Fill из буфера констант для этого экземпляра.

Мы знаем, что в обычном состоянии UV-координаты четырёхугольника (quad) покрывают весь интервал текстуры, и что разделительная линия полоски находится посередине текстуры по горизонтали. Поэтому небольшие математические расчёты по горизонтали сдвигают полоску влево или вправо, а значение Clamp текстуры обеспечивают заполнение оставшейся части.

Фрагментный шейдер не мог быть проще, потому что вся работа уже выполнена:

    return tex2D(_MainTex, i.uv);

Полный код шейдера с комментариями доступен в репозитории GitHub.

Материал Healthbar


Дальше всё просто — нам всего лишь нужно назначить нашей полоске материал, который использует этот шейдер. Больше почти ничего делать не нужно, достаточно только выбрать нужный шейдер в верхней части, назначить красно-зелёную текстуру, и, что самое важное, поставить флажок «Enable GPU Instancing».

image

Обновление свойства HealthBar Fill


Итак, у нас есть объект полоски здоровья, шейдер и материал, который нужно рендерить, теперь нужно задать для каждого экземпляра свойство _Fill. Мы делаем это внутри HealthBar.cs следующим образом:

    private void UpdateParams() {
        meshRenderer.GetPropertyBlock(matBlock);
        matBlock.SetFloat("_Fill", damageable.CurrentHealth / (float)damageable.MaxHealth);
        meshRenderer.SetPropertyBlock(matBlock);
    }

Мы превращаем CurrentHealth класса Damageable в значение от 0 до 1, разделив его на MaxHealth. Затем мы передаём его свойству _Fill с помощью MaterialPropertyBlock.

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

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

Здесь нет почти ничего, кроме бойлерплейта для задания переменных, и код довольно скучный; подробности см. в репозитории GitHub.

Демо


В репозитории GitHub есть тестовое демо, в котором куча злых синих кубиков уничтожается героическими красными сферами (ура!), получая урон, отображаемый описанными в статье полосками. Демо написано в Unity 2018.3.6f1.

Эффект от использования instancing можно наблюдать двумя способами:

Панель Stats


Нажав Play, щёлкните на кнопку Stats над панелью Game. Здесь вы можете увидеть, сколько вызовов отрисовки экономится благодаря instancing (батчингу):

image

Запустив игру, вы можете нажать на материал HealthBar и снять флажок с «Enable GPU Instancing», после чего число сэкономленных вызовов снизится до нуля.

Отладчик кадров


Запустив игру, перейдите в меню Window > Analysis > Frame Debugger, а затем нажмите в появившемся окне «Enable».

Слева внизу вы увидите все выполняемые операции рендеринга. Заметьте, что пока там есть множество отдельных вызовов для врагов и снарядов (при желании можно реализовать instancing и для них). Если прокрутить до самого низа, то вы увидите пункт «Draw Mesh (instanced) Healthbar».

Этот единственный вызов рендерит все полоски. Если нажать на эту операцию, а затем на операцию над ней, то вы увидите, что все полоски исчезнут, потому что они отрисовываются за один вызов. Если находясь во Frame Debugger, вы снимете у материала флажок «Enable GPU Instancing», то увидите, что одна строка превратилась в несколько, а после установки флажка — снова в одну.

Как можно расширить эту систему


Как я говорил раньше, поскольку эти полоски здоровья являются реальными объектами, ничто не мешает превратить простые 2D-полоски во что-то более сложное. Они могут быть полукругами под врагами, которые уменьшаются по дуге, или вращающимися ромбами над их головами. Используя тот же подход, можно по-прежнему рендерить их все за один вызов.

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


  1. KpoKec
    12.04.2019 15:17

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


    1. Tutanhomon
      12.04.2019 16:08

      Рисовать бары в канвасе такая себе идея — никому не понравится канвас, который перегенеривает свой меш в каждом апдейте.


      1. KpoKec
        12.04.2019 17:04
        +1

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


        1. Tutanhomon
          12.04.2019 17:36

          ВО-первых, вращение на камеру тоже можно через шейдер сделать, во вторых — генерация нового канвас-меша (и кучи GC впридачу) — не одно и то же, что инстансирование баров на видеокарте. Почитайте внимательнее как работает инстансинг — и не путайте с динамик батчингом.


  1. KpoKec
    12.04.2019 15:19

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


  1. KnifeEntertainment
    13.04.2019 13:01

    Если я еще хочу проценты выводить цифрой над полоской или на полоске, чем его лучше делать, чтобы работал инстансинг?