fix(ui): Campaign LA gate round 2 — fixed-canvas stretch filters bilinearly like retail's presentation blit

AD-98's fixed-canvas stretch (73041d70) scales every retained-UI quad at
TextRenderer.AppendQuad, but the live gate reported it JAGGED — text
especially. Cause: dat-font glyph atlases and IconComposer's composited
icons upload nearest (TextureCache.UploadUiTexture's UiNearestRepeat
sampler) — correct at the native 1:1 scale (pixel-exact retail art), but
aliased once magnified 2.4x1.8. Chrome/background art was already fine:
it uploads through GpuSamplerDescription.WorldRepeat (linear) by default.
Retail's own fixed-canvas presentation is a single bilinear-filtered
frame blit, never a per-texture stretch — this closes that gap one step
earlier, at the source texture, without adding RHI surface area.

- TextureCache.GetOrCreateLinearUiTwin: lazily registers a SECOND table
  slot for a nearest handle's IGpuTexture, sampled WorldRepeat (linear)
  instead of nearest — no re-decode, no re-upload, no extra memory-ledger
  bytes. Returns the handle unchanged for anything never registered
  nearest (chrome, UiTextureTableHandle.None), so it's a cheap
  unconditional probe. Twin slots are released in Dispose without
  double-disposing the shared texture.
- TextRenderer.LinearTwinResolver + the DrawSprite chokepoint: swaps a
  sprite's texture handle through the resolver only while
  CanvasScale != One. At CanvasScale == One the resolver is never even
  called — zero overhead on the ordinary in-world/UI path.
- InteractionRetainedUiComposition wires the resolver to TextureCache
  right after every UiHost acquisition (the lease can hand back a host
  from a prior session against a fresh TextureCache).
- AD-98's register row gets one added sentence recording the fix.

Tests: TextRendererLinearTwinTests pins the renderer-side handle-swap
seam GPU-free (segment handle selection); TextureCacheLinearTwinTests
pins twin creation/reuse/dispose against RecordingGpuDevice. App suite
5097/3 skips (Release, ACDREAM_PROBE_LIVE_MOUNT=1 live-DAT probes
included). Full solution builds clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 11:05:59 +02:00
parent 73041d7015
commit 308f40a3fb
6 changed files with 346 additions and 1 deletions

View file

@ -218,6 +218,21 @@ public sealed class TextRenderer : IDisposable
/// </summary>
internal Vector2 CanvasScale = Vector2.One;
/// <summary>
/// Campaign LA gate round 2 (register AD-98 filtering fidelity): resolves a
/// UI texture handle to its linear-sampled twin
/// (<see cref="TextureCache.GetOrCreateLinearUiTwin"/>), consulted by
/// <see cref="DrawSprite"/> only while <see cref="CanvasScale"/> is not One.
/// Wired once by the composition root right after <c>TextureCache</c> exists;
/// left null by any test/host that never sets it, in which case a scaled
/// draw keeps sampling its original slot — nearest stays nearest, exactly
/// today's (jagged) behavior, rather than throwing. Nearest-sampled dat-font
/// glyphs and composited icons are the only handles this ever changes —
/// see the resolver's own doc comment for why chrome/background art passes
/// through unchanged.
/// </summary>
internal Func<uint, uint>? LinearTwinResolver { get; set; }
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
public void Begin(Vector2 screenSize)
{
@ -365,6 +380,15 @@ public sealed 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)
{
// AD-98 filtering fidelity: while a fixed-canvas screen is stretching
// every quad (CanvasScale != One), sample nearest-registered handles
// through their linear twin instead — see LinearTwinResolver's doc
// comment. The resolver itself is the identity for any handle that
// isn't a nearest-sampled UI texture, so this is safe to call
// unconditionally rather than needing its own "is this nearest" check.
if (CanvasScale != Vector2.One && LinearTwinResolver is { } resolve)
texture = resolve(texture);
SpriteSeg seg = OverlayMode
? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
: NextSpriteSeg(_spriteSegs, ref _segUsed, texture);