Outline shader (a bit iffy but it works)

Hello people. I noticed that the old shaders for cell shading/outline effects didn’t work for the new playcanvas updates and most of the githubs code for them are now redacted/deprecated. I decided I wanted a outline post effect shader for my game, so I made one. There are issues with it, but my hope is releasing it here other people could adapt the code and make it better. See my current results here:



Basically the script/shader adds an outline to objects in a scene by comparing the depth of pixels from the cameras depth buffer, If there’s a big enough difference in depth between neighboring pixels (controlled by the Depth Threshold which is an attribute) an outline is drawn using the Outline Color and Thickness number attributes. basically, it detects edges based on how far apart surfaces are in 3D space and highlights them with a outline.
see the launch here: PlayCanvas | HTML5 Game Engine
for this result and best results, I reccomend not going over 2.5 outline thickness since outline artifacts occur if you do… this is the best settings for my scene, but play with it as you like:
Screenshot 2024-12-21 8.57.01 AM
since we do use the depth of the scene, for the camera with the script you apply it to you have to tick renderSceneDepthMap or it wont work, seen here:
image
the script is here, if you do fork my project, and adapt it please share your results and what you updated or any recommendations for future updates.
Anyways, hope someone finds this useful. :grin:

4 Likes

Forgot to mention, since I use the depth buffer, shadow outlines tend to “bleed” like spilled ink, and look horrible up close, the best I can say is play with the attributes until you find something you’re comfortable with, if someone knows how to fix this please let me know. Thanks!

1 Like

Actually the issue is that the shader doesn’t use the depth buffer. I’m currently trying to fix it but for some reason the camera isn’t providing a depth buffer despite depthbuffer grabpass being enabled.

1 Like

Weird, maybe the api is different and so I may be using deprecated functions. I’ve been away for some time so I’m not 100% on all the code, if you find it please let me know! :grin:

Yeah in this block of code

if (this.device.sceneDepthMap) {
    scope.resolve("uDepthBuffer").setValue(this.device.sceneDepthMap);
} else {
    console.warn("Depth buffer not available! Make sure the camera has 'Render Depth Map' enabled.");
    scope.resolve("uDepthBuffer").setValue(inputTarget.colorBuffer); // Fallback
}

The this.device.sceneDepthMap is so depreciated that the property isnt even listed under the graphicsDevice docs.

1 Like

see how the depth buffer is enabled and used in a shader here:
https://playcanvas.vercel.app/#/graphics/ground-fog

you don’t assign anything to the scope, you simply call this in shader to use it. The linked examples does this:

float sceneDepth = getLinearScreenDepth(screenCoord);

this gives you depth between near and far plane of the camera.

Also see how this is added to the fragment shader for this function to be available there: pc.shaderChunks.screenDepthPS

1 Like

Thank you for this, it definitely pointed me in the right direction. However, the shader now doesn’t render any outlines.

uniform sampler2D uColorBuffer;
uniform sampler2D uDepthBuffer;
uniform vec2 uResolution;
uniform float uOutlineThickness;
uniform vec3 uOutlineColor;
uniform float uOutlineDepthThreshold;

varying vec2 vUv0;
                
float getDepth(vec2 uv) {
    return getLinearScreenDepth(uv);
}
                
void main() {
    vec2 texel = vec2(1.0) / uResolution;
                
    float center = getDepth(vUv0);
    float left = getDepth(vUv0 + vec2(-texel.x * uOutlineThickness 0));
    float right = getDepth(vUv0 + vec2(texel.x * uOutlineThickness 0));
    float up = getDepth(vUv0 + vec2(0 texel.y * uOutlineThickness));
    float down = getDepth(vUv0 + vec2(0 -texel.y * uOutlineThickness));
    
    float edge = step(uOutlineDepthThreshold abs(center - left)) +
                 step(uOutlineDepthThreshold abs(center - right)) +
                 step(uOutlineDepthThreshold abs(center - up)) +
                 step(uOutlineDepthThreshold abs(center - down));
                
    vec4 sceneColor = texture2D(uColorBuffer vUv0);
                
    if (edge > 0.0) {
        gl_FragColor = vec4(uOutlineColor 1.0);
    } else {
        gl_FragColor = sceneColor;
    }
}

If you could provide any input on why that may be the case, it would be amazing.

1 Like

is this the full script? It looks lacking in most of the definitions and attributes + the actual usage inside the same script, the shader definition + the script definition to apply it to the camera. If it does have the full script could you send that so I could test it out? I’m a bit busy today and the shader isn’t 100% my top priority so I’ll check it out when I can, thanks!

Its not the full script, just the fragment shader.

1 Like

I mean to me it looks right, but it doesn’t work for me when I test it either so I have no clue what’s going wrong. The shader api is also not well documented for custom shaders since the last update to my knowledge.

In my code I just deleted the call for the depthbuffer, so it doesn’t loop and call it over and over.

Hey @Jacob_McBride2

I was interested in your code, but the links are not working anymore.
Are you still sharing it?

1 Like

the project may have been deleted to free up space, luckily I still have the code, and so I can share it here:

//--------------- OUTLINE POST EFFECT V 0.5 ------------------------//
pc.extend(pc, function () {
    var OutlinePostEffect = function (graphicsDevice) {

        this.shader = new pc.Shader(graphicsDevice, {
            attributes: {
                aPosition: pc.SEMANTIC_POSITION
            },
            vshader: [

                "attribute vec2 aPosition;",
                "varying vec2 vUv0;",

                "void main(void) {",
                "    gl_Position = vec4(aPosition, 0.0, 1.0);",
                "    vUv0 = (aPosition.xy + 1.0) * 0.5;",
                "}"

            ].join("\n"),
            fshader: [
                "precision " + graphicsDevice.precision + " float;",

                "uniform sampler2D uColorBuffer;",
                "uniform sampler2D uDepthBuffer;",

                "uniform vec2  uResolution;",
                "uniform float uOutlineThickness;",
                "uniform vec3  uOutlineColor;",
                "uniform float uOutlineDepthThreshold;",

                // NEW UNIFORMS
                "uniform float uNearClip;",
                "uniform float uFarClip;",
                "uniform float uMaxOutlineDistance;",

                "varying vec2 vUv0;",

                // Helper: linearize depth from [0..1] to actual distance in camera space
                "float linearizeDepth(float depth) {",
                "    // Convert [0..1] depth to clip space [-1..1].",
                "    float z = depth * 2.0 - 1.0;",
                "    // Then convert to camera (eye) space distance.",
                "    return (2.0 * uNearClip * uFarClip) / (uFarClip + uNearClip - z * (uFarClip - uNearClip));",
                "}",

                // Get depth with a small check to keep skybox from messing with outlines
                "float getSceneDepth(vec2 uv) {",
                "    float d = texture2D(uDepthBuffer, uv).r;",
                "    // If depth is basically 1.0, well treat it as if its the same as center to avoid edges against the skybox.",
                "    return d;",
                "}",

                "void main() {",
                "    vec2 texel = vec2(1.0) / uResolution;",

                "    // Read center depth",
                "    float centerD = getSceneDepth(vUv0);",
                // If center is near 1.0, it’s sky. Just output color, no outline.
                "    if (centerD >= 0.99999) {",
                "       gl_FragColor = texture2D(uColorBuffer, vUv0);",
                "       return;",
                "    }",
                
                "    float leftD  = getSceneDepth(vUv0 + vec2(-texel.x * uOutlineThickness, 0.0));",
                "    float rightD = getSceneDepth(vUv0 + vec2( texel.x * uOutlineThickness, 0.0));",
                "    float upD    = getSceneDepth(vUv0 + vec2(0.0,  texel.y * uOutlineThickness));",
                "    float downD  = getSceneDepth(vUv0 + vec2(0.0, -texel.y * uOutlineThickness));",

                // If neighbors are essentially sky, treat them like center depth so they don't produce edges.
                "    if (leftD  >= 0.99999) { leftD  = centerD; }",
                "    if (rightD >= 0.99999) { rightD = centerD; }",
                "    if (upD    >= 0.99999) { upD    = centerD; }",
                "    if (downD  >= 0.99999) { downD  = centerD; }",

                "    float depthDiff = 0.0;",
                "    depthDiff += step(uOutlineDepthThreshold, abs(centerD - leftD));",
                "    depthDiff += step(uOutlineDepthThreshold, abs(centerD - rightD));",
                "    depthDiff += step(uOutlineDepthThreshold, abs(centerD - upD));",
                "    depthDiff += step(uOutlineDepthThreshold, abs(centerD - downD));",

                // Convert center depth to actual distance in camera space, for fade-out logic
                "    float linearCenter = linearizeDepth(centerD);",

                // 0 -> no fade at camera=0, then fades to 0 once we pass uMaxOutlineDistance
                "    float fade = clamp(1.0 - (linearCenter / uMaxOutlineDistance), 0.0, 1.0);",

                // final edge = depthDiff * fade
                "    float edge = depthDiff * fade;",

                "    vec4 sceneColor = texture2D(uColorBuffer, vUv0);",

                "    if (edge > 0.0) {",
                "        // Outline color",
                "        gl_FragColor = vec4(uOutlineColor, 1.0);",
                "    } else {",
                "        gl_FragColor = sceneColor;",
                "    }",
                "}"
            ].join("\n")
        });

        this.resolution = new Float32Array(2);

        // User-adjustable defaults
        this.outlineThickness = 1.0;
        this.outlineColor = [0.0, 0.0, 0.0];
        this.outlineDepthThreshold = 0.005;

        // New defaults
        this.nearClip = 0.1;            // Will override in script
        this.farClip  = 1000.0;         // Will override in script
        this.maxOutlineDistance = 100;  // Distance at which outlines fade out
    };

    OutlinePostEffect = pc.inherits(OutlinePostEffect, pc.PostEffect);

    OutlinePostEffect.prototype = pc.extend(OutlinePostEffect.prototype, {
        render: function (inputTarget, outputTarget, rect) {
            var device = this.device;
            var scope = device.scope;

            scope.resolve("uColorBuffer").setValue(inputTarget.colorBuffer);

            // Depth buffer
            if (device.sceneDepthMap) {
                scope.resolve("uDepthBuffer").setValue(device.sceneDepthMap);
            } else {
                scope.resolve("uDepthBuffer").setValue(inputTarget.colorBuffer); // fallback
            }

            this.resolution[0] = inputTarget.width;
            this.resolution[1] = inputTarget.height;
            scope.resolve("uResolution").setValue(this.resolution);

            scope.resolve("uOutlineThickness").setValue(this.outlineThickness);
            scope.resolve("uOutlineColor").setValue(this.outlineColor);
            scope.resolve("uOutlineDepthThreshold").setValue(this.outlineDepthThreshold);

            // Pass new uniforms
            scope.resolve("uNearClip").setValue(this.nearClip);
            scope.resolve("uFarClip").setValue(this.farClip);
            scope.resolve("uMaxOutlineDistance").setValue(this.maxOutlineDistance);

            pc.drawFullscreenQuad(device, outputTarget, this.vertexBuffer, this.shader, rect);
        }
    });

    return {
        OutlinePostEffect: OutlinePostEffect
    };
}());

//--------------- SCRIPT DEFINITION ------------------------//
var PostEffectOutline = pc.createScript('PostEffectOutline');

// Outline Thickness
PostEffectOutline.attributes.add('outlineThickness', {
    type: 'number',
    default: 1,
    min: 1,
    max: 4,
    title: 'Outline Thickness',
    description: 'The thickness of the outline.'
});

// Outline Color (RGB)
PostEffectOutline.attributes.add('outlineColor', {
    type: 'rgb',
    default: [0, 0, 0],
    title: 'Outline Color',
    description: 'The color of the outline.'
});

// Outline Depth Threshold
PostEffectOutline.attributes.add('outlineDepthThreshold', {
    type: 'number',
    default: 0.0001,
    min: 0.0001,
    max: 0.5,
    step: 0.0001,
    title: 'Depth Threshold',
    description: 'The threshold for depth difference to trigger outlines.'
});

// Max distance at which outlines appear (to fade them out for far objects)
PostEffectOutline.attributes.add('maxOutlineDistance', {
    type: 'number',
    default: 100,
    min: 1,
    max: 10000,
    title: 'Max Outline Distance',
    description: 'Beyond this distance from the camera, outlines fade out.'
});

PostEffectOutline.prototype.initialize = function() {
    var effect = new pc.OutlinePostEffect(this.app.graphicsDevice);

    // Attach to the camera’s post-effect queue
    var queue = this.entity.camera.postEffects;
    queue.addEffect(effect);

    // Initialize from attributes
    effect.outlineThickness = this.outlineThickness;
    effect.outlineColor = [this.outlineColor.r, this.outlineColor.g, this.outlineColor.b];
    effect.outlineDepthThreshold = this.outlineDepthThreshold;

    // Pass camera near/far so we can linearize depth properly
    effect.nearClip = this.entity.camera.nearClip;
    effect.farClip  = this.entity.camera.farClip;

    // Distance fade
    effect.maxOutlineDistance = this.maxOutlineDistance;

    // Live updates
    this.on('attr:outlineThickness', function (value) {
        effect.outlineThickness = value;
    }, this);

    this.on('attr:outlineColor', function (value) {
        effect.outlineColor = [value.r, value.g, value.b];
    }, this);

    this.on('attr:outlineDepthThreshold', function (value) {
        effect.outlineDepthThreshold = value;
    }, this);

    this.on('attr:maxOutlineDistance', function (value) {
        effect.maxOutlineDistance = value;
    }, this);

    // Handle enable/disable
    this.on('enable', function () {
        queue.addEffect(effect);
    }, this);

    this.on('disable', function () {
        queue.removeEffect(effect);
    }, this);
};
//------------------------POST EFFECT BY XXALCHEMISTXX------------------------//

the main issue right now is depth buffer, and artifacts around shadows. I haven’t gotten around to fixing this, but, I did have a duct taped on solution. You can turn the depth slider down and it will reduce artifacts around shadows, but may cause darker objects to not be outlined. The depth falloff where the outlines no longer persist isn’t working as the depth buffer isn’t being grabbed correctly. Feel free to try and fix it or use the code in your project.

Oh this is great!
Thanks a lot @Jacob_McBride2

1 Like

Yeah no problem! If you improve performance, or anything else in the code, please do let me know.

1 Like