using System; using System.Collections.Generic; using System.IO; using System.Numerics; using System.Runtime.InteropServices; 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 . /// /// Uses two internal vertex buffers (text and rect) flushed in two draw calls /// 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 readonly GL _gl; private readonly Shader _shader; private readonly uint _vao; private readonly uint _vbo; private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket private int _vboCapacityBytes; private readonly List _textBuf = new(8192); private readonly List _rectBuf = new(1024); // 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 sealed class SpriteSeg { public uint Texture; public readonly List Verts = new(256); } private readonly List _spriteSegs = new(); private int _segUsed; private int _textVerts; private int _rectVerts; private Vector2 _screenSize; // 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; } public TextRenderer(GL gl, string shaderDir) { _gl = gl; _shader = new Shader(gl, Path.Combine(shaderDir, "ui_text.vert"), Path.Combine(shaderDir, "ui_text.frag")); _vao = _gl.GenVertexArray(); _vbo = _gl.GenBuffer(); _gl.BindVertexArray(_vao); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); uint stride = FloatsPerVertex * sizeof(float); _gl.EnableVertexAttribArray(0); _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0); _gl.EnableVertexAttribArray(1); _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float))); _gl.EnableVertexAttribArray(2); _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float))); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); _gl.BindVertexArray(0); // 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). _whiteTex = _gl.GenTexture(); _gl.BindTexture(TextureTarget.Texture2D, _whiteTex); Span whitePixel = stackalloc byte[] { 255, 255, 255, 255 }; fixed (byte* wp = whitePixel) _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0, PixelFormat.Rgba, PixelType.UnsignedByte, wp); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest); _gl.BindTexture(TextureTarget.Texture2D, 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) { 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; if (gw > 0 && gh > 0) { if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; } else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, 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); } /// 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; _shader.Use(); _shader.SetVec2("uScreenSize", _screenSize); _gl.BindVertexArray(_vao); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); // Save GL state. bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); bool wasBlend = _gl.IsEnabled(EnableCap.Blend); bool wasCull = _gl.IsEnabled(EnableCap.CullFace); // 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). bool wasA2C = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage); bool wasMsaa = _gl.IsEnabled(EnableCap.Multisample); _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); DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font); // Restore GL state. _gl.DepthMask(true); if (!wasBlend) _gl.Disable(EnableCap.Blend); if (wasCull) _gl.Enable(EnableCap.CullFace); if (wasDepth) _gl.Enable(EnableCap.DepthTest); if (wasA2C) _gl.Enable(EnableCap.SampleAlphaToCoverage); if (wasMsaa) _gl.Enable(EnableCap.Multisample); _gl.BindVertexArray(0); } /// 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) { // 1. RGBA dat sprites — one draw call per distinct GL texture. if (segUsed > 0) { _shader.SetInt("uUseTexture", 2); _gl.ActiveTexture(TextureUnit.Texture0); _shader.SetInt("uTex", 0); for (int i = 0; i < segUsed; i++) { var seg = spriteSegs[i]; if (seg.Verts.Count == 0) continue; _gl.BindTexture(TextureTarget.Texture2D, seg.Texture); UploadBuffer(seg.Verts); _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(seg.Verts.Count / FloatsPerVertex)); } } // 2. Untextured rects — widget fills on top of the chrome. if (rectVerts > 0) { _shader.SetInt("uUseTexture", 0); UploadBuffer(rectBuf); _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts); } // 3. Textured debug-font text glyphs on top. if (textVerts > 0 && font is not null) { _shader.SetInt("uUseTexture", 1); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, font.TextureId); _shader.SetInt("uTex", 0); UploadBuffer(textBuf); _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)textVerts); } } private void UploadBuffer(List buf) { int bytes = buf.Count * sizeof(float); if (bytes == 0) return; if (bytes > _vboCapacityBytes) { fixed (float* p = CollectionsMarshal.AsSpan(buf)) _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)bytes, p, BufferUsageARB.DynamicDraw); _vboCapacityBytes = bytes; } else { fixed (float* p = CollectionsMarshal.AsSpan(buf)) _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)bytes, p); } } public void Dispose() { _gl.DeleteTexture(_whiteTex); _gl.DeleteBuffer(_vbo); _gl.DeleteVertexArray(_vao); _shader.Dispose(); } }