Quantcast
Channel: Jean-Marc Le Roux
Viewing all articles
Browse latest Browse all 27

Exponential Cascaded Shadow Mapping with WebGL

$
0
0

Update: my implementation worked well on “native” OpenGL configuration but suffered from a GLSL-to-HLSL bug in the ANGLE shader cross-compiler used by Firefox and Chrome on Windows. It should now be fixed.

Update 2: you can toggle each cascade frustum using “L” and the camera frustum using “C”.

TL;DR

shadowmappingWebGL Cascaded Shadow Mapping demo powered by Minko

  • arrow keys: rotate around the scene or zoom in/out
  • C: toggle the debug display of the camera frustum
  • L: toggle the debug display of each cascade frustum
  • A: toggle shadow mapping for the 1st light
  • Z: toggle shadow mapping for the 2nd light

Motivations

Lighting is a very important part of rendering real-time convincing 3D scenes. Minko already provides a quite comprehensive set of components (DirectionalLight, SpotLight, AmbientLight, PointLight) and shaders to implement the Phong reflection model. Yet, without projected shadows, the human eye can hardly grasp the actual layout and depth of the scene.

My goal was to work on directional light projected shadows as part of a broader work to handle sun-light and a dynamic outdoor environment rendering setup (sun flares, sky, weather…).

Shadow Mapping is the preferred method for hardware accelerated dynamic projected shadows. Cascaded Shadow Mapping is a variation that makes it easier – and at some extend automagic – to render high-quality shadow maps especially for large (outdoor) scenes with one (or more) directional sun-like light(s).

Finally, shadow mapping is a complex rendering scheme that involves pre-render out-of-screen rendering and filtering phases and I wanted to make sure Minko’s effect pipeline was able to handle it.

Computing the split projections

The first step is to compute each shadow cascade (or split) projection. The goal is to split the original camera perspective projection frustum into multiple sub-frustum (1 split => 1 frustum). The sum of all sub-frustum must – of course – cover the exact same volume as the original camera frustum.

The Practical Split Scheme (Source: NVIDIA GPU Gems 3 - Chapter 10)
The Practical Split Scheme (Source: NVIDIA GPU Gems 3 – Chapter 10)

To do this, I used the  “Practical Split Scheme” provided in the GPU Gems 3 – Chapters 10 “Parallel Split Shadow Maps on Programmable GPUs” article.

void
DirectionalLight::computeShadowProjection(const math::mat4& view, const math::mat4& projection)
{
    math::mat4 invProjection = math::inverse(projection);
    std::vector v = {
        invProjection * math::vec4(-1.f, 1.f, -1.f, 1.f),
        invProjection * math::vec4(1.f, 1.f, -1.f, 1.f),
        invProjection * math::vec4(1.f, -1.f, -1.f, 1.f),
        invProjection * math::vec4(-1.f, -1.f, -1.f, 1.f),
        invProjection * math::vec4(-1.f, 1.f, 1.f, 1.f),
        invProjection * math::vec4(1.f, 1.f, 1.f, 1.f),
        invProjection * math::vec4(1.f, -1.f, 1.f, 1.f),
        invProjection * math::vec4(-1.f, -1.f, 1.f, 1.f)
    };

    float zNear = -(v[0] / v[0].w).z;
    float zFar = -(v[4] / v[4].w).z;
    float fov = atanf(1.f / projection[1][1]) * 2.f;
    float ratio = projection[1][1] / projection[0][0];

    std::array<float, 4> splitFar = { zFar, zFar, zFar, zFar };
    std::array<float, 4> splitNear = { zNear, zNear, zNear, zNear };
    float lambda = .8f;
    float j = 1.f;
    for (auto i = 0; i < _numShadowCascades - 1; ++i, j+= 1.f)
    {
        splitFar[i] = math::mix(
            zNear + (j / (float)_numShadowCascades) * (zFar - zNear),
            zNear * powf(zFar / zNear, j / (float)_numShadowCascades),
            lambda
        );
        splitNear[i + 1] = splitFar[i];
    }

    for (auto i = 0; i < _numShadowCascades; ++i)    
    {
         math::mat4 cameraViewProjection = math::perspective(fov, ratio, zNear, splitFar[i]) * view;
         auto box = computeBox(cameraViewProjection);
         auto projection = math::ortho(
            box.first.x, box.second.x,
            box.first.y, box.second.y,
            -box.second.z, -box.first.z
         );

         _shadowProjections[i] = projection;
         zNear = splitFar[i];
    }
    data()->set("shadowSplitFar", math::make_vec4(&splitFar[0]));
    data()->set("shadowSplitNear", math::make_vec4(&splitNear[0]));

    updateWorldToScreenMatrix();
}

Rendering the shadow maps

Rendering the shadow map is quite straight forward:

#ifdef GL_ES
# ifdef MINKO_PLATFORM_IOS
    precision highp float;
# else
    precision mediump float;
# endif
#endif

#pragma include "Pack.function.glsl"

uniform float uZNear;
uniform float uZFar;

varying vec4 vPosition;

float linearDepthOrtho(float depth, float zNear, float zFar)
{
    return depth * 0.5 + 0.5;
}

void main(void)
{
    float depth = linearDepthOrtho(vPosition.z, uZNear, uZFar);

    gl_FragColor = packFloat8bitRGBA(depth);
}

The only trick is that – in order to avoid using floating point textures that would require a WebGL extension – I pack the depth value in the R8G8B8A8 fragment color using the following function:

vec4 packFloat8bitRGBA(float val)
{
    vec4 pack = vec4(1.0, 255.0, 65025.0, 16581375.0) * val;
    pack = fract(pack);
    pack -= vec4(pack.yzw / 255.0, 0.0);

    return pack;
}

For performance reasons and in order to avoid branches in the final step when rendering shadow (see “Rendering the shadows” below), we have to render all the cascades in a single render target. This target will be our “shadow atlas”.

To do this, I simply setup 4 Renderers with 4 different viewports that will all render into the same render target:

for (auto i = 0; i < _numShadowCascades; ++i) {
    auto renderer = component::Renderer::create(
         0xffffffff,
         _shadowMap,
         fx,
         "shadow-map-cascade" + std::to_string(i),
         render::Priority::FIRST - i
    );
    renderer->clearBeforeRender(i == 0);
    renderer->viewport(viewports[i]);
    renderer->effectVariables()["lightUuid"] = data()->uuid();
    renderer->layoutMask(256);
    target()->addComponent(renderer);

    _shadowRenderers[i] = renderer;
}

Note that I also make sure only the 1st Renderer will clear the render target by calling renderer->clearBeforeRender(i == 0).

Rendering the shadows

Rendering the shadows themselves is a bit trickier. In order to sample the shadow map, one must first know which shadow map (/cascade) to sample. And with each shadow map comes a different projection, z-near and z-far values…

Because we render a shadow atlas, we just have to know which part/viewport to sample. In the end, selection that viewport, the light projection and the z near/far values can be done without branching using a classic weighting approach combining all possible values.

The following function will compute a vec4 with a single component set to 1 (and all the others set to 0). The component set to 1 will give you the proper cascade to use (x => cascade 1, y => cascade 2…):

vec4 shadowMapping_getCascadeWeights(float depth, vec4 splitNear, vec4 splitFar)
{
	vec4 near = step(splitNear, vec4(depth));
	vec4 far = step(depth, splitFar);

	return near * far;
}

Those weights can then be used to properly build the cascade parameters from all possible values:

vec4 shadowMapping_getCascadeViewport(vec4 weights)
{
	vec2 offset = vec2(0.0, 0.5) * weights.x
        + vec2(0.5, 0.5) * weights.y
        + vec2(0.0, 0.0) * weights.z
        + vec2(0.5, 0.0) * weights.w;

	return vec4(offset, 0.5, 0.5);
}

mat4 shadowMapping_getCascadeViewProjection(vec4 weights, mat4 viewProj[SHADOW_MAPPING_MAX_NUM_CASCADES])
{
	return viewProj[0] * weights.x + viewProj[1] * weights.y + viewProj[2] * weights.z + viewProj[3] * weights.w;
}

float shadowMapping_getCascadeZ(vec4 weights, float z[SHADOW_MAPPING_MAX_NUM_CASCADES])
{
	return z[0] * weights.x + z[1] * weights.y + z[2] * weights.z + z[3] * weights.w;
}

I use those functions like this:

float getShadow(sampler2D shadowMap,
                mat4 viewProj[SHADOW_MAPPING_MAX_NUM_CASCADES],
                float zNear[SHADOW_MAPPING_MAX_NUM_CASCADES],
                float zFar[SHADOW_MAPPING_MAX_NUM_CASCADES],
                vec4 splitNear,
                vec4 splitFar,
                float size,
                float bias)
{
	float shadow = 1.0;
    vec4 weights = shadowMapping_getCascadeWeights(vertexScreenPosition.z, splitNear, splitFar);
	vec4 viewport = shadowMapping_getCascadeViewport(weights);
    mat4 viewProjection = shadowMapping_getCascadeViewProjection(weights, viewProj);
    float near = shadowMapping_getCascadeZ(weights, zNear);
    float far = shadowMapping_getCascadeZ(weights, zFar);
	vec3 vertexLightPosition = (viewProjection * vec4(vertexPosition, 1)).xyz;

    if (shadowMapping_vertexIsInShadowMap(vertexLightPosition))
    {
        float shadowDepth = vertexLightPosition.z - bias;
        vec2 depthUV = vertexLightPosition.xy / 2.0 + 0.5;

        depthUV = vec2(depthUV.xy * viewport.zw + viewport.xy);
        shadow = shadowMapping_ESM(shadowMap, depthUV, shadowDepth, near, far, 30.0);
    }

    return shadow;
}

Exponential Shadow Mapping

To get smoother soft-like shadows, I use Exponential Shadow Mapping (ESM). ESM is done by doing mainly 2 things:

  • Filtering the shadow map: in my case I use a simple box-blur + linear texture filtering when sampling the result.
  • Use the exp() GLSL function before comparing the depths.

Beware! When you perform the box-blur, make sure you unpack the depth values before blurring them. My box-blur shader looks like this:

#ifdef GL_ES
# ifdef MINKO_PLATFORM_IOS
    precision highp float;
# else
    precision mediump float;
# endif
#endif

uniform sampler2D uTexture;
uniform float uTextureSize;
uniform float uSpread;

varying vec2 vUV;

#pragma include \"Pack.function.glsl\"

#ifndef BOXBLUR_OFFSET
# define BOXBLUR_OFFSET 2
#endif

void main()
{
    float c = 0.0;

    for (int x = -BOXBLUR_OFFSET; x <= BOXBLUR_OFFSET; ++x)
        for (int y = -BOXBLUR_OFFSET; y <= BOXBLUR_OFFSET; ++y)
            c += unpackFloat8bitRGBA(texture2D(uTexture, vUV + vec2(x, y) / uTextureSize));

    gl_FragColor = packFloat8bitRGBA(c / float((BOXBLUR_OFFSET * 2 + 1) * (BOXBLUR_OFFSET * 2 + 1)));
}

The ESM depth comparisong code is done with the following function:

float shadowMapping_texture2DDepth(sampler2D depths, vec2 uv, float zNear, float zFar)
{
    float depth = unpackFloat8bitRGBA(texture2D(depths, uv));

    return (depth - 0.5) / 0.5;
}

float shadowMapping_ESM(sampler2D depths, vec2 uv, float compare, float zNear, float zFar, float c)
{
    float depth = shadowMapping_texture2DDepth(depths, uv, zNear, zFar);

    depth = exp(c * depth) * exp(-c * compare);
    depth = clamp(depth, 0.0, 1.0);

    return depth;
}

Going further…

The final code will soon be available on the Minko repository in the dev branch. Next steps include:

  • Stabilizing the shadow map: lower resolution cascades tend to make the final shadow rendering “swim”. That’s a known issue fixed by tweaking the projection matrix.
  • Blending between cascade to get smoother transitions.
  • Implementing shadow mapping for point and spot lights.

Viewing all articles
Browse latest Browse all 27

Trending Articles