TextRenderer batched sprites per-texture and drew each texture's whole buffer at its FIRST-insertion point. The dat-font glyph atlas is one shared texture used by all three vital numbers; it first appeared at the health bar, so all three numbers were emitted right after the health bars — then the stamina + mana bar sprites painted over their own numbers (only health survived). Replaced the per-texture dictionary with submission-ordered segments (consecutive same-texture quads still batch); each meter's number now draws after its own bars. The renderer's own comment had predicted this break once bars became sprites (importer did that). Removed the temporary UiMeter label diagnostic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
296 lines
11 KiB
C#
296 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Numerics;
|
|
using System.Runtime.InteropServices;
|
|
using Silk.NET.OpenGL;
|
|
|
|
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"/>.
|
|
///
|
|
/// 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.
|
|
/// </summary>
|
|
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 int _vboCapacityBytes;
|
|
|
|
private readonly List<float> _textBuf = new(8192);
|
|
private readonly List<float> _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<float> Verts = new(256); }
|
|
private readonly List<SpriteSeg> _spriteSegs = new();
|
|
private int _segUsed;
|
|
private int _textVerts;
|
|
private int _rectVerts;
|
|
private Vector2 _screenSize;
|
|
|
|
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);
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
|
|
public void DrawRect(float x, float y, float w, float h, Vector4 color)
|
|
{
|
|
AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color);
|
|
_rectVerts += 6;
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
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)
|
|
{
|
|
AppendQuad(_textBuf,
|
|
gx, gy, gw, gh,
|
|
g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY,
|
|
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). Batched per
|
|
/// GL texture handle; flushed with uUseTexture=2 (RGBA modulate).
|
|
/// </summary>
|
|
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;
|
|
if (_segUsed > 0 && _spriteSegs[_segUsed - 1].Texture == texture)
|
|
{
|
|
seg = _spriteSegs[_segUsed - 1]; // extend the current same-texture run
|
|
}
|
|
else if (_segUsed < _spriteSegs.Count)
|
|
{
|
|
seg = _spriteSegs[_segUsed++]; // reuse a pooled segment
|
|
seg.Texture = texture;
|
|
seg.Verts.Clear();
|
|
}
|
|
else
|
|
{
|
|
seg = new SpriteSeg { Texture = texture };
|
|
_spriteSegs.Add(seg);
|
|
_segUsed++;
|
|
}
|
|
AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint);
|
|
}
|
|
|
|
private static void AppendQuad(List<float> 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);
|
|
}
|
|
|
|
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
|
|
public void Flush(BitmapFont? font)
|
|
{
|
|
bool hasSprites = _segUsed > 0;
|
|
if (_textVerts == 0 && _rectVerts == 0 && !hasSprites) 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);
|
|
_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 — each meter's dat-font number draws
|
|
// after its own bar sprites. Buckets 2 (rects) + 3 (debug text) composite
|
|
// on top, in that order.
|
|
|
|
// 1. RGBA dat sprites first — one draw call per distinct GL texture.
|
|
if (hasSprites)
|
|
{
|
|
_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 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);
|
|
}
|
|
|
|
// Restore GL state.
|
|
_gl.DepthMask(true);
|
|
if (!wasBlend) _gl.Disable(EnableCap.Blend);
|
|
if (wasCull) _gl.Enable(EnableCap.CullFace);
|
|
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
|
|
|
|
_gl.BindVertexArray(0);
|
|
}
|
|
|
|
private void UploadBuffer(List<float> 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.DeleteBuffer(_vbo);
|
|
_gl.DeleteVertexArray(_vao);
|
|
_shader.Dispose();
|
|
}
|
|
}
|