diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index fc4ad107..a70933c3 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -1003,7 +1003,12 @@ internal sealed class LivePresentationCompositionPhase : null; CompositionAcquisitionScope.CompositionAcquisitionLease< PortalTunnelPresentation>? portalTunnelLease = null; - if (gl is not null && dispatcherLease.Resource is { } portalDispatcher) + // Campaign V slice V6m: portal space exists on BOTH arms. The GL arm is + // unchanged; the RHI arm opens a backbuffer pass of its own and publishes + // it on the scope for the span of the draw, the way the two offscreen + // viewports do. It was the last raw-GL world-adjacent renderer, so the + // Vulkan arm no longer composes a portal-less teleport presentation. + if (dispatcherLease.Resource is { } portalDispatcher) { PortalTunnelPresentation portalTunnel; try @@ -1011,6 +1016,8 @@ internal sealed class LivePresentationCompositionPhase portalTunnel = d.PortalTunnelFallback.AcquirePrepared( () => PortalTunnelPresentation.CreateRequired( gl, + worldPassScope, + host.GpuFrameLifetime, content.Dats, content.AnimationLoader, d.HookRouter, diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 55546364..ca1ac387 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -760,18 +760,13 @@ internal sealed class SessionPlayerCompositionPhase playerMode.BindAutoEntry(playerModeAutoEntry); Fault(SessionPlayerCompositionPoint.PlayerModeBound); - // Campaign V slice V6h: the portal tunnel is a raw-GL renderer, so a - // backend that composed none has nothing in the fallback slot to - // transfer, and the teleport owner drives a presentation that reports - // "no tunnel showing" instead. Every other part of the portal lifecycle - // — reveal generation, destination latch, placement, session — is - // unchanged and runs identically on both arms. + // Campaign V slice V6m: the portal tunnel draws on both arms, so the + // presentation phase always leaves one in the fallback slot and the + // transfer is unconditional again. V6h's null presentation — the + // "no tunnel showing" stand-in a backend without a GL context used to + // drive — is deleted with the backend condition that produced it. LocalPlayerTeleportController localTeleport = - d.PortalTunnelFallback.HasFallback - ? d.PortalTunnelFallback.Transfer(CreateLocalTeleportWithTunnel) - : CreateLocalTeleport( - new AcDream.App.Rendering.Gpu.Vk - .NullLocalPlayerTeleportPresentation()); + d.PortalTunnelFallback.Transfer(CreateLocalTeleportWithTunnel); LocalPlayerTeleportController CreateLocalTeleport( ILocalPlayerTeleportPresentation presentation) => diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs index dd76b31f..97da1fb8 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs @@ -194,76 +194,3 @@ internal sealed class NullRenderFrameGpuMeasurement : IRenderFrameGpuMeasurement { } } - -/// -/// Campaign V slice V6h: the portal viewport on a backend with no portal tunnel. -/// -/// PortalTunnelPresentation is a raw-GL renderer, so the Vulkan arm -/// composes none and the teleport controller drives this instead. Every state -/// query answers "no tunnel is showing", which is true, and keeps the -/// portal-space lifecycle's own invariants — reveal generation, destination -/// latch, wait cue — running unchanged in Runtime. -/// -internal sealed class NullLocalPlayerTeleportPresentation - : ILocalPlayerTeleportPresentation -{ - private readonly TeleportAnimSequencer _animation = new(); - private readonly TeleportViewPlaneController _viewPlane = new(); - - /// Always false: with no tunnel renderer there is no replacement viewport. - public bool IsPortalViewportVisible => false; - - public int CurrentTunnelFrame => 0; - - public void Begin(Matrix4x4 projection) - { - _viewPlane.Begin(projection); - _animation.Begin(TeleportEntryKind.Portal); - } - - public (TeleportAnimSnapshot Snapshot, IReadOnlyList Events) - Tick(float deltaSeconds, bool worldReady) - { - var (snapshot, events) = _animation.Tick( - deltaSeconds, - worldReady, - CurrentTunnelFrame); - _viewPlane.Update(snapshot); - return (snapshot, events); - } - - public void TickTunnel(float deltaSeconds) - { - } - - public void EnterTunnel() - { - } - - public void ExitTunnel() - { - } - - public void SetWaitCue(bool visible) - { - } - - public void Reset() - { - _animation.Reset(); - _viewPlane.Reset(); - } - - public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) => - _viewPlane.Apply(projection); - - public ICamera ApplyViewPlane(ICamera camera) => _viewPlane.ApplyTo(camera); - - public void DrawPortalViewport(int width, int height, Matrix4x4 projection) - { - } - - public void Dispose() - { - } -} diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs index 8b10eb24..9ded8cd7 100644 --- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs +++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Wb; using AcDream.App.UI; using AcDream.Content.Vfx; @@ -21,6 +22,17 @@ namespace AcDream.App.Rendering; /// UseTime 0x004D6E30). It is a synthetic DAT Setup animated at /// 40 frames/second and drawn as a replacement 3-D viewport beneath the /// retained gameplay UI. +/// +/// Campaign V slice V6m. This was the last raw-GL world-adjacent +/// renderer; it now draws on both arms. Nothing about the scene changed — the +/// same synthetic Setup, the same 40 fps sequence, the same rotation and the +/// same distant light, through the same (already dual-arm) +/// . What forked is where the draw is recorded: +/// GL sets ambient capability state under a and draws +/// into whatever framebuffer is bound, while the RHI arm opens a pass of its own +/// against the backbuffer and publishes it on for +/// the span of the draw, exactly as the two offscreen viewports do +/// (, slice V6l). /// public sealed class PortalTunnelPresentation : IDisposable { @@ -35,6 +47,26 @@ public sealed class PortalTunnelPresentation : IDisposable // whole-frame Clear(7) while replacing the hidden world viewport. internal const ClearBufferMask RetailViewportClearMask = ClearBufferMask.DepthBufferBit; + /// + /// The colour the RHI arm's pass loads with, and the one value that makes + /// its Clear load-op equivalent to GL's depth-only clear. + /// + /// Retail preserves the colour target rather than re-establishing it, + /// and so does the GL arm. A Vulkan pass cannot inherit an image the way a + /// bound framebuffer can under MSAA — the frame's world pass RESOLVES into + /// the swapchain image and stores DontCare into the multisampled + /// scratch, so a second multisampled pass declaring Load would load + /// undefined contents (plan §5.5.12 item 5). Re-clearing is exact instead of + /// approximate because of an invariant the frame graph enforces: + /// RenderFrameFoundation.PortalViewportVisible and this scene's own + /// are the same value, read once at the top of the + /// frame, and WorldSceneRenderer returns without drawing when it is + /// set. So whenever this scene draws, the backbuffer holds exactly the + /// opaque black SceneTool::BeginScene @ 0x0043DAD0 establishes and + /// nothing else — and clearing to that same black changes no pixel. + /// + internal static readonly Vector4 RetailPortalSpaceClearColor = new(0f, 0f, 0f, 1f); + private const uint SyntheticEntityId = 0xFFFF_FF01u; private const uint SyntheticLandblockId = 0u; private const float RotationDurationMin = 0.6f; @@ -42,7 +74,23 @@ public sealed class PortalTunnelPresentation : IDisposable private static readonly HashSet AnimatedIds = new() { SyntheticEntityId }; - private readonly GL _gl; + /// The GL arm's context, or null on a backend that has none. + private readonly GL? _glContext; + + /// + /// The world pass scope, or null on the GL arm. + /// + /// 's RHI arm borrows its pass from the + /// scope rather than opening one, so a presentation that opens a pass of its + /// own has to publish it there for the duration of the draw. On the GL arm + /// the dispatcher records against whatever framebuffer is bound, so nothing + /// is published. + /// + private readonly IWorldPassScope? _scope; + + /// The frame the RHI arm's pass is opened on; null on the GL arm. + private readonly ICurrentGpuFrameSource? _frames; + private readonly WbDrawDispatcher _dispatcher; private readonly SceneLightingUboBinding _lightUbo; private readonly Setup _setup; @@ -69,7 +117,9 @@ public sealed class PortalTunnelPresentation : IDisposable private bool _disposed; private PortalTunnelPresentation( - GL gl, + GL? gl, + IWorldPassScope? scope, + ICurrentGpuFrameSource? frames, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo, IWbMeshAdapter meshAdapter, @@ -82,7 +132,9 @@ public sealed class PortalTunnelPresentation : IDisposable Action? displayNotice, IDisposable? displayNoticeLifetime) { - _gl = gl; + _glContext = gl; + _scope = scope; + _frames = frames; _dispatcher = dispatcher; _lightUbo = lightUbo; _setup = setup; @@ -114,9 +166,16 @@ public sealed class PortalTunnelPresentation : IDisposable /// installed DATs. Retail dereferences this scene unconditionally; missing /// required assets therefore fail startup with an actionable diagnostic. /// No substitute tunnel is fabricated. + /// + /// Internal since slice V6m: the RHI arm's two seams — + /// and — + /// are internal RHI contracts, and the type itself keeps its visibility. + /// Composition is the only caller. /// - public static PortalTunnelPresentation CreateRequired( - GL gl, + internal static PortalTunnelPresentation CreateRequired( + GL? gl, + IWorldPassScope? scope, + ICurrentGpuFrameSource? frames, IDatReaderWriter dats, IAnimationLoader animationLoader, IAnimationHookSink hookSink, @@ -127,7 +186,14 @@ public sealed class PortalTunnelPresentation : IDisposable IDisposable? displayNoticeLifetime = null, Random? random = null) { - ArgumentNullException.ThrowIfNull(gl); + if (gl is null && (scope is null || frames is null)) + { + throw new ArgumentNullException( + nameof(scope), + "A backend without a GL context must supply a world pass scope and a frame source " + + "for portal space to open its pass on."); + } + ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(animationLoader); ArgumentNullException.ThrowIfNull(hookSink); @@ -150,6 +216,8 @@ public sealed class PortalTunnelPresentation : IDisposable return new PortalTunnelPresentation( gl, + scope, + frames, dispatcher, lightUbo, meshAdapter, @@ -266,21 +334,69 @@ public sealed class PortalTunnelPresentation : IDisposable _camera.Aspect = width / (float)height; _camera.UseSmartBoxFov(smartBoxProjection); - using var scope = new GLStateScope(_gl); - _gl.Viewport(0, 0, (uint)width, (uint)height); - _gl.Disable(EnableCap.ScissorTest); - _gl.ClearDepth(1.0); - _gl.DepthMask(true); - _gl.Clear(RetailViewportClearMask); + if (_glContext is { } gl) + { + DrawGl(gl, width, height); + return; + } - _gl.Enable(EnableCap.DepthTest); - _gl.DepthFunc(DepthFunction.Less); - _gl.Enable(EnableCap.CullFace); - _gl.CullFace(TriangleFace.Back); - _gl.FrontFace(FrontFaceDirection.Ccw); - _gl.Disable(EnableCap.Blend); + DrawRhi(); + } + private void DrawGl(GL gl, int width, int height) + { + using var scope = new GLStateScope(gl); + + gl.Viewport(0, 0, (uint)width, (uint)height); + gl.Disable(EnableCap.ScissorTest); + gl.ClearDepth(1.0); + gl.DepthMask(true); + gl.Clear(RetailViewportClearMask); + + gl.Enable(EnableCap.DepthTest); + gl.DepthFunc(DepthFunction.Less); + gl.Enable(EnableCap.CullFace); + gl.CullFace(TriangleFace.Back); + gl.FrontFace(FrontFaceDirection.Ccw); + gl.Disable(EnableCap.Blend); + + DrawScene(); + } + + /// + /// The RHI arm. Every capability the GL arm sets by hand is baked into the + /// dispatcher's pipelines, and the pass sets its own full-attachment + /// viewport, so what remains is the pass itself: a backbuffer pass at the + /// world's sample count that clears colour and depth (see + /// for why re-clearing colour is + /// exact) and is published as the scope's for the span of the draw. + /// + private void DrawRhi() + { + IWorldPassScope scope = _scope + ?? throw new InvalidOperationException( + "Portal space reached its RHI arm with no world pass scope."); + IGpuFrame frame = (_frames ?? throw new InvalidOperationException( + "Portal space reached its RHI arm with no frame source.")) + .CurrentFrame + ?? throw new InvalidOperationException( + "Portal space requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); + + using IGpuPassEncoder encoder = frame.BeginPass( + GpuPassDescription.BackbufferClear( + "portal-space", + RetailPortalSpaceClearColor, + scope.SampleCount)); + // Published AFTER the pass opens and BEFORE the light upload: publishing + // resets the frame-global sections, and the distant light installed below + // is the one this scene wants rather than the world's. + using IDisposable publication = scope.Publish(encoder); + DrawScene(); + } + + private void DrawScene() + { UploadRetailLight(); // The dispatcher is shared with the world pass. Portal space is its // own CreatureMode scene: it has no world-cell clip routing and no diff --git a/tests/AcDream.App.Tests/Composition/LivePresentationCompositionTests.cs b/tests/AcDream.App.Tests/Composition/LivePresentationCompositionTests.cs index 71e25652..eb79ccd3 100644 --- a/tests/AcDream.App.Tests/Composition/LivePresentationCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/LivePresentationCompositionTests.cs @@ -150,6 +150,48 @@ public sealed class LivePresentationCompositionTests Assert.Contains("d.PortalTunnelFallback.AcquirePrepared(", phase); } + /// + /// Campaign V slice V6m: portal space is composed on BOTH arms. It was the + /// last renderer this phase built only when a GL context existed, and the + /// Vulkan arm's portal-less teleport presentation went with the condition — + /// so both the gate and the stand-in are asserted absent here. + /// + [Fact] + public void PortalSpaceIsComposedOnBothBackendArms() + { + string root = FindRepoRoot(); + string phase = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Composition", + "LivePresentationComposition.cs")); + string session = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Composition", + "SessionPlayerComposition.cs")); + + Assert.Contains( + "if (dispatcherLease.Resource is { } portalDispatcher)", + phase); + Assert.DoesNotContain( + "if (gl is not null && dispatcherLease.Resource is { } portalDispatcher)", + phase); + Assert.DoesNotContain("NullLocalPlayerTeleportPresentation", session); + Assert.DoesNotContain( + "NullLocalPlayerTeleportPresentation", + File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "Gpu", + "Vk", + "VulkanCompositionFramePhases.cs"))); + } + private static LiveEntityRuntime Runtime() => LiveEntityRuntimeFixture.Create( new GpuWorldState(), new DelegateLiveEntityResourceLifecycle(static _ => { }, static _ => { })); diff --git a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs index 2913cce7..0f638770 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs @@ -24,6 +24,46 @@ public sealed class PortalTunnelAssetTests & Silk.NET.OpenGL.ClearBufferMask.ColorBufferBit) != 0); } + /// + /// 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 + /// only exact if the colour is retail's opaque portal-space black — the same + /// value SceneTool::BeginScene @ 0x0043DAD0 puts there and the same + /// one the clear phase computes when the portal viewport is visible. + /// + [Fact] + public void PortalSpace_RhiPassReclearsTheSameOpaqueBlackTheFrameEstablished() + { + Assert.Equal( + new System.Numerics.Vector4(0f, 0f, 0f, 1f), + PortalTunnelPresentation.RetailPortalSpaceClearColor); + } + + /// + /// Campaign V slice V6m. A backend with no GL context draws portal space + /// through a pass of its own, so it must be given somewhere to open one. + /// Composition failing loudly here beats a null dereference at the first + /// portal transit, which is minutes into a connected run. + /// + [Fact] + public void PortalSpace_BackendWithoutGlRequiresAPassScopeAndFrameSource() + { + var error = Assert.Throws(() => + PortalTunnelPresentation.CreateRequired( + gl: null, + scope: null, + frames: null, + dats: null!, + animationLoader: null!, + hookSink: null!, + dispatcher: null!, + lightUbo: null!, + meshAdapter: null!)); + + Assert.Equal("scope", error.ParamName); + Assert.Contains("world pass scope", error.Message); + } + [Fact] public void InstalledDat_ResolvesRetailPortalSetupAndAnimation() {