Compare commits

...
138 Commits
Author SHA1 Message Date
swung0x48 666f150202 [Fix] (SelfTest): stop optional-capability failures from declaring the whole backend unsupported, and log the POST report in chunks 2026-08-22 09:08:13 -04:00
swung0x48 2f62970dd5 [Refactor] (SelfTest): give every POST capability row a PASS/WARN/FAIL verdict and keep INFO for identity 2026-08-22 08:59:03 -04:00
swung0x48 dcb568d445 [Feature, Test] (SelfTest): probe the four remaining known driver bugs from the POST 2026-08-22 08:45:05 -04:00
swung0x48 a5f36c8f8d [Feature, Test] (SelfTest): add a Known Driver Bugs POST section and probe the geometry write-after-emit drop 2026-08-22 07:49:17 -04:00
swung0x48 1ebe9d11c5 [Fix, Test] (GLState, DirectGLES): stop calling a multisample texture filter-incomplete so it still binds 2026-08-22 07:18:11 -04:00
swung0x48 a8bb63950d [Fix, Test] (ShaderTranspiler): relocate a late length constant so an offset atomic-counter block still flattens 2026-08-22 07:18:11 -04:00
swung0x48 7d68a17774 [Fix, Test] (DirectGLES): give a split buffer image its own view so the sampler still sees whole texels 2026-08-22 06:16:52 -04:00
swung0x48 415645ccdd [Merge] (ShaderTranspiler, DirectGLES): land the normalized-format carriers and the buffer-image split 2026-08-22 04:47:39 -04:00
swung0x48 e6d03eb2a1 [Merge] (DirectGLES): take the image-alias rename under the format carriers 2026-08-22 04:44:41 -04:00
swung0x48 5e29e7d266 [Feat, Test] (ShaderTranspiler, DirectGLES): split a non-core buffer image by its subscript instead of losing the stage 2026-08-22 04:30:06 -04:00
swung0x48 7eac33d17b [Feat, Fix, Test] (ShaderTranspiler, DirectGLES): carry the seven normalized image formats as their own codes in rgba16ui 2026-08-22 04:17:46 -04:00
Swung0x48 8f19ce6fa7 [Fix, Test] (DirectGLES): rename an image SPIRV-Cross already qualified so two stages cannot merge it 2026-08-22 03:46:35 -04:00
swung0x48 d0f7fb99db [Feat, Test] (ShaderTranspiler, DirectGLES): carry rgb10_a2ui storage images in rgba16ui and split its packed upload 2026-08-22 03:21:48 -04:00
swung0x48 d4247db6c3 [Fix, Test] (ShaderTranspiler, GLImpl, ProgramState, DirectVulkan): keep fp64 where the backend consumes it natively 2026-08-22 00:57:06 -04:00
swung0x48 e4f41e0fd3 [Merge] (ShaderTranspiler, DirectGLES): land the fp64 block layout, colour-index and readonly-writeonly repairs 2026-08-21 22:24:38 -04:00
swung0x48 9cf340cbef [Merge] (DirectGLES, ShaderTranspiler): take the viewport routing and image repairs under the fp64 and qualifier fixes 2026-08-21 22:21:17 -04:00
swung0x48 348a30a816 [Fix, Test] (ShaderTranspiler): keep a storage block with doubles at the byte layout it was bound with 2026-08-21 22:15:05 -04:00
swung0x48 b5e0ada97e [Merge] (DirectGLES, ShaderTranspiler, Config): land the viewport-array routing emulation 2026-08-21 22:13:50 -04:00
swung0x48 cb27ac7761 [Merge] (DirectGLES): take the image-widening repairs under the viewport routing 2026-08-21 22:10:53 -04:00
swung0x48 38c56a3d38 [Test] (DirectGLES, IntegrationTest): run the viewport-array scenarios on Espryt and pin the routing rewrites against a negative control 2026-08-21 22:08:33 -04:00
swung0x48 908172ba0f [Feat] (DirectGLES, Config, ShaderTranspiler): route gl_ViewportIndex on Espryt by replaying a draw per distinct viewport state 2026-08-21 22:08:25 -04:00
swung0x48 e18bac8cb2 [Fix, Test] (ShaderTranspiler, DirectGLES): never widen a buffer image - its texels are the application buffer, not storage we can reallocate 2026-08-21 22:02:23 -04:00
swung0x48 7b0f443d3a [Fix, Test] (ShaderTranspiler, DirectGLES): drop the inert readonly+writeonly pair a storage block cannot carry in ESSL 2026-08-21 21:43:42 -04:00
swung0x48 f1b4a5e07f [Fix, Test] (ShaderTranspiler, DirectGLES): stop printing the default fragment-output colour index into ESSL 2026-08-21 21:42:31 -04:00
swung0x48 f3cd4091bf [Fix, Test] (DirectGLES): decode the packed r11f_g11f_b10f shadow into the float level its rgba16f carrier is uploaded as 2026-08-21 21:38:45 -04:00
swung0x48 529d26f38f [Fix] (DirectGLES): arm the image-format widening for r11f_g11f_b10f in the reflection gate too 2026-08-21 21:27:14 -04:00
swung0x48 9bd125aeec [Fix, Test] (ShaderTranspiler): carry r11f_g11f_b10f storage images in rgba16f instead of losing the stage 2026-08-21 21:21:54 -04:00
swung0x48 ece9491d4b [Test] (ShaderTranspiler): pin the two capture holes the side-by-side corpus run found 2026-08-21 13:55:09 -04:00
swung0x48 51b4abd801 [Docs] (ShaderTranspiler, GLState): retire the comments that still describe the lexical side channels 2026-08-21 13:53:17 -04:00
swung0x48 cbb616093b [Refactor, Test] (ShaderTranspiler, GLState): take what the relaxed parse destroys from glslang instead of scanning the source 2026-08-21 13:51:22 -04:00
swung0x48 e5846569ca [Refactor] (ShaderTranspiler): bump the vendored glslang for the uniform-location snapshot and the atomic-counter offset check 2026-08-21 13:51:21 -04:00
swung0x48 194c2f189b [Merge] (ShaderTranspiler): land the macro-spelled storage-block binding repair 2026-08-21 12:16:52 -04:00
swung0x48 7de7cfc6eb [Fix, Test] (ShaderTranspiler): read a storage block's macro-spelled binding as declared, not as absent 2026-08-21 12:09:03 -04:00
swung0x48 03e69fc9ef [Merge] (ShaderTranspiler): land the single-implementation subroutine lowering and the imageSize select ladder 2026-08-21 11:59:49 -04:00
swung0x48 ee74c8ea3a [Merge] (DirectGLES): land the post-relink rebind repair 2026-08-21 11:59:49 -04:00
swung0x48 54bbe805e5 [Merge] (GLState): land the GL block and uniform enumeration repair 2026-08-21 11:59:49 -04:00
swung0x48 6e2a3b3496 [Fix, Test] (GLState): stop enumerating buffer variables as GL uniforms 2026-08-21 11:55:29 -04:00
swung0x48 f5a0779385 [Fix, Test] (GLState): keep the atomic-counter and storage blocks out of the GL uniform-block list 2026-08-21 11:54:45 -04:00
swung0x48 86fdc68efa [Fix, Test] (ShaderTranspiler): let the image-array select ladder carry an imageSize query, not just a read 2026-08-21 11:49:30 -04:00
swung0x48 1185265e22 [Fix, Test] (DirectGLES): re-bind the driver program after a relink so a stage the relink added reaches the draw 2026-08-21 11:47:03 -04:00
swung0x48 77c05b151a [Fix, Test] (ShaderTranspiler): lower a single-implementation GLSL subroutine to a forwarding call 2026-08-21 11:45:49 -04:00
swung0x48 fdbe0b3117 [Fix, Test] (DirectGLES, DirectVulkan, GLImpl, GLState): ask the last link, not the live attach list, what stages a program has 2026-08-21 08:01:48 -04:00
swung0x48 9c9739e1c3 [Merge] (DirectGLES, GLState, GLImpl, ShaderTranspiler): land GL43 wave6 and wave7 2026-08-21 07:15:13 -04:00
swung0x48 87548ae78a [Merge] (DirectGLES, GLState, GLImpl, ShaderTranspiler): land GL43 wave6 and wave7 with the image-uniform naming repair 2026-08-21 07:11:00 -04:00
swung0x48 2a902ff58c [Fix, Test] (ShaderTranspiler): bound the whole loop nest a fragment-output index marks for unrolling 2026-08-21 07:04:19 -04:00
swung0x48 a79eadd724 [Fix, Test] (ShaderTranspiler): bound the whole loop nest a resource-array index marks for unrolling 2026-08-21 07:04:19 -04:00
swung0x48 518e9c7796 [Fix, Test] (DirectGLES): read an image array subscript unsigned literal as the element index it is 2026-08-21 06:57:59 -04:00
swung0x48 a4c11f2603 [Fix, Test] (DirectGLES): arm the image-format widening for a baked format on every driver 2026-08-21 06:54:39 -04:00
swung0x48 37a656dedc [Fix, Test] (DirectGLES): name a repaired image uniform after its repair, not after its stage 2026-08-21 06:51:12 -04:00
swung0x48 3cc6b88767 [Fix, Test] (GLState): keep named-block members out of the GL uniform location pool 2026-08-21 06:36:47 -04:00
swung0x48 b32c35a113 [Fix, Test] (GLImpl): ask the stage, not GL_NONE, whether a geometry shader is active 2026-08-21 06:20:54 -04:00
swung0x48 8587b83be3 [Fix, Test] (ShaderTranspiler, DirectGLES): make every emitted image-array subscript a compile-time constant 2026-08-21 06:11:04 -04:00
swung0x48 0cef345d61 [Fix, Test] (GLState): give an atomic counter array its packed stride of four 2026-08-21 06:10:47 -04:00
swung0x48 31367de628 [Test] (MG_IntegrationTest): cover a storage block's default binding where another resource competes for it 2026-08-21 05:27:45 -04:00
swung0x48 bc4b62026a [Docs] (ShaderTranspiler): record the byte-exact fp64 block-layout evidence and its one misleading artifact 2026-08-21 05:24:49 -04:00
swung0x48 50da7de737 [Fix, Test] (DirectGLES, ShaderTranspiler): synthesize the pass-through tessellation control stage ES requires 2026-08-21 05:23:57 -04:00
swung0x48 d9def5c1bb [Fix, Test] (GLImpl, GLState): give a storage block with no binding qualifier GL's default binding of zero 2026-08-21 05:13:58 -04:00
swung0x48 21a4c8aa95 [Fix, Test] (ShaderTranspiler, DirectGLES): widen the offset and gradients of a 1D sampler lookup for ESSL 2026-08-21 04:57:52 -04:00
swung0x48 6317066add [Fix, Test] (DirectGLES): reach an image array's non-consecutive units by widening the array over their span 2026-08-21 04:38:59 -04:00
swung0x48 02b59bef80 [Fix, Test] (DirectGLES): give every repaired image uniform a per-stage name so no linker can merge two stages' qualifiers 2026-08-21 04:28:44 -04:00
swung0x48 668f3e90c9 [Fix] (DirectGLES, ShaderTranspiler): widen the formats SPIRV-Cross refuses to print even where GL_NV_image_formats exists 2026-08-21 04:13:01 -04:00
swung0x48 07d6277f87 [Test] (MG_IntegrationTest): cover GL's missing-channel semantics for a non-core image format 2026-08-21 03:54:16 -04:00
swung0x48 b164692387 [Test] (ShaderTranspiler): retarget the bake-decline pin at the formats no core carrier rescues 2026-08-21 03:48:59 -04:00
swung0x48 80ea44573e [Test] (ShaderTranspiler): pin the ESSL a widened image module emits, on both SPIRV-Cross failure modes 2026-08-21 03:34:27 -04:00
swung0x48 2a7d6f2e16 [Perf] (DirectGLES, ShaderTranspiler): fold the image-widening gate into the shared SPIR-V probe 2026-08-21 03:24:09 -04:00
swung0x48 3a12f6d4f3 [Fix, Test] (DirectGLES, ShaderTranspiler): emulate the 17 exactly-carriable non-core image formats by channel widening 2026-08-21 03:21:09 -04:00
swung0x48 36b9d26b9d [Fix] (DirectVulkan): match the 2_10_10_10 storage image view format to the texture's own 2026-08-21 02:54:47 -04:00
swung0x48 4b41f01b68 [Merge] (DirectGLES, GLState, ShaderTranspiler): land GL43 wave5 with its two new passes inside the L2 boundary 2026-08-21 00:43:39 -04:00
swung0x48 e81e938bb8 [Docs] (GLImpl): name the right copy_image conformance case in the 1D-array bounds note 2026-08-21 00:28:22 -04:00
swung0x48 5fa849674e [Fix] (DirectVulkan): gate the GL_DOUBLE vertex narrowing on the same fp64 flag the shader demotion uses 2026-08-21 00:28:03 -04:00
swung0x48 aed10f65a6 [Fix, Test] (GLImpl, MG_IntegrationTest): enforce the tessellation draw-mode rules and waive the XFB mode match for it 2026-08-21 00:23:15 -04:00
swung0x48 a687fc4577 [Fix] (ProgramState): stop the uniform-location grow path minting locations past GL_MAX_UNIFORM_LOCATIONS 2026-08-21 00:19:28 -04:00
swung0x48 ef66aea73b [Fix, Test] (GLImpl, DirectGLES, DirectVulkan): address a 1D array's copy-image layers on Z, not Y 2026-08-21 00:15:01 -04:00
swung0x48 c129cdec2d [Fix, Test] (ShaderTranspiler, ProgramState): reject an out-of-range atomic-counter offset at compile 2026-08-21 00:10:53 -04:00
swung0x48 7a7340ebe2 [Fix, Test] (GLImpl, ProgramState): answer the classic uniform queries for atomic counters at GL level 2026-08-21 00:07:07 -04:00
swung0x48 325ba07776 [Fix, Test] (ShaderTranspiler): route every sub-array of an array-of-arrays uniform to its own UBO offset 2026-08-21 00:02:30 -04:00
swung0x48 7aa91e8024 [Fix, Test] (DirectGLES, DirectVulkan): narrow GL_DOUBLE vertex arrays to float32 instead of dropping them 2026-08-20 23:54:49 -04:00
swung0x48 1b5a39473e [Fix, Test] (ShaderTranspiler, DirectGLES): flatten the atomic-counter block's declared offsets for ESSL 2026-08-20 23:39:26 -04:00
swung0x48 0e5f591cfa [Docs] (ShaderTranspiler): name the conformance cases fp64 block demotion costs 2026-08-20 23:26:18 -04:00
swung0x48 79336c5ccc [Fix, Test] (DirectGLES): re-issue the indexed binding of a storage buffer whose store was regrown 2026-08-20 23:16:08 -04:00
swung0x48 be45dbcf54 [Test] (MG_IntegrationTest): pin a non-constant index into an array of storage blocks 2026-08-20 23:15:48 -04:00
swung0x48 f7dfa01c18 [Fix, Test] (ShaderTranspiler, DirectGLES): make every array-of-storage-blocks index a constant for ESSL 2026-08-20 23:08:38 -04:00
swung0x48 50efa4410a [Test] (MG_IntegrationTest): dispatch an imageAtomicAdd against the two 1D image targets 2026-08-20 22:55:51 -04:00
swung0x48 bea3086b41 [Diagnostic, Test] (DirectGLES): name the image-uniform split as a cause when the backend link fails 2026-08-20 22:53:25 -04:00
swung0x48 8ae93c837d [Fix, Test] (DirectGLES): order the split image pair's store before its load with memoryBarrierImage 2026-08-20 22:49:05 -04:00
swung0x48 4154f2e941 [Fix, Test] (ShaderTranspiler, DirectGLES): widen a non-arrayed 1D storage image's atomic coordinate 2026-08-20 22:46:32 -04:00
swung0x48 cd07d42a47 [Fix, Test] (RenderState, DirectGLES, MG_IntegrationTest): tell a deliberately empty scissor box apart from one that was never written 2026-08-20 22:27:14 -04:00
swung0x48 ae0373eb48 [Merge] (DirectGLES, ShaderTranspiler): land GL43 wave4 with the interface-block rename inside the L2 boundary 2026-08-20 21:13:55 -04:00
swung0x48 7480bf4490 [Perf] (CTS-Harness): add a --cpu-mask switch and pin glcts to the big cluster by default 2026-08-20 21:03:54 -04:00
swung0x48 54b206d90c [Test, Bench] (ShaderTranspiler): pin the parse-verdict memo and measure the deferred parse 2026-08-20 18:53:57 -04:00
swung0x48 5daf7bf093 [Perf] (ShaderTranspiler, ProgramState): memoize the glslang parse verdict so a repeated compile skips the parse 2026-08-20 18:53:57 -04:00
swung0x48 a8228ca287 [Merge] (ShaderTranspiler, GLState, DirectGLES): land dev GL43 wave2/wave3 under the translation cache 2026-08-20 18:03:06 -04:00
swung0x48 8b827bd2ce [Fix, Test] (TextureFormatProcessor, DirectGLES, MG_IntegrationTest): give every unrenderable signed-normalized colour attachment an exact float substitute 2026-08-20 17:17:31 -04:00
swung0x48 6aa161fee7 [Fix, Test] (DirectGLES, ShaderTranspiler, MG_IntegrationTest): spell an interface block declared in both directions once per producing stage 2026-08-20 16:56:58 -04:00
swung0x48 48a70fea81 [Fix, Test] (DirectGLES, PixelStoreProcessor, MG_IntegrationTest): read a packed level's stored words instead of trusting the shadow 2026-08-20 16:15:52 -04:00
swung0x48 6ea4f32635 [Fix, Test] (TextureFormatProcessor): store the desktop-only low-bit formats without a driver requantization 2026-08-20 16:04:38 -04:00
swung0x48 dc1fffb041 [Fix, Test] (GLImpl): bound glCopyImageSubData's region against both images 2026-08-20 16:02:08 -04:00
swung0x48 d24d5b5ccd [Fix, Test] (BackendLoader, DirectGLES, DirectVulkan, GLImpl): answer the layer and viewport-index provoking-vertex conventions from the backend 2026-08-20 15:40:25 -04:00
swung0x48 51883cf1a3 [Fix, Test] (GLState): deliver the GL_MIN_MAP_BUFFER_ALIGNMENT that glGetIntegerv advertises 2026-08-20 15:35:02 -04:00
swung0x48 6dfadeb7d2 [Fix, Test] (BackendLoader): drain and gate every capability probe whose pname is not ES core 2026-08-20 15:30:11 -04:00
swung0x48 4fc3531d0d [Fix, Test] (BackendLoader, DirectVulkan, ShaderTranspiler): report GL_MAX_CLIP_DISTANCES from the backend's real clip-distance capability 2026-08-20 15:25:29 -04:00
swung0x48 9bde0e500f [Merge] (CTS): land the GL43 wave-3 fixes and the DirectVulkan texture-shape repairs 2026-08-20 14:17:20 -04:00
swung0x48 3477d87b50 [Fix] (DirectVulkan): back a 1D array with its layers in arrayLayers, not in the image height 2026-08-20 14:14:21 -04:00
swung0x48 c2a081fa75 [Fix] (GLState, DirectVulkan): bust the texture-sync skip when a re-spec moved only the shape 2026-08-20 14:02:05 -04:00
swung0x48 685fd750c9 [Test] (MG_Test): expect buffer-texture level queries to answer, not to error 2026-08-20 13:50:45 -04:00
swung0x48 02c9b8a32d [Fix, Test] (GLImpl, DirectVulkan, MG_IntegrationTest): record glVertexAttribLFormat's state and drop the array at draw 2026-08-20 13:44:44 -04:00
swung0x48 26f02567d7 [Fix, Test] (GLState, GLImpl): reserve an inactive uniform's explicit location and pin the link to GL_MAX_UNIFORM_LOCATIONS 2026-08-20 13:39:38 -04:00
swung0x48 a3dbe234d7 [Fix, Test] (GLImpl, MG_IntegrationTest): answer glGetTexLevelParameter for buffer textures instead of erroring 2026-08-20 13:27:50 -04:00
swung0x48 de8e7a4606 [Fix, Test] (ShaderTranspiler): parse layout literals in every GLSL base and key array-of-arrays uniforms per element 2026-08-20 13:24:04 -04:00
swung0x48 6359fba455 [Test] (MG_Test): compile the compute-limit probe against the captured env, not the null-env fallback 2026-08-20 13:09:38 -04:00
swung0x48 872876961d [Fix] (GLState): pin the storage-binding ceiling's min/max to Int so no platform can widen either argument 2026-08-20 13:08:22 -04:00
swung0x48 e2923a239f [Fix, Test] (ShaderTranspiler): size a non-final unsized storage-block member so the members after it stop aliasing it 2026-08-20 13:05:50 -04:00
swung0x48 1740a8a41a [Feat, Test] (GLImpl, GLState): implement glBeginConditionalRender and discard the commands GL 4.6 10.9 names 2026-08-20 12:59:22 -04:00
swung0x48 6b1d89f279 [Fix, Test] (DirectGLES, MG_IntegrationTest): re-sync image-unit bindings when a draw's image texture was re-specified 2026-08-20 12:52:13 -04:00
swung0x48 01fbe0b4b0 [Fix, Test] (GLState, ShaderTranspiler): reject a storage-block binding at or past GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS 2026-08-20 12:45:56 -04:00
swung0x48 cb155c5b94 [Fix, Test] (GLImpl, ShaderTranspiler): reconcile the compute work-group limits glGetIntegeri_v and glslang advertise 2026-08-20 12:41:01 -04:00
swung0x48 04a06438c5 [Fix] (GLState): count an image-uniform array once however reflection spelled it 2026-08-20 12:16:57 -04:00
swung0x48 db00774224 [Fix] (DirectGLES): report the image formats GLSL ES cannot spell instead of losing the program silently 2026-08-20 12:16:56 -04:00
swung0x48 f378c1a064 [Fix, Test] (DirectGLES): read 1D-array and cube-map-array levels back layer by layer in glGetTexImage 2026-08-20 12:16:55 -04:00
swung0x48 421ccd08c6 [Fix, Test] (DirectGLES): make both halves of a split read+write image coherent 2026-08-20 12:04:43 -04:00
swung0x48 039af520bf [Fix, Test] (GLState): fail the link when a stage exceeds GL_MAX_*_IMAGE_UNIFORMS 2026-08-20 12:02:22 -04:00
Swung0x48 cdba7bed2e [Test, Bench] (ShaderTranspiler): pin L1 backend-agnosticism and measure the whole-front-end hit 2026-08-20 12:00:01 -04:00
Swung0x48 1eeeb44d94 [Perf] (ProgramState): serve a whole linked program from translation cache L1, skipping the link entirely 2026-08-20 12:00:01 -04:00
swung0x48 fa2e15c27e [Fix, Test] (GLImpl): answer GL_IMAGE_FORMAT_COMPATIBILITY_TYPE from glGetTexParameterfv 2026-08-20 11:56:24 -04:00
Swung0x48 14744f117c [Refactor] (ProgramInterface): build the program-resource model from the reflection snapshot, retiring GetReflection 2026-08-20 11:47:57 -04:00
Swung0x48 8329ab4264 [Refactor] (ProgramState): answer the GL query surface from an owned reflection snapshot, not the live TProgram 2026-08-20 11:43:42 -04:00
swung0x48 ee98c453ed [Fix, Test] (GLImpl): enforce GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS on the bind and indexed-query paths 2026-08-20 11:39:28 -04:00
swung0x48 f88322ce84 [Feat, Test] (DirectGLES, ShaderTranspiler): bind atomic counter buffers end-to-end on the ES backend 2026-08-20 11:36:52 -04:00
Swung0x48 93f1106ba4 [Fix] (ShaderTranspiler): key translation cache L1 on the front-end environment only, not backend identity 2026-08-20 11:30:31 -04:00
swung0x48 31b5b563d6 [Fix, Test] (GLState): fail the link when two atomic counters share a binding and an offset 2026-08-20 11:27:13 -04:00
swung0x48 a9fb7ef0af [Fix, Test] (GLImpl, GLState): answer GL_ACTIVE_ATOMIC_COUNTER_BUFFERS and implement glGetActiveAtomicCounterBufferiv 2026-08-20 11:23:33 -04:00
swung0x48 6159166d38 [Fix, Test] (GLImpl, ShaderTranspiler): reconcile the atomic-counter limits glGetIntegerv and glslang advertise 2026-08-20 11:20:21 -04:00
Swung0x48 5fecfa42f6 [Bench] (ShaderTranspiler): bracket the translation-cache win with a CTS-sized and a heavy stage 2026-08-20 11:11:37 -04:00
Swung0x48 d48e5d0053 [Fix] (ShaderTranspiler): leak the translation caches so no worker inserts into a destroyed one at exit 2026-08-20 11:09:01 -04:00
Swung0x48 7a0182b58f [Bench] (ShaderTranspiler): measure the translation cache on a repeated-compile loop 2026-08-20 10:59:46 -04:00
Swung0x48 0f523db14d [Test] (ShaderTranspiler): cover both translation-cache key inventories, eviction and the concurrent path 2026-08-20 10:59:46 -04:00
Swung0x48 442cec1a15 [Perf] (DirectGLES): memoize the SPIR-V to ESSL transpile per stage (translation cache L2) 2026-08-20 10:51:05 -04:00
Swung0x48 246a438138 [Perf] (ShaderTranspiler): memoize a linked program's sanitized SPIR-V (translation cache L1) 2026-08-20 10:51:05 -04:00
154 changed files with 34328 additions and 1853 deletions
+9
View File
@@ -270,6 +270,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp
MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp
MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp
MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp
MobileGL/MG_Util/ShaderTranspiler/glslang/TMglGlslIoResolver.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenInterfaceStructPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EliminateFloatEqualsZeroPass.cpp
@@ -278,10 +279,12 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecomposeWorkgroupVec3Pass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecoratePositionInvariantPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenFloat64StorageBlockPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenXfbInterfaceBlocksPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/UniquifyIoBlockNamesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/SplitArrayVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp
@@ -291,7 +294,9 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateSubgroupsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/NormalizeRectCoordinatesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ClampMultisampleFetchPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp
@@ -299,10 +304,13 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenAtomicCounterBlockPass.cpp
MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp
MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp
MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp
MobileGL/MG_Util/SelfTest/DriverPost.cpp
MobileGL/MG_Util/SelfTest/DriverPostIterationRPWitness.cpp
@@ -388,6 +396,7 @@ set(SOURCE_FILES
MobileGL/MG_State/GLState/TextureState/TextureState.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp
+22
View File
@@ -203,6 +203,28 @@ namespace MobileGL::MG_Config {
// immediately stay serial by their own construction). Off by default; never
// advertise it.
QuirkOverride AsyncOptimisticShaderStatus = QuirkOverride::Auto;
// MOBILEGL_SHADER_CACHE: the three-level, in-memory shader translation memo
// (MG_Util/ShaderTranspiler/TranslationCache.h). The levels follow the GL
// entry points - L1c memoizes one glCompileShader's PARSE VERDICT, L1 a
// linked program's whole front end, L2 DirectGLES's emitted ESSL. Auto is
// ON; ForceOff turns ALL THREE off and makes every translation run from
// scratch. The escape hatch exists because a wrong cache hit is a silently
// miscompiled shader: if a device ever renders differently with the cache
// on, one run with this falsy says so.
QuirkOverride ShaderTranslationCache = QuirkOverride::Auto;
// MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION: DirectGLES' gl_ViewportIndex routing
// emulation - the builtin becomes a flat varying, the fragment stage gets a
// per-pass gate, and a routed draw is REPLAYED once per distinct viewport state
// with the real glViewport/glScissor/glDepthRangef set for it. Auto is ON, and
// it is ON even where the driver advertises GL_OES_viewport_array, because that
// extension only ever gave the SHADER a compilable name: MobileGL has never
// programmed a driver's INDEXED viewport state (SyncRenderState pushes index 0
// and nothing else), so on an extension-capable driver every index rasterized as
// index 0 exactly as it did without one. ForceOff returns to that behaviour -
// the pre-emulation path, extension passthrough where it exists and
// LowerViewportIndexPass' demote-to-a-plain-global where it does not - and is
// the negative control the emulation is measured against.
QuirkOverride ViewportArrayEmulation = QuirkOverride::Auto;
};
extern FeaturesTable Features;
} // namespace MobileGL::MG_Config
+3
View File
@@ -194,6 +194,9 @@ namespace MobileGL::MG_ConfigLoader {
features.AsyncShaderCompileThreads = QueryEnvUint32("MOBILEGL_ASYNC_SHADER_COMPILE_THREADS", 0, 0, 64);
features.AsyncOptimisticShaderStatus =
QueryEnvQuirkOverride("MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS");
features.ShaderTranslationCache = QueryEnvQuirkOverride("MOBILEGL_SHADER_CACHE");
features.ViewportArrayEmulation =
QueryEnvQuirkOverride("MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION");
}
inline void InitBackendType() {
+10
View File
@@ -18,6 +18,8 @@
#include <MG_Impl/GLImpl/Query/GL_Query.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_State/GLState/ProgramState/ProgramTranslationCache.h>
#include <MG_Util/ShaderTranspiler/TranslationCache.h>
#include <atomic>
#include <mutex>
@@ -72,6 +74,14 @@ namespace MobileGL {
// built-in symbol tables the prewarm latch stands for, so leaving it set would
// make the next Initialize() skip a prewarm it genuinely needs.
MG_Util::ShaderTranspiler::ShaderCompiler::ResetPrewarmLatch();
// The two-level translation memo. Nothing in it references a glslang object -
// both levels hold plain bytes - so this is RSS hygiene rather than a lifetime
// requirement, and it is safe either side of FinalizeProcess. Stats first: an
// fordebug build gets one line per level saying how the run went.
MG_Util::ShaderTranspiler::LogShaderTranslationCacheStats();
MG_Util::ShaderTranspiler::ClearShaderTranslationCaches();
MG_State::GLState::LogProgramTranslationCacheStats();
MG_State::GLState::ClearProgramTranslationCache();
MG_Backend::gBackendFunctionsTable = {};
g_isInitialized = false;
if (logLifecycle) {
+48
View File
@@ -372,8 +372,32 @@ namespace MobileGL {
Int MaxComputeImageUniforms = 8;
Int MaxDrawBuffers = 8;
Int MaxColorAttachments = 8;
// GL_MAX_CLIP_DISTANCES. Zero is a legal answer here, not a placeholder, and a
// backend that cannot host a clip distance MUST report it: advertising eight the
// backend will refuse does not make gl_ClipDistance work, it only moves the failure
// from an honest "unsupported" at query time to a backend shader-compile error the
// frontend never surfaces, after which every draw with that program silently renders
// nothing. DirectGLES fills it from GL_EXT_clip_cull_distance, DirectVulkan from the
// shaderClipDistance device feature. The DEFAULT stays at the GL 4.3 core minimum
// because it describes the no-backend case (standalone shader compiles, unit tests),
// where there is no device to be honest about and BuildTBuiltInResource still has to
// hand glslang a workable gl_MaxClipDistances.
Int MaxClipDistances = 8;
Int MaxViewports = 16;
// GL_LAYER_PROVOKING_VERTEX / GL_VIEWPORT_INDEX_PROVOKING_VERTEX: which vertex of a
// primitive supplies gl_Layer and gl_ViewportIndex. GL 4.6 table 23.65 makes
// GL_UNDEFINED_VERTEX a legal answer for both, and it is the honest default - naming
// a convention is a statement about behaviour, so a backend that does not pin one
// must not claim it does. DirectGLES fills the layer one from the ES 3.2 query and
// the viewport one from GL_OES_viewport_array, and leaves UNDEFINED where the
// capability is absent: without the viewport array extension only viewport 0 is ever
// rasterized, so no convention selects anything. DirectVulkan keeps UNDEFINED for
// both - which vertex provokes is decided per pipeline by
// VulkanRenderer::SelectProvokingVertexMode out of VK_EXT_provoking_vertex,
// provokingVertexModePerPipeline and the topology, so no single convention is true
// of the backend.
GLenum LayerProvokingVertex = GL_UNDEFINED_VERTEX;
GLenum ViewportIndexProvokingVertex = GL_UNDEFINED_VERTEX;
Int MaxViewportWidth = 16384;
Int MaxViewportHeight = 16384;
Float ViewportBoundsRangeMin = 0.0f;
@@ -421,12 +445,36 @@ namespace MobileGL {
const Uint32 bit = PerLayerFramebufferAttachmentBit(target);
return bit != 0 && (PerLayerFramebufferAttachmentTargets & bit) != 0;
}
// Whether this backend can CONSUME a shader module that still declares 64-bit floats,
// i.e. whether `double` survives the transpile instead of being narrowed to `float`
// (ShaderTranspiler::DemoteFloat64Pass). Detected, never assumed:
// * DirectVulkan sets it from VkPhysicalDeviceFeatures::shaderFloat64, the feature
// VUID-VkShaderModuleCreateInfo-pCode-08740 requires before a module declaring
// OpCapability Float64 may be created at all. lavapipe has it; Adreno and Mali
// both report VK_FALSE, so no real mobile device does.
// * DirectGLES can NEVER have it. GLSL ES has no 64-bit float type in any version
// or extension, so SPIRV-Cross cannot emit one ("FP64 not supported in ES
// profile") and the demotion there is mathematically mandatory, always.
// Defaults to false so a backend that never sets it - and the no-backend case, which
// is what standalone shader compiles and the unit tests run under - keeps the
// demotion, which is the behaviour that works everywhere.
Bool SupportsShaderFloat64 = false;
// Whether glVertexAttribLFormat / glVertexArrayAttribLFormat can be honoured, i.e.
// whether a 64-bit vertex attribute can actually reach a shader unconverted. Detected,
// never assumed: DirectVulkan needs VkPhysicalDeviceFeatures::shaderFloat64 (the
// attribute travels as its 32-bit word pair, so no VK_FORMAT_R64* is required, but the
// bitcast result is Float64); DirectGLES can never have it, ESSL having no fp64 type at
// all. Defaults to false so a backend that never sets it gets the conservative answer.
//
// INDEPENDENT of SupportsShaderFloat64, and it has to be: this flag decides a VkFormat
// from the VAO ATTRIBUTE alone, which does not know what type the shader declared, and
// glVertexAttribFormat(GL_DOUBLE) feeding a plain `in vec4` is both legal and common
// (KHR-GL43.vertex_attrib_binding.basic-input-case4/5, advanced-bindingUpdate). A
// backend with native fp64 that still cannot FETCH 64 bits keeps this false and relies
// on the per-MODULE rule in ShaderCompiler::SanitizeAndOptimizeBinary instead: a vertex
// module that declares a 64-bit float INPUT is demoted whole, so the two shader-side
// halves (PackDoubleVertexInputsPass and VertexInputStateFactory::ToVkVertexFormat)
// still see one consistent world.
Bool SupportsFloat64VertexAttributes = false;
SizeT MaxShaderStorageBlockSize = 128 * 1024 * 1024;
Uint32 SubgroupSize = 0;
@@ -213,7 +213,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) {
reasons.push_back("no colour-renderable three-channel format on OpenGL ES");
}
if (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) {
// A format is either 8- or 16-bit signed normalized, so at most one of the two ever
// survives GetApplicablePixelFormatNormalizeOptions and the reason is not duplicated.
if ((options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) ||
(options & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget)) {
reasons.push_back("EXT_render_snorm not supported");
}
@@ -1328,14 +1331,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
DynParams::PerLayerFramebufferAttachmentBit(TextureTarget::TextureCubeMapArray);
}
}
// Not a driver question and never will be: OpenGL ES has no double-precision vertex format
// and ESSL has no fp64 type to consume one with, so a 64-bit vertex attribute has nowhere to
// land on this backend regardless of what the driver underneath happens to support.
// Not a driver question and never will be: GLSL ES has no 64-bit float type in ANY version
// or extension, so SPIRV-Cross cannot emit one ("FP64 not supported in ES profile") and a
// module that still declared Float64 would never reach the driver at all. The demotion is
// mathematically mandatory here, on every device, forever - which is why this stays false
// regardless of what the driver underneath happens to support.
m_dynamicParameters.SupportsShaderFloat64 = false;
// Follows the line above, and must: OpenGL ES has no double-precision vertex format and no
// fp64 type to consume one with, so a 64-bit vertex attribute has nowhere to land here.
m_dynamicParameters.SupportsFloat64VertexAttributes = false;
m_dynamicParameters.MaxDrawBuffers = m_GLESCapabilities.MaxDrawBuffers;
m_dynamicParameters.MaxColorAttachments = m_GLESCapabilities.MaxColorAttachments;
m_dynamicParameters.MaxClipDistances = m_GLESCapabilities.MaxClipDistances;
m_dynamicParameters.MaxViewports = m_GLESCapabilities.MaxViewports;
// Whatever the driver said about which vertex supplies gl_Layer, and GL_UNDEFINED_VERTEX
// for gl_ViewportIndex on every driver without GL_OES_viewport_array - which is both test
// devices. That is not a shortfall being hidden: without the extension only viewport 0 is
// ever rasterized, so no vertex "selects" a viewport index and naming a convention would
// describe behaviour this backend does not implement.
m_dynamicParameters.LayerProvokingVertex = m_GLESCapabilities.LayerProvokingVertex;
m_dynamicParameters.ViewportIndexProvokingVertex = m_GLESCapabilities.ViewportIndexProvokingVertex;
m_dynamicParameters.MaxViewportWidth = m_GLESCapabilities.MaxViewportWidth;
m_dynamicParameters.MaxViewportHeight = m_GLESCapabilities.MaxViewportHeight;
m_dynamicParameters.ViewportBoundsRangeMin = m_GLESCapabilities.ViewportBoundsRangeMin;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+256 -3
View File
@@ -21,6 +21,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
String EmulateBaseInstanceInVertexShader(String source, GLenum shaderType);
String PromoteDrawParameterGlobalsToUniforms(String source, GLenum shaderType);
// The ESSL half of the gl_ViewportIndex routing emulation, in the order a program's stages
// meet it. Both are pure String -> String rewrites over what SPIRV-Cross emitted once
// LowerViewportIndexPass has demoted the builtin to the plain global `mg_ViewportIndex`.
//
// The producing stage's global becomes an ordinary flat varying; true when there was one to
// promote, which is also the answer to "does this program route viewports at all".
Bool PromoteViewportIndexGlobalToVarying(String& source);
// The fragment stage grows a matching flat input, the mg_ViewportPassMask uniform the draw
// path writes, and a wrapper entry point that discards every fragment whose primitive routed
// to an index the current replay pass is not drawing. False when the stage has no entry point
// to wrap, which leaves the program renderable but unrouted.
Bool InjectViewportIndexPassGate(String& source);
// Whether a vertex shader may declare a storage block at all, given what the host driver
// reports for GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS. Pure, and separated from the capability
// global purely so the decision can be tested without one.
@@ -113,6 +126,58 @@ namespace MobileGL::MG_Backend::DirectGLES {
// link.
Bool CurrentProgramMayNeedPerSubDrawBuiltins(Bool batchCarriesBaseVertices);
// ---- gl_ViewportIndex routing emulation, draw half ---------------------------------------
//
// GLES has ONE viewport, ONE scissor rectangle and ONE depth range; GL 4.1 has sixteen of
// each, selected per primitive by gl_ViewportIndex. There is no ES entry point to program the
// other fifteen with (GL_OES_viewport_array exists but Adreno 830 does not have it, verified
// three ways), so the only way to rasterize a primitive against index i's rectangle is to
// make index i's rectangle THE viewport for the duration of a draw - which means issuing the
// draw once per distinct viewport state and letting the fragment stage throw away the
// primitives that belong to the other indices (the gate Managers.cpp injects).
//
// Indices whose whole state tuple (viewport rectangle, scissor rectangle, scissor-test enable,
// depth range) is identical share ONE pass, so the overwhelmingly common case - every index
// still holding what glViewport/glScissor/glDepthRange broadcast to all sixteen - collapses
// to a single pass with an all-ones gate mask, i.e. one draw and no behaviour change at all.
//
// Whether emulation runs. Off only under MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION falsy, which
// restores the pre-emulation path as a negative control.
Bool ViewportArrayEmulationEnabled();
// Whether ANY program built in this process has come out with a viewport gate. Sticky once
// true; it exists so that BeginViewportRoutingPasses - which runs on every draw of every
// workload - can answer with one static load in the case that matters, which is every
// application that has never heard of gl_ViewportIndex.
extern Bool g_anyProgramRoutesViewportIndex;
// Number of times the current draw has to be issued. Always >= 1, and exactly 1 - with no
// state touched - whenever the current program does not route viewports, whenever every
// configured index shares one state, and whenever replaying would multiply a side effect the
// fragment gate cannot undo (transform feedback, rasterizer discard). Also seeds the pass
// mask uniform for that single-pass case, so a gated fragment shader never runs against the
// zero every GLSL uniform starts at - which would discard the whole draw.
Uint BeginViewportRoutingPasses();
// Push pass `pass`'s viewport / scissor / scissor-test / depth range onto the ES context and
// set the gate mask to the indices it serves. Only called when the count above exceeds 1.
void ApplyViewportRoutingPass(Uint pass);
// Restore the gate mask and mark the render-state shadow dirty, so the next ordinary draw
// re-pushes index 0's state. Takes the count so it can do nothing at all in the common case.
void EndViewportRoutingPasses(Uint passCount);
// Issue one draw, replayed once per viewport-routing pass. Every application-visible draw
// entry point wraps its native glDraw* call in this; the internal blit and clear helpers
// deliberately do not, because they bind their own programs, which never route.
template <typename IssueDraw>
inline void ForEachViewportRoutingPass(IssueDraw&& issue) {
const Uint passCount = BeginViewportRoutingPasses();
for (Uint pass = 0; pass < passCount; ++pass) {
if (passCount > 1) {
ApplyViewportRoutingPass(pass);
}
issue();
}
EndViewportRoutingPasses(passCount);
}
template <typename StateObject, typename BackendObject>
class StateBackendObjectRegistry {
public:
@@ -396,6 +461,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
void BindBufferBaseCached(GLenum glTarget, Uint index, Uint id);
void BindBufferRangeCached(GLenum glTarget, Uint index, Uint id, GLintptr offset, GLsizeiptr size);
void InvalidateIndexedBufferBindingCache();
// Re-issues the GL_ATOMIC_COUNTER_BUFFER binding points a program's shaders declare as
// GL_SHADER_STORAGE_BUFFER bindings at the reserved slots the transpiled ESSL was built
// against (BackendProgramObjectImpl::GetAtomicCounterBindings /
// GetAtomicCounterEsslBindingTop). ES has no counter-buffer target at all, so without
// this the shader reads a storage block nobody ever bound a buffer to and the buffer the
// application bound never reaches the driver.
void SyncAtomicCounterBuffers(const Vector<Int>& glBindings, Int esslBindingTop);
// Buffer-storage pool maintenance. TrimBufferPool evicts over-budget entries
// (called once per frame from Present); ClearBufferPool drops all pooled ids
// without glDeleteBuffers (called when the ES context is going away).
@@ -502,10 +574,44 @@ namespace MobileGL::MG_Backend::DirectGLES {
PendingAttribValueMask& GetPendingAttribValueMaskMemo() { return m_pendingAttribValueMask; }
private:
// Narrows one enabled GL_DOUBLE array into a tightly packed float32 stream held in
// this VAO's own scratch buffer and declares the attribute against it. ES has no
// 64-bit vertex format, but the source bytes are ordinary IEEE-754 doubles and every
// fp64 value in every shader is already narrowed to 32 bits (DemoteFloat64Pass), so
// narrowing the ARRAY is the coherent completion of that decision rather than
// dropping it. Returns false when the stream cannot be built, in which case the
// caller must DISABLE the array - leaving a 64-bit array enabled with no pointer is
// what the Adreno driver turns into a SIGSEGV at the next draw.
Bool SyncFloat64AttributeAsFloat32(Uint attribIndex, const MG_State::GLState::VertexAttribute& attrib,
Uint32 fetchBaseInstance);
// What the converted float32 stream in m_convertedAttributeBufferIds[i] was built
// from. A hit skips the CPU conversion and the re-upload; the buffer's change serial
// is part of the key, so a glBufferSubData into the source invalidates it.
struct ConvertedFloat64Stream {
Bool valid = false;
Uint64 sourceLifetimeId = 0;
Uint64 sourceChangeSerial = 0;
SizeT sourceOffset = 0;
SizeT sourceStride = 0;
SizeT componentCount = 0;
SizeT elementCount = 0;
};
ResolvedDrawBuffers m_resolvedDrawBuffers;
PendingAttribValueMask m_pendingAttribValueMask;
Uint m_backendVAOId = 0;
Array<Uint, MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS> m_clientAttributeBufferIds;
// Scratch stores for the buffer-backed GL_DOUBLE narrowing. Deliberately separate
// from m_clientAttributeBufferIds: that one holds the per-draw upload of a
// CLIENT-MEMORY array, and an attribute index can carry both shapes over its life.
Array<Uint, MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS> m_convertedAttributeBufferIds;
Array<ConvertedFloat64Stream, MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS>
m_convertedAttributeStreams;
// True while at least one attribute of this VAO is fed by a converted stream. Such a
// stream is derived from buffer CONTENT, which no VAO version covers, so the config
// version early-out in SyncToBackend must not be trusted while it is set.
Bool m_hasConvertedFloat64Attribute = false;
Bool m_isInitialized = false;
Uint16 m_syncedIndexBufferVersion = 0;
// Identity of the buffer the version above was stamped against. Raw and never
@@ -639,9 +745,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Returns `data` untouched when no widening applies. Pure CPU and context-free so a unit
// test can exercise the exact packing the driver is handed; `widenedData` is the caller's
// scratch buffer and has to outlive the returned pointer.
// `alphaOneCodeOverride`, when non-zero, replaces the value written into the synthetic
// alpha channel: an image carrier that holds a NORMALIZED format's channel CODES has to
// pad alpha with that channel's saturated CODE (65535, 32767, 3), which neither of the
// transfer type's own "ones" is.
const void* PrepareChannelWidenedUpload(Uint componentCount, const IntVec3& texelSize, const void* data,
SizeT byteSize, GLenum uploadType, Vector<Uint8>& widenedData,
Bool integerData = false);
Bool integerData = false, Uint32 alphaOneCodeOverride = 0u);
// Splits a GL_UNSIGNED_INT_2_10_10_10_REV shadow (rgb10_a2, rgb10_a2ui) into the four
// GL_UNSIGNED_SHORT channel CODES its GL_RGBA16UI image carrier is uploaded as: red in
// bits 0-9, green 10-19, blue 20-29, alpha 30-31. Pure CPU and context-free so a unit test
// can pin the exact fields; `widenedData` is the caller's scratch and has to outlive the
// returned pointer.
const void* PreparePackedIntWidenedUpload(const IntVec3& texelSize, const void* data, SizeT byteSize,
Vector<Uint8>& widenedData);
struct StateTextureBasicInfo { // Used for tracking texture state changes
TextureInternalFormat internalFormat = TextureInternalFormat::Unknown;
@@ -676,10 +794,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
void SyncMipmapsToBackend(const SharedPtr<MG_State::GLState::ITextureObject>& stateTextureObject);
void SyncBuiltinSamplerToBackend(const SharedPtr<MG_State::GLState::ITextureObject>& stateTextureObject);
void SyncTextureParamsToBackend(const SharedPtr<MG_State::GLState::ITextureObject>& stateTextureObject);
void RequireImageBindableStorage();
// Marks the texture as one whose ES storage has to be image-bindable, which for a
// non-core image format means re-minting it in the widening's carrier. Takes the state
// object because the levels already uploaded have to be marked dirty again: the
// re-mint allocates fresh storage and only replays what the shadow still calls dirty.
void RequireImageBindableStorage(
const SharedPtr<MG_State::GLState::ITextureObject>& stateTextureObject);
// Whether this texture's ES storage was minted in an image carrier rather than in the
// frontend format's own layout - the readback has to ask, because for a NORMALIZED
// carrier the storage is an integer texture holding codes and glGetTexImage still owes
// the application floats.
Bool RequiresImageBindableStorage() const { return m_imageBindableStorageRequired; }
void Bind(GLenum target, Uint unit = TempTextureUnit);
Uint GetBackendTextureId() const;
// The id to hand glBindImageTexture for a SPLIT buffer image, or 0 when this texture
// takes no split. See m_bufferImageSplitViewId.
Uint GetBufferImageSplitViewId() const { return m_bufferImageSplitViewId; }
// Aggregate first-level clean gate for the per-draw trio
// SyncTextureParamsToBackend + SyncBuiltinSamplerToBackend +
// SyncMipmapsToBackend: EXACTLY the conjunction of their own early-outs
@@ -716,6 +848,25 @@ namespace MobileGL::MG_Backend::DirectGLES {
void RecreateBackendTexture();
Uint m_backendTextureId = 0;
// A SECOND buffer-texture name over the SAME buffer object, viewed in the split's
// single-channel base format, used only as the glBindImageTexture target.
//
// The split needs the view to say r32f where the application said rg32f, but a buffer
// texture that is image-bound may ALSO be read through a samplerBuffer - and the
// sampler side is not subscript-rewritten, so re-describing the application's own
// texture broke it: texelFetch(s, i) returned component 2i of the base view instead of
// texel i's pair. That is exactly and only
// KHR-GL42/43.shader_image_load_store.advanced-sync-imageAccess, which image-stores
// into a GL_RG32F buffer texture and then reads the same texture through both an
// imageBuffer and a samplerBuffer in one shader, comparing the two.
//
// Two names over one buffer cost nothing and alias exactly: a buffer texture owns no
// storage, so both views are the application's bytes, and the split's whole premise is
// that the two describe the same memory. The application's own name therefore keeps
// the format it asked for - rg32f IS a legal SAMPLED buffer-texture format in ES 3.2,
// it is only the IMAGE binding ES cannot spell - and the private name below carries
// the split the shader was rewritten against. 0 when this texture takes no split.
Uint m_bufferImageSplitViewId = 0;
// ES context generation the id was created under; a dtor running after
// that context died must not delete a foreign (recycled) name.
Uint m_contextGeneration = 0;
@@ -1062,23 +1213,51 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Image uniforms take their unit from the layout(binding=N) qualifier baked into
// the transpiled ESSL; unlike samplers they must not (and in ES cannot) be
// assigned through glUniform1i.
//
// ALL THIRTY-THREE of them, in the one contiguous block ARB_shader_image_load_store allocated
// (GL_IMAGE_1D 0x904C through GL_UNSIGNED_INT_IMAGE_2D_MULTISAMPLE_ARRAY 0x906C). The list
// used to hold only the fifteen whose TARGET exists in ES, which read as a reasonable
// shortcut and was two bugs: an image uniform this says "no" to is one
// CollectImageFormatBakeInputs never walks, so its non-core format is neither baked nor
// widened and SPIRV-Cross throws for the whole stage ("Attempting to use image format not
// supported in ES profile"), and it is also one SyncToBackend then treats as a SAMPLER and
// assigns with glUniform1i, which ES makes an INVALID_OPERATION. A GL_TEXTURE_CUBE_MAP_ARRAY
// image - which ES 3.2 has in core, so it is not even an emulated target - hit both.
inline Bool IsImageUniformType(GLenum type) {
switch (type) {
case 0x904C: /*GL_IMAGE_1D*/
case 0x904D: /*GL_IMAGE_2D*/
case 0x904E: /*GL_IMAGE_3D*/
case 0x904F: /*GL_IMAGE_2D_RECT*/
case 0x9050: /*GL_IMAGE_CUBE*/
case 0x9051: /*GL_IMAGE_BUFFER*/
case 0x9052: /*GL_IMAGE_1D_ARRAY*/
case 0x9053: /*GL_IMAGE_2D_ARRAY*/
case 0x9054: /*GL_IMAGE_CUBE_MAP_ARRAY*/
case 0x9055: /*GL_IMAGE_2D_MULTISAMPLE*/
case 0x9056: /*GL_IMAGE_2D_MULTISAMPLE_ARRAY*/
case 0x9057: /*GL_INT_IMAGE_1D*/
case 0x9058: /*GL_INT_IMAGE_2D*/
case 0x9059: /*GL_INT_IMAGE_3D*/
case 0x905A: /*GL_INT_IMAGE_2D_RECT*/
case 0x905B: /*GL_INT_IMAGE_CUBE*/
case 0x905C: /*GL_INT_IMAGE_BUFFER*/
case 0x905D: /*GL_INT_IMAGE_1D_ARRAY*/
case 0x905E: /*GL_INT_IMAGE_2D_ARRAY*/
case 0x905F: /*GL_INT_IMAGE_CUBE_MAP_ARRAY*/
case 0x9060: /*GL_INT_IMAGE_2D_MULTISAMPLE*/
case 0x9061: /*GL_INT_IMAGE_2D_MULTISAMPLE_ARRAY*/
case 0x9062: /*GL_UNSIGNED_INT_IMAGE_1D*/
case 0x9063: /*GL_UNSIGNED_INT_IMAGE_2D*/
case 0x9064: /*GL_UNSIGNED_INT_IMAGE_3D*/
case 0x9065: /*GL_UNSIGNED_INT_IMAGE_2D_RECT*/
case 0x9066: /*GL_UNSIGNED_INT_IMAGE_CUBE*/
case 0x9067: /*GL_UNSIGNED_INT_IMAGE_BUFFER*/
case 0x9068: /*GL_UNSIGNED_INT_IMAGE_1D_ARRAY*/
case 0x9069: /*GL_UNSIGNED_INT_IMAGE_2D_ARRAY*/
case 0x906A: /*GL_UNSIGNED_INT_IMAGE_CUBE_MAP_ARRAY*/
case 0x906B: /*GL_UNSIGNED_INT_IMAGE_2D_MULTISAMPLE*/
case 0x906C: /*GL_UNSIGNED_INT_IMAGE_2D_MULTISAMPLE_ARRAY*/
return true;
default:
return false;
@@ -1086,6 +1265,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
namespace PrgramImpl {
// Defined further down, next to CollectImageFormatBakeInputs; only referenced here.
struct ImageFormatBakeInputs;
class BackendProgramObjectImpl {
public:
// Per-link cache of a sampler-style uniform's backend location: built once in
@@ -1145,7 +1327,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
BackendProgramObjectImpl();
~BackendProgramObjectImpl();
void SyncToBackend(const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject);
void Use() const;
void Use();
void SetBaseInstance(Uint32 baseInstance) const;
void SetBaseInstanceWordIndex(Int32 wordIndex) const;
void SetDrawID(Uint32 drawId) const;
@@ -1156,6 +1338,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Same for gl_BaseVertex: only a program that reads it pays for the per-draw
// uniform write, and only such a program needs the reset after one.
Bool ReadsBaseVertex() const { return m_baseVertexUniformLocation >= 0; }
// Which viewport indices the next draw's fragments may keep, one bit each. Written
// once per replay pass; see ForEachViewportRoutingPass.
void SetViewportPassMask(Uint32 indexMask) const;
// True when this build injected the fragment-stage viewport gate, i.e. when a
// pre-rasterization stage routes by gl_ViewportIndex AND the fragment stage can act
// on it. The uniform is the honest test for both halves: it exists only where the
// gate was injected, and the gate is injected only where a stage routes.
Bool RoutesViewportIndex() const { return m_viewportPassMaskUniformLocation >= 0; }
Int GetIndirectParamsBinding() const { return m_indirectParamsBinding; }
Uint GetBackendProgramId() const { return m_backendProgramId; }
// False when the last SyncToBackend could not produce a usable program (a
@@ -1171,6 +1361,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
// 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; }
// GL atomic-counter binding points the transpiled stages declare (sorted, unique),
// and the top of the reserved shader-storage range their counter blocks were
// transpiled against - the slot for GL binding N is `top - N`. Empty for every
// program that uses no atomic counter, which is what keeps the per-draw cost of the
// counter sync at one empty-vector test.
const Vector<Int>& GetAtomicCounterBindings() const { return m_atomicCounterGlBindings; }
Int GetAtomicCounterEsslBindingTop() const { return m_atomicCounterEsslBindingTop; }
// GL_PATCH_VERTICES the synthesized pass-through tessellation control stage was built
// for, or -1 when this program needed no such stage. Another of the same shape as the
// signatures above: the value is compiled INTO the synthesized stage as
// `layout(vertices = N) out`, so a program built for one patch size is stale for
// another and the draw path has to say so. -1 compares equal to itself for every
// program that has a control stage of its own, i.e. for all but a handful.
Int GetPassthroughTessControlPatchVertices() const {
return m_passthroughTessControlPatchVertices;
}
Bool HasGlobalUboBlock() const { return m_globalUboBackendBlockIndex >= 0; }
const Vector<Int>& GetUniformBlockBackendIndices() const { return m_uniformBlockBackendIndices; }
@@ -1219,6 +1425,33 @@ namespace MobileGL::MG_Backend::DirectGLES {
private:
void CacheResourceLocations(const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject);
// Builds, compiles and attaches the pass-through tessellation control stage GL 4.6
// core 11.2.2 describes, for a program that has an evaluation stage and none of its
// own - which ES 3.2 rejects outright. Called from SyncToBackend after every real
// stage has been attached and before the link; see the definition for why it cannot
// regress a program that works today.
void AttachPassthroughTessControlStage(
const MG_State::GLState::ProgramObject& stateProgramObject, Int tessEvalShaderIndex,
const Vector<Vector<unsigned int>>& shaderSpirvs, const String& vertexStageEssl,
const String& tessEvalStageEssl);
// One stage's SPIR-V through the DirectGLES pass chain and SPIRV-Cross, producing
// the raw emitted ESSL and the interface blocks this stage's XFB flattening
// rewrote. This is the segment the L2 shader-translation memo keys on, so every
// input it reads must appear in EsslTranslationKeyInputs - see the definition's
// header comment in Managers.cpp and MG_Util/ShaderTranspiler/TranslationCache.h.
// False means SPIRV-Cross refused the module; `outError` then carries its message.
Bool TranspileSpirvToEssl(const Vector<unsigned int>& spirvCode, GLenum glShaderType,
const std::set<String>& xfbCaptureBlockNames,
const ImageFormatBakeInputs& imageFormatBake,
const UnorderedMap<String, Int>& storageBlockBindingOverrides,
const std::map<String, String>& inputBlockRenames,
const std::map<String, String>& outputBlockRenames,
Int atomicCounterEsslBindingTop, Bool enableSpirvValidation,
String& outSource,
std::set<String>& outFlattenedXfbBlockNames,
Vector<Int>& outAtomicCounterGlBindings, String& outError) const;
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
@@ -1229,6 +1462,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
Int m_drawIdUniformLocation = -1;
Int m_baseVertexUniformLocation = -1;
Int m_baseInstanceWordIndexUniformLocation = -1;
Int m_viewportPassMaskUniformLocation = -1;
Int m_indirectParamsBinding = -1;
Uint32 m_snormFallbackClampOutputMask = 0;
Uint32 m_unormFallbackClampOutputMask = 0;
@@ -1237,8 +1471,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint m_fragColorBroadcastCount = 1;
// 0 is the signature of an empty override set, i.e. what almost every program has.
Uint64 m_shaderStorageBlockBindingSignature = 0;
Vector<Int> m_atomicCounterGlBindings;
Int m_atomicCounterEsslBindingTop = -1;
// -1 for every program that has a tessellation control stage of its own (or none at
// all); otherwise the GL_PATCH_VERTICES the synthesized pass-through stage was built
// with. See GetPassthroughTessControlPatchVertices.
Int m_passthroughTessControlPatchVertices = -1;
Bool m_isInitialized = false;
Bool m_backendProgramUsable = false;
// Set by SyncToBackend every time it relinks the driver program, cleared by the
// next Use(). Use() dedupes on a GL program NAME, and a relink replaces the
// executable behind that name without changing it - see the note at the
// glLinkProgram in SyncToBackend for what the driver runs otherwise.
Bool m_rebindAfterRelink = false;
Int m_globalUboBackendBlockIndex = -1;
Int m_globalUboBackendBlockSize = 0;
@@ -1328,6 +1573,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Some format in play - declared or baked - is outside the GLSL ES core image
// format set, so the emitted ESSL needs the GL_NV_image_formats directive.
Bool needsExtendedImageFormats = false;
// Some DECLARED format in play is one WidenImageFormatsForEssl will re-declare in a
// core carrier. Answered from the uniform reflection rather than from a module parse
// on purpose: the widening is armed on every driver, so a per-stage BuildModule to
// find out would land on every stage of every program - which is the cost
// SpirvGateFeatures exists to avoid. Program-wide, so it can over-arm a stage that
// declares no image; the pass then finds nothing, reports no change, and the caller
// keeps the module it already had.
Bool declaresWidenableImageFormat = false;
};
ImageFormatBakeInputs CollectImageFormatBakeInputs(
const MG_State::GLState::ProgramObject& stateProgramObject);
+18 -8
View File
@@ -414,14 +414,18 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
const Uint previousIndirectBinding = BoundDrawIndirectBufferId();
BufferImpl::BindBufferId(GL_DRAW_INDIRECT_BUFFER, g_indirectCommands.id);
if (batched) {
g_GLESFuncs.glMultiDrawElementsIndirectEXT(mode, type, reinterpret_cast<const void*>(commandBase),
drawcount, 0);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glMultiDrawElementsIndirectEXT(mode, type, reinterpret_cast<const void*>(commandBase),
drawcount, 0);
});
} else {
for (GLsizei i = 0; i < drawcount; ++i) {
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
if (feedBaseVertex) SetCurrentBaseVertex(basevertex ? basevertex[i] : 0);
const SizeT commandOffset = commandBase + static_cast<SizeT>(i) * sizeof(DrawElementsIndirectCommand);
g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast<const void*>(commandOffset));
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast<const void*>(commandOffset));
});
}
if (feedDrawID) SetCurrentDrawID(0);
if (feedBaseVertex) SetCurrentBaseVertex(0);
@@ -442,8 +446,10 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
if (count[i] <= 0) continue;
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
if (feedBaseVertex) SetCurrentBaseVertex(basevertex ? basevertex[i] : 0);
g_GLESFuncs.glDrawElementsBaseVertex(mode, count[i], type, indices[i],
basevertex ? basevertex[i] : 0);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElementsBaseVertex(mode, count[i], type, indices[i],
basevertex ? basevertex[i] : 0);
});
}
if (feedDrawID) SetCurrentDrawID(0);
if (feedBaseVertex) SetCurrentBaseVertex(0);
@@ -515,8 +521,10 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
// driver sees none - but gl_BaseVertex still has to report the value the
// application passed for this sub-draw.
if (feedBaseVertex) SetCurrentBaseVertex(basevertex ? basevertex[i] : 0);
g_GLESFuncs.glDrawElements(mode, count[i], GL_UNSIGNED_INT,
reinterpret_cast<const void*>(indexBase + cursor * sizeof(Uint32)));
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElements(mode, count[i], GL_UNSIGNED_INT,
reinterpret_cast<const void*>(indexBase + cursor * sizeof(Uint32)));
});
cursor += static_cast<SizeT>(count[i]);
}
if (feedDrawID) SetCurrentDrawID(0);
@@ -870,7 +878,9 @@ void main() {
if (flattened.indexCount != 0) {
const Uint previousIndexBinding = BoundIndexBufferId();
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, flattened.bufferId);
g_GLESFuncs.glDrawElements(mode, static_cast<GLsizei>(flattened.indexCount), GL_UNSIGNED_INT, nullptr);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElements(mode, static_cast<GLsizei>(flattened.indexCount), GL_UNSIGNED_INT, nullptr);
});
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, previousIndexBinding);
return;
}
+590 -28
View File
@@ -12,6 +12,7 @@
#include "MG_Backend/BackendObjects.h"
#include "MG_Util/Converters/GLToMG/FramebufferEnumConverter.h"
#include "MG_Util/Texture/TextureFormatProcessor.h"
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
#include <MG_State/GLState/Core.h>
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
@@ -171,6 +172,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!capabilities.SupportsRenderSnorm || !capabilities.SupportsNorm16Texture) {
options |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
}
// 8-bit signed-normalized storage is core ES, so only the rendering half is in
// question here; the 16-bit bit above additionally needs EXT_texture_norm16 for the
// encoding to exist at all.
if (!capabilities.SupportsRenderSnorm) {
options |= PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget;
}
return options;
}
@@ -227,6 +234,105 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat) {
return BackendFormatAddsAlpha(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
}
ImageBindableStorageWidening GetImageBindableStorageWidening(TextureInternalFormat internalFormat) {
const GLenum requested = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
const auto carrier = static_cast<GLenum>(
MG_Util::ShaderTranspiler::ShaderCompiler::WidenedCoreEsslImageFormat(requested));
if (carrier == 0) {
return {};
}
// EXACTLY the arming WidenImageFormatsForEssl uses, and it has to be: the shader, the
// storage and the bind must all widen or none of them may, or the shader addresses a
// texel size the storage does not have (which every driver tested accepts silently,
// reading and writing out of bounds).
//
// A driver WITH GL_NV_image_formats can spell the narrow format - but only for the
// formats SPIRV-Cross will actually print. It throws for its is_desktop_only_format
// set instead of emitting a token, and the throw loses the stage whatever the driver
// would have accepted: on Mesa, which advertises the extension, `layout(r8ui)
// uimage2D` still lost its whole program until the widening ran for it too.
if (g_GLESCapabilities.SupportsExtendedImageFormats &&
MG_Util::ShaderTranspiler::ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(requested)) {
return {};
}
ImageBindableStorageWidening widening;
widening.InternalFormat = carrier;
widening.SourceChannels =
MG_Util::ShaderTranspiler::ShaderCompiler::ImageFormatChannelCount(requested);
switch (carrier) {
case GL_RGBA32UI:
case GL_RGBA16UI:
case GL_RGBA8UI:
case GL_RGBA32I:
case GL_RGBA16I:
case GL_RGBA8I:
widening.IntegerData = true;
break;
default:
widening.IntegerData = false;
break;
}
// The carrier is a core ES format in every case, so it needs no fallback options of
// its own; this call is only here to spell the transfer pair that describes it.
MG_Util::TextureFormatProcessor::NormalizePixelFormat(carrier, Flags<PixelFormatNormalizeOptionBit>{},
nullptr, &widening.Format, &widening.Type);
// The two carriers that are not channel widenings, whose transfer pair has to say so.
// Every other entry keeps the frontend format's own component type - a GL_RG16F shadow
// is halves and so is its GL_RGBA16F carrier, so padding the channels is the whole
// conversion. These two shadows are a PACKED 32-bit word per texel
// (TextureFormatProcessor::NormalizePixelFormat), and no ES driver accepts either
// packed type for the carrier's level, so the transfer names the carrier's own layout
// and PrepareImageWidenedUpload splits the word into it.
switch (internalFormat) {
case TextureInternalFormat::R11FG11FB10F:
// GL_UNSIGNED_INT_10F_11F_11F_REV -> GL_RGBA / GL_FLOAT, legal for GL_RGBA16F.
widening.Format = GL_RGBA;
widening.Type = GL_FLOAT;
widening.SourceEncoding = ImageWidenSourceEncoding::PackedFloat11f11f10f;
break;
case TextureInternalFormat::RGB10A2UI:
case TextureInternalFormat::RGB10A2:
// GL_UNSIGNED_INT_2_10_10_10_REV -> the GL_RGBA_INTEGER / GL_UNSIGNED_SHORT the
// GL_RGBA16UI carrier already asked for above; only the split is new. The two
// formats share it: rgb10_a2's channel codes are the same fields rgb10_a2ui's are,
// and what the shader divides them by is not the transfer's business.
widening.SourceEncoding = ImageWidenSourceEncoding::PackedInt2101010Rev;
break;
default:
break;
}
// The seven normalized formats whose carrier holds CODES rather than values. Both
// halves of the transfer need to know: a missing alpha is padded with the saturated
// code rather than the integer 1, and glGetTexImage has to divide the codes back out.
bool signedNormalized = false;
Uint32 channelMax[4] = {0u, 0u, 0u, 0u};
if (MG_Util::ShaderTranspiler::ShaderCompiler::NormalizedImageCarrierCodes(requested, channelMax,
signedNormalized)) {
for (SizeT channel = 0; channel < 4; ++channel) {
widening.ChannelMax[channel] = channelMax[channel];
}
widening.SignedNormalized = signedNormalized;
}
return widening;
}
GLenum GetImageBindableBufferSplitFormat(TextureInternalFormat internalFormat) {
const GLenum requested = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
const auto base = static_cast<GLenum>(
MG_Util::ShaderTranspiler::ShaderCompiler::SplitCoreEsslBufferImageFormat(requested));
if (base == 0) {
return GL_UNKNOWN_MGL;
}
// EXACTLY the arming WidenImageFormatsForEssl uses, for the reason the widening's is:
// the shader, the glTexBuffer view and the glBindImageTexture argument must all split
// or none of them may, or the shader subscripts a view the buffer is not described as.
if (g_GLESCapabilities.SupportsExtendedImageFormats &&
MG_Util::ShaderTranspiler::ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(requested)) {
return GL_UNKNOWN_MGL;
}
return base;
}
} // namespace TextureImpl
namespace PrgramImpl {
String ProcessOutColorLocations(const String& glslCode) {
@@ -694,6 +800,82 @@ namespace MobileGL::MG_Backend::DirectGLES {
return result;
}
std::optional<String> ExtractPerVertexBlockMembers(const String& essl, const Bool input) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
// Deliberately a scan for the DECLARATION rather than a regex over the whole text:
// "gl_PerVertex" also appears inside the block's own body in some emissions, and the
// direction keyword has to be the one immediately preceding the name for the match to
// mean what this needs it to mean.
const auto isIdentifierChar = [](char c) {
return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '_';
};
const String keyword = input ? String("in") : String("out");
SizeT pos = 0;
while ((pos = essl.find("gl_PerVertex", pos)) != String::npos) {
// Walk back over whitespace to the direction keyword.
SizeT before = pos;
while (before > 0 && std::isspace(static_cast<unsigned char>(essl[before - 1]))) --before;
const Bool matches = before >= keyword.size() &&
essl.compare(before - keyword.size(), keyword.size(), keyword) == 0 &&
(before == keyword.size() ||
!isIdentifierChar(essl[before - keyword.size() - 1]));
if (!matches) {
pos += 1;
continue;
}
const SizeT open = essl.find('{', pos);
if (open == String::npos) return std::nullopt;
const SizeT close = essl.find('}', open);
if (close == String::npos) return std::nullopt;
return essl.substr(open + 1, close - open - 1);
}
return std::nullopt;
}
String BuildPassthroughTessControlEssl(const Uint esslVersion, const Uint patchVertices,
const String& inPerVertexMembers,
const String& outPerVertexMembers) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
// Tessellation is core in ES 3.2 and reachable in 3.1 only through
// GL_EXT_tessellation_shader. The caller has already established that the driver runs
// the evaluation stage at all, so the only question here is which spelling to use.
const Bool core = esslVersion >= 320;
String source = "#version " + std::to_string(core ? 320u : 310u) + " es\n";
if (!core) {
source += "#extension GL_EXT_tessellation_shader : require\n";
}
source += "precision highp float;\n";
source += "precision highp int;\n";
source += "layout(vertices = " + std::to_string(patchVertices) + ") out;\n";
// Mirrored, never invented. An empty member list means the neighbouring stage did not
// redeclare the block either, and the driver's own built-in declaration is then what
// both sides agree on - redeclaring here would be the thing that broke the match.
if (!inPerVertexMembers.empty()) {
source += "in gl_PerVertex {" + inPerVertexMembers + "} gl_in[gl_MaxPatchVertices];\n";
}
if (!outPerVertexMembers.empty()) {
source += "out gl_PerVertex {" + outPerVertexMembers + "} gl_out[];\n";
}
source += "void main() {\n";
// Only gl_Position is forwarded. That is the whole of what the pass-through owes the
// evaluation stage: a program whose evaluation stage reads anything else per-vertex
// was declined before this was ever called (ModuleReadsLocatedInput), and gl_PointSize
// from a tessellation stage is a separate capability on both targets.
source += " gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;\n";
source += " gl_TessLevelOuter[0] = 1.0;\n";
source += " gl_TessLevelOuter[1] = 1.0;\n";
source += " gl_TessLevelOuter[2] = 1.0;\n";
source += " gl_TessLevelOuter[3] = 1.0;\n";
source += " gl_TessLevelInner[0] = 1.0;\n";
source += " gl_TessLevelInner[1] = 1.0;\n";
source += "}\n";
return source;
}
namespace {
Bool IsImagePassIdentifierChar(char c) {
return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
@@ -805,6 +987,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
struct ImageUniformDecl {
String name;
String aliasName; // the repair-tagged name the rewritten declaration takes; empty
// for a declaration this pass leaves alone
String writeName; // the writeonly half's name, when split
String layout; // raw contents of layout(...)
String qualifiers; // memory/precision qualifiers, normalized, no trailing space
@@ -812,19 +996,35 @@ namespace MobileGL::MG_Backend::DirectGLES {
String arraySuffix; // "" or "[7]"
SizeT declStart = 0;
SizeT declLength = 0;
SizeT nameStart = 0; // the name token alone, for a rename that edits nothing else
SizeT nameLength = 0;
SizeT referenceCount = 0; // uses this pass recognized and accounted for
Bool loaded = false;
Bool stored = false;
Bool unknownUse = false;
Bool split = false;
// SPIRV-Cross already tagged this one readonly or writeonly, so it needs no
// qualifier repair - only the rename that keeps two stages from merging it.
Bool preTaggedReadonly = false;
Bool preTaggedWriteonly = 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.
//
// `forceCoherent` is for the SPLIT pair only. GLSL guarantees that a write through
// one image variable is visible to a read through a DIFFERENT one only when both are
// declared coherent, and the split turns a same-variable read-after-write - which
// desktop GLSL orders by construction, so the source almost never says `coherent` -
// into exactly that cross-variable shape. Without it the driver may serve the load
// from a cache that never saw the store through the writeonly half.
String BuildImageDeclaration(const ImageUniformDecl& decl, const char* memoryQualifier,
const String& variableName) {
const String& variableName, Bool forceCoherent = false) {
String out = "layout(" + decl.layout + ") uniform ";
if (forceCoherent && !ContainsIdentifier(decl.qualifiers, "coherent")) {
out += "coherent ";
}
out += memoryQualifier;
out += ' ';
if (!decl.qualifiers.empty()) {
@@ -839,11 +1039,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
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;
// A name for a rewritten declaration that no identifier in the shader (and no other
// alias already minted for this stage) can collide with.
String MakeImageAliasName(const String& prefix, const String& name, const String& source,
const Vector<String>& taken) {
String candidate = 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;
@@ -866,12 +1066,265 @@ namespace MobileGL::MG_Backend::DirectGLES {
SizeT length;
String text;
};
// The offset just past the `;` that terminates the call whose argument list opens at
// `openParen`, or npos when what follows is not a plain statement. Parentheses alone
// are counted: every other bracket a GLSL argument list can contain is balanced
// inside them, and imageStore returns void, so a well-formed call site is always
// `imageStore(...);` and anything else is a shape this pass declines to edit.
SizeT FindEndOfCallStatement(const String& code, SizeT openParen) {
Int depth = 0;
SizeT scan = openParen;
for (; scan < code.size(); ++scan) {
if (code[scan] == '(') {
++depth;
} else if (code[scan] == ')' && --depth == 0) {
break;
}
}
if (scan >= code.size()) return String::npos;
const SizeT after = code.find_first_not_of(" \t\r\n", scan + 1);
if (after == String::npos || code[after] != ';') return String::npos;
return after + 1;
}
} // namespace
String SplitReadWriteImageUniforms(const String& glslCode) {
namespace {
// The digits of an array extent or of an element subscript, or -1 for "not a plain
// decimal literal".
//
// One trailing `u`/`U` is PART of the literal rather than grounds for rejection.
// SPIRV-Cross prints an index in the type SPIR-V gave it, and
// LegalizeResourceArrayIndexPass mints its per-element constants in the type of the
// index it replaced (ConstantLikeIndex reads that index's own type_id), so an image
// array reached through anything unsigned - `for (uint i = 0u; i < 4u; ++i)`, or any
// expression on gl_LocalInvocationIndex, which is uint by definition - arrives here
// spelled `g_image[0u]`. Reading that as "not a literal" declined the array and left
// it on one layout(binding = N), which hands its elements the consecutive units
// N, N+1, ... - exactly the silently-wrong-units defect the split exists to remove.
Int ParseNonNegativeIntLiteral(const String& text) {
if (text.empty()) return -1;
SizeT digitCount = text.size();
if (text[digitCount - 1] == 'u' || text[digitCount - 1] == 'U') --digitCount;
if (digitCount == 0) return -1;
Int value = 0;
for (SizeT i = 0; i < digitCount; ++i) {
const char c = text[i];
if (c < '0' || c > '9') return -1;
value = value * 10 + (c - '0');
if (value > 4096) return -1; // no image array is anywhere near this
}
return value;
}
} // namespace
String RemapImageArrayElementUnits(const String& glslCode, const Vector<ImageArrayUnitPlan>& plans,
Vector<String>* outDeclined) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (outDeclined != nullptr) outDeclined->clear();
if (plans.empty() || glslCode.find("image") == String::npos) return glslCode;
// Same declaration shape as the split pass reads, with the array extent captured.
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*([0-9]*)\s*\])?\s*;)");
static const std::regex bindingValueRegex(R"(binding\s*=\s*\d+)");
struct StageImageDecl {
String name;
String layout;
String qualifiers;
String type;
Int elementCount = 1;
SizeT declStart = 0;
SizeT declLength = 0;
};
// Every image declaration in the stage; the plans are program-wide and name arrays
// this stage may not declare at all.
Vector<StageImageDecl> decls;
for (std::sregex_iterator it(glslCode.begin(), glslCode.end(), imageDeclRegex), last; it != last; ++it) {
const std::smatch& match = *it;
StageImageDecl decl;
decl.layout = match[1].str();
decl.qualifiers = NormalizeDeclarationSpacing(match[2].str());
decl.type = match[3].str();
decl.name = match[4].str();
decl.elementCount = match[5].matched ? ParseNonNegativeIntLiteral(match[5].str()) : 1;
decl.declStart = static_cast<SizeT>(match.position(0));
decl.declLength = match[0].str().size();
decls.push_back(Move(decl));
}
Vector<ImageSourceEdit> edits;
Vector<String> takenNames;
for (const ImageArrayUnitPlan& plan : plans) {
const auto decline = [&](const char* why) {
if (outDeclined != nullptr) outDeclined->push_back(plan.name + ": " + why);
};
if (plan.units.size() < 2) continue;
const StageImageDecl* decl = nullptr;
for (const auto& candidate : decls) {
if (candidate.name == plan.name) {
decl = &candidate;
break;
}
}
if (decl == nullptr) {
// Absent from this stage entirely is the normal outcome - the reflection is
// program-wide and this pass runs per stage. Named but not RECOGNIZED is not:
// it means the declaration is spelled in some shape the regex above does not
// read, and staying quiet about that is how the wrong units got shipped.
if (ContainsIdentifier(glslCode, plan.name)) {
decline("the stage names it but declares it in a shape this pass cannot read");
}
continue;
}
if (decl->elementCount < 0 || static_cast<SizeT>(decl->elementCount) != plan.units.size()) {
decline("the emitted array extent disagrees with the reflected element count");
continue;
}
Bool consecutive = true;
Bool everyElementHasAUnit = true;
for (SizeT element = 0; element < plan.units.size(); ++element) {
const Int unit = plan.units[element];
if (unit < 0) {
everyElementHasAUnit = false;
break;
}
if (unit != plan.units[0] + static_cast<Int>(element)) consecutive = false;
}
if (!everyElementHasAUnit) {
decline("an element has no image unit");
continue;
}
// Already exactly what ESSL would do on its own. The caller filters these out;
// repeating the test here keeps the pass correct on its own terms.
if (consecutive) continue;
// Every use has to be `name[<literal>]`. The literal is what the split turns
// into a name, and by the time this runs there is always one:
// LegalizeResourceArrayIndexingForEssl has already folded or lowered every
// dynamic image-array subscript in the module, because ESSL forbids one
// outright ("image arrays indexed with non-constant expressions are forbidden
// in GLSL ES"). A subscript that is still an expression here is therefore a
// stage that was never going to compile, and guessing which element it meant
// would only change which unit it addressed wrongly.
struct ElementUse {
SizeT start; // the first character of the name
SizeT length; // through the closing ']'
SizeT element;
};
Vector<ElementUse> uses;
const char* refusal = nullptr;
for (SizeT pos = glslCode.find(plan.name); pos != String::npos;
pos = glslCode.find(plan.name, pos + 1)) {
if (pos > 0 && IsImagePassIdentifierChar(glslCode[pos - 1])) continue;
const SizeT after = pos + plan.name.size();
if (after < glslCode.size() && IsImagePassIdentifierChar(glslCode[after])) continue;
if (pos >= decl->declStart && pos < decl->declStart + decl->declLength) {
continue; // the declaration's own name
}
const SizeT open = glslCode.find_first_not_of(" \t\r\n", after);
if (open == String::npos || glslCode[open] != '[') {
refusal = "it is reached by something other than a subscript, so there is no "
"element index to rewrite";
break;
}
Int depth = 0;
SizeT scan = open;
for (; scan < glslCode.size(); ++scan) {
if (glslCode[scan] == '[') {
++depth;
} else if (glslCode[scan] == ']' && --depth == 0) {
break;
}
}
if (scan >= glslCode.size() || open + 1 >= scan) {
refusal = "it is reached by something other than a subscript, so there is no "
"element index to rewrite";
break;
}
const Int element = ParseNonNegativeIntLiteral(
NormalizeDeclarationSpacing(glslCode.substr(open + 1, scan - open - 1)));
if (element < 0 || element >= decl->elementCount) {
refusal = "its subscript is not a literal element index, so which unit the "
"access reaches cannot be decided here";
break;
}
uses.push_back({pos, scan + 1 - pos, static_cast<SizeT>(element)});
}
if (refusal != nullptr) {
decline(refusal);
continue;
}
// One SCALAR declaration per element, each carrying its own binding. ESSL nails
// an ARRAY's elements to consecutive units and offers no way to move them, so
// the only spelling that reaches an arbitrary set of units is one declaration
// per unit - and with every subscript a literal, every use has exactly one of
// them to be rewritten to.
//
// It costs precisely the image uniforms the application declared, which is why
// there is no budget test here: an array of four elements becomes four scalars
// however far apart their units are.
const SizeT elementCount = plan.units.size();
Vector<String> elementNames;
String replacement;
for (SizeT element = 0; element < elementCount; ++element) {
const String elementName =
MakeImageAliasName(IMAGE_ARRAY_ELEMENT_PREFIX,
plan.name + "_" + std::to_string(element), glslCode, takenNames);
takenNames.push_back(elementName);
elementNames.push_back(elementName);
String layout = decl->layout;
const String bindingText = "binding = " + std::to_string(plan.units[element]);
if (std::regex_search(layout, bindingValueRegex)) {
layout = std::regex_replace(layout, bindingValueRegex, bindingText);
} else {
layout = bindingText + (layout.empty() ? String() : ", " + layout);
}
if (element != 0) replacement += '\n';
replacement += "layout(" + layout + ") uniform ";
if (!decl->qualifiers.empty()) {
replacement += decl->qualifiers;
replacement += ' ';
}
replacement += decl->type + " " + elementName + ";";
}
edits.push_back({decl->declStart, decl->declLength, Move(replacement)});
// `name[k]` -> the scalar declared for element k, subscript and all.
for (const ElementUse& use : uses) {
edits.push_back({use.start, use.length, elementNames[use.element]});
}
}
if (edits.empty()) return glslCode;
// Back to front, so an earlier edit's offsets stay valid. No two edits overlap: each
// one covers either a whole declaration or a whole `name[k]`, the declaration's own
// name is skipped when the uses are collected, and one occurrence of a name yields at
// most one edit.
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;
}
String SplitReadWriteImageUniforms(const String& glslCode, Uint* outSplitCount) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
// Written before any early return, so the caller never reads a stale count.
if (outSplitCount != nullptr) *outSplitCount = 0;
if (glslCode.find("image") == String::npos) {
return glslCode;
}
@@ -890,10 +1343,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
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;
}
const Bool hasReadonly = ContainsIdentifier(qualifiers, "readonly");
const Bool hasWriteonly = ContainsIdentifier(qualifiers, "writeonly");
// Carrying BOTH is a spelling no per-stage access analysis produces (SPIRV-Cross
// clears one decoration or the other as soon as it sees a load or a store), so it
// came from the application and is identical in every stage. Nothing to do.
if (hasReadonly && hasWriteonly) continue;
Bool hasFormat = false;
Bool exemptFormat = false;
@@ -902,10 +1357,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
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;
// A declaration carrying neither qualifier is illegal ES unless its format is
// r32f/r32i/r32ui, and no format qualifier at all is a shape SPIRV-Cross refuses
// to emit for an ES target. Either way there is no repair to make - and no rename
// to make either, because a declaration with no access qualifier is spelled the
// same in every stage.
if (!hasReadonly && !hasWriteonly && (!hasFormat || exemptFormat)) continue;
ImageUniformDecl decl;
decl.layout = match[1].str();
@@ -915,6 +1372,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
decl.arraySuffix = NormalizeDeclarationSpacing(match[5].str());
decl.declStart = static_cast<SizeT>(match.position(0));
decl.declLength = match[0].str().size();
decl.nameStart = static_cast<SizeT>(match.position(4));
decl.nameLength = match[4].str().size();
decl.preTaggedReadonly = hasReadonly;
decl.preTaggedWriteonly = hasWriteonly;
decls.push_back(Move(decl));
}
if (decls.empty()) {
@@ -929,12 +1390,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
};
// Walk every `image*(` call and attribute its first argument to a declaration.
struct StoreSite {
// EVERY recognized use is recorded, not only the stores: a declaration this pass
// renames has to take all of its uses with it, and the "every occurrence was one I
// saw" check below is what makes the recorded set provably the complete set.
struct ImageUseSite {
SizeT declIndex;
SizeT start;
SizeT length;
SizeT callOpen; // the '(' of the call this argument belongs to
Bool stores; // an imageStore, i.e. the use a split redirects to the write half
};
Vector<StoreSite> storeSites;
Vector<ImageUseSite> useSites;
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;
@@ -979,12 +1445,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
switch (ClassifyImageBuiltin(builtin)) {
case ImageBuiltinAccess::Load:
decl.loaded = true;
useSites.push_back({declIndex, argStart, argEnd - argStart, openParen, false});
break;
case ImageBuiltinAccess::Store:
decl.stored = true;
storeSites.push_back({declIndex, argStart, argEnd - argStart});
useSites.push_back({declIndex, argStart, argEnd - argStart, openParen, true});
break;
case ImageBuiltinAccess::None:
// imageSize/imageSamples touch nothing, but they still NAME the variable, so
// a rename has to reach them.
useSites.push_back({declIndex, argStart, argEnd - argStart, openParen, false});
break;
default:
decl.unknownUse = true;
@@ -1001,30 +1471,122 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
Vector<ImageSourceEdit> edits;
Vector<String> takenAliases;
Vector<String> takenNames;
for (auto& decl : decls) {
if (decl.unknownUse) continue; // leave it exactly as it was; no guessing
// EVERY declaration this pass rewrites is also RENAMED, under the prefix of the
// repair it is about to receive - the qualifier below is a decision about ONE
// STAGE's accesses, and GLSL requires a uniform declared in two stages to be
// declared IDENTICALLY (GLSL 4.3 4.3.9 / GLSL ES 3.20 4.3.9). A shader that
// stores to an image in the vertex stage and loads it in the fragment stage gets
// `writeonly` on one and `readonly` on the other, and on Adreno the linker merges
// the two same-named declarations and SILENTLY DISCARDS the vertex-stage stores:
// no GL error, no link log, LINK_STATUS = 1, and the image still holding its
// initial contents afterwards
// (KHR-GL4x.shader_image_load_store.advanced-memory-dependentInvocation, and any
// shader pack that writes an image in one stage to read it in another).
//
// Keyed on the REPAIR and not on the stage, which is what makes the rename
// exactly as wide as the problem. Two stages that use the image the same way
// reach the same prefix and emit byte-identical declarations, so they keep ONE
// shared uniform and there is nothing mismatched to merge; two that use it
// differently reach different prefixes and cannot be merged at all. Tagging by
// stage instead also broke the merge - but it broke it for the agreeing stages
// too, turning one image uniform into one PER STAGE that names it, and Adreno
// allocates image locations per distinct uniform: the five stages of
// KHR-GL43.shading_language_420pack.binding_images_texture_type_* went from 6
// image uniforms to 30 and the link failed outright with "Error: Image Image
// location or component exceeds max allowed." on an Adreno 830, where Mali and
// Mesa both accept the same text.
//
// Nothing downstream reads these names: the two passes that key on the GL uniform
// name (RebindImageUniformsToFrontendUnits, BakeImageFormatQualifiers) both run
// BEFORE this one, RemoveLayoutBinding recognises an image declaration by its TYPE
// token, and CacheResourceLocations skips image uniforms outright because ES image
// units come only from layout(binding=N). The declarations this pass LEAVES ALONE -
// already readonly/writeonly in the source, or r32f/r32i/r32ui, which need no
// qualifier - keep their names, and they are exactly the ones that already match
// across stages.
if (decl.preTaggedReadonly || decl.preTaggedWriteonly) {
// No repair: SPIRV-Cross already emitted a legal qualifier. But it derived
// that qualifier from THIS STAGE's accesses, so a uniform stored in one stage
// and loaded in another arrives here `writeonly` in one and `readonly` in the
// other under ONE name - precisely the same-name/mismatched-qualifier pair
// Adreno merges while silently discarding the writing stage's stores
// (advanced-memory-dependentInvocation; a raw-ES probe reproduces it with no
// MobileGL in the process, and renaming either half fixes it). Keyed on the
// qualifier for the same reason the repair below is: two stages that agree
// spell the same alias and stay merged, so no shader gains an image uniform.
const char* preTagPrefix =
decl.preTaggedReadonly ? IMAGE_READONLY_ALIAS_PREFIX : IMAGE_WRITEONLY_ALIAS_PREFIX;
decl.aliasName = MakeImageAliasName(preTagPrefix, decl.name, glslCode, takenNames);
takenNames.push_back(decl.aliasName);
// The name token alone: the qualifiers are already right, and re-emitting the
// whole declaration would only risk changing them.
edits.push_back({decl.nameStart, decl.nameLength, decl.aliasName});
continue;
}
const char* aliasPrefix = decl.loaded && decl.stored ? IMAGE_SPLIT_READ_ALIAS_PREFIX
: decl.stored ? IMAGE_WRITEONLY_ALIAS_PREFIX
: IMAGE_READONLY_ALIAS_PREFIX;
decl.aliasName = MakeImageAliasName(aliasPrefix, decl.name, glslCode, takenNames);
takenNames.push_back(decl.aliasName);
if (decl.loaded && decl.stored) {
decl.writeName = MakeImageWriteAliasName(decl.name, glslCode, takenAliases);
takenAliases.push_back(decl.writeName);
// Minted from the ALREADY access-tagged name, so the write half of a split
// can never collide with the single declaration another stage's repair mints
// for the same image.
decl.writeName =
MakeImageAliasName(IMAGE_WRITE_ALIAS_PREFIX, decl.aliasName, glslCode, takenNames);
takenNames.push_back(decl.writeName);
decl.split = true;
if (outSplitCount != nullptr) ++*outSplitCount;
// Both halves carry `coherent`; see BuildImageDeclaration. The
// single-declaration cases below stay as they were - nothing aliases them, so
// there is no visibility to restore and no reason to pay for the cache
// behaviour.
edits.push_back({decl.declStart, decl.declLength,
BuildImageDeclaration(decl, "readonly", decl.name) + "\n" +
BuildImageDeclaration(decl, "writeonly", decl.writeName)});
BuildImageDeclaration(decl, "readonly", decl.aliasName,
/*forceCoherent=*/true) +
"\n" +
BuildImageDeclaration(decl, "writeonly", decl.writeName,
/*forceCoherent=*/true)});
} else if (decl.stored) {
edits.push_back({decl.declStart, decl.declLength,
BuildImageDeclaration(decl, "writeonly", decl.name)});
BuildImageDeclaration(decl, "writeonly", decl.aliasName)});
} 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)});
BuildImageDeclaration(decl, "readonly", decl.aliasName)});
}
}
for (const StoreSite& site : storeSites) {
for (const ImageUseSite& site : useSites) {
const ImageUniformDecl& decl = decls[site.declIndex];
if (!decl.split) continue;
edits.push_back({site.start, site.length, decl.writeName});
// Empty exactly when the declaration was poisoned above and left untouched; its
// uses must keep naming the variable that is still called that.
if (decl.aliasName.empty()) continue;
edits.push_back(
{site.start, site.length, decl.split && site.stores ? decl.writeName : decl.aliasName});
if (!decl.split || !site.stores) continue;
// ...and an explicit barrier behind it. `coherent` on both halves is what makes
// the store VISIBLE to a load through the other variable, but it says nothing
// about ORDER within one invocation - and the whole reason a declaration is split
// is that the shader both stores and loads through it, which on the ES side is now
// a write to one variable followed by a read of another the compiler has no reason
// to believe alias. Adreno duly serves the load from before the store
// (KHR-GL4x.shader_image_load_store.advanced-memory-order's store/load/compare
// loop reads back the previous iteration's value). memoryBarrierImage() is the
// GLSL primitive for exactly that ordering, is core GLSL ES 3.10 in every stage,
// and is not an execution barrier, so it is legal in non-uniform control flow too.
//
// Confined to the split pair: a single-declaration repair has nothing aliasing it
// and must not pay for this, and a shader that never got split never sees it at
// all.
const SizeT statementEnd = FindEndOfCallStatement(glslCode, site.callOpen);
if (statementEnd != String::npos) {
edits.push_back({statementEnd, 0, " memoryBarrierImage();"});
}
}
if (edits.empty()) {
return glslCode;
+289 -11
View File
@@ -60,6 +60,115 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target);
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat);
Bool ShouldUseCaveatRenderbufferFormat(TextureInternalFormat internalFormat);
// The CHANNEL WIDENING an image-bindable texture's ES storage takes, so that a format
// GLSL ES cannot spell as an image is carried by one it can.
//
// GL has forty image formats, GLSL ES core has thirteen, and no test device advertises
// GL_NV_image_formats - so a shader declaring one of the other twenty-six has no legal
// ESSL at all and glBindImageTexture rejects the narrow format outright for most of them
// (GL_INVALID_VALUE for nineteen of twenty-six on Adreno, twenty-five on both Malis).
// Seventeen have a core format of the SAME per-channel width and component type,
// differing only in channel count, and in one of those the emulation is EXACT: GL already
// defines an imageLoad from a narrower format as (r, 0, 0, 1) and an imageStore as
// dropping the components the format does not have, so the carrier's surplus channels
// hold values GL has already named. WidenImageFormatsPass pins them in the shader; this
// is the storage half, and DirectGLES::TextureImpl::SyncImageTextureBinding the bind
// half. All three ask WidenedCoreEsslImageFormat, so they cannot pick different carriers.
//
// Reports nothing (InternalFormat == GL_UNKNOWN_MGL) for a format that is core already,
// for the nine with no exact carrier (r11f_g11f_b10f, rgb10_a2, rgb10_a2ui, rgba16, rg16,
// r16, rgba16_snorm, rg16_snorm, r16_snorm - those keep the honest "no GLSL ES spelling"
// diagnostic rather than a silent approximation), and on a driver that HAS
// GL_NV_image_formats, where the shader keeps the declared format and no widening may
// happen behind it.
//
// The widened triple REPLACES what GenerateTextureFormatInfo chose, including any
// renderability substitution: an image that cannot be image-bound is useless whatever its
// attachment behaviour, so the image constraint wins. In practice that only bites
// RG8_SNORM/R8_SNORM on a driver without EXT_render_snorm, where the storage stays
// signed-normalized instead of becoming the half float that fallback would have picked -
// so an image-bound texture in one of those two formats is no longer attachable, and
// glGetTexImage on it falls through to the CPU shadow, which a shader-side imageStore
// does not update. Accepted deliberately: before the widening, an image binding in either
// format was refused outright by every driver tested and the stage that declared it never
// compiled at all, so nothing that works today is being given up.
//
// KNOWN GAP, for the same "all three layers move together" reason: a widened texture that
// is ALSO an FBO colour attachment gains one to three writable channels, and a draw into
// it can leave values in channels GL says are 0 and 1. Sampling and imageLoad are covered
// (the swizzle composition in SyncTextureParamsToBackend and the shader-side mask), but a
// glReadPixels/glGetTexImage that asks for more channels than the frontend format has
// would see them. Closing it needs the per-draw-buffer colour mask the three-channel
// widening already carries (FramebufferImpl::g_alphaWidenedDrawBufferMask) generalized
// from "alpha" to a channel count, which is its own change.
// How the FRONTEND's CPU shadow for a widened format is laid out relative to the carrier's
// transfer, i.e. what the upload has to do to it. Almost every entry is `Components`: the
// shadow already holds SourceChannels components of exactly the carrier's own type, so
// padding it out to four is the whole conversion. The packed entries do not - their shadow
// is ONE 32-bit word per texel - and reading such a word as components of the carrier's
// type takes twelve or sixteen bytes out of four and shears the level.
enum class ImageWidenSourceEncoding : Uint8 {
Components = 0,
// r11f_g11f_b10f: GL_UNSIGNED_INT_10F_11F_11F_REV -> four GL_FLOATs of an rgba16f.
PackedFloat11f11f10f,
// rgb10_a2 and rgb10_a2ui: GL_UNSIGNED_INT_2_10_10_10_REV -> four GL_UNSIGNED_SHORT
// channel CODES of an rgba16ui. The same split serves both: the two formats differ
// only in what the codes MEAN, which is the shader's business and not the transfer's.
PackedInt2101010Rev,
};
struct ImageBindableStorageWidening {
GLenum InternalFormat = GL_UNKNOWN_MGL;
GLenum Format = GL_UNKNOWN_MGL;
GLenum Type = GL_UNKNOWN_MGL;
// Channels the FRONTEND format has, i.e. how many of the carrier's four the client
// data fills. The rest are uploaded as 0, and the fourth as the format's implied 1.
Uint SourceChannels = 0;
// Whether that implied 1 is the integer one or a saturated normalized field - the
// transfer type cannot tell the two apart (GL_UNSIGNED_BYTE serves both RG8 and
// RG8UI), so the carrier decides.
Bool IntegerData = false;
// What the upload has to do to the frontend shadow before it describes the level to
// the driver (PrepareImageWidenedUpload).
ImageWidenSourceEncoding SourceEncoding = ImageWidenSourceEncoding::Components;
// Non-zero when the carrier holds this format's channels as the INTEGER CODES of a
// NORMALIZED value - the seven 16-bit and 10-bit normalized formats, which core ESSL
// has no image format of any width for and which a float carrier would requantise.
// Each entry is the largest code that channel can hold, i.e. the denominator of GL 4.6
// 2.3.5; SignedNormalized picks which of the two conversions it is the denominator of.
//
// Two things depend on it, both because the ES storage no longer shares the frontend
// format's component class: the upload pads a missing alpha with ChannelMax[3] instead
// of the transfer type's own "one" (through a uint carrier the saturated field IS the
// one), and glGetTexImage divides the codes back out into the floats the application
// is still owed.
Uint ChannelMax[4] = {0u, 0u, 0u, 0u};
Bool SignedNormalized = false;
Bool CarriesNormalizedCodes() const { return ChannelMax[0] != 0u; }
explicit operator Bool() const { return InternalFormat != GL_UNKNOWN_MGL; }
};
ImageBindableStorageWidening GetImageBindableStorageWidening(TextureInternalFormat internalFormat);
// The single-channel core format an image-bindable BUFFER texture's view is SPLIT into, or
// GL_UNKNOWN_MGL for a format that needs no split (or has no core base).
//
// A buffer texture cannot be widened: its texels are the application's buffer object, at
// the size and layout the application gave it, and it is usually also a vertex, index or
// storage buffer whose bytes are not ours to restride. But an rg32f view of N texels and
// an r32f view of 2N texels describe exactly the SAME bytes, so the split changes only
// how the shader subscripts them - component j of texel i is texel 2i + j of the base
// view - which WidenImageFormatsPass rewrites every access to do. The same rule as the
// widening decides WHETHER: a driver that can spell rg32f for an imageBuffer needs
// nothing.
//
// KNOWN GAP, and the reason this is not applied to a texture that is merely sampled: a
// buffer texture that is BOTH image-bound and read through a samplerBuffer would have its
// sampled view split too, and the sampler side is not rewritten. Accepted for the same
// reason the storage widening's gaps are - on a driver where the split applies at all
// there is no legal ESSL for the image declaration, so such a program did not compile.
GLenum GetImageBindableBufferSplitFormat(TextureInternalFormat internalFormat);
} // namespace TextureImpl
namespace FramebufferImpl {} // namespace FramebufferImpl
@@ -178,9 +287,113 @@ namespace MobileGL::MG_Backend::DirectGLES {
// stops being safe to edit by hand.
String BakeImageFormatQualifiers(String glslCode, const UnorderedMap<String, String>& esslFormatByUniformName);
String RemoveLayoutBinding(const String& glslCode);
// Prefix of the per-element scalar declarations RemapImageArrayElementUnits splits an
// image array into; the suffix is the array's own name and the element's index.
constexpr const char* IMAGE_ARRAY_ELEMENT_PREFIX = "mg_imageElem_";
// One image ARRAY whose elements the application pointed at units that are not
// consecutive-from-element-zero.
struct ImageArrayUnitPlan {
String name; // the array's name, exactly as the emitted ESSL declares it
Vector<Int> units; // the frontend image unit element k has to reach
};
// Desktop GL lets an application give each element of an image array an ARBITRARY unit
// (glUniform1i per element). ES has no such call at all - "ES image units come
// exclusively from the layout(binding=N) qualifier" - and one declaration carries one
// binding, so ESSL nails an array's elements to the CONSECUTIVE units N, N+1, N+2, ...
// MobileGL used to stamp element [0]'s unit as the binding and let the rest fall where
// they fell: KHR-GL4x.shader_image_load_store.advanced-sso-simple assigns 0,2,4,6 and
// 1,3,5,7, so its two programs actually addressed 0,1,2,3 and 1,2,3,4 - one layer got the
// wrong value and three were never written, with no GL error and no link log. The same
// defect for SAMPLER arrays was fixed API-side (SubscriptUniformNameForElement); an image
// array has no API side to fix, because ES makes glUniform1i on an image uniform an
// INVALID_OPERATION.
//
// Repaired by SPLITTING the array into one SCALAR image uniform per element, each with
// its own layout(binding = N), and rewriting `name[k]` to the scalar declared for
// element k. One declaration carries one binding, so one declaration per unit is the
// only spelling that reaches an arbitrary set of them.
//
// That rewrite needs every k in the emitted text to be a LITERAL, and it is:
// LegalizeResourceArrayIndexingForEssl has already folded or lowered every dynamic
// image-array subscript in the module, because ESSL forbids one outright ("image arrays
// indexed with non-constant expressions are forbidden in GLSL ES", Mesa 26.1.4 at
// ES 3.2, on a raw GLES probe with no MobileGL in the loop). The earlier shape here -
// widening the array to cover the whole span of units and routing each subscript through
// a `const highp int` offset table - was written before that pass covered images, and
// the table lookup was itself one of the non-constant expressions the same probe refuses.
// The split also costs exactly the image uniforms the application declared, where the
// widening cost the whole SPAN (seven for the four elements of
// KHR-GL42.shader_image_load_store.advanced-sso-simple), so there is no budget for it to
// fail to fit in.
//
// Declines - leaving the array exactly as it was, and naming it in `outDeclined` for the
// caller to report - when the emitted extent disagrees with the reflection, when the
// array is reached by anything other than a subscript, or when a subscript is not a
// literal element index. Silence was the whole defect here, so a decline must be audible.
//
// Must run AFTER RebindImageUniformsToFrontendUnits and BakeImageFormatQualifiers (both
// key on the GL uniform name and on a binding already being stamped) and BEFORE
// SplitReadWriteImageUniforms (so each element that is both read and written is split
// with its own binding already on it) and RemoveLayoutBinding (which is what preserves
// image bindings). Like them, it is downstream of the L2 shader-translation memo, so the
// per-program units it reads need no entry in BuildEsslTranslationKey.
String RemapImageArrayElementUnits(const String& glslCode, const Vector<ImageArrayUnitPlan>& plans,
Vector<String>* outDeclined = nullptr);
// The member list of a `gl_PerVertex { ... }` redeclaration in already-emitted ESSL -
// the text between the braces, verbatim - or nullopt when the shader does not redeclare
// the block in that direction. `input` selects the `in gl_PerVertex` form over the
// `out` one.
//
// Exists so BuildPassthroughTessControlEssl can MIRROR the stages it has to sit between
// rather than guess at them. Whether SPIRV-Cross redeclares the built-in block, and with
// which members, depends on what the application's shader touched; a synthesized stage
// that redeclares a different shape than its neighbours is an ES link error against a
// program that has no other problem.
std::optional<String> ExtractPerVertexBlockMembers(const String& essl, Bool input);
// The pass-through tessellation control stage GL 4.6 core 11.2.2 describes: "the input
// patch is passed through unmodified", the output patch has PATCH_VERTICES vertices, and
// the levels come from the PATCH_DEFAULT_OUTER_LEVEL / PATCH_DEFAULT_INNER_LEVEL state.
//
// Desktop GL makes the control stage OPTIONAL. OpenGL ES 3.2 does not: it has no
// PATCH_DEFAULT_*_LEVEL state at all (only glPatchParameteri, for PATCH_VERTICES) and
// rejects a program that has an evaluation stage without a control stage - with an EMPTY
// info log, verified on an Adreno 830 with no MobileGL in the process. MobileGL's own
// frontend link succeeds, so the program reports GL_LINK_STATUS = TRUE, program 0 is
// bound in its place, and every draw silently renders nothing.
//
// `inPerVertexMembers` / `outPerVertexMembers` are the member lists to redeclare gl_in
// and gl_out with - normally taken from the neighbouring stages' own emitted ESSL via
// ExtractPerVertexBlockMembers, and empty to leave the driver's built-in declaration
// alone, which is what matching a neighbour that did not redeclare requires.
//
// All four outer levels and both inner levels are written unconditionally: writing a
// level the evaluation stage's domain does not use is legal and ignored, and it saves
// this from having to know the domain. They are literal 1.0 because that is the GL
// default and glPatchParameterfv - their only setter - is a stub in this frontend
// (MG_Impl/GLImpl/Exporting/Definitions.cpp). Implementing that entry point means making
// the levels a parameter here AND part of what makes a built program stale, exactly as
// PATCH_VERTICES already is; the two must move together, so they are named together.
//
// The same stage, for the same reason, that DirectVulkan synthesizes in
// ProgramFactory::BuildPassthroughTessControlSource - Vulkan likewise requires both
// tessellation stages. Kept as two generators rather than one because the two targets
// disagree on everything but the algorithm: desktop GLSL 450 against ESSL, a fixed
// gl_PerVertex shape that Vulkan matches structurally against a mirrored one, and a
// VkShaderModule against a driver shader object.
String BuildPassthroughTessControlEssl(Uint esslVersion, Uint patchVertices,
const String& inPerVertexMembers,
const String& outPerVertexMembers);
// Prefix of the writeonly half a read+write image uniform is split into (see
// SplitReadWriteImageUniforms); the suffix is the image's own name.
// SplitReadWriteImageUniforms); the suffix is the image's own (already access-tagged) name.
constexpr const char* IMAGE_WRITE_ALIAS_PREFIX = "mg_imageWrite_";
// The three names SplitReadWriteImageUniforms renames a rewritten image declaration
// under, one per REPAIR it can apply. Which one a stage picks is decided by that stage's
// own accesses, so two stages that use an image the same way arrive at the SAME name and
// two that use it differently arrive at different ones - which is exactly the property
// the rename exists for, at no cost to the stages that agree. Exposed for the tests.
constexpr const char* IMAGE_READONLY_ALIAS_PREFIX = "mg_imageRo_";
constexpr const char* IMAGE_WRITEONLY_ALIAS_PREFIX = "mg_imageWo_";
constexpr const char* IMAGE_SPLIT_READ_ALIAS_PREFIX = "mg_imageRw_";
// 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).
@@ -192,15 +405,74 @@ namespace MobileGL::MG_Backend::DirectGLES {
// 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`
// Restores a legal declaration, and RENAMES it after the repair it applied while doing so:
// * loaded only -> add `readonly`, rename under IMAGE_READONLY_ALIAS_PREFIX
// * stored only -> add `writeonly`, rename under IMAGE_WRITEONLY_ALIAS_PREFIX
// * 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.
// same type, `coherent readonly
// <IMAGE_SPLIT_READ_ALIAS_PREFIX><name>` and `coherent
// writeonly <IMAGE_WRITE_ALIAS_PREFIX><that name>`, point
// every imageStore at the second one, and follow each of
// those stores with `memoryBarrierImage();`. 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.
//
// The rename is the other half of the repair and applies to all three cases. The qualifier
// chosen above is a decision about ONE STAGE's accesses, and GLSL requires a uniform
// declared in two stages to be declared identically - so a shader that stores an image from
// the vertex stage and loads it from the fragment stage came out of here `writeonly` in one
// and `readonly` in the other. Adreno merges the two same-named declarations and silently
// drops the vertex-stage STORES: no GL error, no link log, LINK_STATUS = 1, and the image
// still reads back its initial contents
// (KHR-GL4x.shader_image_load_store.advanced-memory-dependentInvocation; a raw-ES probe
// isolated the trigger to the same-name/mismatched-qualifier pair, and only when both
// carry `coherent`). Renaming leaves no cross-stage variable to merge.
//
// The name is keyed on the REPAIR, not on the stage, and that distinction is the whole
// point: two stages that use an image the same way emit byte-identical declarations, so
// letting them keep one shared name costs nothing and merging them is correct, while two
// stages that use it differently land on different prefixes and cannot be merged at all.
// A per-STAGE tag also satisfied the first requirement but violated the second: it made
// the SAME image a distinct uniform in every stage that named it, and Adreno allocates
// image LOCATIONS per distinct uniform. KHR-GL43.shading_language_420pack.
// binding_images_texture_type_* declares three read+write images in each of its five
// stages; merged that is 6 image uniforms, per-stage-tagged it is 30, and the Adreno 830
// linker answered "Error: Image Image location or component exceeds max allowed. Error:
// Linking failed." - which, the frontend having already published LINK_STATUS = TRUE from
// glslang's link, surfaced only as every draw silently doing nothing and the images
// reading back zero. Mali and Mesa link the same text, so nothing but a device gate
// catches this.
//
// A declaration SPIRV-Cross already tagged `readonly` or `writeonly` needs no qualifier
// repair, but it is NOT stage-independent: that tag is derived from the accesses of the
// stage being emitted, so an image stored in the vertex stage and loaded in the fragment
// stage arrives here as `coherent writeonly g_image` and `coherent readonly g_image` -
// one name, two spellings, which is exactly the pair Adreno merges. Those declarations
// are therefore renamed too, keyed on the qualifier they already carry (readonly ->
// IMAGE_READONLY_ALIAS_PREFIX, writeonly -> IMAGE_WRITEONLY_ALIAS_PREFIX) and with
// nothing but the identifier changed. Stages that agree still reach the same alias and
// stay merged, so this costs no shader an extra image uniform.
//
// The declarations this pass still leaves untouched keep their names: one carrying BOTH
// readonly and writeonly (a spelling no access analysis produces, so it came from the
// application and is identical everywhere), and one carrying NEITHER, which is legal only
// for the r32f/r32i/r32ui formats and is likewise spelled the same in every stage.
//
// The `coherent` on both halves of the pair is load-bearing, not decoration: GLSL only
// guarantees a write through one image variable is visible to a read through a DIFFERENT
// one when both are coherent, and the split is what makes a same-variable
// read-after-write cross-variable. The single-declaration repairs above do not get it -
// nothing aliases them.
//
// The barrier is the other half of the same problem, and coherent alone did not cover it:
// visibility is not ORDER. Within one invocation the ES compiler sees a write to one
// variable and a read of another it has no reason to believe alias, and is free to serve
// the read from before the write - which is what advanced-memory-order's store/load/
// compare loop measured on Adreno. memoryBarrierImage() orders exactly those two, is core
// GLSL ES 3.10 in every stage, and is not an execution barrier, so it is legal in
// non-uniform control flow. It costs something in a shader that stores to a read+write
// image in a loop, which is why it is confined to the split pair.
//
// 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
@@ -210,8 +482,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
//
// 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);
// site in Managers.cpp. Its output is a function of the emitted text alone - it needs no
// stage and no per-program state - so it adds nothing to BuildEsslTranslationKey either.
//
// `outSplitCount`, when given, receives the number of declarations that were actually
// doubled - i.e. exactly how many image uniforms this stage gained over what the
// application declared. Zero for every shader but a handful, and the only number the
// budget note above can be reported with.
String SplitReadWriteImageUniforms(const String& glslCode, Uint* outSplitCount = nullptr);
// Prefix of the per-sampler float uniform that carries GL_TEXTURE_LOD_BIAS into
// the shader (see EmulateTextureLodBias); the suffix is the sampler's own name.
constexpr const char* LOD_BIAS_UNIFORM_PREFIX = "mg_lodBias_";
@@ -562,12 +562,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (MG_Util::Async::AsyncShaderCompileEnabled()) {
extensions.push_back(E_GL_KHR_parallel_shader_compile);
}
// GL_ARB_gpu_shader_fp64 is opt-in (MOBILEGL_ADVERTISE_FP64). Every `double` in a
// shader compiles and runs already - it is narrowed to 32 bits before the module
// reaches this backend - so an application that simply uses doubles needs nothing
// advertised. What the extension additionally promises is 64-bit PRECISION, which no
// mobile GPU has and the narrowing cannot fake, so advertising it by default would
// make an application that checks the string take a path MobileGL cannot honour.
// GL_ARB_gpu_shader_fp64 is opt-in (MOBILEGL_ADVERTISE_FP64), and stays opt-in even on a
// device that HAS shaderFloat64. Every `double` in a shader compiles and runs either way
// - narrowed to 32 bits where the device has no 64-bit floats, kept whole where it does -
// so an application that simply uses doubles needs nothing advertised. What the extension
// additionally promises is the whole GL_ARB_gpu_shader_fp64 SURFACE (glUniform*d
// conformance, the fp64 built-ins, the state queries), and turning the string on is a
// decision about all of it rather than about the shader path alone.
if (MG_Config::Features.AdvertiseFp64) {
extensions.push_back(E_GL_ARB_gpu_shader_fp64);
}
@@ -905,8 +906,22 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const Int maxSupportedDrawBuffers = static_cast<Int>(MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS);
m_dynamicParameters.MaxDrawBuffers = std::min(m_vulkanCaps.MaxDrawBuffers, maxSupportedDrawBuffers);
m_dynamicParameters.MaxColorAttachments = std::min(m_vulkanCaps.MaxColorAttachments, maxSupportedDrawBuffers);
m_dynamicParameters.MaxClipDistances = m_vulkanCaps.MaxClipDistances;
// Same shape as the image-uniform limits three lines above: maxClipDistances is reported
// by every device, but declaring ClipDistance in a module needs the shaderClipDistance
// FEATURE, which VulkanRenderer enables exactly where the physical device has it. Without
// it the limit describes a capacity no shader may use, so report none.
m_dynamicParameters.MaxClipDistances =
m_vulkanCaps.SupportsShaderClipDistance ? std::max(m_vulkanCaps.MaxClipDistances, 0) : 0;
m_dynamicParameters.MaxViewports = m_vulkanCaps.MaxViewports;
// Assigned explicitly rather than left to the struct's defaults, like every other
// parameter here, so a second fill cannot inherit a stale value. GL_UNDEFINED_VERTEX is
// the truthful answer for DirectVulkan and a legal one (GL 4.6 table 23.65): which vertex
// provokes is chosen per pipeline by VulkanRenderer::SelectProvokingVertexMode out of
// VK_EXT_provoking_vertex, provokingVertexModePerPipeline and the topology, so there is no
// one convention to name. Vulkan's own default is FIRST, which is the opposite of the
// GL_LAST_VERTEX_CONVENTION this used to claim unconditionally.
m_dynamicParameters.LayerProvokingVertex = GL_UNDEFINED_VERTEX;
m_dynamicParameters.ViewportIndexProvokingVertex = GL_UNDEFINED_VERTEX;
m_dynamicParameters.MaxViewportWidth = m_vulkanCaps.MaxViewportWidth;
m_dynamicParameters.MaxViewportHeight = m_vulkanCaps.MaxViewportHeight;
m_dynamicParameters.ViewportBoundsRangeMin = m_vulkanCaps.ViewportBoundsRangeMin;
@@ -951,26 +966,34 @@ namespace MobileGL::MG_Backend::DirectVulkan {
DynParams::PerLayerFramebufferAttachmentBit(TextureTarget::TextureCubeMapArray);
}
}
// Never, on any device, and no longer for the reason it used to be. It used to track
// shaderFloat64 because a `dvec3` input needed the Float64 capability to exist in the
// module at all; a 64-bit vertex FETCH was already impossible (VK_FORMAT_R64*_SFLOAT is
// optional and lavapipe reports zero bufferFeatures for all four), so the attribute
// arrived as its 32-bit word pair and PackDoubleVertexInputsPass bitcast it back.
// The device feature the whole fp64 story hangs off. With it, a module keeps its
// OpCapability Float64 and real doubles reach the driver; without it the transpile
// narrows every 64-bit float to 32 (ShaderTranspiler::DemoteFloat64Pass), because
// VUID-VkShaderModuleCreateInfo-pCode-08740 forbids the capability outright and no
// pipeline could be built from such a module. lavapipe reports it; Adreno and Mali both
// report VK_FALSE, so on every real mobile device this is false and the demotion runs
// exactly as it always has.
m_dynamicParameters.SupportsShaderFloat64 = m_vulkanCaps.SupportsShaderFloat64;
// Never, on any device, and DELIBERATELY NOT COUPLED to the line above even though it
// once tracked the same feature. It used to, because a `dvec` input needed Float64 to
// exist in the module at all; a 64-bit vertex FETCH was already impossible
// (VK_FORMAT_R64*_SFLOAT is optional and lavapipe reports zero bufferFeatures for all
// four), so the attribute arrived as its 32-bit word pair and PackDoubleVertexInputsPass
// bitcast it back.
//
// The shader half of that is gone: every 64-bit float is narrowed before any module
// reaches a backend (ShaderTranspiler::DemoteFloat64Pass), so there is no `double` input
// left to bitcast INTO, and feeding a UINT-formatted attribute to what is now a `float`
// input would be silent garbage. Reconstructing the value would mean decoding the
// IEEE-754 double bit pattern in the shader - software fp64, which is precisely what the
// demotion exists to avoid - and on Espryt it would additionally need the ES driver to
// fetch 2N uint components where the application declared N doubles, which a dvec3 or
// dvec4 cannot even express within one attribute location.
// Re-coupling it does not work, and the reason is worth recording because it is not
// obvious: this flag decides the VkFormat from the VAO ATTRIBUTE alone, and the attribute
// does not know what the shader declared. glVertexAttribFormat(GL_DOUBLE) against a plain
// `in vec4` is not only legal but the common case
// (KHR-GL43.vertex_attrib_binding.basic-input-case4 does exactly that, and case5 adds
// normalized=GL_TRUE), and advanced-bindingUpdate feeds a dvec3 the same way - GL defines
// all of them as "doubles in memory, converted to float". Turning the flag on turns the
// narrowing OFF for every one of them and the attributes come back unfetched.
//
// So glVertexAttribLFormat / glVertexAttribLPointer are declined here exactly as they
// already were on Espryt and on every real mobile device (Adreno and Mali both report
// shaderFloat64 == VK_FALSE), and for the same visible reason. A `dvec3` INPUT still
// compiles and draws - it is a `vec3` after demotion - as long as the application feeds
// it with glVertexAttribPointer(GL_FLOAT) rather than 64-bit data.
// What keeps the two halves honest instead is a per-MODULE decision: a vertex module that
// declares a 64-bit float INPUT is demoted whole, even where the backend has native fp64,
// so `dvec` inputs are `vec` inputs on this backend exactly as they always were. See
// ShaderCompiler::SanitizeAndOptimizeBinary.
m_dynamicParameters.SupportsFloat64VertexAttributes = false;
m_dynamicParameters.MaxShaderStorageBlockSize =
std::min(m_vulkanCaps.MaxShaderStorageBlockSize, kMaxAdvertisedShaderStorageBlockSize);
@@ -83,7 +83,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool isMember = false;
};
ShaderStage PickClipFixupStage(const Vector<SharedPtr<ShaderObject>>& shaders);
ShaderStage PickClipFixupStage(const Vector<ShaderStage>& stages);
Bool IsVec4Float32(spvtools::opt::IRContext* context, Uint32 typeId, Uint32* outFloatTypeId) {
auto* vecInst = context->get_def_use_mgr()->GetDef(typeId);
@@ -614,15 +614,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
void ReflectStageInterface(ShaderStage targetStage,
Bool reflectInputs,
const Vector<SharedPtr<ShaderObject>>& shaders,
const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
StageInterfaceSummary& outSummary,
Uint programExternalIndex,
const char* stageLabel) {
outSummary.slotSignatures.fill(0);
for (SizeT moduleIndex = 0; moduleIndex < shaders.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (!shaders[moduleIndex] || shaders[moduleIndex]->GetShaderStage() != targetStage) {
for (SizeT moduleIndex = 0; moduleIndex < stages.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (stages[moduleIndex] != targetStage) {
continue;
}
@@ -690,11 +690,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
void ValidateRasterizationStageInterface(const Vector<SharedPtr<ShaderObject>>& shaders,
void ValidateRasterizationStageInterface(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
ProgramFactory::VkProgramObject& entry,
Uint programExternalIndex) {
const ShaderStage producerStage = PickClipFixupStage(shaders);
const ShaderStage producerStage = PickClipFixupStage(stages);
entry.rasterizationProducerStage = producerStage;
entry.producerOutputComponentCount = 0;
entry.fragmentInputComponentCount = 0;
@@ -703,8 +703,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
Bool hasFragmentStage = false;
for (const auto& shader : shaders) {
if (shader && shader->GetShaderStage() == ShaderStage::Fragment) {
for (const ShaderStage stage : stages) {
if (stage == ShaderStage::Fragment) {
hasFragmentStage = true;
break;
}
@@ -715,9 +715,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
StageInterfaceSummary producerOutputs{};
StageInterfaceSummary fragmentInputs{};
ReflectStageInterface(producerStage, false, shaders, spirv, producerOutputs, programExternalIndex,
ReflectStageInterface(producerStage, false, stages, spirv, producerOutputs, programExternalIndex,
"producer");
ReflectStageInterface(ShaderStage::Fragment, true, shaders, spirv, fragmentInputs, programExternalIndex,
ReflectStageInterface(ShaderStage::Fragment, true, stages, spirv, fragmentInputs, programExternalIndex,
"fragment");
entry.producerOutputComponentCount = CountOccupiedStageInterfaceSlots(producerOutputs);
entry.fragmentInputComponentCount = CountOccupiedStageInterfaceSlots(fragmentInputs);
@@ -1719,14 +1719,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return success;
}
ShaderStage PickClipFixupStage(const Vector<SharedPtr<ShaderObject>>& shaders) {
ShaderStage PickClipFixupStage(const Vector<ShaderStage>& stages) {
Bool hasGeometry = false;
Bool hasTessEval = false;
Bool hasVertex = false;
for (const auto& shader : shaders) {
if (!shader) continue;
const auto stage = shader->GetShaderStage();
for (const ShaderStage stage : stages) {
hasGeometry |= (stage == ShaderStage::Geometry);
hasTessEval |= (stage == ShaderStage::TessEval);
hasVertex |= (stage == ShaderStage::Vertex);
@@ -2094,7 +2092,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
case SpvImageFormatR11fG11fB10f: return VK_FORMAT_B10G11R11_UFLOAT_PACK32;
case SpvImageFormatR16f: return VK_FORMAT_R16_SFLOAT;
case SpvImageFormatRgba16: return VK_FORMAT_R16G16B16A16_UNORM;
case SpvImageFormatRgb10A2: return VK_FORMAT_A2R10G10B10_UNORM_PACK32;
// A2**B**10G10R10, matching MGToVk::ConvertTextureInternalFormatToVkFormat's RGB10A2.
// This value becomes the storage image VIEW's format while the image itself was created
// from the texture's internal format, so the two must name the same bit layout or the
// shader reads the texel through a different component order than the host wrote it.
// GL_RGB10_A2 with GL_UNSIGNED_INT_2_10_10_10_REV puts R in bits 0-9, G in 10-19, B in
// 20-29 and A in 30-31, which is Vulkan's A2B10G10R10; A2R10G10B10 transposes R and B.
// KHR-GL43.shader_image_load_store.basic-allFormats-store read back [2,1,0,3] for an
// rgb10_a2ui image stored as [0,1,2,3] while these two converters disagreed.
case SpvImageFormatRgb10A2: return VK_FORMAT_A2B10G10R10_UNORM_PACK32;
case SpvImageFormatRg16: return VK_FORMAT_R16G16_UNORM;
case SpvImageFormatRg8: return VK_FORMAT_R8G8_UNORM;
case SpvImageFormatR16: return VK_FORMAT_R16_UNORM;
@@ -2117,7 +2123,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
case SpvImageFormatRgba16ui: return VK_FORMAT_R16G16B16A16_UINT;
case SpvImageFormatRgba8ui: return VK_FORMAT_R8G8B8A8_UINT;
case SpvImageFormatR32ui: return VK_FORMAT_R32_UINT;
case SpvImageFormatRgb10a2ui: return VK_FORMAT_A2R10G10B10_UINT_PACK32;
case SpvImageFormatRgb10a2ui: return VK_FORMAT_A2B10G10R10_UINT_PACK32; // see Rgb10A2 above
case SpvImageFormatRg32ui: return VK_FORMAT_R32G32_UINT;
case SpvImageFormatRg16ui: return VK_FORMAT_R16G16_UINT;
case SpvImageFormatRg8ui: return VK_FORMAT_R8G8_UINT;
@@ -2308,15 +2314,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
void ProgramFactory::ReflectVertexInputs(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
void ProgramFactory::ReflectVertexInputs(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const {
entry.activeVertexInputLocationMask = 0;
entry.vertexInputTypes.fill(0);
entry.readsBaseVertexBuiltin = false;
for (SizeT moduleIndex = 0; moduleIndex < shaders.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (!shaders[moduleIndex] || shaders[moduleIndex]->GetShaderStage() != ShaderStage::Vertex) {
for (SizeT moduleIndex = 0; moduleIndex < stages.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (stages[moduleIndex] != ShaderStage::Vertex) {
continue;
}
@@ -2393,14 +2399,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// evaluation stages. Rather than guess which one is last, every non-fragment, non-compute
// module is asked - one writer anywhere means this program's draws need a multi-viewport
// pipeline, and a false positive costs only a wider viewportCount.
void ProgramFactory::ReflectViewportIndexUsage(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
void ProgramFactory::ReflectViewportIndexUsage(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const {
entry.writesViewportIndexBuiltin = false;
for (SizeT moduleIndex = 0; moduleIndex < shaders.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (!shaders[moduleIndex]) continue;
const ShaderStage stage = shaders[moduleIndex]->GetShaderStage();
for (SizeT moduleIndex = 0; moduleIndex < stages.size() && moduleIndex < spirv.size(); ++moduleIndex) {
const ShaderStage stage = stages[moduleIndex];
if (stage == ShaderStage::Fragment || stage == ShaderStage::Compute) continue;
const auto& module = spirv[moduleIndex];
@@ -2428,15 +2433,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
void ProgramFactory::ReflectFragmentOutputs(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
void ProgramFactory::ReflectFragmentOutputs(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const {
entry.activeFragmentOutputLocationMask = 0;
entry.fragmentOutputTypes.fill(0);
entry.fragmentReplacesDepth = false;
for (SizeT moduleIndex = 0; moduleIndex < shaders.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (!shaders[moduleIndex] || shaders[moduleIndex]->GetShaderStage() != ShaderStage::Fragment) {
for (SizeT moduleIndex = 0; moduleIndex < stages.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (stages[moduleIndex] != ShaderStage::Fragment) {
continue;
}
@@ -3142,7 +3147,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto& entry = m_cache[hash];
entry.hash = hash;
entry.lastUsedFrame = m_frameCounter;
auto& shaders = program.GetAttachedShaders();
// The EXECUTABLE's stage list, not GetAttachedShaders(): `spirv` is a link artifact with
// one module per linked stage, while the attach list is live and grows on
// glAttachShader, which GL 4.6 core 7.3 says does not reach the executable until the
// next link. Sizing this loop by the attach list therefore ran it past the end of both
// `spirv` and `moduleSpirvs` for any program attached to after it linked.
const Vector<ShaderStage> stages = program.GetLinkedShaderStages();
auto& spirv = program.GetGeneratedSpirv();
Vector<Vector<Uint>> moduleSpirvs(spirv.size());
const Bool enableSpirvValidation = program.GetSpirvValidationEnabled();
@@ -3150,14 +3160,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MG_Util::ShaderTranspiler::ShaderCompiler::PrepareSpirvValidation();
}
const ShaderStage fixupStage = PickClipFixupStage(shaders);
const ShaderStage fixupStage = PickClipFixupStage(stages);
for (SizeT i = 0; i < shaders.size(); ++i) {
// Both lists come from the same Link(), so they agree by construction; the min() is what
// makes that an assumption this loop does not have to bet the process on.
const SizeT moduleCount = std::min(stages.size(), spirv.size());
for (SizeT i = 0; i < moduleCount; ++i) {
auto& spv = spirv[i];
if (spv.empty()) continue;
// Apply position fixup if needed
if (fixupStage != ShaderStage::Unknown && shaders[i] && shaders[i]->GetShaderStage() == fixupStage) {
if (fixupStage != ShaderStage::Unknown && stages[i] == fixupStage) {
const Vector<Uint>* fixupInput = &spv;
Vector<Uint> xfbSpirv;
if ((flags & ProgramFactory::CompileOptionBit::XfbCapture) &&
@@ -3173,16 +3186,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
moduleSpirvs[i] = spv;
}
if ((flags & ProgramFactory::CompileOptionBit::ExplicitLod0Sampling) && shaders[i] &&
shaders[i]->GetShaderStage() == ShaderStage::Fragment) {
if ((flags & ProgramFactory::CompileOptionBit::ExplicitLod0Sampling) && stages[i] == ShaderStage::Fragment) {
Vector<Uint> explicitLodSpirv;
if (TransformSpirvForExplicitLod0Sampling(moduleSpirvs[i], explicitLodSpirv)) {
moduleSpirvs[i] = Move(explicitLodSpirv);
}
}
if ((flags & ProgramFactory::CompileOptionBit::FragCoordYFlip) && shaders[i] &&
shaders[i]->GetShaderStage() == ShaderStage::Fragment) {
if ((flags & ProgramFactory::CompileOptionBit::FragCoordYFlip) && stages[i] == ShaderStage::Fragment) {
Vector<Uint> fragCoordSpirv;
if (TransformSpirvForFragCoordYFlip(moduleSpirvs[i], fragCoordSpirv, m_defaultFramebufferHeight)) {
moduleSpirvs[i] = Move(fragCoordSpirv);
@@ -3193,7 +3204,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// operations execute natively; module repairs keep the GL contract intact
// around them. The opt-in emulation path replaces them only on devices with no
// subgroup support at all (MOBILEGL_MAGMA_EMULATE_SUBGROUP).
if (shaders[i] && shaders[i]->GetShaderStage() == ShaderStage::Compute) {
if (stages[i] == ShaderStage::Compute) {
// Program 203 broadcasts the first reduction through
// prefixSumCache[0], then lets the second reduction overwrite that
// scratch without first rendezvousing all readers. Patch that exact
@@ -3296,8 +3307,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// The unsupported-device counterpart of this rebase (warning when a shader reads
// the builtin but shaderDrawParameters is missing) rides along with
// ReflectVertexInputs, which already reflects this stage.
if (shaders[i] && shaders[i]->GetShaderStage() == ShaderStage::Vertex &&
m_shaderDrawParametersEnabled) {
if (stages[i] == ShaderStage::Vertex && m_shaderDrawParametersEnabled) {
Vector<Uint> rebasedSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::RebaseInstanceIndexForVulkan(moduleSpirvs[i],
rebasedSpirv, enableSpirvValidation)) {
@@ -3314,8 +3324,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// through CompileOptionBit::ZeroBaseVertex, so the indexed variant of the same
// program keeps the native builtin and stays correct for glDrawElementsBaseVertex
// and for the baseVertex word of an indexed indirect command.
if (shaders[i] && shaders[i]->GetShaderStage() == ShaderStage::Vertex &&
(flags & CompileOptionBit::ZeroBaseVertex)) {
if (stages[i] == ShaderStage::Vertex && (flags & CompileOptionBit::ZeroBaseVertex)) {
Vector<Uint> zeroedSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::ZeroBaseVertexForVulkan(moduleSpirvs[i],
zeroedSpirv, enableSpirvValidation)) {
@@ -3338,7 +3347,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// committed to R32G32{,B32A32}_UINT for the attribute, so a module still declaring
// `in double` would reconcile to Unknown and build a pipeline with a UINT format under a
// double input - garbage with no diagnostic anywhere.
if (shaders[i] && shaders[i]->GetShaderStage() == ShaderStage::Vertex) {
if (stages[i] == ShaderStage::Vertex) {
Vector<Uint> packedSpirv;
const Bool packOk = MG_Util::ShaderTranspiler::ShaderCompiler::PackDoubleVertexInputsForVulkan(
moduleSpirvs[i], packedSpirv, enableSpirvValidation);
@@ -3377,17 +3386,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const Bool remapOk = RemapDescriptorBindingsForVulkan(moduleSpirvs, m_maxBindings, moduleSpirvs);
MOBILEGL_ASSERT(remapOk, "ProgramFactory::GetOrCreateProgram: descriptor binding remap failed");
for (SizeT i = 0; i < shaders.size(); ++i) {
for (SizeT i = 0; i < moduleCount; ++i) {
auto& moduleSpv = moduleSpirvs[i];
if (moduleSpv.empty()) continue;
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
ValidateTransformedSpirv(moduleSpv, shaders[i]->GetShaderStage(), program.GetExternalIndex());
ValidateTransformedSpirv(moduleSpv, stages[i], 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 (enableSpirvValidation) {
ValidateTransformedSpirv(moduleSpv, shaders[i]->GetShaderStage(), program.GetExternalIndex());
ValidateTransformedSpirv(moduleSpv, stages[i], program.GetExternalIndex());
}
#endif
@@ -3399,7 +3408,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VK_VERIFY(vkCreateShaderModule(m_device, &smci, nullptr, &module), "vkCreateShaderModule");
VkPipelineShaderStageCreateInfo stage{VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
ShaderStage shaderStage = shaders[i]->GetShaderStage();
ShaderStage shaderStage = stages[i];
stage.stage = ToVkStage(shaderStage);
stage.module = module;
stage.pName = "main";
@@ -3434,12 +3443,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Reflect and create layout as part of the program object
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
ValidateRasterizationStageInterface(shaders, moduleSpirvs, entry, program.GetExternalIndex());
ValidateRasterizationStageInterface(stages, moduleSpirvs, entry, program.GetExternalIndex());
#endif
ReflectVertexInputs(shaders, moduleSpirvs, entry);
ReflectViewportIndexUsage(shaders, moduleSpirvs, entry);
ReflectFragmentOutputs(shaders, moduleSpirvs, entry);
ReflectPassthroughTessControlNeed(shaders, moduleSpirvs, entry);
ReflectVertexInputs(stages, moduleSpirvs, entry);
ReflectViewportIndexUsage(stages, moduleSpirvs, entry);
ReflectFragmentOutputs(stages, moduleSpirvs, entry);
ReflectPassthroughTessControlNeed(stages, 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
@@ -3651,7 +3660,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
void ProgramFactory::ReflectPassthroughTessControlNeed(
const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const {
entry.needsPassthroughTessControl = false;
@@ -3660,9 +3669,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool hasTessEval = false;
Bool hasTessControl = false;
SizeT tessEvalModuleIndex = 0;
for (SizeT i = 0; i < shaders.size(); ++i) {
if (!shaders[i]) continue;
const auto stage = shaders[i]->GetShaderStage();
for (SizeT i = 0; i < stages.size(); ++i) {
const ShaderStage stage = stages[i];
if (stage == ShaderStage::TessControl) hasTessControl = true;
if (stage == ShaderStage::TessEval) {
hasTessEval = true;
@@ -499,13 +499,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
};
static TextureTarget UniformTypeToTextureTarget(GLenum glType);
void ReflectVertexInputs(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
// `stages` is ALWAYS ProgramObject::GetLinkedShaderStages() - one entry per module of
// `spirv`, at the same index. Taking the stages rather than the shader objects is what
// keeps the program's live attach list, which is a longer and differently-indexed list
// the moment a glAttachShader lands after the link, from being passed here by mistake.
void ReflectVertexInputs(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const;
void ReflectViewportIndexUsage(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
void ReflectViewportIndexUsage(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const;
void ReflectFragmentOutputs(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
void ReflectFragmentOutputs(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const;
void ReflectLayout(const MG_State::GLState::ProgramObject& program, const Vector<Vector<Uint>>& spirv,
@@ -513,7 +517,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Fills needsPassthroughTessControl / passthroughTessControlEmulatable off the linked
// modules. Const and reflection-only: it decides nothing about the pipeline, it only
// records what the evaluation stage's input interface is made of.
void ReflectPassthroughTessControlNeed(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
void ReflectPassthroughTessControlNeed(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const;
@@ -8,6 +8,7 @@
#include "VertexInputStateFactory.h"
#include "MG_Util/Converters/MGToStr/DataTypeConverter.h"
#include <MG_Backend/BackendObjects.h>
#include <utility>
namespace MobileGL::MG_Backend::DirectVulkan {
@@ -107,8 +108,34 @@ namespace MobileGL::MG_Backend::DirectVulkan {
continue;
}
const VkFormat sourceVkFormat =
VkFormat sourceVkFormat =
ToVkVertexFormat(attr.Type, attr.Size, attr.Normalized, attr.IsInteger, attr.IsBgra, attr.IsLong);
VertexStreamConversion conversion = VertexStreamConversion::None;
// Gated on the SAME flag ToVkVertexFormat gates its 64-bit path on, and that is
// load-bearing rather than belt-and-braces: the narrowing is only correct because the
// shader's `dvec` input is a `vec` by the time the pipeline is built, and what
// guarantees that is the flag being clear. It is clear on every backend today, and a
// program with a 64-bit float vertex input is demoted WHOLE for the same reason even
// where the device has native fp64 (ProgramSpirvTask::GenerateSpirv). With the flag
// set, a dvec3/dvec4 would be declined by ToVkVertexFormat AND left 64-bit in the
// module, so a float32 stream would be fed to a Float64 input.
const Bool narrowFloat64Arrays =
MG_Backend::pActiveBackendObject == nullptr ||
!MG_Backend::pActiveBackendObject->GetDynamicParameters().SupportsFloat64VertexAttributes;
if (sourceVkFormat == VK_FORMAT_UNDEFINED && attr.Type == DataType::Float64 && narrowFloat64Arrays) {
// No native 64-bit fetch here (see ToVkVertexFormat's Float64 case), but the
// source bytes are ordinary IEEE-754 doubles and DemoteFloat64Pass has already
// narrowed every dvec input to a vec, so the array is narrowed to match rather
// than dropped. Mirrors what DirectGLES does for the same state.
const VkFormat narrowedFormat = ToFloat32VertexFormat(attr.Size);
if (narrowedFormat != VK_FORMAT_UNDEFINED && SupportsVertexBufferFormat(narrowedFormat)) {
sourceVkFormat = narrowedFormat;
conversion = VertexStreamConversion::Float64ToFloat32;
MGLOG_W_ONCE("Vertex attribute location=%u is a 64-bit (GL_DOUBLE) array; fetching it at "
"float32 precision through format=%d (size=%d long=%s)",
location, static_cast<Int>(narrowedFormat), attr.Size, attr.IsLong ? "true" : "false");
}
}
if (sourceVkFormat == VK_FORMAT_UNDEFINED) {
MGLOG_E_ONCE("Unsupported vertex attribute layout (location=%u, type=%s, size=%d): the array is "
"enabled but cannot be mapped to a VkFormat",
@@ -118,8 +145,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
VkFormat vkFormat = sourceVkFormat;
VertexStreamConversion conversion = VertexStreamConversion::None;
if (!SupportsVertexBufferFormat(vkFormat)) {
if (conversion == VertexStreamConversion::None && !SupportsVertexBufferFormat(vkFormat)) {
if (IsScaledIntegerVertexFormat(vkFormat)) {
const VkFormat fallbackFormat = ToFloat32VertexFormat(attr.Size);
if (fallbackFormat != VK_FORMAT_UNDEFINED && SupportsVertexBufferFormat(fallbackFormat)) {
@@ -188,7 +214,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (sourceStride != 0) {
if (conversion == VertexStreamConversion::Repack) {
stride = static_cast<Uint32>(attribByteSize);
} else if (conversion == VertexStreamConversion::ScaledIntegerToFloat32) {
} else if (conversion == VertexStreamConversion::ScaledIntegerToFloat32 ||
conversion == VertexStreamConversion::Float64ToFloat32) {
stride = static_cast<Uint32>(attr.Size * static_cast<Int>(sizeof(Float)));
}
}
@@ -330,6 +357,20 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// for every R64 float format, so a native 64-bit vertex fetch is simply unavailable there
// while shaderFloat64 is not. Both halves key off nothing but the attribute being long,
// so they always agree without extra plumbing.
//
// ... as long as the shader half still runs. It does not when the backend has declared
// no 64-bit vertex attribute support: DemoteFloat64Pass has already narrowed every
// `dvec` input to a `vec` by then, so PackDoubleVertexInputsPass finds nothing to pack
// and a UINT-formatted attribute would be fed to a float input - garbage with no
// diagnostic anywhere. Declining here hands the attribute to the caller's
// Float64ToFloat32 fallback instead, which narrows the source doubles to match the
// demoted `vec` input - the same thing DirectGLES does for the same state. The
// frontend RECORDS the format either way, so this gate is the only thing standing
// between a legal glVertexAttribLFormat and a mismatched pipeline.
if (MG_Backend::pActiveBackendObject == nullptr ||
!MG_Backend::pActiveBackendObject->GetDynamicParameters().SupportsFloat64VertexAttributes) {
return VK_FORMAT_UNDEFINED;
}
if (!isLong || isInteger || normalized) return VK_FORMAT_UNDEFINED;
switch (size) {
case 1: return VK_FORMAT_R32G32_UINT;
@@ -23,6 +23,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
None = 0,
Repack,
ScaledIntegerToFloat32,
// GL_DOUBLE source data narrowed to a tightly packed float32 stream: the fetch half
// of the fp64 demotion the shader side already does unconditionally.
Float64ToFloat32,
};
struct BackendVertexInputState {
@@ -1494,6 +1494,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const auto* mipTexture = MG_State::GLState::AsMipmapTexture(&texture);
const Uint32 mipLevelCount = mipTexture != nullptr ? mipTexture->GetMipmapLevelCount() : 0u;
return resource.syncedContentVersion != texture.GetContentVersion() ||
resource.syncedShapeVersion != texture.GetShapeVersion() ||
resource.syncedTextureParamsVersion != texture.GetTextureParamsVersion() ||
resource.syncedMipLevelCount != mipLevelCount;
}
@@ -1593,11 +1594,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool VkTextureManager::SyncTexture(MG_State::GLState::ITextureObject &texture,
TextureResource &outResource) {
// Cross-draw fast path: if the resource is already built and neither the texture's
// pixel content (bumped in MarkStorageDirty) nor its params changed since the last
// sync, there is nothing to re-check or re-upload - skip CheckMipmapCompleteness,
// SyncTextureResource, SyncTextureViews and the per-level dirty scan. Layout is
// maintained separately by the transition path, so the resource still reflects truth.
// pixel content (bumped in MarkStorageDirty), its SHAPE (bumped in BumpShapeVersion)
// nor its params changed since the last sync, there is nothing to re-check or
// re-upload - skip CheckMipmapCompleteness, SyncTextureResource, SyncTextureViews and
// the per-level dirty scan. Layout is maintained separately by the transition path, so
// the resource still reflects truth. The shape version is NOT redundant with the
// content one: glTexImage2D(..., nullptr) re-specifies a level's size or format
// without dirtying a texel, which is exactly how a re-specified image-unit texture used
// to keep reporting its old imageSize().
const Uint64 syncingContentVersion = texture.GetContentVersion();
const Uint64 syncingShapeVersion = texture.GetShapeVersion();
const auto* syncingMipTexture = MG_State::GLState::AsMipmapTexture(&texture);
const Uint32 syncingMipLevelCount =
syncingMipTexture != nullptr ? syncingMipTexture->GetMipmapLevelCount() : 0u;
@@ -1609,6 +1615,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_storageImageTextures.find(MakeTextureIdentity(&texture)) != m_storageImageTextures.end();
if (outResource.image != VK_NULL_HANDLE && !storageUpgradePending &&
outResource.syncedContentVersion == syncingContentVersion &&
outResource.syncedShapeVersion == syncingShapeVersion &&
outResource.syncedTextureParamsVersion == texture.GetTextureParamsVersion() &&
outResource.syncedMipLevelCount == syncingMipLevelCount) {
return true;
@@ -1629,6 +1636,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return false;
}
// From here down the size is VULKAN geometry, not GL's: a 1D array's layer count moves
// out of the height it occupies GL-side and into z, which is the slot
// TryResolveTextureShapeInfo reads arrayLayers from and the only one that leaves
// extent.height at the 1 a VK_IMAGE_TYPE_1D image is required to have.
texelSize = ToVulkanLevelExtent(texture.GetTarget(), texelSize);
if (!SyncTextureResource(texture, uploadTarget, texelSize, byteSize, mipLevelCount, outResource)) {
MGLOG_D("%s: SyncTextureResource failed", __func__);
return false;
@@ -1660,6 +1673,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (!hasDirtyMipLevel) {
outResource.syncedContentVersion = syncingContentVersion;
outResource.syncedMipLevelCount = syncingMipLevelCount;
outResource.syncedShapeVersion = syncingShapeVersion;
return true;
}
@@ -1669,6 +1683,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
outResource.syncedContentVersion = syncingContentVersion;
outResource.syncedMipLevelCount = syncingMipLevelCount;
outResource.syncedShapeVersion = syncingShapeVersion;
return true;
}
@@ -2536,7 +2551,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
uploadItem.target = target;
uploadItem.level = level;
uploadItem.baseArrayLayer = ResolveUploadArrayLayer(target);
uploadItem.texelSize = texelSize;
// Vulkan geometry, like the image this stages into (see SyncTexture): a 1D
// array's layers move from y to z, where the copy loop's depthSelectsArrayLayer
// branch turns them into layerCount. The shadow needs no repacking to follow -
// one layer of a 1D array IS one row of `width` texels, so the tight-packed
// per-layer copy the swapped size describes reads the same bytes in the same
// order as the row-major level it replaces.
uploadItem.texelSize = ToVulkanLevelExtent(mipmapTexture.GetTarget(), texelSize);
uploadItem.source = source;
uploadItem.offset = stagingSize;
uploadItem.uploadByteSize = byteSize;
@@ -2574,6 +2595,23 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
uploadItem.uploadByteSize = rectTexels * uploadItem.texelBytes;
}
// The boxes came out of the shadow in GL coordinates, where a 1D
// array's layer is the y. They have to follow texelSize across to z or
// they would address rows of an image that now has exactly one, and
// the staging walk would read the wrong bytes for them. Every byte
// count computed above is a product of the three extents, so moving
// the axes leaves all of them alone - and an OFFSET lands on a zero y,
// not on the extent's one, which is why this is spelled out rather than
// handed to ToVulkanLevelExtent.
if (mipmapTexture.GetTarget() == TextureTarget::Texture1DArray) {
uploadItem.regionLo = {uploadItem.regionLo.x(), 0, uploadItem.regionLo.y()};
uploadItem.regionSize = {uploadItem.regionSize.x(), 1,
uploadItem.regionSize.y()};
for (auto& rect : uploadItem.rects) {
rect.lo = {rect.lo.x(), 0, rect.lo.y()};
rect.hi = {rect.hi.x(), 1, rect.hi.y()};
}
}
}
}
if (formatInfo.expandRgbToRgba) {
@@ -22,6 +22,25 @@ class ITextureObject;
namespace MobileGL::MG_Backend::DirectVulkan {
enum class SamplerNumericDomain : Uint8;
// A GL 1D-ARRAY level keeps its LAYER COUNT in the state-side HEIGHT: that is what
// glTexImage2D(GL_TEXTURE_1D_ARRAY, width, layers) means, and the frontend records the level
// as {width, layers, 1} (see GL_Texture.cpp's AllocateStorage and the completeness walk in
// TextureObject.cpp, which shrinks only x down the chain). Vulkan packs it the other way: a
// 1D array is a VK_IMAGE_TYPE_1D image whose extent.height MUST be 1 and whose layers live in
// arrayLayers - i.e. in the slot this backend reads out of z. So every place that turns a GL
// level size into Vulkan image geometry has to move the count across first, and every GL-space
// sub-box that rides along with it has to move its y the same way. DirectGLES performs the
// identical remap onto the ES 2D array it maps 1D arrays to (GetBackendUploadSize).
//
// Applied to nothing else: a 2D array, a cube array and a 3D texture all already carry their
// depth/layer count in z, which is where the Vulkan side expects it.
inline IntVec3 ToVulkanLevelExtent(TextureTarget stateTarget, const IntVec3& glTexelSize) {
if (stateTarget == TextureTarget::Texture1DArray) {
return {glTexelSize.x(), 1, glTexelSize.y()};
}
return glTexelSize;
}
class VkTextureManager {
public:
// Monotonic epoch bumped whenever a texture VkImage is (re)created. The render-pass
@@ -206,6 +225,12 @@ public:
// as defense-in-depth: any path that grows the level set (which resizes the sampled view)
// busts the skip even if it failed to bump the content version.
Uint32 syncedMipLevelCount = 0;
// Snapshot of ITextureObject::GetShapeVersion() at the last successful sync. The content
// version alone does NOT cover a re-specification: glTexImage2D(..., nullptr) on an
// already-defined level changes its size or format and dirties no texel, so it moves the
// shape version and nothing else. Without this in the early-out key the image, its views
// and therefore imageSize() all keep answering with the texture's PREVIOUS shape.
Uint64 syncedShapeVersion = 0;
TextureResource() = default;
TextureResource(const TextureResource&) = delete;
@@ -237,6 +262,7 @@ public:
std::swap(this->lastRecordingGeneration, that.lastRecordingGeneration);
std::swap(this->syncedContentVersion, that.syncedContentVersion);
std::swap(this->syncedMipLevelCount, that.syncedMipLevelCount);
std::swap(this->syncedShapeVersion, that.syncedShapeVersion);
}
void Reset() {
@@ -300,6 +326,7 @@ public:
syncedTextureParamsVersion = 0;
syncedContentVersion = 0;
syncedMipLevelCount = 0;
syncedShapeVersion = 0;
}
~TextureResource() {
@@ -972,6 +972,38 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
// The fetch half of the 64-bit vertex narrowing, whose shader half is guaranteed by
// SupportsFloat64VertexAttributes staying false on this backend: any program with a Float64
// vertex INPUT is demoted whole, native fp64 or not, so the input is always a 32-bit one. The
// source bytes are ordinary IEEE-754 doubles, so a GL_DOUBLE array is deinterleaved into a
// tightly packed float32 stream rather than dropped. `normalized` is not consulted - GL
// ignores it for floating-point array types.
static Bool ConvertFloat64VertexStreamToFloat32(
const MG_State::GLState::VertexAttribute& attribute,
const Uint8* sourceData,
SizeT sourceStride,
SizeT elementCount,
Vector<Float>& outData) {
if (sourceData == nullptr || attribute.Size < 1 || attribute.Size > 4 || sourceStride == 0) {
return false;
}
const SizeT componentCount = static_cast<SizeT>(attribute.Size);
outData.resize(elementCount * componentCount);
for (SizeT element = 0; element < elementCount; ++element) {
const Uint8* sourceElement = sourceData + element * sourceStride;
Float* destinationElement = outData.data() + element * componentCount;
for (SizeT component = 0; component < componentCount; ++component) {
// GL byte strides and offsets are arbitrary, so no component carries an 8-byte
// alignment guarantee; copy it out before narrowing it.
Double value = 0.0;
Memcpy(&value, sourceElement + component * sizeof(Double), sizeof(Double));
destinationElement[component] = static_cast<Float>(value);
}
}
return true;
}
static Bool RepackVertexStream(const Uint8* sourceData,
SizeT sourceStride,
SizeT elementSize,
@@ -3595,6 +3627,14 @@ void main() {
uploadData = m_vertexConversionScratch.data();
uploadSize = static_cast<VkDeviceSize>(m_vertexConversionScratch.size() * sizeof(Float));
break;
case VertexInputStateFactory::VertexStreamConversion::Float64ToFloat32:
if (!ConvertFloat64VertexStreamToFloat32(attribute, sourceData, sourceStride, elementCount,
m_vertexConversionScratch)) {
return false;
}
uploadData = m_vertexConversionScratch.data();
uploadSize = static_cast<VkDeviceSize>(m_vertexConversionScratch.size() * sizeof(Float));
break;
case VertexInputStateFactory::VertexStreamConversion::None:
return false;
}
@@ -4727,10 +4767,11 @@ void main() {
// link-time properties, so this is safe to fold into a pipeline keyed on the program hash.
static Bool ProgramCapturesXfbFromGeometryStage(const MG_State::GLState::ProgramObject& program) {
if (program.GetTransformFeedbackVaryingCount() == 0) return false;
for (const auto& shader : program.GetAttachedShaders()) {
if (shader && shader->GetShaderStage() == ShaderStage::Geometry) return true;
}
return false;
// Both halves are link-time properties, so both are asked of the LAST LINK. Reading the
// live attach list would let a glAttachShader that has not been linked in yet - which GL
// 4.6 core 7.3 says changes nothing about what the program runs - flip a property this
// pipeline is cached under, for an executable with no geometry stage in it.
return program.HasLinkedShaderStage(ShaderStage::Geometry);
}
VkPipeline VulkanRenderer::GetOrCreatePipeline(
@@ -8926,6 +8967,7 @@ void main() {
outMapping.baseSlice = baseSlice;
outMapping.availableSlices = std::max(1u, image.depth >> mipLevel);
return true;
case TextureTarget::Texture1DArray:
case TextureTarget::Texture2DArray:
case TextureTarget::Texture2DMultisampleArray:
case TextureTarget::TextureCubeMap:
@@ -8933,15 +8975,18 @@ void main() {
// A cube map is an array of six faces here (see TryResolveTextureShapeInfo), and GL
// numbers its faces on the same z axis an array texture numbers its layers, so both
// arrive as a plain layer range.
//
// GL_TEXTURE_1D_ARRAY belongs here too, and needs no remap: this backend STORES it
// as a VK_IMAGE_TYPE_1D image whose layers live in arrayLayers (ToVulkanLevelExtent
// moves the count across), and GL 4.6 core 18.3.2 ADDRESSES it as a stack of slices
// on z with an image height of 1 - so the frontend's y/height are already the 0/1
// Vulkan requires and the layer lands in baseArrayLayer either way.
outMapping.slicesAreDepth = false;
outMapping.baseSlice = baseSlice;
outMapping.availableSlices = image.arrayLayers;
return true;
default:
// GL_TEXTURE_1D_ARRAY carries its layers on the Y axis (srcY/srcHeight), which
// would have to be remapped against a Vulkan extent that also has to stay height 1
// for a VK_IMAGE_TYPE_1D image; GL_TEXTURE_BUFFER has no image at all. Declined
// rather than mis-addressed.
// GL_TEXTURE_BUFFER has no image at all. Declined rather than mis-addressed.
return false;
}
}
@@ -9813,7 +9858,7 @@ void main() {
VkImageAspectFlags imageAspect, Uint32 mipLevel,
Uint32 baseArrayLayer, GLint x, GLint y, GLsizei width,
GLsizei height, GLenum format, GLenum type, void* pixels,
Bool defaultFramebufferOrientation) {
Bool defaultFramebufferOrientation, Uint32 sourceLayerCount) {
const Bool wantDepth = format != GL_STENCIL_INDEX;
const Bool wantStencil = format != GL_DEPTH_COMPONENT;
auto& frame = m_frameContext.GetCurrent();
@@ -9892,6 +9937,10 @@ void main() {
if (!mapped) return;
}
// See the header: a stack of one-row layers and a single multi-row layer copy out to the
// same tightly-packed bytes, so only the region's shape splits the two cases.
const Uint32 copyLayerCount = std::max<Uint32>(sourceLayerCount, 1u);
const Uint32 copyRowCount = copyLayerCount > 1u ? 1u : copyExtent.height;
VkBufferImageCopy regions[2]{};
Uint32 regionCount = 0;
if (wantDepth) {
@@ -9900,9 +9949,9 @@ void main() {
region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;
region.imageSubresource.mipLevel = mipLevel;
region.imageSubresource.baseArrayLayer = baseArrayLayer;
region.imageSubresource.layerCount = 1;
region.imageSubresource.layerCount = copyLayerCount;
region.imageOffset = {copyOffset.x, copyOffset.y, 0};
region.imageExtent = {copyExtent.width, copyExtent.height, 1};
region.imageExtent = {copyExtent.width, copyRowCount, 1};
}
if (wantStencil) {
auto& region = regions[regionCount++];
@@ -9910,9 +9959,9 @@ void main() {
region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_STENCIL_BIT;
region.imageSubresource.mipLevel = mipLevel;
region.imageSubresource.baseArrayLayer = baseArrayLayer;
region.imageSubresource.layerCount = 1;
region.imageSubresource.layerCount = copyLayerCount;
region.imageOffset = {copyOffset.x, copyOffset.y, 0};
region.imageExtent = {copyExtent.width, copyExtent.height, 1};
region.imageExtent = {copyExtent.width, copyRowCount, 1};
}
vkCmdCopyImageToBuffer(frame.commandBuffer, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, readback.GetHandle(),
regionCount, regions);
@@ -10147,9 +10196,17 @@ void main() {
? static_cast<Uint32>(textureUploadTarget) -
static_cast<Uint32>(TextureUploadTarget::CubeMapPositiveX)
: 0;
// A 1D array's levelSize.y() is its LAYER count, and those layers are the rows
// GL wants back - but in Vulkan they are array layers of a one-row image, not
// rows of layer 0, so the read has to be told which of the two it is looking at.
const Uint32 sourceLayers =
textureObject->GetTarget() == TextureTarget::Texture1DArray
? static_cast<Uint32>(std::max<Int>(levelSize.y(), 1))
: 1u;
ReadDepthStencilImageToClient(resource->image, resource->format, &resource->layout, resource->aspect,
static_cast<Uint32>(level), arrayLayer, 0, 0, levelSize.x(),
levelSize.y(), format, type, pixels);
levelSize.y(), format, type, pixels,
/*defaultFramebufferOrientation=*/false, sourceLayers);
} else {
MGLOG_E_ONCE("DirectVulkan::GetTexImage skipped: color query of a non-color texture");
}
@@ -10167,12 +10224,19 @@ void main() {
// destination layout (GL 3.3 section 6.1.4).
const auto imageTextureTarget = textureObject->GetTarget();
const Bool is3dImage = imageTextureTarget == TextureTarget::Texture3D;
const Bool isArrayImage = imageTextureTarget == TextureTarget::Texture1DArray ||
const Bool is1dArrayImage = imageTextureTarget == TextureTarget::Texture1DArray;
const Bool isArrayImage = is1dArrayImage ||
imageTextureTarget == TextureTarget::Texture2DArray ||
imageTextureTarget == TextureTarget::TextureCubeMapArray;
const GLsizei depthSlices = is3dImage ? std::max<GLsizei>(texelSize.z(), 1) : 1;
const GLsizei arrayLayers = isArrayImage ? static_cast<GLsizei>(resource->arrayLayers) : 1;
const GLsizei sliceCount = std::max<GLsizei>(depthSlices * arrayLayers, 1);
// A 1D array level comes back as ONE two-dimensional image whose rows are its layers
// (GL 4.6 core 8.11.4), so its layers are already counted by `height` above and must not
// multiply the slice count the way a 2D-array's or a cube-array's do. Vulkan still keeps
// them in arrayLayers on a one-row image, which is what the copy region below says - the
// two describe the same tightly-packed bytes.
const GLsizei sliceCount =
std::max<GLsizei>(depthSlices * (is1dArrayImage ? 1 : arrayLayers), 1);
if (bufSize >= 0) {
const Int dstChannels = GetReadbackChannelCount(format);
if ((type == GL_UNSIGNED_BYTE || type == GL_FLOAT) && dstChannels > 0) {
@@ -10225,7 +10289,8 @@ void main() {
copyRegion.imageSubresource.mipLevel = static_cast<Uint32>(level);
copyRegion.imageSubresource.baseArrayLayer = 0;
copyRegion.imageSubresource.layerCount = static_cast<Uint32>(arrayLayers);
copyRegion.imageExtent = {static_cast<Uint32>(width), static_cast<Uint32>(height),
copyRegion.imageExtent = {static_cast<Uint32>(width),
is1dArrayImage ? 1u : static_cast<Uint32>(height),
static_cast<Uint32>(depthSlices)};
vkCmdCopyImageToBuffer(frame.commandBuffer, resource->image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
readback.GetHandle(), 1, &copyRegion);
@@ -217,10 +217,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// 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.
// `sourceLayerCount` above 1 says the `height` rows the client is owed are stored as that
// many ARRAY LAYERS of a one-row image rather than as rows of one layer - the shape a GL
// 1D array has in Vulkan. The two produce byte-identical tightly-packed readbacks, so
// only the copy region differs; everything after it is written against `height`.
void ReadDepthStencilImageToClient(VkImage image, VkFormat vkFormat, VkImageLayout* trackedLayout,
VkImageAspectFlags imageAspect, Uint32 mipLevel, Uint32 baseArrayLayer,
GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type,
void* pixels, Bool defaultFramebufferOrientation = false);
void* pixels, Bool defaultFramebufferOrientation = false,
Uint32 sourceLayerCount = 1);
// Same-extent depth blit between images of different depth formats: host
// round-trip with a per-texel re-encode (see BlitNamedFramebuffer).
Bool BlitDepthAcrossFormats(FrameContext::FrameData& frame, VkImage srcImage, VkFormat srcFormat,
+2 -1
View File
@@ -44,4 +44,5 @@ add_subdirectory(Program)
add_subdirectory(Buffer)
add_subdirectory(Driver)
add_subdirectory(Container)
add_subdirectory(Transpile)
add_subdirectory(ShaderCache)
add_subdirectory(Transpile)
@@ -0,0 +1,21 @@
cmake_minimum_required(VERSION 3.24)
add_executable(
TranslationCacheBench
TranslationCacheBench.cpp
)
target_include_directories(TranslationCacheBench PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/3rdparty/SPIRV-Reflect
)
target_link_libraries(
TranslationCacheBench PRIVATE
benchmark::benchmark
${LINK_LIBRARIES}
)
add_test(NAME TranslationCacheBench COMMAND TranslationCacheBench --benchmark_counters_tabular=true)
set_tests_properties(TranslationCacheBench PROPERTIES LABELS benchmark)
@@ -0,0 +1,457 @@
// MobileGL - MobileGL/MG_Benchmark/ShaderCache/TranslationCacheBench.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
// What the two-level shader translation memo is worth, measured on the workload that
// motivated it: the KHR-GL33.texture_swizzle.smoke_* shape, where one case builds 2592
// programs out of a handful of distinct sources.
//
// Four pairs of cases, each Off/On:
//
// ProgramLink - the whole glCompileShader + glLinkProgram path for one program, with
// FRESH SHADER OBJECTS every iteration. This is the CTS shape exactly,
// and it is the headline case now. It used to be the PESSIMISTIC one:
// a hit still paid for both glslang parses, because the parse happens
// at glCompileShader - a different entry point from the one L1
// memoizes - and fresh shader objects meant ShaderCompileAdoptionMap
// could not hand the earlier parse over either. L1c is what closed
// that: the compile half of the memo recognises each stage's source
// and publishes its verdict without parsing, so on a hit this case now
// constructs no glslang object at all.
//
// SharedShaderLink - the same program population with the shader objects KEPT ALIVE, so
// the parses happen once outside the measured loop whatever the cache
// does. That makes it the CONTROL for L1c rather than a target: its
// numbers should not move, and if they do, L1c has added cost to a
// path it was supposed to leave alone.
//
// DeferredParseLink - the shape where L1c could LOSE: a constant vertex source (which
// hits L1c and therefore skips its parse) against a fresh fragment
// source every iteration (which makes the PROGRAM key miss, so the
// skipped parse has to happen inside the link after all). Same parse
// count either way, so the pair should land within noise; see its own
// header below.
//
// EsslTranspile - the DirectGLES backend segment: the SPIR-V pass chain plus
// SPIRV-Cross. Runs the driver-INDEPENDENT half of the real chain (the
// passes SyncToBackend runs unconditionally, plus the two stage-gated
// ones a fragment module reaches) so the miss path costs what
// production costs; the capability-gated passes need a live ES driver
// and are not reachable from a benchmark process.
//
// Every On case runs with a warm cache: the first iteration misses and every one after it
// hits, which is exactly the steady state of a 2592-program smoke case.
#include <benchmark/benchmark.h>
#include <string>
#include "Config.h"
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_State/GLState/Core.h"
#include "MG_State/GLState/ProgramState/ProgramTranslationCache.h"
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
#include "MG_Util/ShaderTranspiler/SpvcSession.h"
#include "MG_Util/ShaderTranspiler/TranslationCache.h"
#include "MG_Util/ShaderTranspiler/Types.h"
using namespace MobileGL;
using namespace MobileGL::MG_Util::ShaderTranspiler;
namespace {
const char* kVertexSource = R"(#version 460
layout(location = 0) in vec3 aPos;
out vec3 vPos;
out vec2 vUv;
void main() {
vPos = aPos;
vUv = aPos.xy * 0.5 + 0.5;
gl_Position = vec4(aPos, 1.0);
}
)";
// Shaped after gl3cTextureSwizzleTests.cpp's template: a sampler of one type, one
// TEXTURE_ACCESS, one CHANNEL, and an output whose BASIC_TYPE is the only thing that
// varies within a case. Padded with enough real arithmetic that the translation chain
// is doing work rather than measuring fixed overheads.
// `padLines` = 0 is the honest CTS size: gl3cTextureSwizzleTests' smoke template is a
// handful of lines, and that is the workload the memo exists for. The padded variant is
// kept alongside it because a shaderpack stage is orders of magnitude bigger, and the
// two bracket the ratio the cache is worth in practice.
String SwizzleLikeFragment(const String& prefix, const int padLines) {
String source = "#version 460\n";
source += "in vec3 vPos;\n";
source += "in vec2 vUv;\n";
source += "layout(location = 0) out " + prefix + "vec4 fragColor;\n";
source += "uniform sampler2D uTex;\n";
source += "uniform vec4 uTint;\n";
source += "uniform mat4 uModel;\n";
source += "uniform float uArr[8];\n";
source += "void main() {\n";
source += " vec4 s = texture(uTex, vUv);\n";
source += " float acc = s.r;\n";
for (int i = 0; i < padLines; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
}
source += " for (int i = 0; i < 8; ++i) acc += uArr[i];\n";
source += " vec4 p = uModel * vec4(vPos, 1.0);\n";
source += " fragColor = " + prefix + "vec4((s + uTint) * acc + p);\n";
source += "}\n";
return source;
}
class CacheModeScope {
public:
explicit CacheModeScope(const Bool enabled)
: m_saved(MG_Config::Features.ShaderTranslationCache) {
MG_Config::Features.ShaderTranslationCache =
enabled ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
}
~CacheModeScope() { MG_Config::Features.ShaderTranslationCache = m_saved; }
private:
const MG_Config::QuirkOverride m_saved;
};
class SyncCompileScope {
public:
SyncCompileScope() : m_saved(MG_Config::Features.AsyncShaderCompile) {
MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::ForceOff;
}
~SyncCompileScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
private:
const MG_Config::QuirkOverride m_saved;
};
// One program, built the way the CTS builds one: fresh shader objects every time.
void LinkOneProgram(const String& vertexSource, const String& fragmentSource) {
using namespace MG_Impl::GLImpl;
const GLuint vs = CreateShader(GL_VERTEX_SHADER);
const char* vsText = vertexSource.c_str();
ShaderSource(vs, 1, &vsText, nullptr);
CompileShader(vs);
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
const char* fsText = fragmentSource.c_str();
ShaderSource(fs, 1, &fsText, nullptr);
CompileShader(fs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
benchmark::DoNotOptimize(program);
DeleteProgram(program);
DeleteShader(vs);
DeleteShader(fs);
}
Vector<Uint32> BuildSanitizedFragmentSpirv(const String& fragmentSource) {
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = fragmentSource};
auto shader = ShaderCompiler::CompileShader(attrib);
if (!shader) return {};
ProgramAttrib programAttrib{.shaders = {shader.value()}};
auto program = ShaderCompiler::LinkProgram(programAttrib);
if (!program) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_FRAGMENT_SHADER}, .program = *program.value()};
auto binary = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
if (!binary || binary->empty()) return {};
Vector<Uint32> sanitized;
if (!ShaderCompiler::SanitizeAndOptimizeBinary(binary->front(), sanitized)) return {};
return sanitized;
}
// The driver-independent part of BackendProgramObjectImpl::TranspileSpirvToEssl, in the
// same order. What is missing is only the capability-gated passes (viewport lowering,
// multisample clamping, noperspective emulation, the image-format bake), which cannot
// fire without a live ES driver to arm them.
Bool TranspileLikeDirectGles(const Vector<Uint32>& spirv, const Uint esslVersion, String& outEssl) {
Vector<Uint32> a;
const Vector<Uint32>* effective = &spirv;
if (ShaderCompiler::StripUboMemberRelaxedPrecisionForEssl(*effective, a, false) && !a.empty()) {
effective = &a;
}
Vector<Uint32> b;
if (ShaderCompiler::LowerRectImages(*effective, b, false) && !b.empty()) effective = &b;
Vector<Uint32> c;
if (ShaderCompiler::Lower1DArrayImagesForEssl(*effective, c, false) && !c.empty()) effective = &c;
Vector<Uint32> d;
if (ShaderCompiler::LegalizeFragmentOutputIndexingForEssl(*effective, d, false) && !d.empty()) {
effective = &d;
}
SpvcSession session(*effective, SessionUsageBit::Transpile);
spvc_compiler_options options;
if (session.CreateOptions(&options) != SPVC_SUCCESS) return false;
spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, esslVersion);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_ES, SPVC_TRUE);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_VULKAN_SEMANTICS, SPVC_FALSE);
session.SetOptions(options);
const char* result = nullptr;
session.Compile(&result);
if (!result) return false;
outEssl = result;
return true;
}
EsslTranslationKeyInputs EsslInputsFor(const Vector<Uint32>& spirv) {
EsslTranslationKeyInputs inputs;
inputs.spirv = &spirv;
inputs.shaderType = GL_FRAGMENT_SHADER;
inputs.maxColorTextureSamples = 4;
inputs.maxIntegerSamples = 1;
inputs.maxDepthTextureSamples = 4;
inputs.advertisedMaxSamples = 4;
inputs.esslVersion = 320;
return inputs;
}
} // namespace
// ---------------------------------------------------------------------------------------
// L1, in situ: the full glCompileShader + glLinkProgram path for a repeated program.
// ---------------------------------------------------------------------------------------
// Arg(0) = the CTS smoke size; Arg(120) = a heavy stage, bracketing the ratio.
static void BM_ProgramLink_CacheOff(benchmark::State& state) {
MobileGL::Initialize();
const SyncCompileScope sync;
const CacheModeScope cache(false);
const String vs = kVertexSource;
const String fs = SwizzleLikeFragment("", static_cast<int>(state.range(0)));
for (auto _ : state) {
LinkOneProgram(vs, fs);
}
state.SetLabel("MOBILEGL_SHADER_CACHE=0");
}
BENCHMARK(BM_ProgramLink_CacheOff)->Arg(0)->Arg(120)->Unit(benchmark::kMicrosecond);
static void BM_ProgramLink_CacheOn(benchmark::State& state) {
MobileGL::Initialize();
const SyncCompileScope sync;
const CacheModeScope cache(true);
const String vs = kVertexSource;
const String fs = SwizzleLikeFragment("", static_cast<int>(state.range(0)));
LinkOneProgram(vs, fs); // prime, so the measured loop is the steady state
const TranslationCacheStats before = MG_State::GLState::GetProgramTranslationCache().Stats();
const TranslationCacheStats parseBefore = GetShaderParseVerdictCache().Stats();
for (auto _ : state) {
LinkOneProgram(vs, fs);
}
const TranslationCacheStats stats = MG_State::GLState::GetProgramTranslationCache().Stats();
const TranslationCacheStats parseStats = GetShaderParseVerdictCache().Stats();
state.counters["L1_hits"] = static_cast<double>(stats.hits - before.hits);
state.counters["L1_misses"] = static_cast<double>(stats.misses - before.misses);
// Two stages per iteration, so a clean run shows L1c_hits == 2 * iterations and zero
// misses: every glCompileShader in the loop skipped its parse.
state.counters["L1c_hits"] = static_cast<double>(parseStats.hits - parseBefore.hits);
state.counters["L1c_misses"] = static_cast<double>(parseStats.misses - parseBefore.misses);
}
BENCHMARK(BM_ProgramLink_CacheOn)->Arg(0)->Arg(120)->Unit(benchmark::kMicrosecond);
// ---------------------------------------------------------------------------------------
// L1, the shape the memo actually exists for: MANY PROGRAMS OUT OF THE SAME SHADERS.
//
// The pair above deletes its shader objects every iteration, which forces a fresh glslang
// parse per iteration no matter what the link does - glCompileShader parses, and that is a
// DIFFERENT entry point from the one L1 memoizes. It is a real workload (what an application
// that never reuses a shader object pays) but it is the pessimistic one, and the residual it
// leaves is the parse, not the link.
//
// This pair keeps the shader objects alive, so the parses happen once before the measured
// loop and the L1 hit then skips the link, mapIO, the SPIR-V, the reflection and the routing
// outright.
//
// SINCE L1c THIS IS THE CONTROL, NOT THE TARGET. Nothing inside the measured loop calls
// glCompileShader, so L1c cannot fire here at all - which is exactly what makes the pair
// useful: it is the shape that says whether the compile-side memo has slowed the LINK path
// down. Its numbers should be indistinguishable from the pre-L1c ones.
// ---------------------------------------------------------------------------------------
namespace {
struct SharedShaders {
GLuint vs = 0;
GLuint fs = 0;
};
SharedShaders MakeSharedShaders(const String& vertexSource, const String& fragmentSource) {
using namespace MG_Impl::GLImpl;
SharedShaders shaders;
shaders.vs = CreateShader(GL_VERTEX_SHADER);
const char* vsText = vertexSource.c_str();
ShaderSource(shaders.vs, 1, &vsText, nullptr);
CompileShader(shaders.vs);
shaders.fs = CreateShader(GL_FRAGMENT_SHADER);
const char* fsText = fragmentSource.c_str();
ShaderSource(shaders.fs, 1, &fsText, nullptr);
CompileShader(shaders.fs);
return shaders;
}
void LinkFromSharedShaders(const SharedShaders& shaders) {
using namespace MG_Impl::GLImpl;
const GLuint program = CreateProgram();
AttachShader(program, shaders.vs);
AttachShader(program, shaders.fs);
LinkProgram(program);
benchmark::DoNotOptimize(program);
DeleteProgram(program);
}
} // namespace
static void BM_SharedShaderLink_CacheOff(benchmark::State& state) {
MobileGL::Initialize();
const SyncCompileScope sync;
const CacheModeScope cache(false);
const SharedShaders shaders =
MakeSharedShaders(kVertexSource, SwizzleLikeFragment("", static_cast<int>(state.range(0))));
for (auto _ : state) {
LinkFromSharedShaders(shaders);
}
state.SetLabel("MOBILEGL_SHADER_CACHE=0");
}
BENCHMARK(BM_SharedShaderLink_CacheOff)->Arg(0)->Arg(120)->Unit(benchmark::kMicrosecond);
static void BM_SharedShaderLink_CacheOn(benchmark::State& state) {
MobileGL::Initialize();
const SyncCompileScope sync;
const CacheModeScope cache(true);
const SharedShaders shaders =
MakeSharedShaders(kVertexSource, SwizzleLikeFragment("", static_cast<int>(state.range(0))));
LinkFromSharedShaders(shaders); // prime, so the measured loop is the steady state
const TranslationCacheStats before = MG_State::GLState::GetProgramTranslationCache().Stats();
for (auto _ : state) {
LinkFromSharedShaders(shaders);
}
const TranslationCacheStats stats = MG_State::GLState::GetProgramTranslationCache().Stats();
state.counters["L1_hits"] = static_cast<double>(stats.hits - before.hits);
state.counters["L1_misses"] = static_cast<double>(stats.misses - before.misses);
}
BENCHMARK(BM_SharedShaderLink_CacheOn)->Arg(0)->Arg(120)->Unit(benchmark::kMicrosecond);
// ---------------------------------------------------------------------------------------
// L2, component: the DirectGLES SPIR-V pass chain plus SPIRV-Cross for one stage.
// ---------------------------------------------------------------------------------------
static void BM_EsslTranspile_CacheOff(benchmark::State& state) {
MobileGL::Initialize();
const Vector<Uint32> spirv =
BuildSanitizedFragmentSpirv(SwizzleLikeFragment("", static_cast<int>(state.range(0))));
if (spirv.empty()) {
state.SkipWithError("could not build the fragment module");
return;
}
String essl;
for (auto _ : state) {
if (!TranspileLikeDirectGles(spirv, 320, essl)) {
state.SkipWithError("transpile failed");
break;
}
benchmark::DoNotOptimize(essl.data());
}
state.SetLabel("MOBILEGL_SHADER_CACHE=0");
}
BENCHMARK(BM_EsslTranspile_CacheOff)->Arg(0)->Arg(120)->Unit(benchmark::kMicrosecond);
static void BM_EsslTranspile_CacheOn(benchmark::State& state) {
MobileGL::Initialize();
const Vector<Uint32> spirv =
BuildSanitizedFragmentSpirv(SwizzleLikeFragment("", static_cast<int>(state.range(0))));
if (spirv.empty()) {
state.SkipWithError("could not build the fragment module");
return;
}
BoundedTranslationCache<EsslTranslationResult> cache("bench L2", 64, 8u << 20);
const EsslTranslationKeyInputs inputs = EsslInputsFor(spirv);
for (auto _ : state) {
const TranslationCacheKey key = BuildEsslTranslationKey(inputs);
EsslTranslationResultPtr hit = cache.Find(key);
if (!hit) {
auto payload = MakeShared<EsslTranslationResult>();
if (!TranspileLikeDirectGles(spirv, inputs.esslVersion, payload->essl)) {
state.SkipWithError("transpile failed");
break;
}
cache.Insert(key, EsslTranslationResultPtr(payload), EsslTranslationResultBytes(*payload));
hit = payload;
}
benchmark::DoNotOptimize(hit->essl.data());
}
const TranslationCacheStats stats = cache.Stats();
state.counters["L2_hits"] = static_cast<double>(stats.hits);
state.counters["L2_misses"] = static_cast<double>(stats.misses);
}
BENCHMARK(BM_EsslTranspile_CacheOn)->Arg(0)->Arg(120)->Unit(benchmark::kMicrosecond);
// ---------------------------------------------------------------------------------------
// L1c, the shape where it could LOSE rather than win: the DEFERRED PARSE.
// ---------------------------------------------------------------------------------------
// A stage whose compile hits L1c holds no AST, so if the program-level key then MISSES, the
// parse it skipped has to happen anyway - inside the link, via ClaimParsedShader. The parse
// is moved, not removed, and this pair is what says whether moving it costs anything.
//
// The shape forces exactly that, every iteration: one CONSTANT vertex source (hits L1c after
// the first iteration) linked against a FRESH fragment source each time (misses L1c, and
// makes the program key miss too). So:
//
// cache off - two parses at glCompileShader, then the link.
// cache on - one parse at glCompileShader (the fragment), one deferred parse inside the
// link (the vertex), then the link.
//
// The parse count is identical, so these two should land within noise of each other. If the
// On arm is materially SLOWER, L1c is charging for something - the per-compile key build and
// hash over the full preprocessed source, or the loss of the claim-CAS reuse - and that cost
// shows up here and nowhere else.
//
// The distinct fragment sources also churn both front-end levels through their FIFO caps,
// which is the eviction behaviour a real shaderpack load produces; over a long run the
// constant vertex entry is occasionally evicted by that churn and re-inserted, so the L1c
// hit rate reported below is high but not exactly 1.0 per iteration.
namespace {
String UniqueFragmentSource(const Uint64 serial, const int padLines) {
return SwizzleLikeFragment("", padLines) +
"\n// unique-" + std::to_string(serial) + "\n";
}
} // namespace
static void BM_DeferredParseLink_CacheOff(benchmark::State& state) {
MobileGL::Initialize();
const SyncCompileScope sync;
const CacheModeScope cache(false);
const String vs = kVertexSource;
Uint64 serial = 0;
for (auto _ : state) {
LinkOneProgram(vs, UniqueFragmentSource(serial++, static_cast<int>(state.range(0))));
}
state.SetLabel("MOBILEGL_SHADER_CACHE=0");
}
BENCHMARK(BM_DeferredParseLink_CacheOff)->Arg(0)->Arg(120)->Unit(benchmark::kMicrosecond);
static void BM_DeferredParseLink_CacheOn(benchmark::State& state) {
MobileGL::Initialize();
const SyncCompileScope sync;
const CacheModeScope cache(true);
const String vs = kVertexSource;
Uint64 serial = 0;
LinkOneProgram(vs, UniqueFragmentSource(~0ull, static_cast<int>(state.range(0)))); // prime the vertex entry
const TranslationCacheStats before = MG_State::GLState::GetProgramTranslationCache().Stats();
const TranslationCacheStats parseBefore = GetShaderParseVerdictCache().Stats();
for (auto _ : state) {
LinkOneProgram(vs, UniqueFragmentSource(serial++, static_cast<int>(state.range(0))));
}
const TranslationCacheStats stats = MG_State::GLState::GetProgramTranslationCache().Stats();
const TranslationCacheStats parseStats = GetShaderParseVerdictCache().Stats();
// Expected shape: L1 all misses (every program is new), L1c one hit (vertex) and one miss
// (fragment) per iteration.
state.counters["L1_hits"] = static_cast<double>(stats.hits - before.hits);
state.counters["L1_misses"] = static_cast<double>(stats.misses - before.misses);
state.counters["L1c_hits"] = static_cast<double>(parseStats.hits - parseBefore.hits);
state.counters["L1c_misses"] = static_cast<double>(parseStats.misses - parseBefore.misses);
}
BENCHMARK(BM_DeferredParseLink_CacheOn)->Arg(0)->Arg(120)->Unit(benchmark::kMicrosecond);
BENCHMARK_MAIN();
@@ -13,6 +13,7 @@
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/Converters/MGToGL/BufferEnumConverter.h>
#include <MG_Util/Converters/MGToStr/BufferEnumConverter.h>
#include <MG_Util/ShaderTranspiler/Types.h>
namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
Bool ValidateBufferTarget(BufferTarget target) {
@@ -67,6 +68,13 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
// binding points in GL 3.3 (no ARB_transform_feedback3).
pointCount = std::min<SizeT>(pointCount, 4);
}
if (target == BufferTarget::AtomicCounter) {
// GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS, which is NOT the state layer's array
// size: a counter buffer reaches a shader only as a lowered storage block, so the
// reserved range is the ceiling, and glGetIntegerv advertises the same number.
pointCount = std::min<SizeT>(
pointCount, static_cast<SizeT>(MG_Util::ShaderTranspiler::MAX_ATOMIC_COUNTER_BUFFER_BINDINGS));
}
return pointCount;
}
} // namespace
+104 -11
View File
@@ -45,7 +45,11 @@ namespace MobileGL::MG_Impl::GLImpl {
const auto& currentProgram = MG_State::pGLContext->GetProgramForDispatch();
if (!ValidateProgramForExecution(currentProgram, functionName)) return false;
if (currentProgram->GetShaderIndexByStage(ShaderStage::Compute) < 0) {
// Of the EXECUTABLE, not the live attach list: attaching a compute shader to an
// already-linked graphics program does not give that program a compute stage to
// dispatch (GL 4.6 core 7.3), and letting the dispatch through on the strength of the
// attach hands the backend a program whose SPIR-V has no compute module in it.
if (!currentProgram->HasLinkedShaderStage(ShaderStage::Compute)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", functionName,
@@ -111,7 +115,7 @@ namespace MobileGL::MG_Impl::GLImpl {
// A geometry stage writes what it emits, not what the draw assembled, and the
// amplification factor lives in the shader. Record that this span contained such
// a draw so the transform feedback queries keep their backend result for it.
if (program->GetShaderIndexByStage(ShaderStage::Geometry) >= 0) {
if (program->HasLinkedShaderStage(ShaderStage::Geometry)) {
MG_State::pGLContext->AddTransformFeedbackGeometryCaptureDraw();
}
// Capacity in captured vertices = the tightest bound buffer.
@@ -199,13 +203,58 @@ namespace MobileGL::MG_Impl::GLImpl {
return false;
}
const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw();
// GL 4.6 core 10.1: the tessellation pipeline's only input primitive is GL_PATCHES, and
// GL_PATCHES has no meaning without it. Both directions are INVALID_OPERATION, and
// neither was implemented - which is two of the four sites
// KHR-GL43.transform_feedback.api_errors_test checks with one shared message string.
// The EVALUATION stage is what decides: a control stage cannot run without one, and a
// program carrying only an evaluation stage still tessellates, through GL's
// fixed-function pass-through control stage (11.2.2).
// Asked of the LAST LINK, not the live attach list (GL 4.6 core 7.3): attaching a
// tessellation evaluation shader to an already-linked program does not put it in the
// executable, so reading the live list here would reject every non-GL_PATCHES draw
// against a program that does not tessellate - and keep rejecting them, since a detach
// is likewise deferred to the next link.
const Bool tessellationActive = currentProgram && currentProgram->HasLinkedShaderStage(ShaderStage::TessEval);
if (tessellationActive && mode != GL_PATCHES) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", functionName,
"A program with a tessellation evaluation shader can only be drawn with GL_PATCHES."));
return false;
}
if (!tessellationActive && mode == GL_PATCHES) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", functionName,
"GL_PATCHES requires an active tessellation evaluation shader."));
return false;
}
// A geometry stage only accepts the primitive types that decompose into its declared
// input primitive (GL 4.6 core 11.3.1); anything else is INVALID_OPERATION. GL_PATCHES
// is the tessellation pipeline's input and reaches the geometry stage already
// converted, so it is not constrained here.
const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw();
const GLenum gsInput = currentProgram ? currentProgram->GetGeometryInputType() : GL_NONE;
if (gsInput != GL_NONE && mode != GL_PATCHES) {
//
// "Is there a geometry stage at all" has to be asked of the STAGE, never of the input
// primitive: GL_NONE and GL_POINTS are both 0, so a `layout(points) in` geometry shader
// is indistinguishable from no geometry shader by its reflected input type alone. The
// sentinel test this replaces therefore skipped the whole rule for exactly the geometry
// shaders whose input is the most restrictive one - every mode but GL_POINTS was
// accepted (KHR-GL43.transform_feedback.api_errors_test draws a points-in geometry
// program with GL_LINES and requires INVALID_OPERATION).
//
// And it has to be asked of the LAST LINK: gsInputPrimitive is a link artifact, so
// pairing it with the live attach list would re-point the very same 0-aliasing rather
// than remove it. In the window after glAttachShader(GS) on a linked program the live
// list says "geometry present" while the artifact still reads GL_NONE == GL_POINTS, and
// the switch below would silently reject every mode but GL_POINTS.
const Bool geometryActive = currentProgram && currentProgram->HasLinkedShaderStage(ShaderStage::Geometry);
const GLenum gsInput = geometryActive ? currentProgram->GetGeometryInputType() : GL_NONE;
if (geometryActive && mode != GL_PATCHES) {
Bool compatible = false;
switch (gsInput) {
case GL_POINTS:
@@ -239,13 +288,20 @@ namespace MobileGL::MG_Impl::GLImpl {
// While transform feedback is active the draw's primitive type must match
// the feedback primitive mode (GL 3.3 core 13.2.2). With a geometry shader
// the constraint moves to the shader's output primitive type instead, so
// the draw mode itself is unconstrained here. A paused span is exempt: it
// captures nothing, so there is nothing for the mode to be incompatible with
// (GL 4.6 core 13.2.3).
// the draw mode itself is unconstrained here - and a TESSELLATION EVALUATION
// stage relocates it exactly the same way (GL 4.6 core 13.2.2 names both):
// what is captured is the tessellator's output primitive, and the draw mode
// can only ever be GL_PATCHES. A paused span is exempt: it captures nothing,
// so there is nothing for the mode to be incompatible with (GL 4.6 core 13.2.3).
const auto& feedbackProgram = MG_State::pGLContext->GetTransformFeedbackProgram();
// Both stage tests are asked of the last link, for the same reason as the two guards
// above: what relocates the constraint is a stage the program actually RUNS, and an
// attach that has not been linked in yet gives it none.
const Bool feedbackModeIsProgramDriven =
feedbackProgram && (feedbackProgram->HasLinkedShaderStage(ShaderStage::Geometry) ||
feedbackProgram->HasLinkedShaderStage(ShaderStage::TessEval));
if (MG_State::pGLContext->IsTransformFeedbackActive() &&
!MG_State::pGLContext->IsTransformFeedbackPaused() &&
!(MG_State::pGLContext->GetTransformFeedbackProgram() &&
MG_State::pGLContext->GetTransformFeedbackProgram()->GetShaderIndexByStage(ShaderStage::Geometry) >= 0)) {
!MG_State::pGLContext->IsTransformFeedbackPaused() && !feedbackModeIsProgramDriven) {
const GLenum feedbackMode = MG_State::pGLContext->GetTransformFeedbackPrimitiveMode();
Bool compatible = false;
switch (feedbackMode) {
@@ -326,10 +382,23 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
// GL 4.6 core 10.9: inside a conditional block whose predicate did not pass, the drawing
// commands, Clear, ClearBuffer* and the compute dispatches are DISCARDED. The gate sits on the
// wrappers that ISSUE the backend call rather than at the top of each entry point, so that
// everything a real driver would still do inside the block - argument validation and the
// errors it raises - happens exactly as it does outside one, and only the command itself is
// dropped. It is deliberately not on the frontend's transform-feedback accounting either:
// that mirrors what the capture stage would have written, and a conditional block around a
// capturing draw has no test coverage in either direction.
static Bool ConditionalRenderDiscardsCommand() {
return MG_State::pGLContext->ConditionalRenderDiscardsCommands();
}
void Clear_Backend(GLbitfield mask) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.Clear(mask);
}
@@ -337,6 +406,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawElements(mode, count, type, indices);
}
@@ -345,6 +415,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.MultiDrawElements(mode, count, type, indices, drawcount);
}
@@ -353,6 +424,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsBaseVertex(mode, count, type, indices, drawcount,
basevertex);
}
@@ -361,6 +433,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawArrays(mode, first, count);
}
@@ -368,6 +441,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.MultiDrawArrays(mode, first, count, drawcount);
}
@@ -376,6 +450,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawElementsBaseVertex(mode, count, type, indices, basevertex);
}
@@ -384,6 +459,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsIndirect(mode, type, indirect, drawcount, stride);
}
@@ -391,6 +467,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.MultiDrawArraysIndirect(mode, indirect, drawcount, stride);
}
@@ -399,6 +476,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsIndirectCount(mode, type, indirect, drawcount,
maxdrawcount, stride);
}
@@ -408,6 +486,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.MultiDrawArraysIndirectCount(mode, indirect, drawcount, maxdrawcount,
stride);
}
@@ -417,6 +496,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawRangeElementsBaseVertex(mode, start, end, count, type, indices,
basevertex);
}
@@ -426,6 +506,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawRangeElements(mode, start, end, count, type, indices);
}
@@ -435,6 +516,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseVertexBaseInstance(
mode, count, type, indices, instancecount, basevertex, baseinstance);
}
@@ -444,6 +526,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseVertex(mode, count, type, indices, instancecount,
basevertex);
}
@@ -453,6 +536,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseInstance(mode, count, type, indices,
instancecount, baseinstance);
}
@@ -462,6 +546,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstanced(mode, count, type, indices, instancecount);
}
@@ -469,6 +554,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawElementsIndirect(mode, type, indirect);
}
void DrawArraysInstancedBaseInstance_Backend(GLenum mode, GLint first, GLsizei count, GLsizei instancecount,
@@ -476,6 +562,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawArraysInstancedBaseInstance(mode, first, count, instancecount,
baseinstance);
}
@@ -484,6 +571,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawArraysInstanced(mode, first, count, instancecount);
}
@@ -491,6 +579,7 @@ namespace MobileGL::MG_Impl::GLImpl {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MG_Backend::gBackendFunctionsTable.GL.DrawArraysIndirect(mode, indirect);
}
@@ -519,6 +608,9 @@ namespace MobileGL::MG_Impl::GLImpl {
return;
}
}
// GL 4.3 added both dispatches to the conditional-render set (GL 4.6 core 10.9), which is
// exactly what KHR-GL43.compute_shader.conditional-dispatching checks.
if (ConditionalRenderDiscardsCommand()) return;
dispatchCompute(numGroupsX, numGroupsY, numGroupsZ);
}
@@ -570,6 +662,7 @@ namespace MobileGL::MG_Impl::GLImpl {
return;
}
if (!ValidateCurrentProgramForCompute(__func__)) return;
if (ConditionalRenderDiscardsCommand()) return;
dispatchComputeIndirect(indirect);
}
@@ -725,8 +725,8 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, LoadName, GLuint name) DECLARE_GL_FUNCTION_S
DECLARE_GL_FUNCTION_STUB_HEAD(void, PushName, GLuint name) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, PushName, name)
DECLARE_GL_FUNCTION_STUB_HEAD(void, PopName) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, PopName)
DECLARE_GL_FUNCTION_HEAD(void, ClampColor, GLenum target, GLenum clamp) DECLARE_GL_FUNCTION_END_NO_RETURN(void, ClampColor, target, clamp)
DECLARE_GL_FUNCTION_STUB_HEAD(void, BeginConditionalRender, GLuint id, GLenum mode) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, BeginConditionalRender, id, mode)
DECLARE_GL_FUNCTION_STUB_HEAD(void, EndConditionalRender, void) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, EndConditionalRender)
DECLARE_GL_FUNCTION_HEAD(void, BeginConditionalRender, GLuint id, GLenum mode) DECLARE_GL_FUNCTION_END_NO_RETURN(void, BeginConditionalRender, id, mode)
DECLARE_GL_FUNCTION_HEAD(void, EndConditionalRender) DECLARE_GL_FUNCTION_END_NO_RETURN(void, EndConditionalRender)
DECLARE_GL_FUNCTION_HEAD(void, VertexAttribI1i, GLuint index, GLint x) DECLARE_GL_FUNCTION_END_NO_RETURN(void, VertexAttribI1i, index, x)
DECLARE_GL_FUNCTION_HEAD(void, VertexAttribI2i, GLuint index, GLint x, GLint y) DECLARE_GL_FUNCTION_END_NO_RETURN(void, VertexAttribI2i, index, x, y)
DECLARE_GL_FUNCTION_HEAD(void, VertexAttribI3i, GLuint index, GLint x, GLint y, GLint z) DECLARE_GL_FUNCTION_END_NO_RETURN(void, VertexAttribI3i, index, x, y, z)
@@ -982,7 +982,7 @@ DECLARE_GL_FUNCTION_HEAD(void, GetDoublei_v, GLenum target, GLuint index, GLdoub
DECLARE_GL_FUNCTION_HEAD(void, DrawArraysInstancedBaseInstance, GLenum mode, GLint first, GLsizei count, GLsizei instancecount, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawArraysInstancedBaseInstance, mode, first, count, instancecount, baseinstance)
DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseInstance, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseInstance, mode, count, type, indices, instancecount, baseinstance)
DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseVertexBaseInstance, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLint basevertex, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseVertexBaseInstance, mode, count, type, indices, instancecount, basevertex, baseinstance)
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetActiveAtomicCounterBufferiv, GLuint program, GLuint bufferIndex, GLenum pname, GLint* params) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetActiveAtomicCounterBufferiv, program, bufferIndex, pname, params)
DECLARE_GL_FUNCTION_HEAD(void, GetActiveAtomicCounterBufferiv, GLuint program, GLuint bufferIndex, GLenum pname, GLint* params) DECLARE_GL_FUNCTION_END_NO_RETURN(void, GetActiveAtomicCounterBufferiv, program, bufferIndex, pname, params)
DECLARE_GL_FUNCTION_HEAD(void, DrawTransformFeedbackInstanced, GLenum mode, GLuint id, GLsizei instancecount) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawTransformFeedbackInstanced, mode, id, instancecount)
DECLARE_GL_FUNCTION_HEAD(void, DrawTransformFeedbackStreamInstanced, GLenum mode, GLuint id, GLuint stream, GLsizei instancecount) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawTransformFeedbackStreamInstanced, mode, id, stream, instancecount)
DECLARE_GL_FUNCTION_HEAD(void, ClearBufferData, GLenum target, GLenum internalformat, GLenum format, GLenum type, const void* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, ClearBufferData, target, internalformat, format, type, data)
@@ -2613,18 +2613,26 @@ namespace MobileGL::MG_Impl::GLImpl {
}
void ClearBufferfi_Backend(GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil) {
// GL 4.6 core 10.9 makes ClearBuffer* conditional alongside the drawing commands.
if (MG_State::pGLContext->ConditionalRenderDiscardsCommands()) return;
MG_Backend::gBackendFunctionsTable.GL.ClearBufferfi(buffer, drawbuffer, depth, stencil);
}
void ClearBufferfv_Backend(GLenum buffer, GLint drawbuffer, const GLfloat* value) {
// GL 4.6 core 10.9 makes ClearBuffer* conditional alongside the drawing commands.
if (MG_State::pGLContext->ConditionalRenderDiscardsCommands()) return;
MG_Backend::gBackendFunctionsTable.GL.ClearBufferfv(buffer, drawbuffer, value);
}
void ClearBufferuiv_Backend(GLenum buffer, GLint drawbuffer, const GLuint* value) {
// GL 4.6 core 10.9 makes ClearBuffer* conditional alongside the drawing commands.
if (MG_State::pGLContext->ConditionalRenderDiscardsCommands()) return;
MG_Backend::gBackendFunctionsTable.GL.ClearBufferuiv(buffer, drawbuffer, value);
}
void ClearBufferiv_Backend(GLenum buffer, GLint drawbuffer, const GLint* value) {
// GL 4.6 core 10.9 makes ClearBuffer* conditional alongside the drawing commands.
if (MG_State::pGLContext->ConditionalRenderDiscardsCommands()) return;
MG_Backend::gBackendFunctionsTable.GL.ClearBufferiv(buffer, drawbuffer, value);
}
+95 -25
View File
@@ -25,6 +25,7 @@
#include <MG_State/GLState/FramebufferState/FramebufferObject.h>
#include <MG_Util/Texture/TextureFormatProcessor.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <MG_Backend/BackendObjects.h>
namespace MobileGL::MG_Impl::GLImpl {
@@ -46,13 +47,29 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
constexpr GLint kFrontendMaxComputeUniformComponents = 1024;
constexpr GLint kFrontendMaxComputeAtomicCounters = 8;
constexpr GLint kFrontendMaxComputeAtomicCounterBuffers = 8;
// Shared with the glslang resource table for the same reason as the atomic-counter
// limits below: gl_MaxComputeUniformComponents expands from BuildTBuiltInResource.
constexpr GLint kFrontendMaxComputeUniformComponents =
static_cast<GLint>(MG_Util::ShaderTranspiler::MAX_COMPUTE_UNIFORM_COMPONENTS);
// Every atomic-counter limit is shared with the glslang resource table
// (BuildTBuiltInResource) through MG_Util/ShaderTranspiler/Types.h: GL 4.6 requires
// glGetIntegerv and the gl_MaxAtomicCounter* built-in constants to agree, and the two
// used to be independent tables that disagreed on both the binding count and the buffer
// size. Never move one of these without the other.
constexpr GLint kFrontendMaxComputeAtomicCounters =
static_cast<GLint>(MG_Util::ShaderTranspiler::MAX_ATOMIC_COUNTERS_PER_STAGE);
constexpr GLint kFrontendMaxComputeAtomicCounterBuffers =
static_cast<GLint>(MG_Util::ShaderTranspiler::MAX_ATOMIC_COUNTER_BUFFERS_PER_STAGE);
constexpr GLint kFrontendMaxComputeSharedMemorySize = 32768;
constexpr GLint kFrontendMaxComputeWorkGroupInvocations = 1024;
constexpr GLint kFrontendMaxCombinedAtomicCounters = 8;
constexpr GLint kFrontendMaxFragmentAtomicCounters = 8;
constexpr GLint kFrontendMaxCombinedAtomicCounters =
static_cast<GLint>(MG_Util::ShaderTranspiler::MAX_ATOMIC_COUNTERS_PER_STAGE);
constexpr GLint kFrontendMaxCombinedAtomicCounterBuffers =
static_cast<GLint>(MG_Util::ShaderTranspiler::MAX_ATOMIC_COUNTER_BUFFERS_PER_STAGE);
constexpr GLint kFrontendMaxFragmentAtomicCounters =
static_cast<GLint>(MG_Util::ShaderTranspiler::MAX_ATOMIC_COUNTERS_PER_STAGE);
constexpr GLint kFrontendMaxFragmentAtomicCounterBuffers =
static_cast<GLint>(MG_Util::ShaderTranspiler::MAX_ATOMIC_COUNTER_BUFFERS_PER_STAGE);
constexpr GLint kFrontendMaxGeometryAtomicCounters = 0;
constexpr GLint kFrontendMaxTessControlAtomicCounters = 0;
constexpr GLint kFrontendMaxTessEvaluationAtomicCounters = 0;
@@ -66,10 +83,11 @@ namespace MobileGL::MG_Impl::GLImpl {
constexpr GLint kFrontendMaxTessControlAtomicCounterBuffers = 0;
constexpr GLint kFrontendMaxTessEvaluationAtomicCounterBuffers = 0;
constexpr GLint kFrontendMaxVertexAtomicCounterBuffers = 0;
// One atomic counter is a uint, and a buffer never has to hold more counters than the
// combined limit the frontend advertises. GL 4.6 table 23.63 floors this at 32 bytes.
// GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE: the byte offset ceiling a counter may be declared
// at. The matching binding count is applied in GetIndexedBufferQueryPointCount, so that
// the getter, the indexed queries and glBindBufferBase all share one ceiling.
constexpr GLint kFrontendMaxAtomicCounterBufferSize =
kFrontendMaxCombinedAtomicCounters * static_cast<GLint>(sizeof(GLuint));
static_cast<GLint>(MG_Util::ShaderTranspiler::MAX_ATOMIC_COUNTER_BUFFER_SIZE);
// KHR_debug minima (GL 4.6 table 23.66); the debug entry points are stubs, but the
// limits they advertise still have to be legal.
constexpr GLint kFrontendMaxDebugGroupStackDepth = 64;
@@ -103,12 +121,16 @@ namespace MobileGL::MG_Impl::GLImpl {
constexpr GLint kFrontendSubpixelBits = 4;
constexpr GLint kFrontendMaxSamples = 4;
// The floors under GL_MAX_COMPUTE_WORK_GROUP_COUNT / _SIZE. Shared with the compile
// pipeline (CaptureCompileEnv floors the same driver answers at them, and
// BuildTBuiltInResource expands gl_MaxComputeWorkGroup* from the result), because a
// shader is allowed to compare the built-in constant against this query.
constexpr GLint GetMinComputeWorkGroupCount(GLuint index) {
return index < 3 ? 65535 : 0;
return index < 3 ? static_cast<GLint>(MG_Util::ShaderTranspiler::MIN_COMPUTE_WORK_GROUP_COUNT[index]) : 0;
}
constexpr GLint GetMinComputeWorkGroupSize(GLuint index) {
return index < 2 ? 1024 : (index == 2 ? 64 : 0);
return index < 3 ? static_cast<GLint>(MG_Util::ShaderTranspiler::MIN_COMPUTE_WORK_GROUP_SIZE[index]) : 0;
}
GLint GetMaxCombinedUniformComponents(GLint maxDefaultUniformComponents, GLint maxUniformBlocks,
@@ -186,6 +208,16 @@ namespace MobileGL::MG_Impl::GLImpl {
MG_Backend::pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings;
return std::min(frontendCount, static_cast<SizeT>(std::max(backendCount, 0)));
}
if (bufferTarget == BufferTarget::AtomicCounter) {
// The counter family's binding count is NOT the state layer's array size: a
// counter buffer only reaches a shader as a lowered storage block, so what an
// implementation can serve is the reserved range, and that number is also what
// glslang compiles a layout(binding = N) atomic_uint against. Clamped here so
// GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS, the indexed getters' index check and
// glBindBufferBase's all report the same ceiling.
return std::min(frontendCount,
static_cast<SizeT>(MG_Util::ShaderTranspiler::MAX_ATOMIC_COUNTER_BUFFER_BINDINGS));
}
return frontendCount;
}
@@ -1540,15 +1572,15 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_LINE_WIDTH:
*params = static_cast<GLint>(MG_State::pGLContext->GetLineWidth());
return;
case GL_LAYER_PROVOKING_VERTEX:
*params = GL_LAST_VERTEX_CONVENTION;
return;
case GL_LOGIC_OP_MODE:
*params = static_cast<GLint>(MG_Util::ConvertLogicOperationToGLEnum(MG_State::pGLContext->GetLogicOp()));
return;
case GL_MAX_COMBINED_ATOMIC_COUNTERS:
*params = kFrontendMaxCombinedAtomicCounters;
return;
case GL_MAX_COMBINED_ATOMIC_COUNTER_BUFFERS:
*params = kFrontendMaxCombinedAtomicCounterBuffers;
return;
case GL_MAX_COMBINED_UNIFORM_BLOCKS:
*params = ClampUniformBlockCount(kFrontendMaxCombinedUniformBlocks);
return;
@@ -1564,6 +1596,9 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_MAX_FRAGMENT_ATOMIC_COUNTERS:
*params = kFrontendMaxFragmentAtomicCounters;
return;
case GL_MAX_FRAGMENT_ATOMIC_COUNTER_BUFFERS:
*params = kFrontendMaxFragmentAtomicCounterBuffers;
return;
case GL_MAX_FRAGMENT_SHADER_STORAGE_BLOCKS:
*params = StageStorageBlockCount(&MG_Backend::DynamicBackendParameters::MaxFragmentShaderStorageBlocks);
return;
@@ -1626,7 +1661,11 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::Multisample) ? GL_TRUE : GL_FALSE;
return;
case GL_MIN_MAP_BUFFER_ALIGNMENT:
*params = 64; // TODO
// The same constant the map paths align to (MG_State/GLState/BufferState/
// PipeResource.h), never a literal: this number is a PROMISE about the pointers
// glMapBuffer and glMapBufferRange return, and the two used to be unrelated - the
// query said 64 while the pointers came out of a std::vector aligned to 16.
*params = static_cast<GLint>(MG_State::GLState::MIN_MAP_BUFFER_ALIGNMENT);
return;
case GL_MAX_LABEL_LENGTH:
*params = 256; // TODO
@@ -1672,7 +1711,8 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = 15; // TODO
return;
case GL_MAX_UNIFORM_LOCATIONS:
*params = 1024 * 4; // TODO
// The same constant the link's location allocator enforces - see ProgramObject.
*params = MG_State::GLState::ProgramObject::MAX_UNIFORM_LOCATIONS;
return;
case GL_MAX_VARYING_COMPONENTS:
*params = kFrontendMaxVaryingComponents;
@@ -2002,6 +2042,24 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_UNIFORM_BUFFER_START:
RecordIndexedOnlyGetterError(__func__, pname);
return;
// glBindBufferBase/Range set the GENERIC binding point too (GL 4.6 core 6.1.1), and this
// is the one indexed-buffer family whose non-indexed query was never answered - so it
// fell through to INVALID_ENUM and left the caller's variable holding whatever was in its
// stack slot. _START/_SIZE stay indexed-only, exactly like their uniform-buffer siblings.
case GL_ATOMIC_COUNTER_BUFFER_BINDING:
if (const auto& obj =
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::AtomicCounter).GetBoundObject()) {
*params = static_cast<GLint>(obj->GetExternalIndex());
} else {
*params = 0;
}
return;
case GL_ATOMIC_COUNTER_BUFFER_START:
RecordIndexedOnlyGetterError(__func__, pname);
return;
case GL_ATOMIC_COUNTER_BUFFER_SIZE:
RecordIndexedOnlyGetterError(__func__, pname);
return;
case GL_UNPACK_ALIGNMENT:
*params = MG_State::pGLContext->GetPixelStoreParam(PixelStoreParam::UnpackAlignment);
return;
@@ -2056,9 +2114,6 @@ namespace MobileGL::MG_Impl::GLImpl {
params[3] = vp.w();
return;
}
case GL_VIEWPORT_INDEX_PROVOKING_VERTEX:
*params = GL_LAST_VERTEX_CONVENTION;
return;
case GL_MAX_ELEMENT_INDEX:
*params = 1024 * 1024; // TODO
return;
@@ -2146,6 +2201,20 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_MAX_CLIP_DISTANCES:
*params = dynamicParameters.MaxClipDistances;
break;
// Both were a hard-coded GL_LAST_VERTEX_CONVENTION, derived from nothing. GL 4.6 table
// 23.65 permits GL_UNDEFINED_VERTEX for either, and that is what the backends report
// wherever they do not actually pin a convention - claiming one is a statement about
// which vertex of a primitive supplies gl_Layer / gl_ViewportIndex, and DirectGLES
// rasterizes only viewport 0 on a driver without GL_OES_viewport_array while
// DirectVulkan picks its provoking mode per pipeline. KHR-GLxx.viewport_array.query
// accepts all four values, and .provoking_vertex - which failed on both devices, in
// OPPOSITE directions - stops verifying as soon as either answer is undefined.
case GL_LAYER_PROVOKING_VERTEX:
*params = static_cast<GLint>(dynamicParameters.LayerProvokingVertex);
break;
case GL_VIEWPORT_INDEX_PROVOKING_VERTEX:
*params = static_cast<GLint>(dynamicParameters.ViewportIndexProvokingVertex);
break;
case GL_MAX_COLOR_TEXTURE_SAMPLES:
*params = std::max(dynamicParameters.MaxColorTextureSamples, GetAdvertisedMaxSamples());
break;
@@ -2237,18 +2306,19 @@ namespace MobileGL::MG_Impl::GLImpl {
static_cast<Uint64>(INT32_MAX)));
break;
case GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS:
// NOT the frontend's binding-point array size: GetIndexedBufferQueryPointCount
// clamps this family to the range a lowered counter block can actually be served
// from, which is the same number glslang compiles a layout(binding = N) atomic_uint
// against and the same one glBindBufferBase validates an index against.
*params = static_cast<GLint>(GetIndexedBufferQueryPointCount(BufferTarget::AtomicCounter));
break;
case GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE:
// The conformance suite splits this evenly across every advertised binding point and
// binds all of them in one glBindBuffersRange
// (KHR-GL44.multi_bind.functional_bind_buffers_range), so the pair has to divide:
// 32 bytes over 36 binding points is a zero-sized range, which BindBufferRange
// rejects with INVALID_VALUE before it binds anything. Floor the advertised size at
// one counter per binding point.
*params = std::max<GLint>(
kFrontendMaxAtomicCounterBufferSize,
static_cast<GLint>(GetIndexedBufferQueryPointCount(BufferTarget::AtomicCounter) * sizeof(GLuint)));
// (KHR-GL44.multi_bind.functional_bind_buffers_range), so the pair has to divide -
// a zero-sized range is INVALID_VALUE before BindBufferRange binds anything. The
// shared constant is 16384 over 8 binding points, which divides.
*params = kFrontendMaxAtomicCounterBufferSize;
break;
case GL_MAX_TEXTURE_BUFFER_SIZE:
*params = dynamicParameters.MaxTextureBufferSize;
+274 -68
View File
@@ -21,6 +21,9 @@
#include <MG_Backend/BackendObjects.h>
namespace MobileGL::MG_Impl::GLImpl {
// The flattened uniform type these helpers used to take as a raw glslang::TType*
// pointing into the TProgram's pool allocator. See ProgramObject::TypeFacts.
using TypeFactsRef = const MG_State::GLState::ProgramObject::TypeFacts&;
static GLint BoolToGLInt(bool value) {
return value ? GL_TRUE : GL_FALSE;
}
@@ -223,14 +226,14 @@ namespace MobileGL::MG_Impl::GLImpl {
return false;
}
GLint GetOpaqueUniformUnitLimit(const glslang::TType* type) {
GLint GetOpaqueUniformUnitLimit(const TypeFactsRef type) {
const auto& dynamicParameters = MG_Backend::pActiveBackendObject->GetDynamicParameters();
if (type && type->isImage()) return dynamicParameters.MaxImageUnits;
if (type && type->isTexture()) return dynamicParameters.MaxCombinedTextureImageUnits;
if (type.isImage) return dynamicParameters.MaxImageUnits;
if (type.isTexture) return dynamicParameters.MaxCombinedTextureImageUnits;
return 0;
}
bool ValidateOpaqueUniformUnit(const char* functionName, const glslang::TType* type, GLint unit) {
bool ValidateOpaqueUniformUnit(const char* functionName, const TypeFactsRef type, GLint unit) {
const GLint limit = GetOpaqueUniformUnitLimit(type);
if (unit < 0 || unit >= limit) {
MG_State::pGLContext->RecordError(
@@ -525,6 +528,10 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_UNIFORM_ARRAY_STRIDE:
case GL_UNIFORM_MATRIX_STRIDE:
case GL_UNIFORM_IS_ROW_MAJOR:
// GL 4.2 / ARB_shader_atomic_counters adds this one to the accepted set. Leaving it
// out did not merely lose the answer: the leftover GL_INVALID_ENUM is what made
// KHR-GL43.shader_atomic_counters.basic-program-query force a FAIL.
case GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX:
break;
default:
MG_State::pGLContext->RecordError(
@@ -580,6 +587,11 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_UNIFORM_IS_ROW_MAJOR:
params[i] = programObject->GetActiveUniformIsRowMajor(idx);
break;
case GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX:
// Index into the GL_ACTIVE_ATOMIC_COUNTER_BUFFERS list, -1 for every uniform
// that is not an atomic counter (GL 4.6 core table 7.6).
params[i] = programObject->GetActiveUniformAtomicCounterBufferIndex(idx);
break;
default:
break;
}
@@ -642,7 +654,13 @@ namespace MobileGL::MG_Impl::GLImpl {
break;
}
case GL_ACTIVE_ATOMIC_COUNTER_BUFFERS:
*params = programObject->GetActiveAtomicCounterCount();
// Counter BUFFERS, not counters, and glslang's own getNumAtomicCounters() answers
// neither: the relaxed parse has already turned every atomic_uint into a plain uint
// member of a synthesized storage block by the time it builds its reflection, so it
// reports zero. The interface-query model recovers the buffers from those blocks and
// is what glGetProgramInterfaceiv(GL_ATOMIC_COUNTER_BUFFER, GL_ACTIVE_RESOURCES)
// already answers - the two queries are required to agree.
*params = ProgramInterface::GetActiveResourceCount(*programObject, GL_ATOMIC_COUNTER_BUFFER);
MGLOG_D("%s: %s = %d", __func__, MG_Util::ConvertGLEnumToString(pname).c_str(), *params);
break;
case GL_ACTIVE_ATTRIBUTES:
@@ -662,7 +680,9 @@ namespace MobileGL::MG_Impl::GLImpl {
MGLOG_D("%s: %s = %d", __func__, MG_Util::ConvertGLEnumToString(pname).c_str(), *params);
break;
case GL_ACTIVE_UNIFORM_BLOCKS: // GL >= 3.1
*params = programObject->GetActiveUniformBlocksCount();
// Uniform blocks only. GetActiveUniformBlocksCount() is the internal block space,
// which also carries the storage blocks and the synthesized atomic counter blocks.
*params = programObject->GetGlUniformBlockCount();
MGLOG_D("%s: %s = %d", __func__, MG_Util::ConvertGLEnumToString(pname).c_str(), *params);
break;
case GL_ACTIVE_UNIFORM_BLOCK_MAX_NAME_LENGTH: // ditto.
@@ -682,7 +702,11 @@ namespace MobileGL::MG_Impl::GLImpl {
MGLOG_D("%s: %s = %d", __func__, MG_Util::ConvertGLEnumToString(pname).c_str(), *params);
break;
case GL_COMPUTE_WORK_GROUP_SIZE: { // GL >= 4.3
if (!programObject->GetLinkStatus() || programObject->GetShaderIndexByStage(ShaderStage::Compute) < 0) {
// "a linked program object with a compute shader" is one whose EXECUTABLE has the
// stage: the local size below is a link artifact, so an attached-but-not-yet-linked
// compute shader would answer this query with the previous link's (absent) value
// instead of the INVALID_OPERATION GL 4.6 core 7.13 asks for.
if (!programObject->GetLinkStatus() || !programObject->HasLinkedShaderStage(ShaderStage::Compute)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
@@ -856,10 +880,14 @@ namespace MobileGL::MG_Impl::GLImpl {
// demotion makes a dmat4 a mat4 in the shader and a mat4-shaped slot here - but because it
// is ROUTED differently: the caller's component-by-component EbtDouble branch has to widen
// each float back to the queried type, and it undoes the same padding itself.
Bool TryGatherFloatMatrixColumns(const glslang::TType* ttype, const char* pBase, void* params) {
if (ttype == nullptr || !ttype->isMatrix() || ttype->getBasicType() == glslang::EbtDouble) return false;
const Int columns = ttype->getMatrixCols();
const Int rows = ttype->getMatrixRows();
// Float matrices only, in both senses: a DOUBLE matrix never comes through here, whether its
// program was demoted (components are floats, the query is not) or kept its doubles (the
// column stride is a dvec4's, and the caller's converting branch already walks it component
// by component with the right one).
Bool TryGatherFloatMatrixColumns(const TypeFactsRef ttype, const char* pBase, void* params) {
if (!ttype.isMatrix || ttype.isDouble) return false;
const Int columns = ttype.matrixCols;
const Int rows = ttype.matrixRows;
for (Int column = 0; column < columns; ++column) {
Memcpy(static_cast<char*>(params) + static_cast<SizeT>(column) * rows * sizeof(GLfloat),
pBase + static_cast<SizeT>(column) * 4 * sizeof(GLfloat), rows * sizeof(GLfloat));
@@ -868,11 +896,12 @@ namespace MobileGL::MG_Impl::GLImpl {
}
// Bytes a uniform actually occupies in the global UBO. It is the tight GL type size for
// everything except a float matrix, whose padded columns make it wider. 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) {
return MG_State::GLState::ProgramObject::UniformStorageSpanInBytes(ttype, tightSize);
// everything except a matrix, whose padded columns make it wider, and a `double` on a
// program whose modules were demoted, where it is half. 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 TypeFactsRef ttype, SizeT tightSize, const Bool nativeFloat64) {
return MG_State::GLState::ProgramObject::UniformStorageSpanInBytes(ttype, tightSize, nativeFloat64);
}
void GetUniform_State(GLuint program, GLint location, void* params) {
@@ -904,8 +933,9 @@ namespace MobileGL::MG_Impl::GLImpl {
auto offset = programObject->GetUniformOffset(location);
auto size = programObject->GetUniformSizesInBytes(location);
char* pUBO = (char*)programObject->MapUBO();
auto* ttype = programObject->GetUniformTType(location);
const SizeT span = UniformStorageSpanInBytes(ttype, size);
const auto& ttype = programObject->GetUniformTypeFacts(location);
const Bool nativeFloat64 = programObject->UsesNativeFloat64();
const SizeT span = UniformStorageSpanInBytes(ttype, size, nativeFloat64);
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
offset + span > programObject->GetUBOSize()) {
MGLOG_E_ONCE("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
@@ -915,9 +945,9 @@ namespace MobileGL::MG_Impl::GLImpl {
if (!TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) {
// Never more than the uniform actually occupies. `size` is the GL type size,
// which for a `double` uniform is twice its storage - every 64-bit float is
// narrowed before the module reaches a backend, so the slot holds floats. The
// typed entry points (glGetUniformdv and friends) go through
// which on a DEMOTED program is twice a `double` uniform's storage - its 64-bit
// floats were narrowed before the module reached a backend, so the slot holds
// floats. The typed entry points (glGetUniformdv and friends) go through
// GetUniformScalar_State, which converts component by component; this raw
// copy has no type to convert with, so it is bounded rather than converted.
Memcpy(params, pUBO + offset, std::min<SizeT>(size, span));
@@ -958,8 +988,9 @@ namespace MobileGL::MG_Impl::GLImpl {
auto offset = programObject->GetUniformOffset(location);
auto size = programObject->GetUniformSizesInBytes(location);
char* pUBO = static_cast<char*>(programObject->MapUBO());
auto* ttype = programObject->GetUniformTType(location);
const SizeT span = UniformStorageSpanInBytes(ttype, size);
const auto& ttype = programObject->GetUniformTypeFacts(location);
const Bool nativeFloat64 = programObject->UsesNativeFloat64();
const SizeT span = UniformStorageSpanInBytes(ttype, size, nativeFloat64);
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
offset + span > programObject->GetUBOSize()) {
MGLOG_E_ONCE("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
@@ -971,28 +1002,38 @@ namespace MobileGL::MG_Impl::GLImpl {
if (TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) return;
}
// A double-precision uniform is the one case where the stored component type differs
// from the DECLARED one for a non-opaque uniform: the shader's 64-bit floats are
// narrowed to 32 bits before the module reaches a backend
// A double-precision uniform is the one case where the stored component type can differ
// from the DECLARED one for a non-opaque uniform: on a DEMOTED program the shader's
// 64-bit floats were narrowed to 32 before the module reached the backend
// (ShaderTranspiler::DemoteFloat64Pass), so what is in the global UBO is a float per
// component, laid out exactly like the float-typed twin of this uniform - std140
// 16-byte column stride for a matrix included. Reading it as a GLdouble would return
// two components reinterpreted as one. Read component by component and let GL's
// two components reinterpreted as one. A program that KEPT its doubles stores real ones
// at the dvec4 column stride instead, so the width and the stride both move; everything
// else about this walk is the same. Read component by component either way and let GL's
// conversion rules (7.6: round to nearest for the integer queries) apply; the value
// widens back to the queried type, having lost precision at the glUniform*d that
// stored it and not here.
if (ttype->getBasicType() == glslang::EbtDouble) {
const Int columns = ttype->isMatrix() ? ttype->getMatrixCols() : 1;
const Int rows = ttype->isMatrix() ? ttype->getMatrixRows()
: (ttype->isVector() ? ttype->getVectorSize() : 1);
// std140 gives every matrix column its own 16-byte slot; a non-matrix is one
// tightly packed run and never reaches the stride at all.
const SizeT columnStride = 4 * sizeof(GLfloat);
// widens back to the queried type, having lost precision - where it lost any - at the
// glUniform*d that stored it and not here.
if (ttype.isDouble) {
const Int columns = ttype.isMatrix ? ttype.matrixCols : 1;
const Int rows = ttype.isMatrix ? ttype.matrixRows
: (ttype.isVector ? ttype.vectorSize : 1);
// A non-matrix is one tightly packed run and never reaches the stride at all.
const SizeT columnStride =
MG_State::GLState::ProgramObject::UniformMatrixColumnStride(ttype, nativeFloat64);
const SizeT componentSize = nativeFloat64 ? sizeof(GLdouble) : sizeof(GLfloat);
for (Int column = 0; column < columns; ++column) {
for (Int row = 0; row < rows; ++row) {
GLfloat component = 0.0f;
Memcpy(&component, pUBO + offset + column * columnStride + row * sizeof(GLfloat),
sizeof(component));
GLdouble component = 0.0;
if (nativeFloat64) {
Memcpy(&component, pUBO + offset + column * columnStride + row * componentSize,
sizeof(GLdouble));
} else {
GLfloat narrow = 0.0f;
Memcpy(&narrow, pUBO + offset + column * columnStride + row * componentSize,
sizeof(narrow));
component = static_cast<GLdouble>(narrow);
}
if constexpr (std::is_integral_v<T>) {
// Rounded to the nearest integer and clamped into the queried type's
// range, so a negative double read through glGetUniformuiv is 0
@@ -1191,8 +1232,8 @@ namespace MobileGL::MG_Impl::GLImpl {
Memcpy(pUBO + offset + byteOffsetInsideUniform, value, writeSize);
programObject.MarkUBOContentDirty();
} else {
auto* ttype = programObject.GetUniformTType(location);
if (!ttype->isTexture() && !ttype->isImage()) return;
const auto& ttype = programObject.GetUniformTypeFacts(location);
if (!ttype.isTexture && !ttype.isImage) return;
if constexpr (!std::is_same_v<std::remove_cv_t<T>, GLint> || ItemCount != 1) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
@@ -1263,17 +1304,45 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
// glUniform*d / glUniformMatrix*dv. Neither needs a layout of its own any more: the
// transpile chain narrows every 64-bit float in the shader to 32 bits
// Whether the program a uniform write is about to land in stores 64-bit floats at their
// declared width. Answered off the PROGRAM, never off the live backend: it describes the
// modules that were actually built for it, and a backend with native fp64 still demotes a
// program whose vertex stage declares a Float64 input (see ProgramSpirvTask::GenerateSpirv).
// Nullptr - no current program, or a name that is not a program - answers false and lets the
// callee record the same error it always did.
Bool CurrentProgramUsesNativeFloat64() {
if (MG_State::pGLContext == nullptr) return false;
const auto& programObject = MG_State::pGLContext->GetProgramForUniform();
return programObject != nullptr && programObject->UsesNativeFloat64();
}
Bool NamedProgramUsesNativeFloat64(GLuint program) {
const auto& programObject = TryToGetProgramObject(program);
return programObject != nullptr && programObject->GetLinkStatus() && programObject->UsesNativeFloat64();
}
// glUniform*d / glUniformMatrix*dv. On a DEMOTED program neither needs a layout of its own:
// the transpile chain narrowed every 64-bit float in the shader to 32
// (ShaderTranspiler::DemoteFloat64Pass) and the global UBO is laid out by reflecting that
// demoted module, so a double uniform's storage IS a float uniform's - same offset, same
// 4-byte components, same std140 column padding for matrices. Narrowing here, at the one
// place the 64-bit value enters, and then handing the bytes to the ordinary float upload
// path is what keeps the two in step; a separate double-shaped layout here would write
// path is what keeps the two in step; a separate double-shaped layout there would write
// 8-byte components into 4-byte slots and silently address the wrong ones.
//
// The narrowing is the same static_cast the shader's own arithmetic now performs, so the
// The narrowing is the same static_cast the demoted shader's own arithmetic performs, so the
// value the shader reads is the value glUniform*d was given, at float precision.
//
// On a program that KEPT its doubles the reverse is true and for the same reason: its global
// UBO really does hold 8-byte components, so narrowing would leave a float bit pattern in the
// low half of a double slot - which is not a precision loss but a garbage value. The 64-bit
// values go through unchanged then, and the upload path is width-agnostic (it is templated on
// the component type and bounded by the uniform's own slot span).
//
// Note TryToGetProgramObject / GetProgramForUniform run TWICE on this path, once for the
// width question and once inside the call below. That is a lookup and a join on an entry
// point no shader pack uses; the alternative is duplicating both functions' whole validation
// sequence here, which is the thing that must not drift.
template <GLsizei ItemCount>
void UniformvNarrowed_State(GLint location, GLsizei count, const GLdouble* value) {
if (value == nullptr || count <= 0) {
@@ -1282,6 +1351,10 @@ namespace MobileGL::MG_Impl::GLImpl {
Uniformv_State<ItemCount>(location, count, reinterpret_cast<const GLfloat*>(value));
return;
}
if (location != -1 && CurrentProgramUsesNativeFloat64()) {
Uniformv_State<ItemCount>(location, count, value);
return;
}
Vector<GLfloat> narrowed(static_cast<SizeT>(count) * ItemCount);
for (SizeT i = 0; i < narrowed.size(); ++i) narrowed[i] = static_cast<GLfloat>(value[i]);
Uniformv_State<ItemCount>(location, count, narrowed.data());
@@ -1293,6 +1366,10 @@ namespace MobileGL::MG_Impl::GLImpl {
ProgramUniformv_State<ItemCount>(program, location, count, reinterpret_cast<const GLfloat*>(value));
return;
}
if (location != -1 && NamedProgramUsesNativeFloat64(program)) {
ProgramUniformv_State<ItemCount>(program, location, count, value);
return;
}
Vector<GLfloat> narrowed(static_cast<SizeT>(count) * ItemCount);
for (SizeT i = 0; i < narrowed.size(); ++i) narrowed[i] = static_cast<GLfloat>(value[i]);
ProgramUniformv_State<ItemCount>(program, location, count, narrowed.data());
@@ -1344,15 +1421,63 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
// glUniformMatrix*dv / glProgramUniformMatrix*dv. Narrowed to the float form and handed
// straight to it: after DemoteFloat64Pass a `dmat4` uniform is a `mat4` in the shader and a
// mat4-shaped slot in the global UBO, columns padded to a vec4 and all. Everything else
// about the call - transpose handling, the array-element walk, the opaque-uniform refusal -
// is then the one implementation both spellings share.
// glUniformMatrix*dv / glProgramUniformMatrix*dv on a program that KEPT its doubles. Same
// walk as UniformMatrixfv_Object down to the last branch, and deliberately a copy of it
// rather than a template over the component type: the two differ in exactly one number that
// is not derivable from the component type alone - std140 pads a double matrix's column out
// to a dvec4 (32 bytes) unless the column is a dvec2, which is already 16 - and folding that
// into the float version would put a per-call branch on the hot glUniformMatrix4fv path
// Minecraft calls thousands of times a frame for a case no shader pack ever takes.
template <typename Program>
void UniformMatrixdvNative_Object(Program& programObject, GLint location, GLsizei count, GLboolean transpose,
const GLdouble* value, Int columns, Int rows,
const String& ownerDescription) {
const SizeT columnStride = rows <= 2 ? 2 * sizeof(GLdouble) : 4 * sizeof(GLdouble);
const SizeT componentCount = static_cast<SizeT>(columns) * static_cast<SizeT>(rows);
GLdouble column[4] = {};
for (GLint matrix = 0; matrix < count; ++matrix) {
if (matrix > 0 && !programObject.UniformLocationsAliasSameUniform(location, location + matrix)) break;
if (!programObject.IsValidUniformLocation(location + matrix)) {
RecordInvalidUniformLocationError("glUniformMatrixdv", location + matrix, ownerDescription);
return;
}
if (programObject.IsUniformOpaqueAtLocation(location + matrix)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "glUniformMatrixdv",
"Opaque uniforms cannot be set with matrix Uniform calls."));
return;
}
const GLdouble* source = value + static_cast<SizeT>(matrix) * componentCount;
for (Int c = 0; c < columns; ++c) {
for (Int r = 0; r < rows; ++r) {
column[r] = transpose == GL_TRUE ? source[r * columns + c] : source[c * rows + r];
}
const SizeT byteOffset = static_cast<SizeT>(c) * columnStride;
switch (rows) {
case 2: Uniform_State<2>(programObject, location + matrix, column, byteOffset); break;
case 3: Uniform_State<3>(programObject, location + matrix, column, byteOffset); break;
default: Uniform_State<4>(programObject, location + matrix, column, byteOffset); break;
}
}
}
}
// glUniformMatrix*dv / glProgramUniformMatrix*dv. On a DEMOTED program this narrows to the
// float form and hands it straight over: after DemoteFloat64Pass a `dmat4` uniform is a
// `mat4` in the shader and a mat4-shaped slot in the global UBO, columns padded to a vec4
// and all. Everything else about the call - transpose handling, the array-element walk, the
// opaque-uniform refusal - is then the one implementation both spellings share. A program
// that kept its doubles gets the same walk at double width and the wider column stride.
template <typename Program>
void UniformMatrixdv_Object(Program& programObject, GLint location, GLsizei count, GLboolean transpose,
const GLdouble* value, Int columns, Int rows) {
if (value == nullptr || count <= 0) return;
if (programObject.UsesNativeFloat64()) {
UniformMatrixdvNative_Object(programObject, location, count, transpose, value, columns, rows,
"the current program object");
return;
}
const SizeT componentCount = static_cast<SizeT>(columns) * static_cast<SizeT>(rows);
Vector<GLfloat> narrowed(static_cast<SizeT>(count) * componentCount);
for (SizeT i = 0; i < narrowed.size(); ++i) narrowed[i] = static_cast<GLfloat>(value[i]);
@@ -1694,7 +1819,10 @@ namespace MobileGL::MG_Impl::GLImpl {
return GL_INVALID_INDEX;
}
const auto& index = programObject->GetUniformBlockIndex(uniformBlockName);
// GetGlUniformBlockIndex, not GetUniformBlockIndex: the latter answers in the internal
// block space, which also resolves storage blocks and the synthesized atomic counter
// blocks. Neither is a uniform block (GL 4.6 core 7.6), so both are GL_INVALID_INDEX here.
const auto index = programObject->GetGlUniformBlockIndex(uniformBlockName);
MGLOG_D("GBI prog=%u name='%s' -> %d", program, uniformBlockName ? uniformBlockName : "(null)", (Int)index);
return index;
}
@@ -1708,7 +1836,7 @@ namespace MobileGL::MG_Impl::GLImpl {
"Program object" + std::to_string(program) + " that has been linked."));
return;
}
if (!programObject->IsActiveUniformBlock(uniformBlockIndex)) {
if (!programObject->IsActiveGlUniformBlock(uniformBlockIndex)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>(
@@ -1719,8 +1847,11 @@ namespace MobileGL::MG_Impl::GLImpl {
std::to_string(program) + "."));
return;
}
// The GL_UNIFORM_BLOCK index space skips the storage and atomic counter blocks the
// block-keyed tables still carry; translate before touching them.
const Uint blockIndex = static_cast<Uint>(programObject->BlockIndexFromGlUniformBlock(uniformBlockIndex));
MGLOG_D("UBB prog=%u idx=%u binding=%u", program, uniformBlockIndex, uniformBlockBinding);
programObject->SetUniformBlockBinding(uniformBlockIndex, uniformBlockBinding);
programObject->SetUniformBlockBinding(blockIndex, uniformBlockBinding);
}
void GetActiveUniformBlockiv_State(GLuint program, GLuint uniformBlockIndex, GLenum pname, GLint* params) {
@@ -1732,7 +1863,7 @@ namespace MobileGL::MG_Impl::GLImpl {
"Program object" + std::to_string(program) + " that has been linked."));
return;
}
if (!programObject->IsActiveUniformBlock(uniformBlockIndex)) {
if (!programObject->IsActiveGlUniformBlock(uniformBlockIndex)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>(
@@ -1743,61 +1874,68 @@ namespace MobileGL::MG_Impl::GLImpl {
std::to_string(program) + "."));
return;
}
// The GL_UNIFORM_BLOCK index space skips the storage and atomic counter blocks the
// block-keyed tables still carry; every accessor below is indexed by the block space.
const Uint blockIndex = static_cast<Uint>(programObject->BlockIndexFromGlUniformBlock(uniformBlockIndex));
switch (pname) {
case GL_UNIFORM_BLOCK_DATA_SIZE: {
*params = (GLint)programObject->GetUBOSizeAt(uniformBlockIndex);
*params = (GLint)programObject->GetUBOSizeAt(blockIndex);
MGLOG_D("%s: GL_UNIFORM_BLOCK_DATA_SIZE = %d", __func__, *params);
break;
}
case GL_UNIFORM_BLOCK_NAME_LENGTH: {
*params = (GLint)programObject->GetUniformBlockName(uniformBlockIndex).length() + 1;
*params = (GLint)programObject->GetUniformBlockName(blockIndex).length() + 1;
MGLOG_D("%s: GL_UNIFORM_BLOCK_NAME_LENGTH = %d", __func__, *params);
break;
}
case GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS: {
*params = programObject->GetUniformBlockActiveUniformCount(uniformBlockIndex);
*params = programObject->GetUniformBlockActiveUniformCount(blockIndex);
MGLOG_D("%s: GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS = %d", __func__, *params);
break;
}
case GL_UNIFORM_BLOCK_BINDING: {
*params = static_cast<GLint>(programObject->GetUniformBlockBinding(uniformBlockIndex));
*params = static_cast<GLint>(programObject->GetUniformBlockBinding(blockIndex));
MGLOG_D("%s: GL_UNIFORM_BLOCK_BINDING = %d", __func__, *params);
break;
}
case GL_UNIFORM_BLOCK_REFERENCED_BY_VERTEX_SHADER:
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(uniformBlockIndex, EShLangVertex));
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(blockIndex, EShLangVertex));
MGLOG_D("%s: GL_UNIFORM_BLOCK_REFERENCED_BY_VERTEX_SHADER = %d", __func__, *params);
break;
case GL_UNIFORM_BLOCK_REFERENCED_BY_TESS_CONTROL_SHADER:
*params =
BoolToGLInt(programObject->IsUniformBlockReferencedByStage(uniformBlockIndex, EShLangTessControl));
BoolToGLInt(programObject->IsUniformBlockReferencedByStage(blockIndex, EShLangTessControl));
MGLOG_D("%s: GL_UNIFORM_BLOCK_REFERENCED_BY_TESS_CONTROL_SHADER = %d", __func__, *params);
break;
case GL_UNIFORM_BLOCK_REFERENCED_BY_TESS_EVALUATION_SHADER:
*params =
BoolToGLInt(programObject->IsUniformBlockReferencedByStage(uniformBlockIndex, EShLangTessEvaluation));
BoolToGLInt(programObject->IsUniformBlockReferencedByStage(blockIndex, EShLangTessEvaluation));
MGLOG_D("%s: GL_UNIFORM_BLOCK_REFERENCED_BY_TESS_EVALUATION_SHADER = %d", __func__, *params);
break;
case GL_UNIFORM_BLOCK_REFERENCED_BY_GEOMETRY_SHADER:
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(uniformBlockIndex, EShLangGeometry));
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(blockIndex, EShLangGeometry));
MGLOG_D("%s: GL_UNIFORM_BLOCK_REFERENCED_BY_GEOMETRY_SHADER = %d", __func__, *params);
break;
case GL_UNIFORM_BLOCK_REFERENCED_BY_FRAGMENT_SHADER:
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(uniformBlockIndex, EShLangFragment));
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(blockIndex, EShLangFragment));
MGLOG_D("%s: GL_UNIFORM_BLOCK_REFERENCED_BY_FRAGMENT_SHADER = %d", __func__, *params);
break;
case GL_UNIFORM_BLOCK_REFERENCED_BY_COMPUTE_SHADER:
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(uniformBlockIndex, EShLangCompute));
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(blockIndex, EShLangCompute));
MGLOG_D("%s: GL_UNIFORM_BLOCK_REFERENCED_BY_COMPUTE_SHADER = %d", __func__, *params);
break;
case GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES: {
// Member entries of an arrayed block are recorded against the first instance;
// every instance of the array reports that shared member set (matches
// GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS, which scans with the same owner index).
const Int ownerIndex = static_cast<Int>(programObject->GetUniformBlockMemberOwnerIndex(uniformBlockIndex));
//
// Both sides of the comparison are BLOCK indices: GetUniformBlockMemberOwnerIndex
// answers in that space, so the scan uses GetActiveUniformOwnerBlockIndex rather
// than the GL_UNIFORM_BLOCK-space GetActiveUniformBlockIndex.
const Int ownerIndex = static_cast<Int>(programObject->GetUniformBlockMemberOwnerIndex(blockIndex));
GLint uniformIndexCount = 0;
for (Uint uniformIndex = 0; uniformIndex < programObject->GetUniformCount(); ++uniformIndex) {
if (programObject->GetActiveUniformBlockIndex(uniformIndex) != ownerIndex) {
if (programObject->GetActiveUniformOwnerBlockIndex(uniformIndex) != ownerIndex) {
continue;
}
params[uniformIndexCount++] = static_cast<GLint>(uniformIndex);
@@ -1827,7 +1965,7 @@ namespace MobileGL::MG_Impl::GLImpl {
" is not a program object that has been linked."));
return;
}
if (!programObject->IsActiveUniformBlock(uniformBlockIndex)) {
if (!programObject->IsActiveGlUniformBlock(uniformBlockIndex)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>(
@@ -1837,7 +1975,8 @@ namespace MobileGL::MG_Impl::GLImpl {
"not the index of an active uniform block in program."));
return;
}
const auto& name = programObject->GetUniformBlockName(uniformBlockIndex);
const auto& name = programObject->GetUniformBlockName(
static_cast<Uint>(programObject->BlockIndexFromGlUniformBlock(uniformBlockIndex)));
CopyStr(bufSize, length, uniformBlockName, name.c_str(), (GLsizei)name.length());
MGLOG_D("%s: \"%s\" at uniformBlockIndex %02d, length = %d", __func__, uniformBlockName, uniformBlockIndex,
length ? *length : 0);
@@ -2835,6 +2974,73 @@ namespace MobileGL::MG_Impl::GLImpl {
return ProgramInterface::GetResourceLocationIndex(*programObject, programInterface, name);
}
// GL 4.6 §7.7. Every property this reports is one the GL_ATOMIC_COUNTER_BUFFER interface
// already carries, so this is a rename of glGetProgramResourceiv's props onto the older
// entry point's - and the two are required to agree, which is only true while both read the
// same model. It was a silent stub: it wrote nothing, raised nothing, and left every probe
// reading its own uninitialised output.
static Bool TryMapActiveAtomicCounterBufferProp(GLenum pname, GLenum& outProp) {
switch (pname) {
case GL_ATOMIC_COUNTER_BUFFER_BINDING:
outProp = GL_BUFFER_BINDING;
return true;
case GL_ATOMIC_COUNTER_BUFFER_DATA_SIZE:
outProp = GL_BUFFER_DATA_SIZE;
return true;
case GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTERS:
outProp = GL_NUM_ACTIVE_VARIABLES;
return true;
case GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTER_INDICES:
outProp = GL_ACTIVE_VARIABLES;
return true;
case GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_VERTEX_SHADER:
outProp = GL_REFERENCED_BY_VERTEX_SHADER;
return true;
case GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_TESS_CONTROL_SHADER:
outProp = GL_REFERENCED_BY_TESS_CONTROL_SHADER;
return true;
case GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_TESS_EVALUATION_SHADER:
outProp = GL_REFERENCED_BY_TESS_EVALUATION_SHADER;
return true;
case GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_GEOMETRY_SHADER:
outProp = GL_REFERENCED_BY_GEOMETRY_SHADER;
return true;
case GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_FRAGMENT_SHADER:
outProp = GL_REFERENCED_BY_FRAGMENT_SHADER;
return true;
case GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_COMPUTE_SHADER:
outProp = GL_REFERENCED_BY_COMPUTE_SHADER;
return true;
default:
return false;
}
}
void GetActiveAtomicCounterBufferiv(GLuint program, GLuint bufferIndex, GLenum pname, GLint* params) {
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
if (!programObject) return;
GLenum prop = GL_NONE;
if (!TryMapActiveAtomicCounterBufferProp(pname, prop)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"pname is not an active atomic counter buffer property."));
return;
}
Vector<GLint> values;
if (!ProgramInterface::GetResourceProp(*programObject, GL_ATOMIC_COUNTER_BUFFER, bufferIndex, prop, values)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"bufferIndex is not an active atomic counter buffer index."));
return;
}
if (params == nullptr) return;
// GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTER_INDICES is the only multi-value property
// here, and the caller sized its array from _ACTIVE_ATOMIC_COUNTERS.
for (SizeT i = 0; i < values.size(); ++i) params[i] = values[i];
}
// GL 4.6 §7.6.2: <storageBlockIndex> is an active shader storage block index of <program>
// - that is, exactly what glGetProgramResourceIndex(GL_SHADER_STORAGE_BLOCK) returned.
// Since wave 2 that index is the interface-query layer's, so this is where the one index
@@ -140,6 +140,7 @@ namespace MobileGL::MG_Impl::GLImpl {
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params);
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name);
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name);
void GetActiveAtomicCounterBufferiv(GLuint program, GLuint bufferIndex, GLenum pname, GLint* params);
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding);
void Uniform1d(GLint location, GLdouble v0);
void Uniform1dv(GLint location, GLsizei count, const GLdouble* value);
@@ -19,7 +19,7 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
// "<getAtomicCounterBlockName()>_<binding>" (ParseContextBase.cpp), one per GL
// atomic-counter binding point. That block IS the GL_ATOMIC_COUNTER_BUFFER resource
// and its trailing number IS GL_BUFFER_BINDING; its members stay GL_UNIFORMs.
constexpr const char* kAtomicCounterBlockPrefix = "gl_AtomicCounterBlock";
constexpr const char* kAtomicCounterBlockPrefix = MG_Util::ShaderTranspiler::ATOMIC_COUNTER_BLOCK_PREFIX;
enum class BlockKind {
Uniform, // a real GL uniform block
@@ -81,19 +81,18 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
// The enumerated spelling of an array resource is "name[0]". glslang already applies
// that to uniforms and buffer variables (EShReflectionBasicArraySuffix), but never to
// stage inputs/outputs, so those get it here.
String WithArraySuffix(const String& name, const glslang::TType* type) {
if (type == nullptr || !type->isArray() || EndsWithZeroSubscript(name)) return name;
String WithArraySuffix(const String& name, const ProgramObject::TypeFacts& type) {
if (!type.isArray || EndsWithZeroSubscript(name)) return name;
return name + "[0]";
}
// GL_ARRAY_SIZE: element count for a sized array, 0 for a runtime-sized one
// (a shader storage block's unsized trailing member), 1 for a non-array.
GLint ArraySizeOf(const glslang::TType* type, GLint reflectedSize) {
if (type != nullptr && type->isArray()) {
if (!type->isSizedArray()) return 0;
return type->getOuterArraySize();
}
return reflectedSize < 1 ? 1 : reflectedSize;
// `record.arraySize` is already the sized-array/reflected-size resolution; the only
// extra rule here is GL's 0 for a runtime-sized array.
GLint ArraySizeOf(const ProgramObject::ResourceReflection& record) {
if (record.type.isArray && !record.type.isSizedArray) return 0;
return record.arraySize;
}
// Two spellings name the same resource when they are equal, or differ only by the
@@ -174,22 +173,21 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
return static_cast<GLint>(element);
}
BlockKind ClassifyBlock(const glslang::TObjectReflection& block) {
BlockKind ClassifyBlock(const ProgramObject::BlockReflection& block) {
if (std::strstr(block.name.c_str(), MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) != nullptr) {
return BlockKind::GlobalUbo;
}
if (IsAtomicCounterBlockName(block.name)) return BlockKind::AtomicCounter;
const glslang::TType* type = block.getType();
if (type != nullptr && type->getQualifier().storage == glslang::EvqBuffer) return BlockKind::Storage;
if (block.type.isBuffer) return BlockKind::Storage;
return BlockKind::Uniform;
}
// std140/std430 column stride, the same vec4-rounded rule ProgramObject applies to
// uniform matrices. 0 for a non-matrix.
GLint MatrixStrideOf(const glslang::TType* type) {
if (type == nullptr || !type->isMatrix()) return 0;
const bool rowMajor = type->getQualifier().layoutMatrix == glslang::ElmRowMajor;
const int strideVectorComponents = rowMajor ? type->getMatrixCols() : type->getMatrixRows();
GLint MatrixStrideOf(const ProgramObject::TypeFacts& type) {
if (!type.isMatrix) return 0;
const bool rowMajor = type.layoutMatrix == static_cast<Int>(glslang::ElmRowMajor);
const int strideVectorComponents = rowMajor ? type.matrixCols : type.matrixRows;
constexpr int scalarSize = 4;
const int vectorAlignment = (strideVectorComponents <= 1) ? scalarSize
: (strideVectorComponents == 2) ? 2 * scalarSize
@@ -197,9 +195,9 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
return (vectorAlignment + 15) & ~15;
}
GLint IsRowMajorOf(const glslang::TType* type) {
if (type == nullptr || !type->isMatrix()) return 0;
return type->getQualifier().layoutMatrix == glslang::ElmRowMajor ? 1 : 0;
GLint IsRowMajorOf(const ProgramObject::TypeFacts& type) {
if (!type.isMatrix) return 0;
return type.layoutMatrix == static_cast<Int>(glslang::ElmRowMajor) ? 1 : 0;
}
GLint MappedLocation(Int rawLocation) {
@@ -227,12 +225,12 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
// Note the union is used even when it is empty: an array element nobody dereferenced has
// no member bits and is genuinely referenced by nobody, which is the whole point - falling
// back to the block's own mask there would restore the over-approximation.
Vector<Uint32> BuildBlockStagesFromMembers(const glslang::TProgram& reflection, Int blockCount) {
auto& mutableReflection = const_cast<glslang::TProgram&>(reflection);
Vector<Uint32> BuildBlockStagesFromMembers(const ProgramObject::LinkArtifacts& reflection,
Int blockCount) {
Vector<Uint32> stagesByBlock(static_cast<SizeT>(blockCount < 0 ? 0 : blockCount), 0u);
const Int uniformCount = mutableReflection.getNumUniformVariables();
const Int uniformCount = static_cast<Int>(reflection.uniformReflection.size());
for (Int index = 0; index < uniformCount; ++index) {
const auto& uniform = mutableReflection.getUniform(index);
const auto& uniform = reflection.uniformReflection[index];
const Int owner = uniform.index;
if (owner < 0 || owner >= blockCount) continue;
stagesByBlock[static_cast<SizeT>(owner)] |= static_cast<Uint32>(uniform.stages);
@@ -250,7 +248,7 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
// ss[1] and requires both to report the fragment stage, which only glslang's own
// (deliberately over-approximating) block mask gets right. Storage and atomic-counter
// blocks therefore keep that mask untouched.
Uint32 UniformBlockStages(const glslang::TObjectReflection& block, const Vector<Uint32>& stagesFromMembers,
Uint32 UniformBlockStages(const ProgramObject::BlockReflection& block, const Vector<Uint32>& stagesFromMembers,
Int tIndex) {
String arrayBase;
Uint element = 0;
@@ -264,15 +262,15 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
return stagesFromMembers[static_cast<SizeT>(tIndex)];
}
void BuildBlocks(ProgramObject& program, const glslang::TProgram& reflection, Model& model,
void BuildBlocks(ProgramObject& program, const ProgramObject::LinkArtifacts& reflection, Model& model,
Vector<BlockKind>& blockKind, Vector<Int>& blockInterfaceIndex) {
const Int blockCount = const_cast<glslang::TProgram&>(reflection).getNumUniformBlocks();
const Int blockCount = static_cast<Int>(reflection.blockReflection.size());
blockKind.assign(blockCount, BlockKind::Uniform);
blockInterfaceIndex.assign(blockCount, -1);
const Vector<Uint32> stagesFromMembers = BuildBlockStagesFromMembers(reflection, blockCount);
for (Int tIndex = 0; tIndex < blockCount; ++tIndex) {
const auto& block = const_cast<glslang::TProgram&>(reflection).getUniformBlock(tIndex);
const auto& block = reflection.blockReflection[tIndex];
const BlockKind kind = ClassifyBlock(block);
blockKind[tIndex] = kind;
if (kind == BlockKind::AtomicCounter) {
@@ -293,7 +291,7 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
// glShaderStorageBlockBinding wins over the declaration (GL 4.6 §7.6.2 -
// exactly the same rule GL_UNIFORM_BLOCK follows through
// GetUniformBlockBinding below).
const GLint declared = block.getBinding();
const GLint declared = block.binding;
resource.bufferBinding = declared < 0 ? 0 : declared + BlockArrayElement(block.name);
const Int rebound = program.GetShaderStorageBlockBindingOverride(block.name);
if (rebound >= 0) resource.bufferBinding = static_cast<GLint>(rebound);
@@ -307,38 +305,53 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
// GL_UNIFORM_BLOCK keeps the index space glUniformBlockBinding and
// glGetActiveUniformBlockiv already use, so an index handed out here is usable
// with them (which is exactly what the CTS does).
const Int glBlockCount = program.GetActiveUniformBlocksCount();
const Int glBlockCount = program.GetGlUniformBlockCount();
for (Int glIndex = 0; glIndex < glBlockCount; ++glIndex) {
// The block-space index the block-keyed accessors want; the two spaces differ
// whenever the program also has a storage or atomic counter block, which
// glslang files under the same reflection list (no EShReflectionSeparateBuffers).
const Int blockIndex = program.BlockIndexFromGlUniformBlock(static_cast<Uint>(glIndex));
Resource resource;
resource.name = program.GetUniformBlockName(glIndex);
resource.bufferBinding = static_cast<GLint>(program.GetUniformBlockBinding(glIndex));
resource.bufferDataSize = static_cast<GLint>(program.GetUBOSizeAt(glIndex));
const Int tIndex = program.TProgramBlockIndex(static_cast<Uint>(glIndex));
resource.name = program.GetUniformBlockName(static_cast<Uint>(blockIndex));
resource.bufferBinding = static_cast<GLint>(program.GetUniformBlockBinding(static_cast<Uint>(blockIndex)));
resource.bufferDataSize = static_cast<GLint>(program.GetUBOSizeAt(static_cast<Uint>(blockIndex)));
const Int tIndex = program.TProgramBlockIndex(static_cast<Uint>(blockIndex));
if (tIndex >= 0 && tIndex < blockCount) {
resource.stages = UniformBlockStages(const_cast<glslang::TProgram&>(reflection).getUniformBlock(tIndex),
resource.stages = UniformBlockStages(reflection.blockReflection[tIndex],
stagesFromMembers, tIndex);
}
model.uniformBlocks.push_back(Move(resource));
}
}
void BuildUniformsAndBufferVariables(ProgramObject& program, const glslang::TProgram& reflection, Model& model,
void BuildUniformsAndBufferVariables(ProgramObject& program,
const ProgramObject::LinkArtifacts& reflection, Model& model,
const Vector<BlockKind>& blockKind,
const Vector<Int>& blockInterfaceIndex) {
const Uint uniformCount = program.GetUniformCount();
for (Uint glIndex = 0; glIndex < uniformCount; ++glIndex) {
const Int tIndex = program.TProgramUniformIndex(glIndex);
const auto& refl = const_cast<glslang::TProgram&>(reflection).getUniform(tIndex);
const glslang::TType* type = refl.getType();
// Walks the TPROGRAM uniform space, not the GL one. A buffer variable is not a GL
// uniform (GL 4.6 core 7.3.1) and DoReflection therefore keeps it out of the GL
// active-uniform index space - but GL_BUFFER_VARIABLE still has to enumerate it, and
// this is the only place that does. GL uniforms keep their GL index as their
// GL_UNIFORM resource index: the GL space is a subsequence of this one, so pushing
// the GL-visible entries in this order preserves the correspondence.
const Int tUniformCount = static_cast<Int>(reflection.uniformReflection.size());
for (Int tIndex = 0; tIndex < tUniformCount; ++tIndex) {
const auto& refl = ProgramObject::UniformAtIn(reflection, tIndex);
const auto& type = refl.type;
const Int owner = refl.index;
const BlockKind kind = (owner >= 0 && owner < static_cast<Int>(blockKind.size()))
? blockKind[owner]
: BlockKind::GlobalUbo;
const Int glIndex = program.GlUniformIndexFromTProgram(tIndex);
// Everything except a buffer variable is enumerated through the GL space, so a
// uniform the relaxed parse swept out of it (a declared-but-dead default-block
// one) stays out of GL_UNIFORM too.
if (kind != BlockKind::Storage && glIndex < 0) continue;
Resource resource;
resource.name = refl.name;
resource.type = static_cast<GLenum>(refl.glDefineType);
resource.arraySize = ArraySizeOf(type, refl.size);
resource.arraySize = ArraySizeOf(refl);
resource.stages = static_cast<Uint32>(refl.stages);
if (kind == BlockKind::Storage) {
@@ -366,11 +379,12 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
resource.atomicCounterBufferIndex = blockInterfaceIndex[owner];
resource.location = -1;
} else {
resource.blockIndex = program.GetActiveUniformBlockIndex(glIndex);
resource.offset = program.GetActiveUniformOffset(glIndex);
resource.arrayStride = program.GetActiveUniformArrayStride(glIndex);
resource.matrixStride = program.GetActiveUniformMatrixStride(glIndex);
resource.isRowMajor = program.GetActiveUniformIsRowMajor(glIndex);
const Uint glUniformIndex = static_cast<Uint>(glIndex);
resource.blockIndex = program.GetActiveUniformBlockIndex(glUniformIndex);
resource.offset = program.GetActiveUniformOffset(glUniformIndex);
resource.arrayStride = program.GetActiveUniformArrayStride(glUniformIndex);
resource.matrixStride = program.GetActiveUniformMatrixStride(glUniformIndex);
resource.isRowMajor = program.GetActiveUniformIsRowMajor(glUniformIndex);
// A member of a named uniform block has no location, whatever the
// frontend's own location table says (it hands one out to every uniform
// so glUniform* can address block members through the global UBO).
@@ -389,12 +403,16 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
static_cast<GLuint>(i));
}
}
for (SizeT blockIndex = 0; blockIndex < model.uniformBlocks.size(); ++blockIndex) {
for (SizeT glBlockIndex = 0; glBlockIndex < model.uniformBlocks.size(); ++glBlockIndex) {
// Members of an arrayed block are reflected once, against instance [0].
const Int owner = static_cast<Int>(program.GetUniformBlockMemberOwnerIndex(static_cast<Uint>(blockIndex)));
// GetUniformBlockMemberOwnerIndex takes and answers BLOCK indices, while
// Resource::blockIndex is a GL_UNIFORM_BLOCK index, so translate both ways.
const Int blockIndex = program.BlockIndexFromGlUniformBlock(static_cast<Uint>(glBlockIndex));
const Int owner = program.GlUniformBlockIndexFromBlock(
static_cast<Int>(program.GetUniformBlockMemberOwnerIndex(static_cast<Uint>(blockIndex))));
for (SizeT i = 0; i < model.uniforms.size(); ++i) {
if (model.uniforms[i].blockIndex == owner) {
model.uniformBlocks[blockIndex].activeVariables.push_back(static_cast<GLuint>(i));
model.uniformBlocks[glBlockIndex].activeVariables.push_back(static_cast<GLuint>(i));
}
}
}
@@ -414,17 +432,13 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
// program that redeclares `out gl_PerVertex { vec4 gl_Position; }` still carries
// gl_PointSize and gl_ClipDistance through the block-unwrapping reflection, and they
// are not part of its output interface.
Bool IsHiddenBlockMember(const glslang::TType* type) {
return type != nullptr && type->getBasicType() == glslang::EbtVoid;
}
Bool IsHiddenBlockMember(const ProgramObject::TypeFacts& type) { return type.isVoid; }
void BuildStageIO(ProgramObject& program, const glslang::TProgram& reflection, Model& model) {
auto& mutableReflection = const_cast<glslang::TProgram&>(reflection);
const Int inputCount = mutableReflection.getNumPipeInputs();
void BuildStageIO(ProgramObject& program, const ProgramObject::LinkArtifacts& reflection, Model& model) {
const Int inputCount = static_cast<Int>(reflection.pipeInputReflection.size());
for (Int index = 0; index < inputCount; ++index) {
const auto& refl = mutableReflection.getPipeInput(index);
const glslang::TType* type = refl.getType();
const auto& refl = reflection.pipeInputReflection[index];
const auto& type = refl.type;
if (IsHiddenBlockMember(type)) continue;
Resource resource;
// The Vulkan-semantics parse reflects the vertex builtins under their SPIR-V
@@ -432,10 +446,10 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
const String& glName = ProgramObject::NormalizeBuiltinPipeInputName(refl.name);
resource.name = WithArraySuffix(glName, type);
resource.type = static_cast<GLenum>(refl.glDefineType);
resource.arraySize = ArraySizeOf(type, refl.size);
resource.arraySize = ArraySizeOf(refl);
resource.location = program.GetAttributeLocation(refl.name);
if (resource.location < 0) resource.location = MappedLocation(static_cast<Int>(refl.layoutLocation()));
resource.isPerPatch = (type != nullptr && type->getQualifier().patch) ? 1 : 0;
if (resource.location < 0) resource.location = MappedLocation(refl.location);
resource.isPerPatch = type.isPatch ? 1 : 0;
resource.stages = static_cast<Uint32>(refl.stages);
model.programInputs.push_back(Move(resource));
}
@@ -447,16 +461,16 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
// carries its own layout(location=N)), and a location then manufactures a color
// index of 0 where GL requires -1
// (KHR-GL43.program_interface_query.separate-programs-tess-control).
const Bool lastStageIsFragment = mutableReflection.getIntermediate(EShLangFragment) != nullptr;
const Int outputCount = mutableReflection.getNumPipeOutputs();
const Bool lastStageIsFragment = reflection.lastStageIsFragment;
const Int outputCount = static_cast<Int>(reflection.pipeOutputReflection.size());
for (Int index = 0; index < outputCount; ++index) {
const auto& refl = mutableReflection.getPipeOutput(index);
const glslang::TType* type = refl.getType();
const auto& refl = reflection.pipeOutputReflection[index];
const auto& type = refl.type;
if (IsHiddenBlockMember(type)) continue;
Resource resource;
resource.name = WithArraySuffix(refl.name, type);
resource.type = static_cast<GLenum>(refl.glDefineType);
resource.arraySize = ArraySizeOf(type, refl.size);
resource.arraySize = ArraySizeOf(refl);
resource.location = MappedLocation(program.GetFragmentDataLocation(refl.name.c_str()));
if (resource.location < 0 || !lastStageIsFragment) {
// A built-in output (gl_FragDepth, gl_SampleMask) has no location, and a
@@ -467,11 +481,11 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
resource.locationIndex = program.GetFragmentDataIndex(refl.name.c_str());
// glBindFragDataLocationIndexed wins; otherwise the shader's
// layout(index = N), which the frag-data maps never saw.
if (resource.locationIndex == 0 && type != nullptr && type->getQualifier().hasIndex()) {
resource.locationIndex = static_cast<GLint>(type->getQualifier().layoutIndex);
if (resource.locationIndex == 0 && type.hasIndex) {
resource.locationIndex = static_cast<GLint>(type.layoutIndex);
}
}
resource.isPerPatch = (type != nullptr && type->getQualifier().patch) ? 1 : 0;
resource.isPerPatch = type.isPatch ? 1 : 0;
resource.stages = static_cast<Uint32>(refl.stages);
model.programOutputs.push_back(Move(resource));
}
@@ -511,15 +525,14 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
Model BuildModel(ProgramObject& program) {
Model model;
if (!program.GetLinkStatus()) return model;
const glslang::TProgram* reflection = program.GetReflection();
if (reflection == nullptr) return model;
const ProgramObject::LinkArtifacts& reflection = program.GetLinkReflection();
model.valid = true;
Vector<BlockKind> blockKind;
Vector<Int> blockInterfaceIndex;
BuildBlocks(program, *reflection, model, blockKind, blockInterfaceIndex);
BuildUniformsAndBufferVariables(program, *reflection, model, blockKind, blockInterfaceIndex);
BuildStageIO(program, *reflection, model);
BuildBlocks(program, reflection, model, blockKind, blockInterfaceIndex);
BuildUniformsAndBufferVariables(program, reflection, model, blockKind, blockInterfaceIndex);
BuildStageIO(program, reflection, model);
BuildXfb(program, model);
return model;
}
@@ -565,6 +565,75 @@ namespace MobileGL::MG_Impl::GLImpl {
queryObject->ended = true;
}
void BeginConditionalRender(GLuint id, GLenum mode) {
// GL 4.6 core 10.9's eight modes. The _INVERTED half flips the sense of the predicate;
// the BY_REGION half only narrows WHERE an implementation is permitted to discard, so
// treating it as its whole-framebuffer sibling is what an implementation without region
// granularity does. The _NO_WAIT half is a permission to render rather than stall, not an
// obligation - see the resolve below.
Bool inverted = false;
switch (mode) {
case GL_QUERY_WAIT:
case GL_QUERY_NO_WAIT:
case GL_QUERY_BY_REGION_WAIT:
case GL_QUERY_BY_REGION_NO_WAIT:
inverted = false;
break;
case GL_QUERY_WAIT_INVERTED:
case GL_QUERY_NO_WAIT_INVERTED:
case GL_QUERY_BY_REGION_WAIT_INVERTED:
case GL_QUERY_BY_REGION_NO_WAIT_INVERTED:
inverted = true;
break;
default:
RecordQueryError(ErrorCode::InvalidEnum, __FUNCTION__, "mode is not a conditional render mode.");
return;
}
if (MG_State::pGLContext->IsConditionalRenderActive()) {
RecordQueryError(ErrorCode::InvalidOperation, __FUNCTION__, "Conditional rendering is already active.");
return;
}
{
const std::lock_guard<std::mutex> lock(g_queryObjectsMutex);
const auto* queryObject = FindQueryObjectLocked(id);
// A generated NAME is not yet a query object; it becomes one at its first use with a
// target (the same rule glIsQuery answers by).
if (!queryObject || (!queryObject->created && queryObject->target == 0)) {
RecordQueryError(ErrorCode::InvalidValue, __FUNCTION__, "id is not the name of a query object.");
return;
}
if (queryObject->active) {
RecordQueryError(ErrorCode::InvalidOperation, __FUNCTION__, "The query object is still active.");
return;
}
if (queryObject->target != GL_SAMPLES_PASSED && queryObject->target != GL_ANY_SAMPLES_PASSED &&
queryObject->target != GL_ANY_SAMPLES_PASSED_CONSERVATIVE) {
RecordQueryError(ErrorCode::InvalidOperation, __FUNCTION__,
"Conditional rendering requires an occlusion query object.");
return;
}
}
// Resolved ONCE, here, and by WAITING even for the _NO_WAIT modes: the spec lets those
// render instead of stalling, so always waiting is conforming and is the only choice that
// gives the whole block one deterministic verdict. Reading it per command instead would
// let a result that lands mid-block change the answer half way through.
Uint64 samplesPassed = 0;
if (!GetQueryObjectValue(id, GL_QUERY_RESULT, __FUNCTION__, samplesPassed)) return;
const Bool passed = samplesPassed != 0;
MG_State::pGLContext->BeginConditionalRender(id, mode, inverted ? passed : !passed);
}
void EndConditionalRender() {
if (!MG_State::pGLContext->IsConditionalRenderActive()) {
RecordQueryError(ErrorCode::InvalidOperation, __FUNCTION__, "Conditional rendering is not active.");
return;
}
MG_State::pGLContext->EndConditionalRender();
}
void GetQueryiv(GLenum target, GLenum pname, GLint* params) {
if (!params) {
return;
+5
View File
@@ -29,6 +29,11 @@ namespace MobileGL::MG_Impl::GLImpl {
void GetQueryBufferObjecti64v(GLuint id, GLuint buffer, GLenum pname, GLintptr offset);
void GetQueryBufferObjectui64v(GLuint id, GLuint buffer, GLenum pname, GLintptr offset);
void QueryCounter(GLuint id, GLenum target);
// Conditional rendering (GL 4.6 core 10.9). Implemented here rather than beside the drawing
// entry points because the predicate is a QUERY OBJECT's result, and the object registry -
// with the lock that guards it - lives in this file.
void BeginConditionalRender(GLuint id, GLenum mode);
void EndConditionalRender();
// Destroys every still-registered query object exactly as DeleteQueries would.
// GL requires queries to die with their context; called only from full library
// teardown (DestroyImpl), where no context survives on any thread, so the
+175 -13
View File
@@ -661,21 +661,42 @@ namespace MobileGL::MG_Impl::GLImpl {
"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.
// GL_TEXTURE_WIDTH of a buffer texture: how many texels of the texture's internal format fit
// in the buffer range it addresses, CLAMPED to GL_MAX_TEXTURE_BUFFER_SIZE. Attaching a larger
// buffer is legal (GL 4.6 core 8.9) - the texture simply addresses the first
// MAX_TEXTURE_BUFFER_SIZE texels of it, and that clamped count is what WIDTH reports.
//
// GL_TEXTURE_BUFFER_SIZE is deliberately NOT clamped the same way: it reports the range in
// basic machine units exactly as glTexBuffer/glTexBufferRange were given it. Swapping the two
// fails KHR-GL43.texture_buffer.texture_buffer_max_size in the opposite direction.
GLint GetBufferTextureTexelWidth(const MG_State::GLState::ITextureObject* textureObject) {
const SizeT texelByteSize = MG_Util::GetSizedInternalFormatSizeInBytes(textureObject->GetFormat());
// A format with no known footprint has no texel count to report; answering 0 beats
// dividing by it.
if (texelByteSize == 0) return 0;
const auto* bufferTextureObject =
static_cast<const MG_State::GLState::TextureObjectBuffer*>(textureObject);
const SizeT texelCount = bufferTextureObject->GetBufferRangeSizeInBytes() / texelByteSize;
const SizeT maxTexelCount = static_cast<SizeT>(
std::max(0, MG_Backend::pActiveBackendObject->GetDynamicParameters().MaxTextureBufferSize));
return static_cast<GLint>(std::min(texelCount, maxTexelCount));
}
// glGetTexLevelParameter{i,f}v answers WIDTH/HEIGHT/DEPTH out of the mipmap chain, and (since
// the buffer-texture arms above) out of the attached buffer range for GL_TEXTURE_BUFFER. This
// is what is left: a storage class with no level geometry at all. Report it 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_W_ONCE("%s: glGetTexLevelParameter(pname=%s) is not implemented for texture-buffer "
"storage; recording GL_INVALID_OPERATION instead of terminating",
MGLOG_W_ONCE("%s: glGetTexLevelParameter(pname=%s) is not implemented for this texture's "
"storage class; 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."));
"Level queries are not supported for this texture's storage class."));
}
} // namespace
@@ -3059,6 +3080,15 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = textureObject->GetSamplerObject()->GetMaxAnisotropy();
}
break;
// GL 4.6 core 8.11 lists this among the parameters EVERY GetTexParameter form answers.
// It was handled by the iv/Iiv/Iuiv getters and missed by this one, so the float query
// raised GL_INVALID_ENUM and left the caller's float untouched - which is what
// KHR-GL4x.shader_image_load_store.basic-api-texParam reads back.
case GL_IMAGE_FORMAT_COMPATIBILITY_TYPE:
if (params) {
*params = static_cast<GLfloat>(GL_IMAGE_FORMAT_COMPATIBILITY_BY_SIZE);
}
break;
case GL_DEPTH_STENCIL_TEXTURE_MODE:
if (params) {
*params = static_cast<GLfloat>(textureObject->GetDepthStencilTextureMode());
@@ -3108,6 +3138,9 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = textureMipmapObject->GetMipmapTexelSize(textureUploadTarget, level).x();
break;
}
case TextureStorageType::Buffer:
*params = GetBufferTextureTexelWidth(textureObject.get());
break;
default:
RecordUnsupportedLevelQueryStorage("GetTexLevelParameteriv_State", pname);
break;
@@ -3123,6 +3156,9 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = textureMipmapObject->GetMipmapTexelSize(textureUploadTarget, level).y();
break;
}
case TextureStorageType::Buffer:
*params = 1; // a buffer texture is one-dimensional
break;
default:
RecordUnsupportedLevelQueryStorage("GetTexLevelParameteriv_State", pname);
break;
@@ -3138,6 +3174,9 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = textureMipmapObject->GetMipmapTexelSize(textureUploadTarget, level).z();
break;
}
case TextureStorageType::Buffer:
*params = 1; // a buffer texture is one-dimensional
break;
default:
RecordUnsupportedLevelQueryStorage("GetTexLevelParameteriv_State", pname);
break;
@@ -3207,6 +3246,31 @@ namespace MobileGL::MG_Impl::GLImpl {
}
break;
}
case GL_TEXTURE_BUFFER_SIZE:
case GL_TEXTURE_BUFFER_OFFSET: {
// GL 4.6 core 8.9: both describe the window of the attached buffer a GL_TEXTURE_BUFFER
// texture addresses, so there is nothing to report for any other storage - which is
// INVALID_OPERATION, the same shape GL_TEXTURE_COMPRESSED_IMAGE_SIZE guards itself with
// above.
if (textureObject->GetStorageType() != TextureStorageType::Buffer) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "GetTexLevelParameteriv_State",
"GL_TEXTURE_BUFFER_SIZE / GL_TEXTURE_BUFFER_OFFSET need a buffer texture."));
return;
}
if (params) {
const auto* bufferTextureObject =
static_cast<MG_State::GLState::TextureObjectBuffer*>(textureObject.get());
// Basic machine units, and UNCLAMPED - see GetBufferTextureTexelWidth for why this
// half does not take the GL_MAX_TEXTURE_BUFFER_SIZE clamp that WIDTH does.
*params = static_cast<GLint>(pname == GL_TEXTURE_BUFFER_SIZE
? bufferTextureObject->GetBufferRangeSizeInBytes()
: bufferTextureObject->GetBufferRangeOffset());
}
break;
}
default:
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "GetTexLevelParameteriv_State",
@@ -3246,6 +3310,9 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = (GLfloat)textureMipmapObject->GetMipmapTexelSize(textureUploadTarget, level).x();
break;
}
case TextureStorageType::Buffer:
*params = (GLfloat)GetBufferTextureTexelWidth(textureObject.get());
break;
default:
RecordUnsupportedLevelQueryStorage("GetTexLevelParameterfv_State", pname);
break;
@@ -3261,6 +3328,9 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = (GLfloat)textureMipmapObject->GetMipmapTexelSize(textureUploadTarget, level).y();
break;
}
case TextureStorageType::Buffer:
*params = 1.0f; // a buffer texture is one-dimensional
break;
default:
RecordUnsupportedLevelQueryStorage("GetTexLevelParameterfv_State", pname);
break;
@@ -3276,6 +3346,9 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = (GLfloat)textureMipmapObject->GetMipmapTexelSize(textureUploadTarget, level).z();
break;
}
case TextureStorageType::Buffer:
*params = 1.0f; // a buffer texture is one-dimensional
break;
default:
RecordUnsupportedLevelQueryStorage("GetTexLevelParameterfv_State", pname);
break;
@@ -3343,6 +3416,27 @@ namespace MobileGL::MG_Impl::GLImpl {
}
break;
}
case GL_TEXTURE_BUFFER_SIZE:
case GL_TEXTURE_BUFFER_OFFSET: {
// See GetTexLevelParameteriv_State: both describe the attached buffer range of a
// GL_TEXTURE_BUFFER texture, so any other storage makes the query INVALID_OPERATION.
if (textureObject->GetStorageType() != TextureStorageType::Buffer) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "GetTexLevelParameterfv_State",
"GL_TEXTURE_BUFFER_SIZE / GL_TEXTURE_BUFFER_OFFSET need a buffer texture."));
return;
}
if (params) {
const auto* bufferTextureObject =
static_cast<MG_State::GLState::TextureObjectBuffer*>(textureObject.get());
*params = static_cast<GLfloat>(pname == GL_TEXTURE_BUFFER_SIZE
? bufferTextureObject->GetBufferRangeSizeInBytes()
: bufferTextureObject->GetBufferRangeOffset());
}
break;
}
default:
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "GetTexLevelParameterfv_State",
@@ -3655,12 +3749,72 @@ namespace MobileGL::MG_Impl::GLImpl {
}
return GetCopyImageLevelSize(endpoint.Texture, uploadTarget, level);
}
// The per-axis extent of one endpoint's image AS THIS ENTRY POINT ADDRESSES IT, which is
// not always the level extent this frontend stores.
//
// GL 4.6 core 18.3.2 treats EVERY array texture as a stack of slices addressed by z, and
// gives a 1D array an image height of 1. This frontend stores a 1D array the way
// glTexImage2D(GL_TEXTURE_1D_ARRAY, w, layers) writes it instead - layers on y - so the
// two views have to be told apart here. Measuring y against the LAYER count is what let
// srcY = 14 on a 16-wide, 16-layer 1D array come back GL_NO_ERROR
// (KHR-GL43.copy_image.exceeding_boundaries, the src_test_case y variants); the CTS is
// unambiguous about the convention, forcing height = 1 for 1D and 1D_ARRAY and listing
// 1D_ARRAY as multilayer.
//
// A CUBE MAP is the other target whose z bound is not the level extent: this frontend
// keeps its six faces as six separate one-slice upload targets, so the level says 1 and
// the real bound is 6. A cube-map ARRAY is one upload target whose depth already counts
// layer-faces, and every remaining target is answered by the level extent verbatim.
IntVec3 GetCopyImageEndpointRegionBounds(const MG_Backend::CopyImageEndpoint& endpoint,
const IntVec3& levelSize) {
const TextureTarget target = (!endpoint.IsRenderbuffer() && endpoint.Texture)
? endpoint.Texture->GetTarget()
: TextureTarget::Unknown;
if (target == TextureTarget::TextureCubeMap) {
return {levelSize.x(), levelSize.y(), 6};
}
if (target == TextureTarget::Texture1DArray) {
return {levelSize.x(), 1, std::max(levelSize.y(), 1)};
}
return {levelSize.x(), levelSize.y(), std::max(levelSize.z(), 1)};
}
// GL 4.6 core 18.3.2 requires INVALID_VALUE when the region exceeds either image's
// boundaries. The only bounds-shaped call this validator used to make was
// ValidateCopyImageBlockAlignment, whose first line returns true for every UNCOMPRESSED
// format - so no uncompressed copy was bounded at all, and the z extent could not be
// bounded even in principle because srcZ/dstZ never reached the validator. Texture
// endpoints were covered only by accident, through the ES driver's own error, which the
// DirectGLES backend logs and swallows rather than reporting; a GL_RENDERBUFFER endpoint
// got neither (KHR-GL43.copy_image.exceeding_boundaries).
Bool ValidateCopyImageRegionBounds(const MG_Backend::CopyImageEndpoint& endpoint, const IntVec3& levelSize,
GLint x, GLint y, GLint z, GLsizei width, GLsizei height, GLsizei depth,
const char* endpointName) {
// An extent this frontend does not know cannot bound anything, and guessing would
// reject a copy GL allows. Every caller has already established that the level
// exists and that the image is complete, so this is a belt-and-braces guard.
if (levelSize.x() <= 0 || levelSize.y() <= 0) return true;
const IntVec3 bounds = GetCopyImageEndpointRegionBounds(endpoint, levelSize);
if (x >= 0 && y >= 0 && z >= 0 && static_cast<Int64>(x) + width <= bounds.x() &&
static_cast<Int64>(y) + height <= bounds.y() && static_cast<Int64>(z) + depth <= bounds.z()) {
return true;
}
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "ValidateCopyImageSubData_State",
std::format("The {} region [{}, {}, {}] + [{} x {} x {}] does not fit inside the {} x {} x {} "
"image.",
endpointName, x, y, z, width, height, depth, bounds.x(), bounds.y(), bounds.z())));
return false;
}
} // namespace
Bool ValidateCopyImageSubData_State(const MG_Backend::CopyImageEndpoint& src,
GLenum srcTarget, GLint srcLevel, GLint srcX, GLint srcY,
GLenum srcTarget, GLint srcLevel, GLint srcX, GLint srcY, GLint srcZ,
const MG_Backend::CopyImageEndpoint& dst,
GLenum dstTarget, GLint dstLevel, GLint dstX, GLint dstY,
GLenum dstTarget, GLint dstLevel, GLint dstX, GLint dstY, GLint dstZ,
GLsizei srcWidth, GLsizei srcHeight, GLsizei srcDepth) {
if (!ValidateCopyImageObjectExists(src, "source") ||
!ValidateCopyImageObjectExists(dst, "destination")) {
@@ -3755,6 +3909,14 @@ namespace MobileGL::MG_Impl::GLImpl {
dstLevelSize.x(), dstLevelSize.y(), "destination")) {
return false;
}
// One region extent, measured against both images: GL 4.6 core 18.3.2 gives the copy a
// single width/height/depth and requires it to fit in the source AND the destination.
if (!ValidateCopyImageRegionBounds(src, srcLevelSize, srcX, srcY, srcZ, srcWidth, srcHeight, srcDepth,
"source") ||
!ValidateCopyImageRegionBounds(dst, dstLevelSize, dstX, dstY, dstZ, srcWidth, srcHeight, srcDepth,
"destination")) {
return false;
}
return true;
}
@@ -5988,8 +6150,8 @@ namespace MobileGL::MG_Impl::GLImpl {
};
const MG_Backend::CopyImageEndpoint src = resolveEndpoint(srcName, srcTarget);
const MG_Backend::CopyImageEndpoint dst = resolveEndpoint(dstName, dstTarget);
if (!ValidateCopyImageSubData_State(src, srcTarget, srcLevel, srcX, srcY, dst, dstTarget,
dstLevel, dstX, dstY, srcWidth, srcHeight, srcDepth)) {
if (!ValidateCopyImageSubData_State(src, srcTarget, srcLevel, srcX, srcY, srcZ, dst, dstTarget,
dstLevel, dstX, dstY, dstZ, srcWidth, srcHeight, srcDepth)) {
return;
}
CopyImageSubData_Backend(src, srcTarget, srcLevel, srcX, srcY, srcZ, dst, dstTarget, dstLevel,
@@ -514,10 +514,17 @@ namespace MobileGL::MG_Impl::GLImpl {
// recorded DataType is always Float64 - what IsLong adds is that this is the *unconverted* form,
// as opposed to VertexAttribFormat(GL_DOUBLE), which asks for a float conversion.
//
// Whether the backend can feed it is detected, not assumed: DirectVulkan needs shaderFloat64,
// and DirectGLES can never have it at all. A backend without it declines here, loudly - GL error
// plus a log line naming the reason - rather than accepting state no draw could honour and
// rendering garbage. The matching startup POST row is in MG_Util/SelfTest/DriverPost.cpp.
// Whether the backend can FEED it at full precision is detected, not assumed: DirectVulkan
// needs shaderFloat64, and DirectGLES can never have it at all. What that costs is PRECISION,
// not the call and no longer the array: GL 4.6 core 10.3.2 defines no error for a well-formed
// glVertexAttribLFormat, and a GL 4.3 context has 64-bit attributes in core, so declining the
// call would be non-conformant and would make the four pure state queries
// (VERTEX_ATTRIB_ARRAY_SIZE / _TYPE / _LONG / _RELATIVE_OFFSET) unanswerable
// (KHR-GL43.vertex_attrib_binding.basic-state1/3). The format is therefore RECORDED here and
// the array is NARROWED to float32 at draw, matching the fp64 demotion every shader already
// gets (DemoteFloat64Pass) - loudly, once, naming the cost. The matching startup POST row is in
// MG_Util/SelfTest/DriverPost.cpp; the draw-side narrowing is DirectGLES/Managers.cpp and, on
// DirectVulkan, VertexInputStateFactory's Float64 case.
static void VertexAttribLFormatSeparate_State(const SharedPtr<MG_State::GLState::VertexArrayObject>& vao,
GLuint attribindex, GLint size, GLenum type,
GLuint relativeoffset) {
@@ -528,14 +535,11 @@ namespace MobileGL::MG_Impl::GLImpl {
if (!MG_Backend::pActiveBackendObject ||
!MG_Backend::pActiveBackendObject->GetDynamicParameters().SupportsFloat64VertexAttributes) {
MGLOG_W_ONCE("VertexAttribLFormat: attribute %u asked for a 64-bit (GL_DOUBLE) format, but this "
"backend has no double-precision vertex attribute support - see the "
"\"64-bit vertex attributes\" / \"shaderFloat64\" POST row for what that costs",
"backend has no double-precision vertex attribute support - the format is recorded "
"and queryable, and the array is FETCHED AT FLOAT32 PRECISION at draw (the same "
"narrowing the shader's dvec inputs already get); see the \"64-bit vertex "
"attributes\" / \"shaderFloat64\" POST row for what that costs",
attribindex);
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "VertexAttribLFormat",
"64-bit vertex attributes are not supported by this backend."));
return;
}
vao->SetAttributeFormatSeparate(attribindex, size, MG_Util::ConvertGLEnumToDataType(type),
@@ -59,6 +59,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/AsyncCompileScenario.cpp
Scenarios/XfbAfterClipDistanceScenario.cpp
Scenarios/ThreeChannelAttachmentScenario.cpp
Scenarios/SnormAttachmentScenario.cpp
Scenarios/PipelineFailureScenario.cpp
Scenarios/AdvertisedLimitsScenario.cpp
Scenarios/PixelStoreSweepScenario.cpp
@@ -80,8 +81,15 @@ add_executable(MobileGLIntegrationTest
Scenarios/ImageLoadStoreSsoScenario.cpp
Scenarios/ImageTargetKindScenario.cpp
Scenarios/ImageFormatQualifierScenario.cpp
Scenarios/NonCoreImageFormatScenario.cpp
Scenarios/ImageSizeAfterRespecScenario.cpp
Scenarios/SsboDeclarationFormScenario.cpp
Scenarios/Glsl420DeclarationScenario.cpp
Scenarios/IoBlockNameCollisionScenario.cpp
Scenarios/TessellationDrawModeScenario.cpp
Scenarios/GeometryDrawModeScenario.cpp
Scenarios/PostLinkAttachScenario.cpp
Scenarios/FormatlessImageBakeScenario.cpp
Scenarios/FragmentOutputArrayIndexScenario.cpp
Scenarios/BufferTextureScenario.cpp
Scenarios/VertexAttribBindingScenario.cpp
@@ -90,7 +98,13 @@ add_executable(MobileGLIntegrationTest
Scenarios/VertexArrayEnableDisableScenario.cpp
Scenarios/CopyImageLevelRangeScenario.cpp
Scenarios/CopyImageLayeredScenario.cpp
Scenarios/PackedWordReadbackScenario.cpp
Scenarios/LayeredAttachmentBarrierScenario.cpp
Scenarios/LayeredTextureReadbackScenario.cpp
Scenarios/AtomicCounterScenario.cpp
Scenarios/SsboArrayDynamicIndexScenario.cpp
Scenarios/StorageBufferRegrowScenario.cpp
Scenarios/RelinkStageSetScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -0,0 +1,239 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/AtomicCounterScenario.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 - ATOMIC COUNTERS, END TO END.
//
// GL_ATOMIC_COUNTER_BUFFER does not exist in ES, and glslang does not hand one to a backend
// either: its Vulkan-relaxed parse rewrites every atomic_uint into a uint member of a
// synthesized gl_AtomicCounterBlock_<N> STORAGE block. Making counters work therefore means
// closing two open ends that used to be missing entirely -
//
// * the block's shader-storage binding, which the IO mapper picked at random and which had no
// relation to the GL binding point N the application bound its buffer to (and could alias an
// SSBO the application binds itself), is moved to a slot reserved at the top of the driver's
// range; and
// * the buffer bound at GL_ATOMIC_COUNTER_BUFFER point N, which nothing in the ES backend ever
// read, is re-issued as a shader-storage binding at that reserved slot.
//
// Neither end alone is observable: with only the first the shader increments a block nobody
// bound a buffer to, with only the second the buffer lands where the shader does not look. The
// only thing that proves both is the VALUE, so every assertion here reads the counter back.
//
// Compute rather than a draw on purpose: the invocation count is exactly what was dispatched,
// while a fragment stage's is a property of the rasterizer (helper invocations, early depth).
// Conformance cases behind this: KHR-GL42/GL43.shader_atomic_counters.basic-usage-cs,
// .advanced-usage-multi-stage and .advanced-usage-draw-update-draw.
#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 {
// Two counters share binding 0 at DIFFERENT offsets and a third sits alone on binding 1.
// The offsets are what separates "the buffer arrived" from "the buffer arrived and the
// block is laid out the way GL says": a lowering that packed the members in declaration
// order without honouring `offset` would still pass a single-counter check.
constexpr const char* kCounterComputeSource = R"(#version 430 core
layout(local_size_x = 4) in;
layout(binding = 0, offset = 0) uniform atomic_uint g_first;
layout(binding = 0, offset = 4) uniform atomic_uint g_second;
layout(binding = 1, offset = 0) uniform atomic_uint g_other;
void main() {
atomicCounterIncrement(g_first);
atomicCounterIncrement(g_second);
atomicCounterIncrement(g_second);
atomicCounterIncrement(g_other);
}
)";
constexpr int kLocalSizeX = 4;
constexpr int kWorkGroups = 2;
constexpr unsigned int kInvocations = kLocalSizeX * kWorkGroups;
// Deliberately non-zero: the shader adds to whatever the application uploaded, so a seed
// that survives is also proof that the buffer's CPU-side contents reached the driver.
constexpr unsigned int kSeedFirst = 5;
constexpr unsigned int kSeedSecond = 100;
constexpr unsigned int kSeedOther = 7;
class AtomicCounterScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
GLint counters = 0;
glGetIntegerv(GL_MAX_COMPUTE_ATOMIC_COUNTERS, &counters);
GLint buffers = 0;
glGetIntegerv(GL_MAX_COMPUTE_ATOMIC_COUNTER_BUFFERS, &buffers);
if (counters < 3 || buffers < 2) {
GTEST_SKIP() << "GL_MAX_COMPUTE_ATOMIC_COUNTERS is " << counters
<< " and GL_MAX_COMPUTE_ATOMIC_COUNTER_BUFFERS is " << buffers
<< "; this needs 3 and 2";
}
if (!AtomicCountersAreWired()) {
GTEST_SKIP() << "atomic counter buffers are not wired up on " << Gl().BackendName()
<< " yet: glslang lowers them onto a storage block and that block's descriptor "
<< "is still resolved from the shader-storage binding points";
}
m_program = CompileComputeProgram(kCounterComputeSource);
ASSERT_NE(m_program, 0u) << m_buildLog;
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
if (!m_buffers.empty()) glDeleteBuffers(static_cast<GLsizei>(m_buffers.size()), m_buffers.data());
if (m_program != 0) glDeleteProgram(m_program);
m_buffers.clear();
m_program = 0;
}
// Magma binds the lowered block as an ordinary storage-buffer descriptor resolved
// from GL_SHADER_STORAGE_BUFFER point N, so the counter buffer never reaches it. The
// frontend half (limits, reflection queries, the link-time offset rules) is
// backend-agnostic and is covered by the unit suites; only the VALUE is scoped here.
bool AtomicCountersAreWired() const { return Gl().BackendName() != "DirectVulkan"; }
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 counter buffer of `count` uints, seeded and bound to atomic-counter point
// `binding`.
GLuint MakeCounterBuffer(GLuint binding, const std::vector<unsigned int>& seed) {
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, buffer);
glBufferData(GL_ATOMIC_COUNTER_BUFFER,
static_cast<GLsizeiptr>(seed.size() * sizeof(unsigned int)), seed.data(),
GL_DYNAMIC_DRAW);
glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, binding, buffer);
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, 0);
m_buffers.push_back(buffer);
return buffer;
}
std::vector<unsigned int> ReadCounters(GLuint buffer, int count) {
std::vector<unsigned int> values(static_cast<std::size_t>(count), 0xDEADBEEFu);
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, buffer);
glGetBufferSubData(GL_ATOMIC_COUNTER_BUFFER, 0,
static_cast<GLsizeiptr>(values.size() * sizeof(unsigned int)), values.data());
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, 0);
return values;
}
void Dispatch() {
glUseProgram(m_program);
glDispatchCompute(kWorkGroups, 1, 1);
glMemoryBarrier(GL_ATOMIC_COUNTER_BARRIER_BIT | GL_BUFFER_UPDATE_BARRIER_BIT);
}
unsigned int m_program = 0;
std::string m_buildLog;
std::vector<GLuint> m_buffers;
};
} // namespace
// The counter values a dispatch leaves behind, per binding point and per offset within one
// binding. Nothing in the ES backend used to touch BufferTarget::AtomicCounter at all, so
// before the wiring landed every one of these read back its seed unchanged.
TEST_F(AtomicCounterScenario, DispatchIncrementsTheBoundCounterBuffers) {
if (!Ready() || IsSkipped()) return;
const GLuint zero = MakeCounterBuffer(0, {kSeedFirst, kSeedSecond});
const GLuint one = MakeCounterBuffer(1, {kSeedOther});
ASSERT_EQ(FirstGLError(), 0u) << "binding the counter buffers raised a GL error";
Dispatch();
EXPECT_EQ(FirstGLError(), 0u) << "the dispatch raised a GL error";
const std::vector<unsigned int> zeroValues = ReadCounters(zero, 2);
const std::vector<unsigned int> oneValues = ReadCounters(one, 1);
EXPECT_EQ(FirstGLError(), 0u) << "reading the counters back raised a GL error";
EXPECT_EQ(zeroValues[0], kSeedFirst + kInvocations)
<< "binding 0 offset 0 read back " << zeroValues[0] << "; " << kSeedFirst
<< " means the shader's increments never reached the buffer the application bound";
EXPECT_EQ(zeroValues[1], kSeedSecond + 2 * kInvocations)
<< "binding 0 offset 4 read back " << zeroValues[1] << "; the seed means the counter at a NON-ZERO "
<< "offset was not carried through the lowering, even though offset 0 was";
EXPECT_EQ(oneValues[0], kSeedOther + kInvocations)
<< "binding 1 read back " << oneValues[0] << "; a counter buffer past the first binding point "
<< "resolves to a different reserved slot and is where an off-by-one shows up";
}
// A second dispatch continues from where the first left off, and a re-seed between them is
// visible to the shader. Both halves of the buffer's traffic have to work, in both
// directions: the increments are only observable through the readback path, and the re-seed
// is only observable if the upload reaches the driver AFTER the buffer has been GPU-written.
TEST_F(AtomicCounterScenario, CountersAccumulateAcrossDispatchesAndFollowAReseed) {
if (!Ready() || IsSkipped()) return;
const GLuint zero = MakeCounterBuffer(0, {0u, 0u});
MakeCounterBuffer(1, {0u});
ASSERT_EQ(FirstGLError(), 0u);
Dispatch();
Dispatch();
std::vector<unsigned int> values = ReadCounters(zero, 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(values[0], 2 * kInvocations) << "two dispatches did not accumulate";
EXPECT_EQ(values[1], 4 * kInvocations) << "two dispatches did not accumulate at offset 4";
const unsigned int reseed[2] = {1000u, 2000u};
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, zero);
glBufferSubData(GL_ATOMIC_COUNTER_BUFFER, 0, sizeof(reseed), reseed);
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, 0);
ASSERT_EQ(FirstGLError(), 0u) << "re-seeding the counter buffer raised a GL error";
Dispatch();
values = ReadCounters(zero, 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(values[0], reseed[0] + kInvocations) << "the re-seeded value did not reach the shader";
EXPECT_EQ(values[1], reseed[1] + 2 * kInvocations) << "the re-seeded value at offset 4 did not reach the shader";
}
} // namespace MGITest
@@ -299,4 +299,99 @@ void main() {
EXPECT_EQ(FirstGLError(), 0u);
}
// glGetTexLevelParameter used to refuse EVERY pname on a buffer texture: WIDTH/HEIGHT/DEPTH
// fell out of a mipmap-only switch as GL_INVALID_OPERATION, and GL_TEXTURE_BUFFER_SIZE /
// GL_TEXTURE_BUFFER_OFFSET were not in the switch at all, so they came back GL_INVALID_ENUM.
// KHR-GL43.texture_buffer wraps both queries in GLU_EXPECT_NO_ERROR, so the error alone fails
// the case before any value is compared.
//
// The two halves report DIFFERENT units and only one of them is clamped, which is the thing
// easiest to get backwards: WIDTH is a TEXEL count clamped to GL_MAX_TEXTURE_BUFFER_SIZE,
// BUFFER_SIZE is the range in basic machine units exactly as it was given.
TEST_F(BufferTextureScenario, LevelQueriesDescribeTheAttachedBufferRange) {
if (!Ready()) return;
FirstGLError();
GLint offsetAlignment = 1;
glGetIntegerv(GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT, &offsetAlignment);
if (offsetAlignment < 1) offsetAlignment = 1;
GLint maxTexels = 0;
glGetIntegerv(GL_MAX_TEXTURE_BUFFER_SIZE, &maxTexels);
ASSERT_EQ(FirstGLError(), 0u);
ASSERT_GT(maxTexels, 0) << "an OpenGL 4.x context may not advertise a zero buffer-texture limit";
constexpr GLint kTexelBytes = 4; // GL_RGBA8
const GLsizeiptr rangeOffset = static_cast<GLsizeiptr>(offsetAlignment);
const GLsizeiptr rangeBytes = 32 * kTexelBytes;
// Deliberately bigger than the range, so a getter that answered out of the BUFFER rather
// than out of the texture's window would be caught.
const GLsizeiptr bufferBytes = rangeOffset + rangeBytes + 16 * kTexelBytes;
const std::vector<GLubyte> zeros(static_cast<size_t>(bufferBytes), 0);
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_TEXTURE_BUFFER, buffer);
glBufferData(GL_TEXTURE_BUFFER, bufferBytes, zeros.data(), GL_STATIC_DRAW);
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_BUFFER, texture);
glTexBufferRange(GL_TEXTURE_BUFFER, GL_RGBA8, buffer, rangeOffset, rangeBytes);
ASSERT_EQ(FirstGLError(), 0u) << "glTexBufferRange(GL_RGBA8) was refused";
const auto levelQuery = [](GLenum pname) {
GLint value = -1;
glGetTexLevelParameteriv(GL_TEXTURE_BUFFER, 0, pname, &value);
return value;
};
const auto levelQueryF = [](GLenum pname) {
GLfloat value = -1.0f;
glGetTexLevelParameterfv(GL_TEXTURE_BUFFER, 0, pname, &value);
return value;
};
EXPECT_EQ(levelQuery(GL_TEXTURE_WIDTH), static_cast<GLint>(rangeBytes / kTexelBytes))
<< "GL_TEXTURE_WIDTH is a texel count over the attached RANGE";
EXPECT_EQ(levelQuery(GL_TEXTURE_HEIGHT), 1);
EXPECT_EQ(levelQuery(GL_TEXTURE_DEPTH), 1);
EXPECT_EQ(levelQuery(GL_TEXTURE_BUFFER_SIZE), static_cast<GLint>(rangeBytes))
<< "GL_TEXTURE_BUFFER_SIZE reports basic machine units, not texels";
EXPECT_EQ(levelQuery(GL_TEXTURE_BUFFER_OFFSET), static_cast<GLint>(rangeOffset));
EXPECT_EQ(FirstGLError(), 0u) << "a buffer-texture level query raised an error";
EXPECT_LE(levelQuery(GL_TEXTURE_WIDTH), maxTexels)
<< "GL_TEXTURE_WIDTH must stay clamped to GL_MAX_TEXTURE_BUFFER_SIZE";
// The float getter is a separate switch and has drifted from the integer one before.
EXPECT_FLOAT_EQ(levelQueryF(GL_TEXTURE_WIDTH), static_cast<GLfloat>(rangeBytes / kTexelBytes));
EXPECT_FLOAT_EQ(levelQueryF(GL_TEXTURE_HEIGHT), 1.0f);
EXPECT_FLOAT_EQ(levelQueryF(GL_TEXTURE_BUFFER_SIZE), static_cast<GLfloat>(rangeBytes));
EXPECT_EQ(FirstGLError(), 0u) << "the float form of a buffer-texture level query raised an error";
// The whole-buffer form follows the buffer's current size instead of freezing a window.
glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA8, buffer);
EXPECT_EQ(levelQuery(GL_TEXTURE_BUFFER_OFFSET), 0);
EXPECT_EQ(levelQuery(GL_TEXTURE_BUFFER_SIZE), static_cast<GLint>(bufferBytes));
EXPECT_EQ(levelQuery(GL_TEXTURE_WIDTH), static_cast<GLint>(bufferBytes / kTexelBytes));
EXPECT_EQ(FirstGLError(), 0u);
// Both buffer pnames belong to buffer textures alone; anything else is INVALID_OPERATION,
// the same shape GL_TEXTURE_COMPRESSED_IMAGE_SIZE uses for an uncompressed image.
GLuint plainTexture = 0;
glGenTextures(1, &plainTexture);
glBindTexture(GL_TEXTURE_2D, plainTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
EXPECT_EQ(FirstGLError(), 0u);
GLint unused = -1;
glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_BUFFER_SIZE, &unused);
EXPECT_EQ(FirstGLError(), static_cast<unsigned int>(GL_INVALID_OPERATION));
glBindTexture(GL_TEXTURE_2D, 0);
glBindTexture(GL_TEXTURE_BUFFER, 0);
glBindBuffer(GL_TEXTURE_BUFFER, 0);
glDeleteTextures(1, &plainTexture);
glDeleteTextures(1, &texture);
glDeleteBuffers(1, &buffer);
EXPECT_EQ(FirstGLError(), 0u);
}
} // namespace MGITest
@@ -151,6 +151,18 @@ void main() { fragColor = vec4(0.0, 1.0, 0.0, 1.0); }
glReadPixels(x, y, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, out);
}
// GL_MAX_CLIP_DISTANCES is a real backend answer, not a constant: DirectGLES reports
// 0 on a driver without GL_EXT_clip_cull_distance, and DirectVulkan reports 0 without
// the shaderClipDistance device feature. On such a stack the shader above cannot
// compile - and MUST not, because declaring a clip distance the backend cannot host
// is exactly what used to link cleanly and then render nothing. Skip rather than
// fail: there is no clipping to assert about.
static bool BackendHostsTwoClipDistances() {
GLint maxClipDistances = 0;
glGetIntegerv(GL_MAX_CLIP_DISTANCES, &maxClipDistances);
return maxClipDistances >= 2;
}
// Never assume the eight start disabled - see the header note about
// XfbAfterClipDistanceScenario leaving one on for the rest of the process.
static void DisableEveryClipDistance() {
@@ -229,6 +241,9 @@ void main() { fragColor = vec4(0.0, 1.0, 0.0, 1.0); }
// The claim: an enabled clip distance removes the fragments where it is negative.
TEST_F(ClipDistanceScenario, AnEnabledClipDistanceRemovesTheNegativeHalf) {
if (!Ready()) return;
if (!BackendHostsTwoClipDistances()) {
GTEST_SKIP() << "this backend advertises no clip distances, so there is nothing to clip with";
}
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
@@ -280,6 +295,9 @@ void main() { fragColor = vec4(0.0, 1.0, 0.0, 1.0); }
// draw simply failed - would pass the case above.
TEST_F(ClipDistanceScenario, ADisabledClipDistanceRemovesNothing) {
if (!Ready()) return;
if (!BackendHostsTwoClipDistances()) {
GTEST_SKIP() << "this backend advertises no clip distances, so there is nothing to clip with";
}
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
@@ -329,6 +347,9 @@ void main() { fragColor = vec4(0.0, 1.0, 0.0, 1.0); }
// passes both cases above and fails this one.
TEST_F(ClipDistanceScenario, TheEnablesAreIndependentPerDistance) {
if (!Ready()) return;
if (!BackendHostsTwoClipDistances()) {
GTEST_SKIP() << "this backend advertises no clip distances, so there is nothing to clip with";
}
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
@@ -6,27 +6,32 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - GLSL DOUBLES, RUN AT SINGLE PRECISION.
// Scenario - GLSL DOUBLES, AT WHATEVER PRECISION THE BACKEND CAN GIVE.
//
// No mobile GPU has 64-bit floats. Adreno and Mali both report shaderFloat64 == VK_FALSE, so
// Magma cannot build a module that declares the Float64 capability, and ESSL has no fp64 type
// at all, so SPIRV-Cross refuses the module outright on Espryt ("FP64 not supported in ES
// profile") and the program never reaches the driver. MobileGL therefore narrows every 64-bit
// float in a shader to 32 bits (ShaderTranspiler::DemoteFloat64Pass) rather than declining the
// shader: `double` compiles and runs everywhere, at float precision.
// Magma cannot build a module that declares the Float64 capability there, and ESSL has no fp64
// type at all, so SPIRV-Cross refuses the module outright on Espryt ("FP64 not supported in ES
// profile") and the program never reaches the driver. On every such backend MobileGL narrows
// every 64-bit float in a shader to 32 bits (ShaderTranspiler::DemoteFloat64Pass) rather than
// declining the shader: `double` compiles and runs everywhere, at float precision. Where the
// backend DOES consume 64-bit floats - lavapipe is the one that does - the narrowing is skipped
// and the doubles reach the driver whole.
//
// The narrowing is only half a contract. The other half is the API side: the global UBO is
// laid out by reflecting the DEMOTED module, so glUniform*d has to store a float where the
// shader reads a float, glGetUniform*v has to read one back, and a dmat4's columns are now
// std140-padded like any other matrix's. Every one of those is a byte offset that fails
// silently - the uniform simply reads as something else - so the cases below set values
// through the API and have the SHADER report what it saw.
// Either way it is only half a contract. The other half is the API side: the global UBO is laid
// out by reflecting whichever module was produced, so glUniform*d has to store the width the
// shader reads, glGetUniform*v has to read that width back, and a matrix's columns are
// std140-padded to a vec4 or a dvec4 to match. Every one of those is a byte offset that fails
// silently - the uniform simply reads as something else - so the cases below set values through
// the API and have the SHADER report what it saw.
//
// What is deliberately NOT asserted: that the values are exact to double precision. They are
// not, and cannot be. Every expectation here is the float value of the double that was set,
// which is the whole point.
// WHY ALMOST EVERY EXPECTATION HERE IS A FLOAT VALUE, and why that is not an accident of the
// demotion: the shader reports through a `float` SSBO, and every value chosen is exact in
// float32, so the same number is correct in both regimes and the assertions test the LAYOUT
// rather than the precision. Exactly one case (GetUniformdvReadsBackWhatWasStored) uses a value
// that is not - 0.1 - and it names both answers explicitly.
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
@@ -153,6 +158,151 @@ void main() {
std::string m_buildLog;
};
// A SHADER STORAGE BLOCK that holds doubles is the one place the narrowing is NOT free:
// demoting `double` to `float` also repacks the block, and the bytes the application
// wrote into the buffer do not move with it. Every member past the first double then
// reads and writes at the wrong offset, and the block is simply shorter than the one
// that was bound - the tail of it is never touched at all
// (KHR-GL43.shader_storage_buffer_object.basic-stdLayout-case3, whose output matched its
// input up to the first double's slot and was zero from there on).
//
// The block layout is fixed by GL 4.6 core 7.6.2.2 and is asserted here as literal byte
// offsets rather than queried, so this says what the SPEC requires and not what MobileGL
// happens to report. Both packings are covered because they differ in exactly the places
// that matter: std140 rounds an array's stride and a matrix's column stride up to 16,
// std430 does not, and only std430 packs the scalars tightly.
//
// Every value is exactly representable in binary32, so a correct implementation copies
// the block BYTE FOR BYTE even though it narrows each double on the way through.
constexpr const char* kBlockCopySource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std140, binding = 0) buffer In140 {
int data0;
float data1[3];
mat3x2 data2;
double data3;
double data4[2];
int data5;
dvec3 data6;
} g_in140;
layout(std430, binding = 1) buffer In430 {
int data0;
float data1[3];
mat3x2 data2;
double data3;
double data4[2];
int data5;
dvec3 data6;
} g_in430;
layout(std140, binding = 2) buffer Out140 {
int data0;
float data1[3];
mat3x2 data2;
double data3;
double data4[2];
int data5;
dvec3 data6;
} g_out140;
layout(std430, binding = 3) buffer Out430 {
int data0;
float data1[3];
mat3x2 data2;
double data3;
double data4[2];
int data5;
dvec3 data6;
} g_out430;
void main() {
g_out140.data0 = g_in140.data0;
for (int i = 0; i < 3; ++i) g_out140.data1[i] = g_in140.data1[i];
g_out140.data2 = g_in140.data2;
g_out140.data3 = g_in140.data3;
for (int i = 0; i < 2; ++i) g_out140.data4[i] = g_in140.data4[i];
g_out140.data5 = g_in140.data5;
g_out140.data6 = g_in140.data6;
g_out430.data0 = g_in430.data0;
for (int i = 0; i < 3; ++i) g_out430.data1[i] = g_in430.data1[i];
g_out430.data2 = g_in430.data2;
g_out430.data3 = g_in430.data3;
for (int i = 0; i < 2; ++i) g_out430.data4[i] = g_in430.data4[i];
g_out430.data5 = g_in430.data5;
g_out430.data6 = g_in430.data6;
}
)";
// GL 4.6 core 7.6.2.2 rule by rule, for the block above.
// std140: an array's element stride and a matrix's column stride round up to 16, a
// double aligns to 8 and a dvec3 to 32.
// std430: the same without the rounding - so the scalars pack tightly and only the
// dvec3's 32-byte alignment leaves a hole.
struct BlockLayout {
int data0;
int data1;
int data1Stride;
int data2;
int data2ColumnStride;
int data3;
int data4;
int data4Stride;
int data5;
int data6;
int size;
};
constexpr BlockLayout kStd140{0, 16, 16, 64, 16, 112, 128, 16, 160, 192, 216};
constexpr BlockLayout kStd430{0, 4, 4, 16, 8, 40, 48, 8, 64, 96, 120};
void PokeInt(std::vector<unsigned char>& bytes, int offset, int value) {
std::memcpy(&bytes[static_cast<std::size_t>(offset)], &value, sizeof(value));
}
void PokeFloat(std::vector<unsigned char>& bytes, int offset, float value) {
std::memcpy(&bytes[static_cast<std::size_t>(offset)], &value, sizeof(value));
}
void PokeDouble(std::vector<unsigned char>& bytes, int offset, double value) {
std::memcpy(&bytes[static_cast<std::size_t>(offset)], &value, sizeof(value));
}
// The block's contents, at the offsets the standard puts them. Padding stays zero, which
// is what makes a byte-for-byte comparison against the (zero-initialised) output buffer
// catch a member that landed somewhere it should not have.
std::vector<unsigned char> MakeBlockContents(const BlockLayout& layout) {
std::vector<unsigned char> bytes(static_cast<std::size_t>(layout.size), 0);
PokeInt(bytes, layout.data0, 1);
for (int i = 0; i < 3; ++i) {
PokeFloat(bytes, layout.data1 + i * layout.data1Stride, 2.0f + static_cast<float>(i));
}
// Column-major, two rows per column.
for (int column = 0; column < 3; ++column) {
for (int row = 0; row < 2; ++row) {
PokeFloat(bytes, layout.data2 + column * layout.data2ColumnStride + row * 4,
5.0f + static_cast<float>(column * 2 + row));
}
}
PokeDouble(bytes, layout.data3, 11.0);
for (int i = 0; i < 2; ++i) {
PokeDouble(bytes, layout.data4 + i * layout.data4Stride, 12.0 + static_cast<double>(i));
}
PokeInt(bytes, layout.data5, 14);
for (int i = 0; i < 3; ++i) {
PokeDouble(bytes, layout.data6 + i * 8, 15.0 + static_cast<double>(i));
}
return bytes;
}
// Names the first byte that differs, and which member owns it, so a failure is a
// diagnosis rather than "the buffer is wrong".
std::string DescribeOffset(const BlockLayout& layout, int offset) {
const std::pair<int, const char*> members[] = {
{layout.data0, "data0"}, {layout.data1, "data1"}, {layout.data2, "data2"},
{layout.data3, "data3"}, {layout.data4, "data4"}, {layout.data5, "data5"},
{layout.data6, "data6"}};
const char* owner = "(padding before data0)";
for (const auto& [start, name] : members) {
if (offset >= start) owner = name;
}
return std::string(owner);
}
// Every double-typed uniform shape GLSL has, all thirteen of them, in one program - the
// shape of KHR-GL43.compute_shader.fp64-case2. The scalar and the square matrices are
// covered by the cases above; what only a set like this reaches is the NON-SQUARE
@@ -428,12 +578,24 @@ void main() {
glUseProgram(0);
// The readback has to undo exactly what the write did - the same std140 column
// padding, the same 4-byte components - or a dmat4 comes back with its columns
// shifted and nothing else in the API would say so.
// padding, the same component width - or a dmat4 comes back with its columns
// shifted and nothing else in the API would say so. Every value below except the
// scalar is exact in float32, so those expectations pin the LAYOUT and hold in
// either regime; the scalar is the one that also pins the PRECISION.
GLdouble readScalar = 0.0;
glGetUniformdv(m_program, scalar, &readScalar);
EXPECT_DOUBLE_EQ(readScalar, static_cast<double>(static_cast<float>(0.1)))
<< "the value is what a float can hold, not the double that was passed in";
// 0.1 is not representable in float32, so what comes back names the regime: a
// backend without native fp64 narrowed it at the glUniform1d above (the module's own
// doubles were demoted, so its storage is 4 bytes per component), and one with it
// stored the double whole. Both are correct; asserting only the narrow answer would
// fail the moment fp64 stops being emulated, and asserting only the wide one would
// fail on every mobile device there is.
if (readScalar == 0.1) {
SUCCEED() << "this backend consumes 64-bit floats natively; the double survived whole";
} else {
EXPECT_DOUBLE_EQ(readScalar, static_cast<double>(static_cast<float>(0.1)))
<< "the value is what a float can hold, not the double that was passed in";
}
GLdouble readVector[3] = {};
glGetUniformdv(m_program, vector, readVector);
@@ -447,7 +609,8 @@ void main() {
EXPECT_DOUBLE_EQ(readMatrix[i], 100.0 + i) << "dmat4 component " << i;
}
// The float query sees the same storage through the type it is actually stored as.
// The float query sees the same storage through a narrower type, and answers the
// same float either way: GL 4.6 core 7.6 converts on the way out.
GLfloat readFloat = 0.0f;
glGetUniformfv(m_program, scalar, &readFloat);
EXPECT_FLOAT_EQ(readFloat, static_cast<float>(0.1));
@@ -697,24 +860,188 @@ void main() {
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
}
TEST_F(DoublePrecisionScenario, A64BitVertexFormatIsDeclinedOnEveryBackend) {
TEST_F(DoublePrecisionScenario, A64BitVertexFormatIsRecordedAndItsArrayIsDroppedAtDraw) {
if (!Ready()) return;
// The demotion leaves no 64-bit shader input to feed, so there is nothing a 64-bit
// vertex FETCH could be fetched into - on either backend, and no longer only on the
// ones whose device lacks shaderFloat64. Declined loudly rather than accepted and
// drawn as garbage; the matching POST row says the same thing at startup.
// ones whose device lacks shaderFloat64.
//
// What that costs is the ARRAY, not the CALL. GL 4.6 core 10.3.2 defines no error for
// a well-formed glVertexAttribLFormat and 64-bit attributes are core in the GL 4.3
// context MobileGL advertises, so refusing the call would be non-conformant and would
// leave four pure state queries unanswerable
// (KHR-GL43.vertex_attrib_binding.basic-state1/3). The format is therefore recorded and
// queryable; the enabled array is what gets dropped, and the attribute then reads its
// generic current value. The matching POST row says exactly that at startup.
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
while (glGetError() != GL_NO_ERROR) {}
glVertexAttribLFormat(0, 3, GL_DOUBLE, 0);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION));
glVertexAttribLFormat(1, 3, GL_DOUBLE, 8);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< "glVertexAttribLFormat is a legal call in a GL 4.3 context";
GLint attribSize = 0;
GLint attribType = 0;
GLint attribIsLong = 0;
GLint attribRelativeOffset = 0;
glGetVertexAttribiv(1, GL_VERTEX_ATTRIB_ARRAY_SIZE, &attribSize);
glGetVertexAttribiv(1, GL_VERTEX_ATTRIB_ARRAY_TYPE, &attribType);
glGetVertexAttribiv(1, GL_VERTEX_ATTRIB_ARRAY_LONG, &attribIsLong);
glGetVertexAttribiv(1, GL_VERTEX_ATTRIB_RELATIVE_OFFSET, &attribRelativeOffset);
EXPECT_EQ(attribSize, 3);
EXPECT_EQ(attribType, static_cast<GLint>(GL_DOUBLE));
EXPECT_EQ(attribIsLong, GL_TRUE) << "GL_VERTEX_ATTRIB_ARRAY_LONG is what makes this the "
"unconverted form; without it the state is a lie";
EXPECT_EQ(attribRelativeOffset, 8);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
while (glGetError() != GL_NO_ERROR) {}
}
// The consequence of recording the state rather than refusing the call: a 64-bit array can
// now be ENABLED in a VAO that a draw uses, which it never could before. That must not
// take the draw down. Leaving such an array enabled with no pointer behind it is exactly
// the documented Adreno null-deref (SIGSEGV inside the next glDraw*), so DirectGLES
// disables it before glVertexAttribPointer can ever see GL_DOUBLE, and DirectVulkan maps
// the format to VK_FORMAT_UNDEFINED so it never enters the pipeline's vertex input state.
//
// The shader deliberately does NOT read location 1: that keeps the two backends on the
// same path (DirectVulkan declines a draw whose SHADER reads an unsupported enabled array,
// by design and loudly, which is a different assertion from this one) and it is the shape
// the crash needed - an enabled array nothing set a pointer for.
TEST_F(DoublePrecisionScenario, AnEnabledLongArrayDoesNotBreakADrawThatIgnoresIt) {
if (!Ready()) return;
constexpr const char* kVs = R"(#version 430 core
layout(location = 0) in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
)";
constexpr const char* kFs = R"(#version 430 core
out vec4 o_color;
void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
)";
std::string error;
const unsigned int program = CompileProgram(kVs, kFs, &error);
ASSERT_NE(program, 0u) << error;
ColorFbo target = MakeColorFbo(32, 32);
ASSERT_NE(target.fbo, 0u) << "could not create the render target";
BindFbo(target);
const float positions[8] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
const double doubles[4] = {1.0, 2.0, 3.0, 4.0};
GLuint vao = 0;
GLuint positionBuffer = 0;
GLuint doubleBuffer = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &positionBuffer);
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);
glGenBuffers(1, &doubleBuffer);
glBindBuffer(GL_ARRAY_BUFFER, doubleBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(doubles), doubles, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glVertexAttribFormat(0, 2, GL_FLOAT, GL_FALSE, 0);
glVertexAttribBinding(0, 0);
glBindVertexBuffer(0, positionBuffer, 0, static_cast<GLsizei>(2 * sizeof(float)));
glEnableVertexAttribArray(0);
glVertexAttribLFormat(1, 1, GL_DOUBLE, 0);
glVertexAttribBinding(1, 1);
glBindVertexBuffer(1, doubleBuffer, 0, static_cast<GLsizei>(sizeof(double)));
glEnableVertexAttribArray(1);
EXPECT_EQ(FirstGLError(), 0u) << "setting up the 64-bit array was refused";
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
EXPECT_EQ(FirstGLError(), 0u) << "a draw with an enabled 64-bit array must not raise an error";
const Image image = ReadPixels(target.width, target.height);
ASSERT_FALSE(image.Empty());
EXPECT_GT(image.At(target.width / 2, target.height / 2).g, 200)
<< "the draw did not happen; the enabled 64-bit array must be dropped, not fatal";
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &positionBuffer);
glDeleteBuffers(1, &doubleBuffer);
BindDefaultFramebuffer();
DestroyColorFbo(target);
glUseProgram(0);
glDeleteProgram(program);
EXPECT_EQ(FirstGLError(), 0u);
}
TEST_F(DoublePrecisionScenario, AStorageBlockWithDoublesKeepsTheLayoutItWasBoundWith) {
if (!Ready()) return;
GLint blocks = 0;
glGetIntegerv(GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, &blocks);
if (blocks < 4) {
GTEST_SKIP() << "GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS is " << blocks << "; this needs 4";
}
const unsigned int program = CompileComputeProgram(kBlockCopySource);
ASSERT_NE(program, 0u) << m_buildLog;
const std::vector<unsigned char> in140 = MakeBlockContents(kStd140);
const std::vector<unsigned char> in430 = MakeBlockContents(kStd430);
const std::vector<unsigned char> zero140(in140.size(), 0);
const std::vector<unsigned char> zero430(in430.size(), 0);
GLuint buffers[4] = {};
glGenBuffers(4, buffers);
const std::vector<unsigned char>* contents[4] = {&in140, &in430, &zero140, &zero430};
for (int i = 0; i < 4; ++i) {
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, static_cast<GLuint>(i), buffers[i]);
glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(contents[i]->size()),
contents[i]->data(), GL_DYNAMIC_COPY);
}
ASSERT_EQ(FirstGLError(), 0u);
glUseProgram(program);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
EXPECT_EQ(FirstGLError(), 0u);
for (int pass = 0; pass < 2; ++pass) {
const BlockLayout& layout = pass == 0 ? kStd140 : kStd430;
const std::vector<unsigned char>& expected = pass == 0 ? in140 : in430;
const char* packing = pass == 0 ? "std140" : "std430";
std::vector<unsigned char> observed(expected.size(), 0xEE);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffers[2 + pass]);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0,
static_cast<GLsizeiptr>(observed.size()), observed.data());
int mismatches = 0;
int firstMismatch = -1;
for (std::size_t i = 0; i < expected.size(); ++i) {
if (expected[i] == observed[i]) continue;
++mismatches;
if (firstMismatch < 0) firstMismatch = static_cast<int>(i);
}
EXPECT_EQ(mismatches, 0)
<< packing << " block: " << mismatches << " of " << expected.size()
<< " bytes differ, first at byte " << firstMismatch << " (in "
<< DescribeOffset(layout, firstMismatch < 0 ? 0 : firstMismatch)
<< "); a block that was repacked around its doubles reads and writes every "
"member after the first one at the wrong offset";
}
glUseProgram(0);
glDeleteProgram(program);
glDeleteBuffers(4, buffers);
EXPECT_EQ(FirstGLError(), 0u);
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,211 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/FormatlessImageBakeScenario.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 FORMAT-LESS IMAGE UNIFORM WHOSE UNIT HOLDS A NON-CORE FORMAT.
//
// GLSL 4.20 lets a write-only image uniform omit its layout format; GLSL ES demands one, so
// DirectGLES BAKES the format of whatever glBindImageTexture put on the unit into the
// declaration. When that format is outside the GLSL ES core thirteen, the bake alone is not
// enough - the baked declaration then has to go through the same channel-widening
// WidenImageFormatsForEssl gives a DECLARED non-core format (see NonCoreImageFormatScenario for
// the widening itself).
//
// The two routes had different arming. The declared route armed the widening on the format
// alone; the baked route armed it only when the driver lacked GL_NV_image_formats. That reads
// like an optimisation and is not one: SPIRV-Cross throws for its is_desktop_only_format set the
// moment it targets ESSL, whatever the driver would have accepted, so on a driver that HAS the
// extension the shader half of the widening stayed switched off while TextureImpl's storage/bind
// half - which keys on SpirvCrossCanPrintEsslImageFormat, not on the driver bit - still ran. The
// stage threw, the program linked without it, and every dispatch silently did nothing.
//
// KHR-GL43.stencil_texturing.functional is where it surfaced: its compute half writes through a
// format-less `uimage2D` bound to an R8UI texture, and returned zeros for every texel.
//
// DISCRIMINATING ONLY WHERE THE DRIVER ADVERTISES GL_NV_image_formats - Mesa does, which is what
// the software lanes run and where this was found. On Adreno 830 and both Malis the extension is
// absent, the old code already armed the widening, and these cases pass before and after; they
// are kept running there as a guard against the opposite mistake.
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kExtent = 8;
// No layout format on uni_image on purpose: that is the whole subject. uni_source is a
// plain integer texture so nothing but the image declaration is in play.
const char* const kComputeSource = R"(#version 430 core
layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
writeonly uniform uimage2D uni_image;
uniform usampler2D uni_source;
void main()
{
ivec2 at = ivec2(gl_GlobalInvocationID.xy);
imageStore(uni_image, at, uvec4(texelFetch(uni_source, at, 0).r, 0u, 0u, 0u));
}
)";
class FormatlessImageBakeScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
if (!BackendHostsCompute()) {
GTEST_SKIP() << "no compute stage on " << Gl().BackendName() << " ("
<< Gl().RendererString() << ")";
}
}
static bool BackendHostsCompute() {
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
DrainErrors();
return maxImageUnits >= 2;
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
static GLuint BuildCompute(const char* source, std::string& log) {
const GLuint cs = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(cs, 1, &source, nullptr);
glCompileShader(cs);
GLint ok = 0;
glGetShaderiv(cs, GL_COMPILE_STATUS, &ok);
if (!ok) {
char buffer[2048] = "";
glGetShaderInfoLog(cs, sizeof(buffer), nullptr, buffer);
log = buffer;
glDeleteShader(cs);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, cs);
glLinkProgram(program);
glGetProgramiv(program, GL_LINK_STATUS, &ok);
glDeleteShader(cs);
if (!ok) {
char buffer[2048] = "";
glGetProgramInfoLog(program, sizeof(buffer), nullptr, buffer);
log = buffer;
glDeleteProgram(program);
return 0;
}
return program;
}
// internalFormat is the NON-CORE image format under test; the destination texture and
// the glBindImageTexture argument both use it, and the shader declares nothing.
void RunCopy(GLenum internalFormat, GLenum uploadFormat, GLenum uploadType) {
std::vector<GLuint> expected(kExtent * kExtent);
for (int i = 0; i < kExtent * kExtent; ++i) {
expected[i] = static_cast<GLuint>(1 + i);
}
// Source: a core-format integer texture holding 1..64.
std::vector<GLubyte> sourceBytes(kExtent * kExtent);
for (int i = 0; i < kExtent * kExtent; ++i) {
sourceBytes[i] = static_cast<GLubyte>(expected[i]);
}
GLuint sourceTexture = 0;
glGenTextures(1, &sourceTexture);
glBindTexture(GL_TEXTURE_2D, sourceTexture);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_R8UI, kExtent, kExtent);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kExtent, kExtent, GL_RED_INTEGER, GL_UNSIGNED_BYTE,
sourceBytes.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
// Destination: the format under test, zero-filled so "the dispatch did nothing"
// and "the dispatch wrote zeros" are the same observation the CTS made.
GLuint destTexture = 0;
glGenTextures(1, &destTexture);
glBindTexture(GL_TEXTURE_2D, destTexture);
glTexStorage2D(GL_TEXTURE_2D, 1, internalFormat, kExtent, kExtent);
const std::vector<GLubyte> zeros(static_cast<std::size_t>(kExtent) * kExtent * 8, 0);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kExtent, kExtent, uploadFormat, uploadType, zeros.data());
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "destination storage";
std::string log;
const GLuint program = BuildCompute(kComputeSource, log);
ASSERT_NE(program, 0u) << "the format-less image program did not build: " << log;
glUseProgram(program);
glBindImageTexture(1, destTexture, 0, GL_FALSE, 0, GL_WRITE_ONLY, internalFormat);
glUniform1i(glGetUniformLocation(program, "uni_image"), 1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, sourceTexture);
glUniform1i(glGetUniformLocation(program, "uni_source"), 1);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "binding";
glDispatchCompute(kExtent, kExtent, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "dispatch";
std::vector<GLuint> readback(kExtent * kExtent, 0xFFFFFFFFu);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, destTexture);
glGetTexImage(GL_TEXTURE_2D, 0, GL_RED_INTEGER, GL_UNSIGNED_INT, readback.data());
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "readback";
int offenders = 0;
for (int i = 0; i < kExtent * kExtent; ++i) {
if (readback[i] != expected[i]) ++offenders;
}
EXPECT_EQ(offenders, 0) << "the dispatch wrote " << offenders << " of "
<< (kExtent * kExtent) << " texels wrongly; texel 0 was "
<< readback[0] << ", expected " << expected[0]
<< ". A whole stage lost to the ESSL emitter looks exactly like this.";
glUseProgram(0);
glDeleteProgram(program);
glDeleteTextures(1, &sourceTexture);
glDeleteTextures(1, &destTexture);
DrainErrors();
}
};
// R8UI: one of the seven formats GLSL ES reaches only through GL_NV_image_formats AND one
// SPIRV-Cross refuses to print for ESSL, so it needs the widening in both driver modes.
TEST_F(FormatlessImageBakeScenario, R8uiBakedFromTheBoundUnitStillReachesTheDriver) {
if (!Ready()) GTEST_SKIP();
RunCopy(GL_R8UI, GL_RED_INTEGER, GL_UNSIGNED_BYTE);
}
// R16UI, from the same set, carried in RGBA16UI: the fix must not be R8UI-shaped.
TEST_F(FormatlessImageBakeScenario, R16uiBakedFromTheBoundUnitStillReachesTheDriver) {
if (!Ready()) GTEST_SKIP();
RunCopy(GL_R16UI, GL_RED_INTEGER, GL_UNSIGNED_SHORT);
}
// The control: R32UI is in the GLSL ES core thirteen, so it is baked and never widened.
// It passed before the fix and has to keep passing.
TEST_F(FormatlessImageBakeScenario, CoreFormatBakedFromTheBoundUnitIsUnaffected) {
if (!Ready()) GTEST_SKIP();
RunCopy(GL_R32UI, GL_RED_INTEGER, GL_UNSIGNED_INT);
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,413 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/GeometryDrawModeScenario.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 GEOMETRY SHADER'S INPUT PRIMITIVE CONSTRAINS THE DRAW MODE, AND
// GL_NONE IS NOT A USABLE "NO GEOMETRY SHADER" SENTINEL.
//
// GL 4.6 core 11.3.1: mode must be one of the primitive types that decomposes into the
// geometry shader's declared input primitive, or the draw is GL_INVALID_OPERATION. The
// validator asked "is there a geometry stage?" by comparing the REFLECTED INPUT PRIMITIVE
// against GL_NONE - and GL_NONE and GL_POINTS are both 0, so a `layout(points) in` geometry
// shader answered "no geometry stage" and every mode sailed through. The rule was therefore
// dead for exactly the geometry shaders whose input primitive rejects the most modes.
//
// KHR-GL43.transform_feedback.api_errors_test is where it showed: it draws a points-in
// geometry program with GL_LINES through glDrawTransformFeedbackInstanced and requires
// INVALID_OPERATION. The bug is not specific to that entry point - every draw shares this
// validator - so the ordinary glDrawArrays spelling is pinned here too, and the lines-in
// program is the control that proves the rule was not simply widened.
//
// Needs a real context: the validator returns before this rule when no backend object is
// active, so the GPU-free negative-API suite cannot reach it.
#include <string>
#include <utility>
#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 {
const char* const kVertexSource = R"(#version 420 core
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
// The input primitive the CTS case uses, and the one the GL_NONE sentinel erased.
// `result` is here so the same program can be captured with transform feedback.
const char* const kPointsInGeometrySource = R"(#version 420 core
layout(points) in;
layout(points, max_vertices = 1) out;
out float result;
void main()
{
gl_Position = gl_in[0].gl_Position;
result = 1.0;
EmitVertex();
}
)";
const char* const kLinesInGeometrySource = R"(#version 420 core
layout(lines) in;
layout(points, max_vertices = 1) out;
void main()
{
gl_Position = gl_in[0].gl_Position;
EmitVertex();
}
)";
const char* const kFragmentSource = R"(#version 420 core
out vec4 fragColor;
void main()
{
fragColor = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
class GeometryDrawModeScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " ("
<< Gl().RendererString() << "); there is no input primitive to validate";
}
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
}
// The same real-backend probe IoBlockNameCollisionScenario uses: 0 on a DirectGLES
// driver without GL_EXT_geometry_shader and on a DirectVulkan device without the
// geometryShader feature.
static bool BackendHostsGeometry() {
GLint maxGeometryOutputVertices = 0;
glGetIntegerv(GL_MAX_GEOMETRY_OUTPUT_VERTICES, &maxGeometryOutputVertices);
DrainErrors();
return maxGeometryOutputVertices >= 4;
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
GLuint BuildProgram(const char* geometrySource, const char* capturedVarying = nullptr) {
const std::vector<std::pair<GLenum, const char*>> stages = {
{GL_VERTEX_SHADER, kVertexSource},
{GL_GEOMETRY_SHADER, geometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}};
std::vector<GLuint> shaders;
bool ok = true;
for (const auto& [stage, source] : stages) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
shaders.push_back(shader);
if (!compiled) {
m_buildLog = InfoLog(shader, true);
ok = false;
break;
}
}
if (!ok) {
for (const GLuint shader : shaders) glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
for (const GLuint shader : shaders) glAttachShader(program, shader);
if (capturedVarying != nullptr) {
glTransformFeedbackVaryings(program, 1, &capturedVarying, GL_INTERLEAVED_ATTRIBS);
}
glLinkProgram(program);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
for (const GLuint shader : shaders) glDeleteShader(shader);
if (!linked) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
return 0;
}
m_programs.push_back(program);
return program;
}
static std::string InfoLog(GLuint object, bool isShader) {
GLint length = 0;
if (isShader) {
glGetShaderiv(object, GL_INFO_LOG_LENGTH, &length);
} else {
glGetProgramiv(object, GL_INFO_LOG_LENGTH, &length);
}
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
if (isShader) {
glGetShaderInfoLog(object, length + 1, nullptr, buffer.data());
} else {
glGetProgramInfoLog(object, length + 1, nullptr, buffer.data());
}
return buffer.data();
}
const std::string& BuildLog() const { return m_buildLog; }
GLuint m_vao = 0;
std::vector<GLuint> m_programs;
std::string m_buildLog;
};
// GL_POINTS is the only mode that decomposes into a points input primitive.
TEST_F(GeometryDrawModeScenario, PointsInGeometryProgramRejectsEveryOtherMode) {
if (!Ready()) GTEST_SKIP();
const GLuint program = BuildProgram(kPointsInGeometrySource);
ASSERT_NE(program, 0u) << "the points-in geometry program did not build: " << BuildLog();
glUseProgram(program);
DrainErrors();
for (const GLenum mode :
{static_cast<GLenum>(GL_LINES), static_cast<GLenum>(GL_LINE_STRIP),
static_cast<GLenum>(GL_TRIANGLES), static_cast<GLenum>(GL_TRIANGLE_STRIP)}) {
glDrawArrays(mode, 0, 3);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION))
<< "mode " << mode << " does not decompose into the geometry shader's points input";
DrainErrors();
}
// The one mode that IS compatible still draws.
glDrawArrays(GL_POINTS, 0, 1);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
DrainErrors();
}
// The same rule reached through glDrawTransformFeedback*, which is the spelling the CTS
// case asks about. The capture span is really completed first, so GL_POINTS comes back
// GL_NO_ERROR: without that the draw would report INVALID_OPERATION for the
// never-ended-a-span reason instead and the case could not tell the two apart.
TEST_F(GeometryDrawModeScenario, PointsInGeometryProgramRejectsNonPointModesOnFeedbackDraws) {
if (!Ready()) GTEST_SKIP();
const GLuint program = BuildProgram(kPointsInGeometrySource, "result");
ASSERT_NE(program, 0u) << "the points-in geometry program did not build: " << BuildLog();
GLuint feedback = 0;
glGenTransformFeedbacks(1, &feedback);
glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, feedback);
GLuint captureBuffer = 0;
glGenBuffers(1, &captureBuffer);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, captureBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 64, nullptr, GL_STATIC_DRAW);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer);
glUseProgram(program);
DrainErrors();
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, 1);
glEndTransformFeedback();
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "the capture span did not complete";
glDrawTransformFeedbackInstanced(GL_LINES, feedback, 1);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION))
<< "glDrawTransformFeedbackInstanced must honour the geometry input primitive";
DrainErrors();
glDrawTransformFeedbackStreamInstanced(GL_LINES, feedback, 0, 1);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION))
<< "glDrawTransformFeedbackStreamInstanced must honour the geometry input primitive";
DrainErrors();
// The compatible mode replays the captured span with no error at all, which is what
// makes the two assertions above about the MODE and not about the span.
glDrawTransformFeedbackInstanced(GL_POINTS, feedback, 1);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< "a compatible mode must still replay the captured span";
DrainErrors();
glUseProgram(0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, 0);
glDeleteBuffers(1, &captureBuffer);
glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, 0);
glDeleteTransformFeedbacks(1, &feedback);
DrainErrors();
}
// The control: a lines-in geometry shader is a NON-zero input primitive, so it exercised
// the rule even before the fix. It must still accept the line modes and still reject the
// others - a fix that widened the rule instead of repairing its guard breaks this.
TEST_F(GeometryDrawModeScenario, LinesInGeometryProgramStillAcceptsLineModesOnly) {
if (!Ready()) GTEST_SKIP();
const GLuint program = BuildProgram(kLinesInGeometrySource);
ASSERT_NE(program, 0u) << "the lines-in geometry program did not build: " << BuildLog();
glUseProgram(program);
DrainErrors();
for (const GLenum mode : {static_cast<GLenum>(GL_LINES), static_cast<GLenum>(GL_LINE_STRIP),
static_cast<GLenum>(GL_LINE_LOOP)}) {
glDrawArrays(mode, 0, 2);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< "mode " << mode << " decomposes into lines and must be accepted";
DrainErrors();
}
for (const GLenum mode : {static_cast<GLenum>(GL_POINTS), static_cast<GLenum>(GL_TRIANGLES)}) {
glDrawArrays(mode, 0, 3);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION))
<< "mode " << mode << " does not decompose into lines";
DrainErrors();
}
}
// The other half of "ask the stage": WHICH stage list is asked. gsInputPrimitive is a
// LINK artifact, so pairing it with the live attach list re-points the GL_NONE/GL_POINTS
// aliasing instead of removing it - inside the window between glAttachShader and the
// next link, the live list says "geometry present" while the artifact still reads
// GL_NONE, which is 0, which is GL_POINTS, so every mode but GL_POINTS is rejected.
//
// GL 4.6 core 7.3 makes that window legal and ordinary: an attach affects the program's
// executable only at the next link, and leaves LINK_STATUS alone. The attached shader
// need not even compile. Worse, it does not heal - glDetachShader defers the removal to
// the next Link() too, so the program would keep failing every non-POINTS draw until the
// application happened to relink for some unrelated reason.
TEST_F(GeometryDrawModeScenario, AttachingAGeometryStageAfterTheLinkDoesNotConstrainTheDrawMode) {
if (!Ready()) GTEST_SKIP();
// Deliberately NOT BuildProgram: the executable under test has no geometry stage.
const GLuint program = glCreateProgram();
m_programs.push_back(program);
for (const auto& [stage, source] :
std::vector<std::pair<GLenum, const char*>>{{GL_VERTEX_SHADER, kVertexSource},
{GL_FRAGMENT_SHADER, kFragmentSource}}) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
glAttachShader(program, shader);
glDeleteShader(shader);
}
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE) << "the vertex+fragment program did not link";
glUseProgram(program);
DrainErrors();
glDrawArrays(GL_TRIANGLES, 0, 3);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< "a program with no geometry stage must draw triangles";
DrainErrors();
const GLuint geometry = glCreateShader(GL_GEOMETRY_SHADER);
glShaderSource(geometry, 1, &kPointsInGeometrySource, nullptr);
glCompileShader(geometry);
glAttachShader(program, geometry);
glDeleteShader(geometry);
DrainErrors();
// Same executable as three lines ago - no relink has happened.
glDrawArrays(GL_TRIANGLES, 0, 3);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< "the attach does not reach the executable until the next link, so the geometry "
"shader's points input must not constrain this draw";
DrainErrors();
// And once it IS linked in, the rule applies - the fix must not have simply disabled it.
glLinkProgram(program);
glGetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE) << "the relink with the geometry stage failed";
glUseProgram(program);
DrainErrors();
glDrawArrays(GL_TRIANGLES, 0, 3);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION))
<< "now that the points-in geometry shader is in the executable, triangles must be rejected";
DrainErrors();
}
// The tessellation guard above the geometry one had the identical defect, and it does not
// even need the GL_NONE aliasing to misfire: it drives BOTH directions unconditionally, so
// reading the live attach list rejects every non-GL_PATCHES draw the moment an evaluation
// shader is attached, whether or not it was ever linked in.
TEST_F(GeometryDrawModeScenario, AttachingATessEvalStageAfterTheLinkDoesNotForceGlPatches) {
if (!Ready()) GTEST_SKIP();
GLint maxPatchVertices = 0;
glGetIntegerv(GL_MAX_PATCH_VERTICES, &maxPatchVertices);
DrainErrors();
if (maxPatchVertices < 3) GTEST_SKIP() << "no tessellation stage on this backend";
const GLuint program = glCreateProgram();
m_programs.push_back(program);
for (const auto& [stage, source] :
std::vector<std::pair<GLenum, const char*>>{{GL_VERTEX_SHADER, kVertexSource},
{GL_FRAGMENT_SHADER, kFragmentSource}}) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
glAttachShader(program, shader);
glDeleteShader(shader);
}
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE) << "the vertex+fragment program did not link";
glUseProgram(program);
DrainErrors();
static const char* const kTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, ccw) in;
void main()
{
gl_Position = gl_in[0].gl_Position;
}
)";
const GLuint tessEval = glCreateShader(GL_TESS_EVALUATION_SHADER);
glShaderSource(tessEval, 1, &kTessEvalSource, nullptr);
glCompileShader(tessEval);
glAttachShader(program, tessEval);
glDeleteShader(tessEval);
DrainErrors();
glDrawArrays(GL_TRIANGLES, 0, 3);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< "the executable still has no tessellation stage, so GL_PATCHES must not be required";
DrainErrors();
}
} // namespace
} // namespace MGITest
@@ -179,6 +179,13 @@ void main()
in flat uint v_index;
out vec4 o_color;
void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
)";
// The colour index spelled out at its default value. Says nothing that
// `layout(location = 0)` alone does not, and must therefore cost nothing.
constexpr const char* kExplicitColorIndexFS = R"(#version 420 core
layout(location = 0, index = 0) out vec4 o_color;
void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
)";
class Glsl420DeclarationScenario : public ScenarioTest {
@@ -473,4 +480,24 @@ void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
EXPECT_EQ(centre.g, 255) << "the atomic-counter shader linked but painted nothing";
}
// `layout(location = 0, index = 0)` is the GL default written out loud, and an application
// is entitled to write it - KHR-GL43.shader_atomic_counters.basic-program-query does. It has
// to reach the driver as an ORDINARY single-source output: GLSL ES has no `index` qualifier
// in core, so a transpiler that prints the decoration back gets "index layout qualifier
// requires EXT_blend_func_extended", the stage never compiles, the program runs with a stage
// missing and the draw paints nothing at all. Black, not red - which is why the conformance
// case looked like the atomic counters had stopped counting.
TEST_F(Glsl420DeclarationScenario, AnExplicitDefaultColorIndexStillDraws) {
if (!Ready()) return;
const GLuint program = Build(kQuadVS, kExplicitColorIndexFS);
if (program == 0) return;
const Rgba8 centre = DrawAndRead(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(centre.g, 255) << "a fragment output declared layout(location = 0, index = 0) painted "
"nothing; its stage was almost certainly refused by the driver";
EXPECT_EQ(centre.r, 0u);
}
} // namespace MGITest
@@ -127,14 +127,23 @@ void main()
// 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.
// spelling in a single declaration.
//
// 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"; }
// RemapImageArrayElementUnits repairs it by SPLITTING the array into one scalar
// image uniform per element, each carrying its own binding, which costs exactly the
// four image uniforms the application declared. (It used to WIDEN the array to cover
// the whole span instead, which cost seven for those four elements and had to be
// declined on a stage that could not afford them - hence the budget gate that used
// to be here.) DirectVulkan needs no rewrite at all.
bool PerElementImageUnitsAreHonoured() const {
if (Gl().BackendName() == "DirectVulkan") return true;
GLint maxFragmentImageUniforms = 0;
glGetIntegerv(GL_MAX_FRAGMENT_IMAGE_UNIFORMS, &maxFragmentImageUniforms);
while (glGetError() != GL_NO_ERROR) {
}
// One per element of the four-element array either fragment program declares.
return maxFragmentImageUniforms >= 4;
}
// The scenarios below need image load/store at all; a driver without it should skip
// rather than fail.
@@ -164,7 +173,7 @@ void main()
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";
GTEST_SKIP() << "fewer than 4 fragment image uniforms: the array under test does not fit";
}
HeadlessGL& gl = Gl();
@@ -283,8 +292,12 @@ void main()
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";
// The defect this guards is the SPIR-V descriptor remap, which only Magma has; the units
// here are consecutive on purpose, so on Espryt this would exercise nothing the case
// above does not. Scoped by what it TESTS rather than by the image-array widening, which
// it deliberately never triggers.
if (Gl().BackendName() != "DirectVulkan") {
GTEST_SKIP() << "the descriptor binding remap under test is DirectVulkan's";
}
HeadlessGL& gl = Gl();
@@ -0,0 +1,234 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ImageSizeAfterRespecScenario.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 DRAW READS imageSize() AFTER THE IMAGE TEXTURE IS RE-SPECIFIED.
//
// KHR-GL43.shader_image_size.advanced-changeSize reduced to its mechanism. The application binds
// a texture to an image unit ONCE, draws, then re-specifies that same texture with a new size
// through glTexImage2D and draws again - without touching the image unit. GL says the unit
// references the texture OBJECT, so the second draw must see the new dimensions.
//
// On Espryt it did not, and the reason is two facts meeting:
//
// 1. ES 3.1 only allows IMMUTABLE storage on an image unit, so the backend forces glTexStorage
// backing on any texture that reaches one (SyncTextureObjectToBackend's
// imageBindableStorageRequired). Immutable storage cannot be redefined, so a glTexImage2D
// that changes size or format has to MINT A NEW ES TEXTURE NAME.
// 2. The draw path never re-issued glBindImageTexture. Image units were established eagerly,
// once, when the application called glBindImageTexture, and PrepareForDraw only ever
// re-synced SAMPLED textures - so the unit kept pointing at the deleted name and
// imageSize() reported whatever that stale binding still meant.
//
// A dispatch was never affected: PrepareForCompute has always swept the image units. This is a
// draw-path scenario for exactly that reason - a compute-shaped case cannot see the defect.
//
// Both backends run it. Magma re-derives its image descriptors per draw and so was never wrong
// here, which makes it the control: the two backends have to agree on what the second draw sees.
#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 kTargetSize = 8;
constexpr const char* kVS = R"(#version 430 core
void main()
{
// A single triangle that covers the whole target, with no vertex buffer at all: the
// scenario is about the image unit, so nothing else may be able to make it fail.
switch (gl_VertexID)
{
case 0: gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); break;
case 1: gl_Position = vec4( 3.0, -1.0, 0.0, 1.0); break;
case 2: gl_Position = vec4(-1.0, 3.0, 0.0, 1.0); break;
}
}
)";
// Green when the image the unit currently holds has the size the application last gave
// it, red otherwise - the conformance case's own comparison, and its own colours.
constexpr const char* kFS = R"(#version 430 core
layout(rgba8) readonly uniform image2D g_image;
uniform ivec2 g_expected_size;
layout(location = 0) out vec4 o_color;
void main()
{
o_color = (imageSize(g_image) == g_expected_size) ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(1.0, 0.0, 0.0, 1.0);
}
)";
class ImageSizeAfterRespecScenario : public ScenarioTest {
protected:
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (m_program != 0) glDeleteProgram(m_program);
if (m_fbo != 0) glDeleteFramebuffers(1, &m_fbo);
if (m_color != 0) glDeleteTextures(1, &m_color);
if (m_image != 0) glDeleteTextures(1, &m_image);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_program = m_fbo = m_color = m_image = m_vao = 0;
while (glGetError() != GL_NO_ERROR) {
}
}
// imageSize() needs a fragment-stage image uniform; a driver that serves none should
// skip rather than fail.
bool FragmentImagesAreUsable() const {
GLint maxImageUnits = 0;
GLint maxFragmentImageUniforms = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
glGetIntegerv(GL_MAX_FRAGMENT_IMAGE_UNIFORMS, &maxFragmentImageUniforms);
while (glGetError() != GL_NO_ERROR) {
}
return maxImageUnits >= 1 && maxFragmentImageUniforms >= 1;
}
GLuint MakeProgram() {
const GLuint vs = glCreateShader(GL_VERTEX_SHADER);
const GLuint fs = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(vs, 1, &kVS, nullptr);
glShaderSource(fs, 1, &kFS, nullptr);
glCompileShader(vs);
glCompileShader(fs);
for (const GLuint shader : {vs, fs}) {
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[4096] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "a shader did not compile: " << log;
glDeleteShader(vs);
glDeleteShader(fs);
return 0;
}
}
const GLuint program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
glDeleteShader(vs);
glDeleteShader(fs);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[4096] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the program did not link: " << log;
glDeleteProgram(program);
return 0;
}
return program;
}
void MakeRenderTarget() {
glGenTextures(1, &m_color);
glBindTexture(GL_TEXTURE_2D, m_color);
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, kTargetSize, kTargetSize, 0, GL_RGBA, GL_UNSIGNED_BYTE,
nullptr);
glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_color, 0);
}
// Draw once with `expected` pushed to the shader and report the centre pixel.
void DrawAndReadCentre(int expectedWidth, int expectedHeight, unsigned char (&centre)[4]) {
const GLint location = glGetUniformLocation(m_program, "g_expected_size");
ASSERT_NE(location, -1) << "the program has no g_expected_size uniform";
glUseProgram(m_program);
glUniform2i(location, expectedWidth, expectedHeight);
glViewport(0, 0, kTargetSize, kTargetSize);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glDrawArrays(GL_TRIANGLES, 0, 3);
ASSERT_EQ(FirstGLError(), 0u) << "the draw left a GL error";
std::vector<unsigned char> pixels(static_cast<std::size_t>(kTargetSize) * kTargetSize * 4, 0);
glReadPixels(0, 0, kTargetSize, kTargetSize, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
ASSERT_EQ(FirstGLError(), 0u) << "reading the target back errored";
const std::size_t offset =
(static_cast<std::size_t>(kTargetSize / 2) * kTargetSize + kTargetSize / 2) * 4;
for (int i = 0; i < 4; ++i) {
centre[i] = pixels[offset + static_cast<std::size_t>(i)];
}
}
GLuint m_program = 0;
GLuint m_fbo = 0;
GLuint m_color = 0;
GLuint m_image = 0;
GLuint m_vao = 0;
};
} // namespace
// The whole conformance shape: bind once, draw, re-specify the SAME texture smaller, draw
// again. The first draw is the control - it proves the binding and the shader work at all -
// and the second is the regression pin. Blue would mean the draw never ran; red means the
// image unit answered with the size the texture had BEFORE the re-spec.
TEST_F(ImageSizeAfterRespecScenario, ADrawSeesTheNewSizeOfARespecifiedImageTexture) {
if (!Ready()) return;
if (!FragmentImagesAreUsable()) GTEST_SKIP() << "no fragment-stage image uniform available";
m_program = MakeProgram();
if (m_program == 0) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
MakeRenderTarget();
ASSERT_EQ(FirstGLError(), 0u) << "setting the render target up errored";
glGenTextures(1, &m_image);
glBindTexture(GL_TEXTURE_2D, m_image);
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, 32, 32, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glBindImageTexture(0, m_image, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
ASSERT_EQ(FirstGLError(), 0u) << "binding the image texture errored";
unsigned char centre[4] = {0, 0, 0, 0};
DrawAndReadCentre(32, 32, centre);
EXPECT_EQ(static_cast<int>(centre[0]), 0) << "the FIRST draw already disagrees about imageSize(): got ("
<< static_cast<int>(centre[0]) << ", "
<< static_cast<int>(centre[1]) << ", "
<< static_cast<int>(centre[2]) << ")";
EXPECT_EQ(static_cast<int>(centre[1]), 255);
// The re-spec. The image unit is deliberately NOT re-bound: GL 4.6 core 8.26 says the
// unit references the texture object, so this alone has to be visible to the next draw.
glBindTexture(GL_TEXTURE_2D, m_image);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 16, 16, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
ASSERT_EQ(FirstGLError(), 0u) << "re-specifying the image texture errored";
DrawAndReadCentre(16, 16, centre);
EXPECT_EQ(static_cast<int>(centre[0]), 0)
<< "after the re-spec the draw still sees the OLD image size; centre pixel was ("
<< static_cast<int>(centre[0]) << ", " << static_cast<int>(centre[1]) << ", "
<< static_cast<int>(centre[2]) << ")";
EXPECT_EQ(static_cast<int>(centre[1]), 255);
}
} // namespace MGITest
@@ -66,6 +66,9 @@ namespace MGITest {
constexpr int kExtent = 6;
constexpr GLuint kFilledValue = 7u;
constexpr GLuint kStoredValue = 13u;
// What the atomic cases add to a filled texel. Distinct from both values above, so a
// wrong answer cannot be read as either the untouched fill or a plain store.
constexpr GLuint kAtomicAddend = 5u;
// Everything that differs between the eleven kinds, in one row.
struct TargetKind {
@@ -129,6 +132,25 @@ namespace MGITest {
kind.imageType + " i0;\n\nvoid main()\n{\n " + StoreStatement(kind, "i0", "13u") + "\n}\n";
}
// The third direction, and the one neither of the two above can stand in for: an
// imageAtomic* reaches its texel through a SPIR-V operand path of its own
// (OpImageTexelPointer), not through OpImageRead or OpImageWrite. SPIRV-Cross's "ES has
// no 1D image, address it as 2D" coordinate widening is applied on the read and write
// paths and NOT on that one, so a 1D image whose loads and stores are both correct could
// still lose its entire stage to a single imageAtomicAdd - which is what
// KHR-GL4x.shader_image_load_store.basic-allTargets-atomic measured, with the driver
// answering "'imageAtomicAdd' : no matching overloaded function found".
//
// No readonly/writeonly here: an atomic needs both directions, and r32ui is one of the
// three formats GLSL ES exempts from the qualifier rule, so the bare declaration is legal.
// Returns the value the texel held BEFORE the add, so one dispatch checks the atomic's
// return value and the load case that follows checks its memory effect.
std::string SingleAtomicSource(const TargetKind& kind) {
return std::string(kComputePrologue) + "layout (location = 0, r32ui) coherent uniform " +
kind.imageType + " i0;\n" + kResultBlock + "void main()\n{\n ssb.sum = imageAtomicAdd(i0, " +
kind.coord + (kind.multisample ? ", 0, " : ", ") + std::to_string(kAtomicAddend) + "u);\n}\n";
}
class ImageTargetKindScenario : public ScenarioTest {
protected:
void TearDown() override {
@@ -374,6 +396,39 @@ namespace MGITest {
glUseProgram(0);
}
// Fill a texture of `kind`, add to texel (0,0,0) atomically, and require BOTH the
// value the atomic returned and the value it left behind. The read-back runs as a
// second program, for the same reason the store case does: a backend that gets the
// atomic's return right and its memory effect wrong cannot cancel itself out.
void RunAtomicCase(const TargetKind& kind) {
const GLuint atomicProgram = MakeComputeProgram(SingleAtomicSource(kind));
const GLuint loadProgram = MakeComputeProgram(SingleLoadSource(kind));
if (atomicProgram == 0 || loadProgram == 0) return;
const GLuint texture = MakeTexture(kind, true);
if (texture == 0) return;
const GLuint ssbo = MakeResultBuffer();
glBindImageTexture(0, texture, 0, GL_TRUE, 0, GL_READ_WRITE, GL_R32UI);
ASSERT_EQ(FirstGLError(), 0u) << kind.name << ": glBindImageTexture errored";
glUseProgram(atomicProgram);
glUniform1i(0, 0);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(FirstGLError(), 0u) << kind.name << ": the atomic dispatch leaked a GL error";
EXPECT_EQ(ReadResult(ssbo), kFilledValue)
<< kind.name << ": imageAtomicAdd did not return the value the texel held before it";
glUseProgram(loadProgram);
glUniform1i(0, 0);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(FirstGLError(), 0u) << kind.name << ": the loading dispatch leaked a GL error";
EXPECT_EQ(ReadResult(ssbo), kFilledValue + kAtomicAddend)
<< kind.name << ": imageAtomicAdd did not leave the sum in the texel";
glUseProgram(0);
}
// The same texture, bound four times over, varying nothing but `layered` and `layer`.
//
// GL 4.6 core 8.26 (and ES 3.2 8.22, word for word): "If the texture identified by
@@ -515,6 +570,29 @@ namespace MGITest {
#undef MGL_DEFINE_LOAD_CASE
#undef MGL_DEFINE_STORE_CASE
// ---- and the atomic direction, on the two kinds ES has to emulate -------
//
// Deliberately NOT every kind. imageAtomic* takes its own SPIR-V operand path
// (OpImageTexelPointer), and the only kinds whose coordinate that path has to RESHAPE are the
// two 1D ones - everything else addresses its ES texture with the coordinate the application
// wrote. GL_TEXTURE_1D_ARRAY is the control (its reshape has been in
// Lower1DArrayImagesForEssl from the start, and basic-allTargets-atomic passes on it);
// GL_TEXTURE_1D is the one that had none, so `imageAtomicAdd(g_image_1d, coord.x, 2)` reached
// the driver as a scalar against an iimage2D and took the whole fragment stage - and its six
// other images - with it.
#define MGL_DEFINE_ATOMIC_CASE(CaseName, Kind) \
TEST_F(ImageTargetKindScenario, AtomicallyAddsTo##CaseName) { \
if (!Ready()) return; \
if (!ImagesAreUsable()) GTEST_SKIP() << "no compute image uniforms"; \
RunAtomicCase(Kind); \
}
MGL_DEFINE_ATOMIC_CASE(Texture1D, kKind1D)
MGL_DEFINE_ATOMIC_CASE(Texture1DArray, kKind1DArray)
#undef MGL_DEFINE_ATOMIC_CASE
// ---- and the same texture bound four times, varying only layered/layer ---
//
// KHR-GL42.bind_image_texture.single_layer's sweep, on the kinds whose backend target has
@@ -0,0 +1,389 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/IoBlockNameCollisionScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - ONE BLOCK NAME USED IN BOTH DIRECTIONS BY ONE STAGE STILL CARRIES ITS PAYLOAD.
//
// Desktop GLSL keeps SEPARATE name namespaces for input and output interface blocks, so a
// single stage may legally write
//
// in TcsData { ... } tes_in[];
// out TcsData { ... } tes_out;
//
// The tessellation evaluation stage of both interface-block tests in
// KHR-GL42/43.shading_language_420pack does exactly that, and MobileGL's backend used to
// hand the shape straight through: SPIRV-Cross splits the namespace the same way glslang
// does (block_input_names vs block_output_names) and re-emits BOTH blocks under the name
// TcsData, so the generated ESSL declares two different blocks of one name in one shader.
// Adreno's ES compiler keeps them apart. Mali's does not - the stage compiles, the program
// links, and the evaluation stage's writes never reach the geometry stage, which is all 22
// of that group's Mali failures and none of Adreno's or DirectVulkan's.
//
// Both cases below drive the SAME five-stage pipeline (vertex -> tessellation control ->
// tessellation evaluation -> geometry -> fragment) and differ only in whether the
// evaluation stage reuses one name. The distinct-name case is the negative control: it is
// what says a red pixel in the colliding case is about the name and not about this machine's
// tessellation, its geometry stage, or the block mechanism in general.
//
// Colour code, so a failure names its own cause:
// green - the payload crossed all four stage boundaries, which is the pass.
// blue - the clear colour: nothing was drawn at all (the program did not link, or the
// backend program was rejected and every draw became a no-op).
// red - the pipeline ran but the plain (non-block) varying did not arrive, i.e. the
// failure is not about interface blocks.
// black - the pipeline ran, the plain varying arrived, and the BLOCK payload came back
// zeroed or garbage. That is the defect this scenario exists for.
//
// llvmpipe and lavapipe run this faithfully but do NOT reproduce the original defect - the
// aliasing is a Mali ES compiler behaviour. Read a green run here as "the rename did not
// break the ordinary path"; the claim it pins on the device is the CTS group above.
#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 payload starts here and is copied, unmodified, through every block below.
const char* const kVertexSource = R"(#version 420 core
out VsData {
vec4 payload;
} vs_out;
void main()
{
vs_out.payload = vec4(0.0, 1.0, 0.0, 1.0);
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
const char* const kTessControlSource = R"(#version 420 core
layout(vertices = 1) out;
in VsData {
vec4 payload;
} tcs_in[];
out TcsData {
vec4 payload;
} tcs_out[];
void main()
{
tcs_out[gl_InvocationID].payload = tcs_in[gl_InvocationID].payload;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelOuter[3] = 1.0;
gl_TessLevelInner[0] = 1.0;
gl_TessLevelInner[1] = 1.0;
}
)";
// THE CASE UNDER TEST: one name, both directions, in one stage.
const char* const kCollidingTessEvalSource = R"(#version 420 core
layout(isolines, point_mode) in;
in TcsData {
vec4 payload;
} tes_in[];
out TcsData {
vec4 payload;
} tes_out;
out float tes_gs_alive;
void main()
{
tes_out.payload = tes_in[0].payload;
tes_gs_alive = 1.0;
}
)";
// The negative control: byte-identical but for the output block's name.
const char* const kDistinctTessEvalSource = R"(#version 420 core
layout(isolines, point_mode) in;
in TcsData {
vec4 payload;
} tes_in[];
out TesData {
vec4 payload;
} tes_out;
out float tes_gs_alive;
void main()
{
tes_out.payload = tes_in[0].payload;
tes_gs_alive = 1.0;
}
)";
// One geometry source per evaluation stage, because the block it consumes is named
// after the block the evaluation stage produced.
const char* const kCollidingGeometrySource = R"(#version 420 core
layout(points) in;
layout(triangle_strip, max_vertices = 4) out;
in TcsData {
vec4 payload;
} gs_in[];
in float tes_gs_alive[];
out GsData {
vec4 payload;
} gs_out;
out float gs_fs_alive;
void EmitCorner(vec2 corner)
{
gs_out.payload = gs_in[0].payload;
gs_fs_alive = tes_gs_alive[0];
gl_Position = vec4(corner, 0.0, 1.0);
EmitVertex();
}
void main()
{
EmitCorner(vec2(-1.0, -1.0));
EmitCorner(vec2(-1.0, 1.0));
EmitCorner(vec2( 1.0, -1.0));
EmitCorner(vec2( 1.0, 1.0));
}
)";
const char* const kDistinctGeometrySource = R"(#version 420 core
layout(points) in;
layout(triangle_strip, max_vertices = 4) out;
in TesData {
vec4 payload;
} gs_in[];
in float tes_gs_alive[];
out GsData {
vec4 payload;
} gs_out;
out float gs_fs_alive;
void EmitCorner(vec2 corner)
{
gs_out.payload = gs_in[0].payload;
gs_fs_alive = tes_gs_alive[0];
gl_Position = vec4(corner, 0.0, 1.0);
EmitVertex();
}
void main()
{
EmitCorner(vec2(-1.0, -1.0));
EmitCorner(vec2(-1.0, 1.0));
EmitCorner(vec2( 1.0, -1.0));
EmitCorner(vec2( 1.0, 1.0));
}
)";
// Red when the PLAIN varying did not arrive, so "the pipeline is broken" and "the
// block payload is broken" cannot be confused for one another.
const char* const kFragmentSource = R"(#version 420 core
in GsData {
vec4 payload;
} fs_in;
in float gs_fs_alive;
out vec4 fragColor;
void main()
{
fragColor = gs_fs_alive > 0.5 ? fs_in.payload : vec4(1.0, 0.0, 0.0, 1.0);
}
)";
class IoBlockNameCollisionScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
if (!BackendHostsTessellationAndGeometry()) {
GTEST_SKIP() << "no tessellation/geometry stages on " << Gl().BackendName() << " ("
<< Gl().RendererString() << "); there is no five-stage pipeline to "
<< "carry a block through";
}
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
}
// GL_MAX_TESS_GEN_LEVEL is a real backend answer, not a frontend constant: it
// reads 0 on a DirectGLES driver without GL_EXT_tessellation_shader and on a
// DirectVulkan device without the tessellationShader feature. There is no
// five-stage pipeline to assert about on such a stack.
static bool BackendHostsTessellationAndGeometry() {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
GLint maxGeometryOutputVertices = 0;
glGetIntegerv(GL_MAX_GEOMETRY_OUTPUT_VERTICES, &maxGeometryOutputVertices);
while (glGetError() != GL_NO_ERROR) {
}
return maxTessGenLevel >= 1 && maxGeometryOutputVertices >= 4;
}
GLuint BuildPipeline(const char* tessEvalSource, const char* geometrySource) {
const GLenum stages[] = {GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER,
GL_TESS_EVALUATION_SHADER, GL_GEOMETRY_SHADER,
GL_FRAGMENT_SHADER};
const char* const sources[] = {kVertexSource, kTessControlSource, tessEvalSource,
geometrySource, kFragmentSource};
GLuint shaders[5] = {0, 0, 0, 0, 0};
bool ok = true;
for (int i = 0; i < 5; ++i) {
shaders[i] = glCreateShader(stages[i]);
glShaderSource(shaders[i], 1, &sources[i], nullptr);
glCompileShader(shaders[i]);
GLint compiled = 0;
glGetShaderiv(shaders[i], GL_COMPILE_STATUS, &compiled);
if (!compiled) {
m_buildLog = InfoLog(shaders[i], true);
ok = false;
break;
}
}
if (!ok) {
for (const GLuint shader : shaders) {
if (shader != 0) glDeleteShader(shader);
}
return 0;
}
const GLuint program = glCreateProgram();
for (const GLuint shader : shaders) {
glAttachShader(program, shader);
}
glLinkProgram(program);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
for (const GLuint shader : shaders) {
glDeleteShader(shader);
}
if (!linked) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
return 0;
}
m_programs.push_back(program);
return program;
}
// Clears to BLUE, so "the draw painted nothing" is a colour of its own rather
// than something that could be mistaken for a zeroed payload.
Rgba8 DrawAndReadCentre(GLuint program) const {
glViewport(0, 0, Gl().Width(), Gl().Height());
glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(program);
glPatchParameteri(GL_PATCH_VERTICES, 1);
glDrawArrays(GL_PATCHES, 0, 1);
Rgba8 pixel{};
glReadPixels(Gl().Width() / 2, Gl().Height() / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, &pixel);
return pixel;
}
static bool IsGreen(const Rgba8& pixel) {
return pixel.r < 64 && pixel.g > 192 && pixel.b < 64;
}
const std::string& BuildLog() const { return m_buildLog; }
static GLenum FirstGLError() {
const GLenum first = glGetError();
while (glGetError() != GL_NO_ERROR) {
}
return first;
}
private:
static std::string InfoLog(GLuint object, bool isShader) {
GLint length = 0;
if (isShader) {
glGetShaderiv(object, GL_INFO_LOG_LENGTH, &length);
} else {
glGetProgramiv(object, GL_INFO_LOG_LENGTH, &length);
}
std::vector<char> log(static_cast<std::size_t>(length > 1 ? length : 1), '\0');
if (isShader) {
glGetShaderInfoLog(object, static_cast<GLsizei>(log.size()), nullptr, log.data());
} else {
glGetProgramInfoLog(object, static_cast<GLsizei>(log.size()), nullptr, log.data());
}
return std::string(log.data());
}
GLuint m_vao = 0;
std::vector<GLuint> m_programs;
std::string m_buildLog;
};
// The negative control, and it runs first on purpose: if this one is not green there
// is nothing to conclude from the case below it.
//
// It is also the CALIBRATION. GL_MAX_TESS_GEN_LEVEL answers for the tessellation
// stages honestly, but nothing MobileGL reports answers for the geometry stage the
// same way (GL_MAX_GEOMETRY_* are frontend constants and an ES driver may legitimately
// report zero geometry storage blocks while having geometry shaders), so a stack that
// cannot build a five-stage program at all is recognised here, by trying.
TEST_F(IoBlockNameCollisionScenario, DistinctlyNamedBlocksCarryThePayloadThroughFiveStages) {
if (!Ready()) return;
const GLuint program = BuildPipeline(kDistinctTessEvalSource, kDistinctGeometrySource);
if (program == 0) {
GTEST_SKIP() << "this stack cannot build a five-stage tessellation+geometry program on "
<< Gl().BackendName() << ", so there is no block to carry through: "
<< BuildLog();
}
const Rgba8 centre = DrawAndReadCentre(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_TRUE(IsGreen(centre)) << "the control pipeline did not deliver its payload: " << centre;
}
TEST_F(IoBlockNameCollisionScenario, OneBlockNameInBothDirectionsStillCarriesThePayload) {
if (!Ready()) return;
// Same calibration as the case above, and for the same reason: a five-stage program
// this stack cannot build at all is not evidence about block names. Only once the
// DISTINCT-name build succeeds does a failure of the colliding one mean something.
if (BuildPipeline(kDistinctTessEvalSource, kDistinctGeometrySource) == 0) {
GTEST_SKIP() << "this stack cannot build a five-stage tessellation+geometry program on "
<< Gl().BackendName() << ", so there is no block to carry through: "
<< BuildLog();
}
// Legal desktop GLSL: input and output block names live in separate namespaces, so
// the evaluation stage below declares TcsData twice and must still compile. The
// control above having built is what makes this assertion about the NAME.
const GLuint program = BuildPipeline(kCollidingTessEvalSource, kCollidingGeometrySource);
ASSERT_NE(program, 0u)
<< "an interface block name reused across the two directions of one stage is legal "
"desktop GLSL, but the program did not build: "
<< BuildLog();
const Rgba8 centre = DrawAndReadCentre(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_TRUE(IsGreen(centre))
<< "the payload did not survive the stage that names its input and output block "
"the same: "
<< centre << " (blue: nothing drew; red: the plain varying was lost too; black: "
"the block arrived empty)";
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,286 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/LayeredTextureReadbackScenario.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 - READING EVERY LAYER OF A 1D-ARRAY / CUBE-MAP-ARRAY LEVEL BACK.
//
// glGetTexImage has no ES equivalent, so Espryt serves it by attaching the level to a scratch
// READ framebuffer and reading it with glReadPixels. Two of the targets it has to answer for do
// not fit that shape the way the others do, and both came back as zeroes in
// KHR-GL4x.shader_image_load_store.basic-allTargets-* and .non-layered_binding:
//
// * GL_TEXTURE_1D_ARRAY carries its LAYERS in the state-side height - that is what
// glTexImage2D(GL_TEXTURE_1D_ARRAY, w, layers) means - while the ES texture behind it is a 2D
// array of height 1 with the layers in depth. The readback used the state-side shape, so it
// asked layer 0 for a `layers`-row rectangle that layer does not have: row 0 was the only one
// that could be right, and everything past it was whatever reading outside an attachment
// produces.
// * GL_TEXTURE_CUBE_MAP_ARRAY has no glFramebufferTexture2D target token at all, so the 2D
// attach it used to take errored, the scratch FBO stayed incomplete, and every read fell
// through to the CPU shadow - which holds what was UPLOADED, i.e. the seed, not what the
// shader stored.
//
// Both cases store from a compute dispatch (so the only copy of the data is the GPU one and a
// stale shadow cannot pass) and then read the whole level back in one glGetTexImage, checking
// every layer separately so a failure names which one. r32ui throughout: it is a core GLSL ES
// image format, so nothing here can be confused with the missing-format story that
// ImageFormatQualifierScenario covers.
//
// Magma reads these back through its own path and is unaffected by the ES attachment rules, so
// both cases run on both backends and must agree.
#include <cstddef>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kExtent = 4;
constexpr int kArrayLayers = 3; // enough that "layer 0 only" is visibly wrong
constexpr int kCubeLayerFaces = 12; // two cubes, which is what the conformance case uses
// A value no store writes, so "the store never landed" and "the store wrote the wrong
// thing" cannot be confused - and so a readback served from the stale CPU shadow is
// recognisable on sight.
constexpr GLuint kSeed = 0xFEEDBEEFu;
// Deliberately not 0: the unit has to travel through glUniform1i and be baked into the
// generated ESSL, so a defect there cannot hide behind the default.
constexpr GLint kImageUnit = 1;
GLuint Expected1DArrayTexel(int x, int layer) {
return 1000u + static_cast<GLuint>(layer) * 100u + static_cast<GLuint>(x);
}
GLuint ExpectedCubeArrayTexel(int x, int y, int layerFace) {
return 1000u + static_cast<GLuint>(layerFace) * 100u + static_cast<GLuint>(y) * 10u +
static_cast<GLuint>(x);
}
// One invocation per texel, and the value it writes is a function of its coordinate - so
// a layer read from the wrong slice does not merely differ, it says which slice it came
// from.
const char* k1DArrayStoreSource = R"(#version 430 core
layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
layout (r32ui) writeonly uniform uimage1DArray uni_image;
void main()
{
uint x = gl_GlobalInvocationID.x;
uint layer = gl_GlobalInvocationID.z;
imageStore(uni_image, ivec2(int(x), int(layer)), uvec4(1000u + layer * 100u + x, 0u, 0u, 0u));
}
)";
const char* kCubeArrayStoreSource = R"(#version 430 core
layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
layout (r32ui) writeonly uniform uimageCubeArray uni_image;
void main()
{
uint x = gl_GlobalInvocationID.x;
uint y = gl_GlobalInvocationID.y;
uint layerFace = gl_GlobalInvocationID.z;
imageStore(uni_image, ivec3(int(x), int(y), int(layerFace)),
uvec4(1000u + layerFace * 100u + y * 10u + x, 0u, 0u, 0u));
}
)";
class LayeredTextureReadbackScenario : public ScenarioTest {
protected:
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (GLuint p : m_programs) glDeleteProgram(p);
for (GLuint t : m_textures) glDeleteTextures(1, &t);
m_programs.clear();
m_textures.clear();
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
for (GLint unit = 0; unit < maxImageUnits; ++unit) {
glBindImageTexture(static_cast<GLuint>(unit), 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_R32UI);
}
while (glGetError() != GL_NO_ERROR) {
}
}
bool ImagesAreUsable() const {
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
GLint maxComputeImageUniforms = 0;
glGetIntegerv(GL_MAX_COMPUTE_IMAGE_UNIFORMS, &maxComputeImageUniforms);
while (glGetError() != GL_NO_ERROR) {
}
return maxImageUnits > kImageUnit && maxComputeImageUniforms >= 1;
}
GLuint MakeComputeProgram(const char* source) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[4096] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute shader did not compile: " << log;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
m_programs.push_back(program);
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[4096] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute program did not link: " << log;
return 0;
}
return program;
}
GLuint TrackTexture() {
GLuint texture = 0;
glGenTextures(1, &texture);
m_textures.push_back(texture);
return texture;
}
// layered = GL_TRUE, i.e. the whole level: that is what makes every layer reachable
// from one dispatch, and it is what glBindImageTextures is specified to pass.
bool DispatchStore(GLuint program, GLuint texture, GLsizei groupsX, GLsizei groupsY, GLsizei groupsZ) {
glBindImageTexture(static_cast<GLuint>(kImageUnit), texture, 0, GL_TRUE, 0, GL_WRITE_ONLY, GL_R32UI);
if (const GLenum error = FirstGLError()) {
ADD_FAILURE() << "glBindImageTexture errored with " << GLErrorName(error);
return false;
}
glUseProgram(program);
const GLint location = glGetUniformLocation(program, "uni_image");
if (location < 0) {
ADD_FAILURE() << "the image uniform was not reflected";
return false;
}
glUniform1i(location, kImageUnit);
if (const GLenum error = FirstGLError()) {
ADD_FAILURE() << "assigning the image unit errored with " << GLErrorName(error);
return false;
}
glDispatchCompute(groupsX, groupsY, groupsZ);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
glUseProgram(0);
if (const GLenum error = FirstGLError()) {
ADD_FAILURE() << "the dispatch errored with " << GLErrorName(error);
return false;
}
return true;
}
std::vector<GLuint> m_programs;
std::vector<GLuint> m_textures;
};
// The 1D-array half. A layer past the first is the whole test: layer 0 lines up with the
// ES image's only row whichever way the axes are read, so a readback that never swapped
// them still got it right and only the deeper layers came back wrong.
TEST_F(LayeredTextureReadbackScenario, GetTexImageReturnsEveryLayerOfA1DArray) {
if (!Ready()) return;
if (!ImagesAreUsable()) GTEST_SKIP() << "no compute image uniforms";
const GLuint program = MakeComputeProgram(k1DArrayStoreSource);
if (program == 0) return;
const GLuint texture = TrackTexture();
glBindTexture(GL_TEXTURE_1D_ARRAY, texture);
glTexParameteri(GL_TEXTURE_1D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_1D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
const std::vector<GLuint> seed(static_cast<std::size_t>(kExtent) * kArrayLayers, kSeed);
glTexImage2D(GL_TEXTURE_1D_ARRAY, 0, GL_R32UI, kExtent, kArrayLayers, 0, GL_RED_INTEGER, GL_UNSIGNED_INT,
seed.data());
ASSERT_EQ(FirstGLError(), 0u) << "creating the R32UI 1D-array texture errored";
if (!DispatchStore(program, texture, kExtent, 1, kArrayLayers)) return;
std::vector<GLuint> texels(seed.size(), 0u);
glBindTexture(GL_TEXTURE_1D_ARRAY, texture);
glGetTexImage(GL_TEXTURE_1D_ARRAY, 0, GL_RED_INTEGER, GL_UNSIGNED_INT, texels.data());
ASSERT_EQ(FirstGLError(), 0u) << "reading the 1D-array level back errored";
// GL hands a 1D array back as a plain two-dimensional image whose ROWS are the
// layers, so the destination index is layer * width + x.
for (int layer = 0; layer < kArrayLayers; ++layer) {
for (int x = 0; x < kExtent; ++x) {
const std::size_t index = static_cast<std::size_t>(layer) * kExtent + x;
EXPECT_EQ(texels[index], Expected1DArrayTexel(x, layer))
<< "layer " << layer << " texel " << x << " read back "
<< (texels[index] == kSeed ? "the seed (the store never reached it, or the readback came "
"from the stale CPU shadow)"
: "an unexpected value");
}
}
}
// The cube-map-array half. glFramebufferTexture2D has no token for the target, so the
// scratch FBO used to stay incomplete and every read - including layer 0 - was answered
// from the CPU shadow; the seed is what makes that visible rather than merely wrong.
TEST_F(LayeredTextureReadbackScenario, GetTexImageReturnsEveryLayerFaceOfACubeMapArray) {
if (!Ready()) return;
if (!ImagesAreUsable()) GTEST_SKIP() << "no compute image uniforms";
const GLuint program = MakeComputeProgram(kCubeArrayStoreSource);
if (program == 0) return;
const GLuint texture = TrackTexture();
glBindTexture(GL_TEXTURE_CUBE_MAP_ARRAY, texture);
glTexParameteri(GL_TEXTURE_CUBE_MAP_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
const std::vector<GLuint> seed(static_cast<std::size_t>(kExtent) * kExtent * kCubeLayerFaces, kSeed);
glTexImage3D(GL_TEXTURE_CUBE_MAP_ARRAY, 0, GL_R32UI, kExtent, kExtent, kCubeLayerFaces, 0, GL_RED_INTEGER,
GL_UNSIGNED_INT, seed.data());
ASSERT_EQ(FirstGLError(), 0u) << "creating the R32UI cube-map-array texture errored";
if (!DispatchStore(program, texture, kExtent, kExtent, kCubeLayerFaces)) return;
std::vector<GLuint> texels(seed.size(), 0u);
glBindTexture(GL_TEXTURE_CUBE_MAP_ARRAY, texture);
glGetTexImage(GL_TEXTURE_CUBE_MAP_ARRAY, 0, GL_RED_INTEGER, GL_UNSIGNED_INT, texels.data());
ASSERT_EQ(FirstGLError(), 0u) << "reading the cube-map-array level back errored";
for (int layerFace = 0; layerFace < kCubeLayerFaces; ++layerFace) {
for (int y = 0; y < kExtent; ++y) {
for (int x = 0; x < kExtent; ++x) {
const std::size_t index =
(static_cast<std::size_t>(layerFace) * kExtent + y) * kExtent + x;
EXPECT_EQ(texels[index], ExpectedCubeArrayTexel(x, y, layerFace))
<< "layer-face " << layerFace << " texel (" << x << ", " << y << ") read back "
<< (texels[index] == kSeed ? "the seed (the store never reached it, or the readback "
"came from the stale CPU shadow)"
: "an unexpected value");
}
}
}
}
} // namespace
} // namespace MGITest
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,220 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PackedWordReadbackScenario.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
//
// glGetTexImage of a 32-bit packed format read with its OWN client type owes the application the
// words the image HOLDS, and KHR-GL43.copy_image compares exactly those words. Two routes used to
// answer, and both are wrong for a level glCopyImageSubData wrote:
//
// * the colour-attachment route reads GL_RGBA/GL_FLOAT and re-encodes, which canonicalizes an
// RGB9_E5 shared exponent and collapses an R11F_G11F_B10F NaN payload to 1;
// * the CPU shadow only holds what was UPLOADED, and the mirror that replays a copy into it
// declines - silently - for a renderbuffer source, which has no shadow to mirror from.
//
// Both are pinned here with words the CTS itself uses, because both failures are invisible to a
// value comparison: every assertion below is on BITS that decode to the very value the wrong
// answer also decodes to.
//
// The fix is a raw-word route (DirectGLES::ReadPackedLevelWordsViaScratch: copy the level into a
// scratch GL_R32UI image, read that back as unsigned integers), and DirectVulkan reaches the same
// place through PackReadbackToClientOrPbo's raw-word branch over the staging bytes - so these
// scenarios are backend-agnostic on purpose.
#include <cstddef>
#include <ios>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr GLsizei kExtent = 4;
// The non-canonical RGB9_E5 word KHR-GL43.copy_image writes: R=0, G=0, B mantissa 63,
// shared exponent 31, i.e. the value 8064, which the spec's own encoder would emit as
// 0xe7e00000 instead. Anything that decodes and re-encodes hands back the canonical word.
//
// Reinterpreted in the destination of an RGB9_E5 -> R11F_G11F_B10F copy it is R=0,
// G=1920, B=995 - and B's 5-bit exponent is all ones with a nonzero mantissa, i.e. a NaN
// whose payload 3 does not survive a float32 round trip (it comes back as the canonical
// payload 1, B=993, word 0xf87c0000). The two defects therefore land on the same word.
constexpr GLuint kRgb9E5Word = 0xf8fc0000u;
// The R11F_G11F_B10F word the same test pairs with it: R=0, G=0, B = exponent 12,
// mantissa 0 = 0.125. As an RGB9_E5 word it is all-zero channels with a shared exponent of
// 12, which the canonical encoder would write as 0x00000000 - so a decode/re-encode of THIS
// one loses every bit that distinguishes it.
constexpr GLuint kR11fG11fB10fWord = 0x60000000u;
class PackedWordReadbackScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
DrainErrors();
}
void TearDown() override {
if (!Ready()) return;
DeleteObjects();
DrainErrors();
ScenarioTest::TearDown();
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
void DeleteObjects() {
if (m_src != 0) glDeleteTextures(1, &m_src);
if (m_dst != 0) glDeleteTextures(1, &m_dst);
if (m_rbo != 0) glDeleteRenderbuffers(1, &m_rbo);
m_src = 0;
m_dst = 0;
m_rbo = 0;
}
// A complete single-level texture whose every texel holds `word`, uploaded through the
// packed client type so the stored bits are the client's bits and nothing has had a
// chance to re-encode them.
GLuint MakePackedTexture(GLenum internalFormat, GLenum type, GLuint word) {
const std::vector<GLuint> words(static_cast<std::size_t>(kExtent) * kExtent, word);
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(internalFormat), kExtent, kExtent, 0, GL_RGB, type,
words.data());
// What Utils::makeTextureComplete does in the conformance cases, and what
// glCopyImageSubData requires of both endpoints.
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glBindTexture(GL_TEXTURE_2D, 0);
return texture;
}
// Every texel of level 0, as raw client words.
std::vector<GLuint> ReadPackedWords(GLuint texture, GLenum type) {
std::vector<GLuint> words(static_cast<std::size_t>(kExtent) * kExtent, 0xDEADBEEFu);
glBindTexture(GL_TEXTURE_2D, texture);
glGetTexImage(GL_TEXTURE_2D, 0, GL_RGB, type, words.data());
glBindTexture(GL_TEXTURE_2D, 0);
return words;
}
// The copy under test. Returns the error it raised so a driver that cannot perform the
// move at all can skip rather than fail: the point of these cases is which BITS come
// back, and there are none to compare if the copy never happened.
GLenum CopyWholeImage(GLuint srcName, GLenum srcTarget, GLuint dstName, GLenum dstTarget) {
DrainErrors();
glCopyImageSubData(srcName, srcTarget, 0, 0, 0, 0, dstName, dstTarget, 0, 0, 0, 0, kExtent, kExtent,
1);
const GLenum error = glGetError();
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "the copy recorded more than one error";
return error;
}
static void ExpectEveryTexel(const std::vector<GLuint>& words, GLuint expected, const char* what) {
for (std::size_t i = 0; i < words.size(); ++i) {
ASSERT_EQ(words[i], expected)
<< what << ": texel " << i << " read 0x" << std::hex << words[i] << ", expected 0x"
<< expected;
}
}
GLuint m_src = 0;
GLuint m_dst = 0;
GLuint m_rbo = 0;
};
// The control that has to hold before either regression means anything: a packed word
// uploaded and read straight back must be the SAME word, not merely the same colour.
TEST_F(PackedWordReadbackScenario, AnUploadedPackedWordReadsBackVerbatim) {
if (!Ready()) GTEST_SKIP();
m_src = MakePackedTexture(GL_RGB9_E5, GL_UNSIGNED_INT_5_9_9_9_REV, kRgb9E5Word);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "RGB9_E5 upload";
ExpectEveryTexel(ReadPackedWords(m_src, GL_UNSIGNED_INT_5_9_9_9_REV), kRgb9E5Word, "RGB9_E5 round trip");
m_dst = MakePackedTexture(GL_R11F_G11F_B10F, GL_UNSIGNED_INT_10F_11F_11F_REV, kR11fG11fB10fWord);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "R11F_G11F_B10F upload";
ExpectEveryTexel(ReadPackedWords(m_dst, GL_UNSIGNED_INT_10F_11F_11F_REV), kR11fG11fB10fWord,
"R11F_G11F_B10F round trip");
}
// KHR-GL43.copy_image.functional rgb9_e5 -> r11f_g11f_b10f, all nine target combinations of
// which failed on both GPUs. glCopyImageSubData is a raw block move, so the destination
// physically holds the source's word - but the readback decoded it to float and re-encoded,
// and the destination's blue field is a NaN whose payload float32 does not carry. Every
// texel came back 0xf87c0000 (payload 1) instead of 0xf8fc0000 (payload 3): the same
// "colour", two bits apart.
TEST_F(PackedWordReadbackScenario, ACopiedRgb9E5WordSurvivesInAnR11fG11fB10fDestination) {
if (!Ready()) GTEST_SKIP();
m_src = MakePackedTexture(GL_RGB9_E5, GL_UNSIGNED_INT_5_9_9_9_REV, kRgb9E5Word);
m_dst = MakePackedTexture(GL_R11F_G11F_B10F, GL_UNSIGNED_INT_10F_11F_11F_REV, 0u);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "texture setup";
const GLenum copyError = CopyWholeImage(m_src, GL_TEXTURE_2D, m_dst, GL_TEXTURE_2D);
if (copyError != static_cast<GLenum>(GL_NO_ERROR)) {
GTEST_SKIP() << "this driver declined the RGB9_E5 -> R11F_G11F_B10F copy (" << copyError << ")";
}
ExpectEveryTexel(ReadPackedWords(m_dst, GL_UNSIGNED_INT_10F_11F_11F_REV), kRgb9E5Word,
"copied word in the R11F_G11F_B10F destination");
// ...and the source is still the source. This is verify()'s FIRST check in the
// conformance case, and the half that a canonicalizing readback fails on its own.
ExpectEveryTexel(ReadPackedWords(m_src, GL_UNSIGNED_INT_5_9_9_9_REV), kRgb9E5Word,
"the RGB9_E5 source after the copy");
}
// KHR-GL43.copy_image.functional *->rgb9_e5 with a GL_RENDERBUFFER source: exactly the three
// renderbuffer combinations of each such family failed, and no texture one did. The
// destination's CPU shadow is what the readback answered from, the mirror that replays a
// copy into it declines when an endpoint is a renderbuffer (there is no shadow to mirror
// FROM), and the decline is silent - so glGetTexImage handed back the destination's
// pre-copy contents. The word chosen here makes that unmissable: it decodes to the same
// all-zero channels the canonical encoder would write as 0x00000000.
TEST_F(PackedWordReadbackScenario, ACopyThroughARenderbufferReachesAnRgb9E5Destination) {
if (!Ready()) GTEST_SKIP();
m_src = MakePackedTexture(GL_R11F_G11F_B10F, GL_UNSIGNED_INT_10F_11F_11F_REV, kR11fG11fB10fWord);
m_dst = MakePackedTexture(GL_RGB9_E5, GL_UNSIGNED_INT_5_9_9_9_REV, 0xFFFFFFFFu);
glGenRenderbuffers(1, &m_rbo);
glBindRenderbuffer(GL_RENDERBUFFER, m_rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_R11F_G11F_B10F, kExtent, kExtent);
glBindRenderbuffer(GL_RENDERBUFFER, 0);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "renderbuffer setup";
// The conformance case's own shape: texture -> renderbuffer -> texture.
const GLenum toRenderbuffer = CopyWholeImage(m_src, GL_TEXTURE_2D, m_rbo, GL_RENDERBUFFER);
if (toRenderbuffer != static_cast<GLenum>(GL_NO_ERROR)) {
GTEST_SKIP() << "this driver declined a renderbuffer copy destination (" << toRenderbuffer << ")";
}
const GLenum fromRenderbuffer = CopyWholeImage(m_rbo, GL_RENDERBUFFER, m_dst, GL_TEXTURE_2D);
if (fromRenderbuffer != static_cast<GLenum>(GL_NO_ERROR)) {
GTEST_SKIP() << "this driver declined a renderbuffer copy source (" << fromRenderbuffer << ")";
}
ExpectEveryTexel(ReadPackedWords(m_dst, GL_UNSIGNED_INT_5_9_9_9_REV), kR11fG11fB10fWord,
"copied word in the RGB9_E5 destination");
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,325 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PostLinkAttachScenario.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 PROGRAM'S LIVE ATTACH LIST IS NOT ITS EXECUTABLE, AND THE BACKENDS MAY NOT
// INDEX ONE BY THE OTHER.
//
// GL 4.6 core 7.3: glAttachShader adds to the program's attach list immediately and affects
// what the program RUNS only at the next link (glDetachShader defers its removal the same
// way). So between an attach and the relink the two lists differ - the attach list is
// strictly longer - and the program stays perfectly drawable throughout, with the executable
// its last link produced.
//
// Both backends walked the attach list while indexing the LAST LINK's generated SPIR-V by
// the same running index:
//
// DirectGLES BackendProgramObjectImpl::SyncToBackend - `shaderSpirvs[index]` over
// `attachedShaders.size()`
// DirectVulkan ProgramFactory::GetOrCreateProgram - `spirv[i]` and `moduleSpirvs[i]`
// over `shaders.size()`
//
// One post-link attach therefore read one Vector past the end of the module array and
// copied it, which is the SIGSEGV this scenario is the regression test for (the source
// vector reported a capacity of 35177040171136). DirectGLES additionally derived
// "does this program tessellate" from the same wrong list, which would synthesize a
// pass-through tessellation control stage for an executable that does not tessellate.
//
// The repro needs the attach to land BEFORE the program's first backend build: the ES
// twin's rebuild is gated on the link version (which an attach does not move), so a program
// that was already drawn once keeps its built driver program and never re-reads the list.
// Every case below therefore attaches first and draws second.
//
// Deliberately pinned with a PIXEL and not just with glGetError. "Reject the draw earlier"
// would silence the crash while breaking the spec - GL requires this draw to execute - so
// the assertion has to be that the frame really came out, not merely that nothing complained.
//
// Needs a real context: the crash is in a backend program build, which the GPU-free suites
// never reach.
#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 kFboWidth = 64;
constexpr int kFboHeight = 64;
// A full-viewport triangle from gl_VertexID alone, so the scenario needs no vertex
// buffer and every pixel of the target is covered by the one draw.
const char* const kVertexSource = R"(#version 330 core
void main()
{
vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}
)";
const char* const kFragmentSource = R"(#version 330 core
out vec4 fragColor;
void main()
{
fragColor = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
// The replacement fragment stage of the last case. A different colour, so "which
// executable did this draw run" is answerable from the frame alone.
const char* const kBlueFragmentSource = R"(#version 330 core
out vec4 fragColor;
void main()
{
fragColor = vec4(0.0, 0.0, 1.0, 1.0);
}
)";
constexpr Rgba8 kGreen{0, 255, 0, 255};
constexpr Rgba8 kBlue{0, 0, 255, 255};
// The extra attaches. Each declares a stage the executable ALREADY has and no main(),
// which is what a real shader library looks like and what makes the relink at the end
// of the second case legal. Their whole job here is to make the attach list longer
// than the module array.
const char* const kVertexHelperSource = R"(#version 330 core
vec4 mgPostLinkAttachVertexHelper()
{
return vec4(0.0, 0.0, 0.0, 1.0);
}
)";
const char* const kFragmentHelperSource = R"(#version 330 core
vec4 mgPostLinkAttachFragmentHelper()
{
return vec4(1.0, 0.0, 1.0, 1.0);
}
)";
// A pass-through, so that once it IS linked in the same full-viewport triangle still
// reaches the rasterizer and the final frame is still comparable to the first one.
const char* const kGeometrySource = R"(#version 330 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
void main()
{
for (int i = 0; i < 3; ++i) {
gl_Position = gl_in[i].gl_Position;
EmitVertex();
}
EndPrimitive();
}
)";
class PostLinkAttachScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
m_target = MakeColorFbo(kFboWidth, kFboHeight);
ASSERT_NE(m_target.fbo, 0u) << "could not create the scenario's colour target";
BindFbo(m_target);
DrainErrors();
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) glDeleteProgram(program);
m_programs.clear();
for (const GLuint shader : m_shaders) glDeleteShader(shader);
m_shaders.clear();
BindDefaultFramebuffer();
DestroyColorFbo(m_target);
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
DrainErrors();
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
static bool BackendHostsGeometry() {
GLint maxGeometryOutputVertices = 0;
glGetIntegerv(GL_MAX_GEOMETRY_OUTPUT_VERTICES, &maxGeometryOutputVertices);
DrainErrors();
return maxGeometryOutputVertices >= 4;
}
// Kept alive until TearDown rather than flagged for deletion at attach time: a
// deleted-but-attached shader is a second, unrelated lifetime rule, and this
// scenario is about which LIST the backend reads.
GLuint MakeShader(GLenum stage, const char* source) {
const GLuint shader = glCreateShader(stage);
if (shader == 0) return 0;
m_shaders.push_back(shader);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
return shader;
}
// Vertex + fragment, linked. This is the executable every case draws with.
// `outFragmentShader` is the stage that paints green, which the last case needs a
// name for in order to detach it.
GLuint LinkBaseProgram(GLuint* outFragmentShader = nullptr) {
const GLuint program = glCreateProgram();
m_programs.push_back(program);
const GLuint fragment = MakeShader(GL_FRAGMENT_SHADER, kFragmentSource);
glAttachShader(program, MakeShader(GL_VERTEX_SHADER, kVertexSource));
glAttachShader(program, fragment);
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (!linked) return 0;
if (outFragmentShader != nullptr) *outFragmentShader = fragment;
return program;
}
// Clears to red, draws the full-viewport triangle, and hands back the frame. Red
// is deliberately the clear colour: a draw that silently did not execute leaves a
// red target, which is a different failure message from a draw that executed and
// painted the wrong thing.
//
// `outDrawError` is sampled between the draw and the readback, so a rejected draw
// is never confused with a readback that went wrong afterwards.
Image DrawFullViewportTriangle(GLuint program, GLenum mode, GLenum* outDrawError = nullptr) {
glUseProgram(program);
ClearTo(1.0f, 0.0f, 0.0f, 1.0f);
DrainErrors();
glDrawArrays(mode, 0, 3);
if (outDrawError != nullptr) *outDrawError = glGetError();
return ReadPixels(kFboWidth, kFboHeight);
}
// The clear colour is red and no shader here ever writes red, so "still red" reads
// as "the draw did not execute" and any other wrong colour as "it executed against
// the wrong modules" - two failures worth telling apart.
static void ExpectFullyColored(const Image& frame, const Rgba8& expected, const char* what) {
ASSERT_FALSE(frame.Empty()) << what << ": nothing was read back";
for (const int y : {0, kFboHeight / 2, kFboHeight - 1}) {
for (const int x : {0, kFboWidth / 2, kFboWidth - 1}) {
EXPECT_EQ(frame.At(x, y), expected)
<< what << ": pixel (" << x << ", " << y << ") is " << frame.ColorName(x, y);
}
}
}
GLuint m_vao = 0;
ColorFbo m_target{};
std::vector<GLuint> m_programs;
std::vector<GLuint> m_shaders;
};
// THE REGRESSION. Up to four shaders attached after the link (the geometry one only
// where the backend has that stage), two of them duplicating a stage the executable
// already carries - so the attach list runs to five or six while the last link produced
// two modules, and the old loops read indices 2..5 of a 2-element array.
//
// Duplicating a stage is the sharp case on purpose: it is the one shape under which a
// "look the stage up in the attach list instead" repair still returns a valid-looking
// index for a module that does not exist.
TEST_F(PostLinkAttachScenario, DrawingAfterPostLinkAttachesStaysInsideTheGeneratedModules) {
if (!Ready()) GTEST_SKIP();
const GLuint program = LinkBaseProgram();
ASSERT_NE(program, 0u) << "the vertex+fragment program did not link";
// Not drawn yet: the ES backend's rebuild is gated on the link version, so a draw
// here would build the driver program from the 2-module executable and the attaches
// below would never be re-read. The repro is the FIRST build seeing the long list.
glAttachShader(program, MakeShader(GL_VERTEX_SHADER, kVertexHelperSource));
glAttachShader(program, MakeShader(GL_FRAGMENT_SHADER, kFragmentHelperSource));
if (BackendHostsGeometry()) {
glAttachShader(program, MakeShader(GL_GEOMETRY_SHADER, kGeometrySource));
}
// The stage that made DirectGLES synthesize a pass-through control stage for a
// program whose executable does not tessellate. Attached whether or not this
// backend can tessellate - an attach needs no support and no successful compile.
const GLuint tessEval = MakeShader(GL_TESS_EVALUATION_SHADER, R"(#version 420 core
layout(triangles, equal_spacing, ccw) in;
void main()
{
gl_Position = gl_in[0].gl_Position;
}
)");
if (tessEval != 0) glAttachShader(program, tessEval);
DrainErrors();
GLint attachedCount = 0;
glGetProgramiv(program, GL_ATTACHED_SHADERS, &attachedCount);
DrainErrors();
ASSERT_GT(attachedCount, 2) << "the attaches did not land, so this case is not testing anything";
// Still the two-stage executable of three lines ago, and GL says it draws.
GLenum drawError = GL_NO_ERROR;
const Image frame = DrawFullViewportTriangle(program, GL_TRIANGLES, &drawError);
EXPECT_EQ(drawError, static_cast<GLenum>(GL_NO_ERROR))
<< "the attaches have not been linked in, so nothing about them may reject this draw";
ExpectFullyColored(frame, kGreen, "the post-attach draw");
DrainErrors();
}
// The same window, asked to prove something stronger than "it did not crash": WHICH
// modules the draw in that window ran. Between the detach+attach and the relink the
// program has three attached shaders and two modules, and GL 4.6 core 7.3 says the
// executable is still the one the last link produced - so the frame must come out in
// the OLD fragment shader's colour, not the newly attached one's and not garbage.
//
// This is also the other direction of the fix, so it cannot be "freeze the backend on
// the first link": the relink really does swap the executable, and the very next draw
// has to be rebuilt from it.
TEST_F(PostLinkAttachScenario, TheWindowKeepsTheOldExecutableAndTheRelinkSwapsIt) {
if (!Ready()) GTEST_SKIP();
GLuint greenFragment = 0;
const GLuint program = LinkBaseProgram(&greenFragment);
ASSERT_NE(program, 0u) << "the vertex+fragment program did not link";
// Both of these are deferred to the next link, in opposite directions: the green
// stage stays in the executable until then, and the blue one stays out of it.
const GLuint blueFragment = MakeShader(GL_FRAGMENT_SHADER, kBlueFragmentSource);
glDetachShader(program, greenFragment);
glAttachShader(program, blueFragment);
DrainErrors();
GLenum windowDrawError = GL_NO_ERROR;
const Image inTheWindow = DrawFullViewportTriangle(program, GL_TRIANGLES, &windowDrawError);
EXPECT_EQ(windowDrawError, static_cast<GLenum>(GL_NO_ERROR))
<< "neither the detach nor the attach has been linked in, so the draw must execute";
ExpectFullyColored(inTheWindow, kGreen, "the draw inside the attach window");
DrainErrors();
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE) << "the relink onto the blue fragment stage failed";
DrainErrors();
GLenum relinkedDrawError = GL_NO_ERROR;
const Image afterRelink = DrawFullViewportTriangle(program, GL_TRIANGLES, &relinkedDrawError);
EXPECT_EQ(relinkedDrawError, static_cast<GLenum>(GL_NO_ERROR)) << "the relinked program must draw";
ExpectFullyColored(afterRelink, kBlue, "the draw after the relink");
DrainErrors();
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,366 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/RelinkStageSetScenario.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 RELINK MAY CHANGE WHICH STAGES A PROGRAM HAS, AND EVERY DRAW AFTER IT RUNS
// THE NEW STAGE SET.
//
// GL 4.6 core 7.3: glLinkProgram builds an executable out of whatever is attached at that
// moment, so the stage set is a property of a LINK and not of a program. A program that
// linked vertex+fragment, drew, then had a geometry shader attached and was relinked runs
// three stages from that point on.
//
// DirectGLES rebuilds its driver program in place - same GL name, new executable - and the
// per-draw bind dedupes on that name, so a relink that changed the stage set installed
// nothing and the following draws rendered NOTHING at all: no GL error, LINK_STATUS true,
// and a framebuffer that kept its clear colour. See the note at the glLinkProgram in
// BackendProgramObjectImpl::SyncToBackend for what the driver does with such a relink.
//
// PostLinkAttachScenario pins the other half of the same rule - that the executable does
// NOT move until the relink. This one pins what happens when it does, in all three
// directions: a stage added, a stage removed, and a stage added that the ES backend has to
// synthesize a partner for.
//
// Every case asserts on a SHAPE and not merely on "something came out". The geometry and
// tessellation stages here halve the triangle, so a full-viewport green frame and a
// half-size one say which executable ran - "still drew" and "drew the right stages" are
// different claims and only the second one is worth pinning.
//
// Needs a real context: what is asserted is a rendered pixel out of a backend program build.
#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 kFboWidth = 64;
constexpr int kFboHeight = 64;
// A full-viewport triangle out of gl_VertexID alone, so no case here needs a vertex
// buffer and one draw covers every pixel of the target.
const char* const kVertexSource = R"(#version 420 core
void main()
{
vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}
)";
const char* const kFragmentSource = R"(#version 420 core
out vec4 fragColor;
void main()
{
fragColor = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
// Halves the triangle instead of passing it through: the centre pixel stays covered
// and all four corners fall outside, so the frame alone says whether this stage ran.
const char* const kGeometrySource = R"(#version 420 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
void main()
{
for (int i = 0; i < 3; ++i) {
gl_Position = vec4(gl_in[i].gl_Position.xy * 0.5, gl_in[i].gl_Position.zw);
EmitVertex();
}
EndPrimitive();
}
)";
// No control stage on purpose: OpenGL ES rejects that shape outright, so DirectGLES
// synthesizes a pass-through one (AttachPassthroughTessControlStage) and DirectVulkan
// does the same. Reading only gl_in[].gl_Position keeps this inside what such a
// pass-through may forward. At the tessellation levels it sets (all 1.0) the patch
// comes back out as one triangle whose gl_TessCoord values are the three corners, so
// the barycentric sum reproduces the vertex stage's triangle - halved, for the same
// reason the geometry stage above halves it.
const char* const kTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, ccw) in;
void main()
{
vec4 p = gl_TessCoord.x * gl_in[0].gl_Position +
gl_TessCoord.y * gl_in[1].gl_Position +
gl_TessCoord.z * gl_in[2].gl_Position;
gl_Position = vec4(p.xy * 0.5, p.zw);
}
)";
constexpr Rgba8 kGreen{0, 255, 0, 255};
constexpr Rgba8 kRed{255, 0, 0, 255};
class RelinkStageSetScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
m_target = MakeColorFbo(kFboWidth, kFboHeight);
ASSERT_NE(m_target.fbo, 0u) << "could not create the scenario's colour target";
BindFbo(m_target);
DrainErrors();
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) glDeleteProgram(program);
m_programs.clear();
for (const GLuint shader : m_shaders) glDeleteShader(shader);
m_shaders.clear();
BindDefaultFramebuffer();
DestroyColorFbo(m_target);
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
DrainErrors();
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
// The same real-backend probes the other stage-gated scenarios use: 0 on a
// DirectGLES driver without the extension and on a DirectVulkan device without
// the feature.
static bool BackendHostsGeometry() {
GLint maxGeometryOutputVertices = 0;
glGetIntegerv(GL_MAX_GEOMETRY_OUTPUT_VERTICES, &maxGeometryOutputVertices);
DrainErrors();
return maxGeometryOutputVertices >= 4;
}
static bool BackendHostsTessellation() {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
DrainErrors();
return maxTessGenLevel >= 1;
}
static std::string InfoLog(GLuint object, bool isShader) {
GLint length = 0;
if (isShader) {
glGetShaderiv(object, GL_INFO_LOG_LENGTH, &length);
} else {
glGetProgramiv(object, GL_INFO_LOG_LENGTH, &length);
}
if (length <= 0) return {};
std::string log(static_cast<size_t>(length), '\0');
if (isShader) {
glGetShaderInfoLog(object, length, nullptr, log.data());
} else {
glGetProgramInfoLog(object, length, nullptr, log.data());
}
log.resize(std::char_traits<char>::length(log.c_str()));
return log;
}
GLuint MakeShader(GLenum stage, const char* source) {
const GLuint shader = glCreateShader(stage);
if (shader == 0) return 0;
m_shaders.push_back(shader);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
EXPECT_EQ(compiled, GL_TRUE) << "a scenario shader did not compile: " << InfoLog(shader, true);
return shader;
}
GLuint MakeProgram() {
const GLuint program = glCreateProgram();
m_programs.push_back(program);
return program;
}
bool Link(GLuint program) {
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked != GL_TRUE) {
ADD_FAILURE() << "the link failed: " << InfoLog(program, false);
return false;
}
return true;
}
// Clears to red and draws. Red is the clear colour deliberately: nothing here ever
// paints red inside the triangle, so a frame that is red where it should be green
// says "this draw did not execute" while a frame that is green where it should be
// red says "it executed against the wrong executable" - two failures worth telling
// apart. The error is sampled between the draw and the readback so a rejected draw
// is never confused with a readback that went wrong afterwards.
Image DrawTriangle(GLuint program, GLenum mode, GLenum* outDrawError = nullptr) {
glUseProgram(program);
ClearTo(1.0f, 0.0f, 0.0f, 1.0f);
DrainErrors();
glDrawArrays(mode, 0, 3);
if (outDrawError != nullptr) *outDrawError = glGetError();
return ReadPixels(kFboWidth, kFboHeight);
}
// The vertex stage's triangle covers the whole target, corners included.
static void ExpectFullTriangle(const Image& frame, const char* what) {
ASSERT_FALSE(frame.Empty()) << what << ": nothing was read back";
ExpectPixel(frame, kFboWidth / 2, kFboHeight / 2, kGreen, what, "centre");
for (const int y : {0, kFboHeight - 1}) {
for (const int x : {0, kFboWidth - 1}) {
ExpectPixel(frame, x, y, kGreen, what, "corner");
}
}
}
// ...and halved by a geometry or tessellation stage it no longer reaches any of
// them, which is what makes the shape readable as "that stage ran".
static void ExpectHalvedTriangle(const Image& frame, const char* what) {
ASSERT_FALSE(frame.Empty()) << what << ": nothing was read back";
ExpectPixel(frame, kFboWidth / 2, kFboHeight / 2, kGreen, what, "centre");
for (const int y : {0, kFboHeight - 1}) {
for (const int x : {0, kFboWidth - 1}) {
ExpectPixel(frame, x, y, kRed, what, "corner");
}
}
}
static void ExpectPixel(const Image& frame, int x, int y, const Rgba8& expected, const char* what,
const char* where) {
EXPECT_EQ(frame.At(x, y), expected)
<< what << ": " << where << " pixel (" << x << ", " << y << ") is " << frame.ColorName(x, y);
}
GLuint m_vao = 0;
ColorFbo m_target{};
std::vector<GLuint> m_programs;
std::vector<GLuint> m_shaders;
};
// THE REGRESSION. Vertex+fragment, linked and DRAWN - which is what puts a built driver
// program on the backend twin - then a geometry shader attached and the program
// relinked. The halved frame is the assertion: the three-stage executable really is
// what the next draw ran.
TEST_F(RelinkStageSetScenario, RelinkingToAddAGeometryStageRunsTheNewExecutable) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " (" << Gl().RendererString()
<< "); there is no stage to add";
}
const GLuint program = MakeProgram();
glAttachShader(program, MakeShader(GL_VERTEX_SHADER, kVertexSource));
glAttachShader(program, MakeShader(GL_FRAGMENT_SHADER, kFragmentSource));
ASSERT_TRUE(Link(program));
DrainErrors();
GLenum beforeError = GL_NO_ERROR;
const Image before = DrawTriangle(program, GL_TRIANGLES, &beforeError);
EXPECT_EQ(beforeError, static_cast<GLenum>(GL_NO_ERROR)) << "the vertex+fragment draw must execute";
ExpectFullTriangle(before, "the draw before the relink");
DrainErrors();
glAttachShader(program, MakeShader(GL_GEOMETRY_SHADER, kGeometrySource));
ASSERT_TRUE(Link(program));
DrainErrors();
GLenum afterError = GL_NO_ERROR;
const Image after = DrawTriangle(program, GL_TRIANGLES, &afterError);
EXPECT_EQ(afterError, static_cast<GLenum>(GL_NO_ERROR)) << "the relinked three-stage program must draw";
ExpectHalvedTriangle(after, "the draw after the geometry stage was linked in");
DrainErrors();
}
// The same move in the other direction, which no repair may confuse with "the stage
// set did not change": the geometry stage leaves the executable, so the halving has to
// stop with it.
TEST_F(RelinkStageSetScenario, RelinkingToRemoveAGeometryStageRunsTheNewExecutable) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " (" << Gl().RendererString()
<< "); there is no stage to remove";
}
const GLuint program = MakeProgram();
glAttachShader(program, MakeShader(GL_VERTEX_SHADER, kVertexSource));
const GLuint geometry = MakeShader(GL_GEOMETRY_SHADER, kGeometrySource);
glAttachShader(program, geometry);
glAttachShader(program, MakeShader(GL_FRAGMENT_SHADER, kFragmentSource));
ASSERT_TRUE(Link(program));
DrainErrors();
// Also the control for the case above: a three-stage program linked in ONE go and
// never relinked draws its halved triangle.
GLenum beforeError = GL_NO_ERROR;
const Image before = DrawTriangle(program, GL_TRIANGLES, &beforeError);
EXPECT_EQ(beforeError, static_cast<GLenum>(GL_NO_ERROR)) << "the three-stage draw must execute";
ExpectHalvedTriangle(before, "the draw before the geometry stage was dropped");
DrainErrors();
glDetachShader(program, geometry);
ASSERT_TRUE(Link(program));
DrainErrors();
GLenum afterError = GL_NO_ERROR;
const Image after = DrawTriangle(program, GL_TRIANGLES, &afterError);
EXPECT_EQ(afterError, static_cast<GLenum>(GL_NO_ERROR)) << "the relinked vertex+fragment program must draw";
ExpectFullTriangle(after, "the draw after the geometry stage was dropped");
DrainErrors();
}
// The third direction, and the one that asks the most of the rebuild: the added stage
// is a tessellation evaluation shader with no control stage, so the ES backend has to
// synthesize a pass-through control stage for an executable that had neither a moment
// ago. GL_PATCHES becomes the only legal mode with it, which is also the only draw-mode
// change any case here makes.
TEST_F(RelinkStageSetScenario, RelinkingToAddATessEvalStageRunsTheNewExecutable) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< "); there is no stage to add";
}
const GLuint program = MakeProgram();
glAttachShader(program, MakeShader(GL_VERTEX_SHADER, kVertexSource));
glAttachShader(program, MakeShader(GL_FRAGMENT_SHADER, kFragmentSource));
ASSERT_TRUE(Link(program));
DrainErrors();
GLenum beforeError = GL_NO_ERROR;
const Image before = DrawTriangle(program, GL_TRIANGLES, &beforeError);
EXPECT_EQ(beforeError, static_cast<GLenum>(GL_NO_ERROR)) << "the vertex+fragment draw must execute";
ExpectFullTriangle(before, "the draw before the relink");
DrainErrors();
glAttachShader(program, MakeShader(GL_TESS_EVALUATION_SHADER, kTessEvalSource));
ASSERT_TRUE(Link(program));
// Three, which is already the default; spelled out because the synthesized control
// stage's output patch size is compiled from it.
glPatchParameteri(GL_PATCH_VERTICES, 3);
DrainErrors();
GLenum afterError = GL_NO_ERROR;
const Image after = DrawTriangle(program, GL_PATCHES, &afterError);
EXPECT_EQ(afterError, static_cast<GLenum>(GL_NO_ERROR)) << "the relinked tessellating program must draw";
ExpectHalvedTriangle(after, "the draw after the tessellation stage was linked in");
DrainErrors();
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,245 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SnormAttachmentScenario.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 - SIGNED-NORMALIZED COLOUR ATTACHMENTS, on a live driver.
//
// The bug: a GLES driver without GL_EXT_render_snorm treats every signed-normalized format as
// texture-only. DirectGLES had a colour-renderable substitute for exactly one of the eight
// (GL_RGB16_SNORM, through the three-channel widening), so an R8_SNORM or R16_SNORM attachment got
// no storage the driver would render into: the ES framebuffer was incomplete, the draw landed
// nowhere, and glGetTexImage fell through to the CPU shadow - all zeroes for a texture created with
// no data. KHR-GL4x.texture_swizzle renders into a SINGLE-CHANNEL SNORM output for every one of its
// SNORM source formats, which is why all 46 of its GL43 SNORM cases failed on Mali.
//
// THE OTHER HALF, and the reason this scenario asserts VALUES rather than only completeness: the
// substitute has to be exact. A half float's 11-bit mantissa cannot represent a 16-bit SNORM
// channel - 23451/32767 quantizes about six SNORM steps away, against a conformance window of one -
// so the 16-bit formats must land on a 32-bit float even though the 8-bit ones are fine in a half.
// Trading 46 visible failures for silent precision loss in Iris' SNORM normal buffers would be the
// worse outcome, so the round trip below is pinned tightly enough to fail on a half-float substitute
// (tolerance two SNORM steps, half-float error six).
//
// WHAT THIS GATE CAN AND CANNOT SEE. Both CI drivers (Mesa llvmpipe) and Adreno expose
// GL_EXT_render_snorm, so they take the NATIVE path here and the substitution stays dead. That is
// precisely why the assertions are written as invariants of the format rather than of the fallback:
// "a signed-normalized colour attachment is complete and round-trips its channel values" has to
// hold whichever path answers it, so the scenario fails if anyone ever routes these formats to a
// lossy storage on a driver where it IS live. The substitution itself can only be observed on a
// device without EXT_render_snorm (Mali Immortalis-G925).
//
// DirectGLES only, like the three-channel scenario next door: DirectVulkan resolves SNORM formats
// on its own terms and asserting Espryt's answers there would pin a coincidence.
#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);
}
)";
// A uniform rather than a literal so nothing can constant-fold the value into a different
// precision than the one the attachment stores.
constexpr const char* kFS = R"(#version 330 core
out vec4 oColor;
uniform float uValue;
void main() { oColor = vec4(uValue, 0.0, 0.0, 1.0); }
)";
constexpr int kSize = 8;
// The two channel values the round trip is pinned on. Both are positive on purpose:
// glReadPixels applies GL_CLAMP_READ_COLOR (GL_FIXED_ONLY by default) to a fixed-point
// colour buffer, so the negative half of a SNORM attachment reads back as 0 and would
// measure the clamp instead of the storage.
constexpr int kSnorm8Value = 99;
constexpr int kSnorm16Value = 23451;
class SnormAttachmentScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
if (Gl().BackendName() != "DirectGLES") {
GTEST_SKIP() << "the signed-normalized substitution is a DirectGLES fallback; backend is "
<< Gl().BackendName();
}
}
// A single-level 2D texture in `internalFormat`, or 0 when the driver rejects the
// storage outright (which is a different failure from rejecting the ATTACHMENT).
static GLuint MakeTexture(GLenum internalFormat) {
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexStorage2D(GL_TEXTURE_2D, 1, internalFormat, kSize, kSize);
if (glGetError() != GL_NO_ERROR) {
glDeleteTextures(1, &texture);
return 0;
}
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glBindTexture(GL_TEXTURE_2D, 0);
return texture;
}
static GLenum SingleAttachmentStatus(GLenum internalFormat) {
const GLuint texture = MakeTexture(internalFormat);
if (texture == 0) return GL_NONE;
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
const GLenum status = glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &fbo);
glDeleteTextures(1, &texture);
return status;
}
// Renders `value` into the red channel of a fresh `internalFormat` attachment and hands
// back what glReadPixels sees. Returns false when the framebuffer never came up, which
// is the failure mode this scenario exists for - a draw into an incomplete framebuffer
// is dropped by the driver and leaves the caller reading the cleared texture.
bool RenderAndReadRed(GLenum internalFormat, float value, float* outRed) {
std::string error;
const GLuint program = CompileProgram(kVS, kFS, &error);
EXPECT_NE(program, 0u) << error;
if (program == 0) return false;
const GLint valueLocation = glGetUniformLocation(program, "uValue");
EXPECT_GE(valueLocation, 0);
const GLuint texture = MakeTexture(internalFormat);
EXPECT_NE(texture, 0u) << "the driver refused the texture storage itself";
if (texture == 0) {
glDeleteProgram(program);
return false;
}
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
const bool complete = glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;
if (complete) {
const float quad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
GLuint vao = 0;
GLuint vbo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
glUseProgram(program);
glUniform1f(valueLocation, value);
glViewport(0, 0, kSize, kSize);
// Cleared to zero so a dropped draw cannot be mistaken for a correct one.
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
std::vector<float> pixels(static_cast<std::size_t>(kSize) * kSize * 4, -1.0f);
glReadBuffer(GL_COLOR_ATTACHMENT0);
glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_FLOAT, pixels.data());
if (outRed) *outRed = pixels[0];
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &fbo);
glDeleteTextures(1, &texture);
glDeleteProgram(program);
return complete;
}
};
// THE regression gate for the frontend's answer. Every one of these used to be
// GL_FRAMEBUFFER_UNSUPPORTED on a driver without EXT_render_snorm, and nothing in the CTS
// (or in Iris) checks the status before drawing, so the failure was silent all the way to a
// readback of zeroes.
TEST_F(SnormAttachmentScenario, SignedNormalizedColorAttachmentsReportComplete) {
if (!Ready() || IsSkipped()) return;
// GL_R8 is the control: colour-renderable in ES core, so it must pass with or without
// any substitution. If it ever fails, nothing below means anything.
EXPECT_EQ(SingleAttachmentStatus(GL_R8), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
<< "GL_R8 is ES-core colour-renderable";
// The single-channel pair KHR-GL4x.texture_swizzle renders into for every SNORM source
// format - the whole 46-case failure.
EXPECT_EQ(SingleAttachmentStatus(GL_R8_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
EXPECT_EQ(SingleAttachmentStatus(GL_R16_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
// ...and the two- and four-channel siblings, which are what a shaderpack actually
// declares (Iris colortex buffers in RGBA16_SNORM).
EXPECT_EQ(SingleAttachmentStatus(GL_RG8_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
EXPECT_EQ(SingleAttachmentStatus(GL_RG16_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
EXPECT_EQ(SingleAttachmentStatus(GL_RGBA8_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
EXPECT_EQ(SingleAttachmentStatus(GL_RGBA16_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
// The other half: whatever storage answers for the attachment has to hold the channel value
// to the format's own precision. This is the assertion that fails if the 16-bit formats are
// ever routed to a half float - the substitute an implementer naturally reaches for, because
// it is what the 8-bit ones correctly use.
TEST_F(SnormAttachmentScenario, SignedNormalizedAttachmentsRoundTripTheirChannelValues) {
if (!Ready() || IsSkipped()) return;
const float snorm8Expected = static_cast<float>(kSnorm8Value) / 127.0f;
float red8 = -1.0f;
ASSERT_TRUE(RenderAndReadRed(GL_R8_SNORM, snorm8Expected, &red8))
<< "an R8_SNORM colour attachment must be complete before any value can be asserted";
// Two 8-bit SNORM steps. A half float is exact here (worst case 0.03 of a step), so this
// only has to catch a storage that quantizes harder than the format itself.
EXPECT_NEAR(red8, snorm8Expected, 2.0f / 127.0f)
<< "R8_SNORM attachment lost its channel value";
EXPECT_GT(red8, 0.5f) << "the draw never landed - this is the cleared texture, not the rendered one";
const float snorm16Expected = static_cast<float>(kSnorm16Value) / 32767.0f;
float red16 = -1.0f;
ASSERT_TRUE(RenderAndReadRed(GL_R16_SNORM, snorm16Expected, &red16))
<< "an R16_SNORM colour attachment must be complete before any value can be asserted";
// Two 16-bit SNORM steps (6.1e-5). A half float would land 1.9e-4 away - three times
// this window - which is exactly the failure this bound exists to catch.
EXPECT_NEAR(red16, snorm16Expected, 2.0f / 32767.0f)
<< "R16_SNORM attachment was stored in something that cannot hold 16 signed bits";
EXPECT_GT(red16, 0.5f) << "the draw never landed - this is the cleared texture, not the rendered one";
float red16x4 = -1.0f;
ASSERT_TRUE(RenderAndReadRed(GL_RGBA16_SNORM, snorm16Expected, &red16x4))
<< "an RGBA16_SNORM colour attachment must be complete before any value can be asserted";
EXPECT_NEAR(red16x4, snorm16Expected, 2.0f / 32767.0f)
<< "RGBA16_SNORM attachment was stored in something that cannot hold 16 signed bits";
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,189 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SsboArrayDynamicIndexScenario.cpp
// Copyright (c) 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 NON-CONSTANT INDEX INTO AN ARRAY OF SHADER STORAGE BLOCKS.
//
// GL 4.3 allows any dynamically-uniform expression there; GLSL ES keeps the ES 3.1 rule that the
// index must be a constant integral expression, and the Qualcomm compiler enforces it:
//
// '[' : indexing into an SSBO array using a non-constant expression is not permitted
//
// The stage then never compiles, the backend program links nothing, and every dispatch is a
// silent no-op - while glGetProgramiv(GL_LINK_STATUS) keeps reporting the successful link the
// frontend already published. That is why the conformance failures
// (KHR-GL43.shader_storage_buffer_object.basic-stdLayout-case1/case4,
// advanced-indirectAddressing-case2, compute_shader.resources-max, 7 cases in all) read back as
// "the buffer was never written" rather than as an error, and why this scenario asserts on
// contents rather than on link status.
//
// Both index shapes the legalization has to cover are exercised in one dispatch: a loop induction
// variable (which folds when the loop unrolls) and a `uniform int` (which nothing can fold, so the
// switch/select lowering is what carries it), for a read AND for a write.
#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 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) buffer Slot {
uint value;
} g_slots[4];
layout(std430, binding = 4) buffer Output {
uint g_result[];
};
uniform int g_index;
void main() {
// Loop-derived index: foldable by unrolling.
for (int i = 0; i < 4; ++i) {
g_result[i] = g_slots[i].value;
}
// Uniform-derived index: not foldable, read and write both.
g_result[4] = g_slots[g_index].value;
g_slots[g_index].value = 99u;
}
)";
constexpr int kSlotCount = 4;
constexpr int kResultCount = 5;
class SsboArrayDynamicIndexScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
GLint blocks = 0;
glGetIntegerv(GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, &blocks);
if (blocks < kSlotCount + 1) {
GTEST_SKIP() << "GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS is " << blocks << "; this needs "
<< kSlotCount + 1;
}
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;
}
GLuint MakeStorageBuffer(const std::vector<unsigned int>& contents) {
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
glBufferData(GL_SHADER_STORAGE_BUFFER,
static_cast<GLsizeiptr>(contents.size() * sizeof(unsigned int)), contents.data(),
GL_DYNAMIC_COPY);
m_buffers.push_back(buffer);
return buffer;
}
static std::vector<unsigned int> ReadBuffer(GLuint buffer, int count) {
std::vector<unsigned int> values(static_cast<std::size_t>(count), 0xDEADBEEFu);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0,
static_cast<GLsizeiptr>(values.size() * sizeof(unsigned int)), values.data());
return values;
}
unsigned int m_program = 0;
std::string m_buildLog;
std::vector<GLuint> m_buffers;
};
} // namespace
TEST_F(SsboArrayDynamicIndexScenario, ReadsAndWritesTheBlockTheIndexNames) {
if (!Ready() || IsSkipped()) return;
GLuint slots[kSlotCount] = {};
for (int i = 0; i < kSlotCount; ++i) {
slots[i] = MakeStorageBuffer({static_cast<unsigned int>(10 + i)});
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, static_cast<GLuint>(i), slots[i]);
}
const GLuint output = MakeStorageBuffer(std::vector<unsigned int>(kResultCount, 0u));
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, kSlotCount, output);
ASSERT_EQ(FirstGLError(), 0u);
glUseProgram(m_program);
const GLint indexLocation = glGetUniformLocation(m_program, "g_index");
ASSERT_NE(indexLocation, -1);
glUniform1i(indexLocation, 2);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
EXPECT_EQ(FirstGLError(), 0u);
const std::vector<unsigned int> result = ReadBuffer(output, kResultCount);
for (int i = 0; i < kSlotCount; ++i) {
EXPECT_EQ(result[static_cast<std::size_t>(i)], static_cast<unsigned int>(10 + i))
<< "g_slots[" << i << "] read through the loop index came back as "
<< result[static_cast<std::size_t>(i)]
<< "; 0 means the stage never compiled and the dispatch was a silent no-op";
}
EXPECT_EQ(result[4], 12u) << "g_slots[g_index] with g_index = 2 read back as " << result[4];
const std::vector<unsigned int> written = ReadBuffer(slots[2], 1);
EXPECT_EQ(written[0], 99u) << "the uniform-indexed WRITE landed as " << written[0]
<< " instead of 99 in g_slots[2]";
// The write must have gone to element 2 and nowhere else.
for (int i = 0; i < kSlotCount; ++i) {
if (i == 2) continue;
const std::vector<unsigned int> untouched = ReadBuffer(slots[i], 1);
EXPECT_EQ(untouched[0], static_cast<unsigned int>(10 + i))
<< "g_slots[" << i << "] was overwritten by a write that named element 2";
}
for (int i = 0; i <= kSlotCount; ++i) {
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, static_cast<GLuint>(i), 0);
}
}
} // namespace MGITest
@@ -64,6 +64,25 @@ void main() {
g_length[2] = g_input23[0].data.length();
g_length[3] = g_input23[1].data.length();
}
)";
// GL 4.6 core 4.10 lets a buffer variable be declared readonly AND writeonly at once:
// it can then be neither read nor written, and `.length()` is the only thing left that
// may be asked of it. The pair is inert - and printing it into ESSL is not, because
// SPIRV-Cross hoists the qualifiers every member shares onto the BLOCK and Mesa's ES
// compiler refuses that spelling ("Interface block sets both readonly and writeonly").
// Lifted from KHR-GL43.shader_storage_buffer_object.basic-readonly-writeonly.
constexpr const char* kReadonlyWriteonlyComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Input {
readonly writeonly int g_in[];
};
layout(std430, binding = 4) buffer Output {
int g_length[];
};
void main() {
g_length[0] = g_in.length();
}
)";
constexpr int kElementBytes = 16; // ivec4, std430
@@ -212,4 +231,33 @@ void main() {
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, input0);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, input3);
}
// A buffer variable qualified readonly AND writeonly can only be asked its length, and that
// question still has to be answered. A stage the driver refused answers 0 - and refuses
// silently, because the program links without it and the dispatch is then a no-op.
TEST_F(SsboArrayLengthScenario, AReadonlyWriteonlyArrayStillReportsItsLength) {
if (!Ready() || IsSkipped()) return;
const GLuint program = CompileComputeProgram(kReadonlyWriteonlyComputeSource);
ASSERT_NE(program, 0u) << m_buildLog;
const GLuint input = MakeStorageBuffer(6); // 6 ivec4 = 24 ints
const GLuint output = MakeStorageBuffer(1);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, input);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, output);
ASSERT_EQ(FirstGLError(), 0u);
glUseProgram(program);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
int length = -1;
glBindBuffer(GL_SHADER_STORAGE_BUFFER, output);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(length), &length);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(length, 24) << "a readonly+writeonly runtime array reported length " << length
<< "; 0 means the stage never reached the program";
glUseProgram(m_program);
glDeleteProgram(program);
}
} // namespace MGITest
@@ -42,10 +42,10 @@
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] = {
// The eight vertex shaders of the conformance sweep, verbatim in shape, plus a ninth that
// is not from the sweep (see form 8). 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[9] = {
// 0 - instance name, no binding qualifier, sized array member
R"(#version 430 core
layout(std430) buffer Buffer {
@@ -127,6 +127,38 @@ void main() {
case 2: gl_Position = g_buffer.position2[gl_VertexID - 2]; break;
}
}
)",
// 8 - NOT from the conformance sweep. An unqualified storage block with a UNIFORM
// BLOCK beside it, which is what makes the block's DEFAULT binding observable at all.
//
// GL 4.3 core 7.8 gives a storage block with no layout(binding = N) a buffer binding
// of zero. Forms 0, 1, 3, 4 and 5 above are all unqualified and all pass, but they
// cannot prove that rule holds: they are the only resource in their shader, so the
// binding glslang's IO mapper invents for them happens to BE zero and the right answer
// arrives for the wrong reason.
//
// Every shader here is parsed as a Vulkan client, so that mapper allocates out of ONE
// flat space shared by samplers, images, uniform blocks, storage blocks and the
// synthesized global-uniform block (iomapper.cpp resolveBinding takes the `ent.newSet`
// branch, and every resource resolves to set 0), and then writes the result back into
// the type's qualifier - so the reflection cannot tell an invented binding from a
// declared one. Put anything live next to the block and it is pushed off zero, the
// draw reads a binding point nothing was ever bound to, and the triangle collapses
// with no GL error anywhere. That is
// KHR-GL43.compute_shader.resource-ubo's whole failure, in a vertex stage.
//
// The uniform block is REBOUND explicitly with glUniformBlockBinding, exactly as that
// conformance case does. That keeps this case about the storage block's default and
// not about the uniform block's - the rebinding path has always worked, and the
// uniform-block default is a separate (still open) question.
R"(#version 430 core
layout(std140) uniform ScaleBlock {
vec4 factor;
} g_scale;
layout(std430) buffer Buffer {
vec4 position[3];
} g_input_buffer;
void main() { gl_Position = g_input_buffer.position[gl_VertexID] * g_scale.factor; }
)",
};
@@ -197,6 +229,26 @@ void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
const unsigned int program = CompileProgram(kFormVS[form], kFormFS, &error);
ASSERT_NE(program, 0u) << "form " << form << " did not build: " << error;
// Form 8 alone declares a uniform block, and it exists only to occupy a slot the
// storage block must not be pushed onto. Bound to a buffer of ones so it scales
// the positions by exactly 1 - the block's contribution to the IMAGE is nothing,
// and its contribution to the TEST is that it is there at all.
GLuint uniformBuffer = 0;
if (form == 8) {
const float ones[4] = {1.0f, 1.0f, 1.0f, 1.0f};
glGenBuffers(1, &uniformBuffer);
glBindBuffer(GL_UNIFORM_BUFFER, uniformBuffer);
glBufferData(GL_UNIFORM_BUFFER, sizeof(ones), ones, GL_STATIC_DRAW);
glBindBufferBase(GL_UNIFORM_BUFFER, 0, uniformBuffer);
glBindBuffer(GL_UNIFORM_BUFFER, 0);
const GLuint blockIndex = glGetUniformBlockIndex(program, "ScaleBlock");
ASSERT_NE(blockIndex, GL_INVALID_INDEX) << "form 8: the uniform block is not active";
// Explicit, so this case cannot fail on the uniform block's own default
// binding - which is a separate question from the storage block's.
glUniformBlockBinding(program, blockIndex, 0);
ASSERT_EQ(FirstGLError(), 0u) << "form 8: uniform block setup errored";
}
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
@@ -221,6 +273,7 @@ void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
glDeleteVertexArrays(1, &vao);
glDeleteProgram(program);
glDeleteBuffers(1, &buffer);
if (uniformBuffer != 0) glDeleteBuffers(1, &uniformBuffer);
gl.EndFrame();
}
};
@@ -241,6 +294,10 @@ void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
MGL_SSBO_FORM_CASE(3, GlobalLayoutDefaultsThenAnInstanceNamedBlock)
MGL_SSBO_FORM_CASE(4, BlockInstanceArrayOfOne)
MGL_SSBO_FORM_CASE(5, BlockInstanceArrayOfOneWithSharedLayout)
// The form that makes the DEFAULT binding observable rather than accidental: forms 0/1/3/4/5
// are unqualified too, but nothing competes with them for glslang's flat slot 0, so they
// would keep passing even with the default wrong. See the comment on kFormVS[8].
MGL_SSBO_FORM_CASE(8, NoBindingQualifierBesideAUniformBlock)
// ---- 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
@@ -0,0 +1,156 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/StorageBufferRegrowScenario.cpp
// Copyright (c) 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 - glBufferData GROWS A BUFFER THAT IS ALREADY BOUND AT AN INDEXED POINT.
//
// GL says the indexed binding follows the buffer object, so after the store is re-specified the
// shader sees the NEW extent. DirectGLES shadows the indexed bindings so a redundant
// glBindBufferBase can be skipped, and nothing used to invalidate that shadow when the store was
// re-specified underneath it - so on a driver that resolves a whole-buffer indexed binding's
// extent at BIND time (Adreno does; Mali does not) the shader kept seeing the OLD, smaller range.
// Stores past it are dropped and loads return zero, which is exactly what
// KHR-GL43.compute_shader.dispatch-indirect reported: the first iteration's 6 elements correct and
// everything past byte 24 zero, after the same buffer was re-specified from 24 to 96 bytes.
//
// The assertion is deliberately on the WHOLE grown range, so a partial write names the byte the
// stale extent stopped at.
#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* kComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Output {
uint g_data[];
};
void main() {
g_data[gl_GlobalInvocationID.x] = gl_GlobalInvocationID.x + 1u;
}
)";
constexpr int kSmallElements = 6; // 24 bytes - the first iteration's size
constexpr int kLargeElements = 24; // 96 bytes - what the second iteration grows to
class StorageBufferRegrowScenario : 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_buffer);
}
void TearDown() override {
if (!Ready()) return;
if (m_buffer != 0) glDeleteBuffers(1, &m_buffer);
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;
}
void RespecifyTo(int elements) {
const std::vector<unsigned int> zeros(static_cast<std::size_t>(elements), 0u);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_buffer);
glBufferData(GL_SHADER_STORAGE_BUFFER,
static_cast<GLsizeiptr>(zeros.size() * sizeof(unsigned int)), zeros.data(),
GL_DYNAMIC_COPY);
}
std::vector<unsigned int> DispatchAndRead(int elements) {
glUseProgram(m_program);
glDispatchCompute(static_cast<GLuint>(elements), 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
std::vector<unsigned int> values(static_cast<std::size_t>(elements), 0xDEADBEEFu);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_buffer);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0,
static_cast<GLsizeiptr>(values.size() * sizeof(unsigned int)), values.data());
return values;
}
unsigned int m_program = 0;
GLuint m_buffer = 0;
std::string m_buildLog;
};
} // namespace
TEST_F(StorageBufferRegrowScenario, AGrownStoreIsVisibleThroughItsExistingIndexedBinding) {
if (!Ready() || IsSkipped()) return;
// Iteration one: 24 bytes, bound once, six groups.
RespecifyTo(kSmallElements);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_buffer);
ASSERT_EQ(FirstGLError(), 0u);
const std::vector<unsigned int> small = DispatchAndRead(kSmallElements);
ASSERT_EQ(FirstGLError(), 0u);
for (int i = 0; i < kSmallElements; ++i) {
ASSERT_EQ(small[static_cast<std::size_t>(i)], static_cast<unsigned int>(i + 1))
<< "the 24-byte iteration itself did not write element " << i;
}
// Iteration two: the SAME buffer grows to 96 bytes with NO new glBindBufferBase, which is
// what the application is entitled to do and what the shadow used to swallow.
RespecifyTo(kLargeElements);
ASSERT_EQ(FirstGLError(), 0u);
const std::vector<unsigned int> large = DispatchAndRead(kLargeElements);
EXPECT_EQ(FirstGLError(), 0u);
for (int i = 0; i < kLargeElements; ++i) {
EXPECT_EQ(large[static_cast<std::size_t>(i)], static_cast<unsigned int>(i + 1))
<< "element " << i << " (byte " << i * 4 << ") of the grown store came back as "
<< large[static_cast<std::size_t>(i)]
<< "; zero from element " << kSmallElements
<< " on means the shader still saw the pre-growth extent";
}
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
}
} // namespace MGITest
@@ -0,0 +1,226 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/TessellationDrawModeScenario.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_PATCHES AND THE TESSELLATION PIPELINE ARE EACH OTHER'S ONLY PARTNER.
//
// GL 4.6 core 10.1 states the rule in both directions, and both are GL_INVALID_OPERATION:
// a program with a tessellation evaluation shader may only be drawn with GL_PATCHES, and
// GL_PATCHES may only be drawn with such a program. MobileGL's draw-mode validator
// implemented the geometry-shader input-primitive rule and NOTHING for tessellation, which
// is two of the four sites KHR-GL43.transform_feedback.api_errors_test checks (all four
// share one copy-pasted message string, so the trace cannot say which one it stopped at).
//
// Needs a real context: the validator returns before either rule when no backend object is
// active, so the GPU-free negative-API suite cannot reach them.
#include <string>
#include <utility>
#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 {
const char* const kVertexSource = R"(#version 420 core
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
const char* const kTessControlSource = R"(#version 420 core
layout(vertices = 1) out;
void main()
{
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
gl_out[gl_InvocationID].gl_Position = gl_in[0].gl_Position;
}
)";
const char* const kTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, cw) in;
void main()
{
gl_Position = gl_in[0].gl_Position;
}
)";
const char* const kFragmentSource = R"(#version 420 core
out vec4 fragColor;
void main()
{
fragColor = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
class TessellationDrawModeScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " ("
<< Gl().RendererString() << "); there is no patch draw to validate";
}
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
}
// The same real-backend probe IoBlockNameCollisionScenario uses: 0 on a DirectGLES
// driver without GL_EXT_tessellation_shader and on a DirectVulkan device without
// the tessellationShader feature.
static bool BackendHostsTessellation() {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
DrainErrors();
return maxTessGenLevel >= 1;
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
GLuint BuildProgram(const std::vector<std::pair<GLenum, const char*>>& stages) {
std::vector<GLuint> shaders;
bool ok = true;
for (const auto& [stage, source] : stages) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
shaders.push_back(shader);
if (!compiled) {
m_buildLog = InfoLog(shader, true);
ok = false;
break;
}
}
if (!ok) {
for (const GLuint shader : shaders) glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
for (const GLuint shader : shaders) glAttachShader(program, shader);
glLinkProgram(program);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
for (const GLuint shader : shaders) glDeleteShader(shader);
if (!linked) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
return 0;
}
m_programs.push_back(program);
return program;
}
static std::string InfoLog(GLuint object, bool isShader) {
GLint length = 0;
if (isShader) {
glGetShaderiv(object, GL_INFO_LOG_LENGTH, &length);
} else {
glGetProgramiv(object, GL_INFO_LOG_LENGTH, &length);
}
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
if (isShader) {
glGetShaderInfoLog(object, length + 1, nullptr, buffer.data());
} else {
glGetProgramInfoLog(object, length + 1, nullptr, buffer.data());
}
return buffer.data();
}
const std::string& BuildLog() const { return m_buildLog; }
GLuint m_vao = 0;
std::vector<GLuint> m_programs;
std::string m_buildLog;
};
// A tessellation program drawn with anything but GL_PATCHES.
TEST_F(TessellationDrawModeScenario, TessellationProgramRejectsNonPatchModes) {
if (!Ready()) GTEST_SKIP();
const GLuint program = BuildProgram({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_NE(program, 0u) << "the tessellation program did not build: " << BuildLog();
glUseProgram(program);
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
for (const GLenum mode : {static_cast<GLenum>(GL_POINTS), static_cast<GLenum>(GL_LINES),
static_cast<GLenum>(GL_TRIANGLES)}) {
glDrawArrays(mode, 0, 1);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION))
<< "mode " << mode << " must not be accepted while tessellation is active";
DrainErrors();
}
// The one mode that IS accepted still is - a rule keyed any wider would break every
// patch draw in the suite.
glDrawArrays(GL_PATCHES, 0, 1);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
DrainErrors();
}
// ... and the other direction: GL_PATCHES without a tessellation evaluation stage.
TEST_F(TessellationDrawModeScenario, PatchesRejectedWithoutATessellationEvaluationStage) {
if (!Ready()) GTEST_SKIP();
const GLuint program =
BuildProgram({{GL_VERTEX_SHADER, kVertexSource}, {GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_NE(program, 0u) << "the vertex/fragment program did not build: " << BuildLog();
glUseProgram(program);
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
glDrawArrays(GL_PATCHES, 0, 1);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION))
<< "GL_PATCHES has no meaning without a tessellation evaluation stage";
DrainErrors();
// The same program with an ordinary mode is untouched.
glDrawArrays(GL_TRIANGLES, 0, 3);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
DrainErrors();
}
} // namespace
} // namespace MGITest
@@ -673,4 +673,86 @@ void main() {
glDeleteProgram(program);
}
// A GL_DOUBLE array is NARROWED to float32 and fetched, not dropped. No backend here has a
// 64-bit vertex format, but glVertexAttribFormat(GL_DOUBLE) is defined as "doubles in memory,
// converted to float" and the shader input is a plain vec4 either way, so nothing about fp64
// is needed - only the fetch conversion (KHR-GL43.vertex_attrib_binding.basic-input-case4).
// Every value here is exact in float32, so the capture is an equality test.
TEST_F(VertexAttribBindingScenario, DoubleArrayIsFetchedAtFloat32Precision) {
if (!Ready()) GTEST_SKIP();
ResetCurrentAttribs();
const double vertices[] = {100.0, 200.0, 300.0, 400.0};
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexBuffer(0, vbo, 0, 2 * static_cast<GLsizei>(sizeof(double)));
glVertexAttribFormat(1, 2, GL_DOUBLE, GL_FALSE, 0);
glVertexAttribBinding(1, 0);
glEnableVertexAttribArray(1);
const std::vector<float> data = CapturePoints(m_program, m_xfbo, 2, 1);
EXPECT_TRUE(Vec4Is(data, 0, 1, 100.0f, 200.0f, 0.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 1, 1, 300.0f, 400.0f, 0.0f, 1.0f));
glDisableVertexAttribArray(1);
glDeleteBuffers(1, &vbo);
}
// GL ignores `normalized` for floating-point array types, GL_DOUBLE included: the fetched
// values are the raw ones, not scaled into [0,1]. A conversion that forwarded the flag would
// return zeros here (KHR-GL43.vertex_attrib_binding.basic-input-case5).
TEST_F(VertexAttribBindingScenario, NormalizedIsIgnoredForDoubleArrays) {
if (!Ready()) GTEST_SKIP();
ResetCurrentAttribs();
const double vertices[] = {0.0, 10.0, 20.0, 0.0};
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexBuffer(0, vbo, 0, 4 * static_cast<GLsizei>(sizeof(double)));
glVertexAttribFormat(2, 4, GL_DOUBLE, GL_TRUE, 0);
glVertexAttribBinding(2, 0);
glEnableVertexAttribArray(2);
const std::vector<float> data = CapturePoints(m_program, m_xfbo, 1, 1);
EXPECT_TRUE(Vec4Is(data, 0, 2, 0.0f, 10.0f, 20.0f, 0.0f));
glDisableVertexAttribArray(2);
glDeleteBuffers(1, &vbo);
}
// The LONG form asks for more precision than any backend here can give and gets the same
// float32 stream. IsLong must not gate the narrowing off
// (KHR-GL43.vertex_attrib_binding.advanced-bindingUpdate feeds its dvec3 this way).
TEST_F(VertexAttribBindingScenario, LongDoubleArrayIsFetchedAtFloat32Precision) {
if (!Ready()) GTEST_SKIP();
ResetCurrentAttribs();
const double vertices[] = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0};
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexBuffer(0, vbo, 0, 3 * static_cast<GLsizei>(sizeof(double)));
glVertexAttribLFormat(3, 3, GL_DOUBLE, 0);
glVertexAttribBinding(3, 0);
glEnableVertexAttribArray(3);
const std::vector<float> data = CapturePoints(m_program, m_xfbo, 2, 1);
EXPECT_TRUE(Vec4Is(data, 0, 3, 1.0f, 2.0f, 3.0f, 1.0f));
EXPECT_TRUE(Vec4Is(data, 1, 3, 4.0f, 5.0f, 6.0f, 1.0f));
glDisableVertexAttribArray(3);
glDeleteBuffers(1, &vbo);
}
} // namespace MGITest
@@ -30,15 +30,23 @@
// applies the flip to viewport 0 and forgets the other fifteen renders a correct-looking FBO and
// an upside-down window - the classic multi-viewport bug, and invisible to every FBO-only case.
//
// HONEST LIMIT OF THIS FILE. DirectGLES SKIPS every case: GLES has one viewport, one scissor
// rectangle and no gl_ViewportIndex, so routing to index > 0 is an emulation feature that has
// not been built (the Espryt half of KHR-GL43.viewport_array's rendering group is deliberately
// still red). The skip is explicit rather than silent so a future emulation lands here as a
// failing test and not as a test that was quietly never running. DirectVulkan additionally
// skips when the device lacks the multiViewport feature - Vulkan then forbids a pipeline from
// declaring more than one viewport at all, which is a device limit and not a MobileGL bug;
// lavapipe (every CI lane) and both Mali/Adreno devices support it, so the cases do run where
// it matters.
// BOTH BACKENDS RUN EVERY CASE, by two completely different routes, which is the point of
// keeping them in one file. DirectVulkan declares sixteen viewports on the pipeline and lets the
// hardware route. DirectGLES has one viewport, one scissor rectangle and one depth range and no
// gl_ViewportIndex at all, so it EMULATES: the builtin becomes a flat varying, the fragment stage
// gets a gate, and the draw is replayed once per distinct viewport state (Managers.h,
// ForEachViewportRoutingPass). Every assertion below is about pixels, so it cannot tell the two
// apart - which is exactly what has to be true.
//
// DirectVulkan skips when the device lacks the multiViewport feature - Vulkan then forbids a
// pipeline from declaring more than one viewport at all, which is a device limit and not a
// MobileGL bug; lavapipe (every CI lane) and both Mali/Adreno devices support it, so the cases do
// run where it matters.
//
// The last case is the negative control for the emulation and runs on DirectGLES only: it builds
// the SAME program with the emulation switched off and requires the routing to collapse onto
// viewport 0. Without it every assertion above could be satisfied by a backend that happened to
// be right for some other reason, and the emulation's own switch would be untested.
#include <cmath>
#include <string>
@@ -47,6 +55,10 @@
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
// For the emulation switch the negative-control case below flips. Nothing else in this file needs
// to know which backend it is running on.
#include <Config.h>
#ifdef GLAPI
#undef GLAPI
#endif
@@ -142,13 +154,6 @@ void main() { fragColor = gl_FragCoord.z; }
ScenarioTest::SetUp();
if (!Ready()) return;
if (Gl().BackendName() == "DirectGLES") {
GTEST_SKIP() << "gl_ViewportIndex routing is not emulated on DirectGLES: GLES has one viewport "
"and one scissor rectangle, so every index rasterizes as index 0. The indexed "
"STATE is still asserted (MG_Test RenderStateTest); this is the deferred "
"rendering half of KHR-GL43.viewport_array.";
}
GLint maxViewports = 0;
glGetIntegerv(GL_MAX_VIEWPORTS, &maxViewports);
ASSERT_GE(maxViewports, kViewportCount) << "GL 4.3 core requires GL_MAX_VIEWPORTS >= 16";
@@ -520,5 +525,282 @@ void main() { fragColor = vec4(float(gsIndex) * 16.0 / 255.0, 0.0, 0.0, 1.0); }
DestroyIntTarget(target);
}
// --- 4. the negative control for the DirectGLES emulation -----------------------------
//
// Everything above is a claim about pixels, and a claim about pixels cannot tell an
// emulation that works from a backend that was going to be right anyway. This case builds
// the SAME program with MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION off and requires case 1's
// result to COLLAPSE: with no routing, every geometry invocation rasterizes against
// viewport 0's rectangle, so the last invocation paints the whole surface and every cell
// reads 15 instead of its own index. That is the pre-emulation behaviour this backend had
// (and the failure signature KHR-GL43.viewport_array reported on it), pinned here so that
// (a) the three cases above are known to be testing the emulation and not the weather,
// and (b) the switch itself has a test.
//
// DirectGLES only: the flag steers nothing on DirectVulkan, which routes natively.
TEST_F(ViewportArrayScenario, WithoutTheEmulationEveryIndexCollapsesOntoViewportZero) {
if (Gl().BackendName() != "DirectGLES") {
GTEST_SKIP() << "the emulation switch is a DirectGLES concern; DirectVulkan routes "
"gl_ViewportIndex natively and ignores it";
}
// The feature table is a process-global and this fixture shares its context with every
// other scenario in the process, so the restore is not optional.
struct ScopedEmulationOff {
ScopedEmulationOff(): saved(MobileGL::MG_Config::Features.ViewportArrayEmulation) {
MobileGL::MG_Config::Features.ViewportArrayEmulation =
MobileGL::MG_Config::QuirkOverride::ForceOff;
}
~ScopedEmulationOff() { MobileGL::MG_Config::Features.ViewportArrayEmulation = saved; }
MobileGL::MG_Config::QuirkOverride saved;
};
IntTarget target = MakeIntTarget(kSurfaceSide, kSurfaceSide);
SetupGridViewports(kCellSize, kCellSize);
GLuint unroutedProgram = 0;
{
const ScopedEmulationOff scopedEmulationOff;
// A FRESH program: the emitted ESSL is decided at link time and memoized on a key
// that carries this flag, so reusing m_program would just replay the routed build.
unroutedProgram = BuildProgram(kGridGeometrySource, kIntFragmentSource);
ASSERT_NE(unroutedProgram, 0u) << "unrouted program failed to build: " << m_buildLog;
glUseProgram(unroutedProgram);
glBindVertexArray(m_vao);
glDrawArrays(GL_POINTS, 0, 1);
ASSERT_EQ(glGetError(), GL_NO_ERROR);
}
const std::vector<GLint> pixels = ReadInts(kSurfaceSide, kSurfaceSide);
// Cell (0, 0) IS viewport 0's rectangle, so it is the one cell an unrouted draw paints
// with something. Everything it holds comes from the last geometry invocation.
EXPECT_EQ(CellCentre(pixels, kSurfaceSide, 0, 0), kViewportCount - 1)
<< "with the emulation off, viewport 0's rectangle must hold the LAST invocation's "
"index - if it holds 0 the routing is still happening and this control proves "
"nothing";
for (int y = 0; y < kGridSide; ++y) {
for (int x = 0; x < kGridSide; ++x) {
if (x == 0 && y == 0) continue;
EXPECT_EQ(CellCentre(pixels, kSurfaceSide, x, y), kUnwritten)
<< "cell (" << x << ", " << y << ") is outside viewport 0's rectangle and an "
<< "unrouted draw cannot reach it";
}
}
glUseProgram(0);
glDeleteProgram(unroutedProgram);
DestroyIntTarget(target);
}
// --- 5. an explicitly EMPTY scissor box clips, it does not mean "never written" --------
//
// Deliberately NOT a ViewportArrayScenario case, because that fixture's geometry stage
// routes and this claim needs none of it: one viewport, one scissor rectangle, no
// geometry stage - and it has to hold identically whether or not anything routes.
//
// glScissor(0, 0, 0, 0) is legal GL meaning "the scissor test rejects every fragment",
// but it is byte-identical to the all-zero rectangle a context starts with, whose meaning
// is the OPPOSITE ("the whole window", which the frontend cannot spell before a surface
// exists). DirectGLES resolved the collision from the EXTENT, so it substituted the whole
// surface for a deliberately empty box and inverted the request into "clip nothing" -
// and did so on every draw, at any origin, no matter how many times the application had
// already called glScissor. KHR-GL43.viewport_array.scissor_zero_dimension is the
// conformance shape of exactly this, and it is what the written-flag now separates.
const char* const kFullScreenVertexSource = R"(#version 330 core
void main() {
// One clip-space-covering triangle straight from gl_VertexID: no buffers, no attributes,
// and nothing that could clip the draw except the scissor rectangle under test.
const vec2 corners[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
gl_Position = vec4(corners[gl_VertexID], 0.0, 1.0);
}
)";
const char* const kConstantIntFragmentSource = R"(#version 330 core
layout(location = 0) out int fragColor;
void main() { fragColor = 7; }
)";
constexpr GLint kPainted = 7;
class EmptyScissorScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_program = BuildQuadProgram();
ASSERT_NE(m_program, 0u) << "full-screen program failed to build: " << m_buildLog;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenTextures(1, &m_texture);
glBindTexture(GL_TEXTURE_2D, m_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexImage2D(GL_TEXTURE_2D, 0, GL_R32I, kSurfaceSide, kSurfaceSide, 0, GL_RED_INTEGER, GL_INT,
nullptr);
glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_texture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GL_FRAMEBUFFER_COMPLETE)
<< "R32I is required to be colour-renderable; an incomplete target would make every "
"assertion below vacuous";
glViewport(0, 0, kSurfaceSide, kSurfaceSide);
glDisable(GL_DEPTH_TEST);
ResetScissorState();
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "setup left a GL error behind";
}
void TearDown() override {
if (!Ready() || IsSkipped()) return;
// The context is shared with every other scenario in the process, and a leftover
// 0x0 scissor box with the test enabled would silently blank whatever runs next.
ResetScissorState();
glScissor(0, 0, kSurfaceSide, kSurfaceSide);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_program != 0) glDeleteProgram(m_program);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (m_fbo != 0) glDeleteFramebuffers(1, &m_fbo);
if (m_texture != 0) glDeleteTextures(1, &m_texture);
while (glGetError() != GL_NO_ERROR) {
}
}
static void ResetScissorState() {
for (int i = 0; i < kViewportCount; ++i) {
glDisablei(GL_SCISSOR_TEST, static_cast<GLuint>(i));
}
glDisable(GL_SCISSOR_TEST);
}
// Uploaded, not cleared, for the reason FillIntTarget gives - and here for a second
// one that is decisive: glClear is ITSELF scissored, so a clear issued under the very
// state this case is testing would be clipped away and prove nothing.
void FillTarget() const {
const std::vector<GLint> unwritten(static_cast<size_t>(kSurfaceSide) * kSurfaceSide, kUnwritten);
glBindTexture(GL_TEXTURE_2D, m_texture);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kSurfaceSide, kSurfaceSide, GL_RED_INTEGER, GL_INT,
unwritten.data());
}
static std::vector<GLint> ReadTarget() {
std::vector<GLint> pixels(static_cast<size_t>(kSurfaceSide) * kSurfaceSide, 0);
glReadPixels(0, 0, kSurfaceSide, kSurfaceSide, GL_RED_INTEGER, GL_INT, pixels.data());
return pixels;
}
GLuint BuildQuadProgram() {
const GLuint vs = CompileOne(GL_VERTEX_SHADER, kFullScreenVertexSource);
if (vs == 0) return 0;
const GLuint fs = CompileOne(GL_FRAGMENT_SHADER, kConstantIntFragmentSource);
if (fs == 0) {
glDeleteShader(vs);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
glDeleteShader(vs);
glDeleteShader(fs);
if (linked) return program;
GLint length = 0;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
std::vector<char> log(static_cast<size_t>(length > 1 ? length : 1), '\0');
glGetProgramInfoLog(program, static_cast<GLsizei>(log.size()), nullptr, log.data());
m_buildLog = log.data();
glDeleteProgram(program);
return 0;
}
GLuint CompileOne(GLenum stage, const char* source) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled) return shader;
GLint length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
std::vector<char> log(static_cast<size_t>(length > 1 ? length : 1), '\0');
glGetShaderInfoLog(shader, static_cast<GLsizei>(log.size()), nullptr, log.data());
m_buildLog = log.data();
glDeleteShader(shader);
return 0;
}
std::string m_buildLog;
GLuint m_program = 0;
GLuint m_vao = 0;
GLuint m_fbo = 0;
GLuint m_texture = 0;
};
TEST_F(EmptyScissorScenario, AnExplicitlyEmptyScissorBoxClipsEveryFragment) {
// Positive control FIRST. Without it a regression that simply lost the draw entirely
// would sail through the half below, which only asserts that nothing was painted.
FillTarget();
glEnable(GL_SCISSOR_TEST);
glScissor(0, 0, kSurfaceSide, kSurfaceSide);
glUseProgram(m_program);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
ASSERT_EQ(glGetError(), GL_NO_ERROR);
{
const std::vector<GLint> pixels = ReadTarget();
ASSERT_EQ(pixels.front(), kPainted) << "control: a full-surface scissor box must not clip";
ASSERT_EQ(pixels.back(), kPainted) << "control: a full-surface scissor box must not clip";
}
// The case itself, and note it runs AFTER an explicit glScissor - the old
// extent-based sentinel misfired here too, which is what made this a live rendering
// bug and not just a first-frame startup quirk.
FillTarget();
glScissor(0, 0, 0, 0);
glDrawArrays(GL_TRIANGLES, 0, 3);
ASSERT_EQ(glGetError(), GL_NO_ERROR);
{
const std::vector<GLint> pixels = ReadTarget();
for (size_t i = 0; i < pixels.size(); ++i) {
ASSERT_EQ(pixels[i], kUnwritten)
<< "texel " << i << " was painted through a 0x0 scissor box: the empty rectangle was "
"substituted with the whole surface, inverting 'clip everything' into 'clip nothing'";
}
}
}
TEST_F(EmptyScissorScenario, IndexedZeroDimensionScissorBoxesClipEveryFragment) {
// The conformance shape: setup4x4Scissor(..., set_zeros=true) writes all 16 boxes
// through glScissorArrayv with zero extents at a 4x4 grid of origins and enables the
// test on every index. Index 0's box is (0, 0, 0, 0) - byte-identical to the
// never-written default - which is precisely the collision the written flag breaks.
// Backends that collapse every index to 0 (DirectGLES today) still pass: index 0's
// box is empty, so the draw is clipped away, which is what the case requires.
FillTarget();
std::vector<GLint> boxes(static_cast<size_t>(kViewportCount) * 4, 0);
for (int i = 0; i < kViewportCount; ++i) {
boxes[static_cast<size_t>(i) * 4 + 0] = (i % kGridSide) * kCellSize;
boxes[static_cast<size_t>(i) * 4 + 1] = (i / kGridSide) * kCellSize;
// width and height stay 0 - that IS the case.
}
glScissorArrayv(0, kViewportCount, boxes.data());
for (int i = 0; i < kViewportCount; ++i) {
glEnablei(GL_SCISSOR_TEST, static_cast<GLuint>(i));
}
glUseProgram(m_program);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
ASSERT_EQ(glGetError(), GL_NO_ERROR);
const std::vector<GLint> pixels = ReadTarget();
for (size_t i = 0; i < pixels.size(); ++i) {
ASSERT_EQ(pixels[i], kUnwritten) << "texel " << i << " was painted through a zero-extent indexed "
"scissor box";
}
}
} // namespace
} // namespace MGITest
@@ -163,7 +163,7 @@ namespace MobileGL::MG_State::GLState {
if (!m_resource.IsGpuResident() &&
!(m_mappingAccess & BufferMappingAccessBit::FlushExplicit)) { // if we didn't flush explicitly
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
Memcpy(m_resource.Bytes() + m_mappedRange.start, m_stagingData.data(),
Memcpy(m_resource.Bytes() + m_mappedRange.start, m_stagingData.data() + m_stagingBias,
m_mappedRange.end - m_mappedRange.start);
}
NotifyFlushMappedRange(m_mappedRange, m_mappingAccess);
@@ -175,6 +175,7 @@ namespace MobileGL::MG_State::GLState {
m_isMapped = false;
m_mappingAccess = BufferMappingAccessBit::Null;
m_mappedRange = {0, 0};
m_stagingBias = 0;
m_ownsStagingData = false;
}
@@ -193,7 +194,7 @@ namespace MobileGL::MG_State::GLState {
// FLUSH_EXPLICIT maps are never GPU-resident (only coherent maps are adopted), so
// the staged bytes must be copied into the shadow before the backend reads them.
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
Memcpy(m_resource.Bytes() + start, m_stagingData.data() + offset, length);
Memcpy(m_resource.Bytes() + start, m_stagingData.data() + m_stagingBias + offset, length);
}
NotifyFlushMappedRange({start, end}, m_mappingAccess);
}
@@ -311,6 +312,9 @@ namespace MobileGL::MG_State::GLState {
m_mappedRange = {0, m_size};
if (m_mappingAccess & BufferMappingAccessBit::Write) {
// glMapBuffer maps from offset 0, so no bias: the allocation's own
// GL_MIN_MAP_BUFFER_ALIGNMENT-aligned base is what the application must get.
m_stagingBias = 0;
m_stagingData.resize(m_size);
m_ownsStagingData = true;
@@ -372,14 +376,21 @@ namespace MobileGL::MG_State::GLState {
}
if (access & BufferMappingAccessBit::Write) {
m_stagingData.resize(range.end - range.start);
// ARB_map_buffer_alignment constrains (returned pointer - offset), not the pointer:
// a map at offset 63 must hand back a pointer 63 bytes past the alignment grid, which
// is exactly what the read path below gets for free from shadowBase + offset. The
// staging store has to be biased by the same phase to match, so it over-allocates by
// it and the mapped bytes start at data() + m_stagingBias.
m_stagingBias = range.start % MIN_MAP_BUFFER_ALIGNMENT;
const SizeT mappedLength = range.end - range.start;
m_stagingData.resize(m_stagingBias + mappedLength);
m_ownsStagingData = true;
if (!(access & (BufferMappingAccessBit::InvalidateRange | BufferMappingAccessBit::InvalidateBuffer))) {
Memcpy(m_stagingData.data(), m_resource.Bytes() + range.start, m_stagingData.size());
Memcpy(m_stagingData.data() + m_stagingBias, m_resource.Bytes() + range.start, mappedLength);
}
return m_stagingData.data();
return m_stagingData.data() + m_stagingBias;
} else {
m_ownsStagingData = false;
return m_resource.Bytes() + range.start;
@@ -438,7 +449,7 @@ namespace MobileGL::MG_State::GLState {
return const_cast<Uint8*>(m_resource.Bytes()) + m_mappedRange.start;
}
if (m_ownsStagingData) {
return const_cast<Uint8*>(m_stagingData.data());
return const_cast<Uint8*>(m_stagingData.data()) + m_stagingBias;
}
return const_cast<Uint8*>(m_resource.Bytes()) + m_mappedRange.start;
}
@@ -239,7 +239,14 @@ namespace MobileGL {
// Set by MarkGpuWritten, cleared by SyncGpuWrites once the shadow is refreshed.
Bool m_gpuWritePending = false;
Range1D m_mappedRange;
Vector<Uint8> m_stagingData;
// The write-map staging store. MapAlignedData because the application is handed a
// pointer into it, and biased by m_stagingBias because ARB_map_buffer_alignment
// requires (returned pointer - offset) to be aligned, not the pointer itself: a range
// map at offset 63 must hand back a pointer sitting 63 bytes past the alignment grid.
// The bias is the offset's phase, so the mapped bytes still start at
// m_stagingData.data() + m_stagingBias and the allocation is that much longer.
MapAlignedData m_stagingData;
SizeT m_stagingBias = 0;
Bool m_ownsStagingData;
};
} // namespace MG_State::GLState
@@ -10,8 +10,56 @@
#include <Includes.h>
#include <MG_Util/Types.h>
#include <bit>
#include <new>
#include <vector>
namespace MobileGL::MG_State::GLState {
// GL_MIN_MAP_BUFFER_ALIGNMENT. GL 4.2 / ARB_map_buffer_alignment fix the minimum at 64 and
// MobileGL advertises exactly that (MG_Impl/GLImpl/Getter/GL_Getter.cpp reads this constant),
// so under-reporting is not available - the implementation has to be brought up to the number
// instead. The promise is about POINTERS, not just the query: glMapBuffer must return a
// 64-byte-aligned pointer, and glMapBufferRange must return one whose base - the returned
// pointer minus the offset the caller asked for - is. Every pointer the frontend hands out
// comes from the shadow below or from BufferObject's staging buffer, and std::vector only
// promises alignof(std::max_align_t) (16 on aarch64), so both allocations carry the alignment
// themselves. One constant for the getter and the allocator, because the two may never
// disagree - the same reason the atomic-counter limits are shared through
// MG_Util/ShaderTranspiler/Types.h.
inline constexpr SizeT MIN_MAP_BUFFER_ALIGNMENT = 64;
// Allocator that gives every allocation MIN_MAP_BUFFER_ALIGNMENT. Deliberately minimal: the
// vectors it backs hold raw bytes and are only ever sized, so allocate/deallocate plus the
// rebinding and equality boilerplate std::vector requires is the whole interface.
template <typename T>
struct MapAlignedAllocator {
using value_type = T;
MapAlignedAllocator() noexcept = default;
template <typename U>
MapAlignedAllocator(const MapAlignedAllocator<U>&) noexcept {}
T* allocate(SizeT count) {
if (count == 0) return nullptr;
return static_cast<T*>(
::operator new(count * sizeof(T), std::align_val_t{MIN_MAP_BUFFER_ALIGNMENT}));
}
void deallocate(T* pointer, SizeT) noexcept {
::operator delete(pointer, std::align_val_t{MIN_MAP_BUFFER_ALIGNMENT});
}
template <typename U>
Bool operator==(const MapAlignedAllocator<U>&) const noexcept {
return true;
}
template <typename U>
Bool operator!=(const MapAlignedAllocator<U>&) const noexcept {
return false;
}
};
// Byte store for anything the application may end up holding a mapped pointer into.
using MapAlignedData = std::vector<Uint8, MapAlignedAllocator<Uint8>>;
// Opaque, refcounted handle to the backend's GPU storage for one buffer
// (the driver-side resource). The active backend derives from it and attaches
// its own payload (VkBufferResource / GLESBufferResource). Held by PipeResource.
@@ -57,8 +105,8 @@ namespace MobileGL::MG_State::GLState {
}
// Direct shadow access, used only by the backend's upload-from-shadow path,
// which never runs for a GPU-resident (persistent) buffer.
Data& Shadow() { return *m_shadow; }
const Data& Shadow() const { return *m_shadow; }
MapAlignedData& Shadow() { return *m_shadow; }
const MapAlignedData& Shadow() const { return *m_shadow; }
// Transition to persistent GPU residency: adopt the backend's coherent
// mapped base as the source of truth and drop the CPU shadow. The caller
@@ -85,7 +133,10 @@ namespace MobileGL::MG_State::GLState {
SharedPtr<BackendBufferResource> ReleaseBackend() { return std::move(m_backend); }
private:
SharedPtr<Data> m_shadow = MakeShared<Data>();
// MapAlignedData, not Data: a read-only glMapBuffer hands the application this very
// pointer, and a range map hands it base + offset, so the base has to be on the
// GL_MIN_MAP_BUFFER_ALIGNMENT grid for either to satisfy ARB_map_buffer_alignment.
SharedPtr<MapAlignedData> m_shadow = MakeShared<MapAlignedData>();
void* m_gpuMapped = nullptr;
SharedPtr<BackendBufferResource> m_backend;
};
+32
View File
@@ -368,6 +368,31 @@ namespace MobileGL {
return m_transformFeedbackGeometryCaptureDraws;
}
// Conditional rendering (GL 4.6 core 10.9). `discard` is the verdict already
// resolved from the query object at glBeginConditionalRender - the predicate is
// read ONCE there, not per command, because GL specifies the block against the
// result available at Begin and re-reading it would let a query that is still
// being written change the answer mid-block.
void BeginConditionalRender(GLuint queryId, GLenum mode, Bool discard) {
m_conditionalRenderActive = true;
m_conditionalRenderQuery = queryId;
m_conditionalRenderMode = mode;
m_conditionalRenderDiscards = discard;
}
void EndConditionalRender() {
m_conditionalRenderActive = false;
m_conditionalRenderQuery = 0;
m_conditionalRenderMode = GL_NONE;
m_conditionalRenderDiscards = false;
}
Bool IsConditionalRenderActive() const { return m_conditionalRenderActive; }
GLuint GetConditionalRenderQuery() const { return m_conditionalRenderQuery; }
// Whether the commands GL 4.6 core 10.9 makes conditional are being discarded
// right now. False whenever no block is open, so a caller needs no second test.
Bool ConditionalRenderDiscardsCommands() const {
return m_conditionalRenderActive && m_conditionalRenderDiscards;
}
// Transform feedback objects (ARB_transform_feedback2 / GL 4.0 core).
// The capture state above and the indexed GL_TRANSFORM_FEEDBACK_BUFFER
// binding points are object state, but the context keeps exactly one live
@@ -466,6 +491,13 @@ namespace MobileGL {
Uint64 m_transformFeedbackAccountedCaptureDraws = 0;
Uint64 m_transformFeedbackGeometryCaptureDraws = 0;
// Conditional rendering. Context state, not object state: GL 4.6 core 10.9 allows
// exactly one block open at a time and no object owns it.
Bool m_conditionalRenderActive = false;
Bool m_conditionalRenderDiscards = false;
GLuint m_conditionalRenderQuery = 0;
GLenum m_conditionalRenderMode = GL_NONE;
// Everything a transform feedback object owns while it is NOT the bound one.
struct TransformFeedbackObjectState {
struct SavedBufferBinding {
@@ -8,6 +8,8 @@
#include "ProgramLinkTask.h"
#include <MG_State/GLState/ProgramState/ProgramTranslationCache.h>
#include <MG_State/GLState/VertexArrayState/VertexArrayObject.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
@@ -36,6 +38,70 @@ namespace {
return std::min(backendLimit, capacity);
}
// Everything the post-link query surface ever asks a glslang::TType, flattened into a
// POD. The list is closed and was audited call site by call site: nothing after the link
// walks a struct, a type name or the AST, so there is no recursion to mirror.
//
// Why it has to be flattened at all: TObjectReflection::type points into the TProgram's
// OWN TPoolAllocator (reflection.cpp clones each TType into it), so every one of these
// pointers dangles the moment the TProgram is released - and releasing it is exactly what
// lets a link be served from the L1 translation memo without a parse.
static MobileGL::MG_State::GLState::ProgramObject::TypeFacts MakeTypeFacts(const glslang::TType* type) {
MobileGL::MG_State::GLState::ProgramObject::TypeFacts facts;
if (type == nullptr) return facts;
facts.isArray = type->isArray();
facts.isSizedArray = type->isSizedArray();
facts.isMatrix = type->isMatrix();
facts.isVector = type->isVector();
facts.isOpaque = type->isOpaque();
facts.isTexture = type->isTexture();
facts.isImage = type->isImage();
facts.isDouble = type->getBasicType() == glslang::EbtDouble;
facts.isVoid = type->getBasicType() == glslang::EbtVoid;
facts.basicType = static_cast<MobileGL::Int>(type->getBasicType());
// Stored RAW, exactly as glslang reports them (0 for a non-matrix, 1 for a scalar),
// because the callers already gate on isMatrix()/isVector() themselves.
facts.vectorSize = type->getVectorSize();
facts.matrixCols = type->getMatrixCols();
facts.matrixRows = type->getMatrixRows();
const glslang::TQualifier& qualifier = type->getQualifier();
facts.isBuffer = qualifier.storage == glslang::EvqBuffer;
facts.isPatch = qualifier.patch;
facts.hasIndex = qualifier.hasIndex();
facts.layoutIndex = static_cast<MobileGL::Int>(qualifier.layoutIndex);
facts.hasFormat = qualifier.hasFormat();
facts.layoutFormat = static_cast<MobileGL::Uint>(qualifier.getFormat());
facts.layoutMatrix = static_cast<MobileGL::Int>(qualifier.layoutMatrix);
return facts;
}
// One glslang::TObjectReflection, flattened. Shared by uniforms, blocks, pipe inputs and
// pipe outputs, because glslang reflects all four as TObjectReflection.
static MobileGL::MG_State::GLState::ProgramObject::ResourceReflection MakeResourceReflection(
const glslang::TObjectReflection& object) {
MobileGL::MG_State::GLState::ProgramObject::ResourceReflection record;
record.name = object.name;
record.glDefineType = object.glDefineType;
record.offset = object.offset;
record.size = object.size;
record.index = object.index;
record.counterIndex = object.counterIndex;
record.arrayStride = object.arrayStride;
record.topLevelArraySize = object.topLevelArraySize;
record.topLevelArrayStride = object.topLevelArrayStride;
record.binding = object.getBinding();
record.location = object.layoutLocation();
record.stages = static_cast<MobileGL::Uint32>(object.stages);
record.type = MakeTypeFacts(object.getType());
// GL_UNIFORM_SIZE / GL_ARRAY_SIZE, resolved here so no caller needs the TType:
// TObjectReflection::size carries the element count only for a NON-block array, so
// the sized-array outer count wins whenever it exists.
const glslang::TType* type = object.getType();
record.arraySize = (type != nullptr && type->isSizedArray()) ? type->getOuterArraySize()
: (object.size < 1 ? 1 : object.size);
return record;
}
static MobileGL::String StripArrayElementSuffix(const MobileGL::String& name) {
const MobileGL::SizeT bracket = name.find('[');
return bracket == MobileGL::String::npos ? name : name.substr(0, bracket);
@@ -63,6 +129,208 @@ namespace {
return element;
}
// Blocks come out of reflection in three kinds and only one of them is a GL uniform block.
// The same split ProgramInterface::ClassifyBlock makes (it reads the flattened
// TypeFacts::isBuffer, which is this very qualifier), reachable here from the live TProgram
// because the block index spaces are built before the reflection snapshot exists.
// The transpiler lowers every atomic_uint onto a synthesized "gl_AtomicCounterBlock_<binding>"
// buffer block, which reflection then reports as an ordinary block. It is not one: GL
// enumerates it through GL_ACTIVE_ATOMIC_COUNTER_BUFFERS instead.
static MobileGL::Bool IsAtomicCounterBlockName(const MobileGL::String& name) {
namespace Transpiler = MobileGL::MG_Util::ShaderTranspiler;
const MobileGL::SizeT prefixLength = std::strlen(Transpiler::ATOMIC_COUNTER_BLOCK_PREFIX);
return name.compare(0, prefixLength, Transpiler::ATOMIC_COUNTER_BLOCK_PREFIX) == 0;
}
// A shader storage block: GL enumerates it through GL_SHADER_STORAGE_BLOCK and its members
// through GL_BUFFER_VARIABLE. The counter blocks above are buffer blocks too, hence the
// exclusion. A block whose type reflection did not survive is treated as a uniform block,
// which is what every caller assumed before this classification existed.
static MobileGL::Bool IsStorageBlock(const glslang::TObjectReflection& block) {
if (IsAtomicCounterBlockName(block.name)) return false;
const glslang::TType* type = block.getType();
return type != nullptr && type->getQualifier().storage == glslang::EvqBuffer;
}
static MobileGL::Bool IsGlUniformBlock(const glslang::TObjectReflection& block) {
return !IsAtomicCounterBlockName(block.name) && !IsStorageBlock(block);
}
// GL 4.6 core 7.7 / ARB_shader_atomic_counters: within one binding no two atomic counters
// may occupy the same bytes, every offset is a multiple of 4, and no counter may reach past
// GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE. glslang enforces all three in fixOffset(), which the
// Vulkan-relaxed parse never reaches - vkRelaxedRemapUniformVariable folds the atomic_uint
// into a synthesized storage block and returns from declareVariable() before fixOffset()
// runs, clearing explicitOffset on the way ("xxTODO: use logic from fixOffset()"). Two
// counters declared at the same binding AND the same offset therefore linked cleanly.
//
// The offsets themselves survive that lowering (reflection and the SPIR-V generator both
// honour layoutOffset), so the check belongs here, over the same model the GL queries answer
// from. Returns the info-log line for an illegal layout, empty for a legal one.
static MobileGL::String ValidateAtomicCounterLayout(glslang::TProgram& reflection) {
using MobileGL::Bool;
using MobileGL::Int;
using MobileGL::SizeT;
using MobileGL::String;
using MobileGL::Vector;
namespace Transpiler = MobileGL::MG_Util::ShaderTranspiler;
const Int blockCount = reflection.getNumUniformBlocks();
if (blockCount <= 0) return {};
const SizeT prefixLength = std::strlen(Transpiler::ATOMIC_COUNTER_BLOCK_PREFIX);
Vector<Bool> isCounterBlock(static_cast<SizeT>(blockCount), false);
Bool anyCounterBlock = false;
for (Int i = 0; i < blockCount; ++i) {
const auto& block = reflection.getUniformBlock(i);
isCounterBlock[static_cast<SizeT>(i)] =
block.name.compare(0, prefixLength, Transpiler::ATOMIC_COUNTER_BLOCK_PREFIX) == 0;
anyCounterBlock = anyCounterBlock || isCounterBlock[static_cast<SizeT>(i)];
}
if (!anyCounterBlock) return {}; // every program that declares no atomic counter
struct CounterSpan {
Int offset = 0;
Int size = 0;
String name;
};
Vector<Vector<CounterSpan>> spansByBlock(static_cast<SizeT>(blockCount));
const Int uniformCount = reflection.getNumUniformVariables();
for (Int i = 0; i < uniformCount; ++i) {
const auto& uniform = reflection.getUniform(i);
const Int owner = uniform.index;
if (owner < 0 || owner >= blockCount || !isCounterBlock[static_cast<SizeT>(owner)]) continue;
const Int offset = uniform.offset;
if (offset < 0) continue; // no offset recorded; nothing to compare
Int elements = uniform.size > 1 ? uniform.size : 1;
if (const glslang::TType* type = uniform.getType(); type != nullptr && type->isArray()) {
elements = type->isSizedArray() ? type->getCumulativeArraySize() : 1;
}
const Int size = elements * static_cast<Int>(sizeof(MobileGL::Uint32));
if (offset % 4 != 0) {
return std::format("Atomic counter '{}' is declared at offset {}, which is not a multiple of 4.",
uniform.name, offset);
}
if (offset > Transpiler::MAX_ATOMIC_COUNTER_BUFFER_SIZE - size) {
return std::format("Atomic counter '{}' ends at byte {}, past the {}-byte "
"GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE.",
uniform.name, offset + size, Transpiler::MAX_ATOMIC_COUNTER_BUFFER_SIZE);
}
auto& spans = spansByBlock[static_cast<SizeT>(owner)];
for (const CounterSpan& existing : spans) {
if (offset < existing.offset + existing.size && existing.offset < offset + size) {
return std::format("Atomic counters '{}' and '{}' share a binding and overlap at byte offset {}.",
existing.name, uniform.name, std::max(offset, existing.offset));
}
}
spans.push_back({offset, size, uniform.name});
}
return {};
}
// GL 4.6 core 7.6: LinkProgram FAILS when a stage's count of active image uniforms exceeds
// GL_MAX_{VERTEX,TESS_CONTROL,TESS_EVALUATION,GEOMETRY,FRAGMENT,COMPUTE}_IMAGE_UNIFORMS, or
// when their sum exceeds GL_MAX_COMBINED_IMAGE_UNIFORMS. Nothing enforced it: glslang carries
// those numbers in TBuiltInResource only so gl_Max*ImageUniforms can expand from them, and
// its linker never counts uniforms against them - so a program declaring one image uniform
// more than the limit linked cleanly and then rendered nothing.
//
// The limits are the ones glGetIntegerv answers (MG_Impl/GLImpl/Getter/GL_Getter.cpp), the
// hardcoded tessellation zeros included: a program may not exceed a limit the implementation
// advertises, whatever the driver underneath would have taken.
//
// Counts the APPLICATION's image uniforms. The DirectGLES read/write split emits a second
// declaration for an image a stage both reads and writes (MG_Backend/DirectGLES/Utils.h), but
// that happens in the backend after this link, and counting the expanded set here would
// reject programs that are legal by the numbers GL advertises. Returns the info-log line for
// a program over a limit, empty for one within them.
static MobileGL::String ValidateImageUniformLimits(
glslang::TProgram& reflection, const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) {
using MobileGL::Array;
using MobileGL::Int;
using MobileGL::SizeT;
using MobileGL::UnorderedMap;
static constexpr EShLanguage kStages[] = {EShLangVertex, EShLangTessControl, EShLangTessEvaluation,
EShLangGeometry, EShLangFragment, EShLangCompute};
static constexpr const char* kLimitNames[] = {
"GL_MAX_VERTEX_IMAGE_UNIFORMS", "GL_MAX_TESS_CONTROL_IMAGE_UNIFORMS",
"GL_MAX_TESS_EVALUATION_IMAGE_UNIFORMS", "GL_MAX_GEOMETRY_IMAGE_UNIFORMS",
"GL_MAX_FRAGMENT_IMAGE_UNIFORMS", "GL_MAX_COMPUTE_IMAGE_UNIFORMS"};
constexpr SizeT kStageCount = sizeof(kStages) / sizeof(kStages[0]);
const Int limits[kStageCount] = {env.params.MaxVertexImageUniforms,
0,
0,
env.params.MaxGeometryImageUniforms,
env.params.MaxFragmentImageUniforms,
env.params.MaxComputeImageUniforms};
// Reflection spells an image ARRAY one of two ways, and which one it picks depends on how
// the shader indexed it: a variable index makes glslang expand the array into one entry
// per element ("u_image[0]".."u_image[8]", each carrying the ELEMENT type), while an
// array never dereferenced at all stays a single entry carrying the array type. One
// program can even produce both spellings for the same array. So neither counting entries
// nor trusting the declared size is right on its own - they are reconciled per declared
// name with a max, which is exact for either spelling and cannot double-count the mixture.
struct ImageUse {
Int entries = 0; // reflection entries seen for this name in this stage
Int declared = 0; // largest element count any of them declared
};
UnorderedMap<MobileGL::String, Array<ImageUse, kStageCount>> useByName;
const Int uniformCount = reflection.getNumUniformVariables();
for (Int i = 0; i < uniformCount; ++i) {
const auto& uniform = reflection.getUniform(i);
const glslang::TType* type = uniform.getType();
if (type == nullptr || !type->isImage()) continue;
// An array occupies one image unit per element; an unsized one (never indexed, so
// never more than the single element glslang kept) counts as one.
Int elements = uniform.size > 1 ? uniform.size : 1;
if (type->isArray()) {
elements = type->isSizedArray() ? type->getCumulativeArraySize() : 1;
}
// `stages` is the set of stages that REFERENCE the uniform, which is exactly what GL
// counts: an image declared in two stages costs a unit in each, and one no stage
// reads is not active at all and costs nothing.
Array<ImageUse, kStageCount>* use = nullptr;
for (SizeT stage = 0; stage < kStageCount; ++stage) {
if ((static_cast<unsigned>(uniform.stages) & (1u << static_cast<unsigned>(kStages[stage]))) == 0) {
continue;
}
// The one insert this uniform performs, so the reference survives the rest of the
// stage loop - a flat hash map relocates on insert, never on read.
if (use == nullptr) {
use = &useByName[StripArrayElementSuffix(uniform.name)];
}
++(*use)[stage].entries;
(*use)[stage].declared = std::max((*use)[stage].declared, elements);
}
}
Int counts[kStageCount] = {};
for (const auto& entry : useByName) {
for (SizeT stage = 0; stage < kStageCount; ++stage) {
counts[stage] += std::max(entry.second[stage].entries, entry.second[stage].declared);
}
}
Int combined = 0;
for (SizeT stage = 0; stage < kStageCount; ++stage) {
combined += counts[stage];
if (counts[stage] > limits[stage]) {
return std::format("This program uses {} active image uniforms in one stage, more than the {} "
"{} allows.",
counts[stage], limits[stage], kLimitNames[stage]);
}
}
if (combined > env.params.MaxCombinedImageUniforms) {
return std::format("This program uses {} active image uniforms across its stages, more than the {} "
"GL_MAX_COMBINED_IMAGE_UNIFORMS allows.",
combined, env.params.MaxCombinedImageUniforms);
}
return {};
}
static bool IsBuiltInPipelineOutput(const glslang::TObjectReflection& output) {
const auto* type = output.getType();
return type && type->getQualifier().builtIn != glslang::EbvNone;
@@ -309,6 +577,21 @@ namespace MobileGL::MG_State::GLState {
MGLOG_D("ProgramObject %u: Link body start, shaders to link: %zu", in.externalIndex, in.shaders.size());
if (!ValidateAttachedShaders()) return;
// Reads the COMPILE snapshots only - no parsed shader - so it runs before the L1
// probe: a conflicting explicit uniform location must fail the link whether or not
// the memo has an answer for this program's sources.
MergeShaderSideChannels();
if (!artifacts.infoLog.empty()) return; // a conflicting explicit uniform location
// ---- L1 of the shader translation memo ----
// Everything below this point - the parse, the link, mapIO, GlslangToSpv, spirv-opt,
// buildReflection and the global-UBO routing - is what a hit skips. See
// ProgramTranslationCache.h.
spirvHandoff.spirvCacheKey = BuildSpirvCacheKey(env);
if (TryPublishFromTranslationCache()) return;
Vector<SharedPtr<glslang::TShader>> shaders;
if (!ConsumeShaders(shaders)) return;
@@ -335,35 +618,16 @@ namespace MobileGL::MG_State::GLState {
}
}
// Merge the shaders' lexically extracted explicit uniform locations. The same
// uniform declared in several stages must agree on its location (config-A glslang
// enforced this at mapIO; the relaxed parse no longer sees the qualifiers).
for (const auto& shader : in.shaders) {
const ShaderCompileArtifacts& compiled = CompiledArtifacts(shader.compiled);
for (const auto& [name, location] : compiled.explicitUniformLocations) {
const auto [it, inserted] = artifacts.linkedExplicitUniformLocations.emplace(name, location);
if (!inserted && it->second != location) {
artifacts.infoLog = std::format(
"Uniform '{}' is declared with conflicting explicit locations ({} and {}) "
"across stages.",
name, it->second, location);
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
return;
}
}
// Sampler/image layout(binding = N) initial units, likewise invisible to the
// relaxed parse. Stage order matches the old per-stage mapIO capture, so a
// name declared in several stages keeps the last stage's binding as before.
for (const auto& [name, binding] : compiled.explicitOpaqueBindings) {
artifacts.explicitOpaqueUniformBindings[name] = binding;
}
}
// The last two are OUT parameters that mapIO fills, not requests it honours: the IO
// mapper's collect callback is the last point at which a resource's qualifier still
// says what the SHADER declared rather than what glslang assigned, so both captures
// have to be taken from inside the link. See TMglGlslIoResolver::reserverResourceSlot.
ProgramAttrib attrib{.shaders = Move(shaders),
.explicitVertexInLocations = in.explicitAttribLocations,
.explicitFragmentOutLocations = in.explicitFragDataLocation,
.explicitFragmentOutIndices = in.explicitFragDataIndex,
.explicitOpaqueUniformBindings = &artifacts.explicitOpaqueUniformBindings};
.explicitOpaqueUniformBindings = &artifacts.explicitOpaqueUniformBindings,
.storageBlocksWithoutBinding = &artifacts.storageBlocksWithoutBinding};
MGLOG_D("ProgramObject %u: Calling ShaderCompiler::LinkProgram", in.externalIndex);
auto result = ShaderCompiler::LinkProgram(attrib);
@@ -381,6 +645,7 @@ namespace MobileGL::MG_State::GLState {
return;
}
// A compute program must have a fixed local group size, and GL states that as a
// property of the PROGRAM: "at least one" of its compute shaders declares it (GL 4.6
// core 7.13 / GLSL 4.30 4.4.1.4). MobileGL used to answer that question per SHADER,
@@ -497,14 +762,138 @@ namespace MobileGL::MG_State::GLState {
spirvHandoff.reflection.uniformIndexInTProgram = artifacts.uniformIndexInTProgram;
spirvHandoff.reflection.tProgramUniformIndexToGl = artifacts.tProgramUniformIndexToGl;
spirvHandoff.reflection.maxUniformLocation = artifacts.maxUniformLocation;
// The owned reflection mirror, and the block index space its global-UBO test needs.
// BuildGlobalUboRouting reads BOTH - per-uniform array size, opaqueness, GL type and
// matrix shape, plus "is this a member of a GL-visible block". Leaving them out of the
// handoff is not a compile error, it is a SILENT one: every array collapses to a
// single element and every element past the first falls through to the fallback tail
// allocator (ProgramTest.NestedStructArrayUniformElementWrites catches exactly that).
spirvHandoff.reflection.uniformReflection = artifacts.uniformReflection;
spirvHandoff.reflection.blockReflection = artifacts.blockReflection;
spirvHandoff.reflection.tProgramBlockIndexToGl = artifacts.tProgramBlockIndexToGl;
// Phase B pairs this with its own SpirvArtifacts to insert the completed front end.
// A COPY, because the GL-thread join moves `artifacts` out of this node before phase B
// runs - and with the TProgram dropped, because a memo must never hold a glslang arena.
if (spirvHandoff.spirvCacheKey.Valid()) {
auto forCache = MakeShared<ProgramObject::LinkArtifacts>(artifacts);
forCache->program.reset();
spirvHandoff.linkArtifactsForCache = Move(forCache);
}
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) {
outShaders.assign(in.shaders.size(), nullptr);
// The L1 key. Every input below is one that can change the SPIR-V this program
// generates; see the key inventory on SpirvTranslationKeyInputs.
//
// Deliberately NOT keyed on: anything that only steers a BACKEND transpile - see the
// classification on CompileEnv::frontendFingerprint, and L2's own key in
// MG_Util/ShaderTranspiler/TranslationCache.h. The single capability bit that IS here
// (nativeFloat64) earns its place by changing SanitizeAndOptimizeBinary's own output,
// which is what the payload stores.
MG_Util::ShaderTranspiler::TranslationCacheKey ProgramLinkTask::BuildSpirvCacheKey(
const MG_Util::ShaderTranspiler::CompileEnv& env) const {
using namespace MG_Util::ShaderTranspiler;
if (!ShaderTranslationCacheEnabled()) return {};
SpirvTranslationKeyInputs keyInputs;
// The FRONT-END fingerprint, not env.fingerprint: L1 must be shared by two contexts
// on different GPUs whenever glslang would produce the same thing for them. See the
// classification on CompileEnv::frontendFingerprint.
keyInputs.frontendFingerprint = env.frontendFingerprint;
// Always 0 on both production parse paths (ShaderCompileTask::RunCompilePipeline and
// ClaimParsedShader's re-parse). In the key regardless, so that a future non-zero
// value cannot alias a module parsed without it.
keyInputs.shaderCompileFlags = 0;
keyInputs.enableSpirvValidation = in.enableSpirvValidation;
// The one BACKEND capability bit in this key, and it has to be here: it reaches inside
// SanitizeAndOptimizeBinary, whose output is what the payload holds. Read from the same
// env snapshot ProgramSpirvTask hands the chain, so the key and the bytes can never
// disagree.
keyInputs.nativeFloat64 = env.ConsumesFloat64Natively();
keyInputs.stages.reserve(in.shaders.size());
for (const LinkShaderInput& shader : in.shaders) {
const ShaderCompileArtifacts& compiled = CompiledArtifacts(shader.compiled);
if (compiled.preprocessedSource.empty()) {
// No text to key on - an internal shader object, or an artifact this build
// did not populate. Refuse to key rather than key on nothing.
return {};
}
keyInputs.stages.push_back(SpirvTranslationKeyInputs::Stage{
.type = MG_Util::ConvertShaderStageToGLEnum(shader.stage),
.preprocessedSource = StringView(compiled.preprocessedSource)});
}
if (keyInputs.stages.empty()) return {};
keyInputs.explicitVertexInLocations = &in.explicitAttribLocations;
keyInputs.explicitFragmentOutLocations = &in.explicitFragDataLocation;
keyInputs.explicitFragmentOutIndices = &in.explicitFragDataIndex;
// In the key ONLY because the payload now carries the reflection: transform feedback
// is resolved by reading the linked intermediates and never perturbs the generated
// SPIR-V, but it does shape xfbVaryings / xfbStrides / xfbBufferMode /
// gsStripTriangles, and maxFragmentOutputColorNumber decides whether the link is
// rejected at all. Widening a payload means widening the key.
keyInputs.requestedXfbVaryings = &in.requestedXfbVaryings;
keyInputs.xfbBufferMode = static_cast<Uint32>(in.requestedXfbBufferMode);
keyInputs.maxFragmentOutputColorNumber = in.maxFragmentOutputColorNumber;
return BuildSpirvTranslationKey(keyInputs);
}
// The one link rejection that needs nothing but the compile snapshots. It runs before the
// L1 memo is consulted, so a hit can never paper over a program that must fail to link.
//
// Only the explicit default-block uniform locations are merged here, and only because they
// are the one piece of relaxed-parse wreckage that has to be recovered at COMPILE time:
// the snapshot is taken inside the parse, so it is per-shader by construction, and the
// same uniform declared in several stages must agree or the program cannot link. The
// opaque bindings and the unqualified storage blocks used to be merged alongside them;
// both now arrive from mapIO during LinkProgram below, straight into `artifacts`, which is
// both later and strictly better informed - the IO mapper sees macro-expanded declarations
// and a per-shader lexer never could.
void ProgramLinkTask::MergeShaderSideChannels() {
for (const auto& shader : in.shaders) {
const ShaderCompileArtifacts& compiled = CompiledArtifacts(shader.compiled);
for (const auto& [name, location] : compiled.explicitUniformLocations) {
const auto [it, inserted] = artifacts.linkedExplicitUniformLocations.emplace(name, location);
if (!inserted && it->second != location) {
artifacts.infoLog = std::format(
"Uniform '{}' is declared with conflicting explicit locations ({} and {}) "
"across stages.",
name, it->second, location);
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
return;
}
}
}
}
// An L1 hit: the entire front end, published without constructing a TShader or a
// TProgram. Everything here is a copy out of plain owned data - `link.program` is null in
// the payload by construction, and nothing reads it any more.
Bool ProgramLinkTask::TryPublishFromTranslationCache() {
if (!spirvHandoff.spirvCacheKey.Valid()) return false;
const ProgramTranslationResultPtr hit =
GetProgramTranslationCache().Find(spirvHandoff.spirvCacheKey);
if (!hit) return false;
artifacts = hit->link;
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);
}
// An ALIASING SharedPtr: it points at the payload's SpirvArtifacts while sharing
// ownership of the whole payload, so phase B publishes them without a second copy and
// without any chance of the entry being evicted from under it.
spirvHandoff.cachedSpirv =
SharedPtr<const ProgramObject::SpirvArtifacts>(hit, &hit->spirv);
spirvHandoff.ready = true;
MGLOG_D("ProgramObject %u: L1 cache hit - the whole front end was reused; no parse, no "
"link, no SPIR-V generation",
in.externalIndex);
return true;
}
Bool ProgramLinkTask::ValidateAttachedShaders() {
// 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).
@@ -526,8 +915,6 @@ namespace MobileGL::MG_State::GLState {
const LinkShaderInput& input = in.shaders[i];
const GLenum shaderType = MG_Util::ConvertShaderStageToGLEnum(input.stage);
const ShaderCompileArtifacts& compiled = CompiledArtifacts(input.compiled);
MGLOG_D("ProgramObject %u: Preparing shader[%zu] stage %s", in.externalIndex, i,
MG_Util::ConvertGLEnumToString(shaderType).c_str());
if (!compiled.compileStatus) {
// The compile log LEADS the quoted source, and that order is load-bearing:
@@ -546,6 +933,17 @@ namespace MobileGL::MG_State::GLState {
in.externalIndex, i, artifacts.infoLog));
return false;
}
}
return true;
}
Bool ProgramLinkTask::ConsumeShaders(Vector<SharedPtr<glslang::TShader>>& outShaders) {
outShaders.assign(in.shaders.size(), nullptr);
for (SizeT i = 0; i < in.shaders.size(); i++) {
const LinkShaderInput& input = in.shaders[i];
const GLenum shaderType = MG_Util::ConvertShaderStageToGLEnum(input.stage);
MGLOG_D("ProgramObject %u: Preparing shader[%zu] stage %s", in.externalIndex, i,
MG_Util::ConvertGLEnumToString(shaderType).c_str());
String reparseLog;
outShaders[i] = input.compiled->ClaimParsedShader(reparseLog);
if (!outShaders[i]) {
@@ -606,6 +1004,22 @@ namespace MobileGL::MG_State::GLState {
return false;
}
if (String atomicCounterError = ValidateAtomicCounterLayout(*artifacts.program);
!atomicCounterError.empty()) {
artifacts.infoLog = Move(atomicCounterError);
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
ProgramObject::ResetLinkArtifacts(artifacts);
return false;
}
if (String imageUniformError = ValidateImageUniformLimits(*artifacts.program, env);
!imageUniformError.empty()) {
artifacts.infoLog = Move(imageUniformError);
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
ProgramObject::ResetLinkArtifacts(artifacts);
return false;
}
// ---------- GL-facing index spaces (relaxed-parse cleanup) ----------
// Blocks first: global-UBO membership drives the uniform filter below. The
// synthesized MGL_GLOBAL_UBO is a transpiler artifact - its members are GL
@@ -623,6 +1037,30 @@ namespace MobileGL::MG_State::GLState {
artifacts.glBlockIndexToTProgram.push_back(i);
}
// The GL_UNIFORM_BLOCK subsequence of that space. MobileGL does not pass
// EShReflectionSeparateBuffers to buildReflection above, so glslang files BUFFER blocks
// under indexToUniformBlock as well and the list just built also holds every shader
// storage block and every synthesized gl_AtomicCounterBlock_N. GL 4.6 core 7.6 says
// GL_ACTIVE_UNIFORM_BLOCKS / glGetActiveUniformBlockiv / glGetUniformBlockIndex see
// uniform blocks and nothing else; an atomic counter buffer is enumerated by
// GL_ACTIVE_ATOMIC_COUNTER_BUFFERS and a storage block by GL_SHADER_STORAGE_BLOCK.
//
// A SECOND space rather than a filter of the first, deliberately: the block space is
// what the backends walk (DirectGLES hands out one ESSL uniform-buffer binding point per
// entry as it goes) and what "tProgramBlockIndexToGl[i] < 0 means MGL_GLOBAL_UBO" reads,
// and neither may move.
artifacts.blockIndexToGlUniformBlock.assign(artifacts.glBlockIndexToTProgram.size(), -1);
artifacts.glUniformBlockIndexToBlock.clear();
for (SizeT blockIndex = 0; blockIndex < artifacts.glBlockIndexToTProgram.size(); ++blockIndex) {
const auto& block = artifacts.program->getUniformBlock(artifacts.glBlockIndexToTProgram[blockIndex]);
if (!IsGlUniformBlock(block)) continue;
artifacts.blockIndexToGlUniformBlock[blockIndex] =
static_cast<Int>(artifacts.glUniformBlockIndexToBlock.size());
artifacts.glUniformBlockIndexToBlock.push_back(static_cast<Int>(blockIndex));
}
MGLOG_D("ProgramObject %u: Reflection - %zu block(s), %zu of them GL uniform blocks", in.externalIndex,
artifacts.glBlockIndexToTProgram.size(), artifacts.glUniformBlockIndexToBlock.size());
// ------------ Uniforms (GL Plain) ----------------
// The relaxed parse sweeps every DECLARED default-block uniform into
// MGL_GLOBAL_UBO whether or not any stage reads it. GL requires a
@@ -637,6 +1075,26 @@ namespace MobileGL::MG_State::GLState {
return uniform.index >= 0 && uniform.index < static_cast<Int>(artifacts.tProgramBlockIndexToGl.size()) &&
artifacts.tProgramBlockIndexToGl[uniform.index] < 0;
};
// Member of a block GL can see - a named uniform block, a buffer block, or the
// synthesized atomic-counter block. GL locations are a property of the DEFAULT uniform
// block alone (GL 4.6 core 7.6.1), so these take none.
const auto isNamedBlockMember = [&isGlobalUboMember](const glslang::TObjectReflection& uniform) {
return uniform.index >= 0 && !isGlobalUboMember(uniform);
};
// A member of a BUFFER block is a buffer variable, not a uniform: GL 4.6 core 7.3.1
// gives it the GL_BUFFER_VARIABLE interface and 7.6 keeps it out of GL_ACTIVE_UNIFORMS,
// glGetActiveUniform, glGetUniformIndices and glGetActiveUniformsiv. The relaxed parse
// reflects it as a uniform anyway (no EShReflectionSeparateBuffers), so drop it from the
// GL index space here - the same place the dead default-block uniforms are dropped, and
// the counterpart of the location half already handled by isNamedBlockMember below.
//
// Atomic counters are NOT in this set even though their synthesized owner is a buffer
// block: an atomic_uint IS a uniform (of type GL_UNSIGNED_INT_ATOMIC_COUNTER), and
// KHR-GL43.shader_atomic_counters.basic-program-query enumerates it as one.
const auto isBufferVariable = [this](const glslang::TObjectReflection& uniform) {
if (uniform.index < 0 || uniform.index >= artifacts.program->getNumUniformBlocks()) return false;
return IsStorageBlock(artifacts.program->getUniformBlock(uniform.index));
};
for (Int i = 0; i < tProgramUniformCount; i++) {
const auto& uniform = artifacts.program->getUniform(i);
if (isGlobalUboMember(uniform) && uniform.stages == 0) {
@@ -645,6 +1103,12 @@ namespace MobileGL::MG_State::GLState {
in.externalIndex, uniform.name.c_str());
continue;
}
if (isBufferVariable(uniform)) {
MGLOG_D("ProgramObject %u: Reflection - buffer variable '%s' filtered from the GL uniform "
"surface",
in.externalIndex, uniform.name.c_str());
continue;
}
artifacts.tProgramUniformIndexToGl[i] = static_cast<Int>(artifacts.glUniformIndexToTProgram.size());
artifacts.glUniformIndexToTProgram.push_back(i);
}
@@ -653,12 +1117,21 @@ namespace MobileGL::MG_State::GLState {
artifacts.activeUniformCount, tProgramUniformCount);
// Effective explicit location per TProgram uniform, from two sources:
// - the lexical side-channel for default-block uniforms - the relaxed parse
// - the parse-time snapshot for default-block uniforms - the relaxed parse
// dropped their layout(location = N) qualifiers when collecting them into
// MGL_GLOBAL_UBO, so reflection cannot provide them ("source-explicit");
// - glslang's layoutLocation() for opaque uniforms, where the qualifier
// survives the relaxed parse (and mapIO auto-assigns the rest).
constexpr Uint kNoLocation = glslang::TQualifier::layoutLocationEnd;
//
// "no effective location yet". Deliberately OUTSIDE the location space rather than
// glslang::TQualifier::layoutLocationEnd, which is the first location past the pool and
// therefore only one off a legal one - a sentinel that sits at the boundary it guards has
// to be re-proved safe every time the ceiling moves, and glslang uses that same value for
// "this opaque uniform has no location" as well.
constexpr Uint kNoLocation = ~static_cast<Uint>(0);
// The ceiling glGetIntegerv(GL_MAX_UNIFORM_LOCATIONS) advertises, which is what the
// allocator below has to honour: locations 0..kMaxUniformLocations-1 and no others.
constexpr Uint kMaxUniformLocations = static_cast<Uint>(ProgramObject::MAX_UNIFORM_LOCATIONS);
Vector<Uint> effectiveLocation(tProgramUniformCount, kNoLocation);
Vector<Bool> locationIsSourceExplicit(tProgramUniformCount, false);
UnorderedMap<String, Uint> structExplicitCursor; // declared root -> next member location
@@ -674,8 +1147,7 @@ namespace MobileGL::MG_State::GLState {
for (const Int i : artifacts.glUniformIndexToTProgram) {
const auto& uniform = artifacts.program->getUniform(i);
const glslang::TType* type = uniform.getType();
const Bool inNamedBlock = uniform.index >= 0 && !isGlobalUboMember(uniform);
if (inNamedBlock) continue; // block members never take glUniform locations
if (isNamedBlockMember(uniform)) continue; // block members never take glUniform locations
if (const Int* explicitLocation = findExplicitLocation(uniform.name)) {
effectiveLocation[i] = static_cast<Uint>(*explicitLocation);
@@ -695,13 +1167,19 @@ namespace MobileGL::MG_State::GLState {
cursor->second += static_cast<Uint>(GetUniformLocationSpan(uniform));
}
}
if (effectiveLocation[i] == kNoLocation && type != nullptr && type->isOpaque()) {
// glslang parks "no location" at layoutLocationEnd, which is a real location in this
// table's numbering - test for it explicitly rather than letting it through as one.
if (effectiveLocation[i] == kNoLocation && type != nullptr && type->isOpaque() &&
uniform.layoutLocation() != glslang::TQualifier::layoutLocationEnd) {
effectiveLocation[i] = uniform.layoutLocation();
}
if (locationIsSourceExplicit[i] &&
effectiveLocation[i] + static_cast<Uint>(GetUniformLocationSpan(uniform)) > kNoLocation) {
effectiveLocation[i] + static_cast<Uint>(GetUniformLocationSpan(uniform)) > kMaxUniformLocations) {
// Config A rejected out-of-range explicit locations at parse; keep them
// from growing the location table unboundedly.
// from growing the location table unboundedly. Stated against the advertised
// GL_MAX_UNIFORM_LOCATIONS, because that is the rule being enforced (GL 4.6 core
// 7.6.1): an array whose LAST element passes the ceiling is a link error even
// though its base compiled fine.
artifacts.infoLog = std::format("Uniform '{}' explicit location {} is out of range.", uniform.name,
effectiveLocation[i]);
ProgramObject::ResetLinkArtifacts(artifacts);
@@ -709,12 +1187,58 @@ namespace MobileGL::MG_State::GLState {
}
}
Int requiredUniformLocations = 0;
// ARB_explicit_uniform_location / GL 4.6 core 7.6.1: an explicit location is RESERVED
// whether or not the uniform turned out to be active. The dead default-block uniforms
// filtered out of glUniformIndexToTProgram above are invisible to every GL query - which
// is correct - but their locations must still be kept out of the implicit allocator's
// reach, or an implicit uniform is handed a location the source already claimed.
//
// Deliberately NOT written into artifacts.uniformLocations or uniformIndexInTProgram:
// glGetUniformLocation must keep answering -1 for a dead uniform, and a location no
// application can legally obtain must not become writable through glUniform*. The
// occupancy therefore lives in its own bitset, built once the table has been sized.
Vector<Pair<Uint, Int>> deadExplicitReservations;
Int deadReservedLocationCount = 0;
for (Int i = 0; i < tProgramUniformCount; i++) {
if (artifacts.tProgramUniformIndexToGl[i] >= 0) continue; // GL-visible: handled above
const auto& uniform = artifacts.program->getUniform(i);
if (!isGlobalUboMember(uniform) || uniform.stages != 0) continue;
const Int* explicitLocation = findExplicitLocation(uniform.name);
if (explicitLocation == nullptr) continue;
const Uint location = static_cast<Uint>(*explicitLocation);
const Int locationSpan = GetUniformLocationSpan(uniform);
if (location + static_cast<Uint>(locationSpan) > kMaxUniformLocations) {
artifacts.infoLog = std::format("Uniform '{}' explicit location {} is out of range.", uniform.name,
location);
ProgramObject::ResetLinkArtifacts(artifacts);
return false;
}
deadExplicitReservations.emplace_back(location, locationSpan);
deadReservedLocationCount += locationSpan;
artifacts.maxUniformLocation = std::max(artifacts.maxUniformLocation, location + locationSpan - 1);
MGLOG_D("ProgramObject %u: Reflection - inactive uniform '%s' reserves locations %u..%u without "
"becoming GL-visible",
in.externalIndex, uniform.name.c_str(), location, location + locationSpan - 1);
}
// Counts ONLY default-block uniforms, which is the whole of what a GL uniform location
// is and the whole of what GL_MAX_UNIFORM_LOCATIONS bounds (GL 4.6 core 7.6.1). A
// named-block member used to be counted here too and used to be handed a location by the
// first-fit pass below, which is a spec violation twice over: glGetUniformLocation must
// answer -1 for it (glGetProgramResourceLocation already did), and every slot it took
// pushed a real default-block uniform one location further up. On a program with a
// buffer block that is exactly how a location EQUAL to the advertised maximum got minted
// - the table's ceiling is raised to hold this count, so one extra block member raised it
// to MAX and the first-fit pass then filled the last slot
// (KHR-GL43.explicit_uniform_location.uniform-loc-mix-with-implicit-max, whose compute
// program carries an SSBO; its -max-array sibling ran the pool out and failed to link).
Int requiredUniformLocations = deadReservedLocationCount;
for (const Int i : artifacts.glUniformIndexToTProgram) {
auto& uniform = artifacts.program->getUniform(i);
const Uint location = effectiveLocation[i];
const Int locationSpan = GetUniformLocationSpan(uniform);
requiredUniformLocations += locationSpan;
if (!isNamedBlockMember(uniform)) requiredUniformLocations += locationSpan;
if (location != kNoLocation) {
artifacts.maxUniformLocation = std::max(artifacts.maxUniformLocation, location + locationSpan - 1);
}
@@ -727,6 +1251,22 @@ namespace MobileGL::MG_State::GLState {
MGLOG_D("ProgramObject %u: Reflection - computed maxUniformLocation=%u uniformNameMaxLength=%d",
in.externalIndex, artifacts.maxUniformLocation, artifacts.uniformNameMaxLength);
// GL 4.6 core 7.6.1: explicit, implicit and reserved-but-inactive default-block uniforms
// all draw from the one GL_MAX_UNIFORM_LOCATIONS pool, and a program asking for more than
// the implementation advertises FAILS TO LINK
// (KHR-GL43.explicit_uniform_location.uniform-loc-negative-link-max-num-of-locations).
// A single uniform whose own span passes the ceiling was already rejected above; this is
// the aggregate half of the same rule.
if (requiredUniformLocations > static_cast<Int>(kMaxUniformLocations)) {
artifacts.infoLog =
std::format("Uniform locations exhausted: the default-block uniforms need {} locations but "
"GL_MAX_UNIFORM_LOCATIONS is {}.",
requiredUniformLocations, kMaxUniformLocations);
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
ProgramObject::ResetLinkArtifacts(artifacts);
return false;
}
if (artifacts.maxUniformLocation + 1 < requiredUniformLocations) {
MGLOG_D("ProgramObject %u: Reflection - maxUniformLocation+1 (%u) < requiredUniformLocations (%d), "
"adjusting",
@@ -741,6 +1281,27 @@ namespace MobileGL::MG_State::GLState {
glslang::TQualifier::layoutLocationEnd);
artifacts.uniformSamplerOrImageUnitIndex.resize(artifacts.maxUniformLocation + 1, -1);
// Occupancy for the inactive explicit uniforms collected above: a set bit means "the
// source claimed this location", which is enough to keep the two implicit passes off it
// without making the location reachable through any GL entry point. A location the
// fallback grow path mints later is past this bitset by construction (every reservation
// was folded into maxUniformLocation before the table was sized), so the lookup treats
// out-of-range as free rather than resizing in lockstep.
// Left empty - and unallocated - when nothing reserved anything, which is every program in
// the shader-pack corpus; the lookup below reads an empty bitset as "nothing is reserved".
Vector<Bool> reservedLocation;
if (!deadExplicitReservations.empty()) {
reservedLocation.assign(artifacts.maxUniformLocation + 1, false);
for (const auto& [reservedBase, reservedSpan] : deadExplicitReservations) {
for (Int element = 0; element < reservedSpan; ++element) {
reservedLocation[reservedBase + element] = true;
}
}
}
const auto locationIsReserved = [&reservedLocation](SizeT location) {
return location < reservedLocation.size() && reservedLocation[location];
};
Vector<int> unallocatedUniformIndex;
// Pass 1: source-explicit locations. These are API contract
@@ -773,6 +1334,10 @@ namespace MobileGL::MG_State::GLState {
// is demoted to the first-fit pass below instead of failing the link.
for (const Int i : artifacts.glUniformIndexToTProgram) {
auto& uniform = artifacts.program->getUniform(i);
// Same rule the effective-location loop applies: a block member has no GL location,
// so it must not reach the first-fit pass either. Its uniformLocations entry stays
// at kNoLocation, which glGetUniformLocation reads back as the -1 the spec wants.
if (isNamedBlockMember(uniform)) continue;
if (locationIsSourceExplicit[i]) continue;
const Uint location = effectiveLocation[i];
if (location == kNoLocation) {
@@ -785,7 +1350,8 @@ namespace MobileGL::MG_State::GLState {
Bool spanIsFree = location + locationSpan - 1 <= artifacts.maxUniformLocation;
for (Int element = 0; spanIsFree && element < locationSpan; ++element) {
spanIsFree =
artifacts.uniformIndexInTProgram[location + element] == glslang::TQualifier::layoutLocationEnd;
artifacts.uniformIndexInTProgram[location + element] == glslang::TQualifier::layoutLocationEnd &&
!locationIsReserved(location + element);
}
if (!spanIsFree) {
artifacts.uniformLocations[uniform.name] = kNoLocation;
@@ -817,7 +1383,8 @@ namespace MobileGL::MG_State::GLState {
bool hasRoom = locNeedle + locationSpan - 1 <= artifacts.maxUniformLocation;
for (Int element = 0; hasRoom && element < locationSpan; ++element) {
hasRoom = artifacts.uniformIndexInTProgram[locNeedle + element] ==
glslang::TQualifier::layoutLocationEnd;
glslang::TQualifier::layoutLocationEnd &&
!locationIsReserved(locNeedle + element);
}
if (!hasRoom) continue;
// Found a vacant location at locNeedle
@@ -837,6 +1404,24 @@ namespace MobileGL::MG_State::GLState {
// span is left; grow the table instead of leaving the uniform without
// a location (which would make it unsettable via glUniform*).
const SizeT base = artifacts.uniformIndexInTProgram.size();
// The growth stops at the pool GL advertises. GL 4.6 core 7.6.1 bounds every
// uniform location by GL_MAX_UNIFORM_LOCATIONS, and the conformance suite reads a
// returned location >= the advertised maximum as a failure outright
// (KHR-GLES31.explicit_uniform_location.uniform-loc-mix-with-implicit-max). Minting
// 4095, 4096, ... is strictly worse than refusing: those are locations no
// application may legally name and no later query can make legal, so they would
// only turn a link-time exhaustion into a silently unwritable uniform. Unreachable
// for any program that fits glslang's per-stage uniform-component limits - it takes
// a fragmented pool of thousands of explicitly-located slots to get here.
if (base + static_cast<SizeT>(locationSpan) > kMaxUniformLocations) {
artifacts.infoLog = std::format(
"Uniform locations exhausted: '{}' needs {} location(s) and no free span is left below "
"GL_MAX_UNIFORM_LOCATIONS ({}).",
uniform.name, locationSpan, kMaxUniformLocations);
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
ProgramObject::ResetLinkArtifacts(artifacts);
return false;
}
artifacts.uniformIndexInTProgram.resize(base + locationSpan,
glslang::TQualifier::layoutLocationEnd);
artifacts.uniformSamplerOrImageUnitIndex.resize(base + locationSpan, -1);
@@ -959,14 +1544,21 @@ namespace MobileGL::MG_State::GLState {
}
// ---------- UBO ----------
// GL-visible blocks only (MGL_GLOBAL_UBO was filtered out above).
// The BLOCK space (MGL_GLOBAL_UBO was filtered out above, storage and atomic counter
// blocks were not): these tables are what the backends index, and what the GL
// uniform-block entry points reach after translating out of the GL_UNIFORM_BLOCK space.
const Int uboCount = static_cast<Int>(artifacts.glBlockIndexToTProgram.size());
MGLOG_D("ProgramObject %u: Reflection - uniform block count (UBO) = %d", in.externalIndex, uboCount);
artifacts.uniformBlockBinding.resize(uboCount, -1);
for (Int i = 0; i < uboCount; i++) {
auto& ubo = artifacts.program->getUniformBlock(artifacts.glBlockIndexToTProgram[i]);
artifacts.uniformBlockNameMaxLength =
std::max(artifacts.uniformBlockNameMaxLength, (Int)ubo.name.length());
// GL_ACTIVE_UNIFORM_BLOCK_MAX_NAME_LENGTH is measured over the names
// glGetActiveUniformBlockName can report, so only the GL uniform blocks count -
// a long storage-block name must not size the caller's buffer.
if (artifacts.blockIndexToGlUniformBlock[i] >= 0) {
artifacts.uniformBlockNameMaxLength =
std::max(artifacts.uniformBlockNameMaxLength, (Int)ubo.name.length());
}
artifacts.uniformBlockIndexByName[ubo.name] = i;
// if there's binding defined in shader as layout(binding = ...),
// retrieve it here.
@@ -987,9 +1579,140 @@ namespace MobileGL::MG_State::GLState {
MGLOG_D("ProgramObject %u: Reflection - UBO[%d] name='%s' size=%u binding=%d", in.externalIndex, i,
ubo.name.c_str(), ubo.size, ubo.getBinding());
}
SnapshotGlslangReflection();
return true;
}
// The last thing DoReflection does, and the thing that lets everything after it stop
// caring that a glslang::TProgram ever existed: copy every reflection record the GL query
// surface reads into LinkArtifacts' own owned tables.
//
// Indexed by TPROGRAM index throughout - the same space glUniformIndexToTProgram,
// tProgramUniformIndexToGl and uniformIndexInTProgram already speak - so the accessors
// that used to call program->getUniform(i) index uniformReflection[i] and are otherwise
// unchanged.
void ProgramLinkTask::SnapshotGlslangReflection() {
glslang::TProgram& program = *artifacts.program;
// Blocks FIRST: a uniform's effective layoutMatrix is resolved against its owning
// block below, which needs the block records to already exist.
const Int blockCount = program.getNumUniformBlocks();
artifacts.blockReflection.clear();
artifacts.blockReflection.reserve(static_cast<SizeT>(blockCount));
for (Int i = 0; i < blockCount; ++i) {
artifacts.blockReflection.push_back(MakeResourceReflection(program.getUniformBlock(i)));
}
SeedDefaultStorageBlockBindings();
const Int uniformCount = program.getNumUniformVariables();
artifacts.uniformReflection.clear();
artifacts.uniformReflection.reserve(static_cast<SizeT>(uniformCount));
artifacts.uniformIndexByName.clear();
artifacts.uniformIndexByName.reserve(static_cast<SizeT>(uniformCount));
for (Int i = 0; i < uniformCount; ++i) {
ProgramObject::UniformReflection record = MakeResourceReflection(program.getUniform(i));
// A block-level layout(row_major)/(column_major) that the member did not inherit
// in its own qualifier. Resolved once HERE rather than at every GL_UNIFORM_* query,
// which is what the getUniformBlock() fallback in the old accessors was doing.
if (record.type.layoutMatrix == static_cast<Int>(glslang::ElmNone) && record.index >= 0 &&
record.index < static_cast<Int>(artifacts.blockReflection.size())) {
record.type.layoutMatrix = artifacts.blockReflection[record.index].type.layoutMatrix;
}
// Keyed on the REFLECTED name and on uniforms only. That is deliberate and is the
// filtered semantics the old code hand-rolled: glslang's TReflection::nameToIndex
// also holds block and function entries, which is exactly why every
// getUniformIndex() call site re-checked getUniform(idx).name == name afterwards.
// First writer wins, so a duplicated name resolves the way a forward scan would.
artifacts.uniformIndexByName.emplace(record.name, i);
artifacts.uniformReflection.push_back(Move(record));
}
const Int pipeInputCount = program.getNumPipeInputs();
artifacts.pipeInputReflection.clear();
artifacts.pipeInputReflection.reserve(static_cast<SizeT>(pipeInputCount));
for (Int i = 0; i < pipeInputCount; ++i) {
artifacts.pipeInputReflection.push_back(MakeResourceReflection(program.getPipeInput(i)));
}
const Int pipeOutputCount = program.getNumPipeOutputs();
artifacts.pipeOutputReflection.clear();
artifacts.pipeOutputReflection.reserve(static_cast<SizeT>(pipeOutputCount));
for (Int i = 0; i < pipeOutputCount; ++i) {
artifacts.pipeOutputReflection.push_back(MakeResourceReflection(program.getPipeOutput(i)));
}
artifacts.lastStageIsFragment = program.getIntermediate(EShLangFragment) != nullptr;
for (Uint dim = 0; dim < 3u; ++dim) {
artifacts.computeLocalSize[dim] = program.getLocalSize(static_cast<Int>(dim));
}
MGLOG_D("ProgramObject %u: Reflection - snapshot: %zu uniform(s), %zu block(s), %zu input(s), "
"%zu output(s)",
in.externalIndex, artifacts.uniformReflection.size(), artifacts.blockReflection.size(),
artifacts.pipeInputReflection.size(), artifacts.pipeOutputReflection.size());
}
// GL 4.3 core 7.8: a shader storage block declared without a layout(binding = N) qualifier
// has a buffer binding of ZERO. MobileGL could not report that, because by the time this
// reflection is built the number in the block's qualifier is one glslang INVENTED.
//
// Every shader is parsed as a Vulkan client, so glslang's IO mapper takes the `set = openGl
// ? resource : ent.newSet` branch with openGl == 0 (iomapper.cpp resolveBinding) - i.e. it
// allocates out of ONE flat binding space shared by every sampler, image, uniform block,
// storage block and the synthesized MGL_GLOBAL_UBO - and then writes the result back into
// the type's qualifier (iomapper.cpp, `base->getWritableType().getQualifier().layoutBinding =
// at->second.newBinding`). getBinding() therefore answers with the auto-assigned slot and
// cannot be distinguished from a declared one. An unqualified block lands on 0 only when
// nothing else in the program claimed 0 first, which is why a lone storage block in a
// trivial shader looked correct and KHR-GL43.compute_shader.resource-ubo - whose shader also
// declares twelve uniform blocks - wrote everything to a binding nothing was bound at.
//
// THE FLAT SPACE IS LEFT ALONE. It is load-bearing: DirectVulkan indexes bindingKinds[],
// uniformBlockIndexByBinding[] and storageBlockIndexByBinding[] by that one number and
// asserts when two resources collide on it, so forcing the SPIR-V decoration to 0 would
// collide an unqualified block with the global UBO and take working programs down. What is
// repaired is the GL-VISIBLE binding, through the record GL already has for exactly this -
// the same per-name map glShaderStorageBlockBinding writes, which both backends already
// consult (ProgramInterface's GL_BUFFER_BINDING, DirectGLES's SPIRV-Cross binding rewrite,
// DirectVulkan's GetShaderStorageBlockBinding). Seeding it here means the default and a
// later rebind travel the same path, and basic-noBindingLayout - which rebinds all three of
// its unqualified blocks - keeps working because a rebind simply overwrites the seed.
//
// Seeded INSIDE `artifacts`, so an L1 translation-cache hit that republishes the artifacts
// wholesale carries it too; a seed applied outside them would silently vanish on a hit.
//
// The blocks are named by TMglGlslIoResolver at mapIO's collect callback, which runs over
// every declared block of every stage BEFORE the write-back above happens - so "declared no
// binding" is a fact read off the AST, not a guess made about the text. The lexical scanner
// this replaced could only report positively, dropping any declaration whose grammar it did
// not fully recognise, and could not read `binding = SOME_MACRO` at all (it ran on
// macro-unexpanded source, and reading "no literal" as "no binding" once aliased eight
// Flywheel storage blocks onto 0).
//
// THE COLLISION IS DELIBERATE, and it is GL's. Several unqualified blocks all default to 0
// and alias there until the application rebinds them; a real GL driver does the same, which
// is why every program that has more than one either rebinds or uses one of them.
// basic-noBindingLayout is that regression test - it rebinds all three of its blocks
// immediately after linking, and the DirectGLES transpile is lazy (first use, not link), so
// the ESSL it eventually emits already carries the rebound 0/1/2 and never the aliased seed.
// What this replaces was not a safer arrangement, only an accidental one: the three blocks
// got glslang's 0/1/2 and an application that rebound them to anything else still wrote to
// the wrong buffers.
void ProgramLinkTask::SeedDefaultStorageBlockBindings() {
if (artifacts.storageBlocksWithoutBinding.empty()) return;
for (const ProgramObject::BlockReflection& block : artifacts.blockReflection) {
if (!block.type.isBuffer) continue;
// An instance array reflects as "B[0]", "B[1]", ... and each element is its own GL
// resource with its own binding; the scanner keys on the block TYPE name, so the
// subscript is stripped before the lookup. GL gives element k of an unqualified
// array binding 0 + k, the same base + element rule a declared binding follows.
const String base = StripArrayElementSuffix(block.name);
if (!artifacts.storageBlocksWithoutBinding.contains(base)) continue;
// First writer wins: never overwrite a binding the application has already chosen.
artifacts.shaderStorageBlockBinding.emplace(block.name, BlockArrayElement(block.name));
}
}
Bool ProgramLinkTask::ValidateFragmentOutputLocations() {
if (!artifacts.program) return false;
// The pipe-output list is the output interface of the program's LAST stage. Only a
@@ -12,6 +12,7 @@
#include <MG_State/GLState/ProgramState/ShaderCompileTask.h>
#include <MG_Util/Async/JobNode.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <MG_Util/ShaderTranspiler/TranslationCache.h>
namespace MobileGL::MG_State::GLState {
// One attached shader, as the link sees it: never the ShaderObject, always a snapshot.
@@ -116,6 +117,26 @@ namespace MobileGL::MG_State::GLState {
// for phase B after the join has moved `artifacts` away.
ProgramObject::LinkArtifacts reflection;
// L1 shader-translation memo key for this program's SPIR-V (see
// MG_Util/ShaderTranspiler/TranslationCache.h). Built HERE, at the tail of phase
// A, and not by phase B - two reasons, both structural:
// * the key covers the three link-time request maps, which live in `in` - and
// it has to be built before the link, because a hit is what makes the link
// unnecessary;
// * built once, it serves both the lookup and the insert, so the program's
// sources are copied into the blob exactly once per link.
// Invalid (null blob) when the cache is disabled, or when a stage arrived
// without preprocessed source - in which case phase B simply translates.
MG_Util::ShaderTranspiler::TranslationCacheKey spirvCacheKey;
// Set on an L1 HIT: phase B publishes these SpirvArtifacts verbatim instead of
// generating anything. Null on a miss.
SharedPtr<const ProgramObject::SpirvArtifacts> cachedSpirv;
// Set on a MISS: the LinkArtifacts phase B has to pair with its own SpirvArtifacts
// to insert the completed front end. Copied here rather than read off the node,
// because the GL-thread join MOVES `artifacts` out before phase B runs.
SharedPtr<const ProgramObject::LinkArtifacts> linkArtifactsForCache;
// 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;
@@ -142,8 +163,37 @@ namespace MobileGL::MG_State::GLState {
// ---- the link body, split exactly as ProgramObject::Link() had it ----
// Each returns false to abort the link with `artifacts.infoLog` already set, which is
// GL's definition of a failed link: LINK_STATUS false plus a log, never a GL error.
// The two link-rejection gates that need no parsed shader: a compute stage mixed
// with any other, and an attached shader that failed to compile. Split out of
// ConsumeShaders so they still run - in the same order, with the same diagnostics -
// BEFORE the L1 memo is consulted, rather than behind a hit that would skip them.
// Merges the per-stage explicit default-block uniform locations glslang recorded at
// compile time. Reads the compile snapshots only, so it runs before any parse - and
// before the L1 memo, so a hit can never paper over a program that must fail to link.
// Sets artifacts.infoLog and leaves linkStatus false when two stages disagree on an
// explicit uniform location.
void MergeShaderSideChannels();
Bool ValidateAttachedShaders();
Bool ConsumeShaders(Vector<SharedPtr<glslang::TShader>>& outShaders);
// Publishes a whole front end straight out of the L1 memo: no TShader, no TProgram,
// no SPIR-V generation. Returns false on a miss.
Bool TryPublishFromTranslationCache();
// The L1 memo key for the SPIR-V this program is about to generate, or an invalid
// key when the cache is off or a stage has no preprocessed source to key on.
// Called at the tail of RunBody, where every input it needs is still owned by this
// node and `artifacts` has not yet been published.
MG_Util::ShaderTranspiler::TranslationCacheKey BuildSpirvCacheKey(
const MG_Util::ShaderTranspiler::CompileEnv& env) const;
Bool DoReflection(const MG_Util::ShaderTranspiler::CompileEnv& env);
// Copies every reflection record the GL query surface reads out of the glslang
// TProgram into LinkArtifacts own owned tables. Runs at the tail of DoReflection.
void SnapshotGlslangReflection();
// Gives every storage block whose shader declared no layout(binding = N) the binding
// GL 4.3 core 7.8 says it has - zero - because glslang's IO mapper has by then invented
// one and overwritten the qualifier. See the definition for why the invented binding is
// deliberately left in place for the backends' own use.
void SeedDefaultStorageBlockBindings();
Bool ValidateFragmentOutputLocations();
Bool ResolveTransformFeedbackVaryings();
void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate);
@@ -155,6 +155,10 @@ namespace MobileGL::MG_State::GLState {
Uint8* const scratch = m_spirv.globalUboScratch.data();
const SizeT uboSize = m_spirv.globalUboScratch.size();
// Read straight off m_spirv, not through UsesNativeFloat64(): this runs INSIDE the
// phase-B publish, where the join gate is not re-entrant. Same reason the scratch above
// is taken directly.
const Bool nativeFloat64 = m_spirv.nativeFloat64;
for (const auto& init : initializers) {
// Scalars per array ELEMENT. A matrix element carries cols * rows of them, laid
@@ -165,12 +169,13 @@ namespace MobileGL::MG_State::GLState {
const Int elements = init.arraySize;
if (componentsPerElement <= 0 || elements <= 0) continue;
// EbtDouble belongs with the floats now, not with the skipped types: every 64-bit
// float in a shader is narrowed to 32 bits before the module reaches a backend
// EbtDouble belongs with the floats, not with the skipped types. On a DEMOTED
// program its 64-bit floats were narrowed to 32 before the module reached a backend
// (ShaderTranspiler::DemoteFloat64Pass), so a `uniform double d = 1.5;` has exactly
// the 32-bit shadow encoding a `uniform float` does - and glslang already folded its
// value into floatValues, which is a vector<double> either way. Leaving it out meant
// the initializer was silently dropped and the uniform came up zero.
// the 32-bit shadow encoding a `uniform float` does; on a program that kept them it
// has an 8-byte one, which the store width below picks up. glslang folded the value
// into floatValues, a vector<double>, in both cases. Leaving it out meant the
// initializer was silently dropped and the uniform came up zero.
const Bool isFloat = init.basicType == glslang::EbtFloat ||
init.basicType == glslang::EbtFloat16 ||
init.basicType == glslang::EbtDouble;
@@ -195,22 +200,36 @@ namespace MobileGL::MG_State::GLState {
// 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));
// The static form, with the width taken from m_spirv directly: the member
// overload asks UsesNativeFloat64(), which joins phase B - and phase B is what
// is publishing right now.
const SizeT slotSpan =
UniformStorageSpanInBytes(GetUniformTypeFacts(static_cast<Uint>(location)),
GetUniformSizesInBytes(static_cast<Uint>(location)), nativeFloat64);
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;
// A `double` initializer on a program that KEPT its doubles lands in an 8-byte
// component, not a 4-byte one; every other basic type - and every double on a
// demoted program - stays one 32-bit word. glslang folded the value into
// floatValues (a vector<double>) either way, so only the store width moves.
const Bool isWideDouble = init.basicType == glslang::EbtDouble && nativeFloat64;
const SizeT componentSize = isWideDouble ? sizeof(Double) : sizeof(Uint32);
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);
const SizeT writeSize = static_cast<SizeT>(componentsPerColumn) * componentSize;
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) {
Uint8* const destination = scratch + byteOffset + component * componentSize;
if (isWideDouble) {
const Double value = init.floatValues[source];
std::memcpy(destination, &value, sizeof(value));
} else if (isFloat) {
const Float value = static_cast<Float>(init.floatValues[source]);
std::memcpy(destination, &value, sizeof(value));
} else {
@@ -331,6 +350,8 @@ namespace MobileGL::MG_State::GLState {
artifacts.tProgramUniformIndexToGl.clear();
artifacts.glBlockIndexToTProgram.clear();
artifacts.tProgramBlockIndexToGl.clear();
artifacts.glUniformBlockIndexToBlock.clear();
artifacts.blockIndexToGlUniformBlock.clear();
artifacts.linkedExplicitUniformLocations.clear();
artifacts.uniformInitialValues.clear();
artifacts.uniformIndexInTProgram.clear();
@@ -344,6 +365,10 @@ namespace MobileGL::MG_State::GLState {
artifacts.uniformBlockIndexByName.clear();
artifacts.uniformBlockBinding.clear();
artifacts.shaderStorageBlockBinding.clear();
// Cleared with it: the seed above is re-derived from the newly attached shaders on every
// link, so a stale set would otherwise default a block the new sources do declare a
// binding for.
artifacts.storageBlocksWithoutBinding.clear();
artifacts.attribs.clear();
artifacts.attribTypes.clear();
artifacts.activeUniformCount = 0;
@@ -615,15 +640,22 @@ namespace MobileGL::MG_State::GLState {
Int ProgramObject::GetFragmentDataLocation(const char* name) {
if (!Artifacts().program || !name) return -1;
// Answered from the OWNED pipe-output snapshot, not from Artifacts().program. The live
// TProgram is null on a translation-cache L1 hit - that is the entire point of the memo
// - and it is also null for any program that never linked. The old `if
// (!Artifacts().program) return -1` guard silently produced the never-linked answer for
// a perfectly good cached program, so glGetFragDataLocation returned -1 for every
// fragment output of it. The empty snapshot gives the never-linked case the same -1
// without needing the guard at all.
if (!name) return -1;
const auto explicitLocation = Artifacts().linkedFragDataLocation.find(name);
const Int outputCount = Artifacts().program->getNumPipeOutputs();
for (Int index = 0; index < outputCount; ++index) {
const auto& output = Artifacts().program->getPipeOutput(index);
for (const PipeOutputReflection& output : Artifacts().pipeOutputReflection) {
if (output.name != name) continue;
if (explicitLocation != Artifacts().linkedFragDataLocation.end()) return static_cast<Int>(explicitLocation->second);
return static_cast<Int>(output.layoutLocation());
if (explicitLocation != Artifacts().linkedFragDataLocation.end()) {
return static_cast<Int>(explicitLocation->second);
}
return output.location;
}
return -1;
}
@@ -24,6 +24,85 @@ namespace MobileGL::MG_State::GLState {
class ProgramObject {
public:
// GL_MAX_UNIFORM_LOCATIONS: locations 0 .. MAX_UNIFORM_LOCATIONS-1 are the whole legal
// range (GL 4.6 core 7.6.1 / ARB_explicit_uniform_location). Shared with GL_Getter rather
// than spelled twice, because the link and the query must agree exactly - the CTS declares
// a uniform at the advertised value minus one and expects it to link
// (KHR-GL43.explicit_uniform_location.uniform-loc-max).
//
// Tied to glslang's own ceiling and NOT raisable past it: ParseHelper rejects
// `layout(location = N)` for N >= TQualifier::layoutLocationEnd at COMPILE time, so
// layoutLocationEnd - 1 is the largest location any shader in this stack can declare -
// which makes exactly layoutLocationEnd locations, 0 .. layoutLocationEnd - 1, the pool.
// Advertising more would promise a location no shader could name. Comfortably above the
// 1024 GL 4.3 requires.
static constexpr Int MAX_UNIFORM_LOCATIONS = static_cast<Int>(glslang::TQualifier::layoutLocationEnd);
// Everything the query surface ever asked a glslang::TType, flattened. Twenty
// predicates, no recursion: nothing post-link ever walks a struct, a type name or the
// AST, so a POD covers the whole surface exactly.
struct TypeFacts {
Bool isArray = false;
// A runtime-sized array (a storage block's unsized trailing member) is an array
// that is NOT sized; GL_ARRAY_SIZE reports 0 for it.
Bool isSizedArray = false;
Bool isMatrix = false;
Bool isVector = false;
Bool isOpaque = false;
Bool isTexture = false;
Bool isImage = false;
Bool isDouble = false; // getBasicType() == EbtDouble
Bool isVoid = false; // getBasicType() == EbtVoid (hidden block members)
Bool isBuffer = false; // getQualifier().storage == EvqBuffer
Bool isPatch = false; // getQualifier().patch
Bool hasIndex = false; // getQualifier().hasIndex()
Bool hasFormat = false; // getQualifier().hasFormat()
Int vectorSize = 0;
Int matrixCols = 0;
Int matrixRows = 0;
Int layoutIndex = 0; // getQualifier().layoutIndex
Uint layoutFormat = 0; // getQualifier().getFormat()
// glslang::TLayoutMatrix, widened. For a uniform this is already RESOLVED against
// the owning block's qualifier, so the getUniformBlock() fallback the old
// accessors carried is gone.
Int layoutMatrix = 0;
// glslang::TBasicType, widened - ApplyUniformInitialValues and the typed
// glGetUniform* paths compare against a handful of enumerators.
Int basicType = 0;
};
// One glslang::TObjectReflection, flattened. Used for uniforms, blocks, pipe inputs
// and pipe outputs alike, because glslang reflects all four as TObjectReflection.
struct ResourceReflection {
String name;
GLenum glDefineType = 0;
Int offset = -1;
// TObjectReflection::size, RAW. For a uniform prefer `arraySize` below, which is
// the resolved GL_UNIFORM_SIZE answer.
Int size = 0;
// TObjectReflection::index - for a uniform, the TPROGRAM block index owning it
// (-1 for a default-block one; translate with GlBlockIndexFromTProgram).
Int index = -1;
Int counterIndex = -1;
Int arrayStride = 0;
Int topLevelArraySize = 0;
Int topLevelArrayStride = 0;
Int binding = -1;
Int location = -1; // layoutLocation()
// EShLanguageMask of the stages that reference it; 0 means "declared but read by
// nobody", which is what the dead-default-block-uniform filter tests.
Uint32 stages = 0;
// GL_UNIFORM_SIZE / GL_ARRAY_SIZE, already resolved through the
// isSizedArray()/getOuterArraySize()/size fallback.
GLint arraySize = 1;
TypeFacts type;
};
using UniformReflection = ResourceReflection;
using BlockReflection = ResourceReflection;
using PipeInputReflection = ResourceReflection;
using PipeOutputReflection = ResourceReflection;
ProgramObject(Uint externalIndex) : m_externalIndex(externalIndex), m_lifetimeId(AllocateLifetimeId()) {}
// Cancel-not-join, exactly like ~ShaderObject: the link job owns its inputs, so an
// in-flight link whose program just went away is safe to abandon where it stands.
@@ -76,6 +155,46 @@ namespace MobileGL::MG_State::GLState {
// The last link's full input set; empty when this program has never linked (or its
// last link had no shaders attached). GL-thread-owned, rebuilt in Link()'s prologue.
const Vector<LinkedShaderRef>& GetLinkedShaderSnapshot() const { return m_linkedShaderSnapshot; }
// "Does this program's EXECUTABLE have this stage" - the only form of the question a
// draw may ask. GetShaderIndexByStage answers it of the live attach list, which by the
// rule above is a different set: glAttachShader adds to that list immediately while
// leaving the executable (and LINK_STATUS) alone, and glDetachShader defers the removal
// to the next Link(), so between an attach and the relink the two disagree in both
// directions. A draw-time stage test that reads the live list therefore starts rejecting
// draws GL requires to execute, against an executable that does not carry the stage at
// all - and stays wrong until the application happens to relink.
Bool HasLinkedShaderStage(ShaderStage stage) const {
return std::any_of(m_linkedShaderSnapshot.begin(), m_linkedShaderSnapshot.end(),
[stage](const LinkedShaderRef& ref) {
return ref.shader && ref.shader->GetShaderStage() == stage;
});
}
// The stage of each module of GetGeneratedSpirv(), at the SAME index and with the same
// size: phase B emits exactly one module per entry of the snapshot above, in that order
// (Link() fills ProgramLinkTask::in.shaders from the snapshot loop, phase A copies the
// stages straight across into SpirvHandoff::shaderTypes, and GetSpirvBinaryFromProgram
// walks that list). This - never GetAttachedShaders() - is what a consumer of the
// generated SPIR-V must size its loop by and index alongside.
//
// The two lists are NOT interchangeable and cannot be made so: the attach list is live
// and the SPIR-V is a link artifact, so a glAttachShader after a link grows one and not
// the other, with no link in between at which they could be reconciled. A loop that runs
// over the attach list and indexes the SPIR-V therefore reads off the end of it - which
// is a plain out-of-bounds Vector read, not a wrong answer.
//
// Deliberately a Vector<ShaderStage> and not the shader objects: every consumer wants
// only the stage, and a distinct type is what makes handing it the attach list by
// mistake a compile error rather than a segfault. Built on demand because these callers
// are program-BUILD paths (a backend rebuild, a pipeline cache miss), each of which then
// spends milliseconds compiling the very modules this indexes.
Vector<ShaderStage> GetLinkedShaderStages() const {
Vector<ShaderStage> stages;
stages.reserve(m_linkedShaderSnapshot.size());
for (const LinkedShaderRef& ref : m_linkedShaderSnapshot) {
stages.push_back(ref.shader ? ref.shader->GetShaderStage() : ShaderStage::Unknown);
}
return stages;
}
// Pipeline-composite attach: AttachShader plus a pin that makes THIS program's
// Link() consume ref's (source, node) instead of the shader's current ones, so a
// post-link recompile of the stage program's shader cannot leak into the composite.
@@ -134,8 +253,7 @@ namespace MobileGL::MG_State::GLState {
const Int index = Artifacts().uniformIndexInTProgram[base];
// "[k]" only addresses arrays ("scalar[0]" is not a uniform name), and only
// in-range elements.
const glslang::TType* type = Artifacts().program->getUniform(index).getType();
if (type == nullptr || !type->isArray()) return -1;
if (!UniformAt(index).type.isArray) return -1;
if (static_cast<GLint>(element) >= GetUniformArraySizeByTIndex(index)) return -1;
const Int location = base + (Int)element;
if (!UniformLocationsAliasSameUniform(base, location)) return -1;
@@ -161,12 +279,12 @@ namespace MobileGL::MG_State::GLState {
if (tIndex < 0 || tIndex >= static_cast<Int>(Artifacts().tProgramUniformIndexToGl.size())) return -1;
return Artifacts().tProgramUniformIndexToGl[tIndex];
}
// GL uniform-block index -> glslang TProgram block index (the inverse of
// Block index -> glslang TProgram block index (the inverse of
// GlBlockIndexFromTProgram). The interface-query layer needs it to reach block
// properties glslang exposes but no typed getter here does.
Int TProgramBlockIndex(Uint glBlockIndex) const {
return glBlockIndex < Artifacts().glBlockIndexToTProgram.size()
? Artifacts().glBlockIndexToTProgram[glBlockIndex]
Int TProgramBlockIndex(Uint blockIndex) const {
return blockIndex < Artifacts().glBlockIndexToTProgram.size()
? Artifacts().glBlockIndexToTProgram[blockIndex]
: -1;
}
Int GlBlockIndexFromTProgram(Int tBlockIndex) const {
@@ -174,45 +292,74 @@ namespace MobileGL::MG_State::GLState {
return Artifacts().tProgramBlockIndexToGl[tBlockIndex];
}
// ---- GL_UNIFORM_BLOCK index <-> block index translation ----
// The block index space above carries the storage blocks and the synthesized atomic
// counter blocks as well; GL_ACTIVE_UNIFORM_BLOCKS counts only actual uniform blocks
// (GL 4.6 core 7.6). Every glGetActiveUniformBlock* / glGetUniformBlockIndex /
// glUniformBlockBinding entry point speaks THIS space and translates into the block
// space before touching any of the block-keyed tables; the backends keep speaking the
// block space directly. See LinkArtifacts::glUniformBlockIndexToBlock.
Int GetGlUniformBlockCount() const {
return static_cast<Int>(Artifacts().glUniformBlockIndexToBlock.size());
}
Bool IsActiveGlUniformBlock(Uint glUniformBlockIndex) const {
return glUniformBlockIndex < Artifacts().glUniformBlockIndexToBlock.size();
}
Int BlockIndexFromGlUniformBlock(Uint glUniformBlockIndex) const {
return glUniformBlockIndex < Artifacts().glUniformBlockIndexToBlock.size()
? Artifacts().glUniformBlockIndexToBlock[glUniformBlockIndex]
: -1;
}
Int GlUniformBlockIndexFromBlock(Int blockIndex) const {
if (blockIndex < 0 || blockIndex >= static_cast<Int>(Artifacts().blockIndexToGlUniformBlock.size())) {
return -1;
}
return Artifacts().blockIndexToGlUniformBlock[blockIndex];
}
// glGetUniformBlockIndex: GL_INVALID_INDEX for a name that is not an active UNIFORM
// block, which includes every storage block and every atomic counter block even though
// GetUniformBlockIndex() below resolves them (it answers in the block space, which the
// backends need to keep reaching them by name).
Uint GetGlUniformBlockIndex(const char* name) const {
const Uint blockIndex = GetUniformBlockIndex(name);
if (blockIndex == 0xFFFFFFFFu) return 0xFFFFFFFFu;
const Int glIndex = GlUniformBlockIndexFromBlock(static_cast<Int>(blockIndex));
return glIndex < 0 ? 0xFFFFFFFFu : static_cast<Uint>(glIndex);
}
Int GetActiveUniformIndex(const String& name) const {
const Int tProgramCount = static_cast<Int>(Artifacts().tProgramUniformIndexToGl.size());
const Int uniformIndex = Artifacts().program->getUniformIndex(name.c_str());
if (uniformIndex >= 0 && uniformIndex < tProgramCount &&
Artifacts().program->getUniform(uniformIndex).name == name) {
return GlUniformIndexFromTProgram(uniformIndex);
// uniformIndexByName is keyed by the REFLECTED name, so a lookup that hits is
// already the exact-match the old code re-verified with a string compare after
// glslang's getUniformIndex(); a lookup that misses needs no bounds check.
const auto& byName = Artifacts().uniformIndexByName;
if (const auto direct = byName.find(name); direct != byName.end()) {
return GlUniformIndexFromTProgram(direct->second);
}
// Reflection stores an array uniform under "arr[0]"; accept the bare "arr"
// spelling too. The reverse ("arr[0]" against a bare "arr" entry) is kept for
// robustness against non-suffixed reflection entries.
if (!name.empty() && name.back() != ']') {
const String suffixedName = name + "[0]";
const Int suffixedIndex = Artifacts().program->getUniformIndex(suffixedName.c_str());
if (suffixedIndex >= 0 && suffixedIndex < tProgramCount &&
Artifacts().program->getUniform(suffixedIndex).name == suffixedName) {
return GlUniformIndexFromTProgram(suffixedIndex);
}
return -1;
const auto suffixed = byName.find(name + "[0]");
return suffixed != byName.end() ? GlUniformIndexFromTProgram(suffixed->second) : -1;
}
if (name.length() <= 3 || name.compare(name.length() - 3, 3, "[0]") != 0) return -1;
const String baseName = name.substr(0, name.length() - 3);
const Int baseIndex = Artifacts().program->getUniformIndex(baseName.c_str());
if (baseIndex < 0 || baseIndex >= tProgramCount) return -1;
return Artifacts().program->getUniform(baseIndex).name == baseName ? GlUniformIndexFromTProgram(baseIndex)
: -1;
const auto base = byName.find(name.substr(0, name.length() - 3));
return base != byName.end() ? GlUniformIndexFromTProgram(base->second) : -1;
}
Bool IsValidUniformLocation(Int location) const { return IsValidUniformLocation(Artifacts(), location); }
GLenum GetUniformType(Uint location) const {
auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]);
return uniform.glDefineType;
return UniformAt(Artifacts().uniformIndexInTProgram[location]).glDefineType;
}
GLenum GetActiveUniformType(Uint index) const {
auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
return uniform.glDefineType;
// The lowered counter is a plain uint inside a synthesized block; what the GL
// client declared - and what glGetActiveUniform must report - is an atomic_uint.
if (IsActiveUniformAtomicCounter(index)) return GL_UNSIGNED_INT_ATOMIC_COUNTER;
return UniformAt(TProgramUniformIndex(index)).glDefineType;
}
// Number of active array elements (GL_UNIFORM_SIZE / GL_ARRAY_SIZE); 1 for a non-array.
@@ -228,17 +375,73 @@ namespace MobileGL::MG_State::GLState {
return GetUniformArraySizeByTIndex(TProgramUniformIndex(index));
}
Int GetActiveUniformBlockIndex(Uint index) const {
auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
// The BLOCK index of the block owning this active uniform, or -1 when it owns none as
// far as GL is concerned. Internal: pair it with another block-space index, never with
// a GL_UNIFORM_BLOCK one (GetActiveUniformBlockIndex below is that one).
Int GetActiveUniformOwnerBlockIndex(Uint index) const {
// An atomic counter is a DEFAULT-BLOCK uniform to GL, whatever block the
// transpiler lowered it onto (GL 4.6 core 7.6, table 7.6): -1.
if (IsActiveUniformAtomicCounter(index)) return -1;
// Members of the synthesized global UBO are default-block uniforms to GL: -1.
return GlBlockIndexFromTProgram(uniform.index);
return GlBlockIndexFromTProgram(UniformAt(TProgramUniformIndex(index)).index);
}
// GL_UNIFORM_BLOCK_INDEX: an index into the GL_ACTIVE_UNIFORM_BLOCKS list, or -1. A
// buffer variable owns a storage block, which is not in that list, so it answers -1 too
// (and after the enumeration filter it is not an active uniform in the first place).
Int GetActiveUniformBlockIndex(Uint index) const {
return GlUniformBlockIndexFromBlock(GetActiveUniformOwnerBlockIndex(index));
}
// The transpiler lowers every atomic_uint onto a synthesized gl_AtomicCounterBlock_N
// block, but GL keeps seeing an atomic counter as a default-block uniform of type
// GL_UNSIGNED_INT_ATOMIC_COUNTER that points at an atomic-counter BUFFER. These two
// answer for that GL-level declaration; without them the query surface reports the
// lowering instead (GL_UNSIGNED_INT, block index 0) and
// KHR-GL43.shader_atomic_counters.basic-program-query fails on both.
//
// The returned value is an index into the GL_ACTIVE_ATOMIC_COUNTER_BUFFERS list, i.e.
// the RANK of the owning counter block among the counter blocks in glslang's block
// order - exactly how ProgramInterface numbers the GL_ATOMIC_COUNTER_BUFFER
// resources glGetActiveAtomicCounterBufferiv answers from. -1 when this uniform is
// not an atomic counter.
// Answered from the OWNED reflection snapshot, never from Artifacts().program. This
// arrived reading the live TProgram, which is null for every program served from the
// translation cache's L1 - and unlike the other query-surface accessors that made the
// same mistake, this one DEREFERENCES it, so the second program built from a given set
// of sources would have taken the process down rather than answered wrongly. The
// snapshot carries the same three facts in the same TPROGRAM index space:
// getUniform(i).index -> UniformAt(i).index, getNumUniformBlocks() ->
// blockReflection.size(), getUniformBlock(i).name -> BlockAt(i).name.
Int GetActiveUniformAtomicCounterBufferIndex(Uint index) const {
const Int tIndex = TProgramUniformIndex(index);
if (tIndex < 0) return -1;
const Int owner = UniformAt(tIndex).index;
if (owner < 0) return -1;
const Int blockCount = static_cast<Int>(Artifacts().blockReflection.size());
if (owner >= blockCount) return -1;
const SizeT prefixLength = StringView(MG_Util::ShaderTranspiler::ATOMIC_COUNTER_BLOCK_PREFIX).size();
Int counterBufferIndex = 0;
for (Int i = 0; i < blockCount; ++i) {
const auto& blockName = BlockAt(i).name;
if (blockName.compare(0, prefixLength, MG_Util::ShaderTranspiler::ATOMIC_COUNTER_BLOCK_PREFIX) != 0) {
continue;
}
if (i == owner) return counterBufferIndex;
++counterBufferIndex;
}
return -1;
}
Bool IsActiveUniformAtomicCounter(Uint index) const {
return GetActiveUniformAtomicCounterBufferIndex(index) >= 0;
}
// GL_UNIFORM_OFFSET: byte offset within the owning named block; -1 for a default-block
// uniform. The relaxed parse gives global-UBO members real byte offsets, but GL must keep
// seeing them as default-block uniforms, so gate on the GL-visible block index.
GLint GetActiveUniformOffset(Uint index) const {
const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
const auto& uniform = UniformAt(TProgramUniformIndex(index));
if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1;
return uniform.offset;
}
@@ -252,13 +455,19 @@ namespace MobileGL::MG_State::GLState {
// generated SPIR-V lay the array out with std140 16-byte-rounded strides. MobileGL's UBO
// layout is always std140, where every array element stride rounds up to a vec4.
GLint GetActiveUniformArrayStride(Uint index) const {
const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
const auto& uniform = UniformAt(TProgramUniformIndex(index));
if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1;
const glslang::TType* type = uniform.getType();
if (type == nullptr || !type->isArray()) return 0;
if (type->isMatrix()) {
if (!uniform.type.isArray) return 0;
// An atomic counter reaches the std140 branch below only because the transpiler
// lowered it onto a synthesized block; the buffer it actually addresses is an
// ATOMIC COUNTER buffer, whose elements are tightly packed uints (GL 4.6 core 7.6:
// "each counter is a single 4-byte value"). Its array stride is therefore 4, not the
// vec4 round-up std140 would apply
// (KHR-GL43.shader_atomic_counters.basic-program-query wants 4 for ac_counter67[0]).
if (IsActiveUniformAtomicCounter(index)) return 4;
if (uniform.type.isMatrix) {
const bool rowMajor = GetActiveUniformIsRowMajor(index) != 0;
const int vectors = rowMajor ? type->getMatrixRows() : type->getMatrixCols();
const int vectors = rowMajor ? uniform.type.matrixRows : uniform.type.matrixCols;
return GetActiveUniformMatrixStride(index) * vectors;
}
return 16; // scalars and vectors: std140 rounds the element stride up to a vec4
@@ -272,15 +481,12 @@ namespace MobileGL::MG_State::GLState {
// check suffices; the getUniformBlock() fallback is defensive for a config that instead leaves
// an inheriting member's layoutMatrix == ElmNone.
GLint GetActiveUniformIsRowMajor(Uint index) const {
const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
const auto& uniform = UniformAt(TProgramUniformIndex(index));
if (GlBlockIndexFromTProgram(uniform.index) < 0) return 0;
const glslang::TType* type = uniform.getType();
if (type == nullptr || !type->isMatrix()) return 0;
glslang::TLayoutMatrix layoutMatrix = type->getQualifier().layoutMatrix;
if (layoutMatrix == glslang::ElmNone) {
layoutMatrix = Artifacts().program->getUniformBlock(uniform.index).getType()->getQualifier().layoutMatrix;
}
return (layoutMatrix == glslang::ElmRowMajor) ? 1 : 0;
if (!uniform.type.isMatrix) return 0;
// layoutMatrix is already resolved against the owning block's qualifier at
// snapshot time, so the getUniformBlock() fallback this used to carry is gone.
return (uniform.type.layoutMatrix == static_cast<Int>(glslang::ElmRowMajor)) ? 1 : 0;
}
// GL_UNIFORM_MATRIX_STRIDE: byte stride between columns (col-major) / rows (row-major) of a
@@ -290,16 +496,11 @@ namespace MobileGL::MG_State::GLState {
// out as std140 (packed/shared are coerced), so this matches the offsets glslang reports. For
// every GL 3.3 float matrix this evaluates to 16, independent of majorness.
GLint GetActiveUniformMatrixStride(Uint index) const {
const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
const auto& uniform = UniformAt(TProgramUniformIndex(index));
if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1;
const glslang::TType* type = uniform.getType();
if (type == nullptr || !type->isMatrix()) return 0;
glslang::TLayoutMatrix layoutMatrix = type->getQualifier().layoutMatrix;
if (layoutMatrix == glslang::ElmNone) {
layoutMatrix = Artifacts().program->getUniformBlock(uniform.index).getType()->getQualifier().layoutMatrix;
}
const bool rowMajor = (layoutMatrix == glslang::ElmRowMajor);
const int strideVectorComponents = rowMajor ? type->getMatrixCols() : type->getMatrixRows();
if (!uniform.type.isMatrix) return 0;
const bool rowMajor = (uniform.type.layoutMatrix == static_cast<Int>(glslang::ElmRowMajor));
const int strideVectorComponents = rowMajor ? uniform.type.matrixCols : uniform.type.matrixRows;
constexpr int scalarSize = 4; // GL 3.3 core uniform matrices are float
const int vectorAlignment = (strideVectorComponents <= 1) ? scalarSize
: (strideVectorComponents == 2) ? 2 * scalarSize
@@ -307,21 +508,39 @@ namespace MobileGL::MG_State::GLState {
return (vectorAlignment + 15) & ~15; // std140 round-up to a vec4
}
const glslang::TType* GetUniformTType(Uint location) const {
auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]);
return uniform.getType();
// The flattened type of the uniform at `location`. This is what replaced
// GetUniformTType(): the same information, owned by the program instead of by a
// glslang pool, so it stays valid for a link served from the L1 translation memo.
const TypeFacts& GetUniformTypeFacts(Uint location) const {
return UniformAt(Artifacts().uniformIndexInTProgram[location]).type;
}
Bool IsUniformOpaqueAtLocation(Uint location) const { return GetUniformTType(location)->isOpaque(); }
// Replaces GetUniformTType(), which used to hand a raw glslang::TType* - into a
// pool the program no longer necessarily owns - out to the DirectGLES image-format
// bake. These are the only three things any caller ever read off it.
Bool UniformHasDeclaredImageFormat(Uint location) const {
return UniformAt(Artifacts().uniformIndexInTProgram[location]).type.hasFormat;
}
Uint GetUniformDeclaredImageFormat(Uint location) const {
return UniformAt(Artifacts().uniformIndexInTProgram[location]).type.layoutFormat;
}
// Matrix column count, 0 for a non-matrix. The global-UBO fallback allocator sizes a
// matrix slot from it.
Int GetUniformMatrixColumns(Uint location) const {
const auto& uniform = UniformAt(Artifacts().uniformIndexInTProgram[location]);
return uniform.type.isMatrix ? uniform.type.matrixCols : 0;
}
Bool IsUniformOpaqueAtLocation(Uint location) const {
return UniformAt(Artifacts().uniformIndexInTProgram[location]).type.isOpaque;
}
const String& GetUniformName(Uint location) const {
auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]);
return uniform.name;
return UniformAt(Artifacts().uniformIndexInTProgram[location]).name;
}
const String& GetActiveUniformName(Uint index) const {
auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
return uniform.name;
return UniformAt(TProgramUniformIndex(index)).name;
}
// Sentinel for a uniform location without global-UBO backing storage (should not
// survive linking: GenerateBinary falls back to tail-allocated scratch storage).
@@ -346,26 +565,47 @@ namespace MobileGL::MG_State::GLState {
: kInvalidUniformOffset;
}
Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); }
// Bytes a uniform actually occupies in the global UBO, which is not its GL type size,
// for two reasons. std140 pads each column of a matrix out to a vec4, so a mat3 spans
// 48 bytes even though only 36 of them carry components. And every 64-bit float in a
// shader is narrowed to 32 bits before the module reaches a backend
// (ShaderTranspiler::DemoteFloat64Pass) - the global UBO is laid out by reflecting that
// demoted module - so a `double` uniform occupies exactly what its float-typed twin
// would, half its GL type size, and a `dmat4` is padded like any other matrix. Anything
// reading or writing a whole uniform's storage - a bounds check, a copy between two
// programs' shadows - wants this rather than GetUniformSizesInBytes.
static SizeT UniformStorageSpanInBytes(const glslang::TType* type, SizeT tightSize) {
if (type != nullptr && type->isMatrix()) {
return static_cast<SizeT>(type->getMatrixCols()) * 4 * sizeof(Float);
// std140 column stride of a matrix uniform in the global UBO: every column is padded out
// to the base alignment of a vec4 for 32-bit components, and of a dvec4 for 64-bit ones -
// except that a 2-ROW double column is a dvec2, whose base alignment is already 16.
// (GL 4.6 core 7.6.2.2 rules 2-4; SPIRV-Cross derives the same numbers, which is what
// makes this agree with the reflected module.)
static SizeT UniformMatrixColumnStride(const TypeFacts& type, const Bool nativeFloat64) {
if (type.isDouble && nativeFloat64) {
return type.matrixRows <= 2 ? 2 * sizeof(GLdouble) : 4 * sizeof(GLdouble);
}
if (type != nullptr && type->getBasicType() == glslang::EbtDouble) {
return 4 * sizeof(Float);
}
// Bytes a uniform actually occupies in the global UBO, which is not its GL type size,
// for two reasons. std140 pads each column of a matrix out to a vec4 (or a dvec4), so a
// mat3 spans 48 bytes even though only 36 of them carry components. And a 64-bit float
// may have been narrowed to 32 before the module reached the backend
// (ShaderTranspiler::DemoteFloat64Pass) - the global UBO is laid out by reflecting
// whichever module was produced - so on a DEMOTED program a `double` uniform occupies
// exactly what its float-typed twin would, half its GL type size, and a `dmat4` is padded
// like any other 32-bit matrix. On a program that kept its doubles it occupies the full
// GL type size and its matrix columns are twice as far apart. `nativeFloat64` is the
// program's own SpirvArtifacts flag, never a live backend read: it describes the modules
// that were actually built. 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 TypeFacts& type, SizeT tightSize,
const Bool nativeFloat64 = false) {
if (type.isMatrix) {
return static_cast<SizeT>(type.matrixCols) * UniformMatrixColumnStride(type, nativeFloat64);
}
if (type.isDouble && !nativeFloat64) {
return tightSize / 2;
}
return tightSize;
}
// Whether this program's modules KEPT their 64-bit floats. Joins phase B, like every
// other question about the global UBO's layout - and it is one: it decides how wide a
// `double` uniform's slot is.
Bool UsesNativeFloat64() const { return Spirv().nativeFloat64; }
SizeT GetUniformStorageSpanInBytes(Uint location) const {
return UniformStorageSpanInBytes(GetUniformTType(location), GetUniformSizesInBytes(location));
return UniformStorageSpanInBytes(GetUniformTypeFacts(location), GetUniformSizesInBytes(location),
UsesNativeFloat64());
}
// ---- "written since link": the per-location dirty set the pipeline composite mirrors from ----
@@ -476,14 +716,14 @@ namespace MobileGL::MG_State::GLState {
return mask;
}
Uint32 GetActiveFragmentOutputLocationMask() const {
if (!Artifacts().program) {
if (Artifacts().pipeOutputReflection.empty()) {
return 0;
}
Uint32 mask = 0;
const Int outputCount = Artifacts().program->getNumPipeOutputs();
const Int outputCount = static_cast<Int>(Artifacts().pipeOutputReflection.size());
for (Int index = 0; index < outputCount; ++index) {
const Int location = static_cast<Int>(Artifacts().program->getPipeOutput(index).layoutLocation());
const Int location = Artifacts().pipeOutputReflection[index].location;
if (location >= 0 && location < 32) {
mask |= (1u << location);
}
@@ -491,38 +731,34 @@ namespace MobileGL::MG_State::GLState {
return mask;
}
Int GetActiveFragmentOutputCount() const {
return Artifacts().program ? Artifacts().program->getNumPipeOutputs() : 0;
return static_cast<Int>(Artifacts().pipeOutputReflection.size());
}
const String& GetActiveFragmentOutputName(Uint index) const {
MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetActiveFragmentOutputName: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().pipeOutputReflection.size()),
"ProgramObject::GetActiveFragmentOutputName: index=%u out of range", index);
return Artifacts().program->getPipeOutput(static_cast<Int>(index)).name;
return Artifacts().pipeOutputReflection[index].name;
}
Int GetFragmentOutputLocation(Uint index) const {
MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetFragmentOutputLocation: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().pipeOutputReflection.size()),
"ProgramObject::GetFragmentOutputLocation: index=%u out of range",
index);
return static_cast<Int>(Artifacts().program->getPipeOutput(static_cast<Int>(index)).layoutLocation());
return Artifacts().pipeOutputReflection[index].location;
}
GLint GetActiveFragmentOutputArraySize(Uint index) const {
MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetActiveFragmentOutputArraySize: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().pipeOutputReflection.size()),
"ProgramObject::GetActiveFragmentOutputArraySize: index=%u out of range", index);
return Artifacts().program->getPipeOutput(static_cast<Int>(index)).size;
return Artifacts().pipeOutputReflection[index].size;
}
GLenum GetFragmentOutputType(Uint index) const {
MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetFragmentOutputType: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().pipeOutputReflection.size()),
"ProgramObject::GetFragmentOutputType: index=%u out of range",
index);
return Artifacts().program->getPipeOutput(static_cast<Int>(index)).glDefineType;
return Artifacts().pipeOutputReflection[index].glDefineType;
}
GLenum GetAttribType(Uint index) const { return Artifacts().attribTypes[index]; }
const String& GetAttribName(Uint index) const { return Artifacts().attribs[index]; }
GLenum GetActiveAttribType(Uint index) const { return Artifacts().program->getPipeInput(static_cast<Int>(index)).glDefineType; }
GLint GetActiveAttribArraySize(Uint index) const { return Artifacts().program->getPipeInput(static_cast<Int>(index)).size; }
GLenum GetActiveAttribType(Uint index) const { return Artifacts().pipeInputReflection[index].glDefineType; }
GLint GetActiveAttribArraySize(Uint index) const { return Artifacts().pipeInputReflection[index].size; }
// The Vulkan-semantics parse reflects the vertex builtins under their SPIR-V names;
// GL must keep reporting the GL spellings (glGetActiveAttrib and the program-input
// resource queries enumerate builtins).
@@ -534,7 +770,7 @@ namespace MobileGL::MG_State::GLState {
return name;
}
const String& GetActiveAttribName(Uint index) const {
return NormalizeBuiltinPipeInputName(Artifacts().program->getPipeInput(static_cast<Int>(index)).name);
return NormalizeBuiltinPipeInputName(Artifacts().pipeInputReflection[index].name);
}
// PHASE B, all three (see EnsureSpirvJoined): the shadow buffer's layout is decided
// by the OPTIMIZED SPIR-V, so it does not exist until the SPIR-V job has settled - and
@@ -627,7 +863,7 @@ namespace MobileGL::MG_State::GLState {
// 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()) {
if (GetUniformTypeFacts(location).isImage) {
++m_imageUnitVersion;
}
}
@@ -702,23 +938,22 @@ namespace MobileGL::MG_State::GLState {
// 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;
return static_cast<Int>(Artifacts().pipeInputReflection.size());
}
// GL-visible uniform blocks only: the synthesized MGL_GLOBAL_UBO the relaxed parse
// materializes for default-block uniforms is filtered out by DoReflection.
// Size of the BLOCK index space - every block the relaxed parse produced except the
// synthesized MGL_GLOBAL_UBO, which DoReflection filters out. NOT the answer to
// glGetProgramiv(GL_ACTIVE_UNIFORM_BLOCKS): storage blocks and atomic counter blocks
// live in here too, and GetGlUniformBlockCount() is the one that excludes them.
Int GetActiveUniformBlocksCount() const { return static_cast<Int>(Artifacts().glBlockIndexToTProgram.size()); }
GLuint GetComputeLocalSize(Uint dim) const {
const auto& program = Artifacts().program;
return program ? program->getLocalSize(static_cast<Int>(dim)) : 0;
return dim < 3u ? Artifacts().computeLocalSize[dim] : 0u;
}
Int GetActiveAttributesMaxLength() const { return Artifacts().attribInNameMaxLength; }
Int GetActiveUniformBlocksMaxNameLength() const { return Artifacts().uniformBlockNameMaxLength; }
// Answers in the BLOCK space, so it resolves storage and atomic counter blocks too -
// the backends reach those by name. glGetUniformBlockIndex must NOT: use
// GetGlUniformBlockIndex() for the GL entry point.
Uint GetUniformBlockIndex(const char* name) const {
auto it = Artifacts().uniformBlockIndexByName.find(name);
if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
@@ -729,21 +964,20 @@ namespace MobileGL::MG_State::GLState {
if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
return 0xFFFFFFFFu; // GL_INVALID_INDEX
}
Bool IsActiveUniformBlock(Uint index) const {
if (index >= GetActiveUniformBlocksCount()) return false;
return true;
}
// Takes a BLOCK index. The GL entry points validate their argument against the
// GL_UNIFORM_BLOCK space with IsActiveGlUniformBlock() first and translate; the bound
// test here is only the range of the space this index actually lives in.
Uint GetUBOSizeAt(Uint index) const {
if (!IsActiveUniformBlock(index)) return 0;
if (index >= Artifacts().glBlockIndexToTProgram.size()) return 0;
// glslang reports the unpadded end offset of the last member, but a std140 block
// (like a std140 struct) occupies a vec4-rounded size, and that is what the
// backend compiles: ES drivers reject draws whose bound UBO range is smaller
// than the block (a block ending in ivec3 reported 12 while the driver needs 16).
return (Artifacts().program->getUniformBlock(Artifacts().glBlockIndexToTProgram[index]).size + 15u) & ~15u;
return (static_cast<Uint>(BlockAt(Artifacts().glBlockIndexToTProgram[index]).size) + 15u) & ~15u;
}
const String& GetUniformBlockName(Uint index) const {
auto& ubo = Artifacts().program->getUniformBlock(Artifacts().glBlockIndexToTProgram[index]);
const auto& ubo = BlockAt(Artifacts().glBlockIndexToTProgram[index]);
return ubo.name;
}
@@ -764,17 +998,20 @@ namespace MobileGL::MG_State::GLState {
// fills GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES, so the two queries always agree
// (glslang's numMembers counts declared members, which diverges from the reflected
// entry list for struct arrays and arrayed block instances).
// Takes a BLOCK index, and scans in the block space: GetUniformBlockMemberOwnerIndex
// answers there, so pairing it with the GL_UNIFORM_BLOCK-space
// GetActiveUniformBlockIndex would compare two different numberings.
Int GetUniformBlockActiveUniformCount(Uint index) const {
const Int ownerIndex = static_cast<Int>(GetUniformBlockMemberOwnerIndex(index));
Int count = 0;
for (Uint uniformIndex = 0; uniformIndex < Artifacts().activeUniformCount; ++uniformIndex) {
if (GetActiveUniformBlockIndex(uniformIndex) == ownerIndex) ++count;
if (GetActiveUniformOwnerBlockIndex(uniformIndex) == ownerIndex) ++count;
}
return count;
}
Bool IsUniformBlockReferencedByStage(Uint index, EShLanguage stage) const {
const auto& ubo = Artifacts().program->getUniformBlock(Artifacts().glBlockIndexToTProgram[index]);
const auto& ubo = BlockAt(Artifacts().glBlockIndexToTProgram[index]);
const auto stageMask = static_cast<EShLanguageMask>(1 << stage);
return (ubo.stages & stageMask) != 0;
}
@@ -855,8 +1092,6 @@ namespace MobileGL::MG_State::GLState {
// (MG_Impl/GLImpl/Program/ProgramInterface.cpp), which has to enumerate buffer
// blocks, buffer variables, atomic counters and per-stage reference masks. Null
// until a link has succeeded. Read through the join gate like everything else.
const glslang::TProgram* GetReflection() const { return Artifacts().program.get(); }
Int GetShaderIndexByStage(ShaderStage stage) const {
auto it = std::find_if(m_shaders.begin(), m_shaders.end(), [stage](const SharedPtr<ShaderObject>& shader) {
return shader->GetShaderStage() == stage;
@@ -909,9 +1144,50 @@ namespace MobileGL::MG_State::GLState {
// what makes "every read of link output joins the pending link" a property the
// compiler checks rather than a review item - a new reader cannot spell the field
// without going through the gate.
// ---- the owned mirror of glslang's reflection ----
//
// WHY THIS EXISTS. Every GL query about a linked program used to be answered by
// asking the live glslang::TProgram - program->getUniform(i).getType()->isMatrix()
// and friends. That made the TProgram part of the program's PERMANENT state, which
// in turn made the whole front end (parse + link) unskippable: the L1 shader
// translation memo could hand back the SPIR-V but the reflection still had to be
// rebuilt from a freshly parsed AST.
//
// These three tables are a snapshot of everything the query surface ever reads off
// the TProgram, in PLAIN OWNED VALUES - no TType*, no TString, nothing pointing into
// a glslang pool. Taken once at the tail of DoReflection (SnapshotGlslangReflection),
// they are copyable, immutable after the link, and safe to memoize and share between
// ProgramObjects and threads. Once they are filled, `program` is dead weight to
// everything except DoReflection itself.
//
// INDEXED BY TPROGRAM INDEX, deliberately: that is the space uniformIndexInTProgram,
// glUniformIndexToTProgram and tProgramUniformIndexToGl already speak, so every
// accessor that used to call program->getUniform(i) indexes uniformReflection[i]
// instead, unchanged in every other respect.
struct LinkArtifacts {
// Live only between LinkProgram() and the end of DoReflection. Everything after
// that reads the owned mirror below; a link served from the L1 memo never
// constructs one at all, so this is null for such a program and MUST NOT be
// dereferenced outside DoReflection.
SharedPtr<glslang::TProgram> program;
// The owned reflection snapshot. Indexed by TProgram index; see the structs above.
Vector<UniformReflection> uniformReflection;
Vector<BlockReflection> blockReflection;
Vector<PipeInputReflection> pipeInputReflection;
Vector<PipeOutputReflection> pipeOutputReflection;
// Program-level scalars glslang answers off the linked intermediates.
// Whether the program's LAST stage is the fragment stage. A color number - and so a
// color index - exists only there; a separable tess/geometry/vertex program's
// outputs are varyings and must report -1 (KHR-GL43.program_interface_query.
// separate-programs-tess-control).
Bool lastStageIsFragment = false;
Array<GLuint, 3> computeLocalSize{};
// Replaces program->getUniformIndex(name). Maps the reflected name to its
// TProgram uniform index.
UnorderedMap<String, Int> uniformIndexByName;
// Attributes (Vertex in)
Vector<String> attribs;
Vector<GLenum> attribTypes;
@@ -928,9 +1204,32 @@ namespace MobileGL::MG_State::GLState {
Vector<Int> tProgramUniformIndexToGl;
Vector<Int> glBlockIndexToTProgram;
Vector<Int> tProgramBlockIndexToGl;
// Per-link merged snapshot of the attached shaders' lexically extracted
// layout(location = N) default-block uniform qualifiers (the relaxed parse drops
// them from reflection; the DoReflection assigner restores them from here).
// GL_UNIFORM_BLOCK index space: ACTUAL uniform blocks only, a strict subsequence of
// glBlockIndexToTProgram above.
//
// That list is the BLOCK space - everything the relaxed parse produced except
// MGL_GLOBAL_UBO - and it is what the backends walk and what every block-keyed table
// here (uniformBlockBinding, uniformBlockIndexByName, blockReflection ordering) is
// indexed by. It is NOT the GL uniform-block list: MobileGL does not pass
// EShReflectionSeparateBuffers to buildReflection, so glslang routes BUFFER blocks
// through indexToUniformBlock too, and the list therefore also carries every shader
// storage block and every synthesized gl_AtomicCounterBlock_N. GL 4.6 core 7.6 gives
// those their own enumerations (GL_SHADER_STORAGE_BLOCK and
// GL_ACTIVE_ATOMIC_COUNTER_BUFFERS respectively), and GL_ACTIVE_UNIFORM_BLOCKS /
// glGetActiveUniformBlock*/glGetUniformBlockIndex must not see either.
//
// Kept as a SECOND space rather than filtering the first in place: DirectGLES assigns
// one ESSL uniform-buffer binding point per entry of the block list as it walks it
// (Managers.cpp CacheResourceLocations and the matching per-draw loop in
// DirectGLES.cpp), so compacting that list would renumber every backend binding
// point, and tProgramBlockIndexToGl[i] < 0 is what DoReflection and
// BuildGlobalUboRouting read as "member of the synthesized global UBO".
Vector<Int> glUniformBlockIndexToBlock; // GL uniform-block index -> block index
Vector<Int> blockIndexToGlUniformBlock; // block index -> GL uniform-block index (-1)
// Per-link merged snapshot of the layout(location = N) qualifiers the attached
// shaders' default-block uniforms declared, as glslang recorded them at the point
// its relaxed remap dropped them (the relaxed parse drops them from reflection; the
// DoReflection assigner restores them from here).
UnorderedMap<String, Int> linkedExplicitUniformLocations;
// Per-link snapshot of the default-block uniform INITIALIZERS the attached shaders
// declared ("uniform int i = 1;"). Desktop GLSL says that value is what the uniform
@@ -955,6 +1254,10 @@ namespace MobileGL::MG_State::GLState {
Vector<Int> uniformIndexInTProgram;
// ditto. Will be set at glUniform1i
Vector<Int> uniformSamplerOrImageUnitIndex;
// Sampler/image layout(binding = N) initial texture/image units, captured by
// TMglGlslIoResolver at mapIO's collect callback - the last point at which the
// qualifier still says what the shader declared. An OUTPUT of the link, not an
// input to it: nothing supplies this map, the resolver fills it.
UnorderedMap<String, Uint> explicitOpaqueUniformBindings;
// Ordered by uniform block index
@@ -969,7 +1272,21 @@ namespace MobileGL::MG_State::GLState {
Vector<Int> uniformBlockBinding;
// glShaderStorageBlockBinding overrides, keyed by GL block name. See
// SetShaderStorageBlockBinding for why this one is by name and not by index.
//
// ALSO SEEDED AT LINK, by ProgramLinkTask::SeedDefaultStorageBlockBindings, with the
// GL-mandated binding 0 for every storage block whose shader declared no
// layout(binding = N). Those blocks have no other way to be told apart from a block
// that declared one: glslang's IO mapper invents a binding and writes it into the
// qualifier, so the reflection reports the invention. A seed is therefore "GL's
// default binding for this block", and a later glShaderStorageBlockBinding simply
// overwrites it - default and rebind travel one path.
UnorderedMap<String, Int> shaderStorageBlockBinding;
// Block type names of the storage blocks the program's shaders declared with NO
// layout(binding = N). Input to the seeding above; filled during mapIO by
// TMglGlslIoResolver, which is the last observer that can still tell a declared
// binding from an invented one - and, unlike the per-shader lexer this replaced,
// sees the declaration with its macros expanded.
std::set<String> storageBlocksWithoutBinding;
Uint activeUniformCount = 0;
Uint maxUniformLocation = 0;
@@ -1027,6 +1344,15 @@ namespace MobileGL::MG_State::GLState {
// not drawable, which the backends already express through their link-status
// gates.
Bool spirvStatus = false;
// Whether these modules KEPT their 64-bit floats instead of being narrowed to 32
// (ShaderTranspiler::DemoteFloat64Pass). Decided per PROGRAM, never per module - the
// global UBO is one buffer all stages read, so two stages disagreeing about whether a
// `uniform double` occupies 4 or 8 bytes would put every uniform after it at a
// different offset in each. Recorded here rather than re-derived from the backend
// because it is the layout THESE modules were built with: it is what the routing
// table's offsets mean, and glUniform*d / glGetUniform*v have to write and read the
// width the shader actually declares.
Bool nativeFloat64 = false;
};
// ---- artifacts-only helpers, shared with ProgramLinkTask ----
@@ -1042,6 +1368,14 @@ namespace MobileGL::MG_State::GLState {
// ordering is explicit and nothing is exempt.
static void ResetLinkArtifacts(LinkArtifacts& artifacts);
// The owned reflection snapshot, for the program-interface query layer. Replaces
// GetReflection(), which handed out the live glslang::TProgram - the last thing that
// forced a linked program to keep its parse alive.
const LinkArtifacts& GetLinkReflection() const {
EnsureLinkJoined();
return Artifacts();
}
static Bool IsValidUniformLocation(const LinkArtifacts& artifacts, Int location) {
if (location < 0 || location > static_cast<Int>(artifacts.maxUniformLocation)) return false;
if (static_cast<SizeT>(location) >= artifacts.uniformIndexInTProgram.size()) return false;
@@ -1057,12 +1391,24 @@ namespace MobileGL::MG_State::GLState {
// for both. GL 3.3 core uniforms are always sized. Takes a TProgram uniform index (the space
// the artifacts' uniformIndexInTProgram stores).
static GLint GetUniformArraySizeByTIndex(const LinkArtifacts& artifacts, Int tIndex) {
const auto& uniform = artifacts.program->getUniform(tIndex);
const glslang::TType* type = uniform.getType();
if (type != nullptr && type->isSizedArray()) {
return type->getOuterArraySize();
return UniformAtIn(artifacts, tIndex).arraySize;
}
// Bounds-checked mirror lookup. Out of range yields a default-constructed entry
// rather than UB, which is the same shape the phase-B getters use: a program whose
// reflection is missing must stay answerable, not crash the query surface.
static const UniformReflection& UniformAtIn(const LinkArtifacts& artifacts, Int tIndex) {
static const UniformReflection kEmpty;
if (tIndex < 0 || static_cast<SizeT>(tIndex) >= artifacts.uniformReflection.size()) return kEmpty;
return artifacts.uniformReflection[tIndex];
}
const UniformReflection& UniformAt(Int tIndex) const { return UniformAtIn(Artifacts(), tIndex); }
const BlockReflection& BlockAt(Int tBlockIndex) const {
static const BlockReflection kEmpty;
if (tBlockIndex < 0 || static_cast<SizeT>(tBlockIndex) >= Artifacts().blockReflection.size()) {
return kEmpty;
}
return uniform.size < 1 ? 1 : uniform.size;
return Artifacts().blockReflection[tBlockIndex];
}
// Blocks until a pending link has published its artifacts. Public because a few call
@@ -12,6 +12,8 @@
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_State/GLState/ProgramState/ProgramTranslationCache.h>
#include <MG_Util/ShaderTranspiler/TranslationCache.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <cstring>
@@ -95,18 +97,39 @@ namespace MobileGL::MG_State::GLState {
// 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) {
if (!handoff.ready) {
// Phase A did not reach its tail (it failed the link, or was cancelled mid-body).
// Publish nothing; spirvStatus stays false.
return;
}
// A TProgram is required only to GENERATE. A link served from the L1 memo has none by
// construction - that is the entire point of the widened payload - and its SPIR-V and
// routing tables arrive ready-made in cachedSpirv.
if (!handoff.cachedSpirv && !handoff.reflection.program) return;
// An L1 hit already carries everything this phase would have produced. Publish it
// and stop: no GlslangToSpv, no spirv-opt, no routing pass.
if (handoff.cachedSpirv) {
artifacts = *handoff.cachedSpirv;
MGLOG_D("ProgramObject %u: L1 cache hit - %zu SPIR-V module(s) and the global-UBO "
"routing reused",
externalIndex, artifacts.generatedSpirv.size());
return;
}
MGLOG_D("ProgramObject %u: Starting SPIR-V generation", externalIndex);
const Bool deferOutputValidationForDirectVulkan =
m_phaseA->in.env != nullptr && m_phaseA->in.env->backend == BackendType::DirectVulkan;
const Bool enableSpirvValidation = m_phaseA->in.enableSpirvValidation;
artifacts.enableSpirvValidation = enableSpirvValidation;
GenerateSpirv(handoff, externalIndex, deferOutputValidationForDirectVulkan, enableSpirvValidation);
// Whether this backend consumes 64-bit floats itself. Read off the SNAPSHOT, like every
// other environment question this node asks: a worker may not touch
// MG_Backend::pActiveBackendObject, and the answer has to be the one the L1 key was built
// with (ProgramLinkTask::BuildSpirvCacheKey reads the same env) or a memo written under
// one answer could be handed back under the other.
const Bool nativeFloat64 = m_phaseA->in.env != nullptr && m_phaseA->in.env->ConsumesFloat64Natively();
GenerateSpirv(handoff, externalIndex, deferOutputValidationForDirectVulkan, enableSpirvValidation,
nativeFloat64);
// 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
@@ -117,13 +140,18 @@ namespace MobileGL::MG_State::GLState {
// * 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.
// * L1c-HIT shaders (the compile published a verdict and never parsed, so the parse
// was made on demand by ClaimParsedShader): freed here in full, exactly like a
// CAS loser and for the same reason - the handoff is their only owner. This
// category did not exist before the translation memo's compile half, and it makes
// the clear below strictly more effective than the paragraph below describes.
// * CAS-WINNER shaders (one shader object linked into one program, whose compile
// MISSED L1c and therefore stored its parse): NOT freed here. The winner branch
// returns a COPY of ShaderCompileTask::artifacts.shader 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
@@ -137,13 +165,30 @@ namespace MobileGL::MG_State::GLState {
MGLOG_D("ProgramObject %u: Building global-UBO routing tables", externalIndex);
BuildGlobalUboRouting(handoff, externalIndex);
// The completed front end goes into the L1 memo HERE, where both halves exist: phase
// A's LinkArtifacts (carried in the handoff) and this phase's SpirvArtifacts.
//
// Only a clean run is memoized. A failed optimizer run leaves a module as whatever the
// chain got to before it gave up, and that is exactly the binary no other program
// should ever be handed.
if (artifacts.spirvStatus && handoff.spirvCacheKey.Valid() && handoff.linkArtifactsForCache) {
auto payload = MakeShared<ProgramTranslationResult>();
payload->link = *handoff.linkArtifactsForCache;
payload->link.program.reset(); // belt and braces: never memoize a glslang arena
payload->spirv = artifacts;
const SizeT payloadBytes = ProgramTranslationResultBytes(*payload);
GetProgramTranslationCache().Insert(handoff.spirvCacheKey,
ProgramTranslationResultPtr(Move(payload)),
payloadBytes);
}
MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", externalIndex,
artifacts.generatedSpirv.size());
}
void ProgramSpirvTask::GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, const Uint externalIndex,
const Bool deferOutputValidationForDirectVulkan,
const Bool enableSpirvValidation) {
const Bool enableSpirvValidation, const Bool nativeFloat64) {
/* As we passed first stage compilation/linking,
* we'll assume all the operations here should
* pass. We may be able to employ some optimizations
@@ -171,12 +216,39 @@ namespace MobileGL::MG_State::GLState {
MGLOG_D("ProgramObject %u: GenerateSpirv - generated %zu SPIR-V modules", externalIndex,
artifacts.generatedSpirv.size());
// The fp64 verdict, taken ONCE for the whole program and before any module is touched.
//
// Per program rather than per module, and that is forced by the global UBO: all stages
// read one buffer whose layout is derived by reflecting the modules, so a vertex stage
// that stored a `uniform double` as 4 bytes next to a fragment stage that stored it as 8
// would put every uniform after it somewhere different in each, and the routing table
// (one offset per location) could only describe one of them.
//
// The exception itself is the vertex INPUT: no backend here can fetch a 64-bit attribute,
// and VertexInputStateFactory picks the format from the VAO attribute without ever seeing
// what the shader declared, so a Float64 input would meet a narrowed float32 stream. One
// such stage demotes the whole program, which is exactly what every backend without
// native fp64 does to it anyway.
Bool keepFloat64 = nativeFloat64;
if (keepFloat64) {
for (const auto& spv : artifacts.generatedSpirv) {
if (ShaderCompiler::ModuleDeclaresFloat64VertexInput(spv)) {
keepFloat64 = false;
MGLOG_D("ProgramObject %u: a vertex stage declares a 64-bit float input; demoting the "
"whole program despite native fp64",
externalIndex);
break;
}
}
}
artifacts.nativeFloat64 = keepFloat64;
// Linked SPIR-V generated, sanitize and optimize it
Bool allOptimized = true;
{
for (auto& spv : artifacts.generatedSpirv) {
auto success = ShaderCompiler::SanitizeAndOptimizeBinary(
spv, spv, !deferOutputValidationForDirectVulkan, enableSpirvValidation);
spv, spv, !deferOutputValidationForDirectVulkan, enableSpirvValidation, keepFloat64);
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
@@ -296,22 +368,25 @@ namespace MobileGL::MG_State::GLState {
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.
const auto& uniform =
ProgramObject::UniformAtIn(reflection, reflection.uniformIndexInTProgram[location]);
if (uniform.type.isOpaque) continue;
// Member of a named uniform block: not settable through glUniform*, so it needs
// no global-UBO shadow storage. tProgramBlockIndexToGl[i] >= 0 means block i is
// GL-visible, i.e. NOT the synthesized MGL_GLOBAL_UBO - which is exactly what the
// strstr(GLOBAL_UBO_NAME) test this replaced was asking, without needing the
// TProgram to spell the block name.
if (uniform.index >= 0 &&
uniform.index < static_cast<Int>(reflection.tProgramBlockIndexToGl.size()) &&
reflection.tProgramBlockIndexToGl[uniform.index] >= 0) {
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;
if (uniform.type.isMatrix) {
slotSize = static_cast<SizeT>(uniform.type.matrixCols) * 16u;
}
slotSize = (slotSize + 15u) & ~static_cast<SizeT>(15u);
const SizeT slotOffset = (artifacts.globalUboScratch.size() + 15u) & ~static_cast<SizeT>(15u);
@@ -66,7 +66,8 @@ namespace MobileGL::MG_State::GLState {
void RunBody() override;
void GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex,
Bool deferOutputValidationForDirectVulkan, Bool enableSpirvValidation);
Bool deferOutputValidationForDirectVulkan, Bool enableSpirvValidation,
Bool nativeFloat64);
void BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex);
// Worker-side MGLOG replacement, replayed by the join on the GL thread. Same reason as
@@ -0,0 +1,82 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.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 "ProgramTranslationCache.h"
namespace MobileGL::MG_State::GLState {
namespace {
// ---- L1 caps: 48 entries / 24 MiB ----
//
// Both numbers moved when the payload grew from "the SPIR-V modules" to "the whole
// front end". An entry is now the stages' preprocessed source (the key), the SPIR-V,
// the reflection snapshot and the global-UBO shadow - roughly twice what it was - so
// the byte budget doubled and the entry count came down to keep the worst case in the
// same place on a phone.
//
// The shape of the choice has not changed: this cache exists for REPETITION, not
// coverage. A KHR-GL33.texture_swizzle smoke case builds 2592 programs out of fewer
// than ten distinct ones, so a handful of entries serves it completely; an Iris
// shaderpack load is ~300-600 MOSTLY DISTINCT programs that would never hit however
// large the cache is, so a bigger cap there buys nothing and costs resident memory.
// 48 is comfortably above the distinct-program count of every repetition workload
// measured, and 24 MiB bounds the pathological case - a pack whose ~100 KB stages
// really are re-linked - at roughly three times the existing 8 MiB
// ShaderPreprocessCache budget, which is the other memo on this path.
constexpr SizeT kMaxEntries = 48;
constexpr SizeT kMaxBytes = 24u * 1024u * 1024u;
SizeT StringsBytes(const Vector<String>& values) {
SizeT bytes = 0;
for (const String& value : values) bytes += value.size() + sizeof(String);
return bytes;
}
SizeT ResourcesBytes(const Vector<ProgramObject::ResourceReflection>& records) {
SizeT bytes = records.size() * sizeof(ProgramObject::ResourceReflection);
for (const auto& record : records) bytes += record.name.size();
return bytes;
}
} // namespace
// Approximate on purpose: it feeds a budget, not an allocator. It counts the things that
// actually scale with shader size - the SPIR-V, the reflection names, the UBO shadow -
// and ignores per-entry fixed overhead.
SizeT ProgramTranslationResultBytes(const ProgramTranslationResult& result) {
SizeT bytes = 0;
for (const auto& module : result.spirv.generatedSpirv) bytes += module.size() * sizeof(unsigned);
bytes += result.spirv.uniformOffsets.size() * sizeof(Uint);
bytes += result.spirv.globalUboScratch.size();
bytes += ResourcesBytes(result.link.uniformReflection);
bytes += ResourcesBytes(result.link.blockReflection);
bytes += ResourcesBytes(result.link.pipeInputReflection);
bytes += ResourcesBytes(result.link.pipeOutputReflection);
bytes += StringsBytes(result.link.attribs);
bytes += StringsBytes(result.link.xfbInterfaceNames);
bytes += result.link.infoLog.size();
return bytes;
}
MG_Util::ShaderTranspiler::BoundedTranslationCache<ProgramTranslationResult>&
GetProgramTranslationCache() {
// DELIBERATELY LEAKED - see the same note on the L2 cache in
// MG_Util/ShaderTranspiler/TranslationCache.cpp. A function-local static OBJECT
// registers its destructor at first use, and first use here is a ShaderCompilePool
// worker; ShaderCompilePool's own atexit drain sentinel is registered strictly
// earlier, and exit handlers run in reverse order - so the cache would be destroyed
// while workers were still inserting into it. A function-local static POINTER is
// trivially destructible and registers no exit handler at all.
static auto* const kCache =
new MG_Util::ShaderTranspiler::BoundedTranslationCache<ProgramTranslationResult>(
"ShaderTranslationCache L1 (GLSL->front end)", kMaxEntries, kMaxBytes);
return *kCache;
}
void ClearProgramTranslationCache() { GetProgramTranslationCache().Clear(); }
void LogProgramTranslationCacheStats() { GetProgramTranslationCache().LogStats(); }
} // namespace MobileGL::MG_State::GLState
@@ -0,0 +1,69 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.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/ProgramObject.h>
#include <MG_Util/ShaderTranspiler/TranslationCache.h>
namespace MobileGL::MG_State::GLState {
// ===================================================================================
// L1 of the shader translation memo: THE WHOLE FRONT END of one glLinkProgram.
//
// A hit skips the glslang link and mapIO, GlslangToSpv, the 11-pass
// SanitizeAndOptimizeBinary chain, buildReflection, and the global-UBO routing pass. No
// TProgram is constructed at all - which is only possible because the GL query surface no
// longer reads one (see ProgramObject::UniformReflection and
// ProgramLinkTask::SnapshotGlslangReflection).
//
// IT DOES NOT SKIP THE PARSE, and no widening of this payload could: the parse belongs to
// glCompileShader, a different entry point one job earlier, and it has already run by the
// time a link looks this key up. Skipping it is L1c's job - the compile half of the memo,
// in MG_Util/ShaderTranspiler/TranslationCache.h. The two together are what make a
// repeated program build construct no glslang object of any kind; either one alone leaves
// roughly half the front end on the hot path (~322 us of parse against a ~650 us
// CTS-shaped program build, and 1.45-1.48x measured on device with L1 alone).
//
// WHY THE PAYLOAD IS THE WHOLE THING rather than just the SPIR-V: the frontend answers
// glGetActiveUniform, glGetProgramResource*, glGetUniformLocation and the rest out of
// LinkArtifacts, and glUniform*/glGetUniform* out of SpirvArtifacts. Caching only the
// modules would have left the link on the hot path to rebuild exactly the data the
// payload can carry.
//
// WHY IT LIVES HERE AND NOT IN MG_Util: the payload is a ProgramObject::LinkArtifacts
// plus a ProgramObject::SpirvArtifacts, and MG_Util must not depend on MG_State. The
// KEY is plain bytes and stays in MG_Util (BuildSpirvTranslationKey), so both layers
// agree on exactly one definition of "the same front-end input".
//
// EVERYTHING IN THE PAYLOAD IS PLAIN OWNED DATA. `link.program` is null by construction:
// the whole point is that a hit never has a glslang arena to point into. Both structs
// were audited field by field - the only member that ever pointed into glslang-owned
// memory was `program` itself, and TUniformInitializer / XfbVarying, which look like
// glslang types, are std::string + std::vector aggregates.
struct ProgramTranslationResult {
// program == nullptr, always. Asserted at insert.
ProgramObject::LinkArtifacts link;
ProgramObject::SpirvArtifacts spirv;
};
using ProgramTranslationResultPtr = SharedPtr<const ProgramTranslationResult>;
SizeT ProgramTranslationResultBytes(const ProgramTranslationResult& result);
// Process-global, and safe to be: the FRONT-END environment fingerprint is in the key
// (see CompileEnv::frontendFingerprint), so a program built under one context's glslang
// limits can never be handed to a context with different ones - while two contexts on
// DIFFERENT GPUs that agree on those limits deliberately share entries.
//
// Global rather than per-context because the producer runs on a ShaderCompilePool worker
// and must not reach MG_State::pGLContext.
MG_Util::ShaderTranspiler::BoundedTranslationCache<ProgramTranslationResult>&
GetProgramTranslationCache();
void ClearProgramTranslationCache();
void LogProgramTranslationCacheStats();
} // namespace MobileGL::MG_State::GLState
@@ -8,13 +8,16 @@
#include "ShaderCompileTask.h"
#include <MG_State/GLState/BufferState/BufferState.h>
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
#include <MG_Util/ShaderTranspiler/TranslationCache.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <glslang/Include/PoolAlloc.h>
#include <algorithm>
#include <charconv>
namespace {
@@ -137,11 +140,30 @@ namespace {
return std::nullopt;
}
// What glGetIntegerv(GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS) answers, recomputed rather than
// queried: the compile runs on a worker with no context, and the pname is not a plain backend
// parameter - the getter caps the backend's count by the state layer's fixed binding-point
// array (GL_Getter's GetIndexedBufferQueryPointCount). A shader must be judged against the
// number the application was told, not against either half of it.
static MobileGL::Int MaxShaderStorageBufferBindings(
const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) {
const MobileGL::Int frontendPoints =
static_cast<MobileGL::Int>(MobileGL::MG_State::GLState::BufferBindingPointCount);
if (!env.HasBackend()) return frontendPoints;
return std::min<MobileGL::Int>(frontendPoints, std::max<MobileGL::Int>(env.params.MaxShaderStorageBufferBindings, 0));
}
// The half of a compile that depends on nothing but the source text, the stage and the
// environment snapshot: preprocessing, the two lexical rejections, and the two lexical
// side-channel extractions. Split out so P0b layer 2 can memoize exactly this and
// nothing else - the glslang parse stays per-object because its TShader is consume-once.
// Deliberately free of any per-object state so the memo is sound.
// environment snapshot: preprocessing and the three lexical rejections. Split out so P0b
// layer 2 can memoize exactly this and nothing else - the glslang parse stays per-object
// because its TShader is consume-once. Deliberately free of any per-object state so the
// memo is sound.
//
// The side-channel EXTRACTIONS that used to live here are gone: what the relaxed parse
// destroys is now recovered from glslang itself, at the two points where it is destroyed
// (see ShaderCompileArtifacts::explicitUniformLocations and
// TMglGlslIoResolver::reserverResourceSlot). They could not stay here anyway - none of
// them is a function of the unexpanded source text, which is all this half can see.
//
// The compute local-size verdict reads `env` rather than the live backend, and
// env.fingerprint is part of the P0b cache key, so a memo can never be returned against
@@ -172,15 +194,28 @@ namespace {
return result;
}
if (const std::optional<String> bindingError = FindShaderStorageBindingViolation(
result.preprocessedSource, MaxShaderStorageBufferBindings(env))) {
result.outcome = ShaderPreprocessOutcome::ResourceBindingRejected;
result.infoLog = *bindingError;
return result;
}
// NO ATOMIC-COUNTER OFFSET SCAN HERE ANY MORE: glslang raises both rules itself now, at
// the site where its relaxed remap folds the counter into a synthesized block
// (ParseHelper.cpp atomicCounterOffsetCheck, called from vkRelaxedRemapUniformVariable).
// A violation is an ordinary parse failure, so it reaches GL through the same path every
// other compile error does - and, unlike a scan of unexpanded text, it sees an offset
// spelled as a macro or a const expression.
// The parse this feeds runs in the link-compatible configuration (Vulkan-client
// env with relaxed rules): the TShader it produces is what glLinkProgram links and
// what the backends' SPIR-V is generated from - there is no second, GL-client
// parse. The GL frontend semantics the relaxed parse cannot provide are restored
// on top: explicit default-block uniform locations through the lexical
// side-channels below, dead-uniform/global-UBO filtering in
// on top, all of them out of glslang: explicit default-block uniform locations from
// the snapshot the parse takes, opaque bindings and unqualified storage blocks from
// the IO mapper's collect callback, dead-uniform/global-UBO filtering in
// ProgramObject::DoReflection.
result.explicitUniformLocations = ExtractExplicitUniformLocations(result.preprocessedSource);
result.explicitOpaqueBindings = ExtractExplicitOpaqueBindings(result.preprocessedSource);
result.outcome = ShaderPreprocessOutcome::Preprocessed;
return result;
}
@@ -248,26 +283,94 @@ namespace MobileGL::MG_State::GLState {
return;
}
ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(stage),
.sourceStr = shared.preprocessedSource,
.flags = 0,
.env = &compileEnv};
const GLenum glShaderType = MG_Util::ConvertShaderStageToGLEnum(stage);
// Always 0 on both production parse paths; see the key inventory on
// ShaderParseVerdictKeyInputs for why it is in the key regardless.
constexpr Uint32 kShaderCompileFlags = 0;
auto result = ShaderCompiler::CompileShader(attrib);
if (result) {
// ---- L1c of the shader translation memo: the PARSE VERDICT ----------------------
// Everything below this probe - the glslang parse itself - is what a hit skips. What
// a hit does NOT produce is a TShader, and that is deliberate rather than a
// limitation: the TShader is consume-once, so it could never have been shared, and
// nothing on the COMPILE side of GL reads it. GL_COMPILE_STATUS, the info log,
// GL_SHADER_SOURCE, attach/detach and reuse across programs are all answered from
// what the verdict and the source-only half already carry.
//
// The parse is not skipped, it is DEFERRED: ClaimParsedShader re-parses on demand
// when a link finds no stored parse. A link that hits L1 never asks, so the parse
// never happens at all; a link that misses pays exactly one parse, where the CAS
// loser has always paid it. See TranslationCache.h's L1c section.
const TranslationCacheKey parseKey =
ShaderTranslationCacheEnabled()
? BuildShaderParseVerdictKey(ShaderParseVerdictKeyInputs{
.frontendFingerprint = compileEnv.frontendFingerprint,
.shaderType = glShaderType,
.preprocessedSource = StringView(shared.preprocessedSource),
.shaderCompileFlags = kShaderCompileFlags})
: TranslationCacheKey{};
const ShaderParseVerdictPtr verdict =
parseKey.Valid() ? GetShaderParseVerdictCache().Find(parseKey) : nullptr;
// The two branches produce exactly one thing between them - a verdict, plus a TShader
// only when this task actually parsed - and converge on one publish below. Keeping the
// publish common is what stops a hit and a miss from ever drifting on WHAT a compile
// makes observable.
Bool parsedOk = false;
String parseLog;
SharedPtr<glslang::TShader> parsedShader;
UnorderedMap<String, Int> explicitUniformLocations;
if (verdict) {
parsedOk = verdict->parsed;
parseLog = verdict->infoLog;
// From the verdict, not from a parse - see ShaderParseVerdict for why they had to
// move into the payload when their origin moved into glslang.
explicitUniformLocations = verdict->explicitUniformLocations;
MGLOG_D("ShaderCompileTask: shader %u (stage %d) L1c hit - the glslang parse was skipped; "
"compileStatus = %d",
externalIndex, static_cast<Int>(stage), static_cast<Int>(parsedOk));
} else {
const ShaderAttrib attrib{.shaderType = glShaderType,
.sourceStr = shared.preprocessedSource,
.flags = kShaderCompileFlags,
.env = &compileEnv};
auto result = ShaderCompiler::CompileShader(attrib);
parsedOk = result.has_value();
if (parsedOk) {
parsedShader = result.value();
explicitUniformLocations = CollectExplicitUniformLocations(*parsedShader);
} else {
parseLog = result.error().log;
}
if (parseKey.Valid()) {
auto freshVerdict = MakeShared<ShaderParseVerdict>();
freshVerdict->parsed = parsedOk;
// Empty on success by construction, matching what the publish below does with
// the artifacts' own log; the diagnostic the application reads on failure.
freshVerdict->infoLog = parseLog;
freshVerdict->explicitUniformLocations = explicitUniformLocations;
const SizeT verdictBytes = ShaderParseVerdictBytes(*freshVerdict);
GetShaderParseVerdictCache().Insert(parseKey, ShaderParseVerdictPtr(Move(freshVerdict)),
verdictBytes);
}
}
if (parsedOk) {
artifacts.compileStatus = true;
artifacts.shader = result.value();
// NULL ON AN L1c HIT, and that is a supported state rather than an oversight: see
// ShaderCompileArtifacts::shader and ClaimParsedShader.
artifacts.shader = Move(parsedShader);
// Copy, not move: `shared` may alias a cache entry that has to outlive us, and
// `fresh` is about to be handed to the cache.
// `fresh` is about to be handed to the cache. Populated on the hit path too - it
// is what ClaimParsedShader's deferred parse consumes.
artifacts.preprocessedSource = shared.preprocessedSource;
artifacts.explicitUniformLocations = shared.explicitUniformLocations;
artifacts.explicitOpaqueBindings = shared.explicitOpaqueBindings;
artifacts.explicitUniformLocations = Move(explicitUniformLocations);
artifacts.infoLog.clear();
if (shouldPopulateCache) {
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
}
} else {
artifacts.infoLog = result.error().log;
artifacts.infoLog = Move(parseLog);
// Deferred, not logged here, for two reasons. MGLOG from a pool thread interleaves
// mid-line with the GL thread's own output and lands out of order relative to the
// glCompileShader that caused it; diagnostics.logLines is replayed by the join, on
@@ -286,8 +389,6 @@ namespace MobileGL::MG_State::GLState {
if (shouldPopulateCache) {
fresh->outcome = ShaderPreprocessOutcome::ParseFailed;
fresh->infoLog = artifacts.infoLog;
fresh->explicitUniformLocations.clear();
fresh->explicitOpaqueBindings.clear();
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
}
}
@@ -310,10 +411,11 @@ namespace MobileGL::MG_State::GLState {
}
}
// Either another link already consumed the stored parse (and mapIO mutated its
// intermediate), or there never was one. Re-parse the preprocessed source through the
// identical configuration; that costs one glslang parse, which is what GenerateBinary
// used to spend here on EVERY link rather than only on reuse.
// Three ways to be here: another link already consumed the stored parse (and mapIO
// mutated its intermediate); the compile hit L1c and never parsed at all; or there
// simply never was one. All three want the same thing - parse the preprocessed source
// through the identical configuration. That costs one glslang parse, which is what
// GenerateBinary used to spend here on EVERY link rather than only when needed.
//
// The guard is not optional on this path: from stage 4 this runs on a pool worker,
// and TShader::parse would leave that worker's TLS allocator pointing at a pool the
@@ -329,7 +431,12 @@ namespace MobileGL::MG_State::GLState {
.env = artifacts.env.get()};
auto result = ShaderCompiler::CompileShader(attrib);
if (!result) {
// Should be unreachable: the same source parsed successfully at Compile().
// Should be unreachable. This exact (stage, preprocessed source, front-end env)
// parsed successfully once - either at this node's own Compile(), or at the
// Compile() whose verdict L1c handed this node - and every input the parse reads
// is covered by that tuple. ConsumeShaders turns a null into a failed link with a
// named internal error rather than a crash, which is the right shape for a
// "cannot happen" that would otherwise be a silent miscompile.
outReparseLog = result.error().log;
return nullptr;
}
@@ -41,20 +41,42 @@ namespace MobileGL::MG_State::GLState {
// re-parse in ClaimParsedShader() reproduces the original parse exactly, instead of
// re-reading whatever the backend says now.
SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env;
// The parse, WHEN THIS COMPILE ACTUALLY PARSED - and null otherwise, including when
// compileStatus is true.
//
// That combination is not a half-finished compile; it is an L1c hit. The translation
// memo's compile half (TranslationCache.h) knows this exact (stage, preprocessed
// source, front-end env) parses cleanly, so the verdict is published without running
// glslang. What a hit cannot hand over is the TShader itself: mapIO mutates its
// aliased intermediate at link, so a parse feeds exactly ONE link and could never
// have been shared between compiles.
//
// Nothing on the compile side of GL reads this - GL_COMPILE_STATUS, the info log,
// GL_SHADER_SOURCE, attach/detach and reuse across programs are all answered from the
// fields below. The one reader is ClaimParsedShader, which treats null as "parse it
// now", which is the same path the consume-once CAS loser has always taken.
SharedPtr<glslang::TShader> shader;
// The source the parse actually consumed (after PreprocessShaderSource), kept for
// ClaimParsedShader's re-parse so a later link never depends on the preprocessor
// being deterministic across backend-state changes.
String preprocessedSource;
// The explicit layout(location = N) qualifiers this stage's default-block uniforms
// declared, as glslang recorded them at the point its Vulkan-relaxed remap dropped
// them (CollectExplicitUniformLocations).
//
// Populated on the L1c HIT path too, out of the cached verdict rather than out of a
// parse - which is why the verdict carries them. Everything else the relaxed parse
// destroys is recovered at LINK instead, from the IO mapper's collect callback, and so
// has no field here at all.
UnorderedMap<String, Int> explicitUniformLocations;
UnorderedMap<String, Uint> explicitOpaqueBindings;
String infoLog;
Bool compileStatus = false;
};
// The unit of asynchronous shader compilation: one glCompileShader's worth of pure CPU
// work - preprocess, the two lexical rejections, the two lexical extractions, and the
// glslang parse - with every input it needs owned by the node itself.
// work - preprocess, the lexical rejections, and (unless the translation memo's compile
// half already knows the answer) the glslang parse plus the explicit-uniform-location
// snapshot it yields - with every input it needs owned by the node itself.
//
// That ownership is the whole point. The node reads no GL-thread state (the source is a
// SharedPtr<const String> snapshot, the device limits come from the CompileEnv snapshot,
@@ -87,22 +109,29 @@ namespace MobileGL::MG_State::GLState {
// ---- output: valid iff IsComplete(), immutable afterwards ----
ShaderCompileArtifacts artifacts;
// Hands out a link-consumable TShader, exactly once for the stored parse.
// Hands out a link-consumable TShader, parsing one on demand when this node has none.
//
// glslang's mapIO mutates the TShader's aliased intermediate, so the parse this node
// produced may feed exactly ONE link; every later link (a relink, or the same shader
// attached to a second program) needs a fresh parse. The claim is a CAS on this
// shared node rather than a flag on the ShaderObject because from stage 4 the two
// callers can be two ProgramLinkTasks running on two workers: two programs sharing
// one shader, linked back to back. Copying the parse out and tracking consumed-ness
// per program would let both of them decide they were the first, run mapIO over the
// same intermediate twice, and ship silently corrupt SPIR-V.
// TWO WAYS TO GET HERE WITHOUT A STORED PARSE, and they share one implementation:
// * the CAS loser. glslang's mapIO mutates the TShader's aliased intermediate, so
// the parse this node produced may feed exactly ONE link; every later link (a
// relink, or the same shader attached to a second program) needs a fresh one. The
// claim is a CAS on this shared node rather than a flag on the ShaderObject
// because from stage 4 the two callers can be two ProgramLinkTasks on two
// workers: two programs sharing one shader, linked back to back. Copying the
// parse out and tracking consumed-ness per program would let both of them decide
// they were the first, run mapIO over the same intermediate twice, and ship
// silently corrupt SPIR-V.
// * an L1c HIT. The compile published a verdict without parsing at all (see
// ShaderCompileArtifacts::shader), so this call IS the parse - deferred out of
// glCompileShader to the first link that genuinely needs an AST. A link served
// from L1 never gets here, which is the whole point: that program's front end
// never constructs a glslang object of any kind.
//
// The CAS loser re-parses artifacts.preprocessedSource against THIS node's own
// Either way the parse runs over artifacts.preprocessedSource against THIS node's own
// CompileEnv (not against whatever the backend reports now), through the identical
// CompileShader path - so winner and loser produce byte-identical SPIR-V. Callable
// only once IsComplete() and compileStatus are true. Returns null only if that
// re-parse fails, and outReparseLog then carries its diagnostics.
// CompileShader path - so every claimant produces byte-identical SPIR-V. Callable
// only once IsComplete() and compileStatus are true. Returns null only if that parse
// fails, and outReparseLog then carries its diagnostics.
//
// Const because the claim is the node's own synchronization, not a mutation of its
// published artifacts: a claim that is taken and then abandoned (its link was
@@ -155,7 +155,7 @@ namespace MobileGL::MG_State::GLState {
//
// The failure case is covered too: the info log stays queryable because nothing is
// cleared. And if the stored TShader already fed a link, the no-op leaves
// preprocessedSource and both side-channel maps intact, which is precisely what
// preprocessedSource and the explicit-location snapshot intact, which is precisely what
// ClaimParsedShader's on-demand re-parse needs - a real recompile would have handed
// the next link a fresh parse, the no-op hands it a fresh re-parse of the identical
// source instead. Same result, one parse either way.
@@ -101,17 +101,11 @@ namespace MobileGL {
const SharedPtr<glslang::TShader>& GetCompiledShader() const { return Compiled().shader; }
const String& GetInfoLog() const { return Compiled().infoLog; }
// Explicit layout(location = N) qualifiers on this shader's default-block
// uniforms, captured lexically at Compile() because the relaxed parse drops
// them from reflection (see ExtractExplicitUniformLocations).
// uniforms, as glslang recorded them at the point its Vulkan-relaxed remap
// discarded them (see CollectExplicitUniformLocations).
const UnorderedMap<String, Int>& GetExplicitUniformLocations() const {
return Compiled().explicitUniformLocations;
}
// Explicit layout(binding = N) on sampler/image uniforms - their initial
// texture/image units - captured lexically for the same reason (see
// ExtractExplicitOpaqueBindings).
const UnorderedMap<String, Uint>& GetExplicitOpaqueBindings() const {
return Compiled().explicitOpaqueBindings;
}
Bool GetCompileStatus() const { return Compiled().compileStatus; }
Bool GetDeleteStatus() const { return m_deleteStatus; }
@@ -26,6 +26,9 @@ namespace MobileGL::MG_State::GLState {
ComputeLocalSizeRejected,
// FindReservedIdentifierViolation rejected it.
ReservedIdentifierRejected,
// FindShaderStorageBindingViolation rejected it: a storage block declared a binding at or
// past GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS.
ResourceBindingRejected,
// The source-only half was clean but glslang rejected the preprocessed source.
// Memoizing this saves the parse itself on every later object with that source.
ParseFailed,
@@ -33,13 +36,23 @@ namespace MobileGL::MG_State::GLState {
// Everything ShaderObject::Compile() derives from the source text alone, i.e.
// everything that is identical for two shader objects holding byte-identical source.
//
// "The source text alone" is now literally true: the preprocessed text, an accept/reject
// verdict, and the log that explains a rejection. Anything that needs to know what the
// shader MEANS is derived from the parse instead - see the note on the missing fields.
struct ShaderPreprocessResult {
ShaderPreprocessOutcome outcome = ShaderPreprocessOutcome::Preprocessed;
// Valid unless the preprocessor itself never ran; kept even for the rejection
// outcomes because that is the text the diagnostics refer to.
String preprocessedSource;
UnorderedMap<String, Int> explicitUniformLocations;
UnorderedMap<String, Uint> explicitOpaqueBindings;
// NO EXTRACTED SIDE CHANNELS ANY MORE, and their absence is the point. Explicit
// uniform locations, explicit opaque bindings and unqualified storage blocks used to be
// lexed out of the text here, which meant reading MACRO-UNEXPANDED source: MobileGL's
// preprocessor rewrites the text, it does not run the C preprocessor, so
// `binding = SOME_MACRO` reached the scanners verbatim. All three now come from
// glslang - the first from a snapshot taken inside the parse, the other two from the
// IO mapper's collect callback - and none of them is a function of the source text
// ALONE any more, which is the only thing this struct is allowed to hold.
// The compile info log to publish; empty when outcome == Preprocessed.
String infoLog;
@@ -843,6 +843,21 @@ namespace MobileGL {
stored = box;
stateChanged = true;
}
// "The application has written this rectangle" is a DIFFERENT predicate from "the
// value moved", and the backends need the first one: glScissor(0, 0, 0, 0) as the
// very first scissor call leaves every stored box byte-identical to its
// never-written default, and that call is precisely the one whose meaning a
// backend must stop guessing at (see ScissorBoxWrittenMask).
//
// The transition has to count as a state change for the version too. DirectGLES'
// SyncRenderState early-outs on an unchanged render-state version BEFORE it
// reaches the span memcmp that would otherwise notice the mask, so a version-less
// flag flip would sit in the parameter block and never be pushed. It is a
// once-per-index transition, so the steady state still costs nothing.
if (m_parameters.ScissorBoxWrittenMask != kAllViewportsMask) {
m_parameters.ScissorBoxWrittenMask = kAllViewportsMask;
stateChanged = true;
}
if (stateChanged) ++m_version;
}
@@ -855,9 +870,15 @@ namespace MobileGL {
MOBILEGL_ASSERT(false, "Scissor box index out of range: %u", index);
return;
}
if (m_parameters.ScissorBoxes[index] == box) return;
// See SetScissorBox: a first write is state even when it does not move the value,
// so the unchanged-value early-out may only fire once this index is already
// marked written.
const Uint32 writtenBit = 1u << index;
const Bool alreadyWritten = (m_parameters.ScissorBoxWrittenMask & writtenBit) != 0;
if (alreadyWritten && m_parameters.ScissorBoxes[index] == box) return;
m_parameters.ScissorBoxes[index] = box;
m_parameters.ScissorBoxWrittenMask |= writtenBit;
++m_version;
}
@@ -328,6 +328,18 @@ namespace MobileGL {
// turns it into a real glEnable/glDisable.
Uint32 ScissorTestEnabledMask = 0;
Array<IntVec4, MAX_VIEWPORTS> ScissorBoxes{}; // x, y, width, height
// One bit per viewport, set the first time the application writes that index's scissor
// rectangle - glScissor broadcasts and sets all 16, glScissorIndexed/glScissorArrayv set
// the indices they name. It exists because the RECTANGLE cannot answer "has the
// application spoken?": ScissorBoxes starts all-zero (its spec initial value is the size
// of a window the frontend does not know yet, see the RenderState constructor), and
// glScissor(0, 0, 0, 0) is a legal GL state meaning "the scissor test rejects every
// fragment". A backend that reads an empty rectangle as the never-written sentinel
// therefore INVERTS that request into "accept every fragment"; DirectGLES did exactly
// that and KHR-GL43.viewport_array.scissor_zero_dimension caught it. Deliberately beside
// ScissorBoxes so it shares their tail span (after LogicOp) and DirectGLES' span memcmp
// picks a transition up like any other state.
Uint32 ScissorBoxWrittenMask = 0;
// glEnable(GL_CLIP_DISTANCE0 + i) for i in [0, 8), one bit each. A bitmask rather than
// eight bools because every consumer wants the set, not an individual flag, and because
// the SYNC_CAPABILITY/SET_CAPABILITY macros key off a "<Name>Enabled" field name that
@@ -250,6 +250,10 @@ namespace MobileGL {
return m_contentVersion;
}
Uint64 TextureObjectBase::GetShapeVersion() const {
return m_shapeVersion;
}
Bool TextureObjectBase::IsMipmapCompleteForFilterCached(Bool mipmapped) const {
const int slot = mipmapped ? 1 : 0;
if (m_completeMemoShapeVersion[slot] == m_shapeVersion) {
@@ -430,8 +434,28 @@ namespace MobileGL {
}
Bool SamplesAsIncompleteTexture(const ITextureObject* texture, const SamplerObject* effectiveSampler) {
const Bool mipmapped =
effectiveSampler != nullptr && effectiveSampler->GetMipmapMode() != SamplerMipmapMode::None;
// A multisample texture is fetched, never filtered. GL 4.6 core 8.17 gives it exactly
// one level and says its sampler state is not used at all - texelFetch is the only way
// a shader can read it - so 8.14's filter-completeness rules, which is what the
// `mipmapped` branch below asks about, never apply to it.
//
// Deriving `mipmapped` from that unused sampler is what made EVERY multisample texture
// look incomplete: MIN_FILTER's initial value is NEAREST_MIPMAP_LINEAR, and a texture
// that can only ever have one level never satisfies the mip-chain check. Both backends
// treat "samples as incomplete" as "do not bind it" (DirectGLES's per-unit walk in
// ResolveAndBindUnitTextures, DirectVulkan's UniformManager), so the sampler2DMS the
// shader declared was left pointing at nothing and every texelFetch read zero. That is
// the sampler2DMS/sampler2DMSArray half of KHR-GL43.compute_shader.resource-texture,
// which fails at the first data7 element with the multisample texture correctly
// cleared and simply never bound.
//
// IsCopyImageEndpointComplete already spells the same guard as
// CopyImageTargetHasMipmapChain; this was the one place that asked without it.
const TextureTarget target = texture != nullptr ? texture->GetTarget() : TextureTarget::Unknown;
const Bool filtered = target != TextureTarget::Texture2DMultisample &&
target != TextureTarget::Texture2DMultisampleArray;
const Bool mipmapped = filtered && effectiveSampler != nullptr &&
effectiveSampler->GetMipmapMode() != SamplerMipmapMode::None;
return !IsMipmapCompleteForFilter(texture, mipmapped);
}
@@ -55,6 +55,12 @@ namespace MobileGL::MG_State::GLState {
// Backends compare it against a per-resource snapshot to skip re-syncing unchanged
// textures across draws (e.g. the block atlas bound across a whole terrain batch).
virtual Uint64 GetContentVersion() const = 0;
// Monotonic counter bumped on every SHAPE mutation - level sizes, the stored level
// set, the internal format, the level range (see BumpShapeVersion). Disjoint from the
// content version on purpose: glTexImage2D(..., nullptr) re-specifies a level's size
// without dirtying a single texel, so a backend that keys its "nothing changed since
// the last sync" skip on content alone keeps a resource of the OLD size alive.
virtual Uint64 GetShapeVersion() const = 0;
// Answers IsMipmapCompleteForFilter() from a memo. Sampling completeness is a
// property of the texture's SHAPE - level sizes, level count, level range,
// internal format - and never of its texel content, but every draw asks about
@@ -106,6 +112,7 @@ namespace MobileGL::MG_State::GLState {
void SetImmutableLevels(Uint levels) override;
Uint16 GetTextureParamsVersion() const override;
Uint64 GetContentVersion() const override;
Uint64 GetShapeVersion() const override;
Bool IsMipmapCompleteForFilterCached(Bool mipmapped) const override;
// Bumps the content version without touching per-level storage-dirty flags. Used when the
// set of defined mip levels grows via GPU-side mip generation (glGenerateMipmap): the level
@@ -32,6 +32,23 @@ target_link_libraries(
${LINK_LIBRARIES}
)
add_executable(
ViewportIndexRoutingTest
ViewportIndexRoutingTest.cpp
)
target_include_directories(ViewportIndexRoutingTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
ViewportIndexRoutingTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
include(GoogleTest)
gtest_discover_tests(EsslShaderPassTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(BaseInstanceInjectionTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ViewportIndexRoutingTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -17,8 +17,16 @@
using namespace MobileGL;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::BakeImageFormatQualifiers;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::BuildPassthroughTessControlEssl;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ExtractPerVertexBlockMembers;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ForceFlatIntegerVaryings;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_ARRAY_ELEMENT_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_READONLY_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_SPLIT_READ_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITE_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITEONLY_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ImageArrayUnitPlan;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemapImageArrayElementUnits;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemoveLayoutBinding;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestExtendedImageFormats;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestViewportArrayExtension;
@@ -37,7 +45,18 @@ namespace {
return count;
}
// The pass tags the name of every declaration it rewrites with the REPAIR it applied, so the
// expectations have to spell the tag that matches how the fixture uses the image.
String RoAlias(const String& name) { return String(IMAGE_READONLY_ALIAS_PREFIX) + name; }
String WoAlias(const String& name) { return String(IMAGE_WRITEONLY_ALIAS_PREFIX) + name; }
String RwAlias(const String& name) { return String(IMAGE_SPLIT_READ_ALIAS_PREFIX) + name; }
// The writeonly half is minted from the ALREADY access-tagged name, so it carries both.
String WriteAlias(const String& name) { return String(IMAGE_WRITE_ALIAS_PREFIX) + name; }
String SplitWriteAlias(const String& name) { return WriteAlias(RwAlias(name)); }
// The scalar RemapImageArrayElementUnits declares for one element of a split image array.
String Elem(const String& name, Int element) {
return String(IMAGE_ARRAY_ELEMENT_PREFIX) + name + "_" + std::to_string(element);
}
} // namespace
// The bug the pass exists for. SPIRV-Cross speculatively marks every storage image
@@ -59,14 +78,21 @@ void main()
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") + ";"));
// variables on one image unit legal - and both `coherent`, which is what makes the store
// through one of them visible to the load through the other.
EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform coherent readonly highp image2D " +
RwAlias("goku") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform coherent writeonly highp image2D " +
SplitWriteAlias("goku") + ";"))
<< out;
// 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") + ","));
// The load goes to the readonly half, the store to the writeonly one, and neither is called
// what the application called it any more.
EXPECT_TRUE(Contains(out, "imageLoad(" + RwAlias("goku") + ","));
EXPECT_TRUE(Contains(out, "imageStore(" + SplitWriteAlias("goku") + ","));
EXPECT_FALSE(Contains(out, "imageStore(goku,"));
EXPECT_FALSE(Contains(out, "imageLoad(goku,"));
}
// The split has to survive RemoveLayoutBinding, which runs straight after it: an ES image
@@ -96,7 +122,10 @@ void main()
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba16f) uniform readonly highp image2DArray trunks;"));
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba16f) uniform readonly highp image2DArray " +
RoAlias("trunks") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "imageLoad(" + RoAlias("trunks") + ","));
EXPECT_FALSE(Contains(out, "writeonly"));
EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX));
EXPECT_EQ(CountOf(out, "image2DArray"), 1u);
@@ -111,7 +140,10 @@ void main()
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "layout(binding = 3, rgba8) uniform writeonly highp image2D gohan;"));
EXPECT_TRUE(
Contains(out, "layout(binding = 3, rgba8) uniform writeonly highp image2D " + WoAlias("gohan") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "imageStore(" + WoAlias("gohan") + ","));
EXPECT_FALSE(Contains(out, "readonly"));
EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX));
}
@@ -128,8 +160,12 @@ TEST(SplitReadWriteImageUniformsTest, ExemptFormatsAreLeftCompletelyAlone) {
}
}
// A declaration SPIRV-Cross already qualified is none of this pass's business.
TEST(SplitReadWriteImageUniformsTest, AlreadyQualifiedDeclarationsAreUntouched) {
// A declaration SPIRV-Cross already qualified needs no REPAIR - but it still needs the rename.
// The input to this pass is SPIRV-Cross output, not application source, and SPIRV-Cross picks
// `readonly` or `writeonly` from the accesses of the stage it is emitting, so "already qualified"
// says nothing about whether the other stages spell it the same way. The qualifiers must survive
// untouched; only the identifier changes.
TEST(SplitReadWriteImageUniformsTest, AlreadyQualifiedDeclarationsAreRenamedButNotRequalified) {
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;
@@ -137,6 +173,33 @@ void main()
{
imageStore(writer, ivec2(0), imageLoad(reader, ivec2(0)));
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "layout(binding = 0, rgba8) uniform readonly highp image2D " +
RoAlias("reader") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba8) uniform writeonly highp image2D " +
WoAlias("writer") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "imageStore(" + WoAlias("writer") + ",")) << out;
EXPECT_TRUE(Contains(out, "imageLoad(" + RoAlias("reader") + ",")) << out;
// Neither declaration is doubled and neither gains a qualifier it did not have: this is a
// rename, not a repair.
EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX)) << out;
EXPECT_EQ(CountOf(out, "coherent"), 0u) << out;
EXPECT_FALSE(Contains(out, "memoryBarrierImage")) << out;
}
// A declaration carrying BOTH qualifiers is a spelling no per-stage access analysis produces, so
// it came from the application and reads the same in every stage. Nothing to rename.
TEST(SplitReadWriteImageUniformsTest, ADeclarationQualifiedBothWaysIsLeftCompletelyAlone) {
const String source = R"(#version 320 es
layout(binding = 0, rgba8) uniform readonly writeonly highp image2D inert;
void main()
{
highp ivec2 size = imageSize(inert);
if (size.x < 0) discard;
}
)";
EXPECT_EQ(SplitReadWriteImageUniforms(source), source);
}
@@ -152,11 +215,14 @@ void main()
}
)";
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],"));
EXPECT_TRUE(Contains(out, "layout(binding = 6, rgba8) uniform coherent readonly highp image2D " +
RwAlias("gohan") + "[3];"))
<< out;
EXPECT_TRUE(Contains(out, "layout(binding = 6, rgba8) uniform coherent writeonly highp image2D " +
SplitWriteAlias("gohan") + "[3];"))
<< out;
EXPECT_TRUE(Contains(out, "imageStore(" + SplitWriteAlias("gohan") + "[1],"));
EXPECT_TRUE(Contains(out, "imageLoad(" + RwAlias("gohan") + "[2],"));
}
// The rewrite is by identifier, not by substring: "goku" must not reach into "goku_hd", and
@@ -174,13 +240,20 @@ void main()
)";
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")));
// goku is read+write -> split (and coherent with it); goku_hd is write-only -> qualified in
// place, not split, and left non-coherent because nothing aliases it. Both are renamed.
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba8) uniform coherent readonly highp image2D " +
RwAlias("goku") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba8) uniform coherent writeonly highp image2D " +
SplitWriteAlias("goku") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform writeonly highp image2D " +
WoAlias("goku_hd") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "imageStore(" + WoAlias("goku_hd") + ","));
EXPECT_FALSE(Contains(out, SplitWriteAlias("goku") + "_hd"));
EXPECT_FALSE(Contains(out, SplitWriteAlias("goku_hd")));
}
// Other qualifiers belong to both halves, and the memory qualifier goes where SPIRV-Cross
@@ -194,9 +267,141 @@ void main()
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "uniform readonly coherent restrict highp image2D goku;"));
EXPECT_TRUE(Contains(out, "uniform readonly coherent restrict highp image2D " + RwAlias("goku") + ";"))
<< out;
EXPECT_TRUE(
Contains(out, "uniform writeonly coherent restrict highp image2D " + WriteAlias("goku") + ";"));
Contains(out, "uniform writeonly coherent restrict highp image2D " + SplitWriteAlias("goku") + ";"))
<< out;
// ...and the coherent the split adds is not a SECOND one: a repeated memory qualifier is a
// compile error in ESSL, so the source's own has to be recognized.
EXPECT_EQ(CountOf(out, "coherent"), 2u);
}
// The visibility half of the split, and the reason it is not cosmetic: GLSL orders a
// same-variable read-after-write within one invocation by construction, but once the store goes
// through `mg_imageWrite_goku` and the load through `goku` the two are DIFFERENT variables, and
// the ordering only holds if both are coherent. Desktop sources almost never say so - they had
// no reason to - which is how KHR-GL4x.shader_image_load_store.advanced-memory-order's
// store/load/compare loop started reading back the value it had not stored yet.
TEST(SplitReadWriteImageUniformsTest, SplitPairIsMadeCoherentEvenWhenTheSourceIsNot) {
const String source = R"(#version 320 es
layout(binding = 2, rgba8) uniform highp image2D goku;
layout(binding = 3, rgba8) uniform highp image2D storeOnly;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
imageStore(goku, ivec2(0), vec4(1.0));
mg_FragColor = imageLoad(goku, ivec2(0));
imageStore(storeOnly, ivec2(0), vec4(2.0));
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "uniform coherent readonly highp image2D " + RwAlias("goku") + ";")) << out;
EXPECT_TRUE(Contains(out, "uniform coherent writeonly highp image2D " + SplitWriteAlias("goku") + ";"))
<< out;
// Exactly the two halves of the pair, and nothing else: the store-only image is repaired in
// place, has no alias to stay visible to, and must not pay for uncached access.
EXPECT_EQ(CountOf(out, "coherent"), 2u);
EXPECT_TRUE(Contains(out, "uniform writeonly highp image2D " + WoAlias("storeOnly") + ";")) << out;
}
// The ORDERING half of the split, which `coherent` alone does not buy. Coherent makes the store
// through one variable VISIBLE to a load through the other; it says nothing about the order of
// the two within a single invocation, and the ES compiler - seeing a write to one variable and a
// read of another it has no reason to believe alias - is free to serve the read from before the
// write. That is what advanced-memory-order measured on Adreno with the coherent pair already in
// place. memoryBarrierImage() is the primitive that orders them.
TEST(SplitReadWriteImageUniformsTest, EverySplitStoreIsFollowedByAnImageMemoryBarrier) {
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()
{
imageStore(goku, ivec2(0), vec4(1.0));
highp vec4 first = imageLoad(goku, ivec2(0));
imageStore(goku, ivec2(0), vec4(2.0));
mg_FragColor = first + imageLoad(goku, ivec2(0));
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(
Contains(out, "imageStore(" + SplitWriteAlias("goku") + ", ivec2(0), vec4(1.0)); memoryBarrierImage();"))
<< out;
EXPECT_TRUE(
Contains(out, "imageStore(" + SplitWriteAlias("goku") + ", ivec2(0), vec4(2.0)); memoryBarrierImage();"))
<< out;
// One per store, not one per shader and not one per load.
EXPECT_EQ(CountOf(out, "memoryBarrierImage();"), 2u) << out;
}
// The barrier belongs to the SPLIT alone. A store-only image was repaired in place, nothing
// aliases it, and paying for a barrier there would slow down every shader that merely writes an
// image - which is most of them.
TEST(SplitReadWriteImageUniformsTest, ARepairedButUnsplitStoreGetsNoBarrier) {
const String source = R"(#version 320 es
layout(binding = 3, rgba8) uniform highp image2D storeOnly;
void main()
{
imageStore(storeOnly, ivec2(0), vec4(1.0));
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "uniform writeonly highp image2D " + WoAlias("storeOnly") + ";")) << out;
EXPECT_FALSE(Contains(out, "memoryBarrierImage")) << out;
}
// The store site is found by matching the call's own parentheses, not by looking for the next
// ')', so a nested call in the value argument does not truncate the statement and the barrier
// still lands after the whole thing.
TEST(SplitReadWriteImageUniformsTest, TheBarrierLandsAfterAStoreWithNestedParentheses) {
const String source = R"(#version 320 es
layout(binding = 6, rgba8) uniform highp image2D gohan[3];
void main()
{
imageStore(gohan[1], ivec2(0), max(imageLoad(gohan[2], ivec2(0)), vec4(0.5)));
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "max(imageLoad(" + RwAlias("gohan") +
"[2], ivec2(0)), vec4(0.5))); memoryBarrierImage();"))
<< out;
EXPECT_EQ(CountOf(out, "memoryBarrierImage();"), 1u) << out;
}
// The split is the one thing that makes a stage declare MORE image uniforms than the application
// did, and MobileGL keeps advertising GL_MAX_*_IMAGE_UNIFORMS unadjusted (lowering it would fail
// basic-api and NotSupported-out every case that only uses readonly/writeonly images). So the
// count has to be reportable, or a link failure caused by the doubling looks like a driver
// mystery - which is what KHR-GL4x.shader_image_load_store.multiple-uniforms will hit the moment
// the format work stops masking it.
TEST(SplitReadWriteImageUniformsTest, TheSplitCountIsReportedToTheCaller) {
const String twoSplits = R"(#version 320 es
layout(binding = 0, rgba8) uniform highp image2D goku;
layout(binding = 1, rgba16f) uniform highp image2D gohan;
layout(binding = 2, rgba8) uniform highp image2D storeOnly;
void main()
{
imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0)));
imageStore(gohan, ivec2(0), imageLoad(gohan, ivec2(0)));
imageStore(storeOnly, ivec2(0), vec4(0.0));
}
)";
Uint splitCount = 99u;
SplitReadWriteImageUniforms(twoSplits, &splitCount);
EXPECT_EQ(splitCount, 2u) << "only the read+write pair counts; the store-only repair adds no uniform";
// Every early return has to write the count too, or a caller reads whatever was there before.
const String noImages = R"(#version 320 es
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
mg_FragColor = vec4(1.0);
}
)";
splitCount = 99u;
SplitReadWriteImageUniforms(noImages, &splitCount);
EXPECT_EQ(splitCount, 0u);
}
// imageSize reads no texels and writes none, so it decides nothing; readonly is what keeps
@@ -211,25 +416,38 @@ void main()
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "layout(binding = 8, rgba8ui) uniform readonly highp uimage2D sizeOnly;"));
EXPECT_TRUE(Contains(out, "layout(binding = 8, rgba8ui) uniform readonly highp uimage2D " +
RoAlias("sizeOnly") + ";"))
<< out;
// The rename has to reach imageSize too, or the declaration and its only use stop agreeing.
EXPECT_TRUE(Contains(out, "imageSize(" + RoAlias("sizeOnly") + ")")) << out;
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);
}
)";
// Neither minted name may land on an identifier the shader already uses - and there are two of
// them now, the access-tagged name of the repaired declaration and the writeonly half built on
// top of it. Both collisions are exercised at once.
TEST(SplitReadWriteImageUniformsTest, AliasNamesAvoidExistingIdentifiers) {
const String stageCollision = RwAlias("taken");
const String writeCollision = SplitWriteAlias("taken");
const String source = "#version 320 es\n"
"layout(binding = 6, rgba8) uniform highp image2D taken;\n"
"highp vec4 " +
stageCollision + ";\nhighp vec4 " + writeCollision +
";\nvoid main()\n{\n"
" imageStore(taken, ivec2(0), imageLoad(taken, ivec2(0)) + " +
stageCollision + " + " + writeCollision + ");\n}\n";
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)"));
EXPECT_FALSE(Contains(out, "image2D " + stageCollision + ";")) << out;
EXPECT_FALSE(Contains(out, "image2D " + writeCollision + ";")) << out;
EXPECT_TRUE(Contains(out, "image2D " + stageCollision + "X;")) << out;
EXPECT_TRUE(Contains(out, "image2D " + writeCollision + "X;")) << out;
EXPECT_TRUE(Contains(out, "imageStore(" + writeCollision + "X,")) << out;
EXPECT_TRUE(Contains(out, "imageLoad(" + stageCollision + "X,")) << out;
// ...and the globals that forced the suffix are still themselves.
EXPECT_TRUE(Contains(out, "highp vec4 " + stageCollision + ";")) << out;
EXPECT_TRUE(Contains(out, "highp vec4 " + writeCollision + ";")) << out;
}
// A use the pass cannot account for (here: the image handed to a user function) means it
@@ -243,6 +461,9 @@ void main()
imageStore(passed, ivec2(0), helper(passed));
}
)";
// Declining means declining EVERYTHING: no qualifier, and no rename either. A rename that
// moved the declaration but not the use inside helper() would be a compile error rather than
// the wrong-but-compiling shader this pass refuses to guess at.
EXPECT_EQ(SplitReadWriteImageUniforms(source), source);
}
@@ -258,6 +479,468 @@ void main()
EXPECT_EQ(SplitReadWriteImageUniforms(source), source);
}
// The defect the rename exists for. The pass sees ONE stage at a time and picks the memory
// qualifier from the accesses in THAT stage, so a vertex shader that only stores and a fragment
// shader that only loads the same image came out `writeonly g_image` and `readonly g_image` -
// two declarations of one uniform name that GLSL requires to be identical. Adreno merges them
// and silently discards the vertex-stage stores (advanced-memory-dependentInvocation reads back
// the untouched zeros, with LINK_STATUS = 1 and an empty driver log). Tagging by the repair
// leaves nothing to merge.
TEST(SplitReadWriteImageUniformsTest, StagesThatUseAnImageDifferentlyGetDifferentNames) {
const String vertexSource = R"(#version 320 es
layout(binding = 0, rgba32f) uniform coherent highp image2D g_image;
void main()
{
imageStore(g_image, ivec2(0), vec4(1.0));
gl_Position = vec4(0.0);
}
)";
const String fragmentSource = R"(#version 320 es
layout(binding = 0, rgba32f) uniform coherent highp image2D g_image;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
mg_FragColor = imageLoad(g_image, ivec2(0));
}
)";
const String vsOut = SplitReadWriteImageUniforms(vertexSource);
const String fsOut = SplitReadWriteImageUniforms(fragmentSource);
const String vsName = WoAlias("g_image");
const String fsName = RoAlias("g_image");
EXPECT_NE(vsName, fsName);
EXPECT_TRUE(Contains(vsOut, "uniform writeonly coherent highp image2D " + vsName + ";")) << vsOut;
EXPECT_TRUE(Contains(fsOut, "uniform readonly coherent highp image2D " + fsName + ";")) << fsOut;
EXPECT_TRUE(Contains(vsOut, "imageStore(" + vsName + ",")) << vsOut;
EXPECT_TRUE(Contains(fsOut, "imageLoad(" + fsName + ",")) << fsOut;
// The whole point: after the rewrite the two stages no longer declare a common name, so
// there is nothing for a linker to merge and mis-qualify.
EXPECT_FALSE(Contains(vsOut, fsName)) << vsOut;
EXPECT_FALSE(Contains(fsOut, vsName)) << fsOut;
// Both bindings are untouched - the image unit is still the same one.
EXPECT_TRUE(Contains(vsOut, "binding = 0"));
EXPECT_TRUE(Contains(fsOut, "binding = 0"));
}
// The same defect, in the shape it actually reaches the driver in. SPIRV-Cross emits the access
// qualifier ITSELF whenever the stage only loads or only stores, so the declaration arrives here
// already legal - and this pass used to skip it on exactly that ground, leaving the vertex stage's
// `coherent writeonly g_image` and the fragment stage's `coherent readonly g_image` sharing one
// name. That is the pair a raw-ES probe on the Adreno 830 reproduces with no MobileGL in the
// process: the fragment stage reads back the untouched zeros
// (KHR-GL4x.shader_image_load_store.advanced-memory-dependentInvocation's [1,0,0,0.2]), and
// renaming either half fixes it. This is the emitted text of that test, verbatim.
TEST(SplitReadWriteImageUniformsTest, StagesSpirvCrossQualifiedDifferentlyGetDifferentNames) {
const String vertexSource = R"(#version 320 es
layout(binding = 1, rgba32f) uniform coherent writeonly highp image2D g_image;
void main()
{
imageStore(g_image, ivec2(0), vec4(2.0));
gl_Position = vec4(0.0);
}
)";
const String fragmentSource = R"(#version 320 es
layout(binding = 1, rgba32f) uniform coherent readonly highp image2D g_image;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
mg_FragColor = imageLoad(g_image, ivec2(0));
}
)";
const String vsOut = SplitReadWriteImageUniforms(vertexSource);
const String fsOut = SplitReadWriteImageUniforms(fragmentSource);
const String vsName = WoAlias("g_image");
const String fsName = RoAlias("g_image");
EXPECT_NE(vsName, fsName);
EXPECT_TRUE(Contains(vsOut, "uniform coherent writeonly highp image2D " + vsName + ";")) << vsOut;
EXPECT_TRUE(Contains(fsOut, "uniform coherent readonly highp image2D " + fsName + ";")) << fsOut;
EXPECT_TRUE(Contains(vsOut, "imageStore(" + vsName + ",")) << vsOut;
EXPECT_TRUE(Contains(fsOut, "imageLoad(" + fsName + ",")) << fsOut;
// Nothing left for a linker to merge and mis-qualify...
EXPECT_FALSE(Contains(vsOut, fsName)) << vsOut;
EXPECT_FALSE(Contains(fsOut, vsName)) << fsOut;
// ...and the image unit is still the one the application asked for.
EXPECT_TRUE(Contains(vsOut, "binding = 1")) << vsOut;
EXPECT_TRUE(Contains(fsOut, "binding = 1")) << fsOut;
}
// ...and the budget half of it: two stages SPIRV-Cross qualified the SAME way must still land on
// one shared name, or every stage that names the image spends an image location of its own.
TEST(SplitReadWriteImageUniformsTest, StagesSpirvCrossQualifiedAlikeShareOneName) {
const String stage = R"(#version 320 es
layout(binding = 1, rgba32f) uniform coherent readonly highp image2D g_image;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
mg_FragColor = imageLoad(g_image, ivec2(0));
}
)";
const String first = SplitReadWriteImageUniforms(stage);
const String second = SplitReadWriteImageUniforms(stage);
EXPECT_EQ(first, second);
EXPECT_TRUE(Contains(first, "uniform coherent readonly highp image2D " + RoAlias("g_image") + ";"))
<< first;
}
// The other side of that coin, and the one a per-STAGE tag got wrong. Two stages that use the
// image the same way emit byte-identical declarations, so they must arrive at ONE shared name:
// Adreno allocates an image LOCATION per distinct uniform, and giving each stage its own name
// multiplied a program's image-uniform count by the number of stages that mention it - which is
// how the five stages of KHR-GL43.shading_language_420pack.binding_images_texture_type_* went
// from 6 image uniforms to 30 and drew "Error: Image Image location or component exceeds max
// allowed." out of the Adreno 830 linker, with LINK_STATUS = TRUE already published by the
// frontend and every draw silently doing nothing.
TEST(SplitReadWriteImageUniformsTest, StagesThatUseAnImageAlikeShareOneName) {
const String vertexSource = R"(#version 320 es
layout(binding = 1, rgba8) uniform highp image2D goku;
void main()
{
imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0)));
gl_Position = vec4(0.0);
}
)";
const String fragmentSource = R"(#version 320 es
layout(binding = 1, rgba8) uniform highp image2D goku;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0)));
mg_FragColor = vec4(0.0);
}
)";
const String vsOut = SplitReadWriteImageUniforms(vertexSource);
const String fsOut = SplitReadWriteImageUniforms(fragmentSource);
// One name, arrived at independently by two different stages, so the linker merges them
// back into the single image uniform the application declared.
for (const String& out : {vsOut, fsOut}) {
EXPECT_TRUE(Contains(out, "uniform coherent readonly highp image2D " + RwAlias("goku") + ";")) << out;
EXPECT_TRUE(Contains(out, "uniform coherent writeonly highp image2D " + SplitWriteAlias("goku") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "imageLoad(" + RwAlias("goku") + ",")) << out;
EXPECT_TRUE(Contains(out, "imageStore(" + SplitWriteAlias("goku") + ",")) << out;
}
}
// One tag per repair, all three distinct, and each a legal identifier stem.
TEST(SplitReadWriteImageUniformsTest, EveryAccessTagIsDistinct) {
const String prefixes[] = {String(IMAGE_READONLY_ALIAS_PREFIX), String(IMAGE_WRITEONLY_ALIAS_PREFIX),
String(IMAGE_SPLIT_READ_ALIAS_PREFIX), String(IMAGE_WRITE_ALIAS_PREFIX)};
Vector<String> seenPrefixes;
for (const String& prefix : prefixes) {
// A GLSL identifier may not contain "__" (GLSL ES 3.20 3.7), and the prefix is glued
// straight onto a name that may itself start with '_'.
EXPECT_EQ(prefix.find("__"), String::npos) << prefix;
for (const String& seen : seenPrefixes) {
EXPECT_NE(seen, prefix) << prefix;
// Nor may one be a prefix of another: the write half is minted on top of an
// already-tagged name, so a shared stem would let two repairs collide.
EXPECT_NE(prefix.rfind(seen, 0), 0u) << prefix << " vs " << seen;
}
seenPrefixes.push_back(prefix);
}
}
// ---------------------------------------------------------------------------------------
// RemapImageArrayElementUnits
//
// ES takes an image unit only from layout(binding=N), and one declaration carries one of them,
// so an image array's elements land on N, N+1, N+2, ... Desktop GL lets an application point
// each element wherever it likes with glUniform1i, which ES makes an INVALID_OPERATION on an
// image uniform - there is no API side to fix, so the emitted text has to carry it.
namespace {
// The advanced-sso-simple shape: a four-element image array on units 0, 2, 4, 6. The
// subscripts are literals because LegalizeResourceArrayIndexingForEssl has already folded
// the conformance case's `for (int i = 0; i < g_image.length(); ++i)` - ESSL forbids a
// non-constant image-array subscript outright, so a loop counter never reaches this pass.
const char* const kSsoImageArrayFS = R"(#version 320 es
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[4];
void main()
{
imageStore(g_image[0], ivec2(gl_FragCoord.xy), vec4(1.0));
imageStore(g_image[1], ivec2(gl_FragCoord.xy), vec4(1.0));
imageStore(g_image[2], ivec2(gl_FragCoord.xy), vec4(1.0));
imageStore(g_image[3], ivec2(gl_FragCoord.xy), vec4(1.0));
}
)";
ImageArrayUnitPlan Plan(const String& name, const Vector<Int>& units) {
ImageArrayUnitPlan plan;
plan.name = name;
plan.units = units;
return plan;
}
} // namespace
// The defect, end to end. Elements 0..3 need units 0, 2, 4, 6, so the array becomes four scalars
// carrying those four bindings. Before this, the single stamped binding sent the four elements to
// units 0, 1, 2, 3.
TEST(RemapImageArrayElementUnitsTest, NonConsecutiveUnitsSplitIntoOneScalarPerElement) {
Vector<String> declined;
const String out =
RemapImageArrayElementUnits(kSsoImageArrayFS, {Plan("g_image", {0, 2, 4, 6})}, &declined);
EXPECT_TRUE(declined.empty()) << (declined.empty() ? String() : declined[0]);
const Int units[4] = {0, 2, 4, 6};
for (Int element = 0; element < 4; ++element) {
EXPECT_TRUE(Contains(out, "layout(rgba32f, binding = " + std::to_string(units[element]) +
") uniform writeonly highp image2D " + Elem("g_image", element) + ";"))
<< out;
EXPECT_TRUE(Contains(out, "imageStore(" + Elem("g_image", element) + ", ivec2(gl_FragCoord.xy)"))
<< out;
}
// The array is gone entirely; nothing may still address units 0,1,2,3 through it.
EXPECT_FALSE(Contains(out, "image2D g_image[4];")) << out;
EXPECT_FALSE(Contains(out, "g_image[")) << out;
// Exactly the four image uniforms the application declared - what the earlier widening cost
// was the whole SPAN, seven here, which is the budget failure mode this shape removes.
EXPECT_EQ(CountOf(out, "image2D "), 4u) << out;
}
// The other program of the same conformance case: units 1, 3, 5, 7 in the application's own
// element ORDER, which is what carries the assignment, so it must NOT be sorted or rebased.
TEST(RemapImageArrayElementUnitsTest, EachElementCarriesTheUnitTheApplicationGaveIt) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 3) uniform writeonly highp image2D g_image[4];
void main()
{
imageStore(g_image[0], ivec2(0), vec4(2.0));
imageStore(g_image[3], ivec2(0), vec4(2.0));
}
)";
const String out = RemapImageArrayElementUnits(source, {Plan("g_image", {3, 1, 7, 5})});
const Int units[4] = {3, 1, 7, 5};
for (Int element = 0; element < 4; ++element) {
EXPECT_TRUE(Contains(out, "binding = " + std::to_string(units[element]) +
") uniform writeonly highp image2D " + Elem("g_image", element) + ";"))
<< out;
}
// Only elements 0 and 3 are ever accessed; elements 1 and 2 are declared and unused, because
// the reflection says the array has four of them.
EXPECT_TRUE(Contains(out, "imageStore(" + Elem("g_image", 0) + ", ivec2(0)")) << out;
EXPECT_TRUE(Contains(out, "imageStore(" + Elem("g_image", 3) + ", ivec2(0)")) << out;
}
// Consecutive-from-element-zero is exactly what ESSL does unaided, so the emitted text of an
// ordinary image shader must come out byte-identical. The caller filters these; the pass must
// not depend on that.
TEST(RemapImageArrayElementUnitsTest, ConsecutiveUnitsAreLeftCompletelyAlone) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 2) uniform writeonly highp image2D g_image[3];
void main()
{
imageStore(g_image[1], ivec2(0), vec4(1.0));
}
)";
EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {2, 3, 4})}), source);
// ...and so is a plan for an array this stage does not declare at all: the reflection is
// program-wide, the pass runs per stage.
EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("other_image", {0, 4})}), source);
}
// A subscript that is not a literal names no element, so there is no scalar to rewrite it to.
// It should never arrive - LegalizeResourceArrayIndexingForEssl runs first and ESSL rejects the
// shape outright - but if one does, guessing an element would only change WHICH unit the access
// reaches wrongly. Decline, loudly, and change nothing.
TEST(RemapImageArrayElementUnitsTest, ANonLiteralSubscriptIsDeclinedAndNamed) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[4];
void main()
{
for (int i = 0; i < 4; i++)
{
imageStore(g_image[i], ivec2(gl_FragCoord.xy), vec4(1.0));
}
}
)";
Vector<String> declined;
EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {0, 2, 4, 6})}, &declined), source);
ASSERT_EQ(declined.size(), 1u);
EXPECT_TRUE(Contains(declined[0], "g_image")) << declined[0];
// A literal that is out of the reflected range is the same class of mismatch.
const String outOfRange = R"(#version 320 es
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[2];
void main()
{
imageStore(g_image[5], ivec2(0), vec4(1.0));
}
)";
Vector<String> outOfRangeDeclined;
EXPECT_EQ(RemapImageArrayElementUnits(outOfRange, {Plan("g_image", {0, 5})}, &outOfRangeDeclined),
outOfRange);
ASSERT_EQ(outOfRangeDeclined.size(), 1u);
}
// A uint subscript IS a literal element index. SPIRV-Cross prints an index in the type SPIR-V
// gave it and LegalizeResourceArrayIndexPass mints its per-element constants in the type of the
// index it replaced, so an array walked by anything unsigned - a `uint` loop counter, or
// anything derived from gl_LocalInvocationIndex, which is uint by definition - reaches this pass
// spelled `g_image[0u]`. Refusing the `u` declined the array and left every element on the
// consecutive units one binding hands out, silently.
TEST(RemapImageArrayElementUnitsTest, AUintSubscriptIsStillALiteralElementIndex) {
const String source = R"(#version 320 es
layout(local_size_x = 1) in;
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[3];
void main()
{
imageStore(g_image[0u], ivec2(0), vec4(1.0));
imageStore(g_image[2U], ivec2(0), vec4(2.0));
}
)";
Vector<String> declined;
const String out = RemapImageArrayElementUnits(source, {Plan("g_image", {0, 4, 8})}, &declined);
EXPECT_TRUE(declined.empty()) << (declined.empty() ? String() : declined[0]);
EXPECT_TRUE(Contains(out, "binding = 0) uniform writeonly highp image2D " + Elem("g_image", 0) + ";")) << out;
EXPECT_TRUE(Contains(out, "binding = 4) uniform writeonly highp image2D " + Elem("g_image", 1) + ";")) << out;
EXPECT_TRUE(Contains(out, "binding = 8) uniform writeonly highp image2D " + Elem("g_image", 2) + ";")) << out;
EXPECT_TRUE(Contains(out, "imageStore(" + Elem("g_image", 0) + ", ivec2(0), vec4(1.0))")) << out;
EXPECT_TRUE(Contains(out, "imageStore(" + Elem("g_image", 2) + ", ivec2(0), vec4(2.0))")) << out;
EXPECT_FALSE(Contains(out, "g_image[")) << out;
}
// ...and the suffix is not a licence to accept anything else that ends in one: `iu` is not a
// literal, and neither is a bare `u`.
TEST(RemapImageArrayElementUnitsTest, ASuffixAloneDoesNotMakeAnExpressionALiteral) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[2];
void main()
{
highp int iu = 1;
imageStore(g_image[iu], ivec2(0), vec4(1.0));
}
)";
Vector<String> declined;
EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {0, 4})}, &declined), source);
ASSERT_EQ(declined.size(), 1u);
}
// A use the pass cannot see a subscript on has no element index to rewrite, so splitting the
// array out from under it would leave it naming a declaration that no longer exists. Decline,
// loudly, and change nothing.
TEST(RemapImageArrayElementUnitsTest, AUseWithoutASubscriptIsDeclined) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[2];
void helper();
void main()
{
imageStore(g_image[0], ivec2(0), vec4(1.0));
helper(g_image);
}
)";
Vector<String> declined;
EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {0, 5})}, &declined), source);
ASSERT_EQ(declined.size(), 1u);
EXPECT_TRUE(Contains(declined[0], "g_image")) << declined[0];
}
// The reflection and the emitted text have to be talking about the same array. If they are not,
// the pass has misidentified something and must not rewrite on a guess.
TEST(RemapImageArrayElementUnitsTest, AnExtentThatDisagreesWithTheReflectionIsDeclined) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[2];
void main()
{
imageStore(g_image[0], ivec2(0), vec4(1.0));
}
)";
Vector<String> declined;
EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {0, 4, 8})}, &declined), source);
ASSERT_EQ(declined.size(), 1u);
}
// The two passes that run after it have to see the split declarations and keep their bindings: an
// ES image unit cannot be assigned through the API, so the qualifier is the only mechanism there
// is, and an element that is both read and written is split again into a pair that must BOTH
// carry that element's own unit.
TEST(RemapImageArrayElementUnitsTest, TheSplitElementsSurviveTheLaterImagePasses) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 4) uniform highp image2D g_image[2];
void main()
{
imageStore(g_image[1], ivec2(0), imageLoad(g_image[0], ivec2(0)));
}
)";
String out = RemapImageArrayElementUnits(source, {Plan("g_image", {4, 6})});
out = SplitReadWriteImageUniforms(out);
out = RemoveLayoutBinding(out);
// Element 0 is only ever loaded and element 1 only ever stored, so neither is split into a
// pair - but each keeps the unit the application gave it, which the array could not express.
EXPECT_TRUE(Contains(out, "binding = 4")) << out;
EXPECT_TRUE(Contains(out, "binding = 6")) << out;
EXPECT_TRUE(Contains(out, "readonly highp image2D " + RoAlias(Elem("g_image", 0)) + ";")) << out;
EXPECT_TRUE(Contains(out, "writeonly highp image2D " + WoAlias(Elem("g_image", 1)) + ";")) << out;
EXPECT_TRUE(Contains(out, "imageStore(" + WoAlias(Elem("g_image", 1)) + ", ivec2(0), imageLoad(" +
RoAlias(Elem("g_image", 0)) + ", ivec2(0)))"))
<< out;
// Nothing is left addressing the array.
EXPECT_FALSE(Contains(out, "g_image[")) << out;
}
// The same element both read and written IS split into a coherent pair, and both halves have to
// inherit that element's binding - the shape the widening used to have to carry on an array.
TEST(RemapImageArrayElementUnitsTest, AnElementThatIsBothReadAndWrittenIsSplitWithItsOwnBinding) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 4) uniform highp image2D g_image[2];
void main()
{
imageStore(g_image[1], ivec2(0), imageLoad(g_image[1], ivec2(0)));
imageStore(g_image[0], ivec2(0), vec4(0.0));
}
)";
String out = RemapImageArrayElementUnits(source, {Plan("g_image", {4, 9})});
out = SplitReadWriteImageUniforms(out);
out = RemoveLayoutBinding(out);
// Element 1 sits on unit 9, and both halves of its split pair say so.
EXPECT_EQ(CountOf(out, "binding = 9"), 2u) << out;
EXPECT_TRUE(Contains(out, "readonly highp image2D " + RwAlias(Elem("g_image", 1)) + ";")) << out;
EXPECT_TRUE(Contains(out, "writeonly highp image2D " + WriteAlias(RwAlias(Elem("g_image", 1))) + ";"))
<< out;
EXPECT_EQ(CountOf(out, "binding = 4"), 1u) << out;
}
// The gap that let a per-STAGE image rename reach production: every fixture above declares an
// image ARRAY, and the regression it caused was in the SCALAR images sitting next to one. A
// scalar with an explicit binding has to come out of the whole chain still on ITS OWN unit,
// still spelled once, and named the same thing every stage would name it - it is the array that
// needs repairing, not its neighbour.
TEST(RemapImageArrayElementUnitsTest, AScalarImageWithItsOwnBindingIsUntouchedByTheArrayRepair) {
const String source = R"(#version 320 es
layout(rgba8, binding = 7) uniform highp image2D goku;
layout(rgba32f, binding = 4) uniform highp image2D g_image[2];
void main()
{
imageStore(g_image[1], ivec2(0), imageLoad(g_image[0], ivec2(0)));
imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0)));
}
)";
Vector<String> declined;
String out = RemapImageArrayElementUnits(source, {Plan("g_image", {4, 9})}, &declined);
EXPECT_TRUE(declined.empty());
// The array pass may only ever touch the arrays it was handed a plan for.
EXPECT_TRUE(Contains(out, "layout(rgba8, binding = 7) uniform highp image2D goku;")) << out;
out = SplitReadWriteImageUniforms(out);
out = RemoveLayoutBinding(out);
// Unit 7 exactly twice - the two halves of the scalar's own split pair - and nothing has
// moved it onto one of the array's units.
EXPECT_EQ(CountOf(out, "binding = 7"), 2u) << out;
EXPECT_TRUE(Contains(out, "readonly highp image2D " + RwAlias("goku") + ";")) << out;
EXPECT_TRUE(Contains(out, "writeonly highp image2D " + SplitWriteAlias("goku") + ";")) << out;
// ...and no per-stage tag anywhere: the name a scalar gets is a function of how this text
// uses it, so every stage that uses it the same way keeps ONE shared uniform (Adreno spends
// an image location per distinct one).
EXPECT_FALSE(Contains(out, "mg_imageVs_")) << out;
EXPECT_FALSE(Contains(out, "mg_imageFs_")) << out;
EXPECT_FALSE(Contains(out, "mg_imageCs_")) << out;
}
// ---------------------------------------------------------------------------------------
// RetargetTextureBufferExtension
//
@@ -603,3 +1286,84 @@ void main() { gl_ViewportIndex = 1; imageStore(uni_image, ivec2(0), uvec4(1u));
EXPECT_TRUE(Contains(out, "#extension GL_NV_image_formats : require\n")) << out;
EXPECT_TRUE(Contains(out, "#extension GL_OES_viewport_array : require\n")) << out;
}
// --- pass-through tessellation control stage --------------------------------------------------
//
// Desktop GL makes the tessellation control stage optional and takes the levels from
// PATCH_DEFAULT_OUTER_LEVEL / PATCH_DEFAULT_INNER_LEVEL; ES 3.2 has neither, and rejects a
// program that has an evaluation stage without a control stage - with an EMPTY info log. The
// synthesized stage is what stands in, and it has to MIRROR its two neighbours' gl_PerVertex
// rather than pick a shape, because a redeclaration that disagrees with the stage it feeds is an
// ES link error against a program that has nothing else wrong with it.
TEST(PassthroughTessControlEsslTest, DeclaresThePatchSizeAndWritesEveryTessLevel) {
const String out = BuildPassthroughTessControlEssl(320, 4, "", "");
EXPECT_EQ(out.find("#version 320 es"), 0u) << out;
EXPECT_TRUE(Contains(out, "layout(vertices = 4) out;")) << out;
EXPECT_TRUE(Contains(out,
"gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;"))
<< out;
// All six, unconditionally: writing a level the evaluation stage's domain does not use is
// legal and ignored, and it saves the generator from having to know the domain.
for (const char* level : {"gl_TessLevelOuter[0]", "gl_TessLevelOuter[1]", "gl_TessLevelOuter[2]",
"gl_TessLevelOuter[3]", "gl_TessLevelInner[0]", "gl_TessLevelInner[1]"}) {
EXPECT_TRUE(Contains(out, String(level) + " = 1.0;")) << level << "\n" << out;
}
// Nothing redeclared when the neighbours redeclared nothing - the driver's own built-in
// gl_in/gl_out is then what both sides agree on, and redeclaring is what would break it.
EXPECT_FALSE(Contains(out, "gl_PerVertex")) << out;
}
// ES 3.1 reaches tessellation only through the extension; the caller has already established
// that the driver runs the evaluation stage at all, so the only question is the spelling.
TEST(PassthroughTessControlEsslTest, RequestsTheExtensionBelowEs32) {
const String out = BuildPassthroughTessControlEssl(310, 3, "", "");
EXPECT_EQ(out.find("#version 310 es"), 0u) << out;
EXPECT_TRUE(Contains(out, "#extension GL_EXT_tessellation_shader : require")) << out;
}
TEST(PassthroughTessControlEsslTest, MirrorsTheNeighboursPerVertexBlocks) {
const String inMembers = " highp vec4 gl_Position; highp float gl_PointSize; ";
const String outMembers = " highp vec4 gl_Position; ";
const String out = BuildPassthroughTessControlEssl(320, 4, inMembers, outMembers);
EXPECT_TRUE(Contains(out, "in gl_PerVertex {" + inMembers + "} gl_in[gl_MaxPatchVertices];")) << out;
EXPECT_TRUE(Contains(out, "out gl_PerVertex {" + outMembers + "} gl_out[];")) << out;
}
TEST(ExtractPerVertexBlockMembersTest, ReadsEitherDirectionAndOnlyThatDirection) {
const String essl = R"(#version 320 es
in gl_PerVertex { highp vec4 gl_Position; } gl_in[gl_MaxPatchVertices];
out gl_PerVertex { highp vec4 gl_Position; highp float gl_PointSize; } gl_out[];
void main() {}
)";
const auto inMembers = ExtractPerVertexBlockMembers(essl, true);
ASSERT_TRUE(inMembers.has_value()) << essl;
EXPECT_TRUE(Contains(*inMembers, "gl_Position")) << *inMembers;
EXPECT_FALSE(Contains(*inMembers, "gl_PointSize"))
<< "the `in` block must not pick up the `out` block's members: " << *inMembers;
const auto outMembers = ExtractPerVertexBlockMembers(essl, false);
ASSERT_TRUE(outMembers.has_value()) << essl;
EXPECT_TRUE(Contains(*outMembers, "gl_PointSize")) << *outMembers;
}
// A shader that does not redeclare the block must report nothing, so the generator leaves the
// driver's built-in declaration alone rather than inventing one.
TEST(ExtractPerVertexBlockMembersTest, ReportsNothingWhenTheBlockIsNotRedeclared) {
const String essl = R"(#version 320 es
layout(quads) in;
void main() { gl_Position = gl_in[0].gl_Position; }
)";
EXPECT_FALSE(ExtractPerVertexBlockMembers(essl, true).has_value()) << essl;
EXPECT_FALSE(ExtractPerVertexBlockMembers(essl, false).has_value()) << essl;
}
// "min" ends in "in" and "layout" ends in "out": the direction keyword has to be a whole token
// immediately before the block name, or an unrelated identifier would be read as a redeclaration.
TEST(ExtractPerVertexBlockMembersTest, DoesNotMatchAnIdentifierEndingInTheKeyword) {
const String essl = R"(#version 320 es
struct fin gl_PerVertex { highp vec4 gl_Position; };
void main() {}
)";
EXPECT_FALSE(ExtractPerVertexBlockMembers(essl, true).has_value()) << essl;
}
@@ -0,0 +1,179 @@
// MobileGL - MobileGL/MG_Test/Backend/DirectGLES/ViewportIndexRoutingTest.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 ESSL half of the gl_ViewportIndex routing emulation (MG_Backend/DirectGLES/Managers.cpp).
// GLES has one viewport, one scissor rectangle and one depth range where GL 4.1 has sixteen of
// each selected per primitive, and the target device has no GL_OES_viewport_array to borrow, so
// DirectGLES turns the builtin into an ordinary flat varying and gives the fragment stage a gate
// the draw path replays against.
//
// Both passes are pure String -> String over what SPIRV-Cross emits once LowerViewportIndexPass
// has demoted the builtin, so no GL context and no driver: the shapes they have to survive - and
// the ones they must refuse - can be pinned here rather than only on a device. What they cannot
// pin is that the routing produces the right pixels; that is
// MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp, which runs the same claim through both
// backends.
#include <gtest/gtest.h>
#include <MG_Backend/DirectGLES/Managers.h>
using MobileGL::Bool;
using MobileGL::String;
using MobileGL::MG_Backend::DirectGLES::InjectViewportIndexPassGate;
using MobileGL::MG_Backend::DirectGLES::PromoteViewportIndexGlobalToVarying;
namespace {
Bool Contains(const String& haystack, const String& needle) {
return haystack.find(needle) != String::npos;
}
// What SPIRV-Cross hands the backend for a geometry stage after LowerViewportIndexPass has
// demoted gl_ViewportIndex: a plain file-scope global the shader still writes and which, until
// this pass runs, nothing anywhere reads.
constexpr const char* kLoweredGeometryShader = R"(#version 320 es
layout(invocations = 16, points) in;
layout(max_vertices = 4, triangle_strip) out;
layout(location = 0) flat out int gsIndex;
int mg_ViewportIndex;
void main()
{
gsIndex = gl_InvocationID;
mg_ViewportIndex = gl_InvocationID;
gl_Position = vec4(-1.0, -1.0, 0.0, 1.0);
EmitVertex();
EndPrimitive();
}
)";
constexpr const char* kFragmentShader = R"(#version 320 es
precision mediump float;
precision highp int;
layout(location = 0) flat in int gsIndex;
layout(location = 0) out highp vec4 fragColor;
void main()
{
fragColor = vec4(float(gsIndex));
}
)";
} // namespace
// The promotion itself. The declaration becomes an interface variable and the STORE is left
// exactly where it was - the pass must not touch the body, because the body is the application's.
TEST(ViewportIndexRoutingTest, TheDemotedGlobalBecomesAFlatVarying) {
String source = kLoweredGeometryShader;
ASSERT_TRUE(PromoteViewportIndexGlobalToVarying(source)) << source;
EXPECT_TRUE(Contains(source, "flat out highp int mg_ViewportIndex;")) << source;
EXPECT_FALSE(Contains(source, "\nint mg_ViewportIndex;")) << source;
EXPECT_TRUE(Contains(source, " mg_ViewportIndex = gl_InvocationID;")) << source;
}
// FLAT is the semantics and not a hint: GL takes a primitive's viewport index from its provoking
// vertex, and flat interpolation is what delivers that. An interpolated integer would not even
// compile in ESSL, so losing the qualifier fails loudly - but silently losing it to a `smooth`
// rewrite somewhere downstream would route by whichever vertex the rasterizer felt like.
TEST(ViewportIndexRoutingTest, ThePromotedVaryingIsFlatAndCarriesNoExplicitLocation) {
String source = kLoweredGeometryShader;
ASSERT_TRUE(PromoteViewportIndexGlobalToVarying(source));
const size_t declPos = source.find("flat out highp int mg_ViewportIndex;");
ASSERT_NE(declPos, String::npos) << source;
// No layout(location = N): the two stages are transpiled independently and cannot agree on a
// number, so the varying is matched by NAME. A location that appeared here would have to
// appear identically in the fragment stage, which nothing can guarantee.
const size_t lineStart = source.rfind('\n', declPos);
const String declLine = source.substr(lineStart + 1, declPos - lineStart - 1);
EXPECT_EQ(declLine, "") << "the declaration must start its own line, with no layout qualifier";
}
// A precision-qualified declaration is the same declaration. SPIRV-Cross prints one or the other
// depending on what the module carried, and a pass that only matched the bare form would leave
// half the drivers unrouted while reporting success.
TEST(ViewportIndexRoutingTest, APrecisionQualifiedDeclarationIsPromotedToo) {
String source = "#version 320 es\nhighp int mg_ViewportIndex;\nvoid main() { mg_ViewportIndex = 3; }\n";
ASSERT_TRUE(PromoteViewportIndexGlobalToVarying(source)) << source;
EXPECT_TRUE(Contains(source, "flat out highp int mg_ViewportIndex;")) << source;
}
// A stage that never routed must come out byte-identical, because every stage of every program on
// this backend goes through the pass.
TEST(ViewportIndexRoutingTest, AStageWithoutTheGlobalIsUntouched) {
const String before = kFragmentShader;
String source = before;
EXPECT_FALSE(PromoteViewportIndexGlobalToVarying(source));
EXPECT_EQ(source, before);
}
// The one shape that would silently break a shader: a name that ends in mg_ViewportIndex but is
// not the declaration. Only a declaration starting its own line may be rewritten.
TEST(ViewportIndexRoutingTest, ADeclarationThatIsNotAtLineStartIsRefused) {
const String before = "#version 320 es\nuniform highp int mg_ViewportIndex;\nvoid main() {}\n";
String source = before;
EXPECT_FALSE(PromoteViewportIndexGlobalToVarying(source));
EXPECT_EQ(source, before);
}
// The fragment gate. Three things have to be true at once: the varying and the uniform are
// declared, the application's entry point survives under a new name, and the new entry point
// discards on a mask miss and calls the old one otherwise.
TEST(ViewportIndexRoutingTest, TheFragmentGateWrapsTheEntryPoint) {
String source = kFragmentShader;
ASSERT_TRUE(InjectViewportIndexPassGate(source)) << source;
EXPECT_TRUE(Contains(source, "flat in highp int mg_ViewportIndex;")) << source;
EXPECT_TRUE(Contains(source, "uniform highp int mg_ViewportPassMask;")) << source;
EXPECT_TRUE(Contains(source, "void mg_ViewportGatedMain()")) << source;
EXPECT_TRUE(Contains(source, "discard;")) << source;
EXPECT_TRUE(Contains(source, "mg_ViewportGatedMain();")) << source;
// The application's body is not edited, only renamed.
EXPECT_TRUE(Contains(source, " fragColor = vec4(float(gsIndex));")) << source;
// Exactly one entry point remains, and it is the wrapper.
EXPECT_EQ(source.find("void main()"), source.rfind("void main()")) << source;
}
// The shift operand has to be clamped. GL leaves a gl_ViewportIndex outside [0, MAX_VIEWPORTS)
// undefined and the emulation is free to pick anything, but an ESSL shift by >= 32 is undefined
// in a way that can take the whole draw with it - so the gate must not be able to reach one.
TEST(ViewportIndexRoutingTest, TheGateClampsTheShiftIntoRange) {
String source = kFragmentShader;
ASSERT_TRUE(InjectViewportIndexPassGate(source));
EXPECT_TRUE(Contains(source, "mg_ViewportPassMask >> (mg_ViewportIndex & 15)")) << source;
}
// A fragment stage that READS gl_ViewportIndex has no ESSL spelling for it either, and the
// routing varying is exactly the value it wanted. This is the only place the read can be repaired
// - LowerViewportIndexPass deliberately demotes outputs only, because a demoted input would
// answer from an undefined global.
TEST(ViewportIndexRoutingTest, AFragmentStageReadOfTheBuiltinIsRedirectedOntoTheVarying) {
String source = R"(#version 320 es
precision highp int;
layout(location = 0) out highp vec4 fragColor;
void main()
{
fragColor = vec4(float(gl_ViewportIndex));
}
)";
ASSERT_TRUE(InjectViewportIndexPassGate(source)) << source;
EXPECT_FALSE(Contains(source, "gl_ViewportIndex")) << source;
EXPECT_TRUE(Contains(source, "fragColor = vec4(float(mg_ViewportIndex));")) << source;
}
// A stage the pass declines must reach the driver exactly as it arrived, not half-rewritten.
// The caller logs the decline and the program still renders - unrouted, which is the old
// behaviour - so a partially edited source here would turn a degradation into a broken shader.
TEST(ViewportIndexRoutingTest, AStageWithNoEntryPointIsDeclinedWithoutBeingEdited) {
const String before = "#version 320 es\nprecision highp int;\nhighp int f() { return gl_ViewportIndex; }\n";
String source = before;
EXPECT_FALSE(InjectViewportIndexPassGate(source));
EXPECT_EQ(source, before);
}
@@ -45,6 +45,31 @@ namespace {
GLint maxFragmentSsboBlocks = 9;
bool tessAndGeometrySsboBlocksQueried = false;
bool perStageSsboBlockQueryRaisesError = false;
// GL_MAX_CLIP_DISTANCES. Not ES core in any version - it exists only as
// GL_MAX_CLIP_DISTANCES_EXT under GL_EXT_clip_cull_distance - so asking a driver without
// the extension raises GL_INVALID_ENUM and leaves the out-param untouched. The "queried"
// flag is what pins the gating; the "raises error" knob is what pins the drain.
GLint maxClipDistances = 8;
bool maxClipDistancesQueried = false;
bool clipDistanceQueryRaisesError = false;
// GL_MAX_VIEWPORTS / GL_VIEWPORT_SUBPIXEL_BITS / GL_VIEWPORT_BOUNDS_RANGE are
// GL_OES_viewport_array state and, like the clip-distance pname, exist nowhere in ES core.
GLint maxViewports = 32;
GLint viewportSubpixelBits = 8;
bool viewportArrayLimitsQueried = false;
// GL_LAYER_PROVOKING_VERTEX is ES 3.2 core; GL_VIEWPORT_INDEX_PROVOKING_VERTEX comes with
// GL_OES_viewport_array. Both must go unasked where they do not exist, and a driver answer
// outside the four legal conventions must not be forwarded as one.
GLint layerProvokingVertex = GL_FIRST_VERTEX_CONVENTION;
GLint viewportIndexProvokingVertex = GL_LAST_VERTEX_CONVENTION;
bool layerProvokingVertexQueried = false;
// A driver rejecting one of the UNCONDITIONAL probes. GL_SMOOTH_LINE_WIDTH_RANGE is the
// realistic one - it is desktop-only state that every GLES driver refuses - and it stands
// in for the whole run: whatever it leaves behind must not reach the application.
bool smoothLineWidthQueryRaisesError = false;
// What the driver answers for the four multisample ceilings. Zero is the value that has
// to be floored away: the frontend would otherwise advertise a sample count it rejects.
GLint multisampleCeiling = 4;
GLfloat minFragmentInterpolationOffset = -0.75f;
GLfloat maxFragmentInterpolationOffset = 0.625f;
GLint fragmentInterpolationOffsetBits = 6;
@@ -160,6 +185,37 @@ namespace {
case GL_MAX_COMPUTE_IMAGE_UNIFORMS:
*data = g_fake.maxComputeImageUniforms;
break;
case GL_MAX_CLIP_DISTANCES:
g_fake.maxClipDistancesQueried = true;
if (g_fake.clipDistanceQueryRaisesError) {
g_fake.pendingError = GL_INVALID_ENUM;
} else {
*data = g_fake.maxClipDistances;
}
break;
case GL_MAX_VIEWPORTS:
g_fake.viewportArrayLimitsQueried = true;
*data = g_fake.maxViewports;
break;
case GL_VIEWPORT_SUBPIXEL_BITS:
g_fake.viewportArrayLimitsQueried = true;
*data = g_fake.viewportSubpixelBits;
break;
case GL_VIEWPORT_INDEX_PROVOKING_VERTEX:
g_fake.viewportArrayLimitsQueried = true;
*data = g_fake.viewportIndexProvokingVertex;
break;
case GL_LAYER_PROVOKING_VERTEX:
g_fake.layerProvokingVertexQueried = true;
*data = g_fake.layerProvokingVertex;
break;
case GL_MAX_COLOR_TEXTURE_SAMPLES:
case GL_MAX_DEPTH_TEXTURE_SAMPLES:
case GL_MAX_FRAMEBUFFER_SAMPLES:
case GL_MAX_INTEGER_SAMPLES:
case GL_MAX_SAMPLES:
*data = g_fake.multisampleCeiling;
break;
case GL_FRAGMENT_INTERPOLATION_OFFSET_BITS:
g_fake.fragmentInterpolationLimitsQueried = true;
if (g_fake.fragmentInterpolationQueryRaisesError) {
@@ -239,11 +295,22 @@ namespace {
data[0] = g_fake.maxFragmentInterpolationOffset;
}
break;
case GL_SMOOTH_LINE_WIDTH_RANGE:
if (g_fake.smoothLineWidthQueryRaisesError) {
g_fake.pendingError = GL_INVALID_ENUM;
} else {
data[0] = 0.0f;
data[1] = 0.0f;
}
break;
case GL_VIEWPORT_BOUNDS_RANGE:
g_fake.viewportArrayLimitsQueried = true;
data[0] = 0.0f;
data[1] = 0.0f;
break;
// Two-component range queries.
case GL_ALIASED_LINE_WIDTH_RANGE:
case GL_SMOOTH_LINE_WIDTH_RANGE:
case GL_ALIASED_POINT_SIZE_RANGE:
case GL_VIEWPORT_BOUNDS_RANGE:
data[0] = 0.0f;
data[1] = 0.0f;
break;
@@ -642,6 +709,186 @@ TEST(PerStageStorageBlockCapabilities, ARejectedQueryIsDrainedAndFallsBackToTheS
EXPECT_EQ(g_fake.pendingError, static_cast<GLenum>(GL_NO_ERROR));
}
// GL_MAX_CLIP_DISTANCES is the same defect as the per-stage storage blocks above, one pname
// over: the query does not exist without GL_EXT_clip_cull_distance, so an unguarded probe left
// an optimistic 8 behind on every ARM driver. Advertising eight clip planes a driver cannot host
// does not make gl_ClipDistance work - SPIRV-Cross emits it behind an `#extension ... : require`
// the ESSL compiler rejects, DirectGLES has nowhere to put the per-distance enables, and the
// draw renders nothing while LINK_STATUS says everything is fine.
TEST(ClipDistanceCapabilities, NoExtensionMeansNoClipDistancesAndNoQuery) {
const auto funcs = MakeFakeGLESFunctions();
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_FALSE(caps.SupportsClipDistance);
EXPECT_EQ(caps.MaxClipDistances, 0);
EXPECT_FALSE(g_fake.maxClipDistancesQueried)
<< "GL_MAX_CLIP_DISTANCES is not ES core; asking for it without the extension only leaks "
"a GL_INVALID_ENUM";
}
// The other half of the same claim, and the one that keeps this from being a blanket zero: a
// driver that HAS the extension must have its real limit come through untouched. Adreno does,
// and it passes the clip-distance conformance cases on the strength of it.
TEST(ClipDistanceCapabilities, TheExtensionIsQueriedAndItsLimitIsReportedVerbatim) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_EXT_clip_cull_distance");
g_fake.maxClipDistances = 6;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_TRUE(caps.SupportsClipDistance);
EXPECT_TRUE(g_fake.maxClipDistancesQueried);
EXPECT_EQ(caps.MaxClipDistances, 6);
}
// A driver that advertises the extension and then refuses the query is a driver fault, not a
// missing feature - but the answer has to be the honest zero either way, and the error must not
// be left for the application's first glGetError to find.
TEST(ClipDistanceCapabilities, ARejectedQueryIsDrainedAndReportsZero) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_EXT_clip_cull_distance");
g_fake.clipDistanceQueryRaisesError = true;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_TRUE(g_fake.maxClipDistancesQueried);
EXPECT_EQ(caps.MaxClipDistances, 0);
EXPECT_EQ(funcs.glGetError(), GL_NO_ERROR) << "the failed query must not leave an error behind";
}
// The same defect one more time, for the three GL_OES_viewport_array pnames. Their advertised
// values do not come from the driver (GL_Getter answers GL_MAX_VIEWPORTS from the frontend state
// width and floors GL_SUBPIXEL_BITS at its own constant), so what this pins is the other half of
// the class defect: a pname that does not exist must not be asked for, because the GL_INVALID_ENUM
// it raises is then attributed to whatever the application calls next.
TEST(ViewportArrayCapabilities, TheLimitsAreOnlyAskedForWhenTheExtensionIsPresent) {
const auto funcs = MakeFakeGLESFunctions();
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
MobileGL::MG_External::GLESCapabilities withoutCaps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(withoutCaps, funcs));
EXPECT_FALSE(withoutCaps.SupportsViewportArray);
EXPECT_FALSE(g_fake.viewportArrayLimitsQueried);
EXPECT_EQ(withoutCaps.MaxViewports, 16) << "the OpenGL core minimum, not a driver answer";
EXPECT_FLOAT_EQ(withoutCaps.ViewportBoundsRangeMin, -32768.0f);
EXPECT_FLOAT_EQ(withoutCaps.ViewportBoundsRangeMax, 32767.0f);
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_OES_viewport_array");
MobileGL::MG_External::GLESCapabilities withCaps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(withCaps, funcs));
EXPECT_TRUE(withCaps.SupportsViewportArray);
EXPECT_TRUE(g_fake.viewportArrayLimitsQueried);
EXPECT_EQ(withCaps.MaxViewports, g_fake.maxViewports);
EXPECT_EQ(withCaps.ViewportSubpixelBits, g_fake.viewportSubpixelBits);
}
// GL_LAYER_PROVOKING_VERTEX and GL_VIEWPORT_INDEX_PROVOKING_VERTEX name which vertex of a
// primitive supplies gl_Layer and gl_ViewportIndex. MobileGL used to answer a hard-coded
// GL_LAST_VERTEX_CONVENTION for both, derived from nothing, and got it wrong on both test devices
// in OPPOSITE directions. GL_UNDEFINED_VERTEX is a legal answer (GL 4.6 table 23.65) and it is
// the honest one wherever the capability that would give the convention meaning is absent.
TEST(ProvokingVertexConventions, AreTakenFromTheDriverOnlyWhereThePnameExists) {
const auto funcs = MakeFakeGLESFunctions();
// ES 3.1, no viewport array: neither pname exists, so neither is asked for.
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
MobileGL::MG_External::GLESCapabilities es31Caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(es31Caps, funcs));
EXPECT_FALSE(g_fake.layerProvokingVertexQueried);
EXPECT_EQ(es31Caps.LayerProvokingVertex, static_cast<GLenum>(GL_UNDEFINED_VERTEX));
EXPECT_EQ(es31Caps.ViewportIndexProvokingVertex, static_cast<GLenum>(GL_UNDEFINED_VERTEX));
// ES 3.2 with the viewport array: both exist and both driver answers come through verbatim.
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.glesMinorVersion = 2;
g_fake.extensions.emplace_back("GL_OES_viewport_array");
MobileGL::MG_External::GLESCapabilities es32Caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(es32Caps, funcs));
EXPECT_TRUE(g_fake.layerProvokingVertexQueried);
EXPECT_EQ(es32Caps.LayerProvokingVertex, static_cast<GLenum>(GL_FIRST_VERTEX_CONVENTION));
EXPECT_EQ(es32Caps.ViewportIndexProvokingVertex, static_cast<GLenum>(GL_LAST_VERTEX_CONVENTION));
// ES 3.2 WITHOUT the viewport array - the shape of both test devices. The layer convention is
// real and comes from the driver; the viewport-index one describes a selection that never
// happens, because only viewport 0 is ever rasterized, and stays undefined.
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.glesMinorVersion = 2;
MobileGL::MG_External::GLESCapabilities deviceLikeCaps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(deviceLikeCaps, funcs));
EXPECT_EQ(deviceLikeCaps.LayerProvokingVertex, static_cast<GLenum>(GL_FIRST_VERTEX_CONVENTION));
EXPECT_EQ(deviceLikeCaps.ViewportIndexProvokingVertex, static_cast<GLenum>(GL_UNDEFINED_VERTEX));
}
// A driver answering something that is not one of the four legal conventions must not have it
// forwarded as one: GL_UNDEFINED_VERTEX describes "MobileGL cannot tell you" exactly.
TEST(ProvokingVertexConventions, AnIllegalDriverAnswerBecomesUndefined) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.glesMinorVersion = 2;
g_fake.layerProvokingVertex = 0x1234;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_TRUE(g_fake.layerProvokingVertexQueried);
EXPECT_EQ(caps.LayerProvokingVertex, static_cast<GLenum>(GL_UNDEFINED_VERTEX));
}
// The multisample ceilings are ES 3.1 state; a driver that answers zero - or an older context
// that answers nothing - must not have that reach GL_Getter, which would then reject the sample
// count it just advertised.
TEST(MultisampleCapabilities, TheAdvertisedSampleCountsNeverFallBelowOne) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.multisampleCeiling = 0;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_EQ(caps.MaxColorTextureSamples, 1);
EXPECT_EQ(caps.MaxDepthTextureSamples, 1);
EXPECT_EQ(caps.MaxFramebufferSamples, 1);
EXPECT_EQ(caps.MaxIntegerSamples, 1);
EXPECT_EQ(caps.MaxSamples, 1);
EXPECT_EQ(caps.MaxSampleMaskWords, 1);
}
// The whole point of the drain, stated once at the level that matters: capability init is the
// first thing that ever touches the driver, so an error it leaves behind surfaces at the
// APPLICATION's first glGetError and is blamed on an unrelated call. GL_SMOOTH_LINE_WIDTH_RANGE
// is the stand-in because it is desktop-only state that every real GLES driver refuses.
TEST(CapabilityProbeHygiene, ARejectedUnconditionalProbeLeavesNoErrorBehind) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.smoothLineWidthQueryRaisesError = true;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_EQ(funcs.glGetError(), GL_NO_ERROR)
<< "capability init must not hand the application an error it never caused";
}
TEST(FragmentInterpolationCapabilities, QueriesOnlyWhenSupportedAndPreservesDriverLimits) {
const auto funcs = MakeFakeGLESFunctions();
+111
View File
@@ -8,6 +8,7 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <limits>
#include "Includes.h"
@@ -267,6 +268,116 @@ TEST_F(BufferTest, AcquireMemoryRangeWithExplicit) {
ASSERT_EQ(actual, expected);
}
// GL_MIN_MAP_BUFFER_ALIGNMENT is a promise about POINTERS, and MobileGL used to keep only the
// query half of it: glGetIntegerv answered 64 while every mapped pointer came out of a plain
// std::vector, aligned to alignof(std::max_align_t) - 16 on aarch64. GL 4.2 /
// ARB_map_buffer_alignment fix the minimum at 64, so under-reporting is not available and the
// implementation has to be brought up to the number instead. Note the two different constraints:
// glMapBuffer's pointer must be aligned outright, while glMapBufferRange's must be aligned AFTER
// subtracting the offset the caller asked for - i.e. it sits at the offset's own alignment phase.
// KHR-GLxx.map_buffer_alignment.functional asserts exactly these two, at offset 63, for 24
// storage-flag combinations across 14 targets, and failed identically on both test devices.
TEST_F(BufferTest, MappedPointersHonourTheAdvertisedMapBufferAlignment) {
GLint advertisedAlignment = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MIN_MAP_BUFFER_ALIGNMENT, &advertisedAlignment);
ASSERT_EQ(advertisedAlignment, static_cast<GLint>(MobileGL::MG_State::GLState::MIN_MAP_BUFFER_ALIGNMENT))
<< "the query and the allocator must read the same constant";
ASSERT_GE(advertisedAlignment, 64) << "GL 4.2 fixes the minimum at 64";
const SizeT alignment = static_cast<SizeT>(advertisedAlignment);
auto& slot = MobileGL::MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Uniform);
Vector<Uint> bufferNames;
MobileGL::MG_State::pGLContext->GenBufferNames(1, bufferNames);
auto bufObj = MobileGL::MG_State::pGLContext->CreateBufferObject(bufferNames[0]);
slot.Bind(bufObj);
// The conformance test's own shape: a buffer two alignments long, mapped from the last byte
// inside the first alignment - the offset most likely to expose a base-aligned-only fix.
const SizeT bufferSize = 2 * alignment;
const SizeT offset = alignment - 1;
bufObj->Resize(bufferSize);
Vector<Uint8> initData(bufferSize);
for (SizeT i = 0; i < bufferSize; ++i) initData[i] = static_cast<Uint8>(i);
bufObj->UploadData(DataPtr{.data = initData.data(), .size = bufferSize}, 0);
const auto addressOf = [](const void* pointer) { return reinterpret_cast<std::uintptr_t>(pointer); };
// glMapBuffer, read-only: the shadow base itself is handed out.
void* readMapped = bufObj->AcquireMemory(true, true, false);
ASSERT_NE(readMapped, nullptr);
EXPECT_EQ(addressOf(readMapped) % alignment, 0u) << "glMapBuffer(GL_READ_ONLY) returned an unaligned pointer";
bufObj->ReleaseMemory();
// glMapBuffer, write: the staging store is handed out instead.
void* writeMapped = bufObj->AcquireMemory(true, false, true);
ASSERT_NE(writeMapped, nullptr);
EXPECT_EQ(addressOf(writeMapped) % alignment, 0u) << "glMapBuffer(GL_WRITE_ONLY) returned an unaligned pointer";
EXPECT_EQ(bufObj->GetMappedPointer(), writeMapped)
<< "GL_BUFFER_MAP_POINTER must report the pointer the map returned";
bufObj->ReleaseMemory();
// glMapBufferRange, read-only: shadow base + offset, so the phase falls out for free.
const Range1D mapRange{.start = offset, .end = bufferSize};
void* rangeRead = bufObj->AcquireMemoryRange(mapRange, BufferMappingAccessBit::Read);
ASSERT_NE(rangeRead, nullptr);
EXPECT_EQ((addressOf(rangeRead) - offset) % alignment, 0u)
<< "glMapBufferRange(READ) returned a pointer whose base is unaligned";
bufObj->ReleaseMemory();
// glMapBufferRange, write: the staging store has to be biased to the same phase, and the
// write-back has to follow the bias or the bytes land at the wrong place in the shadow.
Uint8* rangeWrite = static_cast<Uint8*>(bufObj->AcquireMemoryRange(mapRange, BufferMappingAccessBit::Write));
ASSERT_NE(rangeWrite, nullptr);
EXPECT_EQ((addressOf(rangeWrite) - offset) % alignment, 0u)
<< "glMapBufferRange(WRITE) returned a pointer whose base is unaligned";
EXPECT_EQ(bufObj->GetMappedPointer(), rangeWrite)
<< "GL_BUFFER_MAP_POINTER must report the pointer the map returned";
// Seeded from the shadow, so the mapped view starts at the offset's byte.
EXPECT_EQ(rangeWrite[0], static_cast<Uint8>(offset));
rangeWrite[0] = 0xAB;
rangeWrite[bufferSize - offset - 1] = 0xCD;
bufObj->ReleaseMemory();
Vector<Uint8> readBack(bufferSize);
bufObj->DownloadSubData(readBack.data(), 0, bufferSize);
EXPECT_EQ(readBack[offset], 0xAB) << "the biased staging write-back landed at the wrong offset";
EXPECT_EQ(readBack[bufferSize - 1], 0xCD) << "the biased staging write-back landed at the wrong offset";
EXPECT_EQ(readBack[offset - 1], static_cast<Uint8>(offset - 1)) << "the write-back overran the mapped range";
}
// The explicit-flush path reads through the same bias, one flush offset further in: a flush of
// [offset + 4, offset + 8) must copy the bytes the application wrote at rangeWrite[4..8), not the
// ones sitting four bytes into the raw allocation.
TEST_F(BufferTest, ExplicitFlushOfARangeMapFollowsTheAlignmentBias) {
auto& slot = MobileGL::MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Uniform);
Vector<Uint> bufferNames;
MobileGL::MG_State::pGLContext->GenBufferNames(1, bufferNames);
auto bufObj = MobileGL::MG_State::pGLContext->CreateBufferObject(bufferNames[0]);
slot.Bind(bufObj);
const SizeT alignment = MobileGL::MG_State::GLState::MIN_MAP_BUFFER_ALIGNMENT;
const SizeT bufferSize = 2 * alignment;
const SizeT offset = alignment - 1;
bufObj->Resize(bufferSize);
Vector<Uint8> initData(bufferSize, 0);
bufObj->UploadData(DataPtr{.data = initData.data(), .size = bufferSize}, 0);
const Range1D mapRange{.start = offset, .end = bufferSize};
Uint8* mapped = static_cast<Uint8*>(bufObj->AcquireMemoryRange(
mapRange, BufferMappingAccessBit::Write | BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
mapped[4] = 0x5A;
mapped[5] = 0x5B;
bufObj->FlushMemoryRange(4, 2);
bufObj->ReleaseMemory();
Vector<Uint8> readBack(bufferSize);
bufObj->DownloadSubData(readBack.data(), 0, bufferSize);
EXPECT_EQ(readBack[offset + 4], 0x5A);
EXPECT_EQ(readBack[offset + 5], 0x5B);
EXPECT_EQ(readBack[offset + 3], 0x00) << "the explicit flush copied bytes outside the flushed range";
}
TEST_F(BufferTest, CopyBufferSubData) {
auto& srcSlot = MobileGL::MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::CopyRead);
auto& dstSlot = MobileGL::MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::CopyWrite);
@@ -877,7 +877,7 @@ TEST_F(AsyncSpirvPhaseTest, AsyncOffAndAsyncOnProduceIdenticalSpirvAndShadow) {
// The ASYNC arm runs FIRST, deliberately. Both arms must compile the same source text for
// their SPIR-V to be comparable, and the first arm to run is the one that pays for the
// cold path: it misses the per-context ShaderPreprocessCache and therefore executes
// PreprocessShaderSource, the reserved-identifier scan and both lexical extractions. Run
// PreprocessShaderSource and the lexical rejection scans. Run
// the sync arm first and the async arm becomes a cache hit that never runs any of that on
// a worker - which is exactly the half this case exists to compare.
const SingleWorkerScope oneWorker;
@@ -716,6 +716,257 @@ void main() {
EXPECT_EQ(TakeError(), GL_INVALID_ENUM);
}
// The CLASSIC query surface has to agree with the interface query above. MobileGL lowers
// every atomic_uint onto a synthesized gl_AtomicCounterBlock_N, and glGetActiveUniform /
// glGetActiveUniformsiv used to report that lowering: GL_UNSIGNED_INT instead of
// GL_UNSIGNED_INT_ATOMIC_COUNTER, the synthesized block's index instead of the -1 a
// default-block uniform owes, and GL_INVALID_ENUM for
// GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX - the last of which is what made
// KHR-GL43.shader_atomic_counters.basic-program-query a forced FAIL.
TEST_F(ProgramInterfaceTest, AtomicCounterClassicUniformQueries) {
const char* fs = R"(#version 430
out vec4 color;
layout (binding = 0, offset = 0) uniform atomic_uint ac_counter0;
layout (binding = 1, offset = 0) uniform atomic_uint ac_counter1;
uniform float plain;
void main() {
color = vec4(float(atomicCounterIncrement(ac_counter0) + atomicCounterIncrement(ac_counter1)) + plain);
}
)";
const GLuint p = MakeProgram(kSimpleVs, fs);
LinkProgram(p);
ExpectLinked(p);
ClearErrors();
const auto indexOf = [p](const char* name) {
const GLchar* names[1] = {name};
GLuint index = GL_INVALID_INDEX;
GetUniformIndices(p, 1, names, &index);
return index;
};
const auto uniformiv = [p](GLuint index, GLenum pname) {
GLint value = -12345;
const GLuint indices[1] = {index};
GetActiveUniformsiv(p, 1, indices, pname, &value);
return value;
};
const GLuint counter0 = indexOf("ac_counter0");
const GLuint counter1 = indexOf("ac_counter1");
const GLuint plain = indexOf("plain");
ASSERT_NE(counter0, GL_INVALID_INDEX);
ASSERT_NE(counter1, GL_INVALID_INDEX);
ASSERT_NE(plain, GL_INVALID_INDEX);
// (a) glGetActiveUniform and glGetActiveUniformsiv(GL_UNIFORM_TYPE) both report the
// GL-level type.
GLint size = 0;
GLenum type = 0;
GLchar nameBuffer[64] = {'\0'};
GetActiveUniform(p, counter0, sizeof(nameBuffer), nullptr, &size, &type, nameBuffer);
EXPECT_EQ(type, static_cast<GLenum>(GL_UNSIGNED_INT_ATOMIC_COUNTER));
EXPECT_EQ(std::string(nameBuffer), "ac_counter0");
EXPECT_EQ(uniformiv(counter0, GL_UNIFORM_TYPE), GL_UNSIGNED_INT_ATOMIC_COUNTER);
EXPECT_EQ(uniformiv(counter1, GL_UNIFORM_TYPE), GL_UNSIGNED_INT_ATOMIC_COUNTER);
EXPECT_EQ(uniformiv(plain, GL_UNIFORM_TYPE), GL_FLOAT);
// (b) an atomic counter is a DEFAULT-BLOCK uniform, whatever it was lowered onto.
EXPECT_EQ(uniformiv(counter0, GL_UNIFORM_BLOCK_INDEX), -1);
EXPECT_EQ(uniformiv(counter1, GL_UNIFORM_BLOCK_INDEX), -1);
EXPECT_EQ(uniformiv(plain, GL_UNIFORM_BLOCK_INDEX), -1);
// (c) the pname is accepted, answers with the buffer's index, and reports -1 for a
// uniform that is not a counter. Two bindings mean two distinct buffers.
const GLint buffer0 = uniformiv(counter0, GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX);
const GLint buffer1 = uniformiv(counter1, GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX);
EXPECT_GE(buffer0, 0);
EXPECT_GE(buffer1, 0);
EXPECT_NE(buffer0, buffer1);
EXPECT_LT(buffer0, Interfaceiv(p, GL_ATOMIC_COUNTER_BUFFER, GL_ACTIVE_RESOURCES));
EXPECT_LT(buffer1, Interfaceiv(p, GL_ATOMIC_COUNTER_BUFFER, GL_ACTIVE_RESOURCES));
EXPECT_EQ(uniformiv(plain, GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX), -1);
// No leftover error: the CTS harness fails the subcase on one.
EXPECT_EQ(TakeError(), GL_NO_ERROR);
// The classic surface and the interface query name the same buffer.
const std::vector<GLint> interfaceBuffer =
PropsOf(p, GL_UNIFORM, "ac_counter0", {GL_ATOMIC_COUNTER_BUFFER_INDEX});
ASSERT_EQ(interfaceBuffer.size(), 1u);
EXPECT_EQ(interfaceBuffer[0], buffer0);
EXPECT_EQ(TakeError(), GL_NO_ERROR);
}
// Two counters that share a binding AND an offset must fail to link. glslang's own check
// lives in fixOffset(), which the Vulkan-relaxed parse never reaches - it folds the
// atomic_uint into a storage block and returns from declareVariable() first - so the pair
// used to link cleanly and then increment the same four bytes.
TEST_F(ProgramInterfaceTest, OverlappingAtomicCounterOffsetsFailToLink) {
const char* fs = R"(#version 430
out vec4 color;
layout (binding = 0, offset = 0) uniform atomic_uint a;
layout (binding = 0, offset = 0) uniform atomic_uint b;
void main() { color = vec4(float(atomicCounterIncrement(a) + atomicCounterIncrement(b))); }
)";
const GLuint p = MakeProgram(kSimpleVs, fs);
LinkProgram(p);
GLint status = -1;
GetProgramiv(p, GL_LINK_STATUS, &status);
EXPECT_EQ(status, GL_FALSE);
char log[4096] = "";
GetProgramInfoLog(p, sizeof(log), nullptr, log);
EXPECT_NE(std::string(log).find("overlap"), std::string::npos) << "info log was: " << log;
ClearErrors();
// Distinct offsets at one binding, and the same offset at two different bindings, are
// both legal and must still link - a check keyed any wider would reject them.
const char* legalFs = R"(#version 430
out vec4 color;
layout (binding = 0, offset = 0) uniform atomic_uint a;
layout (binding = 0, offset = 4) uniform atomic_uint b;
layout (binding = 1, offset = 0) uniform atomic_uint c;
void main() {
color = vec4(float(atomicCounterIncrement(a) + atomicCounterIncrement(b) + atomicCounterIncrement(c)));
}
)";
const GLuint legal = MakeProgram(kSimpleVs, legalFs);
LinkProgram(legal);
ExpectLinked(legal);
ClearErrors();
}
// GL 4.6 core 7.6 fails the link when a stage's active image uniforms exceed
// GL_MAX_*_IMAGE_UNIFORMS, or when their sum exceeds GL_MAX_COMBINED_IMAGE_UNIFORMS. Nothing
// counted them - glslang keeps those numbers only so gl_Max*ImageUniforms can expand from
// them - so every deliberately-oversized program in
// KHR-GL4x.shader_image_load_store.uniform-limits linked cleanly and then rendered nothing.
//
// Sized off the ADVERTISED limits rather than a constant, because the numbers come from the
// active backend and the whole point of the check is that the two agree.
TEST_F(ProgramInterfaceTest, ImageUniformsOverAStageLimitFailToLink) {
GLint maxFragmentImages = 0;
GLint maxCombinedImages = 0;
GetIntegerv(GL_MAX_FRAGMENT_IMAGE_UNIFORMS, &maxFragmentImages);
GetIntegerv(GL_MAX_COMBINED_IMAGE_UNIFORMS, &maxCombinedImages);
ClearErrors();
ASSERT_GT(maxFragmentImages, 0);
// The fragment stage is compiled explicitly so a COMPILE failure can never be mistaken
// for the link failure under test.
const auto linkWithFragmentImages = [](GLint count) {
const std::string n = std::to_string(count);
const std::string source = std::string(R"(#version 430
out vec4 color;
layout(r32i) uniform iimage2D u_image[)") + n + R"(];
void main() {
int value = 1;
for (int i = 0; i < )" + n + R"(; ++i) {
value = imageAtomicAdd(u_image[i], ivec2(0), value);
}
color = vec4(float(value));
}
)";
const char* sourcePtr = source.c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &sourcePtr, nullptr);
CompileShader(fs);
GLint compiled = 0;
GetShaderiv(fs, GL_COMPILE_STATUS, &compiled);
EXPECT_EQ(compiled, GL_TRUE) << "the fragment stage with " << count << " image uniforms must compile";
const GLuint vs = CreateShader(GL_VERTEX_SHADER);
ShaderSource(vs, 1, &kSimpleVs, nullptr);
CompileShader(vs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
return program;
};
const GLuint over = linkWithFragmentImages(maxFragmentImages + 1);
GLint status = -1;
GetProgramiv(over, GL_LINK_STATUS, &status);
EXPECT_EQ(status, GL_FALSE);
char log[4096] = "";
GetProgramInfoLog(over, sizeof(log), nullptr, log);
EXPECT_NE(std::string(log).find("GL_MAX_FRAGMENT_IMAGE_UNIFORMS"), std::string::npos)
<< "info log was: " << log;
ClearErrors();
// Exactly AT the limit is legal and must still link: the comparison is strictly
// greater-than, and the conformance suite's combined-stage subcase builds a program that
// fills every stage to its own limit and expects it to link whenever the combined limit
// can hold them.
if (maxFragmentImages <= maxCombinedImages) {
const GLuint atLimit = linkWithFragmentImages(maxFragmentImages);
ExpectLinked(atLimit);
ClearErrors();
}
}
// glGetProgramiv(GL_ACTIVE_ATOMIC_COUNTER_BUFFERS) and glGetActiveAtomicCounterBufferiv are
// the pre-4.3 spelling of the interface above, and the spec requires the two to agree.
// Neither did: the first counted glslang's atomic counter UNIFORMS - zero, because the
// relaxed parse folds every atomic_uint into a storage block before reflection runs - and
// the second was a stub that wrote nothing and raised nothing.
TEST_F(ProgramInterfaceTest, ActiveAtomicCounterBufferQueriesMatchTheInterface) {
const char* fs = R"(#version 430
out vec4 color;
layout (binding = 1, offset = 0) uniform atomic_uint a;
layout (binding = 2, offset = 0) uniform atomic_uint b;
layout (binding = 2, offset = 4) uniform atomic_uint c;
void main() {
color = vec4(float(atomicCounterIncrement(a) + atomicCounterIncrement(b) + atomicCounterIncrement(c)));
}
)";
const GLuint p = MakeProgram(kSimpleVs, fs);
LinkProgram(p);
ExpectLinked(p);
ClearErrors();
GLint bufferCount = -12345;
GetProgramiv(p, GL_ACTIVE_ATOMIC_COUNTER_BUFFERS, &bufferCount);
EXPECT_EQ(bufferCount, Interfaceiv(p, GL_ATOMIC_COUNTER_BUFFER, GL_ACTIVE_RESOURCES));
ASSERT_EQ(bufferCount, 2);
const auto activeBufferiv = [p](GLuint index, GLenum pname) {
GLint value = -12345;
GetActiveAtomicCounterBufferiv(p, index, pname, &value);
return value;
};
for (GLuint index = 0; index < static_cast<GLuint>(bufferCount); ++index) {
const std::vector<GLint> viaInterface =
Props(p, GL_ATOMIC_COUNTER_BUFFER, index,
{GL_BUFFER_BINDING, GL_BUFFER_DATA_SIZE, GL_NUM_ACTIVE_VARIABLES,
GL_REFERENCED_BY_VERTEX_SHADER, GL_REFERENCED_BY_FRAGMENT_SHADER});
ASSERT_EQ(viaInterface.size(), 5u);
EXPECT_EQ(activeBufferiv(index, GL_ATOMIC_COUNTER_BUFFER_BINDING), viaInterface[0]);
EXPECT_EQ(activeBufferiv(index, GL_ATOMIC_COUNTER_BUFFER_DATA_SIZE), viaInterface[1]);
EXPECT_EQ(activeBufferiv(index, GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTERS), viaInterface[2]);
EXPECT_EQ(activeBufferiv(index, GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_VERTEX_SHADER), viaInterface[3]);
EXPECT_EQ(activeBufferiv(index, GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_FRAGMENT_SHADER), viaInterface[4]);
// The counter indices are the GL_UNIFORM indices, in the same order.
const std::vector<GLint> expectedIndices = Props(p, GL_ATOMIC_COUNTER_BUFFER, index, {GL_ACTIVE_VARIABLES});
ASSERT_FALSE(expectedIndices.empty());
std::vector<GLint> indices(expectedIndices.size(), -12345);
GetActiveAtomicCounterBufferiv(p, index, GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTER_INDICES,
indices.data());
EXPECT_EQ(indices, expectedIndices);
}
EXPECT_EQ(TakeError(), GL_NO_ERROR);
GLint sink = -12345;
GetActiveAtomicCounterBufferiv(p, static_cast<GLuint>(bufferCount), GL_ATOMIC_COUNTER_BUFFER_BINDING, &sink);
EXPECT_EQ(TakeError(), GL_INVALID_VALUE);
EXPECT_EQ(sink, -12345) << "a rejected query must not write the caller's output";
// The interface-query spelling of the same property is NOT accepted here.
GetActiveAtomicCounterBufferiv(p, 0, GL_BUFFER_BINDING, &sink);
EXPECT_EQ(TakeError(), GL_INVALID_ENUM);
EXPECT_EQ(sink, -12345);
}
// --------------------------------------------------------- transform-feedback ------
TEST_F(ProgramInterfaceTest, TransformFeedbackVaryingTypes) {
const char* vs = R"(#version 430
+588 -18
View File
@@ -1934,10 +1934,19 @@ TEST_F(ProgramTest, GetActiveUniformsivErrors) {
EXPECT_EQ(GetError(), GL_INVALID_VALUE);
EXPECT_EQ(params[0], -999);
// E3: GL 4.2 token -> GL_INVALID_ENUM here.
// E3: the GL 4.2 / ARB_shader_atomic_counters token is ACCEPTED, not rejected.
//
// This case used to assert GL_INVALID_ENUM, which was right only while the token was
// unimplemented. It is implemented now, and `validIndex` names an ordinary uniform rather
// than an atomic counter, so the spec answer is -1 with no error (GL 4.6 core table 7.6).
// ProgramInterfaceTest's atomic-counter case asserts the same -1 for a non-counter
// uniform; leaving this one inverted made the two contradict each other.
GetActiveUniformsiv(program, 1, &validIndex, GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX, params);
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
EXPECT_EQ(params[0], -999);
EXPECT_EQ(GetError(), GL_NO_ERROR);
EXPECT_EQ(params[0], -1);
// Restored: the cases below assert that a REJECTED call leaves params untouched, and this
// one legitimately wrote to it.
params[0] = -999;
// E4a: a live shader name -> GL_INVALID_OPERATION.
GLuint shader = CreateShader(GL_VERTEX_SHADER);
@@ -2162,6 +2171,69 @@ void main() {
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Repro for KHR-GLES31.explicit_uniform_location.uniform-loc-arrays-of-arrays: an
// array-of-arrays uniform reaches the GL surface as one entry PER SUB-ARRAY ("u0[0]",
// "u0[1]" - glslang stops expanding at reflection granularity), while SPIRV-Reflect keeps
// it as a single leaf carrying every dimension. Routing the single leaf only ever covered
// the first sub-array, so every element from u0[1][0] on found no UBO offset and fell
// through to the fallback scratch at the tail of the shadow - storage the GPU never reads,
// which made those glUniform writes silently vanish.
TEST_F(ProgramTest, ArrayOfArraysUniformElementOffsets) {
// Arrays of arrays need GLSL 4.30; both stages take the same version.
const char* vsSource = R"(#version 430 core
in vec4 a_position;
void main() {
gl_Position = a_position;
})";
const char* fsSource = R"(#version 430 core
uniform float u0[2][3];
uniform vec3 u1[2][2];
out vec4 o_color;
void main() {
float s = 0.0;
for (int i = 0; i < 2; ++i) {
for (int j = 0; j < 3; ++j) s += u0[i][j];
}
vec3 v = vec3(0.0);
for (int i = 0; i < 2; ++i) {
for (int j = 0; j < 2; ++j) v += u1[i][j];
}
o_color = vec4(v, s);
})";
GLuint program = LinkVsFsProgram(vsSource, fsSource);
UseProgram(program);
auto programObject = MG_State::pGLContext->GetProgramObject(program);
ASSERT_NE(programObject, nullptr);
// std140 gives a float array element and a vec3 array element the same 16-byte slot,
// and a flattened array-of-arrays is one contiguous run of those slots.
constexpr Uint kStd140ElementStride = 16u;
const auto checkFlattenedRun = [&](const char* base, int outer, int inner) {
Uint firstOffset = MG_State::GLState::ProgramObject::kInvalidUniformOffset;
for (int i = 0; i < outer; ++i) {
for (int j = 0; j < inner; ++j) {
const std::string name =
std::string(base) + "[" + std::to_string(i) + "][" + std::to_string(j) + "]";
const GLint location = GetUniformLocation(program, name.c_str());
ASSERT_GE(location, 0) << name;
const Uint offset = programObject->GetUniformOffset(static_cast<Uint>(location));
ASSERT_NE(offset, MG_State::GLState::ProgramObject::kInvalidUniformOffset) << name;
const Uint element = static_cast<Uint>(i * inner + j);
if (element == 0) {
firstOffset = offset;
} else {
EXPECT_EQ(offset, firstOffset + element * kStd140ElementStride) << name;
}
}
}
};
checkFlattenedRun("u0", 2, 3);
checkFlattenedRun("u1", 2, 2);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------
// GL CTS KHR-GL33.shaders.uniform_block regression pack. MobileGL's SPIR-V
// pipeline lays every uniform block out as std140; the frontend implements the
@@ -2341,6 +2413,235 @@ void main() {
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
namespace {
// One program carrying all four block/uniform kinds at once: a real uniform block, a
// shader storage block, an atomic counter (which the transpiler lowers onto a synthesized
// gl_AtomicCounterBlock_N buffer block) and plain default-block uniforms.
//
// MobileGL does not pass EShReflectionSeparateBuffers to glslang's buildReflection, so
// glslang routes BUFFER blocks through indexToUniformBlock alongside the uniform blocks -
// which is why every one of these has to be classified explicitly rather than taken at
// face value from the reflection list.
// The storage block and the counter are declared FIRST on purpose: that pushes both
// uniform blocks off the front of the block list, so the GL uniform-block index and the
// internal block index of every one of them differ. A translation that quietly reused one
// space for the other would answer with the storage block's name, size and binding here.
const char* kMixedBlockKindsFs = R"(#version 430
layout(std430, binding = 0) buffer AVeryLongStorageBlockName {
vec4 storageVec;
};
layout(binding = 1, offset = 0) uniform atomic_uint counter;
layout(std140) uniform Blk {
vec4 uboVec;
};
layout(std140) uniform Blk2 {
vec4 uboVec2[3];
};
uniform float uScale;
out vec4 o_color;
void main() {
o_color = uboVec * uScale + uboVec2[1] + storageVec + vec4(float(atomicCounterIncrement(counter)));
})";
const char* kMixedBlockKindsVs = R"(#version 430
void main() { gl_Position = vec4(0.0); })";
} // namespace
// GL 4.6 core 7.6: GL_ACTIVE_UNIFORM_BLOCKS and the glGetActiveUniformBlock* /
// glGetUniformBlockIndex family enumerate ACTUAL uniform blocks. An atomic counter buffer is
// enumerated by GL_ACTIVE_ATOMIC_COUNTER_BUFFERS and a shader storage block by the
// GL_SHADER_STORAGE_BLOCK program interface; neither may appear in the uniform-block list.
TEST_F(ProgramTest, UniformBlockListExcludesStorageAndAtomicCounterBlocks) {
GLuint program = LinkVsFsProgram(kMixedBlockKindsVs, kMixedBlockKindsFs);
GLint activeBlocks = -1;
GetProgramiv(program, GL_ACTIVE_UNIFORM_BLOCKS, &activeBlocks);
ASSERT_EQ(activeBlocks, 2) << "only 'Blk' and 'Blk2' are GL uniform blocks";
// GL_ACTIVE_UNIFORM_BLOCK_MAX_NAME_LENGTH is measured over that same list, so the far
// longer storage-block name must not raise it.
GLint maxBlockNameLength = -1;
GetProgramiv(program, GL_ACTIVE_UNIFORM_BLOCK_MAX_NAME_LENGTH, &maxBlockNameLength);
EXPECT_EQ(maxBlockNameLength, static_cast<GLint>(std::strlen("Blk2") + 1));
const GLuint blk = GetUniformBlockIndex(program, "Blk");
const GLuint blk2 = GetUniformBlockIndex(program, "Blk2");
ASSERT_NE(blk, GL_INVALID_INDEX);
ASSERT_NE(blk2, GL_INVALID_INDEX);
EXPECT_LT(blk, 2u);
EXPECT_LT(blk2, 2u);
EXPECT_NE(blk, blk2);
EXPECT_EQ(GetUniformBlockIndex(program, "AVeryLongStorageBlockName"), GL_INVALID_INDEX);
EXPECT_EQ(GetUniformBlockIndex(program, "gl_AtomicCounterBlock_1"), GL_INVALID_INDEX);
EXPECT_EQ(GetError(), GL_NO_ERROR);
// Every index in the list names one of the two, and each index answers with ITS OWN
// block's properties - the storage block sits ahead of both in the internal block space,
// so a query answered in the wrong space reports "AVeryLongStorageBlockName" here.
char nameBuf[128] = "";
GLsizei nameLen = 0;
GetActiveUniformBlockName(program, blk, sizeof(nameBuf), &nameLen, nameBuf);
EXPECT_STREQ(nameBuf, "Blk");
GetActiveUniformBlockName(program, blk2, sizeof(nameBuf), &nameLen, nameBuf);
EXPECT_STREQ(nameBuf, "Blk2");
GLint dataSize = -1;
GetActiveUniformBlockiv(program, blk, GL_UNIFORM_BLOCK_DATA_SIZE, &dataSize);
EXPECT_EQ(dataSize, 16) << "Blk is one vec4";
GetActiveUniformBlockiv(program, blk2, GL_UNIFORM_BLOCK_DATA_SIZE, &dataSize);
EXPECT_EQ(dataSize, 48) << "Blk2 is a vec4[3]";
GLint nameLengthProp = -1;
GetActiveUniformBlockiv(program, blk2, GL_UNIFORM_BLOCK_NAME_LENGTH, &nameLengthProp);
EXPECT_EQ(nameLengthProp, static_cast<GLint>(std::strlen("Blk2") + 1));
// glUniformBlockBinding lands on the block the GL index names, and reads back through the
// same index.
UniformBlockBinding(program, blk2, 7);
GLint binding = -1;
GetActiveUniformBlockiv(program, blk2, GL_UNIFORM_BLOCK_BINDING, &binding);
EXPECT_EQ(binding, 7);
GetActiveUniformBlockiv(program, blk, GL_UNIFORM_BLOCK_BINDING, &binding);
EXPECT_NE(binding, 7) << "the rebind must not have leaked onto the neighbouring block";
EXPECT_EQ(GetError(), GL_NO_ERROR);
// An index past the end of the (now shorter) list is GL_INVALID_VALUE, not a silently
// answered query about a storage block.
GLint sink = -12345;
GetActiveUniformBlockiv(program, static_cast<GLuint>(activeBlocks), GL_UNIFORM_BLOCK_BINDING, &sink);
EXPECT_EQ(GetError(), GL_INVALID_VALUE);
EXPECT_EQ(sink, -12345);
UniformBlockBinding(program, static_cast<GLuint>(activeBlocks), 1);
EXPECT_EQ(GetError(), GL_INVALID_VALUE);
GetActiveUniformBlockName(program, static_cast<GLuint>(activeBlocks), sizeof(nameBuf), &nameLen, nameBuf);
EXPECT_EQ(GetError(), GL_INVALID_VALUE);
// Each block's own member resolves against the block index this list hands out.
const GLuint uboVec = UniformIndexByName(program, "uboVec");
const GLuint uboVec2 = UniformIndexByName(program, "uboVec2[0]");
ASSERT_NE(uboVec, GL_INVALID_INDEX);
ASSERT_NE(uboVec2, GL_INVALID_INDEX);
EXPECT_EQ(QueryUniformiv(program, uboVec, GL_UNIFORM_BLOCK_INDEX), static_cast<GLint>(blk));
EXPECT_EQ(QueryUniformiv(program, uboVec2, GL_UNIFORM_BLOCK_INDEX), static_cast<GLint>(blk2));
GLint blockMemberCount = -1;
GetActiveUniformBlockiv(program, blk, GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS, &blockMemberCount);
EXPECT_EQ(blockMemberCount, 1);
GLint blockMemberIndex = -1;
GetActiveUniformBlockiv(program, blk, GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES, &blockMemberIndex);
EXPECT_EQ(static_cast<GLuint>(blockMemberIndex), uboVec);
GetActiveUniformBlockiv(program, blk2, GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES, &blockMemberIndex);
EXPECT_EQ(static_cast<GLuint>(blockMemberIndex), uboVec2);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The GL_UNIFORM_BLOCK program interface hands out indices that are usable with
// glUniformBlockBinding / glGetActiveUniformBlockiv (ARB_program_interface_query), so it has
// to enumerate exactly the same list - not the internal block space that also carries the
// storage and atomic counter blocks.
TEST_F(ProgramTest, UniformBlockProgramInterfaceMatchesTheUniformBlockList) {
GLuint program = LinkVsFsProgram(kMixedBlockKindsVs, kMixedBlockKindsFs);
GLint interfaceBlocks = -1;
GetProgramInterfaceiv(program, GL_UNIFORM_BLOCK, GL_ACTIVE_RESOURCES, &interfaceBlocks);
GLint activeBlocks = -1;
GetProgramiv(program, GL_ACTIVE_UNIFORM_BLOCKS, &activeBlocks);
EXPECT_EQ(interfaceBlocks, activeBlocks);
ASSERT_EQ(interfaceBlocks, 2);
// The storage block is enumerated by its OWN interface instead.
GLint storageBlocks = -1;
GetProgramInterfaceiv(program, GL_SHADER_STORAGE_BLOCK, GL_ACTIVE_RESOURCES, &storageBlocks);
EXPECT_EQ(storageBlocks, 1);
EXPECT_EQ(GetProgramResourceIndex(program, GL_UNIFORM_BLOCK, "AVeryLongStorageBlockName"), GL_INVALID_INDEX);
EXPECT_NE(GetProgramResourceIndex(program, GL_SHADER_STORAGE_BLOCK, "AVeryLongStorageBlockName"),
GL_INVALID_INDEX);
// ... and the buffer variable by GL_BUFFER_VARIABLE, not GL_UNIFORM.
EXPECT_NE(GetProgramResourceIndex(program, GL_BUFFER_VARIABLE, "storageVec"), GL_INVALID_INDEX);
EXPECT_EQ(GetProgramResourceIndex(program, GL_UNIFORM, "storageVec"), GL_INVALID_INDEX);
for (const char* blockName : {"Blk", "Blk2"}) {
const GLuint interfaceIndex = GetProgramResourceIndex(program, GL_UNIFORM_BLOCK, blockName);
ASSERT_NE(interfaceIndex, GL_INVALID_INDEX) << blockName;
EXPECT_EQ(interfaceIndex, GetUniformBlockIndex(program, blockName)) << blockName;
// GL_NUM_ACTIVE_VARIABLES / GL_ACTIVE_VARIABLES must reach the same member the
// glGetActiveUniformBlockiv spelling does.
const GLenum numActive = GL_NUM_ACTIVE_VARIABLES;
GLint memberCount = -1;
GetProgramResourceiv(program, GL_UNIFORM_BLOCK, interfaceIndex, 1, &numActive, 1, nullptr, &memberCount);
ASSERT_EQ(memberCount, 1) << blockName;
const GLenum activeVariables = GL_ACTIVE_VARIABLES;
GLint memberIndex = -1;
GetProgramResourceiv(program, GL_UNIFORM_BLOCK, interfaceIndex, 1, &activeVariables, 1, nullptr,
&memberIndex);
GLint viaBlockiv = -1;
GetActiveUniformBlockiv(program, interfaceIndex, GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES, &viaBlockiv);
EXPECT_EQ(memberIndex, viaBlockiv) << blockName;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// GL 4.6 core 7.3.1 / 7.6: a buffer variable is not a uniform - it lives in the
// GL_BUFFER_VARIABLE interface - so it must not appear in GL_ACTIVE_UNIFORMS,
// glGetActiveUniform, glGetUniformIndices or glGetActiveUniformsiv. An ATOMIC COUNTER, by
// contrast, IS a uniform (of type GL_UNSIGNED_INT_ATOMIC_COUNTER) and must stay enumerated.
TEST_F(ProgramTest, ActiveUniformsExcludeBufferVariablesButKeepAtomicCounters) {
GLuint program = LinkVsFsProgram(kMixedBlockKindsVs, kMixedBlockKindsFs);
GLint activeUniforms = -1;
GetProgramiv(program, GL_ACTIVE_UNIFORMS, &activeUniforms);
ASSERT_EQ(activeUniforms, 4)
<< "uboVec, uboVec2[0], uScale and counter - storageVec is a buffer variable";
// Neither spelling of the buffer variable is a uniform index.
EXPECT_EQ(UniformIndexByName(program, "storageVec"), GL_INVALID_INDEX);
EXPECT_EQ(UniformIndexByName(program, "AVeryLongStorageBlockName.storageVec"), GL_INVALID_INDEX);
// The location half of the same rule (already landed) must stay consistent with it.
EXPECT_EQ(GetUniformLocation(program, "storageVec"), -1);
char nameBuf[128] = "";
for (GLint i = 0; i < activeUniforms; ++i) {
GLsizei nameLen = 0;
GLint size = 0;
GLenum type = 0;
GetActiveUniform(program, static_cast<GLuint>(i), sizeof(nameBuf), &nameLen, &size, &type, nameBuf);
EXPECT_EQ(std::string(nameBuf).find("storageVec"), std::string::npos)
<< "buffer variable enumerated as active uniform " << i << ": " << nameBuf;
}
// The counter is still a uniform, still reports the atomic-counter type, has no owning
// uniform block, and still points at its atomic counter BUFFER.
const GLuint counter = UniformIndexByName(program, "counter");
ASSERT_NE(counter, GL_INVALID_INDEX);
EXPECT_EQ(QueryUniformiv(program, counter, GL_UNIFORM_TYPE),
static_cast<GLint>(GL_UNSIGNED_INT_ATOMIC_COUNTER));
EXPECT_EQ(QueryUniformiv(program, counter, GL_UNIFORM_BLOCK_INDEX), -1);
EXPECT_EQ(QueryUniformiv(program, counter, GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX), 0);
EXPECT_EQ(QueryUniformiv(program, counter, GL_UNIFORM_OFFSET), 0);
EXPECT_EQ(GetUniformLocation(program, "counter"), -1);
// GL_ACTIVE_ATOMIC_COUNTER_BUFFERS indexes into the GL uniform index space, so the
// counter index it reports has to be the one glGetUniformIndices just handed out.
GLint counterBuffers = -1;
GetProgramiv(program, GL_ACTIVE_ATOMIC_COUNTER_BUFFERS, &counterBuffers);
ASSERT_EQ(counterBuffers, 1);
GLint counterCount = -1;
GetActiveAtomicCounterBufferiv(program, 0, GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTERS, &counterCount);
ASSERT_EQ(counterCount, 1);
GLint counterIndex = -1;
GetActiveAtomicCounterBufferiv(program, 0, GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTER_INDICES,
&counterIndex);
EXPECT_EQ(static_cast<GLuint>(counterIndex), counter);
GLint counterBinding = -1;
GetActiveAtomicCounterBufferiv(program, 0, GL_ATOMIC_COUNTER_BUFFER_BINDING, &counterBinding);
EXPECT_EQ(counterBinding, 1);
// The default-block uniform is untouched by either filter.
EXPECT_NE(GetUniformLocation(program, "uScale"), -1);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
TEST_F(ProgramTest, DeleteShaderWhileAttachedKeepsNameUsableUntilDetach) {
// GL CTS compiles through exactly this sequence (create, attach, DELETE, source,
// compile): glDeleteShader on an attached shader only flags it, and the name must
@@ -2868,9 +3169,9 @@ void main() { fragColor = vec4(pow(uBase, 2.2), 1.0); }
// Layer 1 - the same shader object re-sourced with byte-identical text keeps its
// compiled state, and glCompileShader on it is a no-op.
// Layer 2 - two DIFFERENT shader objects holding byte-identical text share the
// source-only half of the pipeline (preprocess + lexical checks +
// side-channel extraction) through the context's ShaderPreprocessCache,
// while each still gets its own glslang parse.
// source-only half of the pipeline (preprocess + the lexical rejection
// checks) through the context's ShaderPreprocessCache, while each still
// gets its own glslang parse.
// ---------------------------------------------------------------------------
namespace {
const char* kP0bVs = R"(#version 330 core
@@ -3010,21 +3311,38 @@ TEST_F(ProgramTest, TwoShaderObjectsWithIdenticalSourceLinkIndependently) {
ASSERT_NE(objectA, nullptr);
ASSERT_NE(objectB, nullptr);
EXPECT_EQ(objectA->GetShaderSource(), objectB->GetShaderSource());
// P0b's layer 2 shares the PREPROCESS and never the parse: glslang's TShader is
// consume-once, so a memo hit still has to parse for itself.
// WHAT THIS CASE IS ACTUALLY ABOUT: two GL shader names holding the same text must never
// end up feeding one TShader to two links, because mapIO mutates the aliased intermediate
// and the second link would get a corrupted one. There are now three mechanisms that keep
// that true, and which one is in play depends on the mode - so the assertion below is on
// the PARSES NOT BEING SHARED, never on where each object's parse came from:
//
// P1 stage 6 shares something stronger when it is active - the whole compile JOB, and
// therefore the single parse that job produced - and that sharing is made safe by
// ShaderCompileTask::ClaimParsedShader's CAS instead, exactly as it already was for one
// shader object attached to two programs. ShaderCompileAdoptionTest is where that is
// pinned down (it links both objects and compares the generated SPIR-V). So the
// one-parse-per-object assertion belongs to the non-adopting path; the two independent
// LINKS below are what both modes have to agree on, and they are the point of this case.
// * P0b layer 2 shares the PREPROCESS and never the parse, so each object parses for
// itself. This was the only mechanism when the case was written.
// * P1 stage 6, when async is active, shares the whole compile JOB and therefore its
// single parse - made safe by ClaimParsedShader's CAS, exactly as it already was for
// one shader object attached to two programs. ShaderCompileAdoptionTest pins that
// down by linking both objects and comparing the generated SPIR-V.
// * The translation memo's compile half (L1c) recognises the second object's source and
// publishes its verdict WITHOUT parsing, so that object legitimately holds no TShader
// at all until a link asks ClaimParsedShader for one. Asserting a non-null parse here
// would be asserting that the parse had NOT been skipped - i.e. testing the absence
// of the optimisation rather than the invariant.
//
// So the pointer assertion applies only where the two objects are genuinely INDEPENDENT,
// i.e. where job adoption is not in play. What every mode has to agree on is the two
// independent LINKS below, and they are the real point of this case.
if (!MG_Util::Async::AsyncShaderCompileActive()) {
EXPECT_NE(objectA->GetCompiledShader(), objectB->GetCompiledShader());
const auto& shaderA = objectA->GetCompiledShader();
const auto& shaderB = objectB->GetCompiledShader();
// Either may legitimately hold NO parse: that is an L1c hit, where the AST is made on
// demand at link instead. So this asserts they are not the SAME non-null parse, and
// deliberately not that both have one - the latter would be asserting that the
// optimisation had not happened.
if (shaderA != nullptr && shaderB != nullptr) {
EXPECT_NE(shaderA, shaderB) << "two independent shader objects share one consume-once parse";
}
}
EXPECT_NE(objectA->GetCompiledShader(), nullptr);
EXPECT_NE(objectB->GetCompiledShader(), nullptr);
GLuint programA = LinkVsFs(vsA, fsA, GL_TRUE);
GLuint programB = LinkVsFs(vsB, fsB, GL_TRUE);
@@ -3239,3 +3557,255 @@ TEST_F(ProgramTest, CreateShaderAndCreateShaderProgramvReportTheRightErrorClasse
EXPECT_NE(program, 0u);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ARB_explicit_uniform_location / GL 4.6 core 7.6.1: a `layout(location = N)` uniform reserves N
// EVEN WHEN IT IS INACTIVE. Dead default-block uniforms are correctly filtered off the GL surface
// (glGetUniformLocation must answer -1 for them), but the implicit allocator used to walk straight
// over the location they claimed and hand it to a uniform that never asked for it
// (KHR-GL43.explicit_uniform_location.uniform-loc-mix-with-implicit3).
TEST_F(ProgramTest, InactiveExplicitUniformLocationIsStillReserved) {
const char* vsSource = R"(#version 430 core
layout(location = 2) uniform vec4 uDeadAtTwo;
uniform vec4 uA;
uniform vec4 uB;
uniform vec4 uC;
uniform vec4 uD;
void main() { gl_Position = uA + uB + uC + uD; }
)";
const char* fsSource = R"(#version 430 core
out vec4 fragColor;
void main() { fragColor = vec4(1.0); }
)";
const GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
const GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
const GLuint program = LinkVsFs(vs, fs, GL_TRUE);
// Reserving a location must not resurrect the uniform: it is still inactive to GL.
EXPECT_EQ(GetUniformLocation(program, "uDeadAtTwo"), -1);
for (const char* name : {"uA", "uB", "uC", "uD"}) {
const GLint location = GetUniformLocation(program, name);
EXPECT_GE(location, 0) << name << " lost its implicit location";
EXPECT_NE(location, 2) << name << " was handed the location uDeadAtTwo reserved";
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The GL_MAX_UNIFORM_LOCATIONS boundary, from both sides. MAX_UNIFORM_LOCATIONS - 1 is the LAST
// LEGAL location: it has to link and read back verbatim
// (KHR-GL43.explicit_uniform_location.uniform-loc-max), which is only true while the advertised
// value and what the link accepts are the SAME number - the getter used to advertise one more
// location than any shader could name.
//
// The over-the-ceiling half is asserted through an ARRAY, because that is the only spelling the
// link gets to judge: a bare `layout(location = MAX)` is already a compile error inside glslang
// ("location is too large"), while an array's base compiles fine and only its last element passes
// the ceiling (...uniform-loc-negative-link-max-num-of-locations).
TEST_F(ProgramTest, ExplicitUniformLocationsHonourMaxUniformLocations) {
GLint maxLocations = 0;
GetIntegerv(GL_MAX_UNIFORM_LOCATIONS, &maxLocations);
ASSERT_GE(maxLocations, 1024) << "GL 4.3 requires at least 1024 uniform locations";
const char* fsSource = R"(#version 430 core
out vec4 fragColor;
void main() { fragColor = vec4(1.0); }
)";
const GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
{
const String source = String("#version 430 core\nlayout(location = ") +
std::to_string(maxLocations - 1) +
") uniform vec4 uAtLimit;\nvoid main() { gl_Position = uAtLimit; }\n";
const GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, source.c_str());
const GLuint program = LinkVsFs(vs, fs, GL_TRUE);
EXPECT_EQ(GetUniformLocation(program, "uAtLimit"), maxLocations - 1)
<< "the last location in the pool is legal and must come back verbatim";
}
{
const String source = String("#version 430 core\nlayout(location = ") +
std::to_string(maxLocations - 4) +
") uniform vec4 uSpill[8];\nvoid main() { gl_Position = uSpill[0]; }\n";
const GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, source.c_str());
(void)LinkVsFs(vs, fs, GL_FALSE);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// GL 4.6 core 7.6: an atomic counter is a default-block uniform that addresses an ATOMIC COUNTER
// buffer, where every counter is a tightly packed 4-byte value. MobileGL lowers each atomic_uint
// onto a synthesized block, which used to drag the whole array-stride query onto the std140 rule
// that rounds an element stride up to a vec4 - so an atomic counter array reported 16
// (KHR-GL43.shader_atomic_counters.basic-program-query: "GL_UNIFORM_ARRAY_STRIDE is 16 should be
// 4"). The offsets, matrix stride and row-major flag are pinned alongside it because the same
// synthesized block feeds all four queries.
TEST_F(ProgramTest, AtomicCounterArrayReportsThePackedFourByteStride) {
const char* vsSource = R"(#version 430 core
void main() { gl_Position = vec4(1.0); }
)";
const char* fsSource = R"(#version 430 core
layout(location = 0) out vec4 o_color;
layout(binding = 0, offset = 0) uniform atomic_uint ac_counter0;
layout(binding = 0, offset = 4) uniform atomic_uint ac_counter1;
layout(binding = 0) uniform atomic_uint ac_counter2;
layout(binding = 0) uniform atomic_uint ac_counter67[2];
layout(binding = 0) uniform atomic_uint ac_counter3;
void main() {
uint c = 0u;
c += atomicCounterIncrement(ac_counter0);
c += atomicCounterIncrement(ac_counter1);
c += atomicCounterIncrement(ac_counter2);
c += atomicCounterIncrement(ac_counter3);
c += atomicCounterIncrement(ac_counter67[0]);
c += atomicCounterIncrement(ac_counter67[1]);
o_color = vec4(float(c));
}
)";
const GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
const GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
const GLuint program = LinkVsFs(vs, fs, GL_TRUE);
GLint activeUniforms = 0;
GetProgramiv(program, GL_ACTIVE_UNIFORMS, &activeUniforms);
ASSERT_EQ(activeUniforms, 5);
// Declared offset -> expected {array size, array stride}. layout(offset=) pins the first two;
// the rest are packed after them in declaration order, the array taking two 4-byte slots.
struct Expectation {
const char* name;
GLint size;
GLint offset;
GLint arrayStride;
};
const Expectation expectations[] = {
{"ac_counter0", 1, 0, 0}, {"ac_counter1", 1, 4, 0}, {"ac_counter2", 1, 8, 0},
{"ac_counter67[0]", 2, 12, 4}, {"ac_counter3", 1, 20, 0},
};
for (const auto& expected : expectations) {
const char* queryName = expected.name;
GLuint index = GL_INVALID_INDEX;
GetUniformIndices(program, 1, &queryName, &index);
ASSERT_NE(index, GL_INVALID_INDEX) << expected.name << " is not an active uniform";
GLint value = -2;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_TYPE, &value);
EXPECT_EQ(value, static_cast<GLint>(GL_UNSIGNED_INT_ATOMIC_COUNTER)) << expected.name;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_SIZE, &value);
EXPECT_EQ(value, expected.size) << expected.name;
// An atomic counter is a default-block uniform however it was lowered.
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_BLOCK_INDEX, &value);
EXPECT_EQ(value, -1) << expected.name;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_OFFSET, &value);
EXPECT_EQ(value, expected.offset) << expected.name;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_ARRAY_STRIDE, &value);
EXPECT_EQ(value, expected.arrayStride) << expected.name;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_MATRIX_STRIDE, &value);
EXPECT_EQ(value, 0) << expected.name;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_IS_ROW_MAJOR, &value);
EXPECT_EQ(value, 0) << expected.name;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX, &value);
EXPECT_EQ(value, 0) << expected.name;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// GL 4.6 core 7.6.1: a uniform LOCATION is a property of the default uniform block. A member of
// a named uniform block or a buffer block has none, and glGetUniformLocation must answer -1 for
// it - which is what glGetProgramResourceLocation(GL_UNIFORM, ...) already did, so the two used
// to disagree. The location such a member was handed was not merely reported, it was CONSUMED:
// it came out of the same first-fit table the default-block uniforms draw from.
TEST_F(ProgramTest, BlockMembersConsumeNoUniformLocation) {
const char* csSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 1) buffer ResultBuffer { vec4 bufferMember; };
layout(std140, binding = 2) uniform SettingsBlock { vec4 blockMember; };
layout(location = 0) uniform float uDead[3];
uniform float uImplicit;
void main() { bufferMember = blockMember * uImplicit; }
)";
const GLuint cs = CompileShaderChecked(GL_COMPUTE_SHADER, csSource);
const GLuint program = CreateProgram();
AttachShader(program, cs);
LinkProgram(program);
GLint linkStatus = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
char infoLog[1024] = "";
GetProgramInfoLog(program, sizeof(infoLog), nullptr, infoLog);
ASSERT_EQ(linkStatus, GL_TRUE) << infoLog;
for (const char* member : {"bufferMember", "blockMember"}) {
EXPECT_EQ(GetUniformLocation(program, member), -1) << member << " is a block member, not a GL uniform";
EXPECT_EQ(GetProgramResourceLocation(program, GL_UNIFORM, member), -1)
<< member << ": the two location queries must agree";
}
// uDead[3] reserves 0..2 without becoming visible, so the first location left for the one
// default-block uniform is 3. It used to be 4, because a block member took 3 first.
EXPECT_EQ(GetUniformLocation(program, "uImplicit"), 3)
<< "a block member consumed a location the default-block uniform was entitled to";
EXPECT_EQ(GetUniformLocation(program, "uDead"), -1);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The same defect at the boundary, which is where the conformance suite catches it. The location
// table's ceiling is raised to hold every uniform it must place; counting block members into that
// raise pushed the ceiling to GL_MAX_UNIFORM_LOCATIONS itself, and the first-fit pass then handed
// out the one location past the legal 0..MAX-1 range
// (KHR-GL43.explicit_uniform_location.uniform-loc-mix-with-implicit-max, whose compute program
// carries an SSBO: "Uniform u2 returned location (4095) is greater than implementation dependent
// limit (4095)"). Its -array sibling shares the root cause and failed one step further along, with
// the pool reported exhausted and no link at all.
TEST_F(ProgramTest, ImplicitLocationStaysInRangeWhenABufferBlockSharesTheProgram) {
GLint maxLocations = 0;
GetIntegerv(GL_MAX_UNIFORM_LOCATIONS, &maxLocations);
ASSERT_GE(maxLocations, 1024) << "GL 4.3 requires at least 1024 uniform locations";
// The CTS shape: explicit unused arrays fill the pool except for a hole of `implicitCount`
// locations at `holeBase`, and the one implicit uniform must land exactly in that hole.
const auto runCase = [&](int holeBase, int implicitCount) {
String decls;
int nextName = 0;
if (holeBase > 0) {
decls += "layout(location = 0) uniform float u" + std::to_string(nextName++) + "[" +
std::to_string(holeBase) + "];\n";
}
const int tailBase = holeBase + implicitCount;
if (tailBase < maxLocations) {
decls += "layout(location = " + std::to_string(tailBase) + ") uniform float u" +
std::to_string(nextName++) + "[" + std::to_string(maxLocations - tailBase) + "];\n";
}
const String implicitName = "u" + std::to_string(nextName);
decls += "uniform float " + implicitName + "[" + std::to_string(implicitCount) + "];\n";
// The buffer block is the whole point: it is one more uniform the table has to seat, and
// seating it inside the location space is what used to push the implicit uniform out.
const String csSource = "#version 430 core\n"
"layout(local_size_x = 1) in;\n"
"layout(std430, binding = 1) buffer ResultBuffer { vec4 cs_result; };\n" +
decls + "void main() { cs_result = vec4(" + implicitName + "[0]); }\n";
const GLuint cs = CompileShaderChecked(GL_COMPUTE_SHADER, csSource.c_str());
const GLuint program = CreateProgram();
AttachShader(program, cs);
LinkProgram(program);
GLint linkStatus = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
char infoLog[1024] = "";
GetProgramInfoLog(program, sizeof(infoLog), nullptr, infoLog);
ASSERT_EQ(linkStatus, GL_TRUE) << "hole at " << holeBase << " x" << implicitCount << ": " << infoLog;
const GLint location = GetUniformLocation(program, implicitName.c_str());
EXPECT_EQ(location, holeBase) << "the implicit uniform must take the one free span left";
EXPECT_LT(location + implicitCount, maxLocations + 1)
<< "locations " << location << ".." << (location + implicitCount - 1)
<< " must stay inside 0.." << (maxLocations - 1);
EXPECT_EQ(GetUniformLocation(program, "cs_result"), -1);
};
// The three holes the CTS walks, for its single-uniform and its 3-element-array subcase.
for (const int implicitCount : {1, 3}) {
runCase(0, implicitCount);
runCase(3, implicitCount);
runCase(maxLocations - implicitCount, implicitCount);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
+757 -28
View File
@@ -22,6 +22,7 @@
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
#include <MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.h>
#include <MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.h>
#include <MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.h>
#include <MG_Util/ShaderTranspiler/SpirvPasses/RenameSamplerFunctionParameterPass.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <MG_Util/ShaderTranspiler/glslang/UniformTraverser.h>
@@ -2130,9 +2131,11 @@ void main() {
}
// The case the old masker actually broke: an apostrophe in real (non-comment) text. Everything after
// it looked like string interior, so ExtractExplicitUniformLocations tokenized a blank source and
// handed the GL location assigner an empty map - the uniform silently lost its explicit location.
TEST_F(ProgramUtilTest, PreprocessApostropheInDirectiveKeepsLaterCodeVisibleToExtractors) {
// it looked like string interior, so the rewriter's own scans went blind past it - which is still
// what this pins, now that the explicit location itself is recovered from the parse rather than
// from a scan. The two halves have to agree end to end: the preprocessed text must still declare
// the uniform, AND the parse must still hand its location back.
TEST_F(ProgramUtilTest, PreprocessApostropheInDirectiveKeepsLaterCodeVisibleToTheParse) {
using namespace MG_Util::ShaderTranspiler;
String source = R"(#version 460 core
@@ -2147,15 +2150,15 @@ void main() {
)";
PreprocessShaderSource(ShaderStage::Fragment, source);
const UnorderedMap<String, Int> locations = ExtractExplicitUniformLocations(source);
ASSERT_EQ(locations.count("tint"), 1u) << "extractor went blind past the apostrophe:\n" << source;
EXPECT_EQ(locations.at("tint"), 7);
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) {
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
}
const UnorderedMap<String, Int> locations = CollectExplicitUniformLocations(*res.value());
ASSERT_EQ(locations.count("tint"), 1u) << "the rewriter went blind past the apostrophe:\n" << source;
EXPECT_EQ(locations.at("tint"), 7);
}
// PreprocessShaderSource used to rediscover "where does the #version directive end?" once per
@@ -2339,8 +2342,7 @@ namespace {
auto result = MakeShared<ShaderPreprocessResult>();
result->outcome = ShaderPreprocessOutcome::Preprocessed;
result->preprocessedSource = preprocessed;
result->explicitUniformLocations["uMarker"] = 7;
result->explicitOpaqueBindings["sMarker"] = 3;
result->infoLog = "marker:" + preprocessed;
return result;
}
} // namespace
@@ -2357,12 +2359,9 @@ TEST_F(ProgramUtilTest, ShaderPreprocessCacheRoundTripsAndSeparatesStages) {
ASSERT_NE(hit, nullptr);
EXPECT_TRUE(hit->Preprocessed());
EXPECT_EQ(hit->preprocessedSource, "vertex-preprocessed");
const auto uniformIt = hit->explicitUniformLocations.find("uMarker");
ASSERT_NE(uniformIt, hit->explicitUniformLocations.end());
EXPECT_EQ(uniformIt->second, 7);
const auto bindingIt = hit->explicitOpaqueBindings.find("sMarker");
ASSERT_NE(bindingIt, hit->explicitOpaqueBindings.end());
EXPECT_EQ(bindingIt->second, 3u);
// The whole payload round-trips, not just the text: every field the entry carries has to
// come back, or a hit would publish a half-populated result.
EXPECT_EQ(hit->infoLog, "marker:vertex-preprocessed");
// Byte-identical source, different stage: a different key, so still a miss. Two
// stages sharing one entry would hand a fragment shader a vertex preprocess.
@@ -3041,6 +3040,47 @@ void main() {
<< "the generated ESSL still indexes a fragment output with a non-constant:\n" << essl;
}
// Marking a loop for unrolling means marking every loop enclosing it - SPIRV-Tools only unrolls
// innermost loops - and the copies those levels produce MULTIPLY, so bounding each loop on its
// own bounds nothing. This nest is the OIT shape wrapped in a tile walk: 64 x 64 x 2, every level
// individually inside kMaxUnrolledIterations, and its product is not. Spending the budget as the
// walk climbs stops at the innermost level; the switch lowering, whose cost is the output array's
// length rather than the trip counts, legalizes whatever the unroll no longer reaches. The same
// defect was measured first on LegalizeResourceArrayIndexPass, which the image half of that pass
// made reachable; this walk is its twin and is fixed the same way.
TEST_F(ProgramUtilTest, ALoopNestAroundAFragmentOutputIndexIsBoundedAsAWhole) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = CompileFragmentToRawSpirv(R"(#version 330 core
out vec4 coeff[2];
in vec4 vColor;
void main() {
for (int y = 0; y < 64; ++y) {
for (int x = 0; x < 64; ++x) {
for (int attachmentIndex = 0; attachmentIndex < 2; ++attachmentIndex) {
coeff[attachmentIndex] = vColor * float(x + y + attachmentIndex);
}
}
}
}
)");
ASSERT_FALSE(raw.empty());
ASSERT_TRUE(LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(raw))
<< "the fixture must reproduce the defect before the fix is asked to remove it:\n"
<< DisassembleSpirv(raw);
Vector<Uint32> legalized;
ASSERT_TRUE(ShaderCompiler::LegalizeFragmentOutputIndexingForEssl(raw, legalized, true));
ASSERT_FALSE(legalized.empty());
// Still legalized - that is not what is being traded away.
EXPECT_FALSE(LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(legalized));
// ...and the module the driver has to compile is still a module, not the nest's product.
// Measured on this fixture: 318 words with the nest budget, 5112 without - so the bound is
// loose enough not to pin spirv-opt's exact output (3x the real figure) and tight enough
// that a nest-wide unroll cannot slip under it (5x below the unbounded one).
EXPECT_LT(legalized.size(), 1024u) << "legalized module is " << legalized.size() << " words";
}
// The fallback half: an index computed from a uniform cannot be folded by any amount of
// unrolling, so the write becomes a switch over the array's range and the read becomes
// constant-indexed loads combined with selects.
@@ -3348,11 +3388,38 @@ namespace {
return count;
}
// Same word walk, for the NON-arrayed half of the family (Arrayed == 0).
SizeT Count1DNonArrayedStorageImageTypes(const Vector<Uint32>& spirv) {
constexpr unsigned kOpTypeImage = 25, kDim1D = 0;
SizeT count = 0;
for (SizeT i = 5; i < spirv.size();) {
const unsigned wordCount = spirv[i] >> 16;
const unsigned opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
if (opcode == kOpTypeImage && wordCount >= 8 && spirv[i + 3] == kDim1D && spirv[i + 5] == 0u &&
spirv[i + 7] == 2u) {
++count;
}
i += wordCount;
}
return count;
}
const char* k1DArrayImageCompute = R"(#version 440 core
layout (local_size_x = 1) in;
layout (location = 0, r32ui) readonly uniform uimage1DArray i0;
layout (std430, binding = 0) buffer SSB { uint sum; } ssb;
void main() { ssb.sum = imageLoad(i0, ivec2(2, 3)).r; }
)";
// KHR-GL4x.shader_image_load_store.basic-allTargets-atomic's own shape, minus the six other
// targets: a non-arrayed 1D storage image reached ONLY through an atomic. r32ui because ES
// defines image atomics on r32i/r32ui/r32f alone.
const char* k1DImageAtomicCompute = R"(#version 440 core
layout (local_size_x = 1) in;
layout (r32ui) coherent uniform uimage1D i0;
layout (std430, binding = 0) buffer SSB { uint sum; } ssb;
void main() { ssb.sum = imageAtomicAdd(i0, 2, 7u); }
)";
} // namespace
@@ -3464,9 +3531,11 @@ void main() { ssb.sum = imageLoad(i0, ivec2(2, 3)).r + imageLoad(i1, ivec3(1, 1,
EXPECT_NE(essl.find("ivec3(2, 0, 3)"), String::npos) << essl;
}
// Scope, half one: a NON-arrayed 1D storage image is emitted correctly by the very same
// SPIRV-Cross code, so the pass must not touch it - replacing working emission with our own buys
// nothing and risks everything.
// Scope, half one: a NON-arrayed 1D storage image that is only READ or WRITTEN is emitted
// correctly by the very same SPIRV-Cross code, so the pass must not touch it - replacing working
// emission with our own buys nothing and risks everything. (The atomic shape below is the one
// exception, and it is gated on an OpImageTexelPointer actually being present, which is why this
// fixture still passes through byte for byte.)
TEST_F(ProgramUtilTest, Lower1DArrayImagesLeavesNonArrayed1DImagesToSpirvCross) {
using namespace MG_Util::ShaderTranspiler;
@@ -3489,6 +3558,94 @@ void main() { ssb.sum = imageLoad(i0, 2).r; }
<< "SPIRV-Cross's own 1D-as-2D emulation must still be what handles this:\n" << essl;
}
// The negative control for the ATOMIC half, and the reason the non-arrayed case is in scope at
// all: SPIRV-Cross widens a 1D image coordinate in OpImageRead and OpImageWrite but not in
// OpImageTexelPointer, so the atomic comes out addressing an `uimage2D` with a scalar. Every ES
// driver answers "no matching overloaded function found" and the whole stage - with every other
// image in it - is lost. Pinning the upstream behaviour here means a future SPIRV-Cross bump that
// fixes it fails this test instead of leaving the lowering as silent dead weight.
TEST_F(ProgramUtilTest, SpirvCrossEmitsAScalarCoordinateForA1DImageAtomic) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> spirv = BuildSpirvForStage(k1DImageAtomicCompute, GL_COMPUTE_SHADER);
ASSERT_FALSE(spirv.empty());
ASSERT_EQ(Count1DNonArrayedStorageImageTypes(spirv), 1u)
<< "glslang no longer emits a Dim1D/non-arrayed/Sampled=2 image for uimage1D";
const String essl = DecompileToEssl(spirv);
ASSERT_FALSE(essl.empty());
EXPECT_NE(essl.find("uimage2D"), String::npos)
<< "SPIRV-Cross declares the 1D image as 2D on ES; that half it does do:\n" << essl;
EXPECT_NE(essl.find("imageAtomicAdd(i0, 2"), String::npos)
<< "SPIRV-Cross is expected to pass the SCALAR coordinate straight through to the atomic. "
"If this no longer happens, the non-arrayed half of Lower1DArrayImagesForEssl may no "
"longer be needed:\n"
<< essl;
EXPECT_EQ(essl.find("ivec2("), String::npos)
<< "nothing else in this fixture builds an ivec2, so its absence is the defect:\n" << essl;
}
// The fix: the type becomes a plain 2D image - which is what MobileGL stores a GL_TEXTURE_1D in,
// height 1 - and the coordinate becomes (u, 0), so the atomic type-checks against the declaration
// SPIRV-Cross was already emitting.
TEST_F(ProgramUtilTest, Lower1DArrayImagesWidensThe1DAtomicCoordinate) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = BuildSpirvForStage(k1DImageAtomicCompute, GL_COMPUTE_SHADER);
ASSERT_FALSE(raw.empty());
Vector<Uint32> spirv;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv));
ASSERT_EQ(Count1DNonArrayedStorageImageTypes(spirv), 1u)
<< "the shared chain must leave the 1D image for this pass to handle";
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DArrayImagesForEssl(spirv, lowered, true));
ASSERT_FALSE(lowered.empty());
EXPECT_EQ(Count1DNonArrayedStorageImageTypes(lowered), 0u)
<< "no non-arrayed 1D storage image type may survive when an atomic reaches one:\n"
<< DisassembleSpirv(lowered);
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "the lowered module must stay validator-clean";
const String essl = DecompileToEssl(lowered);
ASSERT_FALSE(essl.empty());
EXPECT_NE(essl.find("uimage2D"), String::npos)
<< "the declaration must still be the 2D one the ES texture is:\n" << essl;
EXPECT_NE(essl.find("imageAtomicAdd(i0, ivec2(2, 0)"), String::npos)
<< "the atomic must address the image with the same (u, 0) SPIRV-Cross writes for a read "
"or a write:\n"
<< essl;
}
// The declined shape for the atomic half, for the same reason as the arrayed one: after the
// rewrite the image is 2D, so imageSize() yields two components where the shader consumes one and
// there is no correct scalar to substitute.
TEST_F(ProgramUtilTest, Lower1DArrayImagesDeclinesA1DAtomicModuleThatQueriesTheImageSize) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> spirv = BuildSpirvForStage(R"(#version 440 core
layout (local_size_x = 1) in;
layout (r32ui) coherent uniform uimage1D i0;
layout (std430, binding = 0) buffer SSB { uint sum; } ssb;
void main() { ssb.sum = imageAtomicAdd(i0, 2, 7u) + uint(imageSize(i0)); }
)",
GL_COMPUTE_SHADER);
ASSERT_FALSE(spirv.empty());
const auto traits = Lower1DArrayImagesPass::InspectBinary(spirv);
ASSERT_TRUE(traits.declaresImage && traits.queriesImageSize)
<< "the fixture must contain the shape the pass declines";
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DArrayImagesForEssl(spirv, lowered, true));
EXPECT_EQ(lowered, spirv) << "a declined module must be handed back untouched, not partly rewritten";
EXPECT_EQ(Count1DNonArrayedStorageImageTypes(lowered), 1u)
<< "declining means the 1D type is still there for the driver to reject";
}
// Scope, half two: a 1D-array SAMPLER reaches SPIRV-Cross's sampler path, which does check
// `arrayed` and does move the layer into the third component. The pass is storage-image only.
TEST_F(ProgramUtilTest, Lower1DArrayImagesLeavesSampledImagesAlone) {
@@ -3534,6 +3691,290 @@ void main() { ssb.sum = uint(imageSize(i0).x) + imageLoad(i0, ivec2(0, 0)).r; }
<< "declining means the 1D-array type is still there for the driver to reject";
}
// --- 1D SAMPLED images (Lower1DSampledImagesPass) ----------------------------------------------
//
// The other half of the 1D story. SPIRV-Cross DOES widen a 1D sampler's coordinate for ES - the
// test above pins that - but it prints the OFFSET and the two GRADIENT operands with the arity the
// desktop shader spelled, against a sampler it has just declared 2D. The result has no ESSL
// overload, the driver says "no matching overloaded function found", and the stage is lost.
namespace {
// Same word walk as the storage-image counters, for Sampled == 1.
SizeT Count1DSampledImageTypes(const Vector<Uint32>& spirv) {
constexpr unsigned kOpTypeImage = 25, kDim1D = 0;
SizeT count = 0;
for (SizeT i = 5; i < spirv.size();) {
const unsigned wordCount = spirv[i] >> 16;
const unsigned opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
if (opcode == kOpTypeImage && wordCount >= 8 && spirv[i + 3] == kDim1D &&
spirv[i + 7] == 1u) {
++count;
}
i += wordCount;
}
return count;
}
// KHR-GL43.compute_shader.resource-texture's own sampler1DArray lookup, minus the other eight
// samplers: a textureLodOffset whose offset is the scalar GL gives a 1D array.
const char* k1DArraySamplerOffsetCompute = R"(#version 440 core
layout (local_size_x = 1) in;
uniform sampler1DArray g_sampler4;
layout (std430, binding = 0) buffer SSB { vec4 data; } ssb;
void main() { ssb.data = textureLodOffset(g_sampler4, vec2(0.5, 1.0), 0.0, 0); }
)";
} // namespace
// The negative control, and the whole reason the pass exists: SPIRV-Cross emits the sampler as 2D
// and widens the coordinate, then hands the scalar offset straight through. Pinning the upstream
// behaviour here means that if a future SPIRV-Cross bump fixes it, this test fails and says so,
// rather than the pass quietly becoming dead weight.
TEST_F(ProgramUtilTest, SpirvCrossEmitsAScalarOffsetFor1DSamplers) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> spirv = BuildSpirvForStage(k1DArraySamplerOffsetCompute, GL_COMPUTE_SHADER);
ASSERT_FALSE(spirv.empty());
ASSERT_EQ(Count1DSampledImageTypes(spirv), 1u)
<< "glslang no longer emits a Dim1D/Sampled=1 image for sampler1DArray";
const String essl = DecompileToEssl(spirv);
ASSERT_FALSE(essl.empty());
EXPECT_NE(essl.find("sampler2DArray"), String::npos)
<< "SPIRV-Cross declares the 1D array sampler as 2D on ES; that half it does do:\n" << essl;
EXPECT_EQ(essl.find("ivec2"), String::npos)
<< "SPIRV-Cross is expected to pass the SCALAR offset straight through, so nothing in this "
"fixture builds an ivec2 - its absence IS the defect, because ESSL has no "
"textureLodOffset(sampler2DArray, vec3, float, int). If this no longer happens, "
"Lower1DSampledImagesForEssl may no longer be needed:\n"
<< essl;
}
// The fix: the type becomes a 2D array and the offset becomes two components, so the call
// type-checks against the declaration SPIRV-Cross was already emitting.
TEST_F(ProgramUtilTest, Lower1DSampledImagesWidensTheOffsetOfA1DArrayLookup) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = BuildSpirvForStage(k1DArraySamplerOffsetCompute, GL_COMPUTE_SHADER);
ASSERT_FALSE(raw.empty());
// Through the shared chain first, exactly as the DirectGLES transpile path does - the same
// reason the storage-image tests above do it: the pass runs on sanitized bytes, and validating
// raw glslang output would latch pre-existing properties against this pass.
Vector<Uint32> spirv;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv));
ASSERT_TRUE(Lower1DSampledImagesPass::BinaryHasOffsetOrGrad1DSampledImage(spirv))
<< "the fixture must reproduce the defect before the fix is asked to remove it:\n"
<< DisassembleSpirv(spirv);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DSampledImagesForEssl(spirv, lowered, true));
ASSERT_FALSE(lowered.empty());
EXPECT_EQ(Count1DSampledImageTypes(lowered), 0u)
<< "no 1D sampled image type may survive the pass:\n"
<< DisassembleSpirv(lowered);
// The point of moving the TYPE rather than only the operand: an ivec2 offset against a type
// still declared Dim1D is an invalid module, and the validator would say so.
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "the lowered module must stay validator-clean:\n"
<< DisassembleSpirv(lowered);
const String essl = DecompileToEssl(lowered);
ASSERT_FALSE(essl.empty());
EXPECT_NE(essl.find("sampler2DArray"), String::npos)
<< "the sampler must still be declared as the 2D array the texture is stored as:\n" << essl;
EXPECT_NE(essl.find("ivec2"), String::npos)
<< "the offset must now be the two-component one ESSL's sampler2DArray overload takes:\n"
<< essl;
}
// The gradients take the identical repair, and through a different SPIRV-Cross branch - the offset
// is emitted at `if (args.offset)` and the gradients at `if (args.grad_x || args.grad_y)`, so one
// fixture cannot cover both.
TEST_F(ProgramUtilTest, Lower1DSampledImagesWidensTheGradientsOfA1DLookup) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = BuildSpirvForStage(R"(#version 440 core
layout (local_size_x = 1) in;
uniform sampler1D g_sampler0;
layout (std430, binding = 0) buffer SSB { vec4 data; } ssb;
void main() { ssb.data = textureGrad(g_sampler0, 0.5, 0.25, 0.125); }
)",
GL_COMPUTE_SHADER);
ASSERT_FALSE(raw.empty());
Vector<Uint32> spirv;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv));
ASSERT_TRUE(Lower1DSampledImagesPass::BinaryHasOffsetOrGrad1DSampledImage(spirv))
<< DisassembleSpirv(spirv);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DSampledImagesForEssl(spirv, lowered, true));
ASSERT_FALSE(lowered.empty());
EXPECT_EQ(Count1DSampledImageTypes(lowered), 0u) << DisassembleSpirv(lowered);
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "the lowered module must stay validator-clean:\n"
<< DisassembleSpirv(lowered);
const String essl = DecompileToEssl(lowered);
ASSERT_FALSE(essl.empty());
EXPECT_NE(essl.find("textureGrad"), String::npos) << essl;
// Both derivatives have to be widened, not just the first: ESSL's overload takes two vec2s.
EXPECT_NE(essl.find("vec2(0.25, 0.0)"), String::npos)
<< "dPdx must be widened to two components:\n" << essl;
EXPECT_NE(essl.find("vec2(0.125, 0.0)"), String::npos)
<< "dPdy must be widened too:\n" << essl;
}
// Scope: a 1D sampler that is only SAMPLED or FETCHED is emitted correctly by the very same
// SPIRV-Cross code, so the pass must not touch it. Replacing working emission with our own buys
// nothing and risks everything - the same rule the storage-image sibling applies to a 1D image
// with no atomic on it. resource-texture's own sampler1D is exactly this shape (it only calls
// texelFetch), so this is not a hypothetical.
TEST_F(ProgramUtilTest, Lower1DSampledImagesLeavesPlainLookupsToSpirvCross) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> spirv = BuildSpirvForStage(R"(#version 440 core
layout (local_size_x = 1) in;
uniform sampler1D g_sampler0;
uniform sampler1DArray g_sampler4;
layout (std430, binding = 0) buffer SSB { vec4 data; } ssb;
void main() {
ssb.data = texelFetch(g_sampler0, 2, 0) + texture(g_sampler4, vec2(0.5, 1.0));
}
)",
GL_COMPUTE_SHADER);
ASSERT_FALSE(spirv.empty());
ASSERT_EQ(Count1DSampledImageTypes(spirv), 2u);
EXPECT_FALSE(Lower1DSampledImagesPass::BinaryHasOffsetOrGrad1DSampledImage(spirv))
<< "no offset and no gradient here, so the probe must say there is nothing to do";
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DSampledImagesForEssl(spirv, lowered, true));
EXPECT_EQ(lowered, spirv) << "a 1D sampler with no offset or gradient must pass through byte "
"for byte";
}
// The gate is per arrayed-ness, matching the two distinct OpTypeImage declarations glslang emits:
// the sampler1DArray carries the offset and is rewritten, while the sampler1D in the same module
// is left to SPIRV-Cross. This is resource-texture's own shape.
TEST_F(ProgramUtilTest, Lower1DSampledImagesRewritesOnlyTheArrayednessThatCarriesTheOffset) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = BuildSpirvForStage(R"(#version 440 core
layout (local_size_x = 1) in;
uniform sampler1D g_sampler0;
uniform sampler1DArray g_sampler4;
layout (std430, binding = 0) buffer SSB { vec4 data; } ssb;
void main() {
ssb.data = texelFetch(g_sampler0, 2, 0) +
textureLodOffset(g_sampler4, vec2(0.5, 1.0), 0.0, 0);
}
)",
GL_COMPUTE_SHADER);
ASSERT_FALSE(raw.empty());
Vector<Uint32> spirv;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv));
ASSERT_EQ(Count1DSampledImageTypes(spirv), 2u);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DSampledImagesForEssl(spirv, lowered, true));
ASSERT_FALSE(lowered.empty());
EXPECT_EQ(Count1DSampledImageTypes(lowered), 1u)
<< "the arrayed sampler must be rewritten and the non-arrayed one left alone:\n"
<< DisassembleSpirv(lowered);
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "the lowered module must stay validator-clean:\n"
<< DisassembleSpirv(lowered);
// Both spellings coincide on ES, which is why a partial rewrite is safe here and is NOT safe
// for the storage-image sibling: SPIRV-Cross prints Dim1D as "2D" already, so the stage that
// was rewritten and the stage that was not declare the same ESSL type.
const String essl = DecompileToEssl(lowered);
ASSERT_FALSE(essl.empty());
EXPECT_EQ(essl.find("sampler1D"), String::npos)
<< "nothing may reach the driver still spelled 1D:\n" << essl;
}
// The shape that would emit INVALID SPIR-V without the deduplication, and the shape the
// conformance case actually has: a 1D sampler and a real 2D sampler of the same sampled type in
// one module. Rewriting the first one's Dim in place makes the two OpTypeImage declarations
// structurally identical, and SPIR-V forbids duplicate non-aggregate types.
TEST_F(ProgramUtilTest, Lower1DSampledImagesDeduplicatesAgainstAnExisting2DSampler) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = BuildSpirvForStage(R"(#version 440 core
layout (local_size_x = 1) in;
uniform sampler1D g_sampler0;
uniform sampler2D g_sampler1;
layout (std430, binding = 0) buffer SSB { vec4 data; } ssb;
void main() {
ssb.data = textureLodOffset(g_sampler0, 0.5, 0.0, 1) +
textureLod(g_sampler1, vec2(0.5), 0.0);
}
)",
GL_COMPUTE_SHADER);
ASSERT_FALSE(raw.empty());
Vector<Uint32> spirv;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv));
ASSERT_EQ(Count1DSampledImageTypes(spirv), 1u);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DSampledImagesForEssl(spirv, lowered, true));
ASSERT_FALSE(lowered.empty());
EXPECT_EQ(Count1DSampledImageTypes(lowered), 0u) << DisassembleSpirv(lowered);
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "the rewritten 1D sampler collided with the module's own 2D sampler and left a "
"duplicate type declaration behind:\n"
<< DisassembleSpirv(lowered);
}
// The declined shape, for the sibling's reason: textureSize(sampler1D) yields an int and
// textureSize(sampler2D) an ivec2, so rewriting the type while leaving the query would hand the
// shader a value of the wrong shape. The module is returned untouched rather than half-translated.
TEST_F(ProgramUtilTest, Lower1DSampledImagesDeclinesAModuleThatQueriesTheTextureSize) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = BuildSpirvForStage(R"(#version 440 core
layout (local_size_x = 1) in;
uniform sampler1D g_sampler0;
layout (std430, binding = 0) buffer SSB { vec4 data; } ssb;
void main() {
ssb.data = textureLodOffset(g_sampler0, 0.5, 0.0, 1) + float(textureSize(g_sampler0, 0));
}
)",
GL_COMPUTE_SHADER);
ASSERT_FALSE(raw.empty());
Vector<Uint32> spirv;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv));
ASSERT_TRUE(Lower1DSampledImagesPass::BinaryHasOffsetOrGrad1DSampledImage(spirv))
<< "the fixture must still carry the offset that arms the pass, so that the decline is "
"what leaves the module alone rather than the gate:\n"
<< DisassembleSpirv(spirv);
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DSampledImagesForEssl(spirv, lowered, true));
EXPECT_EQ(lowered, spirv)
<< "a declined module must be handed back untouched, not partly rewritten";
EXPECT_EQ(Count1DSampledImageTypes(lowered), 1u)
<< "declining means the 1D type is still there for the driver to reject";
}
// --- image format qualifier bake (BakeImageFormatsPass) ---------------------------------------
//
// Desktop GLSL 4.2 lets a writeonly image declaration omit its format layout qualifier; GLSL ES
@@ -3557,6 +3998,9 @@ namespace {
constexpr Uint kGlRgba32ui = 0x8D70;
constexpr Uint kGlR8ui = 0x8232;
constexpr Uint kGlR32f = 0x822E;
constexpr Uint kGlRgb10A2ui = 0x906F;
constexpr Uint kGlRgb10A2 = 0x8059;
constexpr Uint kGlRgb8 = 0x8051; // not one of the forty image formats at all
} // namespace
// The KHR-GL4x.packed_depth_stencil.stencil_texturing compute shader, reduced: one format-less
@@ -3599,14 +4043,34 @@ void main() { imageStore(uni_image, ivec2(gl_GlobalInvocationID.xy), uvec4(15u,
// SPIRV-Cross THROWS rather than printing the formats it calls desktop-only when it targets ESSL
// (Compiler::is_desktop_only_format), and a throw loses the whole stage - so baking one of those
// into the module would trade a missing qualifier for a missing shader. They are left format-less
// here and completed on the emitted text instead (PrgramImpl::BakeImageFormatQualifiers). r8ui,
// which the stencil half of the packed_depth_stencil case binds, is one of them.
TEST_F(ProgramUtilTest, BakeImageFormatsLeavesTheFormatsSpirvCrossRefusesToPrint) {
// into the module would trade a missing qualifier for a missing shader.
//
// That still holds for the formats NOTHING can rescue, which are left format-less here and
// completed on the emitted text instead (PrgramImpl::BakeImageFormatQualifiers). It stopped
// holding for the ones that widen EXACTLY: WidenImageFormatsForEssl runs immediately after this
// pass on the ESSL chain and re-declares them in a core carrier SPIRV-Cross does print, so for
// those the module is the right place and the text completion would put back the narrow token no
// ES driver accepts. r8ui - which the stencil half of the packed_depth_stencil case binds - is
// one of the rescued ones, and so, now that the carriers cover all twenty-six non-core formats,
// is every other IMAGE format. What is left for the guard is a format that is not an image format
// at all: it has no carrier and no ESSL image spelling either, so baking it would put a token in
// the module that means nothing.
TEST_F(ProgramUtilTest, BakeImageFormatsLeavesOnlyTheFormatsNoCoreCarrierRescues) {
using namespace MG_Util::ShaderTranspiler;
ASSERT_FALSE(ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(kGlR8ui))
<< "if SPIRV-Cross ever learns to print r8ui for ES, the text completion can go";
<< "if SPIRV-Cross ever learns to print r8ui for ES, this route can go";
ASSERT_NE(ShaderCompiler::WidenedCoreEsslImageFormat(kGlR8ui), 0u);
// Unprintable and rescued anyway: rgb10_a2ui's channels are unsigned INTEGER, so an rgba16ui
// holds all four outright, and rgb10_a2's are the same channels read as NORMALIZED, which the
// same carrier holds as their codes.
ASSERT_FALSE(ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(kGlRgb10A2ui));
ASSERT_NE(ShaderCompiler::WidenedCoreEsslImageFormat(kGlRgb10A2ui), 0u);
ASSERT_FALSE(ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(kGlRgb10A2));
ASSERT_NE(ShaderCompiler::WidenedCoreEsslImageFormat(kGlRgb10A2), 0u);
// ...and the one the guard still turns away.
ASSERT_FALSE(ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(kGlRgb8));
ASSERT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(kGlRgb8), 0u);
ASSERT_TRUE(ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(kGlR32ui));
EXPECT_EQ(ShaderCompiler::EsslImageFormatSpelling(kGlR8ui), "r8ui");
EXPECT_EQ(ShaderCompiler::EsslImageFormatSpelling(0x8051 /*GL_RGB8*/), "");
@@ -3619,11 +4083,29 @@ void main() { imageStore(uni_image, ivec2(0), uvec4(15u)); }
GL_COMPUTE_SHADER);
ASSERT_FALSE(spirv.empty());
Vector<Uint32> baked;
ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"uni_image", kGlR8ui}}, baked));
EXPECT_EQ(baked, spirv) << "a format SPIRV-Cross cannot print must leave the module untouched";
// ...and the stage still transpiles, which is the whole point of declining.
EXPECT_FALSE(DecompileToEssl(baked).empty());
{ // Unprintable AND uncarriable: declined, module untouched, and the stage still transpiles.
Vector<Uint32> baked;
ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"uni_image", kGlRgb8}}, baked));
EXPECT_EQ(baked, spirv) << "a format nothing can carry must leave the module untouched";
EXPECT_FALSE(DecompileToEssl(baked).empty());
}
{ // Unprintable but carriable: baked narrow here, then widened into the carrier, which is
// what finally gives the declaration a qualifier ES accepts.
Vector<Uint32> baked;
ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"uni_image", kGlR8ui}}, baked, true));
ASSERT_FALSE(baked.empty());
EXPECT_NE(baked, spirv) << "a format the widening carries must reach the module";
EXPECT_FALSE(ShaderCompiler::DeclaresFormatlessStorageImage(baked));
ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(baked));
Vector<Uint32> widened;
ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(baked, widened, false, true));
ASSERT_FALSE(widened.empty());
const String essl = DecompileToEssl(widened);
ASSERT_FALSE(essl.empty());
EXPECT_NE(essl.find("rgba8ui"), String::npos)
<< "the baked r8ui must come out as the core carrier:\n" << essl;
}
}
// A DECLARED format is authoritative: GL requires the qualifier, the bind format and the
@@ -3840,3 +4322,250 @@ TEST_F(ProgramUtilTest, EsslCoreImageFormatSetIsTheThirteenTheSpecLists) {
EXPECT_FALSE(ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(0x8051 /*GL_RGB8*/));
EXPECT_FALSE(ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(0 /*GL_NONE*/));
}
// KHR-GL43.shader_storage_buffer_object.basic-syntax iteration 6. glslang assigns a block's member
// offsets at DECLARATION time, where a member array that is still unsized contributes zero bytes -
// so `vec4 position01[]; vec4 position2;` put both members at offset 0 and the shader read
// position01[0] where it asked for position2. The preprocessor sizes the non-final member from the
// largest constant index the source uses, which is what the language says it means.
TEST_F(ProgramUtilTest, ANonFinalUnsizedBufferBlockMemberIsSizedFromItsLargestConstantIndex) {
using namespace MG_Util::ShaderTranspiler;
String source = 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;
}
)";
PreprocessShaderSource(ShaderStage::Vertex, source);
EXPECT_NE(source.find("vec4 position01[2];"), String::npos) << source;
EXPECT_EQ(source.find("position01[];"), String::npos) << source;
// The LAST member of a storage block is a run-time sized array, which is legal and already
// laid out correctly - sizing it would be a wire-format change, not a repair.
String lastMember = R"(#version 430 core
buffer Buffer {
vec4 head;
vec4 tail[];
} g_buffer;
void main() {
gl_Position = g_buffer.tail[0] + g_buffer.tail[3];
}
)";
PreprocessShaderSource(ShaderStage::Vertex, lastMember);
EXPECT_NE(lastMember.find("vec4 tail[];"), String::npos) << lastMember;
// A member the shader subscripts with anything but a literal cannot be sized from the source,
// so it is left exactly as it was.
String dynamicIndex = R"(#version 430 core
buffer Buffer {
vec4 head[];
vec4 tail;
} g_buffer;
uniform int g_index;
void main() {
gl_Position = g_buffer.head[g_index] + g_buffer.tail;
}
)";
PreprocessShaderSource(ShaderStage::Vertex, dynamicIndex);
EXPECT_NE(dynamicIndex.find("vec4 head[];"), String::npos) << dynamicIndex;
// `buffer` is also a member memory qualifier; a declaration that uses it must not be mistaken
// for a block header.
String memberQualifier = R"(#version 430 core
coherent buffer Buffer {
buffer vec4 position0;
vec4 position1[];
vec4 position2;
} g_buffer;
void main() {
gl_Position = g_buffer.position0 + g_buffer.position1[2] + g_buffer.position2;
}
)";
PreprocessShaderSource(ShaderStage::Vertex, memberQualifier);
EXPECT_NE(memberQualifier.find("vec4 position1[3];"), String::npos) << memberQualifier;
}
// KHR-GL43.shader_storage_buffer_object.negative-glsl-compileTime: a storage block declared at
// GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS must fail to compile, and so must an arrayed one whose
// LAST element passes the ceiling. The relaxed Vulkan-rules parse enforces neither.
TEST_F(ProgramUtilTest, StorageBlockBindingCeilingIsCheckedAtItsExactBoundary) {
using namespace MG_Util::ShaderTranspiler;
constexpr Int kMaxBindings = 36;
const auto violation = [](const String& body) {
return FindShaderStorageBindingViolation("#version 430 core\n" + body + "void main() {}\n", kMaxBindings);
};
// The boundary itself: max - 1 is the last legal point, max is one past it.
EXPECT_FALSE(violation("layout(binding = 35) buffer Buffer { int x; };\n").has_value());
EXPECT_TRUE(violation("layout(binding = 36) buffer Buffer { int x; };\n").has_value());
// An instance array takes CONSECUTIVE points, so what has to fit is base + count - 1.
EXPECT_FALSE(violation("layout(binding = 32) buffer Buffer { int x; } g_array[4];\n").has_value());
EXPECT_TRUE(violation("layout(binding = 34) buffer Buffer { int x; } g_array[4];\n").has_value());
// Qualifiers and a second layout list may sit between the binding and the keyword.
EXPECT_TRUE(violation("layout(std430) layout(binding = 36) coherent restrict buffer B { int x; };\n")
.has_value());
// Things the scanner must NOT judge: a uniform block (a different ceiling), a storage block
// with no explicit binding, the bare default-qualifier form, and an instance array whose size
// is not a literal.
EXPECT_FALSE(violation("layout(binding = 40) uniform Block { int x; };\n"
"layout(binding = 0) buffer Buffer { int y; };\n")
.has_value());
EXPECT_FALSE(violation("buffer Buffer { int x; };\nconst int binding = 40;\n").has_value());
EXPECT_FALSE(violation("layout(binding = 1) buffer;\nbuffer Buffer { int x; };\n").has_value());
EXPECT_FALSE(violation("const int kCount = 4;\nlayout(binding = 34) buffer B { int x; } g[kCount];\n")
.has_value());
// A backend that advertises no binding points has no ceiling to enforce.
EXPECT_FALSE(FindShaderStorageBindingViolation("layout(binding = 36) buffer B { int x; };\n", 0).has_value());
}
// KHR-GL43.shader_image_size.advanced-nonMS-* is nothing but its passing twin basic-nonMS-* plus a
// GLSL subroutine, and glslang refuses the keyword outright when the target is SPIR-V ("subroutine
// : not allowed when generating SPIR-V"), so every stage of those shaders failed to compile. The
// lowering turns a subroutine uniform with exactly ONE compatible subroutine - the case where GL
// 4.3 core 7.9 makes a direct call indistinguishable from a dispatch, because every legal value of
// the uniform selects that one function - into a forwarding call.
TEST_F(ProgramUtilTest, PreprocessLowersSingleImplementationSubroutineToAForwardingCall) {
using namespace MG_Util::ShaderTranspiler;
String source = R"(#version 430 core
layout(binding = 0, rgba32i) writeonly uniform iimage2D g_result;
subroutine void FuncType(int coord);
subroutine uniform FuncType g_func;
void main() {
int coord = gl_VertexID;
g_func(coord);
}
subroutine(FuncType) void Func0(int coord) {
imageStore(g_result, ivec2(coord, 0), ivec4(imageSize(g_result), 0, 0));
}
)";
const SizeT mainLine = std::count(source.begin(), source.begin() + source.find("void main"), '\n');
PreprocessShaderSource(ShaderStage::Vertex, source);
EXPECT_EQ(source.find("subroutine"), String::npos) << "the keyword glslang refuses must be gone";
EXPECT_NE(source.find("void g_func(int mgl_sr_arg0);"), String::npos)
<< "the subroutine uniform becomes a prototype under its own name, so call sites stand";
EXPECT_NE(source.find("g_func(coord);"), String::npos) << "the call site is untouched";
EXPECT_NE(source.find("void Func0(int coord)"), String::npos)
<< "the compatible subroutine keeps its body and only sheds the qualifier";
EXPECT_NE(source.find("Func0(mgl_sr_arg0);"), String::npos) << "the forwarding body";
// The forwarding body has to come after every definition it names: the CTS shaders define
// their subroutine BELOW the function that calls through the uniform.
EXPECT_LT(source.find("void Func0(int coord)"), source.find("Func0(mgl_sr_arg0);"));
// Blanking preserves newlines, and the prototype is single-line, so glslang's diagnostics still
// point at the line the application wrote.
EXPECT_EQ(std::count(source.begin(), source.begin() + source.find("void main"), '\n'), mainLine)
<< "the rewrite must not move a single line";
ShaderAttrib attrib{.shaderType = GL_VERTEX_SHADER, .sourceStr = source};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) {
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
}
}
// The forwarding function is rebuilt from the subroutine TYPE declaration, so it has to carry the
// parameter qualifiers and array shapes across (an parameter that arrives by value writes
// nothing back) and has to return the forwarded value for a non-void subroutine.
TEST_F(ProgramUtilTest, PreprocessSubroutineForwardingKeepsParameterQualifiersAndReturnsValues) {
using namespace MG_Util::ShaderTranspiler;
String source = R"(#version 430 core
subroutine float Blend(const int k, out vec4 rgba, float weights[2]);
subroutine uniform Blend g_blend;
out vec4 fragColor;
void main() {
vec4 rgba;
float w[2] = float[2](0.25, 0.75);
fragColor = rgba * g_blend(1, rgba, w);
}
subroutine(Blend) float Mix(const int k, out vec4 rgba, float weights[2]) {
rgba = vec4(weights[0], weights[1], float(k), 1.0);
return weights[0];
}
)";
PreprocessShaderSource(ShaderStage::Fragment, source);
EXPECT_NE(source.find("float g_blend(const int mgl_sr_arg0, out vec4 mgl_sr_arg1, float mgl_sr_arg2 [ 2 ]);"),
String::npos)
<< "qualifiers and the array declarator have to survive, under generated names";
EXPECT_NE(source.find("return Mix(mgl_sr_arg0, mgl_sr_arg1, mgl_sr_arg2);"), String::npos)
<< "a non-void subroutine has to have its value forwarded back";
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) {
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
}
}
// Two compatible subroutines is genuine dynamic selection, which MobileGL does not implement:
// glUniformSubroutinesuiv is still a stub and nothing reflects the subroutine interfaces. Pinning
// such a shader to one of the alternatives would render silently wrong, so the whole rewrite is
// abandoned and the source is left exactly as it arrived.
TEST_F(ProgramUtilTest, PreprocessLeavesMultiImplementationSubroutinesAlone) {
using namespace MG_Util::ShaderTranspiler;
String source = R"(#version 430 core
subroutine void FuncType(int coord);
subroutine uniform FuncType g_func;
out vec4 fragColor;
void main() {
g_func(1);
fragColor = vec4(1.0);
}
subroutine(FuncType) void Func0(int coord) { fragColor = vec4(float(coord)); }
subroutine(FuncType) void Func1(int coord) { fragColor = vec4(float(coord) * 2.0); }
)";
const String before = source;
PreprocessShaderSource(ShaderStage::Fragment, source);
EXPECT_EQ(source, before) << "an unimplementable dispatch must not be quietly pinned to one arm";
}
// An ARRAY of subroutine uniforms indexes the dispatch at the call site ("g_func[i](x)"), which is
// the same dynamic selection - and a subroutine declared inside a #if arm cannot be reasoned about
// at all, because the forwarding bodies this appends are unconditional.
TEST_F(ProgramUtilTest, PreprocessLeavesArrayAndConditionalSubroutinesAlone) {
using namespace MG_Util::ShaderTranspiler;
String arrayed = R"(#version 430 core
subroutine void FuncType(int coord);
subroutine uniform FuncType g_func[2];
out vec4 fragColor;
void main() { g_func[0](1); fragColor = vec4(1.0); }
subroutine(FuncType) void Func0(int coord) { fragColor = vec4(float(coord)); }
)";
const String arrayedBefore = arrayed;
PreprocessShaderSource(ShaderStage::Fragment, arrayed);
EXPECT_EQ(arrayed, arrayedBefore) << "an arrayed subroutine uniform is a dispatch, not a call";
String conditional = R"(#version 430 core
out vec4 fragColor;
#ifdef USE_SUBROUTINE
subroutine void FuncType(int coord);
subroutine uniform FuncType g_func;
#endif
void main() { fragColor = vec4(1.0); }
subroutine(FuncType) void Func0(int coord) { fragColor = vec4(float(coord)); }
)";
const String conditionalBefore = conditional;
PreprocessShaderSource(ShaderStage::Fragment, conditional);
EXPECT_EQ(conditional, conditionalBefore)
<< "an inactive #if arm must not have an unconditional forwarding body appended for it";
}
+125
View File
@@ -140,6 +140,29 @@ namespace {
void StubEndXfbPrimitivesQuery(MG_Backend::BackendQueryHandle) { ++g_stubXfbEndCount; }
// Stub backend occlusion queries. The host has no ES context, and BeginQuery refuses the
// occlusion targets outright when the backend advertises no hook - so a conditional-render
// test cannot get a legal predicate object without these. g_stubResultNs is the sample count
// the "driver" reports, which is the whole input to the predicate.
MG_Backend::BackendQueryHandle StubBeginOcclusionQuery() {
return reinterpret_cast<MG_Backend::BackendQueryHandle>(static_cast<uintptr_t>(0x54));
}
void StubEndOcclusionQuery(MG_Backend::BackendQueryHandle) {}
void InstallStubBackendOcclusionQueries() {
auto& backendGL = MG_Backend::gBackendFunctionsTable.GL;
backendGL.BeginOcclusionQuery = StubBeginOcclusionQuery;
backendGL.EndOcclusionQuery = StubEndOcclusionQuery;
backendGL.IsQueryResultAvailable = StubIsQueryResultAvailable;
backendGL.GetQueryResult64 = StubGetQueryResult64;
backendGL.DeleteBackendQuery = StubDeleteBackendQuery;
g_stubDeleteCount = 0;
g_stubResultAvailable = true;
g_stubResultObtainable = true;
g_stubResultNs = 0;
}
void InstallStubBackendXfbQueries() {
auto& backendGL = MG_Backend::gBackendFunctionsTable.GL;
backendGL.BeginXfbPrimitivesQuery = StubBeginXfbPrimitivesQuery;
@@ -677,6 +700,108 @@ TEST_F(QueryTest, PrimitivesGeneratedKeepsTheBackendResultUnderTheCpuPreference)
// unified truthy rule (set, non-empty, not "0", case-insensitive not "false").
// Running the binary under MOBILEGL_DISABLE_TIMERQUERY=1 therefore exercises
// the real end-to-end path rather than the struct field alone.
// KHR-GL43.compute_shader.conditional-dispatching and the conditional_render family.
// glBeginConditionalRender/glEndConditionalRender were bare stubs: every command inside a
// conditional block executed whatever the query said, so the block that should have been
// discarded ran and doubled the atomic counter the case reads back.
TEST_F(QueryTest, ConditionalRenderResolvesItsPredicateFromTheOcclusionQuery) {
ScopedBackendFunctionsOverride backendGuard;
InstallStubBackendOcclusionQueries();
GLuint ids[2] = {0, 0};
MG_Impl::GLImpl::GenQueries(2, ids);
ASSERT_NE(ids[0], 0u);
ASSERT_NE(ids[1], 0u);
// One span that saw samples and one that saw none, which is exactly the pair the
// conformance case builds out of a passing and a failing depth test.
g_stubResultNs = 1;
MG_Impl::GLImpl::BeginQuery(GL_ANY_SAMPLES_PASSED, ids[0]);
MG_Impl::GLImpl::EndQuery(GL_ANY_SAMPLES_PASSED);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
GLuint passedResult = 0xFFFFFFFFu;
MG_Impl::GLImpl::GetQueryObjectuiv(ids[0], GL_QUERY_RESULT, &passedResult);
ASSERT_EQ(passedResult, 1u);
g_stubResultNs = 0;
MG_Impl::GLImpl::BeginQuery(GL_ANY_SAMPLES_PASSED, ids[1]);
MG_Impl::GLImpl::EndQuery(GL_ANY_SAMPLES_PASSED);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// A block on the query that passed executes.
MG_Impl::GLImpl::BeginConditionalRender(ids[0], GL_QUERY_WAIT);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_TRUE(MG_State::pGLContext->IsConditionalRenderActive());
EXPECT_FALSE(MG_State::pGLContext->ConditionalRenderDiscardsCommands());
MG_Impl::GLImpl::EndConditionalRender();
EXPECT_FALSE(MG_State::pGLContext->IsConditionalRenderActive());
EXPECT_FALSE(MG_State::pGLContext->ConditionalRenderDiscardsCommands());
// A block on the query that did not passes nothing through.
MG_Impl::GLImpl::BeginConditionalRender(ids[1], GL_QUERY_WAIT);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_TRUE(MG_State::pGLContext->ConditionalRenderDiscardsCommands());
MG_Impl::GLImpl::EndConditionalRender();
// ...and the _INVERTED modes swap both verdicts.
MG_Impl::GLImpl::BeginConditionalRender(ids[0], GL_QUERY_WAIT_INVERTED);
EXPECT_TRUE(MG_State::pGLContext->ConditionalRenderDiscardsCommands());
MG_Impl::GLImpl::EndConditionalRender();
MG_Impl::GLImpl::BeginConditionalRender(ids[1], GL_QUERY_BY_REGION_NO_WAIT_INVERTED);
EXPECT_FALSE(MG_State::pGLContext->ConditionalRenderDiscardsCommands());
MG_Impl::GLImpl::EndConditionalRender();
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
MG_Impl::GLImpl::DeleteQueries(2, ids);
}
TEST_F(QueryTest, ConditionalRenderRejectsTheErrorsTheSpecNames) {
ScopedBackendFunctionsOverride backendGuard;
InstallStubBackendOcclusionQueries();
GLuint ids[2] = {0, 0};
MG_Impl::GLImpl::GenQueries(2, ids);
g_stubResultNs = 1;
MG_Impl::GLImpl::BeginQuery(GL_ANY_SAMPLES_PASSED, ids[0]);
MG_Impl::GLImpl::EndQuery(GL_ANY_SAMPLES_PASSED);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// GL 4.6 core 10.9, one rule at a time.
MG_Impl::GLImpl::BeginConditionalRender(ids[0], GL_TIME_ELAPSED);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_ENUM);
EXPECT_FALSE(MG_State::pGLContext->IsConditionalRenderActive());
// A generated NAME is not yet a query object.
MG_Impl::GLImpl::BeginConditionalRender(ids[1], GL_QUERY_WAIT);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_VALUE);
MG_Impl::GLImpl::BeginConditionalRender(0, GL_QUERY_WAIT);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_VALUE);
// A query that is not an occlusion query cannot drive one.
GLuint timerId = 0;
MG_Impl::GLImpl::GenQueries(1, &timerId);
MG_Impl::GLImpl::BeginQuery(GL_TIME_ELAPSED, timerId);
MG_Impl::GLImpl::EndQuery(GL_TIME_ELAPSED);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
MG_Impl::GLImpl::BeginConditionalRender(timerId, GL_QUERY_WAIT);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_OPERATION);
// End without a block, and a nested Begin.
MG_Impl::GLImpl::EndConditionalRender();
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_OPERATION);
MG_Impl::GLImpl::BeginConditionalRender(ids[0], GL_QUERY_WAIT);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
MG_Impl::GLImpl::BeginConditionalRender(ids[0], GL_QUERY_WAIT);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_OPERATION);
// The rejected nested Begin must not have disturbed the open block.
EXPECT_EQ(MG_State::pGLContext->GetConditionalRenderQuery(), ids[0]);
MG_Impl::GLImpl::EndConditionalRender();
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
MG_Impl::GLImpl::DeleteQueries(2, ids);
MG_Impl::GLImpl::DeleteQueries(1, &timerId);
}
TEST_F(QueryTest, DisableTimerQueryFeatureMatchesEnvironment) {
const char* raw = std::getenv("MOBILEGL_DISABLE_TIMERQUERY");
Bool expected = false;
+267 -2
View File
@@ -17,6 +17,7 @@
#include <MG_Backend/DirectGLES/Managers.h>
#include <MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h>
#include <MG_Backend/BackendObjects.h>
#include <MG_Impl/GLImpl/Buffer/GL_Buffer.h>
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <MG_Impl/GLImpl/RenderState/GL_RenderState.h>
#include <MG_Impl/GLImpl/Texture/GL_Texture.h>
@@ -262,6 +263,63 @@ TEST(DirectGLESSanity, AdvertisesVoxyRequiredRenderingExtensions) {
extensions.end());
}
// A multisample texture is fetched, never filtered, so the mip-chain completeness rules never
// apply to it (GL 4.6 core 8.17). It has exactly one level and MIN_FILTER's initial value is
// NEAREST_MIPMAP_LINEAR, so asking those rules anyway calls EVERY multisample texture incomplete
// - and both backends express "incomplete" as "leave the native target unbound", which makes the
// shader's sampler2DMS read zero from a texture that was written correctly.
//
// That is KHR-GL43.compute_shader.resource-texture: it clears its 2DMS texture to 123.0 through
// an FBO (which succeeds - the ES clear is issued on a COMPLETE 4-sample framebuffer with no
// error) and then fails at the first sampler2DMS element because the texture was never bound.
TEST(DirectGLESSanity, BindsAMultisampleTextureDespiteTheDefaultMipmapFilter) {
using namespace MobileGL;
namespace DirectGLES = MG_Backend::DirectGLES;
ScopedDirectGLESTextureBindings state;
GLuint frontendTexture = 0;
MG_Impl::GLImpl::GenTextures(1, &frontendTexture);
ASSERT_NE(frontendTexture, 0u);
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D_MULTISAMPLE, frontendTexture);
const auto& textureObject = MG_State::pGLContext->GetTextureUnitObject(0)
.GetBindingSlot(TextureTarget::Texture2DMultisample)
.GetBoundObject();
ASSERT_NE(textureObject, nullptr);
textureObject->SetInternalFormat(TextureInternalFormat::RGBA8);
textureObject->SetSamples(4);
textureObject->SetFixedSampleLocations(false);
// One level, 4x4 - the shape glTexImage2DMultisample produces, and a size whose mip chain
// would need three levels if the filter rules were (wrongly) applied.
MG_State::GLState::AsMipmapTexture(textureObject.get())
->AllocateStorage(TextureUploadTarget::Texture2DMultisample, 0, {{4, 4, 1}, 4});
// The precondition that used to poison it, asserted rather than assumed: the texture's own
// sampler still reports a mipmapping filter, because GL's initial MIN_FILTER is
// NEAREST_MIPMAP_LINEAR and a multisample texture has no way (and no reason) to change it.
// If a future default made this None the test would pass without covering anything.
const auto& sampler = textureObject->GetSamplerObject();
ASSERT_NE(sampler, nullptr);
ASSERT_NE(sampler->GetMipmapMode(), SamplerMipmapMode::None)
<< "fixture is stale: the default sampler no longer asks for mipmapping, so this test "
"would not exercise the multisample guard";
EXPECT_FALSE(MG_State::GLState::SamplesAsIncompleteTexture(textureObject.get(), sampler.get()))
<< "a multisample texture is never filter-incomplete";
auto& backendTexture = DirectGLES::TextureImpl::g_backendTextureObjects.GetOrCreate(textureObject);
backendTexture = MakeShared<DirectGLES::TextureImpl::BackendTextureObject>();
const GLuint backendTextureId = backendTexture->GetBackendTextureId();
// The symptom itself: the per-unit walk has to actually bind it.
DirectGLES::BindCurrentTextures();
ASSERT_EQ(state.bindCalls.size(), 1u)
<< "the multisample texture was not bound; every texelFetch against it reads zero";
EXPECT_EQ(state.bindCalls[0].target, GL_TEXTURE_2D_MULTISAMPLE);
EXPECT_EQ(state.bindCalls[0].texture, backendTextureId);
}
TEST(DirectGLESSanity, BindingZeroClearsPreviousNativeTextureBinding) {
using namespace MobileGL;
namespace DirectGLES = MG_Backend::DirectGLES;
@@ -642,6 +700,55 @@ TEST(DirectGLESSanity, PreservesHostPerStageImageUniformLimits) {
EXPECT_EQ(params.MaxComputeImageUniforms, 5);
}
// maxClipDistances is a LIMIT every Vulkan device reports; declaring ClipDistance in a module
// needs the shaderClipDistance FEATURE, which is separate and which VulkanRenderer enables only
// where the physical device has it. Forwarding the limit without the feature advertises eight
// clip planes no shader may use - the same shape as the image-uniform limits above, and the same
// shape as the GL_EXT_clip_cull_distance lie on DirectGLES. Not a blanket zero: a device WITH the
// feature keeps its real number.
TEST(DirectVulkanSanity, GatesClipDistancesOnTheShaderClipDistanceFeature) {
using namespace MobileGL;
MG_Backend::DirectVulkan::BackendObject_DirectVulkan backend;
MG_External::VulkanCapabilities caps;
caps.MaxClipDistances = 8;
caps.SupportsShaderClipDistance = false;
backend.ApplyVulkanCapabilitiesForTesting(caps);
EXPECT_EQ(backend.GetDynamicParameters().MaxClipDistances, 0);
caps.SupportsShaderClipDistance = true;
backend.ApplyVulkanCapabilitiesForTesting(caps);
EXPECT_EQ(backend.GetDynamicParameters().MaxClipDistances, 8);
}
// GL_LAYER_PROVOKING_VERTEX / GL_VIEWPORT_INDEX_PROVOKING_VERTEX were a hard-coded
// GL_LAST_VERTEX_CONVENTION for both backends, derived from nothing, and wrong on both test
// devices in opposite directions. DirectGLES now forwards what its loader resolved; DirectVulkan
// reports GL_UNDEFINED_VERTEX, which GL 4.6 table 23.65 permits and which is what the backend
// honestly implements - the provoking mode is chosen per pipeline out of VK_EXT_provoking_vertex,
// provokingVertexModePerPipeline and the topology.
TEST(ProvokingVertexConventions, EachBackendReportsWhatItActuallyPins) {
using namespace MobileGL;
MG_Backend::DirectGLES::BackendObject_DirectGLES glesBackend;
MG_External::GLESCapabilities glesCaps;
glesCaps.LayerProvokingVertex = GL_FIRST_VERTEX_CONVENTION;
glesCaps.ViewportIndexProvokingVertex = GL_UNDEFINED_VERTEX;
glesBackend.ApplyGLESCapabilitiesForTesting(glesCaps);
EXPECT_EQ(glesBackend.GetDynamicParameters().LayerProvokingVertex,
static_cast<GLenum>(GL_FIRST_VERTEX_CONVENTION));
EXPECT_EQ(glesBackend.GetDynamicParameters().ViewportIndexProvokingVertex,
static_cast<GLenum>(GL_UNDEFINED_VERTEX));
MG_Backend::DirectVulkan::BackendObject_DirectVulkan vkBackend;
MG_External::VulkanCapabilities vkCaps;
vkBackend.ApplyVulkanCapabilitiesForTesting(vkCaps);
EXPECT_EQ(vkBackend.GetDynamicParameters().LayerProvokingVertex, static_cast<GLenum>(GL_UNDEFINED_VERTEX));
EXPECT_EQ(vkBackend.GetDynamicParameters().ViewportIndexProvokingVertex,
static_cast<GLenum>(GL_UNDEFINED_VERTEX));
}
TEST(FragmentInterpolationCapabilities, PlumbsGLESAndBothVulkanPropertyPaths) {
using namespace MobileGL;
@@ -929,6 +1036,161 @@ void main() {
MG_Backend::pActiveBackendObject.reset();
}
// KHR-GL43.shader_atomic_counters.basic-glsl-built-in, .basic-buffer-bind and .basic-api-get.
// The atomic-counter limits used to live in two unreconciled tables - glslang compiled every
// shader against ONE binding while glGetIntegerv advertised thirty-six - and three of the enums
// had no case in the getter at all, so the query raised INVALID_ENUM and left the caller reading
// whatever was in its own stack slot.
TEST(GetterSanity, AtomicCounterQueriesMatchShaderCompilerLimits) {
using namespace MobileGL;
namespace Transpiler = MG_Util::ShaderTranspiler;
auto previousContext = Move(MG_State::pGLContext);
auto previousBackend = Move(MG_Backend::pActiveBackendObject);
MG_State::pGLContext = MakeUnique<MG_State::GLState::GLContext>();
MG_Backend::pActiveBackendObject = MakeUnique<DynamicParameterBackend>(MG_Backend::DynamicBackendParameters{});
GLint reported = -1;
MG_Impl::GLImpl::GetIntegerv(GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS, &reported);
EXPECT_EQ(reported, static_cast<GLint>(Transpiler::MAX_ATOMIC_COUNTER_BUFFER_BINDINGS));
MG_Impl::GLImpl::GetIntegerv(GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE, &reported);
EXPECT_EQ(reported, static_cast<GLint>(Transpiler::MAX_ATOMIC_COUNTER_BUFFER_SIZE));
for (const GLenum pname : {GL_MAX_COMBINED_ATOMIC_COUNTER_BUFFERS, GL_MAX_FRAGMENT_ATOMIC_COUNTER_BUFFERS,
GL_MAX_COMPUTE_ATOMIC_COUNTER_BUFFERS}) {
reported = -1;
MG_Impl::GLImpl::GetIntegerv(pname, &reported);
EXPECT_EQ(reported, static_cast<GLint>(Transpiler::MAX_ATOMIC_COUNTER_BUFFERS_PER_STAGE))
<< "pname " << pname;
}
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// glBindBufferBase sets the GENERIC binding point too (GL 4.6 6.1.1), and this is the one
// indexed-buffer family whose non-indexed query had no case.
reported = -1;
MG_Impl::GLImpl::GetIntegerv(GL_ATOMIC_COUNTER_BUFFER_BINDING, &reported);
EXPECT_EQ(reported, 0);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
GLuint buffer = 0;
MG_Impl::GLImpl::GenBuffers(1, &buffer);
MG_Impl::GLImpl::BindBuffer(GL_ATOMIC_COUNTER_BUFFER, buffer);
MG_Impl::GLImpl::BufferData(GL_ATOMIC_COUNTER_BUFFER, 64, nullptr, GL_STATIC_DRAW);
MG_Impl::GLImpl::BindBufferBase(GL_ATOMIC_COUNTER_BUFFER, 2, buffer);
MG_Impl::GLImpl::GetIntegerv(GL_ATOMIC_COUNTER_BUFFER_BINDING, &reported);
EXPECT_EQ(static_cast<GLuint>(reported), buffer);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// The advertised ceiling is also the one glBindBufferBase and the indexed getter enforce.
// A limit nothing validates against is how these tables drifted apart in the first place:
// the binding-point ARRAY is 36 deep, and it used to be that number an application saw.
constexpr GLuint pastLastBinding = static_cast<GLuint>(Transpiler::MAX_ATOMIC_COUNTER_BUFFER_BINDINGS);
MG_Impl::GLImpl::BindBufferBase(GL_ATOMIC_COUNTER_BUFFER, pastLastBinding, buffer);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), static_cast<GLenum>(GL_INVALID_VALUE));
MG_Impl::GLImpl::GetIntegeri_v(GL_ATOMIC_COUNTER_BUFFER_BINDING, pastLastBinding, &reported);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), static_cast<GLenum>(GL_INVALID_VALUE));
// ...and the shading language has to expand the same numbers. Each array is sized by a
// built-in constant and indexed at its last element with a literal, so the stage only
// compiles when that constant is at least what glGetIntegerv just reported - which it was
// not while the resource table said one.
const String lastBinding = std::to_string(Transpiler::MAX_ATOMIC_COUNTER_BUFFER_BINDINGS - 1);
const String lastBuffer = std::to_string(Transpiler::MAX_ATOMIC_COUNTER_BUFFERS_PER_STAGE - 1);
const String source = R"(#version 430 core
out vec4 color;
int mgBindings[gl_MaxAtomicCounterBindings];
int mgCombinedBuffers[gl_MaxCombinedAtomicCounterBuffers];
int mgFragmentBuffers[gl_MaxFragmentAtomicCounterBuffers];
layout(binding = )" + lastBinding + R"(, offset = 0) uniform atomic_uint mgCounter;
void main() {
color = vec4(float(mgBindings[)" + lastBinding + R"(] + mgCombinedBuffers[)" + lastBuffer +
R"(] + mgFragmentBuffers[)" + lastBuffer + R"(] + int(atomicCounterIncrement(mgCounter))));
}
)";
auto compiled = MG_Util::ShaderTranspiler::ShaderCompiler::CompileShader({
.shaderType = GL_FRAGMENT_SHADER,
.sourceStr = source,
});
EXPECT_TRUE(compiled) << (compiled ? "" : compiled.error().log);
MG_Backend::pActiveBackendObject = Move(previousBackend);
MG_State::pGLContext = Move(previousContext);
}
// KHR-GL43.compute_shader.max: the test queries every GL_MAX_COMPUTE_* value through the API and
// then makes a compute shader compare the matching gl_MaxCompute* constant against it. The two
// used to be independent tables and gl_MaxComputeWorkGroupSize.z disagreed - glslang compiled
// against a permissive 1024 while the context advertises the 64 the GL 4.6 minimum (and every ES
// driver) reports.
TEST(GetterSanity, ComputeWorkGroupQueriesMatchShaderCompilerLimits) {
using namespace MobileGL;
auto previousContext = Move(MG_State::pGLContext);
auto previousBackend = Move(MG_Backend::pActiveBackendObject);
MG_State::pGLContext = MakeUnique<MG_State::GLState::GLContext>();
MG_Backend::pActiveBackendObject = MakeUnique<DynamicParameterBackend>(MG_Backend::DynamicBackendParameters{});
GLint size[3] = {0, 0, 0};
GLint count[3] = {0, 0, 0};
for (GLuint index = 0; index < 3; ++index) {
MG_Impl::GLImpl::GetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, index, &size[index]);
MG_Impl::GLImpl::GetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, index, &count[index]);
}
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// The compile runs against a captured env, exactly as the pipeline's does. That is the whole
// invariant: the env holds the same floored driver answer GetIntegeri_v just returned, so the
// resource table and the query agree BY CONSTRUCTION rather than by two tables happening to
// carry the same literals.
const auto env = MG_Util::ShaderTranspiler::CaptureCompileEnv();
for (GLuint index = 0; index < 3; ++index) {
EXPECT_EQ(static_cast<GLint>(env->maxComputeWorkGroupSize[index]), size[index]) << "index " << index;
EXPECT_EQ(static_cast<GLint>(env->maxComputeWorkGroupCount[index]), count[index]) << "index " << index;
}
// A negative array size is a compile error, so the stage only compiles when EVERY component
// of both built-in constants equals what the query above reported. Two-sided by construction:
// a resource table that is too permissive fails it exactly like one that is too tight.
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
const int mgAgree = (gl_MaxComputeWorkGroupSize == ivec3()" +
std::to_string(size[0]) + ", " + std::to_string(size[1]) + ", " +
std::to_string(size[2]) + R"() &&
gl_MaxComputeWorkGroupCount == ivec3()" +
std::to_string(count[0]) + ", " + std::to_string(count[1]) + ", " +
std::to_string(count[2]) + R"()) ? 1 : -1;
int mgProbe[mgAgree];
void main() {
mgProbe[0] = 0;
}
)";
auto compiled = MG_Util::ShaderTranspiler::ShaderCompiler::CompileShader({
.shaderType = GL_COMPUTE_SHADER,
.sourceStr = source,
.env = env.get(),
});
EXPECT_TRUE(compiled) << (compiled ? "" : compiled.error().log);
// The z ceiling is also what glslang checks a declared local_size_z against, so it has to
// reject one invocation past the advertised limit and accept the limit itself.
const String atLimit = "#version 430 core\nlayout(local_size_z = " + std::to_string(size[2]) +
") in;\nvoid main() {}\n";
const String pastLimit = "#version 430 core\nlayout(local_size_z = " + std::to_string(size[2] + 1) +
") in;\nvoid main() {}\n";
EXPECT_TRUE(MG_Util::ShaderTranspiler::ShaderCompiler::CompileShader({
.shaderType = GL_COMPUTE_SHADER,
.sourceStr = atLimit,
.env = env.get(),
}));
EXPECT_FALSE(MG_Util::ShaderTranspiler::ShaderCompiler::CompileShader({
.shaderType = GL_COMPUTE_SHADER,
.sourceStr = pastLimit,
.env = env.get(),
}));
MG_Backend::pActiveBackendObject = Move(previousBackend);
MG_State::pGLContext = Move(previousContext);
}
TEST(GetterSanity, ReportsKhrSubgroupDynamicParameters) {
using namespace MobileGL;
@@ -1169,7 +1431,10 @@ TEST(DirectVulkanSanity, SpirvStorageImageFormatsMapToVulkanFormats) {
{SpvImageFormatR11fG11fB10f, VK_FORMAT_B10G11R11_UFLOAT_PACK32},
{SpvImageFormatR16f, VK_FORMAT_R16_SFLOAT},
{SpvImageFormatRgba16, VK_FORMAT_R16G16B16A16_UNORM},
{SpvImageFormatRgb10A2, VK_FORMAT_A2R10G10B10_UNORM_PACK32},
// A2**B**10G10R10, matching MGToVk::ConvertTextureInternalFormatToVkFormat's RGB10A2:
// the view format and the image format have to name the same bit layout, and
// GL_UNSIGNED_INT_2_10_10_10_REV is A2B10G10R10. A2R10G10B10 transposes R and B.
{SpvImageFormatRgb10A2, VK_FORMAT_A2B10G10R10_UNORM_PACK32},
{SpvImageFormatRg16, VK_FORMAT_R16G16_UNORM},
{SpvImageFormatRg8, VK_FORMAT_R8G8_UNORM},
{SpvImageFormatR16, VK_FORMAT_R16_UNORM},
@@ -1192,7 +1457,7 @@ TEST(DirectVulkanSanity, SpirvStorageImageFormatsMapToVulkanFormats) {
{SpvImageFormatRgba16ui, VK_FORMAT_R16G16B16A16_UINT},
{SpvImageFormatRgba8ui, VK_FORMAT_R8G8B8A8_UINT},
{SpvImageFormatR32ui, VK_FORMAT_R32_UINT},
{SpvImageFormatRgb10a2ui, VK_FORMAT_A2R10G10B10_UINT_PACK32},
{SpvImageFormatRgb10a2ui, VK_FORMAT_A2B10G10R10_UINT_PACK32},
{SpvImageFormatRg32ui, VK_FORMAT_R32G32_UINT},
{SpvImageFormatRg16ui, VK_FORMAT_R16G16_UINT},
{SpvImageFormatRg8ui, VK_FORMAT_R8G8_UINT},
+16
View File
@@ -15,5 +15,21 @@ target_link_libraries(DriverPostIterationRPWitnessTest PRIVATE
${LINK_LIBRARIES}
)
add_executable(
DriverBugProbesTest
DriverBugProbesTest.cpp
)
target_include_directories(DriverBugProbesTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(DriverBugProbesTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
include(GoogleTest)
gtest_discover_tests(DriverPostIterationRPWitnessTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(DriverBugProbesTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -0,0 +1,660 @@
// MobileGL - MobileGL/MG_Test/SelfTest/DriverBugProbesTest.cpp
// Copyright (c) 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 <gtest/gtest.h>
#include <MG_Util/SelfTest/DriverBugProbes.h>
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <map>
#include <string>
#include <vector>
using namespace MobileGL;
using MobileGL::MG_Util::SelfTest::CollectGlesKnownDriverBugs;
using MobileGL::MG_Util::SelfTest::DriverBugVerdict;
using MobileGL::MG_Util::SelfTest::ProbeCrossStageImageQualifierMergeDropsWrites;
using MobileGL::MG_Util::SelfTest::ProbeGeometryStageSsboWriteAfterEmitDropped;
using MobileGL::MG_Util::SelfTest::ProbeImageLocationPerNameBudget;
using MobileGL::MG_Util::SelfTest::ProbeImageWriteReadCoherencyResidual;
using MobileGL::MG_Util::SelfTest::ProbeR32FMultisampleSwizzleCorruption;
namespace {
// A driver table with nothing resolved. Every probe has to treat this as "cannot tell",
// never as "affected".
MG_External::GLESFunctionsTable EmptyFunctionTable() {
return MG_External::GLESFunctionsTable{};
}
// ===================== THE FAKE DRIVER =====================
//
// Same idea as the fake GLES table BackendLoaderTest drives the gl_InstanceID probe with:
// captureless lambdas over one file-scope state, with per-test knobs that turn each defect
// on and off. It is deliberately a MODEL of the defect rather than a canned answer - the
// fake reads the shader text the probe actually submitted and reproduces what the affected
// driver does with it, so a probe that stopped building the triggering shape would stop
// detecting, which is exactly what these tests are for.
//
// These tests call the Probe* functions directly rather than through
// CollectGlesKnownDriverBugs(): the collector goes through the once-per-process memos, and a
// memo latched by one test would decide the answer for every later one.
// The exact text an affected Adreno driver puts in the info log for this refusal.
const char* const kImageLocationLinkLog =
"Error: Image Image location or component exceeds max allowed.\nError: Linking failed.";
struct FakeDriver {
// ---- limits the probes gate on -------------------------------------
GLint maxColorTextureSamples = 4;
GLint maxImageUnits = 8;
GLint maxVertexImageUniforms = 8;
GLint maxFragmentImageUniforms = 8;
GLint maxGeometryImageUniforms = 3;
// The landed geometry probe reads this; zero keeps it inert so it cannot interfere.
GLint maxGeometrySsboBlocks = 0;
bool geometryImageLimitQueryRaisesError = false;
bool colorTextureSamplesQueryRaisesError = false;
// ---- defect knobs ---------------------------------------------------
// Probe 1: a swizzled-alpha, non-zero-sample .w fetch reads garbage from the second
// sampling program onward.
bool msaaSwizzledAlphaCorrupted = false;
// Probe 1's inconclusive path: EVERY sampled read is wrong, including the controls.
bool msaaEveryReadWrong = false;
// Probe 2: the link fails once the program declares more distinct image uniform NAMES
// than this.
int distinctImageNameBudget = 1000;
// Probe 3: a same-name coherent writeonly/readonly pair loses the writing stage's store.
bool sameNameImagePairDropsWrites = false;
// Probe 3's inconclusive path: the renamed control loses it too.
bool everyVertexImageWriteDropped = false;
// Probe 4: how many texels the in-invocation dependent read misses under the STRONGEST
// shape, how many it misses under the shape MobileGL emits today, and whether the
// two-draw control misses them too.
int coherencyStrongestShapeFailedTexels = 0;
int coherencyEmittedShapeFailedTexels = 0;
int coherencyControlFailedTexels = 0;
// ---- object bookkeeping ---------------------------------------------
GLenum pendingError = GL_NO_ERROR;
GLuint nextShaderId = 1;
GLuint nextProgramId = 1;
GLuint nextTextureId = 1;
GLuint nextFramebufferId = 1;
GLuint nextVertexArrayId = 1;
int aliveShaders = 0;
int alivePrograms = 0;
int aliveTextures = 0;
int aliveFramebuffers = 0;
int aliveVertexArrays = 0;
std::map<GLuint, std::string> shaderSources;
std::map<GLuint, std::vector<GLuint>> programShaders;
std::map<GLuint, bool> programLinked;
std::map<GLuint, std::string> programInfoLogs;
// texture id -> GL_TEXTURE_SWIZZLE_A
std::map<GLuint, GLenum> multisampleAlphaSwizzle;
GLuint boundMultisampleTexture = 0;
GLuint currentProgram = 0;
// How many programs that sample a multisample texture have been linked so far. The
// corruption starts at the second.
int sampledMultisampleProgramCount = 0;
// Set by glDrawArrays, consumed by glReadPixels.
GLfloat lastSampledValue = 1.0f;
int lastFailedTexelCount = 0;
};
FakeDriver g_fake;
void ResetFakeDriver() { g_fake = FakeDriver{}; }
const std::string& SourceOf(GLuint shader) {
static const std::string empty;
const auto it = g_fake.shaderSources.find(shader);
return it == g_fake.shaderSources.end() ? empty : it->second;
}
bool Contains(const std::string& haystack, const char* needle) {
return haystack.find(needle) != std::string::npos;
}
// Every `image2D <name>` the program declares, across all its stages.
std::vector<std::string> DeclaredImageNames(GLuint program) {
std::vector<std::string> names;
const auto attached = g_fake.programShaders.find(program);
if (attached == g_fake.programShaders.end()) return names;
for (const GLuint shader : attached->second) {
const std::string& source = SourceOf(shader);
std::size_t at = 0;
while ((at = source.find("image2D ", at)) != std::string::npos) {
at += std::strlen("image2D ");
const std::size_t end = source.find_first_of(";,)", at);
if (end == std::string::npos) break;
std::string name = source.substr(at, end - at);
while (!name.empty() && (name.back() == ' ' || name.back() == '\t')) name.pop_back();
if (std::find(names.begin(), names.end(), name) == names.end()) {
names.push_back(name);
}
at = end;
}
}
return names;
}
std::string StageSourceContaining(GLuint program, const char* needle) {
const auto attached = g_fake.programShaders.find(program);
if (attached == g_fake.programShaders.end()) return {};
for (const GLuint shader : attached->second) {
const std::string& source = SourceOf(shader);
if (Contains(source, needle)) return source;
}
return {};
}
// The uniform name in `... image2D <name>;` of the first declaration in `source`.
std::string FirstImageNameIn(const std::string& source) {
const std::size_t at = source.find("image2D ");
if (at == std::string::npos) return {};
const std::size_t start = at + std::strlen("image2D ");
const std::size_t end = source.find(';', start);
if (end == std::string::npos) return {};
return source.substr(start, end - start);
}
// Whatever the sampling vertex shader asked for: `texelFetch(mg_probeSampler, ivec2(0), N).C`.
void ParseSampledFetch(const std::string& source, int& sampleIndex, char& component) {
sampleIndex = -1;
component = '?';
const std::size_t at = source.find("texelFetch(mg_probeSampler, ivec2(0), ");
if (at == std::string::npos) return;
const std::size_t start = at + std::strlen("texelFetch(mg_probeSampler, ivec2(0), ");
sampleIndex = std::atoi(source.c_str() + start);
const std::size_t dot = source.find(").", start);
if (dot != std::string::npos && dot + 2 < source.size()) component = source[dot + 2];
}
MG_External::GLESFunctionsTable MakeFakeGLESFunctions() {
MG_External::GLESFunctionsTable funcs{};
funcs.glGetError = []() -> GLenum {
const GLenum error = g_fake.pendingError;
g_fake.pendingError = GL_NO_ERROR;
return error;
};
funcs.glGetIntegerv = [](GLenum pname, GLint* data) {
switch (pname) {
case GL_MAX_COLOR_TEXTURE_SAMPLES:
if (g_fake.colorTextureSamplesQueryRaisesError) {
g_fake.pendingError = GL_INVALID_ENUM;
} else {
*data = g_fake.maxColorTextureSamples;
}
break;
case GL_MAX_IMAGE_UNITS:
*data = g_fake.maxImageUnits;
break;
case GL_MAX_VERTEX_IMAGE_UNIFORMS:
*data = g_fake.maxVertexImageUniforms;
break;
case GL_MAX_FRAGMENT_IMAGE_UNIFORMS:
*data = g_fake.maxFragmentImageUniforms;
break;
case GL_MAX_GEOMETRY_IMAGE_UNIFORMS:
if (g_fake.geometryImageLimitQueryRaisesError) {
g_fake.pendingError = GL_INVALID_ENUM;
} else {
*data = g_fake.maxGeometryImageUniforms;
}
break;
case GL_MAX_GEOMETRY_SHADER_STORAGE_BLOCKS:
*data = g_fake.maxGeometrySsboBlocks;
break;
default:
break;
}
};
funcs.glGetIntegeri_v = [](GLenum, GLuint, GLint* data) { *data = 0; };
funcs.glGetFloatv = [](GLenum, GLfloat* data) {
data[0] = 0.0f;
data[1] = 0.0f;
data[2] = 0.0f;
data[3] = 0.0f;
};
funcs.glIsEnabled = [](GLenum) -> GLboolean { return GL_FALSE; };
funcs.glEnable = [](GLenum) {};
funcs.glDisable = [](GLenum) {};
funcs.glFinish = []() {};
funcs.glMemoryBarrier = [](GLbitfield) {};
funcs.glPixelStorei = [](GLenum, GLint) {};
funcs.glViewport = [](GLint, GLint, GLsizei, GLsizei) {};
funcs.glClear = [](GLbitfield) {};
funcs.glClearColor = [](GLfloat, GLfloat, GLfloat, GLfloat) {};
funcs.glActiveTexture = [](GLenum) {};
// ---- shaders and programs -------------------------------------------
funcs.glCreateShader = [](GLenum) -> GLuint {
++g_fake.aliveShaders;
return g_fake.nextShaderId++;
};
funcs.glShaderSource = [](GLuint shader, GLsizei count, const GLchar* const* strings,
const GLint*) {
std::string source;
for (GLsizei i = 0; i < count; ++i) {
if (strings[i] != nullptr) source += strings[i];
}
g_fake.shaderSources[shader] = std::move(source);
};
funcs.glCompileShader = [](GLuint) {};
funcs.glGetShaderiv = [](GLuint, GLenum pname, GLint* params) {
if (pname == GL_COMPILE_STATUS) *params = GL_TRUE;
};
funcs.glGetShaderInfoLog = [](GLuint, GLsizei bufSize, GLsizei*, GLchar* infoLog) {
if (bufSize > 0) infoLog[0] = '\0';
};
funcs.glDeleteShader = [](GLuint shader) {
if (shader != 0) --g_fake.aliveShaders;
};
funcs.glCreateProgram = []() -> GLuint {
++g_fake.alivePrograms;
return g_fake.nextProgramId++;
};
funcs.glAttachShader = [](GLuint program, GLuint shader) {
g_fake.programShaders[program].push_back(shader);
};
funcs.glLinkProgram = [](GLuint program) {
const std::vector<std::string> names = DeclaredImageNames(program);
const bool overBudget = static_cast<int>(names.size()) > g_fake.distinctImageNameBudget;
g_fake.programLinked[program] = !overBudget;
g_fake.programInfoLogs[program] = overBudget ? kImageLocationLinkLog : "";
if (!overBudget && !StageSourceContaining(program, "texelFetch(mg_probeSampler").empty()) {
++g_fake.sampledMultisampleProgramCount;
}
};
funcs.glGetProgramiv = [](GLuint program, GLenum pname, GLint* params) {
if (pname != GL_LINK_STATUS) return;
const auto it = g_fake.programLinked.find(program);
*params = (it == g_fake.programLinked.end() || it->second) ? GL_TRUE : GL_FALSE;
};
funcs.glGetProgramInfoLog = [](GLuint program, GLsizei bufSize, GLsizei*, GLchar* infoLog) {
if (bufSize <= 0) return;
const auto it = g_fake.programInfoLogs.find(program);
const std::string& log = it == g_fake.programInfoLogs.end() ? std::string() : it->second;
const GLsizei copied = static_cast<GLsizei>(
std::min<std::size_t>(log.size(), static_cast<std::size_t>(bufSize - 1)));
std::memcpy(infoLog, log.data(), static_cast<std::size_t>(copied));
infoLog[copied] = '\0';
};
funcs.glDeleteProgram = [](GLuint program) {
if (program != 0) --g_fake.alivePrograms;
};
funcs.glUseProgram = [](GLuint program) { g_fake.currentProgram = program; };
funcs.glGetUniformLocation = [](GLuint, const GLchar*) -> GLint { return 0; };
funcs.glUniform1i = [](GLint, GLint) {};
// ---- textures, framebuffers, vertex arrays ---------------------------
funcs.glGenTextures = [](GLsizei n, GLuint* textures) {
for (GLsizei i = 0; i < n; ++i) {
textures[i] = g_fake.nextTextureId++;
++g_fake.aliveTextures;
}
};
funcs.glBindTexture = [](GLenum target, GLuint texture) {
if (target == GL_TEXTURE_2D_MULTISAMPLE) g_fake.boundMultisampleTexture = texture;
};
funcs.glDeleteTextures = [](GLsizei n, const GLuint* textures) {
for (GLsizei i = 0; i < n; ++i) {
if (textures[i] != 0) --g_fake.aliveTextures;
}
};
funcs.glTexParameteri = [](GLenum target, GLenum pname, GLint param) {
if (target == GL_TEXTURE_2D_MULTISAMPLE && pname == GL_TEXTURE_SWIZZLE_A) {
g_fake.multisampleAlphaSwizzle[g_fake.boundMultisampleTexture] =
static_cast<GLenum>(param);
}
};
funcs.glTexImage2D = [](GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum,
const void*) {};
funcs.glTexSubImage2D = [](GLenum, GLint, GLint, GLint, GLsizei, GLsizei, GLenum, GLenum,
const void*) {};
funcs.glTexStorage2D = [](GLenum, GLsizei, GLenum, GLsizei, GLsizei) {};
funcs.glTexStorage2DMultisample = [](GLenum, GLsizei, GLenum, GLsizei, GLsizei, GLboolean) {};
funcs.glGenFramebuffers = [](GLsizei n, GLuint* framebuffers) {
for (GLsizei i = 0; i < n; ++i) {
framebuffers[i] = g_fake.nextFramebufferId++;
++g_fake.aliveFramebuffers;
}
};
funcs.glBindFramebuffer = [](GLenum, GLuint) {};
funcs.glFramebufferTexture2D = [](GLenum, GLenum, GLenum, GLuint, GLint) {};
funcs.glCheckFramebufferStatus = [](GLenum) -> GLenum { return GL_FRAMEBUFFER_COMPLETE; };
funcs.glDeleteFramebuffers = [](GLsizei n, const GLuint* framebuffers) {
for (GLsizei i = 0; i < n; ++i) {
if (framebuffers[i] != 0) --g_fake.aliveFramebuffers;
}
};
funcs.glGenVertexArrays = [](GLsizei n, GLuint* arrays) {
for (GLsizei i = 0; i < n; ++i) {
arrays[i] = g_fake.nextVertexArrayId++;
++g_fake.aliveVertexArrays;
}
};
funcs.glBindVertexArray = [](GLuint) {};
funcs.glDeleteVertexArrays = [](GLsizei n, const GLuint* arrays) {
for (GLsizei i = 0; i < n; ++i) {
if (arrays[i] != 0) --g_fake.aliveVertexArrays;
}
};
funcs.glBindImageTexture = [](GLuint, GLuint, GLint, GLboolean, GLint, GLenum, GLenum) {};
// ---- the draw, where the defects live --------------------------------
funcs.glDrawArrays = [](GLenum, GLint, GLsizei) {
const GLuint program = g_fake.currentProgram;
const std::string sampling = StageSourceContaining(program, "texelFetch(mg_probeSampler");
if (!sampling.empty()) {
int sampleIndex = -1;
char component = '?';
ParseSampledFetch(sampling, sampleIndex, component);
const GLenum swizzle = g_fake.multisampleAlphaSwizzle.count(
g_fake.boundMultisampleTexture) != 0
? g_fake.multisampleAlphaSwizzle[g_fake.boundMultisampleTexture]
: GL_ALPHA;
// An R32F texel filled with (1, 0, 0, -) reads 1.0 through both the ALPHA and the
// RED swizzle sources, which is why one expected constant covers every shape.
g_fake.lastSampledValue = 1.0f;
if (g_fake.msaaEveryReadWrong) {
g_fake.lastSampledValue = 0.0f;
} else if (g_fake.msaaSwizzledAlphaCorrupted && swizzle == GL_RED && component == 'w' &&
sampleIndex != 0 && g_fake.sampledMultisampleProgramCount >= 2) {
// Uninitialised memory: a value that is neither the answer nor the clear.
g_fake.lastSampledValue = -1.34954e-17f;
}
return;
}
// Matched on the access qualifier alone, not on "coherent writeonly": the strongest
// coherency shape spells it "coherent volatile writeonly".
const std::string writeStage = StageSourceContaining(program, "writeonly");
const std::string readStage = StageSourceContaining(program, "readonly");
if (!writeStage.empty() && !readStage.empty() && Contains(readStage, "memoryBarrierImage")) {
// The coherency probe: one invocation stores and then reads back. `volatile` is
// what tells the strongest shape apart from the one MobileGL emits today, and
// giving them separate knobs is what lets a test pin the case where only the
// emitted shape is wrong - a fixable defect that must not be reported here.
g_fake.lastFailedTexelCount = Contains(readStage, "coherent volatile")
? g_fake.coherencyStrongestShapeFailedTexels
: g_fake.coherencyEmittedShapeFailedTexels;
return;
}
if (!writeStage.empty() && readStage.empty()) {
// The coherency control's store half; the load half decides the result.
g_fake.lastFailedTexelCount = 0;
return;
}
if (writeStage.empty() && !readStage.empty()) {
g_fake.lastFailedTexelCount = g_fake.coherencyControlFailedTexels;
return;
}
if (!writeStage.empty() && !readStage.empty()) {
// The qualifier-merge pair: the stores are lost when the two halves share a name.
const bool sharedName =
FirstImageNameIn(writeStage) == FirstImageNameIn(readStage) &&
!FirstImageNameIn(writeStage).empty();
const bool lost = g_fake.everyVertexImageWriteDropped ||
(g_fake.sameNameImagePairDropsWrites && sharedName);
g_fake.lastFailedTexelCount = lost ? 1 << 20 : 0;
return;
}
g_fake.lastFailedTexelCount = 0;
};
funcs.glReadPixels = [](GLint, GLint, GLsizei width, GLsizei height, GLenum format, GLenum type,
void* pixels) {
const std::size_t texels = static_cast<std::size_t>(width) * static_cast<std::size_t>(height);
if (format == GL_RED && type == GL_FLOAT) {
GLfloat* out = static_cast<GLfloat*>(pixels);
for (std::size_t i = 0; i < texels; ++i) out[i] = g_fake.lastSampledValue;
return;
}
GLubyte* out = static_cast<GLubyte*>(pixels);
const std::size_t failed =
std::min<std::size_t>(texels, static_cast<std::size_t>(g_fake.lastFailedTexelCount));
for (std::size_t i = 0; i < texels; ++i) {
const bool ok = i >= failed;
out[i * 4 + 0] = ok ? 0 : 255;
out[i * 4 + 1] = ok ? 255 : 0;
out[i * 4 + 2] = 0;
out[i * 4 + 3] = 255;
}
};
return funcs;
}
void ExpectProbeReleasedEverything() {
EXPECT_EQ(g_fake.aliveShaders, 0);
EXPECT_EQ(g_fake.alivePrograms, 0);
EXPECT_EQ(g_fake.aliveTextures, 0);
EXPECT_EQ(g_fake.aliveFramebuffers, 0);
EXPECT_EQ(g_fake.aliveVertexArrays, 0);
}
} // namespace
// The rule the whole section depends on: a probe that cannot run reports NO bug. If an
// unrunnable probe answered "affected", every device without the entry points - every desktop
// build, every unit-test process - would grow a driver-bug row it has no evidence for, and the
// section would stop meaning "this device has these bugs".
TEST(DriverBugProbes, AProbeThatCannotRunReportsNoBug) {
const MG_External::GLESFunctionsTable gl = EmptyFunctionTable();
EXPECT_FALSE(ProbeGeometryStageSsboWriteAfterEmitDropped(gl))
<< "a probe with no entry points to call must not claim the driver is affected";
EXPECT_FALSE(ProbeR32FMultisampleSwizzleCorruption(gl));
EXPECT_FALSE(ProbeImageLocationPerNameBudget(gl).detected);
EXPECT_FALSE(ProbeCrossStageImageQualifierMergeDropsWrites(gl));
EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected);
}
// The section lists only bugs the device HAS, so a driver nothing could be probed on renders
// nothing at all rather than a list of reassurances.
TEST(DriverBugProbes, CollectsNoFindingsWhenNothingCanBeProbed) {
const MG_External::GLESFunctionsTable gl = EmptyFunctionTable();
EXPECT_TRUE(CollectGlesKnownDriverBugs(gl).empty());
}
// Every finding the table can produce is a bug that is PRESENT, which is why the vocabulary is
// FIXED/UNFIXABLE and not PASS/FAIL. This latches that no probe can smuggle in a "not affected"
// row by returning a finding with an empty name or detail - the screen renders both.
TEST(DriverBugProbes, EveryFindingCarriesANameAndAnExplanation) {
const MG_External::GLESFunctionsTable gl = EmptyFunctionTable();
for (const auto& finding : CollectGlesKnownDriverBugs(gl)) {
EXPECT_FALSE(finding.name.empty());
EXPECT_FALSE(finding.detail.empty()) << finding.name << " must say what MobileGL does about it";
EXPECT_TRUE(finding.verdict == DriverBugVerdict::Fixed ||
finding.verdict == DriverBugVerdict::Unfixable);
}
}
// ===================== R32F MULTISAMPLE SWIZZLE =====================
TEST(DriverBugProbes, R32FMultisampleSwizzleIsCleanOnAConformingDriver) {
ResetFakeDriver();
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeR32FMultisampleSwizzleCorruption(gl));
ExpectProbeReleasedEverything();
}
TEST(DriverBugProbes, R32FMultisampleSwizzleIsDetectedFromTheSecondProgramOnward) {
ResetFakeDriver();
g_fake.msaaSwizzledAlphaCorrupted = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_TRUE(ProbeR32FMultisampleSwizzleCorruption(gl));
ExpectProbeReleasedEverything();
}
// The control rule, made executable: a driver on which even the default-swizzle, sample-zero and
// .x reads are wrong is broken in some larger way, and the probe may not name the alpha swizzle
// as the cause.
TEST(DriverBugProbes, R32FMultisampleSwizzleReportsNothingWhenTheControlsAreWrongToo) {
ResetFakeDriver();
g_fake.msaaSwizzledAlphaCorrupted = true;
g_fake.msaaEveryReadWrong = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeR32FMultisampleSwizzleCorruption(gl))
<< "with every read wrong the probe has no evidence that the alpha swizzle is the variable";
}
TEST(DriverBugProbes, R32FMultisampleSwizzleNeedsMoreThanOneSample) {
ResetFakeDriver();
g_fake.msaaSwizzledAlphaCorrupted = true;
g_fake.maxColorTextureSamples = 1;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeR32FMultisampleSwizzleCorruption(gl));
}
// ===================== IMAGE LOCATION PER NAME =====================
TEST(DriverBugProbes, ImageLocationBudgetIsCleanWhenNamesDoNotCost) {
ResetFakeDriver();
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
const auto measurement = ProbeImageLocationPerNameBudget(gl);
EXPECT_FALSE(measurement.detected);
ExpectProbeReleasedEverything();
}
TEST(DriverBugProbes, ImageLocationBudgetIsDetectedWhenOnlyTheSharedNamesLink) {
ResetFakeDriver();
// Four image uniforms per stage: twelve distinct names in the subject, four in the control.
g_fake.distinctImageNameBudget = 5;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
const auto measurement = ProbeImageLocationPerNameBudget(gl);
EXPECT_TRUE(measurement.detected);
EXPECT_EQ(measurement.perStageImageUniforms, g_fake.maxGeometryImageUniforms + 1);
EXPECT_EQ(measurement.subjectDistinctNames, measurement.perStageImageUniforms * 3);
EXPECT_EQ(measurement.controlDistinctNames, measurement.perStageImageUniforms);
EXPECT_NE(measurement.driverMessage.find("exceeds max allowed"), String::npos)
<< "the report quotes the driver rather than paraphrasing it";
ExpectProbeReleasedEverything();
}
// The control rule again: when the shared-name program is refused too, the shape is simply too
// big for this driver and the refusal is honest.
TEST(DriverBugProbes, ImageLocationBudgetReportsNothingWhenTheControlAlsoFails) {
ResetFakeDriver();
g_fake.distinctImageNameBudget = 2;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeImageLocationPerNameBudget(gl).detected);
}
TEST(DriverBugProbes, ImageLocationBudgetNeedsAGeometryStageThatCanHoldImages) {
ResetFakeDriver();
g_fake.distinctImageNameBudget = 5;
g_fake.maxGeometryImageUniforms = 0;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeImageLocationPerNameBudget(gl).detected);
}
TEST(DriverBugProbes, ImageLocationBudgetStaysSilentOnAContextWithoutTheGeometryLimit) {
ResetFakeDriver();
g_fake.distinctImageNameBudget = 5;
g_fake.geometryImageLimitQueryRaisesError = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeImageLocationPerNameBudget(gl).detected)
<< "a pre-ES-3.2 context has no geometry stage to build the shape out of";
}
// ===================== CROSS-STAGE QUALIFIER MERGE =====================
TEST(DriverBugProbes, QualifierMergeIsCleanWhenTheDriverKeepsTheStore) {
ResetFakeDriver();
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeCrossStageImageQualifierMergeDropsWrites(gl));
ExpectProbeReleasedEverything();
}
TEST(DriverBugProbes, QualifierMergeIsDetectedWhenOnlyTheSharedNameLosesTheStore) {
ResetFakeDriver();
g_fake.sameNameImagePairDropsWrites = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_TRUE(ProbeCrossStageImageQualifierMergeDropsWrites(gl));
ExpectProbeReleasedEverything();
}
// A driver that loses the RENAMED store too cannot write images from the vertex stage at all -
// a different and much larger claim, which this probe may not make.
TEST(DriverBugProbes, QualifierMergeReportsNothingWhenTheRenamedControlAlsoFails) {
ResetFakeDriver();
g_fake.sameNameImagePairDropsWrites = true;
g_fake.everyVertexImageWriteDropped = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeCrossStageImageQualifierMergeDropsWrites(gl));
}
TEST(DriverBugProbes, QualifierMergeNeedsVertexStageImageUniforms) {
ResetFakeDriver();
g_fake.sameNameImagePairDropsWrites = true;
g_fake.maxVertexImageUniforms = 0;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeCrossStageImageQualifierMergeDropsWrites(gl));
}
// ===================== IMAGE COHERENCY RESIDUAL =====================
TEST(DriverBugProbes, ImageCoherencyIsCleanWhenTheDependentReadObservesTheStore) {
ResetFakeDriver();
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
const auto measurement = ProbeImageWriteReadCoherencyResidual(gl);
EXPECT_FALSE(measurement.detected);
ExpectProbeReleasedEverything();
}
TEST(DriverBugProbes, ImageCoherencyResidualIsDetectedAndQuantified) {
ResetFakeDriver();
g_fake.coherencyStrongestShapeFailedTexels = 376;
g_fake.coherencyEmittedShapeFailedTexels = 418;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
const auto measurement = ProbeImageWriteReadCoherencyResidual(gl);
EXPECT_TRUE(measurement.detected);
EXPECT_EQ(measurement.mismatchedTexels, 376);
EXPECT_EQ(measurement.emittedShapeMismatchedTexels, 418)
<< "the row reports what applications get, not only what is theoretically reachable";
EXPECT_GT(measurement.totalTexels, 418) << "the report needs a denominator to quote a rate";
ExpectProbeReleasedEverything();
}
// The reason the subject is the STRONGEST shape and not the one MobileGL emits. Mesa llvmpipe
// misses every texel with `coherent` + memoryBarrierImage() and none once the pair is also
// `volatile` - a defect MobileGL could fix by emitting a different shape, which is not what
// UNFIXABLE means and does not belong in this section.
TEST(DriverBugProbes, ImageCoherencyReportsNothingWhenAStrongerShapeWouldFixIt) {
ResetFakeDriver();
g_fake.coherencyStrongestShapeFailedTexels = 0;
g_fake.coherencyEmittedShapeFailedTexels = 4096;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected)
<< "a driver the volatile shape satisfies has a fixable defect, not an unfixable one";
}
// The control rule once more: a driver whose glFinish-separated two-draw dependency is ALSO
// dirty has a bigger defect than an in-invocation ordering residual, and this probe must not
// dress that up as one.
TEST(DriverBugProbes, ImageCoherencyReportsNothingWhenTheFinishSeparatedControlIsDirtyToo) {
ResetFakeDriver();
g_fake.coherencyStrongestShapeFailedTexels = 376;
g_fake.coherencyControlFailedTexels = 4096;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected);
}
TEST(DriverBugProbes, ImageCoherencyNeedsBothHalvesOfTheSplitPairInOneStage) {
ResetFakeDriver();
g_fake.coherencyStrongestShapeFailedTexels = 376;
g_fake.maxFragmentImageUniforms = 1;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected);
}
@@ -8,9 +8,15 @@ add_executable(
FixIterationRPSubgroupScratchTest.cpp
EmulateSubgroupsTest.cpp
DemoteFloat64Test.cpp
FlattenFloat64StorageBlockTest.cpp
FlattenXfbInterfaceBlocksTest.cpp
UniquifyIoBlockNamesTest.cpp
LowerViewportIndexTest.cpp
ClampMultisampleFetchTest.cpp
LegalizeResourceArrayIndexTest.cpp
FlattenAtomicCounterBlockTest.cpp
WidenImageFormatsTest.cpp
GlslangCaptureTest.cpp
)
target_include_directories(SpirvPassTest PRIVATE
@@ -29,5 +35,33 @@ if (MSVC)
target_compile_options(SpirvPassTest PRIVATE /Zc:preprocessor)
endif()
# Its own binary: several cases flip MG_Config::Features.ShaderTranslationCache and
# MG_Config::Features.AsyncShaderCompile and read the PROCESS-GLOBAL L1 counters straight
# afterwards, so another suite's links running in the same process would show up as
# hits/misses this one did not cause.
add_executable(
TranslationCacheTest
TranslationCacheTest.cpp
)
target_include_directories(TranslationCacheTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/3rdparty/SPIRV-Reflect
)
target_link_libraries(
TranslationCacheTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(TranslationCacheTest PRIVATE /Zc:preprocessor)
endif()
include(GoogleTest)
gtest_discover_tests(SpirvPassTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
# Heavier than a pure-pass suite: the concurrency cases run 8 threads over thousands of
# rounds and the end-to-end cases link real programs through the compile pool.
gtest_discover_tests(TranslationCacheTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
@@ -379,6 +379,90 @@ TEST_F(DemoteFloat64Test, TheSharedChainDemotesToo) {
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64(output)) << Disassemble(output);
}
// ---------------------------------------------------------------------------
// The capability gate. A backend that consumes 64-bit floats itself gets none of this.
// ---------------------------------------------------------------------------
namespace {
// Everything kWideVertexSource has except the 64-bit vertex INPUT, which is what the
// whole-program demotion falls back for. A fragment stage, so there is no input to have.
constexpr const char* kWideFragmentSource = R"(#version 460 core
layout(std140, binding = 0) uniform Blk {
float a;
double d;
dvec2 v2;
dvec4 v4;
dmat4 m4;
double arr[3];
};
layout(location = 0) uniform double uScale;
layout(location = 0) in vec3 inNormal;
layout(location = 0) out float fOut;
void main() {
double s = d * uScale + a;
s += v2.x + v4.y + m4[0].z + arr[0] + arr[1] + arr[2] + 0.5lf;
fOut = float(s) + inNormal.x;
}
)";
} // namespace
// THE NEGATIVE CONTROL for the whole change: the identical module through the identical entry
// point answers both ways, and the only thing that moved is the capability argument.
TEST_F(DemoteFloat64Test, TheSharedChainKeepsFloat64WhenTheBackendConsumesIt) {
const Vector<Uint32> input = CompileToSpirv(GL_FRAGMENT_SHADER, kWideFragmentSource);
ASSERT_FALSE(input.empty());
ASSERT_TRUE(DeclaresFloat64Capability(input));
ASSERT_GT(CountFloatTypesOfWidth(input, 64), 0u);
Vector<Uint32> native;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, native, true, true, true));
EXPECT_TRUE(DeclaresFloat64Capability(native)) << Disassemble(native);
EXPECT_GT(CountFloatTypesOfWidth(native, 64), 0u) << Disassemble(native);
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresFloat64(native));
Vector<Uint32> demoted;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, demoted, true, true, false));
EXPECT_FALSE(DeclaresFloat64Capability(demoted)) << Disassemble(demoted);
EXPECT_EQ(CountFloatTypesOfWidth(demoted, 64), 0u) << Disassemble(demoted);
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64(demoted));
EXPECT_NE(native, demoted);
}
// The exception the vertex path needs, at the level ProgramSpirvTask asks it: no backend here can
// FETCH 64 bits, so a stage that declares a Float64 input is demoted whole even where the rest of
// its doubles could have survived.
TEST_F(DemoteFloat64Test, AFloat64VertexInputIsRecognisedAndOnlyOnAVertexStage) {
const Vector<Uint32> vertexWithDoubleInput = CompileToSpirv(GL_VERTEX_SHADER, kWideVertexSource);
ASSERT_FALSE(vertexWithDoubleInput.empty());
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresFloat64VertexInput(vertexWithDoubleInput));
// Doubles everywhere but the inputs: the same verdict must be false, or nothing would ever
// take the native path.
const Vector<Uint32> fragmentWithDoubles = CompileToSpirv(GL_FRAGMENT_SHADER, kWideFragmentSource);
ASSERT_FALSE(fragmentWithDoubles.empty());
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64VertexInput(fragmentWithDoubles));
// A vertex stage whose doubles are all internal is fine too - it is the INPUT that cannot be
// fed, not the stage.
const String vertexWithoutDoubleInput = R"(#version 460 core
layout(location = 0) uniform double uScale;
layout(location = 0) in vec3 inPos;
layout(location = 0) out float vOut;
void main() {
double s = double(inPos.x) * uScale + 0.5lf;
vOut = float(s);
gl_Position = vec4(float(s));
}
)";
const Vector<Uint32> internalOnly = CompileToSpirv(GL_VERTEX_SHADER, vertexWithoutDoubleInput);
ASSERT_FALSE(internalOnly.empty());
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresFloat64(internalOnly));
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64VertexInput(internalOnly));
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64VertexInput({}));
}
// The payoff on the Espryt path: SPIRV-Cross throws "FP64 not supported in ES profile" for every
// one of these before demotion, so the program simply could not be transpiled at all.
class DemoteFloat64EsslTest : public DemoteFloat64Test, public ::testing::WithParamInterface<const char*> {};
@@ -0,0 +1,303 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/FlattenAtomicCounterBlockTest.cpp
// Copyright (c) 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 <gtest/gtest.h>
#define SPV_ENABLE_UTILITY_CODE
#include "glslang/SPIRV/spirv.hpp11"
#undef SPV_ENABLE_UTILITY_CODE
#include "Includes.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
#include <cstring>
#include <map>
#include <string>
#include <utility>
#include <vector>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
namespace {
constexpr SizeT kSpirvHeaderWordCount = 5u;
template <typename Visitor>
void ForEachInstruction(const Vector<Uint32>& spirv, Visitor&& visit) {
for (SizeT offset = kSpirvHeaderWordCount; offset < spirv.size();) {
const Uint32 wordCount = spirv[offset] >> 16u;
if (wordCount == 0u || offset + wordCount > spirv.size()) break;
visit(static_cast<spv::Op>(spirv[offset] & 0xffffu), &spirv[offset], wordCount);
offset += wordCount;
}
}
Vector<Uint32> CompileCompute(const String& source) {
using namespace MobileGL::MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = GL_COMPUTE_SHADER, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log);
if (!shaderResult) return {};
ProgramAttrib programAttrib{.shaders = {shaderResult.value()}};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log);
if (!programResult) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_COMPUTE_SHADER}, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult || binaryResult->empty()) return {};
return binaryResult->front();
}
bool Validates(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
tools.SetMessageConsumer(
[](spv_message_level_t, const char*, const spv_position_t& position, const char* message) {
ADD_FAILURE() << "spirv-val at word " << position.index << ": " << message;
});
return tools.Validate(spirv);
}
// Test-side reference walker, deliberately independent of the production code.
Uint32 FindAtomicCounterBlockStructId(const Vector<Uint32>& spirv) {
const String prefix = MG_Util::ShaderTranspiler::ATOMIC_COUNTER_BLOCK_PREFIX;
Uint32 structId = 0;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpName || wordCount < 3u || structId != 0u) return;
const char* text = reinterpret_cast<const char*>(&words[2]);
const SizeT available = static_cast<SizeT>(wordCount - 2u) * sizeof(Uint32);
if (available < prefix.size()) return;
if (std::strncmp(text, prefix.c_str(), prefix.size()) != 0) return;
structId = words[1];
});
return structId;
}
// The Offset of member `member` on struct `structId`, or -1.
Int64 MemberOffsetOf(const Vector<Uint32>& spirv, Uint32 structId, Uint32 member) {
Int64 offset = -1;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpMemberDecorate || wordCount < 5u) return;
if (words[1] != structId || words[2] != member) return;
if (static_cast<spv::Decoration>(words[3]) != spv::Decoration::Offset) return;
offset = words[4];
});
return offset;
}
Uint32 MemberCountOf(const Vector<Uint32>& spirv, Uint32 structId) {
Uint32 count = 0;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpTypeStruct || wordCount < 2u || words[1] != structId) return;
count = wordCount - 2u;
});
return count;
}
Uint32 MemberTypeOf(const Vector<Uint32>& spirv, Uint32 structId, Uint32 member) {
Uint32 typeId = 0;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpTypeStruct || wordCount < 3u + member || words[1] != structId) return;
typeId = words[2 + member];
});
return typeId;
}
// The declared length of an OpTypeArray, resolved through the uint constants in the module.
Int64 ArrayLengthOf(const Vector<Uint32>& spirv, Uint32 arrayTypeId) {
std::map<Uint32, Uint32> constants;
Int64 length = -1;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode == spv::Op::OpConstant && wordCount >= 4u) constants[words[2]] = words[3];
if (opcode == spv::Op::OpTypeArray && wordCount >= 4u && words[1] == arrayTypeId) {
const auto it = constants.find(words[3]);
if (it != constants.end()) length = it->second;
}
});
return length;
}
// KHR-GL43.compute_shader.resources-atomic-counter's non-zero-offset shape: two counters
// declared eight bytes into the buffer, which glslang lowers to one block member at Offset 8.
constexpr const char* kOffsetCounters = R"(#version 450 core
layout(local_size_x = 1) in;
layout(binding = 1, offset = 8) uniform atomic_uint g_counter[2];
layout(std430, binding = 0) buffer Output { uint value[]; } g_out;
void main() {
g_out.value[0] = atomicCounterIncrement(g_counter[0]);
g_out.value[1] = atomicCounterIncrement(g_counter[1]);
}
)";
// The latch: offset 0 is what nearly every shader declares, and it transpiles today.
constexpr const char* kNaturalCounters = R"(#version 450 core
layout(local_size_x = 1) in;
layout(binding = 1, offset = 0) uniform atomic_uint g_counter[2];
layout(std430, binding = 0) buffer Output { uint value[]; } g_out;
void main() {
g_out.value[0] = atomicCounterIncrement(g_counter[0]);
g_out.value[1] = atomicCounterIncrement(g_counter[1]);
}
)";
constexpr const char* kNoCounters = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Output { uint value[]; } g_out;
void main() {
g_out.value[0] = 1u;
}
)";
// glslang emits constants in FIRST-USE order, so a shader that does not use the flattened
// array's length until after it has declared the counter block leaves that constant BELOW the
// block. The pass needs the length to build `uint[length]` immediately before the block (SPIR-V
// forbids forward type references), and it used to decline the whole block in that case - which
// left the offsets in place and made SPIRV-Cross refuse the stage outright:
//
// Push constant block cannot be expressed as neither std430 nor std140.
//
// That is KHR-GL43.compute_shader.pipeline-compute-chain: its first kernel declares two counters
// at offset 8 (so the flattened array is 4 elements) and first uses the value 4 after the block,
// so the kernel never reached the driver and every buffer, image and counter it writes kept its
// initial value. Here `i < 4u` is what puts `uint 4` below the block; the ordering assertion
// below is the fixture's own latch, so a future glslang that emits constants differently reports
// a stale fixture rather than silently testing nothing.
constexpr const char* kLateLengthConstantCounters = R"(#version 430 core
layout(local_size_x = 1) in;
layout(binding = 1, offset = 8) uniform atomic_uint g_counter[2];
layout(std430, binding = 0) buffer Output { uint value[]; } g_out;
void main() {
uint i = atomicCounterIncrement(g_counter[1]);
if (i < 4u) { g_out.value[0] = i; }
}
)";
// Index of the first OpConstant of type uint with value |value|, and of struct |structId|, in
// the module's instruction order. -1 when absent.
std::pair<Int64, Int64> UintConstantAndStructOrder(const Vector<Uint32>& spirv, Uint32 structId,
Uint32 value) {
Int64 index = 0, constantIndex = -1, structIndex = -1;
Uint32 uintTypeId = 0;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode == spv::Op::OpTypeInt && wordCount >= 4u && words[2] == 32u && words[3] == 0u) {
uintTypeId = words[1];
}
if (opcode == spv::Op::OpConstant && wordCount >= 4u && words[1] == uintTypeId &&
words[3] == value && constantIndex < 0) {
constantIndex = index;
}
if (opcode == spv::Op::OpTypeStruct && wordCount >= 2u && words[1] == structId) {
structIndex = index;
}
++index;
});
return {constantIndex, structIndex};
}
} // namespace
TEST(FlattenAtomicCounterBlockPass, MovesTheBlockToOffsetZeroAndGrowsTheArray) {
const Vector<Uint32> input = CompileCompute(kOffsetCounters);
ASSERT_FALSE(input.empty());
const Uint32 structId = FindAtomicCounterBlockStructId(input);
ASSERT_NE(structId, 0u) << "glslang did not lower the counters onto a gl_AtomicCounterBlock_*";
ASSERT_EQ(MemberOffsetOf(input, structId, 0u), 8) << "the input's member 0 is not at the declared offset";
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::FlattenAtomicCounterBlockOffsetsForEssl(input, output, true));
ASSERT_FALSE(output.empty());
const Uint32 outStructId = FindAtomicCounterBlockStructId(output);
ASSERT_EQ(outStructId, structId) << "the block's id must not move; SetAtomicCounterBlockBindings "
"still finds it by name";
EXPECT_EQ(MemberCountOf(output, outStructId), 1u);
EXPECT_EQ(MemberOffsetOf(output, outStructId, 0u), 0)
<< "member 0 must sit at offset 0 or no std140/std430 layout can express the block";
// Two counters eight bytes in: the flattened array has to cover bytes [0, 16), i.e. 4 uints,
// so counter k lands on element 2 + k and therefore on byte 8 + 4k - where it was declared.
EXPECT_EQ(ArrayLengthOf(output, MemberTypeOf(output, outStructId, 0u)), 4);
EXPECT_TRUE(Validates(output));
}
TEST(FlattenAtomicCounterBlockPass, LeavesANaturallyPackedBlockByteIdentical) {
const Vector<Uint32> input = CompileCompute(kNaturalCounters);
ASSERT_FALSE(input.empty());
ASSERT_NE(FindAtomicCounterBlockStructId(input), 0u);
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::FlattenAtomicCounterBlockOffsetsForEssl(input, output, true));
EXPECT_EQ(output, input);
}
TEST(FlattenAtomicCounterBlockPass, LeavesAShaderWithoutCountersByteIdentical) {
const Vector<Uint32> input = CompileCompute(kNoCounters);
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::FlattenAtomicCounterBlockOffsetsForEssl(input, output, true));
EXPECT_EQ(output, input);
}
TEST(FlattenAtomicCounterBlockPass, IsIdempotent) {
const Vector<Uint32> input = CompileCompute(kOffsetCounters);
ASSERT_FALSE(input.empty());
Vector<Uint32> once;
ASSERT_TRUE(ShaderCompiler::FlattenAtomicCounterBlockOffsetsForEssl(input, once, true));
ASSERT_FALSE(once.empty());
Vector<Uint32> twice;
ASSERT_TRUE(ShaderCompiler::FlattenAtomicCounterBlockOffsetsForEssl(once, twice, true));
EXPECT_EQ(twice, once);
}
// The block must still flatten when the module already declares the flattened array's length
// constant BELOW the block. The pass relocates that constant instead of declining; declining
// left the offsets in place and cost the whole stage its transpile.
TEST(FlattenAtomicCounterBlockPass, FlattensWhenTheLengthConstantIsDeclaredAfterTheBlock) {
const Vector<Uint32> input = CompileCompute(kLateLengthConstantCounters);
ASSERT_FALSE(input.empty());
const Uint32 structId = FindAtomicCounterBlockStructId(input);
ASSERT_NE(structId, 0u);
ASSERT_EQ(MemberOffsetOf(input, structId, 0u), 8);
// The fixture's precondition, asserted rather than assumed: two counters at offset 8 need a
// 4-element array, and this shader's `uint 4` really does sit below the block.
const auto [constantIndex, structIndex] = UintConstantAndStructOrder(input, structId, 4u);
ASSERT_GE(constantIndex, 0) << "fixture is stale: the module no longer declares a uint 4";
ASSERT_GE(structIndex, 0);
ASSERT_GT(constantIndex, structIndex)
<< "fixture is stale: `uint 4` is no longer declared after the counter block, so this "
"test would pass without exercising the relocation at all";
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::FlattenAtomicCounterBlockOffsetsForEssl(input, output, true));
ASSERT_FALSE(output.empty());
ASSERT_NE(output, input) << "the block was declined; the offsets are still in the module and "
"SPIRV-Cross will refuse the stage";
const Uint32 outStructId = FindAtomicCounterBlockStructId(output);
ASSERT_EQ(outStructId, structId);
EXPECT_EQ(MemberCountOf(output, outStructId), 1u);
EXPECT_EQ(MemberOffsetOf(output, outStructId, 0u), 0);
EXPECT_EQ(ArrayLengthOf(output, MemberTypeOf(output, outStructId, 0u)), 4);
// The relocation moved a definition; the module has to still be well-ordered.
EXPECT_TRUE(Validates(output));
// The symptom the CTS case actually failed on: with the block declined this throws.
MG_Util::ShaderTranspiler::SpvcSession session(
output, MG_Util::ShaderTranspiler::SessionUsageBit::Transpile);
auto essl = ShaderCompiler::DecompileShader(session);
EXPECT_TRUE(essl) << "ESSL transpile failed: " << (essl ? String{} : essl.error().log);
}
@@ -0,0 +1,347 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/FlattenFloat64StorageBlockTest.cpp
// Copyright (c) 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
//
// FlattenFloat64StorageBlockPass, over the module the production chain actually hands it:
// ShaderCompiler::SanitizeAndOptimizeBinary, where the pass sits immediately before the fp64
// demotion. The behavioural half - that a block copied through the flattened words comes back
// byte for byte - is DoublePrecisionScenario's; what only a module walk can say is WHICH blocks
// were flattened, how wide, and that the ones this pass must not touch came through unchanged.
#include <gtest/gtest.h>
#include <cstring>
#include <map>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <spirv-tools/libspirv.hpp>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
namespace {
// A test-side reference walker, deliberately independent of the production code: a bug in
// the pass must not be able to hide behind the same helper.
constexpr Uint32 kSpirvHeaderWordCount = 5;
constexpr Uint32 kOpName = 5;
constexpr Uint32 kOpDecorate = 71;
constexpr Uint32 kOpMemberDecorate = 72;
constexpr Uint32 kOpTypeInt = 21;
constexpr Uint32 kOpTypeFloat = 22;
constexpr Uint32 kOpTypeArray = 28;
constexpr Uint32 kOpTypeStruct = 30;
constexpr Uint32 kOpConstant = 43;
constexpr Uint32 kDecorationArrayStride = 6;
constexpr Uint32 kDecorationOffset = 35;
template <typename Visitor>
void ForEachInstruction(const Vector<Uint32>& spirv, Visitor&& visit) {
for (SizeT i = kSpirvHeaderWordCount; i < spirv.size();) {
const Uint32 wordCount = spirv[i] >> 16;
const Uint32 opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
visit(opcode, &spirv[i], wordCount);
i += wordCount;
}
}
Uint32 StructIdNamed(const Vector<Uint32>& spirv, const String& name) {
Uint32 structId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpName || wordCount < 3 || structId != 0) return;
const char* text = reinterpret_cast<const char*>(&words[2]);
const SizeT available = static_cast<SizeT>(wordCount - 2) * sizeof(Uint32);
// The whole name, not a prefix of it: "Wide" must not match "WideOther".
if (available <= name.size() || text[name.size()] != 0) return;
if (std::strncmp(text, name.c_str(), name.size()) == 0) structId = words[1];
});
return structId;
}
// The operands of OpTypeStruct <structId>, i.e. one type id per member.
Vector<Uint32> MemberTypesOf(const Vector<Uint32>& spirv, Uint32 structId) {
Vector<Uint32> members;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpTypeStruct || wordCount < 2 || words[1] != structId) return;
for (Uint32 i = 2; i < wordCount; ++i) members.push_back(words[i]);
});
return members;
}
Vector<Uint32> MemberOffsetsOf(const Vector<Uint32>& spirv, Uint32 structId) {
std::map<Uint32, Uint32> byMember;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpMemberDecorate || wordCount < 5 || words[1] != structId) return;
if (words[3] != kDecorationOffset) return;
byMember[words[2]] = words[4];
});
Vector<Uint32> offsets;
for (const auto& [member, offset] : byMember) offsets.push_back(offset);
return offsets;
}
Uint32 DecorationValueOf(const Vector<Uint32>& spirv, Uint32 id, Uint32 decoration) {
Uint32 value = 0xFFFFFFFFu;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpDecorate || wordCount < 4 || words[1] != id || words[2] != decoration) return;
value = words[3];
});
return value;
}
// (element type id, declared length) of OpTypeArray <arrayId>, or (0, 0).
std::pair<Uint32, Uint32> ArrayShapeOf(const Vector<Uint32>& spirv, Uint32 arrayId) {
Uint32 elementTypeId = 0;
Uint32 lengthConstantId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpTypeArray || wordCount < 4 || words[1] != arrayId) return;
elementTypeId = words[2];
lengthConstantId = words[3];
});
if (elementTypeId == 0) return {0, 0};
Uint32 length = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpConstant || wordCount < 4 || words[2] != lengthConstantId) return;
length = words[3];
});
return {elementTypeId, length};
}
Bool IsUint32Type(const Vector<Uint32>& spirv, Uint32 typeId) {
Bool isUint = false;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpTypeInt || wordCount < 4 || words[1] != typeId) return;
isUint = words[2] == 32u && words[3] == 0u;
});
return isUint;
}
Uint32 CountFloatTypesOfWidth(const Vector<Uint32>& spirv, Uint32 width) {
Uint32 count = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode == kOpTypeFloat && wordCount >= 3 && words[2] == width) ++count;
});
return count;
}
String Disassemble(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String text;
tools.Disassemble(spirv, &text);
return text;
}
Vector<Uint32> CompileToSpirv(GLenum stage, const String& source) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log);
if (!shaderResult) return {};
ProgramAttrib programAttrib{.shaders = {shaderResult.value()}};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log);
if (!programResult) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {stage}, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult || binaryResult->empty()) return {};
return binaryResult->front();
}
// The whole shared chain, exactly as the frontend runs it at link.
Vector<Uint32> Sanitize(const Vector<Uint32>& input) {
Vector<Uint32> output;
EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output, true, true));
return output;
}
// The block std140 lays out as data0@0, data1[3]@16 stride 16, data2@64 column stride 16,
// data3@112, data4[2]@128 stride 16, data5@160, data6@192 - 216 bytes, i.e. 54 words.
constexpr const char* kStd140BlockSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std140, binding = 0) buffer Wide {
int data0;
float data1[3];
mat3x2 data2;
double data3;
double data4[2];
int data5;
dvec3 data6;
} g_wide;
void main() {
g_wide.data0 = 1;
for (int i = 0; i < 3; ++i) g_wide.data1[i] = float(i);
g_wide.data2 = mat3x2(1.0);
g_wide.data3 = 2.0lf;
for (int i = 0; i < 2; ++i) g_wide.data4[i] = double(i);
g_wide.data5 = 3;
g_wide.data6 = dvec3(4.0lf);
}
)";
} // namespace
class FlattenFloat64StorageBlockTest : public ::testing::Test {
protected:
void SetUp() override {
MobileGL::Initialize();
m_validationFailuresAtStart = ShaderCompiler::SpirvValidationFailureCount();
}
void TearDown() override {
// The wrapper validates its output on every run, so this covers every rewrite the test
// performed without any of them having to say so.
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), m_validationFailuresAtStart)
<< "the flattened module did not survive spirv-val";
}
Uint64 m_validationFailuresAtStart = 0;
};
TEST_F(FlattenFloat64StorageBlockTest, AStorageBlockWithDoublesBecomesOneWordArray) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, kStd140BlockSource);
ASSERT_FALSE(input.empty());
// Before: seven members, at the std140 offsets the standard requires WITH the doubles.
const Uint32 inputStructId = StructIdNamed(input, "Wide");
ASSERT_NE(inputStructId, 0u) << Disassemble(input);
EXPECT_EQ(MemberOffsetsOf(input, inputStructId),
(Vector<Uint32>{0, 16, 64, 112, 128, 160, 192}))
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const Uint32 structId = StructIdNamed(output, "Wide");
ASSERT_NE(structId, 0u) << Disassemble(output);
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << "the block should have collapsed to one member\n"
<< Disassemble(output);
EXPECT_EQ(MemberOffsetsOf(output, structId), (Vector<Uint32>{0}));
const auto [elementTypeId, length] = ArrayShapeOf(output, members[0]);
ASSERT_NE(elementTypeId, 0u) << "member 0 is not an array\n" << Disassemble(output);
EXPECT_TRUE(IsUint32Type(output, elementTypeId)) << Disassemble(output);
// 216 bytes is where the standard puts the end of this block; 216 / 4 = 54 words.
EXPECT_EQ(length, 54u) << Disassemble(output);
EXPECT_EQ(DecorationValueOf(output, members[0], kDecorationArrayStride), 4u);
// And the demotion that runs straight afterwards still has nothing 64-bit left to find.
EXPECT_EQ(CountFloatTypesOfWidth(output, 64), 0u) << Disassemble(output);
}
// The gate, from the other side: a storage block with no 64-bit member keeps every member and
// every offset it was compiled with. This is what makes the pass free for every shader that does
// not use doubles - which is all of them but a handful.
TEST_F(FlattenFloat64StorageBlockTest, AStorageBlockWithoutDoublesIsLeftAlone) {
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std140, binding = 0) buffer Plain {
int data0;
float data1[3];
mat3x2 data2;
int data3;
} g_plain;
void main() {
g_plain.data0 = 1;
for (int i = 0; i < 3; ++i) g_plain.data1[i] = float(i);
g_plain.data2 = mat3x2(1.0);
g_plain.data3 = 2;
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const Uint32 structId = StructIdNamed(output, "Plain");
ASSERT_NE(structId, 0u) << Disassemble(output);
EXPECT_EQ(MemberTypesOf(output, structId).size(), 4u) << Disassemble(output);
EXPECT_EQ(MemberOffsetsOf(output, structId), (Vector<Uint32>{0, 16, 64, 112}))
<< Disassemble(output);
}
// A plain UNIFORM block is deliberately NOT flattened, however many doubles it holds: the
// frontend's glUniform*d routing is built by reflecting the DEMOTED module
// (ProgramSpirvTask::BuildGlobalUboRouting), so a representation change there would have to move
// with it. It keeps its members and takes the demotion's repacking, exactly as before.
TEST_F(FlattenFloat64StorageBlockTest, AUniformBlockWithDoublesIsLeftToTheDemotion) {
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std140, binding = 0) uniform Params {
int data0;
double data1;
int data2;
} g_params;
layout(std430, binding = 0) buffer Sink {
float g_out[];
};
void main() {
g_out[0] = float(g_params.data0) + float(g_params.data1) + float(g_params.data2);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const Uint32 structId = StructIdNamed(output, "Params");
ASSERT_NE(structId, 0u) << Disassemble(output);
EXPECT_EQ(MemberTypesOf(output, structId).size(), 3u)
<< "a uniform block must not be flattened\n"
<< Disassemble(output);
// The demotion's re-derived std140 layout for `int, float, int`, which is what the frontend
// reflects and what glUniform*d then writes into.
EXPECT_EQ(MemberOffsetsOf(output, structId), (Vector<Uint32>{0, 4, 8})) << Disassemble(output);
}
// ---------------------------------------------------------------------------
// The capability-gated half: a backend that consumes 64-bit floats natively gets neither pass.
// ---------------------------------------------------------------------------
// The flatten exists to preserve a byte layout ACROSS a narrowing. Where nothing narrows there is
// nothing to preserve and the driver lays the block out itself - so the block keeps its seven
// members at the offsets glslang computed, and the doubles in it are still doubles.
TEST_F(FlattenFloat64StorageBlockTest, TheNativePathLeavesTheBlockAndItsDoublesAlone) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, kStd140BlockSource);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Wide");
ASSERT_NE(inputStructId, 0u);
const Vector<Uint32> inputOffsets = MemberOffsetsOf(input, inputStructId);
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output, true, true, true));
ASSERT_FALSE(output.empty());
const Uint32 structId = StructIdNamed(output, "Wide");
ASSERT_NE(structId, 0u) << Disassemble(output);
EXPECT_EQ(MemberTypesOf(output, structId).size(), 7u)
<< "the block must not be flattened when nothing is narrowing it\n"
<< Disassemble(output);
EXPECT_EQ(MemberOffsetsOf(output, structId), inputOffsets) << Disassemble(output);
EXPECT_GT(CountFloatTypesOfWidth(output, 64), 0u) << Disassemble(output);
}
// And the control: the SAME module through the SAME entry point with the bit clear is flattened
// exactly as it always was. This is the pair that pins "capability-false is byte-for-byte the old
// behaviour" at the level the device A/B checks.
TEST_F(FlattenFloat64StorageBlockTest, TheDemotedPathIsUnchangedByTheCapabilityArgument) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, kStd140BlockSource);
ASSERT_FALSE(input.empty());
Vector<Uint32> explicitlyDemoted;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, explicitlyDemoted, true, true, false));
// The four-argument spelling every existing caller uses, which must keep meaning "demote".
const Vector<Uint32> defaulted = Sanitize(input);
EXPECT_EQ(explicitlyDemoted, defaulted);
EXPECT_EQ(CountFloatTypesOfWidth(defaulted, 64), 0u) << Disassemble(defaulted);
}

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