diff --git a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
index 56eaccef..2d6966d7 100644
--- a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
+++ b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
@@ -438,6 +438,41 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
};
}
+ ///
+ /// The fitted frustum slice bounds receivers, not the casters that project
+ /// onto them. At low celestial elevations a modest tree can be many metres
+ /// farther along the light ray than its visible ground shadow. Padding by
+ /// less than the effective receiver reach therefore clips a caster as the
+ /// camera-relative slice turns, producing partial or disappearing shadows.
+ /// This expands depth only; cascade XY density, draw count, and caster
+ /// membership are unchanged.
+ ///
+ internal static float ResolveCasterDepthPaddingMeters(
+ float configuredPaddingMeters,
+ float qualityReachMeters,
+ float residentMaximumReachMeters)
+ {
+ if (!float.IsFinite(configuredPaddingMeters)
+ || configuredPaddingMeters <= 0f)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(configuredPaddingMeters));
+ }
+ if (!float.IsFinite(qualityReachMeters) || qualityReachMeters <= 0f)
+ throw new ArgumentOutOfRangeException(nameof(qualityReachMeters));
+ if (float.IsNaN(residentMaximumReachMeters)
+ || residentMaximumReachMeters <= 0f)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(residentMaximumReachMeters));
+ }
+
+ float effectiveReceiverReach = MathF.Min(
+ qualityReachMeters,
+ residentMaximumReachMeters);
+ return MathF.Max(configuredPaddingMeters, effectiveReceiverReach);
+ }
+
internal DirectionalSunShadowDiagnostics RenderPrepared(
IGpuFrame frame,
in DirectionalShadowEnvironmentState environment,
@@ -473,6 +508,11 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
nameof(worldTransforms));
long started = Stopwatch.GetTimestamp();
+ float effectiveCasterDepthPaddingMeters =
+ ResolveCasterDepthPaddingMeters(
+ casterDepthPaddingMeters,
+ _quality.MaximumReachMeters,
+ residentMaximumReachMeters);
var fit = new DirectionalShadowCascadeFitInput(
cameraView,
cameraProjection,
@@ -480,7 +520,7 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
_quality,
cameraNearMeters,
PracticalSplitLambda: 0.65f,
- casterDepthPaddingMeters,
+ effectiveCasterDepthPaddingMeters,
residentMaximumReachMeters);
int cascadeCount = DirectionalShadowCascadeFitter.Fit(
fit,
diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs
index b8205292..1c4166d8 100644
--- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs
+++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs
@@ -23,6 +23,15 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
private const double FrameEpsilon = 0.000199999995;
private const float MaximumElapsed = 2f;
+ // The eight retail SetOmega-authored ambient-flyer animations use their
+ // vector as a per-animation-quantum turn. Their animation data is authored
+ // at 30 fps; replaying that raw turn once per modern host/render frame
+ // makes orbit speed scale with monitor refresh (6x at 180 Hz). Keep the
+ // exact authored result at 30 Hz while making the DAT-scenery projection
+ // independent of host cadence. Live PhysicsBody owners retain the literal
+ // CPhysicsObj branch below and are deliberately not changed here.
+ private const double DatStaticOmegaReferenceHz = 30d;
+
private sealed class Owner
{
public required WorldEntity Entity;
@@ -468,10 +477,17 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
// static has no CPhysicsObj of its own, so the WorldEntity IS
// the frame retail would be rotating — and the render
// projection re-reads entity.Rotation every frame, so the
- // parts composed below orbit it.
+ // parts composed below orbit it. SetOmega's authored vector is
+ // a 30 Hz per-animation-quantum turn; normalize it at this
+ // modern host boundary so monitor refresh cannot change the
+ // flight period.
owner.RootFrameScratch.Origin = owner.Entity.Position;
owner.RootFrameScratch.Orientation = owner.Entity.Rotation;
- FrameOps.GRotate(owner.RootFrameScratch, owner.Omega);
+ float omegaScale = (float)(
+ ownerElapsed * DatStaticOmegaReferenceHz);
+ FrameOps.GRotate(
+ owner.RootFrameScratch,
+ owner.Omega * omegaScale);
owner.Entity.Rotation = owner.RootFrameScratch.Orientation;
}
diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowCascadeFitterTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowCascadeFitterTests.cs
index 481909c9..61a76fef 100644
--- a/tests/AcDream.App.Tests/Rendering/DirectionalShadowCascadeFitterTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowCascadeFitterTests.cs
@@ -192,6 +192,117 @@ public sealed class DirectionalShadowCascadeFitterTests
Assert.Equal(0, DirectionalShadowCascadeFitter.Fit(in input, cascades));
}
+ [Theory]
+ [InlineData(48f, 144f, float.PositiveInfinity, 144f)]
+ [InlineData(48f, 144f, 96f, 96f)]
+ [InlineData(160f, 144f, 96f, 160f)]
+ public void CasterDepthPadding_CoversTheEffectiveResidentReceiverReach(
+ float configuredPadding,
+ float qualityReach,
+ float residentReach,
+ float expectedPadding)
+ {
+ Assert.Equal(
+ expectedPadding,
+ DirectionalSunShadowRenderer.ResolveCasterDepthPaddingMeters(
+ configuredPadding,
+ qualityReach,
+ residentReach));
+ }
+
+ [Fact]
+ public void ReachSizedCasterDepth_KeepsLowSunTreeShadowInsideDuringCameraRotation()
+ {
+ DirectionalShadowQuality quality = DirectionalShadowQuality.For(
+ DirectionalShadowPreset.Medium);
+ Vector3 light = Vector3.Normalize(new Vector3(0.8f, 0.4f, 0.15f));
+ Vector3 receiver = new(30f, 0f, 0f);
+ Vector3 caster = receiver + light * 80f;
+ int visibleSamples = 0;
+ bool legacyPaddingClippedCaster = false;
+ float casterDepthPadding =
+ DirectionalSunShadowRenderer.ResolveCasterDepthPaddingMeters(
+ configuredPaddingMeters: 48f,
+ quality.MaximumReachMeters,
+ residentMaximumReachMeters: float.PositiveInfinity);
+ Assert.Equal(quality.MaximumReachMeters, casterDepthPadding);
+ Span cascades =
+ stackalloc DirectionalShadowCascade[4];
+ Span legacyCascades =
+ stackalloc DirectionalShadowCascade[4];
+
+ for (int yawDegrees = -50; yawDegrees <= 50; yawDegrees += 5)
+ {
+ float yaw = yawDegrees * MathF.PI / 180f;
+ Vector3 forward = new(MathF.Cos(yaw), MathF.Sin(yaw), 0f);
+ Matrix4x4 view = Matrix4x4.CreateLookAt(
+ Vector3.Zero,
+ forward,
+ Vector3.UnitZ);
+ Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
+ 70f * MathF.PI / 180f,
+ 16f / 9f,
+ 0.1f,
+ 5000f);
+ Matrix4x4 viewProjection = view * projection;
+ if (!InsideClip(receiver, viewProjection))
+ continue;
+ visibleSamples++;
+
+ var input = new DirectionalShadowCascadeFitInput(
+ view,
+ projection,
+ light,
+ quality,
+ CasterDepthPaddingMeters: casterDepthPadding);
+ int count = DirectionalShadowCascadeFitter.Fit(in input, cascades);
+ DirectionalShadowCascadeFitInput legacyInput = input with
+ {
+ CasterDepthPaddingMeters = 48f,
+ };
+ int legacyCount = DirectionalShadowCascadeFitter.Fit(
+ in legacyInput,
+ legacyCascades);
+ Assert.Equal(count, legacyCount);
+ for (int cascadeIndex = 0; cascadeIndex < count; cascadeIndex++)
+ {
+ Assert.Equal(
+ legacyCascades[cascadeIndex].HalfExtentMeters,
+ cascades[cascadeIndex].HalfExtentMeters);
+ Assert.Equal(
+ legacyCascades[cascadeIndex].TexelWorldSize,
+ cascades[cascadeIndex].TexelWorldSize);
+ }
+ DirectionalShadowCascadeBlend selected =
+ DirectionalShadowReceiverPolicy.SelectCascade(
+ receiver.Length(),
+ new Vector4(
+ cascades[0].SplitFarMeters,
+ cascades[1].SplitFarMeters,
+ cascades[2].SplitFarMeters,
+ 0f),
+ count,
+ blendWidthMeters: 2f);
+ legacyPaddingClippedCaster |= !InsideClip(
+ caster,
+ legacyCascades[selected.PrimaryCascade].WorldToShadowClip);
+
+ Assert.True(
+ InsideClip(
+ receiver,
+ cascades[selected.PrimaryCascade].WorldToShadowClip),
+ $"receiver left cascade {selected.PrimaryCascade} at yaw {yawDegrees}");
+ Assert.True(
+ InsideClip(
+ caster,
+ cascades[selected.PrimaryCascade].WorldToShadowClip),
+ $"caster left cascade {selected.PrimaryCascade} at yaw {yawDegrees}");
+ }
+
+ Assert.True(visibleSamples > 1);
+ Assert.True(legacyPaddingClippedCaster);
+ }
+
private static DirectionalShadowCascadeFitInput CameraInput(
Vector3 position,
DirectionalShadowQuality quality)
@@ -219,4 +330,15 @@ public sealed class DirectionalShadowCascadeFitterTests
float y = new Vector3(matrix.M12, matrix.M22, matrix.M32).Length();
return 0.5f * (x + y);
}
+
+ private static bool InsideClip(Vector3 point, Matrix4x4 transform)
+ {
+ Vector4 clip = Vector4.Transform(new Vector4(point, 1f), transform);
+ if (!float.IsFinite(clip.W) || MathF.Abs(clip.W) <= 1e-6f)
+ return false;
+ Vector3 ndc = new(clip.X / clip.W, clip.Y / clip.W, clip.Z / clip.W);
+ return MathF.Abs(ndc.X) <= 1f
+ && MathF.Abs(ndc.Y) <= 1f
+ && ndc.Z is >= 0f and <= 1f;
+ }
}
diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs
index 2dec7c24..253f4b32 100644
--- a/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs
@@ -316,6 +316,17 @@ public sealed class DirectionalShadowGpuTests
Assert.Equal(expectedPasses, device.OfKind().Count());
Assert.Equal(expectedPasses, device.OfKind()
.Count(call => call.Binding == GpuBindingModel.UniformDirectionalShadow));
+ GpuRecordedUniformBind shadowUniformBind = Assert.Single(
+ device.OfKind()
+ .DistinctBy(call => (call.BufferName, call.OffsetBytes, call.SizeBytes)),
+ call => call.Binding == GpuBindingModel.UniformDirectionalShadow);
+ DirectionalShadowUniforms shadowUniforms = MemoryMarshal.Read(
+ device.RingBytes.Slice(
+ checked((int)shadowUniformBind.OffsetBytes),
+ checked((int)shadowUniformBind.SizeBytes)));
+ Assert.Equal(
+ DirectionalShadowQuality.For(preset).MaximumReachMeters,
+ shadowUniforms.BiasMeters.W);
Assert.Equal(expectedDraws, device.OfKind().Count());
Assert.Equal(2, device.OfKind().Count());
GpuRecordedRingAllocation transformAllocation = Assert.Single(
diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs
index 1a7378eb..d18f4e2b 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs
@@ -842,6 +842,53 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
Assert.NotEqual(afterFirst, entity.Rotation);
}
+ [Theory]
+ [InlineData(30)]
+ [InlineData(60)]
+ [InlineData(180)]
+ public void SetOmegaHook_DatSceneryOrbitIsIndependentOfHostFrameRate(
+ int hostFramesPerSecond)
+ {
+ const float authoredYawPerQuantum = -0.027f;
+ var loader = new Loader();
+ loader.Add(
+ AnimationId,
+ OmegaAnimation(new Vector3(0f, 0f, authoredYawPerQuantum)));
+ var scheduler = new RetailStaticAnimatingObjectScheduler(
+ loader,
+ (_, sequencer) => sequencer.ConsumePendingHooks(),
+ (_, _, _) => { });
+ WorldEntity entity = MakeEntity();
+ scheduler.Register(entity, new ScriptActivationInfo(
+ ScriptId: 0,
+ PartTransforms: entity.IndexedPartTransforms,
+ PartAvailability: entity.IndexedPartAvailable,
+ Setup: MakeSetup(),
+ DefaultAnimationId: AnimationId,
+ UsesStaticAnimationWorkset: true));
+
+ // Bank SetOmega at process_hooks; retail first applies it on the next
+ // static-animation pass. Do not include this installation frame in the
+ // measured one-second orbit interval.
+ scheduler.Tick(1f / 30f);
+ scheduler.ProcessHooks();
+
+ float hostDelta = 1f / hostFramesPerSecond;
+ for (int frame = 0; frame < hostFramesPerSecond; frame++)
+ {
+ scheduler.Tick(hostDelta);
+ scheduler.ProcessHooks();
+ }
+
+ Vector3 actualForward = Vector3.Transform(Vector3.UnitX, entity.Rotation);
+ float expectedYaw = authoredYawPerQuantum * 30f;
+ var expectedForward = new Vector3(
+ MathF.Cos(expectedYaw),
+ MathF.Sin(expectedYaw),
+ 0f);
+ Assert.InRange(Vector3.Distance(actualForward, expectedForward), 0f, 0.0001f);
+ }
+
[Fact]
public void WithoutASetOmegaHookTheRootNeverTurns()
{