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() {