feat(render): Campaign V slice V6m commit 1 - portal space draws on Vulkan

PortalTunnelPresentation was the last raw-GL world-adjacent renderer. It now
draws on both arms, and the composition that used to hand the Vulkan arm a
portal-less teleport presentation is gone with it.

Nothing about the scene changed. Same synthetic DAT Setup resolved through the
same client-enum mapping, same 40 fps CSequence, same retail rotation cadence,
same distant light, drawn through the same already-dual-arm WbDrawDispatcher.
What forked is only where the draw is recorded:

  * GL keeps its GLStateScope, its viewport/scissor/depth/cull/blend statements
    and its depth-only glClear, untouched.
  * The RHI arm opens a backbuffer pass of its own and publishes it on
    IWorldPassScope for the span of the draw - the shape V6l gave the two
    offscreen viewports, and required for the same reason: the dispatcher's RHI
    arm borrows its pass rather than opening one. Publication comes after
    BeginPass and before UploadRetailLight, because publishing resets the
    frame-global sections and this scene wants its own light, not the world's.

The one substantive decision is the pass's COLOUR load op, and it is a Clear
rather than a Load. Retail preserves the colour target and only clears depth
(UIViewportObject::DrawContent @ 0x006950A5 -> Clear(4) = D3DCLEAR_ZBUFFER), 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 section
5.5.12 item 5, the same hazard that merged the clear into the world pass.

Re-clearing is exact rather than approximate because of an invariant the frame
graph already enforces. RenderFrameFoundation.PortalViewportVisible and this
scene's IsVisible are the same value, read once at the top of the frame, and
WorldSceneRenderer returns without drawing when it is set. So whenever portal
space draws, the backbuffer holds exactly the opaque black
SceneTool::BeginScene @ 0x0043DAD0 establishes and nothing else, and clearing to
that same black changes no pixel. The alternative - a single-sampled Load pass
over the resolved image - would have been both a silent MSAA divergence and
invalid, since the backbuffer's depth attachment is multisampled.

The pass takes IWorldPassScope.SampleCount, so WbDrawDispatcher's sample-count
pipeline variants (V6l) select the backbuffer set, and depth matches the
attachment.

CreateRequired becomes internal: its two new seams are internal RHI contracts
and composition is its only caller. The TYPE keeps its visibility - plan section
7.1 rule 3.

Gates. Release build green. App tests 4,132 / 3 skips against the 4,129
baseline (three new: the retail black constant, the RHI arm's composition
precondition, and the both-arms composition assertion). Complete Release suite
9,195 / 5; one AcDream.Content failure in the solution-wide run that passes
124/124 rerun alone - the documented rerun-singly flake class, not carried
forward as a claim. Strict GL offline pixel gate against 280f3b3f: 28 px of
563,200, fraction 4.97e-05, inside the documented 9-31 band, with a same-commit
control pair at 20 px / 3.55e-05 taken immediately afterwards. GL connected
-Runs 3: 3/3 RENDERED on the desktop witness and 3/3 on the client capture. One
offline Vulkan run with VK_LAYER_KHRONOS_validation proven inserted by the
loader: zero validation errors, zero warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 18:25:39 +02:00
parent 280f3b3fe9
commit 59c6b2ae94
6 changed files with 230 additions and 103 deletions

View file

@ -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,

View file

@ -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) =>

View file

@ -194,76 +194,3 @@ internal sealed class NullRenderFrameGpuMeasurement : IRenderFrameGpuMeasurement
{
}
}
/// <summary>
/// Campaign V slice V6h: the portal viewport on a backend with no portal tunnel.
///
/// <para><c>PortalTunnelPresentation</c> 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.</para>
/// </summary>
internal sealed class NullLocalPlayerTeleportPresentation
: ILocalPlayerTeleportPresentation
{
private readonly TeleportAnimSequencer _animation = new();
private readonly TeleportViewPlaneController _viewPlane = new();
/// <summary>Always false: with no tunnel renderer there is no replacement viewport.</summary>
public bool IsPortalViewportVisible => false;
public int CurrentTunnelFrame => 0;
public void Begin(Matrix4x4 projection)
{
_viewPlane.Begin(projection);
_animation.Begin(TeleportEntryKind.Portal);
}
public (TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> 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()
{
}
}

View file

@ -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;
/// <c>UseTime 0x004D6E30</c>). It is a synthetic DAT Setup animated at
/// 40 frames/second and drawn as a replacement 3-D viewport beneath the
/// retained gameplay UI.
///
/// <para><b>Campaign V slice V6m.</b> 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)
/// <see cref="WbDrawDispatcher"/>. What forked is where the draw is recorded:
/// GL sets ambient capability state under a <see cref="GLStateScope"/> and draws
/// into whatever framebuffer is bound, while the RHI arm opens a pass of its own
/// against the backbuffer and publishes it on <see cref="IWorldPassScope"/> for
/// the span of the draw, exactly as the two offscreen viewports do
/// (<see cref="PrivateEntityViewportRenderer"/>, slice V6l).</para>
/// </summary>
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;
/// <summary>
/// The colour the RHI arm's pass loads with, and the one value that makes
/// its <c>Clear</c> load-op equivalent to GL's depth-only clear.
///
/// <para>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 <c>DontCare</c> into the multisampled
/// scratch, so a second multisampled pass declaring <c>Load</c> 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:
/// <c>RenderFrameFoundation.PortalViewportVisible</c> and this scene's own
/// <see cref="IsVisible"/> are the same value, read once at the top of the
/// frame, and <c>WorldSceneRenderer</c> returns without drawing when it is
/// set. So whenever this scene draws, the backbuffer holds exactly the
/// opaque black <c>SceneTool::BeginScene @ 0x0043DAD0</c> establishes and
/// nothing else — and clearing to that same black changes no pixel.</para>
/// </summary>
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<uint> AnimatedIds = new() { SyntheticEntityId };
private readonly GL _gl;
/// <summary>The GL arm's context, or null on a backend that has none.</summary>
private readonly GL? _glContext;
/// <summary>
/// The world pass scope, or null on the GL arm.
///
/// <para><see cref="WbDrawDispatcher"/>'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.</para>
/// </summary>
private readonly IWorldPassScope? _scope;
/// <summary>The frame the RHI arm's pass is opened on; null on the GL arm.</summary>
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<string?>? 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.
///
/// <para>Internal since slice V6m: the RHI arm's two seams —
/// <see cref="IWorldPassScope"/> and <see cref="ICurrentGpuFrameSource"/> —
/// are internal RHI contracts, and the type itself keeps its visibility.
/// Composition is the only caller.</para>
/// </summary>
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();
}
/// <summary>
/// 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
/// <see cref="RetailPortalSpaceClearColor"/> for why re-clearing colour is
/// exact) and is published as the scope's for the span of the draw.
/// </summary>
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

View file

@ -150,6 +150,48 @@ public sealed class LivePresentationCompositionTests
Assert.Contains("d.PortalTunnelFallback.AcquirePrepared(", phase);
}
/// <summary>
/// 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.
/// </summary>
[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 _ => { }));

View file

@ -24,6 +24,46 @@ public sealed class PortalTunnelAssetTests
& Silk.NET.OpenGL.ClearBufferMask.ColorBufferBit) != 0);
}
/// <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
/// only exact if the colour is retail's opaque portal-space black — the same
/// value <c>SceneTool::BeginScene @ 0x0043DAD0</c> puts there and the same
/// one the clear phase computes when the portal viewport is visible.
/// </summary>
[Fact]
public void PortalSpace_RhiPassReclearsTheSameOpaqueBlackTheFrameEstablished()
{
Assert.Equal(
new System.Numerics.Vector4(0f, 0f, 0f, 1f),
PortalTunnelPresentation.RetailPortalSpaceClearColor);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void PortalSpace_BackendWithoutGlRequiresAPassScopeAndFrameSource()
{
var error = Assert.Throws<ArgumentNullException>(() =>
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()
{