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>
106 lines
4.7 KiB
C#
106 lines
4.7 KiB
C#
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);
|
||
}
|
||
}
|