diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp index 458f0734..44a46ec6 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp @@ -206,6 +206,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { XXHASH_VERIFY( XXH64_update(m_hashState, &payload.primitiveRestartEnable, sizeof(payload.primitiveRestartEnable))); XXHASH_VERIFY(XXH64_update(m_hashState, &payload.patchControlPoints, sizeof(payload.patchControlPoints))); + XXHASH_VERIFY(XXH64_update(m_hashState, &payload.viewportCount, sizeof(payload.viewportCount))); XXHASH_VERIFY(XXH64_update(m_hashState, &payload.polygonMode, sizeof(payload.polygonMode))); XXHASH_VERIFY(XXH64_update(m_hashState, &payload.cullMode, sizeof(payload.cullMode))); XXHASH_VERIFY(XXH64_update(m_hashState, &payload.frontFace, sizeof(payload.frontFace))); @@ -406,8 +407,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { tessellation.patchControlPoints = payload.patchControlPoints; VkPipelineViewportStateCreateInfo vpci{VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO}; - vpci.viewportCount = 1; - vpci.scissorCount = 1; + // Both counts move together: GL has one scissor rectangle per viewport, and Vulkan + // requires viewportCount == scissorCount whenever both are dynamic + // (VUID-VkPipelineViewportStateCreateInfo-scissorCount-04136). The caller has already + // clamped this to the device's multiViewport capability. + vpci.viewportCount = std::max(payload.viewportCount, 1u); + vpci.scissorCount = vpci.viewportCount; VkPipelineRasterizationStateCreateInfo raster{VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO}; raster.polygonMode = payload.polygonMode; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h index 3feb7cd0..cf846a3d 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h @@ -42,6 +42,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool primitiveRestartEnable = false; // GL_PATCH_VERTICES; only read for a PATCH_LIST topology. Uint32 patchControlPoints = 3; + // How many of ARB_viewport_array's viewports this pipeline rasterizes into. 1 for + // every program that never assigns gl_ViewportIndex, which is all of them outside the + // conformance suite - the wide shape costs a longer vkCmdSetViewport/Scissor per state + // change and can cost hardware fast paths, so it is opt-in per program. Baked into the + // pipeline (viewportCount is not dynamic without VK_EXT_extended_dynamic_state) and + // therefore hashed; the DYNAMIC viewport/scissor arrays the draw pushes must have + // exactly this many elements (VUID-vkCmdDraw-viewportCount-03417/-03418). + Uint32 viewportCount = 1; VkPolygonMode polygonMode = VK_POLYGON_MODE_FILL; VkCullModeFlags cullMode = VK_CULL_MODE_BACK_BIT; VkFrontFace frontFace = VK_FRONT_FACE_CLOCKWISE; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp index cff25d7e..531ef6da 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp @@ -1997,6 +1997,29 @@ namespace MobileGL::MG_Backend::DirectVulkan { return ReflectedDeclaresInputBuiltin(reflectModule, SpvBuiltInBaseVertex); } + // gl_ViewportIndex on the last pre-rasterization stage. glslang emits it natively for Vulkan + // (BuiltIn ViewportIndex plus OpCapability MultiViewport), and nothing in the SpirvPasses + // chain touches it, so a plain reflection of the declared output builtins is the whole test. + Bool ProgramFactory::ReflectedWritesViewportIndexBuiltin(const SpvReflectShaderModule& reflectModule) { + return ReflectedDeclaresOutputBuiltin(reflectModule, SpvBuiltInViewportIndex); + } + + Bool ProgramFactory::ReflectedDeclaresOutputBuiltin(const SpvReflectShaderModule& reflectModule, + SpvBuiltIn builtin) { + for (Uint32 entryIndex = 0; entryIndex < reflectModule.entry_point_count; ++entryIndex) { + const SpvReflectEntryPoint& entryPoint = reflectModule.entry_points[entryIndex]; + for (Uint32 variableIndex = 0; variableIndex < entryPoint.output_variable_count; ++variableIndex) { + const SpvReflectInterfaceVariable* variable = entryPoint.output_variables[variableIndex]; + if (variable != nullptr && + (variable->decoration_flags & SPV_REFLECT_DECORATION_BUILT_IN) != 0 && + variable->built_in == builtin) { + return true; + } + } + } + return false; + } + Bool ProgramFactory::ReflectedDeclaresInputBuiltin(const SpvReflectShaderModule& reflectModule, SpvBuiltIn builtin) { for (Uint32 entryIndex = 0; entryIndex < reflectModule.entry_point_count; ++entryIndex) { @@ -2339,6 +2362,46 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } + // Which pre-rasterization stage assigns gl_ViewportIndex is not fixed: GL 4.1 allows only the + // geometry stage, ARB_shader_viewport_layer_array/GL 4.6 also the vertex and tessellation + // evaluation stages. Rather than guess which one is last, every non-fragment, non-compute + // module is asked - one writer anywhere means this program's draws need a multi-viewport + // pipeline, and a false positive costs only a wider viewportCount. + void ProgramFactory::ReflectViewportIndexUsage(const Vector>& shaders, + const Vector>& spirv, + VkProgramObject& entry) const { + entry.writesViewportIndexBuiltin = false; + + for (SizeT moduleIndex = 0; moduleIndex < shaders.size() && moduleIndex < spirv.size(); ++moduleIndex) { + if (!shaders[moduleIndex]) continue; + const ShaderStage stage = shaders[moduleIndex]->GetShaderStage(); + if (stage == ShaderStage::Fragment || stage == ShaderStage::Compute) continue; + + const auto& module = spirv[moduleIndex]; + if (module.empty()) continue; + + SpvReflectShaderModule reflectModule{}; + const SpvReflectResult createResult = + spvReflectCreateShaderModule(module.size() * sizeof(Uint), module.data(), &reflectModule); + if (createResult != SPV_REFLECT_RESULT_SUCCESS) { + // Fail toward the wide pipeline. Missing a real gl_ViewportIndex writer would + // silently collapse every viewport onto 0 (the exact bug this reflection exists + // to fix); over-declaring costs one extra viewport slot on a program that never + // uses it. + MGLOG_E_ONCE("ProgramFactory::ReflectViewportIndexUsage: reflection failed (result=%d); assuming the " + "program writes gl_ViewportIndex", + static_cast(createResult)); + entry.writesViewportIndexBuiltin = true; + continue; + } + + if (ReflectedWritesViewportIndexBuiltin(reflectModule)) { + entry.writesViewportIndexBuiltin = true; + } + spvReflectDestroyShaderModule(&reflectModule); + } + } + void ProgramFactory::ReflectFragmentOutputs(const Vector>& shaders, const Vector>& spirv, VkProgramObject& entry) const { @@ -3189,6 +3252,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { ValidateRasterizationStageInterface(shaders, moduleSpirvs, entry, program.GetExternalIndex()); #endif ReflectVertexInputs(shaders, moduleSpirvs, entry); + ReflectViewportIndexUsage(shaders, moduleSpirvs, entry); ReflectFragmentOutputs(shaders, moduleSpirvs, entry); ReflectPassthroughTessControlNeed(shaders, moduleSpirvs, entry); ReflectLayout(program, moduleSpirvs, entry); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h index d37f1661..b4ee96e3 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h @@ -151,6 +151,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { // PROGRAM rather than of the variant: the zeroed variant leaves the variable // declared, so both variants answer the same and the draw path can ask either. Bool readsBaseVertexBuiltin = false; + // Some pre-rasterization stage assigns gl_ViewportIndex. Its pipeline declares + // viewportCount = the renderer's rasterizable viewport count instead of 1, and its + // draws push the whole viewport/scissor array; every other program keeps the + // single-viewport fast path untouched. Part of the program's identity (folded into + // the pipeline hash through programHash), so no memo can serve the wrong shape. + Bool writesViewportIndexBuiltin = false; // This program has a tessellation EVALUATION stage and no tessellation CONTROL // stage. GL allows that (4.6 core 11.2.2: with no control shader the input patch // is passed through unmodified, the output patch size is PATCH_VERTICES, and the @@ -400,6 +406,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Shared by the two above: does any entry point list an input variable decorated with // this builtin? static Bool ReflectedDeclaresInputBuiltin(const SpvReflectShaderModule& reflectModule, SpvBuiltIn builtin); + // True when an entry point writes the ViewportIndex builtin (gl_ViewportIndex), i.e. when + // the program can route primitives to a viewport other than 0 and its pipeline therefore + // has to declare more than one. Asks about OUTPUT variables because that is the direction + // a pre-rasterization stage declares it in. + static Bool ReflectedWritesViewportIndexBuiltin(const SpvReflectShaderModule& reflectModule); + static Bool ReflectedDeclaresOutputBuiltin(const SpvReflectShaderModule& reflectModule, SpvBuiltIn builtin); // The pass-through tessellation control stage GL 4.6 core 11.2.2 describes for a // program that has an evaluation stage and no control stage, for an input patch of @@ -434,6 +446,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { void ReflectVertexInputs(const Vector>& shaders, const Vector>& spirv, VkProgramObject& entry) const; + void ReflectViewportIndexUsage(const Vector>& shaders, + const Vector>& spirv, + VkProgramObject& entry) const; void ReflectFragmentOutputs(const Vector>& shaders, const Vector>& spirv, VkProgramObject& entry) const; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 2400a392..d786fe22 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -437,12 +437,24 @@ namespace MobileGL::MG_Backend::DirectVulkan { vkCmdSetScissor(commandBuffer, 0, 1, &scissor); } - static void ApplyGLViewportState(VkCommandBuffer commandBuffer, - const IntVec2& framebufferExtent, - VkSurfaceTransformFlagBitsKHR preTransform, - Bool isDefaultFramebuffer) { - const IntVec4& viewportState = MG_State::pGLContext->GetViewport(); - const FloatVec2& depthRange = MG_State::pGLContext->GetDepthRange(); + // One viewport of the ARB_viewport_array state, mapped into Vulkan's frame. Split out of + // ApplyGLViewportState so the multi-viewport path derives index i through EXACTLY the same + // arithmetic as index 0 - the default-framebuffer Y-flip and pre-transform rotation + // especially, which is the classic way a multi-viewport port comes out upside down for every + // index but the one that was tested. + static VkViewport ComputeGLViewport(Uint32 index, + const IntVec2& framebufferExtent, + VkSurfaceTransformFlagBitsKHR preTransform, + Bool isDefaultFramebuffer) { + // Rounded to integers on purpose: MobileGL advertises GL_VIEWPORT_SUBPIXEL_BITS = 0, so + // the fractional rectangle glViewportIndexedf can store is exact as STATE and snapped + // when it rasterizes. + const FloatVec4& stored = MG_State::pGLContext->GetViewportIndexed(index); + const IntVec4 viewportState(static_cast(std::lround(stored.x())), + static_cast(std::lround(stored.y())), + static_cast(std::lround(stored.z())), + static_cast(std::lround(stored.w()))); + const FloatVec2& depthRange = MG_State::pGLContext->GetDepthRangeIndexed(index); const IntVec2 logicalExtent = isDefaultFramebuffer ? ResolveDefaultFramebufferLogicalExtent(preTransform, framebufferExtent) : framebufferExtent; @@ -477,6 +489,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { viewport.height = static_cast(viewportHeight); viewport.minDepth = depthRange.x(); viewport.maxDepth = depthRange.y(); + return viewport; + } + + static void ApplyGLViewportState(VkCommandBuffer commandBuffer, + const IntVec2& framebufferExtent, + VkSurfaceTransformFlagBitsKHR preTransform, + Bool isDefaultFramebuffer) { + const VkViewport viewport = ComputeGLViewport(0, framebufferExtent, preTransform, isDefaultFramebuffer); auto& shadow = g_dynamicStateShadow; if (shadow.viewportValid && shadow.viewport.x == viewport.x && shadow.viewport.y == viewport.y && shadow.viewport.width == viewport.width && shadow.viewport.height == viewport.height && @@ -4879,6 +4899,7 @@ void main() { .topology = vkTopology, .primitiveRestartEnable = primitiveRestartEnabled, .patchControlPoints = static_cast(MG_State::pGLContext->GetPatchVertices()), + .viewportCount = ResolveDrawViewportCount(programObj.writesViewportIndexBuiltin), .polygonMode = effectivePolygonMode, .cullMode = cullFaceEnabled ? MG_Util::ConvertCullFaceModeToVkEnum(MG_State::pGLContext->GetCullFaceMode(), invertClockwise) @@ -5321,9 +5342,71 @@ void main() { } - void VulkanRenderer::ApplyDynamicDrawStateTail(FrameContext::FrameData& frame, const IntVec2& extent, - Bool isDefaultFbo) { + // The scissor rectangle Vulkan needs for ARB_viewport_array index `index`. Vulkan has no + // per-viewport scissor-test TOGGLE - a scissor rectangle always applies - so an index whose + // GL scissor test is disabled gets the whole framebuffer, which is exactly "the test always + // passes" (GL 4.6 core 17.3.2). + VkRect2D VulkanRenderer::ComputeGLScissorRect(Uint32 index, const IntVec2& extent, + VkSurfaceTransformFlagBitsKHR preTransform, + Bool isDefaultFbo) const { + const auto& parameters = MG_State::pGLContext->GetRenderStateParameters(); + if ((parameters.ScissorTestEnabledMask & (1u << index)) == 0) { + VkRect2D full{}; + full.offset = {0, 0}; + full.extent = {static_cast(extent.x()), static_cast(extent.y())}; + return full; + } + const IntVec4& scissorBox = parameters.ScissorBoxes[index]; + return isDefaultFbo ? MakeDefaultFramebufferScissorRect(scissorBox, extent, preTransform) + : MakeClampedScissorRect(scissorBox, extent); + } + + // The wide half of ApplyDynamicDrawStateTail: a pipeline built for a gl_ViewportIndex-writing + // program declares viewportCount > 1, and Vulkan then requires that many viewports AND that + // many scissors to have been set before the draw + // (VUID-vkCmdDraw-viewportCount-03417/-03418). Deliberately unmemoized: only conformance + // shaders reach it, the single-element dynamic-state shadow cannot describe an array, and + // leaving that shadow invalidated is what makes the next ordinary draw re-push its own + // single viewport instead of believing the array's element 0 is already bound. + void VulkanRenderer::ApplyMultiViewportDynamicState(VkCommandBuffer commandBuffer, Uint32 viewportCount, + const IntVec2& extent, + VkSurfaceTransformFlagBitsKHR preTransform, + Bool isDefaultFbo) { + MOBILEGL_ASSERT(viewportCount <= RenderStateParameters::MAX_VIEWPORTS, + "ApplyMultiViewportDynamicState: viewportCount=%u exceeds the indexed state width", + viewportCount); + const Uint32 count = std::min(viewportCount, RenderStateParameters::MAX_VIEWPORTS); + + Array viewports{}; + Array scissors{}; + for (Uint32 i = 0; i < count; ++i) { + viewports[i] = ComputeGLViewport(i, extent, preTransform, isDefaultFbo); + scissors[i] = ComputeGLScissorRect(i, extent, preTransform, isDefaultFbo); + } + vkCmdSetViewport(commandBuffer, 0, count, viewports.data()); + vkCmdSetScissor(commandBuffer, 0, count, scissors.data()); + auto& shadow = g_dynamicStateShadow; + shadow.viewportValid = false; + shadow.scissorValid = false; + shadow.dynamicTailValid = false; + } + + void VulkanRenderer::ApplyDynamicDrawStateTail(FrameContext::FrameData& frame, const IntVec2& extent, + Bool isDefaultFbo, Uint32 viewportCount) { + auto& shadow = g_dynamicStateShadow; + if (viewportCount > 1) { + // The other five Apply* still run: blend constants, depth bias, line width and the + // stencil masks are not per-viewport and a multi-viewport draw needs them just as + // much. Only the viewport/scissor pair takes the array shape. + ApplyBlendConstants(frame.commandBuffer); + ApplyPolygonOffsetState(frame.commandBuffer); + ApplyLineWidthState(frame.commandBuffer); + ApplyStencilState(frame.commandBuffer); + ApplyMultiViewportDynamicState(frame.commandBuffer, viewportCount, extent, + m_swapchainObject.GetPreTransform(), isDefaultFbo); + return; + } // One compare for the whole tail: see the gate's declaration in // DynamicStateShadow for why (version, extent, default-FBO flag) pins every // input the six Apply* below read. @@ -5761,7 +5844,7 @@ void main() { const Bool idxUploadOk = UploadAndBindIndexBuffer(frame, vao, pIndexBufferView); MOBILEGL_ASSERT(idxUploadOk, "SetupDraw fast path: failed to upload index buffer"); } - ApplyDynamicDrawStateTail(frame, snap.renderPassExtent, snap.drawFboIsDefault); + ApplyDynamicDrawStateTail(frame, snap.renderPassExtent, snap.drawFboIsDefault, snap.viewportCount); return true; } @@ -6201,7 +6284,8 @@ void main() { MOBILEGL_ASSERT(idxUploadOk, "SetupDraw skipped: failed to upload index buffer"); } - ApplyDynamicDrawStateTail(frame, renderPassEntry->extent, drawFbo->IsDefaultFramebuffer()); + ApplyDynamicDrawStateTail(frame, renderPassEntry->extent, drawFbo->IsDefaultFramebuffer(), + ResolveDrawViewportCount(programObj.writesViewportIndexBuiltin)); // Snapshot the fully resolved configuration for the consecutive-draw // fast path (see TrySetupDrawFastPath). @@ -6220,6 +6304,7 @@ void main() { snap.drawFbo = drawFbo.get(); snap.fboVersion = drawFbo->GetObjectVersion(); snap.drawFboIsDefault = drawFboIsDefault; + snap.viewportCount = ResolveDrawViewportCount(programObj.writesViewportIndexBuiltin); snap.renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion(); snap.bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); snap.baseTransformFlags = GetBaseTransformFlagsRaw(drawFboIsDefault); @@ -12216,6 +12301,28 @@ void main() { m_fillModeNonSolidFeatureEnabled = deviceFeatures.fillModeNonSolid == VK_TRUE; deviceFeatures.dualSrcBlend = supportedDeviceFeatures.dualSrcBlend; m_dualSrcBlendFeatureEnabled = deviceFeatures.dualSrcBlend == VK_TRUE; + // ARB_viewport_array rasterization. Without multiViewport a pipeline may declare exactly + // one viewport (VUID-VkPipelineViewportStateCreateInfo-viewportCount-01216), so a shader's + // gl_ViewportIndex can only ever select viewport 0 and the other fifteen rectangles are + // state with nowhere to go. The GL state stays 16 wide either way - GL 4.3 core requires + // MAX_VIEWPORTS >= 16 and that is a frontend promise, not a device one; this gate decides + // only whether a DRAW can rasterize into more than one of them. + deviceFeatures.multiViewport = supportedDeviceFeatures.multiViewport; + m_multiViewportFeatureEnabled = deviceFeatures.multiViewport == VK_TRUE; + m_maxRasterizableViewports = + m_multiViewportFeatureEnabled + ? std::min(RenderStateParameters::MAX_VIEWPORTS, + std::max(m_physicalDevice.properties.limits.maxViewports, 1u)) + : 1u; + MGLOG_I("Vulkan: multiViewport %s; rasterizable viewports=%u (device limit %u, GL state width %u)", + m_multiViewportFeatureEnabled ? "enabled" : "UNAVAILABLE", m_maxRasterizableViewports, + m_physicalDevice.properties.limits.maxViewports, + static_cast(RenderStateParameters::MAX_VIEWPORTS)); + if (!m_multiViewportFeatureEnabled) { + MGLOG_W("Vulkan: the device does not support the multiViewport feature; gl_ViewportIndex will always " + "select viewport 0 and per-viewport scissor/depth-range state past index 0 cannot be " + "rasterized (the state itself is still stored and queryable)"); + } deviceFeatures.logicOp = supportedDeviceFeatures.logicOp; deviceFeatures.shaderClipDistance = supportedDeviceFeatures.shaderClipDistance; deviceFeatures.shaderCullDistance = supportedDeviceFeatures.shaderCullDistance; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index 06600b98..69d958f8 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -547,6 +547,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { // needs no feature). Both cached at device creation and drive a hard-fail-at-draw when absent. Bool m_dualSrcBlendFeatureEnabled = false; Bool m_primitiveTopologyListRestartFeatureEnabled = false; + // multiViewport gates rasterizing into more than one of ARB_viewport_array's 16 viewports + // (gl_ViewportIndex). m_maxRasterizableViewports is min(MAX_VIEWPORTS, device limit), or 1 + // when the feature is off, and is the viewportCount a gl_ViewportIndex-writing pipeline + // declares - it is NOT what GL_MAX_VIEWPORTS reports, which is the frontend state width. + Bool m_multiViewportFeatureEnabled = false; + Uint32 m_maxRasterizableViewports = 1; // Union of shader stages sampled-read barriers may name; built at device creation // because geometry/tessellation stage bits are invalid in a barrier when their // feature is off (VUID-vkCmdPipelineBarrier-srcStageMask-04090/-04091), and @@ -830,6 +836,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { // re-resolve just the pipeline against the active pass; a change that // flips it must fall back to the full path's pass selection. Bool drawUsesDepthStencil = false; + // The snapshotting draw's pipeline viewportCount. A pure function of the PROGRAM + // (writesViewportIndexBuiltin) and of a device feature fixed at renderer init, both + // of which the programLifetimeId/programVersion guards above already pin - carried + // here so the fast path does not re-fetch the program object to re-derive it. + Uint32 viewportCount = 1; IntVec2 renderPassExtent = {0, 0}; // colorAttachmentCount of the snapshotting draw's render pass: the // pipeline-state hash input, so the fast path can refresh that hash and @@ -1120,7 +1131,22 @@ namespace MobileGL::MG_Backend::DirectVulkan { // The per-draw dynamic-state tail (viewport, scissor, blend constants, depth // bias, line width, stencil), gated behind one render-state-parameters-version // compare per command buffer - see the gate fields in DynamicStateShadow. - void ApplyDynamicDrawStateTail(FrameContext::FrameData& frame, const IntVec2& extent, Bool isDefaultFbo); + // viewportCount is the bound pipeline's declared viewport count: 1 for every program that + // does not write gl_ViewportIndex (the memoized fast path), otherwise the renderer's + // rasterizable viewport count, which takes the unmemoized array path. + void ApplyDynamicDrawStateTail(FrameContext::FrameData& frame, const IntVec2& extent, Bool isDefaultFbo, + Uint32 viewportCount = 1); + void ApplyMultiViewportDynamicState(VkCommandBuffer commandBuffer, Uint32 viewportCount, const IntVec2& extent, + VkSurfaceTransformFlagBitsKHR preTransform, Bool isDefaultFbo); + VkRect2D ComputeGLScissorRect(Uint32 index, const IntVec2& extent, + VkSurfaceTransformFlagBitsKHR preTransform, Bool isDefaultFbo) const; + // How many viewports a draw with this program rasterizes into: 1 unless the program + // assigns gl_ViewportIndex AND the device enabled multiViewport. Both the pipeline's + // baked viewportCount and the dynamic arrays come from this one answer, so they cannot + // disagree. + Uint32 ResolveDrawViewportCount(Bool programWritesViewportIndex) const { + return programWritesViewportIndex && m_multiViewportFeatureEnabled ? m_maxRasterizableViewports : 1u; + } Bool UploadAndBindVertexBuffers(VkCommandBuffer commandBuffer, const MG_State::GLState::VertexArrayObject& vao, const ProgramFactory::VkProgramObject& programObj, diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 5a0109eb..9d7c8d7b 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -63,6 +63,7 @@ add_executable(MobileGLIntegrationTest Scenarios/DepthStencilReadbackMatrixScenario.cpp Scenarios/DepthStencilReadbackAttachmentShapeScenario.cpp Scenarios/ClipDistanceScenario.cpp + Scenarios/ViewportArrayScenario.cpp Scenarios/SsboArrayLengthScenario.cpp Scenarios/DoublePrecisionScenario.cpp Scenarios/UniformInitializerScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp new file mode 100644 index 00000000..11b79957 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp @@ -0,0 +1,524 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp +// 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 +// +// Scenario - gl_ViewportIndex ACTUALLY ROUTES, AND THE PER-INDEX STATE IT SELECTS IS REAL. +// +// The state half of ARB_viewport_array is asserted in MG_Test/State/RenderStateTest.cpp, which +// is a pure set/get exercise and would pass just as green against a backend that stores all 16 +// rectangles and rasterizes only the first. This file is the other half: every case here routes +// primitives to a viewport OTHER than 0 and then looks at where the pixels landed. +// +// Three claims, one per case: +// 1. gl_ViewportIndex selects the viewport RECTANGLE - a 4x4 grid of 32x32 viewports, one +// geometry-shader invocation per cell, and every cell must hold its own index. +// 2. gl_ViewportIndex selects the DEPTH RANGE - 16 one-pixel-wide viewports whose ranges are +// (i/16, 1 - i/16), a quad at each end of clip space, and gl_FragCoord.z read back. +// This is the claim that fails loudest against a single-viewport backend, because the +// geometry is still in the right place while every depth comes back as viewport 0's. +// 3. The per-index SCISSOR TEST ENABLE is honoured. Vulkan has no per-viewport scissor-test +// toggle, so a disabled index has to be given the whole framebuffer as its rectangle; the +// case draws the same primitive into the same index twice, once with the test off and once +// with it on, and requires the two results to differ in the documented direction. +// +// Case 1 runs a second time against the DEFAULT framebuffer. MobileGL Y-flips (and pre-transform +// rotates) the default framebuffer's rectangles and does not touch an FBO's, so a port that +// applies the flip to viewport 0 and forgets the other fifteen renders a correct-looking FBO and +// an upside-down window - the classic multi-viewport bug, and invisible to every FBO-only case. +// +// HONEST LIMIT OF THIS FILE. DirectGLES SKIPS every case: GLES has one viewport, one scissor +// rectangle and no gl_ViewportIndex, so routing to index > 0 is an emulation feature that has +// not been built (the Espryt half of KHR-GL43.viewport_array's rendering group is deliberately +// still red). The skip is explicit rather than silent so a future emulation lands here as a +// failing test and not as a test that was quietly never running. DirectVulkan additionally +// skips when the device lacks the multiViewport feature - Vulkan then forbids a pipeline from +// declaring more than one viewport at all, which is a device limit and not a MobileGL bug; +// lavapipe (every CI lane) and both Mali/Adreno devices support it, so the cases do run where +// it matters. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr int kViewportCount = 16; + constexpr int kGridSide = 4; // 4x4 grid of viewports + constexpr int kCellSize = 32; // ... each 32x32 + constexpr int kSurfaceSide = kGridSide * kCellSize; + constexpr GLint kUnwritten = -1; + + // A geometry shader is the only stage GL 4.1 lets write gl_ViewportIndex, and + // `invocations` runs it once per viewport off a single input point - the same shape + // KHR-GL43.viewport_array.draw_to_single_layer_with_multiple_viewports uses. + const char* const kVertexSource = R"(#version 410 core +void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); } +)"; + + const char* const kGridGeometrySource = R"(#version 410 core +layout(points, invocations = 16) in; +layout(triangle_strip, max_vertices = 4) out; +flat out int gsIndex; +void main() { + gsIndex = gl_InvocationID; + gl_ViewportIndex = gl_InvocationID; + gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); EmitVertex(); + EndPrimitive(); +} +)"; + + // One invocation, viewport chosen by a uniform: lets a case draw the SAME primitive into + // the SAME index twice under two different scissor-enable states. + const char* const kSingleGeometrySource = R"(#version 410 core +layout(points, invocations = 1) in; +layout(triangle_strip, max_vertices = 4) out; +uniform int uViewport; +flat out int gsIndex; +void main() { + gsIndex = uViewport; + gl_ViewportIndex = uViewport; + gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); EmitVertex(); + EndPrimitive(); +} +)"; + + const char* const kIntFragmentSource = R"(#version 410 core +flat in int gsIndex; +layout(location = 0) out int fragColor; +void main() { fragColor = gsIndex; } +)"; + + // Two quads, one at each end of clip space, so the fragment stage can report the depth + // the viewport's range mapped them to. gl_FragCoord.z IS the post-range window depth, so + // it reads back the per-viewport minDepth/maxDepth directly. + const char* const kDepthGeometrySource = R"(#version 410 core +layout(points, invocations = 16) in; +layout(triangle_strip, max_vertices = 8) out; +void main() { + gl_ViewportIndex = gl_InvocationID; + gl_Position = vec4(-1.0, -1.0, -1.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, -1.0, -1.0, 1.0); EmitVertex(); + gl_Position = vec4(-1.0, 0.0, -1.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, 0.0, -1.0, 1.0); EmitVertex(); + EndPrimitive(); + gl_Position = vec4(-1.0, 0.0, 1.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, 0.0, 1.0, 1.0); EmitVertex(); + gl_Position = vec4(-1.0, 1.0, 1.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, 1.0, 1.0, 1.0); EmitVertex(); + EndPrimitive(); +} +)"; + + const char* const kDepthFragmentSource = R"(#version 410 core +layout(location = 0) out float fragColor; +void main() { fragColor = gl_FragCoord.z; } +)"; + + class ViewportArrayScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + + if (Gl().BackendName() == "DirectGLES") { + GTEST_SKIP() << "gl_ViewportIndex routing is not emulated on DirectGLES: GLES has one viewport " + "and one scissor rectangle, so every index rasterizes as index 0. The indexed " + "STATE is still asserted (MG_Test RenderStateTest); this is the deferred " + "rendering half of KHR-GL43.viewport_array."; + } + + GLint maxViewports = 0; + glGetIntegerv(GL_MAX_VIEWPORTS, &maxViewports); + ASSERT_GE(maxViewports, kViewportCount) << "GL 4.3 core requires GL_MAX_VIEWPORTS >= 16"; + + m_program = BuildProgram(kGridGeometrySource, kIntFragmentSource); + ASSERT_NE(m_program, 0u) << "grid program failed to build: " << m_buildLog; + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + ResetViewportArrayState(); + ASSERT_EQ(glGetError(), GL_NO_ERROR) << "setup left a GL error behind"; + } + + void TearDown() override { + if (!Ready() || IsSkipped()) return; + ResetViewportArrayState(); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_program != 0) glDeleteProgram(m_program); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + while (glGetError() != GL_NO_ERROR) { + } + } + + // Every case starts from the same slate: this fixture shares its context with every + // other scenario in the process, and a leftover per-index scissor enable is exactly + // the kind of state that would make a later case pass or fail for the wrong reason. + static void ResetViewportArrayState() { + for (int i = 0; i < kViewportCount; ++i) { + glDisablei(GL_SCISSOR_TEST, static_cast(i)); + } + glDisable(GL_SCISSOR_TEST); + glViewport(0, 0, kSurfaceSide, kSurfaceSide); + glScissor(0, 0, kSurfaceSide, kSurfaceSide); + glDepthRange(0.0, 1.0); + glDisable(GL_DEPTH_TEST); + } + + // The 4x4 grid: viewport y*4+x covers the cell whose lower-left corner is + // (x*cellW, y*cellH), in GL's bottom-left-origin window coordinates. Parameterized on + // the cell size because the default framebuffer this scenario also renders into is + // deliberately non-square (HeadlessGL is 128x96, so a transposing bug cannot hide). + static void SetupGridViewports(int cellW, int cellH) { + std::vector data(static_cast(kViewportCount) * 4); + for (int y = 0; y < kGridSide; ++y) { + for (int x = 0; x < kGridSide; ++x) { + const size_t base = static_cast(y * kGridSide + x) * 4; + data[base + 0] = static_cast(x * cellW); + data[base + 1] = static_cast(y * cellH); + data[base + 2] = static_cast(cellW); + data[base + 3] = static_cast(cellH); + } + } + glViewportArrayv(0, kViewportCount, data.data()); + } + + GLuint BuildProgram(const char* geometrySource, const char* fragmentSource) { + const GLuint vs = CompileStage(GL_VERTEX_SHADER, kVertexSource); + if (vs == 0) return 0; + const GLuint gs = CompileStage(GL_GEOMETRY_SHADER, geometrySource); + if (gs == 0) { + glDeleteShader(vs); + return 0; + } + const GLuint fs = CompileStage(GL_FRAGMENT_SHADER, fragmentSource); + if (fs == 0) { + glDeleteShader(vs); + glDeleteShader(gs); + return 0; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, vs); + glAttachShader(program, gs); + glAttachShader(program, fs); + glLinkProgram(program); + GLint linked = 0; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + glDeleteShader(vs); + glDeleteShader(gs); + glDeleteShader(fs); + if (!linked) { + GLint length = 0; + glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length); + std::vector log(static_cast(length > 1 ? length : 1), '\0'); + glGetProgramInfoLog(program, static_cast(log.size()), nullptr, log.data()); + m_buildLog = log.data(); + glDeleteProgram(program); + return 0; + } + return program; + } + + GLuint CompileStage(GLenum stage, const char* source) { + const GLuint shader = glCreateShader(stage); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint compiled = 0; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled) return shader; + GLint length = 0; + glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length); + std::vector log(static_cast(length > 1 ? length : 1), '\0'); + glGetShaderInfoLog(shader, static_cast(log.size()), nullptr, log.data()); + m_buildLog = log.data(); + glDeleteShader(shader); + return 0; + } + + // An R32I colour target, pre-filled with kUnwritten so "nothing was drawn here" is + // distinguishable from "index 0 was drawn here". + struct IntTarget { + GLuint fbo = 0; + GLuint texture = 0; + }; + + // The "nothing drawn here" value is UPLOADED, not cleared: the CTS fills its R32I + // targets the same way (fillTexture), and an upload cannot be confused with a clear + // that a backend defers, reorders or drops - which is exactly the ambiguity a case + // asserting "this cell must be untouched" cannot afford. + static void FillIntTarget(const IntTarget& target, int width, int height) { + const std::vector unwritten(static_cast(width) * height, kUnwritten); + glBindTexture(GL_TEXTURE_2D, target.texture); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RED_INTEGER, GL_INT, unwritten.data()); + } + + static IntTarget MakeIntTarget(int width, int height) { + IntTarget target; + glGenTextures(1, &target.texture); + glBindTexture(GL_TEXTURE_2D, target.texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexImage2D(GL_TEXTURE_2D, 0, GL_R32I, width, height, 0, GL_RED_INTEGER, GL_INT, nullptr); + glGenFramebuffers(1, &target.fbo); + glBindFramebuffer(GL_FRAMEBUFFER, target.fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target.texture, 0); + FillIntTarget(target, width, height); + return target; + } + + static void DestroyIntTarget(IntTarget& target) { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + if (target.fbo != 0) glDeleteFramebuffers(1, &target.fbo); + if (target.texture != 0) glDeleteTextures(1, &target.texture); + } + + static std::vector ReadInts(int width, int height) { + std::vector pixels(static_cast(width) * height, 0); + glReadPixels(0, 0, width, height, GL_RED_INTEGER, GL_INT, pixels.data()); + return pixels; + } + + // The centre of grid cell (x, y), in the bottom-left-origin coordinates glReadPixels + // returns. Sampling the centre rather than a corner keeps the assertion about WHICH + // viewport was selected rather than about edge rounding. + static GLint CellCentre(const std::vector& pixels, int stride, int x, int y) { + const int px = x * kCellSize + kCellSize / 2; + const int py = y * kCellSize + kCellSize / 2; + return pixels[static_cast(py) * stride + px]; + } + + std::string m_buildLog; + GLuint m_program = 0; + GLuint m_vao = 0; + }; + + // --- 1. the viewport rectangle ------------------------------------------------------- + + TEST_F(ViewportArrayScenario, EachViewportIndexRasterizesIntoItsOwnRectangle) { + IntTarget target = MakeIntTarget(kSurfaceSide, kSurfaceSide); + SetupGridViewports(kCellSize, kCellSize); + glUseProgram(m_program); + glBindVertexArray(m_vao); + glDrawArrays(GL_POINTS, 0, 1); + ASSERT_EQ(glGetError(), GL_NO_ERROR); + + const std::vector pixels = ReadInts(kSurfaceSide, kSurfaceSide); + for (int y = 0; y < kGridSide; ++y) { + for (int x = 0; x < kGridSide; ++x) { + const GLint expected = y * kGridSide + x; + EXPECT_EQ(CellCentre(pixels, kSurfaceSide, x, y), expected) + << "cell (" << x << ", " << y << ") should hold viewport index " << expected + << "; a single-viewport backend paints the whole image with 15 (the last invocation)"; + } + } + DestroyIntTarget(target); + } + + // The same claim against the DEFAULT framebuffer, where MobileGL applies its Y-flip and + // pre-transform rotation. Index 0 alone getting the mapping is the classic bug. + TEST_F(ViewportArrayScenario, TheDefaultFramebufferAppliesTheSameFlipToEveryViewport) { + const int surfaceW = Gl().Width(); + const int surfaceH = Gl().Height(); + ASSERT_GE(surfaceW, kGridSide); + ASSERT_GE(surfaceH, kGridSide); + const int cellW = surfaceW / kGridSide; + const int cellH = surfaceH / kGridSide; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + // Paint a value no viewport index can produce, so an unwritten cell is obvious. + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + + // The default framebuffer is 8-bit RGBA, so the index travels as a colour: cell i is + // painted with red = i * 16, which is exact in 8 bits for i in [0, 16). + const char* const kColorFragmentSource = R"(#version 410 core +flat in int gsIndex; +layout(location = 0) out vec4 fragColor; +void main() { fragColor = vec4(float(gsIndex) * 16.0 / 255.0, 0.0, 0.0, 1.0); } +)"; + const GLuint colorProgram = BuildProgram(kGridGeometrySource, kColorFragmentSource); + ASSERT_NE(colorProgram, 0u) << "colour program failed to build: " << m_buildLog; + + SetupGridViewports(cellW, cellH); + glUseProgram(colorProgram); + glBindVertexArray(m_vao); + glDrawArrays(GL_POINTS, 0, 1); + ASSERT_EQ(glGetError(), GL_NO_ERROR); + + std::vector pixels(static_cast(surfaceW) * surfaceH * 4, 0); + glReadPixels(0, 0, surfaceW, surfaceH, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + for (int y = 0; y < kGridSide; ++y) { + for (int x = 0; x < kGridSide; ++x) { + const int px = x * cellW + cellW / 2; + const int py = y * cellH + cellH / 2; + const int red = pixels[(static_cast(py) * surfaceW + px) * 4]; + const int expected = (y * kGridSide + x) * 16; + // One LSB of slack for an 8-bit round trip; the values are 16 apart, so this + // cannot confuse two neighbouring indices. + EXPECT_LE(std::abs(red - expected), 1) + << "default-framebuffer cell (" << x << ", " << y << ") holds red=" << red << ", expected " + << expected << ". A vertically mirrored grid means the Y-flip was applied to viewport 0 " + << "only"; + } + } + glDeleteProgram(colorProgram); + } + + // --- 2. the depth range -------------------------------------------------------------- + + TEST_F(ViewportArrayScenario, EachViewportIndexUsesItsOwnDepthRange) { + // 16 columns one pixel wide and two rows tall: row 0 gets the near-plane quad, row 1 + // the far-plane one, so both ends of viewport i's range land in the same column. + constexpr int kWidth = kViewportCount; + constexpr int kHeight = 2; + + GLuint texture = 0; + GLuint fbo = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexImage2D(GL_TEXTURE_2D, 0, GL_R32F, kWidth, kHeight, 0, GL_RED, GL_FLOAT, nullptr); + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + const GLfloat clearValue[4] = {-1.0f, 0.0f, 0.0f, 0.0f}; + glClearBufferfv(GL_COLOR, 0, clearValue); + + std::vector viewports(static_cast(kViewportCount) * 4); + std::vector ranges(static_cast(kViewportCount) * 2); + for (int i = 0; i < kViewportCount; ++i) { + viewports[static_cast(i) * 4 + 0] = static_cast(i); + viewports[static_cast(i) * 4 + 1] = 0.0f; + viewports[static_cast(i) * 4 + 2] = 1.0f; + viewports[static_cast(i) * 4 + 3] = 2.0f; + ranges[static_cast(i) * 2 + 0] = static_cast(i) / 16.0; + ranges[static_cast(i) * 2 + 1] = 1.0 - static_cast(i) / 16.0; + } + glViewportArrayv(0, kViewportCount, viewports.data()); + glDepthRangeArrayv(0, kViewportCount, ranges.data()); + + const GLuint depthProgram = BuildProgram(kDepthGeometrySource, kDepthFragmentSource); + ASSERT_NE(depthProgram, 0u) << "depth program failed to build: " << m_buildLog; + glUseProgram(depthProgram); + glBindVertexArray(m_vao); + glDrawArrays(GL_POINTS, 0, 1); + ASSERT_EQ(glGetError(), GL_NO_ERROR); + + std::vector pixels(static_cast(kWidth) * kHeight, 0.0f); + glReadPixels(0, 0, kWidth, kHeight, GL_RED, GL_FLOAT, pixels.data()); + for (int i = 0; i < kViewportCount; ++i) { + const float near = static_cast(i) / 16.0f; + const float far = 1.0f - static_cast(i) / 16.0f; + // The tolerance covers depth-buffer-free rasterization of gl_FragCoord.z on a + // software rasterizer; the per-index values are 1/16 apart, so it cannot let a + // neighbouring viewport's range through, and viewport 0's range (0, 1) differs + // from every other index by at least 1/16. + EXPECT_NEAR(pixels[i], near, 1.0e-3f) + << "viewport " << i << " near-plane depth; got viewport 0's range if this is 0"; + EXPECT_NEAR(pixels[static_cast(kWidth) + i], far, 1.0e-3f) + << "viewport " << i << " far-plane depth; got viewport 0's range if this is 1"; + } + + glDeleteProgram(depthProgram); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glDeleteTextures(1, &texture); + } + + // --- 3. the per-index scissor-test enable -------------------------------------------- + + TEST_F(ViewportArrayScenario, AnIndexedScissorEnableClipsOnlyThatIndex) { + IntTarget target = MakeIntTarget(kSurfaceSide, kSurfaceSide); + + // One full-size viewport per index so the scissor rectangle is the ONLY thing that + // can shrink the quad - the same separation KHR-GL43.viewport_array.scissor uses. + glViewport(0, 0, kSurfaceSide, kSurfaceSide); + std::vector boxes(static_cast(kViewportCount) * 4); + for (int y = 0; y < kGridSide; ++y) { + for (int x = 0; x < kGridSide; ++x) { + const size_t base = static_cast(y * kGridSide + x) * 4; + boxes[base + 0] = x * kCellSize; + boxes[base + 1] = y * kCellSize; + boxes[base + 2] = kCellSize; + boxes[base + 3] = kCellSize; + } + } + glScissorArrayv(0, kViewportCount, boxes.data()); + + const GLuint singleProgram = BuildProgram(kSingleGeometrySource, kIntFragmentSource); + ASSERT_NE(singleProgram, 0u) << "single-viewport program failed to build: " << m_buildLog; + glUseProgram(singleProgram); + glBindVertexArray(m_vao); + const GLint uViewport = glGetUniformLocation(singleProgram, "uViewport"); + ASSERT_NE(uViewport, -1); + + constexpr GLint kProbeIndex = 6; // grid cell (2, 1) + constexpr int kProbeX = kProbeIndex % kGridSide; + constexpr int kProbeY = kProbeIndex / kGridSide; + + // (a) scissor test ENABLED for this index: the quad is clipped to its 32x32 box. + glUniform1i(uViewport, kProbeIndex); + glEnablei(GL_SCISSOR_TEST, kProbeIndex); + glDrawArrays(GL_POINTS, 0, 1); + ASSERT_EQ(glGetError(), GL_NO_ERROR); + { + const std::vector pixels = ReadInts(kSurfaceSide, kSurfaceSide); + EXPECT_EQ(CellCentre(pixels, kSurfaceSide, kProbeX, kProbeY), kProbeIndex) + << "the scissored index must still paint inside its own box"; + for (int y = 0; y < kGridSide; ++y) { + for (int x = 0; x < kGridSide; ++x) { + if (x == kProbeX && y == kProbeY) continue; + EXPECT_EQ(CellCentre(pixels, kSurfaceSide, x, y), kUnwritten) + << "cell (" << x << ", " << y << ") is outside scissor rectangle " << kProbeIndex + << " and must be untouched"; + } + } + } + + // (b) scissor test DISABLED for the same index, everything else identical: with no + // per-viewport toggle in Vulkan this is the case that needs the disabled index to be + // given the full framebuffer rectangle, and it is exactly where "leave the last + // rectangle bound" would show up as a still-clipped quad. + FillIntTarget(target, kSurfaceSide, kSurfaceSide); + glBindFramebuffer(GL_FRAMEBUFFER, target.fbo); + glDisablei(GL_SCISSOR_TEST, kProbeIndex); + glDrawArrays(GL_POINTS, 0, 1); + ASSERT_EQ(glGetError(), GL_NO_ERROR); + { + const std::vector pixels = ReadInts(kSurfaceSide, kSurfaceSide); + for (int y = 0; y < kGridSide; ++y) { + for (int x = 0; x < kGridSide; ++x) { + EXPECT_EQ(CellCentre(pixels, kSurfaceSide, x, y), kProbeIndex) + << "with the scissor test off for index " << kProbeIndex + << ", its full-viewport quad must cover cell (" << x << ", " << y << ")"; + } + } + } + + glDeleteProgram(singleProgram); + DestroyIntTarget(target); + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_Test/Pipeline/CMakeLists.txt b/MobileGL/MG_Test/Pipeline/CMakeLists.txt index 0b9cc576..34ebeb9b 100644 --- a/MobileGL/MG_Test/Pipeline/CMakeLists.txt +++ b/MobileGL/MG_Test/Pipeline/CMakeLists.txt @@ -4,6 +4,7 @@ add_executable( PipelineQuirkTest PipelineQuirkTest.cpp PassthroughTessControlTest.cpp + ViewportIndexReflectionTest.cpp ) target_include_directories(PipelineQuirkTest PRIVATE diff --git a/MobileGL/MG_Test/Pipeline/ViewportIndexReflectionTest.cpp b/MobileGL/MG_Test/Pipeline/ViewportIndexReflectionTest.cpp new file mode 100644 index 00000000..75b1b5e2 --- /dev/null +++ b/MobileGL/MG_Test/Pipeline/ViewportIndexReflectionTest.cpp @@ -0,0 +1,183 @@ +// MobileGL - MobileGL/MG_Test/Pipeline/ViewportIndexReflectionTest.cpp +// 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 +// +// ProgramFactory::ReflectedWritesViewportIndexBuiltin is the switch that decides whether a +// DirectVulkan pipeline declares one viewport or all sixteen. Getting it wrong is silent in both +// directions and neither direction is caught by a state test: +// +// - a false NEGATIVE collapses every gl_ViewportIndex onto viewport 0, which is precisely the +// bug the multi-viewport work exists to fix and which a set/get round trip cannot see; +// - a false POSITIVE widens viewportCount for an ordinary Minecraft shader, costing a longer +// vkCmdSetViewport per state change and, on a tiler, possibly a hardware fast path. +// +// So this compiles REAL GLSL through the same glslang path the renderer uses and reflects the +// SPIR-V that comes out, rather than asserting against hand-assembled words: what has to hold is +// that the detector agrees with what glslang actually emits for a shader that writes the builtin, +// including the stage-by-stage question of WHERE it may be written (GL 4.1 allows the geometry +// stage; ARB_shader_viewport_layer_array adds vertex and tessellation evaluation). +// +// The end-to-end claim - that a detected writer really does route pixels to its own viewport - +// lives in MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp. + +#include + +#include +#include + +#include "Includes.h" +#include "Init.h" + +#include +#include +#include + +#include + +using namespace MobileGL; +using MobileGL::MG_Backend::DirectVulkan::ProgramFactory; +using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler; + +namespace { + + Vector CompileToSpirv(GLenum stage, const String& source) { + using namespace MG_Util::ShaderTranspiler; + ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source}; + auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib); + EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log); + if (!shaderResult) return {}; + + ProgramAttrib programAttrib{.shaders = {shaderResult.value()}}; + auto programResult = ShaderCompiler::LinkProgram(programAttrib); + EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log); + if (!programResult) return {}; + + ProgramBinaryAttrib binaryAttrib{.shaderTypes = {stage}, .program = *programResult.value()}; + auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log); + if (!binaryResult || binaryResult->empty()) return {}; + return binaryResult->front(); + } + + // Owns the reflection module so a failing EXPECT cannot leak it. + class ReflectModule { + public: + explicit ReflectModule(const Vector& spirv) { + if (spirv.empty()) return; + m_created = spvReflectCreateShaderModule(spirv.size() * sizeof(Uint32), spirv.data(), &m_module) == + SPV_REFLECT_RESULT_SUCCESS; + } + ~ReflectModule() { + if (m_created) spvReflectDestroyShaderModule(&m_module); + } + ReflectModule(const ReflectModule&) = delete; + ReflectModule& operator=(const ReflectModule&) = delete; + + Bool Created() const { return m_created; } + const SpvReflectShaderModule& Get() const { return m_module; } + + private: + SpvReflectShaderModule m_module{}; + Bool m_created = false; + }; + + class ViewportIndexReflectionTest : public ::testing::Test { + protected: + void SetUp() override { MobileGL::Initialize(); } + }; + + const char* const kGeometryWritesViewportIndex = R"(#version 410 core +layout(points, invocations = 16) in; +layout(triangle_strip, max_vertices = 4) out; +void main() { + gl_ViewportIndex = gl_InvocationID; + gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); EmitVertex(); + EndPrimitive(); +} +)"; + + // Same stage, same shape, writing gl_Layer INSTEAD. Layered rendering and viewport routing + // are different features and the detector must not confuse them: a Minecraft-style cubemap + // pass writes gl_Layer and must keep the one-viewport pipeline. + const char* const kGeometryWritesLayerOnly = R"(#version 410 core +layout(points, invocations = 6) in; +layout(triangle_strip, max_vertices = 4) out; +void main() { + gl_Layer = gl_InvocationID; + gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); EmitVertex(); + EndPrimitive(); +} +)"; + + const char* const kPlainGeometry = R"(#version 410 core +layout(points, invocations = 1) in; +layout(triangle_strip, max_vertices = 4) out; +void main() { + gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); EmitVertex(); + EndPrimitive(); +} +)"; + + const char* const kPlainVertex = R"(#version 410 core +void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); } +)"; + + const char* const kPlainFragment = R"(#version 410 core +layout(location = 0) out vec4 fragColor; +void main() { fragColor = vec4(1.0); } +)"; + + TEST_F(ViewportIndexReflectionTest, TrueForAGeometryShaderThatAssignsViewportIndex) { + const ReflectModule module(CompileToSpirv(GL_GEOMETRY_SHADER, kGeometryWritesViewportIndex)); + ASSERT_TRUE(module.Created()); + EXPECT_TRUE(ProgramFactory::ReflectedWritesViewportIndexBuiltin(module.Get())) + << "a shader that assigns gl_ViewportIndex must get a multi-viewport pipeline; missing it is what " + "collapses every index onto viewport 0"; + } + + TEST_F(ViewportIndexReflectionTest, FalseForAGeometryShaderThatOnlyAssignsLayer) { + const ReflectModule module(CompileToSpirv(GL_GEOMETRY_SHADER, kGeometryWritesLayerOnly)); + ASSERT_TRUE(module.Created()); + EXPECT_FALSE(ProgramFactory::ReflectedWritesViewportIndexBuiltin(module.Get())) + << "gl_Layer is layered rendering, not viewport routing; widening viewportCount for it costs the " + "single-viewport fast path for nothing"; + } + + TEST_F(ViewportIndexReflectionTest, FalseForAPlainGeometryShader) { + const ReflectModule module(CompileToSpirv(GL_GEOMETRY_SHADER, kPlainGeometry)); + ASSERT_TRUE(module.Created()); + EXPECT_FALSE(ProgramFactory::ReflectedWritesViewportIndexBuiltin(module.Get())); + } + + TEST_F(ViewportIndexReflectionTest, FalseForTheOrdinaryVertexAndFragmentStages) { + // The shape every real application ships: neither stage may widen the pipeline. + const ReflectModule vertexModule(CompileToSpirv(GL_VERTEX_SHADER, kPlainVertex)); + ASSERT_TRUE(vertexModule.Created()); + EXPECT_FALSE(ProgramFactory::ReflectedWritesViewportIndexBuiltin(vertexModule.Get())); + + const ReflectModule fragmentModule(CompileToSpirv(GL_FRAGMENT_SHADER, kPlainFragment)); + ASSERT_TRUE(fragmentModule.Created()); + EXPECT_FALSE(ProgramFactory::ReflectedWritesViewportIndexBuiltin(fragmentModule.Get())); + } + + TEST_F(ViewportIndexReflectionTest, FalseForAnEmptyModuleWithoutDereferencing) { + // A default-constructed module has no entry points. The scan runs on every link, so it + // must survive a reflection that never got built rather than walk a null array. + SpvReflectShaderModule emptyModule{}; + EXPECT_FALSE(ProgramFactory::ReflectedWritesViewportIndexBuiltin(emptyModule)); + } + +} // namespace