acdream/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs
Erik 81fe5e1b63 fix(render): Campaign V slice V6j commit 1 - the Vulkan winding needs no inversion
VulkanViewportMapping has inverted the front face since V6c, on the standard
argument that rendering with a negative viewport height mirrors framebuffer
space and therefore reverses triangle orientation. The world arm is the first
consumer that culls anything, and it falsified the inversion twice over on one
frame.

Nothing exercised it before now. Every Vulkan consumer through V6i - TextRenderer,
DebugLineRenderer and the bring-up scene - declares Cull = GpuCullMode.None, so
the mapping had never decided a single fragment. That is why a wrong answer
survived four slices and a validation-clean run: an unexercised path.

What the world arm measured, on the same offline scene the GL pixel gate captures.
Terrain is the one single-sided surface acdream draws - FrontFace(Ccw) plus
Cull(Back), matching ACRender::landPolysDraw's per-triangle eye-side predicate -
and under the inversion it vanished completely, 190 multi-draw commands issuing
against 625 loaded landblocks with nothing on screen. Every closed building shell
rendered inside-out in the same frame: the front wall culled and the interior
beams visible through the gap, which is what a back-face-front cull looks like on
geometry that is only nearly convex. Declaring the GL winding verbatim restores
both at once - terrain draws single-sided from above, and the shells close.

Two independent surfaces, one change, and the correction is the identity mapping.
Recorded here rather than worked around in the renderers, because a renderer that
compensates for its backend is exactly the shape this file exists to prevent: the
contract says renderers speak GL and the backend translates, and the backend was
translating wrongly.

The viewport flip itself is untouched and still correct - it is what puts
GL-authored geometry the right way up with no shader or matrix change. What goes
is the claim that a winding inversion has to travel with it. The scissor's
explicit flip is a separate correction with a separate justification and is
likewise untouched.

The test suite says so now rather than describing the old behaviour: the
pass-through is asserted directly, and the exact-inverses test becomes a
travels-alone test, so a later change that reintroduces the inversion fails here
first and on any single-sided surface second.

Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged
baseline. GL offline pixel gate unaffected by construction - this file has no GL
arm - and measured with the world arm in commit 2.

No divergence-register row: this corrects a backend translation error rather than
introducing a deviation from retail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:45:53 +02:00

205 lines
8.6 KiB
C#

using System;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using Silk.NET.Vulkan;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6c — the coordinate reconciliation (plan §3.3, §4.7,
/// §4.10).
///
/// <para>Renderers speak GL: viewport origin bottom-left, front faces
/// counter-clockwise. The Vulkan backend renders with a NEGATIVE viewport
/// height, which mirrors framebuffer space vertically and therefore also
/// reverses winding, so the front face is inverted to compensate. The two flips
/// are exact inverses and must always travel together — which is why they live
/// in one file, and why these tests assert them together.</para>
///
/// <para>The scissor is the trap. It does NOT flip with the viewport:
/// <c>vkCmdSetScissor</c> is always top-left-origin regardless of viewport sign,
/// while <c>NdcScissorRect.ToPixels</c> emits GL bottom-left rectangles. The V3
/// audit flagged this explicitly as a V6 acceptance item, and getting it wrong
/// shows up as a doorway aperture clipped from the wrong edge — which only a
/// scene containing one would reveal.</para>
/// </summary>
public sealed class VulkanViewportMappingTests
{
[Fact]
public void FullViewportBecomesANegativeHeightRectangleAnchoredAtTheBottom()
{
Viewport viewport = VulkanViewportMapping.ToVulkan(0, 0, 1280, 720, attachmentHeight: 720);
Assert.Equal(0f, viewport.X);
// Y is the BOTTOM edge in Vulkan's top-left space, and the height runs
// upward from it. Together they mirror clip space.
Assert.Equal(720f, viewport.Y);
Assert.Equal(1280f, viewport.Width);
Assert.Equal(-720f, viewport.Height);
Assert.Equal(0f, viewport.MinDepth);
Assert.Equal(1f, viewport.MaxDepth);
}
[Fact]
public void AnOffsetViewportKeepsItsGlBottomLeftMeaning()
{
// A 100x50 viewport whose bottom edge sits 30 px above the bottom of a
// 720 px attachment.
Viewport viewport = VulkanViewportMapping.ToVulkan(10, 30, 100, 50, attachmentHeight: 720);
Assert.Equal(10f, viewport.X);
Assert.Equal(690f, viewport.Y);
Assert.Equal(-50f, viewport.Height);
}
[Fact]
public void ScissorFlipsAgainstTheAttachmentBecauseTheViewportSignDoesNotDoItForUs()
{
// GL rectangle: 100 px wide, 50 px tall, bottom edge 30 px up.
Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(10, 30, 100, 50, attachmentHeight: 720);
Assert.Equal(10, scissor.Offset.X);
// Top edge measured from the top: 720 - (30 + 50).
Assert.Equal(640, scissor.Offset.Y);
Assert.Equal(100u, scissor.Extent.Width);
Assert.Equal(50u, scissor.Extent.Height);
}
[Fact]
public void AFullAttachmentScissorIsUnchangedByTheFlip()
{
Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(0, 0, 1280, 720, attachmentHeight: 720);
Assert.Equal(0, scissor.Offset.X);
Assert.Equal(0, scissor.Offset.Y);
Assert.Equal(1280u, scissor.Extent.Width);
Assert.Equal(720u, scissor.Extent.Height);
}
[Fact]
public void AScissorStraddlingTheTopEdgeIsClampedRatherThanRejected()
{
// Bottom edge 700 px up in a 720 px attachment, 50 px tall: 30 px of it
// is off the top. GL silently clips this; a driver error here would turn
// a harmless off-screen aperture into a crash.
Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(0, 700, 100, 50, attachmentHeight: 720);
Assert.Equal(0, scissor.Offset.Y);
Assert.Equal(20u, scissor.Extent.Height);
}
/// <summary>
/// Campaign V slice V6j: the winding a renderer declares is the winding
/// Vulkan gets. V6c inverted it on the standard negative-viewport argument;
/// the world arm — the mapping's first culling consumer — measured that the
/// inversion culls terrain outright and turns every closed building shell
/// inside-out. See <c>VulkanViewportMapping</c>'s remarks for the captures.
/// </summary>
[Fact]
public void FrontFacePassesThroughSoRenderersDeclareTheGlWinding()
{
Assert.Equal(
FrontFace.CounterClockwise,
VulkanViewportMapping.ToVulkan(GpuFrontFace.CounterClockwise));
Assert.Equal(
FrontFace.Clockwise,
VulkanViewportMapping.ToVulkan(GpuFrontFace.Clockwise));
}
[Fact]
public void TheViewportFlipTravelsAloneAndTheWindingIsUntouched()
{
// The viewport still mirrors — that is what puts GL-authored geometry
// the right way up with no matrix change. What it does NOT do is drag a
// winding inversion along with it. A later change that reintroduces one
// fails here and, more usefully, fails visibly on any single-sided
// surface.
Viewport once = VulkanViewportMapping.ToVulkan(0, 0, 640, 480, attachmentHeight: 480);
Assert.Equal(-480f, once.Height);
Assert.Equal(480f, once.Y);
Assert.Equal(
FrontFace.CounterClockwise,
VulkanViewportMapping.ToVulkan(GpuFrontFace.CounterClockwise));
}
[Fact]
public void CullModesMapStraightAcross()
{
Assert.Equal(CullModeFlags.None, VulkanViewportMapping.ToVulkan(GpuCullMode.None));
Assert.Equal(CullModeFlags.BackBit, VulkanViewportMapping.ToVulkan(GpuCullMode.Back));
Assert.Equal(CullModeFlags.FrontBit, VulkanViewportMapping.ToVulkan(GpuCullMode.Front));
}
[Fact]
public void AllThreeRetailBlendModesAreRepresentable()
{
Assert.Equal(
(BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.StraightAlpha));
Assert.Equal(
(BlendFactor.SrcAlpha, BlendFactor.One),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.Additive));
// Retail's third mode, found at slice V4c. Mapping it onto straight
// alpha would have silently changed how every inverse-alpha surface
// composites.
Assert.Equal(
(BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.InverseAlpha));
}
[Fact]
public void IntegerVertexAttributesTakeAUintFormatNotANormalisedOne()
{
Assert.Equal(Format.R8G8B8A8Unorm, VulkanViewportMapping.ToVulkan(GpuVertexFormat.UByte4Normalized));
// terrain_modern.vert reads locations 2-5 as uvec4; those packed bytes
// carry terrain-type, road and split-direction codes, so normalising
// them would not be an approximation - it would be garbage.
Assert.Equal(Format.R8G8B8A8Uint, VulkanViewportMapping.ToVulkan(GpuVertexFormat.UByte4UInt));
}
[Fact]
public void ResolveIsExpressedByAResolveTargetRatherThanAStoreOp()
{
// There is no VK_ATTACHMENT_STORE_OP_RESOLVE; a resolving attachment
// discards its multisampled contents and names a resolve image instead.
Assert.Equal(AttachmentStoreOp.DontCare, VulkanViewportMapping.ToVulkan(GpuStoreOp.Resolve));
Assert.Equal(AttachmentStoreOp.Store, VulkanViewportMapping.ToVulkan(GpuStoreOp.Store));
Assert.Equal(AttachmentStoreOp.DontCare, VulkanViewportMapping.ToVulkan(GpuStoreOp.DontCare));
}
[Fact]
public void PipelineCacheHeaderValidationRejectsAnotherDevicesBlob()
{
byte[] uuid = new byte[16];
for (int i = 0; i < 16; i++)
uuid[i] = (byte)(i + 1);
byte[] blob = new byte[64];
BitConverter.GetBytes(32u).CopyTo(blob, 0);
BitConverter.GetBytes(1u).CopyTo(blob, 4);
BitConverter.GetBytes(0x1002u).CopyTo(blob, 8);
BitConverter.GetBytes(0x7550u).CopyTo(blob, 12);
uuid.CopyTo(blob, 16);
Assert.NotNull(VulkanPipelineCache.ValidateHeader(blob, 0x1002, 0x7550, uuid));
// A driver update changes the cache UUID, and feeding the old blob back
// is exactly the case the header exists to catch.
uuid[0] = 0xFF;
Assert.Null(VulkanPipelineCache.ValidateHeader(blob, 0x1002, 0x7550, uuid));
}
[Fact]
public void PipelineCacheHeaderValidationRejectsTruncatedAndForeignBlobs()
{
byte[] uuid = new byte[16];
Assert.Null(VulkanPipelineCache.ValidateHeader(null, 1, 1, uuid));
Assert.Null(VulkanPipelineCache.ValidateHeader(new byte[8], 1, 1, uuid));
byte[] blob = new byte[32];
BitConverter.GetBytes(32u).CopyTo(blob, 0);
BitConverter.GetBytes(1u).CopyTo(blob, 4);
BitConverter.GetBytes(0x8086u).CopyTo(blob, 8);
Assert.Null(VulkanPipelineCache.ValidateHeader(blob, 0x1002, 0, uuid));
}
}