Campaign V slice V6e, last of three. Sky was the hardest of the four pairs
because it was the only one that still worked the way a 2004 shader works: a
dozen loose uniforms pushed one glUniform call at a time, and a texture bound to
unit 0 with a sampler object chosen per submesh. Vulkan GLSL has neither a
default uniform block nor a way to declare a bare sampler, so both had to move —
and the second one had a sting in it.
The uniforms go into a `SkyParams` std140 block at uniform binding 4, the new
pre-authorized constant in GpuBindingModel (1, 2 and 3 are SceneLighting, the
terrain clip block and terrain tiling; the contract test now proves the three
constants and that literal 2 do not collide). Three matrices are 192 bytes on
their own, so the 96-byte push-constant block was never in the running. The
block's member order IS its layout: std140 aligns a vec3 to 16 bytes while using
12, so each of the three lighting vectors is followed by the float that rides in
its pad word, which is why colours and per-surface scalars interleave rather
than grouping by meaning. SkyParamsLayoutTests asserts all twelve offsets and
the 256-byte size, because getting one member wrong would read the sun direction
as a colour with no compile error, no link error and no GL error to say so.
The texture is the interesting half. sky.frag now reads through the shared table
(ACDREAM_SAMPLE_2D), and a bindless handle BAKES its sampler — so the
per-submesh Repeat-versus-ClampToEdge choice, which used to be a glBindSampler
on unit 0, becomes which slot the submesh asks for. SkyRenderer interns one
handle per (texture, wrap) pair, exactly as ManagedGLTextureArray has done since
the world path went bindless, and exactly the shape Vulkan's table has, where an
entry is a combined image sampler. Same two SamplerCache objects, same wrap
behaviour, consulted once at interning instead of once per draw. A pleasant
consequence: the sky no longer touches texture unit 0, so the load-bearing
`BindSampler(0, 0)` restore at the end of the pass — there because the binding
was global state that would otherwise force ClampToEdge on the next renderer —
has nothing left to undo and is gone.
Gates. Release build clean; App tests 4,072 passed / 3 skipped (4,057 baseline,
plus the sentinel guard from the previous commit and fourteen sky-layout
assertions). Offline pixel gate against 95f8c25f: 18 px of 563,200 compared
(3.20e-05), inside the documented 15–23 px band.
That gate masks the sky for determinism, so it proves nothing about this commit
and the sky renderer has no automated pixel coverage at all. What was done
instead: a base-versus-head offline capture at ALL SEVEN day groups, built by
stashing the change and rebuilding so the two runs differ only in this commit.
Every pair matches in gradient, cloud sheet, horizon band and fog — including
day group 2's salmon cloud band and day group 6's green one, which between them
exercise texture sampling, per-vertex tint, blend mode and fog. Then 3/3
RENDERED on the desktop-witness repeat-connected gate.
That bounds the risk; it does not close it. The offline camera is fixed and
looks down, so a thin band of dome is all it ever sees: the sun and moon
(additive, high) and the rain cylinder (the one sky mesh that surrounds the
camera, and the one whose REPEAT wrap is most visible) remain unproven. Recorded
as user-gate debt in §5.1 alongside V2c's and V4e's particles — check it by
standing outside at dawn or dusk, and by standing in rain.
Manifest: 8/9 pairs compile. `terrain_modern` is the last production pair, and
it is blocked on V4d's content rather than on dialect — details in §5.5's slice
table. `mesh` has no consumer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
243 lines
11 KiB
C#
243 lines
11 KiB
C#
using System.Numerics;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Runtime.InteropServices;
|
|
using AcDream.App.Rendering.Gpu;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Gpu;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V0 — the pinned RHI contract.
|
|
///
|
|
/// These are not behaviour tests; they are the tripwires that stop the contract
|
|
/// drifting out from under the shaders and the two backends. Every constant
|
|
/// asserted here also appears in a GLSL source file or in a backend's binding
|
|
/// setup, so a change that lands in only one place fails here rather than as a
|
|
/// corrupted frame.
|
|
/// </summary>
|
|
public sealed class GpuContractTests
|
|
{
|
|
[Fact]
|
|
public void PushConstantBlockMatchesThePinnedLayout()
|
|
{
|
|
Assert.Equal(GpuBindingModel.PushConstantBytes, Unsafe.SizeOf<GpuPushConstants>());
|
|
Assert.True(GpuBindingModel.PushConstantBytes <= GpuBindingModel.MaxPushConstantBytes);
|
|
|
|
Assert.Equal(0, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.ViewProjection)));
|
|
Assert.Equal(64, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.DrawIdOffset)));
|
|
Assert.Equal(68, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.LightingMode)));
|
|
Assert.Equal(72, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.RenderPass)));
|
|
Assert.Equal(76, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.LightDebug)));
|
|
Assert.Equal(80, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.TextureIndexA)));
|
|
Assert.Equal(84, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.TextureIndexB)));
|
|
Assert.Equal(88, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.ParamA)));
|
|
Assert.Equal(92, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.ParamB)));
|
|
}
|
|
|
|
[Fact]
|
|
public void StorageBindingsMatchTheShaderSources()
|
|
{
|
|
// mesh_modern.vert declares std430 bindings 0..8 in exactly this order;
|
|
// binding 9 is the GL-only texture handle table added by slice V2.
|
|
Assert.Equal(0u, GpuBindingModel.StorageInstances);
|
|
Assert.Equal(1u, GpuBindingModel.StorageBatches);
|
|
Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
|
|
Assert.Equal(3u, GpuBindingModel.StorageClipSlots);
|
|
Assert.Equal(4u, GpuBindingModel.StorageGlobalLights);
|
|
Assert.Equal(5u, GpuBindingModel.StorageInstanceLightSets);
|
|
Assert.Equal(6u, GpuBindingModel.StorageInstanceIndoor);
|
|
Assert.Equal(7u, GpuBindingModel.StorageInstanceAlpha);
|
|
Assert.Equal(8u, GpuBindingModel.StorageInstanceSelectionLighting);
|
|
Assert.Equal(9u, GpuBindingModel.StorageTextureTable);
|
|
Assert.Equal(10u, GpuBindingModel.StorageBindingCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void UniformAndTextureTableLiveInSeparateSets()
|
|
{
|
|
// The SceneLighting UBO keeps binding=1 even though the BatchBuffer SSBO
|
|
// also uses binding=1. GL tolerates that because its SSBO and UBO binding
|
|
// tables are separate; Vulkan does not, so the set index disambiguates.
|
|
Assert.Equal(GpuBindingModel.StorageBatches, GpuBindingModel.UniformSceneLighting);
|
|
Assert.NotEqual(0u, GpuBindingModel.UniformSet);
|
|
Assert.NotEqual(GpuBindingModel.UniformSet, GpuBindingModel.TextureTableSet);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClipRegionStrideMatchesTheUploadedLayout()
|
|
{
|
|
// ClipFrame lays these bytes out on the CPU; ClipFrameLayoutTests pins the
|
|
// producer side, this pins the contract side. 16 B header + 8 x vec4.
|
|
Assert.Equal(8, GpuBindingModel.ClipPlanesPerSlot);
|
|
Assert.Equal(144, GpuBindingModel.ClipRegionStrideBytes);
|
|
}
|
|
|
|
[Fact]
|
|
public void BlendModesCoverEveryRetailTranslucencyKind()
|
|
{
|
|
// WbDrawDispatcher.ApplyRetailBlend selects a blend function from each DAT
|
|
// surface's TranslucencyKind. Retail has three, and the V0 contract shipped
|
|
// with only two — slice V4c found the gap. Mapping InvAlpha onto
|
|
// StraightAlpha would silently change how every inverse-alpha surface
|
|
// composites, so the contract has to carry all three.
|
|
Assert.Equal(4, Enum.GetValues<GpuBlendMode>().Length);
|
|
Assert.Contains(GpuBlendMode.None, Enum.GetValues<GpuBlendMode>());
|
|
Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues<GpuBlendMode>());
|
|
Assert.Contains(GpuBlendMode.Additive, Enum.GetValues<GpuBlendMode>());
|
|
Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues<GpuBlendMode>());
|
|
}
|
|
|
|
[Fact]
|
|
public void IntegerVertexAttributesAreRepresentableDistinctlyFromNormalizedOnes()
|
|
{
|
|
// terrain_modern.vert declares locations 2-5 as uvec4 and the CPU feeds
|
|
// them with glVertexAttribIPointer. GL leaves an integer shader input
|
|
// undefined if it arrives through the float path, and Vulkan needs the
|
|
// format named as R8G8B8A8_UINT rather than _UNORM — so the two cannot be
|
|
// the same contract value. Those packed bytes carry terrain-type, road and
|
|
// split-direction codes, so normalising them would produce garbage, not an
|
|
// approximation.
|
|
Assert.NotEqual(GpuVertexFormat.UByte4Normalized, GpuVertexFormat.UByte4UInt);
|
|
Assert.Contains(GpuVertexFormat.UByte4UInt, Enum.GetValues<GpuVertexFormat>());
|
|
}
|
|
|
|
[Fact]
|
|
public void APipelineNamesTheColorFormatItRendersInto()
|
|
{
|
|
// Vulkan's dynamic rendering bakes the colour-attachment format into the
|
|
// pipeline, so a pipeline that cannot name it either forces one format on
|
|
// every pass or is undefined against the ones it does not match. Slice V6c
|
|
// hit that wall and hard-coded the swapchain format for every pipeline,
|
|
// recording the gap in VulkanTextureFormatMapping rather than hiding it.
|
|
var description = new GpuPipelineDescription
|
|
{
|
|
Name = "contract-default",
|
|
Shaders = new GpuShaderSet("ui_text"),
|
|
VertexLayout = GpuVertexLayout.None,
|
|
};
|
|
|
|
// The default has to be the render-target format, because that is what
|
|
// the Vulkan backend already maps to the B8G8R8A8_UNORM swapchain — so
|
|
// every pipeline written before this field existed keeps its behaviour.
|
|
Assert.Equal(GpuTextureFormat.Rgba8UnormRenderTarget, description.ColorFormat);
|
|
|
|
// And it has to be settable, or naming it would be decoration.
|
|
GpuPipelineDescription single = description with { ColorFormat = GpuTextureFormat.R8Unorm };
|
|
Assert.Equal(GpuTextureFormat.R8Unorm, single.ColorFormat);
|
|
Assert.Equal(GpuTextureFormat.Rgba8UnormRenderTarget, description.ColorFormat);
|
|
}
|
|
|
|
[Fact]
|
|
public void UniformBindingsDoNotCollide()
|
|
{
|
|
// Campaign V slice V6e added the sky block. Vulkan has ONE binding
|
|
// namespace per set, so two uniform buffers sharing a number is not a
|
|
// style problem — it is one of them silently reading the other's bytes.
|
|
uint[] uniformBindings =
|
|
[
|
|
GpuBindingModel.UniformSceneLighting,
|
|
GpuBindingModel.UniformTerrainTiling,
|
|
GpuBindingModel.UniformSkyParams,
|
|
];
|
|
|
|
Assert.Equal(uniformBindings.Length, uniformBindings.Distinct().Count());
|
|
|
|
// Binding 2 is the terrain clip block, which sky.vert also reads and
|
|
// which has no constant here because no CPU writer names it through the
|
|
// binding model. It is spelled as a literal on purpose: a new uniform
|
|
// buffer that took 2 would compile, link, and render the wrong thing.
|
|
Assert.DoesNotContain(2u, uniformBindings);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnassignedTextureSlotIsNeverAValidIndex()
|
|
{
|
|
Assert.False(GpuTextureSlot.Unassigned.IsAssigned);
|
|
Assert.True(new GpuTextureSlot(0).IsAssigned);
|
|
Assert.Equal("slot#unassigned", GpuTextureSlot.Unassigned.ToString());
|
|
Assert.Equal("slot#7", new GpuTextureSlot(7).ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldMeshVertexLayoutMatchesTheMeshShaderInputs()
|
|
{
|
|
GpuVertexLayout layout = GpuVertexLayout.WorldMesh;
|
|
|
|
Assert.Equal(32u, layout.StrideBytes);
|
|
Assert.Equal(3, layout.Attributes.Length);
|
|
Assert.Equal(new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0), layout.Attributes[0]);
|
|
Assert.Equal(new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), layout.Attributes[1]);
|
|
Assert.Equal(new GpuVertexAttribute(2, GpuVertexFormat.Float2, 24), layout.Attributes[2]);
|
|
}
|
|
|
|
[Fact]
|
|
public void MultisampledBackbufferPassResolvesWhileDepthIsDiscarded()
|
|
{
|
|
GpuPassDescription multisampled = GpuPassDescription.BackbufferClear("world", Vector4.Zero, sampleCount: 4);
|
|
Assert.Equal(GpuStoreOp.Resolve, multisampled.Color.Store);
|
|
Assert.Null(multisampled.Color.Target);
|
|
Assert.Equal(GpuStoreOp.DontCare, multisampled.Depth!.Value.Store);
|
|
Assert.Equal(1f, multisampled.Depth!.Value.ClearDepth);
|
|
|
|
GpuPassDescription single = GpuPassDescription.BackbufferClear("world", Vector4.Zero, sampleCount: 1);
|
|
Assert.Equal(GpuStoreOp.Store, single.Color.Store);
|
|
}
|
|
|
|
[Fact]
|
|
public void CapabilityRecordAcceptsADeviceThatMeetsEveryRequirement()
|
|
{
|
|
GpuCapabilityRecord record = SupportedRecord();
|
|
|
|
Assert.Empty(record.SupportFailures);
|
|
Assert.True(record.IsSupported);
|
|
}
|
|
|
|
[Fact]
|
|
public void CapabilityRecordNamesEveryMissingRequirement()
|
|
{
|
|
GpuCapabilityRecord record = SupportedRecord() with
|
|
{
|
|
SupportsMultiDrawIndirect = false,
|
|
SupportsDrawParameters = false,
|
|
SupportsTextureCompressionBc = false,
|
|
MaxTextureTableSlots = 16,
|
|
MaxStorageBufferBindings = 4,
|
|
MaxPushConstantBytes = 32,
|
|
MaxClipDistances = 0,
|
|
};
|
|
|
|
Assert.False(record.IsSupported);
|
|
Assert.Equal(7, record.SupportFailures.Count);
|
|
Assert.Contains(record.SupportFailures, failure => failure.Contains("Multi-draw-indirect", StringComparison.Ordinal));
|
|
Assert.Contains(record.SupportFailures, failure => failure.Contains("gl_DrawID", StringComparison.Ordinal));
|
|
Assert.Contains(record.SupportFailures, failure => failure.Contains("BC (DXT)", StringComparison.Ordinal));
|
|
Assert.Contains(record.SupportFailures, failure => failure.Contains("clip distances", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void TimestampSupportIsOptional()
|
|
{
|
|
// Losing GPU timing degrades profiling; it must never refuse to start.
|
|
GpuCapabilityRecord record = SupportedRecord() with { SupportsTimestampQueries = false };
|
|
Assert.True(record.IsSupported);
|
|
}
|
|
|
|
private static GpuCapabilityRecord SupportedRecord() => new()
|
|
{
|
|
Backend = GpuBackendKind.Vulkan,
|
|
DeviceName = "test-adapter",
|
|
DriverInfo = "test-driver",
|
|
ApiVersion = "Vulkan 1.3.0",
|
|
MaxTextureTableSlots = GpuBindingModel.TextureTableCapacity,
|
|
MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
|
|
MaxPushConstantBytes = GpuBindingModel.MaxPushConstantBytes,
|
|
MinStorageBufferOffsetAlignment = 64,
|
|
MinUniformBufferOffsetAlignment = 256,
|
|
MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
|
|
MaxSampleCount = 8,
|
|
SupportsMultiDrawIndirect = true,
|
|
SupportsDrawParameters = true,
|
|
SupportsTextureCompressionBc = true,
|
|
SupportsTimestampQueries = true,
|
|
SupportsPersistentlyMappedRings = true,
|
|
};
|
|
}
|