Make Unity Text Animation Easy with Shaders

I spent much too long searching for a solution to animate text in Unity efficiently and flexibly, and when my search came up short I decided to just cobble together a shader myself. Shaders are magical and efficient, after all! Hopefully, if you’re after the same thing, my results will help make life easier for you.

Let’s walk through a shader and corresponding C# script to allow arbitrary text animations in Unity. It took a few failed attempts, but I’ve pieced together a solution that relies only on adding delimiters to the text to indicate which substrings you want to animate. I’m not super experienced with shaders, so there are probably some improvements to make, but this works well enough for me.

We’re going to make some wavy rainbow text like this:

White sample text, with some text animated with a rainbow wave

We’ll do this by retrieving vertex indices for the text we want to animate, passing those indices into a text shader, and then applying the animations to the matching vertices.

C# Script to Retrieve Text Vertices

Start by creating this TextAnimated script as a child of the UnityEngine.UI.Text.

public class TextAnimated : Text
{
    private const char delimiter = '`';
    public Material baseMaterial;
    private float[] animateVerts = new float[16];

    public void Start()
    {
        this.material = Material.Instantiate(this.baseMaterial);
    }
}

First, we define a delimiter character. This is a character we know we will never want to actually appear in our text that we will use to indicate the text we want to animate. I use the grave mark (`) since it’s present on most keyboards and I have never used it for any other reason.

We also expose a Material to use for our text and clone it so that each TextAnimated can modify its own version without affecting the others. This Material will use our text shader.

The animateVerts array will be used to communicate with the text shader. This will act as a big buffer where the TextAnimated will write start and end target vertex indices, so it will hold two indices per animated text range; that is, for each block of text marked with our ` delimiter, animateVerts will receive two indices. (Note that these should be ints, but they are instead floats since Material has no SetIntArray method for some reason.)

Now, we need a way to populate the animateVerts array. Create a method SetText, which we will use instead of assigning to TextAnimated.text directly.

    public void SetText(string newText)
    {
        if (newText.Contains(delimiter))
        {
            string[] substrings = newText.Split(delimiter);
            int charCount = 0;
            int spaces = 0; // Whitespace doesn't have a glyph, so we need to deduct from the vertex indices when counting characters.
            StringBuilder output = new StringBuilder(); // The actual output text should not have the delimiter.

            for (int s = 0; s < substrings.Length; s++)
            {
                output.Append(substrings[s]);
                if (s == substrings.Length - 1 && s % 2 == 0) // The text to animate will always be an odd-numbered substring,
                    break;                                    // so if we're on an even-numbered substring with no corresponding odd-numbered one, we can just stop.

                spaces += substrings[s].Count(c => char.IsWhiteSpace(c));

                this.animateVerts[s] = charCount + substrings[s].Length - spaces; // This gives the index of the character at the start/end of an animated text region, accounting for whitespace.
                this.animateVerts[s] = 
                    this.animateVerts[s] * 4 + // Each glyph has 4 vertices (TL->TR->BR->BL), so this gives the actual vertex index.
                    (s % 2 == 1 ? -1 : 0); // For the ends of animated text substrings, that index will be the first index after the substring, so add -1 to get the last index of the substring instead.

                charCount += substrings[s].Length;
            }
            this.animateVerts[substrings.Length] = -1; // We'll use a -1 index so the shader knows where the valid data ends, since we don't ever clear out this array.
            this.text = output.ToString();
        }
        else
        {
            this.animateVerts[0] = -1;
            this.text = newText;
        }
    }

We first Split our input text on the ` delimiter, so we know that even-numbered substrings are normal text and odd-numbered ones are animated (Split will return empty strings between any adjacent delimiters). Then, we just need to track the length of each substring so we know what character they start and end on. Each character glyph has 4 vertices (for the top-left, top-right, bottom-right, and bottom-left corners, in that order), except for whitespaces which have no glyphs. Using that info, we can calculate the indices of the vertices for our odd-numbered substrings and then write those values into the animateVerts array.

Now, animateVerts has pairs of start and end indices, using a -1 entry to note the end of valid data, but we need to update the shader with these data. We can use OnPopulateMesh, which is called any time a Unity Graphic needs to be redrawn. This is why we’re making a child of Text – so we always update the shader exactly when the text is getting redrawn.

    protected override void OnPopulateMesh(VertexHelper toFill)
    {
        base.OnPopulateMesh(toFill);
        this.material.SetFloatArray("_AnimateVerts", this.animateVerts);
    }

Text Shader to Apply Animations

Now that we have a C# script that retrieves the text vertex indices, we need a shader to use them for animations. Download the built-in shaders for your version of Unity from the Unity downloads archive (or you can use some other text shader if you have one). The UI-Default shader should suffice, so make a copy of that one in your Unity project.

We have two minor edits to make before adding our animation logic. First, the appdata_t struct needs to include a vertex ID. This is a built-in variable for vertex data in ShaderLab, so that each vertex will know its index.

struct appdata_t
{
    // [Other members defined here...]
    uint vid : SV_VertexID;
};

Next, add the _AnimateVerts array in the body of the Pass function. There are likely other members declared there to match the shader’s Properties; we can also declare a member without a corresponding Property if we don’t want it to appear in the Inspector. Make sure it has the same capacity as animateVerts in the C# script.

float _AnimateVerts[16];

In the vert function, we can now add whatever animations we want for the text. Let’s add a simple vertical wave and a cycle through the full rainbow of colors. I’ve included simple implementations of these with some values hard-coded in just to look nice for our example, but in practice you can do whatever your’d like now that you have the vertex indices. Note that we use the built-in _Time value to advance the animations over time.

v2f vert(appdata_t v)
{
    // [Other logic here...]

    // Check this vertex against all valid animation data to see if this vertex should be animated.
    for (uint av = 0; av < _AnimateVerts.Length - 1; av += 2)
    {
        if (_AnimateVerts[av] < 0 || _AnimateVerts[av+1] <= 0) // -1 is used to indicate the end of valid data. Also, no valid end index could possibly be 0, though a start index could.
            break;
        if (v.vid >= _AnimateVerts[av] && v.vid <= _AnimateVerts[av+1])
        {
            OUT.vertex.y += (cos(OUT.vertex.x / 4 * 80 + _Time * -120) / 80); // Vertical wave. The exact values used are arbitrary; tinker with them to suit your needs.
            float t = (_Time + v.vid / 4 * 2.0) * 100; // Rainbow timing. The values are also arbitrary. This uses v.vid so each character is entirely one color.
            float r = cos(t) * 0.5 + 0.5; // 0 * pi
            float g = cos(t + 2.094395) * 0.5 + 0.5; // 2/3 * pi
            float b = cos(t + 4.188790) * 0.5 + 0.5; // 4/3 * pi
            OUT.color.rgba = float4(r, g, b, 1);
        }
    }
    return OUT;
}

All that remains is to create a new Material that uses our shader, assign that as the baseMaterial of the TextAnimated, and call SetText with the text to animate!

Full Unity Text Code

Here’s the full TextAnimated script and the shader edits. There’s also an Editor script for the TextAnimated since it otherwise just looks like a regular Text in the Inspector. Cut them up to suit your needs!

using System.Linq;
using System.Text;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// A Text that can supply a custom shader with data for animated text.
/// </summary>
public class TextAnimated : Text
{
    private float[] animateVerts = new float[16]; // 16 is arbitrarily large. Make sure this matches the shader.

    [SerializeField]
    private Material baseMaterial;
    private const char delimiter = '`';

#pragma warning disable CS0114 // Member hides inherited member; missing override keyword
    public void Start()
#pragma warning restore CS0114 // Unity will handle that since this is a Unity function.
    {
        this.material = Material.Instantiate(this.baseMaterial); // Make a copy so we don't modify the base material. Note that this means edits to the shader won't affect this while in Play mode.
        this.SetText("Here's some text. `WOW!!!` It's great!\nLook, it even crosses `multiple\nlines!`"); // Just for demonstration.
    }

    /// <summary>
    /// Be sure to call this instead of setting TextAnimated.text!
    /// </summary>
    public void SetText(string newText)
    {
        // If the text uses the delimiter, we need to both calculate the correct vertex indices and remove the delimiter from the output text.
        if (newText.Contains(delimiter))
        {
            string[] substrings = newText.Split(delimiter);
            int charCount = 0;
            int spaces = 0; // Whitespace doesn't have a glyph, so we need to deduct from the vertex indices when counting characters.
            StringBuilder output = new StringBuilder(); // The actual output text should not have the delimiter.

            for (int s = 0; s < substrings.Length; s++)
            {
                output.Append(substrings[s]);
                if (s == substrings.Length - 1 && s % 2 == 0) // The text to animate will always be an odd-numbered substring,
                    break;                                    // so if we're on an even-numbered substring with no corresponding odd-numbered one, we can just stop.

                spaces += substrings[s].Count(c => char.IsWhiteSpace(c));

                this.animateVerts[s] = charCount + substrings[s].Length - spaces; // This gives the index of the character at the start/end of an animated text region, accounting for whitespace.
                this.animateVerts[s] = 
                    this.animateVerts[s] * 4 + // Each glyph has 4 vertices (TL->TR->BR->BL), so this gives the actual vertex index.
                    (s % 2 == 1 ? -1 : 0); // For the ends of animated text substrings, that index will be the first index after the substring, so add -1 to get the last index of the substring instead.

                charCount += substrings[s].Length;
            }
            this.animateVerts[substrings.Length] = -1; // We'll use a -1 index so the shader knows where the valid data ends, since we don't ever clear out this array.
            this.text = output.ToString();
        }
        // If the text doesn't use the delimiter, just show it normally.
        else
        {
            this.animateVerts[0] = -1;
            this.text = newText;
        }
    }

    /// <summary>
    /// This is called whenever the text Graphic needs to be redrawn. We're just using it to know when to update the shader data.
    /// </summary>
    protected override void OnPopulateMesh(VertexHelper toFill)
    {
        base.OnPopulateMesh(toFill);
        this.material.SetFloatArray("_AnimateVerts", this.animateVerts);
    }
}
using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(TextAnimated))]
public class TextAnimatedEditor : UnityEditor.UI.TextEditor
{
    private SerializedProperty baseMaterialProp;

    protected override void OnEnable()
    {
        base.OnEnable();
        this.baseMaterialProp = serializedObject.FindProperty("baseMaterial");
    }

    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();
        serializedObject.Update();
        EditorGUILayout.PropertyField(this.baseMaterialProp, new GUIContent("Base Material"));
        serializedObject.ApplyModifiedProperties();
    }
}
// This is just part of the shader. Add this to the relevant text shader.
Shader "UI/TextAnimated"
{
    // Add this to your struct appdata_t
    uint vid        : SV_VertexID; // Adding this in to identify verts more easily.

    // Declare this in the Pass body
    float _AnimateVerts[16]; // Pairs of start and end indices. There's no Material.SetIntArray for some reason, so use floats.

    // Add this to your vert function
    // Check this vertex against all valid animation data to see if this vertex should be animated.
    for (uint av = 0; av < _AnimateVerts.Length - 1; av += 2)
    {
        if (_AnimateVerts[av] < 0 || _AnimateVerts[av+1] <= 0) // -1 is used to indicate the end of valid data. Also, no valid end index could possibly be 0, though a start index could.
            break;
        if (v.vid >= _AnimateVerts[av] && v.vid <= _AnimateVerts[av+1])
        {
            // Here is where you can call whatever animation logic you need.
            OUT.vertex.y += (cos(OUT.vertex.x / 4 * 80 + _Time * -120) / 80); // Vertical wave. The exact values used are arbitrary; tinker with them to suit your needs.
            float t = (_Time + v.vid / 4 * 2.0) * 100; // Rainbow timing. The values are also arbitrary. This uses v.vid so each character is entirely one color.
            float r = cos(t) * 0.5 + 0.5; // 0 * pi
            float g = cos(t + 2.094395) * 0.5 + 0.5; // 2/3 * pi
            float b = cos(t + 4.188790) * 0.5 + 0.5; // 4/3 * pi
            OUT.color.rgba = float4(r, g, b, 1);
        }
    }
}

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *