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
19 changes: 15 additions & 4 deletions Sources/UntoldEngine/Shaders/LightShader.metal
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,29 @@ float computeCSMShadow(
return 1.0;
}

constexpr sampler shadowSampler(coord::normalized, filter::linear, address::clamp_to_edge);
constexpr sampler shadowSampler(
coord::normalized,
filter::linear,
address::clamp_to_edge,
compare_func::less_equal
);
float2 texelSize = 1.0 / float2(shadowArray.get_width(), shadowArray.get_height());

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;
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;

float shadow = 0.0;
for (int i = 0; i < 16; ++i) {
float2 offset = poissonDisk[i] * texelSize;
float sampledDepth = shadowArray.sample(shadowSampler, proj.xy + offset, cascade);
shadow += (currentDepth - bias) > sampledDepth ? 0.0 : 1.0;
float2 offset = poissonDisk[i] * texelSize * filterRadius;
shadow += shadowArray.sample_compare(shadowSampler, proj.xy + offset, cascade, currentDepth - bias);
}
return shadow / 16.0;
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/UntoldEngine/Shaders/ShaderStructs.h
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ struct CSMUniforms {
float _pad0;
float _pad1;
float _pad2;
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
float shadowSoftnessEnabled;
};

#endif /* ShaderStructs_h */
56 changes: 55 additions & 1 deletion Sources/UntoldEngine/Systems/ShadowSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,28 @@
import Foundation
import simd

/// Runtime tuning for CSM shadow filtering.
public struct ShadowSoftnessSettings: Equatable, Sendable {
public var enabled: Bool
public var nearRadiusTexels: Float
public var farRadiusTexels: Float
public var depthScale: Float

public init(
enabled: Bool = true,
nearRadiusTexels: Float = 1.0,
farRadiusTexels: Float = 2.25,
depthScale: Float = 1.0
) {
self.enabled = enabled
self.nearRadiusTexels = nearRadiusTexels
self.farRadiusTexels = farRadiusTexels
self.depthScale = depthScale
}
}

/// Swift mirror of the Metal CSMUniforms struct in ShaderStructs.h.
/// Layout must stay in sync: 3xfloat4x4, camera view matrix, 3xfloat, then int + 3xfloat padding.
/// Layout must stay in sync with the Metal struct.
struct CSMUniforms {
var lightSpaceMatrices: (simd_float4x4, simd_float4x4, simd_float4x4) = (
matrix_identity_float4x4, matrix_identity_float4x4, matrix_identity_float4x4
Expand All @@ -23,12 +43,17 @@ struct CSMUniforms {
var _pad0: Float = 0
var _pad1: Float = 0
var _pad2: Float = 0
var shadowSoftnessNear: Float = 1.0
var shadowSoftnessFar: Float = 2.25
var shadowSoftnessDepthScale: Float = 1.0
var shadowSoftnessEnabled: Float = 1.0
}

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 softnessSettings: ShadowSoftnessSettings = .init()
var isActive: Bool = false

/// Legacy accessor used by callers that only need to know "is there a shadow?"
Expand All @@ -52,9 +77,30 @@ struct ShadowSystem {
csmCascadeCount > 2 ? cascadeSplitDistances[2] : 0
)
u.cascadeCount = Int32(csmCascadeCount)
let softness = Self.sanitizedSoftnessSettings(softnessSettings)
u.shadowSoftnessNear = softness.nearRadiusTexels
u.shadowSoftnessFar = softness.farRadiusTexels
u.shadowSoftnessDepthScale = softness.depthScale
u.shadowSoftnessEnabled = softness.enabled ? 1.0 : 0.0
return u
}

mutating func setSoftness(_ settings: ShadowSoftnessSettings) {
softnessSettings = Self.sanitizedSoftnessSettings(settings)
}

private static func sanitizedSoftnessSettings(_ settings: ShadowSoftnessSettings) -> ShadowSoftnessSettings {
let nearRadius = simd_clamp(settings.nearRadiusTexels, 0.25, 8.0)
let farRadius = simd_clamp(settings.farRadiusTexels, nearRadius, 12.0)
let depthScale = simd_clamp(settings.depthScale, 0.0, 2.0)
return ShadowSoftnessSettings(
enabled: settings.enabled,
nearRadiusTexels: nearRadius,
farRadiusTexels: farRadius,
depthScale: depthScale
)
}

/// Main per-frame update — computes one tight ortho matrix per cascade.
mutating func updateCascades() {
// In XR stereo mode the shadow map is shared between both eyes.
Expand Down Expand Up @@ -225,3 +271,11 @@ struct ShadowSystem {
return corners
}
}

public func setShadowSoftness(_ settings: ShadowSoftnessSettings) {
shadowSystem.setSoftness(settings)
}

public func getShadowSoftness() -> ShadowSoftnessSettings {
shadowSystem.softnessSettings
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
42 changes: 42 additions & 0 deletions Tests/UntoldEngineTests/ShadowCascadeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,48 @@ final class ShadowSystemMakeUniformsTests: XCTestCase {
"Cascade 1 split must match the value set on the system")
}
}

func testDefaultSoftnessValuesArePackedIntoUniforms() {
let sys = ShadowSystem()
let u = sys.makeUniforms()

XCTAssertEqual(u.shadowSoftnessNear, 1.0, accuracy: 1e-6)
XCTAssertEqual(u.shadowSoftnessFar, 2.25, accuracy: 1e-6)
XCTAssertEqual(u.shadowSoftnessDepthScale, 1.0, accuracy: 1e-6)
XCTAssertEqual(u.shadowSoftnessEnabled, 1.0, accuracy: 1e-6)
}

func testCustomSoftnessValuesArePackedIntoUniforms() {
var sys = ShadowSystem()
sys.setSoftness(ShadowSoftnessSettings(
enabled: false,
nearRadiusTexels: 2.0,
farRadiusTexels: 5.0,
depthScale: 0.5
))

let u = sys.makeUniforms()
XCTAssertEqual(u.shadowSoftnessNear, 2.0, accuracy: 1e-6)
XCTAssertEqual(u.shadowSoftnessFar, 5.0, accuracy: 1e-6)
XCTAssertEqual(u.shadowSoftnessDepthScale, 0.5, accuracy: 1e-6)
XCTAssertEqual(u.shadowSoftnessEnabled, 0.0, accuracy: 1e-6)
}

func testSoftnessSettingsAreClampedBeforePacking() {
var sys = ShadowSystem()
sys.setSoftness(ShadowSoftnessSettings(
enabled: true,
nearRadiusTexels: -5.0,
farRadiusTexels: -1.0,
depthScale: 10.0
))

let u = sys.makeUniforms()
XCTAssertEqual(u.shadowSoftnessNear, 0.25, accuracy: 1e-6)
XCTAssertEqual(u.shadowSoftnessFar, 0.25, accuracy: 1e-6)
XCTAssertEqual(u.shadowSoftnessDepthScale, 2.0, accuracy: 1e-6)
XCTAssertEqual(u.shadowSoftnessEnabled, 1.0, accuracy: 1e-6)
}
}

// MARK: - shadowCascadeMaxDistance
Expand Down
Loading