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:
parent
73041d7015
commit
308f40a3fb
6 changed files with 346 additions and 1 deletions
106
tests/AcDream.App.Tests/Rendering/TextRendererLinearTwinTests.cs
Normal file
106
tests/AcDream.App.Tests/Rendering/TextRendererLinearTwinTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
124
tests/AcDream.App.Tests/Rendering/TextureCacheLinearTwinTests.cs
Normal file
124
tests/AcDream.App.Tests/Rendering/TextureCacheLinearTwinTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue