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:
parent
828bec5fb5
commit
ebfeaff840
4 changed files with 248 additions and 63 deletions
|
|
@ -25,6 +25,7 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
private readonly Shader _shader;
|
||||
private readonly uint _vao;
|
||||
private readonly uint _vbo;
|
||||
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
|
||||
private int _vboCapacityBytes;
|
||||
|
||||
private readonly List<float> _textBuf = new(8192);
|
||||
|
|
@ -42,6 +43,21 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
private int _rectVerts;
|
||||
private Vector2 _screenSize;
|
||||
|
||||
// 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; }
|
||||
|
||||
public TextRenderer(GL gl, string shaderDir)
|
||||
{
|
||||
_gl = gl;
|
||||
|
|
@ -65,6 +81,20 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
||||
_gl.BindVertexArray(0);
|
||||
|
||||
// 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).
|
||||
_whiteTex = _gl.GenTexture();
|
||||
_gl.BindTexture(TextureTarget.Texture2D, _whiteTex);
|
||||
Span<byte> whitePixel = stackalloc byte[] { 255, 255, 255, 255 };
|
||||
fixed (byte* wp = whitePixel)
|
||||
_gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0,
|
||||
PixelFormat.Rgba, PixelType.UnsignedByte, wp);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, 0);
|
||||
}
|
||||
|
||||
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
|
||||
|
|
@ -76,15 +106,29 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
_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)
|
||||
{
|
||||
AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color);
|
||||
_rectVerts += 6;
|
||||
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)
|
||||
{
|
||||
|
|
@ -129,11 +173,8 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
|
||||
if (gw > 0 && gh > 0)
|
||||
{
|
||||
AppendQuad(_textBuf,
|
||||
gx, gy, gw, gh,
|
||||
g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY,
|
||||
color);
|
||||
_textVerts += 6;
|
||||
if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; }
|
||||
else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; }
|
||||
}
|
||||
cursorX += g.Advance;
|
||||
}
|
||||
|
|
@ -147,26 +188,32 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
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;
|
||||
if (_segUsed > 0 && _spriteSegs[_segUsed - 1].Texture == texture)
|
||||
{
|
||||
seg = _spriteSegs[_segUsed - 1]; // extend the current same-texture run
|
||||
}
|
||||
else if (_segUsed < _spriteSegs.Count)
|
||||
{
|
||||
seg = _spriteSegs[_segUsed++]; // reuse a pooled segment
|
||||
seg.Texture = texture;
|
||||
seg.Verts.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
seg = new SpriteSeg { Texture = texture };
|
||||
_spriteSegs.Add(seg);
|
||||
_segUsed++;
|
||||
}
|
||||
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>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)
|
||||
|
|
@ -197,8 +244,9 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
|
||||
public void Flush(BitmapFont? font)
|
||||
{
|
||||
bool hasSprites = _segUsed > 0;
|
||||
if (_textVerts == 0 && _rectVerts == 0 && !hasSprites) return;
|
||||
bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0;
|
||||
bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0;
|
||||
if (!anyNormal && !anyOverlay) return;
|
||||
|
||||
_shader.Use();
|
||||
_shader.SetVec2("uScreenSize", _screenSize);
|
||||
|
|
@ -210,6 +258,15 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
|
||||
bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
|
||||
bool wasCull = _gl.IsEnabled(EnableCap.CullFace);
|
||||
// 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).
|
||||
bool wasA2C = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage);
|
||||
bool wasMsaa = _gl.IsEnabled(EnableCap.Multisample);
|
||||
_gl.Disable(EnableCap.SampleAlphaToCoverage);
|
||||
_gl.Disable(EnableCap.Multisample);
|
||||
_gl.Disable(EnableCap.DepthTest);
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
_gl.DepthMask(false);
|
||||
|
|
@ -221,19 +278,40 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
// 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 — each meter's dat-font number draws
|
||||
// after its own bar sprites. Buckets 2 (rects) + 3 (debug text) composite
|
||||
// on top, in that order.
|
||||
// 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);
|
||||
DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font);
|
||||
|
||||
// 1. RGBA dat sprites first — one draw call per distinct GL texture.
|
||||
if (hasSprites)
|
||||
// Restore GL state.
|
||||
_gl.DepthMask(true);
|
||||
if (!wasBlend) _gl.Disable(EnableCap.Blend);
|
||||
if (wasCull) _gl.Enable(EnableCap.CullFace);
|
||||
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
|
||||
if (wasA2C) _gl.Enable(EnableCap.SampleAlphaToCoverage);
|
||||
if (wasMsaa) _gl.Enable(EnableCap.Multisample);
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
// 1. RGBA dat sprites — one draw call per distinct GL texture.
|
||||
if (segUsed > 0)
|
||||
{
|
||||
_shader.SetInt("uUseTexture", 2);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_shader.SetInt("uTex", 0);
|
||||
for (int i = 0; i < _segUsed; i++)
|
||||
for (int i = 0; i < segUsed; i++)
|
||||
{
|
||||
var seg = _spriteSegs[i];
|
||||
var seg = spriteSegs[i];
|
||||
if (seg.Verts.Count == 0) continue;
|
||||
_gl.BindTexture(TextureTarget.Texture2D, seg.Texture);
|
||||
UploadBuffer(seg.Verts);
|
||||
|
|
@ -242,31 +320,23 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
}
|
||||
|
||||
// 2. Untextured rects — widget fills on top of the chrome.
|
||||
if (_rectVerts > 0)
|
||||
if (rectVerts > 0)
|
||||
{
|
||||
_shader.SetInt("uUseTexture", 0);
|
||||
UploadBuffer(_rectBuf);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts);
|
||||
UploadBuffer(rectBuf);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts);
|
||||
}
|
||||
|
||||
// 3. Textured text glyphs on top.
|
||||
if (_textVerts > 0 && font is not null)
|
||||
// 3. Textured debug-font text glyphs on top.
|
||||
if (textVerts > 0 && font is not null)
|
||||
{
|
||||
_shader.SetInt("uUseTexture", 1);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
|
||||
_shader.SetInt("uTex", 0);
|
||||
UploadBuffer(_textBuf);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts);
|
||||
UploadBuffer(textBuf);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)textVerts);
|
||||
}
|
||||
|
||||
// Restore GL state.
|
||||
_gl.DepthMask(true);
|
||||
if (!wasBlend) _gl.Disable(EnableCap.Blend);
|
||||
if (wasCull) _gl.Enable(EnableCap.CullFace);
|
||||
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
private void UploadBuffer(List<float> buf)
|
||||
|
|
@ -289,6 +359,7 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
_gl.DeleteTexture(_whiteTex);
|
||||
_gl.DeleteBuffer(_vbo);
|
||||
_gl.DeleteVertexArray(_vao);
|
||||
_shader.Dispose();
|
||||
|
|
|
|||
|
|
@ -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&Right ⇒ stretch width
|
||||
/// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise
|
||||
/// pin left at fixed width. Same logic vertically.</summary>
|
||||
|
|
|
|||
|
|
@ -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 & 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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue