acdream/src/AcDream.App/Rendering/Gpu/Vk/VulkanRetainedUiScene.cs
Erik 95f8c25f31 feat(render): draw the retained UI on Vulkan, and fix the pass it exposed
Campaign V slice V6d, commit 3 of 3 — the evidence commit, which turned out to also be a bug-fix commit.

VulkanBringUpHost now builds a real UiHost and DebugLineRenderer on the Vulkan device and draws them after the V6c verification scene, in their own single-sampled load/store passes against the backbuffer — the same shape the GL client's HUD phase has. Nothing in the retained stack is backend-aware: UiRoot walks a real widget tree, each widget draws through UiRenderContext, and UiHost.Draw brackets it with TextRenderer.Begin/Flush. What it cannot be is the game's own UI, because the retail tree is built from LayoutDesc and DAT chrome by TextureCache, which stays a GL type until V4t; the sprites here are generated instead. The widget rectangles are authored at known pixel offsets from the top-left and nothing is mirror-symmetric, so a wrong Y flip would put the title bar at the bottom.

The frame this produced was wrong, and usefully so. Whole runs of the debug-line figure were missing. Vulkan's rasterization-order guarantees are scoped to one render-pass instance; between two instances writing the same attachment there is no implicit ordering, and that includes a multisample RESOLVE, which is part of the render pass and therefore equally unordered against what follows. TransitionBackbufferForRendering emitted its acquire barrier once per frame and returned for every pass after the first, so the second and third passes raced the first one's resolve. V6c's frame had exactly one backbuffer pass and could not see this; V6d's has three. A later backbuffer pass now gets a colour-attachment dependency instead of nothing, and keeps ColorAttachmentOptimal as its old layout rather than Undefined, which would have licensed discarding everything drawn so far. Every line renders continuously afterwards.

Inspection of artifacts/vk-ui/vulkan-bringup.png against the authored layout, by pixel probe:

The header panel is authored at (24,18), 420x96. Its tiled chrome fills exactly x 24..443 and y 18..113 — one pixel outside on any edge is the clear colour. The tile's lit edge appears at the top and left of every cell, so texture row 0 lands at the top and the V axis is not flipped. Both labels read left to right, right side up, through the font-coverage branch. The nested panel's border samples exactly (153,191,255) against an authored (0.6,0.75,1.0), unblended — the untextured branch is bit-exact. The badge sprite is authored at (460,58), 64x64, and its gradient starts at x=460 with the clear colour at 455 — the RGBA-modulate branch, sampling a table slot. The two debug-line segments land on their computed screen coordinates. All three fragment branches, the pixel-to-NDC mapping, the top-left origin, straight-alpha blending and table sampling are therefore all confirmed on Vulkan, which is everything the offline GL gate confirms about the same code on GL.

The plan's V6 milestone is amended rather than claimed: "full game frame on Vulkan" is not reachable while V4c/V4d are parked and the world renderers and TextureCache are still raw GL, so V6 delivers the backend plus the two renderers that can use it today. The accumulated user-gate table gains a V6d row for the paperdoll/appraisal viewport sprite — the one retained-UI texture the offline scene never draws, on a slice that changed how every UI texture is sampled.

App tests 4,057 passed / 3 skipped, unchanged. Offline pixel gate against f6f58a12: differing fraction 3.20e-05, 18 pixels of 563,200, inside the documented 15-23 pixel noise band — as expected, since this commit touches only Vulkan files and the campaign doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:03:58 +02:00

264 lines
10 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.UI;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6d: the retained UI, drawn on Vulkan by the same
/// <see cref="TextRenderer"/> the GL client draws it with.
///
/// <para><b>What this proves and what it does not.</b> The whole retained stack
/// runs here — <see cref="UiRoot"/> walks a real widget tree, each widget draws
/// through <see cref="UiRenderContext"/>, and <see cref="UiHost.Draw"/> brackets
/// it with <see cref="TextRenderer.Begin"/>/<see cref="TextRenderer.Flush"/>.
/// Nothing about that path is Vulkan-aware. What it cannot be is the game's own
/// UI: the retail widget tree is built from LayoutDesc and DAT chrome by
/// <c>TextureCache</c>, which is still a GL type until slice V4t, so the sprites
/// here are generated rather than decoded. The geometry of the exercise is what
/// matters — every branch of <c>ui_text.frag</c>, the pixel-to-NDC mapping, and
/// the top-left origin — and all three are visible in one screenshot.</para>
///
/// <para><b>Deliberately asymmetric and labelled</b>, for the same reason
/// <see cref="VulkanRhiScene"/> is: the one thing a symmetric layout could never
/// prove is that the negative-viewport Y flip and the capture path agree. Widget
/// rectangles are authored at known pixel coordinates from the TOP-LEFT corner,
/// so a wrong flip puts the title bar at the bottom and is unmissable.</para>
/// </summary>
internal sealed class VulkanRetainedUiScene : IDisposable
{
/// <summary>Where the header panel sits, in pixels from the top-left. Inspected in the capture.</summary>
internal const int HeaderLeft = 24;
internal const int HeaderTop = 18;
internal const int HeaderWidth = 420;
internal const int HeaderHeight = 96;
private readonly MutableFrameSource _frames = new();
private readonly UiHost _host;
private readonly DebugLineRenderer _lines;
private readonly BitmapFont? _font;
private readonly List<IGpuTexture> _textures = [];
private bool _disposed;
internal VulkanRetainedUiScene(IGpuDevice device, string shaderDirectory)
{
ArgumentNullException.ThrowIfNull(device);
byte[]? ttf = BitmapFont.TryLoadSystemMonospaceFont();
_font = ttf is null ? null : new BitmapFont(device, ttf, pixelHeight: 18f);
_host = new UiHost(device, _frames, shaderDirectory, _font);
_lines = new DebugLineRenderer(device, _frames, shaderDirectory);
uint chrome = CreateTexture(device, "vk-ui-chrome", BuildChromeTile(), 16, 16, nearest: false);
uint icon = CreateTexture(device, "vk-ui-icon", BuildIcon(), 32, 32, nearest: true);
BuildTree(chrome, icon);
}
/// <summary>True when a system font was found; without one no glyph draws happen.</summary>
internal bool HasFont => _font is not null;
/// <summary>
/// Draws one frame of the retained UI, plus a debug-line figure so both
/// renderers ported this slice are exercised in the same frame. Each opens
/// its own load/store pass against the backbuffer, exactly as they do in the
/// GL client's HUD phase.
/// </summary>
internal void Render(IGpuFrame frame, uint width, uint height, double seconds)
{
ArgumentNullException.ThrowIfNull(frame);
_frames.CurrentFrame = frame;
try
{
// A 3-D figure through the second ported renderer. Identity view
// with a plain orthographic-ish projection keeps it in NDC, which is
// all this needs to demonstrate: the pipeline binds, the ring
// allocation lands, and the push-constant matrix arrives.
// Diagonal on purpose: an axis-aligned segment can land exactly on a
// pixel boundary and rasterize sparsely, which reads as a bug in a
// screenshot when it is only the diamond-exit rule. A slope makes
// the evidence unambiguous, and the two segments are different
// colours so the per-vertex attribute is visible too.
_lines.Begin();
float sweep = (float)Math.Sin(seconds) * 0.05f;
_lines.AddLine(new Vector3(-0.92f, -0.30f, 0f), new Vector3(-0.30f, -0.68f, 0f), new Vector3(1f, 0.85f, 0.2f));
_lines.AddLine(new Vector3(-0.30f, -0.68f, 0f), new Vector3(0.34f, -0.36f + sweep, 0f), new Vector3(0.2f, 1f, 0.6f));
_lines.Flush(Matrix4x4.Identity, Matrix4x4.Identity);
_host.Tick(0.016);
_host.Draw(new Vector2(width, height));
}
finally
{
_frames.CurrentFrame = null;
}
}
/// <summary>
/// A header panel with a tiled sprite background, a nested solid panel with
/// a border, two labels, and a loose icon. Between them these cover all
/// three fragment branches: sprite modulate, flat vertex colour (both the
/// rect bucket and DrawFill), and font coverage.
/// </summary>
private void BuildTree(uint chrome, uint icon)
{
var header = new UiPanel
{
Name = "vk-header",
Left = HeaderLeft,
Top = HeaderTop,
Width = HeaderWidth,
Height = HeaderHeight,
BackgroundSprite = 1,
SpriteResolve = _ => (chrome, 16, 16),
};
header.AddChild(new UiLabel
{
Name = "vk-title",
Left = 12,
Top = 10,
Width = 380,
Height = 22,
Text = "acdream - retained UI on Vulkan",
TextColor = new Vector4(1f, 0.94f, 0.72f, 1f),
});
header.AddChild(new UiLabel
{
Name = "vk-subtitle",
Left = 12,
Top = 34,
Width = 380,
Height = 22,
Text = "top-left origin - slice V6d",
TextColor = new Vector4(0.75f, 0.9f, 1f, 1f),
});
var inner = new UiPanel
{
Name = "vk-inner",
Left = 12,
Top = 58,
Width = 260,
Height = 28,
BackgroundColor = new Vector4(0.05f, 0.08f, 0.16f, 0.85f),
BorderColor = new Vector4(0.6f, 0.75f, 1f, 1f),
BorderThickness = 2f,
};
inner.AddChild(new UiLabel
{
Left = 8,
Top = 6,
Width = 240,
Height = 18,
Text = "fill + border + glyphs",
TextColor = new Vector4(1f, 1f, 1f, 1f),
});
header.AddChild(inner);
// Off to one side and near the bottom, so nothing about the layout is
// mirror-symmetric in either axis.
var badge = new UiTextureElement
{
Name = "vk-icon",
Left = HeaderLeft + HeaderWidth + 16,
Top = HeaderTop + 40,
Width = 64,
Height = 64,
Texture = icon,
};
_host.Root.AddChild(header);
_host.Root.AddChild(badge);
}
private uint CreateTexture(IGpuDevice device, string name, byte[] rgba, int width, int height, bool nearest)
{
IGpuTexture texture = device.CreateTexture(new GpuTextureDescription(
name,
GpuTextureKind.Texture2D,
GpuTextureFormat.Rgba8Unorm,
width,
height,
LayerCount: 1,
MipLevelCount: 1));
_textures.Add(texture);
texture.Upload(0, 0, rgba);
IGpuSampler sampler = device.CreateSampler(nearest
? GpuSamplerDescription.UiNearest
: GpuSamplerDescription.WorldRepeat);
return UiTextureTableHandle.FromSlot(device.RegisterTexture(texture, sampler));
}
/// <summary>A 16x16 tile with a lit top-left corner, so tiling and orientation both read.</summary>
private static byte[] BuildChromeTile()
{
const int extent = 16;
var pixels = new byte[extent * extent * 4];
for (int y = 0; y < extent; y++)
{
for (int x = 0; x < extent; x++)
{
bool edge = x == 0 || y == 0;
byte r = edge ? (byte)0xC8 : (byte)0x2A;
byte g = edge ? (byte)0xA0 : (byte)0x24;
byte b = edge ? (byte)0x40 : (byte)0x1C;
int offset = ((y * extent) + x) * 4;
pixels[offset + 0] = r;
pixels[offset + 1] = g;
pixels[offset + 2] = b;
pixels[offset + 3] = 0xF0;
}
}
return pixels;
}
/// <summary>A 32x32 badge: red at the top, blue at the bottom, opaque ring.</summary>
private static byte[] BuildIcon()
{
const int extent = 32;
var pixels = new byte[extent * extent * 4];
for (int y = 0; y < extent; y++)
{
for (int x = 0; x < extent; x++)
{
float dx = (x - 15.5f) / 15.5f;
float dy = (y - 15.5f) / 15.5f;
bool inside = (dx * dx) + (dy * dy) <= 1f;
int offset = ((y * extent) + x) * 4;
pixels[offset + 0] = (byte)(inside ? 255 - (y * 6) : 0);
pixels[offset + 1] = (byte)(inside ? 60 : 0);
pixels[offset + 2] = (byte)(inside ? y * 7 : 0);
pixels[offset + 3] = (byte)(inside ? 255 : 0);
}
}
return pixels;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_lines.Dispose();
_host.Dispose();
_font?.Dispose();
for (int i = _textures.Count - 1; i >= 0; i--)
_textures[i].Dispose();
_textures.Clear();
}
/// <summary>
/// The host drives frames itself (it owns swapchain acquire and the
/// out-of-date policy), so it hands the open frame to the renderers rather
/// than the other way round. Same contract as
/// <c>GpuDeviceFrameLifetime</c>, without owning the begin/end.
/// </summary>
private sealed class MutableFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame { get; set; }
}
}