Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 16 additions & 42 deletions Sources/UntoldEngine/Renderer/RenderPasses.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -4972,28 +4968,6 @@ private func uploadAndBindLights<T>(
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).
Expand Down
106 changes: 82 additions & 24 deletions Sources/UntoldEngine/Shaders/LightShader.metal
Original file line number Diff line number Diff line change
Expand Up @@ -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<float> 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;
Expand All @@ -52,24 +57,77 @@ 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<float> 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);
float farRadius = max(csm.shadowSoftnessFar, nearRadius);
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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
10 changes: 7 additions & 3 deletions Sources/UntoldEngine/Shaders/ShaderStructs.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion Sources/UntoldEngine/Shaders/ShadersUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,6 @@ float3 diffuseIBL(float3 normal, texture2d<float> irradianceMap, float3 rotation
float computeCSMShadow(depth2d_array<float> shadowArray,
constant CSMUniforms &csm,
float3 worldPos,
float3 cameraPos,
float3 normal,
float3 lightDir);

Expand Down
2 changes: 1 addition & 1 deletion Sources/UntoldEngine/Shaders/TransparencyShader.metal
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading