Rotated Poisson Disc Point Light Soft Shadows in GLSL

Omnidirectional point light soft shadows are achieved in real-time rendering by combining cubemap depth testing with jittered, rotated Poisson disc sampling kernels in GLSL. While traditional cube shadow mapping produces hard, aliased silhouettes, sampling multiple points distributed via a Poisson disc around the lookup vector softens the shadow boundary. By dynamically rotating this disc per pixel using a screen-space noise function or a blue noise texture, regular banding and structural sampling artifacts are transformed into high-frequency dithered noise, which is significantly less perceptible to the human eye and ideal for subsequent temporal anti-aliasing (TAA) or spatial blur passes.

Generating the Basis and Kernel

Point light shadow maps store distance in a cubemap, requiring a 3D direction vector from the light source to the fragment:

\[\vec{L} = \vec{P}_{\text{world}} - \vec{L}_{\text{pos}}\]

Because a Poisson disc is inherently defined in 2D Euclidean space, it must be mapped onto a plane perpendicular to the 3D direction vector \(\vec{L}\). This requires constructing an orthonormal basis (tangent and bitangent) aligned with the normalized light-to-surface vector.

A stable orthonormal basis can be formed in GLSL using cross products:

vec3 N = normalize(worldPos - lightPos);
vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
vec3 tangent = normalize(cross(up, N));
vec3 bitangent = cross(N, tangent);

Applying Per-Pixel Kernel Rotation

Using the same static Poisson offsets across adjacent pixels produces visible repeated grid patterns. To eliminate this, a random rotation angle \(\theta\) is derived per pixel using screen-space coordinates. Blue noise textures are preferred for generating this angle, though pseudo-random spatial hash functions (like interleaved gradient noise) can also be used.

Given an angle \(\theta\), a 2D rotation is applied to each Poisson sample \((u, v)\):

\[\begin{aligned} u' &= u \cos(\theta) - v \sin(\theta) \\ v' &= u \sin(\theta) + v \cos(\theta) \end{aligned}\]

Using this rotated 2D coordinate, the 3D sampling ray is computed by displacing along the tangent and bitangent:

\[\vec{D}_{\text{sample}} = \vec{L} + (u' \cdot \text{tangent} + v' \cdot \text{bitangent}) \times \text{filterRadius}\]

Complete GLSL Implementation

Below is a GLSL fragment implementation demonstrating Poisson disc sampling with blue-noise-driven rotation for a cube shadow map:

#version 450 core

uniform samplerCube shadowMap;
uniform sampler2D blueNoiseTex;

uniform vec3 lightPos;
uniform float farPlane;
uniform float filterRadius;

const int NUM_SAMPLES = 16;
const vec2 POISSON_DISK[16] = vec2[](
    vec2(-0.94201624, -0.39906216), vec2( 0.94558609, -0.76890725),
    vec2(-0.09418410, -0.92938870), vec2( 0.34495938,  0.29387760),
    vec2(-0.91588581,  0.45771432), vec2(-0.81544232, -0.87912464),
    vec2(-0.38277543,  0.27676845), vec2( 0.97484398,  0.75648379),
    vec2( 0.44323325, -0.97511554), vec2( 0.53742981, -0.47373420),
    vec2(-0.26496911, -0.41893023), vec2( 0.79197514,  0.19090188),
    vec2(-0.24188840,  0.99706507), vec2(-0.81409955,  0.91437590),
    vec2( 0.19984126,  0.78641367), vec2( 0.14383161, -0.14100790)
);

float calculatePointShadow(vec3 worldPos) {
    vec3 lightToFrag = worldPos - lightPos;
    float currentDepth = length(lightToFrag);

    // Bias to eliminate shadow acne
    float bias = 0.05;

    // Build orthonormal basis
    vec3 dir = normalize(lightToFrag);
    vec3 up = abs(dir.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
    vec3 tangent = normalize(cross(up, dir));
    vec3 bitangent = cross(dir, tangent);

    // Retrieve rotation angle via screen-space coordinates
    vec2 screenCoord = gl_FragCoord.xy / vec2(textureSize(blueNoiseTex, 0));
    float randomAngle = texture(blueNoiseTex, screenCoord).r * 6.2831853; // 2 * PI

    float cosTheta = cos(randomAngle);
    float sinTheta = sin(randomAngle);

    float shadow = 0.0;

    for (int i = 0; i < NUM_SAMPLES; ++i) {
        vec2 sampleOffset = POISSON_DISK[i];

        // Apply 2D rotation
        vec2 rotatedOffset = vec2(
            sampleOffset.x * cosTheta - sampleOffset.y * sinTheta,
            sampleOffset.x * sinTheta + sampleOffset.y * cosTheta
        );

        // Project rotated offset onto the tangent plane
        vec3 sampleDir = lightToFrag + (tangent * rotatedOffset.x + bitangent * rotatedOffset.y) * filterRadius;

        // Sample cubemap (assuming linear depth normalized to farPlane)
        float closestDepth = texture(shadowMap, sampleDir).r * farPlane;

        if (currentDepth - bias > closestDepth) {
            shadow += 1.0;
        }
    }

    return 1.0 - (shadow / float(NUM_SAMPLES));
}

Key Considerations

  • Dynamic Filter Radius: To approximate Percentage-Closer Soft Shadows (PCSS), the filterRadius can be adjusted dynamically based on the ratio between the receiver distance and blocker distance, making penumbras realistically wider the farther a shadow is cast from the occluder.
  • Cubemap Filtering: Sampling with standard samplerCube triggers point comparisons in the shader. When using hardware depth cubemaps (samplerCubeShadow), manual depth comparison is replaced by texture(samplerCubeShadow, vec4(sampleDir, currentDepthNorm)), allowing built-in PCF bilinear filtering between neighboring texels alongside the Poisson disc filter.