using System; using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; using AcDream.App.Rendering.Gpu; namespace AcDream.App.Rendering; /// /// 2D batched quad renderer for text + solid rectangles. Coordinates are in /// screen pixels with origin top-left, +X right, +Y down. Call /// at the start of a HUD pass, queue geometry via /// / , then . /// /// Campaign V slice V4a: the ui_text shader compiles through /// (one , /// replacing the old hand-rolled Shader class) and its three /// fence-buffered per-flight VBOs are gone in favour of a per- /// ring allocation per draw bucket. /// /// 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. /// /// Textures. V4a kept a classic glActiveTexture/glBindTexture /// path because receives an arbitrary texture from /// dozens of widget call sites. Those textures are all registered into the /// device's global table by TextureCache already — the classic path was /// only ever consuming the raw GL name that registration also produced. They /// now travel as values instead, and the /// shader samples the table. /// /// Loose uniforms. uScreenSize and uUseTexture moved /// into the pinned 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 /// ui_text.frag for the three cases. /// /// GL capability state. The pass no longer disables multisampling /// by hand — GlGpuPassEncoder 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. /// /// 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. /// 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 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 _textBuf = new(8192); private readonly List _rectBuf = new(1024); private readonly List _spriteSegs = new(); private int _segUsed; private int _textVerts; private int _rectVerts; private Vector2 _screenSize; /// /// 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 's telemetry /// read still compiles; the dynamic-buffer dimension it reported is now a /// device-wide, not a per-renderer, concern. /// internal long DynamicBufferCapacityBytes => 0; /// /// 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 /// RecordingGpuDevice test double. Campaign CH slice CH6c rider (CH6a/b /// re-review): strengthens the grip-media regression guard past a bare /// SpriteFile != 0 check, which proves a sprite RESOLVED but not that /// was ever called. AcDream.App.Tests-only via /// InternalsVisibleTo. /// 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; } } /// /// Test-only: same submission-ordered segmentation as /// , but exposing the FULL per-vertex float /// buffer (8 floats/vertex: x,y,u,v,r,g,b,a — see , /// 6 vertices/quad) instead of just texture/count/alpha. Needed by the /// UiRenderContext.DrawStringDat 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 exposes. /// AcDream.App.Tests-only via InternalsVisibleTo. /// internal IReadOnlyList<(uint Texture, IReadOnlyList Verts)> DebugSpriteSegmentVerts { get { var result = new List<(uint, IReadOnlyList)>(_segUsed); for (int i = 0; i < _segUsed; i++) { SpriteSeg seg = _spriteSegs[i]; result.Add((seg.Texture, seg.Verts.ToArray())); } return result; } } /// /// Test-only snapshot of the current frame's queued NORMAL-layer BITMAP FONT /// text buffer (/, /// the 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 /// 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 /// 's alpha chokepoint is guarded /// the same way 's already was. /// 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 _overlayTextBuf = new(1024); private readonly List _overlayRectBuf = new(256); private readonly List _overlaySpriteSegs = new(); private int _overlaySegUsed; private int _overlayTextVerts; private int _overlayRectVerts; /// 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. 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, }); } /// Begin a HUD pass. Call once per frame before any Draw* calls. 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; } /// Draw a filled rectangle in screen pixel space. 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; } } /// 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 — 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. /// /// 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. /// 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); /// Draw a 1-pixel-thick outline rect. 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); } /// /// 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. /// 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); /// /// 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 UIElement_Text::DrawSelf @ 0x00467AA0 clips the /// individual glyph blits instead of discarding the whole line. /// 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; } } /// /// Draw a textured sprite quad in screen pixel space with an explicit /// source-UV rectangle (for 9-slice / atlas sub-regions). /// /// is a — a /// one-based index into the device's global texture table, which is what /// TextureCache now hands out in place of the raw GL name it used to. /// Segments batch per handle and draw in submission order. /// draws the tint alone; every /// widget guards against passing it, and uses it /// deliberately. /// 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); } /// /// Encodes a produced by the paperdoll/appraisal /// viewport transitional seam (GlGpuDevice.RegisterExternalColorTexture, /// campaign doc §7.1) as the handle takes. Returns /// for an unassigned slot. /// /// 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. /// internal static uint ResolveExternalTextureSlot(GpuTextureSlot slot) => UiTextureTableHandle.FromSlot(slot); /// Pick the sprite segment for : 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). private static SpriteSeg NextSpriteSeg(List 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 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); } /// Upload + draw accumulated rects + text. font may be null if only DrawRect was used. 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); } /// 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 . private void DrawLayer( List spriteSegs, int segUsed, List rectBuf, int rectVerts, List 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); } } /// /// 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. /// 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); } /// /// Allocates a ring range from the current frame, copies /// into it, and issues one non-indexed draw. Replaces the old growable /// per-flight VBO + BufferSubData pattern: every UI vertex upload is /// now the frame's shared ring, reset once per frame by /// /// (the raw-GL device's equivalent reset was deleted at Campaign V slice V11). /// private static void DrawRing(IGpuFrame frame, IGpuPassEncoder encoder, List buf) { if (buf.Count == 0) return; GpuRingAllocation allocation = frame.AllocateRing(buf.Count * sizeof(float), GpuRingUsage.Vertex); CollectionsMarshal.AsSpan(buf).CopyTo(allocation.AsSpan()); encoder.BindVertexBuffer(0, allocation.Buffer, allocation.OffsetBytes); encoder.Draw((uint)(buf.Count / FloatsPerVertex), 1, 0, 0); } public void Dispose() => _pipeline.Dispose(); }