diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index c66882f4..4f147686 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -42,8 +42,10 @@ namespace AcDream.App.Rendering; /// Vulkan backend gets from the pass description for free. /// /// 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. +/// layer, to avoid a per-vertex "use texture" flag. The untextured-rect bucket +/// ('s _rectBuf) composites AFTER every sprite +/// segment regardless of submission order — see that method's doc comment for +/// why the retained UI (AcDream.App.UI) no longer feeds it. /// public sealed class TextRenderer : IDisposable { @@ -155,6 +157,16 @@ public sealed class TextRenderer : IDisposable internal (int VertexCount, float Alpha) DebugTextBuffer => (_textVerts, _textBuf.Count > 0 ? _textBuf[7] : 0f); + /// + /// Test-only: vertex count queued in the NORMAL-layer untextured rect bucket + /// ('s _rectBuf — see that method's doc comment for why + /// the retained UI no longer feeds it). Used to prove a fix stays fixed: nothing + /// should land here for retained-UI content, since this bucket always flushes AFTER + /// every sprite segment regardless of submission order (see ). + /// AcDream.App.Tests-only via InternalsVisibleTo. + /// + internal int DebugRectVertexCount => _rectVerts; + // 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 @@ -250,7 +262,29 @@ public sealed class TextRenderer : IDisposable OverlayMode = false; } - /// Draw a filled rectangle in screen pixel space. + /// + /// Draw a filled rectangle into the SEPARATE untextured-rect bucket + /// (_rectBuf/_overlayRectBuf), which always + /// composites AFTER every sprite segment in the layer, REGARDLESS of submission + /// order — the opposite of painter order. + /// + /// + /// The retained UI (AcDream.App.UI) does not call this. It used to, + /// through — every + /// BorderColor outline (, + /// , via + /// ) landed here and + /// so always composited above every OTHER window's sprite content, no matter which + /// window actually drew later. Owner report: with the MossTank plugin window + /// BEHIND the inventory window, the plugin buttons' border outlines drew on top + /// of the inventory paperdoll. UiRenderContext.DrawRect now forwards to + /// instead (the same untextured SPRITE-bucket segment, + /// painter/submission order), so this bucket is currently unreached by any + /// caller in src/ outside this class. Kept — not deleted — for a future + /// non-retained-UI caller (e.g. a debug overlay) that genuinely wants "always on + /// top of every sprite" compositing; do not route new retained-UI code through it. + /// + /// 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; } @@ -258,10 +292,11 @@ public sealed class TextRenderer : IDisposable } /// 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. + /// when active), so it composites in painter/submission order with sprites, outline + /// segments, and dat-font text. Every retained-UI fill and outline + /// (/DrawFill/ + /// DrawRectOutline) routes through here, not — see that + /// method's doc comment for why. /// /// Slice V6d: this used to route through a 1×1 white texture, relying on /// white × colour = colour. The shader now has an untextured branch that produces @@ -271,7 +306,10 @@ public sealed class TextRenderer : IDisposable public void DrawFill(float x, float y, float w, float h, Vector4 color) => DrawSprite(UiTextureTableHandle.None, x, y, w, h, 0f, 0f, 1f, 1f, color); - /// Draw a 1-pixel-thick outline rect. + /// Draw a 1-pixel-thick outline rect through the (unreached-by-retained-UI) + /// rect bucket — see 's doc comment. Retained-UI outlines go + /// through instead, which + /// composites in painter order via . public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) { // top, bottom, left, right @@ -502,21 +540,28 @@ public sealed class TextRenderer : IDisposable }); encoder.BindPipeline(_pipeline); - // 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. + // THREE buckets per layer, drawn in this fixed order: + // 1. RGBA dat sprites AND untextured fills/outlines — window chrome, + // panel backgrounds, vital bars, and every retained-UI BorderColor + // outline (DrawFill/DrawRect route here — see DrawFill's doc comment). + // Draws in SUBMISSION (painter) order via _spriteSegs, so painter + // z-order is preserved for sprite-on-sprite, fill-on-sprite, AND + // outline-on-sprite compositing alike. + // 2. The SEPARATE untextured-rect bucket (_rectBuf) — always composites + // AFTER every bucket-1 segment regardless of submission order; see + // DrawRect's doc comment for why the retained UI no longer feeds it. + // 3. Text glyphs (debug bitmap-font text; retained-UI dat-font text is + // also a bucket-1 sprite segment, not this bucket) — on top. + // The OVERLAY layer repeats all three AFTER the normal layer, so open + // popups beat even the normal layer's bucket-1 content. DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font, frame, encoder); DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font, frame, encoder); } - /// 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 . + /// Draw one compositing layer: sprites + fills + outlines, all in submission + /// (painter) order, one call per texture-table slot → the separate untextured-rect + /// bucket (unused by the retained UI) → 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, diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index a91ee7cb..15b008ff 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -153,18 +153,27 @@ public sealed class UiRenderContext // ── Pass-through draw helpers (add current translate) ────────────── - public void DrawRect(float x, float y, float w, float h, Vector4 color) - { - x += _current.X; - y += _current.Y; - if (!ClipRect(ref x, ref y, ref w, ref h)) return; - TextRenderer.DrawRect(x, y, w, h, ApplyAlpha(color)); - } + /// + /// Solid-colour fill/outline-segment drawn in the SPRITE bucket (painter/submission + /// order with every other sprite and dat-font glyph). Root-cause fix (owner report, + /// "MossTank border draws over the inventory paperdoll"): this used to forward into + /// 's separate untextured-rect bucket, which + /// always composited AFTER every sprite segment in + /// the layer, regardless of submission order — so a + /// border (every BorderColor outline in the retained UI: , + /// ) always won against whatever window happened to draw + /// after it, instead of only the windows actually painted before it. Routing through + /// the same untextured segment uses makes painter order the + /// ONLY thing that decides who's on top, exactly like sprites and text. + /// See 's own doc comment for what (if anything) + /// still uses that bucket. + /// + public void DrawRect(float x, float y, float w, float h, Vector4 color) => DrawFill(x, y, w, h, color); - /// Solid-colour fill drawn in the SPRITE bucket (painter order with text), for - /// a panel BACKGROUND that text draws on top of. composites after - /// all sprites and would cover the text — use this for backgrounds, that for foreground - /// fills (carets, vital bars). + /// Solid-colour fill drawn in the SPRITE bucket (painter/submission order with + /// every other sprite, outline segment, and dat-font glyph) — see 's + /// doc comment; the two are now the same operation under different names (background vs. + /// foreground callers), both routing through . public void DrawFill(float x, float y, float w, float h, Vector4 color) { x += _current.X; @@ -173,6 +182,9 @@ public sealed class UiRenderContext TextRenderer.DrawFill(x, y, w, h, ApplyAlpha(color)); } + /// Four-quad border, each side drawn through — and so, + /// since that fix, in painter/submission order with everything else rather than always + /// on top of every window drawn after it. See 's doc comment. public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) { if (thickness <= 0f || w <= 0f || h <= 0f) return; diff --git a/tests/AcDream.App.Tests/UI/UiRectOutlinePainterOrderTests.cs b/tests/AcDream.App.Tests/UI/UiRectOutlinePainterOrderTests.cs new file mode 100644 index 00000000..1b1586cd --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiRectOutlinePainterOrderTests.cs @@ -0,0 +1,112 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +/// +/// Owner-reported symptom: with a plugin window BEHIND the inventory window, +/// the plugin button's BORDER outline drew on top of the inventory's paperdoll +/// even though the inventory window was added (and so composited) LATER. Root +/// cause — used to composite in three buckets per +/// layer: submission-ordered sprite segments, THEN every untextured +/// quad (regardless of when it was +/// submitted), THEN debug text. (and so +/// , which every +/// BorderColor outline in the retained UI — , +/// — goes through) forwarded into that rect +/// bucket, so every outline composited above every window's sprite content +/// drawn after it, no matter the actual paint order. +/// +/// +/// This pins the mechanism directly against the real +/// draw path: a back panel with a visible border, then a front sibling +/// (added AFTER — later paint order) that draws an opaque sprite over the +/// same screen rect. The back panel's border must composite UNDER the front +/// sprite, exactly like already does +/// for panel backgrounds. +/// +/// +public sealed class UiRectOutlinePainterOrderTests +{ + private sealed class TestElement : UiElement { } + + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h) + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(w, h)); + var ctx = new UiRenderContext(renderer, new Vector2(w, h)); + return (renderer, ctx); + } + + [Fact] + public void BackPanelBorder_ComposesUnderAFrontSpriteAddedLater() + { + var root = new TestElement { Width = 200f, Height = 200f }; + + // Back window: added FIRST, drawn first (lower paint order). Its + // border is the ONLY thing it draws — background left transparent so + // any leaked geometry in the assertions below can only be the border. + var backPanel = new UiPanel + { + Left = 0f, Top = 0f, Width = 100f, Height = 60f, + BackgroundColor = default, + BorderColor = new Vector4(1f, 1f, 1f, 1f), + BorderThickness = 2f, + }; + + // Front window: added AFTER (drawn later / higher paint order) and + // covers the SAME screen rect with an opaque sprite — the inventory + // window's paperdoll, standing in for MossTank's button border. + const uint frontTexture = 55u; + var frontSprite = new UiSolidSpriteFill + { + Left = 0f, Top = 0f, Width = 100f, Height = 60f, + SpriteId = frontTexture, + SpriteResolve = id => (id, 8, 8), + }; + + root.AddChild(backPanel); + root.AddChild(frontSprite); + + var (renderer, ctx) = MakeContext(200f, 200f); + root.DrawSelfAndChildren(ctx); + + var segs = renderer.DebugSpriteSegmentVerts; + + int frontIndex = -1; + for (int i = 0; i < segs.Count; i++) + { + if (segs[i].Texture == frontTexture) { frontIndex = i; break; } + } + Assert.True(frontIndex >= 0, "the front sprite must be recorded in the sprite bucket"); + + int outlineIndex = -1; + for (int i = 0; i < segs.Count; i++) + { + if (segs[i].Texture == UiTextureTableHandle.None) { outlineIndex = i; break; } + } + Assert.True( + outlineIndex >= 0, + "the back panel's border must route through the painter-order sprite bucket " + + "(an untextured segment, UiTextureTableHandle.None), not the separate rect bucket"); + Assert.True( + outlineIndex < frontIndex, + "the back panel's border segment must be submitted BEFORE the front sprite's " + + "segment so it composites underneath it, matching the actual paint order"); + + // No outline geometry may land in TextRenderer's separate untextured + // rect bucket at all: that bucket always flushes AFTER every sprite + // segment regardless of submission order, which is exactly the bug — + // an outline drawn there would win against every window painted after it. + Assert.Equal(0, renderer.DebugRectVertexCount); + } +}