acdream/src/AcDream.App/Rendering/TextRenderer.cs
Erik 749e8ceeb1 fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
2026-07-18 21:35:16 +02:00

494 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Wb;
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 uint _vao;
private uint _vbo;
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
private int _vboCapacityBytes;
private sealed class FrameBufferSet
{
public uint Vao;
public uint Vbo;
public int CapacityBytes;
public int UsedBytes;
}
private readonly FrameBufferSet[] _frameBuffers = new FrameBufferSet[3];
private FrameBufferSet? _activeFrameBuffer;
internal long DynamicBufferCapacityBytes =>
_frameBuffers.Sum(set => (long)set.CapacityBytes);
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;
// 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<float> _overlayTextBuf = new(1024);
private readonly List<float> _overlayRectBuf = new(256);
private readonly List<SpriteSeg> _overlaySpriteSegs = new();
private int _overlaySegUsed;
private int _overlayTextVerts;
private int _overlayRectVerts;
/// <summary>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.</summary>
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"));
for (int i = 0; i < _frameBuffers.Length; i++)
_frameBuffers[i] = CreateFrameBufferSet();
// 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<byte> 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);
}
/// <summary>
/// Selects the GPU-fenced frame slot and resets its append cursor. Every
/// UI segment rendered during the frame receives a distinct byte range;
/// later text or sprite batches cannot overwrite an earlier in-flight draw.
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_frameBuffers.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
FrameBufferSet set = _frameBuffers[frameSlot];
set.UsedBytes = 0;
_activeFrameBuffer = set;
_vao = set.Vao;
_vbo = set.Vbo;
_vboCapacityBytes = set.CapacityBytes;
}
private FrameBufferSet CreateFrameBufferSet()
{
uint vao = 0;
uint vbo = 0;
try
{
vao = TrackedGlResource.CreateVertexArray(
_gl,
"TextRenderer frame VAO creation");
vbo = TrackedGlResource.CreateBuffer(
_gl,
"TextRenderer frame VBO creation");
var set = new FrameBufferSet { Vao = vao, Vbo = vbo };
_gl.BindVertexArray(set.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.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);
return set;
}
catch
{
if (vbo != 0)
TrackedGlResource.DeleteBuffer(
_gl, vbo, 0, "TextRenderer frame VBO rollback");
if (vao != 0)
TrackedGlResource.DeleteVertexArray(
_gl, vao, "TextRenderer frame VAO rollback");
throw;
}
}
/// <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;
_overlayTextBuf.Clear();
_overlayRectBuf.Clear();
_overlaySegUsed = 0;
_overlayTextVerts = 0;
_overlayRectVerts = 0;
OverlayMode = false;
}
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
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; }
}
/// <summary>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 <see cref="DrawRect"/> — 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.</summary>
public void DrawFill(float x, float y, float w, float h, Vector4 color)
=> DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color);
/// <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)
=> DrawStringCore(
font, text, x, y, color,
clip: false, 0f, 0f, 0f, 0f);
/// <summary>
/// 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 <c>UIElement_Text::DrawSelf @ 0x00467AA0</c> clips the
/// individual glyph blits instead of discarding the whole line.
/// </summary>
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;
}
}
/// <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 = OverlayMode
? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
: NextSpriteSeg(_spriteSegs, ref _segUsed, texture);
AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint);
}
/// <summary>Pick the sprite segment for <paramref name="texture"/>: 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).</summary>
private static SpriteSeg NextSpriteSeg(List<SpriteSeg> 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<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 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);
}
/// <summary>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 <see cref="Flush"/>.</summary>
private void DrawLayer(
List<SpriteSeg> spriteSegs, int segUsed,
List<float> rectBuf, int rectVerts,
List<float> 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);
int firstVertex = UploadBuffer(seg.Verts);
_gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)(seg.Verts.Count / FloatsPerVertex));
}
}
// 2. Untextured rects — widget fills on top of the chrome.
if (rectVerts > 0)
{
_shader.SetInt("uUseTexture", 0);
int firstVertex = UploadBuffer(rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (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);
int firstVertex = UploadBuffer(textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)textVerts);
}
}
private int UploadBuffer(List<float> buf)
{
int bytes = buf.Count * sizeof(float);
if (bytes == 0) return 0;
FrameBufferSet set = _activeFrameBuffer
?? throw new InvalidOperationException("BeginFrame must be called before rendering text.");
int byteOffset = set.UsedBytes;
int requiredBytes = checked(byteOffset + bytes);
if (requiredBytes > _vboCapacityBytes)
{
int newCapacity = DynamicBufferCapacity.Grow(
_vboCapacityBytes,
requiredBytes);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ArrayBuffer,
_vbo,
_vboCapacityBytes,
newCapacity,
GLEnum.DynamicDraw,
"TextRenderer frame VBO growth");
_vboCapacityBytes = newCapacity;
}
fixed (float* p = CollectionsMarshal.AsSpan(buf))
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, (nint)byteOffset, (nuint)bytes, p);
set.UsedBytes = requiredBytes;
set.CapacityBytes = _vboCapacityBytes;
return byteOffset / (FloatsPerVertex * sizeof(float));
}
public void Dispose()
{
_gl.DeleteTexture(_whiteTex);
foreach (FrameBufferSet set in _frameBuffers)
{
TrackedGlResource.DeleteBuffer(
_gl,
set.Vbo,
set.CapacityBytes,
"TextRenderer frame VBO disposal");
TrackedGlResource.DeleteVertexArray(
_gl,
set.Vao,
"TextRenderer frame VAO disposal");
}
_shader.Dispose();
}
}