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>
124 lines
5.2 KiB
C#
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);
|
|
}
|
|
}
|