fix(render): harden portal exit handoff
This commit is contained in:
parent
ddbd7e4096
commit
82e4b4cb6d
17 changed files with 896 additions and 119 deletions
|
|
@ -1,4 +1,7 @@
|
|||
using System.Reflection;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.Tests.Architecture;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Content;
|
||||
using AcDream.Content.Vfx;
|
||||
|
|
@ -12,6 +15,30 @@ namespace AcDream.App.Tests.Rendering;
|
|||
|
||||
public sealed class PortalTunnelAssetTests
|
||||
{
|
||||
[Fact]
|
||||
public void PortalWorldHandoff_HidesTunnelBeforePublishingWorldFadeInProjection()
|
||||
{
|
||||
MethodInfo tick = typeof(LocalPlayerTeleportPresentation).GetMethod(
|
||||
nameof(LocalPlayerTeleportPresentation.Tick),
|
||||
BindingFlags.Instance | BindingFlags.Public)
|
||||
?? throw new InvalidOperationException("Presentation Tick method missing.");
|
||||
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(tick);
|
||||
int hide = CompiledCallGraph.IndexOf(
|
||||
calls,
|
||||
typeof(PortalTunnelPresentation),
|
||||
nameof(PortalTunnelPresentation.Exit));
|
||||
int publish = CompiledCallGraph.IndexOf(
|
||||
calls,
|
||||
typeof(TeleportViewPlaneController),
|
||||
nameof(TeleportViewPlaneController.Update));
|
||||
|
||||
Assert.True(hide >= 0, "Presentation Tick no longer retires portal space.");
|
||||
Assert.True(publish >= 0, "Presentation Tick no longer publishes its view plane.");
|
||||
Assert.True(
|
||||
hide < publish,
|
||||
"Portal space must retire before the WorldFadeIn projection is visible to rendering.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6m. The RHI arm cannot inherit a bound framebuffer, so
|
||||
/// its pass re-establishes the colour the frame already cleared. That is
|
||||
|
|
@ -189,7 +216,7 @@ public sealed class PortalTunnelAssetTests
|
|||
TeleportViewPlaneController.TransitionViewPlaneDistance,
|
||||
wideProjection.M22,
|
||||
precision: 5);
|
||||
Assert.Equal(0.1f, wideProjection.M43 / wideProjection.M33, precision: 5);
|
||||
Assert.Equal(0.00025f, wideProjection.M43 / wideProjection.M33, precision: 7);
|
||||
|
||||
controller.Update(new TeleportAnimSnapshot(
|
||||
TeleportAnimState.Off,
|
||||
|
|
@ -201,6 +228,37 @@ public sealed class PortalTunnelAssetTests
|
|||
Assert.Equal(baseProjection, controller.Apply(baseProjection));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TeleportViewPlane_TunnelKeepsRetailNearPlaneWhileWorldUsesCoverageNearPlane()
|
||||
{
|
||||
var baseProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI / 3f,
|
||||
16f / 9f,
|
||||
0.1f,
|
||||
5000f);
|
||||
var controller = new TeleportViewPlaneController();
|
||||
controller.Begin(baseProjection);
|
||||
|
||||
controller.Update(new TeleportAnimSnapshot(
|
||||
TeleportAnimState.TunnelFadeOut,
|
||||
ViewPlaneBlend: 1f,
|
||||
ShowTunnel: true,
|
||||
ShowPleaseWait: false));
|
||||
System.Numerics.Matrix4x4 tunnelProjection = controller.Apply(baseProjection);
|
||||
|
||||
controller.Update(new TeleportAnimSnapshot(
|
||||
TeleportAnimState.WorldFadeIn,
|
||||
ViewPlaneBlend: 1f,
|
||||
ShowTunnel: false,
|
||||
ShowPleaseWait: false));
|
||||
System.Numerics.Matrix4x4 worldProjection = controller.Apply(baseProjection);
|
||||
|
||||
Assert.Equal(0.1f, tunnelProjection.M43 / tunnelProjection.M33, precision: 5);
|
||||
Assert.Equal(0.00025f, worldProjection.M43 / worldProjection.M33, precision: 7);
|
||||
Assert.Equal(tunnelProjection.M11, worldProjection.M11);
|
||||
Assert.Equal(tunnelProjection.M22, worldProjection.M22);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TeleportViewPlane_ApplyToSuppliesOverrideToEveryCameraConsumer()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -468,7 +468,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitSound_ReleasesDestinationBeforeHidingTunnelButKeepsProtocolActive()
|
||||
public void ExitSound_HidesTunnelBeforeReleasingDestinationAndKeepsProtocolActive()
|
||||
{
|
||||
var order = new List<string>();
|
||||
var harness = new Harness(order: order);
|
||||
|
|
@ -481,6 +481,8 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.True(Index(order, "presentation-exit") < Index(order, "reservation-end"));
|
||||
Assert.True(Index(order, "reservation-end") < Index(order, "exit-cue"));
|
||||
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
||||
Assert.True(harness.Controller.IsActive);
|
||||
Assert.False(harness.Reveal.Snapshot.Completed);
|
||||
|
|
@ -1589,10 +1591,13 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
harness.Controller.Tick(0.016f);
|
||||
Assert.False(harness.Placement.Called);
|
||||
|
||||
// Viewport swap: reservation release + exit cue + tunnel retire
|
||||
// Viewport swap: tunnel retire, reservation release, then exit cue
|
||||
// (gmSmartBoxUI::UseTime, Sound_UI_ExitPortal @ 0x004D7405).
|
||||
order.Clear();
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.True(Index(order, "presentation-exit") < Index(order, "reservation-end"));
|
||||
Assert.True(Index(order, "reservation-end") < Index(order, "exit-cue"));
|
||||
Assert.Equal(["enter", "exit"], harness.Presentation.Cues);
|
||||
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
||||
Assert.Single(harness.Streaming.ReservationEnds);
|
||||
|
|
@ -2126,9 +2131,17 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
public void TickTunnel(float deltaSeconds) => _order.Add("tunnel-tick");
|
||||
public readonly List<string> Cues = new();
|
||||
public void PlayEnterCue() => Cues.Add("enter");
|
||||
public void PlayExitCue() => Cues.Add("exit");
|
||||
public void PlayExitCue()
|
||||
{
|
||||
Cues.Add("exit");
|
||||
_order.Add("exit-cue");
|
||||
}
|
||||
public void EnterTunnel() => IsPortalViewportVisible = true;
|
||||
public void ExitTunnel() => IsPortalViewportVisible = false;
|
||||
public void ExitTunnel()
|
||||
{
|
||||
IsPortalViewportVisible = false;
|
||||
_order.Add("presentation-exit");
|
||||
}
|
||||
public void SetWaitCue(bool visible) => WaitCueValues.Add(visible);
|
||||
|
||||
public void Reset()
|
||||
|
|
|
|||
129
tests/AcDream.App.Tests/Streaming/RevealTimingProbeTests.cs
Normal file
129
tests/AcDream.App.Tests/Streaming/RevealTimingProbeTests.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
public sealed class RevealTimingProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResourceSampling_IsLowFrequencyAndCoalescesSamePollEdges()
|
||||
{
|
||||
var resources = new CountingResourceSource();
|
||||
var probe = new RevealTimingProbe(
|
||||
loadedLandblockCount: static () => 4,
|
||||
renderResources: resources);
|
||||
probe.Begin(
|
||||
kind: "Login",
|
||||
generation: 2,
|
||||
destinationCell: 0x1234_0001u,
|
||||
window: new StreamingRevealWindow(NearRadius: 1, FarRadius: 2));
|
||||
|
||||
probe.Observe(
|
||||
default,
|
||||
RuntimePortalSnapshot.Idle with { Generation = 2 });
|
||||
probe.Observe(
|
||||
new WorldRevealReadinessSnapshot(
|
||||
DestinationCell: 0x1234_0001u,
|
||||
IsIndoor: false,
|
||||
IsUnhydratable: false,
|
||||
RequiredRenderRadius: 2,
|
||||
RequiredNearRadius: 1,
|
||||
IsRenderNeighborhoodReady: true,
|
||||
AreCompositeTexturesReady: true,
|
||||
IsCollisionReady: true),
|
||||
RuntimePortalSnapshot.Idle with
|
||||
{
|
||||
Generation = 2,
|
||||
Materialized = true,
|
||||
WorldViewportObserved = true,
|
||||
});
|
||||
|
||||
// Begin, one coalesced readiness-edge snapshot, and summary. The
|
||||
// ordinary false poll does not touch the renderer resource owners.
|
||||
Assert.Equal(3, resources.CaptureCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResourceLineCarriesColdMeshTextureAndProcessAttribution()
|
||||
{
|
||||
MeshStreamResourceDiagnostics mesh =
|
||||
default(MeshStreamResourceDiagnostics) with
|
||||
{
|
||||
RenderData = 12,
|
||||
AtlasArrays = 3,
|
||||
EstimatedBytes = 4_000,
|
||||
GlobalUploadCount = 7,
|
||||
GlobalUploadedBytes = 8_000,
|
||||
FrameUploadCount = 2,
|
||||
FrameUploadBytes = 900,
|
||||
StagedUploadBacklog = 5,
|
||||
StagedUploadBytes = 6_000,
|
||||
GlobalCapacityBytes = 10_000,
|
||||
GlobalPhysicalCapacityBytes = 20_000,
|
||||
GlobalMigrationInProgress = true,
|
||||
PreparedReads = 21,
|
||||
PreparedLoaded = 20,
|
||||
};
|
||||
TextureStreamResourceDiagnostics textures =
|
||||
default(TextureStreamResourceDiagnostics) with
|
||||
{
|
||||
OwnedBindlessTextures = 17,
|
||||
TextureOwners = 15,
|
||||
CachedCompositeTextures = 9,
|
||||
CachedUnownedComposites = 2,
|
||||
CachedUnownedCompositeBytes = 700,
|
||||
CompositeAtlases = 2,
|
||||
CompositeAtlasBytes = 30_000,
|
||||
CompositeWarmupPending = 4,
|
||||
FrameCompositeUploadCount = 3,
|
||||
FrameCompositeUploadBytes = 1_200,
|
||||
};
|
||||
ProcessResourceDiagnostics process = new(
|
||||
ManagedBytes: 40_000,
|
||||
ManagedCommittedBytes: 50_000,
|
||||
TrackedGpuBytes: 60_000,
|
||||
TrackedGpuBuffers: 11,
|
||||
TrackedGpuTextures: 13);
|
||||
var snapshot = new RenderFrameResourceDiagnosticsSnapshot(
|
||||
default,
|
||||
default,
|
||||
mesh,
|
||||
textures,
|
||||
process);
|
||||
|
||||
string line = RevealTimingProbe.FormatResourceLine(
|
||||
"render-ready",
|
||||
"Login",
|
||||
generation: 2,
|
||||
elapsedMilliseconds: 3456,
|
||||
snapshot);
|
||||
|
||||
Assert.StartsWith(
|
||||
"[reveal-resource] checkpoint=render-ready kind=Login gen=2 elapsedMs=3456",
|
||||
line);
|
||||
Assert.Contains("meshData=12", line);
|
||||
Assert.Contains("globalUploads=7", line);
|
||||
Assert.Contains("staged=5", line);
|
||||
Assert.Contains("arenaMigrating=1", line);
|
||||
Assert.Contains("prepared=0/21/20/0/0", line);
|
||||
Assert.Contains("ownedTextures=17", line);
|
||||
Assert.Contains("unownedCompositeBytes=700", line);
|
||||
Assert.Contains("compositePending=4", line);
|
||||
Assert.Contains("frameCompositeUploadBytes=1200", line);
|
||||
Assert.Contains("managedBytes=40000", line);
|
||||
Assert.Contains("trackedGpuBytes=60000", line);
|
||||
}
|
||||
|
||||
private sealed class CountingResourceSource :
|
||||
IRenderFrameResourceDiagnosticsSource
|
||||
{
|
||||
public int CaptureCount { get; private set; }
|
||||
|
||||
public RenderFrameResourceDiagnosticsSnapshot Capture()
|
||||
{
|
||||
CaptureCount++;
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
using AcDream.App.Streaming;
|
||||
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
public sealed class StreamingDiagnosticsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData("0", null)]
|
||||
[InlineData("garbage", null)]
|
||||
[InlineData("1", StreamingDiagnostics.DefaultTunnelFreezeFrame)]
|
||||
[InlineData("true", StreamingDiagnostics.DefaultTunnelFreezeFrame)]
|
||||
[InlineData("TRUE", StreamingDiagnostics.DefaultTunnelFreezeFrame)]
|
||||
[InlineData("2", 2)]
|
||||
[InlineData("72", 72)]
|
||||
[InlineData("120", 120)]
|
||||
[InlineData("121", null)]
|
||||
public void TunnelFreezeParser_AcceptsTheDefaultAliasOrAnAuthoredFrame(
|
||||
string? raw,
|
||||
int? expected) =>
|
||||
Assert.Equal(expected, StreamingDiagnostics.ParseTunnelFreezeFrame(raw));
|
||||
}
|
||||
|
|
@ -168,6 +168,31 @@ public sealed class TeleportAnimSequencerTests
|
|||
Assert.Contains(TeleportAnimEvent.Place, evts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticHold_KeepsReadyPortalInStableTunnelUntilReleased()
|
||||
{
|
||||
var seq = new TeleportAnimSequencer();
|
||||
seq.Begin(TeleportEntryKind.Portal);
|
||||
|
||||
var (_, heldEvents) = seq.Tick(
|
||||
dt: 30f,
|
||||
worldReady: true,
|
||||
tunnelAnimationFrame: 72,
|
||||
holdInTunnel: true);
|
||||
|
||||
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
||||
Assert.DoesNotContain(TeleportAnimEvent.Place, heldEvents);
|
||||
|
||||
var (_, releasedEvents) = seq.Tick(
|
||||
dt: 0f,
|
||||
worldReady: true,
|
||||
tunnelAnimationFrame: 72,
|
||||
holdInTunnel: false);
|
||||
|
||||
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
|
||||
Assert.Contains(TeleportAnimEvent.Place, releasedEvents);
|
||||
}
|
||||
|
||||
// --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut ---
|
||||
|
||||
[Fact]
|
||||
|
|
@ -301,6 +326,44 @@ public sealed class TeleportAnimSequencerTests
|
|||
framesPerSecond);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TunnelFadeOut_SwapsAfterLastFullViewportCaptureLevel()
|
||||
{
|
||||
Assert.Equal((short)1001, TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel);
|
||||
|
||||
var seq = new TeleportAnimSequencer();
|
||||
seq.Begin(TeleportEntryKind.Portal);
|
||||
seq.Tick(0f, worldReady: false);
|
||||
seq.Tick(0.016f, worldReady: true);
|
||||
seq.Tick(
|
||||
TeleportAnimSequencer.MinContinue,
|
||||
worldReady: true,
|
||||
tunnelAnimationFrame: 72);
|
||||
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
|
||||
|
||||
float lastSafeTime = Enumerable.Range(0, 1001)
|
||||
.Select(static i => i / 1000f)
|
||||
.First(t => TeleportAnimSequencer.GetRetailAnimationLevel(t)
|
||||
== TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel);
|
||||
float firstUnsafeTime = Enumerable.Range(0, 1001)
|
||||
.Select(static i => i / 1000f)
|
||||
.First(t => TeleportAnimSequencer.GetRetailAnimationLevel(t)
|
||||
> TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel);
|
||||
|
||||
var (lastSafe, _) = seq.Tick(lastSafeTime, worldReady: true);
|
||||
Assert.Equal(TeleportAnimState.TunnelFadeOut, lastSafe.State);
|
||||
Assert.Equal(
|
||||
TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel / 1024f,
|
||||
lastSafe.ViewPlaneBlend);
|
||||
|
||||
var (swapped, events) = seq.Tick(
|
||||
firstUnsafeTime - lastSafeTime,
|
||||
worldReady: true);
|
||||
Assert.Equal(TeleportAnimState.WorldFadeIn, swapped.State);
|
||||
Assert.False(swapped.ShowTunnel);
|
||||
Assert.Contains(TeleportAnimEvent.PlayExitSound, events);
|
||||
}
|
||||
|
||||
private static void AssertOutgoingViewportRetiresBeforeTerminal(
|
||||
TeleportAnimSequencer seq,
|
||||
TeleportAnimState outgoingState,
|
||||
|
|
@ -314,7 +377,8 @@ public sealed class TeleportAnimSequencerTests
|
|||
if (snapshot.State == outgoingState)
|
||||
{
|
||||
Assert.True(
|
||||
snapshot.ViewPlaneBlend <= 1022f / 1024f,
|
||||
snapshot.ViewPlaneBlend
|
||||
<= TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel / 1024f,
|
||||
$"Published unsafe outgoing blend {snapshot.ViewPlaneBlend}.");
|
||||
continue;
|
||||
}
|
||||
|
|
@ -384,8 +448,13 @@ public sealed class TeleportAnimSequencerTests
|
|||
seq.Begin(TeleportEntryKind.Logout);
|
||||
seq.Tick(0f, worldReady: false); // consume enter-sound tick, elapsed≈0
|
||||
|
||||
// Drive to just BEFORE the transition (so we're still in WorldFadeOut)
|
||||
DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.03f, worldReady: false);
|
||||
float lastSafeTime = Enumerable.Range(0, 1001)
|
||||
.Select(static i => i / 1000f)
|
||||
.First(t => TeleportAnimSequencer.GetRetailAnimationLevel(t)
|
||||
== TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel);
|
||||
|
||||
// Drive directly to the last full-viewport quantized table level.
|
||||
seq.Tick(lastSafeTime, worldReady: false);
|
||||
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
|
||||
|
||||
var (snap, _) = seq.Tick(0f, worldReady: false);
|
||||
|
|
@ -393,7 +462,9 @@ public sealed class TeleportAnimSequencerTests
|
|||
Assert.True(
|
||||
snap.ViewPlaneBlend > 0.95f,
|
||||
$"Expected ViewPlaneBlend near 1, got {snap.ViewPlaneBlend}");
|
||||
Assert.True(snap.ViewPlaneBlend <= 1022f / 1024f);
|
||||
Assert.True(
|
||||
snap.ViewPlaneBlend
|
||||
<= TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel / 1024f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue