Skip to main content

Simple outline for multi-sprite characters in Unity 2D using Shader Graph

For the last 6 months I've been working on a new (untitled) 2D game project in Unity both as a way to learn C# and also to play around with some game concepts I've been thinking about for quite a while.

Since I'm not much of an artist or a graphic designer I purchased a set of rather nice looking character sprites from https://tokegameart.net/ that also came with animations and ready to use Unity packages.


Since my game has multiple characters on screen at one and each one can be given orders I needed a way to show which one was selected or active. One common way to handle this which felt like a good fit for me is to show an outline around the selected character.

Luckily there's a lot of examples and guides explaining how to do this in Unity (and I based this one on a great article by Daniel Ilett). There was one snag though, my characters consist of multiple sprites (one for reach part of the body) that are drawn and animated separately. This meant that it would not be enough to just draw an outline around each part, otherwise you'd get something like this:



To avoid drawing over the sprites further away from the camera I instead created a separate sprite for each of the existing body parts, setting its sort order to -10. If we for a moment disable the regular sprites it looks like this:

And with the sprite drawn on top:

Creating the outline sprites

Instead of manually creating a copy of each sprite and applying the shader from the next section I created a script which attaches to the root object of the character and then creates copies of all children (and grand-children) that has a SpriteRenderer component.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class OutlineController : MonoBehaviour
{
    // Reference to the shader material defined in the next section
    public Material outlineMaterial;
    public float outlineSize = 1f;

    private List<Material> attachedMaterials = new List<Material>();

    void Start()
    {
        foreach (var s in GetComponentsInChildren<SpriteRenderer>())
        {
            AddOutline(s);
        }
    }

    void OnMouseEnter()
    {
        StartCoroutine(Animate(
            (m, progress) => m.SetFloat("_Alpha", progress)));
    }

    void OnMouseExit()
    {
        StartCoroutine(Animate(
            (m, progress) => m.SetFloat("_Alpha", 1 - progress)));
    }

    private IEnumerator Animate(Action<Material, float> updateAction)
    {
        for (int i = 0; i < 20; i++)
        {
            var progress = Mathf.SmoothStep(0f, 1f, (i + 1) / 20f);
            foreach (var m in attachedMaterials)
            {
                updateAction(m, progress);
            }
            yield return new WaitForSeconds(0.02f);
        }
    }

    private void AddOutline(SpriteRenderer sprite)
    {
        var width = sprite.bounds.size.x;
        var height = sprite.bounds.size.x;

        var widthScale = 1 / width;
        var heightScale = 1 / height;

        // Add child object with sprite renderer
        var outline = new GameObject("Outline");
        outline.transform.parent = sprite.gameObject.transform;
        outline.transform.localScale = new Vector3(1f, 1f, 1f);
        outline.transform.localPosition = new Vector3(0f, 0f, 0f);
        outline.transform.localRotation = Quaternion.identity;
        var outlineSprite = outline.AddComponent<SpriteRenderer>();
        outlineSprite.sprite = sprite.sprite;
        outlineSprite.material = outlineMaterial;
        // The UV coordinates of the texture is always from 0..1 no matter
        // what the aspect ratio is so we need to specify both the
        // horizontal and vertical size of the outline
        outlineSprite.material.SetFloat(
            "_HSize", 0.1f * widthScale * outlineSize);
        outlineSprite.material.SetFloat(
            "_VSize", 0.1f * heightScale * outlineSize);
        outlineSprite.sortingOrder = -10;
        attachedMaterials.Add(outlineSprite.material);
    }
}



The outline shader

When creating the shader I first explored the idea of simply scaling the sprite by a factor (e.g 1.10) and drawing it with a red color for all opaque pixels. I quickly realized that this would not work as it can only handle simple shapes consisting only of convex sides. If the shape has a cutout for example, there would be no outline inside it as the outline drawable has only expanded outwards.

The approach I instead took was the one suggested by Daniel Iletts post, to sample the texture in number of nearby positions (the distance determining how far the outline extends) and use those values to determine if the pixel should be transparent or opaque (part o the outline).

In pseudo code it can be expressed as:

pixel(x, y) = rgba(255, 0, 0,
  Max(
    SampleAlpha(texture, x - offset, y),
    SampleAlpha(texture, x + offset, y),
    SampleAlpha(texture, x, y - offset),
    SampleAlpha(texture, x, y - offset)
  ))


Using the maximum value allows us to get a smooth edge for the outline (as long as the source texture has a faded edge).

Main shader graph

Sub-shader graph for sampling the alpha value at an offset position

Final result



Comments

  1. There are some small mistakes here and there, do you read comments? One of the issues is a paragraph is repeated.

    ReplyDelete
  2. Also a pretty serious typo in your script that results in uneven outlines.

    ReplyDelete
    Replies
    1. Thanks for the feedback! I removed the copy pasted paragraph :)

      > Also a pretty serious typo in your script that results in uneven outlines.


      Nice catch, what's the typo?

      Delete
  3. I am shader newbie , which node is Sample Alpha ? or any chance download this shader ?

    ReplyDelete

Post a Comment

Popular posts from this blog

Getting started with OpenSTM32 on OSX

For some time now I have been doing projects (or should I rather say "been playing around") with AVR microcontrollers. Both in the form of different types of Arduinos but also in stand-alone projects (including the USB KVM and a battery powered ATTINY85 board, which I still haven't written a post about). For the most part I really like these microcontrollers, they are versatile, low powered and the development tools available are excellent (and importantly, available on all major platforms). However, In one of my latest projects I encountered a situation where AVRs just might not be enough. What I wanted to do was to capture images from a digital camera module (OV7670) and process them to determine movement speed and direction. While it might in theory be possible to do so on an ATMEGA microcontroller or similar, the small amount of memory available would make such an operation tricky at best. At that point I started looking for a more powerful microcontroller, and o...

Nucleo STM32F446RE and OV7670

After many hours of trial and failure I finally managed to get my OV7670 camera module to work properly with the Nucleo STM32F446RE board. I will try to put together a longer article about some of the issues I encountered and how I solved them but for now the source code is available on GitHub .