diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs index 5a19f9f9..f7c81759 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs @@ -10,11 +10,30 @@ namespace AcDream.App.Rendering.Gpu.Vk; /// counter-clockwise. Vulkan's framebuffer origin is top-left, so this backend /// renders with a NEGATIVE viewport height, which mirrors clip space vertically /// and makes GL-authored geometry land in the right place with no shader or -/// matrix change anywhere. Mirroring also reverses triangle winding, so the -/// front face is inverted to compensate. The two flips are exact inverses and -/// must therefore always travel together — which is precisely why they live in -/// one file with one test suite rather than at the dozen call sites that would -/// otherwise each have to remember. +/// matrix change anywhere. +/// +/// The winding does NOT need a compensating inversion, and slice V6j +/// measured that rather than reasoning about it. V6c wrote this file with an +/// inverted front face on the standard argument that mirroring framebuffer space +/// reverses triangle orientation. Nothing exercised it: every Vulkan consumer +/// through V6i — the retained UI's text, debug lines and the bring-up scene — +/// declares Cull = GpuCullMode.None, so the mapping had never decided a +/// single fragment. +/// +/// The world arm is its first culling consumer, and it falsified the +/// inversion twice over on the same frame. Terrain is the one single-sided +/// surface acdream draws (FrontFace(Ccw) + Cull(Back), matching +/// ACRender::landPolysDraw's eye-side predicate) and it vanished +/// completely; every closed building shell rendered inside-out, its front wall +/// culled and its interior beams visible through the gap. Declaring the GL +/// winding verbatim — no inversion — restores both: terrain draws single-sided +/// from above, and the shells close. The captures are +/// artifacts/v6j-vk1 (inverted: no terrain, hollow shells) and +/// artifacts/v6j-head (GL reference) against the corrected frame. +/// +/// So the viewport flip travels alone. The scissor's explicit flip below +/// is unaffected — that one was always a separate correction and is separately +/// justified. /// /// Scissor does NOT flip with the viewport. The V3 audit called /// this out as a concrete acceptance item (plan §4.10, item 1): @@ -80,14 +99,14 @@ internal static class VulkanViewportMapping } /// - /// Inverts the winding a renderer asked for, because the negative viewport - /// height mirrors framebuffer space. No renderer performs this flip itself - /// and no other code in the backend may repeat it. + /// Passes the winding a renderer asked for straight through. See the class + /// remarks for the measurement that replaced V6c's inversion — and note that + /// a renderer must still never flip anything itself. /// internal static FrontFace ToVulkan(GpuFrontFace frontFace) => frontFace switch { - GpuFrontFace.CounterClockwise => FrontFace.Clockwise, - GpuFrontFace.Clockwise => FrontFace.CounterClockwise, + GpuFrontFace.CounterClockwise => FrontFace.CounterClockwise, + GpuFrontFace.Clockwise => FrontFace.Clockwise, _ => throw new ArgumentOutOfRangeException(nameof(frontFace), frontFace, "Unknown winding."), }; diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs index 90de252f..0b889d57 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs @@ -88,25 +88,39 @@ public sealed class VulkanViewportMappingTests Assert.Equal(20u, scissor.Extent.Height); } + /// + /// 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 VulkanViewportMapping's remarks for the captures. + /// [Fact] - public void FrontFaceIsInvertedBecauseTheViewportMirrorsFramebufferSpace() + public void FrontFacePassesThroughSoRenderersDeclareTheGlWinding() { - Assert.Equal(FrontFace.Clockwise, VulkanViewportMapping.ToVulkan(GpuFrontFace.CounterClockwise)); - Assert.Equal(FrontFace.CounterClockwise, VulkanViewportMapping.ToVulkan(GpuFrontFace.Clockwise)); + Assert.Equal( + FrontFace.CounterClockwise, + VulkanViewportMapping.ToVulkan(GpuFrontFace.CounterClockwise)); + Assert.Equal( + FrontFace.Clockwise, + VulkanViewportMapping.ToVulkan(GpuFrontFace.Clockwise)); } [Fact] - public void TheFlipAndTheWindingInversionAreExactInverses() + public void TheViewportFlipTravelsAloneAndTheWindingIsUntouched() { - // Mirroring twice is identity, and inverting the winding twice is too. - // If a later change ever flipped one without the other, this is the - // shape of the assertion that catches it. + // 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); - FrontFace inverted = VulkanViewportMapping.ToVulkan(GpuFrontFace.CounterClockwise); - Assert.NotEqual(FrontFace.CounterClockwise, inverted); + Assert.Equal( + FrontFace.CounterClockwise, + VulkanViewportMapping.ToVulkan(GpuFrontFace.CounterClockwise)); } [Fact]