fix(ui): retained-UI rect outlines composite in painter order, not above every window
Owner report: with the MossTank plugin window BEHIND the inventory window, the plugin's "Force Buff"/"Cancel Force Buff" button border outlines drew on top of the inventory paperdoll. Only outlines leaked; fills did not. Root cause: TextRenderer composited three buckets per layer — submission- ordered sprite segments, then ALL untextured DrawRect quads (_rectBuf), then debug text (Flush/DrawLayer). UiRenderContext.DrawRect forwarded into that separate rect bucket, which always flushed AFTER every sprite segment regardless of submission order. UiRenderContext.DrawRectOutline is four DrawRect calls, so every BorderColor outline in the retained UI (UiPanel, UiMarkupList) composited above every window's sprite content drawn after it, instead of only the windows actually painted before it. Fix: UiRenderContext.DrawRect now forwards to DrawFill — the same untextured SPRITE-bucket segment DrawFill already used for panel backgrounds — so DrawRectOutline inherits real painter/submission order. Audited the only other DrawRect caller (UiMeter's bg-then-bar fill, which already relied on same-call submission order and is unaffected) and the only other DrawRectOutline callers (UiPanel, UiMarkupList, both routed through the same fixed chokepoint). TextRenderer.DrawRect/DrawRectOutline and the _rectBuf bucket are left in place (not deleted) with updated doc comments noting no caller in src/ outside TextRenderer itself reaches them anymore; a future non-retained-UI caller (e.g. a debug overlay) could still want that "always on top of sprites" behavior. Added TextRenderer.DebugRectVertexCount (test-only) and a failing-first regression test (UiRectOutlinePainterOrderTests) that builds a real UiPanel border behind a later-added opaque sprite and asserts submission order. No retail-divergence register row: this is a renderer ordering bug, not a documented retail behavior deviation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
47eb2d575b
commit
761a7519f1
3 changed files with 199 additions and 30 deletions
|
|
@ -42,8 +42,10 @@ namespace AcDream.App.Rendering;
|
|||
/// Vulkan backend gets from the pass description for free.</para>
|
||||
///
|
||||
/// 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
|
||||
/// (<see cref="DrawRect"/>'s <c>_rectBuf</c>) composites AFTER every sprite
|
||||
/// segment regardless of submission order — see that method's doc comment for
|
||||
/// why the retained UI (<c>AcDream.App.UI</c>) no longer feeds it.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Test-only: vertex count queued in the NORMAL-layer untextured rect bucket
|
||||
/// (<see cref="DrawRect"/>'s <c>_rectBuf</c> — 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 <see cref="DrawLayer"/>).
|
||||
/// <c>AcDream.App.Tests</c>-only via <c>InternalsVisibleTo</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
|
||||
/// <summary>
|
||||
/// Draw a filled rectangle into the SEPARATE untextured-rect bucket
|
||||
/// (<c>_rectBuf</c>/<c>_overlayRectBuf</c>), which <see cref="Flush"/> always
|
||||
/// composites AFTER every sprite segment in the layer, REGARDLESS of submission
|
||||
/// order — the opposite of painter order.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The retained UI (<c>AcDream.App.UI</c>) does not call this.</b> It used to,
|
||||
/// through <see cref="AcDream.App.UI.UiRenderContext.DrawRect"/> — every
|
||||
/// <c>BorderColor</c> outline (<see cref="AcDream.App.UI.UiPanel"/>,
|
||||
/// <see cref="AcDream.App.UI.UiMarkupList"/>, via
|
||||
/// <see cref="AcDream.App.UI.UiRenderContext.DrawRectOutline"/>) 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. <c>UiRenderContext.DrawRect</c> now forwards to
|
||||
/// <see cref="DrawFill"/> instead (the same untextured SPRITE-bucket segment,
|
||||
/// painter/submission order), so this bucket is currently unreached by any
|
||||
/// caller in <c>src/</c> 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.
|
||||
/// </para>
|
||||
/// </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; }
|
||||
|
|
@ -258,10 +292,11 @@ public sealed class TextRenderer : IDisposable
|
|||
}
|
||||
|
||||
/// <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.
|
||||
/// when active), so it composites in painter/submission order with sprites, outline
|
||||
/// segments, and dat-font text. Every retained-UI fill and outline
|
||||
/// (<see cref="AcDream.App.UI.UiRenderContext.DrawRect"/>/<c>DrawFill</c>/
|
||||
/// <c>DrawRectOutline</c>) routes through here, not <see cref="DrawRect"/> — see that
|
||||
/// method's doc comment for why.
|
||||
///
|
||||
/// <para>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);
|
||||
|
||||
/// <summary>Draw a 1-pixel-thick outline rect.</summary>
|
||||
/// <summary>Draw a 1-pixel-thick outline rect through the (unreached-by-retained-UI)
|
||||
/// rect bucket — see <see cref="DrawRect"/>'s doc comment. Retained-UI outlines go
|
||||
/// through <see cref="AcDream.App.UI.UiRenderContext.DrawRectOutline"/> instead, which
|
||||
/// composites in painter order via <see cref="DrawFill"/>.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>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 <see cref="Flush"/>.</summary>
|
||||
private void DrawLayer(
|
||||
List<SpriteSeg> spriteSegs, int segUsed,
|
||||
List<float> rectBuf, int rectVerts,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="TextRenderer.DrawRect"/>'s separate untextured-rect bucket, which
|
||||
/// <see cref="TextRenderer.Flush"/> always composited AFTER every sprite segment in
|
||||
/// the layer, regardless of submission order — so a <see cref="DrawRectOutline"/>
|
||||
/// border (every <c>BorderColor</c> outline in the retained UI: <see cref="UiPanel"/>,
|
||||
/// <see cref="UiMarkupList"/>) 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 <see cref="DrawFill"/> uses makes painter order the
|
||||
/// ONLY thing that decides who's on top, exactly like sprites and text.
|
||||
/// See <see cref="TextRenderer.DrawRect"/>'s own doc comment for what (if anything)
|
||||
/// still uses that bucket.
|
||||
/// </summary>
|
||||
public void DrawRect(float x, float y, float w, float h, Vector4 color) => DrawFill(x, y, w, h, color);
|
||||
|
||||
/// <summary>Solid-colour fill drawn in the SPRITE bucket (painter order with text), for
|
||||
/// a panel BACKGROUND that text draws on top of. <see cref="DrawRect"/> composites after
|
||||
/// all sprites and would cover the text — use this for backgrounds, that for foreground
|
||||
/// fills (carets, vital bars).</summary>
|
||||
/// <summary>Solid-colour fill drawn in the SPRITE bucket (painter/submission order with
|
||||
/// every other sprite, outline segment, and dat-font glyph) — see <see cref="DrawRect"/>'s
|
||||
/// doc comment; the two are now the same operation under different names (background vs.
|
||||
/// foreground callers), both routing through <see cref="TextRenderer.DrawFill"/>.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Four-quad border, each side drawn through <see cref="DrawRect"/> — and so,
|
||||
/// since that fix, in painter/submission order with everything else rather than always
|
||||
/// on top of every window drawn after it. See <see cref="DrawRect"/>'s doc comment.</summary>
|
||||
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
|
||||
{
|
||||
if (thickness <= 0f || w <= 0f || h <= 0f) return;
|
||||
|
|
|
|||
112
tests/AcDream.App.Tests/UI/UiRectOutlinePainterOrderTests.cs
Normal file
112
tests/AcDream.App.Tests/UI/UiRectOutlinePainterOrderTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 — <see cref="TextRenderer"/> used to composite in three buckets per
|
||||
/// layer: submission-ordered sprite segments, THEN every untextured
|
||||
/// <see cref="TextRenderer.DrawRect"/> quad (regardless of when it was
|
||||
/// submitted), THEN debug text. <see cref="UiRenderContext.DrawRect"/> (and so
|
||||
/// <see cref="UiRenderContext.DrawRectOutline"/>, which every
|
||||
/// <c>BorderColor</c> outline in the retained UI — <see cref="UiPanel"/>,
|
||||
/// <see cref="UiMarkupList"/> — 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.
|
||||
///
|
||||
/// <para>
|
||||
/// This pins the mechanism directly against the real <see cref="UiPanel"/>
|
||||
/// 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 <see cref="UiRenderContext.DrawFill"/> already does
|
||||
/// for panel backgrounds.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue