🔳 OutlineSystemPro

The Majestic Outlion in All of its Splendor

A full-screen, URP-based outline effect for 3D GameObjects using a ScriptableRendererFeature, ScriptableRenderPasses, and the Outline3DPro component.

This system allows you to draw sharp, controllable outlines around specific objects without requiring custom shaders on the objects themselves. Outline masks are generated in dedicated mask passes and then calculated and composited in screen-space using a configurable material and full-screen Shader Graph.

🧭 Overview

The OutlineSystem consists of three core components:

  1. Outline3DPro : MonoBehaviour
    A MonoBehaviour added to any GameObject you want to outline.
    It marks the object for inclusion into the outline mask pass.

  2. Outline3DRendererFeature : ScriptableRendererFeature
    A custom URP Renderer Feature that:

    • collects all objects with the Outline3D component
    • renders them to an off-screen mask
    • applies a full-screen composite pass to draw the outline
  3. Outline Materials / Shader Graph
    A ShaderGraph (e.g., SG_OutlineComposite) and material used by the renderer feature to generate the final outline.
    Common parameters include:

    • Outline thickness
    • Outline color
    • Blur/soften
    • Edge detection or dilation strength

🔮 CosterGraphics.Systems.OutlineSystemPro Feature Roadmap

  • Implement OutlineFeatureProfileSOs instead of FeatureIDs?

Jump Flood Algorithm Notes:

Q: Im trying to create an outline renderer feature in Unity using JFA. I have a voronoi UV screen space distance field to create the outlines but when my screen isn't a perfect rectangle the outlines become very jagged.. I've tried multiplying by screen size etcetera but nothing seems to make any difference.. The jaggedness you're experiencing with JFA (Jump Flooding Algorithm) outlines on non-square aspect ratios is a common issue caused by aspect ratio distortion in the distance calculations.

Here's what's likely happening and how to fix it:

The Problem When you calculate distances in screen space without accounting for aspect ratio, a pixel step horizontally covers a different "real" distance than a vertical step. This causes the Voronoi cells to become stretched, making diagonal boundaries jagged.

The Solution You need to normalize your UV coordinates by aspect ratio during the JFA distance calculations, not just when rendering. Here's the key approach:

    // In your JFA shader (during the jump flooding passes)
    float2 aspectCorrection = float2(_ScreenParams.x / _ScreenParams.y, 1.0);
    // Or if height > width: float2(1.0, _ScreenParams.y / _ScreenParams.x)

    // When sampling neighbors
    float2 offset = float2(jumpDistance, 0) * aspectCorrection;
    float2 neighborUV = uv + offset * _ScreenParams.zw; // .zw = 1/width, 1/height

    // When calculating distances
    float2 diff = (seedPos - currentPos) * aspectCorrection;
    float distance = length(diff);

Key Points Apply correction during JFA passes, not just final rendering Store aspect-corrected positions in your seed initialization pass Make sure you're using the same aspect correction consistently across all passes Consider using _ScreenParams.xy for dimensions and .zw for inverse dimensions Could you share a snippet of your JFA jump pass shader? That would help identify exactly where the aspect correction needs to be applied.

Q:Well I'm using shader graph shaders in Unity but this is the method in HLSL that I'm doing:

static const float2 DIRS[8] = { float2(-1, 0), float2( 1, 0), float2( 0, -1), float2( 0, 1), float2(-1, -1), float2(-1, 1), float2( 1, -1), float2( 1, 1), };

float2 hopUV = hopPixels * texelSize;

float2 sampleUV = uv + DIRS[i] * hopUV; Ah, I see the issue! You're sampling in the 8 directions, but those directions aren't aspect-ratio corrected. The diagonal directions especially will be distorted on non-square screens.

Here's the fix - aspect-correct your direction vectors:

hlsl

Copy static const float2 DIRS = { float2(-1, 0), float2( 1, 0), float2( 0, -1), float2( 0, 1), float2(-1, -1), float2(-1, 1), float2( 1, -1), float2( 1, 1), };

// Add this - get aspect ratio float aspect = _ScreenParams.x / _ScreenParams.y; float2 aspectCorrection = float2(aspect, 1.0);

float2 hopUV = hopPixels * texelSize;

// Apply aspect correction to the direction float2 correctedDir = normalize(DIRS[i] * aspectCorrection);

// Sample using corrected direction float2 sampleUV = uv + correctedDir * hopUV * aspectCorrection; However, there's a subtlety: when you normalize the direction after aspect correction, you need to also apply the correction to the hop distance to maintain proper jump lengths.

Actually, a simpler approach that often works better:

float aspect = _ScreenParams.x / _ScreenParams.y;
float2 hopUV = hopPixels * texelSize;

// Apply aspect directly to the hop in X
float2 sampleUV = uv + DIRS[i] * float2(hopUV.x * aspect, hopUV.y);
Or even simpler - apply aspect correction to texelSize itself:
float aspect = _ScreenParams.x / _ScreenParams.y;
float2 correctedTexelSize = texelSize * float2(aspect, 1.0);
float2 hopUV = hopPixels * correctedTexelSize;
float2 sampleUV = uv + DIRS[i] * hopUV;
Try the last approach first - it's the cleanest. The key is making your texel steps "square" in screen space rather than rectangular.

Q: I forgot to mention, I'm doing horizontal and vertical jump passes.. is the math the same for both? Ah, that changes things! If you're doing separate horizontal and vertical passes (which is more efficient than 8-directional), you need to handle them differently.

For separable JFA passes:

// Horizontal pass - only correct the X direction
float aspect = _ScreenParams.x / _ScreenParams.y;
float2 hopUV = float2(hopPixels * texelSize.x * aspect, 0);
float2 sampleUV = uv + float2(dir, 0) * hopUV; // dir = -1 or +1

// Vertical pass - no correction needed (or inverse if you prefer)
float2 hopUV = float2(0, hopPixels * texelSize.y);
float2 sampleUV = uv + float2(0, dir) * hopUV; // dir = -1 or +1
Important: You also need to aspect-correct your distance calculations when comparing which seed is closer:
// When calculating distance to stored seed position
float aspect = _ScreenParams.x / _ScreenParams.y;
float2 diff = seedPos - currentPos;
diff.x *= aspect; // Correct the X component
float distance = length(diff);
This distance correction must happen in both passes and in your final outline detection.

Are you alternating horizontal/vertical passes (H→V→H→V), or doing all horizontal then all vertical? The standard JFA does alternating passes with decreasing jump distances.

Q: I'm alternating passes. Do I sample the ping pong texture linearly or point in the jump passes?

For alternating JFA passes, you should use point sampling (nearest neighbor) during the jump passes, not linear sampling.

Here's why:

Use Point Sampling (Nearest Neighbor) Preserves discrete jumps - JFA relies on discrete step sizes Avoids interpolation artifacts - Linear sampling can cause "bleeding" between cells Maintains the algorithm's correctness - The jump distance is based on pixel positions, not interpolated values Use Linear Sampling (Bilinear) for Final Output Only in the final outline detection pass To get smooth gradients and avoid stair-stepping Shader Graph Settings In Shader Graph:

For JFA jump passes: Set texture sampling to Point (nearest neighbor) For final outline rendering: Set texture sampling to Bilinear Why This Matters Linear sampling during JFA steps can:

Cause incorrect distance propagation - values get interpolated into wrong cells Create smearing artifacts - especially on diagonal boundaries Break the discrete nature of the jump flooding algorithm The key insight: JFA is a discrete algorithm that relies on pixel-accurate jumps. Any interpolation during the passes will corrupt the distance field.

So to summarize:

Jump passes: Point sampling (always) Final output: Linear sampling (only for rendering) This is the same principle as in the original JFA paper - discrete steps with nearest neighbor sampling.

Q: these are the methods I use for calculating distances, am I doing this correctly?:

void CG_PickNearestSeedHorizontal_float(
    float3 centerSeed,
    float3 seedL,
    float3 seedR,
    float3 seedLU,
    float3 seedLD,
    float3 seedRU,
    float3 seedRD,
    float2 uv,
    out float3 Out
)
{
    float bestDist = 1e20;
    // float bestDist = length(centerSeed.xy - uv);
    float3 result = centerSeed;

    // NEW
    if (centerSeed.z > 0)
    {
        bestDist = length(centerSeed.xy - uv);
    }

    float3 seeds[6];
    seeds[0] = seedL;
    seeds[1] = seedR;
    seeds[2] = seedLU;
    seeds[3] = seedLD;
    seeds[4] = seedRU;
    seeds[5] = seedRD;

    // [unroll]
    // [loop]
    for (int i = 0; i < 6; i++)
    {
        if(seeds[i].z == 0)
            continue;

        float d = length(seeds[i].xy - uv);
        if (d < bestDist)
        {
            bestDist = d;
            result = seeds[i];
        }
    }

    Out = result;
}

void CG_PickNearestSeedVertical_float(
    float3 centerSeed,
    float3 seedU,
    float3 seedD,
    float3 seedUL,
    float3 seedUR,
    float3 seedDL,
    float3 seedDR,
    float2 uv,
    out float3 Out
)
{
    float bestDist = 1e20;
    // float bestDist = length(centerSeed.xy - uv);
    float3 result = centerSeed;

    if (centerSeed.z > 0)
    {
        bestDist = length(centerSeed.xy - uv);
    }

    float3 seeds[6];
    seeds[0] = seedU;
    seeds[1] = seedD;
    seeds[2] = seedUL;
    seeds[3] = seedUR;
    seeds[4] = seedDL;
    seeds[5] = seedDR;

    // [unroll]
    // [loop]
    for (int i = 0; i < 6; i++)
    {
        if(seeds[i].z == 0)
            continue;

        float d = length(seeds[i].xy - uv);
        if (d < bestDist)
        {
            bestDist = d;
            result = seeds[i];
        }
    }

    Out = result;
}

You're close, but there's a critical issue: you're not applying aspect ratio correction to your distance calculations. Since you're doing separable passes, you need to correct distances differently for each pass:

void CG_PickNearestSeedHorizontal_float(
    float3 centerSeed,
    float3 seedL,
    float3 seedR,
    float3 seedLU,
    float3 seedLD,
    float3 seedRU,
    float3 seedRD,
    float2 uv,
    out float3 Out
)
{
    float aspect = _ScreenParams.x / _ScreenParams.y;
    
    float bestDist = 1e20;
    float3 result = centerSeed;

    if (centerSeed.z > 0)
    {
        float2 diff = centerSeed.xy - uv;
        diff.x *= aspect; // Correct X for aspect ratio
        bestDist = length(diff);
    }

    float3 seeds;
    seeds = seedL;
    seeds = seedR;
    seeds = seedLU;
    seeds = seedLD;
    seeds = seedRU;
    seeds = seedRD;

    for (int i = 0; i < 6; i++)
    {
        if(seeds[i].z == 0)
            continue;

        float2 diff = seeds[i].xy - uv;
        diff.x *= aspect; // Correct X for aspect ratio
        float d = length(diff);
        
        if (d < bestDist)
        {
            bestDist = d;
            result = seeds[i];
        }
    }

    Out = result;
}

void CG_PickNearestSeedVertical_float(
    float3 centerSeed,
    float3 seedU,
    float3 seedD,
    float3 seedUL,
    float3 seedUR,
    float3 seedDL,
    float3 seedDR,
    float2 uv,
    out float3 Out
)
{
    float aspect = _ScreenParams.x / _ScreenParams.y;
    
    float bestDist = 1e20;
    float3 result = centerSeed;

    if (centerSeed.z > 0)
    {
        float2 diff = centerSeed.xy - uv;
        diff.x *= aspect; // Still correct X even in vertical pass
        bestDist = length(diff);
    }

    float3 seeds;
    seeds = seedU;
    seeds = seedD;
    seeds = seedUL;
    seeds = seedUR;
    seeds = seedDL;
    seeds = seedDR;

    for (int i = 0; i < 6; i++)
    {
        if(seeds[i].z == 0)
            continue;

        float2 diff = seeds[i].xy - uv;
        diff.x *= aspect; // Still correct X even in vertical pass
        float d = length(diff);
        
        if (d < bestDist)
        {
            bestDist = d;
            result = seeds[i];
        }
    }

    Out = result;
}

Key changes:

Always correct the X component by multiplying by aspect ratio in both passes Apply the correction before calling length() This ensures distances are calculated in a "square" coordinate space This should eliminate the jaggedness! The issue was that your diagonal neighbors (seedLU, seedRU, etc.) were being evaluated with uncorrected distances, causing incorrect seed selection.

Q: Hey thanks man that seems to have done the trick! Thanks! You're better at this than ChatGPT it seems! On to the next inevitable glitch to fix!

Glad to hear it worked! You're welcome - I'm here to help with the next glitch too. What's the next issue you're running into? Let me know and I'll help you debug it.

Qwen 14B