Building a Text Renderer

Hello! In this post I will go through how I built my text renderer in Unity. I will be using my systems I talked about in a previous post and show how I build upon them.

This is all for my game pixel based game ‘Traitors on the Train’. I wanted to make my own fonts instead of scouring dafont.com or something to find a font that vaguely resembled my specific style. I drew all my other sprites so why would text be any different.

Please keep in mind, this is not a step by step tutorial, rather it is a retrospective of my process creating my own text renderer and my understanding of how text renderers work. Ok lets continue…

Being a pixel style game, I can already get away from a few challenges most text renderers need to face. First instead of reading a TTF file and handling vector based rendering (which is something I have not covered in my current rendering system), I can use a regular bitmap instead. I don’t need to scale any of my text nor do I want to because I have made it a hard rule that everything is true to size and avoid any filtering. This means any pixel I draw is exactly where it will be placed in the game.

You have noticed the atlas above is set in a familiar order, that is the ASCII table.

The first 33 symbols are non printable. Between index 32 to 126 are ASCII’s printable characters and the only ones I really care about.

Putting them through my atlas factory, the exclamation mark is index 0 going all the way to tilde at index 93. Each character is treated like a separate sprite and the whole text will be one mesh.

Features Built

Basics

As always I’ll start with a data structure:

public struct TextBoxData
{
    public Vector2 size;
    public float[] lineWidthArray;
    public string[] lineTextArray;

    public float maxLineWidth;
    public int lineCount;
    public int charCount;
}
const int MAX_CHARS = 256;
const int MAX_LINES = 16;

I also define some memory constraints too. I’m not playing on having too much text for one renderer so I’ve made the maximum character length 256 and the maximum lines 16. Considering I’m using C# with Unity, I’d rather the memory to be occupied with a persistent array rather than a dynamic list to avoid garbage collection.

Now over to the function that actually sets the text:

public void SetTextWorld(string inputText, float alpha)
{
    if (inputText == null) return;

    text = inputText;

    textBoxData.lineCount = 0;
    textBoxData.charCount = 0;

    string curLineText = "";
    float curLineWidth = -kerning;
    float maxLineWidth = 0f;
        
    string[] words = text.Split(' ');
...

First I reset the line and character count to 0, ready to sum up that data. the curLineText will be the string of the current line. curLineWidth starts at the negative kerning value. This is because every word will always have one more character than space between characters which is important when calculating when there should be a new line. I also want to keep track of the max line width because that will determine the bounds width of the entire text. I then split the text into words using the Split() function so I can keep each word together without splitting a word between lines.

...
for (int i = 0; i < words.Length; i++)
{
    string word = words[i];

    float wordWidth = 0f;

    for (int j = 0; j < word.Length; j++)
    {
        char c = word[j];

        int letterIndex = c - 33;

        if (letterIndex < 0) continue;

        SimpleSprite sprite = textAtlas.atlas.simpleSprites[letterIndex];

        wordWidth += sprite.worldSize.x + kerning;
    }

    float spacingWidth = curLineText.Length > 0 ? spacing : 0f;
    float newLineWidth = curLineWidth + spacingWidth + wordWidth;

    if (curLineText.Length > 0 && newLineWidth > textBoxData.size.x)
    {
        textBoxData.lineTextArray[textBoxData.lineCount] = curLineText;
        textBoxData.lineWidthArray[textBoxData.lineCount] = curLineWidth;
        textBoxData.lineCount++;
        curLineText = word;
        curLineWidth = wordWidth - kerning;
        if (curLineWidth > maxLineWidth) maxLineWidth = curLineWidth;
    }
    else
    {
        if (curLineText.Length > 0) curLineText += " ";

        curLineText += word;
        curLineWidth = newLineWidth;
    }

    if (curLineWidth > maxLineWidth) maxLineWidth = curLineWidth;
    textBoxData.charCount += word.Length;
}
...

Here come the nested loops. The first nested loop is to calculate the textbox data for the second loop to read. For the outer loop, I iterate through each word of the text and for the inner loop, I iterate through each character. The letterIndex you’ll notice is subtracted by 33. This is because of the ASCII indexing I mentioned above where the printable characters start at index 33. I can look up the character sprite from my atlas with that index. All I need from the sprite at the moment is the world width to then add on to the wordWidth. The kerning value is then added on top for each character. After iterating through the current word, I add the curLineWidth to the spacingWidth between each word as well as the wordWidth that was just calculated. I test to see if the newLineWidth is larger than the text box width. If it is, I know a new line should start. I initially thought the curLineWidth should reset to 0, because it’s a new line.

But I quickly found out that was wrong shown here where the word “Old is outside the text box. How do I handle the word that triggers the new line to occur? Well this word would be the first of the new line so whatever that word is needs to snap there, meaning the curLineWidth resets to the wordWidth minus the kerning value because there’s no kerning spacing before the first word of each line. If the newLineWidth is not greater than the textbox width, it’s a little easier, we just continue that line adding back a space for the next word and updating the curLineWidth. In the end of the outer loop I update the maxLineWidth if needed and accumulate the character count.

...
float curPosY = -textAtlas.typeWorldHeight;
...

So we now know the length of each line, but what about the height? This is determined but the tallest letter of the font where I cache the height value in the variable typeWorldHeight. This is stored in the atlas and calculated in edit time.

Another thing to note is where the transform lies which at the top and the x axis is determined by the alignment type which I’ll explain later. For now, I want to write left to right, top to bottom so having the transform here means no matter how long or tall the text is, the world position won’t change. Relating back to the code, the curPosY starts as the negative of typeWorldHeight because my character sprites pivots are on the bottom left. I need to offset the first line by that height downwards. I can now start my second nested loop.

...
for (int i = 0; i < textBoxData.lineCount; i++)
{
    string line = textBoxData.lineTextArray[i];
    float lineWidth = textBoxData.lineWidthArray[i];

    float curPosX = 0;

    if (alignmentType == AtlasTextAlignmentType.Center)
    {
        curPosX = -(lineWidth * 0.5f);
    }
    else if (alignmentType == AtlasTextAlignmentType.Right)
    {
        curPosX = -lineWidth;
    }

    for (int  j = 0; j < line.Length; j++)
    {
        char c = line[j];

        if (c == ' ')
        {
            curPosX += spacing;
            continue;
        }

        int letterIndex = c - 33;

        if (letterIndex < 0) continue;

        SimpleSprite sprite = textAtlas.atlas.simpleSprites[letterIndex];

        Vector4 worldPivSize = new Vector4();

        worldPivSize.x = curPosX;
        worldPivSize.y = curPosY;
        worldPivSize.z = sprite.worldSize.x;
        worldPivSize.w = sprite.worldSize.y;
        worldPivotsAndSizes[spriteIndex] = worldPivSize;

        uvSizesAndPositions[spriteIndex] = sprite.uvSizeAndPos;
        scalesAndFlips[spriteIndex] = Vector4.one;

        spriteIndex++;

        curPosX += sprite.worldSize.x + kerning;
    }
    curPosY -= textAtlas.typeWorldHeight;
}

textBoxData.size.y = Mathf.Max(Mathf.Abs(curPosY + textAtlas.typeWorldHeight), textAtlas.typeWorldHeight); 
bounds.size = new Vector2(maxLineWidth, textBoxData.size.y);
...

The outer loop iterates through each line I calculated earlier. This is where I handle my alignments to bare with me as I will include that too. The curPosX starts at 0 if the alignment type is on the left which is the default case. Otherwise the curPosX starts in the middle of the lineWidth if the alignment type is Center or at the end of the lineWidth if the alignment type is Right. The inner loop iterates through the line characters and look up the character sprites again. This time I store the data for my Renderer Feature to hand off to the GPU to render in my shader. The sprite information is already cached in edit time so I just need to fill the arrays of worldPivotsAndSizes and uvSizesAndPositions and the scalesAndFlips with the sprite data and then add onto the curPosX after for the next character sprite. When the line ends curPosY offsets downwards by the typeWorldHeight. This all could’ve been done in one nested loop but the problem became apparent when I added alignments. Going left to right or right to left is easy because I can just append each character to the following position without any other characters moving. The center alignment is a different story.

Each character’s position depends on the characters in front of it, so I saw no other way other than to pre compute the line widths first. Just another thing I didn’t see coming as I was making this.

Text Box Editor

This is just a quick walkthrough here, but I think it good to show some Unity Editor API.

[CustomEditor(typeof(AtlasTextRenderer))]
public class AtlasTextRendererEditor : Editor
{
    BoxBoundsHandle boundsHandle = new BoxBoundsHandle();

    Vector4 custom;
    private void OnSceneGUI()
    {
        AtlasTextRenderer textRend = (AtlasTextRenderer)target;
        EditorGUI.BeginChangeCheck();

        switch (textRend.rendererType)
        {
            case AtlasTextRenderer.AtlasTextRendererType.Simple:
            case AtlasTextRenderer.AtlasTextRendererType.Border:
            {
                boundsHandle.size = textRend.textBoxData.size;

                float centerX = textRend.transform.position.x;

                switch (textRend.alignmentType)
                {
                    case AtlasTextRenderer.AtlasTextAlignmentType.Left:
                    {
                        centerX += boundsHandle.size.x * 0.5f;
                    }
                    break;

                    case AtlasTextRenderer.AtlasTextAlignmentType.Center:
                    {

                    }
                    break;

                    case AtlasTextRenderer.AtlasTextAlignmentType.Right:
                    {

                        centerX -= boundsHandle.size.x * 0.5f;
                    }
                    break;
                }


                boundsHandle.center = new Vector3(centerX, textRend.transform.position.y - boundsHandle.size.y * 0.5f, textRend.transform.position.z);
                boundsHandle.SetColor(Color.green);
                boundsHandle.DrawHandle();

                if (EditorGUI.EndChangeCheck())
                {
                    Undo.RecordObject(textRend, "Resize Textbox");
                    textRend.textBoxData.size = boundsHandle.size;
                    textRend.SetText(textRend.text);
                }
            }
            break;
...

Unity already has a bunch of editor tools built into every game object so it’s a different mindset here. I inherit from Editor where I have access to the OnSceneGUI() function. To refer to the instance of my text renderer you cast the `target` (which is a Unity Object inherited from Editor), to the type to access all the functions and variables I assigned. Using the type BoxBoundsHandle from the UnityEditor.IMGUI.Controls class, I get access to the green box editor tool. This is the same tool 2D box colliders use. If you don’t want a 2D box collider but need this draw tool, doing it this way is a nice alternative and you have more control over it’s behaviour. Plus you don’t get any of the other bloated unused data colliders have that you may not need. To tell Unity when to start looking for editor changes, you call EditorGUI.BeginChangeCheck(). The text scroll type uses a different scene GUI system, but the simple and border types are the same. the BoxBoundsHandle is mostly the same as a regular Bounds so to define it’s shader you need a Vector3 center and a Vector3 size. The center on the x axis is dependant on the alignment type. I start at the world position and either offset left or right by half the boundsHandle width. The user gets to control the size or at least the width which is all I need. To get the frame the boundsHandle changes, you call EditorGUI.EndChangeCheck() inside an if statement. From there you can override the textbox size but also you need to record that change for the undo stack. Unity has Undo.RecordObject() for that.

Background Borders

The background is a separate game object using my regular sprite renderer under the nine slice setting. I have not made a post explicitly about 9 nine slicing however I do have a LinkedIn post here .

private void SetBorderText()
{
    switch (alignmentType)
    {
        case AtlasTextAlignmentType.Left:
        {
            borderLocalPos.x = bounds.extents.x;
        }
        break;

        case AtlasTextAlignmentType.Center:
        {
            borderLocalPos.x = 0;
        }
        break;

        case AtlasTextAlignmentType.Right:
        {
            borderLocalPos.x = -bounds.extents.x;
        }
        break;
    }

    borderLocalPos.y = -textBoxData.size.y - backgroundRenderer.worldPivotsAndSizes[0].w - margin.y;
    borderLocalPos.z = backgroundRenderer.transform.localPosition.z;

    backgroundRenderer.enabled = true;

    Vector2 worldSize = new Vector2();
    worldSize.x = bounds.size.x + (margin.x * 2);  
    worldSize.y = bounds.size.y + (margin.y * 2);

    backgroundRenderer.transform.localPosition = borderLocalPos;

    backgroundRenderer.SetNineSliceSizeFromWorldSpace(worldSize);
}

I treat the background sprite like a bounding box so the code here is sort of similar to my text box editor tool. The background sprite is parented to the text and I use the localPosition to offset it depending on the alignment type. It offsets by half the bounds yet again. I also added a Vector2 margin so I can control the width and height of the background.

Scrolling Text

This was a fun one and such an extreme edge case, but I wanted it for my carriage signs. So unlike the other two renderer types, this type requires a specific shader to go along with it. I’ll show the shader first.

Varyings vert(Attributes v)
{
    Varyings o;

    AtlasSprite spriteData = _SpriteData[v.instanceID];

    float3 position = spriteData.position.xyz;
    float2 pivot = spriteData.pivotAndSize.xy;
    float2 size = spriteData.pivotAndSize.zw;

    float textBoundWidth = spriteData.custom.x;
    float scrollBoundWidth = spriteData.custom.y; 
    float scrollSpeed = spriteData.custom.z;
    float scrollingBackwards = spriteData.custom.w;

    float buffer = 0.1;
    float repeatWidth = max(scrollBoundWidth, textBoundWidth);
                
    float time = _Time.y * scrollSpeed;

    float2 objPos = v.positionOS.xy;
    objPos *= size;

    float bufferSign =  buffer * (scrollingBackwards * 2 - 1);
    objPos.x += fmod(time + pivot.x, repeatWidth + (buffer * 2)) + bufferSign;
    objPos.x += repeatWidth * scrollingBackwards;
                
    objPos.y += pivot.y;
    float3 worldPos = float3(position.xy + objPos, position.z);

    o.objPos = objPos;
    o.positionHCS = TransformWorldToHClip(worldPos);
    o.uv = v.uv;
    o.uvSizeAndPos = spriteData.uvSizeAndPos;
    o.scrollBoundWidth = scrollBoundWidth;

    return o;
}

The vertex pass is the only bit really worth mentioning here as the fragment pass just samples and renders the sprites. To optimise sprite batching, I pass in an extra vector for each of my renderers called custom. This vector4 can store whatever else I specifically want from the CPU. In the x channel i store the text bounds width which is the width of the raw text represented as the purple box. In the y channel I store the width of the scroll bounds which is represented as the red box. The speed in the z channel and which way it scrolls in the w channel. repeatWidth is the max between the two widths. Which ever is greater is where the characters will reset too. To offset the x position I use the modulo or fmod in HLSL which returns the remainder of a division. The dividend is time the pivot.x (which is the left side of the character sprite). and the divisor is the repeatWidth with a buffer. The reason for a buffer here is because the bounds represent the width of the text, but I need the width plus a little more so the position doesn’t repeat until the whole character sprite is outside the scrolling bounds. Considering every letter has a different width, I give it a generic buffer value that does a good enough job. Not great because it’s sort of a guess, but so far I can get away with it. If it is scrolling backwards the buffer needs to be on the other side too which is where objPos.x += repeatWidth * scrollingBackwards; comes from. This is what the vertex pass is doing close up where you can see that buffer.

The rest is regular vertex instructions for sprites.

private void SetScrollingText()
{
    for (int i = 0; i < customs.Length; i++)
    {
        Vector4 custom = customs[i];
        custom.x = bounds.size.x;
        custom.y = scrollBoundSize;
        custom.z = scrollSpeed;
        custom.w = scrollSpeed < 0 ? 1 : 0;
        customs[i] = custom; 
    }
}

Over to the CPU I just pass in the custom data from the renderer. Not much else.

That’s All For Now

Thanks for reading this far and hopefully it made some sense. I’ll get a post about 9 slicing done sometime soon. See Ya!

Sonar Ping Post Processing Shader

The idea behind this game is to simulate the deep sea in an abstract fashion. 80% to 90% of screen is pitch black and the colours are all on a large grid that can move with player abilities. The challenge was to still make the game legible, working with these quite extreme constraints.

This is how the scene is set. I Use the 3D render pipeline and an orthographic camera. Every material uses a flat colour and a simplified custom lighting model where all I use is the distance attenuation, and light direction to render light. This is all done in shader graph using a custom function.

void BasicAdditionalLights_half(float3 WorldPosition, float3 WorldNormal, out float TotalAttenuation, out float3 LightColor)
{
    TotalAttenuation = 0.0;
    LightColor = float3(0, 0, 0);

#ifndef SHADERGRAPH_PREVIEW
    uint pixelLightCount = GetAdditionalLightsCount();
    LIGHT_LOOP_BEGIN(pixelLightCount)

    uint perObjectLightIndex = GetPerObjectLightIndex(lightIndex);
    Light light = GetAdditionalPerObjectLight(perObjectLightIndex, WorldPosition);

    float atten = light.distanceAttenuation;

    float NdotL = saturate(dot(WorldNormal, light.direction));

    float diffuse = atten * NdotL;
    TotalAttenuation += diffuse;
    LightColor += light.color;
    LIGHT_LOOP_END

#endif
}

Too simplify it even further I use basic cell shading to get as few colours as possible for the post processing filter. Legibility is my number one priority here.

Onto the post processing, I use a Custom Render Feature because the shader I create is not possible in shader graph and optimisation is a huge factor particularly for this game.

        float4 calc(Varyings input) : SV_Target
        {
            float2 screenPos = float2(_ScreenParams.x, _ScreenParams.y);
            float2 scaledAspectRatioUV = screenPos / _gridScale;

            float2 scaledTexCoord = input.texcoord * scaledAspectRatioUV;
            float2 id = round(scaledTexCoord);
            float2 gridTexCoord = id / scaledAspectRatioUV; // quantized UV
            float4 blit = SAMPLE_TEXTURE2D_X(_BlitTexture, point_clamp_sampler, gridTexCoord);
            return blit;

I start off quantising the camera colour to retrieve this output.

Already looking tough to see. The reason why the cells need to be so big is because a black grid will need to be rendered over the top of it. Now making a grid is fairly simple and I got a video tutorial for it here. The real challenge is moving each grid cell without it loosing its square shape.

I use a compute shader to render a render texture that my post processing shader can sample.

[numthreads(8, 8, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
    float2 aspScreen = aspectRatioPentile(_Resolution);
    float2 uv = (float2(id.xy) + 0.5) / _Resolution;

    float2 scaledAspectRatioUV = _Resolution / _gridScale;
    float2 scaledTexCoord = uv * scaledAspectRatioUV;

    float2 idCoord = round(scaledTexCoord);
    float2 gridTexCoord = idCoord / scaledAspectRatioUV;

    float2 gridSpacePlayerPos = (_playerPos * _Resolution) / _gridScale;

    float centerLight = circleSDF(gridTexCoord, aspScreen);
    float sonarPing = sonarSDF(gridTexCoord, aspScreen, 0, 0.5);
    float flare = flareSDF(gridTexCoord, aspScreen, 20, 2) * 50;
    float radialScan = radialScanSDF(gridTexCoord, aspScreen, 1, 1) * 10;
    float col = 0;
    int neighbourRange = 4;
    for (int x = -neighbourRange; x <= neighbourRange; x++)
    {
        for (int y = -neighbourRange; y <= neighbourRange; y++)
        {
            float2 offset = float2(x, y);
            float2 localTexCoord = scaledTexCoord - offset;
            float4 currSC = float4(frac(localTexCoord), floor(localTexCoord));

            float2 localUV = (currSC.zw) / scaledAspectRatioUV;
            float sonar = sonarSDF(localUV, aspScreen, 0, 2);
            float flare = flareSDF(localUV, aspScreen, 20, 2) * 4;
            float radialScan = radialScanSDF(localUV, aspScreen, 1, 5);
            
            float totalMask = sonar + flare + radialScan;
            
            float2 displacementDir = normalize(gridSpacePlayerPos - currSC.zw);
            float2 displacedPos = currSC.xy + offset + (displacementDir * totalMask);
            float currDistFromSquare = max(-(max(abs(displacedPos.x) - 0.5, abs(displacedPos.y) - 0.5)), 0);
            col += currDistFromSquare;
        }
    } 
    float totalMask = saturate(centerLight + sonarPing + flare + radialScan);
    col = smoothstep(_gridThickness, 1 - _gridThickness, col);
    col *= totalMask;
    col = step(0.01,col);
    Result[id.xy] = float4(col, totalMask, 0, 0);
}

The way this compute shader works is I quantise the UVs to the same size as the post processing and create a mask for each type of ability that I will show later. For context I have the “Flare”, “Radial Scan” and “Sonar Ping” abilities. I make each of the SDFs and combine them all in one single mask. Then I loop through a 2D array with “offset” being each index and use that to calculate the direction the grid cell should be moving. The ability SDFs I generate determine the distance the cell will move. The larger the 2D array, the further the grid cell can travel before being culled.

It works as intended, however there is a great cost. The red flag for me is the nest for loop. Unfortunately I couldn’t find anyway of avoid the nest for loop considering I rely on a 2D array to control the direction. Along with keeping both the grid and square cell intact, made for a huge challenge. Keeping the neighbouring samples around 4 was a good balance between performance and effectiveness. Here are the other two abilties in the render texture form:

Now the mask is done I can use render texture as a multiplying factor in the UV for the blit texture.

Heres the final result with all three abilities:

In the end this was a quick solo project that tested my current capabilities as a tech artist. I feel like I stretched the what can be done here with these aesthetic and hardware limitations. For what looks to be a simplistic looking game, there’s a lot that happens underneath!