Unity: draw many health bars in one drawcall

Original author: Steve Streeting
  • Transfer
Recently, I needed to solve a problem that is quite common in many games with a top view: to render on the screen a whole bunch of enemy health bars. Something like this:


Obviously, I wanted to do this as efficiently as possible, preferably in one draw call. As usual, before starting work, I did a little online research on other people's decisions, and the results were very different.

I will not shame anyone for the code, but suffice it to say that some of the solutions were not entirely brilliant, for example, someone added a Canvas object to each enemy (which is very inefficient).

The method that I came to as a result is slightly different from everything that I saw in others, and does not use any UI classes (including Canvas) at all, so I decided to document it for the public. And for those who want to learn the source code, I posted it on Github .

Why not use Canvas?


One Canvas for each enemy is obviously a bad decision, but I could use a common Canvas for all enemies; a single Canvas would also lead to rendering call batching.

However, I do not like the amount of work performed in each frame related to this approach. If you use Canvas, then in each frame you have to perform the following operations:

  • Determine which of the enemies are on the screen, and select each of them from the pool UI strip.
  • Project the enemy’s position in the camera to position the strip.
  • Resize the "fill" part of the strip, probably like Image.
  • Most likely to change the size of the strips in accordance with the type of enemies; for example, large enemies should have large strips so that it does not look silly.

Anyway, all this would contaminate the Canvas geometry buffers and lead to a rebuild of all vertex data in the processor. I did not want all this to be done for such a simple element.

Briefly about my decision


A brief description of my work process:

  • We attach objects of energy strips to enemies in 3D.
    • This allows you to automatically arrange and trim strips.
    • The position / size of the strip can be adjusted according to the type of enemy.
    • We will direct the stripes to the camera in the code using transform, which is still there.
    • The shader ensures that they always render on top of everything.
  • We use Instancing to render all the strips in a single draw call.
  • We use simple procedural UV-coordinates to display the level of fullness of the strip.

Now let's look at the solution in more detail.

What is Instancing?


In working with graphics, the standard technique has been used for a long time: several objects are combined together so that they have common vertex data and materials and they can be rendered in one draw call. This is exactly what we need, because every draw call is an extra load on the CPU and GPU. Instead of making a single draw call for each object, we render them all at the same time and use a shader to add variability to each copy.

You can do this manually by duplicating the mesh vertex data X times in one buffer, where X is the maximum number of copies that can be rendered, and then using the array of shader parameters to convert / color / vary each copy. Each copy must store knowledge about what numbered instance it is, in order to use this value as an index of the array. Then we can use the indexed render call, which orders “render only to N”, where N is the number of instances that is actually needed in the current frame, less than the maximum number of X.

Most modern APIs already have code for this, so you don’t need to do this manually. This operation is called "Instancing"; in fact, it automates the process described above with predefined restrictions.

The Unity engine also supports instancing , it has its own API and a set of shader macros that help in its implementation. It uses certain assumptions, for example, that each instance requires a full 3D transformation. Strictly speaking, for 2D strips it is not needed completely - we can do with simplifications, but since they are, we will use them. This will simplify our shader, and also provide the ability to use 3D indicators, for example, circles or arcs.

Class damageable


Our enemies will have a component called Damageable, giving them health and allowing them to take damage from collisions. In our example, it is quite simple:

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 Object: Position / Turn


The health bar object is very simple: in fact, it is just a Quad attached to the enemy.



We use the scale of this object to make the strip long and thin, and place it directly above the enemy. Do not worry about its rotation, we will fix it using the code attached to the object in 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);
        }
    }

This code always directs the quad toward the camera. We can perform resizing and rotation in the shader, but I implement them here for two reasons.

Firstly, Unity instancing always uses the complete transform of each object, and since we transfer all the data anyway, you can use it. Secondly, setting the scale / rotation here ensures that the bounding parallelogram for trimming the strip will always be true. If we made the task of size and rotation the responsibility of the shader, then Unity could truncate the strips that should be visible when they are close to the edges of the screen, because the size and rotation of their bounding parallelogram will not correspond to what we are going to render. Of course, we could implement our own method of truncation, but usually, if possible, it is better to use what we have (Unity code is native and has access to more spatial data than we do).

I will explain how the strip is rendered after we look at the shader.

Shader HealthBar


In this version, we will create a simple classic red-green strip.

I use a 2x1 texture with one green pixel on the left and one red on the right. Naturally, I turned off mipmapping, filtering, and compression, and set the addressing mode parameter to Clamp, which means that the pixels in our strip will always be perfectly green or red, and will not spread around the edges. This will allow us to change the texture coordinates in the shader to shift the line dividing the red and green pixels down and up the strip.

(Since there are only two colors here, I could just use the step function in the shader to return to the point of one or the other. However, this method is convenient because you can use a more complex texture if desired, and this will work similarly while the transition is in mid texture.)

First, we will declare the properties we need:

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

_MainTex- this is a red-green texture, and _Fill- a value from 0 to 1, where 1 is full health.

Next, we need to order the strip to render in the overlay queue, which means to ignore all the depth in the scene and render on top of everything:

    SubShader {
        Tags { "Queue"="Overlay" }
        Pass {
            ZTest Off

The next part is the shader code itself. We are writing a shader without lighting (unlit), so we don’t need to worry about integration with various Unity surface shaders, this is a simple vertex / fragment shader pair. First, write bootstrap:

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

For the most part, this is a standard bootstrap, with the exception of the #pragma multi_compile_instancingone that tells the Unity compiler what to compile for Instancing.

The vertex structure must include instance data, so we will do the following:

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

We also need to specify what exactly will be in the data of instances, in addition to what Unity (transform) processes for us:

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

So we are reporting that Unity must create a buffer called “Props” to store the data for each instance, and inside it we will use one float per instance for the property called _Fill.

You can use several buffers; it is worth doing if you have several properties updated at different frequencies; dividing them, you can, for example, not change one buffer when changing another, which is more efficient. But we do not need this.

Our vertex shader almost completely does the standard work, because size, position and rotation are already transferred to transform. This is implemented usingUnityObjectToClipPoswhich automatically uses transform of each instance. One could imagine that without instancing this would usually be a simple use of a single matrix property. but when using instancing inside the engine, it looks like an array of matrices, and Unity independently selects a matrix suitable for this instance.

Also, you need to change UV to change the location of the transition point from red to green in accordance with the property _Fill. Here is the relevant code snippet:

    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_IDand UNITY_ACCESS_INSTANCED_PROPdo all the magic by accessing the correct version of the property _Fillfrom the constant buffer for this instance.

We know that in the normal state, the quadrant's UV coordinates cover the entire texture interval, and that the dividing line of the strip is in the middle of the texture horizontally. Therefore, small mathematical calculations horizontally shift the strip to the left or right, and the Clamp value of the texture ensures the filling of the remaining part.

The fragment shader could not be simpler because all the work has already been done:

    return tex2D(_MainTex, i.uv);

The full comment shader code is available in the GitHub repository .

Healthbar Material


Then everything is simple - we just need to assign to our strip the material that this shader uses. Almost nothing more needs to be done, just select the desired shader in the upper part, assign a red-green texture, and, most importantly, check the “Enable GPU Instancing” box .

image

HealthBar Fill Property Update


So, we have an object of the health bar, a shader and the material to be rendered, now we need to set a property for each instance _Fill. We do this internally HealthBar.csas follows:

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

We turn the CurrentHealthclass Damageableinto a value from 0 to 1, dividing it by MaxHealth. Then we pass it to the property _Fillusing MaterialPropertyBlock.

If you have not used MaterialPropertyBlockto transfer data to shaders, even without instancing, then you need to study it. It is not well explained in the Unity documentation, but it is the most efficient way to transfer data from each object to shaders.

In our case, when instancing is used, the values ​​for all health bars are packed in a constant buffer so that they can be transferred all together and drawn at a time.

There is almost nothing here except a boilerplate for setting variables, and the code is rather boring; see the GitHub repository for details .

Demo


The GitHub repository has a test demo in which a bunch of evil blue cubes are destroyed by heroic red spheres (hurray!), Taking the damage displayed by the stripes described in the article. Demo written in Unity 2018.3.6f1.

The effect of using instancing can be observed in two ways:

Stats Panel


After clicking Play, click on the Stats button above the Game panel. Here you can see how many draw calls are saved thanks to instancing:

image

Having launched the game, you can click on the HealthBar material and uncheck the “Enable GPU Instancing” checkbox , after which the number of saved calls will be reduced to zero.

Frame debugger


After launching the game, go to Window> Analysis> Frame Debugger, and then click “Enable” in the window that appears.

At the bottom left you will see all the rendering operations performed. Note that while there are many separate challenges for enemies and shells (if you wish, you can implement instancing for them too). If you scroll to the bottom, you will see the item "Draw Mesh (instanced) Healthbar".

This single call renders all strips. If you click on this operation, and then on the operation on it, you will see that all the strips disappear, because they are drawn in one call. If being in the Frame Debugger, you uncheck the Enable GPU Instancing checkbox from the material, you will see that one line turned into several, and after setting the flag again into one.

How to expand this system


As I said before, since these health bars are real objects, there is nothing stopping you from turning simple 2D bars into something more complex. They can be semicircles under enemies that decrease in an arc, or rotating rhombuses above their heads. Using the same approach, you can still render them all in one call.

Also popular now: