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...

Converting a Type 80 Polaroid Land Camera to take Type 100 Film

Inspired by some great how-to's on how to convert Polaroid cameras using type 80 film to the wider type 100 film format (by Stratski and Nano_Burger ), I recently purchased a Polaroid Super Swinger Colour Camera. You might ask why I would buy this camera instead of just getting one that already takes type 100 film and to be honest, my answer is mostly: Because I can! Besides the fun prospect of bringing an obsolete piece of technology back to life there was also the idea of trying improve on previous attempts. This guide will show you how to convert any (as far as I know) type 80 camera to use type 100 film, without any modifications to the film packs themselves. This means that it will be much more convenient to load a new cartridge when you are out in the field (with no preparation needed). What you need Type 80 camera (I used a Super Colour Swinger) Side cutting pliers Type 80 film (Fujifilm FP-100C) Method Since type 100 film is wider than type 80 film, this w...