DEV Community

GameDevToolLab
GameDevToolLab

Posted on

12 Practical Unity 2D Shader Graph Effects (with a Sample Project)

You can transform the look of a Unity 2D game with shaders without redrawing the original artwork. Hit flashes, dissolves, color variants, glitches, holograms, the illusion of surface depth, and world-space scans all come down to manipulating UVs, color, alpha, and coordinates.

The implementation has its traps: packing a sprite into an Atlas changes the density of a pattern, an outline samples a neighboring sprite, moving vertices does not produce a smooth bend, or 2D lighting suddenly becomes expensive. This article covers 12 techniques together with ways to avoid those problems.

Watch the effects in motion

There is a demonstration video for the 2D shader effects in this article. Still images do not communicate timing and deformation particularly well, so start with the video to see the intended results, then jump to the implementation that interests you.

Use the video to inspect the appearance and motion. Use the article for coordinate choices, Sprite Atlas precautions, Shader Graph construction, and performance considerations.

Using the sample project

GitHub sample project (inspected commit c12a8ab)

The distributed project uses Unity 6000.5.11f1 and URP / Shader Graph 17.5.0. Keep it separate from the build-it-yourself recipes below, which target Unity 6.4 / Shader Graph 17.4. Compatibility of the distributed graphs with older versions has not been verified.

File locations and running the demos

Combine the filename in each technique with the corresponding folder below to get its full path. All paths are relative to the repository root.

Label in each technique Folder Purpose
Graph Assets/ShaderCapture/Shaders/Graphs/ Edit the shader nodes
Material Assets/ShaderCapture/Materials/ Duplicate and assign to a SpriteRenderer
Scene Assets/Scenes/ Inspect the complete setup
Art Assets/ShaderCapture/Art/Generated/ Images and sprite geometry
Runtime Assets/ShaderCapture/Scripts/Runtime/ Control components

Open the complete project through Unity Hub, then open Assets/Scenes/00_CaptureHub.unity or the Scene listed for a technique and enter Play Mode. Space plays or pauses, the left and right arrow keys adjust the effect amount, and R resets the demo. The Scene files are already stored in the locations above; you do not need to run the generation menu commands.

Shared controls and requirements for reuse

Start with DemoCharacter.png in the Art folder. Copying a Material does not copy the demo controls. In a different Scene, add EffectDemoController.cs from Runtime to an empty GameObject. Assign the Art files Noise.png, DemoCharacter_Index.png, and Palette.png to Noise Texture, Index Texture, and Palette Texture, respectively. This controller supplies _SC_Effect, _SC_DemoTime, colors, textures, and other global shader values. It requires the Input System. Scene navigation uses the original build-index order. Avoid multiple controllers overwriting the same global values.

The per-renderer settings mentioned in each technique are Inspector fields on ShaderCapturePropertyBlock.cs, also in Runtime. Attach it to the target Renderer. It does not supply the shared global values. The recipe properties _Effect and _ScanCenter are different names from the sample properties _SC_Effect and _SC_ScanCenter; the controllers shown in the article cannot be used with the sample graphs unchanged.

When moving an effect to another project, copy the Graph, Material, technique-specific dependencies, and shared controls, including their .meta files. Do not rely on Export Assets to discover textures assigned through global properties. When changing the artwork, update _SC_SpriteSize, which is fixed at 2.56, and _SC_TexelSize, which is derived from the Index Texture. The distributed graphs have Use TexelSize disabled and use this separate value instead; do not overwrite their settings with the build-it-yourself Property table below. They do not automatically adapt to arbitrary pivots or Atlas layouts, so first check them outside an Atlas.

The sample-file guidance is based on source inspection at the pinned commit. Launching the project, compiling it in Unity, and testing an actual transfer to another project were not performed for this edition.

Target environment for the recipes

Read the node, formula, and custom C# explanations below separately from the ready-made sample project above.

The recipes target this configuration:

  • Unity 6.4
  • Universal Render Pipeline (URP)
  • 2D Renderer
  • Shader Graph 17.4
  • SpriteRenderer

Use a Sprite Lit Shader Graph for effects that respond to 2D lights. The other effects generally use a Sprite Unlit Shader Graph. The Built-in Render Pipeline has different targets and lighting behavior, so these recipes target URP.

Start with three kinds of coordinates

Many 2D shader problems begin with the choice of coordinates.

Coordinates Use cases Behavior
Texture UV Original image, outlines, RGB offsets Refers to the packed region when using an Atlas
Object Position Per-sprite dissolves, wind, local scan lines Independent of the Atlas
World / Screen Position Waves crossing multiple objects, screen-fixed dithering The pattern does not stick to the object

Sprite Atlas UVs are not necessarily local 0–1 coordinates

A sprite packed into a Sprite Atlas has UVs that refer to positions in the entire Atlas. Feeding those UVs into sin(UV.x * Frequency) or floor(UV * Divisions) changes the density of stripes or waves when the sprite is packed.

Separate the responsibilities:

  1. Sample the original image with the unmodified Texture UV.
  2. Generate patterns with 0–1 coordinates derived from Object Position.
  3. Specify Texture UV offsets in units such as “2 texels,” not an arbitrary value such as “0.01.”
  4. Increase Atlas Padding for effects with large UV offsets, or keep those sprites out of the Atlas.

Do not use one coordinate stream for both pattern generation and image sampling.

Atlas-independent local 0–1 coordinates

For a SpriteRenderer using the Simple draw mode, derive local coordinates as follows:

local01 = Position(Object).xy / SpriteSize + Pivot01
Enter fullscreen mode Exit fullscreen mode

SpriteSize is the sprite's width and height in local units. Pivot01 is its pivot normalized within the Rect; a centered pivot is (0.5, 0.5). This component supplies those values:

using UnityEngine;

[RequireComponent(typeof(SpriteRenderer))]
public sealed class SpriteShaderDriver : MonoBehaviour
{
    static readonly int SizeId = Shader.PropertyToID("_SpriteSize");
    static readonly int PivotId = Shader.PropertyToID("_Pivot01");
    static readonly int EffectId = Shader.PropertyToID("_Effect");
    static readonly int SeedId = Shader.PropertyToID("_Seed");

    [SerializeField, Range(0f, 1f)] float effect;
    [SerializeField] float seed;

    readonly MaterialPropertyBlock block = new MaterialPropertyBlock();
    SpriteRenderer target;
    Sprite cachedSprite;

    bool EnsureTarget()
    {
        if (target != null) return true;
        target = GetComponent<SpriteRenderer>();
        return target != null;
    }

    void Awake()
    {
        if (EnsureTarget()) Apply();
    }

    void LateUpdate()
    {
        if (!EnsureTarget()) return;
        if (target.sprite != cachedSprite) Apply();
    }

    public void SetEffect(float value)
    {
        effect = Mathf.Clamp01(value);
        if (!EnsureTarget()) return;
        target.GetPropertyBlock(block);
        block.SetFloat(EffectId, effect);
        target.SetPropertyBlock(block);
    }

    void Apply()
    {
        if (!EnsureTarget()) return;
        Sprite sprite = target.sprite;
        cachedSprite = sprite; // Cache null too, avoiding reapplication every frame.
        if (sprite == null) return;

        // Rect-based 0-1 coordinates. Do not mix these with Tight Mesh bounds.
        Vector2 size = sprite.rect.size / sprite.pixelsPerUnit;
        Vector2 pivot = new Vector2(
            sprite.pivot.x / sprite.rect.width,
            sprite.pivot.y / sprite.rect.height);

        target.GetPropertyBlock(block);
        block.SetVector(SizeId, new Vector4(size.x, size.y, 0f, 0f));
        block.SetVector(PivotId, new Vector4(pivot.x, pivot.y, 0f, 0f));
        block.SetFloat(EffectId, effect);
        block.SetFloat(SeedId, seed);
        target.SetPropertyBlock(block);
    }
}
Enter fullscreen mode Exit fullscreen mode

In Shader Graph, Divide Object-space Position.xy by _SpriteSize, then Add _Pivot01. _SpriteSize is sprite.rect.size / sprite.pixelsPerUnit. With either a centered or off-center pivot, the Rect's bottom-left maps to 0 and its top-right maps to 1. An asymmetric Tight Mesh occupies only part of that 0–1 range, but the definition of the Rect coordinates does not change.

To normalize the visible Tight bounds instead, use a separate approach: supply both bounds.min and bounds.size, then calculate (Position - BoundsMin) / BoundsSize. Do not mix that approach with the pivot-based formula. For Sliced, Tiled, Sprite Skin, or configurations that make frequent use of Flip, check that the Renderer's actual mesh matches the local coordinates you expect.

MaterialPropertyBlock supplies per-renderer values, but removes SRP Batcher compatibility. It is convenient for a small number of individual settings. When setting values on many sprites every frame, compare it with shared Materials and global properties, and measure on the target device.

Build a common base graph

Before implementing the techniques, build the shared portion in a Sprite Unlit Shader Graph. These are the Properties for the Shader Graph 17.4 recipes:

Property Type Reference Scope Supply / attribute Settings
Main Texture Texture 2D _MainTex Per Material Read Only ON (PerRendererData) Set as Main Texture ON / Use TexelSize ON
Mask Texture Texture 2D _MaskTex Per Material Read Only ON (PerRendererData) Match the Secondary Texture name
Normal Map Texture 2D _NormalMap Per Material Read Only ON (PerRendererData) Mode: Normal Map / Fallback: Flat Normal
Sprite Size Vector 2 _SpriteSize Per Material Override per Renderer with an MPB Rect size in Object Space
Pivot 01 Vector 2 _Pivot01 Per Material Override per Renderer with an MPB Pivot within the Rect
Effect Float _Effect Per Material Override per Renderer with an MPB 0–1
Seed Float _Seed Per Material Override per Renderer with an MPB Per-object variation

The three Texture Properties use Per Material, an actual Scope option in the Shader Graph 17.4 UI. Per Renderer Data is not a Scope option. Enabling Read Only adds the PerRendererData attribute so that SpriteRenderer can supply textures per renderer. The Normal Map fallback is Flat Normal. An Unlit Graph that does not use Secondary Textures does not need _MaskTex or _NormalMap.

  1. Create a Texture 2D Property with Reference _MainTex and mark it as the Main Texture.
  2. Read the original image with Sample Texture 2D.
  3. Connect RGB to the Fragment Base Color.
  4. Connect A to the Fragment Alpha.
  5. Use Alpha as the starting Blending Mode in the Graph Inspector for an ordinary sprite.
  6. Leave Disable Color Tint off when using SpriteRenderer.color.

From here on, baseRGBA means the original image's RGBA, textureUV means the UV used to sample that image, and local01 means 0–1 coordinates derived from Object Position.

Processing RGB in transparent regions before ordinary alpha blending can produce dark or light fringes. As a rule, multiply the final alpha by baseRGBA.a. If the edges of a transparent PNG are dirty, also inspect Alpha Is Transparency in the Import Settings, Sprite Atlas Padding, and the compression format.

Technique 1: Hit flashes and invincibility blinking

Watch this effect (from 0:06)

Sample files (use the folders listed at the beginning)

Graph: Effect01_HitFlashInvincible.shadergraph

Material: Effect01.mat / Scene: 01_HitFlashInvincible.unity

Use the per-renderer Seed Offset setting to vary the blinking phase. The Bloom file is Assets/ShaderCapture/Settings/ShaderCaptureVolumeProfile.asset. In the inspected version, its saved component reference is empty (fileID: 0), so copying it alone does not reproduce Bloom. Configure the following:

  1. Create and assign a new Profile to a Global Volume with Weight set to 1, then add Add Override > Post-processing > Bloom. Match the generation code with Threshold = 0.75, Intensity = 0.65, and Scatter = 0.62. Enable Bloom and the override checkbox for each setting.
  2. On the Camera used for rendering, enable Rendering > Post Processing and include the Volume GameObject's Layer in Volume Mask. HDR shader colors alone do not create a glow outside the sprite's silhouette.

See the URP post-processing setup guide for the configuration requirements.

A simple Lerp between the original color and a flash color makes a hit much easier to read.

outRGB = Lerp(baseRGBA.rgb, FlashColor.rgb, FlashAmount)
outAlpha = baseRGBA.a
Enter fullscreen mode Exit fullscreen mode

Add _FlashColor, _FlashAmount, and optionally _FlashPower. For an emissive look, add an HDR color:

outRGB = baseRGBA.rgb
       + FlashColor.rgb * FlashAmount * FlashPower
Enter fullscreen mode Exit fullscreen mode

When using Bloom, reserve high brightness for meaningful moments, such as an attack telegraph.

For invincibility blinking, generate a square wave from Time:

blink = Step(0.5, Fraction(Time * BlinkSpeed + Seed))
outAlpha = baseRGBA.a * Lerp(1, blink, Invincible)
Enter fullscreen mode Exit fullscreen mode

Change Seed per Renderer to avoid perfectly synchronized blinking. Combine the blinking with color or rim effects to communicate the state even during the invisible part of the cycle.

Technique 2: Palette swapping for character color variants

Watch this effect (from 0:13)

Sample files (use the folders listed at the beginning)

Graph: Effect02_PaletteSwap.shadergraph

Material: Effect02.mat / Scene: 02_PaletteSwap.unity

This sample needs DemoCharacter_Index.png and Palette.png from Art. Its palette has four columns and four rows: the Index Map's R channel stores the color ID, G stores shading, and _SC_PaletteRow selects row 0–3. Different artwork needs a corresponding Index Map.

The Hue node can rotate the hue, but use an Index Map and a Palette Texture when skin, clothing, metal, and eyes need independent color changes.

  • Main Texture: the visible sprite.
  • Index Map: the color-group number for each pixel.
  • Palette Texture: a small texture containing replacement colors in a horizontal row.
encoded = Sample(IndexMap, textureUV).r
id = round(encoded * (PaletteCount - 1))
paletteUV = float2((id + 0.5) / PaletteCount, 0.5)
paletteRGB = Sample(PaletteTexture, paletteUV).rgb

outRGB = paletteRGB
outAlpha = baseRGBA.a
Enter fullscreen mode Exit fullscreen mode

To preserve shading, store brightness in the Index Map's G channel:

outRGB = paletteRGB * Lerp(Shadow, Highlight, indexMap.g)
Enter fullscreen mode Exit fullscreen mode

Normalize the region IDs 0–PaletteCount - 1 into the R channel's 0–1 range. G can hold shading and B an emission mask. Disable sRGB for the Index Map, use Point filtering, and disable compression and mipmaps when needed. Set the Palette Texture to Point filtering and Clamp wrapping as well.

When the Main Texture is packed into an Atlas, the Index Map must be sampled with a matching layout. Associate it as a Secondary Texture, or supply a separate UV Rect for the Index Map. Sampling a non-atlased Index Map directly with Atlas UVs does not work.

Replace Color is sufficient for a one-off effect, but Index Maps are easier to manage for a large set of assets containing anti-aliasing and shading.

Technique 3: Dissolves for burning, freezing, and digitizing

Watch this effect (from 0:20)

Sample files (use the folders listed at the beginning)

Graph: Effect03_Dissolve.shadergraph

Material: Effect03.mat / Scene: 03_Dissolve.unity

The sample uses Noise.png from Art. The per-renderer Dissolve Softness setting controls the boundary. Set Dissolve Invert to 0 for disappearing or 1 for appearing. All four comparison views use the same Graph.

A dissolve compares a noise value with the effect's progress and removes alpha accordingly. Split it into three regions—Body, Edge, and Hidden—to make the result easier to control.

threshold = Lerp(-EdgeWidth, 1 + EdgeWidth, Dissolve)
visible = Step(threshold, noise)
body = Step(threshold + EdgeWidth, noise)
edge = Max(visible - body, 0)

// At 0: fully visible, no Edge. At 1: fully hidden.
start = 1 - Step(0.000001, Dissolve)
finish = Step(0.999999, Dissolve)
visible = Lerp(visible, 1, start) * (1 - finish)
body = Lerp(body, 1, start) * (1 - finish)
edge = edge * (1 - start) * (1 - finish)

outRGB = baseRGBA.rgb * body + EdgeColor.rgb * edge
outAlpha = baseRGBA.a * visible
Enter fullscreen mode Exit fullscreen mode

At Dissolve=0, noise values 0, 0.5, and 1 all produce Body. At Dissolve=1, they all produce Hidden. At 0.5, low noise values are Hidden, high values are Body, and Edge appears only along the boundary. Max keeps Edge nonnegative.

For a Smoothstep version, keep the same threshold direction: use visibleSoft=Smoothstep(threshold-Softness, threshold+Softness, noise), bodySoft=Smoothstep(threshold+EdgeWidth-Softness, threshold+EdgeWidth+Softness, noise), and edgeSoft=Saturate(visibleSoft-bodySoft). Apply the same start/finish guards. Invert with One Minus to use the effect for appearing instead.

Noise and coordinate choices change the impression:

Look Example input
Fire Broad Gradient Noise + an orange HDR Edge
Ice Voronoi + a thin light-blue Edge
Digitization Noise quantized into horizontal bands + cyan
Turning to ash Add World Position.y to Noise to progress from bottom to top
Reversing petrification Use region masks to offset the start time

Object Position makes the effect progress through the same relative portion of each sprite. World Position makes it cross multiple objects at the same height.

For many sprites or a mobile target, measure whether a small shared Noise Texture performs better than procedural noise.

Technique 4: Outer outlines and inner rims

Watch this effect (from 0:27)

Sample files (use the folders listed at the beginning)

Graph: Effect04_OutlineInnerRim.shadergraph

Material: Effect04.mat / Scene: 04_OutlineInnerRim.unity

Set the per-renderer Outline Directions to 4 or 8. Compare the geometry using DemoCharacter.png and DemoCharacter_Tight.png from Art. ShaderCapture.spriteatlas has Rotation OFF, Tight Packing OFF, and Padding 8, but you must still check the transparent margin and maximum UV offset.

Create an outline by comparing the center pixel's alpha with the alpha of neighboring pixels.

Four-direction outline

Let texel be the UV size of one texel in the source texture.

a0 = Alpha(textureUV)
aL = Alpha(textureUV + (-texel.x, 0))
aR = Alpha(textureUV + ( texel.x, 0))
aU = Alpha(textureUV + (0,  texel.y))
aD = Alpha(textureUV + (0, -texel.y))

neighbor = Max(aL, aR, aU, aD)
outline = Saturate(neighbor - a0)
Enter fullscreen mode Exit fullscreen mode

Composite the output as follows:

outRGB = Lerp(OutlineColor.rgb, baseRGBA.rgb, a0)
outAlpha = Max(a0, outline * OutlineColor.a)
Enter fullscreen mode Exit fullscreen mode

An eight-direction version adds diagonal samples and looks smoother, but takes nine samples including the center. It may be suitable for a few selected objects, but not as a permanent effect on hundreds of grass sprites or tiles.

Changing the thickness

Use texel * OutlinePixels as the offset. As the thickness increases to 2, 3, or 4 texels, the samples can skip intermediate pixels and leave gaps. For thick outlines, add samples at different distances or prepare a Signed Distance Field in advance to keep quality and cost manageable.

Inner rim

To illuminate only the inside edge rather than adding color outside the sprite, use the minimum neighboring alpha:

inner = a0 * (1 - Min(aL, aR, aU, aD))
outRGB = baseRGBA.rgb + InnerColor.rgb * inner * Strength
outAlpha = a0
Enter fullscreen mode Exit fullscreen mode

This works well for state indicators: a red inner rim during a hit, or a light-blue rim while frozen.

Sprite Atlas and Mesh Type pitfalls

Offsetting UVs outward can sample a neighboring sprite in the Atlas. Also, no fragments are generated outside the sprite's rendered mesh. If opaque pixels extend all the way to the image Rect's edges, the outer outline can be cut off.

Check these conditions:

  • Disable Allow Rotation for Atlases containing direction-dependent UV effects; this is a requirement for these recipes.
  • For outer outlines, also check Atlas Tight Packing. Disable it or use a separate Atlas when needed.
  • Leave transparent space inside the source sprite's Rect at least as wide as the outline.
  • Check Alpha Dilation for color bleeding at transparent edges; it does not enlarge the drawable region.
  • Provide enough Sprite Atlas Padding.
  • Use a separate Atlas for sprites with large effects extending outward.
  • Consider Full Rect if Mesh Type: Tight cuts off the effect.
  • Keep UV offsets within the range you planned for.

Sprite Atlases can pack sprites with a 90-degree rotation. With Allow Rotation enabled, textureUV.x/y may not match the sprite's local horizontal and vertical directions. These recipes assume Allow Rotation is OFF for UV wobble, RGB glitches, pixelation, and directional outlines. To retain rotated packing, read packing information such as Sprite.packingRotation on the CPU and supply a rotation basis to the shader. Full Rect alone cannot draw beyond the source Rect, so check transparent margins, Tight Packing, and Padding together.

Technique 5: Animating grass, cloth, and hair

Watch this effect (from 0:34)

Sample files (use the folders listed at the beginning)

Graph: Effect05_WindVertexSquash.shadergraph

Material: Effect05.mat / Scene: 05_WindVertexSquash.unity

Use DemoCharacter_Quad.png (4 vertices) and DemoCharacter_Subdivided.png (169 vertices) from Art together with their .meta files. Toggle the per-renderer UV Wiggle Amount, Vertex Wind Amount, and Squash Amount settings between 0 and 1. This demo uses the sprite geometry stored with the PNG and .meta, not a Mesh asset.

There are two approaches to swaying: offset the UVs or offset the vertices. They can look similar, but differ in whether the silhouette changes.

Wobbling only the UVs

phase = local01.y * Frequency
      + Time * Speed
      + Seed

offsetX = sin(phase) * AmplitudePixels
textureUV.x += offsetX * texel.x * local01.y
Enter fullscreen mode Exit fullscreen mode

Multiplying by local01.y at the end anchors the bottom and increases the movement toward the top. This works for grass, flames, patterns inside a flag, and water reflections.

Specifying amplitude in pixel units such as “2 texels,” rather than “0.01 UV units,” makes adjustment easier when the Atlas size or source texture resolution changes.

UV wobble alone does not change the sprite's rectangular geometry. Without enough transparent margin, the displaced image is clipped at the Rect's edges.

Moving the vertices

Use Object Position as the starting point for the Master Stack's Vertex Position, and add a wave to X:

positionOS.x += sin(
    local01.y * Frequency
    + Time * Speed
    + Seed) * Amplitude * local01.y
Enter fullscreen mode Exit fullscreen mode

This also moves the silhouette. However, a Full Rect sprite with only four vertices produces something closer to a shear than a smooth S-shaped curve.

For smooth bending, use one of these approaches:

  • Give the sprite enough vertices through 2D Animation's Sprite Skin workflow.
  • Subdivide the mesh in the Sprite Editor.
  • Render a custom mesh with a MeshRenderer.
  • Split the object into parts and animate them with Transforms or bones.

A shader cannot create vertices that do not exist. If the graph seems correct but the sprite only tilts like a board, mesh density—not the calculation—is the issue.

For a custom Graph using Sprite Skin, first check three prerequisites: (1) 2D Animation's SpriteSkin, (2) GPU Skinning enabled in Player Settings, and (3) SRP Batcher enabled in the URP Render Pipeline Asset. Feed Object-space Position, Normal, and Tangent into the Sprite Skinning node and use its skinned Position, Normal, and Tangent outputs. Add custom offsets after skinning, then connect the result to Vertex Position. Enabling SRP Batcher here is a functional prerequisite; losing SRP Batcher compatibility through MaterialPropertyBlock is a separate performance consideration.

Jelly-like squash

For a landing squash, widen the vertices along X and compress them along Y:

anchor01 = float2(0.5, 0.0) // Bottom anchor. For a center anchor, use float2(0.5, 0.5).
scale = float2(1 + Squash, 1 - Squash)
deformed01 = (local01 - anchor01) * scale + anchor01

// Convert normalized coordinates back to Object Space for Vertex Position.
deformedOS.xy = (deformed01 - Pivot01) * SpriteSize
deformedOS.z = Position(Object).z
Enter fullscreen mode Exit fullscreen mode

At Squash=0, deformed01 == local01, so the result matches the original Object Position. Restoring Object Space with Pivot01 prevents a positional jump even with an off-center pivot. A bottom anchor uses anchor01.y=0, keeping vertices at local01.y=0 fixed vertically. For a center anchor, use anchor01=(0.5, 0.5). Compared with animating Transform Scale through an Animator, shader deformation makes it easier to mask the deformation to specific regions.

Technique 6: Row-based glitches and RGB splitting

Watch this effect (from 0:41)

Sample files (use the folders listed at the beginning)

Graph: Effect06_GlitchRgbSplit.shadergraph

Material: Effect06.mat / Scene: 06_GlitchRgbSplit.unity

Additional required dependency: Assets/ShaderCapture/Shaders/Includes/ShaderCaptureEffects.hlsl and its .meta. The sample function is Effect06Fragment, which also composites RGB and supports both float and half. It differs from the recipe's GlitchUV, which only returns modified UVs. The sample HLSL contains four explicit texture samples: the original image, center, R, and B. Evaluate that separately from the three-sample minimal recipe below.

A glitch usually feels more convincing when a few horizontal blocks suddenly shift rather than continuously wobble. Put the row-and-time-based random calculation in a Custom Function to keep the graph readable.

// Assets/Shaders/Glitch2D.hlsl
#ifndef GLITCH_2D_INCLUDED
#define GLITCH_2D_INCLUDED

float Hash21(float2 p)
{
    p = frac(p * float2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return frac(p.x * p.y);
}

void GlitchUV_float(
    float2 UV, float LocalY, float TimeValue,
    float BlockCount, float Rate,
    float AmountInTexels, float TexelX,
    float Threshold,
    out float2 OutUV, out float Mask)
{
    float row = floor(LocalY * max(BlockCount, 1.0));
    float tick = floor(TimeValue * max(Rate, 0.001));
    float random = Hash21(float2(row, tick));

    Mask = step(Threshold, random);
    OutUV = UV + float2(
        (random * 2.0 - 1.0)
        * AmountInTexels * TexelX * Mask, 0.0);
}

void GlitchUV_half(half2 UV, half LocalY, half TimeValue, half BlockCount, half Rate, half AmountInTexels, half TexelX, half Threshold, out half2 OutUV, out half Mask)
{
    float2 uv; float mask;
    GlitchUV_float((float2)UV, LocalY, TimeValue, BlockCount, Rate, AmountInTexels, TexelX, Threshold, uv, mask);
    OutUV = (half2)uv; Mask = (half)mask;
}

#endif
Enter fullscreen mode Exit fullscreen mode

Set the Custom Function Node to File Mode and enter GlitchUV as the function name, without a suffix. The HLSL provides both _float and _half, so it resolves with Single or Half precision. Define the ports in this order: inputs UV Vector2, LocalY Float, TimeValue Float, BlockCount Float, Rate Float, AmountInTexels Float, TexelX Float, and Threshold Float; outputs OutUV Vector2 and Mask Float. Match the names, types, and order to the HLSL arguments. Connect local01.y to LocalY and the original Texture UV to UV.

RGB splitting uses three samples:

right = Sample(MainTex, glitchUV + (chroma, 0))
center = Sample(MainTex, glitchUV)
left = Sample(MainTex, glitchUV - (chroma, 0))
r = right.r
g = center.g
b = left.b
a = center.a
Enter fullscreen mode Exit fullscreen mode

That is one sample on each side and one at the center: three Sample Texture 2D nodes in total. G and alpha reuse the same center sample. If all three samples remain active during normal rendering, another option is to switch Materials only while the effect is playing.

With an Atlas, provide Padding wider than the maximum AmountInTexels so that UV offsets do not reach neighboring sprites. An effect that tears the entire screen is better suited to a Renderer Feature or full-screen Blit than an individual sprite shader.

Technique 7: Pixelation, posterization, and dithering

Watch this effect (from 0:48)

Sample files (use the folders listed at the beginning)

Graph: Effect07_PixelPosterizeDither.shadergraph

Material: Effect07.mat / Scene: 07_PixelPosterizeDither.unity

Set the per-renderer Dither Space to 0 for UV-based pixels or 1 for screen pixels. The sample adds a dither pattern to the color; it does not use the Alpha Clipping variation described below.

For a pixel-art-like result, separate the process into three stages: UV quantization, color quantization, and dithering.

Pixelation

Convert UVs to actual texture pixel coordinates, then quantize them into blocks:

pixel = textureUV * TextureSize
pixelated = floor(pixel / BlockPixels) * BlockPixels
sampleUV = (pixelated + BlockPixels * 0.5) / TextureSize
Enter fullscreen mode Exit fullscreen mode

The 0.5 samples the center of each block. In Shader Graph, build this with Texel Size, Multiply, Divide, and Floor.

Quantizing in pixel coordinates across the entire Atlas can look acceptable with small BlockPixels values, but can sample neighboring sprites at the boundaries. To keep the effect strictly within each sprite, supply its UV Rect and convert to local coordinates, or keep the target sprite outside the Atlas.

Posterization

Round RGB values to the desired number of levels:

quantized = round(color * (Levels - 1)) / (Levels - 1)
Enter fullscreen mode Exit fullscreen mode

Shader Graph also has a Posterize node. Around 4–8 levels produces retro-looking gradients. To constrain the actual colors rather than only their levels, map them to the nearest color in the Palette Texture from Technique 2.

Dithering

Add a Dither node when reducing the number of colors produces banding.

You can also enable Alpha Clipping in the Graph Inspector and connect the Dither result to Alpha Clip Threshold to represent transparency as a pattern of discarded pixels. However, Alpha Clipping and Depth Write are separate settings. Transparent rendering with Depth Write: Auto does not acquire opaque-cutout-style depth writing merely because Alpha Clipping is enabled. Consider Force Enabled only for a specific need to write depth, and check its effects on 2D sorting, SpriteMask, and overlapping transparent objects. Prioritize sorting for ordinary 2D sprites.

Using Screen Position for dithering keeps the pattern fixed on the screen while the sprite moves. Using UVs or local01 attaches it to the sprite. When combining this with a Pixel Perfect Camera, consistently use either the game resolution or the final output resolution as the pattern's reference; mixing them can make the pattern shimmer during camera movement.

Technique 8: Holograms, scan lines, and diagonal shine

Watch this effect (from 0:55)

Sample files (use the folders listed at the beginning)

Graph: Effect08_HologramShine.shadergraph

Material: Effect08.mat / Scene: 08_HologramShine.unity

This Graph combines scan lines, UV offsets, and diagonal shine using standard nodes. It does not need the HLSL from Technique 6. The horizontal-line and diagonal-shine branches can be adjusted separately.

Scan lines

line = sin((local01.y * LineCount + Time * ScrollSpeed) * 6.283185)
lineMask = Smoothstep(LineThreshold, 1, line)

outRGB = baseRGBA.rgb * HologramTint
       + LineColor * lineMask * LinePower

outAlpha = baseRGBA.a * Lerp(MinAlpha, 1, lineMask)
Enter fullscreen mode Exit fullscreen mode

Use Screen Position.y to keep the lines fixed on the screen like a CRT, or local01.y to attach them to a projected image. A mostly stable hologram with occasional horizontal shifts from Technique 6 is usually easier to read than a constantly unstable one.

Diagonal shine

A dot product creates a band that sweeps across a card or item:

p = dot(local01, float2(0.5, 0.5))
center = frac(Time * Speed)
d = abs(p - center)
d = min(d, 1 - d)
shine = 1 - Smoothstep(Width, Width + Softness, d)
Enter fullscreen mode Exit fullscreen mode

Add shine as an HDR color and multiply it by baseRGBA.a to keep it out of transparent regions. A Mask Map that marks only metal or glass prevents faces and cloth from shining unnaturally.

Technique 9: Adding depth with normal maps and 2D lights

Watch this effect (from 1:02)

Sample files (use the folders listed at the beginning)

Graph: Effect09_NormalMap2DLight.shadergraph

Material: Effect09.mat / Scene: 09_NormalMap2DLight.unity

This is a Sprite Lit Graph. Register DemoCharacter_Normal.png from Art as the _NormalMap Secondary Texture. To reproduce the moving Light 2D as well, include DeterministicLightRig.cs from Runtime and preserve the Scene's reference assignments. In addition to the standard normal connection, the sample Graph emphasizes shading in Base Color.

A Sprite Lit Shader Graph with a Normal Map registered as a Secondary Texture can add directional shading to a flat sprite.

Setup

  1. Prepare a Normal Map with the same layout and dimensions as the Main Texture. If using a Mask Map, prepare one with the same layout and dimensions as well.
  2. Import the Normal Map with Texture Type: Normal Map and sRGB OFF. Import the Mask Map with Texture Type: Default and sRGB OFF. For pixel-precise masks, check for channel contamination from mipmaps and compression in the actual target texture format.
  3. Open Secondary Textures in the Sprite Editor, add the Normal Map, and set its Name to _NormalMap.
  4. If using a Mask Map, add it on the same Secondary Textures screen and set its Name to _MaskTex.
  5. Check the Texture assignments for _NormalMap and, when used, _MaskTex, then click Apply.
  6. Create a Sprite Lit Shader Graph.
  7. Connect _MainTex, _MaskTex, and _NormalMap to their respective Sample Texture 2D nodes.
  8. Change the normal sample's Type to Normal.
  9. Connect the results to Base Color, Sprite Mask, Normal (Tangent Space), and Alpha in the Master Stack.
  10. Add Light 2D objects to the Scene and enable Normal Map support on the lights that need it.

Automatically generated normal maps tend to turn line art into surface bumps. Refine the large forms first: the face, clumps of hair, and curved armor surfaces.

Performance considerations

2D lighting uses Light Render Textures for each Blend Style. When normal maps are enabled, a full-size Render Texture is created for the depth prepass for each Layer Batch. The Unity 6.4 documentation describes this as expensive.

Start with one Blend Style, enable normals only for the Sorting Layers that need them, and measure Light Render Texture Scale, light count, and shadows on the target device.

Technique 10: Masking wetness, metal highlights, and weak points

Watch this effect (from 1:09)

Sample files (use the folders listed at the beginning)

Graph: Effect10_MaskMapLighting.shadergraph

Material: Effect10.mat / Scene: 10_MaskMapLighting.unity

This is a Sprite Lit Graph. Register DemoCharacter_Mask.png from Art as the _MaskTex Secondary Texture. The sample uses R for wetness, G for metal lighting, and B for a pulsing weak point. Set the per-renderer Mask Channel to 0, 1, or 2; do not leave it at the initial value of −1. For G, the Light 2D uses Blend Style 3 (zero-based: Additive with Mask, G) from Assets/Settings/Renderer2D.asset. Set the target sprite's Sorting Layer to MaskMetal and configure the light to target that Sorting Layer. Recreate this correspondence in the destination project instead of overwriting its entire settings file.

Assigning the channels of a Mask Map lets you distinguish materials within a single sprite.

Channel Example use
R Ordinary shading
G Metal or wet surfaces
B Magic or weak points
A Reserved for another effect

Set Mask Texture Channel in the 2D Renderer Data's Light Blend Styles, then select the corresponding Blend Style on the Light 2D. The Master Stack's Sprite Mask is the input for this 2D lighting Mask Map; it is not the SpriteMask component.

For rain, paint upward-facing surfaces and hair highlights into G, then multiply by _Wetness:

wet = mask.g * Wetness
outRGB += WetColor * wet
Enter fullscreen mode Exit fullscreen mode

A boss core painted into B can pulse only while it is vulnerable:

pulse = 0.5 + 0.5 * sin(Time * PulseSpeed)
weak = mask.b * WeakPoint * pulse
outRGB += WeakColor * weak * WeakPower
Enter fullscreen mode Exit fullscreen mode

Pack color-variation, wetness, and weak-point data into RGBA without giving a channel conflicting meanings. Check for channel contamination caused by lossy compression in the actual device texture format. Adding color in a Lit Graph modifies Base Color before lighting. If you need emission that does not depend on lighting, use an additional Unlit sprite or a separate additive effect.

Technique 11: Water reflections with a duplicated sprite

Watch this effect (from 1:16)

Sample files (use the folders listed at the beginning)

Graph: Effect11_WaterReflection.shadergraph

Material: Effect11.mat / Scene: 11_WaterReflection.unity

Assign the Material only to the duplicated reflection sprite. In the Scene, Stable Subject is rendered normally, while Reflection is flipped vertically, dimmed, and UV-distorted. The Material does not create the reflection object for you.

For a small puddle or water in a side-scrolling scene, duplicating a SpriteRenderer is a lightweight alternative to using another Camera.

  1. Place a reflection Renderer as a child of the original SpriteRenderer.
  2. Flip it vertically and position it below the waterline.
  3. Move it to the water's Sorting Layer.
  4. Apply a horizontal wave and an alpha fade.
wave = sin(local01.y * Frequency + Time * Speed)
     * AmplitudePixels

sampleUV.x = textureUV.x + wave * texel.x
fade = Smoothstep(0, FadeLength, 1 - local01.y)

outAlpha = baseRGBA.a * fade * ReflectionAlpha
Enter fullscreen mode Exit fullscreen mode

Depending on how the reflection is flipped, the waterline may be at the opposite end. Swap local01.y and 1 - local01.y when needed. Keep distortion within the transparent margin and Atlas Padding. This method reflects only the duplicated sprite. For background objects and occlusion, use a Camera plus a Render Texture; for a full-screen effect, use a Renderer Feature.

Technique 12: World-space scans and circular reveals

Watch this effect (from 1:23)

Sample files (use the folders listed at the beginning)

Graph: Effect12_WorldScanReveal.shadergraph

Material: Effect12.mat / Scene: 12_WorldScanReveal.unity

Share the Material, _SC_ScanCenter, and _SC_ScanRadius across the target sprites. The sample's reveal does not completely hide the area outside the radius. To make it fully invisible, use the alpha formula below.

World Position lets one wave sweep across multiple sprites. Uses include item searches, sonar, boss attack telegraphs, and the end of a time-stop effect.

Circular scan

Supply a world-space center, _ScanCenter, and a radius, _ScanRadius:

distance = Distance(Position(World).xy, ScanCenter.xy)

ring = 1 - Smoothstep(
    RingWidth,
    RingWidth + Softness,
    abs(distance - ScanRadius))

outRGB = baseRGBA.rgb
       + ScanColor.rgb * ring
       * baseRGBA.a * ScanPower
Enter fullscreen mode Exit fullscreen mode

Global shader properties are appropriate when all targets use the same values. Set the Scope of _ScanCenter and _ScanRadius to Global in the Blackboard. If they remain Per Material, the Material values take precedence.

using UnityEngine;

public sealed class WorldScanController : MonoBehaviour
{
    static readonly int CenterId = Shader.PropertyToID("_ScanCenter");
    static readonly int RadiusId = Shader.PropertyToID("_ScanRadius");

    [SerializeField] Transform center;
    [SerializeField] float radius;

    void Update()
    {
        Vector3 p = center != null ? center.position : transform.position;
        Shader.SetGlobalVector(CenterId, new Vector4(p.x, p.y, p.z, 0f));
        Shader.SetGlobalFloat(RadiusId, radius);
    }
}
Enter fullscreen mode Exit fullscreen mode

Turning the scan into a reveal

To show only the area within the radius, multiply alpha by this mask:

inside = 1 - Smoothstep(
    ScanRadius - Softness,
    ScanRadius,
    distance)

outAlpha = baseRGBA.a * inside
Enter fullscreen mode Exit fullscreen mode

For large numbers of hidden objects, disable distant Renderers and use the shader fade only near the boundary.

World Position makes the sprite appear to pass through the light. Object Position attaches the pattern to the sprite. Screen Position fixes it to the screen.

Combine techniques into more expressive effects

A small set of combinations with clear gameplay meaning is easier to maintain than a large collection of unrelated effects.

Use case Combination
Taking damage Brief white Flash + red Inner Rim + a momentary Glitch
Teleporting Dissolve + HDR Edge + RGB splitting just before disappearance
Rare card Palette + diagonal Shine + glow restricted to the metal Mask
Reversing petrification Desaturation + Voronoi Dissolve + a return to the original Palette

Drive several features from one _Effect value with offset timing, and the script only needs to animate a single value from 0 to 1. For example, remap 0–0.2 for the Flash, 0.15–0.9 for the Dissolve, and 0.85–1.0 for the final glow.

Five factors that determine performance

1. Texture sample count

These estimates describe the article's minimal implementations. Measure them separately from the distributed sample's extra processing and the complete comparison Scenes.

Flash uses one sample; Palette uses three (Main + Index + Palette); a four-direction Outline uses five; an eight-direction Outline uses nine; and RGB Glitch uses three. Even at _Effect = 0, the effect's texture samples can remain in a graph that evaluates both sides of a Lerp. One option is to use a lightweight shader for the normal state and a different shader only while the effect is active.

2. Transparent overdraw

Large transparent margins, oversized particles, and nearly full-screen holograms can make overdraw more important than instruction count.

3. Render Textures for 2D lighting

Each Blend Style needs a Light Render Texture. Unity's optimization guide gives examples of using one for a simple Scene and keeping more general cases to around two. Include normal maps, shadows, and the light sets for each Sorting Layer in your measurements.

4. Per-renderer values

Accessing renderer.material creates a Material instance. MaterialPropertyBlock avoids that duplication, but removes SRP Batcher compatibility.

Choose the data path according to its scope:

  • Shared everywhere: shader properties with Global Scope.
  • Shared by a group: a shared Material.
  • A few individual characters: MaterialPropertyBlock.
  • Large amounts of per-object data: vertex streams, additional UVs, or custom meshes.

5. Shader variants

Keywords can remove unnecessary calculations by disabling features, but their combinations increase the number of variants. Do not combine Flash, Outline, Dissolve, and Glitch without limit in a single shader. Group only the features that need to run together.

Common failures and their causes

Symptom Main cause and remedy
A horizontal offset becomes vertical after packing Allow Rotation rotated the packed sprite. Disable it for direction-dependent UV effects.
An outer Outline is clipped or samples a neighbor Check Tight Packing, transparent margins, Alpha Dilation, Padding, and Mesh Type together.
Stripes become denser after packing into an Atlas The pattern uses Texture UVs. Separate it into local01 derived from Object Position.
UV wobble reveals a neighboring image Atlas Padding is insufficient. Revisit the maximum offset, Padding, and Atlas grouping.
An Outline is cut off at the rectangle's edge Nothing is drawn outside the Sprite Rect or mesh. Add transparent margin and use Full Rect when needed.
Vertex deformation only tilts the sprite like a board There are not enough vertices. Use Sprite Skin or a subdivided mesh.
Transparent edges look black Check the RGB of transparent pixels, blending, Padding, compression, and whether the original alpha was applied.
SpriteRenderer.color is applied twice Reconcile Disable Color Tint with any manual Vertex Color multiplication so tint is applied once.
The effect is cheap on PC but expensive on the target device Check resolution, overdraw, texture formats, and 2D lighting Render Textures on the device GPU.

Which techniques should you introduce first?

A cautious implementation order is Flash, Palette, Dissolve, Inner Rim, Shine, selective use of Outline, and finally Normal Map + 2D Light. The later techniques need more attention to Atlases, additional samples, and Light Render Textures.

Give color and motion consistent meanings: white for damage, a red inner rim for danger, a blue scan for search targets, and diagonal shine for rare items. The visual effects then become part of the game's information design.

Conclusion

Unity 2D shader effects can be broken down into operations on UVs, RGB, alpha, and Object, World, or Screen coordinates. Separate Texture UVs from pattern coordinates, respect Atlas boundaries and the original alpha, and measure sample count and overdraw on the target device.

Start by adding Flash and Dissolve to a Sprite Unlit Shader Graph. Extract reusable parts into Sub Graphs, and the remaining effects can be built with the same ideas.

References

Sources for the distributed implementation

Generation code at the pinned revision: ShaderCaptureProjectGenerator.cs defines the artwork, Scenes, and Bloom settings; ShaderCaptureNativeGraphBuilder.cs defines the effect nodes; and ShaderCaptureGraphGenerator.cs defines the Graph settings.

Top comments (0)