acdream/src/AcDream.App/Rendering/WorldPassScope.cs
Erik 2e8b8b91ad feat(render): Campaign V slice V6l commit 3 - the offscreen viewports draw on Vulkan
Amendment 3 of three: the paperdoll and creature-appraisal views render on the
Vulkan arm. Plan section 5.5.16 defect 3 named two backend fixes as the
precondition; both are here, and running it found two more the note could not
have known about.

Fix 1: a layered sampled view per render target. An ATTACHMENT view must be
VK_IMAGE_VIEW_TYPE_2D and the global texture table's descriptor array is
sampler2DArray, so the attachment view cannot legally be registered into it -
section 5.5.7 recorded that as invalid usage rather than a mismatch that samples
oddly, and V6k made RegisterTexture refuse it loudly and name this fix.
VulkanGpuTexture now creates a SECOND, layered view over the same image for a
colour render target: one image, one allocation, two ways of looking at it,
legal without any creation flag. SampledView is what the table registers for
every texture, so the question disappears rather than being answered.

Fix 2: sample-count pipeline variants for WbDrawDispatcher. Vulkan requires a
pipeline's rasterizationSamples to equal the pass it draws in, and this
dispatcher draws in two passes with different counts - the multisampled
backbuffer world pass and the single-sampled offscreen target, which the
contract fixes at one sample. Its five pipelines became a MeshPipelineSet with
two instances, selected at bind time from the live pass rather than from the
scope, which is the same shape section 5.5.8 gave the depth-format problem. When
the backbuffer is single-sampled the two sets are one object, so nothing is
built twice and nothing is freed twice. The offscreen target's DEPTH attachment
also had to take the device's own combined depth/stencil format rather than the
contract enum's literal D24_UNORM_S8_UINT: a pipeline bakes one depth/stencil
format under dynamic rendering and the same pipelines draw in both passes, so a
second format would make one of the two undefined.

Fix 3, which running it found: entity APPEARANCE composites were still
bindless-only, so no entity with a palette override could be drawn on the Vulkan
arm at all - the doll being one, and every creature and player besides. The
backend that serves it has existed since V6i-2 and had no production consumer;
it has one now. TextureCache builds the composite cache on both arms, and
EnsureCompositeTexturesAvailable stops asking about bindless. Nothing about the
cache itself changed: the sharing, the bounded unowned LRU, the metered upload
budget and the retirement fence were already backend-neutral.

Fix 4, which the first successful capture found: the doll rendered upside down.
UiViewport has flipped V since V4a because a GL framebuffer's origin is
bottom-left, so its colour texture samples bottom-up. A Vulkan image's origin is
top-left and the backend's negative viewport height stores the rendered image
that way round, so the same flip stands the doll on its head. That is a property
of the backend that made the texture, not of the widget that draws it, so
IUiViewportRenderer answers TextureIsBottomUp and UiViewport asks. The line this
replaces had predicted exactly this failure since it was written.

The seam. WbDrawDispatcher's RHI arm borrows its pass from IWorldPassScope
rather than opening one, so a viewport that opens a pass of its own has to
publish it there for the span of the draw. Publish is on the interface now for
that. It does not nest: the world phase has closed its own pass by the time
private presentation runs, which is where these viewports have always drawn.

Gates. Release build green. App tests 4,129/3 skips; complete Release suite
9,192/5 (one solution-wide run reported a single App failure that did not
reproduce in the App suite alone or in a second solution-wide run - the
documented rerun-singly flake class; the failing test name was not surfaced by
the runner and is not carried forward as a claim). Strict GL offline pixel gate
against 08ffe141: 3.55e-05, 20 differing pixels of 563,200, inside the
documented 9-31 band. 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.

And the two captures the offline gate cannot reach, both connected and both
inspected. The Vulkan paperdoll (artifacts/v6l-vk-paperdoll3) renders the doll
upright, in armour, at the right scale, over a transparent background, and is
indistinguishable from the same capture on GL taken minutes later
(artifacts/v6l-gl-paperdoll) - which is also the no-regression check for the V
change. Particles (artifacts/v6l-vk-poi versus artifacts/v6l-gl-poi, cropped
4x at artifacts/crop-vk-glow.png and crop-gl-glow.png): Holtburg's forge plume
and its field of glint sprites draw in the same places with the same alpha
compositing on both backends, the puffs differing only in phase because two
launches cannot agree on an emitter's age.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:05:24 +02:00

216 lines
8.2 KiB
C#

using AcDream.App.Rendering.Gpu;
using AcDream.Core.Lighting;
namespace AcDream.App.Rendering;
/// <summary>
/// A buffer range reduced to the three values a bind needs.
///
/// <para><see cref="GpuRingAllocation"/> is a <c>ref struct</c> — deliberately,
/// so nothing can outlive the frame's memory — but the buffer reference plus its
/// offset and size are ordinary values and stay valid for as long as that memory
/// does. That is what lets one writer publish a section and several renderers
/// bind it later in the same frame without recopying.</para>
/// </summary>
internal readonly record struct GpuBufferSection(
IGpuBuffer? Buffer,
uint OffsetBytes,
uint SizeBytes)
{
public bool IsValid => Buffer is not null && SizeBytes > 0;
}
/// <summary>
/// Campaign V slice V6j: the three sections GL binds frame-globally and Vulkan
/// cannot.
///
/// <para>On GL the SceneLighting UBO (set 1 binding 1), the per-cell clip regions
/// (set 0 binding 2) and the terrain clip block (set 1 binding 2) are each bound
/// once, to a global binding point, and every consumer inherits them. Vulkan has
/// no global binding points: a descriptor set is bound per draw, and a renderer
/// that binds its own buffers selects the descriptor scope those sections have to
/// land in (plan §5.5.14 item 2). So the writers PUBLISH here and each renderer
/// binds them inside the pass, after its own binds.</para>
///
/// <para>Borrowed for the frame that publishes it — the sections are ring slices
/// and die when the frame retires.</para>
/// </summary>
internal sealed class WorldFrameSections
{
/// <summary>Set 1 binding 1 — <c>SceneLighting</c>.</summary>
public GpuBufferSection SceneLighting { get; set; }
/// <summary>Set 0 binding 2 — the per-cell <c>CellClip</c> table.</summary>
public GpuBufferSection ClipRegions { get; set; }
/// <summary>Set 1 binding 2 — <c>TerrainClip</c>.</summary>
public GpuBufferSection TerrainClip { get; set; }
public void Reset()
{
SceneLighting = default;
ClipRegions = default;
TerrainClip = default;
}
}
/// <summary>
/// Campaign V slice V6j: the one render pass every world renderer records into,
/// borrowed rather than opened.
///
/// <para><b>Why they cannot each open one.</b> Under MSAA the frame's backbuffer
/// pass renders into a multisampled scratch image and RESOLVES it into the
/// swapchain image, storing <c>DONT_CARE</c> into the scratch — so a second pass
/// declaring <c>Load</c> would load undefined contents and lose everything the
/// first drew. The Vulkan backend also permits only one open pass per frame.
/// V4c's shape, where <c>WbDrawDispatcher</c> and <c>EnvCellRenderer</c> each
/// bracketed their own <c>Load</c>/<c>Store</c> pass, is therefore not available
/// on this backend (plan §5.5.12 item 5, §5.5.14 item 1).</para>
///
/// <para>So the world-scene phase opens the pass, publishes the encoder here for
/// the duration of <c>WorldSceneRenderer</c>, and every renderer borrows it.</para>
/// </summary>
internal interface IWorldPassScope
{
/// <summary>
/// Sample count of the pass, and therefore of every pipeline recorded into
/// it. Vulkan requires a pipeline's <c>rasterizationSamples</c> to match the
/// pass, and alpha-to-coverage does nothing at one sample — which is why
/// V4c's blanket <c>SampleCount = 1</c> is not portable (plan §5.5.14 item 6).
/// </summary>
int SampleCount { get; }
/// <summary>The open encoder, or null outside the world phase.</summary>
IGpuPassEncoder? CurrentEncoder { get; }
/// <summary>The open encoder, or a composition error if the phase is not bracketing.</summary>
IGpuPassEncoder RequireEncoder();
/// <summary>Colour-attachment width in pixels, for scissor and clear rectangles.</summary>
int AttachmentWidth { get; }
/// <summary>Colour-attachment height in pixels.</summary>
int AttachmentHeight { get; }
/// <summary>
/// Retail's interior depth clear, scoped to the live pass.
///
/// <para><c>RetailPViewPassExecutor</c> issues <c>glClear(GL_DEPTH_BUFFER_BIT)</c>
/// between the landscape slice and the interior cells. Splitting the pass to
/// get a depth load-op is exactly what the resolve forbids, so the backend
/// records <c>vkCmdClearAttachments</c> instead — reached through here, so the
/// pinned contract stays frozen and the backend-only verb stays inside the
/// backend (plan §5.5.14 item 3).</para>
/// </summary>
void ClearInteriorDepth();
/// <summary>The frame-global sections every renderer in this pass rebinds.</summary>
WorldFrameSections Sections { get; }
/// <summary>
/// Publishes <paramref name="encoder"/> as the pass every world renderer
/// borrows, for the duration of the returned scope.
///
/// <para>Campaign V slice V6l lifted this onto the interface for the
/// offscreen viewports. The world-scene phase is still the only publisher of
/// the frame's backbuffer pass, but the paperdoll and creature-appraisal
/// views open a pass of their OWN — a real render target, after the world
/// pass has closed — and then ask <c>WbDrawDispatcher</c> to draw one entity
/// into it. The dispatcher borrows its pass from here, so the viewport has to
/// be able to say which pass "here" means.</para>
///
/// <para>Publications do not nest: the backend permits one open pass at a
/// time, so a nested publication could only mean two owners believe they hold
/// the frame.</para>
/// </summary>
IDisposable Publish(IGpuPassEncoder encoder);
}
/// <summary>
/// Campaign V slice V6j: binds the frame-global sections inside a renderer's own
/// draw, which is the only place they can go on Vulkan.
///
/// <para>Each helper falls back to a zeroed ring slice when nothing published the
/// section. That is the same rule the GL arm already states for its own bindings
/// — bind at least one element so the shader never reads an unbound buffer —
/// applied to the three sections whose publisher runs outside the renderer.</para>
/// </summary>
internal static class WorldFrameSectionBinding
{
internal static void BindSceneLighting(
IGpuPassEncoder encoder,
WorldFrameSections sections,
IGpuFrame frame)
{
GpuBufferSection section = sections.SceneLighting;
if (!section.IsValid)
{
section = Zeroed(
frame,
SceneLightingUbo.SizeInBytes,
GpuRingUsage.Uniform);
}
encoder.BindUniformBuffer(
(uint)SceneLightingUbo.BindingPoint,
section.Buffer!,
section.OffsetBytes,
section.SizeBytes);
}
internal static void BindClipRegions(
IGpuPassEncoder encoder,
WorldFrameSections sections,
IGpuFrame frame)
{
GpuBufferSection section = sections.ClipRegions;
if (!section.IsValid)
{
// Slot 0 zeroed is retail's "no clip": count 0 means ungated.
section = Zeroed(
frame,
ClipFrame.CellClipStrideBytes,
GpuRingUsage.Storage);
}
encoder.BindStorageBuffer(
GpuBindingModel.StorageClipRegions,
section.Buffer!,
section.OffsetBytes,
section.SizeBytes);
}
internal static void BindTerrainClip(
IGpuPassEncoder encoder,
WorldFrameSections sections,
IGpuFrame frame)
{
GpuBufferSection section = sections.TerrainClip;
if (!section.IsValid)
{
section = Zeroed(
frame,
ClipFrame.TerrainUboBytes,
GpuRingUsage.Uniform);
}
encoder.BindUniformBuffer(
ClipFrame.TerrainClipUboBinding,
section.Buffer!,
section.OffsetBytes,
section.SizeBytes);
}
private static GpuBufferSection Zeroed(
IGpuFrame frame,
int byteCount,
GpuRingUsage usage)
{
GpuRingAllocation allocation = frame.AllocateRing(byteCount, usage);
allocation.Data.Clear();
return new GpuBufferSection(
allocation.Buffer,
allocation.OffsetBytes,
(uint)byteCount);
}
}