Compare commits

..
Author SHA1 Message Date
Swung0x48 cee17025a0 [Test] (MG_Test): pin the plain mutable-texture copy every new validator rule is a new way to reject 2026-08-13 03:17:27 -04:00
Swung0x48 9642ae4d20 [Fix, Test] (MG_Impl/Texture, MG_Backend, MG_Test): copy_image compatibility is texel-block size, and neither backend was told which slice to copy 2026-08-13 03:17:27 -04:00
swung0x48 595d140036 Merge branch "feat/cts-copyimage-magma-layers" into dev 2026-08-13 03:11:21 -04:00
swung0x48 b62d1f2078 [Fix, Test] (MG_Backend/DirectVulkan, MG_IntegrationTest): map a layered CopyImageSubData onto the axis each endpoint keeps its slices on, instead of copying slice 0 and calling it done 2026-08-13 02:49:57 -04:00
swung0x48 5e82ff968a Merge branch "feat/cts-rgb9e5-raw-transfer" into dev 2026-08-13 02:47:18 -04:00
swung0x48 6a80a82dd3 [Fix, Test] (MG_Util, MG_Backend/DirectGLES, MG_Backend/DirectVulkan, MG_Test): a packed texel whose client type already spells its storage word must cross glGetTexImage unencoded, or the RGB9_E5 shared exponent gets canonicalized 2026-08-13 02:41:37 -04:00
swung0x48 f9182a5ca3 Merge branch "fix/cts-copyimage-level-validation" into dev 2026-08-13 02:38:43 -04:00
swung0x48 f37b511fca [Fix, Test] (MG_Impl/GLImpl, MG_Backend/DirectVulkan, MG_Backend/DirectGLES, MG_Test, MG_IntegrationTest): a glCopyImageSubData to a mipmap level the texture never had is INVALID_VALUE, not a subresource handed to the driver 2026-08-13 02:28:55 -04:00
swung0x48 38027d21f8 Merge branch "feat/log-level-semantics" into dev 2026-08-13 02:19:26 -04:00
Swung0x48 dd2a62228f [Fix, Feat, Test] (MG_Util, MG_Backend, MG_Impl, MG_State): correct the log severity ordering and unwind the diagnostics it silenced 2026-08-13 02:02:49 -04:00
swung0x48 373aa44dd7 Merge branch "fix/cts-tess-ssbo-crash" into dev 2026-08-13 01:38:58 -04:00
swung0x48 96646df12e [Fix] (MG_Backend/DirectVulkan): review round - failure diagnostics take MGLOG_E, the once-per-patch-size success note takes MGLOG_D, and the per-draw refusal stays latched 2026-08-13 01:34:57 -04:00
swung0x48 f20b20e643 [Fix, Feat, Test] (MG_Backend/DirectVulkan, MG_Test): synthesize the pass-through tessellation control stage GL gives an evaluation-only program, and refuse the half-tessellated pipeline Mali dereferences null inside 2026-08-13 01:28:35 -04:00
swung0x48 2587814970 Merge branch "feat/cts-image-format-qualifier" into dev 2026-08-12 19:23:51 -04:00
swung0x48 43398e33e8 [Fix, Test] (MG_Util, MG_IntegrationTest): review round - anchor a clone only behind a real definition, decline the atomic-image shape, and say what the software lanes cannot falsify 2026-08-12 19:12:39 -04:00
swung0x48 b7557d6615 [Feat, Fix, Test] (MG_Util, MG_Backend/DirectGLES, MG_IntegrationTest): bake the bound image format into the ESSL a format-less image declaration needs 2026-08-12 18:56:31 -04:00
swung0x48 bce34d7fac Merge branch "feat/cts-followup-fp64-ds-imgbuf" into dev 2026-08-12 17:56:13 -04:00
swung0x48 f91857266f [Fix, Test] (MG_Backend/DirectGLES, MG_Test): the tessellation stages never got the flat qualifier an ES linker compares across both sides of every integer interface 2026-08-12 17:50:31 -04:00
swung0x48 49cb1be0fd [Fix, Test] (MG_Backend/DirectGLES, MG_IntegrationTest): a shader writing a buffer texture through an image unit left the CPU shadow stale, so every map and readback after it saw the old bytes 2026-08-12 17:50:31 -04:00
swung0x48 a51c68bb2c [Fix, Test] (MG_Util, MG_IntegrationTest): a 64-bit float constant was read as 32 bits, so every comparison against a round double became an epsilon test against zero 2026-08-12 17:50:25 -04:00
swung0x48 1c723a6cfc Merge branch "feat/cts-image-targets" into dev 2026-08-12 16:52:34 -04:00
swung0x48 44805bfa07 [Fix, Test] (MG_Util, MG_Backend/DirectVulkan, MG_IntegrationTest): adversarial review - the rewritten 1D-array image collided with a module's own 2D-array one and left an invalid duplicate type; pin the component order, keep the subject kinds in a truncated matrix 2026-08-12 16:49:59 -04:00
swung0x48 257fcbfd0b [Fix, Test] (MG_Util, MG_Backend/DirectVulkan, MG_IntegrationTest): review round - one module parse for the shaders with no 1D-array image, per-kind values the combined case can name, and the invariants two shared buffers rest on 2026-08-12 16:43:29 -04:00
swung0x48 2b46a3db96 [Fix, Test] (MG_Util, MG_Backend/DirectGLES, MG_Backend/DirectVulkan): the two image target kinds a compute dispatch could not read - 1D-array on ES, imageBuffer on Vulkan 2026-08-12 16:11:10 -04:00
swung0x48 0b36621069 Merge branch "feat/cts-diag-wave" into dev 2026-08-12 15:33:43 -04:00
swung0x48 9ee2e0a1db [Fix] (MG_Backend/DirectGLES): re-push the texture parameters a regenerated driver texture has lost, instead of trusting caches that outlived it 2026-08-12 15:22:39 -04:00
swung0x48 6b6623ae72 [Fix, Test] (MG_Backend, MG_Impl, MG_State): advertise the uniform-block and stencil-texturing strings a 4.0 context hides behind, forward DEPTH_STENCIL_TEXTURE_MODE to both backends, and stop a reserved transform feedback name passing for an object 2026-08-12 15:12:01 -04:00
swung0x48 a6e029734b [Test, Doc] (MG_IntegrationTest, MG_Backend/DirectGLES): review wave - stop the skip paths leaking their GL objects, normalise the clip enables a neighbouring scenario leaves behind, and state what the software lanes cannot falsify 2026-08-12 13:57:32 -04:00
swung0x48 e7d6bfddac [Fix, Test] (MG_Backend/DirectGLES, MG_State, MG_Util): publish the real default framebuffer depth/stencil format, forward the clip distance enables, and stop trusting a stale format probe for non-2D depth attachments 2026-08-12 13:34:39 -04:00
swung0x48 f2c879528f Merge branch "feat/es-ds-readback" into dev 2026-08-12 12:12:49 -04:00
Swung0x48 eaba4ac1dc [Fix, Docs] (MG_Backend/DirectGLES): drain the attachment-format probe, and document the depth/stencil readback emulation switch 2026-08-12 12:09:45 -04:00
Swung0x48 ed6578954e [Feat, Fix, Test] (MG_Backend/DirectGLES, MG_IntegrationTest): emulate ES depth/stencil readback by shader sampling, and stop the replicate blit leaking its borrowed texture unit 2026-08-12 11:38:34 -04:00
swung0x48 e005c8b6cb [Test] (tools/trace_replay): drop the minecraft-26.2-main-menu fixture 2026-08-12 11:07:38 -04:00
swung0x48 535b5e3095 Merge branch "feat/cts-xfb-respec-compressed" into dev 2026-08-12 10:35:09 -04:00
swung0x48 1b05a84928 [Fix, Test] (MG_State, MG_Backend, MG_Impl, MG_Test): review wave - hand the mapping back instead of renewing it, retire an immutable ES store on respecify, and tag glTexStorage2D levels too 2026-08-12 10:32:39 -04:00
swung0x48 7ccb762936 [Fix, Test] (MG_Impl, MG_Test): a persistently mapped unpack buffer is a legal source for the compressed uploads too 2026-08-12 09:24:57 -04:00
swung0x48 442e7eec1c [Docs] (MG_IntegrationTest): the capture-buffer note described a bug that is fixed and pinned elsewhere now 2026-08-12 09:22:17 -04:00
swung0x48 2787d15706 [Fix] (MG_Impl): the compressed sub-image bounds check added two application-supplied ints 2026-08-12 09:19:50 -04:00
swung0x48 74ce58a6c7 [Fix] (MG_Backend/DirectGLES): a declined map renewal left the respecify pointing at immutable storage 2026-08-12 09:17:54 -04:00
swung0x48 bb122ebd4f [Test] (MG_IntegrationTest): the DSA enable-disable-attributes case, reassembled where it can be stepped through 2026-08-12 08:56:13 -04:00
swung0x48 9b0ed5b3af [Fix, Feat, Test] (MG_Impl, MG_Test): a specific compressed internalformat now tags the level it defines, and the compressed sub-image entry points stopped being stubs 2026-08-12 08:52:01 -04:00
swung0x48 0e31c1481b [Fix, Test] (MG_State, MG_Backend/DirectVulkan, MG_Backend/DirectGLES, MG_IntegrationTest): a respecified capture buffer left the transform feedback writing one store and the readback reading another 2026-08-12 08:42:45 -04:00
swung0x48 811f32760e [CI] (test): gate the retrace pipeline on the integration lane - build-retrace and trace-cases now need it 2026-08-12 08:41:19 -04:00
swung0x48 01381a0404 Merge branch "feat/cts-baseinstance-dsa-bufstorage" into dev 2026-08-12 08:23:57 -04:00
swung0x48 0f02b0fdb1 [Fix] (MG_Util): the POST base-instance row described an emulation that no longer exists 2026-08-12 08:22:26 -04:00
swung0x48 3be02abf47 [Fix] (MG_Impl): multisample renderbuffers ignored the integer formats own sample limit 2026-08-12 08:17:11 -04:00
swung0x48 f91d6b676c [Fix] (MG_Impl): the per-stage atomic-counter-buffer limits answered GL_INVALID_ENUM instead of their zero 2026-08-12 08:12:32 -04:00
swung0x48 1ebf191f94 [Fix, Test] (MG_Backend/DirectGLES, MG_Util): baseInstance reached the shader but never the vertex fetch 2026-08-12 08:12:27 -04:00
swung0x48 ccad803023 Merge branch "feat/cts-vab-current-attrib" into dev 2026-08-12 07:40:47 -04:00
swung0x48 21159caf31 [Fix, Test] (MG_Util, MG_Backend/DirectGLES): transform feedback captured nothing at all from an interface block 2026-08-12 07:38:50 -04:00
swung0x48 ea4819a21d [Fix, Test] (MG_State, MG_IntegrationTest): an array vertex input occupies one location per element, not one location in total 2026-08-12 07:36:12 -04:00
swung0x48 6b882b3ccf Merge branch "feat/fp64-demote-to-fp32" into dev 2026-08-12 06:42:23 -04:00
swung0x48 96bd36c50b [Docs] (README): the MOBILEGL_ADVERTISE_FP64 switch 2026-08-12 06:41:54 -04:00
swung0x48 46fbd837b3 [Fix, Test] (MG_State, MG_Impl, MG_IntegrationTest): a double uniform initializer no longer reads zero 2026-08-12 06:41:54 -04:00
swung0x48 796a57a115 [Feat, Test] (MG_Backend, MG_Util, MG_Impl): report the fp64 tier at startup, decline 64-bit vertex formats everywhere, advertise GL_ARB_gpu_shader_fp64 only on request 2026-08-12 06:25:35 -04:00
swung0x48 62a2dae5ba [Fix, Test] (MG_Impl, MG_State, MG_IntegrationTest): glUniform*d stores what the demoted shader reads 2026-08-12 06:20:11 -04:00
swung0x48 532836c058 [Feat, Test] (MG_Util): demote every 64-bit float in a shader to 32 bits, with the block layout re-derived 2026-08-12 06:11:50 -04:00
swung0x48 2fced2241b [Fix, Test] (MG_Util, MG_State, MG_Backend): the GL 4.3 vertex binding model - array vertex inputs, zero binding strides, instance divisors, and formats ES refuses 2026-08-12 05:36:00 -04:00
swung0x48 21b5fc2d92 Merge branch 'feat/cts-draw-parameters' into dev 2026-08-12 04:24:54 -04:00
swung0x48 1f753ab5fa [Test] (MG_IntegrationTest): the draw-parameter builtins, read back out of the shader the draw produced 2026-08-12 04:18:08 -04:00
swung0x48 3ed9501be5 [Fix, Feat, Test] (MG_Backend/DirectGLES, MG_Test): the draw-parameter builtins never reached the draws that carry them, and glMultiDrawArraysIndirectCount had no backend at all 2026-08-12 04:18:08 -04:00
swung0x48 7311251f30 [Fix] (MG_Util, MG_Backend/DirectVulkan): GL reads gl_BaseVertex as zero on a non-indexed draw where Vulkan's builtin hands over firstVertex 2026-08-12 04:18:07 -04:00
swung0x48 7625cf450d [Fix, Test] (MG_Impl, MG_IntegrationTest): a block count may not exceed its binding points, and an atomic-counter buffer size must survive division by them 2026-08-12 02:38:04 -04:00
swung0x48 450eb209b6 [Fix, Test] (MG_State, MG_Impl): the program interface of a separable program is its own first and last stage, not vertex and fragment 2026-08-12 02:38:04 -04:00
swung0x48 8c5c39b3c3 [Feat, Test] (MG_Impl): glBindTextures and glBindImageTextures were no-op stubs while GL_ARB_multi_bind was advertised 2026-08-12 02:38:03 -04:00
swung0x48 64a0ea397c [Perf] (CI): rewrite one rolling ccache entry per job on the default branch instead of saving one per run 2026-08-12 01:56:17 -04:00
swung0x48 205d837942 [Fix, Test] (MG_Backend/DirectGLES, MG_Util, CI): keep an explicit LOD constant under a new avoid flag - folding the bias uniform into it kills the ANGLE llvmpipe JIT 2026-08-12 01:42:55 -04:00
swung0x48 b5e9339c66 [Fix] (MG_Util): the unsupported-buffer-textures log tells a too-old driver apart from one whose entry point failed to resolve 2026-08-12 01:24:41 -04:00
swung0x48 e310e3e9ff [Fix, Test] (MG_Util, MG_Backend/DirectGLES, MG_Test): resolve buffer textures through the entry point the tier ships, not the ES 3.2 core name; bound the OES retarget to an exact extension name 2026-08-12 01:24:40 -04:00
swung0x48 a0bf4a83bc [Fix, Test] (MG_Util, MG_Backend/DirectGLES, MG_Test, MG_IntegrationTest): detect buffer-texture support, emit the directive the driver advertises, and name the capability when it is missing 2026-08-12 01:24:40 -04:00
swung0x48 97facf777b [Fix] (MG_Backend/DirectVulkan): act on a failed binding remap, bound the storage-block array count, and correct the decline rationale review found describing the reverted mechanism 2026-08-12 01:09:56 -04:00
swung0x48 5cfbb716c0 Merge remote-tracking branch 'origin/dev' into feat/sampler-array-descriptors 2026-08-12 00:58:20 -04:00
swung0x48 64c3411d70 [Perf] (CI): restore unchanged trace fixtures from the Actions cache keyed on their Git LFS pointer oid, downloading only on a miss 2026-08-12 00:49:47 -04:00
swung0x48 f3a0d9e0a3 [Refactor] (CI): move the trace fixture LFS pointer and verification helpers into a shared library 2026-08-12 00:49:47 -04:00
swung0x48 068786e812 [Fix, Test] (MG_Backend/DirectVulkan, MG_IntegrationTest): a declined descriptor must refuse the draw, not just leave the layout - a multi-dimensional sampler array otherwise faulted in the shader 2026-08-12 00:45:36 -04:00
swung0x48 8e7cc62c24 [Test] (MG_Benchmark): a standing UnorderedMap observatory - the container's own shapes, measured through the alias so a future swap re-runs the same suite 2026-08-12 00:40:57 -04:00
swung0x48 d2a36d65a3 [Fix, Test] (MG_Backend/DirectVulkan, MG_IntegrationTest): a sampler uniform array is one binding with many descriptors too - Magma wrote only element zero, and declines the multi-dimensional shape loudly 2026-08-12 00:31:37 -04:00
swung0x48 a020de76e3 Merge remote-tracking branch 'origin/dev' into feat/sampler-array-descriptors 2026-08-12 00:18:51 -04:00
swung0x48 574634adfa [Fix, Test] (MG_Backend/DirectVulkan, MG_IntegrationTest): let the binding remap accept the descriptor arrays that now have per-element paths, and reserve image-info scratch for them 2026-08-12 00:14:18 -04:00
swung0x48 e71d715e1a [Fix, Test] (MG_Backend/DirectVulkan, MG_Util, MG_Test): VkProgramObject move dropped stageSpirvDigests - a robin-hood swap would pair one program's modules with another's digests; sanity tests assert key identity, not iteration counts 2026-08-12 00:11:56 -04:00
swung0x48 7b946fd527 [Fix] (MG_Backend/DirectVulkan, MG_Util): RenderPassEntry needs move assignment - robin-hood probing swaps entries, so mapped types must be swappable 2026-08-12 00:11:55 -04:00
swung0x48 8f3ce5f5b7 [Fix] (MG_Backend/DirectGLES, MG_State, Docs): CopyImageSubData held a registry reference across a re-entrant sync; retarget container rationales at the new erase contract 2026-08-12 00:11:54 -04:00
swung0x48 21ec744ef2 [Refactor] (MG_Util, MG_Test): retire FastSTL for ska::flat_hash_map, the table MobileGlues settled on 2026-08-12 00:11:54 -04:00
swung0x48 faa7b17da3 [Test] (tools/trace_replay): drop the minecraft-26.2-in-world fixture 2026-08-12 00:03:03 -04:00
swung0x48 f6849fc0b3 [Fix, Test] (MG_Backend/DirectVulkan, MG_IntegrationTest): an image uniform array is one binding with many descriptors, not one - Magma wrote only element zero and left the rest undefined 2026-08-11 23:49:31 -04:00
swung0x48 4d1d4f6225 [Fix] (DirectGLES): report a rejected shader and a failed link at MGLOG_I - MGLOG_E is compiled out of every INFO build, which is why the retrace artifact could not say which shader the driver refused 2026-08-11 23:39:48 -04:00
swung0x48 cef81df73f [Fix, Test] (ShaderTranspiler, DirectGLES): fold or lower dynamically indexed fragment outputs before ESSL emission - GLSL ES requires constant integral indices, so the OIT coefficient shader linked nothing on ANGLE and every translucent draw was a silent no-op 2026-08-11 23:39:03 -04:00
swung0x48 c4e6ea1f23 [Docs] (tools/trace_replay): strip investigation logs from the README - usage and procedures only 2026-08-11 23:25:20 -04:00
swung0x48 b4e07ce651 [Fix, Test] (MG_State, MG_Backend/DirectGLES, MG_Test): close three holes review found in the composite uniform mirror - unrecorded stage programs, byte-identical writes, and Espryt's baked image units 2026-08-11 23:19:22 -04:00
swung0x48 ff324057ad [Fix, Perf, Test] (MG_State, MG_Impl/GLImpl, MG_Test, MG_IntegrationTest): a pipeline composite mirrors only the uniforms a stage was written to, and survives a sampler or block rebinding 2026-08-11 23:11:13 -04:00
swung0x48 ef4c6dbe0a Merge branch 'feat/cts-420pack-wave' into dev 2026-08-11 23:07:45 -04:00
swung0x48 19fc7346c5 [Fix] (MG_Backend/DirectVulkan): a re-entrant cache lookup dangled the reference glShaderStorageBlockBinding then wrote through 2026-08-11 23:05:21 -04:00
swung0x48 bcb0e894ef [Fix, Test] (MG_State, MG_Backend/DirectGLES, MG_Util, MG_IntegrationTest, MG_Test): array layout(binding=N) elements bind consecutively, a read+write image reaches ESSL legally, compute local_size comes from the linked intermediate, and glShaderStorageBlockBinding is baked into the generated source 2026-08-11 23:05:21 -04:00
swung0x48 794c10e56c [Fix] (3rdparty/glslang): bump the fork to the invariant-placement and atomic-counter-lowering patches 2026-08-11 23:05:20 -04:00
swung0x48 28390667d7 [Docs] (tools/trace_replay): sundial-lite's sampler state is identical on both stacks - the crash is a driver defect, not a dangling binding 2026-08-11 23:04:25 -04:00
swung0x48 90dd9bec77 [Docs] (tools/trace_replay): sundial-lite crashes in llvmpipe's JIT on a NULL per-texture-unit descriptor 2026-08-11 22:54:50 -04:00
swung0x48 7994ca31d3 [Docs] (tools/trace_replay): sundial-lite has no output-array indexing - separate cause from improved-transparency, and it reproduces locally 2026-08-11 22:48:51 -04:00
swung0x48 5705e05156 [Docs] (tools/trace_replay): sundial-lite kills the emulator via a host llvmpipe segfault, not OOM 2026-08-11 22:38:14 -04:00
swung0x48 ab62f81545 [Docs] (tools/trace_replay): the local farm ANGLE is not the CI ANGLE - re-attribute the improved-transparency causes 2026-08-11 22:32:14 -04:00
swung0x48 f6cf04d6d7 [Docs] (tools/trace_replay): name the two generated-ESSL defects that empty the improved-transparency lane on ANGLE 2026-08-11 22:25:57 -04:00
swung0x48 38eb9589f9 [Test] (tools/trace_replay): dump every live framebuffer attachment at a call boundary 2026-08-11 22:25:57 -04:00
swung0x48 99ebf67a3d [Fix] (CMake): the Android API guard holds a floor of 26, not an exact pin - higher platforms may configure, the minSdk-26 gradle build enforces API usage 2026-08-11 22:08:54 -04:00
swung0x48 2292e99476 Merge remote-tracking branch 'origin/dev' into dev 2026-08-11 21:59:25 -04:00
swung0x48 4c5afecc71 Merge branch 'fix/drop-android-api-pin' into dev 2026-08-11 21:59:05 -04:00
swung0x48 1c5744f2be [Fix] (MobileGL, Build): drop the __ANDROID_API__ source pin; hold API 26 in CMake instead 2026-08-11 21:58:30 -04:00
swung0x48 22859b0958 [Docs] (tools/trace_replay): localise the improved-transparency ANGLE divergence to the OIT composite 2026-08-11 21:55:40 -04:00
swung0x48 7aa958fbc9 [Docs] (tools/trace_replay): reproduce the Android DirectGLES lane on Linux, and pin improved-transparency below MobileGL 2026-08-11 21:43:56 -04:00
swung0x48 a4bd4e04a1 Merge branch 'fix/ssbo-rc13' into dev 2026-08-11 21:11:38 -04:00
swung0x48 09459edb6b [Fix] (CI, android-plugin): an ANGLE window surface lost before init finishes is infrastructure, not a trace failure 2026-08-11 21:00:31 -04:00
swung0x48 8af6ebc174 [Fix] (MG_State): seed uniform initializers through the phase-A gate, not off the raw artifact block 2026-08-11 20:59:25 -04:00
swung0x48 577cd8c670 [Fix] (3rdparty/glslang): bump the fork to the uniform-initializer preservation patch 2026-08-11 20:56:59 -04:00
swung0x48 5267243404 [Test] (MG_IntegrationTest): pin declared uniform initializers end to end, on both backends 2026-08-11 20:56:07 -04:00
swung0x48 33c2715912 [Fix] (MG_State, MG_Util): a default-block uniform starts at its declared initializer instead of zero 2026-08-11 20:54:14 -04:00
swung0x48 3068cdadf8 [Test] (CI): keep the emulator log of the attempt that lost the emulator, and record host memory pressure 2026-08-11 20:51:54 -04:00
swung0x48 6dd0201bf2 [Fix, Test] (MG_Impl/GLImpl): an indexed buffer SIZE query reports the range that was asked for, not what the buffer currently holds 2026-08-11 20:43:28 -04:00
swung0x48 05bef7118b [Fix, Test] (MG_State): a pipeline draw uses the block bindings its stage programs were given, not the ones their shaders declared 2026-08-11 20:23:06 -04:00
swung0x48 94e75fef79 [Fix, Test] (MG_State, MG_Impl): glIsProgramPipeline answers for the first bind, not for the materialization every pipeline command now does 2026-08-11 20:10:18 -04:00
swung0x48 43bcd03dca [Fix] (MG_Backend/DirectVulkan, MG_Impl): a malformed graphics pipeline shape skips the draw instead of faulting inside the driver 2026-08-11 20:10:18 -04:00
swung0x48 2ce0595fab [Fix, Test] (MG_State, MG_Impl, MG_Backend): a program pipeline's compute stage is dispatched on its own, and the graphics composite draws its stage programs' uniform values 2026-08-11 20:10:17 -04:00
swung0x48 ba9af18033 [Test] (MG_IntegrationTest): park the two program-pipeline cases whose mechanisms are still open 2026-08-11 20:10:17 -04:00
swung0x48 f7d63f88fa [Fix, Test] (MG_State, MG_Impl): a reserved program-pipeline name takes state from UseProgramStages and its siblings instead of rejecting them 2026-08-11 20:10:16 -04:00
swung0x48 6cf5a7744e [Test] (MG_IntegrationTest): separable programs drawn through a program pipeline object 2026-08-11 20:10:16 -04:00
swung0x48 ca3d24f5ea [Fix] (CI, android-plugin): keep a failed Android retrace's result artifact, and echo the logs a failed replay already wrote 2026-08-11 19:30:38 -04:00
183 changed files with 27652 additions and 1640 deletions
+4 -51
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=trace-fixture-lib.sh
. "${script_dir}/trace-fixture-lib.sh"
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
echo "usage: $0 <trace-case> [fixture-dir]" >&2
exit 2
@@ -62,57 +66,6 @@ if [ "${case_name}" = "OpenRA" ]; then
exit 0
fi
get_lfs_metadata() {
local file="$1"
local pointer
local expected_oid
local expected_size
if ! pointer="$(git show "HEAD:${file}" 2>/dev/null)"; then
echo "failed to read tracked fixture metadata: ${file}" >&2
return 1
fi
if ! grep -q '^version https://git-lfs.github.com/spec/v1$' <<< "${pointer}"; then
echo "tracked fixture is not a Git LFS pointer: ${file}" >&2
return 1
fi
expected_oid="$(awk '$1 == "oid" && $2 ~ /^sha256:/ { sub(/^sha256:/, "", $2); print $2 }' <<< "${pointer}")"
expected_size="$(awk '$1 == "size" { print $2 }' <<< "${pointer}")"
if ! [[ "${expected_oid}" =~ ^[0-9a-f]{64}$ ]] || ! [[ "${expected_size}" =~ ^[0-9]+$ ]]; then
echo "invalid Git LFS pointer metadata: ${file}" >&2
return 1
fi
printf '%s %s\n' "${expected_oid}" "${expected_size}"
}
verify_fixture_file() {
local downloaded_file="$1"
local display_name="$2"
local expected_oid="$3"
local expected_size="$4"
local actual_oid
local actual_size
if [ ! -f "${downloaded_file}" ]; then
echo "fixture file is missing: ${display_name}" >&2
return 1
fi
actual_size="$(wc -c < "${downloaded_file}" | tr -d '[:space:]')"
if [ "${actual_size}" != "${expected_size}" ]; then
echo "fixture size mismatch for ${display_name}: expected ${expected_size}, got ${actual_size}" >&2
return 1
fi
actual_oid="$(sha256sum "${downloaded_file}" | awk '{ print $1 }')"
if [ "${actual_oid}" != "${expected_oid}" ]; then
echo "fixture SHA-256 mismatch for ${display_name}: expected ${expected_oid}, got ${actual_oid}" >&2
return 1
fi
}
fetch_file_from_mirror() {
local file="$1"
local url="$2"
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Cache-side helper for trace fixtures.
#
# key <case> [fixture-dir] derive the actions/cache key and path list
# verify <case> [fixture-dir] check restored fixtures against their pointers
# reset <case> [fixture-dir] drop restored fixtures, leaving the pointers
#
# The cache key is content-addressed on the Git LFS pointer oids tracked at
# HEAD, which are readable from a plain checkout without smudging. Fixture
# content therefore maps 1:1 onto a key: unchanged content hits, changed
# content is a new key and thus a miss, and the download path handles it. The
# key deliberately carries no restore-keys prefix in the workflow - a fixture
# that does not match the pointer exactly must never be restored.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=trace-fixture-lib.sh
. "${script_dir}/trace-fixture-lib.sh"
# Bump when the key derivation changes in a way that must invalidate old
# entries; the content digest alone would not notice a format change.
key_schema="v1"
if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
echo "usage: $0 <key|verify|reset> <trace-case> [fixture-dir]" >&2
exit 2
fi
command_name="$1"
case_name="$2"
fixture_dir="${3:-tools/trace_replay/fixtures}"
python_bin="${PYTHON:-python3}"
if ! command -v "${python_bin}" >/dev/null 2>&1 && command -v python >/dev/null 2>&1; then
python_bin=python
fi
mapfile -t files < <(trace_fixture_files "${case_name}" "${fixture_dir}" "${python_bin}")
if [ "${#files[@]}" -eq 0 ]; then
echo "no fixture files declared for trace case: ${case_name}" >&2
exit 1
fi
# Writes "name=value" to $GITHUB_OUTPUT when running under Actions, and to
# stdout otherwise so the script stays runnable (and testable) off-CI.
emit_output() {
local name="$1"
local value="$2"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
if [[ "${value}" == *$'\n'* ]]; then
local delimiter="ghadelim_$(date +%s%N)_$$"
{
printf '%s<<%s\n' "${name}" "${delimiter}"
printf '%s\n' "${value}"
printf '%s\n' "${delimiter}"
} >> "${GITHUB_OUTPUT}"
else
printf '%s=%s\n' "${name}" "${value}" >> "${GITHUB_OUTPUT}"
fi
fi
printf '%s=%s\n' "${name}" "${value}"
}
sanitize_case() {
printf '%s' "$1" | sed 's/[^A-Za-z0-9._-]/_/g'
}
case "${command_name}" in
key)
manifest=""
for file in "${files[@]}"; do
# A case whose fixtures are committed directly rather than through Git LFS
# (OpenRA) has no pointer oid to key on, and nothing to download either.
# Report it as uncacheable so the workflow skips the cache entirely.
if ! metadata="$(get_lfs_metadata "${file}" 2>/dev/null)"; then
echo "trace case ${case_name} is not stored in Git LFS; skipping fixture cache" >&2
emit_output "cacheable" "false"
emit_output "key" ""
exit 0
fi
read -r expected_oid expected_size <<< "${metadata}"
manifest+="$(basename "${file}") ${expected_oid} ${expected_size}"$'\n'
done
digest="$(printf '%s' "${manifest}" | sha256sum | awk '{ print substr($1, 1, 16) }')"
safe_case="$(sanitize_case "${case_name}")"
emit_output "cacheable" "true"
emit_output "key" "trace-fixture-${key_schema}-${safe_case}-${digest}"
emit_output "paths" "$(printf '%s\n' "${files[@]}")"
;;
verify)
for file in "${files[@]}"; do
metadata="$(get_lfs_metadata "${file}")"
read -r expected_oid expected_size <<< "${metadata}"
verify_fixture_file "${file}" "${file}" "${expected_oid}" "${expected_size}"
done
echo "Verified ${#files[@]} fixture file(s) for ${case_name} against the tracked Git LFS pointers."
;;
reset)
# Put the working tree back to the pointer files a fresh checkout would
# have, so that a rejected cache entry falls through to exactly the same
# download path a cache miss takes.
for file in "${files[@]}"; do
rm -f "${file}" "${file}.tmp"
done
git checkout -- "${files[@]}"
echo "Reset ${#files[@]} fixture file(s) for ${case_name} to their tracked Git LFS pointers."
;;
*)
echo "unknown command: ${command_name}" >&2
exit 2
;;
esac
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Shared helpers for trace-fixture handling: reading the in-tree Git LFS pointer
# metadata and verifying a fixture file against it. Sourced by
# fetch-trace-fixture-lfs.sh (verify after download) and by
# trace-fixture-cache.sh (cache key derivation and verify after cache restore),
# so both paths agree on what a valid fixture is.
# Reads the Git LFS pointer tracked at HEAD for a fixture path and prints
# "<oid> <size>". Fails if the tracked blob is not a well-formed LFS pointer.
get_lfs_metadata() {
local file="$1"
local pointer
local expected_oid
local expected_size
if ! pointer="$(git show "HEAD:${file}" 2>/dev/null)"; then
echo "failed to read tracked fixture metadata: ${file}" >&2
return 1
fi
if ! grep -q '^version https://git-lfs.github.com/spec/v1$' <<< "${pointer}"; then
echo "tracked fixture is not a Git LFS pointer: ${file}" >&2
return 1
fi
expected_oid="$(awk '$1 == "oid" && $2 ~ /^sha256:/ { sub(/^sha256:/, "", $2); print $2 }' <<< "${pointer}")"
expected_size="$(awk '$1 == "size" { print $2 }' <<< "${pointer}")"
if ! [[ "${expected_oid}" =~ ^[0-9a-f]{64}$ ]] || ! [[ "${expected_size}" =~ ^[0-9]+$ ]]; then
echo "invalid Git LFS pointer metadata: ${file}" >&2
return 1
fi
printf '%s %s\n' "${expected_oid}" "${expected_size}"
}
# Checks an on-disk fixture against the size and SHA-256 from its LFS pointer.
verify_fixture_file() {
local downloaded_file="$1"
local display_name="$2"
local expected_oid="$3"
local expected_size="$4"
local actual_oid
local actual_size
if [ ! -f "${downloaded_file}" ]; then
echo "fixture file is missing: ${display_name}" >&2
return 1
fi
actual_size="$(wc -c < "${downloaded_file}" | tr -d '[:space:]')"
if [ "${actual_size}" != "${expected_size}" ]; then
echo "fixture size mismatch for ${display_name}: expected ${expected_size}, got ${actual_size}" >&2
return 1
fi
actual_oid="$(sha256sum "${downloaded_file}" | awk '{ print $1 }')"
if [ "${actual_oid}" != "${expected_oid}" ]; then
echo "fixture SHA-256 mismatch for ${display_name}: expected ${expected_oid}, got ${actual_oid}" >&2
return 1
fi
}
# Prints the fixture file paths of a trace case, one per line. Strips CR so the
# result is usable when python emits CRLF (Git Bash on Windows).
trace_fixture_files() {
local case_name="$1"
local fixture_dir="$2"
local python_bin="${3:-python3}"
"${python_bin}" tools/trace_replay/trace_cases.py \
--format fixture-files \
--case "${case_name}" \
--fixture-root "${fixture_dir}" | tr -d '\r'
}
+111 -8
View File
@@ -11,6 +11,9 @@ on:
jobs:
build:
runs-on: ubuntu-latest
permissions:
actions: write
contents: read
env:
CCACHE_BASEDIR: ${{ github.workspace }}
CCACHE_COMPRESS: "true"
@@ -41,12 +44,11 @@ jobs:
gradle-version: 8.10.2
- name: Restore ccache
uses: actions/cache@v5
uses: actions/cache/restore@v5
with:
path: .ccache
key: ${{ runner.os }}-apk-${{ github.job }}-ccache-${{ github.ref_name }}-${{ github.run_id }}
key: ${{ runner.os }}-apk-${{ github.job }}-ccache-v1
restore-keys: |
${{ runner.os }}-apk-${{ github.job }}-ccache-${{ github.ref_name }}-
${{ runner.os }}-apk-${{ github.job }}-ccache-
- name: Install ccache
@@ -125,6 +127,28 @@ jobs:
if: always()
run: ccache --show-stats
# Rewrite one rolling entry per job on the default branch. The upload stays
# cumulative - it carries every object restored at the top of this run plus
# the few TUs that actually changed - but Actions cache keys are immutable,
# so the superseded blob has to be released before the same key can be
# re-uploaded. Running after the build means a failed build leaves the
# existing entry untouched. The other trigger branches restore this entry
# rather than each writing a ~4 GB one of their own.
- name: Release superseded ccache entry
if: github.ref_name == github.event.repository.default_branch
env:
GH_TOKEN: ${{ github.token }}
CACHE_KEY: ${{ runner.os }}-apk-${{ github.job }}-ccache-v1
run: gh cache delete "${CACHE_KEY}" || true
- name: Save ccache
if: github.ref_name == github.event.repository.default_branch
continue-on-error: true
uses: actions/cache/save@v5
with:
path: .ccache
key: ${{ runner.os }}-apk-${{ github.job }}-ccache-v1
- name: Verify APK metadata and packaging
run: |
AAPT2="$(find "$ANDROID_HOME/build-tools" -name aapt2 -type f | sort -V | tail -n 1)"
@@ -201,9 +225,41 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v6
- name: Derive trace fixture cache key
id: fixture-key
run: bash .github/scripts/trace-fixture-cache.sh key '${{ matrix.case }}'
- name: Restore trace fixture cache
id: fixture-cache
if: steps.fixture-key.outputs.cacheable == 'true'
uses: actions/cache/restore@v5
with:
path: ${{ steps.fixture-key.outputs.paths }}
key: ${{ steps.fixture-key.outputs.key }}
- name: Verify restored trace fixture
id: fixture-verify
if: steps.fixture-cache.outputs.cache-hit == 'true'
run: |
if bash .github/scripts/trace-fixture-cache.sh verify '${{ matrix.case }}'; then
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::warning::Cached fixture for ${{ matrix.case }} failed verification; falling back to the download path"
bash .github/scripts/trace-fixture-cache.sh reset '${{ matrix.case }}'
fi
- name: Fetch trace fixture
if: steps.fixture-verify.outputs.ok != 'true'
run: bash .github/scripts/fetch-trace-fixture-lfs.sh '${{ matrix.case }}'
- name: Save trace fixture cache
if: steps.fixture-key.outputs.cacheable == 'true' && steps.fixture-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: ${{ steps.fixture-key.outputs.paths }}
key: ${{ steps.fixture-key.outputs.key }}
- name: Stage trace fixture
run: |
safe_case="$(printf '%s' '${{ matrix.case }}' | sed 's/[^A-Za-z0-9._-]/_/g')"
@@ -379,6 +435,9 @@ jobs:
if [ "${{ matrix.backend.name }}" = "DirectGLES" ] && [ "${{ matrix.case.name }}" = "minecraft-1.21.4-fabric-iris-bliss-in-world" ]; then
extra_retrace_args+=(--avoid-angle-llvmpipe-sampler-mipmap-min-filter)
fi
if [ "${{ matrix.backend.name }}" = "DirectGLES" ] && [ "${{ matrix.case.avoid_angle_llvmpipe_explicit_lod_bias || false }}" = "true" ]; then
extra_retrace_args+=(--avoid-angle-llvmpipe-explicit-lod-bias)
fi
if [ "${{ matrix.case.coherent_as_flush || false }}" = "true" ]; then
extra_retrace_args+=(--coherent-as-flush)
fi
@@ -411,6 +470,24 @@ jobs:
run_retrace || retrace_status=$?
if [ "${retrace_status}" -eq 75 ]; then
echo "::warning::Android emulator infrastructure failed; restarting it and retrying this retrace once."
# Surface-lost is retried rather than failed, so it would otherwise
# be invisible. Report it per job - a healthy run prints nothing and
# a rate spike shows up as a row per affected case.
reason_file="android-retrace-result/infrastructure-failure-reason.txt"
surface_lost_retries=0
if [ -f "${reason_file}" ]; then
surface_lost_retries="$(grep -c 'angle-surface-lost' "${reason_file}" || true)"
fi
if [ "${surface_lost_retries}" -gt 0 ]; then
echo "surface-lost retries: ${surface_lost_retries} (${{ matrix.backend.name }}, ${{ matrix.case.name }})" \
>> "${GITHUB_STEP_SUMMARY}"
fi
# The restart truncates EMULATOR_LOG, and the attempt that lost the
# emulator is the one worth reading - the retry usually only shows
# the wreckage. Keep the first attempt's log before it is clobbered.
if [ -f "${EMULATOR_LOG}" ]; then
cp "${EMULATOR_LOG}" "${EMULATOR_LOG}.first-attempt" || true
fi
sh android-plugin/run-avd-ci.sh stop \
--avd-name "${AVD_NAME}" \
--emulator-log "${EMULATOR_LOG}" \
@@ -450,6 +527,13 @@ jobs:
if [ -f "${EMULATOR_LOG}" ]; then
cp "${EMULATOR_LOG}" android-retrace-result/diagnostics/emulator.log
fi
if [ -f "${EMULATOR_LOG}.first-attempt" ]; then
cp "${EMULATOR_LOG}.first-attempt" android-retrace-result/diagnostics/emulator-first-attempt.log
fi
# A vanished emulator looks identical whether the host OOM killer took
# qemu or the renderer faulted. These two say which.
free -h > android-retrace-result/diagnostics/host-memory.txt 2>&1 || true
sudo dmesg -T 2>/dev/null | tail -300 > android-retrace-result/diagnostics/host-dmesg.txt || true
- name: Stop Emulator
if: always()
@@ -531,22 +615,41 @@ jobs:
)
if ((${#failed_cases[@]})); then
echo "Retaining fixtures for failed retrace case(s):"
echo "Retaining fixtures and results for failed retrace case(s):"
printf ' %s\n' "${!failed_cases[@]}"
else
echo "All retrace jobs succeeded; no fixtures need to be retained."
echo "All retrace jobs succeeded; nothing needs to be retained."
fi
deleted=0
retained=0
while IFS=$'\t' read -r artifact_id artifact_name; do
keep=0
if [[ "${artifact_name}" == MobileGL-trace-fixture-* ]]; then
case_name="${artifact_name#MobileGL-trace-fixture-}"
if [[ -v "failed_cases[${case_name}]" ]]; then
echo "Retaining ${artifact_name} (${artifact_id}) for failed retrace."
((retained += 1))
continue
keep=1
fi
elif [[ "${artifact_name}" == MobileGL-android-retrace-result-* ]]; then
# The result artifact carries mobilegl.log, retrace.log, logcat,
# the emulator log and the actual/diff images - the only record of
# why a retrace failed. Its name ends in -<backend>-<case>, so a
# suffix match on the case name keeps both backends' results for a
# case that failed on either of them, which is what a comparison
# needs. The match is anchored at the end, so a case name that is a
# prefix of a longer one does not retain the longer one's results.
for case_name in "${!failed_cases[@]}"; do
if [[ "${artifact_name}" == *-"${case_name}" ]]; then
keep=1
break
fi
done
fi
if ((keep)); then
echo "Retaining ${artifact_name} (${artifact_id}) for failed retrace."
((retained += 1))
continue
fi
echo "Deleting ${artifact_name} (${artifact_id})"
+81 -6
View File
@@ -11,6 +11,9 @@ on:
jobs:
build-linux:
runs-on: ubuntu-latest
permissions:
actions: write
contents: read
env:
BUILD_DIR: build-linux
CCACHE_BASEDIR: ${{ github.workspace }}
@@ -34,12 +37,11 @@ jobs:
uses: lukka/get-cmake@v4.3.3
- name: Restore ccache
uses: actions/cache@v5
uses: actions/cache/restore@v5
with:
path: .ccache
key: ${{ runner.os }}-test-${{ github.job }}-ccache-${{ github.ref_name }}-${{ github.run_id }}
key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1
restore-keys: |
${{ runner.os }}-test-${{ github.job }}-ccache-${{ github.ref_name }}-
${{ runner.os }}-test-${{ github.job }}-ccache-
- name: Prepare Vulkan SDK
@@ -97,6 +99,28 @@ jobs:
if: always()
run: ccache --show-stats
# Rewrite one rolling entry per job on the default branch. The upload stays
# cumulative - it carries every object restored at the top of this run plus
# the few TUs that actually changed - but Actions cache keys are immutable,
# so the superseded blob has to be released before the same key can be
# re-uploaded. Running after the build means a failed build leaves the
# existing entry untouched. The other trigger branches restore this entry
# rather than each writing one of their own.
- name: Release superseded ccache entry
if: github.ref_name == github.event.repository.default_branch
env:
GH_TOKEN: ${{ github.token }}
CACHE_KEY: ${{ runner.os }}-test-${{ github.job }}-ccache-v1
run: gh cache delete "${CACHE_KEY}" || true
- name: Save ccache
if: github.ref_name == github.event.repository.default_branch
continue-on-error: true
uses: actions/cache/save@v5
with:
path: .ccache
key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1
- name: Package Linux runtime
run: |
mkdir -p ci-artifacts
@@ -320,6 +344,10 @@ jobs:
- build-linux
- test
- benchmark
- integration
permissions:
actions: write
contents: read
env:
BUILD_DIR: build-retrace
CCACHE_BASEDIR: ${{ github.workspace }}
@@ -344,12 +372,11 @@ jobs:
uses: lukka/get-cmake@v4.3.3
- name: Restore ccache
uses: actions/cache@v5
uses: actions/cache/restore@v5
with:
path: .ccache
key: ${{ runner.os }}-test-${{ github.job }}-ccache-${{ github.ref_name }}-${{ github.run_id }}
key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1
restore-keys: |
${{ runner.os }}-test-${{ github.job }}-ccache-${{ github.ref_name }}-
${{ runner.os }}-test-${{ github.job }}-ccache-
- name: Prepare Vulkan SDK
@@ -415,6 +442,21 @@ jobs:
if: always()
run: ccache --show-stats
- name: Release superseded ccache entry
if: github.ref_name == github.event.repository.default_branch
env:
GH_TOKEN: ${{ github.token }}
CACHE_KEY: ${{ runner.os }}-test-${{ github.job }}-ccache-v1
run: gh cache delete "${CACHE_KEY}" || true
- name: Save ccache
if: github.ref_name == github.event.repository.default_branch
continue-on-error: true
uses: actions/cache/save@v5
with:
path: .ccache
key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1
- name: Normalize CTest command paths
run: |
python - <<'PY'
@@ -447,6 +489,7 @@ jobs:
needs:
- test
- benchmark
- integration
outputs:
names: ${{ steps.trace-cases.outputs.names }}
steps:
@@ -470,9 +513,41 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v6
- name: Derive trace fixture cache key
id: fixture-key
run: bash .github/scripts/trace-fixture-cache.sh key '${{ matrix.case }}'
- name: Restore trace fixture cache
id: fixture-cache
if: steps.fixture-key.outputs.cacheable == 'true'
uses: actions/cache/restore@v5
with:
path: ${{ steps.fixture-key.outputs.paths }}
key: ${{ steps.fixture-key.outputs.key }}
- name: Verify restored trace fixture
id: fixture-verify
if: steps.fixture-cache.outputs.cache-hit == 'true'
run: |
if bash .github/scripts/trace-fixture-cache.sh verify '${{ matrix.case }}'; then
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::warning::Cached fixture for ${{ matrix.case }} failed verification; falling back to the download path"
bash .github/scripts/trace-fixture-cache.sh reset '${{ matrix.case }}'
fi
- name: Fetch trace fixture
if: steps.fixture-verify.outputs.ok != 'true'
run: bash .github/scripts/fetch-trace-fixture-lfs.sh '${{ matrix.case }}'
- name: Save trace fixture cache
if: steps.fixture-key.outputs.cacheable == 'true' && steps.fixture-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: ${{ steps.fixture-key.outputs.paths }}
key: ${{ steps.fixture-key.outputs.key }}
- name: Stage trace fixture
run: |
safe_case="$(printf '%s' '${{ matrix.case }}' | sed 's/[^A-Za-z0-9._-]/_/g')"
+3 -3
View File
@@ -7,9 +7,6 @@
[submodule "3rdparty/SPIRV-Cross"]
path = 3rdparty/SPIRV-Cross
url = https://github.com/KhronosGroup/SPIRV-Cross.git
[submodule "include/FastSTL"]
path = include/FastSTL
url = https://github.com/MobileGL-Dev/FastSTL.git
[submodule "3rdparty/tracy"]
path = 3rdparty/tracy
url = https://github.com/wolfpld/tracy.git
@@ -34,3 +31,6 @@
[submodule "3rdparty/asio"]
path = 3rdparty/asio
url = https://github.com/chriskohlhoff/asio.git
[submodule "include/ska"]
path = include/ska
url = https://github.com/MobileGL-Dev/flat_hash_map.git
+82
View File
@@ -20,6 +20,81 @@ set(MOBILEGL_VULKAN_LIBRARY "" CACHE FILEPATH "Vulkan loader/MoltenVK library to
if (ANDROID)
set(MOBILEGL_BUILD_TEST OFF CACHE BOOL "Build MobileGL tests" FORCE)
set(MOBILEGL_BUILD_BENCHMARK OFF CACHE BOOL "Build MobileGL benchmarks" FORCE)
# ------- Android API level policy: minimum 26, decided here and only here -------
# MobileGL ships against API 26: the codebase must not use any API introduced
# after 26. That usage constraint is enforced where it is real - the shipping
# gradle build compiles at minSdk 26, where a newer API is simply undeclared
# and fails to compile. Configuring at a HIGHER level is therefore allowed
# (nothing in the tree may rely on it), but a LOWER level would change the
# libc contract underneath the shipped library and is refused.
#
# This has to live at configure time because the level cannot be corrected
# from a source header. A `#define __ANDROID_API__ 26` in a common header
# only rewrites the macro for the bionic headers that happen to be included
# after it; any libc++ header pulled in earlier has already latched its
# feature macros at the real configure-time level. libc++ and bionic then
# disagree about which symbols exist - libc++ calls e.g.
# pthread_cond_clockwait while bionic, re-read at the lowered level, has
# hidden its declaration. MobileGL/Defines.h carried exactly that pin from
# the first commit until it was removed; this guard is what replaces it.
#
# Read the level back from the compiler target triple first. Its trailing
# number (aarch64-none-linux-android26) is precisely what clang turns into
# __ANDROID_API__, so it cannot disagree with the compile itself, and it is
# already past every NDK normalisation step - codename aliases, "latest",
# and per-ABI minimum pull-ups. ANDROID_PLATFORM_LEVEL is the fallback for
# generators/languages where the triple variable is not populated.
#
# Note CMAKE_SYSTEM_VERSION is deliberately NOT consulted: it holds the API
# level only under the NDK's newer toolchain path, and is a meaningless 1
# when ANDROID_USE_LEGACY_TOOLCHAIN_FILE is on (which is what AGP has been
# defaulting to). Reading it would fail every legacy-mode build.
set(MOBILEGL_ANDROID_API_LEVEL 26)
set(_mobilegl_android_api "")
foreach (_mobilegl_api_triple "${CMAKE_CXX_COMPILER_TARGET}"
"${CMAKE_C_COMPILER_TARGET}")
if (NOT _mobilegl_android_api AND
_mobilegl_api_triple MATCHES "-android([0-9]+)$")
set(_mobilegl_android_api "${CMAKE_MATCH_1}")
endif()
endforeach()
foreach (_mobilegl_api_var ANDROID_PLATFORM_LEVEL ANDROID_NATIVE_API_LEVEL
ANDROID_PLATFORM)
if (NOT _mobilegl_android_api AND ${_mobilegl_api_var})
string(REGEX REPLACE "^android-" ""
_mobilegl_android_api "${${_mobilegl_api_var}}")
endif()
endforeach()
if (NOT _mobilegl_android_api MATCHES "^[0-9]+$")
message(FATAL_ERROR
"MobileGL: could not determine the Android API level (got "
"\"${_mobilegl_android_api}\"). Configure with the NDK toolchain "
"file and -DANDROID_PLATFORM=android-${MOBILEGL_ANDROID_API_LEVEL}.")
elseif (_mobilegl_android_api LESS MOBILEGL_ANDROID_API_LEVEL)
message(FATAL_ERROR
"MobileGL requires at least Android API ${MOBILEGL_ANDROID_API_LEVEL}, "
"but this build resolved to API ${_mobilegl_android_api}.\n"
"Configure with -DANDROID_PLATFORM=android-${MOBILEGL_ANDROID_API_LEVEL} "
"(gradle builds get this from minSdk ${MOBILEGL_ANDROID_API_LEVEL}, so "
"check that minSdk instead of adding an override).")
elseif (_mobilegl_android_api GREATER MOBILEGL_ANDROID_API_LEVEL)
message(STATUS
"MobileGL: configuring at Android API ${_mobilegl_android_api} "
"(> shipping minimum ${MOBILEGL_ANDROID_API_LEVEL}). Allowed, but the "
"tree must not use post-${MOBILEGL_ANDROID_API_LEVEL} APIs - the "
"minSdk-${MOBILEGL_ANDROID_API_LEVEL} gradle build is the enforcing "
"compile.")
endif()
message(STATUS "MobileGL: Android API level ${_mobilegl_android_api}")
unset(_mobilegl_android_api)
unset(_mobilegl_api_var)
unset(_mobilegl_api_triple)
endif()
option(MOBILEGL_ENABLE_LTO "Build with ThinLTO/IPO" OFF)
@@ -201,15 +276,22 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecomposeWorkgroupVec3Pass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecoratePositionInvariantPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenXfbInterfaceBlocksPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/SplitArrayVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/NormalizeRectCoordinatesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUboMemberRelaxedPrecisionPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp
MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp
MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp
+22
View File
@@ -82,6 +82,14 @@ namespace MobileGL::MG_Config {
#endif
// MOBILEGL_DISABLE_SUBGROUP: force-disable Vulkan shader subgroup support.
Bool DisableSubgroup = false;
// MOBILEGL_ADVERTISE_FP64: add GL_ARB_gpu_shader_fp64 to the advertised extension
// string. `double` in a shader always WORKS - it is narrowed to 32 bits before any
// module reaches a backend (ShaderTranspiler::DemoteFloat64Pass) - but the extension
// promises 64-bit precision, and that is the one thing the narrowing cannot deliver.
// Off by default so an application that checks the string before using doubles keeps
// its float path; on for measuring what the conformance suite makes of the demoted
// precision. See the DemoteFloat64Pass header and the "fp64" POST row.
Bool AdvertiseFp64 = false;
// MOBILEGL_MAGMA_R11G11B10F_FALLBACK: use fallback format for R11G11B10F on Vulkan.
Bool MagmaR11G11B10FFallback = false;
// MOBILEGL_MAGMA_FRAMESINFLIGHT: requested Magma frames in flight, defaulting to 3.
@@ -89,6 +97,13 @@ namespace MobileGL::MG_Config {
// MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER: avoid mipmap min filters in samplers,
// resolves certain rendering bugs on ANGLE + llvmpipe.
Bool AvoidSamplerMipmapMinFilter = false;
// MOBILEGL_AVOID_EXPLICIT_LOD_BIAS: leave an already-explicit LOD argument alone when
// emulating GL_TEXTURE_LOD_BIAS, instead of adding the bias uniform to it. Injecting
// the uniform turns a compile-time-constant LOD into a runtime expression, which
// sends ANGLE + llvmpipe down a mip-selection path that dereferences a NULL
// descriptor and kills the process. Deviates from spec (Vulkan adds the bias to
// OpImageSampleExplicitLod), so it is an avoidance for that stack only.
Bool AvoidExplicitLodBias = false;
// MOBILEGL_COHERENT_AS_FLUSH: app-compat for engines (e.g. Flywheel) that write
// GPU-read data through persistent GL_MAP_FLUSH_EXPLICIT_BIT maps they never
// flush. Persistent FLUSH_EXPLICIT map requests are rewritten to coherent
@@ -102,6 +117,13 @@ namespace MobileGL::MG_Config {
// per-draw glBufferSubData path instead of the persistent-mapped ring allocator
// (negative control / driver-bug escape hatch).
Bool DisableUboRing = false;
// MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION: make DirectGLES skip the native ES
// depth/stencil reads and always go through the shader-sampling emulation. Core GL
// ES has no depth or stencil readback, but some drivers accept it anyway (Mesa does,
// Adreno does not), which means the emulation is dead code on exactly the stack the
// headless suite runs on. This forces it live so the scenarios and the CTS can
// exercise the path, and gives the device an A/B lever over the same choice.
Bool EsprytForceDepthStencilReadbackEmulation = false;
// MOBILEGL_RELAXED_SEMANTICS: relax strict core-profile rules (e.g. VAO-0 draws,
// texture-name reuse after delete) even on contexts that explicitly requested a core
// profile. Without it, relaxed semantics still apply to every context that did not
+4
View File
@@ -167,13 +167,17 @@ namespace MobileGL::MG_ConfigLoader {
QueryEnvVariable("MOBILEGL_TRACE_ANGLE_VARIANT", features.TraceAngleVariant, "");
#endif
features.DisableSubgroup = QueryEnvFlag("MOBILEGL_DISABLE_SUBGROUP");
features.AdvertiseFp64 = QueryEnvFlag("MOBILEGL_ADVERTISE_FP64");
features.MagmaR11G11B10FFallback = QueryEnvFlag("MOBILEGL_MAGMA_R11G11B10F_FALLBACK");
features.MagmaFramesInFlight = QueryEnvUint32("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3, 1, 64);
features.AvoidSamplerMipmapMinFilter =
QueryEnvFlag("MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
features.AvoidExplicitLodBias = QueryEnvFlag("MOBILEGL_AVOID_EXPLICIT_LOD_BIAS");
features.CoherentAsFlush = QueryEnvFlag("MOBILEGL_COHERENT_AS_FLUSH");
features.TraceSkipAutodestroy = QueryEnvFlag("MOBILEGL_TRACE_SKIP_AUTODESTROY");
features.DisableUboRing = QueryEnvFlag("MOBILEGL_DISABLE_UBO_RING");
features.EsprytForceDepthStencilReadbackEmulation =
QueryEnvFlag("MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION");
features.RelaxedSemantics = QueryEnvFlag("MOBILEGL_RELAXED_SEMANTICS");
features.SubgroupPrefixScanQuirk = QueryEnvQuirkOverride("MOBILEGL_QUIRK_SUBGROUP_PREFIX_SCAN");
features.MagmaDisableBlendedDepthWriteQuirk =
+27 -7
View File
@@ -9,10 +9,20 @@
#pragma once
// ============== Platform-specific definitions and macros ============== //
#ifdef __ANDROID__
#undef __ANDROID_API__
#define __ANDROID_API__ 26 // force Android API level to 26 for compatibility
#endif
// No __ANDROID_API__ pin here on purpose. The effective API level is owned by
// the build system (gradle minSdk 26 -> -DANDROID_PLATFORM=android-26, enforced
// by the configure-time guard in CMakeLists.txt), not by a macro.
//
// History: this used to `#define __ANDROID_API__ 26` to *raise* the level back
// when the build configured something lower, so that pthread_getname_np (which
// bionic guards with __INTRODUCED_IN(26)) would be declared. Once a later
// change added an `#undef` in front of it, the same line started *lowering* the
// level whenever the build configured higher than 26 - and that is an
// include-order split-brain, not a compatibility knob: a TU that includes any
// libc++ header before Includes.h latches libc++'s feature macros at the
// configure-time level, and only the bionic headers pulled in afterwards see
// the lowered value. The two halves then disagree (e.g. libc++ believes
// pthread_cond_clockwait exists while bionic has since hidden its declaration).
#ifdef _WIN32
#ifndef NOMINMAX
@@ -42,11 +52,15 @@
// that includes Defines.h without Log.h both tokens would silently evaluate to 0 in the
// preprocessor conditional - enabling the assert in exactly the INFO-level builds it is
// documented to be compiled out of. Log.h redefines them identically, which is legal.
//
// Severity order, ascending: DEBUG < INFO < WARN < ERROR < FATAL. MOBILEGL_LOG_ACTIVE_LEVEL
// names the lowest severity compiled in, so the production default INFO keeps I/W/E/F and
// drops only D. Any edit here must be mirrored in Log.h.
#ifndef MOBILEGL_LOG_LEVEL_DEBUG
#define MOBILEGL_LOG_LEVEL_DEBUG 0
#define MOBILEGL_LOG_LEVEL_WARN 1
#define MOBILEGL_LOG_LEVEL_ERROR 2
#define MOBILEGL_LOG_LEVEL_INFO 3
#define MOBILEGL_LOG_LEVEL_INFO 1
#define MOBILEGL_LOG_LEVEL_WARN 2
#define MOBILEGL_LOG_LEVEL_ERROR 3
#define MOBILEGL_LOG_LEVEL_FATAL 4
#endif
@@ -81,6 +95,12 @@
#endif
// =============================== Utils ================================ //
// Asserts are live in exactly the builds where MGLOG_D is live, i.e. DEBUG builds only;
// an INFO build (the production default) compiles them out. DEBUG is the lowest severity
// in the ordering above, so "ACTIVE <= DEBUG" is true only for ACTIVE == DEBUG - the same
// gate MGLOG_D uses in Log.h. That equivalence is what makes this gate survive the
// 2026-08-13 renumbering unchanged; the contract is and stays
// "INFO builds: asserts OFF; DEBUG builds: asserts ON".
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
#define MOBILEGL_ASSERT(condition, ...) \
do { \
+2 -2
View File
@@ -49,8 +49,8 @@
#include <stacktrace>
#endif
// Include FastSTL
#include <FastSTL/UnorderedMap.h>
// Include ska::flat_hash_map
#include <ska/flat_hash_map.hpp>
// Include xxHash
#include <xxhash.h>
@@ -940,6 +940,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
// picks a whole different shader for draw_buffers without
// explicit_attrib_location. DirectVulkan advertises both.
E_GL_ARB_explicit_attrib_location, E_GL_ARB_texture_multisample, E_GL_ARB_shader_image_size,
// Core since GL 3.1 and implemented for every version advertised here. The string
// matters because applications gate the ENTRY POINTS on it rather than on the
// version: a caller that finds the extension missing never resolves
// glGetUniformBlockIndex / glUniformBlockBinding, and one that then uses uniform
// blocks anyway calls through a null pointer.
E_GL_ARB_uniform_buffer_object,
// Sampling the stencil aspect through DEPTH_STENCIL_TEXTURE_MODE. Core from 4.3,
// so on a 4.0 context the string is the only way to reach it. The host ES driver
// has had the same texture parameter since ES 3.1, which every device MobileGL
// runs on provides.
E_GL_ARB_stencil_texturing,
// Advertised with GL_NUM_PROGRAM_BINARY_FORMATS = 0, which the
// extension explicitly permits. It is also the only thing that
// exposes glProgramParameteri before GL 4.1.
@@ -960,6 +971,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (MG_Util::Async::AsyncShaderCompileEnabled()) {
extensions.push_back(E_GL_KHR_parallel_shader_compile);
}
// GL_ARB_gpu_shader_fp64 is opt-in (MOBILEGL_ADVERTISE_FP64). Every `double` in a
// shader compiles and runs already - it is narrowed to 32 bits before the module
// reaches this backend - so an application that simply uses doubles needs nothing
// advertised. What the extension additionally promises is 64-bit PRECISION, which no
// mobile GPU has and the narrowing cannot fake, so advertising it by default would
// make an application that checks the string take a path MobileGL cannot honour.
if (MG_Config::Features.AdvertiseFp64) {
extensions.push_back(E_GL_ARB_gpu_shader_fp64);
}
// Only advertised when the device driver actually has usable timer queries
// (GL_EXT_disjoint_timer_query plus its entry points) and the
// MOBILEGL_DISABLE_TIMERQUERY escape hatch is off.
@@ -1002,6 +1022,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
funcsTable.GL.MultiDrawElementsIndirect = MultiDrawElementsIndirect;
funcsTable.GL.MultiDrawElementsIndirectCount = MultiDrawElementsIndirectCount;
funcsTable.GL.MultiDrawArraysIndirect = MultiDrawArraysIndirect;
funcsTable.GL.MultiDrawArraysIndirectCount = MultiDrawArraysIndirectCount;
funcsTable.GL.DrawRangeElementsBaseVertex = DrawRangeElementsBaseVertex;
funcsTable.GL.DrawRangeElements = DrawRangeElements;
funcsTable.GL.DrawElementsInstancedBaseVertexBaseInstance = DrawElementsInstancedBaseVertexBaseInstance;
@@ -1151,6 +1172,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
m_dynamicParameters.MaxComputeUniformBlocks = m_GLESCapabilities.MaxComputeUniformBlocks;
m_dynamicParameters.MaxComputeWorkGroupInvocations = m_GLESCapabilities.MaxComputeWorkGroupInvocations;
m_dynamicParameters.MaxShaderStorageBufferBindings = m_GLESCapabilities.MaxShaderStorageBufferBindings;
// This is the number glGetIntegerv(GL_MAX_TEXTURE_BUFFER_SIZE) hands the application, and
// on a host without buffer textures it is knowingly a floor MobileGL cannot honour rather
// than a driver answer (m_GLESCapabilities.MaxTextureBufferSizeIsDriverReported says
// which). Reporting 0 instead was considered and rejected: MobileGL advertises an OpenGL
// 4.x context, where buffer textures are core and the limit has a spec minimum of 65536,
// so 0 is not a legal answer and applications are not written to survive it. GL offers no
// way to say "this core feature is missing", so the honesty is carried outside the limit:
// FillInGLESCapabilities logs the tier, glTexBuffer and the program build each name the
// missing capability at MGLOG_I, and the driver POST carries a "Buffer textures" row that
// FAILs on this tier.
m_dynamicParameters.MaxTextureBufferSize = m_GLESCapabilities.MaxTextureBufferSize;
m_dynamicParameters.TextureBufferOffsetAlignment = m_GLESCapabilities.TextureBufferOffsetAlignment;
m_dynamicParameters.MaxUniformBufferBindings = m_GLESCapabilities.MaxUniformBufferBindings;
File diff suppressed because it is too large Load Diff
@@ -40,6 +40,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
void MultiDrawElementsIndirectCount(GLenum mode, GLenum type, const void* indirect, GLintptr drawcount,
GLsizei maxdrawcount, GLsizei stride);
void MultiDrawArraysIndirect(GLenum mode, const void* indirect, GLsizei drawcount, GLsizei stride);
void MultiDrawArraysIndirectCount(GLenum mode, const void* indirect, GLintptr drawcount, GLsizei maxdrawcount,
GLsizei stride);
void DrawRangeElementsBaseVertex(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type,
const void* indices, GLint basevertex);
void DrawRangeElements(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type, const void* indices);
@@ -117,6 +119,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
// capability read needs no current ES context, and it stays false until
// the ES capabilities have been filled in.
Bool AreTimerQueriesSupported();
// True when the host ES driver can back a GL_TEXTURE_BUFFER at all - ES 3.2 core, or
// EXT/OES_texture_buffer, with glTexBuffer resolved. Desktop GL has had buffer textures as
// core since 3.1, so the frontend advertises them unconditionally and an app may call
// glTexBuffer whenever it likes; this is the only thing standing between that call and a
// null entry point. False also means every shader declaring a samplerBuffer is
// uncompilable on this driver, which the program build reports by name.
Bool AreBufferTexturesSupported();
// Human-readable name of the buffer-texture tier for diagnostics and the driver POST:
// "core (ES 3.2)", "GL_EXT_texture_buffer", "GL_OES_texture_buffer" or "unsupported".
const char* GetBufferTextureTierName();
// glTexBuffer / glTexBufferRange through whichever spelling this driver's buffer-texture
// support actually ships: the unsuffixed names are ES 3.2 core, while an EXT/OES driver
// exports glTexBuffer{,Range}EXT / OES. Callers must have checked
// AreBufferTexturesSupported() first. CallTexBufferRange reports whether it could honour
// the range - no tier is required to expose the range form, and the whole-buffer form is
// the documented fallback.
void CallTexBuffer(GLenum target, GLenum internalFormat, GLuint buffer);
Bool CallTexBufferRange(GLenum target, GLenum internalFormat, GLuint buffer, GLintptr offset, GLsizeiptr size);
// GL timer-query objects, backed by GL_EXT_disjoint_timer_query. The
// creators return null (the frontend then falls back to an immediately
// available zero result) when the calling thread does not own the ES
File diff suppressed because it is too large Load Diff
+167 -6
View File
@@ -82,14 +82,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
// GLES core supports only GL_PRIMITIVE_RESTART_FIXED_INDEX. Throws when the app enabled
// the arbitrary GL_PRIMITIVE_RESTART with a non-fixed index for this index type.
void CheckPrimitiveRestartSupported(GLenum indexType);
// Feed the current program's gl_BaseInstance / gl_DrawID emulation uniforms. Both are
// no-ops when the program does not read the corresponding builtin.
// Feed the current program's gl_BaseInstance / gl_DrawID / gl_BaseVertex emulation
// uniforms. All are no-ops when the program does not read the corresponding builtin.
void SetCurrentBaseInstance(Uint32 baseInstance);
void SetCurrentDrawID(Uint32 drawId);
// GL's gl_BaseVertex is the base-vertex parameter of an indexed draw and zero for every
// command that has none - including all the DrawArrays forms - so every draw path that
// does not carry one must leave this at zero rather than inherit the last draw's value.
void SetCurrentBaseVertex(Int32 baseVertex);
// True when the current program actually reads gl_DrawID, i.e. when a batched
// (single driver call) multi-draw tier would have to feed it one value for the whole
// batch and would therefore be wrong.
Bool CurrentProgramReadsDrawID();
// Same question for gl_BaseVertex: a batched multi-draw tier cannot give each sub-draw
// its own base vertex through a uniform either.
Bool CurrentProgramReadsBaseVertex();
// Both of the above, conservatively, for a caller that must decide BEFORE PrepareForDraw
// has synced the program - where "does not read it" is indistinguishable from "cannot be
// asked yet". Answers true whenever the backend twin is missing or predates the current
// link.
Bool CurrentProgramMayNeedPerSubDrawBuiltins(Bool batchCarriesBaseVertices);
template <typename StateObject, typename BackendObject>
class StateBackendObjectRegistry {
@@ -129,6 +141,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Null when no live state object owns this key. The result points into the map, so
// it stays valid only until the next GetOrCreate/Find/CollectGarbage on this registry.
// Take that literally, including for Find: the map is open-addressed and erases by
// shifting the rest of the probe cluster into the hole, so an erase relocates entries
// OTHER than the erased one - and Find erases, whenever it lands on a key whose state
// object has expired. Callers that need the twin across another registry call must copy
// the BackendPtr out (or keep only the pointee, which is heap-allocated and never moves).
BackendPtr* Find(StateObject* stateObj) {
const auto entryIt = m_entries.find(stateObj);
if (entryIt == m_entries.end()) {
@@ -269,6 +286,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
// context loss.
Bool persistentMapped = false;
void* persistentPtr = nullptr;
// The GL store behind `id` was created with glBufferStorageEXT and is
// therefore IMMUTABLE - glBufferData cannot respecify it and it must never be
// recycled through the size-keyed buffer pool. Tracked separately from
// persistentMapped because the two come apart: a glMapBufferRange that fails
// after its glBufferStorageEXT succeeded leaves immutable storage behind with
// no map, and a respecification then has to retire the id rather than hand it
// to glBufferData, which the driver would silently refuse.
Bool immutableStorage = false;
};
// Registered as the frontend's BufferBackendOps at backend init and on
@@ -449,6 +474,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint32 m_syncedConfigVersion = 0;
Array<MG_State::GLState::VertexAttributeVersion, MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS>
m_syncedAttributeVersions;
// Byte shift currently baked into the instanced arrays' offsets by the baseInstance
// emulation (see SetPendingFetchBaseInstance). It is draw state, not VAO state, so it
// is deliberately NOT covered by the config version: the frontend never bumps for it.
// Kept here because it describes what was last EMITTED, which is what the next sync
// has to correct.
Uint32 m_syncedFetchBaseInstance = 0;
};
extern StateBackendObjectRegistry<MG_State::GLState::VertexArrayObject, BackendVertexArrayObject>
@@ -462,6 +493,23 @@ namespace MobileGL::MG_Backend::DirectGLES {
void InvalidateVAOBindingCache();
// ES resets the binding to 0 when the currently bound VAO is deleted.
void NoteVAOIdDeleted(Uint id);
// baseInstance emulation for drivers without GL_EXT_base_instance. GL fetches an
// instanced array at element "floor(instance / divisor) + baseInstance", and ES has no
// way to say the "+ baseInstance" part - so it is folded into the attribute's own byte
// offset (baseInstance * stride) for every divisor'd array, which is exactly equivalent.
// Must be set BEFORE PrepareForDraw so the VAO sync sees it, and cleared after the draw
// so the next one refetches from element 0; ScopedFetchBaseInstance does both.
void SetPendingFetchBaseInstance(Uint32 baseInstance);
Uint32 GetPendingFetchBaseInstance();
class ScopedFetchBaseInstance {
public:
explicit ScopedFetchBaseInstance(Uint32 baseInstance) { SetPendingFetchBaseInstance(baseInstance); }
~ScopedFetchBaseInstance() { SetPendingFetchBaseInstance(0); }
ScopedFetchBaseInstance(const ScopedFetchBaseInstance&) = delete;
ScopedFetchBaseInstance& operator=(const ScopedFetchBaseInstance&) = delete;
};
} // namespace VertexArrayImpl
namespace TextureImpl {
@@ -621,6 +669,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool m_isInitialized = false;
Bool m_imageBindableStorageRequired = false;
Bool m_backendStorageImmutable = false;
// Latches the "this driver has no buffer textures" report to once per texture. The
// report is emitted from the respecify path, which bails before recording the state
// it was asked to apply - so without the latch the texture stays permanently dirty
// and every draw of every frame logs the same line.
Bool m_bufferTextureUnsupportedReported = false;
StateTextureBasicInfo m_prevTextureInfo;
// Frontend content version at the last completed mipmap sync. The per-draw
// clean probe compares this before rebuilding shape info and scanning
@@ -646,8 +699,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
FloatVec4 m_cacheBorderColor = {0.0f, 0.0f, 0.0f, 0.0f};
Vec4<TextureSwizzleParam> m_cacheSwizzleParams = {TextureSwizzleParam::Red, TextureSwizzleParam::Green,
TextureSwizzleParam::Blue, TextureSwizzleParam::Alpha};
// GL_DEPTH_STENCIL_TEXTURE_MODE. GL_DEPTH_COMPONENT is the GL and ES default, so a
// texture that never asks for the stencil aspect never emits the call. The
// depth/stencil readback and replicate-blit emulations also write this parameter
// raw, but only ever on their own scratch textures (never on an application
// texture), so they cannot desynchronise this cache.
GLenum m_cacheDepthStencilTextureMode = GL_DEPTH_COMPONENT;
Uint16 m_syncedSamplerVersion = 0;
Uint16 m_syncedTextureParamsVersion = 0;
// Set when the driver texture underneath was regenerated and has therefore lost every
// parameter already pushed onto it: the params-version early-out has to be overridden
// once, or an unchanged version would skip the re-push forever.
Bool m_forceTextureParamsResync = false;
};
void ActivateTextureUnit(Uint unit);
@@ -1015,9 +1078,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
void SetBaseInstance(Uint32 baseInstance) const;
void SetBaseInstanceWordIndex(Int32 wordIndex) const;
void SetDrawID(Uint32 drawId) const;
void SetBaseVertex(Int32 baseVertex) const;
// True when the transpiled program kept a gl_DrawID uniform, i.e. SetDrawID
// actually reaches a shader read rather than being discarded.
Bool ReadsDrawID() const { return m_drawIdUniformLocation >= 0; }
// Same for gl_BaseVertex: only a program that reads it pays for the per-draw
// uniform write, and only such a program needs the reset after one.
Bool ReadsBaseVertex() const { return m_baseVertexUniformLocation >= 0; }
Int GetIndirectParamsBinding() const { return m_indirectParamsBinding; }
Uint GetBackendProgramId() const { return m_backendProgramId; }
// False when the last SyncToBackend could not produce a usable program (a
@@ -1028,6 +1095,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint32 GetSnormFallbackClampOutputMask() const { return m_snormFallbackClampOutputMask; }
Uint32 GetUnormFallbackClampOutputMask() const { return m_unormFallbackClampOutputMask; }
Uint GetFragColorBroadcastCount() const { return m_fragColorBroadcastCount; }
// Signature of the glShaderStorageBlockBinding override set the generated ESSL was
// transpiled against (ES can only express a storage-block binding as the declared
// qualifier, so the overrides are baked into the source). A mismatch means the
// program is stale exactly like the clamp masks above.
Uint64 GetShaderStorageBlockBindingSignature() const { return m_shaderStorageBlockBindingSignature; }
Bool HasGlobalUboBlock() const { return m_globalUboBackendBlockIndex >= 0; }
const Vector<Int>& GetUniformBlockBackendIndices() const { return m_uniformBlockBackendIndices; }
@@ -1043,14 +1115,48 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Frontend link version this backend program (and its resource caches) was
// built from; a mismatch means every link-derived cache here is stale.
Uint32 GetSyncedLinkVersion() const { return m_syncedLinkVersion; }
// Image-uniform unit generation this backend program was GENERATED against.
// Separate from the link version because it is not link state: ES forbids
// glUniform1i on an image uniform, so RebindImageUniformsToFrontendUnits bakes the
// unit into the ESSL, and a program built before glUniform1i moved that unit is as
// stale as one built before a relink - while the sampler half, which really is
// re-issued per draw, needs nothing of the sort.
Uint32 GetSyncedImageUnitVersion() const { return m_syncedImageUnitVersion; }
// Whether the (unit, bound format) pairs this program's FORMAT-LESS image uniforms
// resolve to are still the ones its ESSL was generated against.
//
// A fourth condition of the same family as the three above, and the only one that
// reads live state rather than a program-side counter, because that is where the
// dependency actually is. GLSL ES requires a format layout qualifier on every image
// where desktop GLSL lets a writeonly declaration omit one, and the only correct
// qualifier is whatever glBindImageTexture named - so a declaration with no format
// is compiled against the BINDING, and a rebind to a different format makes the
// built program wrong. Keyed on the units the program's own images address (cached
// at sync, since a unit can only move by glUniform1i, which bumps the image-unit
// version above and forces a re-sync anyway), so the cost on a program with no
// format-less image - which is all but a handful - is one empty-vector test.
//
// Deliberately NOT reached from glBindImageTexture: that entry point must never
// trigger a build (same constraint as glShaderStorageBlockBinding). It moves the
// state and this comparison notices at the next Prepare, which is also what makes
// an image first bound AFTER link work.
Bool ImageUnitFormatsStillMatch() const;
// The value ImageUnitFormatsStillMatch() compares against, recomputed from live
// image-unit state. 0 when the program has no format-less image uniform.
Uint64 ComputeImageUnitFormatSignature() const;
private:
void CacheResourceLocations(const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject);
Uint m_backendProgramId = 0;
// GL name of the frontend program this was last synced from; diagnostics only, so
// an unusable backend program can be traced back to the glCreateProgram id the app
// knows it by.
Uint m_frontendProgramId = 0;
Uint m_backendGlobalUBOId = 0;
Int m_baseInstanceUniformLocation = -1;
Int m_drawIdUniformLocation = -1;
Int m_baseVertexUniformLocation = -1;
Int m_baseInstanceWordIndexUniformLocation = -1;
Int m_indirectParamsBinding = -1;
Uint32 m_snormFallbackClampOutputMask = 0;
@@ -1058,6 +1164,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Draw buffers a legacy gl_FragColor write has to reach (see
// PrgramImpl::BroadcastLegacyFragColor); 1 keeps the plain single-output shader.
Uint m_fragColorBroadcastCount = 1;
// 0 is the signature of an empty override set, i.e. what almost every program has.
Uint64 m_shaderStorageBlockBindingSignature = 0;
Bool m_isInitialized = false;
Bool m_backendProgramUsable = false;
@@ -1068,6 +1176,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint32 m_lastUploadedGlobalUboVersion = ~0u;
BufferImpl::UboRingAllocation m_globalUboRingAllocation;
Uint32 m_syncedLinkVersion = ~0u;
Uint32 m_syncedImageUnitVersion = ~0u;
// Image units addressed by the program's FORMAT-LESS image uniforms, and the digest
// of the (unit, format) pairs the generated ESSL baked. Empty/0 for every program
// that declares a format on all of its images, which is the overwhelming majority -
// and what keeps the per-draw comparison free for them.
Vector<Int> m_formatlessImageUnits;
Uint64 m_imageUnitFormatSignature = 0;
SamplerPassMemo m_samplerPassMemo;
};
@@ -1091,14 +1206,60 @@ namespace MobileGL::MG_Backend::DirectGLES {
// on the backend program (eliminated as unused, or the driver lacks the entry
// points), which is not an error - GL_BUFFER_BINDING is served from the frontend
// record either way.
//
// NOT how a rebinding reaches the shader. glShaderStorageBlockBinding has no ES
// equivalent and is absent from every real ES driver, so this is a no-op there;
// SyncToBackend bakes the effective binding into the ESSL it generates instead
// (SpvcSession::SetShaderStorageBlockBinding). This is kept as the cheaper path on
// a driver that does happen to expose the entry point.
Bool ApplyShaderStorageBlockBinding(Uint backendProgramId, const String& blockName, Uint binding);
// Replays every glShaderStorageBlockBinding recorded on the program onto a backend
// program that was just built. The frontend record is authoritative (only the
// shader's DECLARED binding survives in the SPIR-V), so without this replay any
// rebuild would silently revert rebound blocks. Mirrors DirectVulkan's
// reseed-on-rebuild in BuildProgramResourceCache.
// program that was just built - best effort, on the same "only where the driver has
// the entry point" terms as ApplyShaderStorageBlockBinding above. Mirrors
// DirectVulkan's reseed-on-rebuild in BuildProgramResourceCache.
void ReseedShaderStorageBlockBindings(Uint backendProgramId,
const MG_State::GLState::ProgramObject& stateProgramObject);
// Order-independent digest of the program's glShaderStorageBlockBinding overrides.
// The generated ESSL carries them (ES has no way to move a storage block's binding
// after link), so a program built against a different set is stale and the draw path
// has to rebuild it. Computed from the values, so re-setting a block to the binding it
// already has costs nothing. 0 when nothing was ever rebound.
Uint64 ComputeShaderStorageBlockBindingSignature(
const MG_State::GLState::ProgramObject& stateProgramObject);
// Everything the image-format bake needs from one walk of a program's uniform
// reflection. GLSL ES requires a format layout qualifier on every image uniform;
// desktop GLSL lets a writeonly (or readonly) declaration omit one, and the only
// format that is CORRECT to substitute is whatever glBindImageTexture named for the
// unit that uniform addresses - so the transpile bakes it in and the build is keyed
// on it.
struct ImageFormatBakeInputs {
// Uniform name (SPIR-V spelling, i.e. an array named once, unsubscripted) to the GL
// internal format to bake. Holds only uniforms that DECLARED no format; a declared
// one is authoritative and is never overridden.
UnorderedMap<String, Uint> glFormatByUniformName;
// The same uniforms whose format SPIRV-Cross REFUSES to print for ESSL (it throws on
// its desktop-only set, which loses the stage), paired with the ESSL spelling to
// write into the emitted declaration instead. Disjoint from the map above by
// construction: a format is baked into the module or completed in the text, never
// both. r8ui - the stencil half of the packed_depth_stencil case - lands here.
UnorderedMap<String, String> esslFormatQualifierByUniformName;
// Units those uniforms address, kept so the draw path can re-read their formats
// without walking the reflection again.
Vector<Int> units;
// Digest of the (unit, format) pairs above. 0 when the program has no format-less
// image uniform, which is all but a handful.
Uint64 signature = 0;
// Array uniforms whose elements resolved to units holding DIFFERENT formats: one
// declaration carries one qualifier, so there is nothing correct to bake and they
// are dropped from the map above. Kept for diagnostics.
Vector<String> conflictedNames;
// Some format in play - declared or baked - is outside the GLSL ES core image
// format set, so the emitted ESSL needs the GL_NV_image_formats directive.
Bool needsExtendedImageFormats = false;
};
ImageFormatBakeInputs CollectImageFormatBakeInputs(
const MG_State::GLState::ProgramObject& stateProgramObject);
} // namespace PrgramImpl
namespace SamplerImpl {
+57 -23
View File
@@ -252,7 +252,7 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
g_resolvedTier =
ResolveTier(g_GLESCapabilities, g_GLESFuncs, MG_Config::Features.EsprytMultiDrawMode,
&g_tierResolution);
MGLOG_I("DirectGLES multi-draw: %s", g_tierResolution.c_str());
MGLOG_D("DirectGLES multi-draw: %s", g_tierResolution.c_str());
}
// Which tiers have already announced themselves, one bit per GLESMultiDrawMode.
@@ -267,24 +267,29 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
const Uint32 bit = 1u << static_cast<Uint32>(tier);
if (g_announcedTiers & bit) return;
g_announcedTiers |= bit;
MGLOG_I("DirectGLES multi-draw: first batch executed via tier \"%s\"", TierName(tier));
MGLOG_D("DirectGLES multi-draw: first batch executed via tier \"%s\"", TierName(tier));
}
// The tier this particular batch can actually take. A tier is demoted here when
// the batch's own shape - not the driver - rules it out; the compute tier keeps
// its remaining feasibility checks inside its implementation, where the data it
// has to walk is already in hand.
GLESMultiDrawMode ResolveTierForBatch(Bool programReadsDrawID, Bool hasIndexBuffer) {
GLESMultiDrawMode ResolveTierForBatch(Bool programReadsDrawID, Bool perSubDrawBaseVertex,
Bool hasIndexBuffer) {
ResolveTierOnce();
GLESMultiDrawMode tier = g_resolvedTier;
// Batched tiers issue one driver entry for the whole batch, so the emulated
// gl_DrawID uniform can only hold one value across every sub-draw. A program
// that reads gl_DrawID gets an unrolled tier, which feeds each sub-draw its
// own index (the spec's value); nothing else observes the difference.
// own index (the spec's value); nothing else observes the difference. The
// emulated gl_BaseVertex is one uniform for the same reason, so a batch whose
// sub-draws carry their own base vertices unrolls too - even the Ext tier,
// which hands the driver the whole basevertex array, can only leave ONE value
// in the uniform the shader reads.
const Bool batched = tier == GLESMultiDrawMode::Ext || tier == GLESMultiDrawMode::MultiIndirect ||
tier == GLESMultiDrawMode::Compute;
if (batched && programReadsDrawID) {
if (batched && (programReadsDrawID || perSubDrawBaseVertex)) {
tier = SupportsTier(GLESMultiDrawMode::BaseVertex) ? GLESMultiDrawMode::BaseVertex
: GLESMultiDrawMode::DrawElements;
}
@@ -371,7 +376,8 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
// ---------------------------------------------------------------------------
Bool RunIndirect(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
GLsizei drawcount, const GLint* basevertex, Bool batched, Bool feedDrawID) {
GLsizei drawcount, const GLint* basevertex, Bool batched, Bool feedDrawID,
Bool feedBaseVertex) {
if (!SupportsTier(batched ? GLESMultiDrawMode::MultiIndirect : GLESMultiDrawMode::Indirect)) return false;
const SizeT indexSize = IndexTypeSize(type);
if (indexSize == 0) return false;
@@ -413,10 +419,12 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
} else {
for (GLsizei i = 0; i < drawcount; ++i) {
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
if (feedBaseVertex) SetCurrentBaseVertex(basevertex ? basevertex[i] : 0);
const SizeT commandOffset = commandBase + static_cast<SizeT>(i) * sizeof(DrawElementsIndirectCommand);
g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast<const void*>(commandOffset));
}
if (feedDrawID) SetCurrentDrawID(0);
if (feedBaseVertex) SetCurrentBaseVertex(0);
}
BufferImpl::BindBufferId(GL_DRAW_INDIRECT_BUFFER, previousIndirectBinding);
NoteTierExecuted(batched ? GLESMultiDrawMode::MultiIndirect : GLESMultiDrawMode::Indirect);
@@ -428,15 +436,17 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
// ---------------------------------------------------------------------------
Bool RunBaseVertexLoop(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
GLsizei drawcount, const GLint* basevertex, Bool feedDrawID) {
GLsizei drawcount, const GLint* basevertex, Bool feedDrawID, Bool feedBaseVertex) {
if (!SupportsTier(GLESMultiDrawMode::BaseVertex)) return false;
for (GLsizei i = 0; i < drawcount; ++i) {
if (count[i] <= 0) continue;
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
if (feedBaseVertex) SetCurrentBaseVertex(basevertex ? basevertex[i] : 0);
g_GLESFuncs.glDrawElementsBaseVertex(mode, count[i], type, indices[i],
basevertex ? basevertex[i] : 0);
}
if (feedDrawID) SetCurrentDrawID(0);
if (feedBaseVertex) SetCurrentBaseVertex(0);
NoteTierExecuted(GLESMultiDrawMode::BaseVertex);
return true;
}
@@ -446,7 +456,8 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
// ---------------------------------------------------------------------------
Bool RunRebasedDrawElements(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
GLsizei drawcount, const GLint* basevertex, Bool feedDrawID) {
GLsizei drawcount, const GLint* basevertex, Bool feedDrawID,
Bool feedBaseVertex) {
const SizeT indexSize = IndexTypeSize(type);
if (indexSize == 0) return false;
@@ -479,7 +490,7 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
const Uint8* source = ResolveSubDrawIndices(indexBuffer, indexBufferBytes, indexBufferSize, indices[i],
subDrawCount, indexSize);
if (!source) {
MGLOG_E("DirectGLES multi-draw (drawelements tier): sub-draw %d reads outside the bound index "
MGLOG_E_ONCE("DirectGLES multi-draw (drawelements tier): sub-draw %d reads outside the bound index "
"buffer; skipping the batch",
i);
return false;
@@ -500,11 +511,16 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
for (GLsizei i = 0; i < drawcount; ++i) {
if (count[i] <= 0) continue;
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
// The base vertex is folded into the rewritten index stream here, so the
// driver sees none - but gl_BaseVertex still has to report the value the
// application passed for this sub-draw.
if (feedBaseVertex) SetCurrentBaseVertex(basevertex ? basevertex[i] : 0);
g_GLESFuncs.glDrawElements(mode, count[i], GL_UNSIGNED_INT,
reinterpret_cast<const void*>(indexBase + cursor * sizeof(Uint32)));
cursor += static_cast<SizeT>(count[i]);
}
if (feedDrawID) SetCurrentDrawID(0);
if (feedBaseVertex) SetCurrentBaseVertex(0);
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, previousIndexBinding);
NoteTierExecuted(GLESMultiDrawMode::DrawElements);
return true;
@@ -580,7 +596,7 @@ void main() {
const GLuint shader = g_GLESFuncs.glCreateShader(GL_COMPUTE_SHADER);
if (shader == 0) {
MGLOG_E("DirectGLES multi-draw (compute tier): glCreateShader(GL_COMPUTE_SHADER) failed");
MGLOG_E_ONCE("DirectGLES multi-draw (compute tier): glCreateShader(GL_COMPUTE_SHADER) failed");
return false;
}
const char* source = kFlattenComputeSource;
@@ -591,14 +607,14 @@ void main() {
if (status != GL_TRUE) {
char log[1024] = {};
g_GLESFuncs.glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
MGLOG_E("DirectGLES multi-draw (compute tier): index-flattening shader failed to compile: %s", log);
MGLOG_E_ONCE("DirectGLES multi-draw (compute tier): index-flattening shader failed to compile: %s", log);
g_GLESFuncs.glDeleteShader(shader);
return false;
}
const GLuint program = g_GLESFuncs.glCreateProgram();
if (program == 0) {
MGLOG_E("DirectGLES multi-draw (compute tier): glCreateProgram failed");
MGLOG_E_ONCE("DirectGLES multi-draw (compute tier): glCreateProgram failed");
g_GLESFuncs.glDeleteShader(shader);
return false;
}
@@ -609,7 +625,7 @@ void main() {
if (status != GL_TRUE) {
char log[1024] = {};
g_GLESFuncs.glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
MGLOG_E("DirectGLES multi-draw (compute tier): index-flattening program failed to link: %s", log);
MGLOG_E_ONCE("DirectGLES multi-draw (compute tier): index-flattening program failed to link: %s", log);
g_GLESFuncs.glDeleteProgram(program);
return false;
}
@@ -619,7 +635,7 @@ void main() {
g_uDrawCount = g_GLESFuncs.glGetUniformLocation(program, "uDrawCount");
g_uTotalIndices = g_GLESFuncs.glGetUniformLocation(program, "uTotalIndices");
g_computeProgramFailed = false;
MGLOG_I("DirectGLES multi-draw: index-flattening compute program ready (id %u)", program);
MGLOG_D("DirectGLES multi-draw: index-flattening compute program ready (id %u)", program);
return true;
}
@@ -837,8 +853,15 @@ void main() {
// afterwards would mean unpicking the program, SSBO and index bindings
// PrepareForDraw just made, and a dispatch inside an open transform feedback
// span is not legal at all. On success it hands back a flattened index stream.
// A batch whose sub-draws carry their own base vertices cannot be flattened either
// when the program reads gl_BaseVertex: one draw call leaves one uniform value.
// Asked conservatively because this decision precedes PrepareForDraw - see
// CurrentProgramMayNeedPerSubDrawBuiltins. Flattening is the irreversible half:
// once the batch is one draw the values are gone, whereas declining to flatten only
// costs the unrolled tier.
FlattenedStream flattened;
if (ResolvedTier() == GLESMultiDrawMode::Compute && !CurrentProgramReadsDrawID()) {
if (ResolvedTier() == GLESMultiDrawMode::Compute &&
!CurrentProgramMayNeedPerSubDrawBuiltins(basevertex != nullptr)) {
FlattenWithCompute(mode, count, type, indices, drawcount, basevertex, flattened);
}
@@ -852,8 +875,11 @@ void main() {
return;
}
// Now that PrepareForDraw has synced the program, both questions have real answers;
// the tier choice and the per-sub-draw feeds use those, not the guess above.
const Bool feedDrawID = CurrentProgramReadsDrawID();
const GLESMultiDrawMode tier = ResolveTierForBatch(feedDrawID, hasIndexBuffer);
const Bool feedBaseVertex = basevertex != nullptr && CurrentProgramReadsBaseVertex();
const GLESMultiDrawMode tier = ResolveTierForBatch(feedDrawID, feedBaseVertex, hasIndexBuffer);
Bool drawn = false;
switch (tier) {
@@ -861,16 +887,19 @@ void main() {
drawn = RunExt(mode, count, type, indices, drawcount, basevertex);
break;
case GLESMultiDrawMode::MultiIndirect:
drawn = RunIndirect(mode, count, type, indices, drawcount, basevertex, /*batched=*/true, feedDrawID);
drawn = RunIndirect(mode, count, type, indices, drawcount, basevertex, /*batched=*/true, feedDrawID,
feedBaseVertex);
break;
case GLESMultiDrawMode::Indirect:
drawn = RunIndirect(mode, count, type, indices, drawcount, basevertex, /*batched=*/false, feedDrawID);
drawn = RunIndirect(mode, count, type, indices, drawcount, basevertex, /*batched=*/false, feedDrawID,
feedBaseVertex);
break;
case GLESMultiDrawMode::BaseVertex:
drawn = RunBaseVertexLoop(mode, count, type, indices, drawcount, basevertex, feedDrawID);
drawn = RunBaseVertexLoop(mode, count, type, indices, drawcount, basevertex, feedDrawID, feedBaseVertex);
break;
case GLESMultiDrawMode::DrawElements:
drawn = RunRebasedDrawElements(mode, count, type, indices, drawcount, basevertex, feedDrawID);
drawn = RunRebasedDrawElements(mode, count, type, indices, drawcount, basevertex, feedDrawID,
feedBaseVertex);
break;
case GLESMultiDrawMode::Compute:
// Its pre-pass ran above; reaching here means it declined this batch's shape.
@@ -883,10 +912,15 @@ void main() {
// below are the floor: a base-vertex replay where the driver has one, and the
// rewritten index stream where it does not. Both are safe for any batch these
// entry points can receive.
if (!drawn) drawn = RunBaseVertexLoop(mode, count, type, indices, drawcount, basevertex, feedDrawID);
if (!drawn) drawn = RunRebasedDrawElements(mode, count, type, indices, drawcount, basevertex, feedDrawID);
if (!drawn) {
MGLOG_E("DirectGLES multi-draw: no usable tier for a %d sub-draw batch (mode 0x%x, type 0x%x); "
drawn = RunBaseVertexLoop(mode, count, type, indices, drawcount, basevertex, feedDrawID, feedBaseVertex);
}
if (!drawn) {
drawn = RunRebasedDrawElements(mode, count, type, indices, drawcount, basevertex, feedDrawID,
feedBaseVertex);
}
if (!drawn) {
MGLOG_E_ONCE("DirectGLES multi-draw: no usable tier for a %d sub-draw batch (mode 0x%x, type 0x%x); "
"the batch was dropped",
drawcount, mode, type);
}
+636 -68
View File
@@ -21,6 +21,7 @@
#include <MG_Util/Math/HalfFloat.h>
#include <MG_Util/Math/SmallFloat.h>
#include <algorithm>
#include <cmath>
#include <cctype>
#include <cstring>
@@ -416,10 +417,27 @@ namespace MobileGL::MG_Backend::DirectGLES {
result = std::regex_replace(result, pattern, "$1flat $2");
};
// Every stage that has an integer interface at all, on BOTH sides. Interpolation is
// only ever consumed at a fragment input, so the qualifier is semantically inert on
// a tessellation or geometry interface - but an ES linker still compares the two
// sides of every interface and rejects a program whose producer says `flat` and
// whose consumer does not. Covering only the stages that "need" it left exactly two
// holes, and a program that used tessellation fell into both:
// vertex `flat out uint` -> tess-control `in uint` (producer flat, consumer not)
// tess-eval `out uint` -> geometry `flat in uint` (consumer flat, producer not)
// Adreno answers "output ... interpolation mismatch with other stage" and the whole
// program fails to link, which is a draw that silently paints nothing.
//
// Adding rather than stripping, because a fragment input's `flat` is load-bearing
// (ESSL forbids an interpolated integer) and would have to be put back for the last
// stage before the fragment shader anyway - so "everything integer is flat" is the
// one rule that is consistent no matter which stages a program happens to have.
switch (shaderType) {
case GL_VERTEX_SHADER:
addFlatQualifier("out");
break;
case GL_TESS_CONTROL_SHADER:
case GL_TESS_EVALUATION_SHADER:
case GL_GEOMETRY_SHADER:
addFlatQualifier("in");
addFlatQualifier("out");
@@ -434,6 +452,175 @@ namespace MobileGL::MG_Backend::DirectGLES {
return result;
}
String RetargetTextureBufferExtension(String glslCode,
MG_External::GLESCapabilities::TextureBufferTier tier) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
// SPIRV-Cross hardcodes the EXT spelling: CompilerGLSL::type_to_glsl emits
// require_extension_internal("GL_EXT_texture_buffer") for any Dim=Buffer image
// whenever it targets ESSL below 320, with no OES alternative and no way to
// configure it. GL_OES_texture_buffer is functionally identical but is a separate
// directive, and `#extension <name> : require` on a name the driver does not
// advertise is a hard compile error - so on an OES-only driver the emitted shader
// fails to compile for the sake of one token.
//
// Line comments are excluded by the directive check below; a `#extension` line inside
// a /* */ block is not, and would be rewritten. That is harmless (it stays a comment)
// and is not worth a preprocessor-aware scan here.
//
// Deliberately a directive rewrite and nothing more. The alternative - teaching the
// SPIR-V to stop asking for the extension - is not available: the requirement is
// synthesized by SPIRV-Cross from the image type itself, not carried in the module,
// so there is nothing upstream to strip. Everything about the shader body that
// actually uses the buffer texture is identical between the two extensions.
using Tier = MG_External::GLESCapabilities::TextureBufferTier;
if (tier != Tier::ExtensionOES) {
return glslCode;
}
static constexpr const char* kExtName = "GL_EXT_texture_buffer";
static constexpr const char* kOesName = "GL_OES_texture_buffer";
constexpr SizeT kExtNameLength = 21; // strlen("GL_EXT_texture_buffer")
static_assert(sizeof("GL_EXT_texture_buffer") - 1 == kExtNameLength, "name length drifted");
static_assert(sizeof("GL_OES_texture_buffer") - 1 == kExtNameLength,
"the two spellings must be the same length for the in-place replace");
// Only rewrite the name where it is the whole subject of an #extension directive.
// Two separate guards, both load-bearing:
// * the directive check, so a line-comment mentioning the name is left alone;
// * the identifier-boundary check, because GL_EXT_texture_buffer is a PREFIX of
// GL_EXT_texture_buffer_object - a different, real extension that SPIRV-Cross
// emits from the same `case DimBuffer:` on its legacy-desktop branch. Without
// the boundary this pass would silently rewrite a request for that extension
// into a request for a GL_OES_texture_buffer_object that does not exist.
const auto isIdentifierChar = [](char c) {
return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '_';
};
SizeT searchFrom = 0;
while (true) {
const SizeT hit = glslCode.find(kExtName, searchFrom);
if (hit == String::npos) {
break;
}
searchFrom = hit + kExtNameLength;
// Identifier boundary on both sides, so the name is not a fragment of a longer one.
if (hit > 0 && isIdentifierChar(glslCode[hit - 1])) {
continue;
}
if (hit + kExtNameLength < glslCode.size() && isIdentifierChar(glslCode[hit + kExtNameLength])) {
continue;
}
// Walk back to the start of the line and require that it is an #extension
// directive, allowing whitespace between '#' and the keyword.
SizeT lineStart = glslCode.rfind('\n', hit);
lineStart = (lineStart == String::npos) ? 0 : lineStart + 1;
SizeT cursor = lineStart;
while (cursor < hit && std::isspace(static_cast<unsigned char>(glslCode[cursor]))) {
++cursor;
}
if (cursor >= hit || glslCode[cursor] != '#') {
continue;
}
++cursor;
while (cursor < hit && std::isspace(static_cast<unsigned char>(glslCode[cursor]))) {
++cursor;
}
if (glslCode.compare(cursor, 9, "extension") != 0) {
continue;
}
glslCode.replace(hit, kExtNameLength, kOesName);
}
return glslCode;
}
String RequestExtendedImageFormats(String glslCode, Bool needed) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
// GLSL ES core has thirteen image formats; GL has forty. SPIRV-Cross prints whatever
// format the OpTypeImage carries and asks for no extension for it, so an r8ui or
// rg16f image - declared as such, or baked from the bound one - reaches the driver as
// a format its core language does not know. GL_NV_image_formats is the only thing
// that adds them, and it has to be requested by name.
//
// The caller decides `needed`: it knows which formats are in play (from the uniform
// reflection and the image-unit bindings) and whether the driver advertises the
// extension at all - `#extension` on an unadvertised name is itself a hard error, so
// this must never be emitted speculatively.
static constexpr const char* kDirective = "#extension GL_NV_image_formats : require\n";
static constexpr const char* kExtName = "GL_NV_image_formats";
if (!needed || glslCode.find(kExtName) != String::npos) {
return glslCode;
}
// After the #version line, which must stay first. Everything else about the header is
// order-insensitive, and ForceSupporterOutput's scan for the LAST #extension
// directive still finds whichever one that is.
const SizeT versionPos = glslCode.find("#version");
if (versionPos == String::npos) {
return kDirective + glslCode;
}
const SizeT lineEnd = glslCode.find('\n', versionPos);
if (lineEnd == String::npos) {
return glslCode + "\n" + kDirective;
}
glslCode.insert(lineEnd + 1, kDirective);
return glslCode;
}
String BakeImageFormatQualifiers(String glslCode,
const UnorderedMap<String, String>& esslFormatByUniformName) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (esslFormatByUniformName.empty() || glslCode.find("image") == String::npos) {
return glslCode;
}
// Same declaration shape RebindImageUniformsToFrontendUnits matches, and for the same
// reason: one line, one image uniform, the name in group 3.
static const std::regex imageDeclRegex(
R"((layout\s*\(([^)]*)\)\s*)?uniform\s+(?:(?:readonly|writeonly|coherent|volatile|restrict|highp|mediump|lowp)\s+)*[iu]?image[A-Za-z0-9]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*(\[[^\]]*\])?\s*;)");
// Every image format spelling GLSL has, so a declaration that already carries one is
// recognised whatever it says - the caller's map is consulted only for declarations
// with NO format, never to override a written one.
static const std::regex existingFormatRegex(
R"(\b(rgba32f|rgba16f|rg32f|rg16f|r11f_g11f_b10f|r32f|r16f|rgba16|rgb10_a2|rg16|rg8|r16|r8|rgba16_snorm|rgba8_snorm|rg16_snorm|rg8_snorm|r16_snorm|r8_snorm|rgba32i|rgba16i|rgba8i|rg32i|rg16i|rg8i|r32i|r16i|r8i|rgba32ui|rgba16ui|rgba8ui|rgb10_a2ui|rg32ui|rg16ui|rg8ui|r32ui|r16ui|r8ui)\b)");
String result;
result.reserve(glslCode.size());
SizeT lineStart = 0;
while (lineStart <= glslCode.size()) {
const SizeT lineEnd = glslCode.find('\n', lineStart);
const Bool lastLine = lineEnd == String::npos;
String line = glslCode.substr(lineStart, lastLine ? String::npos : lineEnd - lineStart);
std::smatch match;
if (std::regex_search(line, match, imageDeclRegex)) {
const String name = match[3].str();
const auto formatIt = esslFormatByUniformName.find(name);
const String layoutContents = match[2].matched ? match[2].str() : String();
if (formatIt != esslFormatByUniformName.end() && !formatIt->second.empty() &&
!std::regex_search(layoutContents, existingFormatRegex)) {
if (match[1].matched) {
const SizeT layoutOpen = line.find('(', match.position(1));
line.insert(layoutOpen + 1, formatIt->second + ", ");
} else {
line.insert(match.position(0), "layout(" + formatIt->second + ") ");
}
}
}
result += line;
if (lastLine) {
break;
}
result += '\n';
lineStart = lineEnd + 1;
}
return result;
}
String RemoveLayoutBinding(const String& glslCode) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
@@ -470,6 +657,352 @@ namespace MobileGL::MG_Backend::DirectGLES {
return result;
}
namespace {
Bool IsImagePassIdentifierChar(char c) {
return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
}
// Occurrences of `identifier` in `code` that are whole identifiers, i.e. not the
// tail or head of a longer one. "goku" must not find "goku_hd" or "my_goku".
SizeT CountIdentifierOccurrences(const String& code, const String& identifier) {
if (identifier.empty()) return 0;
SizeT count = 0;
for (SizeT pos = code.find(identifier); pos != String::npos;
pos = code.find(identifier, pos + 1)) {
if (pos > 0 && IsImagePassIdentifierChar(code[pos - 1])) continue;
const SizeT after = pos + identifier.size();
if (after < code.size() && IsImagePassIdentifierChar(code[after])) continue;
++count;
}
return count;
}
Bool ContainsIdentifier(const String& code, const String& identifier) {
return CountIdentifierOccurrences(code, identifier) > 0;
}
// The image format layout qualifiers ESSL accepts (GLSL ES 3.20 4.4.7 table 4.6 -
// the ES-legal subset of what SPIRV-Cross's format_to_glsl can print). The
// readonly/writeonly rule only applies to a declaration that carries one of them.
Bool IsImageFormatQualifier(const String& token) {
static constexpr StringView FORMATS[] = {
"rgba32f", "rgba16f", "rg32f", "rg16f", "r11f_g11f_b10f",
"r32f", "r16f", "rgba16", "rgb10_a2", "rgba8",
"rg16", "rg8", "r16", "r8", "rgba16_snorm",
"rgba8_snorm", "rg16_snorm", "rg8_snorm", "r16_snorm", "r8_snorm",
"rgba32i", "rgba16i", "rgba8i", "rg32i", "rg16i",
"rg8i", "r32i", "r16i", "r8i", "rgba32ui",
"rgba16ui", "rgb10_a2ui", "rgba8ui", "rg32ui", "rg16ui",
"rg8ui", "r32ui", "r16ui", "r8ui",
};
for (const StringView format : FORMATS) {
if (token == format) return true;
}
return false;
}
// "Except for image variables qualified with the format qualifiers r32f, r32i, and
// r32ui, image variables must specify either memory qualifier readonly or the
// memory qualifier writeonly." (GLSL ES 3.20 4.10)
Bool IsMemoryQualifierExemptImageFormat(const String& token) {
return token == "r32f" || token == "r32i" || token == "r32ui";
}
// Comma-separated contents of a layout(...) list, each entry trimmed.
Vector<String> SplitLayoutQualifierList(const String& layout) {
Vector<String> tokens;
SizeT start = 0;
while (start <= layout.size()) {
SizeT comma = layout.find(',', start);
const Bool last = comma == String::npos;
String token = layout.substr(start, last ? String::npos : comma - start);
const SizeT first = token.find_first_not_of(" \t\r\n");
if (first == String::npos) {
token.clear();
} else {
token = token.substr(first, token.find_last_not_of(" \t\r\n") - first + 1);
}
if (!token.empty()) tokens.push_back(Move(token));
if (last) break;
start = comma + 1;
}
return tokens;
}
// Trims both ends and collapses every internal whitespace run to one space, so a
// qualifier list or array suffix can be spliced back into a rebuilt declaration
// whatever the original spacing was.
String NormalizeDeclarationSpacing(const String& text) {
String out;
out.reserve(text.size());
Bool pendingSpace = false;
for (const char c : text) {
if (std::isspace(static_cast<unsigned char>(c))) {
pendingSpace = !out.empty();
continue;
}
if (pendingSpace) out += ' ';
pendingSpace = false;
out += c;
}
return out;
}
// How an image builtin touches the image it is handed.
enum class ImageBuiltinAccess { None, Load, Store, Unknown };
ImageBuiltinAccess ClassifyImageBuiltin(const String& name) {
if (name == "imageStore") return ImageBuiltinAccess::Store;
if (name == "imageLoad") return ImageBuiltinAccess::Load;
// imageAtomic* both reads and writes, but ES only defines the atomics on
// r32i/r32ui/r32f images - exactly the formats the rule above exempts - so this
// pass has already skipped any declaration they can legally appear on. Load is
// enough to keep the classification total without ever being acted upon.
if (name.compare(0, 11, "imageAtomic") == 0) return ImageBuiltinAccess::Load;
if (name == "imageSize" || name == "imageSamples") return ImageBuiltinAccess::None;
// Some other identifier that starts with "image" and is being called: not a
// shape this pass can reason about, so it poisons the declaration instead of
// being guessed at.
return ImageBuiltinAccess::Unknown;
}
struct ImageUniformDecl {
String name;
String writeName; // the writeonly half's name, when split
String layout; // raw contents of layout(...)
String qualifiers; // memory/precision qualifiers, normalized, no trailing space
String type; // image2D, uimage2DArray, ...
String arraySuffix; // "" or "[7]"
SizeT declStart = 0;
SizeT declLength = 0;
SizeT referenceCount = 0; // uses this pass recognized and accounted for
Bool loaded = false;
Bool stored = false;
Bool unknownUse = false;
Bool split = false;
};
// A rebuilt declaration. Keeps SPIRV-Cross's own word order (`uniform readonly
// highp image2D`) so the image-rebinding regex in Managers.cpp still matches what
// comes out of here, whichever order the two passes end up running in.
String BuildImageDeclaration(const ImageUniformDecl& decl, const char* memoryQualifier,
const String& variableName) {
String out = "layout(" + decl.layout + ") uniform ";
out += memoryQualifier;
out += ' ';
if (!decl.qualifiers.empty()) {
out += decl.qualifiers;
out += ' ';
}
out += decl.type;
out += ' ';
out += variableName;
out += decl.arraySuffix;
out += ';';
return out;
}
// A name for the writeonly half that no identifier in the shader (and no other
// half already minted) can collide with.
String MakeImageWriteAliasName(const String& name, const String& source,
const Vector<String>& taken) {
String candidate = String(IMAGE_WRITE_ALIAS_PREFIX) + name;
// "__" anywhere in an identifier is reserved (GLSL ES 3.20 3.7), which a name
// that already starts with '_' would otherwise produce.
for (SizeT doubled = candidate.find("__"); doubled != String::npos;
doubled = candidate.find("__", doubled)) {
candidate.erase(doubled, 1);
}
auto isTaken = [&](const String& identifier) {
if (ContainsIdentifier(source, identifier)) return true;
for (const auto& other : taken) {
if (other == identifier) return true;
}
return false;
};
while (isTaken(candidate)) candidate += 'X';
return candidate;
}
struct ImageSourceEdit {
SizeT start;
SizeT length;
String text;
};
} // namespace
String SplitReadWriteImageUniforms(const String& glslCode) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (glslCode.find("image") == String::npos) {
return glslCode;
}
// layout(...) uniform <memory/precision qualifiers> <image type> <name>[array];
// The qualifier alternation is order-free even though SPIRV-Cross emits a fixed
// order (to_qualifiers_glsl: storage, then coherent/restrict/readonly/writeonly,
// then precision), and the array group is repeated so a hypothetical multi-
// dimensional image array survives the round trip intact.
static const std::regex imageDeclRegex(
R"(layout\s*\(([^)]*)\)\s*uniform\s+)"
R"(((?:(?:readonly|writeonly|coherent|volatile|restrict|highp|mediump|lowp)\s+)*))"
R"(([iu]?image[A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*((?:\[[^\]]*\]\s*)*);)");
Vector<ImageUniformDecl> decls;
for (std::sregex_iterator it(glslCode.begin(), glslCode.end(), imageDeclRegex), last; it != last; ++it) {
const std::smatch& match = *it;
const String qualifiers = match[2].str();
// Already legal: SPIRV-Cross decided one way, leave it alone.
if (ContainsIdentifier(qualifiers, "readonly") || ContainsIdentifier(qualifiers, "writeonly")) {
continue;
}
Bool hasFormat = false;
Bool exemptFormat = false;
for (const String& token : SplitLayoutQualifierList(match[1].str())) {
if (!IsImageFormatQualifier(token)) continue;
hasFormat = true;
exemptFormat = IsMemoryQualifierExemptImageFormat(token);
}
// No format qualifier at all is a different (and, in ES, unconditionally
// illegal) shape that GL_EXT_shader_image_load_formatted would be needed for;
// SPIRV-Cross refuses to emit it for an ES target, so nothing to do here.
if (!hasFormat || exemptFormat) continue;
ImageUniformDecl decl;
decl.layout = match[1].str();
decl.qualifiers = NormalizeDeclarationSpacing(qualifiers);
decl.type = match[3].str();
decl.name = match[4].str();
decl.arraySuffix = NormalizeDeclarationSpacing(match[5].str());
decl.declStart = static_cast<SizeT>(match.position(0));
decl.declLength = match[0].str().size();
decls.push_back(Move(decl));
}
if (decls.empty()) {
return glslCode;
}
auto findDecl = [&decls](const String& name) -> SizeT {
for (SizeT i = 0; i < decls.size(); ++i) {
if (decls[i].name == name) return i;
}
return decls.size();
};
// Walk every `image*(` call and attribute its first argument to a declaration.
struct StoreSite {
SizeT declIndex;
SizeT start;
SizeT length;
};
Vector<StoreSite> storeSites;
for (SizeT pos = glslCode.find("image"); pos != String::npos; pos = glslCode.find("image", pos + 1)) {
if (pos > 0 && IsImagePassIdentifierChar(glslCode[pos - 1])) continue; // uimage2D, myimageFoo
SizeT tokenEnd = pos;
while (tokenEnd < glslCode.size() && IsImagePassIdentifierChar(glslCode[tokenEnd])) ++tokenEnd;
const String builtin = glslCode.substr(pos, tokenEnd - pos);
const SizeT openParen = glslCode.find_first_not_of(" \t\r\n", tokenEnd);
if (openParen == String::npos || glslCode[openParen] != '(') continue; // a type, not a call
const SizeT argStart = glslCode.find_first_not_of(" \t\r\n", openParen + 1);
if (argStart == String::npos) continue;
if (!std::isalpha(static_cast<unsigned char>(glslCode[argStart])) && glslCode[argStart] != '_') {
continue; // an expression, not a bare variable - it names no image of ours
}
SizeT argEnd = argStart;
while (argEnd < glslCode.size() && IsImagePassIdentifierChar(glslCode[argEnd])) ++argEnd;
const SizeT declIndex = findDecl(glslCode.substr(argStart, argEnd - argStart));
if (declIndex == decls.size()) continue;
ImageUniformDecl& decl = decls[declIndex];
++decl.referenceCount;
// The operand has to be the bare variable, optionally subscripted. Anything
// else (a member access, a call result) is a shape this pass cannot rewrite.
SizeT after = glslCode.find_first_not_of(" \t\r\n", argEnd);
if (after != String::npos && glslCode[after] == '[') {
Int depth = 0;
SizeT scan = after;
for (; scan < glslCode.size(); ++scan) {
if (glslCode[scan] == '[') ++depth;
else if (glslCode[scan] == ']' && --depth == 0) break;
}
after = scan >= glslCode.size() ? String::npos
: glslCode.find_first_not_of(" \t\r\n", scan + 1);
}
const char nextChar = after == String::npos ? '\0' : glslCode[after];
if (nextChar != ',' && nextChar != ')') {
decl.unknownUse = true;
continue;
}
switch (ClassifyImageBuiltin(builtin)) {
case ImageBuiltinAccess::Load:
decl.loaded = true;
break;
case ImageBuiltinAccess::Store:
decl.stored = true;
storeSites.push_back({declIndex, argStart, argEnd - argStart});
break;
case ImageBuiltinAccess::None:
break;
default:
decl.unknownUse = true;
break;
}
}
// Every mention of the name has to be one this pass saw, or the split would leave
// a store pointing at the readonly half. One occurrence is the declaration itself.
for (auto& decl : decls) {
if (CountIdentifierOccurrences(glslCode, decl.name) != decl.referenceCount + 1) {
decl.unknownUse = true;
}
}
Vector<ImageSourceEdit> edits;
Vector<String> takenAliases;
for (auto& decl : decls) {
if (decl.unknownUse) continue; // leave it exactly as it was; no guessing
if (decl.loaded && decl.stored) {
decl.writeName = MakeImageWriteAliasName(decl.name, glslCode, takenAliases);
takenAliases.push_back(decl.writeName);
decl.split = true;
edits.push_back({decl.declStart, decl.declLength,
BuildImageDeclaration(decl, "readonly", decl.name) + "\n" +
BuildImageDeclaration(decl, "writeonly", decl.writeName)});
} else if (decl.stored) {
edits.push_back({decl.declStart, decl.declLength,
BuildImageDeclaration(decl, "writeonly", decl.name)});
} else {
// Loaded only, or only ever handed to imageSize (or unused): readonly is
// the qualifier that keeps every one of those legal.
edits.push_back({decl.declStart, decl.declLength,
BuildImageDeclaration(decl, "readonly", decl.name)});
}
}
for (const StoreSite& site : storeSites) {
const ImageUniformDecl& decl = decls[site.declIndex];
if (!decl.split) continue;
edits.push_back({site.start, site.length, decl.writeName});
}
if (edits.empty()) {
return glslCode;
}
// Back to front, so an earlier edit's offsets stay valid.
std::sort(edits.begin(), edits.end(),
[](const ImageSourceEdit& a, const ImageSourceEdit& b) { return a.start > b.start; });
String result = glslCode;
for (const ImageSourceEdit& edit : edits) {
result.replace(edit.start, edit.length, edit.text);
}
return result;
}
namespace {
// How a lookup carries its level of detail, and how many arguments it takes
// before the optional bias.
@@ -527,7 +1060,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
} // namespace
String EmulateTextureLodBias(const String& glslCode) {
String EmulateTextureLodBias(const String& glslCode, Bool avoidExplicitLodBias) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
@@ -588,6 +1121,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (samplerIt == samplerNames.end()) continue;
const String& biasName = samplerIt->second;
if (form->explicitLodArg >= 0 && avoidExplicitLodBias) {
// The lookup already names its level; leaving it alone keeps a constant
// LOD constant. Costs the bias on explicit-LOD lookups only.
continue;
}
if (form->explicitLodArg >= 0) {
// Explicit LOD: the bias adds to it, as Vulkan does for
// OpImageSampleExplicitLod and as the CTS reference expects.
@@ -638,7 +1176,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
for (GLenum err = g_GLESFuncs.glGetError(); err != GL_NO_ERROR; err = g_GLESFuncs.glGetError()) {
MGLOG_E("-> GLES Error: %s", MG_Util::ConvertGLEnumToString(err).c_str());
MGLOG_D("-> GLES Error: %s", MG_Util::ConvertGLEnumToString(err).c_str());
}
}
@@ -1058,88 +1596,71 @@ namespace MobileGL::MG_Backend::DirectGLES {
return (rowBytes + align - 1) / align * align;
}
// Repacks wide RGBA(_INTEGER) rows into the client's (format, type) layout, honoring the
// client-side PACK parameters and the bound pixel-pack buffer. `wide` holds
// `sliceHeight * sliceCount` rows of `width` texels (slice-major, tightly stacked),
// 4 components x GetReadbackComponentSize(wideType) bytes each.
// Walks the client-side destination the PACK parameters describe and hands each row to
// `fillRow(slice, row, dstRow)`, which writes width * dstPixelBytes bytes of finished client
// texels. Shared by the converting and the raw-word stores so both address the destination -
// and feed the bound pixel-pack buffer - identically.
// applyPackImageParams: GL_PACK_IMAGE_HEIGHT / GL_PACK_SKIP_IMAGES apply only to GetTexImage
// of 3D/array images; ReadPixels and 2D GetTexImage ignore them (GL 3.3 sections 4.3.1, 6.1.4).
// Per the GL addressing rules, slice k row j lands at
// SKIP_IMAGES*imageStride + SKIP_ROWS*rowStride + SKIP_PIXELS*pixelBytes
// + k*imageStride + j*rowStride, with imageStride = max(IMAGE_HEIGHT, sliceHeight)*rowStride.
Bool StoreWideRowsToClient(const Uint8* wide, GLenum wideType, GLsizei width, GLsizei sliceHeight,
GLsizei sliceCount, const ReadbackChannelMapping& mapping, GLenum type,
void* pixels, Bool applyPackImageParams) {
const SizeT dstPixelBytes = GetReadbackDstPixelSize(mapping, type);
if (dstPixelBytes == 0) {
return false;
}
PackedReadbackLayout packedLayout{};
const Bool isPackedType = GetPackedReadbackLayout(type, packedLayout);
const SizeT dstComponentSize = GetReadbackComponentSize(type);
template <typename FillRow>
static Bool StoreClientRows(SizeT dstPixelBytes, SizeT swapGroupSize, GLsizei width, GLsizei sliceHeight,
GLsizei sliceCount, void* pixels, Bool applyPackImageParams, FillRow&& fillRow) {
const auto& pixelPackBufferObject =
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject();
const auto& pixelPackBufferObject =
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject();
// Destination layout is computed from the client-side PACK parameters; only the actual pixel
// rows are written so skip regions of the destination stay untouched.
const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false);
const SizeT rowPixels = static_cast<SizeT>(packParams.RowLength > 0 ? packParams.RowLength : width);
const SizeT dstRowStride = AlignReadbackRow(rowPixels * dstPixelBytes, packParams.Alignment);
const SizeT imageRows =
applyPackImageParams && packParams.ImageHeight > 0
? static_cast<SizeT>(packParams.ImageHeight)
: static_cast<SizeT>(sliceHeight);
const SizeT dstImageStride = imageRows * dstRowStride;
const SizeT skipImages =
applyPackImageParams ? static_cast<SizeT>(std::max(packParams.SkipImages, 0)) : SizeT{0};
const SizeT dstSkipOffset = skipImages * dstImageStride +
static_cast<SizeT>(std::max(packParams.SkipRows, 0)) * dstRowStride +
static_cast<SizeT>(std::max(packParams.SkipPixels, 0)) * dstPixelBytes;
const SizeT dstRowBytes = static_cast<SizeT>(width) * dstPixelBytes;
// Destination layout is computed from the client-side PACK parameters; only the actual pixel
// rows are written so skip regions of the destination stay untouched.
const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false);
const SizeT rowPixels = static_cast<SizeT>(packParams.RowLength > 0 ? packParams.RowLength : width);
const SizeT dstRowStride = AlignReadbackRow(rowPixels * dstPixelBytes, packParams.Alignment);
const SizeT imageRows =
applyPackImageParams && packParams.ImageHeight > 0
? static_cast<SizeT>(packParams.ImageHeight)
: static_cast<SizeT>(sliceHeight);
const SizeT dstImageStride = imageRows * dstRowStride;
const SizeT skipImages =
applyPackImageParams ? static_cast<SizeT>(std::max(packParams.SkipImages, 0)) : SizeT{0};
const SizeT dstSkipOffset = skipImages * dstImageStride +
static_cast<SizeT>(std::max(packParams.SkipRows, 0)) * dstRowStride +
static_cast<SizeT>(std::max(packParams.SkipPixels, 0)) * dstPixelBytes;
const SizeT dstRowBytes = static_cast<SizeT>(width) * dstPixelBytes;
const SizeT pboBaseOffset = reinterpret_cast<SizeT>(pixels); // with a PBO, `pixels` is an offset
if (pixelPackBufferObject) {
const SizeT requiredSize = pboBaseOffset + dstSkipOffset +
static_cast<SizeT>(sliceCount - 1) * dstImageStride +
static_cast<SizeT>(sliceHeight - 1) * dstRowStride + dstRowBytes;
if (requiredSize > pixelPackBufferObject->GetSize()) {
MGLOG_E("Readback conversion: pixel pack buffer is too small");
return true;
const SizeT pboBaseOffset = reinterpret_cast<SizeT>(pixels); // with a PBO, `pixels` is an offset
if (pixelPackBufferObject) {
const SizeT requiredSize = pboBaseOffset + dstSkipOffset +
static_cast<SizeT>(sliceCount - 1) * dstImageStride +
static_cast<SizeT>(sliceHeight - 1) * dstRowStride + dstRowBytes;
if (requiredSize > pixelPackBufferObject->GetSize()) {
MGLOG_E_ONCE("Readback conversion: pixel pack buffer is too small");
return true;
}
}
}
const SizeT srcComponentSize = GetReadbackComponentSize(wideType);
const SizeT srcPixelBytes = 4 * srcComponentSize;
Vector<Uint8> convertedRow(dstRowBytes);
Vector<Uint8> convertedRow(dstRowBytes);
for (GLsizei slice = 0; slice < sliceCount; ++slice) {
for (GLsizei row = 0; row < sliceHeight; ++row) {
const SizeT flatRow = static_cast<SizeT>(slice) * static_cast<SizeT>(sliceHeight) +
static_cast<SizeT>(row);
const Uint8* srcRow = wide + flatRow * static_cast<SizeT>(width) * srcPixelBytes;
ConvertWideReadbackRow(srcRow, convertedRow.data(), static_cast<SizeT>(width), wideType,
mapping, type);
for (GLsizei slice = 0; slice < sliceCount; ++slice) {
for (GLsizei row = 0; row < sliceHeight; ++row) {
fillRow(slice, row, convertedRow.data());
if (packParams.SwapBytes) {
const SizeT groupSize = isPackedType ? packedLayout.byteSize : dstComponentSize;
if (groupSize > 1) {
for (SizeT offset = 0; offset + groupSize <= dstRowBytes; offset += groupSize) {
std::reverse(convertedRow.data() + offset, convertedRow.data() + offset + groupSize);
if (packParams.SwapBytes && swapGroupSize > 1) {
for (SizeT offset = 0; offset + swapGroupSize <= dstRowBytes; offset += swapGroupSize) {
std::reverse(convertedRow.data() + offset, convertedRow.data() + offset + swapGroupSize);
}
}
}
const SizeT dstOffset = dstSkipOffset + static_cast<SizeT>(slice) * dstImageStride +
static_cast<SizeT>(row) * dstRowStride;
if (pixelPackBufferObject) {
pixelPackBufferObject->WritebackFromBackend({convertedRow.data(), dstRowBytes},
pboBaseOffset + dstOffset);
} else {
Memcpy(static_cast<Uint8*>(pixels) + dstOffset, convertedRow.data(), dstRowBytes);
const SizeT dstOffset = dstSkipOffset + static_cast<SizeT>(slice) * dstImageStride +
static_cast<SizeT>(row) * dstRowStride;
if (pixelPackBufferObject) {
pixelPackBufferObject->WritebackFromBackend({convertedRow.data(), dstRowBytes},
pboBaseOffset + dstOffset);
} else {
Memcpy(static_cast<Uint8*>(pixels) + dstOffset, convertedRow.data(), dstRowBytes);
}
}
}
}
if (pixelPackBufferObject) {
// WritebackFromBackend bumps change serials with no backend op; re-open
// the buffer draw-clean memos (once for the whole row loop).
@@ -1147,5 +1668,52 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
return true;
}
// Repacks wide RGBA(_INTEGER) rows into the client's (format, type) layout, honoring the
// client-side PACK parameters and the bound pixel-pack buffer. `wide` holds
// `sliceHeight * sliceCount` rows of `width` texels (slice-major, tightly stacked),
// 4 components x GetReadbackComponentSize(wideType) bytes each.
Bool StoreWideRowsToClient(const Uint8* wide, GLenum wideType, GLsizei width, GLsizei sliceHeight,
GLsizei sliceCount, const ReadbackChannelMapping& mapping, GLenum type,
void* pixels, Bool applyPackImageParams) {
const SizeT dstPixelBytes = GetReadbackDstPixelSize(mapping, type);
if (dstPixelBytes == 0) {
return false;
}
PackedReadbackLayout packedLayout{};
const Bool isPackedType = GetPackedReadbackLayout(type, packedLayout);
const SizeT swapGroupSize = isPackedType ? packedLayout.byteSize : GetReadbackComponentSize(type);
const SizeT srcPixelBytes = 4 * GetReadbackComponentSize(wideType);
return StoreClientRows(dstPixelBytes, swapGroupSize, width, sliceHeight, sliceCount, pixels,
applyPackImageParams,
[&](GLsizei slice, GLsizei row, Uint8* dstRow) {
const SizeT flatRow = static_cast<SizeT>(slice) *
static_cast<SizeT>(sliceHeight) +
static_cast<SizeT>(row);
const Uint8* srcRow =
wide + flatRow * static_cast<SizeT>(width) * srcPixelBytes;
ConvertWideReadbackRow(srcRow, dstRow, static_cast<SizeT>(width), wideType,
mapping, type);
});
}
Bool StorePackedWordsToClient(const Uint8* srcWords, GLsizei width, GLsizei sliceHeight, GLsizei sliceCount,
GLenum type, void* pixels, Bool applyPackImageParams) {
PackedReadbackLayout packedLayout{};
if (!GetPackedReadbackLayout(type, packedLayout) || packedLayout.byteSize != 4) {
return false;
}
const SizeT srcRowBytes = static_cast<SizeT>(width) * 4;
return StoreClientRows(4, packedLayout.byteSize, width, sliceHeight, sliceCount, pixels,
applyPackImageParams,
[&](GLsizei slice, GLsizei row, Uint8* dstRow) {
const SizeT flatRow = static_cast<SizeT>(slice) *
static_cast<SizeT>(sliceHeight) +
static_cast<SizeT>(row);
Memcpy(dstRow, srcWords + flatRow * srcRowBytes, srcRowBytes);
});
}
} // namespace ReadbackImpl
} // namespace MobileGL::MG_Backend::DirectGLES
+77 -1
View File
@@ -115,6 +115,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool StoreWideRowsToClient(const Uint8* wide, GLenum wideType, GLsizei width, GLsizei sliceHeight,
GLsizei sliceCount, const ReadbackChannelMapping& mapping, GLenum type,
void* pixels, Bool applyPackImageParams);
// Stores packed 32-bit source words verbatim, with the same destination addressing, PACK
// parameters and pixel-pack-buffer handling as StoreWideRowsToClient. For the sources whose
// storage word already IS the client word (MG_Util::IsRawPackedPixelTransfer): routing those
// through the wide float intermediate re-encodes them, and the RGB9_E5 encoder canonicalizes
// the shared exponent, so glGetTexImage would answer with different bits than were stored.
// `srcWords` holds sliceHeight * sliceCount tightly stacked rows of `width` 32-bit words.
// False when `type` is not a 4-byte packed type.
Bool StorePackedWordsToClient(const Uint8* srcWords, GLsizei width, GLsizei sliceHeight, GLsizei sliceCount,
GLenum type, void* pixels, Bool applyPackImageParams);
} // namespace ReadbackImpl
namespace PrgramImpl {
@@ -130,7 +140,68 @@ namespace MobileGL::MG_Backend::DirectGLES {
// drawBufferCount <= 1, i.e. for everything but a framebuffer that actually
// enables several draw buffers, so the ordinary single-target shader is untouched.
String BroadcastLegacyFragColor(String glslCode, GLenum shaderType, Uint drawBufferCount);
// SPIRV-Cross emits `#extension GL_EXT_texture_buffer : require` for every buffer-texture
// sampler when it targets ESSL below 320, and offers no way to ask for the OES spelling.
// On a driver that advertises only GL_OES_texture_buffer that directive is a compile
// error, so the name is retargeted in the emitted source. A no-op on every other tier:
// ES 3.2 needs no directive at all and an EXT driver already has the right one.
String RetargetTextureBufferExtension(String glslCode,
MG_External::GLESCapabilities::TextureBufferTier tier);
// Adds `#extension GL_NV_image_formats : require` when the shader carries an image
// format qualifier GLSL ES has no core spelling for. SPIRV-Cross prints the format and
// asks for nothing, so the request has to be made here. `needed` is the caller's answer,
// because only it knows which formats are in play AND whether the driver advertises the
// extension - requesting an unadvertised extension is itself a compile error, so this is
// never emitted speculatively. A no-op when not needed or already present.
String RequestExtendedImageFormats(String glslCode, Bool needed);
// Writes a format layout qualifier into the image declarations named in
// `esslFormatByUniformName` that still have none. The completion half of the image-format
// bake, and ONLY that: the SPIR-V pass (BakeImageFormatsPass) is what normally puts the
// format in, but SPIRV-Cross throws rather than printing the formats it calls
// desktop-only when it targets ESSL - r8ui among them, which is what the stencil half of
// KHR-GL4x.packed_depth_stencil.stencil_texturing binds - and a throw loses the whole
// stage. So those formats stay out of the module and are spelled here instead, on the
// emitted text, where nothing can refuse them.
//
// Declarations that already carry a format are left exactly as they are, whoever wrote
// it. Must run before RemoveLayoutBinding, which is where an image's layout qualifier
// stops being safe to edit by hand.
String BakeImageFormatQualifiers(String glslCode, const UnorderedMap<String, String>& esslFormatByUniformName);
String RemoveLayoutBinding(const String& glslCode);
// Prefix of the writeonly half a read+write image uniform is split into (see
// SplitReadWriteImageUniforms); the suffix is the image's own name.
constexpr const char* IMAGE_WRITE_ALIAS_PREFIX = "mg_imageWrite_";
// ESSL refuses an image variable that carries a format qualifier other than r32f /
// r32i / r32ui unless it also carries `readonly` or `writeonly` (GLSL ES 3.10 4.9 /
// 3.20 4.10; glslang enforces it verbatim in ParseHelper.cpp's layoutObjectCheck).
// SPIRV-Cross emits NEITHER for an image the shader both reads and writes: it
// speculatively decorates every storage image NonWritable+NonReadable
// (fixup_image_load_store_access), then OpImageRead clears NonReadable and
// OpImageWrite clears NonWritable, and to_qualifiers_glsl only prints `readonly`
// from NonWritable and `writeonly` from NonReadable. Desktop GLSL is happy with the
// bare declaration, so the frontend raises no error and the illegal ESSL only shows
// up as a device compile failure - and then as a silently no-op draw.
//
// Restores a legal declaration:
// * loaded only -> add `readonly`
// * stored only -> add `writeonly`
// * both -> emit TWO declarations on the same binding and of the
// same type, `readonly <name>` and `writeonly
// <IMAGE_WRITE_ALIAS_PREFIX><name>`, and point every
// imageStore at the second one. Several image variables
// may share an image unit as long as they have the same
// type and format, which is exactly what the pair is.
//
// Budget note: the split DOUBLES the image-uniform count of the stage it fires in, so
// a driver advertising a tight GL_MAX_{FRAGMENT,VERTEX,...}_IMAGE_UNIFORMS can turn a
// shader that used to compile into a link failure. ES only guarantees 4 fragment image
// uniforms, so a shader with more than half the limit in read+write images is the case
// to watch.
//
// Runs on the transpiled ESSL, so it must see the bindings the frontend units were
// already rewritten to and must run before those bindings are stripped - see the call
// site in Managers.cpp.
String SplitReadWriteImageUniforms(const String& glslCode);
// Prefix of the per-sampler float uniform that carries GL_TEXTURE_LOD_BIAS into
// the shader (see EmulateTextureLodBias); the suffix is the sampler's own name.
constexpr const char* LOD_BIAS_UNIFORM_PREFIX = "mg_lodBias_";
@@ -142,7 +213,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the bound texture's (or sampler object's) value into it; a shader whose samplers
// all have a zero bias is therefore unaffected. Returns the source unchanged when
// there is nothing to rewrite.
String EmulateTextureLodBias(const String& glslCode);
//
// avoidExplicitLodBias leaves lookups that already carry an explicit LOD untouched,
// so their constant level stays constant; only the implicit-LOD forms take the bias.
// Off by default and only ever set on ANGLE + llvmpipe, where injecting the uniform
// into a constant LOD crashes the driver (MOBILEGL_AVOID_EXPLICIT_LOD_BIAS).
String EmulateTextureLodBias(const String& glslCode, Bool avoidExplicitLodBias = false);
} // namespace PrgramImpl
namespace Utils {
@@ -517,6 +517,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
E_GL_ARB_gpu_shader_int64, E_GL_KHR_debug, E_GL_ARB_gpu_shader5, E_GL_ARB_multi_bind,
E_GL_ARB_shading_language_420pack, E_GL_ARB_vertex_attrib_binding, E_GL_ARB_shader_image_size,
E_GL_ARB_explicit_attrib_location,
// Core since GL 3.1 and implemented for every version advertised here. The string
// matters because applications gate the ENTRY POINTS on it rather than on the
// version: a caller that finds the extension missing never resolves
// glGetUniformBlockIndex / glUniformBlockBinding, and one that then uses uniform
// blocks anyway calls through a null pointer.
E_GL_ARB_uniform_buffer_object,
// Sampling the stencil aspect through DEPTH_STENCIL_TEXTURE_MODE. Core from 4.3,
// so on a 4.0 context the string is the only way to reach it.
E_GL_ARB_stencil_texturing,
// Advertised with GL_NUM_PROGRAM_BINARY_FORMATS = 0, which the
// extension explicitly permits. It is also the only thing that
// exposes glProgramParameteri before GL 4.1.
@@ -539,6 +548,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (MG_Util::Async::AsyncShaderCompileEnabled()) {
extensions.push_back(E_GL_KHR_parallel_shader_compile);
}
// GL_ARB_gpu_shader_fp64 is opt-in (MOBILEGL_ADVERTISE_FP64). Every `double` in a
// shader compiles and runs already - it is narrowed to 32 bits before the module
// reaches this backend - so an application that simply uses doubles needs nothing
// advertised. What the extension additionally promises is 64-bit PRECISION, which no
// mobile GPU has and the narrowing cannot fake, so advertising it by default would
// make an application that checks the string take a path MobileGL cannot honour.
if (MG_Config::Features.AdvertiseFp64) {
extensions.push_back(E_GL_ARB_gpu_shader_fp64);
}
// GL_ARB_timer_query gates MC's F3 GPU% (LWJGL checks the extension string);
// only advertised when the device actually supports timestamp queries and the
// MOBILEGL_DISABLE_TIMERQUERY escape hatch is off.
@@ -877,7 +895,27 @@ namespace MobileGL::MG_Backend::DirectVulkan {
DynParams::PerLayerFramebufferAttachmentBit(TextureTarget::TextureCubeMapArray);
}
}
m_dynamicParameters.SupportsFloat64VertexAttributes = m_vulkanCaps.SupportsShaderFloat64;
// Never, on any device, and no longer for the reason it used to be. It used to track
// shaderFloat64 because a `dvec3` input needed the Float64 capability to exist in the
// module at all; a 64-bit vertex FETCH was already impossible (VK_FORMAT_R64*_SFLOAT is
// optional and lavapipe reports zero bufferFeatures for all four), so the attribute
// arrived as its 32-bit word pair and PackDoubleVertexInputsPass bitcast it back.
//
// The shader half of that is gone: every 64-bit float is narrowed before any module
// reaches a backend (ShaderTranspiler::DemoteFloat64Pass), so there is no `double` input
// left to bitcast INTO, and feeding a UINT-formatted attribute to what is now a `float`
// input would be silent garbage. Reconstructing the value would mean decoding the
// IEEE-754 double bit pattern in the shader - software fp64, which is precisely what the
// demotion exists to avoid - and on Espryt it would additionally need the ES driver to
// fetch 2N uint components where the application declared N doubles, which a dvec3 or
// dvec4 cannot even express within one attribute location.
//
// So glVertexAttribLFormat / glVertexAttribLPointer are declined here exactly as they
// already were on Espryt and on every real mobile device (Adreno and Mali both report
// shaderFloat64 == VK_FALSE), and for the same visible reason. A `dvec3` INPUT still
// compiles and draws - it is a `vec3` after demotion - as long as the application feeds
// it with glVertexAttribPointer(GL_FLOAT) rather than 64-bit data.
m_dynamicParameters.SupportsFloat64VertexAttributes = false;
m_dynamicParameters.MaxShaderStorageBlockSize =
std::min(m_vulkanCaps.MaxShaderStorageBlockSize, kMaxAdvertisedShaderStorageBlockSize);
if (m_vulkanCaps.SupportsShaderSubgroup) {
@@ -269,14 +269,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
drawBuffer->SyncPersistentMappedRange();
const SizeT commandOffset = reinterpret_cast<SizeT>(indirect);
if (drawBuffer->MappedData() == nullptr || commandOffset + requiredBytes > drawBuffer->GetSize()) {
MGLOG_E("%s skipped: invalid GL_DRAW_INDIRECT_BUFFER binding or range", label);
MGLOG_E_ONCE("%s skipped: invalid GL_DRAW_INDIRECT_BUFFER binding or range", label);
return nullptr;
}
return drawBuffer->MappedData() + commandOffset;
}
if (!indirect) {
MGLOG_E("%s skipped: indirect pointer is null", label);
MGLOG_E_ONCE("%s skipped: indirect pointer is null", label);
return nullptr;
}
@@ -398,7 +398,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
stride = sizeof(DrawArraysIndirectCommand);
}
if (stride < static_cast<GLsizei>(sizeof(DrawArraysIndirectCommand))) {
MGLOG_E("MultiDrawArraysIndirect skipped: stride %d is smaller than command size %zu",
MGLOG_E_ONCE("MultiDrawArraysIndirect skipped: stride %d is smaller than command size %zu",
stride, sizeof(DrawArraysIndirectCommand));
return;
}
@@ -446,20 +446,20 @@ namespace MobileGL::MG_Backend::DirectVulkan {
stride = sizeof(DrawArraysIndirectCommand);
}
if (stride < static_cast<GLsizei>(sizeof(DrawArraysIndirectCommand))) {
MGLOG_E("MultiDrawArraysIndirectCount skipped: stride %d is smaller than command size %zu",
MGLOG_E_ONCE("MultiDrawArraysIndirectCount skipped: stride %d is smaller than command size %zu",
stride, sizeof(DrawArraysIndirectCommand));
return;
}
auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject();
if (!parameterBuffer || drawcount < 0 || static_cast<SizeT>(drawcount) + sizeof(Uint32) > parameterBuffer->GetSize()) {
MGLOG_E("MultiDrawArraysIndirectCount skipped: invalid GL_PARAMETER_BUFFER binding or range");
MGLOG_E_ONCE("MultiDrawArraysIndirectCount skipped: invalid GL_PARAMETER_BUFFER binding or range");
return;
}
parameterBuffer->SyncPersistentMappedRange();
if (parameterBuffer->MappedData() == nullptr) {
MGLOG_E("MultiDrawArraysIndirectCount skipped: CPU fallback cannot read parameter buffer");
MGLOG_E_ONCE("MultiDrawArraysIndirectCount skipped: CPU fallback cannot read parameter buffer");
return;
}
@@ -513,7 +513,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const SizeT indexSize = MG_Util::GetGLTypeSize(type);
if (indexSize == 0) {
MGLOG_E("DrawElementsIndirect skipped: unsupported index type 0x%x", type);
MGLOG_E_ONCE("DrawElementsIndirect skipped: unsupported index type 0x%x", type);
return;
}
@@ -801,9 +801,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// already recorded the new binding on the program - which is what reseeds this cache
// whenever it is rebuilt. Writing the entry here as well keeps an ALREADY-BUILT cache
// (the common case: the very next draw reads it) from having to be thrown away.
auto& cache = GetProgramResourceCache(*programObject);
//
// Resolve the index BEFORE taking the reference, and bounds-check the way the
// sibling getter does. GetShaderStorageBlockIndex re-enters GetProgramResourceCache,
// which indexes g_programResourceCaches and can therefore insert - and that map is
// open-addressed, so a rehash MOVES its entries and a reference taken before the
// call is left dangling. Binding a program's storage block
// while another program's entry was still absent from the cache was a reproducible
// segfault (ProgramPipelineScenario's two storage-block cases, in one process).
const GLuint blockIndex = GetShaderStorageBlockIndex(*programObject, storageBlockName);
if (blockIndex == GL_INVALID_INDEX) return;
auto& cache = GetProgramResourceCache(*programObject);
if (blockIndex >= cache.storageBlocks.size()) return;
cache.storageBlocks[blockIndex].binding = storageBlockBinding;
}
void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) {
@@ -1000,7 +1009,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// shift - the hardware divide was the hottest instruction of this loop.
const SizeT indexSize = MG_Util::GetGLTypeSize(type);
if (indexSize == 0) {
MGLOG_E("MultiDrawElements skipped: unsupported index type 0x%x", type);
MGLOG_E_ONCE("MultiDrawElements skipped: unsupported index type 0x%x", type);
return;
}
const Uint32 indexSizeShift = static_cast<Uint32>(std::countr_zero(indexSize));
@@ -205,7 +205,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// commands away. The device is gone on that path anyway - stay silent-safe
// rather than trade a lost device for a barrier into a closed buffer.
if (frame.hasCommandBufferRecorded) {
MGLOG_E("TransitionToPresent: command buffer already closed; skipping the present barrier");
MGLOG_E_ONCE("TransitionToPresent: command buffer already closed; skipping the present barrier");
return false;
}
@@ -259,7 +259,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// is the correct price for a broken pipeline and is bounded by the draw itself being
// skipped.
if (pipeline == VK_NULL_HANDLE) {
MGLOG_I("PipelineFactory::GetOrCreatePipeline: creation failed for hash=0x%llx "
// Unlatched, like the CreatePipeline report it accompanies: a pipeline MobileGL
// assembled and the driver refused is a broken invariant, not an expected failure,
// so it stays loud for as long as it is reachable. Raised from MGLOG_I once the
// Log.h ordering fix made MGLOG_E live in INFO builds.
MGLOG_E("PipelineFactory::GetOrCreatePipeline: creation failed for hash=0x%llx "
"programHash=0x%llx; not caching the failure",
static_cast<unsigned long long>(hash),
static_cast<unsigned long long>(payload.programHash));
@@ -471,9 +475,56 @@ namespace MobileGL::MG_Backend::DirectVulkan {
blend.attachmentCount = payload.colorAttachmentCount;
blend.pAttachments = colorAttachments.empty() ? nullptr : colorAttachments.data();
// A GL program may have a tessellation EVALUATION stage and no CONTROL stage: GL 4.6 core
// 11.2.2 gives it a fixed-function pass-through instead. Vulkan has no such stage, and
// VUID-VkGraphicsPipelineCreateInfo-pStages-00730 requires both tessellation stages or
// neither - so the renderer synthesizes the pass-through GL describes and hands it in
// here (see ProgramFactory::GetOrCreatePassthroughTessControlStage).
//
// The refusal below is what keeps the half-tessellated shape away from the driver when
// there is no synthesized stage to add - because Mali does not reject it, it dereferences
// null INSIDE vkCreateGraphicsPipelines and takes the process down (SIGSEGV, fault addr
// 0x34, on Mali-G715/r54p2 and Mali-G925/r49p1 alike; Adreno and lavapipe merely render
// wrong). Returning VK_NULL_HANDLE routes this through the same path a driver rejection
// takes: the draw is skipped, nothing is memoised, and the process survives.
const Vector<VkPipelineShaderStageCreateInfo>* effectiveStages = payload.stages;
Vector<VkPipelineShaderStageCreateInfo> stagesWithPassthrough;
if (payload.passthroughTessControlStage.module != VK_NULL_HANDLE) {
stagesWithPassthrough = *payload.stages;
stagesWithPassthrough.push_back(payload.passthroughTessControlStage);
effectiveStages = &stagesWithPassthrough;
}
{
VkShaderStageFlags stagesPresent = 0;
for (const auto& stageInfo : *effectiveStages) {
stagesPresent |= stageInfo.stage;
}
const Bool hasTessControl = (stagesPresent & VK_SHADER_STAGE_TESSELLATION_CONTROL_BIT) != 0;
const Bool hasTessEval = (stagesPresent & VK_SHADER_STAGE_TESSELLATION_EVALUATION_BIT) != 0;
if (hasTessControl != hasTessEval) {
// Latched, and the latch is the point: a failed creation is deliberately never
// memoised (see GetOrCreatePipeline), so a program in this state re-enters here
// once per draw, every frame - and a refusal diagnostic that repeats per draw is
// noise, not a diagnostic. One line names the program; the draws it explains are
// all the same draw.
static Bool s_warnedHalfTessellatedPipeline = false;
if (!s_warnedHalfTessellatedPipeline) {
s_warnedHalfTessellatedPipeline = true;
MGLOG_E_ONCE("PipelineFactory::CreatePipeline: refusing a pipeline with %s tessellation stage and "
"no %s stage (VUID-VkGraphicsPipelineCreateInfo-pStages-00730). programHash=0x%llx "
"patchControlPoints=%u. Its draws are skipped; logged once.",
hasTessEval ? "an evaluation" : "a control",
hasTessEval ? "control" : "evaluation",
static_cast<unsigned long long>(payload.programHash),
payload.patchControlPoints);
}
return VK_NULL_HANDLE;
}
}
VkGraphicsPipelineCreateInfo gpi{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO};
gpi.stageCount = static_cast<Uint32>(payload.stages->size());
gpi.pStages = payload.stages->data();
gpi.stageCount = static_cast<Uint32>(effectiveStages->size());
gpi.pStages = effectiveStages->data();
gpi.pVertexInputState = payload.vertexInputState;
gpi.pInputAssemblyState = &ia;
gpi.pTessellationState =
@@ -490,6 +541,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkPipeline pipeline = VK_NULL_HANDLE;
const VkResult result = vkCreateGraphicsPipelines(m_device, m_pipelineCache, 1, &gpi, nullptr, &pipeline);
// Loud, at MGLOG_F, and deliberately NOT latched. vkCreateGraphicsPipelines refusing a
// pipeline MobileGL assembled is a should-never-happen state, and the driver's own
// answer is VK_ERROR_UNKNOWN - no information at all - so this dump is the entire
// diagnosis. It is not an expected failure mode, so the one-shot rule that quiets W/E
// does not apply: while this is reachable it should keep saying so on every draw.
// GetOrCreatePipeline deliberately does not cache the failure, which is what makes that
// repetition happen; if the repetition ever needs to stop, fix the pipeline, not the log.
if (result != VK_SUCCESS) {
MGLOG_F("PipelineFactory::CreatePipeline failed: result=%s (%d) programHash=0x%llx vertexInputHash=0x%llx stageCount=%u topology=%s(%d) colorAttachmentCount=%u samples=%s(%d) subpass=%u",
VkResultToString(result),
@@ -522,8 +580,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
payload.vertexInputState->vertexAttributeDescriptionCount);
// The driver's own answer is VK_ERROR_UNKNOWN, i.e. no information at all, so the only
// way to work out WHICH shader it choked on (the open sampler-array-in-struct
// investigation) is to name the modules. MGLOG_I, not _D/_E: this must survive in the
// INFO-level builds that CTS actually runs against.
// investigation) is to name the modules. MGLOG_I, not _D: this is part of a
// should-never-happen report and must survive in the INFO-level builds that CTS
// actually runs against, alongside the MGLOG_F lines above.
if (payload.stageSpirvDigests) {
for (SizeT i = 0; i < payload.stageSpirvDigests->size(); ++i) {
const auto& digest = (*payload.stageSpirvDigests)[i];
@@ -71,6 +71,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool fragmentReplacesDepth = false;
Array<VkPipelineColorBlendAttachmentState, kMaxColorAttachments> colorBlendAttachments{};
const Vector<VkPipelineShaderStageCreateInfo>* stages = nullptr;
// The tessellation control stage this renderer synthesized for a program that has
// an evaluation stage and none of its own (GL 4.6 core 11.2.2 gives such a program a
// fixed-function pass-through; Vulkan has no such thing and
// VUID-VkGraphicsPipelineCreateInfo-pStages-00730 forbids the half-tessellated
// pipeline outright). Appended to `stages` at creation. A null module means the
// renderer could not build one, and CreatePipeline refuses the pipeline - the same
// refusal it applies when `stages` itself is half-tessellated.
//
// NOT hashed: it is a pure function of the program and of patchControlPoints, both
// of which ComputeHash already mixes in.
VkPipelineShaderStageCreateInfo passthroughTessControlStage{};
const VkPipelineVertexInputStateCreateInfo* vertexInputState = nullptr;
// Diagnostic only; may be null. Read solely from the pipeline-creation failure path.
const Vector<ShaderStageSpirvDigest>* stageSpirvDigests = nullptr;
@@ -376,12 +376,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spv_diagnostic diagnostic = nullptr;
const spv_result_t result = spvValidateWithOptions(context, options, &binary, &diagnostic);
if (result != SPV_SUCCESS) {
// MGLOG_I, not E: at the INFO compile level of the CI/test lanes that arm
// the validation switch, MGLOG_E is compiled out (Log.h orders
// DEBUG < WARN < ERROR < INFO) and the VUID would never reach a log. The
// latch is what a test harness asserts on.
// MGLOG_E, unlatched: reaching here already requires the validation switch to
// be armed, which bounds the volume, and each VUID names a different defect.
// (Parked at MGLOG_I until the Log.h level ordering was fixed, when E was
// compiled out of every INFO build.) The latch is what a test harness asserts on.
MG_Util::ShaderTranspiler::ShaderCompiler::NoteSpirvValidationFailure();
MGLOG_I(
MGLOG_E(
"ProgramFactory::ValidateTransformedSpirv: validation failed for stage=%d program=%u result=%d index=%zu msg=%s",
static_cast<Int>(shaderStage),
programExternalIndex,
@@ -1266,7 +1266,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
for (SizeT i = 1; i < group.offsets.size(); ++i) {
if (group.elementBytes == 0 ||
group.offsets[i] != group.offsets[i - 1] + group.elementBytes) {
MGLOG_I("XfbCaptureDecoratePass: block member %u of type %%%u is captured with a "
MGLOG_D("XfbCaptureDecoratePass: block member %u of type %%%u is captured with a "
"non-contiguous element set; the capture layout will differ from GL's",
key.second, key.first);
break;
@@ -1721,6 +1721,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return ProgramFactory::DescriptorBindingKind::CombinedImageSampler;
case SPV_REFLECT_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER:
return ProgramFactory::DescriptorBindingKind::UniformTexelBuffer;
case SPV_REFLECT_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER:
return ProgramFactory::DescriptorBindingKind::StorageTexelBuffer;
case SPV_REFLECT_DESCRIPTOR_TYPE_STORAGE_BUFFER:
return ProgramFactory::DescriptorBindingKind::StorageBuffer;
case SPV_REFLECT_DESCRIPTOR_TYPE_STORAGE_IMAGE:
@@ -1750,6 +1752,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
if (kind == ProgramFactory::DescriptorBindingKind::CombinedImageSampler ||
kind == ProgramFactory::DescriptorBindingKind::UniformTexelBuffer ||
kind == ProgramFactory::DescriptorBindingKind::StorageTexelBuffer ||
kind == ProgramFactory::DescriptorBindingKind::StorageImage) {
const auto arraySuffix = name.find("[0]");
if (arraySuffix != String::npos) {
@@ -1834,10 +1837,33 @@ namespace MobileGL::MG_Backend::DirectVulkan {
for (auto* binding : bindings) {
MOBILEGL_ASSERT(binding != nullptr, "ProgramFactory: null descriptor binding reflection record");
const auto kind = ReflectDescriptorTypeToBindingKind(binding->descriptor_type);
// UBO instance arrays (uniform Block {...} b[N];) occupy one binding with
// descriptorCount = N; other descriptor arrays stay unsupported and must
// fail program creation cleanly rather than continue with corrupt state.
if (binding->count != 1 && kind != ProgramFactory::DescriptorBindingKind::UniformBufferDynamic) {
// A descriptor ARRAY occupies one binding with descriptorCount = N, and is
// supported for exactly the kinds that have a per-element resolve path in
// UniformManager::BindProgramUniformBuffers: UBO instance arrays
// (uniform Block {...} b[N];), storage-block instance arrays, image uniform
// arrays, and combined-image-sampler arrays (uniform sampler2D s[N];).
// Anything else - the two TEXEL buffer kinds are what remain, samplerBuffer[N]
// and imageBuffer[N] - must fail program creation cleanly rather than continue
// with corrupt state. Their per-draw path writes pTexelBufferView as the
// address of a vector element sized for one descriptor per binding, so an
// array would not merely be unresolved, it would dangle.
//
// Getting listed here is not cosmetic: a kind that is rejected leaves
// GetOrCreateProgram's MOBILEGL_ASSERT(remapOk) as the only complaint, and
// that assert compiles out above DEBUG - so a release build SILENTLY kept
// glslang's per-stage auto-mapped binding numbers, skipping the cross-stage
// unification and the set->0 normalisation this function exists to do. A
// program with an image array plus any second descriptor got aliased
// bindings out of that, and a DEBUG build trapped on the same program.
// The refusal below is MGLOG_E and per-program-compile, so it reports every
// program it declines. It spent time at MGLOG_I because the old level
// ordering compiled E out of the builds that ship.
const Bool arraySupportedForKind =
kind == ProgramFactory::DescriptorBindingKind::UniformBufferDynamic ||
kind == ProgramFactory::DescriptorBindingKind::StorageBuffer ||
kind == ProgramFactory::DescriptorBindingKind::StorageImage ||
kind == ProgramFactory::DescriptorBindingKind::CombinedImageSampler;
if (binding->count != 1 && !arraySupportedForKind) {
MGLOG_E("ProgramFactory: descriptor arrays are unsupported for this descriptor "
"kind (name='%s' count=%u type=%d)",
binding->name ? binding->name : "<null>", binding->count,
@@ -1959,13 +1985,27 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// cannot be corrected and instanced draws with a non-zero baseInstance misrender; this
// detects the case so the user gets one warning instead of silent corruption.
Bool ProgramFactory::ReflectedReadsInstanceIndexBuiltin(const SpvReflectShaderModule& reflectModule) {
return ReflectedDeclaresInputBuiltin(reflectModule, SpvBuiltInInstanceIndex);
}
// GL's gl_BaseVertex and Vulkan's BaseVertex agree for indexed draws and disagree for every
// other command, so a program declaring the builtin needs the ZeroBaseVertex variant when a
// non-indexed draw uses it (see CompileOptionBit::ZeroBaseVertex). "Declares" rather than
// "reads" is the honest word and the useful one: the zeroing pass keeps the variable, so
// both variants of a program answer this question identically.
Bool ProgramFactory::ReflectedReadsBaseVertexBuiltin(const SpvReflectShaderModule& reflectModule) {
return ReflectedDeclaresInputBuiltin(reflectModule, SpvBuiltInBaseVertex);
}
Bool ProgramFactory::ReflectedDeclaresInputBuiltin(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.input_variable_count; ++variableIndex) {
const SpvReflectInterfaceVariable* variable = entryPoint.input_variables[variableIndex];
if (variable != nullptr &&
(variable->decoration_flags & SPV_REFLECT_DECORATION_BUILT_IN) != 0 &&
variable->built_in == SpvBuiltInInstanceIndex) {
variable->built_in == builtin) {
return true;
}
}
@@ -2224,6 +2264,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkProgramObject& entry) const {
entry.activeVertexInputLocationMask = 0;
entry.vertexInputTypes.fill(0);
entry.readsBaseVertexBuiltin = false;
for (SizeT moduleIndex = 0; moduleIndex < shaders.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (!shaders[moduleIndex] || shaders[moduleIndex]->GetShaderStage() != ShaderStage::Vertex) {
@@ -2245,6 +2286,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
continue;
}
entry.readsBaseVertexBuiltin = ReflectedReadsBaseVertexBuiltin(reflectModule);
if (!m_shaderDrawParametersEnabled && ReflectedReadsInstanceIndexBuiltin(reflectModule)) {
static Bool s_warnedInstanceIndexUnsupported = false;
if (!s_warnedInstanceIndexUnsupported) {
@@ -2371,6 +2414,78 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
// How many descriptors to declare for an ARRAY of opaque uniforms (samplers, images) at one
// binding. A returned count is always DECLARED in the descriptor set layout; `outDeclined`
// says whether the binding can also be RESOLVED at draw time, or whether the program has to
// be refused instead.
//
// Those are deliberately two different things. The layout must keep describing what the
// shader declares even for a binding MobileGL cannot resolve: a descriptor the shader reads
// and the layout omits is not a missing draw, it is an undefined descriptor access, and
// lavapipe segfaults on it inside pipeline creation - in a JIT worker thread, before any
// draw runs, which is why removing the binding produced a flaky crash rather than a clean
// refusal. Declining is done by refusing the draw (VkProgramObject::declinedDescriptors),
// not by shrinking the layout.
//
// Two separate things have to hold, and neither is checkable from the SPIR-V alone:
//
// * the count has to fit a VkDescriptorSetLayoutBinding this device will accept, and fit
// the Uint16 it is stored in (65536 would narrow to 0) and the scratch the bind path
// reserves from it;
// * the frontend reflection has to have RESERVED that many consecutive uniform locations
// for this uniform, because the per-element resolve paths address element k as
// baseLocation + k. SPIRV-Reflect's `count` is the FLATTENED element count, while GL
// locations follow the OUTER dimension only (ProgramObject::GetUniformArraySizeByTIndex
// answers TType::getOuterArraySize()). For a one-dimensional array the two agree; for
// `uniform sampler2D g[2][3]` SPIR-V says 6 where the reflection reserved 2, and
// elements 2..5 would silently resolve onto whichever uniform got the next locations.
//
// Asking the reflection whether baseLocation and baseLocation + count - 1 are slots of the
// SAME uniform tests exactly that precondition, without this code having to model how
// glslang chooses to lay an array of arrays out.
//
// That is NOT on its own enough to start supporting the shape, though, and this check must
// not be relaxed alone: the binding-qualifier unit seeding in ProgramLinkTask looks an
// opaque uniform up by its name minus a trailing "[0]", so `goku[0][0]` misses the `goku`
// key and every element of an array of arrays seeds texture unit 0. Resolving those elements
// would then paint silently-wrong pixels with no diagnostic at all - strictly worse than
// declining. The decline goes away together with the seeding fix, not before it.
static Uint32 DescriptorCountForOpaqueUniformArray(const MG_State::GLState::ProgramObject& program,
const String& uniformName, Uint32 binding, Int baseLocation,
Uint32 reflectedCount, Uint32 maxBindings,
const char* kindLabel, Bool& outDeclined) {
const Uint32 count = std::max<Uint32>(1u, reflectedCount);
if (count == 1) {
return 1u;
}
if (count > maxBindings) {
// Nothing legal to declare: the count would not fit a VkDescriptorSetLayoutBinding
// this device accepts, and it would narrow badly into the Uint16 that carries it
// (65536 becomes 0). Unlike the extent case below, this one CANNOT keep the layout
// consistent with the shader, so refusing the draw does not fully protect it - the
// driver still JITs a shader indexing past the declared count. Declaring as many as
// the device allows keeps vkCreateDescriptorSetLayout succeeding and the program
// inert; a device whose binding cap is smaller than a shader's array is not a
// configuration MobileGL can serve at all. Needs a >maxBindings-element array to
// reach (256 on desktop, ~16 on mobile).
MGLOG_D("ProgramFactory::ReflectLayout: %s array '%s' at binding %u has %u elements, past the %u "
"this device can describe - declining the program",
kindLabel, uniformName.c_str(), binding, count, maxBindings);
outDeclined = true;
return maxBindings;
}
if (baseLocation < 0 ||
!program.UniformLocationsAliasSameUniform(baseLocation, baseLocation + static_cast<Int>(count - 1u))) {
MGLOG_D("ProgramFactory::ReflectLayout: %s array '%s' at binding %u spans %u descriptors but the "
"reflection reserved fewer uniform locations for it (base=%d) - a multi-dimensional array "
"is the usual cause, and MobileGL declines it rather than resolve elements onto a "
"neighbouring uniform",
kindLabel, uniformName.c_str(), binding, count, baseLocation);
outDeclined = true;
}
return count;
}
void ProgramFactory::ReflectLayout(const MG_State::GLState::ProgramObject& program,
const Vector<Vector<Uint>>& spirv, VkProgramObject& entry) const {
// Initialize layout vectors
@@ -2388,6 +2503,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
entry.dynamicBindings.clear();
entry.bindingDescriptorCounts.assign(m_maxBindings, 1);
entry.arrayedUniformBlockIndicesByBinding.clear();
entry.declinedDescriptors = false;
// Use SpvcSession (Reflection mode) to reflect all SPIR-V modules in a single pass per module
for (const auto& module : spirv) {
@@ -2551,6 +2667,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const auto descriptorKind = ReflectDescriptorTypeToBindingKind(sampler->descriptor_type);
if (descriptorKind != DescriptorBindingKind::CombinedImageSampler &&
descriptorKind != DescriptorBindingKind::UniformTexelBuffer &&
descriptorKind != DescriptorBindingKind::StorageTexelBuffer &&
descriptorKind != DescriptorBindingKind::StorageImage &&
descriptorKind != DescriptorBindingKind::StorageBuffer) {
continue;
@@ -2587,12 +2704,49 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// `b[1].data.length()` answered from an unconstrained buffer instead of its
// own bound range (KHR-GL43.shader_storage_buffer_object.-
// advanced-unsizedArrayLength-*).
entry.bindingDescriptorCounts[binding] = static_cast<Uint16>(std::max<Uint32>(1u, sampler->count));
//
// Bounds-checked like every other array kind. The EXTENT rule differs - a
// block array's elements take consecutive GL binding points rather than
// consecutive uniform locations, so DescriptorCountForOpaqueUniformArray's
// location test does not apply here - but the size rule is identical: this
// count goes straight into a VkDescriptorSetLayoutBinding and is narrowed to
// a Uint16 on the way, where 65536 would silently become 0.
const Uint32 storageArrayCount = std::max<Uint32>(1u, sampler->count);
if (storageArrayCount > m_maxBindings) {
MGLOG_D("ProgramFactory::ReflectLayout: storage block array '%s' at binding %u has %u "
"elements, past the %u this device can describe - declining the program",
uniformName.c_str(), binding, storageArrayCount, m_maxBindings);
entry.declinedDescriptors = true;
entry.bindingDescriptorCounts[binding] = static_cast<Uint16>(m_maxBindings);
continue;
}
entry.bindingDescriptorCounts[binding] = static_cast<Uint16>(storageArrayCount);
continue;
}
const Int location = program.GetUniformLocation(uniformName);
if (location < 0) {
// A uniform with no location is ordinarily one GL never made active, and
// dropping it is routine. An ARRAY reaching here is not routine: it is the
// multi-dimensional case. `uniform sampler2D g[2][3]` arrives from
// SPIRV-Reflect as one binding of 6 descriptors named "g", while the frontend
// reflection keys an array of arrays by its full "[0]"-terminated spelling
// ("g[0][0]"), so no base location resolves and the per-element paths have
// nothing to count from. Declining is the honest answer - but it has to SAY
// so at a level that survives a release build, because dropping the binding
// leaves the shader reading a descriptor the layout never declared.
if (sampler->count > 1) {
MGLOG_E("ProgramFactory::ReflectLayout: declining '%s' at binding %u - a %u-element "
"descriptor array with no frontend uniform location (a multi-dimensional array "
"of samplers or images is the known cause)",
uniformName.c_str(), binding, sampler->count);
entry.declinedDescriptors = true;
// Declared, not resolved - see DescriptorCountForOpaqueUniformArray for
// why the layout keeps describing a binding the draw path will refuse.
entry.bindingDescriptorCounts[binding] =
static_cast<Uint16>(std::min<Uint32>(sampler->count, m_maxBindings));
continue;
}
entry.bindingKinds[binding] = DescriptorBindingKind::None;
continue;
}
@@ -2600,6 +2754,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const GLenum uniformType = program.GetUniformType(static_cast<Uint>(location));
if (descriptorKind == DescriptorBindingKind::StorageImage) {
// An ARRAY of image uniforms is ONE binding carrying `count` descriptors,
// and the layout has to say so. Leaving it at the default 1 declared
// `uniform image2D g_image[4]` as a single-descriptor binding while the
// shader indexed descriptors 1..3 of it - an out-of-bounds descriptor
// access that lavapipe SIGSEGVs inside the JIT-ed shader thread rather than
// reporting (KHR-GL42.shader_image_load_store.advanced-sso-simple). Unlike
// a storage BLOCK array, whose elements take consecutive GL binding points
// from the declared one, each element of an image array carries its own
// independently assigned image unit - see ResolveStorageImageDescriptor.
// Bounds- and extent-checked like the UBO array path above; see
// DescriptorCountForOpaqueUniformArray for what "declined" costs and why
// the reflection's reserved extent - not SPIRV-Reflect's flattened count -
// is what the per-element resolve can actually address.
const Uint32 imageArrayCount =
DescriptorCountForOpaqueUniformArray(program, uniformName, binding, location, sampler->count,
m_maxBindings, "image", entry.declinedDescriptors);
entry.bindingDescriptorCounts[binding] = static_cast<Uint16>(imageArrayCount);
const VkFormat reflectedFormat =
ConvertSpirvImageFormatToVkFormat(sampler->image.image_format);
VkFormat& existingFormat = entry.storageImageFormatByBinding[binding];
@@ -2625,11 +2797,48 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
if (descriptorKind == DescriptorBindingKind::StorageTexelBuffer) {
// Only the declared format is recorded, and only so the per-draw resolve can
// prefer it over the one glBindImageTexture named. Everything the StorageImage
// branch above does about ARRAYS is deliberately absent: an imageBuffer array
// is refused outright by the array gate in RemapDescriptorBindingsForVulkan,
// exactly as a samplerBuffer array is, so bindingDescriptorCounts stays at the
// default 1 and the descriptor write below may take the address of a vector
// element without reserving room for extra elements.
const VkFormat reflectedFormat =
ConvertSpirvImageFormatToVkFormat(sampler->image.image_format);
VkFormat& existingFormat = entry.storageImageFormatByBinding[binding];
MOBILEGL_ASSERT(existingFormat == VK_FORMAT_UNDEFINED ||
reflectedFormat == VK_FORMAT_UNDEFINED ||
existingFormat == reflectedFormat,
"ProgramFactory::ReflectLayout: storage texel buffer binding %u ('%s') "
"has conflicting reflected formats (%d vs %d)",
binding, uniformName.c_str(), static_cast<Int>(existingFormat),
static_cast<Int>(reflectedFormat));
if (existingFormat == VK_FORMAT_UNDEFINED) {
existingFormat = reflectedFormat;
}
}
const TextureTarget target = UniformTypeToTextureTarget(uniformType);
MOBILEGL_ASSERT(target != TextureTarget::Unknown,
"ProgramFactory::ReflectLayout: failed to resolve texture target for '%s'",
uniformName.c_str());
if (descriptorKind == DescriptorBindingKind::CombinedImageSampler) {
// An ARRAY of sampler uniforms is ONE binding carrying `count` descriptors,
// exactly like the image array above, and for the same reason: GLSL 4.20
// gives `layout(binding = 1) uniform sampler2D goku[4]` one declaration
// spanning texture units 1..4, each element with its own glUniform1i-assigned
// unit. Leaving descriptorCount at 1 declared a single-descriptor binding
// while the shader indexed descriptors 1..3 of it, and the bind path wrote
// only element 0 - so elements 1..N read a descriptor nobody had written
// (KHR-GL42.shading_language_420pack.binding_sampler_array; lavapipe faults
// inside the JIT-ed shader rather than reporting).
const Uint32 samplerArrayCount =
DescriptorCountForOpaqueUniformArray(program, uniformName, binding, location, sampler->count,
m_maxBindings, "sampler", entry.declinedDescriptors);
entry.bindingDescriptorCounts[binding] = static_cast<Uint16>(samplerArrayCount);
const SamplerNumericDomain numericDomain = UniformTypeToSamplerNumericDomain(uniformType);
MOBILEGL_ASSERT(numericDomain != SamplerNumericDomain::Unknown,
"ProgramFactory::ReflectLayout: failed to resolve sampler numeric domain "
@@ -2688,6 +2897,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
entry.dynamicBindings.push_back(binding);
} else if (kind == DescriptorBindingKind::UniformTexelBuffer) {
layoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER;
} else if (kind == DescriptorBindingKind::StorageTexelBuffer) {
layoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER;
} else if (kind == DescriptorBindingKind::StorageBuffer) {
layoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
} else if (kind == DescriptorBindingKind::StorageImage) {
@@ -2747,6 +2958,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// a FragCoordYFlip variant also depends on the baked default-framebuffer height, so
// that height rides in the free high half of the key. Flags occupy the low bits, and a
// height cannot exceed the 16 bits a swapchain extent fits in.
//
// "The low bits" is load-bearing and was until now only a comment: a flag that reached
// bit 16 would alias the height and two different variants would share one memo slot.
static_assert(static_cast<Uint>(CompileOptionBit::ZeroBaseVertex) < (1u << 16),
"CompileOptionBit values must stay below bit 16: GetOrCreateProgram packs the "
"default-framebuffer height into the high half of the same memo key");
const Uint memoKey = (flags & CompileOptionBit::FragCoordYFlip)
? (flags.GetRaw() | (m_defaultFramebufferHeight << 16))
: flags.GetRaw();
@@ -2865,6 +3082,26 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
// The non-indexed variant of a vertex stage that reads gl_BaseVertex: GL wants zero
// there, Vulkan's builtin would hand it the draw's firstVertex. Requested per draw
// through CompileOptionBit::ZeroBaseVertex, so the indexed variant of the same
// program keeps the native builtin and stays correct for glDrawElementsBaseVertex
// and for the baseVertex word of an indexed indirect command.
if (shaders[i] && shaders[i]->GetShaderStage() == ShaderStage::Vertex &&
(flags & CompileOptionBit::ZeroBaseVertex)) {
Vector<Uint> zeroedSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::ZeroBaseVertexForVulkan(moduleSpirvs[i],
zeroedSpirv)) {
moduleSpirvs[i] = std::move(zeroedSpirv);
} else {
// Failing open keeps the native builtin, which is the pre-fix behavior:
// gl_BaseVertex reads firstVertex on a DrawArrays instead of zero.
MGLOG_E("ProgramFactory: failed to zero gl_BaseVertex for program %u; non-indexed "
"draws will read the draw's first vertex from it instead of zero",
program.GetExternalIndex());
}
}
// A 64-bit vertex input has to arrive as its 32-bit word pair: VK_FORMAT_R64*_SFLOAT is
// optional and lavapipe advertises none of them at all. The pass is unconditional so it
// always agrees with the Float64 case in VertexInputStateFactory::ToVkVertexFormat, and
@@ -2953,7 +3190,22 @@ namespace MobileGL::MG_Backend::DirectVulkan {
#endif
ReflectVertexInputs(shaders, moduleSpirvs, entry);
ReflectFragmentOutputs(shaders, moduleSpirvs, entry);
ReflectPassthroughTessControlNeed(shaders, moduleSpirvs, entry);
ReflectLayout(program, moduleSpirvs, entry);
// A failed remap means the modules kept glslang's per-stage auto-mapped binding numbers -
// no cross-stage unification, no set->0 normalisation - so the bindings this layout
// describes are not the bindings the shader reads. That has to stop the program from
// drawing, and until now nothing did: the MOBILEGL_ASSERT above compiles out of every
// build past DEBUG, and RemapDescriptorBindingsForVulkan's own refusal message said so at
// a level an INFO build also drops. Declining is the mechanism that already exists for
// "the layout and the shader disagree", so route it through that. Set AFTER ReflectLayout,
// which clears the flag.
if (!remapOk) {
MGLOG_E("ProgramFactory::GetOrCreateProgram: declining program %u - its descriptor bindings could not "
"be remapped, so the layout does not describe what the shader reads",
program.GetExternalIndex());
entry.declinedDescriptors = true;
}
return entry;
}
@@ -2996,4 +3248,235 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
}
ProgramFactory::~ProgramFactory() {
for (auto& entry : m_passthroughTessControlStages) {
if (entry.second.module != VK_NULL_HANDLE) {
vkDestroyShaderModule(m_device, entry.second.module, nullptr);
}
}
}
String ProgramFactory::BuildPassthroughTessControlSource(Uint32 patchVertices) {
// The stage GL 4.6 core 11.2.2 describes when a program has an evaluation shader and no
// control shader: "the input patch is passed through unmodified", the output patch has
// as many vertices as the input one (PATCH_VERTICES), and the levels come from the
// PATCH_DEFAULT_OUTER_LEVEL / PATCH_DEFAULT_INNER_LEVEL state.
//
// Those two levels default to 1.0 and are baked here as literals because
// glPatchParameterfv - their only setter - is not implemented in this frontend (it is a
// stub in MG_Impl/GLImpl/Exporting/Definitions.cpp). Implementing that entry point means
// making the levels a parameter of this source AND of the cache key in
// GetOrCreatePassthroughTessControlStage; the two must move together, so they are named
// together here.
//
// gl_out carries gl_Position and nothing else on purpose. The evaluation stage that
// reads it was linked against the VERTEX stage directly, so its input gl_PerVertex holds
// exactly the built-ins that stage used, and its user-defined inputs (if any) come
// straight off the vertex stage's outputs - which a control stage sitting in between
// would leave unwritten. ReflectPassthroughTessControlNeed refuses those programs rather
// than let this write a partial interface.
//
// All four outer levels and both inner levels are written unconditionally: writing a
// level the evaluation stage's domain does not use is legal and ignored, and it saves
// this from having to know the domain.
String source = "#version 450 core\n";
source += "layout(vertices = " + std::to_string(patchVertices) + ") out;\n";
// gl_in and gl_out are redeclared to the exact gl_PerVertex the FRONTEND's linked programs
// carry - gl_Position, gl_PointSize, gl_ClipDistance[1], in that order - because Vulkan
// matches built-in interface blocks by their whole shape, and the two obvious spellings
// are both wrong:
// * narrowing the block to gl_Position alone makes the evaluation stage read a patch of
// zeroes (degenerate triangles, nothing rasterized), and
// * taking glslang's DEFAULT block for a standalone control stage yields FOUR members -
// it appends gl_CullDistance - where a linked vertex+evaluation program has three.
// PassthroughTessControlTest.MatchesTheFrontendPerVertexBlock is the latch: it links a
// vertex+evaluation program through this same compiler and fails if the two shapes ever
// stop agreeing, rather than letting the mismatch show up as a black frame.
//
// Only gl_Position is written. gl_PointSize is declared but left alone deliberately:
// writing it from a tessellation stage requires the shaderTessellationAndGeometryPointSize
// feature, which this renderer does not enable, so a program whose evaluation stage reads
// gl_in[].gl_PointSize gets an undefined point size instead of the vertex stage's - a gap
// this trades for not making every tessellated pipeline depend on an optional feature.
source += "in gl_PerVertex {\n"
" vec4 gl_Position;\n"
" float gl_PointSize;\n"
" float gl_ClipDistance[1];\n"
"} gl_in[gl_MaxPatchVertices];\n";
source += "out gl_PerVertex {\n"
" vec4 gl_Position;\n"
" float gl_PointSize;\n"
" float gl_ClipDistance[1];\n"
"} gl_out[];\n";
source += "void main() {\n";
source += " gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;\n";
source += " gl_TessLevelOuter[0] = 1.0;\n";
source += " gl_TessLevelOuter[1] = 1.0;\n";
source += " gl_TessLevelOuter[2] = 1.0;\n";
source += " gl_TessLevelOuter[3] = 1.0;\n";
source += " gl_TessLevelInner[0] = 1.0;\n";
source += " gl_TessLevelInner[1] = 1.0;\n";
source += "}\n";
return source;
}
VkPipelineShaderStageCreateInfo ProgramFactory::GetOrCreatePassthroughTessControlStage(Uint32 patchVertices) {
// A cached VK_NULL_HANDLE is a remembered failure, not a miss: returning it keeps a
// generator that cannot compile from re-running glslang on every draw.
const auto cached = m_passthroughTessControlStages.find(patchVertices);
if (cached != m_passthroughTessControlStages.end()) {
return cached->second;
}
VkPipelineShaderStageCreateInfo stage{VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
stage.stage = VK_SHADER_STAGE_TESSELLATION_CONTROL_BIT;
stage.module = VK_NULL_HANDLE;
stage.pName = "main";
using namespace MG_Util::ShaderTranspiler;
const String source = BuildPassthroughTessControlSource(patchVertices);
// Same compile configuration as every other stage of every other program: this runs on
// the GL thread (the draw path), so the live compile env is the right one, and flags=0
// is the Vulkan-targeting form (CompileForOpenGL is what the GLES backend adds).
const SharedPtr<const CompileEnv>& env = GetCurrentCompileEnv();
ShaderAttrib shaderAttrib{.shaderType = GL_TESS_CONTROL_SHADER,
.sourceStr = source,
.flags = 0,
.env = env.get()};
auto compiled = ShaderCompiler::CompileShader(shaderAttrib);
if (!compiled) {
MGLOG_E("ProgramFactory: could not compile the pass-through tessellation control stage for "
"patchVertices=%u; a program with an evaluation stage and no control stage cannot draw. %s",
patchVertices, compiled.error().log.c_str());
m_passthroughTessControlStages.emplace(patchVertices, stage);
return stage;
}
ProgramAttrib programAttrib{};
programAttrib.shaders.push_back(compiled.value());
auto linked = ShaderCompiler::LinkProgram(programAttrib);
if (!linked) {
MGLOG_E("ProgramFactory: could not link the pass-through tessellation control stage for "
"patchVertices=%u. %s", patchVertices, linked.error().log.c_str());
m_passthroughTessControlStages.emplace(patchVertices, stage);
return stage;
}
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_TESS_CONTROL_SHADER}, .program = *linked.value()};
auto binary = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
if (!binary || binary.value().empty() || binary.value().front().empty()) {
MGLOG_E("ProgramFactory: could not generate SPIR-V for the pass-through tessellation control stage "
"for patchVertices=%u", patchVertices);
m_passthroughTessControlStages.emplace(patchVertices, stage);
return stage;
}
const Vector<Uint>& spirv = binary.value().front();
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
ValidateTransformedSpirv(spirv, ShaderStage::TessControl, 0);
#else
if (MG_Util::ShaderTranspiler::ShaderCompiler::SpirvValidationEnabled()) {
ValidateTransformedSpirv(spirv, ShaderStage::TessControl, 0);
}
#endif
VkShaderModuleCreateInfo smci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
smci.codeSize = spirv.size() * sizeof(Uint);
smci.pCode = spirv.data();
VkShaderModule module = VK_NULL_HANDLE;
const VkResult result = vkCreateShaderModule(m_device, &smci, nullptr, &module);
if (result != VK_SUCCESS) {
MGLOG_E("ProgramFactory: vkCreateShaderModule failed (%d) for the pass-through tessellation control "
"stage for patchVertices=%u", static_cast<Int>(result), patchVertices);
m_passthroughTessControlStages.emplace(patchVertices, stage);
return stage;
}
stage.module = module;
MGLOG_D("ProgramFactory: built the pass-through tessellation control stage for patchVertices=%u "
"(GL 4.6 11.2.2; Vulkan has no fixed-function equivalent)", patchVertices);
m_passthroughTessControlStages.emplace(patchVertices, stage);
return stage;
}
void ProgramFactory::ReflectPassthroughTessControlNeed(
const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const {
entry.needsPassthroughTessControl = false;
entry.passthroughTessControlEmulatable = false;
Bool hasTessEval = false;
Bool hasTessControl = false;
SizeT tessEvalModuleIndex = 0;
for (SizeT i = 0; i < shaders.size(); ++i) {
if (!shaders[i]) continue;
const auto stage = shaders[i]->GetShaderStage();
if (stage == ShaderStage::TessControl) hasTessControl = true;
if (stage == ShaderStage::TessEval) {
hasTessEval = true;
tessEvalModuleIndex = i;
}
}
if (!hasTessEval || hasTessControl) return;
entry.needsPassthroughTessControl = true;
if (tessEvalModuleIndex >= spirv.size() || spirv[tessEvalModuleIndex].empty()) return;
const auto& module = spirv[tessEvalModuleIndex];
SpvReflectShaderModule reflectModule{};
const SpvReflectResult createResult =
spvReflectCreateShaderModule(module.size() * sizeof(Uint), module.data(), &reflectModule);
if (createResult != SPV_REFLECT_RESULT_SUCCESS) {
MGLOG_E("ProgramFactory::ReflectPassthroughTessControlNeed: reflection failed (result=%d); the "
"evaluation stage's inputs are unknown, so the pass-through is not offered",
static_cast<Int>(createResult));
return;
}
uint32_t inputCount = 0;
SpvReflectResult reflectResult = spvReflectEnumerateInputVariables(&reflectModule, &inputCount, nullptr);
Vector<SpvReflectInterfaceVariable*> inputs(inputCount);
if (reflectResult == SPV_REFLECT_RESULT_SUCCESS && inputCount > 0) {
reflectResult = spvReflectEnumerateInputVariables(&reflectModule, &inputCount, inputs.data());
}
if (reflectResult != SPV_REFLECT_RESULT_SUCCESS) {
spvReflectDestroyShaderModule(&reflectModule);
return;
}
// The question is only ever "does this stage read anything a control stage would have to
// forward", and the answer is: does it have a LOCATION. A located input is a user-defined
// varying (or a per-patch input), which the vertex stage writes today and would stop
// reaching once a control stage sits in between - the pass-through carries gl_Position and
// nothing else, so such a program is declined instead of being handed undefined values.
// Everything without a location is a built-in: gl_in, gl_TessCoord, gl_PatchVerticesIn,
// gl_PrimitiveID, gl_TessLevel*, all either forwarded or generated for the evaluation
// stage by the tessellator itself.
//
// This deliberately does NOT judge on SpvReflectInterfaceVariable::built_in. gl_in is an
// array of interface blocks, and for those SPIRV-Reflect reports built_in == -1 on the
// block AND leaves every member's built_in at 0 - which is SpvBuiltInPosition, so a
// member walk reads "Position, Position, Position" for a {Position, PointSize,
// ClipDistance} block and would accept anything on the strength of parse garbage. The
// location, by contrast, is decorated on the OpVariable and is what SPIRV-Reflect reads
// straight through.
constexpr Uint32 kNoLocation = 0xFFFFFFFFu;
Bool emulatable = true;
for (auto* input : inputs) {
if (input == nullptr) continue;
if (input->location == kNoLocation) continue;
MGLOG_E("ProgramFactory: a tessellation evaluation stage with no control stage reads the "
"user-defined input '%s' at location=%u; a synthesized control stage cannot forward it, so "
"this program's draws are declined rather than fed an undefined varying",
input->name != nullptr ? input->name : "<null>", input->location);
emulatable = false;
break;
}
spvReflectDestroyShaderModule(&reflectModule);
entry.passthroughTessControlEmulatable = emulatable;
}
} // namespace MobileGL::MG_Backend::DirectVulkan
@@ -33,7 +33,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
CombinedImageSampler,
UniformTexelBuffer,
StorageBuffer,
StorageImage
StorageImage,
// GLSL `imageBuffer` - a buffer texture reached through an IMAGE unit rather than a
// texture unit. Vulkan spells it VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER, which is a
// VkBufferView like UniformTexelBuffer and not a VkImageView like StorageImage: it is
// the one image uniform whose descriptor is a buffer. Appended, never inserted -
// DescriptorKeyHash mixes the enumerator's value.
StorageTexelBuffer
};
enum class CompileOptionBit : Uint {
@@ -60,6 +66,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// PositionYFlip (the two are the same fact about the same draws) except under a
// quarter turn, which this renderer does not convert rectangles for either.
FragCoordYFlip = 1 << 7,
// Replaces the vertex stage's gl_BaseVertex reads with zero. GL defines the builtin
// as zero for every drawing command that has no baseVertex parameter - all the
// DrawArrays forms - while Vulkan's BaseVertex reports firstVertex there. Set only
// for a non-indexed draw whose program actually reads the builtin, so nothing else
// acquires a second program/pipeline variant. See ZeroBaseVertexPass.
ZeroBaseVertex = 1 << 8,
};
using CompileOptionFlags = Flags<CompileOptionBit>;
using HashType = Uint64;
@@ -86,8 +98,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Vector<Uint32> activeBindings;
Vector<Uint32> dynamicBindings;
Vector<Int> uniformBlockIndexByBinding;
// Descriptor count per binding (1 except for UBO instance arrays, which occupy one
// binding with descriptorCount = N).
// Descriptor count per binding (1 except for a descriptor ARRAY - a UBO or storage
// block instance array, an image uniform array or a sampler uniform array - each of
// which occupies one binding with descriptorCount = N).
Vector<Uint16> bindingDescriptorCounts;
// Per-element GL uniform block indices for arrayed UBO bindings (count > 1);
// element 0 of a non-arrayed binding stays in uniformBlockIndexByBinding.
@@ -96,6 +109,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Vector<Int> samplerUniformLocationByBinding;
Vector<TextureTarget> samplerTextureTargetByBinding;
Vector<SamplerNumericDomain> samplerNumericDomainByBinding;
// Shared by StorageImage and StorageTexelBuffer bindings: a binding is one kind or
// the other, never both, and both need exactly the same thing - the format the
// shader declared, so the per-draw resolve can tell a typed declaration from a
// formatless one. Kept as one pair rather than two so the move operations below
// cannot drift out of sync with a field that only one kind populates.
Vector<VkFormat> storageImageFormatByBinding;
Vector<Bool> storageImageUsesBindingFormatByBinding;
Vector<String> storageBlockNameByBinding;
@@ -103,6 +121,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Set once during ReflectLayout so the per-draw path can skip the whole
// storage-image preparation for the overwhelming majority of programs.
Bool hasStorageImages = false;
// Something about this program's descriptors could not be resolved - an opaque
// uniform array whose elements have no addressable uniform locations (the
// multi-dimensional case), or a binding remap that failed outright. The binding
// STAYS DECLARED in the descriptor set layout; declining is done here, by refusing
// every draw, and BindProgramUniformBuffers returns false so the draw setup skips
// the draw exactly as it does for any other bind failure.
//
// Keeping the layout intact is the load-bearing half. Shrinking it instead - which
// is what the first cut of this did - leaves the shader reading a descriptor the
// layout never declared, and lavapipe segfaults on that inside PIPELINE CREATION,
// in a JIT worker thread, before any draw runs where a refusal could help. The
// reason was logged once at MGLOG_I when the descriptor was declined.
Bool declinedDescriptors = false;
Int globalUboBinding = -1;
Uint32 activeVertexInputLocationMask = 0;
Array<GLenum, kMaxVertexInputLocations> vertexInputTypes{};
@@ -115,6 +146,27 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// gl_FragDepth); shader-computed depth is immune to the cross-pipeline
// position-invariance quirk (see PipelineFactory::ShouldSuppressDepthWrite).
Bool fragmentReplacesDepth = false;
// The vertex module declares the BaseVertex builtin. Selects the ZeroBaseVertex
// program variant for non-indexed draws, and is deliberately a property of the
// 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;
// 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
// levels come from the PATCH_DEFAULT_*_LEVEL state); Vulkan does not - either both
// tessellation stages are present or neither
// (VUID-VkGraphicsPipelineCreateInfo-pStages-00730). So the draw path has to supply
// the pass-through stage GL describes; see GetOrCreatePassthroughTessControlStage.
Bool needsPassthroughTessControl = false;
// ...and the pass-through this renderer can synthesize carries gl_Position and
// nothing else, so it is only correct when the evaluation stage's inputs are
// built-ins. A user-defined varying would arrive at the evaluation stage
// UNWRITTEN once a control stage sits between it and the vertex stage, which is
// silently wrong pixels rather than a crash - so those programs are declined
// instead (PipelineFactory::CreatePipeline refuses the pipeline and the draw is
// skipped). See ReflectPassthroughTessControlNeed.
Bool passthroughTessControlEmulatable = false;
// Frame-boundary counter value of the last GetOrCreateProgram hit; drives
// cache eviction (see OnFrameBoundary). Mutable: the draw snapshot's memoised
// entry pointer re-stamps use through a const reference (StampProgramUse).
@@ -129,6 +181,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
hash = other.hash;
stages = std::move(other.stages);
modules = std::move(other.modules);
// Must travel with `modules`: these digests name the SPIR-V those exact
// shader modules were built from, and the pipeline-failure diagnostics
// print the two together. Leaving it behind used to merely lose the
// digests on a rehash; now that the cache is a robin-hood table, insertion
// SWAPS two entries, and a field that no move touches stays behind in the
// slot - pairing one program's modules with another program's digests, so
// a pipeline failure would be reported against the wrong SPIR-V.
stageSpirvDigests = std::move(other.stageSpirvDigests);
descriptorSetLayout = other.descriptorSetLayout;
pipelineLayout = other.pipelineLayout;
bindingKinds = std::move(other.bindingKinds);
@@ -147,6 +207,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
storageBlockNameByBinding = std::move(other.storageBlockNameByBinding);
storageBlockIndexByBinding = std::move(other.storageBlockIndexByBinding);
hasStorageImages = other.hasStorageImages;
declinedDescriptors = other.declinedDescriptors;
globalUboBinding = other.globalUboBinding;
activeVertexInputLocationMask = other.activeVertexInputLocationMask;
vertexInputTypes = other.vertexInputTypes;
@@ -156,11 +217,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
producerOutputComponentCount = other.producerOutputComponentCount;
fragmentInputComponentCount = other.fragmentInputComponentCount;
fragmentReplacesDepth = other.fragmentReplacesDepth;
readsBaseVertexBuiltin = other.readsBaseVertexBuiltin;
needsPassthroughTessControl = other.needsPassthroughTessControl;
passthroughTessControlEmulatable = other.passthroughTessControlEmulatable;
lastUsedFrame = other.lastUsedFrame;
other.hash = 0;
other.descriptorSetLayout = VK_NULL_HANDLE;
other.pipelineLayout = VK_NULL_HANDLE;
other.hasStorageImages = false;
other.declinedDescriptors = false;
other.globalUboBinding = -1;
other.activeVertexInputLocationMask = 0;
other.activeFragmentOutputLocationMask = 0;
@@ -168,6 +233,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
other.producerOutputComponentCount = 0;
other.fragmentInputComponentCount = 0;
other.fragmentReplacesDepth = false;
other.readsBaseVertexBuiltin = false;
other.needsPassthroughTessControl = false;
other.passthroughTessControlEmulatable = false;
other.lastUsedFrame = 0;
}
VkProgramObject& operator=(VkProgramObject&& other) noexcept {
@@ -178,6 +246,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
hash = other.hash;
stages = std::move(other.stages);
modules = std::move(other.modules);
stageSpirvDigests = std::move(other.stageSpirvDigests); // travels with `modules` - see the move ctor
descriptorSetLayout = other.descriptorSetLayout;
pipelineLayout = other.pipelineLayout;
bindingKinds = std::move(other.bindingKinds);
@@ -196,6 +265,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
storageBlockNameByBinding = std::move(other.storageBlockNameByBinding);
storageBlockIndexByBinding = std::move(other.storageBlockIndexByBinding);
hasStorageImages = other.hasStorageImages;
declinedDescriptors = other.declinedDescriptors;
globalUboBinding = other.globalUboBinding;
activeVertexInputLocationMask = other.activeVertexInputLocationMask;
vertexInputTypes = other.vertexInputTypes;
@@ -205,11 +275,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
producerOutputComponentCount = other.producerOutputComponentCount;
fragmentInputComponentCount = other.fragmentInputComponentCount;
fragmentReplacesDepth = other.fragmentReplacesDepth;
readsBaseVertexBuiltin = other.readsBaseVertexBuiltin;
needsPassthroughTessControl = other.needsPassthroughTessControl;
passthroughTessControlEmulatable = other.passthroughTessControlEmulatable;
lastUsedFrame = other.lastUsedFrame;
other.hash = 0;
other.descriptorSetLayout = VK_NULL_HANDLE;
other.pipelineLayout = VK_NULL_HANDLE;
other.hasStorageImages = false;
other.declinedDescriptors = false;
other.globalUboBinding = -1;
other.activeVertexInputLocationMask = 0;
other.activeFragmentOutputLocationMask = 0;
@@ -217,6 +291,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
other.producerOutputComponentCount = 0;
other.fragmentInputComponentCount = 0;
other.fragmentReplacesDepth = false;
other.readsBaseVertexBuiltin = false;
other.needsPassthroughTessControl = false;
other.passthroughTessControlEmulatable = false;
other.lastUsedFrame = 0;
return *this;
}
@@ -244,6 +321,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
modules.clear();
stages.clear();
stageSpirvDigests.clear(); // the modules they describe are gone
}
};
@@ -267,7 +345,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_unformattedFloatStorageImagesEnabled(unformattedFloatStorageImagesEnabled) {
VkProgramObject::s_device = device;
}
~ProgramFactory() = default;
// Destroys the pass-through tessellation control modules. Runs while the device is
// still alive for the same reason ~VkProgramObject's does: this factory outlives
// nothing that owns the device.
~ProgramFactory();
ProgramFactory(const ProgramFactory&) = delete;
HashType ComputeHash(const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags) const;
@@ -313,6 +394,33 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// True when an entry point reads the InstanceIndex builtin. Only gates a diagnostic:
// without shaderDrawParameters such a shader cannot have gl_InstanceID rebased.
static Bool ReflectedReadsInstanceIndexBuiltin(const SpvReflectShaderModule& reflectModule);
// True when an entry point declares the BaseVertex builtin, i.e. when a non-indexed
// draw with this program has to take the ZeroBaseVertex variant.
static Bool ReflectedReadsBaseVertexBuiltin(const SpvReflectShaderModule& reflectModule);
// 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);
// 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
// `patchVertices` control points. Returned BY VALUE (a stage description is a POD, and
// the cache below is a rehashing map, so a pointer into it would not survive the next
// distinct patch size). `.module == VK_NULL_HANDLE` means the stage could not be built:
// the caller then has no control stage to inject, and CreatePipeline refuses the
// pipeline rather than handing the driver a half-tessellated one.
//
// Keyed on the patch size because GL takes the output patch size from PATCH_VERTICES,
// which is draw state, not link state - the CTS case that motivated this links at the
// default 3 and draws at 4. The pipeline cache already re-keys on patchControlPoints,
// so the module a pipeline was built with is part of that pipeline's identity.
// Compiling is bounded by the number of distinct patch sizes a program draws with
// (MAX_PATCH_VERTICES = 32 in the worst case, one or two in practice) and only ever
// happens for the rare program that has no control stage at all.
VkPipelineShaderStageCreateInfo GetOrCreatePassthroughTessControlStage(Uint32 patchVertices);
// Source of the module above. Exposed for tests: the generated GLSL is the whole
// contract with the evaluation stage, so it is worth pinning independently of a device.
static String BuildPassthroughTessControlSource(Uint32 patchVertices);
private:
struct ProgramLookupCache {
@@ -331,6 +439,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkProgramObject& entry) const;
void ReflectLayout(const MG_State::GLState::ProgramObject& program, const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const;
// Fills needsPassthroughTessControl / passthroughTessControlEmulatable off the linked
// modules. Const and reflection-only: it decides nothing about the pipeline, it only
// records what the evaluation stage's input interface is made of.
void ReflectPassthroughTessControlNeed(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const;
VkDevice m_device = VK_NULL_HANDLE;
Uint32 m_maxBindings = 0;
@@ -351,6 +465,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// See GetCacheStructureEpoch(). Starts at 1 so a zero-initialized memo can never match.
Uint64 m_cacheStructureEpoch = 1;
IEvictionObserver* m_evictionObserver = nullptr;
// Pass-through tessellation control stages by input patch size. Never evicted: at most
// MAX_PATCH_VERTICES entries exist for the lifetime of the device, and every pipeline
// ever built from one keeps referencing its module. A failed build is cached as
// VK_NULL_HANDLE so a broken generator costs one compile, not one per draw.
UnorderedMap<Uint32, VkPipelineShaderStageCreateInfo> m_passthroughTessControlStages;
static inline XXH64_state_t* m_hashState = XXH64_createState();
};
} // namespace MobileGL::MG_Backend::DirectVulkan
@@ -157,7 +157,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MGLOG_I("Got %d surface formats:", swapchainCapabilities.surfaceFormats.size());
for (const auto& sf : swapchainCapabilities.surfaceFormats) {
MGLOG_I(" [%s, %s]", string_VkFormat(sf.format), string_VkColorSpaceKHR(sf.colorSpace));
MGLOG_D(" [%s, %s]", string_VkFormat(sf.format), string_VkColorSpaceKHR(sf.colorSpace));
}
const auto pickedSurfaceFormat = ChooseSwapchainSurfaceFormat(swapchainCapabilities.surfaceFormats);
@@ -166,7 +166,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MGLOG_I("Got %d present modes:", swapchainCapabilities.presentModes.size());
for (const auto& pm : swapchainCapabilities.presentModes) {
MGLOG_I(" %s", string_VkPresentModeKHR(pm));
MGLOG_D(" %s", string_VkPresentModeKHR(pm));
}
const auto presentMode = ChooseSwapchainPresentMode(swapchainCapabilities.presentModes);
File diff suppressed because it is too large Load Diff
@@ -53,10 +53,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// caches - a live layout's entry must never be purged (its sets would be
// unreachable pool slots), so there is deliberately no age-based sweep here.
void OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout);
// One record per visited CombinedImageSampler binding (post fallback substitution,
// in binding order): the resolved texture and effective sampler, as never-reused
// lifetime ids so a freed-and-reallocated object at the same heap address can only
// MISS a comparison, never false-hit it (same ABA rule as SamplerResolveMemo).
// One record per visited CombinedImageSampler DESCRIPTOR (post fallback substitution,
// in binding order, and within a binding in array-element order): the resolved texture
// and effective sampler, as never-reused lifetime ids so a freed-and-reallocated object
// at the same heap address can only MISS a comparison, never false-hit it (same ABA
// rule as SamplerResolveMemo). An arrayed binding contributes one record per element -
// element granularity is required, or swapping the textures of two elements of the same
// array would leave the record list identical and the fast path would keep a stale set.
struct SampledBindingRecord {
Uint64 textureLifetimeId = 0;
Uint64 samplerLifetimeId = 0;
@@ -143,8 +146,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// texture after the fallback substitution (may still be null when no fallback
// exists), effective sampler = unit override else the texture's own sampler.
// False = the binding is skipped (unbound with a non-2D fallback target).
// `element` indexes a sampler array inside the binding; see ResolveSamplerDescriptor.
Bool ResolveSampledBinding(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 element,
MG_State::GLState::ITextureObject*& outTexture,
const MG_State::GLState::SamplerObject*& outSampler) const;
// Raw-pointer variant for the per-draw sampled-texture walk (CollectSampledTextures):
@@ -152,30 +156,45 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// only need the pointer skip the SharedPtr copy's atomic refcount churn.
static MG_State::GLState::ITextureObject* ResolveSamplerTextureRaw(
const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding);
const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 element);
SharedPtr<MG_State::GLState::ITextureObject> GetFallbackTexture(TextureTarget target) const;
// `element` indexes a sampler ARRAY inside one binding; each element carries its own
// independently assigned GL texture unit, so it selects the texture, the sampler
// override and the fallback separately from its neighbours.
//
// trustUnchangedHint: reuse this binding's cached VkDescriptorImageInfo outright
// (see BindProgramUniformBuffers' samplerDescriptorsUnchangedHint for the proof
// obligations the caller carries).
// obligations the caller carries). The cache is keyed by binding alone, so it is
// used ONLY for single-descriptor bindings - see m_samplerResolveMemo.
Bool ResolveSamplerDescriptor(VkCommandBuffer commandBuffer, const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
VkDescriptorImageInfo& outImageInfo,
Uint32 element, VkDescriptorImageInfo& outImageInfo,
Bool trustUnchangedHint = false) const;
Bool ResolveSamplerDescriptorOverride(const SamplerBindingOverride& samplerBindingOverride,
VkDescriptorImageInfo& outImageInfo) const;
Bool ResolveTexelBufferDescriptor(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
Uint32 frameIndex, VkBufferView& outBufferView);
// GLSL `imageBuffer`: the same VkBufferView descriptor as the sampled texel buffer above,
// but resolved from an IMAGE unit (glBindImageTexture) rather than a texture unit, and
// made GPU-resident-writable because the shader may store to it. No `element` parameter:
// an imageBuffer ARRAY is refused at program creation, so a binding is always one
// descriptor (see the array gate in RemapDescriptorBindingsForVulkan).
Bool ResolveStorageTexelBufferDescriptor(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
Uint32 frameIndex, VkBufferView& outBufferView);
// `element` indexes a block INSTANCE array's descriptors; it is 0 for every ordinary
// block. Each element resolves through its own GL storage block, and so its own GL
// binding point, buffer and glBindBufferRange window.
Bool ResolveStorageBufferDescriptor(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
Uint32 element, VkDescriptorBufferInfo& outBufferInfo) const;
// `element` indexes an image ARRAY inside one binding; each element carries its own
// independently assigned GL image unit.
Bool ResolveStorageImageDescriptor(VkCommandBuffer commandBuffer,
const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
VkDescriptorImageInfo& outImageInfo) const;
Uint32 element, VkDescriptorImageInfo& outImageInfo) const;
// Result of resolving a UBO binding: either a zero-copy direct bind to the app's resident
// VkBuffer (the GLES backend's approach - no per-draw copy) or the CPU payload to upload.
struct UboBindResult {
@@ -344,6 +363,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// proves every resolve input unchanged; cleared with the per-frame reset
// (the cached VkSampler outlives a frame only via a fresh resolve, which
// also re-stamps it against VkSamplerManager's frame-boundary sweep).
//
// This one field is keyed by binding but describes ONE descriptor, so it is
// written and read only for single-descriptor bindings. A sampler ARRAY's
// elements share the binding and would overwrite each other here - the last
// element resolved would then be handed to element 0 on the next hinted draw.
// Every other field above is self-validating (each compares its full key
// before reuse, and the view-format entry is a pure function of format and
// numeric domain), so an arrayed binding may keep using those.
VkDescriptorImageInfo info{};
Bool infoValid = false;
};
@@ -110,7 +110,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const VkFormat sourceVkFormat =
ToVkVertexFormat(attr.Type, attr.Size, attr.Normalized, attr.IsInteger, attr.IsBgra, attr.IsLong);
if (sourceVkFormat == VK_FORMAT_UNDEFINED) {
MGLOG_E("Unsupported vertex attribute layout (location=%u, type=%s, size=%d): the array is "
MGLOG_E_ONCE("Unsupported vertex attribute layout (location=%u, type=%s, size=%d): the array is "
"enabled but cannot be mapped to a VkFormat",
location, MG_Util::ConvertDataTypeToString(attr.Type).c_str(), attr.Size);
unsupportedAttribMask |= (1u << location);
@@ -125,7 +125,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (fallbackFormat != VK_FORMAT_UNDEFINED && SupportsVertexBufferFormat(fallbackFormat)) {
vkFormat = fallbackFormat;
conversion = VertexStreamConversion::ScaledIntegerToFloat32;
MGLOG_W("Vertex attribute location=%u format=%d lacks "
MGLOG_W_ONCE("Vertex attribute location=%u format=%d lacks "
"VK_FORMAT_FEATURE_VERTEX_BUFFER_BIT; using float32 stream format=%d "
"(type=%s size=%d normalized=%s integer=%s)",
location, static_cast<Int>(sourceVkFormat), static_cast<Int>(vkFormat),
@@ -135,7 +135,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
if (conversion == VertexStreamConversion::None) {
MGLOG_E("Unsupported Vulkan vertex format (location=%u, format=%d, type=%s, size=%d): "
MGLOG_E_ONCE("Unsupported Vulkan vertex format (location=%u, format=%d, type=%s, size=%d): "
"VK_FORMAT_FEATURE_VERTEX_BUFFER_BIT is unavailable and no semantic fallback exists",
location, static_cast<Int>(sourceVkFormat),
MG_Util::ConvertDataTypeToString(attr.Type).c_str(), attr.Size);
@@ -146,15 +146,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const SizeT attribByteSize = GetAttributeByteSize(attr.Type, attr.Size, attr.IsBgra);
if (attribByteSize == 0) {
MGLOG_E("Vertex attribute with unknown component size (location=%u, type=%s): the array is "
MGLOG_E_ONCE("Vertex attribute with unknown component size (location=%u, type=%s): the array is "
"enabled but cannot be sized",
location, MG_Util::ConvertDataTypeToString(attr.Type).c_str());
unsupportedAttribMask |= (1u << location);
continue;
}
const Uint32 sourceStride =
attr.Stride > 0 ? static_cast<Uint32>(attr.Stride) : static_cast<Uint32>(attribByteSize);
// Verbatim, zero included. The frontend already resolved a pointer call's
// "tightly packed" stride 0 into the element size (see VertexAttribute::Stride),
// so a zero here is the binding model's stride 0 - every vertex reads the same
// element - which is exactly what a zero VkVertexInputBindingDescription::stride
// means. Substituting the element size fetched a fresh element per vertex and ran
// off the end of the buffer (KHR-GL43.vertex_attrib_binding.basic-input-case7/8).
// Client-memory arrays cannot reach zero: they only exist on the pointer path.
const Uint32 sourceStride = static_cast<Uint32>(attr.Stride);
const Bool packedAttribute = attr.Type == DataType::Int2101010Rev ||
attr.Type == DataType::Uint2101010Rev;
const SizeT requiredAlignment = packedAttribute ? attribByteSize : GetComponentSize(attr.Type);
@@ -169,16 +175,22 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// unless VK_EXT_legacy_vertex_attributes is available, so deinterleave this one
// attribute into a tightly packed transient stream without changing its format.
conversion = VertexStreamConversion::Repack;
MGLOG_W("Vertex attribute location=%u uses Vulkan-incompatible alignment "
MGLOG_W_ONCE("Vertex attribute location=%u uses Vulkan-incompatible alignment "
"(offset=%zu stride=%u required=%zu); using a tightly packed stream",
location, attr.Offset, sourceStride, requiredAlignment);
}
Uint32 stride = sourceStride;
if (conversion == VertexStreamConversion::Repack) {
stride = static_cast<Uint32>(attribByteSize);
} else if (conversion == VertexStreamConversion::ScaledIntegerToFloat32) {
stride = static_cast<Uint32>(attr.Size * static_cast<Int>(sizeof(Float)));
// A converted stream is tightly packed, so its stride is the converted element
// size - unless the source stride is zero, which does not describe a packing at
// all but "never advance". That survives the conversion unchanged: the draw path
// converts exactly one element and every vertex reads it.
if (sourceStride != 0) {
if (conversion == VertexStreamConversion::Repack) {
stride = static_cast<Uint32>(attribByteSize);
} else if (conversion == VertexStreamConversion::ScaledIntegerToFloat32) {
stride = static_cast<Uint32>(attr.Size * static_cast<Int>(sizeof(Float)));
}
}
const VkVertexInputRate inputRate =
(attr.Divisor == 0) ? VK_VERTEX_INPUT_RATE_VERTEX : VK_VERTEX_INPUT_RATE_INSTANCE;
@@ -111,10 +111,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const VulkanRendererConfig& m_config;
VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE;
// Values are heap-allocated: FastSTL::unordered_map is open-addressing,
// so INSERT invalidates references to stored values. The draw path (and
// the VAOs' state-pointer memos) hold entry pointers across inserts;
// only the unique_ptr cell moves, never the pointee.
// Values are heap-allocated: UnorderedMap is open-addressing, so INSERT
// invalidates references to stored values - and so does ERASE, which shifts
// the rest of the probe cluster into the hole and therefore moves entries
// other than the erased one. The draw path (and the VAOs' state-pointer
// memos) hold entry pointers across both; only the unique_ptr cell moves,
// never the pointee.
UnorderedMap<HashType, UniquePtr<BackendVertexInputState>> m_cache;
// Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging.
Uint64 m_frameBoundaryCounter = 0;
@@ -23,7 +23,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT |
VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT |
VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT | VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT |
VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
// "Every usage" has to mean every usage: a buffer texture reached through an IMAGE
// unit takes a VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER descriptor, and the write is
// invalid unless the buffer was created with this bit. Nothing asked for it until
// imageBuffer support existed, so the omission was invisible.
VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
// Appended to kPersistentBackedUsage when VK_EXT_transform_feedback is enabled
// (see VkBufferManagerInitInfo::transformFeedbackUsageEnabled).
constexpr VkBufferUsageFlags kTransformFeedbackUsage =
@@ -298,7 +302,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
.requiredFlags = requiredFlags,
});
if (!created || resource.buffer.Map() == nullptr) {
MGLOG_E("VkBufferManager::CreateResidentStorage failed (size=%llu)",
MGLOG_E_ONCE("VkBufferManager::CreateResidentStorage failed (size=%llu)",
static_cast<unsigned long long>(size));
resource.buffer.Destroy();
resource.storageSize = 0;
@@ -320,7 +324,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return false;
}
if (!resource.buffer.Upload(bufferObject.MappedData(), size, 0)) {
MGLOG_E("VkBufferManager::SwapStorageAndUploadAll: upload failed");
MGLOG_E_ONCE("VkBufferManager::SwapStorageAndUploadAll: upload failed");
resource.pendingFullUpload = true;
return false;
}
@@ -379,6 +383,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
BumpSliceEpoch(*resource);
// Any cached streaming slice refers to the previous contents.
resource->transientFrameSerial = 0;
// Redefining the store hands any adopted mapping back to the CPU shadow
// (BufferObject::RedefineStorage), so a buffer that reaches here persistent-mapped
// is an ordinary resident one again: it needs the busy-tracking and conditional
// orphan below, and the next AcquirePersistentMap has to mint storage for the new
// store rather than hand back a mapping of the old one.
resource->persistentMapped = false;
if (!resource->buffer.IsValid()) {
return; // streaming-only resource: shadow + serial are enough
}
@@ -399,7 +409,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
if (!resource->buffer.Upload(bufferObject.MappedData(), size, 0)) {
MGLOG_E("VkBufferManager::OnRespecify: in-place upload failed");
MGLOG_E_ONCE("VkBufferManager::OnRespecify: in-place upload failed");
resource->pendingFullUpload = true;
}
}
@@ -424,7 +434,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (!IsResourceBusy(*resource)) {
if (!resource->buffer.Upload(bufferObject.MappedData() + offset,
static_cast<VkDeviceSize>(size), static_cast<VkDeviceSize>(offset))) {
MGLOG_E("VkBufferManager::OnSubData: host upload failed");
MGLOG_E_ONCE("VkBufferManager::OnSubData: host upload failed");
resource->pendingFullUpload = true;
}
return;
@@ -461,7 +471,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if ((appAccess & BufferMappingAccessBit::Unsynchronized) || !IsResourceBusy(*resource)) {
if (!resource->buffer.Upload(bufferObject.MappedData() + offset,
static_cast<VkDeviceSize>(size), static_cast<VkDeviceSize>(offset))) {
MGLOG_E("VkBufferManager::OnFlushMappedRange: host upload failed");
MGLOG_E_ONCE("VkBufferManager::OnFlushMappedRange: host upload failed");
resource->pendingFullUpload = true;
}
return;
@@ -553,7 +563,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const VkDeviceSize size = static_cast<VkDeviceSize>(bufferObject->GetSize());
if (size == 0) {
MGLOG_E("VkBufferManager::AcquireResidentSlice failed: buffer size is zero");
MGLOG_E_ONCE("VkBufferManager::AcquireResidentSlice failed: buffer size is zero");
return false;
}
@@ -575,7 +585,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return false;
}
if (!resource->buffer.Upload(bufferObject->MappedData(), size, 0)) {
MGLOG_E("VkBufferManager::AcquireResidentSlice failed: initial upload failed");
MGLOG_E_ONCE("VkBufferManager::AcquireResidentSlice failed: initial upload failed");
resource->buffer.Destroy();
resource->storageSize = 0;
resource->usageFlags = 0;
@@ -610,7 +620,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const VkDeviceSize size = static_cast<VkDeviceSize>(bufferObject->GetSize());
if (size == 0) {
MGLOG_E("VkBufferManager::AcquireStreamedSlice failed: buffer size is zero");
MGLOG_E_ONCE("VkBufferManager::AcquireStreamedSlice failed: buffer size is zero");
return false;
}
@@ -708,7 +718,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
case BufferKind::Uniform:
return VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT;
case BufferKind::TextureBuffer:
return VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT;
// Both texel roles, for the same reason vertex/index carry both bits: one GL buffer
// texture can be read as a samplerBuffer and written as an imageBuffer, and which of
// the two it is only becomes known when a shader that uses it is bound - long after
// the resident buffer was created. A VkBufferView for a storage-texel descriptor is
// invalid unless the buffer was created with the storage bit, so a buffer that
// acquired only the uniform bit could never be given one.
return VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT;
case BufferKind::ShaderStorage:
return VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT;
case BufferKind::Indirect:
@@ -76,7 +76,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const VkResult result =
vmaCreateBuffer(m_allocator, &bufferInfo, &allocationInfo, &m_buffer, &m_allocation, nullptr);
if (result != VK_SUCCESS) {
MGLOG_E("VkBufferObject::Create failed: vmaCreateBuffer returned %d", result);
MGLOG_E_ONCE("VkBufferObject::Create failed: vmaCreateBuffer returned %d", result);
m_allocator = nullptr;
m_buffer = VK_NULL_HANDLE;
m_allocation = nullptr;
@@ -108,7 +108,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const VkResult mapResult = vmaMapMemory(m_allocator, m_allocation, &m_mappedData);
if (mapResult != VK_SUCCESS || m_mappedData == nullptr) {
MGLOG_E("VkBufferObject::Map failed: vmaMapMemory returned %d", mapResult);
MGLOG_E_ONCE("VkBufferObject::Map failed: vmaMapMemory returned %d", mapResult);
m_mappedData = nullptr;
return nullptr;
}
@@ -138,14 +138,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const Bool wasMapped = IsMapped();
void* mapped = wasMapped ? m_mappedData : Map();
if (mapped == nullptr) {
MGLOG_E("VkBufferObject::Upload failed: unable to map buffer");
MGLOG_E_ONCE("VkBufferObject::Upload failed: unable to map buffer");
return false;
}
Memcpy(static_cast<Uint8*>(mapped) + offset, data, static_cast<SizeT>(size));
const VkResult flushResult = vmaFlushAllocation(m_allocator, m_allocation, offset, size);
if (flushResult != VK_SUCCESS) {
MGLOG_E("VkBufferObject::Upload failed: vmaFlushAllocation returned %d", flushResult);
MGLOG_E_ONCE("VkBufferObject::Upload failed: vmaFlushAllocation returned %d", flushResult);
if (!wasMapped) {
Unmap();
}
@@ -170,7 +170,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const VkResult result = vmaInvalidateAllocation(m_allocator, m_allocation, offset, resolvedSize);
if (result != VK_SUCCESS) {
MGLOG_E("VkBufferObject::Invalidate failed: vmaInvalidateAllocation returned %d", result);
MGLOG_E_ONCE("VkBufferObject::Invalidate failed: vmaInvalidateAllocation returned %d", result);
return false;
}
return true;
@@ -123,7 +123,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
if (!attachment.IsComplete()) {
MGLOG_W("GetOrCreateRenderPass: draw buffer slot %u (%s) on FBO %u has an incomplete texture attachment; using VK_ATTACHMENT_UNUSED",
MGLOG_W_ONCE("GetOrCreateRenderPass: draw buffer slot %u (%s) on FBO %u has an incomplete texture attachment; using VK_ATTACHMENT_UNUSED",
drawBufferIndex,
MG_Util::ConvertFramebufferAttachmentTypeToString(attachmentType).c_str(),
fbo.GetExternalIndex());
@@ -132,7 +132,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto* texture = attachment.GetTexture().get();
if (texture == nullptr) {
MGLOG_W("GetOrCreateRenderPass: draw buffer slot %u (%s) on FBO %u resolved to a null texture; using VK_ATTACHMENT_UNUSED",
MGLOG_W_ONCE("GetOrCreateRenderPass: draw buffer slot %u (%s) on FBO %u resolved to a null texture; using VK_ATTACHMENT_UNUSED",
drawBufferIndex,
MG_Util::ConvertFramebufferAttachmentTypeToString(attachmentType).c_str(),
fbo.GetExternalIndex());
@@ -311,7 +311,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkSampleCountFlagBits sampleCount = VK_SAMPLE_COUNT_1_BIT;
if (!TryResolveSampleCountFlagBits(renderbuffer->GetSamples(), sampleCount)) {
MGLOG_E("GetOrCreateRenderbufferResource: unsupported renderbuffer sample count %d for renderbuffer %u",
MGLOG_E_ONCE("GetOrCreateRenderbufferResource: unsupported renderbuffer sample count %d for renderbuffer %u",
renderbuffer->GetSamples(),
renderbuffer->GetExternalIndex());
return nullptr;
@@ -457,7 +457,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_physicalDevice, format, imageInfo.imageType, imageInfo.tiling, imageInfo.usage, imageInfo.flags,
&imageFormatProperties);
if (imageFormatResult != VK_SUCCESS || (imageFormatProperties.sampleCounts & sampleCount) == 0) {
MGLOG_E("GetOrCreateRenderbufferResource: unsupported renderbuffer format=%d samples=%d for renderbuffer %u",
MGLOG_E_ONCE("GetOrCreateRenderbufferResource: unsupported renderbuffer format=%d samples=%d for renderbuffer %u",
static_cast<Int>(format),
static_cast<Int>(sampleCount),
renderbuffer->GetExternalIndex());
@@ -929,7 +929,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const auto& renderbuffer = rbAtt.GetRenderbuffer();
auto* rbResource = GetOrCreateRenderbufferResource(renderbuffer);
if (rbResource == nullptr || (rbResource->aspect & VK_IMAGE_ASPECT_COLOR_BIT) == 0) {
MGLOG_E("GetOrCreateRenderPass: draw buffer slot %u on FBO %u has an unsupported color "
MGLOG_E_ONCE("GetOrCreateRenderPass: draw buffer slot %u on FBO %u has an unsupported color "
"renderbuffer %u; using VK_ATTACHMENT_UNUSED",
i, fbo.GetExternalIndex(), renderbuffer->GetExternalIndex());
continue;
@@ -1105,7 +1105,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
adoptRenderPassSampleCount(attachmentSampleCount, "color", texture->GetExternalIndex());
if (!hasClear && trackedColorLayout == VK_IMAGE_LAYOUT_UNDEFINED) {
MGLOG_W("GetOrCreateRenderPass: color attachment textureId=%d starts with undefined layout and no clear; "
MGLOG_W_ONCE("GetOrCreateRenderPass: color attachment textureId=%d starts with undefined layout and no clear; "
"using LOAD_OP_DONT_CARE",
texture->GetExternalIndex());
desc.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
@@ -1161,7 +1161,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
isUsableDepthStencilAttachment(depthAtt) && isUsableDepthStencilAttachment(stencilAtt) &&
!sameDepthStencilAttachmentObject(depthAtt, stencilAtt);
if (hasDistinctDepthAndStencilAttachments) {
MGLOG_E("GetOrCreateRenderPass: separate depth/stencil attachments are not supported yet; using the depth attachment and ignoring the standalone stencil attachment for framebuffer %u",
MGLOG_E_ONCE("GetOrCreateRenderPass: separate depth/stencil attachments are not supported yet; using the depth attachment and ignoring the standalone stencil attachment for framebuffer %u",
fbo.GetExternalIndex());
}
if (selectedDepthStencilAttachment != nullptr) {
@@ -1223,7 +1223,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
depthAttachmentDescription.finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
depthAttachmentDescription.initialLayout = loadInfo.initialLayout;
if (trackedDepthLayout == VK_IMAGE_LAYOUT_UNDEFINED && (!clearDepth || !clearStencil)) {
MGLOG_W("GetOrCreateRenderPass: depth/stencil attachment id=%d starts with undefined layout "
MGLOG_W_ONCE("GetOrCreateRenderPass: depth/stencil attachment id=%d starts with undefined layout "
"and partial/no clear; using DONT_CARE for uncleared aspects",
depthAttachmentId);
}
@@ -101,6 +101,42 @@ namespace MobileGL::MG_Backend::DirectVulkan {
std::swap(layers, that.layers);
std::swap(lastUsedFrame, that.lastUsedFrame);
}
// Move ASSIGNMENT, not just construction. The move constructor above and the
// destructor below each independently suppress the implicit one, which left the
// type move-constructible but not move-assignable - and therefore not swappable,
// which std::swap(pair&, pair&) requires. That was invisible while UnorderedMap
// only ever move-CONSTRUCTED an element into a fresh slot. ska::flat_hash_map
// probes robin-hood: inserting swaps the entry being placed against the one
// already sitting in the slot whenever it has travelled further from its desired
// position, so the mapped type has to be swappable or the table fails to
// instantiate at all.
//
// SWAP SEMANTICS, exactly like the move constructor: this does not release the
// destination's handles, it parks them in `that`, which destroys them when it
// dies. That is correct for the only caller - std::swap, whose temporary expires
// immediately - and it is what keeps the three-move sequence from destroying a
// live render pass. It is NOT correct for a hand-written `a = std::move(b)` where
// `a` held live handles and `b` outlives the statement: those handles would then
// survive until `b` dies. There is no such caller; add a destroy-then-steal
// assignment before writing one.
RenderPassEntry& operator=(RenderPassEntry&& that) noexcept {
if (this != &that) {
std::swap(hash, that.hash);
std::swap(renderPass, that.renderPass);
std::swap(framebuffer, that.framebuffer);
std::swap(compatibilityHash, that.compatibilityHash);
std::swap(pendingClearAttachments, that.pendingClearAttachments);
std::swap(trackedAttachmentLayouts, that.trackedAttachmentLayouts);
std::swap(attachmentCount, that.attachmentCount);
std::swap(colorAttachmentCount, that.colorAttachmentCount);
std::swap(hasDepthStencilAttachment, that.hasDepthStencilAttachment);
std::swap(sampleCount, that.sampleCount);
std::swap(extent, that.extent);
std::swap(layers, that.layers);
std::swap(lastUsedFrame, that.lastUsedFrame);
}
return *this;
}
RenderPassEntry(
Uint64 hash,
VkRenderPass renderpass,
@@ -315,26 +351,30 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint64 deferredAtFrame = 0;
};
// Node-based std::unordered_map, deliberately not FastSTL's open-addressing UnorderedMap:
// Node-based std::unordered_map, deliberately NOT the open-addressing UnorderedMap:
// callers cache a RenderbufferResource* - or a bare &resource->layout - and then make further
// calls that touch this map. BlitFramebuffer is the one that bit: it resolves the source and
// destination colour bindings (ResolveColorBlitBinding caches &rbResource->layout), then
// materializes the source's pending clear, which looks that same resource up again. FastSTL's
// operator[] runs its load-factor check before find_key and reallocates the whole bucket array
// when occupancy crosses it, so even a plain lookup relocates every element; erase only
// tombstones and never decrements the occupancy, so the doubling keeps firing. After a
// relocation the cached pointer names freed storage still holding the pre-clear
// VK_IMAGE_LAYOUT_UNDEFINED, and BlitFramebuffer bails out at "source image layout is
// undefined", silently dropping the blit - renderbuffers_storage_multisample read back zero
// instead of the clear colour on exactly the iterations that grew the table.
// materializes the source's pending clear, which looks that same resource up again. Growing
// an open-addressed table relocates every element, so the cached pointer went on to name
// freed storage still holding the pre-clear VK_IMAGE_LAYOUT_UNDEFINED; BlitFramebuffer bailed
// out at "source image layout is undefined", silently dropping the blit -
// renderbuffers_storage_multisample read back zero instead of the clear colour on exactly the
// iterations that grew the table.
//
// Reordering the materialize ahead of the resolves - the fix ReadPixels got - does not cover
// this: the destination resolve still runs after the source pointer is taken. The depth blit,
// GetOrCreateRenderPass's depthRenderbufferResource and ReadDepthStencilPixels cache the same
// kind of pointer, so the invariant belongs in the container rather than in a per-call-site
// ordering rule. m_textureResources is node-based for the same reason. This buys stability
// across rehash and insert only - erase still invalidates the erased element, which is safe
// here because a renderbuffer that is an FBO attachment is held alive by that attachment.
// ordering rule. m_textureResources is node-based for the same reason.
//
// The case for keeping this node-based got STRONGER with ska::flat_hash_map, so do not read
// the paragraph above as merely historical: ska erases by shifting the rest of the probe
// cluster backwards into the hole, so erasing one renderbuffer relocates OTHER renderbuffers'
// entries - a cached pointer can now be invalidated by a key it has nothing to do with, which
// no call-site ordering rule can defend against. (What did change: ska's operator[] returns on
// a hit before it runs its grow check, so a plain lookup of a PRESENT key no longer relocates.
// That narrows the insert hazard; it does not touch the erase one.)
std::unordered_map<MG_State::GLState::RenderbufferObject*, RenderbufferResource> m_renderbufferResources;
UnorderedMap<MG_State::GLState::RenderbufferObject*, PendingRenderbufferClear> m_pendingRenderbufferClears;
Vector<DeferredRenderbufferRelease> m_deferredRenderbufferReleases;
@@ -950,7 +950,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const TextureFormatInfo formatInfo = ResolveTextureFormatInfo(texture.GetFormat());
const VkComponentMapping sampledComponents = ResolveSampledViewComponents(texture, formatInfo);
const VkImageAspectFlags sampledAspect = ResolveSampledImageViewAspectMask(resource->aspect);
const VkImageAspectFlags sampledAspect =
ResolveSampledImageViewAspectMask(resource->aspect, texture.GetDepthStencilTextureMode());
perMipSampledView = CreateImageView(resource->image, resource->format, sampledAspect, resource->viewType,
mipLevel, 1, 0, resource->arrayLayers, &sampledComponents);
if (perMipSampledView == VK_NULL_HANDLE) {
@@ -974,13 +975,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return resource->sampledView;
}
if (!AreSampledImageViewFormatsCompatible(resource->format, format)) {
MGLOG_E("%s: incompatible sampled image view format=%d for textureId=%d imageFormat=%d",
MGLOG_E_ONCE("%s: incompatible sampled image view format=%d for textureId=%d imageFormat=%d",
__func__, static_cast<Int>(format), texture.GetExternalIndex(),
static_cast<Int>(resource->format));
return VK_NULL_HANDLE;
}
if ((resource->imageCreateFlags & VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT) == 0) {
MGLOG_E("%s: textureId=%d needs mutable image format=%d for sampled view format=%d",
MGLOG_E_ONCE("%s: textureId=%d needs mutable image format=%d for sampled view format=%d",
__func__, texture.GetExternalIndex(), static_cast<Int>(resource->format),
static_cast<Int>(format));
return VK_NULL_HANDLE;
@@ -1000,7 +1001,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkFormatProperties formatProperties{};
vkGetPhysicalDeviceFormatProperties(m_physicalDevice, format, &formatProperties);
if ((formatProperties.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT) == 0) {
MGLOG_E("%s: sampled image view format=%d lacks VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT "
MGLOG_E_ONCE("%s: sampled image view format=%d lacks VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT "
"for textureId=%d (available=0x%x)",
__func__, static_cast<Int>(format), texture.GetExternalIndex(),
static_cast<Uint32>(formatProperties.optimalTilingFeatures));
@@ -1014,7 +1015,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
resource->sampledBaseMipLevel, resource->sampledLevelCount, 0, resource->arrayLayers,
&sampledComponents, VK_IMAGE_USAGE_SAMPLED_BIT);
if (view == VK_NULL_HANDLE) {
MGLOG_E("%s: failed to create sampled image view textureId=%d imageFormat=%d viewFormat=%d",
MGLOG_E_ONCE("%s: failed to create sampled image view textureId=%d imageFormat=%d viewFormat=%d",
__func__, texture.GetExternalIndex(), static_cast<Int>(resource->format),
static_cast<Int>(format));
return VK_NULL_HANDLE;
@@ -1042,14 +1043,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
format = resource->format;
}
if (!AreStorageImageViewFormatsCompatible(resource->format, format)) {
MGLOG_E("%s: incompatible storage image view format=%d for textureId=%d imageFormat=%d",
MGLOG_E_ONCE("%s: incompatible storage image view format=%d for textureId=%d imageFormat=%d",
__func__, static_cast<Int>(format), texture.GetExternalIndex(),
static_cast<Int>(resource->format));
return VK_NULL_HANDLE;
}
if (format != resource->format &&
(resource->imageCreateFlags & VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT) == 0) {
MGLOG_E("%s: textureId=%d needs mutable image format=%d for storage view format=%d",
MGLOG_E_ONCE("%s: textureId=%d needs mutable image format=%d for storage view format=%d",
__func__, texture.GetExternalIndex(), static_cast<Int>(resource->format),
static_cast<Int>(format));
return VK_NULL_HANDLE;
@@ -1069,7 +1070,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
viewType = VK_IMAGE_VIEW_TYPE_2D;
break;
case VK_IMAGE_VIEW_TYPE_3D:
MGLOG_E("%s: non-layered 3D storage views are unsupported for textureId=%d",
MGLOG_E_ONCE("%s: non-layered 3D storage views are unsupported for textureId=%d",
__func__, texture.GetExternalIndex());
return VK_NULL_HANDLE;
default:
@@ -1078,7 +1079,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (viewType != resource->viewType) {
if (layer < 0 || static_cast<Uint32>(layer) >= resource->arrayLayers) {
MGLOG_E("%s: storage image layer=%d is out of range for textureId=%d arrayLayers=%u",
MGLOG_E_ONCE("%s: storage image layer=%d is out of range for textureId=%d arrayLayers=%u",
__func__, layer, texture.GetExternalIndex(), resource->arrayLayers);
return VK_NULL_HANDLE;
}
@@ -1113,7 +1114,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkFormatProperties formatProperties{};
vkGetPhysicalDeviceFormatProperties(m_physicalDevice, format, &formatProperties);
if ((formatProperties.optimalTilingFeatures & requiredFormatFeatures) != requiredFormatFeatures) {
MGLOG_E("%s: storage image view format=%d lacks required features=0x%x for textureId=%d "
MGLOG_E_ONCE("%s: storage image view format=%d lacks required features=0x%x for textureId=%d "
"(available=0x%x)",
__func__, static_cast<Int>(format), static_cast<Uint32>(requiredFormatFeatures),
texture.GetExternalIndex(), static_cast<Uint32>(formatProperties.optimalTilingFeatures));
@@ -1124,7 +1125,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
mipLevel, 1, baseArrayLayer, layerCount, nullptr,
VK_IMAGE_USAGE_STORAGE_BIT);
if (view == VK_NULL_HANDLE) {
MGLOG_E("%s: failed to create storage image view for textureId=%d mip=%u imageFormat=%d viewFormat=%d",
MGLOG_E_ONCE("%s: failed to create storage image view for textureId=%d mip=%u imageFormat=%d viewFormat=%d",
__func__, texture.GetExternalIndex(), mipLevel, static_cast<Int>(resource->format),
static_cast<Int>(format));
return VK_NULL_HANDLE;
@@ -1222,7 +1223,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return true;
}
if (resource->layout == VK_IMAGE_LAYOUT_UNDEFINED) {
MGLOG_W("TransitionTextureForSampling: textureId=%d is still in VK_IMAGE_LAYOUT_UNDEFINED before sampling",
MGLOG_W_ONCE("TransitionTextureForSampling: textureId=%d is still in VK_IMAGE_LAYOUT_UNDEFINED before sampling",
texture.GetExternalIndex());
}
@@ -1573,7 +1574,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// targets this manager has no Vulkan image shape for yet (cube map arrays above all).
// Declining the sync leaves the texture unbacked - wrong, but recoverable - where an
// assertion would take the whole process down instead.
MGLOG_W("SyncTextureResource: unsupported uploadTarget=%s textureTarget=%s textureId=%d size=(%d,%d,%d) "
MGLOG_W_ONCE("SyncTextureResource: unsupported uploadTarget=%s textureTarget=%s textureId=%d size=(%d,%d,%d) "
"mipLevels=%u vkViewType=%d",
MG_Util::ConvertTextureUploadTargetToString(uploadTarget).c_str(),
MG_Util::ConvertTextureTargetToString(texture.GetTarget()).c_str(), texture.GetExternalIndex(),
@@ -1802,7 +1803,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Losing reinterpreted views only degrades the formatless-image feature for
// this texture; failing creation would lose the texture entirely, so retry
// as a plain immutable-format image.
MGLOG_W("%s: mutable image format=%d is unsupported for textureId=%d; creating "
MGLOG_W_ONCE("%s: mutable image format=%d is unsupported for textureId=%d; creating "
"without VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT (format reinterpretation "
"will be unavailable for it)",
__func__, static_cast<Int>(format), texture.GetExternalIndex());
@@ -1820,7 +1821,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Losing 2D-array compatibility only costs per-slice framebuffer attachment for this
// format; failing creation would lose the texture entirely. Remembered so later syncs
// neither reprobe nor flag-mismatch against this image and recreate it.
MGLOG_W("%s: VK_IMAGE_CREATE_2D_ARRAY_COMPATIBLE_BIT is unsupported for format=%d "
MGLOG_W_ONCE("%s: VK_IMAGE_CREATE_2D_ARRAY_COMPATIBLE_BIT is unsupported for format=%d "
"textureId=%d; creating without it (per-slice framebuffer attachment will be "
"unavailable for it)",
__func__, static_cast<Int>(format), texture.GetExternalIndex());
@@ -1852,7 +1853,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const VkResult createImageResult =
vmaCreateImage(m_allocator, &imageInfo, &allocationInfo, &resource.image, &resource.allocation, nullptr);
if (createImageResult != VK_SUCCESS) {
MGLOG_F("SyncTextureResource: vmaCreateImage failed (%d) textureId=%d extent=%ux%u depth=%u layers=%u "
// E_ONCE, not F: the comment above says it - this is a soft failure the caller
// recovers from, and it re-fires on every sync of every texture the driver refuses.
MGLOG_E_ONCE("SyncTextureResource: vmaCreateImage failed (%d) textureId=%d extent=%ux%u depth=%u layers=%u "
"mips=%u samples=%d format=%d",
createImageResult, texture.GetExternalIndex(), imageInfo.extent.width, imageInfo.extent.height,
imageInfo.extent.depth, imageInfo.arrayLayers, imageInfo.mipLevels,
@@ -2238,7 +2241,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (resource.fullView == VK_NULL_HANDLE) {
return false;
}
const VkImageAspectFlags sampledAspect = ResolveSampledImageViewAspectMask(resource.aspect);
const VkImageAspectFlags sampledAspect =
ResolveSampledImageViewAspectMask(resource.aspect, texture.GetDepthStencilTextureMode());
resource.sampledView = CreateImageView(resource.image, resource.format, sampledAspect, resource.viewType,
baseMipLevel, levelCount, 0, resource.arrayLayers, &sampledComponents);
if (resource.sampledView == VK_NULL_HANDLE) {
@@ -2424,7 +2428,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const Bool srcIsD24S8 = outResource.format == VK_FORMAT_D24_UNORM_S8_UINT;
const Bool srcIsD32FS8 = outResource.format == VK_FORMAT_D32_SFLOAT_S8_UINT;
if (!srcIsD24S8 && !srcIsD32FS8) {
MGLOG_E("UploadDirtyMipLevels: unsupported combined depth-stencil format %d for textureId=%d",
MGLOG_E_ONCE("UploadDirtyMipLevels: unsupported combined depth-stencil format %d for textureId=%d",
static_cast<Int>(outResource.format), mipmapTexture.GetExternalIndex());
for (const auto& item : uploadItems) {
mipmapTexture.MarkStorageDirty(item.target, item.level, false);
@@ -2860,10 +2864,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
VkImageAspectFlags VkTextureManager::ResolveSampledImageViewAspectMask(VkImageAspectFlags imageAspect) {
VkImageAspectFlags VkTextureManager::ResolveSampledImageViewAspectMask(VkImageAspectFlags imageAspect,
GLenum depthStencilTextureMode) {
if ((imageAspect & VK_IMAGE_ASPECT_COLOR_BIT) != 0) {
return VK_IMAGE_ASPECT_COLOR_BIT;
}
// A sampled view of a combined depth/stencil image may name exactly one aspect
// (VUID-VkDescriptorImageInfo-imageView-01976), and GL_DEPTH_STENCIL_TEXTURE_MODE is
// what picks it - the whole content of GL_ARB_stencil_texturing. Depth stays the
// default, so nothing that never sets the mode changes shape. The texture's params
// version moves with the mode, which is what makes the cached views be rebuilt.
if (depthStencilTextureMode == GL_STENCIL_INDEX && (imageAspect & VK_IMAGE_ASPECT_STENCIL_BIT) != 0) {
return VK_IMAGE_ASPECT_STENCIL_BIT;
}
if ((imageAspect & VK_IMAGE_ASPECT_DEPTH_BIT) != 0) {
return VK_IMAGE_ASPECT_DEPTH_BIT;
}
@@ -379,7 +379,11 @@ public:
// true - a false positive merely ends the render pass, a false negative would skip a barrier.
Bool NeedsStorageImagePreparation(MG_State::GLState::ITextureObject& texture) const;
static VkImageAspectFlags ResolveSampledImageViewAspectMask(VkImageAspectFlags imageAspect);
// `depthStencilTextureMode` is the texture's GL_DEPTH_STENCIL_TEXTURE_MODE; it only decides
// anything for an image that carries both aspects. Defaulted so the call sites that have no
// texture in hand keep the depth-aspect answer they have always given.
static VkImageAspectFlags ResolveSampledImageViewAspectMask(VkImageAspectFlags imageAspect,
GLenum depthStencilTextureMode = GL_DEPTH_COMPONENT);
static VkFormat ResolveSampledImageViewFormat(VkFormat imageFormat, SamplerNumericDomain numericDomain);
static Bool AreSampledImageViewFormatsCompatible(VkFormat imageFormat, VkFormat viewFormat);
static Bool AreStorageImageViewFormatsCompatible(VkFormat imageFormat, VkFormat viewFormat);
@@ -15,7 +15,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MOBILEGL_ASSERT(initInfo.device != VK_NULL_HANDLE, "VkTimerQueryManager::Initialize requires valid VkDevice");
MOBILEGL_ASSERT(initInfo.frameCount > 0, "VkTimerQueryManager::Initialize requires non-zero frame count");
if (initInfo.timestampValidBits == 0 || initInfo.timestampPeriodNs <= 0.0f || initInfo.slotsPerPool == 0) {
MGLOG_W("VkTimerQueryManager: timestamps unsupported (validBits=%u, period=%f, slots=%u)",
MGLOG_W_ONCE("VkTimerQueryManager: timestamps unsupported (validBits=%u, period=%f, slots=%u)",
initInfo.timestampValidBits, initInfo.timestampPeriodNs, initInfo.slotsPerPool);
return false;
}
@@ -35,7 +35,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
for (auto& poolState : m_pools) {
const VkResult result = vkCreateQueryPool(m_device, &poolInfo, nullptr, &poolState.pool);
if (result != VK_SUCCESS) {
MGLOG_E("VkTimerQueryManager: vkCreateQueryPool failed with %s", VkResultToString(result));
MGLOG_E_ONCE("VkTimerQueryManager: vkCreateQueryPool failed with %s", VkResultToString(result));
Shutdown();
return false;
}
@@ -90,7 +90,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto& poolState = m_pools[frameIndex];
if (poolState.cursor >= m_slotsPerPool) {
if (!poolState.exhaustionWarned) {
MGLOG_W("VkTimerQueryManager: frame %u timestamp pool exhausted (%u slots); further timer queries "
MGLOG_W_ONCE("VkTimerQueryManager: frame %u timestamp pool exhausted (%u slots); further timer queries "
"this frame fall back to the frontend path",
frameIndex, m_slotsPerPool);
poolState.exhaustionWarned = true;
@@ -120,7 +120,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_device, m_pools[record.poolIndex].pool, record.slot, 1, sizeof(resultWithAvailability),
resultWithAvailability, sizeof(Uint64), VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WITH_AVAILABILITY_BIT);
if (result != VK_SUCCESS && result != VK_NOT_READY) {
MGLOG_E("VkTimerQueryManager: vkGetQueryPoolResults failed with %s", VkResultToString(result));
MGLOG_E_ONCE("VkTimerQueryManager: vkGetQueryPoolResults failed with %s", VkResultToString(result));
return false;
}
if (resultWithAvailability[1] == 0) {
File diff suppressed because it is too large Load Diff
@@ -773,6 +773,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
ProgramFactory::CompileOptionFlags m_lastLodBaseFlags = {};
ProgramFactory::CompileOptionFlags m_lastLodResultFlags = {};
// Does the current program's vertex stage declare the BaseVertex builtin? A property
// of the program's SPIR-V, so (lifetime id, backend-state version) is the whole key.
//
// Memoized rather than re-asked because asking means resolving the UN-zeroed program
// variant, and a program that only ever draws non-indexed would then compile a variant
// no draw uses AND re-stamp its use every draw, so the idle sweep could never retire
// it. With the memo the answer is known before the first lookup and only the variant
// the draw actually needs is resolved.
Bool m_lastBaseVertexQueryValid = false;
Uint64 m_lastBaseVertexProgramLifetimeId = 0;
Uint32 m_lastBaseVertexProgramVersion = 0;
Bool m_lastBaseVertexReads = false;
// Snapshot behind TrySetupDrawFastPath. Values only: the program and
// render-pass caches are open-addressing maps whose entries move on
// insert, so no pointers into them are cached; the pipeline handle is
@@ -74,6 +74,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// The context line (__VA_ARGS__ = its own format string + args) must be a SEPARATE log
// call: appending its format to the base format while its arguments precede the base
// arguments makes every conversion read the wrong slot (a %s pulling an int crashes).
//
// MGLOG_F and deliberately NOT latched. VK_VERIFY is the invariant-check macro: a Vulkan call
// MobileGL believes it has already made legal came back non-success, which is a
// should-never-happen state, not an expected failure mode a user hits. Those fast-fail loudly
// and keep saying so - the log-quietness rules that latch W/E cover expected failures (driver
// capability gaps, app misuse), not broken internal invariants. MOBILEGL_ASSERT below traps in
// a DEBUG build; MGLOG_F is what makes the same condition visible in an INFO test run, where
// the assert is compiled out by contract.
//
// A soft, recoverable failure must therefore NOT be routed through VK_VERIFY. Check the
// VkResult directly and report it with MGLOG_E_ONCE - see VkTextureManager::SyncTextureResource,
// where a driver legitimately refuses an image the format pre-check accepted.
#define VK_VERIFY(expr, ...) \
do { \
VkResult _vk_verify_result = (expr); \
+2 -1
View File
@@ -42,4 +42,5 @@ set_tests_properties(SanityBench PROPERTIES LABELS benchmark)
add_subdirectory(Program)
add_subdirectory(Buffer)
add_subdirectory(Driver)
add_subdirectory(Driver)
add_subdirectory(Container)
@@ -0,0 +1,20 @@
cmake_minimum_required(VERSION 3.24)
add_executable(
UnorderedMapBench
UnorderedMapBench.cpp
)
target_include_directories(UnorderedMapBench PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
UnorderedMapBench PRIVATE
benchmark::benchmark
${LINK_LIBRARIES}
)
add_test(NAME UnorderedMapBench COMMAND UnorderedMapBench --benchmark_counters_tabular=true)
set_tests_properties(UnorderedMapBench PROPERTIES LABELS benchmark)
@@ -0,0 +1,248 @@
// MobileGL - MobileGL/MG_Benchmark/Container/UnorderedMapBench.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
//
// The standing performance observatory for MobileGL::UnorderedMap.
//
// This benchmarks the ALIAS, never a concrete table, so whatever UnorderedMap
// names today is what gets measured - swap the container in MG_Util/Types.h and
// re-run this same binary to get a directly comparable set of numbers. That is
// the point of it: the container sits on per-draw paths, so a change to it needs
// evidence, and the evidence should be produced the same way every time.
//
// The workloads are the shapes the tree actually exercises, not generic hash-map
// microbenchmarks. Four key shapes, because they stress a hash function very
// differently:
// * SEQUENTIAL dense small integers - GL object names from the index generator
// (buffer/texture/framebuffer/sampler registries).
// * POINTER real heap addresses - StateBackendObjectRegistry keys on
// StateObject*. These are aligned, so their low bits are the
// least random part of the key; a table that indexes on raw low
// bits clusters badly here and one that mixes first does not.
// Taken from the real allocator rather than a synthetic stride,
// which would flatter whichever table mixes its bits.
// * DIGEST already well-mixed 64-bit values - the XXH64 pipeline,
// vertex-input-state and program memos.
// * NAME short strings - uniform/attribute name to location maps.
//
// Sizes sweep from 8 upward because the per-draw memos are usually SMALL; a table
// that only wins at 4096 entries has not won anything that matters here.
//
// Run: build-linux/MobileGL/MG_Benchmark/Container/UnorderedMapBench
// or: ctest -R UnorderedMapBench (label: benchmark)
#include <cstdint>
#include <memory>
#include <random>
#include <string>
#include <vector>
#include <benchmark/benchmark.h>
#include "MG_Util/Types.h"
using namespace MobileGL;
namespace {
constexpr Int64 kMinSize = 8;
constexpr Int64 kMaxSize = 4096;
// Keep the real allocations alive for the whole process: the POINTER shape is
// only honest if the keys are addresses the allocator actually handed out, and
// they have to stay unique (a freed address can be handed out twice).
std::vector<std::unique_ptr<char[]>>& PointerKeyStorage() {
static std::vector<std::unique_ptr<char[]>> storage;
return storage;
}
Vector<Uint64> SequentialKeys(SizeT n) {
Vector<Uint64> keys;
keys.reserve(n);
for (SizeT i = 0; i < n; ++i) keys.push_back(static_cast<Uint64>(i) + 1);
return keys;
}
Vector<Uint64> PointerKeys(SizeT n) {
auto& storage = PointerKeyStorage();
Vector<Uint64> keys;
keys.reserve(n);
std::mt19937_64 rng(0xBEEF);
std::vector<std::unique_ptr<char[]>> churn;
for (SizeT i = 0; i < n; ++i) {
// State objects are not all one size, and the allocator sees other
// traffic between them - a single uniform stride is not what this
// registry ever sees.
const SizeT sz = 96 + (rng() % 192);
auto p = std::make_unique<char[]>(sz);
keys.push_back(reinterpret_cast<Uint64>(p.get()));
storage.push_back(std::move(p));
if ((rng() & 3) == 0) churn.push_back(std::make_unique<char[]>(32 + (rng() % 128)));
}
return keys;
}
Vector<Uint64> DigestKeys(SizeT n) {
Vector<Uint64> keys;
keys.reserve(n);
std::mt19937_64 rng(0xC0FFEE);
for (SizeT i = 0; i < n; ++i) keys.push_back(rng());
return keys;
}
Vector<String> NameKeys(SizeT n) {
static const char* kPrefixes[] = {"u_", "a_", "mc_", "iris_", "gl_", "v_"};
Vector<String> keys;
keys.reserve(n);
for (SizeT i = 0; i < n; ++i) {
keys.push_back(String(kPrefixes[i % 6]) + "Uniform" + std::to_string(i) + "_xyz");
}
return keys;
}
// Key sets are built once per size and shared: generating them inside the timed
// loop would measure the generator (and, for POINTER, the allocator) instead of
// the table.
template <typename KeyVec, KeyVec (*Make)(SizeT)>
const KeyVec& CachedKeys(SizeT n) {
static UnorderedMap<SizeT, KeyVec> cache;
auto it = cache.find(n);
if (it != cache.end()) return it->second;
return cache.emplace(n, Make(n)).first->second;
}
template <typename Key>
UnorderedMap<Key, Uint64> Populated(const Vector<Key>& keys) {
UnorderedMap<Key, Uint64> map;
for (SizeT i = 0; i < keys.size(); ++i) map[keys[i]] = i;
return map;
}
// ---- the workloads ----------------------------------------------------
// The dominant per-draw operation by a wide margin: a populated cache that is
// read far more often than it is written.
template <typename KeyVec, KeyVec (*Make)(SizeT)>
void LookupHit(benchmark::State& state) {
const auto& keys = CachedKeys<KeyVec, Make>(static_cast<SizeT>(state.range(0)));
auto map = Populated(keys);
for (auto _ : state) {
for (const auto& k : keys) {
auto it = map.find(k);
benchmark::DoNotOptimize(it->second);
}
}
state.SetItemsProcessed(state.iterations() * static_cast<Int64>(keys.size()));
}
// "Is this resource cached yet?" answered NO - the probe length on a miss is a
// different cost from a hit, and resource caches ask this constantly.
template <typename KeyVec, KeyVec (*Make)(SizeT)>
void LookupMiss(benchmark::State& state) {
const SizeT n = static_cast<SizeT>(state.range(0));
const auto& keys = CachedKeys<KeyVec, Make>(n);
auto map = Populated(keys);
const KeyVec absent = Make(n); // same shape, never inserted
for (auto _ : state) {
for (const auto& k : absent) {
benchmark::DoNotOptimize(map.find(k) != map.end());
}
}
state.SetItemsProcessed(state.iterations() * static_cast<Int64>(absent.size()));
}
// Building a cache from empty, rehashes included.
template <typename KeyVec, KeyVec (*Make)(SizeT)>
void InsertGrow(benchmark::State& state) {
const auto& keys = CachedKeys<KeyVec, Make>(static_cast<SizeT>(state.range(0)));
for (auto _ : state) {
UnorderedMap<typename KeyVec::value_type, Uint64> map;
for (SizeT i = 0; i < keys.size(); ++i) map[keys[i]] = i;
benchmark::DoNotOptimize(map.size());
}
state.SetItemsProcessed(state.iterations() * static_cast<Int64>(keys.size()));
}
// Cache eviction and refill: erase half by key, put them back. This is the
// aged-out-entry sweep the pipeline and vertex-input caches do.
template <typename KeyVec, KeyVec (*Make)(SizeT)>
void EraseChurn(benchmark::State& state) {
const auto& keys = CachedKeys<KeyVec, Make>(static_cast<SizeT>(state.range(0)));
for (auto _ : state) {
state.PauseTiming();
auto map = Populated(keys);
state.ResumeTiming();
for (SizeT i = 0; i < keys.size(); i += 2) benchmark::DoNotOptimize(map.erase(keys[i]));
for (SizeT i = 0; i < keys.size(); i += 2) map[keys[i]] = i;
benchmark::DoNotOptimize(map.size());
}
state.SetItemsProcessed(state.iterations() * static_cast<Int64>(keys.size()));
}
// Mass eviction: erase-while-iterating across the whole table. This is the loop
// shape that a container's erase()-return contract can get wrong, and the one
// that fed garbage handles to vkDestroyPipeline when it was wrong before.
template <typename KeyVec, KeyVec (*Make)(SizeT)>
void EraseSweep(benchmark::State& state) {
const auto& keys = CachedKeys<KeyVec, Make>(static_cast<SizeT>(state.range(0)));
for (auto _ : state) {
state.PauseTiming();
auto map = Populated(keys);
state.ResumeTiming();
for (auto it = map.begin(); it != map.end();) it = map.erase(it);
benchmark::DoNotOptimize(map.size());
}
state.SetItemsProcessed(state.iterations() * static_cast<Int64>(keys.size()));
}
// Whole-table walks: the per-frame sweeps that age entries out, and the
// teardown loops that destroy every Vulkan object a cache owns.
template <typename KeyVec, KeyVec (*Make)(SizeT)>
void Iterate(benchmark::State& state) {
const auto& keys = CachedKeys<KeyVec, Make>(static_cast<SizeT>(state.range(0)));
auto map = Populated(keys);
for (auto _ : state) {
Uint64 acc = 0;
for (const auto& entry : map) acc += entry.second;
benchmark::DoNotOptimize(acc);
}
state.SetItemsProcessed(state.iterations() * static_cast<Int64>(keys.size()));
}
} // namespace
#define MGL_MAP_BENCH(WORKLOAD, SHAPE, VEC, MAKER) \
BENCHMARK_TEMPLATE(WORKLOAD, VEC, MAKER) \
->Name(#WORKLOAD "/" #SHAPE) \
->RangeMultiplier(8) \
->Range(kMinSize, kMaxSize)
MGL_MAP_BENCH(LookupHit, sequential, Vector<Uint64>, SequentialKeys);
MGL_MAP_BENCH(LookupHit, pointer, Vector<Uint64>, PointerKeys);
MGL_MAP_BENCH(LookupHit, digest, Vector<Uint64>, DigestKeys);
MGL_MAP_BENCH(LookupHit, name, Vector<String>, NameKeys);
MGL_MAP_BENCH(LookupMiss, sequential, Vector<Uint64>, SequentialKeys);
MGL_MAP_BENCH(LookupMiss, pointer, Vector<Uint64>, PointerKeys);
MGL_MAP_BENCH(LookupMiss, digest, Vector<Uint64>, DigestKeys);
MGL_MAP_BENCH(LookupMiss, name, Vector<String>, NameKeys);
MGL_MAP_BENCH(InsertGrow, sequential, Vector<Uint64>, SequentialKeys);
MGL_MAP_BENCH(InsertGrow, pointer, Vector<Uint64>, PointerKeys);
MGL_MAP_BENCH(InsertGrow, digest, Vector<Uint64>, DigestKeys);
MGL_MAP_BENCH(InsertGrow, name, Vector<String>, NameKeys);
MGL_MAP_BENCH(EraseChurn, sequential, Vector<Uint64>, SequentialKeys);
MGL_MAP_BENCH(EraseChurn, digest, Vector<Uint64>, DigestKeys);
MGL_MAP_BENCH(EraseChurn, name, Vector<String>, NameKeys);
MGL_MAP_BENCH(EraseSweep, sequential, Vector<Uint64>, SequentialKeys);
MGL_MAP_BENCH(EraseSweep, digest, Vector<Uint64>, DigestKeys);
MGL_MAP_BENCH(Iterate, sequential, Vector<Uint64>, SequentialKeys);
MGL_MAP_BENCH(Iterate, digest, Vector<Uint64>, DigestKeys);
BENCHMARK_MAIN();
+11 -11
View File
@@ -21,7 +21,7 @@ namespace MobileGL::MG_Impl::EGLImpl {
EGLStateContext* GetState() {
if (!MG_State::pEGLContext) {
MGLOG_E("pEGLContext is null. MG_State may not be initialized.");
MGLOG_E_ONCE("pEGLContext is null. MG_State may not be initialized.");
}
return MG_State::pEGLContext.get();
}
@@ -146,7 +146,7 @@ namespace MobileGL::MG_Impl::EGLImpl {
auto* backendObject = GetBackendObject(state);
if (!backendObject) {
MGLOG_E("activeBackendObject not initialized!");
MGLOG_E_ONCE("activeBackendObject not initialized!");
state->DestroySurface(dpy, surface);
return EGL_NO_SURFACE;
}
@@ -172,11 +172,11 @@ namespace MobileGL::MG_Impl::EGLImpl {
auto* backendObject = GetBackendObject(state);
if (!backendObject) {
MGLOG_E("activeBackendObject not initialized!");
MGLOG_E_ONCE("activeBackendObject not initialized!");
return EGL_FALSE;
}
if (!backendObject->SwapEGLBuffers(dpy, draw)) {
MGLOG_E("eglSwapBuffers failed on thread=%s dpy=%p draw=%p", CurrentThreadIdString().c_str(), dpy, draw);
MGLOG_E_ONCE("eglSwapBuffers failed on thread=%s dpy=%p draw=%p", CurrentThreadIdString().c_str(), dpy, draw);
state->SetError(EGL_BAD_SURFACE);
return EGL_FALSE;
}
@@ -211,7 +211,7 @@ namespace MobileGL::MG_Impl::EGLImpl {
auto* backendObject = GetBackendObject(state);
if (!backendObject) {
MGLOG_E("activeBackendObject not initialized!");
MGLOG_E_ONCE("activeBackendObject not initialized!");
return EGL_FALSE;
}
if (!backendObject->InitializeEGLDisplay(dpy, major, minor)) {
@@ -265,7 +265,7 @@ namespace MobileGL::MG_Impl::EGLImpl {
if (releaseCurrentRequest) {
if (auto* backendObject = MG_Backend::pActiveBackendObject.get()) {
if (!backendObject->MakeEGLCurrent(dpy, draw, read, ctx)) {
MGLOG_E("eglMakeCurrent release failed in backend thread=%s", threadId.c_str());
MGLOG_E_ONCE("eglMakeCurrent release failed in backend thread=%s", threadId.c_str());
state->MakeCurrent(oldDisplay, oldDraw, oldRead, oldContext);
state->SetError(EGL_BAD_ACCESS);
return EGL_FALSE;
@@ -277,12 +277,12 @@ namespace MobileGL::MG_Impl::EGLImpl {
auto* backendObject = GetBackendObject(state);
if (!backendObject) {
MGLOG_E("activeBackendObject not initialized!");
MGLOG_E_ONCE("activeBackendObject not initialized!");
state->MakeCurrent(oldDisplay, oldDraw, oldRead, oldContext);
return EGL_FALSE;
}
if (!backendObject->MakeEGLCurrent(dpy, draw, read, ctx)) {
MGLOG_E("eglMakeCurrent backend attach failed thread=%s dpy=%p draw=%p read=%p ctx=%p", threadId.c_str(),
MGLOG_E_ONCE("eglMakeCurrent backend attach failed thread=%s dpy=%p draw=%p read=%p ctx=%p", threadId.c_str(),
dpy, draw, read, ctx);
state->SetError(EGL_BAD_ACCESS);
state->MakeCurrent(oldDisplay, oldDraw, oldRead, oldContext);
@@ -703,7 +703,7 @@ namespace MobileGL::MG_Impl::EGLImpl {
auto* backendObject = GetBackendObject(state);
if (!backendObject) {
MGLOG_E("activeBackendObject not initialized!");
MGLOG_E_ONCE("activeBackendObject not initialized!");
state->DestroySurface(dpy, surface);
return EGL_NO_SURFACE;
}
@@ -726,7 +726,7 @@ namespace MobileGL::MG_Impl::EGLImpl {
}
auto* backendObject = GetBackendObject(state);
if (!backendObject) {
MGLOG_E("activeBackendObject not initialized!");
MGLOG_E_ONCE("activeBackendObject not initialized!");
return EGL_FALSE;
}
width = std::max<EGLint>(width, 1);
@@ -764,7 +764,7 @@ namespace MobileGL::MG_Impl::EGLImpl {
MGLOG_D("eglGetProcAddress(%s)", name);
void* proc = MG_Impl::GetProcAddress(name);
if (!proc) {
MGLOG_W("Failed to get function: %s", name);
MGLOG_D("Failed to get function: %s", name);
return nullptr;
}
return (__eglMustCastToProperFunctionPointerType)proc;
+1 -1
View File
@@ -149,7 +149,7 @@ namespace MobileGL::MG_Impl::GLImpl {
// quietly writing a differently-sized pattern.
const SizeT sourceSize = MG_Util::GetInputBytesPerPixel(inputFormat, pixelType);
if (sourceSize != elementSize) {
MGLOG_W("%s: clear pattern is %zu bytes but internalformat 0x%X stores %zu; "
MGLOG_W_ONCE("%s: clear pattern is %zu bytes but internalformat 0x%X stores %zu; "
"converting between them is not implemented",
GetBufferOpName(op), sourceSize, internalformat, elementSize);
}
+33 -5
View File
@@ -14,8 +14,8 @@
#include "../Getter/GL_Getter.h"
namespace MobileGL::MG_Impl::GLImpl {
static Bool ValidateCurrentProgramForExecution(const char* functionName) {
const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw();
static Bool ValidateProgramForExecution(const SharedPtr<MG_State::GLState::ProgramObject>& currentProgram,
const char* functionName) {
if (!currentProgram) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
@@ -34,10 +34,17 @@ namespace MobileGL::MG_Impl::GLImpl {
return true;
}
static Bool ValidateCurrentProgramForExecution(const char* functionName) {
return ValidateProgramForExecution(MG_State::pGLContext->GetProgramForDraw(), functionName);
}
// A dispatch resolves its program through the DISPATCH accessor: with a pipeline bound
// that is the pipeline's compute stage program, not the graphics composite a draw would
// build - which no longer contains a compute stage to find at all.
static Bool ValidateCurrentProgramForCompute(const char* functionName) {
if (!ValidateCurrentProgramForExecution(functionName)) return false;
const auto& currentProgram = MG_State::pGLContext->GetProgramForDispatch();
if (!ValidateProgramForExecution(currentProgram, functionName)) return false;
const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw();
if (currentProgram->GetShaderIndexByStage(ShaderStage::Compute) < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
@@ -663,6 +670,17 @@ namespace MobileGL::MG_Impl::GLImpl {
5 * sizeof(Uint32), __func__)) {
return;
}
// The only two draw entry points that were missing this. Every backend draw path
// dereferences GetProgramForDraw() unconditionally, so "no current program" has to be
// stopped here or it is a null dereference rather than the INVALID_OPERATION the spec
// asks for - reachable through a bound pipeline that supplies no graphics stage.
//
// AFTER the argument checks, unlike the sibling draw entry points, and deliberately:
// the argument rules here are properties of the call rather than of GL state, and
// NegativeApiErrorsTest.IndirectParameterDrawsCheckBothBuffers pins the INVALID_VALUE
// they produce for a call made with no program bound. Same precedence decision, and
// the same reason, as DispatchComputeIndirect above.
if (!ValidateCurrentProgramForExecution(__func__)) return;
auto multiDrawElementsIndirectCount = MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsIndirectCount;
if (!multiDrawElementsIndirectCount) {
MG_State::pGLContext->RecordError(
@@ -682,6 +700,8 @@ namespace MobileGL::MG_Impl::GLImpl {
4 * sizeof(Uint32), __func__)) {
return;
}
// See MultiDrawElementsIndirectCount, including why this one goes last.
if (!ValidateCurrentProgramForExecution(__func__)) return;
auto multiDrawArraysIndirectCount = MG_Backend::gBackendFunctionsTable.GL.MultiDrawArraysIndirectCount;
if (!multiDrawArraysIndirectCount) {
MG_State::pGLContext->RecordError(
@@ -1239,7 +1259,15 @@ namespace MobileGL::MG_Impl::GLImpl {
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", functionName, "instancecount must be non-negative."));
return;
}
if (!MG_State::pGLContext->ValidateTransformFeedbackName(id)) {
// "id is not the name of a transform feedback object" has to mean the same thing here
// as it does to glIsTransformFeedback, and the two predicates are not interchangeable:
// a name glGenTransformFeedbacks handed out is only reserved until it is first bound,
// and only the bind turns it into an object (GL 4.6 core 13.2.1). ValidateTransformFeedbackName
// answers the reservation question - the right one for glBindTransformFeedback, which is
// what turns a reserved name into an object - so using it here let a generated-but-unbound
// name through to the completed-span check below and raised INVALID_OPERATION where the
// spec asks for INVALID_VALUE. Name 0 is the default object and always drawable.
if (id != 0 && !MG_State::pGLContext->IsTransformFeedbackObject(id)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", functionName,
@@ -25,12 +25,12 @@
#define DECLARE_GL_FUNCTION_STUB_HEAD(type, name, ...) MOBILEGL_GL_API type gl##name(__VA_ARGS__) {
#define DECLARE_GL_FUNCTION_STUB_END(type, name, ...) \
MGLOG_W("Stub function: %s(...)", __FUNCTION__); \
MGLOG_W_ONCE("Stub function: %s(...)", __FUNCTION__); \
return (type)1; \
}
#define DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(type, name, ...) \
MGLOG_W("Stub function: %s(...)", __FUNCTION__); \
MGLOG_W_ONCE("Stub function: %s(...)", __FUNCTION__); \
}
#define DECLARE_GL_FUNCTION_HEAD(type, name, ...) MOBILEGL_GL_API type gl##name(__VA_ARGS__) {
@@ -1003,9 +1003,9 @@ DECLARE_GL_FUNCTION_HEAD(void, ClearTexImage, GLuint texture, GLint level, GLenu
DECLARE_GL_FUNCTION_HEAD(void, ClearTexSubImage, GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width, GLsizei height, GLsizei depth, GLenum format, GLenum type, const void* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, ClearTexSubImage, texture, level, xoffset, yoffset, zoffset, width, height, depth, format, type, data)
DECLARE_GL_FUNCTION_HEAD(void, BindBuffersBase, GLenum target, GLuint first, GLsizei count, const GLuint* buffers) DECLARE_GL_FUNCTION_END_NO_RETURN(void, BindBuffersBase, target, first, count, buffers)
DECLARE_GL_FUNCTION_HEAD(void, BindBuffersRange, GLenum target, GLuint first, GLsizei count, const GLuint* buffers, const GLintptr* offsets, const GLsizeiptr* sizes) DECLARE_GL_FUNCTION_END_NO_RETURN(void, BindBuffersRange, target, first, count, buffers, offsets, sizes)
DECLARE_GL_FUNCTION_STUB_HEAD(void, BindTextures, GLuint first, GLsizei count, const GLuint* textures) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, BindTextures, first, count, textures)
DECLARE_GL_FUNCTION_HEAD(void, BindTextures, GLuint first, GLsizei count, const GLuint* textures) DECLARE_GL_FUNCTION_END_NO_RETURN(void, BindTextures, first, count, textures)
DECLARE_GL_FUNCTION_HEAD(void, BindSamplers, GLuint first, GLsizei count, const GLuint* samplers) DECLARE_GL_FUNCTION_END_NO_RETURN(void, BindSamplers, first, count, samplers)
DECLARE_GL_FUNCTION_STUB_HEAD(void, BindImageTextures, GLuint first, GLsizei count, const GLuint* textures) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, BindImageTextures, first, count, textures)
DECLARE_GL_FUNCTION_HEAD(void, BindImageTextures, GLuint first, GLsizei count, const GLuint* textures) DECLARE_GL_FUNCTION_END_NO_RETURN(void, BindImageTextures, first, count, textures)
DECLARE_GL_FUNCTION_HEAD(void, BindVertexBuffers, GLuint first, GLsizei count, const GLuint* buffers, const GLintptr* offsets, const GLsizei* strides) DECLARE_GL_FUNCTION_END_NO_RETURN(void, BindVertexBuffers, first, count, buffers, offsets, strides)
DECLARE_GL_FUNCTION_STUB_HEAD(void, ClipControl, GLenum origin, GLenum depth) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ClipControl, origin, depth)
DECLARE_GL_FUNCTION_HEAD(void, CreateTransformFeedbacks, GLsizei n, GLuint* ids) DECLARE_GL_FUNCTION_END_NO_RETURN(void, CreateTransformFeedbacks, n, ids)
@@ -1061,7 +1061,7 @@ DECLARE_GL_FUNCTION_HEAD(void, TextureSubImage1D, GLuint texture, GLint level, G
DECLARE_GL_FUNCTION_HEAD(void, TextureSubImage2D, GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLsizei width, GLsizei height, GLenum format, GLenum type, const void* pixels) DECLARE_GL_FUNCTION_END_NO_RETURN(void, TextureSubImage2D, texture, level, xoffset, yoffset, width, height, format, type, pixels)
DECLARE_GL_FUNCTION_HEAD(void, TextureSubImage3D, GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width, GLsizei height, GLsizei depth, GLenum format, GLenum type, const void* pixels) DECLARE_GL_FUNCTION_END_NO_RETURN(void, TextureSubImage3D, texture, level, xoffset, yoffset, zoffset, width, height, depth, format, type, pixels)
DECLARE_GL_FUNCTION_STUB_HEAD(void, CompressedTextureSubImage1D, GLuint texture, GLint level, GLint xoffset, GLsizei width, GLenum format, GLsizei imageSize, const void* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, CompressedTextureSubImage1D, texture, level, xoffset, width, format, imageSize, data)
DECLARE_GL_FUNCTION_STUB_HEAD(void, CompressedTextureSubImage2D, GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLsizei width, GLsizei height, GLenum format, GLsizei imageSize, const void* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, CompressedTextureSubImage2D, texture, level, xoffset, yoffset, width, height, format, imageSize, data)
DECLARE_GL_FUNCTION_HEAD(void, CompressedTextureSubImage2D, GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLsizei width, GLsizei height, GLenum format, GLsizei imageSize, const void* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, CompressedTextureSubImage2D, texture, level, xoffset, yoffset, width, height, format, imageSize, data)
DECLARE_GL_FUNCTION_STUB_HEAD(void, CompressedTextureSubImage3D, GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width, GLsizei height, GLsizei depth, GLenum format, GLsizei imageSize, const void* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, CompressedTextureSubImage3D, texture, level, xoffset, yoffset, zoffset, width, height, depth, format, imageSize, data)
DECLARE_GL_FUNCTION_HEAD(void, CopyTextureSubImage1D, GLuint texture, GLint level, GLint xoffset, GLint x, GLint y, GLsizei width) DECLARE_GL_FUNCTION_END_NO_RETURN(void, CopyTextureSubImage1D, texture, level, xoffset, x, y, width)
DECLARE_GL_FUNCTION_HEAD(void, CopyTextureSubImage2D, GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLint x, GLint y, GLsizei width, GLsizei height) DECLARE_GL_FUNCTION_END_NO_RETURN(void, CopyTextureSubImage2D, texture, level, xoffset, yoffset, x, y, width, height)
@@ -1849,7 +1849,7 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, CompressedTextureImage3DEXT, GLuint texture,
DECLARE_GL_FUNCTION_STUB_HEAD(void, CompressedTextureImage2DEXT, GLuint texture, GLenum target, GLint level, GLenum internalformat, GLsizei width, GLsizei height, GLint border, GLsizei imageSize, const void* bits) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, CompressedTextureImage2DEXT, texture, target, level, internalformat, width, height, border, imageSize, bits)
DECLARE_GL_FUNCTION_STUB_HEAD(void, CompressedTextureImage1DEXT, GLuint texture, GLenum target, GLint level, GLenum internalformat, GLsizei width, GLint border, GLsizei imageSize, const void* bits) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, CompressedTextureImage1DEXT, texture, target, level, internalformat, width, border, imageSize, bits)
DECLARE_GL_FUNCTION_STUB_HEAD(void, CompressedTextureSubImage3DEXT, GLuint texture, GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width, GLsizei height, GLsizei depth, GLenum format, GLsizei imageSize, const void* bits) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, CompressedTextureSubImage3DEXT, texture, target, level, xoffset, yoffset, zoffset, width, height, depth, format, imageSize, bits)
DECLARE_GL_FUNCTION_STUB_HEAD(void, CompressedTextureSubImage2DEXT, GLuint texture, GLenum target, GLint level, GLint xoffset, GLint yoffset, GLsizei width, GLsizei height, GLenum format, GLsizei imageSize, const void* bits) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, CompressedTextureSubImage2DEXT, texture, target, level, xoffset, yoffset, width, height, format, imageSize, bits)
DECLARE_GL_FUNCTION_HEAD(void, CompressedTextureSubImage2DEXT, GLuint texture, GLenum target, GLint level, GLint xoffset, GLint yoffset, GLsizei width, GLsizei height, GLenum format, GLsizei imageSize, const void* bits) DECLARE_GL_FUNCTION_END_NO_RETURN(void, CompressedTextureSubImage2D, texture, level, xoffset, yoffset, width, height, format, imageSize, bits)
DECLARE_GL_FUNCTION_STUB_HEAD(void, CompressedTextureSubImage1DEXT, GLuint texture, GLenum target, GLint level, GLint xoffset, GLsizei width, GLenum format, GLsizei imageSize, const void* bits) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, CompressedTextureSubImage1DEXT, texture, target, level, xoffset, width, format, imageSize, bits)
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetCompressedTextureImageEXT, GLuint texture, GLenum target, GLint lod, void* img) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetCompressedTextureImageEXT, texture, target, lod, img)
DECLARE_GL_FUNCTION_STUB_HEAD(void, CompressedMultiTexImage3DEXT, GLenum texunit, GLenum target, GLint level, GLenum internalformat, GLsizei width, GLsizei height, GLsizei depth, GLint border, GLsizei imageSize, const void* bits) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, CompressedMultiTexImage3DEXT, texunit, target, level, internalformat, width, height, depth, border, imageSize, bits)
@@ -2585,7 +2585,7 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, BindTransformFeedbackNV, GLenum target, GLui
DECLARE_GL_FUNCTION_STUB_HEAD(void, DeleteTransformFeedbacksNV, GLsizei n, const GLuint* ids) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DeleteTransformFeedbacksNV, n, ids)
DECLARE_GL_FUNCTION_STUB_HEAD(void, GenTransformFeedbacksNV, GLsizei n, GLuint* ids) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GenTransformFeedbacksNV, n, ids)
MOBILEGL_GL_API GLboolean glIsTransformFeedbackNV(GLuint id) {
MGLOG_W("Stub function: %s(...)", __FUNCTION__);
MGLOG_W_ONCE("Stub function: %s(...)", __FUNCTION__);
return GL_FALSE;
}
DECLARE_GL_FUNCTION_STUB_HEAD(void, PauseTransformFeedbackNV, void) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, PauseTransformFeedbackNV, )
@@ -3181,5 +3181,5 @@ MOBILEGL_GL_API void glVertexAttribDivisorARB(GLuint index, GLuint divisor) {
}
MOBILEGL_GL_API void glWindowRectanglesEXT(GLenum mode, GLsizei count, const GLint* box) {
MGLOG_W("Stub function: %s(...)", __FUNCTION__);
MGLOG_W_ONCE("Stub function: %s(...)", __FUNCTION__);
}
@@ -547,7 +547,7 @@ namespace MobileGL::MG_Impl::GLImpl {
GLint dstX1, GLint dstY1, GLbitfield mask, GLenum filter) {
auto blitNamedFramebuffer = MG_Backend::gBackendFunctionsTable.GL.BlitNamedFramebuffer;
if (!blitNamedFramebuffer) {
MGLOG_E("glBlitNamedFramebuffer skipped: backend does not implement explicit framebuffer blit.");
MGLOG_E_ONCE("glBlitNamedFramebuffer skipped: backend does not implement explicit framebuffer blit.");
return;
}
blitNamedFramebuffer(readFramebuffer, drawFramebuffer, srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1,
@@ -558,7 +558,7 @@ namespace MobileGL::MG_Impl::GLImpl {
GLenum buffer, GLint drawbuffer, const GLfloat* value) {
auto clearNamedFramebufferfv = MG_Backend::gBackendFunctionsTable.GL.ClearNamedFramebufferfv;
if (!clearNamedFramebufferfv) {
MGLOG_E("glClearNamedFramebufferfv skipped: backend does not implement explicit framebuffer clear.");
MGLOG_E_ONCE("glClearNamedFramebufferfv skipped: backend does not implement explicit framebuffer clear.");
return;
}
clearNamedFramebufferfv(framebuffer, buffer, drawbuffer, value);
@@ -568,7 +568,7 @@ namespace MobileGL::MG_Impl::GLImpl {
GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil) {
auto clearNamedFramebufferfi = MG_Backend::gBackendFunctionsTable.GL.ClearNamedFramebufferfi;
if (!clearNamedFramebufferfi) {
MGLOG_E("glClearNamedFramebufferfi skipped: backend does not implement explicit framebuffer clear.");
MGLOG_E_ONCE("glClearNamedFramebufferfi skipped: backend does not implement explicit framebuffer clear.");
return;
}
clearNamedFramebufferfi(framebuffer, buffer, drawbuffer, depth, stencil);
@@ -578,7 +578,7 @@ namespace MobileGL::MG_Impl::GLImpl {
GLenum buffer, GLint drawbuffer, const GLint* value) {
auto clearNamedFramebufferiv = MG_Backend::gBackendFunctionsTable.GL.ClearNamedFramebufferiv;
if (!clearNamedFramebufferiv) {
MGLOG_E("glClearNamedFramebufferiv skipped: backend does not implement explicit framebuffer clear.");
MGLOG_E_ONCE("glClearNamedFramebufferiv skipped: backend does not implement explicit framebuffer clear.");
return;
}
clearNamedFramebufferiv(framebuffer, buffer, drawbuffer, value);
@@ -588,7 +588,7 @@ namespace MobileGL::MG_Impl::GLImpl {
GLenum buffer, GLint drawbuffer, const GLuint* value) {
auto clearNamedFramebufferuiv = MG_Backend::gBackendFunctionsTable.GL.ClearNamedFramebufferuiv;
if (!clearNamedFramebufferuiv) {
MGLOG_E("glClearNamedFramebufferuiv skipped: backend does not implement explicit framebuffer clear.");
MGLOG_E_ONCE("glClearNamedFramebufferuiv skipped: backend does not implement explicit framebuffer clear.");
return;
}
clearNamedFramebufferuiv(framebuffer, buffer, drawbuffer, value);
@@ -620,6 +620,34 @@ namespace MobileGL::MG_Impl::GLImpl {
return std::max(MG_Backend::pActiveBackendObject->GetDynamicParameters().MaxSamples, 1);
}
// GL_MAX_SAMPLES is the ceiling over all formats; an integer format has its own, lower
// one (GL_MAX_INTEGER_SAMPLES) and GL 4.6 core 9.2.4 makes exceeding it INVALID_OPERATION.
// The multisample TEXTURE path already resolves the limit per format
// (GL_Texture.cpp, GetMaxTextureSamplesForFormat); renderbuffers only ever compared
// against GL_MAX_SAMPLES, so on a driver where the two differ - Adreno reports
// GL_MAX_SAMPLES 4 and GL_MAX_INTEGER_SAMPLES 1 - an integer renderbuffer accepted a
// sample count the format cannot deliver, and said GL_NO_ERROR about it.
Int GetMaxRenderbufferSamplesForFormat_State(TextureInternalFormat format) {
if (MG_Backend::pActiveBackendObject == nullptr) {
return std::numeric_limits<Int>::max();
}
const auto& dynamicParameters = MG_Backend::pActiveBackendObject->GetDynamicParameters();
GLenum normalizedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(format);
GLenum normalizedFormat = GL_RGBA;
GLenum normalizedType = GL_UNSIGNED_BYTE;
MG_Util::TextureFormatProcessor::NormalizePixelFormat(normalizedInternalFormat,
PixelFormatNormalizeOptionBit::None,
&normalizedInternalFormat, &normalizedFormat,
&normalizedType);
const Bool isIntegerFormat = normalizedFormat == GL_RED_INTEGER || normalizedFormat == GL_RG_INTEGER ||
normalizedFormat == GL_RGB_INTEGER || normalizedFormat == GL_RGBA_INTEGER;
if (!isIntegerFormat) {
return GetMaxRenderbufferSamples_State();
}
return std::max(dynamicParameters.MaxIntegerSamples, 1);
}
Bool ValidateRenderbufferStorageSize_State(GLsizei width, GLsizei height, const char* caller) {
if (width < 0 || height < 0) {
MG_State::pGLContext->RecordError(
@@ -641,7 +669,7 @@ namespace MobileGL::MG_Impl::GLImpl {
return true;
}
Bool ValidateRenderbufferStorageSamples_State(GLsizei samples, const char* caller) {
Bool ValidateRenderbufferStorageSamples_State(GLsizei samples, TextureInternalFormat format, const char* caller) {
if (samples < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
@@ -649,9 +677,10 @@ namespace MobileGL::MG_Impl::GLImpl {
return false;
}
const Int maxSamples = GetMaxRenderbufferSamples_State();
// TODO: Resolve the remaining per-internalformat renderbuffer sample limits once
// glGetInternalformativ is backed; integer formats are handled below.
const Int maxSamples = GetMaxRenderbufferSamplesForFormat_State(format);
if (samples > maxSamples) {
// TODO: Use per-internalformat renderbuffer sample limits once glGetInternalformativ is backed.
// GL 4.6 core 9.2.4 makes asking for more samples than the format supports
// INVALID_OPERATION, not INVALID_VALUE - the count is well formed, this format just
// cannot deliver it. Only a negative count is INVALID_VALUE.
@@ -659,7 +688,7 @@ namespace MobileGL::MG_Impl::GLImpl {
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", caller,
std::format("Sample count {} exceeds GL_MAX_SAMPLES ({}).", samples, maxSamples)));
std::format("Sample count {} exceeds this format's sample limit ({}).", samples, maxSamples)));
return false;
}
return true;
@@ -684,7 +713,7 @@ namespace MobileGL::MG_Impl::GLImpl {
TextureInternalFormat format = MG_Util::ConvertGLEnumToTextureInternalFormat(internalformat);
if (!TextureImpl::ValidateTextureInternalFormat(format)) return;
if (!ValidateRenderbufferStorageSamples_State(samples, kCaller)) return;
if (!ValidateRenderbufferStorageSamples_State(samples, format, kCaller)) return;
if (!ValidateRenderbufferStorageSize_State(width, height, kCaller)) return;
renderbufferObject->AllocateStorage({width, height});
@@ -931,7 +960,8 @@ namespace MobileGL::MG_Impl::GLImpl {
TextureInternalFormat format = MG_Util::ConvertGLEnumToTextureInternalFormat(internalformat);
if (!TextureImpl::ValidateTextureInternalFormat(format)) return;
if (!ValidateRenderbufferStorageSamples_State(samples, "NamedRenderbufferStorageMultisample_State")) return;
if (!ValidateRenderbufferStorageSamples_State(samples, format, "NamedRenderbufferStorageMultisample_State"))
return;
if (!ValidateRenderbufferStorageSize_State(width, height, "NamedRenderbufferStorageMultisample_State")) return;
renderbufferObject->AllocateStorage({width, height});
+79 -23
View File
@@ -51,6 +51,15 @@ namespace MobileGL::MG_Impl::GLImpl {
constexpr GLint kFrontendMaxTessControlAtomicCounters = 0;
constexpr GLint kFrontendMaxTessEvaluationAtomicCounters = 0;
constexpr GLint kFrontendMaxVertexAtomicCounters = 0;
// Zero counters means zero buffers to hold them. These have to be ANSWERED rather than
// left to the default INVALID_ENUM: a well-behaved application queries the limit exactly
// to find out that the stage cannot do this, and an error instead both leaves its output
// untouched (so it reads uninitialised memory and may conclude the opposite) and leaves a
// GL error pending that surfaces at whatever unrelated call checks next.
constexpr GLint kFrontendMaxGeometryAtomicCounterBuffers = 0;
constexpr GLint kFrontendMaxTessControlAtomicCounterBuffers = 0;
constexpr GLint kFrontendMaxTessEvaluationAtomicCounterBuffers = 0;
constexpr GLint kFrontendMaxVertexAtomicCounterBuffers = 0;
// One atomic counter is a uint, and a buffer never has to hold more counters than the
// combined limit the frontend advertises. GL 4.6 table 23.63 floors this at 32 bytes.
constexpr GLint kFrontendMaxAtomicCounterBufferSize =
@@ -174,6 +183,30 @@ namespace MobileGL::MG_Impl::GLImpl {
return frontendCount;
}
// A per-stage or combined BLOCK count is an amount of indexed binding points an
// application will occupy, and GL 4.6 table 23.64 orders the two accordingly:
// MAX_UNIFORM_BUFFER_BINDINGS >= MAX_COMBINED_UNIFORM_BLOCKS >= every per-stage count,
// and the same for the shader-storage family. The two families are answered from
// unrelated places here - frontend constants, backend dynamic parameters, and a few
// hard-coded TODOs - so nothing kept them ordered, and a backend that reports Vulkan
// descriptor-indexing counts advertised 256 compute uniform blocks over 36 binding
// points. KHR-GL44.multi_bind.dispatch_bind_buffers_base reads the block count and binds
// that many buffers in ONE glBindBuffersBase, which is then INVALID_OPERATION before it
// binds anything. Clamping is the only direction available: the binding count is the
// capacity of the state layer's indexed-binding array, not a number we may inflate.
GLint ClampBlockCountToBindingPoints(GLint blockCount, BufferTarget bufferTarget) {
const GLint bindingPoints = static_cast<GLint>(GetIndexedBufferQueryPointCount(bufferTarget));
return std::min(std::max(blockCount, 0), bindingPoints);
}
GLint ClampUniformBlockCount(GLint blockCount) {
return ClampBlockCountToBindingPoints(blockCount, BufferTarget::Uniform);
}
GLint ClampStorageBlockCount(GLint blockCount) {
return ClampBlockCountToBindingPoints(blockCount, BufferTarget::ShaderStorage);
}
bool TryDecodeDrawBufferQuery(GLenum pname, SizeT& drawBufferIndex) {
if (pname == GL_DRAW_BUFFER) {
drawBufferIndex = 0;
@@ -350,7 +383,7 @@ namespace MobileGL::MG_Impl::GLImpl {
MGLOG_D("glGetString, name: %s", MG_Util::ConvertGLEnumToString(name).c_str());
if (!activeBackendObject) {
MGLOG_E("activeBackendObject is not initialized!");
MGLOG_E_ONCE("activeBackendObject is not initialized!");
return (GLubyte*)"Unknown";
}
@@ -409,7 +442,7 @@ namespace MobileGL::MG_Impl::GLImpl {
const auto& activeBackendObject = MG_Backend::pActiveBackendObject;
if (!activeBackendObject) {
MGLOG_E("activeBackendObject is not initialized!");
MGLOG_E_ONCE("activeBackendObject is not initialized!");
return (GLubyte*)"Unknown";
}
const auto& rendererInfo = activeBackendObject->GetRendererInfo();
@@ -722,10 +755,14 @@ namespace MobileGL::MG_Impl::GLImpl {
*data = 0;
return;
}
// GL 4.6 core table 23.4/23.5: *_BUFFER_SIZE reports the size glBindBufferRange
// was ASKED for, verbatim. It is not clamped to the buffer's storage, and it does
// not follow the buffer when a later glBufferData resizes it - a range may legally
// name bytes the buffer does not have yet. Clamping it here answered 0 for the
// common conformance shape of binding a range on a buffer that has no storage
// yet (KHR-GL43.shader_storage_buffer_object.basic-binding).
const Range1D range = bindingPoint.GetRange();
const auto start = std::min(range.start, bufferObject->GetSize());
const auto end = std::min(range.end, bufferObject->GetSize());
*data = static_cast<GLint>(end - start);
*data = static_cast<GLint>(range.end - range.start);
return;
}
default:
@@ -951,9 +988,8 @@ namespace MobileGL::MG_Impl::GLImpl {
*data = 0;
return;
}
const auto start = std::min(range.start, bufferObject->GetSize());
const auto end = std::min(range.end, bufferObject->GetSize());
*data = static_cast<GLint64>(end - start);
// Verbatim, unclamped - see the GetIntegeri_v arm.
*data = static_cast<GLint64>(range.end - range.start);
return;
}
default:
@@ -1394,7 +1430,7 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = kFrontendMaxCombinedAtomicCounters;
return;
case GL_MAX_COMBINED_UNIFORM_BLOCKS:
*params = kFrontendMaxCombinedUniformBlocks;
*params = ClampUniformBlockCount(kFrontendMaxCombinedUniformBlocks);
return;
case GL_MAX_DUAL_SOURCE_DRAW_BUFFERS:
*params = 1; // TODO
@@ -1409,7 +1445,7 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = kFrontendMaxFragmentAtomicCounters;
return;
case GL_MAX_FRAGMENT_SHADER_STORAGE_BLOCKS:
*params = 16; // TODO
*params = ClampStorageBlockCount(16); // TODO
return;
case GL_MAX_FRAGMENT_INPUT_COMPONENTS:
*params = kFrontendMaxFragmentInputComponents;
@@ -1426,13 +1462,16 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = kFrontendMaxFragmentUniformVectors;
return;
case GL_MAX_FRAGMENT_UNIFORM_BLOCKS:
*params = kFrontendMaxFragmentUniformBlocks;
*params = ClampUniformBlockCount(kFrontendMaxFragmentUniformBlocks);
return;
case GL_MAX_GEOMETRY_ATOMIC_COUNTERS:
*params = kFrontendMaxGeometryAtomicCounters;
return;
case GL_MAX_GEOMETRY_ATOMIC_COUNTER_BUFFERS:
*params = kFrontendMaxGeometryAtomicCounterBuffers;
return;
case GL_MAX_GEOMETRY_SHADER_STORAGE_BLOCKS:
*params = 16; // TODO
*params = ClampStorageBlockCount(16); // TODO
return;
case GL_MAX_GEOMETRY_INPUT_COMPONENTS:
*params = kFrontendMaxGeometryInputComponents;
@@ -1455,7 +1494,7 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = kFrontendMaxGeometryTotalOutputComponents;
return;
case GL_MAX_GEOMETRY_UNIFORM_BLOCKS:
*params = kFrontendMaxGeometryUniformBlocks;
*params = ClampUniformBlockCount(kFrontendMaxGeometryUniformBlocks);
return;
case GL_MAX_GEOMETRY_UNIFORM_COMPONENTS:
*params = kFrontendMaxGeometryUniformComponents;
@@ -1487,9 +1526,15 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_MAX_TESS_CONTROL_ATOMIC_COUNTERS:
*params = kFrontendMaxTessControlAtomicCounters;
return;
case GL_MAX_TESS_CONTROL_ATOMIC_COUNTER_BUFFERS:
*params = kFrontendMaxTessControlAtomicCounterBuffers;
return;
case GL_MAX_TESS_EVALUATION_ATOMIC_COUNTERS:
*params = kFrontendMaxTessEvaluationAtomicCounters;
return;
case GL_MAX_TESS_EVALUATION_ATOMIC_COUNTER_BUFFERS:
*params = kFrontendMaxTessEvaluationAtomicCounterBuffers;
return;
case GL_MAX_TESS_CONTROL_IMAGE_UNIFORMS:
*params = 0;
return;
@@ -1497,10 +1542,10 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = 0;
return;
case GL_MAX_TESS_CONTROL_SHADER_STORAGE_BLOCKS:
*params = 16; // TODO
*params = ClampStorageBlockCount(16); // TODO
return;
case GL_MAX_TESS_EVALUATION_SHADER_STORAGE_BLOCKS:
*params = 16; // TODO
*params = ClampStorageBlockCount(16); // TODO
return;
case GL_MAX_TEXTURE_LOD_BIAS:
*params = 15; // TODO
@@ -1517,13 +1562,16 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_MAX_VERTEX_ATOMIC_COUNTERS:
*params = kFrontendMaxVertexAtomicCounters;
return;
case GL_MAX_VERTEX_ATOMIC_COUNTER_BUFFERS:
*params = kFrontendMaxVertexAtomicCounterBuffers;
return;
case GL_MAX_VERTEX_IMAGE_UNIFORMS:
*params = MG_Backend::pActiveBackendObject
? MG_Backend::pActiveBackendObject->GetDynamicParameters().MaxVertexImageUniforms
: MG_Backend::DynamicBackendParameters{}.MaxVertexImageUniforms;
return;
case GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS:
*params = 16; // TODO
*params = ClampStorageBlockCount(16); // TODO
return;
case GL_MAX_VERTEX_UNIFORM_COMPONENTS:
*params = kFrontendMaxVertexUniformComponents;
@@ -1535,7 +1583,7 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = kFrontendMaxVertexOutputComponents;
return;
case GL_MAX_VERTEX_UNIFORM_BLOCKS:
*params = kFrontendMaxVertexUniformBlocks;
*params = ClampUniformBlockCount(kFrontendMaxVertexUniformBlocks);
return;
case GL_NUM_COMPRESSED_TEXTURE_FORMATS:
*params = 0; // compressed texture upload entrypoints are still unimplemented
@@ -1906,7 +1954,7 @@ namespace MobileGL::MG_Impl::GLImpl {
const auto& activeBackendObject = MG_Backend::pActiveBackendObject;
if (!activeBackendObject) {
MGLOG_E("activeBackendObject is not initialized!");
MGLOG_E_ONCE("activeBackendObject is not initialized!");
return;
}
const auto& rendererInfo = activeBackendObject->GetRendererInfo();
@@ -1935,13 +1983,13 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = dynamicParameters.SubgroupQuadOperationsInAllStages ? GL_TRUE : GL_FALSE;
break;
case GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS:
*params = dynamicParameters.MaxComputeShaderStorageBlocks;
*params = ClampStorageBlockCount(dynamicParameters.MaxComputeShaderStorageBlocks);
break;
case GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS:
*params = dynamicParameters.MaxCombinedShaderStorageBlocks;
*params = ClampStorageBlockCount(dynamicParameters.MaxCombinedShaderStorageBlocks);
break;
case GL_MAX_COMPUTE_UNIFORM_BLOCKS:
*params = dynamicParameters.MaxComputeUniformBlocks;
*params = ClampUniformBlockCount(dynamicParameters.MaxComputeUniformBlocks);
break;
case GL_MAX_COMPUTE_TEXTURE_IMAGE_UNITS:
*params = dynamicParameters.MaxComputeTextureImageUnits;
@@ -2071,7 +2119,15 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = static_cast<GLint>(GetIndexedBufferQueryPointCount(BufferTarget::AtomicCounter));
break;
case GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE:
*params = kFrontendMaxAtomicCounterBufferSize;
// The conformance suite splits this evenly across every advertised binding point and
// binds all of them in one glBindBuffersRange
// (KHR-GL44.multi_bind.functional_bind_buffers_range), so the pair has to divide:
// 32 bytes over 36 binding points is a zero-sized range, which BindBufferRange
// rejects with INVALID_VALUE before it binds anything. Floor the advertised size at
// one counter per binding point.
*params = std::max<GLint>(
kFrontendMaxAtomicCounterBufferSize,
static_cast<GLint>(GetIndexedBufferQueryPointCount(BufferTarget::AtomicCounter) * sizeof(GLuint)));
break;
case GL_MAX_TEXTURE_BUFFER_SIZE:
*params = dynamicParameters.MaxTextureBufferSize;
@@ -2192,7 +2248,7 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = static_cast<GLint>(std::lround(dynamicParameters.MaxTextureMaxAnisotropy));
break;
default:
MGLOG_E("glGetIntegerv: Invalid enum %s (0x%X)", MG_Util::ConvertGLEnumToString(pname).c_str(), pname);
MGLOG_D("glGetIntegerv: Invalid enum %s (0x%X)", MG_Util::ConvertGLEnumToString(pname).c_str(), pname);
MG_State::pGLContext->RecordError(ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "GetIntegerv",
std::format("Invalid enum: 0x{:X}", pname)));
+113 -68
View File
@@ -850,7 +850,12 @@ namespace MobileGL::MG_Impl::GLImpl {
// vector per column - while the value glGetUniform* must return is tightly packed
// columns * rows floats. Only mat4 is the same either way; every other shape needs the
// padding undone, and the readback has to undo exactly what UniformMatrixfv_Object put
// there. Returns false when `ttype` is not a float matrix (nothing to unpack).
// there. Returns false when there is nothing here to unpack.
//
// A DOUBLE matrix is declined not because it is laid out differently - it is not, the
// demotion makes a dmat4 a mat4 in the shader and a mat4-shaped slot here - but because it
// is ROUTED differently: the caller's component-by-component EbtDouble branch has to widen
// each float back to the queried type, and it undoes the same padding itself.
Bool TryGatherFloatMatrixColumns(const glslang::TType* ttype, const char* pBase, void* params) {
if (ttype == nullptr || !ttype->isMatrix() || ttype->getBasicType() == glslang::EbtDouble) return false;
const Int columns = ttype->getMatrixCols();
@@ -863,12 +868,11 @@ namespace MobileGL::MG_Impl::GLImpl {
}
// Bytes a uniform actually occupies in the global UBO. It is the tight GL type size for
// everything except a float matrix, whose padded columns make it wider.
// everything except a float matrix, whose padded columns make it wider. The rule itself
// lives on ProgramObject, because the pipeline composite's uniform refresh needs the same
// one and two copies of a layout rule is one too many.
SizeT UniformStorageSpanInBytes(const glslang::TType* ttype, SizeT tightSize) {
if (ttype != nullptr && ttype->isMatrix() && ttype->getBasicType() != glslang::EbtDouble) {
return static_cast<SizeT>(ttype->getMatrixCols()) * 4 * sizeof(GLfloat);
}
return tightSize;
return MG_State::GLState::ProgramObject::UniformStorageSpanInBytes(ttype, tightSize);
}
void GetUniform_State(GLuint program, GLint location, void* params) {
@@ -904,13 +908,19 @@ namespace MobileGL::MG_Impl::GLImpl {
const SizeT span = UniformStorageSpanInBytes(ttype, size);
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
offset + span > programObject->GetUBOSize()) {
MGLOG_E("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
MGLOG_E_ONCE("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
program, location);
return;
}
if (!TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) {
Memcpy(params, pUBO + offset, size);
// Never more than the uniform actually occupies. `size` is the GL type size,
// which for a `double` uniform is twice its storage - every 64-bit float is
// narrowed before the module reaches a backend, so the slot holds floats. The
// typed entry points (glGetUniformdv and friends) go through
// GetUniformScalar_State, which converts component by component; this raw
// copy has no type to convert with, so it is bounded rather than converted.
Memcpy(params, pUBO + offset, std::min<SizeT>(size, span));
}
}
// TODO: handle 1i variant as texture unit
@@ -952,7 +962,7 @@ namespace MobileGL::MG_Impl::GLImpl {
const SizeT span = UniformStorageSpanInBytes(ttype, size);
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
offset + span > programObject->GetUBOSize()) {
MGLOG_E("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
MGLOG_E_ONCE("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
program, location);
return;
}
@@ -961,22 +971,27 @@ namespace MobileGL::MG_Impl::GLImpl {
if (TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) return;
}
// A double-precision uniform is the one case where the stored component type can
// differ from the queried one for a non-opaque uniform, and the difference is not
// just a reinterpretation: it is twice as wide, so a raw copy would overrun the
// caller's buffer as well as return nonsense. Read component by component and let
// GL's conversion rules (7.6: round to nearest for the integer queries) apply.
// A double-precision uniform is the one case where the stored component type differs
// from the DECLARED one for a non-opaque uniform: the shader's 64-bit floats are
// narrowed to 32 bits before the module reaches a backend
// (ShaderTranspiler::DemoteFloat64Pass), so what is in the global UBO is a float per
// component, laid out exactly like the float-typed twin of this uniform - std140
// 16-byte column stride for a matrix included. Reading it as a GLdouble would return
// two components reinterpreted as one. Read component by component and let GL's
// conversion rules (7.6: round to nearest for the integer queries) apply; the value
// widens back to the queried type, having lost precision at the glUniform*d that
// stored it and not here.
if (ttype->getBasicType() == glslang::EbtDouble) {
const Int columns = ttype->isMatrix() ? ttype->getMatrixCols() : 1;
const Int rows = ttype->isMatrix() ? ttype->getMatrixRows()
: (ttype->isVector() ? ttype->getVectorSize() : 1);
// The slot the linker handed out is exactly `columns` columns wide, so it also
// states the column stride - which for a double matrix is not a float's 16 bytes.
const SizeT columnStride = columns > 0 ? size / static_cast<SizeT>(columns) : size;
// std140 gives every matrix column its own 16-byte slot; a non-matrix is one
// tightly packed run and never reaches the stride at all.
const SizeT columnStride = 4 * sizeof(GLfloat);
for (Int column = 0; column < columns; ++column) {
for (Int row = 0; row < rows; ++row) {
GLdouble component = 0.0;
Memcpy(&component, pUBO + offset + column * columnStride + row * sizeof(GLdouble),
GLfloat component = 0.0f;
Memcpy(&component, pUBO + offset + column * columnStride + row * sizeof(GLfloat),
sizeof(component));
if constexpr (std::is_integral_v<T>) {
// Rounded to the nearest integer and clamped into the queried type's
@@ -1047,7 +1062,7 @@ namespace MobileGL::MG_Impl::GLImpl {
if (!initialized) {
const auto& activeBackendObject = MG_Backend::pActiveBackendObject;
if (!activeBackendObject) {
MGLOG_E("activeBackendObject is not initialized!");
MGLOG_E_ONCE("activeBackendObject is not initialized!");
return;
}
const auto& rendererInfo = activeBackendObject->GetRendererInfo();
@@ -1120,13 +1135,24 @@ namespace MobileGL::MG_Impl::GLImpl {
if (!programObject.IsUniformOpaqueAtLocation(location)) {
MGLOG_D("%s: program = %d, location = %d, maxLocation = %d", __func__, programObject.GetExternalIndex(),
location, programObject.GetMaxUniformLocation());
// Record the write for the pipeline composite's uniform mirror, which copies only
// the locations a stage program has actually been written to (see
// ProgramObject::MarkUniformWrittenAtLocation). Here rather than further down
// because every exit below is still a write as far as GL is concerned: the
// buffered-write detour returns early, the bytes-equal dedupe returns early, and
// even the no-backing-storage bail is a uniform the application addressed. This is
// the funnel EVERY glUniform* and glProgramUniform* entry point reaches, once per
// LOCATION - so an array element write marks that element and nothing else. On a
// program that can never be a pipeline stage - the monolithic glUseProgram path,
// which is where the thousands of calls per frame are - this is one bool branch.
programObject.MarkUniformWrittenAtLocation(location);
// Everything up to and including the clamp is phase-A data (the uniform's GL type
// decides its size), so it is answered without joining anything.
const SizeT size = programObject.GetUniformSizesInBytes(location);
SizeT writeSize = ItemCount * sizeof(T);
if (size < writeSize) {
// Metadata bug: degrade to a clamped copy instead of killing the process.
MGLOG_E("%s: uniform size mismatch at program %u location %u: expected at least %zu bytes, got %zu "
MGLOG_E_ONCE("%s: uniform size mismatch at program %u location %u: expected at least %zu bytes, got %zu "
"bytes; clamping",
__func__, programObject.GetExternalIndex(), location, ItemCount * sizeof(T), size);
writeSize = size;
@@ -1147,7 +1173,7 @@ namespace MobileGL::MG_Impl::GLImpl {
offset + byteOffsetInsideUniform + writeSize > uboSize) {
// Should not happen: linking gives every settable uniform backing
// storage. Log and drop the write instead of faulting.
MGLOG_E("%s: uniform at program %u location %u has no backing storage (ubo=%p offset=%u size=%zu "
MGLOG_E_ONCE("%s: uniform at program %u location %u has no backing storage (ubo=%p offset=%u size=%zu "
"uboSize=%zu); dropping write",
__func__, programObject.GetExternalIndex(), location, static_cast<void*>(pUBO), offset,
writeSize, uboSize);
@@ -1238,36 +1264,39 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
// glUniform*d / glUniformMatrix*dv. The vector forms need nothing beyond the shared
// upload template - it is already typed on the component - but a matrix does: the
// column stride the linker used for a double matrix is not the 16 bytes a float one
// gets. It is not guessed here; the slot the uniform was given is exactly `columns`
// columns wide, so dividing states the stride the rest of the pipeline agreed on.
template <typename Program>
void UniformMatrixdv_Object(Program& programObject, GLint location, GLsizei count, GLboolean transpose,
const GLdouble* value, Int columns, Int rows) {
const SizeT slotSize = programObject.GetUniformSizesInBytes(location);
const SizeT columnStride = columns > 0 ? slotSize / static_cast<SizeT>(columns) : slotSize;
const SizeT componentCount = static_cast<SizeT>(columns) * static_cast<SizeT>(rows);
Vector<GLdouble> column(static_cast<SizeT>(rows));
for (GLint matrix = 0; matrix < count; ++matrix) {
if (matrix > 0 && !programObject.UniformLocationsAliasSameUniform(location, location + matrix)) break;
if (!programObject.IsValidUniformLocation(location + matrix)) {
RecordInvalidUniformLocationError(__func__, location + matrix, "the current program object");
return;
}
const GLdouble* source = value + matrix * componentCount;
for (Int c = 0; c < columns; ++c) {
for (Int r = 0; r < rows; ++r) {
column[r] = transpose == GL_TRUE ? source[r * columns + c] : source[c * rows + r];
}
Uniform_State<1>(programObject, location + matrix, column.data(), c * columnStride);
for (Int r = 1; r < rows; ++r) {
Uniform_State<1>(programObject, location + matrix, column.data() + r,
c * columnStride + r * sizeof(GLdouble));
}
}
// glUniform*d / glUniformMatrix*dv. Neither needs a layout of its own any more: the
// transpile chain narrows every 64-bit float in the shader to 32 bits
// (ShaderTranspiler::DemoteFloat64Pass) and the global UBO is laid out by reflecting that
// demoted module, so a double uniform's storage IS a float uniform's - same offset, same
// 4-byte components, same std140 column padding for matrices. Narrowing here, at the one
// place the 64-bit value enters, and then handing the bytes to the ordinary float upload
// path is what keeps the two in step; a separate double-shaped layout here would write
// 8-byte components into 4-byte slots and silently address the wrong ones.
//
// The narrowing is the same static_cast the shader's own arithmetic now performs, so the
// value the shader reads is the value glUniform*d was given, at float precision.
template <GLsizei ItemCount>
void UniformvNarrowed_State(GLint location, GLsizei count, const GLdouble* value) {
if (value == nullptr || count <= 0) {
// Same shape as the float entry points: the location validation still runs, and a
// null pointer is left to fault exactly where glUniform*fv would.
Uniformv_State<ItemCount>(location, count, reinterpret_cast<const GLfloat*>(value));
return;
}
Vector<GLfloat> narrowed(static_cast<SizeT>(count) * ItemCount);
for (SizeT i = 0; i < narrowed.size(); ++i) narrowed[i] = static_cast<GLfloat>(value[i]);
Uniformv_State<ItemCount>(location, count, narrowed.data());
}
template <GLsizei ItemCount>
void ProgramUniformvNarrowed_State(GLuint program, GLint location, GLsizei count, const GLdouble* value) {
if (value == nullptr || count <= 0) {
ProgramUniformv_State<ItemCount>(program, location, count, reinterpret_cast<const GLfloat*>(value));
return;
}
Vector<GLfloat> narrowed(static_cast<SizeT>(count) * ItemCount);
for (SizeT i = 0; i < narrowed.size(); ++i) narrowed[i] = static_cast<GLfloat>(value[i]);
ProgramUniformv_State<ItemCount>(program, location, count, narrowed.data());
}
// glUniformMatrix*fv / glProgramUniformMatrix*fv, every shape (square and non-square).
@@ -1316,6 +1345,22 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
// glUniformMatrix*dv / glProgramUniformMatrix*dv. Narrowed to the float form and handed
// straight to it: after DemoteFloat64Pass a `dmat4` uniform is a `mat4` in the shader and a
// mat4-shaped slot in the global UBO, columns padded to a vec4 and all. Everything else
// about the call - transpose handling, the array-element walk, the opaque-uniform refusal -
// is then the one implementation both spellings share.
template <typename Program>
void UniformMatrixdv_Object(Program& programObject, GLint location, GLsizei count, GLboolean transpose,
const GLdouble* value, Int columns, Int rows) {
if (value == nullptr || count <= 0) return;
const SizeT componentCount = static_cast<SizeT>(columns) * static_cast<SizeT>(rows);
Vector<GLfloat> narrowed(static_cast<SizeT>(count) * componentCount);
for (SizeT i = 0; i < narrowed.size(); ++i) narrowed[i] = static_cast<GLfloat>(value[i]);
UniformMatrixfv_Object(programObject, "glUniformMatrixdv", location, count, transpose, narrowed.data(),
columns, rows, "the current program object");
}
// Helper function to transpose a 2x2 matrix
void TransposeMatrix2x2(const GLfloat* input, GLfloat* output) {
// Input matrix is in column-major order (OpenGL default)
@@ -1762,7 +1807,7 @@ namespace MobileGL::MG_Impl::GLImpl {
break;
}
default:
MGLOG_E("%s: unknown pname = %p %s", __func__, pname, MG_Util::ConvertGLEnumToString(pname).c_str());
MGLOG_D("%s: unknown pname = %p %s", __func__, pname, MG_Util::ConvertGLEnumToString(pname).c_str());
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
@@ -2079,71 +2124,71 @@ namespace MobileGL::MG_Impl::GLImpl {
}
void Uniform1d(GLint location, GLdouble v0) {
const GLdouble v[] = {v0};
Uniformv_State<1>(location, 1, v);
UniformvNarrowed_State<1>(location, 1, v);
}
void Uniform1dv(GLint location, GLsizei count, const GLdouble* value) {
Uniformv_State<1>(location, count, value);
UniformvNarrowed_State<1>(location, count, value);
}
void ProgramUniform1d(GLuint program, GLint location, GLdouble v0) {
const GLdouble v[] = {v0};
ProgramUniformv_State<1>(program, location, 1, v);
ProgramUniformvNarrowed_State<1>(program, location, 1, v);
}
void ProgramUniform1dv(GLuint program, GLint location, GLsizei count, const GLdouble* value) {
ProgramUniformv_State<1>(program, location, count, value);
ProgramUniformvNarrowed_State<1>(program, location, count, value);
}
void Uniform2d(GLint location, GLdouble v0, GLdouble v1) {
const GLdouble v[] = {v0, v1};
Uniformv_State<2>(location, 1, v);
UniformvNarrowed_State<2>(location, 1, v);
}
void Uniform2dv(GLint location, GLsizei count, const GLdouble* value) {
Uniformv_State<2>(location, count, value);
UniformvNarrowed_State<2>(location, count, value);
}
void ProgramUniform2d(GLuint program, GLint location, GLdouble v0, GLdouble v1) {
const GLdouble v[] = {v0, v1};
ProgramUniformv_State<2>(program, location, 1, v);
ProgramUniformvNarrowed_State<2>(program, location, 1, v);
}
void ProgramUniform2dv(GLuint program, GLint location, GLsizei count, const GLdouble* value) {
ProgramUniformv_State<2>(program, location, count, value);
ProgramUniformvNarrowed_State<2>(program, location, count, value);
}
void Uniform3d(GLint location, GLdouble v0, GLdouble v1, GLdouble v2) {
const GLdouble v[] = {v0, v1, v2};
Uniformv_State<3>(location, 1, v);
UniformvNarrowed_State<3>(location, 1, v);
}
void Uniform3dv(GLint location, GLsizei count, const GLdouble* value) {
Uniformv_State<3>(location, count, value);
UniformvNarrowed_State<3>(location, count, value);
}
void ProgramUniform3d(GLuint program, GLint location, GLdouble v0, GLdouble v1, GLdouble v2) {
const GLdouble v[] = {v0, v1, v2};
ProgramUniformv_State<3>(program, location, 1, v);
ProgramUniformvNarrowed_State<3>(program, location, 1, v);
}
void ProgramUniform3dv(GLuint program, GLint location, GLsizei count, const GLdouble* value) {
ProgramUniformv_State<3>(program, location, count, value);
ProgramUniformvNarrowed_State<3>(program, location, count, value);
}
void Uniform4d(GLint location, GLdouble v0, GLdouble v1, GLdouble v2, GLdouble v3) {
const GLdouble v[] = {v0, v1, v2, v3};
Uniformv_State<4>(location, 1, v);
UniformvNarrowed_State<4>(location, 1, v);
}
void Uniform4dv(GLint location, GLsizei count, const GLdouble* value) {
Uniformv_State<4>(location, count, value);
UniformvNarrowed_State<4>(location, count, value);
}
void ProgramUniform4d(GLuint program, GLint location, GLdouble v0, GLdouble v1, GLdouble v2, GLdouble v3) {
const GLdouble v[] = {v0, v1, v2, v3};
ProgramUniformv_State<4>(program, location, 1, v);
ProgramUniformvNarrowed_State<4>(program, location, 1, v);
}
void ProgramUniform4dv(GLuint program, GLint location, GLsizei count, const GLdouble* value) {
ProgramUniformv_State<4>(program, location, count, value);
ProgramUniformvNarrowed_State<4>(program, location, count, value);
}
void UniformMatrix2dv(GLint location, GLsizei count, GLboolean transpose, const GLdouble* value) {
if (location == -1) return;
@@ -19,16 +19,26 @@ namespace MobileGL::MG_Impl::GLImpl {
code, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", function, Move(message)));
}
// A pipeline name only names an object once it has been bound or created; querying a
// reserved-but-unmaterialised name is INVALID_OPERATION (GL 4.6 core 7.4).
// GL 4.6 core 7.4 asks only that the name came from GenProgramPipelines and has not been
// deleted - so a name that was reserved and never bound is legal here, and the command
// MATERIALIZES it rather than rejecting it.
//
// Requiring a bound object instead is what broke every separable-program conformance case
// across three families: the CTS reserves a name, calls glUseProgramStages three times and
// only then binds, which is the order the spec's own example uses. Each of those calls
// failed with INVALID_OPERATION, so the stage programs were never recorded - the pipeline
// stayed empty, GetProgramForDraw flattened nothing and the draw painted nothing, and the
// rejected calls' error was left in the queue for the harness to find. One cause, both
// symptoms.
const SharedPtr<MG_State::GLState::ProgramPipelineObject>* TryGetPipeline(GLuint pipeline,
const char* function) {
if (!MG_State::pGLContext->IsProgramPipelineObject(pipeline)) {
const auto& object = MG_State::pGLContext->MaterializeProgramPipelineObject(pipeline);
if (!object) {
RecordPipelineError(ErrorCode::InvalidOperation, function,
std::format("Program pipeline {} does not exist.", pipeline));
return nullptr;
}
return &MG_State::pGLContext->GetProgramPipelineObject(pipeline);
return &object;
}
Bool ValidatePipelineCount(GLsizei n, const char* function) {
@@ -210,11 +210,66 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
// ---- model construction --------------------------------------------------------
// GL_REFERENCED_BY_*_SHADER for an ARRAYED block instance, refined per element.
//
// glslang records a block reference by walking up to the base symbol and calling
// addBlockName with the whole ARRAY type, which ORs the referencing stage into every
// element at once - it has not resolved the subscript yet at that point. So reading
// "e[0].b" marks both TrickyBlock[0] and TrickyBlock[1] as referenced by the fragment
// stage (KHR-GL43.program_interface_query.uniform-block-types).
//
// The MEMBER masks are exact: EShReflectionAllBlockVariables enumerates every member of
// every element with the stage mask suppressed, and only the dereference chain actually
// walked turns a bit on - and that chain carries the subscript. So the union of a block
// instance's members is the reference set of that instance.
//
// Applied ONLY to arrayed instances, because for a scalar block glslang is already exact.
// Note the union is used even when it is empty: an array element nobody dereferenced has
// no member bits and is genuinely referenced by nobody, which is the whole point - falling
// back to the block's own mask there would restore the over-approximation.
Vector<Uint32> BuildBlockStagesFromMembers(const glslang::TProgram& reflection, Int blockCount) {
auto& mutableReflection = const_cast<glslang::TProgram&>(reflection);
Vector<Uint32> stagesByBlock(static_cast<SizeT>(blockCount < 0 ? 0 : blockCount), 0u);
const Int uniformCount = mutableReflection.getNumUniformVariables();
for (Int index = 0; index < uniformCount; ++index) {
const auto& uniform = mutableReflection.getUniform(index);
const Int owner = uniform.index;
if (owner < 0 || owner >= blockCount) continue;
stagesByBlock[static_cast<SizeT>(owner)] |= static_cast<Uint32>(uniform.stages);
}
return stagesByBlock;
}
// UNIFORM blocks only, and that scope is load-bearing rather than cautious. The member
// names glslang produces for a uniform block array carry the subscript
// ("TrickyBlock[0].b", via EShReflectionStrictArraySuffix), so each element's members are
// distinct entries and the bits land on the right one. A SHADER STORAGE block array does
// NOT get that treatment - its buffer variables reflect under one subscript-free spelling
// shared by every element - so a union over them credits element 0 and starves the rest.
// KHR-GL43.program_interface_query.ssb-types is the case that says so: it reads ss[0] and
// ss[1] and requires both to report the fragment stage, which only glslang's own
// (deliberately over-approximating) block mask gets right. Storage and atomic-counter
// blocks therefore keep that mask untouched.
Uint32 UniformBlockStages(const glslang::TObjectReflection& block, const Vector<Uint32>& stagesFromMembers,
Int tIndex) {
String arrayBase;
Uint element = 0;
Bool malformed = false;
if (!SplitTrailingSubscript(block.name, arrayBase, element, malformed) || malformed) {
return static_cast<Uint32>(block.stages);
}
if (tIndex < 0 || tIndex >= static_cast<Int>(stagesFromMembers.size())) {
return static_cast<Uint32>(block.stages);
}
return stagesFromMembers[static_cast<SizeT>(tIndex)];
}
void BuildBlocks(ProgramObject& program, const glslang::TProgram& reflection, Model& model,
Vector<BlockKind>& blockKind, Vector<Int>& blockInterfaceIndex) {
const Int blockCount = const_cast<glslang::TProgram&>(reflection).getNumUniformBlocks();
blockKind.assign(blockCount, BlockKind::Uniform);
blockInterfaceIndex.assign(blockCount, -1);
const Vector<Uint32> stagesFromMembers = BuildBlockStagesFromMembers(reflection, blockCount);
for (Int tIndex = 0; tIndex < blockCount; ++tIndex) {
const auto& block = const_cast<glslang::TProgram&>(reflection).getUniformBlock(tIndex);
@@ -260,8 +315,8 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
resource.bufferDataSize = static_cast<GLint>(program.GetUBOSizeAt(glIndex));
const Int tIndex = program.TProgramBlockIndex(static_cast<Uint>(glIndex));
if (tIndex >= 0 && tIndex < blockCount) {
resource.stages =
static_cast<Uint32>(const_cast<glslang::TProgram&>(reflection).getUniformBlock(tIndex).stages);
resource.stages = UniformBlockStages(const_cast<glslang::TProgram&>(reflection).getUniformBlock(tIndex),
stagesFromMembers, tIndex);
}
model.uniformBlocks.push_back(Move(resource));
}
@@ -352,6 +407,17 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
}
}
// A built-in interface block that a shader redeclares with fewer members keeps the
// omitted ones in its type when the redeclaration is ANONYMOUS - glslang hides them
// (basic type void) instead of erasing them, because the original shared declaration
// has to stay usable. Only the instance-named form erases. So a separable vertex
// program that redeclares `out gl_PerVertex { vec4 gl_Position; }` still carries
// gl_PointSize and gl_ClipDistance through the block-unwrapping reflection, and they
// are not part of its output interface.
Bool IsHiddenBlockMember(const glslang::TType* type) {
return type != nullptr && type->getBasicType() == glslang::EbtVoid;
}
void BuildStageIO(ProgramObject& program, const glslang::TProgram& reflection, Model& model) {
auto& mutableReflection = const_cast<glslang::TProgram&>(reflection);
@@ -359,6 +425,7 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
for (Int index = 0; index < inputCount; ++index) {
const auto& refl = mutableReflection.getPipeInput(index);
const glslang::TType* type = refl.getType();
if (IsHiddenBlockMember(type)) continue;
Resource resource;
// The Vulkan-semantics parse reflects the vertex builtins under their SPIR-V
// names; GL enumerates the GL spellings.
@@ -373,18 +440,28 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
model.programInputs.push_back(Move(resource));
}
// A color number, and therefore a color INDEX, exists only for a fragment stage's
// outputs. The output interface belongs to the program's last stage, so for a
// separable tessellation/geometry/vertex program these are varyings: asking the
// frag-data maps about them can still answer a location (a tess-control output
// carries its own layout(location=N)), and a location then manufactures a color
// index of 0 where GL requires -1
// (KHR-GL43.program_interface_query.separate-programs-tess-control).
const Bool lastStageIsFragment = mutableReflection.getIntermediate(EShLangFragment) != nullptr;
const Int outputCount = mutableReflection.getNumPipeOutputs();
for (Int index = 0; index < outputCount; ++index) {
const auto& refl = mutableReflection.getPipeOutput(index);
const glslang::TType* type = refl.getType();
if (IsHiddenBlockMember(type)) continue;
Resource resource;
resource.name = WithArraySuffix(refl.name, type);
resource.type = static_cast<GLenum>(refl.glDefineType);
resource.arraySize = ArraySizeOf(type, refl.size);
resource.location = MappedLocation(program.GetFragmentDataLocation(refl.name.c_str()));
if (resource.location < 0) {
// A built-in output (gl_FragDepth, gl_SampleMask) and a non-fragment stage
// output both have no location, and therefore no color index either.
if (resource.location < 0 || !lastStageIsFragment) {
// A built-in output (gl_FragDepth, gl_SampleMask) has no location, and a
// non-fragment stage's outputs have no color number at all - either way there
// is no color index.
resource.locationIndex = -1;
} else {
resource.locationIndex = program.GetFragmentDataIndex(refl.name.c_str());
@@ -9,6 +9,7 @@
#include "GL_Sampler.h"
#include "Validators.h"
#include "../Getter/GL_Getter.h"
#include "../Texture/GL_Texture.h"
#include <MG_State/GLState/Core.h>
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
@@ -269,15 +270,13 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
// The number of texture units a sampler may be bound to. GL 3.3 core 3.8.2 names
// GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, which is what the backend advertises; the frontend's
// MAX_TEXTURE_IMAGE_UNITS is only the capacity of the unit array, so it is a clamp on the
// answer and never the answer itself - gating on it alone accepts every unit up to 192 no
// matter what the driver reports.
// The number of texture units a sampler may be bound to is the same count a TEXTURE may be
// bound to - GL 3.3 core 3.8.2 names GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS for both - so it is
// computed once, in GetCombinedTextureImageUnitCount, and named here for the sampler-side
// readers below. Two copies of that arithmetic is how glBindSamplers and glBindTextures would
// come to disagree about which units exist.
static GLint GetSamplerBindableTextureUnitCount() {
GLint maxTextureUnits = 0;
GetIntegerv(GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, &maxTextureUnits);
return std::min<GLint>(std::max(maxTextureUnits, 0), MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS);
return GetCombinedTextureImageUnitCount();
}
void BindSampler_State(GLuint unit, GLuint sampler) {
+523 -47
View File
@@ -621,7 +621,7 @@ namespace MobileGL::MG_Impl::GLImpl {
// the process down, which is never an acceptable answer to a query - see the same reasoning
// above for the compressed-format path.
void RecordUnsupportedLevelQueryStorage(const char* caller, GLenum pname) {
MGLOG_I("%s: glGetTexLevelParameter(pname=%s) is not implemented for texture-buffer "
MGLOG_W_ONCE("%s: glGetTexLevelParameter(pname=%s) is not implemented for texture-buffer "
"storage; recording GL_INVALID_OPERATION instead of terminating",
caller, MG_Util::ConvertGLEnumToString(pname).c_str());
MG_State::pGLContext->RecordError(
@@ -870,7 +870,7 @@ namespace MobileGL::MG_Impl::GLImpl {
MG_Util::GetInputBytesPerPixel(MG_Util::ConvertGLEnumToTextureInputFormat(format),
MG_Util::ConvertGLEnumToTexturePixelDataType(type));
if (readBytesPerTexel != bytesPerTexel) {
MGLOG_I("%s: cannot copy into a %zu-byte texel from a %zu-byte readback layout", caller,
MGLOG_W_ONCE("%s: cannot copy into a %zu-byte texel from a %zu-byte readback layout", caller,
bytesPerTexel, readBytesPerTexel);
return false;
}
@@ -1376,6 +1376,47 @@ namespace MobileGL::MG_Impl::GLImpl {
return true;
}
// The same rules for the COMPRESSED entry points, whose payload size is the imageSize the
// caller passed rather than something derived from a (format, type) pair - and which have no
// datum size, so the alignment rule above does not apply to them. Shared by
// glCompressedTexImage2D and glCompressedTexSubImage2D so the two cannot drift; the point
// that is easy to get wrong and that KHR-GL44.buffer_storage.map_persistent_texture exists to
// check is the first one: a PERSISTENT mapping stays a legal transfer source.
Bool ValidateCompressedUnpackBufferSource(const void* data, SizeT imageSize, const char* caller) {
const auto& unpackBuffer =
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelUnpack).GetBoundObject();
if (!unpackBuffer) return true;
if (unpackBuffer->IsMapped() && !(unpackBuffer->GetMappingAccess() & BufferMappingAccessBit::Persistent)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller, "Pixel unpack buffer is currently mapped."));
return false;
}
const SizeT offset = reinterpret_cast<SizeT>(data);
const SizeT bufferSize = unpackBuffer->GetSize();
if (offset > bufferSize || imageSize > bufferSize - offset) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
"Unpacking would read past the end of the pixel unpack buffer."));
return false;
}
return true;
}
// Where a compressed upload reads its blocks from: `data` is an offset into the bound unpack
// buffer when there is one, and a client pointer otherwise. Only meaningful once
// ValidateCompressedUnpackBufferSource has passed. Null means there is nothing to read, which
// GL leaves undefined and which callers must not dereference.
const void* CompressedUnpackSource(const void* data) {
const auto& unpackBuffer =
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelUnpack).GetBoundObject();
if (!unpackBuffer) return data;
return reinterpret_cast<const char*>(unpackBuffer->MappedData()) + reinterpret_cast<SizeT>(data);
}
void TexSubImage3D_State(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width,
GLsizei height, GLsizei depth, GLenum format, GLenum type, const void* pixels) {
TextureUploadTarget textureUploadTarget = MG_Util::ConvertGLEnumToTextureUploadTarget(target);
@@ -1449,7 +1490,7 @@ namespace MobileGL::MG_Impl::GLImpl {
if (xoffset + width > static_cast<GLsizei>(texelSize.x()) ||
yoffset + height > static_cast<GLsizei>(texelSize.y()) ||
zoffset + depth > static_cast<GLsizei>(texelSize.z())) {
MGLOG_E("TexSubImage3D_State: Specified region exceeds texture level dimensions");
MGLOG_E_ONCE("TexSubImage3D_State: Specified region exceeds texture level dimensions");
free(processedPixels);
return;
}
@@ -1558,7 +1599,7 @@ namespace MobileGL::MG_Impl::GLImpl {
{width, height, 1}, false, inputSize);
if (!processedPixels || inputSize == 0) {
MGLOG_E("TexSubImage2D_State: Failed to process pixel data for TexSubImage2D, width: %d, height: %d", width,
MGLOG_E_ONCE("TexSubImage2D_State: Failed to process pixel data for TexSubImage2D, width: %d, height: %d", width,
height);
if (processedPixels) free(processedPixels);
return;
@@ -1572,7 +1613,7 @@ namespace MobileGL::MG_Impl::GLImpl {
if (xoffset + width > static_cast<GLsizei>(texelSize.x()) ||
yoffset + height > static_cast<GLsizei>(texelSize.y())) {
MGLOG_E("TexSubImage2D_State: Specified region exceeds texture dimensions");
MGLOG_E_ONCE("TexSubImage2D_State: Specified region exceeds texture dimensions");
free(processedPixels);
return;
}
@@ -2123,7 +2164,7 @@ namespace MobileGL::MG_Impl::GLImpl {
if (processedPixels && imageSize > 0) {
if (imageSize != internalBytes) {
MGLOG_W("%s: Processed pixel data size (%zu) does not match expected size (%zu). "
MGLOG_W_ONCE("%s: Processed pixel data size (%zu) does not match expected size (%zu). "
"This may indicate an alignment or processing issue.",
__func__, imageSize, internalBytes);
}
@@ -2238,6 +2279,23 @@ namespace MobileGL::MG_Impl::GLImpl {
DiscardMipmapChainOnBaseRespecification(textureMipmapObject, textureUploadTarget, level);
textureMipmapObject->AllocateStorage(textureUploadTarget, level,
{{width, height, 1}, internalBytes});
// GL 4.6 core 8.5: a SPECIFIC compressed internalformat (unlike a generic
// GL_COMPRESSED_* one, where the implementation is free to choose) commits the
// level to that format - GL_TEXTURE_COMPRESSED must then answer true for it and
// GL_TEXTURE_INTERNAL_FORMAT must report it, which is how an application asks for
// the size to hand glCompressedTexSubImage2D afterwards. Only the tag and the size
// are recorded: there is no BC/ETC codec here, so the texel shadow keeps the
// uncompressed storage this format resolved to (which is also what lets the level
// sample as the application's texels), and the compressed image the tag describes
// is zero-filled - the one reproducible answer glGetCompressedTexImage can give for
// an image nothing ever compressed. AllocateStorage above clears the tag, so this
// has to follow it.
const auto compressedInfo = MG_Util::GetCompressedFormatInfo(static_cast<GLenum>(internalformat));
if (compressedInfo.blockWidth != 0) {
textureMipmapObject->SetMipmapCompressedImage(
textureUploadTarget, level, static_cast<GLenum>(internalformat), nullptr,
MG_Util::CalculateCompressedTextureImageSize(compressedInfo, {width, height, 1}));
}
}
if (!originalPixels) {
@@ -2252,7 +2310,7 @@ namespace MobileGL::MG_Impl::GLImpl {
if (processedPixels && imageSize > 0) {
if (imageSize != internalBytes) {
MGLOG_W("TexImage2D_State: Processed pixel data size (%zu) does not match expected size (%zu). "
MGLOG_W_ONCE("TexImage2D_State: Processed pixel data size (%zu) does not match expected size (%zu). "
"This may indicate an alignment or processing issue.",
imageSize, internalBytes);
}
@@ -2965,9 +3023,9 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_TEXTURE_INTERNAL_FORMAT:
if (params) {
// A level stored compressed must report the token it was given, not the
// uncompressed format backing it (GL 4.6 core 8.11). Only glCompressedTexImage* sets
// that tag, so every level created by glTexImage*D - including one given a compressed
// internalformat - still answers with its resolved storage format.
// uncompressed format backing it (GL 4.6 core 8.11). glCompressedTexImage2D sets
// that tag, and so does a glTexImage2D given a SPECIFIC compressed internalformat;
// every other level answers with its resolved storage format.
const GLenum compressedFormat = GetCompressedLevelFormat(textureObject, textureUploadTarget, level);
*params = (compressedFormat != GL_NONE)
? (GLint)compressedFormat
@@ -3103,9 +3161,9 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_TEXTURE_INTERNAL_FORMAT:
if (params) {
// A level stored compressed must report the token it was given, not the
// uncompressed format backing it (GL 4.6 core 8.11). Only glCompressedTexImage* sets
// that tag, so every level created by glTexImage*D - including one given a compressed
// internalformat - still answers with its resolved storage format.
// uncompressed format backing it (GL 4.6 core 8.11). glCompressedTexImage2D sets
// that tag, and so does a glTexImage2D given a SPECIFIC compressed internalformat;
// every other level answers with its resolved storage format.
const GLenum compressedFormat = GetCompressedLevelFormat(textureObject, textureUploadTarget, level);
*params = (GLfloat)((compressedFormat != GL_NONE)
? compressedFormat
@@ -3324,12 +3382,84 @@ namespace MobileGL::MG_Impl::GLImpl {
dstY, dstZ, srcWidth, srcHeight, srcDepth);
}
namespace {
// The eleven targets GL 4.6 core 18.3.2 accepts. GL_TEXTURE_BUFFER, the six cube FACE
// enums and every PROXY enum all convert to a TextureTarget this frontend recognises,
// so ValidateTextureTarget lets them through; here they are INVALID_ENUM.
Bool ValidateCopyImageTarget(GLenum target, const char* endpointName) {
switch (target) {
case GL_RENDERBUFFER:
case GL_TEXTURE_1D:
case GL_TEXTURE_1D_ARRAY:
case GL_TEXTURE_2D:
case GL_TEXTURE_2D_ARRAY:
case GL_TEXTURE_2D_MULTISAMPLE:
case GL_TEXTURE_2D_MULTISAMPLE_ARRAY:
case GL_TEXTURE_3D:
case GL_TEXTURE_CUBE_MAP:
case GL_TEXTURE_CUBE_MAP_ARRAY:
case GL_TEXTURE_RECTANGLE:
return true;
default:
break;
}
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "ValidateCopyImageSubData_State",
std::format("{} is not a target glCopyImageSubData accepts as the {}.",
MG_Util::ConvertGLEnumToString(target), endpointName)));
return false;
}
IntVec3 GetCopyImageLevelSize(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
TextureUploadTarget uploadTarget, GLint level) {
const auto* mipmapTexture = MG_State::GLState::AsMipmapTexture(textureObject.get());
if (!mipmapTexture) return textureObject->GetBaseSize();
return mipmapTexture->GetMipmapTexelSize(uploadTarget, static_cast<Uint>(level));
}
// glCopyImageSubData names an object that must already exist, and GL 4.6 core 18.3.2
// spells the failure INVALID_VALUE - "if either name does not correspond to a valid
// object". The shared ValidateTextureObject says INVALID_OPERATION, which is right for
// the ~30 entry points that reach it through a BOUND object (where the name was never
// in question and the fault is the binding), so this is a local rule rather than a
// change to the helper.
Bool ValidateCopyImageObjectExists(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
const char* endpointName) {
if (textureObject) return true;
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "ValidateCopyImageSubData_State",
std::format("The {} name does not correspond to an existing image object.", endpointName)));
return false;
}
// Same split for the target/object disagreement: GL 4.6 core 18.3.2 makes a target that
// does not match the object INVALID_ENUM, where the shared uniformity helper records
// INVALID_OPERATION for the upload paths that share it.
Bool ValidateCopyImageTargetMatchesObject(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
TextureTarget target, const char* endpointName) {
if (!textureObject || textureObject->GetTarget() == target) return true;
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "ValidateCopyImageSubData_State",
std::format("The {} target {} does not match the target the object was created with ({}).",
endpointName, MG_Util::ConvertTextureTargetToString(target),
MG_Util::ConvertTextureTargetToString(textureObject->GetTarget()))));
return false;
}
} // namespace
Bool ValidateCopyImageSubData_State(const SharedPtr<MG_State::GLState::ITextureObject>& srcTexture,
GLenum srcTarget, GLint srcLevel,
GLenum srcTarget, GLint srcLevel, GLint srcX, GLint srcY,
const SharedPtr<MG_State::GLState::ITextureObject>& dstTexture,
GLenum dstTarget, GLint dstLevel,
GLenum dstTarget, GLint dstLevel, GLint dstX, GLint dstY,
GLsizei srcWidth, GLsizei srcHeight, GLsizei srcDepth) {
if (!TextureImpl::ValidateTextureObject(srcTexture) || !TextureImpl::ValidateTextureObject(dstTexture)) {
if (!ValidateCopyImageObjectExists(srcTexture, "source") ||
!ValidateCopyImageObjectExists(dstTexture, "destination")) {
return false;
}
const auto srcTextureTarget = MG_Util::ConvertGLEnumToTextureTarget(srcTarget);
@@ -3338,14 +3468,30 @@ namespace MobileGL::MG_Impl::GLImpl {
!TextureImpl::ValidateTextureTarget(dstTextureTarget)) {
return false;
}
if (!TextureImpl::ValidateTextureTargetUniformity(srcTexture, srcTextureTarget) ||
!TextureImpl::ValidateTextureTargetUniformity(dstTexture, dstTextureTarget)) {
// GL_TEXTURE_BUFFER and the cube FACE enums convert to a target this frontend knows, but
// 18.3.2 does not accept them here - only the eleven whole-image targets do.
if (!ValidateCopyImageTarget(srcTarget, "source") || !ValidateCopyImageTarget(dstTarget, "destination")) {
return false;
}
if (!ValidateCopyImageTargetMatchesObject(srcTexture, srcTextureTarget, "source") ||
!ValidateCopyImageTargetMatchesObject(dstTexture, dstTextureTarget, "destination")) {
return false;
}
if (!TextureImpl::ValidateTextureLevelNumber(srcLevel) ||
!TextureImpl::ValidateTextureLevelNumber(dstLevel)) {
return false;
}
// ValidateTextureLevelNumber only bounds the index by GL_MAX_TEXTURE_SIZE; it cannot
// see that this particular texture stops at level 0. Both backends turn <level> into an
// image subresource with no further checking (DirectVulkan builds a VkImageCopy from it,
// DirectGLES forwards it to the ES copy), so a level the texture never had reached the
// driver as an out-of-range mip index - on Adreno that is a SIGSEGV inside
// vkCmdCopyImage, which is what KHR-GL43.copy_image.non_existent_mipmap used to do to
// the whole glcts process. The answer the spec asks for is GL_INVALID_VALUE.
if (!TextureImpl::ValidateTextureLevelExists(srcTexture, srcLevel, __func__) ||
!TextureImpl::ValidateTextureLevelExists(dstTexture, dstLevel, __func__)) {
return false;
}
if (srcWidth < 0 || srcHeight < 0 || srcDepth < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
@@ -3356,7 +3502,44 @@ namespace MobileGL::MG_Impl::GLImpl {
if (srcWidth == 0 || srcHeight == 0 || srcDepth == 0) {
return false;
}
if (!TextureImpl::ValidateBaseInternalFormatMatch(srcTexture->GetFormat(), dstTexture->GetFormat())) {
// A multisample image can only be copied to one with the same sample count, and a
// single-sample image reports zero - so this one comparison is also what rejects
// copying between a multisample target and a non-multisample one.
if (srcTexture->GetSamples() != dstTexture->GetSamples()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", __func__,
std::format("The two images have different sample counts ({} vs. {}).",
srcTexture->GetSamples(), dstTexture->GetSamples())));
return false;
}
// 18.3.2: both images must be complete. An incomplete one has no defined texels to copy
// and no defined storage to copy into.
if (!srcTexture->IsComplete() || !dstTexture->IsComplete()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", __func__,
std::format("A copied image is incomplete (source complete: {}, destination complete: {}).",
srcTexture->IsComplete(), dstTexture->IsComplete())));
return false;
}
const auto srcUploadTarget = GetPrimaryUploadTarget(srcTexture);
const auto dstUploadTarget = GetPrimaryUploadTarget(dstTexture);
const auto srcBlock = TextureImpl::ResolveCopyImageTexelBlock(
srcTexture->GetFormat(), GetCompressedLevelFormat(srcTexture, srcUploadTarget, srcLevel));
const auto dstBlock = TextureImpl::ResolveCopyImageTexelBlock(
dstTexture->GetFormat(), GetCompressedLevelFormat(dstTexture, dstUploadTarget, dstLevel));
if (!TextureImpl::ValidateCopyImageFormatCompatibility(srcBlock, dstBlock)) {
return false;
}
const IntVec3 srcLevelSize = GetCopyImageLevelSize(srcTexture, srcUploadTarget, srcLevel);
const IntVec3 dstLevelSize = GetCopyImageLevelSize(dstTexture, dstUploadTarget, dstLevel);
if (!TextureImpl::ValidateCopyImageBlockAlignment(srcBlock, srcX, srcY, srcWidth, srcHeight,
srcLevelSize.x(), srcLevelSize.y(), "source") ||
!TextureImpl::ValidateCopyImageBlockAlignment(dstBlock, dstX, dstY, srcWidth, srcHeight,
dstLevelSize.x(), dstLevelSize.y(), "destination")) {
return false;
}
return true;
@@ -3468,10 +3651,170 @@ namespace MobileGL::MG_Impl::GLImpl {
RecordUnsupportedCompressedFormat(__func__);
}
// Replaces a block-aligned rectangle of the compressed image glCompressedTexImage2D (or a
// compressed glTexImage2D) shadowed for this level. Same deviation as the image call it
// patches: the uncompressed texel shadow beside it is NOT touched, because there is no
// BC/ETC codec here to decode the incoming blocks with - so what changes is the image
// glGetCompressedTexImage hands back, not what the level samples as. Marking the texels
// dirty would therefore only re-upload bytes that did not change.
void CompressedTexSubImage2D_State(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLsizei width,
GLsizei height, GLenum format, GLsizei imageSize, const void* data) {
// TODO: implement compressed upload - see CompressedTexImage2D_State.
RecordUnsupportedCompressedFormat(__func__);
// ======================= Converting ================================
const auto textureUploadTarget = MG_Util::ConvertGLEnumToTextureUploadTarget(target);
const auto textureTarget = MG_Util::ConvertGLEnumToTextureTarget(target);
// Zero block width doubles as "format is not a specific compressed format", the
// INVALID_ENUM case - one lookup answers both questions.
const auto compressedInfo = MG_Util::GetCompressedFormatInfo(format);
// ===================== Error Checking ==============================
if (!TextureImpl::ValidateTextureUploadTarget(textureUploadTarget)) return;
// A proxy holds no image to modify; only the glTexImage*/glCompressedTexImage* pair
// accepts one.
if (TextureImpl::IsProxyTextureTarget(textureUploadTarget)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"A proxy target has no texture image to modify."));
return;
}
if (!TextureImpl::ValidateTextureLevelNumber(level)) return;
if (!TextureImpl::ValidateTextureLevelWithUploadTarget(textureUploadTarget, level)) return;
if (width < 0 || height < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "width and height must be non-negative."));
return;
}
if (compressedInfo.blockWidth == 0) {
RecordUnsupportedCompressedFormat(__func__);
return;
}
auto& textureObject = GetTextureObjectByTarget(textureUploadTarget, textureTarget);
if (!TextureImpl::ValidateTextureObject(textureObject)) return;
auto* textureMipmapObject = MG_State::GLState::AsMipmapTexture(textureObject.get());
if (textureMipmapObject == nullptr) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "Texture storage is not mipmap-backed."));
return;
}
// GL 4.6 core 8.7: INVALID_OPERATION unless the image being modified is stored in
// exactly this compressed format. That is also what makes the block arithmetic below
// sound - the level's grid is measured with THIS format's block size.
const GLenum levelFormat =
textureMipmapObject->GetMipmapCompressedFormat(textureUploadTarget, static_cast<Uint>(level));
if (levelFormat != format) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"format does not match the internal format of the texture image."));
return;
}
const IntVec3 levelSize = textureMipmapObject->GetMipmapTexelSize(textureUploadTarget, static_cast<Uint>(level));
// Written as a subtraction rather than `xoffset + width > levelSize.x()`: both operands
// are application-supplied GLints, so the sum is free to overflow, and a signed overflow
// is undefined behaviour that a compiler may resolve by assuming the check passes.
// levelSize is our own and non-negative, and the offsets are known non-negative by the
// time the subtraction runs, so this form cannot wrap.
if (xoffset < 0 || yoffset < 0 || width > levelSize.x() - xoffset || height > levelSize.y() - yoffset) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"The replaced region does not lie within the texture image."));
return;
}
// GL 4.6 core 8.7 for block-based formats: the region must start on a block boundary
// and must either be a whole number of blocks wide/high or run to the image's edge.
const Int blockWidth = static_cast<Int>(compressedInfo.blockWidth);
const Int blockHeight = static_cast<Int>(compressedInfo.blockHeight);
const Bool alignedX = (xoffset % blockWidth == 0) &&
(width % blockWidth == 0 || xoffset + width == levelSize.x());
const Bool alignedY = (yoffset % blockHeight == 0) &&
(height % blockHeight == 0 || yoffset + height == levelSize.y());
if (!alignedX || !alignedY) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"The replaced region is not aligned to the format's compressed blocks."));
return;
}
// Exactly the size the format and dimensions imply, which is also what keeps the copy
// below in bounds.
const SizeT expectedImageSize =
MG_Util::CalculateCompressedTextureImageSize(compressedInfo, {width, height, 1});
if (imageSize < 0 || static_cast<SizeT>(imageSize) != expectedImageSize) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"imageSize does not match the compressed image size."));
return;
}
// ======================= Processing ================================
if (!ValidateCompressedUnpackBufferSource(data, expectedImageSize, __func__)) return;
const void* compressedBytes = CompressedUnpackSource(data);
if (expectedImageSize == 0) return; // a zero-sized region is a legal no-op
if (compressedBytes == nullptr) {
// No unpack buffer and a null client pointer: there is nothing to read. GL leaves
// this undefined rather than erroring, and dereferencing it is the one answer that
// is never acceptable.
MGLOG_D("%s: null data with no pixel unpack buffer bound, nothing to replace", __func__);
return;
}
// Once per process: the call is about to succeed, and what it does is narrower than what
// an application has every right to expect from it. Before this existed the call answered
// GL_INVALID_ENUM, which was wrong but at least visible; a silent success that leaves the
// sampled texels untouched is the kind of thing that costs a day to find from the other
// end. MGLOG_W is the right level and now survives at INFO; it sat at MGLOG_I only
// while the Log.h ordering compiled warnings out of the builds that ship.
static std::atomic<Bool> announcedNoCodec{false};
if (!announcedNoCodec.exchange(true)) {
MGLOG_W("%s: the compressed blocks are stored verbatim and returned by "
"glGetCompressedTexImage, but there is no BC/ETC decoder here, so they do not "
"reach the texels this level SAMPLES as. Upload through glTexSubImage2D for "
"that.",
__func__);
}
// The level's compressed image is stored as one blob, so the rectangle is patched into
// a copy of it and the whole thing handed back. Compressed sub-image uploads are not a
// hot path, and this keeps the storage layer's compressed API to the two calls it has.
const SizeT blobSize =
textureMipmapObject->GetMipmapCompressedByteSize(textureUploadTarget, static_cast<Uint>(level));
const void* existing =
textureMipmapObject->MapMipmapCompressedImage(textureUploadTarget, static_cast<Uint>(level));
if (blobSize == 0 || existing == nullptr) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"The texture level holds no compressed image to modify."));
return;
}
Vector<Uint8> blob(blobSize);
Memcpy(blob.data(), existing, blobSize);
const SizeT blockByteSize = compressedInfo.blockByteSize;
const SizeT levelBlocksX = (static_cast<SizeT>(levelSize.x()) + compressedInfo.blockWidth - 1) /
compressedInfo.blockWidth;
const SizeT levelRowBytes = levelBlocksX * blockByteSize;
const SizeT regionBlocksX = (static_cast<SizeT>(width) + compressedInfo.blockWidth - 1) /
compressedInfo.blockWidth;
const SizeT regionBlocksY = (static_cast<SizeT>(height) + compressedInfo.blockHeight - 1) /
compressedInfo.blockHeight;
const SizeT firstBlockX = static_cast<SizeT>(xoffset) / compressedInfo.blockWidth;
const SizeT firstBlockY = static_cast<SizeT>(yoffset) / compressedInfo.blockHeight;
const SizeT regionRowBytes = regionBlocksX * blockByteSize;
const auto* source = static_cast<const Uint8*>(compressedBytes);
for (SizeT row = 0; row < regionBlocksY; ++row) {
const SizeT destOffset = (firstBlockY + row) * levelRowBytes + firstBlockX * blockByteSize;
if (destOffset + regionRowBytes > blobSize) break; // a level whose blob predates its size
Memcpy(blob.data() + destOffset, source + row * regionRowBytes, regionRowBytes);
}
textureMipmapObject->SetMipmapCompressedImage(textureUploadTarget, static_cast<Uint>(level), format,
blob.data(), blobSize);
}
void CompressedTexSubImage1D_State(GLenum target, GLint level, GLint xoffset, GLsizei width, GLenum format,
@@ -3564,28 +3907,8 @@ namespace MobileGL::MG_Impl::GLImpl {
// SetMipmapCompressedImage re-arms it.
textureMipmapObject->AllocateStorage(textureUploadTarget, level, {{width, height, 1}, internalBytes});
const void* compressedBytes = data;
const auto& pixelUnpackBufferObject =
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelUnpack).GetBoundObject();
if (pixelUnpackBufferObject) {
if (pixelUnpackBufferObject->IsMapped()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Pixel unpack buffer is currently mapped."));
return;
}
const SizeT offset = reinterpret_cast<SizeT>(data);
const SizeT bufferSize = pixelUnpackBufferObject->GetSize();
if (offset > bufferSize || expectedImageSize > bufferSize - offset) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Unpacking would read past the end of the pixel unpack buffer."));
return;
}
compressedBytes = reinterpret_cast<const char*>(pixelUnpackBufferObject->MappedData()) + offset;
}
if (!ValidateCompressedUnpackBufferSource(data, expectedImageSize, __func__)) return;
const void* compressedBytes = CompressedUnpackSource(data);
textureMipmapObject->SetMipmapCompressedImage(textureUploadTarget, level, internalformat, compressedBytes,
expectedImageSize);
textureMipmapObject->MarkStorageDirty(textureUploadTarget, level, true);
@@ -4095,6 +4418,13 @@ namespace MobileGL::MG_Impl::GLImpl {
// core 8.19). Allocating only the primary one left the object cube-incomplete, so every
// framebuffer it was attached to reported GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT. Every other
// 2D target has exactly one upload target, so this loop is a no-op change for them.
// A specific compressed internalformat commits every level it allocates to that
// format, the same way glTexImage2D does - and here it matters twice over, because
// immutable storage plus glCompressedTexSubImage2D IS the modern way to upload a
// compressed texture: without the tag that sub-image call finds an uncompressed
// level and refuses it. Zero width means a generic (implementation's choice)
// format, which MobileGL answers with uncompressed storage, so it is not tagged.
const auto compressedInfo = MG_Util::GetCompressedFormatInfo(internalformat);
for (const auto uploadTarget : textureObject->GetUploadTargets()) {
for (GLsizei level = 0; level < levels; ++level) {
const GLsizei levelWidth = std::max<GLsizei>(1, width >> level);
@@ -4103,6 +4433,13 @@ namespace MobileGL::MG_Impl::GLImpl {
static_cast<SizeT>(levelWidth) * static_cast<SizeT>(levelHeight) * bytesPerPixel;
textureMipmapObject->AllocateStorage(uploadTarget, level, {{levelWidth, levelHeight, 1}, byteSize});
textureMipmapObject->MarkStorageDirty(uploadTarget, level, false);
if (compressedInfo.blockWidth != 0) {
// After AllocateStorage, which clears the tag.
textureMipmapObject->SetMipmapCompressedImage(
uploadTarget, static_cast<Uint>(level), internalformat, nullptr,
MG_Util::CalculateCompressedTextureImageSize(compressedInfo,
{levelWidth, levelHeight, 1}));
}
}
// See TextureStorage1D.
textureMipmapObject->TruncateMipmapLevels(uploadTarget, static_cast<Uint>(levels));
@@ -4472,6 +4809,14 @@ namespace MobileGL::MG_Impl::GLImpl {
free(processedPixels);
}
void CompressedTextureSubImage2D(GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLsizei width,
GLsizei height, GLenum format, GLsizei imageSize, const void* data) {
auto textureObject = GetTextureObjectByName(texture, __func__);
WithTemporarilyBoundNamedTexture(textureObject, [&](GLenum target) {
CompressedTexSubImage2D_State(target, level, xoffset, yoffset, width, height, format, imageSize, data);
});
}
void TextureSubImage3D(GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width,
GLsizei height, GLsizei depth, GLenum format, GLenum type, const void* pixels) {
auto textureObject = GetTextureObjectByName(texture, __func__);
@@ -4549,6 +4894,132 @@ namespace MobileGL::MG_Impl::GLImpl {
MG_State::pGLContext->NoteTextureUnitTouched(static_cast<Int>(unit), changed);
}
GLint GetCombinedTextureImageUnitCount() {
GLint maxTextureUnits = 0;
GetIntegerv(GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, &maxTextureUnits);
return std::min<GLint>(std::max(maxTextureUnits, 0), MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS);
}
namespace {
// ARB_multi_bind checks the whole [first, first + count) range before binding anything and
// reports an overrun as INVALID_OPERATION - not the INVALID_VALUE the single-bind entry
// points report for an out-of-range unit, and not after binding the in-range prefix.
Bool ValidateMultiBindUnitRange(GLuint first, GLsizei count, GLint unitCount, const char* funcName) {
if (count < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName, "count must be non-negative."));
return false;
}
if (static_cast<Uint64>(first) + static_cast<Uint64>(count) > static_cast<Uint64>(unitCount)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
std::format("first + count ({} + {}) exceeds the {} available units.",
first, count, unitCount)));
return false;
}
return true;
}
// ARB_multi_bind states the equivalence to a loop of single binds "except that <textures>
// will not be created if they do not exist": glBindTexture instantiates a name GenTextures
// merely reserved, the multi-bind entry points must refuse it. The error class is
// INVALID_OPERATION for both of them, where the scalar glBindImageTexture reports
// INVALID_VALUE - hence the check here rather than inside BindImageTexture.
//
// Deliberately PER ELEMENT: the extension defines these calls as a loop, so a bad entry
// costs its own unit and leaves the rest of the range bound.
SharedPtr<MG_State::GLState::ITextureObject> ResolveMultiBindTexture(GLuint texture, GLsizei index,
const char* funcName) {
SharedPtr<MG_State::GLState::ITextureObject> textureObject =
MG_State::pGLContext->GetTextureObject(texture);
if (!textureObject) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", funcName,
std::format("textures[{}] ({}) is not the name of an existing texture object.", index,
texture)));
}
return textureObject;
}
// ARB_multi_bind: an element naming texture zero unbinds EVERY target of its unit, i.e.
// rebinds each target's default texture object - the unit's initial state. Same rule
// glBindTextureUnit(unit, 0) follows.
void UnbindAllTargetsOnUnit(Int unit) {
auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
Bool changed = false;
for (auto& slot : textureUnit.GetAllBindingSlots()) {
if (slot.Bind(MG_State::pGLContext->GetDefaultTextureObject(slot.GetTarget()))) changed = true;
}
MG_State::pGLContext->NoteTextureUnitTouched(unit, changed);
}
} // namespace
// ARB_multi_bind: glBindTextures binds each texture to ITS OWN target on unit <first> + i, so
// there is no target parameter and no way to express it through glBindTexture - the per-unit,
// by-object form glBindTextureUnit uses is the one that matches. A NULL <textures> unbinds the
// whole range.
void BindTextures(GLuint first, GLsizei count, const GLuint* textures) {
if (!ValidateMultiBindUnitRange(first, count, GetCombinedTextureImageUnitCount(), __func__)) return;
for (GLsizei i = 0; i < count; ++i) {
const GLuint texture = textures ? textures[i] : 0;
const Int unit = static_cast<Int>(first) + i;
if (texture == 0) {
UnbindAllTargetsOnUnit(unit);
continue;
}
const SharedPtr<MG_State::GLState::ITextureObject> textureObject =
ResolveMultiBindTexture(texture, i, __func__);
if (!textureObject) continue;
auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
const Bool changed = textureUnit.GetBindingSlot(textureObject->GetTarget()).Bind(textureObject);
MG_State::pGLContext->NoteTextureUnitTouched(unit, changed);
}
}
// ARB_multi_bind: glBindImageTextures is a loop of glBindImageTexture with every parameter but
// the unit and the texture fixed by the spec - level 0, layered, layer 0, READ_WRITE, and the
// texture's own internal format. An element that names texture zero resets the unit.
void BindImageTextures(GLuint first, GLsizei count, const GLuint* textures) {
if (!ValidateMultiBindUnitRange(first, count, static_cast<GLint>(GetAdvertisedImageUnitCount()), __func__)) {
return;
}
for (GLsizei i = 0; i < count; ++i) {
const GLuint texture = textures ? textures[i] : 0;
const GLuint unit = first + static_cast<GLuint>(i);
if (texture == 0) {
BindImageTexture(unit, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_R8);
continue;
}
const SharedPtr<MG_State::GLState::ITextureObject> textureObject =
ResolveMultiBindTexture(texture, i, __func__);
if (!textureObject) continue;
// "An INVALID_OPERATION error is generated if the internal format of any texture is not
// supported for image textures" - a texture that has never been given storage has no
// format at all and lands here too, rather than being reported as a bad enum by the
// scalar path.
const GLenum format = MG_Util::ConvertTextureInternalFormatToGLEnum(textureObject->GetFormat());
if (!IsValidImageTextureFormat(format)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", __func__,
std::format("textures[{}] ({}) has an internal format that is not supported for image "
"textures.",
i, texture)));
continue;
}
BindImageTexture(unit, texture, 0, GL_TRUE, 0, GL_READ_WRITE, format);
}
}
void GetTextureImage(GLuint texture, GLint level, GLenum format, GLenum type, GLsizei bufSize, void* pixels) {
auto textureObject = GetTextureObjectByName(texture, __func__);
if (!textureObject) return;
@@ -5243,10 +5714,15 @@ namespace MobileGL::MG_Impl::GLImpl {
void CopyImageSubData(GLuint srcName, GLenum srcTarget, GLint srcLevel, GLint srcX, GLint srcY, GLint srcZ,
GLuint dstName, GLenum dstTarget, GLint dstLevel, GLint dstX, GLint dstY, GLint dstZ,
GLsizei srcWidth, GLsizei srcHeight, GLsizei srcDepth) {
auto srcTexture = GetTextureObjectByName(srcName, __func__);
auto dstTexture = GetTextureObjectByName(dstName, __func__);
if (!ValidateCopyImageSubData_State(srcTexture, srcTarget, srcLevel, dstTexture, dstTarget, dstLevel,
srcWidth, srcHeight, srcDepth)) {
// A missing name is INVALID_VALUE here, where GetTextureObjectByName's own diagnostic is
// INVALID_OPERATION - so resolve through the plain lookup, which answers a null
// SharedPtr, and let the validator record the error this entry point owes.
const SharedPtr<MG_State::GLState::ITextureObject> srcTexture =
MG_State::pGLContext->GetTextureObject(srcName);
const SharedPtr<MG_State::GLState::ITextureObject> dstTexture =
MG_State::pGLContext->GetTextureObject(dstName);
if (!ValidateCopyImageSubData_State(srcTexture, srcTarget, srcLevel, srcX, srcY, dstTexture, dstTarget,
dstLevel, dstX, dstY, srcWidth, srcHeight, srcDepth)) {
return;
}
CopyImageSubData_Backend(srcTexture, srcTarget, srcLevel, srcX, srcY, srcZ, dstTexture, dstTarget, dstLevel,
@@ -37,6 +37,8 @@ namespace MobileGL::MG_Impl::GLImpl {
GLenum format, GLenum type, const void* pixels);
void TextureSubImage3D(GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width,
GLsizei height, GLsizei depth, GLenum format, GLenum type, const void* pixels);
void CompressedTextureSubImage2D(GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLsizei width,
GLsizei height, GLenum format, GLsizei imageSize, const void* data);
void TextureParameterf(GLuint texture, GLenum pname, GLfloat param);
void TextureParameterfv(GLuint texture, GLenum pname, const GLfloat* params);
void TextureParameteri(GLuint texture, GLenum pname, GLint param);
@@ -132,5 +134,11 @@ namespace MobileGL::MG_Impl::GLImpl {
void CompressedTexImage1D(GLenum target, GLint level, GLenum internalformat, GLsizei width, GLint border,
GLsizei imageSize, const void* data);
void BindTexture(GLenum target, GLuint texture);
void BindTextures(GLuint first, GLsizei count, const GLuint* textures);
void BindImageTextures(GLuint first, GLsizei count, const GLuint* textures);
void ActiveTexture(GLenum texture);
// The number of texture image units a texture or a sampler may be bound to: what the backend
// advertises as GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, clamped by the frontend's fixed unit-array
// capacity. Shared so the texture and sampler multi-bind range checks cannot drift apart.
GLint GetCombinedTextureImageUnitCount();
} // namespace MobileGL::MG_Impl::GLImpl
+130 -12
View File
@@ -15,6 +15,7 @@
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToMG/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToStr/TextureEnumConverter.h>
#include <MG_Util/Metrics/TextureMetrics.h>
namespace MobileGL::MG_Impl::GLImpl::TextureImpl {
Bool ValidateTextureTarget(TextureTarget target) {
@@ -353,6 +354,63 @@ namespace MobileGL::MG_Impl::GLImpl::TextureImpl {
return true;
}
Bool ValidateTextureLevelExists(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject, Int level,
const char* caller) {
// A null object is somebody else's error to report - ValidateTextureObject runs
// first at every call site and has already recorded it.
if (!textureObject) return false;
const auto* mipmapTexture = MG_State::GLState::AsMipmapTexture(textureObject.get());
if (mipmapTexture == nullptr) {
// The only non-mipmap storage class is a buffer texture, and GL_TEXTURE_BUFFER is
// not a target glCopyImageSubData accepts at all (it is in the CTS's invalid-target
// set). Declining here is not the error code the spec asks for - that would be
// INVALID_ENUM from a target check this validator is not - but it does keep a
// texture with no image levels whatsoever from reaching a backend that would
// dereference a backend texture it never created.
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
"Texture has no mipmap levels to address."));
return false;
}
// What this number is, exactly, because two other things are almost it and neither is
// safe to assume: it is the number of level SLOTS the shadow has allocated - holes
// included, since MipmapStorage::AllocateLevel grows to level+1 and never fills the gap.
// For a cube map MipmapUploadTargetArray reports face +X's chain rather than the union.
//
// The guarantee that matters is one-sided: this count is always >= the level count the
// backends derive (VkTextureManager::GetUploadMipLevelCount stops at the first level
// with a non-positive extent, so it can only be shorter). That is the safe direction -
// no copy to a level the texture genuinely has is ever rejected here. It is NOT an
// exact match, so the backends keep their own range guard for the band in between: a
// chain with a hole (level 0 and 2 defined, 1 not) is accepted by this predicate and
// declined by the backend, which is a silent no-op rather than a copy. That band is a
// backend storage limitation, not a validation one - rejecting it here with
// INVALID_VALUE would be refusing a copy the spec permits.
const Uint levelCount = mipmapTexture->GetMipmapLevelCount();
if (levelCount == 0) {
// No image has ever been defined on this texture, so the fault is the texture,
// not the number: GL 4.6 core 18.3.2 asks for INVALID_OPERATION when an object a
// copy names is an incomplete texture.
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
"Texture has no image defined at any level."));
return false;
}
if (level < 0 || static_cast<Uint>(level) >= levelCount) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
"Texture level does not exist in this texture."));
return false;
}
return true;
}
Bool ValidateTextureObject(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject) {
if (!textureObject) {
MG_State::pGLContext->RecordError(
@@ -458,26 +516,86 @@ namespace MobileGL::MG_Impl::GLImpl::TextureImpl {
}
} // namespace
Bool ValidateBaseInternalFormatMatch(TextureInternalFormat format1, TextureInternalFormat format2) {
const auto unsizedFormat1 = MG_Util::ConvertInternalFormatToUnsized(format1);
const auto unsizedFormat2 = MG_Util::ConvertInternalFormatToUnsized(format2);
if (unsizedFormat1 != unsizedFormat2) {
// The 3-argument GenericErrorInfo constructor used to be spelled as a single
// std::format() call whose format string was the component name, so every
// diagnostic collapsed to the literal "MG_Impl/GLImpl". Format the message, then
// hand over component/function/message separately.
CopyImageTexelBlock ResolveCopyImageTexelBlock(TextureInternalFormat format, GLenum compressedFormat) {
CopyImageTexelBlock block{};
if (compressedFormat != GL_NONE) {
const auto info = MG_Util::GetCompressedFormatInfo(compressedFormat);
if (info.blockByteSize != 0) {
block.byteSize = info.blockByteSize;
block.blockWidth = info.blockWidth;
block.blockHeight = info.blockHeight;
block.compressed = true;
return block;
}
}
// The size MobileGL actually stores a texel of this format in, which for every format GL
// gives a required size is that required size. The handful of legacy formats GL leaves
// implementation-defined (R3_G3_B2, RGB4/5/10/12, RGBA2/12) have no view class in table
// 8.22 to be compared against anyway, and this is the size that decides whether a raw
// copy between them would in fact preserve the bytes.
block.byteSize = MG_Util::GetSizedInternalFormatSizeInBytes(format);
return block;
}
Bool ValidateCopyImageFormatCompatibility(const CopyImageTexelBlock& srcBlock,
const CopyImageTexelBlock& dstBlock) {
if (srcBlock.byteSize == 0 || dstBlock.byteSize == 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "ValidateCopyImageFormatCompatibility",
"A copied image has no storage whose texel size is known."));
return false;
}
if (srcBlock.byteSize != dstBlock.byteSize) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "ValidateBaseInternalFormatMatch",
std::format("The base internal format of the two formats do not match ({} vs. {})",
MG_Util::ConvertTextureInternalFormatToString(unsizedFormat1),
MG_Util::ConvertTextureInternalFormatToString(unsizedFormat2))));
"MG_Impl/GLImpl", "ValidateCopyImageFormatCompatibility",
std::format("The two images' texel blocks are different sizes ({} vs. {} bytes), so the "
"formats are not copy-compatible.",
srcBlock.byteSize, dstBlock.byteSize)));
return false;
}
// Two compressed images additionally have to agree on the SHAPE of the block, not only
// its size: an 8-byte 4x4 block and a hypothetical 8-byte 8x8 one hold different texel
// counts, and GL 4.6 core 18.3.2 requires both dimensions to match.
if (srcBlock.compressed && dstBlock.compressed &&
(srcBlock.blockWidth != dstBlock.blockWidth || srcBlock.blockHeight != dstBlock.blockHeight)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "ValidateCopyImageFormatCompatibility",
std::format("The two compressed images have different block dimensions ({}x{} vs. {}x{}).",
srcBlock.blockWidth, srcBlock.blockHeight, dstBlock.blockWidth,
dstBlock.blockHeight)));
return false;
}
return true;
}
Bool ValidateCopyImageBlockAlignment(const CopyImageTexelBlock& block, Int x, Int y, Int width, Int height,
Int imageWidth, Int imageHeight, const char* endpointName) {
if (!block.compressed) return true;
const Int blockWidth = static_cast<Int>(block.blockWidth);
const Int blockHeight = static_cast<Int>(block.blockHeight);
if (blockWidth <= 1 && blockHeight <= 1) return true;
// The origin is unconditional; the extent gets the "or it reaches the edge of the image"
// exemption GL 4.6 core 18.3.2 grants, which is what lets a 16x16 BPTC image be copied
// whole even when the last block is partial.
const Bool originAligned = (x % blockWidth == 0) && (y % blockHeight == 0);
const Bool widthOk = (width % blockWidth == 0) || (x + width == imageWidth);
const Bool heightOk = (height % blockHeight == 0) || (y + height == imageHeight);
if (originAligned && widthOk && heightOk) return true;
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "ValidateCopyImageBlockAlignment",
std::format("The {} region [{}, {}] + [{} x {}] is not aligned to the {}x{} compressed block "
"grid of a {} x {} image.",
endpointName, x, y, width, height, blockWidth, blockHeight, imageWidth, imageHeight)));
return false;
}
Bool ValidateCopyTexImageBaseFormatSubset(TextureInternalFormat destFormat, TextureInternalFormat srcFormat) {
const auto unsizedDest = MG_Util::ConvertInternalFormatToUnsized(destFormat);
const auto unsizedSrc = MG_Util::ConvertInternalFormatToUnsized(srcFormat);
+36 -2
View File
@@ -30,6 +30,16 @@ namespace MobileGL::MG_Impl::GLImpl::TextureImpl {
TextureInternalFormat internalFormat,
TexturePixelDataType type);
Bool ValidateTextureLevelWithUploadTarget(TextureUploadTarget target, Int level);
// "Is <level> a level this texture actually has?", which ValidateTextureLevelNumber above
// does NOT answer - that one only bounds the index by GL_MAX_TEXTURE_SIZE and knows nothing
// about the object. Entry points that resolve a level straight into a backend image
// subresource need this one: a level the texture never had is GL_INVALID_VALUE (GL 4.6 core
// 18.3.2), and passing it through instead reaches the driver as an out-of-range subresource.
// Note the error split is per-entry-point, so this is not universally reusable:
// glClearTexImage owes INVALID_OPERATION for the same out-of-range level and spells its own
// copy of this predicate in GL_Texture.cpp (GetClearTextureObject).
Bool ValidateTextureLevelExists(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject, Int level,
const char* caller);
Bool ValidateTextureObject(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject);
// Rejects the per-target default texture objects (name 0) with GL_INVALID_OPERATION for entry
// points that require a GenTextures-created texture, e.g. TexStorage* ("An INVALID_OPERATION
@@ -40,8 +50,32 @@ namespace MobileGL::MG_Impl::GLImpl::TextureImpl {
TextureTarget target);
Bool ValidateTextureSubImageOffsets(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject, Int xoffset,
Int width, Int yoffset = 0, Int height = 0, Int zoffset = 0, Int depth = 0);
// Exact base-format equality - what glCopyImageSubData's format compatibility needs.
Bool ValidateBaseInternalFormatMatch(TextureInternalFormat format1, TextureInternalFormat format2);
// The texel block of one glCopyImageSubData endpoint, resolved to the two things the
// compatibility rule actually asks about. `compressed` is not redundant with a block bigger
// than 1x1: it is what distinguishes "compressed, and so the region is measured in texels of
// a blocked image" from "uncompressed, and so it is measured in texels".
struct CopyImageTexelBlock {
SizeT byteSize = 0;
Uint blockWidth = 1;
Uint blockHeight = 1;
Bool compressed = false;
};
// `compressedFormat` is the GLenum a glCompressedTexImage* upload recorded for the level, or
// GL_NONE. It has to be asked for separately because MobileGL stores every compressed format
// in uncompressed storage (ConvertGLEnumToTextureInternalFormat), so the TextureInternalFormat
// alone can no longer tell a BPTC image from the RGBA8 backing it.
CopyImageTexelBlock ResolveCopyImageTexelBlock(TextureInternalFormat format, GLenum compressedFormat);
// GL 4.6 core 18.3.2: the two images must be COMPATIBLE, and compatible means their texel
// blocks are the same SIZE - not that they share a base internal format. RGBA32UI into
// RGBA32F is legal (both 128-bit) while RGBA8 into RGBA32F is not, and a compressed image
// pairs with an uncompressed one whose texel is as big as the compressed block.
Bool ValidateCopyImageFormatCompatibility(const CopyImageTexelBlock& srcBlock,
const CopyImageTexelBlock& dstBlock);
// GL 4.6 core 18.3.2: for a compressed image the region's origin must sit on a block
// boundary and its size must be a whole number of blocks - unless the edge it runs to is
// the edge of the image.
Bool ValidateCopyImageBlockAlignment(const CopyImageTexelBlock& block, Int x, Int y, Int width, Int height,
Int imageWidth, Int imageHeight, const char* endpointName);
// GL 4.6 SS 8.6 subset rule for glCopyTexImage*: the read buffer must supply every component
// the requested internalformat asks for, but may supply more.
Bool ValidateCopyTexImageBaseFormatSubset(TextureInternalFormat destFormat, TextureInternalFormat srcFormat);
@@ -315,9 +315,10 @@ namespace MobileGL::MG_Impl::GLImpl {
auto offset = reinterpret_cast<SizeT>(pointer);
vao->SetAttributeFormat(index, size, dataType, false, stride, offset, true, false);
const int effectiveStride = EffectiveVertexStride(stride, size, type);
vao->SetAttributeFormat(index, size, dataType, false, stride, offset, true, false, effectiveStride);
vao->BindAttributeBuffer(index, vbo);
vao->MirrorPointerIntoBinding(index, vbo, offset, EffectiveVertexStride(stride, size, type));
vao->MirrorPointerIntoBinding(index, vbo, offset, effectiveStride);
}
void VertexAttribPointer_State(GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride,
@@ -345,9 +346,11 @@ namespace MobileGL::MG_Impl::GLImpl {
// backend can pick the reversed VkFormat / pass GL_BGRA through to a GLES driver.
const bool isBgra = (size == static_cast<GLint>(GL_BGRA));
const int effectiveSize = isBgra ? 4 : size;
vao->SetAttributeFormat(index, effectiveSize, dataType, normalized, stride, offset, false, isBgra);
const int effectiveStride = EffectiveVertexStride(stride, effectiveSize, type);
vao->SetAttributeFormat(index, effectiveSize, dataType, normalized, stride, offset, false, isBgra,
effectiveStride);
vao->BindAttributeBuffer(index, vbo);
vao->MirrorPointerIntoBinding(index, vbo, offset, EffectiveVertexStride(stride, effectiveSize, type));
vao->MirrorPointerIntoBinding(index, vbo, offset, effectiveStride);
}
void BindVertexArray_State(GLuint array) {
@@ -524,7 +527,7 @@ namespace MobileGL::MG_Impl::GLImpl {
if (!MG_Backend::pActiveBackendObject ||
!MG_Backend::pActiveBackendObject->GetDynamicParameters().SupportsFloat64VertexAttributes) {
MGLOG_I("VertexAttribLFormat: attribute %u asked for a 64-bit (GL_DOUBLE) format, but this "
MGLOG_W_ONCE("VertexAttribLFormat: attribute %u asked for a 64-bit (GL_DOUBLE) format, but this "
"backend has no double-precision vertex attribute support - see the "
"\"64-bit vertex attributes\" / \"shaderFloat64\" POST row for what that costs",
attribindex);
@@ -166,32 +166,32 @@ MOBILEGL_GLX_API int glXSwapIntervalSGI(int interval) {
// Legacy entry points some loaders probe for; harmless no-op stubs.
MOBILEGL_GLX_API void glXCopyContext(Display*, void*, void*, unsigned long) {
MGLOG_W("glx: glXCopyContext is not supported");
MGLOG_W_ONCE("glx: glXCopyContext is not supported");
}
MOBILEGL_GLX_API unsigned long glXCreateGLXPixmap(Display*, void*, unsigned long) {
MGLOG_W("glx: glXCreateGLXPixmap is not supported");
MGLOG_W_ONCE("glx: glXCreateGLXPixmap is not supported");
return 0;
}
MOBILEGL_GLX_API void glXDestroyGLXPixmap(Display*, unsigned long) {}
MOBILEGL_GLX_API unsigned long glXCreatePixmap(Display*, void*, unsigned long, const int*) {
MGLOG_W("glx: glXCreatePixmap is not supported");
MGLOG_W_ONCE("glx: glXCreatePixmap is not supported");
return 0;
}
MOBILEGL_GLX_API void glXDestroyPixmap(Display*, unsigned long) {}
MOBILEGL_GLX_API unsigned long glXCreatePbuffer(Display*, void*, const int*) {
MGLOG_W("glx: glXCreatePbuffer is not supported");
MGLOG_W_ONCE("glx: glXCreatePbuffer is not supported");
return 0;
}
MOBILEGL_GLX_API void glXDestroyPbuffer(Display*, unsigned long) {}
MOBILEGL_GLX_API void glXUseXFont(unsigned long, int, int, int) {
MGLOG_W("glx: glXUseXFont is not supported");
MGLOG_W_ONCE("glx: glXUseXFont is not supported");
}
MOBILEGL_GLX_API void glXSelectEvent(Display*, unsigned long, unsigned long) {}
+9 -9
View File
@@ -149,7 +149,7 @@ namespace MobileGL::MG_Impl::GLXImpl {
fns->Sync = reinterpret_cast<decltype(fns->Sync)>(dlsym(fns->Library, "XSync"));
}
if (!fns->Valid()) {
MGLOG_E("glx: failed to load libX11 entry points");
MGLOG_E_ONCE("glx: failed to load libX11 entry points");
}
return fns;
}();
@@ -314,7 +314,7 @@ namespace MobileGL::MG_Impl::GLXImpl {
Uint32 width = 0;
Uint32 height = 0;
if (!QueryDrawableSize(dpy, drawable, width, height)) {
MGLOG_E("glx: XGetGeometry failed for drawable 0x%lx", drawable);
MGLOG_E_ONCE("glx: XGetGeometry failed for drawable 0x%lx", drawable);
return nullptr;
}
@@ -326,7 +326,7 @@ namespace MobileGL::MG_Impl::GLXImpl {
EGLSurface surface = EGLImpl::CreatePlatformWindowSurface(
context.Display, context.Config, reinterpret_cast<void*>(drawable), attribs);
if (surface == EGL_NO_SURFACE) {
MGLOG_E("glx: failed to create window surface for drawable 0x%lx (%ux%u)", drawable,
MGLOG_E_ONCE("glx: failed to create window surface for drawable 0x%lx (%ux%u)", drawable,
width, height);
return nullptr;
}
@@ -347,7 +347,7 @@ namespace MobileGL::MG_Impl::GLXImpl {
const std::lock_guard<std::recursive_mutex> lock(RegistryMutex());
EGLDisplay display = EnsureDisplay();
if (display == EGL_NO_DISPLAY) {
MGLOG_E("glx: no EGL display");
MGLOG_E_ONCE("glx: no EGL display");
return nullptr;
}
EGLImpl::BindAPI(EGL_OPENGL_API);
@@ -376,13 +376,13 @@ namespace MobileGL::MG_Impl::GLXImpl {
EGLint configCount = 0;
if (!EGLImpl::ChooseConfig(display, configAttribs, &config, 1, &configCount) ||
configCount <= 0) {
MGLOG_E("glx: eglChooseConfig failed");
MGLOG_E_ONCE("glx: eglChooseConfig failed");
return nullptr;
}
EGLContext eglContext = EGLImpl::CreateContext(display, config, shareContext, contextAttribs);
if (eglContext == EGL_NO_CONTEXT) {
MGLOG_E("glx: eglCreateContext failed");
MGLOG_E_ONCE("glx: eglCreateContext failed");
return nullptr;
}
@@ -931,7 +931,7 @@ namespace MobileGL::MG_Impl::GLXImpl {
if (!EGLImpl::MakeCurrent(object->Display, surface->Surface, surface->Surface,
object->Context)) {
MGLOG_E("glx: eglMakeCurrent failed (drawable=0x%lx, ctx=%p)", drawable, context);
MGLOG_E_ONCE("glx: eglMakeCurrent failed (drawable=0x%lx, ctx=%p)", drawable, context);
return 0;
}
t_current = {dpy, drawable, drawable, context};
@@ -943,7 +943,7 @@ namespace MobileGL::MG_Impl::GLXImpl {
if (context && draw != read) {
// MobileGL's backends reject split draw/read surfaces; bind the draw
// drawable for both, which is what every real caller here needs.
MGLOG_W("glx: glXMakeContextCurrent draw 0x%lx != read 0x%lx, using draw for both", draw,
MGLOG_W_ONCE("glx: glXMakeContextCurrent draw 0x%lx != read 0x%lx, using draw for both", draw,
read);
}
const int result = MakeCurrent(dpy, draw, context);
@@ -958,7 +958,7 @@ namespace MobileGL::MG_Impl::GLXImpl {
auto& surfaces = DrawableSurfaces();
auto it = surfaces.find(drawable);
if (it == surfaces.end()) {
MGLOG_W("glx: glXSwapBuffers with no surface for drawable 0x%lx", drawable);
MGLOG_W_ONCE("glx: glXSwapBuffers with no surface for drawable 0x%lx", drawable);
return;
}
SyncSurfaceSize(dpy, drawable, it->second);
+1 -1
View File
@@ -31,7 +31,7 @@ namespace MG_Impl::GLXImpl {
#endif
void* proc = MobileGL::MG_Impl::GetProcAddress(name);
if (!proc) {
MGLOG_W("Failed to get function: %s", (const char*)name);
MGLOG_D("Failed to get function: %s", (const char*)name);
return nullptr;
}
+1 -1
View File
@@ -1403,7 +1403,7 @@ namespace MobileGL::MG_Impl {
GETPROC(glFramebufferTextureMultiviewOVR, name);
// GETPROC(glNamedFramebufferTextureMultiviewOVR, name);
MGLOG_W("GetProcAddress(%s) = nullptr!", name);
MGLOG_D("GetProcAddress(%s) = nullptr!", name);
return nullptr;
}
} // namespace MobileGL::MG_Impl
@@ -269,7 +269,7 @@ namespace MobileGL::MG_Impl::NSOpenGLImpl {
}
id metalLayerClass = reinterpret_cast<id>(objc_getClass("CAMetalLayer"));
if (!metalLayerClass) {
MGLOG_E("NSOpenGLImpl: CAMetalLayer class not found");
MGLOG_E_ONCE("NSOpenGLImpl: CAMetalLayer class not found");
return nil;
}
@@ -310,7 +310,7 @@ namespace MobileGL::MG_Impl::NSOpenGLImpl {
static_cast<GLint>(geometry.DrawableSize.width),
static_cast<GLint>(geometry.DrawableSize.height));
if (error != kCGLNoError) {
MGLOG_E("NSOpenGLImpl: failed to attach drawable: %s", CGLImpl::ErrorString(error));
MGLOG_E_ONCE("NSOpenGLImpl: failed to attach drawable: %s", CGLImpl::ErrorString(error));
}
}
@@ -325,7 +325,7 @@ namespace MobileGL::MG_Impl::NSOpenGLImpl {
}
const auto error = CGLImpl::SetCurrentContext(context);
if (error != kCGLNoError) {
MGLOG_E("NSOpenGLImpl: makeCurrentContext failed: %s", CGLImpl::ErrorString(error));
MGLOG_E_ONCE("NSOpenGLImpl: makeCurrentContext failed: %s", CGLImpl::ErrorString(error));
}
}
@@ -345,7 +345,7 @@ namespace MobileGL::MG_Impl::NSOpenGLImpl {
}
const auto error = CGLImpl::FlushDrawable(context);
if (error != kCGLNoError) {
MGLOG_E("NSOpenGLImpl: flushBuffer failed: %s", CGLImpl::ErrorString(error));
MGLOG_E_ONCE("NSOpenGLImpl: flushBuffer failed: %s", CGLImpl::ErrorString(error));
}
}
@@ -377,7 +377,7 @@ namespace MobileGL::MG_Impl::NSOpenGLImpl {
static_cast<GLint>(geometry.DrawableSize.width),
static_cast<GLint>(geometry.DrawableSize.height));
if (error != kCGLNoError) {
MGLOG_E("NSOpenGLImpl: update failed to attach drawable: %s", CGLImpl::ErrorString(error));
MGLOG_E_ONCE("NSOpenGLImpl: update failed to attach drawable: %s", CGLImpl::ErrorString(error));
return;
}
CGLImpl::UpdateContext(context);
@@ -421,7 +421,7 @@ namespace MobileGL::MG_Impl::NSOpenGLImpl {
SEL selector = sel_registerName(selectorName);
Method method = class_getInstanceMethod(cls, selector);
if (!method) {
MGLOG_W("NSOpenGLImpl: missing instance method %s", selectorName);
MGLOG_W_ONCE("NSOpenGLImpl: missing instance method %s", selectorName);
return;
}
if (original) {
@@ -434,7 +434,7 @@ namespace MobileGL::MG_Impl::NSOpenGLImpl {
SEL selector = sel_registerName(selectorName);
Method method = class_getClassMethod(cls, selector);
if (!method) {
MGLOG_W("NSOpenGLImpl: missing class method %s", selectorName);
MGLOG_W_ONCE("NSOpenGLImpl: missing class method %s", selectorName);
return;
}
method_setImplementation(method, replacement);
@@ -444,7 +444,7 @@ namespace MobileGL::MG_Impl::NSOpenGLImpl {
Class pixelFormatClass = objc_getClass("NSOpenGLPixelFormat");
Class contextClass = objc_getClass("NSOpenGLContext");
if (!pixelFormatClass || !contextClass) {
MGLOG_W("NSOpenGLImpl: NSOpenGL classes are not loaded; hooks not installed");
MGLOG_W_ONCE("NSOpenGLImpl: NSOpenGL classes are not loaded; hooks not installed");
return false;
}
@@ -56,7 +56,7 @@ extern "C" HGLRC WINAPI wglCreateLayerContext(HDC hdc, int iLayerPlane) {
}
extern "C" BOOL WINAPI wglCopyContext(HGLRC, HGLRC, UINT) {
MGLOG_W("wglCopyContext is not supported");
MGLOG_W_ONCE("wglCopyContext is not supported");
SetLastError(ERROR_NOT_SUPPORTED);
return FALSE;
}
@@ -132,24 +132,24 @@ extern "C" DWORD WINAPI wglSwapMultipleBuffers(UINT n, CONST WGLSWAP* ps) {
// ---- Font rendering (legacy immediate-mode feature; not supported) ----
extern "C" BOOL WINAPI wglUseFontBitmapsA(HDC, DWORD, DWORD, DWORD) {
MGLOG_W("wglUseFontBitmapsA is not supported");
MGLOG_W_ONCE("wglUseFontBitmapsA is not supported");
return FALSE;
}
extern "C" BOOL WINAPI wglUseFontBitmapsW(HDC, DWORD, DWORD, DWORD) {
MGLOG_W("wglUseFontBitmapsW is not supported");
MGLOG_W_ONCE("wglUseFontBitmapsW is not supported");
return FALSE;
}
extern "C" BOOL WINAPI wglUseFontOutlinesA(HDC, DWORD, DWORD, DWORD, FLOAT, FLOAT, int,
LPGLYPHMETRICSFLOAT) {
MGLOG_W("wglUseFontOutlinesA is not supported");
MGLOG_W_ONCE("wglUseFontOutlinesA is not supported");
return FALSE;
}
extern "C" BOOL WINAPI wglUseFontOutlinesW(HDC, DWORD, DWORD, DWORD, FLOAT, FLOAT, int,
LPGLYPHMETRICSFLOAT) {
MGLOG_W("wglUseFontOutlinesW is not supported");
MGLOG_W_ONCE("wglUseFontOutlinesW is not supported");
return FALSE;
}
+7 -7
View File
@@ -215,7 +215,7 @@ namespace MobileGL::MG_Impl::WGLImpl {
Uint32 width = 0;
Uint32 height = 0;
if (!QueryClientSize(hwnd, width, height)) {
MGLOG_E("wgl: GetClientRect failed for HWND %p", hwnd);
MGLOG_E_ONCE("wgl: GetClientRect failed for HWND %p", hwnd);
return nullptr;
}
@@ -227,7 +227,7 @@ namespace MobileGL::MG_Impl::WGLImpl {
EGLSurface surface =
EGLImpl::CreatePlatformWindowSurface(context.Display, context.Config, hwnd, attribs);
if (surface == EGL_NO_SURFACE) {
MGLOG_E("wgl: failed to create window surface for HWND %p (%ux%u)", hwnd, width, height);
MGLOG_E_ONCE("wgl: failed to create window surface for HWND %p (%ux%u)", hwnd, width, height);
return nullptr;
}
@@ -244,7 +244,7 @@ namespace MobileGL::MG_Impl::WGLImpl {
const std::lock_guard<std::recursive_mutex> lock(RegistryMutex());
EGLDisplay display = EnsureDisplay();
if (display == EGL_NO_DISPLAY) {
MGLOG_E("wgl: no EGL display");
MGLOG_E_ONCE("wgl: no EGL display");
return nullptr;
}
EGLImpl::BindAPI(EGL_OPENGL_API);
@@ -275,13 +275,13 @@ namespace MobileGL::MG_Impl::WGLImpl {
EGLConfig config = nullptr;
EGLint configCount = 0;
if (!EGLImpl::ChooseConfig(display, configAttribs, &config, 1, &configCount) || configCount <= 0) {
MGLOG_E("wgl: eglChooseConfig failed");
MGLOG_E_ONCE("wgl: eglChooseConfig failed");
return nullptr;
}
EGLContext eglContext = EGLImpl::CreateContext(display, config, shareContext, contextAttribs);
if (eglContext == EGL_NO_CONTEXT) {
MGLOG_E("wgl: eglCreateContext failed");
MGLOG_E_ONCE("wgl: eglCreateContext failed");
return nullptr;
}
@@ -612,7 +612,7 @@ namespace MobileGL::MG_Impl::WGLImpl {
auto& surfaces = WindowSurfaces();
auto it = surfaces.find(hwnd);
if (it == surfaces.end()) {
MGLOG_W("wglSwapBuffers: no surface for HWND %p", hwnd);
MGLOG_W_ONCE("wglSwapBuffers: no surface for HWND %p", hwnd);
return FALSE;
}
SyncSurfaceSize(hwnd, it->second);
@@ -685,7 +685,7 @@ namespace MobileGL::MG_Impl::WGLImpl {
}
if (!EGLImpl::MakeCurrent(object->Display, surface->Surface, surface->Surface, object->Context)) {
MGLOG_E("wglMakeCurrent: eglMakeCurrent failed (hdc=%p, hglrc=%p)", hdc, hglrc);
MGLOG_E_ONCE("wglMakeCurrent: eglMakeCurrent failed (hdc=%p, hglrc=%p)", hdc, hglrc);
return FALSE;
}
t_current = {hdc, hglrc};
@@ -50,6 +50,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/CrossFrameBufferScenario.cpp
Scenarios/ResidentIndexScenario.cpp
Scenarios/MultiDrawScenario.cpp
Scenarios/DrawParametersScenario.cpp
Scenarios/AsyncCompileScenario.cpp
Scenarios/XfbAfterClipDistanceScenario.cpp
Scenarios/ThreeChannelAttachmentScenario.cpp
@@ -59,8 +60,26 @@ add_executable(MobileGLIntegrationTest
Scenarios/FragCoordOriginScenario.cpp
Scenarios/ClearThenReadPixelsScenario.cpp
Scenarios/DepthStencilReadbackScenario.cpp
Scenarios/DepthStencilReadbackMatrixScenario.cpp
Scenarios/DepthStencilReadbackAttachmentShapeScenario.cpp
Scenarios/ClipDistanceScenario.cpp
Scenarios/SsboArrayLengthScenario.cpp
Scenarios/DoublePrecisionScenario.cpp
Scenarios/UniformInitializerScenario.cpp
Scenarios/SwizzleAccessRoutineScenario.cpp
Scenarios/ProgramPipelineScenario.cpp
Scenarios/ImageLoadStoreSsoScenario.cpp
Scenarios/ImageTargetKindScenario.cpp
Scenarios/ImageFormatQualifierScenario.cpp
Scenarios/SsboDeclarationFormScenario.cpp
Scenarios/Glsl420DeclarationScenario.cpp
Scenarios/FragmentOutputArrayIndexScenario.cpp
Scenarios/BufferTextureScenario.cpp
Scenarios/VertexAttribBindingScenario.cpp
Scenarios/XfbCaptureBufferReuseScenario.cpp
Scenarios/VertexArrayEnableDisableScenario.cpp
Scenarios/CopyImageLevelRangeScenario.cpp
Scenarios/CopyImageLayeredScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -228,6 +247,8 @@ mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_ASYNC_SHADER_COMPILE=1" ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_FORCED_DS_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION=1" ${MGL_ITEST_COMMON_ENV})
# TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it.
set(MGL_ITEST_TIMEOUT 120)
@@ -275,3 +296,21 @@ gtest_discover_tests(MobileGLIntegrationTest
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT}"
)
# A fourth registration, of the depth/stencil readback scenarios, with the ES
# shader-sampling emulation forced on. Not paranoia - without it these scenarios are
# UNFALSIFIABLE on the machines this suite runs on: OpenGL ES has no depth or stencil
# readback in core, but Mesa accepts the reads anyway, so on llvmpipe every one of them
# goes green through a native path that the Adreno device does not have. Deleting the
# entire emulation left all of them passing. With the flag the native spellings are off
# the table and only the path the device actually takes remains. DirectGLES only - the
# emulation is DirectGLES's.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.ForcedDepthStencilEmulation."
TEST_FILTER "DepthStencilReadback*Scenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_FORCED_DS_ENVIRONMENT}"
)
@@ -94,6 +94,89 @@ namespace MGITest {
}
}
// A per-stage block count is an amount of BINDING POINTS an application will use, so it
// can never exceed the number of binding points that exist. GL 4.6 Table 23.64 states the
// relation the other way round (MAX_UNIFORM_BUFFER_BINDINGS >= MAX_COMBINED_UNIFORM_BLOCKS
// >= every per-stage count), and DirectVulkan broke it by clamping the two families
// independently: a device reporting 256 compute uniform blocks and 84 uniform binding
// points passes both ceilings and still cannot serve
// KHR-GL44.multi_bind.dispatch_bind_buffers_base, which reads the block count and binds
// that many buffers in one glBindBuffersBase - INVALID_OPERATION before a single bind.
TEST_F(AdvertisedLimitsScenario, PerStageBlockCountsFitInTheirBindingPoints) {
struct Relation {
GLenum blocks;
const char* blocksName;
GLenum bindings;
const char* bindingsName;
};
const Relation relations[] = {
{GL_MAX_COMPUTE_UNIFORM_BLOCKS, "GL_MAX_COMPUTE_UNIFORM_BLOCKS", GL_MAX_UNIFORM_BUFFER_BINDINGS,
"GL_MAX_UNIFORM_BUFFER_BINDINGS"},
{GL_MAX_VERTEX_UNIFORM_BLOCKS, "GL_MAX_VERTEX_UNIFORM_BLOCKS", GL_MAX_UNIFORM_BUFFER_BINDINGS,
"GL_MAX_UNIFORM_BUFFER_BINDINGS"},
{GL_MAX_FRAGMENT_UNIFORM_BLOCKS, "GL_MAX_FRAGMENT_UNIFORM_BLOCKS", GL_MAX_UNIFORM_BUFFER_BINDINGS,
"GL_MAX_UNIFORM_BUFFER_BINDINGS"},
{GL_MAX_COMBINED_UNIFORM_BLOCKS, "GL_MAX_COMBINED_UNIFORM_BLOCKS", GL_MAX_UNIFORM_BUFFER_BINDINGS,
"GL_MAX_UNIFORM_BUFFER_BINDINGS"},
{GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, "GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS",
GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS, "GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS"},
{GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS, "GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS",
GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS, "GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS"},
};
for (const Relation& relation : relations) {
GLint blocks = -1;
GLint bindings = -1;
glGetIntegerv(relation.blocks, &blocks);
glGetIntegerv(relation.bindings, &bindings);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << relation.blocksName;
EXPECT_LE(blocks, bindings)
<< relation.blocksName << " = " << blocks << " exceeds " << relation.bindingsName << " = "
<< bindings << "; a shader may declare more blocks than there are binding points to bind them to";
}
}
// KHR-GL44.multi_bind.functional_bind_buffers_range sizes each of an indexed target's
// binding points at MAX_<target>_SIZE / MAX_<target>_BINDINGS and binds all of them in
// one glBindBuffersRange. That quotient has to be a legal BindBufferRange size, which
// makes the two limits of every indexed family a PAIR: advertise a size that does not
// survive division by the binding count and the call fails with INVALID_VALUE before any
// of it binds.
TEST_F(AdvertisedLimitsScenario, IndexedTargetSizeSurvivesDivisionByItsBindingCount) {
struct IndexedFamily {
GLenum maxSize;
const char* maxSizeName;
GLenum maxBindings;
const char* maxBindingsName;
GLint sizeGranularity; // BindBufferRange's size rule for the target
};
const IndexedFamily families[] = {
{GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE, "GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE",
GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS, "GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS", 1},
{GL_MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS, "GL_MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS",
GL_MAX_TRANSFORM_FEEDBACK_BUFFERS, "GL_MAX_TRANSFORM_FEEDBACK_BUFFERS", 4},
{GL_MAX_UNIFORM_BLOCK_SIZE, "GL_MAX_UNIFORM_BLOCK_SIZE", GL_MAX_UNIFORM_BUFFER_BINDINGS,
"GL_MAX_UNIFORM_BUFFER_BINDINGS", 1},
{GL_MAX_SHADER_STORAGE_BLOCK_SIZE, "GL_MAX_SHADER_STORAGE_BLOCK_SIZE",
GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS, "GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS", 1},
};
for (const IndexedFamily& family : families) {
GLint maxSize = -1;
GLint maxBindings = -1;
glGetIntegerv(family.maxSize, &maxSize);
glGetIntegerv(family.maxBindings, &maxBindings);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << family.maxSizeName;
ASSERT_GT(maxBindings, 0) << family.maxBindingsName;
const GLint perBinding = maxSize / maxBindings;
EXPECT_GT(perBinding, 0)
<< family.maxSizeName << " (" << maxSize << ") / " << family.maxBindingsName << " ("
<< maxBindings << ") is zero, and BindBufferRange rejects a zero size";
EXPECT_EQ(perBinding % family.sizeGranularity, 0)
<< family.maxSizeName << " (" << maxSize << ") / " << family.maxBindingsName << " ("
<< maxBindings << ") = " << perBinding << " is not a multiple of the "
<< family.sizeGranularity << "-byte size granularity BindBufferRange requires for it";
}
}
// The OOM case in isolation, because it is the one with a known CTS victim and the one a
// future refactor is most likely to reintroduce by copying the Vulkan limit back.
TEST_F(AdvertisedLimitsScenario, ComputeUniformBlocksIsAnAmountAnApplicationCouldActuallyAllocate) {
@@ -0,0 +1,302 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/BufferTextureScenario.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 - A BUFFER TEXTURE IS SAMPLED FROM THE VERTEX STAGE, AND TRACKS ITS BUFFER.
//
// Buffer textures are core in OpenGL 3.1 and MobileGL advertises a 4.x context, so an
// application may build geometry out of one without asking whether the host can. Minecraft
// 26.3 does exactly that: its cloud layer has no vertex attributes at all, only gl_VertexID
// and texelFetch on a GL_R8I buffer texture. Nothing covered that path end to end on either
// backend - the frontend unit tests stop at glTexBuffer's state, and no scenario ever drew
// with the result - which is how DirectGLES came to emit `#extension GL_EXT_texture_buffer :
// require` unconditionally, compile nothing on a host without the extension, and lose the
// whole cloud layer with no diagnostic anywhere.
//
// Two claims, in the order they can break:
// 1. a vertex-stage texelFetch on an R8I buffer texture reads the byte the application put
// in the buffer (the shape of the real workload: no attributes, index from gl_VertexID);
// 2. a later glBufferSubData is visible to the next draw WITHOUT re-specifying the texture.
// glTexBuffer attaches storage, it does not copy: the texture is a live view of the
// buffer, so a backend that only refreshes the view when the texture's own state changes
// must still show the new bytes. DirectGLES' respecify gate is keyed on the texture info
// and deliberately does not include the buffer's contents, so this is the assertion that
// says that is safe rather than merely untested.
//
// NOTE ON A HOST WITHOUT BUFFER TEXTURES: this scenario is expected to FAIL there, and that is
// the honest outcome - MobileGL keeps advertising GL_MAX_TEXTURE_BUFFER_SIZE (an OpenGL 4.x
// context may not answer 0), so there is no capability an application, or this test, could
// branch on. The driver POST's "Buffer textures" row is where that verdict is stated.
#include <cstdint>
#include <cstring>
#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 {
// No vertex attributes: the quad's corners come from gl_VertexID, exactly like the
// workload this exists for. The texel is fetched in the VERTEX stage - the stage where
// buffer-texture support is scarcest across ES drivers - and carried flat so every
// fragment of the quad reports the same byte and the readback is exact.
constexpr const char* kVS = R"(#version 330 core
uniform isamplerBuffer uFaces;
flat out int vFace;
void main() {
vec2 corner = vec2((gl_VertexID & 1) == 0 ? -1.0 : 1.0,
(gl_VertexID & 2) == 0 ? -1.0 : 1.0);
vFace = texelFetch(uFaces, 0).r;
gl_Position = vec4(corner, 0.0, 1.0);
}
)";
// 1/255 steps survive an RGBA8 round trip exactly, so the readback byte IS the value
// the vertex shader fetched.
constexpr const char* kFS = R"(#version 330 core
flat in int vFace;
out vec4 o_color;
void main() { o_color = vec4(float(vFace) / 255.0, 0.0, 0.0, 1.0); }
)";
// A buffer texture bound as a WRITABLE image: the shader reads one texel and writes
// another, so a single dispatch proves the read direction (which already worked) and
// the write direction (which is what this exists for) apart from each other.
constexpr const char* kImageBufferCS = R"(#version 430 core
layout(local_size_x = 1) in;
layout(binding = 0, rgba8) uniform imageBuffer uImage;
void main() {
vec4 read = imageLoad(uImage, 1);
imageStore(uImage, 0, vec4(0.0, 1.0, 0.0, 1.0));
imageStore(uImage, 2, read);
}
)";
class BufferTextureScenario : public ScenarioTest {
protected:
bool ComputeImagesAreUsable() const {
GLint maxImageUnits = 0;
GLint maxComputeImageUniforms = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
glGetIntegerv(GL_MAX_COMPUTE_IMAGE_UNIFORMS, &maxComputeImageUniforms);
while (glGetError() != GL_NO_ERROR) {
}
return maxImageUnits >= 1 && maxComputeImageUniforms >= 1;
}
unsigned int MakeComputeProgram(const char* source) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[4096] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute shader did not compile: " << log;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[4096] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute program did not link: " << log;
glDeleteProgram(program);
return 0;
}
return program;
}
};
// Draws the full-viewport quad and returns the red byte every fragment was painted with,
// or -1 if the quad did not come out uniform (which would mean the flat varying, not the
// fetch, is what this test is measuring).
int PaintedValue(unsigned int program, int width, int height) {
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glUseProgram(program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
const Image image = ReadPixels(width, height);
if (image.Empty()) {
return -1;
}
const int first = image.At(0, 0).r;
for (int y = 0; y < image.Height(); ++y) {
for (int x = 0; x < image.Width(); ++x) {
if (image.At(x, y).r != first) {
return -1;
}
}
}
return first;
}
} // namespace
TEST_F(BufferTextureScenario, VertexStageTexelFetchReadsTheBufferAndTracksItsUpdates) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
// GL_R8I is the format the real workload uses. Signed, so the values stay well inside
// [0, 127] to keep the readback arithmetic honest.
constexpr signed char kInitial = 37;
constexpr signed char kUpdated = 91;
std::vector<signed char> texels(64, 0);
texels[0] = kInitial;
// The harness shares one context across every scenario in the process, so an error left
// by an earlier one would surface below as "glTexBuffer was refused".
FirstGLError();
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_TEXTURE_BUFFER, buffer);
glBufferData(GL_TEXTURE_BUFFER, static_cast<GLsizeiptr>(texels.size()), texels.data(),
GL_DYNAMIC_DRAW);
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_BUFFER, texture);
glTexBuffer(GL_TEXTURE_BUFFER, GL_R8I, buffer);
ASSERT_EQ(FirstGLError(), 0u) << "glTexBuffer(GL_R8I) was refused";
ColorFbo target = MakeColorFbo(64, 64);
ASSERT_NE(target.fbo, 0u) << "could not create the render target";
BindFbo(target);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_BUFFER, texture);
glUseProgram(program);
const GLint location = glGetUniformLocation(program, "uFaces");
ASSERT_NE(location, -1) << "the buffer sampler was optimized away or never reflected";
glUniform1i(location, 0);
EXPECT_EQ(PaintedValue(program, target.width, target.height), static_cast<int>(kInitial))
<< "a vertex-stage texelFetch on an R8I buffer texture did not read the byte the "
"application stored (a uniform -1 here means the quad was not uniform at all)";
// The texture is a VIEW of the buffer: no glTexBuffer call follows, and none should be
// needed for the new bytes to be visible.
glBindBuffer(GL_TEXTURE_BUFFER, buffer);
glBufferSubData(GL_TEXTURE_BUFFER, 0, 1, &kUpdated);
ASSERT_EQ(FirstGLError(), 0u) << "glBufferSubData on the texture's buffer was refused";
EXPECT_EQ(PaintedValue(program, target.width, target.height), static_cast<int>(kUpdated))
<< "the buffer texture kept showing the old contents after glBufferSubData; the "
"texture must track its buffer without being re-specified";
BindDefaultFramebuffer();
DestroyColorFbo(target);
glUseProgram(0);
glDeleteProgram(program);
glDeleteTextures(1, &texture);
glDeleteBuffers(1, &buffer);
glViewport(0, 0, gl.Width(), gl.Height());
EXPECT_EQ(FirstGLError(), 0u);
}
// A shader may WRITE a buffer texture too, through an image unit, and the bytes it writes
// land in the backend's buffer - not in the frontend's CPU shadow, which is what MapBuffer
// and GetBufferSubData hand back. A storage-block write is flagged for exactly this reason
// and the shadow is refreshed on the next read; a buffer reached through an image unit is
// the same write through a different binding, and Espryt used to flag only the first, so
// an imageStore into a buffer texture was invisible to every CPU read that followed it -
// silently, with the correct value sitting in the driver's buffer the whole time.
//
// The read direction is asserted in the same dispatch (texel 2 is a copy of texel 1) so a
// failure here cannot be blamed on the image binding not working at all.
TEST_F(BufferTextureScenario, AnImageStoreIntoABufferTextureIsVisibleToTheCpu) {
if (!Ready()) return;
if (!ComputeImagesAreUsable()) GTEST_SKIP() << "no compute image units on this host";
constexpr GLuint kRed = 0x000000ffu; // RGBA8 little-endian: r = 255
constexpr GLuint kGreen = 0xff00ff00u; // what the shader stores: (0, 1, 0, 1)
constexpr int kTexels = 16;
FirstGLError();
const unsigned int program = MakeComputeProgram(kImageBufferCS);
ASSERT_NE(program, 0u);
const std::vector<GLuint> texels(kTexels, kRed);
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_TEXTURE_BUFFER, buffer);
glBufferData(GL_TEXTURE_BUFFER, static_cast<GLsizeiptr>(texels.size() * sizeof(GLuint)), texels.data(),
GL_DYNAMIC_COPY);
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_BUFFER, texture);
glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA8, buffer);
EXPECT_EQ(FirstGLError(), 0u) << "glTexBuffer(GL_RGBA8) was refused";
glBindImageTexture(0, texture, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA8);
EXPECT_EQ(FirstGLError(), 0u) << "glBindImageTexture on a buffer texture was refused";
glUseProgram(program);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
// Both CPU read paths, because they are two entry points onto the same refresh and a
// fix that reaches only one of them is not a fix. Everything below is EXPECT rather than
// ASSERT so that a failure still reaches the cleanup at the end: the harness shares one
// context across every scenario in the process, and a leaked buffer or image binding
// here would surface as a failure somewhere else entirely.
std::vector<GLuint> readBack(kTexels, 0u);
glBindBuffer(GL_TEXTURE_BUFFER, buffer);
glGetBufferSubData(GL_TEXTURE_BUFFER, 0, static_cast<GLsizeiptr>(readBack.size() * sizeof(GLuint)),
readBack.data());
EXPECT_EQ(readBack[0], kGreen) << "glGetBufferSubData did not see the imageStore";
EXPECT_EQ(readBack[2], kRed) << "the imageLoad side of the same dispatch read the wrong texel";
const void* mapped = glMapBuffer(GL_TEXTURE_BUFFER, GL_READ_ONLY);
EXPECT_NE(mapped, nullptr) << "glMapBuffer(GL_READ_ONLY) on the texture's buffer failed";
if (mapped != nullptr) {
GLuint mappedTexel0 = 0;
std::memcpy(&mappedTexel0, mapped, sizeof(mappedTexel0));
EXPECT_EQ(mappedTexel0, kGreen) << "glMapBuffer did not see the imageStore";
glUnmapBuffer(GL_TEXTURE_BUFFER);
}
glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
glBindBuffer(GL_TEXTURE_BUFFER, 0);
glBindTexture(GL_TEXTURE_BUFFER, 0);
glUseProgram(0);
glDeleteProgram(program);
glDeleteTextures(1, &texture);
glDeleteBuffers(1, &buffer);
EXPECT_EQ(FirstGLError(), 0u);
}
} // namespace MGITest
@@ -0,0 +1,388 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ClipDistanceScenario.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_ClipDistance ACTUALLY CLIPS, AND ONLY WHERE IT IS ENABLED.
//
// CapabilityInput::ClipDistance0..7 existed end to end - the GL enum converted to it, the
// string converter named it, glEnable(GL_CLIP_DISTANCE0 + i) raised no error - and then
// RenderState::SetCapability had no case for it and dropped it into `default: break`. Nothing
// was stored, no version was bumped, and neither backend ever heard about it. The shader half
// worked all along (SPIRV-Cross emits gl_ClipDistance with a
// `#extension GL_EXT_clip_cull_distance : require` that Adreno accepts), so the distances were
// computed and then ignored: no clipping ever happened on DirectGLES, which is the whole of
// KHR-GLxx.clip_distance.functional. glIsEnabled lied about it too - it returned GL_FALSE
// immediately after a successful glEnable.
//
// The assertions are behavioural, not query-shaped, because a query-only test passes against a
// backend that stores the bit and never forwards it. Each case draws one full-viewport triangle
// whose clip distance is positive on one side of the viewport and negative on the other, then
// checks BOTH sides: the kept side proves the draw happened at all, and the clipped side is the
// actual claim. The disabled case is the negative control - the identical shader with the
// identical distances and the enable turned off must leave both sides painted, which is what
// says the pixels below are being removed by clipping and not by something else.
//
// HONEST LIMIT OF THIS FILE IN CI. Of the four cases, only EnableIsObservableThroughIsEnabled is
// falsifiable on the software rasterizers every automated lane runs on. llvmpipe and lavapipe
// clip by EVERY declared gl_ClipDistance regardless of the enables, so
// AnEnabledClipDistanceRemovesTheNegativeHalf goes green there against the broken tree as well,
// and the two cases that need real per-distance semantics skip (see
// DriverHonoursPerDistanceEnables). What actually pins the behaviour is Adreno, through
// KHR-GLxx.clip_distance.functional - whose "without dynamic redeclaration" variants declare all
// gl_MaxClipDistances slots and enable only the first N, i.e. exactly the subset semantics these
// skipped cases assert. Read a green CI run here as "the state survives the frontend", not as
// "clipping is correct"; the second claim is a device claim.
//
// Every case disables all eight distances on entry rather than assuming they start off:
// XfbAfterClipDistanceScenario deliberately leaves one enabled for the rest of the process, and
// forwarding the enables is what turned that leftover from inert bookkeeping into live driver
// state.
#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
#ifndef GL_CLIP_DISTANCE0
#define GL_CLIP_DISTANCE0 0x3000
#endif
#ifndef GL_CLIP_DISTANCE1
#define GL_CLIP_DISTANCE1 0x3001
#endif
namespace MGITest {
namespace {
// One clip distance per half of the viewport: distance 0 is positive on the right half
// (x > 0 in clip space) and distance 1 is positive on the top half. A vertex shader
// producing a full-screen triangle from gl_VertexID, so no buffers are needed.
const char* const kVertexSource = R"(#version 400 core
out float gl_ClipDistance[2];
void main() {
vec2 positions[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
vec2 p = positions[gl_VertexID];
gl_Position = vec4(p, 0.0, 1.0);
gl_ClipDistance[0] = p.x;
gl_ClipDistance[1] = p.y;
}
)";
const char* const kFragmentSource = R"(#version 400 core
out vec4 fragColor;
void main() { fragColor = vec4(0.0, 1.0, 0.0, 1.0); }
)";
class ClipDistanceScenario : public ScenarioTest {
protected:
GLuint BuildProgram() {
const GLuint vs = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vs, 1, &kVertexSource, nullptr);
glCompileShader(vs);
GLint compiled = 0;
glGetShaderiv(vs, GL_COMPILE_STATUS, &compiled);
if (!compiled) {
m_buildLog = ShaderLog(vs);
glDeleteShader(vs);
return 0;
}
const GLuint fs = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fs, 1, &kFragmentSource, nullptr);
glCompileShader(fs);
glGetShaderiv(fs, GL_COMPILE_STATUS, &compiled);
if (!compiled) {
m_buildLog = ShaderLog(fs);
glDeleteShader(vs);
glDeleteShader(fs);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
glDeleteShader(vs);
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;
}
const std::string& BuildLog() const { return m_buildLog; }
// Paints the whole viewport red, then draws the clipped triangle in green.
void DrawClippedTriangle(GLuint program, GLuint vao) const {
glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(program);
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
}
static bool IsGreen(const unsigned char* px) {
return px[0] < 64 && px[1] > 192;
}
static bool IsRed(const unsigned char* px) {
return px[0] > 192 && px[1] < 64;
}
void PixelAt(int x, int y, unsigned char* out) const {
glReadPixels(x, y, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, out);
}
// Never assume the eight start disabled - see the header note about
// XfbAfterClipDistanceScenario leaving one on for the rest of the process.
static void DisableEveryClipDistance() {
for (int i = 0; i < 8; ++i) {
glDisable(static_cast<GLenum>(GL_CLIP_DISTANCE0 + i));
}
}
// True when the driver under this backend actually implements PER-DISTANCE enable
// state, i.e. when a written-but-disabled gl_ClipDistance leaves its fragments
// alone. Not every stack does, and the difference is not MobileGL's to hide:
//
// - Adreno's ES driver honours GL_CLIP_DISTANCE0_EXT..7_EXT, which is what makes
// KHR-GLxx.clip_distance.functional pass on the device once the enables are
// forwarded at all.
// - Vulkan has no such state: every clip distance a shader declares is active,
// always. DirectVulkan therefore clips by a disabled distance.
// - Mesa's llvmpipe ES driver behaves like Vulkan here.
//
// Emulating GL's semantics on those two would mean forcing the disabled slots to a
// non-negative value inside the shader, which makes the enable mask part of the
// pipeline key - a feature, not a fix, and deliberately not attempted here. The
// cases that need the real semantics gate on this probe and say so when they skip,
// rather than being deleted or silently weakened.
bool DriverHonoursPerDistanceEnables(GLuint program, GLuint vao) const {
for (int i = 0; i < 8; ++i) {
glDisable(static_cast<GLenum>(GL_CLIP_DISTANCE0 + i));
}
DrawClippedTriangle(program, vao);
unsigned char negativeSide[4] = {0, 0, 0, 0};
glReadPixels(Gl().Width() / 4, Gl().Height() / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, negativeSide);
return IsGreen(negativeSide);
}
private:
static std::string ShaderLog(GLuint 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());
return log.data();
}
std::string m_buildLog;
};
} // namespace
// The state itself: glEnable must be observable through glIsEnabled. This is the cheap half
// of the bug - SetCapability's missing case made the query answer GL_FALSE for a capability
// that had just been enabled without error.
TEST_F(ClipDistanceScenario, EnableIsObservableThroughIsEnabled) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
DisableEveryClipDistance();
EXPECT_EQ(glIsEnabled(GL_CLIP_DISTANCE0), GL_FALSE)
<< "glDisable(GL_CLIP_DISTANCE0) is not observable through glIsEnabled";
glEnable(GL_CLIP_DISTANCE0);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(glIsEnabled(GL_CLIP_DISTANCE0), GL_TRUE)
<< "glEnable(GL_CLIP_DISTANCE0) raised no error but glIsEnabled still reports it disabled";
EXPECT_EQ(glIsEnabled(GL_CLIP_DISTANCE1), GL_FALSE)
<< "enabling distance 0 must not enable distance 1 - the eight are independent";
glEnable(GL_CLIP_DISTANCE1);
glDisable(GL_CLIP_DISTANCE0);
EXPECT_EQ(glIsEnabled(GL_CLIP_DISTANCE0), GL_FALSE);
EXPECT_EQ(glIsEnabled(GL_CLIP_DISTANCE1), GL_TRUE);
glDisable(GL_CLIP_DISTANCE1);
EXPECT_EQ(FirstGLError(), 0u);
gl.EndFrame();
}
// The claim: an enabled clip distance removes the fragments where it is negative.
TEST_F(ClipDistanceScenario, AnEnabledClipDistanceRemovesTheNegativeHalf) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(width, 8);
ASSERT_GE(height, 8);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
const GLuint program = BuildProgram();
ASSERT_NE(program, 0u) << "the gl_ClipDistance program did not build: " << BuildLog();
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glDisable(GL_CULL_FACE);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
// Distance 1 is positive by a single pixel at the sampled row, so a stray enable on it
// would put the "kept" probe right on the clip boundary.
DisableEveryClipDistance();
glEnable(GL_CLIP_DISTANCE0);
DrawClippedTriangle(program, vao);
EXPECT_EQ(FirstGLError(), 0u);
unsigned char right[4] = {0, 0, 0, 0};
unsigned char left[4] = {0, 0, 0, 0};
PixelAt(width - 1 - width / 4, height / 2, right);
PixelAt(width / 4, height / 2, left);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_TRUE(IsGreen(right)) << "the kept half is not painted (" << int(right[0]) << "," << int(right[1])
<< "," << int(right[2]) << ") - the draw itself did not happen, so the clipped "
"half below proves nothing";
EXPECT_TRUE(IsRed(left)) << "gl_ClipDistance[0] is negative on the left half and GL_CLIP_DISTANCE0 is "
"enabled, so those fragments must be clipped away; found ("
<< int(left[0]) << "," << int(left[1]) << "," << int(left[2]) << ")";
glDisable(GL_CLIP_DISTANCE0);
glUseProgram(0);
glBindVertexArray(0);
glDeleteProgram(program);
glDeleteVertexArrays(1, &vao);
gl.EndFrame();
}
// The negative control: the same shader writing the same distances, with the enable off,
// must paint both halves. Without this a backend that clipped everything - or one whose
// draw simply failed - would pass the case above.
TEST_F(ClipDistanceScenario, ADisabledClipDistanceRemovesNothing) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(width, 8);
ASSERT_GE(height, 8);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
const GLuint program = BuildProgram();
ASSERT_NE(program, 0u) << "the gl_ClipDistance program did not build: " << BuildLog();
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glDisable(GL_CULL_FACE);
DisableEveryClipDistance();
DrawClippedTriangle(program, vao);
EXPECT_EQ(FirstGLError(), 0u);
unsigned char right[4] = {0, 0, 0, 0};
unsigned char left[4] = {0, 0, 0, 0};
PixelAt(width - 1 - width / 4, height / 2, right);
PixelAt(width / 4, height / 2, left);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_TRUE(IsGreen(right)) << "with every clip distance disabled the whole triangle must survive";
const bool driverHonoursEnables = IsGreen(left);
glUseProgram(0);
glBindVertexArray(0);
glDeleteProgram(program);
glDeleteVertexArrays(1, &vao);
gl.EndFrame();
if (!driverHonoursEnables) {
GTEST_SKIP() << "renderer " << gl.RendererString()
<< " clips by a DISABLED gl_ClipDistance - it does not implement per-distance enable state "
"(see DriverHonoursPerDistanceEnables). Emulating GL's semantics there needs shader-side "
"masking keyed on the enable mask, which is a separate feature";
}
}
// The eight enables are independent: enabling only distance 1 must clip by distance 1 and
// leave distance 0 alone. A backend that forwarded "any clip distance enabled" as a single
// bit, or that always enables every declared distance (which is what Vulkan does natively),
// passes both cases above and fails this one.
TEST_F(ClipDistanceScenario, TheEnablesAreIndependentPerDistance) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(width, 8);
ASSERT_GE(height, 8);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
const GLuint program = BuildProgram();
ASSERT_NE(program, 0u) << "the gl_ClipDistance program did not build: " << BuildLog();
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glDisable(GL_CULL_FACE);
if (!DriverHonoursPerDistanceEnables(program, vao)) {
glUseProgram(0);
glBindVertexArray(0);
glDeleteProgram(program);
glDeleteVertexArrays(1, &vao);
DisableEveryClipDistance();
gl.EndFrame();
GTEST_SKIP() << "renderer " << gl.RendererString()
<< " clips by every declared gl_ClipDistance regardless of the enables, so per-distance "
"independence is not observable here";
}
DisableEveryClipDistance();
glEnable(GL_CLIP_DISTANCE1);
DrawClippedTriangle(program, vao);
EXPECT_EQ(FirstGLError(), 0u);
// Distance 1 is negative on the bottom half, distance 0 on the left half. With only
// distance 1 enabled, the bottom-left must survive (distance 0 is off) and the bottom
// must not.
unsigned char topLeft[4] = {0, 0, 0, 0};
unsigned char bottomRight[4] = {0, 0, 0, 0};
PixelAt(width / 4, height - 1 - height / 4, topLeft);
PixelAt(width - 1 - width / 4, height / 4, bottomRight);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_TRUE(IsGreen(topLeft)) << "gl_ClipDistance[0] is negative here but GL_CLIP_DISTANCE0 is disabled, so "
"this fragment must survive";
EXPECT_TRUE(IsRed(bottomRight)) << "gl_ClipDistance[1] is negative here and GL_CLIP_DISTANCE1 is enabled, so "
"this fragment must be clipped";
glDisable(GL_CLIP_DISTANCE1);
glUseProgram(0);
glBindVertexArray(0);
glDeleteProgram(program);
glDeleteVertexArrays(1, &vao);
gl.EndFrame();
}
} // namespace MGITest
@@ -0,0 +1,331 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CopyImageLayeredScenario.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 - glCopyImageSubData MOVES EVERY SLICE IT WAS ASKED FOR, NOT JUST SLICE 0.
//
// KHR-GL43.copy_image.functional_* copies a whole 12-layer region in one call whenever both
// endpoints are layered, i.e. for the four target pairs 2d_array->2d_array, 2d_array->3d,
// 3d->2d_array and 3d->3d. DirectVulkan built its VkImageCopy with baseArrayLayer 0, layerCount 1
// and srcOffset.z 0 no matter what the call asked for, so slice 0 landed correctly and slices 1..N
// were never written - 64 conformance cases (16 compatible format pairs x those 4 pairs) failing
// with "first mismatch at [x, y, 1]", the first texel of the first slice the copy skipped.
//
// The reason one hardcode covered both shapes wrongly is that GL states a layered copy ONE way -
// srcZ/dstZ and srcDepth - while Vulkan states it two ways and picks by image type:
//
// GL_TEXTURE_3D -> VK_IMAGE_TYPE_3D: slices are z, so srcOffset.z/dstOffset.z select them
// and extent.depth counts them; the layer range must stay (0, 1).
// GL_TEXTURE_2D_ARRAY -> VK_IMAGE_TYPE_2D: slices are array layers, so baseArrayLayer selects
// them and layerCount counts them; offset.z stays 0.
//
// A mixed pair is legal (maintenance1, core in Vulkan 1.1) but only when the counts correspond:
// the 3D side's extent.depth has to equal the array side's layerCount. So the four pairs below are
// four DIFFERENT VkImageCopy shapes, not one shape with different arguments, which is why one
// scenario per pair is the coverage that matters here.
//
// Every case also asserts the slices OUTSIDE the copied range still hold their fill. A backend
// that "fixed" the miss by copying the whole image regardless of srcZ/srcDepth would pass a
// slices-landed check and fail this one.
//
// The verification path is an FBO attachment per slice plus glReadPixels, not glGetTexImage: it is
// the readback both backends share, and glFramebufferTextureLayer names an array layer and a 3D
// slice through the same call, so the two texture kinds are read back identically.
//
// DirectGLES is the control - it forwards to the driver's own glCopyImageSubData - so a failure on
// both backends means the scenario is wrong, and a failure on DirectVulkan alone means Magma is.
#include <array>
#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 kWidth = 4;
constexpr int kHeight = 4;
// Six is enough for a copy that starts and ends away from both edges of both endpoints
// while still leaving untouched slices on either side to assert against.
constexpr int kSlices = 6;
struct Rgba8 {
GLubyte r = 0, g = 0, b = 0, a = 0;
bool operator==(const Rgba8& other) const {
return r == other.r && g == other.g && b == other.b && a == other.a;
}
};
std::string Describe(const Rgba8& color) {
return "(" + std::to_string(color.r) + ", " + std::to_string(color.g) + ", " + std::to_string(color.b) +
", " + std::to_string(color.a) + ")";
}
// Per-slice constants, uniform within a slice. A uniform fill is deliberate: the defect is
// in which SLICE the copy addresses, and a value that also varied within the slice would
// make the assertions depend on the framebuffer row order as well.
Rgba8 SourceColor(int slice) {
return {static_cast<GLubyte>(10 + slice * 20), static_cast<GLubyte>(40 + slice * 10),
static_cast<GLubyte>(200 - slice * 15), 255};
}
Rgba8 DestinationFill(int slice) {
return {static_cast<GLubyte>(3 + slice), static_cast<GLubyte>(250 - slice * 7),
static_cast<GLubyte>(120 + slice * 5), 255};
}
class CopyImageLayeredScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
if (!CopyImageSubDataUsable()) {
GTEST_SKIP() << "glCopyImageSubData is unavailable on backend " << Gl().BackendName();
}
}
void TearDown() override {
if (!Ready()) return;
for (const GLuint texture : m_textures) {
glDeleteTextures(1, &texture);
}
m_textures.clear();
if (m_fbo != 0) {
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &m_fbo);
m_fbo = 0;
}
}
// A trivial 1x1x1 array-to-array copy: it exercises the entry point without depending
// on any of the behaviour under test, so a driver (or a backend function table) that
// simply does not have the call skips instead of failing every case below.
bool CopyImageSubDataUsable() {
GLuint probe[2] = {0, 0};
glGenTextures(2, probe);
for (const GLuint texture : probe) {
glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, 1, 1, 1);
}
glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
while (glGetError() != GL_NO_ERROR) {
}
glCopyImageSubData(probe[0], GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, probe[1], GL_TEXTURE_2D_ARRAY, 0, 0, 0,
0, 1, 1, 1);
const bool usable = glGetError() == GL_NO_ERROR;
glDeleteTextures(2, probe);
return usable;
}
// `target` is GL_TEXTURE_2D_ARRAY or GL_TEXTURE_3D; both take glTexStorage3D and
// glTexSubImage3D with the slice on the same axis, which is the whole reason GL can
// copy between them. `levels` > 1 puts a real mip chain behind the level the copy
// names, so the level's own extent - a 3D level's depth included - has to be resolved
// rather than assumed to be the image's.
GLuint MakeTexture(GLenum target, int levels, Rgba8 (*colorForSlice)(int)) {
GLuint texture = 0;
glGenTextures(1, &texture);
m_textures.push_back(texture);
glBindTexture(target, texture);
glTexStorage3D(target, levels, GL_RGBA8, kWidth << (levels - 1), kHeight << (levels - 1),
target == GL_TEXTURE_3D ? (kSlices << (levels - 1)) : kSlices);
glTexParameteri(target, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(target, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
// Fill every level, so nothing below can pass by reading a level that was never
// written and happened to hold the expected bytes.
for (int level = 0; level < levels; ++level) {
const int levelWidth = kWidth << (levels - 1 - level);
const int levelHeight = kHeight << (levels - 1 - level);
const int levelSlices =
target == GL_TEXTURE_3D ? (kSlices << (levels - 1 - level)) : kSlices;
for (int slice = 0; slice < levelSlices; ++slice) {
const Rgba8 color = colorForSlice(slice % kSlices);
std::vector<Rgba8> texels(static_cast<size_t>(levelWidth) * levelHeight, color);
glTexSubImage3D(target, level, 0, 0, slice, levelWidth, levelHeight, 1, GL_RGBA,
GL_UNSIGNED_BYTE, texels.data());
}
}
glBindTexture(target, 0);
return texture;
}
// One slice of one level, through an FBO attachment. glFramebufferTextureLayer takes an
// array layer and a 3D slice through the same argument, so both targets read back the
// same way.
Rgba8 ReadSlice(GLuint texture, int level, int slice, int width, int height) {
if (m_fbo == 0) {
glGenFramebuffers(1, &m_fbo);
}
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, level, slice);
EXPECT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
<< "slice " << slice << " of level " << level << " is not attachable";
std::vector<Rgba8> pixels(static_cast<size_t>(width) * height, Rgba8{});
glReadBuffer(GL_COLOR_ATTACHMENT0);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// The fill is uniform within a slice, so any disagreement between texels is itself
// a failure - reported here rather than silently reduced to pixels[0].
for (size_t i = 1; i < pixels.size(); ++i) {
EXPECT_TRUE(pixels[i] == pixels[0])
<< "slice " << slice << " of level " << level << " is not uniform: texel 0 is "
<< Describe(pixels[0]) << ", texel " << i << " is " << Describe(pixels[i]);
}
return pixels[0];
}
// The assertion every case ends with: slices inside [dstZ, dstZ + depth) hold the
// source slice they were fed, and every slice outside it still holds its own fill.
void ExpectCopied(GLuint destination, int level, int width, int height, int sliceCount, int srcZ,
int dstZ, int depth, const char* what) {
for (int slice = 0; slice < sliceCount; ++slice) {
const bool inRange = slice >= dstZ && slice < dstZ + depth;
const Rgba8 expected =
inRange ? SourceColor(srcZ + (slice - dstZ)) : DestinationFill(slice);
const Rgba8 actual = ReadSlice(destination, level, slice, width, height);
EXPECT_TRUE(actual == expected)
<< what << ": destination slice " << slice << (inRange ? " (copied)" : " (untouched)")
<< " is " << Describe(actual) << ", expected " << Describe(expected);
}
}
std::vector<GLuint> m_textures;
GLuint m_fbo = 0;
};
// 2d_array -> 2d_array. Both endpoints put the slices on the layer axis, so BOTH layer
// counts carry the depth and extent.depth must stay 1.
TEST_F(CopyImageLayeredScenario, ArrayToArrayCopiesEverySlice) {
if (!Ready() || IsSkipped()) return;
const GLuint source = MakeTexture(GL_TEXTURE_2D_ARRAY, 1, SourceColor);
const GLuint destination = MakeTexture(GL_TEXTURE_2D_ARRAY, 1, DestinationFill);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "texture setup failed";
glCopyImageSubData(source, GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, destination, GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0,
kWidth, kHeight, kSlices);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "glCopyImageSubData raised an error";
ExpectCopied(destination, 0, kWidth, kHeight, kSlices, 0, 0, kSlices, "array->array, all slices");
}
// The same pair with the layer ranges offset differently on the two sides: the shape that
// separates "copies more than slice 0" from "copies the RIGHT slices". A backend that read
// the source range but wrote from layer 0 (or vice versa) passes the case above.
TEST_F(CopyImageLayeredScenario, ArrayToArrayHonoursDifferentLayerOffsets) {
if (!Ready() || IsSkipped()) return;
const GLuint source = MakeTexture(GL_TEXTURE_2D_ARRAY, 1, SourceColor);
const GLuint destination = MakeTexture(GL_TEXTURE_2D_ARRAY, 1, DestinationFill);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "texture setup failed";
constexpr int kSrcZ = 3;
constexpr int kDstZ = 1;
constexpr int kDepth = 2;
glCopyImageSubData(source, GL_TEXTURE_2D_ARRAY, 0, 0, 0, kSrcZ, destination, GL_TEXTURE_2D_ARRAY, 0, 0, 0,
kDstZ, kWidth, kHeight, kDepth);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "glCopyImageSubData raised an error";
ExpectCopied(destination, 0, kWidth, kHeight, kSlices, kSrcZ, kDstZ, kDepth,
"array->array, offset layer ranges");
}
// 3d -> 3d. Neither endpoint has array layers at all: the depth travels on extent.depth and
// the offsets on srcOffset.z/dstOffset.z, with both layer counts pinned to 1.
TEST_F(CopyImageLayeredScenario, VolumeToVolumeHonoursNonZeroZ) {
if (!Ready() || IsSkipped()) return;
const GLuint source = MakeTexture(GL_TEXTURE_3D, 1, SourceColor);
const GLuint destination = MakeTexture(GL_TEXTURE_3D, 1, DestinationFill);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "texture setup failed";
constexpr int kSrcZ = 1;
constexpr int kDstZ = 3;
constexpr int kDepth = 3;
glCopyImageSubData(source, GL_TEXTURE_3D, 0, 0, 0, kSrcZ, destination, GL_TEXTURE_3D, 0, 0, 0, kDstZ,
kWidth, kHeight, kDepth);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "glCopyImageSubData raised an error";
ExpectCopied(destination, 0, kWidth, kHeight, kSlices, kSrcZ, kDstZ, kDepth, "3d->3d, non-zero z");
}
// The same pair one mip level down. A 3D level's DEPTH halves with its width and height, so
// this is the only case where the slice count the copy may name is not the image's own -
// the bound a layered endpoint is checked against has to come from the level.
TEST_F(CopyImageLayeredScenario, VolumeToVolumeAtNonZeroMipLevel) {
if (!Ready() || IsSkipped()) return;
const GLuint source = MakeTexture(GL_TEXTURE_3D, 2, SourceColor);
const GLuint destination = MakeTexture(GL_TEXTURE_3D, 2, DestinationFill);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "texture setup failed";
constexpr int kLevel = 1;
constexpr int kSrcZ = 2;
constexpr int kDstZ = 0;
constexpr int kDepth = 4;
glCopyImageSubData(source, GL_TEXTURE_3D, kLevel, 0, 0, kSrcZ, destination, GL_TEXTURE_3D, kLevel, 0, 0,
kDstZ, kWidth, kHeight, kDepth);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "glCopyImageSubData raised an error";
ExpectCopied(destination, kLevel, kWidth, kHeight, kSlices, kSrcZ, kDstZ, kDepth,
"3d->3d at mip level 1");
}
// 2d_array -> 3d. The mixed shape: the source counts its slices as layers, the destination
// as depth, and Vulkan requires extent.depth to equal the source's layerCount.
TEST_F(CopyImageLayeredScenario, ArrayToVolumeCopiesEverySlice) {
if (!Ready() || IsSkipped()) return;
const GLuint source = MakeTexture(GL_TEXTURE_2D_ARRAY, 1, SourceColor);
const GLuint destination = MakeTexture(GL_TEXTURE_3D, 1, DestinationFill);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "texture setup failed";
constexpr int kSrcZ = 2;
constexpr int kDstZ = 1;
constexpr int kDepth = 4;
glCopyImageSubData(source, GL_TEXTURE_2D_ARRAY, 0, 0, 0, kSrcZ, destination, GL_TEXTURE_3D, 0, 0, 0, kDstZ,
kWidth, kHeight, kDepth);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "glCopyImageSubData raised an error";
ExpectCopied(destination, 0, kWidth, kHeight, kSlices, kSrcZ, kDstZ, kDepth, "2d_array->3d");
}
// 3d -> 2d_array, the mirror image: the depth now has to reach the DESTINATION's layerCount
// while the source states it as extent.depth from a z offset.
TEST_F(CopyImageLayeredScenario, VolumeToArrayCopiesEverySlice) {
if (!Ready() || IsSkipped()) return;
const GLuint source = MakeTexture(GL_TEXTURE_3D, 1, SourceColor);
const GLuint destination = MakeTexture(GL_TEXTURE_2D_ARRAY, 1, DestinationFill);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "texture setup failed";
constexpr int kSrcZ = 1;
constexpr int kDstZ = 2;
constexpr int kDepth = 4;
glCopyImageSubData(source, GL_TEXTURE_3D, 0, 0, 0, kSrcZ, destination, GL_TEXTURE_2D_ARRAY, 0, 0, 0, kDstZ,
kWidth, kHeight, kDepth);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "glCopyImageSubData raised an error";
ExpectCopied(destination, 0, kWidth, kHeight, kSlices, kSrcZ, kDstZ, kDepth, "3d->2d_array");
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,209 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CopyImageLevelRangeScenario.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
//
// KHR-GL43.copy_image.non_existent_mipmap, and what it cost.
//
// The CTS case is a pure negative test: two 16x16 textures that have level 0 and
// nothing else, and a glCopyImageSubData naming level 1. The answer is
// GL_INVALID_VALUE (GL 4.6 core 18.3.2 / ARB_copy_image: "srcLevel/dstLevel is not
// a valid level"). MobileGL's frontend only checked the level against
// GL_MAX_TEXTURE_SIZE, so level 1 sailed through into the backends, DirectVulkan
// resolved it into a VkImageCopy subresource on a VkImage that was created with
// exactly one mip level, and the Adreno driver dereferenced the level it was
// promised - SIGSEGV inside vkCmdCopyImage, taking the whole glcts process down
// mid-run. A negative case must never do that.
//
// So the level-1-on-a-one-level-texture rejection is the regression proper, and the
// rest of this file is what keeps the fix honest. A validator that answered
// GL_INVALID_VALUE to every level would satisfy the regression tests alone, so the
// scenarios below pin the BOUNDARY rather than the symptom:
//
// * a texture that really does have two levels must accept a copy at level 1,
// * the same texture must still reject level 2,
// * and a plain level-0 copy must move pixels, which is checked by reading the
// destination back rather than by trusting glGetError.
//
// Both backends are covered because the fix is in the shared frontend: DirectGLES
// forwards to the ES glCopyImageSubData (whose own error lands in the ES context,
// not in MobileGL's, so it never reached the application either) and DirectVulkan
// records the copy itself.
#include <array>
#include <cstring>
#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 GLsizei kSize = 16;
struct Rgba8 {
GLubyte r, g, b, a;
bool operator==(const Rgba8& other) const {
return r == other.r && g == other.g && b == other.b && a == other.a;
}
};
std::vector<Rgba8> SolidImage(GLsizei width, GLsizei height, Rgba8 color) {
return std::vector<Rgba8>(static_cast<std::size_t>(width) * static_cast<std::size_t>(height), color);
}
class CopyImageLevelRangeScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
DrainErrors();
}
void TearDown() override {
if (!Ready()) return;
DeleteTextures();
if (m_fbo != 0) {
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &m_fbo);
m_fbo = 0;
}
DrainErrors();
ScenarioTest::TearDown();
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
void DeleteTextures() {
if (m_src != 0) glDeleteTextures(1, &m_src);
if (m_dst != 0) glDeleteTextures(1, &m_dst);
m_src = 0;
m_dst = 0;
}
// One 16x16 RGBA8 texture with `levelCount` levels defined through
// glTexImage2D - the same way the CTS case builds its textures, and
// deliberately NOT glTexStorage2D: an immutable allocation would define the
// whole chain up front and could not express "level 1 does not exist".
GLuint MakeTexture(int levelCount, Rgba8 baseColor) {
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
for (int level = 0; level < levelCount; ++level) {
const GLsizei extent = kSize >> level;
const std::vector<Rgba8> pixels = SolidImage(extent, extent, baseColor);
glTexImage2D(GL_TEXTURE_2D, level, GL_RGBA8, extent, extent, 0, GL_RGBA, GL_UNSIGNED_BYTE,
pixels.data());
}
// What Utils::makeTextureComplete does in the CTS case: the texture is
// complete for the levels it actually has, not for a chain it does not.
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, levelCount - 1);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glBindTexture(GL_TEXTURE_2D, 0);
return texture;
}
void MakePair(int levelCount) {
DeleteTextures();
m_src = MakeTexture(levelCount, Rgba8{11, 22, 33, 255});
m_dst = MakeTexture(levelCount, Rgba8{200, 100, 50, 255});
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "texture setup with " << levelCount << " level(s)";
}
// The call under test, at whatever levels the caller wants, over a 1x1
// region so the region check can never be what rejects it.
GLenum CopyAt(GLint srcLevel, GLint dstLevel, GLsizei extent = 1) {
DrainErrors();
glCopyImageSubData(m_src, GL_TEXTURE_2D, srcLevel, 0, 0, 0, m_dst, GL_TEXTURE_2D, dstLevel, 0, 0, 0,
extent, extent, 1);
const GLenum error = glGetError();
// A second pending error would mean the entry point queued more than one,
// and the extra would be handed out at an unrelated call site later.
EXPECT_EQ(glGetError(), GL_NO_ERROR) << "the copy recorded more than one error";
return error;
}
Rgba8 ReadBackDestinationLevel0() {
if (m_fbo == 0) glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_dst, 0);
const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) {
ADD_FAILURE() << "readback framebuffer incomplete: " << status;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
return Rgba8{0, 0, 0, 0};
}
Rgba8 texel{0, 0, 0, 0};
glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, &texel);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
return texel;
}
GLuint m_src = 0;
GLuint m_dst = 0;
GLuint m_fbo = 0;
};
// The regression. Level 1 of a texture that has only level 0 is not a level, and
// saying so is the whole job: before the fix this reached DirectVulkan, which
// handed mipLevel=1 to vkCmdCopyImage on a one-level VkImage and died inside the
// Adreno driver.
TEST_F(CopyImageLevelRangeScenario, LevelOneOfASingleLevelTextureIsRejected) {
if (!Ready()) GTEST_SKIP();
MakePair(1);
EXPECT_EQ(CopyAt(1, 0), static_cast<GLenum>(GL_INVALID_VALUE)) << "source level 1";
EXPECT_EQ(CopyAt(0, 1), static_cast<GLenum>(GL_INVALID_VALUE)) << "destination level 1";
EXPECT_EQ(CopyAt(1, 1), static_cast<GLenum>(GL_INVALID_VALUE)) << "both levels 1";
}
// The negative control that makes the test above falsifiable: the same level
// index, on textures that genuinely have it, must be accepted. A validator that
// rejected every non-zero level would pass the regression test and fail here.
TEST_F(CopyImageLevelRangeScenario, LevelOneOfATwoLevelTextureIsAccepted) {
if (!Ready()) GTEST_SKIP();
MakePair(2);
EXPECT_EQ(CopyAt(1, 1), static_cast<GLenum>(GL_NO_ERROR));
}
// And the boundary from the other side: two levels means 0 and 1, not 2.
TEST_F(CopyImageLevelRangeScenario, LevelTwoOfATwoLevelTextureIsRejected) {
if (!Ready()) GTEST_SKIP();
MakePair(2);
EXPECT_EQ(CopyAt(2, 0), static_cast<GLenum>(GL_INVALID_VALUE)) << "source level 2";
EXPECT_EQ(CopyAt(0, 2), static_cast<GLenum>(GL_INVALID_VALUE)) << "destination level 2";
}
// Errors alone cannot tell an accepted copy from a silently dropped one, so the
// ordinary case is checked by reading the destination back: the copy has to move
// the source's texel, not merely decline to complain.
TEST_F(CopyImageLevelRangeScenario, AValidLevelZeroCopyStillMovesPixels) {
if (!Ready()) GTEST_SKIP();
MakePair(1);
ASSERT_EQ(ReadBackDestinationLevel0(), (Rgba8{200, 100, 50, 255})) << "destination before the copy";
EXPECT_EQ(CopyAt(0, 0, kSize), static_cast<GLenum>(GL_NO_ERROR));
EXPECT_EQ(ReadBackDestinationLevel0(), (Rgba8{11, 22, 33, 255})) << "destination after the copy";
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,370 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackAttachmentShapeScenario.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 - DEPTH/STENCIL READBACK WHEN THE ATTACHMENT IS NOT A PLAIN GL_TEXTURE_2D,
// AND THE DEFAULT FRAMEBUFFER'S ADVERTISED DEPTH/STENCIL FORMAT.
//
// Three shipped defects, all of them invisible to a test that only ever attaches a 2D texture
// or only ever asks the default framebuffer for a colour value.
//
// (1) The ES depth/stencil readback emulation identifies the source format by binding the
// attachment's texture NAME to GL_TEXTURE_2D and asking that target for its internal
// format. A name whose target is GL_TEXTURE_2D_ARRAY (attached by
// glFramebufferTextureLayer) makes the bind answer GL_INVALID_OPERATION and change
// nothing - so the query then truthfully describes whatever texture was already on
// GL_TEXTURE_2D, which on that path is the emulation's own staging scratch. A wrong
// answer that looks like a right one: the staging blit is issued between mismatched
// depth formats, ES rejects it, and the read reports nothing at all.
//
// (2) Adreno answers GL_NONE for GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE on an attachment made
// by glFramebufferTexture (a cube map, attached layered) while still reporting its depth
// and stencil bits correctly. The emulation took OBJECT_TYPE as the sole witness for "is
// there an aspect here at all" and declined the whole read.
//
// (3) DirectGLES never told the frontend what its default framebuffer's depth/stencil format
// actually is, so the placeholder from MG_Impl/Init.cpp - GL_DEPTH32F_STENCIL8 - was what
// every attachment query answered, whatever the surface really had. That is not cosmetic:
// GL blits depth/stencil only between IDENTICAL formats, so an application that reads
// GL_FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE, allocates the buffer it was just told about and
// blits gets GL_INVALID_OPERATION - and a rejected glBlitFramebuffer transfers NOTHING,
// colour bits included. DirectVulkan has published its real format since the swapchain
// work; this is the half that was missing.
//
// Every case poisons its destination with a value the correct answer cannot be, so "the
// backend wrote nothing" fails loudly instead of passing on stale memory. The plain
// GL_TEXTURE_2D case at the end is the built-in control: it shares every line of the readback
// path with the array and cube cases, so its passing is what says a failure above is about the
// attachment's SHAPE and not about depth readback in general.
//
// The scenario name starts with DepthStencilReadback on purpose - that is the filter the
// forced-emulation ctest registration uses (MG_IntegrationTest/CMakeLists.txt), and without
// that registration these cases are unfalsifiable on llvmpipe, which accepts the native ES
// depth reads that the Adreno device does not have.
#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 float kDepthPoison = 0.2f;
constexpr int kStencilPoison = 50;
constexpr float kDepthValue = 0.75f;
constexpr int kStencilValue = 7;
constexpr int kSize = 16;
class DepthStencilReadbackAttachmentShapeScenario : public ScenarioTest {
protected:
float ReadDepthAt(int x, int y) const {
float depth = kDepthPoison;
glReadPixels(x, y, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, &depth);
return depth;
}
int ReadStencilAt(int x, int y) const {
int stencil = kStencilPoison;
glReadPixels(x, y, 1, 1, GL_STENCIL_INDEX, GL_INT, &stencil);
return stencil;
}
// Clears the currently bound framebuffer's depth and stencil to the shared
// reference values, with both write masks explicitly open (glClear honours them,
// and a leftover mask from another scenario in this shared context would look
// exactly like the bug under test).
void ClearDepthStencil() const {
glDepthMask(GL_TRUE);
glStencilMask(0xFFu);
glDisable(GL_SCISSOR_TEST);
glClearDepth(kDepthValue);
glClearStencil(kStencilValue);
glClear(GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
}
};
// Fails the calling test if the framebuffer bound at both targets is not complete;
// an incomplete framebuffer would make every read below return the poison for a
// reason that has nothing to do with what is being tested.
::testing::AssertionResult FramebufferIsComplete() {
const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status == GL_FRAMEBUFFER_COMPLETE) return ::testing::AssertionSuccess();
return ::testing::AssertionFailure() << "framebuffer status 0x" << std::hex << status;
}
} // namespace
// (1) A depth slice of a 2D ARRAY texture, attached with glFramebufferTextureLayer.
// Pre-fix this read back the poison: the format probe answered with the staging scratch's
// GL_DEPTH24_STENCIL8 instead of the array's GL_DEPTH_COMPONENT24, and the mismatched
// staging blit was rejected.
TEST_F(DepthStencilReadbackAttachmentShapeScenario, DepthOfAnArrayLayerAttachmentReadsBack) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
GLuint fbo = 0;
GLuint depthArray = 0;
glGenFramebuffers(1, &fbo);
glGenTextures(1, &depthArray);
glBindTexture(GL_TEXTURE_2D_ARRAY, depthArray);
glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_DEPTH_COMPONENT24, kSize, kSize, 4);
glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
// Layer 2, not layer 0: a backend that silently reads the wrong slice would still
// agree with a single-layer texture.
glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthArray, 0, 2);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
EXPECT_EQ(FirstGLError(), 0u);
ASSERT_TRUE(FramebufferIsComplete());
glViewport(0, 0, kSize, kSize);
ClearDepthStencil();
const float depth = ReadDepthAt(kSize / 2, kSize / 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_NEAR(depth, kDepthValue, 1.0f / 4096.0f)
<< "glReadPixels(GL_DEPTH_COMPONENT) of a GL_TEXTURE_2D_ARRAY layer attachment returned " << depth
<< (std::fabs(depth - kDepthPoison) < 1e-6f ? " - the destination was never written at all" : "");
BindDefaultFramebuffer();
glDeleteFramebuffers(1, &fbo);
glDeleteTextures(1, &depthArray);
gl.EndFrame();
}
// (2) A depth cube map, attached whole with glFramebufferTexture - a LAYERED attachment.
// Pre-fix the emulation declined outright, because the driver reports GL_NONE for that
// attachment's OBJECT_TYPE.
TEST_F(DepthStencilReadbackAttachmentShapeScenario, DepthOfALayeredCubeAttachmentReadsBack) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
GLuint fbo = 0;
GLuint depthCube = 0;
glGenFramebuffers(1, &fbo);
glGenTextures(1, &depthCube);
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCube);
glTexStorage2D(GL_TEXTURE_CUBE_MAP, 1, GL_DEPTH_COMPONENT24, kSize, kSize);
glBindTexture(GL_TEXTURE_CUBE_MAP, 0);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCube, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
EXPECT_EQ(FirstGLError(), 0u);
ASSERT_TRUE(FramebufferIsComplete());
glViewport(0, 0, kSize, kSize);
ClearDepthStencil();
const float depth = ReadDepthAt(kSize / 2, kSize / 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_NEAR(depth, kDepthValue, 1.0f / 4096.0f)
<< "glReadPixels(GL_DEPTH_COMPONENT) of a layered GL_TEXTURE_CUBE_MAP attachment returned " << depth
<< (std::fabs(depth - kDepthPoison) < 1e-6f ? " - the destination was never written at all" : "");
BindDefaultFramebuffer();
glDeleteFramebuffers(1, &fbo);
glDeleteTextures(1, &depthCube);
gl.EndFrame();
}
// Both aspects of a packed array attachment. The stencil half goes through a different
// sampling mode than the depth half, and only the depth half was covered above.
TEST_F(DepthStencilReadbackAttachmentShapeScenario, PackedArrayLayerAttachmentReadsBackBothAspects) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
GLuint fbo = 0;
GLuint packedArray = 0;
glGenFramebuffers(1, &fbo);
glGenTextures(1, &packedArray);
glBindTexture(GL_TEXTURE_2D_ARRAY, packedArray);
glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_DEPTH24_STENCIL8, kSize, kSize, 3);
glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, packedArray, 0, 1);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
EXPECT_EQ(FirstGLError(), 0u);
ASSERT_TRUE(FramebufferIsComplete());
glViewport(0, 0, kSize, kSize);
ClearDepthStencil();
const float depth = ReadDepthAt(kSize / 2, kSize / 2);
const int stencil = ReadStencilAt(kSize / 2, kSize / 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_NEAR(depth, kDepthValue, 1.0f / 4096.0f)
<< "depth of a packed GL_TEXTURE_2D_ARRAY layer attachment returned " << depth;
EXPECT_EQ(stencil, kStencilValue)
<< "stencil of a packed GL_TEXTURE_2D_ARRAY layer attachment returned " << stencil
<< (stencil == kStencilPoison ? " - the destination was never written at all" : "");
BindDefaultFramebuffer();
glDeleteFramebuffers(1, &fbo);
glDeleteTextures(1, &packedArray);
gl.EndFrame();
}
// The control: the plain GL_TEXTURE_2D shape, which always worked. If this one ever fails
// alongside the three above, the fault is in depth readback generally rather than in how
// the attachment's format and presence are discovered.
TEST_F(DepthStencilReadbackAttachmentShapeScenario, DepthOfAPlainTexture2DAttachmentReadsBack) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
GLuint fbo = 0;
GLuint depthTex = 0;
glGenFramebuffers(1, &fbo);
glGenTextures(1, &depthTex);
glBindTexture(GL_TEXTURE_2D, depthTex);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_DEPTH_COMPONENT24, kSize, kSize);
glBindTexture(GL_TEXTURE_2D, 0);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthTex, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
EXPECT_EQ(FirstGLError(), 0u);
ASSERT_TRUE(FramebufferIsComplete());
glViewport(0, 0, kSize, kSize);
ClearDepthStencil();
const float depth = ReadDepthAt(kSize / 2, kSize / 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_NEAR(depth, kDepthValue, 1.0f / 4096.0f)
<< "the control case failed: even a plain GL_TEXTURE_2D depth attachment read back " << depth;
BindDefaultFramebuffer();
glDeleteFramebuffers(1, &fbo);
glDeleteTextures(1, &depthTex);
gl.EndFrame();
}
// (3) The default framebuffer must describe its depth/stencil truthfully enough that a
// buffer allocated from that description is blit-compatible with it. This is the exact
// sequence KHR-GLxx.framebuffer_blit performs, and the exact reason 22 of its cases died
// on DirectGLES: the frontend answered 32-bit float depth for a 24-bit fixed-point
// surface, so the renderbuffer the caller allocated could never be blitted to.
TEST_F(DepthStencilReadbackAttachmentShapeScenario, DefaultFramebufferDepthStencilFormatIsBlitCompatible) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
BindDefaultFramebuffer();
GLint depthBits = 0;
GLint stencilBits = 0;
GLint componentType = GL_UNSIGNED_NORMALIZED;
glGetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, GL_DEPTH,
GL_FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE, &depthBits);
glGetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, GL_STENCIL,
GL_FRAMEBUFFER_ATTACHMENT_STENCIL_SIZE, &stencilBits);
glGetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, GL_DEPTH,
GL_FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE, &componentType);
EXPECT_EQ(FirstGLError(), 0u);
if (depthBits <= 0 || stencilBits <= 0) {
GTEST_SKIP() << "this surface has no packed depth/stencil (depth=" << depthBits
<< " stencil=" << stencilBits << "); the blit-compatibility contract needs both";
}
// The one sized format the reported description names. Getting here with the wrong
// answer is the bug: the two candidates are not interchangeable for a blit.
const GLenum reported = (componentType == GL_FLOAT || depthBits > 24) ? GL_DEPTH32F_STENCIL8
: GL_DEPTH24_STENCIL8;
GLuint fbo = 0;
GLuint colorRbo = 0;
GLuint depthRbo = 0;
glGenFramebuffers(1, &fbo);
glGenRenderbuffers(1, &colorRbo);
glGenRenderbuffers(1, &depthRbo);
glBindRenderbuffer(GL_RENDERBUFFER, colorRbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, width, height);
glBindRenderbuffer(GL_RENDERBUFFER, depthRbo);
glRenderbufferStorage(GL_RENDERBUFFER, reported, width, height);
glBindRenderbuffer(GL_RENDERBUFFER, 0);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorRbo);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, depthRbo);
EXPECT_EQ(FirstGLError(), 0u);
ASSERT_TRUE(FramebufferIsComplete());
// Put a known depth in the default framebuffer, then blit colour+depth+stencil out of
// it into the buffer that its own description asked for.
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
glClearColor(0.0f, 1.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ClearDepthStencil();
EXPECT_EQ(FirstGLError(), 0u);
glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height,
GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT, GL_NEAREST);
EXPECT_EQ(FirstGLError(), 0u)
<< "blitting depth/stencil out of the default framebuffer into a buffer allocated from the format "
"the default framebuffer itself reported was rejected - the report and the storage disagree";
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
unsigned char color[4] = {0, 0, 0, 0};
glReadPixels(width / 2, height / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, color);
const float depth = ReadDepthAt(width / 2, height / 2);
const int stencil = ReadStencilAt(width / 2, height / 2);
EXPECT_EQ(FirstGLError(), 0u);
// The colour bit is the precondition, not the claim: it says this stack can blit out of
// its default framebuffer at all, which has nothing to do with depth/stencil formats.
// DirectVulkan on a surfaceless pbuffer cannot - the whole call, colour included, is a
// no-op there, while the same blit works on a real surface (KHR-GLxx.framebuffer_blit
// exercises exactly it and Magma passes 33/33 on device). Skipping keeps the
// depth/stencil claim below falsifiable instead of drowning it in an unrelated
// harness limitation.
if (int(color[1]) <= 192) {
// GTEST_SKIP() expands to a return, so the teardown below it would never run and this
// scenario would hand the next one a foreign framebuffer plus three leaked objects -
// and this is the path DirectVulkan takes on every headless run, not a rare one.
BindDefaultFramebuffer();
glDeleteFramebuffers(1, &fbo);
glDeleteRenderbuffers(1, &colorRbo);
glDeleteRenderbuffers(1, &depthRbo);
gl.EndFrame();
GTEST_SKIP() << "backend " << gl.BackendName() << " on this surface transferred no colour either (green="
<< int(color[1])
<< "): it cannot blit out of the default framebuffer here, so the depth/stencil half proves "
"nothing. The GL-error assertion above still ran, and it is the format contract";
}
EXPECT_NEAR(depth, kDepthValue, 1.0f / 4096.0f)
<< "depth blitted out of the default framebuffer read back " << depth
<< (std::fabs(depth - kDepthPoison) < 1e-6f ? " - the blit transferred nothing" : "");
EXPECT_EQ(stencil, kStencilValue) << "stencil blitted out of the default framebuffer read back " << stencil;
BindDefaultFramebuffer();
glDeleteFramebuffers(1, &fbo);
glDeleteRenderbuffers(1, &colorRbo);
glDeleteRenderbuffers(1, &depthRbo);
gl.EndFrame();
}
} // namespace MGITest
@@ -0,0 +1,784 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackMatrixScenario.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 - THE DEPTH/STENCIL READBACK MATRIX: every verb, every source kind.
//
// DepthStencilReadbackScenario pins the default framebuffer. This file pins the rest of
// the surface a depth/stencil read has to cover, because the three verbs and the four
// source kinds do NOT share a code path by accident - they share one on purpose, and a
// change that quietly serves only one of them is exactly what these assertions catch:
//
// verbs glReadPixels(GL_DEPTH_COMPONENT | GL_STENCIL_INDEX | GL_DEPTH_STENCIL),
// glGetTexImage(GL_DEPTH_STENCIL), glCopyTexImage2D followed by a read
// source kinds depth(-stencil) TEXTURE, RENDERBUFFER (not samplable at all),
// MULTISAMPLE renderbuffer (needs a resolve first), default framebuffer
// formats DEPTH24_STENCIL8, DEPTH32F_STENCIL8, DEPTH_COMPONENT16/24/32F,
// STENCIL_INDEX8
// client types GL_FLOAT / GL_UNSIGNED_INT / GL_UNSIGNED_SHORT depth, GL_INT /
// GL_UNSIGNED_BYTE stencil, both packed GL_DEPTH_STENCIL layouts
//
// On DirectGLES none of this exists natively - ES has no depth or stencil readback in
// core - so every assertion here is really an assertion about the shader-sampling
// emulation. The catch is that some ES drivers accept the reads anyway (Mesa does,
// Adreno does not), which would make the emulation dead code on the very stack the
// headless suite runs on. That is what the second ctest registration is for: the same
// scenarios run again with MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION=1, which takes the
// native spellings off the table and leaves only the path the device actually uses.
//
// Every destination is poisoned with a value the correct answer cannot be, so "the
// backend wrote nothing" fails loudly instead of passing on a coincidence - a test that
// only checked "no GL error" would pass against a readback that never touched the buffer,
// which is precisely how this whole cluster hid for so long.
#include <cmath>
#include <cstring>
#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 float kDepthPoison = 0.2f;
constexpr int kStencilPoison = 50;
constexpr int kWidth = 64;
constexpr int kHeight = 48;
// A depth-stencil pair no clear in these tests produces, packed both ways.
constexpr unsigned int kPacked24_8Poison = 0xAAAAAA33u;
struct D32fS8 {
float depth;
unsigned int stencil;
};
// Everything a source needs to be read: the framebuffer to bind, plus the objects
// to delete afterwards.
struct DepthSource {
GLuint fbo = 0;
GLuint colorTexture = 0;
GLuint depthTexture = 0;
GLuint depthRenderbuffer = 0;
GLuint colorRenderbuffer = 0;
};
void DestroySource(DepthSource& source) {
if (source.fbo != 0) glDeleteFramebuffers(1, &source.fbo);
if (source.colorTexture != 0) glDeleteTextures(1, &source.colorTexture);
if (source.depthTexture != 0) glDeleteTextures(1, &source.depthTexture);
if (source.depthRenderbuffer != 0) glDeleteRenderbuffers(1, &source.depthRenderbuffer);
if (source.colorRenderbuffer != 0) glDeleteRenderbuffers(1, &source.colorRenderbuffer);
source = DepthSource{};
}
GLenum AttachmentPointFor(GLenum internalFormat) {
switch (internalFormat) {
case GL_DEPTH24_STENCIL8:
case GL_DEPTH32F_STENCIL8: return GL_DEPTH_STENCIL_ATTACHMENT;
case GL_STENCIL_INDEX8: return GL_STENCIL_ATTACHMENT;
default: return GL_DEPTH_ATTACHMENT;
}
}
bool FormatHasDepth(GLenum internalFormat) { return internalFormat != GL_STENCIL_INDEX8; }
bool FormatHasStencil(GLenum internalFormat) {
return internalFormat == GL_DEPTH24_STENCIL8 || internalFormat == GL_DEPTH32F_STENCIL8 ||
internalFormat == GL_STENCIL_INDEX8;
}
// A framebuffer whose depth/stencil lives in a TEXTURE. The colour attachment is
// there so a stencil-only or depth-only framebuffer still has something to size it.
DepthSource MakeTextureSource(GLenum internalFormat) {
DepthSource source;
glGenFramebuffers(1, &source.fbo);
glBindFramebuffer(GL_FRAMEBUFFER, source.fbo);
glGenTextures(1, &source.colorTexture);
glBindTexture(GL_TEXTURE_2D, source.colorTexture);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kWidth, kHeight);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, source.colorTexture, 0);
glGenTextures(1, &source.depthTexture);
glBindTexture(GL_TEXTURE_2D, source.depthTexture);
glTexStorage2D(GL_TEXTURE_2D, 1, internalFormat, kWidth, kHeight);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, AttachmentPointFor(internalFormat), GL_TEXTURE_2D,
source.depthTexture, 0);
return source;
}
// The same, with the depth/stencil in a RENDERBUFFER - which cannot be sampled at
// all, so the readback has no choice but to copy it somewhere samplable first.
// `samples` > 0 makes it multisample, which additionally needs a resolve.
DepthSource MakeRenderbufferSource(GLenum internalFormat, int samples) {
DepthSource source;
glGenFramebuffers(1, &source.fbo);
glBindFramebuffer(GL_FRAMEBUFFER, source.fbo);
glGenRenderbuffers(1, &source.colorRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, source.colorRenderbuffer);
if (samples > 0) {
glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, GL_RGBA8, kWidth, kHeight);
} else {
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, kWidth, kHeight);
}
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, source.colorRenderbuffer);
glGenRenderbuffers(1, &source.depthRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, source.depthRenderbuffer);
if (samples > 0) {
glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, internalFormat, kWidth, kHeight);
} else {
glRenderbufferStorage(GL_RENDERBUFFER, internalFormat, kWidth, kHeight);
}
glFramebufferRenderbuffer(GL_FRAMEBUFFER, AttachmentPointFor(internalFormat), GL_RENDERBUFFER,
source.depthRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, 0);
return source;
}
// Clears the bound framebuffer's depth and stencil to known values, with the masks
// and the scissor explicitly out of the way (a leaked scissor from an earlier
// scenario would clip the clear and every assertion after it).
void ClearDepthStencil(GLenum internalFormat, float depth, int stencil) {
glDisable(GL_SCISSOR_TEST);
glViewport(0, 0, kWidth, kHeight);
GLbitfield mask = 0;
if (FormatHasDepth(internalFormat)) {
glDepthMask(GL_TRUE);
glClearDepth(depth);
mask |= GL_DEPTH_BUFFER_BIT;
}
if (FormatHasStencil(internalFormat)) {
glStencilMask(0xFFu);
glClearStencil(stencil);
mask |= GL_STENCIL_BUFFER_BIT;
}
glClear(mask);
}
class DepthStencilReadbackMatrixScenario : public ScenarioTest {
protected:
// Not every ES driver can render to every depth format (DEPTH_COMPONENT32F and
// the multisample counts in particular), and an incomplete framebuffer would
// turn a legitimate "this machine cannot host the source" into a spurious
// failure about the readback.
static bool SourceIsUsable() {
return glCheckFramebufferStatus(GL_FRAMEBUFFER) == GLenum(GL_FRAMEBUFFER_COMPLETE);
}
static std::vector<float> ReadDepthFloat(int x, int y, int width, int height) {
std::vector<float> depth(static_cast<size_t>(width) * height, kDepthPoison);
glReadPixels(x, y, width, height, GL_DEPTH_COMPONENT, GL_FLOAT, depth.data());
return depth;
}
static std::vector<int> ReadStencilInt(int x, int y, int width, int height) {
std::vector<int> stencil(static_cast<size_t>(width) * height, kStencilPoison);
glReadPixels(x, y, width, height, GL_STENCIL_INDEX, GL_INT, stencil.data());
return stencil;
}
// "every value in the region is `expected`" rather than "the middle pixel is":
// a staging blit that lands the wrong rectangle, or a conversion pass with a
// half-texel offset, still gets the centre right.
static void ExpectAllDepth(const std::vector<float>& values, float expected, const char* what) {
size_t bad = 0;
float worst = expected;
for (float value : values) {
if (std::fabs(value - expected) > 1.0f / 4096.0f) {
if (bad == 0) worst = value;
++bad;
}
}
EXPECT_EQ(bad, 0u) << what << ": " << bad << " of " << values.size()
<< " depth values differ from " << expected << "; first bad value " << worst
<< (std::fabs(worst - kDepthPoison) < 1e-6f
? " - which is the poison value, so nothing was written at all"
: "");
}
static void ExpectAllStencil(const std::vector<int>& values, int expected, const char* what) {
size_t bad = 0;
int worst = expected;
for (int value : values) {
if (value != expected) {
if (bad == 0) worst = value;
++bad;
}
}
EXPECT_EQ(bad, 0u) << what << ": " << bad << " of " << values.size()
<< " stencil values differ from " << expected << "; first bad value " << worst
<< (worst == kStencilPoison
? " - which is the poison value, so nothing was written at all"
: "");
}
};
// ---- glReadPixels across the source kinds -----------------------------------
struct SourceCase {
const char* name;
GLenum internalFormat;
int samples;
bool renderbuffer;
};
const SourceCase kSourceCases[] = {
{"texture depth24_stencil8", GL_DEPTH24_STENCIL8, 0, false},
{"texture depth32f_stencil8", GL_DEPTH32F_STENCIL8, 0, false},
{"texture depth_component16", GL_DEPTH_COMPONENT16, 0, false},
{"texture depth_component24", GL_DEPTH_COMPONENT24, 0, false},
{"texture depth_component32f", GL_DEPTH_COMPONENT32F, 0, false},
{"renderbuffer depth24_stencil8", GL_DEPTH24_STENCIL8, 0, true},
{"renderbuffer depth_component24", GL_DEPTH_COMPONENT24, 0, true},
{"renderbuffer stencil_index8", GL_STENCIL_INDEX8, 0, true},
};
} // namespace
TEST_F(DepthStencilReadbackMatrixScenario, EverySourceKindReadsItsClearBack) {
if (!Ready()) return;
int exercised = 0;
for (const SourceCase& testCase : kSourceCases) {
SCOPED_TRACE(testCase.name);
DepthSource source = testCase.renderbuffer
? MakeRenderbufferSource(testCase.internalFormat, testCase.samples)
: MakeTextureSource(testCase.internalFormat);
if (!SourceIsUsable()) {
DestroySource(source);
continue;
}
FirstGLError(); // the storage calls above may have probed an unsupported combination
ClearDepthStencil(testCase.internalFormat, 0.625f, 9);
EXPECT_EQ(FirstGLError(), 0u) << "clearing the source";
if (FormatHasDepth(testCase.internalFormat)) {
const std::vector<float> depth = ReadDepthFloat(0, 0, kWidth, kHeight);
EXPECT_EQ(FirstGLError(), 0u) << "glReadPixels(GL_DEPTH_COMPONENT, GL_FLOAT)";
ExpectAllDepth(depth, 0.625f, testCase.name);
}
if (FormatHasStencil(testCase.internalFormat)) {
const std::vector<int> stencil = ReadStencilInt(0, 0, kWidth, kHeight);
EXPECT_EQ(FirstGLError(), 0u) << "glReadPixels(GL_STENCIL_INDEX, GL_INT)";
ExpectAllStencil(stencil, 9, testCase.name);
}
++exercised;
DestroySource(source);
}
// A machine that hosted none of the sources would report a vacuous pass.
EXPECT_GE(exercised, 4) << "too few depth/stencil source kinds were usable to call this a matrix";
glBindFramebuffer(GL_FRAMEBUFFER, 0);
Gl().EndFrame();
}
// Depth and stencil in two SEPARATE objects, with two different formats, on the same
// framebuffer. Legal GL, and the shape KHR-GL3x.framebuffer_blit builds when its depth
// config and its stencil config are configured independently - so a readback that
// describes "the" depth/stencil source as one thing serves whichever aspect it happened
// to find first and silently abandons the other. Each aspect has to be staged from its
// own attachment, in its own format.
TEST_F(DepthStencilReadbackMatrixScenario, SeparateDepthAndStencilAttachmentsAreBothReadable) {
if (!Ready()) return;
DepthSource source;
glGenFramebuffers(1, &source.fbo);
glBindFramebuffer(GL_FRAMEBUFFER, source.fbo);
glGenRenderbuffers(1, &source.colorRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, source.colorRenderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, kWidth, kHeight);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, source.colorRenderbuffer);
// Depth in a DEPTH_COMPONENT24 renderbuffer...
glGenRenderbuffers(1, &source.depthRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, source.depthRenderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, kWidth, kHeight);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, source.depthRenderbuffer);
// ...and stencil in a STENCIL_INDEX8 one of its own.
GLuint stencilRenderbuffer = 0;
glGenRenderbuffers(1, &stencilRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, stencilRenderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_STENCIL_INDEX8, kWidth, kHeight);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, GL_RENDERBUFFER, stencilRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, 0);
if (!SourceIsUsable()) {
// Separate depth and stencil images are legal GL but many stacks answer
// GL_FRAMEBUFFER_UNSUPPORTED for them; say which, so a skip here is a fact about
// the driver rather than an unexplained hole in the matrix.
const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
glDeleteRenderbuffers(1, &stencilRenderbuffer);
DestroySource(source);
GTEST_SKIP() << "this driver cannot host separate DEPTH_COMPONENT24 and STENCIL_INDEX8 attachments: "
<< "glCheckFramebufferStatus = 0x" << std::hex << status;
}
FirstGLError();
glDisable(GL_SCISSOR_TEST);
glViewport(0, 0, kWidth, kHeight);
glDepthMask(GL_TRUE);
glStencilMask(0xFFu);
glClearDepth(0.3125);
glClearStencil(17);
glClear(GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
ASSERT_EQ(FirstGLError(), 0u);
const std::vector<float> depth = ReadDepthFloat(0, 0, kWidth, kHeight);
EXPECT_EQ(FirstGLError(), 0u) << "reading depth from a separately-attached DEPTH_COMPONENT24";
ExpectAllDepth(depth, 0.3125f, "separate depth attachment");
const std::vector<int> stencil = ReadStencilInt(0, 0, kWidth, kHeight);
EXPECT_EQ(FirstGLError(), 0u) << "reading stencil from a separately-attached STENCIL_INDEX8";
ExpectAllStencil(stencil, 17, "separate stencil attachment");
glDeleteRenderbuffers(1, &stencilRenderbuffer);
DestroySource(source);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
Gl().EndFrame();
}
// A multisample source is never read directly - glReadPixels on a multisampled
// framebuffer is INVALID_OPERATION in GL as much as in ES, and the state layer says so.
// The way multisample depth reaches a reader is a resolve blit into a single-sampled
// framebuffer, which is then read; that pair is
// KHR-GL3x.framebuffer_blit.multisampled_to_singlesampled_blit_depth_config_test, and
// the assertion here is that the resolved depth arrives intact rather than as the
// destination's own clear value.
TEST_F(DepthStencilReadbackMatrixScenario, AResolvedMultisampleDepthReadsBackFromTheDestination) {
if (!Ready()) return;
DepthSource multisampled = MakeRenderbufferSource(GL_DEPTH24_STENCIL8, 4);
if (!SourceIsUsable()) {
DestroySource(multisampled);
GTEST_SKIP() << "this driver cannot host a 4x multisample DEPTH24_STENCIL8 renderbuffer";
}
FirstGLError();
ClearDepthStencil(GL_DEPTH24_STENCIL8, 0.875f, 0);
ASSERT_EQ(FirstGLError(), 0u);
// The destination starts at a depth the resolve must overwrite everywhere.
DepthSource resolved = MakeTextureSource(GL_DEPTH24_STENCIL8);
ASSERT_TRUE(SourceIsUsable());
ClearDepthStencil(GL_DEPTH24_STENCIL8, 0.125f, 0);
ASSERT_EQ(FirstGLError(), 0u);
glBindFramebuffer(GL_READ_FRAMEBUFFER, multisampled.fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, resolved.fbo);
glDisable(GL_SCISSOR_TEST);
glBlitFramebuffer(0, 0, kWidth, kHeight, 0, 0, kWidth, kHeight, GL_DEPTH_BUFFER_BIT, GL_NEAREST);
EXPECT_EQ(FirstGLError(), 0u) << "resolving a multisample depth buffer into a single-sampled one";
glBindFramebuffer(GL_FRAMEBUFFER, resolved.fbo);
const std::vector<float> depth = ReadDepthFloat(0, 0, kWidth, kHeight);
EXPECT_EQ(FirstGLError(), 0u);
ExpectAllDepth(depth, 0.875f, "resolved multisample depth");
DestroySource(resolved);
DestroySource(multisampled);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
Gl().EndFrame();
}
// A read whose rectangle is NOT the whole attachment. The staging copy has to carry
// the requested rect (not the origin) and hand back its rows bottom-up, which a
// full-extent uniform read is a fixed point of and therefore cannot see.
TEST_F(DepthStencilReadbackMatrixScenario, ASubRectangleReadsTheRightBandInTheRightOrder) {
if (!Ready()) return;
DepthSource source = MakeTextureSource(GL_DEPTH24_STENCIL8);
ASSERT_TRUE(SourceIsUsable());
// Bottom half 0.25, top half 0.75, and the stencil banded the other way round so a
// mix-up between the two aspects cannot pass either.
glDisable(GL_SCISSOR_TEST);
glViewport(0, 0, kWidth, kHeight);
glDepthMask(GL_TRUE);
glStencilMask(0xFFu);
glEnable(GL_SCISSOR_TEST);
glScissor(0, 0, kWidth, kHeight / 2);
glClearDepth(0.25);
glClearStencil(11);
glClear(GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
glScissor(0, kHeight / 2, kWidth, kHeight - kHeight / 2);
glClearDepth(0.75);
glClearStencil(22);
glClear(GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
glDisable(GL_SCISSOR_TEST);
ASSERT_EQ(FirstGLError(), 0u);
// A rect wholly inside the bottom band, offset from the origin in both axes.
const int rectWidth = 8;
const int rectHeight = 4;
const std::vector<float> bottom = ReadDepthFloat(16, 4, rectWidth, rectHeight);
EXPECT_EQ(FirstGLError(), 0u);
ExpectAllDepth(bottom, 0.25f, "sub-rect inside the bottom depth band");
const std::vector<int> bottomStencil = ReadStencilInt(16, 4, rectWidth, rectHeight);
EXPECT_EQ(FirstGLError(), 0u);
ExpectAllStencil(bottomStencil, 11, "sub-rect inside the bottom stencil band");
// And one wholly inside the top band. Reading the mirrored row would answer 0.25.
const std::vector<float> top = ReadDepthFloat(16, kHeight - 4 - rectHeight, rectWidth, rectHeight);
EXPECT_EQ(FirstGLError(), 0u);
ExpectAllDepth(top, 0.75f, "sub-rect inside the top depth band");
// A rect that STRADDLES the boundary pins the row order itself: its first rows must
// be the bottom band and its last rows the top one.
const int straddleHeight = 8;
const std::vector<float> straddle =
ReadDepthFloat(16, kHeight / 2 - straddleHeight / 2, rectWidth, straddleHeight);
EXPECT_EQ(FirstGLError(), 0u);
ASSERT_EQ(straddle.size(), static_cast<size_t>(rectWidth) * straddleHeight);
EXPECT_NEAR(straddle[0], 0.25f, 1.0f / 4096.0f)
<< "the first row of the returned rect must be its BOTTOM row (GL order), which is in the 0.25 band";
EXPECT_NEAR(straddle[straddle.size() - 1], 0.75f, 1.0f / 4096.0f)
<< "the last row of the returned rect must be its TOP row, which is in the 0.75 band";
DestroySource(source);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
Gl().EndFrame();
}
// The packed layouts the packed_depth_stencil family reads its gradients with.
TEST_F(DepthStencilReadbackMatrixScenario, PackedDepthStencilReadPixelsCarriesBothAspects) {
if (!Ready()) return;
struct PackedCase {
const char* name;
GLenum internalFormat;
GLenum type;
};
const PackedCase cases[] = {
{"depth24_stencil8 / GL_UNSIGNED_INT_24_8", GL_DEPTH24_STENCIL8, GL_UNSIGNED_INT_24_8},
{"depth32f_stencil8 / GL_FLOAT_32_UNSIGNED_INT_24_8_REV", GL_DEPTH32F_STENCIL8,
GL_FLOAT_32_UNSIGNED_INT_24_8_REV},
};
int exercised = 0;
for (const PackedCase& testCase : cases) {
SCOPED_TRACE(testCase.name);
DepthSource source = MakeTextureSource(testCase.internalFormat);
if (!SourceIsUsable()) {
DestroySource(source);
continue;
}
FirstGLError();
ClearDepthStencil(testCase.internalFormat, 0.5f, 3);
ASSERT_EQ(FirstGLError(), 0u);
const size_t pixels = static_cast<size_t>(kWidth) * kHeight;
if (testCase.type == GL_UNSIGNED_INT_24_8) {
std::vector<unsigned int> packed(pixels, kPacked24_8Poison);
glReadPixels(0, 0, kWidth, kHeight, GL_DEPTH_STENCIL, testCase.type, packed.data());
EXPECT_EQ(FirstGLError(), 0u);
size_t bad = 0;
for (unsigned int value : packed) {
const float depth = static_cast<float>(value >> 8) / 16777215.0f;
const int stencil = static_cast<int>(value & 0xFFu);
if (std::fabs(depth - 0.5f) > 0.01f || stencil != 3) ++bad;
}
EXPECT_EQ(bad, 0u) << testCase.name << ": " << bad << " of " << pixels
<< " packed words carry the wrong depth or stencil (first word 0x" << std::hex
<< packed[0] << std::dec << ")";
} else {
std::vector<D32fS8> packed(pixels, D32fS8{kDepthPoison, static_cast<unsigned int>(kStencilPoison)});
glReadPixels(0, 0, kWidth, kHeight, GL_DEPTH_STENCIL, testCase.type, packed.data());
EXPECT_EQ(FirstGLError(), 0u);
size_t bad = 0;
for (const D32fS8& value : packed) {
if (std::fabs(value.depth - 0.5f) > 0.01f || (value.stencil & 0xFFu) != 3u) ++bad;
}
EXPECT_EQ(bad, 0u) << testCase.name << ": " << bad << " of " << pixels
<< " packed pairs carry the wrong depth or stencil (first pair depth "
<< packed[0].depth << " stencil " << (packed[0].stencil & 0xFFu) << ")";
}
++exercised;
DestroySource(source);
}
EXPECT_GE(exercised, 1) << "neither packed depth/stencil format was renderable";
glBindFramebuffer(GL_FRAMEBUFFER, 0);
Gl().EndFrame();
}
// glGetTexImage reads a TEXTURE, not the bound framebuffer - a different entry point
// that has to reach the same machinery. This is verify_get_tex_image's shape.
TEST_F(DepthStencilReadbackMatrixScenario, GetTexImageReadsAPackedDepthStencilTexture) {
if (!Ready()) return;
DepthSource source = MakeTextureSource(GL_DEPTH24_STENCIL8);
ASSERT_TRUE(SourceIsUsable());
FirstGLError();
ClearDepthStencil(GL_DEPTH24_STENCIL8, 0.375f, 5);
ASSERT_EQ(FirstGLError(), 0u);
// Read it back through the texture, with the framebuffer that owns it unbound so a
// path that secretly read the framebuffer instead would answer from somewhere else.
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glBindTexture(GL_TEXTURE_2D, source.depthTexture);
const size_t pixels = static_cast<size_t>(kWidth) * kHeight;
std::vector<unsigned int> packed(pixels, kPacked24_8Poison);
glGetTexImage(GL_TEXTURE_2D, 0, GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, packed.data());
EXPECT_EQ(FirstGLError(), 0u);
size_t bad = 0;
for (unsigned int value : packed) {
const float depth = static_cast<float>(value >> 8) / 16777215.0f;
if (std::fabs(depth - 0.375f) > 0.01f || (value & 0xFFu) != 5u) ++bad;
}
EXPECT_EQ(bad, 0u) << bad << " of " << pixels
<< " words from glGetTexImage(GL_DEPTH_STENCIL) are wrong (first word 0x" << std::hex
<< packed[0] << std::dec << ")";
glBindTexture(GL_TEXTURE_2D, 0);
DestroySource(source);
Gl().EndFrame();
}
// glCopyTexImage2D out of a depth attachment, then read the copy - verify_copy_tex_image.
TEST_F(DepthStencilReadbackMatrixScenario, CopyTexImageFromADepthAttachmentSurvivesAReadBack) {
if (!Ready()) return;
DepthSource source = MakeTextureSource(GL_DEPTH24_STENCIL8);
ASSERT_TRUE(SourceIsUsable());
FirstGLError();
ClearDepthStencil(GL_DEPTH24_STENCIL8, 0.75f, 6);
ASSERT_EQ(FirstGLError(), 0u);
GLuint copy = 0;
glGenTextures(1, &copy);
glBindTexture(GL_TEXTURE_2D, copy);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, kWidth, kHeight, 0, GL_DEPTH_STENCIL,
GL_UNSIGNED_INT_24_8, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glCopyTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, 0, 0, kWidth, kHeight, 0);
EXPECT_EQ(FirstGLError(), 0u) << "glCopyTexImage2D from a depth/stencil attachment";
glBindFramebuffer(GL_FRAMEBUFFER, 0);
const size_t pixels = static_cast<size_t>(kWidth) * kHeight;
std::vector<unsigned int> packed(pixels, kPacked24_8Poison);
glGetTexImage(GL_TEXTURE_2D, 0, GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, packed.data());
EXPECT_EQ(FirstGLError(), 0u);
size_t bad = 0;
for (unsigned int value : packed) {
const float depth = static_cast<float>(value >> 8) / 16777215.0f;
if (std::fabs(depth - 0.75f) > 0.01f) ++bad;
}
EXPECT_EQ(bad, 0u) << bad << " of " << pixels << " copied depth values are wrong (first word 0x" << std::hex
<< packed[0] << std::dec << ")";
glBindTexture(GL_TEXTURE_2D, 0);
glDeleteTextures(1, &copy);
DestroySource(source);
Gl().EndFrame();
}
// The integer client widths, which are a separate conversion each.
TEST_F(DepthStencilReadbackMatrixScenario, DepthAndStencilConvertIntoEveryClientWidth) {
if (!Ready()) return;
DepthSource source = MakeTextureSource(GL_DEPTH24_STENCIL8);
ASSERT_TRUE(SourceIsUsable());
FirstGLError();
ClearDepthStencil(GL_DEPTH24_STENCIL8, 0.5f, 200);
ASSERT_EQ(FirstGLError(), 0u);
const size_t pixels = static_cast<size_t>(kWidth) * kHeight;
std::vector<unsigned int> depthUint(pixels, 0xDEADBEEFu);
glReadPixels(0, 0, kWidth, kHeight, GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, depthUint.data());
EXPECT_EQ(FirstGLError(), 0u) << "glReadPixels(GL_DEPTH_COMPONENT, GL_UNSIGNED_INT)";
// 0.5 of the full 32-bit range, with room for the source's 24-bit quantisation.
EXPECT_NEAR(static_cast<double>(depthUint[0]) / 4294967295.0, 0.5, 0.01)
<< "GL_UNSIGNED_INT depth came back as " << depthUint[0];
std::vector<unsigned short> depthUshort(pixels, 0xBEEFu);
glReadPixels(0, 0, kWidth, kHeight, GL_DEPTH_COMPONENT, GL_UNSIGNED_SHORT, depthUshort.data());
EXPECT_EQ(FirstGLError(), 0u) << "glReadPixels(GL_DEPTH_COMPONENT, GL_UNSIGNED_SHORT)";
EXPECT_NEAR(static_cast<double>(depthUshort[0]) / 65535.0, 0.5, 0.01)
<< "GL_UNSIGNED_SHORT depth came back as " << depthUshort[0];
// A stencil index is written unconverted into whichever width was asked for, so 200
// must survive intact in all of them - it is also large enough that a signed byte
// would wrap, which is the point of choosing it.
std::vector<unsigned char> stencilByte(pixels, static_cast<unsigned char>(kStencilPoison));
glReadPixels(0, 0, kWidth, kHeight, GL_STENCIL_INDEX, GL_UNSIGNED_BYTE, stencilByte.data());
EXPECT_EQ(FirstGLError(), 0u) << "glReadPixels(GL_STENCIL_INDEX, GL_UNSIGNED_BYTE)";
EXPECT_EQ(static_cast<int>(stencilByte[0]), 200);
std::vector<int> stencilInt(pixels, kStencilPoison);
glReadPixels(0, 0, kWidth, kHeight, GL_STENCIL_INDEX, GL_INT, stencilInt.data());
EXPECT_EQ(FirstGLError(), 0u) << "glReadPixels(GL_STENCIL_INDEX, GL_INT)";
EXPECT_EQ(stencilInt[0], 200);
DestroySource(source);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
Gl().EndFrame();
}
// The PACK pixel-store parameters apply to a depth read exactly as they do to a colour
// one, and the gap regions they create must be left alone.
TEST_F(DepthStencilReadbackMatrixScenario, DepthReadbackHonoursThePackPixelStoreParameters) {
if (!Ready()) return;
DepthSource source = MakeTextureSource(GL_DEPTH_COMPONENT24);
ASSERT_TRUE(SourceIsUsable());
FirstGLError();
ClearDepthStencil(GL_DEPTH_COMPONENT24, 0.5f, 0);
ASSERT_EQ(FirstGLError(), 0u);
const int rectWidth = 4;
const int rectHeight = 3;
const int rowLength = 8;
const int skipPixels = 2;
const int skipRows = 1;
constexpr float kGap = -7.0f;
std::vector<float> destination(static_cast<size_t>(rowLength) * (skipRows + rectHeight) + 16, kGap);
glPixelStorei(GL_PACK_ROW_LENGTH, rowLength);
glPixelStorei(GL_PACK_SKIP_PIXELS, skipPixels);
glPixelStorei(GL_PACK_SKIP_ROWS, skipRows);
glPixelStorei(GL_PACK_ALIGNMENT, 4);
glReadPixels(0, 0, rectWidth, rectHeight, GL_DEPTH_COMPONENT, GL_FLOAT, destination.data());
const unsigned int readError = FirstGLError();
glPixelStorei(GL_PACK_ROW_LENGTH, 0);
glPixelStorei(GL_PACK_SKIP_PIXELS, 0);
glPixelStorei(GL_PACK_SKIP_ROWS, 0);
glPixelStorei(GL_PACK_ALIGNMENT, 4);
EXPECT_EQ(readError, 0u);
size_t written = 0;
size_t gapsTouched = 0;
for (size_t index = 0; index < destination.size(); ++index) {
const long row = static_cast<long>(index) / rowLength - skipRows;
const long column = static_cast<long>(index) % rowLength - skipPixels;
const bool inRect = row >= 0 && row < rectHeight && column >= 0 && column < rectWidth;
if (inRect) {
if (std::fabs(destination[index] - 0.5f) <= 1.0f / 4096.0f) ++written;
} else if (destination[index] != kGap) {
++gapsTouched;
}
}
EXPECT_EQ(written, static_cast<size_t>(rectWidth) * rectHeight)
<< "only " << written << " of " << (rectWidth * rectHeight)
<< " destination pixels landed where GL_PACK_ROW_LENGTH/SKIP_* put them";
EXPECT_EQ(gapsTouched, 0u) << gapsTouched << " bytes outside the packed rectangle were overwritten";
DestroySource(source);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
Gl().EndFrame();
}
// The readback borrows the application's context for a full-screen pass. Everything it
// touches has to come back, or the next draw inherits it - which is how an emulation
// that "works" takes the rest of the renderer down with it.
TEST_F(DepthStencilReadbackMatrixScenario, ReadbackLeavesNoGLStateBehind) {
if (!Ready()) return;
DepthSource source = MakeTextureSource(GL_DEPTH24_STENCIL8);
ASSERT_TRUE(SourceIsUsable());
FirstGLError();
ClearDepthStencil(GL_DEPTH24_STENCIL8, 0.5f, 4);
// A deliberately awkward state: nothing here is what an emulation pass would want,
// so anything it forgets to put back shows up below.
GLuint scratchTexture = 0;
glGenTextures(1, &scratchTexture);
glBindTexture(GL_TEXTURE_2D, scratchTexture);
glActiveTexture(GL_TEXTURE3);
glBindTexture(GL_TEXTURE_2D, scratchTexture);
glEnable(GL_SCISSOR_TEST);
glScissor(3, 5, 7, 11);
glEnable(GL_CULL_FACE);
glEnable(GL_BLEND);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_GEQUAL);
glDepthMask(GL_FALSE);
glEnable(GL_STENCIL_TEST);
glStencilFunc(GL_NOTEQUAL, 0x5, 0x0Fu);
glStencilOp(GL_INCR, GL_DECR, GL_INVERT);
glStencilMask(0x3Cu);
glColorMask(GL_FALSE, GL_TRUE, GL_FALSE, GL_TRUE);
glViewport(2, 3, 5, 7);
ASSERT_EQ(FirstGLError(), 0u);
const std::vector<float> depth = ReadDepthFloat(0, 0, kWidth, kHeight);
const std::vector<int> stencil = ReadStencilInt(0, 0, kWidth, kHeight);
EXPECT_EQ(FirstGLError(), 0u);
ExpectAllDepth(depth, 0.5f, "state-preservation case depth");
ExpectAllStencil(stencil, 4, "state-preservation case stencil");
GLint viewport[4] = {0, 0, 0, 0};
GLint scissorBox[4] = {0, 0, 0, 0};
GLboolean colorMask[4] = {GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE};
GLint depthFunc = 0;
GLboolean depthMask = GL_TRUE;
GLint stencilFunc = 0, stencilRef = 0, stencilValueMask = 0, stencilWriteMask = 0;
GLint stencilFail = 0, stencilPassDepthFail = 0, stencilPassDepthPass = 0;
GLint activeTexture = 0, boundTexture = 0;
glGetIntegerv(GL_VIEWPORT, viewport);
glGetIntegerv(GL_SCISSOR_BOX, scissorBox);
glGetBooleanv(GL_COLOR_WRITEMASK, colorMask);
glGetIntegerv(GL_DEPTH_FUNC, &depthFunc);
glGetBooleanv(GL_DEPTH_WRITEMASK, &depthMask);
glGetIntegerv(GL_STENCIL_FUNC, &stencilFunc);
glGetIntegerv(GL_STENCIL_REF, &stencilRef);
glGetIntegerv(GL_STENCIL_VALUE_MASK, &stencilValueMask);
glGetIntegerv(GL_STENCIL_WRITEMASK, &stencilWriteMask);
glGetIntegerv(GL_STENCIL_FAIL, &stencilFail);
glGetIntegerv(GL_STENCIL_PASS_DEPTH_FAIL, &stencilPassDepthFail);
glGetIntegerv(GL_STENCIL_PASS_DEPTH_PASS, &stencilPassDepthPass);
glGetIntegerv(GL_ACTIVE_TEXTURE, &activeTexture);
glGetIntegerv(GL_TEXTURE_BINDING_2D, &boundTexture);
EXPECT_EQ(viewport[0], 2);
EXPECT_EQ(viewport[1], 3);
EXPECT_EQ(viewport[2], 5);
EXPECT_EQ(viewport[3], 7);
EXPECT_EQ(scissorBox[0], 3);
EXPECT_EQ(scissorBox[1], 5);
EXPECT_EQ(scissorBox[2], 7);
EXPECT_EQ(scissorBox[3], 11);
EXPECT_EQ(glIsEnabled(GL_SCISSOR_TEST), GLboolean(GL_TRUE));
EXPECT_EQ(glIsEnabled(GL_CULL_FACE), GLboolean(GL_TRUE));
EXPECT_EQ(glIsEnabled(GL_BLEND), GLboolean(GL_TRUE));
EXPECT_EQ(glIsEnabled(GL_DEPTH_TEST), GLboolean(GL_TRUE));
EXPECT_EQ(glIsEnabled(GL_STENCIL_TEST), GLboolean(GL_TRUE));
EXPECT_EQ(colorMask[0], GLboolean(GL_FALSE));
EXPECT_EQ(colorMask[1], GLboolean(GL_TRUE));
EXPECT_EQ(colorMask[2], GLboolean(GL_FALSE));
EXPECT_EQ(colorMask[3], GLboolean(GL_TRUE));
EXPECT_EQ(depthFunc, GLint(GL_GEQUAL));
EXPECT_EQ(depthMask, GLboolean(GL_FALSE));
EXPECT_EQ(stencilFunc, GLint(GL_NOTEQUAL));
EXPECT_EQ(stencilRef, 0x5);
EXPECT_EQ(stencilValueMask, 0x0F);
EXPECT_EQ(stencilWriteMask, 0x3C);
EXPECT_EQ(stencilFail, GLint(GL_INCR));
EXPECT_EQ(stencilPassDepthFail, GLint(GL_DECR));
EXPECT_EQ(stencilPassDepthPass, GLint(GL_INVERT));
EXPECT_EQ(activeTexture, GLint(GL_TEXTURE3));
EXPECT_EQ(boundTexture, GLint(scratchTexture))
<< "the readback left a scratch texture on the application's texture unit";
EXPECT_EQ(FirstGLError(), 0u);
// Put the awkward state back so the next scenario in this process starts clean.
glDisable(GL_SCISSOR_TEST);
glDisable(GL_CULL_FACE);
glDisable(GL_BLEND);
glDisable(GL_DEPTH_TEST);
glDisable(GL_STENCIL_TEST);
glDepthFunc(GL_LESS);
glDepthMask(GL_TRUE);
glStencilFunc(GL_ALWAYS, 0, 0xFFFFFFFFu);
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
glStencilMask(0xFFFFFFFFu);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
glBindTexture(GL_TEXTURE_2D, 0);
glActiveTexture(GL_TEXTURE0);
glDeleteTextures(1, &scratchTexture);
DestroySource(source);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glViewport(0, 0, Gl().Width(), Gl().Height());
Gl().EndFrame();
}
} // namespace MGITest
@@ -58,12 +58,12 @@ namespace MGITest {
class DepthStencilReadbackScenario : public ScenarioTest {
protected:
// DirectGLES reads depth and stencil back through the ES driver, which has no
// guaranteed path for either (GL_NV_read_depth / GL_NV_read_stencil are optional and
// absent on both the Adreno device and Mesa's ES). That gap is tracked separately as
// the packed_depth_stencil cluster and needs a shader-sampling emulation, not this
// change; asserting it here would only pin a known-missing feature.
bool BackendReadsDepthStencil() const { return Gl().BackendName() == "DirectVulkan"; }
// Both backends now answer these reads. DirectGLES has no native ES path for
// either aspect (GL_NV_read_depth / GL_NV_read_stencil are optional and absent on
// both the Adreno device and Mesa's ES), so it stages the attachment into a
// scratch depth texture and samples it into a colour target; the assertions below
// are the same either way, which is the point.
bool BackendReadsDepthStencil() const { return true; }
float ReadDepthAt(int x, int y) const {
float depth = kDepthPoison;
@@ -0,0 +1,720 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.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 - GLSL DOUBLES, RUN AT SINGLE PRECISION.
//
// No mobile GPU has 64-bit floats. Adreno and Mali both report shaderFloat64 == VK_FALSE, so
// Magma cannot build a module that declares the Float64 capability, and ESSL has no fp64 type
// at all, so SPIRV-Cross refuses the module outright on Espryt ("FP64 not supported in ES
// profile") and the program never reaches the driver. MobileGL therefore narrows every 64-bit
// float in a shader to 32 bits (ShaderTranspiler::DemoteFloat64Pass) rather than declining the
// shader: `double` compiles and runs everywhere, at float precision.
//
// The narrowing is only half a contract. The other half is the API side: the global UBO is
// laid out by reflecting the DEMOTED module, so glUniform*d has to store a float where the
// shader reads a float, glGetUniform*v has to read one back, and a dmat4's columns are now
// std140-padded like any other matrix's. Every one of those is a byte offset that fails
// silently - the uniform simply reads as something else - so the cases below set values
// through the API and have the SHADER report what it saw.
//
// What is deliberately NOT asserted: that the values are exact to double precision. They are
// not, and cannot be. Every expectation here is the float value of the double that was set,
// which is the whole point.
#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 {
// Doubles in every shape the demotion has to handle - a scalar, a vector, a matrix
// whose column stride changes, an array whose element stride changes - all reported
// through one float SSBO so a single readback says which one moved.
constexpr const char* kComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
uniform double uScalar;
uniform dvec3 uVector;
uniform dmat4 uMatrix;
uniform double uArray[3];
layout(std430, binding = 0) buffer Output {
float g_out[];
};
void main() {
g_out[0] = float(uScalar);
g_out[1] = float(uVector.x);
g_out[2] = float(uVector.y);
g_out[3] = float(uVector.z);
// Column-major [column][row]. Off-diagonal entries catch a column-stride mistake that a
// diagonal-only check reads straight past.
g_out[4] = float(uMatrix[0][0]);
g_out[5] = float(uMatrix[0][3]);
g_out[6] = float(uMatrix[3][0]);
g_out[7] = float(uMatrix[3][3]);
g_out[8] = float(uArray[0]);
g_out[9] = float(uArray[1]);
g_out[10] = float(uArray[2]);
// Arithmetic on doubles, including an implicit float->double conversion and a literal
// with the fp64 suffix: this is what an application actually writes, and it is the part
// that has to survive the conversion folding.
double accumulated = uScalar * 2.0lf + 1.5;
g_out[11] = float(accumulated);
}
)";
constexpr int kOutputSlots = 12;
class DoublePrecisionScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_program = CompileComputeProgram(kComputeSource);
ASSERT_NE(m_program, 0u) << m_buildLog;
glGenBuffers(1, &m_output);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output);
const std::vector<float> zeroes(kOutputSlots, 0.0f);
glBufferData(GL_SHADER_STORAGE_BUFFER, kOutputSlots * sizeof(float), zeroes.data(),
GL_DYNAMIC_DRAW);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_output);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
}
void TearDown() override {
if (!Ready()) return;
if (m_shapeOutput != 0) glDeleteBuffers(1, &m_shapeOutput);
if (m_shapeProgram != 0) glDeleteProgram(m_shapeProgram);
if (m_output != 0) glDeleteBuffers(1, &m_output);
if (m_program != 0) glDeleteProgram(m_program);
}
unsigned int CompileComputeProgram(const char* source) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[2048] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("compute shader did not compile: ") + log;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[2048] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("compute program did not link: ") + log;
glDeleteProgram(program);
return 0;
}
return program;
}
std::vector<float> Dispatch() {
glUseProgram(m_program);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
std::vector<float> values(kOutputSlots, -1.0f);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, kOutputSlots * sizeof(float), values.data());
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
glUseProgram(0);
return values;
}
unsigned int m_program = 0;
unsigned int m_output = 0;
unsigned int m_shapeProgram = 0;
unsigned int m_shapeOutput = 0;
std::string m_buildLog;
};
// Every double-typed uniform shape GLSL has, all thirteen of them, in one program - the
// shape of KHR-GL43.compute_shader.fp64-case2. The scalar and the square matrices are
// covered by the cases above; what only a set like this reaches is the NON-SQUARE
// matrices, whose column stride and total size both change when the demotion turns a
// 64-bit column into a 32-bit one, and whose members therefore move every uniform
// declared after them.
//
// The shader reports every component separately rather than one pass/fail flag, because
// "the readback is wrong" is not a diagnosis: a wrong column stride, a wrong member
// offset and a wrong narrowing all fail the same single comparison, and only the
// component map says which.
// No #version here on purpose: it is handed over as a separate source string, the way
// the CTS case hands it over.
constexpr const char* kAllDoubleShapesSource = R"(
layout(local_size_x = 1) in;
uniform double g_0;
uniform dvec2 g_1;
uniform dvec3 g_2;
uniform dvec4 g_3;
uniform dmat2 g_4;
uniform dmat2x3 g_5;
uniform dmat2x4 g_6;
uniform dmat3x2 g_7;
uniform dmat3 g_8;
uniform dmat3x4 g_9;
uniform dmat4x2 g_10;
uniform dmat4x3 g_11;
uniform dmat4 g_12;
layout(std430, binding = 0) buffer Output {
float g_out[];
};
void main() {
g_out[0] = float(g_0);
for (int i = 0; i < 2; ++i) g_out[1 + i] = float(g_1[i]);
for (int i = 0; i < 3; ++i) g_out[3 + i] = float(g_2[i]);
for (int i = 0; i < 4; ++i) g_out[6 + i] = float(g_3[i]);
for (int c = 0; c < 2; ++c) for (int r = 0; r < 2; ++r) g_out[10 + c * 2 + r] = float(g_4[c][r]);
for (int c = 0; c < 2; ++c) for (int r = 0; r < 3; ++r) g_out[14 + c * 3 + r] = float(g_5[c][r]);
for (int c = 0; c < 2; ++c) for (int r = 0; r < 4; ++r) g_out[20 + c * 4 + r] = float(g_6[c][r]);
for (int c = 0; c < 3; ++c) for (int r = 0; r < 2; ++r) g_out[28 + c * 2 + r] = float(g_7[c][r]);
for (int c = 0; c < 3; ++c) for (int r = 0; r < 3; ++r) g_out[34 + c * 3 + r] = float(g_8[c][r]);
for (int c = 0; c < 3; ++c) for (int r = 0; r < 4; ++r) g_out[43 + c * 4 + r] = float(g_9[c][r]);
for (int c = 0; c < 4; ++c) for (int r = 0; r < 2; ++r) g_out[55 + c * 2 + r] = float(g_10[c][r]);
for (int c = 0; c < 4; ++c) for (int r = 0; r < 3; ++r) g_out[63 + c * 3 + r] = float(g_11[c][r]);
for (int c = 0; c < 4; ++c) for (int r = 0; r < 4; ++r) g_out[75 + c * 4 + r] = float(g_12[c][r]);
}
)";
// The values the CTS case sets, spelled the way it spells them - column-major, and small
// enough that every one is exact in a float. Nothing here is a precision question; a
// component that comes back wrong came back from the wrong bytes.
constexpr double kG0 = 1.0;
constexpr double kG1[2] = {2.0, 3.0};
constexpr double kG2[3] = {4.0, 5.0, 6.0};
constexpr double kG3[4] = {7.0, 8.0, 9.0, 10.0};
constexpr double kG4[4] = {11.0, 12.0, 13.0, 14.0};
constexpr double kG5[6] = {15.0, 16.0, 17.0, 18.0, 19.0, 20.0};
constexpr double kG6[8] = {21.0, 22.0, 23.0, 24.0, 25.0, 26.0, 27.0, 28.0};
constexpr double kG7[6] = {29.0, 30.0, 31.0, 32.0, 33.0, 34.0};
constexpr double kG8[9] = {35.0, 36.0, 37.0, 38.0, 39.0, 40.0, 41.0, 42.0, 43.0};
constexpr double kG9[12] = {44.0, 45.0, 46.0, 47.0, 48.0, 49.0, 50.0, 51.0, 52.0, 53.0, 54.0, 55.0};
constexpr double kG10[8] = {56.0, 57.0, 58.0, 59.0, 60.0, 61.0, 62.0, 63.0};
constexpr double kG11[12] = {63.0, 64.0, 65.0, 66.0, 67.0, 68.0, 69.0, 70.0, 71.0, 27.0, 73.0, 74.0};
constexpr double kG12[16] = {75.0, 76.0, 77.0, 78.0, 79.0, 80.0, 81.0, 82.0,
83.0, 84.0, 85.0, 86.0, 87.0, 88.0, 89.0, 90.0};
struct DoubleShape {
const char* name;
int base;
int columns; // 1 for the scalar and the vectors
int rows; // component count for the scalar and the vectors
const double* values;
};
constexpr DoubleShape kDoubleShapes[] = {
{"g_0 double", 0, 1, 1, &kG0}, {"g_1 dvec2", 1, 1, 2, kG1},
{"g_2 dvec3", 3, 1, 3, kG2}, {"g_3 dvec4", 6, 1, 4, kG3},
{"g_4 dmat2", 10, 2, 2, kG4}, {"g_5 dmat2x3", 14, 2, 3, kG5},
{"g_6 dmat2x4", 20, 2, 4, kG6}, {"g_7 dmat3x2", 28, 3, 2, kG7},
{"g_8 dmat3", 34, 3, 3, kG8}, {"g_9 dmat3x4", 43, 3, 4, kG9},
{"g_10 dmat4x2", 55, 4, 2, kG10}, {"g_11 dmat4x3", 63, 4, 3, kG11},
{"g_12 dmat4", 75, 4, 4, kG12},
};
constexpr int kAllShapeSlots = 91;
// The conformance case's own shader, kept verbatim down to the literal suffixes and the
// unnamed, unqualified storage block - except that each comparison sets its OWN bit
// instead of collapsing all thirteen into one flag. That single flag is the whole reason
// the case was unexplained for a wave: it says "something is wrong" and nothing else.
//
// Verbatim matters here. Reading the components out one at a time (the case above)
// passes; whatever fails does so through the shape the conformance case actually
// writes - whole-matrix comparison against a constructor, a storage block with no
// layout qualifier and no instance name, values reached with constant indices.
constexpr const char* kCtsShapedSource = R"(
layout(local_size_x = 1) in;
buffer Result {
int g_result;
};
uniform double g_0;
uniform dvec2 g_1;
uniform dvec3 g_2;
uniform dvec4 g_3;
uniform dmat2 g_4;
uniform dmat2x3 g_5;
uniform dmat2x4 g_6;
uniform dmat3x2 g_7;
uniform dmat3 g_8;
uniform dmat3x4 g_9;
uniform dmat4x2 g_10;
uniform dmat4x3 g_11;
uniform dmat4 g_12;
void main() {
g_result = 0;
if (g_0 != 1.0LF) g_result |= 1;
if (g_1 != dvec2(2.0LF, 3.0LF)) g_result |= 2;
if (g_2 != dvec3(4.0LF, 5.0LF, 6.0LF)) g_result |= 4;
if (g_3 != dvec4(7.0LF, 8.0LF, 9.0LF, 10.0LF)) g_result |= 8;
if (g_4 != dmat2(11.0LF, 12.0LF, 13.0LF, 14.0LF)) g_result |= 16;
if (g_5 != dmat2x3(15.0LF, 16.0LF, 17.0LF, 18.0LF, 19.0LF, 20.0LF)) g_result |= 32;
if (g_6 != dmat2x4(21.0LF, 22.0LF, 23.0LF, 24.0LF, 25.0LF, 26.0LF, 27.0LF, 28.0LF)) g_result |= 64;
if (g_7 != dmat3x2(29.0LF, 30.0LF, 31.0LF, 32.0LF, 33.0LF, 34.0LF)) g_result |= 128;
if (g_8 != dmat3(35.0LF, 36.0LF, 37.0LF, 38.0LF, 39.0LF, 40.0LF, 41.0LF, 42.0LF, 43.0LF)) g_result |= 256;
if (g_9 != dmat3x4(44.0LF, 45.0LF, 46.0LF, 47.0LF, 48.0LF, 49.0LF, 50.0LF, 51.0LF, 52.0LF, 53.0LF, 54.0LF, 55.0LF)) g_result |= 512;
if (g_10 != dmat4x2(56.0, 57.0, 58.0, 59.0, 60.0, 61.0, 62.0, 63.0)) g_result |= 1024;
if (g_11 != dmat4x3(63.0, 64.0, 65.0, 66.0, 67.0, 68.0, 69.0, 70.0, 71.0, 27.0, 73, 74.0)) g_result |= 2048;
if (g_12 != dmat4(75.0, 76.0, 77.0, 78.0, 79.0, 80.0, 81.0, 82.0, 83.0, 84.0, 85.0, 86.0, 87.0, 88.0, 89.0, 90.0)) g_result |= 4096;
}
)";
TEST_F(DoublePrecisionScenario, ADoubleUniformReachesTheShaderAtFloatPrecision) {
if (!Ready()) return;
glUseProgram(m_program);
const GLint scalar = glGetUniformLocation(m_program, "uScalar");
ASSERT_GE(scalar, 0);
// 0.1 has no exact float (or double) representation, so this only passes if the
// value really travelled through the demoted slot rather than being read out of
// some other four bytes.
glUniform1d(scalar, 0.1);
glUseProgram(0);
const std::vector<float> values = Dispatch();
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
EXPECT_FLOAT_EQ(values[0], static_cast<float>(0.1));
EXPECT_FLOAT_EQ(values[11], static_cast<float>(static_cast<float>(0.1) * 2.0f + 1.5f))
<< "arithmetic on the demoted value, including the folded fp64 literal";
}
TEST_F(DoublePrecisionScenario, EveryDoubleShapeLandsInItsOwnSlot) {
if (!Ready()) return;
glUseProgram(m_program);
const GLint scalar = glGetUniformLocation(m_program, "uScalar");
const GLint vector = glGetUniformLocation(m_program, "uVector");
const GLint matrix = glGetUniformLocation(m_program, "uMatrix");
const GLint array0 = glGetUniformLocation(m_program, "uArray[0]");
const GLint array2 = glGetUniformLocation(m_program, "uArray[2]");
ASSERT_GE(scalar, 0);
ASSERT_GE(vector, 0);
ASSERT_GE(matrix, 0);
ASSERT_GE(array0, 0);
ASSERT_GE(array2, 0);
glUniform1d(scalar, 5.0);
const GLdouble vectorValue[3] = {11.0, 12.0, 13.0};
glUniform3dv(vector, 1, vectorValue);
// Column-major, and every entry distinct so a transposed or mis-strided write
// cannot land on a value that happens to match.
GLdouble matrixValue[16] = {};
for (int i = 0; i < 16; ++i) matrixValue[i] = 100.0 + i;
glUniformMatrix4dv(matrix, 1, GL_FALSE, matrixValue);
const GLdouble arrayValue[3] = {71.0, 72.0, 73.0};
glUniform1dv(array0, 3, arrayValue);
glUseProgram(0);
const std::vector<float> values = Dispatch();
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
EXPECT_FLOAT_EQ(values[0], 5.0f) << "scalar double";
EXPECT_FLOAT_EQ(values[1], 11.0f) << "dvec3 .x";
EXPECT_FLOAT_EQ(values[2], 12.0f) << "dvec3 .y";
EXPECT_FLOAT_EQ(values[3], 13.0f) << "dvec3 .z";
EXPECT_FLOAT_EQ(values[4], 100.0f) << "dmat4 [0][0]";
EXPECT_FLOAT_EQ(values[5], 103.0f) << "dmat4 [0][3] - within the first column";
EXPECT_FLOAT_EQ(values[6], 112.0f) << "dmat4 [3][0] - column stride";
EXPECT_FLOAT_EQ(values[7], 115.0f) << "dmat4 [3][3]";
EXPECT_FLOAT_EQ(values[8], 71.0f) << "double array element 0";
EXPECT_FLOAT_EQ(values[9], 72.0f) << "double array element 1 - element stride";
EXPECT_FLOAT_EQ(values[10], 73.0f) << "double array element 2";
}
TEST_F(DoublePrecisionScenario, TheTransposeFlagStillTransposes) {
if (!Ready()) return;
glUseProgram(m_program);
const GLint matrix = glGetUniformLocation(m_program, "uMatrix");
ASSERT_GE(matrix, 0);
GLdouble matrixValue[16] = {};
for (int i = 0; i < 16; ++i) matrixValue[i] = 100.0 + i;
glUniformMatrix4dv(matrix, 1, GL_TRUE, matrixValue);
glUseProgram(0);
const std::vector<float> values = Dispatch();
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
// Transposed, so [column][row] now reads the source's [row][column].
EXPECT_FLOAT_EQ(values[4], 100.0f) << "dmat4 [0][0] is on the diagonal either way";
EXPECT_FLOAT_EQ(values[5], 112.0f) << "dmat4 [0][3] after transpose";
EXPECT_FLOAT_EQ(values[6], 103.0f) << "dmat4 [3][0] after transpose";
EXPECT_FLOAT_EQ(values[7], 115.0f) << "dmat4 [3][3] is on the diagonal either way";
}
TEST_F(DoublePrecisionScenario, TheUniformIsStillReportedAsADouble) {
if (!Ready()) return;
// The demotion is an implementation detail of how the value is STORED. What the
// shader source declared is what the application asked about, so the reflection
// keeps answering GL_DOUBLE* - an application that switches on the type and calls
// glUniform*d has to keep working, and it is the glUniform*d path that is correct
// for these uniforms.
struct Expectation {
const char* name;
GLenum type;
GLint size;
};
const Expectation expectations[] = {
{"uScalar", GL_DOUBLE, 1},
{"uVector", GL_DOUBLE_VEC3, 1},
{"uMatrix", GL_DOUBLE_MAT4, 1},
{"uArray[0]", GL_DOUBLE, 3},
};
GLint activeUniforms = 0;
glGetProgramiv(m_program, GL_ACTIVE_UNIFORMS, &activeUniforms);
ASSERT_GT(activeUniforms, 0);
for (const Expectation& expectation : expectations) {
bool found = false;
for (GLint index = 0; index < activeUniforms; ++index) {
char name[128] = {};
GLsizei length = 0;
GLint size = 0;
GLenum type = 0;
glGetActiveUniform(m_program, static_cast<GLuint>(index), sizeof(name) - 1, &length, &size,
&type, name);
if (std::string(name, static_cast<size_t>(length)) != expectation.name) continue;
found = true;
EXPECT_EQ(type, expectation.type) << expectation.name;
EXPECT_EQ(size, expectation.size) << expectation.name;
break;
}
EXPECT_TRUE(found) << "glGetActiveUniform never reported " << expectation.name;
}
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
}
TEST_F(DoublePrecisionScenario, GetUniformdvReadsBackWhatWasStored) {
if (!Ready()) return;
glUseProgram(m_program);
const GLint scalar = glGetUniformLocation(m_program, "uScalar");
const GLint vector = glGetUniformLocation(m_program, "uVector");
const GLint matrix = glGetUniformLocation(m_program, "uMatrix");
ASSERT_GE(scalar, 0);
ASSERT_GE(vector, 0);
ASSERT_GE(matrix, 0);
glUniform1d(scalar, 0.1);
const GLdouble vectorValue[3] = {11.5, 12.5, 13.5};
glUniform3dv(vector, 1, vectorValue);
GLdouble matrixValue[16] = {};
for (int i = 0; i < 16; ++i) matrixValue[i] = 100.0 + i;
glUniformMatrix4dv(matrix, 1, GL_FALSE, matrixValue);
glUseProgram(0);
// The readback has to undo exactly what the write did - the same std140 column
// padding, the same 4-byte components - or a dmat4 comes back with its columns
// shifted and nothing else in the API would say so.
GLdouble readScalar = 0.0;
glGetUniformdv(m_program, scalar, &readScalar);
EXPECT_DOUBLE_EQ(readScalar, static_cast<double>(static_cast<float>(0.1)))
<< "the value is what a float can hold, not the double that was passed in";
GLdouble readVector[3] = {};
glGetUniformdv(m_program, vector, readVector);
EXPECT_DOUBLE_EQ(readVector[0], 11.5);
EXPECT_DOUBLE_EQ(readVector[1], 12.5);
EXPECT_DOUBLE_EQ(readVector[2], 13.5);
GLdouble readMatrix[16] = {};
glGetUniformdv(m_program, matrix, readMatrix);
for (int i = 0; i < 16; ++i) {
EXPECT_DOUBLE_EQ(readMatrix[i], 100.0 + i) << "dmat4 component " << i;
}
// The float query sees the same storage through the type it is actually stored as.
GLfloat readFloat = 0.0f;
glGetUniformfv(m_program, scalar, &readFloat);
EXPECT_FLOAT_EQ(readFloat, static_cast<float>(0.1));
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
}
TEST_F(DoublePrecisionScenario, ADoubleUniformKeepsItsDeclaredInitializer) {
if (!Ready()) return;
// A declared initializer is seeded straight into the uniform shadow at link, and the
// seeding used to skip 64-bit floats outright ("no 32-bit shadow encoding") - which
// was true before the demotion and silently left every such uniform reading zero.
const char* source = R"(#version 430 core
layout(local_size_x = 1) in;
uniform double uSeeded = 2.5lf;
uniform dvec3 uSeededVector = dvec3(4.0lf, 5.0lf, 6.0lf);
layout(std430, binding = 0) buffer Output {
float g_out[];
};
void main() {
g_out[0] = float(uSeeded);
g_out[1] = float(uSeededVector.x);
g_out[2] = float(uSeededVector.y);
g_out[3] = float(uSeededVector.z);
}
)";
const GLuint program = CompileComputeProgram(source);
ASSERT_NE(program, 0u) << m_buildLog;
glUseProgram(program);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
std::vector<float> values(4, -1.0f);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, 4 * sizeof(float), values.data());
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
glUseProgram(0);
glDeleteProgram(program);
EXPECT_FLOAT_EQ(values[0], 2.5f) << "scalar double initializer";
EXPECT_FLOAT_EQ(values[1], 4.0f) << "dvec3 initializer .x";
EXPECT_FLOAT_EQ(values[2], 5.0f) << "dvec3 initializer .y";
EXPECT_FLOAT_EQ(values[3], 6.0f) << "dvec3 initializer .z";
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
}
TEST_F(DoublePrecisionScenario, EveryDoubleUniformShapeArrivesWhereTheShaderReadsIt) {
if (!Ready()) return;
// Built the way the CTS case builds it, because every step of that build has been a
// bug here at least once: the source arrives as TWO strings (the version directive
// and the body), the shader is attached before it has a source and deleted while
// still attached, and the program is linked twice.
m_shapeProgram = glCreateProgram();
ASSERT_NE(m_shapeProgram, 0u);
{
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glAttachShader(m_shapeProgram, shader);
glDeleteShader(shader);
const char* const sources[2] = {"#version 430 core\n", kAllDoubleShapesSource};
glShaderSource(shader, 2, sources, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[2048] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
FAIL() << "compute shader did not compile: " << log;
}
}
glLinkProgram(m_shapeProgram);
{
GLint linkedOnce = 0;
glGetProgramiv(m_shapeProgram, GL_LINK_STATUS, &linkedOnce);
if (linkedOnce == GL_FALSE) {
char log[2048] = {};
glGetProgramInfoLog(m_shapeProgram, sizeof(log) - 1, nullptr, log);
FAIL() << "compute program did not link: " << log;
}
}
glGenBuffers(1, &m_shapeOutput);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_shapeOutput);
const std::vector<float> zeroes(kAllShapeSlots, 0.0f);
glBufferData(GL_SHADER_STORAGE_BUFFER, kAllShapeSlots * sizeof(float), zeroes.data(), GL_DYNAMIC_DRAW);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_shapeOutput);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
const auto location = [&](const char* name) { return glGetUniformLocation(m_shapeProgram, name); };
// Pass one sets through glProgramUniform*, pass two through glUniform* after a
// re-link - the two entry-point families the CTS case exercises, and two different
// routes into the same uniform storage.
const auto setWithProgramUniform = [&]() {
glProgramUniform1d(m_shapeProgram, location("g_0"), kG0);
glProgramUniform2d(m_shapeProgram, location("g_1"), kG1[0], kG1[1]);
glProgramUniform3d(m_shapeProgram, location("g_2"), kG2[0], kG2[1], kG2[2]);
glProgramUniform4d(m_shapeProgram, location("g_3"), kG3[0], kG3[1], kG3[2], kG3[3]);
glProgramUniformMatrix2dv(m_shapeProgram, location("g_4"), 1, GL_FALSE, kG4);
glProgramUniformMatrix2x3dv(m_shapeProgram, location("g_5"), 1, GL_FALSE, kG5);
glProgramUniformMatrix2x4dv(m_shapeProgram, location("g_6"), 1, GL_FALSE, kG6);
glProgramUniformMatrix3x2dv(m_shapeProgram, location("g_7"), 1, GL_FALSE, kG7);
glProgramUniformMatrix3dv(m_shapeProgram, location("g_8"), 1, GL_FALSE, kG8);
glProgramUniformMatrix3x4dv(m_shapeProgram, location("g_9"), 1, GL_FALSE, kG9);
glProgramUniformMatrix4x2dv(m_shapeProgram, location("g_10"), 1, GL_FALSE, kG10);
glProgramUniformMatrix4x3dv(m_shapeProgram, location("g_11"), 1, GL_FALSE, kG11);
glProgramUniformMatrix4dv(m_shapeProgram, location("g_12"), 1, GL_FALSE, kG12);
};
// Deliberately does NOT re-issue glUseProgram: the CTS case leaves the program
// current across the re-link and writes into it from there, so this is the path
// where a re-link has to keep the current program's uniform storage addressable.
const auto setWithUniform = [&]() {
glUniform1d(location("g_0"), kG0);
glUniform2d(location("g_1"), kG1[0], kG1[1]);
glUniform3d(location("g_2"), kG2[0], kG2[1], kG2[2]);
glUniform4d(location("g_3"), kG3[0], kG3[1], kG3[2], kG3[3]);
glUniformMatrix2dv(location("g_4"), 1, GL_FALSE, kG4);
glUniformMatrix2x3dv(location("g_5"), 1, GL_FALSE, kG5);
glUniformMatrix2x4dv(location("g_6"), 1, GL_FALSE, kG6);
glUniformMatrix3x2dv(location("g_7"), 1, GL_FALSE, kG7);
glUniformMatrix3dv(location("g_8"), 1, GL_FALSE, kG8);
glUniformMatrix3x4dv(location("g_9"), 1, GL_FALSE, kG9);
glUniformMatrix4x2dv(location("g_10"), 1, GL_FALSE, kG10);
glUniformMatrix4x3dv(location("g_11"), 1, GL_FALSE, kG11);
glUniformMatrix4dv(location("g_12"), 1, GL_FALSE, kG12);
};
const auto dispatchAndRead = [&]() {
glUseProgram(m_shapeProgram);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
std::vector<float> values(kAllShapeSlots, -1.0f);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_shapeOutput);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, kAllShapeSlots * sizeof(float), values.data());
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
// The program stays current on purpose - see setWithUniform.
return values;
};
const auto expectEverything = [](const std::vector<float>& values, const char* pass) {
for (const DoubleShape& shape : kDoubleShapes) {
for (int c = 0; c < shape.columns; ++c) {
for (int r = 0; r < shape.rows; ++r) {
const int component = c * shape.rows + r;
EXPECT_FLOAT_EQ(values[shape.base + component],
static_cast<float>(shape.values[component]))
<< pass << ": " << shape.name << " column " << c << " row " << r;
}
}
}
};
setWithProgramUniform();
expectEverything(dispatchAndRead(), "glProgramUniform*");
// A re-link zeroes every uniform, so pass two proves its own writes rather than
// reading pass one's bytes back.
glLinkProgram(m_shapeProgram);
GLint linked = 0;
glGetProgramiv(m_shapeProgram, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_shapeOutput);
glBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, kAllShapeSlots * sizeof(float), zeroes.data());
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
setWithUniform();
expectEverything(dispatchAndRead(), "glUniform* after re-link");
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
}
TEST_F(DoublePrecisionScenario, TheConformanceUniformShaderAgreesWithEveryValueItWasGiven) {
if (!Ready()) return;
m_shapeProgram = glCreateProgram();
ASSERT_NE(m_shapeProgram, 0u);
{
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glAttachShader(m_shapeProgram, shader);
glDeleteShader(shader);
const char* const sources[2] = {"#version 430 core\n", kCtsShapedSource};
glShaderSource(shader, 2, sources, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[2048] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
FAIL() << "compute shader did not compile: " << log;
}
}
glLinkProgram(m_shapeProgram);
GLint linked = 0;
glGetProgramiv(m_shapeProgram, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[2048] = {};
glGetProgramInfoLog(m_shapeProgram, sizeof(log) - 1, nullptr, log);
FAIL() << "compute program did not link: " << log;
}
glGenBuffers(1, &m_shapeOutput);
const GLint seed = 123;
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_shapeOutput);
glBufferData(GL_SHADER_STORAGE_BUFFER, sizeof(seed), &seed, GL_STATIC_DRAW);
const auto location = [&](const char* name) { return glGetUniformLocation(m_shapeProgram, name); };
glProgramUniform1d(m_shapeProgram, location("g_0"), kG0);
glProgramUniform2d(m_shapeProgram, location("g_1"), kG1[0], kG1[1]);
glProgramUniform3d(m_shapeProgram, location("g_2"), kG2[0], kG2[1], kG2[2]);
glProgramUniform4d(m_shapeProgram, location("g_3"), kG3[0], kG3[1], kG3[2], kG3[3]);
glProgramUniformMatrix2dv(m_shapeProgram, location("g_4"), 1, GL_FALSE, kG4);
glProgramUniformMatrix2x3dv(m_shapeProgram, location("g_5"), 1, GL_FALSE, kG5);
glProgramUniformMatrix2x4dv(m_shapeProgram, location("g_6"), 1, GL_FALSE, kG6);
glProgramUniformMatrix3x2dv(m_shapeProgram, location("g_7"), 1, GL_FALSE, kG7);
glProgramUniformMatrix3dv(m_shapeProgram, location("g_8"), 1, GL_FALSE, kG8);
glProgramUniformMatrix3x4dv(m_shapeProgram, location("g_9"), 1, GL_FALSE, kG9);
glProgramUniformMatrix4x2dv(m_shapeProgram, location("g_10"), 1, GL_FALSE, kG10);
glProgramUniformMatrix4x3dv(m_shapeProgram, location("g_11"), 1, GL_FALSE, kG11);
glProgramUniformMatrix4dv(m_shapeProgram, location("g_12"), 1, GL_FALSE, kG12);
glUseProgram(m_shapeProgram);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
GLint disagreements = -1;
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(disagreements), &disagreements);
for (int bit = 0; bit < 13; ++bit) {
EXPECT_EQ(disagreements & (1 << bit), 0)
<< kDoubleShapes[bit].name << " did not compare equal to the value it was given";
}
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
}
TEST_F(DoublePrecisionScenario, TheFp64ExtensionIsNotAdvertised) {
if (!Ready()) return;
// The shader above compiled, linked and ran without the extension string, which is
// the point: an application does not need GL_ARB_gpu_shader_fp64 advertised to USE
// doubles here. What the string additionally promises is 64-bit precision, and that
// is the one thing the demotion cannot deliver - so it stays off unless
// MOBILEGL_ADVERTISE_FP64 asks for it, and an application that branches on the
// string keeps taking its float path.
GLint extensionCount = 0;
glGetIntegerv(GL_NUM_EXTENSIONS, &extensionCount);
ASSERT_GT(extensionCount, 0);
bool advertised = false;
for (GLint i = 0; i < extensionCount; ++i) {
const char* name = reinterpret_cast<const char*>(glGetStringi(GL_EXTENSIONS, static_cast<GLuint>(i)));
if (name != nullptr && std::string(name) == "GL_ARB_gpu_shader_fp64") advertised = true;
}
EXPECT_FALSE(advertised);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
}
TEST_F(DoublePrecisionScenario, A64BitVertexFormatIsDeclinedOnEveryBackend) {
if (!Ready()) return;
// The demotion leaves no 64-bit shader input to feed, so there is nothing a 64-bit
// vertex FETCH could be fetched into - on either backend, and no longer only on the
// ones whose device lacks shaderFloat64. Declined loudly rather than accepted and
// drawn as garbage; the matching POST row says the same thing at startup.
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
while (glGetError() != GL_NO_ERROR) {}
glVertexAttribLFormat(0, 3, GL_DOUBLE, 0);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION));
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
while (glGetError() != GL_NO_ERROR) {}
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,348 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DrawParametersScenario.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
//
// gl_BaseVertex / gl_BaseInstance / gl_DrawID (GL_ARB_shader_draw_parameters),
// read straight out of the shader that a draw command produced.
//
// Neither backend has these builtins for free, and each is wrong in its own way
// when nobody watches:
//
// * DirectVulkan HAS a BaseVertex builtin, but Vulkan's carries the draw's
// firstVertex on a NON-INDEXED draw where GL's is defined to be zero ("the
// value passed to the baseVertex parameter, or zero for a command with no
// such parameter"). Only the indexed meaning of the two agrees. Every
// DrawArrays form therefore takes the ZeroBaseVertex program variant.
// * DirectGLES has no such builtins at all: ESSL knows none of them, so the
// transpiler demotes each one to a uniform the draw paths feed. A uniform
// nobody writes keeps whatever the previous draw left in it - which is what
// made gl_BaseVertex report a stale base vertex, and what made
// gl_BaseInstance read an unbound storage buffer on a plain glDrawArrays.
//
// The shader paints the three values, so a draw that carries the wrong ones
// paints the wrong colour rather than merely disagreeing with an expectation
// somewhere. The framebuffer is cleared to WHITE and no case expects 255 in any
// channel, so "the draw did not happen" can never be mistaken for a pass.
#include <cstdint>
#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/glext.h>
namespace MGITest {
namespace {
// #version 450: glslang only declares the ARB builtins from 440 up.
//
// Each value is painted at 8 units per count, not 1: the errors these builtins
// actually have are OFF BY ONE (a sub-draw that never got its own gl_DrawID reads
// the previous one's, a base vertex that arrives one command late), and at one unit
// per count no readback tolerance can tell those from rounding.
//
// And biased by two counts, so that ZERO is not the clamp floor. Five of these cases
// expect zero, and an unbiased encoding would let every negative value - the shape a
// sign or rebase mistake produces - clamp to the same black and pass.
constexpr const char* kVertexSource = R"(#version 450 core
#extension GL_ARB_shader_draw_parameters : require
layout(location = 0) in vec2 aPos;
flat out vec3 vParams;
void main() {
vParams = (vec3(gl_BaseVertexARB, gl_BaseInstanceARB, gl_DrawIDARB) * 8.0 + 16.0) / 255.0;
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kFragmentSource = R"(#version 450 core
flat in vec3 vParams;
out vec4 oColor;
void main() {
oColor = vec4(vParams, 1.0);
}
)";
struct Vertex {
float x, y;
};
// 3 dummy vertices, then the left half of the viewport as two triangles,
// then the right half. Nothing here is symmetric by accident:
//
// * the padding makes a draw that ignores `first` / baseVertex paint a
// degenerate triangle (i.e. nothing) instead of the right picture;
// * the two halves let one multi-draw show TWO different gl_DrawID
// values in one readback.
//
// Indices 3..14 together cover the whole viewport, which is what the
// single-draw cases use.
constexpr int kPad = 3;
constexpr int kLeftFirst = kPad; // 3
constexpr int kRightFirst = kPad + 6; // 9
constexpr int kHalfCount = 6;
std::vector<Vertex> SceneVertices() {
std::vector<Vertex> vertices(static_cast<std::size_t>(kPad), Vertex{0.0f, 0.0f});
const float bounds[2][2] = {{-1.0f, 0.0f}, {0.0f, 1.0f}};
for (const auto& half : bounds) {
const float x0 = half[0];
const float x1 = half[1];
vertices.push_back({x0, -1.0f});
vertices.push_back({x1, -1.0f});
vertices.push_back({x1, 1.0f});
vertices.push_back({x0, -1.0f});
vertices.push_back({x1, 1.0f});
vertices.push_back({x0, 1.0f});
}
return vertices;
}
// GL's DrawArraysIndirectCommand / DrawElementsIndirectCommand, spelled out
// so a test can write one without depending on a GL header's struct.
struct ArraysCommand {
std::uint32_t count, instanceCount, first, baseInstance;
};
struct ElementsCommand {
std::uint32_t count, instanceCount, firstIndex;
std::int32_t baseVertex;
std::uint32_t baseInstance;
};
class DrawParametersScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
const std::vector<Vertex> vertices = SceneVertices();
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(vertices.size() * sizeof(Vertex)),
vertices.data(), GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<const void*>(0));
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
}
void TearDown() override {
if (!Ready()) return;
for (GLuint* buffer : {&m_ebo, &m_indirect, &m_parameter, &m_vbo}) {
if (*buffer != 0) glDeleteBuffers(1, buffer);
*buffer = 0;
}
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_program != 0) glDeleteProgram(m_program);
}
template <typename T>
void FillBuffer(GLuint& name, GLenum target, const std::vector<T>& data) {
if (name == 0) glGenBuffers(1, &name);
glBindBuffer(target, name);
glBufferData(target, static_cast<GLsizeiptr>(data.size() * sizeof(T)), data.data(), GL_STATIC_DRAW);
}
// Clears to white, runs `draw` and reads the frame back.
template <typename DrawFn>
Image Render(DrawFn&& draw) {
BindDefaultFramebuffer();
glViewport(0, 0, HeadlessGL::Get().Width(), HeadlessGL::Get().Height());
ClearTo(1.0f, 1.0f, 1.0f, 1.0f);
glUseProgram(m_program);
glBindVertexArray(m_vao);
draw();
return ReadPixels(HeadlessGL::Get().Width(), HeadlessGL::Get().Height());
}
// The three builtins as the shader saw them, at a point in one half of
// the viewport. `half` is 0 for the left half and 1 for the right.
struct DrawParams {
int baseVertex = -1, baseInstance = -1, drawId = -1;
};
// Decodes the biased 8-units-per-count encoding back to the integer the
// shader saw. Rounding to the nearest step absorbs any UNORM slop; adjacent
// values stay eight units apart, so an off-by-one still reads as one, and a
// negative value lands below the bias and decodes negative rather than
// clamping into a legitimate zero.
static DrawParams ParamsAt(const Image& image, int half) {
const int x = image.Width() * (1 + 2 * half) / 4;
const Rgba8 pixel = image.At(x, image.Height() / 2);
const auto decode = [](std::uint8_t channel) {
return (static_cast<int>(channel) - 16 + 4) / 8;
};
return {decode(pixel.r), decode(pixel.g), decode(pixel.b)};
}
static void ExpectParams(const Image& image, int half, const DrawParams& expected,
const std::string& what) {
const DrawParams actual = ParamsAt(image, half);
EXPECT_EQ(actual.baseVertex, expected.baseVertex)
<< what << ": gl_BaseVertex (half " << half << ")";
EXPECT_EQ(actual.baseInstance, expected.baseInstance)
<< what << ": gl_BaseInstance (half " << half << ")";
EXPECT_EQ(actual.drawId, expected.drawId) << what << ": gl_DrawID (half " << half << ")";
}
GLuint m_program = 0;
GLuint m_vao = 0;
GLuint m_vbo = 0;
GLuint m_ebo = 0;
GLuint m_indirect = 0;
GLuint m_parameter = 0;
};
// ---- the non-indexed forms: gl_BaseVertex is zero, `first` or not ----
// Vulkan's BaseVertex would answer 3 here (the draw's firstVertex); GL's
// must answer 0, because glDrawArrays has no baseVertex parameter at all.
TEST_F(DrawParametersScenario, DrawArraysReportsAZeroBaseVertexDespiteItsFirst) {
if (!Ready()) return;
const Image image = Render([&] { glDrawArrays(GL_TRIANGLES, kLeftFirst, 2 * kHalfCount); });
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectParams(image, 0, {0, 0, 0}, "glDrawArrays(first=3)");
ExpectParams(image, 1, {0, 0, 0}, "glDrawArrays(first=3)");
}
TEST_F(DrawParametersScenario, DrawArraysInstancedBaseInstanceReportsItsBaseInstance) {
if (!Ready()) return;
const Image image = Render([&] {
glDrawArraysInstancedBaseInstance(GL_TRIANGLES, kLeftFirst, 2 * kHalfCount, 1, 5);
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectParams(image, 0, {0, 5, 0}, "glDrawArraysInstancedBaseInstance(baseInstance=5)");
}
// The base instance of one draw must not survive into the next one. This is
// the shape that broke on DirectGLES: the emulation uniform is per-program
// state, so a draw that never writes it inherits the last writer's value.
TEST_F(DrawParametersScenario, APlainDrawAfterABaseInstancedOneSeesZeroAgain) {
if (!Ready()) return;
const Image image = Render([&] {
glDrawArraysInstancedBaseInstance(GL_TRIANGLES, kLeftFirst, 2 * kHalfCount, 1, 7);
glDrawArrays(GL_TRIANGLES, kLeftFirst, 2 * kHalfCount);
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectParams(image, 0, {0, 0, 0}, "plain glDrawArrays after a base-instanced draw");
}
// ---- the indexed forms: gl_BaseVertex IS the base vertex ----
TEST_F(DrawParametersScenario, DrawElementsBaseVertexReportsItsBaseVertex) {
if (!Ready()) return;
std::vector<std::uint32_t> indices;
for (std::uint32_t i = 0; i < 2 * kHalfCount; ++i) indices.push_back(i);
FillBuffer(m_ebo, GL_ELEMENT_ARRAY_BUFFER, indices);
const Image image = Render([&] {
glDrawElementsBaseVertex(GL_TRIANGLES, 2 * kHalfCount, GL_UNSIGNED_INT,
reinterpret_cast<const void*>(0), kLeftFirst);
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectParams(image, 0, {kLeftFirst, 0, 0}, "glDrawElementsBaseVertex(basevertex=3)");
ExpectParams(image, 1, {kLeftFirst, 0, 0}, "glDrawElementsBaseVertex(basevertex=3)");
}
// ... and is zero again for the command that has none, including after one
// that did: the same leak the base instance has, on the other builtin. The
// preceding draw MUST carry a non-zero base vertex or this case proves nothing -
// one index run reaches the geometry through the base vertex, the second through
// its own indices, so the two draws paint the same picture with different
// gl_BaseVertex and only the second one's value survives in the framebuffer.
TEST_F(DrawParametersScenario, DrawElementsAfterABaseVertexDrawReportsZeroAgain) {
if (!Ready()) return;
std::vector<std::uint32_t> indices;
for (std::uint32_t i = 0; i < 2 * kHalfCount; ++i) indices.push_back(i);
for (std::uint32_t i = 0; i < 2 * kHalfCount; ++i) indices.push_back(i + kLeftFirst);
FillBuffer(m_ebo, GL_ELEMENT_ARRAY_BUFFER, indices);
const auto rebasedRun = reinterpret_cast<const void*>(2 * kHalfCount * sizeof(std::uint32_t));
const Image image = Render([&] {
glDrawElementsBaseVertex(GL_TRIANGLES, 2 * kHalfCount, GL_UNSIGNED_INT,
reinterpret_cast<const void*>(0), kLeftFirst);
glDrawElements(GL_TRIANGLES, 2 * kHalfCount, GL_UNSIGNED_INT, rebasedRun);
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectParams(image, 0, {0, 0, 0}, "glDrawElements after a base-vertex draw");
ExpectParams(image, 1, {0, 0, 0}, "glDrawElements after a base-vertex draw");
}
// ---- the multi-draw forms: one gl_DrawID per sub-draw ----
TEST_F(DrawParametersScenario, MultiDrawArraysNumbersItsSubDraws) {
if (!Ready()) return;
const GLint firsts[2] = {kLeftFirst, kRightFirst};
const GLsizei counts[2] = {kHalfCount, kHalfCount};
const Image image = Render([&] { glMultiDrawArrays(GL_TRIANGLES, firsts, counts, 2); });
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectParams(image, 0, {0, 0, 0}, "glMultiDrawArrays sub-draw 0");
ExpectParams(image, 1, {0, 0, 1}, "glMultiDrawArrays sub-draw 1");
}
// Every field of an indexed indirect command at once: its own gl_DrawID, the
// baseVertex word (which the CPU reads out of the command) and the
// baseInstance word (which DirectGLES reads through a storage-buffer view of
// the very same buffer).
TEST_F(DrawParametersScenario, MultiDrawElementsIndirectCarriesEveryCommandsParameters) {
if (!Ready()) return;
std::vector<std::uint32_t> indices;
for (std::uint32_t i = 0; i < kHalfCount; ++i) indices.push_back(i);
FillBuffer(m_ebo, GL_ELEMENT_ARRAY_BUFFER, indices);
const std::vector<ElementsCommand> commands = {
{kHalfCount, 1, 0, kLeftFirst, 0},
{kHalfCount, 1, 0, kRightFirst, 4},
};
FillBuffer(m_indirect, GL_DRAW_INDIRECT_BUFFER, commands);
const Image image = Render([&] {
glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_INT, reinterpret_cast<const void*>(0), 2,
sizeof(ElementsCommand));
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectParams(image, 0, {kLeftFirst, 0, 0}, "indirect command 0");
ExpectParams(image, 1, {kRightFirst, 4, 1}, "indirect command 1");
}
// glMultiDrawArraysIndirectCount was missing from the DirectGLES backend
// table entirely, so the frontend answered INVALID_OPERATION for every call
// while GL_ARB_indirect_parameters was advertised. The parameter buffer here
// holds a count SMALLER than maxdrawcount, so a path that ignores it draws a
// third command over the top of the second and changes the right half.
TEST_F(DrawParametersScenario, MultiDrawArraysIndirectCountObeysItsParameterBuffer) {
if (!Ready()) return;
const std::vector<ArraysCommand> commands = {
{kHalfCount, 1, kLeftFirst, 0},
{kHalfCount, 1, kRightFirst, 6},
{kHalfCount, 1, kRightFirst, 9},
};
FillBuffer(m_indirect, GL_DRAW_INDIRECT_BUFFER, commands);
const std::vector<std::uint32_t> parameters = {2};
FillBuffer(m_parameter, GL_PARAMETER_BUFFER, parameters);
const Image image = Render([&] {
glMultiDrawArraysIndirectCount(GL_TRIANGLES, reinterpret_cast<const void*>(0), 0, 3,
sizeof(ArraysCommand));
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectParams(image, 0, {0, 0, 0}, "counted indirect command 0");
ExpectParams(image, 1, {0, 6, 1}, "counted indirect command 1");
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,227 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/FragmentOutputArrayIndexScenario.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 - DYNAMICALLY INDEXED FRAGMENT OUTPUT ARRAYS, on a live driver.
//
// The bug: GLSL ES requires a *constant integral expression* to index a fragment output array
// (GLSL ES 3.00 4.3.6); SPIR-V has no such rule. A shader that writes `coeff[i]` from a loop
// therefore travels through glslang and SPIRV-Cross intact and lands on the ES driver as ESSL it
// refuses outright - "array indexes for fragment outputs must be constant integral expressions".
// The program links nothing and every draw that uses it becomes a silent no-op. That is the whole
// of improved-transparency-minecraft-26.3 on the Android DirectGLES lane: Minecraft 26.3's OIT
// coefficient shader has exactly this shape, and losing it empties the entire translucent layer
// (clouds and water) while the opaque geometry stays pixel-exact.
//
// WHY THIS SCENARIO EXISTS RATHER THAN A UNIT TEST. The unit tests in MG_Test/Program (see
// ProgramUtilTest, LoopDerivedFragmentOutputIndexFoldsToConstantIndices and its
// genuinely-dynamic sibling) prove the SPIR-V comes out with constant indices, validates, and
// decompiles to ESSL with only literal indices. What they cannot prove is that a real driver
// then ACCEPTS and RUNS it - and acceptance is the whole failure mode, because Mesa accepts the
// illegal form too. Only a live glCompileShader/glLinkProgram followed by a draw can tell the two
// apart, and only reading the pixels back can tell "linked" from "wrote the right attachment".
//
// Both backends run this: on DirectVulkan the original module is already legal (the legalization
// is DirectGLES-only, deliberately), so this doubles as the check that the two backends agree
// about what such a shader means.
#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 const char* kVS = R"(#version 330 core
in vec2 aPos;
void main() {
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
// The Minecraft 26.3 OIT coefficient shape: both the attachment index and the component
// index come from loop counters, so nothing but the loop bounds decides where each value
// lands. Attachment 0 gets (0.0, 0.1, 0.2, 0.3) and attachment 1 gets (0.5, 0.6, 0.7, 0.8) -
// values that are only correct if the two indices were folded to the RIGHT constants, not
// merely to some constant.
constexpr const char* kLoopIndexedFS = R"(#version 330 core
out vec4 coeff[2];
void main() {
for (int attachmentIndex = 0; attachmentIndex < 2; ++attachmentIndex) {
for (int i = 0; i < 4; ++i) {
coeff[attachmentIndex][i] = float(attachmentIndex) * 0.5 + float(i) * 0.1;
}
}
}
)";
// No loop can fold this one: the index arrives in a uniform. It exercises the fallback
// lowering (a switch over the array range for the write, constant-indexed loads and a
// select for the read) and it checks the untargeted attachment is left ALONE, which a
// lowering that wrote every element unconditionally would break.
constexpr const char* kUniformIndexedFS = R"(#version 330 core
uniform int uTarget;
out vec4 coeff[2];
void main() {
coeff[0] = vec4(0.25, 0.25, 0.25, 1.0);
coeff[1] = vec4(0.75, 0.75, 0.75, 1.0);
coeff[uTarget] = coeff[uTarget] + vec4(0.25, 0.0, 0.0, 0.0);
}
)";
constexpr int kSize = 8;
class FragmentOutputArrayIndexScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
for (int i = 0; i < 2; ++i) {
glGenTextures(1, &m_color[i]);
glBindTexture(GL_TEXTURE_2D, m_color[i]);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSize, kSize);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D,
m_color[i], 0);
}
const GLenum drawBuffers[2] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1};
glDrawBuffers(2, drawBuffers);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER),
static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
const float quad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
glViewport(0, 0, kSize, kSize);
}
void TearDown() override {
if (Ready()) {
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &m_fbo);
glDeleteTextures(2, m_color);
glDeleteBuffers(1, &m_vbo);
glDeleteVertexArrays(1, &m_vao);
}
ScenarioTest::TearDown();
}
// Clears both attachments to a colour no shader below writes, so an attachment that
// was never written reads back as the sentinel rather than as a plausible value.
void ClearToSentinel() {
glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
}
std::vector<float> ReadAttachment(int index) {
std::vector<unsigned char> bytes(static_cast<std::size_t>(kSize) * kSize * 4, 0);
glReadBuffer(GL_COLOR_ATTACHMENT0 + index);
glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_UNSIGNED_BYTE, bytes.data());
std::vector<float> centre(4, -1.0f);
// The middle pixel: the quad covers the whole target, so every pixel is the same,
// and the middle one cannot be a rasterization edge case.
const std::size_t offset = (static_cast<std::size_t>(kSize / 2) * kSize + kSize / 2) * 4;
for (int i = 0; i < 4; ++i) {
centre[static_cast<std::size_t>(i)] = static_cast<float>(bytes[offset + i]) / 255.0f;
}
return centre;
}
GLuint m_fbo = 0;
GLuint m_color[2] = {0, 0};
GLuint m_vao = 0;
GLuint m_vbo = 0;
};
// The gate for the whole defect: before the legalization this program did not link on a
// strict ES driver (ANGLE), so the draw wrote nothing and BOTH attachments kept the
// sentinel. Now each attachment must carry the value its loop iteration produced.
TEST_F(FragmentOutputArrayIndexScenario, LoopIndexedOutputArrayWritesEveryAttachment) {
if (!Ready() || IsSkipped()) return;
std::string error;
const GLuint program = CompileProgram(kVS, kLoopIndexedFS, &error);
ASSERT_NE(program, 0u) << "a loop-indexed fragment output array must compile and link: "
<< error;
ClearToSentinel();
glUseProgram(program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const std::vector<float> first = ReadAttachment(0);
EXPECT_NEAR(first[0], 0.0f, 0.02f) << "attachment 0 red";
EXPECT_NEAR(first[1], 0.1f, 0.02f) << "attachment 0 green";
EXPECT_NEAR(first[2], 0.2f, 0.02f)
<< "attachment 0 blue - a sentinel 1.0 here means the draw never ran";
EXPECT_NEAR(first[3], 0.3f, 0.02f) << "attachment 0 alpha";
const std::vector<float> second = ReadAttachment(1);
EXPECT_NEAR(second[0], 0.5f, 0.02f)
<< "attachment 1 red - the second loop iteration must reach the second draw buffer";
EXPECT_NEAR(second[1], 0.6f, 0.02f) << "attachment 1 green";
EXPECT_NEAR(second[2], 0.7f, 0.02f) << "attachment 1 blue";
EXPECT_NEAR(second[3], 0.8f, 0.02f) << "attachment 1 alpha";
glDeleteProgram(program);
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
// The fallback half, on a live driver, for both values of the uniform: the targeted
// attachment is read, incremented and written back; the other one keeps exactly what the
// constant-indexed store put there.
TEST_F(FragmentOutputArrayIndexScenario, UniformIndexedOutputArrayWritesOnlyTheSelectedAttachment) {
if (!Ready() || IsSkipped()) return;
std::string error;
const GLuint program = CompileProgram(kVS, kUniformIndexedFS, &error);
ASSERT_NE(program, 0u) << "a uniform-indexed fragment output array must compile and link: "
<< error;
const GLint targetLocation = glGetUniformLocation(program, "uTarget");
ASSERT_GE(targetLocation, 0);
glUseProgram(program);
for (int target = 0; target < 2; ++target) {
ClearToSentinel();
glUniform1i(targetLocation, target);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const std::vector<float> first = ReadAttachment(0);
const std::vector<float> second = ReadAttachment(1);
EXPECT_NEAR(first[0], target == 0 ? 0.5f : 0.25f, 0.02f)
<< "attachment 0 red with uTarget=" << target;
EXPECT_NEAR(first[1], 0.25f, 0.02f) << "attachment 0 green with uTarget=" << target;
EXPECT_NEAR(second[0], target == 1 ? 1.0f : 0.75f, 0.02f)
<< "attachment 1 red with uTarget=" << target;
EXPECT_NEAR(second[1], 0.75f, 0.02f) << "attachment 1 green with uTarget=" << target;
}
glDeleteProgram(program);
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,476 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/Glsl420DeclarationScenario.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 - GLSL 4.20 DECLARATIONS THE FRONTEND USED TO REJECT OR COLLAPSE.
//
// GLSL 4.20 gives an array of opaque uniforms or of block instances CONSECUTIVE binding
// points: "layout(binding = 1) uniform sampler2D goku[7]" puts goku[0] on texture unit 1
// and goku[6] on unit 7, and the same rule holds for "layout(binding = 2) uniform GOKU
// {...} goku[14]" over uniform buffer binding points 2..15 (GLSL 4.20 4.4.5, GL 4.6 7.6.2).
// One qualifier, N bindings - which is exactly the part that is easy to get wrong, because
// every element shares one declaration and one reflection record.
//
// Three separate mechanisms all collapsed that array down to its first element, and the
// three cases below pin one each:
//
// * the SAMPLER array (Espryt): reflection names an array after its first element at
// every location it spans, so the backend resolved "goku[0]" once per element, got one
// backend location N times, and the per-draw pass's last glUniform1i was the only one
// that survived. goku[0] ended up holding the LAST element's unit and goku[1..N-1] kept
// unit 0 - so every element sampled whatever was bound to unit 0.
// * the uniform BLOCK array (both backends): glslang reports the declared binding for
// every expanded instance, so nothing added the element offset. glGetActiveUniformBlockiv
// answered the base binding for all of them, and since both backends feed a block from
// that same number at draw time, all instances also read one buffer.
// * 'invariant' on a non-vertex stage's INPUT: legal desktop GLSL at every version, and
// ignored where it is written, but glslang rejected it from 4.20 up - so a shader that
// compiled as "#version 400" stopped compiling as "#version 420".
//
// The fourth case is the same species as the third - a legal 4.20 shader the frontend
// refused - and lives here for that reason: atomicCounterIncrement() was rejected because
// glslang applied its atomicAdd() extension gate to the atomicAdd() its own Vulkan-relaxed
// lowering had just synthesized.
//
// Conformance cases behind these: KHR-GL42.shading_language_420pack.binding_sampler_array,
// .binding_uniform_block_array, .qualifier_order[_block]_test_id_*, and
// KHR-GL42.shader_image_load_store.advanced-sso-atomicCounters.
#include <cstdint>
#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 kElements = 4;
// No vertex attributes: the quad comes from gl_VertexID, so nothing here depends on
// the harness's attribute pinning and the fragment stage is the only thing under test.
constexpr const char* kQuadVS = R"(#version 420 core
void main()
{
switch (gl_VertexID)
{
case 0: gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); break;
case 1: gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); break;
case 2: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break;
default: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break;
}
}
)";
// The red channel comes back as a BITMASK of which elements read the wrong thing, so
// a failure names the element instead of just saying "not green". float(bad)/255.0
// round-trips exactly through an RGBA8 target for every mask this can produce.
constexpr const char* kSamplerArrayFS = R"(#version 420 core
layout(binding = 1) uniform sampler2D goku[4];
out vec4 o_color;
void main()
{
const vec2 uv = vec2(0.5, 0.5);
int bad = 0;
if (texture(goku[0], uv) != vec4(1.0, 0.0, 0.0, 1.0)) bad |= 1;
if (texture(goku[1], uv) != vec4(0.0, 0.0, 1.0, 1.0)) bad |= 2;
if (texture(goku[2], uv) != vec4(1.0, 1.0, 0.0, 1.0)) bad |= 4;
if (texture(goku[3], uv) != vec4(0.0, 1.0, 1.0, 1.0)) bad |= 8;
o_color = vec4(float(bad) / 255.0, bad == 0 ? 1.0 : 0.0, 0.0, 1.0);
}
)";
// Same declaration one dimension deeper. GLSL 4.30 arrays of arrays are legal here, and
// the elements still take consecutive units (1..4) in declaration order - but the two
// reflections disagree about how to count them, which is the whole point of this case.
constexpr const char* kSamplerArrayOfArraysFS = R"(#version 430 core
layout(binding = 1) uniform sampler2D goku[2][2];
out vec4 o_color;
void main()
{
const vec2 uv = vec2(0.5, 0.5);
int bad = 0;
if (texture(goku[0][0], uv) != vec4(1.0, 0.0, 0.0, 1.0)) bad |= 1;
if (texture(goku[0][1], uv) != vec4(0.0, 0.0, 1.0, 1.0)) bad |= 2;
if (texture(goku[1][0], uv) != vec4(1.0, 1.0, 0.0, 1.0)) bad |= 4;
if (texture(goku[1][1], uv) != vec4(0.0, 1.0, 1.0, 1.0)) bad |= 8;
o_color = vec4(float(bad) / 255.0, bad == 0 ? 1.0 : 0.0, 0.0, 1.0);
}
)";
constexpr const char* kBlockArrayFS = R"(#version 420 core
layout(std140, binding = 2) uniform GOKU
{
vec4 gohan;
} goku[4];
out vec4 o_color;
void main()
{
int bad = 0;
if (goku[0].gohan != vec4(1.0, 0.0, 0.0, 1.0)) bad |= 1;
if (goku[1].gohan != vec4(0.0, 0.0, 1.0, 1.0)) bad |= 2;
if (goku[2].gohan != vec4(1.0, 1.0, 0.0, 1.0)) bad |= 4;
if (goku[3].gohan != vec4(0.0, 1.0, 1.0, 1.0)) bad |= 8;
o_color = vec4(float(bad) / 255.0, bad == 0 ? 1.0 : 0.0, 0.0, 1.0);
}
)";
// The producing stage declares the varying invariant (always legal) and the consuming
// stage redeclares it (the part that regressed at 4.20). The qualifier ORDER is the
// shuffled one 420pack exists to allow, so this also covers the parse path the
// qualifier_order cases exercise.
constexpr const char* kInvariantInVS = R"(#version 420 core
smooth invariant out highp vec4 v_data;
void main()
{
v_data = vec4(0.0, 1.0, 0.0, 1.0);
switch (gl_VertexID)
{
case 0: gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); break;
case 1: gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); break;
case 2: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break;
default: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break;
}
}
)";
constexpr const char* kInvariantInFS = R"(#version 420 core
highp in smooth invariant vec4 v_data;
out vec4 o_color;
void main() { o_color = v_data; }
)";
// atomicCounterIncrement() is core GLSL from 4.20 and needs no extension. MobileGL
// parses under Vulkan-relaxed rules, which rewrite it into an atomicAdd() on a buffer
// block - and glslang then applied to its OWN rewrite the desktop-below-430 gate that
// demands GL_ARB_shader_storage_buffer_object for atomicAdd, rejecting a shader it had
// just accepted. The shape is lifted from
// KHR-GL42.shader_image_load_store.advanced-sso-atomicCounters.
constexpr const char* kAtomicCounterVS = R"(#version 420 core
layout(binding = 0, offset = 0) uniform atomic_uint g_counter;
out flat uint v_index;
void main()
{
v_index = atomicCounterIncrement(g_counter);
switch (gl_VertexID)
{
case 0: gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); break;
case 1: gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); break;
case 2: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break;
default: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break;
}
}
)";
constexpr const char* kAtomicCounterFS = R"(#version 420 core
in flat uint v_index;
out vec4 o_color;
void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
)";
class Glsl420DeclarationScenario : public ScenarioTest {
protected:
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
if (!m_textures.empty()) glDeleteTextures(static_cast<GLsizei>(m_textures.size()), m_textures.data());
if (!m_buffers.empty()) glDeleteBuffers(static_cast<GLsizei>(m_buffers.size()), m_buffers.data());
for (GLuint p : m_programs) glDeleteProgram(p);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_textures.clear();
m_buffers.clear();
m_programs.clear();
m_vao = 0;
}
GLuint Build(const char* vs, const char* fs) {
std::string error;
const GLuint program = CompileProgram(vs, fs, &error);
if (program == 0) {
ADD_FAILURE() << "program did not build: " << error;
return 0;
}
m_programs.push_back(program);
return program;
}
// One 1x1 RGBA8 texture per element, each a colour whose channels are exactly 0 or
// 255 so the shader's == comparisons are exact.
void MakeElementTextures(const std::uint8_t colors[kElements][4]) {
m_textures.assign(kElements, 0);
glGenTextures(kElements, m_textures.data());
for (int i = 0; i < kElements; ++i) {
glActiveTexture(GL_TEXTURE0 + 1 + i);
glBindTexture(GL_TEXTURE_2D, m_textures[i]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, colors[i]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
}
glActiveTexture(GL_TEXTURE0);
}
void MakeElementBuffers(const float values[kElements][4], GLuint firstBinding) {
m_buffers.assign(kElements, 0);
glGenBuffers(kElements, m_buffers.data());
for (int i = 0; i < kElements; ++i) {
glBindBuffer(GL_UNIFORM_BUFFER, m_buffers[i]);
glBufferData(GL_UNIFORM_BUFFER, 4 * sizeof(float), values[i], GL_STATIC_DRAW);
glBindBufferBase(GL_UNIFORM_BUFFER, firstBinding + i, m_buffers[i]);
}
glBindBuffer(GL_UNIFORM_BUFFER, 0);
}
// Draws the full-screen quad and hands back the centre pixel.
Rgba8 DrawAndRead(GLuint program) {
HeadlessGL& gl = Gl();
if (m_vao == 0) glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
BindDefaultFramebuffer();
glViewport(0, 0, gl.Width(), gl.Height());
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const Image image = ReadPixels(gl.Width(), gl.Height());
glUseProgram(0);
return image.At(gl.Width() / 2, gl.Height() / 2);
}
// An array of ARRAYS is declined by Magma (ProgramFactory::ReflectLayout logs it and
// VkProgramObject::declinedDescriptors then refuses every draw), which is a defined
// outcome the case below can assert. Espryt has no such gate: it bakes the units the
// frontend reports into its ESSL, and since the binding-qualifier seeding does not
// walk the inner dimension every element reports unit 0 - so it samples one texture
// four times and paints a mismatch. That gap is in the FRONTEND, one level below
// either backend, and fixing it is the feature that would make this shape work
// everywhere; it is not part of wiring descriptor arrays through Magma, so the
// Espryt arm is SCOPED and the reflection half is asserted on both backends.
bool MultiDimensionalSamplerArraysAreDeclined() const { return Gl().BackendName() == "DirectVulkan"; }
// Same shape, different gap: with the compile fixed, this shader now links on
// both backends but paints nothing on Magma - the atomic counter becomes a
// buffer descriptor there and that half is not wired up yet (the conformance
// case KHR-GL42.shader_image_load_store.advanced-sso-atomicCounters is where it
// is measured). The regression this case exists for is the COMPILE, which is
// asserted on both backends above; only the paint is scoped.
bool AtomicCounterDrawsAreSupported() const { return Gl().BackendName() != "DirectVulkan"; }
static std::string BadElements(std::uint8_t mask) {
if (mask == 0) return "none";
std::string out;
for (int i = 0; i < kElements; ++i) {
if ((mask & (1u << i)) == 0) continue;
if (!out.empty()) out += ", ";
out += "[" + std::to_string(i) + "]";
}
return out;
}
std::vector<GLuint> m_textures;
std::vector<GLuint> m_buffers;
std::vector<GLuint> m_programs;
GLuint m_vao = 0;
};
} // namespace
// Element k of a sampler array samples texture unit N+k - both as the API reports it and,
// the part that was actually broken, as the draw behaves.
TEST_F(Glsl420DeclarationScenario, SamplerArrayElementsSampleConsecutiveTextureUnits) {
if (!Ready()) return;
static const std::uint8_t colors[kElements][4] = {
{255, 0, 0, 255}, {0, 0, 255, 255}, {255, 255, 0, 255}, {0, 255, 255, 255}};
MakeElementTextures(colors);
const GLuint program = Build(kQuadVS, kSamplerArrayFS);
if (program == 0) return;
// The reported unit is the shadow the frontend seeds from the qualifier. It was
// already right when the draw was wrong, so checking only this would have passed
// straight through the bug - it is here to separate a reflection regression from a
// backend one if this case ever fails again.
glUseProgram(program);
for (int i = 0; i < kElements; ++i) {
const std::string name = "goku[" + std::to_string(i) + "]";
const GLint location = glGetUniformLocation(program, name.c_str());
ASSERT_GE(location, 0) << name << " has no location";
GLint unit = -1;
glGetUniformiv(program, location, &unit);
EXPECT_EQ(unit, 1 + i) << name << " should default to texture unit " << (1 + i);
}
glUseProgram(0);
const Rgba8 centre = DrawAndRead(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(centre.r, 0) << "sampler array elements that read the wrong texture: " << BadElements(centre.r);
EXPECT_EQ(centre.g, 255) << "the draw did not reach the fragment stage at all";
}
// An array of ARRAYS of samplers is the shape the two reflections count differently:
// SPIRV-Reflect reports one binding of 4 flattened descriptors, while the frontend hands out
// uniform locations along the outer dimension only and keys the uniform by its full
// "goku[0][0]" spelling. Magma therefore cannot address elements 1..3 of that binding, and
// the contract this case pins is that it says so and DECLINES - the failure it must never
// return to is resolving those elements onto whatever uniform got the next locations, which
// is a silently wrong texture rather than a missing draw.
//
// Deliberately weak on the pixels for that reason: what is asserted on every backend is that
// the program builds, the draw raises no GL error, and the process survives. Where the
// descriptors do resolve, the colours are checked too.
TEST_F(Glsl420DeclarationScenario, AnArrayOfSamplerArraysIsHonouredOrDeclinedCleanly) {
if (!Ready()) return;
static const std::uint8_t colors[kElements][4] = {
{255, 0, 0, 255}, {0, 0, 255, 255}, {255, 255, 0, 255}, {0, 255, 255, 255}};
MakeElementTextures(colors);
std::string error;
const GLuint program = CompileProgram(kQuadVS, kSamplerArrayOfArraysFS, &error);
if (program == 0) {
GTEST_SKIP() << "the frontend does not build an array of sampler arrays: " << error;
}
m_programs.push_back(program);
// The reflection DOES reserve one location per flattened element, in the order
// SPIRV-Reflect flattens them - which is the whole reason baseLocation + element is the
// right addressing rule for a descriptor array, and would be right for this shape too.
// What is missing is one level up: the `layout(binding = 1)` unit seeding walks the outer
// dimension only, so all four elements report unit 0 instead of 1..4. That is why this
// shape is declined rather than supported, and it is asserted here because the day the
// seeding learns arrays of arrays, the decline should be revisited rather than kept.
glUseProgram(program);
for (int outer = 0; outer < 2; ++outer) {
for (int inner = 0; inner < 2; ++inner) {
const std::string name = "goku[" + std::to_string(outer) + "][" + std::to_string(inner) + "]";
EXPECT_EQ(glGetUniformLocation(program, name.c_str()), outer * 2 + inner)
<< name << " should hold the flattened element's own location";
}
}
glUseProgram(0);
const Rgba8 centre = DrawAndRead(program);
EXPECT_EQ(FirstGLError(), 0u) << "declining a descriptor array must not raise a GL error";
if (!MultiDimensionalSamplerArraysAreDeclined()) {
GTEST_SKIP() << "the frontend's binding-qualifier seeding does not walk an array of arrays, so "
<< Gl().BackendName() << " samples unit 0 for every element; the locations "
<< "asserted above are the half of this case it can answer";
}
// Three outcomes are possible and only two are acceptable. Green means every element
// sampled its own unit. Black - the untouched clear - means the program was declined and
// painted nothing, which is the documented Magma outcome. A non-zero red channel is the
// third: the draw DID reach the fragment stage and elements read the wrong textures,
// which is exactly the silent mismatch this decline exists to prevent.
if (centre.g == 255) {
EXPECT_EQ(centre.r, 0) << "elements of the array of arrays that read the wrong texture: "
<< BadElements(centre.r);
return;
}
EXPECT_EQ(centre.r, 0) << "the array of arrays was not resolved, but the draw still painted "
"a mismatch instead of being declined: " << BadElements(centre.r);
}
// Instance k of a uniform block array sits on buffer binding point N+k - again both as
// reported and as fed to the shader.
TEST_F(Glsl420DeclarationScenario, UniformBlockArrayInstancesTakeConsecutiveBindings) {
if (!Ready()) return;
static const float values[kElements][4] = {
{1.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 1.0f, 1.0f}, {1.0f, 1.0f, 0.0f, 1.0f}, {0.0f, 1.0f, 1.0f, 1.0f}};
constexpr GLuint kFirstBinding = 2;
MakeElementBuffers(values, kFirstBinding);
const GLuint program = Build(kQuadVS, kBlockArrayFS);
if (program == 0) return;
for (int i = 0; i < kElements; ++i) {
const std::string name = "GOKU[" + std::to_string(i) + "]";
const GLuint index = glGetUniformBlockIndex(program, name.c_str());
ASSERT_NE(index, static_cast<GLuint>(GL_INVALID_INDEX)) << name << " is not an active block";
GLint binding = -1;
glGetActiveUniformBlockiv(program, index, GL_UNIFORM_BLOCK_BINDING, &binding);
EXPECT_EQ(binding, static_cast<GLint>(kFirstBinding) + i)
<< name << " should start on binding point " << (kFirstBinding + i);
}
EXPECT_EQ(FirstGLError(), 0u) << "the block queries left a GL error behind";
const Rgba8 centre = DrawAndRead(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(centre.r, 0) << "block array instances that read the wrong buffer: " << BadElements(centre.r);
EXPECT_EQ(centre.g, 255) << "the draw did not reach the fragment stage at all";
}
// 'invariant' written on a fragment input at #version 420. The same source compiles at
// #version 400 on any implementation, so a version-dependent rejection is the defect.
TEST_F(Glsl420DeclarationScenario, InvariantIsAcceptedOnANonVertexStageInput) {
if (!Ready()) return;
const GLuint program = Build(kInvariantInVS, kInvariantInFS);
if (program == 0) return;
const Rgba8 centre = DrawAndRead(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(centre.g, 255) << "the invariant-qualified varying did not arrive";
EXPECT_EQ(centre.r, 0);
}
// A #version 420 shader may call atomicCounterIncrement() with no extension at all. The
// assertion is deliberately the COMPILE, because the defect was a compile-time gate on
// glslang's own atomic-counter lowering; the draw that follows only checks the shader
// survives the rest of the pipeline without leaving an error behind.
TEST_F(Glsl420DeclarationScenario, AnAtomicCounterCompilesWithoutTheSsboExtension) {
if (!Ready()) return;
const GLuint shader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(shader, 1, &kAtomicCounterVS, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[2048] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
glDeleteShader(shader);
FAIL() << "atomicCounterIncrement() at #version 420 core did not compile: " << log;
}
glDeleteShader(shader);
const GLuint program = Build(kAtomicCounterVS, kAtomicCounterFS);
if (program == 0) return;
GLuint counter = 0;
glGenBuffers(1, &counter);
m_buffers.push_back(counter);
const GLuint zero = 0;
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, counter);
glBufferData(GL_ATOMIC_COUNTER_BUFFER, sizeof(GLuint), &zero, GL_DYNAMIC_DRAW);
glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, 0, counter);
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, 0);
if (!AtomicCounterDrawsAreSupported()) {
GTEST_SKIP() << "atomic-counter draws do not paint on " << Gl().BackendName()
<< " yet; the compile above is what this case pins";
}
const Rgba8 centre = DrawAndRead(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(centre.g, 255) << "the atomic-counter shader linked but painted nothing";
}
} // namespace MGITest
@@ -0,0 +1,314 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ImageFormatQualifierScenario.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 - AN IMAGE UNIFORM THAT DECLARES NO FORMAT.
//
// Desktop GLSL 4.2 lets a writeonly image declaration omit its format layout qualifier:
//
// writeonly uniform uimage2D uni_image; // legal desktop GLSL
//
// GLSL ES has no such relaxation; every image uniform must carry one, and Adreno says so as "all
// images have to define layout format", which fails the whole program. That is what took the
// compute half of KHR-GL4x.packed_depth_stencil.stencil_texturing.
//
// The only qualifier that is CORRECT to substitute is whatever glBindImageTexture named for the
// unit that uniform addresses - GL requires the qualifier, the bind format and the texture's
// internal format to belong to one format class - so the format is not knowable when the shader
// is compiled, only when it is drawn with. Espryt therefore BAKES it into the program it
// generates and keys that program on the (unit, format) pairs it baked
// (BackendProgramObjectImpl::ImageUnitFormatsStillMatch, MG_Backend/DirectGLES).
//
// Three separate things follow from "the program is built against live binding state", and each
// one is a case below:
//
// 1. the format reaches the shader at all, so the store lands where the texture is (Writes);
// 2. binding a DIFFERENT format to the same unit rebuilds the program, rather than reusing one
// compiled against the old format (RebindToADifferentFormatRebuilds);
// 3. an image bound for the FIRST time after the link works, i.e. the program built against
// "nothing bound yet" is not the one the dispatch runs (FirstBindAfterLinkRebuilds).
//
// Magma needs none of this - Vulkan takes an Unknown-format storage image given
// shaderStorageImageWriteWithoutFormat, and the view format is resolved from the same bind state
// at descriptor time - so every case here runs on both backends and must agree, which is what
// makes the ES-only machinery falsifiable rather than merely exercised.
#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 kExtent = 4;
// The image unit is deliberately NOT 0 and the uniform declares no binding, so the unit
// has to travel through glUniform1i and be baked into the ESSL alongside the format -
// the two bakes share a rebuild key and a bug in either shows up as the wrong texel.
constexpr GLint kImageUnit = 1;
// KHR-GL4x.packed_depth_stencil.stencil_texturing's own image declaration, verbatim.
const char* kStoreSource = R"(#version 430 core
layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
writeonly uniform uimage2D uni_image;
void main()
{
imageStore(uni_image, ivec2(gl_GlobalInvocationID.xy), uvec4(gl_GlobalInvocationID.x + 100u, 0u, 0u, 0u));
}
)";
class ImageFormatQualifierScenario : public ScenarioTest {
protected:
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (GLuint p : m_programs) glDeleteProgram(p);
for (GLuint t : m_textures) glDeleteTextures(1, &t);
m_programs.clear();
m_textures.clear();
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
for (GLint unit = 0; unit < maxImageUnits; ++unit) {
glBindImageTexture(static_cast<GLuint>(unit), 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_R32UI);
}
while (glGetError() != GL_NO_ERROR) {
}
}
bool ImagesAreUsable() const {
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
GLint maxComputeImageUniforms = 0;
glGetIntegerv(GL_MAX_COMPUTE_IMAGE_UNIFORMS, &maxComputeImageUniforms);
while (glGetError() != GL_NO_ERROR) {
}
return maxImageUnits > kImageUnit && maxComputeImageUniforms >= 1;
}
GLuint MakeComputeProgram(const std::string& source) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
const char* text = source.c_str();
glShaderSource(shader, 1, &text, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[4096] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute shader did not compile: " << log;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
m_programs.push_back(program);
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[4096] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute program did not link: " << log;
return 0;
}
return program;
}
GLuint MakeTexture(GLenum internalFormat) {
GLuint texture = 0;
glGenTextures(1, &texture);
m_textures.push_back(texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexStorage2D(GL_TEXTURE_2D, 1, internalFormat, kExtent, kExtent);
if (const GLenum error = FirstGLError()) {
ADD_FAILURE() << "allocating storage errored with " << GLErrorName(error);
return 0;
}
// Seeded to a value no dispatch writes, so "the store never happened" and "the
// store wrote the right thing" cannot be confused.
const std::vector<GLuint> zeros(static_cast<std::size_t>(kExtent) * kExtent * 4u, 0u);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kExtent, kExtent,
internalFormat == GL_RGBA32UI ? GL_RGBA_INTEGER : GL_RED_INTEGER, GL_UNSIGNED_INT,
zeros.data());
while (glGetError() != GL_NO_ERROR) {
}
return texture;
}
// Texel (x, 0) of the texture's red channel, read back through the GL frontend rather
// than through a second image uniform: a defect in the format bake would be shared by
// a reader declared the same way and could cancel itself out.
GLuint ReadRedTexel(GLuint texture, GLenum internalFormat, int x) {
const bool rgba = internalFormat == GL_RGBA32UI;
std::vector<GLuint> texels(static_cast<std::size_t>(kExtent) * kExtent * (rgba ? 4u : 1u),
0xFFFFFFFFu);
glBindTexture(GL_TEXTURE_2D, texture);
glGetTexImage(GL_TEXTURE_2D, 0, rgba ? GL_RGBA_INTEGER : GL_RED_INTEGER, GL_UNSIGNED_INT,
texels.data());
if (const GLenum error = FirstGLError()) {
ADD_FAILURE() << "reading the image back errored with " << GLErrorName(error);
return 0xFFFFFFFFu;
}
return texels[static_cast<std::size_t>(x) * (rgba ? 4u : 1u)];
}
void DispatchStore(GLuint program, GLuint texture, GLenum internalFormat) {
glBindImageTexture(static_cast<GLuint>(kImageUnit), texture, 0, GL_FALSE, 0, GL_WRITE_ONLY,
internalFormat);
ASSERT_EQ(FirstGLError(), 0u) << "glBindImageTexture errored";
glUseProgram(program);
const GLint location = glGetUniformLocation(program, "uni_image");
ASSERT_GE(location, 0) << "the image uniform was not reflected";
glUniform1i(location, kImageUnit);
ASSERT_EQ(FirstGLError(), 0u) << "assigning the image unit errored";
glDispatchCompute(kExtent, 1, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(FirstGLError(), 0u) << "the dispatch leaked a GL error";
glUseProgram(0);
}
std::vector<GLuint> m_programs;
std::vector<GLuint> m_textures;
};
// The defect itself. Without the bake the ES driver refuses the program outright and the
// texture keeps its seed - which is also exactly what a silently no-op dispatch looks
// like, and why the seed is a value no store writes.
TEST_F(ImageFormatQualifierScenario, AFormatlessWriteonlyImageWrites) {
if (!Ready()) GTEST_SKIP() << "no GL context";
if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver";
const GLuint program = MakeComputeProgram(kStoreSource);
const GLuint texture = MakeTexture(GL_R32UI);
if (program == 0 || texture == 0) return;
DispatchStore(program, texture, GL_R32UI);
for (int x = 0; x < kExtent; ++x) {
EXPECT_EQ(ReadRedTexel(texture, GL_R32UI, x), static_cast<GLuint>(x) + 100u)
<< "texel " << x << " of a format-less writeonly image did not take the store";
}
}
// The rebuild key. The SAME program is dispatched twice with a different format bound to
// its unit; a build keyed only on the link (or only on the image UNIT) would reuse the
// r32ui program for the rgba32ui texture, and the second half would come back seeded.
//
// What the SOFTWARE lanes cannot falsify: with the key disabled this case still passes on
// Mesa, because the reused r32ui declaration writes the red channel of an RGBA32UI image
// anyway - a format-class mismatch GL leaves undefined and that driver happens to absorb.
// FirstBindAfterLinkRebuilds below is the case that fails there, because the reused
// program was built with no format at all and never compiled. Both are kept: this one is
// the shape a strict driver is entitled to reject, and it is the shape the device runs.
TEST_F(ImageFormatQualifierScenario, RebindToADifferentFormatRebuilds) {
if (!Ready()) GTEST_SKIP() << "no GL context";
if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver";
const GLuint program = MakeComputeProgram(kStoreSource);
const GLuint first = MakeTexture(GL_R32UI);
const GLuint second = MakeTexture(GL_RGBA32UI);
if (program == 0 || first == 0 || second == 0) return;
DispatchStore(program, first, GL_R32UI);
for (int x = 0; x < kExtent; ++x) {
ASSERT_EQ(ReadRedTexel(first, GL_R32UI, x), static_cast<GLuint>(x) + 100u)
<< "the first format must work before the rebind can be blamed for anything";
}
DispatchStore(program, second, GL_RGBA32UI);
for (int x = 0; x < kExtent; ++x) {
EXPECT_EQ(ReadRedTexel(second, GL_RGBA32UI, x), static_cast<GLuint>(x) + 100u)
<< "texel " << x << ": the program was not rebuilt for the newly bound format";
}
// ...and back, so the rebuild is not a one-way door: returning to a format the
// program was once built against must build for it again, not resurrect a cache row.
const GLuint third = MakeTexture(GL_R32UI);
if (third == 0) return;
DispatchStore(program, third, GL_R32UI);
for (int x = 0; x < kExtent; ++x) {
EXPECT_EQ(ReadRedTexel(third, GL_R32UI, x), static_cast<GLuint>(x) + 100u)
<< "texel " << x << ": going back to the first format did not rebuild";
}
}
// Nothing is bound to the unit when the program links, so whatever the first build sees
// is not the format the dispatch needs. glBindImageTexture must not itself trigger a
// build - it is an entry point, and building there is the constraint
// glShaderStorageBlockBinding is held to as well - so the rebuild has to happen at the
// next dispatch preparation instead. This case fails either way round: no rebuild, or a
// build attempted from the entry point before the state settles.
TEST_F(ImageFormatQualifierScenario, FirstBindAfterLinkRebuilds) {
if (!Ready()) GTEST_SKIP() << "no GL context";
if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver";
const GLuint program = MakeComputeProgram(kStoreSource);
if (program == 0) return;
// Use it once with NOTHING bound to the unit, which is what makes the backend build
// against an empty binding. The dispatch writes nowhere and must not error.
glUseProgram(program);
const GLint location = glGetUniformLocation(program, "uni_image");
ASSERT_GE(location, 0);
glUniform1i(location, kImageUnit);
glDispatchCompute(kExtent, 1, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(FirstGLError(), 0u) << "dispatching with an unbound image unit must not error";
glUseProgram(0);
const GLuint texture = MakeTexture(GL_R32UI);
if (texture == 0) return;
DispatchStore(program, texture, GL_R32UI);
for (int x = 0; x < kExtent; ++x) {
EXPECT_EQ(ReadRedTexel(texture, GL_R32UI, x), static_cast<GLuint>(x) + 100u)
<< "texel " << x << ": the first bind after the link did not reach the shader";
}
}
// A DECLARED format is authoritative and the bake must never touch it - including when
// the texture behind the unit has a different (but class-compatible) internal format,
// which GL explicitly allows. If the bake ever overrode a declaration, this is the case
// that would go wrong while every other one stayed green.
TEST_F(ImageFormatQualifierScenario, ADeclaredFormatStillWins) {
if (!Ready()) GTEST_SKIP() << "no GL context";
if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver";
const GLuint program = MakeComputeProgram(R"(#version 430 core
layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
layout (r32ui) writeonly uniform uimage2D uni_image;
void main()
{
imageStore(uni_image, ivec2(gl_GlobalInvocationID.xy), uvec4(gl_GlobalInvocationID.x + 100u, 0u, 0u, 0u));
}
)");
const GLuint texture = MakeTexture(GL_R32UI);
if (program == 0 || texture == 0) return;
DispatchStore(program, texture, GL_R32UI);
for (int x = 0; x < kExtent; ++x) {
EXPECT_EQ(ReadRedTexel(texture, GL_R32UI, x), static_cast<GLuint>(x) + 100u)
<< "texel " << x << ": a declared format stopped working";
}
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,473 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.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 - IMAGE UNIFORMS REACHED THROUGH A PROGRAM PIPELINE.
//
// KHR-GL42.shader_image_load_store.advanced-sso-simple reduced to its mechanism. An ARRAY of
// image uniforms lives in a separable FRAGMENT program; the application assigns each element its
// own image unit with glProgramUniform1i, on a program that is not current and whose pipeline is
// not even bound yet; the draw then goes through the pipeline, i.e. through the flattened
// composite program (MG_State/GLState/Core.cpp, GetProgramForDraw) rather than through the stage
// program the units were written to.
//
// Three separate things have to survive that indirection, and each one is a different mechanism:
//
// 1. the units themselves, which are per-program state on a DIFFERENT object from the one the
// draw reads (the composite mirror carries them);
// 2. the units as seen by a backend that cannot take them at draw time - Espryt has to BAKE an
// image unit into the ESSL it generates, because ES forbids glUniform1i on image uniforms,
// so a change has to invalidate the generated program;
// 3. per-ELEMENT assignment, which is what makes this different from every sampler case: the
// four elements of g_image[] are four locations with four different units, and nothing may
// collapse them to the array's base.
//
// Two pipelines that SHARE their vertex stage program and differ only in the fragment one are
// used exactly as the conformance case does, because that is what makes the composite cache and
// the stage programs' separate uniform storage both load-bearing at once.
#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 const char* kSsoVS = R"(#version 420 core
out gl_PerVertex { vec4 gl_Position; };
void main()
{
switch (gl_VertexID)
{
case 0: gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); break;
case 1: gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); break;
case 2: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break;
case 3: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break;
}
}
)";
// The conformance case's two fragment programs: one with an explicit format qualifier,
// one writeonly with none. Both write every element of a four-image array and discard.
constexpr const char* kImageFS0 = R"(#version 420 core
layout(rgba32f) uniform image2D g_image[4];
void main()
{
for (int i = 0; i < g_image.length(); ++i) {
imageStore(g_image[i], ivec2(gl_FragCoord), vec4(1.0));
}
discard;
}
)";
constexpr const char* kImageFS1 = R"(#version 420 core
writeonly uniform image2D g_image[4];
void main()
{
for (int i = 0; i < g_image.length(); ++i) {
imageStore(g_image[i], ivec2(gl_FragCoord), vec4(2.0));
}
discard;
}
)";
class ImageLoadStoreSsoScenario : public ScenarioTest {
protected:
void TearDown() override {
if (!Ready()) return;
glBindProgramPipeline(0);
glUseProgram(0);
for (GLuint p : m_programs) glDeleteProgram(p);
for (GLuint p : m_pipelines) glDeleteProgramPipelines(1, &p);
m_programs.clear();
m_pipelines.clear();
}
GLuint MakeSeparable(GLenum stage, const char* source) {
const GLuint program = glCreateShaderProgramv(stage, 1, &source);
if (program != 0) m_programs.push_back(program);
EXPECT_EQ(FirstGLError(), 0u)
<< "glCreateShaderProgramv(stage 0x" << std::hex << stage << std::dec << ") left a GL error";
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[2048] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "glCreateShaderProgramv(stage 0x" << std::hex << stage << std::dec
<< ") did not link: " << log;
return 0;
}
return program;
}
GLuint MakePipeline() {
GLuint pipeline = 0;
glGenProgramPipelines(1, &pipeline);
m_pipelines.push_back(pipeline);
return pipeline;
}
// Espryt reaches the GPU through an ES driver, and ES forbids glUniform1i on an
// image uniform: the unit has to be BAKED into the generated ESSL as
// layout(binding = N) (RebindImageUniformsToFrontendUnits, MG_Backend/DirectGLES).
// One qualifier is all an ARRAY declaration can carry, and ESSL then gives the
// array's elements the CONSECUTIVE units N, N+1, N+2, ... - so a per-element
// assignment that is not consecutive (the conformance case uses 0, 2, 4, 6) has no
// spelling in a single declaration and cannot be expressed at all without splitting
// the array into one declaration per element and rewriting every use of it.
//
// Scoped rather than disabled, exactly as ProgramPipelineScenario scopes its
// storage-block rebinding cases: the defect is per-backend and the frontend
// mechanism these cases exist for - per-element units surviving the trip to the
// pipeline composite - is fully exercised on Magma.
bool PerElementImageUnitsAreHonoured() const { return Gl().BackendName() == "DirectVulkan"; }
// The scenarios below need image load/store at all; a driver without it should skip
// rather than fail.
bool ImagesAreUsable() const {
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
while (glGetError() != GL_NO_ERROR) {
}
return maxImageUnits >= 8;
}
std::vector<GLuint> m_programs;
std::vector<GLuint> m_pipelines;
};
} // namespace
// The whole conformance shape in one case: two pipelines sharing a vertex stage, four image
// array elements each pointed at a different unit through glProgramUniform1i, eight layers of
// one array texture bound one per unit, and every layer checked.
//
// Layers alternate 1.0 / 2.0 because the two fragment programs interleave their units
// (0,2,4,6 and 1,3,5,7) - so a defect that collapses an image array to its base element, or
// that loses the units on the way to the composite, does not merely dim the result: it puts
// the wrong VALUE in a layer and names which one.
TEST_F(ImageLoadStoreSsoScenario, PerElementImageUnitsReachAPipelineDraw) {
if (!Ready()) return;
if (!ImagesAreUsable()) GTEST_SKIP() << "fewer than 8 image units";
if (!PerElementImageUnitsAreHonoured()) {
GTEST_SKIP() << "non-consecutive per-element image units cannot be baked into ESSL";
}
HeadlessGL& gl = Gl();
constexpr int kWidth = 8;
constexpr int kHeight = 8;
constexpr int kLayers = 8;
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSsoVS);
const GLuint fs0 = MakeSeparable(GL_FRAGMENT_SHADER, kImageFS0);
const GLuint fs1 = MakeSeparable(GL_FRAGMENT_SHADER, kImageFS1);
if (vs == 0 || fs0 == 0 || fs1 == 0) return;
// Per ELEMENT, by name, on programs that are neither current nor attached to a bound
// pipeline yet - exactly the conformance call order.
const int units0[4] = {0, 2, 4, 6};
const int units1[4] = {1, 3, 5, 7};
for (int i = 0; i < 4; ++i) {
const std::string name = "g_image[" + std::to_string(i) + "]";
const GLint loc0 = glGetUniformLocation(fs0, name.c_str());
const GLint loc1 = glGetUniformLocation(fs1, name.c_str());
ASSERT_NE(loc0, -1) << "fs0 has no location for " << name;
ASSERT_NE(loc1, -1) << "fs1 has no location for " << name;
glProgramUniform1i(fs0, loc0, units0[i]);
glProgramUniform1i(fs1, loc1, units1[i]);
}
ASSERT_EQ(FirstGLError(), 0u) << "assigning image units with glProgramUniform1i errored";
const GLuint pipeline0 = MakePipeline();
const GLuint pipeline1 = MakePipeline();
glUseProgramStages(pipeline0, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline0, GL_FRAGMENT_SHADER_BIT, fs0);
glUseProgramStages(pipeline1, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline1, GL_FRAGMENT_SHADER_BIT, fs1);
ASSERT_EQ(FirstGLError(), 0u) << "pipeline setup errored";
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
const std::vector<float> zeros(static_cast<size_t>(kWidth) * kHeight * kLayers * 4, 0.0f);
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA32F, kWidth, kHeight, kLayers, 0, GL_RGBA, GL_FLOAT, zeros.data());
ASSERT_EQ(FirstGLError(), 0u) << "creating the RGBA32F array texture errored";
// One LAYER of the array texture per unit, which is what makes each element's unit
// independently observable in the readback.
for (int unit = 0; unit < kLayers; ++unit) {
glBindImageTexture(static_cast<GLuint>(unit), texture, 0, GL_FALSE, unit, GL_READ_WRITE, GL_RGBA32F);
}
ASSERT_EQ(FirstGLError(), 0u) << "glBindImageTexture errored";
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glViewport(0, 0, kWidth, kHeight);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glUseProgram(0);
glBindProgramPipeline(pipeline0);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindProgramPipeline(pipeline1);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glMemoryBarrier(GL_TEXTURE_UPDATE_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
EXPECT_EQ(FirstGLError(), 0u) << "the two pipeline draws leaked a GL error";
std::vector<float> readback(static_cast<size_t>(kWidth) * kHeight * kLayers * 4, -1.0f);
glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
glGetTexImage(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA, GL_FLOAT, readback.data());
ASSERT_EQ(FirstGLError(), 0u) << "reading the array texture back errored";
// Even layers were written through fs0's units, odd layers through fs1's.
for (int layer = 0; layer < kLayers; ++layer) {
const float expected = (layer % 2) ? 2.0f : 1.0f;
int offenders = 0;
float firstSeen = 0.0f;
for (int y = 0; y < kHeight; ++y) {
for (int x = 0; x < kWidth; ++x) {
const size_t base =
(static_cast<size_t>(layer) * kHeight * kWidth + static_cast<size_t>(y) * kWidth + x) * 4;
for (int c = 0; c < 4; ++c) {
if (readback[base + c] != expected) {
if (offenders == 0) firstSeen = readback[base + c];
++offenders;
}
}
}
}
EXPECT_EQ(offenders, 0) << "layer " << layer << " (image unit " << layer << ") expected " << expected
<< " but " << offenders << " components differ; first was " << firstSeen;
}
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteTextures(1, &texture);
gl.EndFrame();
}
// An image ARRAY sharing a program with another descriptor, which is the shape that makes
// the SPIR-V binding remap load-bearing.
//
// The remap (ProgramFactory::RemapDescriptorBindingsForVulkan) is what unifies bindings
// across stages and normalises every descriptor onto set 0; glslang hands it per-stage
// numbering that starts at 0 in EACH stage. It used to refuse any descriptor array that was
// not a UBO, and its only complaint was an assert that compiles out above DEBUG - so a
// release build carried on with the un-remapped numbering and a program holding an image
// array plus a second descriptor could see the two alias onto one binding, while a DEBUG
// build trapped on the very same program.
//
// A case with ONE descriptor cannot see any of that: with a single resource there is nothing
// to collide with and skipping the remap is indistinguishable from running it. Hence this
// one - an image array AND a uniform block in the same fragment program, with the block
// supplying the value that gets stored, so a mis-assigned binding shows up as the wrong
// colour rather than as nothing at all.
TEST_F(ImageLoadStoreSsoScenario, AnImageArrayAlongsideAnotherDescriptorKeepsBothBindings) {
if (!Ready()) return;
if (!ImagesAreUsable()) GTEST_SKIP() << "fewer than 8 image units";
if (!PerElementImageUnitsAreHonoured()) {
GTEST_SKIP() << "non-consecutive per-element image units cannot be baked into ESSL";
}
HeadlessGL& gl = Gl();
constexpr int kWidth = 8;
constexpr int kHeight = 8;
constexpr int kLayers = 2;
static const char* kMixedFS = R"(#version 420 core
layout(rgba32f) uniform image2D g_image[2];
layout(std140) uniform Value { vec4 u_value; };
void main()
{
for (int i = 0; i < g_image.length(); ++i) {
imageStore(g_image[i], ivec2(gl_FragCoord), u_value);
}
discard;
}
)";
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSsoVS);
const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kMixedFS);
if (vs == 0 || fs == 0) return;
// Consecutive units here on purpose: this case is about the two descriptor KINDS
// coexisting, not about non-consecutive assignment, which the case above covers.
for (int i = 0; i < 2; ++i) {
const std::string name = "g_image[" + std::to_string(i) + "]";
const GLint loc = glGetUniformLocation(fs, name.c_str());
ASSERT_NE(loc, -1) << "no location for " << name;
glProgramUniform1i(fs, loc, i);
}
const GLfloat value[4] = {7.0f, 7.0f, 7.0f, 7.0f};
GLuint ubo = 0;
glGenBuffers(1, &ubo);
glBindBuffer(GL_UNIFORM_BUFFER, ubo);
glBufferData(GL_UNIFORM_BUFFER, sizeof(value), value, GL_STATIC_DRAW);
const GLuint blockIndex = glGetUniformBlockIndex(fs, "Value");
ASSERT_NE(blockIndex, GL_INVALID_INDEX);
glUniformBlockBinding(fs, blockIndex, 0);
glBindBufferBase(GL_UNIFORM_BUFFER, 0, ubo);
glBindBuffer(GL_UNIFORM_BUFFER, 0);
ASSERT_EQ(FirstGLError(), 0u) << "uniform block setup errored";
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
const std::vector<float> zeros(static_cast<size_t>(kWidth) * kHeight * kLayers * 4, 0.0f);
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA32F, kWidth, kHeight, kLayers, 0, GL_RGBA, GL_FLOAT, zeros.data());
glBindImageTexture(0, texture, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA32F);
glBindImageTexture(1, texture, 0, GL_FALSE, 1, GL_READ_WRITE, GL_RGBA32F);
ASSERT_EQ(FirstGLError(), 0u) << "image texture setup errored";
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glViewport(0, 0, kWidth, kHeight);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glUseProgram(0);
glBindProgramPipeline(pipeline);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glMemoryBarrier(GL_TEXTURE_UPDATE_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
EXPECT_EQ(FirstGLError(), 0u) << "the mixed-descriptor pipeline draw leaked a GL error";
std::vector<float> readback(static_cast<size_t>(kWidth) * kHeight * kLayers * 4, -1.0f);
glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
glGetTexImage(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA, GL_FLOAT, readback.data());
ASSERT_EQ(FirstGLError(), 0u) << "reading the array texture back errored";
for (int layer = 0; layer < kLayers; ++layer) {
int offenders = 0;
float firstSeen = 0.0f;
for (size_t i = 0; i < static_cast<size_t>(kWidth) * kHeight * 4; ++i) {
const size_t index = static_cast<size_t>(layer) * kHeight * kWidth * 4 + i;
if (readback[index] != 7.0f) {
if (offenders == 0) firstSeen = readback[index];
++offenders;
}
}
EXPECT_EQ(offenders, 0) << "layer " << layer << ": " << offenders
<< " components are not the uniform block's value; first was " << firstSeen
<< " (an image-array binding and a uniform block did not both survive)";
}
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteTextures(1, &texture);
glDeleteBuffers(1, &ubo);
gl.EndFrame();
}
// The same units, reassigned BETWEEN draws through the same pipeline. This is the half that
// the composite cache key change put weight on: the composite object now survives a
// glProgramUniform1i, so nothing rebuilds by accident and the new unit has to be carried by
// the refresh path (and, on Espryt, by regenerating the program the unit is baked into).
TEST_F(ImageLoadStoreSsoScenario, ReassigningAnImageUnitBetweenDrawsReachesTheNextDraw) {
if (!Ready()) return;
if (!ImagesAreUsable()) GTEST_SKIP() << "fewer than 8 image units";
HeadlessGL& gl = Gl();
constexpr int kWidth = 8;
constexpr int kHeight = 8;
constexpr int kLayers = 2;
static const char* kSingleImageFS = R"(#version 420 core
layout(rgba32f) uniform image2D g_image;
void main()
{
imageStore(g_image, ivec2(gl_FragCoord), vec4(3.0));
discard;
}
)";
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSsoVS);
const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kSingleImageFS);
if (vs == 0 || fs == 0) return;
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
const std::vector<float> zeros(static_cast<size_t>(kWidth) * kHeight * kLayers * 4, 0.0f);
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA32F, kWidth, kHeight, kLayers, 0, GL_RGBA, GL_FLOAT, zeros.data());
glBindImageTexture(0, texture, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA32F);
glBindImageTexture(1, texture, 0, GL_FALSE, 1, GL_READ_WRITE, GL_RGBA32F);
ASSERT_EQ(FirstGLError(), 0u) << "image texture setup errored";
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glViewport(0, 0, kWidth, kHeight);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glUseProgram(0);
glBindProgramPipeline(pipeline);
const GLint location = glGetUniformLocation(fs, "g_image");
ASSERT_NE(location, -1);
// Draw one against unit 0 (layer 0)...
glProgramUniform1i(fs, location, 0);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
// ...and draw two against unit 1 (layer 1), with the composite already built and cached.
glProgramUniform1i(fs, location, 1);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glMemoryBarrier(GL_TEXTURE_UPDATE_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
EXPECT_EQ(FirstGLError(), 0u) << "the two pipeline draws leaked a GL error";
std::vector<float> readback(static_cast<size_t>(kWidth) * kHeight * kLayers * 4, -1.0f);
glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
glGetTexImage(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA, GL_FLOAT, readback.data());
ASSERT_EQ(FirstGLError(), 0u) << "reading the array texture back errored";
for (int layer = 0; layer < kLayers; ++layer) {
int offenders = 0;
float firstSeen = 0.0f;
for (size_t i = 0; i < static_cast<size_t>(kWidth) * kHeight * 4; ++i) {
const size_t index = static_cast<size_t>(layer) * kHeight * kWidth * 4 + i;
if (readback[index] != 3.0f) {
if (offenders == 0) firstSeen = readback[index];
++offenders;
}
}
EXPECT_EQ(offenders, 0) << "layer " << layer << " was not written; " << offenders
<< " components differ, first was " << firstSeen
<< " (the image unit reassignment did not reach the draw)";
}
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteTextures(1, &texture);
gl.EndFrame();
}
} // namespace MGITest
@@ -0,0 +1,550 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ImageTargetKindScenario.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 - ONE IMAGE TARGET KIND AT A TIME, THROUGH A COMPUTE DISPATCH.
//
// KHR-GL44.multi_bind.dispatch_bind_image_textures decomposed. That conformance case declares
// ELEVEN image uniforms of eleven different target kinds in one compute shader, binds a texture
// of the matching kind to each unit, sums one texel from every one of them and compares the sum
// against N*(N-1)/2. It is a single pass/fail bit over eleven independent mechanisms: if any one
// of them is wrong - or merely fails to compile - the case fails and says nothing about which.
// That is what it did here, on both backends, for two waves.
//
// So the eleven are pulled apart into one case each. Each case declares ONE image uniform, binds
// ONE texture and checks the value that comes back, so a failure names the target kind and the
// direction. What the conformance case does with eleven at once, AllKindsInOneProgram at the
// bottom still does - a defect that only appears when several kinds share a program is invisible
// to the single-kind cases by construction.
//
// The shape is deliberately the conformance case's own, not a cleaner equivalent:
//
// * r32ui / GL_R32UI throughout, 6x6x6 storage, one level, texel (0,0,0) read;
// * `layout (location = N, r32ui) readonly uniform` - an explicit uniform LOCATION, not a
// binding, with the image unit then assigned by glUniform1i. That combination is the one ES
// cannot express directly, because ES forbids glUniform1i on an image uniform and the unit
// has to be baked into the generated ESSL (RebindImageUniformsToFrontendUnits);
// * `layout (std140, ...) buffer` for the result block - legal, but unusual enough that a
// frontend could plausibly mishandle it. Mirroring it means a green scenario cannot be green
// for a reason the conformance case excludes;
// * glBindImageTexture with layered = GL_TRUE, which is what glBindImageTextures is specified
// to pass, and which is where a target kind whose layeredness a backend does not recognise
// goes wrong.
//
// MULTISAMPLE is the one kind that is not merely an emulation problem, and the conformance case
// already knows it: it reads GL_MAX_IMAGE_SAMPLES and, when that is zero, substitutes a plain 2D
// texture and a plain uimage2D for both multisample entries. MobileGL reports zero, so the
// conformance case never asks it for a multisample image at all. The two cases below are kept
// and skip on that same query, so the coverage is already written the day a backend advertises
// them - and so the skip is a standing record of WHY the conformance case passes without them.
#include <algorithm>
#include <cstddef>
#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 {
// The conformance case's own dimensions: one level, 6 on every axis (which is also
// exactly one cube's worth for a cube array), and a single texel read at the origin.
constexpr int kExtent = 6;
constexpr GLuint kFilledValue = 7u;
constexpr GLuint kStoredValue = 13u;
// Everything that differs between the eleven kinds, in one row.
struct TargetKind {
const char* name; // this scenario's name for it, which failure messages carry
GLenum target; // the GL texture target
const char* imageType; // the GLSL image uniform type
const char* coord; // the coordinate expression imageLoad/imageStore takes
bool multisample; // needs GL_MAX_IMAGE_SAMPLES > 0
bool buffer; // storage comes from a buffer object, not TexStorage
};
constexpr TargetKind kKind1D{"1D", GL_TEXTURE_1D, "uimage1D", "0", false, false};
constexpr TargetKind kKind1DArray{"1DArray", GL_TEXTURE_1D_ARRAY, "uimage1DArray", "ivec2(0, 0)", false,
false};
constexpr TargetKind kKind2D{"2D", GL_TEXTURE_2D, "uimage2D", "ivec2(0, 0)", false, false};
constexpr TargetKind kKind2DArray{"2DArray", GL_TEXTURE_2D_ARRAY, "uimage2DArray", "ivec3(0, 0, 0)", false,
false};
constexpr TargetKind kKind3D{"3D", GL_TEXTURE_3D, "uimage3D", "ivec3(0, 0, 0)", false, false};
constexpr TargetKind kKindBuffer{"Buffer", GL_TEXTURE_BUFFER, "uimageBuffer", "0", false, true};
constexpr TargetKind kKindCube{"Cube", GL_TEXTURE_CUBE_MAP, "uimageCube", "ivec3(0, 0, 0)", false, false};
constexpr TargetKind kKindCubeArray{"CubeArray", GL_TEXTURE_CUBE_MAP_ARRAY, "uimageCubeArray",
"ivec3(0, 0, 0)", false, false};
constexpr TargetKind kKindRect{"Rect", GL_TEXTURE_RECTANGLE, "uimage2DRect", "ivec2(0, 0)", false, false};
constexpr TargetKind kKind2DMS{"2DMS", GL_TEXTURE_2D_MULTISAMPLE, "uimage2DMS", "ivec2(0, 0)", true, false};
constexpr TargetKind kKind2DMSArray{"2DMSArray", GL_TEXTURE_2D_MULTISAMPLE_ARRAY, "uimage2DMSArray",
"ivec3(0, 0, 0)", true, false};
// A multisample image load/store takes the sample index as an extra argument; no other
// kind does. Keeping that in one place stops the two spellings drifting apart.
std::string LoadExpression(const TargetKind& kind, const std::string& name) {
return "imageLoad(" + name + ", " + kind.coord + (kind.multisample ? ", 0)" : ")");
}
std::string StoreStatement(const TargetKind& kind, const std::string& name, const char* value) {
return "imageStore(" + name + ", " + kind.coord + (kind.multisample ? ", 0, uvec4(" : ", uvec4(") +
value + ", 0, 0, 0));";
}
const char* kComputePrologue = "#version 440 core\n"
"\n"
"layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;\n"
"\n";
const char* kResultBlock = "layout (std140, binding = 0) buffer SSB {\n"
" uint sum;\n"
"} ssb;\n"
"\n";
// The conformance case's shader, narrowed to a single image.
std::string SingleLoadSource(const TargetKind& kind) {
return std::string(kComputePrologue) + "layout (location = 0, r32ui) readonly uniform " + kind.imageType +
" i0;\n" + kResultBlock + "void main()\n{\n uvec4 v = " + LoadExpression(kind, "i0") +
";\n ssb.sum = v.r;\n}\n";
}
// The other direction. Written as its own program rather than a read-write one so that a
// backend which gets the store right and the load wrong (or the reverse) is not able to
// cancel its own defect out.
std::string SingleStoreSource(const TargetKind& kind) {
return std::string(kComputePrologue) + "layout (location = 0, r32ui) writeonly uniform " +
kind.imageType + " i0;\n\nvoid main()\n{\n " + StoreStatement(kind, "i0", "13u") + "\n}\n";
}
class ImageTargetKindScenario : public ScenarioTest {
protected:
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (GLuint p : m_programs) glDeleteProgram(p);
for (GLuint t : m_textures) glDeleteTextures(1, &t);
for (GLuint b : m_buffers) glDeleteBuffers(1, &b);
m_programs.clear();
m_textures.clear();
m_buffers.clear();
// Leave no image unit bound. These scenarios share one context, and a stale image
// binding is exactly the kind of state that makes the NEXT scenario's failure
// impossible to reproduce on its own.
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
for (GLint unit = 0; unit < maxImageUnits; ++unit) {
glBindImageTexture(static_cast<GLuint>(unit), 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_R32UI);
}
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
while (glGetError() != GL_NO_ERROR) {
}
}
bool ImagesAreUsable() const {
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
GLint maxComputeImageUniforms = 0;
glGetIntegerv(GL_MAX_COMPUTE_IMAGE_UNIFORMS, &maxComputeImageUniforms);
while (glGetError() != GL_NO_ERROR) {
}
return maxImageUnits >= 1 && maxComputeImageUniforms >= 1;
}
// The conformance case's own multisample gate, asked the same way it asks it.
bool MultisampleImagesAreUsable() const {
GLint maxImageSamples = 0;
glGetIntegerv(GL_MAX_IMAGE_SAMPLES, &maxImageSamples);
while (glGetError() != GL_NO_ERROR) {
}
return maxImageSamples > 0;
}
GLuint MakeComputeProgram(const std::string& source) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
const char* text = source.c_str();
glShaderSource(shader, 1, &text, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[4096] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute shader did not compile: " << log << "\nsource:\n" << source;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
m_programs.push_back(program);
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[4096] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute program did not link: " << log << "\nsource:\n" << source;
return 0;
}
return program;
}
// Storage plus a full fill with `value`, in the spelling each target kind needs.
// Returns 0 - having already reported - when the target could not be created.
GLuint MakeTexture(const TargetKind& kind, bool fill, GLuint value = kFilledValue) {
const std::vector<GLuint> texels(static_cast<std::size_t>(kExtent) * kExtent * kExtent, value);
if (kind.buffer) {
GLuint buffer = 0;
glGenBuffers(1, &buffer);
m_buffers.push_back(buffer);
glBindBuffer(GL_TEXTURE_BUFFER, buffer);
glBufferData(GL_TEXTURE_BUFFER, static_cast<GLsizeiptr>(texels.size() * sizeof(GLuint)),
fill ? texels.data() : nullptr, GL_DYNAMIC_COPY);
GLuint texture = 0;
glGenTextures(1, &texture);
m_textures.push_back(texture);
glBindTexture(GL_TEXTURE_BUFFER, texture);
glTexBuffer(GL_TEXTURE_BUFFER, GL_R32UI, buffer);
if (const GLenum error = FirstGLError()) {
ADD_FAILURE() << kind.name << ": creating the texture buffer errored with "
<< GLErrorName(error);
return 0;
}
return texture;
}
GLuint texture = 0;
glGenTextures(1, &texture);
m_textures.push_back(texture);
glBindTexture(kind.target, texture);
switch (kind.target) {
case GL_TEXTURE_1D:
glTexStorage1D(kind.target, 1, GL_R32UI, kExtent);
break;
case GL_TEXTURE_2D:
case GL_TEXTURE_RECTANGLE:
case GL_TEXTURE_1D_ARRAY:
case GL_TEXTURE_CUBE_MAP:
glTexStorage2D(kind.target, 1, GL_R32UI, kExtent, kExtent);
break;
case GL_TEXTURE_2D_ARRAY:
case GL_TEXTURE_3D:
case GL_TEXTURE_CUBE_MAP_ARRAY:
glTexStorage3D(kind.target, 1, GL_R32UI, kExtent, kExtent, kExtent);
break;
case GL_TEXTURE_2D_MULTISAMPLE:
glTexStorage2DMultisample(kind.target, 1, GL_R32UI, kExtent, kExtent, GL_FALSE);
break;
case GL_TEXTURE_2D_MULTISAMPLE_ARRAY:
glTexStorage3DMultisample(kind.target, 1, GL_R32UI, kExtent, kExtent, kExtent, GL_FALSE);
break;
default:
ADD_FAILURE() << kind.name << ": no storage spelling for target 0x" << std::hex << kind.target;
return 0;
}
if (const GLenum error = FirstGLError()) {
ADD_FAILURE() << kind.name << ": allocating storage errored with " << GLErrorName(error);
return 0;
}
// A multisample texture has no TexSubImage - the conformance case fills it with a
// compute pass, which is what the store cases below do.
if (!fill || kind.multisample) return texture;
switch (kind.target) {
case GL_TEXTURE_1D:
glTexSubImage1D(kind.target, 0, 0, kExtent, GL_RED_INTEGER, GL_UNSIGNED_INT, texels.data());
break;
case GL_TEXTURE_2D:
case GL_TEXTURE_RECTANGLE:
case GL_TEXTURE_1D_ARRAY:
glTexSubImage2D(kind.target, 0, 0, 0, kExtent, kExtent, GL_RED_INTEGER, GL_UNSIGNED_INT,
texels.data());
break;
case GL_TEXTURE_CUBE_MAP:
for (int face = 0; face < 6; ++face) {
glTexSubImage2D(static_cast<GLenum>(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face), 0, 0, 0, kExtent,
kExtent, GL_RED_INTEGER, GL_UNSIGNED_INT, texels.data());
}
break;
case GL_TEXTURE_2D_ARRAY:
case GL_TEXTURE_3D:
case GL_TEXTURE_CUBE_MAP_ARRAY:
glTexSubImage3D(kind.target, 0, 0, 0, 0, kExtent, kExtent, kExtent, GL_RED_INTEGER,
GL_UNSIGNED_INT, texels.data());
break;
default:
break;
}
if (const GLenum error = FirstGLError()) {
ADD_FAILURE() << kind.name << ": uploading texels errored with " << GLErrorName(error);
return 0;
}
return texture;
}
// A 4-byte `buffer` block bound to base 0, which is where every case puts its answer.
GLuint MakeResultBuffer() {
GLuint ssbo = 0;
glGenBuffers(1, &ssbo);
m_buffers.push_back(ssbo);
const GLuint zero = 0u;
glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssbo);
glBufferData(GL_SHADER_STORAGE_BUFFER, sizeof(GLuint), &zero, GL_DYNAMIC_COPY);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, ssbo);
return ssbo;
}
GLuint ReadResult(GLuint ssbo) {
glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssbo);
GLuint value = 0xFFFFFFFFu;
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(GLuint), &value);
return value;
}
// Fill a texture of `kind`, read texel (0,0,0) of it through an image uniform in a
// compute dispatch, and require the value back.
void RunLoadCase(const TargetKind& kind) {
const GLuint program = MakeComputeProgram(SingleLoadSource(kind));
if (program == 0) return;
const GLuint texture = MakeTexture(kind, true);
if (texture == 0) return;
const GLuint ssbo = MakeResultBuffer();
glBindImageTexture(0, texture, 0, GL_TRUE, 0, GL_READ_ONLY, GL_R32UI);
ASSERT_EQ(FirstGLError(), 0u) << kind.name << ": glBindImageTexture errored";
glUseProgram(program);
// The unit, by LOCATION - the conformance case's own redundant-but-legal
// assignment, and the one ES cannot take at the API level.
glUniform1i(0, 0);
ASSERT_EQ(FirstGLError(), 0u) << kind.name << ": assigning the image unit errored";
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(FirstGLError(), 0u) << kind.name << ": the dispatch leaked a GL error";
EXPECT_EQ(ReadResult(ssbo), kFilledValue)
<< kind.name << ": the compute dispatch did not read the value the texture was filled with";
glUseProgram(0);
}
// The other direction: store through an image uniform, then read the same texel back
// through a SECOND program, so a defect cannot cancel itself out.
void RunStoreCase(const TargetKind& kind) {
const GLuint storeProgram = MakeComputeProgram(SingleStoreSource(kind));
const GLuint loadProgram = MakeComputeProgram(SingleLoadSource(kind));
if (storeProgram == 0 || loadProgram == 0) return;
const GLuint texture = MakeTexture(kind, false);
if (texture == 0) return;
const GLuint ssbo = MakeResultBuffer();
glBindImageTexture(0, texture, 0, GL_TRUE, 0, GL_READ_WRITE, GL_R32UI);
ASSERT_EQ(FirstGLError(), 0u) << kind.name << ": glBindImageTexture errored";
glUseProgram(storeProgram);
glUniform1i(0, 0);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(FirstGLError(), 0u) << kind.name << ": the storing dispatch leaked a GL error";
glUseProgram(loadProgram);
glUniform1i(0, 0);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(FirstGLError(), 0u) << kind.name << ": the loading dispatch leaked a GL error";
EXPECT_EQ(ReadResult(ssbo), kStoredValue)
<< kind.name << ": the value stored through the image did not come back";
glUseProgram(0);
}
std::vector<GLuint> m_programs;
std::vector<GLuint> m_textures;
std::vector<GLuint> m_buffers;
};
} // namespace
// ---- the load direction, one target kind per case -----------------------
//
// Exactly what the conformance case does with each of its eleven uniforms, but alone, so a
// failure names the kind.
#define MGL_DEFINE_LOAD_CASE(CaseName, Kind) \
TEST_F(ImageTargetKindScenario, Loads##CaseName) { \
if (!Ready()) return; \
if (!ImagesAreUsable()) GTEST_SKIP() << "no compute image uniforms"; \
if ((Kind).multisample && !MultisampleImagesAreUsable()) { \
GTEST_SKIP() << "GL_MAX_IMAGE_SAMPLES is 0, so the conformance case substitutes a plain 2D image " \
"here and never asks for a multisample one"; \
} \
RunLoadCase(Kind); \
}
#define MGL_DEFINE_STORE_CASE(CaseName, Kind) \
TEST_F(ImageTargetKindScenario, Stores##CaseName) { \
if (!Ready()) return; \
if (!ImagesAreUsable()) GTEST_SKIP() << "no compute image uniforms"; \
if ((Kind).multisample && !MultisampleImagesAreUsable()) { \
GTEST_SKIP() << "GL_MAX_IMAGE_SAMPLES is 0, so the conformance case substitutes a plain 2D image " \
"here and never asks for a multisample one"; \
} \
RunStoreCase(Kind); \
}
MGL_DEFINE_LOAD_CASE(Texture1D, kKind1D)
MGL_DEFINE_LOAD_CASE(Texture1DArray, kKind1DArray)
MGL_DEFINE_LOAD_CASE(Texture2D, kKind2D)
MGL_DEFINE_LOAD_CASE(Texture2DArray, kKind2DArray)
MGL_DEFINE_LOAD_CASE(Texture3D, kKind3D)
MGL_DEFINE_LOAD_CASE(TextureBuffer, kKindBuffer)
MGL_DEFINE_LOAD_CASE(TextureCube, kKindCube)
MGL_DEFINE_LOAD_CASE(TextureCubeArray, kKindCubeArray)
MGL_DEFINE_LOAD_CASE(TextureRectangle, kKindRect)
MGL_DEFINE_LOAD_CASE(Texture2DMultisample, kKind2DMS)
MGL_DEFINE_LOAD_CASE(Texture2DMultisampleArray, kKind2DMSArray)
MGL_DEFINE_STORE_CASE(Texture1D, kKind1D)
MGL_DEFINE_STORE_CASE(Texture1DArray, kKind1DArray)
MGL_DEFINE_STORE_CASE(Texture2D, kKind2D)
MGL_DEFINE_STORE_CASE(Texture2DArray, kKind2DArray)
MGL_DEFINE_STORE_CASE(Texture3D, kKind3D)
MGL_DEFINE_STORE_CASE(TextureBuffer, kKindBuffer)
MGL_DEFINE_STORE_CASE(TextureCube, kKindCube)
MGL_DEFINE_STORE_CASE(TextureCubeArray, kKindCubeArray)
MGL_DEFINE_STORE_CASE(TextureRectangle, kKindRect)
MGL_DEFINE_STORE_CASE(Texture2DMultisample, kKind2DMS)
MGL_DEFINE_STORE_CASE(Texture2DMultisampleArray, kKind2DMSArray)
#undef MGL_DEFINE_LOAD_CASE
#undef MGL_DEFINE_STORE_CASE
// ---- and all of them at once -------------------------------------------
//
// The conformance case's actual shape. The single-kind cases above cannot see a defect that
// needs several kinds in one program - a binding remap that only collides when two image
// types share a descriptor set, a per-kind rewrite that is not idempotent across declarations
// - and that class of defect is precisely what "each kind passes alone but the case still
// fails" would mean.
//
// Each unit is filled with its own DISTINCT value rather than a shared one, so a shortfall
// names WHICH kind is missing rather than merely how many are: with one shared value, "three
// kinds read zero" and "one kind read zero" differ only by a multiple, and any two kinds are
// interchangeable in the total. A sum still cannot see two kinds SWAPPING - addition is
// commutative, and the conformance case has exactly the same blind spot - but the single-kind
// cases above pin each kind to its own texture already, so a swap cannot hide there.
TEST_F(ImageTargetKindScenario, AllKindsInOneProgram) {
if (!Ready()) return;
if (!ImagesAreUsable()) GTEST_SKIP() << "no compute image uniforms";
// The two kinds this whole scenario file exists for come FIRST, and that ordering is
// load-bearing rather than cosmetic. The list has to be truncated to the device's image
// unit count, and the guaranteed minimum is small - ES 3.1 promises only four compute
// image uniforms - so a list in the conformance case's own order would put imageBuffer
// at index five and drop it on exactly the devices most likely to get it wrong. A test
// that quietly stops covering its own subject is worse than one that fails.
const bool multisample = MultisampleImagesAreUsable();
std::vector<TargetKind> kinds{kKind1DArray, kKindBuffer, kKind2D, kKind1D, kKind2DArray,
kKind3D, kKindCube, kKindRect, kKindCubeArray};
if (multisample) {
kinds.push_back(kKind2DMS);
kinds.push_back(kKind2DMSArray);
}
GLint maxComputeImageUniforms = 0;
glGetIntegerv(GL_MAX_COMPUTE_IMAGE_UNIFORMS, &maxComputeImageUniforms);
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
while (glGetError() != GL_NO_ERROR) {
}
const std::size_t count =
std::min<std::size_t>(kinds.size(), static_cast<std::size_t>(std::max(0, std::min(maxComputeImageUniforms,
maxImageUnits))));
if (count == 0) GTEST_SKIP() << "no image units";
// Named, not silently dropped: `expected` is computed over whatever survives, so a
// truncated run is self-consistently green and would otherwise never say what it stopped
// covering.
if (count < kinds.size()) {
std::string dropped;
for (std::size_t i = count; i < kinds.size(); ++i) {
if (!dropped.empty()) dropped += ", ";
dropped += kinds[i].name;
}
RecordProperty("dropped_image_target_kinds", dropped);
GTEST_LOG_(INFO) << "only " << count << " image units, so these kinds are not covered by the "
<< "combined case: " << dropped;
}
kinds.resize(count);
std::string declarations;
std::string sum;
for (std::size_t i = 0; i < kinds.size(); ++i) {
const std::string name = "i" + std::to_string(i);
declarations += "layout (location = " + std::to_string(i) + ", r32ui) readonly uniform " +
kinds[i].imageType + " " + name + ";\n";
if (!sum.empty()) sum += " + ";
sum += LoadExpression(kinds[i], name);
}
const std::string source = std::string(kComputePrologue) + declarations + kResultBlock +
"void main()\n{\n uvec4 v = " + sum + ";\n ssb.sum = v.r;\n}\n";
const GLuint program = MakeComputeProgram(source);
if (program == 0) return;
// Powers of two, so the shortfall's bit pattern names exactly which kinds read zero -
// no other subset of the values can sum to the same total. Eleven kinds at most, so the
// largest is 1 << 10 and the sum cannot approach a uint's range.
GLuint expected = 0;
for (std::size_t i = 0; i < kinds.size(); ++i) {
const GLuint value = 1u << i;
const GLuint texture = MakeTexture(kinds[i], true, value);
if (texture == 0) return;
expected += value;
glBindImageTexture(static_cast<GLuint>(i), texture, 0, GL_TRUE, 0, GL_READ_ONLY, GL_R32UI);
ASSERT_EQ(FirstGLError(), 0u) << kinds[i].name << ": glBindImageTexture errored";
}
const GLuint ssbo = MakeResultBuffer();
glUseProgram(program);
for (std::size_t i = 0; i < kinds.size(); ++i) {
glUniform1i(static_cast<GLint>(i), static_cast<GLint>(i));
}
ASSERT_EQ(FirstGLError(), 0u) << "assigning the image units errored";
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(FirstGLError(), 0u) << "the dispatch leaked a GL error";
const GLuint actual = ReadResult(ssbo);
std::string missing;
for (std::size_t i = 0; i < kinds.size(); ++i) {
if ((actual & (1u << i)) == 0u) {
if (!missing.empty()) missing += ", ";
missing += kinds[i].name;
}
}
EXPECT_EQ(actual, expected)
<< "the sum over " << kinds.size()
<< " image target kinds is wrong; each kind contributes its own bit, and these read "
"zero: "
<< (missing.empty() ? "(none - so some kind read a value it was never given)" : missing);
glUseProgram(0);
}
} // namespace MGITest
@@ -0,0 +1,887 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ProgramPipelineScenario.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 - SEPARABLE PROGRAMS DRAWN THROUGH A PROGRAM PIPELINE OBJECT.
//
// A pipeline object holds one program per stage and stands in for glUseProgram; MobileGL
// flattens it into a single composite program at draw time (MG_State/GLState/Core.cpp,
// GetProgramForDraw). Sixteen conformance cases across three different families depend on that
// flattening and fail identically on BOTH backends - so the defect is in the shared frontend, not
// in either backend's draw path:
//
// compute_shader.{build-monolithic, build-separable, sso-case2, sso-case3, sso-compute-pipeline}
// shader_image_load_store.advanced-sso-{atomicCounters, simple, subroutine}
// shader_storage_buffer_object.{basic-syntaxSSO, basic-noBindingLayout}
//
// They fail with two symptoms at once - the draw renders nothing, AND the case leaves a
// GL_INVALID_OPERATION behind that the harness reports as "forcing FAIL for subcase". Anything
// claiming to be the root cause has to explain both.
//
// The cases here are the conformance shapes reduced to what fails in milliseconds, ordered from
// the simplest pipeline that can render at all up to the compute-then-draw shape of
// sso-compute-pipeline. Each one also asserts glGetError is clean at the end, because a case that
// paints correctly and leaks an error still fails conformance.
#include <cstdint>
#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 {
// Separable stage sources. A separable VS must redeclare gl_PerVertex, which is exactly
// the kind of thing a flattening step can drop on the floor.
constexpr const char* kSeparableVS = R"(#version 430 core
out gl_PerVertex { vec4 gl_Position; };
void main()
{
switch (gl_VertexID)
{
case 0: gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); break;
case 1: gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); break;
case 2: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break;
case 3: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break;
}
}
)";
constexpr const char* kSeparableFS = R"(#version 430 core
out vec4 o_color;
void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
)";
// The sso-compute-pipeline shape: a compute stage writes the vertex positions the vertex
// stage then reads as an attribute, all from one pipeline object.
constexpr const char* kComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Positions {
vec4 g_position[4];
};
void main()
{
g_position[0] = vec4(-1.0, -1.0, 0.0, 1.0);
g_position[1] = vec4( 1.0, -1.0, 0.0, 1.0);
g_position[2] = vec4(-1.0, 1.0, 0.0, 1.0);
g_position[3] = vec4( 1.0, 1.0, 0.0, 1.0);
}
)";
constexpr const char* kAttributeVS = R"(#version 430 core
layout(location = 0) in vec4 i_position;
out gl_PerVertex { vec4 gl_Position; };
void main() { gl_Position = i_position; }
)";
// Two shader storage blocks with NO layout(binding) qualifier, so the only thing that
// can say where they live is glShaderStorageBlockBinding - which is per-PROGRAM state.
constexpr const char* kStorageBlockVS = R"(#version 430 core
out gl_PerVertex { vec4 gl_Position; };
layout(std430) buffer Output0 { uint value0; };
layout(std430) buffer Output1 { uint value1; };
void main()
{
value0 = 11u;
value1 = 22u;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
class ProgramPipelineScenario : public ScenarioTest {
protected:
void TearDown() override {
if (!Ready()) return;
glBindProgramPipeline(0);
glUseProgram(0);
for (GLuint p : m_programs) glDeleteProgram(p);
for (GLuint p : m_pipelines) glDeleteProgramPipelines(1, &p);
m_programs.clear();
m_pipelines.clear();
}
GLuint MakeSeparable(GLenum stage, const char* source) {
const GLuint program = glCreateShaderProgramv(stage, 1, &source);
if (program != 0) m_programs.push_back(program);
// Checked here rather than only at the end of the case: glCreateShaderProgramv is
// specified as a sequence of other entry points, so it is the most likely place
// for one of them to leave an error nobody consumes.
EXPECT_EQ(FirstGLError(), 0u)
<< "glCreateShaderProgramv(stage 0x" << std::hex << stage << std::dec << ") left a GL error";
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[2048] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "glCreateShaderProgramv(stage 0x" << std::hex << stage << std::dec
<< ") did not link: " << log;
return 0;
}
return program;
}
GLuint MakePipeline() {
GLuint pipeline = 0;
glGenProgramPipelines(1, &pipeline);
m_pipelines.push_back(pipeline);
return pipeline;
}
std::vector<GLuint> m_programs;
std::vector<GLuint> m_pipelines;
};
} // namespace
// The root cause of the cluster, stated as the two halves it actually has.
//
// Half one: glGenProgramPipelines only reserves a name, and every pipeline command used to
// demand a materialized object - so the spec's own call order (stages attached BEFORE the
// first bind, GL 4.6 core 7.4) was rejected with GL_INVALID_OPERATION and the stages were
// never recorded. Half two is the trap that fix walks into: the object now appears the
// moment anything needs somewhere to put state, so "the object exists" stops being the
// right answer for glIsProgramPipeline, which the spec ties to the first BIND. A pure
// query must not turn a reserved name into a program pipeline either.
TEST_F(ProgramPipelineScenario, AReservedNameTakesStateBeforeItIsAProgramPipeline) {
if (!Ready()) return;
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS);
if (vs == 0) return;
const GLuint pipeline = MakePipeline();
ASSERT_NE(pipeline, 0u);
EXPECT_EQ(glIsProgramPipeline(pipeline), GL_FALSE) << "a merely reserved name is not a pipeline yet";
// A query answers out of default state - and leaves the name exactly as it found it.
GLint validateStatus = -1;
glGetProgramPipelineiv(pipeline, GL_VALIDATE_STATUS, &validateStatus);
EXPECT_EQ(FirstGLError(), 0u) << "querying a reserved pipeline name must not be an error";
EXPECT_EQ(validateStatus, 0) << "a pipeline that was never validated reports VALIDATE_STATUS 0";
EXPECT_EQ(glIsProgramPipeline(pipeline), GL_FALSE) << "a pure query must not create the object";
// ...and glUseProgramStages RECORDS the stage on the reserved name rather than
// rejecting it, which is the whole defect: without this the pipeline stayed empty.
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
EXPECT_EQ(FirstGLError(), 0u) << "glUseProgramStages before the first bind must be accepted";
GLint stageProgram = 0;
glGetProgramPipelineiv(pipeline, GL_VERTEX_SHADER, &stageProgram);
EXPECT_EQ(static_cast<GLuint>(stageProgram), vs) << "the stage program was not recorded";
EXPECT_EQ(glIsProgramPipeline(pipeline), GL_FALSE) << "taking state is still not being bound";
// The bind is what the spec ties glIsProgramPipeline to.
glBindProgramPipeline(pipeline);
EXPECT_EQ(glIsProgramPipeline(pipeline), GL_TRUE);
EXPECT_EQ(FirstGLError(), 0u);
glBindProgramPipeline(0);
}
// The floor: a two-stage pipeline must paint. If this fails, nothing above it can pass, and
// the eight shared conformance cases have exactly one cause.
TEST_F(ProgramPipelineScenario, ATwoStagePipelinePaintsWhatItsStagesDescribe) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS);
const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kSeparableFS);
if (vs == 0 || fs == 0) return;
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
ASSERT_EQ(FirstGLError(), 0u) << "pipeline setup left a GL error behind";
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
// No glUseProgram anywhere: the pipeline IS the program state for this draw.
glUseProgram(0);
glBindProgramPipeline(pipeline);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const Image painted = ReadPixels(width, height);
EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0,
"a two-stage program pipeline drawing a full-viewport strip"));
// The conformance harness fails a subcase on a leaked error even when the pixels are
// right, so this assertion is not redundant with the one above.
EXPECT_EQ(FirstGLError(), 0u) << "the pipeline draw leaked a GL error";
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
gl.EndFrame();
}
// glActiveShaderProgram picks which stage program glUniform* addresses - and the draw has to
// see what was written there.
//
// The second defect of the cluster, and the one the pixels expose most directly: uniform
// values live on the stage program (GetProgramForUniform returns the pipeline's active
// program) while the draw reads the composite GetProgramForDraw builds out of the stage
// programs' shaders. Two objects, two sets of uniform storage; before the composite was
// refreshed from its stage programs this painted u_color's zero default instead of green.
TEST_F(ProgramPipelineScenario, UniformsGoToTheActiveShaderProgram) {
if (!Ready()) return;
static const char* kUniformFS = R"(#version 430 core
uniform vec4 u_color;
out vec4 o_color;
void main() { o_color = u_color; }
)";
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS);
const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kUniformFS);
if (vs == 0 || fs == 0) return;
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
glBindProgramPipeline(pipeline);
glActiveShaderProgram(pipeline, fs);
ASSERT_EQ(FirstGLError(), 0u) << "glActiveShaderProgram left a GL error behind";
const GLint location = glGetUniformLocation(fs, "u_color");
ASSERT_NE(location, -1);
glUniform4f(location, 0.0f, 1.0f, 0.0f, 1.0f);
EXPECT_EQ(FirstGLError(), 0u) << "glUniform4f through the active shader program errored";
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const Image painted = ReadPixels(width, height);
EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0,
"a pipeline whose fragment uniform was set via glActiveShaderProgram"));
EXPECT_EQ(FirstGLError(), 0u) << "the pipeline draw leaked a GL error";
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
gl.EndFrame();
}
// The sso-compute-pipeline shape: compute and non-compute stages on ONE pipeline object, the
// compute stage writing the buffer the vertex stage then reads.
//
// The third defect of the cluster: the flattening used to pull EVERY stage into one
// composite, so a single program was asked to serve both glDispatchCompute and glDrawArrays.
// GL keeps them apart - a pipeline's compute stage is a whole program dispatched on its own
// and never participates in a draw - which is why the accessors are split (GetProgramForDraw
// composites the graphics stages, GetProgramForDispatch hands back the compute stage
// program). It is also the shape that killed the process on Adreno: the composite carried a
// compute module into vkCreateGraphicsPipelines, and that driver SIGSEGVs rather than
// returning an error.
TEST_F(ProgramPipelineScenario, ComputeAndGraphicsStagesShareOnePipeline) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
GLint storageBlocks = 0;
glGetIntegerv(GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, &storageBlocks);
if (storageBlocks < 1) {
GTEST_SKIP() << "no compute shader storage blocks available";
}
const GLuint cs = MakeSeparable(GL_COMPUTE_SHADER, kComputeSource);
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kAttributeVS);
const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kSeparableFS);
if (cs == 0 || vs == 0 || fs == 0) return;
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
glUseProgramStages(pipeline, GL_COMPUTE_SHADER_BIT, cs);
ASSERT_EQ(FirstGLError(), 0u) << "attaching compute and graphics stages to one pipeline errored";
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
glBufferData(GL_SHADER_STORAGE_BUFFER, 4 * 4 * sizeof(float), nullptr, GL_DYNAMIC_DRAW);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, nullptr);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_DEPTH_TEST);
glUseProgram(0);
glBindProgramPipeline(pipeline);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffer);
glDispatchCompute(1, 1, 1);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glBindVertexArray(vao);
glMemoryBarrier(GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const Image painted = ReadPixels(width, height);
EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0,
"a pipeline whose compute stage wrote the vertex positions"));
EXPECT_EQ(FirstGLError(), 0u) << "the compute-then-draw pipeline leaked a GL error";
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &buffer);
gl.EndFrame();
}
// Interface-resource bindings are per-PROGRAM state, and the program a pipeline draw executes
// is the composite - not the stage program the application set them on.
//
// This is shader_storage_buffer_object.basic-noBindingLayout reduced: blocks declared without
// a layout(binding) qualifier, placed onto binding points purely by
// glShaderStorageBlockBinding against the stage program. The stage program records the
// rebinding (ProgramObject::SetShaderStorageBlockBinding, keyed by block name) and the
// composite is built from the stage program's SHADERS - which carry the declared bindings and
// know nothing of the rebinding. So the draw writes wherever the shader source said, the
// bound buffer ranges never see a byte, and no GL error is raised anywhere: the readback is
// the only thing that notices.
TEST_F(ProgramPipelineScenario, AStageProgramsStorageBlockBindingReachesThePipelineDraw) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
GLint vertexStorageBlocks = 0;
glGetIntegerv(GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS, &vertexStorageBlocks);
if (vertexStorageBlocks < 2) {
GTEST_SKIP() << "fewer than two vertex shader storage blocks available";
}
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kStorageBlockVS);
if (vs == 0) return;
// Rebound to binding points the shader source never mentions, so nothing but the
// rebinding can put the writes where this case looks for them.
constexpr GLuint kBinding0 = 1;
constexpr GLuint kBinding1 = 5;
const GLuint block0 = glGetProgramResourceIndex(vs, GL_SHADER_STORAGE_BLOCK, "Output0");
const GLuint block1 = glGetProgramResourceIndex(vs, GL_SHADER_STORAGE_BLOCK, "Output1");
ASSERT_NE(block0, GL_INVALID_INDEX);
ASSERT_NE(block1, GL_INVALID_INDEX);
glShaderStorageBlockBinding(vs, block0, kBinding0);
glShaderStorageBlockBinding(vs, block1, kBinding1);
ASSERT_EQ(FirstGLError(), 0u) << "glShaderStorageBlockBinding on a separable program errored";
GLint offsetAlignment = 256;
glGetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &offsetAlignment);
if (offsetAlignment <= 0) offsetAlignment = 256;
const GLsizeiptr secondOffset = offsetAlignment;
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
const std::vector<GLuint> zeros(static_cast<std::size_t>(secondOffset) / sizeof(GLuint) + 4, 0u);
glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(zeros.size() * sizeof(GLuint)), zeros.data(),
GL_DYNAMIC_DRAW);
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, kBinding0, buffer, 0, sizeof(GLuint));
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, kBinding1, buffer, secondOffset, sizeof(GLuint));
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
// The whole point is the buffer writes, so the rasterizer is not involved - which is
// also what keeps a vertex-only pipeline (no fragment stage) legal here.
glEnable(GL_RASTERIZER_DISCARD);
glUseProgram(0);
glBindProgramPipeline(pipeline);
glDrawArrays(GL_POINTS, 0, 1);
glDisable(GL_RASTERIZER_DISCARD);
EXPECT_EQ(FirstGLError(), 0u) << "the storage-block pipeline draw leaked a GL error";
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
GLuint readback0 = 0;
GLuint readback1 = 0;
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(readback0), &readback0);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, secondOffset, sizeof(readback1), &readback1);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
EXPECT_EQ(readback0, 11u) << "Output0 did not reach the binding glShaderStorageBlockBinding gave it";
EXPECT_EQ(readback1, 22u) << "Output1 did not reach the binding glShaderStorageBlockBinding gave it";
EXPECT_EQ(FirstGLError(), 0u);
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &buffer);
gl.EndFrame();
}
// CONTROL for the case above, and the thing that says whether a storage-block failure is
// about pipelines at all: the same shader, the same rebinding, in an ordinary two-stage
// monolithic program run through glUseProgram. If this one fails too then the composite is
// innocent and the defect is in how the backend replays a rebinding.
//
// Two stages on purpose. Handing glUseProgram a vertex-ONLY program would confound the
// experiment - a program with no fragment stage is a thing some backends cannot build at
// all, so its failure would say nothing about block bindings.
//
// Runs on both backends. glShaderStorageBlockBinding is a GL 4.3 entry point with no ES
// equivalent - ES fixes a storage block's binding at link from its layout(binding=)
// qualifier - so Espryt honours a rebinding by writing the effective binding into the ESSL
// it generates (the Binding decoration is rewritten before SPIRV-Cross emits, and the draw
// path rebuilds a program whose override set has moved).
TEST_F(ProgramPipelineScenario, AStorageBlockRebindingHoldsWithoutAPipeline) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
GLint vertexStorageBlocks = 0;
glGetIntegerv(GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS, &vertexStorageBlocks);
if (vertexStorageBlocks < 2) {
GTEST_SKIP() << "fewer than two vertex shader storage blocks available";
}
static const char* kMonolithicVS = R"(#version 430 core
layout(std430) buffer Output0 { uint value0; };
layout(std430) buffer Output1 { uint value1; };
void main()
{
value0 = 11u;
value1 = 22u;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
static const char* kMonolithicFS = R"(#version 430 core
out vec4 o_color;
void main() { o_color = vec4(1.0); }
)";
std::string compileError;
const GLuint vs = CompileProgram(kMonolithicVS, kMonolithicFS, &compileError);
ASSERT_NE(vs, 0u) << compileError;
m_programs.push_back(vs);
constexpr GLuint kBinding0 = 1;
constexpr GLuint kBinding1 = 5;
const GLuint block0 = glGetProgramResourceIndex(vs, GL_SHADER_STORAGE_BLOCK, "Output0");
const GLuint block1 = glGetProgramResourceIndex(vs, GL_SHADER_STORAGE_BLOCK, "Output1");
ASSERT_NE(block0, GL_INVALID_INDEX);
ASSERT_NE(block1, GL_INVALID_INDEX);
glShaderStorageBlockBinding(vs, block0, kBinding0);
glShaderStorageBlockBinding(vs, block1, kBinding1);
ASSERT_EQ(FirstGLError(), 0u);
GLint offsetAlignment = 256;
glGetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &offsetAlignment);
if (offsetAlignment <= 0) offsetAlignment = 256;
const GLsizeiptr secondOffset = offsetAlignment;
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
const std::vector<GLuint> zeros(static_cast<std::size_t>(secondOffset) / sizeof(GLuint) + 4, 0u);
glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(zeros.size() * sizeof(GLuint)), zeros.data(),
GL_DYNAMIC_DRAW);
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, kBinding0, buffer, 0, sizeof(GLuint));
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, kBinding1, buffer, secondOffset, sizeof(GLuint));
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glEnable(GL_RASTERIZER_DISCARD);
// No pipeline anywhere: a separable program is still a perfectly good current program.
glBindProgramPipeline(0);
glUseProgram(vs);
glDrawArrays(GL_POINTS, 0, 1);
glDisable(GL_RASTERIZER_DISCARD);
EXPECT_EQ(FirstGLError(), 0u) << "the monolithic storage-block draw leaked a GL error";
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
GLuint readback0 = 0;
GLuint readback1 = 0;
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(readback0), &readback0);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, secondOffset, sizeof(readback1), &readback1);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
EXPECT_EQ(readback0, 11u) << "Output0 missed its rebinding with no pipeline involved";
EXPECT_EQ(readback1, 22u) << "Output1 missed its rebinding with no pipeline involved";
glUseProgram(0);
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &buffer);
gl.EndFrame();
}
// The same defect through the other block flavour: glUniformBlockBinding is also per-program
// state, recorded on the stage program by GL block index, and also never reaches the
// composite the draw actually runs.
TEST_F(ProgramPipelineScenario, AStageProgramsUniformBlockBindingReachesThePipelineDraw) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
static const char* kUniformBlockFS = R"(#version 430 core
layout(std140) uniform Colour { vec4 u_colour; };
out vec4 o_color;
void main() { o_color = u_colour; }
)";
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS);
const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kUniformBlockFS);
if (vs == 0 || fs == 0) return;
constexpr GLuint kBinding = 3; // not the default 0 the declaration implies
const GLuint blockIndex = glGetUniformBlockIndex(fs, "Colour");
ASSERT_NE(blockIndex, GL_INVALID_INDEX);
glUniformBlockBinding(fs, blockIndex, kBinding);
ASSERT_EQ(FirstGLError(), 0u) << "glUniformBlockBinding on a separable program errored";
const GLfloat green[4] = {0.0f, 1.0f, 0.0f, 1.0f};
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_UNIFORM_BUFFER, buffer);
glBufferData(GL_UNIFORM_BUFFER, sizeof(green), green, GL_STATIC_DRAW);
glBindBufferBase(GL_UNIFORM_BUFFER, kBinding, buffer);
glBindBuffer(GL_UNIFORM_BUFFER, 0);
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(0);
glBindProgramPipeline(pipeline);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const Image painted = ReadPixels(width, height);
EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0,
"a pipeline whose fragment uniform block was rebound to binding 3"));
EXPECT_EQ(FirstGLError(), 0u) << "the uniform-block pipeline draw leaked a GL error";
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &buffer);
gl.EndFrame();
}
// The shared-header idiom, drawn: BOTH stages declare `u_mvp` because they both include the
// same header, and only the VERTEX program is ever written to.
//
// The composite has one slot for `u_mvp`, and mirroring every active uniform of every stage
// in stage order meant the fragment program's untouched zero matrix landed last and won.
// The vertex stage then transformed every vertex by a zero matrix and the frame came out
// empty - from an application that had done nothing wrong, with no GL error anywhere to say
// so. Only uniforms a stage has actually been written to are mirrored now.
TEST_F(ProgramPipelineScenario, AUniformDeclaredInTwoStagesKeepsTheValueTheWrittenStageHolds) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
// The same declaration in both stages, exactly as a shared header produces it. The
// fragment stage does not even USE it for its output - declaring it is enough.
static const char* kSharedMvpVS = R"(#version 430 core
out gl_PerVertex { vec4 gl_Position; };
uniform mat4 u_mvp;
void main()
{
vec4 corner = vec4(0.0, 0.0, 0.0, 1.0);
switch (gl_VertexID)
{
case 0: corner = vec4(-1.0, -1.0, 0.0, 1.0); break;
case 1: corner = vec4( 1.0, -1.0, 0.0, 1.0); break;
case 2: corner = vec4(-1.0, 1.0, 0.0, 1.0); break;
case 3: corner = vec4( 1.0, 1.0, 0.0, 1.0); break;
}
gl_Position = u_mvp * corner;
}
)";
static const char* kSharedMvpFS = R"(#version 430 core
uniform mat4 u_mvp;
out vec4 o_color;
void main() { o_color = vec4(0.0, 1.0, 0.0, u_mvp[3][3]); }
)";
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSharedMvpVS);
const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kSharedMvpFS);
if (vs == 0 || fs == 0) return;
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
glBindProgramPipeline(pipeline);
// Written through the VERTEX program only - which is the whole point. The fragment
// program's `u_mvp` is left at GL's zero default and must not win the composite's slot.
glActiveShaderProgram(pipeline, vs);
const GLint location = glGetUniformLocation(vs, "u_mvp");
ASSERT_NE(location, -1);
const GLfloat identity[16] = {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f};
glUniformMatrix4fv(location, 1, GL_FALSE, identity);
ASSERT_EQ(FirstGLError(), 0u) << "glUniformMatrix4fv through the active shader program errored";
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(0);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
// A zero matrix collapses all four corners onto the origin and paints nothing at all, so
// "green over the whole viewport" IS the assertion that the written matrix was the one
// the draw used. (The fragment stage reads u_mvp too - into the alpha channel - purely
// so the optimizer cannot delete its declaration and make the case vacuous.)
const Image painted = ReadPixels(width, height);
EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0,
"a pipeline whose u_mvp is declared in both stages and written in one"));
EXPECT_EQ(FirstGLError(), 0u) << "the shared-uniform pipeline draw leaked a GL error";
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
gl.EndFrame();
}
// Rebinding a uniform block AFTER the pipeline has already drawn once.
//
// This is the shape the composite cache key change put weight on. The composite used to be
// thrown away and relinked whenever glUniformBlockBinding moved a stage program's backend
// state version, so the second draw here got a brand-new composite that happened to pick the
// new binding up on the way. Now the composite SURVIVES the rebinding, which means the only
// thing that can carry the new binding to the draw is the refresh path - so this case is
// what says that path is really doing the work.
TEST_F(ProgramPipelineScenario, RebindingAUniformBlockBetweenDrawsReachesTheNextDraw) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
static const char* kUniformBlockFS = R"(#version 430 core
layout(std140) uniform Colour { vec4 u_colour; };
out vec4 o_color;
void main() { o_color = u_colour; }
)";
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS);
const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kUniformBlockFS);
if (vs == 0 || fs == 0) return;
// Two buffers on two different binding points, holding two different colours.
const GLfloat red[4] = {1.0f, 0.0f, 0.0f, 1.0f};
const GLfloat green[4] = {0.0f, 1.0f, 0.0f, 1.0f};
constexpr GLuint kFirstBinding = 2;
constexpr GLuint kSecondBinding = 5;
GLuint buffers[2] = {0, 0};
glGenBuffers(2, buffers);
glBindBuffer(GL_UNIFORM_BUFFER, buffers[0]);
glBufferData(GL_UNIFORM_BUFFER, sizeof(red), red, GL_STATIC_DRAW);
glBindBufferBase(GL_UNIFORM_BUFFER, kFirstBinding, buffers[0]);
glBindBuffer(GL_UNIFORM_BUFFER, buffers[1]);
glBufferData(GL_UNIFORM_BUFFER, sizeof(green), green, GL_STATIC_DRAW);
glBindBufferBase(GL_UNIFORM_BUFFER, kSecondBinding, buffers[1]);
glBindBuffer(GL_UNIFORM_BUFFER, 0);
const GLuint blockIndex = glGetUniformBlockIndex(fs, "Colour");
ASSERT_NE(blockIndex, GL_INVALID_INDEX);
glUniformBlockBinding(fs, blockIndex, kFirstBinding);
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glUseProgram(0);
glBindProgramPipeline(pipeline);
// Draw one: the composite is built here, against binding 2.
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const Image first = ReadPixels(width, height);
EXPECT_TRUE(RegionIsMostly(first, 2, width - 3, 2, height - 3, "red", 0.0,
"the first pipeline draw, with Colour on binding 2"));
ASSERT_EQ(FirstGLError(), 0u) << "the first uniform-block pipeline draw leaked a GL error";
// Move the block to the other binding point, with the composite already built and cached.
glUniformBlockBinding(fs, blockIndex, kSecondBinding);
ASSERT_EQ(FirstGLError(), 0u) << "rebinding a uniform block between draws errored";
// Draw two must read the OTHER buffer.
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const Image second = ReadPixels(width, height);
EXPECT_TRUE(RegionIsMostly(second, 2, width - 3, 2, height - 3, "green", 0.0,
"the second pipeline draw, after Colour was rebound to binding 5"));
EXPECT_EQ(FirstGLError(), 0u) << "the rebound uniform-block pipeline draw leaked a GL error";
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(2, buffers);
gl.EndFrame();
}
// The sampler-unit half of the same question, in a loop: set a unit, draw, repeat. This is
// the shape KHR-GL42.shader_image_load_store.advanced-sso-* and the compute_shader SSO cases
// run, and the one that used to relink the composite on every single iteration. The pixels
// pin what the loop must PRODUCE; the composite-identity assertion that pins what it must
// COST lives in the MG_Test unit suite, where the object itself is reachable.
TEST_F(ProgramPipelineScenario, ASamplerUnitRewrittenBetweenDrawsKeepsPaintingTheRightTexture) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
static const char* kSamplerFS = R"(#version 430 core
uniform sampler2D u_tex;
out vec4 o_color;
void main() { o_color = texture(u_tex, vec2(0.5)); }
)";
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS);
const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kSamplerFS);
if (vs == 0 || fs == 0) return;
// One texture per unit, each a different solid colour, so the pixels say which unit the
// draw actually sampled.
constexpr int kUnits = 4;
const GLubyte colours[kUnits][4] = {{255, 0, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}, {255, 255, 0, 255}};
const char* names[kUnits] = {"red", "green", "blue", "yellow"};
GLuint textures[kUnits] = {};
glGenTextures(kUnits, textures);
for (int unit = 0; unit < kUnits; ++unit) {
glActiveTexture(GL_TEXTURE0 + unit);
glBindTexture(GL_TEXTURE_2D, textures[unit]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, colours[unit]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
}
glActiveTexture(GL_TEXTURE0);
ASSERT_EQ(FirstGLError(), 0u) << "texture setup left a GL error behind";
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
glBindProgramPipeline(pipeline);
glActiveShaderProgram(pipeline, fs);
const GLint sampler = glGetUniformLocation(fs, "u_tex");
ASSERT_NE(sampler, -1);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glUseProgram(0);
for (int unit = 0; unit < kUnits; ++unit) {
glUniform1i(sampler, unit);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const Image painted = ReadPixels(width, height);
EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, names[unit], 0.0,
"a pipeline draw after its sampler was pointed at another unit"))
<< "unit " << unit;
EXPECT_EQ(FirstGLError(), 0u) << "the sampler-rewrite pipeline draw leaked a GL error at unit " << unit;
}
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteTextures(kUnits, textures);
gl.EndFrame();
}
// build-separable / build-monolithic reduce to this: a separable program and a monolithic one
// must both be usable, and switching between pipeline and glUseProgram must leave no error.
TEST_F(ProgramPipelineScenario, SwitchingBetweenAPipelineAndAMonolithicProgramLeavesNoError) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS);
const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kSeparableFS);
if (vs == 0 || fs == 0) return;
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT | GL_FRAGMENT_SHADER_BIT, 0);
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
std::string error;
const unsigned int monolithic = CompileProgram(
"#version 330 core\nin vec2 aPos;\nvoid main(){ gl_Position = vec4(aPos,0.0,1.0); }\n",
"#version 330 core\nout vec4 o;\nvoid main(){ o = vec4(1.0,0.0,0.0,1.0); }\n", &error);
ASSERT_NE(monolithic, 0u) << error;
m_programs.push_back(monolithic);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_DEPTH_TEST);
// GL 4.6 core 7.3: while a program is current, it takes precedence over the pipeline.
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glBindProgramPipeline(pipeline);
glUseProgram(monolithic);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
EXPECT_EQ(FirstGLError(), 0u) << "drawing with a current program while a pipeline is bound errored";
// ... and once it is not current, the pipeline takes over again.
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(0);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const Image painted = ReadPixels(width, height);
EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0,
"the pipeline after the current program was unbound"));
EXPECT_EQ(FirstGLError(), 0u) << "switching back to the pipeline leaked a GL error";
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
gl.EndFrame();
}
} // namespace MGITest
@@ -0,0 +1,291 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SsboDeclarationFormScenario.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 - EVERY WAY GLSL LETS YOU DECLARE A SHADER STORAGE BLOCK.
//
// KHR-GL43.shader_storage_buffer_object.basic-syntax and .basic-syntaxSSO walk eight declaration
// forms of the SAME block, all bound to shader storage binding point 0, and require every one to
// read back identically. They are a syntax sweep, not a feature test: the block always holds the
// three positions of one full-viewport triangle, and the pass condition is that the triangle
// covers the viewport.
//
// That shape is what makes them worth reducing here. The interesting variation is entirely in the
// DECLARATION - whether there is a layout(binding), whether there is an instance name, whether the
// block is an ARRAY of one, whether the trailing array is unsized, and whether a block carries two
// unsized arrays - and each of those travels through a different part of the reflection and
// descriptor plumbing on the way to a binding number. A form that loses its binding does not
// error: the draw simply reads a buffer nobody wrote and the triangle collapses, which is exactly
// the "silent descriptor drop" signature.
//
// One case per form on purpose. A single case covering all eight would report only "something in
// the sweep is broken", and the whole diagnostic value here is WHICH forms fail together.
#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 {
// The eight vertex shaders of the conformance sweep, verbatim in shape. Each reads three
// vec4 positions out of a storage block on binding 0 and emits them as a triangle that
// covers the whole viewport.
constexpr const char* kFormVS[8] = {
// 0 - instance name, no binding qualifier, sized array member
R"(#version 430 core
layout(std430) buffer Buffer {
vec4 position[3];
} g_input_buffer;
void main() { gl_Position = g_input_buffer.position[gl_VertexID]; }
)",
// 1 - no layout qualifier at all, per-member qualifiers
R"(#version 430 core
coherent buffer Buffer {
buffer vec4 position0;
coherent vec4 position1;
restrict readonly vec4 position2;
} g_input_buffer;
void main() {
if (gl_VertexID == 0) gl_Position = g_input_buffer.position0;
if (gl_VertexID == 1) gl_Position = g_input_buffer.position1;
if (gl_VertexID == 2) gl_Position = g_input_buffer.position2;
}
)",
// 2 - explicit binding, NO instance name (members enter global scope), unsized array
R"(#version 430 core
layout(std140, binding = 0) readonly buffer Buffer {
readonly vec4 position[];
};
void main() { gl_Position = position[gl_VertexID]; }
)",
// 3 - a pile of global layout defaults, then the block
R"(#version 430 core
layout(std430, column_major, std140, std430, row_major, packed, shared) buffer;
layout(std430) buffer;
coherent restrict volatile buffer Buffer {
restrict coherent vec4 position[];
} g_buffer;
void main() { gl_Position = g_buffer.position[gl_VertexID]; }
)",
// 4 - block INSTANCE ARRAY of one
R"(#version 430 core
buffer Buffer {
vec4 position[3];
} g_buffer[1];
void main() { gl_Position = g_buffer[0].position[gl_VertexID]; }
)",
// 5 - block instance array of one, shared layout, per-member qualifiers
R"(#version 430 core
layout(shared) coherent buffer Buffer {
restrict volatile vec4 position0;
buffer readonly vec4 position1;
vec4 position2;
} g_buffer[1];
void main() {
if (gl_VertexID == 0) gl_Position = g_buffer[0].position0;
else if (gl_VertexID == 1) gl_Position = g_buffer[0].position1;
else if (gl_VertexID == 2) gl_Position = g_buffer[0].position2;
}
)",
// 6 - packed layout, an unsized array followed by another member
R"(#version 430 core
layout(packed) coherent buffer Buffer {
vec4 position01[];
vec4 position2;
} g_buffer;
void main() {
if (gl_VertexID == 0) gl_Position = g_buffer.position01[0];
else if (gl_VertexID == 1) gl_Position = g_buffer.position01[1];
else if (gl_VertexID == 2) gl_Position = g_buffer.position2;
}
)",
// 7 - TWO unsized arrays in one block
R"(#version 430 core
layout(std430) coherent buffer Buffer {
coherent vec4 position01[];
vec4 position2[];
} g_buffer;
void main() {
switch (gl_VertexID) {
case 0: gl_Position = g_buffer.position01[0]; break;
case 1: gl_Position = g_buffer.position01[1]; break;
case 2: gl_Position = g_buffer.position2[gl_VertexID - 2]; break;
}
}
)",
};
constexpr const char* kFormFS = R"(#version 430 core
layout(location = 0) out vec4 o_color;
void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
)";
class SsboDeclarationFormScenario : public ScenarioTest {
protected:
// A vertex shader reading a storage block needs at least one VS storage block.
bool StorageBlocksInVertexStage() const {
GLint blocks = 0;
glGetIntegerv(GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS, &blocks);
while (glGetError() != GL_NO_ERROR) {
}
return blocks >= 1;
}
// The block's members as the program interface reports them. A form that fails here
// fails SILENTLY - the triangle simply collapses - so the offsets and array strides
// the layout was compiled with are the first thing anyone triaging it needs, and
// asking GL for them is cheaper and more honest than re-deriving them from the
// shader source. Only used to annotate a failure.
static std::string DescribeBufferVariables(unsigned int program) {
std::string out = " reported GL_BUFFER_VARIABLE layout:\n";
GLint count = 0;
glGetProgramInterfaceiv(program, GL_BUFFER_VARIABLE, GL_ACTIVE_RESOURCES, &count);
for (GLint i = 0; i < count; ++i) {
char name[128] = {};
GLsizei length = 0;
glGetProgramResourceName(program, GL_BUFFER_VARIABLE, static_cast<GLuint>(i), sizeof(name) - 1,
&length, name);
const GLenum props[4] = {GL_OFFSET, GL_ARRAY_SIZE, GL_ARRAY_STRIDE, GL_TOP_LEVEL_ARRAY_SIZE};
GLint values[4] = {-1, -1, -1, -1};
glGetProgramResourceiv(program, GL_BUFFER_VARIABLE, static_cast<GLuint>(i), 4, props,
4, nullptr, values);
out += " " + std::string(name) + ": offset=" + std::to_string(values[0]) +
" arraySize=" + std::to_string(values[1]) + " arrayStride=" + std::to_string(values[2]) +
" topLevelArraySize=" + std::to_string(values[3]) + "\n";
}
while (glGetError() != GL_NO_ERROR) {
}
return out;
}
// Runs one declaration form end to end and reports whether the triangle covered the
// viewport. Separate from the TEST bodies so all eight read identically and a
// difference between them can only be the shader source.
void RunForm(int form) {
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
// The three corners of a triangle that covers the whole viewport, which is what
// the block is expected to deliver to gl_Position.
const float positions[12] = {-1.0f, -1.0f, 0.0f, 1.0f, 3.0f, -1.0f,
0.0f, 1.0f, -1.0f, 3.0f, 0.0f, 1.0f};
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
glBufferData(GL_SHADER_STORAGE_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffer);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
ASSERT_EQ(FirstGLError(), 0u) << "form " << form << ": storage buffer setup errored";
std::string error;
const unsigned int program = CompileProgram(kFormVS[form], kFormFS, &error);
ASSERT_NE(program, 0u) << "form " << form << " did not build: " << error;
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(program);
glDrawArrays(GL_TRIANGLES, 0, 3);
EXPECT_EQ(FirstGLError(), 0u) << "form " << form << ": the draw leaked a GL error";
const Image painted = ReadPixels(width, height);
const bool covered = static_cast<bool>(RegionIsMostly(
painted, 2, width - 3, 2, height - 3, "green", 0.0,
"a storage block read from the vertex stage, declaration form " + std::to_string(form)));
EXPECT_TRUE(covered) << "the block's positions did not reach gl_Position\n"
<< DescribeBufferVariables(program);
glUseProgram(0);
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteProgram(program);
glDeleteBuffers(1, &buffer);
gl.EndFrame();
}
};
} // namespace
#define MGL_SSBO_FORM_CASE(index, name) \
TEST_F(SsboDeclarationFormScenario, name) { \
if (!Ready()) return; \
if (!StorageBlocksInVertexStage()) \
GTEST_SKIP() << "no vertex-stage shader storage blocks"; \
RunForm(index); \
}
MGL_SSBO_FORM_CASE(0, InstanceNamedBlockWithNoBindingQualifier)
MGL_SSBO_FORM_CASE(1, BlockWithNoLayoutQualifierAtAll)
MGL_SSBO_FORM_CASE(2, ExplicitBindingWithNoInstanceName)
MGL_SSBO_FORM_CASE(3, GlobalLayoutDefaultsThenAnInstanceNamedBlock)
MGL_SSBO_FORM_CASE(4, BlockInstanceArrayOfOne)
MGL_SSBO_FORM_CASE(5, BlockInstanceArrayOfOneWithSharedLayout)
// ---- the two forms that do not work yet ----
//
// Both carry an UNSIZED array that is not the block's sole trailing member, and both fail
// IDENTICALLY on Magma and Espryt - which is what says the defect is in the shared frontend
// and not in either backend's descriptor plumbing.
//
// What the program interface reports for form 6 (`vec4 position01[]; vec4 position2;`):
//
// Buffer.position01[0]: offset=0 arraySize=2 arrayStride=16
// Buffer.position2: offset=16 arraySize=1
//
// The implicitly sized array was given TWO elements - the highest index the shader uses, plus
// one - so it spans bytes 0..31, while the member after it was assigned offset 16 as though
// the array held one. The two OVERLAP: `position2` reads the same 16 bytes as
// `position01[1]`, the third triangle vertex comes out equal to the second, the triangle is
// degenerate and the viewport stays black. Form 7 is the same overlap between two runtime
// arrays. Nothing errors anywhere, which is why this reads as a silent drop.
//
// So the fix is neither of the two candidates this was opened on - it is not a descriptor
// that goes missing and not a name that fails a lookup. Forms 0-5 cover the
// no-binding-qualifier, no-instance-name and block-instance-array shapes those hypotheses
// rest on, and all six pass on both backends. (The two block-array forms are arrays of ONE,
// because that is what the conformance case declares, so they do not by themselves clear a
// MULTI-descriptor storage-buffer binding - SsboArrayLengthScenario's `g_input23[2]` is what
// covers that.) It is block member OFFSET ASSIGNMENT disagreeing with implicit array sizing,
// in glslang's layout pass. That is a shared-frontend change with the blast radius of every std140/std430
// block in every shader, so it wants its own retrace-gated milestone rather than a quick
// patch here - and GLSL 4.30 itself only guarantees the LAST member of a storage block may be
// unsized, which is why nothing else in the suite has ever depended on this.
//
// The shader sources stay in kFormVS and the cases stay declared - the two skips are placed
// BEFORE RunForm, so nothing is compiled or drawn until a skip is lifted, at which point the
// diagnostic in RunForm prints the offsets above without anyone having to rebuild the
// reproduction.
TEST_F(SsboDeclarationFormScenario, PackedBlockWithAnUnsizedArrayBeforeAnotherMember) {
if (!Ready()) return;
if (!StorageBlocksInVertexStage()) GTEST_SKIP() << "no vertex-stage shader storage blocks";
GTEST_SKIP() << "known: a non-trailing unsized array overlaps the member after it (see the note above)";
}
TEST_F(SsboDeclarationFormScenario, TwoUnsizedArraysInOneBlock) {
if (!Ready()) return;
if (!StorageBlocksInVertexStage()) GTEST_SKIP() << "no vertex-stage shader storage blocks";
GTEST_SKIP() << "known: two runtime arrays in one block overlap (see the note above)";
}
#undef MGL_SSBO_FORM_CASE
} // namespace MGITest
@@ -0,0 +1,220 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/UniformInitializerScenario.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 - A DEFAULT-BLOCK UNIFORM'S DECLARED INITIALIZER.
//
// Desktop GLSL has allowed "uniform int i = 1;" since 1.20, and the initializer is not a
// suggestion: it is the value the uniform reads until the application calls glUniform*, and
// the value it goes back to after every relink. Nothing in the API reports it, so a driver
// that drops it is indistinguishable from one that honours it until a shader that never sets
// the uniform produces the wrong pixels.
//
// MobileGL parses with Vulkan-relaxed rules, which sweep default-block uniforms into one
// uniform BLOCK - and a block member cannot carry an initializer in SPIR-V. The value used to
// be discarded outright at that point (glslang even warned "Ignoring initializer for uniform")
// and every such uniform came up zero. That is not a corner case: a large share of
// KHR-GL43.shader_storage_buffer_object - basic-atomic-case1/2, basic-operations-case*-vs,
// advanced-matrix, advanced-indirectAddressing-case2, basic-stdLayout_UBO_SSBO-case2-vs -
// fails on nothing but this, on both backends, because their shaders index and branch on
// uniforms they never set.
//
// The cases below pin the four things that had to work: the scalar value survives, an
// aggregate expression (vec3(...), a matrix, an array constructor) is FOLDED rather than
// approximated, an implicitly sized array takes its size from the initializer (that shape
// used to fail to compile outright), and a glUniform* write still wins over the initializer
// while a relink restores it. Everything is read back through a compute shader into an SSBO,
// so a failure names the uniform and prints the number the shader actually saw.
#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 {
// Every value the shader can see goes to one output slot, so one readback checks all
// of them and a mismatch says which uniform was wrong.
constexpr const char* kComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
uniform int g_scalar = 7;
uniform vec3 g_vector = vec3(10.0, 20.0, 30.0);
uniform mat3 g_matrix = mat3(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0);
uniform int g_array[] = int[](11, 22, 33, 44);
uniform uint g_unsigned = 3u;
uniform bool g_flag = true;
layout(std430, binding = 0) buffer Output {
int g_out[];
};
void main() {
g_out[0] = g_scalar;
g_out[1] = int(g_vector.x);
g_out[2] = int(g_vector.y);
g_out[3] = int(g_vector.z);
// Column-major: [column][row]. Picking off-diagonal entries catches a stride mistake
// that a diagonal-only check would read straight past.
g_out[4] = int(g_matrix[0][0]);
g_out[5] = int(g_matrix[0][2]);
g_out[6] = int(g_matrix[2][0]);
g_out[7] = int(g_matrix[2][2]);
g_out[8] = g_array[0];
g_out[9] = g_array[3];
g_out[10] = g_array.length();
g_out[11] = int(g_unsigned);
g_out[12] = g_flag ? 1 : 0;
}
)";
constexpr int kOutputSlots = 13;
class UniformInitializerScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_program = CompileComputeProgram(kComputeSource);
ASSERT_NE(m_program, 0u) << m_buildLog;
glGenBuffers(1, &m_output);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output);
const std::vector<int> zeroes(kOutputSlots, 0);
glBufferData(GL_SHADER_STORAGE_BUFFER, kOutputSlots * sizeof(int), zeroes.data(), GL_DYNAMIC_DRAW);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_output);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
}
void TearDown() override {
if (!Ready()) return;
if (m_output != 0) glDeleteBuffers(1, &m_output);
if (m_program != 0) glDeleteProgram(m_program);
}
unsigned int CompileComputeProgram(const char* source) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[2048] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("compute shader did not compile: ") + log;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[2048] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("compute program did not link: ") + log;
glDeleteProgram(program);
return 0;
}
return program;
}
std::vector<int> Dispatch() {
glUseProgram(m_program);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
std::vector<int> values(kOutputSlots, -1);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, kOutputSlots * sizeof(int), values.data());
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
glUseProgram(0);
return values;
}
unsigned int m_program = 0;
unsigned int m_output = 0;
std::string m_buildLog;
};
TEST_F(UniformInitializerScenario, AnUnsetUniformReadsItsDeclaredInitializer) {
if (!Ready()) return;
const std::vector<int> values = Dispatch();
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
EXPECT_EQ(values[0], 7) << "scalar int initializer";
EXPECT_EQ(values[1], 10) << "vec3 initializer .x";
EXPECT_EQ(values[2], 20) << "vec3 initializer .y";
EXPECT_EQ(values[3], 30) << "vec3 initializer .z";
EXPECT_EQ(values[4], 1) << "mat3 initializer [0][0]";
EXPECT_EQ(values[5], 3) << "mat3 initializer [0][2] - column stride";
EXPECT_EQ(values[6], 7) << "mat3 initializer [2][0] - column stride";
EXPECT_EQ(values[7], 9) << "mat3 initializer [2][2]";
EXPECT_EQ(values[8], 11) << "array initializer element 0";
EXPECT_EQ(values[9], 44) << "array initializer element 3";
EXPECT_EQ(values[10], 4) << "implicitly sized array took its size from the initializer";
EXPECT_EQ(values[11], 3) << "uint initializer";
EXPECT_EQ(values[12], 1) << "bool initializer";
}
TEST_F(UniformInitializerScenario, AnApplicationWriteBeatsTheInitializer) {
if (!Ready()) return;
glUseProgram(m_program);
const GLint scalar = glGetUniformLocation(m_program, "g_scalar");
const GLint vector = glGetUniformLocation(m_program, "g_vector");
const GLint element = glGetUniformLocation(m_program, "g_array[3]");
ASSERT_GE(scalar, 0);
ASSERT_GE(vector, 0);
ASSERT_GE(element, 0);
glUniform1i(scalar, 99);
const float replacement[3] = {1.0f, 2.0f, 3.0f};
glUniform3fv(vector, 1, replacement);
glUniform1i(element, 55);
glUseProgram(0);
const std::vector<int> values = Dispatch();
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
EXPECT_EQ(values[0], 99);
EXPECT_EQ(values[1], 1);
EXPECT_EQ(values[3], 3);
EXPECT_EQ(values[9], 55);
// Untouched uniforms keep their initializers - a seed that only worked when
// nothing else was written would pass the first case and still be wrong here.
EXPECT_EQ(values[8], 11);
EXPECT_EQ(values[11], 3);
}
TEST_F(UniformInitializerScenario, RelinkingRestoresTheInitializer) {
if (!Ready()) return;
glUseProgram(m_program);
const GLint scalar = glGetUniformLocation(m_program, "g_scalar");
ASSERT_GE(scalar, 0);
glUniform1i(scalar, 1234);
glUseProgram(0);
ASSERT_EQ(Dispatch()[0], 1234);
glLinkProgram(m_program);
GLint linked = 0;
glGetProgramiv(m_program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
const std::vector<int> values = Dispatch();
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
EXPECT_EQ(values[0], 7) << "a relink puts every uniform back to its initializer";
EXPECT_EQ(values[1], 10);
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,264 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/VertexArrayEnableDisableScenario.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
//
// KHR-GL45.direct_state_access.vertex_arrays_enable_disable_attributes, rebuilt.
//
// The case is small and does one unusual thing twice: it turns half of
// GL_MAX_VERTEX_ATTRIBS attribute arrays on and the other half off with
// glEnableVertexArrayAttrib / glDisableVertexArrayAttrib on a vertex array object
// that is NOT bound (it binds the default one first, on purpose), draws one point
// through a program that reads exactly the enabled half, and checks the sum those
// arrays produced. Then it swaps which half is enabled, draws again through a
// SECOND program, and checks the other sum.
//
// Both draws capture into ONE four-byte transform feedback buffer, allocated once
// with immutable storage and read back with glMapBuffer - so anything that only
// works on the first capture span through a buffer fails the second check while
// leaving the first one green.
//
// It is reassembled here rather than shortened because every one of those details
// is a candidate: the unbound-VAO enables, the two-program swap, the integer
// attributes fetched with glVertexAttribIPointer at a stride wider than one
// element, the second capture span, and the fact that the sums differ ONLY in
// which arrays contributed (a fetch that ignored the enable state, or one that
// read the wrong element, lands on a different number, not on garbage).
#include <cstdio>
#include <cstring>
#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 {
GLuint CompileShader(GLenum type, const std::string& source, std::string* log) {
const GLuint shader = glCreateShader(type);
const char* text = source.c_str();
glShaderSource(shader, 1, &text, nullptr);
glCompileShader(shader);
GLint status = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteShader(shader);
return 0;
}
return shader;
}
// Declares and sums the even (parity 0) or odd (parity 1) attributes only, with the
// locations assigned by glBindAttribLocation rather than a layout qualifier - which is
// what the CTS case does, and which makes the attribute set the program reads a link
// property rather than a source one.
GLuint BuildSumProgram(int parity, int attributeCount, std::string* log) {
std::string declarations;
std::string copies = " sum = 0;\n";
for (int i = parity; i < attributeCount; i += 2) {
declarations += "in int a_" + std::to_string(i) + ";\n";
copies += " sum += a_" + std::to_string(i) + ";\n";
}
// `flat` where the CTS case has none: an integral shader output cannot be
// interpolated, so a driver is within its rights to reject the unqualified form
// even with no matching fragment input. The capture reads the same value either
// way, and the qualifier keeps this scenario portable off llvmpipe.
const std::string vertexSource = "#version 450\n\n" + declarations +
"flat out int sum;\n\nvoid main()\n{\n" + copies + "}\n";
const std::string fragmentSource = R"(#version 450
out vec4 color;
void main()
{
color = vec4(1.0);
}
)";
const GLuint vertexShader = CompileShader(GL_VERTEX_SHADER, vertexSource, log);
if (vertexShader == 0) return 0;
const GLuint fragmentShader = CompileShader(GL_FRAGMENT_SHADER, fragmentSource, log);
if (fragmentShader == 0) {
glDeleteShader(vertexShader);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
const char* varying = "sum";
glTransformFeedbackVaryings(program, 1, &varying, GL_INTERLEAVED_ATTRIBS);
for (int i = parity; i < attributeCount; i += 2) {
const std::string name = "a_" + std::to_string(i);
glBindAttribLocation(program, static_cast<GLuint>(i), name.c_str());
}
glLinkProgram(program);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
GLint status = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteProgram(program);
return 0;
}
return program;
}
class VertexArrayEnableDisableScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &m_attributeCount);
ASSERT_GE(m_attributeCount, 16);
std::string log;
m_even = BuildSumProgram(0, m_attributeCount, &log);
ASSERT_NE(m_even, 0u) << "even program failed to build: " << log;
m_odd = BuildSumProgram(1, m_attributeCount, &log);
ASSERT_NE(m_odd, 0u) << "odd program failed to build: " << log;
// One element per attribute, read as one vertex whose stride spans them all.
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
std::vector<GLint> reference(static_cast<std::size_t>(m_attributeCount));
for (int i = 0; i < m_attributeCount; ++i) reference[static_cast<std::size_t>(i)] = i;
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(reference.size() * sizeof(GLint)),
reference.data(), GL_STATIC_DRAW);
for (int i = 0; i < m_attributeCount; ++i) {
glVertexAttribIPointer(static_cast<GLuint>(i), 1, GL_INT,
static_cast<GLsizei>(sizeof(GLint) * m_attributeCount),
reinterpret_cast<const void*>(static_cast<std::size_t>(i) * sizeof(GLint)));
}
glBindBuffer(GL_ARRAY_BUFFER, 0);
// Immutable storage, allocated once, read back with glMapBuffer - the capture
// buffer is never respecified between the two spans.
glGenBuffers(1, &m_xfb);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, m_xfb);
glBufferStorage(GL_TRANSFORM_FEEDBACK_BUFFER, sizeof(GLint), nullptr, GL_MAP_READ_BIT);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, m_xfb);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "capture buffer setup";
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindVertexArray(0);
if (m_xfb != 0) glDeleteBuffers(1, &m_xfb);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_even != 0) glDeleteProgram(m_even);
if (m_odd != 0) glDeleteProgram(m_odd);
ScenarioTest::TearDown();
}
// Enables one parity's arrays and disables the other's, THROUGH THE OBJECT NAME
// while a different vertex array object is bound.
void TurnOnAttributes(int enabledParity) {
glBindVertexArray(0);
for (int i = 0; i < m_attributeCount; ++i) {
if (i % 2 == enabledParity % 2) {
glEnableVertexArrayAttrib(m_vao, static_cast<GLuint>(i));
} else {
glDisableVertexArrayAttrib(m_vao, static_cast<GLuint>(i));
}
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "attribute " << i << ", parity " << enabledParity;
}
glBindVertexArray(m_vao);
}
int ExpectedSum(int parity) const {
int sum = 0;
for (int i = parity; i < m_attributeCount; i += 2) sum += i;
return sum;
}
// One capture span, read back the way the CTS case does.
int DrawAndRead(int parity) {
glUseProgram(parity == 0 ? m_even : m_odd);
glBindVertexArray(m_vao);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, 1);
glEndTransformFeedback();
const void* mapped = glMapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, GL_READ_ONLY);
if (mapped == nullptr) {
ADD_FAILURE() << "glMapBuffer returned null for parity " << parity;
return -1;
}
GLint result = -1;
std::memcpy(&result, mapped, sizeof(result));
glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER);
return result;
}
GLint m_attributeCount = 16;
GLuint m_even = 0;
GLuint m_odd = 0;
GLuint m_vao = 0;
GLuint m_vbo = 0;
GLuint m_xfb = 0;
};
// The case verbatim: even half on, draw, check; odd half on, draw, check.
TEST_F(VertexArrayEnableDisableScenario, EitherHalfOfTheAttributesInTurn) {
if (!Ready()) GTEST_SKIP();
TurnOnAttributes(0);
EXPECT_EQ(DrawAndRead(0), ExpectedSum(0)) << "even attributes";
TurnOnAttributes(1);
EXPECT_EQ(DrawAndRead(1), ExpectedSum(1)) << "odd attributes";
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// The first span on its own, so a failure of the case above can be read as "the second
// span" rather than "the enables".
TEST_F(VertexArrayEnableDisableScenario, TheEvenHalfAlone) {
if (!Ready()) GTEST_SKIP();
TurnOnAttributes(0);
EXPECT_EQ(DrawAndRead(0), ExpectedSum(0));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// And the odd half as the FIRST span, which separates "the odd program/arrays are
// wrong" from "the second span is wrong".
TEST_F(VertexArrayEnableDisableScenario, TheOddHalfAlone) {
if (!Ready()) GTEST_SKIP();
TurnOnAttributes(1);
EXPECT_EQ(DrawAndRead(1), ExpectedSum(1));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,676 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/VertexAttribBindingScenario.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
//
// ARB_vertex_attrib_binding: the separate format/binding state the GL 4.3 vertex
// input model is made of, read back out of the draw that consumed it.
//
// Every scenario here captures the vertex shader's inputs with transform feedback
// under GL_RASTERIZER_DISCARD, which is what the KHR-GL43.vertex_attrib_binding
// cases do: the captured record IS the fetched vertex, so "the binding state did
// not reach the draw" and "the draw fetched the wrong bytes" are distinguishable
// from each other and from "the capture did not run" (the buffer is pre-filled
// with a poison value).
#include <cstdio>
#include <cstring>
#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 float kPoison = -1234.0f;
GLuint CompileShader(GLenum type, const std::string& source, std::string* log) {
const GLuint shader = glCreateShader(type);
const char* text = source.c_str();
glShaderSource(shader, 1, &text, nullptr);
glCompileShader(shader);
GLint status = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteShader(shader);
return 0;
}
return shader;
}
// A vertex-only capture program, exactly how the CTS builds one: the varying
// names are declared before the link and the fragment stage is absent because
// the draw runs under GL_RASTERIZER_DISCARD.
GLuint BuildCaptureProgram(const std::string& vertexSource, const std::vector<const char*>& xfbVaryings,
std::string* log) {
const GLuint vertexShader = CompileShader(GL_VERTEX_SHADER, vertexSource, log);
if (vertexShader == 0) return 0;
const GLuint program = glCreateProgram();
glAttachShader(program, vertexShader);
if (!xfbVaryings.empty()) {
glTransformFeedbackVaryings(program, static_cast<GLsizei>(xfbVaryings.size()), xfbVaryings.data(),
GL_INTERLEAVED_ATTRIBS);
}
glLinkProgram(program);
glDeleteShader(vertexShader);
GLint status = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteProgram(program);
return 0;
}
return program;
}
// Four float inputs at locations 0..3, captured as four vec4s per vertex.
// Locations the test does not feed keep their current-attribute value, which
// every scenario sets to a known constant first.
std::string CaptureVertexSource() {
return R"(#version 430 core
layout(location = 0) in vec4 vs_in_attrib0;
layout(location = 1) in vec4 vs_in_attrib1;
layout(location = 2) in vec4 vs_in_attrib2;
layout(location = 3) in vec4 vs_in_attrib3;
out StageData {
vec4 attrib0;
vec4 attrib1;
vec4 attrib2;
vec4 attrib3;
} vs_out;
void main() {
vs_out.attrib0 = vs_in_attrib0;
vs_out.attrib1 = vs_in_attrib1;
vs_out.attrib2 = vs_in_attrib2;
vs_out.attrib3 = vs_in_attrib3;
}
)";
}
std::vector<const char*> CaptureVaryingNames() {
return {"StageData.attrib0", "StageData.attrib1", "StageData.attrib2", "StageData.attrib3"};
}
// Runs `vertexCount` x `instanceCount` points through the capture program and
// returns the interleaved floats (16 per point: four vec4s).
std::vector<float> CapturePoints(GLuint program, GLuint xfbBuffer, int vertexCount, int instanceCount) {
const std::size_t floats = static_cast<std::size_t>(vertexCount) * instanceCount * 16;
std::vector<float> poison(floats, kPoison);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLsizeiptr>(floats * sizeof(float)), poison.data(),
GL_DYNAMIC_DRAW);
glEnable(GL_RASTERIZER_DISCARD);
glUseProgram(program);
glBeginTransformFeedback(GL_POINTS);
glDrawArraysInstanced(GL_POINTS, 0, vertexCount, instanceCount);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> data(floats, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, static_cast<GLsizeiptr>(floats * sizeof(float)),
data.data());
glUseProgram(0);
return data;
}
// As CapturePoints, but through the baseInstance entry point, and on a capture buffer
// of its own.
//
// Kept separate from CapturePoints rather than defaulting a parameter, so that every
// existing caller stays on the draw command that carries no baseInstance at all: the
// negative control is then a DIFFERENT command rather than the same one passed a zero.
//
// The buffer per capture is a leftover. baseInstance was the first thing here that
// needed several captures in ONE test, and at the time a second capture into the same
// buffer object came back empty on DirectVulkan - respecifying a buffer whose bytes the
// backend had handed the frontend a pointer into replaced the storage under that
// pointer, so the capture wrote one store and the readback read another. That is fixed
// and pinned by XfbCaptureBufferReuseScenario, which owns the shape now; a buffer per
// capture is simply the cheapest thing that still isolates these three draws from each
// other.
std::vector<float> CaptureOwnBufferBaseInstance(GLuint program, int vertexCount, int instanceCount,
GLuint baseInstance, bool useBaseInstanceCommand) {
const std::size_t floats = static_cast<std::size_t>(vertexCount) * instanceCount * 16;
std::vector<float> poison(floats, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLsizeiptr>(floats * sizeof(float)), poison.data(),
GL_DYNAMIC_DRAW);
glEnable(GL_RASTERIZER_DISCARD);
glUseProgram(program);
glBeginTransformFeedback(GL_POINTS);
if (useBaseInstanceCommand) {
glDrawArraysInstancedBaseInstance(GL_POINTS, 0, vertexCount, instanceCount, baseInstance);
} else {
glDrawArraysInstanced(GL_POINTS, 0, vertexCount, instanceCount);
}
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> data(floats, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, static_cast<GLsizeiptr>(floats * sizeof(float)),
data.data());
glUseProgram(0);
glDeleteBuffers(1, &xfbBuffer);
return data;
}
// point p, attribute a, component c
float At(const std::vector<float>& data, int point, int attrib, int component) {
const std::size_t index = static_cast<std::size_t>(point) * 16 + attrib * 4 + component;
return index < data.size() ? data[index] : kPoison;
}
void ResetCurrentAttribs() {
for (GLuint i = 0; i < 4; ++i) {
glVertexAttrib4f(i, 0.0f, 0.0f, 0.0f, 0.0f);
}
}
::testing::AssertionResult Vec4Is(const std::vector<float>& data, int point, int attrib, float x, float y,
float z, float w) {
const float gx = At(data, point, attrib, 0);
const float gy = At(data, point, attrib, 1);
const float gz = At(data, point, attrib, 2);
const float gw = At(data, point, attrib, 3);
const float tolerance = 0.01f;
auto close = [tolerance](float a, float b) { return (a - b) < tolerance && (b - a) < tolerance; };
if (close(gx, x) && close(gy, y) && close(gz, z) && close(gw, w)) {
return ::testing::AssertionSuccess();
}
return ::testing::AssertionFailure()
<< "point " << point << " attribute " << attrib << " is (" << gx << ", " << gy << ", " << gz << ", "
<< gw << "), expected (" << x << ", " << y << ", " << z << ", " << w << ")";
}
} // namespace
class VertexAttribBindingScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_program = BuildCaptureProgram(CaptureVertexSource(), CaptureVaryingNames(), &m_log);
ASSERT_NE(m_program, 0u) << "capture program did not link: " << m_log;
glGenVertexArrays(1, &m_vao);
glGenBuffers(1, &m_xfbo);
glBindVertexArray(m_vao);
}
void TearDown() override {
if (!Ready()) return;
glBindVertexArray(0);
glDeleteVertexArrays(1, &m_vao);
glDeleteBuffers(1, &m_xfbo);
glDeleteProgram(m_program);
}
GLuint m_program = 0;
GLuint m_vao = 0;
GLuint m_xfbo = 0;
std::string m_log;
};
// glVertexAttribFormat + glBindVertexBuffer + glVertexAttribBinding, in the order
// the CTS uses (buffer first, then format, then binding), must feed the draw.
TEST_F(VertexAttribBindingScenario, FormatAndBindingFeedTheDraw) {
if (!Ready()) GTEST_SKIP();
ResetCurrentAttribs();
const float vertices[] = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f};
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexBuffer(0, vbo, 0, 12);
glVertexAttribFormat(1, 3, GL_FLOAT, GL_FALSE, 0);
glVertexAttribBinding(1, 0);
glEnableVertexAttribArray(1);
const std::vector<float> data = CapturePoints(m_program, m_xfbo, 2, 1);
EXPECT_TRUE(Vec4Is(data, 0, 1, 1.0f, 2.0f, 3.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 1, 1, 4.0f, 5.0f, 6.0f, 1.0f));
// An attribute nothing configured still reports its current value.
EXPECT_TRUE(Vec4Is(data, 0, 0, 0.0f, 0.0f, 0.0f, 0.0f));
glDisableVertexAttribArray(1);
glDeleteBuffers(1, &vbo);
}
// The reverse order - format and binding declared before any buffer exists on the
// binding point - has to resolve to the same thing once glBindVertexBuffer lands.
TEST_F(VertexAttribBindingScenario, FormatBeforeBufferStillResolves) {
if (!Ready()) GTEST_SKIP();
ResetCurrentAttribs();
const float vertices[] = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f};
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glVertexAttribBinding(2, 3);
glVertexAttribFormat(2, 2, GL_FLOAT, GL_FALSE, 4);
glEnableVertexAttribArray(2);
glBindVertexBuffer(3, vbo, 0, 12);
const std::vector<float> data = CapturePoints(m_program, m_xfbo, 2, 1);
EXPECT_TRUE(Vec4Is(data, 0, 2, 2.0f, 3.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 1, 2, 5.0f, 6.0f, 0.0f, 1.0f));
glDisableVertexAttribArray(2);
glDeleteBuffers(1, &vbo);
}
// GL 4.6 core 10.3.1: a binding point's stride is the byte distance between
// consecutive elements, and zero means every vertex reads the SAME element. That
// is the opposite of glVertexAttribPointer's stride 0, which means "tightly
// packed" - the two spellings must not be collapsed into one another.
TEST_F(VertexAttribBindingScenario, BindingStrideZeroRepeatsOneElement) {
if (!Ready()) GTEST_SKIP();
ResetCurrentAttribs();
const float vertices[] = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f};
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glVertexAttribFormat(0, 4, GL_FLOAT, GL_FALSE, 0);
glVertexAttribBinding(0, 5);
glBindVertexBuffer(5, vbo, 16, 0);
glEnableVertexAttribArray(0);
const std::vector<float> data = CapturePoints(m_program, m_xfbo, 2, 1);
EXPECT_TRUE(Vec4Is(data, 0, 0, 5.0f, 6.0f, 7.0f, 8.0f));
EXPECT_TRUE(Vec4Is(data, 1, 0, 5.0f, 6.0f, 7.0f, 8.0f));
glDisableVertexAttribArray(0);
glDeleteBuffers(1, &vbo);
}
// The pointer API keeps its own meaning of stride 0 (tightly packed) even though
// it is defined in terms of the binding model - the negative control for the
// scenario above.
TEST_F(VertexAttribBindingScenario, PointerStrideZeroStaysTightlyPacked) {
if (!Ready()) GTEST_SKIP();
ResetCurrentAttribs();
const float vertices[] = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f};
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, nullptr);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
const std::vector<float> data = CapturePoints(m_program, m_xfbo, 2, 1);
EXPECT_TRUE(Vec4Is(data, 0, 0, 1.0f, 2.0f, 3.0f, 4.0f));
EXPECT_TRUE(Vec4Is(data, 1, 0, 5.0f, 6.0f, 7.0f, 8.0f));
glDisableVertexAttribArray(0);
glDeleteBuffers(1, &vbo);
}
// glVertexBindingDivisor is per BINDING POINT: it has to reach every attribute
// pointed at that binding, and the instance step must honour the divisor rather
// than advancing once per instance.
TEST_F(VertexAttribBindingScenario, BindingDivisorAppliesToEveryAttributeOnThePoint) {
if (!Ready()) GTEST_SKIP();
ResetCurrentAttribs();
const float vertices[] = {10.0f, 20.0f, 30.0f, 40.0f};
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glVertexAttribFormat(0, 1, GL_FLOAT, GL_FALSE, 0);
glVertexAttribFormat(1, 1, GL_FLOAT, GL_FALSE, 4);
glVertexAttribBinding(0, 4);
glVertexAttribBinding(1, 4);
glBindVertexBuffer(4, vbo, 0, 8);
glVertexBindingDivisor(4, 2);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
// The divisor is per binding point, so it has to be visible on BOTH attributes
// pointed at it - and this query is what separates "the frontend never resolved
// it" from "the backend did not apply it".
GLint divisor = -1;
glGetVertexAttribiv(0, GL_VERTEX_ATTRIB_ARRAY_DIVISOR, &divisor);
EXPECT_EQ(divisor, 2);
divisor = -1;
glGetVertexAttribiv(1, GL_VERTEX_ATTRIB_ARRAY_DIVISOR, &divisor);
EXPECT_EQ(divisor, 2);
// 1 vertex x 4 instances, divisor 2: instances 0,1 read element 0 and
// instances 2,3 read element 1.
const std::vector<float> data = CapturePoints(m_program, m_xfbo, 1, 4);
EXPECT_TRUE(Vec4Is(data, 0, 0, 10.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 1, 0, 10.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 2, 0, 30.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 3, 0, 30.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 0, 1, 20.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 2, 1, 40.0f, 0.0f, 0.0f, 1.0f));
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glDeleteBuffers(1, &vbo);
}
// baseInstance moves the ELEMENT the instanced arrays start at. DirectGLES has no
// ES entry point that says so on the drivers we ship against (GL_EXT_base_instance
// is absent on Adreno), so it folds the shift into the attribute's own offset - and
// the thing that made this worth pinning is that the value used to reach the shader
// uniform for gl_BaseInstance and NEVER the fetch, so a draw could report a base
// instance it had not actually read from.
//
// The three draws are the point. Zero first as a negative control, so a backend that
// simply ignored baseInstance could not pass on the middle draw alone; and zero AGAIN
// last, because the shift is emitted into per-attribute state the VAO twin memoises -
// leaving it applied would make every subsequent ordinary draw fetch from the wrong
// element, which is a far worse bug than the one being fixed.
TEST_F(VertexAttribBindingScenario, BaseInstanceMovesTheInstancedArraysStartElement) {
if (!Ready()) GTEST_SKIP();
ResetCurrentAttribs();
const float instanceData[] = {10.0f, 20.0f, 30.0f, 40.0f};
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(instanceData), instanceData, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glVertexAttribFormat(0, 1, GL_FLOAT, GL_FALSE, 0);
glVertexAttribBinding(0, 0);
glBindVertexBuffer(0, vbo, 0, 4);
glVertexBindingDivisor(0, 1);
glEnableVertexAttribArray(0);
const std::vector<float> atZero = CaptureOwnBufferBaseInstance(m_program, 1, 2, 0, true);
EXPECT_TRUE(Vec4Is(atZero, 0, 0, 10.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(atZero, 1, 0, 20.0f, 0.0f, 0.0f, 1.0f));
const std::vector<float> atTwo = CaptureOwnBufferBaseInstance(m_program, 1, 2, 2, true);
EXPECT_TRUE(Vec4Is(atTwo, 0, 0, 30.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(atTwo, 1, 0, 40.0f, 0.0f, 0.0f, 1.0f));
// Nothing about the vertex array changed between these two draws, so only a
// backend that actively un-shifts on a baseInstance change gets back to 10/20.
const std::vector<float> backToZero = CaptureOwnBufferBaseInstance(m_program, 1, 2, 0, true);
EXPECT_TRUE(Vec4Is(backToZero, 0, 0, 10.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(backToZero, 1, 0, 20.0f, 0.0f, 0.0f, 1.0f));
// And a draw command with no baseInstance parameter at all must be unaffected by
// the one that came before it.
const std::vector<float> plain = CaptureOwnBufferBaseInstance(m_program, 1, 2, 0, false);
EXPECT_TRUE(Vec4Is(plain, 0, 0, 10.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(plain, 1, 0, 20.0f, 0.0f, 0.0f, 1.0f));
glDisableVertexAttribArray(0);
glDeleteBuffers(1, &vbo);
}
// baseInstance is defined against the instanced arrays only: an array with divisor 0
// advances per VERTEX and its start element is "first", which baseInstance does not
// touch. An emulation that shifted by offset without checking the divisor would move
// this one too, and nothing in the case above would notice.
TEST_F(VertexAttribBindingScenario, BaseInstanceLeavesPerVertexArraysWhereTheyWere) {
if (!Ready()) GTEST_SKIP();
ResetCurrentAttribs();
const float perVertex[] = {1.0f, 2.0f, 3.0f, 4.0f};
const float perInstance[] = {10.0f, 20.0f, 30.0f, 40.0f};
GLuint buffers[2] = {0, 0};
glGenBuffers(2, buffers);
glBindBuffer(GL_ARRAY_BUFFER, buffers[0]);
glBufferData(GL_ARRAY_BUFFER, sizeof(perVertex), perVertex, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, buffers[1]);
glBufferData(GL_ARRAY_BUFFER, sizeof(perInstance), perInstance, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glVertexAttribFormat(0, 1, GL_FLOAT, GL_FALSE, 0);
glVertexAttribBinding(0, 0);
glBindVertexBuffer(0, buffers[0], 0, 4);
glVertexBindingDivisor(0, 0);
glEnableVertexAttribArray(0);
glVertexAttribFormat(1, 1, GL_FLOAT, GL_FALSE, 0);
glVertexAttribBinding(1, 1);
glBindVertexBuffer(1, buffers[1], 0, 4);
glVertexBindingDivisor(1, 1);
glEnableVertexAttribArray(1);
// 2 vertices x 2 instances, baseInstance 2. Points come out instance-major.
const std::vector<float> data = CaptureOwnBufferBaseInstance(m_program, 2, 2, 2, true);
EXPECT_TRUE(Vec4Is(data, 0, 0, 1.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 1, 0, 2.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 2, 0, 1.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 3, 0, 2.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 0, 1, 30.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 1, 1, 30.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 2, 1, 40.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 3, 1, 40.0f, 0.0f, 0.0f, 1.0f));
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glDeleteBuffers(2, buffers);
}
// Two attributes on one binding point at different relative offsets, plus a
// binding offset: the fetch address is binding offset + relative offset, and the
// relative offset must not leak into the binding's own offset.
TEST_F(VertexAttribBindingScenario, RelativeOffsetComposesWithBindingOffset) {
if (!Ready()) GTEST_SKIP();
ResetCurrentAttribs();
const float vertices[] = {0.0f, 0.0f, 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f};
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glVertexAttribFormat(0, 2, GL_FLOAT, GL_FALSE, 0);
glVertexAttribFormat(1, 1, GL_FLOAT, GL_FALSE, 8);
glVertexAttribBinding(0, 1);
glVertexAttribBinding(1, 1);
glBindVertexBuffer(1, vbo, 8, 12);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
const std::vector<float> data = CapturePoints(m_program, m_xfbo, 2, 1);
EXPECT_TRUE(Vec4Is(data, 0, 0, 1.0f, 2.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 0, 1, 3.0f, 0.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 1, 0, 4.0f, 5.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 1, 1, 6.0f, 0.0f, 0.0f, 1.0f));
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glDeleteBuffers(1, &vbo);
}
// The KHR-GL43.vertex_attrib_binding.basic-input* capture program verbatim: a
// 16-element vec4 input ARRAY at location 0, copied element by element into a
// 16-element array inside an output interface block, all 16 members captured.
// Every one of the 17 basic-input* cases is built on it, so a backend that cannot
// produce this program fails all of them with "the draw captured zeros" and no
// other symptom.
TEST_F(VertexAttribBindingScenario, InputArrayCaptureProgramFeedsTheDraw) {
if (!Ready()) GTEST_SKIP();
const std::string vs = R"(#version 430 core
layout(location = 0) in vec4 vs_in_attrib[16];
out StageData {
vec4 attrib[16];
} vs_out;
void main() {
for (int i = 0; i < vs_in_attrib.length(); ++i) {
vs_out.attrib[i] = vs_in_attrib[i];
}
}
)";
std::vector<std::string> names;
for (int i = 0; i < 16; ++i) names.push_back("StageData.attrib[" + std::to_string(i) + "]");
std::vector<const char*> varyings;
for (const auto& n : names) varyings.push_back(n.c_str());
std::string log;
const GLuint program = BuildCaptureProgram(vs, varyings, &log);
ASSERT_NE(program, 0u) << "capture program did not link: " << log;
for (GLuint i = 0; i < 16; ++i) glVertexAttrib4f(i, 0.0f, 0.0f, 0.0f, 0.0f);
const float vertices[] = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f};
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexBuffer(0, vbo, 0, 12);
glVertexAttribFormat(1, 3, GL_FLOAT, GL_FALSE, 0);
glVertexAttribBinding(1, 0);
glEnableVertexAttribArray(1);
// 16 vec4s per point rather than the 4 the shared helper assumes.
constexpr std::size_t kFloatsPerPoint = 64;
std::vector<float> poison(kFloatsPerPoint * 2, kPoison);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, m_xfbo);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLsizeiptr>(poison.size() * sizeof(float)),
poison.data(), GL_DYNAMIC_DRAW);
glEnable(GL_RASTERIZER_DISCARD);
glUseProgram(program);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, 2);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> data(poison.size(), kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(data.size() * sizeof(float)), data.data());
glUseProgram(0);
// Element 0 of the array has no enabled array behind it, so it must deliver the
// current generic attribute value set above - including its w, which is 0 here and
// NOT the 1 an unwritten vec4 input defaults to.
EXPECT_FLOAT_EQ(data[0], 0.0f);
EXPECT_FLOAT_EQ(data[3], 0.0f);
// attribute 1 of point 0 and of point 1.
EXPECT_FLOAT_EQ(data[4], 1.0f);
EXPECT_FLOAT_EQ(data[5], 2.0f);
EXPECT_FLOAT_EQ(data[6], 3.0f);
EXPECT_FLOAT_EQ(data[7], 1.0f);
EXPECT_FLOAT_EQ(data[kFloatsPerPoint + 4], 4.0f);
EXPECT_FLOAT_EQ(data[kFloatsPerPoint + 5], 5.0f);
EXPECT_FLOAT_EQ(data[kFloatsPerPoint + 6], 6.0f);
EXPECT_FLOAT_EQ(data[kFloatsPerPoint + 7], 1.0f);
glDisableVertexAttribArray(1);
glDeleteBuffers(1, &vbo);
glDeleteProgram(program);
}
// Same program, but every one of the 16 elements is asked for a DIFFERENT current value.
//
// An input array occupies one location per element (GL 4.6 core 11.1.1), so `in vec4 a[16]`
// at location 0 is active on 0..15 - and the whole location span is what a backend reads to
// decide which attributes need their current value pushed. Reflection used to record the
// span of the ELEMENT type only, so a 16-element array claimed exactly one location: every
// element above the first silently read the (0,0,0,1) an unwritten input defaults to instead
// of the value glVertexAttrib4f had set. The test above could not see it, because the only
// element it reads a current value from is element 0 - the one location the array did claim.
TEST_F(VertexAttribBindingScenario, EveryInputArrayElementGetsItsOwnCurrentValue) {
if (!Ready()) GTEST_SKIP();
const std::string vs = R"(#version 430 core
layout(location = 0) in vec4 vs_in_attrib[16];
out StageData {
vec4 attrib[16];
} vs_out;
void main() {
for (int i = 0; i < vs_in_attrib.length(); ++i) {
vs_out.attrib[i] = vs_in_attrib[i];
}
}
)";
std::vector<std::string> names;
for (int i = 0; i < 16; ++i) names.push_back("StageData.attrib[" + std::to_string(i) + "]");
std::vector<const char*> varyings;
for (const auto& n : names) varyings.push_back(n.c_str());
std::string log;
const GLuint program = BuildCaptureProgram(vs, varyings, &log);
ASSERT_NE(program, 0u) << "capture program did not link: " << log;
// Distinct in every component, and never (0,0,0,1): the value an element that was
// skipped would report has to be distinguishable from every value that was asked for.
for (GLuint i = 0; i < 16; ++i) {
const float base = static_cast<float>(i) + 1.0f;
glVertexAttrib4f(i, base, base + 100.0f, base + 200.0f, base + 300.0f);
}
constexpr std::size_t kFloatsPerPoint = 64;
std::vector<float> poison(kFloatsPerPoint, kPoison);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, m_xfbo);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLsizeiptr>(poison.size() * sizeof(float)),
poison.data(), GL_DYNAMIC_DRAW);
glEnable(GL_RASTERIZER_DISCARD);
glUseProgram(program);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, 1);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> data(poison.size(), kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(data.size() * sizeof(float)), data.data());
glUseProgram(0);
for (int element = 0; element < 16; ++element) {
const float base = static_cast<float>(element) + 1.0f;
EXPECT_FLOAT_EQ(data[element * 4 + 0], base) << "element " << element;
EXPECT_FLOAT_EQ(data[element * 4 + 1], base + 100.0f) << "element " << element;
EXPECT_FLOAT_EQ(data[element * 4 + 2], base + 200.0f) << "element " << element;
EXPECT_FLOAT_EQ(data[element * 4 + 3], base + 300.0f) << "element " << element;
}
for (GLuint i = 0; i < 16; ++i) glVertexAttrib4f(i, 0.0f, 0.0f, 0.0f, 0.0f);
glDeleteProgram(program);
}
} // namespace MGITest
@@ -0,0 +1,317 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/XfbCaptureBufferReuseScenario.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
//
// ONE capture buffer, SEVERAL capture spans - the shape most KHR-GL4x cases that
// use transform feedback as a readback channel are built on. They allocate the
// capture buffer once in a setup step and then run span after span through it,
// so a defect that only shows from the second span onwards fails the whole case
// while the first span (and every single-span scenario in this suite) stays
// green. The first thing checked here is therefore not the capture itself but
// that the bytes the capture wrote are the bytes the readback reads.
//
// Two ways of reusing the buffer, because they exercise different machinery:
//
// * respecified between spans (glBufferData while the buffer is still bound to
// the transform-feedback binding point), which is what a test helper that
// poisons its capture buffer before every span does;
// * allocated ONCE with immutable storage and never touched again, which is
// what KHR-GL45.direct_state_access.vertex_arrays_enable_disable_attributes
// does - glBufferStorage(4 bytes) in its setup, then two draws.
//
// The negative control (a fresh buffer object per span) is a separate case
// rather than a parameter: it is the configuration that already worked, so it
// has to keep working for the others to mean anything.
#include <cmath>
#include <cstdio>
#include <cstring>
#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 float kPoison = -1234.0f;
// One vec4 per point, one point per draw.
constexpr std::size_t kCaptureFloats = 4;
constexpr std::size_t kCaptureBytes = kCaptureFloats * sizeof(float);
GLuint CompileShader(GLenum type, const std::string& source, std::string* log) {
const GLuint shader = glCreateShader(type);
const char* text = source.c_str();
glShaderSource(shader, 1, &text, nullptr);
glCompileShader(shader);
GLint status = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteShader(shader);
return 0;
}
return shader;
}
// Vertex-only capture program: whatever the draw fetched at location 0 comes
// straight back out through the capture. Runs under GL_RASTERIZER_DISCARD, so
// there is no fragment stage.
GLuint BuildCaptureProgram(std::string* log) {
const std::string vertexSource = R"(#version 430 core
layout(location = 0) in vec4 vs_in_value;
out vec4 vs_out_value;
void main() {
vs_out_value = vs_in_value;
}
)";
const GLuint vertexShader = CompileShader(GL_VERTEX_SHADER, vertexSource, log);
if (vertexShader == 0) return 0;
const GLuint program = glCreateProgram();
glAttachShader(program, vertexShader);
const char* varying = "vs_out_value";
glTransformFeedbackVaryings(program, 1, &varying, GL_INTERLEAVED_ATTRIBS);
glLinkProgram(program);
glDeleteShader(vertexShader);
GLint status = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteProgram(program);
return 0;
}
return program;
}
class XfbCaptureBufferReuseScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string log;
m_program = BuildCaptureProgram(&log);
ASSERT_NE(m_program, 0u) << "capture program failed to build: " << log;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferData(GL_ARRAY_BUFFER, kCaptureBytes, nullptr, GL_DYNAMIC_DRAW);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, nullptr);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
}
void TearDown() override {
if (!Ready()) return;
glBindVertexArray(0);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_program != 0) glDeleteProgram(m_program);
glUseProgram(0);
ScenarioTest::TearDown();
}
// The vertex the next span will fetch and capture.
void SetVertex(float value) {
const float data[kCaptureFloats] = {value, value + 1.0f, value + 2.0f, value + 3.0f};
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferSubData(GL_ARRAY_BUFFER, 0, kCaptureBytes, data);
glBindBuffer(GL_ARRAY_BUFFER, 0);
}
// One capture span over the buffer currently bound to capture point 0.
void RunSpan() {
glEnable(GL_RASTERIZER_DISCARD);
glUseProgram(m_program);
glBindVertexArray(m_vao);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, 1);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
glUseProgram(0);
}
static ::testing::AssertionResult CapturedIs(const float* data, float value) {
for (std::size_t i = 0; i < kCaptureFloats; ++i) {
const float expected = value + static_cast<float>(i);
const float got = data[i];
// isfinite first: every ordered comparison against a NaN is false, so a
// pair of one-sided range tests REPORTS SUCCESS for uninitialised
// storage that happens to read as NaN - which is exactly the failure
// these scenarios exist to catch.
if (!std::isfinite(got) || std::fabs(got - expected) > 0.01f) {
return ::testing::AssertionFailure()
<< "component " << i << " is " << got << ", expected " << expected
<< (got == kPoison ? " (the capture never reached these bytes)" : "");
}
}
return ::testing::AssertionSuccess();
}
GLuint m_program = 0;
GLuint m_vao = 0;
GLuint m_vbo = 0;
};
// The negative control: one buffer object per span. This is the configuration
// every multi-span scenario in this suite works around the others with, so it
// has to hold or nothing below is interpretable.
TEST_F(XfbCaptureBufferReuseScenario, EverySpanIntoABufferObjectOfItsOwn) {
if (!Ready()) GTEST_SKIP();
for (int span = 0; span < 3; ++span) {
const float value = 10.0f * static_cast<float>(span + 1);
const std::vector<float> poison(kCaptureFloats, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, kCaptureBytes, poison.data(), GL_DYNAMIC_DRAW);
SetVertex(value);
RunSpan();
float readback[kCaptureFloats] = {kPoison, kPoison, kPoison, kPoison};
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, kCaptureBytes, readback);
EXPECT_TRUE(CapturedIs(readback, value)) << "span " << span;
glDeleteBuffers(1, &xfbBuffer);
}
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// The same three spans through ONE buffer object, respecified before each of
// them WHILE it is bound to capture point 0 - a helper poisoning its capture
// buffer, which is what makes "captured nothing" legible in the first place.
//
// A respecification is free to replace the storage underneath (that is what
// orphaning is), and on a buffer whose bytes the backend has already handed
// the frontend a pointer into, the replacement has to reach that pointer too.
// It did not: the capture wrote the new storage and the readback kept reading
// the old one, so every span after the first came back poison.
TEST_F(XfbCaptureBufferReuseScenario, EverySpanIntoOneRespecifiedBufferObject) {
if (!Ready()) GTEST_SKIP();
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
for (int span = 0; span < 3; ++span) {
const float value = 10.0f * static_cast<float>(span + 1);
const std::vector<float> poison(kCaptureFloats, kPoison);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, kCaptureBytes, poison.data(), GL_DYNAMIC_DRAW);
SetVertex(value);
RunSpan();
float readback[kCaptureFloats] = {kPoison, kPoison, kPoison, kPoison};
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, kCaptureBytes, readback);
EXPECT_TRUE(CapturedIs(readback, value)) << "span " << span;
}
glDeleteBuffers(1, &xfbBuffer);
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// A respecification that CHANGES the size, which is the case a re-pointing
// that only handled same-size storage would still get wrong - and, before the
// fix, the case that wrote the new (larger) contents through a mapping sized
// for the old ones.
TEST_F(XfbCaptureBufferReuseScenario, ARespecificationMayChangeTheCaptureBufferSize) {
if (!Ready()) GTEST_SKIP();
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
// Sized for one point, then for four, then back down to one.
const std::size_t pointCapacity[] = {1, 4, 1};
for (int span = 0; span < 3; ++span) {
const float value = 10.0f * static_cast<float>(span + 1);
const std::size_t floats = kCaptureFloats * pointCapacity[span];
const std::vector<float> poison(floats, kPoison);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLsizeiptr>(floats * sizeof(float)),
poison.data(), GL_DYNAMIC_DRAW);
SetVertex(value);
RunSpan();
std::vector<float> readback(floats, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(floats * sizeof(float)), readback.data());
EXPECT_TRUE(CapturedIs(readback.data(), value)) << "span " << span;
// The bytes past the one point the draw produced must still be the
// poison the respecification put there, not whatever the previous
// (differently sized) storage held.
for (std::size_t i = kCaptureFloats; i < floats; ++i) {
EXPECT_FLOAT_EQ(readback[i], kPoison) << "span " << span << " float " << i;
}
}
glDeleteBuffers(1, &xfbBuffer);
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// The KHR-GL45.direct_state_access.vertex_arrays_enable_disable_attributes
// shape: the capture buffer gets IMMUTABLE storage once, in a setup step, and
// is never respecified - two spans simply run through it, each read back with
// glMapBuffer. Nothing here may depend on a respecification to reset the
// capture: glBeginTransformFeedback does that on its own.
TEST_F(XfbCaptureBufferReuseScenario, EverySpanIntoOneImmutableStorageBuffer) {
if (!Ready()) GTEST_SKIP();
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, xfbBuffer);
// Poisoned at creation - the storage is immutable, so this is the only chance to
// put a recognisable value there, and without it a span that captured nothing
// would be indistinguishable from one that captured the right thing whenever the
// untouched bytes happened to read back as the expected number.
const std::vector<float> poison(kCaptureFloats, kPoison);
glBufferStorage(GL_TRANSFORM_FEEDBACK_BUFFER, kCaptureBytes, poison.data(), GL_MAP_READ_BIT);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "glBufferStorage on the capture buffer";
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
for (int span = 0; span < 3; ++span) {
const float value = 10.0f * static_cast<float>(span + 1);
SetVertex(value);
RunSpan();
const void* mapped = glMapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, GL_READ_ONLY);
ASSERT_NE(mapped, nullptr) << "span " << span << ": glMapBuffer returned null";
float readback[kCaptureFloats] = {kPoison, kPoison, kPoison, kPoison};
std::memcpy(readback, mapped, kCaptureBytes);
glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER);
EXPECT_TRUE(CapturedIs(readback, value)) << "span " << span;
}
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, 0);
glDeleteBuffers(1, &xfbBuffer);
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
} // namespace
} // namespace MGITest
@@ -75,10 +75,42 @@ namespace MobileGL::MG_State::GLState {
NotifySubData(offset, size);
}
void BufferObject::Respecify(SizeT size, const void* data) {
ReleaseMemory();
// A (re)definition of the store is about to write `size` bytes through Bytes().
// Sizing the shadow is all that takes for a shadow-backed buffer. A buffer whose
// bytes were adopted into backend GPU memory has to give the adoption back first,
// because the mapping it holds describes exactly the OLD store: writing the new
// contents through it runs past its end the moment the store grows, and a backend
// that replaces the storage for the new store - which is what an orphaning
// respecification asks for - would leave that mapping, and therefore every later
// read of this buffer, addressing storage nothing writes to any more. That was the
// transform feedback capture that wrote one buffer while the readback read another.
//
// Given back rather than renewed here, deliberately. Renewing in place would mean
// memcpying the new contents into storage that submitted-but-unretired draws may
// still be reading, which is precisely what the orphaning idiom exists to avoid;
// avoiding THAT would mean either stalling on a fence in the middle of a frame or
// teaching the persistent-map op to orphan, and the op must never orphan for the
// other kind of caller (an application-held GL_MAP_PERSISTENT_BIT mapping, whose
// pointer has to stay valid for the buffer's whole life). Handing the store back to
// the CPU shadow needs none of that: the backend's ordinary respecification path
// then does the busy-tracking and the conditional orphan it has always done, and the
// next binding that wants GPU residency takes a fresh mapping of the new store.
void BufferObject::RedefineStorage(SizeT size) {
if (m_resource.IsGpuResident()) {
m_resource.ReleasePersistentMap();
// Whatever a shader or a capture wrote is in the store being replaced, so
// there is nothing left to reconcile - and leaving the flag set would make
// the next read of this buffer wait for GPU work on behalf of bytes the
// application has just thrown away.
m_gpuWritePending = false;
}
m_size = size;
m_resource.ResizeShadow(size);
}
void BufferObject::Respecify(SizeT size, const void* data) {
ReleaseMemory();
RedefineStorage(size);
if (data && size > 0) {
Memcpy(m_resource.Bytes(), data, size);
}
@@ -96,8 +128,7 @@ namespace MobileGL::MG_State::GLState {
void BufferObject::AllocateImmutableStorage(SizeT size, const void* data, GLbitfield storageFlags) {
ReleaseMemory();
m_size = size;
m_resource.ResizeShadow(size);
RedefineStorage(size);
if (data) {
Memcpy(m_resource.Bytes(), data, size);
} else if (size > 0) {
@@ -205,6 +205,9 @@ namespace MobileGL {
void SetBackendResource(SharedPtr<BackendBufferResource> resource);
private:
// Sizes the store for a (re)definition, renewing an adopted GPU-resident
// mapping across it. See the definition for why the renewal is not optional.
void RedefineStorage(SizeT size);
void NotifyRespecify();
void NotifySubData(SizeT offset, SizeT size);
void NotifyFlushMappedRange(Range1D range, Flags<BufferMappingAccessBit> appAccess);
@@ -67,9 +67,13 @@ namespace MobileGL::MG_State::GLState {
}
}
}
// Key-based erase skips FastSTL's successor-iterator scan, which is
// pure overhead here and dominates delete-heavy frames.
m_bufferObjects.erase(index);
// Erase through the iterator already in hand: erase(key) would repeat the
// find() above, and the successor scan that once made key-based
// erase the cheaper of the two no longer happens here - erase(iterator)
// hands back an unconverted proxy, and the scan is what converting it
// would cost. The unbind loops above touch only the binding arrays, so
// `it` is still live.
m_bufferObjects.erase(it);
}
m_indexGenerator.Delete(index);
}
@@ -70,6 +70,15 @@ namespace MobileGL::MG_State::GLState {
m_shadow->shrink_to_fit();
}
// Give the adoption back: the bytes resolve against the shadow again (which
// the caller must (re)size, it was released on adoption). Used when the store
// itself is redefined - the mapping describes exactly the store that is going
// away, so it may neither be written through nor kept. It is NOT a general
// "unmap": a persistent map the application holds outlives every unmap by
// definition, and the calls that could redefine such a buffer's store are
// errors the frontend refuses before reaching here.
void ReleasePersistentMap() { m_gpuMapped = nullptr; }
// Backend GPU resource, owned here in both modes.
const SharedPtr<BackendBufferResource>& Backend() const { return m_backend; }
void SetBackend(SharedPtr<BackendBufferResource> backend) { m_backend = std::move(backend); }
+292 -21
View File
@@ -196,7 +196,7 @@ namespace MobileGL::MG_State {
// (which expands to nothing outside debug builds).
void GLContext::SetCurrentVertexAttributeFloat(Uint index, const Array<Float, 4>& value) {
if (index >= m_currentVertexAttributes.size()) {
MGLOG_E("SetCurrentVertexAttributeFloat: index %u is out of range", index);
MGLOG_E_ONCE("SetCurrentVertexAttributeFloat: index %u is out of range", index);
return;
}
@@ -210,7 +210,7 @@ namespace MobileGL::MG_State {
void GLContext::SetCurrentVertexAttributeInt(Uint index, const Array<Int32, 4>& value) {
if (index >= m_currentVertexAttributes.size()) {
MGLOG_E("SetCurrentVertexAttributeInt: index %u is out of range", index);
MGLOG_E_ONCE("SetCurrentVertexAttributeInt: index %u is out of range", index);
return;
}
@@ -224,7 +224,7 @@ namespace MobileGL::MG_State {
void GLContext::SetCurrentVertexAttributeUint(Uint index, const Array<Uint32, 4>& value) {
if (index >= m_currentVertexAttributes.size()) {
MGLOG_E("SetCurrentVertexAttributeUint: index %u is out of range", index);
MGLOG_E_ONCE("SetCurrentVertexAttributeUint: index %u is out of range", index);
return;
}
@@ -239,7 +239,7 @@ namespace MobileGL::MG_State {
const CurrentVertexAttributeValue& GLContext::GetCurrentVertexAttribute(Uint index) const {
static const CurrentVertexAttributeValue defaultValue{};
if (index >= m_currentVertexAttributes.size()) {
MGLOG_E("GetCurrentVertexAttribute: index %u is out of range", index);
MGLOG_E_ONCE("GetCurrentVertexAttribute: index %u is out of range", index);
return defaultValue;
}
return m_currentVertexAttributes[index];
@@ -369,6 +369,214 @@ namespace MobileGL::MG_State {
return m_programState.GetCurrentProgram();
}
// Copies every default-block uniform value `source` holds into the same-named uniform of
// `destination`, by name and by location.
//
// The composite a pipeline draws through is a DIFFERENT program object from the stage
// programs the application writes uniforms to - glUniform* addresses the pipeline's
// active program and glProgramUniform* addresses a named one, neither of which is the
// composite - so without this a pipeline draw reads the composite's zero defaults and
// paints them. Values are COPIED rather than aliased: the two programs' global UBOs are
// laid out independently (the composite merges several stages' uniforms into one block,
// so the same uniform sits at a different offset in each), and a copy also means the
// composite can outlive a stage program without ever pointing into freed storage.
//
// Location-by-location so that arrays are carried across whole, and via the padded
// storage span so a mat3's std140 column padding travels with it.
//
// WHICH uniforms: exactly the ones `source` has been WRITTEN to since its last link
// (ProgramObject's per-location dirty set), and that restriction is a correctness fix
// as much as it is the reason this is cheap.
//
// SSO gives each stage program its own storage for a uniform, so two stage programs
// may declare the same name and hold different values - but the composite is one link
// with one slot for it, and RefreshCompositeUniforms walks the stages in order. When
// every active uniform was copied unconditionally, the LAST graphics stage that merely
// DECLARED a name won, even while holding nothing but GL's zero default, and an
// earlier stage's written value was overwritten with zeros on the way to the draw. The
// shared-header idiom - the same `uniform mat4 u_mvp` declared in the VS and the FS,
// written through glActiveShaderProgram(pipe, vs) - rendered nothing because of it.
// Copying only written uniforms makes that case, which is the overwhelmingly common
// one, simply correct: an unwritten declaration has nothing to say and says nothing.
//
// WHEN BOTH STAGES WROTE THE SAME NAME there is no single right answer available -
// GL_ARB_separate_shader_objects gives the two values separate storage and the
// composite has one slot - so the rule is LAST WRITTEN-TO GRAPHICS STAGE WINS, in
// ShaderStage enum order (Vertex .. Fragment), decided by the stage walk in
// RefreshCompositeUniforms. It is deterministic, and it is strictly better than what
// it replaces: only a stage that actually holds an application-written value can now
// take the slot. True last-WRITE-wins would need a global write ordering the dirty set
// does not carry.
//
// An unwritten uniform is not left to chance either: the composite links the same
// shader objects the stages do, so its own link seeds it with the same declared
// initializers (ApplyUniformInitialValues), which is precisely the value GL says an
// unwritten uniform reads.
static void MirrorUniformValues(ProgramObject& source, ProgramObject& destination) {
if (!source.GetLinkStatus() || !destination.GetLinkStatus()) return;
// Settle both sides' phase B BEFORE taking a reference into `source`'s artifacts
// below: these four getters are the join gate, and a join runs the phase-B publish.
// Nothing that publish does marks a uniform today, but the loop holds a reference to
// a Vector that a mark would push_back to, and "the replay does not mark" is not a
// property a future reader of this line can see.
const char* sourceUbo = static_cast<const char*>(source.GetUBOData());
char* destinationUbo = static_cast<char*>(destination.MapUBO());
const SizeT sourceUboSize = source.GetUBOSize();
const SizeT destinationUboSize = destination.GetUBOSize();
// O(uniforms written), not O(uniforms declared). The two name lookups below are
// string hashes into both programs' location maps, and doing them for every active
// uniform of every stage on every gate trip was hundreds of them per draw on a
// large program. A stage nothing has been written to costs one empty() test.
//
// FALLBACK, and it is load-bearing rather than defensive: a program only records
// its writes once something asks it to be separable (ProgramObject::SetSeparable
// arms the latch), but glUseProgramStages here validates only LINK_STATUS - it does
// not reject a program that was never linked as separable, which GL 4.6 core 7.4
// says it should. So a plain glCreateProgram/glLinkProgram program CAN be installed
// as a stage, and it will have recorded nothing at all. Mirroring "only what was
// written" would then mirror nothing and paint the composite's defaults - a fresh
// regression on a shape that worked. For such a program the old full walk is exactly
// right: it has no dirty set to be more precise with.
const Bool byWriteSet = source.TracksUniformWrites();
const Vector<Uint>& writtenIndices = source.GetWrittenUniformIndices();
const Uint uniformCount = source.GetUniformCount();
const SizeT indexCount = byWriteSet ? writtenIndices.size() : static_cast<SizeT>(uniformCount);
if (indexCount == 0) return;
for (SizeT slot = 0; slot < indexCount; ++slot) {
const Uint index = byWriteSet ? writtenIndices[slot] : static_cast<Uint>(slot);
const String& name = source.GetActiveUniformName(index);
if (name.empty()) continue;
const Int sourceBase = source.GetUniformLocation(name);
const Int destinationBase = destination.GetUniformLocation(name);
// A uniform the composite's own link dropped (or renamed) is simply not
// mirrored; the draw cannot read what does not exist.
if (sourceBase < 0 || destinationBase < 0) continue;
const GLint arraySize = source.GetActiveUniformArraySize(index);
const Int elements = arraySize > 0 ? static_cast<Int>(arraySize) : 1;
for (Int element = 0; element < elements; ++element) {
const Int sourceLocation = sourceBase + element;
const Int destinationLocation = destinationBase + element;
if (!source.IsValidUniformLocation(sourceLocation) ||
!destination.IsValidUniformLocation(destinationLocation)) {
break;
}
// Per ELEMENT, not per array: `arr[3] = x` must carry element 3 and leave
// the elements another stage owns alone. `continue`, not `break` - the
// written elements of an array need not be a prefix of it.
if (byWriteSet && !source.IsUniformWrittenAtLocation(static_cast<Uint>(sourceLocation))) {
continue;
}
// Stop at the end of EITHER side's array rather than walking onto the
// neighbouring uniform of whichever program has the shorter one.
if (!source.UniformLocationsAliasSameUniform(sourceBase, sourceLocation) ||
!destination.UniformLocationsAliasSameUniform(destinationBase, destinationLocation)) {
break;
}
const Bool sourceOpaque = source.IsUniformOpaqueAtLocation(sourceLocation);
if (sourceOpaque != destination.IsUniformOpaqueAtLocation(destinationLocation)) break;
if (sourceOpaque) {
// A sampler/image unit is phase-A state, not UBO bytes. The setter
// itself is a no-op when the value already matches, so this does not
// churn the composite's backend state version.
destination.SetUniformSamplerOrImageUnitIndex(
destinationLocation, source.GetUniformSamplerOrImageUnitIndex(sourceLocation));
continue;
}
const SizeT span = source.GetUniformStorageSpanInBytes(sourceLocation);
if (span == 0 || span != destination.GetUniformStorageSpanInBytes(destinationLocation)) continue;
const Uint sourceOffset = source.GetUniformOffset(sourceLocation);
const Uint destinationOffset = destination.GetUniformOffset(destinationLocation);
// Either side can legitimately lack backing storage: the optimizer deletes a
// uniform nothing reads, and a program whose SPIR-V phase settled cancelled
// has no shadow at all. Both report kInvalidUniformOffset / a null shadow.
if (sourceUbo == nullptr || destinationUbo == nullptr ||
sourceOffset == ProgramObject::kInvalidUniformOffset ||
destinationOffset == ProgramObject::kInvalidUniformOffset ||
sourceOffset + span > sourceUboSize || destinationOffset + span > destinationUboSize) {
continue;
}
if (std::memcmp(destinationUbo + destinationOffset, sourceUbo + sourceOffset, span) == 0) {
continue;
}
Memcpy(destinationUbo + destinationOffset, sourceUbo + sourceOffset, span);
destination.MarkUBOContentDirty();
}
}
}
// The other half of "the composite is a different program object": interface BLOCK
// bindings. glUniformBlockBinding and glShaderStorageBlockBinding place a block on a
// binding point, and they do it per program - so a pipeline whose blocks were placed
// that way drew against the composite's own bindings, which come from the shader
// declarations alone. A block declared without any layout(binding) therefore sat on
// whatever the declaration implied while the application's buffers sat somewhere else,
// and nothing anywhere raised an error: the draw simply read or wrote the wrong place.
//
// Both sides seed these from the same shader declarations at link, so mirroring a block
// the application never rebound writes back the value the destination already holds and
// the setters' equality checks make it free.
static void MirrorBlockBindings(const ProgramObject& source, ProgramObject& destination) {
// Storage blocks are keyed by GL name on both sides - the one coordinate the
// frontend, SPIR-V and driver index spaces all agree on - so this is a direct
// replay. Empty for the overwhelming majority of programs.
for (const auto& [blockName, binding] : source.GetShaderStorageBlockBindingOverrides()) {
if (binding < 0) continue;
destination.SetShaderStorageBlockBinding(blockName, static_cast<Uint>(binding));
}
// Uniform blocks are keyed by index, and the two programs number them
// independently, so they are matched by name.
const Int sourceBlockCount = source.GetActiveUniformBlocksCount();
for (Int sourceIndex = 0; sourceIndex < sourceBlockCount; ++sourceIndex) {
const Int binding = static_cast<Int>(source.GetUniformBlockBinding(static_cast<Uint>(sourceIndex)));
// -1 is "no declared binding and never rebound" - there is nothing to carry,
// and forwarding it would land as binding 0xFFFFFFFF.
if (binding < 0) continue;
const String& blockName = source.GetUniformBlockName(static_cast<Uint>(sourceIndex));
if (blockName.empty()) continue;
const Uint destinationIndex = destination.GetUniformBlockIndex(blockName.c_str());
if (destinationIndex == 0xFFFFFFFFu) continue; // GL_INVALID_INDEX
destination.SetUniformBlockBinding(destinationIndex, static_cast<Uint>(binding));
}
}
// Brings the pipeline's composite up to date with the per-program state its stage
// programs hold and it does not: uniform values, and interface block bindings. Runs on
// every draw through a pipeline, so the common case is the version compare below and
// nothing else.
static void RefreshCompositeUniforms(ProgramPipelineObject& pipeline, const SharedPtr<ProgramObject>& composite) {
if (!composite) return;
const auto versions = pipeline.ComputeUniformMirrorVersions();
if (versions == pipeline.GetMirroredUniformVersions()) return;
// A program bound to two stages appears twice; mirroring it twice would be
// idempotent but is still work, and the second pass would have nothing to do.
Array<ProgramObject*, ProgramPipelineObject::kGraphicsStageCount> mirrored{};
SizeT mirroredCount = 0;
for (SizeT stage = 0; stage < ProgramPipelineObject::kGraphicsStageCount; ++stage) {
const auto& stageProgram = pipeline.GetStageProgram(static_cast<ShaderStage>(stage));
if (!stageProgram) continue;
Bool alreadyMirrored = false;
for (SizeT i = 0; i < mirroredCount; ++i) {
if (mirrored[i] == stageProgram.get()) {
alreadyMirrored = true;
break;
}
}
if (alreadyMirrored) continue;
mirrored[mirroredCount++] = stageProgram.get();
MirrorUniformValues(*stageProgram, *composite);
MirrorBlockBindings(*stageProgram, *composite);
}
pipeline.SetMirroredUniformVersions(versions);
}
const SharedPtr<ProgramObject>& GLContext::GetProgramForDraw() {
static const SharedPtr<ProgramObject> nullProgram = nullptr;
const auto& currentProgram = m_programState.GetCurrentProgram();
@@ -395,20 +603,23 @@ namespace MobileGL::MG_State {
if (!pipeline) return nullProgram;
// P1 join site J1. ComputeDrawProgramSignature() keys the composite cache on each
// stage program's lifetimeId and backendStateVersion - NON-artifact fields, so
// they do not pass through ProgramObject's join gate and a pending link would
// stay pending right through the signature. Since the version is bumped both at
// enqueue and at publish, the signature computed inside a pending window is one
// that will never be produced again: every draw would miss the cache and rebuild
// (and relink) the composite. Join first, so the signature describes settled
// programs. In steady state this is a null check per stage.
for (SizeT stage = 0; stage < static_cast<SizeT>(ShaderStage::ShaderStageCount); ++stage) {
// stage program's lifetimeId and linkVersion - NON-artifact fields, so they do not
// pass through ProgramObject's join gate and a pending link would stay pending
// right through the signature. Since the version is bumped both at enqueue and at
// publish, the signature computed inside a pending window is one that will never
// be produced again: every draw would miss the cache and rebuild (and relink) the
// composite. Join first, so the signature describes settled programs. In steady
// state this is a null check per stage.
for (SizeT stage = 0; stage < ProgramPipelineObject::kGraphicsStageCount; ++stage) {
const auto& stageProgram = pipeline->GetStageProgram(static_cast<ShaderStage>(stage));
if (stageProgram) stageProgram->JoinLinkAndSpirv();
}
const auto signature = pipeline->ComputeDrawProgramSignature();
if (const auto& cached = pipeline->GetCachedDrawProgram(signature)) return cached;
if (const auto& cached = pipeline->GetCachedDrawProgram(signature)) {
RefreshCompositeUniforms(*pipeline, cached);
return cached;
}
// Everything downstream of here - the backends, the uniform plumbing, the draw
// validation - is written against a single linked program, so the pipeline is
@@ -420,8 +631,14 @@ namespace MobileGL::MG_State {
// could otherwise be handed. Backend registries key on the object, not the name.
auto composite = MakeShared<ProgramObject>(0u);
// GRAPHICS stages only. A pipeline may carry a compute stage alongside them (GL
// 4.6 core 7.4 forbids linking compute WITH another stage into one program, not
// attaching a compute program to a pipeline that also has graphics ones), and that
// stage belongs to glDispatchCompute, not to this draw. Compositing it in produced
// a graphics program carrying a compute module, which Adreno 830 does not reject
// from vkCreateGraphicsPipelines - it SIGSEGVs inside it.
Bool anyStage = false;
for (SizeT stage = 0; stage < static_cast<SizeT>(ShaderStage::ShaderStageCount); ++stage) {
for (SizeT stage = 0; stage < ProgramPipelineObject::kGraphicsStageCount; ++stage) {
const auto& stageProgram = pipeline->GetStageProgram(static_cast<ShaderStage>(stage));
if (!stageProgram) continue;
for (const auto& shader : stageProgram->GetAttachedShaders()) {
@@ -440,7 +657,32 @@ namespace MobileGL::MG_State {
// for the same reason: the backend is about to read its SPIR-V.
composite->JoinLinkAndSpirv();
pipeline->SetCachedDrawProgram(signature, Move(composite));
return pipeline->GetCachedDrawProgram(signature);
const auto& cached = pipeline->GetCachedDrawProgram(signature);
RefreshCompositeUniforms(*pipeline, cached);
return cached;
}
const SharedPtr<ProgramObject>& GLContext::GetProgramForDispatch() {
static const SharedPtr<ProgramObject> nullProgram = nullptr;
const auto& currentProgram = m_programState.GetCurrentProgram();
if (currentProgram) {
// Same join contract as GetProgramForDraw's glUseProgram half - see the note
// there. A dispatch reads the same non-artifact versions a draw does.
currentProgram->JoinLinkAndSpirv();
return currentProgram;
}
if (m_boundProgramPipeline == 0) return nullProgram;
const auto& pipeline = GetBoundProgramPipeline();
if (!pipeline) return nullProgram;
// No compositing and no cache: GL 4.6 core 7.4 makes a compute program exclusive of
// every other stage, so the pipeline's compute stage program IS the program to
// dispatch, uniforms and all. That also means glUniform* through the active program
// lands on the very object the dispatch reads - the composite's uniform refresh has
// no counterpart to do here.
const auto& computeProgram = pipeline->GetStageProgram(ShaderStage::Compute);
if (!computeProgram) return nullProgram;
computeProgram->JoinLinkAndSpirv();
return computeProgram;
}
const SharedPtr<ProgramObject>& GLContext::GetProgramForUniform() {
@@ -919,31 +1161,60 @@ namespace MobileGL::MG_State {
// Program pipeline
void GLContext::GenProgramPipelineNames(Uint number, Vector<Uint>& pipelines) {
pipelines.resize(number);
// Names only: glIsProgramPipeline must answer GL_FALSE until one is bound or created.
// Names only. The OBJECT appears as soon as a command needs somewhere to put state
// (see MaterializeProgramPipelineObject), but glIsProgramPipeline still answers
// GL_FALSE until the name is bound or created - see IsProgramPipelineObject.
m_programPipelineNames.Generate(number, pipelines.data());
}
void GLContext::CreateProgramPipelineObject(Uint index) {
m_programPipelines[index] = MakeShared<ProgramPipelineObject>(index);
const auto object = MakeShared<ProgramPipelineObject>(index);
// glCreateProgramPipelines makes the object outright, so it answers
// glIsProgramPipeline immediately - unlike a name that only got here through
// GenProgramPipelines plus a command that materialized it.
object->MarkEverBound();
m_programPipelines[index] = object;
}
Bool GLContext::ValidateProgramPipelineName(Uint index) const {
return index == 0 || m_programPipelineNames.IsValid(index);
}
// glIsProgramPipeline. Materialization is NOT the test: the object now appears as soon
// as any command takes state from a reserved name, and two of those commands are the
// pure queries glGetProgramPipelineiv / glGetProgramPipelineInfoLog - so keying this on
// map membership would let merely READING a gen'd name turn it into an object. GL 4.6
// core 7.4 gives the real rule: a GenProgramPipelines name acquires program pipeline
// state when it is first bound. Same shape as IsTransformFeedbackObject.
Bool GLContext::IsProgramPipelineObject(Uint index) const {
if (index == 0 || !m_programPipelineNames.IsValid(index)) return false;
return m_programPipelines.find(index) != m_programPipelines.end();
const auto it = m_programPipelines.find(index);
return it != m_programPipelines.end() && it->second && it->second->GetEverBound();
}
void GLContext::BindProgramPipelineObject(Uint index) {
if (index != 0 && m_programPipelines.find(index) == m_programPipelines.end()) {
// First bind is what turns a reserved name into an object.
m_programPipelines[index] = MakeShared<ProgramPipelineObject>(index);
if (index != 0) {
if (const auto& object = MaterializeProgramPipelineObject(index)) {
object->MarkEverBound();
}
}
m_boundProgramPipeline = index;
}
// Binding is not the only thing that turns a reserved name into an object. GL 4.6 core
// 7.4 asks of UseProgramStages, ActiveShaderProgram and ValidateProgramPipeline only that
// the name came from GenProgramPipelines and has not been deleted - so a name that was
// reserved and never bound must take state from them, not be rejected. glIsProgramPipeline
// is the one place the distinction survives (it answers FALSE until the name is used),
// which is why IsProgramPipelineObject stays as it is.
const SharedPtr<ProgramPipelineObject>& GLContext::MaterializeProgramPipelineObject(Uint index) {
static const SharedPtr<ProgramPipelineObject> kNone;
if (index == 0 || !m_programPipelineNames.IsValid(index)) return kNone;
const auto it = m_programPipelines.find(index);
if (it != m_programPipelines.end()) return it->second;
return m_programPipelines[index] = MakeShared<ProgramPipelineObject>(index);
}
void GLContext::MarkProgramPipelineForDeletion(Uint index) {
if (index == 0 || !m_programPipelineNames.IsValid(index)) return;
if (index == m_boundProgramPipeline) {
+17 -5
View File
@@ -163,21 +163,31 @@ namespace MobileGL {
}
void UseProgram(Uint program);
const SharedPtr<ProgramObject>& GetCurrentProgram();
// What a draw or dispatch actually executes: the program in use, or - when
// there is none - the bound pipeline's stages composited into one program.
// What a DRAW executes: the program in use, or - when there is none - the bound
// pipeline's GRAPHICS stages composited into one program. A pipeline's compute
// stage is never part of that composite; ask GetProgramForDispatch for it.
const SharedPtr<ProgramObject>& GetProgramForDraw();
// What a DISPATCH executes: the program in use, or - when there is none - the
// bound pipeline's compute stage program itself. GL's compute stage is a whole
// program on its own (GL 4.6 core 7.4: it may not be linked with any other
// stage), so there is nothing to composite and no composite to cache.
const SharedPtr<ProgramObject>& GetProgramForDispatch();
// What glUniform* addresses: the program in use, or the bound pipeline's
// active program (GL 4.6 core 7.6.1).
const SharedPtr<ProgramObject>& GetProgramForUniform();
// Program pipeline (GL_ARB_separate_shader_objects, GL 4.6 core 7.4). Like queries
// and transform feedbacks, glGenProgramPipelines only RESERVES a name - the object
// appears on first bind - while glCreateProgramPipelines makes it immediately.
// appears on first USE (any of bind, UseProgramStages, ActiveShaderProgram,
// ValidateProgramPipeline) - while glCreateProgramPipelines makes it immediately.
void GenProgramPipelineNames(Uint number, Vector<Uint>& pipelines);
void CreateProgramPipelineObject(Uint index);
Bool ValidateProgramPipelineName(Uint index) const;
Bool IsProgramPipelineObject(Uint index) const;
void BindProgramPipelineObject(Uint index);
// Materializes a reserved name; returns null for 0 or a name that is not a live
// GenProgramPipelines name.
const SharedPtr<ProgramPipelineObject>& MaterializeProgramPipelineObject(Uint index);
void MarkProgramPipelineForDeletion(Uint index);
const SharedPtr<ProgramPipelineObject>& GetProgramPipelineObject(Uint index) const;
Uint GetBoundProgramPipelineName() const { return m_boundProgramPipeline; }
@@ -447,8 +457,10 @@ namespace MobileGL {
UnorderedMap<Uint, TransformFeedbackObjectState> m_transformFeedbackObjects;
IndexGenerator<Uint> m_transformFeedbackNames;
Uint m_boundTransformFeedback = 0;
// Map membership IS object existence here: a pipeline has no stateful default
// object 0, so no everBound flag is needed.
// Map membership is object EXISTENCE, which is not the same as the answer
// glIsProgramPipeline gives: any command that needs somewhere to put state
// materializes a reserved name, so the object can exist well before it is
// bound. ProgramPipelineObject::everBound carries the Is* answer.
UnorderedMap<Uint, SharedPtr<ProgramPipelineObject>> m_programPipelines;
IndexGenerator<Uint> m_programPipelineNames;
Uint m_boundProgramPipeline = 0;
@@ -14,10 +14,10 @@
namespace MobileGL::MG_State::GLState {
void ErrorState::RecordError(ErrorCode code, UniquePtr<ErrorInfo> info) {
if (code == ErrorCode::NoError) {
MGLOG_E("Recording Non-OpenGL error:\n%s", info->toString().c_str());
MGLOG_D("Recording Non-OpenGL error:\n%s", info->toString().c_str());
m_nonGLErrors.push_back(MakeUnique<Error>(code, Move(info)));
} else {
MGLOG_E("Recording OpenGL error (%s):\n%s",
MGLOG_D("Recording OpenGL error (%s):\n%s",
MG_Util::ConvertGLEnumToString(MG_Util::ConvertErrorCodeToGLEnum(code)).c_str(),
info->toString().c_str());
// GL error semantics are sticky flags, not a queue (GL 3.3 core §2.5): with multiple
@@ -41,11 +41,35 @@ namespace {
return bracket == MobileGL::String::npos ? name : name.substr(0, bracket);
}
// Element index of an arrayed interface-block instance: "GOKU[3]" -> 3, "GOKU" -> 0.
// Reflection spells arrayed instances exactly this way (glslang expands the instance
// array into one TObjectReflection per element), and the subscript it writes is a plain
// decimal, so a strict-decimal parse is both sufficient and the same rule GL 4.6
// 7.3.1.1 puts on the name a program-resource query may use.
static MobileGL::Int BlockArrayElement(const MobileGL::String& name) {
if (name.empty() || name.back() != ']') return 0;
const MobileGL::SizeT bracket = name.rfind('[');
if (bracket == MobileGL::String::npos) return 0;
const MobileGL::SizeT first = bracket + 1;
const MobileGL::SizeT last = name.length() - 1;
if (first >= last) return 0;
if (name[first] == '0' && last - first > 1) return 0; // no leading zeros
MobileGL::Int element = 0;
for (MobileGL::SizeT i = first; i < last; ++i) {
if (name[i] < '0' || name[i] > '9') return 0;
element = element * 10 + static_cast<MobileGL::Int>(name[i] - '0');
if (element > 0x0FFFFFFF) return 0;
}
return element;
}
static bool IsBuiltInPipelineOutput(const glslang::TObjectReflection& output) {
const auto* type = output.getType();
return type && type->getQualifier().builtIn != glslang::EbvNone;
}
// Locations one ELEMENT of a vertex input occupies (GL 4.6 core 11.1.1): a matrix
// takes one per column, everything else this backend can feed takes one.
static int GetVertexInputLocationSpan(GLenum glType) {
switch (glType) {
case GL_FLOAT_MAT2:
@@ -65,6 +89,26 @@ namespace {
}
}
// How many elements an ARRAY vertex input has. glslang reflects such an input as ONE
// record spelled "name[0]" carrying the ELEMENT's glDefineType and the array length,
// so the type alone cannot say how many locations the declaration covers: GL 4.6 core
// 11.1.1 gives an array one location per element (times the element's own span), and
// `in vec4 a[16]` at location 0 therefore occupies 0..15, not 0. Missing that left
// every location above the base with no recorded name or type, which is what the
// backends read to decide whether an attribute is active at all.
static MobileGL::Int GetVertexInputArrayElements(const glslang::TObjectReflection& input) {
const glslang::TType* type = input.getType();
if (type == nullptr || !type->isArray()) return 1;
// An unsized input array has no span to compute; treat it as one element rather
// than guessing, so it can only ever under-claim locations.
if (!type->isSizedArray()) return 1;
return std::max(1, type->getCumulativeArraySize());
}
static MobileGL::Int GetVertexInputTotalLocationSpan(const glslang::TObjectReflection& input) {
return GetVertexInputLocationSpan(input.glDefineType) * GetVertexInputArrayElements(input);
}
static GLenum GetVertexInputLocationType(GLenum glType) {
switch (glType) {
case GL_FLOAT_MAT2:
@@ -97,39 +141,6 @@ namespace {
return std::max(1, uniform.size);
}
static bool ComputeShaderDeclaresLocalSize(const MobileGL::String& source) {
bool inLineComment = false;
bool inBlockComment = false;
for (MobileGL::SizeT i = 0; i < source.length(); ++i) {
if (inLineComment) {
inLineComment = source[i] != '\n';
continue;
}
if (inBlockComment) {
if (source[i] == '*' && i + 1 < source.length() && source[i + 1] == '/') {
inBlockComment = false;
++i;
}
continue;
}
if (source[i] == '/' && i + 1 < source.length()) {
if (source[i + 1] == '/') {
inLineComment = true;
++i;
continue;
}
if (source[i + 1] == '*') {
inBlockComment = true;
++i;
continue;
}
}
if (source.compare(i, 11, "local_size_") == 0) {
return true;
}
}
return false;
}
} // namespace
namespace MobileGL::MG_State::GLState {
@@ -301,6 +312,29 @@ namespace MobileGL::MG_State::GLState {
Vector<SharedPtr<glslang::TShader>> shaders;
if (!ConsumeShaders(shaders)) return;
// Harvest the declared default-block uniform initializers before the TShaders are
// handed to the linker. They come from the parse itself (glslang folds the constant
// and hands it over instead of dropping it), not from a lexical scan, so an
// expression like vec3(10, 20, 30) or int[](1, 2, 3) is already evaluated.
//
// Stage order decides a tie. GLSL requires a uniform declared in several stages to be
// declared identically, initializer included, so a conflict is a malformed program;
// taking the first stage's value keeps a link that other implementations accept from
// failing here, and both stages agree in every well-formed one.
for (const auto& shader : shaders) {
const glslang::TIntermediate* intermediate = shader ? shader->getIntermediate() : nullptr;
if (intermediate == nullptr) continue;
for (const auto& initializer : intermediate->getUniformInitializers()) {
const auto known = std::find_if(artifacts.uniformInitialValues.begin(),
artifacts.uniformInitialValues.end(),
[&initializer](const auto& existing) {
return existing.name == initializer.name;
});
if (known != artifacts.uniformInitialValues.end()) continue;
artifacts.uniformInitialValues.push_back(initializer);
}
}
// Merge the shaders' lexically extracted explicit uniform locations. The same
// uniform declared in several stages must agree on its location (config-A glslang
// enforced this at mapIO; the relaxed parse no longer sees the qualifiers).
@@ -347,6 +381,31 @@ namespace MobileGL::MG_State::GLState {
return;
}
// A compute program must have a fixed local group size, and GL states that as a
// property of the PROGRAM: "at least one" of its compute shaders declares it (GL 4.6
// core 7.13 / GLSL 4.30 4.4.1.4). MobileGL used to answer that question per SHADER,
// by scanning each source for the text "local_size_" - which rejected the perfectly
// legal shape KHR-GL42.compute_shader.build-monolithic submits, three compilation
// units of which only two carry the layout and the third holds nothing but a buffer
// block and a function. It also could not see a local size that arrived through a
// macro, and it happily accepted the substring inside an unrelated identifier.
//
// glslang already merged the units' modes at link (linkValidate.cpp mergeModes, which
// also diagnoses two units declaring CONTRADICTORY sizes), so the linked
// intermediate is the thing that knows - and asking it is both correct and free.
if (const glslang::TIntermediate* cs = artifacts.program->getIntermediate(EShLangCompute);
cs != nullptr && !cs->isLocalSizeSet()) {
artifacts.linkStatus = false;
// The gate this replaced ran before LinkProgram, so a program that failed it
// published no TProgram at all. Keep that invariant: everything downstream reads
// artifacts.program as "the linked program", and a rejected link should not leave
// one behind for a query surface to find.
artifacts.program.reset();
artifacts.infoLog = "Compute shader is missing a local_size layout declaration.";
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
return;
}
// GL_GEOMETRY_INPUT_TYPE. A draw's primitive type has to be compatible with it
// (GL 4.6 core 11.3.1), so it is resolved for every link, not only a capturing one.
artifacts.gsInputPrimitive = GL_NONE;
@@ -487,13 +546,6 @@ namespace MobileGL::MG_State::GLState {
in.externalIndex, i, artifacts.infoLog));
return false;
}
if (input.stage == ShaderStage::Compute &&
!ComputeShaderDeclaresLocalSize(input.source ? *input.source : String())) {
artifacts.infoLog = "Compute shader is missing a local_size layout declaration.";
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
return false;
}
String reparseLog;
outShaders[i] = input.compiled->ClaimParsedShader(reparseLog);
if (!outShaders[i]) {
@@ -533,8 +585,20 @@ namespace MobileGL::MG_State::GLState {
// - SharedStd140UBO: a DECLARED uniform block is active even when no member is
// ever read (reflected from the linker objects). PreprocessShaderSource coerces
// every block to std140, so this covers all of them.
// - IntermediateIO: GL_PROGRAM_INPUT is the input interface of the program's FIRST
// stage and GL_PROGRAM_OUTPUT the output interface of its LAST one. Without this
// glslang hardcodes those boundaries to vertex/fragment, so a separable program
// made of one non-vertex stage has an empty input interface and one made of a
// non-fragment stage an empty output interface
// (KHR-GL43.program_interface_query.separate-programs-*).
// - UnwrapIOBlocks: an inter-stage interface block enumerates as its MEMBERS -
// "Color.r", and "gl_Position" for an anonymous gl_PerVertex - not as the block
// instance. Only reachable through IntermediateIO: a vertex stage's inputs and a
// fragment stage's outputs can never be blocks, so this is inert for a program
// whose boundary stages are the hardcoded ones.
if (!artifacts.program->buildReflection(EShReflectionStrictArraySuffix | EShReflectionBasicArraySuffix |
EShReflectionAllBlockVariables | EShReflectionSharedStd140UBO)) {
EShReflectionAllBlockVariables | EShReflectionSharedStd140UBO |
EShReflectionIntermediateIO | EShReflectionUnwrapIOBlocks)) {
artifacts.linkStatus = false;
artifacts.infoLog = "Build reflection failed.";
DeferLog(std::format("ProgramObject {}: DoReflection - buildReflection() returned false",
@@ -822,14 +886,21 @@ namespace MobileGL::MG_State::GLState {
}
// ------------ attributes (vertex in) ---------------
Int inCount = artifacts.program->getNumPipeInputs();
// The pipe-input list is the input interface of the program's FIRST stage, which is only
// the vertex attribute set when the program actually HAS a vertex stage. A separable
// fragment/geometry/tessellation program reflects its own stage inputs here, and those are
// varyings - registering them as vertex attributes would hand glGetActiveAttrib and the
// attribute location table interstage varyings.
Int inCount = artifacts.program->getIntermediate(EShLangVertex) != nullptr
? artifacts.program->getNumPipeInputs()
: 0;
MGLOG_D("ProgramObject %u: Reflection - pipe input count (attributes) = %d", in.externalIndex, inCount);
Int maxLoc = -1;
for (int i = 0; i < inCount; ++i) {
Int loc = (Int)artifacts.program->getPipeInput(i).layoutLocation();
if (loc >= 0 && loc != glslang::TQualifier::layoutLocationEnd) {
const Int locationSpan = GetVertexInputLocationSpan(artifacts.program->getPipeInput(i).glDefineType);
const Int locationSpan = GetVertexInputTotalLocationSpan(artifacts.program->getPipeInput(i));
maxLoc = std::max(maxLoc, loc + locationSpan - 1);
}
MGLOG_D("ProgramObject %u: Reflection - pipe input[%d] name='%s' layoutLocation=%d glType=%u",
@@ -865,7 +936,7 @@ namespace MobileGL::MG_State::GLState {
(Int)ProgramObject::NormalizeBuiltinPipeInputName(inVar.name).length());
if (location >= 0 && location < (int)artifacts.attribs.size()) {
const Int locationSpan = GetVertexInputLocationSpan(inVar.glDefineType);
const Int locationSpan = GetVertexInputTotalLocationSpan(inVar);
const GLenum locationType = GetVertexInputLocationType(inVar.glDefineType);
for (Int locationOffset = 0; locationOffset < locationSpan; ++locationOffset) {
const Int expandedLocation = location + locationOffset;
@@ -898,8 +969,21 @@ namespace MobileGL::MG_State::GLState {
std::max(artifacts.uniformBlockNameMaxLength, (Int)ubo.name.length());
artifacts.uniformBlockIndexByName[ubo.name] = i;
// if there's binding defined in shader as layout(binding = ...),
// retrieve it here
artifacts.uniformBlockBinding[i] = ubo.getBinding();
// retrieve it here.
//
// An instance array takes CONSECUTIVE binding points: "layout(binding = 2)
// uniform GOKU {...} goku[14];" puts goku[0] on 2 and goku[13] on 15 (GL 4.6
// 7.6.2 / GLSL 4.20 4.4.5). glslang expands the array into one reflection
// record per element but hands every one of them the DECLARED binding, because
// they all share the block's TType - so the element offset has to be added
// here. Without it every element reported the base binding, and since both
// backends feed a block from GetUniformBlockBinding() at draw time
// (DirectGLES.cpp / UniformManager.cpp), all 14 elements also read the same
// buffer. This is the rule the storage-block path in ProgramInterface.cpp
// already applies, and whose comment there claims uniform blocks follow.
const Int declaredBinding = ubo.getBinding();
artifacts.uniformBlockBinding[i] =
declaredBinding < 0 ? declaredBinding : declaredBinding + BlockArrayElement(ubo.name);
MGLOG_D("ProgramObject %u: Reflection - UBO[%d] name='%s' size=%u binding=%d", in.externalIndex, i,
ubo.name.c_str(), ubo.size, ubo.getBinding());
}
@@ -908,6 +992,11 @@ namespace MobileGL::MG_State::GLState {
Bool ProgramLinkTask::ValidateFragmentOutputLocations() {
if (!artifacts.program) return false;
// The pipe-output list is the output interface of the program's LAST stage. Only a
// fragment stage's outputs are color numbers indexed against GL_MAX_DRAW_BUFFERS; a
// separable vertex/geometry/tessellation program's outputs are varyings, and holding
// them to the draw-buffer range fails the link of every such program.
if (artifacts.program->getIntermediate(EShLangFragment) == nullptr) return true;
UnorderedMap<Int, String> colorNumberOwners;
const Int outputCount = artifacts.program->getNumPipeOutputs();
@@ -90,8 +90,11 @@ namespace MobileGL::MG_State::GLState {
// A node that settled as Cancelled published nothing, so m_spirv stays empty with
// spirvStatus false: linked, queryable, not drawable. Nothing to repair.
// Before the version bump, and before any caller can read the shadow: the writes the
// application made while the layout did not exist yet.
// Order matters, and it is the GL order. The shadow arrives zero-filled; the shaders'
// declared uniform initializers are what it should actually start from, and only then
// do the application's own writes - the ones it made while the layout did not exist
// yet - land on top. Seeding after the replay would clobber them.
ApplyUniformInitialValues();
ReplayBufferedUniformWrites();
// The THIRD version bump of this link (enqueue, phase-A publish, phase-B publish), and
@@ -126,6 +129,101 @@ namespace MobileGL::MG_State::GLState {
return true;
}
// "uniform vec3 v = vec3(10, 20, 30);" - legal desktop GLSL since 1.20, and the value is
// what the uniform reads until glUniform* replaces it (and again after every relink).
// MobileGL parses with Vulkan-relaxed rules, which sweep default-block uniforms into
// MGL_GLOBAL_UBO; a block member cannot carry an initializer in SPIR-V, so glslang hands
// the folded constants over as a side-channel (TIntermediate::getUniformInitializers) and
// this is where they are honoured. Without it every such uniform silently read zero -
// which is what half of KHR-GL43.shader_storage_buffer_object was actually failing on.
//
// Writes go straight into the shadow rather than through glUniform*: this runs INSIDE the
// phase-B publish, so re-entering the join gate is not available, and the location space
// reflection assigns (one location per array element) is all that is needed.
void ProgramObject::ApplyUniformInitialValues() const {
// Through the phase-A gate, not off m_artifacts directly: phase B can be joined by a
// caller that has not read anything phase A publishes yet, and reading the raw field
// there would find the PREVIOUS link's block (or an empty one) and drop every
// initializer without a trace. Artifacts() is a no-op once phase A is in.
const auto& initializers = Artifacts().uniformInitialValues;
if (initializers.empty()) return;
if (m_spirv.globalUboScratch.empty() || m_spirv.uniformOffsets.empty()) {
// Phase B published no shadow (cancelled, or superseded by a relink). The program
// is not drawable; there is nowhere for these to land.
return;
}
Uint8* const scratch = m_spirv.globalUboScratch.data();
const SizeT uboSize = m_spirv.globalUboScratch.size();
for (const auto& init : initializers) {
// Scalars per array ELEMENT. A matrix element carries cols * rows of them, laid
// out column by column - which is also the order glslang folded them in.
const Int columns = init.matrixCols;
const Int rows = init.matrixRows;
const Int componentsPerElement = columns > 0 ? columns * rows : init.vectorSize;
const Int elements = init.arraySize;
if (componentsPerElement <= 0 || elements <= 0) continue;
// EbtDouble belongs with the floats now, not with the skipped types: every 64-bit
// float in a shader is narrowed to 32 bits before the module reaches a backend
// (ShaderTranspiler::DemoteFloat64Pass), so a `uniform double d = 1.5;` has exactly
// the 32-bit shadow encoding a `uniform float` does - and glslang already folded its
// value into floatValues, which is a vector<double> either way. Leaving it out meant
// the initializer was silently dropped and the uniform came up zero.
const Bool isFloat = init.basicType == glslang::EbtFloat ||
init.basicType == glslang::EbtFloat16 ||
init.basicType == glslang::EbtDouble;
const Bool isInt = init.basicType == glslang::EbtInt || init.basicType == glslang::EbtUint ||
init.basicType == glslang::EbtBool;
// Anything else (64-bit integers) has no 32-bit shadow encoding here, and a
// half-written uniform is worse than an untouched one.
if (!isFloat && !isInt) continue;
const SizeT provided = isFloat ? init.floatValues.size() : init.intValues.size();
if (provided < static_cast<SizeT>(componentsPerElement) * static_cast<SizeT>(elements)) continue;
const Int baseLocation = GetUniformLocation(init.name);
if (baseLocation < 0) continue; // optimized away, or not a default-block uniform
for (Int element = 0; element < elements; ++element) {
const Int location = baseLocation + element;
if (element > 0 && !UniformLocationsAliasSameUniform(baseLocation, location)) break;
if (!IsValidUniformLocation(location)) break;
const Uint offset = GetUniformOffset(static_cast<Uint>(location));
if (offset == kInvalidUniformOffset) continue;
// std140 pads every column of a float matrix out to a vec4, so the columns of
// a mat3 are 16 bytes apart even though each carries 12. The slot's own span
// states the stride the rest of the pipeline agreed on rather than guessing it.
const SizeT slotSpan = GetUniformStorageSpanInBytes(static_cast<Uint>(location));
const SizeT columnStride =
columns > 0 ? slotSpan / static_cast<SizeT>(columns) : slotSpan;
const Int componentsPerColumn = columns > 0 ? rows : componentsPerElement;
const Int columnCount = columns > 0 ? columns : 1;
for (Int column = 0; column < columnCount; ++column) {
const SizeT byteOffset = static_cast<SizeT>(offset) + static_cast<SizeT>(column) * columnStride;
const SizeT writeSize = static_cast<SizeT>(componentsPerColumn) * sizeof(Uint32);
if (byteOffset + writeSize > uboSize) break;
const SizeT firstComponent = static_cast<SizeT>(element) * componentsPerElement +
static_cast<SizeT>(column) * componentsPerColumn;
for (Int component = 0; component < componentsPerColumn; ++component) {
const SizeT source = firstComponent + static_cast<SizeT>(component);
Uint8* const destination = scratch + byteOffset + component * sizeof(Uint32);
if (isFloat) {
const Float value = static_cast<Float>(init.floatValues[source]);
std::memcpy(destination, &value, sizeof(value));
} else {
const Int32 value = static_cast<Int32>(init.intValues[source]);
std::memcpy(destination, &value, sizeof(value));
}
}
}
}
}
MarkUBOContentDirty();
}
void ProgramObject::ReplayBufferedUniformWrites() const {
if (m_pendingUniformWrites.empty()) {
m_pendingUniformBytes.clear();
@@ -157,7 +255,7 @@ namespace MobileGL::MG_State::GLState {
static_cast<SizeT>(offset) + write.byteOffsetInUniform + write.byteSize > uboSize) {
// Same verdict the live write path reaches for a uniform without backing
// storage: log and drop, rather than fault.
MGLOG_E("ProgramObject %u: buffered uniform write at location %u has no backing storage "
MGLOG_E_ONCE("ProgramObject %u: buffered uniform write at location %u has no backing storage "
"(offset=%u size=%u uboSize=%zu); dropping write",
m_externalIndex, write.location, offset, write.byteSize, uboSize);
continue;
@@ -234,7 +332,13 @@ namespace MobileGL::MG_State::GLState {
artifacts.glBlockIndexToTProgram.clear();
artifacts.tProgramBlockIndexToGl.clear();
artifacts.linkedExplicitUniformLocations.clear();
artifacts.uniformInitialValues.clear();
artifacts.uniformIndexInTProgram.clear();
// GL resets every uniform to its initial value at link, so nothing is "written since
// link" any more - and the locations these bits index no longer mean anything either.
artifacts.writtenUniformLocationBits.clear();
artifacts.writtenUniformIndexBits.clear();
artifacts.writtenUniformIndices.clear();
artifacts.uniformSamplerOrImageUnitIndex.clear();
artifacts.explicitOpaqueUniformBindings.clear();
artifacts.uniformBlockIndexByName.clear();
@@ -332,7 +436,7 @@ namespace MobileGL::MG_State::GLState {
defaultFS->Compile(); // TODO: use a global default FS object.
auto status = defaultFS->GetCompileStatus();
if (!status) {
MGLOG_E("ProgramObject %u: Failed to compile default fragment shader. InfoLog:\n%s", m_externalIndex,
MGLOG_E_ONCE("ProgramObject %u: Failed to compile default fragment shader. InfoLog:\n%s", m_externalIndex,
defaultFS->GetInfoLog().c_str());
return;
}
@@ -326,6 +326,120 @@ namespace MobileGL::MG_State::GLState {
: kInvalidUniformOffset;
}
Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); }
// Bytes a uniform actually occupies in the global UBO, which is not its GL type size,
// for two reasons. std140 pads each column of a matrix out to a vec4, so a mat3 spans
// 48 bytes even though only 36 of them carry components. And every 64-bit float in a
// shader is narrowed to 32 bits before the module reaches a backend
// (ShaderTranspiler::DemoteFloat64Pass) - the global UBO is laid out by reflecting that
// demoted module - so a `double` uniform occupies exactly what its float-typed twin
// would, half its GL type size, and a `dmat4` is padded like any other matrix. Anything
// reading or writing a whole uniform's storage - a bounds check, a copy between two
// programs' shadows - wants this rather than GetUniformSizesInBytes.
static SizeT UniformStorageSpanInBytes(const glslang::TType* type, SizeT tightSize) {
if (type != nullptr && type->isMatrix()) {
return static_cast<SizeT>(type->getMatrixCols()) * 4 * sizeof(Float);
}
if (type != nullptr && type->getBasicType() == glslang::EbtDouble) {
return tightSize / 2;
}
return tightSize;
}
SizeT GetUniformStorageSpanInBytes(Uint location) const {
return UniformStorageSpanInBytes(GetUniformTType(location), GetUniformSizesInBytes(location));
}
// ---- "written since link": the per-location dirty set the pipeline composite mirrors from ----
//
// A pipeline's stage programs each own their uniform storage, but the composite the draw
// goes through has ONE slot per name. Mirroring every active uniform of every stage
// therefore lets the last stage that merely DECLARES a name overwrite the value an
// earlier stage was actually written with - the shared-header idiom (the same
// `uniform mat4 u_mvp` in the VS and the FS) rendered nothing because of it. Recording
// which locations an application has written is what lets the mirror carry only those.
//
// WHO PAYS: only a program that could ever be a pipeline stage, decided by the latch
// below. glUseProgram's uniform path - thousands of calls per frame in Minecraft - pays
// one predictable bool branch and nothing else.
//
// GRANULARITY is per LOCATION, not per name: glUniform*v writes array elements at
// element locations, and a program that wrote `arr[3]` and nothing else must mirror
// exactly that element. The compact index list beside it is what keeps the mirror
// O(uniforms actually written) instead of O(active uniforms) - it is the set of GL
// active-uniform indices owning at least one written location, so the mirror does its
// two name lookups once per written uniform rather than once per uniform in the program.
//
// NOT counted as a write: the declared initializers ProgramLinkTask seeds at link
// (ApplyUniformInitialValues). They are a property of the SHADERS, and the composite
// links the very same shader objects, so it seeds itself with the identical values -
// there is nothing to carry. Counting them would also re-introduce the bug this set
// exists to fix, by letting a stage that only declares `uniform float f = 0.0;` clobber
// the value the application wrote for `f` in another stage.
Bool TracksUniformWrites() const { return m_tracksUniformWrites; }
// Generation of the write SET itself, as distinct from the values in it. The refresh
// gate (ProgramPipelineObject::ComputeUniformMirrorVersions) is otherwise built out of
// counters that only move when BYTES move - and a write can enlarge the set without
// moving a byte, because both write funnels drop a value-identical write before
// bumping anything. glProgramUniform1f(fs, f, 0.0f) on an `f` that already reads 0.0
// is exactly that: it makes the FRAGMENT stage the last written-to stage for `f`, so
// the composite must be re-mirrored to hand it the slot, and nothing else in the gate
// would have noticed.
Uint32 GetUniformWriteSetVersion() const { return m_uniformWriteSetVersion; }
// Records that `location` has been written since the last link. Cheap and idempotent;
// a no-op on a program that can never be a pipeline stage.
void MarkUniformWrittenAtLocation(Uint location) {
if (!m_tracksUniformWrites) return;
LinkArtifacts& artifacts = Artifacts();
if (!IsValidUniformLocation(artifacts, static_cast<Int>(location))) return;
// Sized to cover this location AND the whole location space, so a program whose
// highest location is written first does not reallocate on every later write, and
// so the subscript below needs no second guard: the vector provably contains it.
const SizeT locationWord = location / 64u;
if (locationWord >= artifacts.writtenUniformLocationBits.size()) {
artifacts.writtenUniformLocationBits.resize(
std::max<SizeT>(locationWord + 1u, static_cast<SizeT>(artifacts.maxUniformLocation) / 64u + 1u),
0u);
}
const Uint64 locationBit = Uint64{1} << (location % 64u);
if ((artifacts.writtenUniformLocationBits[locationWord] & locationBit) == 0) {
artifacts.writtenUniformLocationBits[locationWord] |= locationBit;
// Only on the 0 -> 1 transition: a re-write of a location already in the set
// changes nothing the mirror would do differently, and moving the version for
// it would re-walk the set on every repeated glUniform* call.
++m_uniformWriteSetVersion;
}
// Add the owning GL active-uniform index to the compact list, once.
const Int tIndex = artifacts.uniformIndexInTProgram[location];
if (tIndex < 0 || static_cast<SizeT>(tIndex) >= artifacts.tProgramUniformIndexToGl.size()) return;
const Int glIndex = artifacts.tProgramUniformIndexToGl[tIndex];
// -1 is a uniform the relaxed parse swept out of the GL-visible index space; the
// mirror enumerates GL indices, so there is nothing it could look such a one up by.
if (glIndex < 0) return;
const SizeT indexWord = static_cast<SizeT>(glIndex) / 64u;
if (indexWord >= artifacts.writtenUniformIndexBits.size()) {
artifacts.writtenUniformIndexBits.resize(
std::max<SizeT>(indexWord + 1u, static_cast<SizeT>(artifacts.activeUniformCount) / 64u + 1u), 0u);
}
const Uint64 indexBit = Uint64{1} << (static_cast<SizeT>(glIndex) % 64u);
if ((artifacts.writtenUniformIndexBits[indexWord] & indexBit) != 0) return;
artifacts.writtenUniformIndexBits[indexWord] |= indexBit;
artifacts.writtenUniformIndices.push_back(static_cast<Uint>(glIndex));
}
Bool IsUniformWrittenAtLocation(Uint location) const {
const auto& bits = Artifacts().writtenUniformLocationBits;
const SizeT locationWord = location / 64u;
return locationWord < bits.size() &&
(bits[locationWord] & (Uint64{1} << (location % 64u))) != 0;
}
// GL active-uniform indices owning at least one written location. Empty for every
// program that has not been written to since its last link - and for every program
// that never asked to be separable, which is what makes the mirror free for them.
const Vector<Uint>& GetWrittenUniformIndices() const { return Artifacts().writtenUniformIndices; }
Int GetAttributeLocation(const String& name) {
const auto it = std::find(Artifacts().attribs.begin(), Artifacts().attribs.end(), name);
@@ -474,14 +588,35 @@ namespace MobileGL::MG_State::GLState {
}
void SetUniformSamplerOrImageUnitIndex(Uint location, Int unit) {
if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size() ||
Artifacts().uniformSamplerOrImageUnitIndex[location] == unit) {
return;
}
if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size()) return;
// BEFORE the equality bail-out, not after: "written" is about the application
// having addressed the uniform, not about the bytes changing. glUniform1i(s, 0) on
// a sampler that already reads 0 still has to beat another stage's untouched
// declaration of the same name in the composite - which is only possible if the
// write is recorded. (The mirror is the only reader, and it runs this same setter
// on the composite, where the latch is off.)
MarkUniformWrittenAtLocation(location);
if (Artifacts().uniformSamplerOrImageUnitIndex[location] == unit) return;
Artifacts().uniformSamplerOrImageUnitIndex[location] = unit;
++m_backendStateVersion;
// IMAGE units get their own generation, and it is not redundant with the one
// above. A sampler unit is re-issued to the driver per draw as a plain
// glUniform1i, so a backend can honour a change without rebuilding anything; an
// image unit cannot be, because ES forbids glUniform1i on image uniforms - Espryt
// has to BAKE it into the ESSL it generates (RebindImageUniformsToFrontendUnits),
// which means the change is only honoured by regenerating the program. That
// regeneration is gated on link-shaped versions, so without a counter that moves
// here the new unit would never reach the driver.
if (const glslang::TType* type = GetUniformTType(location); type != nullptr && type->isImage()) {
++m_imageUnitVersion;
}
}
// Generation of the image-uniform unit assignment; see SetUniformSamplerOrImageUnitIndex.
// A backend that compiles the unit into its program source compares this to decide
// whether what it built is still describing the right binding.
Uint32 GetImageUnitVersion() const { return m_imageUnitVersion; }
Int GetUniformSamplerOrImageUnitIndex(Uint location) const {
return Artifacts().uniformSamplerOrImageUnitIndex[location];
}
@@ -497,7 +632,32 @@ namespace MobileGL::MG_State::GLState {
// subset of the stages of a program pipeline. Only takes effect on the next link,
// which is why it is plain state here rather than something Link() consults.
Bool GetSeparable() const { return m_separable; }
void SetSeparable(Bool separable) { m_separable = separable; }
void SetSeparable(Bool separable) {
m_separable = separable;
// ---- arming the uniform-write tracking latch ----
//
// The predicate wanted is "this program can ever be a pipeline stage", and
// GetSeparable() is NOT it in either direction. GL_PROGRAM_SEPARABLE takes effect
// at the NEXT link, so it can read true on a program glUseProgramStages would
// still reject; that direction is merely wasteful. The other direction is a
// correctness hole: glProgramParameteri may clear the flag AFTER a separable link,
// and glUseProgramStages tests the state the program was LINKED with, so such a
// program is still a legal stage while GetSeparable() reads false. Tracking driven
// by the live flag would stop recording writes on a program the composite is still
// mirroring from, and those uniforms would silently stop reaching the draw.
//
// "Attached to a pipeline" is not usable either, and for a more basic reason:
// glProgramUniform* legitimately runs before glUseProgramStages, so the marks have
// to already exist by the time the program becomes a stage.
//
// So: a MONOTONE latch, armed the first time GL_PROGRAM_SEPARABLE is requested
// true and never cleared. It over-approximates - a program that was separable once
// keeps paying the bookkeeping - and over-approximating only ever costs a bitset,
// never a wrong value. glCreateShaderProgramv arms it through this same setter.
// A program that never asks (every monolithic glUseProgram program, which is the
// hot uniform path) never arms it and pays one bool branch per glUniform*.
if (separable) m_tracksUniformWrites = true;
}
// glProgramBinary always fails here (there is no format it could accept) and the
// spec then requires the program's LINK_STATUS to read FALSE.
void MarkLinkFailedByProgramBinary() {
@@ -599,13 +759,23 @@ namespace MobileGL::MG_State::GLState {
return (ubo.stages & stageMask) != 0;
}
// Set by glUniformBlockBinding
// Bumped by both block-binding setters below. A program pipeline's flattened composite
// is a different program object from the stage programs the application rebinds blocks
// on, so it has to be told - and this is what tells it something is worth re-reading.
// Separate from m_backendStateVersion because the storage-block setter deliberately
// does not disturb that one (see SetShaderStorageBlockBinding).
Uint32 GetBlockBindingVersion() const { return m_blockBindingVersion; }
// Set by glUniformBlockBinding. The vector is seeded at link with each block's DECLARED
// binding (layout(binding=N), else -1), so an untouched program already reports what its
// shaders asked for.
void SetUniformBlockBinding(Uint index, Uint binding) {
if (index >= Artifacts().uniformBlockBinding.size() || Artifacts().uniformBlockBinding[index] == static_cast<Int>(binding)) {
return;
}
Artifacts().uniformBlockBinding[index] = static_cast<Int>(binding);
++m_backendStateVersion;
++m_blockBindingVersion;
}
Uint GetUniformBlockBinding(Uint index) const { return Artifacts().uniformBlockBinding[index]; }
@@ -617,6 +787,10 @@ namespace MobileGL::MG_State::GLState {
// means "never rebound", and the shader's declared binding still stands.
void SetShaderStorageBlockBinding(const String& blockName, Uint binding) {
Artifacts().shaderStorageBlockBinding[blockName] = static_cast<Int>(binding);
// Deliberately NOT m_backendStateVersion: Espryt's entry point never forces a
// program build off this, and bumping that version would start doing so. The
// dedicated counter carries the news to the pipeline composite instead.
++m_blockBindingVersion;
}
// -1 when the block has never been rebound. `blockName` is the interface-query
// spelling; an arrayed block's elements ("B[0]", "B[1]") are separate GL resources
@@ -728,7 +902,24 @@ namespace MobileGL::MG_State::GLState {
// layout(location = N) default-block uniform qualifiers (the relaxed parse drops
// them from reflection; the DoReflection assigner restores them from here).
UnorderedMap<String, Int> linkedExplicitUniformLocations;
// Per-link snapshot of the default-block uniform INITIALIZERS the attached shaders
// declared ("uniform int i = 1;"). Desktop GLSL says that value is what the uniform
// reads until the application overwrites it, and relinking restores it - but the
// relaxed parse turns those uniforms into members of MGL_GLOBAL_UBO, where SPIR-V
// cannot carry an initializer, so the value only survives as this side-channel.
// Applied into the uniform shadow at the phase-B publish (ApplyUniformInitialValues).
Vector<glslang::TIntermediate::TUniformInitializer> uniformInitialValues;
UnorderedMap<String, Uint> uniformLocations;
// ---- "written since link" (see MarkUniformWrittenAtLocation) ----
// In LinkArtifacts deliberately: a link is exactly the event that retracts every
// write (GL resets uniforms to their initial values), so living here means the set
// is cleared by the same three paths that clear the rest of a link's output -
// Link()'s whole-struct reset, ResetLinkArtifacts, and the publish's move - and no
// fourth reset site can be forgotten. Empty (and never allocated) for a program
// that never asked to be separable.
Vector<Uint64> writtenUniformLocationBits;
Vector<Uint64> writtenUniformIndexBits;
Vector<Uint> writtenUniformIndices;
// Ordered by location,
// aka. uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`"
Vector<Int> uniformIndexInTProgram;
@@ -982,6 +1173,10 @@ namespace MobileGL::MG_State::GLState {
// detour exactly - and a record that really does change bytes moves the version, which
// is what makes a backend re-upload the UBO it cached during the window.
void ReplayBufferedUniformWrites() const;
// Seeds the freshly published uniform shadow with the declared initializers. Runs at
// the phase-B publish, BEFORE ReplayBufferedUniformWrites, so an application write
// made during the A->B window still wins - which is the GL ordering.
void ApplyUniformInitialValues() const;
// Past this, BufferUniformWrite declines and the write joins instead. Sized so an
// ordinary pack load never reaches it (a pending window is one program's worth of
// uniforms) while a pathological writer cannot grow the heap without bound.
@@ -1040,11 +1235,22 @@ namespace MobileGL::MG_State::GLState {
Bool m_deleteStatus = false;
Bool m_binaryRetrievableHint = false;
Bool m_separable = false;
// Monotone "this program may ever be a pipeline stage" latch; see SetSeparable for why
// it is a latch and not just m_separable. Outside LinkArtifacts on purpose: a relink
// clears the write SET, but a program that was separable is still separable after it.
Bool m_tracksUniformWrites = false;
// Generation counters that must NOT be reset by a link, for the same reason the memo
// versions above are not: a reader compares them for INEQUALITY, so a reset could make
// a stale cache compare equal to a fresh program. See their getters.
Uint32 m_uniformWriteSetVersion = 0;
Uint32 m_imageUnitVersion = 0;
Bool m_validateStatus = true;
// Mutable, like m_artifacts and for the same reason: publishing a pending link is a
// READ-side operation (the first gated getter is what pulls the result in), and the
// publish has to bump these. Still GL-thread-only - a worker never touches them.
mutable Uint32 m_backendStateVersion = 0;
// Interface-block binding generation; see GetBlockBindingVersion.
Uint32 m_blockBindingVersion = 0;
// Backend-owned content-hash memo (see GetBackendHashMemo): valid only while
// m_backendStateVersion matches. Several slots, not one: a backend may resolve the same
@@ -40,25 +40,112 @@ namespace MobileGL {
Uint GetExternalIndex() const { return m_externalIndex; }
// glIsProgramPipeline's answer, and NOT the same question as "does this object
// exist" (GL 4.6 core 7.4: a GenProgramPipelines name "acquires program pipeline
// state only when first bound"). The object is materialized by any of the
// commands that take state from a reserved name - including the pure queries
// glGetProgramPipelineiv and glGetProgramPipelineInfoLog, which have to answer
// out of default state without ever making the name report as an object. So
// existence is map membership and this is a separate latch, exactly as
// TransformFeedbackObject::everBound is.
Bool GetEverBound() const { return m_everBound; }
void MarkEverBound() { m_everBound = true; }
// The stages a DRAW is built from: every stage but compute. GL 4.6 core 7.4
// makes the compute stage exclusive - a program object containing a compute
// shader may contain no other stage, and a pipeline's compute stage is
// dispatched on its own and never participates in a draw. So the compute stage
// is not merely irrelevant to the composite below, it must never enter it: a
// compute module handed to vkCreateGraphicsPipelines is a driver crash rather
// than an error return (Adreno 830 SIGSEGVs inside it).
static constexpr SizeT kGraphicsStageCount = static_cast<SizeT>(ShaderStage::Compute);
static_assert(static_cast<SizeT>(ShaderStage::Compute) + 1 ==
static_cast<SizeT>(ShaderStage::ShaderStageCount),
"ShaderStage must keep Compute last so the graphics stages are a prefix");
// A draw sees one program, but a pipeline holds one program per stage. The
// stages are composited into a single hidden program object, rebuilt whenever
// the stage set - or any stage program's own link - changes. The signature is
// what that "changes" means: a stage program's lifetime id pins the object and
// its backend state version pins the link generation.
using DrawProgramSignature =
Array<Uint64, static_cast<SizeT>(ShaderStage::ShaderStageCount) * 2>;
// GRAPHICS stages are composited into a single hidden program object, rebuilt
// whenever the stage set - or any stage program's own link - changes. The
// signature is what that "changes" means: a stage program's lifetime id pins the
// object and its LINK version pins the link generation. It covers exactly the
// stages the composite is built from, so attaching or relinking a compute stage
// never invalidates a perfectly good graphics composite - and the compute stage,
// having no composite of its own, can never collide with it.
//
// GetLinkVersion() and NOT GetBackendStateVersion(), which is what this used to
// key on. The backend state version moves on every glUniform1i to a sampler and
// every glUniformBlockBinding, so the "set a sampler unit, draw" loop that the
// SSO conformance cases run threw the composite away and REBUILT it on every
// single draw: a fresh ProgramObject, a full Link(true) settled synchronously
// (glslang + SPIR-V + spirv-opt), a full re-mirror, and a brand-new program
// identity that invalidated both backends' per-program registries and pipeline
// memos along the way. The composite's CONTENT depends on the link generations
// and nothing else, and m_linkVersion is bumped by exactly those
// (BumpLinkObservableVersions).
//
// The prerequisite that makes the narrowing legal: because the composite no
// longer rebuilds when per-program uniform STATE changes, every such change must
// reach it through the refresh below instead. Both do - sampler/image units via
// MirrorUniformValues, interface block bindings via MirrorBlockBindings - and
// the two setters that write them still bump the counters the REFRESH gate reads
// (see ComputeUniformMirrorVersions), which is a separate question from what
// this signature reads. They are the only two writers of m_backendStateVersion
// outside the link paths, so nothing else was ever riding on the rebuild.
using DrawProgramSignature = Array<Uint64, kGraphicsStageCount * 2>;
DrawProgramSignature ComputeDrawProgramSignature() const {
DrawProgramSignature signature{};
for (SizeT stage = 0; stage < static_cast<SizeT>(ShaderStage::ShaderStageCount); ++stage) {
for (SizeT stage = 0; stage < kGraphicsStageCount; ++stage) {
const auto& program = m_stagePrograms[stage];
if (!program) continue;
signature[stage * 2] = program->GetLifetimeId();
signature[stage * 2 + 1] = program->GetBackendStateVersion();
signature[stage * 2 + 1] = program->GetLinkVersion();
}
return signature;
}
// Per-program state is written to the STAGE programs - glUniform* addresses the
// pipeline's active program (GL 4.6 core 7.6.1), glProgramUniform* addresses a
// named one, and the two block-binding calls address a named one - while the
// draw reads the composite. Two different objects' state, so the composite is
// refreshed from its stage programs before each draw that needs it. These are
// the per-stage versions "needs it" is measured against. All zero after a
// rebuild, because a fresh composite holds only what its shaders declared and
// so needs a full refresh.
//
// backendStateVersion belongs HERE even though ComputeDrawProgramSignature no
// longer reads it, and that is the whole point of the split: a sampler-unit or
// uniform-block-binding write must still trip the MIRROR (it is now the only
// route those values have to the composite) while deliberately NOT tripping the
// rebuild. uboContentVersion covers ordinary uniform writes, and
// blockBindingVersion covers the storage-block setter, which moves neither of
// the other two.
using UniformMirrorVersions = Array<Uint64, kGraphicsStageCount * 2>;
UniformMirrorVersions ComputeUniformMirrorVersions() const {
UniformMirrorVersions versions{};
for (SizeT stage = 0; stage < kGraphicsStageCount; ++stage) {
const auto& program = m_stagePrograms[stage];
if (!program) continue;
versions[stage * 2] = (static_cast<Uint64>(program->GetBackendStateVersion()) << 32) |
static_cast<Uint64>(program->GetUBOContentVersion());
// Their own slot rather than folded into the pair above: the
// storage-block setter moves the block-binding version and NOTHING
// else, so a rebinding would otherwise be invisible to the refresh
// gate - and the write-set version is the only counter that moves for
// a write which ENLARGES the set without changing a byte (see
// ProgramObject::GetUniformWriteSetVersion), which is what decides
// which stage owns a shared name.
versions[stage * 2 + 1] = (static_cast<Uint64>(program->GetBlockBindingVersion()) << 32) |
static_cast<Uint64>(program->GetUniformWriteSetVersion());
}
return versions;
}
const UniformMirrorVersions& GetMirroredUniformVersions() const { return m_mirroredUniformVersions; }
void SetMirroredUniformVersions(const UniformMirrorVersions& versions) {
m_mirroredUniformVersions = versions;
}
const SharedPtr<ProgramObject>& GetCachedDrawProgram(const DrawProgramSignature& signature) const {
static const SharedPtr<ProgramObject> nullProgram = nullptr;
if (!m_drawProgram || m_drawProgramSignature != signature) return nullProgram;
@@ -67,6 +154,8 @@ namespace MobileGL {
void SetCachedDrawProgram(const DrawProgramSignature& signature, SharedPtr<ProgramObject> program) {
m_drawProgramSignature = signature;
m_drawProgram = Move(program);
// A rebuilt composite holds none of its stage programs' uniform values yet.
m_mirroredUniformVersions = {};
}
private:
@@ -74,9 +163,11 @@ namespace MobileGL {
SharedPtr<ProgramObject> m_activeProgram;
SharedPtr<ProgramObject> m_drawProgram;
DrawProgramSignature m_drawProgramSignature{};
UniformMirrorVersions m_mirroredUniformVersions{};
String m_infoLog;
const Uint m_externalIndex = 0;
Bool m_validateStatus = false;
Bool m_everBound = false;
};
} // namespace GLState
} // namespace MG_State

Some files were not shown because too many files have changed in this diff Show More