diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index a489a7de..8c247a38 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -1600,7 +1600,7 @@ namespace MobileGL::MG_Backend::DirectGLES { !g_hasSyncedRenderState || std::memcmp(currentBytes + kBlendSpanEnd, syncedBytes + kBlendSpanEnd, sizeof(RenderStateParameters) - kBlendSpanEnd) != 0; - IntVec4 backendViewport = parameters.Viewport; + IntVec4 backendViewport = MG_State::pGLContext->GetViewport(); if (backendViewport.z() <= 0 || backendViewport.w() <= 0) { Int surfaceWidth = 0; Int surfaceHeight = 0; @@ -1614,7 +1614,8 @@ namespace MobileGL::MG_Backend::DirectGLES { g_syncedBackendViewport = backendViewport; } - // All 12 capability bools live after LogicOp in the struct, i.e. in the tail span. + // Every capability bool (and the scissor-test mask below) lives after LogicOp in the + // struct, i.e. in the tail span. if (tailSpanDirty) { #define SYNC_CAPABILITY(cap_mg, cap_gl) \ if (forceFullPush || parameters.cap_mg##Enabled != g_syncedRenderStateParameters.cap_mg##Enabled) { \ @@ -1633,11 +1634,26 @@ namespace MobileGL::MG_Backend::DirectGLES { SYNC_CAPABILITY(SampleMask, GL_SAMPLE_MASK); SYNC_CAPABILITY(PolygonOffsetFill, GL_POLYGON_OFFSET_FILL); SYNC_CAPABILITY(RasterizerDiscard, GL_RASTERIZER_DISCARD); - SYNC_CAPABILITY(ScissorTest, GL_SCISSOR_TEST); SYNC_CAPABILITY(StencilTest, GL_STENCIL_TEST); SYNC_CAPABILITY(CullFace, GL_CULL_FACE); #undef SYNC_CAPABILITY + + // GL_SCISSOR_TEST is per-viewport enable state (ARB_viewport_array), so it is a + // 16-bit mask and not a "Enabled" bool the macro above could key off. ES + // has exactly one scissor rectangle and one scissor enable, so only bit 0 - the + // index every ES draw rasterizes against - can be forwarded; a program that + // enables the test for viewport 3 alone gets viewport 0's answer here. That is + // the same limitation as the unemulated gl_ViewportIndex on this backend and is + // why the multi-viewport half of KHR-GL43.viewport_array stays red on Espryt. + { + const Bool scissorTest = (parameters.ScissorTestEnabledMask & 1u) != 0; + const Bool syncedScissorTest = + (g_syncedRenderStateParameters.ScissorTestEnabledMask & 1u) != 0; + if (forceFullPush || scissorTest != syncedScissorTest) { + scissorTest ? g_GLESFuncs.glEnable(GL_SCISSOR_TEST) : g_GLESFuncs.glDisable(GL_SCISSOR_TEST); + } + } } if (tailSpanDirty && g_GLESCapabilities.SupportsClipDistance) { @@ -1864,8 +1880,8 @@ namespace MobileGL::MG_Backend::DirectGLES { if (forceFullPush || parameters.DepthMask != g_syncedRenderStateParameters.DepthMask) { g_GLESFuncs.glDepthMask(parameters.DepthMask ? GL_TRUE : GL_FALSE); } - if (forceFullPush || parameters.DepthRange != g_syncedRenderStateParameters.DepthRange) { - g_GLESFuncs.glDepthRangef(parameters.DepthRange.x(), parameters.DepthRange.y()); + if (forceFullPush || parameters.DepthRanges[0] != g_syncedRenderStateParameters.DepthRanges[0]) { + g_GLESFuncs.glDepthRangef(parameters.DepthRanges[0].x(), parameters.DepthRanges[0].y()); } } @@ -2003,7 +2019,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // everything drawn with GL_SCISSOR_TEST enabled before the app's first glScissor // is clipped away - Minecraft 26.2 keeps only its unscissored sky and hand and // loses the terrain and the whole GUI. - IntVec4 backendScissorBox = parameters.ScissorBox; + IntVec4 backendScissorBox = parameters.ScissorBoxes[0]; if (backendScissorBox.z() <= 0 || backendScissorBox.w() <= 0) { Int surfaceWidth = 0; Int surfaceHeight = 0; @@ -4762,7 +4778,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // restores the app state on exit, tracked via the render-state shadow. class ScopedScissorDisable { public: - ScopedScissorDisable() : m_wasEnabled(RenderStateImpl::g_syncedRenderStateParameters.ScissorTestEnabled) { + ScopedScissorDisable() + : m_wasEnabled((RenderStateImpl::g_syncedRenderStateParameters.ScissorTestEnabledMask & 1u) != 0) { if (m_wasEnabled) g_GLESFuncs.glDisable(GL_SCISSOR_TEST); } ~ScopedScissorDisable() { 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 c6f8caf3..432a81b0 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -335,19 +335,19 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Complete input inventory of ApplyDynamicDrawStateTail, one line per reader // (each accessor it replaces is a verified plain field read of the same // RenderStateParameters field - RenderState.cpp): - // ApplyGLViewportState : Viewport, DepthRange, + extent/isDefaultFbo/preTransform + // ApplyGLViewportState : Viewports[0], DepthRanges[0], + extent/isDefaultFbo/preTransform // ApplyBlendConstants : BlendColor // ApplyPolygonOffsetState : PolygonOffsetUnits, PolygonOffsetFactor // ApplyLineWidthState : LineWidth (see the caveat below) // ApplyStencilState : StencilStates[0..1].{ValueMask, WriteMask, Ref} - // scissor rect : ScissorTestEnabled, ScissorBox, + // scissor rect : ScissorTestEnabledMask bit 0, ScissorBoxes[0], // + extent/isDefaultFbo/preTransform // Caveat, unchanged from the version-only gate: ApplyLineWidthState also clamps // to the ACTIVE BACKEND OBJECT's aliased line-width range. Those are device // limits queried once at backend init and constant for the renderer's lifetime, // so they are not part of the key (the version gate never covered them either). struct DynamicTailKey { - Int viewport[4] = {0, 0, 0, 0}; + Float viewport[4] = {0.0f, 0.0f, 0.0f, 0.0f}; Float depthRange[2] = {0.0f, 0.0f}; Float blendColor[4] = {0.0f, 0.0f, 0.0f, 0.0f}; Float polygonOffsetFactor = 0.0f; @@ -437,12 +437,30 @@ 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) { + // Snapped to integers. The viewport is float STATE (glViewportIndexedf may set a + // fractional origin, and GetFloati_v hands it back verbatim), but what rasterizes here is + // the rounded rectangle - a deliberate, documented infidelity rather than a spec claim: + // MobileGL passes the driver's VIEWPORT_SUBPIXEL_BITS through, so it does advertise + // subpixel viewport precision it does not deliver. Nothing in KHR-GL43.viewport_array or + // in Minecraft sets a fractional viewport (the conformance checks are all on the state + // round trip), which is why the honest-but-lossy path was kept over widening every + // default-framebuffer Y-flip/pre-transform helper to floats. See the KNOWN INFIDELITY + // note in MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp. + 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 +495,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 +4905,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 +5348,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. @@ -5342,12 +5431,14 @@ void main() { DynamicStateShadow::DynamicTailKey key; { const RenderStateParameters& p = MG_State::pGLContext->GetRenderStateParameters(); - key.viewport[0] = p.Viewport.x(); - key.viewport[1] = p.Viewport.y(); - key.viewport[2] = p.Viewport.z(); - key.viewport[3] = p.Viewport.w(); - key.depthRange[0] = p.DepthRange.x(); - key.depthRange[1] = p.DepthRange.y(); + // Viewport 0 and its depth range: ApplyGLViewportState reads exactly those two + // (per-index state for indices > 0 is keyed separately, see multiViewportKey below). + key.viewport[0] = p.Viewports[0].x(); + key.viewport[1] = p.Viewports[0].y(); + key.viewport[2] = p.Viewports[0].z(); + key.viewport[3] = p.Viewports[0].w(); + key.depthRange[0] = p.DepthRanges[0].x(); + key.depthRange[1] = p.DepthRanges[0].y(); key.blendColor[0] = p.BlendColor.x(); key.blendColor[1] = p.BlendColor.y(); key.blendColor[2] = p.BlendColor.z(); @@ -5362,11 +5453,11 @@ void main() { key.stencilWriteMask[face] = p.StencilStates[face].WriteMask; key.stencilRef[face] = p.StencilStates[face].Ref; } - key.scissorEnabled = p.ScissorTestEnabled; - key.scissorBox[0] = p.ScissorBox.x(); - key.scissorBox[1] = p.ScissorBox.y(); - key.scissorBox[2] = p.ScissorBox.z(); - key.scissorBox[3] = p.ScissorBox.w(); + key.scissorEnabled = (p.ScissorTestEnabledMask & 1u) != 0; + key.scissorBox[0] = p.ScissorBoxes[0].x(); + key.scissorBox[1] = p.ScissorBoxes[0].y(); + key.scissorBox[2] = p.ScissorBoxes[0].z(); + key.scissorBox[3] = p.ScissorBoxes[0].w(); key.extentX = extent.x(); key.extentY = extent.y(); key.preTransform = static_cast(preTransform); @@ -5759,7 +5850,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; } @@ -6199,7 +6290,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). @@ -6218,6 +6310,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); @@ -12214,6 +12307,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_Impl/GLImpl/Exporting/Definitions.cpp b/MobileGL/MG_Impl/GLImpl/Exporting/Definitions.cpp index 06434bde..7a15d937 100644 --- a/MobileGL/MG_Impl/GLImpl/Exporting/Definitions.cpp +++ b/MobileGL/MG_Impl/GLImpl/Exporting/Definitions.cpp @@ -969,14 +969,14 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, VertexAttribL3dv, GLuint index, const GLdoub DECLARE_GL_FUNCTION_STUB_HEAD(void, VertexAttribL4dv, GLuint index, const GLdouble* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, VertexAttribL4dv, index, v) DECLARE_GL_FUNCTION_STUB_HEAD(void, VertexAttribLPointer, GLuint index, GLint size, GLenum type, GLsizei stride, const void* pointer) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, VertexAttribLPointer, index, size, type, stride, pointer) DECLARE_GL_FUNCTION_STUB_HEAD(void, GetVertexAttribLdv, GLuint index, GLenum pname, GLdouble* params) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetVertexAttribLdv, index, pname, params) -DECLARE_GL_FUNCTION_STUB_HEAD(void, ViewportArrayv, GLuint first, GLsizei count, const GLfloat* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ViewportArrayv, first, count, v) -DECLARE_GL_FUNCTION_STUB_HEAD(void, ViewportIndexedf, GLuint index, GLfloat x, GLfloat y, GLfloat w, GLfloat h) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ViewportIndexedf, index, x, y, w, h) -DECLARE_GL_FUNCTION_STUB_HEAD(void, ViewportIndexedfv, GLuint index, const GLfloat* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ViewportIndexedfv, index, v) -DECLARE_GL_FUNCTION_STUB_HEAD(void, ScissorArrayv, GLuint first, GLsizei count, const GLint* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ScissorArrayv, first, count, v) -DECLARE_GL_FUNCTION_STUB_HEAD(void, ScissorIndexed, GLuint index, GLint left, GLint bottom, GLsizei width, GLsizei height) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ScissorIndexed, index, left, bottom, width, height) -DECLARE_GL_FUNCTION_STUB_HEAD(void, ScissorIndexedv, GLuint index, const GLint* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ScissorIndexedv, index, v) -DECLARE_GL_FUNCTION_STUB_HEAD(void, DepthRangeArrayv, GLuint first, GLsizei count, const GLdouble* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DepthRangeArrayv, first, count, v) -DECLARE_GL_FUNCTION_STUB_HEAD(void, DepthRangeIndexed, GLuint index, GLdouble n, GLdouble f) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DepthRangeIndexed, index, n, f) +DECLARE_GL_FUNCTION_HEAD(void, ViewportArrayv, GLuint first, GLsizei count, const GLfloat* v) DECLARE_GL_FUNCTION_END_NO_RETURN(void, ViewportArrayv, first, count, v) +DECLARE_GL_FUNCTION_HEAD(void, ViewportIndexedf, GLuint index, GLfloat x, GLfloat y, GLfloat w, GLfloat h) DECLARE_GL_FUNCTION_END_NO_RETURN(void, ViewportIndexedf, index, x, y, w, h) +DECLARE_GL_FUNCTION_HEAD(void, ViewportIndexedfv, GLuint index, const GLfloat* v) DECLARE_GL_FUNCTION_END_NO_RETURN(void, ViewportIndexedfv, index, v) +DECLARE_GL_FUNCTION_HEAD(void, ScissorArrayv, GLuint first, GLsizei count, const GLint* v) DECLARE_GL_FUNCTION_END_NO_RETURN(void, ScissorArrayv, first, count, v) +DECLARE_GL_FUNCTION_HEAD(void, ScissorIndexed, GLuint index, GLint left, GLint bottom, GLsizei width, GLsizei height) DECLARE_GL_FUNCTION_END_NO_RETURN(void, ScissorIndexed, index, left, bottom, width, height) +DECLARE_GL_FUNCTION_HEAD(void, ScissorIndexedv, GLuint index, const GLint* v) DECLARE_GL_FUNCTION_END_NO_RETURN(void, ScissorIndexedv, index, v) +DECLARE_GL_FUNCTION_HEAD(void, DepthRangeArrayv, GLuint first, GLsizei count, const GLdouble* v) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DepthRangeArrayv, first, count, v) +DECLARE_GL_FUNCTION_HEAD(void, DepthRangeIndexed, GLuint index, GLdouble n, GLdouble f) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DepthRangeIndexed, index, n, f) DECLARE_GL_FUNCTION_HEAD(void, GetFloati_v, GLenum target, GLuint index, GLfloat* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, GetFloati_v, target, index, data) DECLARE_GL_FUNCTION_HEAD(void, GetDoublei_v, GLenum target, GLuint index, GLdouble* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, GetDoublei_v, target, index, data) DECLARE_GL_FUNCTION_HEAD(void, DrawArraysInstancedBaseInstance, GLenum mode, GLint first, GLsizei count, GLsizei instancecount, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawArraysInstancedBaseInstance, mode, first, count, instancecount, baseinstance) diff --git a/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp b/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp index 9d6baf93..893f4909 100644 --- a/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp +++ b/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,11 @@ #include namespace MobileGL::MG_Impl::GLImpl { + // Declared rather than #included from GL_RenderState.h on purpose: that header also declares + // a free function named BlendEquation, which would hide the ::MobileGL::BlendEquation enum + // this file's blend-state queries name unqualified. + GLboolean IsEnabledi(GLenum target, GLuint index); + namespace { enum class IndexedBufferQueryKind { Binding, @@ -339,26 +345,70 @@ namespace MobileGL::MG_Impl::GLImpl { return sampler ? static_cast(sampler->GetExternalIndex()) : 0; } - // The ARB_viewport_array indexed rectangles. MobileGL keeps exactly one viewport, one - // scissor box and one depth range, so every in-range index answers with that single - // value - but it has to come from the frontend state the non-indexed getters read. - // The generic path at the bottom of GetIntegeri_v is a raw backend passthrough that - // has no case for these, so routing them through it returned zeros. + // The ARB_viewport_array indexed rectangles. Each of these is genuinely per-viewport + // frontend state (RenderStateParameters::Viewports / ScissorBoxes / DepthRanges), so the + // indexed getters must read the indexed storage - the generic path at the bottom of + // GetIntegeri_v is a raw backend passthrough that has no case for them and returned + // zeros, and routing them to the NON-indexed getter (what this used to do) answered every + // index with viewport 0's value, which is what + // KHR-GL43.viewport_array.{viewport,scissor,depth_range}_api caught. Bool IsIndexedViewportQuery(GLenum target) { return target == GL_VIEWPORT || target == GL_SCISSOR_BOX || target == GL_DEPTH_RANGE; } - // ARB_viewport_array: `index` selects a viewport and MAX_VIEWPORTS bounds it. + // Component count of an indexed viewport-array query, so every width of getter writes the + // caller's whole buffer instead of just element 0 (GL 4.6 core 22.1). + GLsizei IndexedViewportQueryComponents(GLenum target) { + return target == GL_DEPTH_RANGE ? 2 : 4; + } + + // ARB_viewport_array: `index` selects a viewport and MAX_VIEWPORTS bounds it. The bound is + // the frontend's own state width, which is also exactly what GL_MAX_VIEWPORTS reports - + // taking it from the backend caps instead would let a device limit of 1 (a Vulkan device + // without the multiViewport feature) make index 1 illegal even though the state exists. Bool ValidateViewportQueryIndex(GLuint index, const char* caller) { - GLint maxViewports = 0; - GetIntegerv(GL_MAX_VIEWPORTS, &maxViewports); - if (index < static_cast(std::max(maxViewports, 1))) return true; + if (index < RenderStateParameters::MAX_VIEWPORTS) return true; MG_State::pGLContext->RecordError( ErrorCode::InvalidValue, MakeUnique("MG_Impl/GLImpl", caller, "Viewport index is out of range.")); return false; } + // The indexed viewport/scissor/depth-range state as floats, which is the widest lossless + // shape MobileGL stores (the viewport really is float state; the scissor box is integral + // and well inside float's exact range, and every depth range is in [0, 1]). Every indexed + // getter width funnels through this so they can never disagree with each other. + void ReadIndexedViewportStateFloat(GLenum target, GLuint index, GLfloat* out) { + switch (target) { + case GL_VIEWPORT: { + const FloatVec4& viewport = MG_State::pGLContext->GetViewportIndexed(index); + out[0] = viewport.x(); + out[1] = viewport.y(); + out[2] = viewport.z(); + out[3] = viewport.w(); + return; + } + case GL_SCISSOR_BOX: { + const IntVec4& box = MG_State::pGLContext->GetScissorBoxIndexed(index); + out[0] = static_cast(box.x()); + out[1] = static_cast(box.y()); + out[2] = static_cast(box.z()); + out[3] = static_cast(box.w()); + return; + } + case GL_DEPTH_RANGE: { + const FloatVec2& range = MG_State::pGLContext->GetDepthRangeIndexed(index); + out[0] = range.x(); + out[1] = range.y(); + return; + } + default: + MOBILEGL_ASSERT(false, "ReadIndexedViewportStateFloat: unexpected target 0x%x", + static_cast(target)); + return; + } + } + void CopyIntsToBooleans(const GLint* src, SizeT count, GLboolean* dst) { for (SizeT i = 0; i < count; ++i) { dst[i] = src[i] ? GL_TRUE : GL_FALSE; @@ -629,6 +679,17 @@ namespace MobileGL::MG_Impl::GLImpl { params[1] = dynamicParameters.ViewportBoundsRangeMax; return; } + // Viewport 0's rectangle, verbatim. Falling through to the integer width below would + // round the fractional rectangle a glViewportIndexedf(0, ...) is allowed to set, and + // glGetFloatv(GL_VIEWPORT) is a lossless query of float state. + case GL_VIEWPORT: { + const FloatVec4& viewport = MG_State::pGLContext->GetViewportIndexed(0); + params[0] = viewport.x(); + params[1] = viewport.y(); + params[2] = viewport.z(); + params[3] = viewport.w(); + return; + } case GL_MIN_FRAGMENT_INTERPOLATION_OFFSET: case GL_MAX_FRAGMENT_INTERPOLATION_OFFSET: case GL_FRAGMENT_INTERPOLATION_OFFSET_BITS: { @@ -792,15 +853,32 @@ namespace MobileGL::MG_Impl::GLImpl { return; } + // GL 4.6 core 22.1: an indexed query answers EVERY indexed state, and GL_SCISSOR_TEST is + // indexed by viewport just like GL_BLEND is by draw buffer. Without this the integer + // width fell through to the backend passthrough and answered GL_INVALID_ENUM, which is + // the sticky error KHR-GL43.viewport_array.queries trips over at its next error check. + if (MG_Util::ConvertGLEnumToCapabilityInput(target) != CapabilityInput::Unknown) { + *data = IsEnabledi(target, index); + return; + } + switch (target) { // ARB_viewport_array queries the indexed rectangles through glGetIntegeri_v as well - // (gl4cMultiBindTests and the viewport_array group both do). The frontend keeps one - // viewport and one scissor box, so every in-range index reports that one. + // (gl4cMultiBindTests and the viewport_array group both do). case GL_VIEWPORT: case GL_SCISSOR_BOX: + case GL_DEPTH_RANGE: { if (!ValidateViewportQueryIndex(index, __func__)) return; - GetIntegerv(target, data); + GLfloat values[4] = {}; + ReadIndexedViewportStateFloat(target, index, values); + const GLsizei components = IndexedViewportQueryComponents(target); + for (GLsizei i = 0; i < components; ++i) { + // Round, not truncate: glGetIntegerv on floating-point state rounds to nearest + // (GL 4.6 core 22.2), so a 255.875-wide viewport reads back as 256 and not 255. + data[i] = static_cast(std::lround(values[i])); + } return; + } // The vertex buffer binding points of the vertex array object that is bound. Indexed by // binding point, not by attribute (GL 4.6 core 10.3.1). case GL_VERTEX_BINDING_BUFFER: @@ -927,7 +1005,10 @@ namespace MobileGL::MG_Impl::GLImpl { } if (IsIndexedViewportQuery(target)) { if (!ValidateViewportQueryIndex(index, __func__)) return; - GetFloatv(target, data); + // Verbatim, NOT via the integer width: the viewport is float state and + // KHR-GL43.viewport_array.viewport_api compares the read-back with ==, so a + // glViewportIndexedf(i, 0.125f, ...) has to come back as 0.125f exactly. + ReadIndexedViewportStateFloat(target, index, data); return; } GLint ints[4] = {}; @@ -944,7 +1025,12 @@ namespace MobileGL::MG_Impl::GLImpl { } if (IsIndexedViewportQuery(target)) { if (!ValidateViewportQueryIndex(index, __func__)) return; - GetDoublev(target, data); + GLfloat values[4] = {}; + ReadIndexedViewportStateFloat(target, index, values); + const GLsizei components = IndexedViewportQueryComponents(target); + for (GLsizei i = 0; i < components; ++i) { + data[i] = static_cast(values[i]); + } return; } GLint ints[4] = {}; @@ -1020,7 +1106,12 @@ namespace MobileGL::MG_Impl::GLImpl { // frontend-only value simply is not in the driver's table. GLint values[4] = {}; GetIntegeri_v(target, index, values); - *data = static_cast(values[0]); + // The viewport-array rectangles are the only multi-component indexed state here; every + // other pname is scalar, so widening element 0 alone would silently truncate them. + const GLsizei components = IsIndexedViewportQuery(target) ? IndexedViewportQueryComponents(target) : 1; + for (GLsizei i = 0; i < components; ++i) { + data[i] = static_cast(values[i]); + } } void GetInteger64v(GLenum pname, GLint64* params) { @@ -2192,7 +2283,15 @@ namespace MobileGL::MG_Impl::GLImpl { params[1] = dynamicParameters.MaxViewportHeight; break; case GL_MAX_VIEWPORTS: - *params = dynamicParameters.MaxViewports; + // The frontend's own state width, not the backend's device limit. GL 4.3 core + // requires MAX_VIEWPORTS >= 16 and every indexed viewport entry point validates + // against RenderStateParameters::MAX_VIEWPORTS, so reporting anything else would + // either advertise viewports the state cannot hold or reject indices it can. A + // Vulkan device without the multiViewport feature reports maxViewports == 1, which + // limits what can be RASTERIZED to more than one rectangle (see the multiViewport + // gate in VulkanRenderer), not what the GL state can hold; caps.MaxViewports keeps + // carrying that device number for exactly that decision. + *params = static_cast(RenderStateParameters::MAX_VIEWPORTS); break; case GL_MINOR_VERSION: *params = rendererInfo.RendererGLInfo.TargetGLVersion.Minor; diff --git a/MobileGL/MG_Impl/GLImpl/RenderState/GL_RenderState.cpp b/MobileGL/MG_Impl/GLImpl/RenderState/GL_RenderState.cpp index 14697130..6ccf39bf 100644 --- a/MobileGL/MG_Impl/GLImpl/RenderState/GL_RenderState.cpp +++ b/MobileGL/MG_Impl/GLImpl/RenderState/GL_RenderState.cpp @@ -20,28 +20,118 @@ namespace MobileGL::MG_Impl::GLImpl { return std::clamp(static_cast(value), 0.0f, 1.0f); } - static Bool ValidateIndexedBlendCapability(GLenum target, GLuint index, const char* functionName) { - if (target != GL_BLEND) { + // GL 4.6 core 17.3.2 and 22.1 give exactly two indexed capabilities: GL_BLEND, indexed by + // draw buffer, and GL_SCISSOR_TEST, indexed by viewport. They have DIFFERENT bounds + // (MAX_DRAW_BUFFERS vs MAX_VIEWPORTS), so the limit is picked per target rather than shared. + static Bool ValidateIndexedCapability(GLenum target, GLuint index, const char* functionName) { + GLuint limit = 0; + const char* indexName = nullptr; + switch (target) { + case GL_BLEND: + limit = MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS; + indexName = "Buffer"; + break; + case GL_SCISSOR_TEST: + limit = RenderStateParameters::MAX_VIEWPORTS; + indexName = "Viewport"; + break; + default: MG_State::pGLContext->RecordError( ErrorCode::InvalidEnum, MakeUnique("MG_Impl/GLImpl", functionName, - "Only GL_BLEND is supported for indexed capability state.")); + "Only GL_BLEND and GL_SCISSOR_TEST are supported for indexed " + "capability state.")); return false; } - if (index >= MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS) { + if (index >= limit) { MG_State::pGLContext->RecordError( ErrorCode::InvalidValue, - MakeUnique( - "MG_Impl/GLImpl", functionName, - "Buffer index " + std::to_string(index) + " is out of range. Max supported is " + - std::to_string(MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS - 1) + ".")); + MakeUnique("MG_Impl/GLImpl", functionName, + String(indexName) + " index " + std::to_string(index) + + " is out of range. Max supported is " + std::to_string(limit - 1) + + ".")); return false; } return true; } + // ------------------ ARB_viewport_array parameter validation ------------------ + // All three families share the same two shapes, so they share the two checkers. GL 4.6 core + // 13.6.1/17.3.2: an out-of-range index is GL_INVALID_VALUE, and so is a negative width or + // height. `first + count == MAX_VIEWPORTS` is LEGAL - only strictly greater is an error, + // which KHR-GL43.viewport_array.api_errors checks explicitly in both directions. + static Bool ValidateViewportIndex(GLuint index, const char* functionName) { + if (index < RenderStateParameters::MAX_VIEWPORTS) return true; + + MG_State::pGLContext->RecordError( + ErrorCode::InvalidValue, + MakeUnique("MG_Impl/GLImpl", functionName, + "Viewport index " + std::to_string(index) + + " is out of range. Max supported is " + + std::to_string(RenderStateParameters::MAX_VIEWPORTS - 1) + ".")); + return false; + } + + static Bool ValidateViewportRange(GLuint first, GLsizei count, const char* functionName) { + if (count < 0) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidValue, + MakeUnique("MG_Impl/GLImpl", functionName, "count must not be negative.")); + return false; + } + // Widened before adding: first is a GLuint and count a GLsizei, so `first + count` in + // 32 bits can wrap past MAX_VIEWPORTS and let an out-of-range range through. + const Uint64 last = static_cast(first) + static_cast(count); + if (last > RenderStateParameters::MAX_VIEWPORTS) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidValue, + MakeUnique("MG_Impl/GLImpl", functionName, + "first (" + std::to_string(first) + ") + count (" + + std::to_string(count) + ") exceeds GL_MAX_VIEWPORTS (" + + std::to_string(RenderStateParameters::MAX_VIEWPORTS) + ").")); + return false; + } + return true; + } + + template + static Bool ValidateNonNegativeExtent(T width, T height, const char* functionName) { + if (width >= T(0) && height >= T(0)) return true; + + MG_State::pGLContext->RecordError( + ErrorCode::InvalidValue, + MakeUnique("MG_Impl/GLImpl", functionName, "Width and height must be non-negative.")); + return false; + } + + // The array forms are all-or-nothing: one bad element rejects the whole call with a SINGLE + // GL_INVALID_VALUE and leaves every rectangle untouched. api_errors relies on both halves - + // it passes a full 16-element array with exactly one negative extent and then asserts the + // error queue holds exactly one entry. + template + static Bool ValidateArrayExtents(GLsizei count, const T* v, const char* functionName) { + for (GLsizei i = 0; i < count; ++i) { + if (v[i * 4 + 2] >= T(0) && v[i * 4 + 3] >= T(0)) continue; + MG_State::pGLContext->RecordError( + ErrorCode::InvalidValue, + MakeUnique("MG_Impl/GLImpl", functionName, + "Width and height must be non-negative (element " + std::to_string(i) + + ").")); + return false; + } + return true; + } + + static Bool ValidateNonNullArray(const void* v, const char* functionName) { + if (v != nullptr) return true; + MG_State::pGLContext->RecordError( + ErrorCode::InvalidValue, + MakeUnique("MG_Impl/GLImpl", functionName, "value pointer cannot be null.")); + return false; + } + static Bool TryConvertBlendEquation(GLenum mode, const char* functionName, ::MobileGL::BlendEquation& outEquation) { outEquation = MG_Util::ConvertGLEnumToBlendEquation(mode); @@ -93,16 +183,70 @@ namespace MobileGL::MG_Impl::GLImpl { } void Viewport_State(GLint x, GLint y, GLsizei width, GLsizei height) { - if (width < 0 || height < 0) { - MG_State::pGLContext->RecordError(ErrorCode::InvalidValue, - MakeUnique("MG_Impl/GLImpl", "Viewport_State", - "Width abd height must be non-negative.")); - return; - } + if (!ValidateNonNegativeExtent(width, height, "Viewport_State")) return; MG_State::pGLContext->SetViewport(IntVec4(x, y, width, height)); } + // ------------------ ARB_viewport_array setters ------------------ + void ViewportArrayv_State(GLuint first, GLsizei count, const GLfloat* v) { + if (!ValidateViewportRange(first, count, "ViewportArrayv_State")) return; + if (count == 0) return; + if (!ValidateNonNullArray(v, "ViewportArrayv_State")) return; + if (!ValidateArrayExtents(count, v, "ViewportArrayv_State")) return; + + for (GLsizei i = 0; i < count; ++i) { + MG_State::pGLContext->SetViewportIndexed(first + static_cast(i), + FloatVec4(v[i * 4 + 0], v[i * 4 + 1], v[i * 4 + 2], v[i * 4 + 3])); + } + } + + void ViewportIndexedf_State(GLuint index, GLfloat x, GLfloat y, GLfloat w, GLfloat h) { + if (!ValidateViewportIndex(index, "ViewportIndexedf_State")) return; + if (!ValidateNonNegativeExtent(w, h, "ViewportIndexedf_State")) return; + + MG_State::pGLContext->SetViewportIndexed(index, FloatVec4(x, y, w, h)); + } + + void ScissorArrayv_State(GLuint first, GLsizei count, const GLint* v) { + if (!ValidateViewportRange(first, count, "ScissorArrayv_State")) return; + if (count == 0) return; + if (!ValidateNonNullArray(v, "ScissorArrayv_State")) return; + if (!ValidateArrayExtents(count, v, "ScissorArrayv_State")) return; + + for (GLsizei i = 0; i < count; ++i) { + MG_State::pGLContext->SetScissorBoxIndexed(first + static_cast(i), + IntVec4(v[i * 4 + 0], v[i * 4 + 1], v[i * 4 + 2], v[i * 4 + 3])); + } + } + + void ScissorIndexed_State(GLuint index, GLint left, GLint bottom, GLsizei width, GLsizei height) { + if (!ValidateViewportIndex(index, "ScissorIndexed_State")) return; + if (!ValidateNonNegativeExtent(width, height, "ScissorIndexed_State")) return; + + MG_State::pGLContext->SetScissorBoxIndexed(index, IntVec4(left, bottom, width, height)); + } + + void DepthRangeArrayv_State(GLuint first, GLsizei count, const GLdouble* v) { + if (!ValidateViewportRange(first, count, "DepthRangeArrayv_State")) return; + if (count == 0) return; + if (!ValidateNonNullArray(v, "DepthRangeArrayv_State")) return; + + for (GLsizei i = 0; i < count; ++i) { + MG_State::pGLContext->SetDepthRangeIndexed( + first + static_cast(i), + FloatVec2(ClampUnitFloat(static_cast(v[i * 2 + 0])), + ClampUnitFloat(static_cast(v[i * 2 + 1])))); + } + } + + void DepthRangeIndexed_State(GLuint index, GLdouble n, GLdouble f) { + if (!ValidateViewportIndex(index, "DepthRangeIndexed_State")) return; + + MG_State::pGLContext->SetDepthRangeIndexed( + index, FloatVec2(ClampUnitFloat(static_cast(n)), ClampUnitFloat(static_cast(f)))); + } + void StencilOpSeparate_State(GLenum face, GLenum sfail, GLenum dpfail, GLenum dppass) { Bool applyFront = false; Bool applyBack = false; @@ -175,12 +319,7 @@ namespace MobileGL::MG_Impl::GLImpl { } void Scissor_State(GLint x, GLint y, GLsizei width, GLsizei height) { - if (width < 0 || height < 0) { - MG_State::pGLContext->RecordError(ErrorCode::InvalidValue, - MakeUnique("MG_Impl/GLImpl", "Scissor_State", - "Width abd height must be non-negative.")); - return; - } + if (!ValidateNonNegativeExtent(width, height, "Scissor_State")) return; MG_State::pGLContext->SetScissorBox(IntVec4(x, y, width, height)); } @@ -336,7 +475,7 @@ namespace MobileGL::MG_Impl::GLImpl { } GLboolean IsEnabledi_State(GLenum target, GLuint index) { - if (!ValidateIndexedBlendCapability(target, index, "IsEnabledi_State")) { + if (!ValidateIndexedCapability(target, index, "IsEnabledi_State")) { return GL_FALSE; } @@ -392,7 +531,14 @@ namespace MobileGL::MG_Impl::GLImpl { } GLint values[4] = {}; GetIntegeri_v(target, index, values); - *data = values[0] != 0 ? GL_TRUE : GL_FALSE; + // The ARB_viewport_array rectangles are the only multi-component indexed state that + // reaches here; writing element 0 alone would leave the caller's other three untouched. + const GLsizei components = target == GL_VIEWPORT || target == GL_SCISSOR_BOX + ? 4 + : (target == GL_DEPTH_RANGE ? 2 : 1); + for (GLsizei i = 0; i < components; ++i) { + data[i] = values[i] != 0 ? GL_TRUE : GL_FALSE; + } } GLboolean IsEnabled_State(GLenum cap) { @@ -725,7 +871,7 @@ namespace MobileGL::MG_Impl::GLImpl { } void Disablei_State(GLenum target, GLuint index) { - if (!ValidateIndexedBlendCapability(target, index, "Disablei_State")) { + if (!ValidateIndexedCapability(target, index, "Disablei_State")) { return; } @@ -743,7 +889,7 @@ namespace MobileGL::MG_Impl::GLImpl { } void Enablei_State(GLenum target, GLuint index) { - if (!ValidateIndexedBlendCapability(target, index, "Enablei_State")) { + if (!ValidateIndexedCapability(target, index, "Enablei_State")) { return; } @@ -797,6 +943,44 @@ namespace MobileGL::MG_Impl::GLImpl { Viewport_State(x, y, width, height); } + void ViewportArrayv(GLuint first, GLsizei count, const GLfloat* v) { + ViewportArrayv_State(first, count, v); + } + + void ViewportIndexedf(GLuint index, GLfloat x, GLfloat y, GLfloat w, GLfloat h) { + ViewportIndexedf_State(index, x, y, w, h); + } + + void ViewportIndexedfv(GLuint index, const GLfloat* v) { + // The index is validated before the pointer is touched: glViewportIndexedfv(MAX, nullptr) + // must be one GL_INVALID_VALUE, not a null dereference. + if (!ValidateViewportIndex(index, "ViewportIndexedfv")) return; + if (!ValidateNonNullArray(v, "ViewportIndexedfv")) return; + ViewportIndexedf_State(index, v[0], v[1], v[2], v[3]); + } + + void ScissorArrayv(GLuint first, GLsizei count, const GLint* v) { + ScissorArrayv_State(first, count, v); + } + + void ScissorIndexed(GLuint index, GLint left, GLint bottom, GLsizei width, GLsizei height) { + ScissorIndexed_State(index, left, bottom, width, height); + } + + void ScissorIndexedv(GLuint index, const GLint* v) { + if (!ValidateViewportIndex(index, "ScissorIndexedv")) return; + if (!ValidateNonNullArray(v, "ScissorIndexedv")) return; + ScissorIndexed_State(index, v[0], v[1], v[2], v[3]); + } + + void DepthRangeArrayv(GLuint first, GLsizei count, const GLdouble* v) { + DepthRangeArrayv_State(first, count, v); + } + + void DepthRangeIndexed(GLuint index, GLdouble n, GLdouble f) { + DepthRangeIndexed_State(index, n, f); + } + void StencilOpSeparate(GLenum face, GLenum sfail, GLenum dpfail, GLenum dppass) { StencilOpSeparate_State(face, sfail, dpfail, dppass); } diff --git a/MobileGL/MG_Impl/GLImpl/RenderState/GL_RenderState.h b/MobileGL/MG_Impl/GLImpl/RenderState/GL_RenderState.h index 1fe19f4a..30b668eb 100644 --- a/MobileGL/MG_Impl/GLImpl/RenderState/GL_RenderState.h +++ b/MobileGL/MG_Impl/GLImpl/RenderState/GL_RenderState.h @@ -20,6 +20,16 @@ namespace MobileGL::MG_Impl::GLImpl { void Enablei(GLenum target, GLuint index); void BlendFunc(GLenum sfactor, GLenum dfactor); void Viewport(GLint x, GLint y, GLsizei width, GLsizei height); + // ARB_viewport_array (core since GL 4.1). Every one of these addresses the same 16-element + // indexed state the classic glViewport/glScissor/glDepthRange trio broadcasts to. + void ViewportArrayv(GLuint first, GLsizei count, const GLfloat* v); + void ViewportIndexedf(GLuint index, GLfloat x, GLfloat y, GLfloat w, GLfloat h); + void ViewportIndexedfv(GLuint index, const GLfloat* v); + void ScissorArrayv(GLuint first, GLsizei count, const GLint* v); + void ScissorIndexed(GLuint index, GLint left, GLint bottom, GLsizei width, GLsizei height); + void ScissorIndexedv(GLuint index, const GLint* v); + void DepthRangeArrayv(GLuint first, GLsizei count, const GLdouble* v); + void DepthRangeIndexed(GLuint index, GLdouble n, GLdouble f); void StencilOpSeparate(GLenum face, GLenum sfail, GLenum dpfail, GLenum dppass); void StencilOp(GLenum fail, GLenum zfail, GLenum zpass); void StencilMaskSeparate(GLenum face, GLuint mask); 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/AdvertisedLimitsScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp index 7cbead56..c04b9d17 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp @@ -199,5 +199,61 @@ namespace MGITest { "derived component limits are computed in"; } + // ARB_viewport_array's own limits. They are advertised from three different places - + // GL_MAX_VIEWPORTS from the frontend's indexed state width, the bounds range and the + // subpixel bits from the backend caps table - and each backend fills that table from a + // different source, so all three are checked on both lanes. + // + // GL_VIEWPORT_BOUNDS_RANGE is the one that shipped wrong: GLES has no such query, the + // DirectGLES loader's glGetFloatv(GL_VIEWPORT_BOUNDS_RANGE) therefore raised + // GL_INVALID_ENUM and left the probe's zero-initialized array in place, and MobileGL + // advertised [0, 0] - a range that admits no viewport origin at all, and the check that + // kept KHR-GL43.viewport_array.queries red on Espryt after the indexed-state work. + TEST_F(AdvertisedLimitsScenario, ViewportArrayLimitsMeetTheirGL43Floors) { + GLint maxViewports = -1; + glGetIntegerv(GL_MAX_VIEWPORTS, &maxViewports); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_GE(maxViewports, 16) << "GL 4.3 core table 23.53 sets the MAX_VIEWPORTS minimum at 16"; + EXPECT_LE(maxViewports, 256) << "one viewport rectangle of indexed state is allocated per advertised " + "viewport, and the CTS sizes its arrays off this number"; + + GLfloat boundsRange[2] = {1.0f, -1.0f}; + glGetFloatv(GL_VIEWPORT_BOUNDS_RANGE, boundsRange); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_LE(boundsRange[0], -32768.0f) + << "GL 4.6 core table 23.60 sets the VIEWPORT_BOUNDS_RANGE minimum at [-32768, 32767]; got [" + << boundsRange[0] << ", " << boundsRange[1] << "]"; + EXPECT_GE(boundsRange[1], 32767.0f) + << "GL 4.6 core table 23.60 sets the VIEWPORT_BOUNDS_RANGE minimum at [-32768, 32767]; got [" + << boundsRange[0] << ", " << boundsRange[1] << "]"; + + // KNOWN INFIDELITY, pinned here rather than hidden. MobileGL reports the driver's own + // VIEWPORT_SUBPIXEL_BITS (4 on llvmpipe, i.e. 1/16-pixel viewport precision), but the + // float viewport rectangle glViewportIndexedf stores is snapped to integers on its + // way to both backends (ComputeGLViewport, DirectGLES SyncRenderState). The STATE + // round trip is exact - which is all KHR-GL43.viewport_array.viewport_api checks, and + // all this cluster set out to fix - so the gap is in rasterization only: a fractional + // viewport origin rasterizes as if it had been rounded. Nothing in the suite or in + // Minecraft sets one. Only the spec floor is asserted; tightening this to EQ(0) would + // mean advertising no subpixel precision at all, which is a separate decision about a + // limit MobileGL currently passes through from the driver. + GLint subpixelBits = -1; + glGetIntegerv(GL_VIEWPORT_SUBPIXEL_BITS, &subpixelBits); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_GE(subpixelBits, 0) << "GL 4.6 core table 23.60: VIEWPORT_SUBPIXEL_BITS has a minimum of 0, and " + "a negative value is what a sign-flipped uint32 looks like"; + + GLint viewportDims[2] = {-1, -1}; + glGetIntegerv(GL_MAX_VIEWPORT_DIMS, viewportDims); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + GLint maxRenderbufferSize = -1; + glGetIntegerv(GL_MAX_RENDERBUFFER_SIZE, &maxRenderbufferSize); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + // GL 4.6 core 13.6.1: MAX_VIEWPORT_DIMS must be at least as large as the largest + // renderable surface, or a full-size framebuffer could not be fully viewported. + EXPECT_GE(viewportDims[0], maxRenderbufferSize); + EXPECT_GE(viewportDims[1], maxRenderbufferSize); + } + } // namespace } // namespace MGITest 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_State/GLState/Core.cpp b/MobileGL/MG_State/GLState/Core.cpp index 3d8cd5e9..03c5c165 100644 --- a/MobileGL/MG_State/GLState/Core.cpp +++ b/MobileGL/MG_State/GLState/Core.cpp @@ -712,10 +712,18 @@ namespace MobileGL::MG_State { m_renderState.SetViewport(viewport); } - const IntVec4& GLContext::GetViewport() const { + IntVec4 GLContext::GetViewport() const { return m_renderState.GetViewport(); } + void GLContext::SetViewportIndexed(Uint index, FloatVec4 viewport) { + m_renderState.SetViewportIndexed(index, viewport); + } + + const FloatVec4& GLContext::GetViewportIndexed(Uint index) const { + return m_renderState.GetViewportIndexed(index); + } + void GLContext::SetLineWidth(Float width) { m_renderState.SetLineWidth(width); } @@ -953,6 +961,14 @@ namespace MobileGL::MG_State { return m_renderState.GetDepthRange(); } + void GLContext::SetDepthRangeIndexed(Uint index, FloatVec2 range) { + m_renderState.SetDepthRangeIndexed(index, range); + } + + const FloatVec2& GLContext::GetDepthRangeIndexed(Uint index) const { + return m_renderState.GetDepthRangeIndexed(index); + } + void GLContext::SetSampleCoverage(Float value, Bool invert) { m_renderState.SetSampleCoverage(value, invert); } @@ -1017,6 +1033,14 @@ namespace MobileGL::MG_State { return m_renderState.GetScissorBox(); } + void GLContext::SetScissorBoxIndexed(Uint index, IntVec4 box) { + m_renderState.SetScissorBoxIndexed(index, box); + } + + const IntVec4& GLContext::GetScissorBoxIndexed(Uint index) const { + return m_renderState.GetScissorBoxIndexed(index); + } + // Framebuffer void GLContext::GenFramebufferNames(Uint number, Vector& framebuffers) { m_framebufferState.GenerateNames(number, framebuffers); diff --git a/MobileGL/MG_State/GLState/Core.h b/MobileGL/MG_State/GLState/Core.h index 1d26519a..73b28fcf 100644 --- a/MobileGL/MG_State/GLState/Core.h +++ b/MobileGL/MG_State/GLState/Core.h @@ -198,8 +198,10 @@ namespace MobileGL { // Only the pipeline-relevant subset - see RenderState::m_pipelineStateVersion. Uint GetPipelineStateVersion() const; const RenderStateParameters& GetRenderStateParameters() const; - void SetViewport(IntVec4 viewport); // x, y, width, height - const IntVec4& GetViewport() const; // x, y, width, height + void SetViewport(IntVec4 viewport); // x, y, width, height; writes ALL viewports + IntVec4 GetViewport() const; // x, y, width, height; viewport 0, rounded + void SetViewportIndexed(Uint index, FloatVec4 viewport); + const FloatVec4& GetViewportIndexed(Uint index) const; void SetLineWidth(Float width); Float GetLineWidth() const; void SetPointSize(Float size); @@ -260,8 +262,10 @@ namespace MobileGL { Uint32 GetClearStencil() const; void SetBlendColor(FloatVec4 color); const FloatVec4& GetBlendColor() const; - void SetDepthRange(FloatVec2 range); + void SetDepthRange(FloatVec2 range); // writes ALL viewports' depth ranges const FloatVec2& GetDepthRange() const; + void SetDepthRangeIndexed(Uint index, FloatVec2 range); + const FloatVec2& GetDepthRangeIndexed(Uint index) const; void SetSampleCoverage(Float value, Bool invert); Float GetSampleCoverageValue() const; Bool GetSampleCoverageInvert() const; @@ -276,8 +280,10 @@ namespace MobileGL { FrontFaceMode GetFrontFaceMode() const; void SetProvokingVertexMode(ProvokingVertexMode mode); ProvokingVertexMode GetProvokingVertexMode() const; - void SetScissorBox(IntVec4 box); // x, y, width, height - const IntVec4& GetScissorBox() const; // x, y, width, height + void SetScissorBox(IntVec4 box); // x, y, width, height; writes ALL rectangles + const IntVec4& GetScissorBox() const; // x, y, width, height; rectangle 0 + void SetScissorBoxIndexed(Uint index, IntVec4 box); + const IntVec4& GetScissorBoxIndexed(Uint index) const; // Transform feedback. The fields below are the state of the transform // feedback object currently bound to GL_TRANSFORM_FEEDBACK; see the object diff --git a/MobileGL/MG_State/GLState/RenderState/RenderState.cpp b/MobileGL/MG_State/GLState/RenderState/RenderState.cpp index 22c8292b..f2eb4503 100644 --- a/MobileGL/MG_State/GLState/RenderState/RenderState.cpp +++ b/MobileGL/MG_State/GLState/RenderState/RenderState.cpp @@ -25,6 +25,12 @@ namespace MobileGL { return 0; } } + + // Every viewport's scissor-test bit set, i.e. what glEnable(GL_SCISSOR_TEST) writes. + constexpr Uint32 kAllViewportsMask = + RenderStateParameters::MAX_VIEWPORTS >= 32 + ? ~0u + : (1u << RenderStateParameters::MAX_VIEWPORTS) - 1u; } // namespace RenderState::RenderState() { @@ -32,6 +38,15 @@ namespace MobileGL { for (auto& mask : m_parameters.ColorMasks) { mask = BoolVec4(true, true, true, true); } + // Every viewport's depth range starts at (0, 1) - GL 4.6 core table 23.4. The + // viewport and scissor rectangles legitimately start all-zero here: their spec + // initial value is the size of the window the context is first made current to, + // which the frontend does not know yet, so an all-zero rectangle means "never + // written" and the backends resolve it against the live surface (see + // DirectGLES' SyncRenderState and VulkanRenderer's ApplyGLViewportState). + for (auto& range : m_parameters.DepthRanges) { + range = FloatVec2(0.0f, 1.0f); + } } Uint RenderState::GetVersion() const { @@ -47,15 +62,47 @@ namespace MobileGL { } // -------------------- Rasterization -------------------- + // ARB_viewport_array, "Additions to Chapter 2": Viewport(x, y, w, h) is equivalent to + // ViewportIndexedf(i, x, y, w, h) for every i in [0, MAX_VIEWPORTS) - it is not a + // synonym for "viewport 0". void RenderState::SetViewport(IntVec4 viewport) { - if (m_parameters.Viewport == viewport) return; + const FloatVec4 asFloat(static_cast(viewport.x()), static_cast(viewport.y()), + static_cast(viewport.z()), static_cast(viewport.w())); + Bool stateChanged = false; + for (auto& stored : m_parameters.Viewports) { + if (stored == asFloat) continue; + stored = asFloat; + stateChanged = true; + } + if (stateChanged) ++m_version; + } - m_parameters.Viewport = viewport; + IntVec4 RenderState::GetViewport() const { + const FloatVec4& viewport = m_parameters.Viewports[0]; + // Round rather than truncate: glGetIntegerv on floating-point state rounds to + // nearest (GL 4.6 core 22.2), and truncating a 63.5-wide viewport to 63 would + // also hand the backends a rectangle one pixel short of what was asked for. + return IntVec4(static_cast(std::lround(viewport.x())), static_cast(std::lround(viewport.y())), + static_cast(std::lround(viewport.z())), static_cast(std::lround(viewport.w()))); + } + + void RenderState::SetViewportIndexed(Uint index, FloatVec4 viewport) { + if (index >= RenderStateParameters::MAX_VIEWPORTS) { + MOBILEGL_ASSERT(false, "Viewport index out of range: %u", index); + return; + } + if (m_parameters.Viewports[index] == viewport) return; + + m_parameters.Viewports[index] = viewport; ++m_version; } - const IntVec4& RenderState::GetViewport() const { - return m_parameters.Viewport; + const FloatVec4& RenderState::GetViewportIndexed(Uint index) const { + if (index >= RenderStateParameters::MAX_VIEWPORTS) { + MOBILEGL_ASSERT(false, "Viewport index out of range: %u", index); + return m_parameters.Viewports[0]; + } + return m_parameters.Viewports[index]; } void RenderState::SetLineWidth(Float width) { @@ -223,7 +270,6 @@ namespace MobileGL { SET_CAPABILITY(SampleAlphaToOne, enabled); SET_CAPABILITY(SampleCoverage, enabled); SET_CAPABILITY(SampleMask, enabled); - SET_CAPABILITY(ScissorTest, enabled); SET_CAPABILITY(StencilTest, enabled); SET_CAPABILITY(ProgramPointSize, enabled); case CapabilityInput::Blend: { @@ -236,6 +282,17 @@ namespace MobileGL { if (stateChanged) BumpVersions(); break; } + // GL 4.6 core 17.3.2: the non-indexed Enable/Disable(SCISSOR_TEST) enables or + // disables the test for ALL viewports, exactly like glViewport writes all + // viewports. Anything narrower fails KHR-GL43.viewport_array.scissor_test_state_api, + // whose "enable all" phase reads every index back through glIsEnabledi. + case CapabilityInput::ScissorTest: { + const Uint32 updated = enabled ? kAllViewportsMask : 0u; + if (m_parameters.ScissorTestEnabledMask == updated) break; + m_parameters.ScissorTestEnabledMask = updated; + BumpVersions(); + break; + } case CapabilityInput::ClipDistance0: case CapabilityInput::ClipDistance1: case CapabilityInput::ClipDistance2: @@ -287,11 +344,14 @@ namespace MobileGL { RETURN_CAPABILITY(SampleAlphaToOne); RETURN_CAPABILITY(SampleCoverage); RETURN_CAPABILITY(SampleMask); - RETURN_CAPABILITY(ScissorTest); RETURN_CAPABILITY(StencilTest); RETURN_CAPABILITY(ProgramPointSize); case CapabilityInput::Blend: return m_parameters.BlendStates[0].Enabled; + // The non-indexed query of an indexed capability answers for index 0 + // (GL 4.6 core 22.1), which is also the only bit either backend consumes today. + case CapabilityInput::ScissorTest: + return (m_parameters.ScissorTestEnabledMask & 1u) != 0; case CapabilityInput::ClipDistance0: case CapabilityInput::ClipDistance1: case CapabilityInput::ClipDistance2: @@ -307,13 +367,29 @@ namespace MobileGL { } void RenderState::SetCapabilityIndexed(CapabilityInput cap, Uint index, Bool enabled) { - // Only for BlendState currently. The GL entry points (glEnablei/glDisablei) already - // reject every non-GL_BLEND target with GL_INVALID_ENUM before reaching here, so this - // is a backstop - but it must stay a backstop: THROW_UNIMPL_EXCEPTION unwinds a C++ - // exception through the C GL ABI and terminates the process. + // GL_BLEND (indexed by draw buffer) and GL_SCISSOR_TEST (indexed by viewport) are + // the only indexed capabilities in GL 4.6 core. The GL entry points + // (glEnablei/glDisablei) already reject every other target with GL_INVALID_ENUM + // and every out-of-range index with GL_INVALID_VALUE before reaching here, so the + // guards below are backstops - but they must stay backstops: + // THROW_UNIMPL_EXCEPTION unwinds a C++ exception through the C GL ABI and + // terminates the process. + if (cap == CapabilityInput::ScissorTest) { + if (index >= RenderStateParameters::MAX_VIEWPORTS) { + MOBILEGL_ASSERT(false, "Scissor test capability index out of range: %u", index); + return; + } + const Uint32 bit = 1u << index; + const Uint32 updated = enabled ? (m_parameters.ScissorTestEnabledMask | bit) + : (m_parameters.ScissorTestEnabledMask & ~bit); + if (updated == m_parameters.ScissorTestEnabledMask) return; + m_parameters.ScissorTestEnabledMask = updated; + BumpVersions(); + return; + } if (cap != CapabilityInput::Blend) { MGLOG_I("RenderState::SetCapabilityIndexed: indexed capability state exists only for " - "GL_BLEND (cap=%d, index=%u); ignoring", + "GL_BLEND and GL_SCISSOR_TEST (cap=%d, index=%u); ignoring", static_cast(cap), index); return; } @@ -328,9 +404,17 @@ namespace MobileGL { } Bool RenderState::IsCapabilityEnabledIndexed(CapabilityInput cap, Uint index) const { - // Only for BlendState currently - same backstop reasoning as SetCapabilityIndexed: - // glIsEnabledi has already answered GL_INVALID_ENUM/GL_FALSE for anything else, and a - // query must never be able to terminate the process. + // GL_BLEND and GL_SCISSOR_TEST only - same backstop reasoning as + // SetCapabilityIndexed: glIsEnabledi has already answered + // GL_INVALID_ENUM/GL_INVALID_VALUE for anything else, and a query must never be + // able to terminate the process. + if (cap == CapabilityInput::ScissorTest) { + if (index >= RenderStateParameters::MAX_VIEWPORTS) { + MOBILEGL_ASSERT(false, "Scissor test capability index out of range: %u", index); + return false; + } + return (m_parameters.ScissorTestEnabledMask & (1u << index)) != 0; + } if (cap != CapabilityInput::Blend) { MGLOG_I("RenderState::IsCapabilityEnabledIndexed: indexed capability state exists only " "for GL_BLEND (cap=%d, index=%u); reporting disabled", @@ -591,15 +675,39 @@ namespace MobileGL { return m_parameters.BlendColor; } + // Like Viewport: ARB_viewport_array makes DepthRange(n, f) the same as + // DepthRangeIndexed(i, n, f) for every i. void RenderState::SetDepthRange(FloatVec2 range) { - if (m_parameters.DepthRange == range) return; - - m_parameters.DepthRange = range; - ++m_version; + Bool stateChanged = false; + for (auto& stored : m_parameters.DepthRanges) { + if (stored == range) continue; + stored = range; + stateChanged = true; + } + if (stateChanged) ++m_version; } const FloatVec2& RenderState::GetDepthRange() const { - return m_parameters.DepthRange; + return m_parameters.DepthRanges[0]; + } + + void RenderState::SetDepthRangeIndexed(Uint index, FloatVec2 range) { + if (index >= RenderStateParameters::MAX_VIEWPORTS) { + MOBILEGL_ASSERT(false, "Depth range index out of range: %u", index); + return; + } + if (m_parameters.DepthRanges[index] == range) return; + + m_parameters.DepthRanges[index] = range; + ++m_version; + } + + const FloatVec2& RenderState::GetDepthRangeIndexed(Uint index) const { + if (index >= RenderStateParameters::MAX_VIEWPORTS) { + MOBILEGL_ASSERT(false, "Depth range index out of range: %u", index); + return m_parameters.DepthRanges[0]; + } + return m_parameters.DepthRanges[index]; } void RenderState::SetSampleCoverage(Float value, Bool invert) { @@ -726,15 +834,39 @@ namespace MobileGL { } // --------------------- Scissor --------------------- + // Like Viewport: ARB_viewport_array makes Scissor(x, y, w, h) the same as + // ScissorIndexed(i, x, y, w, h) for every i. void RenderState::SetScissorBox(IntVec4 box) { - if (m_parameters.ScissorBox == box) return; - - m_parameters.ScissorBox = box; - ++m_version; + Bool stateChanged = false; + for (auto& stored : m_parameters.ScissorBoxes) { + if (stored == box) continue; + stored = box; + stateChanged = true; + } + if (stateChanged) ++m_version; } const IntVec4& RenderState::GetScissorBox() const { - return m_parameters.ScissorBox; + return m_parameters.ScissorBoxes[0]; + } + + void RenderState::SetScissorBoxIndexed(Uint index, IntVec4 box) { + if (index >= RenderStateParameters::MAX_VIEWPORTS) { + MOBILEGL_ASSERT(false, "Scissor box index out of range: %u", index); + return; + } + if (m_parameters.ScissorBoxes[index] == box) return; + + m_parameters.ScissorBoxes[index] = box; + ++m_version; + } + + const IntVec4& RenderState::GetScissorBoxIndexed(Uint index) const { + if (index >= RenderStateParameters::MAX_VIEWPORTS) { + MOBILEGL_ASSERT(false, "Scissor box index out of range: %u", index); + return m_parameters.ScissorBoxes[0]; + } + return m_parameters.ScissorBoxes[index]; } } // namespace GLState } // namespace MG_State diff --git a/MobileGL/MG_State/GLState/RenderState/RenderState.h b/MobileGL/MG_State/GLState/RenderState/RenderState.h index 52009e79..73b51fbd 100644 --- a/MobileGL/MG_State/GLState/RenderState/RenderState.h +++ b/MobileGL/MG_State/GLState/RenderState/RenderState.h @@ -220,8 +220,22 @@ namespace MobileGL { }; struct RenderStateParameters { + // ARB_viewport_array / GL 4.6 core 13.6.1: the viewport, the scissor rectangle, the depth + // range and the scissor-test enable are all arrays indexed by gl_ViewportIndex, and the + // spec floor for MAX_VIEWPORTS is 16. MobileGL advertises exactly 16 on both backends, so + // this is also what GL_MAX_VIEWPORTS reports (see the backend loaders' caps.MaxViewports). + static constexpr Uint MAX_VIEWPORTS = 16; + // Rasterization - IntVec4 Viewport = IntVec4(0, 0, 0, 0); // x, y, width, height + // The viewport rectangle is FLOAT state as of GL 4.1 - ViewportIndexedf writes fractional + // values and GetFloati_v(GL_VIEWPORT) must hand them back bit-exact + // (KHR-GL43.viewport_array.viewport_api compares with ==, no tolerance). glViewport's + // integers are simply one way to write it. Index 0 is what a program that never assigns + // gl_ViewportIndex rasterizes against, and what the classic glViewport / + // glGetIntegerv(GL_VIEWPORT) pair addresses. Both backends rasterize the rectangle + // rounded back to integers; the STATE stays exact, which is the half the conformance + // suite checks (see the KNOWN INFIDELITY note in AdvertisedLimitsScenario.cpp). + Array Viewports{}; // x, y, width, height Float LineWidth = 1.0f; Float PointSize = 1.0f; // GL_PATCH_VERTICES: how many vertices one tessellation patch consumes. @@ -247,7 +261,13 @@ namespace MobileGL { Float ClearDepth = 1.0f; Uint32 ClearStencil = 0; FloatVec4 BlendColor = FloatVec4(0.0f, 0.0f, 0.0f, 0.0f); - FloatVec2 DepthRange = FloatVec2(0.0f, 1.0f); + // Per-viewport depth range (glDepthRangeIndexed / glDepthRangeArrayv). Every entry is + // initialized to (0, 1) in RenderState's constructor - a default member initializer would + // not survive the Array<> aggregate. Kept float rather than double: DepthRangeArrayv takes + // GLdouble, but the value reaches the hardware as VkViewport::minDepth/maxDepth (float) on + // Magma and glDepthRangef on Espryt, so a double store would only widen the readback and + // then lose it again at the same place. + Array DepthRanges{}; Float SampleCoverageValue = 1.0f; Bool SampleCoverageInvert = false; Uint32 SampleMaskValue = 0xffffffffu; @@ -299,10 +319,15 @@ namespace MobileGL { Bool SampleAlphaToOneEnabled = false; Bool SampleCoverageEnabled = false; Bool SampleMaskEnabled = false; - Bool ScissorTestEnabled = false; Bool StencilTestEnabled = false; Bool ProgramPointSizeEnabled = false; - IntVec4 ScissorBox = IntVec4(0, 0, 0, 0); // x, y, width, height + // glEnable(GL_SCISSOR_TEST) enables the test for EVERY viewport, glEnablei for one + // (GL 4.6 core 17.3.2), so this is 16 bits and not a bool. Bit 0 is what the classic + // glIsEnabled(GL_SCISSOR_TEST) reports and what both backends currently consume. Unlike + // ClipDistanceEnabledMask below it DOES bump the pipeline version, because DirectGLES + // turns it into a real glEnable/glDisable. + Uint32 ScissorTestEnabledMask = 0; + Array ScissorBoxes{}; // x, y, width, height // glEnable(GL_CLIP_DISTANCE0 + i) for i in [0, 8), one bit each. A bitmask rather than // eight bools because every consumer wants the set, not an individual flag, and because // the SYNC_CAPABILITY/SET_CAPABILITY macros key off a "Enabled" field name that @@ -323,8 +348,14 @@ namespace MobileGL { const RenderStateParameters& GetAllParameters() const; // Rasterization + // ARB_viewport_array defines glViewport as ViewportIndexedf on EVERY index, so the + // classic setter broadcasts; GetViewport answers for index 0 (rounded to the + // integers glGetIntegerv(GL_VIEWPORT) and both backends want) and is BY VALUE for + // that reason. The indexed pair is the verbatim float state. void SetViewport(IntVec4 viewport); // x, y, width, height - const IntVec4& GetViewport() const; // x, y, width, height + IntVec4 GetViewport() const; // x, y, width, height, viewport 0, rounded + void SetViewportIndexed(Uint index, FloatVec4 viewport); + const FloatVec4& GetViewportIndexed(Uint index) const; void SetLineWidth(Float width); Float GetLineWidth() const; void SetPointSize(Float size); @@ -400,8 +431,12 @@ namespace MobileGL { Uint32 GetClearStencil() const; void SetBlendColor(FloatVec4 color); const FloatVec4& GetBlendColor() const; + // glDepthRange(f) writes every viewport's range (ARB_viewport_array); the indexed + // pair is glDepthRangeIndexed / glDepthRangeArrayv. GetDepthRange answers index 0. void SetDepthRange(FloatVec2 range); const FloatVec2& GetDepthRange() const; + void SetDepthRangeIndexed(Uint index, FloatVec2 range); + const FloatVec2& GetDepthRangeIndexed(Uint index) const; void SetSampleCoverage(Float value, Bool invert); Float GetSampleCoverageValue() const; Bool GetSampleCoverageInvert() const; @@ -421,9 +456,12 @@ namespace MobileGL { void SetProvokingVertexMode(ProvokingVertexMode mode); ProvokingVertexMode GetProvokingVertexMode() const; - // Scissor + // Scissor. glScissor writes every rectangle (ARB_viewport_array); GetScissorBox + // answers for index 0. void SetScissorBox(IntVec4 box); // x, y, width, height const IntVec4& GetScissorBox() const; // x, y, width, height + void SetScissorBoxIndexed(Uint index, IntVec4 box); + const IntVec4& GetScissorBoxIndexed(Uint index) const; private: // Bump both: any state change invalidates the draw snapshot, and this one also 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 diff --git a/MobileGL/MG_Test/State/RenderStateTest.cpp b/MobileGL/MG_Test/State/RenderStateTest.cpp index 8a5ee533..66ed6033 100644 --- a/MobileGL/MG_Test/State/RenderStateTest.cpp +++ b/MobileGL/MG_Test/State/RenderStateTest.cpp @@ -6,11 +6,20 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header // -// Indexed capability state (glEnablei/glDisablei/glIsEnabledi) exists only for GL_BLEND in this -// stack. Every other capability must come back as GL_INVALID_ENUM per GL 4.6 sec. 17.3.3 - and, -// far more importantly, must come back at all: RenderState::SetCapabilityIndexed and -// IsCapabilityEnabledIndexed used to answer a non-blend capability with THROW_UNIMPL_EXCEPTION, +// Indexed capability state (glEnablei/glDisablei/glIsEnabledi) exists for exactly two +// capabilities: GL_BLEND, indexed by draw buffer, and GL_SCISSOR_TEST, indexed by viewport +// (ARB_viewport_array). Every other capability must come back as GL_INVALID_ENUM per GL 4.6 +// sec. 17.3.3 - and, far more importantly, must come back at all: RenderState::SetCapabilityIndexed +// and IsCapabilityEnabledIndexed used to answer a non-blend capability with THROW_UNIMPL_EXCEPTION, // which unwinds a C++ exception through the C GL ABI and terminates the process. +// +// The second half of this file is the ARB_viewport_array indexed rectangle state. Every one of +// glViewportArrayv/glViewportIndexedf(v)/glScissorArrayv/glScissorIndexed(v)/glDepthRangeArrayv/ +// glDepthRangeIndexed was a MGLOG_W_ONCE stub that raised no error and stored nothing, and the +// indexed getters answered EVERY index with viewport 0's value, so a set/get round trip silently +// reported the initial state. The assertions below are deliberately state-shaped rather than +// render-shaped: this IS the state machine, and the rendering half (gl_ViewportIndex routing) is +// asserted separately in MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp. #include @@ -21,6 +30,7 @@ #include #include #include +#include using namespace MobileGL; @@ -50,10 +60,11 @@ namespace { }; } // namespace -TEST_F(RenderStateTest, IndexedCapabilityTogglesRejectNonBlendCapabilities) { +TEST_F(RenderStateTest, IndexedCapabilityTogglesRejectNonIndexedCapabilities) { // GL_CLIP_DISTANCE0 is a real capability, just not an indexed one - the shape an application or - // a CTS negative test would hit. - for (const GLenum cap : {GL_CLIP_DISTANCE0, GL_DEPTH_TEST, GL_SCISSOR_TEST}) { + // a CTS negative test would hit. GL_SCISSOR_TEST used to be in this list and is not any more: + // ARB_viewport_array makes it the second indexed capability (see the tests below). + for (const GLenum cap : {GL_CLIP_DISTANCE0, GL_DEPTH_TEST, GL_STENCIL_TEST}) { MG_Impl::GLImpl::Enablei(cap, 0); ExpectSingleGlError(GL_INVALID_ENUM); @@ -89,3 +100,462 @@ TEST_F(RenderStateTest, IndexedBlendTogglesStillWork) { EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(GL_BLEND, 1), GL_FALSE); EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); } + +// --------------------------------------------------------------------------------------------- +// ARB_viewport_array: indexed viewport / scissor / depth-range state +// --------------------------------------------------------------------------------------------- + +namespace { + constexpr GLuint kMaxViewports = RenderStateParameters::MAX_VIEWPORTS; + + Array, kMaxViewports> ReadAllViewports() { + Array, kMaxViewports> out{}; + for (GLuint i = 0; i < kMaxViewports; ++i) { + MG_Impl::GLImpl::GetFloati_v(GL_VIEWPORT, i, out[i].data()); + } + return out; + } + + Array, kMaxViewports> ReadAllDepthRanges() { + Array, kMaxViewports> out{}; + for (GLuint i = 0; i < kMaxViewports; ++i) { + MG_Impl::GLImpl::GetDoublei_v(GL_DEPTH_RANGE, i, out[i].data()); + } + return out; + } +} // namespace + +TEST_F(RenderStateTest, ScissorTestIsIndexedByViewport) { + // The exact shape of KHR-GL43.viewport_array.scissor_test_state_api's toggle loop: one index + // is flipped and EVERY index is read back, so a broadcast masquerading as an indexed write + // cannot pass. + MG_Impl::GLImpl::Disable(GL_SCISSOR_TEST); + ExpectSingleGlError(GL_NO_ERROR); + + for (GLuint toggled = 0; toggled < kMaxViewports; ++toggled) { + MG_Impl::GLImpl::Enablei(GL_SCISSOR_TEST, toggled); + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "index " << toggled; + for (GLuint i = 0; i < kMaxViewports; ++i) { + EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(GL_SCISSOR_TEST, i), i == toggled ? GL_TRUE : GL_FALSE) + << "enabled index " << toggled << ", read index " << i; + } + MG_Impl::GLImpl::Disablei(GL_SCISSOR_TEST, toggled); + EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(GL_SCISSOR_TEST, toggled), GL_FALSE); + } + ExpectSingleGlError(GL_NO_ERROR); +} + +TEST_F(RenderStateTest, NonIndexedScissorTestEnableWritesEveryViewport) { + // GL 4.6 core 17.3.2: Enable/Disable(SCISSOR_TEST) is "for all viewports". Reading only + // index 0 back would let a broadcast-less implementation through, so every index is checked. + MG_Impl::GLImpl::Enable(GL_SCISSOR_TEST); + for (GLuint i = 0; i < kMaxViewports; ++i) { + EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(GL_SCISSOR_TEST, i), GL_TRUE) << "index " << i; + } + // ... and the non-indexed query answers for viewport 0 (GL 4.6 core 22.1). + EXPECT_EQ(MG_Impl::GLImpl::IsEnabled(GL_SCISSOR_TEST), GL_TRUE); + + MG_Impl::GLImpl::Disable(GL_SCISSOR_TEST); + for (GLuint i = 0; i < kMaxViewports; ++i) { + EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(GL_SCISSOR_TEST, i), GL_FALSE) << "index " << i; + } + EXPECT_EQ(MG_Impl::GLImpl::IsEnabled(GL_SCISSOR_TEST), GL_FALSE); + + // An indexed enable on a NON-zero index must not move the non-indexed answer. + MG_Impl::GLImpl::Enablei(GL_SCISSOR_TEST, 3); + EXPECT_EQ(MG_Impl::GLImpl::IsEnabled(GL_SCISSOR_TEST), GL_FALSE); + MG_Impl::GLImpl::Enablei(GL_SCISSOR_TEST, 0); + EXPECT_EQ(MG_Impl::GLImpl::IsEnabled(GL_SCISSOR_TEST), GL_TRUE); + + MG_Impl::GLImpl::Disable(GL_SCISSOR_TEST); + ExpectSingleGlError(GL_NO_ERROR); +} + +TEST_F(RenderStateTest, ScissorTestEnableRejectsAnOutOfRangeViewportIndex) { + MG_Impl::GLImpl::Enablei(GL_SCISSOR_TEST, kMaxViewports); + ExpectSingleGlError(GL_INVALID_VALUE); + + MG_Impl::GLImpl::Disablei(GL_SCISSOR_TEST, kMaxViewports); + ExpectSingleGlError(GL_INVALID_VALUE); + + EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(GL_SCISSOR_TEST, kMaxViewports), GL_FALSE); + ExpectSingleGlError(GL_INVALID_VALUE); + + // MAX_VIEWPORTS - 1 is the last LEGAL index and must stay silent. + MG_Impl::GLImpl::Enablei(GL_SCISSOR_TEST, kMaxViewports - 1); + ExpectSingleGlError(GL_NO_ERROR); + MG_Impl::GLImpl::Disablei(GL_SCISSOR_TEST, kMaxViewports - 1); + ExpectSingleGlError(GL_NO_ERROR); +} + +TEST_F(RenderStateTest, MaxViewportsMatchesTheIndexedStateWidth) { + // The advertised limit and the width of the state arrays are the same number by + // construction; a divergence would make some index simultaneously legal to the CTS and + // out of range to the setters. + GLint maxViewports = 0; + MG_Impl::GLImpl::GetIntegerv(GL_MAX_VIEWPORTS, &maxViewports); + ExpectSingleGlError(GL_NO_ERROR); + EXPECT_EQ(maxViewports, static_cast(kMaxViewports)); + EXPECT_GE(maxViewports, 16) << "GL 4.3 core requires MAX_VIEWPORTS >= 16"; +} + +TEST_F(RenderStateTest, ViewportArrayvRoundTripsThroughEveryGetterWidth) { + Array written{}; + for (GLuint i = 0; i < kMaxViewports; ++i) { + written[i * 4 + 0] = static_cast(i) + 0.125f; + written[i * 4 + 1] = static_cast(i) + 0.25f; + written[i * 4 + 2] = static_cast(64 + i); + written[i * 4 + 3] = static_cast(32 + i); + } + MG_Impl::GLImpl::ViewportArrayv(0, kMaxViewports, written.data()); + ExpectSingleGlError(GL_NO_ERROR); + + for (GLuint i = 0; i < kMaxViewports; ++i) { + GLfloat asFloat[4] = {}; + MG_Impl::GLImpl::GetFloati_v(GL_VIEWPORT, i, asFloat); + // Bit-exact: the fractional origin is the whole point of float viewport state, and the + // CTS compares with == (0.125 and 0.25 are exact binary fractions, so this is fair). + EXPECT_EQ(asFloat[0], written[i * 4 + 0]) << "index " << i << " must round-trip verbatim"; + EXPECT_EQ(asFloat[1], written[i * 4 + 1]) << "index " << i; + EXPECT_EQ(asFloat[2], written[i * 4 + 2]) << "index " << i; + EXPECT_EQ(asFloat[3], written[i * 4 + 3]) << "index " << i; + + GLdouble asDouble[4] = {}; + MG_Impl::GLImpl::GetDoublei_v(GL_VIEWPORT, i, asDouble); + for (int c = 0; c < 4; ++c) { + EXPECT_EQ(asDouble[c], static_cast(written[i * 4 + c])) << "index " << i << " component " << c; + } + + // The integer widths round to nearest rather than truncate; the .5+ case is pinned by + // ViewportRoundsRatherThanTruncatesForIntegerQueries below. + GLint asInt[4] = {}; + MG_Impl::GLImpl::GetIntegeri_v(GL_VIEWPORT, i, asInt); + EXPECT_EQ(asInt[2], static_cast(64 + i)) << "index " << i; + EXPECT_EQ(asInt[3], static_cast(32 + i)) << "index " << i; + + GLint64 asInt64[4] = {}; + MG_Impl::GLImpl::GetInteger64i_v(GL_VIEWPORT, i, asInt64); + for (int c = 0; c < 4; ++c) { + EXPECT_EQ(asInt64[c], static_cast(asInt[c])) << "index " << i << " component " << c; + } + + GLboolean asBool[4] = {}; + MG_Impl::GLImpl::GetBooleani_v(GL_VIEWPORT, i, asBool); + EXPECT_EQ(asBool[2], GL_TRUE) << "index " << i << ": a non-zero width is GL_TRUE"; + } + ExpectSingleGlError(GL_NO_ERROR); +} + +TEST_F(RenderStateTest, ViewportRoundsRatherThanTruncatesForIntegerQueries) { + MG_Impl::GLImpl::ViewportIndexedf(2, 0.0f, 0.0f, 255.875f, 63.5f); + ExpectSingleGlError(GL_NO_ERROR); + + GLint asInt[4] = {}; + MG_Impl::GLImpl::GetIntegeri_v(GL_VIEWPORT, 2, asInt); + EXPECT_EQ(asInt[2], 256); + EXPECT_EQ(asInt[3], 64); + + GLfloat asFloat[4] = {}; + MG_Impl::GLImpl::GetFloati_v(GL_VIEWPORT, 2, asFloat); + EXPECT_EQ(asFloat[2], 255.875f) << "the integer query must not disturb the stored float"; + ExpectSingleGlError(GL_NO_ERROR); +} + +TEST_F(RenderStateTest, ViewportIndexedWritesTouchExactlyOneIndex) { + MG_Impl::GLImpl::Viewport(0, 0, 8, 8); + const auto before = ReadAllViewports(); + + for (GLuint target = 0; target < kMaxViewports; ++target) { + const GLfloat value[4] = {0.375f, 0.375f, 0.625f, 0.625f}; + // Alternate the two indexed entry points so both are covered by the isolation claim. + if (target % 2 == 0) { + MG_Impl::GLImpl::ViewportIndexedf(target, value[0], value[1], value[2], value[3]); + } else { + MG_Impl::GLImpl::ViewportIndexedfv(target, value); + } + ExpectSingleGlError(GL_NO_ERROR); + + const auto after = ReadAllViewports(); + for (GLuint i = 0; i < kMaxViewports; ++i) { + if (i == target) { + EXPECT_EQ(after[i][0], value[0]) << "index " << i; + EXPECT_EQ(after[i][2], value[2]) << "index " << i; + } else { + EXPECT_EQ(after[i], before[i]) << "write to " << target << " disturbed index " << i; + } + } + MG_Impl::GLImpl::ViewportIndexedf(target, before[target][0], before[target][1], before[target][2], + before[target][3]); + } + ExpectSingleGlError(GL_NO_ERROR); +} + +TEST_F(RenderStateTest, ClassicViewportWritesEveryIndexAndIsVisibleThroughIndexZero) { + // Both directions of the aliasing. ARB_viewport_array defines glViewport as ViewportIndexedf + // on every index, and glGetIntegerv(GL_VIEWPORT) as viewport 0. + MG_Impl::GLImpl::ViewportIndexedf(5, 1.0f, 2.0f, 3.0f, 4.0f); + MG_Impl::GLImpl::Viewport(0, 0, 1, 1); + ExpectSingleGlError(GL_NO_ERROR); + for (GLuint i = 0; i < kMaxViewports; ++i) { + GLfloat data[4] = {}; + MG_Impl::GLImpl::GetFloati_v(GL_VIEWPORT, i, data); + EXPECT_EQ(data[0], 0.0f) << "index " << i; + EXPECT_EQ(data[2], 1.0f) << "glViewport must overwrite index " << i; + } + + MG_Impl::GLImpl::ViewportIndexedf(0, 4.0f, 5.0f, 6.0f, 7.0f); + GLint classic[4] = {}; + MG_Impl::GLImpl::GetIntegerv(GL_VIEWPORT, classic); + EXPECT_EQ(classic[0], 4); + EXPECT_EQ(classic[2], 6); + GLfloat classicFloat[4] = {}; + MG_Impl::GLImpl::GetFloatv(GL_VIEWPORT, classicFloat); + EXPECT_EQ(classicFloat[2], 6.0f); + // Index 5 keeps its own value: writing index 0 is not a broadcast. + GLfloat other[4] = {}; + MG_Impl::GLImpl::GetFloati_v(GL_VIEWPORT, 5, other); + EXPECT_EQ(other[2], 1.0f); + ExpectSingleGlError(GL_NO_ERROR); +} + +TEST_F(RenderStateTest, ScissorBoxRoundTripsPerIndexAndAliasesIndexZero) { + Array written{}; + for (GLuint i = 0; i < kMaxViewports; ++i) { + written[i * 4 + 0] = static_cast(i); + written[i * 4 + 1] = static_cast(i * 2); + written[i * 4 + 2] = static_cast(16 + i); + written[i * 4 + 3] = static_cast(8 + i); + } + MG_Impl::GLImpl::ScissorArrayv(0, kMaxViewports, written.data()); + ExpectSingleGlError(GL_NO_ERROR); + + for (GLuint i = 0; i < kMaxViewports; ++i) { + GLint readBack[4] = {}; + MG_Impl::GLImpl::GetIntegeri_v(GL_SCISSOR_BOX, i, readBack); + for (int c = 0; c < 4; ++c) { + EXPECT_EQ(readBack[c], written[i * 4 + c]) << "index " << i << " component " << c; + } + } + + // Indexed writes stay indexed; both spellings. + MG_Impl::GLImpl::ScissorIndexed(4, 4, 4, 8, 8); + const GLint indexedV[4] = {9, 9, 12, 12}; + MG_Impl::GLImpl::ScissorIndexedv(7, indexedV); + ExpectSingleGlError(GL_NO_ERROR); + GLint probe[4] = {}; + MG_Impl::GLImpl::GetIntegeri_v(GL_SCISSOR_BOX, 4, probe); + EXPECT_EQ(probe[2], 8); + MG_Impl::GLImpl::GetIntegeri_v(GL_SCISSOR_BOX, 7, probe); + EXPECT_EQ(probe[2], 12); + MG_Impl::GLImpl::GetIntegeri_v(GL_SCISSOR_BOX, 5, probe); + EXPECT_EQ(probe[2], static_cast(16 + 5)) << "index 5 must be untouched"; + + // glScissor writes every rectangle, and glGetIntegerv(GL_SCISSOR_BOX) reports rectangle 0. + MG_Impl::GLImpl::Scissor(2, 3, 5, 6); + for (GLuint i = 0; i < kMaxViewports; ++i) { + MG_Impl::GLImpl::GetIntegeri_v(GL_SCISSOR_BOX, i, probe); + EXPECT_EQ(probe[0], 2) << "index " << i; + EXPECT_EQ(probe[2], 5) << "index " << i; + } + GLint classic[4] = {}; + MG_Impl::GLImpl::GetIntegerv(GL_SCISSOR_BOX, classic); + EXPECT_EQ(classic[2], 5); + ExpectSingleGlError(GL_NO_ERROR); +} + +TEST_F(RenderStateTest, DepthRangeRoundTripsPerIndexAndAliasesIndexZero) { + Array written{}; + for (GLuint i = 0; i < kMaxViewports; ++i) { + // Exact binary fractions, like the CTS uses: a float-backed store round-trips them. + written[i * 2 + 0] = static_cast(i) / 16.0; + written[i * 2 + 1] = 1.0 - static_cast(i) / 16.0; + } + MG_Impl::GLImpl::DepthRangeArrayv(0, kMaxViewports, written.data()); + ExpectSingleGlError(GL_NO_ERROR); + + const auto readBack = ReadAllDepthRanges(); + for (GLuint i = 0; i < kMaxViewports; ++i) { + EXPECT_EQ(readBack[i][0], written[i * 2 + 0]) << "index " << i; + EXPECT_EQ(readBack[i][1], written[i * 2 + 1]) << "index " << i; + } + + MG_Impl::GLImpl::DepthRangeIndexed(9, 0.25, 0.75); + ExpectSingleGlError(GL_NO_ERROR); + GLdouble probe[2] = {}; + MG_Impl::GLImpl::GetDoublei_v(GL_DEPTH_RANGE, 9, probe); + EXPECT_EQ(probe[0], 0.25); + EXPECT_EQ(probe[1], 0.75); + MG_Impl::GLImpl::GetDoublei_v(GL_DEPTH_RANGE, 8, probe); + EXPECT_EQ(probe[0], 8.0 / 16.0) << "index 8 must be untouched"; + + GLfloat asFloat[2] = {}; + MG_Impl::GLImpl::GetFloati_v(GL_DEPTH_RANGE, 9, asFloat); + EXPECT_EQ(asFloat[0], 0.25f); + EXPECT_EQ(asFloat[1], 0.75f); + + // glDepthRange writes every range; glGetDoublev(GL_DEPTH_RANGE) reports range 0. + MG_Impl::GLImpl::DepthRange(0.0, 1.0); + for (GLuint i = 0; i < kMaxViewports; ++i) { + MG_Impl::GLImpl::GetDoublei_v(GL_DEPTH_RANGE, i, probe); + EXPECT_EQ(probe[0], 0.0) << "index " << i; + EXPECT_EQ(probe[1], 1.0) << "index " << i; + } + MG_Impl::GLImpl::DepthRangeIndexed(0, 0.125, 0.875); + GLdouble classic[2] = {}; + MG_Impl::GLImpl::GetDoublev(GL_DEPTH_RANGE, classic); + EXPECT_EQ(classic[0], 0.125); + EXPECT_EQ(classic[1], 0.875); + MG_Impl::GLImpl::DepthRange(0.0, 1.0); + ExpectSingleGlError(GL_NO_ERROR); +} + +TEST_F(RenderStateTest, IndexedRectangleSettersRejectAnOutOfRangeIndex) { + const GLfloat viewport[4] = {0.0f, 0.0f, 1.0f, 1.0f}; + const GLint scissor[4] = {0, 0, 1, 1}; + + for (const GLuint index : {kMaxViewports, kMaxViewports + 1}) { + MG_Impl::GLImpl::ViewportIndexedf(index, 0.0f, 0.0f, 1.0f, 1.0f); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::ViewportIndexedfv(index, viewport); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::ScissorIndexed(index, 0, 0, 1, 1); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::ScissorIndexedv(index, scissor); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::DepthRangeIndexed(index, 0.0, 1.0); + ExpectSingleGlError(GL_INVALID_VALUE); + } + + // The last legal index must stay silent - api_errors checks both sides of the boundary. + MG_Impl::GLImpl::ViewportIndexedf(kMaxViewports - 1, 0.0f, 0.0f, 1.0f, 1.0f); + ExpectSingleGlError(GL_NO_ERROR); + MG_Impl::GLImpl::ScissorIndexed(kMaxViewports - 1, 0, 0, 1, 1); + ExpectSingleGlError(GL_NO_ERROR); + MG_Impl::GLImpl::DepthRangeIndexed(kMaxViewports - 1, 0.0, 1.0); + ExpectSingleGlError(GL_NO_ERROR); +} + +TEST_F(RenderStateTest, ArraySettersRejectAnOutOfRangeRangeButAcceptAnExactlyFullOne) { + Array viewports{}; + Array scissors{}; + Array depths{}; + for (GLuint i = 0; i < kMaxViewports; ++i) { + viewports[i * 4 + 2] = 1.0f; + viewports[i * 4 + 3] = 1.0f; + scissors[i * 4 + 2] = 1; + scissors[i * 4 + 3] = 1; + depths[i * 2 + 1] = 1.0; + } + + // first == MAX_VIEWPORTS, and first + count > MAX_VIEWPORTS. + MG_Impl::GLImpl::ViewportArrayv(kMaxViewports, 1, viewports.data()); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::ViewportArrayv(1, kMaxViewports, viewports.data()); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::ScissorArrayv(kMaxViewports, 1, scissors.data()); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::ScissorArrayv(1, kMaxViewports, scissors.data()); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::DepthRangeArrayv(kMaxViewports, 1, depths.data()); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::DepthRangeArrayv(1, kMaxViewports, depths.data()); + ExpectSingleGlError(GL_INVALID_VALUE); + + // first + count == MAX_VIEWPORTS is LEGAL - the off-by-one an ">=" bound would get wrong, + // and one KHR-GL43.viewport_array.api_errors asserts explicitly. + MG_Impl::GLImpl::ViewportArrayv(1, kMaxViewports - 1, viewports.data()); + ExpectSingleGlError(GL_NO_ERROR); + MG_Impl::GLImpl::ScissorArrayv(1, kMaxViewports - 1, scissors.data()); + ExpectSingleGlError(GL_NO_ERROR); + MG_Impl::GLImpl::DepthRangeArrayv(1, kMaxViewports - 1, depths.data()); + ExpectSingleGlError(GL_NO_ERROR); + + // A negative count is GL_INVALID_VALUE and must not be read as a huge unsigned length. + MG_Impl::GLImpl::ViewportArrayv(0, -1, viewports.data()); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::ScissorArrayv(0, -1, scissors.data()); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::DepthRangeArrayv(0, -1, depths.data()); + ExpectSingleGlError(GL_INVALID_VALUE); +} + +TEST_F(RenderStateTest, NegativeExtentsAreRejectedWithoutDisturbingState) { + MG_Impl::GLImpl::Viewport(0, 0, 4, 4); + MG_Impl::GLImpl::Scissor(0, 0, 4, 4); + ExpectSingleGlError(GL_NO_ERROR); + + MG_Impl::GLImpl::Viewport(0, 0, -1, 1); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::Viewport(0, 0, 1, -1); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::Scissor(0, 0, -1, 1); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::Scissor(0, 0, 1, -1); + ExpectSingleGlError(GL_INVALID_VALUE); + + for (GLuint index = 0; index < kMaxViewports; ++index) { + MG_Impl::GLImpl::ViewportIndexedf(index, 0.0f, 0.0f, -1.0f, 1.0f); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::ViewportIndexedf(index, 0.0f, 0.0f, 1.0f, -1.0f); + ExpectSingleGlError(GL_INVALID_VALUE); + + const GLfloat badW[4] = {0.0f, 0.0f, -1.0f, 1.0f}; + MG_Impl::GLImpl::ViewportIndexedfv(index, badW); + ExpectSingleGlError(GL_INVALID_VALUE); + + MG_Impl::GLImpl::ScissorIndexed(index, 0, 0, -1, 1); + ExpectSingleGlError(GL_INVALID_VALUE); + const GLint badH[4] = {0, 0, 1, -1}; + MG_Impl::GLImpl::ScissorIndexedv(index, badH); + ExpectSingleGlError(GL_INVALID_VALUE); + + // The array form must reject the WHOLE call for one bad element, exactly once, and + // leave every rectangle alone - api_errors submits a full 16-element array with a + // single negative extent and then requires the error queue to hold one entry. + Array viewports{}; + Array scissors{}; + for (GLuint i = 0; i < kMaxViewports; ++i) { + viewports[i * 4 + 2] = 1.0f; + viewports[i * 4 + 3] = 1.0f; + scissors[i * 4 + 2] = 1; + scissors[i * 4 + 3] = 1; + } + viewports[index * 4 + 2] = -1.0f; + scissors[index * 4 + 3] = -1; + MG_Impl::GLImpl::ViewportArrayv(0, kMaxViewports, viewports.data()); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::ScissorArrayv(0, kMaxViewports, scissors.data()); + ExpectSingleGlError(GL_INVALID_VALUE); + } + + // Nothing above may have landed. + GLint viewport[4] = {}; + MG_Impl::GLImpl::GetIntegeri_v(GL_VIEWPORT, 0, viewport); + EXPECT_EQ(viewport[2], 4); + EXPECT_EQ(viewport[3], 4); + GLint scissor[4] = {}; + MG_Impl::GLImpl::GetIntegeri_v(GL_SCISSOR_BOX, 0, scissor); + EXPECT_EQ(scissor[2], 4); + EXPECT_EQ(scissor[3], 4); + ExpectSingleGlError(GL_NO_ERROR); +} + +TEST_F(RenderStateTest, IndexedRectangleQueriesRejectAnOutOfRangeIndex) { + GLint ints[4] = {}; + GLfloat floats[4] = {}; + GLdouble doubles[4] = {}; + + MG_Impl::GLImpl::GetIntegeri_v(GL_SCISSOR_BOX, kMaxViewports, ints); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::GetFloati_v(GL_VIEWPORT, kMaxViewports, floats); + ExpectSingleGlError(GL_INVALID_VALUE); + MG_Impl::GLImpl::GetDoublei_v(GL_DEPTH_RANGE, kMaxViewports, doubles); + ExpectSingleGlError(GL_INVALID_VALUE); + + MG_Impl::GLImpl::GetIntegeri_v(GL_SCISSOR_BOX, kMaxViewports - 1, ints); + ExpectSingleGlError(GL_NO_ERROR); + MG_Impl::GLImpl::GetFloati_v(GL_VIEWPORT, kMaxViewports - 1, floats); + ExpectSingleGlError(GL_NO_ERROR); + MG_Impl::GLImpl::GetDoublei_v(GL_DEPTH_RANGE, kMaxViewports - 1, doubles); + ExpectSingleGlError(GL_NO_ERROR); +} diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp index b615e76a..9e28dca1 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp @@ -1000,7 +1000,11 @@ namespace MobileGL::MG_Util::BackendLoader { GLfloat smoothLineWidthRange[2] = {1.0f, 1.0f}; GLfloat smoothLineWidthGranularity = 1.0f; GLfloat aliasedPointSizeRange[2] = {1.0f, 1.0f}; - GLfloat viewportBoundsRange[2] = {0.0f, 0.0f}; + // GL 4.6 core table 23.60 sets the MINIMUM VIEWPORT_BOUNDS_RANGE at [-32768, 32767], and + // KHR-GL43.viewport_array.queries asserts exactly that floor. GLES has no such query, so + // the glGetFloatv below raises GL_INVALID_ENUM and leaves this untouched - starting it at + // {0, 0} advertised a range that admits no viewport origin at all. + GLfloat viewportBoundsRange[2] = {-32768.0f, 32767.0f}; GLint maxViewportDims[2] = {16384, 16384}; GLint viewportSubpixelBits = 0; GLint max3DTextureSize = 16384; @@ -1289,8 +1293,12 @@ namespace MobileGL::MG_Util::BackendLoader { caps.MaxViewports = maxViewports; caps.MaxViewportWidth = maxViewportDims[0]; caps.MaxViewportHeight = maxViewportDims[1]; - caps.ViewportBoundsRangeMin = viewportBoundsRange[0]; - caps.ViewportBoundsRangeMax = viewportBoundsRange[1]; + // Only ever WIDER than the core minimum: a driver that answered the query is allowed to + // exceed the floor but never to sit inside it, and a driver that rejected the query left + // the floor in place. Written as a clamp rather than a plain assignment so a partial + // write (one component answered, the other not) cannot narrow the range either. + caps.ViewportBoundsRangeMin = std::min(viewportBoundsRange[0], -32768.0f); + caps.ViewportBoundsRangeMax = std::max(viewportBoundsRange[1], 32767.0f); caps.ViewportSubpixelBits = viewportSubpixelBits; caps.MinFragmentInterpolationOffset = std::isfinite(minFragmentInterpolationOffset) && minFragmentInterpolationOffset <= -0.5f