Custom PostEffects Shaders

Good day everyone,

I was hoping I might get some help with custom PostEffects. The tutorials are a little light on anything other than the very basic set up on the PC side. I know this means I need to dive into more GLSL stuff, and I am currently doing just that.

The goal is some PostEffect depth based outlines for a cartoon shaded look. I played around with the Toon Shading projects that I saw out there, but that style of creating the outline is not quite what I’m looking for.

Luckily, I found this depth based shader demo on ShaderToy:

https://www.shadertoy.com/view/4dVGRW

It seems to be exactly what I’m looking for. Any tips or pointers on the process of porting this over for use in Playcanvas?

Hi @eproasim,

The following article contains some pointers on how to transfer a shadertoy effect to PlayCanvas. It’s not a post process effect but it’s still useful.

Since you require access to the depth map the bokeh.js post process effect is a good base on how to structure yours:

3 Likes

Thank you very much Leonidas! This kind of content is exactly what I was looking for!

1 Like

Hi @Leonidas !

I was delayed in following the documentation you provided, by some different tasks I had to complete, and the general learning curve of shaders. I was wondering if you might be able to help me out a bit. I managed to move the shader listed above to Playcanvas, define my uniforms and clear out all of the errors. While that is good progress to be sure, it seems I have reached my limits of understanding what and where to feed the shader scripts. While the errors are gone, I’ve only managed to output a single color instead of the depth shaded effect I was looking for. Could you help me make sense of where I went wrong?

I had to deviate a bit from your tutorial as I got an unhelpful “1:1 Syntax error” Whenever introducing this specific value “-1.0 + 2.0 *$UV;” when trying to redefine the uv variables, but I think I got close with the rest. Here is the script I currently have attached to my camera:

// --------------- POST EFFECT DEFINITION --------------- //
Object.assign(pc, function () {

    /**
     * @class
     * @name pc.ToonShadeEffect
     * @classdesc Implements the ToonShadeEffect post processing effect.
     * @description Creates new instance of the post effect.
     * @augments pc.PostEffect
     * @param {pc.GraphicsDevice} graphicsDevice - The graphics device of the application.
     */
    var ToonShadeEffect = function (graphicsDevice) {
        pc.PostEffect.call(this, graphicsDevice);

        this.needsDepthBuffer = true;

        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;",
                (graphicsDevice.webgl2) ? "#define GL2" : "",
                pc.shaderChunks.screenDepthPS,
                "",

                "uniform vec3 iResolution;",           // viewport resolution (in pixels)
                "uniform float iTime;",               // shader playback time (in seconds)
                "uniform sampler2D iChannel0;",        // input channel. XX = 2D/Cube
                "",


                "mat3 calcLookAtMatrix(vec3 origin, vec3 target, float roll)",
                "{",
                  "vec3 rr = vec3(sin(roll), cos(roll), 0.0);",
                  "vec3 ww = normalize(target - origin);",
                  "vec3 uu = normalize(cross(ww, rr));",
                  "vec3 vv = normalize(cross(uu, ww));",

                  "return mat3(uu, vv, ww);",
                "}",

                "",

                "vec3 getRay(vec3 origin, vec3 target, vec2 screenPos, float lensLength)",
                "{",
                  "mat3 camMat = calcLookAtMatrix(origin, target, 0.0);",
                  "return normalize(camMat * vec3(screenPos, lensLength));",
                "}",

                "",

                "vec2 squareFrame(vec2 screenSize, vec2 coord)",
                "{",
                  "vec2 position = 2.0 * (coord.xy / screenSize.xy) - 1.0;",
                  "position.x *= screenSize.x / screenSize.y;",
                  "return position;",
                "}",

                "",

                "vec2 getDeltas(sampler2D uDepthMap, vec2 uv)",
                "{",
                  "vec2 pixel = vec2(1. / iResolution.xy);",
                  "vec3 pole = vec3(-1, 0, +1);",
                  "float dpos = 0.0;",
                  "float dnor = 0.0;",
                    
                  "vec4 s0 = texture2D(uDepthMap, uv + pixel.xy * pole.xx);", // x1, y1
                  "vec4 s1 = texture2D(uDepthMap, uv + pixel.xy * pole.yx);", // x2, y1
                  "vec4 s2 = texture2D(uDepthMap, uv + pixel.xy * pole.zx);", // x3, y1
                  "vec4 s3 = texture2D(uDepthMap, uv + pixel.xy * pole.xy);", // x1, y2
                  "vec4 s4 = texture2D(uDepthMap, uv + pixel.xy * pole.yy);", // x2, y2
                  "vec4 s5 = texture2D(uDepthMap, uv + pixel.xy * pole.zy);", // x3, y2
                  "vec4 s6 = texture2D(uDepthMap, uv + pixel.xy * pole.xz);", // x1, y3
                  "vec4 s7 = texture2D(uDepthMap, uv + pixel.xy * pole.yz);", // x2, y3
                  "vec4 s8 = texture2D(uDepthMap, uv + pixel.xy * pole.zz);", // x3, y3

                  "dpos = (",
                    "abs(s1.a - s7.a) +",
                "",
                    "abs(s5.a - s3.a) +",
                "",
                    "abs(s0.a - s8.a) +",
                "",
                    "abs(s2.a - s6.a)",
                "",
                  ") * 0.5;",
                  "dpos += (",
                    "max(0.0, 1.0 - dot(s1.rgb, s7.rgb)) +",
                    "",
                    "max(0.0, 1.0 - dot(s5.rgb, s3.rgb)) +",
                    "",
                    "max(0.0, 1.0 - dot(s0.rgb, s8.rgb)) +",
                    "",
                    "max(0.0, 1.0 - dot(s2.rgb, s6.rgb))",
                    "",
                ");",
                  
                  "dpos = pow(max(dpos - 0.5, 0.0), 5.0);",
                    "",
                  "return vec2(dpos, dnor);",
                "}",

                "void main()", 
                "{",
                "",
                  "vec3 ro = vec3(sin(iTime * 0.2), 1.5, cos(iTime * 0.2)) * 5.;",
                  "vec3 ta = vec3(0, 0, 0);",
                  "vec3 rd = getRay(ro, ta, squareFrame(iResolution.xy, gl_FragCoord.xy), 2.0);",
                  "vec2 uv = gl_FragCoord.xy / iResolution.xy;",
                    
                  "vec4 buf = texture2D(uDepthMap, gl_FragCoord.xy / iResolution.xy);",
                  "float t = buf.a;",
                  "vec3 nor = buf.rgb;",
                  "vec3 pos = ro + rd * t;",
                    
                  "vec3 col = vec3(0.5, 0.8, 1);",
                  "vec2 deltas = getDeltas(uDepthMap, uv);",
                  "if (t > -0.5)",
                  "{",
                  "",
                    "col = vec3(1.0);",
                    "col *= max(0.3, 0.3 + dot(nor, normalize(vec3(0, 1, 0.5))));",
                    "col *= vec3(1, 0.8, 0.35);",
                  "}",
                  "col.r = smoothstep(0.1, 1.0, col.r);",
                  "col.g = smoothstep(0.1, 1.1, col.g);",
                  "col.b = smoothstep(-0.1, 1.0, col.b);",
                  "col = pow(col, vec3(1.1));",
                  "col -= deltas.x - deltas.y;",
                  
                    
                  "gl_FragColor = vec4(col, 1);",
                "}"
            ].join("\n")
        });

        // Uniforms
        this.iResolution = new pc.Vec3(graphicsDevice.width, graphicsDevice.height, 0.0).data;
        this.iTime = 0.0;
    };

    ToonShadeEffect.prototype = Object.create(pc.PostEffect.prototype);
    ToonShadeEffect.prototype.constructor = ToonShadeEffect;

    Object.assign(ToonShadeEffect.prototype, {
        render: function (inputTarget, outputTarget, rect) {
            var device = this.device;
            var scope = device.scope;

             scope.resolve("iChannel0").setValue(inputTarget.colorBuffer);
             scope.resolve("iTime").setValue(this.timer);

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

    return {
        ToonShadeEffect: ToonShadeEffect
    };
}());

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



ToonShadingv1.prototype.initialize = function () {
    this.effect = new pc.ToonShadeEffect(this.app.graphicsDevice);
    this.timer = 0.0;

    this.on('attr', function (name, value) {
        this.effect[name] = value;
    }, this);

    var queue = this.entity.camera.postEffects;

    queue.addEffect(this.effect);

    this.on('state', function (enabled) {
        if (enabled) {
            queue.addEffect(this.effect);
        } else {
            queue.removeEffect(this.effect);
        }
    });

    this.on('destroy', function () {
        queue.removeEffect(this.effect);
    });
};

ToonShadingv1.prototype.update = function (dt) {

    this.timer += dt;
    
};

Thank you very much in advance for any help. It is always greatly appreciated.

1 Like

Hi @eproasim, I will try and reproduce this when I get some time and let you know of the fix.

The tutorial above was updating a material based shader, on post process effect shader the uv coordinates work a bit differently.

1 Like

Thank you!

So the original shader toy code makes some assumptions that you need to take into consideration. The image buffer contains:

  • In the RGB channels screen normals
  • In the alpha channel depth

image

image

So you will have to map those to the Playcanvas buffers and also add the colorbuffer in place, the demo doesn’t have one.

Now for the screen normals I don’t think Playcanvas calculates that in any place, I’ve tried to reconstruct them from the viewPosition vector. The code now uses GLSL 3.0 and requires WebGL2, sorry no time to add the fallbacks. Here is my try on it, it’s not completed and there are artifacts (pixelated edges) but you can see how I resolved the errors:

https://playcanvas.com/editor/scene/1036375

4 Likes

Thank you for the example @Leonidas! I will be spending the day playing with the code to see if I can get a better grip on it. Some of the ways you resolved the issue were quite elegant, and it is already pretty close to what I want.

You mentioned the pixelated outline artifacts, and I do see them. Do you know of any resources you could point me to, or a general strategy to tackle the smoothing of those lines I can look in to?

Your advice, as always, is very much appreciated!

So basically the cause for that I think is the way the screen space normals are being calculated from depth. Something there isn’t properly “unpacked” maybe check the viewPosition method. This may be of help:

1 Like

Thank you! I will keep working on this and hopefully find a solution!

1 Like

Hi @Leonidas! Or anyone else browsing!

I think I made some additional progress with this shader. Looking through the blog post you provided, and finding this post on StackExchange: https://stackoverflow.com/questions/28095508/lwgl-reconstruct-position-in-fragment-shader , I found that some of the more obvious normal issues on large polygons have cleared up, but the outlines still don’t come out straight.

I was wondering if you might be able to tell me if I went wrong somewhere, or if there is something else I can search for to get additional ideas.

As always, your advice is appreciated! Here is the script as it sits now:

// --------------- POST EFFECT DEFINITION --------------- //
Object.assign(pc, function () {

    /**
     * @class
     * @name pc.ToonShadeEffect
     * @classdesc Implements the ToonShadeEffect post processing effect.
     * @description Creates new instance of the post effect.
     * @augments pc.PostEffect
     * @param {pc.GraphicsDevice} graphicsDevice - The graphics device of the application.
     */
    var ToonShadeEffect = function (graphicsDevice) {
        pc.PostEffect.call(this, graphicsDevice);

        this.needsDepthBuffer = true;
        this.matrix = new pc.Mat4();

        this.shader = new pc.Shader(graphicsDevice, {
            attributes: {
                aPosition: pc.SEMANTIC_POSITION
            },
            vshader: [
                "#version 300 es",
                "in vec4 aPosition;",
                
                "",
                "uniform mat4 matrix_viewProjectionInverse;",
                "uniform mat4 matrix_viewProjection;",
                "out vec3 WorldPos;",
                "out vec2 vUv0;",
                "",
                
                "void main(void)",
                "{",
                
                "gl_Position = vec4(aPosition.xy, 0.0, 1.0);",
                "vUv0 = (aPosition.xy + 1.0) * 0.5;",
                "WorldPos = (aPosition * matrix_viewProjection).xyz;",
                "}"
            ].join("\n"),
            fshader: [
                "#version 300 es",
                "precision " + graphicsDevice.precision + " float;",
                "",
                "in vec2 vUv0;",
                "in vec3 WorldPos;",
                "",
                "uniform sampler2D uColorBuffer;",
                "",
                "uniform sampler2D uDepthMap;",
                "uniform vec4 camera_params;",
                "uniform vec2 uResolution;",
                "uniform mat4 matrix_viewProjectionInverse;",
                "uniform mat4 matrix_viewProjection;",
                
                "float linearizeDepth(float z)", 
                "{",
                    "z = z * 2.0 - 1.0;",
                    "return 1.0 / (camera_params.z * z + camera_params.w);",
                "}",

                "float getLinearScreenDepth(const in vec2 uv)",
                "{",
                    "return linearizeDepth(texture(uDepthMap, uv).r) * camera_params.y;",
                "}",

                "vec3 getViewNormal( const in vec3 viewPosition )",
                "{",

                    "return normalize( cross( dFdx( viewPosition ), dFdy( viewPosition ) ) );",
                "}",

                "vec3 getViewPositionFromDepth( const in vec2 screenPosition, const in float depth)",
                "{",
                  "vec3 normalizedDeviceCoordinatesPosition;",
                  "normalizedDeviceCoordinatesPosition.xy = (2.0 * screenPosition) / uResolution - 1.0;",
                  "normalizedDeviceCoordinatesPosition.z = 2.0 * depth - 1.0;",


                  "vec4 clipSpaceLocation;",

                  "clipSpaceLocation.w = matrix_viewProjection[3][2] / (normalizedDeviceCoordinatesPosition.z - (matrix_viewProjection[2][2] / matrix_viewProjection[2][3]));",
                  "clipSpaceLocation.xyz = normalizedDeviceCoordinatesPosition * clipSpaceLocation.w;",

                  "vec4 eyePosition = vec4(WorldPos , 0.0) - (matrix_viewProjectionInverse * clipSpaceLocation);",

                  "return eyePosition.xyz / eyePosition.w;",

                "}",

                "mat3 calcLookAtMatrix(const in vec3 origin, const in vec3 target, const in float roll)",
                "{",
                  "vec3 rr = vec3(sin(roll), cos(roll), 0.0);",
                  "vec3 ww = normalize(target - origin);",
                  "vec3 uu = normalize(cross(ww, rr));",
                  "vec3 vv = normalize(cross(uu, ww));",

                  "return mat3(uu, vv, ww);",
                "}",

                "vec3 getRay(const in vec3 origin, const in vec3 target, const in vec2 screenPos, const in float lensLength)", 
                "{",
                  "mat3 camMat = calcLookAtMatrix(origin, target, 0.0);",
                  "return normalize(camMat * vec3(screenPos, lensLength));",
                "}",

                "vec2 squareFrame(const in vec2 screenSize, const in vec2 coord)",
                "{",
                 "vec2 position = 2.0 * (coord.xy / screenSize.xy) - 1.0;",
                  "position.x *= screenSize.x / screenSize.y;",
                  "return position;",
                "}",

                "vec3 getDeltaNormal(const in vec2 uv)",
                "{",

                  "float depth = getLinearScreenDepth(uv.xy);",
                  "vec3 viewPosition = getViewPositionFromDepth( uv.xy, depth);",
                  "return getViewNormal(viewPosition);",
                "}",

                "vec2 getDeltas(const in vec2 uv)",
                "{",
                  "vec2 pixel = vec2(1. / uResolution.xy);",
                  "vec3 pole = vec3(-.75, 0, +.75);",
                  "float dpos = 0.0;",
                  "float dnor = 0.0;",

                  "float d0 = getLinearScreenDepth(uv + pixel.xy * pole.xx);",
                  "float d1 = getLinearScreenDepth(uv + pixel.xy * pole.yx);",
                  "float d2 = getLinearScreenDepth(uv + pixel.xy * pole.zx);",
                  "float d3 = getLinearScreenDepth(uv + pixel.xy * pole.xy);",
                  "float d4 = getLinearScreenDepth(uv + pixel.xy * pole.yy);",
                  "float d5 = getLinearScreenDepth(uv + pixel.xy * pole.zy);",
                  "float d6 = getLinearScreenDepth(uv + pixel.xy * pole.xz);",
                  "float d7 = getLinearScreenDepth(uv + pixel.xy * pole.yz);",
                  "float d8 = getLinearScreenDepth(uv + pixel.xy * pole.zz);",

                  "dpos = (",
                    "abs(d1 - d7) +",
                    "abs(d5 - d3) +",
                    "abs(d0 - d8) +",
                    "abs(d2 - d6)",
                  ") * 0.5;",

                  "vec3 s0 = getDeltaNormal(uv + pixel.xy * pole.xx);",
                  "vec3 s1 = getDeltaNormal(uv + pixel.xy * pole.yx);",
                  "vec3 s2 = getDeltaNormal(uv + pixel.xy * pole.zx);",
                  "vec3 s3 = getDeltaNormal(uv + pixel.xy * pole.xy);",
                  "vec3 s4 = getDeltaNormal(uv + pixel.xy * pole.yy);",
                  "vec3 s5 = getDeltaNormal(uv + pixel.xy * pole.zy);",
                  "vec3 s6 = getDeltaNormal(uv + pixel.xy * pole.xz);",
                  "vec3 s7 = getDeltaNormal(uv + pixel.xy * pole.yz);",
                  "vec3 s8 = getDeltaNormal(uv + pixel.xy * pole.zz);",

                  "dpos += (",
                    "max(0.0, 1.0 - dot(s1.rgb, s7.rgb)) +",
                    "max(0.0, 1.0 - dot(s5.rgb, s3.rgb)) +",
                    "max(0.0, 1.0 - dot(s0.rgb, s8.rgb)) +",
                    "max(0.0, 1.0 - dot(s2.rgb, s6.rgb))",
                  ");",

                  "dpos = pow(max(dpos - 0.5, 0.0), 5.0);",

                  "return vec2(dpos, dnor);",
                "}",
                
                "out vec4 fragColor;",
                "void main(void)",
                "{",
                
                  "vec4 buf = texture(uColorBuffer, vUv0.xy);",
                  "float depth = getLinearScreenDepth(vUv0.xy);",

                  "float t = depth;",

                    "vec3 viewPosition = getViewPositionFromDepth( vUv0, depth);",
                    "vec3 nor = getViewNormal(viewPosition);",
                  "nor += vec3(.75);",

                  "vec3 col = texture(uColorBuffer, vUv0).rgb;",

                  "vec2 deltas = getDeltas(vUv0);",

                  "if (t > -0.5) {",
                    "col *= max(0.3, 0.5 + dot(nor, normalize(vec3(0, 1, 0.5))));",
                    "col *= vec3(1, 0.8, 0.35);",

                  "}",
                  "col.r = smoothstep(0.1, 1.0, col.r);",
                  "col.g = smoothstep(0.1, 1.1, col.g);",
                  "col.b = smoothstep(-0.1, 1.0, col.b);",
                  "col = pow(col, vec3(.75));",
                  "col -= deltas.x - deltas.y;",
                
                "",
                "fragColor = vec4(col, 1.0);",
                "}" 
            ].join("\n")
        });

    };

    ToonShadeEffect.prototype = Object.create(pc.PostEffect.prototype);
    ToonShadeEffect.prototype.constructor = ToonShadeEffect;

    Object.assign(ToonShadeEffect.prototype, {
        render: function (inputTarget, outputTarget, rect) {
            var device = this.device;
            var scope = device.scope;

             scope.resolve("uColorBuffer").setValue(inputTarget.colorBuffer);
             scope.resolve("uResolution").setValue(this.resolution);

             
            
            var matrixValue = scope.resolve("matrix_viewProjection").getValue();
            this.matrix.set(matrixValue);
            this.matrix.invert();
            scope.resolve("matrix_viewProjectionInverse").setValue(this.matrix.data);

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

    return {
        ToonShadeEffect: ToonShadeEffect
    };
}());

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


ToonShadingv1.prototype.initialize = function () {
    this.effect = new pc.ToonShadeEffect(this.app.graphicsDevice);
    this.effect.time = 0;
    this.effect.resolution = [this.app.graphicsDevice.width, this.app.graphicsDevice.height];

    this.on('attr', function (name, value) {
        this.effect[name] = value;
    }, this);

    var queue = this.entity.camera.postEffects;

    queue.addEffect(this.effect);

    this.on('state', function (enabled) {
        if (enabled) {
            queue.addEffect(this.effect);
        } else {
            queue.removeEffect(this.effect);
        }
    });

    this.on('destroy', function () {
        queue.removeEffect(this.effect);
    });
};



1 Like

Hi @eproasim,

I am fairly certain something with the way depth is calculated in the world position method produces that line issue. Sorry for not being of more help on this.

In case it’s of any help, here is an in depth tutorial on how depth is used and calculated in graphics engine, it’s on unity but the general concept holds true everywhere:

@eproasim, the following three.js screen space effect calculates screen normals from the world position. It may be of help:

https://threejs.org/examples/?q=sao#webgl_postprocessing_sao

Here is a working example of a depth buffer post effect shader for 2024. I could not find a working example and I finally created this one. Hope I can save you and whoever finds this post some time!

Here’s an example project as well: Depth Buffer Post Effect Shader Example

//--------------- POST EFFECT DEFINITION------------------------//
pc.extend(pc, function () {
    // Constructor - Creates an instance of our post effect
    var ExamplePostEffect = function (graphicsDevice, vs, fs) {
        
        const vertex = `#define VERTEXSHADER\n` + pc.shaderChunks.screenDepthPS + vs;
        const fragment = pc.shaderChunks.screenDepthPS + fs;
        const shader = pc.createShaderFromCode(pc.app.graphicsDevice, vertex, fragment, 'FogShader');
        this.shader = shader;

    };

    // Our effect must derive from pc.PostEffect
    ExamplePostEffect = pc.inherits(ExamplePostEffect, pc.PostEffect);

    ExamplePostEffect.prototype = pc.extend(ExamplePostEffect.prototype, {
    
        render: function (inputTarget, outputTarget, rect) {
            var device = this.device;
            var scope = device.scope;
            scope.resolve("uColorBuffer").setValue(inputTarget.colorBuffer);
            pc.drawFullscreenQuad(device, outputTarget, this.vertexBuffer, this.shader, rect);
        }
    });

    return {
        ExamplePostEffect: ExamplePostEffect
    };
}());

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


PostEffectDepthShader.prototype.initialize = function() {
    this.entity.camera.requestSceneDepthMap(true);
    const vertShader = `
        attribute vec3 vertex_position;
        varying vec2 vUv0;

        void main(void)
        {
            vec2 aPosition = vertex_position.xy;
            gl_Position = vec4(aPosition, 0.0, 1.0);
            vUv0 = (aPosition.xy + 1.0) * 0.5;
            
        }`
    const fragShader = `
        precision highp float;
        varying vec2 vUv0;

        void main() {
            float depth = getLinearScreenDepth(vUv0) * camera_params.x;
            gl_FragColor = vec4(vec3(depth), 1.0);
        }`

    var effect = new pc.ExamplePostEffect(this.app.graphicsDevice, vertShader, fragShader); 
    
    // add the effect to the camera's postEffects queue
    var queue = this.entity.camera.postEffects;
    queue.addEffect(effect);
    
    // when the script is enabled add our effect to the camera's postEffects queue
    this.on('enable', function () {
        queue.addEffect(effect, false); 
    });
    
    // when the script is disabled remove our effect from the camera's postEffects queue
    this.on('disable', function () {
        queue.removeEffect(effect); 
    });
    
    
};




1 Like