SSAO implementation in Babylon JS and GLSL, using view ray for depth comparison

1.8k Views Asked by At

I'm trying to create my own SSAO shader in forward rendering (not in post processing) with GLSL. I'm encountering some issues, but I really can't figure out what's wrong with my code.

It is created with Babylon JS engine as a BABYLON.ShaderMaterial and set in a BABYLON.RenderTargetTexture, and it is mainly inspired by this renowned SSAO tutorial: http://john-chapman-graphics.blogspot.fr/2013/01/ssao-tutorial.html

For performance reasons, I have to do all the calculation without projecting and unprojecting in screen space, I'd rather use the view ray method described in the tutorial above.

Before explaining the whole thing, please note that Babylon JS uses a left-handed coordinate system, which may have quite an incidence on my code.

Here are my classic steps:

  1. First, I calculate my four camera far plane corners positions in my JS code. They might be constants every time as they are calculated in view space position.
// Calculating 4 corners manually in view space
var tan = Math.tan;
var atan = Math.atan;
var ratio = SSAOSize.x / SSAOSize.y;
var far = scene.activeCamera.maxZ;
var fovy = scene.activeCamera.fov;
var fovx = 2 * atan(tan(fovy/2) * ratio);
var xFarPlane = far * tan(fovx/2);
var yFarPlane = far * tan(fovy/2);

var topLeft     = new BABYLON.Vector3(-xFarPlane,  yFarPlane, far);
var topRight    = new BABYLON.Vector3( xFarPlane,  yFarPlane, far);
var bottomRight = new BABYLON.Vector3( xFarPlane, -yFarPlane, far);
var bottomLeft  = new BABYLON.Vector3(-xFarPlane, -yFarPlane, far);

var farCornersVec = [topLeft, topRight, bottomRight, bottomLeft];
var farCorners = [];

for (var i = 0; i < 4; i++) {
    var vecTemp = farCornersVec[i];
    farCorners.push(vecTemp.x, vecTemp.y, vecTemp.z);
}
  1. These corner positions are sent to the vertex shader -- that is why the vector coordinates are serialized in the farCorners[] array to be sent in the vertex shader.

  2. In my vertex shader, position.x and position.y signs let the shader know which corner to use at each pass.

  3. These corners are then interpolated in my fragment shader for calculating a view ray, i.e. a vector from the camera to the far plane (its .z component is, therefore, equal to the far plane distance to camera).

  4. The fragment shader follows the instructions of John Chapman's tutorial (see commented code below).

I get my depth buffer as a BABYLON.RenderTargetTexture with the DepthRenderer.getDepthMap() method. A depth texture lookup actually returns (according to Babylon JS's depth shaders): (gl_FragCoord.z / gl_FragCoord.w) / far, with:

  • gl_FragCoord.z: the non-linear depth
  • gl_FragCoord.z = 1/Wc, where Wc is the clip-space vertex position (i.e. gl_Position.w in the vertex shader)
  • far: the positive distance from camera to the far plane.

The kernel samples are arranged in a hemisphere with random floats in [0,1], most being distributed close to origin with a linear interpolation.

As I don't have a normal texture, I calculate them from the current depth buffer value with getNormalFromDepthValue():

vec3 getNormalFromDepthValue(float depth) {
    vec2 offsetX = vec2(texelSize.x, 0.0);
    vec2 offsetY = vec2(0.0, texelSize.y);
    // texelSize = size of a texel = (1/SSAOSize.x, 1/SSAOSize.y)

    float depthOffsetX = getDepth(depthTexture, vUV + offsetX); // Horizontal neighbour
    float depthOffsetY = getDepth(depthTexture, vUV + offsetY); // Vertical neighbour

    vec3 pX = vec3(offsetX, depthOffsetX - depth);
    vec3 pY = vec3(offsetY, depthOffsetY - depth);
    vec3 normal = cross(pY, pX);
    normal.z = -normal.z; // We want normal.z positive

    return normalize(normal); // [-1,1]
}

Finally, my getDepth() function allows me to get the depth value at current UV in 32-bit float:

float getDepth(sampler2D tex, vec2 texcoord) {
    return unpack(texture2D(tex, texcoord));
    // unpack() retreives the depth value from the 4 components of the vector given by texture2D()
}

Here are my vertex and fragment shader codes (without function declarations):

// ---------------------------- Vertex Shader ----------------------------
precision highp float;

uniform float fov;
uniform float far;
uniform vec3 farCorners[4];

attribute vec3 position; // 3D position of each vertex (4) of the quad in object space
attribute vec2 uv; // UV of each vertex (4) of the quad

varying vec3 vPosition;
varying vec2 vUV;
varying vec3 vCornerPositionVS;

void main(void) {
    vPosition = position;
    vUV = uv;

    // Map current vertex with associated frustum corner position in view space:
    // 0: top left, 1: top right, 2: bottom right, 3: bottom left
    // This frustum corner position will be interpolated so that the pixel shader always has a ray from camera->far-clip plane.
    vCornerPositionVS = vec3(0.0);

    if (positionVS.x > 0.0) {
        if (positionVS.y <= 0.0) { // top left
        vCornerPositionVS = farCorners[0];
        }
        else if (positionVS.y > 0.0) { // top right
            vCornerPositionVS = farCorners[1];
        }
    }
    else if (positionVS.x <= 0.0) {
        if (positionVS.y > 0.0) { // bottom right
            vCornerPositionVS = farCorners[2];
        }
        else if (positionVS.y <= 0.0) { // bottom left
            vCornerPositionVS = farCorners[3];
        }
    }

    gl_Position = vec4(position * 2.0, 1.0); // 2D position of each vertex
}
// ---------------------------- Fragment Shader ----------------------------
precision highp float;    

uniform mat4 projection; // Projection matrix
uniform float radius; // Scaling factor for sample position, by default = 1.7
uniform float depthBias; // 1e-5
uniform vec2 noiseScale; // (SSAOSize.x / noiseSize, SSAOSize.y / noiseSize), with noiseSize = 4

varying vec3 vCornerPositionVS; // vCornerPositionVS is the interpolated position calculated from the 4 far corners

void main() {
    // Get linear depth in [0,1] with texture2D(depthBufferTexture, vUV)
    float fragDepth = getDepth(depthBufferTexture, vUV);
    float occlusion = 0.0;

    if (fragDepth < 1.0) {
        // Retrieve fragment's view space normal
        vec3 normal = getNormalFromDepthValue(fragDepth); // in [-1,1]

        // Random rotation: rvec.xyz are the components of the generated random vector
        vec3 rvec = texture2D(randomSampler, vUV * noiseScale).rgb * 2.0 - 1.0; // [-1,1]
        rvec.z = 0.0; // Random rotation around Z axis

        // Get view ray, from camera to far plane, scaled by 1/far so that viewRayVS.z == 1.0
        vec3 viewRayVS = vCornerPositionVS / far;

        // Current fragment's view space position
        vec3 fragPositionVS = viewRay * fragDepth;

        // Creation of TBN matrix
        vec3 tangent = normalize(rvec - normal * dot(rvec, normal));
        vec3 bitangent = cross(normal, tangent);
        mat3 tbn = mat3(tangent, bitangent, normal);

        for (int i = 0; i < NB_SAMPLES; i++) {
            // Get sample kernel position, from tangent space to view space
            vec3 samplePosition = tbn * kernelSamples[i];

           // Add VS kernel offset sample to fragment's VS position
            samplePosition = samplePosition * radius + fragPosition;

            // Project sample position from view space to screen space:
            vec4 offset = vec4(samplePosition, 1.0);
            offset = projection * offset; // To view space
            offset.xy /= offset.w; // Perspective division
            offset.xy = offset.xy * 0.5 + 0.5; // [-1,1] -> [0,1]

            // Get current sample depth:
            float sampleDepth = getDepth(depthTexture, offset.xy);

            float rangeCheck = abs(fragDepth - sampleDepth) < radius ? 1.0 : 0.0;
            // Reminder: fragDepth == fragPosition.z

            // Range check and accumulate if fragment contributes to occlusion:
            occlusion += (samplePosition.z - sampleDepth >= depthBias ? 1.0 : 0.0) * rangeCheck;
        }
    }

    // Inversion
    float ambientOcclusion = 1.0 - (occlusion / float(NB_SAMPLES));
    ambientOcclusion = pow(ambientOcclusion, power);
    gl_FragColor = vec4(vec3(ambientOcclusion), 1.0);
}

A horizontal and vertical Gaussian shader blur clears the noise generated by the random texture afterwards.

My parameters are:

NB_SAMPLES = 16
radius = 1.7
depthBias = 1e-5
power = 1.0

Here is the result:

Please click to see my result

The result has artifacts on its edges, and the close shadows are not very strong... Would anyone see something wrong or weird in my code?

Thanks a lot!

1

There are 1 best solutions below

10
On

fragPositionVS is a position in view space coordinates and radius is length in view coordinates. You use them to calculate the samplePosition:

samplePosition = samplePosition * radius + fragPositionVS;

But in the line rangeCheck = abs(fragDepth - sampleDepth) < radius ? 1.0 : 0.0;, you compare the difference of fragDepth and sampleDepth with radius. That makes no sense, since fragDepth and sampleDepth are values from the depth buffer in, the range [0, 1] and radius is a lenght in the view space.

In the line occlusion += (samplePosition.z - sampleDepth >= depthBias ? 1.0 : 0.0) * rangeCheck;, you calculate the difference of samplePosition.z and sampleDepth. While samplePosition.z is a view space coordinate inbetween -near and -far, sampleDepth is a depth in range [0, 1]. Calculating a difference between these two values doesn't make any sense either.

I suggest using always Z coordinates, if you want to calculate distances or if you want to compare distances.

If you have a depth value, the Z-coordinate in view space can be calculated by converting the depth value to normalized device coordinate and converting the normalized device coordinate to a view coordinate:

float DepthToZ( in float depth )
{
    float near  = .... ; // distance to near plane (absolute value)
    float far   = .... ; // distance to far plane (absolute value)
    float z_ndc = 2.0 * depth - 1.0;
    float z_eye = 2.0 * near * far / (far + near - z_ndc * (far - near));
    return -z_eye;
}

The depth is a value in the range [0, 1] and maps the range from the distance to the near plane and the distance to the far plane (in view space), but not linear (for perspective projection).
For this reason, the code line vec3 fragPositionVS = (vCornerPositionVS / far) * fragDepth; will not calculate a correct fragment position, but you can do it like this:

vec3 fragPositionVS = vCornerPositionVS * abs( DepthToZ(fragDepth) / far );

Note, in view space the z axis comes out of the view port. If the corner positions are set up in view space, then the Z-coordinate has to be the negative distance to the far plane:

var topLeft     = new BABYLON.Vector3(-xFarPlane,  yFarPlane, -far);
var topRight    = new BABYLON.Vector3( xFarPlane,  yFarPlane, -far);
var bottomRight = new BABYLON.Vector3( xFarPlane, -yFarPlane, -far);
var bottomLeft  = new BABYLON.Vector3(-xFarPlane, -yFarPlane, -far);

In the vertex shader the assignment of the corner positions is mixed. The lower left position of the viewport is (-1,-1) and the top right position is (1,1) (in normalized device coordinates).
Adapt the code like this:

JavaScript:

var farCornersVec = [bottomLeft, bottomRight, topLeft, topRight];

Vertex shader:

// bottomLeft=0*2+0*1, bottomRight=0*2+1*1, topLeft=1*2+0*1, topRight=1*2+1*1;
int i = (positionVS.y > 0.0 ? 2 : 0) + (positionVS.x > 0.0 ? 1 : 0);
vCornerPositionVS = farCorners[i];

Note, if you could add an additional vertex attribute for the corner position, then it would be simplified.

The calculation of the fragment position can be simplified, if the aspect ratio, the field of view angle and the normalized device coordinates of the fragment (fragment position in range [-1,1]) are known:

ndc_xy   = vUV * 2.0 - 1.0;
tanFov_2 = tan( radians( fov / 2 ) )
aspect   = vp_size_x / vp_size_y
fragZ    = DepthToZ( fragDepth );
fragPos  = vec3( ndc_xy.x * aspect * tanFov_2, ndc_xy.y * tanFov_2, -1.0 ) * abs( fragZ );

If the perspective projection matrix is known, this can be calculated easily:

vec2 ndc_xy       = vUV.xy * 2.0 - 1.0;
vec4 viewH        = inverse( projection ) * vec4( ndc_xy, fragDepth * 2.0 - 1.0, 1.0 );
vec3 fragPosition = viewH.xyz / viewH.w;

If the perspective projection is symmetric (the filed of view is not displaced and the Z-axis of the view space is in the center of the viewport), this can be simplified:

vec2 ndc_xy       = vUV.xy * 2.0 - 1.0;
vec3 fragPosition = vec3( ndc_xy.x / projection[0][0], ndc_xy.y / projection[1][1], -1.0 ) * abs(DepthToZ(fragDepth));

See also:


I suggest to write the fragment shader somehow like this:

float fragDepth = getDepth(depthBufferTexture, vUV);
float ambientOcclusion = 1.0;
if (fragDepth > 0.0)
{
    vec3 normal = getNormalFromDepthValue(fragDepth); // in [-1,1]
    vec3 rvec = texture2D(randomSampler, vUV * noiseScale).rgb * 2.0 - 1.0;
    rvec.z = 0.0;
    vec3 tangent = normalize(rvec - normal * dot(rvec, normal));
    mat3 tbn = mat3(tangent, cross(normal, tangent), normal);

    vec2 ndc_xy = vUV.xy * 2.0 - 1.0;
    vec3 fragPositionVS = vec3( ndc_xy.x / projection[0][0], ndc_xy.y / projection[1][1], -1.0 ) * abs( DepthToZ(fragDepth) );
    // vec3 fragPositionVS = vCornerPositionVS * abs( DepthToZ(fragDepth) / far );

    float occlusion = 0.0;
    for (int i = 0; i < NB_SAMPLES; i++)
    {
        vec3 samplePosition = fragPositionVS + radius * tbn * kernelSamples[i];

        // Project sample position from view space to screen space:
        vec4 offset  = projection * vec4(samplePosition, 1.0);
        offset.xy   /= offset.w;               // Perspective division -> [-1,1]
        offset.xy    = offset.xy * 0.5 + 0.5;  // [-1,1] -> [0,1]

        // Get current sample depth
        float sampleZ = DepthToZ( getDepth(depthTexture, offset.xy) );

        // Range check and accumulate if fragment contributes to occlusion:
        float rangeCheck = step( abs(fragPositionVS.z - sampleZ), radius );
        occlusion += step( samplePosition.z - sampleZ, -depthBias ) * rangeCheck;
    }
    // Inversion
    ambientOcclusion = 1.0 - (occlusion / float(NB_SAMPLES));
    ambientOcclusion = pow(ambientOcclusion, power);
}
gl_FragColor = vec4(vec3(ambientOcclusion), 1.0);


See the WebGL example, which demonstrates the full algorithm (Unfortunately the full code would exceed the limit of 30000 signs, which an answer is limited to):

JSFiddle or GitHub

SSAOtest


Extension to the answer

The depth as it would be stored in the depth buffer is calculated like this:

(see OpenGL ES write depth data to color)

float ndc_depth = vPosPrj.z / vPosPrj.w;
float depth     = ndc_depth * 0.5 + 0.5;

This value is already calculated in the fragment shader and is contained in gl_FragCoord.z. See the Khronos Group reference page for gl_FragCoord which says:

The z component is the depth value that would be used for the fragment's depth if no shader contained any writes to gl_FragDepth.

If the depth has to be stored in a RGBA8 buffer, the depth has to be encoded to the 4 bytes of the buffer to avoid a loss of accuracy, and has to be decoded when read from the buffer:

encode

vec3 PackDepth( in float depth )
{
    float depthVal = depth * (256.0*256.0*256.0 - 1.0) / (256.0*256.0*256.0);
    vec4 encode = fract( depthVal * vec4(1.0, 256.0, 256.0*256.0, 256.0*256.0*256.0) );
    return encode.xyz - encode.yzw / 256.0 + 1.0/512.0;
}

decode

float UnpackDepth( in vec3 pack )
{
  float depth = dot( pack, 1.0 / vec3(1.0, 256.0, 256.0*256.0) );
  return depth * (256.0*256.0*256.0) / (256.0*256.0*256.0 - 1.0);
}

See also the answers to the following questions: