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
filterRadiuscan 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
samplerCubetriggers point comparisons in the shader. When using hardware depth cubemaps (samplerCubeShadow), manual depth comparison is replaced bytexture(samplerCubeShadow, vec4(sampleDir, currentDepthNorm)), allowing built-in PCF bilinear filtering between neighboring texels alongside the Poisson disc filter.