diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 2effae7f6..83f60f2db 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -616,14 +616,14 @@ public enum RenderPasses { ensureShadowCacheConfigured() guard let frustum = shadowFrustum(for: cascadeIdx) else { return [] } - let cameraPosition: simd_float3 - if let cam = CameraSystem.shared.activeCamera, - let camComp = scene.get(component: CameraComponent.self, for: cam) - { - cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(camComp.localPosition) - } else { - cameraPosition = .zero - } + // Conservative, direction-agnostic pre-reject: a caster farther than the engine's + // own shadow-distance horizon (maxShadowCastingDistance) from anything this cascade's + // camera-frustum slice could see cannot matter to this cascade, regardless of light + // direction — unlike a camera-depth cutoff, this never excludes the far/shallow-angle + // casters that motivated removing the old per-cascade distance cull, since it is + // measured from the cascade's own world-space bounding sphere, not the camera. + let cascadeCenter = shadowSystem.cascadeWorldCenters[cascadeIdx] + let cascadeReach = shadowSystem.cascadeWorldRadii[cascadeIdx] + RenderPasses.maxShadowCastingDistance // Rebuild candidate list if dirty. At most one rebuild per dirty event, shared // across all cascade invocations in the same frame. @@ -671,20 +671,16 @@ public enum RenderPasses { localMax: localTransformComponent.boundingBox.max, worldMatrix: worldTransformComponent.space ) - // Per-cascade distance limit: cap at the cascade's own split distance so - // objects beyond this cascade's far plane are not rendered into it. - // This prevents the near cascade from receiving shadow casters that are - // only relevant to farther cascades, cutting draw calls significantly for - // the near (most expensive) cascade. - let cascadeMaxDistance = shadowCascadeMaxDistance( - cascadeIdx: cascadeIdx, - splitDistances: shadowSystem.cascadeSplitDistances, - globalMax: RenderPasses.maxShadowCastingDistance - ) + // Directional-light caster relevance cannot be determined from camera + // distance or the cascade receiver split — a caster outside a cascade's + // camera-depth interval can still project a shadow into that interval. + // The world-space distance reject above stays correct for any light + // direction; the fitted light-space cascade frustum below is the + // correctness-preserving cull for what actually lands in the map. if shadowEntityBeyondMaxDistance( worldMin: worldMin, worldMax: worldMax, - cameraPosition: cameraPosition, - maxDistance: cascadeMaxDistance + cameraPosition: cascadeCenter, + maxDistance: cascadeReach ) { continue } if isAABBInFrustum(frustum, min: worldMin, max: worldMax) { result.append(entityId) @@ -4972,28 +4968,6 @@ private func uploadAndBindLights( return true } -// MARK: - Shadow cascade distance helpers (internal — exposed for testing via @testable import) - -/// Returns the effective maximum shadow-casting distance for a single CSM cascade. -/// -/// Each cascade only needs shadow casters within its own split range. Capping at the -/// cascade's split distance prevents the near cascade from receiving distant casters -/// that are only relevant to farther cascades, reducing shadow draw calls on cascade 0. -/// -/// - Parameters: -/// - cascadeIdx: Index of the cascade (0 = nearest). -/// - splitDistances: Per-cascade far-plane distances from the camera, as computed by ShadowSystem. -/// - globalMax: The scene-wide shadow distance cap (RenderPasses.maxShadowCastingDistance). -/// - Returns: The tighter of globalMax and the cascade's own split distance. -func shadowCascadeMaxDistance( - cascadeIdx: Int, - splitDistances: [Float], - globalMax: Float -) -> Float { - guard cascadeIdx < splitDistances.count else { return globalMax } - return min(globalMax, splitDistances[cascadeIdx]) -} - /// Returns true when the entity's AABB is farther than maxDistance from the camera. /// Uses closest-point-on-AABB distance so large meshes near the camera are never wrongly excluded. /// maxDistance == 0 disables culling (always returns false). diff --git a/Sources/UntoldEngine/Shaders/LightShader.metal b/Sources/UntoldEngine/Shaders/LightShader.metal index 15c3a5b40..fd73210bf 100644 --- a/Sources/UntoldEngine/Shaders/LightShader.metal +++ b/Sources/UntoldEngine/Shaders/LightShader.metal @@ -14,25 +14,30 @@ #include "ShadersUtils.h" using namespace metal; -// Cascaded shadow map sampling. -// Selects the cascade whose far-split encloses the fragment's camera view-depth, -// then performs a 16-tap Poisson-disk PCF on that cascade's depth slice. -float computeCSMShadow( +// Normal-offset shadows: how many of the cascade's own world-space texels to push the +// sampled point along the surface normal before the light-space lookup. This moves the +// tested point off the surface instead of only fudging the depth comparison, so it stays +// robust to acne from a normal-mapped shading normal (whose NoL — and so whose depth +// bias — can vary within a single flat, single-depth shadow-map texel). +constant float kNormalOffsetTexels = 1.5; + +float sampleCSMCascade( depth2d_array shadowArray, constant CSMUniforms &csm, + int cascade, float3 worldPos, - float3 cameraPos, float3 normal, - float3 lightDir + float worldBias, + float worldFilterRadius ) { - // Pick cascade using the same right-handed camera depth space as the CPU split calculation. - float viewDepth = -(csm.cameraViewMatrix * float4(worldPos, 1.0)).z; - int cascade = csm.cascadeCount - 1; - for (int i = 0; i < csm.cascadeCount - 1; i++) { - if (viewDepth < csm.cascadeSplits[i]) { cascade = i; break; } - } + float cascadeWorldTexelSize = max(csm.cascadeWorldTexelSizes[cascade], 1.0e-6); - float4 shadowCoords = csm.lightSpaceMatrices[cascade] * float4(worldPos, 1.0); + // Guard against a degenerate (zero-length) normal — e.g. an unwritten G-buffer + // texel — so normalize() can't produce a NaN that corrupts the shadow-space UV. + float normalLengthSq = length_squared(normal); + float3 safeNormalDir = normalLengthSq > 1.0e-12 ? (normal * rsqrt(normalLengthSq)) : float3(0.0, 1.0, 0.0); + float3 offsetWorldPos = worldPos + safeNormalDir * (cascadeWorldTexelSize * kNormalOffsetTexels); + float4 shadowCoords = csm.lightSpaceMatrices[cascade] * float4(offsetWorldPos, 1.0); // Clip → NDC → [0,1] UV float3 proj = shadowCoords.xyz / shadowCoords.w; @@ -52,10 +57,44 @@ float computeCSMShadow( compare_func::less_equal ); float2 texelSize = 1.0 / float2(shadowArray.get_width(), shadowArray.get_height()); + float cascadeFilterRadius = worldFilterRadius / cascadeWorldTexelSize; + // Orthographic depth is linear. Convert the shared physical receiver bias + // into this cascade's normalized depth units so blended edges stay aligned. + float cascadeDepthSpan = max(csm.cascadeDepthSpans[cascade], 1.0e-6); + float normalizedBias = worldBias / cascadeDepthSpan; + float shadow = 0.0; + for (int i = 0; i < 16; ++i) { + float2 offset = poissonDisk[i] * texelSize * cascadeFilterRadius; + shadow += shadowArray.sample_compare( + shadowSampler, proj.xy + offset, cascade, proj.z - normalizedBias + ); + } + return shadow / 16.0; +} + +// Cascaded shadow map sampling. The farther cascade overlaps the end of the +// preceding cascade. Inside that overlap both slices are sampled and their +// visibility is cross-faded; elsewhere only one slice is sampled. +float computeCSMShadow( + depth2d_array shadowArray, + constant CSMUniforms &csm, + float3 worldPos, + float3 normal, + float3 lightDir +) { + // Pick cascade using the same right-handed camera depth space as the CPU split calculation. + float viewDepth = -(csm.cameraViewMatrix * float4(worldPos, 1.0)).z; + int cascade = csm.cascadeCount - 1; + for (int i = 0; i < csm.cascadeCount - 1; i++) { + if (viewDepth < csm.cascadeSplits[i]) { cascade = i; break; } + } float NoL = clamp(dot(normalize(normal), normalize(lightDir)), 0.0, 1.0); - float bias = max(0.0011 * (1.0 - NoL), 0.0003); - float currentDepth = proj.z; + // Preserve the established cascade-0 appearance, but express its bias as + // a physical light-space distance shared by every cascade sample. + float referenceNormalizedBias = max(0.0011 * (1.0 - NoL), 0.0003); + float referenceDepthSpan = max(csm.cascadeDepthSpans[0], 1.0e-6); + float worldBias = referenceNormalizedBias * referenceDepthSpan; float shadowDistance = max(csm.cascadeSplits[max(csm.cascadeCount - 1, 0)], 0.001); float depthFade = clamp(viewDepth / shadowDistance, 0.0, 1.0) * clamp(csm.shadowSoftnessDepthScale, 0.0, 2.0); float nearRadius = max(csm.shadowSoftnessNear, 0.25); @@ -63,13 +102,32 @@ float computeCSMShadow( float filterRadius = csm.shadowSoftnessEnabled > 0.5 ? mix(nearRadius, farRadius, clamp(depthFade, 0.0, 1.0)) : 1.0; + // Softness settings are authored in cascade-0 texels. Convert that reference + // footprint to world units once here — it does not depend on which cascade is + // sampled, so both the primary and (during a blend) the next-cascade sample + // reuse this same value instead of each re-deriving it from csm.cascadeWorldTexelSizes[0]. + float referenceWorldTexelSize = max(csm.cascadeWorldTexelSizes[0], 1.0e-6); + float worldFilterRadius = filterRadius * referenceWorldTexelSize; + + float shadow = sampleCSMCascade( + shadowArray, csm, cascade, worldPos, normal, worldBias, worldFilterRadius + ); - float shadow = 0.0; - for (int i = 0; i < 16; ++i) { - float2 offset = poissonDisk[i] * texelSize * filterRadius; - shadow += shadowArray.sample_compare(shadowSampler, proj.xy + offset, cascade, currentDepth - bias); + if (cascade < csm.cascadeCount - 1) { + // Blend start is computed once per frame on the CPU (ShadowSystem.cascadeBlendStart) + // and uploaded here rather than re-derived per-fragment, so the frustum widening + // that makes the next cascade's map cover this region and the shader's cross-fade + // agree on the same boundary by construction instead of by two hand-matched formulas. + float blendStart = csm.cascadeBlendStarts[cascade]; + if (viewDepth > blendStart) { + float nextShadow = sampleCSMCascade( + shadowArray, csm, cascade + 1, worldPos, normal, worldBias, worldFilterRadius + ); + float blend = smoothstep(blendStart, csm.cascadeSplits[cascade], viewDepth); + shadow = mix(shadow, nextShadow, blend); + } } - return shadow / 16.0; + return shadow; } float computeSpotShadow( @@ -530,12 +588,12 @@ fragment float4 fragmentLightShader(VertexCompositeOutput vertexOut [[stage_in]] color.spec = brdf.spec*lights.color*lights.intensity; // Compute shadow using cascaded shadow maps - float shadow = computeCSMShadow(csmShadowArray, csmUniforms, verticesInWorldSpace.xyz, cameraPosition, surfaceNormal, lightRayDirection); - + float shadow = computeCSMShadow(csmShadowArray, csmUniforms, verticesInWorldSpace.xyz, surfaceNormal, lightRayDirection); + // shadows affect directional light for now color.diff = color.diff*(half)shadow; color.spec = color.spec*shadow; - + // compute point light contribution LightContribution pointColor; @@ -695,7 +753,7 @@ fragment TBDRLightOutput fragmentLightShaderTBDR( color.diff = brdf.diff * (half3)lights.color * (half)lights.intensity; color.spec = brdf.spec * lights.color * lights.intensity; - float shadow = computeCSMShadow(csmShadowArray, csmUniforms, verticesInWorldSpace.xyz, cameraPosition, surfaceNormal, lightRayDirection); + float shadow = computeCSMShadow(csmShadowArray, csmUniforms, verticesInWorldSpace.xyz, surfaceNormal, lightRayDirection); color.diff *= (half)shadow; color.spec *= shadow; diff --git a/Sources/UntoldEngine/Shaders/ShaderStructs.h b/Sources/UntoldEngine/Shaders/ShaderStructs.h index 65914e082..8a5d939c1 100644 --- a/Sources/UntoldEngine/Shaders/ShaderStructs.h +++ b/Sources/UntoldEngine/Shaders/ShaderStructs.h @@ -201,9 +201,13 @@ struct CSMUniforms { float4x4 cameraViewMatrix; float cascadeSplits[CSM_CASCADE_COUNT]; // world-space camera distances (far edge of each cascade) int cascadeCount; - float _pad0; - float _pad1; - float _pad2; + float cascadeWorldTexelSizes[CSM_CASCADE_COUNT]; + float cascadeDepthSpans[CSM_CASCADE_COUNT]; + // Camera-depth distance at which each cascade begins cross-fading into the next. + // Computed once per frame on the CPU (ShadowSystem.cascadeBlendStart) — the shader + // reads this instead of re-deriving it from cascadeSplits, so both the CPU frustum + // widening and the GPU cross-fade agree on the same value by construction. + float cascadeBlendStarts[CSM_CASCADE_COUNT]; float shadowSoftnessNear; // Poisson PCF radius in texels near the camera float shadowSoftnessFar; // Poisson PCF radius in texels at the shadow distance float shadowSoftnessDepthScale; // 0 = fixed near radius, 1 = full near-to-far ramp diff --git a/Sources/UntoldEngine/Shaders/ShadersUtils.h b/Sources/UntoldEngine/Shaders/ShadersUtils.h index 09c705942..074a69163 100644 --- a/Sources/UntoldEngine/Shaders/ShadersUtils.h +++ b/Sources/UntoldEngine/Shaders/ShadersUtils.h @@ -162,7 +162,6 @@ float3 diffuseIBL(float3 normal, texture2d irradianceMap, float3 rotation float computeCSMShadow(depth2d_array shadowArray, constant CSMUniforms &csm, float3 worldPos, - float3 cameraPos, float3 normal, float3 lightDir); diff --git a/Sources/UntoldEngine/Shaders/TransparencyShader.metal b/Sources/UntoldEngine/Shaders/TransparencyShader.metal index 374825d55..36e71e6ea 100644 --- a/Sources/UntoldEngine/Shaders/TransparencyShader.metal +++ b/Sources/UntoldEngine/Shaders/TransparencyShader.metal @@ -107,7 +107,7 @@ fragment float4 fragmentTransparencyShader( totalLight.diff = brdf.diff * (half3)lights.color * (half)lights.intensity; totalLight.spec = brdf.spec * lights.color * lights.intensity; - float shadow = computeCSMShadow(csmShadowArray, csmUniforms, verticesInWorldSpace.xyz, cameraPosition, normal, lightDirection); + float shadow = computeCSMShadow(csmShadowArray, csmUniforms, verticesInWorldSpace.xyz, normal, lightDirection); totalLight.diff *= (half)shadow; totalLight.spec *= shadow; diff --git a/Sources/UntoldEngine/Systems/ShadowSystem.swift b/Sources/UntoldEngine/Systems/ShadowSystem.swift index 4db2014f2..497b41be6 100644 --- a/Sources/UntoldEngine/Systems/ShadowSystem.swift +++ b/Sources/UntoldEngine/Systems/ShadowSystem.swift @@ -65,9 +65,9 @@ struct CSMUniforms { var cameraViewMatrix: simd_float4x4 = matrix_identity_float4x4 var cascadeSplits: (Float, Float, Float) = (0, 0, 0) var cascadeCount: Int32 = .init(csmCascadeCount) - var _pad0: Float = 0 - var _pad1: Float = 0 - var _pad2: Float = 0 + var cascadeWorldTexelSizes: (Float, Float, Float) = (1, 1, 1) + var cascadeDepthSpans: (Float, Float, Float) = (1, 1, 1) + var cascadeBlendStarts: (Float, Float, Float) = (0, 0, 0) var shadowSoftnessNear: Float = 1.0 var shadowSoftnessFar: Float = 2.25 var shadowSoftnessDepthScale: Float = 1.0 @@ -257,6 +257,20 @@ struct ShadowSystem { // Per-frame cascade outputs var cascadeLightSpaceMatrices: [simd_float4x4] = Array(repeating: matrix_identity_float4x4, count: csmCascadeCount) var cascadeSplitDistances: [Float] = Array(repeating: 0, count: csmCascadeCount) + var cascadeWorldTexelSizes: [Float] = Array(repeating: 1, count: csmCascadeCount) + var cascadeDepthSpans: [Float] = Array(repeating: 1, count: csmCascadeCount) + /// World-space centroid and bounding-sphere radius of each cascade's camera-frustum + /// slice. Used by the renderer to reject shadow casters that are farther than the + /// engine's own shadow-distance horizon from anything this cascade could possibly + /// receive — a cull that stays correct for any light direction, unlike a camera-depth + /// cutoff (see RenderPasses.shadowCasterEntityIds). + var cascadeWorldCenters: [simd_float3] = Array(repeating: .zero, count: csmCascadeCount) + var cascadeWorldRadii: [Float] = Array(repeating: 0, count: csmCascadeCount) + /// Camera-depth distance at which each cascade begins cross-fading into the next. + /// Computed once per frame by `cascadeBlendStart` and uploaded to the shader — see + /// that function's doc comment for why this is the single source of truth for the + /// blend boundary shared by the CPU frustum widening and the GPU cross-fade. + var cascadeBlendStarts: [Float] = Array(repeating: 0, count: csmCascadeCount) var softnessSettings: ShadowSoftnessSettings = .init() var isActive: Bool = false @@ -265,22 +279,64 @@ struct ShadowSystem { isActive ? cascadeLightSpaceMatrices[0] : nil } + /// Camera-depth distance at which cascade `cascadeIdx` begins cross-fading its + /// visibility into the next cascade. This is the single source of truth for the + /// blend boundary: it is computed once per frame here on the CPU, used directly to + /// widen the *next* cascade's frustum-fitting near plane (`cascadeNearDistance` + /// below), and uploaded via `makeUniforms()` so the shader reads the same value + /// instead of re-deriving it from `cascadeSplits` — eliminating the CPU/GPU-duplicated + /// formula that previously had to be kept in sync by hand. + /// Internal — exposed for testing via @testable import. + static func cascadeBlendStart( + cascadeIdx: Int, + splits: [Float], + blendFraction: Float + ) -> Float { + guard cascadeIdx >= 0, cascadeIdx < splits.count else { return 0 } + // Match the shader's first interval, which begins at camera depth zero. + let intervalNear: Float = cascadeIdx > 0 ? splits[cascadeIdx - 1] : 0 + let intervalLength = max(splits[cascadeIdx] - intervalNear, 0.001) + let blendWidth = intervalLength * simd_clamp(blendFraction, 0.0, 0.5) + return splits[cascadeIdx] - blendWidth + } + + /// Near-plane distance for cascade `cascadeIdx`'s frustum-fitting sub-frustum. + /// Cascades after the first begin at the preceding cascade's blend-start distance, + /// so both cascades' fitted frustums cover the receiver positions the shader + /// cross-fades over. + /// Internal — exposed for testing via @testable import. + static func cascadeNearDistance( + cascadeIdx: Int, + splits: [Float], + cameraNear: Float, + blendFraction: Float + ) -> Float { + guard cascadeIdx > 0 else { return cameraNear } + return max(cameraNear, cascadeBlendStart(cascadeIdx: cascadeIdx - 1, splits: splits, blendFraction: blendFraction)) + } + + /// Fills the 3 fixed GPU slots from a per-cascade array, padding unused slots + /// (beyond csmCascadeCount) with `fallback` so the shader's cascadeCount field + /// controls which slots are actually read. + private static func pack3(_ values: [T], fallback: T) -> (T, T, T) { + ( + values[0], + csmCascadeCount > 1 ? values[1] : fallback, + csmCascadeCount > 2 ? values[2] : fallback + ) + } + /// Pack into the GPU-ready uniform struct. /// CSMUniforms always carries 3 slots (GPU layout is fixed); unused slots are /// left as identity/zero so the shader's cascadeCount field controls which are read. func makeUniforms() -> CSMUniforms { var u = CSMUniforms() - u.lightSpaceMatrices = ( - cascadeLightSpaceMatrices[0], - csmCascadeCount > 1 ? cascadeLightSpaceMatrices[1] : matrix_identity_float4x4, - csmCascadeCount > 2 ? cascadeLightSpaceMatrices[2] : matrix_identity_float4x4 - ) - u.cascadeSplits = ( - cascadeSplitDistances[0], - csmCascadeCount > 1 ? cascadeSplitDistances[1] : 0, - csmCascadeCount > 2 ? cascadeSplitDistances[2] : 0 - ) + u.lightSpaceMatrices = Self.pack3(cascadeLightSpaceMatrices, fallback: matrix_identity_float4x4) + u.cascadeSplits = Self.pack3(cascadeSplitDistances, fallback: 0) u.cascadeCount = Int32(csmCascadeCount) + u.cascadeWorldTexelSizes = Self.pack3(cascadeWorldTexelSizes, fallback: cascadeWorldTexelSizes[0]) + u.cascadeDepthSpans = Self.pack3(cascadeDepthSpans, fallback: cascadeDepthSpans[0]) + u.cascadeBlendStarts = Self.pack3(cascadeBlendStarts, fallback: cascadeBlendStarts[0]) let softness = Self.sanitizedSoftnessSettings(softnessSettings) let xrScale = renderInfo.isXRStereoMode ? softness.xrRadiusScale : 1.0 u.shadowSoftnessNear = softness.nearRadiusTexels * xrScale @@ -357,6 +413,11 @@ struct ShadowSystem { // Practical split scheme (blend of log and uniform, λ=0.5). let splits = computeCascadeSplits(cameraNear: near, cameraFar: cameraFar) cascadeSplitDistances = splits + for i in 0 ..< csmCascadeCount { + cascadeBlendStarts[i] = Self.cascadeBlendStart( + cascadeIdx: i, splits: splits, blendFraction: csmCascadeBlendFraction + ) + } // Light view: look along lightForward using a stable up vector. let lightUp: simd_float3 = abs(lightForward.y) < 0.99 @@ -367,16 +428,23 @@ struct ShadowSystem { lightSpaceBounds(for: $0, lightView: lightView) } - var prevNear: Float = near for i in 0 ..< csmCascadeCount { let cascadeFar = splits[i] + let cascadeNear = Self.cascadeNearDistance( + cascadeIdx: i, + splits: splits, + cameraNear: near, + blendFraction: csmCascadeBlendFraction + ) // 8 corners of the cascade sub-frustum in world space. + // Farther cascades begin inside the preceding cascade so both maps + // cover the receiver positions used by the transition blend. let corners = cascadeFrustumCornersWorldSpace( invViewMatrix: invView, tanHalfFovX: tanHalfFovX, tanHalfFovY: tanHalfFovY, - nearDist: prevNear, + nearDist: cascadeNear, farDist: cascadeFar, xrIPDExpansion: xrExpansion ) @@ -399,6 +467,9 @@ struct ShadowSystem { let diameter = max(ceil(radius * 2.0), 0.001) let texelSize = diameter / Float(shadowResolution.x) + cascadeWorldTexelSizes[i] = texelSize + cascadeWorldCenters[i] = center + cascadeWorldRadii[i] = radius var centerLS = lightView * simd_float4(center, 1.0) centerLS.x = floor(centerLS.x / texelSize) * texelSize @@ -446,11 +517,10 @@ struct ShadowSystem { // the largest (least-negative) Z, which becomes nearZ; farthest becomes farZ. let orthoNearZ = -maxZ let orthoFarZ = -minZ + cascadeDepthSpans[i] = max(orthoFarZ - orthoNearZ, 0.001) let ortho = matrix_ortho_right_hand(minX, maxX, minY, maxY, orthoNearZ, farZ: orthoFarZ) cascadeLightSpaceMatrices[i] = simd_mul(ortho, lightView) - - prevNear = cascadeFar } isActive = true diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib index ece9ba353..88675beed 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib index 8850c930b..e59f4a467 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib index 24fb1a88e..05a651916 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib index 860a7ee83..3bd3897cb 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib index 531db89ad..db99d4d95 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib index 27374b6f3..b99cebf57 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib index 2302ab013..ddc944d83 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib differ diff --git a/Sources/UntoldEngine/Utils/Globals.swift b/Sources/UntoldEngine/Utils/Globals.swift index 54f70cbe4..e793f938f 100644 --- a/Sources/UntoldEngine/Utils/Globals.swift +++ b/Sources/UntoldEngine/Utils/Globals.swift @@ -337,6 +337,8 @@ let shadowMaxHeight: Float = 300.0 // Raise to 3 for outdoor scenes that need a wide far cascade (> 40 m shadow range). let csmCascadeCount: Int = 2 let shadowResolution: simd_int2 = .init(2048, 2048) +/// Fraction of each cascade interval used to cross-fade into the next cascade. +let csmCascadeBlendFraction: Float = 0.1 var rayTracingPipeline: ComputePipeline { get { diff --git a/Tests/UntoldEngineTests/ShadowCascadeTests.swift b/Tests/UntoldEngineTests/ShadowCascadeTests.swift index 936d5cf5a..822300e4f 100644 --- a/Tests/UntoldEngineTests/ShadowCascadeTests.swift +++ b/Tests/UntoldEngineTests/ShadowCascadeTests.swift @@ -2,13 +2,14 @@ // ShadowCascadeTests.swift // UntoldEngine // -// Tests for the two shadow rendering fixes: +// Tests for the shadow cascade configuration: // 1. csmCascadeCount reduced from 3 to 2. // 2. ShadowSystem.makeUniforms() handles variable cascade counts safely — // unused GPU uniform slots are filled with identity / zero so the shader // reads only the cascades indicated by the cascadeCount field. -// 3. shadowCascadeMaxDistance() clamps each cascade to its own split distance -// so the near cascade never receives shadow casters beyond its far plane. +// 3. ShadowSystem.cascadeNearDistance() widens each non-first cascade's +// frustum-fitting near plane to overlap the tail of the preceding cascade, +// matching the shader's cross-fade blend region. // // Copyright (C) Untold Engine Studios // @@ -51,6 +52,51 @@ final class ShadowSystemMakeUniformsTests: XCTestCase { "cascadeCount in the GPU uniform must match the engine constant") } + func testCascadeBlendStartsArePackedIntoUniforms() { + var sys = ShadowSystem() + sys.cascadeBlendStarts[0] = 18.0 + if csmCascadeCount > 1 { sys.cascadeBlendStarts[1] = 56.0 } + + let starts = sys.makeUniforms().cascadeBlendStarts + XCTAssertEqual(starts.0, 18.0, accuracy: 1e-6) + if csmCascadeCount > 1 { + XCTAssertEqual(starts.1, 56.0, accuracy: 1e-6) + } + if csmCascadeCount < 3 { + XCTAssertEqual(starts.2, starts.0, accuracy: 1e-6) + } + } + + func testCascadeWorldTexelSizesArePackedIntoUniforms() { + var sys = ShadowSystem() + sys.cascadeWorldTexelSizes[0] = 0.01 + if csmCascadeCount > 1 { sys.cascadeWorldTexelSizes[1] = 0.04 } + + let sizes = sys.makeUniforms().cascadeWorldTexelSizes + XCTAssertEqual(sizes.0, 0.01, accuracy: 1e-6) + if csmCascadeCount > 1 { + XCTAssertEqual(sizes.1, 0.04, accuracy: 1e-6) + } + if csmCascadeCount < 3 { + XCTAssertEqual(sizes.2, sizes.0, accuracy: 1e-6) + } + } + + func testCascadeDepthSpansArePackedIntoUniforms() { + var sys = ShadowSystem() + sys.cascadeDepthSpans[0] = 12.0 + if csmCascadeCount > 1 { sys.cascadeDepthSpans[1] = 36.0 } + + let spans = sys.makeUniforms().cascadeDepthSpans + XCTAssertEqual(spans.0, 12.0, accuracy: 1e-6) + if csmCascadeCount > 1 { + XCTAssertEqual(spans.1, 36.0, accuracy: 1e-6) + } + if csmCascadeCount < 3 { + XCTAssertEqual(spans.2, spans.0, accuracy: 1e-6) + } + } + func testUsedSlotsCarryAssignedMatrices() { var sys = ShadowSystem() let m0 = sentinel(1.0) @@ -169,77 +215,190 @@ final class ShadowSystemMakeUniformsTests: XCTestCase { } } -// MARK: - shadowCascadeMaxDistance +// MARK: - ShadowSystem.cascadeBlendStart -/// Tests for the per-cascade shadow distance helper. The logic determines how far from -/// the camera an entity may be and still cast a shadow into a given cascade — using the -/// cascade's own split distance as the tighter cap when it is less than the global max. -final class ShadowCascadeMaxDistanceTests: XCTestCase { - private let globalMax: Float = 40.0 +/// Tests for the single source of truth behind the cascade cross-fade boundary. +/// This value is computed once per frame on the CPU and uploaded via makeUniforms() +/// so the shader reads it directly instead of re-deriving it from cascadeSplits — +/// see cascadeBlendStart's doc comment for why this replaced two independently +/// hand-matched formulas (one in Swift, one in Metal). +final class ShadowSystemCascadeBlendStartTests: XCTestCase { + private let splits: [Float] = [20.0, 60.0, 150.0] - func testReturnsSplitDistanceWhenSplitIsTighter() { - // Cascade 0 split (e.g. 25 m) is inside the global 40 m cap → use split. - let result = shadowCascadeMaxDistance( - cascadeIdx: 0, - splitDistances: [25.0, 100.0], - globalMax: globalMax + func testBlendStartMatchesSplitWhenBlendFractionIsZero() { + let result = ShadowSystem.cascadeBlendStart(cascadeIdx: 0, splits: splits, blendFraction: 0.0) + XCTAssertEqual(result, splits[0], accuracy: 1e-6, + "With no blending, cascade 0 must fade out exactly at its own far split") + } + + func testBlendStartForFirstCascadeUsesZeroAsIntervalStart() { + // Cascade 0's interval is [0, 20]; blend width = 20 * 0.1 = 2. + let result = ShadowSystem.cascadeBlendStart(cascadeIdx: 0, splits: splits, blendFraction: 0.1) + XCTAssertEqual(result, 18.0, accuracy: 1e-6, + "Cascade 0 must start fading 10% of its own interval length before its split") + } + + func testBlendStartForLaterCascadeUsesPreviousSplitAsIntervalStart() { + // Cascade 1's interval is [20, 60], length 40; blend width = 40 * 0.1 = 4. + let result = ShadowSystem.cascadeBlendStart(cascadeIdx: 1, splits: splits, blendFraction: 0.1) + XCTAssertEqual(result, 56.0, accuracy: 1e-6, + "Cascade 1 must start fading 10% of its own interval length before its split") + } + + func testBlendFractionIsClampedToOneHalf() { + // Without this clamp, a fraction above 0.5 could push blendStart before the + // interval's own start. The shader no longer computes this at all — it reads + // cascadeBlendStarts directly — so this clamp is now the only place it happens. + let unclamped = ShadowSystem.cascadeBlendStart(cascadeIdx: 0, splits: splits, blendFraction: 0.9) + let clampedAtHalf = ShadowSystem.cascadeBlendStart(cascadeIdx: 0, splits: splits, blendFraction: 0.5) + XCTAssertEqual(unclamped, clampedAtHalf, accuracy: 1e-6, + "A blend fraction above 0.5 must be clamped to keep blendStart inside the interval") + } + + func testOutOfBoundsCascadeIndexReturnsZeroInsteadOfCrashing() { + let result = ShadowSystem.cascadeBlendStart(cascadeIdx: 5, splits: splits, blendFraction: 0.1) + XCTAssertEqual(result, 0.0, accuracy: 1e-6) + } +} + +// MARK: - ShadowSystem.cascadeNearDistance + +/// Tests for the cascade cross-fade near-plane widening: cascades after the first +/// must begin their frustum-fitting sub-frustum inside the tail of the preceding +/// cascade's interval, by `blendFraction` of that interval's length, so the fitted +/// frustum covers the receiver positions the shader's cross-fade blends over. +final class ShadowSystemCascadeNearDistanceTests: XCTestCase { + private let splits: [Float] = [20.0, 60.0, 150.0] + + func testMatchesCascadeBlendStartOfPrecedingCascade() { + // Documents the unification: the near-plane widening for cascade i is exactly + // the blend-start distance of cascade i-1, clamped to the camera's near plane — + // the same value the shader reads directly from cascadeBlendStarts[i-1]. + for cascadeIdx in 1 ..< splits.count { + let near = ShadowSystem.cascadeNearDistance( + cascadeIdx: cascadeIdx, splits: splits, cameraNear: 0.1, blendFraction: 0.1 + ) + let blendStart = ShadowSystem.cascadeBlendStart( + cascadeIdx: cascadeIdx - 1, splits: splits, blendFraction: 0.1 + ) + XCTAssertEqual(near, max(0.1, blendStart), accuracy: 1e-6) + } + } + + func testCascadeZeroStartsAtCameraNear() { + let result = ShadowSystem.cascadeNearDistance( + cascadeIdx: 0, splits: splits, cameraNear: 0.1, blendFraction: 0.1 ) - XCTAssertEqual(result, 25.0, accuracy: 1e-6, - "When cascade split < globalMax the split distance must be used") + XCTAssertEqual(result, 0.1, accuracy: 1e-6, + "The first cascade must start exactly at the camera's near plane") } - func testReturnsGlobalMaxWhenSplitIsWider() { - // Cascade 1 split (e.g. 500 m with far=500) exceeds the global cap → clamp. - let result = shadowCascadeMaxDistance( - cascadeIdx: 1, - splitDistances: [25.0, 500.0], - globalMax: globalMax + func testSecondCascadeOverlapsTailOfFirstIntervalByBlendFraction() { + // Cascade 1's own interval is [0, 20]; blend width = 20 * 0.1 = 2. + let result = ShadowSystem.cascadeNearDistance( + cascadeIdx: 1, splits: splits, cameraNear: 0.1, blendFraction: 0.1 ) - XCTAssertEqual(result, globalMax, accuracy: 1e-6, - "When cascade split > globalMax the global max must be used") + XCTAssertEqual(result, 18.0, accuracy: 1e-6, + "Cascade 1 must start 10% of cascade 0's interval length before cascade 0's split") } - func testReturnsSplitWhenSplitEqualsGlobalMax() { - // Exact equality: min(40, 40) = 40. - let result = shadowCascadeMaxDistance( - cascadeIdx: 0, - splitDistances: [40.0, 200.0], - globalMax: globalMax + func testThirdCascadeOverlapsTailOfSecondIntervalByBlendFraction() { + // Cascade 1's interval is [20, 60], length 40; blend width = 40 * 0.1 = 4. + let result = ShadowSystem.cascadeNearDistance( + cascadeIdx: 2, splits: splits, cameraNear: 0.1, blendFraction: 0.1 ) - XCTAssertEqual(result, 40.0, accuracy: 1e-6, - "When split equals globalMax the result must be globalMax") + XCTAssertEqual(result, 56.0, accuracy: 1e-6, + "Cascade 2 must start 10% of cascade 1's interval length before cascade 1's split") } - func testFallsBackToGlobalMaxWhenIndexOutOfBounds() { - // Safety guard: if cascadeIdx >= splitDistances.count use global max. - let result = shadowCascadeMaxDistance( - cascadeIdx: 5, - splitDistances: [25.0, 100.0], - globalMax: globalMax + func testResultNeverGoesBelowCameraNear() { + // A large blend fraction on a tiny first interval must still clamp to cameraNear. + let result = ShadowSystem.cascadeNearDistance( + cascadeIdx: 1, splits: [0.2, 60.0], cameraNear: 0.1, blendFraction: 0.5 ) - XCTAssertEqual(result, globalMax, accuracy: 1e-6, - "Out-of-bounds cascade index must fall back to globalMax, not crash") + XCTAssertGreaterThanOrEqual(result, 0.1, + "Widened near plane must never project in front of the camera's own near plane") } - func testFallsBackToGlobalMaxForEmptySplitDistances() { - let result = shadowCascadeMaxDistance( - cascadeIdx: 0, - splitDistances: [], - globalMax: globalMax + func testZeroBlendFractionDisablesOverlap() { + let result = ShadowSystem.cascadeNearDistance( + cascadeIdx: 1, splits: splits, cameraNear: 0.1, blendFraction: 0.0 ) - XCTAssertEqual(result, globalMax, accuracy: 1e-6, - "Empty splitDistances must fall back to globalMax") - } - - func testNearCascadeIsTighterThanFarCascade() { - // Validates the intent of the fix: cascade 0 gets a smaller max distance - // than cascade 1 when its split distance is within the global cap. - let splits: [Float] = [20.0, 500.0] - let near = shadowCascadeMaxDistance(cascadeIdx: 0, splitDistances: splits, globalMax: globalMax) - let far = shadowCascadeMaxDistance(cascadeIdx: 1, splitDistances: splits, globalMax: globalMax) - XCTAssertLessThan(near, far, - "Near cascade max distance must be less than far cascade max distance") - XCTAssertEqual(near, 20.0, accuracy: 1e-6) - XCTAssertEqual(far, globalMax, accuracy: 1e-6) + XCTAssertEqual(result, splits[0], accuracy: 1e-6, + "With no blending, cascade 1 must start exactly at cascade 0's far split") + } +} + +// MARK: - Cascade world-space caster reach + +/// Tests for the direction-agnostic distance reject in RenderPasses.shadowCasterEntityIds: +/// a caster farther than `cascadeWorldRadii[i] + maxShadowCastingDistance` from +/// `cascadeWorldCenters[i]` cannot matter to that cascade regardless of light direction, +/// so it is safe to drop before the more expensive light-space frustum test. +final class ShadowSystemCascadeCasterReachTests: XCTestCase { + private let originalMaxShadowCastingDistance = RenderPasses.maxShadowCastingDistance + + override func tearDown() { + RenderPasses.maxShadowCastingDistance = originalMaxShadowCastingDistance + } + + func testCasterWellWithinReachIsNotExcluded() { + RenderPasses.maxShadowCastingDistance = 40.0 + let center = simd_float3(100, 0, 0) + let radius: Float = 20.0 + let cascadeReach = radius + RenderPasses.maxShadowCastingDistance + + // A caster sitting right at the edge of the cascade's own bounding sphere. + let casterMin = simd_float3(100 + radius - 1, -1, -1) + let casterMax = simd_float3(100 + radius + 1, 1, 1) + + XCTAssertFalse(shadowEntityBeyondMaxDistance( + worldMin: casterMin, worldMax: casterMax, + cameraPosition: center, maxDistance: cascadeReach + ), "A caster at the edge of the cascade's own bounding sphere must never be excluded") + } + + func testCasterAtExactHorizonOfALowAngleShadowIsNotExcluded() { + // The whole point of this reject: a caster far from the cascade along the + // light's raking direction, but still within the engine's own shadow-distance + // horizon of the cascade's bounding sphere, must still be included — this is + // exactly the class of caster the old camera-depth cull incorrectly dropped. + RenderPasses.maxShadowCastingDistance = 100.0 + let center = simd_float3.zero + let radius: Float = 10.0 + let cascadeReach = radius + RenderPasses.maxShadowCastingDistance // 110 + + let farCaster = simd_float3(cascadeReach - 1, 0, 0) + XCTAssertFalse(shadowEntityBeyondMaxDistance( + worldMin: farCaster, worldMax: farCaster, + cameraPosition: center, maxDistance: cascadeReach + ), "A caster just inside the combined radius+horizon distance must be included") + } + + func testCasterBeyondTheEnginesShadowHorizonIsExcluded() { + RenderPasses.maxShadowCastingDistance = 40.0 + let center = simd_float3.zero + let radius: Float = 5.0 + let cascadeReach = radius + RenderPasses.maxShadowCastingDistance // 45 + + let veryFarCaster = simd_float3(cascadeReach + 1000, 0, 0) + XCTAssertTrue(shadowEntityBeyondMaxDistance( + worldMin: veryFarCaster, worldMax: veryFarCaster, + cameraPosition: center, maxDistance: cascadeReach + ), "A caster far beyond the engine's own shadow-distance horizon must be excluded") + } + + func testZeroGlobalShadowDistanceDisablesTheReject() { + // maxDistance == 0 means "no cap" per shadowEntityBeyondMaxDistance's contract. + RenderPasses.maxShadowCastingDistance = 0.0 + let center = simd_float3.zero + let radius: Float = 0.0 + let cascadeReach = radius + RenderPasses.maxShadowCastingDistance + + let veryFarCaster = simd_float3(1.0e6, 0, 0) + XCTAssertFalse(shadowEntityBeyondMaxDistance( + worldMin: veryFarCaster, worldMax: veryFarCaster, + cameraPosition: center, maxDistance: cascadeReach + ), "A zero combined reach must disable the reject entirely, not exclude everything") } } diff --git a/docs/Architecture/renderingSystem.md b/docs/Architecture/renderingSystem.md index de7ed28be..2ce9206bf 100644 --- a/docs/Architecture/renderingSystem.md +++ b/docs/Architecture/renderingSystem.md @@ -171,9 +171,13 @@ Both passes render scene geometry from the **directional light's point of view** The cascade count is 2 by default. Raise to 3 in `Globals.swift` for outdoor scenes that need a third far cascade beyond 40 m. -**Per-cascade shadow distance:** Each cascade only receives shadow casters within its own split distance (`shadowCascadeMaxDistance`). The effective limit is `min(maxShadowCastingDistance, cascadeSplitDistances[cascadeIdx])`. This prevents the near cascade from rendering distant objects that are only relevant to the far cascade, significantly reducing shadow draw calls in dense scenes. +**Cascade transitions:** The final 10% of each non-final cascade overlaps the next cascade. `ShadowSystem.cascadeBlendStart` computes this boundary once per frame on the CPU and is the single source of truth for it: the same value both widens the next cascade's frustum-fitting near plane and is uploaded to the shader (`cascadeBlendStarts`) for the cross-fade, so the two are never independently re-derived and cannot drift out of sync. The lighting shader samples both slices in the overlap and blends smoothly between them, avoiding a hard visibility change when camera motion moves stationary geometry across a split. -**Shadow softness:** CSM sampling uses a centered 16-tap Poisson PCF kernel. The default runtime softness is `nearRadiusTexels = 2.0`, `farRadiusTexels = 5.0`, with an `xrRadiusScale = 1.35` applied only in stereo XR. Use `setShadowSoftness(_:)` to tune this globally per scene. +**Directional caster culling:** Each cascade culls shadow casters in two stages. First, a direction-agnostic world-space distance reject: a caster farther than `maxShadowCastingDistance` from the cascade's own camera-frustum-slice bounding sphere (`ShadowSystem.cascadeWorldCenters`/`cascadeWorldRadii`) cannot matter to that cascade regardless of light direction, so it is dropped before the more expensive test. Second, the fitted light-space frustum is the correctness-preserving cull for what actually lands in the shadow map. Camera distance and cascade receiver splits alone are not valid caster-relevance tests for a directional light — an object outside the receiver's camera-depth interval can still project a shadow into it — which is why the distance reject is measured from the cascade's world-space bounds, not the camera. + +**Shadow softness:** CSM sampling uses a centered 16-tap Poisson PCF kernel. Softness is authored in cascade-0 texels, converted to a world-space radius, then converted into each sampled cascade's texels. This keeps the physical filter footprint consistent across cascade transitions. The default runtime softness is `nearRadiusTexels = 2.0`, `farRadiusTexels = 5.0`, with an `xrRadiusScale = 1.35` applied only in stereo XR. Use `setShadowSoftness(_:)` to tune this globally per scene. + +**Receiver bias:** The established cascade-0 bias is converted from normalized depth into a light-space world distance. Each cascade then converts that shared distance through its own fitted depth span before comparison. This prevents blended cascades from producing spatially offset shadow edges solely because their orthographic depth ranges differ. The shadow map produced here is consumed by the TBDR light sub-pass inside `model`.