
Shadow artifacts
There is a self shadow artifact, the black rectangle on the side of the wall. Weirdly this is also dependent on zoom level.
What do you think is causing this issue?

There is a self shadow artifact, the black rectangle on the side of the wall. Weirdly this is also dependent on zoom level.
What do you think is causing this issue?
Orbiting the camera translates cameraPos and zooms change the frustum splits, shifting the stable bounding spheres and triggering texel-snapping updates. However, the resulting edge shimmering is severe.
To make cascade sizes rotation-invariant, bounding spheres are centered at the camera position:
cpp// Center is camera position, radius is constant based on split distances
const Vec3 center = cameraPos;
float radius = farDist * fovAspectFactor + 2.0f; // 2.0f PCF padding
StableCascadeData cascadeData = MakeStableCascadeViewProj(*shadowLight, center, radius, CASCADE_MAP_SIZE);
Grid snapping shifts projection boundaries to world-space texel alignment using std::floor:
cpp// Project world origin (0, 0, 0) into light space
Mat4x4 viewProj = proj * view;
Vec4 shadowOrigin = viewProj * Vec4(0.0f, 0.0f, 0.0f, 1.0f);
shadowOrigin.x /= shadowOrigin.w;
shadowOrigin.y /= shadowOrigin.w;
const f32 halfMapSize = (f32)mapSize * 0.5f;
const f32 originX = shadowOrigin.x * halfMapSize;
const f32 originY = shadowOrigin.y * halfMapSize;
// Offset the projection translation column
proj.columns[3].x += (std::floor(originX) - originX) / halfMapSize;
proj.columns[3].y += (std::floor(originY) - originY) / halfMapSize;
We use nearest filtering for reading G-Buffer depth to prevent edge interpolation jitter, reconstructing world position via u_InvViewProj:
glslvec3 ReconstructWorldPos(vec2 uv, float depth) {
vec2 screenUV = vec2(uv.x, 1.0 - uv.y); // Metal UV flip
vec4 ndc = vec4(screenUV * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
vec4 world = u_InvViewProj * ndc;
return world.xyz / world.w;
}
In the sampling step, we apply world-space normal bias scaled by texel size, and evaluate shadows using a 9-tap bilateral PCF gather (5x5 footprint):
glslfloat SampleCascade(sampler2D shadowMap, mat4 cascadeVP, int cascadeIndex, vec3 worldPos, vec3 N, float NdotL) {
float texelSize = max(u_CascadeTexelSize[cascadeIndex], 1e-5);
float depthRange = max(u_CascadeDepthRange[cascadeIndex], 1e-3);
// Apply normal bias in world units
vec3 shadowPos = worldPos + N * (u_ShadowParams.y * 30.0 * texelSize);
vec4 lightClip = cascadeVP * vec4(shadowPos, 1.0);
vec3 projected = lightClip.xyz / lightClip.w;
vec2 shadowUV = vec2(projected.x * 0.5 + 0.5, 1.0 - (projected.y * 0.5 + 0.5));
float currentDepth = projected.z * 0.5 + 0.5;
// Slope-scaled depth bias
float slopeScale = sqrt(max(1.0 - NdotL * NdotL, 0.0)) / max(NdotL, 0.05);
float bias = u_ShadowParams.z * (texelSize / depthRange) * (1.0 + 1.75 * clamp(slopeScale, 0.0, 4.0));
// PCF grid sampling using textureGather...
return EvaluatePCF(shadowMap, shadowUV, currentDepth, bias);
}