Hi all!

You may have noticed my silence over the past month. I fell into the trap of adding “just one more feature” and spent roughly 70 hours tweaking my outline post-processing solution. It might look like an easy problem, but it’s surprisingly complex. If you found this page looking for tutorials, you probably already know exactly what I mean.

There are many ways to implement outlines, each with its own trade-offs. It seems there is no “one-size-fits-all” solution that simply works; every project uses different meshes and has unique requirements. Unity’s sample outline, for instance, is quite basic: it relies heavily on depth detection and physical layers, making it hard to filter specific objects. That approach falls short when you need pixel-perfect precision for a downscaling project, especially with steep angles, or when your layers are already cluttered by a thousand other systems (physics, collision, etc.). Transparent objects are another nightmare. I also tried the inverted hull technique, but making it play nice with a pixel-perfect setup is tough, and it struggles with low-poly meshes where steep edges expand unevenly. After trying several other solutions without success, I gave up and decided to write my own custom shader pass.

Please note that this post does not give you a plug-and-play solution but it’s a walkthrough (with a bit of code) of how my shader work. I might do a simpler ready to use solution later, but it might still require a lot of customization to make it work with your project. You can find most of the code here: https://github.com/Lazy3valuation/PEYSOutlinesCode

My game without outlines
My game with outlines

Let’s take a look at how my outlines work, by looking at the features and trade offs/bugs of my solution.

Features:

  • Full-Screen Pass Architecture: It functions as a full-screen render pass, after a custom render pass.
  • Works with Transparents. Most tutorials stop at Opaque, but this handles both.
  • The outline filtering isn’t just based on Layers. Every object that needs an outline has a dedicated component. This allows me to easily filter which objects get an outline without clogging up Unity Layers, which are already used for a hundred other things like physics collisions.
  • Extra filters for grouping. I can distinguish between objects that are on the same physics Layer and have the outline component by using Rendering Layers.
  • Each object supports two modes: Material/Texture color (where the outline inherits the object’s color, e.g., to make the edges a darker version of the texture) or a single unique color for the whole object (like the classic flat black).
  • Per-object outline transparency.
  • Per-object alpha clipping.
  • Per-object color and mode.
  • It achieves pixel-perfect results when combined with my pixel-art post-processing.
  • Customizable via global settings (e.g., how much depth, normals, or their combination influences the final outcome).

Trade offs:

  • Very hard to implement, customize, and adapt. Every small change potentially breaks everything. You need deep knowledge to make it work properly. It also requires perfectly tuned settings and two dedicated materials with specific shaders for every outline type that differs from the default static one.
  • Performance hit. It can handle thousands of objects at once without crashing, which is good for stress tests, but it seems to drop about 10 FPS when active. It’s not very optimized yet and will definitely need optimization in the future.
  • Thickness is “stepped.” Since it’s pixel-perfect, the line thickness doesn’t grow linearly.
  • Transparency limitations. It manages to show outlines behind transparent objects (so you see the outline even if you are behind a semi-transparent or fully transparent object), but it causes small imperfections. Also, the outlines disappear completely if viewed behind multiple overlapping transparent objects.
  • Dithering would be better. In the future, it would probably be better to handle fades with dithering rather than standard transparency.
  • Stray pixels. There are small black pixels in the outlines that become visible specifically with the pixel-art filter, though you don’t really notice them unless you are looking for them.
  • Inconsistent direction. It seems undecided on whether to grow inwards or outwards: the behavior seems to change depending on the viewing angle. Luckily it doens’t impact my game.

Let’s start with the visual entry point: the Full Screen Shader Graph. If you look at the image below, it might seem deceptively simple, but that’s because the heavy lifting is hidden inside a custom HLSL include file (OutlinePackage.hlsl).

The main full screen shader

The graph logic is built around three main custom nodes that call into my HLSL package:

  1. Depth Based Detection: This node scans for edges based on the depth buffer, essential for detecting silhouettes and the separation between objects at different distances. It uses a StepDepth parameter to tune sensitivity, and is the same technique used by Unity’s outline sample and many tutorials.
  2. Normal Based Detection (combined with Depth): This first normal node runs in parallel with the depth node, using a specific threshold (StepsNormalsForDepth).
  3. Normal Based Detection (additional details): A second, independent normal node handles the hard edges and surface details using its own step threshold.

The important part is how these signals are combined. I take the output of the Depth node and the first Normal node and pass them through a minimum node. This acts as a “strict filter”, essentially saying that for a depth edge to be valid in this specific context, it must agree with this specific normal threshold (or vice versa), which helps eliminate noise and false positives. This, for example, removes false positives when objects were far away and turned full black (outlined), or when they were seen at a steep angle with the camera (e.g. when the camera was close to the terrain, like in the Menu, it became full black -outlined-).

Finally, I take that filtered result and add the output of the second, pure normal based node. This ensures that sharp internal edges (like 90-degree corners on a building) are always drawn on top. The result is then split: the RGB goes to the “Base Color”, and the alpha is isolated via a swizzle node to drive the final transparency of whatever is not an outline.

Now, let’s move to the hot sauce. The OutlinePackage.hlsl file contains the logic that actually decides “is this pixel an edge?”.

It’s important to note that before doing any sampling, I use a helper function called GetSnappedUV. Since I’m aiming for a pixel-perfect look, I can’t just sample textures arbitrarily. This function snaps the UV coordinates to the nearest pixel center relative to the screen resolution. This prevents sub-pixel jittering and ensures that my “thick” lines align perfectly with the screen grid, rather than looking like a blurry antialiased mess. You might want to modify that code if you don’t need pixel perfect outlines.


Normal-Based Detection:

/**
 * INTERNAL LOGIC: Normal-based Edge Detection.
 * Samples neighbor pixels to find discontinuities in surface normals.
 */
float4 NormalBased_Internal(float2 screenUV, float thickness, float step_val,
  TEXTURE2D_PARAM(depthTex, depthSampler),
  TEXTURE2D_PARAM(normTex, normSampler),
  TEXTURE2D_PARAM(colorTex, colorSampler)) {
  float2 centerUV = GetSnappedUV(screenUV, float2(0, 0), 0);
  float3 nC = SAMPLE_TEXTURE2D(normTex, normSampler, centerUV).rgb;
  float centerRawDepth = SAMPLE_TEXTURE2D(depthTex, depthSampler, centerUV).r;
  float closestDepth = centerRawDepth;
  float4 closestColor = SAMPLE_TEXTURE2D(colorTex, colorSampler, centerUV);

  // Fallback to Scene Normals if the custom buffer is empty/invalid
  if (length(nC) < 0.01) nC = SampleSceneNormals(centerUV);

  float totalDiff = 0.0;
  // Neighbor directions: Top, Right, Bottom, Left
  float2 dirs[4] = {
    float2(0, 1),
    float2(1, 0),
    float2(0, -1),
    float2(-1, 0)
  };

  for (
    int i = 0; i < 4; i++) {
    float2 neighborUV = GetSnappedUV(screenUV, dirs[i], thickness);
    float3 nN = SAMPLE_TEXTURE2D(normTex, normSampler, neighborUV).rgb;
    float neighborRawDepth = SAMPLE_TEXTURE2D(depthTex, depthSampler, neighborUV).r;

    // Track the closest depth to ensure the outline color belongs to the foremost object
    float oldClosest = closestDepth;
    closestDepth = GetCloserDepth(closestDepth, neighborRawDepth);
    if (closestDepth != oldClosest) {
      closestColor = SAMPLE_TEXTURE2D(colorTex, colorSampler, neighborUV);
    }

    if (length(nN) < 0.01) nN = SampleSceneNormals(neighborUV);

    // Accumulate difference between center normal and neighbor normal
    totalDiff += distance(nC, nN);
  }

  // Smoothstep filters the accumulated difference into a crisp line
  float outlineStrength = smoothstep(step_val, step_val + 0.01, totalDiff) * GetOcclusionMask(centerUV, closestDepth);

  return float4(closestColor.rgb, closestColor.a * outlineStrength);
}
HLSL

This function detects edges by looking for sudden changes in surface direction.

Edge Calculation: It accumulates the distance between the center normal and neighbor normals. The result is filtered through a smoothstep to create a crisp line.

The Sampling Loop: It samples the central pixel and its four immediate neighbors (Top, Right, Bottom, Left).

The “Color Stealing” Trick: One specific issue with outlines is deciding which color the outline should be. If I have a red object in front of a blue object, the edge pixel is technically on the blue object’s background, but I want the outline to be red (belonging to the foreground). The code tracks the closesdepth among the neighbors. If a neighbor is closer to the camera than the center pixel, I swap the closestColor to that neighbor’s color. This ensures the outline always inherits the properties of the foreground object.

Depth-Based Detection:

/**
 * INTERNAL LOGIC: Depth-based Edge Detection.
 * Uses Adaptive Thresholding to prevent artifacts on slanted surfaces.
 */
float4 DepthBased_Internal(float2 screenUV, float thickness, float step_val,
  TEXTURE2D_PARAM(depthTex, depthSampler),
  TEXTURE2D_PARAM(normTex, normSampler),
  TEXTURE2D_PARAM(colorTex, colorSampler)) {
  float2 centerUV = GetSnappedUV(screenUV, float2(0, 0), 0);
  float centerRaw = SAMPLE_TEXTURE2D(depthTex, depthSampler, centerUV).r;
  float dC = Linear01Depth(centerRaw, _ZBufferParams);
  float4 closestColor = SAMPLE_TEXTURE2D(colorTex, colorSampler, centerUV);
  float closestRawDepth = centerRaw;

  float totalDiff = 0.0;
  float2 dirs[4] = {
    float2(0, 1),
    float2(1, 0),
    float2(0, -1),
    float2(-1, 0)
  };

  for (
    int i = 0; i < 4; i++) {
    float2 neighborUV = GetSnappedUV(screenUV, dirs[i], thickness);
    float rawN = SAMPLE_TEXTURE2D(depthTex, depthSampler, neighborUV).r;

    float oldClosest = closestRawDepth;
    closestRawDepth = GetCloserDepth(closestRawDepth, rawN);
    if (closestRawDepth != oldClosest) {
      closestColor = SAMPLE_TEXTURE2D(colorTex, colorSampler, neighborUV);
    }

    float dN = Linear01Depth(rawN, _ZBufferParams);
    // Normalize depth difference relative to distance from camera
    totalDiff += abs(dC - dN) / (dC + 0.001);
  }

  totalDiff *= 100.0;

  // Adaptive Thresholding:
  // Surfaces parallel to view direction (high NdotV) need a lower threshold.
  // Surfaces perpendicular (low NdotV) need a higher threshold to avoid false positives.
  float3 n = SAMPLE_TEXTURE2D(normTex, normSampler, centerUV).rgb;
  if (length(n) < 0.01) n = SampleSceneNormals(centerUV);
  float3 viewNormal = mul((float3x3) UNITY_MATRIX_V, n);
  float NdotV = saturate(dot(viewNormal, float3(0, 0, 1)));

  float adaptiveThreshold = step_val * (1.0 + pow(1.0 - NdotV, 2.0) * 10.0);

  float outlineStrength = smoothstep(adaptiveThreshold, adaptiveThreshold + 0.05, totalDiff) * GetOcclusionMask(centerUV, closestRawDepth);

  return float4(closestColor.rgb, closestColor.a * outlineStrength);
}
HLSL

Depth detection is trickier because “distance” is relative. A 1-meter gap is huge if it’s right in front of your face, but invisible if it’s 1km away.

To solve the distance issue, I calculate the difference between neighbors relative to the camera distance: abs(dC – dN) / (dC + 0.001).

Adaptive Thresholding: A common bug in depth outlines is “striping” on floors or walls that are at a steep angle to the camera. The depth changes rapidly across the surface even though it’s flat. To fix this, I calculate NdotV (the dot product of the view normal).

  • If the surface is facing the camera, the threshold remains low (sensitive).
  • If the surface is slanted (grazing angle), I increase the threshold dynamically. This “Adaptive Thresholding” prevents the shader from drawing false outlines across flat but tilted surfaces.


Both functions finally pass their result through GetOcclusionMask, which compares the custom depth against the actual Scene Depth. This ensures that if an outline is physically blocked by a wall in the main scene, it gets properly occluded.


Now, you’re probably wondering: Why aren’t we using the standard scene depth and normal buffers directly in the shader graph? Why go through the trouble of generating 4 global textures?”

The answer comes down to control, specifically regarding Transparent objects.

Standard scene normals and depth are great for global effects, but they are “all or nothing.” You can’t easily filter out specific objects (like excluding a particle system or a specific UI element) without fighting the rendering pipeline. More importantly, transparent objects usually don’t write to the standard depth/normal buffers in the way an outline shader needs. If you rely on the global scene depth, your outlines will often glitch or disappear entirely when dealing with semi-transparent meshes.

To solve this, I implemented a Custom Render Pass (OutlineDataCaptureFeature). Instead of relying on what Unity gives us by default, we explicitly tell the renderer to “redraw” our outlineable objects into off-screen textures using specific override materials:

  1. Normals Pass: Renders the object geometry using a material that outputs world-space normals, written in a global texture.
  2. Color/Data Pass: Renders the object using a material that encodes the outline color (or the object’s texture color) and other data, written in the second global texture.

This gives us full control. We know exactly what is in those textures.

If we simply rendered all our objects into one “depth/normal” buffer, we would hit a wall as soon as we tried to fade an object out. Imagine a character standing behind a tree, like in my game. Both have outlines. If the tree starts to fade out (becoming transparent) to reveal the character, its values in the depth buffer would still be “solid.” The shader would think the tree is still blocking the view, and the character’s outline would remain hidden until the wall completely vanishes.

To fix this, we split our data into two distinct “channels” (sets of textures), effectively creating the 4 global textures mentioned earlier (normals A, color A, normals B, color B):

  • Buffer A (solid): Contains all fully opaque objects.
  • Buffer B (transition): Reserved for objects that are currently fading or transparent.

By separating them, the shader can compare the depth of the Solid object against the transition object. If the Transition object is in front, the shader can “look through” it (applying an alpha fade) to draw the outline of the solid object behind it. This solution is everything but perfect: if you are behind two or more transparent objects, the buffer breaks and outlines are not drawn for objects behind them. Moreover, I noticed that when an object becomes transparent, its outlines become darker, as if the outlines are rendered twice. In the future I’ll consider to fix those bugs.

Both the full screen pass and the custom render pass fully set

To fill those four specific textures, I wrote a Custom Renderer Feature called OutlineDataCaptureFeature. The core responsibility of this feature is to function as a strict gatekeeper. It doesn’t just “render everything with a different material.” It specifically hunts for objects based on a combination of their components (if they have the OutlineableObject component), standard GameObject Layer and, more importantly, their Rendering Layer Mask.

Here is how the logic flows inside the RecordRenderGraph method:

  1. Texture Allocation: First, we define our render targets. We need high-precision formats (ARGBHalf) for normals to avoid banding artifacts, while standard ARGB32 is sufficient for the color data.
  2. The Four Passes: The feature executes four distinct “Raster Render Passes.” This is where the separation happens.
    • Pass 1 & 2 (solid buffer): These passes iterate through my object list but explicitly target rendering layer 20, which is the default for the solid objects. It draws them once for normals/depth and again for color.
    • Pass 3 & 4 (transition buffer): These passes do the exact same thing but target Rendering Layer 30. Layer 30 is reserved for the objects that needs to be rendered “in front of other outlines”, so the objects that are semi-transparent or transparent.

The magic command here is overrideMaterial inside the RendererListDesc. This tells the render pipeline: “Ignore whatever shader this object usually wears. For this specific pass, use this specific utility material.” This is how we convert a complex character with albedo, roughness, and metallic maps into a flat, data-rich silhouette that our HLSL shader can easily read.

This step is crucial. You need to create two different materials, each handling a specific aspect of the outline data.
The first material of the override group defines the normals. If the object is static (or animated by a standard Animator), the output shader should look like this:

The simplest static normal shader

It’s very easy. The Normal Vector node (set to World Space) is directly linked to the output and will populate the texture.

But what happens if you have an object with vertex animation via shader graph, like water or trees swaying in the wind? You need to create a specific shader variant for those objects. For example, this is the vertex displacement used to animate my trees:

A more complex normal shader (used in my case to animate trees with wind)

You can actually ignore all those fancy nodes. All of them calculate the displacement of the tree’s vertices and are an exact copy of the nodes found in the original Tree shader. It’s imperative to configure your URP Renderer Data correctly. You need to add two features:

Add a standard Full Screen Pass Renderer Feature (assigning the material created with the Full Screen Shader Graph) and set it to After Rendering Post Processing.

Add my OutlineDataCaptureFeature and set the injection point to Before Rendering Transparents.

Now, let’s look at the color material. The first shader we’ll see is the one used for static objects. The default setting doesn’t use alpha clipping, so the outlines can range from transparent to semi-transparent (or fully opaque) using the Alpha output node.

An online color shader

Since my game uses toon shading with light ramps and custom code, I created a subshader graph to hold all my lighting and color logic. For now, just take a look at how the alpha of the _OutlineColor goes to the “Alpha” fragment. This means that whatever alpha the outline color property has, it controls the outline transparency.

The subshader is big and most of the nodes are customized for my toon solution, so I’ll split the explanation in two and focus only on the important parts.

Lighting part of the color outline subgraph

This is the Lighting Group, which you can ignore. If you don’t have any custom lighting in your game, you can just use a “Lit” shader and/or use the Main Light and other light sources in your shader graph. If you’re using only a full black outline, you can completely skip this part. It’s mostly needed to link the outline color to the lights so that, for example, a white outline becomes darker (until it becomes full black) when the main light dims, simulating night. Otherwise, outlines would keep their color regardless of the lighting conditions.

The core of the color outline shader

Below the lighting group, we find three other groups: the Fog group (which you can again ignore if you don’t need an outline that scales with environment color), the Merging section where everything is linked up, and the most important part: the “Outline settings” group.

The outline settings group implements all the logic to customize the desired color. There are three main modes:

  1. Static Color: A simple color for the whole outline. The most common approach (e.g., flat black, or red/orange to highlight selected objects).
  2. Material Color: Very useful for low-poly objects; the outline follows the color of the mesh’s material. It automatically supports multiple materials for one mesh, but you must add the OutlineableObject component to every object.
  3. Texture Color: Same as the material color, but uses a texture map. You use this when the object’s material has a texture rather than a flat color.

You can see how for options 2 and 3, the material/texture color is multiplied by the outline color property. That’s because if you didn’t, outlines would have the exact same color as the edges, and you wouldn’t even notice them. My personal choice is to use the material/texture option combined with a light gray outline color: this makes the outline of the object a darker version of itself, as seen in the example below.

If you need alpha clipping instead of uniform transparency, you can enable that and set up the clipping according to the same logic used in the main object shader. Here is an example I use for the player spawn animation:

An example of alpha clipping color shader, used for my player
Alpha clipping with both the player’s mesh and outline.

Finally, the settings of the Custom Render Feature will look something like this:

Example of the content of the capture gropus

The “Layers” define which target objects the custom pass will attempt to outline. After filtering by layer, it checks if the objects have the OutlineableComponent. If they do, it checks which Rendering Layer they are assigned to, and if they match the pass criteria, their outlines are rendered accordingly.

It is imperative to set the OutlineDataCaptureFeature injection point to Before Rendering Transparents and the Full Screen Pass to After Rendering Post Processing.

Let’s jump to the final main component: the OutlineableObject.cs, which I’m not pasting here for simplicity but you can find it in the GitHub repo I’ve linked above.

So we have the Shader that draws the lines, and the Render Feature that captures the data. But how does an object tell the system: “Hey, I want an outline, and I want it to be Red” or “I am currently transparent, please render me in the Transition buffer”?

This is handled by the OutlineableObject.cs component that must be attached to every mesh you want to outline. It acts as the bridge between the GameObjects in your scene and the rendering pipeline.

Remember how the Render Feature looks specifically for objects in Rendering Layer 20 (Solid) or 30 (Transition)? This component is responsible for assigning those bits. When the game starts (or when you change settings in the inspector), the script ensures the MeshRenderer has the correct bit set in its renderingLayerMask. If an object needs to fade out, my transparency system (not shown here) simply toggles the bit from 20 (or whatever the object target is) to 30, and the Render Feature automatically moves it to the correct buffer in the next frame.

The most interesting part of this script is how it handles colors. Since the render feature overrides the material with a generic data material, we lose access to the original object’s properties (like its main texture or color). To solve this without creating thousands of material instances (which would kill performance), I use a MaterialPropertyBlock.

The script runs a logic loop that pushes data directly to the GPU for that specific renderer:

  • Static Color Mode: It simply passes the outlineColor chosen in the Inspector.
  • Dynamic/Texture Mode: It searches the original material for a main texture (checking for standard properties like _BaseMap, _MainTex, etc.) and “bakes” it into a variable. It then passes this texture to the Render Feature via the property block.

Important: If you want to use the texture color, it is crucial that your shader uses standard naming conventions for the texture property (e.g., _BaseMap or _MainTex). If your shader uses custom names, you have two options: either add those names directly into the search list in the code, or manually drag and drop the texture into the “Baked Texture” slot in the inspector of the OutlineableObject.

Here is a snippet of how it “bakes” the texture to ensure the outline matches the object’s visual pattern:

private void FindAndBakeTexture()
{
    if (Renderer == null || Renderer.sharedMaterial == null) return;
    Material mat = Renderer.sharedMaterial;
    Shader shader = mat.shader;
    
    // Automatically find the first texture property in the shader
    // to use as the source for the outline color
    int count = shader.GetPropertyCount();
    for (int i = 0; i < count; i++)
    {
        if (shader.GetPropertyType(i) == ShaderPropertyType.Texture)
        {
            Texture foundTex = mat.GetTexture(shader.GetPropertyName(i));
            if (foundTex != null)
            {
                bakedTexture = foundTex;
                return;
            }
        }
    }
}
HLSL

By using SetPropertyBlock, we can have hundreds of objects with completely different outline colors or textures, all processed by the same single Render Feature pass.


Handling Transparency: The “Layer Switch”

Now, how do we automate the movement of objects between Buffer A (solid, layer 20 or other custom layers) and Buffer B (transition, layer 30)? We can’t expect the Render Feature to guess when an object is transparent; we have to tell it explicitly.

In my game, I have a camera system that detects obstructions (trees, walls, etc.) between the camera and the player. When an object needs to fade out, I don’t just lower the alpha on its material; I also communicate with the OutlineableObject component to switch its Rendering Layer.

Here is a simplified example of the logic running in my Camera Controller that I use to turn the trees transparent:

// Inside the Camera/Obstruction logic loop
if (objectIsObstructing)
{
    // 1. Fade the object normally
    currentAlpha -= Time.deltaTime * speed;
    meshRenderer.material.SetFloat("_Alpha", currentAlpha);

    // 2. Move Outline to Transition Buffer (Layer 30) & Sync Alpha
    outlineObject.SetTransitionState(true);
    outlineObject.UpdateObstructionFade(currentAlpha);
}
else
{
    // Object is visible again: Return to Solid Buffer (Layer 20)
    if (currentAlpha >= 1.0f)
    {
        outlineObject.SetTransitionState(false);
    }
}
HLSL

How it works:

  1. SetTransitionState(true): This flips the bit in the Rendering Layer Mask from 20 to 30. The Render Feature immediately picks this up in the next frame and moves the object to the “Transition” textures.
  2. UpdateObstructionFade: Since the outline material is distinct from the object material, we manually pass the alpha value to the outline’s MaterialPropertyBlock, ensuring the line fades out in perfect sync with the mesh.


And that, essentially, is how I spent 70+ hours “reinventing the wheel” for outlines.

Is it over-engineered? Probably. Is it laggy? More than I hoped for. Does it solve my specific problem of pixel-perfect, depth-aware, transparency-friendly outlines for a 2.5D game? Yes.

You can find most of the code here: https://github.com/Lazy3valuation/PEYSOutlinesCode

If something is not clear, if I forgot to explain something or if you need a bit of help, you can reach me out in my Discord: https://discord.gg/RnErrAHX

Good luck!