Second attempt at V4a afterceec3bc4was reverted at9aaf97e7for losing world multisampling and a 334-file scope explosion. This lands the same functional slice with a much smaller footprint and the two structural fixes the revert postmortem (docs/plans/2026-07-27-vulkan-campaign.md SS7.1) called for. What moved onto the RHI: - TextRenderer: the ui_text shader now compiles through IGpuDevice.CreatePipeline (one IGpuPipeline, replacing the old hand-rolled Shader class); its three fence-buffered per-flight VBOs are gone in favour of a per-IGpuFrame ring allocation per draw bucket; its 1x1 white fill texture is created via IGpuDevice.CreateTexture and registered into the device's texture table. Flush keeps TextRenderGlStateScope and the manual GL disable block verbatim (TextRendererFailureSafetyTests pins their literal presence) alongside the new pipeline bind - both target the identical final GL state, so this is redundant, not contradictory. Sprite/font texture binding stays classic (glActiveTexture/glBindTexture) because DrawSprite receives arbitrary externally-owned GL texture names from dozens of UI call sites outside this slice's scope; IGpuPassEncoder has no verb for that, by design (every other RHI consumer samples through the bindless texture table). - BitmapFont: the stb-baked R8 atlas is created/uploaded through IGpuDevice.CreateTexture; TextureId stays a raw GL name extracted from the IGpuTexture, since its only consumer is TextRenderer's classic path above. - DebugLineRenderer: the debug_line shader compiles through IGpuDevice.CreatePipeline (LineList topology, depth disabled); Flush ring- allocates its vertex data and draws through IGpuPassEncoder. uView/uProjection don't fit the shared GpuPushConstants block (one combined VP matrix) so they are set directly on the pipeline's compiled program, mirroring TextRenderer. - TextureCache: GetOrUploadRenderSurface and the public UploadRgba8(byte[],...) wrapper now create IGpuTexture+GpuTextureSlot internally, extracting the raw GL name for their unchanged uint return type - DrawSprite's signature and its 16 call sites across the UI are untouched. The world-material path (GetOrUpload, the raw layer-array upload) is untouched. - UiViewport: TextureHandle (uint) -> TextureSlot (GpuTextureSlot), resolved back to a raw GL name via TextRenderer.ResolveExternalTextureSlot at draw time. Its texture is produced by PaperdollViewportRenderer/ PrivateEntityViewportRenderer, both still raw GL until V4g, so RetailPaperdollFrameView/RetailCreatureAppraisalFrameView register it through the pre-approved GlGpuDevice.RegisterExternalColorTexture transitional seam (campaign doc SS7.1's final paragraph) instead of inventing anything broader. The two revert-postmortem fixes, both in Gpu/Gl (never in the pinned Gpu/ contract): - GlGpuDevice.BeginPass now resets the render-state cache unconditionally on every pass, not only a clearing one. The first attempt's crash came from exactly this gap: a raw-GL renderer running between two RHI passes changes GL program/blend/depth/cull state the cache never observes, so a later BindPipeline skipped re-issuing glUseProgram and the following push-constant upload threw GL_INVALID_OPERATION. - GlGpuPassEncoder now captures ambient GL capability state (program, VAO, array buffer, texture0 binding, depth test/write/func, blend enable+func, cull enable+mode, front face, alpha-to-coverage, multisample) on construction and restores it on Dispose, generalizing what TextRenderGlStateScope already did for TextRenderer specifically to every RHI pass - this is what stops DebugLineRenderer's pipeline bind (which has no scope of its own) from leaking state into the next raw-GL renderer. Both are marked transitional, deleted at V4h once nothing raw-GL remains. Frame lifecycle (additive, per the task's own description of this piece): new GpuDeviceFrameLifetime wraps IGpuDevice.BeginFrame()/IGpuFrame.End() and exposes the open frame via ICurrentGpuFrameSource. RenderFrameOrchestrator's IRenderFrameLifetime now routes through this wrapper instead of calling GpuFrameFlightController directly - GlGpuDevice.BeginFrame already calls straight through to that same controller, so the fence/slot-rotation contract is unchanged; the wrapper only additionally yields the IGpuFrame ported renderers need. No clears moved, no framebuffer binding changed, frame-graph phase order is untouched. The two now-dead per-slot TextRenderer.BeginFrame(int) calls in RuntimeRenderFrameBeginResources are removed. The UI Studio (RenderBootstrap/StudioWindow) gets its own independent RHI device+lifetime, mirroring the production composition. Real bug found and fixed while exercising this for the first time: both BitmapFont and TextureCache's nearest-filter override called TexParameter AFTER RegisterTexture, which made the bindless handle resident - GL_ARB_ bindless_texture forbids modifying a texture's parameters once its handle is resident, so this threw GL_INVALID_OPERATION building the retained UI's own TextRenderer. Fixed by moving both TexParameter blocks before RegisterTexture. Scope note: touches 25 files (24 modified + this commit's one new file), not the ~10 the brief estimated, because the frame-lifecycle wiring and the viewport escape hatch (both explicitly asked for) ripple through five composition files and two frame presenters that thread IGpuDevice/ ICurrentGpuFrameSource to construction sites. No file outside that necessary set was touched: no visibility sweep beyond the specific constructors/ properties whose new parameter types are internal (TextRenderer/BitmapFont/ DebugLineRenderer/UiHost's constructors, TextureCache's otherwise-orphaned convenience overload, UiViewport.TextureSlot), no world-mesh/terrain/particle/ sky file touched, no test deleted or weakened - three source-text conformance tests (TextRendererPublishesEveryConstructorResourceBeforeLaterGlWork, GlTextureOwnershipTests' TextRenderer.cs check, and RenderFrameResourceControllerTests' frame-order check) were replaced with equivalent assertions against the new construction/wiring shape, since their pinned invariant was specifically the old raw-GL shape this slice legitimately replaces. Gates: - dotnet build -c Release: 0 warnings, 0 errors. - dotnet test tests/AcDream.App.Tests -c Release: 3,843 passed / 3 skipped - exactly the baseline. Complete solution: 8,906 passed / 5 skipped across all nine test projects. - Offline pixel gate (tools/run-offline-pixel-gate.ps1, parenta97e04aevs this commit): 26 differing pixels of 563,200 compared (fraction 4.62e-05), pass against the 0.001/563-pixel threshold. Verified against a same-commit control (two captures at this commit differ by 20 pixels) rather than accepted at face value - the two numbers are in the same band, confirming this is normal animated-content/frame-pacing noise and not the systematic silhouette-edge loss (1,791 pixels, 224x higher) the first attempt's revert diagnosed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
521 lines
23 KiB
C#
521 lines
23 KiB
C#
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;
|
||
|
||
/// <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"/>.
|
||
///
|
||
/// Campaign V slice V4a: the <c>ui_text</c> shader compiles through
|
||
/// <see cref="IGpuDevice.CreatePipeline"/> (one <see cref="IGpuPipeline"/>,
|
||
/// replacing the old hand-rolled <c>Shader</c> class), its three
|
||
/// fence-buffered per-flight VBOs are gone in favour of a per-<see cref="IGpuFrame"/>
|
||
/// ring allocation per draw bucket, and the 1×1 white fill texture is created
|
||
/// and uploaded through <see cref="IGpuDevice.CreateTexture"/> and registered
|
||
/// into the device's texture table.
|
||
///
|
||
/// <see cref="IGpuPassEncoder"/> has no verb for classic texture-unit binding
|
||
/// (every acdream RHI pass samples through the bindless texture table) but
|
||
/// <see cref="DrawSprite"/> 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 (<c>glActiveTexture</c>/<c>glBindTexture</c>,
|
||
/// <c>ui_text.frag</c>'s <c>uTex</c> 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.
|
||
/// </summary>
|
||
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<float> 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<float> _textBuf = new(8192);
|
||
private readonly List<float> _rectBuf = new(1024);
|
||
private readonly List<SpriteSeg> _spriteSegs = new();
|
||
private int _segUsed;
|
||
private int _textVerts;
|
||
private int _rectVerts;
|
||
private Vector2 _screenSize;
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="RenderFrameDiagnosticSources"/>'s telemetry
|
||
/// read still compiles; the dynamic-buffer dimension it reported is now a
|
||
/// device-wide, not a per-renderer, concern.
|
||
/// </summary>
|
||
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<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; }
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
/// <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>
|
||
/// Resolves a <see cref="GpuTextureSlot"/> produced by the paperdoll/appraisal
|
||
/// viewport transitional seam (<c>GlGpuDevice.RegisterExternalColorTexture</c>,
|
||
/// campaign doc §7.1) back to the raw GL texture name <see cref="DrawSprite"/>
|
||
/// needs. Returns 0 (no texture) for an unassigned slot. GL-only; removed
|
||
/// at V4g alongside the seam it resolves.
|
||
/// </summary>
|
||
internal uint ResolveExternalTextureSlot(GpuTextureSlot slot) =>
|
||
_glDevice.TryResolveExternalColorTexture(slot, out uint name) ? name : 0;
|
||
|
||
/// <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;
|
||
|
||
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);
|
||
}
|
||
|
||
/// <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,
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Allocates a ring range from the current frame, copies <paramref name="buf"/>
|
||
/// into it, and issues one non-indexed draw. Replaces the old growable
|
||
/// per-flight VBO + <c>BufferSubData</c> pattern: every UI vertex upload is
|
||
/// now the frame's shared ring, reset once per frame by
|
||
/// <see cref="AcDream.App.Rendering.Gpu.Gl.GlGpuDevice.BeginFrame"/>.
|
||
/// </summary>
|
||
private static void DrawRing(IGpuFrame frame, IGpuPassEncoder encoder, List<float> buf)
|
||
{
|
||
if (buf.Count == 0)
|
||
return;
|
||
GpuRingAllocation allocation = frame.AllocateRing(buf.Count * sizeof(float), GpuRingUsage.Vertex);
|
||
CollectionsMarshal.AsSpan(buf).CopyTo(allocation.AsSpan<float>());
|
||
encoder.BindVertexBuffer(allocation.Buffer, allocation.OffsetBytes);
|
||
encoder.Draw((uint)(buf.Count / FloatsPerVertex), 1, 0, 0);
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
_whiteTexture.Dispose();
|
||
_pipeline.Dispose();
|
||
}
|
||
}
|