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