Fix ambient flyer cadence and rotating shadows

This commit is contained in:
Erik 2026-08-22 17:39:38 +02:00
parent 9cd429dd73
commit 8b7b601b32
5 changed files with 239 additions and 3 deletions

View file

@ -438,6 +438,41 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
};
}
/// <summary>
/// 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.
/// </summary>
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,

View file

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

View file

@ -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<DirectionalShadowCascade> cascades =
stackalloc DirectionalShadowCascade[4];
Span<DirectionalShadowCascade> 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;
}
}

View file

@ -316,6 +316,17 @@ public sealed class DirectionalShadowGpuTests
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedTimerScope>().Count());
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedUniformBind>()
.Count(call => call.Binding == GpuBindingModel.UniformDirectionalShadow));
GpuRecordedUniformBind shadowUniformBind = Assert.Single(
device.OfKind<GpuRecordedUniformBind>()
.DistinctBy(call => (call.BufferName, call.OffsetBytes, call.SizeBytes)),
call => call.Binding == GpuBindingModel.UniformDirectionalShadow);
DirectionalShadowUniforms shadowUniforms = MemoryMarshal.Read<DirectionalShadowUniforms>(
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<GpuRecordedMultiDrawIndirect>().Count());
Assert.Equal(2, device.OfKind<GpuRecordedRingAllocation>().Count());
GpuRecordedRingAllocation transformAllocation = Assert.Single(

View file

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