feat(D.2b): UI render infra — overlay layer, DrawFill, crisp text, write-mode focus

The retail-look render + focus primitives this chat pass builds on:

- TextRenderer: an OVERLAY layer (sprite/rect/text buckets flushed AFTER the
  normal layer) so an open popup composites on top of everything incl. rect
  panel backgrounds; a DrawFill primitive (solid quad via a 1x1 white texture)
  routed through the SPRITE bucket so a panel background draws UNDER its text
  instead of being washed by the later rect bucket; and the text pass now
  disables SampleAlphaToCoverage + Multisample so glyph alpha edges aren't
  dithered into MSAA coverage (the "fuzzy text") — self-contained GL state
  per feedback_render_self_contained_gl_state.
- UiRenderContext.DrawStringDat: snap the line baseline to a whole pixel ONCE
  then add the integer per-glyph offset (retail DrawCharacter takes an int
  pen-Y + schar m_VerticalOffsetBefore) — fixes the "letters dip down" jitter
  at a fractional line origin. Outline pass is now opt-in (retail gates it per
  element via SetOutline; default off = crisp fill-only). Adds DrawFill +
  Begin/EndOverlayLayer.
- UiElement: OnDrawOverlay + DrawOverlays (second traversal), FindRoot (blur
  self), ResetAnchorCapture (re-baseline an anchored element after reflow).
- UiRoot: runs the overlay pass after the main tree; Tab/Enter focuses the
  DefaultTextInput (write-mode activation); a left click on a non-edit target
  blurs the focused input (exit write mode without submitting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-16 15:23:48 +02:00
parent 828bec5fb5
commit ebfeaff840
4 changed files with 248 additions and 63 deletions

View file

@ -154,6 +154,16 @@ public abstract class UiElement
/// </summary>
protected virtual void OnDraw(UiRenderContext ctx) { }
/// <summary>
/// Draw content that must sit ON TOP of the ENTIRE UI, regardless of this
/// element's position in the tree — open menus, dropdowns, tooltips. Called in
/// a SECOND traversal after the whole tree's <see cref="OnDraw"/> pass, with the
/// same accumulated transform/alpha this element had during its normal draw.
/// Retail spawns popups as ROOT elements (UIElement_Menu::MakePopup) for exactly
/// this reason; this is the equivalent without reparenting. Default: nothing.
/// </summary>
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
protected virtual void OnTick(double deltaSeconds) { }
@ -213,6 +223,34 @@ public abstract class UiElement
}
}
/// <summary>Second draw traversal: re-walks the tree applying the same
/// transform/alpha as <see cref="DrawSelfAndChildren"/> and calls
/// <see cref="OnDrawOverlay"/> on each element, so popups composite on top of
/// everything drawn in the main pass (dat-font glyphs and sprites share one
/// submission-ordered bucket, so later submissions win).</summary>
internal void DrawOverlays(UiRenderContext ctx)
{
if (!Visible) return;
ctx.PushTransform(Left, Top);
ctx.PushAlpha(Opacity);
try
{
OnDrawOverlay(ctx);
if (_children.Count > 0)
{
var ordered = _children.ToArray();
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawOverlays(ctx);
}
}
finally
{
ctx.PopAlpha();
ctx.PopTransform();
}
}
internal void TickSelfAndChildren(double dt)
{
if (!Visible) return;
@ -275,6 +313,22 @@ public abstract class UiElement
Left = x; Top = y; Width = w; Height = h;
}
/// <summary>Forget the captured anchor margins so the next <see cref="ApplyAnchor"/>
/// re-captures them from the CURRENT rect. Call after manually repositioning/resizing
/// an anchored element at runtime (e.g. reflowing the chat input when the channel
/// button width changes) so the new rect becomes the anchor baseline.</summary>
internal void ResetAnchorCapture() => _anchorCaptured = false;
/// <summary>Walk up to the owning <see cref="UiRoot"/> (the top of the tree), or null
/// if this element is not attached. Lets a widget reach focus/capture services — e.g.
/// a chat input blurring itself (exiting write mode) after submit.</summary>
internal UiRoot? FindRoot()
{
UiElement e = this;
while (e.Parent is not null) e = e.Parent;
return e as UiRoot;
}
/// <summary>Compute an anchored child rect. Left&amp;Right ⇒ stretch width
/// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise
/// pin left at fixed width. Same logic vertically.</summary>

View file

@ -68,11 +68,23 @@ public sealed class UiRenderContext
public Vector2 CurrentOrigin => _current;
/// <summary>Route subsequent draws to the overlay layer (flushed on top of the whole
/// UI). Used by the root for the popup/overlay traversal. Pair with <see cref="EndOverlayLayer"/>.</summary>
public void BeginOverlayLayer() => TextRenderer.OverlayMode = true;
public void EndOverlayLayer() => TextRenderer.OverlayMode = false;
// ── Pass-through draw helpers (add current translate) ──────────────
public void DrawRect(float x, float y, float w, float h, Vector4 color)
=> TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(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>
public void DrawFill(float x, float y, float w, float h, Vector4 color)
=> TextRenderer.DrawFill(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color));
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
=> TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color), thickness);
@ -102,10 +114,17 @@ public sealed class UiRenderContext
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> and each
/// glyph is positioned at <c>pen + HorizontalOffsetBefore</c> on the X axis
/// and at <c>baseline + VerticalOffsetBefore - (BaselineOffset)</c> via the
/// glyph's OffsetY into the atlas. If the font has no background atlas the
/// outline pass is skipped.
/// glyph's OffsetY into the atlas.
///
/// <para><paramref name="outline"/> gates the black outline pass. Retail decides
/// this PER text element: <c>UIElement_Text::DrawSelf</c> (acclient 0x00467aa0)
/// runs the outline pass only when <c>m_bitField &amp; 0x10</c> is set — i.e. the
/// element called <c>SetOutline(true)</c> (LayoutDesc property 0xd). The DEFAULT
/// is OFF (one fill-only pass): the talk-focus menu items set no outline, so an
/// always-on outline shows as a grey halo over the solid menu panel. Pass
/// <c>outline:true</c> only for elements retail outlines.</para>
/// </summary>
public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color)
public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color, bool outline = false)
{
if (font is null || string.IsNullOrEmpty(text)) return;
@ -116,32 +135,44 @@ public sealed class UiRenderContext
float originY = _current.Y + y;
float pen = originX;
var outline = new Vector4(0f, 0f, 0f, color.W);
// Snap the LINE baseline to a whole pixel ONCE. Retail's
// SurfaceWindow::DrawCharacter (acclient 0x00442bd0) takes an int32 pen Y
// (arg3) and adds the glyph's integer m_VerticalOffsetBefore (a schar) — every
// glyph on a line shares one integer baseline. If we instead round EACH glyph's
// Y independently and the caller passes a fractional line Y (e.g. a channel-menu
// item centered in a 17px row over a 16px font → y = 0.5), adjacent letters round
// to different rows and the line looks crooked ("letters dip down"). The vitals
// digits never showed it because their bar baseline lands on an integer; chat text
// does. Snapping the baseline once, then adding the integer offset, keeps the whole
// line on one row and pixel-aligned.
float baseY = System.MathF.Round(originY);
var outlineTint = new Vector4(0f, 0f, 0f, color.W);
for (int i = 0; i < text.Length; i++)
{
if (!font.TryGetGlyph(text[i], out var g))
continue;
// Pixel-snap each glyph's destination to whole pixels so the atlas samples
// texel-aligned. Without this, a fractional bar width after resize puts the
// centered number on a sub-pixel x and linear filtering smears the glyphs
// (the "unsharp at certain sizes" artifact). The pen keeps its true
// fractional advance, so only the per-glyph dest is snapped.
// Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its
// true fractional advance). Vertical: integer baseline + integer per-glyph
// offset — never an independent per-glyph round (see baseY note above).
float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore);
float gy = System.MathF.Round(originY + g.VerticalOffsetBefore);
float gy = baseY + g.VerticalOffsetBefore;
float gw = g.Width;
float gh = g.Height;
if (gw > 0f && gh > 0f)
{
// Background (outline) atlas pass, tinted black — drawn behind.
if (font.BackgroundTexture != 0)
// Background (outline) atlas pass, tinted black — drawn behind. Gated by
// `outline` (retail's per-element m_bitField & 0x10); off by default so UI
// text is crisp fill-only and free of the grey halo over solid panels.
if (outline && font.BackgroundTexture != 0)
{
var (bu0, bv0, bu1, bv1) = AtlasUv(
g.OffsetX, g.OffsetY, g.Width, g.Height,
font.BackgroundWidth, font.BackgroundHeight);
TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outline);
TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outlineTint);
}
// Foreground (fill) atlas pass, tinted with the requested color.

View file

@ -44,6 +44,10 @@ public sealed class UiRoot : UiElement
/// <summary>Widget currently receiving keyboard events.</summary>
public UiElement? KeyboardFocus { get; private set; }
/// <summary>The edit control activated by Tab/Enter when nothing is focused — retail's
/// chat input "write mode" toggle. Set by the host once the chat window is built.</summary>
public UiElement? DefaultTextInput { get; set; }
/// <summary>
/// Single modal overlay; while set, mouse clicks outside its rect
/// are ignored. Retail sets this via Device vtable +0x48.
@ -131,6 +135,13 @@ public sealed class UiRoot : UiElement
// Render children (panels) sorted by z-order — modal last so it
// sits on top.
DrawSelfAndChildren(ctx);
// Second pass: open popups/menus draw ON TOP of the whole tree (so e.g. the
// chat channel menu isn't greyed by the translucent chat panel that draws
// after it in the main pass). Routed to the renderer's overlay layer so it
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
ctx.BeginOverlayLayer();
DrawOverlays(ctx);
ctx.EndOverlayLayer();
}
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
@ -200,12 +211,18 @@ public sealed class UiRoot : UiElement
var (target, _, _) = HitTestTopDown(x, y);
if (target is null)
{
// Clicking the 3D world exits write mode (no submit) and returns control to
// the character — retail blurs the chat input on an outside click.
if (btn == UiMouseButton.Left) SetKeyboardFocus(null);
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
return;
}
// Set keyboard focus if target accepts it.
if (target.AcceptsFocus) SetKeyboardFocus(target);
// Keyboard focus follows a left click: the input bar (an edit control) takes
// focus = enters write mode; clicking anything else (chrome, Send, scrollbar,
// menu, another window) blurs the input = exits write mode WITHOUT submitting.
if (btn == UiMouseButton.Left)
SetKeyboardFocus(target.AcceptsFocus ? target : null);
SetCapture(target);
@ -355,6 +372,18 @@ public sealed class UiRoot : UiElement
public void OnKeyDown(int vk, uint lparam = 0)
{
// Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat
// input (retail's chat-activation hotkeys). Consumed so the same press doesn't
// also fall through to a game hotkey.
if (KeyboardFocus is null && DefaultTextInput is not null
&& (vk == (int)Silk.NET.Input.Key.Tab
|| vk == (int)Silk.NET.Input.Key.Enter
|| vk == (int)Silk.NET.Input.Key.KeypadEnter))
{
SetKeyboardFocus(DefaultTextInput);
return;
}
// Focus widget first.
if (KeyboardFocus is not null)
{