u/Foreign-Reply5841

Shadow artifacts

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?

u/Foreign-Reply5841 — 20 hours ago

Shadows are flickering when I move the camera

The Problem / Questions:

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.

  1. Reconstruction Precision: Reconstructing worldPos in the fragment shader via u_InvViewProj * ndc is highly sensitive to single-precision float accuracy under view-matrix updates. Should we reconstruct view-space position first via u_InvProj and then analytically reconstruct world-space position?
  2. Texel Snapping Math: Does offsetting the projection matrix (proj.columns[3].x += ...) introduce floating-point drift/mismatch when used with Metal's coordinate system (Z∈[0,1])?
  3. Cascade Fluttering: Cascade selection uses viewDepth = abs((u_View * vec4(worldPos, 1.0)).z). Since worldPos is reconstructed, does this float round-trip cause cascade indices to flutter back and forth at boundaries?

1. CPU-Side: Bounding Spheres & Texel Snapping

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;

2. GPU-Side: Reconstruction & Sampling

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); 
}
u/Foreign-Reply5841 — 1 day ago