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
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