[Tech blog] How I did the "afterimage" effect


What's an afterimage?

The "image copies" behind Marisa and the Yin-Yang Orb are what we call afterimages. It's a kind of visual illusion that happens when your eyes are too slow to keep up with a fast-moving object.

How it works 

  • As with most gameplay code in Unity, we make a MonoBehaviour component that handles this effect for us. We'll call it "Afterimage".
  • The afterimage effect basically needs to copy a set of SpriteRenderers and make each copy fade away on its own. 
  •  The set of SpriteRenderers being all the SpriteRenderers in a GameObject, including its children.
    • For example, you may have a character holding a gun that can rotate in their right hand. The character has a SpriteRenderer, the gun has a SpriteRenderer, and the gun is a child GameObject / descendant of the character's GameObject. 
    •  You can change this to be manually assigned in the inspector if your hierarchy doesn't work like this, or just give the gun its own Afterimage component. 
  • And so we just periodically spawn a new copy in the Update method. Each copy also has its own MonoBehaviour component, and can do the fading effect on its own.
  • To make the afterimage effect more visually pleasing, we do multiplicative blending to make each afterimage copy darker.
  • Each copy also fades to transparent black rather than transparent white, so it only gets darker as it fades away.

Source Code

(Note: I renamed some properties and added comments to make this more readable)

using System.Collections; 
using System.Collections.Generic; 
using UnityEngine;
// Place this component on the root / topmost GameObject containing all the SpriteRenderers 
// you want an afterimage effect for.
public class Afterimage : MonoBehaviour     
{         
    // Component that's put on every SpriteRenderer copy. A "sprite frame".
    private class Frame : MonoBehaviour         
    {             
        // Note: The public properties are set by the Afterimage class.
        public float lifetime = 1.0f; // How long the frame lasts.
        private float startTime = 0; // Record start time for color interpolation.
        public SpriteRenderer spriteRenderer; // The frame's own SpriteRenderer, added by the Afterimage class.
        private Color startColor; // The frame's SpriteRenderer's initial color.
        private void Start()              
        {                 
            startTime = Time.time;                 
            startColor = spriteRenderer.color;             
        }             
        private void Update()             
        {                 
            // Calculate "progress" of the frame.
            float t = (Time.time - startTime) / lifetime;                 
            // If "done" (t >= 1), destroy the frame along with its GameObject.
            if(t >= 1) Destroy(gameObject);                 
            // Otherwise, interpolate from the initial color to transparent black (Color.clear).
            else spriteRenderer.color = Color.Lerp(startColor, Color.clear, t);             
        }         
    }         
    // We record an array of SpriteRenderers to take care of the case of nested SpriteRenderers.
    private SpriteRenderer[] spriteRenderers;         
    // How long should we wait before placing the next Afterimage Frame?
    [SerializeField, Min(0.01f)]         
    private float interval = 0.1f;         
    // How long should each Afterimage Frame last?
    [SerializeField, Min(0.01f)]         
    private float frameLifetime = 0.25f;         
    // The scheduled time for the next Afterimage Frame.
    private float nextFrameTime = 0;         
    // The hue of the Afterimage Frame.
    // We will do multiplicative blending with each SpriteRenderer's original color.
    [SerializeField]         
    private Color hue = new Color(0.5f, 0.5f, 0.8f);
    private void Awake()          
    {             
        // Initialize: Get all SpriteRenderers from the current GameObject and its children.
        spriteRenderers = GetComponentsInChildren<spriterenderer>();         
    }          
    // Start is called before the first frame update         
    void Start()         
    {             
        // Schedule the first Afterimage Frame. It can be Time.time as well, doesn't really matter.
        nextFrameTime = Time.time + interval;         
    }          
    // Update is called once per frame         
    protected virtual void Update()         
    {             
        // If it's time for the scheduled Afterimage Frame
        if(Time.time >= nextFrameTime)             
        {                 
            // Create a frame for each SpriteRenderer.
            foreach(var spriteRenderer in spriteRenderers)                     
                CreateFrame(spriteRenderer);          
            // Then schedule the next Afterimage       
            nextFrameTime = Time.time + interval;             
        }         
    }     
    // Method that handles the creation and initialization of each Frame object.
    private void CreateFrame(SpriteRenderer spriteRenderer)              
    {                      
        GameObject frameGO = new(); // Create new GameObject for the frame.
        frameGO.hideFlags = HideFlags.HideInHierarchy; // Set HideFlags so the scene hierarchy don't get polluted by stray Frames.
        frameGO.transform.SetPositionAndRotation(spriteRenderer.transform.position, spriteRenderer.transform.rotation);         
        frameGO.transform.localScale = spriteRenderer.transform.lossyScale; // Copy the SpriteRenderer's world scale AKA lossy scale.
        var frame = frameGO.AddComponent(); // Add the Frame component to the GameObject.
        frame.spriteRenderer = frameGO.AddComponent<spriterenderer>(); // Give the GameObject a SpriteRenderer, and pass the reference to the Frame component.
        // Copy SpriteRenderer properties over.
        frame.spriteRenderer.sprite = spriteRenderer.sprite;
        frame.spriteRenderer.flipX = spriteRenderer.flipX;                      
        frame.spriteRenderer.flipY = spriteRenderer.flipY;                      
        frame.spriteRenderer.color = spriteRenderer.color * hue; // Multiplicative blending to make the frame darker.
        frame.spriteRenderer.sharedMaterial = spriteRenderer.sharedMaterial;                      
        frame.spriteRenderer.sortingOrder = spriteRenderer.sortingOrder; // You can subtract 1 from this to make afterimages always appear below the original.                     
        frame.lifetime = frameLifetime; // Pass the frameLifetime property to the Frame component.
    }
}

Additional Notes

  • I declared the Update method as a protected virtual method. This allows me to make different flavors of Afterimage based on this very basic implementation that keeps the effect active at all times.
  • In this game, I subclassed Afterimage and overridden the Update method to stop updating when the GameObject's Rigidbody2D component has low speed. This makes it so when you're moving slowly, the afterimage effect won't show up. Afterimage, after all, is an effect that hints at high speed.

As for why I'm writing this? I just feel like I should try writing more tech posts to lean a bit more on my programmer side :3

Files

Ice Hockey Festival-Release-WebGL-2023_01_31.zip Play in browser
Jan 30, 2023
Ice Hockey Festival-Release-PC-2023_01_31.zip 40 MB
Jan 30, 2023
Ice Hockey Festival-Release-macOS-2023_01_31.zip 41 MB
Jan 30, 2023
Ice Hockey Festival-Release-Linux-2023_01_31.zip 43 MB
Jan 30, 2023

Get 東方氷球祭 Touhou Ice Hockey Festival

Leave a comment

Log in with itch.io to leave a comment.