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

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