Compare commits

..
Author SHA1 Message Date
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
swung0x48 cc34d34706 [Fix, Test] (MG_Backend/DirectGLES): hand every compiled shader's lifetime to its program - glDeleteShader was never called, so each program build leaked its driver shader objects 2026-08-11 11:11:28 -04:00
swung0x48 757b31592d [Fix, Test] (MG_Backend/DirectVulkan): a multisample resolve that must also change orientation resolves through a pooled scratch image, then blits 2026-08-11 10:49:58 -04:00
swung0x48 dbae4eda10 [Fix, Test] (MG_Backend/DirectVulkan): depth blits to or from the default framebuffer convert their rect out of GL's bottom-origin space, like the colour blit does 2026-08-11 10:24:59 -04:00
swung0x48 fa5ff5d168 [Test] (MG_IntegrationTest): every texture access routine must read the same texel out of a usampler2DArray 2026-08-11 10:21:38 -04:00
swung0x48 994ae372f8 [Fix, Test] (MG_Backend/DirectVulkan): an SSBO block instance array is one binding of N descriptors, so bind every element from its own GL binding point 2026-08-11 10:11:23 -04:00
swung0x48 7ba012adf9 [Test] (MG_IntegrationTest): SSBO runtime-array length across preambles, block arrays and bound ranges 2026-08-11 10:11:23 -04:00
swung0x48 b1fdffd767 [Fix, Test] (MG_Backend/DirectVulkan): read the default framebuffer's depth and stencil back instead of leaving the caller's buffer untouched 2026-08-11 10:11:22 -04:00
swung0x48 18c17ae5ca [Fix, Test] (MG_Backend/DirectVulkan): a blit into the default framebuffer must execute the clear parked before it, not leave it for the readback 2026-08-11 10:08:25 -04:00
swung0x48 16c010985f [Fix, Test] (MG_Impl): multi-bind name rejection is per element, and only transform feedback constrains the range size to a multiple of four 2026-08-11 09:17:51 -04:00
swung0x48 6f64ec0f51 [Fix, Test] (MG_Impl): validate indirect-dispatch and indirect-count arguments before the backend-availability check 2026-08-11 09:12:49 -04:00
swung0x48 5d47698349 [Fix, Test] (MG_Backend/DirectVulkan): materialize the default framebuffer's pending clear before a readback, alpha included 2026-08-11 09:09:31 -04:00
swung0x48 7b593e39ef [Fix, Test] (MG_Impl, MG_State): negative-path GL errors for multi_bind, indirect_parameters, texture_storage, compute dispatch/link and buffer-range alignment; indexed getters answer the full pname table 2026-08-11 09:02:22 -04:00
swung0x48 d83b4dbbb5 [Fix, Test] (MG_State, MG_Impl): ARB_vertex_attrib_binding state model - spec stride default, legacy stride/pointer shadows, divisor re-binds, core-profile VAO-0 rejection 2026-08-11 08:49:37 -04:00
swung0x48 964a7fcc92 [Fix, Test] (MG_State, MG_Backend/DirectVulkan): resolve transform-feedback captures that name a member of an output interface block 2026-08-11 08:39:39 -04:00
swung0x48 5a7bd9942d [Fix] (MG_Backend/DirectVulkan): print VkShaderModule as a 64-bit value - the const void* cast is ill-formed on 32-bit ABIs where the handle is a plain uint64_t 2026-08-11 08:19:31 -04:00
swung0x48 21a43bf6a4 [Fix, Test] (MG_Backend/DirectVulkan): re-land the gl_FragCoord default-framebuffer origin fix - the suspected slowdown was a mismeasurement, paired timings are within 1% 2026-08-11 07:54:24 -04:00
swung0x48 5b6dec2d81 [Revert] (MG_Backend/DirectVulkan): back out the gl_FragCoord default-framebuffer origin fix - correct, but it costs DirectVulkan a large order-dependent slowdown that is not yet root-caused 2026-08-11 06:30:30 -04:00
swung0x48 543c29bf86 [Fix, Test] (MG_Backend/DirectVulkan): gl_FragCoord on the default framebuffer reports GL's window origin - the stored row is not the window row once the viewport rect is converted 2026-08-11 05:25:32 -04:00
swung0x48 ef562ee9b5 [Fix, Test] (MG_Backend/DirectGLES): backend framebuffer, renderbuffer and sampler twins release their driver ids - a framebuffer per readback leaked the driver into stale pixels 2026-08-11 03:54:05 -04:00
swung0x48 fa0f6693d0 [Fix, Test] (MG_State, MG_Impl): reflection-backed glGetProgramiv queries answer zero instead of dereferencing a null TProgram 2026-08-11 02:59:10 -04:00
swung0x48 a6c362c6ce [Fix, Test] (MG_Backend/DirectVulkan): convert every default-framebuffer rectangle between GL and display Y origins - viewport, scissor, ReadPixels offset, rect-capable readback remap, blit source 2026-08-11 02:51:22 -04:00
swung0x48 921504eccf [Fix, Test] (MG_Backend/DirectVulkan, MG_Util): clamp Vulkan-derived GL buffer limits and saturate the uint32 to Int casts 2026-08-11 02:38:51 -04:00
swung0x48 7c5fc03b26 [Fix, Test] (MG_Backend/DirectVulkan): never bind or cache a null pipeline, and name the modules a failed vkCreateGraphicsPipelines rejected 2026-08-11 02:33:32 -04:00
swung0x48 ed29e63543 [Fix, Test] (MG_Impl): glGetProgramResourceiv reports a written length on every exit path 2026-08-11 02:27:26 -04:00
swung0x48 5c8a9c41d6 [Fix, Test] (MG_Impl, MG_State): GL entry points record errors instead of throwing through the C ABI - CopyTexImage superset rule, TEXTURE_BUFFER level queries, indexed cap toggles 2026-08-11 02:24:41 -04:00
swung0x48 efa0345c36 [Fix] (MG_Backend/DirectVulkan): offscreen surfaces never touch a window system - delete the hidden-Xlib fallback, make surface and device-enumeration failures loud, guard zero devices 2026-08-11 00:58:24 -04:00
swung0x48 ce0f18969c [Fix] (MG_Util): dlopen the versioned libEGL.so.1 first - the unversioned name is a dev-package symlink absent on runtime-only systems, and its silent failure nulled every EGL entry point 2026-08-11 00:58:23 -04:00
swung0x48 7ce0966e7d [Fix, Test] (MG_IntegrationTest): the harness is surfaceless by construction - never binds a window system, hardware demand split out of REQUIRE_GPU, pre-flight crashes keep their cores 2026-08-11 00:58:22 -04:00
swung0x48 1c6ca2753f [Test] (CI): wire the integration-gpu lane into the Test workflow - lavapipe ICD pinned at configure time, REQUIRE_GPU armed, failure-only core-dump artifacts in every native lane 2026-08-11 00:58:21 -04:00
swung0x48 61b0532865 [Fix] (MG_Backend/DirectVulkan): replay client-memory multi-draw through the single-draw path - the batched shared index view cannot express per-draw client pointers and dropped the whole batch 2026-08-10 21:58:06 -04:00
swung0x48 f5b8a505ed [Fix] (MG_Backend/DirectVulkan): keep the transient arena's outgrown buffers alive until frame rewind - the transient drain freed them while frame-serial memos still bound them 2026-08-10 21:47:28 -04:00
swung0x48 8371365db5 [Fix] (MG_Backend/DirectVulkan): include the texture enum converter the WARN-level log line needs - VulkanRenderer.cpp did not compile below INFO log level 2026-08-10 21:47:27 -04:00
swung0x48 b219992ee3 [Feat, Perf, Test] (MG_Util, MG_Backend, MG_Test): test-lane SPIR-V validation latch; entry-local Private rewrite, in-chain rect lowering and uniform-Location strip 2026-08-10 13:00:20 -04:00
swung0x48 94233ef928 [Fix] (MG_Backend/DirectVulkan): drop draws and dispatches when a program has no optimized SPIR-V - a phase-B failure left raw glslang words in GetGeneratedSpirv 2026-08-10 13:00:11 -04:00
swung0x48 0827d7a539 [Fix] (MobileGL): define the numeric log levels in Defines.h - the MOBILEGL_ASSERT gate compared undefined tokens in TUs that never include Log.h 2026-08-10 13:00:10 -04:00
swung0x48 5248b8b746 [Test] (MG_Test): replay a real Iris shader pair through the whole async frontend, both phases and both quirk states 2026-08-10 10:06:26 -04:00
swung0x48 d868e1c476 [Test] (MG_Util, MG_State): remove the TEMP stage-timing probes - the async-compile measurement campaign is done 2026-08-10 09:56:05 -04:00
swung0x48 c3412ca394 [Fix, Test] (MG_Util): give inactive vertex inputs a Location - undecorated inputs are invalid SPIR-V and Adreno rejects the whole pipeline 2026-08-10 09:27:01 -04:00
swung0x48 5ccaff37af [Fix] (MG_State): revert the Cut A-prime reorder - device dumps prove the ordering is not the pipeline failure, and record the third measurement 2026-08-10 08:58:38 -04:00
swung0x48 71e29f9d58 [Fix] (MG_State): keep GlslangToSpv ahead of buildReflection - reflection-first drops a vertex input's Location decoration and Adreno rejects the pipeline 2026-08-10 08:33:47 -04:00
swung0x48 8ad07c222c [Test] (MG_Test): cover the cancelled-SPIR-V program, the buffered-write valve and drop, the delete-does-not-block bound and the zero-thread join 2026-08-10 07:31:58 -04:00
swung0x48 5722094d6f [Docs] (MG_State): the early AST drop frees only the re-parsed shaders - the compile node co-owns the rest 2026-08-10 07:31:58 -04:00
swung0x48 4831387cf0 [Fix] (MG_State, MG_Backend/DirectVulkan): a program whose SPIR-V job was cancelled faulted on the first glUniform - bounds-check the shadow lookup 2026-08-10 07:31:57 -04:00
swung0x48 d03b72267a [Perf] (MG_State): drop the parsed ASTs as soon as the SPIR-V job has generated its modules 2026-08-10 06:37:49 -04:00
swung0x48 847ec74f48 [Test] (MG_Test): AsyncSpirvPhaseTest - phase-A completeness, buffered-uniform replay, version bumps and the cancel matrix 2026-08-10 06:12:39 -04:00
swung0x48 e02e5caa17 [Fix] (MG_State, MG_Backend/DirectGLES): join both link phases on the draw and drain paths, and refuse to bind a program whose SPIR-V never arrived 2026-08-10 06:08:30 -04:00
swung0x48 1958934594 [Feat] (MG_State, MG_Impl): buffer non-opaque glUniform writes across the SPIR-V window and replay them at its publish 2026-08-10 06:05:37 -04:00
swung0x48 dec0c5eaff [Refactor, Test] (MG_State, MG_Util): split the link into ProgramLinkTask (query surface) and a chained ProgramSpirvTask behind its own join gate 2026-08-10 06:02:52 -04:00
swung0x48 b6a44cd1e2 [Refactor] (MG_State): run reflection and link validation before SPIR-V generation - the ordering constraint retested byte-identical 2026-08-10 05:50:20 -04:00
swung0x48 85f45d0e44 [Test] (MG_Util, MG_State): TEMP spirv-null plumbing probe for the allocator-pathology discriminator 2026-08-10 05:42:23 -04:00
swung0x48 404236d337 [Test] (MG_Util, MG_State): TEMP stage-timing probes for the async-compile campaign - remove before merge 2026-08-10 05:16:24 -04:00
swung0x48 6ea948779e [Feat, Test] (MG_Impl, MG_State, MG_Util): opt-in MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS - shader compile status/log answer without joining, latched per compile 2026-08-10 02:06:17 -04:00
151 changed files with 20302 additions and 2431 deletions
+4 -51
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail 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 if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
echo "usage: $0 <trace-case> [fixture-dir]" >&2 echo "usage: $0 <trace-case> [fixture-dir]" >&2
exit 2 exit 2
@@ -62,57 +66,6 @@ if [ "${case_name}" = "OpenRA" ]; then
exit 0 exit 0
fi 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() { fetch_file_from_mirror() {
local file="$1" local file="$1"
local url="$2" 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: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
actions: write
contents: read
env: env:
CCACHE_BASEDIR: ${{ github.workspace }} CCACHE_BASEDIR: ${{ github.workspace }}
CCACHE_COMPRESS: "true" CCACHE_COMPRESS: "true"
@@ -41,12 +44,11 @@ jobs:
gradle-version: 8.10.2 gradle-version: 8.10.2
- name: Restore ccache - name: Restore ccache
uses: actions/cache@v5 uses: actions/cache/restore@v5
with: with:
path: .ccache 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: | restore-keys: |
${{ runner.os }}-apk-${{ github.job }}-ccache-${{ github.ref_name }}-
${{ runner.os }}-apk-${{ github.job }}-ccache- ${{ runner.os }}-apk-${{ github.job }}-ccache-
- name: Install ccache - name: Install ccache
@@ -125,6 +127,28 @@ jobs:
if: always() if: always()
run: ccache --show-stats 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 - name: Verify APK metadata and packaging
run: | run: |
AAPT2="$(find "$ANDROID_HOME/build-tools" -name aapt2 -type f | sort -V | tail -n 1)" AAPT2="$(find "$ANDROID_HOME/build-tools" -name aapt2 -type f | sort -V | tail -n 1)"
@@ -201,9 +225,41 @@ jobs:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v6 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 - name: Fetch trace fixture
if: steps.fixture-verify.outputs.ok != 'true'
run: bash .github/scripts/fetch-trace-fixture-lfs.sh '${{ matrix.case }}' 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 - name: Stage trace fixture
run: | run: |
safe_case="$(printf '%s' '${{ matrix.case }}' | sed 's/[^A-Za-z0-9._-]/_/g')" 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 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) extra_retrace_args+=(--avoid-angle-llvmpipe-sampler-mipmap-min-filter)
fi 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 if [ "${{ matrix.case.coherent_as_flush || false }}" = "true" ]; then
extra_retrace_args+=(--coherent-as-flush) extra_retrace_args+=(--coherent-as-flush)
fi fi
@@ -411,6 +470,24 @@ jobs:
run_retrace || retrace_status=$? run_retrace || retrace_status=$?
if [ "${retrace_status}" -eq 75 ]; then if [ "${retrace_status}" -eq 75 ]; then
echo "::warning::Android emulator infrastructure failed; restarting it and retrying this retrace once." 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 \ sh android-plugin/run-avd-ci.sh stop \
--avd-name "${AVD_NAME}" \ --avd-name "${AVD_NAME}" \
--emulator-log "${EMULATOR_LOG}" \ --emulator-log "${EMULATOR_LOG}" \
@@ -450,6 +527,13 @@ jobs:
if [ -f "${EMULATOR_LOG}" ]; then if [ -f "${EMULATOR_LOG}" ]; then
cp "${EMULATOR_LOG}" android-retrace-result/diagnostics/emulator.log cp "${EMULATOR_LOG}" android-retrace-result/diagnostics/emulator.log
fi 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 - name: Stop Emulator
if: always() if: always()
@@ -531,22 +615,41 @@ jobs:
) )
if ((${#failed_cases[@]})); then 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[@]}" printf ' %s\n' "${!failed_cases[@]}"
else else
echo "All retrace jobs succeeded; no fixtures need to be retained." echo "All retrace jobs succeeded; nothing needs to be retained."
fi fi
deleted=0 deleted=0
retained=0 retained=0
while IFS=$'\t' read -r artifact_id artifact_name; do while IFS=$'\t' read -r artifact_id artifact_name; do
keep=0
if [[ "${artifact_name}" == MobileGL-trace-fixture-* ]]; then if [[ "${artifact_name}" == MobileGL-trace-fixture-* ]]; then
case_name="${artifact_name#MobileGL-trace-fixture-}" case_name="${artifact_name#MobileGL-trace-fixture-}"
if [[ -v "failed_cases[${case_name}]" ]]; then if [[ -v "failed_cases[${case_name}]" ]]; then
echo "Retaining ${artifact_name} (${artifact_id}) for failed retrace." keep=1
((retained += 1))
continue
fi 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 fi
echo "Deleting ${artifact_name} (${artifact_id})" echo "Deleting ${artifact_name} (${artifact_id})"
+195 -8
View File
@@ -1,4 +1,4 @@
name: Test name: Test
on: on:
push: push:
@@ -11,6 +11,9 @@ on:
jobs: jobs:
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
actions: write
contents: read
env: env:
BUILD_DIR: build-linux BUILD_DIR: build-linux
CCACHE_BASEDIR: ${{ github.workspace }} CCACHE_BASEDIR: ${{ github.workspace }}
@@ -34,12 +37,11 @@ jobs:
uses: lukka/get-cmake@v4.3.3 uses: lukka/get-cmake@v4.3.3
- name: Restore ccache - name: Restore ccache
uses: actions/cache@v5 uses: actions/cache/restore@v5
with: with:
path: .ccache 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: | restore-keys: |
${{ runner.os }}-test-${{ github.job }}-ccache-${{ github.ref_name }}-
${{ runner.os }}-test-${{ github.job }}-ccache- ${{ runner.os }}-test-${{ github.job }}-ccache-
- name: Prepare Vulkan SDK - name: Prepare Vulkan SDK
@@ -83,6 +85,8 @@ jobs:
-DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \ -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \
-DMOBILEGL_BUILD_TEST=ON \ -DMOBILEGL_BUILD_TEST=ON \
-DMOBILEGL_BUILD_BENCHMARK=ON \ -DMOBILEGL_BUILD_BENCHMARK=ON \
-DMOBILEGL_BUILD_INTEGRATION_TEST=ON \
-DMOBILEGL_ITEST_VK_ICD=/usr/share/vulkan/icd.d/lvp_icd.json \
-DMOBILEGL_BUILD_TRACE_REPLAY=OFF \ -DMOBILEGL_BUILD_TRACE_REPLAY=OFF \
-DBENCHMARK_DOWNLOAD_DEPENDENCIES=ON \ -DBENCHMARK_DOWNLOAD_DEPENDENCIES=ON \
-DBENCHMARK_ENABLE_TESTING=OFF \ -DBENCHMARK_ENABLE_TESTING=OFF \
@@ -95,6 +99,28 @@ jobs:
if: always() if: always()
run: ccache --show-stats 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 - name: Package Linux runtime
run: | run: |
mkdir -p ci-artifacts mkdir -p ci-artifacts
@@ -110,6 +136,7 @@ jobs:
"${BUILD_DIR}/CTestTestfile.cmake" \ "${BUILD_DIR}/CTestTestfile.cmake" \
"${BUILD_DIR}/MobileGL/MG_Test" \ "${BUILD_DIR}/MobileGL/MG_Test" \
"${BUILD_DIR}/MobileGL/MG_Benchmark" \ "${BUILD_DIR}/MobileGL/MG_Benchmark" \
"${BUILD_DIR}/MobileGL/MG_IntegrationTest" \
"${SHARED_LIBS[@]}" "${SHARED_LIBS[@]}"
- name: Upload Linux runtime - name: Upload Linux runtime
@@ -159,12 +186,102 @@ jobs:
- name: Test - name: Test
working-directory: build-linux working-directory: build-linux
run: | run: |
ulimit -c unlimited
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then
ctest -V -L unit --no-tests=error ctest -V -L unit --no-tests=error
else else
ctest --output-on-failure -L unit --no-tests=error ctest --output-on-failure -L unit --no-tests=error
fi fi
- name: Upload core dumps
if: failure()
uses: actions/upload-artifact@v7
with:
name: unit-core-dumps
path: /tmp/core.*
if-no-files-found: ignore
integration:
runs-on: ubuntu-latest
needs: build-linux
steps:
- name: Checkout repo
uses: actions/checkout@v6
- name: Get CMake
uses: lukka/get-cmake@v4.3.3
- name: Install runtime dependencies
# Same set as the benchmark job, for the same reason: the scenarios bring
# up real headless EGL (llvmpipe) and Vulkan (lavapipe) contexts, and
# libegl-mesa0 - the EGL vendor library behind glvnd's libegl1 dispatch -
# only arrives as a Recommends.
run: |
sudo apt-get update
sudo apt-get install -y libvulkan1 libegl1 libegl-mesa0 libgles2 libgl1-mesa-dri mesa-vulkan-drivers
- name: Download Linux runtime
uses: actions/download-artifact@v8
with:
name: mobilegl-linux-runtime
path: .
- name: Unpack Linux runtime
run: tar -xzf mobilegl-linux-runtime.tgz
- name: Normalize CTest command paths
run: |
python - <<'PY'
from pathlib import Path
import re
for path in Path('build-linux').rglob('CTestTestfile.cmake'):
text = path.read_text()
text = re.sub(r'"[^"]*/cmake-[^"]*/bin/cmake"', '"cmake"', text)
path.write_text(text)
PY
- name: Integration scenarios
working-directory: build-linux
# REQUIRE_GPU makes a driverless runner FAIL instead of skipping every
# scenario - an all-skip run is otherwise indistinguishable from a pass,
# which is how a five-month-old draw-dropping bug survived unseen until
# this lane existed.
#
# The lavapipe ICD pin lives in the build-linux configure
# (-DMOBILEGL_ITEST_VK_ICD), NOT here: the configure bakes it into each
# test's ctest ENVIRONMENT property, and a property entry OVERRIDES the
# job environment - a VK_ICD_FILENAMES exported here would be silently
# ignored while looking like it works. This lane runs on lavapipe
# deterministically, not on whichever of the eight Mesa ICDs a GPU-less
# runner enumerates first.
#
# Cores are armed so that any crash - the harness pre-flight child's
# included - leaves /tmp/core.*, which the failure-only step below ships
# as an artifact. Analyzing a downloaded core against the runtime
# artifact's binary in an ubuntu-24.04 userspace reproduces the exact
# crash stack without burning a CI round on an in-workflow debugger.
env:
MOBILEGL_ITEST_REQUIRE_GPU: "1"
run: |
ulimit -c unlimited
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then
ctest -V -L integration-gpu --no-tests=error
else
ctest --output-on-failure -L integration-gpu --no-tests=error
fi
- name: Upload core dumps
if: failure()
uses: actions/upload-artifact@v7
with:
name: integration-core-dumps
path: /tmp/core.*
if-no-files-found: ignore
benchmark: benchmark:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-linux needs: build-linux
@@ -208,7 +325,18 @@ jobs:
- name: Benchmark - name: Benchmark
working-directory: build-linux working-directory: build-linux
run: ctest -V -C Release -L benchmark --no-tests=error run: |
ulimit -c unlimited
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
ctest -V -C Release -L benchmark --no-tests=error
- name: Upload core dumps
if: failure()
uses: actions/upload-artifact@v7
with:
name: benchmark-core-dumps
path: /tmp/core.*
if-no-files-found: ignore
build-retrace: build-retrace:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -216,6 +344,9 @@ jobs:
- build-linux - build-linux
- test - test
- benchmark - benchmark
permissions:
actions: write
contents: read
env: env:
BUILD_DIR: build-retrace BUILD_DIR: build-retrace
CCACHE_BASEDIR: ${{ github.workspace }} CCACHE_BASEDIR: ${{ github.workspace }}
@@ -240,12 +371,11 @@ jobs:
uses: lukka/get-cmake@v4.3.3 uses: lukka/get-cmake@v4.3.3
- name: Restore ccache - name: Restore ccache
uses: actions/cache@v5 uses: actions/cache/restore@v5
with: with:
path: .ccache 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: | restore-keys: |
${{ runner.os }}-test-${{ github.job }}-ccache-${{ github.ref_name }}-
${{ runner.os }}-test-${{ github.job }}-ccache- ${{ runner.os }}-test-${{ github.job }}-ccache-
- name: Prepare Vulkan SDK - name: Prepare Vulkan SDK
@@ -311,6 +441,21 @@ jobs:
if: always() if: always()
run: ccache --show-stats 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 - name: Normalize CTest command paths
run: | run: |
python - <<'PY' python - <<'PY'
@@ -366,9 +511,41 @@ jobs:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v6 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 - name: Fetch trace fixture
if: steps.fixture-verify.outputs.ok != 'true'
run: bash .github/scripts/fetch-trace-fixture-lfs.sh '${{ matrix.case }}' 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 - name: Stage trace fixture
run: | run: |
safe_case="$(printf '%s' '${{ matrix.case }}' | sed 's/[^A-Za-z0-9._-]/_/g')" safe_case="$(printf '%s' '${{ matrix.case }}' | sed 's/[^A-Za-z0-9._-]/_/g')"
@@ -456,6 +633,8 @@ jobs:
- name: Retrace and validate - name: Retrace and validate
working-directory: build-retrace/tools/trace_replay working-directory: build-retrace/tools/trace_replay
run: | run: |
ulimit -c unlimited
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
if [ '${{ matrix.backend }}' = 'DirectVulkan' ]; then if [ '${{ matrix.backend }}' = 'DirectVulkan' ]; then
export MOBILEGL_MAGMA_R11G11B10F_FALLBACK=1 export MOBILEGL_MAGMA_R11G11B10F_FALLBACK=1
fi fi
@@ -470,6 +649,14 @@ jobs:
fi fi
ctest -V --no-tests=error -R '^MobileGLTraceReplay\.${{ matrix.case }}\.${{ matrix.backend }}$' ctest -V --no-tests=error -R '^MobileGLTraceReplay\.${{ matrix.case }}\.${{ matrix.backend }}$'
- name: Upload core dumps
if: failure()
uses: actions/upload-artifact@v7
with:
name: retrace-core-dumps-${{ matrix.backend }}-${{ matrix.case }}
path: /tmp/core.*
if-no-files-found: ignore
- name: Upload actual image - name: Upload actual image
if: always() if: always()
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
+3 -6
View File
@@ -7,9 +7,6 @@
[submodule "3rdparty/SPIRV-Cross"] [submodule "3rdparty/SPIRV-Cross"]
path = 3rdparty/SPIRV-Cross path = 3rdparty/SPIRV-Cross
url = https://github.com/KhronosGroup/SPIRV-Cross.git 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"] [submodule "3rdparty/tracy"]
path = 3rdparty/tracy path = 3rdparty/tracy
url = https://github.com/wolfpld/tracy.git url = https://github.com/wolfpld/tracy.git
@@ -34,6 +31,6 @@
[submodule "3rdparty/asio"] [submodule "3rdparty/asio"]
path = 3rdparty/asio path = 3rdparty/asio
url = https://github.com/chriskohlhoff/asio.git url = https://github.com/chriskohlhoff/asio.git
[submodule "3rdparty/libfork"] [submodule "include/ska"]
path = 3rdparty/libfork path = include/ska
url = https://github.com/ConorWilliams/libfork.git url = https://github.com/MobileGL-Dev/flat_hash_map.git
-1
Submodule 3rdparty/libfork deleted from 9b2b844a5f
+79 -7
View File
@@ -20,6 +20,81 @@ set(MOBILEGL_VULKAN_LIBRARY "" CACHE FILEPATH "Vulkan loader/MoltenVK library to
if (ANDROID) if (ANDROID)
set(MOBILEGL_BUILD_TEST OFF CACHE BOOL "Build MobileGL tests" FORCE) set(MOBILEGL_BUILD_TEST OFF CACHE BOOL "Build MobileGL tests" FORCE)
set(MOBILEGL_BUILD_BENCHMARK OFF CACHE BOOL "Build MobileGL benchmarks" 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() endif()
option(MOBILEGL_ENABLE_LTO "Build with ThinLTO/IPO" OFF) option(MOBILEGL_ENABLE_LTO "Build with ThinLTO/IPO" OFF)
@@ -205,9 +280,12 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/NormalizeRectCoordinatesPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/NormalizeRectCoordinatesPass.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/StripUboMemberRelaxedPrecisionPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.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/OpenGL/Loader.cpp
MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp
@@ -296,6 +374,7 @@ set(SOURCE_FILES
MobileGL/MG_State/GLState/TextureState/TextureState.cpp MobileGL/MG_State/GLState/TextureState/TextureState.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp
@@ -373,13 +452,6 @@ set(MOBILEGL_INCLUDE_DIR
# MG_Util/Async/ShaderCompilePool.cpp includes it, and it stays behind that file's # MG_Util/Async/ShaderCompilePool.cpp includes it, and it stays behind that file's
# pimpl so no consumer target needs this path. # pimpl so no consumer target needs this path.
${CMAKE_SOURCE_DIR}/3rdparty/asio/asio/include ${CMAKE_SOURCE_DIR}/3rdparty/asio/asio/include
# The second shader-compile execution engine (MOBILEGL_ASYNC_POOL=libfork), on the
# same terms as Asio above: header-only, no add_subdirectory (its CMakeLists only
# declares an INTERFACE target plus install/test scaffolding we do not want), no link
# target, and reachable from exactly one translation unit. libfork's own
# target_compile_features asks for cxx_std_23, which this project already sets
# globally, so its C++20 coroutines need no per-source standard override.
${CMAKE_SOURCE_DIR}/3rdparty/libfork/include
) )
add_library(${CMAKE_PROJECT_NAME} SHARED add_library(${CMAKE_PROJECT_NAME} SHARED
+25 -6
View File
@@ -66,12 +66,11 @@ namespace MobileGL::MG_Config {
// - DISPLAY: X11 session variable, not MobileGL configuration. // - DISPLAY: X11 session variable, not MobileGL configuration.
// - MOBILEGL_LOG_FILE_PATH: log-file init runs before MG_ConfigLoader::Init // - MOBILEGL_LOG_FILE_PATH: log-file init runs before MG_ConfigLoader::Init
// (see MG_Util/Debug/Log.cpp). // (see MG_Util/Debug/Log.cpp).
// - MOBILEGL_ASYNC_POOL: a ShaderCompilePool is constructed by binaries that never call // - MOBILEGL_VALIDATE_SPIRV: test suites like SpirvPassTest exercise
// MobileGL::Initialize() and so never run MG_ConfigLoader::Init - MG_Test's // ShaderCompiler without ever running MobileGL::Initialize(), and every
// JobNodeTest builds pools directly, and it is the suite that runs the whole async // Initialize() re-runs MG_ConfigLoader::Init, which would clobber a
// matrix against both execution engines. Mirroring it here would resolve to the // programmatic override stored here (see ShaderCompiler.cpp,
// default in exactly the tests that exist to tell the engines apart (see // SpirvValidationEnabled).
// MG_Util/Async/ShaderCompilePool.cpp, DetectAsyncPoolEngine).
struct FeaturesTable { struct FeaturesTable {
// MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries. // MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries.
Bool DisableTimerQuery = false; Bool DisableTimerQuery = false;
@@ -90,6 +89,13 @@ namespace MobileGL::MG_Config {
// MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER: avoid mipmap min filters in samplers, // MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER: avoid mipmap min filters in samplers,
// resolves certain rendering bugs on ANGLE + llvmpipe. // resolves certain rendering bugs on ANGLE + llvmpipe.
Bool AvoidSamplerMipmapMinFilter = false; 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 // 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 // GPU-read data through persistent GL_MAP_FLUSH_EXPLICIT_BIT maps they never
// flush. Persistent FLUSH_EXPLICIT map requests are rewritten to coherent // flush. Persistent FLUSH_EXPLICIT map requests are rewritten to coherent
@@ -142,6 +148,19 @@ namespace MobileGL::MG_Config {
// MOBILEGL_ASYNC_SHADER_COMPILE_THREADS: shader-compile worker count. 0 (unset) means // MOBILEGL_ASYNC_SHADER_COMPILE_THREADS: shader-compile worker count. 0 (unset) means
// auto, which is min(4, big cores); an explicit value is honoured as given. // auto, which is min(4, big cores); an explicit value is honoured as given.
Uint32 AsyncShaderCompileThreads = 0; Uint32 AsyncShaderCompileThreads = 0;
// MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS: while a compile job is still in flight,
// glGetShaderiv(GL_COMPILE_STATUS) answers GL_TRUE and the shader info log reads
// empty, WITHOUT joining the job (latched per compile - see
// ShaderObject::TakeOptimisticCompileAnswer). A deliberate, bounded spec violation:
// a real failure still fails the program link with the compile log quoted. It
// exists for applications that compile hundreds of shaders serially and read the
// status right after each glCompileShader - Iris's shader-pack load - where those
// per-shader joins are what serializes the batch on its main path (Iris's gbuffer
// phase issues no program-level query between programs; program-level LINK_STATUS
// and the program info log still join truthfully, so paths that check each link
// immediately stay serial by their own construction). Off by default; never
// advertise it.
QuirkOverride AsyncOptimisticShaderStatus = QuirkOverride::Auto;
}; };
extern FeaturesTable Features; extern FeaturesTable Features;
} // namespace MobileGL::MG_Config } // namespace MobileGL::MG_Config
+3
View File
@@ -171,6 +171,7 @@ namespace MobileGL::MG_ConfigLoader {
features.MagmaFramesInFlight = QueryEnvUint32("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3, 1, 64); features.MagmaFramesInFlight = QueryEnvUint32("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3, 1, 64);
features.AvoidSamplerMipmapMinFilter = features.AvoidSamplerMipmapMinFilter =
QueryEnvFlag("MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER"); QueryEnvFlag("MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
features.AvoidExplicitLodBias = QueryEnvFlag("MOBILEGL_AVOID_EXPLICIT_LOD_BIAS");
features.CoherentAsFlush = QueryEnvFlag("MOBILEGL_COHERENT_AS_FLUSH"); features.CoherentAsFlush = QueryEnvFlag("MOBILEGL_COHERENT_AS_FLUSH");
features.TraceSkipAutodestroy = QueryEnvFlag("MOBILEGL_TRACE_SKIP_AUTODESTROY"); features.TraceSkipAutodestroy = QueryEnvFlag("MOBILEGL_TRACE_SKIP_AUTODESTROY");
features.DisableUboRing = QueryEnvFlag("MOBILEGL_DISABLE_UBO_RING"); features.DisableUboRing = QueryEnvFlag("MOBILEGL_DISABLE_UBO_RING");
@@ -183,6 +184,8 @@ namespace MobileGL::MG_ConfigLoader {
features.EsprytMultiDrawMode = QueryEnvGLESMultiDrawMode("MOBILEGL_ESPRYT_MULTIDRAW_MODE"); features.EsprytMultiDrawMode = QueryEnvGLESMultiDrawMode("MOBILEGL_ESPRYT_MULTIDRAW_MODE");
features.AsyncShaderCompile = QueryEnvQuirkOverride("MOBILEGL_ASYNC_SHADER_COMPILE"); features.AsyncShaderCompile = QueryEnvQuirkOverride("MOBILEGL_ASYNC_SHADER_COMPILE");
features.AsyncShaderCompileThreads = QueryEnvUint32("MOBILEGL_ASYNC_SHADER_COMPILE_THREADS", 0, 0, 64); features.AsyncShaderCompileThreads = QueryEnvUint32("MOBILEGL_ASYNC_SHADER_COMPILE_THREADS", 0, 0, 64);
features.AsyncOptimisticShaderStatus =
QueryEnvQuirkOverride("MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS");
} }
inline void InitBackendType() { inline void InitBackendType() {
+27 -4
View File
@@ -9,10 +9,20 @@
#pragma once #pragma once
// ============== Platform-specific definitions and macros ============== // // ============== Platform-specific definitions and macros ============== //
#ifdef __ANDROID__ // No __ANDROID_API__ pin here on purpose. The effective API level is owned by
#undef __ANDROID_API__ // the build system (gradle minSdk 26 -> -DANDROID_PLATFORM=android-26, enforced
#define __ANDROID_API__ 26 // force Android API level to 26 for compatibility // by the configure-time guard in CMakeLists.txt), not by a macro.
#endif //
// 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 #ifdef _WIN32
#ifndef NOMINMAX #ifndef NOMINMAX
@@ -37,6 +47,19 @@
#define MOBILEGL_WGL_API MOBILEGL_API #define MOBILEGL_WGL_API MOBILEGL_API
// ====================== MobileGL configurations ======================= // // ====================== MobileGL configurations ======================= //
// The numeric log levels live here, not only in Log.h: MOBILEGL_ASSERT below compares
// MOBILEGL_LOG_ACTIVE_LEVEL against MOBILEGL_LOG_LEVEL_DEBUG, and in a translation unit
// 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.
#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_FATAL 4
#endif
#ifndef MOBILEGL_LOG_ACTIVE_LEVEL #ifndef MOBILEGL_LOG_ACTIVE_LEVEL
#define MOBILEGL_LOG_ACTIVE_LEVEL MOBILEGL_LOG_LEVEL_INFO #define MOBILEGL_LOG_ACTIVE_LEVEL MOBILEGL_LOG_LEVEL_INFO
#endif #endif
+2 -2
View File
@@ -49,8 +49,8 @@
#include <stacktrace> #include <stacktrace>
#endif #endif
// Include FastSTL // Include ska::flat_hash_map
#include <FastSTL/UnorderedMap.h> #include <ska/flat_hash_map.hpp>
// Include xxHash // Include xxHash
#include <xxhash.h> #include <xxhash.h>
@@ -1151,6 +1151,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
m_dynamicParameters.MaxComputeUniformBlocks = m_GLESCapabilities.MaxComputeUniformBlocks; m_dynamicParameters.MaxComputeUniformBlocks = m_GLESCapabilities.MaxComputeUniformBlocks;
m_dynamicParameters.MaxComputeWorkGroupInvocations = m_GLESCapabilities.MaxComputeWorkGroupInvocations; m_dynamicParameters.MaxComputeWorkGroupInvocations = m_GLESCapabilities.MaxComputeWorkGroupInvocations;
m_dynamicParameters.MaxShaderStorageBufferBindings = m_GLESCapabilities.MaxShaderStorageBufferBindings; 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.MaxTextureBufferSize = m_GLESCapabilities.MaxTextureBufferSize;
m_dynamicParameters.TextureBufferOffsetAlignment = m_GLESCapabilities.TextureBufferOffsetAlignment; m_dynamicParameters.TextureBufferOffsetAlignment = m_GLESCapabilities.TextureBufferOffsetAlignment;
m_dynamicParameters.MaxUniformBufferBindings = m_GLESCapabilities.MaxUniformBufferBindings; m_dynamicParameters.MaxUniformBufferBindings = m_GLESCapabilities.MaxUniformBufferBindings;
+141 -29
View File
@@ -605,10 +605,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Cached address of g_xfbObjects[g_currentXfbName]: PrepareForDraw consults // Cached address of g_xfbObjects[g_currentXfbName]: PrepareForDraw consults
// CurrentXfb on EVERY draw (StartPendingTransformFeedback) and the map // CurrentXfb on EVERY draw (StartPendingTransformFeedback) and the map
// lookup was pure per-draw overhead for the overwhelmingly common no-capture // lookup was pure per-draw overhead for the overwhelmingly common no-capture
// case. FastSTL's open addressing keeps values in the bucket array, so ANY // case. Open addressing keeps values in the bucket array, so ANY insert can
// insert can rehash and move them (and erase/clear can too): every site that // rehash and move them - and erase moves them too, by shifting the rest of the
// mutates the map or rebinds the current name resets this to null instead of // probe cluster into the hole, which reaches entries other than the erased one.
// reasoning about stability, and CurrentXfb re-resolves lazily. // Every site that mutates the map or rebinds the current name resets this to
// null instead of reasoning about stability, and CurrentXfb re-resolves lazily.
XfbObjectState* g_currentXfbState = nullptr; XfbObjectState* g_currentXfbState = nullptr;
XfbObjectState& CurrentXfb() { XfbObjectState& CurrentXfb() {
@@ -883,7 +884,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (it->second.esId != 0 && g_GLESFuncs.glDeleteTransformFeedbacks != nullptr) { if (it->second.esId != 0 && g_GLESFuncs.glDeleteTransformFeedbacks != nullptr) {
g_GLESFuncs.glDeleteTransformFeedbacks(1, &it->second.esId); g_GLESFuncs.glDeleteTransformFeedbacks(1, &it->second.esId);
} }
g_currentXfbState = nullptr; // erase can move values (open addressing) g_currentXfbState = nullptr; // erase shifts the probe cluster, moving other entries
g_xfbObjects.erase(it); g_xfbObjects.erase(it);
// The frontend reverts to the default object when the bound one is deleted. // The frontend reverts to the default object when the bound one is deleted.
if (g_currentXfbName == name) { if (g_currentXfbName == name) {
@@ -1214,7 +1215,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (g_unitTextureSyncListValid && if (g_unitTextureSyncListValid &&
g_unitTextureSyncListContextId == keys.contextId && g_unitTextureSyncListContextId == keys.contextId &&
g_unitTextureSyncListMaxUnit == maxTouchedUnit && g_unitTextureSyncListMaxUnit == maxTouchedUnit &&
g_unitTextureSyncListContextGeneration == g_textureContextGeneration && g_unitTextureSyncListContextGeneration == g_backendContextGeneration &&
g_unitTextureSyncListEpoch == unitBindingsEpoch && g_unitTextureSyncListEpoch == unitBindingsEpoch &&
g_unitTextureSyncListSamplingGeneration == samplingGeneration && g_unitTextureSyncListSamplingGeneration == samplingGeneration &&
PairingsIntact(g_unitTextureSyncList)) { PairingsIntact(g_unitTextureSyncList)) {
@@ -1246,7 +1247,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
g_unitTextureSyncListContextId = keys.contextId; g_unitTextureSyncListContextId = keys.contextId;
g_unitTextureSyncListMaxUnit = maxTouchedUnit; g_unitTextureSyncListMaxUnit = maxTouchedUnit;
g_unitTextureSyncListContextGeneration = g_textureContextGeneration; g_unitTextureSyncListContextGeneration = g_backendContextGeneration;
g_unitTextureSyncListEpoch = unitBindingsEpoch; g_unitTextureSyncListEpoch = unitBindingsEpoch;
g_unitTextureSyncListSamplingGeneration = samplingGeneration; g_unitTextureSyncListSamplingGeneration = samplingGeneration;
g_unitTextureSyncListValid = true; g_unitTextureSyncListValid = true;
@@ -1274,7 +1275,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_fboTextureSyncListSlotVersion == fboSlotVersion && g_fboTextureSyncListSlotVersion == fboSlotVersion &&
g_fboTextureSyncListObjectVersion == fboObjectVersion && g_fboTextureSyncListObjectVersion == fboObjectVersion &&
g_fboTextureSyncListContextId == keys.contextId && g_fboTextureSyncListContextId == keys.contextId &&
g_fboTextureSyncListContextGeneration == g_textureContextGeneration && g_fboTextureSyncListContextGeneration == g_backendContextGeneration &&
PairingsIntact(g_fboTextureSyncList); PairingsIntact(g_fboTextureSyncList);
if (fboListValid) { if (fboListValid) {
for (const auto& entry : g_fboTextureSyncList) { for (const auto& entry : g_fboTextureSyncList) {
@@ -1302,7 +1303,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_fboTextureSyncListSlotVersion = fboSlotVersion; g_fboTextureSyncListSlotVersion = fboSlotVersion;
g_fboTextureSyncListObjectVersion = fboObjectVersion; g_fboTextureSyncListObjectVersion = fboObjectVersion;
g_fboTextureSyncListContextId = keys.contextId; g_fboTextureSyncListContextId = keys.contextId;
g_fboTextureSyncListContextGeneration = g_textureContextGeneration; g_fboTextureSyncListContextGeneration = g_backendContextGeneration;
} }
} else { } else {
g_fboTextureSyncListFbo = nullptr; g_fboTextureSyncListFbo = nullptr;
@@ -2028,7 +2029,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_currentDrawFrontendProgram = nullptr; g_currentDrawFrontendProgram = nullptr;
g_currentDrawBackendProgram = nullptr; g_currentDrawBackendProgram = nullptr;
if (!currentProgram || !currentProgram->GetLinkStatus()) { // ... || !GetSpirvStatus(): see BackendProgramObjectImpl::SyncToBackend - a
// program whose SPIR-V never arrived is linked but not drawable.
if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) {
g_GLESFuncs.glUseProgram(0); g_GLESFuncs.glUseProgram(0);
g_lastUsedBackendProgramId = 0; g_lastUsedBackendProgramId = 0;
return; return;
@@ -2075,11 +2078,30 @@ namespace MobileGL::MG_Backend::DirectGLES {
// A link-version mismatch means the program was relinked: the backend // A link-version mismatch means the program was relinked: the backend
// shaders and every cache built by CacheResourceLocations (block // shaders and every cache built by CacheResourceLocations (block
// indices, sampler locations, UBO upload gate) are stale. // indices, sampler locations, UBO upload gate) are stale.
//
// The storage-block signature is the same shape of condition: ES cannot move a
// storage block's binding after link, so glShaderStorageBlockBinding is honoured by
// baking the effective binding into the generated ESSL - which makes a program built
// against a different override set stale. It is compared HERE rather than acted on in
// the entry point because that one must never trigger a build (see
// ShaderStorageBlockBinding below). The signature is over the values, so an
// application that re-sets the same bindings every frame rebuilds nothing.
//
// The image-unit generation is a third of the same shape, and it used to be
// carried by accident: glUniform1i on an image uniform bumped the program's backend
// state version, which was in the program-pipeline composite's cache key, so a
// pipeline draw got a whole NEW composite object and therefore a fresh twin. Keying
// that cache on the link version instead (ProgramPipelineObject) removed the
// accident - and it never covered the monolithic glUseProgram path at all - so the
// dependency is stated here instead.
if (!twin->GetBackendProgramId() || if (!twin->GetBackendProgramId() ||
twin->GetSyncedLinkVersion() != currentProgram->GetLinkVersion() || twin->GetSyncedLinkVersion() != currentProgram->GetLinkVersion() ||
twin->GetSyncedImageUnitVersion() != currentProgram->GetImageUnitVersion() ||
twin->GetSnormFallbackClampOutputMask() != g_snormFallbackClampOutputMask || twin->GetSnormFallbackClampOutputMask() != g_snormFallbackClampOutputMask ||
twin->GetUnormFallbackClampOutputMask() != g_unormFallbackClampOutputMask || twin->GetUnormFallbackClampOutputMask() != g_unormFallbackClampOutputMask ||
twin->GetFragColorBroadcastCount() != g_fragColorBroadcastCount) { twin->GetFragColorBroadcastCount() != g_fragColorBroadcastCount ||
twin->GetShaderStorageBlockBindingSignature() !=
ComputeShaderStorageBlockBindingSignature(*currentProgram)) {
twin->SyncToBackend(currentProgram); twin->SyncToBackend(currentProgram);
} }
g_currentDrawFrontendProgram = currentProgram.get(); g_currentDrawFrontendProgram = currentProgram.get();
@@ -2421,7 +2443,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
static_cast<SizeT>(maxTouchedUnit + 1) * sizeof(SamplerImpl::g_boundSamplersCache[0]); static_cast<SizeT>(maxTouchedUnit + 1) * sizeof(SamplerImpl::g_boundSamplersCache[0]);
if (g_unitSamplerWalkValid && g_unitSamplerWalkContextId == keys.contextId && if (g_unitSamplerWalkValid && g_unitSamplerWalkContextId == keys.contextId &&
g_unitSamplerWalkEpoch == keys.unitBindingsEpoch && g_unitSamplerWalkMaxUnit == maxTouchedUnit && g_unitSamplerWalkEpoch == keys.unitBindingsEpoch && g_unitSamplerWalkMaxUnit == maxTouchedUnit &&
g_unitSamplerWalkContextGeneration == TextureImpl::g_textureContextGeneration && g_unitSamplerWalkContextGeneration == g_backendContextGeneration &&
std::memcmp(g_unitSamplerWalkRows.data(), SamplerImpl::g_boundSamplersCache.data(), rowBytes) == 0) { std::memcmp(g_unitSamplerWalkRows.data(), SamplerImpl::g_boundSamplersCache.data(), rowBytes) == 0) {
return; return;
} }
@@ -2442,7 +2464,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_unitSamplerWalkContextId = keys.contextId; g_unitSamplerWalkContextId = keys.contextId;
g_unitSamplerWalkEpoch = keys.unitBindingsEpoch; g_unitSamplerWalkEpoch = keys.unitBindingsEpoch;
g_unitSamplerWalkMaxUnit = maxTouchedUnit; g_unitSamplerWalkMaxUnit = maxTouchedUnit;
g_unitSamplerWalkContextGeneration = TextureImpl::g_textureContextGeneration; g_unitSamplerWalkContextGeneration = g_backendContextGeneration;
std::memcpy(g_unitSamplerWalkRows.data(), SamplerImpl::g_boundSamplersCache.data(), rowBytes); std::memcpy(g_unitSamplerWalkRows.data(), SamplerImpl::g_boundSamplersCache.data(), rowBytes);
g_unitSamplerWalkValid = true; g_unitSamplerWalkValid = true;
} }
@@ -2552,7 +2574,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
memo.programBackendStateVersion == memo.programBackendStateVersion ==
(currentProgram ? currentProgram->GetBackendStateVersion() : 0) && (currentProgram ? currentProgram->GetBackendStateVersion() : 0) &&
memo.programLinked == (currentProgram && currentProgram->GetLinkStatus()) && memo.programLinked == (currentProgram && currentProgram->GetLinkStatus()) &&
memo.contextGeneration == TextureImpl::g_textureContextGeneration; memo.contextGeneration == g_backendContextGeneration;
// Short-circuited: the shadow compare is only meaningful once the key (and with it the // Short-circuited: the shadow compare is only meaningful once the key (and with it the
// snapshotted row count) matches. // snapshotted row count) matches.
if (!keysMatch || std::memcmp(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(), if (!keysMatch || std::memcmp(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(),
@@ -2567,7 +2589,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
memo.programLifetimeId = currentProgram ? currentProgram->GetLifetimeId() : 0; memo.programLifetimeId = currentProgram ? currentProgram->GetLifetimeId() : 0;
memo.programBackendStateVersion = currentProgram ? currentProgram->GetBackendStateVersion() : 0; memo.programBackendStateVersion = currentProgram ? currentProgram->GetBackendStateVersion() : 0;
memo.programLinked = currentProgram && currentProgram->GetLinkStatus(); memo.programLinked = currentProgram && currentProgram->GetLinkStatus();
memo.contextGeneration = TextureImpl::g_textureContextGeneration; memo.contextGeneration = g_backendContextGeneration;
std::memcpy(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(), shadowBytes); std::memcpy(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(), shadowBytes);
memo.valid = true; memo.valid = true;
} }
@@ -2589,7 +2611,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
static void BindCurrentProgramWithResources( static void BindCurrentProgramWithResources(
const SharedPtr<MG_State::GLState::ProgramObject>& currentProgram, const SharedPtr<MG_State::GLState::ProgramObject>& currentProgram,
const TextureImpl::DrawTextureSyncKeys& keys) { const TextureImpl::DrawTextureSyncKeys& keys) {
if (currentProgram && currentProgram->GetLinkStatus()) { if (currentProgram && currentProgram->GetLinkStatus() && currentProgram->GetSpirvStatus()) {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
ZoneScopedNC("BindCurrentProgram", TRACY_ZONECOLOR_BACKEND); ZoneScopedNC("BindCurrentProgram", TRACY_ZONECOLOR_BACKEND);
#endif #endif
@@ -2742,7 +2764,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
samplerPassMemo.unitBindingsEpoch == keys.unitBindingsEpoch && samplerPassMemo.unitBindingsEpoch == keys.unitBindingsEpoch &&
samplerPassMemo.samplingGeneration == keys.samplingGeneration && samplerPassMemo.samplingGeneration == keys.samplingGeneration &&
samplerPassMemo.backendStateVersion == programBackendStateVersion && samplerPassMemo.backendStateVersion == programBackendStateVersion &&
samplerPassMemo.textureContextGeneration == TextureImpl::g_textureContextGeneration; samplerPassMemo.textureContextGeneration == g_backendContextGeneration;
if (samplerPassClean) { if (samplerPassClean) {
for (Uint i = 0; i < samplerPassMemo.count; ++i) { for (Uint i = 0; i < samplerPassMemo.count; ++i) {
if (SamplerImpl::g_boundSamplersCache[samplerPassMemo.units[i]] != if (SamplerImpl::g_boundSamplersCache[samplerPassMemo.units[i]] !=
@@ -2841,7 +2863,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
samplerPassMemo.unitBindingsEpoch = keys.unitBindingsEpoch; samplerPassMemo.unitBindingsEpoch = keys.unitBindingsEpoch;
samplerPassMemo.samplingGeneration = keys.samplingGeneration; samplerPassMemo.samplingGeneration = keys.samplingGeneration;
samplerPassMemo.backendStateVersion = programBackendStateVersion; samplerPassMemo.backendStateVersion = programBackendStateVersion;
samplerPassMemo.textureContextGeneration = TextureImpl::g_textureContextGeneration; samplerPassMemo.textureContextGeneration = g_backendContextGeneration;
samplerPassMemo.valid = true; samplerPassMemo.valid = true;
} }
} }
@@ -2859,7 +2881,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// is pinned for the duration. Prefers the per-draw stash those preparations wrote. // is pinned for the duration. Prefers the per-draw stash those preparations wrote.
static PrgramImpl::BackendProgramObjectImpl* GetCurrentBackendProgram() { static PrgramImpl::BackendProgramObjectImpl* GetCurrentBackendProgram() {
const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw();
if (!currentProgram || !currentProgram->GetLinkStatus()) { if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) {
return nullptr; return nullptr;
} }
if (PrgramImpl::g_currentDrawFrontendProgram == currentProgram.get()) { if (PrgramImpl::g_currentDrawFrontendProgram == currentProgram.get()) {
@@ -3008,8 +3030,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif #endif
// Single per-dispatch program resolve and texture-key capture, as in // Single per-dispatch program resolve and texture-key capture, as in
// PrepareForDraw (nothing below can move either). // PrepareForDraw (nothing below can move either). The DISPATCH accessor: with a
const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); // pipeline bound this is its compute stage program, which is a whole program on its
// own - the graphics composite a draw builds carries no compute stage.
const auto& currentProgram = MG_State::pGLContext->GetProgramForDispatch();
const TextureImpl::DrawTextureSyncKeys textureKeys = TextureImpl::CaptureDrawTextureSyncKeys(); const TextureImpl::DrawTextureSyncKeys textureKeys = TextureImpl::CaptureDrawTextureSyncKeys();
BufferImpl::SyncComputeBuffers(includeDispatchIndirectBuffer); BufferImpl::SyncComputeBuffers(includeDispatchIndirectBuffer);
@@ -3017,7 +3041,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
TextureImpl::SyncImageTextureBindings(); TextureImpl::SyncImageTextureBindings();
PrgramImpl::SyncCurrentProgram(currentProgram); PrgramImpl::SyncCurrentProgram(currentProgram);
if (!currentProgram || !currentProgram->GetLinkStatus()) { if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) {
g_GLESFuncs.glUseProgram(0); g_GLESFuncs.glUseProgram(0);
PrgramImpl::g_lastUsedBackendProgramId = 0; PrgramImpl::g_lastUsedBackendProgramId = 0;
return; return;
@@ -3603,12 +3627,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
return false; return false;
} }
if (s_resolveContextGeneration != TextureImpl::g_textureContextGeneration) { if (s_resolveContextGeneration != g_backendContextGeneration) {
// The ids belonged to a dead context; the context reclaimed them with it. // The ids belonged to a dead context; the context reclaimed them with it.
s_resolveFramebuffer = 0; s_resolveFramebuffer = 0;
s_resolveRenderbuffer = 0; s_resolveRenderbuffer = 0;
s_resolveFormat = 0; s_resolveFormat = 0;
s_resolveContextGeneration = TextureImpl::g_textureContextGeneration; s_resolveContextGeneration = g_backendContextGeneration;
} }
if (s_resolveFramebuffer == 0) { if (s_resolveFramebuffer == 0) {
g_GLESFuncs.glGenFramebuffers(1, &s_resolveFramebuffer); g_GLESFuncs.glGenFramebuffers(1, &s_resolveFramebuffer);
@@ -3756,7 +3780,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
static Bool EnsureResources() { static Bool EnsureResources() {
if (s_contextGeneration != TextureImpl::g_textureContextGeneration) { if (s_contextGeneration != g_backendContextGeneration) {
// The ids belonged to a dead context; the context reclaimed them with it. // The ids belonged to a dead context; the context reclaimed them with it.
s_framebuffer = 0; s_framebuffer = 0;
s_texture = 0; s_texture = 0;
@@ -3767,7 +3791,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
s_depthProgram = 0; s_depthProgram = 0;
s_stencilProgram = 0; s_stencilProgram = 0;
s_programsFailed = false; s_programsFailed = false;
s_contextGeneration = TextureImpl::g_textureContextGeneration; s_contextGeneration = g_backendContextGeneration;
} }
if (s_programsFailed) { if (s_programsFailed) {
return false; return false;
@@ -5149,8 +5173,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
const SharedPtr<MG_State::GLState::ITextureObject>& dstTexture, const SharedPtr<MG_State::GLState::ITextureObject>& dstTexture,
GLenum dstTarget, GLint dstLevel, GLint dstX, GLint dstY, GLint dstZ, GLenum dstTarget, GLint dstLevel, GLint dstX, GLint dstY, GLint dstZ,
GLsizei srcWidth, GLsizei srcHeight, GLsizei srcDepth) { GLsizei srcWidth, GLsizei srcHeight, GLsizei srcDepth) {
auto& srcBackendTexture = TextureImpl::SyncTextureObjectToBackend(srcTexture); // BY VALUE, not by reference. SyncTextureObjectToBackend hands back a reference to a
auto& dstBackendTexture = TextureImpl::SyncTextureObjectToBackend(dstTexture); // slot inside the backend texture registry, and the second call mutates that very map:
// GetOrCreate indexes it (an insert relocates entries - by rehashing, and also by
// robin-hood displacement well under the load factor), and Find drops any
// entry whose state object has expired - which, with the map open-addressed and erasing
// by shifting the probe cluster backwards, relocates entries other than the erased one.
// Either way a reference taken by the first call is stale by the time the second returns,
// and it is read four more times below. Copying the SharedPtr costs two refcount bumps on
// a path that is already doing a texture copy.
const SharedPtr<TextureImpl::BackendTextureObject> srcBackendTexture =
TextureImpl::SyncTextureObjectToBackend(srcTexture);
const SharedPtr<TextureImpl::BackendTextureObject> dstBackendTexture =
TextureImpl::SyncTextureObjectToBackend(dstTexture);
const Bool srcIsDepth = MG_Util::IsDepthFormatInternalFormat(srcTexture->GetFormat()); const Bool srcIsDepth = MG_Util::IsDepthFormatInternalFormat(srcTexture->GetFormat());
const Bool dstIsDepth = MG_Util::IsDepthFormatInternalFormat(dstTexture->GetFormat()); const Bool dstIsDepth = MG_Util::IsDepthFormatInternalFormat(dstTexture->GetFormat());
@@ -7198,6 +7233,83 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_GLESFuncs.glGetQueryObjectui64vEXT; g_GLESFuncs.glGetQueryObjectui64vEXT;
} }
namespace {
// The entry point the resolved tier's support ships, or null when there is none.
MG_External::GLES::glTexBuffer_PTR ResolveTexBufferEntryPoint() {
using Tier = MG_External::GLESCapabilities::TextureBufferTier;
switch (g_GLESCapabilities.TextureBufferSupport) {
case Tier::ExtensionEXT:
return g_GLESFuncs.glTexBufferEXT ? g_GLESFuncs.glTexBufferEXT : g_GLESFuncs.glTexBuffer;
case Tier::ExtensionOES:
return g_GLESFuncs.glTexBufferOES ? g_GLESFuncs.glTexBufferOES : g_GLESFuncs.glTexBuffer;
case Tier::CoreEs32:
return g_GLESFuncs.glTexBuffer;
case Tier::None:
default:
return nullptr;
}
}
MG_External::GLES::glTexBufferRange_PTR ResolveTexBufferRangeEntryPoint() {
using Tier = MG_External::GLESCapabilities::TextureBufferTier;
switch (g_GLESCapabilities.TextureBufferSupport) {
case Tier::ExtensionEXT:
return g_GLESFuncs.glTexBufferRangeEXT ? g_GLESFuncs.glTexBufferRangeEXT
: g_GLESFuncs.glTexBufferRange;
case Tier::ExtensionOES:
return g_GLESFuncs.glTexBufferRangeOES ? g_GLESFuncs.glTexBufferRangeOES
: g_GLESFuncs.glTexBufferRange;
case Tier::CoreEs32:
return g_GLESFuncs.glTexBufferRange;
case Tier::None:
default:
return nullptr;
}
}
} // namespace
Bool AreBufferTexturesSupported() {
// Both halves matter. The tier is what the driver ADVERTISES, and it is only meaningful
// once the capabilities have been filled in; the resolved pointer is what MobileGL can
// actually call, through the spelling that tier's support ships. Gating on the
// unsuffixed name alone would call an entry point an EXT/OES driver never exported.
return g_GLESCapabilities.TextureBufferSupport !=
MG_External::GLESCapabilities::TextureBufferTier::None &&
ResolveTexBufferEntryPoint() != nullptr;
}
void CallTexBuffer(GLenum target, GLenum internalFormat, GLuint buffer) {
MG_External::GLES::glTexBuffer_PTR entryPoint = ResolveTexBufferEntryPoint();
if (entryPoint == nullptr) {
return;
}
entryPoint(target, internalFormat, buffer);
}
Bool CallTexBufferRange(GLenum target, GLenum internalFormat, GLuint buffer, GLintptr offset, GLsizeiptr size) {
MG_External::GLES::glTexBufferRange_PTR entryPoint = ResolveTexBufferRangeEntryPoint();
if (entryPoint == nullptr) {
return false;
}
entryPoint(target, internalFormat, buffer, offset, size);
return true;
}
const char* GetBufferTextureTierName() {
using Tier = MG_External::GLESCapabilities::TextureBufferTier;
switch (g_GLESCapabilities.TextureBufferSupport) {
case Tier::CoreEs32:
return "core (ES 3.2)";
case Tier::ExtensionEXT:
return "GL_EXT_texture_buffer";
case Tier::ExtensionOES:
return "GL_OES_texture_buffer";
case Tier::None:
default:
return "unsupported";
}
}
BackendQueryHandle BeginTimeElapsedQuery() { BackendQueryHandle BeginTimeElapsedQuery() {
// Query objects can only be created on the thread that owns the ES // Query objects can only be created on the thread that owns the ES
// context (MC's F3 profiler queries on the render thread, which // context (MC's F3 profiler queries on the render thread, which
@@ -7480,7 +7592,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
PixelStoreImpl::InvalidatePackStateCache(); PixelStoreImpl::InvalidatePackStateCache();
// Texture ids belong to the dying context; wrappers destroyed later must // Texture ids belong to the dying context; wrappers destroyed later must
// not glDeleteTextures a recycled name in a successor context. // not glDeleteTextures a recycled name in a successor context.
++TextureImpl::g_textureContextGeneration; ++g_backendContextGeneration;
g_backendContextOwnerThread.store(std::thread::id{}, std::memory_order_release); g_backendContextOwnerThread.store(std::thread::id{}, std::memory_order_release);
// Outstanding fence handles now refer to a dead context; treat them as // Outstanding fence handles now refer to a dead context; treat them as
// signaled from here on. // signaled from here on.
@@ -117,6 +117,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
// capability read needs no current ES context, and it stays false until // capability read needs no current ES context, and it stays false until
// the ES capabilities have been filled in. // the ES capabilities have been filled in.
Bool AreTimerQueriesSupported(); 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 // GL timer-query objects, backed by GL_EXT_disjoint_timer_query. The
// creators return null (the frontend then falls back to an immediately // creators return null (the frontend then falls back to an immediately
// available zero result) when the calling thread does not own the ES // available zero result) when the calling thread does not own the ES
+305 -28
View File
@@ -33,6 +33,8 @@
#include <regex> #include <regex>
namespace MobileGL::MG_Backend::DirectGLES { namespace MobileGL::MG_Backend::DirectGLES {
Uint g_backendContextGeneration = 1;
constexpr Bool PREFER_MAP_BUFFER_RANGE_FOR_BUFFER_SYNC = false; constexpr Bool PREFER_MAP_BUFFER_RANGE_FOR_BUFFER_SYNC = false;
constexpr const char* BASE_INSTANCE_UNIFORM_NAME = "mg_BaseInstance"; constexpr const char* BASE_INSTANCE_UNIFORM_NAME = "mg_BaseInstance";
constexpr const char* DRAW_ID_UNIFORM_NAME = "mg_DrawID"; constexpr const char* DRAW_ID_UNIFORM_NAME = "mg_DrawID";
@@ -53,6 +55,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
return g_GLESCapabilities.AvoidSamplerMipmapMinFilter; return g_GLESCapabilities.AvoidSamplerMipmapMinFilter;
} }
static Bool ShouldAvoidExplicitLodBiasOnAngleLlvmpipe() {
// IsAngleLlvmpipeRenderer combined with the MOBILEGL_AVOID_EXPLICIT_LOD_BIAS
// feature toggle, both resolved in FillInGLESCapabilities.
return g_GLESCapabilities.AvoidExplicitLodBias;
}
static GLenum ResolveBackendMinFilter(const SamplerParameters& samplerParams, static GLenum ResolveBackendMinFilter(const SamplerParameters& samplerParams,
Bool avoidMipmapMinFilter) { Bool avoidMipmapMinFilter) {
GLenum filter = MG_Util::ConvertSamplerFilterModeToGLEnum(samplerParams.minFilter, GLenum filter = MG_Util::ConvertSamplerFilterModeToGLEnum(samplerParams.minFilter,
@@ -1646,7 +1654,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif #endif
g_GLESFuncs.glGenTextures(1, &m_backendTextureId); g_GLESFuncs.glGenTextures(1, &m_backendTextureId);
m_contextGeneration = g_textureContextGeneration; m_contextGeneration = g_backendContextGeneration;
if (m_backendTextureId == 0) { if (m_backendTextureId == 0) {
MGLOG_E("Failed to generate texture object."); MGLOG_E("Failed to generate texture object.");
MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str());
@@ -1673,7 +1681,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
} }
} }
if (m_contextGeneration == g_textureContextGeneration && g_GLESFuncs.glDeleteTextures) { if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteTextures) {
g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId); g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId);
} }
m_backendTextureId = 0; m_backendTextureId = 0;
@@ -1712,7 +1720,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
void BackendTextureObject::RecreateBackendTexture() { void BackendTextureObject::RecreateBackendTexture() {
if (m_backendTextureId != 0) { if (m_backendTextureId != 0) {
ScratchFBOImpl::NoteTextureIdDeleted(m_backendTextureId); ScratchFBOImpl::NoteTextureIdDeleted(m_backendTextureId);
if (m_contextGeneration == g_textureContextGeneration) { if (m_contextGeneration == g_backendContextGeneration) {
g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId); g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId);
} }
for (auto& unitCache : g_boundTexturesCache) { for (auto& unitCache : g_boundTexturesCache) {
@@ -1725,7 +1733,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
g_GLESFuncs.glGenTextures(1, &m_backendTextureId); g_GLESFuncs.glGenTextures(1, &m_backendTextureId);
m_contextGeneration = g_textureContextGeneration; m_contextGeneration = g_backendContextGeneration;
if (m_backendTextureId == 0) { if (m_backendTextureId == 0) {
MGLOG_E("Failed to regenerate texture object."); MGLOG_E("Failed to regenerate texture object.");
MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str());
@@ -2777,6 +2785,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
&glType, TextureTarget::TextureBuffer); &glType, TextureTarget::TextureBuffer);
if (needsRegeneration) { if (needsRegeneration) {
// Desktop GL has had buffer textures core since 3.1 and MobileGL advertises a
// 4.x context, so glTexBuffer is a legal call the app may make on any driver -
// but ES only gained them in 3.2, and g_GLESFuncs.glTexBuffer is simply null
// below that without EXT/OES_texture_buffer. Calling it was an unconditional
// null dereference. There is no conformant way to refuse the call (it is valid
// in the context MobileGL claims), so the texture is left unbacked and the
// reason is stated once per respecify at a level that survives the shipped
// INFO build - MGLOG_E is compiled out there, which is exactly how this class
// of defect stays invisible.
if (!AreBufferTexturesSupported()) {
if (m_bufferTextureUnsupportedReported) {
break;
}
m_bufferTextureUnsupportedReported = true;
MGLOG_I("Texture buffer %u cannot be backed: this ES driver has no buffer "
"textures (%s). Every draw sampling it will read zero and every "
"shader declaring a samplerBuffer will fail to compile. MobileGL "
"still advertises GL_MAX_TEXTURE_BUFFER_SIZE = %d because an "
"OpenGL 4.x context may not report 0.",
stateTextureObject->GetExternalIndex(), GetBufferTextureTierName(),
g_GLESCapabilities.MaxTextureBufferSize);
break;
}
MGLOG_D("Texture state changed significantly or not initialized, regenerating texture buffer with " MGLOG_D("Texture state changed significantly or not initialized, regenerating texture buffer with "
"ID: %u, buffer ID: %u, buffer size: %zu, format: %s", "ID: %u, buffer ID: %u, buffer size: %zu, format: %s",
m_backendTextureId, backendId, buffer->GetSize(), m_backendTextureId, backendId, buffer->GetSize(),
@@ -2787,17 +2818,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
// is absent). // is absent).
const SizeT rangeOffset = textureBufferObject->GetBufferRangeOffset(); const SizeT rangeOffset = textureBufferObject->GetBufferRangeOffset();
const SizeT rangeSize = textureBufferObject->GetBufferRangeSizeInBytes(); const SizeT rangeSize = textureBufferObject->GetBufferRangeSizeInBytes();
// Through CallTexBuffer/CallTexBufferRange rather than g_GLESFuncs directly:
// the unsuffixed entry points are the ES 3.2 core spelling, and a driver
// whose buffer textures come from EXT/OES_texture_buffer exports the
// suffixed ones instead. The dispatchers pick whichever this tier ships.
if (rangeOffset == 0 && rangeSize == buffer->GetSize()) { if (rangeOffset == 0 && rangeSize == buffer->GetSize()) {
g_GLESFuncs.glTexBuffer(GL_TEXTURE_BUFFER, glInternalFormat, backendId); CallTexBuffer(GL_TEXTURE_BUFFER, glInternalFormat, backendId);
} else if (g_GLESFuncs.glTexBufferRange != nullptr) { } else if (!CallTexBufferRange(GL_TEXTURE_BUFFER, glInternalFormat, backendId,
g_GLESFuncs.glTexBufferRange(GL_TEXTURE_BUFFER, glInternalFormat, backendId, static_cast<GLintptr>(rangeOffset),
static_cast<GLintptr>(rangeOffset), static_cast<GLsizeiptr>(rangeSize))) {
static_cast<GLsizeiptr>(rangeSize)); MGLOG_I("Texture buffer %u names a sub-range but the driver has no "
} else {
MGLOG_E("Texture buffer %u names a sub-range but the driver has no "
"glTexBufferRange; binding the whole buffer instead", "glTexBufferRange; binding the whole buffer instead",
stateTextureObject->GetExternalIndex()); stateTextureObject->GetExternalIndex());
g_GLESFuncs.glTexBuffer(GL_TEXTURE_BUFFER, glInternalFormat, backendId); CallTexBuffer(GL_TEXTURE_BUFFER, glInternalFormat, backendId);
} }
DebugImpl::ErrorLopper::Loop( DebugImpl::ErrorLopper::Loop(
[file = __FILE__, line = __LINE__, func = __func__, glInternalFormat, backendId](GLenum err) { [file = __FILE__, line = __LINE__, func = __func__, glInternalFormat, backendId](GLenum err) {
@@ -2809,7 +2842,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; // TextureStorageType is {Mipmap, Buffer}, both handled above, so this is a
// backstop for a state object that grew a new storage kind. Skipping the upload
// renders wrong; throwing unwinds through the C GL ABI and kills the process.
MGLOG_I("DirectGLES texture sync: no upload path for storage type %d on texture %u; "
"skipping this sync",
static_cast<int>(stateTextureObject->GetStorageType()),
stateTextureObject->GetExternalIndex());
break;
} }
DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__, func = __func__](GLenum err) { DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__, func = __func__](GLenum err) {
@@ -3074,7 +3114,6 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
Uint g_activeTextureUnit = 0; Uint g_activeTextureUnit = 0;
Uint g_textureContextGeneration = 1;
Array<Array<BackendTextureObject*, (SizeT)TextureTarget::TextureTargetCount>, Array<Array<BackendTextureObject*, (SizeT)TextureTarget::TextureTargetCount>,
MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS> MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
g_boundTexturesCache; g_boundTexturesCache;
@@ -3093,6 +3132,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
m_backendColorSlots[i] = GL_COLOR_ATTACHMENT0 + i; m_backendColorSlots[i] = GL_COLOR_ATTACHMENT0 + i;
} }
g_GLESFuncs.glGenFramebuffers(1, &m_backendFBOId); g_GLESFuncs.glGenFramebuffers(1, &m_backendFBOId);
m_contextGeneration = g_backendContextGeneration;
if (m_backendFBOId == 0) { if (m_backendFBOId == 0) {
MGLOG_E("Failed to generate framebuffer object."); MGLOG_E("Failed to generate framebuffer object.");
MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str());
@@ -3101,6 +3141,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
} }
BackendFramebufferObject::~BackendFramebufferObject() {
if (InProcessTeardown()) {
return; // see InProcessTeardown(): the driver may be unloaded already
}
if (m_backendFBOId == 0) {
return;
}
// Scrub the binding shadow whether or not the id can still be deleted: a
// recycled name must never satisfy the shadow's dedup.
NoteFramebufferIdDeleted(m_backendFBOId);
if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteFramebuffers) {
g_GLESFuncs.glDeleteFramebuffers(1, &m_backendFBOId);
}
m_backendFBOId = 0;
}
void BackendFramebufferObject::Bind(FramebufferTarget target) const { void BackendFramebufferObject::Bind(FramebufferTarget target) const {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
@@ -3156,6 +3212,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
return g_driverFBOBindings[idx]; return g_driverFBOBindings[idx];
} }
void NoteFramebufferIdDeleted(Uint id) {
if (id == 0) {
return;
}
for (SizeT idx = 0; idx < g_driverFBOBindings.size(); ++idx) {
if (g_driverFBOBindingKnown[idx] && g_driverFBOBindings[idx] == id) {
g_driverFBOBindings[idx] = 0; // glDeleteFramebuffers reverts a bound FBO to 0
}
}
}
void InvalidateFramebufferBindingCache() { void InvalidateFramebufferBindingCache() {
g_driverFBOBindings = {0, 0}; g_driverFBOBindings = {0, 0};
g_driverFBOBindingKnown = {false, false}; g_driverFBOBindingKnown = {false, false};
@@ -4146,6 +4213,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
} }
Uint64 ComputeShaderStorageBlockBindingSignature(
const MG_State::GLState::ProgramObject& stateProgramObject) {
const auto& overrides = stateProgramObject.GetShaderStorageBlockBindingOverrides();
if (overrides.empty()) return 0; // the overwhelming majority of programs
// Order-independent on purpose: the source is an UnorderedMap, so any signature that
// depended on iteration order would differ between two identical override sets and
// rebuild the program for nothing.
//
// Built from the VALUES, not from a change counter, so re-setting a block to the
// binding it already carries produces the same signature and forces no rebuild - an
// application that calls glShaderStorageBlockBinding every frame with unchanged
// arguments must not retranspile every frame.
Uint64 signature = 0;
for (const auto& [blockName, binding] : overrides) {
if (binding < 0) continue; // never rebound; the declared qualifier still stands
Uint64 entry = std::hash<String>{}(blockName);
// Mixed rather than merely summed with the name hash: name and binding must not
// be able to trade places between two entries and cancel out.
entry ^= (static_cast<Uint64>(static_cast<Uint32>(binding)) + 0x9e3779b97f4a7c15ull +
(entry << 6) + (entry >> 2));
signature += entry; // commutative combine
}
return signature;
}
void BackendProgramObjectImpl::SyncToBackend( void BackendProgramObjectImpl::SyncToBackend(
const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject) { const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject) {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
@@ -4155,9 +4247,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
MGLOG_E("State program object is null, skipping backend sync."); MGLOG_E("State program object is null, skipping backend sync.");
return; return;
} }
// Recorded before either early return below, so Use() can always name the GL
// program a no-op draw belongs to - including the "linked but not drawable" exit.
m_frontendProgramId = stateProgramObject->GetExternalIndex();
if (!stateProgramObject->GetLinkStatus()) { // GetSpirvStatus() as well as GetLinkStatus(): a program whose phase-B job was
MGLOG_E("Program object is not linked, skipping backend sync. State program ID: %u", // cancelled (teardown) or whose optimizer run failed is fully linked and fully
// queryable, but has no SPIR-V to build a driver program out of. GL cannot retract
// a LINK_STATUS it already reported true, so "linked but not drawable" is the
// answer, and this is where the ES backend expresses it.
if (!stateProgramObject->GetLinkStatus() || !stateProgramObject->GetSpirvStatus()) {
MGLOG_E("Program object is not linked or has no generated SPIR-V, skipping backend sync. State "
"program ID: %u",
stateProgramObject->GetExternalIndex()); stateProgramObject->GetExternalIndex());
return; return;
} }
@@ -4172,6 +4273,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
m_snormFallbackClampOutputMask = g_snormFallbackClampOutputMask; m_snormFallbackClampOutputMask = g_snormFallbackClampOutputMask;
m_unormFallbackClampOutputMask = g_unormFallbackClampOutputMask; m_unormFallbackClampOutputMask = g_unormFallbackClampOutputMask;
m_fragColorBroadcastCount = g_fragColorBroadcastCount; m_fragColorBroadcastCount = g_fragColorBroadcastCount;
// The generated ESSL bakes these in (see the SetShaderStorageBlockBinding call in the
// transpile loop below), so the set they were generated against is part of what makes
// this build current - the draw path compares the signature and rebuilds on a change.
const auto& storageBlockBindingOverrides = stateProgramObject->GetShaderStorageBlockBindingOverrides();
m_shaderStorageBlockBindingSignature = ComputeShaderStorageBlockBindingSignature(*stateProgramObject);
// Detach all existing shaders // Detach all existing shaders
GLint attachedCount = 0; GLint attachedCount = 0;
@@ -4222,6 +4328,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
String source; String source;
auto& spirvCode = shaderSpirvs[index]; auto& spirvCode = shaderSpirvs[index];
// A samplerBuffer is core in the OpenGL 3.1+ context MobileGL advertises but needs
// ES 3.2 or EXT/OES_texture_buffer on the host. Without it SPIRV-Cross emits
// `#extension GL_EXT_texture_buffer : require` and the driver rejects both that
// and the isamplerBuffer keyword - the program never links and every draw using it
// becomes a silent no-op. Say so here, naming the stage, instead of leaving a
// driver info log the shipped INFO build compiles out (MGLOG_E is inactive there).
// Gated on the capability so the module walk never runs on a healthy driver.
if (!AreBufferTexturesSupported() &&
MG_Util::ShaderTranspiler::ShaderCompiler::ModuleDeclaresBufferTextureSampler(spirvCode)) {
MGLOG_I("Program %u stage %s samples a buffer texture, which this ES driver "
"cannot provide (%s). The shader will not compile and the program will "
"not link; every draw using it is a no-op.",
m_backendProgramId,
MG_Util::ConvertGLEnumToString(glShaderType).c_str(),
GetBufferTextureTierName());
m_backendProgramUsable = false;
g_GLESFuncs.glDeleteShader(backendShaderId);
continue;
}
// ESSL cannot express gl_DrawID/gl_BaseInstance/gl_BaseVertex; demote them to // ESSL cannot express gl_DrawID/gl_BaseInstance/gl_BaseVertex; demote them to
// plain globals (mg_*) before handing the module to SPIRV-Cross. // plain globals (mg_*) before handing the module to SPIRV-Cross.
Vector<unsigned int> loweredSpirv; Vector<unsigned int> loweredSpirv;
@@ -4271,6 +4397,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
effectiveSpirv = &rectLoweredSpirv; effectiveSpirv = &rectLoweredSpirv;
} }
// GLSL ES demands a constant integral expression to index a fragment output
// array; SPIR-V does not, so a shader that writes coeff[i] from a loop
// reaches SPIRV-Cross intact and comes out as ESSL a strict driver rejects
// outright ("array indexes for fragment outputs must be constant integral
// expressions"), linking no program and silently no-oping every draw that
// uses it. Mesa accepts it, ANGLE does not - which is the whole of the
// improved-transparency-minecraft-26.3 failure. Fold or lower the index here,
// on the ESSL path only: the same module is legal for DirectVulkan.
Vector<unsigned int> outputIndexSpirv;
if (glShaderType == GL_FRAGMENT_SHADER &&
MG_Util::ShaderTranspiler::ShaderCompiler::LegalizeFragmentOutputIndexingForEssl(
*effectiveSpirv, outputIndexSpirv) &&
!outputIndexSpirv.empty()) {
effectiveSpirv = &outputIndexSpirv;
}
MG_Util::ShaderTranspiler::SpvcSession spvcSession(*effectiveSpirv, MG_Util::ShaderTranspiler::SpvcSession spvcSession(*effectiveSpirv,
MG_Util::ShaderTranspiler::SessionUsageBit::Transpile); MG_Util::ShaderTranspiler::SessionUsageBit::Transpile);
@@ -4284,6 +4426,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
spvcSession.SetOptions(options); spvcSession.SetOptions(options);
// ES fixes a storage block's binding at link from its layout(binding=) qualifier
// and has no glShaderStorageBlockBinding to move it afterwards, so a rebinding
// can only be honoured by printing it INTO the qualifier. Rewriting the Binding
// decoration before SPIRV-Cross emits is what does that; RemoveLayoutBinding
// then deliberately preserves the qualifier for `buffer` declarations.
if (!storageBlockBindingOverrides.empty()) { // empty for almost every program
spvcSession.SetShaderStorageBlockBinding(storageBlockBindingOverrides);
}
const char* result = nullptr; const char* result = nullptr;
spvcSession.Compile(&result); spvcSession.Compile(&result);
@@ -4299,12 +4450,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
source = result; source = result;
// Position in the chain is arbitrary: this is the only header-level rewrite, it
// edits #extension directives and never the body, and the replacement is the
// same length and stays an #extension line - so it commutes with every pass
// below, including ForceSupporterOutput's scan for the last directive. First,
// because a header concern reads better before the body ones.
source = RetargetTextureBufferExtension(std::move(source),
g_GLESCapabilities.TextureBufferSupport);
source = RebindImageUniformsToFrontendUnits(std::move(source), stateProgramObject); source = RebindImageUniformsToFrontendUnits(std::move(source), stateProgramObject);
// Wedged between those two on purpose:
// * AFTER RebindImageUniformsToFrontendUnits, so the binding it copies onto
// both halves of a split image is already the frontend texture unit (and so
// that pass never has to reason about the alias it introduces);
// * BEFORE RemoveLayoutBinding, whose keepBindingRegex recognises an image
// declaration and preserves its binding - an image unit cannot be set from
// the API in ES, so the qualifier is the only binding mechanism there is,
// and both halves of the pair have to still be carrying theirs when it runs.
source = SplitReadWriteImageUniforms(source);
source = RemoveLayoutBinding(source); source = RemoveLayoutBinding(source);
source = ProcessOutColorLocations(source); source = ProcessOutColorLocations(source);
source = ForceFlatIntegerVaryings(source, glShaderType); source = ForceFlatIntegerVaryings(source, glShaderType);
source = BroadcastLegacyFragColor(std::move(source), glShaderType, m_fragColorBroadcastCount); source = BroadcastLegacyFragColor(std::move(source), glShaderType, m_fragColorBroadcastCount);
source = EmulateTextureLodBias(source); source = EmulateTextureLodBias(source, ShouldAvoidExplicitLodBiasOnAngleLlvmpipe());
source = EmulateBaseInstanceInVertexShader(std::move(source), glShaderType); source = EmulateBaseInstanceInVertexShader(std::move(source), glShaderType);
source = PromoteDrawParameterGlobalsToUniforms(std::move(source), glShaderType); source = PromoteDrawParameterGlobalsToUniforms(std::move(source), glShaderType);
source = ForceSupporterOutput(source); source = ForceSupporterOutput(source);
@@ -4342,13 +4510,41 @@ namespace MobileGL::MG_Backend::DirectGLES {
Vector<GLchar> log(static_cast<SizeT>(logLength) + 1, '\0'); Vector<GLchar> log(static_cast<SizeT>(logLength) + 1, '\0');
g_GLESFuncs.glGetShaderInfoLog(backendShaderId, logLength, nullptr, log.data()); g_GLESFuncs.glGetShaderInfoLog(backendShaderId, logLength, nullptr, log.data());
log.back() = '\0'; log.back() = '\0';
MGLOG_E("Shader compilation failed for backend ID %u: %s", backendShaderId, log.data()); // MGLOG_I, deliberately. Every CI, retrace and release build compiles at
// MOBILEGL_LOG_LEVEL_INFO, where MGLOG_E and MGLOG_W expand to nothing
// (Log.h orders DEBUG < WARN < ERROR < INFO), so this diagnostic used to
// exist only in debug builds: the Android retrace artifact carried 294
// INFO lines and zero ERROR lines while two generated shaders were being
// rejected outright, and the lane could not say why it was rendering an
// empty translucent layer. A shader the driver refuses is never noise.
MGLOG_I("Shader compilation failed. State program ID: %u, stage: %s, backend shader ID: "
"%u, driver log: %s",
stateProgramObject->GetExternalIndex(),
MG_Util::ConvertGLEnumToString(glShaderType).c_str(), backendShaderId,
log.data());
m_backendProgramUsable = false; m_backendProgramUsable = false;
// Nothing will ever attach this one, so nothing else can free it.
g_GLESFuncs.glDeleteShader(backendShaderId);
continue; continue;
} }
MGLOG_D("Attaching shader ID: %u to program %u", backendShaderId, m_backendProgramId); MGLOG_D("Attaching shader ID: %u to program %u", backendShaderId, m_backendProgramId);
g_GLESFuncs.glAttachShader(m_backendProgramId, backendShaderId); g_GLESFuncs.glAttachShader(m_backendProgramId, backendShaderId);
// Hand the shader's lifetime to the program, immediately and unconditionally.
//
// glDeleteShader only FLAGS a shader; the driver frees it when it is attached to
// nothing. Flagging it here is what makes the program own it, so deleting the
// program (or the detach loop above, on a relink) is what actually frees it.
// Without this call every program build leaked its shader objects for the process
// lifetime, and a relink leaked them twice - the detach loop above dropped the
// program's reference to shaders nothing had flagged, so they became unreachable
// AND undeletable. The GL swizzle conformance test builds 1,296 programs per case,
// so a handful of cases left tens of thousands of live driver shaders behind and
// the driver started mis-serving them (KHR-GL33/GL40.texture_swizzle.smoke_*).
// Same class of defect as the missing framebuffer/renderbuffer/sampler destructors
// fixed in Wave 1, and the last of that family: this is the one backend GL object
// MobileGL creates without an owning wrapper to destroy it.
g_GLESFuncs.glDeleteShader(backendShaderId);
MGLOG_D("Processed shader source length: %zu", source.length()); MGLOG_D("Processed shader source length: %zu", source.length());
} }
@@ -4387,8 +4583,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
Vector<GLchar> log(static_cast<SizeT>(logLength) + 1, '\0'); Vector<GLchar> log(static_cast<SizeT>(logLength) + 1, '\0');
g_GLESFuncs.glGetProgramInfoLog(m_backendProgramId, logLength, nullptr, log.data()); g_GLESFuncs.glGetProgramInfoLog(m_backendProgramId, logLength, nullptr, log.data());
log.back() = '\0'; log.back() = '\0';
MGLOG_E("Program %u linking failed for %u: %s", stateProgramObject->GetExternalIndex(), // MGLOG_I for the same reason as the compile failure above: a program that
m_backendProgramId, log.data()); // links nothing no-ops every draw that uses it, and that has to be readable
// in an INFO-level artifact.
MGLOG_I("Program linking failed. State program ID: %u, backend program ID: %u, driver log: %s",
stateProgramObject->GetExternalIndex(), m_backendProgramId, log.data());
} else { } else {
MGLOG_D("Program linked successfully. ID: %u", m_backendProgramId); MGLOG_D("Program linked successfully. ID: %u", m_backendProgramId);
} }
@@ -4419,18 +4618,38 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
CacheResourceLocations(stateProgramObject); CacheResourceLocations(stateProgramObject);
// AFTER the link, because glShaderStorageBlockBinding needs the driver's linked // NOT the mechanism that makes a rebinding work - the transpiled qualifier above is.
// interface. This is the only place Espryt applies a rebinding: the frontend // glShaderStorageBlockBinding is a GL 4.3 entry point that no real ES driver exposes,
// record is authoritative and the glShaderStorageBlockBinding entry point itself // so this replay is a no-op almost everywhere; it stays because it is still correct
// deliberately never forces a program build (see DirectGLES.cpp), so a rebinding // (and cheaper than a rebuild) on a driver that does expose it, e.g. a desktop GL
// requested while no backend program existed yet arrives here instead. // driver used as the ES backend. AFTER the link either way, because it needs the
// driver's linked interface.
ReseedShaderStorageBlockBindings(m_backendProgramId, *stateProgramObject); ReseedShaderStorageBlockBindings(m_backendProgramId, *stateProgramObject);
m_syncedLinkVersion = stateProgramObject->GetLinkVersion(); m_syncedLinkVersion = stateProgramObject->GetLinkVersion();
m_syncedImageUnitVersion = stateProgramObject->GetImageUnitVersion();
m_isInitialized = true; m_isInitialized = true;
MGLOG_D("Program sync completed. backend ID %u", m_backendProgramId); MGLOG_D("Program sync completed. backend ID %u", m_backendProgramId);
} }
namespace {
// The GL name of the array element that lives at `location`, given the reflection
// name reported for it. Reflection reports one name per UNIFORM ("goku[0]") but
// one location per ELEMENT, so a caller walking locations sees the same name
// repeatedly; this turns it back into "goku[k]". Anything that is not an array
// (or whose base location cannot be resolved) comes back unchanged, so the only
// behaviour that moves is the array case.
String SubscriptUniformNameForElement(const MG_State::GLState::ProgramObject& program, const String& name,
Uint location) {
if (name.size() < 3 || name.compare(name.size() - 3, 3, "[0]") != 0) return name;
const Int base = program.GetUniformLocation(name);
if (base < 0 || static_cast<Uint>(base) > location) return name;
const Uint element = location - static_cast<Uint>(base);
if (element == 0) return name;
return name.substr(0, name.size() - 3) + "[" + std::to_string(element) + "]";
}
} // namespace
// Resolves every name-based resource lookup once per link so the per-draw path // Resolves every name-based resource lookup once per link so the per-draw path
// (BindCurrentProgramWithResources) never issues glGetUniformBlockIndex / // (BindCurrentProgramWithResources) never issues glGetUniformBlockIndex /
// glGetUniformLocation string queries; block-to-binding-point assignments are // glGetUniformLocation string queries; block-to-binding-point assignments are
@@ -4493,7 +4712,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
// is an INVALID_OPERATION. // is an INVALID_OPERATION.
continue; continue;
} }
const Int backendLoc = g_GLESFuncs.glGetUniformLocation(m_backendProgramId, name.c_str()); // Reflection names an array uniform after its FIRST element ("goku[0]") at
// every location the array spans, so asking the driver for that one name
// once per location hands back the same backend location N times. The
// per-draw pass then issues N glUniform1i calls against it and only the
// last element's unit survives - "layout(binding = 1) uniform sampler2D
// goku[7]" ended up with goku[0] on unit 7 and goku[1..6] still on 0.
// Address each element by its own name instead; the frontend already
// reserves one location per element, so the element index is the distance
// from the array's base location.
const String elementName = SubscriptUniformNameForElement(*stateProgramObject, name, loc);
const Int backendLoc = g_GLESFuncs.glGetUniformLocation(m_backendProgramId, elementName.c_str());
if (backendLoc < 0) continue; if (backendLoc < 0) continue;
SamplerUniformBinding binding; SamplerUniformBinding binding;
binding.frontendLocation = loc; binding.frontendLocation = loc;
@@ -4502,8 +4731,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
binding.lastAssignedUnit = -1; binding.lastAssignedUnit = -1;
// Present only for the samplers EmulateTextureLodBias actually rewrote; the // Present only for the samplers EmulateTextureLodBias actually rewrote; the
// pass names it after the sampler, which SPIRV-Cross preserves verbatim. // pass names it after the sampler, which SPIRV-Cross preserves verbatim.
binding.lodBiasLocation = binding.lodBiasLocation = g_GLESFuncs.glGetUniformLocation(
g_GLESFuncs.glGetUniformLocation(m_backendProgramId, (String(LOD_BIAS_UNIFORM_PREFIX) + name).c_str()); m_backendProgramId, (String(LOD_BIAS_UNIFORM_PREFIX) + elementName).c_str());
binding.lastAssignedLodBias = 0.0f; binding.lastAssignedLodBias = 0.0f;
m_samplerUniformBindings.push_back(binding); m_samplerUniformBindings.push_back(binding);
} }
@@ -4522,6 +4751,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (g_lastUsedBackendProgramId == programToBind) { if (g_lastUsedBackendProgramId == programToBind) {
return; return;
} }
if (!m_backendProgramUsable) {
// MGLOG_I, not MGLOG_W: at MOBILEGL_LOG_LEVEL_INFO - the level the shipped
// fordebug builds compile at - only I and F survive, and this is precisely the
// line those builds need. Every draw made with this program renders nothing and
// raises no GL error, so without it the only symptom is a framebuffer that kept
// its clear colour. The early return above keeps it to at most one line per
// program state change, not one per draw.
MGLOG_I("Backend program for GL program %u is unusable (a shader failed to transpile, "
"compile or link); binding program 0 - draws with it will render nothing",
m_frontendProgramId);
}
MGLOG_D("Using program %u", programToBind); MGLOG_D("Using program %u", programToBind);
g_GLESFuncs.glUseProgram(programToBind); g_GLESFuncs.glUseProgram(programToBind);
g_lastUsedBackendProgramId = programToBind; g_lastUsedBackendProgramId = programToBind;
@@ -4557,6 +4797,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif #endif
g_GLESFuncs.glGenSamplers(1, &m_backendSamplerId); g_GLESFuncs.glGenSamplers(1, &m_backendSamplerId);
m_contextGeneration = g_backendContextGeneration;
if (m_backendSamplerId == 0) { if (m_backendSamplerId == 0) {
MGLOG_E("Failed to generate sampler object."); MGLOG_E("Failed to generate sampler object.");
MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str());
@@ -4565,6 +4806,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
} }
BackendSamplerObject::~BackendSamplerObject() {
if (InProcessTeardown()) {
return; // see InProcessTeardown(): the driver may be unloaded already
}
if (m_backendSamplerId == 0) {
return;
}
// Scrub the unit shadow whether or not the id can still be deleted - the next
// twin can land on this heap address and would otherwise false-skip its Bind.
for (auto& boundSampler : g_boundSamplersCache) {
if (boundSampler == this) {
boundSampler = nullptr; // glDeleteSamplers unbinds from every unit
}
}
if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteSamplers) {
g_GLESFuncs.glDeleteSamplers(1, &m_backendSamplerId);
}
m_backendSamplerId = 0;
}
void BackendSamplerObject::SyncToBackend( void BackendSamplerObject::SyncToBackend(
const SharedPtr<MG_State::GLState::SamplerObject>& stateSamplerObject) { const SharedPtr<MG_State::GLState::SamplerObject>& stateSamplerObject) {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
@@ -4680,12 +4941,28 @@ namespace MobileGL::MG_Backend::DirectGLES {
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif #endif
g_GLESFuncs.glGenRenderbuffers(1, &m_backendRBOId); g_GLESFuncs.glGenRenderbuffers(1, &m_backendRBOId);
m_contextGeneration = g_backendContextGeneration;
if (m_backendRBOId == 0) { if (m_backendRBOId == 0) {
MGLOG_E("Failed to generate renderbuffer object."); MGLOG_E("Failed to generate renderbuffer object.");
MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str());
} }
} }
BackendRenderbufferObject::~BackendRenderbufferObject() {
if (InProcessTeardown()) {
return; // see InProcessTeardown(): the driver may be unloaded already
}
if (m_backendRBOId == 0) {
return;
}
// No driver-level renderbuffer-binding shadow exists (Bind() always issues the
// call), so there is nothing to scrub here - only the id to release.
if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteRenderbuffers) {
g_GLESFuncs.glDeleteRenderbuffers(1, &m_backendRBOId);
}
m_backendRBOId = 0;
}
void BackendRenderbufferObject::Bind() const { void BackendRenderbufferObject::Bind() const {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
+79 -7
View File
@@ -36,6 +36,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool InProcessTeardown(); Bool InProcessTeardown();
void EnsureProcessTeardownSentinel(); void EnsureProcessTeardownSentinel();
// Generation of the backend ES context that owns the driver ids currently handed
// out. Bumped exactly once per DestroyEGLContext. Every backend twin that owns a
// driver name (texture, framebuffer, renderbuffer, sampler) stamps this at
// construction and compares it in its destructor: a twin outliving its context
// must NOT glDelete* its id, because a successor context may already have recycled
// that name and the delete would take out a live object of the new context.
extern Uint g_backendContextGeneration;
// Which optional pieces of state a draw needs synchronized before it is issued. // Which optional pieces of state a draw needs synchronized before it is issued.
// Index/indirect buffer syncs and the instancing-related work are skipped for // Index/indirect buffer syncs and the instancing-related work are skipped for
// draws that provably cannot read them. // draws that provably cannot read them.
@@ -121,6 +129,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Null when no live state object owns this key. The result points into the map, so // 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. // 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) { BackendPtr* Find(StateObject* stateObj) {
const auto entryIt = m_entries.find(stateObj); const auto entryIt = m_entries.find(stateObj);
if (entryIt == m_entries.end()) { if (entryIt == m_entries.end()) {
@@ -613,6 +626,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool m_isInitialized = false; Bool m_isInitialized = false;
Bool m_imageBindableStorageRequired = false; Bool m_imageBindableStorageRequired = false;
Bool m_backendStorageImmutable = 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; StateTextureBasicInfo m_prevTextureInfo;
// Frontend content version at the last completed mipmap sync. The per-draw // Frontend content version at the last completed mipmap sync. The per-draw
// clean probe compares this before rebuilding shape info and scanning // clean probe compares this before rebuilding shape info and scanning
@@ -657,15 +675,20 @@ namespace MobileGL::MG_Backend::DirectGLES {
MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS> MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
g_boundTexturesCache; g_boundTexturesCache;
extern Uint g_activeTextureUnit; extern Uint g_activeTextureUnit;
// Bumped when the backend ES context is destroyed; texture ids stamped with
// an older generation belong to a dead context and must not be deleted.
extern Uint g_textureContextGeneration;
} // namespace TextureImpl } // namespace TextureImpl
namespace FramebufferImpl { namespace FramebufferImpl {
class BackendFramebufferObject { class BackendFramebufferObject {
public: public:
BackendFramebufferObject(); BackendFramebufferObject();
// Deletes the driver framebuffer and scrubs the binding shadow. Without it every
// frontend glDeleteFramebuffers leaked one ES framebuffer for the process lifetime;
// an app that creates a framebuffer per readback (GL CTS packed_pixels does ~3300
// per case) walked the driver into hundreds of megabytes of dead framebuffers and
// out of the resources a later attachment needs.
~BackendFramebufferObject();
BackendFramebufferObject(const BackendFramebufferObject&) = delete;
BackendFramebufferObject& operator=(const BackendFramebufferObject&) = delete;
void SyncToBackend(const SharedPtr<MG_State::GLState::FramebufferObject>& stateFBOObject, void SyncToBackend(const SharedPtr<MG_State::GLState::FramebufferObject>& stateFBOObject,
FramebufferTarget asTarget); FramebufferTarget asTarget);
// Apply only this FBO's read buffer (glReadBuffer) to the backend. Split out so it can // Apply only this FBO's read buffer (glReadBuffer) to the backend. Split out so it can
@@ -680,6 +703,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
private: private:
Uint m_backendFBOId = 0; Uint m_backendFBOId = 0;
Uint m_contextGeneration = 0;
/* this will save buffers in its original form, /* this will save buffers in its original form,
reversion, absence or not consecutive are all allowed, as long as GL spec allows it reversion, absence or not consecutive are all allowed, as long as GL spec allows it
@@ -821,6 +845,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
void BindFramebufferId(GLenum fbTarget, Uint id); void BindFramebufferId(GLenum fbTarget, Uint id);
Uint CurrentFramebufferBinding(FramebufferTarget target); Uint CurrentFramebufferBinding(FramebufferTarget target);
void InvalidateFramebufferBindingCache(); void InvalidateFramebufferBindingCache();
// A driver framebuffer id is about to be deleted: ES reverts every target that
// currently binds it to 0, so the binding shadow has to follow or the next
// BindFramebufferId(0) would be deduped away and leave the deleted name bound.
void NoteFramebufferIdDeleted(Uint id);
} // namespace FramebufferImpl } // namespace FramebufferImpl
// Shared scratch framebuffers for the readback/copy/blit emulation paths, with a // Shared scratch framebuffers for the readback/copy/blit emulation paths, with a
@@ -1010,6 +1038,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint32 GetSnormFallbackClampOutputMask() const { return m_snormFallbackClampOutputMask; } Uint32 GetSnormFallbackClampOutputMask() const { return m_snormFallbackClampOutputMask; }
Uint32 GetUnormFallbackClampOutputMask() const { return m_unormFallbackClampOutputMask; } Uint32 GetUnormFallbackClampOutputMask() const { return m_unormFallbackClampOutputMask; }
Uint GetFragColorBroadcastCount() const { return m_fragColorBroadcastCount; } 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; } Bool HasGlobalUboBlock() const { return m_globalUboBackendBlockIndex >= 0; }
const Vector<Int>& GetUniformBlockBackendIndices() const { return m_uniformBlockBackendIndices; } const Vector<Int>& GetUniformBlockBackendIndices() const { return m_uniformBlockBackendIndices; }
@@ -1025,11 +1058,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Frontend link version this backend program (and its resource caches) was // Frontend link version this backend program (and its resource caches) was
// built from; a mismatch means every link-derived cache here is stale. // built from; a mismatch means every link-derived cache here is stale.
Uint32 GetSyncedLinkVersion() const { return m_syncedLinkVersion; } 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; }
private: private:
void CacheResourceLocations(const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject); void CacheResourceLocations(const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject);
Uint m_backendProgramId = 0; 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; Uint m_backendGlobalUBOId = 0;
Int m_baseInstanceUniformLocation = -1; Int m_baseInstanceUniformLocation = -1;
Int m_drawIdUniformLocation = -1; Int m_drawIdUniformLocation = -1;
@@ -1040,6 +1084,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Draw buffers a legacy gl_FragColor write has to reach (see // Draw buffers a legacy gl_FragColor write has to reach (see
// PrgramImpl::BroadcastLegacyFragColor); 1 keeps the plain single-output shader. // PrgramImpl::BroadcastLegacyFragColor); 1 keeps the plain single-output shader.
Uint m_fragColorBroadcastCount = 1; 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_isInitialized = false;
Bool m_backendProgramUsable = false; Bool m_backendProgramUsable = false;
@@ -1050,6 +1096,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint32 m_lastUploadedGlobalUboVersion = ~0u; Uint32 m_lastUploadedGlobalUboVersion = ~0u;
BufferImpl::UboRingAllocation m_globalUboRingAllocation; BufferImpl::UboRingAllocation m_globalUboRingAllocation;
Uint32 m_syncedLinkVersion = ~0u; Uint32 m_syncedLinkVersion = ~0u;
Uint32 m_syncedImageUnitVersion = ~0u;
SamplerPassMemo m_samplerPassMemo; SamplerPassMemo m_samplerPassMemo;
}; };
@@ -1073,26 +1120,45 @@ namespace MobileGL::MG_Backend::DirectGLES {
// on the backend program (eliminated as unused, or the driver lacks the entry // 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 // points), which is not an error - GL_BUFFER_BINDING is served from the frontend
// record either way. // 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); Bool ApplyShaderStorageBlockBinding(Uint backendProgramId, const String& blockName, Uint binding);
// Replays every glShaderStorageBlockBinding recorded on the program onto a backend // Replays every glShaderStorageBlockBinding recorded on the program onto a backend
// program that was just built. The frontend record is authoritative (only the // program that was just built - best effort, on the same "only where the driver has
// shader's DECLARED binding survives in the SPIR-V), so without this replay any // the entry point" terms as ApplyShaderStorageBlockBinding above. Mirrors
// rebuild would silently revert rebound blocks. Mirrors DirectVulkan's // DirectVulkan's reseed-on-rebuild in BuildProgramResourceCache.
// reseed-on-rebuild in BuildProgramResourceCache.
void ReseedShaderStorageBlockBindings(Uint backendProgramId, void ReseedShaderStorageBlockBindings(Uint backendProgramId,
const MG_State::GLState::ProgramObject& stateProgramObject); 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);
} // namespace PrgramImpl } // namespace PrgramImpl
namespace SamplerImpl { namespace SamplerImpl {
class BackendSamplerObject { class BackendSamplerObject {
public: public:
BackendSamplerObject(); BackendSamplerObject();
// Deletes the driver sampler and clears the units whose binding shadow still names
// this twin (a recycled heap address would otherwise false-skip a later Bind).
// Frontend glDeleteSamplers used to leak the backend id for the process lifetime.
~BackendSamplerObject();
BackendSamplerObject(const BackendSamplerObject&) = delete;
BackendSamplerObject& operator=(const BackendSamplerObject&) = delete;
void SyncToBackend(const SharedPtr<MG_State::GLState::SamplerObject>& stateSamplerObject); void SyncToBackend(const SharedPtr<MG_State::GLState::SamplerObject>& stateSamplerObject);
void Bind(Uint unit); void Bind(Uint unit);
Uint GetBackendSamplerId() const; Uint GetBackendSamplerId() const;
private: private:
Uint m_backendSamplerId = 0; Uint m_backendSamplerId = 0;
Uint m_contextGeneration = 0;
Bool m_isInitialized = false; Bool m_isInitialized = false;
SamplerParameters m_cacheSamplerParameters; SamplerParameters m_cacheSamplerParameters;
Uint16 m_syncedSamplerVersion = 0; Uint16 m_syncedSamplerVersion = 0;
@@ -1110,12 +1176,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
class BackendRenderbufferObject { class BackendRenderbufferObject {
public: public:
BackendRenderbufferObject(); BackendRenderbufferObject();
// Deletes the driver renderbuffer; frontend glDeleteRenderbuffers used to leak it
// (with its whole image allocation) for the process lifetime.
~BackendRenderbufferObject();
BackendRenderbufferObject(const BackendRenderbufferObject&) = delete;
BackendRenderbufferObject& operator=(const BackendRenderbufferObject&) = delete;
void SyncToBackend(const SharedPtr<MG_State::GLState::RenderbufferObject>& stateRBOObject); void SyncToBackend(const SharedPtr<MG_State::GLState::RenderbufferObject>& stateRBOObject);
Uint GetBackendRenderbufferId() const { return m_backendRBOId; } Uint GetBackendRenderbufferId() const { return m_backendRBOId; }
void Bind() const; void Bind() const;
private: private:
Uint m_backendRBOId = 0; Uint m_backendRBOId = 0;
Uint m_contextGeneration = 0;
Bool m_isInitialized = false; Bool m_isInitialized = false;
TextureInternalFormat m_cacheInternalFormat = TextureInternalFormat::Unknown; TextureInternalFormat m_cacheInternalFormat = TextureInternalFormat::Unknown;
Int m_cacheWidth = 0; Int m_cacheWidth = 0;
+436 -1
View File
@@ -21,6 +21,7 @@
#include <MG_Util/Math/HalfFloat.h> #include <MG_Util/Math/HalfFloat.h>
#include <MG_Util/Math/SmallFloat.h> #include <MG_Util/Math/SmallFloat.h>
#include <algorithm>
#include <cmath> #include <cmath>
#include <cctype> #include <cctype>
#include <cstring> #include <cstring>
@@ -434,6 +435,89 @@ namespace MobileGL::MG_Backend::DirectGLES {
return result; 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 RemoveLayoutBinding(const String& glslCode) { String RemoveLayoutBinding(const String& glslCode) {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
@@ -470,6 +554,352 @@ namespace MobileGL::MG_Backend::DirectGLES {
return result; 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 { namespace {
// How a lookup carries its level of detail, and how many arguments it takes // How a lookup carries its level of detail, and how many arguments it takes
// before the optional bias. // before the optional bias.
@@ -527,7 +957,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
} // namespace } // namespace
String EmulateTextureLodBias(const String& glslCode) { String EmulateTextureLodBias(const String& glslCode, Bool avoidExplicitLodBias) {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif #endif
@@ -588,6 +1018,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (samplerIt == samplerNames.end()) continue; if (samplerIt == samplerNames.end()) continue;
const String& biasName = samplerIt->second; 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) { if (form->explicitLodArg >= 0) {
// Explicit LOD: the bias adds to it, as Vulkan does for // Explicit LOD: the bias adds to it, as Vulkan does for
// OpImageSampleExplicitLod and as the CTS reference expects. // OpImageSampleExplicitLod and as the CTS reference expects.
+47 -1
View File
@@ -130,7 +130,48 @@ namespace MobileGL::MG_Backend::DirectGLES {
// drawBufferCount <= 1, i.e. for everything but a framebuffer that actually // drawBufferCount <= 1, i.e. for everything but a framebuffer that actually
// enables several draw buffers, so the ordinary single-target shader is untouched. // enables several draw buffers, so the ordinary single-target shader is untouched.
String BroadcastLegacyFragColor(String glslCode, GLenum shaderType, Uint drawBufferCount); 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);
String RemoveLayoutBinding(const String& glslCode); 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 // 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. // the shader (see EmulateTextureLodBias); the suffix is the sampler's own name.
constexpr const char* LOD_BIAS_UNIFORM_PREFIX = "mg_lodBias_"; constexpr const char* LOD_BIAS_UNIFORM_PREFIX = "mg_lodBias_";
@@ -142,7 +183,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the bound texture's (or sampler object's) value into it; a shader whose samplers // 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 // all have a zero bias is therefore unaffected. Returns the source unchanged when
// there is nothing to rewrite. // 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 PrgramImpl
namespace Utils { namespace Utils {
@@ -768,14 +768,48 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// the Uint32 attribute masks the draw path passes around are both bounded by MAX_VERTEX_ATTRIBS. // the Uint32 attribute masks the draw path passes around are both bounded by MAX_VERTEX_ATTRIBS.
m_dynamicParameters.MaxVertexAttribs = std::min( m_dynamicParameters.MaxVertexAttribs = std::min(
m_vulkanCaps.MaxVertexAttribs, static_cast<Int>(MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS)); m_vulkanCaps.MaxVertexAttribs, static_cast<Int>(MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS));
m_dynamicParameters.MaxComputeShaderStorageBlocks = m_vulkanCaps.MaxComputeShaderStorageBlocks; // Vulkan descriptor limits are not GL limits, and a GL application reads an advertised
m_dynamicParameters.MaxCombinedShaderStorageBlocks = m_vulkanCaps.MaxCombinedShaderStorageBlocks; // limit as an amount it may actually USE. Adreno answers the per-stage/per-set descriptor
m_dynamicParameters.MaxComputeUniformBlocks = m_vulkanCaps.MaxComputeUniformBlocks; // queries at descriptor-indexing scale - the same driver whose
// GL_MAX_SHADER_STORAGE_BLOCK_SIZE is clamped from 2147483647 further down - so
// KHR-GL44.multi_bind.dispatch_bind_buffers_base read GL_MAX_COMPUTE_UNIFORM_BLOCKS,
// created that many buffers and spliced that many UBO declarations into a single compute
// shader: ~14 s of allocation, then death on std::bad_alloc. Its sibling
// dispatch_bind_buffers_range hard-codes 4 buffers and passes, which is the clean
// discriminator. Every ceiling below is far above what any desktop driver advertises for
// these (84-96 for the binding families) and far below a descriptor-indexing count, so it
// can only lower a limit that was never usable in the first place. The zero floor is not
// decoration: a driver reporting UINT32_MAX used to arrive here as -1.
const auto clampLimit = [](const char* name, Int reported, Int ceiling) {
const Int clamped = std::min(std::max(reported, 0), ceiling);
if (clamped != reported) {
MGLOG_I("DirectVulkan: clamped %s from %d to %d", name, reported, clamped);
}
return clamped;
};
// GL 4.6 required minimums, for the record: MAX_COMPUTE_UNIFORM_BLOCKS 12,
// MAX_COMPUTE/COMBINED_SHADER_STORAGE_BLOCKS 8, MAX_SHADER_STORAGE_BUFFER_BINDINGS 8,
// MAX_UNIFORM_BUFFER_BINDINGS 84, MAX_TEXTURE_BUFFER_SIZE 65536.
constexpr Int kMaxAdvertisedBufferBlocks = 256;
constexpr Int kMaxAdvertisedTextureBufferSize = 1 << 27; // texels; what desktop GL reports
m_dynamicParameters.MaxComputeShaderStorageBlocks =
clampLimit("GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS", m_vulkanCaps.MaxComputeShaderStorageBlocks,
kMaxAdvertisedBufferBlocks);
m_dynamicParameters.MaxCombinedShaderStorageBlocks =
clampLimit("GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS", m_vulkanCaps.MaxCombinedShaderStorageBlocks,
kMaxAdvertisedBufferBlocks);
m_dynamicParameters.MaxComputeUniformBlocks =
clampLimit("GL_MAX_COMPUTE_UNIFORM_BLOCKS", m_vulkanCaps.MaxComputeUniformBlocks,
kMaxAdvertisedBufferBlocks);
m_dynamicParameters.MaxComputeWorkGroupInvocations = m_vulkanCaps.MaxComputeWorkGroupInvocations; m_dynamicParameters.MaxComputeWorkGroupInvocations = m_vulkanCaps.MaxComputeWorkGroupInvocations;
m_dynamicParameters.MaxShaderStorageBufferBindings = m_vulkanCaps.MaxShaderStorageBufferBindings; m_dynamicParameters.MaxShaderStorageBufferBindings =
m_dynamicParameters.MaxTextureBufferSize = m_vulkanCaps.MaxTextureBufferSize; clampLimit("GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS", m_vulkanCaps.MaxShaderStorageBufferBindings,
kMaxAdvertisedBufferBlocks);
m_dynamicParameters.MaxTextureBufferSize = clampLimit(
"GL_MAX_TEXTURE_BUFFER_SIZE", m_vulkanCaps.MaxTextureBufferSize, kMaxAdvertisedTextureBufferSize);
m_dynamicParameters.TextureBufferOffsetAlignment = m_vulkanCaps.TextureBufferOffsetAlignment; m_dynamicParameters.TextureBufferOffsetAlignment = m_vulkanCaps.TextureBufferOffsetAlignment;
m_dynamicParameters.MaxUniformBufferBindings = m_vulkanCaps.MaxUniformBufferBindings; m_dynamicParameters.MaxUniformBufferBindings = clampLimit(
"GL_MAX_UNIFORM_BUFFER_BINDINGS", m_vulkanCaps.MaxUniformBufferBindings, kMaxAdvertisedBufferBlocks);
m_dynamicParameters.MaxUniformBlockSize = m_vulkanCaps.MaxUniformBlockSize; m_dynamicParameters.MaxUniformBlockSize = m_vulkanCaps.MaxUniformBlockSize;
m_dynamicParameters.MaxImageUnits = std::max(std::min(m_vulkanCaps.MaxImageUnits, maxSupportedTextureUnits), 0); m_dynamicParameters.MaxImageUnits = std::max(std::min(m_vulkanCaps.MaxImageUnits, maxSupportedTextureUnits), 0);
m_dynamicParameters.MaxCombinedImageUniforms = std::max(m_vulkanCaps.MaxCombinedImageUniforms, 0); m_dynamicParameters.MaxCombinedImageUniforms = std::max(m_vulkanCaps.MaxCombinedImageUniforms, 0);
@@ -801,9 +801,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// already recorded the new binding on the program - which is what reseeds this cache // 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 // 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. // (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); const GLuint blockIndex = GetShaderStorageBlockIndex(*programObject, storageBlockName);
if (blockIndex == GL_INVALID_INDEX) return; if (blockIndex == GL_INVALID_INDEX) return;
auto& cache = GetProgramResourceCache(*programObject);
if (blockIndex >= cache.storageBlocks.size()) return;
cache.storageBlocks[blockIndex].binding = storageBlockBinding; cache.storageBlocks[blockIndex].binding = storageBlockBinding;
} }
void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) { void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) {
@@ -966,6 +975,29 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (drawcount <= 0) { if (drawcount <= 0) {
return; return;
} }
// With no element-array buffer bound, every indices[i] is a client pointer into a
// separate CPU allocation, not an offset into one shared buffer. The batched payload
// below cannot express that: it carries ONE index-buffer view for the whole batch and
// turns each pointer into a firstIndex relative to it. Replay the sub-draws through
// the single-draw entry point instead - it snapshots each client range into its own
// transient slice, which is exactly what the unrolled draws this must match do.
// (The batch used to be built this way; the shared-view rewrite that added
// MultiDrawIndexedCmd left the client-memory shape addressing a view whose byte
// offset is a hardcoded 0, so UploadAndBindIndexBuffer saw a null client pointer,
// declined the whole batch and painted nothing.)
const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();
if (vao.GetIndexBufferBindingSlot().GetBoundObject() == nullptr) {
for (GLsizei i = 0; i < drawcount; ++i) {
if (count[i] <= 0) {
continue;
}
DrawElementsBaseVertex(mode, count[i], type, indices[i],
basevertex != nullptr ? basevertex[i] : 0);
}
return;
}
MultiDrawIndexedCmd payload{}; MultiDrawIndexedCmd payload{};
payload.mode = mode; payload.mode = mode;
payload.indexBufferView.indexType = type; payload.indexBufferView.indexType = type;
@@ -111,6 +111,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
if (buffer.IsValid()) { if (buffer.IsValid()) {
// Outgrown, not dead: every BufferSlice handed out from this frame's arena so far
// still names it, and those slices stay in service until the frame slot is rewound
// (VkBufferResource::transientSlice, the converted-vertex-stream cache, the draw
// memos). The release therefore has to survive every mid-frame reclaim and land on
// the next ResetFrame of this slot - see VkBufferManager::CollectAllDeferredReleases.
m_deferredReleases[frameIndex].push_back(std::move(buffer)); m_deferredReleases[frameIndex].push_back(std::move(buffer));
} }
@@ -252,6 +252,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
VkPipeline pipeline = CreatePipeline(payload); VkPipeline pipeline = CreatePipeline(payload);
// A failed creation must never be memoized. Caching VK_NULL_HANDLE served the null back for
// the rest of the process, so one transient driver rejection turned every later draw with
// the same state into a vkCmdBindPipeline(VK_NULL_HANDLE) - the SIGSEGV behind 9 of the 15
// CTS process deaths. Retrying costs one failed vkCreateGraphicsPipelines per draw, which
// 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 "
"programHash=0x%llx; not caching the failure",
static_cast<unsigned long long>(hash),
static_cast<unsigned long long>(payload.programHash));
return VK_NULL_HANDLE;
}
m_cache.emplace(hash, PipelineCacheEntry{pipeline, payload.programHash, payload.renderPass, m_cache.emplace(hash, PipelineCacheEntry{pipeline, payload.programHash, payload.renderPass,
m_frameCounter}); m_frameCounter});
return pipeline; return pipeline;
@@ -507,6 +520,35 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MGLOG_F("PipelineFactory::CreatePipeline vertex input: bindingCount=%u attributeCount=%u", MGLOG_F("PipelineFactory::CreatePipeline vertex input: bindingCount=%u attributeCount=%u",
payload.vertexInputState->vertexBindingDescriptionCount, payload.vertexInputState->vertexBindingDescriptionCount,
payload.vertexInputState->vertexAttributeDescriptionCount); 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.
if (payload.stageSpirvDigests) {
for (SizeT i = 0; i < payload.stageSpirvDigests->size(); ++i) {
const auto& digest = (*payload.stageSpirvDigests)[i];
MGLOG_I("PipelineFactory::CreatePipeline spirv[%zu]: stage=0x%x words=%u bytes=%zu "
"hash=0x%llx",
i, digest.stage, digest.wordCount,
static_cast<SizeT>(digest.wordCount) * sizeof(Uint32),
static_cast<unsigned long long>(digest.hash));
}
} else {
MGLOG_I("PipelineFactory::CreatePipeline: no SPIR-V digests attached to the payload");
}
if (payload.stages) {
for (SizeT i = 0; i < payload.stages->size(); ++i) {
const auto& stage = (*payload.stages)[i];
// VkShaderModule is a non-dispatchable handle: a pointer on 64-bit but a
// plain uint64_t on 32-bit ABIs, where a cast to const void* is ill-formed
// (broke the armeabi-v7a build). Print it as the 64-bit value it is.
MGLOG_I("PipelineFactory::CreatePipeline stage[%zu]: stage=0x%x module=0x%llx entry=%s "
"specialization=%d",
i, static_cast<Uint32>(stage.stage),
static_cast<unsigned long long>(reinterpret_cast<Uint64>(stage.module)),
stage.pName ? stage.pName : "(null)", stage.pSpecializationInfo ? 1 : 0);
}
}
for (Uint32 i = 0; i < payload.colorAttachmentCount; ++i) { for (Uint32 i = 0; i < payload.colorAttachmentCount; ++i) {
const auto& attachment = payload.colorBlendAttachments[i]; const auto& attachment = payload.colorBlendAttachments[i];
MGLOG_F("PipelineFactory::CreatePipeline colorAttachment[%u]: blend=%d colorWriteMask=0x%x srcColor=%d dstColor=%d colorOp=%d srcAlpha=%d dstAlpha=%d alphaOp=%d", MGLOG_F("PipelineFactory::CreatePipeline colorAttachment[%u]: blend=%d colorWriteMask=0x%x srcColor=%d dstColor=%d colorOp=%d srcAlpha=%d dstAlpha=%d alphaOp=%d",
@@ -14,6 +14,16 @@
#include <Includes.h> #include <Includes.h>
namespace MobileGL::MG_Backend::DirectVulkan { namespace MobileGL::MG_Backend::DirectVulkan {
// Enough of a fingerprint to identify the exact module the driver rejected without keeping the
// SPIR-V alive for every program in the cache: a driver that answers VK_ERROR_UNKNOWN tells us
// nothing, so the log has to carry the shader's identity itself. Diagnostic only - never part
// of any pipeline or program hash.
struct ShaderStageSpirvDigest {
Uint32 stage = 0; // VkShaderStageFlagBits
Uint32 wordCount = 0;
Uint64 hash = 0;
};
class PipelineFactory { class PipelineFactory {
public: public:
using HashType = Uint64; using HashType = Uint64;
@@ -62,6 +72,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Array<VkPipelineColorBlendAttachmentState, kMaxColorAttachments> colorBlendAttachments{}; Array<VkPipelineColorBlendAttachmentState, kMaxColorAttachments> colorBlendAttachments{};
const Vector<VkPipelineShaderStageCreateInfo>* stages = nullptr; const Vector<VkPipelineShaderStageCreateInfo>* stages = nullptr;
const VkPipelineVertexInputStateCreateInfo* vertexInputState = nullptr; const VkPipelineVertexInputStateCreateInfo* vertexInputState = nullptr;
// Diagnostic only; may be null. Read solely from the pipeline-creation failure path.
const Vector<ShaderStageSpirvDigest>* stageSpirvDigests = nullptr;
}; };
explicit PipelineFactory(VkDevice device, const VulkanRendererConfig& config); explicit PipelineFactory(VkDevice device, const VulkanRendererConfig& config);
@@ -12,7 +12,10 @@
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h" #include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
#include "MG_Util/ShaderTranspiler/SpvcSession.h" #include "MG_Util/ShaderTranspiler/SpvcSession.h"
#include "MG_Util/ShaderTranspiler/Types.h" #include "MG_Util/ShaderTranspiler/Types.h"
#include <algorithm>
#include <cstring> #include <cstring>
#include <map>
#include <utility>
#include <spirv-tools/libspirv.h> #include <spirv-tools/libspirv.h>
#include <spirv-tools/optimizer.hpp> #include <spirv-tools/optimizer.hpp>
#include <source/opt/build_module.h> #include <source/opt/build_module.h>
@@ -372,6 +375,20 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spv_diagnostic diagnostic = nullptr; spv_diagnostic diagnostic = nullptr;
const spv_result_t result = spvValidateWithOptions(context, options, &binary, &diagnostic); 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.
MG_Util::ShaderTranspiler::ShaderCompiler::NoteSpirvValidationFailure();
MGLOG_I(
"ProgramFactory::ValidateTransformedSpirv: validation failed for stage=%d program=%u result=%d index=%zu msg=%s",
static_cast<Int>(shaderStage),
programExternalIndex,
static_cast<Int>(result),
diagnostic != nullptr ? diagnostic->position.index : 0,
diagnostic != nullptr && diagnostic->error != nullptr ? diagnostic->error : "<null>");
}
MOBILEGL_ASSERT( MOBILEGL_ASSERT(
result == SPV_SUCCESS, result == SPV_SUCCESS,
"ProgramFactory::ValidateTransformedSpirv: validation failed for stage=%d program=%u result=%d line=%zu column=%zu index=%zu msg=%s", "ProgramFactory::ValidateTransformedSpirv: validation failed for stage=%d program=%u result=%d line=%zu column=%zu index=%zu msg=%s",
@@ -923,6 +940,189 @@ namespace MobileGL::MG_Backend::DirectVulkan {
ProgramFactory::CompileOptionFlags m_transformFlags; ProgramFactory::CompileOptionFlags m_transformFlags;
}; };
// gl_FragCoord back into GL's window space, for default-framebuffer draws only.
//
// Vulkan's gl_FragCoord.y is the framebuffer ROW being written - not a value the
// viewport rect can move independently of placement. The default framebuffer's image is
// stored display-side-up and the vertex stage compensates by negating gl_Position.y, so
// for every default-FBO draw the framebuffer row of a fragment is exactly
// `height - y_GL` (the viewport terms cancel: yf_VK = H - yf_GL for any viewport rect).
// A shader that reads gl_FragCoord therefore sees a flipped Y, and once the viewport
// rect started being converted to the stored orientation it also sees a Y that is
// OUTSIDE the range GL promises - a 32-pixel-tall viewport at GL y=0 reports 224..255 on
// a 256-tall surface. GL CTS shader_image_load_store writes imageStore(image,
// ivec2(gl_FragCoord.xy)) into an image exactly the size of that viewport, so every
// store fell outside the image and the test read back zeroes.
//
// The rewrite redirects every read of the builtin to a Private copy initialised once at
// entry, which is exact for all access forms (whole-vector loads, `.y` access chains,
// OpCopyMemory) and leaves the builtin itself - and its decorations - untouched.
class GlFragCoordYFlipPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-fragcoord-y-flip"; }
explicit GlFragCoordYFlipPass(Uint32 framebufferHeight) : m_framebufferHeight(framebufferHeight) {}
Status Process() override {
using namespace spvtools::opt;
if (m_framebufferHeight == 0) return Status::SuccessWithoutChange;
Instruction* entryPoint = nullptr;
for (auto& candidate : get_module()->entry_points()) {
if (candidate.NumInOperands() >= 2 &&
static_cast<spv::ExecutionModel>(candidate.GetSingleWordInOperand(0)) ==
spv::ExecutionModel::Fragment) {
entryPoint = &candidate;
break;
}
}
if (!entryPoint) return Status::SuccessWithoutChange;
const Uint32 builtinVarId = FindFragCoordVariable();
if (builtinVarId == 0) return Status::SuccessWithoutChange;
Instruction* builtinVar = context()->get_def_use_mgr()->GetDef(builtinVarId);
if (!builtinVar || builtinVar->opcode() != spv::Op::OpVariable) return Status::SuccessWithoutChange;
// The builtin is `Input vec4`; take the vector and component types from its own
// pointer type rather than assuming float32x4, so a module that spells it
// differently declines instead of miscompiling.
Instruction* inputPtrType = context()->get_def_use_mgr()->GetDef(builtinVar->type_id());
if (!inputPtrType || inputPtrType->opcode() != spv::Op::OpTypePointer) {
return Status::SuccessWithoutChange;
}
const Uint32 vectorTypeId = inputPtrType->GetSingleWordInOperand(1);
Instruction* vectorType = context()->get_def_use_mgr()->GetDef(vectorTypeId);
if (!vectorType || vectorType->opcode() != spv::Op::OpTypeVector ||
vectorType->GetSingleWordInOperand(1) != 4) {
return Status::SuccessWithoutChange;
}
const Uint32 floatTypeId = vectorType->GetSingleWordInOperand(0);
auto* floatType = context()->get_type_mgr()->GetType(floatTypeId);
if (!floatType || !floatType->AsFloat() || floatType->AsFloat()->width() != 32) {
return Status::SuccessWithoutChange;
}
const auto heightBits = std::bit_cast<Uint32>(static_cast<float>(m_framebufferHeight));
const auto* heightConst = context()->get_constant_mgr()->GetConstant(floatType, {heightBits});
auto* heightInst = context()->get_constant_mgr()->GetDefiningInstruction(heightConst);
if (!heightInst) return Status::SuccessWithoutChange;
auto* function = context()->GetFunction(entryPoint->GetSingleWordInOperand(1));
if (!function || function->begin() == function->end()) return Status::SuccessWithoutChange;
const Uint32 privatePtrTypeId =
context()->get_type_mgr()->FindPointerToType(vectorTypeId, spv::StorageClass::Private);
if (privatePtrTypeId == 0) return Status::SuccessWithoutChange;
const Uint32 copyVarId = context()->TakeNextId();
if (copyVarId == 0) return Status::SuccessWithoutChange;
auto copyVar = std::make_unique<Instruction>(
context(), spv::Op::OpVariable, privatePtrTypeId, copyVarId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_STORAGE_CLASS, {static_cast<Uint32>(spv::StorageClass::Private)}}});
context()->AddGlobalValue(std::move(copyVar));
// Redirect the reads BEFORE emitting the initialiser, so the initialiser's own
// load of the builtin is not rewritten into a load of the (still empty) copy.
if (!RedirectReads(builtinVarId, copyVarId)) return Status::SuccessWithoutChange;
auto& entryBlock = *function->begin();
auto insertPoint = entryBlock.begin();
while (insertPoint != entryBlock.end() && insertPoint->opcode() == spv::Op::OpVariable) {
++insertPoint;
}
if (insertPoint == entryBlock.end()) return Status::SuccessWithoutChange;
InstructionBuilder builder(context(), &*insertPoint,
IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
auto* raw = builder.AddLoad(vectorTypeId, builtinVarId);
if (!raw) return Status::SuccessWithoutChange;
auto* x = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {0});
auto* y = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {1});
auto* z = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {2});
auto* w = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {3});
if (!x || !y || !z || !w) return Status::SuccessWithoutChange;
auto* flippedY =
builder.AddBinaryOp(floatTypeId, spv::Op::OpFSub, heightInst->result_id(), y->result_id());
if (!flippedY) return Status::SuccessWithoutChange;
auto* corrected = builder.AddCompositeConstruct(
vectorTypeId, {x->result_id(), flippedY->result_id(), z->result_id(), w->result_id()});
if (!corrected) return Status::SuccessWithoutChange;
if (!builder.AddStore(copyVarId, corrected->result_id())) return Status::SuccessWithoutChange;
// SPIR-V 1.4 widened the entry-point interface to every global the entry point
// statically uses, Private included; earlier versions accept Input/Output only,
// so listing it there would be invalid.
if (get_module()->version() >= 0x00010400u) {
entryPoint->AddOperand({SPV_OPERAND_TYPE_ID, {copyVarId}});
context()->AnalyzeUses(entryPoint);
}
context()->InvalidateAnalysesExceptFor(spvtools::opt::IRContext::kAnalysisDefUse |
spvtools::opt::IRContext::kAnalysisInstrToBlockMapping);
return Status::SuccessWithChange;
}
private:
Uint32 FindFragCoordVariable() const {
for (const auto& annotation : get_module()->annotations()) {
if (annotation.opcode() != spv::Op::OpDecorate) continue;
if (annotation.NumInOperands() < 3) continue;
if (static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) !=
spv::Decoration::BuiltIn) {
continue;
}
if (static_cast<spv::BuiltIn>(annotation.GetSingleWordInOperand(2)) != spv::BuiltIn::FragCoord) {
continue;
}
return annotation.GetSingleWordInOperand(0);
}
return 0;
}
// Every instruction that reads through the builtin's POINTER gets the copy instead.
// Decorations, names and the entry-point interface keep naming the builtin.
Bool RedirectReads(Uint32 builtinVarId, Uint32 copyVarId) {
using namespace spvtools::opt;
Bool ok = true;
Vector<Instruction*> users;
context()->get_def_use_mgr()->ForEachUser(builtinVarId, [&](Instruction* user) {
switch (user->opcode()) {
case spv::Op::OpLoad:
case spv::Op::OpAccessChain:
case spv::Op::OpInBoundsAccessChain:
case spv::Op::OpPtrAccessChain:
case spv::Op::OpInBoundsPtrAccessChain:
case spv::Op::OpCopyMemory:
case spv::Op::OpCopyMemorySized:
users.push_back(user);
break;
case spv::Op::OpStore:
// gl_FragCoord is read-only; a store through it means this is not the
// module we think it is.
ok = false;
break;
default:
break;
}
});
if (!ok) return false;
for (Instruction* user : users) {
for (Uint32 i = 0; i < user->NumInOperands(); ++i) {
auto& operand = user->GetInOperand(i);
if (operand.type == SPV_OPERAND_TYPE_ID && !operand.words.empty() &&
operand.words[0] == builtinVarId) {
operand.words[0] = copyVarId;
}
}
context()->AnalyzeUses(user);
}
return true;
}
Uint32 m_framebufferHeight = 0;
};
// Decorates the module's captured varyings for VK_EXT_transform_feedback: // Decorates the module's captured varyings for VK_EXT_transform_feedback:
// user outputs get XfbBuffer/XfbStride/Offset directly; a captured // user outputs get XfbBuffer/XfbStride/Offset directly; a captured
// gl_Position (a gl_PerVertex member) is mirrored into a dedicated output // gl_Position (a gl_PerVertex member) is mirrored into a dedicated output
@@ -934,6 +1134,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
std::string name; std::string name;
Uint32 bufferIndex = 0; Uint32 bufferIndex = 0;
Uint32 offsetBytes = 0; Uint32 offsetBytes = 0;
// Set when the capture names a member of an output interface block
// ("Block.member"): the decoration target is then the block's struct TYPE,
// decorated per member, not the variable. `name` keeps the GL spelling and
// is useless for the id lookup, so the instance name is carried separately.
std::string blockInstanceName;
std::string blockName;
Int blockMemberIndex = -1;
Int blockMemberElement = -1; // array element of that member, -1 = the whole member
Uint32 byteSize = 0;
}; };
const char* name() const override { return "mobilegl-xfb-capture-decorate"; } const char* name() const override { return "mobilegl-xfb-capture-decorate"; }
XfbCaptureDecoratePass(Vector<CapturedVarying> varyings, Vector<Uint32> strides) XfbCaptureDecoratePass(Vector<CapturedVarying> varyings, Vector<Uint32> strides)
@@ -965,6 +1174,33 @@ namespace MobileGL::MG_Backend::DirectVulkan {
decorationManager->AddDecorationVal(targetId, static_cast<Uint32>(spv::Decoration::Offset), decorationManager->AddDecorationVal(targetId, static_cast<Uint32>(spv::Decoration::Offset),
offsetBytes); offsetBytes);
}; };
// SPIR-V puts XfbBuffer/XfbStride/Offset on the struct MEMBER when the
// captured varying lives in an interface block (SPIR-V 1.6 §3.20 lists all
// three as member-decoratable); Offset in particular is illegal on the block
// variable once the type is decorated Block.
const auto decorateMemberForXfb = [&](Uint32 structTypeId, Uint32 memberIndex, Uint32 bufferIndex,
Uint32 offsetBytes) {
const Uint32 stride = bufferIndex < m_strides.size() ? m_strides[bufferIndex] : 0;
decorationManager->AddMemberDecoration(structTypeId, memberIndex,
static_cast<Uint32>(spv::Decoration::XfbBuffer),
bufferIndex);
decorationManager->AddMemberDecoration(structTypeId, memberIndex,
static_cast<Uint32>(spv::Decoration::XfbStride), stride);
decorationManager->AddMemberDecoration(structTypeId, memberIndex,
static_cast<Uint32>(spv::Decoration::Offset), offsetBytes);
};
// A member array captured element by element ("Block.attrib[0]" .. "[15]")
// is one SPIR-V member, so its captures collapse into a single decoration
// placed at the first element's offset - the rest follow from the member's
// own layout. Collected first so the group is complete before it decorates.
struct MemberGroup {
Uint32 bufferIndex = 0;
Uint32 minOffset = 0;
Uint32 elementBytes = 0;
Vector<Uint32> offsets;
};
std::map<std::pair<Uint32, Uint32>, MemberGroup> memberGroups;
Bool modified = false; Bool modified = false;
Bool needsPositionMirror = false; Bool needsPositionMirror = false;
@@ -977,6 +1213,41 @@ namespace MobileGL::MG_Backend::DirectVulkan {
positionOffset = varying.offsetBytes; positionOffset = varying.offsetBytes;
continue; continue;
} }
if (varying.blockMemberIndex >= 0) {
// glslang names the block's instance variable and its struct type
// separately; an anonymous instance leaves only the type named, so
// both spellings are tried before giving up.
Uint32 structTypeId = 0;
if (const auto it = idsByName.find(varying.blockInstanceName); it != idsByName.end()) {
structTypeId = BlockStructTypeOf(it->second);
}
if (structTypeId == 0) {
if (const auto it = idsByName.find(varying.blockName); it != idsByName.end()) {
const spvtools::opt::Instruction* def = context()->get_def_use_mgr()->GetDef(it->second);
if (def != nullptr && def->opcode() == spv::Op::OpTypeStruct) {
structTypeId = it->second;
} else if (def != nullptr && def->opcode() == spv::Op::OpVariable) {
structTypeId = BlockStructTypeOf(it->second);
}
}
}
if (structTypeId == 0) {
MGLOG_E("XfbCaptureDecoratePass: no SPIR-V interface block '%s' (instance '%s') for "
"capture '%s'",
varying.blockName.c_str(), varying.blockInstanceName.c_str(),
varying.name.c_str());
continue;
}
auto& group =
memberGroups[{structTypeId, static_cast<Uint32>(varying.blockMemberIndex)}];
if (group.offsets.empty() || varying.offsetBytes < group.minOffset) {
group.minOffset = varying.offsetBytes;
}
group.bufferIndex = varying.bufferIndex;
group.elementBytes = varying.byteSize;
group.offsets.push_back(varying.offsetBytes);
continue;
}
const auto idIt = idsByName.find(varying.name); const auto idIt = idsByName.find(varying.name);
if (idIt == idsByName.end()) { if (idIt == idsByName.end()) {
MGLOG_E("XfbCaptureDecoratePass: no SPIR-V variable named '%s'", varying.name.c_str()); MGLOG_E("XfbCaptureDecoratePass: no SPIR-V variable named '%s'", varying.name.c_str());
@@ -986,6 +1257,25 @@ namespace MobileGL::MG_Backend::DirectVulkan {
modified = true; modified = true;
} }
for (auto& [key, group] : memberGroups) {
// The single Offset can only stand for the whole group when the group's
// captures are a gap-free ascending run - that is what SPIR-V lays the
// member's elements out as. Anything else still gets a best-effort
// decoration, but say so, because the capture layout will not match GL.
std::sort(group.offsets.begin(), group.offsets.end());
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 "
"non-contiguous element set; the capture layout will differ from GL's",
key.second, key.first);
break;
}
}
decorateMemberForXfb(key.first, key.second, group.bufferIndex, group.minOffset);
modified = true;
}
if (needsPositionMirror) { if (needsPositionMirror) {
modified |= MirrorPositionForCapture(entryFunctionId, *entryPoint, positionBufferIndex, modified |= MirrorPositionForCapture(entryFunctionId, *entryPoint, positionBufferIndex,
positionOffset, decorateForXfb); positionOffset, decorateForXfb);
@@ -1007,6 +1297,27 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
private: private:
// The struct type an interface-block variable points at, peeling an array of
// block instances on the way. 0 when the id is not a block variable at all.
Uint32 BlockStructTypeOf(Uint32 variableId) {
auto* defUse = context()->get_def_use_mgr();
const spvtools::opt::Instruction* variable = defUse->GetDef(variableId);
if (variable == nullptr || variable->opcode() != spv::Op::OpVariable) return 0;
const spvtools::opt::Instruction* pointer = defUse->GetDef(variable->type_id());
if (pointer == nullptr || pointer->opcode() != spv::Op::OpTypePointer) return 0;
Uint32 pointeeId = pointer->GetSingleWordInOperand(1);
for (const spvtools::opt::Instruction* pointee = defUse->GetDef(pointeeId); pointee != nullptr;
pointee = defUse->GetDef(pointeeId)) {
if (pointee->opcode() == spv::Op::OpTypeStruct) return pointeeId;
if (pointee->opcode() != spv::Op::OpTypeArray &&
pointee->opcode() != spv::Op::OpTypeRuntimeArray) {
return 0;
}
pointeeId = pointee->GetSingleWordInOperand(0);
}
return 0;
}
template <typename DecorateFn> template <typename DecorateFn>
Bool MirrorPositionForCapture(Uint32 entryFunctionId, spvtools::opt::Instruction& entryPoint, Bool MirrorPositionForCapture(Uint32 entryFunctionId, spvtools::opt::Instruction& entryPoint,
Uint32 bufferIndex, Uint32 offsetBytes, const DecorateFn& decorateForXfb) { Uint32 bufferIndex, Uint32 offsetBytes, const DecorateFn& decorateForXfb) {
@@ -1263,8 +1574,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3); spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options; spvtools::OptimizerOptions options;
// Matches the position-fix pass: this build of spirv-tools asserts rather than // Always off: the optimizer's input validator conflates "input invalid" with
// reporting, so validation stays off in the shipping path. // "transform failed", and this call site fails open. Validating lanes check the
// FINAL module via ValidateTransformedSpirv, which latches instead of rerouting
// control flow.
options.set_run_validator(false); options.set_run_validator(false);
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&, optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&,
const char* message) { const char* message) {
@@ -1285,6 +1598,35 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return spvtools::Optimizer::PassToken(MakeUnique<GlToVulkanPositionFixPass>(transformFlags)); return spvtools::Optimizer::PassToken(MakeUnique<GlToVulkanPositionFixPass>(transformFlags));
} }
Bool TransformSpirvForFragCoordYFlip(const Vector<Uint>& input, Vector<Uint>& output,
Uint32 framebufferHeight) {
if (input.empty()) {
output.clear();
return true;
}
if (framebufferHeight == 0) {
output = input;
return true;
}
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options;
options.set_run_validator(false); // see TransformSpirvForExplicitLod0Sampling
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&,
const char* message) {
MGLOG_E("Vulkan: fragcoord y-flip pass: %s", message != nullptr ? message : "");
});
optimizer.RegisterPass(
spvtools::Optimizer::PassToken(MakeUnique<GlFragCoordYFlipPass>(framebufferHeight)));
const Bool success = optimizer.Run(input.data(), input.size(), &output, options);
if (!success) {
MGLOG_E("Vulkan: failed to run the gl_FragCoord y-flip pass; keeping the original module");
output = input;
}
return success;
}
Bool TransformSpirvForXfbCapture(const Vector<Uint>& input, Vector<Uint>& output, Bool TransformSpirvForXfbCapture(const Vector<Uint>& input, Vector<Uint>& output,
const MG_State::GLState::ProgramObject& program) { const MG_State::GLState::ProgramObject& program) {
if (input.empty()) { if (input.empty()) {
@@ -1294,7 +1636,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Vector<XfbCaptureDecoratePass::CapturedVarying> varyings; Vector<XfbCaptureDecoratePass::CapturedVarying> varyings;
varyings.reserve(program.GetTransformFeedbackVaryingCount()); varyings.reserve(program.GetTransformFeedbackVaryingCount());
for (const auto& varying : program.GetTransformFeedbackVaryings()) { for (const auto& varying : program.GetTransformFeedbackVaryings()) {
varyings.push_back({varying.name, varying.bufferIndex, varying.offsetBytes}); varyings.push_back({varying.name, varying.bufferIndex, varying.offsetBytes,
varying.blockInstanceName, varying.blockName, varying.blockMemberIndex,
varying.blockMemberElement, varying.byteSize});
} }
Vector<Uint32> strides; Vector<Uint32> strides;
strides.reserve(program.GetTransformFeedbackBufferCount()); strides.reserve(program.GetTransformFeedbackBufferCount());
@@ -1304,7 +1648,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3); spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options; spvtools::OptimizerOptions options;
options.set_run_validator(false); options.set_run_validator(false); // see TransformSpirvForExplicitLod0Sampling
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&, optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&,
const char* message) { const char* message) {
MGLOG_E("Vulkan: xfb capture pass: %s", message != nullptr ? message : ""); MGLOG_E("Vulkan: xfb capture pass: %s", message != nullptr ? message : "");
@@ -1334,7 +1678,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3); spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options; spvtools::OptimizerOptions options;
options.set_run_validator(false); options.set_run_validator(false); // see TransformSpirvForExplicitLod0Sampling
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&,
const char* message) {
MGLOG_E("Vulkan: position fix pass: %s", message != nullptr ? message : "");
});
optimizer.RegisterPass(CreateGlToVulkanPositionFixPass(transformFlags)); optimizer.RegisterPass(CreateGlToVulkanPositionFixPass(transformFlags));
const Bool success = optimizer.Run(input.data(), input.size(), &output, options); const Bool success = optimizer.Run(input.data(), input.size(), &output, options);
@@ -1486,11 +1834,31 @@ namespace MobileGL::MG_Backend::DirectVulkan {
for (auto* binding : bindings) { for (auto* binding : bindings) {
MOBILEGL_ASSERT(binding != nullptr, "ProgramFactory: null descriptor binding reflection record"); MOBILEGL_ASSERT(binding != nullptr, "ProgramFactory: null descriptor binding reflection record");
const auto kind = ReflectDescriptorTypeToBindingKind(binding->descriptor_type); const auto kind = ReflectDescriptorTypeToBindingKind(binding->descriptor_type);
// UBO instance arrays (uniform Block {...} b[N];) occupy one binding with // A descriptor ARRAY occupies one binding with descriptorCount = N, and is
// descriptorCount = N; other descriptor arrays stay unsupported and must // supported for exactly the kinds that have a per-element resolve path in
// fail program creation cleanly rather than continue with corrupt state. // UniformManager::BindProgramUniformBuffers: UBO instance arrays
if (binding->count != 1 && kind != ProgramFactory::DescriptorBindingKind::UniformBufferDynamic) { // (uniform Block {...} b[N];), storage-block instance arrays, image uniform
MGLOG_E("ProgramFactory: descriptor arrays are unsupported for this descriptor " // arrays, and combined-image-sampler arrays (uniform sampler2D s[N];).
// Anything else - a uniform TEXEL buffer array is the one remaining kind -
// must fail program creation cleanly rather than continue with corrupt state.
//
// 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.
// Which is also why the message below is MGLOG_I: MGLOG_E is compiled out
// of an INFO build, so a refusal that only said MGLOG_E said nothing at all
// in 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_I("ProgramFactory: descriptor arrays are unsupported for this descriptor "
"kind (name='%s' count=%u type=%d)", "kind (name='%s' count=%u type=%d)",
binding->name ? binding->name : "<null>", binding->count, binding->name ? binding->name : "<null>", binding->count,
static_cast<Int>(binding->descriptor_type)); static_cast<Int>(binding->descriptor_type));
@@ -1752,6 +2120,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
XXHASH_VERIFY(XXH64_update(m_hashState, spv.data(), spv.size() * sizeof(Uint))); XXHASH_VERIFY(XXH64_update(m_hashState, spv.data(), spv.size() * sizeof(Uint)));
} }
XXHASH_VERIFY(XXH64_update(m_hashState, &flags, sizeof(CompileOptionFlags))); XXHASH_VERIFY(XXH64_update(m_hashState, &flags, sizeof(CompileOptionFlags)));
// Only FragCoordYFlip variants bake the height in, so mixing it unconditionally would
// re-key every program in the cache on a resize for no reason.
if (flags & CompileOptionBit::FragCoordYFlip) {
XXHASH_VERIFY(XXH64_update(m_hashState, &m_defaultFramebufferHeight,
sizeof(m_defaultFramebufferHeight)));
}
// Include UBO block bindings in hash so different binding configurations produce different entries // Include UBO block bindings in hash so different binding configurations produce different entries
const Uint32 blockCount = static_cast<Uint32>(program.GetActiveUniformBlocksCount()); const Uint32 blockCount = static_cast<Uint32>(program.GetActiveUniformBlocksCount());
@@ -2017,6 +2391,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_I("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_I("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, void ProgramFactory::ReflectLayout(const MG_State::GLState::ProgramObject& program,
const Vector<Vector<Uint>>& spirv, VkProgramObject& entry) const { const Vector<Vector<Uint>>& spirv, VkProgramObject& entry) const {
// Initialize layout vectors // Initialize layout vectors
@@ -2034,6 +2480,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
entry.dynamicBindings.clear(); entry.dynamicBindings.clear();
entry.bindingDescriptorCounts.assign(m_maxBindings, 1); entry.bindingDescriptorCounts.assign(m_maxBindings, 1);
entry.arrayedUniformBlockIndicesByBinding.clear(); entry.arrayedUniformBlockIndicesByBinding.clear();
entry.declinedDescriptors = false;
// Use SpvcSession (Reflection mode) to reflect all SPIR-V modules in a single pass per module // Use SpvcSession (Reflection mode) to reflect all SPIR-V modules in a single pass per module
for (const auto& module : spirv) { for (const auto& module : spirv) {
@@ -2224,11 +2671,58 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
entry.storageBlockNameByBinding[binding] = uniformName; entry.storageBlockNameByBinding[binding] = uniformName;
entry.storageBlockIndexByBinding[binding] = static_cast<Int>(blockIndex); entry.storageBlockIndexByBinding[binding] = static_cast<Int>(blockIndex);
// A block INSTANCE array is ONE Vulkan binding carrying `count`
// descriptors, while GL assigns its elements consecutive binding points
// starting at the declared one (GL 4.6 core 7.8). Recording only element 0 -
// which is all this used to do - left the layout claiming descriptorCount 1,
// so every element past the first read a descriptor nobody wrote and
// `b[1].data.length()` answered from an unconstrained buffer instead of its
// own bound range (KHR-GL43.shader_storage_buffer_object.-
// advanced-unsizedArrayLength-*).
//
// 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_I("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; continue;
} }
const Int location = program.GetUniformLocation(uniformName); const Int location = program.GetUniformLocation(uniformName);
if (location < 0) { 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_I("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; entry.bindingKinds[binding] = DescriptorBindingKind::None;
continue; continue;
} }
@@ -2236,6 +2730,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const GLenum uniformType = program.GetUniformType(static_cast<Uint>(location)); const GLenum uniformType = program.GetUniformType(static_cast<Uint>(location));
if (descriptorKind == DescriptorBindingKind::StorageImage) { 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 = const VkFormat reflectedFormat =
ConvertSpirvImageFormatToVkFormat(sampler->image.image_format); ConvertSpirvImageFormatToVkFormat(sampler->image.image_format);
VkFormat& existingFormat = entry.storageImageFormatByBinding[binding]; VkFormat& existingFormat = entry.storageImageFormatByBinding[binding];
@@ -2266,6 +2778,20 @@ namespace MobileGL::MG_Backend::DirectVulkan {
"ProgramFactory::ReflectLayout: failed to resolve texture target for '%s'", "ProgramFactory::ReflectLayout: failed to resolve texture target for '%s'",
uniformName.c_str()); uniformName.c_str());
if (descriptorKind == DescriptorBindingKind::CombinedImageSampler) { 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); const SamplerNumericDomain numericDomain = UniformTypeToSamplerNumericDomain(uniformType);
MOBILEGL_ASSERT(numericDomain != SamplerNumericDomain::Unknown, MOBILEGL_ASSERT(numericDomain != SamplerNumericDomain::Unknown,
"ProgramFactory::ReflectLayout: failed to resolve sampler numeric domain " "ProgramFactory::ReflectLayout: failed to resolve sampler numeric domain "
@@ -2360,14 +2886,36 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
} }
void ProgramFactory::SetDefaultFramebufferHeight(Uint32 height) {
if (m_defaultFramebufferHeight == height) {
return;
}
m_defaultFramebufferHeight = height;
// Both memos key on (program, flags) alone, so neither can tell the two heights apart:
// drop the lookup memo, and bump the structure epoch so every caller holding a
// VkProgramObject* re-runs GetOrCreateProgram and lands on the new hash. The cached
// entries themselves stay - they are keyed by a hash that now includes the old height,
// so they can only be reached again if that height comes back, and the frame-boundary
// sweep retires them otherwise.
m_lastLookup = {};
++m_cacheStructureEpoch;
}
const ProgramFactory::VkProgramObject& ProgramFactory::GetOrCreateProgram( const ProgramFactory::VkProgramObject& ProgramFactory::GetOrCreateProgram(
const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags) { const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags) {
// Hashing the full SPIR-V of every stage is far too expensive to repeat per draw; // Hashing the full SPIR-V of every stage is far too expensive to repeat per draw;
// reuse the program's memoized hash while its backend state version is unchanged. // reuse the program's memoized hash while its backend state version is unchanged.
// The memo keys on the flags word, which ComputeHash is no longer a pure function of:
// 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.
const Uint memoKey = (flags & CompileOptionBit::FragCoordYFlip)
? (flags.GetRaw() | (m_defaultFramebufferHeight << 16))
: flags.GetRaw();
HashType hash = 0; HashType hash = 0;
if (!program.GetBackendHashMemo(flags.GetRaw(), hash)) { if (!program.GetBackendHashMemo(memoKey, hash)) {
hash = ComputeHash(program, flags); hash = ComputeHash(program, flags);
program.SetBackendHashMemo(flags.GetRaw(), hash); program.SetBackendHashMemo(memoKey, hash);
} }
auto it = m_cache.find(hash); auto it = m_cache.find(hash);
if (it != m_cache.end()) { if (it != m_cache.end()) {
@@ -2420,6 +2968,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
} }
if ((flags & ProgramFactory::CompileOptionBit::FragCoordYFlip) && shaders[i] &&
shaders[i]->GetShaderStage() == ShaderStage::Fragment) {
Vector<Uint> fragCoordSpirv;
if (TransformSpirvForFragCoordYFlip(moduleSpirvs[i], fragCoordSpirv, m_defaultFramebufferHeight)) {
moduleSpirvs[i] = Move(fragCoordSpirv);
}
}
// Vulkan's SPIR-V environment has no rectangle image dimension, so a // Vulkan's SPIR-V environment has no rectangle image dimension, so a
// GL_TEXTURE_RECTANGLE lookup has to become the 2D one the texture is really // GL_TEXTURE_RECTANGLE lookup has to become the 2D one the texture is really
// stored as - which addresses [0,1] where the application addressed texels. // stored as - which addresses [0,1] where the application addressed texels.
@@ -2525,6 +3081,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG #if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
ValidateTransformedSpirv(moduleSpv, shaders[i]->GetShaderStage(), program.GetExternalIndex()); ValidateTransformedSpirv(moduleSpv, shaders[i]->GetShaderStage(), program.GetExternalIndex());
#else
// Final module the driver receives; also checked in the INFO-level CI/test
// lanes, where the DEBUG gate above is compiled out.
if (MG_Util::ShaderTranspiler::ShaderCompiler::SpirvValidationEnabled()) {
ValidateTransformedSpirv(moduleSpv, shaders[i]->GetShaderStage(), program.GetExternalIndex());
}
#endif #endif
VkShaderModuleCreateInfo smci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; VkShaderModuleCreateInfo smci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
@@ -2542,6 +3104,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
entry.modules.push_back(module); entry.modules.push_back(module);
entry.stages.push_back(stage); entry.stages.push_back(stage);
entry.stageSpirvDigests.push_back(ShaderStageSpirvDigest{
static_cast<Uint32>(stage.stage), static_cast<Uint32>(moduleSpv.size()),
XXH64(moduleSpv.data(), moduleSpv.size() * sizeof(Uint), 0)});
} }
// Reflect and create layout as part of the program object // Reflect and create layout as part of the program object
@@ -2551,6 +3116,20 @@ namespace MobileGL::MG_Backend::DirectVulkan {
ReflectVertexInputs(shaders, moduleSpirvs, entry); ReflectVertexInputs(shaders, moduleSpirvs, entry);
ReflectFragmentOutputs(shaders, moduleSpirvs, entry); ReflectFragmentOutputs(shaders, moduleSpirvs, entry);
ReflectLayout(program, 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_I("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; return entry;
} }
@@ -9,6 +9,7 @@
#pragma once #pragma once
#include "../VkIncludes.h" #include "../VkIncludes.h"
#include "PipelineFactory.h"
#include "MG_State/GLState/ProgramState/ProgramObject.h" #include "MG_State/GLState/ProgramState/ProgramObject.h"
#include "MG_State/GLState/ProgramState/ShaderObject.h" #include "MG_State/GLState/ProgramState/ShaderObject.h"
#include "MG_State/GLState/TextureState/TextureEnum.h" #include "MG_State/GLState/TextureState/TextureEnum.h"
@@ -52,6 +53,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// recorded while GL transform feedback is active, so plain draws keep the // recorded while GL transform feedback is active, so plain draws keep the
// undecorated variant. // undecorated variant.
XfbCapture = 1 << 6, XfbCapture = 1 << 6,
// Rewrites the fragment stage's gl_FragCoord reads to GL's bottom-left window
// origin. Vulkan's gl_FragCoord.y IS the framebuffer row being written, and the
// default framebuffer's image is stored in display (top-left) order, so a shader
// that reads gl_FragCoord there sees `height - y_GL`. Set together with
// 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,
}; };
using CompileOptionFlags = Flags<CompileOptionBit>; using CompileOptionFlags = Flags<CompileOptionBit>;
using HashType = Uint64; using HashType = Uint64;
@@ -62,6 +70,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
HashType hash = 0; HashType hash = 0;
Vector<VkPipelineShaderStageCreateInfo> stages; Vector<VkPipelineShaderStageCreateInfo> stages;
Vector<VkShaderModule> modules; Vector<VkShaderModule> modules;
// Parallel to stages; identifies the exact module bytes handed to the driver when a
// pipeline creation fails. Sixteen bytes per stage instead of keeping the SPIR-V.
Vector<ShaderStageSpirvDigest> stageSpirvDigests;
// Layout data (previously in separate VkProgramLayout) // Layout data (previously in separate VkProgramLayout)
VkDescriptorSetLayout descriptorSetLayout = VK_NULL_HANDLE; VkDescriptorSetLayout descriptorSetLayout = VK_NULL_HANDLE;
@@ -75,8 +86,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Vector<Uint32> activeBindings; Vector<Uint32> activeBindings;
Vector<Uint32> dynamicBindings; Vector<Uint32> dynamicBindings;
Vector<Int> uniformBlockIndexByBinding; Vector<Int> uniformBlockIndexByBinding;
// Descriptor count per binding (1 except for UBO instance arrays, which occupy one // Descriptor count per binding (1 except for a descriptor ARRAY - a UBO or storage
// binding with descriptorCount = N). // block instance array, an image uniform array or a sampler uniform array - each of
// which occupies one binding with descriptorCount = N).
Vector<Uint16> bindingDescriptorCounts; Vector<Uint16> bindingDescriptorCounts;
// Per-element GL uniform block indices for arrayed UBO bindings (count > 1); // Per-element GL uniform block indices for arrayed UBO bindings (count > 1);
// element 0 of a non-arrayed binding stays in uniformBlockIndexByBinding. // element 0 of a non-arrayed binding stays in uniformBlockIndexByBinding.
@@ -92,6 +104,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Set once during ReflectLayout so the per-draw path can skip the whole // Set once during ReflectLayout so the per-draw path can skip the whole
// storage-image preparation for the overwhelming majority of programs. // storage-image preparation for the overwhelming majority of programs.
Bool hasStorageImages = false; 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; Int globalUboBinding = -1;
Uint32 activeVertexInputLocationMask = 0; Uint32 activeVertexInputLocationMask = 0;
Array<GLenum, kMaxVertexInputLocations> vertexInputTypes{}; Array<GLenum, kMaxVertexInputLocations> vertexInputTypes{};
@@ -118,6 +143,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
hash = other.hash; hash = other.hash;
stages = std::move(other.stages); stages = std::move(other.stages);
modules = std::move(other.modules); 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; descriptorSetLayout = other.descriptorSetLayout;
pipelineLayout = other.pipelineLayout; pipelineLayout = other.pipelineLayout;
bindingKinds = std::move(other.bindingKinds); bindingKinds = std::move(other.bindingKinds);
@@ -136,6 +169,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
storageBlockNameByBinding = std::move(other.storageBlockNameByBinding); storageBlockNameByBinding = std::move(other.storageBlockNameByBinding);
storageBlockIndexByBinding = std::move(other.storageBlockIndexByBinding); storageBlockIndexByBinding = std::move(other.storageBlockIndexByBinding);
hasStorageImages = other.hasStorageImages; hasStorageImages = other.hasStorageImages;
declinedDescriptors = other.declinedDescriptors;
globalUboBinding = other.globalUboBinding; globalUboBinding = other.globalUboBinding;
activeVertexInputLocationMask = other.activeVertexInputLocationMask; activeVertexInputLocationMask = other.activeVertexInputLocationMask;
vertexInputTypes = other.vertexInputTypes; vertexInputTypes = other.vertexInputTypes;
@@ -150,6 +184,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
other.descriptorSetLayout = VK_NULL_HANDLE; other.descriptorSetLayout = VK_NULL_HANDLE;
other.pipelineLayout = VK_NULL_HANDLE; other.pipelineLayout = VK_NULL_HANDLE;
other.hasStorageImages = false; other.hasStorageImages = false;
other.declinedDescriptors = false;
other.globalUboBinding = -1; other.globalUboBinding = -1;
other.activeVertexInputLocationMask = 0; other.activeVertexInputLocationMask = 0;
other.activeFragmentOutputLocationMask = 0; other.activeFragmentOutputLocationMask = 0;
@@ -167,6 +202,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
hash = other.hash; hash = other.hash;
stages = std::move(other.stages); stages = std::move(other.stages);
modules = std::move(other.modules); modules = std::move(other.modules);
stageSpirvDigests = std::move(other.stageSpirvDigests); // travels with `modules` - see the move ctor
descriptorSetLayout = other.descriptorSetLayout; descriptorSetLayout = other.descriptorSetLayout;
pipelineLayout = other.pipelineLayout; pipelineLayout = other.pipelineLayout;
bindingKinds = std::move(other.bindingKinds); bindingKinds = std::move(other.bindingKinds);
@@ -185,6 +221,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
storageBlockNameByBinding = std::move(other.storageBlockNameByBinding); storageBlockNameByBinding = std::move(other.storageBlockNameByBinding);
storageBlockIndexByBinding = std::move(other.storageBlockIndexByBinding); storageBlockIndexByBinding = std::move(other.storageBlockIndexByBinding);
hasStorageImages = other.hasStorageImages; hasStorageImages = other.hasStorageImages;
declinedDescriptors = other.declinedDescriptors;
globalUboBinding = other.globalUboBinding; globalUboBinding = other.globalUboBinding;
activeVertexInputLocationMask = other.activeVertexInputLocationMask; activeVertexInputLocationMask = other.activeVertexInputLocationMask;
vertexInputTypes = other.vertexInputTypes; vertexInputTypes = other.vertexInputTypes;
@@ -199,6 +236,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
other.descriptorSetLayout = VK_NULL_HANDLE; other.descriptorSetLayout = VK_NULL_HANDLE;
other.pipelineLayout = VK_NULL_HANDLE; other.pipelineLayout = VK_NULL_HANDLE;
other.hasStorageImages = false; other.hasStorageImages = false;
other.declinedDescriptors = false;
other.globalUboBinding = -1; other.globalUboBinding = -1;
other.activeVertexInputLocationMask = 0; other.activeVertexInputLocationMask = 0;
other.activeFragmentOutputLocationMask = 0; other.activeFragmentOutputLocationMask = 0;
@@ -233,6 +271,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
modules.clear(); modules.clear();
stages.clear(); stages.clear();
stageSpirvDigests.clear(); // the modules they describe are gone
} }
}; };
@@ -263,6 +302,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const VkProgramObject& GetOrCreateProgram( const VkProgramObject& GetOrCreateProgram(
const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags); const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags);
// The default framebuffer's current image height, baked as a literal into every
// FragCoordYFlip variant (there is no push-constant or specialization channel here, and
// adding one for a value that changes only on swapchain recreation would cost the draw
// path more than a recompile costs a resize). It is therefore part of those variants'
// identity: ComputeHash mixes it in when the bit is set, so a height change re-keys them
// and leaves every other program's hash untouched. Setting a NEW height also bumps the
// cache-structure epoch, because a caller holding a memoised VkProgramObject* would
// otherwise keep using a module compiled against the old height.
void SetDefaultFramebufferHeight(Uint32 height);
Uint32 GetDefaultFramebufferHeight() const { return m_defaultFramebufferHeight; }
// Bumped whenever m_cache's STRUCTURE changes (any insert or erase): the cache is // Bumped whenever m_cache's STRUCTURE changes (any insert or erase): the cache is
// an open-addressing map holding entries by value, so both moves existing entries. // an open-addressing map holding entries by value, so both moves existing entries.
// A caller that memoised a VkProgramObject* may keep dereferencing it only while // A caller that memoised a VkProgramObject* may keep dereferencing it only while
@@ -320,6 +370,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// True only when the logical device enabled both // True only when the logical device enabled both
// shaderStorageImageReadWithoutFormat and shaderStorageImageWriteWithoutFormat. // shaderStorageImageReadWithoutFormat and shaderStorageImageWriteWithoutFormat.
Bool m_unformattedFloatStorageImagesEnabled = false; Bool m_unformattedFloatStorageImagesEnabled = false;
// See SetDefaultFramebufferHeight. 0 means "not known yet"; the FragCoordYFlip bit is
// never set before the swapchain exists, so no variant can be compiled against it.
Uint32 m_defaultFramebufferHeight = 0;
mutable ProgramLookupCache m_lastLookup; mutable ProgramLookupCache m_lastLookup;
// Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging. // Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging.
Uint64 m_frameCounter = 0; Uint64 m_frameCounter = 0;
@@ -68,6 +68,29 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
} }
// Uniform location of ELEMENT `element` of the opaque-uniform array at `baseLocation`, or
// -1 when the reflection did not reserve that element. DoReflection hands out one location
// per array element, so the element's location is the base plus its index - bounded by the
// array's real extent so a descriptorCount that outran the reflection cannot walk onto the
// next uniform. Element 0 is the ordinary non-array case and costs nothing extra.
static Int ResolveDescriptorElementLocation(const MG_State::GLState::ProgramObject& program, Int baseLocation,
Uint32 element) {
if (baseLocation < 0 || element == 0) {
return baseLocation;
}
const Int location = baseLocation + static_cast<Int>(element);
return program.UniformLocationsAliasSameUniform(baseLocation, location) ? location : -1;
}
// descriptorCount this binding declares in the descriptor set layout (1 for everything that
// is not an array). Kept in one place because the layout, the scratch reservation and the
// per-element write loops must all agree on it.
static Uint32 BindingDescriptorCount(const ProgramFactory::VkProgramObject& programObj, Uint32 binding) {
return binding < programObj.bindingDescriptorCounts.size()
? std::max<Uint32>(1u, programObj.bindingDescriptorCounts[binding])
: 1u;
}
static Int ResolveSamplerUnitIndex(const MG_State::GLState::ProgramObject& program, Int location, Uint32 binding) { static Int ResolveSamplerUnitIndex(const MG_State::GLState::ProgramObject& program, Int location, Uint32 binding) {
MOBILEGL_ASSERT(location >= -1, "ResolveSamplerUnitIndex: invalid sampler location for binding %u", binding); MOBILEGL_ASSERT(location >= -1, "ResolveSamplerUnitIndex: invalid sampler location for binding %u", binding);
if (location < 0) { if (location < 0) {
@@ -268,28 +291,44 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool UniformManager::ResolveSamplerDescriptor(VkCommandBuffer commandBuffer, Bool UniformManager::ResolveSamplerDescriptor(VkCommandBuffer commandBuffer,
const MG_State::GLState::ProgramObject& program, const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, const ProgramFactory::VkProgramObject& programObj,
Uint32 binding, VkDescriptorImageInfo& outImageInfo, Uint32 binding, Uint32 element,
VkDescriptorImageInfo& outImageInfo,
Bool trustUnchangedHint) const { Bool trustUnchangedHint) const {
MOBILEGL_ASSERT(m_textureManager != nullptr, "ResolveSamplerDescriptor: texture manager is null"); MOBILEGL_ASSERT(m_textureManager != nullptr, "ResolveSamplerDescriptor: texture manager is null");
MOBILEGL_ASSERT(m_samplerManager != nullptr, "ResolveSamplerDescriptor: sampler manager is null"); MOBILEGL_ASSERT(m_samplerManager != nullptr, "ResolveSamplerDescriptor: sampler manager is null");
// The whole-descriptor memo below is keyed by binding alone, so it describes a binding
// that carries exactly one descriptor. An arrayed binding's elements would overwrite
// each other in it (see SamplerResolveMemo::info); they re-resolve instead.
const Bool descriptorMemoUsable = BindingDescriptorCount(programObj, binding) == 1u;
// The caller proved every input of this binding's resolution unchanged since the // The caller proved every input of this binding's resolution unchanged since the
// last full resolve (which also filled the cache), so the whole chain below - // last full resolve (which also filled the cache), so the whole chain below -
// texture/sampler resolution, completeness probe, sync, layout handling, sampler // texture/sampler resolution, completeness probe, sync, layout handling, sampler
// and view lookups - would recompute the identical descriptor. // and view lookups - would recompute the identical descriptor.
if (trustUnchangedHint && binding < m_samplerResolveMemo.size() && if (trustUnchangedHint && descriptorMemoUsable && binding < m_samplerResolveMemo.size() &&
m_samplerResolveMemo[binding].infoValid) { m_samplerResolveMemo[binding].infoValid) {
outImageInfo = m_samplerResolveMemo[binding].info; outImageInfo = m_samplerResolveMemo[binding].info;
return true; return true;
} }
MOBILEGL_ASSERT(binding < programObj.samplerNameByBinding.size(), MOBILEGL_ASSERT(binding < programObj.samplerNameByBinding.size(),
"ResolveSamplerDescriptor: sampler binding %u name lookup out of range", binding); "ResolveSamplerDescriptor: sampler binding %u name lookup out of range", binding);
// Per ELEMENT, and resolved BEFORE anything is looked up through it: GLSL 4.20 gives every
// element of `uniform sampler2D goku[4]` its own texture unit (consecutive from the
// declared binding, but glUniform1i may scatter them afterwards), so the unit - and with
// it the bound texture, the unit's sampler override and the fallback decision - is the
// element's, not the binding's. An element past the array's reserved extent has no unit
// at all, and must not fall back to resolving unit 0's texture.
const Int location =
ResolveDescriptorElementLocation(program, programObj.samplerUniformLocationByBinding[binding], element);
if (location < 0 && element > 0) {
MGLOG_D("ResolveSamplerDescriptor: binding %u element %u is past the end of its sampler array", binding,
element);
return false;
}
const Int unit = ResolveSamplerUnitIndex(program, location, binding);
// Raw-pointer resolve to skip the SharedPtr atomic refcount churn: the bound texture stays // Raw-pointer resolve to skip the SharedPtr atomic refcount churn: the bound texture stays
// alive through the draw via GL binding state. Only the fallback path needs a SharedPtr to // alive through the draw via GL binding state. Only the fallback path needs a SharedPtr to
// keep the fallback texture alive for the rest of this call. // keep the fallback texture alive for the rest of this call.
MG_State::GLState::ITextureObject* texture = ResolveSamplerTextureRaw(program, programObj, binding); MG_State::GLState::ITextureObject* texture = ResolveSamplerTextureRaw(program, programObj, binding, element);
const Int location = programObj.samplerUniformLocationByBinding[binding];
const Int unit = ResolveSamplerUnitIndex(program, location, binding);
auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& samplerOverride = textureUnit.GetSamplerObject(); const auto& samplerOverride = textureUnit.GetSamplerObject();
const auto preferredTarget = programObj.samplerTextureTargetByBinding[binding]; const auto preferredTarget = programObj.samplerTextureTargetByBinding[binding];
@@ -459,9 +498,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (outImageInfo.sampler == VK_NULL_HANDLE) { if (outImageInfo.sampler == VK_NULL_HANDLE) {
return false; return false;
} }
// Only for a binding that carries a single descriptor - an array's elements would
// publish each other's descriptors here, and the next hinted draw would hand element
// N-1's texture to element 0.
if (binding < m_samplerResolveMemo.size()) { if (binding < m_samplerResolveMemo.size()) {
m_samplerResolveMemo[binding].info = outImageInfo; if (descriptorMemoUsable) {
m_samplerResolveMemo[binding].infoValid = true; m_samplerResolveMemo[binding].info = outImageInfo;
m_samplerResolveMemo[binding].infoValid = true;
} else {
// An arrayed binding publishes nothing here, and clears what a previous program
// published at this index. Not strictly required - the hint's proof obligations
// are program-scoped and the entry is reset every frame - but leaving another
// program's descriptor sitting in a slot this one never refreshes is the kind of
// thing the next reader has to re-derive is safe.
m_samplerResolveMemo[binding].infoValid = false;
}
NoteSamplerResolveMemoTouched(binding); NoteSamplerResolveMemoTouched(binding);
} }
return true; return true;
@@ -500,42 +551,58 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool UniformManager::ProgramSamplesOnlySingleLevelTextures( Bool UniformManager::ProgramSamplesOnlySingleLevelTextures(
const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj) { const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj) {
// A declined program never draws (see VkProgramObject::declinedDescriptors), and its
// declined binding has no resolvable uniform location - so there is nothing to prove
// about the textures it would have sampled.
if (programObj.declinedDescriptors) {
return false;
}
Bool sawSampler = false; Bool sawSampler = false;
for (Uint32 binding = 0; binding < programObj.bindingKinds.size(); ++binding) { for (Uint32 binding = 0; binding < programObj.bindingKinds.size(); ++binding) {
if (programObj.bindingKinds[binding] != ProgramFactory::DescriptorBindingKind::CombinedImageSampler) { if (programObj.bindingKinds[binding] != ProgramFactory::DescriptorBindingKind::CombinedImageSampler) {
continue; continue;
} }
const auto* texture = ResolveSamplerTextureRaw(program, programObj, binding); // The rewrite this gates is program-wide, so EVERY sampler the program can read has
if (texture == nullptr) return false; // to qualify - including every element of a sampler array, each of which reaches a
const auto& levelRange = texture->GetLevelRange(); // different texture through its own unit.
if (levelRange.x() != levelRange.y()) return false; const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding);
for (Uint32 element = 0; element < descriptorCount; ++element) {
// The element's own location first, exactly as ResolveSamplerDescriptor resolves
// it - an element with no location would otherwise be judged on unit 0's texture.
const Int location = ResolveDescriptorElementLocation(
program, programObj.samplerUniformLocationByBinding[binding], element);
if (location < 0 && element > 0) return false;
const auto* texture = ResolveSamplerTextureRaw(program, programObj, binding, element);
if (texture == nullptr) return false;
const auto& levelRange = texture->GetLevelRange();
if (levelRange.x() != levelRange.y()) return false;
// An explicit-LOD sample is a single filtered tap, so it also gives up anisotropic // An explicit-LOD sample is a single filtered tap, so it also gives up anisotropic
// filtering - which a single-level view can still have. Resolve the sampler exactly // filtering - which a single-level view can still have. Resolve the sampler exactly
// the way ResolveSamplerDescriptor does and bail if anisotropy would apply. // the way ResolveSamplerDescriptor does and bail if anisotropy would apply.
const Int location = programObj.samplerUniformLocationByBinding[binding]; const Int unit = ResolveSamplerUnitIndex(program, location, binding);
const Int unit = ResolveSamplerUnitIndex(program, location, binding); const auto& samplerOverride = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject();
const auto& samplerOverride = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject(); const auto* effectiveSampler =
const auto* effectiveSampler = samplerOverride ? samplerOverride.get() : texture->GetSamplerObject().get();
samplerOverride ? samplerOverride.get() : texture->GetSamplerObject().get(); if (effectiveSampler == nullptr) return false;
if (effectiveSampler == nullptr) return false; if (effectiveSampler->GetMaxAnisotropy() > 1.0f &&
if (effectiveSampler->GetMaxAnisotropy() > 1.0f && effectiveSampler->GetMinFilter() == SamplerFilterMode::Linear &&
effectiveSampler->GetMinFilter() == SamplerFilterMode::Linear && effectiveSampler->GetMagFilter() == SamplerFilterMode::Linear) {
effectiveSampler->GetMagFilter() == SamplerFilterMode::Linear) { return false;
return false; }
}
// An explicit LOD 0 makes lambda exactly 0, which is the magnification side of the // An explicit LOD 0 makes lambda exactly 0, which is the magnification side of the
// min/mag decision. That only matches the implicit form when lambda could not have been // min/mag decision. That only matches the implicit form when lambda could not have been
// positive anyway (the LOD clamp already pins it at or below 0), or when the two // positive anyway (the LOD clamp already pins it at or below 0), or when the two
// filters are the same and the choice cannot be observed. // filters are the same and the choice cannot be observed.
const Float effectiveMaxLod = effectiveSampler->GetMipmapMode() == SamplerMipmapMode::None const Float effectiveMaxLod = effectiveSampler->GetMipmapMode() == SamplerMipmapMode::None
? 0.0f ? 0.0f
: effectiveSampler->GetMaxLod(); : effectiveSampler->GetMaxLod();
if (effectiveMaxLod > 0.0f && effectiveSampler->GetMinFilter() != effectiveSampler->GetMagFilter()) { if (effectiveMaxLod > 0.0f && effectiveSampler->GetMinFilter() != effectiveSampler->GetMagFilter()) {
return false; return false;
}
sawSampler = true;
} }
sawSampler = true;
} }
return sawSampler; return sawSampler;
} }
@@ -568,14 +635,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MG_State::GLState::ITextureObject* UniformManager::ResolveSamplerTextureRaw( MG_State::GLState::ITextureObject* UniformManager::ResolveSamplerTextureRaw(
const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj,
Uint32 binding) { Uint32 binding, Uint32 element) {
MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveSamplerTextureRaw: GL context is null"); MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveSamplerTextureRaw: GL context is null");
MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(),
"ResolveSamplerTextureRaw: sampler location binding %u out of range", binding); "ResolveSamplerTextureRaw: sampler location binding %u out of range", binding);
MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(), MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(),
"ResolveSamplerTextureRaw: sampler target binding %u out of range", binding); "ResolveSamplerTextureRaw: sampler target binding %u out of range", binding);
const Int location = programObj.samplerUniformLocationByBinding[binding]; const Int location =
ResolveDescriptorElementLocation(program, programObj.samplerUniformLocationByBinding[binding], element);
const Int unit = ResolveSamplerUnitIndex(program, location, binding); const Int unit = ResolveSamplerUnitIndex(program, location, binding);
auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
@@ -677,7 +745,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool UniformManager::ResolveStorageBufferDescriptor(const MG_State::GLState::ProgramObject& program, Bool UniformManager::ResolveStorageBufferDescriptor(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, const ProgramFactory::VkProgramObject& programObj,
Uint32 binding, Uint32 binding, Uint32 element,
VkDescriptorBufferInfo& outBufferInfo) const { VkDescriptorBufferInfo& outBufferInfo) const {
outBufferInfo = {}; outBufferInfo = {};
MOBILEGL_ASSERT(m_bufferManager != nullptr, "ResolveStorageBufferDescriptor: buffer manager is null"); MOBILEGL_ASSERT(m_bufferManager != nullptr, "ResolveStorageBufferDescriptor: buffer manager is null");
@@ -688,8 +756,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const Int blockIndex = programObj.storageBlockIndexByBinding[binding]; const Int blockIndex = programObj.storageBlockIndexByBinding[binding];
MOBILEGL_ASSERT(blockIndex >= 0, "ResolveStorageBufferDescriptor: no SSBO block mapped to binding %u", MOBILEGL_ASSERT(blockIndex >= 0, "ResolveStorageBufferDescriptor: no SSBO block mapped to binding %u",
binding); binding);
// A block instance array declares one block whose elements take consecutive GL binding
// points from the declared one (GL 4.6 core 7.8), and the reflection collapses the whole
// array to that one block - so the element index IS the offset from its binding.
const GLuint frontendBinding = const GLuint frontendBinding =
GetShaderStorageBlockBinding(program, static_cast<GLuint>(blockIndex)); GetShaderStorageBlockBinding(program, static_cast<GLuint>(blockIndex)) + element;
const Uint32 bindingPointCount = const Uint32 bindingPointCount =
static_cast<Uint32>(MG_State::pGLContext->GetBufferBindingPointCount(BufferTarget::ShaderStorage)); static_cast<Uint32>(MG_State::pGLContext->GetBufferBindingPointCount(BufferTarget::ShaderStorage));
MOBILEGL_ASSERT(frontendBinding < bindingPointCount, MOBILEGL_ASSERT(frontendBinding < bindingPointCount,
@@ -742,7 +813,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool UniformManager::ResolveStorageImageDescriptor(VkCommandBuffer commandBuffer, Bool UniformManager::ResolveStorageImageDescriptor(VkCommandBuffer commandBuffer,
const MG_State::GLState::ProgramObject& program, const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, const ProgramFactory::VkProgramObject& programObj,
Uint32 binding, Uint32 binding, Uint32 element,
VkDescriptorImageInfo& outImageInfo) const { VkDescriptorImageInfo& outImageInfo) const {
outImageInfo = {}; outImageInfo = {};
MOBILEGL_ASSERT(m_textureManager != nullptr, "ResolveStorageImageDescriptor: texture manager is null"); MOBILEGL_ASSERT(m_textureManager != nullptr, "ResolveStorageImageDescriptor: texture manager is null");
@@ -750,11 +821,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(),
"ResolveStorageImageDescriptor: binding %u out of range", binding); "ResolveStorageImageDescriptor: binding %u out of range", binding);
const Int location = programObj.samplerUniformLocationByBinding[binding]; const Int baseLocation = programObj.samplerUniformLocationByBinding[binding];
if (location < 0) { if (baseLocation < 0) {
MGLOG_E("ResolveStorageImageDescriptor: storage image binding %u has no uniform location", binding); MGLOG_E("ResolveStorageImageDescriptor: storage image binding %u has no uniform location", binding);
return false; return false;
} }
// Per ELEMENT, and this is where an image array differs from a storage-block array: GL
// gives every element of `uniform image2D g_image[4]` its own glUniform1i-assigned image
// unit, and the four units need not be consecutive or even ordered (the conformance case
// uses 0, 2, 4, 6). DoReflection reserves one uniform location per array element, so the
// element's location is the base plus its index - checked against the array's real
// extent so a descriptorCount that outran the reflection cannot walk onto the next
// uniform.
const Int location = baseLocation + static_cast<Int>(element);
if (!program.UniformLocationsAliasSameUniform(baseLocation, location)) {
MGLOG_E("ResolveStorageImageDescriptor: binding %u element %u is past the end of its image array",
binding, element);
return false;
}
const Int imageUnit = program.GetUniformSamplerOrImageUnitIndex(static_cast<Uint>(location)); const Int imageUnit = program.GetUniformSamplerOrImageUnitIndex(static_cast<Uint>(location));
if (imageUnit < 0 || imageUnit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { if (imageUnit < 0 || imageUnit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) {
MGLOG_E("ResolveStorageImageDescriptor: image unit %d out of range for binding %u", MGLOG_E("ResolveStorageImageDescriptor: image unit %d out of range for binding %u",
@@ -844,7 +928,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool UniformManager::ResolveSampledBinding(const MG_State::GLState::ProgramObject& program, Bool UniformManager::ResolveSampledBinding(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, const ProgramFactory::VkProgramObject& programObj,
Uint32 binding, Uint32 binding, Uint32 element,
MG_State::GLState::ITextureObject*& outTexture, MG_State::GLState::ITextureObject*& outTexture,
const MG_State::GLState::SamplerObject*& outSampler) const { const MG_State::GLState::SamplerObject*& outSampler) const {
// Open-coded ResolveSamplerTextureRaw so the unit is resolved once for both the // Open-coded ResolveSamplerTextureRaw so the unit is resolved once for both the
@@ -855,7 +939,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
"ResolveSampledBinding: sampler location binding %u out of range", binding); "ResolveSampledBinding: sampler location binding %u out of range", binding);
MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(), MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(),
"ResolveSampledBinding: sampler target binding %u out of range", binding); "ResolveSampledBinding: sampler target binding %u out of range", binding);
const Int location = programObj.samplerUniformLocationByBinding[binding]; const Int location =
ResolveDescriptorElementLocation(program, programObj.samplerUniformLocationByBinding[binding], element);
if (location < 0 && element > 0) {
return false;
}
const Int unit = ResolveSamplerUnitIndex(program, location, binding); const Int unit = ResolveSamplerUnitIndex(program, location, binding);
auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding]; const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding];
@@ -891,6 +979,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (outBindingRecords != nullptr) { if (outBindingRecords != nullptr) {
outBindingRecords->clear(); outBindingRecords->clear();
} }
// Nothing to prepare for a program the bind path is going to refuse; its declined
// binding has no uniform location to resolve a texture through either.
if (programObj.declinedDescriptors) {
return true;
}
const Uint32 bindingCount = const Uint32 bindingCount =
std::min<Uint32>(m_maxBindings, static_cast<Uint32>(programObj.bindingKinds.size())); std::min<Uint32>(m_maxBindings, static_cast<Uint32>(programObj.bindingKinds.size()));
@@ -899,19 +992,27 @@ namespace MobileGL::MG_Backend::DirectVulkan {
continue; continue;
} }
MG_State::GLState::ITextureObject* texture = nullptr; // Every ELEMENT of a sampler array reaches its own texture through its own unit,
const MG_State::GLState::SamplerObject* sampler = nullptr; // so every element has to be in the sampled set: this walk is what gets those
if (!ResolveSampledBinding(program, programObj, binding, texture, sampler)) { // textures synced and transitioned to a sampled layout BEFORE the render pass
continue; // opens, and a missed element would first be touched by the descriptor resolve
} // inside an active pass.
if (outBindingRecords != nullptr) { const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding);
outBindingRecords->push_back({texture != nullptr ? texture->GetLifetimeId() : 0, for (Uint32 element = 0; element < descriptorCount; ++element) {
sampler != nullptr ? sampler->GetLifetimeId() : 0}); MG_State::GLState::ITextureObject* texture = nullptr;
} const MG_State::GLState::SamplerObject* sampler = nullptr;
if (!ResolveSampledBinding(program, programObj, binding, element, texture, sampler)) {
continue;
}
if (outBindingRecords != nullptr) {
outBindingRecords->push_back({texture != nullptr ? texture->GetLifetimeId() : 0,
sampler != nullptr ? sampler->GetLifetimeId() : 0});
}
auto found = std::find(outTextures.begin(), outTextures.end(), texture); auto found = std::find(outTextures.begin(), outTextures.end(), texture);
if (found == outTextures.end()) { if (found == outTextures.end()) {
outTextures.push_back(texture); outTextures.push_back(texture);
}
} }
} }
return true; return true;
@@ -920,6 +1021,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool UniformManager::SampledBindingsUnchanged(const MG_State::GLState::ProgramObject& program, Bool UniformManager::SampledBindingsUnchanged(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, const ProgramFactory::VkProgramObject& programObj,
const Vector<SampledBindingRecord>& previousRecords) const { const Vector<SampledBindingRecord>& previousRecords) const {
// A declined program takes the full path every time and is refused there.
if (programObj.declinedDescriptors) {
return false;
}
SizeT recordIndex = 0; SizeT recordIndex = 0;
// Iterate only the bindings this program declares (ascending), exactly like // Iterate only the bindings this program declares (ascending), exactly like
// BindProgramUniformBuffers: this runs per draw whenever the texture bind // BindProgramUniformBuffers: this runs per draw whenever the texture bind
@@ -932,18 +1037,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (programObj.bindingKinds[binding] != ProgramFactory::DescriptorBindingKind::CombinedImageSampler) { if (programObj.bindingKinds[binding] != ProgramFactory::DescriptorBindingKind::CombinedImageSampler) {
continue; continue;
} }
MG_State::GLState::ITextureObject* texture = nullptr; // Element-for-element, in the same order CollectSampledTextures recorded them -
const MG_State::GLState::SamplerObject* sampler = nullptr; // the two walks have to visit the identical descriptor sequence or the positional
if (!ResolveSampledBinding(program, programObj, binding, texture, sampler)) { // comparison below drifts.
continue; const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding);
} for (Uint32 element = 0; element < descriptorCount; ++element) {
if (recordIndex >= previousRecords.size()) { MG_State::GLState::ITextureObject* texture = nullptr;
return false; const MG_State::GLState::SamplerObject* sampler = nullptr;
} if (!ResolveSampledBinding(program, programObj, binding, element, texture, sampler)) {
const SampledBindingRecord& record = previousRecords[recordIndex++]; continue;
if (record.textureLifetimeId != (texture != nullptr ? texture->GetLifetimeId() : 0) || }
record.samplerLifetimeId != (sampler != nullptr ? sampler->GetLifetimeId() : 0)) { if (recordIndex >= previousRecords.size()) {
return false; return false;
}
const SampledBindingRecord& record = previousRecords[recordIndex++];
if (record.textureLifetimeId != (texture != nullptr ? texture->GetLifetimeId() : 0) ||
record.samplerLifetimeId != (sampler != nullptr ? sampler->GetLifetimeId() : 0)) {
return false;
}
} }
} }
return recordIndex == previousRecords.size(); return recordIndex == previousRecords.size();
@@ -956,6 +1067,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
outTextures.clear(); outTextures.clear();
MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, MOBILEGL_ASSERT(MG_State::pGLContext != nullptr,
"CollectStorageImageTextures: GL context is null"); "CollectStorageImageTextures: GL context is null");
// Same as the sampled walk: a declined program is refused at bind time, and its declined
// binding has no uniform location to reach an image unit through.
if (programObj.declinedDescriptors) {
return true;
}
const Uint32 bindingCount = const Uint32 bindingCount =
std::min<Uint32>(m_maxBindings, static_cast<Uint32>(programObj.bindingKinds.size())); std::min<Uint32>(m_maxBindings, static_cast<Uint32>(programObj.bindingKinds.size()));
@@ -968,26 +1084,40 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return false; return false;
} }
const Int location = programObj.samplerUniformLocationByBinding[binding]; const Int baseLocation = programObj.samplerUniformLocationByBinding[binding];
if (location < 0) { if (baseLocation < 0) {
MGLOG_E("CollectStorageImageTextures: binding %u has no image uniform location", binding); MGLOG_E("CollectStorageImageTextures: binding %u has no image uniform location", binding);
return false; return false;
} }
const Int imageUnit = program.GetUniformSamplerOrImageUnitIndex(static_cast<Uint>(location)); // Per ELEMENT, for the same reason the sampled walk above is: an image ARRAY is one
if (imageUnit < 0 || imageUnit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { // binding whose elements each carry their own image unit, so each reaches its own
MGLOG_E("CollectStorageImageTextures: image unit %d is invalid for binding %u", // texture. This walk is what puts those textures into the pre-pass sync and layout
imageUnit, binding); // transition; collecting only element 0 left elements 1..N to be first touched by
return false; // the descriptor resolve, which happens with a render pass already open.
} const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding);
for (Uint32 element = 0; element < descriptorCount; ++element) {
const Int location = ResolveDescriptorElementLocation(program, baseLocation, element);
if (location < 0) {
MGLOG_E("CollectStorageImageTextures: binding %u element %u is past the end of its image array",
binding, element);
return false;
}
const Int imageUnit = program.GetUniformSamplerOrImageUnitIndex(static_cast<Uint>(location));
if (imageUnit < 0 || imageUnit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) {
MGLOG_E("CollectStorageImageTextures: image unit %d is invalid for binding %u element %u",
imageUnit, binding, element);
return false;
}
auto* texture = MG_State::pGLContext->GetImageTextureBinding(imageUnit).Texture.get(); auto* texture = MG_State::pGLContext->GetImageTextureBinding(imageUnit).Texture.get();
if (texture == nullptr) { if (texture == nullptr) {
MGLOG_E("CollectStorageImageTextures: image unit %d is unbound for binding %u", MGLOG_E("CollectStorageImageTextures: image unit %d is unbound for binding %u element %u",
imageUnit, binding); imageUnit, binding, element);
return false; return false;
} }
if (std::find(outTextures.begin(), outTextures.end(), texture) == outTextures.end()) { if (std::find(outTextures.begin(), outTextures.end(), texture) == outTextures.end()) {
outTextures.push_back(texture); outTextures.push_back(texture);
}
} }
} }
return true; return true;
@@ -1358,6 +1488,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkPipelineBindPoint bindPoint, VkPipelineBindPoint bindPoint,
const SamplerBindingOverride* samplerBindingOverride, const SamplerBindingOverride* samplerBindingOverride,
Bool samplerDescriptorsUnchangedHint) { Bool samplerDescriptorsUnchangedHint) {
// This program has a descriptor MobileGL could not resolve (see
// VkProgramObject::declinedDescriptors). Refusing here is the whole of the decline: the
// binding is still declared in the layout, so the pipeline is consistent with the shader
// and creating it is safe - what must not happen is the draw, because the descriptor
// behind that binding can never be written. The draw setup skips the draw on a false
// return. ReflectLayout already said why, once, at MGLOG_I.
if (programObj.declinedDescriptors) {
MGLOG_D("UniformDescriptorBinder::BindProgramUniformBuffers: refusing a program whose descriptor layout "
"was declined at reflection");
return false;
}
auto& frame = m_frames[frameIndex]; auto& frame = m_frames[frameIndex];
if (frame.descriptorPools.empty()) { if (frame.descriptorPools.empty()) {
MGLOG_E("UniformDescriptorBinder::BindProgramUniformBuffers failed: frame descriptor pools are invalid"); MGLOG_E("UniformDescriptorBinder::BindProgramUniformBuffers failed: frame descriptor pools are invalid");
@@ -1416,13 +1557,27 @@ namespace MobileGL::MG_Backend::DirectVulkan {
dynamicOffsets.clear(); dynamicOffsets.clear();
// Arrayed UBO bindings contribute extra buffer infos and dynamic offsets; reserve for // Arrayed UBO bindings contribute extra buffer infos and dynamic offsets; reserve for
// the worst case so the pBufferInfo pointers taken below never dangle on reallocation. // the worst case so the pBufferInfo pointers taken below never dangle on reallocation.
// Arrayed SSBO bindings contribute extra buffer infos too (but no dynamic offsets).
Uint32 uboArrayExtra = 0; Uint32 uboArrayExtra = 0;
for (const auto& arrayEntry : programObj.arrayedUniformBlockIndicesByBinding) { for (const auto& arrayEntry : programObj.arrayedUniformBlockIndicesByBinding) {
uboArrayExtra += static_cast<Uint32>(arrayEntry.second.size()) - 1u; uboArrayExtra += static_cast<Uint32>(arrayEntry.second.size()) - 1u;
} }
// Surplus descriptors over "one per binding", summed across EVERY arrayed binding
// whatever its kind - storage blocks, image arrays and sampler arrays all land here.
// One number for all of them because each container below is bounded by the same total.
Uint32 arrayDescriptorExtra = 0;
for (const Uint16 count : programObj.bindingDescriptorCounts) {
if (count > 1) arrayDescriptorExtra += static_cast<Uint32>(count) - 1u;
}
writes.reserve(m_maxBindings); writes.reserve(m_maxBindings);
bufferInfos.reserve(m_maxBindings + uboArrayExtra); bufferInfos.reserve(m_maxBindings + uboArrayExtra + arrayDescriptorExtra);
imageInfos.reserve(m_maxBindings); // Every binding pushes at most descriptorCount image infos, so bindings + surplus is the
// worst case. Reserving only m_maxBindings here was exact while every binding pushed
// exactly one - and reallocates under an image or sampler array, dangling every
// pImageInfo already recorded in `writes` before vkUpdateDescriptorSets reads them. That
// is reachable wherever m_maxBindings is small (it clamps to ~16 on Adreno and Mali),
// which is exactly where a 7-element CTS sampler array does not fit the slack.
imageInfos.reserve(m_maxBindings + arrayDescriptorExtra);
texelBufferViews.reserve(m_maxBindings); texelBufferViews.reserve(m_maxBindings);
dynamicOffsets.reserve(programObj.dynamicBindings.size() + uboArrayExtra); dynamicOffsets.reserve(programObj.dynamicBindings.size() + uboArrayExtra);
@@ -1450,10 +1605,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
write.descriptorCount = 1; write.descriptorCount = 1;
if (kind == ProgramFactory::DescriptorBindingKind::UniformBufferDynamic) { if (kind == ProgramFactory::DescriptorBindingKind::UniformBufferDynamic) {
const Uint32 descriptorCount = const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding);
binding < programObj.bindingDescriptorCounts.size()
? std::max<Uint32>(1, programObj.bindingDescriptorCounts[binding])
: 1u;
dynamicUboDescriptorCount += descriptorCount; dynamicUboDescriptorCount += descriptorCount;
fastRebindUboBinding = binding; fastRebindUboBinding = binding;
const SizeT firstBufferInfoIndex = bufferInfos.size(); const SizeT firstBufferInfoIndex = bufferInfos.size();
@@ -1493,59 +1645,109 @@ namespace MobileGL::MG_Backend::DirectVulkan {
write.pTexelBufferView = &texelBufferViews.back(); write.pTexelBufferView = &texelBufferViews.back();
writes.push_back(write); writes.push_back(write);
} else if (kind == ProgramFactory::DescriptorBindingKind::StorageBuffer) { } else if (kind == ProgramFactory::DescriptorBindingKind::StorageBuffer) {
VkDescriptorBufferInfo bufferInfo{}; // One write per binding, but `descriptorCount` buffer infos: a GLSL block
if (!ResolveStorageBufferDescriptor(program, programObj, binding, bufferInfo)) { // instance array occupies a single binding whose elements each come from their
MGLOG_E( // own GL binding point.
"UniformDescriptorBinder::BindProgramUniformBuffers failed: storage buffer binding %u has no valid descriptor", const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding);
binding); const SizeT firstBufferInfoIndex = bufferInfos.size();
return false; for (Uint32 element = 0; element < descriptorCount; ++element) {
VkDescriptorBufferInfo bufferInfo{};
if (!ResolveStorageBufferDescriptor(program, programObj, binding, element, bufferInfo)) {
MGLOG_E(
"UniformDescriptorBinder::BindProgramUniformBuffers failed: storage buffer binding %u "
"element %u has no valid descriptor",
binding, element);
return false;
}
bufferInfos.push_back(bufferInfo);
} }
bufferInfos.push_back(bufferInfo);
fastRebindKindsEligible = false; fastRebindKindsEligible = false;
write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
write.pBufferInfo = &bufferInfos.back(); write.descriptorCount = descriptorCount;
write.pBufferInfo = &bufferInfos[firstBufferInfoIndex];
writes.push_back(write); writes.push_back(write);
} else if (kind == ProgramFactory::DescriptorBindingKind::StorageImage) { } else if (kind == ProgramFactory::DescriptorBindingKind::StorageImage) {
VkDescriptorImageInfo imageInfo{}; // One write per binding, but `descriptorCount` image infos: an ARRAY of image
if (!ResolveStorageImageDescriptor(commandBuffer, program, programObj, binding, imageInfo)) { // uniforms is a single binding whose elements each carry their own image unit.
MGLOG_E( // Writing only element 0 - which is all this used to do - left elements 1..N
"UniformDescriptorBinder::BindProgramUniformBuffers failed: storage image binding %u has no valid descriptor", // never written at all, and a shader that indexes them reads an undefined
binding); // descriptor (lavapipe faults inside the shader; a real driver is free to do
return false; // anything).
const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding);
const SizeT firstImageInfoIndex = imageInfos.size();
for (Uint32 element = 0; element < descriptorCount; ++element) {
VkDescriptorImageInfo imageInfo{};
if (!ResolveStorageImageDescriptor(commandBuffer, program, programObj, binding, element,
imageInfo)) {
MGLOG_E(
"UniformDescriptorBinder::BindProgramUniformBuffers failed: storage image binding %u "
"element %u has no valid descriptor",
binding, element);
return false;
}
imageInfos.push_back(imageInfo);
} }
imageInfos.push_back(imageInfo);
fastRebindKindsEligible = false; fastRebindKindsEligible = false;
write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE;
write.pImageInfo = &imageInfos.back(); write.descriptorCount = descriptorCount;
write.pImageInfo = &imageInfos[firstImageInfoIndex];
writes.push_back(write); writes.push_back(write);
} else { } else {
VkDescriptorImageInfo imageInfo{}; // One write per binding, but `descriptorCount` image infos: a sampler ARRAY is a
Bool hasImage = false; // single binding whose elements each carry their own texture unit. Writing only
if (samplerBindingOverride != nullptr && // element 0 - which is all this used to do - left elements 1..N never written,
samplerBindingOverride->binding == binding && // so a shader indexing them sampled a descriptor nobody had filled in
samplerBindingOverride->texture != nullptr && // (KHR-GL42.shading_language_420pack.binding_sampler_array).
samplerBindingOverride->sampler != nullptr) { const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding);
hasImage = ResolveSamplerDescriptorOverride(*samplerBindingOverride, imageInfo); // Overrides come only from MobileGL's own blit and depth-mipmap programs, whose
} else { // samplers are scalars; the override replaces THE descriptor at its binding, so
hasImage = ResolveSamplerDescriptor(commandBuffer, program, programObj, binding, imageInfo, // there is no element for it to mean on an arrayed one.
samplerDescriptorsUnchangedHint); const Bool overrideThisBinding = samplerBindingOverride != nullptr &&
samplerBindingOverride->binding == binding &&
samplerBindingOverride->texture != nullptr &&
samplerBindingOverride->sampler != nullptr;
MOBILEGL_ASSERT(
!overrideThisBinding || descriptorCount == 1,
"BindProgramUniformBuffers: sampler override targets arrayed binding %u (%u descriptors)",
binding, descriptorCount);
const SizeT firstImageInfoIndex = imageInfos.size();
for (Uint32 element = 0; element < descriptorCount; ++element) {
VkDescriptorImageInfo imageInfo{};
Bool hasImage = false;
if (overrideThisBinding && element == 0) {
hasImage = ResolveSamplerDescriptorOverride(*samplerBindingOverride, imageInfo);
} else {
hasImage = ResolveSamplerDescriptor(commandBuffer, program, programObj, binding, element,
imageInfo, samplerDescriptorsUnchangedHint);
}
if (!hasImage) {
MGLOG_E(
"UniformDescriptorBinder::BindProgramUniformBuffers failed: sampler binding %u element %u "
"has no valid texture descriptor",
binding, element);
return false;
}
if (imageInfo.sampler == VK_NULL_HANDLE || imageInfo.imageView == VK_NULL_HANDLE) {
MGLOG_E(
"UniformDescriptorBinder::BindProgramUniformBuffers failed: sampler binding %u element %u "
"has null sampler or imageView",
binding, element);
return false;
}
imageInfos.push_back(imageInfo);
} }
if (!hasImage) { if (descriptorCount > 1) {
MGLOG_E( // The dynamic-offset-only rebind replays a whole descriptor set on the
"UniformDescriptorBinder::BindProgramUniformBuffers failed: sampler binding %u has no valid texture descriptor", // strength of the sampler hint alone, and its eligibility probe was written
binding); // for bindings that carry one descriptor each. An arrayed sampler binding
return false; // also bypasses the per-binding descriptor memo, so there is nothing for it
// to win here either.
fastRebindKindsEligible = false;
} }
if (imageInfo.sampler == VK_NULL_HANDLE || imageInfo.imageView == VK_NULL_HANDLE) {
MGLOG_E(
"UniformDescriptorBinder::BindProgramUniformBuffers failed: sampler binding %u has null sampler or imageView",
binding);
return false;
}
imageInfos.push_back(imageInfo);
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
write.pImageInfo = &imageInfos.back(); write.descriptorCount = descriptorCount;
write.pImageInfo = &imageInfos[firstImageInfoIndex];
writes.push_back(write); writes.push_back(write);
} }
} }
@@ -53,10 +53,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// caches - a live layout's entry must never be purged (its sets would be // 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. // unreachable pool slots), so there is deliberately no age-based sweep here.
void OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout); void OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout);
// One record per visited CombinedImageSampler binding (post fallback substitution, // One record per visited CombinedImageSampler DESCRIPTOR (post fallback substitution,
// in binding order): the resolved texture and effective sampler, as never-reused // in binding order, and within a binding in array-element order): the resolved texture
// lifetime ids so a freed-and-reallocated object at the same heap address can only // and effective sampler, as never-reused lifetime ids so a freed-and-reallocated object
// MISS a comparison, never false-hit it (same ABA rule as SamplerResolveMemo). // 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 { struct SampledBindingRecord {
Uint64 textureLifetimeId = 0; Uint64 textureLifetimeId = 0;
Uint64 samplerLifetimeId = 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 // texture after the fallback substitution (may still be null when no fallback
// exists), effective sampler = unit override else the texture's own sampler. // exists), effective sampler = unit override else the texture's own sampler.
// False = the binding is skipped (unbound with a non-2D fallback target). // 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, 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, MG_State::GLState::ITextureObject*& outTexture,
const MG_State::GLState::SamplerObject*& outSampler) const; const MG_State::GLState::SamplerObject*& outSampler) const;
// Raw-pointer variant for the per-draw sampled-texture walk (CollectSampledTextures): // Raw-pointer variant for the per-draw sampled-texture walk (CollectSampledTextures):
@@ -152,27 +156,37 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// only need the pointer skip the SharedPtr copy's atomic refcount churn. // only need the pointer skip the SharedPtr copy's atomic refcount churn.
static MG_State::GLState::ITextureObject* ResolveSamplerTextureRaw( static MG_State::GLState::ITextureObject* ResolveSamplerTextureRaw(
const MG_State::GLState::ProgramObject& program, 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; 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 // trustUnchangedHint: reuse this binding's cached VkDescriptorImageInfo outright
// (see BindProgramUniformBuffers' samplerDescriptorsUnchangedHint for the proof // (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, Bool ResolveSamplerDescriptor(VkCommandBuffer commandBuffer, const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding, const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
VkDescriptorImageInfo& outImageInfo, Uint32 element, VkDescriptorImageInfo& outImageInfo,
Bool trustUnchangedHint = false) const; Bool trustUnchangedHint = false) const;
Bool ResolveSamplerDescriptorOverride(const SamplerBindingOverride& samplerBindingOverride, Bool ResolveSamplerDescriptorOverride(const SamplerBindingOverride& samplerBindingOverride,
VkDescriptorImageInfo& outImageInfo) const; VkDescriptorImageInfo& outImageInfo) const;
Bool ResolveTexelBufferDescriptor(const MG_State::GLState::ProgramObject& program, Bool ResolveTexelBufferDescriptor(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding, const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
Uint32 frameIndex, VkBufferView& outBufferView); 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, Bool ResolveStorageBufferDescriptor(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding, const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
VkDescriptorBufferInfo& outBufferInfo) const; 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, Bool ResolveStorageImageDescriptor(VkCommandBuffer commandBuffer,
const MG_State::GLState::ProgramObject& program, const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding, 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 // 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. // VkBuffer (the GLES backend's approach - no per-draw copy) or the CPU payload to upload.
struct UboBindResult { struct UboBindResult {
@@ -341,6 +355,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// proves every resolve input unchanged; cleared with the per-frame reset // proves every resolve input unchanged; cleared with the per-frame reset
// (the cached VkSampler outlives a frame only via a fresh resolve, which // (the cached VkSampler outlives a frame only via a fresh resolve, which
// also re-stamps it against VkSamplerManager's frame-boundary sweep). // 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{}; VkDescriptorImageInfo info{};
Bool infoValid = false; Bool infoValid = false;
}; };
@@ -111,10 +111,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const VulkanRendererConfig& m_config; const VulkanRendererConfig& m_config;
VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE; VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE;
// Values are heap-allocated: FastSTL::unordered_map is open-addressing, // Values are heap-allocated: UnorderedMap is open-addressing, so INSERT
// so INSERT invalidates references to stored values. The draw path (and // invalidates references to stored values - and so does ERASE, which shifts
// the VAOs' state-pointer memos) hold entry pointers across inserts; // the rest of the probe cluster into the hole and therefore moves entries
// only the unique_ptr cell moves, never the pointee. // 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; UnorderedMap<HashType, UniquePtr<BackendVertexInputState>> m_cache;
// Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging. // Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging.
Uint64 m_frameBoundaryCounter = 0; Uint64 m_frameBoundaryCounter = 0;
@@ -161,12 +161,23 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
void VkBufferManager::CollectAllDeferredReleases() { void VkBufferManager::CollectAllDeferredReleases() {
// Per-resource releases only. Every one of them was deferred behind a BumpSliceEpoch,
// so no memo can still name the handle, and the caller has proved the GPU is idle.
//
// The transient arena's releases are deliberately NOT collected here. A buffer lands
// there when the arena outgrows it mid-frame (BufferArena::EnsureCapacity), and at
// that moment every slice already handed out from this frame's arena still names it -
// VkBufferResource::transientSlice above all, which AcquireStreamedSlice keeps
// serving for the whole frame serial on the strength of transientFrameSerial alone.
// Nothing bumps the slice epoch for those other resources, so freeing the buffer
// here left the streamed memo handing a destroyed VkBuffer to vkCmdBindIndexBuffer
// (llvmpipe then faulted inside the draw; the Create/Flywheel indirect retrace died
// exactly this way). Mid-frame drains do not advance m_frameSerial, so they must not
// free arena storage either: the arena's own ResetFrame/BeginFrame is the point where
// the slot's slices stop being reachable, and that is where these releases land.
for (Uint32 frameIndex = 0; frameIndex < m_deferredBufferReleases.size(); ++frameIndex) { for (Uint32 frameIndex = 0; frameIndex < m_deferredBufferReleases.size(); ++frameIndex) {
CollectDeferredReleases(frameIndex); CollectDeferredReleases(frameIndex);
} }
for (Uint32 frameIndex = 0; frameIndex < m_transientUploadArena.GetFrameCount(); ++frameIndex) {
m_transientUploadArena.CollectDeferredReleases(frameIndex);
}
} }
void VkBufferManager::NotifyDeviceIdle() { void VkBufferManager::NotifyDeviceIdle() {
@@ -102,10 +102,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Recreate all per-frame transient arenas // Recreate all per-frame transient arenas
Bool RecreateTransientArenas(Uint32 frameCount); Bool RecreateTransientArenas(Uint32 frameCount);
void BeginFrame(Uint32 frameIndex); void BeginFrame(Uint32 frameIndex);
// Drains every frame slot's deferred buffer/resource releases (and the // Drains every frame slot's deferred buffer/resource releases. Only valid when
// transient arena's parked superseded blocks). Only valid when the // the caller has proven every queue submission complete; used by the present-less
// caller has proven every queue submission complete; used by the // frame-boundary drain. Deliberately does NOT touch the transient arena's parked
// present-less frame-boundary drain. // superseded blocks: those are still named by this frame's slices (see the
// definition), and only a frame rewind retires them.
void CollectAllDeferredReleases(); void CollectAllDeferredReleases();
// All previously submitted GPU work has completed (vkDeviceWaitIdle). // All previously submitted GPU work has completed (vkDeviceWaitIdle).
void NotifyDeviceIdle(); void NotifyDeviceIdle();
@@ -101,6 +101,42 @@ namespace MobileGL::MG_Backend::DirectVulkan {
std::swap(layers, that.layers); std::swap(layers, that.layers);
std::swap(lastUsedFrame, that.lastUsedFrame); 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( RenderPassEntry(
Uint64 hash, Uint64 hash,
VkRenderPass renderpass, VkRenderPass renderpass,
@@ -315,26 +351,30 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint64 deferredAtFrame = 0; 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 // 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 // calls that touch this map. BlitFramebuffer is the one that bit: it resolves the source and
// destination colour bindings (ResolveColorBlitBinding caches &rbResource->layout), then // destination colour bindings (ResolveColorBlitBinding caches &rbResource->layout), then
// materializes the source's pending clear, which looks that same resource up again. FastSTL's // materializes the source's pending clear, which looks that same resource up again. Growing
// operator[] runs its load-factor check before find_key and reallocates the whole bucket array // an open-addressed table relocates every element, so the cached pointer went on to name
// when occupancy crosses it, so even a plain lookup relocates every element; erase only // freed storage still holding the pre-clear VK_IMAGE_LAYOUT_UNDEFINED; BlitFramebuffer bailed
// tombstones and never decrements the occupancy, so the doubling keeps firing. After a // out at "source image layout is undefined", silently dropping the blit -
// relocation the cached pointer names freed storage still holding the pre-clear // renderbuffers_storage_multisample read back zero instead of the clear colour on exactly the
// VK_IMAGE_LAYOUT_UNDEFINED, and BlitFramebuffer bails out at "source image layout is // iterations that grew the table.
// 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 // 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, // this: the destination resolve still runs after the source pointer is taken. The depth blit,
// GetOrCreateRenderPass's depthRenderbufferResource and ReadDepthStencilPixels cache the same // 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 // 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 // ordering rule. m_textureResources is node-based for the same reason.
// 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. // 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; std::unordered_map<MG_State::GLState::RenderbufferObject*, RenderbufferResource> m_renderbufferResources;
UnorderedMap<MG_State::GLState::RenderbufferObject*, PendingRenderbufferClear> m_pendingRenderbufferClears; UnorderedMap<MG_State::GLState::RenderbufferObject*, PendingRenderbufferClear> m_pendingRenderbufferClears;
Vector<DeferredRenderbufferRelease> m_deferredRenderbufferReleases; Vector<DeferredRenderbufferRelease> m_deferredRenderbufferReleases;
File diff suppressed because it is too large Load Diff
@@ -211,10 +211,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
GLsizei height, GLenum format, GLenum type, void* pixels); GLsizei height, GLenum format, GLenum type, void* pixels);
// Copy-and-repack core shared by depth-stencil ReadPixels and GetTexImage; // Copy-and-repack core shared by depth-stencil ReadPixels and GetTexImage;
// expects command recording to be active and any render pass already ended. // expects command recording to be active and any render pass already ended.
//
// `defaultFramebufferOrientation` is set only when the source is the swapchain's
// depth/stencil image, which this renderer stores display-side-up: the copy rect then
// has to be mapped out of GL's bottom-origin space and the copied rows re-oriented on
// the way back, exactly as the colour ReadPixels path does.
void ReadDepthStencilImageToClient(VkImage image, VkFormat vkFormat, VkImageLayout* trackedLayout, void ReadDepthStencilImageToClient(VkImage image, VkFormat vkFormat, VkImageLayout* trackedLayout,
VkImageAspectFlags imageAspect, Uint32 mipLevel, Uint32 baseArrayLayer, VkImageAspectFlags imageAspect, Uint32 mipLevel, Uint32 baseArrayLayer,
GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type,
void* pixels); void* pixels, Bool defaultFramebufferOrientation = false);
// Same-extent depth blit between images of different depth formats: host // Same-extent depth blit between images of different depth formats: host
// round-trip with a per-texel re-encode (see BlitNamedFramebuffer). // round-trip with a per-texel re-encode (see BlitNamedFramebuffer).
Bool BlitDepthAcrossFormats(FrameContext::FrameData& frame, VkImage srcImage, VkFormat srcFormat, Bool BlitDepthAcrossFormats(FrameContext::FrameData& frame, VkImage srcImage, VkFormat srcFormat,
@@ -363,6 +368,31 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint32 samplerBinding = 0; Uint32 samplerBinding = 0;
}; };
// A single-sample staging image for multisample-resolve blits that also have to change
// orientation. vkCmdResolveImage cannot flip (it takes one offset per side, not the
// invertible pair vkCmdBlitImage takes), so a resolve into or out of the default
// framebuffer used to land the mirrored band. Resolving here first and then blitting from
// here separates the two operations, and each one then does only what it can express.
//
// Pooled rather than created per blit: the CTS runs hundreds of these back to back, and
// create-destroy per call would both cost allocations and, worse, need per-call deferred
// destruction to outlive the recording. It grows to the largest extent asked for and is
// reused; format changes recreate it.
struct MultisampleResolveScratchImage {
VkImage image = VK_NULL_HANDLE;
VmaAllocation allocation = VK_NULL_HANDLE;
VkFormat format = VK_FORMAT_UNDEFINED;
VkExtent2D extent = {0, 0};
VkImageLayout layout = VK_IMAGE_LAYOUT_UNDEFINED;
};
MultisampleResolveScratchImage m_msResolveScratch;
// Returns a scratch image at least `extent` in size with exactly `format`, transitioned to
// TRANSFER_DST and ready to be resolved into. Null image on failure (the caller then falls
// back to the direct resolve).
Bool AcquireMultisampleResolveScratchImage(VkCommandBuffer commandBuffer, VkFormat format,
VkExtent2D extent);
void DestroyMultisampleResolveScratchImage();
struct DeferredDepthMipmapCleanup { struct DeferredDepthMipmapCleanup {
Vector<VkImageView> imageViews; Vector<VkImageView> imageViews;
Vector<VkFramebuffer> framebuffers; Vector<VkFramebuffer> framebuffers;
@@ -445,15 +475,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
void* m_platformDisplay = nullptr; void* m_platformDisplay = nullptr;
void* m_platformLibrary = nullptr; void* m_platformLibrary = nullptr;
void* m_platformCloseDisplay = nullptr; void* m_platformCloseDisplay = nullptr;
// Some real ICDs (e.g. NVIDIA's proprietary Linux driver) don't implement // Whether the loader exposes VK_EXT_headless_surface, detected once in
// VK_EXT_headless_surface at all. Detected once in CreateInstance() from the // CreateInstance() from the enumerated instance extensions. On desktop an
// enumerated instance extensions; when false, CreateSurface() falls back to a // offscreen surface REQUIRES it: false is a clean, loud bring-up failure, never
// hidden Xlib window instead of vkCreateHeadlessSurfaceEXT. // a substituted window. (Android is the one exception and has its own path -
// no Mali/Adreno driver seen so far exposes the extension, so a windowless
// context is given an AImageReader ANativeWindow that is never displayed.)
Bool m_headlessSurfaceSupported = true; Bool m_headlessSurfaceSupported = true;
// Set when CreateSurface() had to create its own Xlib window for the fallback
// above (rather than being handed one by the caller), so Shutdown() knows it
// owns that window and must destroy it.
Bool m_ownsFallbackXlibWindow = false;
// Android has the same shortfall: no Mali/Adreno driver seen so far exposes // Android has the same shortfall: no Mali/Adreno driver seen so far exposes
// VK_EXT_headless_surface, so a windowless (EGL pbuffer) context gets an // VK_EXT_headless_surface, so a windowless (EGL pbuffer) context gets an
// AImageReader's ANativeWindow to hand the WSI instead. Nothing is ever // AImageReader's ANativeWindow to hand the WSI instead. Nothing is ever
@@ -1120,6 +1148,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool MaterializePendingClearForRenderbuffer( Bool MaterializePendingClearForRenderbuffer(
VkCommandBuffer commandBuffer, VkCommandBuffer commandBuffer,
const SharedPtr<MG_State::GLState::RenderbufferObject>& renderbuffer); const SharedPtr<MG_State::GLState::RenderbufferObject>& renderbuffer);
// The default framebuffer's twin of the two above. It cannot go through
// MaterializePendingClearForTexture: the default FBO's colour attachment is a
// placeholder texture object, and syncing THAT would clear a texture image nobody
// presents instead of the acquired swapchain image.
Bool MaterializePendingClearForDefaultFramebuffer(VkCommandBuffer commandBuffer,
MG_State::GLState::FramebufferObject& fbo,
FramebufferAttachmentType attachmentType);
// Its depth/stencil half: a different image (the swapchain's depth/stencil twin), a
// different clear command and per-aspect masking.
Bool MaterializePendingDepthStencilClearForDefaultFramebuffer(
VkCommandBuffer commandBuffer, const MG_State::GLState::FramebufferAttachmentObject& attachment,
const ClearAttachmentPayload& payload);
VkPipeline GetOrCreateBlitPipeline(const RenderPassEntry& renderPassEntry); VkPipeline GetOrCreateBlitPipeline(const RenderPassEntry& renderPassEntry);
Bool GenerateDepthMipmapWithShader(FrameContext::FrameData& frame, Bool GenerateDepthMipmapWithShader(FrameContext::FrameData& frame,
MG_State::GLState::ITextureObject& texture, MG_State::GLState::ITextureObject& texture,
+2 -1
View File
@@ -42,4 +42,5 @@ set_tests_properties(SanityBench PROPERTIES LABELS benchmark)
add_subdirectory(Program) add_subdirectory(Program)
add_subdirectory(Buffer) 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();
+47 -10
View File
@@ -1491,8 +1491,8 @@ namespace MobileGL::MG_Impl::GLImpl {
// offset and size, which is also how glBindBuffersRange spells "reset this element" // offset and size, which is also how glBindBuffersRange spells "reset this element"
// (a NULL buffers array, or a zero entry inside one). // (a NULL buffers array, or a zero entry inside one).
static Bool ValidateBufferRangeOffsetAndSize(GLenum target, GLintptr offset, GLsizeiptr size, static Bool ValidateBufferRangeOffsetAndSize(GLenum target, GLintptr offset, GLsizeiptr size,
const char* funcName) { const char* funcName, Bool hasBuffer = true) {
if (size <= 0) { if (hasBuffer && size <= 0) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
@@ -1527,16 +1527,27 @@ namespace MobileGL::MG_Impl::GLImpl {
return false; return false;
} }
} }
// A transform feedback capture binding is addressed in 32-bit components, so BOTH the // GL 4.6 core 6.1.1 constrains the OFFSET to a multiple of four for both
// offset and the size must be multiples of 4. // TRANSFORM_FEEDBACK_BUFFER and ATOMIC_COUNTER_BUFFER (the atomic-counter one has no
if (target == GL_TRANSFORM_FEEDBACK_BUFFER && ((offset % 4) != 0 || (size % 4) != 0)) { // queryable alignment pname, which is why it was missing here), and the SIZE only for
// transform feedback, whose capture is written in whole 32-bit components. Extending the
// size rule to atomic counters as well breaks a legal bind: the conformance suite splits
// MAX_ATOMIC_COUNTER_BUFFER_SIZE evenly across the binding points and that quotient is
// not required to land on four.
if ((target == GL_TRANSFORM_FEEDBACK_BUFFER || target == GL_ATOMIC_COUNTER_BUFFER) && (offset % 4) != 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
std::format("offset ({}) must be a multiple of 4 for {}.", offset,
MG_Util::ConvertGLEnumToString(target))));
return false;
}
if (target == GL_TRANSFORM_FEEDBACK_BUFFER && hasBuffer && (size % 4) != 0) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>( MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", funcName, "MG_Impl/GLImpl", funcName,
std::format("offset ({}) and size ({}) must both be multiples of 4 for " std::format("size ({}) must be a multiple of 4 for GL_TRANSFORM_FEEDBACK_BUFFER.", size)));
"GL_TRANSFORM_FEEDBACK_BUFFER.",
offset, size)));
return false; return false;
} }
return true; return true;
@@ -1548,7 +1559,12 @@ namespace MobileGL::MG_Impl::GLImpl {
BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target); BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target);
if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return; if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return;
if (!BufferImpl::ValidateBufferBindingPointIndex(bufferTarget, index)) return; if (!BufferImpl::ValidateBufferBindingPointIndex(bufferTarget, index)) return;
if (buffer != 0 && !ValidateBufferRangeOffsetAndSize(target, offset, size, __func__)) return; // The target's alignment rules are a property of the BINDING POINT, not of the buffer,
// so they apply even when buffer is zero - which is exactly how
// KHR-GL43.shader_storage_buffer_object.negative-api-bind probes the SSBO alignment
// (glBindBufferRange(SHADER_STORAGE_BUFFER, 0, 0, alignment - 1, 0)). Only the size
// rules need a buffer, since buffer 0 detaches the binding point and ignores size.
if (!ValidateBufferRangeOffsetAndSize(target, offset, size, __func__, /*hasBuffer: */ buffer != 0)) return;
if (bufferTarget == BufferTarget::TransformFeedback && MG_State::pGLContext->IsTransformFeedbackActive()) { if (bufferTarget == BufferTarget::TransformFeedback && MG_State::pGLContext->IsTransformFeedbackActive()) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation, ErrorCode::InvalidOperation,
@@ -1732,10 +1748,30 @@ namespace MobileGL::MG_Impl::GLImpl {
return BufferImpl::ValidateBufferBindingPointRange(bufferTarget, first, count, funcName); return BufferImpl::ValidateBufferBindingPointRange(bufferTarget, first, count, funcName);
} }
// ARB_multi_bind states the equivalence to a loop of single binds "except that ... buffers
// will not be created if they do not exist": glBindBuffer instantiates a name glGenBuffers
// merely reserved, glBindBuffers* must refuse it and raise INVALID_OPERATION instead
// (KHR-GL44.multi_bind.errors_bind_buffers).
//
// Deliberately PER ELEMENT, not all-or-nothing: the equivalence the extension defines is a
// loop, so a bad entry costs its own binding point and nothing else. Rejecting the whole
// call instead cost multi_bind.functional_bind_buffers_base its bindings.
static Bool IsExistingBufferForMultiBind(GLuint buffer, GLsizei index, const char* funcName) {
if (buffer == 0 || MG_State::pGLContext->ValidateBufferObject(buffer)) return true;
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", funcName,
std::format("buffers[{}] ({}) is not the name of an existing buffer object.", index, buffer)));
return false;
}
void BindBuffersBase(GLenum target, GLuint first, GLsizei count, const GLuint* buffers) { void BindBuffersBase(GLenum target, GLuint first, GLsizei count, const GLuint* buffers) {
if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return; if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return;
for (GLsizei i = 0; i < count; ++i) { for (GLsizei i = 0; i < count; ++i) {
BindBufferBase_State(target, first + i, buffers ? buffers[i] : 0); const GLuint buffer = buffers ? buffers[i] : 0;
if (!IsExistingBufferForMultiBind(buffer, i, __func__)) continue;
BindBufferBase_State(target, first + i, buffer);
} }
} }
@@ -1749,6 +1785,7 @@ namespace MobileGL::MG_Impl::GLImpl {
const GLsizeiptr* sizes) { const GLsizeiptr* sizes) {
if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return; if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return;
for (GLsizei i = 0; i < count; ++i) { for (GLsizei i = 0; i < count; ++i) {
if (buffers && !IsExistingBufferForMultiBind(buffers[i], i, __func__)) continue;
if (!buffers || buffers[i] == 0) { if (!buffers || buffers[i] == 0) {
BindBufferBase_State(target, first + i, 0); BindBufferBase_State(target, first + i, 0);
} else { } else {
+120 -13
View File
@@ -14,8 +14,8 @@
#include "../Getter/GL_Getter.h" #include "../Getter/GL_Getter.h"
namespace MobileGL::MG_Impl::GLImpl { namespace MobileGL::MG_Impl::GLImpl {
static Bool ValidateCurrentProgramForExecution(const char* functionName) { static Bool ValidateProgramForExecution(const SharedPtr<MG_State::GLState::ProgramObject>& currentProgram,
const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); const char* functionName) {
if (!currentProgram) { if (!currentProgram) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation, ErrorCode::InvalidOperation,
@@ -34,10 +34,17 @@ namespace MobileGL::MG_Impl::GLImpl {
return true; 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) { 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) { if (currentProgram->GetShaderIndexByStage(ShaderStage::Compute) < 0) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation, ErrorCode::InvalidOperation,
@@ -493,15 +500,12 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
void DispatchComputeIndirect(GLintptr indirect) { void DispatchComputeIndirect(GLintptr indirect) {
auto dispatchComputeIndirect = MG_Backend::gBackendFunctionsTable.GL.DispatchComputeIndirect; // Argument and binding validation runs FIRST. Both are properties of the call and of GL
if (!dispatchComputeIndirect) { // state, so a context whose backend cannot dispatch at all must still report the
MG_State::pGLContext->RecordError( // argument error the spec names rather than masking every one of them with
ErrorCode::InvalidOperation, // "unsupported" - which is what put GL_INVALID_OPERATION where
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, // KHR-GL43.compute_shader.api-indirect expects GL_INVALID_VALUE.
"Backend does not support indirect compute dispatch.")); //
return;
}
if (!ValidateCurrentProgramForCompute(__func__)) return;
// GL 4.6 core 19: `indirect` is a byte offset into GL_DISPATCH_INDIRECT_BUFFER - // GL 4.6 core 19: `indirect` is a byte offset into GL_DISPATCH_INDIRECT_BUFFER -
// negative or misaligned is INVALID_VALUE, nothing bound is INVALID_OPERATION. // negative or misaligned is INVALID_VALUE, nothing bound is INVALID_OPERATION.
if (indirect < 0 || (indirect % 4) != 0) { if (indirect < 0 || (indirect % 4) != 0) {
@@ -520,6 +524,29 @@ namespace MobileGL::MG_Impl::GLImpl {
"No buffer is bound to GL_DISPATCH_INDIRECT_BUFFER.")); "No buffer is bound to GL_DISPATCH_INDIRECT_BUFFER."));
return; return;
} }
// ...and the same INVALID_OPERATION covers "the command would source data beyond the end
// of the bound buffer object" (GL 4.6 core 19): the dispatch reads three uints starting
// at `indirect`.
constexpr SizeT kDispatchIndirectCommandSize = 3 * sizeof(Uint32);
if (static_cast<SizeT>(indirect) + kDispatchIndirectCommandSize > indirectBuffer->GetSize()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", __func__,
std::format("indirect ({}) + 12 bytes runs past the end of the {}-byte buffer bound to "
"GL_DISPATCH_INDIRECT_BUFFER.",
indirect, indirectBuffer->GetSize())));
return;
}
auto dispatchComputeIndirect = MG_Backend::gBackendFunctionsTable.GL.DispatchComputeIndirect;
if (!dispatchComputeIndirect) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Backend does not support indirect compute dispatch."));
return;
}
if (!ValidateCurrentProgramForCompute(__func__)) return;
dispatchComputeIndirect(indirect); dispatchComputeIndirect(indirect);
} }
@@ -580,8 +607,80 @@ namespace MobileGL::MG_Impl::GLImpl {
MultiDrawArraysIndirect_Backend(mode, indirect, drawcount, stride); MultiDrawArraysIndirect_Backend(mode, indirect, drawcount, stride);
} }
// ARB_indirect_parameters / GL 4.6 core 10.4: `drawcount` is a byte offset into the buffer
// bound to PARAMETER_BUFFER and holds one uint draw count. Three errors have to be raised
// before the call reaches a backend, and none of them was
// (KHR-GL46.indirect_parameters_tests.MultiDraw{Arrays,Elements}IndirectCount):
// * drawcount not a multiple of four INVALID_VALUE
// * nothing bound to PARAMETER_BUFFER, or the uint at `drawcount`
// lies past its end INVALID_OPERATION
// * maxdrawcount commands from `indirect` run past the end of the
// buffer bound to DRAW_INDIRECT_BUFFER INVALID_OPERATION
static Bool ValidateIndirectCountDraw(GLintptr indirect, GLintptr drawcount, GLsizei maxdrawcount,
GLsizei stride, SizeT commandSize, const char* funcName) {
if (drawcount < 0 || (drawcount % 4) != 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
"drawcount must be non-negative and a multiple of four."));
return false;
}
const auto& parameterBuffer =
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject();
if (!parameterBuffer ||
static_cast<SizeT>(drawcount) + sizeof(Uint32) > parameterBuffer->GetSize()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
"No buffer is bound to GL_PARAMETER_BUFFER, or drawcount runs past "
"the end of the one that is."));
return false;
}
if (maxdrawcount < 0 || stride < 0 || indirect < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
"indirect, maxdrawcount and stride must all be non-negative."));
return false;
}
const SizeT effectiveStride = stride != 0 ? static_cast<SizeT>(stride) : commandSize;
const auto& indirectBuffer =
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();
// A zero maxdrawcount sources nothing, so it cannot run past anything.
const SizeT requiredBytes =
maxdrawcount == 0 ? 0
: static_cast<SizeT>(indirect) +
static_cast<SizeT>(maxdrawcount - 1) * effectiveStride + commandSize;
if (!indirectBuffer || requiredBytes > indirectBuffer->GetSize()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
"maxdrawcount commands would be sourced from beyond the end of the "
"buffer bound to GL_DRAW_INDIRECT_BUFFER."));
return false;
}
return true;
}
void MultiDrawElementsIndirectCount(GLenum mode, GLenum type, const void* indirect, GLintptr drawcount, void MultiDrawElementsIndirectCount(GLenum mode, GLenum type, const void* indirect, GLintptr drawcount,
GLsizei maxdrawcount, GLsizei stride) { GLsizei maxdrawcount, GLsizei stride) {
// Argument validation before the backend-availability check: see DispatchComputeIndirect.
// DrawElementsIndirectCommand: count, instanceCount, firstIndex, baseVertex, baseInstance.
if (!ValidateIndirectCountDraw(reinterpret_cast<GLintptr>(indirect), drawcount, maxdrawcount, stride,
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; auto multiDrawElementsIndirectCount = MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsIndirectCount;
if (!multiDrawElementsIndirectCount) { if (!multiDrawElementsIndirectCount) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
@@ -595,6 +694,14 @@ namespace MobileGL::MG_Impl::GLImpl {
void MultiDrawArraysIndirectCount(GLenum mode, const void* indirect, GLintptr drawcount, void MultiDrawArraysIndirectCount(GLenum mode, const void* indirect, GLintptr drawcount,
GLsizei maxdrawcount, GLsizei stride) { GLsizei maxdrawcount, GLsizei stride) {
// Argument validation before the backend-availability check: see DispatchComputeIndirect.
// DrawArraysIndirectCommand: count, instanceCount, first, baseInstance.
if (!ValidateIndirectCountDraw(reinterpret_cast<GLintptr>(indirect), drawcount, maxdrawcount, stride,
4 * sizeof(Uint32), __func__)) {
return;
}
// See MultiDrawElementsIndirectCount, including why this one goes last.
if (!ValidateCurrentProgramForExecution(__func__)) return;
auto multiDrawArraysIndirectCount = MG_Backend::gBackendFunctionsTable.GL.MultiDrawArraysIndirectCount; auto multiDrawArraysIndirectCount = MG_Backend::gBackendFunctionsTable.GL.MultiDrawArraysIndirectCount;
if (!multiDrawArraysIndirectCount) { if (!multiDrawArraysIndirectCount) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
+31 -13
View File
@@ -722,10 +722,14 @@ namespace MobileGL::MG_Impl::GLImpl {
*data = 0; *data = 0;
return; 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 Range1D range = bindingPoint.GetRange();
const auto start = std::min(range.start, bufferObject->GetSize()); *data = static_cast<GLint>(range.end - range.start);
const auto end = std::min(range.end, bufferObject->GetSize());
*data = static_cast<GLint>(end - start);
return; return;
} }
default: default:
@@ -951,9 +955,8 @@ namespace MobileGL::MG_Impl::GLImpl {
*data = 0; *data = 0;
return; return;
} }
const auto start = std::min(range.start, bufferObject->GetSize()); // Verbatim, unclamped - see the GetIntegeri_v arm.
const auto end = std::min(range.end, bufferObject->GetSize()); *data = static_cast<GLint64>(range.end - range.start);
*data = static_cast<GLint64>(end - start);
return; return;
} }
default: default:
@@ -961,15 +964,30 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
} }
auto getInteger64i = MG_Backend::gBackendFunctionsTable.GL.GetInteger64i_v; // The one indexed pname whose value genuinely needs 64 bits: a vertex buffer binding
if (!getInteger64i) { // offset is an intptr, so taking the 32-bit route below would truncate it.
*data = 0; if (target == GL_VERTEX_BINDING_OFFSET) {
MG_State::pGLContext->RecordError( if (index >= VertexArrayImpl::GetMaxVertexAttribBindings()) {
ErrorCode::InvalidOperation, MG_State::pGLContext->RecordError(
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "Backend does not support indexed integer queries.")); ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Vertex buffer binding index is out of range."));
return;
}
const auto& vao = MG_State::pGLContext->GetBoundVertexArray();
*data = vao ? static_cast<GLint64>(vao->GetBindingPoint(index).Offset) : 0;
return; return;
} }
getInteger64i(target, index, data);
// Everything else is 32-bit indexed state that the glGetIntegeri_v pname table already
// owns, and GL 4.6 core 22.1 says every indexed query answers every indexed pname.
// Handing the leftovers straight to the backend instead made glGetInteger64i_v disagree
// with glGetIntegeri_v on the very same pname - GL_MAX_COMPUTE_WORK_GROUP_COUNT read
// back 0 while the 32-bit view said 65535 (KHR-GL43.compute_shader.max), because a
// frontend-only value simply is not in the driver's table.
GLint values[4] = {};
GetIntegeri_v(target, index, values);
*data = static_cast<GLint64>(values[0]);
} }
void GetInteger64v(GLenum pname, GLint64* params) { void GetInteger64v(GLenum pname, GLint64* params) {
+73 -8
View File
@@ -744,6 +744,21 @@ namespace MobileGL::MG_Impl::GLImpl {
CopyStr(bufSize, length, infoLog, log.c_str(), (GLsizei)log.length()); CopyStr(bufSize, length, infoLog, log.c_str(), (GLsizei)log.length());
} }
// MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS: while the compile job is still in flight -
// and, via the latch below, for the rest of that node's life once any query was
// answered this way - GL_COMPILE_STATUS reads GL_TRUE and the info log reads empty,
// WITHOUT joining. The latch (TakeOptimisticCompileAnswer) is what makes the three
// sites tell ONE story: without it, a job settling between an application's info-log
// read and its status read would produce the torn pair "GL_FALSE with an empty log",
// and an application that aborts on that never reaches the link join that carries the
// real diagnostic. A failure hidden here still fails the program link, with the
// compile log quoted in the program info log (ProgramLinkTask::ConsumeShaders), which
// is where the serial compile-then-check applications this exists for do their error
// handling.
static Bool AnswerCompileOptimistically(const SharedPtr<MG_State::GLState::ShaderObject>& shaderObject) {
return MG_Util::Async::OptimisticShaderStatusActive() && shaderObject->TakeOptimisticCompileAnswer();
}
void GetShaderiv_State(GLuint shader, GLenum pname, GLint* params) { void GetShaderiv_State(GLuint shader, GLenum pname, GLint* params) {
auto& shaderObject = TryToGetShaderObject(shader); auto& shaderObject = TryToGetShaderObject(shader);
if (!shaderObject) return; if (!shaderObject) return;
@@ -756,9 +771,20 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = shaderObject->GetDeleteStatus(); *params = shaderObject->GetDeleteStatus();
break; break;
case GL_COMPILE_STATUS: case GL_COMPILE_STATUS:
if (AnswerCompileOptimistically(shaderObject)) {
*params = GL_TRUE;
break;
}
*params = shaderObject->GetCompileStatus(); *params = shaderObject->GetCompileStatus();
break; break;
case GL_INFO_LOG_LENGTH: case GL_INFO_LOG_LENGTH:
// Not cosmetic: LWJGL's one-argument glGetShaderInfoLog convenience overload
// sizes its buffer from this query, so a joining answer here would defeat the
// non-joining GetShaderInfoLog below.
if (AnswerCompileOptimistically(shaderObject)) {
*params = 0;
break;
}
*params = shaderObject->GetInfoLog().empty() ? 0 : (GLint)shaderObject->GetInfoLog().length() + 1; *params = shaderObject->GetInfoLog().empty() ? 0 : (GLint)shaderObject->GetInfoLog().length() + 1;
break; break;
case GL_SHADER_SOURCE_LENGTH: case GL_SHADER_SOURCE_LENGTH:
@@ -784,6 +810,15 @@ namespace MobileGL::MG_Impl::GLImpl {
auto& shaderObject = TryToGetShaderObject(shader); auto& shaderObject = TryToGetShaderObject(shader);
if (!shaderObject) return; if (!shaderObject) return;
// See AnswerCompileOptimistically: an in-flight compile reads as an empty log. The
// cost is a lost compile WARNING (a successful compile whose log the application
// reads exactly once, now, and never after the join) - accepted as part of the
// opt-in.
if (AnswerCompileOptimistically(shaderObject)) {
CopyStr(bufSize, length, infoLog, "", 0);
return;
}
const auto& log = shaderObject->GetInfoLog(); const auto& log = shaderObject->GetInfoLog();
CopyStr(bufSize, length, infoLog, log.c_str(), (GLsizei)log.length()); CopyStr(bufSize, length, infoLog, log.c_str(), (GLsizei)log.length());
} }
@@ -828,12 +863,11 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
// Bytes a uniform actually occupies in the global UBO. It is the tight GL type size for // 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) { SizeT UniformStorageSpanInBytes(const glslang::TType* ttype, SizeT tightSize) {
if (ttype != nullptr && ttype->isMatrix() && ttype->getBasicType() != glslang::EbtDouble) { return MG_State::GLState::ProgramObject::UniformStorageSpanInBytes(ttype, tightSize);
return static_cast<SizeT>(ttype->getMatrixCols()) * 4 * sizeof(GLfloat);
}
return tightSize;
} }
void GetUniform_State(GLuint program, GLint location, void* params) { void GetUniform_State(GLuint program, GLint location, void* params) {
@@ -1085,10 +1119,20 @@ namespace MobileGL::MG_Impl::GLImpl {
if (!programObject.IsUniformOpaqueAtLocation(location)) { if (!programObject.IsUniformOpaqueAtLocation(location)) {
MGLOG_D("%s: program = %d, location = %d, maxLocation = %d", __func__, programObject.GetExternalIndex(), MGLOG_D("%s: program = %d, location = %d, maxLocation = %d", __func__, programObject.GetExternalIndex(),
location, programObject.GetMaxUniformLocation()); 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); const SizeT size = programObject.GetUniformSizesInBytes(location);
const Uint offset = programObject.GetUniformOffset(location);
char* pUBO = static_cast<char*>(programObject.MapUBO());
const SizeT uboSize = programObject.GetUBOSize();
SizeT writeSize = ItemCount * sizeof(T); SizeT writeSize = ItemCount * sizeof(T);
if (size < writeSize) { if (size < writeSize) {
// Metadata bug: degrade to a clamped copy instead of killing the process. // Metadata bug: degrade to a clamped copy instead of killing the process.
@@ -1097,6 +1141,18 @@ namespace MobileGL::MG_Impl::GLImpl {
__func__, programObject.GetExternalIndex(), location, ItemCount * sizeof(T), size); __func__, programObject.GetExternalIndex(), location, ItemCount * sizeof(T), size);
writeSize = size; writeSize = size;
} }
// The uniform shadow's LAYOUT is phase-B data, so a write that lands while the
// SPIR-V job is still running is recorded and replayed at its publish instead of
// joining it. This is the hot path for a shaderpack that sets its uniforms
// immediately after glLinkProgram. BufferUniformWrite declines (and we fall
// through, joining) only past its size budget.
if (programObject.IsSpirvPending() &&
programObject.BufferUniformWrite(location, byteOffsetInsideUniform, value, writeSize)) {
return;
}
const Uint offset = programObject.GetUniformOffset(location);
char* pUBO = static_cast<char*>(programObject.MapUBO());
const SizeT uboSize = programObject.GetUBOSize();
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset || if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
offset + byteOffsetInsideUniform + writeSize > uboSize) { offset + byteOffsetInsideUniform + writeSize > uboSize) {
// Should not happen: linking gives every settable uniform backing // Should not happen: linking gives every settable uniform backing
@@ -2658,6 +2714,15 @@ namespace MobileGL::MG_Impl::GLImpl {
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount, void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) { const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
// Every early-out below reports "nothing was written", and it has to say so before it can
// take one: callers legitimately leave *length uninitialised and then loop to it. The CTS
// does exactly that (gl4cProgramInterfaceQueryTests.cpp:2172 declares `GLsizei length;` and
// walks `for (i = 0; i < length; ++i)` over a 1000-entry stack array), so an untouched
// *length turned every error path here into a stack overrun inside the caller -
// KHR-GL43.program_interface_query.subroutines-vertex read 0x20202020 entries and died on
// both backends. The success path overwrites this with the real count.
if (length) *length = 0;
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__); auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
if (!programObject) return; if (!programObject) return;
if (!ProgramInterface::IsInterfaceEnum(programInterface)) { if (!ProgramInterface::IsInterfaceEnum(programInterface)) {
@@ -19,16 +19,26 @@ namespace MobileGL::MG_Impl::GLImpl {
code, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", function, Move(message))); 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 // GL 4.6 core 7.4 asks only that the name came from GenProgramPipelines and has not been
// reserved-but-unmaterialised name is INVALID_OPERATION (GL 4.6 core 7.4). // 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 SharedPtr<MG_State::GLState::ProgramPipelineObject>* TryGetPipeline(GLuint pipeline,
const char* function) { const char* function) {
if (!MG_State::pGLContext->IsProgramPipelineObject(pipeline)) { const auto& object = MG_State::pGLContext->MaterializeProgramPipelineObject(pipeline);
if (!object) {
RecordPipelineError(ErrorCode::InvalidOperation, function, RecordPipelineError(ErrorCode::InvalidOperation, function,
std::format("Program pipeline {} does not exist.", pipeline)); std::format("Program pipeline {} does not exist.", pipeline));
return nullptr; return nullptr;
} }
return &MG_State::pGLContext->GetProgramPipelineObject(pipeline); return &object;
} }
Bool ValidatePipelineCount(GLsizei n, const char* function) { Bool ValidatePipelineCount(GLsizei n, const char* function) {
@@ -8,6 +8,7 @@
#include "GL_RenderState.h" #include "GL_RenderState.h"
#include <cmath> #include <cmath>
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <MG_State/GLState/Core.h> #include <MG_State/GLState/Core.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h> #include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/Converters/GLToMG/RenderStateEnumConverter.h> #include <MG_Util/Converters/GLToMG/RenderStateEnumConverter.h>
@@ -380,7 +381,18 @@ namespace MobileGL::MG_Impl::GLImpl {
return; return;
} }
*data = IsEnabledi_State(target, index); // GL 4.6 core 22.1: glGetBooleani_v answers EVERY indexed state, not just the indexed
// capabilities - a non-boolean value simply reads back as "is it non-zero". Routing the
// non-capability enums to the pname table glGetIntegeri_v already owns is what makes
// that true; without it a query like glGetBooleani_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, 0)
// came back GL_INVALID_ENUM (KHR-GL43.compute_shader.max).
if (MG_Util::ConvertGLEnumToCapabilityInput(target) != CapabilityInput::Unknown) {
*data = IsEnabledi_State(target, index);
return;
}
GLint values[4] = {};
GetIntegeri_v(target, index, values);
*data = values[0] != 0 ? GL_TRUE : GL_FALSE;
} }
GLboolean IsEnabled_State(GLenum cap) { GLboolean IsEnabled_State(GLenum cap) {
+15 -1
View File
@@ -336,8 +336,22 @@ namespace MobileGL::MG_Impl::GLImpl {
return; return;
} }
// ARB_multi_bind adds one rule the single-bind path does not have: "samplers will not be
// created if they do not exist", so a name that is not an existing sampler OBJECT is
// INVALID_OPERATION here (KHR-GL44.multi_bind.errors_bind_samplers). Per element, not
// all-or-nothing - the extension defines glBindSamplers as a loop, so a bad entry costs
// its own texture unit and leaves the rest of the range bound.
for (GLsizei i = 0; i < count; ++i) { for (GLsizei i = 0; i < count; ++i) {
BindSampler_State(first + i, samplers ? samplers[i] : 0); const GLuint sampler = samplers ? samplers[i] : 0;
if (sampler != 0 && !MG_State::pGLContext->ValidateSamplerObject(sampler)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "BindSamplers",
std::format("samplers[{}] ({}) is not the name of an existing sampler object.", i, sampler)));
continue;
}
BindSampler_State(first + i, sampler);
} }
} }
+76 -9
View File
@@ -613,6 +613,23 @@ namespace MobileGL::MG_Impl::GLImpl {
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
"Compressed texture formats are not supported.")); "Compressed texture formats are not supported."));
} }
// glGetTexLevelParameter{i,f}v answers WIDTH/HEIGHT/DEPTH out of the mipmap chain. The only
// other storage type the state layer knows is GL_TEXTURE_BUFFER (TextureStorageType is
// {Mipmap, Buffer}), whose level geometry this stack does not track yet. Report that instead
// of throwing: THROW_UNIMPL_EXCEPTION unwinds a C++ exception through the C GL ABI and takes
// 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 "
"storage; recording GL_INVALID_OPERATION instead of terminating",
caller, MG_Util::ConvertGLEnumToString(pname).c_str());
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", caller,
"Level queries are not supported for texture-buffer storage."));
}
} // namespace } // namespace
const SharedPtr<MG_State::GLState::ITextureObject>& GetTextureObjectByName(GLuint texture, const char* caller) { const SharedPtr<MG_State::GLState::ITextureObject>& GetTextureObjectByName(GLuint texture, const char* caller) {
@@ -2910,7 +2927,8 @@ namespace MobileGL::MG_Impl::GLImpl {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; RecordUnsupportedLevelQueryStorage("GetTexLevelParameteriv_State", pname);
break;
} }
} }
break; break;
@@ -2924,7 +2942,8 @@ namespace MobileGL::MG_Impl::GLImpl {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; RecordUnsupportedLevelQueryStorage("GetTexLevelParameteriv_State", pname);
break;
} }
} }
break; break;
@@ -2938,7 +2957,8 @@ namespace MobileGL::MG_Impl::GLImpl {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; RecordUnsupportedLevelQueryStorage("GetTexLevelParameteriv_State", pname);
break;
} }
} }
break; break;
@@ -3045,7 +3065,8 @@ namespace MobileGL::MG_Impl::GLImpl {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; RecordUnsupportedLevelQueryStorage("GetTexLevelParameterfv_State", pname);
break;
} }
} }
break; break;
@@ -3059,7 +3080,8 @@ namespace MobileGL::MG_Impl::GLImpl {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; RecordUnsupportedLevelQueryStorage("GetTexLevelParameterfv_State", pname);
break;
} }
} }
break; break;
@@ -3073,7 +3095,8 @@ namespace MobileGL::MG_Impl::GLImpl {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; RecordUnsupportedLevelQueryStorage("GetTexLevelParameterfv_State", pname);
break;
} }
} }
break; break;
@@ -3403,7 +3426,10 @@ namespace MobileGL::MG_Impl::GLImpl {
GET_SRC_INTERNAL_FORMAT(readBufferType); GET_SRC_INTERNAL_FORMAT(readBufferType);
} }
if (!TextureImpl::ValidateBaseInternalFormatMatch(internalFormat, srcInternalFormat)) THROW_UNIMPL_EXCEPTION; // The validator has already recorded GL_INVALID_OPERATION; just decline. Throwing
// here unwound a C++ exception through the C GL ABI and killed the process (see the
// same reasoning at :604-609).
if (!TextureImpl::ValidateCopyTexImageBaseFormatSubset(internalFormat, srcInternalFormat)) return false;
GLenum outInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(srcInternalFormat); GLenum outInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(srcInternalFormat);
GLenum realInternalFormat = GL_RGBA8; GLenum realInternalFormat = GL_RGBA8;
@@ -3426,8 +3452,13 @@ namespace MobileGL::MG_Impl::GLImpl {
void CopyTexImage1D_State(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width, void CopyTexImage1D_State(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width,
GLint border) { GLint border) {
// TODO: implement // 1D textures are not implemented by this backend set. Record the error the way every
THROW_UNIMPL_EXCEPTION; // other unsupported entry point does - throwing unwinds through the C GL ABI and kills
// the process, which is never an acceptable answer to an unsupported call.
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "CopyTexImage1D",
"1D textures are not supported by this implementation"));
} }
void CompressedTexSubImage3D_State(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, void CompressedTexSubImage3D_State(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset,
@@ -4079,10 +4110,46 @@ namespace MobileGL::MG_Impl::GLImpl {
textureObject->SetImmutableLevels(static_cast<Uint>(levels)); textureObject->SetImmutableLevels(static_cast<Uint>(levels));
} }
// No block-compressed format is defined for a three-dimensional image, so glTexStorage3D on
// TEXTURE_3D must reject one - and with INVALID_OPERATION, not the INVALID_ENUM an unknown
// sized format gets (GL 4.6 core 8.19 / Khronos bug 11239, KHR-GLxx.texture_storage
// .compressed_data). Written against the enum ranges rather than a name list because the
// families are contiguous and MobileGL's own internal-format enum drops the ones it cannot
// carry, which would make this check silently narrower than the API surface.
static Bool IsCompressedGLInternalFormat(GLenum internalformat) {
switch (internalformat) {
case 0x8225: // GL_COMPRESSED_RED
case 0x8226: // GL_COMPRESSED_RG
case 0x84ED: // GL_COMPRESSED_RGB
case 0x84EE: // GL_COMPRESSED_RGBA
case 0x8C48: // GL_COMPRESSED_SRGB
case 0x8C49: // GL_COMPRESSED_SRGB_ALPHA
return true;
default:
break;
}
return (internalformat >= 0x83F0 && internalformat <= 0x83F3) || // S3TC / DXT
(internalformat >= 0x8DBB && internalformat <= 0x8DBE) || // RGTC
(internalformat >= 0x8E8C && internalformat <= 0x8E8F) || // BPTC
(internalformat >= 0x9270 && internalformat <= 0x9279) || // ETC2 / EAC
(internalformat >= 0x93B0 && internalformat <= 0x93BD) || // ASTC LDR
(internalformat >= 0x93D0 && internalformat <= 0x93DD); // ASTC sRGB
}
void TextureStorage3D(GLuint texture, GLsizei levels, GLenum internalformat, GLsizei width, GLsizei height, void TextureStorage3D(GLuint texture, GLsizei levels, GLenum internalformat, GLsizei width, GLsizei height,
GLsizei depth) { GLsizei depth) {
auto textureObject = GetTextureObjectByName(texture, __func__); auto textureObject = GetTextureObjectByName(texture, __func__);
if (!textureObject) return; if (!textureObject) return;
if (textureObject->GetTarget() == TextureTarget::Texture3D &&
IsCompressedGLInternalFormat(internalformat)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", __func__,
std::format("{} is a compressed internal format and cannot back GL_TEXTURE_3D storage.",
MG_Util::ConvertGLEnumToString(internalformat))));
return;
}
TextureInternalFormat textureInternalFormat = MG_Util::ConvertGLEnumToTextureInternalFormat(internalformat); TextureInternalFormat textureInternalFormat = MG_Util::ConvertGLEnumToTextureInternalFormat(internalformat);
if (!ValidateTextureStorageInternalFormat(textureInternalFormat, __func__)) return; if (!ValidateTextureStorageInternalFormat(textureInternalFormat, __func__)) return;
if (!ValidateTextureStorageShape(textureObject, 3, levels, width, height, depth, __func__)) return; if (!ValidateTextureStorageShape(textureObject, 3, levels, width, height, depth, __func__)) return;
+69 -7
View File
@@ -424,19 +424,81 @@ namespace MobileGL::MG_Impl::GLImpl::TextureImpl {
return true; return true;
} }
namespace {
// Component set of an UNSIZED base internal format, as the bitmask GL 4.6 SS 8.6
// reasons about. Colour components are independent bits so "subset" is a plain
// mask test; depth and stencil are their own components and never satisfy a
// colour request (or each other).
enum : Uint32 {
kComponentR = 1u << 0,
kComponentG = 1u << 1,
kComponentB = 1u << 2,
kComponentA = 1u << 3,
kComponentDepth = 1u << 4,
kComponentStencil = 1u << 5,
};
Uint32 BaseFormatComponents(TextureInternalFormat unsizedFormat) {
switch (unsizedFormat) {
case TextureInternalFormat::Red:
return kComponentR;
case TextureInternalFormat::RG:
return kComponentR | kComponentG;
case TextureInternalFormat::RGB:
return kComponentR | kComponentG | kComponentB;
case TextureInternalFormat::RGBA:
return kComponentR | kComponentG | kComponentB | kComponentA;
case TextureInternalFormat::DepthComponent:
return kComponentDepth;
case TextureInternalFormat::DepthStencil:
return kComponentDepth | kComponentStencil;
default:
return 0;
}
}
} // namespace
Bool ValidateBaseInternalFormatMatch(TextureInternalFormat format1, TextureInternalFormat format2) { Bool ValidateBaseInternalFormatMatch(TextureInternalFormat format1, TextureInternalFormat format2) {
auto unsizedFormat1 = MG_Util::ConvertInternalFormatToUnsized(format1); const auto unsizedFormat1 = MG_Util::ConvertInternalFormatToUnsized(format1);
auto unsizedFormat2 = MG_Util::ConvertInternalFormatToUnsized(format2); const auto unsizedFormat2 = MG_Util::ConvertInternalFormatToUnsized(format2);
if (unsizedFormat1 != unsizedFormat2) { 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.
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation, ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>( MakeUnique<GenericErrorInfo>(
std::format("MG_Impl/GLImpl", "ValidateBaseInternalFormatMatch", "MG_Impl/GLImpl", "ValidateBaseInternalFormatMatch",
"The base internal format of the two formats do not match ({} vs. {})", std::format("The base internal format of the two formats do not match ({} vs. {})",
MG_Util::ConvertTextureInternalFormatToString(unsizedFormat1).c_str(), MG_Util::ConvertTextureInternalFormatToString(unsizedFormat1),
MG_Util::ConvertTextureInternalFormatToString(unsizedFormat2).c_str()))); MG_Util::ConvertTextureInternalFormatToString(unsizedFormat2))));
return false; return false;
} }
return true; return true;
} // namespace TextureImpl }
Bool ValidateCopyTexImageBaseFormatSubset(TextureInternalFormat destFormat, TextureInternalFormat srcFormat) {
const auto unsizedDest = MG_Util::ConvertInternalFormatToUnsized(destFormat);
const auto unsizedSrc = MG_Util::ConvertInternalFormatToUnsized(srcFormat);
// GL 4.6 SS 8.6: glCopyTexImage* may request a SUBSET of the read buffer's components,
// not an exact match - GL_RGB from an RGBA8 framebuffer is textbook legal and is what
// Minecraft and its mods do. glCopyTexImage2D used to run the exact-match predicate
// above and turn its rejection into an uncaught exception through the C GL ABI, so the
// app died rather than seeing a GL error.
const Uint32 destComponents = BaseFormatComponents(unsizedDest);
const Uint32 srcComponents = BaseFormatComponents(unsizedSrc);
if (destComponents == 0 || srcComponents == 0 || (destComponents & ~srcComponents) != 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "ValidateCopyTexImageBaseFormatSubset",
std::format("the read buffer's base internal format {} does not provide every component of "
"the requested internal format {}",
MG_Util::ConvertTextureInternalFormatToString(unsizedSrc),
MG_Util::ConvertTextureInternalFormatToString(unsizedDest))));
return false;
}
return true;
}
} // namespace MobileGL::MG_Impl::GLImpl::TextureImpl } // namespace MobileGL::MG_Impl::GLImpl::TextureImpl
@@ -40,5 +40,9 @@ namespace MobileGL::MG_Impl::GLImpl::TextureImpl {
TextureTarget target); TextureTarget target);
Bool ValidateTextureSubImageOffsets(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject, Int xoffset, 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); 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); Bool ValidateBaseInternalFormatMatch(TextureInternalFormat format1, TextureInternalFormat format2);
// 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);
} // namespace MobileGL::MG_Impl::GLImpl::TextureImpl } // namespace MobileGL::MG_Impl::GLImpl::TextureImpl
@@ -179,6 +179,28 @@ namespace MobileGL::MG_Impl::GLImpl {
return vao; return vao;
} }
// The ARB_vertex_attrib_binding entry points that take no vertex array name modify the
// *bound* vertex array, and in a core profile the default vertex array (name 0) is not
// one: every one of them is INVALID_OPERATION there (GL 4.6 core 10.3.1, and the tail of
// each KHR-GL4x.vertex_attrib_binding.negative-* case checks exactly this). MobileGL
// keeps a real object at name 0 for the compatibility paths, so GetBoundVertexArray
// never returns null and the rule has to be spelled out - behind the same gate the VAO-0
// draw rule already uses (MOBILEGL_RELAXED_SEMANTICS, plus "the context never asked for
// a core profile"), so applications that legitimately run relaxed keep working.
static SharedPtr<MG_State::GLState::VertexArrayObject> GetBoundVertexArrayForBindingApi(const char* funcName) {
auto vao = GetBoundVertexArrayOrError(funcName);
if (!vao) return nullptr;
if (vao->GetExternalIndex() == 0 && !MG_State::IsRelaxedSemanticsActive()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", funcName,
"The default vertex array object cannot be modified in a core profile."));
return nullptr;
}
return vao;
}
static bool ValidateVertexAttribPname(GLenum pname) { static bool ValidateVertexAttribPname(GLenum pname) {
switch (pname) { switch (pname) {
case GL_VERTEX_ATTRIB_ARRAY_ENABLED: case GL_VERTEX_ATTRIB_ARRAY_ENABLED:
@@ -944,7 +966,7 @@ namespace MobileGL::MG_Impl::GLImpl {
params[0] = static_cast<GLfloat>(attr->Size); params[0] = static_cast<GLfloat>(attr->Size);
return; return;
case GL_VERTEX_ATTRIB_ARRAY_STRIDE: case GL_VERTEX_ATTRIB_ARRAY_STRIDE:
params[0] = static_cast<GLfloat>(attr->Stride); params[0] = static_cast<GLfloat>(attr->LegacyStride);
return; return;
case GL_VERTEX_ATTRIB_ARRAY_TYPE: case GL_VERTEX_ATTRIB_ARRAY_TYPE:
params[0] = static_cast<GLfloat>(MG_Util::ConvertDataTypeToGLEnum(attr->Type)); params[0] = static_cast<GLfloat>(MG_Util::ConvertDataTypeToGLEnum(attr->Type));
@@ -1014,7 +1036,7 @@ namespace MobileGL::MG_Impl::GLImpl {
params[0] = static_cast<GLdouble>(attr->Size); params[0] = static_cast<GLdouble>(attr->Size);
return; return;
case GL_VERTEX_ATTRIB_ARRAY_STRIDE: case GL_VERTEX_ATTRIB_ARRAY_STRIDE:
params[0] = static_cast<GLdouble>(attr->Stride); params[0] = static_cast<GLdouble>(attr->LegacyStride);
return; return;
case GL_VERTEX_ATTRIB_ARRAY_TYPE: case GL_VERTEX_ATTRIB_ARRAY_TYPE:
params[0] = static_cast<GLdouble>(MG_Util::ConvertDataTypeToGLEnum(attr->Type)); params[0] = static_cast<GLdouble>(MG_Util::ConvertDataTypeToGLEnum(attr->Type));
@@ -1079,8 +1101,11 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_VERTEX_ATTRIB_ARRAY_SIZE: case GL_VERTEX_ATTRIB_ARRAY_SIZE:
params[0] = attr->Size; params[0] = attr->Size;
return; return;
// The legacy shadow, not the resolved draw stride: GL 4.6 core table 23.3 defines this
// as the last glVertexAttrib*Pointer argument, which glBindVertexBuffer must not
// overwrite even though it does overwrite what the backend actually reads.
case GL_VERTEX_ATTRIB_ARRAY_STRIDE: case GL_VERTEX_ATTRIB_ARRAY_STRIDE:
params[0] = attr->Stride; params[0] = attr->LegacyStride;
return; return;
case GL_VERTEX_ATTRIB_ARRAY_TYPE: case GL_VERTEX_ATTRIB_ARRAY_TYPE:
params[0] = static_cast<GLint>(MG_Util::ConvertDataTypeToGLEnum(attr->Type)); params[0] = static_cast<GLint>(MG_Util::ConvertDataTypeToGLEnum(attr->Type));
@@ -1138,7 +1163,7 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
const auto& attr = vao->GetAttribute(index); const auto& attr = vao->GetAttribute(index);
*pointer = reinterpret_cast<void*>(attr.Offset); *pointer = reinterpret_cast<void*>(attr.LegacyPointer);
} }
void GetVertexAttribIiv(GLuint index, GLenum pname, GLint* params) { void GetVertexAttribIiv(GLuint index, GLenum pname, GLint* params) {
@@ -1222,7 +1247,7 @@ namespace MobileGL::MG_Impl::GLImpl {
*param = static_cast<GLint>(attr.Size); *param = static_cast<GLint>(attr.Size);
return; return;
case GL_VERTEX_ATTRIB_ARRAY_STRIDE: case GL_VERTEX_ATTRIB_ARRAY_STRIDE:
*param = static_cast<GLint>(attr.Stride); *param = static_cast<GLint>(attr.LegacyStride);
return; return;
case GL_VERTEX_ATTRIB_ARRAY_TYPE: case GL_VERTEX_ATTRIB_ARRAY_TYPE:
*param = static_cast<GLint>(MG_Util::ConvertDataTypeToGLEnum(attr.Type)); *param = static_cast<GLint>(MG_Util::ConvertDataTypeToGLEnum(attr.Type));
@@ -1294,14 +1319,14 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
void BindVertexBuffer(GLuint bindingindex, GLuint buffer, GLintptr offset, GLsizei stride) { void BindVertexBuffer(GLuint bindingindex, GLuint buffer, GLintptr offset, GLsizei stride) {
auto vao = GetBoundVertexArrayOrError("BindVertexBuffer"); auto vao = GetBoundVertexArrayForBindingApi("BindVertexBuffer");
if (!vao) return; if (!vao) return;
VertexBufferBinding_State(vao, bindingindex, buffer, offset, stride, "BindVertexBuffer"); VertexBufferBinding_State(vao, bindingindex, buffer, offset, stride, "BindVertexBuffer");
} }
void BindVertexBuffers(GLuint first, GLsizei count, const GLuint* buffers, const GLintptr* offsets, void BindVertexBuffers(GLuint first, GLsizei count, const GLuint* buffers, const GLintptr* offsets,
const GLsizei* strides) { const GLsizei* strides) {
auto vao = GetBoundVertexArrayOrError("BindVertexBuffers"); auto vao = GetBoundVertexArrayForBindingApi("BindVertexBuffers");
if (!vao) return; if (!vao) return;
if (!ValidateVertexBindingRange(first, count, "BindVertexBuffers")) return; if (!ValidateVertexBindingRange(first, count, "BindVertexBuffers")) return;
for (GLsizei i = 0; i < count; ++i) { for (GLsizei i = 0; i < count; ++i) {
@@ -1315,21 +1340,21 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
void VertexAttribFormat(GLuint attribindex, GLint size, GLenum type, GLboolean normalized, GLuint relativeoffset) { void VertexAttribFormat(GLuint attribindex, GLint size, GLenum type, GLboolean normalized, GLuint relativeoffset) {
auto vao = GetBoundVertexArrayOrError("VertexAttribFormat"); auto vao = GetBoundVertexArrayForBindingApi("VertexAttribFormat");
if (!vao) return; if (!vao) return;
VertexAttribFormatSeparate_State(vao, attribindex, size, type, normalized, relativeoffset, false, VertexAttribFormatSeparate_State(vao, attribindex, size, type, normalized, relativeoffset, false,
"VertexAttribFormat"); "VertexAttribFormat");
} }
void VertexAttribIFormat(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset) { void VertexAttribIFormat(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset) {
auto vao = GetBoundVertexArrayOrError("VertexAttribIFormat"); auto vao = GetBoundVertexArrayForBindingApi("VertexAttribIFormat");
if (!vao) return; if (!vao) return;
VertexAttribFormatSeparate_State(vao, attribindex, size, type, GL_FALSE, relativeoffset, true, VertexAttribFormatSeparate_State(vao, attribindex, size, type, GL_FALSE, relativeoffset, true,
"VertexAttribIFormat"); "VertexAttribIFormat");
} }
void VertexAttribLFormat(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset) { void VertexAttribLFormat(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset) {
auto vao = GetBoundVertexArrayOrError("VertexAttribLFormat"); auto vao = GetBoundVertexArrayForBindingApi("VertexAttribLFormat");
if (!vao) return; if (!vao) return;
VertexAttribLFormatSeparate_State(vao, attribindex, size, type, relativeoffset); VertexAttribLFormatSeparate_State(vao, attribindex, size, type, relativeoffset);
} }
@@ -1341,7 +1366,7 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
void VertexAttribBinding(GLuint attribindex, GLuint bindingindex) { void VertexAttribBinding(GLuint attribindex, GLuint bindingindex) {
auto vao = GetBoundVertexArrayOrError("VertexAttribBinding"); auto vao = GetBoundVertexArrayForBindingApi("VertexAttribBinding");
if (!vao) return; if (!vao) return;
if (!VertexArrayImpl::ValidateVertexAttributeIndex(attribindex)) return; if (!VertexArrayImpl::ValidateVertexAttributeIndex(attribindex)) return;
if (!ValidateVertexBindingIndex(bindingindex, "VertexAttribBinding")) return; if (!ValidateVertexBindingIndex(bindingindex, "VertexAttribBinding")) return;
@@ -1349,7 +1374,7 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
void VertexBindingDivisor(GLuint bindingindex, GLuint divisor) { void VertexBindingDivisor(GLuint bindingindex, GLuint divisor) {
auto vao = GetBoundVertexArrayOrError("VertexBindingDivisor"); auto vao = GetBoundVertexArrayForBindingApi("VertexBindingDivisor");
if (!vao) return; if (!vao) return;
if (!ValidateVertexBindingIndex(bindingindex, "VertexBindingDivisor")) return; if (!ValidateVertexBindingIndex(bindingindex, "VertexBindingDivisor")) return;
vao->SetBindingDivisor(bindingindex, divisor); vao->SetBindingDivisor(bindingindex, divisor);
+28 -14
View File
@@ -53,6 +53,21 @@ add_executable(MobileGLIntegrationTest
Scenarios/AsyncCompileScenario.cpp Scenarios/AsyncCompileScenario.cpp
Scenarios/XfbAfterClipDistanceScenario.cpp Scenarios/XfbAfterClipDistanceScenario.cpp
Scenarios/ThreeChannelAttachmentScenario.cpp Scenarios/ThreeChannelAttachmentScenario.cpp
Scenarios/PipelineFailureScenario.cpp
Scenarios/AdvertisedLimitsScenario.cpp
Scenarios/PixelStoreSweepScenario.cpp
Scenarios/FragCoordOriginScenario.cpp
Scenarios/ClearThenReadPixelsScenario.cpp
Scenarios/DepthStencilReadbackScenario.cpp
Scenarios/SsboArrayLengthScenario.cpp
Scenarios/UniformInitializerScenario.cpp
Scenarios/SwizzleAccessRoutineScenario.cpp
Scenarios/ProgramPipelineScenario.cpp
Scenarios/ImageLoadStoreSsoScenario.cpp
Scenarios/SsboDeclarationFormScenario.cpp
Scenarios/Glsl420DeclarationScenario.cpp
Scenarios/FragmentOutputArrayIndexScenario.cpp
Scenarios/BufferTextureScenario.cpp
) )
target_include_directories(MobileGLIntegrationTest PRIVATE target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -169,25 +184,24 @@ endif()
option(MOBILEGL_ITEST_REQUIRE_GPU option(MOBILEGL_ITEST_REQUIRE_GPU
"Fail (rather than skip) the integration scenarios when the headless harness is unusable" OFF) "Fail (rather than skip) the integration scenarios when the headless harness is unusable" OFF)
# DirectGLES asks the system EGL for a pbuffer config, and on Mesa the default # No EGL_PLATFORM knob here on purpose. The harness pins EGL_PLATFORM=surfaceless
# platform is not X11 unless it is said out loud (run_driver_bench.sh sets the # itself before its first EGL call (HeadlessGL.cpp, EnsureHeadlessPlatform) so a
# same variable). Wrong platform here is not a soft failure: eglCreatePbuffer # developer's machine and a CI runner take the SAME path whether or not a window
# fails and every scenario skips. # system happens to be running. This used to inject "x11", which is how the lane
if (UNIX AND NOT APPLE AND NOT ANDROID) # came up green on a workstation with WSLg and died on a runner with no X server.
set(MOBILEGL_ITEST_EGL_PLATFORM "x11" CACHE STRING #
"EGL_PLATFORM for the integration tests (empty: leave the loader alone)") # A build-system knob would not just be redundant, it would be a trap: `set(...
else() # CACHE ...)` does not rewrite an existing cache, so every build directory
set(MOBILEGL_ITEST_EGL_PLATFORM "" CACHE STRING # configured before this change would keep injecting EGL_PLATFORM=x11 and go on
"EGL_PLATFORM for the integration tests (empty: leave the loader alone)") # binding to a window system - silently, and only on the machines that have one.
endif() # Someone reproducing a platform-specific bug sets EGL_PLATFORM in their own
# environment, which the harness still honours.
set(MGL_ITEST_COMMON_ENV "") set(MGL_ITEST_COMMON_ENV "")
if (MOBILEGL_ITEST_EGL_VENDOR) if (MOBILEGL_ITEST_EGL_VENDOR)
list(APPEND MGL_ITEST_COMMON_ENV "__EGL_VENDOR_LIBRARY_FILENAMES=${MOBILEGL_ITEST_EGL_VENDOR}") list(APPEND MGL_ITEST_COMMON_ENV "__EGL_VENDOR_LIBRARY_FILENAMES=${MOBILEGL_ITEST_EGL_VENDOR}")
endif() endif()
if (MOBILEGL_ITEST_EGL_PLATFORM) unset(MOBILEGL_ITEST_EGL_PLATFORM CACHE) # see above: an old cache must not resurrect x11
list(APPEND MGL_ITEST_COMMON_ENV "EGL_PLATFORM=${MOBILEGL_ITEST_EGL_PLATFORM}")
endif()
if (MOBILEGL_ITEST_REQUIRE_GPU) if (MOBILEGL_ITEST_REQUIRE_GPU)
list(APPEND MGL_ITEST_COMMON_ENV "MOBILEGL_ITEST_REQUIRE_GPU=1") list(APPEND MGL_ITEST_COMMON_ENV "MOBILEGL_ITEST_REQUIRE_GPU=1")
endif() endif()
@@ -74,6 +74,40 @@ namespace MGITest {
std::string renderer; std::string renderer;
}; };
// The harness is headless BY CONSTRUCTION, on every machine: it must never
// reach a window system, not even where one happens to be running. This is
// not a CI accommodation - it is what keeps a developer's run and a CI run
// the same run. The lane was wired up green on a workstation and immediately
// died on the runner precisely because the workstation had a DISPLAY (WSLg)
// and took Mesa's x11 platform, while the runner has none; that divergence
// is the bug, and pinning the platform here is the fix for it.
//
// Mesa selects its EGL platform from EGL_PLATFORM at loader time, so this
// has to run before the first EGL call in the process (see EnsureHeadless
// callers). surfaceless is the platform with no window-system dependency at
// all; the surface this file then creates is still a pbuffer, which every
// platform supports and which the amendment to this rule requires as the
// fallback shape. DISPLAY/WAYLAND_DISPLAY are cleared as well so that a
// driver that consults them directly cannot reintroduce the dependency
// behind EGL's back. Desktop-only file: MG_IntegrationTest never builds
// for Android, so no device path is affected.
void EnsureHeadlessPlatform() {
#if defined(__linux__) && !defined(__ANDROID__)
static bool done = false;
if (done) {
return;
}
done = true;
// An explicit EGL_PLATFORM from the operator still wins: pinning a
// platform is exactly how someone reproduces a platform-specific bug.
if (std::getenv("EGL_PLATFORM") == nullptr) {
setenv("EGL_PLATFORM", "surfaceless", 1);
}
unsetenv("DISPLAY");
unsetenv("WAYLAND_DISPLAY");
#endif
}
// THE bring-up, in one function so the pre-flight child and the parent run // THE bring-up, in one function so the pre-flight child and the parent run
// literally the same sequence - a pre-flight that tests something narrower // literally the same sequence - a pre-flight that tests something narrower
// than what the parent will do is exactly the kind of "predictive" check // than what the parent will do is exactly the kind of "predictive" check
@@ -82,6 +116,9 @@ namespace MGITest {
// Returns 0 on success, or the 1-based index of the step that failed, and // Returns 0 on success, or the 1-based index of the step that failed, and
// fills outReason either way. // fills outReason either way.
int RunEglBringUp(EglBringUp& out, std::string& outReason) { int RunEglBringUp(EglBringUp& out, std::string& outReason) {
// Belt and braces: the pre-flight child and the parent both enter here,
// and neither may be the first to touch EGL without this having run.
EnsureHeadlessPlatform();
EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY); EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (display == EGL_NO_DISPLAY) { if (display == EGL_NO_DISPLAY) {
outReason = WithEglError("eglGetDisplay(EGL_DEFAULT_DISPLAY) returned EGL_NO_DISPLAY"); outReason = WithEglError("eglGetDisplay(EGL_DEFAULT_DISPLAY) returned EGL_NO_DISPLAY");
@@ -199,11 +236,10 @@ namespace MGITest {
} }
if (child == 0) { if (child == 0) {
close(channel[0]); close(channel[0]);
// The child is EXPECTED to die on a signal on an unusable // No core suppression here, deliberately: when the child dies on a
// platform; that is the measurement. Do not let each such // signal, the core IS the diagnosis (an rlimit that used to sit here
// measurement drop a core file next to the test binary. // made a CI-only crash undebuggable). Machines that do not want
const rlimit noCore{0, 0}; // cores control that with the usual ulimit/core_pattern knobs.
setrlimit(RLIMIT_CORE, &noCore);
std::fprintf(stderr, "[itest] pre-flight child: attempting a full EGL bring-up\n"); std::fprintf(stderr, "[itest] pre-flight child: attempting a full EGL bring-up\n");
EglBringUp local; EglBringUp local;
std::string reason; std::string reason;
@@ -284,9 +320,19 @@ namespace MGITest {
} }
} // namespace } // namespace
namespace {
bool EnvFlag(const char* name) {
const char* value = std::getenv(name);
return value != nullptr && value[0] != '\0' && std::strcmp(value, "0") != 0;
}
} // namespace
bool RequireGpu() { bool RequireGpu() {
const char* value = std::getenv("MOBILEGL_ITEST_REQUIRE_GPU"); return EnvFlag("MOBILEGL_ITEST_REQUIRE_GPU");
return value != nullptr && value[0] != '\0' && std::strcmp(value, "0") != 0; }
bool RequireHardwareGpu() {
return EnvFlag("MOBILEGL_ITEST_REQUIRE_HARDWARE_GPU");
} }
std::ostream& operator<<(std::ostream& os, const Rgba8& c) { std::ostream& operator<<(std::ostream& os, const Rgba8& c) {
@@ -390,6 +436,10 @@ namespace MGITest {
} }
HeadlessGL::HeadlessGL() { HeadlessGL::HeadlessGL() {
// Before anything else in this process can reach EGL, and in particular
// before the pre-flight forks - the child must measure the same platform
// the parent will use.
EnsureHeadlessPlatform();
m_backendName = EnvOr("MOBILEGL_BACKEND_TYPE", "<unset>"); m_backendName = EnvOr("MOBILEGL_BACKEND_TYPE", "<unset>");
m_usable = BringUp(); m_usable = BringUp();
} }
@@ -551,9 +601,13 @@ namespace MGITest {
} }
Image ReadPixels(int width, int height) { Image ReadPixels(int width, int height) {
return ReadPixelsRect(0, 0, width, height);
}
Image ReadPixelsRect(int x, int y, int width, int height) {
Image image(width, height); Image image(width, height);
glPixelStorei(GL_PACK_ALIGNMENT, 1); glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.Data()); glReadPixels(x, y, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.Data());
return image; return image;
} }
@@ -41,6 +41,15 @@ namespace MGITest {
// a job that ran everything. // a job that ran everything.
bool RequireGpu(); bool RequireGpu();
// True when MOBILEGL_ITEST_REQUIRE_HARDWARE_GPU is set: additionally asserts
// that the context did NOT land on a software rasterizer. Deliberately a
// SEPARATE switch from RequireGpu - a GPU-less CI runner is a supported and
// intended configuration for these scenarios (they pin backend draw logic,
// which llvmpipe/lavapipe execute faithfully), so CI wants the falsifiability
// of REQUIRE_GPU without the hardware demand. Use this one only where a vendor
// pin silently degrading to software would invalidate the measurement.
bool RequireHardwareGpu();
struct Rgba8 { struct Rgba8 {
std::uint8_t r = 0, g = 0, b = 0, a = 0; std::uint8_t r = 0, g = 0, b = 0, a = 0;
@@ -175,11 +184,18 @@ namespace MGITest {
void ClearTo(float r, float g, float b, float a); void ClearTo(float r, float g, float b, float a);
// Reads back the whole currently bound READ framebuffer. width/height must // Reads back the whole currently bound READ framebuffer.
// be the target's full size - DirectVulkan's default-framebuffer readback
// only re-orients a full-extent read.
Image ReadPixels(int width, int height); Image ReadPixels(int width, int height);
// A PARTIAL glReadPixels. Row 0 of the returned image is GL row `y` of the
// framebuffer, i.e. the bottom row of the requested rect - the same
// convention ReadPixels uses, just with an origin. This is the shape the
// conformance suite reads in (a random sub-rect of the default
// framebuffer), and the shape DirectVulkan's default-FBO readback used to
// hand back in Vulkan row order because its re-orientation only ran on an
// exact full-extent read.
Image ReadPixelsRect(int x, int y, int width, int height);
// Drains any GL error queue and returns the first error, or 0. // Drains any GL error queue and returns the first error, or 0.
unsigned int FirstGLError(); unsigned int FirstGLError();
const char* GLErrorName(unsigned int error); const char* GLErrorName(unsigned int error);
@@ -45,12 +45,18 @@ namespace MGITest {
} }
GTEST_SKIP() << "no usable GPU/display/ICD for backend " << gl.BackendName() << ": " << gl.SkipReason(); GTEST_SKIP() << "no usable GPU/display/ICD for backend " << gl.BackendName() << ": " << gl.SkipReason();
} }
if (RequireGpu() && LooksLikeSoftwareRasterizer(gl.RendererString())) { if (RequireHardwareGpu() && LooksLikeSoftwareRasterizer(gl.RendererString())) {
// "Ran on llvmpipe" must not be able to pass as "ran on the GPU": // Only when hardware was asked for BY NAME. REQUIRE_GPU means "an
// a misconfigured vendor pin silently lands on the software // unusable harness is a failure, not a silent skip" - it is the
// rasterizer, and REQUIRE_GPU exists precisely to make that loud. // falsifiability switch, and CI is exactly where it belongs. But CI
FAIL() << "MOBILEGL_ITEST_REQUIRE_GPU is set but the context landed on a software rasterizer: " // runners have no GPU, so folding "must not be llvmpipe" into the
<< gl.RendererString(); // same switch made the CI lane unpassable by construction: the
// scenarios pin backend draw logic, which a software rasterizer
// executes just as faithfully. Landing on llvmpipe/lavapipe there is
// the intended configuration, not a misconfiguration. A vendor pin
// that must not silently degrade sets REQUIRE_HARDWARE_GPU.
FAIL() << "MOBILEGL_ITEST_REQUIRE_HARDWARE_GPU is set but the context landed on a software "
<< "rasterizer: " << gl.RendererString();
} }
// A scenario starts from a clean slate but shares the context (and so // A scenario starts from a clean slate but shares the context (and so
// the renderer's memos) with every other scenario in this process - // the renderer's memos) with every other scenario in this process -
+8 -2
View File
@@ -26,8 +26,14 @@ namespace {
const MGITest::HeadlessGL& gl = MGITest::HeadlessGL::Get(); const MGITest::HeadlessGL& gl = MGITest::HeadlessGL::Get();
std::fprintf(stderr, "MobileGL integration scenarios: backend=%s\n", gl.BackendName().c_str()); std::fprintf(stderr, "MobileGL integration scenarios: backend=%s\n", gl.BackendName().c_str());
if (gl.Usable()) { if (gl.Usable()) {
std::fprintf(stderr, " renderer: %s\n surface: %dx%d pbuffer (headless)\n", // EGL_PLATFORM is echoed because it is the invariant this harness
gl.RendererString().c_str(), gl.Width(), gl.Height()); // rests on: the run is headless on every machine, so a run that
// silently bound to a workstation's window system is a different
// run from CI's and must be visible as one in the log.
const char* eglPlatform = std::getenv("EGL_PLATFORM");
std::fprintf(stderr, " renderer: %s\n surface: %dx%d pbuffer (headless, EGL_PLATFORM=%s)\n",
gl.RendererString().c_str(), gl.Width(), gl.Height(),
eglPlatform != nullptr ? eglPlatform : "<unset>");
} else if (MGITest::RequireGpu()) { } else if (MGITest::RequireGpu()) {
std::fprintf(stderr, std::fprintf(stderr,
" FAILING every scenario (MOBILEGL_ITEST_REQUIRE_GPU is set): %s\n", " FAILING every scenario (MOBILEGL_ITEST_REQUIRE_GPU is set): %s\n",
@@ -0,0 +1,120 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.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 limit we advertise is a promise, and an application will hold us to it."
//
// DirectVulkan copied Vulkan descriptor limits straight into the GL limit table. Those are not
// the same quantity: Adreno answers maxPerStageDescriptorUniformBuffers at descriptor-indexing
// scale, and GL_MAX_COMPUTE_UNIFORM_BLOCKS is a count an app will allocate. KHR-GL44.multi_bind
// .dispatch_bind_buffers_base does exactly that - createsO(limit) buffers and splices O(limit)
// UBO declarations into one compute shader - and spent ~14 s allocating before dying on
// std::bad_alloc. Its sibling dispatch_bind_buffers_range hard-codes 4 buffers and passes.
//
// Two failure modes, one table:
// - too LARGE: an unusable promise (the OOM above).
// - too SMALL or negative: a uint32 limit that lost its top bit on the way to a signed Int -
// UINT32_MAX arrived as -1, which every downstream std::min then accepted as "small enough".
// A conformant GL 4.x implementation may never advertise below the spec minimum either.
//
// Every bound below is checked on BOTH backends, because the loader casts are shared and the
// DirectGLES lane is the control: it takes its limits from a driver that already reports GL
// quantities, so an entry that only fails on DirectVulkan is a translation bug and one that
// fails on both is a table bug.
#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 {
struct LimitBound {
GLenum pname;
const char* name;
// The GL 4.x required minimum. A value below this is a conformance failure in its own
// right, and is what a sign-flipped uint32 looks like.
int minimum;
// The largest value this implementation is willing to promise. Chosen well above every
// desktop driver's answer, so it can only catch a descriptor-scale number.
int ceiling;
};
const std::vector<LimitBound>& BufferLimitTable() {
static const std::vector<LimitBound> table = {
{GL_MAX_UNIFORM_BUFFER_BINDINGS, "GL_MAX_UNIFORM_BUFFER_BINDINGS", 36, 256},
{GL_MAX_COMPUTE_UNIFORM_BLOCKS, "GL_MAX_COMPUTE_UNIFORM_BLOCKS", 12, 256},
{GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, "GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS", 8, 256},
{GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS, "GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS", 8, 256},
{GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS, "GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS", 8, 256},
{GL_MAX_TEXTURE_BUFFER_SIZE, "GL_MAX_TEXTURE_BUFFER_SIZE", 65536, 1 << 27},
{GL_MAX_UNIFORM_BLOCK_SIZE, "GL_MAX_UNIFORM_BLOCK_SIZE", 16384, 1 << 30},
// Already clamped before this campaign; in the table so a regression there is
// caught by the same case.
{GL_MAX_SHADER_STORAGE_BLOCK_SIZE, "GL_MAX_SHADER_STORAGE_BLOCK_SIZE", 1 << 24, 512 * 1024 * 1024},
{GL_MAX_TEXTURE_IMAGE_UNITS, "GL_MAX_TEXTURE_IMAGE_UNITS", 16, 32},
{GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, "GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS", 48, 192},
};
return table;
}
class AdvertisedLimitsScenario : public ScenarioTest {};
TEST_F(AdvertisedLimitsScenario, EveryBufferLimitIsWithinItsAdvertisedRange) {
for (const LimitBound& bound : BufferLimitTable()) {
GLint value = -424242;
glGetIntegerv(bound.pname, &value);
const unsigned int error = FirstGLError();
EXPECT_EQ(error, GLenum(GL_NO_ERROR))
<< bound.name << " is not answerable: " << GLErrorName(error);
if (error != GL_NO_ERROR) continue;
EXPECT_GE(value, bound.minimum)
<< bound.name << " = " << value << " is below the GL required minimum "
<< bound.minimum << " (a negative or tiny value here is a uint32 limit that lost "
"its top bit on the way to a signed Int)";
EXPECT_LE(value, bound.ceiling)
<< bound.name << " = " << value << " exceeds the ceiling " << bound.ceiling
<< " this implementation is willing to promise - an application that allocates "
"what we advertise will run out of memory";
}
}
// 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) {
GLint blocks = -1;
glGetIntegerv(GL_MAX_COMPUTE_UNIFORM_BLOCKS, &blocks);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_GE(blocks, 12);
EXPECT_LE(blocks, 256) << "KHR-GL44.multi_bind.dispatch_bind_buffers_base creates one GL buffer "
"and one UBO declaration per advertised block";
GLint blockSize = -1;
glGetIntegerv(GL_MAX_UNIFORM_BLOCK_SIZE, &blockSize);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_GT(blockSize, 0);
// GL_MAX_COMBINED_COMPUTE_UNIFORM_COMPONENTS is derived from the product of these two,
// so their product has to stay representable.
EXPECT_LE(static_cast<long long>(blocks) * blockSize,
static_cast<long long>(2147483647))
<< "blocks(" << blocks << ") * blockSize(" << blockSize << ") overflows the GLint the "
"derived component limits are computed in";
}
} // namespace
} // namespace MGITest
@@ -157,6 +157,22 @@ void main() {
const QuirkOverride m_saved; const QuirkOverride m_saved;
}; };
// MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS, forced in-process for the same reason
// as AsyncModeScope: one ctest run asserts the quirk against the ambient default.
class OptimisticStatusScope {
public:
explicit OptimisticStatusScope(const QuirkOverride mode)
: m_saved(MobileGL::MG_Config::Features.AsyncOptimisticShaderStatus) {
MobileGL::MG_Config::Features.AsyncOptimisticShaderStatus = mode;
}
~OptimisticStatusScope() { MobileGL::MG_Config::Features.AsyncOptimisticShaderStatus = m_saved; }
OptimisticStatusScope(const OptimisticStatusScope&) = delete;
OptimisticStatusScope& operator=(const OptimisticStatusScope&) = delete;
private:
const QuirkOverride m_saved;
};
// glMaxShaderCompilerThreadsKHR writes process-wide state; a scenario that calls // glMaxShaderCompilerThreadsKHR writes process-wide state; a scenario that calls
// it has to put the pool back or it changes how every scenario after it compiles. // it has to put the pool back or it changes how every scenario after it compiles.
class CompilerThreadScope { class CompilerThreadScope {
@@ -463,5 +479,81 @@ void main() {
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
} }
// The Iris two-phase shape end to end on a real driver, with the optimistic-status
// quirk on: phase 1 compiles each stage and reads its log then its status (both
// answered optimistically), links, detaches and deletes the shaders for every
// program with no program-level read anywhere; phase 2 then checks every link and
// draws every program. Deliberately NOT built on the harness CompileProgram(),
// whose status read would join and collapse the phase-1 overlap this exists to
// exercise. What the unit suite cannot see - worker-produced artifacts the backend
// then mis-renders - shows up here as a wrong quadrant signature.
TEST_F(AsyncCompileScenario, IrisShapedTwoPhaseBatchRendersCorrectly) {
if (!Ready()) return;
constexpr int kPrograms = 12;
// Distinct per program (so neither the source memo nor the adoption map turns
// a compile into a no-op) but a pure pass-through at runtime: the bulk sits in
// a branch a zero-initialised uniform never takes.
const auto fragmentSource = [](const int index) {
std::string source = "#version 330 core\nin vec3 vColor;\nout vec4 oColor;\n";
source += "uniform float uGate" + std::to_string(index) + ";\n";
source += "void main() {\n oColor = vec4(vColor, 1.0);\n";
source += " if (uGate" + std::to_string(index) + " > 1e30) {\n float acc = 1.0;\n";
for (int i = 0; i < 60; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0);\n";
}
source += " oColor = vec4(acc);\n }\n}\n";
return source;
};
std::vector<GLuint> programs;
{
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(QuirkOverride::ForceOn);
const CompilerThreadScope threads;
glMaxShaderCompilerThreadsKHR(1);
for (int i = 0; i < kPrograms; ++i) {
m_sources.push_back(fragmentSource(i));
const char* fsText = m_sources.back().c_str();
const GLuint vs = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vs, 1, &kVertexSource, nullptr);
glCompileShader(vs);
(void)ShaderInfoLog(vs); // Iris's exact order: the log first...
(void)ShaderCompileStatus(vs); // ...then the status; both optimistic.
const GLuint fs = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fs, 1, &fsText, nullptr);
glCompileShader(fs);
(void)ShaderInfoLog(fs);
(void)ShaderCompileStatus(fs);
const GLuint program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glBindAttribLocation(program, 0, "aPos");
glBindAttribLocation(program, 1, "aColor");
glLinkProgram(program);
glDetachShader(program, vs);
glDetachShader(program, fs);
glDeleteShader(vs);
glDeleteShader(fs);
programs.push_back(program);
}
}
for (int i = 0; i < kPrograms; ++i) {
const GLuint program = programs[static_cast<std::size_t>(i)];
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE) << "program " << i;
const Image image = DrawFrameWith(program);
EXPECT_EQ(image.QuadrantSignature(), "blue,green,red,white") << "program " << i;
}
for (const GLuint program : programs) glDeleteProgram(program);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
} // namespace } // namespace
} // namespace MGITest } // namespace MGITest
@@ -0,0 +1,174 @@
// 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 <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); }
)";
class BufferTextureScenario : public ScenarioTest {};
// 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);
}
} // namespace MGITest
@@ -0,0 +1,335 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ClearThenReadPixelsScenario.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 CLEAR OF THE DEFAULT FRAMEBUFFER IS VISIBLE TO glReadPixels WITH NO DRAW BETWEEN.
//
// DirectVulkan parks a glClear as a pending clear and folds it into the next render pass's
// loadOp. When nothing is drawn after the clear there is no render pass, and the readback path
// used to materialize pending clears only for USER framebuffers - so a readback right after a
// clear of the DEFAULT framebuffer blitted the untouched swapchain image and handed back the
// previous frame's colour.
//
// That is the whole of KHR-GL40.draw_indirect.negative-* (12 Magma failures): each case clears,
// issues a draw that correctly raises INVALID_OPERATION and therefore never executes, then reads
// the frame back expecting (0,0,0,0) and gets the previous case's (0.1,0.2,0.3,1). The staleness
// cannot appear in one frame, so the scenario paints a frame first and clears in the next.
//
// The alpha assertion is the second half of the same census finding: a cleared default
// framebuffer read back (0,0,0,1) where (0,0,0,0) was written, because the clear was routed
// through the default FBO's placeholder attachment, whose format can lack alpha, rather than
// through the swapchain image that actually has one.
//
// DirectGLES is the built-in control: a native GL driver has no deferred-clear model at all, so
// a failure there would mean the scenario, not the backend.
#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 colour KHR-GL40.draw_indirect's fshSimple paints, so a stale readback shows up as
// the same value the conformance log reports.
constexpr const char* kFS = R"(#version 330 core
out vec4 o_color;
void main() { o_color = vec4(0.1, 0.2, 0.3, 1.0); }
)";
class ClearThenReadPixelsScenario : public ScenarioTest {};
void DrawFullViewportQuad(unsigned int program) {
static const float kQuad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
GLuint vao = 0, vbo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
glUseProgram(program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
}
} // namespace
TEST_F(ClearThenReadPixelsScenario, ClearWithNoDrawIsVisibleToDefaultFramebufferReadPixels) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(width, 8);
ASSERT_GE(height, 8);
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
// Frame 1: paint the whole default framebuffer, so there IS something stale to return.
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(1.0f, 1.0f, 1.0f, 1.0f);
DrawFullViewportQuad(program);
{
const Image painted = ReadPixels(width, height);
const Rgba8 centre = painted.At(width / 2, height / 2);
ASSERT_NEAR(centre.r, 26, 2) << "the setup frame did not paint; the staleness test would be vacuous";
ASSERT_NEAR(centre.g, 51, 2);
ASSERT_NEAR(centre.b, 77, 2);
}
gl.EndFrame();
// Frame 2: clear to transparent black and read back with NO draw at all.
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
ClearTo(0.0f, 0.0f, 0.0f, 0.0f);
const Image cleared = ReadPixels(width, height);
EXPECT_EQ(FirstGLError(), 0u);
int nonZero = 0;
int firstX = -1;
int firstY = -1;
Rgba8 firstOffender{};
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
const Rgba8 pixel = cleared.At(x, y);
if (pixel.r == 0 && pixel.g == 0 && pixel.b == 0 && pixel.a == 0) continue;
if (nonZero == 0) {
firstX = x;
firstY = y;
firstOffender = pixel;
}
++nonZero;
}
}
EXPECT_EQ(nonZero, 0) << "glClear(0,0,0,0) followed by glReadPixels with no draw returned " << nonZero
<< " of " << (width * height) << " non-zero pixels; first at (" << firstX << ", "
<< firstY << ") = (" << static_cast<int>(firstOffender.r) << ", "
<< static_cast<int>(firstOffender.g) << ", " << static_cast<int>(firstOffender.b)
<< ", " << static_cast<int>(firstOffender.a) << ")";
gl.EndFrame();
glDeleteProgram(program);
}
// The same claim for a sub-rect read, which is the shape the conformance suite uses most and
// the one whose orientation handling is separate (see OrientationScenario).
TEST_F(ClearThenReadPixelsScenario, ClearWithNoDrawIsVisibleToASubRectReadback) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(width, 8);
ASSERT_GE(height, 8);
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
DrawFullViewportQuad(program);
gl.EndFrame();
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
ClearTo(0.0f, 0.0f, 0.0f, 0.0f);
const int rectWidth = width / 2;
const int rectHeight = height / 2;
const Image cleared = ReadPixelsRect(width / 4, height / 4, rectWidth, rectHeight);
EXPECT_EQ(FirstGLError(), 0u);
int nonZero = 0;
for (int y = 0; y < rectHeight; ++y) {
for (int x = 0; x < rectWidth; ++x) {
const Rgba8 pixel = cleared.At(x, y);
if (pixel.r != 0 || pixel.g != 0 || pixel.b != 0 || pixel.a != 0) ++nonZero;
}
}
EXPECT_EQ(nonZero, 0) << nonZero << " of " << (rectWidth * rectHeight)
<< " pixels in a sub-rect read after a draw-free clear were not zero";
gl.EndFrame();
glDeleteProgram(program);
}
// The other half of the same rule, and the one the first version of this fix got wrong: a
// parked clear must be executed BEFORE whatever writes the framebuffer next, not whenever the
// readback happens to notice it. Minecraft clears the default framebuffer, renders the world
// into its own framebuffer and blits the result out; nothing in between opens a render pass on
// the default framebuffer, so the clear stays parked across the whole frame. Materializing it
// at readback time therefore ran it AFTER the blit and returned a blank frame - which is what
// took every DirectVulkan retrace to ssim 0.000005.
TEST_F(ClearThenReadPixelsScenario, ABlitIntoTheDefaultFramebufferSurvivesAnEarlierClear) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
// Paint a source framebuffer, exactly as a game renders its world off-screen.
ColorFbo source = MakeColorFbo(width, height);
ASSERT_NE(source.fbo, 0u);
BindFbo(source);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawFullViewportQuad(program);
// Clear the DEFAULT framebuffer, then blit the source over it. The clear is white so a
// frame that lost the blit is unmistakable, and the blit's colour is fshSimple's.
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
ClearTo(1.0f, 1.0f, 1.0f, 1.0f);
glBindFramebuffer(GL_READ_FRAMEBUFFER, source.fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
EXPECT_EQ(FirstGLError(), 0u);
const Image blitted = ReadPixels(width, height);
EXPECT_EQ(FirstGLError(), 0u);
const Rgba8 centre = blitted.At(width / 2, height / 2);
EXPECT_NEAR(centre.r, 26, 2) << "the blit into the default framebuffer did not survive the clear that "
"preceded it; read back rgba(" << static_cast<int>(centre.r) << ", "
<< static_cast<int>(centre.g) << ", " << static_cast<int>(centre.b) << ", "
<< static_cast<int>(centre.a) << ")";
EXPECT_NEAR(centre.g, 51, 2);
EXPECT_NEAR(centre.b, 77, 2);
DestroyColorFbo(source);
gl.EndFrame();
glDeleteProgram(program);
}
// A MULTISAMPLE-RESOLVE blit into the default framebuffer has to change orientation like any
// other, but vkCmdResolveImage takes one offset per side and cannot invert an axis, so it used
// to land the mirrored band. The renderer now resolves into a single-sample scratch image and
// blits from there. The source is painted in two horizontal bands so the mirror is visible;
// a full-extent uniform blit is a fixed point of the flip and would prove nothing.
TEST_F(ClearThenReadPixelsScenario, AMultisampleResolveBlitIntoTheDefaultFramebufferKeepsItsOrientation) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(height, 8);
GLint maxSamples = 0;
glGetIntegerv(GL_MAX_SAMPLES, &maxSamples);
if (maxSamples < 2) {
GTEST_SKIP() << "GL_MAX_SAMPLES is " << maxSamples << "; this needs a multisample renderbuffer";
}
GLuint fbo = 0, rbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glRenderbufferStorageMultisample(GL_RENDERBUFFER, 2, GL_RGBA8, width, height);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, rbo);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
glDeleteRenderbuffers(1, &rbo);
glDeleteFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
GTEST_SKIP() << "no complete 2x multisample RGBA8 renderbuffer on this driver";
}
glViewport(0, 0, width, height);
// Bottom half red, top half blue - via scissored clears, so no shader is involved.
glEnable(GL_SCISSOR_TEST);
glScissor(0, 0, width, height / 2);
ClearTo(1.0f, 0.0f, 0.0f, 1.0f);
glScissor(0, height / 2, width, height - height / 2);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
glDisable(GL_SCISSOR_TEST);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
EXPECT_EQ(FirstGLError(), 0u);
const Image resolved = ReadPixels(width, height);
EXPECT_EQ(FirstGLError(), 0u);
const Rgba8 bottom = resolved.At(width / 2, height / 4);
const Rgba8 top = resolved.At(width / 2, height - 1 - height / 4);
EXPECT_GT(bottom.r, 200) << "the bottom band should be red after the resolve, got rgba("
<< static_cast<int>(bottom.r) << ", " << static_cast<int>(bottom.g) << ", "
<< static_cast<int>(bottom.b) << ") - blue there means the resolve landed "
<< "in the mirrored band";
EXPECT_LT(bottom.b, 60);
EXPECT_GT(top.b, 200) << "the top band should be blue after the resolve, got rgba("
<< static_cast<int>(top.r) << ", " << static_cast<int>(top.g) << ", "
<< static_cast<int>(top.b) << ")";
EXPECT_LT(top.r, 60);
glDeleteRenderbuffers(1, &rbo);
glDeleteFramebuffers(1, &fbo);
gl.EndFrame();
}
// The same ordering claim for the path that DOES open a render pass. It passes today (the
// render pass folds the clear into its loadOp and pops it), and it is here so a future change
// to the pending-clear lifecycle cannot quietly reverse clear and draw.
TEST_F(ClearThenReadPixelsScenario, ADrawIntoTheDefaultFramebufferSurvivesAnEarlierClear) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(1.0f, 1.0f, 1.0f, 1.0f);
DrawFullViewportQuad(program);
EXPECT_EQ(FirstGLError(), 0u);
const Image painted = ReadPixels(width, height);
const Rgba8 centre = painted.At(width / 2, height / 2);
EXPECT_NEAR(centre.r, 26, 2) << "the draw did not survive the clear that preceded it";
EXPECT_NEAR(centre.g, 51, 2);
EXPECT_NEAR(centre.b, 77, 2);
gl.EndFrame();
glDeleteProgram(program);
}
} // namespace MGITest
@@ -0,0 +1,297 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackScenario.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 - glReadPixels OF DEPTH AND STENCIL FROM THE DEFAULT FRAMEBUFFER.
//
// DirectVulkan's depth/stencil readback used to decline the default framebuffer outright
// (`ReadDepthStencilPixels` returned at its first line) because that framebuffer's depth and
// stencil "attachments" are placeholder texture objects backing no image - the real one is the
// swapchain's depth/stencil twin. Declining meant the call raised no GL error and wrote NOTHING,
// so the caller kept whatever its buffer already held.
//
// That silence is what the framebuffer_blit family trips over. Every one of its cases begins by
// clearing the default framebuffer's depth and stencil and reading them straight back as a
// sanity check, into a local pre-initialised to 0.2 (depth) and 50 (stencil); an untouched
// buffer therefore reports "expected DEPTH[0.25] but got DEPTH[0.2]" and "expected STENCIL[1] but
// got STENCIL[50]" - the exact strings in the 15 Magma failures - long before any blit happens.
// A test that only checked "no GL error" would pass against the broken path, so every case here
// poisons its destination with a value the correct answer cannot be.
//
// The orientation case is the second half. This renderer stores the default framebuffer
// display-side-up and converts GL rects on their way in, so the depth copy needs the same rect
// mapping and row re-ordering the colour readback got in the M-1 fix; without them a
// vertically-varying depth buffer reads back mirrored, which no full-extent uniform-value test
// can see.
//
// Depth/stencil readback through a USER framebuffer already worked and is asserted here too, as
// the built-in control: it shares ReadDepthStencilImageToClient with the default-framebuffer
// path, so it is what says a failure is about the default framebuffer specifically.
#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 {
// Values no correct read can produce, so "the backend wrote nothing" fails loudly instead
// of passing on whatever happened to be in the variable. These are the CTS's own poison
// values, which is why its logs report exactly them.
constexpr float kDepthPoison = 0.2f;
constexpr int kStencilPoison = 50;
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"; }
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;
}
};
// A depth buffer whose value depends on the row: bottom half `bottom`, top half `top`.
// Built with a scissored clear rather than a draw so the test stays independent of
// depth-test and shader behaviour.
void ClearDepthInBands(int width, int height, float bottom, float top) {
glEnable(GL_SCISSOR_TEST);
glScissor(0, 0, width, height / 2);
glClearDepth(bottom);
glClear(GL_DEPTH_BUFFER_BIT);
glScissor(0, height / 2, width, height - height / 2);
glClearDepth(top);
glClear(GL_DEPTH_BUFFER_BIT);
glDisable(GL_SCISSOR_TEST);
}
} // namespace
TEST_F(DepthStencilReadbackScenario, DefaultFramebufferDepthClearIsVisibleToReadPixels) {
if (!Ready()) return;
if (!BackendReadsDepthStencil()) {
GTEST_SKIP() << "backend " << Gl().BackendName()
<< " has no depth readback path (ES lacks GL_NV_read_depth); see the packed_depth_stencil "
"cluster";
}
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDepthMask(GL_TRUE);
glClearDepth(0.25);
glClear(GL_DEPTH_BUFFER_BIT);
const float centre = ReadDepthAt(width / 2, height / 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_NEAR(centre, 0.25f, 1.0f / 4096.0f)
<< "glReadPixels(GL_DEPTH_COMPONENT) of the default framebuffer returned " << centre
<< (std::fabs(centre - kDepthPoison) < 1e-6f ? " - the destination was never written at all" : "");
gl.EndFrame();
}
TEST_F(DepthStencilReadbackScenario, DefaultFramebufferStencilClearIsVisibleToReadPixels) {
if (!Ready()) return;
if (!BackendReadsDepthStencil()) {
GTEST_SKIP() << "backend " << Gl().BackendName()
<< " has no stencil readback path (ES lacks GL_NV_read_stencil); see the "
"packed_depth_stencil cluster";
}
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glStencilMask(0xFFu);
glClearStencil(3);
glClear(GL_STENCIL_BUFFER_BIT);
const int centre = ReadStencilAt(width / 2, height / 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(centre, 3) << "glReadPixels(GL_STENCIL_INDEX) of the default framebuffer returned " << centre
<< (centre == kStencilPoison ? " - the destination was never written at all" : "");
gl.EndFrame();
}
// The orientation half: a depth buffer that varies with the row must read back in GL's
// bottom-up order. A full-extent uniform clear is a fixed point of the flip, so only a banded
// buffer can tell the two apart.
TEST_F(DepthStencilReadbackScenario, DefaultFramebufferDepthReadbackKeepsTheGLRowOrder) {
if (!Ready()) return;
if (!BackendReadsDepthStencil()) {
GTEST_SKIP() << "backend " << Gl().BackendName() << " has no depth readback path";
}
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(height, 8);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDepthMask(GL_TRUE);
ClearDepthInBands(width, height, /*bottom=*/0.25f, /*top=*/0.75f);
EXPECT_EQ(FirstGLError(), 0u);
const float bottom = ReadDepthAt(width / 2, height / 4);
const float top = ReadDepthAt(width / 2, height - 1 - height / 4);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_NEAR(bottom, 0.25f, 1.0f / 4096.0f)
<< "GL row " << (height / 4) << " is in the bottom band and was cleared to 0.25, but read back " << bottom
<< " (0.75 there means the readback is upside down)";
EXPECT_NEAR(top, 0.75f, 1.0f / 4096.0f)
<< "GL row " << (height - 1 - height / 4) << " is in the top band and was cleared to 0.75, but read back "
<< top << " (0.25 there means the readback is upside down)";
gl.EndFrame();
}
// A depth blit INTO the default framebuffer has to convert its rect out of GL's bottom-origin
// space, exactly as the colour blit does. The colour path had that conversion and the
// depth path did not, so a scissored depth blit landed in the mirrored band - which is the
// whole of KHR-GL*.framebuffer_blit.scissor_blit once the readback above works well enough to
// see it (before that the test died on the poison values and never reached the blit).
TEST_F(DepthStencilReadbackScenario, AScissoredDepthBlitIntoTheDefaultFramebufferLandsInTheScissorBox) {
if (!Ready()) return;
if (!BackendReadsDepthStencil()) {
GTEST_SKIP() << "backend " << Gl().BackendName() << " has no depth readback path";
}
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(width, 8);
ASSERT_GE(height, 8);
// Source: a user framebuffer whose depth is uniformly 0.75.
GLuint fbo = 0, colorTex = 0, depthTex = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glGenTextures(1, &colorTex);
glBindTexture(GL_TEXTURE_2D, colorTex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTex, 0);
glGenTextures(1, &depthTex);
glBindTexture(GL_TEXTURE_2D, depthTex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, width, height, 0, GL_DEPTH_STENCIL,
GL_UNSIGNED_INT_24_8, nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, depthTex, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE));
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDepthMask(GL_TRUE);
glClearDepth(0.75);
glClear(GL_DEPTH_BUFFER_BIT);
// Destination: the default framebuffer, depth 0 everywhere.
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glClearDepth(0.0);
glClear(GL_DEPTH_BUFFER_BIT);
// Blit the whole rect, but scissored to the BOTTOM-LEFT quadrant in GL coordinates.
glEnable(GL_SCISSOR_TEST);
glScissor(0, 0, width / 2, height / 2);
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_DEPTH_BUFFER_BIT, GL_NEAREST);
glDisable(GL_SCISSOR_TEST);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
EXPECT_EQ(FirstGLError(), 0u);
const float inside = ReadDepthAt(width / 4, height / 4);
const float above = ReadDepthAt(width / 4, height - 1 - height / 4);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_NEAR(inside, 0.75f, 1.0f / 4096.0f)
<< "GL (" << (width / 4) << ", " << (height / 4) << ") is inside the scissor box and should hold the "
<< "blitted 0.75, but read back " << inside;
EXPECT_NEAR(above, 0.0f, 1.0f / 4096.0f)
<< "GL (" << (width / 4) << ", " << (height - 1 - height / 4)
<< ") is ABOVE the scissor box and must still hold the cleared 0.0, but read back " << above
<< " (0.75 there means the depth blit landed in the mirrored band)";
glDeleteTextures(1, &depthTex);
glDeleteTextures(1, &colorTex);
glDeleteFramebuffers(1, &fbo);
gl.EndFrame();
}
// The control: the same read against a user framebuffer, which never went through the
// declined path. It is what makes a failure above specific to the default framebuffer.
TEST_F(DepthStencilReadbackScenario, UserFramebufferDepthClearIsVisibleToReadPixels) {
if (!Ready()) return;
if (!BackendReadsDepthStencil()) {
GTEST_SKIP() << "backend " << Gl().BackendName() << " has no depth readback path";
}
HeadlessGL& gl = Gl();
const int width = 64;
const int height = 48;
GLuint fbo = 0, colorTex = 0, depthTex = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glGenTextures(1, &colorTex);
glBindTexture(GL_TEXTURE_2D, colorTex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTex, 0);
glGenTextures(1, &depthTex);
glBindTexture(GL_TEXTURE_2D, depthTex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, width, height, 0, GL_DEPTH_STENCIL,
GL_UNSIGNED_INT_24_8, nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, depthTex, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE));
ASSERT_EQ(FirstGLError(), 0u);
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDepthMask(GL_TRUE);
glStencilMask(0xFFu);
glClearDepth(0.5);
glClearStencil(7);
glClear(GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
const float depth = ReadDepthAt(width / 2, height / 2);
const int stencil = ReadStencilAt(width / 2, height / 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_NEAR(depth, 0.5f, 1.0f / 4096.0f) << "user-framebuffer depth readback returned " << depth;
EXPECT_EQ(stencil, 7) << "user-framebuffer stencil readback returned " << stencil;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteTextures(1, &depthTex);
glDeleteTextures(1, &colorTex);
glDeleteFramebuffers(1, &fbo);
gl.EndFrame();
}
} // namespace MGITest
@@ -0,0 +1,139 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/FragCoordOriginScenario.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_FragCoord ON THE DEFAULT FRAMEBUFFER CARRIES GL'S WINDOW ORIGIN.
//
// GL measures gl_FragCoord.y from the BOTTOM of the window. Vulkan's gl_FragCoord.y is the
// framebuffer ROW being written, and DirectVulkan stores the default framebuffer display-side-up
// (compensating for vertices by negating gl_Position.y), so a fragment's reported Y there was
// `height - y_GL` - flipped, and for a viewport that does not span the full height, outside the
// range GL promises entirely. GL CTS
// `KHR-GL42.shader_image_load_store.basic-{allTargets-atomic,glsl-earlyFragTests,glsl-misc}`
// caught it: each sets a small viewport at GL y=0 and does
// `imageStore(image, ivec2(gl_FragCoord.xy), ...)` into an image exactly that size, so on a
// 256-tall surface every store addressed rows 224..255 of a 32-row image and was dropped.
//
// The shader here paints each row with its own GL window Y, which is the whole claim in one
// value: row j of the readback must be j, for a full-height viewport and for a half-height one
// (the case where a flip and an offset can no longer hide each other). DirectGLES is the
// built-in control - a native GL driver gets this right by construction, so a failure there
// would mean the test, not the backend.
#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 const char* kVS = R"(#version 330 core
in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
)";
// floor(gl_FragCoord.y) is the fragment's window row; 1/255 steps survive an RGBA8
// round trip exactly, so the readback byte IS the row the shader believes it is on.
constexpr const char* kFS = R"(#version 330 core
out vec4 o_color;
void main() { o_color = vec4(floor(gl_FragCoord.y) / 255.0, 0.0, 0.0, 1.0); }
)";
class FragCoordOriginScenario : public ScenarioTest {};
// A quad covering the whole viewport, drawn with attribute 0 = aPos.
void DrawFullViewportQuad(unsigned int program) {
static const float kQuad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
GLuint vao = 0, vbo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
glUseProgram(program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
}
// Paints `viewportHeight` rows starting at GL y=0 and returns the red byte of each row.
std::vector<int> RowsPaintedWithTheirOwnWindowY(unsigned int program, int width, int viewportHeight) {
BindDefaultFramebuffer();
glViewport(0, 0, width, viewportHeight);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
DrawFullViewportQuad(program);
const Image image = ReadPixelsRect(0, 0, width, viewportHeight);
std::vector<int> rows;
rows.reserve(static_cast<std::size_t>(viewportHeight));
for (int y = 0; y < viewportHeight; ++y) {
rows.push_back(image.At(width / 2, y).r);
}
return rows;
}
::testing::AssertionResult RowsAreTheirOwnIndex(const std::vector<int>& rows, const char* when) {
for (std::size_t y = 0; y < rows.size(); ++y) {
if (rows[y] != static_cast<int>(y)) {
return ::testing::AssertionFailure()
<< when << ": GL window row " << y << " reported gl_FragCoord.y = " << rows[y]
<< " (expected " << y << "). Rows 0.." << (rows.size() - 1) << " read back as ["
<< rows.front() << " .. " << rows.back() << "].";
}
}
return ::testing::AssertionSuccess();
}
} // namespace
TEST_F(FragCoordOriginScenario, DefaultFramebufferFragCoordCountsFromTheBottom) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
// 1/255 steps only stay distinguishable while the row index fits in a byte.
const int width = gl.Width();
const int fullHeight = std::min(gl.Height(), 256);
ASSERT_GE(fullHeight, 8) << "the harness surface is too small to tell rows apart";
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
// Full height first: this one passed even before the fix (a flip alone maps the row set
// onto itself), so it is the control that the shader and the readback agree at all.
EXPECT_TRUE(RowsAreTheirOwnIndex(RowsPaintedWithTheirOwnWindowY(program, width, fullHeight),
"full-height viewport"));
// Half height at GL y=0: the case the CTS failures were made of. A backend that reports
// the stored row here answers `height - y` for every row - off the bottom of the range,
// not merely reversed within it.
const int halfHeight = fullHeight / 2;
EXPECT_TRUE(RowsAreTheirOwnIndex(RowsPaintedWithTheirOwnWindowY(program, width, halfHeight),
"half-height viewport at GL y=0"));
glUseProgram(0);
glDeleteProgram(program);
glViewport(0, 0, gl.Width(), gl.Height());
EXPECT_EQ(FirstGLError(), 0u);
}
} // 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,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
@@ -55,6 +55,7 @@
#include <algorithm> #include <algorithm>
#include <cstdint> #include <cstdint>
#include <cstring>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -101,6 +102,39 @@ void main() {
// "every single pixel" an achievable (and therefore useful) demand. // "every single pixel" an achievable (and therefore useful) demand.
constexpr int kQuadrantInset = 2; constexpr int kQuadrantInset = 2;
// A deliberately asymmetric sub-rect of the 128x96 surface: neither centred nor
// full-extent in either axis, mirroring the conformance suite's randomised
// sub-viewport geometry (glcShaderRenderCase.cpp:735-741). Asymmetry is the whole
// point - y == H - y - h is exactly the case an unconverted Y origin gets right by
// accident, and it is the only case the shipped code ever exercised.
// correct band = GL rows [13, 55)
// mirrored band = GL rows [41, 83) (what H-y-h produces)
constexpr int kSubX = 17;
constexpr int kSubY = 13;
constexpr int kSubW = 60;
constexpr int kSubH = 42;
Image CropRect(const Image& source, int x0, int y0, int width, int height) {
Image out(width, height);
const std::size_t rowBytes = static_cast<std::size_t>(width) * 4;
for (int y = 0; y < height; ++y) {
const std::uint8_t* sourceRow =
source.Data() + (static_cast<std::size_t>(y0 + y) * source.Width() + x0) * 4;
std::memcpy(out.Data() + static_cast<std::size_t>(y) * rowBytes, sourceRow, rowBytes);
}
return out;
}
Image VFlip(const Image& source) {
Image out(source.Width(), source.Height());
const std::size_t rowBytes = static_cast<std::size_t>(source.Width()) * 4;
for (int y = 0; y < source.Height(); ++y) {
std::memcpy(out.Data() + static_cast<std::size_t>(y) * rowBytes,
source.Data() + static_cast<std::size_t>(source.Height() - 1 - y) * rowBytes, rowBytes);
}
return out;
}
struct Vertex { struct Vertex {
float x, y; float x, y;
float r, g, b; float r, g, b;
@@ -377,5 +411,174 @@ void main() {
} }
} }
// ------------------------------------------------------------------ sub-rect / M-1 ----
//
// Everything above reads the FULL extent of its target, which is the one case
// DirectVulkan's default-framebuffer readback ever re-oriented: the remap at
// VulkanRenderer.cpp:2042 had no rect parameters at all, so :8278 gated it on
// `width == swapchainExtent.width && height == swapchainExtent.height` and fell back to a
// raw copy otherwise. Meanwhile the viewport (:422), the scissor (:506-546) and the
// ReadPixels copy offset (:8238) all used the GL bottom-origin Y verbatim as a Vulkan
// top-origin Y.
//
// In the conformance suite those defects CANCEL in placement - the draw lands in Vulkan
// rows [y, y+h) and the readback copies the same rows back - and compose into an exact
// vertical flip of a correct image. That is 1,759 of Magma's 1,793 non-pass cases, and
// image forensics over all 861 gl33 failures found 861 vertical flips and nothing else.
// Taken apart, they are two independent user-visible bugs, so they are tested apart:
// SubViewportDraw pins placement with a full-extent read, SubRectReadback pins the
// readback rect after a full-viewport draw, and SubViewportSubRectRoundTrip is the CTS
// shape where the two cancel.
// Placement: a sub-viewport draw must land in GL rows [y0, y0+h), not mirrored about the
// surface centre. Read back full-extent, which is the path that already worked, so a
// failure here can only be the viewport's Y origin.
TEST_F(OrientationScenario, SubViewportDrawLandsWhereGLPutsIt) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glViewport(kSubX, kSubY, kSubW, kSubH);
DrawQuadrants();
glViewport(0, 0, Gl().Width(), Gl().Height());
const Image whole = ReadPixels(Gl().Width(), Gl().Height());
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
const Image placed = CropRect(whole, kSubX, kSubY, kSubW, kSubH);
EXPECT_EQ(placed.QuadrantSignature(), kUprightSignature)
<< "the sub-viewport draw is not upright inside its own rect";
ExpectUprightQuadrants(placed, "sub-viewport draw, cropped out of a full-extent read");
// Nothing may have been painted outside the viewport. This is what catches the
// mirrored placement: the drawn band would sit at GL rows [41, 83) instead.
EXPECT_TRUE(RegionIsMostly(whole, 0, Gl().Width() - 1, 0, kSubY - 2, "black", 0.0,
"below the sub-viewport"));
EXPECT_TRUE(RegionIsMostly(whole, 0, Gl().Width() - 1, kSubY + kSubH + 1, Gl().Height() - 1, "black",
0.0, "above the sub-viewport"));
}
// Readback: a full-viewport draw read back through a sub-rect must return the requested
// band, in GL row order. Band and orientation are asserted separately so that fixing only
// one of the two cannot pass this case.
TEST_F(OrientationScenario, SubRectReadbackReturnsTheRequestedBandUpright) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawQuadrants();
const Image whole = ReadPixels(Gl().Width(), Gl().Height());
ASSERT_EQ(whole.QuadrantSignature(), kUprightSignature)
<< "the full-extent read is already wrong, so nothing below can be trusted";
const Image sub = ReadPixelsRect(kSubX, kSubY, kSubW, kSubH);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ASSERT_EQ(sub.Width(), kSubW);
ASSERT_EQ(sub.Height(), kSubH);
const Image requestedBand = CropRect(whole, kSubX, kSubY, kSubW, kSubH);
const Image mirroredBand = CropRect(whole, kSubX, Gl().Height() - kSubY - kSubH, kSubW, kSubH);
// The geometry has to be able to see both mistakes; if a future surface size made the
// band symmetric these assertions would be vacuous, so say so loudly instead.
ASSERT_FALSE(requestedBand == VFlip(requestedBand))
<< "the chosen sub-rect is vertically symmetric - it cannot detect a row flip";
ASSERT_FALSE(requestedBand == mirroredBand)
<< "the chosen sub-rect equals its mirror band - it cannot detect a wrong band";
EXPECT_FALSE(sub == VFlip(requestedBand))
<< "ORIENTATION: the requested band came back with its rows in Vulkan (top-first) order";
EXPECT_FALSE(sub == mirroredBand || sub == VFlip(mirroredBand))
<< "BAND: the read returned GL rows [H-y-h, H-y) instead of [y, y+h)";
EXPECT_TRUE(sub == requestedBand)
<< "the sub-rect readback differs from the same rect of the full-extent read in "
<< sub.ByteDiffCount(requestedBand) << " bytes";
}
// The exact conformance-suite shape: an asymmetric sub-viewport draw read back through the
// very same sub-rect. The placement and readback errors cancel, leaving an image that is
// correct in every pixel VALUE and vertically flipped - which is precisely the 861-case
// signature. One assertion, and it pins all of them.
TEST_F(OrientationScenario, SubViewportSubRectRoundTripIsUpright) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glViewport(kSubX, kSubY, kSubW, kSubH);
DrawQuadrants();
const Image sub = ReadPixelsRect(kSubX, kSubY, kSubW, kSubH);
glViewport(0, 0, Gl().Width(), Gl().Height());
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_EQ(sub.QuadrantSignature(), kUprightSignature)
<< "sub-viewport draw + same-rect readback came back flipped - this is the shape "
"behind KHR-GL33/GL40.shaders.* (861 cases each)";
ExpectUprightQuadrants(sub, "sub-viewport draw read back through the same sub-rect");
}
// The same conversion, on the other rect consumer that reads the default framebuffer.
// glBlitFramebuffer already converted its DESTINATION rect when the draw framebuffer was
// the default one (ApplyNativeBlitDefaultFramebufferTransform), but never its SOURCE rect,
// so a blit OUT of the default framebuffer took the mirrored band and wrote it upside
// down. Blitting a sub-rect and comparing against the same sub-rect of a direct read pins
// both halves at once.
TEST_F(OrientationScenario, BlitOutOfTheDefaultFramebufferKeepsBandAndOrientation) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawQuadrants();
const Image whole = ReadPixels(Gl().Width(), Gl().Height());
ASSERT_EQ(whole.QuadrantSignature(), kUprightSignature)
<< "the full-extent read is already wrong, so nothing below can be trusted";
BindFbo(m_offscreen);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_offscreen.fbo);
glBlitFramebuffer(kSubX, kSubY, kSubX + kSubW, kSubY + kSubH, kSubX, kSubY, kSubX + kSubW,
kSubY + kSubH, GL_COLOR_BUFFER_BIT, GL_NEAREST);
const unsigned int blitError = FirstGLError();
if (blitError != GL_NO_ERROR) {
GTEST_SKIP() << "this backend refused the default-framebuffer blit: "
<< GLErrorName(blitError);
}
glBindFramebuffer(GL_FRAMEBUFFER, m_offscreen.fbo);
const Image blitted = ReadPixels(m_offscreen.width, m_offscreen.height);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
const Image landed = CropRect(blitted, kSubX, kSubY, kSubW, kSubH);
const Image expected = CropRect(whole, kSubX, kSubY, kSubW, kSubH);
EXPECT_FALSE(landed == VFlip(expected))
<< "ORIENTATION: the blitted band arrived upside down";
EXPECT_TRUE(landed == expected)
<< "the blitted sub-rect differs from the same sub-rect of a direct read in "
<< landed.ByteDiffCount(expected) << " bytes";
}
// Negative control. A non-default framebuffer is already self-consistent - no
// gl_Position.y negation, GL row 0 IS Vulkan row 0 - so none of the fixes above may touch
// it. If this ever starts failing, the default-FBO remap has leaked into the FBO path.
TEST_F(OrientationScenario, FboSubRectReadbackAndSubViewportAreUnaffected) {
BindFbo(m_offscreen);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawQuadrants();
const Image whole = ReadPixels(m_offscreen.width, m_offscreen.height);
const Image sub = ReadPixelsRect(kSubX, kSubY, kSubW, kSubH);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_TRUE(sub == CropRect(whole, kSubX, kSubY, kSubW, kSubH))
<< "an FBO sub-rect readback differs from the same rect of its full-extent read in "
<< sub.ByteDiffCount(CropRect(whole, kSubX, kSubY, kSubW, kSubH)) << " bytes";
BindFbo(m_offscreen);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glViewport(kSubX, kSubY, kSubW, kSubH);
DrawQuadrants();
glViewport(0, 0, m_offscreen.width, m_offscreen.height);
const Image placedWhole = ReadPixels(m_offscreen.width, m_offscreen.height);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_EQ(CropRect(placedWhole, kSubX, kSubY, kSubW, kSubH).QuadrantSignature(), kUprightSignature)
<< "an FBO sub-viewport draw must land in GL rows [y0, y0+h) upright";
EXPECT_TRUE(RegionIsMostly(placedWhole, 0, m_offscreen.width - 1, 0, kSubY - 2, "black", 0.0,
"below an FBO sub-viewport"));
EXPECT_TRUE(RegionIsMostly(placedWhole, 0, m_offscreen.width - 1, kSubY + kSubH + 1,
m_offscreen.height - 1, "black", 0.0, "above an FBO sub-viewport"));
}
} // namespace } // namespace
} // namespace MGITest } // namespace MGITest
@@ -0,0 +1,197 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PipelineFailureScenario.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 draw had no pipeline, so we bound null."
//
// DirectVulkan's SetupDraw called GetOrCreatePipeline - a function that DOCUMENTS a
// VK_NULL_HANDLE return - and passed the result straight to vkCmdBindPipeline. When the
// Adreno driver answered vkCreateGraphicsPipelines with VK_ERROR_UNKNOWN, the next
// instruction dereferenced null inside the driver: SIGSEGV at fault addr 0x8, and that one
// shape accounted for 9 of the 15 process deaths in the 2026-08-10 GL-CTS run
// (KHR-GL33/GL40.shaders.struct.uniform.sampler_array_vertex, six
// KHR-GL42.shader_image_load_store cases, one shader_storage_buffer_object case).
//
// It was made permanent by a second defect: PipelineFactory memoized the failure, so the
// null was served for the rest of the process. Every later draw with the same state died
// too, which is why a single bad program took whole CTS groups down with it.
//
// What this scenario pins, on both backends:
// 1. The GL program shape the CTS crashed on (an array of structs each containing a
// sampler, sampled from the VERTEX stage) draws without killing the process.
// 2. It draws AGAIN and produces the identical image. A second draw is the only thing
// that can tell a working pipeline apart from a poisoned cache entry: if the first
// creation had failed and been memoized, the second draw is where the null would be
// served back.
//
// A deterministic driver-side pipeline-creation FAILURE is not reachable from the GL API on
// the llvmpipe/lavapipe lanes - both accept every pipeline these scenarios can describe - so
// the guard itself is proven structurally (PipelineFactory returns before it can emplace a
// VK_NULL_HANDLE, SetupDraw returns false before it can bind one) and this scenario holds
// the surrounding path honest.
#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 {
// Lifted from KHR-GL33.shaders.struct.uniform.sampler_array_vertex (the QPA records the
// source verbatim): an array of structs, each carrying an opaque sampler, sampled in the
// vertex stage. The fragment sibling of this case only FAILS on Magma; only the vertex one
// takes the process down, so the stage matters and is kept.
constexpr const char* kSamplerArrayVertexSource = R"(#version 330 core
struct S {
float a;
vec3 b;
sampler2D c;
};
uniform S s[2];
in vec2 aPos;
out vec4 vColor;
void main() {
vec2 coords = aPos * 0.5 + 0.5;
vColor = vec4(texture(s[1].c, coords * s[0].b.xy + s[1].b.z).rgb, s[0].a);
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kPassthroughFragmentSource = R"(#version 330 core
in vec4 vColor;
out vec4 oColor;
void main() {
oColor = vColor;
}
)";
struct Vertex {
float x, y;
};
std::vector<Vertex> FullscreenTriangleStrip() {
return {{-1.0f, -1.0f}, {1.0f, -1.0f}, {-1.0f, 1.0f}, {1.0f, 1.0f}};
}
class PipelineFailureScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kSamplerArrayVertexSource, kPassthroughFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
const std::vector<Vertex> vertices = FullscreenTriangleStrip();
m_vertexCount = static_cast<int>(vertices.size());
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data(),
GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(0));
glBindVertexArray(0);
// A solid red 2x2 texture, so the sampled colour is the same wherever the
// (deliberately degenerate) coordinates land.
const unsigned char red[] = {255, 0, 0, 255, 255, 0, 0, 255,
255, 0, 0, 255, 255, 0, 0, 255};
glGenTextures(1, &m_texture);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, red);
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_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glUseProgram(m_program);
const int samplerLocation = glGetUniformLocation(m_program, "s[1].c");
if (samplerLocation >= 0) glUniform1i(samplerLocation, 0);
const int alphaLocation = glGetUniformLocation(m_program, "s[0].a");
if (alphaLocation >= 0) glUniform1f(alphaLocation, 1.0f);
glUseProgram(0);
m_target = MakeColorFbo(Gl().Width(), Gl().Height());
ASSERT_NE(m_target.fbo, 0u) << "offscreen FBO is not framebuffer-complete";
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "setup left a GL error behind";
}
void TearDown() override {
if (!Ready()) return;
DestroyColorFbo(m_target);
if (m_texture != 0) glDeleteTextures(1, &m_texture);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_program != 0) glDeleteProgram(m_program);
}
Image DrawOnce() {
BindFbo(m_target);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(m_program);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_texture);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, m_vertexCount);
glBindVertexArray(0);
return ReadPixels(m_target.width, m_target.height);
}
unsigned int m_program = 0;
unsigned int m_vao = 0;
unsigned int m_vbo = 0;
unsigned int m_texture = 0;
int m_vertexCount = 0;
ColorFbo m_target;
};
// Reaching the assertion at all is most of the point: the shipped code SIGSEGV'd inside
// the driver on this draw.
TEST_F(PipelineFailureScenario, SamplerArrayInAStructDrawsWithoutKillingTheProcess) {
const Image drawn = DrawOnce();
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_TRUE(RegionIsMostly(drawn, 2, drawn.Width() - 3, 2, drawn.Height() - 3, "red", 0.0,
"sampler-array-in-struct draw"));
}
// The second draw is what a poisoned cache entry cannot survive: a memoized
// VK_NULL_HANDLE is served on every subsequent lookup, so a run that dies (or silently
// stops drawing) on the second draw and not the first is exactly the "failed pipeline was
// cached" defect.
TEST_F(PipelineFailureScenario, TheSameDrawRepeatsIdenticallyWithNoPoisonedPipelineCache) {
const Image first = DrawOnce();
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the first draw already errored";
Gl().EndFrame();
const Image second = DrawOnce();
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the second draw errored";
EXPECT_TRUE(RegionIsMostly(second, 2, second.Width() - 3, 2, second.Height() - 3, "red", 0.0,
"second draw"));
EXPECT_TRUE(second == first) << "the second draw differs from the first in "
<< second.ByteDiffCount(first) << " bytes - the pipeline the second "
"draw resolved is not the one the first draw used";
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,249 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PixelStoreSweepScenario.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 - PIXEL-STORE MODES RESTORE, and FRAMEBUFFER CHURN STAYS EXACT.
//
// Both cases here replay the shape of KHR-GL3x.packed_pixels.varied_rectangle, the single
// heaviest polluter in the GL CTS: for each of 46 (pixel-store mode, value) pairs it uploads a
// gradient into a fresh texture, attaches that texture to a FRESH framebuffer, reads it back and
// deletes both - ~3300 texture+framebuffer pairs per test case.
//
// What that found: DirectGLES had no destructor for BackendFramebufferObject (nor for the
// renderbuffer and sampler twins), so every frontend glDeleteFramebuffers leaked one driver
// framebuffer for the process lifetime. On an Adreno 830 the CTS run walked the driver to 1.2 GB
// of dead objects, and from that point on EVERY readback through a freshly attached framebuffer
// came back with someone else's pixels - which is what made ~1,500 otherwise-correct cases fail
// depending only on how much ran before them. The unit-level pin for the missing destructors is
// MG_Test/SanityTest.cpp (DirectGLESBackendFramebuffer/Renderbuffer/Sampler); this file pins the
// end-to-end behaviour they protect.
//
// The mode sweep is the second half of the same story: 46 modes are set and reset per case, so a
// mode that fails to restore is indistinguishable from the leak in a full-batch CTS run. The
// assertion here is RESTORATION - after every single mode is set and put back, a readback at
// default state must be byte-identical to one taken before the sweep ever started.
//
// Backend-agnostic on purpose: both bugs this guards against are frontend/backend bookkeeping,
// and DirectVulkan is the built-in control.
#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 {
// Small enough that the table's row lengths (10, 15) and image heights are all >= the
// image, which is the shape the CTS uses (its gradient is 7x3).
constexpr int kTexSize = 8;
// Every buffer handed to GL is this big regardless of the image size: with row length 15,
// two skipped rows/pixels and alignment 8 the driver strides well past the natural image
// extent, and a tight buffer would be an out-of-bounds access rather than a test. (It was:
// the first version of this scenario passed its assertions and then segfaulted at
// teardown, because glReadPixels had written past a 1 KiB destination.)
constexpr std::size_t kScratchBytes = 64 * 1024;
// Every pixel-store mode GL 4.0 has, so a reset provably covers the whole state and not
// just the subset a particular test happened to touch.
struct PixelStoreMode {
GLenum name;
GLint defaultValue;
};
const PixelStoreMode kAllModes[] = {
{GL_UNPACK_SWAP_BYTES, 0}, {GL_UNPACK_LSB_FIRST, 0}, {GL_UNPACK_ROW_LENGTH, 0},
{GL_UNPACK_IMAGE_HEIGHT, 0}, {GL_UNPACK_SKIP_ROWS, 0}, {GL_UNPACK_SKIP_PIXELS, 0},
{GL_UNPACK_SKIP_IMAGES, 0}, {GL_UNPACK_ALIGNMENT, 4}, {GL_PACK_SWAP_BYTES, 0},
{GL_PACK_LSB_FIRST, 0}, {GL_PACK_ROW_LENGTH, 0}, {GL_PACK_IMAGE_HEIGHT, 0},
{GL_PACK_SKIP_ROWS, 0}, {GL_PACK_SKIP_PIXELS, 0}, {GL_PACK_SKIP_IMAGES, 0},
{GL_PACK_ALIGNMENT, 4},
};
// The CTS table verbatim (glcPackedPixelsTests.cpp VariedRectangleTest::iterate): 32
// common cases plus the 14 core-only ones ES has no equivalent for and MobileGL therefore
// honours on the CPU. IMAGE_WIDTH_1/2 and IMAGE_HEIGHT_1/2 are the CTS's 10 and 15.
struct SweepCase {
GLenum mode;
GLint value;
};
const SweepCase kSweep[] = {
{GL_UNPACK_ROW_LENGTH, 0}, {GL_UNPACK_ROW_LENGTH, 10}, {GL_UNPACK_ROW_LENGTH, 15},
{GL_UNPACK_SKIP_ROWS, 0}, {GL_UNPACK_SKIP_ROWS, 1}, {GL_UNPACK_SKIP_ROWS, 2},
{GL_UNPACK_SKIP_PIXELS, 0}, {GL_UNPACK_SKIP_PIXELS, 1}, {GL_UNPACK_SKIP_PIXELS, 2},
{GL_UNPACK_ALIGNMENT, 1}, {GL_UNPACK_ALIGNMENT, 2}, {GL_UNPACK_ALIGNMENT, 4},
{GL_UNPACK_ALIGNMENT, 8}, {GL_UNPACK_IMAGE_HEIGHT, 0}, {GL_UNPACK_IMAGE_HEIGHT, 10},
{GL_UNPACK_IMAGE_HEIGHT, 15}, {GL_UNPACK_SKIP_IMAGES, 0}, {GL_UNPACK_SKIP_IMAGES, 1},
{GL_UNPACK_SKIP_IMAGES, 2}, {GL_PACK_ROW_LENGTH, 0}, {GL_PACK_ROW_LENGTH, 10},
{GL_PACK_ROW_LENGTH, 15}, {GL_PACK_SKIP_ROWS, 0}, {GL_PACK_SKIP_ROWS, 1},
{GL_PACK_SKIP_ROWS, 2}, {GL_PACK_SKIP_PIXELS, 0}, {GL_PACK_SKIP_PIXELS, 1},
{GL_PACK_SKIP_PIXELS, 2}, {GL_PACK_ALIGNMENT, 1}, {GL_PACK_ALIGNMENT, 2},
{GL_PACK_ALIGNMENT, 4}, {GL_PACK_ALIGNMENT, 8},
// core-only, no ES equivalent
{GL_UNPACK_SWAP_BYTES, GL_FALSE}, {GL_UNPACK_SWAP_BYTES, GL_TRUE},
{GL_UNPACK_LSB_FIRST, GL_FALSE}, {GL_UNPACK_LSB_FIRST, GL_TRUE},
{GL_PACK_SWAP_BYTES, GL_FALSE}, {GL_PACK_SWAP_BYTES, GL_TRUE},
{GL_PACK_LSB_FIRST, GL_FALSE}, {GL_PACK_LSB_FIRST, GL_TRUE},
{GL_PACK_IMAGE_HEIGHT, 0}, {GL_PACK_IMAGE_HEIGHT, 10},
{GL_PACK_IMAGE_HEIGHT, 15}, {GL_PACK_SKIP_IMAGES, 0},
{GL_PACK_SKIP_IMAGES, 1}, {GL_PACK_SKIP_IMAGES, 2},
};
std::size_t ImageBytes(int size) { return static_cast<std::size_t>(size) * size * 4; }
// Padded to kScratchBytes so it is safe to hand to an upload running under any of the
// sweep's stride/skip settings.
std::vector<std::uint8_t> MakeGradient(int size, unsigned seed) {
std::vector<std::uint8_t> pixels(kScratchBytes, 0);
for (int y = 0; y < size; ++y) {
for (int x = 0; x < size; ++x) {
const std::size_t base = (static_cast<std::size_t>(y) * size + x) * 4;
pixels[base + 0] = static_cast<std::uint8_t>((x * 11 + seed) & 0xFF);
pixels[base + 1] = static_cast<std::uint8_t>((y * 13 + seed) & 0xFF);
pixels[base + 2] = static_cast<std::uint8_t>((x * y + seed) & 0xFF);
pixels[base + 3] = 0xFF;
}
}
return pixels;
}
void ResetAllPixelStoreModes() {
for (const PixelStoreMode& mode : kAllModes) {
glPixelStorei(mode.name, mode.defaultValue);
}
}
// The one operation the CTS repeats: a fresh texture, a fresh framebuffer, one readback,
// both deleted. Returns the readback; `outStatus` carries the completeness answer so a
// caller can tell an incomplete framebuffer apart from wrong pixels.
std::vector<std::uint8_t> UploadAndReadBack(const std::vector<std::uint8_t>& source, int size,
GLenum* outStatus) {
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, size, size, 0, GL_RGBA, GL_UNSIGNED_BYTE, source.data());
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
*outStatus = glCheckFramebufferStatus(GL_FRAMEBUFFER);
std::vector<std::uint8_t> read(kScratchBytes, 0);
if (*outStatus == GL_FRAMEBUFFER_COMPLETE) {
glReadPixels(0, 0, size, size, GL_RGBA, GL_UNSIGNED_BYTE, read.data());
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &fbo);
glBindTexture(GL_TEXTURE_2D, 0);
glDeleteTextures(1, &texture);
return read;
}
// Index of the first differing byte within the image, or `bytes` when they agree.
std::size_t FirstDifference(const std::vector<std::uint8_t>& a, const std::vector<std::uint8_t>& b,
std::size_t bytes) {
for (std::size_t i = 0; i < bytes; ++i) {
if (a[i] != b[i]) return i;
}
return bytes;
}
class PixelStoreSweepScenario : public ScenarioTest {};
class FramebufferChurnScenario : public ScenarioTest {};
} // namespace
// Every mode in the CTS table is set, exercised and put back; the readback at default state
// afterwards must be bit-identical to the one taken before the sweep. A mode that silently
// fails to restore corrupts every later case in the batch, which is exactly how the CTS
// failures presented (the FIRST sub-case, at default state, is what failed).
TEST_F(PixelStoreSweepScenario, DefaultStateSurvivesTheFullModeSweep) {
if (!Ready()) return;
ResetAllPixelStoreModes();
ASSERT_EQ(FirstGLError(), 0u) << "resetting the pixel-store modes must be legal on a GL 4.0 context";
const std::vector<std::uint8_t> gradient = MakeGradient(kTexSize, 0);
GLenum status = 0;
const std::vector<std::uint8_t> baseline = UploadAndReadBack(gradient, kTexSize, &status);
ASSERT_EQ(status, static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
ASSERT_EQ(FirstGLError(), 0u);
const std::vector<std::uint8_t> scratchSource(kScratchBytes, 0x5A);
for (const SweepCase& sweep : kSweep) {
glPixelStorei(sweep.mode, sweep.value);
ASSERT_EQ(FirstGLError(), 0u) << "glPixelStorei(0x" << std::hex << sweep.mode << std::dec << ", "
<< sweep.value << ") must be accepted";
// Exercise the mode: an upload and a readback that both run with it in force.
GLenum sweepStatus = 0;
(void)UploadAndReadBack(scratchSource, kTexSize, &sweepStatus);
ResetAllPixelStoreModes();
GLenum afterStatus = 0;
const std::vector<std::uint8_t> after = UploadAndReadBack(gradient, kTexSize, &afterStatus);
ASSERT_EQ(afterStatus, static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
const std::size_t diff = FirstDifference(baseline, after, ImageBytes(kTexSize));
ASSERT_EQ(diff, ImageBytes(kTexSize))
<< "default-state readback changed after setting and resetting 0x" << std::hex << sweep.mode
<< std::dec << " = " << sweep.value << "; first differing byte " << diff << " (baseline "
<< static_cast<int>(baseline[diff]) << ", now " << static_cast<int>(after[diff]) << ")";
}
// And the modes themselves must read back as the defaults the reset asked for.
for (const PixelStoreMode& mode : kAllModes) {
GLint value = -1;
glGetIntegerv(mode.name, &value);
EXPECT_EQ(value, mode.defaultValue)
<< "pixel-store mode 0x" << std::hex << mode.name << std::dec << " did not return to its default";
}
EXPECT_EQ(FirstGLError(), 0u);
}
// The leak regression. Each iteration is one complete CTS inner step, and every readback has
// to be exactly the gradient THIS iteration uploaded - never the previous one's. Before the
// missing destructors were added, the driver-side framebuffer count grew without bound here.
TEST_F(FramebufferChurnScenario, RepeatedFramebufferReadbackStaysExact) {
if (!Ready()) return;
ResetAllPixelStoreModes();
constexpr int kSize = 8;
constexpr int kIterations = 1024;
for (int i = 0; i < kIterations; ++i) {
// A distinct gradient per iteration: a stale attachment or a recycled driver name
// reads back the PREVIOUS iteration's image, which a constant fill could not tell
// apart from a correct read.
const std::vector<std::uint8_t> gradient = MakeGradient(kSize, static_cast<unsigned>(i * 7 + 1));
GLenum status = 0;
const std::vector<std::uint8_t> read = UploadAndReadBack(gradient, kSize, &status);
ASSERT_EQ(status, static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE)) << "iteration " << i;
const std::size_t diff = FirstDifference(gradient, read, ImageBytes(kSize));
ASSERT_EQ(diff, ImageBytes(kSize))
<< "iteration " << i << " read back a different image than it uploaded; first differing byte "
<< diff << " (uploaded " << static_cast<int>(gradient[diff]) << ", read "
<< static_cast<int>(read[diff]) << ")";
ASSERT_EQ(FirstGLError(), 0u) << "iteration " << i;
}
}
} // 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,215 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SsboArrayLengthScenario.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 - length() ON AN SSBO's UNSIZED ARRAY.
//
// GLSL's `arr.length()` on the trailing runtime array of a shader storage block is not a compile
// time constant: it is (bound range - the array's byte offset inside the block) / array stride,
// evaluated against whatever the descriptor actually covers. Three separate pieces of MobileGL
// have to agree for that to come out right - the byte offsets the block layout was compiled with,
// the buffer the frontend binding resolves to, and the offset/size a glBindBufferRange asked for -
// and a defect in any one of them shows up only as a wrong integer, never as an error.
//
// KHR-GL43.shader_storage_buffer_object.advanced-unsizedArrayLength-* (28 Magma failures, all 28
// passing on Espryt) reports exactly that: lengths too large by roughly the size of the members
// preceding the array. The cases here are the same shape, reduced to what can be asserted in one
// dispatch: a block with no preamble, a block with one, a two-element ARRAY OF BLOCKS (which
// consumes two consecutive bindings and is where the conformance failures concentrate), and the
// two glBindBufferRange forms.
//
// Every length is written into one output SSBO and read back, so a failure names the block and
// prints the number the shader 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 {
// Bindings 0..3 are inputs (2 and 3 are the block array), 4 is the output.
constexpr const char* kComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) readonly buffer Input0 {
ivec4 g_input0[];
};
layout(std430, binding = 1) readonly buffer Input1 {
ivec4 pad1;
ivec4 data[];
} g_input1;
layout(std430, binding = 2) readonly buffer Input23 {
ivec4 data[];
} g_input23[2];
layout(std430, binding = 4) buffer Output {
int g_length[];
};
void main() {
g_length[0] = g_input0.length();
g_length[1] = g_input1.data.length();
g_length[2] = g_input23[0].data.length();
g_length[3] = g_input23[1].data.length();
}
)";
constexpr int kElementBytes = 16; // ivec4, std430
class SsboArrayLengthScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
GLint blocks = 0;
glGetIntegerv(GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, &blocks);
if (blocks < 5) {
GTEST_SKIP() << "GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS is " << blocks << "; this needs 5";
}
m_program = CompileComputeProgram(kComputeSource);
ASSERT_NE(m_program, 0u) << m_buildLog;
}
void TearDown() override {
if (!Ready()) return;
if (!m_buffers.empty()) glDeleteBuffers(static_cast<GLsizei>(m_buffers.size()), m_buffers.data());
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;
}
// A buffer of `elements` ivec4s, filled with a recognisable pattern.
GLuint MakeStorageBuffer(int elements) {
std::vector<int> contents(static_cast<std::size_t>(elements) * 4, 41);
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
glBufferData(GL_SHADER_STORAGE_BUFFER,
static_cast<GLsizeiptr>(elements) * kElementBytes, contents.data(), GL_DYNAMIC_COPY);
m_buffers.push_back(buffer);
return buffer;
}
// Dispatches once and returns the four lengths the shader observed.
std::vector<int> RunAndReadLengths(GLuint outputBuffer) {
glUseProgram(m_program);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
std::vector<int> lengths(4, -1);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, outputBuffer);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0,
static_cast<GLsizeiptr>(lengths.size() * sizeof(int)), lengths.data());
return lengths;
}
unsigned int m_program = 0;
std::string m_buildLog;
std::vector<GLuint> m_buffers;
};
} // namespace
// glBindBufferBase everywhere: the plain case, and the one that pins the block array.
TEST_F(SsboArrayLengthScenario, WholeBufferBindingsReportTheElementCount) {
if (!Ready() || IsSkipped()) return;
// input1 carries one ivec4 of preamble before its runtime array, so a length that ignores
// the member offset comes back one too large there and only there.
const GLuint input0 = MakeStorageBuffer(7);
const GLuint input1 = MakeStorageBuffer(1 + 5);
const GLuint input2 = MakeStorageBuffer(3);
const GLuint input3 = MakeStorageBuffer(4);
const GLuint output = MakeStorageBuffer(4);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, input0);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, input1);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, input2);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, input3);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, output);
ASSERT_EQ(FirstGLError(), 0u);
const std::vector<int> lengths = RunAndReadLengths(output);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(lengths[0], 7) << "Input0 (no preamble, 7 elements) reported length " << lengths[0];
EXPECT_EQ(lengths[1], 5) << "Input1 (1 ivec4 of preamble, 6 elements of storage) reported length "
<< lengths[1] << "; 6 means the array's byte offset inside the block was ignored";
EXPECT_EQ(lengths[2], 3) << "Input23[0] (binding 2, 3 elements) reported length " << lengths[2];
EXPECT_EQ(lengths[3], 4) << "Input23[1] (binding 3, 4 elements) reported length " << lengths[3]
<< "; a block array's second element must resolve to the NEXT binding";
}
// glBindBufferRange with a non-zero offset: length() must see only the bound window.
TEST_F(SsboArrayLengthScenario, RangeBindingsReportTheBoundWindow) {
if (!Ready() || IsSkipped()) return;
GLint alignment = 1;
glGetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &alignment);
if (alignment > 2 * kElementBytes) {
GTEST_SKIP() << "GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT is " << alignment
<< "; a two-element offset cannot be expressed";
}
const GLuint input0 = MakeStorageBuffer(7);
const GLuint input1 = MakeStorageBuffer(1 + 5);
const GLuint input2 = MakeStorageBuffer(3);
const GLuint input3 = MakeStorageBuffer(4);
const GLuint output = MakeStorageBuffer(4);
// Input0: window starts two elements in, so 5 remain.
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 0, input0, 2 * kElementBytes, 5 * kElementBytes);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, input1);
// Both elements of the block array get a window, so a failure says whether the array's
// FIRST element is handled and only the later ones are lost, or neither is.
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, input2, 0, 2 * kElementBytes);
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 3, input3, 0, 2 * kElementBytes);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, output);
ASSERT_EQ(FirstGLError(), 0u);
const std::vector<int> lengths = RunAndReadLengths(output);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(lengths[0], 5) << "Input0 bound as [2 elements, 5 elements) reported length " << lengths[0]
<< "; 7 means glBindBufferRange's offset/size never reached the descriptor";
EXPECT_EQ(lengths[2], 2) << "Input23[0] bound as [0, 2 elements) reported length " << lengths[2];
EXPECT_EQ(lengths[3], 2) << "Input23[1] bound as [0, 2 elements) reported length " << lengths[3];
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, input0);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, input3);
}
} // 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,310 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SwizzleAccessRoutineScenario.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 TEXTURE ACCESS ROUTINE READS THE SAME TEXEL OUT OF A usampler2DArray.
//
// KHR-GL33/GL40.texture_swizzle.smoke_access_idx_* sweeps the fourteen GLSL texture access
// routines against a 1x1x1 GL_RGBA32UI GL_TEXTURE_2D_ARRAY and asserts the fetched channel. On
// Espryt, `texture` and `textureGrad` pass while `textureLod`, `textureOffset`, `texelFetch`,
// `texelFetchOffset` and `textureLodOffset` fail - 21 cases per version, 42 across GL33 and GL40.
// The discriminator is the important part: the swizzle state is IDENTICAL across all of them, so
// swizzle delivery is not the defect; what differs is only how the routine is spelled, i.e. what
// SPIRV-Cross has to emit into ESSL for it.
//
// This scenario is that discriminator, reduced to something that fails in milliseconds: one draw
// per access routine against the same texture and the same swizzle, all reading the same texel.
// A routine that disagrees with the others is the defect, and the failure message names it.
//
// The shader shape is copied from the conformance test rather than idealised - including its
// `int(0)` level-of-detail argument, which is a desktop-GLSL implicit int->float conversion that
// ESSL does not have, and its zero offsets. Both are exactly the things a GLSL -> SPIR-V -> ESSL
// round trip can lose.
//
// DirectVulkan is the built-in control: it consumes the SPIR-V directly and never runs the ESSL
// emission, so a failure there would mean the scenario, not the backend.
#include <array>
#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 {
// The conformance test's own source texel, one recognisable value per channel.
constexpr std::uint32_t kSourceTexel[4] = {0x3FFFFFFFu, 0x7FFFFFFFu, 0xBFFFFFFFu, 0xFFFFFFFFu};
constexpr int kOutputWidth = 8;
constexpr int kOutputHeight = 8;
// The blank vertex shader the smoke test uses: a full-viewport strip with no attributes.
constexpr const char* kVertexSource = R"(#version 330 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;
case 3: gl_Position = vec4( 1.0,-1.0, 0.0, 1.0); break;
}
}
)";
struct AccessRoutine {
const char* name; // as it appears in the conformance case name
const char* callText; // the whole TEXTURE_ACCESS(sampler, ARGUMENTS) expression
};
// Spelled exactly as gl3cTextureSwizzleTests.cpp's prepareArguments builds them for
// GL_TEXTURE_2D_ARRAY: three coordinates, `int(0)` for the level, ivec2 offsets.
constexpr AccessRoutine kRoutines[] = {
{"texture", "texture(smp, vec3(0, 0, 0))"},
{"textureLod", "textureLod(smp, vec3(0, 0, 0), int(0))"},
{"textureOffset", "textureOffset(smp, vec3(0, 0, 0), ivec2(0, 0))"},
{"texelFetch", "texelFetch(smp, ivec3(0, 0, 0), int(0))"},
{"texelFetchOffset", "texelFetchOffset(smp, ivec3(0, 0, 0), int(0), ivec2(0, 0))"},
{"textureLodOffset", "textureLodOffset(smp, vec3(0, 0, 0), int(0), ivec2(0, 0))"},
{"textureGrad", "textureGrad(smp, vec3(0, 0, 0), vec2(0, 0), vec2(0, 0))"},
{"textureGradOffset", "textureGradOffset(smp, vec3(0, 0, 0), vec2(0, 0), vec2(0, 0), ivec2(0, 0))"},
};
constexpr const char* kChannels[4] = {"x", "y", "z", "w"};
std::string FragmentSource(const AccessRoutine& routine, int channel) {
return std::string("#version 330 core\n\nuniform usampler2DArray smp;\n\nout uint out_color;\n\n"
"void main()\n{\n uint result = ") +
routine.callText + "." + kChannels[channel] + ";\n\n out_color = result;\n}\n";
}
class SwizzleAccessRoutineScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
// 1x1x1 RGBA32UI 2D array. Integer textures are not filterable, so NEAREST is
// mandatory, and a single level means every LOD argument must resolve to 0.
glGenTextures(1, &m_sourceTexture);
glBindTexture(GL_TEXTURE_2D_ARRAY, m_sourceTexture);
glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA32UI, 1, 1, 1);
glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, 1, 1, 1, GL_RGBA_INTEGER, GL_UNSIGNED_INT,
kSourceTexel);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
ASSERT_EQ(FirstGLError(), 0u) << "source texture setup left a GL error behind";
// 8x8 R32UI render target, read back with glReadPixels.
glGenTextures(1, &m_outputTexture);
glBindTexture(GL_TEXTURE_2D, m_outputTexture);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_R32UI, kOutputWidth, kOutputHeight);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_outputTexture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE));
glGenVertexArrays(1, &m_vao);
ASSERT_EQ(FirstGLError(), 0u) << "output framebuffer setup left a GL error behind";
}
void TearDown() override {
if (!Ready()) return;
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_fbo != 0) glDeleteFramebuffers(1, &m_fbo);
if (m_outputTexture != 0) glDeleteTextures(1, &m_outputTexture);
if (m_sourceTexture != 0) glDeleteTextures(1, &m_sourceTexture);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
void SetSwizzle(GLenum r, GLenum g, GLenum b, GLenum a) {
glBindTexture(GL_TEXTURE_2D_ARRAY, m_sourceTexture);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_SWIZZLE_R, static_cast<GLint>(r));
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_SWIZZLE_G, static_cast<GLint>(g));
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_SWIZZLE_B, static_cast<GLint>(b));
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_SWIZZLE_A, static_cast<GLint>(a));
}
// Renders one access routine into the 8x8 target and returns every texel it wrote.
// Returns an empty vector (with a gtest failure already recorded) if the program did
// not build.
std::vector<std::uint32_t> Render(const AccessRoutine& routine, int channel) {
const std::string fragment = FragmentSource(routine, channel);
std::string error;
const unsigned int program = CompileProgram(kVertexSource, fragment.c_str(), &error);
if (program == 0) {
ADD_FAILURE() << routine.name << " channel " << kChannels[channel]
<< ": program did not build: " << error << "\n--- source ---\n"
<< fragment;
return {};
}
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glViewport(0, 0, kOutputWidth, kOutputHeight);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
const GLuint clearValue[4] = {0xDEADBEEFu, 0u, 0u, 0u};
glClearBufferuiv(GL_COLOR, 0, clearValue);
glUseProgram(program);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D_ARRAY, m_sourceTexture);
const GLint location = glGetUniformLocation(program, "smp");
glUniform1i(location, 0);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
std::vector<std::uint32_t> texels(static_cast<std::size_t>(kOutputWidth) * kOutputHeight, 0);
glReadPixels(0, 0, kOutputWidth, kOutputHeight, GL_RED_INTEGER, GL_UNSIGNED_INT, texels.data());
glUseProgram(0);
glDeleteProgram(program);
return texels;
}
// Asserts every texel equals `expected`, naming the routine and the first offender.
void ExpectAllTexels(const AccessRoutine& routine, int channel, std::uint32_t expected,
const std::vector<std::uint32_t>& texels) {
if (texels.empty()) return;
std::size_t offenders = 0;
std::uint32_t firstBad = 0;
std::size_t firstIndex = 0;
for (std::size_t i = 0; i < texels.size(); ++i) {
if (texels[i] == expected) continue;
if (offenders == 0) {
firstBad = texels[i];
firstIndex = i;
}
++offenders;
}
EXPECT_EQ(offenders, 0u)
<< routine.name << "(...)." << kChannels[channel] << " returned 0x" << std::hex << firstBad
<< " instead of 0x" << expected << std::dec << " at texel " << firstIndex << " (" << offenders
<< " of " << texels.size() << " wrong)";
}
GLuint m_sourceTexture = 0;
GLuint m_outputTexture = 0;
GLuint m_fbo = 0;
GLuint m_vao = 0;
};
} // namespace
// Identity swizzle: every routine must fetch the channel it was asked for. This is the
// scenario's floor - it does not involve swizzling at all, so a failure here is purely about
// how the access routine itself survives the trip to the backend.
TEST_F(SwizzleAccessRoutineScenario, EveryAccessRoutineFetchesTheSameTexelUnderTheIdentitySwizzle) {
if (!Ready() || IsSkipped()) return;
SetSwizzle(GL_RED, GL_GREEN, GL_BLUE, GL_ALPHA);
ASSERT_EQ(FirstGLError(), 0u);
for (const AccessRoutine& routine : kRoutines) {
for (int channel = 0; channel < 4; ++channel) {
const std::vector<std::uint32_t> texels = Render(routine, channel);
EXPECT_EQ(FirstGLError(), 0u) << routine.name << " left a GL error behind";
ExpectAllTexels(routine, channel, kSourceTexel[channel], texels);
}
}
Gl().EndFrame();
}
// A real swizzle, applied to every routine. Reversing the channels means a routine that
// silently drops the swizzle returns the UNSWIZZLED texel rather than nothing, so the
// failure distinguishes "swizzle lost" from "fetch broken".
TEST_F(SwizzleAccessRoutineScenario, EveryAccessRoutineSeesAReversedSwizzle) {
if (!Ready() || IsSkipped()) return;
SetSwizzle(GL_ALPHA, GL_BLUE, GL_GREEN, GL_RED);
ASSERT_EQ(FirstGLError(), 0u);
const std::uint32_t expected[4] = {kSourceTexel[3], kSourceTexel[2], kSourceTexel[1], kSourceTexel[0]};
for (const AccessRoutine& routine : kRoutines) {
for (int channel = 0; channel < 4; ++channel) {
const std::vector<std::uint32_t> texels = Render(routine, channel);
EXPECT_EQ(FirstGLError(), 0u) << routine.name << " left a GL error behind";
ExpectAllTexels(routine, channel, expected[channel], texels);
}
}
Gl().EndFrame();
}
// Program churn: the shape that made the conformance suite fail, reduced.
//
// The swizzle smoke test builds one program per swizzle combination - 1,296 per case - and
// DirectGLES created a driver shader object per attached shader without ever calling
// glDeleteShader. glDeleteShader only FLAGS a shader for deletion (the driver frees it once
// nothing has it attached), so without that call the program's own deletion could not free
// them either: eight cases left ~20,000 live driver shaders behind, the Adreno ES driver
// passed its ceiling, and it began mis-serving shaders - first the sampling variants with the
// most image operands (textureLod/texelFetch/*Offset), while plain texture/textureGrad still
// worked. On device this loop plus a value check is the whole defect.
//
// HONEST LIMIT OF THIS TEST: llvmpipe has no such ceiling, so this passes here whether or not
// the leak is present - it cannot fail on the CI lane. It is a standing guard for the SHAPE
// (build many programs, keep reading the right texel) and the place to raise the iteration
// count if a driver ceiling ever needs reproducing; the leak itself is pinned by device
// measurement (VmRSS flat at ~137 MB across the 32-case family, against 132 -> 154 MB and
// still climbing before the fix).
TEST_F(SwizzleAccessRoutineScenario, RepeatedProgramBuildsKeepFetchingTheSameTexel) {
if (!Ready() || IsSkipped()) return;
SetSwizzle(GL_RED, GL_GREEN, GL_BLUE, GL_ALPHA);
ASSERT_EQ(FirstGLError(), 0u);
// One routine from each side of the device's failure order, so a ceiling that takes the
// vulnerable one down first is still caught.
const AccessRoutine& plain = kRoutines[0]; // texture
const AccessRoutine& explicitLod = kRoutines[1]; // textureLod
constexpr int kIterations = 200;
for (int i = 0; i < kIterations; ++i) {
const AccessRoutine& routine = (i % 2 == 0) ? plain : explicitLod;
const int channel = i % 4;
const std::vector<std::uint32_t> texels = Render(routine, channel);
if (::testing::Test::HasFailure()) return; // a build failure repeats 200 times; say it once
ExpectAllTexels(routine, channel, kSourceTexel[channel], texels);
if (::testing::Test::HasFailure()) {
ADD_FAILURE() << "diverged at iteration " << i << " of " << kIterations;
return;
}
}
EXPECT_EQ(FirstGLError(), 0u) << "the churn loop left a GL error behind";
Gl().EndFrame();
}
// GL_ONE and GL_ZERO, which the conformance table spells as the literal values 1 and 0 and
// which the backend has to synthesise rather than fetch.
TEST_F(SwizzleAccessRoutineScenario, EveryAccessRoutineSeesConstantSwizzleSources) {
if (!Ready() || IsSkipped()) return;
SetSwizzle(GL_ONE, GL_ZERO, GL_ONE, GL_ZERO);
ASSERT_EQ(FirstGLError(), 0u);
const std::uint32_t expected[4] = {1u, 0u, 1u, 0u};
for (const AccessRoutine& routine : kRoutines) {
for (int channel = 0; channel < 4; ++channel) {
const std::vector<std::uint32_t> texels = Render(routine, channel);
EXPECT_EQ(FirstGLError(), 0u) << routine.name << " left a GL error behind";
ExpectAllTexels(routine, channel, expected[channel], texels);
}
}
Gl().EndFrame();
}
} // 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
@@ -67,9 +67,13 @@ namespace MobileGL::MG_State::GLState {
} }
} }
} }
// Key-based erase skips FastSTL's successor-iterator scan, which is // Erase through the iterator already in hand: erase(key) would repeat the
// pure overhead here and dominates delete-heavy frames. // find() above, and the successor scan that once made key-based
m_bufferObjects.erase(index); // 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); m_indexGenerator.Delete(index);
} }
+300 -22
View File
@@ -369,6 +369,214 @@ namespace MobileGL::MG_State {
return m_programState.GetCurrentProgram(); 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() { const SharedPtr<ProgramObject>& GLContext::GetProgramForDraw() {
static const SharedPtr<ProgramObject> nullProgram = nullptr; static const SharedPtr<ProgramObject> nullProgram = nullptr;
const auto& currentProgram = m_programState.GetCurrentProgram(); const auto& currentProgram = m_programState.GetCurrentProgram();
@@ -380,8 +588,14 @@ namespace MobileGL::MG_State {
// inside the same draw when it finally touched an artifact, and cache under a // inside the same draw when it finally touched an artifact, and cache under a
// version the publish had already superseded. Settling here means every // version the publish had already superseded. Settling here means every
// version a backend reads during a draw describes the program it is drawing. // version a backend reads during a draw describes the program it is drawing.
// One null check in steady state. // Two null checks in steady state.
currentProgram->JoinLink(); //
// BOTH phases, and that is not optional: the phase-B publish bumps those same
// versions, so joining only phase A here would leave exactly the hazard this
// site exists to close - a backend samples a version, then trips the phase-B
// gate through GetGeneratedSpirv() deeper inside the same draw, and memoizes
// under a version the publish has already superseded.
currentProgram->JoinLinkAndSpirv();
return currentProgram; return currentProgram;
} }
if (m_boundProgramPipeline == 0) return nullProgram; if (m_boundProgramPipeline == 0) return nullProgram;
@@ -389,20 +603,23 @@ namespace MobileGL::MG_State {
if (!pipeline) return nullProgram; if (!pipeline) return nullProgram;
// P1 join site J1. ComputeDrawProgramSignature() keys the composite cache on each // P1 join site J1. ComputeDrawProgramSignature() keys the composite cache on each
// stage program's lifetimeId and backendStateVersion - NON-artifact fields, so // stage program's lifetimeId and linkVersion - NON-artifact fields, so they do not
// they do not pass through ProgramObject's join gate and a pending link would // pass through ProgramObject's join gate and a pending link would stay pending
// stay pending right through the signature. Since the version is bumped both at // right through the signature. Since the version is bumped both at enqueue and at
// enqueue and at publish, the signature computed inside a pending window is one // publish, the signature computed inside a pending window is one that will never
// that will never be produced again: every draw would miss the cache and rebuild // be produced again: every draw would miss the cache and rebuild (and relink) the
// (and relink) the composite. Join first, so the signature describes settled // composite. Join first, so the signature describes settled programs. In steady
// programs. In steady state this is a null check per stage. // state this is a null check per stage.
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)); const auto& stageProgram = pipeline->GetStageProgram(static_cast<ShaderStage>(stage));
if (stageProgram) stageProgram->JoinLink(); if (stageProgram) stageProgram->JoinLinkAndSpirv();
} }
const auto signature = pipeline->ComputeDrawProgramSignature(); 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 // Everything downstream of here - the backends, the uniform plumbing, the draw
// validation - is written against a single linked program, so the pipeline is // validation - is written against a single linked program, so the pipeline is
@@ -414,8 +631,14 @@ namespace MobileGL::MG_State {
// could otherwise be handed. Backend registries key on the object, not the name. // could otherwise be handed. Backend registries key on the object, not the name.
auto composite = MakeShared<ProgramObject>(0u); 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; 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)); const auto& stageProgram = pipeline->GetStageProgram(static_cast<ShaderStage>(stage));
if (!stageProgram) continue; if (!stageProgram) continue;
for (const auto& shader : stageProgram->GetAttachedShaders()) { for (const auto& shader : stageProgram->GetAttachedShaders()) {
@@ -430,10 +653,36 @@ namespace MobileGL::MG_State {
composite->Link(true); composite->Link(true);
// P1 join site J2. The draw that asked for this program is the very next thing to // P1 join site J2. The draw that asked for this program is the very next thing to
// happen, so enqueueing the composite's link buys nothing and only moves the wait // happen, so enqueueing the composite's link buys nothing and only moves the wait
// to whichever backend accessor happens to touch its artifacts first. // to whichever backend accessor happens to touch its artifacts first. Both phases,
composite->JoinLink(); // for the same reason: the backend is about to read its SPIR-V.
composite->JoinLinkAndSpirv();
pipeline->SetCachedDrawProgram(signature, Move(composite)); 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() { const SharedPtr<ProgramObject>& GLContext::GetProgramForUniform() {
@@ -912,31 +1161,60 @@ namespace MobileGL::MG_State {
// Program pipeline // Program pipeline
void GLContext::GenProgramPipelineNames(Uint number, Vector<Uint>& pipelines) { void GLContext::GenProgramPipelineNames(Uint number, Vector<Uint>& pipelines) {
pipelines.resize(number); 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()); m_programPipelineNames.Generate(number, pipelines.data());
} }
void GLContext::CreateProgramPipelineObject(Uint index) { 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 { Bool GLContext::ValidateProgramPipelineName(Uint index) const {
return index == 0 || m_programPipelineNames.IsValid(index); 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 { Bool GLContext::IsProgramPipelineObject(Uint index) const {
if (index == 0 || !m_programPipelineNames.IsValid(index)) return false; 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) { void GLContext::BindProgramPipelineObject(Uint index) {
if (index != 0 && m_programPipelines.find(index) == m_programPipelines.end()) { if (index != 0) {
// First bind is what turns a reserved name into an object. if (const auto& object = MaterializeProgramPipelineObject(index)) {
m_programPipelines[index] = MakeShared<ProgramPipelineObject>(index); object->MarkEverBound();
}
} }
m_boundProgramPipeline = index; 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) { void GLContext::MarkProgramPipelineForDeletion(Uint index) {
if (index == 0 || !m_programPipelineNames.IsValid(index)) return; if (index == 0 || !m_programPipelineNames.IsValid(index)) return;
if (index == m_boundProgramPipeline) { if (index == m_boundProgramPipeline) {
+17 -5
View File
@@ -163,21 +163,31 @@ namespace MobileGL {
} }
void UseProgram(Uint program); void UseProgram(Uint program);
const SharedPtr<ProgramObject>& GetCurrentProgram(); const SharedPtr<ProgramObject>& GetCurrentProgram();
// What a draw or dispatch actually executes: the program in use, or - when // What a DRAW executes: the program in use, or - when there is none - the bound
// there is none - the bound pipeline's stages composited into one program. // 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(); 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 // What glUniform* addresses: the program in use, or the bound pipeline's
// active program (GL 4.6 core 7.6.1). // active program (GL 4.6 core 7.6.1).
const SharedPtr<ProgramObject>& GetProgramForUniform(); const SharedPtr<ProgramObject>& GetProgramForUniform();
// Program pipeline (GL_ARB_separate_shader_objects, GL 4.6 core 7.4). Like queries // 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 // 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 GenProgramPipelineNames(Uint number, Vector<Uint>& pipelines);
void CreateProgramPipelineObject(Uint index); void CreateProgramPipelineObject(Uint index);
Bool ValidateProgramPipelineName(Uint index) const; Bool ValidateProgramPipelineName(Uint index) const;
Bool IsProgramPipelineObject(Uint index) const; Bool IsProgramPipelineObject(Uint index) const;
void BindProgramPipelineObject(Uint index); 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); void MarkProgramPipelineForDeletion(Uint index);
const SharedPtr<ProgramPipelineObject>& GetProgramPipelineObject(Uint index) const; const SharedPtr<ProgramPipelineObject>& GetProgramPipelineObject(Uint index) const;
Uint GetBoundProgramPipelineName() const { return m_boundProgramPipeline; } Uint GetBoundProgramPipelineName() const { return m_boundProgramPipeline; }
@@ -447,8 +457,10 @@ namespace MobileGL {
UnorderedMap<Uint, TransformFeedbackObjectState> m_transformFeedbackObjects; UnorderedMap<Uint, TransformFeedbackObjectState> m_transformFeedbackObjects;
IndexGenerator<Uint> m_transformFeedbackNames; IndexGenerator<Uint> m_transformFeedbackNames;
Uint m_boundTransformFeedback = 0; Uint m_boundTransformFeedback = 0;
// Map membership IS object existence here: a pipeline has no stateful default // Map membership is object EXISTENCE, which is not the same as the answer
// object 0, so no everBound flag is needed. // 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; UnorderedMap<Uint, SharedPtr<ProgramPipelineObject>> m_programPipelines;
IndexGenerator<Uint> m_programPipelineNames; IndexGenerator<Uint> m_programPipelineNames;
Uint m_boundProgramPipeline = 0; Uint m_boundProgramPipeline = 0;
@@ -41,6 +41,28 @@ namespace {
return bracket == MobileGL::String::npos ? name : name.substr(0, bracket); 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) { static bool IsBuiltInPipelineOutput(const glslang::TObjectReflection& output) {
const auto* type = output.getType(); const auto* type = output.getType();
return type && type->getQualifier().builtIn != glslang::EbvNone; return type && type->getQualifier().builtIn != glslang::EbvNone;
@@ -97,39 +119,6 @@ namespace {
return std::max(1, uniform.size); 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
namespace MobileGL::MG_State::GLState { namespace MobileGL::MG_State::GLState {
@@ -301,6 +290,29 @@ namespace MobileGL::MG_State::GLState {
Vector<SharedPtr<glslang::TShader>> shaders; Vector<SharedPtr<glslang::TShader>> shaders;
if (!ConsumeShaders(shaders)) return; 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 // Merge the shaders' lexically extracted explicit uniform locations. The same
// uniform declared in several stages must agree on its location (config-A glslang // 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). // enforced this at mapIO; the relaxed parse no longer sees the qualifiers).
@@ -347,6 +359,31 @@ namespace MobileGL::MG_State::GLState {
return; 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_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. // (GL 4.6 core 11.3.1), so it is resolved for every link, not only a capturing one.
artifacts.gsInputPrimitive = GL_NONE; artifacts.gsInputPrimitive = GL_NONE;
@@ -361,29 +398,53 @@ namespace MobileGL::MG_State::GLState {
} }
} }
// SPIR-V must be generated BEFORE buildReflection touches artifacts.program: // ---- everything below this line up to GenerateSpirv() is the GL query surface ----
// reflection's live-variable analysis mutates the intermediates in ways that //
// change subsequent GlslangToSpv output (observed: catastrophic uniform // ORDERING NOTE (rewritten 2026-08-10; the constraint it records was RETESTED, not
// misbinding on DirectVulkan for UBO-heavy content). The old two-link pipeline // dropped on a hunch). This block used to insist that SPIR-V be generated BEFORE
// never ran buildReflection on the SPIR-V-producing program; this order keeps // buildReflection touches artifacts.program, on the grounds that reflection's
// that property with the single link. The glUniform*-to-scratch routing // live-variable analysis mutates the shared intermediates in ways that change
// tables, in contrast, are sized and keyed by reflection results, so they are // subsequent GlslangToSpv output - "observed: catastrophic uniform misbinding on
// built strictly AFTER DoReflection. (Everything else on the reflection // DirectVulkan for UBO-heavy content", recorded with commit 0d052719.
// surface - locations, sampler units, block bindings/sizes - was measured //
// identical in either order.) // Re-measured on the glslang pin this tree vendors, with the same method 0d052719
MGLOG_D("ProgramObject %u: Starting SPIR-V generation", in.externalIndex); // used (per-module SPIR-V hashes, both orders, byte-compared): 636 modules across
GenerateSpirv(); // 320 programs - the whole extracted trace corpus (BSL, Complementary Reimagined,
// IterationRP, Create/Flywheel) plus adversarial synthetics - came out BYTE-IDENTICAL
// in both orders, pre-optimize and post-optimize alike. glslang's code structure
// agrees: reflection.cpp performs no AST write (no getWritableType, no const_cast, no
// qualifier assignment) and GlslangToSpv takes a const TIntermediate&.
//
// Confirmed a third time ON DEVICE, 2026-08-11, and this one closes the gap the
// desktop A/B could not: the corpus replays captured SOURCES, so it never reproduced
// Iris's glBindAttribLocation-before-link flow, which is what drives the io-resolver
// that assigns vertex-input Locations. A Complementary Reimagined pack load on an
// Adreno 830 was dumped at the pipeline the driver rejects (programHash
// 0x4a7e9a37fb49caa1) under BOTH orders and under the pre-split build 6ea94877: all
// three dumps are the same bytes (md5 39ffa10d5186a4d37be82d0b42297a8d). The order
// does not perturb SPIR-V on this pin, including on the exact flow 0d052719 feared.
//
// Not a licence to stop measuring: 0d052719's observation was real once, and the
// method (per-module hashes, both orders) is cheap. Re-run it on any glslang bump.
//
// So the order is now the other way round, and deliberately: reflection, fragment
// output validation and transform-feedback resolution are what the GL query surface
// is made of, and they are also the only remaining ways a link can FAIL, so running
// them first is what lets LINK_STATUS and every query behind it become final without
// waiting for SPIR-V (and stops a program that fails validation from paying for
// ~68 s/pack-load of SPIR-V generation it is about to throw away).
//
// What has NOT changed: the routing tables are sized and keyed by reflection results
// AND read the OPTIMIZED SPIR-V, so BuildGlobalUboRouting still runs strictly after
// both DoReflection and GenerateSpirv.
MGLOG_D("ProgramObject %u: Starting reflection", in.externalIndex); MGLOG_D("ProgramObject %u: Starting reflection", in.externalIndex);
if (!DoReflection(env)) { if (!DoReflection(env)) {
DeferLog(std::format("ProgramObject {}: Link failed during reflection: {}", in.externalIndex, DeferLog(std::format("ProgramObject {}: Link failed during reflection: {}", in.externalIndex,
artifacts.infoLog)); artifacts.infoLog));
return; return;
} }
MGLOG_D("ProgramObject %u: Building global-UBO routing tables", in.externalIndex);
BuildGlobalUboRouting();
MGLOG_D("ProgramObject %u: Reflection done (linkStatus=%d)", in.externalIndex, (int)artifacts.linkStatus); MGLOG_D("ProgramObject %u: Reflection done (linkStatus=%d)", in.externalIndex, (int)artifacts.linkStatus);
if (!ValidateFragmentOutputLocations()) { if (!ValidateFragmentOutputLocations()) {
return; return;
} }
@@ -393,13 +454,52 @@ namespace MobileGL::MG_State::GLState {
in.externalIndex, artifacts.infoLog)); in.externalIndex, artifacts.infoLog));
return; return;
} }
MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", in.externalIndex,
artifacts.generatedSpirv.size()); // ---- past this point the link cannot fail any more ----
// Everything left is SPIR-V work, and it belongs to phase B. Hand it what it needs
// and stop: from the join's point of view this program is now fully linked.
//
// The TShaders move rather than copy - `attrib` borrowed them into the TProgram as
// raw pointers and this node is now their owner of record, for as long as phase B
// (which holds this node) needs the intermediates hanging off them.
spirvHandoff.shaders = Move(attrib.shaders);
spirvHandoff.shaderTypes.resize(in.shaders.size());
for (SizeT i = 0; i < in.shaders.size(); i++) {
spirvHandoff.shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(in.shaders[i].stage);
}
// Copied, not referenced: `artifacts` is MOVED out of this node by the join, and
// phase B runs after that. Measured at ~20 us per program, which is noise against the
// ~450 ms phase B spends on the same program.
spirvHandoff.reflection.program = artifacts.program;
spirvHandoff.reflection.uniformLocations = artifacts.uniformLocations;
spirvHandoff.reflection.uniformIndexInTProgram = artifacts.uniformIndexInTProgram;
spirvHandoff.reflection.tProgramUniformIndexToGl = artifacts.tProgramUniformIndexToGl;
spirvHandoff.reflection.maxUniformLocation = artifacts.maxUniformLocation;
spirvHandoff.ready = true;
MGLOG_D("ProgramObject %u: phase A done, %zu module(s) handed to the SPIR-V job", in.externalIndex,
spirvHandoff.shaderTypes.size());
} }
Bool ProgramLinkTask::ConsumeShaders(Vector<SharedPtr<glslang::TShader>>& outShaders) { Bool ProgramLinkTask::ConsumeShaders(Vector<SharedPtr<glslang::TShader>>& outShaders) {
outShaders.assign(in.shaders.size(), nullptr); outShaders.assign(in.shaders.size(), nullptr);
// GL 4.6 core 7.3: a compute shader may only be linked with other compute shaders -
// the compute pipeline has no other stages to link against, so a program that mixes
// them must fail to link (KHR-GL43.compute_shader.api-program).
{
Bool hasCompute = false;
Bool hasNonCompute = false;
for (const LinkShaderInput& input : in.shaders) {
(input.stage == ShaderStage::Compute ? hasCompute : hasNonCompute) = true;
}
if (hasCompute && hasNonCompute) {
artifacts.infoLog =
"A compute shader cannot be linked with shaders of any other stage.";
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
return false;
}
}
for (SizeT i = 0; i < in.shaders.size(); i++) { for (SizeT i = 0; i < in.shaders.size(); i++) {
const LinkShaderInput& input = in.shaders[i]; const LinkShaderInput& input = in.shaders[i];
const GLenum shaderType = MG_Util::ConvertShaderStageToGLEnum(input.stage); const GLenum shaderType = MG_Util::ConvertShaderStageToGLEnum(input.stage);
@@ -408,6 +508,13 @@ namespace MobileGL::MG_State::GLState {
MG_Util::ConvertGLEnumToString(shaderType).c_str()); MG_Util::ConvertGLEnumToString(shaderType).c_str());
if (!compiled.compileStatus) { if (!compiled.compileStatus) {
// The compile log LEADS the quoted source, and that order is load-bearing:
// under MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS this string is the
// application's ONLY compile diagnostic (the per-shader queries answered
// optimistically), and applications read it through a bounded buffer -
// Iris uses 32768 bytes - so the actionable text must come before the
// potentially-100KB source dump. The full source stays: the device log is
// where a failing pack gets debugged from.
artifacts.infoLog = artifacts.infoLog =
std::format("Linking a {} with compilation error, linking will now terminate. Shader error " std::format("Linking a {} with compilation error, linking will now terminate. Shader error "
"log:\n{}\nShader src:\n{}", "log:\n{}\nShader src:\n{}",
@@ -417,13 +524,6 @@ namespace MobileGL::MG_State::GLState {
in.externalIndex, i, artifacts.infoLog)); in.externalIndex, i, artifacts.infoLog));
return false; 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; String reparseLog;
outShaders[i] = input.compiled->ClaimParsedShader(reparseLog); outShaders[i] = input.compiled->ClaimParsedShader(reparseLog);
if (!outShaders[i]) { if (!outShaders[i]) {
@@ -828,191 +928,27 @@ namespace MobileGL::MG_State::GLState {
std::max(artifacts.uniformBlockNameMaxLength, (Int)ubo.name.length()); std::max(artifacts.uniformBlockNameMaxLength, (Int)ubo.name.length());
artifacts.uniformBlockIndexByName[ubo.name] = i; artifacts.uniformBlockIndexByName[ubo.name] = i;
// if there's binding defined in shader as layout(binding = ...), // if there's binding defined in shader as layout(binding = ...),
// retrieve it here // retrieve it here.
artifacts.uniformBlockBinding[i] = ubo.getBinding(); //
// 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, MGLOG_D("ProgramObject %u: Reflection - UBO[%d] name='%s' size=%u binding=%d", in.externalIndex, i,
ubo.name.c_str(), ubo.size, ubo.getBinding()); ubo.name.c_str(), ubo.size, ubo.getBinding());
} }
return true; return true;
} }
void ProgramLinkTask::GenerateSpirv() {
/* As we passed first stage compilation/linking,
* we'll assume all the operations here should
* pass. We may be able to employ some optimizations
* here without the burden of error reporting.
*/
using namespace MG_Util::ShaderTranspiler;
MGLOG_D("ProgramObject %u: GenerateSpirv - start", in.externalIndex);
// The shaders were parsed once, in the link-compatible (relaxed Vulkan-rules)
// configuration, and artifacts.program linked those parses - so artifacts.program IS
// the program the backends consume. Generate SPIR-V straight from its
// intermediates; the full re-parse + re-link that used to live here (one
// glslang pass per shader per link) is gone.
Vector<GLenum> shaderTypes(in.shaders.size());
for (SizeT i = 0; i < in.shaders.size(); i++) {
shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(in.shaders[i].stage);
}
ProgramBinaryAttrib binaryAttrib{
.shaderTypes = shaderTypes,
.program = *artifacts.program,
};
MGLOG_D("ProgramObject %u: GenerateSpirv - requesting SPIR-V binary from program", in.externalIndex);
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
if (!binaryResult) {
DeferLog(std::format("ProgramObject {}: GenerateSpirv - GetSpirvBinaryFromProgram failed",
in.externalIndex));
}
MOBILEGL_ASSERT(binaryResult, "GetSpirvBinaryFromProgram failed");
artifacts.generatedSpirv = Move(binaryResult.value());
MGLOG_D("ProgramObject %u: GenerateSpirv - generated %zu SPIR-V modules", in.externalIndex,
artifacts.generatedSpirv.size());
// Linked SPIR-V generated, sanitize and optimize it
for (auto& spv : artifacts.generatedSpirv) {
auto success = ShaderCompiler::SanitizeAndOptimizeBinary(spv, spv);
MOBILEGL_ASSERT(success, "SanitizeBinary failed");
}
}
void ProgramLinkTask::BuildGlobalUboRouting() {
using namespace MG_Util::ShaderTranspiler;
Vector<GLenum> shaderTypes(in.shaders.size());
for (SizeT i = 0; i < in.shaders.size(); i++) {
shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(in.shaders[i].stage);
}
artifacts.uniformSizesInBytes.clear();
artifacts.uniformOffsets.clear();
artifacts.globalUboScratch.clear();
// kInvalidUniformOffset marks locations that end up without global-UBO backing
// (e.g. the optimizer eliminated every use of the uniform); the fallback pass
// below gives those locations tail storage so glUniform* always has a target.
artifacts.uniformOffsets.resize(artifacts.maxUniformLocation + 1, ProgramObject::kInvalidUniformOffset);
artifacts.uniformSizesInBytes.resize(artifacts.maxUniformLocation + 1, 0);
for (SizeT i = 0; i < artifacts.generatedSpirv.size(); i++) {
auto& spv = artifacts.generatedSpirv[i];
auto shaderType = shaderTypes[i];
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - parsing SPIR-V meta data for module %zu "
"(shaderType=%u, wordCount=%zu)",
in.externalIndex, i, shaderType, spv.size());
SpvcSession session(spv, SessionUsageBit::Reflection);
auto result = session.ParseMetaData();
if (result < 0) {
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SpvcSession::ParseMetaData failed for module %zu, "
"err = %d%s",
in.externalIndex, i, result,
(result == SPVC_ERROR_INVALID_SPIRV ? ". Probably no global UBO?" : ""));
continue;
} else {
auto& meta = session.GetMetadata();
auto size = meta.globalUboSize;
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SPIR-V meta: uboSize=%zu plainUniformCount=%zu "
"plainUniformOffsets=%zu",
in.externalIndex, meta.globalUboSize, meta.plainUniformMemberSizesInBytes.size(),
meta.plainUniformOffsetsInUBO.size());
if (size == 0) {
continue;
}
if (artifacts.globalUboScratch.size() < size) {
artifacts.globalUboScratch.resize(size);
}
for (const auto& [name, offset] : meta.plainUniformOffsetsInUBO) {
// SPIRV-Reflect leaf names never carry a "[0]" suffix; frontend
// reflection keys arrays as "arr[0]" (GL naming), so retry with the
// suffix before declaring the uniform unbacked.
auto locationIt = artifacts.uniformLocations.find(name);
if (locationIt == artifacts.uniformLocations.end()) {
locationIt = artifacts.uniformLocations.find(name + "[0]");
}
if (locationIt == artifacts.uniformLocations.end()) {
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u but not found in "
"uniformLocations",
in.externalIndex, name.c_str(), offset);
continue;
}
const Uint baseLocation = locationIt->second;
if (!ProgramObject::IsValidUniformLocation(artifacts, static_cast<Int>(baseLocation))) {
continue;
}
const Int uniformIndex = artifacts.uniformIndexInTProgram[baseLocation];
const GLint arraySize = ProgramObject::GetUniformArraySizeByTIndex(artifacts, uniformIndex);
SizeT memberSize = 0;
const auto sizeIt = meta.plainUniformMemberSizesInBytes.find(name);
if (sizeIt != meta.plainUniformMemberSizesInBytes.end()) {
memberSize = sizeIt->second;
}
Uint arrayStride = 0;
const auto strideIt = meta.plainUniformArrayStridesInUBO.find(name);
if (strideIt != meta.plainUniformArrayStridesInUBO.end()) {
arrayStride = strideIt->second;
}
// Array uniforms span one location per element (see DoReflection);
// give each element its real byte offset inside the UBO.
const GLint elementCount = (arraySize > 1 && arrayStride == 0) ? 1 : std::max(arraySize, 1);
for (GLint element = 0; element < elementCount; ++element) {
const Uint location = baseLocation + static_cast<Uint>(element);
if (location > artifacts.maxUniformLocation ||
artifacts.uniformIndexInTProgram[location] != uniformIndex) {
break;
}
artifacts.uniformOffsets[location] = offset + static_cast<Uint>(element) * arrayStride;
const SizeT consumed = static_cast<SizeT>(element) * arrayStride;
artifacts.uniformSizesInBytes[location] = memberSize > consumed ? memberSize - consumed : 0;
}
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u stride=%u size=%zu assigned "
"to locations %u..%u",
in.externalIndex, name.c_str(), offset, arrayStride, memberSize, baseLocation,
baseLocation + static_cast<Uint>(elementCount) - 1);
}
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - finished parsing module %zu metadata",
in.externalIndex, i);
}
}
// Fallback pass: a linked program's active non-opaque uniforms must accept
// glUniform*/glGetUniform* even when the optimized SPIR-V no longer contains
// them (AggressiveDCE can remove a dead loop together with the only loads of a
// uniform -- or the entire global UBO, leaving the scratch unallocated). Hand
// such locations CPU-side storage at the (16-byte aligned) tail of the shadow
// buffer; backends bind at least the SPIR-V-declared UBO range, and the GPU
// never reads these bytes, so this only keeps the GL-visible state coherent.
for (Uint location = 0; location <= artifacts.maxUniformLocation; ++location) {
if (artifacts.uniformOffsets[location] != ProgramObject::kInvalidUniformOffset) continue;
if (!ProgramObject::IsValidUniformLocation(artifacts, static_cast<Int>(location))) continue;
const auto& uniform = artifacts.program->getUniform(artifacts.uniformIndexInTProgram[location]);
const glslang::TType* type = uniform.getType();
if (type != nullptr && type->isOpaque()) continue;
if (uniform.index >= 0 && uniform.index < artifacts.program->getNumUniformBlocks() &&
std::strstr(artifacts.program->getUniformBlock(uniform.index).name.c_str(),
MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) == nullptr) {
// Member of a named uniform block: not settable through glUniform*, so it
// needs no global-UBO shadow storage.
continue;
}
// std140-style slot: the matrix upload paths write column vectors at
// 16-byte strides, so a matrix slot must cover cols * 16 bytes.
SizeT slotSize = MG_Util::GetGLTypeSize(uniform.glDefineType);
if (type != nullptr && type->isMatrix()) {
slotSize = static_cast<SizeT>(type->getMatrixCols()) * 16u;
}
slotSize = (slotSize + 15u) & ~static_cast<SizeT>(15u);
const SizeT slotOffset = (artifacts.globalUboScratch.size() + 15u) & ~static_cast<SizeT>(15u);
artifacts.globalUboScratch.resize(slotOffset + slotSize, 0);
artifacts.uniformOffsets[location] = static_cast<Uint>(slotOffset);
artifacts.uniformSizesInBytes[location] = slotSize;
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' location %u has no UBO backing in the "
"generated SPIR-V (optimized out?); allocated %zu fallback bytes at scratch offset %zu",
in.externalIndex, uniform.name.c_str(), location, slotSize, slotOffset);
}
}
Bool ProgramLinkTask::ValidateFragmentOutputLocations() { Bool ProgramLinkTask::ValidateFragmentOutputLocations() {
if (!artifacts.program) return false; if (!artifacts.program) return false;
@@ -1154,21 +1090,80 @@ namespace MobileGL::MG_State::GLState {
} }
} }
} }
// GL 4.6 core 11.1.2.1 (and the resource-name rule of 7.3.1.1): a member of
// an output interface block is named "<BLOCK name>.<member>" - the block's
// TYPE name, never the instance name, and that holds for an anonymous
// instance too. glslang's linker object for such a block is the *instance*
// symbol ("vs_out", or "anon@N" when there is none), so the head of the
// dotted path has to be matched against getType().getTypeName() instead of
// getName(). Without this every capture of a block member resolved to
// nothing and the link failed with "is not an output of the vertex stage".
String blockName;
String memberName;
if (const SizeT dot = declaredName.find('.'); dot != String::npos) {
blockName = declaredName.substr(0, dot);
memberName = declaredName.substr(dot + 1);
// An array of block instances is spelled "<block>[i].<member>"; every
// instance shares one member list, so the subscript only has to go.
if (!blockName.empty() && blockName.back() == ']') {
const SizeT bracket = blockName.rfind('[');
if (bracket != String::npos) blockName.resize(bracket);
}
}
for (const auto* node : linkerObjects->getSequence()) { for (const auto* node : linkerObjects->getSequence()) {
const glslang::TIntermSymbol* symbol = node->getAsSymbolNode(); const glslang::TIntermSymbol* symbol = node->getAsSymbolNode();
if (symbol == nullptr || symbol->getType().getQualifier().storage != glslang::EvqVaryingOut) { if (symbol == nullptr || symbol->getType().getQualifier().storage != glslang::EvqVaryingOut) {
continue; continue;
} }
if (symbol->getName() != declaredName.c_str()) { const glslang::TType& symbolType = symbol->getType();
continue; const glslang::TType* capturedType = nullptr;
if (memberName.empty()) {
if (symbol->getName() != declaredName.c_str()) {
continue;
}
capturedType = &symbolType;
} else {
if (symbolType.getBasicType() != glslang::EbtBlock) {
continue;
}
// The spec spelling is the block name; the instance name is accepted
// as a fallback so a request written the (common, non-conformant)
// instance-qualified way resolves instead of failing the whole link.
if (symbolType.getTypeName() != blockName.c_str() &&
symbol->getName() != blockName.c_str()) {
continue;
}
const glslang::TTypeList* members = symbolType.getStruct();
if (members == nullptr) {
continue;
}
for (SizeT m = 0; m < members->size(); ++m) {
const glslang::TType* memberType = (*members)[m].type;
if (memberType == nullptr || memberType->getFieldName() != memberName.c_str()) {
continue;
}
capturedType = memberType;
varying.blockMemberIndex = static_cast<Int>(m);
break;
}
if (capturedType == nullptr) {
// Right block, wrong member: no other linker object can match.
break;
}
varying.blockName = symbolType.getTypeName().c_str();
varying.blockInstanceName = symbol->getName().c_str();
} }
resolved = ResolveXfbSymbolType(symbol->getType(), varying.type, varying.size, bytesPerElement); resolved = ResolveXfbSymbolType(*capturedType, varying.type, varying.size, bytesPerElement);
if (resolved && singleElement) { if (resolved && singleElement) {
if (static_cast<Int>(element) >= varying.size) { if (static_cast<Int>(element) >= varying.size) {
resolved = false; resolved = false;
break; break;
} }
varying.size = 1; varying.size = 1;
if (varying.blockMemberIndex >= 0) {
varying.blockMemberElement = static_cast<Int>(element);
}
} }
break; break;
} }
@@ -29,10 +29,16 @@ namespace MobileGL::MG_State::GLState {
SharedPtr<const ShaderCompileTask> compiled; SharedPtr<const ShaderCompileTask> compiled;
}; };
// The unit of asynchronous linking: one glLinkProgram's worth of pure CPU work - glslang // PHASE A of one glLinkProgram: the half that decides what GL can be asked about the
// link + mapIO, SPIR-V generation and optimization, the GL-facing reflection surface, the // program - glslang link + mapIO, the GL-facing reflection surface, fragment-output
// global-UBO routing tables, fragment-output validation and transform-feedback // validation and transform-feedback resolution - with every input it needs snapshotted at
// resolution - with every input it needs snapshotted at enqueue. // enqueue.
//
// Every one of the eight ways a link can fail lives here, so once this node has published
// through EnsureLinkJoined() the program's LINK_STATUS, info log and entire query surface
// are FINAL and truthful. SPIR-V generation, spirv-opt and the global-UBO routing tables
// moved to ProgramSpirvTask, which chains behind this node and is joined by only five
// getters (see ProgramObject::EnsureSpirvJoined).
// //
// Same ownership rule as ShaderCompileTask: the body reads nothing but `in` (all of it // Same ownership rule as ShaderCompileTask: the body reads nothing but `in` (all of it
// owned or immutable) and writes nothing but `artifacts`. No GL call, no // owned or immutable) and writes nothing but `artifacts`. No GL call, no
@@ -40,11 +46,13 @@ namespace MobileGL::MG_State::GLState {
// through the CompileEnv snapshot and diagnostics are deferred to the join. // through the CompileEnv snapshot and diagnostics are deferred to the join.
// //
// ONE LINK IS ONE HANDLER. RunBody() runs start to finish inside a single pool handler // ONE LINK IS ONE HANDLER. RunBody() runs start to finish inside a single pool handler
// and is the only place `artifacts` is written. Do not split it across handlers to // and is the only place `artifacts` is written. Splitting it across handlers to
// "pipeline" the reflection half: the intermediates that GlslangToSpv and buildReflection // "pipeline" the reflection half would let a cancel land between the halves and publish a
// share are mutated in a strict order (see the GenerateSpirv-before-DoReflection comment // program whose SPIR-V and reflection describe different things - so any such split has
// in Run()), and a second handler would let a cancel land between them and publish a // to be structural: the first half must publish a LINK_STATUS and a query surface that
// program whose SPIR-V and reflection describe different things. // are already final, and a lost second half must degrade to "linked but not drawable",
// never to a half-published program. (The intermediates' ordering constraint that used to
// be quoted here is retested and no longer binding; see the ordering note in RunBody.)
class ProgramLinkTask final : public MG_Util::Async::JobNode { class ProgramLinkTask final : public MG_Util::Async::JobNode {
public: public:
// ---- inputs, snapshotted on the GL thread in ProgramObject::Link()'s prologue ---- // ---- inputs, snapshotted on the GL thread in ProgramObject::Link()'s prologue ----
@@ -68,6 +76,49 @@ namespace MobileGL::MG_State::GLState {
// Moved (never copied) into the ProgramObject by EnsureLinkJoined(). // Moved (never copied) into the ProgramObject by EnsureLinkJoined().
ProgramObject::LinkArtifacts artifacts; ProgramObject::LinkArtifacts artifacts;
// ---- output: everything ProgramSpirvTask needs to run without this node's
// artifacts, filled at the tail of a successful RunBody() ----
//
// THIS IS NOT `artifacts` AND MUST NOT BE MERGED INTO IT. The GL thread MOVES
// `artifacts` out of this node at the join, and phase B runs on a worker afterwards -
// so phase B may read `spirvHandoff` and `in` (neither is ever touched by the join)
// and this node's JobState, and nothing else on it. Reading `artifacts` or
// `diagnostics` from phase B would race the publish.
struct SpirvHandoff {
// MANDATORY, and the reason this struct exists at all: TProgram::addShader stores
// a RAW TShader*, and for the one-shader-per-stage case getIntermediate() returns
// the TShader's own intermediate rather than a copy. These used to die when
// RunBody() returned, which was safe only because nothing called getIntermediate()
// afterwards. GlslangToSpv does exactly that, so phase B has to own them.
//
// MEMORY NOTE: this is the one thing the split makes live LONGER than it used to -
// a glslang arena per stage, megabytes for a shaderpack, now alive from the end of
// phase A until phase B runs instead of dying with the link body, so a deep
// phase-B backlog holds one arena per queued program. Phase B clears this vector
// as soon as GlslangToSpv returns, but read that call site's comment before
// relying on it: for the COMMON case (a shader linked into exactly one program)
// the compile node co-owns the same TShader and phase A pins that node, so the
// clear frees nothing and only the re-parsed CAS-loser shaders are actually
// released. If peak RSS ever becomes the binding constraint on a pack load, THIS
// is the field to attack - by bounding the backlog, by releasing the compile
// node's own reference at claim time, or by moving GlslangToSpv back into phase A.
Vector<SharedPtr<glslang::TShader>> shaders;
// GL enum per entry of `in.shaders`, in the same order (GetSpirvBinaryFromProgram
// walks it to pick the intermediates).
Vector<GLenum> shaderTypes;
// The reflection slice BuildGlobalUboRouting consumes: {program, uniformLocations,
// uniformIndexInTProgram, tProgramUniformIndexToGl, maxUniformLocation}. Carried
// as a LinkArtifacts with only those five fields set, so the routing pass can keep
// calling ProgramObject::IsValidUniformLocation / GetUniformArraySizeByTIndex
// unchanged. The SharedPtr copy of `program` is also what keeps the TProgram alive
// for phase B after the join has moved `artifacts` away.
ProgramObject::LinkArtifacts reflection;
// The one flag phase B tests before doing anything: false means this link never
// reached the tail of RunBody (it failed, or was cancelled mid-body).
Bool ready = false;
} spirvHandoff;
// Posts this job once every compile in `deps` is terminal - and not one moment // Posts this job once every compile in `deps` is terminal - and not one moment
// earlier, so the body never waits on anything (invariant I4: no job body may block // earlier, so the body never waits on anything (invariant I4: no job body may block
// on another job, or the pool could deadlock with all its workers waiting on each // on another job, or the pool could deadlock with all its workers waiting on each
@@ -94,8 +145,6 @@ namespace MobileGL::MG_State::GLState {
Bool ValidateFragmentOutputLocations(); Bool ValidateFragmentOutputLocations();
Bool ResolveTransformFeedbackVaryings(); Bool ResolveTransformFeedbackVaryings();
void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate); void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate);
void GenerateSpirv();
void BuildGlobalUboRouting();
// Worker-side MGLOG replacement: appended to diagnostics.logLines and replayed by the // Worker-side MGLOG replacement: appended to diagnostics.logLines and replayed by the
// join, on the GL thread, where a serial implementation would have printed it. // join, on the GL thread, where a serial implementation would have printed it.
@@ -8,7 +8,9 @@
#include "ProgramObject.h" #include "ProgramObject.h"
#include "ProgramLinkTask.h" #include "ProgramLinkTask.h"
#include "ProgramSpirvTask.h"
#include <atomic> #include <atomic>
#include <cstring>
#include <MG_Util/Async/ShaderCompilePool.h> #include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h> #include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h> #include <MG_Util/ShaderTranspiler/CompileEnv.h>
@@ -68,12 +70,219 @@ namespace MobileGL::MG_State::GLState {
Bool ProgramObject::IsPendingLinkTerminal() const { return m_pendingLink->IsTerminal(); } Bool ProgramObject::IsPendingLinkTerminal() const { return m_pendingLink->IsTerminal(); }
Bool ProgramObject::IsPendingSpirvTerminal() const { return m_pendingSpirv->IsTerminal(); }
void ProgramObject::JoinPendingSpirv() const {
MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(),
"ProgramObject::EnsureSpirvJoined() reached from a pool thread; a job body must never read "
"GL-thread-owned objects");
// Move the node out FIRST, for the same reason JoinPendingLink does: everything below
// runs GL-thread-only code that reads program state, and with m_pendingSpirv still set
// that would re-enter this function.
const SharedPtr<ProgramSpirvTask> pending = Move(m_pendingSpirv);
m_pendingSpirv.reset();
pending->Wait();
if (pending->IsComplete()) {
m_spirv = Move(pending->artifacts);
}
// A node that settled as Cancelled published nothing, so m_spirv stays empty with
// spirvStatus false: linked, queryable, not drawable. Nothing to repair.
// 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
// it is mandatory for exactly the reason the phase-A one is (see JoinPendingLink): a
// backend memo taken during the A->B window - when the program was already answering
// as linked but had no SPIR-V and no uniform shadow - must not survive the arrival of
// either. The memos at risk are keyed on (lifetimeId, backendStateVersion).
BumpLinkObservableVersions();
MG_Util::Async::ApplyDeferredDiagnostics(*pending);
}
Bool ProgramObject::BufferUniformWrite(const Uint location, const SizeT byteOffsetInUniform, const void* source,
const SizeT byteSize) {
if (source == nullptr || byteSize == 0) return true; // nothing to record, nothing to join for
if (m_pendingUniformBytes.size() + byteSize > kMaxBufferedUniformBytes) {
// Pressure valve: stop growing and let the caller take the join. Say so once per
// program, because the interesting fact is WHICH program did it.
MGLOG_D("ProgramObject %u: buffered uniform writes exceeded %zu bytes during the SPIR-V window; the "
"write joins instead",
m_externalIndex, kMaxBufferedUniformBytes);
return false;
}
const SizeT dataOffset = m_pendingUniformBytes.size();
m_pendingUniformBytes.resize(dataOffset + byteSize);
std::memcpy(m_pendingUniformBytes.data() + dataOffset, source, byteSize);
m_pendingUniformWrites.push_back(PendingUniformWrite{.location = location,
.byteOffsetInUniform =
static_cast<Uint>(byteOffsetInUniform),
.byteSize = static_cast<Uint>(byteSize),
.dataOffset = static_cast<Uint>(dataOffset)});
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;
const Bool isFloat = init.basicType == glslang::EbtFloat || init.basicType == glslang::EbtFloat16;
const Bool isInt = init.basicType == glslang::EbtInt || init.basicType == glslang::EbtUint ||
init.basicType == glslang::EbtBool;
// Anything else (fp64, 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();
return;
}
// Drain into locals first: MarkUBOContentDirty below is a plain counter bump, but a
// future reader of this function should not be able to observe a half-drained buffer.
Vector<PendingUniformWrite> writes;
Vector<Uint8> bytes;
writes.swap(m_pendingUniformWrites);
bytes.swap(m_pendingUniformBytes);
if (m_spirv.globalUboScratch.empty() || m_spirv.uniformOffsets.empty()) {
// Phase B produced nothing (cancelled at teardown, or a relink superseded it).
// The program is not drawable, so there is nowhere for these to land and nothing
// that could observe them.
MGLOG_D("ProgramObject %u: dropping %zu buffered uniform write(s); the SPIR-V job published no shadow",
m_externalIndex, writes.size());
return;
}
Uint8* const scratch = m_spirv.globalUboScratch.data();
const SizeT uboSize = m_spirv.globalUboScratch.size();
for (const PendingUniformWrite& write : writes) {
if (write.location >= m_spirv.uniformOffsets.size()) continue;
const Uint offset = m_spirv.uniformOffsets[write.location];
if (offset == kInvalidUniformOffset ||
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 "
"(offset=%u size=%u uboSize=%zu); dropping write",
m_externalIndex, write.location, offset, write.byteSize, uboSize);
continue;
}
Uint8* const destination = scratch + offset + write.byteOffsetInUniform;
const Uint8* const sourceBytes = bytes.data() + write.dataOffset;
// The same bytes-equal dedupe the live path applies, per record and in order, so
// the "an identical write does not move the content version" property survives
// the detour byte for byte.
if (std::memcmp(destination, sourceBytes, write.byteSize) == 0) continue;
std::memcpy(destination, sourceBytes, write.byteSize);
MarkUBOContentDirty();
}
}
void ProgramObject::CancelLink() { void ProgramObject::CancelLink() {
// Phase B first: it is chained behind phase A, so cancelling A would otherwise run A's
// continuation and post a node this call is about to abandon anyway. Cancelling it up
// front makes that continuation a no-op.
//
// Cooperative and non-blocking, both of them. A node that no worker has picked up
// settles immediately; one that is running is flagged and settles when its body
// returns, writing only into itself the whole time. Either way nothing waits, and each
// node keeps its own inputs alive for as long as it needs them.
if (m_pendingSpirv) {
m_pendingSpirv->Cancel();
m_pendingSpirv.reset();
// Buffered writes belong to the link that is being abandoned. A relink resets
// every uniform to its initial value anyway (GL 4.6 core 7.6), and the other two
// callers are destruction and glProgramBinary's mandated failure, so there is
// nothing left that could want them.
m_pendingUniformWrites.clear();
m_pendingUniformBytes.clear();
}
if (!m_pendingLink) return; if (!m_pendingLink) return;
// Cooperative and non-blocking. A node that no worker has picked up settles
// immediately; one that is running is flagged and settles when its body returns,
// writing only into itself the whole time. Either way nothing waits, and the node
// keeps its own inputs alive for as long as it needs them.
m_pendingLink->Cancel(); m_pendingLink->Cancel();
m_pendingLink.reset(); m_pendingLink.reset();
} }
@@ -103,23 +312,30 @@ namespace MobileGL::MG_State::GLState {
// function has ever cleared, and its callers depend on that (they write infoLog // function has ever cleared, and its callers depend on that (they write infoLog
// immediately AFTER calling here). Link()'s prologue does not use this - it assigns a // immediately AFTER calling here). Link()'s prologue does not use this - it assigns a
// whole default-constructed block, where the ordering is explicit. // whole default-constructed block, where the ordering is explicit.
// Phase-B output (generatedSpirv / uniformOffsets / globalUboScratch) is NOT cleared
// here and is not in LinkArtifacts at all: the link body calls this on its own block,
// where no phase-B output exists yet. The two GL-thread callers that also have to
// discard phase-B output say so themselves (MarkLinkFailedByProgramBinary clears
// m_spirv; Link()'s prologue assigns a fresh one).
artifacts.program.reset(); artifacts.program.reset();
artifacts.generatedSpirv.clear();
artifacts.uniformLocations.clear(); artifacts.uniformLocations.clear();
artifacts.glUniformIndexToTProgram.clear(); artifacts.glUniformIndexToTProgram.clear();
artifacts.tProgramUniformIndexToGl.clear(); artifacts.tProgramUniformIndexToGl.clear();
artifacts.glBlockIndexToTProgram.clear(); artifacts.glBlockIndexToTProgram.clear();
artifacts.tProgramBlockIndexToGl.clear(); artifacts.tProgramBlockIndexToGl.clear();
artifacts.linkedExplicitUniformLocations.clear(); artifacts.linkedExplicitUniformLocations.clear();
artifacts.uniformInitialValues.clear();
artifacts.uniformIndexInTProgram.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.uniformSamplerOrImageUnitIndex.clear();
artifacts.explicitOpaqueUniformBindings.clear(); artifacts.explicitOpaqueUniformBindings.clear();
artifacts.uniformBlockIndexByName.clear(); artifacts.uniformBlockIndexByName.clear();
artifacts.uniformBlockBinding.clear(); artifacts.uniformBlockBinding.clear();
artifacts.shaderStorageBlockBinding.clear(); artifacts.shaderStorageBlockBinding.clear();
artifacts.uniformOffsets.clear();
artifacts.uniformSizesInBytes.clear();
artifacts.globalUboScratch.clear();
artifacts.attribs.clear(); artifacts.attribs.clear();
artifacts.attribTypes.clear(); artifacts.attribTypes.clear();
artifacts.activeUniformCount = 0; artifacts.activeUniformCount = 0;
@@ -238,6 +454,7 @@ namespace MobileGL::MG_State::GLState {
// is what every gated reader sees, so it has to be the complete "not linked" state - // is what every gated reader sees, so it has to be the complete "not linked" state -
// including the fields ResetLinkArtifacts deliberately preserves for its own callers. // including the fields ResetLinkArtifacts deliberately preserves for its own callers.
m_artifacts = {}; m_artifacts = {};
m_spirv = {};
// ---- GL-thread-owned mutations ---- // ---- GL-thread-owned mutations ----
// Remove detached shaders first // Remove detached shaders first
@@ -292,17 +509,33 @@ namespace MobileGL::MG_State::GLState {
task->in.shaders.push_back({shader->GetShaderStage(), shader->GetShaderSourcePtr(), node}); task->in.shaders.push_back({shader->GetShaderStage(), shader->GetShaderSourcePtr(), node});
} }
// Phase B of the same link: SPIR-V generation, spirv-opt and the global-UBO routing
// tables. Created here, alongside phase A, so that from this instant the program has
// BOTH pending nodes and every cancel site (this prologue, ~ProgramObject,
// glProgramBinary's failure) drops both through the one CancelLink().
auto spirvTask = MakeShared<ProgramSpirvTask>();
m_pendingLink = task; m_pendingLink = task;
m_pendingSpirv = spirvTask;
// Flag off - or glMaxShaderCompilerThreadsKHR(0), see AsyncShaderCompileActive(): // Flag off - or glMaxShaderCompilerThreadsKHR(0), see AsyncShaderCompileActive():
// byte-identical to the synchronous implementation. RunInline() executes the same // byte-identical to the synchronous implementation. RunInline() executes the same
// body on this thread and the join below publishes through the same code, so the two // bodies on this thread, in the same order, and the join below publishes through the
// modes differ only in WHICH thread ran RunBody(). // same code, so the two modes differ only in WHICH thread ran them.
//
// Deliberately NOT expressed as SubmitAfter here: its continuation posts to the pool,
// and in this mode the pool is merely unused rather than stopped - the work would
// silently move off-thread in the one mode whose whole contract is that it does not.
if (!MG_Util::Async::AsyncShaderCompileActive()) { if (!MG_Util::Async::AsyncShaderCompileActive()) {
task->RunInline(); task->RunInline();
EnsureLinkJoined(); spirvTask->RunInlineAfter(task);
EnsureSpirvJoined();
return; return;
} }
// The chain edge FIRST, while phase A is still Pending, so registering it is a plain
// list append rather than an inline continuation on this thread. If SubmitAfter below
// then fails to post phase A it cancels it, and that cancel fires this edge, which
// cancels phase B - nothing is left stranded either way.
spirvTask->SubmitAfter(task);
task->SubmitAfter(deps); task->SubmitAfter(deps);
} }
@@ -18,6 +18,9 @@ namespace MobileGL::MG_State::GLState {
// ProgramLinkTask.h includes THIS header (it outputs a LinkArtifacts), so including it // ProgramLinkTask.h includes THIS header (it outputs a LinkArtifacts), so including it
// back would be circular. The destructor is therefore out of line. // back would be circular. The destructor is therefore out of line.
class ProgramLinkTask; class ProgramLinkTask;
// Phase B of the same link: SPIR-V generation, spirv-opt and the global-UBO routing
// tables. Chained behind the ProgramLinkTask, forward-declared for the same reason.
class ProgramSpirvTask;
class ProgramObject { class ProgramObject {
public: public:
@@ -303,8 +306,133 @@ namespace MobileGL::MG_State::GLState {
// Sentinel for a uniform location without global-UBO backing storage (should not // Sentinel for a uniform location without global-UBO backing storage (should not
// survive linking: GenerateBinary falls back to tail-allocated scratch storage). // survive linking: GenerateBinary falls back to tail-allocated scratch storage).
static constexpr Uint kInvalidUniformOffset = ~0u; static constexpr Uint kInvalidUniformOffset = ~0u;
Uint GetUniformOffset(Uint location) const { return Artifacts().uniformOffsets[location]; } // PHASE B (joins the SPIR-V job; see EnsureSpirvJoined).
//
// BOUNDS-CHECKED, and that is not defensive padding - it is the load-bearing half of
// the "linked but not drawable" contract. A phase B that settles CANCELLED rather than
// Complete (its body threw, the pool failed to enqueue it, or teardown cancelled it
// while phase A had already published) publishes nothing, so the shadow is a
// default-constructed SpirvArtifacts with an EMPTY uniformOffsets - while LINK_STATUS
// stays GL_TRUE, because GL gives no way to retract one, and IsValidUniformLocation()
// keeps answering true out of phase-A reflection. Every glUniform*/glGetUniform* call
// site reaches this getter BEFORE its own kInvalidUniformOffset / null-scratch guard,
// so an unchecked operator[] here would be a null dereference on the query surface
// this design promises stays answerable. Reporting kInvalidUniformOffset instead hands
// each of those sites exactly the value their existing guard already handles - the
// same value the routing pass itself uses for a uniform the optimizer deleted.
Uint GetUniformOffset(Uint location) const {
const SpirvArtifacts& spirv = Spirv();
return location < spirv.uniformOffsets.size() ? spirv.uniformOffsets[location]
: kInvalidUniformOffset;
}
Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); } 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:
// std140 pads each column of a float matrix out to a vec4, so a mat3 spans 48 bytes
// even though only 36 of them carry components. 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() && type->getBasicType() != glslang::EbtDouble) {
return static_cast<SizeT>(type->getMatrixCols()) * 4 * sizeof(Float);
}
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) { Int GetAttributeLocation(const String& name) {
const auto it = std::find(Artifacts().attribs.begin(), Artifacts().attribs.end(), name); const auto it = std::find(Artifacts().attribs.begin(), Artifacts().attribs.end(), name);
@@ -381,9 +509,14 @@ namespace MobileGL::MG_State::GLState {
const String& GetActiveAttribName(Uint index) const { const String& GetActiveAttribName(Uint index) const {
return NormalizeBuiltinPipeInputName(Artifacts().program->getPipeInput(static_cast<Int>(index)).name); return NormalizeBuiltinPipeInputName(Artifacts().program->getPipeInput(static_cast<Int>(index)).name);
} }
void* MapUBO() { return Artifacts().globalUboScratch.data(); } // PHASE B, all three (see EnsureSpirvJoined): the shadow buffer's layout is decided
const void* GetUBOData() const { return Artifacts().globalUboScratch.data(); } // by the OPTIMIZED SPIR-V, so it does not exist until the SPIR-V job has settled - and
Uint GetUBOSize() const { return static_cast<Uint>(Artifacts().globalUboScratch.size()); } // never exists at all for a program whose SPIR-V job settled cancelled. These three
// degrade to nullptr/nullptr/0 in that case, which is exactly the "no backing storage"
// shape every caller already tests for (see GetUniformOffset's note).
void* MapUBO() { return Spirv().globalUboScratch.data(); }
const void* GetUBOData() const { return Spirv().globalUboScratch.data(); }
Uint GetUBOSize() const { return static_cast<Uint>(Spirv().globalUboScratch.size()); }
// Content version of the CPU-side global-UBO shadow: writers bump it so backends // Content version of the CPU-side global-UBO shadow: writers bump it so backends
// can skip re-uploading an unchanged UBO on every draw. ~0u is reserved as the // can skip re-uploading an unchanged UBO on every draw. ~0u is reserved as the
// backends' "never uploaded" sentinel, so skip over it on wrap. // backends' "never uploaded" sentinel, so skip over it on wrap.
@@ -391,6 +524,25 @@ namespace MobileGL::MG_State::GLState {
void MarkUBOContentDirty() const { void MarkUBOContentDirty() const {
if (++m_uboContentVersion == ~0u) m_uboContentVersion = 0; if (++m_uboContentVersion == ~0u) m_uboContentVersion = 0;
} }
// ---- glUniform* inside the phase-A -> phase-B window ----
//
// True while the program is fully linked and fully queryable but its uniform shadow's
// LAYOUT (which the optimized SPIR-V decides) does not exist yet. A non-opaque
// glUniform* write in that window is RECORDED rather than joined, and replayed into
// the shadow at the phase-B publish - so a pack that sets its uniforms immediately
// after glLinkProgram never waits for SPIR-V.
//
// Nothing can observe the difference: the only route to those bytes is glGetUniform*
// (and a draw), and both of those go through the phase-B gate, which replays first.
// The OPAQUE branch of glUniform* is deliberately not buffered - a sampler unit is
// phase-A state (uniformSamplerOrImageUnitIndex), so glUniform1i(samplerLoc, unit)
// right after a link stays a zero-join operation, which is exactly what Iris does.
Bool IsSpirvPending() const { return m_pendingSpirv != nullptr; }
// Records one write. Returns false if it declined to buffer - the caller must then
// perform the write directly (which joins). Declining is the pressure valve for an
// application that writes megabytes of uniforms into a single pending window.
Bool BufferUniformWrite(Uint location, SizeT byteOffsetInUniform, const void* source, SizeT byteSize);
Uint32 GetBackendStateVersion() const { return m_backendStateVersion; } Uint32 GetBackendStateVersion() const { return m_backendStateVersion; }
// Bumped only by (re)linking — lets backends detect that every piece of // Bumped only by (re)linking — lets backends detect that every piece of
// link-derived reflection (locations, block order, UBO layout) is stale. // link-derived reflection (locations, block order, UBO layout) is stale.
@@ -429,14 +581,35 @@ namespace MobileGL::MG_State::GLState {
} }
void SetUniformSamplerOrImageUnitIndex(Uint location, Int unit) { void SetUniformSamplerOrImageUnitIndex(Uint location, Int unit) {
if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size() || if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size()) return;
Artifacts().uniformSamplerOrImageUnitIndex[location] == unit) { // BEFORE the equality bail-out, not after: "written" is about the application
return; // 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; Artifacts().uniformSamplerOrImageUnitIndex[location] = unit;
++m_backendStateVersion; ++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 { Int GetUniformSamplerOrImageUnitIndex(Uint location) const {
return Artifacts().uniformSamplerOrImageUnitIndex[location]; return Artifacts().uniformSamplerOrImageUnitIndex[location];
} }
@@ -452,7 +625,32 @@ namespace MobileGL::MG_State::GLState {
// subset of the stages of a program pipeline. Only takes effect on the next link, // 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. // which is why it is plain state here rather than something Link() consults.
Bool GetSeparable() const { return m_separable; } 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 // glProgramBinary always fails here (there is no format it could accept) and the
// spec then requires the program's LINK_STATUS to read FALSE. // spec then requires the program's LINK_STATUS to read FALSE.
void MarkLinkFailedByProgramBinary() { void MarkLinkFailedByProgramBinary() {
@@ -463,15 +661,35 @@ namespace MobileGL::MG_State::GLState {
CancelLink(); CancelLink();
BumpLinkObservableVersions(); BumpLinkObservableVersions();
ResetLinkArtifacts(Artifacts()); ResetLinkArtifacts(Artifacts());
// ResetLinkArtifacts is a LinkArtifacts-only operation (the link body calls it on
// its own block, where no phase-B output exists yet), so the phase-B half is
// cleared here. CancelLink() above already dropped the pending SPIR-V job, so
// this cannot be racing a publish.
m_spirv = {};
Artifacts().infoLog = "No program binary format is supported."; Artifacts().infoLog = "No program binary format is supported.";
} }
Bool GetValidateStatus() const { return m_validateStatus; } Bool GetValidateStatus() const { return m_validateStatus; }
Int GetActiveAtomicCounterCount() const { return Artifacts().program->getNumAtomicCounters(); } // Artifacts().program is null until a link produces reflection, and glGetProgramiv is
Int GetActiveAttributesCount() const { return Artifacts().program->getNumPipeInputs(); } // perfectly legal on a program that never linked (GL 4.6 sec. 7.3: the queried state is
// simply its initial value, zero). Dereferencing it there took the process down with a
// SIGSEGV inside glslang::TProgram::getNumPipeInputs - KHR-GL30.api.coverage does exactly
// this after a failed glGetAttribLocation, and reached it as soon as the CopyTexImage2D
// throw ahead of it stopped killing the run first.
Int GetActiveAtomicCounterCount() const {
const auto& program = Artifacts().program;
return program ? program->getNumAtomicCounters() : 0;
}
Int GetActiveAttributesCount() const {
const auto& program = Artifacts().program;
return program ? program->getNumPipeInputs() : 0;
}
// GL-visible uniform blocks only: the synthesized MGL_GLOBAL_UBO the relaxed parse // GL-visible uniform blocks only: the synthesized MGL_GLOBAL_UBO the relaxed parse
// materializes for default-block uniforms is filtered out by DoReflection. // materializes for default-block uniforms is filtered out by DoReflection.
Int GetActiveUniformBlocksCount() const { return static_cast<Int>(Artifacts().glBlockIndexToTProgram.size()); } Int GetActiveUniformBlocksCount() const { return static_cast<Int>(Artifacts().glBlockIndexToTProgram.size()); }
GLuint GetComputeLocalSize(Uint dim) const { return Artifacts().program->getLocalSize(static_cast<Int>(dim)); } GLuint GetComputeLocalSize(Uint dim) const {
const auto& program = Artifacts().program;
return program ? program->getLocalSize(static_cast<Int>(dim)) : 0;
}
Int GetActiveAttributesMaxLength() const { return Artifacts().attribInNameMaxLength; } Int GetActiveAttributesMaxLength() const { return Artifacts().attribInNameMaxLength; }
Int GetActiveUniformBlocksMaxNameLength() const { return Artifacts().uniformBlockNameMaxLength; } Int GetActiveUniformBlocksMaxNameLength() const { return Artifacts().uniformBlockNameMaxLength; }
Uint GetUniformBlockIndex(const char* name) const { Uint GetUniformBlockIndex(const char* name) const {
@@ -534,13 +752,23 @@ namespace MobileGL::MG_State::GLState {
return (ubo.stages & stageMask) != 0; 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) { void SetUniformBlockBinding(Uint index, Uint binding) {
if (index >= Artifacts().uniformBlockBinding.size() || Artifacts().uniformBlockBinding[index] == static_cast<Int>(binding)) { if (index >= Artifacts().uniformBlockBinding.size() || Artifacts().uniformBlockBinding[index] == static_cast<Int>(binding)) {
return; return;
} }
Artifacts().uniformBlockBinding[index] = static_cast<Int>(binding); Artifacts().uniformBlockBinding[index] = static_cast<Int>(binding);
++m_backendStateVersion; ++m_backendStateVersion;
++m_blockBindingVersion;
} }
Uint GetUniformBlockBinding(Uint index) const { return Artifacts().uniformBlockBinding[index]; } Uint GetUniformBlockBinding(Uint index) const { return Artifacts().uniformBlockBinding[index]; }
@@ -552,6 +780,10 @@ namespace MobileGL::MG_State::GLState {
// means "never rebound", and the shader's declared binding still stands. // means "never rebound", and the shader's declared binding still stands.
void SetShaderStorageBlockBinding(const String& blockName, Uint binding) { void SetShaderStorageBlockBinding(const String& blockName, Uint binding) {
Artifacts().shaderStorageBlockBinding[blockName] = static_cast<Int>(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 // -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 // spelling; an arrayed block's elements ("B[0]", "B[1]") are separate GL resources
@@ -571,8 +803,15 @@ namespace MobileGL::MG_State::GLState {
return Artifacts().shaderStorageBlockBinding; return Artifacts().shaderStorageBlockBinding;
} }
Vector<Vector<unsigned>>& GetGeneratedSpirv() { return Artifacts().generatedSpirv; } // PHASE B (see EnsureSpirvJoined). Empty for a program whose SPIR-V job was
const Vector<Vector<unsigned>>& GetGeneratedSpirv() const { return Artifacts().generatedSpirv; } // cancelled; GetSpirvStatus() below is how a backend tells that apart from a program
// that never linked.
Vector<Vector<unsigned>>& GetGeneratedSpirv() { return Spirv().generatedSpirv; }
const Vector<Vector<unsigned>>& GetGeneratedSpirv() const { return Spirv().generatedSpirv; }
// Whether phase B produced usable SPIR-V. Joins, like the four getters above: a
// backend asks this exactly where it used to ask GetLinkStatus(), i.e. right before
// it builds or draws with the program.
Bool GetSpirvStatus() const { return Spirv().spirvStatus; }
// The linked glslang reflection itself, for the ONE consumer that needs resource // The linked glslang reflection itself, for the ONE consumer that needs resource
// lists no typed getter above exposes: the GL program-interface query layer // lists no typed getter above exposes: the GL program-interface query layer
@@ -600,6 +839,21 @@ namespace MobileGL::MG_State::GLState {
// Offset within the gap-free record a backend that cannot express the GL // Offset within the gap-free record a backend that cannot express the GL
// layout captures into; see NeedsScatteredTransformFeedbackCapture. // layout captures into; see NeedsScatteredTransformFeedbackCapture.
Uint32 packedOffsetBytes = 0; Uint32 packedOffsetBytes = 0;
// GL 4.6 core 11.1.2.1 / 7.3.1.1: a member of an output interface block is
// captured under "<block name>.<member>". `name` keeps that GL spelling (it is
// what the interface queries and the ESSL backend's driver-side capture list
// need, since SPIRV-Cross re-emits the block under its own type name), while
// the three fields below carry what a SPIR-V backend needs instead: the
// decoration target is the block's *instance* variable and the member index
// inside it. blockMemberIndex < 0 means "not a block member".
String blockInstanceName;
String blockName;
Int blockMemberIndex = -1;
// Which element of an arrayed block member this capture names, -1 for "the
// member as a whole". SPIR-V cannot decorate a single array element, so a
// backend needs the element index to tell a full run from a partial one.
Int blockMemberElement = -1;
}; };
// ---- P1: everything a link PRODUCES, in one movable block ---- // ---- P1: everything a link PRODUCES, in one movable block ----
@@ -620,7 +874,6 @@ namespace MobileGL::MG_State::GLState {
// without going through the gate. // without going through the gate.
struct LinkArtifacts { struct LinkArtifacts {
SharedPtr<glslang::TProgram> program; SharedPtr<glslang::TProgram> program;
Vector<Vector<unsigned>> generatedSpirv;
// Attributes (Vertex in) // Attributes (Vertex in)
Vector<String> attribs; Vector<String> attribs;
@@ -642,7 +895,24 @@ namespace MobileGL::MG_State::GLState {
// layout(location = N) default-block uniform qualifiers (the relaxed parse drops // layout(location = N) default-block uniform qualifiers (the relaxed parse drops
// them from reflection; the DoReflection assigner restores them from here). // them from reflection; the DoReflection assigner restores them from here).
UnorderedMap<String, Int> linkedExplicitUniformLocations; 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; 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, // Ordered by location,
// aka. uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`" // aka. uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`"
Vector<Int> uniformIndexInTProgram; Vector<Int> uniformIndexInTProgram;
@@ -664,11 +934,6 @@ namespace MobileGL::MG_State::GLState {
// SetShaderStorageBlockBinding for why this one is by name and not by index. // SetShaderStorageBlockBinding for why this one is by name and not by index.
UnorderedMap<String, Int> shaderStorageBlockBinding; UnorderedMap<String, Int> shaderStorageBlockBinding;
// Need to be reflected after linking of SPIR-V binary
Vector<Uint> uniformOffsets;
Vector<Uint> uniformSizesInBytes;
Vector<Uint8> globalUboScratch;
Uint activeUniformCount = 0; Uint activeUniformCount = 0;
Uint maxUniformLocation = 0; Uint maxUniformLocation = 0;
Int uniformNameMaxLength = 0; Int uniformNameMaxLength = 0;
@@ -697,6 +962,35 @@ namespace MobileGL::MG_State::GLState {
Uint32 xfbPackedStride = 0; Uint32 xfbPackedStride = 0;
}; };
// ---- everything phase B of a link produces, in one movable block ----
//
// The membership rule is the same mechanical one LinkArtifacts uses: this is exactly
// what ProgramSpirvTask writes, which is what makes moving it THE publish. It is
// deliberately NOT part of LinkArtifacts, and that separation is what routes the five
// readers of SPIR-V-derived data through their own join gate by compiler rather than
// by review - m_spirv is private and Spirv() is the only spelling that reaches it.
//
// Why these three and nothing else: `generatedSpirv` has no GL-thread reader at all
// (every consumer is a backend draw/prepare path), and `uniformOffsets` +
// `globalUboScratch` are the ONLY things glUniform*/glGetUniform* need that are
// derived from the OPTIMIZED SPIR-V rather than from glslang reflection - spirv-opt
// runs in place and can delete a uniform, or the whole global UBO, so the offsets
// cannot be lifted out of glslang's reflection instead.
struct SpirvArtifacts {
Vector<Vector<unsigned>> generatedSpirv;
// Byte offset of each uniform location inside globalUboScratch, or
// kInvalidUniformOffset. Sized maxUniformLocation + 1 by the routing pass.
Vector<Uint> uniformOffsets;
Vector<Uint8> globalUboScratch;
// False for a program whose SPIR-V was never produced (phase B cancelled at
// teardown or by a relink) or whose optimizer run failed. GL has no way to
// retract a LINK_STATUS it already reported true, so such a program stays
// "linked" and every reflection answer it has given stays correct - it is simply
// not drawable, which the backends already express through their link-status
// gates.
Bool spirvStatus = false;
};
// ---- artifacts-only helpers, shared with ProgramLinkTask ---- // ---- artifacts-only helpers, shared with ProgramLinkTask ----
// Static and taking the block explicitly, because from stage 4 the link BODY needs // Static and taking the block explicitly, because from stage 4 the link BODY needs
// them while its artifacts still live on the job node, not on any ProgramObject. The // them while its artifacts still live on the job node, not on any ProgramObject. The
@@ -736,9 +1030,20 @@ namespace MobileGL::MG_State::GLState {
// Blocks until a pending link has published its artifacts. Public because a few call // Blocks until a pending link has published its artifacts. Public because a few call
// sites have to join without reading anything - see the explicit-join list (J1-J8) in // sites have to join without reading anything - see the explicit-join list (J1-J8) in
// the P1 design. GL thread only. // the P1 design. GL thread only.
//
// PHASE A ONLY. After this returns, LINK_STATUS and the whole GL query surface are
// final and truthful, but the SPIR-V and the uniform shadow may still be in flight.
void JoinLink() const { EnsureLinkJoined(); } void JoinLink() const { EnsureLinkJoined(); }
// Drops a link that is still in flight, without waiting for it. Called at the points // Both phases. The draw path uses this, and must: the backends sample lifetimeId /
// backendStateVersion / the UBO content version OUTSIDE the gate, so a draw that
// joined only phase A would sample a version, join phase B later inside the same draw
// (through GetGeneratedSpirv), and memoize under a version the phase-B publish had
// already superseded - the exact lost-invalidation hazard J1 exists to prevent.
void JoinLinkAndSpirv() const { EnsureSpirvJoined(); }
// Drops BOTH phases of a link that is still in flight, without waiting for either.
// Called at the points
// where the pending link's result stops being the answer to "what did this program // where the pending link's result stops being the answer to "what did this program
// link to": a re-link supersedes it, glProgramBinary must force LINK_STATUS false, // link to": a re-link supersedes it, glProgramBinary must force LINK_STATUS false,
// and a destroyed program has no observers left. // and a destroyed program has no observers left.
@@ -757,7 +1062,15 @@ namespace MobileGL::MG_State::GLState {
// MUST NOT JOIN - this is what GL_COMPLETION_STATUS_KHR reads when the extension // MUST NOT JOIN - this is what GL_COMPLETION_STATUS_KHR reads when the extension
// surface lands. "No job at all" counts as complete: there is nothing outstanding to // surface lands. "No job at all" counts as complete: there is nothing outstanding to
// wait for. // wait for.
Bool IsLinkComplete() const { return m_pendingLink == nullptr || IsPendingLinkTerminal(); } //
// BOTH phases, deliberately: an application that polls GL_COMPLETION_STATUS_KHR and
// then draws must not be told "done" while the SPIR-V is still being generated, or
// the draw it was cleared for is the thing that blocks.
Bool IsLinkComplete() const { return IsPhaseALinkComplete() && IsSpirvComplete(); }
// Phase A alone, for the callers that only care about the query surface (and for the
// tests that pin the two phases apart).
Bool IsPhaseALinkComplete() const { return m_pendingLink == nullptr || IsPendingLinkTerminal(); }
Bool IsSpirvComplete() const { return m_pendingSpirv == nullptr || IsPendingSpirvTerminal(); }
void SetTransformFeedbackVaryings(Vector<String>&& names, GLenum bufferMode) { void SetTransformFeedbackVaryings(Vector<String>&& names, GLenum bufferMode) {
m_requestedXfbVaryings = Move(names); m_requestedXfbVaryings = Move(names);
@@ -824,6 +1137,44 @@ namespace MobileGL::MG_State::GLState {
// node's state goes through this out-of-line helper. // node's state goes through this out-of-line helper.
Bool IsPendingLinkTerminal() const; Bool IsPendingLinkTerminal() const;
// ---- the second join gate: phase-B (SPIR-V) output only ----
// Phase A FIRST, always. Two reasons: the phase-B publish replays the uniform writes
// that were buffered during its window, and those need the phase-A reflection to
// validate against; and a caller that reaches a phase-B getter without having settled
// phase A would otherwise leave the link half-published.
//
// Same inline/out-of-line split as the phase-A gate, for the same reason: the five
// getters behind this one include the per-draw uniform upload path.
void EnsureSpirvJoined() const {
if (m_pendingLink) JoinPendingLink();
if (m_pendingSpirv) JoinPendingSpirv();
}
void JoinPendingSpirv() const;
Bool IsPendingSpirvTerminal() const;
// One buffered non-opaque glUniform* write. `dataOffset` indexes m_pendingUniformBytes,
// which is one append-only blob rather than a per-record allocation.
struct PendingUniformWrite {
Uint location = 0;
Uint byteOffsetInUniform = 0;
Uint byteSize = 0;
Uint dataOffset = 0;
};
// Replays the buffer into the freshly published shadow, in write order, and drains it.
// Each record re-does the bounds check and the bytes-equal dedupe the live write path
// performs, so "an identical write does not move the content version" survives the
// 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.
static constexpr SizeT kMaxBufferedUniformBytes = 4u << 20;
LinkArtifacts& Artifacts() { LinkArtifacts& Artifacts() {
EnsureLinkJoined(); EnsureLinkJoined();
return m_artifacts; return m_artifacts;
@@ -832,6 +1183,14 @@ namespace MobileGL::MG_State::GLState {
EnsureLinkJoined(); EnsureLinkJoined();
return m_artifacts; return m_artifacts;
} }
SpirvArtifacts& Spirv() {
EnsureSpirvJoined();
return m_spirv;
}
const SpirvArtifacts& Spirv() const {
EnsureSpirvJoined();
return m_spirv;
}
// GL-thread-only companion to ResetLinkArtifacts (see its definition). Const because // GL-thread-only companion to ResetLinkArtifacts (see its definition). Const because
// the publish half of the join calls it; see the mutable counters below. // the publish half of the join calls it; see the mutable counters below.
@@ -869,11 +1228,22 @@ namespace MobileGL::MG_State::GLState {
Bool m_deleteStatus = false; Bool m_deleteStatus = false;
Bool m_binaryRetrievableHint = false; Bool m_binaryRetrievableHint = false;
Bool m_separable = 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; Bool m_validateStatus = true;
// Mutable, like m_artifacts and for the same reason: publishing a pending link is a // 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 // 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. // publish has to bump these. Still GL-thread-only - a worker never touches them.
mutable Uint32 m_backendStateVersion = 0; 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 // Backend-owned content-hash memo (see GetBackendHashMemo): valid only while
// m_backendStateVersion matches. Several slots, not one: a backend may resolve the same // m_backendStateVersion matches. Several slots, not one: a backend may resolve the same
@@ -899,10 +1269,22 @@ namespace MobileGL::MG_State::GLState {
// Mutable because publishing is a READ-side operation: a const getter has to be able // Mutable because publishing is a READ-side operation: a const getter has to be able
// to settle an outstanding link before answering it. // to settle an outstanding link before answering it.
mutable LinkArtifacts m_artifacts; mutable LinkArtifacts m_artifacts;
// Phase-B output. Same mutability argument as m_artifacts, reached only through
// Spirv().
mutable SpirvArtifacts m_spirv;
// The link job, from enqueue until the first observable read pulls its result. Null // The link job, from enqueue until the first observable read pulls its result. Null
// means m_artifacts is already the answer - which is the state every reader outside // means m_artifacts is already the answer - which is the state every reader outside
// the pending window sees, and the whole reason the gate above is one branch. // the pending window sees, and the whole reason the gate above is one branch.
mutable SharedPtr<ProgramLinkTask> m_pendingLink; mutable SharedPtr<ProgramLinkTask> m_pendingLink;
// The SPIR-V job, chained behind m_pendingLink. Null means m_spirv is already the
// answer. A program can be in the window where m_pendingLink is already null (phase A
// published, the query surface is live) while this is still set.
mutable SharedPtr<ProgramSpirvTask> m_pendingSpirv;
// glUniform* writes taken while m_pendingSpirv was set, in call order, plus their
// bytes. Drained by the phase-B publish and cleared by every cancel site (a relink's
// uniforms are not the previous link's uniforms).
mutable Vector<PendingUniformWrite> m_pendingUniformWrites;
mutable Vector<Uint8> m_pendingUniformBytes;
}; };
} // namespace MobileGL::MG_State::GLState } // namespace MobileGL::MG_State::GLState
@@ -40,25 +40,112 @@ namespace MobileGL {
Uint GetExternalIndex() const { return m_externalIndex; } 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 // 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 // GRAPHICS stages are composited into a single hidden program object, rebuilt
// the stage set - or any stage program's own link - changes. The signature is // whenever the stage set - or any stage program's own link - changes. The
// what that "changes" means: a stage program's lifetime id pins the object and // signature is what that "changes" means: a stage program's lifetime id pins the
// its backend state version pins the link generation. // object and its LINK version pins the link generation. It covers exactly the
using DrawProgramSignature = // stages the composite is built from, so attaching or relinking a compute stage
Array<Uint64, static_cast<SizeT>(ShaderStage::ShaderStageCount) * 2>; // 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 ComputeDrawProgramSignature() const {
DrawProgramSignature signature{}; 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]; const auto& program = m_stagePrograms[stage];
if (!program) continue; if (!program) continue;
signature[stage * 2] = program->GetLifetimeId(); signature[stage * 2] = program->GetLifetimeId();
signature[stage * 2 + 1] = program->GetBackendStateVersion(); signature[stage * 2 + 1] = program->GetLinkVersion();
} }
return signature; 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 { const SharedPtr<ProgramObject>& GetCachedDrawProgram(const DrawProgramSignature& signature) const {
static const SharedPtr<ProgramObject> nullProgram = nullptr; static const SharedPtr<ProgramObject> nullProgram = nullptr;
if (!m_drawProgram || m_drawProgramSignature != signature) return nullProgram; if (!m_drawProgram || m_drawProgramSignature != signature) return nullProgram;
@@ -67,6 +154,8 @@ namespace MobileGL {
void SetCachedDrawProgram(const DrawProgramSignature& signature, SharedPtr<ProgramObject> program) { void SetCachedDrawProgram(const DrawProgramSignature& signature, SharedPtr<ProgramObject> program) {
m_drawProgramSignature = signature; m_drawProgramSignature = signature;
m_drawProgram = Move(program); m_drawProgram = Move(program);
// A rebuilt composite holds none of its stage programs' uniform values yet.
m_mirroredUniformVersions = {};
} }
private: private:
@@ -74,9 +163,11 @@ namespace MobileGL {
SharedPtr<ProgramObject> m_activeProgram; SharedPtr<ProgramObject> m_activeProgram;
SharedPtr<ProgramObject> m_drawProgram; SharedPtr<ProgramObject> m_drawProgram;
DrawProgramSignature m_drawProgramSignature{}; DrawProgramSignature m_drawProgramSignature{};
UniformMirrorVersions m_mirroredUniformVersions{};
String m_infoLog; String m_infoLog;
const Uint m_externalIndex = 0; const Uint m_externalIndex = 0;
Bool m_validateStatus = false; Bool m_validateStatus = false;
Bool m_everBound = false;
}; };
} // namespace GLState } // namespace GLState
} // namespace MG_State } // namespace MG_State
@@ -0,0 +1,318 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.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
#include "ProgramSpirvTask.h"
#include <MG_State/GLState/ProgramState/ShaderCompileTask.h> // GlslangThreadAllocatorGuard
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <cstring>
namespace MobileGL::MG_State::GLState {
void ProgramSpirvTask::DeferLog(String line) { diagnostics.logLines.push_back(Move(line)); }
void ProgramSpirvTask::SubmitAfter(const SharedPtr<ProgramLinkTask>& phaseA) {
MOBILEGL_ASSERT(phaseA != nullptr, "ProgramSpirvTask::SubmitAfter: the phase-A node is missing");
m_phaseA = phaseA;
auto self = std::static_pointer_cast<ProgramSpirvTask>(shared_from_this());
// ONE dependency, so no counter and no guard slot: the whole race
// ProgramLinkTask::SubmitAfter's +1 exists to close (a dependency settling while the
// remaining edges are still being registered) cannot arise with a single edge.
//
// Runs inline, right here, if phase A is already terminal.
phaseA->OnTerminal([self, phaseA] {
// "Dependency did not complete, publish nothing" - the same collapse
// ProgramLinkTask::CompiledArtifacts() performs for an abandoned compile. Note
// this reads the HANDOFF, never phaseA->artifacts: the GL thread may already be
// moving those out (see the class comment).
if (!phaseA->IsComplete() || !phaseA->spirvHandoff.ready) {
self->Cancel();
return;
}
// A cancel that landed before phase A settled (relink, glDeleteProgram, teardown).
// Posting would only make a worker pick up a node that immediately falls out of
// Run() again.
if (self->IsCancellationRequested()) {
self->Cancel();
return;
}
// Non-throwing by construction, and it has to be: this is a JobNode continuation,
// so on the pool side it runs inside an Asio handler. Post() contains its own
// allocation failures, and the catch below CANCELS rather than swallowing - a
// phase B that is never posted is a GL thread blocked forever in
// EnsureSpirvJoined(), which is far worse than a program reported as not drawable.
try {
MG_Util::Async::ShaderCompilePool::Get().Post(self);
} catch (...) {
self->Cancel();
}
});
}
void ProgramSpirvTask::RunInlineAfter(const SharedPtr<ProgramLinkTask>& phaseA) {
MOBILEGL_ASSERT(phaseA != nullptr, "ProgramSpirvTask::RunInlineAfter: the phase-A node is missing");
MOBILEGL_ASSERT(phaseA->IsTerminal(),
"ProgramSpirvTask::RunInlineAfter: phase A has not settled; the inline path must run the "
"two bodies in order on the same thread");
m_phaseA = phaseA;
RunInline();
}
// Pure CPU work only, on a pool worker (or on the GL thread in the inline mode).
// Everything this reads is either owned by this node or published by a terminal phase A;
// everything it writes is `artifacts` (and diagnostics). Same prohibitions as
// ProgramLinkTask::RunBody - no GL/EGL call, no pActiveBackendObject read, no
// pGLContext->RecordError().
void ProgramSpirvTask::RunBody() {
// glslang leaves this worker's TLS pool allocator pointing at the last arena it
// touched; reset it on the way out so an unrelated later job cannot allocate out of a
// pool that has since been freed. Declared FIRST so it is destroyed LAST - the phase-A
// release below drops the TShaders (and their pools) and must happen inside it.
const GlslangThreadAllocatorGuard glslangGuard;
using namespace MG_Util::ShaderTranspiler;
// Drop phase A - and with it the TShaders, the TProgram reference and phase A's whole
// input snapshot - the moment this body is done, rather than at some later join. For a
// pack load that is the difference between W glslang arenas alive and all of them.
struct PhaseAReleaser {
SharedPtr<ProgramLinkTask>& node;
~PhaseAReleaser() { node.reset(); }
} const phaseAReleaser{m_phaseA};
if (!m_phaseA) return;
// Non-const: the TShaders are dropped below, the moment GlslangToSpv is finished with
// them. This is safe by ownership rather than by locking - phase A is terminal and
// therefore immutable to everyone else, the GL-thread join touches only `artifacts`
// and `diagnostics`, and this node is the sole reader of the handoff.
ProgramLinkTask::SpirvHandoff& handoff = m_phaseA->spirvHandoff;
const Uint externalIndex = m_phaseA->in.externalIndex;
if (!handoff.ready || !handoff.reflection.program) {
// Phase A did not reach its tail (it failed the link, or was cancelled mid-body).
// Publish nothing; spirvStatus stays false.
return;
}
MGLOG_D("ProgramObject %u: Starting SPIR-V generation", externalIndex);
GenerateSpirv(handoff, externalIndex);
// GlslangToSpv was the only consumer of the parsed ASTs; everything after this point
// works on the SPIR-V and on the TProgram's own self-contained reflection pool. Drop
// them here rather than at the end of the body, which is ~87% of this node's runtime
// earlier (spirv-opt plus routing).
//
// WHAT THIS ACTUALLY FREES, precisely - it is LESS than "the glslang arenas", and the
// difference matters for the peak-RSS story:
// * CAS-LOSER shaders (the re-parse in ShaderCompileTask::ClaimParsedShader, i.e.
// the 2nd..Nth link of a shared shader): freed here in full. The handoff is their
// ONLY owner.
// * CAS-WINNER shaders (the common case - one shader object linked into one
// program, which is every program of an Iris pack load): NOT freed here. The
// winner branch returns a COPY of ShaderCompileTask::artifacts.shader
// (ShaderCompileTask.cpp:320) and the node never releases its own reference, while
// phase A holds that node through in.shaders[i].compiled for its whole life - and
// phase A lives until PhaseAReleaser fires at the end of this body. So the
// refcount goes 2 -> 1 here and the arena dies where it would have died anyway.
//
// Making it free the winner's arena too means releasing whatever pins the TShader
// inside the compile node, and neither obvious route is safe as a drive-by: moving out
// of artifacts.shader at claim time races ShaderObject::GetCompiledShader() on the GL
// thread and breaks JobNode's "a terminal node is immutable" invariant, and dropping
// phase A's in.shaders[i].compiled reference only helps when nothing else holds the
// node (the adoption map is a WeakPtr index, so it would also change which nodes stay
// adoptable). Both belong in a change that can be reviewed against the consume-once
// and adoption semantics on their own terms.
handoff.shaders.clear();
MGLOG_D("ProgramObject %u: Building global-UBO routing tables", externalIndex);
BuildGlobalUboRouting(handoff, externalIndex);
MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", externalIndex,
artifacts.generatedSpirv.size());
}
void ProgramSpirvTask::GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, const Uint externalIndex) {
/* As we passed first stage compilation/linking,
* we'll assume all the operations here should
* pass. We may be able to employ some optimizations
* here without the burden of error reporting.
*/
using namespace MG_Util::ShaderTranspiler;
MGLOG_D("ProgramObject %u: GenerateSpirv - start", externalIndex);
// The shaders were parsed once, in the link-compatible (relaxed Vulkan-rules)
// configuration, and the handoff's program linked those parses - so it IS the program
// the backends consume. Generate SPIR-V straight from its intermediates, which the
// handoff's TShaders keep alive.
ProgramBinaryAttrib binaryAttrib{
.shaderTypes = handoff.shaderTypes,
.program = *handoff.reflection.program,
};
MGLOG_D("ProgramObject %u: GenerateSpirv - requesting SPIR-V binary from program", externalIndex);
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
if (!binaryResult) {
DeferLog(std::format("ProgramObject {}: GenerateSpirv - GetSpirvBinaryFromProgram failed", externalIndex));
MOBILEGL_ASSERT(binaryResult, "GetSpirvBinaryFromProgram failed");
return; // spirvStatus stays false: linked, but not drawable.
}
artifacts.generatedSpirv = Move(binaryResult.value());
MGLOG_D("ProgramObject %u: GenerateSpirv - generated %zu SPIR-V modules", externalIndex,
artifacts.generatedSpirv.size());
// Linked SPIR-V generated, sanitize and optimize it
Bool allOptimized = true;
{
for (auto& spv : artifacts.generatedSpirv) {
auto success = ShaderCompiler::SanitizeAndOptimizeBinary(spv, spv);
if (!success) {
// The one genuine phase-B failure mode: one of the seven optimizer passes
// reported failure, so `spv` is whatever the run left behind. A fordebug
// build trips the assert below; a release build used to hand that binary
// to the backend regardless. It no longer does - the program keeps its
// (truthful) LINK_STATUS and its whole query surface, and the routing
// tables below still give every settable uniform storage so glUniform*
// and glGetUniform* keep working, but spirvStatus stays false and the
// backends refuse to build or draw with it.
allOptimized = false;
DeferLog(std::format("ProgramObject {}: SanitizeAndOptimizeBinary failed; the program is linked "
"and queryable but not drawable",
externalIndex));
}
MOBILEGL_ASSERT(success, "SanitizeBinary failed");
}
}
artifacts.spirvStatus = allOptimized;
}
void ProgramSpirvTask::BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff,
const Uint externalIndex) {
using namespace MG_Util::ShaderTranspiler;
// The phase-A reflection slice this pass keys off. Carried in the handoff rather than
// read off the phase-A node's artifacts, which the join has very likely already moved.
const ProgramObject::LinkArtifacts& reflection = handoff.reflection;
artifacts.uniformOffsets.clear();
artifacts.globalUboScratch.clear();
// kInvalidUniformOffset marks locations that end up without global-UBO backing
// (e.g. the optimizer eliminated every use of the uniform); the fallback pass
// below gives those locations tail storage so glUniform* always has a target.
artifacts.uniformOffsets.resize(reflection.maxUniformLocation + 1, ProgramObject::kInvalidUniformOffset);
for (SizeT i = 0; i < artifacts.generatedSpirv.size(); i++) {
auto& spv = artifacts.generatedSpirv[i];
auto shaderType = i < handoff.shaderTypes.size() ? handoff.shaderTypes[i] : GLenum{0};
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - parsing SPIR-V meta data for module %zu "
"(shaderType=%u, wordCount=%zu)",
externalIndex, i, shaderType, spv.size());
SpvcSession session(spv, SessionUsageBit::Reflection);
auto result = session.ParseMetaData();
if (result < 0) {
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SpvcSession::ParseMetaData failed for module %zu, "
"err = %d%s",
externalIndex, i, result,
(result == SPVC_ERROR_INVALID_SPIRV ? ". Probably no global UBO?" : ""));
continue;
} else {
auto& meta = session.GetMetadata();
auto size = meta.globalUboSize;
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SPIR-V meta: uboSize=%zu plainUniformCount=%zu "
"plainUniformOffsets=%zu",
externalIndex, meta.globalUboSize, meta.plainUniformMemberSizesInBytes.size(),
meta.plainUniformOffsetsInUBO.size());
if (size == 0) {
continue;
}
if (artifacts.globalUboScratch.size() < size) {
artifacts.globalUboScratch.resize(size);
}
for (const auto& [name, offset] : meta.plainUniformOffsetsInUBO) {
// SPIRV-Reflect leaf names never carry a "[0]" suffix; frontend
// reflection keys arrays as "arr[0]" (GL naming), so retry with the
// suffix before declaring the uniform unbacked.
auto locationIt = reflection.uniformLocations.find(name);
if (locationIt == reflection.uniformLocations.end()) {
locationIt = reflection.uniformLocations.find(name + "[0]");
}
if (locationIt == reflection.uniformLocations.end()) {
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u but not found in "
"uniformLocations",
externalIndex, name.c_str(), offset);
continue;
}
const Uint baseLocation = locationIt->second;
if (!ProgramObject::IsValidUniformLocation(reflection, static_cast<Int>(baseLocation))) {
continue;
}
const Int uniformIndex = reflection.uniformIndexInTProgram[baseLocation];
const GLint arraySize = ProgramObject::GetUniformArraySizeByTIndex(reflection, uniformIndex);
Uint arrayStride = 0;
const auto strideIt = meta.plainUniformArrayStridesInUBO.find(name);
if (strideIt != meta.plainUniformArrayStridesInUBO.end()) {
arrayStride = strideIt->second;
}
// Array uniforms span one location per element (see DoReflection);
// give each element its real byte offset inside the UBO.
const GLint elementCount = (arraySize > 1 && arrayStride == 0) ? 1 : std::max(arraySize, 1);
for (GLint element = 0; element < elementCount; ++element) {
const Uint location = baseLocation + static_cast<Uint>(element);
if (location > reflection.maxUniformLocation ||
reflection.uniformIndexInTProgram[location] != uniformIndex) {
break;
}
artifacts.uniformOffsets[location] = offset + static_cast<Uint>(element) * arrayStride;
}
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u stride=%u assigned "
"to locations %u..%u",
externalIndex, name.c_str(), offset, arrayStride, baseLocation,
baseLocation + static_cast<Uint>(elementCount) - 1);
}
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - finished parsing module %zu metadata",
externalIndex, i);
}
}
// Fallback pass: a linked program's active non-opaque uniforms must accept
// glUniform*/glGetUniform* even when the optimized SPIR-V no longer contains
// them (AggressiveDCE can remove a dead loop together with the only loads of a
// uniform -- or the entire global UBO, leaving the scratch unallocated). Hand
// such locations CPU-side storage at the (16-byte aligned) tail of the shadow
// buffer; backends bind at least the SPIR-V-declared UBO range, and the GPU
// never reads these bytes, so this only keeps the GL-visible state coherent.
for (Uint location = 0; location <= reflection.maxUniformLocation; ++location) {
if (artifacts.uniformOffsets[location] != ProgramObject::kInvalidUniformOffset) continue;
if (!ProgramObject::IsValidUniformLocation(reflection, static_cast<Int>(location))) continue;
const auto& uniform = reflection.program->getUniform(reflection.uniformIndexInTProgram[location]);
const glslang::TType* type = uniform.getType();
if (type != nullptr && type->isOpaque()) continue;
if (uniform.index >= 0 && uniform.index < reflection.program->getNumUniformBlocks() &&
std::strstr(reflection.program->getUniformBlock(uniform.index).name.c_str(),
MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) == nullptr) {
// Member of a named uniform block: not settable through glUniform*, so it
// needs no global-UBO shadow storage.
continue;
}
// std140-style slot: the matrix upload paths write column vectors at
// 16-byte strides, so a matrix slot must cover cols * 16 bytes.
SizeT slotSize = MG_Util::GetGLTypeSize(uniform.glDefineType);
if (type != nullptr && type->isMatrix()) {
slotSize = static_cast<SizeT>(type->getMatrixCols()) * 16u;
}
slotSize = (slotSize + 15u) & ~static_cast<SizeT>(15u);
const SizeT slotOffset = (artifacts.globalUboScratch.size() + 15u) & ~static_cast<SizeT>(15u);
artifacts.globalUboScratch.resize(slotOffset + slotSize, 0);
artifacts.uniformOffsets[location] = static_cast<Uint>(slotOffset);
MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' location %u has no UBO backing in the "
"generated SPIR-V (optimized out?); allocated %zu fallback bytes at scratch offset %zu",
externalIndex, uniform.name.c_str(), location, slotSize, slotOffset);
}
}
} // namespace MobileGL::MG_State::GLState
@@ -0,0 +1,77 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include <MG_State/GLState/ProgramState/ProgramLinkTask.h>
#include <MG_Util/Async/JobNode.h>
namespace MobileGL::MG_State::GLState {
// PHASE B of one glLinkProgram: GlslangToSpv, spirv-opt, and the SPIRV-Cross pass that
// builds the glUniform*-to-scratch routing tables. Chained behind exactly one
// ProgramLinkTask and joined by exactly five ProgramObject getters (GetGeneratedSpirv,
// GetUniformOffset, MapUBO, GetUBOData, GetUBOSize), so ~120 other getters and the whole
// GL query surface stay on the phase-A gate and answer without waiting for any of this.
//
// ---- what this node may read, and what it may not ----
// It holds the phase-A node by SharedPtr and reads `phaseA->spirvHandoff` plus
// `phaseA->in`. It must NEVER read `phaseA->artifacts` or `phaseA->diagnostics`: the GL
// thread MOVES the artifacts out of the node at the phase-A join and DRAINS the
// diagnostics there, and both of those can happen while this body runs. The handoff exists
// precisely so this node has a copy of everything it needs that the join does not touch.
// (The general JobNode rule - a terminal node is immutable, so its outputs need no further
// synchronization - covers everything except the two members the join consumes.)
//
// ---- lifetime ----
// The handoff owns the Vector<SharedPtr<glslang::TShader>>, and that is mandatory rather
// than tidy: glslang::TProgram stores raw TShader* and, for the one-shader-per-stage case,
// BORROWS each stage's TIntermediate from its TShader. GlslangToSpv reads exactly those
// intermediates. Before the split the shaders died when ProgramLinkTask::RunBody returned,
// which was safe only because nothing called getIntermediate() afterwards.
//
// ---- failure ----
// A cancel (relink, teardown, program destruction) or an optimizer failure publishes
// spirvStatus = false rather than a half-built program. GL cannot retract a LINK_STATUS it
// already reported true, so such a program stays linked and fully queryable; it is just
// not drawable, which the backends express through their existing link-status gates.
class ProgramSpirvTask final : public MG_Util::Async::JobNode {
public:
// ---- output: valid iff IsComplete(), immutable afterwards ----
// Moved (never copied) into the ProgramObject by EnsureSpirvJoined().
ProgramObject::SpirvArtifacts artifacts;
// Posts this job when `phaseA` goes terminal - and not one moment earlier, so the body
// never waits on anything (invariant I4: no job body may block on another job). A
// single dependency needs no counter, just the one continuation; it runs inline right
// here if `phaseA` is already terminal, which is the same case
// ProgramLinkTask::SubmitAfter already reasons about.
//
// GL thread only, and only after the caller has stored a SharedPtr to this node: the
// continuation takes shared_from_this().
void SubmitAfter(const SharedPtr<ProgramLinkTask>& phaseA);
// The async-off / glMaxShaderCompilerThreadsKHR(0) path: run the body on the calling
// thread, right now, against an ALREADY-TERMINAL phase A. Deliberately not routed
// through SubmitAfter, whose continuation would Post() to a pool that is merely
// unused rather than stopped - that would move the work off-thread in the one mode
// whose contract is "byte-identical to the synchronous implementation".
void RunInlineAfter(const SharedPtr<ProgramLinkTask>& phaseA);
private:
void RunBody() override;
void GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex);
void BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex);
// Worker-side MGLOG replacement, replayed by the join on the GL thread. Same reason as
// ProgramLinkTask::DeferLog.
void DeferLog(String line);
SharedPtr<ProgramLinkTask> m_phaseA;
};
} // namespace MobileGL::MG_State::GLState
@@ -111,9 +111,13 @@ namespace MobileGL::MG_State::GLState {
// that can grow, and a reallocation underneath this loop would be a use-after-free // that can grow, and a reallocation underneath this loop would be a use-after-free
// that only shows up on the one GL call that walks the whole table. The copy costs a // that only shows up on the one GL call that walks the whole table. The copy costs a
// refcount bump on a path a mode switch takes at most once. // refcount bump on a path a mode switch takes at most once.
// BOTH phases per program. This is the glMaxShaderCompilerThreadsKHR(0) path, whose
// contract is that nothing is outstanding when it returns - a program left with its
// SPIR-V job in flight would make the very next GL_COMPLETION_STATUS_KHR read GL_FALSE
// in a mode the extension says cannot have anything pending.
for (SizeT i = 0; i < m_programObjects.size(); ++i) { for (SizeT i = 0; i < m_programObjects.size(); ++i) {
const SharedPtr<ProgramObject> program = m_programObjects[i]; const SharedPtr<ProgramObject> program = m_programObjects[i];
if (program) program->JoinLink(); if (program) program->JoinLinkAndSpirv();
} }
for (SizeT i = 0; i < m_shaderObjects.size(); ++i) { for (SizeT i = 0; i < m_shaderObjects.size(); ++i) {
const SharedPtr<ShaderObject> shader = m_shaderObjects[i]; const SharedPtr<ShaderObject> shader = m_shaderObjects[i];
@@ -122,7 +126,7 @@ namespace MobileGL::MG_State::GLState {
// The currently-used program is reachable through m_programObjects unless // The currently-used program is reachable through m_programObjects unless
// glDeleteProgram already freed its slot while it stayed current. Nothing else holds // glDeleteProgram already freed its slot while it stayed current. Nothing else holds
// a GL-visible name for it, but a draw would still join it, so settle it here too. // a GL-visible name for it, but a draw would still join it, so settle it here too.
if (m_currentProgram) m_currentProgram->JoinLink(); if (m_currentProgram) m_currentProgram->JoinLinkAndSpirv();
} }
void ProgramState::MarkShaderObjectForDeletion(Uint shader) { void ProgramState::MarkShaderObjectForDeletion(Uint shader) {
@@ -70,9 +70,10 @@ namespace MobileGL::MG_State::GLState {
void ShaderCompileAdoptionMap::SweepIfCrowded() { void ShaderCompileAdoptionMap::SweepIfCrowded() {
if (m_entries.size() < m_sweepThreshold) return; if (m_entries.size() < m_sweepThreshold) return;
// Collect first, erase after: FastSTL::unordered_map is open-addressed, so erasing // Collect first, erase after: the map is open-addressed and erases by shifting the
// through an iterator that the same loop is still advancing is not worth reasoning // rest of the probe cluster into the hole, so an erase moves entries other than the
// about on a path this cold. // erased one. Copying the keys out sidesteps that entirely, and this path is cold
// enough that the extra vector is not worth reasoning about the alternative.
Vector<ShaderSourceKey> dead; Vector<ShaderSourceKey> dead;
for (const auto& entry : m_entries) { for (const auto& entry : m_entries) {
const SharedPtr<ShaderCompileTask> node = entry.second.lock(); const SharedPtr<ShaderCompileTask> node = entry.second.lock();
@@ -88,12 +88,19 @@ namespace MobileGL::MG_State::GLState {
// another object, THIS object has not pulled its result yet. (An adopted node may // another object, THIS object has not pulled its result yet. (An adopted node may
// already be terminal - the join then only replays what is left of its diagnostics.) // already be terminal - the join then only replays what is left of its diagnostics.)
m_compileJoined = false; m_compileJoined = false;
// A new compile is a new story: whatever the optimistic getters promised about the
// previous node does not carry over.
m_optimisticAnswerLatched = false;
} }
void ShaderObject::DropCompileNode() const { void ShaderObject::DropCompileNode() const {
if (!m_compiled) return; if (!m_compiled) return;
m_compiled->ReleaseAdopter(); m_compiled->ReleaseAdopter();
m_compiled.reset(); m_compiled.reset();
// No node means IsCompileComplete() is trivially true and the truthful answers are
// "not compiled"; a stale latch would keep reporting a compile that no longer
// exists as GL_TRUE.
m_optimisticAnswerLatched = false;
} }
void ShaderObject::InvalidateCompiledState() { void ShaderObject::InvalidateCompiledState() {
@@ -116,8 +116,10 @@ namespace MobileGL {
Bool GetDeleteStatus() const { return m_deleteStatus; } Bool GetDeleteStatus() const { return m_deleteStatus; }
// Blocks until a pending compile has published its artifacts. Public for the // Blocks until a pending compile has published its artifacts. Public for the
// sites that must join without reading anything - ProgramObject::Link's // sites that must join without reading anything - ProgramState::
// prologue, which needs every attached shader settled before it runs. // JoinAllPendingWork, the glMaxShaderCompilerThreadsKHR(0) path that settles
// every outstanding job. glLinkProgram deliberately does NOT come through
// here: its prologue takes the nodes unjoined via CompiledNodeForLink().
void JoinCompile() const { EnsureCompileJoined(); } void JoinCompile() const { EnsureCompileJoined(); }
// True while this object holds the outcome (success OR failure) of a Compile() // True while this object holds the outcome (success OR failure) of a Compile()
@@ -141,6 +143,23 @@ namespace MobileGL {
// outstanding to wait for. // outstanding to wait for.
Bool IsCompileComplete() const { return m_compiled == nullptr || m_compiled->IsTerminal(); } Bool IsCompileComplete() const { return m_compiled == nullptr || m_compiled->IsTerminal(); }
// MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS's one-story-per-compile memory. The
// three optimistic getter sites in GL_Program ask THIS instead of a raw
// IsCompileComplete() peek, and the difference is the latch: without it, a job
// that settles between two adjacent queries hands the application a torn pair -
// an empty info log from the optimistic read, then the real GL_FALSE from the
// truthful one - and an application that aborts on that status never reaches
// the link join that quotes the real log. So the first optimistic answer
// latches: until the next AdoptCompileNode/DropCompileNode this object keeps
// answering optimistically even after the job settles, and a real failure
// surfaces exactly once, at the link. Returns whether the caller should answer
// optimistically; the caller has already checked the quirk is active.
Bool TakeOptimisticCompileAnswer() const {
if (!m_optimisticAnswerLatched && IsCompileComplete()) return false;
m_optimisticAnswerLatched = true;
return true;
}
private: private:
// ---- The one and only join gate for compile output (P1 invariant I5) ---- // ---- The one and only join gate for compile output (P1 invariant I5) ----
// The fast path - no job, or a job whose result this object has already pulled - // The fast path - no job, or a job whose result this object has already pulled -
@@ -231,6 +250,10 @@ namespace MobileGL {
// Exactly-once latch for the pull above. Armed with every new job node, set by // Exactly-once latch for the pull above. Armed with every new job node, set by
// the one join that consumes it. // the one join that consumes it.
mutable Bool m_compileJoined = false; mutable Bool m_compileJoined = false;
// TakeOptimisticCompileAnswer's memory: this object has answered a compile
// query optimistically for the current node. Cleared wherever the node
// changes hands (AdoptCompileNode) or goes away (DropCompileNode).
mutable Bool m_optimisticAnswerLatched = false;
}; };
} // namespace MG_State::GLState } // namespace MG_State::GLState
} // namespace MobileGL } // namespace MobileGL
@@ -7,6 +7,7 @@
// End of Source File Header // End of Source File Header
#include "RenderState.h" #include "RenderState.h"
#include "MG_Util/Debug/Log.h"
#include "MG_Util/Types.h" #include "MG_Util/Types.h"
namespace MobileGL { namespace MobileGL {
@@ -268,9 +269,14 @@ namespace MobileGL {
} }
void RenderState::SetCapabilityIndexed(CapabilityInput cap, Uint index, Bool enabled) { void RenderState::SetCapabilityIndexed(CapabilityInput cap, Uint index, Bool enabled) {
// Only for BlendState currently // Only for BlendState currently. The GL entry points (glEnablei/glDisablei) already
// reject every non-GL_BLEND target with GL_INVALID_ENUM before reaching here, so this
// is a backstop - but it must stay a backstop: THROW_UNIMPL_EXCEPTION unwinds a C++
// exception through the C GL ABI and terminates the process.
if (cap != CapabilityInput::Blend) { if (cap != CapabilityInput::Blend) {
THROW_UNIMPL_EXCEPTION; MGLOG_I("RenderState::SetCapabilityIndexed: indexed capability state exists only for "
"GL_BLEND (cap=%d, index=%u); ignoring",
static_cast<int>(cap), index);
return; return;
} }
if (index >= MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS) { if (index >= MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS) {
@@ -284,9 +290,13 @@ namespace MobileGL {
} }
Bool RenderState::IsCapabilityEnabledIndexed(CapabilityInput cap, Uint index) const { Bool RenderState::IsCapabilityEnabledIndexed(CapabilityInput cap, Uint index) const {
// Only for BlendState currently // Only for BlendState currently - same backstop reasoning as SetCapabilityIndexed:
// glIsEnabledi has already answered GL_INVALID_ENUM/GL_FALSE for anything else, and a
// query must never be able to terminate the process.
if (cap != CapabilityInput::Blend) { if (cap != CapabilityInput::Blend) {
THROW_UNIMPL_EXCEPTION; MGLOG_I("RenderState::IsCapabilityEnabledIndexed: indexed capability state exists only "
"for GL_BLEND (cap=%d, index=%u); reporting disabled",
static_cast<int>(cap), index);
return false; return false;
} }
if (index >= MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS) { if (index >= MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS) {
@@ -29,6 +29,8 @@ namespace MobileGL::MG_State::GLState {
attr.Normalized = false; attr.Normalized = false;
attr.Stride = 0; attr.Stride = 0;
attr.Offset = 0; attr.Offset = 0;
attr.LegacyStride = 0;
attr.LegacyPointer = 0;
attr.Buffer = nullptr; attr.Buffer = nullptr;
BumpAttributeFormatVersion(index); BumpAttributeFormatVersion(index);
@@ -61,10 +63,19 @@ namespace MobileGL::MG_State::GLState {
void VertexArrayObject::SetAttributeFormat(Uint index, int size, DataType type, Bool normalized, int stride, void VertexArrayObject::SetAttributeFormat(Uint index, int size, DataType type, Bool normalized, int stride,
SizeT offset, Bool isInteger, Bool isBgra) { SizeT offset, Bool isInteger, Bool isBgra) {
if (index >= MAX_VERTEX_ATTRIBS) return; if (index >= MAX_VERTEX_ATTRIBS) return;
if (size < 1 || size > 4) {
return;
}
// The classic pointer-style API takes back full ownership of the resolved fields. // The classic pointer-style API takes back full ownership of the resolved fields.
m_attributeUsesBindingModel[index] = false; m_attributeUsesBindingModel[index] = false;
// The legacy query shadows: written here and nowhere else, so a later binding-model
// mutation cannot leak into VERTEX_ATTRIB_ARRAY_STRIDE / _POINTER. They are pure
// query state, so they carry no version bump of their own.
m_attributes[index].LegacyStride = stride;
m_attributes[index].LegacyPointer = offset;
if (m_attributes[index].Size == size && m_attributes[index].Type == type && if (m_attributes[index].Size == size && m_attributes[index].Type == type &&
m_attributes[index].Normalized == normalized && m_attributes[index].Stride == stride && m_attributes[index].Normalized == normalized && m_attributes[index].Stride == stride &&
m_attributes[index].Offset == offset && m_attributes[index].IsInteger == isInteger && m_attributes[index].Offset == offset && m_attributes[index].IsInteger == isInteger &&
@@ -72,10 +83,6 @@ namespace MobileGL::MG_State::GLState {
return; return;
} }
if (size < 1 || size > 4) {
return;
}
auto& attr = m_attributes[index]; auto& attr = m_attributes[index];
attr.Size = size; attr.Size = size;
attr.Type = type; attr.Type = type;
@@ -111,6 +118,12 @@ namespace MobileGL::MG_State::GLState {
binding.Offset = offset; binding.Offset = offset;
binding.Stride = effectiveStride; binding.Stride = effectiveStride;
binding.Divisor = m_attributes[index].Divisor; binding.Divisor = m_attributes[index].Divisor;
// Other attributes may already be pointed at this binding point through
// glVertexAttribBinding; they see the new buffer/offset/stride too (basic-state3
// checks exactly that after a glVertexAttribPointer). They are not adopted into the
// binding model here - only the ones already in it re-resolve.
ResolveAttributesForBinding(index, /*adopt: */ false);
} }
void VertexArrayObject::BindAttributeBuffer(Uint index, const SharedPtr<BufferObject>& buffer) { void VertexArrayObject::BindAttributeBuffer(Uint index, const SharedPtr<BufferObject>& buffer) {
@@ -147,10 +160,24 @@ namespace MobileGL::MG_State::GLState {
void VertexArrayObject::SetAttributeDivisor(Uint index, Uint divisor) { void VertexArrayObject::SetAttributeDivisor(Uint index, Uint divisor) {
if (index >= MAX_VERTEX_ATTRIBS) return; if (index >= MAX_VERTEX_ATTRIBS) return;
// glVertexAttribDivisor is VertexBindingDivisor on the attribute's own binding point // GL 4.6 core 10.3.2 defines VertexAttribDivisor(i, d) as
// (GL 4.6 core 10.3.2), so the binding-point view has to follow the resolved attribute. // VertexAttribBinding(i, i); VertexBindingDivisor(i, d)
if (index < MAX_VERTEX_ATTRIB_BINDINGS && m_attributeBindingIndex[index] == index) { // - the binding is RE-POINTED at i, it is not merely written through when it already
// happens to be i. Guarding the write on "binding == index" (which is what this did)
// left an attribute that glVertexAttribBinding had moved elsewhere pointing at the old
// binding, so the next resolve restored that binding's divisor and the new one was
// lost (KHR-GL4x.vertex_attrib_binding.basic-state4).
//
// What is deliberately NOT copied from VertexAttribBinding is the adoption into the
// binding model: an attribute configured the classic way keeps its pointer-resolved
// stride/offset, exactly as before. The binding point mirrors that state already
// (MirrorPointerIntoBinding), so nothing observable differs - and adopting it here
// would silently swap the raw pointer stride for the effective one under every
// application that calls glVertexAttribDivisor after glVertexAttribPointer.
if (index < MAX_VERTEX_ATTRIB_BINDINGS) {
m_attributeBindingIndex[index] = index;
m_bindingPoints[index].Divisor = divisor; m_bindingPoints[index].Divisor = divisor;
ResolveAttributesForBinding(index, /*adopt: */ false);
} }
if (m_attributes[index].Divisor == divisor) return; if (m_attributes[index].Divisor == divisor) return;
m_attributes[index].Divisor = divisor; m_attributes[index].Divisor = divisor;
@@ -164,7 +191,6 @@ namespace MobileGL::MG_State::GLState {
void VertexArrayObject::ResolveAttributeFromBinding(Uint attribIndex) { void VertexArrayObject::ResolveAttributeFromBinding(Uint attribIndex) {
if (attribIndex >= MAX_VERTEX_ATTRIBS) return; if (attribIndex >= MAX_VERTEX_ATTRIBS) return;
if (!m_attributeUsesBindingModel[attribIndex]) return;
const Uint bindingIndex = m_attributeBindingIndex[attribIndex]; const Uint bindingIndex = m_attributeBindingIndex[attribIndex];
if (bindingIndex >= MAX_VERTEX_ATTRIB_BINDINGS) return; if (bindingIndex >= MAX_VERTEX_ATTRIB_BINDINGS) return;
@@ -172,11 +198,24 @@ namespace MobileGL::MG_State::GLState {
auto& attr = m_attributes[attribIndex]; auto& attr = m_attributes[attribIndex];
// VERTEX_ATTRIB_ARRAY_DIVISOR is not independent per-attribute state: it IS the divisor
// of the binding point the attribute is attached to (GL 4.6 core 10.3.2), whichever API
// configured the attribute. glVertexBindingDivisor therefore has to reach a classic
// pointer-configured attribute as well - basic-state4 alternates the two spellings on
// the same attribute and expects each to win in turn.
if (attr.Divisor != binding.Divisor) {
attr.Divisor = binding.Divisor;
BumpAttributeFormatVersion(attribIndex);
}
// Everything else stays owned by whichever API configured the attribute: a classic
// glVertexAttrib*Pointer attribute keeps its pointer-resolved stride and offset.
if (!m_attributeUsesBindingModel[attribIndex]) return;
const SizeT resolvedOffset = binding.Offset + m_attributeRelativeOffset[attribIndex]; const SizeT resolvedOffset = binding.Offset + m_attributeRelativeOffset[attribIndex];
if (attr.Stride != binding.Stride || attr.Offset != resolvedOffset || attr.Divisor != binding.Divisor) { if (attr.Stride != binding.Stride || attr.Offset != resolvedOffset) {
attr.Stride = binding.Stride; attr.Stride = binding.Stride;
attr.Offset = resolvedOffset; attr.Offset = resolvedOffset;
attr.Divisor = binding.Divisor;
BumpAttributeFormatVersion(attribIndex); BumpAttributeFormatVersion(attribIndex);
} }
@@ -186,6 +225,14 @@ namespace MobileGL::MG_State::GLState {
} }
} }
void VertexArrayObject::ResolveAttributesForBinding(Uint bindingIndex, Bool adopt) {
for (Uint attribIndex = 0; attribIndex < MAX_VERTEX_ATTRIBS; ++attribIndex) {
if (m_attributeBindingIndex[attribIndex] != bindingIndex) continue;
if (adopt) m_attributeUsesBindingModel[attribIndex] = true;
ResolveAttributeFromBinding(attribIndex);
}
}
void VertexArrayObject::SetBindingBuffer(Uint bindingIndex, const SharedPtr<BufferObject>& buffer, SizeT offset, void VertexArrayObject::SetBindingBuffer(Uint bindingIndex, const SharedPtr<BufferObject>& buffer, SizeT offset,
int stride) { int stride) {
if (bindingIndex >= MAX_VERTEX_ATTRIB_BINDINGS) return; if (bindingIndex >= MAX_VERTEX_ATTRIB_BINDINGS) return;
@@ -195,15 +242,10 @@ namespace MobileGL::MG_State::GLState {
binding.Offset = offset; binding.Offset = offset;
binding.Stride = stride; binding.Stride = stride;
for (Uint attribIndex = 0; attribIndex < MAX_VERTEX_ATTRIBS; ++attribIndex) { // Binding a vertex buffer to a binding point adopts every attribute currently mapped to
if (m_attributeBindingIndex[attribIndex] == bindingIndex) { // that binding point into the binding model (the default mapping is attribute i ->
// Binding a vertex buffer to a binding point adopts every attribute currently // binding i, which matches the GL 4.3 rules for state mixing).
// mapped to that binding point into the binding model (the default mapping is ResolveAttributesForBinding(bindingIndex, /*adopt: */ true);
// attribute i -> binding i, which matches the GL 4.3 rules for state mixing).
m_attributeUsesBindingModel[attribIndex] = true;
ResolveAttributeFromBinding(attribIndex);
}
}
} }
void VertexArrayObject::SetBindingDivisor(Uint bindingIndex, Uint divisor) { void VertexArrayObject::SetBindingDivisor(Uint bindingIndex, Uint divisor) {
@@ -211,11 +253,7 @@ namespace MobileGL::MG_State::GLState {
m_bindingPoints[bindingIndex].Divisor = divisor; m_bindingPoints[bindingIndex].Divisor = divisor;
for (Uint attribIndex = 0; attribIndex < MAX_VERTEX_ATTRIBS; ++attribIndex) { ResolveAttributesForBinding(bindingIndex, /*adopt: */ false);
if (m_attributeBindingIndex[attribIndex] == bindingIndex && m_attributeUsesBindingModel[attribIndex]) {
ResolveAttributeFromBinding(attribIndex);
}
}
} }
void VertexArrayObject::SetAttributeBinding(Uint attribIndex, Uint bindingIndex) { void VertexArrayObject::SetAttributeBinding(Uint attribIndex, Uint bindingIndex) {
@@ -32,6 +32,16 @@ namespace MobileGL {
Bool IsBgra = false; Bool IsBgra = false;
Uint Divisor = 0; Uint Divisor = 0;
SharedPtr<BufferObject> Buffer; SharedPtr<BufferObject> Buffer;
// GL 4.6 core table 23.3: VERTEX_ATTRIB_ARRAY_STRIDE and _POINTER are the
// arguments of the last glVertexAttrib*Pointer call on this attribute,
// reported verbatim, and NOTHING else writes them - not glVertexAttribFormat,
// not glBindVertexBuffer. Stride/Offset above are the *resolved* draw inputs
// and the binding model does overwrite those, so the two views have to be
// stored apart or the binding-model sequence reports a legacy state it never
// set (KHR-GL4x.vertex_attrib_binding.basic-state3).
int LegacyStride = 0;
SizeT LegacyPointer = 0;
}; };
// ARB_vertex_attrib_binding separate binding point. Attributes configured through the // ARB_vertex_attrib_binding separate binding point. Attributes configured through the
@@ -40,7 +50,8 @@ namespace MobileGL {
struct VertexBufferBindingPoint { struct VertexBufferBindingPoint {
SharedPtr<BufferObject> Buffer; SharedPtr<BufferObject> Buffer;
SizeT Offset = 0; SizeT Offset = 0;
int Stride = 0; // GL 4.6 core table 23.4: the initial VERTEX_BINDING_STRIDE is 16, not 0.
int Stride = 16;
Uint Divisor = 0; Uint Divisor = 0;
}; };
@@ -185,6 +196,10 @@ namespace MobileGL {
void BumpAttributeBufferVersion(Uint index); void BumpAttributeBufferVersion(Uint index);
void BumpAttributeSwitchVersion(Uint index); void BumpAttributeSwitchVersion(Uint index);
void ResolveAttributeFromBinding(Uint attribIndex); void ResolveAttributeFromBinding(Uint attribIndex);
// Re-resolve every attribute currently pointed at `bindingIndex`. `adopt` turns
// the ones that are not in the binding model yet into binding-model attributes
// first (what glBindVertexBuffer does, GL 4.3 rules for state mixing).
void ResolveAttributesForBinding(Uint bindingIndex, Bool adopt);
// The default mapping is attribute i -> binding point i. Keep it an iota over // The default mapping is attribute i -> binding point i. Keep it an iota over
// MAX_VERTEX_ATTRIBS rather than a literal list: a literal list silently leaves the // MAX_VERTEX_ATTRIBS rather than a literal list: a literal list silently leaves the
@@ -0,0 +1,20 @@
cmake_minimum_required(VERSION 3.14)
add_executable(
EsslShaderPassTest
EsslShaderPassTest.cpp
)
target_include_directories(EsslShaderPassTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
EsslShaderPassTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
include(GoogleTest)
gtest_discover_tests(EsslShaderPassTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -0,0 +1,369 @@
// MobileGL - MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.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 post-transpile textual passes the DirectGLES ("Espryt") backend runs over the ESSL
// SPIRV-Cross hands it (MG_Backend/DirectGLES/Utils.cpp). No GL context and no driver: the
// passes are pure String -> String, so the shapes they have to survive can be pinned here
// instead of only on a device.
#include <gtest/gtest.h>
#include <MG_Backend/DirectGLES/Utils.h>
using namespace MobileGL;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITE_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemoveLayoutBinding;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::SplitReadWriteImageUniforms;
namespace {
Bool Contains(const String& haystack, const String& needle) {
return haystack.find(needle) != String::npos;
}
SizeT CountOf(const String& haystack, const String& needle) {
SizeT count = 0;
for (SizeT pos = haystack.find(needle); pos != String::npos; pos = haystack.find(needle, pos + 1)) {
++count;
}
return count;
}
String WriteAlias(const String& name) { return String(IMAGE_WRITE_ALIAS_PREFIX) + name; }
} // namespace
// The bug the pass exists for. SPIRV-Cross speculatively marks every storage image
// NonWritable+NonReadable, then clears NonReadable at the OpImageRead and NonWritable at the
// OpImageWrite, so an image the shader both reads and writes comes out carrying NEITHER
// `readonly` nor `writeonly` - which ESSL rejects for any format other than r32f/r32i/r32ui
// (GLSL ES 3.20 4.10). The device compile then fails and the draw silently binds program 0.
TEST(SplitReadWriteImageUniformsTest, ReadWriteImageIsSplitIntoAnAliasingPair) {
const String source = R"(#version 320 es
layout(binding = 2, rgba8) uniform highp image2D goku;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
highp vec4 loaded = imageLoad(goku, ivec2(gl_FragCoord.xy));
imageStore(goku, ivec2(gl_FragCoord.xy), loaded + vec4(0.25));
mg_FragColor = loaded;
}
)";
const String out = SplitReadWriteImageUniforms(source);
// Both halves: same binding, same format, same type - which is what makes two image
// variables on one image unit legal.
EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform readonly highp image2D goku;"));
EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform writeonly highp image2D " + WriteAlias("goku") + ";"));
// The load keeps the original name, the store moves to the writeonly half.
EXPECT_TRUE(Contains(out, "imageLoad(goku,"));
EXPECT_TRUE(Contains(out, "imageStore(" + WriteAlias("goku") + ","));
EXPECT_FALSE(Contains(out, "imageStore(goku,"));
}
// The split has to survive RemoveLayoutBinding, which runs straight after it: an ES image
// unit cannot be assigned through the API, so the layout qualifier is the only binding
// mechanism and both halves must still carry theirs afterwards.
TEST(SplitReadWriteImageUniformsTest, BothHalvesKeepTheirBindingThroughRemoveLayoutBinding) {
const String source = R"(#version 320 es
layout(binding = 5, rgba8) uniform highp image2D goku;
void main()
{
imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0)));
}
)";
const String out = RemoveLayoutBinding(SplitReadWriteImageUniforms(source));
EXPECT_EQ(CountOf(out, "binding = 5"), 2u);
}
// Cheap hardening: the pass does not depend on SPIRV-Cross getting the read-only case right,
// and a shader that only reads must not pay for a second uniform.
TEST(SplitReadWriteImageUniformsTest, ReadOnlyImageGetsReadonlyAndIsNotSplit) {
const String source = R"(#version 320 es
layout(binding = 1, rgba16f) uniform highp image2DArray trunks;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
mg_FragColor = imageLoad(trunks, ivec3(0));
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba16f) uniform readonly highp image2DArray trunks;"));
EXPECT_FALSE(Contains(out, "writeonly"));
EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX));
EXPECT_EQ(CountOf(out, "image2DArray"), 1u);
}
TEST(SplitReadWriteImageUniformsTest, WriteOnlyImageGetsWriteonlyAndIsNotSplit) {
const String source = R"(#version 320 es
layout(binding = 3, rgba8) uniform highp image2D gohan;
void main()
{
imageStore(gohan, ivec2(0), vec4(1.0));
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "layout(binding = 3, rgba8) uniform writeonly highp image2D gohan;"));
EXPECT_FALSE(Contains(out, "readonly"));
EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX));
}
// r32f / r32i / r32ui are exactly the formats GLSL ES 3.20 4.10 exempts from the rule, so a
// read+write image in one of them is already legal and must not be doubled.
TEST(SplitReadWriteImageUniformsTest, ExemptFormatsAreLeftCompletelyAlone) {
for (const char* format : {"r32f", "r32i", "r32ui"}) {
const String type = String(format) == "r32f" ? "image2D" : (String(format) == "r32i" ? "iimage2D" : "uimage2D");
const String source = "#version 320 es\nlayout(binding = 4, " + String(format) + ") uniform highp " + type +
" vegeta;\nvoid main()\n{\n imageStore(vegeta, ivec2(0), imageLoad(vegeta, "
"ivec2(0)));\n}\n";
EXPECT_EQ(SplitReadWriteImageUniforms(source), source) << "format " << format;
}
}
// A declaration SPIRV-Cross already qualified is none of this pass's business.
TEST(SplitReadWriteImageUniformsTest, AlreadyQualifiedDeclarationsAreUntouched) {
const String source = R"(#version 320 es
layout(binding = 0, rgba8) uniform readonly highp image2D reader;
layout(binding = 1, rgba8) uniform writeonly highp image2D writer;
void main()
{
imageStore(writer, ivec2(0), imageLoad(reader, ivec2(0)));
}
)";
EXPECT_EQ(SplitReadWriteImageUniforms(source), source);
}
// The binding of an image array is the array's base; splitting must keep the array on both
// halves (dropping the subscript would silently turn 3 units into 1).
TEST(SplitReadWriteImageUniformsTest, ImageArraySplitsAndKeepsItsArraySize) {
const String source = R"(#version 320 es
layout(binding = 6, rgba8) uniform highp image2D gohan[3];
void main()
{
imageStore(gohan[1], ivec2(0), imageLoad(gohan[2], ivec2(0)));
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "layout(binding = 6, rgba8) uniform readonly highp image2D gohan[3];"));
EXPECT_TRUE(Contains(out,
"layout(binding = 6, rgba8) uniform writeonly highp image2D " + WriteAlias("gohan") + "[3];"));
EXPECT_TRUE(Contains(out, "imageStore(" + WriteAlias("gohan") + "[1],"));
EXPECT_TRUE(Contains(out, "imageLoad(gohan[2],"));
}
// The rewrite is by identifier, not by substring: "goku" must not reach into "goku_hd", and
// the two images have to be classified independently.
TEST(SplitReadWriteImageUniformsTest, ANameThatIsAPrefixOfAnotherIsNotClobbered) {
const String source = R"(#version 320 es
layout(binding = 1, rgba8) uniform highp image2D goku;
layout(binding = 2, rgba8) uniform highp image2D goku_hd;
void main()
{
highp vec4 loaded = imageLoad(goku, ivec2(0));
imageStore(goku, ivec2(0), loaded);
imageStore(goku_hd, ivec2(0), loaded);
}
)";
const String out = SplitReadWriteImageUniforms(source);
// goku is read+write -> split; goku_hd is write-only -> qualified in place, not split.
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba8) uniform readonly highp image2D goku;"));
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba8) uniform writeonly highp image2D " + WriteAlias("goku") + ";"));
EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform writeonly highp image2D goku_hd;"));
EXPECT_TRUE(Contains(out, "imageStore(goku_hd,"));
EXPECT_FALSE(Contains(out, WriteAlias("goku") + "_hd"));
EXPECT_FALSE(Contains(out, WriteAlias("goku_hd")));
}
// Other qualifiers belong to both halves, and the memory qualifier goes where SPIRV-Cross
// puts it (right after `uniform`) so the image-rebinding regex in Managers.cpp still matches.
TEST(SplitReadWriteImageUniformsTest, ExistingQualifiersAreCarriedOntoBothHalves) {
const String source = R"(#version 320 es
layout(binding = 2, rgba8) uniform coherent restrict highp image2D goku;
void main()
{
imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0)));
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "uniform readonly coherent restrict highp image2D goku;"));
EXPECT_TRUE(
Contains(out, "uniform writeonly coherent restrict highp image2D " + WriteAlias("goku") + ";"));
}
// imageSize reads no texels and writes none, so it decides nothing; readonly is what keeps
// such a declaration legal.
TEST(SplitReadWriteImageUniformsTest, ImageSizeAloneDoesNotCountAsALoadOrAStore) {
const String source = R"(#version 320 es
layout(binding = 8, rgba8ui) uniform highp uimage2D sizeOnly;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
mg_FragColor = vec4(float(imageSize(sizeOnly).x));
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "layout(binding = 8, rgba8ui) uniform readonly highp uimage2D sizeOnly;"));
EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX));
}
// The alias must not land on an identifier the shader already uses.
TEST(SplitReadWriteImageUniformsTest, AliasNameAvoidsAnExistingIdentifier) {
const String source = R"(#version 320 es
layout(binding = 6, rgba8) uniform highp image2D taken;
highp vec4 mg_imageWrite_taken;
void main()
{
imageStore(taken, ivec2(0), imageLoad(taken, ivec2(0)) + mg_imageWrite_taken);
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_FALSE(Contains(out, "image2D " + WriteAlias("taken") + ";"));
EXPECT_TRUE(Contains(out, "image2D " + WriteAlias("taken") + "X;"));
EXPECT_TRUE(Contains(out, "imageStore(" + WriteAlias("taken") + "X,"));
EXPECT_TRUE(Contains(out, "+ mg_imageWrite_taken)"));
}
// A use the pass cannot account for (here: the image handed to a user function) means it
// cannot know every store site, so it declines rather than emitting a half-rewritten shader.
TEST(SplitReadWriteImageUniformsTest, AnUnrecognizedUseLeavesTheDeclarationAlone) {
const String source = R"(#version 320 es
layout(binding = 2, rgba8) uniform highp image2D passed;
highp vec4 helper(highp image2D img) { return imageLoad(img, ivec2(0)); }
void main()
{
imageStore(passed, ivec2(0), helper(passed));
}
)";
EXPECT_EQ(SplitReadWriteImageUniforms(source), source);
}
TEST(SplitReadWriteImageUniformsTest, ShaderWithoutImagesIsReturnedUnchanged) {
const String source = R"(#version 320 es
layout(binding = 0) uniform highp sampler2D goku;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
mg_FragColor = texture(goku, vec2(0.5));
}
)";
EXPECT_EQ(SplitReadWriteImageUniforms(source), source);
}
// ---------------------------------------------------------------------------------------
// RetargetTextureBufferExtension
//
// Buffer textures are core in the OpenGL 3.1+ context MobileGL advertises, but in ES they
// only became core in 3.2; below that they need EXT_texture_buffer or OES_texture_buffer.
// SPIRV-Cross hardcodes the EXT spelling for every Dim=Buffer image it emits below ESSL 320
// and offers no way to ask for the other one, so on a driver that advertises only the OES
// name the `: require` is a hard compile error over a single token.
// ---------------------------------------------------------------------------------------
using Tier = MobileGL::MG_External::GLESCapabilities::TextureBufferTier;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RetargetTextureBufferExtension;
namespace {
// What SPIRV-Cross actually emits for `uniform isamplerBuffer CloudFaces;` at ESSL 310 -
// the shape that empties Minecraft 26.3's cloud layer on a driver without the extension.
const String kBufferTextureShader = R"(#version 310 es
#extension GL_EXT_texture_buffer : require
precision highp float;
uniform highp isamplerBuffer CloudFaces;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
mg_FragColor = vec4(texelFetch(CloudFaces, gl_VertexID).r);
}
)";
} // namespace
TEST(RetargetTextureBufferExtensionTest, OesOnlyDriverGetsTheOesDirective) {
const String out = RetargetTextureBufferExtension(kBufferTextureShader, Tier::ExtensionOES);
EXPECT_TRUE(Contains(out, "#extension GL_OES_texture_buffer : require"))
<< "the OES driver's own spelling must reach the directive:\n" << out;
EXPECT_FALSE(Contains(out, "GL_EXT_texture_buffer"))
<< "the EXT spelling this driver does not advertise must be gone:\n" << out;
// Only the directive changes; the declaration and the fetch are identical between the two
// extensions and must not be touched.
EXPECT_TRUE(Contains(out, "uniform highp isamplerBuffer CloudFaces;"));
EXPECT_TRUE(Contains(out, "texelFetch(CloudFaces, gl_VertexID)"));
}
TEST(RetargetTextureBufferExtensionTest, ExtDriverKeepsWhatSpirvCrossEmitted) {
EXPECT_EQ(RetargetTextureBufferExtension(kBufferTextureShader, Tier::ExtensionEXT),
kBufferTextureShader);
}
// ES 3.2 needs no directive at all, and SPIRV-Cross emits none at ESSL 320 - but a shader
// that arrived with one anyway must not be rewritten to a name the pass was not asked for.
TEST(RetargetTextureBufferExtensionTest, CoreAndUnsupportedTiersAreNoOps) {
EXPECT_EQ(RetargetTextureBufferExtension(kBufferTextureShader, Tier::CoreEs32),
kBufferTextureShader);
EXPECT_EQ(RetargetTextureBufferExtension(kBufferTextureShader, Tier::None),
kBufferTextureShader);
}
// The name is only the subject of a rewrite where it is the subject of an #extension
// directive. A shader that merely mentions it - in a comment SPIRV-Cross carried through, or
// in an identifier - is not an extension request and must come out byte-identical.
TEST(RetargetTextureBufferExtensionTest, OnlyExtensionDirectivesAreRewritten) {
const String source = R"(#version 310 es
// GL_EXT_texture_buffer is what this shader would need
precision highp float;
uniform highp float GL_EXT_texture_buffer_lookalike;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
mg_FragColor = vec4(GL_EXT_texture_buffer_lookalike);
}
)";
EXPECT_EQ(RetargetTextureBufferExtension(source, Tier::ExtensionOES), source);
}
// The dangerous collision, and the one the directive check alone does NOT catch:
// GL_EXT_texture_buffer is a strict prefix of GL_EXT_texture_buffer_object, a different and
// real extension that SPIRV-Cross emits from the same Dim=Buffer branch on its legacy-desktop
// path. Rewriting it would turn a valid request into one for a GL_OES_texture_buffer_object
// that does not exist. Only an identifier-boundary check saves this, so it gets its own test
// with the lookalike on a genuine #extension line.
TEST(RetargetTextureBufferExtensionTest, ALongerExtensionSharingThePrefixIsNotRewritten) {
const String source = R"(#version 310 es
#extension GL_EXT_texture_buffer_object : require
precision highp float;
void main() {}
)";
EXPECT_EQ(RetargetTextureBufferExtension(source, Tier::ExtensionOES), source);
// And when both appear, exactly the exact-match one moves.
const String mixed = R"(#version 310 es
#extension GL_EXT_texture_buffer_object : require
#extension GL_EXT_texture_buffer : require
precision highp float;
void main() {}
)";
const String out = RetargetTextureBufferExtension(mixed, Tier::ExtensionOES);
EXPECT_TRUE(Contains(out, "#extension GL_EXT_texture_buffer_object : require")) << out;
EXPECT_TRUE(Contains(out, "#extension GL_OES_texture_buffer : require")) << out;
EXPECT_EQ(CountOf(out, "GL_OES_texture_buffer_object"), 0u) << out;
}
// Whitespace between '#' and the keyword is legal in GLSL, and a shader carrying several
// extension directives must have exactly the one retargeted.
TEST(RetargetTextureBufferExtensionTest, SpacedDirectiveIsRewrittenAndNeighboursAreLeftAlone) {
const String source = R"(#version 310 es
# extension GL_EXT_texture_buffer : require
#extension GL_EXT_shader_io_blocks : require
precision highp float;
void main() {}
)";
const String out = RetargetTextureBufferExtension(source, Tier::ExtensionOES);
EXPECT_TRUE(Contains(out, "# extension GL_OES_texture_buffer : require")) << out;
EXPECT_TRUE(Contains(out, "#extension GL_EXT_shader_io_blocks : require"))
<< "an unrelated extension must survive untouched:\n" << out;
EXPECT_EQ(CountOf(out, "GL_OES_texture_buffer"), 1u);
}
@@ -52,6 +52,19 @@ namespace {
GLfloat maxTextureMaxAnisotropy = 16.0f; GLfloat maxTextureMaxAnisotropy = 16.0f;
bool maxTextureMaxAnisotropyQueried = false; bool maxTextureMaxAnisotropyQueried = false;
// Buffer textures. GL_MAX_TEXTURE_BUFFER_SIZE is only a legal pname once they exist, so
// asking on a driver without them raises GL_INVALID_ENUM - the same shape as the
// anisotropy probe above. The three entry-point knobs are separate because the
// unsuffixed name is the ES 3.2 CORE spelling while an EXT/OES driver exports the
// suffixed one: a resolver that only looks for the core name declares every extension
// driver unsupported, which is exactly the bug these knobs exist to pin.
GLint maxTextureBufferSize = 131072;
bool maxTextureBufferSizeQueried = false;
bool textureBufferSizeQueryRaisesError = false;
bool hasCoreTexBufferEntryPoint = true;
bool hasExtTexBufferEntryPoint = false;
bool hasOesTexBufferEntryPoint = false;
GLuint nextBufferId = 1; GLuint nextBufferId = 1;
GLuint nextShaderId = 1; GLuint nextShaderId = 1;
GLuint nextProgramId = 1; GLuint nextProgramId = 1;
@@ -121,6 +134,14 @@ namespace {
*data = g_fake.fragmentInterpolationOffsetBits; *data = g_fake.fragmentInterpolationOffsetBits;
} }
break; break;
case GL_MAX_TEXTURE_BUFFER_SIZE:
g_fake.maxTextureBufferSizeQueried = true;
if (g_fake.textureBufferSizeQueryRaisesError) {
g_fake.pendingError = GL_INVALID_ENUM;
} else {
*data = g_fake.maxTextureBufferSize;
}
break;
// FillInGLESCapabilities reads the context version before running the // FillInGLESCapabilities reads the context version before running the
// baseInstance probe, which requires ES >= 3.1. // baseInstance probe, which requires ES >= 3.1.
case GL_MAJOR_VERSION: case GL_MAJOR_VERSION:
@@ -332,6 +353,21 @@ namespace {
funcs.glDisable = [](GLenum) {}; funcs.glDisable = [](GLenum) {};
funcs.glMemoryBarrier = [](GLbitfield) {}; funcs.glMemoryBarrier = [](GLbitfield) {};
// Buffer-texture entry points, each present only when its knob says so. A real loader
// resolves the suffixed names only on a driver whose support is that extension.
funcs.glTexBuffer = g_fake.hasCoreTexBufferEntryPoint
? static_cast<MobileGL::MG_External::GLES::glTexBuffer_PTR>(
[](GLenum, GLenum, GLuint) {})
: nullptr;
funcs.glTexBufferEXT = g_fake.hasExtTexBufferEntryPoint
? static_cast<MobileGL::MG_External::GLES::glTexBufferEXT_PTR>(
[](GLenum, GLenum, GLuint) {})
: nullptr;
funcs.glTexBufferOES = g_fake.hasOesTexBufferEntryPoint
? static_cast<MobileGL::MG_External::GLES::glTexBufferOES_PTR>(
[](GLenum, GLenum, GLuint) {})
: nullptr;
// The probe's vertex shader writes the gl_InstanceID it observed into the // The probe's vertex shader writes the gl_InstanceID it observed into the
// result SSBO at binding 0. A conforming driver observes 0; a leaking one // result SSBO at binding 0. A conforming driver observes 0; a leaking one
// observes the indirect command's baseInstance word (byte offset 12). // observes the indirect command's baseInstance word (byte offset 12).
@@ -520,6 +556,150 @@ TEST(FragmentInterpolationCapabilities, QueriesOnlyWhenSupportedAndPreservesDriv
EXPECT_EQ(funcs.glGetError(), GL_NO_ERROR); EXPECT_EQ(funcs.glGetError(), GL_NO_ERROR);
} }
// Buffer textures are core in the OpenGL 3.1+ context MobileGL advertises but need ES 3.2 or
// EXT/OES_texture_buffer on the host. The tier decides three things at once: whether glTexBuffer
// may be called at all, which #extension directive the emitted ESSL must carry, and whether
// GL_MAX_TEXTURE_BUFFER_SIZE is a driver answer or MobileGL's own floor.
using TextureBufferTier = MobileGL::MG_External::GLESCapabilities::TextureBufferTier;
TEST(BufferTextureCapabilities, Es32ResolvesToCoreAndTakesTheDriverLimit) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.glesMinorVersion = 2;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::CoreEs32);
EXPECT_TRUE(caps.MaxTextureBufferSizeIsDriverReported);
EXPECT_EQ(caps.MaxTextureBufferSize, g_fake.maxTextureBufferSize);
EXPECT_TRUE(g_fake.maxTextureBufferSizeQueried);
}
// The regression this pins: an ES 3.1 driver whose support is GL_EXT_texture_buffer exports
// glTexBufferEXT and NOT the unsuffixed core name. A resolver that requires the core pointer
// declares this driver unsupported and then refuses to compile shaders it could have run.
TEST(BufferTextureCapabilities, Es31WithExtResolvesThroughTheSuffixedEntryPoint) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_EXT_texture_buffer");
g_fake.hasCoreTexBufferEntryPoint = false;
g_fake.hasExtTexBufferEntryPoint = true;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::ExtensionEXT);
EXPECT_TRUE(caps.MaxTextureBufferSizeIsDriverReported);
EXPECT_EQ(caps.MaxTextureBufferSize, g_fake.maxTextureBufferSize);
}
TEST(BufferTextureCapabilities, Es31WithOesResolvesThroughTheSuffixedEntryPoint) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_OES_texture_buffer");
g_fake.hasCoreTexBufferEntryPoint = false;
g_fake.hasOesTexBufferEntryPoint = true;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
// The tier, not just a boolean: it is what selects the OES spelling of the #extension
// directive SPIRV-Cross hardcodes as EXT.
EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::ExtensionOES);
EXPECT_TRUE(caps.MaxTextureBufferSizeIsDriverReported);
}
// EXT wins over OES on a driver advertising both, because SPIRV-Cross emits the EXT spelling
// natively and that tier needs no directive rewriting at all.
TEST(BufferTextureCapabilities, ExtIsPreferredWhenBothExtensionsArePresent) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_OES_texture_buffer");
g_fake.extensions.emplace_back("GL_EXT_texture_buffer");
g_fake.hasExtTexBufferEntryPoint = true;
g_fake.hasOesTexBufferEntryPoint = true;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::ExtensionEXT);
}
// The motivating driver (the emulator SDK's ANGLE: ES 3.1, neither extension). The pname is
// never asked - it would raise GL_INVALID_ENUM - and the floor MobileGL keeps advertising is
// flagged as not being a driver answer, because an OpenGL 4.x context may not report 0.
TEST(BufferTextureCapabilities, Es31WithNeitherExtensionIsUnsupportedAndNeverQueriesTheLimit) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::None);
EXPECT_FALSE(caps.MaxTextureBufferSizeIsDriverReported);
EXPECT_FALSE(g_fake.maxTextureBufferSizeQueried);
EXPECT_EQ(caps.MaxTextureBufferSize, 65536) << "the OpenGL 3.1 spec floor, not the fake's limit";
}
// An extension string with no entry point behind it is not support. This is the ES analogue of
// the multi-draw stub hazard: eglGetProcAddress may hand back live-looking pointers, so the
// two signals are required together.
TEST(BufferTextureCapabilities, AnExtensionStringWithoutAnEntryPointIsNotSupport) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_EXT_texture_buffer");
g_fake.hasCoreTexBufferEntryPoint = false;
g_fake.hasExtTexBufferEntryPoint = false;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::None);
EXPECT_FALSE(caps.MaxTextureBufferSizeIsDriverReported);
}
// A driver that claims buffer textures and then refuses the query is a driver bug. The floor
// stands in, and the flag says the number was not the driver's - the POST row and the
// capability log both branch on exactly that.
TEST(BufferTextureCapabilities, ARejectedLimitQueryIsDrainedAndMarkedAsNotDriverReported) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.glesMinorVersion = 2;
g_fake.textureBufferSizeQueryRaisesError = true;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::CoreEs32);
EXPECT_TRUE(g_fake.maxTextureBufferSizeQueried);
EXPECT_FALSE(caps.MaxTextureBufferSizeIsDriverReported);
EXPECT_EQ(caps.MaxTextureBufferSize, 65536);
EXPECT_EQ(funcs.glGetError(), GL_NO_ERROR) << "the failed query must not leave an error behind";
}
// A stale error from an earlier probe must not be mistaken for this query failing.
TEST(BufferTextureCapabilities, AStaleErrorDoesNotDiscardTheDriverLimit) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.glesMinorVersion = 2;
g_fake.pendingError = GL_INVALID_OPERATION;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_TRUE(caps.MaxTextureBufferSizeIsDriverReported);
EXPECT_EQ(caps.MaxTextureBufferSize, g_fake.maxTextureBufferSize);
}
TEST(FragmentInterpolationCapabilities, QueryErrorIsDrainedAndFallsBackToCoreMinimums) { TEST(FragmentInterpolationCapabilities, QueryErrorIsDrainedAndFallsBackToCoreMinimums) {
ResetFakeDriver(); ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0; g_fake.maxVertexSsboBlocks = 0;
+52
View File
@@ -480,6 +480,58 @@ TEST_F(BufferTest, BindBufferRangeZeroUnbindsBindingPoint) {
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR); EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
} }
// GL 4.6 core tables 23.4/23.5: *_BUFFER_START and *_BUFFER_SIZE report the (offset, size) pair
// glBindBufferRange was ASKED for. They are not clamped to the buffer's storage - a range may
// legally name bytes the buffer does not have, and glBufferData may resize the buffer afterwards
// without the binding's reported window moving. The size arm used to intersect the recorded range
// with the buffer's current size, so binding a range on a still-empty buffer (glGenBuffers with no
// glBufferData - exactly what KHR-GL43.shader_storage_buffer_object.basic-binding does) answered 0
// while START still answered the offset, an internally inconsistent pair no driver reports.
TEST_F(BufferTest, IndexedBufferSizeQueryReportsTheRequestedSizeNotTheBuffersStorage) {
GLint ssboAlignment = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &ssboAlignment);
ASSERT_GT(ssboAlignment, 0);
const GLintptr offset = ssboAlignment;
const GLsizeiptr size = 512;
GLuint buffer = 0;
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
// Deliberately no glBufferData: the name exists, the storage does not.
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 1, buffer, offset, size);
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
GLint start32 = 0;
GLint size32 = 0;
GLint64 start64 = 0;
GLint64 size64 = 0;
MobileGL::MG_Impl::GLImpl::GetIntegeri_v(GL_SHADER_STORAGE_BUFFER_START, 1, &start32);
MobileGL::MG_Impl::GLImpl::GetIntegeri_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &size32);
MobileGL::MG_Impl::GLImpl::GetInteger64i_v(GL_SHADER_STORAGE_BUFFER_START, 1, &start64);
MobileGL::MG_Impl::GLImpl::GetInteger64i_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &size64);
EXPECT_EQ(start32, static_cast<GLint>(offset));
EXPECT_EQ(size32, static_cast<GLint>(size));
EXPECT_EQ(start64, static_cast<GLint64>(offset));
EXPECT_EQ(size64, static_cast<GLint64>(size));
// Giving the buffer storage afterwards does not move the window either way.
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, offset + size, nullptr, GL_DYNAMIC_DRAW);
MobileGL::MG_Impl::GLImpl::GetIntegeri_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &size32);
EXPECT_EQ(size32, static_cast<GLint>(size));
// glBindBufferBase binds the whole buffer and reports (0, 0), not the buffer's size.
MobileGL::MG_Impl::GLImpl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, buffer);
MobileGL::MG_Impl::GLImpl::GetIntegeri_v(GL_SHADER_STORAGE_BUFFER_START, 1, &start32);
MobileGL::MG_Impl::GLImpl::GetIntegeri_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &size32);
EXPECT_EQ(start32, 0);
EXPECT_EQ(size32, 0);
MobileGL::MG_Impl::GLImpl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, 0);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
TEST_F(BufferTest, GetInteger64vMaxShaderStorageBlockSize) { TEST_F(BufferTest, GetInteger64vMaxShaderStorageBlockSize) {
GLint64 maxSsboBlockSize = 0; GLint64 maxSsboBlockSize = 0;
MobileGL::MG_Impl::GLImpl::GetInteger64v(GL_MAX_SHADER_STORAGE_BLOCK_SIZE, &maxSsboBlockSize); MobileGL::MG_Impl::GLImpl::GetInteger64v(GL_MAX_SHADER_STORAGE_BLOCK_SIZE, &maxSsboBlockSize);
+3
View File
@@ -78,6 +78,9 @@ add_subdirectory(Query)
add_subdirectory(Pipeline) add_subdirectory(Pipeline)
add_subdirectory(ShaderTranspiler) add_subdirectory(ShaderTranspiler)
add_subdirectory(Util) add_subdirectory(Util)
# The DirectGLES post-transpile ESSL passes are pure String -> String, so unlike the
# DirectVulkan suite below this one needs no device and always builds.
add_subdirectory(Backend/DirectGLES)
if (ENABLE_INTEGRATION_TESTS) if (ENABLE_INTEGRATION_TESTS)
add_subdirectory(Backend/DirectVulkan) add_subdirectory(Backend/DirectVulkan)
endif() endif()
+70 -3
View File
@@ -117,11 +117,26 @@ void main() { fragColor = thisIdentifierWasNeverDeclared; }
} }
// The non-joining view of the program, i.e. what GL_COMPLETION_STATUS_KHR will report. // The non-joining view of the program, i.e. what GL_COMPLETION_STATUS_KHR will report.
// BOTH phases: a program whose SPIR-V job is still in flight is not finished, even though
// its whole GL query surface already answers.
Bool LinkIsSettled(const GLuint program) { Bool LinkIsSettled(const GLuint program) {
const auto& object = MG_State::pGLContext->GetProgramObject(program); const auto& object = MG_State::pGLContext->GetProgramObject(program);
return object == nullptr || object->IsLinkComplete(); return object == nullptr || object->IsLinkComplete();
} }
// Phase A alone: the half that decides LINK_STATUS, the info log, and every reflection
// query. This is what a read of LINK_STATUS is required to settle.
Bool PhaseALinkIsSettled(const GLuint program) {
const auto& object = MG_State::pGLContext->GetProgramObject(program);
return object == nullptr || object->IsPhaseALinkComplete();
}
// Phase B alone: SPIR-V + the uniform shadow's layout.
Bool SpirvIsSettled(const GLuint program) {
const auto& object = MG_State::pGLContext->GetProgramObject(program);
return object == nullptr || object->IsSpirvComplete();
}
// Enqueues `count` distinct heavy compiles without reading anything back, so the pool is // Enqueues `count` distinct heavy compiles without reading anything back, so the pool is
// left with a real backlog for the caller to race against. // left with a real backlog for the caller to race against.
Vector<GLuint> SaturatePool(const int count, Vector<String>& sourceStorage) { Vector<GLuint> SaturatePool(const int count, Vector<String>& sourceStorage) {
@@ -516,11 +531,63 @@ TEST_F(AsyncLinkTest, LinkProgramReturnsBeforeTheWorkIsDone) {
for (const GLuint program : programs) { for (const GLuint program : programs) {
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_TRUE(LinkIsSettled(program)) << "reading LINK_STATUS must have joined"; // PHASE A only. Reading LINK_STATUS settles the half that decides it, and no more -
// the SPIR-V job may well still be running, which is the entire point of the split.
EXPECT_TRUE(PhaseALinkIsSettled(program)) << "reading LINK_STATUS must have joined phase A";
} }
EXPECT_EQ(GetError(), GL_NO_ERROR); EXPECT_EQ(GetError(), GL_NO_ERROR);
} }
// The other half of the previous case, and the property the two-phase split exists for:
// LINK_STATUS is answerable without the SPIR-V, so a run of LINK_STATUS reads over a
// backlog must leave SPIR-V jobs outstanding rather than draining them one by one.
TEST_F(AsyncLinkTest, ReadingLinkStatusDoesNotSettleTheSpirvJob) {
const AsyncModeScope async(true);
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(1);
constexpr int kPrograms = 24;
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
Vector<GLuint> programs;
Vector<String> sources;
for (int i = 0; i < kPrograms; ++i) {
sources.push_back(MakeBulkySource(7900 + i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
programs.push_back(program);
}
int spirvOutstanding = 0;
for (int i = 0; i < kPrograms; ++i) {
const GLuint program = programs[static_cast<SizeT>(i)];
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_TRUE(PhaseALinkIsSettled(program)) << "reading LINK_STATUS must have joined phase A";
// Reflection has to answer here too, out of phase A and with no further join.
const String uniformName = "uSeed" + std::to_string(7900 + i);
EXPECT_GE(GetUniformLocation(program, uniformName.c_str()), 0) << uniformName;
if (!SpirvIsSettled(program)) ++spirvOutstanding;
}
EXPECT_GT(spirvOutstanding, 0) << "the whole GL query surface was answered and yet every SPIR-V job had "
"already been drained - the reads are joining phase B";
// And the SPIR-V gate really is a gate: touching it settles the job.
for (const GLuint program : programs) {
const auto& object = MG_State::pGLContext->GetProgramObject(program);
ASSERT_NE(object, nullptr);
EXPECT_GT(object->GetGeneratedSpirv().size(), 0u);
EXPECT_TRUE(SpirvIsSettled(program));
EXPECT_TRUE(LinkIsSettled(program));
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(
MG_Util::Async::ShaderCompilePool::Get().GetThreadCount());
}
// With the flag off, a link is finished by the time glLinkProgram returns. This is the guard // With the flag off, a link is finished by the time glLinkProgram returns. This is the guard
// that keeps the default shippable. // that keeps the default shippable.
TEST_F(AsyncLinkTest, LinkIsFullySynchronousWithAsyncOff) { TEST_F(AsyncLinkTest, LinkIsFullySynchronousWithAsyncOff) {
@@ -561,8 +628,8 @@ TEST_F(AsyncLinkTest, DrawThroughAPipelineWithAPendingStageProgramJoinsFirst) {
GLuint pipeline = 0; GLuint pipeline = 0;
GenProgramPipelines(1, &pipeline); GenProgramPipelines(1, &pipeline);
ASSERT_NE(pipeline, 0u); ASSERT_NE(pipeline, 0u);
// Bind before UseProgramStages: glGenProgramPipelines only reserves the name, and the // Bound first only because this test draws through the pipeline; glUseProgramStages no
// first bind is what turns it into an object glUseProgramStages can find. // longer needs it (it materializes a reserved name itself, GL 4.6 core 7.4).
BindProgramPipeline(pipeline); BindProgramPipeline(pipeline);
UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram); UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram);
ASSERT_EQ(GetError(), GL_NO_ERROR); ASSERT_EQ(GetError(), GL_NO_ERROR);
File diff suppressed because it is too large Load Diff
+74
View File
@@ -49,6 +49,22 @@ add_executable(
AsyncLinkTest.cpp AsyncLinkTest.cpp
) )
add_executable(
OptimisticStatusTest
OptimisticStatusTest.cpp
)
target_include_directories(OptimisticStatusTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
OptimisticStatusTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
target_include_directories(AsyncLinkTest PRIVATE target_include_directories(AsyncLinkTest PRIVATE
${MGL_ROOT}/include ${MGL_ROOT}/include
${MGL_ROOT}/MobileGL ${MGL_ROOT}/MobileGL
@@ -60,6 +76,24 @@ target_link_libraries(
${LINK_LIBRARIES} ${LINK_LIBRARIES}
) )
# Its own binary, like the other async suites: its cases pin the compile pool down to one
# worker so a phase-B job really is still queued while the GL query surface is being read.
add_executable(
AsyncSpirvPhaseTest
AsyncSpirvPhaseTest.cpp
)
target_include_directories(AsyncSpirvPhaseTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
AsyncSpirvPhaseTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
add_executable( add_executable(
ShaderCompileAdoptionTest ShaderCompileAdoptionTest
ShaderCompileAdoptionTest.cpp ShaderCompileAdoptionTest.cpp
@@ -130,6 +164,38 @@ target_link_libraries(
${LINK_LIBRARIES} ${LINK_LIBRARIES}
) )
add_executable(
XfbBlockVaryingTest
XfbBlockVaryingTest.cpp
)
target_include_directories(XfbBlockVaryingTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
XfbBlockVaryingTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
add_executable(
ProgramPipelineCompositeTest
ProgramPipelineCompositeTest.cpp
)
target_include_directories(ProgramPipelineCompositeTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
ProgramPipelineCompositeTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
add_executable( add_executable(
ProgramInterfaceTest ProgramInterfaceTest
ProgramInterfaceTest.cpp ProgramInterfaceTest.cpp
@@ -161,15 +227,23 @@ include(GoogleTest)
gtest_discover_tests(ProgramUtilTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(ProgramUtilTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ProgramInterfaceTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(ProgramInterfaceTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ProgramPipelineCompositeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(XfbBlockVaryingTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
# Heavier than the rest of the unit suite by design: several cases deliberately saturate the # Heavier than the rest of the unit suite by design: several cases deliberately saturate the
# compile pool so there is something in flight to race against. # compile pool so there is something in flight to race against.
gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
gtest_discover_tests(AsyncLinkTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) gtest_discover_tests(AsyncLinkTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
# Same reason: every case here links a batch against a one-worker pool so that phase B is
# genuinely outstanding while phase A is being interrogated.
gtest_discover_tests(AsyncSpirvPhaseTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
# Same reason: the stage-6 cases keep a backlog in flight so a release really can race a # Same reason: the stage-6 cases keep a backlog in flight so a release really can race a
# worker, and the 48-object stress links every one of them. # worker, and the 48-object stress links every one of them.
gtest_discover_tests(ShaderCompileAdoptionTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) gtest_discover_tests(ShaderCompileAdoptionTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
# Same reason: the GL_COMPLETION_STATUS_KHR cases saturate a one-worker pool on purpose. # Same reason: the GL_COMPLETION_STATUS_KHR cases saturate a one-worker pool on purpose.
gtest_discover_tests(ParallelShaderCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) gtest_discover_tests(ParallelShaderCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
# Same reason: the optimistic-window cases need a saturated one-worker pool to observe an
# in-flight compile, and the two-phase replay links 48 programs across both flag states.
gtest_discover_tests(OptimisticStatusTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
gtest_discover_tests(AsyncTeardownTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) gtest_discover_tests(AsyncTeardownTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
# Same reason again: several cases leave A links outstanding while B compiles and links. # Same reason again: several cases leave A links outstanding while B compiles and links.
gtest_discover_tests(XfbFrontendOrderInvarianceTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) gtest_discover_tests(XfbFrontendOrderInvarianceTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
@@ -0,0 +1,674 @@
// MobileGL - MobileGL/MG_Test/Program/OptimisticStatusTest.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
// MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS: while a compile job is in flight, the two
// per-shader queries that would join it - GL_COMPILE_STATUS and the info log - answer
// optimistically instead, and the first such answer latches for that compile's lifetime
// (ShaderObject::TakeOptimisticCompileAnswer). These cases pin the corners of that
// contract: the default still joins, the optimistic window really answers without
// joining, the latch keeps the three queries telling one story even after the job
// settles, a real failure still fails the program link with the compile log quoted, and
// the Iris-shaped two-phase batch produces reflection identical to the joining path.
//
// Determinism note: the cases that need "a compile that cannot have settled yet" do not
// race the pool - they occupy its single concurrency slot with a gate-blocked job
// (PoolBlocker), so the assertions are hard EXPECTs rather than skip-if-drained guesses.
// A quirk that silently reverts to joining DEADLOCKS such a case into its 300s ctest
// timeout instead of passing - ugly, but a failure, which is the point.
//
// Like AsyncCompileTest, every case drives the real GL entry points and flips the
// MG_Config::Features fields itself rather than reading the environment, so one binary
// asserts both flag states regardless of how the suite was launched.
#include <gtest/gtest.h>
#include <algorithm>
#include <chrono>
#include <condition_variable>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "Config.h"
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_State/GLState/Core.h"
#include "MG_Util/Async/JobNode.h"
#include "MG_Util/Async/ShaderCompilePool.h"
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
namespace {
class AsyncModeScope {
public:
explicit AsyncModeScope(const Bool async) : m_saved(MG_Config::Features.AsyncShaderCompile) {
MG_Config::Features.AsyncShaderCompile =
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
}
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
AsyncModeScope(const AsyncModeScope&) = delete;
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
private:
const MG_Config::QuirkOverride m_saved;
};
class OptimisticStatusScope {
public:
explicit OptimisticStatusScope(const MG_Config::QuirkOverride mode)
: m_saved(MG_Config::Features.AsyncOptimisticShaderStatus) {
MG_Config::Features.AsyncOptimisticShaderStatus = mode;
}
~OptimisticStatusScope() { MG_Config::Features.AsyncOptimisticShaderStatus = m_saved; }
OptimisticStatusScope(const OptimisticStatusScope&) = delete;
OptimisticStatusScope& operator=(const OptimisticStatusScope&) = delete;
private:
const MG_Config::QuirkOverride m_saved;
};
// glMaxShaderCompilerThreadsKHR writes PROCESS-wide state (the pool's concurrency budget
// and the suspension latch), so a case that touches it has to put both back or it
// poisons every case declared after it in this binary.
class CompilerThreadScope {
public:
CompilerThreadScope() = default;
~CompilerThreadScope() {
MG_Util::Async::SetAsyncShaderCompileSuspended(false);
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(
MG_Util::Async::ShaderCompilePool::Get().GetThreadCount());
}
CompilerThreadScope(const CompilerThreadScope&) = delete;
CompilerThreadScope& operator=(const CompilerThreadScope&) = delete;
};
// A job that occupies a pool slot until released, holding everything queued behind it
// in a provably-unsettled state. Same gate idea as JobNodeTest's TestJob+Gate; waiting
// on a test-owned gate inside a body does not violate the pool's no-job-waits-on-job
// rule - there is no other JOB involved.
class PoolBlocker final : public MG_Util::Async::JobNode {
public:
void Release() {
{
const std::lock_guard<std::mutex> lock(m_mutex);
m_open = true;
}
m_cv.notify_all();
}
protected:
void RunBody() override {
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this] { return m_open; });
}
private:
std::mutex m_mutex;
std::condition_variable m_cv;
Bool m_open = false;
};
// Budget 1 + a blocked job in the only slot: from construction until Release(), no
// shader compile posted afterwards can run, let alone settle. The destructor releases
// and joins so no case can leak a wedged pool into the next one.
class BlockedPoolScope {
public:
BlockedPoolScope() : m_blocker(MakeShared<PoolBlocker>()) {
MaxShaderCompilerThreadsKHR(1);
MG_Util::Async::ShaderCompilePool::Get().Post(m_blocker);
}
~BlockedPoolScope() { Release(); }
void Release() {
m_blocker->Release();
m_blocker->Wait();
}
BlockedPoolScope(const BlockedPoolScope&) = delete;
BlockedPoolScope& operator=(const BlockedPoolScope&) = delete;
private:
SharedPtr<PoolBlocker> m_blocker;
};
const char* kBrokenFs = R"(#version 460
layout(location = 0) out vec4 fragColor;
void main() { fragColor = thisIdentifierWasNeverDeclared; }
)";
// Expensive enough that a compile is not instantaneous, and distinct per index so the
// source-hash memo and the stage-6 adoption map never turn a second instance into a
// no-op. Callers pass disjoint seed ranges for the same reason - two calls in one case
// must never regenerate the same text.
String MakeBulkySource(const int index) {
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
source += "uniform float uSeed" + std::to_string(index) + ";\n";
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
for (int i = 0; i < 320; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
}
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
return source;
}
// The two stages of one Iris-shaped program. Distinct per index (so nothing is memoized
// across programs) but IDENTICAL between the quirk-off and quirk-on replays of the same
// index, which is what makes the reflection comparison meaningful.
String MakeIrisVs(const int index) {
String source = "#version 460\nlayout(location = 0) in vec3 aPos;\n";
source += "uniform mat4 uModel" + std::to_string(index) + ";\n";
source += "uniform vec4 uTint;\nout vec4 vColor;\n";
source += "void main() {\n vColor = uTint;\n gl_Position = uModel" + std::to_string(index) +
" * vec4(aPos, 1.0);\n}\n";
return source;
}
String MakeIrisFs(const int index) {
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\nin vec4 vColor;\n";
source += "uniform float uSeed" + std::to_string(index) + ";\nuniform vec2 uOffset;\n";
source += "void main() {\n float acc = uSeed" + std::to_string(index) + " + uOffset.x;\n";
for (int i = 0; i < 40; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0);\n";
}
source += " fragColor = vColor + vec4(acc, uOffset.y, 0.0, 1.0);\n}\n";
return source;
}
GLuint MakeShader(const GLenum type, const char* source) {
const GLuint shader = CreateShader(type);
ShaderSource(shader, 1, &source, nullptr);
CompileShader(shader);
return shader;
}
GLint QueryShaderCompletion(const GLuint shader) {
GLint status = -1;
GetShaderiv(shader, GL_COMPLETION_STATUS_KHR, &status);
return status;
}
GLint QueryCompileStatus(const GLuint shader) {
GLint status = GL_FALSE;
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
return status;
}
GLint QueryInfoLogLength(const GLuint shader) {
GLint length = -1;
GetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
return length;
}
String QueryShaderInfoLog(const GLuint shader) {
std::vector<GLchar> buffer(65536);
GLsizei written = 0;
GetShaderInfoLog(shader, (GLsizei)buffer.size(), &written, buffer.data());
return String(buffer.data(), static_cast<size_t>(written));
}
GLint QueryLinkStatus(const GLuint program) {
GLint status = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &status);
return status;
}
GLint QueryProgramCompletion(const GLuint program) {
GLint status = -1;
GetProgramiv(program, GL_COMPLETION_STATUS_KHR, &status);
return status;
}
String QueryProgramInfoLog(const GLuint program) {
// Iris reads through an explicit 32768-byte buffer; mirror that cap so the
// log-ordering contract is asserted through the same window the application has.
std::vector<GLchar> buffer(32768);
GLsizei written = 0;
GetProgramInfoLog(program, (GLsizei)buffer.size(), &written, buffer.data());
return String(buffer.data(), static_cast<size_t>(written));
}
// Enqueues `count` distinct heavy compiles without reading anything back. Seed bases
// must be disjoint across calls within one case (see MakeBulkySource).
Vector<GLuint> SaturatePool(const int count, const int seedBase, Vector<String>& sourceStorage) {
Vector<GLuint> shaders;
shaders.reserve(static_cast<SizeT>(count));
for (int i = 0; i < count; ++i) {
sourceStorage.push_back(MakeBulkySource(seedBase + i));
const char* text = sourceStorage.back().c_str();
const GLuint shader = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(shader, 1, &text, nullptr);
CompileShader(shader);
shaders.push_back(shader);
}
return shaders;
}
// One program driven through Iris's exact phase-1 shape: create, source, compile, read
// the info log then the compile status (GlShader.createShader's order), attach, bind an
// attrib, link, detach, delete. NO program-level query of any kind.
GLuint RunIrisPhaseOne(const String& vsSource, const String& fsSource) {
const char* vsText = vsSource.c_str();
const char* fsText = fsSource.c_str();
const GLuint vs = CreateShader(GL_VERTEX_SHADER);
ShaderSource(vs, 1, &vsText, nullptr);
CompileShader(vs);
(void)QueryShaderInfoLog(vs);
(void)QueryCompileStatus(vs);
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &fsText, nullptr);
CompileShader(fs);
(void)QueryShaderInfoLog(fs);
(void)QueryCompileStatus(fs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
BindAttribLocation(program, 0, "aPos");
LinkProgram(program);
DetachShader(program, vs);
DetachShader(program, fs);
DeleteShader(vs);
DeleteShader(fs);
return program;
}
// Phase 2, also in Iris's order: LINK_STATUS first, then the by-name location lookups,
// then the GL_ACTIVE_UNIFORMS enumeration ProgramUniforms$Builder.buildUniforms does.
struct ProgramReflection {
GLint linkStatus = GL_FALSE;
Vector<std::pair<String, GLint>> locations; // queried name -> location
Vector<std::tuple<String, GLenum, GLint, GLint>> activeUniforms; // name, type, size, location
};
ProgramReflection RunIrisPhaseTwo(const GLuint program, const Vector<String>& names) {
ProgramReflection out;
out.linkStatus = QueryLinkStatus(program);
for (const String& name : names) {
out.locations.emplace_back(name, GetUniformLocation(program, name.c_str()));
}
GLint activeCount = 0;
GetProgramiv(program, GL_ACTIVE_UNIFORMS, &activeCount);
for (GLint i = 0; i < activeCount; ++i) {
GLchar name[128] = {};
GLsizei written = 0;
GLint size = 0;
GLenum type = 0;
GetActiveUniform(program, (GLuint)i, (GLsizei)sizeof(name), &written, &size, &type, name);
const String nameStr(name, static_cast<size_t>(written));
out.activeUniforms.emplace_back(nameStr, type, size, GetUniformLocation(program, name));
}
// The enumeration order is an implementation detail; the SET is the contract.
std::sort(out.activeUniforms.begin(), out.activeUniforms.end());
return out;
}
class OptimisticStatusTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
};
} // namespace
// ---------------------------------------------------------------------------------------
// The default still joins
// ---------------------------------------------------------------------------------------
// With the quirk unset (Auto = the shipped default), GL_COMPILE_STATUS on a pending compile
// must join it: after the query, the node is terminal. This is the case that guards the
// default against ever silently flipping. No blocker here - a blocked pool would turn the
// (correct) joining behaviour into a deadlock; a plain backlog only makes the pre-join
// state likely, and the assertion is valid either way.
TEST_F(OptimisticStatusTest, OffByDefaultTheStatusStillJoins) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::Auto);
const CompilerThreadScope threads;
MaxShaderCompilerThreadsKHR(1);
Vector<String> backlog;
const Vector<GLuint> saturation = SaturatePool(8, 70000, backlog);
const Vector<GLuint> probes = SaturatePool(1, 71000, backlog);
const GLuint probe = probes[0];
EXPECT_EQ(QueryCompileStatus(probe), GL_TRUE);
EXPECT_EQ(QueryShaderCompletion(probe), GL_TRUE)
<< "GL_COMPILE_STATUS with the quirk off must have joined the job";
for (const GLuint shader : saturation) DeleteShader(shader);
DeleteShader(probe);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// The optimistic window, deterministically
// ---------------------------------------------------------------------------------------
// A compile that provably cannot have settled (the pool's only slot is gate-blocked)
// answers GL_TRUE / length 0 / empty log, and GL_COMPLETION_STATUS_KHR still reads
// GL_FALSE after all three - i.e. none of them joined. Hard EXPECTs, no skip: if the
// quirk silently reverts to joining, the status read deadlocks against the blocked pool
// and the case fails by timeout.
TEST_F(OptimisticStatusTest, PendingCompileReportsTrueAndEmptyLogWithoutJoining) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOn);
const CompilerThreadScope threads;
const BlockedPoolScope blocked;
Vector<String> storage;
const Vector<GLuint> probes = SaturatePool(1, 72000, storage);
const GLuint probe = probes[0];
EXPECT_EQ(QueryCompileStatus(probe), GL_TRUE) << "an in-flight compile must answer GL_TRUE";
EXPECT_EQ(QueryInfoLogLength(probe), 0) << "an in-flight compile must answer an empty log length";
EXPECT_TRUE(QueryShaderInfoLog(probe).empty()) << "an in-flight compile must answer an empty log";
EXPECT_EQ(QueryShaderCompletion(probe), GL_FALSE)
<< "the three reads above must not have joined the blocked job";
DeleteShader(probe);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// The latch: one story per compile
// ---------------------------------------------------------------------------------------
// The torn-pair regression case. A broken shader's log and status are read while the job
// is provably in flight (optimistic empty/GL_TRUE), the job then settles, and the app
// re-reads: the latch must keep the answers optimistic - GL_TRUE, empty log - rather than
// flip to the real GL_FALSE next to the already-consumed empty log. The real failure then
// surfaces at the link, with the compile error inside the application's 32768-byte read
// window (the compile log leads the quoted source in ConsumeShaders' format).
TEST_F(OptimisticStatusTest, LatchKeepsOneStoryPerCompileAndTheLinkCarriesTheDiagnostic) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOn);
const CompilerThreadScope threads;
const GLuint vs = CreateShader(GL_VERTEX_SHADER);
const char* vsText =
"#version 460\nlayout(location = 0) in vec3 aPos;\nvoid main() { gl_Position = vec4(aPos, 1.0); }\n";
ShaderSource(vs, 1, &vsText, nullptr);
GLuint fs = 0;
{
const BlockedPoolScope blocked;
CompileShader(vs);
fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
// Iris's order, while nothing can settle: log (empty), then status (GL_TRUE).
EXPECT_TRUE(QueryShaderInfoLog(fs).empty());
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE);
EXPECT_EQ(QueryShaderCompletion(fs), GL_FALSE);
} // blocker released and joined; the broken compile can now settle
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
while (QueryShaderCompletion(fs) == GL_FALSE) {
ASSERT_LT(std::chrono::steady_clock::now(), deadline) << "compile job never settled";
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// Settled - but this shader already told the optimistic story, so it keeps telling it.
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE)
<< "the latch must keep a queried-while-pending compile optimistic after it settles";
EXPECT_EQ(QueryInfoLogLength(fs), 0);
EXPECT_TRUE(QueryShaderInfoLog(fs).empty());
// The truth arrives where the design routes it: at the link.
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
EXPECT_EQ(QueryLinkStatus(program), GL_FALSE) << "a latched-over failure must still fail the link";
EXPECT_NE(QueryProgramInfoLog(program).find("thisIdentifierWasNeverDeclared"), String::npos)
<< "the compile error must lead the program info log, inside a 32768-byte window";
DeleteProgram(program);
DeleteShader(vs);
DeleteShader(fs);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// A shader whose FIRST query arrives after the job settled was never answered
// optimistically, so it owes no continuity: the truth comes straight back. (The
// completion poll does not engage the latch - it is the extension's own non-joining
// query and always tells the truth.)
TEST_F(OptimisticStatusTest, OnceTerminalAnUnqueriedShaderTellsTheTruth) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOn);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
while (QueryShaderCompletion(fs) == GL_FALSE) {
ASSERT_LT(std::chrono::steady_clock::now(), deadline) << "compile job never settled";
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
EXPECT_EQ(QueryCompileStatus(fs), GL_FALSE) << "no optimistic answer was given, so no latch holds";
EXPECT_GT(QueryInfoLogLength(fs), 0);
EXPECT_NE(QueryShaderInfoLog(fs).find("thisIdentifierWasNeverDeclared"), String::npos);
DeleteShader(fs);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Recompiling resets the story: a latched optimistic answer must not survive a source
// change (the latch clears when the node changes hands or goes away).
TEST_F(OptimisticStatusTest, ANewCompileResetsTheLatch) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOn);
const CompilerThreadScope threads;
GLuint fs = 0;
{
const BlockedPoolScope blocked;
fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE); // latches
}
// New source, new compile, no query before it settles.
const char* goodFs = "#version 460\nlayout(location = 0) out vec4 fragColor;\n"
"void main() { fragColor = vec4(1.0); }\n";
ShaderSource(fs, 1, &goodFs, nullptr);
CompileShader(fs);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
while (QueryShaderCompletion(fs) == GL_FALSE) {
ASSERT_LT(std::chrono::steady_clock::now(), deadline) << "recompile never settled";
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE);
EXPECT_TRUE(QueryShaderInfoLog(fs).empty());
DeleteShader(fs);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// Failure still fails, at the link, inside the application's read window
// ---------------------------------------------------------------------------------------
// A broken fragment shader whose compile status was answered optimistically still fails
// its program link, and the compile error is readable through a 32768-byte
// glGetProgramInfoLog - the compile log LEADS the quoted source in ConsumeShaders'
// format, so even this >32KB shader source cannot push it out of the window.
TEST_F(OptimisticStatusTest, AFailingCompileStillFailsItsLink) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOn);
// A >32KB broken fragment shader: the undeclared identifier sits at the top, then bulk.
String brokenSource = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
brokenSource += "void main() {\n float acc = thisIdentifierWasNeverDeclared;\n";
for (int i = 0; i < 900; ++i) {
brokenSource += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
}
brokenSource += " fragColor = vec4(acc);\n}\n";
ASSERT_GT(brokenSource.size(), 32768u);
const GLuint vs = MakeShader(GL_VERTEX_SHADER,
"#version 460\nlayout(location = 0) in vec3 aPos;\n"
"void main() { gl_Position = vec4(aPos, 1.0); }\n");
const char* brokenText = brokenSource.c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &brokenText, nullptr);
CompileShader(fs);
(void)QueryShaderInfoLog(fs);
(void)QueryCompileStatus(fs); // may latch optimistic GL_TRUE; must not matter
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
EXPECT_EQ(QueryLinkStatus(program), GL_FALSE) << "a hidden compile failure must still fail the link";
const String log = QueryProgramInfoLog(program);
EXPECT_NE(log.find("thisIdentifierWasNeverDeclared"), String::npos)
<< "the compile error must be readable through a 32768-byte program info log window";
DeleteProgram(program);
DeleteShader(vs);
DeleteShader(fs);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// The Iris two-phase replay
// ---------------------------------------------------------------------------------------
// THE LOAD-BEARING CASE. 24 programs through Iris's exact phase-1 shape (compile, read log
// then status per shader, link, detach, delete - no program query), then phase 2 (link
// status, by-name locations including an absent name, the active-uniform enumeration).
// Every location and every active-uniform record must equal what the identical sequence
// produces with the quirk off.
//
// Two determinism guards make this a real A/B rather than a tautology:
// * The quirk-on arm runs FIRST, against a cold preprocess cache, and the reference arm
// second - so it is the path under test that pays the full pipeline, not the control.
// * The quirk-on arm's phase 1 runs over a BLOCKED pool, and every program is then
// WITNESSED still-incomplete (GL_COMPLETION_STATUS_KHR == GL_FALSE) before the pool
// is released: proof that no phase-1 call joined, i.e. the quirk was really engaged.
// A quirk that silently reverts to joining deadlocks here and fails by timeout.
TEST_F(OptimisticStatusTest, IrisTwoPhaseReplayProducesIdenticalReflection) {
constexpr int kPrograms = 24;
Vector<ProgramReflection> reference;
Vector<ProgramReflection> optimistic;
for (const Bool quirkOn : {true, false}) {
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(quirkOn ? MG_Config::QuirkOverride::ForceOn
: MG_Config::QuirkOverride::ForceOff);
const CompilerThreadScope threads;
Vector<String> vsSources, fsSources;
for (int i = 0; i < kPrograms; ++i) {
vsSources.push_back(MakeIrisVs(i));
fsSources.push_back(MakeIrisFs(i));
}
Vector<GLuint> programs;
if (quirkOn) {
const BlockedPoolScope blocked;
for (int i = 0; i < kPrograms; ++i) {
programs.push_back(RunIrisPhaseOne(vsSources[(SizeT)i], fsSources[(SizeT)i]));
}
// The witness: phase 1 finished with the pool blocked, so nothing can have
// settled and nothing can have been joined - every link must still be pending.
for (int i = 0; i < kPrograms; ++i) {
ASSERT_EQ(QueryProgramCompletion(programs[(SizeT)i]), GL_FALSE)
<< "program " << i << " settled under a blocked pool - a phase-1 call must have joined";
}
} else {
for (int i = 0; i < kPrograms; ++i) {
programs.push_back(RunIrisPhaseOne(vsSources[(SizeT)i], fsSources[(SizeT)i]));
}
}
Vector<ProgramReflection>& out = quirkOn ? optimistic : reference;
for (int i = 0; i < kPrograms; ++i) {
const Vector<String> names = {"uModel" + std::to_string(i), "uTint",
"uSeed" + std::to_string(i), "uOffset", "uDoesNotExist"};
out.push_back(RunIrisPhaseTwo(programs[(SizeT)i], names));
}
for (const GLuint program : programs) DeleteProgram(program);
ASSERT_EQ(GetError(), GL_NO_ERROR);
}
ASSERT_EQ(reference.size(), optimistic.size());
for (SizeT i = 0; i < reference.size(); ++i) {
EXPECT_EQ(reference[i].linkStatus, GL_TRUE) << "program " << i;
EXPECT_EQ(optimistic[i].linkStatus, GL_TRUE) << "program " << i;
EXPECT_EQ(reference[i].locations, optimistic[i].locations)
<< "program " << i << ": by-name locations diverged under the quirk";
EXPECT_EQ(reference[i].activeUniforms, optimistic[i].activeUniforms)
<< "program " << i << ": active-uniform enumeration diverged under the quirk";
// The absent name answers -1 in both worlds.
EXPECT_EQ(reference[i].locations.back().second, -1) << "program " << i;
}
}
// ---------------------------------------------------------------------------------------
// The concurrency observable
// ---------------------------------------------------------------------------------------
// The crisp A/B that phase 1 stopped joining. Quirk-on arm: the phase-1 shape over a
// blocked pool completes without joining anything - every shader is then provably still
// in flight (hard EXPECT; an inert quirk deadlocks and fails by timeout). Quirk-off arm:
// the same shape joins at every status read, so nothing is left in flight afterwards.
TEST_F(OptimisticStatusTest, PhaseOneIssuesNoCompileJoin) {
const AsyncModeScope async(true);
const CompilerThreadScope threads;
// Quirk on: nothing settles, nothing joins.
{
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOn);
const BlockedPoolScope blocked;
Vector<String> storage;
Vector<GLuint> shaders;
for (int i = 0; i < 12; ++i) {
storage.push_back(MakeBulkySource(90000 + i));
const char* text = storage.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
(void)QueryShaderInfoLog(fs);
(void)QueryCompileStatus(fs);
shaders.push_back(fs);
}
for (const GLuint shader : shaders) {
EXPECT_EQ(QueryShaderCompletion(shader), GL_FALSE)
<< "a phase-1 read joined a compile the blocked pool could not have run";
}
for (const GLuint shader : shaders) DeleteShader(shader);
}
// Quirk off: every status read joins its shader.
{
const OptimisticStatusScope quirk(MG_Config::QuirkOverride::ForceOff);
MaxShaderCompilerThreadsKHR(1);
Vector<String> storage;
Vector<GLuint> shaders;
for (int i = 0; i < 12; ++i) {
storage.push_back(MakeBulkySource(80000 + i));
const char* text = storage.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
(void)QueryShaderInfoLog(fs);
(void)QueryCompileStatus(fs);
shaders.push_back(fs);
}
for (const GLuint shader : shaders) {
EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE)
<< "with the quirk off every per-shader status read must have joined";
}
for (const GLuint shader : shaders) DeleteShader(shader);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
@@ -239,7 +239,15 @@ TEST_F(ParallelShaderCompileTest, ProgramCompletionStatusReportsFalseWithoutJoin
for (const GLuint program : programs) { for (const GLuint program : programs) {
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE); EXPECT_EQ(QueryLinkStatus(program), GL_TRUE);
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "GL_LINK_STATUS must have joined"; // GL_COMPLETION_STATUS_KHR spans BOTH phases of a link, so reading GL_LINK_STATUS -
// which is answered out of phase A - is no longer enough to turn it GL_TRUE. That is
// deliberate: an application that polls completion and then draws must not be told
// "done" while the SPIR-V is still being generated, or the draw it was cleared for is
// the thing that blocks. Settling both phases is what makes the query true.
const auto& object = MG_State::pGLContext->GetProgramObject(program);
ASSERT_NE(object, nullptr);
object->JoinLinkAndSpirv();
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "a full join must have settled both phases";
} }
EXPECT_EQ(GetError(), GL_NO_ERROR); EXPECT_EQ(GetError(), GL_NO_ERROR);
} }
@@ -333,6 +341,54 @@ TEST_F(ParallelShaderCompileTest, ZeroCompilerThreadsJoinsEverythingAndCompilesI
EXPECT_EQ(GetError(), GL_NO_ERROR); EXPECT_EQ(GetError(), GL_NO_ERROR);
} }
// The same obligation, but for LINKS that are already in flight when the zero count arrives -
// and specifically for BOTH phases of one. A link is two chained jobs now (ProgramLinkTask,
// then ProgramSpirvTask), and GL_COMPLETION_STATUS_KHR spans both, so
// ProgramState::JoinAllPendingWork has to settle both or this query reads GL_FALSE in the one
// mode the extension says cannot have anything pending. The case above creates its program
// AFTER the zero count, so it links inline and cannot see this; here the programs are linked
// against a saturated pool BEFORE it.
TEST_F(ParallelShaderCompileTest, ZeroCompilerThreadsJoinsPendingLinksAndTheirSpirvJobs) {
const AsyncModeScope async(true);
const CompilerThreadScope threads;
MaxShaderCompilerThreadsKHR(1);
// A backlog first, so the links below cannot all drain before the zero count lands.
Vector<String> sources;
(void)EnqueueBacklog(24, 5000, sources);
Vector<GLuint> programs;
for (int i = 0; i < 8; ++i) {
sources.push_back(MakeBulkySource(5100 + i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
CompileShader(vs); // this file's MakeShader only sources; it does not compile
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
programs.push_back(program);
}
int outstanding = 0;
for (const GLuint program : programs) {
if (QueryProgramCompletion(program) == GL_FALSE) ++outstanding;
}
MaxShaderCompilerThreadsKHR(0);
for (const GLuint program : programs) {
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE)
<< "glMaxShaderCompilerThreadsKHR(0) must leave neither link phase in flight";
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE);
}
EXPECT_GT(outstanding, 0) << "every link had drained before the zero count; this case proved nothing";
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ...and a later NONZERO count is what lifts it. Nothing else does: not a new context, not a // ...and a later NONZERO count is what lifts it. Nothing else does: not a new context, not a
// join, not eglInitialize. That is the documented contract, so it gets an assertion. // join, not eglInitialize. That is the documented contract, so it gets an assertion.
TEST_F(ParallelShaderCompileTest, NonzeroCompilerThreadsRestoresAsynchronousCompilation) { TEST_F(ParallelShaderCompileTest, NonzeroCompilerThreadsRestoresAsynchronousCompilation) {
@@ -1100,4 +1100,119 @@ void main() { color = u + v; }
EXPECT_EQ(viaActiveUniformBlockiv, 5); EXPECT_EQ(viaActiveUniformBlockiv, 5);
EXPECT_EQ(TakeError(), GL_NO_ERROR); EXPECT_EQ(TakeError(), GL_NO_ERROR);
} }
// ---------------------------------------------------- queries on an unlinked program ----
// glGetProgramiv is legal on a program that has never linked - GL 4.6 sec. 7.3 says the
// queried state simply has its initial value - but the reflection-backed pnames read
// Artifacts().program, which is null until a link produces one. That dereference was a
// SIGSEGV inside glslang::TProgram::getNumPipeInputs, and KHR-GL30.api.coverage walks into it
// (it queries GL_ACTIVE_ATTRIBUTES right after a glGetAttribLocation that failed). It only
// became reachable once the glCopyTexImage2D throw ahead of it in the same case stopped
// killing the run first.
TEST_F(ProgramInterfaceTest, ReflectionQueriesOnAnUnlinkedProgramAnswerZero) {
const GLuint neverLinked = CreateProgram();
ASSERT_NE(neverLinked, 0u);
ClearErrors();
for (const GLenum pname : {GL_ACTIVE_ATTRIBUTES, GL_ACTIVE_ATTRIBUTE_MAX_LENGTH, GL_ACTIVE_UNIFORMS,
GL_ACTIVE_UNIFORM_MAX_LENGTH, GL_ACTIVE_UNIFORM_BLOCKS,
GL_ACTIVE_ATOMIC_COUNTER_BUFFERS}) {
GLint value = -1;
GetProgramiv(neverLinked, pname, &value);
ClearErrors();
EXPECT_GE(value, 0) << "pname 0x" << std::hex << pname << " left its output untouched";
}
// A program that was linked and FAILED is the shape api.coverage actually hits.
const GLuint brokenSource = MakeProgram("#version 430\nvoid main() { this is not glsl }\n", kSimpleFs);
LinkProgram(brokenSource);
ClearErrors();
GLint linked = GL_TRUE;
GetProgramiv(brokenSource, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_FALSE) << "the shader was supposed to fail to compile";
ClearErrors();
GLint attributes = -1;
GetProgramiv(brokenSource, GL_ACTIVE_ATTRIBUTES, &attributes);
ClearErrors();
EXPECT_EQ(attributes, 0);
// GL_COMPUTE_WORK_GROUP_SIZE is GL_INVALID_OPERATION on a program that has not linked (GL
// 4.6 sec. 7.13), so it is allowed to leave the output alone - but it still reaches
// GetComputeLocalSize(), and it may not do so through a null reflection.
GLint localSize[3] = {-1, -1, -1};
GetProgramiv(brokenSource, GL_COMPUTE_WORK_GROUP_SIZE, localSize);
const GLenum computeError = TakeError();
ClearErrors();
EXPECT_TRUE(computeError == GL_INVALID_OPERATION || (localSize[0] == 0 && localSize[1] == 0 &&
localSize[2] == 0))
<< "either the query is refused, or it answers the initial value - never both untouched "
"and unreported";
}
// ------------------------------------------------------------- length on every path ----
// glGetProgramResourceiv's *length is the caller's only signal for how many entries params
// holds, and callers are entitled to leave it uninitialised: the CTS declares `GLsizei
// length;` next to a 1000-entry stack array and then loops `for (i = 0; i < length; ++i)`
// (gl4cProgramInterfaceQueryTests.cpp:2172). Leaving it untouched on an error path therefore
// does not "return nothing" - it hands the caller whatever was on its stack and makes it walk
// that far. KHR-GL43.program_interface_query.subroutines-vertex read 0x20202020 (" ")
// entries and took the process down on BOTH backends. So: zero on every exit, real count on
// success. Poisoning with the exact CTS-observed value keeps the assertion honest.
TEST_F(ProgramInterfaceTest, GetProgramResourceivReportsLengthOnEveryExitPath) {
const GLuint p = MakeProgram(kSimpleVs, kSimpleFs);
BindAttribLocation(p, 0, "position");
BindFragDataLocation(p, 0, "color");
LinkProgram(p);
ExpectLinked(p);
ClearErrors();
constexpr GLsizei kPoison = 0x20202020;
constexpr GLsizei kBufSize = 16;
GLint params[kBufSize] = {};
const GLenum nameLengthProp = GL_NAME_LENGTH;
const GLenum compatibleSubroutinesProp = GL_COMPATIBLE_SUBROUTINES;
const GLenum notAProp = GL_TEXTURE_2D;
const auto lengthAfter = [&](GLuint program, GLenum iface, GLuint index, GLsizei propCount,
const GLenum* props, GLsizei bufSize, GLint* out) {
GLsizei length = kPoison;
GetProgramResourceiv(program, iface, index, propCount, props, bufSize, &length, out);
ClearErrors();
return length;
};
// The case that actually crashed: no subroutine reflection exists, so the query errors
// out - and the caller then trusts *length.
EXPECT_EQ(lengthAfter(p, GL_VERTEX_SUBROUTINE_UNIFORM, 0, 1, &compatibleSubroutinesProp, kBufSize, params), 0)
<< "GL_VERTEX_SUBROUTINE_UNIFORM";
// Not a program name.
EXPECT_EQ(lengthAfter(p + 4242, GL_UNIFORM, 0, 1, &nameLengthProp, kBufSize, params), 0) << "bad program";
// Not an interface enum.
EXPECT_EQ(lengthAfter(p, GL_TEXTURE_2D, 0, 1, &nameLengthProp, kBufSize, params), 0) << "bad interface";
// propCount <= 0, bufSize < 0.
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 0, &nameLengthProp, kBufSize, params), 0) << "propCount 0";
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, &nameLengthProp, -1, params), 0) << "negative bufSize";
// props == nullptr.
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, nullptr, kBufSize, params), 0) << "null props";
// A prop this command does not know at all.
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, &notAProp, kBufSize, params), 0) << "unknown prop";
// A prop it knows but this interface does not carry.
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, &compatibleSubroutinesProp, kBufSize, params), 0)
<< "prop/interface mismatch";
// Index past the end of a real interface.
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 9999, 1, &nameLengthProp, kBufSize, params), 0) << "bad index";
// Nowhere to put the values.
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, &nameLengthProp, kBufSize, nullptr), 0) << "null params";
// ...and the success path still reports the count it actually wrote.
const GLuint outputIndex = GetProgramResourceIndex(p, GL_PROGRAM_OUTPUT, "color");
ASSERT_NE(outputIndex, GL_INVALID_INDEX);
GLsizei length = kPoison;
GetProgramResourceiv(p, GL_PROGRAM_OUTPUT, outputIndex, 1, &nameLengthProp, kBufSize, &length, params);
EXPECT_EQ(TakeError(), GL_NO_ERROR);
EXPECT_EQ(length, 1);
EXPECT_EQ(params[0], 6) << "GL_NAME_LENGTH counts the terminator";
}
} // namespace } // namespace
@@ -0,0 +1,505 @@
// MobileGL - MobileGL/MG_Test/Program/ProgramPipelineCompositeTest.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 hidden composite program a pipeline draw goes through (MG_State/GLState/Core.cpp,
// GetProgramForDraw), interrogated directly rather than through pixels.
//
// Two properties live here that the integration scenarios cannot see, because both are about
// the composite as an OBJECT rather than about what it paints:
//
// 1. WHICH stage's uniform value ends up in its single slot when several stages declare the
// same name. The rendering cases pin the answer for the shapes an application actually
// writes; these pin the rule itself, including the tie.
// 2. WHETHER it is the same object from one draw to the next. A composite rebuild is a full
// synchronous Link() plus a new program identity that empties both backends' per-program
// registries, and nothing about the resulting IMAGE would change if it happened on every
// draw - so an assertion on pixels can never catch that regression.
#include <gtest/gtest.h>
#include <cstring>
#include <string>
#include <vector>
#include "Config.h"
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_Impl/GLImpl/Program/GL_ProgramPipeline.h"
#include "MG_State/GLState/Core.h"
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
namespace {
// Both stages declare `u_shared`, which is the shared-header idiom (one header included by
// every stage) and the shape that used to render nothing: the fragment stage's untouched
// zero default overwrote the vertex stage's written value on the way into the composite.
const char* kSharedUniformVs = R"(#version 430 core
out gl_PerVertex { vec4 gl_Position; };
uniform vec4 u_shared;
uniform vec4 u_vsOnly;
void main() { gl_Position = u_shared + u_vsOnly; }
)";
const char* kSharedUniformFs = R"(#version 430 core
uniform vec4 u_shared;
out vec4 o_color;
void main() { o_color = u_shared; }
)";
const char* kArrayUniformVs = R"(#version 430 core
out gl_PerVertex { vec4 gl_Position; };
uniform vec4 u_arr[4];
void main() { gl_Position = u_arr[0] + u_arr[1] + u_arr[2] + u_arr[3]; }
)";
const char* kArrayUniformFs = R"(#version 430 core
uniform vec4 u_arr[4];
out vec4 o_color;
void main() { o_color = u_arr[0] + u_arr[1] + u_arr[2] + u_arr[3]; }
)";
const char* kSamplerVs = R"(#version 430 core
out gl_PerVertex { vec4 gl_Position; };
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
)";
const char* kSamplerFs = R"(#version 430 core
uniform sampler2D u_tex;
out vec4 o_color;
void main() { o_color = texture(u_tex, vec2(0.0)); }
)";
class ProgramPipelineCompositeTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
// Built by hand rather than through glCreateShaderProgramv, for the reason AsyncLinkTest
// gives: that entry point detaches the shader right after linking, so a relink would
// leave the stage program with nothing to composite from - and one of the cases below
// relinks on purpose.
GLuint MakeSeparableProgram(const GLenum stage, const char* source) {
const GLuint shader = CreateShader(stage);
ShaderSource(shader, 1, &source, nullptr);
CompileShader(shader);
const GLuint program = CreateProgram();
ProgramParameteri(program, GL_PROGRAM_SEPARABLE, GL_TRUE);
AttachShader(program, shader);
LinkProgram(program);
GLint linked = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linked);
EXPECT_EQ(linked, GL_TRUE) << "separable stage program did not link";
return program;
}
// The composite the next draw would run, settled.
static SharedPtr<MG_State::GLState::ProgramObject> DrawProgram() {
return MG_State::pGLContext->GetProgramForDraw();
}
// A uniform's value read out of a program's own shadow, by name. This is what the draw
// would upload, which is the thing under test - glGetUniform* would answer the same for
// the STAGE programs but has no way to name the composite at all.
static std::vector<float> ReadVec4(MG_State::GLState::ProgramObject& program, const String& name) {
const Int location = program.GetUniformLocation(name);
if (location < 0) return {};
const Uint offset = program.GetUniformOffset(static_cast<Uint>(location));
const auto* ubo = static_cast<const char*>(program.GetUBOData());
if (ubo == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
offset + 4 * sizeof(float) > program.GetUBOSize()) {
return {};
}
std::vector<float> value(4);
std::memcpy(value.data(), ubo + offset, 4 * sizeof(float));
return value;
}
};
} // namespace
// ---------------------------------------------------------------------------------------
// Which stage wins the composite's single slot
// ---------------------------------------------------------------------------------------
// THE defect. Both stages declare `u_shared`; only the VERTEX program is ever written to.
// Walking the stages in order and copying every active uniform unconditionally meant the
// fragment stage's untouched zero default landed last and won, so the composite drew zeros - a
// whole frame of nothing, from a program that had been set up entirely correctly.
TEST_F(ProgramPipelineCompositeTest, AWrittenStageValueIsNotClobberedByAnotherStagesUntouchedDeclaration) {
const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs);
const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);
GLuint pipeline = 0;
GenProgramPipelines(1, &pipeline);
BindProgramPipeline(pipeline);
UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
ASSERT_EQ(GetError(), GL_NO_ERROR);
// Exactly what an application does: point glUniform* at the vertex stage and write there.
// The fragment program is never written to and holds nothing but GL's zero default.
ActiveShaderProgram(pipeline, vs);
const GLint location = GetUniformLocation(vs, "u_shared");
ASSERT_GE(location, 0);
const float written[4] = {0.25f, 0.5f, 0.75f, 1.0f};
Uniform4fv(location, 1, written);
ASSERT_EQ(GetError(), GL_NO_ERROR);
const auto composite = DrawProgram();
ASSERT_NE(composite, nullptr);
const std::vector<float> value = ReadVec4(*composite, "u_shared");
ASSERT_EQ(value.size(), 4u) << "u_shared has no backing storage in the composite";
EXPECT_EQ(value, (std::vector<float>{0.25f, 0.5f, 0.75f, 1.0f}))
<< "the fragment stage's untouched declaration overwrote the vertex stage's written value";
// The uniform only one stage declares is unaffected either way; it is here so a mirror that
// copied nothing at all would not pass this case by accident.
ActiveShaderProgram(pipeline, vs);
const GLint vsOnly = GetUniformLocation(vs, "u_vsOnly");
ASSERT_GE(vsOnly, 0);
const float other[4] = {1.0f, 2.0f, 3.0f, 4.0f};
Uniform4fv(vsOnly, 1, other);
const auto refreshed = DrawProgram();
EXPECT_EQ(ReadVec4(*refreshed, "u_vsOnly"), (std::vector<float>{1.0f, 2.0f, 3.0f, 4.0f}));
EXPECT_EQ(GetError(), GL_NO_ERROR);
BindProgramPipeline(0);
DeleteProgramPipelines(1, &pipeline);
}
// The tie the fix cannot make disappear: BOTH stages were written, and the composite still has
// one slot. The documented rule is last WRITTEN-TO graphics stage wins, in ShaderStage enum
// order - deterministic, and reachable only by a stage holding a real application value.
TEST_F(ProgramPipelineCompositeTest, WhenBothStagesWereWrittenTheLastGraphicsStageWins) {
const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs);
const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);
GLuint pipeline = 0;
GenProgramPipelines(1, &pipeline);
BindProgramPipeline(pipeline);
UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
const float fromVs[4] = {1.0f, 1.0f, 1.0f, 1.0f};
const float fromFs[4] = {2.0f, 2.0f, 2.0f, 2.0f};
// Written in the order VS then FS...
ProgramUniform4fv(vs, GetUniformLocation(vs, "u_shared"), 1, fromVs);
ProgramUniform4fv(fs, GetUniformLocation(fs, "u_shared"), 1, fromFs);
ASSERT_EQ(GetError(), GL_NO_ERROR);
EXPECT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector<float>{2.0f, 2.0f, 2.0f, 2.0f}));
// ...and in the order FS then VS. The answer is the same, because the rule is stage order
// and not write order - which is the honest statement of what the dirty set can support.
ProgramUniform4fv(fs, GetUniformLocation(fs, "u_shared"), 1, fromFs);
ProgramUniform4fv(vs, GetUniformLocation(vs, "u_shared"), 1, fromVs);
ASSERT_EQ(GetError(), GL_NO_ERROR);
EXPECT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector<float>{2.0f, 2.0f, 2.0f, 2.0f}))
<< "the both-written tie must be decided by stage order, deterministically";
BindProgramPipeline(0);
DeleteProgramPipelines(1, &pipeline);
}
// The both-written tie again, through the case that has no BYTES to move: the fragment stage
// writes the value it was already holding.
//
// The refresh gate is built out of counters that move when bytes move (the UBO content
// version, the backend state version), and both write funnels drop a value-identical write
// before bumping either. So this write enlarges the write SET - it makes the fragment stage
// the last written-to stage for `u_shared`, which is what decides the slot - while moving
// nothing else. Without a generation on the set itself the gate never trips and the draw keeps
// the vertex stage's value.
TEST_F(ProgramPipelineCompositeTest, AValueIdenticalWriteStillTakesTheSlotForItsStage) {
const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs);
const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);
GLuint pipeline = 0;
GenProgramPipelines(1, &pipeline);
BindProgramPipeline(pipeline);
UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
const float fromVs[4] = {5.0f, 5.0f, 5.0f, 5.0f};
ProgramUniform4fv(vs, GetUniformLocation(vs, "u_shared"), 1, fromVs);
ASSERT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector<float>{5.0f, 5.0f, 5.0f, 5.0f}));
// The fragment program's u_shared already reads all-zero, so this write changes not one
// byte of its shadow - and must still hand it the composite's slot.
const float zeros[4] = {0.0f, 0.0f, 0.0f, 0.0f};
ProgramUniform4fv(fs, GetUniformLocation(fs, "u_shared"), 1, zeros);
ASSERT_EQ(GetError(), GL_NO_ERROR);
EXPECT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector<float>{0.0f, 0.0f, 0.0f, 0.0f}))
<< "a write that moved no bytes never reached the refresh gate";
BindProgramPipeline(0);
DeleteProgramPipelines(1, &pipeline);
}
// glUseProgramStages here accepts a program that was never linked as separable (GL 4.6 core 7.4
// says it should not, and MobileGL validates only LINK_STATUS). Such a program has recorded
// none of its writes, because nothing ever armed its tracking latch - so the mirror has to fall
// back to carrying everything rather than carrying nothing. Mirroring nothing would have been a
// fresh regression on a shape that worked before the dirty set existed.
TEST_F(ProgramPipelineCompositeTest, ANonSeparableStageProgramStillMirrorsItsUniforms) {
const char* vsSource = R"(#version 430 core
uniform vec4 u_vsOnly;
void main() { gl_Position = u_vsOnly; }
)";
const GLuint shader = CreateShader(GL_VERTEX_SHADER);
ShaderSource(shader, 1, &vsSource, nullptr);
CompileShader(shader);
const GLuint vs = CreateProgram();
// Deliberately NO ProgramParameteri(GL_PROGRAM_SEPARABLE): this is the shape the latch
// cannot see coming.
AttachShader(vs, shader);
LinkProgram(vs);
GLint linked = GL_FALSE;
GetProgramiv(vs, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);
GLuint pipeline = 0;
GenProgramPipelines(1, &pipeline);
BindProgramPipeline(pipeline);
UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
ASSERT_EQ(GetError(), GL_NO_ERROR);
const float written[4] = {3.0f, 1.0f, 4.0f, 1.0f};
ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, written);
ASSERT_EQ(GetError(), GL_NO_ERROR);
const auto composite = DrawProgram();
ASSERT_NE(composite, nullptr);
EXPECT_FALSE(MG_State::pGLContext->GetProgramObject(vs)->TracksUniformWrites())
<< "this case is only meaningful while the stage program records nothing";
EXPECT_EQ(ReadVec4(*composite, "u_vsOnly"), (std::vector<float>{3.0f, 1.0f, 4.0f, 1.0f}))
<< "a stage program with no write record must fall back to mirroring everything";
BindProgramPipeline(0);
DeleteProgramPipelines(1, &pipeline);
}
// glProgramUniform* addresses a program by NAME and needs neither a current program nor an
// active shader program, so it is a write path that never touches the pipeline at all. It has
// to record the write exactly like glUniform* does.
TEST_F(ProgramPipelineCompositeTest, ProgramUniformOnAnUnboundStageProgramReachesTheComposite) {
const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs);
const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);
GLuint pipeline = 0;
GenProgramPipelines(1, &pipeline);
UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
// Deliberately BEFORE the bind, and with no glActiveShaderProgram anywhere: the write has
// to survive from here to a draw that has not been set up yet.
const float written[4] = {9.0f, 8.0f, 7.0f, 6.0f};
ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, written);
ASSERT_EQ(GetError(), GL_NO_ERROR);
BindProgramPipeline(pipeline);
EXPECT_EQ(ReadVec4(*DrawProgram(), "u_vsOnly"), (std::vector<float>{9.0f, 8.0f, 7.0f, 6.0f}));
EXPECT_EQ(GetError(), GL_NO_ERROR);
BindProgramPipeline(0);
DeleteProgramPipelines(1, &pipeline);
}
// Array uniforms are written at ELEMENT locations, so the record has to be per location and not
// per name: a stage that wrote `u_arr[2]` and nothing else must carry element 2 across and
// leave the rest to whichever stage owns them.
TEST_F(ProgramPipelineCompositeTest, ArrayElementWritesMirrorPerElement) {
const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kArrayUniformVs);
const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kArrayUniformFs);
GLuint pipeline = 0;
GenProgramPipelines(1, &pipeline);
BindProgramPipeline(pipeline);
UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
// Non-prefix on purpose: elements 1 and 3 from the vertex stage, element 2 from the fragment
// stage, element 0 from nobody. A per-name record would have carried whole arrays and let
// one stage's zeros take the other's elements.
const float one[4] = {11.0f, 11.0f, 11.0f, 11.0f};
const float three[4] = {33.0f, 33.0f, 33.0f, 33.0f};
const float two[4] = {22.0f, 22.0f, 22.0f, 22.0f};
ProgramUniform4fv(vs, GetUniformLocation(vs, "u_arr[1]"), 1, one);
ProgramUniform4fv(vs, GetUniformLocation(vs, "u_arr[3]"), 1, three);
ProgramUniform4fv(fs, GetUniformLocation(fs, "u_arr[2]"), 1, two);
ASSERT_EQ(GetError(), GL_NO_ERROR);
const auto composite = DrawProgram();
ASSERT_NE(composite, nullptr);
EXPECT_EQ(ReadVec4(*composite, "u_arr[0]"), (std::vector<float>{0.0f, 0.0f, 0.0f, 0.0f}));
EXPECT_EQ(ReadVec4(*composite, "u_arr[1]"), (std::vector<float>{11.0f, 11.0f, 11.0f, 11.0f}));
EXPECT_EQ(ReadVec4(*composite, "u_arr[2]"), (std::vector<float>{22.0f, 22.0f, 22.0f, 22.0f}));
EXPECT_EQ(ReadVec4(*composite, "u_arr[3]"), (std::vector<float>{33.0f, 33.0f, 33.0f, 33.0f}));
EXPECT_EQ(GetError(), GL_NO_ERROR);
// A multi-element glUniform*v run marks each location it actually reaches.
const float tail[8] = {44.0f, 44.0f, 44.0f, 44.0f, 55.0f, 55.0f, 55.0f, 55.0f};
ActiveShaderProgram(pipeline, fs);
Uniform4fv(GetUniformLocation(fs, "u_arr[2]"), 2, tail);
ASSERT_EQ(GetError(), GL_NO_ERROR);
const auto refreshed = DrawProgram();
EXPECT_EQ(ReadVec4(*refreshed, "u_arr[2]"), (std::vector<float>{44.0f, 44.0f, 44.0f, 44.0f}));
EXPECT_EQ(ReadVec4(*refreshed, "u_arr[3]"), (std::vector<float>{55.0f, 55.0f, 55.0f, 55.0f}))
<< "the second element of a count=2 write was never recorded";
BindProgramPipeline(0);
DeleteProgramPipelines(1, &pipeline);
}
// Relinking resets a program's uniforms to their initial values (GL 4.6 core 7.6), so the record
// of what was written has to be reset with them. If it survived, the composite built after the
// relink would be handed values the stage program no longer holds.
TEST_F(ProgramPipelineCompositeTest, RelinkingAStageProgramClearsWhatItHadWritten) {
const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs);
const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);
GLuint pipeline = 0;
GenProgramPipelines(1, &pipeline);
BindProgramPipeline(pipeline);
UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
const float written[4] = {5.0f, 6.0f, 7.0f, 8.0f};
ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, written);
ASSERT_EQ(ReadVec4(*DrawProgram(), "u_vsOnly"), (std::vector<float>{5.0f, 6.0f, 7.0f, 8.0f}));
LinkProgram(vs);
GLint linked = GL_FALSE;
GetProgramiv(vs, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
const auto composite = DrawProgram();
ASSERT_NE(composite, nullptr);
EXPECT_EQ(ReadVec4(*composite, "u_vsOnly"), (std::vector<float>{0.0f, 0.0f, 0.0f, 0.0f}))
<< "a relinked stage program carried its pre-relink value into the new composite";
EXPECT_EQ(GetError(), GL_NO_ERROR);
// ...and writing again after the relink is recorded afresh.
const float rewritten[4] = {1.5f, 2.5f, 3.5f, 4.5f};
ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, rewritten);
EXPECT_EQ(ReadVec4(*DrawProgram(), "u_vsOnly"), (std::vector<float>{1.5f, 2.5f, 3.5f, 4.5f}));
EXPECT_EQ(GetError(), GL_NO_ERROR);
BindProgramPipeline(0);
DeleteProgramPipelines(1, &pipeline);
}
// ---------------------------------------------------------------------------------------
// Composite cache stability
// ---------------------------------------------------------------------------------------
// The SSO-conformance shape, and the reason the composite cache stopped being keyed on the
// backend state version: pick a stage program, then per draw set a sampler unit and draw.
// glUniform1i on a sampler bumps that version, so the signature changed on every iteration and
// every single draw threw the composite away and relinked it - glslang, SPIR-V and spirv-opt,
// synchronously, inside the draw - handing the backends a brand-new program identity each time.
//
// Asserted on the composite POINTER, which is the honest observable: it is the object both
// backends key their per-program registries and pipeline memos on, so "same pointer" is exactly
// the property that was lost.
TEST_F(ProgramPipelineCompositeTest, ASamplerWritePerDrawDoesNotRebuildTheComposite) {
const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSamplerVs);
const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSamplerFs);
GLuint pipeline = 0;
GenProgramPipelines(1, &pipeline);
BindProgramPipeline(pipeline);
UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
ActiveShaderProgram(pipeline, fs);
ASSERT_EQ(GetError(), GL_NO_ERROR);
const GLint sampler = GetUniformLocation(fs, "u_tex");
ASSERT_GE(sampler, 0);
const auto first = DrawProgram();
ASSERT_NE(first, nullptr);
const Uint64 firstLifetime = first->GetLifetimeId();
const Int compositeSampler = first->GetUniformLocation("u_tex");
ASSERT_GE(compositeSampler, 0);
for (GLint unit = 0; unit < 8; ++unit) {
Uniform1i(sampler, unit);
const auto composite = DrawProgram();
ASSERT_NE(composite, nullptr);
EXPECT_EQ(composite.get(), first.get())
<< "the composite was rebuilt by a sampler-unit write at unit " << unit;
EXPECT_EQ(composite->GetLifetimeId(), firstLifetime) << "the composite's identity changed at unit " << unit;
// The value still has to ARRIVE - the whole point is that the mirror carries it now that
// the rebuild no longer does.
EXPECT_EQ(composite->GetUniformSamplerOrImageUnitIndex(static_cast<Uint>(compositeSampler)), unit)
<< "the sampler unit did not reach the composite at unit " << unit;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
// A relink, by contrast, MUST replace it: that is the one thing the signature still tracks.
LinkProgram(fs);
GLint linked = GL_FALSE;
GetProgramiv(fs, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
const auto afterRelink = DrawProgram();
ASSERT_NE(afterRelink, nullptr);
EXPECT_NE(afterRelink.get(), first.get()) << "a relinked stage program must rebuild the composite";
BindProgramPipeline(0);
DeleteProgramPipelines(1, &pipeline);
}
// The monolithic path must be untouched by any of this: a plain glUseProgram program is not
// separable, records nothing, and is its own draw program.
TEST_F(ProgramPipelineCompositeTest, AMonolithicProgramRecordsNothingAndIsItsOwnDrawProgram) {
const char* vsSource = R"(#version 430 core
uniform vec4 u_shared;
void main() { gl_Position = u_shared; }
)";
const char* fsSource = R"(#version 430 core
uniform vec4 u_shared;
out vec4 o_color;
void main() { o_color = u_shared; }
)";
const GLuint vsShader = CreateShader(GL_VERTEX_SHADER);
ShaderSource(vsShader, 1, &vsSource, nullptr);
CompileShader(vsShader);
const GLuint fsShader = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fsShader, 1, &fsSource, nullptr);
CompileShader(fsShader);
const GLuint program = CreateProgram();
AttachShader(program, vsShader);
AttachShader(program, fsShader);
LinkProgram(program);
GLint linked = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
UseProgram(program);
const float written[4] = {1.0f, 2.0f, 3.0f, 4.0f};
Uniform4fv(GetUniformLocation(program, "u_shared"), 1, written);
ASSERT_EQ(GetError(), GL_NO_ERROR);
const auto drawProgram = DrawProgram();
ASSERT_NE(drawProgram, nullptr);
EXPECT_EQ(drawProgram->GetExternalIndex(), program) << "a current program IS the draw program";
// Nothing was recorded, because nothing ever asked this program to be separable - which is
// what keeps the hot uniform path free of the bookkeeping.
EXPECT_FALSE(drawProgram->TracksUniformWrites());
EXPECT_TRUE(drawProgram->GetWrittenUniformIndices().empty());
EXPECT_EQ(ReadVec4(*drawProgram, "u_shared"), (std::vector<float>{1.0f, 2.0f, 3.0f, 4.0f}));
UseProgram(0);
}

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