feat(render): Campaign V slice V6j commit 2 - Dereth draws on Vulkan

The three world renderers' submission arms, both pass executors, and the
composition that reaches them. This is the unit three predecessors stopped at.

What it produces. ACDREAM_RENDER_BACKEND=vulkan on the offline scene renders
terrain with blended textures and road overlays, the water edge, static world
meshes, procedural scenery, and the complete retained UI - the same frame the GL
pixel gate captures, from the same camera, minus the sky. artifacts/v6j-vk2.

The shape, and why it is not V4c's. Section 5.5.6 chose option (B) after NVIDIA
rendered the V4c binary 10/10 where AMD's GL stack did not: GL keeps its raw
world path through to V10 as a documented fork confined to the submission seam,
and the RHI world path ships on Vulkan. So V4c's and V4d-2's content returns as a
SECOND arm rather than a replacement. The GL arm issues the same GL statements in
the same order against the same objects; the encoder arm lives in three .Rhi.cs
partials and is entered by one branch per submission site.

Three differences from V4c, each because the tree moved under it. There is no
binding-9 texture table - V4t put the slot on the device and Vulkan binds set 2,
so the arm that used to intern bindless handles simply has nothing to do. The
pipelines carry the device's sample count rather than 1, because Vulkan requires
rasterizationSamples to match the pass and alpha-to-coverage is a no-op at one
sample. And no renderer opens a pass.

That last one is structural, not tidiness. Under MSAA the frame's one backbuffer
pass resolves into the swapchain image and stores DONT_CARE into the multisampled
scratch, so a second pass declaring Load would load undefined contents; the
backend also permits one open pass per frame. VulkanWorldScenePhase therefore
opens the pass, publishes the encoder on VulkanWorldPassScope for exactly the
span of the inner WorldSceneRenderer, and every renderer borrows it.

Three sections are frame-global on GL and cannot be on Vulkan: the SceneLighting
UBO, the per-cell clip regions, and the terrain clip block. GL binds each to a
global binding point and every consumer inherits it. Vulkan binds a descriptor
set per draw, and a renderer's own binds are what select the scope those sections
must land in - so their writers PUBLISH into WorldFrameSections and each renderer
binds them inside the pass, after its own binds. SceneLightingUboBinding's
per-flight-slot buffer pool disappears with it: a ring allocation is already
distinct memory that lives until the frame retires, which is the property the
pool existed to provide.

Both pass executors became backend-neutral rather than gaining twins. Everything
they do is delegation to a renderer except four concerns - the clip-frame
publication, the doorway scissor, gl_ClipDistance enablement, and retail's
interior depth clear - so those four move behind IWorldPassSurface and retail's
ordering, which is what these classes are actually for, is written once. The GL
implementation issues the statements the executors used to issue inline.

Clip distances are no-ops on the Vulkan arm, and that is safe rather than a
divergence: Vulkan activates every element the shader declares, and all three
world vertex shaders already write 1.0 into every slot past the active count.
The interior depth clear becomes vkCmdClearAttachments, reached through the scope
so the pinned contract stays frozen and the backend-only verb stays in the
backend. The hook for it was already committed at V6i-3 with a cref to a type
that did not exist yet; it exists now.

The collision-wireframe DebugLineRenderer is composed as null on the Vulkan arm.
DrawAndPublish flushes it INSIDE the world phase and it opens its own pass, which
the one-pass rule forbids. The toggle is DevTools-only and DevTools is not
composed there, so nothing is lost - composing it would throw on the first
wireframe frame rather than silently misdraw.

Two seams widened rather than invented. GameWindowGraphics answers whether the
backend has a world-pass seam, because the three composition phases that need it
already borrow that handle and "does this backend work that way" is what the type
exists to answer. And MeshSourceReady replaces the anyVao != 0 gate with the same
question in backend-neutral form - V6i-3 published HasStores for exactly this -
so the predicate evaluates identically on GL.

What is NOT here, and is expected. Sky and weather are still raw GL (V4f), so the
Vulkan frame's sky is the atmosphere fog clear. Particles (V4e), the paperdoll and
appraisal viewports and the portal depth mask (V4g) likewise. The executors
already accepted all of them as absent.

Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged
baseline; complete Release suite 9,175 / 5. Strict GL offline pixel gate against
847f14ae: 5.50e-05, 31 differing pixels of 563,200, inside the documented 9-31
band and 18x under the threshold. Characterised rather than accepted, because 31
is the band's top: cross-commit pairs measured 21, 29 and 31 while same-commit
controls measured 12 and 20, and maximumChannelDelta is 46-52 in every comparison
INCLUDING the pure controls - so the few large-delta pixels are a property of the
capture, and a cross-commit pair at 21 against a same-commit pair at 20 is not
what a systematic shift looks like. GL connected repeat gate at 3 runs: 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, a captured world frame, and a graceful close.

Coverage gap, stated rather than assumed. The offline scene is a fixed outdoor
view, so EnvCellRenderer's Vulkan arm draws nothing in it - dungeon interiors are
half of this slice and are unproven by anything automated, exactly as they were
for V4c. The deferred-alpha path and the doorway scissor are likewise untouched
by this scene. They join the accumulated user-gate debt in plan section 5.1.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 15:46:54 +02:00
parent 81fe5e1b63
commit f84eef3256
22 changed files with 2566 additions and 264 deletions

View file

@ -0,0 +1,319 @@
using System.Numerics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Campaign V slice V6j: the small graphics surface the two world pass executors
/// touch directly, expressed once so their ordering logic — which is retail's,
/// and heavily tested — is written once for both backends.
///
/// <para>Everything else those executors do is delegation to a renderer. What is
/// left is exactly this: the clip-frame publication, the doorway scissor,
/// <c>gl_ClipDistance</c> enablement, and retail's interior depth clear. Four
/// concerns, each of which genuinely differs between GL and Vulkan, and none of
/// which is expressible on the pinned RHI contract.</para>
/// </summary>
internal interface IWorldPassSurface
{
/// <summary>
/// Publishes this frame's per-cell clip-region table and terrain clip block,
/// and routes both to the renderers that read them.
///
/// <para><paramref name="terrainUploadCount"/> is how many distinct terrain
/// clip blocks the frame will issue. GL reserves that many arena records
/// before the first draw, because reallocating the arena while an earlier
/// slice can still reference it is the hazard the reservation exists for. The
/// RHI arm ignores it: a ring allocation is distinct memory by construction
/// and lives until the frame retires.</para>
/// </summary>
void PrepareClipFrame(int terrainUploadCount);
/// <summary>Replaces the terrain clip planes and republishes the block.</summary>
void SetTerrainClip(ReadOnlySpan<Vector4> planes);
/// <summary>
/// Re-asserts the terrain clip block at its binding.
///
/// <para>GL needs this because binding points are global and the sky and mesh
/// shaders read the same uniform binding between two terrain slices. On the
/// RHI arm every consumer binds the published section inside the pass, so
/// there is no ambient binding to re-assert and this is a no-op.</para>
/// </summary>
void BindTerrainClip();
/// <summary>
/// Enables every <c>gl_ClipDistance</c> slot.
///
/// <para>Vulkan activates every element the shader declares and has no
/// enable, so this is a no-op there — and that is safe rather than a
/// divergence: all three world vertex shaders already write <c>1.0</c>
/// ("keep everything") into every slot past the active count, so a frame with
/// no clip planes clips nothing on either backend (plan §5.5.14 item 4).</para>
/// </summary>
void EnableClipDistances();
/// <summary>Disables every <c>gl_ClipDistance</c> slot. See <see cref="EnableClipDistances"/>.</summary>
void DisableClipDistances();
/// <summary>
/// Scissors to a doorway slice's screen-space bounding box. Returns whether a
/// scissor is now active, so the caller can pair it with <see cref="EndScissor"/>.
/// </summary>
bool BeginScissor(Vector4 ndcAabb);
/// <summary>Restores the full drawable rectangle.</summary>
void EndScissor();
/// <summary>
/// Retail's interior depth clear, between the landscape slice and the
/// interior cells (<c>PView::DrawCells @ 0x005A4840</c>).
/// </summary>
void ClearInteriorDepth();
}
/// <summary>
/// The GL arm. Every statement below is the one the executor used to issue
/// inline, in the same order, against the same objects.
/// </summary>
internal sealed class GlWorldPassSurface : IWorldPassSurface
{
private readonly GL _gl;
private readonly ClipFrame _clipFrame;
private readonly IRetailPViewFramebufferSource _framebuffer;
private readonly WbDrawDispatcher _entities;
private readonly EnvCellRenderer _envCells;
private readonly TerrainModernRenderer? _terrain;
public GlWorldPassSurface(
GL gl,
ClipFrame clipFrame,
IRetailPViewFramebufferSource framebuffer,
WbDrawDispatcher entities,
EnvCellRenderer envCells,
TerrainModernRenderer? terrain)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
_framebuffer = framebuffer ?? throw new ArgumentNullException(nameof(framebuffer));
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
_envCells = envCells ?? throw new ArgumentNullException(nameof(envCells));
_terrain = terrain;
}
public void PrepareClipFrame(int terrainUploadCount)
{
// Allocate every terrain record before issuing the first draw. BufferData
// must not replace the arena while an earlier slice can reference it.
_clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount);
_clipFrame.UploadRegions(_gl);
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
UploadTerrainClip();
}
public void SetTerrainClip(ReadOnlySpan<Vector4> planes)
{
_clipFrame.SetTerrainClip(planes);
UploadTerrainClip();
}
public void BindTerrainClip() => _clipFrame.BindTerrainClip(_gl);
public void EnableClipDistances()
{
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
_gl.Enable(EnableCap.ClipDistance0 + index);
}
public void DisableClipDistances()
{
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
_gl.Disable(EnableCap.ClipDistance0 + index);
}
public bool BeginScissor(Vector4 ndcAabb)
{
RetailPViewFramebufferSize framebuffer = _framebuffer.Capture();
var box = NdcScissorRect.ToPixels(
ndcAabb,
framebuffer.Width,
framebuffer.Height);
_gl.Enable(EnableCap.ScissorTest);
_gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height);
return true;
}
public void EndScissor() => _gl.Disable(EnableCap.ScissorTest);
public void ClearInteriorDepth()
{
_gl.Disable(EnableCap.ScissorTest);
_gl.DepthMask(true);
_gl.Clear(ClearBufferMask.DepthBufferBit);
}
private void UploadTerrainClip()
{
TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl);
_terrain?.SetClipUbo(binding);
}
}
/// <summary>
/// The RHI arm. The clip tables become ring sections published on the world pass
/// scope, the scissor becomes dynamic state on the borrowed encoder, and the
/// depth clear becomes a scoped <c>vkCmdClearAttachments</c>.
/// </summary>
internal sealed class RhiWorldPassSurface : IWorldPassSurface
{
private readonly IWorldPassScope _scope;
private readonly ICurrentGpuFrameSource _frames;
private readonly ClipFrame _clipFrame;
private readonly IRetailPViewFramebufferSource _framebuffer;
public RhiWorldPassSurface(
IWorldPassScope scope,
ICurrentGpuFrameSource frames,
ClipFrame clipFrame,
IRetailPViewFramebufferSource framebuffer)
{
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
_framebuffer = framebuffer ?? throw new ArgumentNullException(nameof(framebuffer));
}
public void PrepareClipFrame(int terrainUploadCount)
{
// The reservation count has no RHI counterpart: each publication below
// takes its own ring slice, which is distinct memory that outlives every
// draw recorded against it in this frame.
_ = terrainUploadCount;
_scope.Sections.ClipRegions = Publish(
_clipFrame.RegionBytes,
GpuRingUsage.Storage);
PublishTerrainClip();
}
public void SetTerrainClip(ReadOnlySpan<Vector4> planes)
{
_clipFrame.SetTerrainClip(planes);
PublishTerrainClip();
}
/// <summary>No-op: there is no ambient binding to re-assert. See the interface.</summary>
public void BindTerrainClip()
{
}
/// <summary>No-op: Vulkan activates every declared clip distance. See the interface.</summary>
public void EnableClipDistances()
{
}
/// <summary>No-op: Vulkan activates every declared clip distance. See the interface.</summary>
public void DisableClipDistances()
{
}
public bool BeginScissor(Vector4 ndcAabb)
{
RetailPViewFramebufferSize framebuffer = _framebuffer.Capture();
var box = NdcScissorRect.ToPixels(
ndcAabb,
framebuffer.Width,
framebuffer.Height);
// GL convention on the way in; the backend performs its own Y flip.
_scope.RequireEncoder().SetScissor(box.X, box.Y, box.Width, box.Height);
return true;
}
public void EndScissor() =>
_scope.RequireEncoder().SetScissor(
0,
0,
_scope.AttachmentWidth,
_scope.AttachmentHeight);
public void ClearInteriorDepth()
{
// The GL arm drops the scissor before clearing so the clear covers the
// whole target; vkCmdClearAttachments takes its own rectangle and is not
// scissored, so the same coverage comes for free — but the scissor still
// has to come off, because the interior cells drawn after it are not
// confined to the doorway slice that was active.
EndScissor();
_scope.ClearInteriorDepth();
}
private void PublishTerrainClip() =>
_scope.Sections.TerrainClip = Publish(
_clipFrame.TerrainBytes,
GpuRingUsage.Uniform);
private GpuBufferSection Publish(ReadOnlySpan<byte> data, GpuRingUsage usage)
{
IGpuFrame frame = _frames.CurrentFrame
?? throw new InvalidOperationException(
"The world clip frame requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
// A logically empty table still reserves one slot so the bound range is
// never zero-length — the same rule the light buffers already state.
int byteCount = Math.Max(data.Length, ClipFrame.CellClipStrideBytes);
GpuRingAllocation allocation = frame.AllocateRing(byteCount, usage);
allocation.Data.Clear();
if (!data.IsEmpty)
data.CopyTo(allocation.Data);
return new GpuBufferSection(
allocation.Buffer,
allocation.OffsetBytes,
(uint)byteCount);
}
}
/// <summary>
/// Campaign V slice V6j: the frame-default restore on a backend with no ambient
/// state to restore.
///
/// <para>Both executors call <see cref="IRenderFrameGlState.RestoreFrameDefaults"/>
/// when aborting a failed frame. On Vulkan every piece of state that call
/// re-establishes is either baked into a pipeline or set per draw, so there is
/// nothing to put back and saying so is more honest than composing a GL
/// controller that would have no context to talk to.</para>
/// </summary>
internal sealed class NullRenderFrameGlState : IRenderFrameGlState
{
public static NullRenderFrameGlState Instance { get; } = new();
private NullRenderFrameGlState()
{
}
public void RestoreFrameDefaults()
{
}
}
/// <summary>
/// Campaign V slice V6j: the GL state reader on a backend with no GL state.
///
/// <para><c>WorldRenderDiagnostics</c> reads live GL state for explicitly enabled
/// probes only. Every snapshot below is the truthful answer for a Vulkan frame —
/// there is no ambient capability state to sample — which keeps every other
/// diagnostic the class emits (render signature, PView input, out-stage routing,
/// phantom objects) working unchanged on both backends.</para>
/// </summary>
internal sealed class NullRenderGlStateReader : IRenderGlStateReader
{
public static NullRenderGlStateReader Instance { get; } = new();
private NullRenderGlStateReader()
{
}
public RenderGlStateSnapshot CaptureState() => default;
public RenderGlScissorSnapshot CaptureScissor() => default;
}