acdream/tests/AcDream.App.Tests/Rendering/TextureCacheLinearTwinTests.cs
Erik 308f40a3fb 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>
2026-08-15 11:05:59 +02:00

124 lines
5.2 KiB
C#

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);
}
}