mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-07 19:58:32 +09:00
[Feat, Test] (MG_Backend/DirectVulkan, MG_Test, MG_IntegrationTest): rasterize the viewport gl_ViewportIndex selects, instead of collapsing all sixteen onto viewport 0
This commit is contained in:
@@ -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<Uint32>(payload.viewportCount, 1u);
|
||||
vpci.scissorCount = vpci.viewportCount;
|
||||
|
||||
VkPipelineRasterizationStateCreateInfo raster{VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO};
|
||||
raster.polygonMode = payload.polygonMode;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
|
||||
const Vector<Vector<Uint>>& 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<Int>(createResult));
|
||||
entry.writesViewportIndexBuiltin = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ReflectedWritesViewportIndexBuiltin(reflectModule)) {
|
||||
entry.writesViewportIndexBuiltin = true;
|
||||
}
|
||||
spvReflectDestroyShaderModule(&reflectModule);
|
||||
}
|
||||
}
|
||||
|
||||
void ProgramFactory::ReflectFragmentOutputs(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
|
||||
const Vector<Vector<Uint>>& 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);
|
||||
|
||||
@@ -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<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
|
||||
const Vector<Vector<Uint>>& spirv,
|
||||
VkProgramObject& entry) const;
|
||||
void ReflectViewportIndexUsage(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
|
||||
const Vector<Vector<Uint>>& spirv,
|
||||
VkProgramObject& entry) const;
|
||||
void ReflectFragmentOutputs(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
|
||||
const Vector<Vector<Uint>>& spirv,
|
||||
VkProgramObject& entry) const;
|
||||
|
||||
@@ -437,12 +437,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
vkCmdSetScissor(commandBuffer, 0, 1, &scissor);
|
||||
}
|
||||
|
||||
static void ApplyGLViewportState(VkCommandBuffer commandBuffer,
|
||||
const IntVec2& framebufferExtent,
|
||||
VkSurfaceTransformFlagBitsKHR preTransform,
|
||||
Bool isDefaultFramebuffer) {
|
||||
const IntVec4& viewportState = MG_State::pGLContext->GetViewport();
|
||||
const FloatVec2& depthRange = MG_State::pGLContext->GetDepthRange();
|
||||
// One viewport of the ARB_viewport_array state, mapped into Vulkan's frame. Split out of
|
||||
// ApplyGLViewportState so the multi-viewport path derives index i through EXACTLY the same
|
||||
// arithmetic as index 0 - the default-framebuffer Y-flip and pre-transform rotation
|
||||
// especially, which is the classic way a multi-viewport port comes out upside down for every
|
||||
// index but the one that was tested.
|
||||
static VkViewport ComputeGLViewport(Uint32 index,
|
||||
const IntVec2& framebufferExtent,
|
||||
VkSurfaceTransformFlagBitsKHR preTransform,
|
||||
Bool isDefaultFramebuffer) {
|
||||
// Rounded to integers on purpose: MobileGL advertises GL_VIEWPORT_SUBPIXEL_BITS = 0, so
|
||||
// the fractional rectangle glViewportIndexedf can store is exact as STATE and snapped
|
||||
// when it rasterizes.
|
||||
const FloatVec4& stored = MG_State::pGLContext->GetViewportIndexed(index);
|
||||
const IntVec4 viewportState(static_cast<Int>(std::lround(stored.x())),
|
||||
static_cast<Int>(std::lround(stored.y())),
|
||||
static_cast<Int>(std::lround(stored.z())),
|
||||
static_cast<Int>(std::lround(stored.w())));
|
||||
const FloatVec2& depthRange = MG_State::pGLContext->GetDepthRangeIndexed(index);
|
||||
const IntVec2 logicalExtent = isDefaultFramebuffer
|
||||
? ResolveDefaultFramebufferLogicalExtent(preTransform, framebufferExtent)
|
||||
: framebufferExtent;
|
||||
@@ -477,6 +489,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
viewport.height = static_cast<float>(viewportHeight);
|
||||
viewport.minDepth = depthRange.x();
|
||||
viewport.maxDepth = depthRange.y();
|
||||
return viewport;
|
||||
}
|
||||
|
||||
static void ApplyGLViewportState(VkCommandBuffer commandBuffer,
|
||||
const IntVec2& framebufferExtent,
|
||||
VkSurfaceTransformFlagBitsKHR preTransform,
|
||||
Bool isDefaultFramebuffer) {
|
||||
const VkViewport viewport = ComputeGLViewport(0, framebufferExtent, preTransform, isDefaultFramebuffer);
|
||||
auto& shadow = g_dynamicStateShadow;
|
||||
if (shadow.viewportValid && shadow.viewport.x == viewport.x && shadow.viewport.y == viewport.y &&
|
||||
shadow.viewport.width == viewport.width && shadow.viewport.height == viewport.height &&
|
||||
@@ -4879,6 +4899,7 @@ void main() {
|
||||
.topology = vkTopology,
|
||||
.primitiveRestartEnable = primitiveRestartEnabled,
|
||||
.patchControlPoints = static_cast<Uint32>(MG_State::pGLContext->GetPatchVertices()),
|
||||
.viewportCount = ResolveDrawViewportCount(programObj.writesViewportIndexBuiltin),
|
||||
.polygonMode = effectivePolygonMode,
|
||||
.cullMode = cullFaceEnabled
|
||||
? MG_Util::ConvertCullFaceModeToVkEnum(MG_State::pGLContext->GetCullFaceMode(), invertClockwise)
|
||||
@@ -5321,9 +5342,71 @@ void main() {
|
||||
}
|
||||
|
||||
|
||||
void VulkanRenderer::ApplyDynamicDrawStateTail(FrameContext::FrameData& frame, const IntVec2& extent,
|
||||
Bool isDefaultFbo) {
|
||||
// The scissor rectangle Vulkan needs for ARB_viewport_array index `index`. Vulkan has no
|
||||
// per-viewport scissor-test TOGGLE - a scissor rectangle always applies - so an index whose
|
||||
// GL scissor test is disabled gets the whole framebuffer, which is exactly "the test always
|
||||
// passes" (GL 4.6 core 17.3.2).
|
||||
VkRect2D VulkanRenderer::ComputeGLScissorRect(Uint32 index, const IntVec2& extent,
|
||||
VkSurfaceTransformFlagBitsKHR preTransform,
|
||||
Bool isDefaultFbo) const {
|
||||
const auto& parameters = MG_State::pGLContext->GetRenderStateParameters();
|
||||
if ((parameters.ScissorTestEnabledMask & (1u << index)) == 0) {
|
||||
VkRect2D full{};
|
||||
full.offset = {0, 0};
|
||||
full.extent = {static_cast<Uint32>(extent.x()), static_cast<Uint32>(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<Uint32>(viewportCount, RenderStateParameters::MAX_VIEWPORTS);
|
||||
|
||||
Array<VkViewport, RenderStateParameters::MAX_VIEWPORTS> viewports{};
|
||||
Array<VkRect2D, RenderStateParameters::MAX_VIEWPORTS> scissors{};
|
||||
for (Uint32 i = 0; i < count; ++i) {
|
||||
viewports[i] = ComputeGLViewport(i, extent, preTransform, isDefaultFbo);
|
||||
scissors[i] = ComputeGLScissorRect(i, extent, preTransform, isDefaultFbo);
|
||||
}
|
||||
vkCmdSetViewport(commandBuffer, 0, count, viewports.data());
|
||||
vkCmdSetScissor(commandBuffer, 0, count, scissors.data());
|
||||
|
||||
auto& shadow = g_dynamicStateShadow;
|
||||
shadow.viewportValid = false;
|
||||
shadow.scissorValid = false;
|
||||
shadow.dynamicTailValid = false;
|
||||
}
|
||||
|
||||
void VulkanRenderer::ApplyDynamicDrawStateTail(FrameContext::FrameData& frame, const IntVec2& extent,
|
||||
Bool isDefaultFbo, Uint32 viewportCount) {
|
||||
auto& shadow = g_dynamicStateShadow;
|
||||
if (viewportCount > 1) {
|
||||
// The other five Apply* still run: blend constants, depth bias, line width and the
|
||||
// stencil masks are not per-viewport and a multi-viewport draw needs them just as
|
||||
// much. Only the viewport/scissor pair takes the array shape.
|
||||
ApplyBlendConstants(frame.commandBuffer);
|
||||
ApplyPolygonOffsetState(frame.commandBuffer);
|
||||
ApplyLineWidthState(frame.commandBuffer);
|
||||
ApplyStencilState(frame.commandBuffer);
|
||||
ApplyMultiViewportDynamicState(frame.commandBuffer, viewportCount, extent,
|
||||
m_swapchainObject.GetPreTransform(), isDefaultFbo);
|
||||
return;
|
||||
}
|
||||
// One compare for the whole tail: see the gate's declaration in
|
||||
// DynamicStateShadow for why (version, extent, default-FBO flag) pins every
|
||||
// input the six Apply* below read.
|
||||
@@ -5761,7 +5844,7 @@ void main() {
|
||||
const Bool idxUploadOk = UploadAndBindIndexBuffer(frame, vao, pIndexBufferView);
|
||||
MOBILEGL_ASSERT(idxUploadOk, "SetupDraw fast path: failed to upload index buffer");
|
||||
}
|
||||
ApplyDynamicDrawStateTail(frame, snap.renderPassExtent, snap.drawFboIsDefault);
|
||||
ApplyDynamicDrawStateTail(frame, snap.renderPassExtent, snap.drawFboIsDefault, snap.viewportCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6201,7 +6284,8 @@ void main() {
|
||||
MOBILEGL_ASSERT(idxUploadOk, "SetupDraw skipped: failed to upload index buffer");
|
||||
}
|
||||
|
||||
ApplyDynamicDrawStateTail(frame, renderPassEntry->extent, drawFbo->IsDefaultFramebuffer());
|
||||
ApplyDynamicDrawStateTail(frame, renderPassEntry->extent, drawFbo->IsDefaultFramebuffer(),
|
||||
ResolveDrawViewportCount(programObj.writesViewportIndexBuiltin));
|
||||
|
||||
// Snapshot the fully resolved configuration for the consecutive-draw
|
||||
// fast path (see TrySetupDrawFastPath).
|
||||
@@ -6220,6 +6304,7 @@ void main() {
|
||||
snap.drawFbo = drawFbo.get();
|
||||
snap.fboVersion = drawFbo->GetObjectVersion();
|
||||
snap.drawFboIsDefault = drawFboIsDefault;
|
||||
snap.viewportCount = ResolveDrawViewportCount(programObj.writesViewportIndexBuiltin);
|
||||
snap.renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion();
|
||||
snap.bindGeneration = MG_State::pGLContext->GetTextureBindGeneration();
|
||||
snap.baseTransformFlags = GetBaseTransformFlagsRaw(drawFboIsDefault);
|
||||
@@ -12216,6 +12301,28 @@ void main() {
|
||||
m_fillModeNonSolidFeatureEnabled = deviceFeatures.fillModeNonSolid == VK_TRUE;
|
||||
deviceFeatures.dualSrcBlend = supportedDeviceFeatures.dualSrcBlend;
|
||||
m_dualSrcBlendFeatureEnabled = deviceFeatures.dualSrcBlend == VK_TRUE;
|
||||
// ARB_viewport_array rasterization. Without multiViewport a pipeline may declare exactly
|
||||
// one viewport (VUID-VkPipelineViewportStateCreateInfo-viewportCount-01216), so a shader's
|
||||
// gl_ViewportIndex can only ever select viewport 0 and the other fifteen rectangles are
|
||||
// state with nowhere to go. The GL state stays 16 wide either way - GL 4.3 core requires
|
||||
// MAX_VIEWPORTS >= 16 and that is a frontend promise, not a device one; this gate decides
|
||||
// only whether a DRAW can rasterize into more than one of them.
|
||||
deviceFeatures.multiViewport = supportedDeviceFeatures.multiViewport;
|
||||
m_multiViewportFeatureEnabled = deviceFeatures.multiViewport == VK_TRUE;
|
||||
m_maxRasterizableViewports =
|
||||
m_multiViewportFeatureEnabled
|
||||
? std::min<Uint32>(RenderStateParameters::MAX_VIEWPORTS,
|
||||
std::max<Uint32>(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<Uint32>(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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <cmath>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#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<GLuint>(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<GLfloat> data(static_cast<size_t>(kViewportCount) * 4);
|
||||
for (int y = 0; y < kGridSide; ++y) {
|
||||
for (int x = 0; x < kGridSide; ++x) {
|
||||
const size_t base = static_cast<size_t>(y * kGridSide + x) * 4;
|
||||
data[base + 0] = static_cast<GLfloat>(x * cellW);
|
||||
data[base + 1] = static_cast<GLfloat>(y * cellH);
|
||||
data[base + 2] = static_cast<GLfloat>(cellW);
|
||||
data[base + 3] = static_cast<GLfloat>(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<char> log(static_cast<size_t>(length > 1 ? length : 1), '\0');
|
||||
glGetProgramInfoLog(program, static_cast<GLsizei>(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<char> log(static_cast<size_t>(length > 1 ? length : 1), '\0');
|
||||
glGetShaderInfoLog(shader, static_cast<GLsizei>(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<GLint> unwritten(static_cast<size_t>(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<GLint> ReadInts(int width, int height) {
|
||||
std::vector<GLint> pixels(static_cast<size_t>(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<GLint>& 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<size_t>(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<GLint> 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<unsigned char> pixels(static_cast<size_t>(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<size_t>(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<GLfloat> viewports(static_cast<size_t>(kViewportCount) * 4);
|
||||
std::vector<GLdouble> ranges(static_cast<size_t>(kViewportCount) * 2);
|
||||
for (int i = 0; i < kViewportCount; ++i) {
|
||||
viewports[static_cast<size_t>(i) * 4 + 0] = static_cast<GLfloat>(i);
|
||||
viewports[static_cast<size_t>(i) * 4 + 1] = 0.0f;
|
||||
viewports[static_cast<size_t>(i) * 4 + 2] = 1.0f;
|
||||
viewports[static_cast<size_t>(i) * 4 + 3] = 2.0f;
|
||||
ranges[static_cast<size_t>(i) * 2 + 0] = static_cast<GLdouble>(i) / 16.0;
|
||||
ranges[static_cast<size_t>(i) * 2 + 1] = 1.0 - static_cast<GLdouble>(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<GLfloat> pixels(static_cast<size_t>(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<float>(i) / 16.0f;
|
||||
const float far = 1.0f - static_cast<float>(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<size_t>(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<GLint> boxes(static_cast<size_t>(kViewportCount) * 4);
|
||||
for (int y = 0; y < kGridSide; ++y) {
|
||||
for (int x = 0; x < kGridSide; ++x) {
|
||||
const size_t base = static_cast<size_t>(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<GLint> 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<GLint> 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
|
||||
@@ -4,6 +4,7 @@ add_executable(
|
||||
PipelineQuirkTest
|
||||
PipelineQuirkTest.cpp
|
||||
PassthroughTessControlTest.cpp
|
||||
ViewportIndexReflectionTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(PipelineQuirkTest PRIVATE
|
||||
|
||||
@@ -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 <gtest/gtest.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Includes.h"
|
||||
#include "Init.h"
|
||||
|
||||
#include <MG_Backend/DirectVulkan/Renderer/ProgramFactory.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
|
||||
#include <MG_Util/ShaderTranspiler/Types.h>
|
||||
|
||||
#include <spirv_reflect.h>
|
||||
|
||||
using namespace MobileGL;
|
||||
using MobileGL::MG_Backend::DirectVulkan::ProgramFactory;
|
||||
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
|
||||
|
||||
namespace {
|
||||
|
||||
Vector<Uint32> 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<Uint32>& 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
|
||||
Reference in New Issue
Block a user