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();
}
}