Basic Screen Space Ambient Occlusion (SSAO) Shader

I’ve put together a basic SSAO shader for PC. It’s more “toonifying” than anything perhaps, but is the basics of a forward rendering depth based fast SSAO based on normal recreation.

Any how it makes this:

Look like this at 30%

Effect code:

var SSAOEffect = pc.inherits(function (graphicsDevice) {
    this.device = graphicsDevice
    this.strength = 0.4
    this.ssaoShader = pc.shaderChunks.createShaderFromCode(graphicsDevice, pc.shaderChunks.fullscreenQuadVS, ssao, "ssao")
}, pc.PostEffect)

SSAOEffect.prototype.render = function(input, output, rect) {
    var device = this.device
    var scope = device.scope
    scope.resolve("uInputTexture").setValue(input.colorBuffer)
    scope.resolve("total_strength").setValue(this.strength)
    pc.drawQuadWithShader(device, output, this.ssaoShader)

}

That code needs an ssao variable set to the shader code:

uniform sampler2D uDepthMap;
uniform sampler2D uInputTexture;
varying vec2 vUv0;
uniform float total_strength;

float unpackFloat(vec4 rgbaDepth) {
    const vec4 bitShift = vec4(1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0);
    float depth = dot(rgbaDepth, bitShift);
    return depth;
}

vec3 normal_from_depth(float depth, vec2 texcoords) {
    const vec2 offset1 = vec2(0.0,0.001);
    const vec2 offset2 = vec2(0.001,0.0);

    float depth1 = unpackFloat(texture2D(uDepthMap, texcoords + offset1));
    float depth2 = unpackFloat(texture2D(uDepthMap, texcoords + offset2));

    vec3 p1 = vec3(offset1, depth1 - depth);
    vec3 p2 = vec3(offset2, depth2 - depth);

    vec3 normal = cross(p1, p2);
    normal.z = -normal.z;
    return normalize(normal);
}


void main(void) {
    const float area = 0.0075;
    const float radius = 0.0017;

    const int samples = 8;
    const vec3 sample_sphere[samples] = vec3[samples](
        vec3( 0.5381, 0.1856,-0.4319), vec3( 0.1379, 0.2486, 0.4430),
        vec3( 0.3371, 0.5679,-0.0057), vec3(-0.6999,-0.0451,-0.0019),
        vec3( 0.0689,-0.1598,-0.8547), vec3( 0.0560, 0.0069,-0.1843),
        vec3(-0.0146, 0.1402, 0.0762), vec3( 0.0100,-0.1924,-0.0344)
//        vec3(-0.3577,-0.5301,-0.4358), vec3(-0.3169, 0.1063, 0.0158),
//        vec3( 0.0103,-0.5869, 0.0046), vec3(-0.0897,-0.4940, 0.3287),
//        vec3( 0.7119,-0.0154,-0.0918), vec3(-0.0533, 0.0596,-0.5411),
//        vec3( 0.0352,-0.0631, 0.5460), vec3(-0.4776, 0.2847,-0.0271)
    );

    float depth = unpackFloat(texture2D(uDepthMap, vUv0));

    vec3 position = vec3(vUv0, depth);
    vec3 normal = normal_from_depth(depth, vUv0);

    float radius_depth = radius/depth;
    float occlusion = 0.0;
    float difference;
    for(int i=0; i < samples; i++) {
        vec3 ray = radius_depth * reflect(sample_sphere[i], normalize(vec3(0.1,0.5,0.3)));
        vec3 hemi_ray = position + sign(dot(ray,normal)) * ray;

        float occ_depth = unpackFloat(texture2D(uDepthMap, clamp(hemi_ray.xy,0.0,1.0)));
        difference = (occ_depth - depth);
        occlusion += smoothstep(0.0,area, clamp(difference,0.0,1.0));
    }

    float ao = total_strength * occlusion * (1.0 / float(samples));
    gl_FragColor = mix(texture2D(uInputTexture, vUv0), vec4(0,0,0,1), vec4(ao, ao, ao, 1));
}

In this you can up the samples to 16 and uncomment the second half of the array initialization if you want a better, slower effect. You can make a script to apply it to the camera. Here’s my legacy script for that:


pc.script.attribute('startEnabled', 'boolean', true)
pc.script.attribute('strength', 'number', 0.5)
pc.script.create('ssao', function (app) {
    var SSAO = function (entity) {
        this.entity = entity
        this._enableEffect = true
    }

    SSAO.prototype.initialize = function() {
        this.entity.camera.camera.requestDepthMap()
        this._enableEffect = this.startEnabled
    }

    SSAO.prototype.onEnable = function () {
        this.effect = this.effect || new SSAOEffect(app.graphicsDevice)
        this.effect.strength = this.strength
        this.enableEffect = this._enableEffect
    }

    SSAO.prototype.onDisable = function() {
        if(!this.enableEffect) return
        this.enableEffect = false
    }

    Object.defineProperties(SSAO.prototype, {
        enableEffect: {
            get: function() {
                return this._enableEffect
            },
            set: function(v) {
                this._enableEffect = v
                let queue = this.entity.camera.postEffects
                if(v) {
                    queue.addEffect(this.effect)
                } else {
                    queue.removeEffect(this.effect)
                }
            }
        },
        strength: {
            get: function() {
                return this._strength
            },
            set: function(v) {
                this._strength = v
                if(this.effect) {
                    this.effect.strength = v
                }
            }
        }
    })

    return SSAO
})

Please note you must call requestDepthMap() on the camera you will attach the effect to.

Adapted from this: http://theorangeduck.com/page/pure-depth-ssao

5 Likes

Hmm, no. I’ll make a different one with the screen effects too.

I’ve edited that line three times now lol.

I will make a separate project for the screen effects is what I mean.

Here you go. Drop this into your project and you’ll get a DOF and an SSAO post effect to add to a camera

I think my better DOF shader is on a different branch actually. This one works though :))

Hey it works best if you have shorter camera clips planes as it needs accurate information from the depth buffer to recreate the normals. I have my camea set to 4 to 150m for instance.

Could u please send me your project? Or share the source code to me? For the SSAO Effect?

I did! It’s at the top of this post :slight_smile: Any demos I have use the packed one above.

I have integrated and transformed the code of the subject, which can be used normally in the current version of playcanvas (version 1.35.0)

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

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

        // Shaders
        var attributes = {
            aPosition: pc.SEMANTIC_POSITION
        };

        var passThroughVert = [
            "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");

        var ssaoFrag = [
            "precision " + graphicsDevice.precision + " float;",
            "uniform sampler2D uDepthMap;",
            "uniform sampler2D uInputTexture;",
            "varying vec2 vUv0;",
            "uniform float total_strength;",
            "",
            "float unpackFloat(vec4 rgbaDepth) {",
            "    const vec4 bitShift = vec4(1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0);",
            "    float depth = dot(rgbaDepth, bitShift);",
            "    return depth;",
            "}",
            "",
            "vec3 normal_from_depth(float depth, vec2 texcoords) {",
            "    const vec2 offset1 = vec2(0.0,0.001);",
            "    const vec2 offset2 = vec2(0.001,0.0);",
            "",
            "    float depth1 = unpackFloat(texture2D(uDepthMap, texcoords + offset1));",
            "    float depth2 = unpackFloat(texture2D(uDepthMap, texcoords + offset2));",
            "",
            "    vec3 p1 = vec3(offset1, depth1 - depth);",
            "    vec3 p2 = vec3(offset2, depth2 - depth);",
            "",
            "    vec3 normal = cross(p1, p2);",
            "    normal.z = -normal.z;",
            "    return normalize(normal);",
            "}",
            "",
            "",
            "void main(void) {",
            "    const float area = 0.0075;",
            "    const float radius = 0.0017;",
            "",
            "    const int samples = 16;",
            "    vec3 sample_sphere[samples];",
            "    sample_sphere[0] = vec3( 0.5381, 0.1856,-0.4319);",
            "    sample_sphere[1] = vec3( 0.1379, 0.2486, 0.4430);",
            "    sample_sphere[2] = vec3( 0.3371, 0.5679,-0.0057);",
            "    sample_sphere[3] = vec3(-0.6999,-0.0451,-0.0019);",
            "    sample_sphere[4] = vec3( 0.0689,-0.1598,-0.8547);",
            "    sample_sphere[5] = vec3( 0.0560, 0.0069,-0.1843);",
            "    sample_sphere[6] = vec3(-0.0146, 0.1402, 0.0762);",
            "    sample_sphere[7] = vec3( 0.0100,-0.1924,-0.0344);",
            "    sample_sphere[8] = vec3(-0.3577,-0.5301,-0.4358);",
            "    sample_sphere[9] = vec3(-0.3169, 0.1063, 0.0158);",
            "    sample_sphere[10] = vec3( 0.0103,-0.5869, 0.0046);",
            "    sample_sphere[11] = vec3(-0.0897,-0.4940, 0.3287);",
            "    sample_sphere[12] = vec3( 0.7119,-0.0154,-0.0918);",
            "    sample_sphere[13] = vec3(-0.0533, 0.0596,-0.5411);",
            "    sample_sphere[14] = vec3( 0.0352,-0.0631, 0.5460);",
            "    sample_sphere[15] = vec3(-0.4776, 0.2847,-0.0271);",
            "",
            "    float depth = unpackFloat(texture2D(uDepthMap, vUv0));",
            "",
            "    vec3 position = vec3(vUv0, depth);",
            "    vec3 normal = normal_from_depth(depth, vUv0);",
            "",
            "    float radius_depth = radius/depth;",
            "    float occlusion = 0.0;",
            "    float difference;",
            "    for(int i=0; i < samples; i++) {",
            "        vec3 ray = radius_depth * reflect(sample_sphere[i], normalize(vec3(0.1,0.5,0.3)));",
            "        vec3 hemi_ray = position + sign(dot(ray,normal)) * ray;",
            "",
            "        float occ_depth = unpackFloat(texture2D(uDepthMap, clamp(hemi_ray.xy,0.0,1.0)));",
            "        difference = (occ_depth - depth);",
            "        occlusion += smoothstep(0.0,area, clamp(difference,0.0,1.0));",
            "    }",
            "",
            "    float ao = total_strength * occlusion * (1.0 / float(samples));",
            "    gl_FragColor = mix(texture2D(uInputTexture, vUv0), vec4(0,0,0,1), vec4(ao, ao, ao, 1));",
            "}"
        ].join("\n");

        this.ssaoShader = new pc.Shader(graphicsDevice, {
            attributes: attributes,
            vshader: passThroughVert,
            fshader: ssaoFrag
        });
        
        this.strength = 0.4;

    };

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

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

            scope.resolve("uInputTexture").setValue(inputTarget.colorBuffer);
            scope.resolve("total_strength").setValue(this.strength);
            pc.drawFullscreenQuad(device, outputTarget, this.vertexBuffer, this.ssaoShader, rect);
        }
    });

    return {
        SSAOEffect: SSAOEffect
    };
}());

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

SSAOForum.attributes.add('strength',{type:'number', default:0.5});

// initialize code called once per entity
SSAOForum.prototype.initialize = function () {
    this.effect = new pc.SSAOEffect(this.app.graphicsDevice);

    this.effect.strength = this.strength;

    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);
    });
};

No need to add requestDepthMap() in the current version of Playcanvas. Actually, if you do this, you won’t get the normal map correctly.

5 Likes

We’re experimenting with baking these directly in Playcanvas, but this is still very early stages

2 Likes