using System; using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu.Gl; using Silk.NET.OpenGL; 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), its three /// fence-buffered per-flight VBOs are gone in favour of a per- /// ring allocation per draw bucket, and the 1×1 white fill texture is created /// and uploaded through and registered /// into the device's texture table. /// /// has no verb for classic texture-unit binding /// (every acdream RHI pass samples through the bindless texture table) but /// receives an ARBITRARY externally-owned raw GL /// texture name from dozens of UI call sites that are not part of this slice /// (icons, dat chrome, composited item art) — converting that whole surface /// to slot-based sampling is out of scope here. So sprite/font texture /// binding stays classic (glActiveTexture/glBindTexture, /// ui_text.frag's uTex sampler unchanged) issued directly against /// the GL handle this class keeps for that reason, while the shader program /// itself, its blend/depth/cull description, and every per-frame vertex /// upload now go through the RHI. This mirrors the same GL-only escape hatch /// the campaign's V2 note already documents for the interim bindless handle /// table, and is retired only when a later slice moves ALL of TextRenderer's /// texture consumers onto registered slots. /// /// 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 unsafe class TextRenderer : IDisposable { private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4) private const int VertexStrideBytes = FloatsPerVertex * sizeof(float); private static readonly GpuVertexLayout SpriteVertexLayout = new( StrideBytes: VertexStrideBytes, [ new GpuVertexAttribute(0, GpuVertexFormat.Float2, 0), new GpuVertexAttribute(1, GpuVertexFormat.Float2, 8), new GpuVertexAttribute(2, GpuVertexFormat.Float4, 16), ]); private readonly IGpuDevice _device; private readonly GlGpuDevice _glDevice; private readonly ICurrentGpuFrameSource _frameSource; private readonly GL _gl; private readonly ITextRenderGlStateApi _glState; private readonly IGpuPipeline _pipeline; private readonly IGpuTexture _whiteTexture; private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket private readonly int _uScreenSizeLocation; private readonly int _uUseTextureLocation; 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; // 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) { _device = device ?? throw new ArgumentNullException(nameof(device)); _frameSource = frameSource ?? throw new ArgumentNullException(nameof(frameSource)); if (device is not GlGpuDevice glDevice) { throw new NotSupportedException( "TextRenderer's classic sprite/font texture-unit binding path (see the class " + "remarks) is GL-only; it needs the raw GL handle GlGpuDevice exposes. Other " + "backends are out of scope until a later slice removes that classic path."); } _glDevice = glDevice; _gl = glDevice.Gl; _glState = new SilkTextRenderGlStateApi(_gl); ArgumentException.ThrowIfNullOrWhiteSpace(shaderDir); IGpuPipeline? pipeline = null; IGpuTexture? whiteTexture = null; try { 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). Flush // below still asserts this by hand via raw GL calls (and // GL_MULTISAMPLE, which has no representation here), matching // what TextRenderGlStateScope has always restored on exit. Depth = GpuDepthState.Disabled, Cull = GpuCullMode.None, AlphaToCoverage = false, ColorWrite = true, SampleCount = 1, }); // 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE // bucket (the shader multiplies texel×color → white×color = color). Lets a panel // background draw UNDER its text in painter order, which DrawRect's separate // bucket cannot (it always composites after all sprites). whiteTexture = device.CreateTexture(new GpuTextureDescription( "ui-text-white", GpuTextureKind.Texture2D, GpuTextureFormat.Rgba8Unorm, Width: 1, Height: 1, LayerCount: 1, MipLevelCount: 1)); whiteTexture.Upload(0, 0, [255, 255, 255, 255]); IGpuSampler whiteSampler = device.CreateSampler(GpuSamplerDescription.UiNearest); device.RegisterTexture(whiteTexture, whiteSampler); } catch { whiteTexture?.Dispose(); pipeline?.Dispose(); throw; } _pipeline = pipeline; _whiteTexture = whiteTexture; _whiteTex = ((GlGpuTexture)whiteTexture).GlName; uint program = ((GlGpuPipeline)_pipeline).GlProgram; _uScreenSizeLocation = _gl.GetUniformLocation(program, "uScreenSize"); _uUseTextureLocation = _gl.GetUniformLocation(program, "uUseTexture"); // uTex (the sampler unit) never changes — bind it once rather than on every Flush. int texLocation = _gl.GetUniformLocation(program, "uTex"); if (texLocation >= 0) { _gl.UseProgram(program); _gl.Uniform1(texLocation, 0); _gl.UseProgram(0); } } /// 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. public void DrawFill(float x, float y, float w, float h, Vector4 color) => DrawSprite(_whiteTex, 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). Batched per /// GL texture handle; flushed with uUseTexture=2 (RGBA modulate). /// 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); } /// /// Resolves a produced by the paperdoll/appraisal /// viewport transitional seam (GlGpuDevice.RegisterExternalColorTexture, /// campaign doc §7.1) back to the raw GL texture name /// needs. Returns 0 (no texture) for an unassigned slot. GL-only; removed /// at V4g alongside the seam it resolves. /// internal uint ResolveExternalTextureSlot(GpuTextureSlot slot) => _glDevice.TryResolveExternalColorTexture(slot, out uint name) ? name : 0; /// 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. The focused scope restores from Flush's generated finally, // including when either DrawLayer call throws. using var stateScope = new TextRenderGlStateScope(_glState); 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); _gl.Uniform2(_uScreenSizeLocation, _screenSize.X, _screenSize.Y); // Establish the self-contained UI pass state. // The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher, // QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha // EDGE is converted to dithered MSAA coverage instead of a clean alpha blend — // the "text not sharp / fuzzy" artifact. The UI composites with straight alpha // blending and must own this state (feedback_render_self_contained_gl_state). _gl.Disable(EnableCap.SampleAlphaToCoverage); _gl.Disable(EnableCap.Multisample); _gl.Disable(EnableCap.DepthTest); _gl.Disable(EnableCap.CullFace); _gl.DepthMask(false); _gl.Enable(EnableCap.Blend); _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); // 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 GL texture. if (segUsed > 0) { SetUseTexture(2); _gl.ActiveTexture(TextureUnit.Texture0); for (int i = 0; i < segUsed; i++) { var seg = spriteSegs[i]; if (seg.Verts.Count == 0) continue; _gl.BindTexture(TextureTarget.Texture2D, seg.Texture); DrawRing(frame, encoder, seg.Verts); } } // 2. Untextured rects — widget fills on top of the chrome. if (rectVerts > 0) { SetUseTexture(0); DrawRing(frame, encoder, rectBuf); } // 3. Textured debug-font text glyphs on top. if (textVerts > 0 && font is not null) { SetUseTexture(1); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, font.TextureId); DrawRing(frame, encoder, textBuf); } } private void SetUseTexture(int mode) { if (_uUseTextureLocation >= 0) _gl.Uniform1(_uUseTextureLocation, mode); } /// /// 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 /// . /// 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(allocation.Buffer, allocation.OffsetBytes); encoder.Draw((uint)(buf.Count / FloatsPerVertex), 1, 0, 0); } public void Dispose() { _whiteTexture.Dispose(); _pipeline.Dispose(); } }