diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 8523e0f5..7bd9fce9 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -52,6 +52,7 @@ #include // P7 wave 2 package B3: rule I's tally for WireDraw.inc's silent draw drops. #include "WireDeclineTally.h" +#include "WireColorBlitFilter.h" #include "WireDepthResolveArm.h" #include "WireDepthResolveProbe.h" #endif diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireColorBlit.inc b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireColorBlit.inc index 7aea149b..feafca3e 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireColorBlit.inc +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireColorBlit.inc @@ -340,7 +340,14 @@ alloc.descriptorSetCount = 1; alloc.pSetLayouts = &cache.descriptorLayout; VK_VERIFY(vkAllocateDescriptorSets(m_device, &alloc, &descriptorSet)); - const VkDescriptorImageInfo sampled{filter == GL_LINEAR ? cache.linear : cache.nearest, + // GL_LINEAR filters only a STRETCHED blit; an unscaled one (mirrored or not, on any of the + // four transforms) is a copy and samples NEAREST, which is exact where linear filtering + // at a quantised coordinate is not (WireColorBlitFilter.h). The format check above still + // asks for SAMPLED_IMAGE_FILTER_LINEAR whenever GL said GL_LINEAR, so which blits reach + // this pass is unchanged. + const VkFilter samplerFilter = WireColorBlitFilterFor({sx0, sy0, sx1, sy1}, {dx0, dy0, dx1, dy1}, + filter, transform); + const VkDescriptorImageInfo sampled{samplerFilter == VK_FILTER_LINEAR ? cache.linear : cache.nearest, sourceView, sourceLayout}; VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; write.dstSet = descriptorSet; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireColorBlitFilter.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireColorBlitFilter.h new file mode 100644 index 00000000..73403d8b --- /dev/null +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireColorBlitFilter.h @@ -0,0 +1,66 @@ +// MobileGL - MobileGL/MG_Backend/DirectVulkan/Renderer/WireColorBlitFilter.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +#pragma once + +#include +#include "../VkIncludes.h" + +// P7 g3-blit (g3det, gate 3): WHICH SAMPLER THE WIRE ARM'S SHADER COLOUR BLIT READS ITS SOURCE +// THROUGH. +// +// GL applies a blit's `filter` only where the image is STRETCHED (GL 4.6 core 18.3.1). A blit whose +// source and destination rectangles are the same size is a copy, whatever `filter` says, and so is +// a mirrored one. The wire arm's shader blit (WireColorBlit.inc, BlitWireColorImage) draws a quad +// and samples `textureLod(sourceImage, texCoord, 0)` at interpolated coordinates, and it used the +// LINEAR sampler for every GL_LINEAR blit. At 1:1 every fragment centre maps to a texel centre, so +// in exact arithmetic linear filtering reads the same single texel. Adreno 830 quantises the +// coordinate to sub-texel precision: a fragment that lands a fraction off the centre blends in a +// small weight of a neighbour. minecraft-1.21.1-neoforge-create-instancing-in-world ends with a +// 1:1 GL_LINEAR glBlitFramebuffer into FB 0, and on the wire arm 712 high-contrast pixels moved by +// 1 LSB toward a neighbour. The monolith's vkCmdBlitImage copied them exactly +// (~/w7/logs/g3det/VERDICT.md section 3). NEAREST within half a texel of the centre is exact, so an +// unscaled blit takes NEAREST. Lavapipe samples texel centres exactly, so no host picture can see +// the difference; this pure half is what the unit test pins +// (MG_Test/Pipeline/PipelineQuirkTest.cpp, WireColorBlitSamples*). +// +// THE SURFACE TRANSFORM DOES NOT TURN A COPY INTO A STRETCH. BlitWireColorImage applies the +// quarter and half turns in NDC, over a viewport of the native image, and for a quarter turn it +// takes the logical extent as the native extent swapped. A native pixel then has exactly the pitch +// of a logical one, and the rotation maps pixel centres onto pixel centres. So the scale test is on +// GL's own (logical) rectangles for identity and all three rotations. BlitWireColorImage declines +// any other transform before it chooses a sampler; for one, this answers with the filter's own +// mapping, as before this package. +#if MOBILEGL_BUILD_DISAGGREGATED +namespace MobileGL::MG_Backend::DirectVulkan { + // A glBlitFramebuffer rectangle as GL names it: two corners, either order. + struct WireBlitRect { + GLint x0 = 0, y0 = 0, x1 = 0, y1 = 0; + }; + + // Same extent on both axes, orientation aside. The extents are taken in 64 bits: GL accepts any + // GLint corner, and INT_MAX - INT_MIN does not fit in one. + inline Bool WireBlitIsUnscaled(const WireBlitRect& source, const WireBlitRect& destination) { + const auto extent = [](GLint a, GLint b) { + return std::abs(static_cast(b) - static_cast(a)); + }; + return extent(source.x0, source.x1) == extent(destination.x0, destination.x1) && + extent(source.y0, source.y1) == extent(destination.y0, destination.y1); + } + + inline VkFilter WireColorBlitFilterFor(const WireBlitRect& source, const WireBlitRect& destination, + GLenum filter, VkSurfaceTransformFlagBitsKHR transform) { + if (filter != GL_LINEAR) return VK_FILTER_NEAREST; + const Bool expressed = transform == VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR || + transform == VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR || + transform == VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR || + transform == VK_SURFACE_TRANSFORM_ROTATE_270_BIT_KHR; + if (expressed && WireBlitIsUnscaled(source, destination)) return VK_FILTER_NEAREST; + return VK_FILTER_LINEAR; + } +} // namespace MobileGL::MG_Backend::DirectVulkan +#endif diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc index 9cde2d8a..6c525cf7 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc @@ -1105,12 +1105,23 @@ // P7 wave 2-B, CONTRACT-P7 ยง3.2: `default-color-blit-shape@P7` RETIRES, in three // pieces, because the one Fatal covered three unrelated situations. // - // The shader blit is here for ROTATION and for nothing else. It can only SAMPLE - // its source - sampler2D, a float result, one of two 2D view types - so an - // RGBA8UI attachment, a 3D slice or an array layer made it return false, and the - // refusal fired even on a surface with NO rotation, where the ordinary - // vkCmdBlitImage arm twenty lines down would have done the blit correctly and - // does handle those shapes. + // The shader blit EXISTS for rotation, because an axis-aligned vkCmdBlitImage cannot + // express a quarter turn. It is still TAKEN FIRST on every transform it can + // express, identity included. That has been the routing since 38919d453: every + // host lane (all identity) and the Redmi pbuffer run their default-framebuffer + // colour blits through it, F1WireScenario.ColorBlitToDefaultHonorsScissorAndReversedRect + // among them. The ordinary vkCmdBlitImage arm below is reached on identity only + // when this pass declines the source. Because identity blits run here, the pass + // must copy exactly wherever GL says the blit is a copy: an unscaled blit samples + // NEAREST whatever `filter` says (WireColorBlitFilter.h, g3-blit; on Adreno 830, + // linear sampling of a 1:1 GL_LINEAR blit moved create-instancing's edge pixels by + // 1 LSB, where the monolith's vkCmdBlitImage is exact). + // + // The pass can only SAMPLE its source - sampler2D, a float result, one of two 2D + // view types - so an RGBA8UI attachment, a 3D slice or an array layer makes it + // return false. That refusal used to fire even on a surface with NO rotation, + // where the vkCmdBlitImage arm below does the blit correctly and does handle + // those shapes. const auto transform = m_swapchainObject.GetPreTransform(); const Bool knownRotation = transform == VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR || transform == VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR || @@ -1154,9 +1165,11 @@ static_cast(transform)); return; } - // Identity: fall through to the ordinary vkCmdBlitImage arm below. It already - // maps GL's bottom row onto the native image's last row for a default target, - // and vkCmdBlitImage takes integer formats and array layers natively. Its 3D + // Identity, and the shader pass above declined this source (an integer format, a + // 3D slice, a view type it cannot sample): fall through to the ordinary + // vkCmdBlitImage arm below. It already maps GL's bottom row onto the native + // image's last row for a default target, and vkCmdBlitImage takes integer + // formats and array layers natively. Its 3D // addressing was wrong and is fixed with this package - see the srcBaseLayer / // srcZ note down there; the bug was invisible until now because a 3D source hit // the Fatal above and never reached that arm. diff --git a/MobileGL/MG_Test/Pipeline/PipelineQuirkTest.cpp b/MobileGL/MG_Test/Pipeline/PipelineQuirkTest.cpp index 898c23b5..164899d6 100644 --- a/MobileGL/MG_Test/Pipeline/PipelineQuirkTest.cpp +++ b/MobileGL/MG_Test/Pipeline/PipelineQuirkTest.cpp @@ -13,6 +13,7 @@ #include #if MOBILEGL_BUILD_DISAGGREGATED #include +#include #include #include #endif @@ -895,3 +896,104 @@ TEST(PipelineQuirkTest, WirePlaceholderKeysArePrivatePerShapeAndUnitAndBounded) GTEST_SKIP() << "the wire placeholders exist only in the disaggregated build"; #endif } + +// P7 g3-blit (g3det, ~/w7/logs/g3det/VERDICT.md section 3): WHICH SAMPLER THE WIRE ARM'S SHADER +// COLOUR BLIT USES (WireColorBlitFilter.h). GL filters only a STRETCHED blit, so a 1:1 blit is a copy +// whatever `filter` says. The wire pass sampled every GL_LINEAR blit through its linear sampler; on +// Adreno 830 the sub-texel coordinate quantisation moved create-instancing's 1:1 GL_LINEAR blit into +// FB 0 by 1 LSB on 712 edge pixels, while the monolith's vkCmdBlitImage copied them exactly. +// Lavapipe samples texel centres exactly and shows the same picture on both samplers, so no host +// lane can see this; the choice is pinned here instead. +// +// Red once (executed, reverted): WireColorBlitFilterFor reduced to the old mapping +// (`filter == GL_LINEAR ? VK_FILTER_LINEAR : VK_FILTER_NEAREST`) fails all 29 sampler expectations +// of the first test and none of the second. +#if MOBILEGL_BUILD_DISAGGREGATED +namespace { + using MobileGL::MG_Backend::DirectVulkan::WireBlitIsUnscaled; + using MobileGL::MG_Backend::DirectVulkan::WireBlitRect; + using MobileGL::MG_Backend::DirectVulkan::WireColorBlitFilterFor; + + // Identity and the three turns BlitWireColorImage expresses. + constexpr VkSurfaceTransformFlagBitsKHR kExpressedTransforms[] = { + VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR, VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR, + VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR, VK_SURFACE_TRANSFORM_ROTATE_270_BIT_KHR}; +} // namespace +#endif + +TEST(PipelineQuirkTest, WireColorBlitSamplesNearestWhereTheBlitIsACopy) { +#if MOBILEGL_BUILD_DISAGGREGATED + // create-instancing's last pass (trace call 530331): glBlitFramebuffer(0,0,854,480 -> 0,0,854,480, + // GL_COLOR_BUFFER_BIT, GL_LINEAR) into FB 0, on an identity pbuffer. + EXPECT_EQ(WireColorBlitFilterFor({0, 0, 854, 480}, {0, 0, 854, 480}, GL_LINEAR, + VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR), + VK_FILTER_NEAREST) + << "a 1:1 GL_LINEAR blit is a copy (GL 4.6 core 18.3.1); linear sampling of it is not exact on Adreno"; + const struct { + const char* what; + WireBlitRect source, destination; + } copies[] = { + {"offset on both sides", {10, 20, 110, 70}, {300, 5, 400, 55}}, + {"source reversed on x", {854, 0, 0, 480}, {0, 0, 854, 480}}, + {"destination reversed on both axes", {0, 0, 6, 4}, {48, 36, 42, 32}}, + {"both reversed", {6, 4, 0, 0}, {42, 32, 48, 36}}, + {"one texel", {3, 3, 4, 4}, {0, 0, 1, 1}}, + {"negative corners", {-8, -4, 8, 4}, {100, 100, 116, 108}}, + // 64-bit extents: INT_MAX - INT_MIN overflows a GLint. + {"full GLint range", {INT_MIN, 0, INT_MAX, 1}, {INT_MAX, 1, INT_MIN, 0}}, + }; + for (const auto& copy : copies) { + EXPECT_TRUE(WireBlitIsUnscaled(copy.source, copy.destination)) << copy.what; + // A quarter turn maps logical pixel centres onto native ones (the native extent is the + // logical one swapped), so a copy is still a copy on every rotated surface. + for (const auto transform : kExpressedTransforms) { + EXPECT_EQ(WireColorBlitFilterFor(copy.source, copy.destination, GL_LINEAR, transform), VK_FILTER_NEAREST) + << copy.what << ", surface transform 0x" << std::hex << static_cast(transform); + } + } +#else + GTEST_SKIP() << "the wire shader blit exists only in the disaggregated build"; +#endif +} + +TEST(PipelineQuirkTest, WireColorBlitKeepsLinearWhereTheBlitStretches) { +#if MOBILEGL_BUILD_DISAGGREGATED + const struct { + const char* what; + WireBlitRect source, destination; + } stretches[] = { + {"2x on both axes", {0, 0, 854, 480}, {0, 0, 1708, 960}}, + {"half on y only", {0, 0, 854, 480}, {0, 0, 854, 240}}, + {"one texel wider on x only", {0, 0, 854, 480}, {0, 0, 855, 480}}, + // GL has no transpose: swapped extents are a stretch on both axes, not a rotation. + {"extents transposed", {0, 0, 480, 854}, {0, 0, 854, 480}}, + {"reversed and stretched", {0, 0, 6, 4}, {48, 36, 36, 28}}, + // What GenerateMipmap's attachable arm asks for (source level -> the next level). + {"a mip step", {0, 0, 64, 32}, {0, 0, 32, 16}}, + {"a 1xN mip step", {0, 0, 1, 8}, {0, 0, 1, 4}}, + {"full GLint range against one texel", {INT_MIN, 0, INT_MAX, 1}, {0, 0, -1, 1}}, + }; + for (const auto& stretch : stretches) { + EXPECT_FALSE(WireBlitIsUnscaled(stretch.source, stretch.destination)) << stretch.what; + for (const auto transform : kExpressedTransforms) { + EXPECT_EQ(WireColorBlitFilterFor(stretch.source, stretch.destination, GL_LINEAR, transform), + VK_FILTER_LINEAR) + << stretch.what << ": GL_LINEAR filters a stretched blit, surface transform 0x" << std::hex + << static_cast(transform); + EXPECT_EQ(WireColorBlitFilterFor(stretch.source, stretch.destination, GL_NEAREST, transform), + VK_FILTER_NEAREST) + << stretch.what << ": GL_NEAREST never becomes linear"; + } + } + for (const auto transform : kExpressedTransforms) { + EXPECT_EQ(WireColorBlitFilterFor({0, 0, 854, 480}, {0, 0, 854, 480}, GL_NEAREST, transform), VK_FILTER_NEAREST); + } + // A transform the pass cannot express is declined before a sampler is chosen; the helper keeps + // the filter's own mapping for one rather than claiming the copy is exact. + EXPECT_EQ(WireColorBlitFilterFor({0, 0, 854, 480}, {0, 0, 854, 480}, GL_LINEAR, + VK_SURFACE_TRANSFORM_HORIZONTAL_MIRROR_BIT_KHR), + VK_FILTER_LINEAR); +#else + GTEST_SKIP() << "the wire shader blit exists only in the disaggregated build"; +#endif +}