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

@ -0,0 +1,106 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Campaign LA gate round 2 (register AD-98 filtering fidelity, 2026-08-15):
/// pins <see cref="TextRenderer.DrawSprite"/>'s texture-swap seam —
/// <see cref="TextRenderer.LinearTwinResolver"/>, consulted only while
/// <see cref="TextRenderer.CanvasScale"/> is not <see cref="Vector2.One"/> — the
/// half of the fix that doesn't need a live GPU. The user-visible symptom this
/// answers: the fixed-canvas char-select stretch (73041d70) reported JAGGED
/// text, because dat-font glyph atlases upload nearest (pixel-exact at 1:1) and
/// stayed nearest even while every quad was being magnified 2.4×1.8. The actual
/// linear-twin CREATION lives in <c>TextureCache.GetOrCreateLinearUiTwin</c>
/// (GPU-backed, see <c>TextureCacheLinearTwinTests</c>); this file proves the
/// RENDERER SIDE of the seam — segment handle selection — using a fake resolver
/// so the assertion doesn't depend on TextureCache's own wiring being correct.
/// </summary>
public sealed class TextRendererLinearTwinTests
{
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static TextRenderer BuildRenderer()
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
return renderer;
}
[Fact]
public void CanvasScaleOne_DrawSprite_NeverConsultsResolver_KeepsOriginalHandle()
{
// At the native 1:1 scale (every in-world/UI frame today) the resolver
// must not even be CALLED, not just "called and return the same value" —
// a resolver that throws proves the ordinary path pays zero overhead for
// a feature it never activates, exactly the "lazy" design constraint the
// Campaign LA round-2 follow-up was scoped to.
TextRenderer renderer = BuildRenderer();
renderer.LinearTwinResolver = _ => throw new System.InvalidOperationException(
"LinearTwinResolver must not be consulted while CanvasScale == One.");
renderer.DrawSprite(5u, 0, 0, 10, 10, 0, 0, 1, 1, Vector4.One);
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(5u, seg.Texture);
}
[Fact]
public void CanvasScaleNotOne_DrawSprite_SwapsHandleThroughResolver()
{
// The fixed-canvas case: CanvasScale is set by UiRoot.Draw for the
// duration of a fixed-canvas screen's tree. A nearest-registered dat-font
// glyph handle (5) must draw through its linear twin (999), not itself.
TextRenderer renderer = BuildRenderer();
renderer.CanvasScale = new Vector2(2.4f, 1.8f);
renderer.LinearTwinResolver = handle => handle == 5u ? 999u : handle;
renderer.DrawSprite(5u, 0, 0, 10, 10, 0, 0, 1, 1, Vector4.One);
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(999u, seg.Texture);
}
[Fact]
public void CanvasScaleNotOne_ResolverIsIdentityForUnknownHandles()
{
// Chrome/background art (never registered nearest) and
// UiTextureTableHandle.None (DrawFill's untextured branch) are the
// majority of scaled-canvas draws. TextureCache.GetOrCreateLinearUiTwin
// returns them unchanged; this pins that TextRenderer forwards whatever
// the resolver returns without a separate "was this swapped" branch.
TextRenderer renderer = BuildRenderer();
renderer.CanvasScale = new Vector2(2.4f, 1.8f);
renderer.LinearTwinResolver = handle => handle == 5u ? 999u : handle;
renderer.DrawSprite(7u, 0, 0, 10, 10, 0, 0, 1, 1, Vector4.One);
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(7u, seg.Texture);
}
[Fact]
public void CanvasScaleNotOne_NoResolverWired_KeepsOriginalHandle_DoesNotThrow()
{
// A host/test that never wires LinearTwinResolver (every existing
// TextRenderer construction site before this change, and any future
// test double) must keep drawing — nearest stays nearest, the
// pre-existing (if jagged) behavior, rather than a null-reference
// failure the moment a fixed-canvas screen activates.
TextRenderer renderer = BuildRenderer();
renderer.CanvasScale = new Vector2(2.4f, 1.8f);
renderer.DrawSprite(5u, 0, 0, 10, 10, 0, 0, 1, 1, Vector4.One);
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(5u, seg.Texture);
}
}

View file

@ -0,0 +1,124 @@
using System.Linq;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Campaign LA gate round 2 (register AD-98 filtering fidelity, 2026-08-15):
/// pins <see cref="TextureCache.GetOrCreateLinearUiTwin"/> — the GPU-table half
/// of the fixed-canvas jagged-text fix (<c>TextRendererLinearTwinTests</c> pins
/// the renderer-side handle swap this method feeds).
///
/// <para>
/// Uses <see cref="TextureCache.UploadRgba8"/> rather than
/// <see cref="TextureCache.GetOrUploadRenderSurface"/> to get a nearest-sampled
/// handle without any DAT fixture — <c>UploadRgba8</c> never touches the injected
/// <c>IDatReaderWriter</c>, so the simple <c>TextureCache(device, dats)</c>
/// constructor can run with <see langword="null"/>/<see cref="RecordingGpuDevice"/>
/// and no live GPU, matching this suite's existing renderer-test-double idiom.
/// </para>
/// </summary>
public sealed class TextureCacheLinearTwinTests
{
private static (RecordingGpuDevice device, TextureCache cache) Build()
{
var device = new RecordingGpuDevice();
// UploadRgba8/GetOrCreateLinearUiTwin/Dispose never touch the dats
// reference — see the class doc comment.
var cache = new TextureCache(device, dats: null!);
// RecordingGpuDevice's own constructor registers a 1x1 default-white
// placeholder (DefaultTextureSlot) — clear that registration out of the
// recorded call log so each test's assertions only see the
// registrations IT caused. Clear() only wipes the call log, not the
// slot/sampler state, so DefaultTextureSlot itself is untouched.
device.Clear();
return (device, cache);
}
[Fact]
public void UnknownHandle_ReturnsUnchanged()
{
// Chrome/background art (never registered nearest) and any handle this
// cache never saw (including UiTextureTableHandle.None == 0) pass
// through untouched — there is nothing to swap.
(_, TextureCache cache) = Build();
Assert.Equal(0u, cache.GetOrCreateLinearUiTwin(0u));
Assert.Equal(12345u, cache.GetOrCreateLinearUiTwin(12345u));
}
[Fact]
public void NearestHandle_GetsADifferentTwinHandle_SampledLinear()
{
(RecordingGpuDevice device, TextureCache cache) = Build();
byte[] rgba = new byte[4 * 4 * 4];
uint nearestHandle = cache.UploadRgba8(rgba, 4, 4, nearest: true);
uint twinHandle = cache.GetOrCreateLinearUiTwin(nearestHandle);
Assert.NotEqual(0u, twinHandle);
Assert.NotEqual(nearestHandle, twinHandle);
// The SECOND registration recorded against the device (the first is the
// original nearest upload) must carry a linear filter — the whole point
// of the twin.
var registrations = device.OfKind<GpuRecordedTextureRegistration>().ToList();
Assert.Equal(2, registrations.Count);
Assert.Equal(GpuFilter.Nearest, registrations[0].Sampler.MinFilter);
Assert.Equal(GpuFilter.Linear, registrations[1].Sampler.MinFilter);
Assert.Equal(GpuFilter.Linear, registrations[1].Sampler.MagFilter);
// Both registrations name the SAME underlying texture — the twin reuses
// the original decoded pixels rather than re-uploading.
Assert.Equal(registrations[0].TextureName, registrations[1].TextureName);
}
[Fact]
public void NearestHandle_RepeatedRequest_ReturnsTheSameCachedTwin()
{
(RecordingGpuDevice device, TextureCache cache) = Build();
byte[] rgba = new byte[4 * 4 * 4];
uint nearestHandle = cache.UploadRgba8(rgba, 4, 4, nearest: true);
uint first = cache.GetOrCreateLinearUiTwin(nearestHandle);
uint second = cache.GetOrCreateLinearUiTwin(nearestHandle);
Assert.Equal(first, second);
// Exactly one twin registration — the second request must not create
// another device-table slot.
Assert.Equal(2, device.OfKind<GpuRecordedTextureRegistration>().Count());
}
[Fact]
public void NonNearestUpload_HasNoTwin()
{
// UploadRgba8's default (nearest: false) mirrors chrome/background art:
// it already samples GpuSamplerDescription.WorldRepeat (linear), so it
// never enters the nearest-source table and GetOrCreateLinearUiTwin
// hands the same handle straight back.
(_, TextureCache cache) = Build();
byte[] rgba = new byte[4 * 4 * 4];
uint linearHandle = cache.UploadRgba8(rgba, 4, 4, nearest: false);
Assert.Equal(linearHandle, cache.GetOrCreateLinearUiTwin(linearHandle));
}
[Fact]
public void Dispose_ReleasesTheTwinSlot()
{
(RecordingGpuDevice device, TextureCache cache) = Build();
byte[] rgba = new byte[4 * 4 * 4];
uint nearestHandle = cache.UploadRgba8(rgba, 4, 4, nearest: true);
uint twinHandle = cache.GetOrCreateLinearUiTwin(nearestHandle);
GpuTextureSlot twinSlot = UiTextureTableHandle.ToSlot(twinHandle);
cache.Dispose();
Assert.Contains(
device.OfKind<GpuRecordedTextureRelease>(),
release => release.Slot == twinSlot.Index);
}
}