Two changes, one measurement. The V6m smoke pair put GL versus Vulkan at Holtburg at 18.52% of the frame differing at tolerance 2 with MSAA off. The same stop on the same instrument now measures 9.05%, and the two populations these address are gone from the difference map rather than merely smaller. 1. THE WORLD ATLASES WERE SAMPLED WITHOUT ANISOTROPY ON VULKAN, AND WITH THE DEVICE MAXIMUM ON GL. RhiWorldTextureArray -- the backend-neutral shared object/material atlas, and the only IWorldTextureArray the Vulkan arm ever constructs -- registered its clamp and repeat slots with GpuSamplerDescription.WorldClamp/WorldRepeat as written, which carry MaxAnisotropy 1. The GL arm asks for the driver's own GL_MAX_TEXTURE_MAX_ANISOTROPY twice over: ManagedGLTextureArray sets GL_TEXTURE_MAX_ANISOTROPY on the image, and the two sampler objects its resident bindless handles are built from (OpenGLGraphicsDevice.WrapSampler/ClampSampler) set it again, which is the one that actually wins. V6i-2 knew it was asking for 1 and said so in a comment -- "the world arm that draws through these arrays is the next slice, and it is the one that can gate a filtering change visually." That slice was V6j, the gate is V7, and this is it. Retail settles the question rather than the GL arm settling it. RenderDeviceD3D::SetDefaultD3DStates (0x005a3800) loops all sixteen sampler stages and issues SetSamplerState(stage, 0xA, this->m_D3DCaps.MaxAnisotropy) at 0x005a4230. 0xA is D3DSAMP_MAXANISOTROPY and the argument is the device's reported cap, not a setting -- so "as much anisotropy as this device has" is retail's own rule, the GL arm is faithful to it, and asking for 1 diverged from retail as well as from the shipping backend. No divergence-register row is owed in either direction: this retires a Vulkan-only gap and lands on retail's value. The fix asks for a ceiling rather than reading a limit back, because the pinned RHI contract (plan section 3.3) carries no anisotropy field and is frozen. It does not need one: VulkanGpuSampler already clamps MaxAnisotropy to VkPhysicalDeviceLimits.maxSamplerAnisotropy, Vulkan guarantees that limit is at least 16 wherever the samplerAnisotropy feature is supported -- which this backend requires -- and 16 is where every desktop driver caps. The request and the GL arm's read therefore land on the same number. What it was worth, from the difference map at the same stop: the roof shingles of both Holtburg cottages, which had been dense hatching across the whole surface, and the stone courses of the near building are now black. Measured as high-frequency energy (mean absolute neighbour difference, GL versus Vulkan) the right-hand roof went from visibly blurred to a ratio of 0.999 and the wall to 1.023; every other textured region in the frame is between 0.99 and 1.02. Grazing-angle surfaces are where anisotropy is the whole difference, which is why a roof was the loudest thing in the frame. 2. THE SKY HAS TWO CLOCKS AND ONLY ONE OF THEM WAS PINNABLE. ACDREAM_DAY_GROUP and the route's AcdreamCycleTimeOfDay presses pin the Dereth date, which chooses the day group, the keyframe and the sun angle. The cloud sheet does not read that clock: SkyRenderer accumulates TexVelocityX/Y against DateTime.UtcNow minus its own construction time, by design, because retail's clouds drift with real time regardless of the date. Two launches minutes apart therefore cannot agree about where the clouds are no matter what the route does, and the V6m smoke measured the cost -- 89% of its 18.52% sat in the top 240 rows. ACDREAM_SKY_PHASE_SECONDS (RuntimeOptions.SkyAnimationPhaseSeconds -> SkyRenderer.AnimationPhaseSecondsOverride) replaces that elapsed-seconds value with a fixed one. Unset -- the default, and every ordinary run -- keeps the wall clock, so nothing a user or the offline gate sees changes. The differential gate forces it on both launches alongside MSAA and the day group; the offline gate keeps its top-280 mask, because a same-commit GL pair still has the sun to disagree about. This is instrument determinism on the same footing as ACDREAM_DAY_GROUP, not a workaround: it is one input to a UV offset, it is off by default, and no shipping path reads it. The alternative on the table was -MaskTopPixels, which would have permanently blinded the campaign's strictest instrument to the entire sky -- one of the five surfaces the offline gate already cannot see. Rows 0-32 of the Holtburg pair went from 23,090 differing pixels to 1,211, and what remains up there is roof and portal rather than cloud. WHAT THE SAME PAIR STILL SHOWS, unattributed and carried to the next commit: the distant treeline, the player and the NPCs, and the animated portal. The portal is phase and expected. The treeline is not filtering -- sharpness now matches within 5% and a shift search finds no sub-pixel offset -- and the two runs entered the world at different last-logout positions (0xC95B0001 versus 0x09040008), so the far-tier streaming history differed. That is the next thing to prove or refute. Gates. Release build green. App tests 4,133 passed / 3 skipped against the 4,132/3 baseline (one new: the sky-phase parse). GL offline pixel gate against the pre-change tree: 2.31e-05, 13 pixels of 563,200, inside the documented 9-31 band -- GL did not move. One offline Vulkan run with VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation errors, zero warnings. Full three-stop differential recorded at artifacts/v7-diff-c1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
202 lines
8.5 KiB
C#
202 lines
8.5 KiB
C#
using System;
|
|
using System.Linq;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Tests.Rendering.Gpu;
|
|
using Chorizite.Core.Render.Enums;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Wb;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6i-2: the backend-neutral world texture array, driven
|
|
/// against <see cref="RecordingGpuDevice"/>.
|
|
///
|
|
/// <para>These assert the two things the GL array and this one genuinely have to
|
|
/// agree on — what a well-formed layer payload is, and that every atlas ends up
|
|
/// addressable from a shader through both address modes — plus the one thing
|
|
/// they deliberately do NOT share: how a mip chain is produced. Vulkan cannot
|
|
/// blit into a compressed image, so a BC array's levels come from
|
|
/// <c>BlockCompressionMipChain</c> while an RGBA8 array's come from the device's
|
|
/// blit. Getting that backwards would compile, run, and render a texture with
|
|
/// undefined mips.</para>
|
|
/// </summary>
|
|
public sealed class RhiWorldTextureArrayTests
|
|
{
|
|
private const int Extent = 64;
|
|
|
|
private static byte[] Rgba(int width, int height)
|
|
{
|
|
var pixels = new byte[width * height * 4];
|
|
Array.Fill(pixels, (byte)0xFF);
|
|
return pixels;
|
|
}
|
|
|
|
private static byte[] Bc1(int width, int height) =>
|
|
new byte[Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 8];
|
|
|
|
[Fact]
|
|
public void AnUncompressedArrayLetsTheDeviceBlitItsMipChain()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
var arrays = new RhiWorldTextureArrayFactory(device);
|
|
|
|
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 4);
|
|
array.UpdateLayer(2, Rgba(Extent, Extent), null, null);
|
|
Assert.Equal(1, array.PendingUpdateCount);
|
|
|
|
long generated = array.ProcessDirtyUpdates();
|
|
|
|
RecordingGpuTexture texture = LastCreatedTexture(device);
|
|
Assert.True(texture.MipChainGenerated);
|
|
// Exactly one upload: level 0 of the layer that was staged. Every other
|
|
// level is the device's blit.
|
|
Assert.Equal([(0, 2, Extent * Extent * 4)], texture.Uploads);
|
|
Assert.True(generated > 0);
|
|
Assert.Equal(0, array.PendingUpdateCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ABlockCompressedArrayUploadsACpuBuiltChainAndNeverBlits()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
var arrays = new RhiWorldTextureArrayFactory(device);
|
|
|
|
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.DXT1, Extent, Extent, 2);
|
|
array.UpdateLayer(0, Bc1(Extent, Extent), null, null);
|
|
array.ProcessDirtyUpdates();
|
|
|
|
RecordingGpuTexture texture = LastCreatedTexture(device);
|
|
Assert.False(texture.MipChainGenerated);
|
|
// 64x64 is seven levels; level 0 was staged and 1..6 were encoded, all
|
|
// for the one layer that was written.
|
|
Assert.Equal(7, texture.Uploads.Count);
|
|
Assert.All(texture.Uploads, upload => Assert.Equal(0, upload.Layer));
|
|
Assert.Equal([0, 1, 2, 3, 4, 5, 6], texture.Uploads.Select(u => u.MipLevel));
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryArrayIsAddressableThroughBothAddressModes()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
var arrays = new RhiWorldTextureArrayFactory(device);
|
|
|
|
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 1);
|
|
|
|
GpuTextureSlot wrap = array.ResolveSlot(wrapping: true);
|
|
GpuTextureSlot clamp = array.ResolveSlot(wrapping: false);
|
|
Assert.True(wrap.IsAssigned);
|
|
Assert.True(clamp.IsAssigned);
|
|
Assert.NotEqual(wrap, clamp);
|
|
|
|
GpuSamplerDescription[] samplers =
|
|
[
|
|
.. device.Calls.OfType<GpuRecordedTextureRegistration>()
|
|
.Where(registration => registration.TextureName.StartsWith("world-atlas", StringComparison.Ordinal))
|
|
.Select(registration => registration.Sampler),
|
|
];
|
|
// Campaign V slice V7: both address modes, and BOTH anisotropic. The GL
|
|
// array asks for GL_TEXTURE_MAX_ANISOTROPY = the driver's maximum on
|
|
// every world atlas, unconditionally; asking for 1 here made Vulkan
|
|
// sample a coarser mip than GL on every grazing-angle surface, which the
|
|
// first GL-versus-Vulkan pair saw as blurred roof shingles and distant
|
|
// scenery. `with { MaxAnisotropy = 1f }` recovers the campaign's shared
|
|
// description so this stays an assertion about ONE dimension.
|
|
Assert.Contains(GpuSamplerDescription.WorldClamp, samplers.Select(Isotropic));
|
|
Assert.Contains(GpuSamplerDescription.WorldRepeat, samplers.Select(Isotropic));
|
|
Assert.All(samplers, sampler => Assert.True(
|
|
sampler.MaxAnisotropy > 1f,
|
|
$"world atlas sampler {sampler.AddressU} asked for anisotropy "
|
|
+ $"{sampler.MaxAnisotropy}; the GL arm always asks for the device maximum."));
|
|
|
|
static GpuSamplerDescription Isotropic(GpuSamplerDescription sampler) =>
|
|
sampler with { MaxAnisotropy = 1f };
|
|
}
|
|
|
|
/// <summary>
|
|
/// The slot pair must come back to the device, or a session that churns
|
|
/// atlases exhausts the table's fixed capacity — the leak-with-an-end V4t
|
|
/// introduced when it capped the table.
|
|
/// </summary>
|
|
[Fact]
|
|
public void DisposalReturnsBothSlotsAndTheImage()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
var arrays = new RhiWorldTextureArrayFactory(device);
|
|
int before = device.LiveTextureSlotCount;
|
|
|
|
IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 1);
|
|
Assert.Equal(before + 2, device.LiveTextureSlotCount);
|
|
|
|
array.Dispose();
|
|
|
|
Assert.Equal(before, device.LiveTextureSlotCount);
|
|
Assert.True(LastCreatedTexture(device).IsDisposed);
|
|
Assert.True(array.IsPhysicalRetirementComplete);
|
|
Assert.True(array.HasDurableDisposeOwnership);
|
|
|
|
// ReleaseTextureSlots after Dispose is idempotent: the retiring-atlas
|
|
// path calls it once physical retirement completes, which is necessarily
|
|
// after disposal.
|
|
array.ReleaseTextureSlots();
|
|
Assert.Equal(before, device.LiveTextureSlotCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The GL array rejects a payload whose length contradicts the format. The
|
|
/// RHI array reuses that validator rather than writing a second one, so a
|
|
/// mis-sized layer fails the same way on both arms.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AMisSizedLayerIsRejectedByTheSharedValidator()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
var arrays = new RhiWorldTextureArrayFactory(device);
|
|
|
|
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 1);
|
|
|
|
Assert.Throws<ArgumentException>(() => array.UpdateLayer(0, new byte[16], null, null));
|
|
Assert.Equal(0, array.PendingUpdateCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The formats with no member of <c>GpuTextureFormat</c> fail loudly at
|
|
/// creation rather than silently substituting. A8 in particular needs the
|
|
/// component swizzle the GL array applies, which lives in a Vulkan image view
|
|
/// and is not part of the pinned texture description.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(TextureFormat.A8)]
|
|
[InlineData(TextureFormat.RGB8)]
|
|
[InlineData(TextureFormat.Rgba32f)]
|
|
public void AFormatWithNoRhiEquivalentIsRefusedAtCreation(TextureFormat format)
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
var arrays = new RhiWorldTextureArrayFactory(device);
|
|
|
|
Assert.Throws<NotSupportedException>(
|
|
() => arrays.CreateClampedArray(format, Extent, Extent, 1));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Both arms meter the same bytes, because the eviction budget that reads
|
|
/// this number is shared policy above the seam.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AllocatedBytesMatchTheSharedMipChainAccounting()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
var arrays = new RhiWorldTextureArrayFactory(device);
|
|
|
|
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 3);
|
|
|
|
Assert.Equal(
|
|
TextureAtlasManager.CalculateMipChainBytes(Extent, Extent, TextureFormat.RGBA8) * 3,
|
|
array.TotalSizeInBytes);
|
|
}
|
|
|
|
private static RecordingGpuTexture LastCreatedTexture(RecordingGpuDevice device) =>
|
|
device.CreatedTextures.Count > 0
|
|
? device.CreatedTextures[^1]
|
|
: throw new InvalidOperationException("The device created no texture.");
|
|
}
|