acdream/src/AcDream.App/Rendering/TextRenderer.cs
Erik 8a7a0837e1 feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend
Vulkan is the sole, user-signed-off backend (V10 landed) and step 1
already removed ImGui/Studio/DevTools. This step deletes the GL
rendering backend itself: every Gpu/Gl/** implementation, the Wb
ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/
BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache,
RenderBootstrap, and RenderFrameGlStateController.

GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/
OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses
its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and
RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone —
there is nothing left to select between. The five world-draw dual-arm
renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer,
ParticleRenderer, SkyRenderer) and the composition roots
(WorldRenderComposition, HostInputCameraComposition,
LivePresentationComposition, FrameRootComposition) collapse to their
RHI-only arm. GL-only diagnostic properties with a live external reader
(DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op
rather than disappearing, since the reader is out of this commit's
scope.

A few GL-flavored mechanisms turned out to be backend-neutral once
isolated: GlConstructionCleanupLedger is renamed
ResourceConstructionCleanupLedger (exception-chain walking has nothing
to do with GL), and GlfwNativePlatformProbe moved out of the otherwise
GL-only GraphicalCapabilityRecord.cs into
GraphicalWindowBackendSelection.cs before the rest of that file was
deleted.

Test files with no surviving subject are deleted outright
(GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests,
PortalDepthShaderParityTests, TextureCacheBindlessTests,
TextRendererFailureSafetyTests, ClipFrameUploadTests, every
Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests);
others get their dead GL-only members trimmed while their live
assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now
reads GpuBindingModel.StorageClipRegions, the same binding index under
its new backend-neutral name; GpuResourceRetirementTransactionTests
drops its OpenGLGraphicsDevice-subclassing test double and the two GL
queue tests it existed for). EnvCellRendererTests' construction helper
now builds a real ObjectMeshManager via VulkanMeshPipelineDevice
instead of passing null through a null-forgiving operator, since the
RHI constructor never tolerated a null mesh manager and the old GL
constructor (which did) is gone.

Deferred to the next two steps, deliberately not touched here: the
Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl
(WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale
csproj comment (the package itself is still load-bearing —
TextureFormat and friends are used well beyond the deleted
ManagedGLUniformBuffer), and the CI/gate scripts.

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors.
Tests: full-solution `dotnet test` green across every project
(App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all
others 100%); the 2 App.Tests names that flake under full-suite
parallel execution (#250-family, documented pre-existing) pass in
isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:19:53 +02:00

472 lines
22 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering;
/// <summary>
/// 2D batched quad renderer for text + solid rectangles. Coordinates are in
/// screen pixels with origin top-left, +X right, +Y down. Call
/// <see cref="Begin"/> at the start of a HUD pass, queue geometry via
/// <see cref="DrawString"/> / <see cref="DrawRect"/>, then <see cref="Flush"/>.
///
/// Campaign V slice V4a: the <c>ui_text</c> shader compiles through
/// <see cref="IGpuDevice.CreatePipeline"/> (one <see cref="IGpuPipeline"/>,
/// replacing the old hand-rolled <c>Shader</c> class) and its three
/// fence-buffered per-flight VBOs are gone in favour of a per-<see cref="IGpuFrame"/>
/// ring allocation per draw bucket.
///
/// <para>Campaign V slice V6d finished the job: this class no longer touches
/// GL at all, and is the first production renderer that draws on either
/// backend. Three things had to change for that.</para>
///
/// <para><b>Textures.</b> V4a kept a classic <c>glActiveTexture</c>/<c>glBindTexture</c>
/// path because <see cref="DrawSprite"/> receives an arbitrary texture from
/// dozens of widget call sites. Those textures are all registered into the
/// device's global table by <c>TextureCache</c> already — the classic path was
/// only ever consuming the raw GL name that registration also produced. They
/// now travel as <see cref="UiTextureTableHandle"/> values instead, and the
/// shader samples the table.</para>
///
/// <para><b>Loose uniforms.</b> <c>uScreenSize</c> and <c>uUseTexture</c> moved
/// into the pinned <see cref="GpuPushConstants"/> block. Screen size is the
/// block's two spare scalars; the sampling mode is derived from which of the
/// two texture-table slots is assigned, so no new field was needed. See
/// <c>ui_text.frag</c> for the three cases.</para>
///
/// <para><b>GL capability state.</b> The pass no longer disables multisampling
/// by hand — <c>GlGpuPassEncoder</c> derives that from the pass's SampleCount
/// and restores it on close, which is where pass state belongs and which the
/// Vulkan backend gets from the pass description for free.</para>
///
/// Uses per-bucket ring allocations flushed in up to three draw calls per
/// layer, to avoid a per-vertex "use texture" flag. Rects are drawn first so
/// text sits on top of background panels.
/// </summary>
public sealed class TextRenderer : IDisposable
{
// internal: slice V6l's stride-equals-the-uploaded-record gate asserts the
// layout against the producer's own float count rather than a literal.
internal const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4)
private const int VertexStrideBytes = FloatsPerVertex * sizeof(float);
internal static readonly GpuVertexLayout SpriteVertexLayout = GpuVertexLayout.Interleaved(
strideBytes: VertexStrideBytes,
[
new GpuVertexAttribute(0, GpuVertexFormat.Float2, 0),
new GpuVertexAttribute(1, GpuVertexFormat.Float2, 8),
new GpuVertexAttribute(2, GpuVertexFormat.Float4, 16),
]);
private readonly ICurrentGpuFrameSource _frameSource;
private readonly IGpuPipeline _pipeline;
private sealed class SpriteSeg { public uint Texture; public readonly List<float> Verts = new(256); }
// Submission-ordered sprite segments: consecutive DrawSprite calls with the
// SAME texture batch into one segment; a texture change starts a new segment.
// Drawing segments in submission order preserves painter z-order for
// sprite-on-sprite UI. (The old per-texture dictionary drew a REUSED texture
// at its FIRST-insertion point, so later bar sprites covered glyphs emitted
// earlier via the shared dat-font atlas — the stamina/mana numbers vanished.)
private readonly List<float> _textBuf = new(8192);
private readonly List<float> _rectBuf = new(1024);
private readonly List<SpriteSeg> _spriteSegs = new();
private int _segUsed;
private int _textVerts;
private int _rectVerts;
private Vector2 _screenSize;
/// <summary>
/// No longer meaningful post-V4a: per-frame vertex data comes from the
/// device's shared per-flight ring rather than a VBO this class owns. Kept
/// (returning 0) so <see cref="RenderFrameDiagnosticSources"/>'s telemetry
/// read still compiles; the dynamic-buffer dimension it reported is now a
/// device-wide, not a per-renderer, concern.
/// </summary>
internal long DynamicBufferCapacityBytes => 0;
// Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text
// buckets, so open popups/menus composite on top of EVERYTHING, including translucent
// rect panel backgrounds (which otherwise always win because rects flush after
// sprites). Routed by OverlayMode; the UI root sets it for the popup traversal.
private readonly List<float> _overlayTextBuf = new(1024);
private readonly List<float> _overlayRectBuf = new(256);
private readonly List<SpriteSeg> _overlaySpriteSegs = new();
private int _overlaySegUsed;
private int _overlayTextVerts;
private int _overlayRectVerts;
/// <summary>When true, Draw* calls route to the overlay layer (flushed last, on top
/// of all normal-layer geometry). Set by the UI root around the popup/overlay pass.</summary>
public bool OverlayMode { get; set; }
// internal, not public: IGpuDevice/ICurrentGpuFrameSource are internal
// types (the pinned RHI contract). The TextRenderer TYPE stays public —
// only construction is restricted — so existing public members that hold
// or return a TextRenderer (e.g. UiHost.TextRenderer) need no visibility
// change of their own.
internal TextRenderer(IGpuDevice device, ICurrentGpuFrameSource frameSource, string shaderDir)
{
ArgumentNullException.ThrowIfNull(device);
_frameSource = frameSource ?? throw new ArgumentNullException(nameof(frameSource));
ArgumentException.ThrowIfNullOrWhiteSpace(shaderDir);
_pipeline = device.CreatePipeline(new GpuPipelineDescription
{
Name = "ui-text",
Shaders = new GpuShaderSet("ui_text"),
VertexLayout = SpriteVertexLayout,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = GpuBlendMode.StraightAlpha,
// The retained UI is a self-contained 2-D pass — depth is
// irrelevant and the world pass's alpha-to-coverage state must not
// leak in (feedback_render_self_contained_gl_state). Multisampling
// is the pass's business rather than the pipeline's and comes from
// the SampleCount below; see GlGpuPassEncoder's constructor.
Depth = GpuDepthState.Disabled,
Cull = GpuCullMode.None,
AlphaToCoverage = false,
ColorWrite = true,
SampleCount = 1,
});
}
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
public void Begin(Vector2 screenSize)
{
_screenSize = screenSize;
_textBuf.Clear();
_rectBuf.Clear();
_segUsed = 0; // pool the SpriteSeg objects across frames
_textVerts = 0;
_rectVerts = 0;
_overlayTextBuf.Clear();
_overlayRectBuf.Clear();
_overlaySegUsed = 0;
_overlayTextVerts = 0;
_overlayRectVerts = 0;
OverlayMode = false;
}
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
public void DrawRect(float x, float y, float w, float h, Vector4 color)
{
if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; }
else { AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); _rectVerts += 6; }
}
/// <summary>Draw a solid-colour quad through the SPRITE bucket (and the overlay layer
/// when active), so it composites in painter order with sprites + dat-font text. Use
/// this — not <see cref="DrawRect"/> — for a panel BACKGROUND that text draws on top of:
/// DrawRect's bucket always flushes after all sprites, so a rect background would cover
/// the text instead.
///
/// <para>Slice V6d: this used to route through a 1×1 white texture, relying on
/// white × colour = colour. The shader now has an untextured branch that produces
/// the same value directly (multiplying by exactly 1.0 changes no bits), so the
/// white texture is gone and the fill is a sprite segment with no texture.</para>
/// </summary>
public void DrawFill(float x, float y, float w, float h, Vector4 color)
=> DrawSprite(UiTextureTableHandle.None, x, y, w, h, 0f, 0f, 1f, 1f, color);
/// <summary>Draw a 1-pixel-thick outline rect.</summary>
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{
// top, bottom, left, right
DrawRect(x, y, w, thickness, color);
DrawRect(x, y + h - thickness, w, thickness, color);
DrawRect(x, y, thickness, h, color);
DrawRect(x + w - thickness, y, thickness, h, color);
}
/// <summary>
/// Draw a single line of text at (x,y) where (x,y) is the top-left of the
/// typographic block. Handles '\n' as a line break.
/// </summary>
public void DrawString(BitmapFont font, string text, float x, float y, Vector4 color)
=> DrawStringCore(
font, text, x, y, color,
clip: false, 0f, 0f, 0f, 0f);
/// <summary>
/// Draw a bitmap-font string clipped to an absolute screen-space rectangle.
/// The retained UI uses this overload when a text element intersects its authored
/// surface edge. Retail <c>UIElement_Text::DrawSelf @ 0x00467AA0</c> clips the
/// individual glyph blits instead of discarding the whole line.
/// </summary>
internal void DrawStringClipped(
BitmapFont font,
string text,
float x,
float y,
Vector4 color,
float clipLeft,
float clipTop,
float clipRight,
float clipBottom)
=> DrawStringCore(
font, text, x, y, color,
clip: true, clipLeft, clipTop, clipRight, clipBottom);
private void DrawStringCore(
BitmapFont font,
string text,
float x,
float y,
Vector4 color,
bool clip,
float clipLeft,
float clipTop,
float clipRight,
float clipBottom)
{
float cursorX = x;
// The caller provides top-y; shift to baseline for glyph offset math.
float baseline = y + font.Ascent;
for (int i = 0; i < text.Length; i++)
{
char c = text[i];
if (c == '\n')
{
cursorX = x;
baseline += font.LineHeight;
continue;
}
if (!font.TryGetGlyph(c, out var g))
{
// Unknown glyph — skip its advance width if '?' exists.
if (font.TryGetGlyph('?', out var q))
cursorX += q.Advance;
continue;
}
float gx = cursorX + g.OffsetX;
float gy = baseline + g.OffsetY;
float gw = g.Width;
float gh = g.Height;
float u0 = g.UvMinX;
float v0 = g.UvMinY;
float u1 = g.UvMaxX;
float v1 = g.UvMaxY;
if (gw > 0 && gh > 0
&& (!clip || QuadClipper.TryClip(
clipLeft, clipTop, clipRight, clipBottom,
ref gx, ref gy, ref gw, ref gh,
ref u0, ref v0, ref u1, ref v1)))
{
if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, u0, v0, u1, v1, color); _overlayTextVerts += 6; }
else { AppendQuad(_textBuf, gx, gy, gw, gh, u0, v0, u1, v1, color); _textVerts += 6; }
}
cursorX += g.Advance;
}
}
/// <summary>
/// Draw a textured sprite quad in screen pixel space with an explicit
/// source-UV rectangle (for 9-slice / atlas sub-regions).
///
/// <paramref name="texture"/> is a <see cref="UiTextureTableHandle"/> — a
/// one-based index into the device's global texture table, which is what
/// <c>TextureCache</c> now hands out in place of the raw GL name it used to.
/// Segments batch per handle and draw in submission order.
/// <see cref="UiTextureTableHandle.None"/> draws the tint alone; every
/// widget guards against passing it, and <see cref="DrawFill"/> uses it
/// deliberately.
/// </summary>
public void DrawSprite(uint texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint)
{
SpriteSeg seg = OverlayMode
? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
: NextSpriteSeg(_spriteSegs, ref _segUsed, texture);
AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint);
}
/// <summary>
/// Encodes a <see cref="GpuTextureSlot"/> produced by the paperdoll/appraisal
/// viewport transitional seam (<c>GlGpuDevice.RegisterExternalColorTexture</c>,
/// campaign doc §7.1) as the handle <see cref="DrawSprite"/> takes. Returns
/// <see cref="UiTextureTableHandle.None"/> for an unassigned slot.
///
/// <para>Slice V6d: this used to resolve the slot back to a raw GL texture
/// name for the classic binding path. Now that the UI samples the table, the
/// externally-owned texture needs no special treatment at draw time at all —
/// registration already put it in the table, and this is a plain encode.</para>
/// </summary>
internal static uint ResolveExternalTextureSlot(GpuTextureSlot slot) =>
UiTextureTableHandle.FromSlot(slot);
/// <summary>Pick the sprite segment for <paramref name="texture"/>: extend the current
/// same-texture run, else reuse a pooled segment, else allocate. Submission order is
/// preserved (painter z-order for sprite-on-sprite UI).</summary>
private static SpriteSeg NextSpriteSeg(List<SpriteSeg> segs, ref int used, uint texture)
{
if (used > 0 && segs[used - 1].Texture == texture)
return segs[used - 1];
if (used < segs.Count)
{
var s = segs[used++];
s.Texture = texture;
s.Verts.Clear();
return s;
}
var ns = new SpriteSeg { Texture = texture };
segs.Add(ns);
used++;
return ns;
}
private static void AppendQuad(List<float> buf,
float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 color)
{
// Two triangles (6 verts). CCW in pixel space is clockwise in NDC
// because the vertex shader flips Y, so OpenGL's default front-face
// is GL_CCW — we rely on cull-face being disabled during HUD pass.
// (x, y) ─ (x+w, y)
// │ │
// (x, y+h) ─ (x+w, y+h)
//
// Triangle 1: (x,y) (x+w,y+h) (x+w,y)
// Triangle 2: (x,y) (x,y+h) (x+w,y+h)
void V(float px, float py, float pu, float pv)
{
buf.Add(px); buf.Add(py);
buf.Add(pu); buf.Add(pv);
buf.Add(color.X); buf.Add(color.Y); buf.Add(color.Z); buf.Add(color.W);
}
V(x, y, u0, v0);
V(x + w, y + h, u1, v1);
V(x + w, y, u1, v0);
V(x, y, u0, v0);
V(x, y + h, u0, v1);
V(x + w, y + h, u1, v1);
}
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
public void Flush(BitmapFont? font)
{
bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0;
bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0;
if (!anyNormal && !anyOverlay) return;
IGpuFrame frame = _frameSource.CurrentFrame
?? throw new InvalidOperationException(
"TextRenderer.Flush requires an open IGpuFrame (see GpuDeviceFrameLifetime) — " +
"the host must drive IGpuDevice.BeginFrame() before rendering the retained UI.");
// Retained UI is a private render pass: an upload or draw failure must
// not leak its depth/cull/blend/MSAA state into a later recoverable
// frame. Slice V6d: the encoder's own `using` is that guarantee — the
// GL backend captures every ambient capability a pipeline bind can
// change when the pass opens and restores it on close, including when
// either DrawLayer call throws. That replaced this renderer's private
// GL state scope, which restored a strict subset of the same values.
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{
Name = "ui-text",
// GL has no framebuffer-implicit "pass" of its own; this slice's
// transitional shape (campaign doc §4's GpuPassDescription remarks)
// opens a Load/Store pass against the backbuffer so clears and
// framebuffer management stay owned by the frame spine, exactly as
// today, while this renderer records through the encoder.
Color = new GpuColorAttachment(
Target: null,
Load: GpuLoadOp.Load,
Store: GpuStoreOp.Store,
ClearColor: default),
Depth = null,
SampleCount = 1,
});
encoder.BindPipeline(_pipeline);
// LAYERED compositing for the UI (background → fill → text):
// 1. RGBA dat sprites — window chrome / panel backgrounds (behind)
// 2. Untextured rects — widget fills (e.g. vital bars) on the chrome
// 3. Text glyphs — on top
// Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs,
// so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text)
// composite on top, in that order. The OVERLAY layer repeats all three
// AFTER the normal layer, so open popups beat even the rect backgrounds.
DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font, frame, encoder);
DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font, frame, encoder);
}
/// <summary>Draw one compositing layer: sprites (submission order, one call per
/// texture) → untextured rects → debug-font text. Shared by the normal and overlay
/// layers; GL state + shader are set up by <see cref="Flush"/>.</summary>
private void DrawLayer(
List<SpriteSeg> spriteSegs, int segUsed,
List<float> rectBuf, int rectVerts,
List<float> textBuf, int textVerts, BitmapFont? font,
IGpuFrame frame, IGpuPassEncoder encoder)
{
// 1. RGBA dat sprites — one draw call per distinct texture-table slot.
if (segUsed > 0)
{
for (int i = 0; i < segUsed; i++)
{
var seg = spriteSegs[i];
if (seg.Verts.Count == 0) continue;
SetTextures(encoder, colorHandle: seg.Texture, coverageHandle: UiTextureTableHandle.None);
DrawRing(frame, encoder, seg.Verts);
}
}
// 2. Untextured rects — widget fills on top of the chrome.
if (rectVerts > 0)
{
SetTextures(encoder, UiTextureTableHandle.None, UiTextureTableHandle.None);
DrawRing(frame, encoder, rectBuf);
}
// 3. Textured debug-font text glyphs on top. The atlas is single-channel
// coverage, which is the coverage slot rather than the colour one.
if (textVerts > 0 && font is not null)
{
SetTextures(encoder, UiTextureTableHandle.None, coverageHandle: font.TextureId);
DrawRing(frame, encoder, textBuf);
}
}
/// <summary>
/// Writes the shared push-constant block for one draw bucket: the screen
/// size the vertex stage maps pixels to NDC with, and the two texture-table
/// slots whose assignment selects the fragment stage's sampling mode.
/// At most one of the two handles is ever a real texture.
/// </summary>
private void SetTextures(IGpuPassEncoder encoder, uint colorHandle, uint coverageHandle)
{
GpuPushConstants constants = GpuPushConstants.Default;
constants.ParamA = _screenSize.X;
constants.ParamB = _screenSize.Y;
constants.TextureIndexA = UiTextureTableHandle.ToSlot(colorHandle).Index;
constants.TextureIndexB = UiTextureTableHandle.ToSlot(coverageHandle).Index;
encoder.SetPushConstants(constants);
}
/// <summary>
/// Allocates a ring range from the current frame, copies <paramref name="buf"/>
/// into it, and issues one non-indexed draw. Replaces the old growable
/// per-flight VBO + <c>BufferSubData</c> pattern: every UI vertex upload is
/// now the frame's shared ring, reset once per frame by
/// <see cref="AcDream.App.Rendering.Gpu.Vk.VulkanGpuDevice.BeginFrame"/>
/// (the raw-GL device's equivalent reset was deleted at Campaign V slice V11).
/// </summary>
private static void DrawRing(IGpuFrame frame, IGpuPassEncoder encoder, List<float> buf)
{
if (buf.Count == 0)
return;
GpuRingAllocation allocation = frame.AllocateRing(buf.Count * sizeof(float), GpuRingUsage.Vertex);
CollectionsMarshal.AsSpan(buf).CopyTo(allocation.AsSpan<float>());
encoder.BindVertexBuffer(0, allocation.Buffer, allocation.OffsetBytes);
encoder.Draw((uint)(buf.Count / FloatsPerVertex), 1, 0, 0);
}
public void Dispose() => _pipeline.Dispose();
}