AD-98's fixed-canvas stretch (73041d70) scales every retained-UI quad at
TextRenderer.AppendQuad, but the live gate reported it JAGGED — text
especially. Cause: dat-font glyph atlases and IconComposer's composited
icons upload nearest (TextureCache.UploadUiTexture's UiNearestRepeat
sampler) — correct at the native 1:1 scale (pixel-exact retail art), but
aliased once magnified 2.4x1.8. Chrome/background art was already fine:
it uploads through GpuSamplerDescription.WorldRepeat (linear) by default.
Retail's own fixed-canvas presentation is a single bilinear-filtered
frame blit, never a per-texture stretch — this closes that gap one step
earlier, at the source texture, without adding RHI surface area.
- TextureCache.GetOrCreateLinearUiTwin: lazily registers a SECOND table
slot for a nearest handle's IGpuTexture, sampled WorldRepeat (linear)
instead of nearest — no re-decode, no re-upload, no extra memory-ledger
bytes. Returns the handle unchanged for anything never registered
nearest (chrome, UiTextureTableHandle.None), so it's a cheap
unconditional probe. Twin slots are released in Dispose without
double-disposing the shared texture.
- TextRenderer.LinearTwinResolver + the DrawSprite chokepoint: swaps a
sprite's texture handle through the resolver only while
CanvasScale != One. At CanvasScale == One the resolver is never even
called — zero overhead on the ordinary in-world/UI path.
- InteractionRetainedUiComposition wires the resolver to TextureCache
right after every UiHost acquisition (the lease can hand back a host
from a prior session against a fresh TextureCache).
- AD-98's register row gets one added sentence recording the fix.
Tests: TextRendererLinearTwinTests pins the renderer-side handle-swap
seam GPU-free (segment handle selection); TextureCacheLinearTwinTests
pins twin creation/reuse/dispose against RecordingGpuDevice. App suite
5097/3 skips (Release, ACDREAM_PROBE_LIVE_MOUNT=1 live-DAT probes
included). Full solution builds clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
589 lines
28 KiB
C#
589 lines
28 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Test-only snapshot of the current frame's queued NORMAL-layer sprite segments, in
|
||
/// submission order: (textureId, vertexCount, alpha of the segment's first vertex —
|
||
/// color.W at float index 7 of the 8-float vertex layout). Lets a unit test assert
|
||
/// that a draw call actually EMITTED sprite geometry — and with what alpha — without
|
||
/// a live GPU, constructing this renderer over the in-memory
|
||
/// <c>RecordingGpuDevice</c> test double. Campaign CH slice CH6c rider (CH6a/b
|
||
/// re-review): strengthens the grip-media regression guard past a bare
|
||
/// <c>SpriteFile != 0</c> check, which proves a sprite RESOLVED but not that
|
||
/// <see cref="DrawSprite"/> was ever called. <c>AcDream.App.Tests</c>-only via
|
||
/// <c>InternalsVisibleTo</c>.
|
||
/// </summary>
|
||
internal IReadOnlyList<(uint Texture, int VertexCount, float Alpha)> DebugSpriteSegments
|
||
{
|
||
get
|
||
{
|
||
var result = new List<(uint, int, float)>(_segUsed);
|
||
for (int i = 0; i < _segUsed; i++)
|
||
{
|
||
SpriteSeg seg = _spriteSegs[i];
|
||
float alpha = seg.Verts.Count > 0 ? seg.Verts[7] : 0f;
|
||
result.Add((seg.Texture, seg.Verts.Count / FloatsPerVertex, alpha));
|
||
}
|
||
return result;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Test-only: same submission-ordered segmentation as
|
||
/// <see cref="DebugSpriteSegments"/>, but exposing the FULL per-vertex float
|
||
/// buffer (8 floats/vertex: x,y,u,v,r,g,b,a — see <see cref="AppendQuad"/>,
|
||
/// 6 vertices/quad) instead of just texture/count/alpha. Needed by the
|
||
/// <c>UiRenderContext.DrawStringDat</c> two-pass outline/fill tests — proving
|
||
/// draw ORDER (outline segment before fill segment), TINT (RGB, not just
|
||
/// alpha), and the background-plane INFLATION (dest quad size + UV span)
|
||
/// all requires more than <see cref="DebugSpriteSegments"/> exposes.
|
||
/// <c>AcDream.App.Tests</c>-only via <c>InternalsVisibleTo</c>.
|
||
/// </summary>
|
||
internal IReadOnlyList<(uint Texture, IReadOnlyList<float> Verts)> DebugSpriteSegmentVerts
|
||
{
|
||
get
|
||
{
|
||
var result = new List<(uint, IReadOnlyList<float>)>(_segUsed);
|
||
for (int i = 0; i < _segUsed; i++)
|
||
{
|
||
SpriteSeg seg = _spriteSegs[i];
|
||
result.Add((seg.Texture, seg.Verts.ToArray()));
|
||
}
|
||
return result;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Test-only snapshot of the current frame's queued NORMAL-layer BITMAP FONT
|
||
/// text buffer (<see cref="DrawString"/>/<see cref="DrawStringClipped"/>,
|
||
/// the <see cref="AcDream.App.UI.UiRenderContext.DrawString"/> path used for
|
||
/// D.6 world-space HUD text): (vertex count, alpha of the first emitted
|
||
/// vertex — color.W at float index 7 of the 8-float vertex layout). Unlike
|
||
/// <see cref="DebugSpriteSegments"/> this buffer is not split per-texture —
|
||
/// bitmap-font glyphs all sample the one atlas — so there is exactly one
|
||
/// (count, alpha) pair to check. CH6c review NIT: pins that
|
||
/// <see cref="UiRenderContext.DrawString"/>'s alpha chokepoint is guarded
|
||
/// the same way <see cref="DrawStringDat"/>'s already was.
|
||
/// </summary>
|
||
internal (int VertexCount, float Alpha) DebugTextBuffer
|
||
=> (_textVerts, _textBuf.Count > 0 ? _textBuf[7] : 0f);
|
||
|
||
// 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>
|
||
/// Campaign LA gate round 2 (register AD-98): uniform canvas scale applied
|
||
/// to every emitted quad — sprites, rects, AND glyphs — at the single
|
||
/// emission chokepoint (<see cref="AppendQuad"/>). Retail renders its
|
||
/// fixed-canvas pre-world screens (char select's authored 800×600, root
|
||
/// 0x1000039A, zero edge anchors) at authored size and stretches the whole
|
||
/// composed frame once at presentation; its UI blitter has no stretch mode
|
||
/// at all (Graphic::Draw @0x00693b20 is copy-or-tile only). We have no
|
||
/// present-time frame stretch, so the equivalent lives here: while a
|
||
/// fixed-canvas screen is active, <see cref="UiRoot"/> sets this for the
|
||
/// duration of its Draw and everything scales together — including retail's
|
||
/// characteristic non-uniform aspect distortion and stretched glyphs.
|
||
/// UVs and colors are untouched. Always reset to One outside UiRoot.Draw
|
||
/// so the world-space HUD keeps native pixels.
|
||
/// </summary>
|
||
internal Vector2 CanvasScale = Vector2.One;
|
||
|
||
/// <summary>
|
||
/// Campaign LA gate round 2 (register AD-98 filtering fidelity): resolves a
|
||
/// UI texture handle to its linear-sampled twin
|
||
/// (<see cref="TextureCache.GetOrCreateLinearUiTwin"/>), consulted by
|
||
/// <see cref="DrawSprite"/> only while <see cref="CanvasScale"/> is not One.
|
||
/// Wired once by the composition root right after <c>TextureCache</c> exists;
|
||
/// left null by any test/host that never sets it, in which case a scaled
|
||
/// draw keeps sampling its original slot — nearest stays nearest, exactly
|
||
/// today's (jagged) behavior, rather than throwing. Nearest-sampled dat-font
|
||
/// glyphs and composited icons are the only handles this ever changes —
|
||
/// see the resolver's own doc comment for why chrome/background art passes
|
||
/// through unchanged.
|
||
/// </summary>
|
||
internal Func<uint, uint>? LinearTwinResolver { get; set; }
|
||
|
||
/// <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)
|
||
{
|
||
// AD-98 filtering fidelity: while a fixed-canvas screen is stretching
|
||
// every quad (CanvasScale != One), sample nearest-registered handles
|
||
// through their linear twin instead — see LinearTwinResolver's doc
|
||
// comment. The resolver itself is the identity for any handle that
|
||
// isn't a nearest-sampled UI texture, so this is safe to call
|
||
// unconditionally rather than needing its own "is this nearest" check.
|
||
if (CanvasScale != Vector2.One && LinearTwinResolver is { } resolve)
|
||
texture = resolve(texture);
|
||
|
||
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 void AppendQuad(List<float> buf,
|
||
float x, float y, float w, float h,
|
||
float u0, float v0, float u1, float v1, Vector4 color)
|
||
{
|
||
// AD-98 canvas stretch — see CanvasScale's doc comment. Applied after
|
||
// all canvas-space clipping, so geometry and UVs stay consistent.
|
||
if (CanvasScale != Vector2.One)
|
||
{
|
||
x *= CanvasScale.X;
|
||
y *= CanvasScale.Y;
|
||
w *= CanvasScale.X;
|
||
h *= CanvasScale.Y;
|
||
}
|
||
// 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();
|
||
}
|