Fix ambient flyer cadence and rotating shadows
This commit is contained in:
parent
9cd429dd73
commit
8b7b601b32
5 changed files with 239 additions and 3 deletions
|
|
@ -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(
|
internal DirectionalSunShadowDiagnostics RenderPrepared(
|
||||||
IGpuFrame frame,
|
IGpuFrame frame,
|
||||||
in DirectionalShadowEnvironmentState environment,
|
in DirectionalShadowEnvironmentState environment,
|
||||||
|
|
@ -473,6 +508,11 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
|
||||||
nameof(worldTransforms));
|
nameof(worldTransforms));
|
||||||
|
|
||||||
long started = Stopwatch.GetTimestamp();
|
long started = Stopwatch.GetTimestamp();
|
||||||
|
float effectiveCasterDepthPaddingMeters =
|
||||||
|
ResolveCasterDepthPaddingMeters(
|
||||||
|
casterDepthPaddingMeters,
|
||||||
|
_quality.MaximumReachMeters,
|
||||||
|
residentMaximumReachMeters);
|
||||||
var fit = new DirectionalShadowCascadeFitInput(
|
var fit = new DirectionalShadowCascadeFitInput(
|
||||||
cameraView,
|
cameraView,
|
||||||
cameraProjection,
|
cameraProjection,
|
||||||
|
|
@ -480,7 +520,7 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
|
||||||
_quality,
|
_quality,
|
||||||
cameraNearMeters,
|
cameraNearMeters,
|
||||||
PracticalSplitLambda: 0.65f,
|
PracticalSplitLambda: 0.65f,
|
||||||
casterDepthPaddingMeters,
|
effectiveCasterDepthPaddingMeters,
|
||||||
residentMaximumReachMeters);
|
residentMaximumReachMeters);
|
||||||
int cascadeCount = DirectionalShadowCascadeFitter.Fit(
|
int cascadeCount = DirectionalShadowCascadeFitter.Fit(
|
||||||
fit,
|
fit,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,15 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
private const double FrameEpsilon = 0.000199999995;
|
private const double FrameEpsilon = 0.000199999995;
|
||||||
private const float MaximumElapsed = 2f;
|
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
|
private sealed class Owner
|
||||||
{
|
{
|
||||||
public required WorldEntity Entity;
|
public required WorldEntity Entity;
|
||||||
|
|
@ -468,10 +477,17 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
// static has no CPhysicsObj of its own, so the WorldEntity IS
|
// static has no CPhysicsObj of its own, so the WorldEntity IS
|
||||||
// the frame retail would be rotating — and the render
|
// the frame retail would be rotating — and the render
|
||||||
// projection re-reads entity.Rotation every frame, so the
|
// 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.Origin = owner.Entity.Position;
|
||||||
owner.RootFrameScratch.Orientation = owner.Entity.Rotation;
|
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;
|
owner.Entity.Rotation = owner.RootFrameScratch.Orientation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,117 @@ public sealed class DirectionalShadowCascadeFitterTests
|
||||||
Assert.Equal(0, DirectionalShadowCascadeFitter.Fit(in input, cascades));
|
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(
|
private static DirectionalShadowCascadeFitInput CameraInput(
|
||||||
Vector3 position,
|
Vector3 position,
|
||||||
DirectionalShadowQuality quality)
|
DirectionalShadowQuality quality)
|
||||||
|
|
@ -219,4 +330,15 @@ public sealed class DirectionalShadowCascadeFitterTests
|
||||||
float y = new Vector3(matrix.M12, matrix.M22, matrix.M32).Length();
|
float y = new Vector3(matrix.M12, matrix.M22, matrix.M32).Length();
|
||||||
return 0.5f * (x + y);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,17 @@ public sealed class DirectionalShadowGpuTests
|
||||||
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedTimerScope>().Count());
|
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedTimerScope>().Count());
|
||||||
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedUniformBind>()
|
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedUniformBind>()
|
||||||
.Count(call => call.Binding == GpuBindingModel.UniformDirectionalShadow));
|
.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(expectedDraws, device.OfKind<GpuRecordedMultiDrawIndirect>().Count());
|
||||||
Assert.Equal(2, device.OfKind<GpuRecordedRingAllocation>().Count());
|
Assert.Equal(2, device.OfKind<GpuRecordedRingAllocation>().Count());
|
||||||
GpuRecordedRingAllocation transformAllocation = Assert.Single(
|
GpuRecordedRingAllocation transformAllocation = Assert.Single(
|
||||||
|
|
|
||||||
|
|
@ -842,6 +842,53 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
|
||||||
Assert.NotEqual(afterFirst, entity.Rotation);
|
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]
|
[Fact]
|
||||||
public void WithoutASetOmegaHookTheRootNeverTurns()
|
public void WithoutASetOmegaHookTheRootNeverTurns()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue