Compare commits

...
Author SHA1 Message Date
swung0x48 d4f8adcf6d [Tools, Test] (tools/cts, MG_Test): make the CTS runner's chunk timeout idle-based so a healthy 20-minute invocation is no longer killed and its in-flight case mis-recorded as a crash, and link MSVC test executables with /WHOLEARCHIVE so the dllimport gl* references in GetProcAddress.cpp resolve 2026-09-05 05:36:50 -04:00
swung0x48 795e08f7e6 [Fix, Test] (ShaderTranspiler): flatten fp64 storage blocks whose last member is a runtime array - the pass declined them, so the fp64 demotion re-derived ArrayStride 4 over the application's 8/16/32-byte double buffer and every double/dvecN data[] SSBO read raw words 2026-09-05 05:36:49 -04:00
swung0x48 1e7ecab4db [Fix, Test] (MG_State, BufferObject): land a non-persistent write map's staged bytes into a GPU-resident store at unmap and explicit flush instead of dropping them - SSBO binding and large-store adoption make resident stores reachable through glMapBufferRange, so every per-draw re-initialisation was silently lost 2026-09-05 05:36:48 -04:00
swung0x48 81b17c0b75 [Test] (ShaderTranspiler, Integration): pin the five reworked shapes - the read-only capture stage through the real link, the seeded carrier's ESSL declaration, the metadata-driven block redeclaration and its decline, the control-stage-less evaluation decline, and a carrier clearing an i64vec4 2026-08-28 06:22:12 -04:00
swung0x48 d1edf765f5 [Fix] (Link, Async): carry the resolved gl_PointSize capture request into the SPIR-V handoff and let a deferred verdict name its own severity - the demotion read the request off a reflection slice phase A never fills it into, so its forced carrier was dead code, and its decline reason replayed at a level no shipped build keeps 2026-08-28 06:22:12 -04:00
swung0x48 97e07190ac [Fix] (ShaderTranspiler): seed a forced point-size carrier nothing writes and decline a control stage whose live clip/cull distance would still print gl_PointSize - an ES front end deletes a never-written output, and SPIRV-Cross redeclares that block from member decorations rather than access 2026-08-28 06:22:11 -04:00
swung0x48 a4dcdf989e [Fix] (ShaderTranspiler): stop the point-size carrier landing on a location a live varying owns, and decline the evaluation stage a synthesized pass-through control stage cannot feed - an i64vec4 counted as one location, and a located input carrier trips both backends' pass-through guard 2026-08-28 06:22:11 -04:00
swung0x48 1c113e4b26 [Fix] (ShaderTranspiler): count a 64-bit integer vector as two locations in the XFB flattener's LocationSpan - it read 64-bitness off the element's float type alone, so an i64vec3/4 member packed the members after it onto locations it already owns 2026-08-28 06:22:11 -04:00
swung0x48 bf9cfb3079 [Test] (Integration): PointSizeDemotionScenario - the demoted value chain is client-invisible in both configurations, with pinned per-backend lanes and a log-latch arming guard 2026-08-28 06:22:10 -04:00
swung0x48 e1818d497a [Test] (ShaderTranspiler): pin the point-size demotion's shapes - capability stripped, carriers named and located past the program's varyings, byte-identical no-ops, whole-struct-copy decline, and the two new L1 key bits 2026-08-28 06:21:31 -04:00
swung0x48 d7f66722d1 [Fix] (ShaderTranspiler, Link, DirectGLES, DirectVulkan): demote tessellation/geometry gl_PointSize to an ordinary varying where the device cannot host the built-in - the value survives for gl_in reads and by-name capture, both backends' declines stay for shapes the pass refuses, and the verdict rides the L1 key 2026-08-28 06:21:30 -04:00
swung0x48 92dc41ebf9 [Test] (DirectVulkan, SelfTest): pin the probe's fence-timeout teardown against a fake driver, the never-worse arming refusal, and paused-span patch/instanced counting against an unpaused control 2026-08-28 06:19:41 -04:00
swung0x48 feea131d8b [Fix] (DirectVulkan, SelfTest): honour the primitives-generated probe's leak-on-timeout contract in both of its callers, count paused-span draws the frontend cannot price, refuse a substitute measured worse than the stream query, and derive the POST row's failure clause from the measurement 2026-08-28 06:19:40 -04:00
swung0x48 19f4402fbf [Test] (DirectVulkan): pin the primitives-generated probe's verdict and override mapping, and hold the reroute's GL answers and arming observable on the integration lane 2026-08-28 06:19:40 -04:00
swung0x48 1350031368 [Fix] (DirectVulkan): count GL_PRIMITIVES_GENERATED for transform-feedback-inactive draws - a bring-up probe measures the silent stream query and reroutes such draws through the dedicated primitives-generated query or a clipping-statistics pool, reported from the Vulkan POST 2026-08-28 06:19:39 -04:00
swung0x48 0ee3384b22 [Fix] (Espryt): land a SubData into an adopted store as a GPU-ordered copy - the in-place coherent write tore the frames still reading the old section bytes 2026-08-28 04:50:07 -04:00
swung0x48 ba3f8d6774 [Test] (Integration): pin the adopted mesh-arena store - cross-frame SubData visibility, readback identity, and the GPU-written readback 2026-08-28 04:19:11 -04:00
swung0x48 3327784fd0 [Fix] (Espryt): adopt mesh-arena-sized stores into coherent persistent maps at definition, and stop the flush tiers from re-synchronizing them 2026-08-28 04:19:11 -04:00
swung0x48 ff426da3a9 [Fix, Test] (MG_State, DirectVulkan): order host writes to an adopted store after recorded GPU work - a SubData issued after a dispatch landed in coherent memory before the deferred dispatch executed, so its increments overwrote the newer bytes; un-skip the DirectVulkan half of the SubData-after-dispatch scenario 2026-08-28 03:01:15 -04:00
swung0x48 08419a1fe6 [Chore] (Config): point the two stale comments at the renamed feature fields 2026-08-28 00:47:35 -04:00
swung0x48 5d51372c44 [Chore] (Config): triage backend-scoped env toggles under MOBILEGL_ESPRYT_ and MOBILEGL_MAGMA_ prefixes 2026-08-28 00:38:17 -04:00
swung0x48 7fd4550968 [Fix] (Espryt): copy before any FBO attach in the packed16 probe - attaching the array relayouts it to the plain order and was neutralizing the subject 2026-08-28 00:22:16 -04:00
swung0x48 971537058e [Fix] (Espryt): probe every allocation recipe of the packed16 shape - the Mali layout heuristic inverts between contexts, so one recipe cannot speak for the storage 2026-08-28 00:22:16 -04:00
swung0x48 dd98c450ad [Test] (SelfTest): model the params-before-upload escape so a params-first probe regression reads as a red test 2026-08-28 00:22:15 -04:00
swung0x48 5dbbbbd7eb [Fix] (Espryt): allocate the packed16 probe's textures uploads-first, the order a minted backend texture performs and the one the Mali layout choice keys on 2026-08-28 00:22:15 -04:00
swung0x48 5d140a41ce [Fix] (Espryt): re-arm the packed16 probe on the allocation-scoped mirror the device actually has and let any mirrored level trigger the widening 2026-08-28 00:22:15 -04:00
swung0x48 bf376b230f [CI] (Integration): rerun the buffer scenarios with the map flush disabled so the upload-ring tier keeps coverage 2026-08-28 00:15:15 -04:00
swung0x48 29599dcf90 [Fix] (DirectGLES): flush queued buffer ranges through a range-invalidating map first, so a partial write into a busy store is priced by the range 2026-08-27 23:54:19 -04:00
swung0x48 734fab9f90 [Test] (Integration): scope the SubData-after-dispatch readback case to DirectGLES, naming the DirectVulkan upload-ordering gap it exposed 2026-08-27 23:04:52 -04:00
swung0x48 2cd1809c29 [Fix] (Review): rebuild the packed16 probe on the CTS's three-level chains and let the POST row answer for the widening knob actually in force 2026-08-27 22:46:13 -04:00
swung0x48 faed498476 [Test] (IntegrationTest): shield the packed16 renderbuffer clear from an inherited scissor 2026-08-27 22:46:13 -04:00
swung0x48 8282eecbfa [Fix] (Espryt): store RGB565/RGB5_A1/RGBA4 as 8-bit channels where a POST probe measures the Mali packed16 array-mip field-order mirror 2026-08-27 22:46:12 -04:00
swung0x48 f4f3afb0b6 [Test] (Integration): pin that a queued SubData survives an immediate readback of a GPU-written buffer 2026-08-27 22:19:45 -04:00
swung0x48 a28da07641 [Fix] (DirectGLES): queue app buffer updates and flush them through a staged-copy upload ring instead of stalling in glBufferSubData 2026-08-27 22:19:44 -04:00
swung0x48 200c21336f [Fix] (Readback): size a cube-face pack buffer by one face, and give a cube view's face its owner layer 2026-08-27 22:05:23 -04:00
swung0x48 2c3fc583d5 [Test] (Readback): pin the one-face pixel pack buffer and a cube view's per-face layers 2026-08-27 21:58:51 -04:00
swung0x48 645a12d8bc [Fix] (Readback): read the cube face a readback names, and let glGetTextureSubImage name one 2026-08-27 21:54:47 -04:00
swung0x48 8e6acc5528 [Test] (Readback): pin that a cube map's readback answers the face it was asked for 2026-08-27 21:54:46 -04:00
swung0x48 525ffe0f14 [Test] (Review): pin the inverted override mapping, the probe's controls, and that the pinned-on lane is really armed 2026-08-27 21:06:01 -04:00
swung0x48 ad28d2b744 [Fix, Test] (Review): strip a block's member-level locations too, restore the probe's colour mask, and let the POST verdict follow the override 2026-08-27 20:15:31 -04:00
swung0x48 eab622388f [Test] (TranslationCache): pin the two interface-block location-strip flags as L2 key material 2026-08-27 20:07:49 -04:00
swung0x48 75e573c923 [Fix] (SelfTest): give the located-interface-block probe its own entry-point gate instead of the storage probe's 2026-08-27 19:56:26 -04:00
swung0x48 0d0ef13619 [Fix] (DirectGLES): run the interface-block location strip last, where its deliberately Vulkan-invalid module reaches no validator 2026-08-27 19:44:18 -04:00
swung0x48 23565fcacd [Fix, Test] (DirectGLES, SelfTest, MG_Test): probe the located-interface-block defect with its controls and cover the strip in both gates 2026-08-27 19:38:39 -04:00
swung0x48 5dc26e3e2c [Fix] (DirectGLES, ShaderTranspiler): drop the location qualifier from inter-stage interface blocks on a driver that loses their payload 2026-08-27 19:18:35 -04:00
96 changed files with 12472 additions and 360 deletions
+3 -3
View File
@@ -44,12 +44,12 @@ require 'key:MOBILEGL_BACKEND_TYPE' "$plugin_resource_text" 'V2 backend variable
require 'defaultValue:DirectGLES' "$plugin_resource_text" 'V2 DirectGLES default'
require 'DirectVulkan' "$plugin_resource_text" 'V2 DirectVulkan option'
require 'key:MOBILEGL_DISABLE_TIMERQUERY' "$plugin_resource_text" 'V2 timer-query toggle'
require 'key:MOBILEGL_DISABLE_SUBGROUP' "$plugin_resource_text" 'V2 Vulkan subgroup toggle'
require 'key:MOBILEGL_MAGMA_DISABLE_SUBGROUP' "$plugin_resource_text" 'V2 Vulkan subgroup toggle'
require 'key:MOBILEGL_MAGMA_R11G11B10F_FALLBACK' "$plugin_resource_text" 'V2 Magma format fallback toggle'
require 'key:MOBILEGL_MAGMA_FRAMESINFLIGHT' "$plugin_resource_text" 'V2 Magma frames-in-flight setting'
require 'key:MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER' "$plugin_resource_text" 'V2 sampler workaround toggle'
require 'key:MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER' "$plugin_resource_text" 'V2 sampler workaround toggle'
require 'key:MOBILEGL_COHERENT_AS_FLUSH' "$plugin_resource_text" 'V2 coherent-as-flush toggle'
require 'key:MOBILEGL_USE_ANGLE' "$plugin_resource_text" 'V2 ANGLE toggle'
require 'key:MOBILEGL_ESPRYT_USE_ANGLE' "$plugin_resource_text" 'V2 ANGLE toggle'
if [[ $(grep -Fc 'fclPlugin_V2' <<<"$plugin_manifest") -ne 1 ]]; then
echo '::error::Plugin manifest must expose exactly one V2 descriptor' >&2
+4 -4
View File
@@ -417,12 +417,12 @@ jobs:
- name: Retrace and validate
env:
MOBILEGL_USE_ANGLE: ${{ matrix.backend.name == 'DirectGLES' && '1' || '0' }}
MOBILEGL_ESPRYT_USE_ANGLE: ${{ matrix.backend.name == 'DirectGLES' && '1' || '0' }}
MOBILEGL_TRACE_ANGLE_VARIANT: ${{ matrix.case.name == 'minecraft-1.21.4-fabric-iris-bliss-in-world' && '90a62123d794' || 'ec889e6ea831' }}
MOBILEGL_MAGMA_R11G11B10F_FALLBACK: ${{ matrix.backend.name == 'DirectVulkan' && '1' || '0' }}
MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH: ${{ matrix.backend.name == 'DirectVulkan' && matrix.case.name == 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' && '1' || '0' }}
MOBILEGL_DERIVE_NUM_SUBGROUPS: ${{ matrix.backend.name == 'DirectVulkan' && matrix.case.name == 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' && '1' || '0' }}
MOBILEGL_ITERATIONRP_FIX_BARRIER: ${{ matrix.backend.name == 'DirectVulkan' && matrix.case.name == 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' && '1' || '0' }}
MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: ${{ matrix.backend.name == 'DirectVulkan' && matrix.case.name == 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' && '1' || '0' }}
MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: ${{ matrix.backend.name == 'DirectVulkan' && matrix.case.name == 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' && '1' || '0' }}
MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: ${{ matrix.backend.name == 'DirectVulkan' && matrix.case.name == 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' && '1' || '0' }}
run: |
apk_file="android-retrace-apks/MobileGL-plugin-trace-release-${GITHUB_SHA}.apk"
test -f "${apk_file}"
+16 -6
View File
@@ -265,16 +265,26 @@ jobs:
# crash stack without burning a CI round on an in-workflow debugger.
env:
MOBILEGL_ITEST_REQUIRE_GPU: "1"
MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1"
MOBILEGL_DERIVE_NUM_SUBGROUPS: "1"
MOBILEGL_ITERATIONRP_FIX_BARRIER: "1"
MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1"
MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: "1"
MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: "1"
run: |
ulimit -c unlimited
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
# Second, filtered pass: with the range-invalidating map flush disabled,
# the buffer scenarios run on the upload ring's staged-copy tier - which
# the default pass never reaches (the map tier absorbs every flush on
# Mesa), so without this the Mali fallback tier would have zero CI
# coverage. The flag is NOT baked into the ctest ENVIRONMENT properties,
# so an inline env reaches the test processes (unlike the ICD pin above).
if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then
ctest -V -L integration-gpu --no-tests=error
MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1 ctest -V -L integration-gpu \
-R 'Buffer|Readback|Atomic|Ssbo|Arena' --no-tests=error
else
ctest --output-on-failure -L integration-gpu --no-tests=error
MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1 ctest --output-on-failure -L integration-gpu \
-R 'Buffer|Readback|Atomic|Ssbo|Arena' --no-tests=error
fi
- name: Upload core dumps
@@ -644,9 +654,9 @@ jobs:
fi
if [ '${{ matrix.backend }}' = 'DirectVulkan' ] \
&& [ '${{ matrix.case }}' = 'minecraft-1.21.4-fabric-iris-iterationrp-in-world' ]; then
export MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH=1
export MOBILEGL_DERIVE_NUM_SUBGROUPS=1
export MOBILEGL_ITERATIONRP_FIX_BARRIER=1
export MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH=1
export MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS=1
export MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER=1
fi
# The blended depth-write quirk auto-enables only on Qualcomm, which no CI
# runner has, so force it on for the OIT case it exists to fix. ForceOn
+3
View File
@@ -285,6 +285,7 @@ set(SOURCE_FILES
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/StripIoBlockLocationsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/SplitArrayVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp
@@ -306,6 +307,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenAtomicCounterBlockPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.cpp
MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp
MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp
@@ -313,6 +315,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp
MobileGL/MG_Util/SelfTest/DriverPost.cpp
MobileGL/MG_Util/SelfTest/DriverPostIterationRPWitness.cpp
MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.cpp
MobileGL/MG_Util/Texture/PixelStoreProcessor.cpp
MobileGL/MG_Util/Texture/TextureFormatProcessor.cpp
+104 -25
View File
@@ -69,34 +69,34 @@ namespace MobileGL::MG_Config {
struct FeaturesTable {
// MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries.
Bool DisableTimerQuery = false;
// MOBILEGL_ENABLE_GLES_TEXTURE_VIEW: advertise GL_ARB_texture_view on DirectGLES when
// MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW: advertise GL_ARB_texture_view on DirectGLES when
// the host ES driver has EXT/OES_texture_view. Off by default: the host extension is
// present on Adreno 830 and the functional half of KHR-GL4{2,3}.texture_view still fails
// there, because the view's ES internalformat is normalized independently of the storage
// it aliases (see BackendObject_DirectGLES::BuildAdvertisedExtensions). The flag exists
// so that work can be done without editing the gate.
Bool EnableGlesTextureView = false;
Bool EsprytEnableTextureView = false;
// MOBILEGL_ENABLE_SPIRV_VALIDATION: validate generated and transformed SPIR-V.
// Disabled by default because validation is a diagnostics-only cost.
Bool EnableSpirvValidation = false;
// MOBILEGL_USE_ANGLE: load ANGLE EGL/GLES libraries.
Bool UseAngle = false;
// MOBILEGL_ESPRYT_USE_ANGLE: load ANGLE EGL/GLES libraries.
Bool EsprytUseAngle = false;
#if defined(MOBILEGL_TRACE_ANGLE_VARIANTS)
// MOBILEGL_TRACE_ANGLE_VARIANT: signed trace-APK ANGLE build short hash.
String TraceAngleVariant;
#endif
// MOBILEGL_DISABLE_SUBGROUP: force-disable Vulkan shader subgroup support,
// MOBILEGL_MAGMA_DISABLE_SUBGROUP: force-disable Vulkan shader subgroup support,
// including the opt-in emulated compute path below.
Bool DisableSubgroup = false;
Bool MagmaDisableSubgroup = false;
// MOBILEGL_MAGMA_EMULATE_SUBGROUP: implement GL_KHR_shader_subgroup's compute
// stage on a 32-lane VIRTUAL subgroup lowered to workgroup-shared memory
// (ShaderTranspiler::EmulateSubgroupsPass). Strictly a last resort: it only ever
// engages when this flag is set AND the device has no native subgroup support at
// all - a device with real subgroup operations always uses them natively,
// whatever their width (the known iterationRP defect is patched by
// FixIterationRPSubgroupScratch below instead). Off by default.
// MagmaFixIterationRPSubgroupScratch below instead). Off by default.
Bool MagmaEmulateSubgroup = false;
// MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH: patch iterationRP's own bug - the
// MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: patch iterationRP's own bug - the
// pack declares `shared vec2 prefixSumCache[32]` for a 512-invocation exposure
// reduction and indexes it by gl_SubgroupID, so any device with sub-16-lane
// subgroups (8-lane lavapipe -> 64 subgroups) writes shared memory out of
@@ -106,12 +106,12 @@ namespace MobileGL::MG_Config {
// so every other shader passes through byte-identical - as does iterationRP
// itself on >= 16-lane devices. Auto is ON; ForceOff replays the pack's bug
// verbatim.
QuirkOverride FixIterationRPSubgroupScratch = QuirkOverride::Auto;
// MOBILEGL_ITERATIONRP_FIX_BARRIER: repair Program 203's missing workgroup
QuirkOverride MagmaFixIterationRPSubgroupScratch = QuirkOverride::Auto;
// MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: repair Program 203's missing workgroup
// rendezvous between its two reductions over prefixSumCache. Off by default and
// fingerprint-gated by FixIterationRPBarrierPass when enabled.
Bool IterationRPFixBarrier = false;
// MOBILEGL_DERIVE_NUM_SUBGROUPS: replace compute gl_NumSubgroups loads with
Bool MagmaIterationRPFixBarrier = false;
// MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: replace compute gl_NumSubgroups loads with
// ceil(workgroup invocations / gl_SubgroupSize) on the NATIVE subgroup path
// (ShaderTranspiler::DeriveNumSubgroupsPass). Auto is ON: GL requires
// gl_SubgroupID < gl_NumSubgroups, Adreno's builtin reports 1 while the same
@@ -119,7 +119,7 @@ namespace MobileGL::MG_Config {
// whenever the pipeline can request REQUIRE_FULL_SUBGROUPS (which the renderer
// does whenever local_size_x is a multiple of the native width). ForceOff returns
// to the raw driver builtin.
QuirkOverride DeriveNumSubgroups = QuirkOverride::Auto;
QuirkOverride MagmaDeriveNumSubgroups = QuirkOverride::Auto;
// MOBILEGL_ADVERTISE_FP64: add GL_ARB_gpu_shader_fp64 to the advertised extension
// string. `double` in a shader always WORKS - it is narrowed to 32 bits before any
// module reaches a backend (ShaderTranspiler::DemoteFloat64Pass) - but the extension
@@ -132,16 +132,39 @@ namespace MobileGL::MG_Config {
Bool MagmaR11G11B10FFallback = false;
// MOBILEGL_MAGMA_FRAMESINFLIGHT: requested Magma frames in flight, defaulting to 3.
Uint32 MagmaFramesInFlight = 3;
// MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER: avoid mipmap min filters in samplers,
// MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER: avoid mipmap min filters in samplers,
// resolves certain rendering bugs on ANGLE + llvmpipe.
Bool AvoidSamplerMipmapMinFilter = false;
// MOBILEGL_AVOID_EXPLICIT_LOD_BIAS: leave an already-explicit LOD argument alone when
Bool EsprytAvoidSamplerMipmapMinFilter = false;
// MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS: leave an already-explicit LOD argument alone when
// emulating GL_TEXTURE_LOD_BIAS, instead of adding the bias uniform to it. Injecting
// the uniform turns a compile-time-constant LOD into a runtime expression, which
// sends ANGLE + llvmpipe down a mip-selection path that dereferences a NULL
// descriptor and kills the process. Deviates from spec (Vulkan adds the bias to
// OpImageSampleExplicitLod), so it is an avoidance for that stack only.
Bool AvoidExplicitLodBias = false;
Bool EsprytAvoidExplicitLodBias = false;
// MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS: emit a tessellation/geometry program's
// inter-stage interface blocks WITHOUT their layout(location=) qualifier, letting ES
// match them by block name and member sequence instead. The Mali ES driver delivers
// nothing at all through a located block once a tessellation or geometry stage is in
// the pipeline; the driver POST measures that and turns this on by itself, so Auto is
// the right setting everywhere. ForceOn exists so the emulation can be exercised on a
// healthy driver - which is what the integration lane does, since llvmpipe and
// lavapipe carry a located block correctly and would otherwise never run this code -
// and ForceOff is the negative control. See StripIoBlockLocationsPass.
QuirkOverride EsprytUnlocatedIoBlocks = QuirkOverride::Auto;
// MOBILEGL_POINT_SIZE_DEMOTION: demote gl_PointSize out of tessellation/geometry
// stages into an ordinary varying (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) instead of declining such programs
// on a device that advertises neither EXT/OES_tessellation_point_size /
// geometry_point_size (DirectGLES) nor shaderTessellationAndGeometryPointSize
// (DirectVulkan). Auto arms it exactly where the detection says the capability is
// absent, which is the right setting everywhere. ForceOn exists so the demotion can
// be exercised on a healthy driver - llvmpipe and lavapipe host the built-in
// natively and would otherwise never run this code, which is what the pinned
// integration lane uses - and ForceOff restores the plain declines (escape hatch /
// negative control). Cross-backend by design: the demotion runs in the shared
// phase-B chain, so one switch covers both. See DemotePointSizePass.
QuirkOverride PointSizeDemotion = QuirkOverride::Auto;
// MOBILEGL_COHERENT_AS_FLUSH: app-compat for engines (e.g. Flywheel) that write
// GPU-read data through persistent GL_MAP_FLUSH_EXPLICIT_BIT maps they never
// flush. Persistent FLUSH_EXPLICIT map requests are rewritten to coherent
@@ -151,15 +174,38 @@ namespace MobileGL::MG_Config {
Bool CoherentAsFlush = false;
// MOBILEGL_TRACE_SKIP_AUTODESTROY: skip teardown in the ELF destructor (Init.cpp).
Bool TraceSkipAutodestroy = false;
// MOBILEGL_DISABLE_UBO_RING: force the DirectGLES global-UBO upload back to the
// MOBILEGL_ESPRYT_DISABLE_UBO_RING: force the DirectGLES global-UBO upload back to the
// per-draw glBufferSubData path instead of the persistent-mapped ring allocator
// (negative control / driver-bug escape hatch).
Bool DisableUboRing = false;
// MOBILEGL_DISABLE_UNPACK_RING: force DirectGLES texture uploads back to
Bool EsprytDisableUboRing = false;
// MOBILEGL_ESPRYT_DISABLE_UNPACK_RING: force DirectGLES texture uploads back to
// glTexSubImage from the client pointer instead of staging them through the
// persistent-mapped unpack-PBO ring (negative control / driver-bug escape
// hatch).
Bool DisableUnpackRing = false;
Bool EsprytDisableUnpackRing = false;
// MOBILEGL_ESPRYT_DISABLE_UPLOAD_RING: force DirectGLES app buffer updates
// (glBufferSubData / map flushes) back to the immediate driver upload instead
// of queueing them for the staged-copy flush through the persistent-mapped
// upload ring (negative control / driver-bug escape hatch; the immediate
// upload stalls on drivers that resolve the WAR hazard on the CPU, e.g. Mali).
Bool EsprytDisableUploadRing = false;
// MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH: skip the glMapBufferRange(WRITE |
// INVALIDATE_RANGE) tier of the DirectGLES pending-range flush and go straight
// to the upload ring's staged glCopyBufferSubData (negative control / escape
// hatch for a driver whose range-invalidating map misbehaves). The map tier is
// what keeps a partial write into a large in-flight buffer priced by the RANGE:
// on Mali both the immediate glBufferSubData and a staged copy into a busy
// mutable store ghost the whole destination on the CPU.
Bool EsprytDisableInvalidateFlush = false;
// MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION: keep mesh-arena-sized buffer stores
// (>= 16MiB) on the CPU-shadow model instead of backing them with the backend's
// persistently+coherently mapped storage at definition time (negative control /
// escape hatch). Frontend-scoped: it engages only where the active backend
// provides AcquirePersistentMap. With adoption on, an app SubData into a busy
// 128MB arena is a plain memcpy into GPU-visible memory; every driver-mediated
// route for the same write stalls the thread or ghost-copies the whole arena on
// this class of Mali driver, and the arena stops costing its size again in RAM.
Bool DisableLargeBufferAdoption = false;
// MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION: make DirectGLES skip the native ES
// depth/stencil reads and always go through the shader-sampling emulation. Core GL
// ES has no depth or stencil readback, but some drivers accept it anyway (Mesa does,
@@ -180,10 +226,10 @@ namespace MobileGL::MG_Config {
// gl_FragDepth writers, and fully color-masked attachments are exempt (see
// PipelineFactory::ShouldSuppressDepthWrite). Auto detects Qualcomm.
QuirkOverride MagmaDisableBlendedDepthWriteQuirk = QuirkOverride::Auto;
// MOBILEGL_DISABLE_ROBUST_BUFFER_ACCESS: leave the Vulkan robustBufferAccess device
// MOBILEGL_MAGMA_DISABLE_ROBUST_BUFFER_ACCESS: leave the Vulkan robustBufferAccess device
// feature off. It is enabled by default to match GL's defined out-of-range fetch
// behavior; this escape hatch exists to measure or dodge its GPU cost on a device.
Bool DisableRobustBufferAccess = false;
Bool MagmaDisableRobustBufferAccess = false;
// MOBILEGL_MAGMA_MULTIDRAW_MODE: preferred DirectVulkan multi-draw dispatch tier
// ("ext" | "indirect" | "unroll", see MultiDrawMode). Clamped to device support;
// unset picks the best supported tier.
@@ -224,7 +270,7 @@ namespace MobileGL::MG_Config {
// 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
// MOBILEGL_ESPRYT_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
@@ -236,7 +282,40 @@ namespace MobileGL::MG_Config {
// 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;
QuirkOverride EsprytViewportArrayEmulation = QuirkOverride::Auto;
// MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE: DirectGLES stores GL_RGB565/GL_RGB5(A1)/GL_RGBA4
// images as 8-bit-per-channel ES storage (GL_RGB8/GL_RGBA8) instead of the driver's
// native 16-bit packed formats. Auto defers to a POST driver-bug probe
// (SelfTest::CopyImageMirrorsPacked16FieldOrder): some Mali drivers store SOME
// packed16 allocations with a MIRRORED field order (allocation-scoped and
// shape/context dependent - the failing 30x30x12 GL_TEXTURE_2D_ARRAYs are mirrored
// at every level), so glCopyImageSubData - a raw texel-block move - lands R/G/B/A
// reversed whenever exactly one endpoint sits in a mirrored allocation
// (KHR-GL4x.copy_image.functional rgb5/rgb5_a1/rgba4 x every *2d_array* pair).
// With no 16-bit packed ES image left there is no field order to disagree about; the
// client word still round-trips exactly, because the canonical shadow is already
// UNorm8 and an n-bit field encodes to UNorm8 and back losslessly for n <= 8.
// ForceOn widens on any driver (the llvmpipe suites use it to exercise the widened
// path); ForceOff keeps the native narrow storage even where the probe fires - the
// negative control that replays the corruption. Costs 2x the memory of the affected
// formats where it engages, which is why Auto is probe-gated rather than always-on.
QuirkOverride EsprytWidenPacked16Storage = QuirkOverride::Auto;
// MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE: DirectVulkan's GL_PRIMITIVES_GENERATED
// reroute for draws made while transform feedback is INACTIVE. The stream query
// (VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT primitivesNeeded) is defined to count
// them, but a Mali driver - and Mesa lavapipe - answers 0 unless a capture span is
// open, which is exactly the shape the CTS uses to measure the tessellator, so ~29
// tessellation tests per tree size a capture buffer from the 0 and die on the
// zero-length map. Auto defers to a device probe at renderer bring-up
// (SelfTest::RunPrimitivesGeneratedNoXfbProbe), which measures two substitutes on
// the same capture-less draws and arms the best proven one: the dedicated
// VK_EXT_primitives_generated_query (exact semantics by definition; lavapipe passes
// it, rasterizer discard included), else a clipping-invocations pipeline-statistics
// pool (see the verdict vocabulary for its rasterizer-discard split). ForceOn pins
// the reroute structurally wherever a pool can exist (the arming-observable lane,
// immune to the probe's verdict moving), and ForceOff is the negative control that
// replays the driver's silence.
QuirkOverride MagmaPrimGenQueryReroute = QuirkOverride::Auto;
};
extern FeaturesTable Features;
} // namespace MobileGL::MG_Config
+23 -15
View File
@@ -162,34 +162,39 @@ namespace MobileGL::MG_ConfigLoader {
inline void InitFeatures() {
auto& features = MG_Config::Features;
features.DisableTimerQuery = QueryEnvFlag("MOBILEGL_DISABLE_TIMERQUERY");
features.EnableGlesTextureView = QueryEnvFlag("MOBILEGL_ENABLE_GLES_TEXTURE_VIEW");
features.EsprytEnableTextureView = QueryEnvFlag("MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW");
features.EnableSpirvValidation = QueryEnvFlag("MOBILEGL_ENABLE_SPIRV_VALIDATION");
features.UseAngle = QueryEnvFlag("MOBILEGL_USE_ANGLE");
features.EsprytUseAngle = QueryEnvFlag("MOBILEGL_ESPRYT_USE_ANGLE");
#if defined(MOBILEGL_TRACE_ANGLE_VARIANTS)
QueryEnvVariable("MOBILEGL_TRACE_ANGLE_VARIANT", features.TraceAngleVariant, "");
#endif
features.DisableSubgroup = QueryEnvFlag("MOBILEGL_DISABLE_SUBGROUP");
features.MagmaDisableSubgroup = QueryEnvFlag("MOBILEGL_MAGMA_DISABLE_SUBGROUP");
features.MagmaEmulateSubgroup = QueryEnvFlag("MOBILEGL_MAGMA_EMULATE_SUBGROUP");
features.FixIterationRPSubgroupScratch =
QueryEnvQuirkOverride("MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH");
features.IterationRPFixBarrier = QueryEnvFlag("MOBILEGL_ITERATIONRP_FIX_BARRIER");
features.DeriveNumSubgroups = QueryEnvQuirkOverride("MOBILEGL_DERIVE_NUM_SUBGROUPS");
features.MagmaFixIterationRPSubgroupScratch =
QueryEnvQuirkOverride("MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH");
features.MagmaIterationRPFixBarrier = QueryEnvFlag("MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER");
features.MagmaDeriveNumSubgroups = QueryEnvQuirkOverride("MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS");
features.AdvertiseFp64 = QueryEnvFlag("MOBILEGL_ADVERTISE_FP64");
features.MagmaR11G11B10FFallback = QueryEnvFlag("MOBILEGL_MAGMA_R11G11B10F_FALLBACK");
features.MagmaFramesInFlight = QueryEnvUint32("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3, 1, 64);
features.AvoidSamplerMipmapMinFilter =
QueryEnvFlag("MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
features.AvoidExplicitLodBias = QueryEnvFlag("MOBILEGL_AVOID_EXPLICIT_LOD_BIAS");
features.EsprytAvoidSamplerMipmapMinFilter =
QueryEnvFlag("MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
features.EsprytAvoidExplicitLodBias = QueryEnvFlag("MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS");
features.EsprytUnlocatedIoBlocks = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS");
features.PointSizeDemotion = QueryEnvQuirkOverride("MOBILEGL_POINT_SIZE_DEMOTION");
features.CoherentAsFlush = QueryEnvFlag("MOBILEGL_COHERENT_AS_FLUSH");
features.TraceSkipAutodestroy = QueryEnvFlag("MOBILEGL_TRACE_SKIP_AUTODESTROY");
features.DisableUboRing = QueryEnvFlag("MOBILEGL_DISABLE_UBO_RING");
features.DisableUnpackRing = QueryEnvFlag("MOBILEGL_DISABLE_UNPACK_RING");
features.EsprytDisableUboRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UBO_RING");
features.EsprytDisableUnpackRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UNPACK_RING");
features.EsprytDisableUploadRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UPLOAD_RING");
features.EsprytDisableInvalidateFlush = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH");
features.DisableLargeBufferAdoption = QueryEnvFlag("MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION");
features.EsprytForceDepthStencilReadbackEmulation =
QueryEnvFlag("MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION");
features.RelaxedSemantics = QueryEnvFlag("MOBILEGL_RELAXED_SEMANTICS");
features.MagmaDisableBlendedDepthWriteQuirk =
QueryEnvQuirkOverride("MOBILEGL_MAGMA_DISABLE_BLENDED_DEPTH_WRITE");
features.DisableRobustBufferAccess = QueryEnvFlag("MOBILEGL_DISABLE_ROBUST_BUFFER_ACCESS");
features.MagmaDisableRobustBufferAccess = QueryEnvFlag("MOBILEGL_MAGMA_DISABLE_ROBUST_BUFFER_ACCESS");
features.MagmaMultiDrawMode = QueryEnvMultiDrawMode("MOBILEGL_MAGMA_MULTIDRAW_MODE");
features.EsprytMultiDrawMode = QueryEnvGLESMultiDrawMode("MOBILEGL_ESPRYT_MULTIDRAW_MODE");
features.AsyncShaderCompile = QueryEnvQuirkOverride("MOBILEGL_ASYNC_SHADER_COMPILE");
@@ -197,8 +202,11 @@ namespace MobileGL::MG_ConfigLoader {
features.AsyncOptimisticShaderStatus =
QueryEnvQuirkOverride("MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS");
features.ShaderTranslationCache = QueryEnvQuirkOverride("MOBILEGL_SHADER_CACHE");
features.ViewportArrayEmulation =
QueryEnvQuirkOverride("MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION");
features.EsprytViewportArrayEmulation =
QueryEnvQuirkOverride("MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION");
features.EsprytWidenPacked16Storage =
QueryEnvQuirkOverride("MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE");
features.MagmaPrimGenQueryReroute = QueryEnvQuirkOverride("MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE");
}
inline void InitBackendType() {
+18
View File
@@ -495,6 +495,24 @@ namespace MobileGL {
// halves (PackDoubleVertexInputsPass and VertexInputStateFactory::ToVkVertexFormat)
// still see one consistent world.
Bool SupportsFloat64VertexAttributes = false;
// Whether a TESSELLATION stage of this backend may access gl_PointSize - i.e.
// whether a module declaring OpCapability TessellationPointSize can reach the
// driver at all. DirectVulkan sets both this and the geometry twin from the one
// shaderTessellationAndGeometryPointSize feature; DirectGLES sets them
// independently from the EXT/OES_tessellation_point_size /
// geometry_point_size extension pairs (PointSizeTier), which really do come
// separately. When absent, ProgramSpirvTask demotes the built-in to an ordinary
// varying program-wide (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram); MOBILEGL_POINT_SIZE_DEMOTION
// overrides the detection in either direction at backend init.
//
// Defaults TRUE, deliberately against the house "assume absent" rule: false
// ARMS a rewrite, so the conservative no-backend answer (standalone compiles,
// unit tests) is the one that leaves modules untouched. A backend that never
// sets it gets standard modules and, at worst, the old honest declines.
Bool SupportsTessellationPointSize = true;
// The geometry-stage twin (OpCapability GeometryPointSize).
Bool SupportsGeometryPointSize = true;
SizeT MaxShaderStorageBlockSize = 128 * 1024 * 1024;
Uint32 SubgroupSize = 0;
Uint32 SubgroupSupportedStages = 0;
@@ -1198,8 +1198,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
//
// Until that reconciliation exists, advertising here would be the same lie the comment
// above refuses to tell, just with an extra prerequisite met. Set
// MOBILEGL_ENABLE_GLES_TEXTURE_VIEW=1 to re-enable it for that work.
if (textureViewSupported && MG_Config::Features.EnableGlesTextureView) {
// MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1 to re-enable it for that work.
if (textureViewSupported && MG_Config::Features.EsprytEnableTextureView) {
extensions.push_back(E_GL_ARB_texture_view);
}
// Only advertised when the host ES driver actually filters anisotropically: the sampler
@@ -1479,6 +1479,36 @@ namespace MobileGL::MG_Backend::DirectGLES {
// 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;
// Whether a tessellation / geometry stage's ESSL may name gl_PointSize at all: the two
// extension pairs the loader probed, independently, because they really do come
// separately. False arms the shared phase-B demotion
// (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram), whose ESSL then
// never names the built-in in those stages and needs no extension.
// MOBILEGL_POINT_SIZE_DEMOTION=1 pretends both are absent so the demotion can be
// exercised on a healthy driver (the pinned integration lane); =0 restores the
// detected answer's declines.
m_dynamicParameters.SupportsTessellationPointSize =
m_GLESCapabilities.TessellationPointSizeSupport !=
MG_External::GLESCapabilities::PointSizeTier::None;
m_dynamicParameters.SupportsGeometryPointSize =
m_GLESCapabilities.GeometryPointSizeSupport !=
MG_External::GLESCapabilities::PointSizeTier::None;
switch (MG_Config::Features.PointSizeDemotion) {
case MG_Config::QuirkOverride::ForceOn:
MGLOG_I("DirectGLES: MOBILEGL_POINT_SIZE_DEMOTION=1 - treating tessellation/geometry "
"gl_PointSize as unhosted so the demotion runs on this driver");
m_dynamicParameters.SupportsTessellationPointSize = false;
m_dynamicParameters.SupportsGeometryPointSize = false;
break;
case MG_Config::QuirkOverride::ForceOff:
MGLOG_I("DirectGLES: MOBILEGL_POINT_SIZE_DEMOTION=0 - keeping the built-in and the "
"plain declines regardless of the driver's extensions");
m_dynamicParameters.SupportsTessellationPointSize = true;
m_dynamicParameters.SupportsGeometryPointSize = true;
break;
case MG_Config::QuirkOverride::Auto:
break;
}
m_dynamicParameters.MaxDrawBuffers = m_GLESCapabilities.MaxDrawBuffers;
m_dynamicParameters.MaxColorAttachments = m_GLESCapabilities.MaxColorAttachments;
m_dynamicParameters.MaxClipDistances = m_GLESCapabilities.MaxClipDistances;
@@ -10636,6 +10636,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// frame's ring high-water marks for slot reclamation.
BufferImpl::UboRingOnPresent();
BufferImpl::UnpackRingOnPresent();
BufferImpl::UploadRingOnPresent();
BufferImpl::TrimBufferPool();
}
+414 -18
View File
@@ -54,7 +54,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the driver fifteen more rectangles to rasterize against, and nothing in MobileGL has ever
// programmed the indexed state it would need.
Bool ViewportArrayEmulationEnabled() {
return MG_Config::Features.ViewportArrayEmulation != MG_Config::QuirkOverride::ForceOff;
return MG_Config::Features.EsprytViewportArrayEmulation != MG_Config::QuirkOverride::ForceOff;
}
Bool g_anyProgramRoutesViewportIndex = false;
@@ -75,13 +75,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
static Bool ShouldAvoidSamplerMipmapMinFilterOnAngleLlvmpipe() {
// IsAngleLlvmpipeRenderer combined with the
// MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER feature toggle,
// MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER feature toggle,
// both resolved in FillInGLESCapabilities.
return g_GLESCapabilities.AvoidSamplerMipmapMinFilter;
}
static Bool ShouldAvoidExplicitLodBiasOnAngleLlvmpipe() {
// IsAngleLlvmpipeRenderer combined with the MOBILEGL_AVOID_EXPLICIT_LOD_BIAS
// IsAngleLlvmpipeRenderer combined with the MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS
// feature toggle, both resolved in FillInGLESCapabilities.
return g_GLESCapabilities.AvoidExplicitLodBias;
}
@@ -648,6 +648,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
// GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT. 64 covers every type with room to
// spare and keeps consecutive staged blocks off each other's cache lines.
constexpr SizeT kUnpackRingAlignment = 64;
// glCopyBufferSubData carries no offset-alignment requirement at all; 64
// keeps staged blocks cache-line separated, same as the unpack ring.
constexpr SizeT kUploadRingInitialBytes = 4u * 1024u * 1024u;
constexpr SizeT kUploadRingMaxBytes = 64u * 1024u * 1024u;
constexpr SizeT kUploadRingAlignment = 64;
struct PersistentRingStore {
Uint id = 0;
@@ -705,6 +710,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
kUnpackRingMaxBytes,
kUnpackRingAlignment,
"Texture unpack ring"};
// Staging ring for app buffer updates whose destination store may still be
// referenced by in-flight GPU work. Mali's glBufferSubData resolves that WAR
// hazard by BLOCKING in the call (osup_sync_object_wait) until every
// referencing job retires - under Minecraft 26.3's per-frame UBO and
// chunk-mesh SubData streams that serialized whole frames (~1 fps while
// chunks stream in). Staging the bytes here and issuing a
// glCopyBufferSubData instead keeps the hazard on the GPU timeline where it
// is just job ordering, and the CPU never waits.
PersistentRing g_uploadRing{{},
{},
{},
kUploadRingInitialBytes,
kUploadRingMaxBytes,
kUploadRingAlignment,
"Buffer upload ring"};
// The ES context the ring's id/map belonged to is gone (or was never
// seen): drop every handle without GL calls and re-arm creation. The
@@ -763,6 +783,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource.storageInitialized = true;
resource.pendingRespecify = false;
resource.pendingRanges.clear();
resource.pendingResidentWrites.clear();
resource.syncedChangeSerial = bufferObject.GetChangeSerial();
// A GROWN store keeps its indexed bindings, and BindBufferBaseCached skips a
// rebind whenever the shadow already records this id at that index - so on a
@@ -794,6 +815,161 @@ namespace MobileGL::MG_Backend::DirectGLES {
bufferObject.MappedData() + start);
}
// Ring machinery shared with the UBO/unpack rings; defined further down in
// this same unnamed namespace.
Bool RingAllocate(PersistentRing& ring, SizeT size, SizeT& outOffset);
Bool RingAvailable(PersistentRing& ring);
// True when a pending-range flush can go through the staging ring right
// now: kill switch off, the ES copy entry point resolved, and the ring's
// own availability gate (EXT_buffer_storage + fences + live context) up.
Bool UploadRingUsableNow() {
if (MG_Config::Features.EsprytDisableUploadRing) return false;
if (!g_GLESFuncs.glCopyBufferSubData) return false;
return RingAvailable(g_uploadRing);
}
// A partial range below this goes through the staging ring instead of a
// range-invalidating map: the map's page-substitution fast path needs a
// sizeable (page-coverable) range to engage, and below it the driver
// falls back to waiting out the WAR hazard on the CPU.
constexpr SizeT kInvalidateRangeMinBytes = 128u * 1024u;
// Push every queued range of `resource` from the shadow into the backend
// store, without ever letting a driver resolve the WAR hazard against
// in-flight frames at the WHOLE BUFFER's expense. Three tiers:
//
// 1. glMapBufferRange(WRITE | INVALIDATE_RANGE) + memcpy. The entire
// mapped range is rewritten from the authoritative shadow, so
// declaring its old bytes dead is exact - and it lets the driver
// swap fresh pages in for JUST that range. This is the only tier
// whose cost scales with the RANGE on this Mali driver: both the
// immediate glBufferSubData (pre-queueing) and a staged
// glCopyBufferSubData into a busy MUTABLE store ghost the whole
// destination with a worker-thread memcpy - Minecraft 26.3 streams
// ~1MB section meshes into 128MB arenas about nine times a frame
// during a camera pan, and 9 x 128MB of ghosting per frame is
// ~380ms, the measured 2-4 fps. (Backing the arenas with immutable
// stores also kills the ghost, but eagerly commits every arena's
// full extent - +hundreds of MB - which LMK'd the whole device.)
// 2. The staging ring + glCopyBufferSubData: the copy is ordered on
// the GPU timeline, no CPU wait (MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH
// forces this tier as the map path's negative control).
// 3. Direct glBufferSubData (potentially stalling) when neither the
// map entry points nor the ring exist.
//
// The ranges are flushed AS QUEUED (VecRange1D::Add already merges
// near-adjacent ones): bytes, not flush calls, are the cost axis here,
// and collapsing a scattered flush into its union re-copied nearly whole
// chunk-mesh arenas every frame.
// The caller owns syncedChangeSerial; this only drains the queue.
void FlushPendingRangesNow(GLESBufferResource& resource, BufferObject& bufferObject) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
VecRange1D ranges;
{
const std::lock_guard<std::mutex> lock(resource.pendingMutex);
if (resource.pendingRanges.empty()) return;
ranges = std::move(resource.pendingRanges);
resource.pendingRanges.clear();
}
// Clamp against BOTH extents: the readback flush may run while the
// frontend size and the backend store disagree (a pending respecify
// resolves that later; bytes past either end have nowhere to land).
const SizeT limit = std::min(bufferObject.GetSize(), resource.storageSize);
const Bool mapUsable = !MG_Config::Features.EsprytDisableInvalidateFlush &&
g_GLESFuncs.glMapBufferRange && g_GLESFuncs.glUnmapBuffer;
const Bool ringUsable = UploadRingUsableNow();
for (const auto& range : ranges) {
const SizeT end = std::min(range.end, limit);
const SizeT start = std::min(range.start, end);
const SizeT size = end - start;
if (size == 0) continue;
// The invalidating map's fast path is SHAPE-dependent on this Mali
// driver: a whole-buffer invalidation renames the store outright,
// and a large range gets fresh pages - but a small unaligned range
// of a busy store makes the map WAIT (osup_sync_object_wait, ~9%
// of a Minecraft 26.3 replay). So: whole buffer -> orphan-map;
// large range -> range-invalidating map; small range -> the staged
// ring copy, whose worst case (a whole-destination ghost) is only
// ever the small destination itself.
//
// The map covers EXACTLY the queued range: only those bytes are the
// shadow's to rewrite. Widening to page bounds looked free and was
// not - the widened bytes clobbered GPU-written data (an SSBO
// counter beside the app's SubData) with the stale shadow.
const Bool wholeBuffer = start == 0 && end == limit && limit == resource.storageSize;
if (mapUsable && (wholeBuffer || size >= kInvalidateRangeMinBytes)) {
BindBufferId(TempBufferTarget, resource.id);
const GLbitfield access =
GL_MAP_WRITE_BIT |
(wholeBuffer ? GL_MAP_INVALIDATE_BUFFER_BIT : GL_MAP_INVALIDATE_RANGE_BIT);
void* dst = g_GLESFuncs.glMapBufferRange(TempBufferTarget, (GLintptr)start,
(GLsizeiptr)size, access);
if (dst) {
Memcpy(dst, bufferObject.MappedData() + start, size);
g_GLESFuncs.glUnmapBuffer(TempBufferTarget);
continue;
}
}
SizeT ringOffset = 0;
if (ringUsable && size <= kUploadRingMaxBytes &&
RingAllocate(g_uploadRing, size, ringOffset)) {
Memcpy(g_uploadRing.store.mappedPtr + ringOffset, bufferObject.MappedData() + start, size);
BindBufferId(GL_COPY_READ_BUFFER, g_uploadRing.store.id);
BindBufferId(GL_COPY_WRITE_BUFFER, resource.id);
g_GLESFuncs.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER,
(GLintptr)ringOffset, (GLintptr)start, (GLsizeiptr)size);
} else {
UploadRangeNow(resource, bufferObject, start, end);
}
}
}
// Land the app bytes queued for an ADOPTED store on the GPU timeline: staged
// into the upload ring and delivered by glCopyBufferSubData. The destination
// is the IMMUTABLE persistent store, which the driver can neither rename nor
// ghost, so the copy is plain job ordering - after every in-flight reader,
// before the next consumer - which is exactly glBufferSubData's contract.
// (The in-place host write these bytes replaced tore the frames still
// reading the old vertex data: one-frame wrong geometry during fast camera
// movement.) Fallback: direct glBufferSubData - the adopted store carries
// DYNAMIC_STORAGE, and immutability again forbids the whole-store ghost.
void DrainResidentWritesNow(GLESBufferResource& resource, BufferObject& bufferObject) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
Vector<GLESBufferResource::PendingResidentWrite> writes;
{
const std::lock_guard<std::mutex> lock(resource.pendingMutex);
if (resource.pendingResidentWrites.empty()) return;
writes = std::move(resource.pendingResidentWrites);
resource.pendingResidentWrites.clear();
}
const SizeT limit = resource.storageSize;
const Bool ringUsable = UploadRingUsableNow();
for (const auto& write : writes) {
if (write.offset >= limit) continue;
const SizeT size = std::min(write.bytes.size(), limit - write.offset);
if (size == 0) continue;
SizeT ringOffset = 0;
if (ringUsable && size <= kUploadRingMaxBytes &&
RingAllocate(g_uploadRing, size, ringOffset)) {
Memcpy(g_uploadRing.store.mappedPtr + ringOffset, write.bytes.data(), size);
BindBufferId(GL_COPY_READ_BUFFER, g_uploadRing.store.id);
BindBufferId(GL_COPY_WRITE_BUFFER, resource.id);
g_GLESFuncs.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER,
(GLintptr)ringOffset, (GLintptr)write.offset,
(GLsizeiptr)size);
} else {
BindBufferId(TempBufferTarget, resource.id);
g_GLESFuncs.glBufferSubData(TempBufferTarget, (GLintptr)write.offset, (GLsizeiptr)size,
write.bytes.data());
}
}
}
// EXT_buffer_storage bit values (same numeric values as the desktop ARB
// tokens); defined locally so this compiles regardless of which GLES headers
// expose the EXT tokens.
@@ -879,6 +1055,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
{
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
}
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
return ptr;
@@ -916,12 +1093,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->storageSize = 0;
resource->pendingRespecify = true;
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
return;
}
if (!CanTouchGLNow() || resource->id == 0 ||
resource->contextGeneration != g_bufferContextGeneration) {
resource->pendingRespecify = true;
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
return;
}
if (bufferObject.GetSize() == 0) {
@@ -929,6 +1108,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->storageSize = 0;
resource->pendingRespecify = false;
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
return;
}
RespecifyStorageNow(*resource, bufferObject);
@@ -941,11 +1121,45 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!CanTouchGLNow() || resource->id == 0 ||
resource->contextGeneration != g_bufferContextGeneration ||
!StorageMatches(*resource, bufferObject)) {
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.Add({offset, offset + size});
return;
}
UploadRangeNow(*resource, bufferObject, offset, offset + size);
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
// An adopted zero-copy persistent store already HAS the bytes (the
// frontend wrote them through the coherent mapping); a driver upload
// here would be a self-copy that re-synchronizes what coherent mapping
// made free.
if (resource->persistentMapped && resource->persistentPtr) {
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
return;
}
// An immediate glBufferSubData resolves the WAR hazard against frames
// still referencing this store on the CPU on some drivers - Mali parks
// the thread in osup_sync_object_wait until every referencing job
// retires, which serialized Minecraft 26.3's per-frame UBO/chunk-mesh
// update streams into ~1 fps. Queue the range instead (the shadow
// already holds the bytes) and let draw-time sync push the merged
// ranges through the staging ring.
if (MG_Config::Features.EsprytDisableUploadRing) {
UploadRangeNow(*resource, bufferObject, offset, offset + size);
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
return;
}
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.Add({offset, offset + size});
}
// App bytes for an ADOPTED store: queue them untouched-by-the-mapping; the
// draw-time sync (or a readback) lands them GPU-ordered through
// DrainResidentWritesNow. No GL here, so the op is thread-agnostic.
void Ops_ResidentSubData(BufferObject& bufferObject, SizeT offset, DataPtr data) {
auto* resource = ResourceOf(bufferObject);
if (!resource || data.size == 0) return;
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
auto& write = resource->pendingResidentWrites.emplace_back();
write.offset = offset;
const auto* bytes = static_cast<const Uint8*>(data.data);
write.bytes.assign(bytes, bytes + data.size);
}
void Ops_FlushMappedRange(BufferObject& bufferObject, Range1D range,
@@ -956,6 +1170,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!CanTouchGLNow() || resource->id == 0 ||
resource->contextGeneration != g_bufferContextGeneration ||
!StorageMatches(*resource, bufferObject)) {
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.Add(range);
return;
}
// An adopted zero-copy persistent store already HAS the bytes: the
// frontend shadow IS the coherent mapping the app (or UploadSubData)
// wrote into, so publishing is free. The self-copy that used to run
// here mapped a buffer this backend keeps persistently mapped (an
// INVALID_OPERATION whose fallback was a WAR-stalling
// glBufferSubData).
if (resource->persistentMapped && resource->persistentPtr) {
resource->syncedChangeSerial = bufferObject.GetChangeSerial();
return;
}
// Same WAR-hazard rule as Ops_SubData: an immediate synchronized upload
// (mapped or glBufferSubData) can park the thread on Mali until the
// frames still referencing this store retire. Queue the range for the
// staged flush at draw-time sync; the negative-control kill switch
// keeps the immediate paths below.
if (!MG_Config::Features.EsprytDisableUploadRing) {
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.Add(range);
return;
}
@@ -998,8 +1235,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!resource || resource->id == 0 || !resource->storageInitialized) return;
if (!CanTouchGLNow() || resource->contextGeneration != g_bufferContextGeneration) return;
if (resource->persistentMapped) {
// Host writes to a persistent map must not race shader writes already queued
// on this context. There is no backend copy to read back in this case.
// Queued resident SubData bytes land first (GPU-ordered), then the
// finish makes them - and any shader writes already queued on this
// context - visible through the coherent mapping the reads use.
// There is no backend copy to read back in this case.
DrainResidentWritesNow(*resource, bufferObject);
if (g_GLESFuncs.glFinish) g_GLESFuncs.glFinish();
return;
}
@@ -1007,6 +1247,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
const SizeT size = std::min<SizeT>(bufferObject.GetSize(), resource->storageSize);
if (size == 0) return;
// Queued app writes must land in the backend store before it is read
// back, or the writeback below would revert them in the shadow.
FlushPendingRangesNow(*resource, bufferObject);
BindBufferId(TempBufferTarget, resource->id);
void* mapped = g_GLESFuncs.glMapBufferRange(TempBufferTarget, 0, static_cast<GLsizeiptr>(size),
GL_MAP_READ_BIT);
@@ -1061,6 +1305,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
Ops_SubData(bufferObject, offset, size);
BumpBufferMutationEpoch();
}
void Ops_ResidentSubDataTracked(BufferObject& bufferObject, SizeT offset, DataPtr data) {
Ops_ResidentSubData(bufferObject, offset, data);
BumpBufferMutationEpoch();
}
void Ops_FlushMappedRangeTracked(BufferObject& bufferObject, Range1D range,
Flags<BufferMappingAccessBit> appAccess) {
Ops_FlushMappedRange(bufferObject, range, appAccess);
@@ -1085,6 +1333,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const BufferBackendOps g_glesBufferBackendOps = {
.Respecify = Ops_RespecifyTracked,
.SubData = Ops_SubDataTracked,
.ResidentSubData = Ops_ResidentSubDataTracked,
.FlushMappedRange = Ops_FlushMappedRangeTracked,
.OnDestroy = Ops_OnDestroyTracked,
.AcquirePersistentMap = Ops_AcquirePersistentMapTracked,
@@ -1186,7 +1435,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (resource->id == 0) return false;
// Zero-copy coherent persistent store: EnsureBufferResource's own early-out —
// the app writes straight into the mapped GPU storage, nothing to sync.
if (resource->persistentMapped) return resource->persistentPtr != nullptr;
// Except queued resident SubData bytes, which land through the sync path
// (same unlocked emptiness probe as pendingRanges below).
if (resource->persistentMapped) {
return resource->persistentPtr != nullptr && resource->pendingResidentWrites.empty();
}
// A live non-zero-copy map may owe a per-draw SyncPersistentMappedRange push
// (persistent maps mutate the shadow without bumping the change serial).
if (frontend->IsMapped()) return false;
@@ -1218,6 +1471,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->storageSize = 0;
resource->pendingRespecify = true;
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
resource->contextGeneration = g_bufferContextGeneration;
// The persistent map (and its pointer) died with the old context; the
// frontend re-acquires a fresh one on its next map.
@@ -1247,6 +1501,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// persistently mapped immutable store, so there is nothing to (re)upload at
// draw time. This is where the per-draw whole-buffer glBufferSubData used to run.
if (resource->persistentMapped && resource->persistentPtr && resource->id != 0) {
DrainResidentWritesNow(*resource, *bufferObject);
return resource;
}
@@ -1268,6 +1523,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
{
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.clear();
resource->pendingResidentWrites.clear();
}
resource->syncedChangeSerial = bufferObject->GetChangeSerial();
} else {
@@ -1295,11 +1551,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
resource->storageSize != bufferObject->GetSize()) {
RespecifyStorageNow(*resource, *bufferObject);
} else if (!resource->pendingRanges.empty()) {
for (const auto& range : resource->pendingRanges) {
const SizeT end = std::min(range.end, bufferObject->GetSize());
UploadRangeNow(*resource, *bufferObject, std::min(range.start, end), end);
}
resource->pendingRanges.clear();
FlushPendingRangesNow(*resource, *bufferObject);
resource->syncedChangeSerial = bufferObject->GetChangeSerial();
} else if (resource->syncedChangeSerial != bufferObject->GetChangeSerial()) {
// Ops could not track some writes (e.g. the ops table was
@@ -1621,6 +1873,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
"ring offset mask below requires power-of-two ring sizes");
static_assert((kUnpackRingInitialBytes & (kUnpackRingInitialBytes - 1)) == 0,
"ring offset mask below requires power-of-two ring sizes");
static_assert((kUploadRingInitialBytes & (kUploadRingInitialBytes - 1)) == 0,
"ring offset mask below requires power-of-two ring sizes");
const SizeT offset = static_cast<SizeT>(store.head & (store.size - 1));
if (offset + alignedSize <= store.size && store.head + alignedSize - store.tail <= store.size) {
store.head += alignedSize;
@@ -1760,7 +2014,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} // namespace
Bool UboRingAvailable() {
if (MG_Config::Features.DisableUboRing) return false;
if (MG_Config::Features.EsprytDisableUboRing) return false;
return RingAvailable(g_uboRing);
}
@@ -1773,7 +2027,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
void UboRingOnPresent() { RingOnPresent(g_uboRing); }
Bool UnpackRingAvailable() {
if (MG_Config::Features.DisableUnpackRing) return false;
if (MG_Config::Features.EsprytDisableUnpackRing) return false;
return RingAvailable(g_unpackRing);
}
@@ -1789,6 +2043,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
SizeT UnpackRingMaxBytes() { return kUnpackRingMaxBytes; }
void UnpackRingOnPresent() { RingOnPresent(g_unpackRing); }
void UploadRingOnPresent() { RingOnPresent(g_uploadRing); }
} // namespace BufferImpl
namespace VertexArrayImpl {
@@ -2485,7 +2741,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
}
if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteTextures) {
// TEMP-EXP (leak texture deletes): /sdcard/MG/exp_leak_texture_deletes.
// Discriminator for the mali-mem-purge hiccup theory: never hand the
// driver a texture free, so the purge daemon has nothing to reclaim.
static const Bool s_expLeakTextureDeletes = [] {
FILE* f = std::fopen("/sdcard/MG/exp_leak_texture_deletes", "rb");
if (!f) return false;
std::fclose(f);
return true;
}();
if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteTextures &&
!s_expLeakTextureDeletes) {
g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId);
if (m_bufferImageSplitViewId != 0) {
g_GLESFuncs.glDeleteTextures(1, &m_bufferImageSplitViewId);
@@ -3261,6 +3527,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (format != TextureInternalFormat::RGB5 && format != TextureInternalFormat::RGB5A1) {
return data;
}
// With the storage widened to 8-bit-per-channel (the packed16 field-order quirk)
// there is no driver requantization left for the repack to pre-empt - the shadow's
// UNorm8 bytes ARE the stored bytes - and the packed 16-bit client type this leg
// retargets to is not a legal upload for a GL_RGB8/GL_RGBA8 store at all.
if (TextureImpl::UsesWidenedPacked16NormStorage(format)) {
return data;
}
const Bool hasAlpha = format == TextureInternalFormat::RGB5A1;
const GLenum packedType = hasAlpha ? GL_UNSIGNED_SHORT_5_5_5_1 : GL_UNSIGNED_SHORT_5_6_5;
// Idempotent across a region's level loop: glType is shared, so later levels arrive with
@@ -6301,7 +6574,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
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,
const std::map<String, String>& outputBlockRenames, const Bool stripInputBlockLocations,
const Bool stripOutputBlockLocations,
const Int atomicCounterEsslBindingTop, const Bool enableSpirvValidation, String& outSource,
std::set<String>& outFlattenedXfbBlockNames, Vector<Int>& outAtomicCounterGlBindings,
String& outError) const {
@@ -6338,7 +6612,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const Int advertisedMaxSamples =
std::max(g_GLESCapabilities.MaxSamples, kFrontendMaxSamples);
// Armed by the EMULATION as well as by the missing extension, and the emulation is on
// by default (MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION). Having the extension is not a
// by default (MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION). Having the extension is not a
// reason to keep the builtin: it only ever gave the SHADER a compilable name, while
// the driver's INDEXED viewport state was never programmed by anything in MobileGL
// (SyncRenderState pushes index 0 and stops), so an extension-capable driver
@@ -6692,6 +6966,54 @@ namespace MobileGL::MG_Backend::DirectGLES {
effectiveSpirv = &atomicCounterSpirv;
}
// The second half of the inter-stage interface-block repair, and the one that
// actually closes the 420pack group: this driver drops the payload of a block that
// carries an explicit layout(location=) whenever a tessellation or geometry stage
// is in the pipeline, so the qualifier comes off and ES matches the block by name
// and member sequence instead. The names those two sides agree on are the ones the
// rename above just fixed, which is why this runs AFTER it and not before.
//
// The caller arms the two directions; both are false unless the driver POST
// measured the defect AND this program has a stage that can hit it. Adopted only
// when this stage really had a located block, for the reason the array-input split
// documents: the optimizer hands back a re-serialised copy either way.
//
// LAST IN THE CHAIN, and that position is load-bearing. Vulkan SPIR-V REQUIRES a
// Location on every user-defined Input/Output variable
// ([VUID-StandaloneSpirv-Location-04915]), so the module this produces is
// deliberately no longer valid Vulkan SPIR-V - it is an ESSL-emission intermediate
// that goes straight into SPIRV-Cross and reaches no driver as SPIR-V. Running it
// here means no later pass validates what it produced; the pass itself skips
// validation for the same reason (see StripIoBlockLocationsForEssl). Anywhere
// earlier and every remaining pass would latch a validation failure on a module
// that is doing exactly what it was asked to.
Vector<unsigned int> strippedIoBlockLocationSpirv;
if (stripInputBlockLocations || stripOutputBlockLocations) {
Bool strippedAny = false;
if (MG_Util::ShaderTranspiler::ShaderCompiler::StripIoBlockLocationsForEssl(
*effectiveSpirv, stripInputBlockLocations, stripOutputBlockLocations,
strippedAny, strippedIoBlockLocationSpirv, enableSpirvValidation) &&
!strippedIoBlockLocationSpirv.empty() && strippedAny) {
effectiveSpirv = &strippedIoBlockLocationSpirv;
// THE ARMING SIGNAL, and it is INFO on purpose: the per-stage line below is
// MGLOG_D, which is compiled out of every build CI and the device runs, so
// nothing outside a debug build could tell an armed repair from a silently
// un-armed one. Latched, so it costs one line per process rather than one
// per stage of every program. The integration lane that pins the emulation
// on asserts on exactly this line - see UnlocatedIoBlockScenario.
MGLOG_I_ONCE("DirectGLES is emitting inter-stage interface blocks WITHOUT their "
"layout(location) qualifier, because this driver loses a located "
"block's payload across a tessellation or geometry boundary.");
MGLOG_D("Program %u stage %s: interface-block location qualifiers dropped "
"(%s), because this driver loses a located block's payload across a "
"tessellation or geometry boundary.",
m_backendProgramId, MG_Util::ConvertGLEnumToString(glShaderType).c_str(),
stripInputBlockLocations
? (stripOutputBlockLocations ? "consumed and produced" : "consumed")
: "produced");
}
}
MG_Util::ShaderTranspiler::SpvcSession spvcSession(*effectiveSpirv,
MG_Util::ShaderTranspiler::SessionUsageBit::Transpile);
@@ -7020,6 +7342,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
m_backendProgramUsable = false;
return;
}
if (stateProgramObject->PointSizeDemoted()) {
// THE ARMING SIGNAL, INFO on purpose and latched: the integration lane that
// pins MOBILEGL_POINT_SIZE_DEMOTION=1 asserts on exactly this line, because
// every rendering assertion stays green on a healthy driver whether the
// demotion ran or was silently disarmed. See PointSizeDemotionScenario.
MGLOG_I_ONCE("DirectGLES is building programs whose tessellation/geometry gl_PointSize was "
"demoted to an ordinary varying, because this driver cannot host the built-in "
"in those stages.");
}
MGLOG_D("Attaching %zu shaders to program %u", linkedStages.size(), m_backendProgramId);
for (const auto& ref : stateProgramObject->GetLinkedShaderSnapshot()) {
if (!ref.shader) continue;
@@ -7087,6 +7418,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
stagePipelineIndices[index] = InterStagePipelineIndex(stage);
if (CanDeclareBlocksInBothDirections(stage)) anyStageCanDeclareBlocksInBothDirections = true;
}
// A SECOND, INDEPENDENT interface-block repair riding the same gate, because it
// needs the same question answered: "does this program have a stage where an
// inter-stage block can go wrong?". CanDeclareBlocksInBothDirections is true for
// exactly the tessellation and geometry stages, which is also exactly the set of
// stages whose presence makes this driver drop a LOCATED block's payload (a
// vertex-to-fragment located block is fine on the same driver, measured). The two
// repairs are otherwise unrelated: the rename fixes a name collision inside ONE
// stage, this drops a qualifier from EVERY block of the program - so it does not
// wait for the collision probe to find anything.
const Bool ioBlockLocationStripArmed =
!g_GLESCapabilities.SupportsLocatedInterStageIoBlocks &&
anyStageCanDeclareBlocksInBothDirections;
if (anyStageCanDeclareBlocksInBothDirections) {
for (SizeT index = 0; index < shaderSpirvs.size(); ++index) {
MG_Util::ShaderTranspiler::ShaderCompiler::ProbeIoBlockNamesForEssl(
@@ -7281,6 +7624,39 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
esslKeyInputs.inputBlockRenames = &inputBlockRenames;
esslKeyInputs.outputBlockRenames = &outputBlockRenames;
// ...and THIS STAGE's share of the interface-block LOCATION strip, planned the
// same way and for the same reason. The gate has three parts, all of which have
// to hold before a single block loses its qualifier:
// * the driver POST measured the defect (never a renderer-string quirk list);
// * this program has a stage that can hit it - a located block between a
// vertex and a fragment stage works on the affected driver, so a program
// with neither tessellation nor geometry keeps its ESSL byte for byte;
// * for THIS stage and THIS direction, this program HAS a stage on that side
// of it. That is the same test the rename plan above makes, and the same
// approximation: it asks "is some stage of this program earlier/later than
// me", not "is the exact partner of every one of my blocks here". The two
// coincide for every program MobileGL builds, because a separable pipeline
// is flattened into one composite carrying every stage that has a shader
// (GLContext::GetProgramForDraw) and a program bound with glUseProgram has
// no partner program at all - so a stage set with a gap in it does not
// arise. Should one ever arise, this must become the nearest-stage
// resolution the rename plan computes, or the two ends of the gap would
// disagree about the qualifier.
// The direction tests deliberately mirror that plan rather than inventing a
// second rule for the same question.
Bool stripInputBlockLocations = false;
Bool stripOutputBlockLocations = false;
if (ioBlockLocationStripArmed && stagePipelineIndices[index] >= 0) {
const Int myPipelineIndex = stagePipelineIndices[index];
for (const Int otherPipelineIndex : stagePipelineIndices) {
if (otherPipelineIndex < 0) continue;
if (otherPipelineIndex < myPipelineIndex) stripInputBlockLocations = true;
if (otherPipelineIndex > myPipelineIndex) stripOutputBlockLocations = true;
}
}
esslKeyInputs.stripInputBlockLocations = stripInputBlockLocations;
esslKeyInputs.stripOutputBlockLocations = stripOutputBlockLocations;
esslKeyInputs.enableSpirvValidation = enableSpirvValidation;
auto& esslCache = MG_Util::ShaderTranspiler::GetEsslTranslationCache();
@@ -7307,6 +7683,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!TranspileSpirvToEssl(spirvCode, glShaderType, xfbCaptureBlockNames,
imageFormatBake, storageBlockBindingOverrides,
inputBlockRenames, outputBlockRenames,
stripInputBlockLocations, stripOutputBlockLocations,
m_atomicCounterEsslBindingTop,
enableSpirvValidation, source,
stageFlattenedXfbBlockNames,
@@ -7644,6 +8021,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
// for; it has the variable that replaced it. Everything else - including a
// member of a block that was left alone - keeps the application's spelling.
// Storage first, pointers after: xfbNames holds pointers into these strings.
//
// Same rule for a demoted gl_PointSize: the capture stage's ESSL no longer
// spells the built-in at all - the value lives in the carrier the demotion
// named - so the driver-side request has to follow it there. Only when the
// capture stage IS a demoted one (geometry, else evaluation): a program whose
// capture stage is the vertex shader keeps the built-in and its spelling,
// whatever happened to a control stage behind it.
Bool captureStageDemoted = false;
if (stateProgramObject->PointSizeDemoted()) {
for (const ShaderStage linkedStage : linkedStages) {
if (linkedStage == ShaderStage::TessEval || linkedStage == ShaderStage::Geometry) {
captureStageDemoted = true;
break;
}
}
}
Vector<String> rewrittenXfbNames(xfbVaryings.size());
for (SizeT nameIndex = 0; nameIndex < xfbVaryings.size(); ++nameIndex) {
String flatName;
@@ -7651,6 +8044,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
MG_Util::ShaderTranspiler::ShaderCompiler::RewriteXfbCaptureNameForFlattenedBlock(
xfbVaryings[nameIndex].name, flattenedXfbBlockNames, flatName)) {
rewrittenXfbNames[nameIndex] = std::move(flatName);
} else if (captureStageDemoted && xfbVaryings[nameIndex].name == "gl_PointSize") {
rewrittenXfbNames[nameIndex] =
MG_Util::ShaderTranspiler::ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME;
} else {
rewrittenXfbNames[nameIndex] = xfbVaryings[nameIndex].name;
}
+30 -2
View File
@@ -230,7 +230,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// 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
// Whether emulation runs. Off only under MOBILEGL_ESPRYT_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
@@ -461,6 +461,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the owning thread replaying them: guard both fields with pendingMutex.
Bool pendingRespecify = false;
VecRange1D pendingRanges;
// App bytes for an ADOPTED store, awaiting their GPU-ordered landing (ring
// stage + glCopyBufferSubData at the next sync; see
// BufferBackendOps::ResidentSubData). The frontend keeps such writes out of
// the coherent mapping - an in-place host write tears the in-flight frames
// still reading the old bytes. Guarded by pendingMutex like pendingRanges.
struct PendingResidentWrite {
SizeT offset = 0;
Vector<Uint8> bytes;
};
Vector<PendingResidentWrite> pendingResidentWrites;
std::mutex pendingMutex;
// Buffer-mutation epoch (see CurrentBufferMutationEpoch) at which this
// resource last probed IsBufferDrawClean == true, 0 = never (epochs start
@@ -627,7 +637,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// which is what to watch if this ring ever shows up in an RSS regression: it
// grows on demand from 4 MiB and is capped, not unbounded.
//
// False when the feature is disabled (MOBILEGL_DISABLE_UNPACK_RING),
// False when the feature is disabled (MOBILEGL_ESPRYT_DISABLE_UNPACK_RING),
// EXT_buffer_storage / fences are missing, the ES context is not current, or
// ring creation already failed under this context. Callers then upload from
// the client pointer exactly as before.
@@ -642,6 +652,23 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Largest single staging request the ring can ever satisfy.
SizeT UnpackRingMaxBytes();
void UnpackRingOnPresent();
// --- Buffer upload ring ---------------------------------------------------
// The same persistent-mapped bump allocator, staging APP BUFFER UPDATES
// (glBufferSubData / non-persistent map flushes) whose destination store may
// still be referenced by in-flight GPU work. Mali resolves that WAR hazard by
// BLOCKING the calling glBufferSubData (osup_sync_object_wait) until every
// referencing job retires - Minecraft 26.3 rewrites its chunk-section and
// dynamic-transform UBOs and streams chunk meshes with per-frame SubData, and
// each such call serialized against the whole GPU queue (~1 fps while chunks
// stream in, and again on every camera pan). App SubData ranges are queued on
// the resource instead (the frontend shadow already holds the bytes) and
// draw-time sync drains them: bytes staged into this ring, then one
// glCopyBufferSubData per merged range - the copy is ordered on the GPU
// timeline, so the hazard costs no CPU wait. Reclamation contract identical
// to the other two rings. MOBILEGL_ESPRYT_DISABLE_UPLOAD_RING restores the
// historical immediate-upload path (negative control / escape hatch).
void UploadRingOnPresent();
} // namespace BufferImpl
namespace VertexArrayImpl {
@@ -1633,6 +1660,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const UnorderedMap<String, Int>& storageBlockBindingOverrides,
const std::map<String, String>& inputBlockRenames,
const std::map<String, String>& outputBlockRenames,
Bool stripInputBlockLocations, Bool stripOutputBlockLocations,
Int atomicCounterEsslBindingTop, Bool enableSpirvValidation,
String& outSource,
std::set<String>& outFlattenedXfbBlockNames,
+40
View File
@@ -11,8 +11,10 @@
#include "Managers.h"
#include "MG_Backend/BackendObjects.h"
#include "MG_Util/Converters/GLToMG/FramebufferEnumConverter.h"
#include "MG_Util/SelfTest/DriverBugProbes.h"
#include "MG_Util/Texture/TextureFormatProcessor.h"
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
#include <Config.h>
#include <MG_State/GLState/Core.h>
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
@@ -125,6 +127,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
requestedInternalFormat,
TextureImpl::GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
}
// Outside the caveat branch on purpose: the driver CAN create the native narrow
// storage - the capability probes say so - it just cannot be trusted as a raw-copy
// endpoint. Texture and renderbuffer targets both come through here, which is what
// keeps a renderbuffer -> texture copy of these formats same-ES-format when the
// widening engages.
if (TextureImpl::UsesWidenedPacked16NormStorage(internalFormat)) {
options |= PixelFormatNormalizeOptionBit::WidenPacked16Norm;
}
NormalizePixelFormat(requestedInternalFormat, options, outInternalFormat, outFormat, outType);
}
} // namespace
@@ -182,6 +192,36 @@ namespace MobileGL::MG_Backend::DirectGLES {
return options;
}
Bool UsesWidenedPacked16NormStorage(TextureInternalFormat internalFormat) {
switch (internalFormat) {
// TextureInternalFormat::RGB5 is both GL_RGB5 and GL_RGB565 - the GL-to-MG
// converter folds the two spellings onto one logical format.
case TextureInternalFormat::RGB5:
case TextureInternalFormat::RGB5A1:
case TextureInternalFormat::RGBA4:
break;
default:
return false;
}
switch (MG_Config::Features.EsprytWidenPacked16Storage) {
case MG_Config::QuirkOverride::ForceOn:
return true;
case MG_Config::QuirkOverride::ForceOff:
return false;
case MG_Config::QuirkOverride::Auto:
break;
}
// Behind the backend gate on purpose: the memoized probe latches its first answer
// for the whole process, and before the backend is up the GL function table may
// not be resolved yet - a probe run then would latch "cannot tell" as "clean"
// forever. Once the backend exists, the first narrow-format image this process
// creates runs the probe on a live context.
if (pActiveBackendObject == nullptr) {
return false;
}
return MG_Util::SelfTest::CopyImageMirrorsPacked16FieldOrder(g_GLESFuncs);
}
void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
GLenum* outFormat, GLenum* outType, TextureTarget target) {
#ifdef TRACY_ENABLE
+10 -1
View File
@@ -46,6 +46,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
Flags<PixelFormatNormalizeOptionBit> GetRenderTargetNormalizeOptions(
const MG_External::GLESCapabilities& capabilities, SizeT targetIndex);
// Whether this format's ES storage is widened to 8-bit-per-channel because the
// driver stores some packed16 allocations with a mirrored field order
// (PixelFormatNormalizeOptionBit::WidenPacked16Norm). True only for
// GL_RGB565/GL_RGB5(_A1)/GL_RGBA4, and only where the POST probe measured the
// divergence (or MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE forces it). The transfer paths
// consult it too: the packed-norm re-upload leg must stand down when the ES storage
// is no longer 16-bit packed.
Bool UsesWidenedPacked16NormStorage(TextureInternalFormat internalFormat);
void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
GLenum* outFormat, GLenum* outType,
TextureTarget target = TextureTarget::Unknown);
@@ -523,7 +532,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// avoidExplicitLodBias leaves lookups that already carry an explicit LOD untouched,
// so their constant level stays constant; only the implicit-LOD forms take the bias.
// Off by default and only ever set on ANGLE + llvmpipe, where injecting the uniform
// into a constant LOD crashes the driver (MOBILEGL_AVOID_EXPLICIT_LOD_BIAS).
// into a constant LOD crashes the driver (MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS).
String EmulateTextureLodBias(const String& glslCode, Bool avoidExplicitLodBias = false);
} // namespace PrgramImpl
@@ -624,7 +624,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (nonZeroIndirectBaseInstanceSupported) {
extensions.push_back(E_GL_ARB_base_instance);
}
if (shaderSubgroupSupported && !MG_Config::Features.DisableSubgroup) {
if (shaderSubgroupSupported && !MG_Config::Features.MagmaDisableSubgroup) {
extensions.push_back(E_GL_KHR_shader_subgroup);
}
// GL_KHR_parallel_shader_compile is MobileGL's own capability, not the Vulkan
@@ -1081,6 +1081,31 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// 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;
// shaderTessellationAndGeometryPointSize, both stage families from the one feature.
// False arms the shared phase-B point-size demotion, whose modules then carry no
// TessellationPointSize/GeometryPointSize capability and build without the feature.
// MOBILEGL_POINT_SIZE_DEMOTION=1 pretends it is absent so the demotion can be
// exercised on a healthy driver (lavapipe advertises the feature); =0 restores the
// detected answer's declines.
{
Bool supportsStagePointSize = m_vulkanCaps.SupportsTessellationAndGeometryPointSize;
switch (MG_Config::Features.PointSizeDemotion) {
case MG_Config::QuirkOverride::ForceOn:
MGLOG_I("DirectVulkan: MOBILEGL_POINT_SIZE_DEMOTION=1 - treating tessellation/geometry "
"gl_PointSize as unhosted so the demotion runs on this driver");
supportsStagePointSize = false;
break;
case MG_Config::QuirkOverride::ForceOff:
MGLOG_I("DirectVulkan: MOBILEGL_POINT_SIZE_DEMOTION=0 - keeping the built-in and the "
"plain declines regardless of the device feature");
supportsStagePointSize = true;
break;
case MG_Config::QuirkOverride::Auto:
break;
}
m_dynamicParameters.SupportsTessellationPointSize = supportsStagePointSize;
m_dynamicParameters.SupportsGeometryPointSize = supportsStagePointSize;
}
// 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
@@ -70,7 +70,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const RendererInfo& GetRendererIdentity();
// The full OpenGL extension list Magma advertises (glGetString(GL_EXTENSIONS)) for
// a device with the given raw capabilities. The MOBILEGL_DISABLE_SUBGROUP and
// a device with the given raw capabilities. The MOBILEGL_MAGMA_DISABLE_SUBGROUP and
// MOBILEGL_DISABLE_TIMERQUERY escape hatches are applied inside, so callers pass
// the detected device support (passing an already-gated value is harmless).
Vector<GLExtension> BuildAdvertisedExtensions(Bool shaderSubgroupSupported, Bool timerQueriesSupported,
@@ -1206,6 +1206,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
SharedPtr<VkTimerQueryManager::TimestampRecord> end;
// Kind::Occlusion - pool slots recorded between Begin/End; summed at result time.
Vector<Uint32> occlusionSlots;
// Kind::XfbGenerated - reroute-pool slots for the span's XFB-INACTIVE
// draws, where the renderer's reroute is armed (the affected driver's
// stream query counts nothing without an open capture; see
// VulkanRenderer::BeginXfbQueryForDraw). Summed alongside the stream
// slots above, which keep the span's XFB-active draws.
Vector<Uint32> rerouteSlots;
// Renderer generation the records were written under (see
// g_rendererGeneration). A stale generation resolves as available
// with a final zero result: the records' pool indices and frame
@@ -1215,11 +1221,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// stale queries are always safe to delete.
Uint64 rendererGeneration = 0;
// Kind::XfbGenerated - the frontend's paused-draw primitive counter when the
// query began. VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT counts only what the
// capture saw, so a draw made while the span was paused is invisible to it -
// but GL_PRIMITIVES_GENERATED counts what the last vertex processing stage
// emitted regardless. The delta closes that gap at result time.
// query began. On the affected drivers VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT
// counts only what the capture saw, so a draw made while the span was paused is
// invisible to it - but GL_PRIMITIVES_GENERATED counts what the last vertex
// processing stage emitted regardless. The delta closes that gap at result time.
Uint64 pausedPrimitiveSnapshot = 0;
// ...unless the GPU already counted those paused draws when the span opened -
// through the reroute pool (VulkanRenderer::BeginXfbQueryForDraw reroutes every
// draw with no open capture, paused ones included) or, where the probe measured
// the stream query as counting capture-less draws, through the stream slot the
// paused draw still takes. Adding the CPU delta on top would count them twice,
// and the CPU counter is the weaker source anyway: only 3 of the ~15 draw entry
// points write it and it answers 0 for GL_PATCHES.
Bool pausedPrimitivesCountedByGpu = false;
};
} // namespace
@@ -1313,12 +1327,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (query->kind == VulkanTimerQuery::Kind::XfbWritten ||
query->kind == VulkanTimerQuery::Kind::XfbGenerated) {
Uint64 primitives = 0;
if (!pVulkanRenderer->ResolveXfbQueryResult(query->occlusionSlots,
if (!pVulkanRenderer->ResolveXfbQueryResult(query->occlusionSlots, query->rerouteSlots,
query->kind == VulkanTimerQuery::Kind::XfbGenerated,
primitives)) {
return false;
}
if (query->kind == VulkanTimerQuery::Kind::XfbGenerated && MG_State::pGLContext != nullptr) {
if (query->kind == VulkanTimerQuery::Kind::XfbGenerated &&
!query->pausedPrimitivesCountedByGpu && MG_State::pGLContext != nullptr) {
primitives += MG_State::pGLContext->GetTransformFeedbackPausedPrimitiveCounter() -
query->pausedPrimitiveSnapshot;
}
@@ -1367,6 +1382,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
query->rendererGeneration = GetRendererGeneration();
query->pausedPrimitiveSnapshot =
MG_State::pGLContext ? MG_State::pGLContext->GetTransformFeedbackPausedPrimitiveCounter() : 0;
// Read AFTER StartXfbQueryCapture, which is where a failed reroute-pool creation
// disarms: the answer is then what this span will actually do for every draw.
query->pausedPrimitivesCountedByGpu = generated && pVulkanRenderer->ArePausedDrawsGpuCounted();
return query;
}
@@ -1377,7 +1395,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return;
}
pVulkanRenderer->StopXfbQueryCapture(
query->kind == VulkanTimerQuery::Kind::XfbGenerated ? 1u : 0u, query->occlusionSlots);
query->kind == VulkanTimerQuery::Kind::XfbGenerated ? 1u : 0u, query->occlusionSlots,
query->rerouteSlots);
}
BackendQueryHandle BeginOcclusionQuery() {
@@ -1428,6 +1428,22 @@ namespace MobileGL::MG_Backend::DirectVulkan {
continue;
}
if (varying.name == "gl_PointSize") {
// A demoted module (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) no longer ACCESSES the
// built-in member - the value lives in the carrier variable the demotion
// named - so the capture binds to the carrier directly. The mirror below
// must not run for it: reading the now-unwritten member would capture
// garbage, and the read itself is the capability access the demotion
// exists to remove. Detected off the module's own debug names, so a
// composite built from another program's stage answers for the module it
// actually contains.
const auto carrierIt = idsByName.find(
MG_Util::ShaderTranspiler::ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME);
if (carrierIt != idsByName.end()) {
decorateForXfb(carrierIt->second, varying.bufferIndex, varying.offsetBytes);
modified = true;
continue;
}
needsPointSizeMirror = true;
pointSizeBufferIndex = varying.bufferIndex;
pointSizeOffset = varying.offsetBytes;
@@ -3454,6 +3470,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// `spirv` and `moduleSpirvs` for any program attached to after it linked.
const Vector<ShaderStage> stages = program.GetLinkedShaderStages();
auto& spirv = program.GetGeneratedSpirv();
if (program.PointSizeDemoted()) {
// THE ARMING SIGNAL, INFO on purpose and latched: the integration lane that pins
// MOBILEGL_POINT_SIZE_DEMOTION=1 asserts on exactly this line, because every
// rendering assertion above it stays green on a healthy driver whether the
// demotion ran or was silently disarmed. See PointSizeDemotionScenario.
MGLOG_I_ONCE("DirectVulkan is building programs whose tessellation/geometry gl_PointSize was "
"demoted to an ordinary varying, because this device cannot host the built-in "
"in those stages.");
}
Vector<Vector<Uint>> moduleSpirvs(spirv.size());
const Bool enableSpirvValidation = program.GetSpirvValidationEnabled();
// Unconditional now: the two ValidateTransformedSpirv calls below run in every build,
@@ -28,6 +28,7 @@
#include "MG_Util/Converters/MGToVk/TextureEnumConverter.h"
#include "MG_Util/Math/HalfFloat.h"
#include "MG_Util/Metrics/TextureMetrics.h"
#include "MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h"
#include "MG_Util/Texture/PixelStoreProcessor.h"
#include <Config.h>
#include <algorithm>
@@ -3277,6 +3278,17 @@ void main() {
vkDestroyQueryPool(m_device, m_xfbQueryPool, nullptr);
m_xfbQueryPool = VK_NULL_HANDLE;
}
if (m_primGenReroutePool != VK_NULL_HANDLE) {
vkDestroyQueryPool(m_device, m_primGenReroutePool, nullptr);
m_primGenReroutePool = VK_NULL_HANDLE;
}
m_primGenRerouteActiveSlots.clear();
m_primGenRerouteSlotCursor = 0;
m_primGenRerouteSlotOpen = false;
// Not sticky across renderers: the next bring-up re-decides both (from the
// per-process probe memo, so it re-decides without re-probing).
m_primGenRerouteKind = MG_Util::SelfTest::PrimGenRerouteKind::None;
m_primGenStreamCountsXfbInactiveDraws = false;
m_bufferManager.Shutdown();
// Device is idle (vkDeviceWaitIdle above); query pools can be destroyed.
@@ -10703,19 +10715,30 @@ void main() {
"GetTexImage: failed to materialize pending clear for textureId=%d",
textureObject->GetExternalIndex());
// WHICH FACE the caller asked for. glGetTexImage names one face of a cube map through the
// TARGET token (GL_TEXTURE_CUBE_MAP_NEGATIVE_X and friends, GL 4.6 core 8.11), and a cube
// map's six faces are its VkImage's six ARRAY LAYERS - so unless the token is turned into a
// baseArrayLayer, every face token reads layer 0 and the whole cube answers as +X. The
// image's own target cannot supply this: a plain GL_TEXTURE_CUBE_MAP is not an array target,
// so the layer arithmetic below leaves it at one layer starting at zero, which is precisely
// the layer this face index has to displace. Same conversion, same reason, as
// VkClearManager's / VkRenderPassManager's ResolveAttachmentBaseArrayLayer, which resolve an
// ATTACHMENT's face; this is the readback's copy of it. Zero for every other target,
// including a cube map ARRAY - that one arrives as TextureUploadTarget::CubeMapArray with
// its layer-faces already counted in the level's z, not as a face token.
const Bool isCubeFaceTarget = textureUploadTarget >= TextureUploadTarget::CubeMapPositiveX &&
textureUploadTarget <= TextureUploadTarget::CubeMapNegativeZ;
const Int glCubeFaceLayer = isCubeFaceTarget
? static_cast<Int>(textureUploadTarget) - static_cast<Int>(TextureUploadTarget::CubeMapPositiveX)
: 0;
if ((resource->aspect & VK_IMAGE_ASPECT_COLOR_BIT) == 0) {
if (format == GL_DEPTH_COMPONENT || format == GL_DEPTH_STENCIL || format == GL_STENCIL_INDEX) {
const auto levelSize =
textureMipmapObject->GetMipmapTexelSize(textureUploadTarget, static_cast<Uint>(level));
const Bool isCubeFace = textureUploadTarget >= TextureUploadTarget::CubeMapPositiveX &&
textureUploadTarget <= TextureUploadTarget::CubeMapNegativeZ;
// Storage space: `resource` is the storage texture's, so a view's level and
// layer have to be shifted into its numbering (see ToStorageMipLevel).
const Int glArrayLayer = isCubeFace
? static_cast<Int>(textureUploadTarget) -
static_cast<Int>(TextureUploadTarget::CubeMapPositiveX)
: 0;
const Uint32 arrayLayer = ToStorageArrayLayer(textureObject.get(), glArrayLayer);
const Uint32 arrayLayer = ToStorageArrayLayer(textureObject.get(), glCubeFaceLayer);
const Uint32 storageLevel = ToStorageMipLevel(textureObject.get(), level);
// 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
@@ -10810,7 +10833,10 @@ void main() {
// Storage space, as above: a texture view reads its own level 0 out of whichever level
// and layer of the parent it opened onto.
copyRegion.imageSubresource.mipLevel = ToStorageMipLevel(textureObject.get(), level);
copyRegion.imageSubresource.baseArrayLayer = ToStorageArrayLayer(textureObject.get(), 0);
// glCubeFaceLayer, not 0: the cube face the target token named (see above). Non-zero for
// exactly one shape - a plain cube map read one face at a time - and layerCount is 1 there,
// so the copy stays inside the six layers the image has.
copyRegion.imageSubresource.baseArrayLayer = ToStorageArrayLayer(textureObject.get(), glCubeFaceLayer);
copyRegion.imageSubresource.layerCount = static_cast<Uint32>(arrayLayers);
copyRegion.imageExtent = {static_cast<Uint32>(width),
is1dArrayImage ? 1u : static_cast<Uint32>(height),
@@ -11285,7 +11311,7 @@ void main() {
VkCommandBuffer& commandBuffer = frame.commandBuffer;
const Bool xfbActive = BeginXfbCaptureForDraw(frame);
BeginXfbQueryForDraw(commandBuffer);
BeginXfbQueryForDraw(commandBuffer, xfbActive);
const Bool occlusionActive = BeginOcclusionForDraw(commandBuffer);
vkCmdDraw(commandBuffer,
payload.params.vertexCount,
@@ -11371,23 +11397,81 @@ void main() {
}
s_vkResetQueryPool(m_device, m_xfbQueryPool, 0, kXfbQuerySlots);
}
// The reroute pool, on the first GENERATED span that needs it. A creation
// failure disarms rather than failing the capture: the stream path still
// answers (with the driver's defect), which beats answering nothing.
if (kind == 1 && m_primGenRerouteKind != MG_Util::SelfTest::PrimGenRerouteKind::None &&
m_primGenReroutePool == VK_NULL_HANDLE) {
VkQueryPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
poolInfo.queryCount = kXfbQuerySlots;
if (m_primGenRerouteKind == MG_Util::SelfTest::PrimGenRerouteKind::PrimitivesGeneratedExt) {
// The query Vulkan defines for this GL target; counts vertex stream 0
// when begun with plain vkCmdBeginQuery.
poolInfo.queryType = VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT;
} else {
poolInfo.queryType = VK_QUERY_TYPE_PIPELINE_STATISTICS;
// The clipping-stage INVOCATION counter: one per primitive reaching
// primitive clipping (GL's CLIPPING_INPUT_PRIMITIVES) - post-tess/GS,
// pre-clip, and per spec still counted under rasterizer discard, which
// is exactly the set GL_PRIMITIVES_GENERATED is defined over. The
// stage's OUTPUT count (CLIPPING_PRIMITIVES_BIT) would be wrong:
// clipping may drop or split primitives.
poolInfo.pipelineStatistics = VK_QUERY_PIPELINE_STATISTIC_CLIPPING_INVOCATIONS_BIT;
}
if (vkCreateQueryPool(m_device, &poolInfo, nullptr, &m_primGenReroutePool) != VK_SUCCESS) {
MGLOG_E_ONCE("StartXfbQueryCapture: reroute pool creation failed; the "
"PRIMITIVES_GENERATED reroute is disarmed and XFB-inactive draws keep "
"the stream query");
m_primGenReroutePool = VK_NULL_HANDLE;
m_primGenRerouteKind = MG_Util::SelfTest::PrimGenRerouteKind::None;
} else {
s_vkResetQueryPool(m_device, m_primGenReroutePool, 0, kXfbQuerySlots);
}
}
m_xfbQueryActiveSlots[kind].clear();
m_xfbQueryCaptureActive[kind] = true;
if (kind == 1) {
m_primGenRerouteActiveSlots.clear();
}
return true;
}
void VulkanRenderer::StopXfbQueryCapture(Uint32 kind, Vector<Uint32>& outSlots) {
Bool VulkanRenderer::ArePausedDrawsGpuCounted() const {
// Exactly the gate BeginXfbQueryForDraw applies per draw, so a span told "armed"
// really does get a reroute slot for every draw with no open capture - a paused
// span's draws included.
const Bool rerouteArmed = m_primGenRerouteKind != MG_Util::SelfTest::PrimGenRerouteKind::None &&
m_primGenReroutePool != VK_NULL_HANDLE;
// Otherwise the paused draw takes a stream slot, which is an exact count of it
// on a driver the probe measured as counting capture-less draws.
return rerouteArmed || m_primGenStreamCountsXfbInactiveDraws;
}
void VulkanRenderer::StopXfbQueryCapture(Uint32 kind, Vector<Uint32>& outSlots,
Vector<Uint32>& outRerouteSlots) {
if (kind > 1) {
return;
}
outSlots = Move(m_xfbQueryActiveSlots[kind]);
m_xfbQueryActiveSlots[kind].clear();
m_xfbQueryCaptureActive[kind] = false;
outRerouteSlots.clear();
if (kind == 1) {
outRerouteSlots = Move(m_primGenRerouteActiveSlots);
m_primGenRerouteActiveSlots.clear();
}
}
Bool VulkanRenderer::ResolveXfbQueryResult(const Vector<Uint32>& slots, Bool wantGenerated, Uint64& outPrimitives) {
Bool VulkanRenderer::ResolveXfbQueryResult(const Vector<Uint32>& slots, const Vector<Uint32>& rerouteSlots,
Bool wantGenerated, Uint64& outPrimitives) {
outPrimitives = 0;
if (slots.empty() || m_xfbQueryPool == VK_NULL_HANDLE) {
const Bool haveStreamSlots = !slots.empty() && m_xfbQueryPool != VK_NULL_HANDLE;
// Reroute slots only ever accumulate the GENERATED target (see
// BeginXfbQueryForDraw); WRITTEN never opens one.
const Bool haveRerouteSlots =
wantGenerated && !rerouteSlots.empty() && m_primGenReroutePool != VK_NULL_HANDLE;
if (!haveStreamSlots && !haveRerouteSlots) {
return true;
}
auto& frame = m_frameContext.GetCurrent();
@@ -11399,44 +11483,108 @@ void main() {
return false;
}
}
for (const Uint32 slot : slots) {
Uint64 pair[2] = {0, 0}; // {primitivesWritten, primitivesNeeded}
const VkResult result =
vkGetQueryPoolResults(m_device, m_xfbQueryPool, slot, 1, sizeof(pair), pair, sizeof(pair),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT);
if (result == VK_SUCCESS) {
outPrimitives += pair[wantGenerated ? 1 : 0];
if (haveStreamSlots) {
for (const Uint32 slot : slots) {
Uint64 pair[2] = {0, 0}; // {primitivesWritten, primitivesNeeded}
const VkResult result =
vkGetQueryPoolResults(m_device, m_xfbQueryPool, slot, 1, sizeof(pair), pair, sizeof(pair),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT);
if (result == VK_SUCCESS) {
outPrimitives += pair[wantGenerated ? 1 : 0];
}
}
}
if (haveRerouteSlots) {
for (const Uint32 slot : rerouteSlots) {
// Both reroute pool kinds answer one 64-bit primitive count per slot.
Uint64 generated = 0;
const VkResult result = vkGetQueryPoolResults(
m_device, m_primGenReroutePool, slot, 1, sizeof(generated), &generated,
sizeof(generated), VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT);
if (result == VK_SUCCESS) {
outPrimitives += generated;
}
}
}
return true;
}
void VulkanRenderer::BeginXfbQueryForDraw(VkCommandBuffer commandBuffer) {
void VulkanRenderer::BeginXfbQueryForDraw(VkCommandBuffer commandBuffer, Bool xfbActive) {
m_xfbQuerySlotOpen = false;
m_primGenRerouteSlotOpen = false;
if ((!m_xfbQueryCaptureActive[0] && !m_xfbQueryCaptureActive[1]) || m_xfbQueryPool == VK_NULL_HANDLE) {
return;
}
const Uint32 slot = m_xfbQuerySlotCursor;
m_xfbQuerySlotCursor = (m_xfbQuerySlotCursor + 1) % kXfbQuerySlots;
// Slots are never host-reset at read time (both GL targets may reference one
// slot); recycle them here instead.
s_vkResetQueryPool(m_device, m_xfbQueryPool, slot, 1);
s_vkCmdBeginQueryIndexedEXT(commandBuffer, m_xfbQueryPool, slot, 0, 0);
for (Uint32 kind = 0; kind < 2; ++kind) {
if (m_xfbQueryCaptureActive[kind]) {
m_xfbQueryActiveSlots[kind].push_back(slot);
// Every draw with no OPEN capture is the stream query's silent case, and that
// includes a draw made while the GL span is merely PAUSED (the pause closes the
// capture, so BeginXfbCaptureForDraw already answered false for it). Paused
// draws are rerouted like any other: the frontend's CPU paused-primitive
// counter cannot stand in for them - it is written by only 3 of the ~15 draw
// entry points (never the instanced, indirect or multi-draw ones) and answers 0
// for GL_PATCHES by design, since the tessellator's amplification is not
// knowable on the CPU - which is exactly the CTS's shape. Double counting is
// prevented on the other side instead: a GENERATED span opened while this
// reroute is armed ignores that CPU counter entirely (see
// ArePausedDrawsGpuCounted and DirectVulkan.cpp's XfbGenerated resolve), so
// every XFB-inactive draw in the span is priced exactly once, by this pool.
const Bool rerouteGenerated = m_xfbQueryCaptureActive[1] &&
m_primGenRerouteKind != MG_Util::SelfTest::PrimGenRerouteKind::None &&
m_primGenReroutePool != VK_NULL_HANDLE && !xfbActive;
// The stream slot stays for WRITTEN whatever the reroute does (with capture
// inactive its primitivesWritten is 0, which is the correct WRITTEN answer),
// and for GENERATED wherever this draw is not rerouted - so one GL query span
// may accumulate stream slots (XFB-active draws) and reroute slots
// (XFB-inactive draws) side by side.
const Bool wantStreamSlot =
m_xfbQueryCaptureActive[0] || (m_xfbQueryCaptureActive[1] && !rerouteGenerated);
if (wantStreamSlot) {
const Uint32 slot = m_xfbQuerySlotCursor;
m_xfbQuerySlotCursor = (m_xfbQuerySlotCursor + 1) % kXfbQuerySlots;
// Slots are never host-reset at read time (both GL targets may reference one
// slot); recycle them here instead.
s_vkResetQueryPool(m_device, m_xfbQueryPool, slot, 1);
s_vkCmdBeginQueryIndexedEXT(commandBuffer, m_xfbQueryPool, slot, 0, 0);
if (m_xfbQueryCaptureActive[0]) {
m_xfbQueryActiveSlots[0].push_back(slot);
}
if (m_xfbQueryCaptureActive[1] && !rerouteGenerated) {
m_xfbQueryActiveSlots[1].push_back(slot);
}
m_xfbQuerySlotOpen = true;
m_xfbQueryOpenSlot = slot;
}
if (rerouteGenerated) {
// Latched at INFO on purpose: it is the pinned integration lane's arming
// observable (the shape UnlocatedIoBlockScenario asserts), and the builds
// CI runs compile INFO in.
MGLOG_I_ONCE("PRIMITIVES_GENERATED reroute engaged: an XFB-inactive draw accumulates "
"through the %s pool",
m_primGenRerouteKind ==
MG_Util::SelfTest::PrimGenRerouteKind::PrimitivesGeneratedExt
? "VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT"
: "clipping-invocations statistics");
const Uint32 slot = m_primGenRerouteSlotCursor;
m_primGenRerouteSlotCursor = (m_primGenRerouteSlotCursor + 1) % kXfbQuerySlots;
// Same recycle-at-begin discipline as the stream pool. Both pool kinds
// are begun with plain vkCmdBeginQuery (a PRIMITIVES_GENERATED_EXT
// query begun this way counts vertex stream 0).
s_vkResetQueryPool(m_device, m_primGenReroutePool, slot, 1);
vkCmdBeginQuery(commandBuffer, m_primGenReroutePool, slot, 0);
m_primGenRerouteActiveSlots.push_back(slot);
m_primGenRerouteSlotOpen = true;
m_primGenRerouteOpenSlot = slot;
}
m_xfbQuerySlotOpen = true;
m_xfbQueryOpenSlot = slot;
}
void VulkanRenderer::EndXfbQueryForDraw(VkCommandBuffer commandBuffer) {
if (!m_xfbQuerySlotOpen) {
return;
if (m_xfbQuerySlotOpen) {
s_vkCmdEndQueryIndexedEXT(commandBuffer, m_xfbQueryPool, m_xfbQueryOpenSlot, 0);
m_xfbQuerySlotOpen = false;
}
if (m_primGenRerouteSlotOpen) {
vkCmdEndQuery(commandBuffer, m_primGenReroutePool, m_primGenRerouteOpenSlot);
m_primGenRerouteSlotOpen = false;
}
s_vkCmdEndQueryIndexedEXT(commandBuffer, m_xfbQueryPool, m_xfbQueryOpenSlot, 0);
m_xfbQuerySlotOpen = false;
}
Bool VulkanRenderer::BeginOcclusionForDraw(VkCommandBuffer commandBuffer) {
@@ -11486,7 +11634,7 @@ void main() {
VkCommandBuffer& commandBuffer = frame.commandBuffer;
const Bool xfbActive = BeginXfbCaptureForDraw(frame);
BeginXfbQueryForDraw(commandBuffer);
BeginXfbQueryForDraw(commandBuffer, xfbActive);
const Bool occlusionActive = BeginOcclusionForDraw(commandBuffer);
vkCmdDrawIndexed(commandBuffer,
payload.params.indexCount,
@@ -13200,8 +13348,8 @@ void main() {
// Match GL's robust buffer-fetch behavior where the Vulkan device supports it. This covers
// out-of-range fetches; arbitrary GL vertex strides/offsets still need the explicit tight
// repack in VertexInputStateFactory when they violate Vulkan's address-alignment rules.
// MOBILEGL_DISABLE_ROBUST_BUFFER_ACCESS leaves it off to measure or dodge its GPU cost.
deviceFeatures.robustBufferAccess = MG_Config::Features.DisableRobustBufferAccess
// MOBILEGL_MAGMA_DISABLE_ROBUST_BUFFER_ACCESS leaves it off to measure or dodge its GPU cost.
deviceFeatures.robustBufferAccess = MG_Config::Features.MagmaDisableRobustBufferAccess
? VK_FALSE
: supportedDeviceFeatures.robustBufferAccess;
deviceFeatures.geometryShader = supportedDeviceFeatures.geometryShader;
@@ -13319,6 +13467,13 @@ void main() {
// occlusion result still satisfies any-samples-style consumers.
deviceFeatures.occlusionQueryPrecise = supportedDeviceFeatures.occlusionQueryPrecise;
m_occlusionQueryPreciseEnabled = deviceFeatures.occlusionQueryPrecise == VK_TRUE;
m_tessellationShaderFeatureEnabled = deviceFeatures.tessellationShader == VK_TRUE;
// Backs the GL_PRIMITIVES_GENERATED reroute's statistics tier (see the
// m_primGenReroute* members): a VK_QUERY_TYPE_PIPELINE_STATISTICS pool may only
// be created with this feature enabled. Enabled wherever the device has it - the
// feature alone costs nothing; pools exist only where the reroute is armed.
deviceFeatures.pipelineStatisticsQuery = supportedDeviceFeatures.pipelineStatisticsQuery;
m_pipelineStatisticsQueryFeatureEnabled = deviceFeatures.pipelineStatisticsQuery == VK_TRUE;
VkDeviceCreateInfo deviceCreateInfo{};
deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
@@ -13576,13 +13731,13 @@ void main() {
subgroupPropertyQuery.pNext = &subgroupProperties;
getPhysicalDeviceProperties2(m_physicalDevice.handle, &subgroupPropertyQuery);
// Mirrors the loader's HasUsableShaderSubgroupSupport gate, including the
// MOBILEGL_DISABLE_SUBGROUP escape hatch, so the module lowerings can never
// MOBILEGL_MAGMA_DISABLE_SUBGROUP escape hatch, so the module lowerings can never
// disagree with the advertised capabilities.
const Bool usableSubgroups =
subgroupProperties.subgroupSize > 0 &&
(subgroupProperties.supportedStages & VK_SHADER_STAGE_COMPUTE_BIT) != 0 &&
(subgroupProperties.supportedOperations & VK_SUBGROUP_FEATURE_BASIC_BIT) != 0;
if (usableSubgroups && !MG_Config::Features.DisableSubgroup) {
if (usableSubgroups && !MG_Config::Features.MagmaDisableSubgroup) {
m_nativeSubgroupSize = subgroupProperties.subgroupSize;
m_nativeSubgroupSupported = true;
}
@@ -13646,6 +13801,38 @@ void main() {
MGLOG_I("Enabled optional device extension: %s", VK_EXT_TRANSFORM_FEEDBACK_EXTENSION_NAME);
}
}
// VK_EXT_primitives_generated_query - the query Vulkan defines for GL's
// GL_PRIMITIVES_GENERATED precisely because the stream query above needs no
// capture by spec but drivers disagree. Taken with BOTH the base feature and the
// rasterizer-discard feature or not at all: without the latter, a discarding draw
// inside the query is invalid usage, and GL applications toggle discard freely.
// Only the PRIMITIVES_GENERATED reroute consumes it (see ArmPrimGenReroute).
m_primitivesGeneratedQueryFeatureEnabled = false;
m_primitivesGeneratedQueryDiscardFeatureEnabled = false;
VkPhysicalDevicePrimitivesGeneratedQueryFeaturesEXT primitivesGeneratedQueryFeatures{};
primitivesGeneratedQueryFeatures.sType =
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRIMITIVES_GENERATED_QUERY_FEATURES_EXT;
if (IsExtensionSupported(availableExtensions, VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME) &&
getPhysicalDeviceFeatures2 != nullptr) {
VkPhysicalDeviceFeatures2 featureQuery{};
featureQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
featureQuery.pNext = &primitivesGeneratedQueryFeatures;
getPhysicalDeviceFeatures2(m_physicalDevice.handle, &featureQuery);
if (primitivesGeneratedQueryFeatures.primitivesGeneratedQuery == VK_TRUE &&
primitivesGeneratedQueryFeatures.primitivesGeneratedQueryWithRasterizerDiscard == VK_TRUE) {
if (!IsExtensionAlreadyEnabled(enabledDeviceExtensions,
VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME)) {
enabledDeviceExtensions.push_back(VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME);
}
primitivesGeneratedQueryFeatures.primitivesGeneratedQueryWithNonZeroStreams = VK_FALSE;
primitivesGeneratedQueryFeatures.pNext = const_cast<void*>(deviceCreateInfo.pNext);
deviceCreateInfo.pNext = &primitivesGeneratedQueryFeatures;
m_primitivesGeneratedQueryFeatureEnabled = true;
m_primitivesGeneratedQueryDiscardFeatureEnabled = true;
MGLOG_I("Enabled optional device extension: %s",
VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME);
}
}
// VK_EXT_provoking_vertex. Two independent features live behind one extension:
// provokingVertexLast -> flat varyings, gl_Layer/gl_ViewportIndex and
// the input-assembler capture order.
@@ -13995,6 +14182,123 @@ void main() {
m_timerQuerySupported = m_timestampValidBits > 0 && m_timestampPeriodNs > 0.0f;
MGLOG_I("Timer queries %s (timestampValidBits=%u, timestampPeriod=%f ns/tick)",
m_timerQuerySupported ? "supported" : "not supported", m_timestampValidBits, m_timestampPeriodNs);
// Last, because it records on m_graphicsQueue: decide the PRIMITIVES_GENERATED
// reroute for XFB-inactive draws. Nothing else has touched the queue yet.
ArmPrimGenReroute();
}
void VulkanRenderer::ArmPrimGenReroute() {
using namespace MG_Util::SelfTest;
m_primGenRerouteKind = PrimGenRerouteKind::None;
const MG_Config::QuirkOverride overrideSetting = MG_Config::Features.MagmaPrimGenQueryReroute;
// Without stream queries the GENERATED path never opens a slot at all, so
// there is nothing to reroute - whatever the override says.
if (!m_xfbQueriesSupported || !m_hostQueryResetEnabled) {
return;
}
const Bool primitivesGeneratedQueryUsable =
m_primitivesGeneratedQueryFeatureEnabled && m_primitivesGeneratedQueryDiscardFeatureEnabled;
PrimitivesGeneratedNoXfbVerdict verdict = PrimitivesGeneratedNoXfbVerdict::Inconclusive;
// The probe only matters under Auto (ForceOn bypasses the verdict, ForceOff
// never asks), and the answer is a device property - so it is memoized per
// process rather than re-paid on every renderer recreation.
if (overrideSetting == MG_Config::QuirkOverride::Auto) {
static const PrimitivesGeneratedNoXfbMeasurement s_measurement = [&]() {
PrimitivesGeneratedNoXfbProbeContext probeContext;
probeContext.device = m_device;
probeContext.queue = m_graphicsQueue;
probeContext.queueFamilyIndex =
static_cast<Uint32>(m_physicalDevice.queueFamilies.graphicsFamily);
probeContext.transformFeedbackQueriesUsable = m_xfbQueriesSupported;
probeContext.primitivesGeneratedQueryUsable = primitivesGeneratedQueryUsable;
probeContext.pipelineStatisticsEnabled = m_pipelineStatisticsQueryFeatureEnabled;
probeContext.tessellationEnabled = m_tessellationShaderFeatureEnabled;
auto& fns = probeContext.fns;
fns.vkCreateCommandPool = vkCreateCommandPool;
fns.vkDestroyCommandPool = vkDestroyCommandPool;
fns.vkAllocateCommandBuffers = vkAllocateCommandBuffers;
fns.vkBeginCommandBuffer = vkBeginCommandBuffer;
fns.vkEndCommandBuffer = vkEndCommandBuffer;
fns.vkCreateQueryPool = vkCreateQueryPool;
fns.vkDestroyQueryPool = vkDestroyQueryPool;
fns.vkCmdResetQueryPool = vkCmdResetQueryPool;
fns.vkCmdBeginQuery = vkCmdBeginQuery;
fns.vkCmdEndQuery = vkCmdEndQuery;
fns.vkCmdBeginQueryIndexedEXT = s_vkCmdBeginQueryIndexedEXT;
fns.vkCmdEndQueryIndexedEXT = s_vkCmdEndQueryIndexedEXT;
fns.vkCreateRenderPass = vkCreateRenderPass;
fns.vkDestroyRenderPass = vkDestroyRenderPass;
fns.vkCreateFramebuffer = vkCreateFramebuffer;
fns.vkDestroyFramebuffer = vkDestroyFramebuffer;
fns.vkCmdBeginRenderPass = vkCmdBeginRenderPass;
fns.vkCmdEndRenderPass = vkCmdEndRenderPass;
fns.vkCreateShaderModule = vkCreateShaderModule;
fns.vkDestroyShaderModule = vkDestroyShaderModule;
fns.vkCreatePipelineLayout = vkCreatePipelineLayout;
fns.vkDestroyPipelineLayout = vkDestroyPipelineLayout;
fns.vkCreateGraphicsPipelines = vkCreateGraphicsPipelines;
fns.vkDestroyPipeline = vkDestroyPipeline;
fns.vkCmdBindPipeline = vkCmdBindPipeline;
fns.vkCmdDraw = vkCmdDraw;
fns.vkCreateFence = vkCreateFence;
fns.vkDestroyFence = vkDestroyFence;
fns.vkQueueSubmit = vkQueueSubmit;
fns.vkWaitForFences = vkWaitForFences;
fns.vkGetQueryPoolResults = vkGetQueryPoolResults;
fns.vkDeviceWaitIdle = vkDeviceWaitIdle;
return RunPrimitivesGeneratedNoXfbProbe(probeContext);
}();
verdict = EvaluatePrimitivesGeneratedNoXfbVerdict(s_measurement);
if (s_measurement.fenceWaitTimedOut) {
// The probe's submission never signaled within its bound, so it left its
// command pool, query pools, render pass, framebuffer, shader modules,
// pipeline layout, pipelines and fence alive on purpose. This device is the
// renderer's own and outlives them, so nothing here may destroy them or
// wait the device idle - the queue may still be executing that submission,
// and an idle wait is the hang the bound exists to prevent. They leak for
// the process's life; a device this sick has bigger problems.
MGLOG_W("PRIMITIVES_GENERATED probe timed out waiting on its own submission (%s); its "
"Vulkan objects are deliberately leaked and XFB-inactive draws keep the stream "
"query", s_measurement.failureReason.c_str());
} else if (!s_measurement.ran) {
MGLOG_W("PRIMITIVES_GENERATED probe did not run (%s); XFB-inactive draws keep the "
"stream query", s_measurement.failureReason.c_str());
} else {
const auto logShape = [](const char* name,
const MG_Util::SelfTest::PrimitivesGeneratedNoXfbShapeMeasurement&
shape) {
MGLOG_I("PRIMITIVES_GENERATED probe %s: drawn=%d stream=%llu/%llu pgq=%llu(%d) "
"stat=%llu(%d)",
name, shape.drawn ? 1 : 0,
static_cast<unsigned long long>(shape.streamGenerated),
static_cast<unsigned long long>(shape.expectedPrimitives),
static_cast<unsigned long long>(shape.primitivesGeneratedExt),
shape.primitivesGeneratedExtMeasured ? 1 : 0,
static_cast<unsigned long long>(shape.statisticsClippingInput),
shape.statisticsMeasured ? 1 : 0);
};
logShape("triangles", s_measurement.trianglesPlain);
logShape("triangles+discard", s_measurement.trianglesDiscard);
logShape("patches+discard", s_measurement.patchesDiscard);
}
}
// A driver whose stream query counts capture-less draws counts a PAUSED span's
// draws through the stream slot they take, so that span's result must not have
// the frontend's CPU paused counter added on top of it either (the pre-reroute
// accounting did exactly that, double counting every paused draw the CPU could
// price). Measured, not assumed: the forced arms never ask the probe and leave
// this false.
m_primGenStreamCountsXfbInactiveDraws = verdict == PrimitivesGeneratedNoXfbVerdict::StreamCounts;
m_primGenRerouteKind = ChoosePrimitivesGeneratedReroute(
overrideSetting, verdict, primitivesGeneratedQueryUsable, m_pipelineStatisticsQueryFeatureEnabled);
if (m_primGenRerouteKind != PrimGenRerouteKind::None) {
MGLOG_I("PRIMITIVES_GENERATED for XFB-inactive draws will accumulate through a %s pool%s",
m_primGenRerouteKind == PrimGenRerouteKind::PrimitivesGeneratedExt
? "VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT"
: "clipping-invocations pipeline-statistics",
overrideSetting == MG_Config::QuirkOverride::ForceOn ? " (forced on)" : "");
}
}
void VulkanRenderer::CreateAllocator() {
@@ -24,6 +24,7 @@
#include "MG_Util/Math/VectorTypes.h"
#include <Includes.h>
#include <MG_Backend/BackendObject.h>
#include <MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h>
#include <vk_mem_alloc.h>
#include "../VkIncludes.h"
@@ -563,7 +564,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Native subgroup topology, queried at device creation for the compute-module
// subgroup repairs (SubgroupSupportPolicy.h) and the REQUIRE_FULL_SUBGROUPS
// stage flag; 0 / false when the device has no usable compute subgroups or
// MOBILEGL_DISABLE_SUBGROUP forced them off.
// MOBILEGL_MAGMA_DISABLE_SUBGROUP forced them off.
Uint32 m_nativeSubgroupSize = 0;
Bool m_nativeSubgroupSupported = false;
Bool m_computeFullSubgroupsFeatureEnabled = false;
@@ -717,15 +718,74 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Vector<Uint32> m_xfbQueryActiveSlots[2];
Bool m_xfbQuerySlotOpen = false;
Uint32 m_xfbQueryOpenSlot = 0;
// GL_PRIMITIVES_GENERATED reroute for draws made while transform feedback is
// INACTIVE. The stream pool's primitivesNeeded is defined to count those draws
// too, but a Mali driver (and Mesa lavapipe) answers 0 unless a capture span
// is open (the CTS's tessellator-measuring shape). Where the bring-up probe
// finds that defect with a working control - or
// MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE forces it - such draws accumulate the
// GENERATED count through this pool instead, whose type the arming picks:
// VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT where the device hosts the dedicated
// query with its rasterizer-discard feature (exact semantics by definition -
// the extension exists because GL needs this count without a capture), else a
// VK_QUERY_TYPE_PIPELINE_STATISTICS pool over clipping-stage invocations (one
// per primitive reaching primitive clipping - after every vertex processing
// stage, before rasterizer discard - which is the same set).
// XFB-ACTIVE draws keep the stream slot (exact today, and WRITTEN needs it);
// every draw with no open capture - a PAUSED span's draws included - takes a
// reroute slot, and the span then ignores the frontend's CPU paused-primitive
// counter rather than adding it on top (see IsPrimGenRerouteArmed): that
// counter is written by only 3 of the ~15 draw entry points and answers 0 for
// GL_PATCHES, so it cannot price the draws this reroute exists to repair. One
// GL query span may therefore hold slots of both pools.
Bool m_pipelineStatisticsQueryFeatureEnabled = false;
// VK_EXT_primitives_generated_query: base feature, and the
// ...WithRasterizerDiscard feature without which a discarding draw inside the
// query is invalid usage (so the reroute never picks the dedicated pool on a
// base-only device - GL applications toggle discard freely).
Bool m_primitivesGeneratedQueryFeatureEnabled = false;
Bool m_primitivesGeneratedQueryDiscardFeatureEnabled = false;
// tessellationShader was enabled at device creation (it is taken whenever the
// device advertises it); gates the probe's PATCHES shape.
Bool m_tessellationShaderFeatureEnabled = false;
MG_Util::SelfTest::PrimGenRerouteKind m_primGenRerouteKind =
MG_Util::SelfTest::PrimGenRerouteKind::None;
// The bring-up probe measured this device's stream query as counting draws made
// with no capture span open (the StreamCounts verdict) - so it counts the
// PAUSED-span ones too, through the stream slot they take when nothing is
// rerouted. Only the probe can know this, so it stays false wherever the probe
// is not consulted (the forced arms), which keeps those lanes' accounting as it
// was.
Bool m_primGenStreamCountsXfbInactiveDraws = false;
VkQueryPool m_primGenReroutePool = VK_NULL_HANDLE;
Uint32 m_primGenRerouteSlotCursor = 0;
Vector<Uint32> m_primGenRerouteActiveSlots;
Bool m_primGenRerouteSlotOpen = false;
Uint32 m_primGenRerouteOpenSlot = 0;
// Runs the bring-up probe (memoized per process) and decides
// m_primGenRerouteKind. Called at the end of device creation: it records on
// m_graphicsQueue, which nothing else is using yet.
void ArmPrimGenReroute();
public:
// Whether a GENERATED span opened now will have the draws made while the GL
// span is PAUSED counted on the GPU - through the reroute pool, which takes
// every draw with no open capture, or (where the reroute is not armed because
// the stream query was measured to count capture-less draws) through the stream
// slot such a draw still takes. The frontend's CPU paused-primitive counter
// must not be added on top of either: it would double count, and it cannot
// price the draws that matter anyway - only 3 of the ~15 draw entry points
// write it and it answers 0 for GL_PATCHES. Read once per span, after
// StartXfbQueryCapture (whose pool creation may disarm the reroute).
Bool ArePausedDrawsGpuCounted() const;
// kind: 0 = PRIMITIVES_WRITTEN, 1 = PRIMITIVES_GENERATED.
Bool StartXfbQueryCapture(Uint32 kind);
void StopXfbQueryCapture(Uint32 kind, Vector<Uint32>& outSlots);
Bool ResolveXfbQueryResult(const Vector<Uint32>& slots, Bool wantGenerated, Uint64& outPrimitives);
void StopXfbQueryCapture(Uint32 kind, Vector<Uint32>& outSlots, Vector<Uint32>& outRerouteSlots);
Bool ResolveXfbQueryResult(const Vector<Uint32>& slots, const Vector<Uint32>& rerouteSlots,
Bool wantGenerated, Uint64& outPrimitives);
private:
void BeginXfbQueryForDraw(VkCommandBuffer commandBuffer);
void BeginXfbQueryForDraw(VkCommandBuffer commandBuffer, Bool xfbActive);
void EndXfbQueryForDraw(VkCommandBuffer commandBuffer);
VkCommandPool m_commandPool = VK_NULL_HANDLE;
@@ -39,18 +39,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
inline Bool ShouldEmulateSubgroups(const Bool nativeSubgroupSupported) {
return MG_Config::Features.MagmaEmulateSubgroup && !nativeSubgroupSupported &&
!MG_Config::Features.DisableSubgroup;
!MG_Config::Features.MagmaDisableSubgroup;
}
inline Bool ShouldFixIterationRPSubgroupScratch() {
// Auto is ON: the patch is fingerprint-gated to iterationRP's reduction and
// grows one under-declared array; every other module passes through untouched.
return MG_Config::Features.FixIterationRPSubgroupScratch !=
return MG_Config::Features.MagmaFixIterationRPSubgroupScratch !=
MG_Config::QuirkOverride::ForceOff;
}
inline Bool ShouldFixIterationRPBarrier() {
return MG_Config::Features.IterationRPFixBarrier;
return MG_Config::Features.MagmaIterationRPFixBarrier;
}
inline Bool ShouldDeriveNumSubgroups() {
@@ -58,6 +58,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// contract to hold, and the derived ceil() value is the one the renderer can pin
// with REQUIRE_FULL_SUBGROUPS - the driver builtin is the value with no
// cross-driver guarantee (Adreno returns 1 for an 8-subgroup dispatch).
return MG_Config::Features.DeriveNumSubgroups != MG_Config::QuirkOverride::ForceOff;
return MG_Config::Features.MagmaDeriveNumSubgroups != MG_Config::QuirkOverride::ForceOff;
}
} // namespace MobileGL::MG_Backend::DirectVulkan
+70 -21
View File
@@ -5078,9 +5078,22 @@ namespace MobileGL::MG_Impl::GLImpl {
// The half of the GetTexImage/GetTextureImage error set (GL 4.6 core 8.11) that depends on the
// resolved texture object rather than on how it was named. Shared because the by-name entry
// point does not route through GetTexImage_State and so used to enforce none of it.
// A cube map's six faces are six independent images, and both readback spellings name one of
// them: glGetTexImage through the TARGET token, glGetTextureSubImage through zoffset. Both then
// have to tell the size checks below that ONE image is coming back, not six.
static Bool IsCubeMapFaceUploadTarget(TextureUploadTarget target) {
return target >= TextureUploadTarget::CubeMapPositiveX && target <= TextureUploadTarget::CubeMapNegativeZ;
}
// `imagesQueried` is how many of the texture's upload-target images the query hands back, and
// exists for the destination-size check at the bottom. Zero means "all of them", which is what
// the whole-level forms return - every face of a cube map. glGetTextureSubImage naming ONE cube
// face passes 1: sizing that request against six faces' worth would reject the only buffer a
// single-face read has any reason to pass.
Bool ValidateTextureImageQuery(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject, GLint level,
TextureInputFormat textureInputFormat, TexturePixelDataType texturePixelDataType,
GLsizei bufSize, const void* pixels, const char* caller) {
GLsizei bufSize, const void* pixels, const char* caller,
SizeT imagesQueried = 0) {
if (!TextureImpl::ValidateTextureObject(textureObject)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
@@ -5196,12 +5209,14 @@ namespace MobileGL::MG_Impl::GLImpl {
return false;
}
// Tightly packed, and summed over every face because a cube map query returns all
// six. Pack pixel-store state only ever grows this, so a request rejected here
// could not have fit under any packing.
// Tightly packed, and summed over every face because a whole-level cube map query
// returns all six - unless the caller named a single face, which is what a non-zero
// imagesQueried says. Pack pixel-store state only ever grows this, so a request
// rejected here could not have fit under any packing.
const SizeT imageCount = imagesQueried != 0 ? imagesQueried : uploadTargets.size();
const SizeT required = MG_Util::CalculateInputTextureImageSize(textureInputFormat,
texturePixelDataType, texelSize) *
uploadTargets.size();
imageCount;
if (bufSize >= 0 && static_cast<SizeT>(bufSize) < required) {
MG_State::pGLContext->RecordError(
@@ -5274,9 +5289,14 @@ namespace MobileGL::MG_Impl::GLImpl {
isProxy ? TextureImpl::pProxyTextureManager->GetProxyTextureObject(textureUploadTarget)
: bindingSlot.GetBoundObject();
// glGetTexImage has no bufSize argument: -1 stands for "no client-side limit".
// glGetTexImage has no bufSize argument: -1 stands for "no client-side limit". That skips
// the destination-size branch but NOT the pixel-pack-buffer one, which measures the same
// `required` against the bound PBO's real size - so a cube FACE query has to say it returns
// one image here too, or a PBO sized for the one face this call packs is refused as too
// small while the copy that follows writes exactly that much into it.
return ValidateTextureImageQuery(textureObject, level, textureInputFormat, texturePixelDataType, -1, pixels,
"GetTexImage_State");
"GetTexImage_State",
IsCubeMapFaceUploadTarget(textureUploadTarget) ? 1u : 0u);
}
// What this helper can and cannot answer.
@@ -6423,6 +6443,23 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
// The half glGetTextureImage and glGetTextureSubImage share: which of the two readbacks answers,
// for ONE named upload target. Factored out so the sub-image form can name a cube FACE - the
// by-name spelling of the face token glGetTexImage takes - instead of re-deriving the target and
// silently landing on the +X face the way the delegation it replaces did.
static void GetTextureImageForUploadTarget(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
TextureUploadTarget uploadTarget, GLint level, GLenum format,
GLenum type, GLsizei bufSize, void* pixels, const char* caller) {
if (MG_Backend::pActiveBackendObject != nullptr &&
MG_Backend::pActiveBackendObject->GetBackendType() == BackendType::DirectVulkan &&
MG_Backend::gBackendFunctionsTable.GL.GetTextureImage != nullptr) {
MG_Backend::gBackendFunctionsTable.GL.GetTextureImage(textureObject, uploadTarget, level, format, type,
bufSize, pixels);
return;
}
CopyTextureImageToClientOrPBO_State(textureObject, uploadTarget, level, format, type, bufSize, pixels, caller);
}
void GetTextureImage(GLuint texture, GLint level, GLenum format, GLenum type, GLsizei bufSize, void* pixels) {
auto textureObject = GetTextureObjectByName(texture, __func__);
if (!textureObject) return;
@@ -6431,16 +6468,8 @@ namespace MobileGL::MG_Impl::GLImpl {
__func__)) {
return;
}
const auto uploadTarget = GetPrimaryUploadTarget(textureObject);
if (MG_Backend::pActiveBackendObject != nullptr &&
MG_Backend::pActiveBackendObject->GetBackendType() == BackendType::DirectVulkan &&
MG_Backend::gBackendFunctionsTable.GL.GetTextureImage != nullptr) {
MG_Backend::gBackendFunctionsTable.GL.GetTextureImage(textureObject, uploadTarget, level, format, type,
bufSize, pixels);
return;
}
CopyTextureImageToClientOrPBO_State(textureObject, uploadTarget, level, format, type, bufSize, pixels,
__func__);
GetTextureImageForUploadTarget(textureObject, GetPrimaryUploadTarget(textureObject), level, format, type,
bufSize, pixels, __func__);
}
void GetCompressedTextureImage(GLuint texture, GLint level, GLsizei bufSize, void* pixels) {
@@ -6484,9 +6513,19 @@ namespace MobileGL::MG_Impl::GLImpl {
}
const auto texelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, static_cast<Uint>(level));
const Bool isFullLevelRead = xoffset == 0 && yoffset == 0 && zoffset == 0 &&
width == texelSize.x() && height == texelSize.y() &&
depth == texelSize.z();
// On a cube map, z is the FACE axis. A cube map's level is stored per face, so its level
// size reads z = 1 whichever face named it - but GL 4.6 core 8.11.4 addresses the six faces
// of a cube map through zoffset/depth, exactly the six layers a face token names for
// glGetTexImage. Without this arm the z range was measured against that 1 and only zoffset 0
// (the +X face) was expressible; the other five were rejected as a partial read.
//
// Only ONE face at a time. depth > 1 would have to concatenate faces into the destination,
// which is the same unimplemented multi-image packing the check below still refuses.
const Bool isSingleCubeFaceRead = textureObject->GetTarget() == TextureTarget::TextureCubeMap &&
depth == 1 && zoffset < 6;
const Bool isFullLevelRead = xoffset == 0 && yoffset == 0 && width == texelSize.x() &&
height == texelSize.y() &&
(isSingleCubeFaceRead || (zoffset == 0 && depth == texelSize.z()));
if (!isFullLevelRead) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
@@ -6495,7 +6534,17 @@ namespace MobileGL::MG_Impl::GLImpl {
return;
}
GetTextureImage(texture, level, format, type, bufSize, pixels);
const TextureUploadTarget readUploadTarget =
isSingleCubeFaceRead ? static_cast<TextureUploadTarget>(
static_cast<Int>(TextureUploadTarget::CubeMapPositiveX) + zoffset)
: uploadTarget;
if (!ValidateTextureImageQuery(textureObject, level, MG_Util::ConvertGLEnumToTextureInputFormat(format),
MG_Util::ConvertGLEnumToTexturePixelDataType(type), bufSize, pixels, __func__,
isSingleCubeFaceRead ? 1u : 0u)) {
return;
}
GetTextureImageForUploadTarget(textureObject, readUploadTarget, level, format, type, bufSize, pixels,
__func__);
}
// A buffer texture carries none of the sampler or level state these queries report. Reached by
+120 -4
View File
@@ -91,6 +91,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/SsboDeclarationFormScenario.cpp
Scenarios/Glsl420DeclarationScenario.cpp
Scenarios/IoBlockNameCollisionScenario.cpp
Scenarios/UnlocatedIoBlockScenario.cpp
Scenarios/TessellationDrawModeScenario.cpp
Scenarios/GeometryDrawModeScenario.cpp
Scenarios/PostLinkAttachScenario.cpp
@@ -100,17 +101,21 @@ add_executable(MobileGLIntegrationTest
Scenarios/VertexAttribBindingScenario.cpp
Scenarios/XfbCaptureBufferReuseScenario.cpp
Scenarios/XfbPrimitiveQueryScenario.cpp
Scenarios/PrimitivesGeneratedNoXfbScenario.cpp
Scenarios/XfbRepeatedCaptureScenario.cpp
Scenarios/TessellationXfbCaptureScenario.cpp
Scenarios/PointSizeDemotionScenario.cpp
Scenarios/VertexArrayEnableDisableScenario.cpp
Scenarios/CopyImageLevelRangeScenario.cpp
Scenarios/CopyImageLayeredScenario.cpp
Scenarios/CopyImagePacked16Scenario.cpp
Scenarios/TextureViewScenario.cpp
Scenarios/PackedWordReadbackScenario.cpp
Scenarios/LayeredAttachmentBarrierScenario.cpp
Scenarios/LayeredAttachmentShapeScenario.cpp
Scenarios/LayeredTextureReadbackScenario.cpp
Scenarios/AtomicCounterScenario.cpp
Scenarios/LargeArenaAdoptionScenario.cpp
Scenarios/SsboArrayDynamicIndexScenario.cpp
Scenarios/StorageBufferRegrowScenario.cpp
Scenarios/SpirvShaderBinaryScenario.cpp
@@ -286,9 +291,9 @@ if (MOBILEGL_ITEST_VK_ICD)
if (MOBILEGL_ITEST_VK_ICD MATCHES "lvp_icd|lavapipe")
message(STATUS "Integration tests: lavapipe ICD - forcing the iterationRP repairs on")
list(APPEND MGL_ITEST_VULKAN_ENV
"MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH=1"
"MOBILEGL_DERIVE_NUM_SUBGROUPS=1"
"MOBILEGL_ITERATIONRP_FIX_BARRIER=1")
"MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH=1"
"MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS=1"
"MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER=1")
endif()
endif()
@@ -351,7 +356,37 @@ mgl_itest_join_environment(MGL_ITEST_VULKAN_OPTIMISTIC_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_ASYNC_SHADER_COMPILE=1"
"MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS=1" ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_NO_VIEWPORT_EMULATION_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION=0" ${MGL_ITEST_COMMON_ENV})
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION=0" ${MGL_ITEST_COMMON_ENV})
# MOBILEGL_LOG_FILE_PATH alongside the pin, because the arming assertion needs somewhere to
# read the library's own report from. The strip's arming signal is a latched MGLOG_I and there
# is no other way for a test process to learn that it fired - MG_Config is not reachable from
# this module on Android, where it links the shipping library. The path is per-lane so nothing
# else appends to it, and the case only trusts the bytes written after it started.
mgl_itest_join_environment(MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/unlocated-io-blocks.log"
${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_WIDENED_PACKED16_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE=1" ${MGL_ITEST_COMMON_ENV})
# Same shape as the UnlocatedIoBlocks entry: the log path is where the reroute's latched
# MGLOG_I lands, and the arming case only trusts the bytes written after it started.
mgl_itest_join_environment(MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/primgen-query-reroute.log"
${MGL_ITEST_VULKAN_ENV})
# The point-size demotion pinned on, per backend, with a per-lane log file for the arming
# assertion - the same MOBILEGL_LOG_FILE_PATH reasoning as the UnlocatedIoBlocks lane above.
# Two lanes because the demotion runs in the SHARED phase-B chain and each backend then
# consumes it differently (Espryt respells the driver-side capture request, Magma binds the
# SPIR-V Xfb decorations to the carrier).
mgl_itest_join_environment(MGL_ITEST_GLES_POINT_SIZE_DEMOTION_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_POINT_SIZE_DEMOTION=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/point-size-demotion-gles.log"
${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_POINT_SIZE_DEMOTION_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_POINT_SIZE_DEMOTION=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/point-size-demotion-vulkan.log"
${MGL_ITEST_VULKAN_ENV})
# TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it.
set(MGL_ITEST_TIMEOUT 120)
@@ -418,6 +453,23 @@ gtest_discover_tests(MobileGLIntegrationTest
ENVIRONMENT "${MGL_ITEST_GLES_FORCED_DS_ENVIRONMENT}"
)
# UnlocatedIoBlockScenario with the interface-block location strip PINNED ON, for the same
# reason the depth/stencil entry above pins its emulation: without it this scenario is
# UNFALSIFIABLE on the machines this suite runs on. llvmpipe carries a located interface block
# correctly, so the driver POST that arms the strip on Mali answers "healthy" here and the
# emulation never runs - the ambient registration would be exercising the un-stripped path
# twice and calling it coverage. With the variable set, the blocks really are emitted with no
# location and the assertion is about the spelling the device gets.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.UnlocatedIoBlocks."
TEST_FILTER "UnlocatedIoBlockScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT}"
)
# AsyncCompileScenario, with asynchronous compilation PINNED ON per backend.
#
# Not a duplicate of what the two ambient registrations already run: they run whatever
@@ -514,3 +566,67 @@ gtest_discover_tests(MobileGLIntegrationTest
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_NO_VIEWPORT_EMULATION_ENVIRONMENT}"
)
# PrimitivesGeneratedNoXfbScenario again, with the GL_PRIMITIVES_GENERATED statistics
# reroute PINNED ON. The ambient DirectVulkan registration runs the same cases under the
# bring-up probe's Auto verdict, so between the two entries both accounting paths answer
# the same GL questions and must produce the same numbers - the "two pools must agree"
# gate this machine can hold that the affected device cannot. The pinned entry is also
# the only one whose arming case runs: it asserts the renderer's latched MGLOG_I, so a
# silently-disarmed reroute (an inverted override mapping, a lost gate) fails here
# instead of leaving every equality case vacuously green. DirectVulkan only - the flag
# steers nothing on DirectGLES.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.PrimGenReroute."
TEST_FILTER "PrimitivesGeneratedNoXfbScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT}"
)
# The packed16 copy scenarios again, with the 8-bit storage widening PINNED ON. The ambient
# registrations above cover the narrow storage - on every CI driver the widening's POST
# probe finds no field-order mirror, so Auto keeps the native 16-bit path - which means the
# storage every AFFECTED device will actually run would otherwise execute nowhere at all:
# no CI driver has the Mali bug that arms it. This lane is what proves the widened storage
# is client-invisible (same packed words in and out on every leg the 18 failing CTS bodies
# used, the renderbuffer one included). DirectGLES only - the flag steers nothing on
# DirectVulkan, which has always stored these formats widened.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.WidenedPacked16."
TEST_FILTER "CopyImagePacked16Scenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_WIDENED_PACKED16_ENVIRONMENT}"
)
# PointSizeDemotionScenario with the demotion PINNED ON, per backend, for the reason every
# pinned lane above exists: llvmpipe and lavapipe both HOST gl_PointSize in tessellation and
# geometry stages, so the ambient registrations run these captures through the built-in and
# the demotion - the path every affected Mali device actually takes - would execute nowhere.
# The ambient runs stay the negative control: same scenario, same CPU-computed bytes, native
# path. Both backends, because the demotion is shared phase-B work with two different
# consumers (the ESSL capture respelling vs the SPIR-V Xfb carrier binding).
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.PointSizeDemotion."
TEST_FILTER "PointSizeDemotionScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_POINT_SIZE_DEMOTION_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.PointSizeDemotion."
TEST_FILTER "PointSizeDemotionScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_POINT_SIZE_DEMOTION_ENVIRONMENT}"
)
@@ -225,4 +225,43 @@ void main() {
EXPECT_EQ(values[1], reseed[1] + 2 * kInvocations) << "the re-seeded value at offset 4 did not reach the shader";
}
// A CPU glBufferSubData issued AFTER a dispatch, read back with NO further GPU work in
// between. Each backend has its own way to invert this pair, and both are pinned here.
// DirectGLES queues app SubData ranges for the draw-time staged-copy flush (the upload
// ring) instead of uploading in place, and readback of a GPU-written buffer overwrites
// the frontend shadow with the driver copy - so if the readback path forgets to flush the
// queued range first, the newer CPU write is REVERTED by the readback and offset 0 reads
// the dispatch's value instead of the reseed. DirectVulkan adopts the buffer into
// coherent GPU memory the moment the dispatch resolves its descriptor, so the SubData
// write lands in the very bytes the GPU reads - while the dispatch still sits recorded in
// the deferred frame command buffer. Unless the frontend retires that pending work before
// writing the adopted store (BufferObject::UploadSubData), the dispatch executes ON TOP
// of the reseed and offset 0 reads reseed + increments instead of the reseed. Offset 4
// pins the other direction for both: the upload must leave bytes outside its range - the
// dispatch's results - untouched.
TEST_F(AtomicCounterScenario, SubDataAfterDispatchSurvivesAnImmediateReadback) {
if (!Ready() || IsSkipped()) return;
const GLuint zero = MakeCounterBuffer(0, {0u, 0u});
MakeCounterBuffer(1, {0u});
ASSERT_EQ(FirstGLError(), 0u);
Dispatch();
const unsigned int reseed = 4242u;
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";
const std::vector<unsigned int> values = ReadCounters(zero, 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(values[0], reseed)
<< "offset 0 read back " << values[0] << "; the dispatch's value (" << kInvocations
<< ") means the readback ran before the queued SubData range was flushed and reverted it";
EXPECT_EQ(values[1], 2 * kInvocations)
<< "offset 4 read back " << values[1] << "; the SubData flush must leave bytes outside its "
<< "range untouched";
}
} // namespace MGITest
@@ -0,0 +1,375 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CopyImagePacked16Scenario.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 - glCopyImageSubData PRESERVES 16-BIT PACKED WORDS ACROSS AN ARRAY MIP LEVEL.
//
// The shape is lifted verbatim from the 18 Espryt bodies of KHR-GL4x.copy_image.functional
// that survived every earlier wave: the three internal formats MobileGL can keep as 16-bit
// packed ES storage - GL_RGB5 (stored GL_RGB565), GL_RGB5_A1, GL_RGBA4 - crossed with the
// target pairs that put a GL_TEXTURE_2D_ARRAY's MIP LEVEL 1 on one side of the copy. On the
// affected Mali the mirrored *_REV field order is a property of WHOLE ALLOCATIONS (shape-
// and context-dependent; the failing 30x30x12 arrays carry it at every level, the small
// arrays of the suite's passing iterations do not), and glCopyImageSubData - a raw
// texel-block move - between a mirrored allocation and a plain one lands the fields
// reversed: src word 0x0047 arrives as 0x8C20 (its 5_5_5_1 -> 1_5_5_5_REV re-encoding),
// 0x0007 as 0x3800, byte-exact on every failing body. Uploads and readbacks of the same
// image are clean (the driver decodes its own layout consistently), which is why only the
// copy path ever crossed the two layouts and why the CTS's "source image was not modified"
// checks always passed.
//
// The array is 30x30x12 with THREE levels and the flat endpoint is 7x7 with three levels
// (7/3/1) because that is the allocation the failures pin - the CTS builds every functional
// texture with FUNCTIONAL_TEST_N_LEVELS = 3 (makeTextureComplete(0, 2)) - and any deviation
// from the measured shape might sit on the clean side of whatever allocation heuristic picks
// the driver's layout.
//
// The repair under test is the packed16 storage widening
// (PixelFormatNormalizeOptionBit::WidenPacked16Norm): where the POST probe
// (SelfTest::CopyImageMirrorsPacked16FieldOrder) measures the mirror - or
// MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE forces it - the three formats are stored as
// GL_RGB8/GL_RGBA8, leaving no 16-bit packed image for a copy to disagree about. The client
// word still round-trips exactly: the canonical shadow is already UNorm8, and an n-bit field
// encodes to UNorm8 and back losslessly for every n <= 8.
//
// This scenario runs in BOTH configurations, and both must hand back identical client words:
// * the ambient registrations take the narrow path on a clean driver (llvmpipe has no
// mirror, so Auto keeps the native 16-bit storage - the pre-existing behaviour stays
// covered);
// * the DirectGLES.WidenedPacked16. registration pins MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE=1,
// which is the storage every affected device will actually run - without it the repair
// is unfalsifiable off-device, because no CI driver has the bug that arms it.
// The Mali mirror itself CANNOT be reproduced here; only the on-device CTS run can show the
// widening killing the 18 bodies. What this scenario pins is that the widened storage is
// client-invisible: same words in, same words out, on every leg the failing bodies used.
//
// DirectVulkan is the control - Magma has always resolved these formats to RGBA8 - so a
// failure on both backends means the scenario is wrong, and a failure on DirectGLES alone
// means the widening (or the narrow path it replaces) is.
#include <algorithm>
#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 kBaseSize = 30; // array level 0; level 1 is 15x15
constexpr int kLevel1Size = kBaseSize / 2;
constexpr int kLayers = 12;
constexpr int kFlatSize = 7; // the plain-2D / renderbuffer endpoint, level 0
// Copies cover the whole flat endpoint and land at (8, 8) inside the 15x15 level so
// that offsets are honoured, not just texel (0, 0): 8 + 7 == 15 reaches the far edge.
constexpr int kRegion = kFlatSize;
constexpr int kArrayOffset = 8;
struct PackedFormatCase {
GLenum internalFormat; // the spelling the CTS uses
GLenum transferFormat;
GLenum transferType;
const char* name;
};
// Per-texel varying words, every field inside its width, so a swapped field order (or
// a mis-addressed row) cannot cancel out the way a uniform fill would let it.
GLushort MakeWord(GLenum type, int i) {
switch (type) {
case GL_UNSIGNED_SHORT_5_6_5: {
const int r = i % 32, g = (i * 7 + 3) % 64, b = (i * 5 + 11) % 32;
return static_cast<GLushort>((r << 11) | (g << 5) | b);
}
case GL_UNSIGNED_SHORT_4_4_4_4: {
const int r = i % 16, g = (i * 3 + 1) % 16, b = (i * 7 + 5) % 16, a = (i * 5 + 2) % 16;
return static_cast<GLushort>((r << 12) | (g << 8) | (b << 4) | a);
}
case GL_UNSIGNED_SHORT_5_5_5_1: {
const int r = i % 32, g = (i * 7 + 3) % 32, b = (i * 3 + 11) % 32, a = i % 2;
return static_cast<GLushort>((r << 11) | (g << 6) | (b << 1) | a);
}
default:
return 0;
}
}
std::vector<GLushort> MakeWords(GLenum type, int count, int seed) {
std::vector<GLushort> words(static_cast<size_t>(count));
for (int i = 0; i < count; ++i) {
words[static_cast<size_t>(i)] = MakeWord(type, i + seed);
}
return words;
}
class CopyImagePacked16Scenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
// 16-bit rows are 2-byte aligned; the default 4-byte row alignment would pad
// every odd-width row of the 15x15 level and shear the comparisons.
glPixelStorei(GL_UNPACK_ALIGNMENT, 2);
glPixelStorei(GL_PACK_ALIGNMENT, 2);
if (!CopyImageSubDataUsable()) {
GTEST_SKIP() << "glCopyImageSubData is unavailable on backend " << Gl().BackendName();
}
}
void TearDown() override {
if (!Ready()) return;
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glPixelStorei(GL_PACK_ALIGNMENT, 4);
for (const GLuint texture : m_textures) {
glDeleteTextures(1, &texture);
}
m_textures.clear();
if (m_renderbuffer != 0) {
glDeleteRenderbuffers(1, &m_renderbuffer);
m_renderbuffer = 0;
}
if (m_fbo != 0) {
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &m_fbo);
m_fbo = 0;
}
}
bool CopyImageSubDataUsable() {
GLuint probe[2] = {0, 0};
glGenTextures(2, probe);
for (const GLuint texture : probe) {
glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, 1, 1, 1);
}
glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
while (glGetError() != GL_NO_ERROR) {
}
glCopyImageSubData(probe[0], GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, probe[1], GL_TEXTURE_2D_ARRAY, 0, 0, 0,
0, 1, 1, 1);
const bool usable = glGetError() == GL_NO_ERROR;
glDeleteTextures(2, probe);
return usable;
}
// The CTS's own mutable shape: glTexImage3D per level, filter NEAREST, THREE levels
// (30/15/7) with the chain clamped to them. Level 2 carries its own fill so nothing
// below can pass by reading a level that was never written.
GLuint MakeArrayTexture(const PackedFormatCase& format, const std::vector<GLushort>& level0,
const std::vector<GLushort>& level1) {
GLuint texture = 0;
glGenTextures(1, &texture);
m_textures.push_back(texture);
glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 2);
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, static_cast<GLint>(format.internalFormat), kBaseSize, kBaseSize,
kLayers, 0, format.transferFormat, format.transferType, level0.data());
glTexImage3D(GL_TEXTURE_2D_ARRAY, 1, static_cast<GLint>(format.internalFormat), kLevel1Size,
kLevel1Size, kLayers, 0, format.transferFormat, format.transferType, level1.data());
const int level2Size = kLevel1Size / 2;
const auto level2 = MakeWords(format.transferType, level2Size * level2Size * kLayers, 211);
glTexImage3D(GL_TEXTURE_2D_ARRAY, 2, static_cast<GLint>(format.internalFormat), level2Size,
level2Size, kLayers, 0, format.transferFormat, format.transferType, level2.data());
glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
return texture;
}
// Three levels (7/3/1) like the CTS's plain endpoints; `texels` is level 0, the one
// every assertion reads.
GLuint MakeFlatTexture(const PackedFormatCase& format, const std::vector<GLushort>& texels) {
GLuint texture = 0;
glGenTextures(1, &texture);
m_textures.push_back(texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 2);
glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(format.internalFormat), kFlatSize, kFlatSize, 0,
format.transferFormat, format.transferType, texels.data());
for (int level = 1; level <= 2; ++level) {
const int size = std::max(kFlatSize >> level, 1);
const auto fill = MakeWords(format.transferType, size * size, 97 + level);
glTexImage2D(GL_TEXTURE_2D, level, static_cast<GLint>(format.internalFormat), size, size, 0,
format.transferFormat, format.transferType, fill.data());
}
glBindTexture(GL_TEXTURE_2D, 0);
return texture;
}
std::vector<GLushort> ReadTexImage(GLenum target, GLuint texture, int level,
const PackedFormatCase& format, size_t texelCount) {
std::vector<GLushort> words(texelCount, 0);
glBindTexture(target, texture);
glGetTexImage(target, level, format.transferFormat, format.transferType, words.data());
glBindTexture(target, 0);
return words;
}
// Every word of `got` inside the kRegion-square at (x0, y0) of a width-wide layer-0
// image equals the corresponding source word, and every word outside it still holds
// `fill`'s. Failures name the texel and both words, which is what turns a field-order
// regression into a one-line diagnosis.
void ExpectRegion(const std::vector<GLushort>& got, int width, int x0, int y0,
const std::vector<GLushort>& source, int sourceWidth, int sourceX0, int sourceY0,
const std::vector<GLushort>& fill, const char* what) {
for (int y = 0; y < width; ++y) {
for (int x = 0; x < width && static_cast<size_t>(y * width + x) < got.size(); ++x) {
const bool inRegion =
x >= x0 && x < x0 + kRegion && y >= y0 && y < y0 + kRegion;
const GLushort actual = got[static_cast<size_t>(y * width + x)];
const GLushort expected =
inRegion ? source[static_cast<size_t>((sourceY0 + y - y0) * sourceWidth + sourceX0 +
(x - x0))]
: fill[static_cast<size_t>(y * width + x)];
EXPECT_EQ(actual, expected)
<< what << ": texel (" << x << ", " << y << ")"
<< (inRegion ? " (copied)" : " (untouched)") << " holds 0x" << std::hex << actual
<< ", expected 0x" << expected;
if (actual != expected) return; // one texel names the defect; 224 more would bury it
}
}
}
std::vector<GLuint> m_textures;
GLuint m_renderbuffer = 0;
GLuint m_fbo = 0;
};
const PackedFormatCase kFormats[] = {
{GL_RGB5, GL_RGB, GL_UNSIGNED_SHORT_5_6_5, "rgb5"},
{GL_RGB5_A1, GL_RGBA, GL_UNSIGNED_SHORT_5_5_5_1, "rgb5_a1"},
{GL_RGBA4, GL_RGBA, GL_UNSIGNED_SHORT_4_4_4_4, "rgba4"},
};
// texture_2d (the ES image behind GL_TEXTURE_RECTANGLE too) -> the array's level 1:
// the array-as-destination direction of 12 of the 18 failing bodies.
TEST_F(CopyImagePacked16Scenario, FlatImageLandsInArrayMipLevelIntact) {
if (!Ready() || IsSkipped()) return;
for (const PackedFormatCase& format : kFormats) {
const auto level0 = MakeWords(format.transferType, kBaseSize * kBaseSize * kLayers, 1);
const auto level1 = MakeWords(format.transferType, kLevel1Size * kLevel1Size * kLayers, 7);
const auto flat = MakeWords(format.transferType, kFlatSize * kFlatSize, 131);
const GLuint array = MakeArrayTexture(format, level0, level1);
const GLuint source = MakeFlatTexture(format, flat);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << format.name << ": setup failed";
glCopyImageSubData(source, GL_TEXTURE_2D, 0, 0, 0, 0, array, GL_TEXTURE_2D_ARRAY, 1, kArrayOffset,
kArrayOffset, 0, kRegion, kRegion, 1);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< format.name << ": glCopyImageSubData raised an error";
const auto got = ReadTexImage(GL_TEXTURE_2D_ARRAY, array, 1, format,
static_cast<size_t>(kLevel1Size) * kLevel1Size * kLayers);
ExpectRegion(got, kLevel1Size, kArrayOffset, kArrayOffset, flat, kFlatSize, 0, 0, level1,
(std::string("2d->2d_array level 1, ") + format.name).c_str());
// The source must not have moved - the CTS asserts this before it ever looks at
// the destination, and it is what pins the corruption to the copy itself.
const auto sourceAfter =
ReadTexImage(GL_TEXTURE_2D, source, 0, format, static_cast<size_t>(kFlatSize) * kFlatSize);
ExpectRegion(sourceAfter, kFlatSize, 0, 0, flat, kFlatSize, 0, 0, flat,
(std::string("source after 2d->2d_array, ") + format.name).c_str());
}
}
// The array's level 1 -> texture_2d: the array-as-source direction of the other 6
// bodies (2d_array -> 3d and 2d_array -> rectangle both read the level-1 array).
TEST_F(CopyImagePacked16Scenario, ArrayMipLevelLandsInFlatImageIntact) {
if (!Ready() || IsSkipped()) return;
for (const PackedFormatCase& format : kFormats) {
const auto level0 = MakeWords(format.transferType, kBaseSize * kBaseSize * kLayers, 1);
const auto level1 = MakeWords(format.transferType, kLevel1Size * kLevel1Size * kLayers, 7);
const auto fill = MakeWords(format.transferType, kFlatSize * kFlatSize, 131);
const GLuint array = MakeArrayTexture(format, level0, level1);
const GLuint destination = MakeFlatTexture(format, fill);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << format.name << ": setup failed";
glCopyImageSubData(array, GL_TEXTURE_2D_ARRAY, 1, kArrayOffset, kArrayOffset, 0, destination,
GL_TEXTURE_2D, 0, 0, 0, 0, kRegion, kRegion, 1);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< format.name << ": glCopyImageSubData raised an error";
const auto got = ReadTexImage(GL_TEXTURE_2D, destination, 0, format,
static_cast<size_t>(kFlatSize) * kFlatSize);
ExpectRegion(got, kFlatSize, 0, 0, level1, kLevel1Size, kArrayOffset, kArrayOffset, fill,
(std::string("2d_array level 1 -> 2d, ") + format.name).c_str());
}
}
// renderbuffer -> the array's level 1: the leg the remaining 3 bodies use, and the one
// that requires the renderbuffer's ES storage to move together with the textures' -
// glCopyImageSubData needs both endpoints in the same driver format, so a widening that
// reached textures alone would break exactly here.
TEST_F(CopyImagePacked16Scenario, RenderbufferLandsInArrayMipLevelIntact) {
if (!Ready() || IsSkipped()) return;
for (const PackedFormatCase& format : kFormats) {
const auto level0 = MakeWords(format.transferType, kBaseSize * kBaseSize * kLayers, 1);
const auto level1 = MakeWords(format.transferType, kLevel1Size * kLevel1Size * kLayers, 7);
const GLuint array = MakeArrayTexture(format, level0, level1);
if (m_renderbuffer == 0) glGenRenderbuffers(1, &m_renderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, format.internalFormat, kFlatSize, kFlatSize);
if (m_fbo == 0) glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, m_renderbuffer);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
<< format.name << ": the renderbuffer is not attachable";
// Field values picked to encode exactly in the narrow fields AND in their
// UNorm8 expansions, so the expected word is the same whichever storage the
// configuration picked - which is the point of the whole scenario.
const int maxG = format.transferType == GL_UNSIGNED_SHORT_5_6_5 ? 63 : 31;
const int max = format.transferType == GL_UNSIGNED_SHORT_4_4_4_4 ? 15 : 31;
const int maxGreen = format.transferType == GL_UNSIGNED_SHORT_4_4_4_4 ? 15 : maxG;
const GLfloat clearColor[4] = {static_cast<GLfloat>(8 % (max + 1)) / max,
static_cast<GLfloat>(maxGreen / 2) / maxGreen,
static_cast<GLfloat>(max - 2) / max, 1.0f};
// The context is shared with every scenario in this process; a scissor left on
// would clip the clear and hand the copy undefined renderbuffer texels.
glDisable(GL_SCISSOR_TEST);
glClearBufferfv(GL_COLOR, 0, clearColor);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << format.name << ": setup failed";
glCopyImageSubData(m_renderbuffer, GL_RENDERBUFFER, 0, 0, 0, 0, array, GL_TEXTURE_2D_ARRAY, 1,
kArrayOffset, kArrayOffset, 0, kRegion, kRegion, 1);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< format.name << ": glCopyImageSubData raised an error";
GLushort clearedWord = 0;
switch (format.transferType) {
case GL_UNSIGNED_SHORT_5_6_5:
clearedWord = static_cast<GLushort>((8 << 11) | ((maxGreen / 2) << 5) | (max - 2));
break;
case GL_UNSIGNED_SHORT_5_5_5_1:
clearedWord = static_cast<GLushort>((8 << 11) | ((maxGreen / 2) << 6) | ((max - 2) << 1) | 1);
break;
case GL_UNSIGNED_SHORT_4_4_4_4:
clearedWord = static_cast<GLushort>((8 << 12) | ((maxGreen / 2) << 8) | ((max - 2) << 4) | 15);
break;
default:
break;
}
std::vector<GLushort> expectedRegion(static_cast<size_t>(kRegion) * kRegion, clearedWord);
const auto got = ReadTexImage(GL_TEXTURE_2D_ARRAY, array, 1, format,
static_cast<size_t>(kLevel1Size) * kLevel1Size * kLayers);
ExpectRegion(got, kLevel1Size, kArrayOffset, kArrayOffset, expectedRegion, kRegion, 0, 0, level1,
(std::string("renderbuffer -> 2d_array level 1, ") + format.name).c_str());
}
}
} // namespace
} // namespace MGITest
@@ -137,7 +137,7 @@ namespace MGITest {
// invocations, i.e. an advertised subgroup width in [16, 256]. A device
// outside that window (lavapipe's 8-lane subgroups give 64 subgroups) cannot
// run the fixture's verbatim reduction at all, so the scenario SKIPS there -
// the pack itself replays through the FixIterationRPSubgroupScratch patch, which
// the pack itself replays through the MagmaFixIterationRPSubgroupScratch patch, which
// this probe deliberately does not model. The width only gates the domain;
// lane placement and group counts still come from observed values alone.
bool SubgroupWidthInSourceDomain() const {
@@ -0,0 +1,278 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.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 - MESH-ARENA-SIZED BUFFERS, END TO END.
//
// A buffer store of at least 16MiB is adopted into the backend's persistently and
// coherently mapped GPU storage the moment it is defined (BufferObject::
// TryAdoptLargeStorage): the CPU shadow is dropped and every later write lands
// directly in GPU-visible memory with no per-write driver call. Minecraft 26.3
// streams chunk meshes into 128MB vertex arenas with plain glNamedBufferSubData -
// on Mali, every driver-mediated route for that write into a busy mutable store
// either parks the calling thread or ghost-copies the whole arena on a driver
// worker (~167ms per touched arena: the recurring in-world hiccup this adoption
// removed). Every existing buffer scenario uses stores far below the threshold,
// so without this file the adopted path would have zero coverage.
//
// What is pinned, deliberately through the same API mix Minecraft uses:
// * a glBufferSubData written AFTER the arena was drawn (in flight) reaches the
// next draw - the write-visibility contract adoption must not weaken;
// * GetBufferSubData reads back the latest CPU write - the shadow IS the map;
// * a compute-shader write through an SSBO binding of the same arena is read
// back - the GPU-written path for adopted stores (glFinish + direct read).
#include <array>
#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 {
// Comfortably past the 16MiB adoption threshold, and the vertex payload sits
// deep inside the store so an implementation that quietly clamped or aliased
// the adopted range would miss it.
constexpr GLsizeiptr kArenaBytes = GLsizeiptr(24) * 1024 * 1024;
constexpr GLintptr kVertexOffset = GLintptr(20) * 1024 * 1024;
constexpr const char* kVertexSource = R"(#version 430 core
layout(location = 0) in vec2 a_pos;
layout(location = 1) in vec3 a_color;
out vec3 v_color;
void main() {
v_color = a_color;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
)";
constexpr const char* kFragmentSource = R"(#version 430 core
in vec3 v_color;
out vec4 o_color;
void main() { o_color = vec4(v_color, 1.0); }
)";
constexpr const char* kMarkerComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Arena { uint word; };
void main() { word = 0xC0FFEEu; }
)";
struct Vertex {
float x, y;
float r, g, b;
};
// A full-viewport quad, colored uniformly so one center readback speaks for
// the whole draw.
std::vector<Vertex> QuadVertices(float r, float g, float b) {
return {
{-1.f, -1.f, r, g, b}, {1.f, -1.f, r, g, b}, {1.f, 1.f, r, g, b},
{-1.f, -1.f, r, g, b}, {1.f, 1.f, r, g, b}, {-1.f, 1.f, r, g, b},
};
}
class LargeArenaAdoptionScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_program = LinkProgram(kVertexSource, kFragmentSource);
ASSERT_NE(m_program, 0u) << m_buildLog;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_arena);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
// The NULL-data definition is the adoption point (and Minecraft's
// arena-creation idiom).
glBufferData(GL_ARRAY_BUFFER, kArenaBytes, nullptr, GL_DYNAMIC_DRAW);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<void*>(kVertexOffset));
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<void*>(kVertexOffset + 2 * sizeof(float)));
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_arena != 0) glDeleteBuffers(1, &m_arena);
if (m_program != 0) glDeleteProgram(m_program);
if (m_compute != 0) glDeleteProgram(m_compute);
m_vao = 0;
m_arena = 0;
m_program = 0;
m_compute = 0;
}
unsigned int CompileStage(GLenum stage, const char* source) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[2048] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("shader did not compile: ") + log;
glDeleteShader(shader);
return 0;
}
return shader;
}
unsigned int LinkProgram(const char* vs, const char* fs) {
const GLuint v = CompileStage(GL_VERTEX_SHADER, vs);
if (v == 0) return 0;
const GLuint f = CompileStage(GL_FRAGMENT_SHADER, fs);
if (f == 0) {
glDeleteShader(v);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, v);
glAttachShader(program, f);
glLinkProgram(program);
glDeleteShader(v);
glDeleteShader(f);
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("program did not link: ") + log;
glDeleteProgram(program);
return 0;
}
return program;
}
void UploadQuad(float r, float g, float b) {
const auto vertices = QuadVertices(r, g, b);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
}
void DrawQuad() {
glViewport(0, 0, Gl().Width(), Gl().Height());
glClearColor(0.f, 0.f, 0.f, 1.f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(m_program);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
std::array<unsigned char, 4> CenterPixel() {
std::array<unsigned char, 4> px = {0, 0, 0, 0};
glReadPixels(Gl().Width() / 2, Gl().Height() / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE,
px.data());
return px;
}
unsigned int m_program = 0;
unsigned int m_compute = 0;
unsigned int m_vao = 0;
unsigned int m_arena = 0;
std::string m_buildLog;
};
} // namespace
// The Minecraft shape: the arena is drawn, the frame retires, and a
// glBufferSubData rewrites the SAME vertex bytes while the previous frame's
// draw may still be in flight. The next draw must show the NEW bytes.
TEST_F(LargeArenaAdoptionScenario, SubDataAfterAnInFlightDrawReachesTheNextDraw) {
if (!Ready() || IsSkipped()) return;
UploadQuad(1.f, 0.f, 0.f);
DrawQuad();
auto px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_GT(px[0], 200) << "the first draw from the adopted arena never landed";
EXPECT_LT(px[1], 50);
Gl().EndFrame();
UploadQuad(0.f, 1.f, 0.f);
DrawQuad();
px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_GT(px[1], 200) << "the cross-frame rewrite of the adopted arena did not reach the draw; "
"the old color means the write went to bytes the draw no longer reads";
EXPECT_LT(px[0], 50) << "the draw still shows the previous frame's bytes";
}
// The shadow IS the mapping: a readback straight after a CPU write must hand
// back exactly those bytes.
TEST_F(LargeArenaAdoptionScenario, ReadbackSeesTheLatestCpuWrite) {
if (!Ready() || IsSkipped()) return;
const auto vertices = QuadVertices(0.25f, 0.5f, 0.75f);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
std::vector<Vertex> read(vertices.size());
glGetBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(read.size() * sizeof(Vertex)), read.data());
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(0, std::memcmp(read.data(), vertices.data(), read.size() * sizeof(Vertex)))
<< "GetBufferSubData of the adopted arena returned different bytes than the SubData wrote";
}
// A GPU write through an SSBO binding of the adopted arena must be visible to
// a CPU readback - the path that waits out the GPU and reads the coherent
// mapping directly.
TEST_F(LargeArenaAdoptionScenario, GpuWriteIntoTheArenaIsReadBack) {
if (!Ready() || IsSkipped()) return;
GLint maxComputeStorageBlocks = 0;
glGetIntegerv(GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, &maxComputeStorageBlocks);
if (maxComputeStorageBlocks < 1) {
GTEST_SKIP() << "no compute shader storage blocks on this driver";
}
const GLuint compute = CompileStage(GL_COMPUTE_SHADER, kMarkerComputeSource);
ASSERT_NE(compute, 0u) << m_buildLog;
m_compute = glCreateProgram();
glAttachShader(m_compute, compute);
glLinkProgram(m_compute);
glDeleteShader(compute);
GLint linked = 0;
glGetProgramiv(m_compute, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
const unsigned int seed = 0u;
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(seed), &seed);
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 0, m_arena, 0, sizeof(unsigned int));
glUseProgram(m_compute);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT | GL_BUFFER_UPDATE_BARRIER_BIT);
unsigned int marker = 0;
glGetBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(marker), &marker);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(marker, 0xC0FFEEu)
<< "the compute write into the adopted arena did not reach the CPU readback";
}
} // namespace MGITest
@@ -32,14 +32,20 @@
// too, so the per-slice branch that exists for exactly this case was unreachable and every
// slice above z = 0 came back VK_NULL_HANDLE.
//
// The seven cases below are those shapes - layered 3D, one 3D slice, layered cube-map array with
// its depth and packed depth-stencil attachments, and (cases 6 and 7) a layered cube MAP and 1D
// ARRAY whose queued glClear is consumed outside a render pass. Each one asserts LAYER ROUTING,
// The first seven cases below are those shapes - layered 3D, one 3D slice, layered cube-map array
// with its depth and packed depth-stencil attachments, and (cases 6 and 7) a layered cube MAP and
// 1D ARRAY whose queued glClear is consumed outside a render pass. Each one asserts LAYER ROUTING,
// not merely survival: what a layer receives is a function of its own index, so an attachment that
// collapsed onto layer 0, or attached one face of a cube, fails on the layers it did not reach
// rather than passing quietly. Every texture is seeded with a poison value first, so "the draw
// never landed here" reads differently from "the wrong layer landed here".
//
// Case (8) is the same collapse one step downstream, and case (6) is what found it: the READBACK
// of a cube map ignored the face it was asked for and answered +X for all six. Every case here
// that reads a layered target back depends on the readback addressing the layer it names, so it
// belongs beside them - and case (6) had to be written around it, which is the strongest argument
// there is that it was never pinned.
//
// One of them turned out not to be a DirectVulkan bug at all. glFramebufferTexture on
// GL_DEPTH_STENCIL_ATTACHMENT is a shorthand the front end splits into a depth and a stencil
// attachment, and the split dropped the call's `layered` flag - so a layered colour attachment
@@ -96,6 +102,10 @@ namespace MGITest {
// mismatch means a real miss rather than rounding.
constexpr Rgba8 kClearColor{17, 68, 187, 255};
// The six cube faces in the order GL numbers them, which is also the order Vulkan keeps
// them in as array layers (GL 4.6 core 8.5.3 / VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT).
const char* const kFaceNames[6] = {"+X", "-X", "+Y", "-Y", "+Z", "-Z"};
// What pass `pass` paints on layer `layer`. r and g name the LAYER (so a mis-routed write
// says which layer it came from) and b names the PASS (so "the second draw was not
// rejected" is distinguishable from "the first draw never happened").
@@ -357,6 +367,27 @@ void main()
return texture;
}
// A cube map whose six faces are UPLOADED with their own colours - the same
// ExpectedColor(face, 0) the painted cube of case (8) ends up holding, so both can be
// checked with one expectation. Uploaded rather than rendered means the CPU shadow and
// the image agree, which is the premise the BY-NAME readback needs; see case (8).
GLuint MakeFaceColoredCubeMap() {
const GLuint texture = TrackTexture();
glBindTexture(GL_TEXTURE_CUBE_MAP, texture);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexStorage2D(GL_TEXTURE_CUBE_MAP, 1, GL_RGBA8, kExtent, kExtent);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
for (int face = 0; face < 6; ++face) {
const std::vector<Rgba8> seed(static_cast<std::size_t>(kExtent) * kExtent,
ExpectedColor(face, 0));
glTexSubImage2D(static_cast<GLenum>(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face), 0, 0, 0, kExtent,
kExtent, GL_RGBA, GL_UNSIGNED_BYTE, seed.data());
}
glBindTexture(GL_TEXTURE_CUBE_MAP, 0);
return texture;
}
// An RGBA8 1D array, every layer poisoned. glTexImage2D's HEIGHT is the layer count -
// that is what GL_TEXTURE_1D_ARRAY means, and it is why reading the level size's z
// gives 1 however many layers there are.
@@ -487,6 +518,34 @@ void main()
}
}
// Every texel of one cube FACE is that face's own colour. When it is not, the message
// says whose colour answered instead - which is the whole point here: a readback that
// ignores the face token does not return garbage, it returns another face's perfectly
// plausible texels, and "+X's colour came back for -Y" is the sentence that names the
// defect. `what` is the spelling under test, since three of them read the same faces.
void ExpectFaceColor(const std::vector<Rgba8>& texels, int face, const char* what) {
const Rgba8 expected = ExpectedColor(face, 0);
for (std::size_t i = 0; i < texels.size(); ++i) {
const Rgba8 actual = texels[i];
if (actual == expected) continue;
std::string blame;
if (actual.r == kPoison && actual.g == kPoison) {
blame = " - the poison, so nothing was ever written to this face";
} else {
for (int other = 0; other < 6; ++other) {
if (other != face && actual == ExpectedColor(other, 0)) {
blame = std::string(" - which is face ") + kFaceNames[other] + "'s colour";
break;
}
}
}
ADD_FAILURE() << what << ": face " << kFaceNames[face] << " texel " << i << " is "
<< Describe(actual) << ", expected " << Describe(expected) << blame;
// One message per face is enough to say what happened.
break;
}
}
::testing::AssertionResult FramebufferIsComplete() {
const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status == GL_FRAMEBUFFER_COMPLETE) return ::testing::AssertionSuccess();
@@ -883,13 +942,16 @@ void main()
// Every face, read back through an FBO that names THAT face.
//
// Not glGetTexImage(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face): measured against a tree
// where only +X had been cleared, that spelling returned the cleared colour for all
// six faces, so it cannot see per-face state on DirectVulkan and the case built on it
// was unfalsifiable. glFramebufferTexture2D + glReadPixels names one face and nothing
// else, and the pending clear is long gone by now (materialised and popped above), so
// this readback cannot alter what it is measuring.
static const char* const kFaceNames[6] = {"+X", "-X", "+Y", "-Y", "+Z", "-Z"};
// Not glGetTexImage(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face): when this case was written
// that spelling could not see per-face state on DirectVulkan at all - measured against
// a tree where only +X had been cleared it returned the cleared colour for all six
// faces - so a case built on it would have been unfalsifiable. That is a readback
// defect rather than an attachment one, and case (8) below is where it is pinned and
// fixed; this case keeps the independent spelling deliberately, because it must go on
// measuring the CLEAR whatever the readback does. glFramebufferTexture2D +
// glReadPixels names one face and nothing else, and the pending clear is long gone by
// now (materialised and popped above), so this readback cannot alter what it is
// measuring.
for (int face = 0; face < 6; ++face) {
const GLuint faceFbo = TrackFramebuffer();
glBindFramebuffer(GL_FRAMEBUFFER, faceFbo);
@@ -958,5 +1020,114 @@ void main()
Gl().EndFrame();
}
// (8) THE CUBE FACE TOKEN A READBACK IS GIVEN, AND WHETHER IT HONOURS IT.
//
// Case (6) above had to route around glGetTexImage(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face)
// entirely: measured against a tree where only the +X face had been cleared, that spelling
// returned +X's colour for all six face tokens. This case is that observation turned into
// an assertion, and it is about the READBACK, not the attachment.
//
// THE DEFECT. DirectVulkan's GetTextureImage derived its copy geometry from the IMAGE's
// target alone. A plain GL_TEXTURE_CUBE_MAP is not one of the array targets, so the layer
// count collapsed to one - correct, one face IS one layer - but nothing ever turned the
// face the TARGET TOKEN named into the copy's baseArrayLayer, which stayed 0. All six face
// tokens therefore read array layer 0 and answered +X: five of a cube map's six faces were
// unreadable through the entry point GL provides for reading them. Nothing announces it -
// the call succeeds, raises no error, and hands back entirely plausible texels from the
// wrong face. The conversion it was missing already existed twice over, as the clear and
// render-pass managers' ResolveAttachmentBaseArrayLayer.
//
// glGetTextureSubImage is the same question asked by name: GL 4.6 core 8.11.4 addresses a
// cube map's faces through zoffset. That spelling was not merely reading the wrong face,
// it could not read ANY face - measured pre-fix, all six returned INVALID_OPERATION on
// both backends. Two independent reasons, and it took both to make even zoffset 0 fail:
// the z range was measured against the level's z, which is one face's 1, so five of the
// six looked like a partial read; and the destination-size check summed all six faces, so
// the one face's worth of buffer a single-face read has any reason to pass was rejected as
// too small.
//
// Each face is painted its OWN colour, so a collapse onto layer 0 does not merely read
// "wrong": the failure names the face that answered. The cube is poisoned first and then
// painted through the GPU, so an answer served from the stale CPU shadow is also called out
// by name rather than passing. And the per-face FBO + glReadPixels read is the control: it
// names one face and nothing else, so if IT disagrees the defect is in how the faces were
// written and this case is measuring the wrong thing.
//
// DirectGLES attaches the named face to a scratch FBO and reads that, so it answers the
// face token correctly throughout - a red there means this case is wrong. Its by-name
// readback is a different matter and gets a texture of its own; see the third block.
TEST_F(LayeredAttachmentShapeScenario, CubeMapFaceReadbackAnswersTheFaceItWasAskedFor) {
if (!Ready()) return;
const GLuint cube = MakePoisonedCubeMap();
ASSERT_EQ(FirstGLError(), 0u) << "creating the RGBA8 cube map failed";
// Paint every face its own colour through an FBO that names that one face. A clear
// rather than a draw, so nothing here depends on a shader stage being present.
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glViewport(0, 0, kExtent, kExtent);
GLuint faceFbos[6] = {};
for (int face = 0; face < 6; ++face) {
faceFbos[face] = TrackFramebuffer();
glBindFramebuffer(GL_FRAMEBUFFER, faceFbos[face]);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
static_cast<GLenum>(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face), cube, 0);
glDrawBuffer(GL_COLOR_ATTACHMENT0);
glReadBuffer(GL_COLOR_ATTACHMENT0);
ASSERT_TRUE(FramebufferIsComplete()) << "cube face " << kFaceNames[face] << " is not attachable";
const Rgba8 want = ExpectedColor(face, 0);
glClearColor(want.r / 255.0f, want.g / 255.0f, want.b / 255.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
ASSERT_EQ(FirstGLError(), 0u) << "painting the six faces errored";
// The control. If this is red, the faces do not hold six different values and the two
// readbacks below are being measured against a premise that is not true.
for (int face = 0; face < 6; ++face) {
glBindFramebuffer(GL_FRAMEBUFFER, faceFbos[face]);
std::vector<Rgba8> texels(static_cast<std::size_t>(kExtent) * kExtent, Rgba8{});
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, kExtent, kExtent, GL_RGBA, GL_UNSIGNED_BYTE, texels.data());
glBindFramebuffer(GL_FRAMEBUFFER, 0);
EXPECT_EQ(FirstGLError(), 0u) << "the control read of face " << kFaceNames[face] << " errored";
ExpectFaceColor(texels, face, "control: per-face FBO + glReadPixels");
}
// The subject: the face TOKEN.
glBindTexture(GL_TEXTURE_CUBE_MAP, cube);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
for (int face = 0; face < 6; ++face) {
std::vector<Rgba8> texels(static_cast<std::size_t>(kExtent) * kExtent, Rgba8{});
glGetTexImage(static_cast<GLenum>(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face), 0, GL_RGBA,
GL_UNSIGNED_BYTE, texels.data());
EXPECT_EQ(FirstGLError(), 0u) << "glGetTexImage of face " << kFaceNames[face] << " errored";
ExpectFaceColor(texels, face, "glGetTexImage(GL_TEXTURE_CUBE_MAP_<face>)");
}
glBindTexture(GL_TEXTURE_CUBE_MAP, 0);
// The same question by name, where zoffset is the face.
//
// On a cube map UPLOADED face by face rather than the painted one above, because the
// by-name readback has no backend entry outside DirectVulkan and answers from the CPU
// shadow there - a separate, pre-existing gap that has nothing to do with which face
// gets read. Asking it about GPU-painted content would make this red on DirectGLES for
// a reason the case is not about; asking it about uploaded content leaves exactly one
// thing either backend can get wrong, which is the face. DirectVulkan still answers
// this one out of the image, so the layer collapse is just as visible here.
const GLuint uploaded = MakeFaceColoredCubeMap();
ASSERT_EQ(FirstGLError(), 0u) << "uploading the six faces failed";
for (int face = 0; face < 6; ++face) {
std::vector<Rgba8> texels(static_cast<std::size_t>(kExtent) * kExtent, Rgba8{});
glGetTextureSubImage(uploaded, 0, 0, 0, face, kExtent, kExtent, 1, GL_RGBA, GL_UNSIGNED_BYTE,
static_cast<GLsizei>(texels.size() * sizeof(Rgba8)), texels.data());
EXPECT_EQ(FirstGLError(), 0u) << "glGetTextureSubImage of face " << kFaceNames[face] << " errored";
ExpectFaceColor(texels, face, "glGetTextureSubImage(zoffset = face)");
}
Gl().EndFrame();
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,522 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PointSizeDemotionScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - THE gl_PointSize DEMOTION IS CLIENT-INVISIBLE, AND IT ACTUALLY ARMS.
//
// On a device that hosts the built-in in tessellation/geometry stages (llvmpipe and
// lavapipe both do), gl_PointSize travels as itself; on one that does not (the Mali
// devices this exists for), phase B demotes it to an ordinary varying
// (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram) and the capture
// machinery follows it there. This scenario runs in BOTH configurations and must hand
// back identical bytes: the ambient registrations take the native path, and the
// PointSizeDemotion. registrations pin MOBILEGL_POINT_SIZE_DEMOTION=1 so the demotion
// runs on the same healthy drivers - CopyImagePacked16Scenario's dual-configuration
// contract, applied to a value chain instead of a storage format.
//
// The VALUE is the whole contract: every case writes gl_PointSize in one stage, reads it
// back out of gl_in[] in the next, and captures it by name under rasterizer discard, so
// one wrong link anywhere in VS -> TCS -> TES -> GS -> capture lands in the readback.
// The RASTERIZED size is deliberately not asserted anywhere: with the built-in unhosted
// it falls back to 1.0 by spec on both targets, which is exactly the honest residue the
// demotion documents (point_rendering-style bodies keep failing truthfully).
//
// The assertions are on the captured BYTES against a CPU-computed reference, never on
// the absence of a GL error: every failure this guards against is silent.
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#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 {
constexpr float kPoison = -987654.0f;
const char* const kFragmentSource = R"(#version 460 core
layout(location = 0) out vec4 fragColor;
void main()
{
fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
)";
// The full chain, with per-vertex VARIATION seeded in the vertex stage so a control
// invocation that read or wrote the wrong slot changes the sum: 2,3,4 arrive, 3,4,5
// leave, the evaluation stage sums its patch to 12, the geometry stage doubles what
// it read to 24.
const char* const kChainVertexSource = R"(#version 460 core
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = 2.0 + float(gl_VertexID);
}
)";
const char* const kChainTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main()
{
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_out[gl_InvocationID].gl_PointSize = gl_in[gl_InvocationID].gl_PointSize + 1.0;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
const char* const kChainTessEvalSource = R"(#version 460 core
layout(triangles, equal_spacing, cw, point_mode) in;
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = gl_in[0].gl_PointSize + gl_in[1].gl_PointSize + gl_in[2].gl_PointSize;
}
)";
const char* const kChainGeometrySource = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main()
{
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize * 2.0;
EmitVertex();
EndPrimitive();
}
)";
// The geometry-only chain: no tessellation required of the stack at all.
const char* const kPointVertexSource = R"(#version 460 core
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = 7.0;
}
)";
const char* const kPointGeometrySource = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main()
{
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize + 1.0;
EmitVertex();
EndPrimitive();
}
)";
// A capture stage that only READS the incoming point size and never writes its own.
// Legal GL, and the shape that separates "the demotion arms" from "the demotion knows
// a capture is coming": with the built-in gone, only the capture request can put a
// carrier back for a by-name capture to bind to.
const char* const kReadOnlyGeometrySource = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
out float g_echo;
void main()
{
gl_Position = gl_in[0].gl_Position;
g_echo = gl_in[0].gl_PointSize;
EmitVertex();
EndPrimitive();
}
)";
const char* const kEchoFragmentSource = R"(#version 460 core
in float g_echo;
layout(location = 0) out vec4 fragColor;
void main()
{
fragColor = vec4(g_echo, 0.0, 0.0, 1.0);
}
)";
class PointSizeDemotionScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
DrainErrors();
}
void TearDown() override {
if (Ready()) {
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;
}
ScenarioTest::TearDown();
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
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);
}
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();
}
GLuint BuildCaptureProgram(const std::vector<std::pair<GLenum, const char*>>& stages,
const std::vector<const char*>& varyings) {
m_buildLog.clear();
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 == GL_FALSE) {
m_buildLog = InfoLog(shader, true) + "\n--- source ---\n" + source;
ok = false;
break;
}
}
GLuint program = 0;
if (ok) {
program = glCreateProgram();
for (const GLuint shader : shaders) {
glAttachShader(program, shader);
}
glTransformFeedbackVaryings(program, static_cast<GLsizei>(varyings.size()),
varyings.data(), GL_INTERLEAVED_ATTRIBS);
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
program = 0;
}
}
for (const GLuint shader : shaders) {
glDeleteShader(shader);
}
if (program != 0) m_programs.push_back(program);
return program;
}
// One capture span over `vertexCount` vertices of `drawMode`, recorded as
// GL_POINTS. The buffer is poison-filled first so bytes the capture never wrote
// name themselves.
std::vector<float> RunCaptureSpan(GLuint program, GLenum drawMode, GLsizei vertexCount,
std::size_t capturedFloats) {
const std::vector<float> poison(capturedFloats, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBuffer(GL_ARRAY_BUFFER, xfbBuffer);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(capturedFloats * sizeof(float)),
poison.data(), GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(drawMode, 0, vertexCount);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> readback(capturedFloats, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(capturedFloats * sizeof(float)),
readback.data());
glUseProgram(0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
return readback;
}
static ::testing::AssertionResult ComponentIs(const std::vector<float>& data,
std::size_t index, float expected,
float epsilon = 1e-4f) {
if (index >= data.size()) {
return ::testing::AssertionFailure()
<< "component " << index << " is past the capture buffer";
}
const float actual = data[index];
if (actual == kPoison) {
return ::testing::AssertionFailure()
<< "component " << index << " still holds the poison value - the capture "
<< "never reached these bytes (expected " << expected << ")";
}
if (std::isnan(actual) || std::abs(actual - expected) > epsilon) {
return ::testing::AssertionFailure()
<< "component " << index << " is " << actual << ", expected " << expected;
}
return ::testing::AssertionSuccess();
}
// The library log, for the arming case. Same machinery and same reasoning as
// UnlocatedIoBlockScenario: MOBILEGL_LOG_FILE_PATH is read at log-init, the file
// is appended to by every process in the lane, and only bytes appended after the
// snapshot may satisfy an assertion.
static std::filesystem::path LibraryLogPath() {
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
return (path != nullptr && *path != '\0') ? std::filesystem::path(path)
: std::filesystem::path();
}
static std::uintmax_t LibraryLogSize() {
std::error_code ec;
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return 0;
const std::uintmax_t size = std::filesystem::file_size(path, ec);
return ec ? 0 : size;
}
static std::string LibraryLogSince(std::uintmax_t offset) {
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return {};
std::ifstream file(path, std::ios::binary);
if (!file.good()) return {};
file.seekg(static_cast<std::streamoff>(offset));
return std::string((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
}
std::string m_buildLog;
private:
GLuint m_vao = 0;
std::vector<GLuint> m_programs;
};
// The five-stage chain. 24.0 can only arrive if the vertex mirror, both control-stage
// redirects (read AND write), the evaluation stage's three gl_in reads and the
// geometry stage's read all carried the right value - one wrong link and the sum
// moves. point_mode with every level at 1 emits three points; the first record proves
// the mechanism, exactly as TessellationXfbCaptureScenario reasons.
TEST_F(PointSizeDemotionScenario, TheValueSurvivesTheFiveStageChainIntoTheCapture) {
if (!Ready()) return;
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " ("
<< Gl().RendererString() << ")";
}
glPatchParameteri(GL_PATCH_VERTICES, 3);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kChainVertexSource},
{GL_TESS_CONTROL_SHADER, kChainTessControlSource},
{GL_TESS_EVALUATION_SHADER, kChainTessEvalSource},
{GL_GEOMETRY_SHADER, kChainGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
const std::vector<float> captured = RunCaptureSpan(program, GL_PATCHES, 3, 3);
EXPECT_TRUE(ComponentIs(captured, 0, 24.0f));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// The same chain without a geometry stage: the capture then binds to the evaluation
// stage's value (the sum, 12.0) - which is also the boundary where a demoted program
// switches its capture carrier from the Io chain to the capture name.
TEST_F(PointSizeDemotionScenario, TheEvaluationStageOwnsTheCaptureWithoutAGeometryStage) {
if (!Ready()) return;
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " ("
<< Gl().RendererString() << ")";
}
glPatchParameteri(GL_PATCH_VERTICES, 3);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kChainVertexSource},
{GL_TESS_CONTROL_SHADER, kChainTessControlSource},
{GL_TESS_EVALUATION_SHADER, kChainTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
const std::vector<float> captured = RunCaptureSpan(program, GL_PATCHES, 3, 3);
EXPECT_TRUE(ComponentIs(captured, 0, 12.0f));
// The GL query surface keeps the truthful spelling whatever the backends renamed
// underneath: reflection is a phase-A product and the demotion happens after it.
char varyingName[64] = {};
GLsizei nameLength = 0;
GLsizei varyingSize = 0;
GLenum varyingType = 0;
glGetTransformFeedbackVarying(program, 0, sizeof(varyingName), &nameLength, &varyingSize,
&varyingType, varyingName);
EXPECT_STREQ(varyingName, "gl_PointSize");
EXPECT_EQ(varyingType, static_cast<GLenum>(GL_FLOAT));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// The geometry-only chain: gl_in[0].gl_PointSize read straight off the vertex stage,
// no tessellation involved - the VS -> GS boundary of the demotion on its own.
TEST_F(PointSizeDemotionScenario, AGeometryOnlyChainCarriesTheVertexValue) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPointVertexSource},
{GL_GEOMETRY_SHADER, kPointGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
const std::vector<float> captured = RunCaptureSpan(program, GL_POINTS, 1, 1);
EXPECT_TRUE(ComponentIs(captured, 0, 8.0f));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// THE CAPTURE-REQUEST PATH, END TO END - the half no unit test can reach, because the
// request travels from glTransformFeedbackVaryings through phase A's resolved capture
// set and the phase-B handoff before it reaches the demotion.
//
// The geometry stage READS gl_in[0].gl_PointSize and never writes gl_PointSize, which
// is enough to arm the demotion (glslang declares GeometryPointSize on a read) but not
// enough to create an output carrier on its own. Only the capture request can, and if
// that request never arrives the program does not merely lose the point-size column:
// DirectGLES respells the driver-side capture to a name no stage declares and the
// WHOLE capture set fails to link, while DirectVulkan mirrors a built-in the demotion
// just removed and can unwind far enough to drop the Xfb execution mode. Either way
// g_echo - an ordinary varying with nothing to do with point size - comes back poison,
// which is what this asserts. gl_PointSize itself is captured but never asserted: no
// stage writes it, so GL leaves its value undefined.
TEST_F(PointSizeDemotionScenario, ACaptureSurvivesAStageThatOnlyReadsThePointSize) {
if (!Ready()) return;
// The NATIVE Espryt path cannot do this at all, and never could: with the built-in
// hosted, the geometry stage's ESSL simply does not declare gl_PointSize unless it
// writes it, so the driver rejects the capture request with "varying undeclared"
// and the program becomes unusable. That is a pre-existing ES limitation the
// demotion happens to REPAIR - the carrier is a real, seeded, declared varying -
// so this case has something to assert only where the demotion is armed. Magma
// consumes SPIR-V and answers on both paths, which keeps the negative control.
if (Gl().BackendName() == "DirectGLES" &&
AmbientQuirkFromEnvironment("MOBILEGL_POINT_SIZE_DEMOTION") != AmbientQuirk::On) {
GTEST_SKIP() << "Espryt cannot capture a gl_PointSize its capture stage never "
"writes without the demotion; the PointSizeDemotion. ctest entry "
"runs this same case with MOBILEGL_POINT_SIZE_DEMOTION=1";
}
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPointVertexSource},
{GL_GEOMETRY_SHADER, kReadOnlyGeometrySource},
{GL_FRAGMENT_SHADER, kEchoFragmentSource}},
{"g_echo", "gl_PointSize"});
ASSERT_NE(program, 0u)
<< "the capture set failed to link. On a demoting configuration this is the "
"capture request never reaching the demotion, so the point-size capture was "
"respelled to a carrier no stage declares. Build log: "
<< m_buildLog;
const std::vector<float> captured = RunCaptureSpan(program, GL_POINTS, 1, 2);
EXPECT_TRUE(ComponentIs(captured, 0, 7.0f))
<< "the unrelated varying captured alongside gl_PointSize did not survive; the "
"point-size capture took the whole set with it";
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// THE ONE CASE THAT CAN FAIL WHEN THE DEMOTION SILENTLY STOPS BEING ARMED.
//
// Everything above captures the right bytes on llvmpipe and lavapipe whether the
// demotion ran or not - these machines host the built-in - so those cases pin that
// the demotion does no HARM and can say nothing about whether it happened. The
// arming is where the cheap mistake lives: MOBILEGL_POINT_SIZE_DEMOTION maps onto
// the two Supports*PointSize capability bits INVERTED (forcing the demotion on
// means declaring the built-in UNHOSTED), and a swap of those arms - or a dropped
// env bit anywhere between ConfigLoader, the backend init, CompileEnv and the L1
// key - would disable the device repair with every rendering case still green.
//
// Same machinery as UnlocatedIoBlockScenario's arming case: the environment says
// the demotion is pinned on, therefore the library must SAY it demoted something.
// The observable is the latched MGLOG_I each backend emits when it first builds a
// demoted program; both spell "demoted to an ordinary varying", so this one case
// covers both pinned lanes without a backend gate.
TEST_F(PointSizeDemotionScenario, TheDemotionIsActuallyArmedWhenTheEnvironmentPinsItOn) {
if (!Ready()) return;
if (AmbientQuirkFromEnvironment("MOBILEGL_POINT_SIZE_DEMOTION") != AmbientQuirk::On) {
GTEST_SKIP() << "this case needs the demotion pinned ON for the whole process, which "
"is what the PointSizeDemotion. ctest entries do with "
"MOBILEGL_POINT_SIZE_DEMOTION=1; with the variable unset the detected "
"capabilities decide, and on this machine the built-in is hosted - so "
"there would be nothing to observe";
}
if (LibraryLogPath().empty()) {
GTEST_SKIP() << "MOBILEGL_POINT_SIZE_DEMOTION is pinned on but MOBILEGL_LOG_FILE_PATH "
"is not set, so the library has nowhere to record that it demoted "
"anything; the PointSizeDemotion. ctest entries set both";
}
// Taken BEFORE the program is built, so the line this looks for can only be one
// this process wrote.
const std::uintmax_t before = LibraryLogSize();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPointVertexSource},
{GL_GEOMETRY_SHADER, kPointGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
// Drawn as well as built, so a stack that defers its backend program to first
// use still reaches the build the latched line fires in - and the capture must
// STILL be right through the carrier.
const std::vector<float> captured = RunCaptureSpan(program, GL_POINTS, 1, 1);
EXPECT_TRUE(ComponentIs(captured, 0, 8.0f))
<< "the pinned-on lane did not even capture correctly";
EXPECT_EQ(glGetError(), GL_NO_ERROR);
const std::string appended = LibraryLogSince(before);
EXPECT_NE(appended.find("demoted to an ordinary varying"), std::string::npos)
<< "MOBILEGL_POINT_SIZE_DEMOTION is pinned ON, a geometry program reading and "
"writing gl_PointSize was built and captured, and no backend ever reported "
"demoting it. The demotion is not armed - check the override mapping in the "
"backend inits (it is inverted on purpose), the CompileEnv accessors, and "
"ProgramSpirvTask's verdict plumbing. Log appended by this test:\n"
<< appended;
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,554 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PrimitivesGeneratedNoXfbScenario.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 - GL_PRIMITIVES_GENERATED COUNTS DRAWS MADE WITH TRANSFORM FEEDBACK
// INACTIVE.
//
// GL 4.6 core 13.4: the query counts what the last vertex processing stage emits,
// capture or no capture. The CTS leans its whole tessellation suite on that - the
// tessellator's output is MEASURED by an XFB-inactive PATCHES draw under
// rasterizer discard inside a GENERATED query, and the capture buffers of ~29
// tessellation tests are sized from the answer - so a backend that answers 0
// hands them a zero-byte buffer and an INVALID_OPERATION off its zero-length map.
//
// DirectVulkan serves the query from the transform-feedback stream query's
// primitivesNeeded, which VK_EXT_transform_feedback defines to count whether or
// not a capture span is open. Both the Mali-G1-Ultra driver AND Mesa lavapipe
// disagree with that definition: with no vkCmdBeginTransformFeedbackEXT recorded,
// the pair reads back 0. Where the bring-up probe measures that defect with a
// working control - or MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1 pins it on - the
// renderer accumulates XFB-inactive draws through the best proven substitute
// pool: VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT (which lavapipe hosts and passes,
// rasterizer discard included), else pipeline statistics over clipping-stage
// invocations (GL's CLIPPING_INPUT_PRIMITIVES). These cases assert the GL-visible
// answer, so on this machine they hold the reroute to the same numbers the
// healthy stream path must produce - the "two pools must agree" assertion - and
// on a healthy driver they pin the stream path itself.
//
// DirectVulkan only: DirectGLES has no GPU counter for an XFB-inactive draw at
// all (ES has no PRIMITIVES_GENERATED without a capture), and its CPU accounting
// is a different mechanism with its own tests.
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <functional>
#include <initializer_list>
#include <iterator>
#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 {
GLuint CompileShaderStage(GLenum type, const char* source, std::string* log) {
const GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint status = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteShader(shader);
return 0;
}
return shader;
}
// A capture-capable vertex-only program: the varying gives glBeginTransformFeedback
// something to capture for the mixed-span case; the XFB-inactive cases draw with the
// same program and simply never begin a span.
const char* const kVertexSource = R"(#version 430 core
out vec4 vs_out_value;
void main() {
const vec2 corners[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
vs_out_value = vec4(1.0);
gl_Position = vec4(corners[gl_VertexID % 3], 0.0, 1.0);
}
)";
// A passthrough tessellation pipeline whose all-1 levels emit exactly one
// triangle per patch - the count the tessellation cases assert.
const char* const kTessVertexSource = R"(#version 430 core
void main() {
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
const char* const kTessControlSource = R"(#version 430 core
layout(vertices = 1) out;
void main() {
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;
}
)";
const char* const kTessEvalSource = R"(#version 430 core
layout(triangles, equal_spacing, cw) in;
void main() {
gl_Position = vec4(gl_TessCoord.xy * 2.0 - 1.0, 0.0, 1.0);
}
)";
// The same tessellation pipeline with something to capture, so that
// glBeginTransformFeedback accepts it: the paused-span PATCHES case needs an
// open (but paused) capture span AND a tessellator in one program.
const char* const kTessEvalCaptureSource = R"(#version 430 core
layout(triangles, equal_spacing, cw) in;
out vec4 te_out_value;
void main() {
te_out_value = vec4(1.0);
gl_Position = vec4(gl_TessCoord.xy * 2.0 - 1.0, 0.0, 1.0);
}
)";
class PrimitivesGeneratedNoXfbScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
if (Gl().BackendName() != std::string("DirectVulkan")) {
GTEST_SKIP() << "the stream-query defect and its reroute are DirectVulkan's; "
<< Gl().BackendName()
<< " answers this query from a different mechanism";
}
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenQueries(2, m_queries);
ASSERT_NE(m_queries[0], 0u);
ASSERT_NE(m_queries[1], 0u);
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
if (m_queries[0] != 0 || m_queries[1] != 0) glDeleteQueries(2, m_queries);
m_queries[0] = m_queries[1] = 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;
ScenarioTest::TearDown();
}
// captureVarying: the name to record with glTransformFeedbackVaryings, or
// nullptr for a program that can never open a capture span.
GLuint BuildProgram(std::initializer_list<std::pair<GLenum, const char*>> stages,
const char* captureVarying) {
std::vector<GLuint> shaders;
for (const auto& [type, source] : stages) {
const GLuint shader = CompileShaderStage(type, source, &m_buildLog);
if (shader == 0) {
for (const GLuint built : shaders) glDeleteShader(built);
return 0;
}
shaders.push_back(shader);
}
const GLuint program = glCreateProgram();
for (const GLuint shader : shaders) glAttachShader(program, shader);
if (captureVarying != nullptr) {
glTransformFeedbackVaryings(program, 1, &captureVarying, GL_INTERLEAVED_ATTRIBS);
}
glLinkProgram(program);
for (const GLuint shader : shaders) glDeleteShader(shader);
GLint status = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
m_buildLog = buffer.data();
glDeleteProgram(program);
return 0;
}
m_programs.push_back(program);
return program;
}
GLuint BuildCaptureProgram() {
return BuildProgram({{GL_VERTEX_SHADER, kVertexSource}}, "vs_out_value");
}
GLuint BuildTessellationProgram(bool withCaptureVarying = false) {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
while (glGetError() != GL_NO_ERROR) {
}
if (maxTessGenLevel < 1) return 0;
return BuildProgram(
{{GL_VERTEX_SHADER, kTessVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER,
withCaptureVarying ? kTessEvalCaptureSource : kTessEvalSource}},
withCaptureVarying ? "te_out_value" : nullptr);
}
// A capture span that is open but PAUSED. The pause closes the capture, so
// every draw inside it is XFB-inactive at the backend - the stream query's
// silent case - while the GL span stays active. `program` must be the one
// that is bound: GL requires the same program at resume.
void BeginPausedSpan() {
glGenBuffers(1, &m_captureBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, m_captureBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 64 * sizeof(float), nullptr, GL_DYNAMIC_DRAW);
glBeginTransformFeedback(GL_TRIANGLES);
glPauseTransformFeedback();
}
void EndPausedSpan() {
glResumeTransformFeedback();
glEndTransformFeedback();
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
if (m_captureBuffer != 0) glDeleteBuffers(1, &m_captureBuffer);
m_captureBuffer = 0;
}
// GENERATED query around `record()`, answered with GL_QUERY_RESULT.
GLuint QueryGenerated(const std::function<void()>& record) {
glBeginQuery(GL_PRIMITIVES_GENERATED, m_queries[1]);
record();
glEndQuery(GL_PRIMITIVES_GENERATED);
GLuint generated = 0xFFFFFFFFu;
glGetQueryObjectuiv(m_queries[1], GL_QUERY_RESULT, &generated);
return generated;
}
static GLenum DrainGLErrors() {
const GLenum first = glGetError();
while (glGetError() != GL_NO_ERROR) {
}
return first;
}
const std::string& BuildLog() const { return m_buildLog; }
static std::filesystem::path LibraryLogPath() {
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
return (path != nullptr && *path != '\0') ? std::filesystem::path(path)
: std::filesystem::path();
}
static std::uintmax_t LibraryLogSize() {
std::error_code ec;
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return 0;
const std::uintmax_t size = std::filesystem::file_size(path, ec);
return ec ? 0 : size;
}
static std::string LibraryLogSince(std::uintmax_t offset) {
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return {};
std::ifstream file(path, std::ios::binary);
if (!file.good()) return {};
file.seekg(static_cast<std::streamoff>(offset));
return std::string((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
}
GLuint m_vao = 0;
GLuint m_queries[2] = {0, 0}; // [0]=written, [1]=generated
GLuint m_captureBuffer = 0;
std::vector<GLuint> m_programs;
std::string m_buildLog;
};
// The plain shape: no capture object was ever bound, no span begun, no
// rasterizer discard - just a GENERATED query around two triangles. On a
// healthy driver the stream query answers it; on an affected one the armed
// reroute must produce the same 2.
TEST_F(PrimitivesGeneratedNoXfbScenario, CountsADrawMadeWithNoCaptureSpan) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 6); });
EXPECT_EQ(DrainGLErrors(), 0u);
EXPECT_EQ(generated, 2u)
<< "GL_PRIMITIVES_GENERATED must count a draw made while transform feedback is "
"inactive (GL 4.6 core 13.4)";
}
// THE CTS SHAPE (esextcTessellationShaderUtils.cpp, captureTessellationData):
// rasterizer discard ON, transform feedback INACTIVE, the draw inside a
// GENERATED query. This is the exact query whose 0 sizes ~29 tessellation
// tests' capture buffers on the affected device.
//
// On lavapipe this case holds through the dedicated
// VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT reroute (its discard feature is
// what makes a discarded draw countable there - llvmpipe's clipping
// statistics AND stream query both read 0 under discard).
//
// The value-conditioned skip below is deliberate and narrow, for a stack
// with NO counter that survives discard: there this case is unfalsifiable,
// and a red would indict MobileGL for a hole the bring-up probe already
// measures and reports (StatisticsSubstitutePlainOnly / Unfixable). The
// exact-zero answer IS the capability signal - any wrong nonzero count
// still fails - and on every driver that counts discarded draws at all the
// full assertion runs. The device probe list holds this shape on the Mali.
TEST_F(PrimitivesGeneratedNoXfbScenario, CountsUnderRasterizerDiscardWithNoCaptureSpan) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 6); });
glDisable(GL_RASTERIZER_DISCARD);
EXPECT_EQ(DrainGLErrors(), 0u);
if (generated == 0u) {
GTEST_SKIP() << "no counter this backend can reach (stream query, dedicated "
"primitives-generated query, clipping statistics) survives "
"rasterizer discard for an XFB-inactive draw on this stack - the "
"shape is unfalsifiable here; the bring-up probe measures the same "
"hole and the POST row reports it";
}
EXPECT_EQ(generated, 2u)
<< "rasterizer discard drops primitives after clipping and must not hide them from "
"GL_PRIMITIVES_GENERATED - this is the exact shape the CTS measures the "
"tessellator with";
}
// The tessellation flavour: a PATCHES draw whose all-1 levels emit exactly
// one triangle - the count the CTS's getAmountOfVerticesGeneratedByTessellator
// protocol derives everything from. Undiscarded, so that the answer is
// holdable on this machine through whichever accounting path is armed (the
// discard interaction is the case above's business, measured separately).
TEST_F(PrimitivesGeneratedNoXfbScenario, CountsATessellatedPatchWithNoCaptureSpan) {
if (!Ready()) return;
const GLuint program = BuildTessellationProgram();
if (program == 0) {
GTEST_SKIP() << "no tessellation stages on this stack: " << BuildLog();
}
glUseProgram(program);
glPatchParameteri(GL_PATCH_VERTICES, 1);
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_PATCHES, 0, 1); });
EXPECT_EQ(DrainGLErrors(), 0u);
EXPECT_EQ(generated, 1u)
<< "a triangles-domain patch with every level 1 tessellates to exactly one "
"triangle, and GL_PRIMITIVES_GENERATED must say so with no capture active";
}
// One query span holding BOTH kinds of draw: an XFB-inactive draw, then a
// captured one, then another XFB-inactive one. The GENERATED answer must
// accumulate across the two accounting paths the armed reroute splits them
// into (stream slots for the captured draw, statistics slots for the
// others), and WRITTEN must stay exactly the captured draw's count - the
// pairing the stream path exists to keep exact. Undiscarded, so the
// accumulation invariant is holdable on this machine (see the discard
// case's comment); the triangles rasterize into the harness framebuffer,
// which nothing here reads.
TEST_F(PrimitivesGeneratedNoXfbScenario, ASpanMixingActiveAndInactiveDrawsAccumulatesBoth) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
GLuint captureBuffer = 0;
glGenBuffers(1, &captureBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 3 * 4 * sizeof(float), nullptr, GL_DYNAMIC_DRAW);
glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, m_queries[0]);
const GLuint generated = QueryGenerated([]() {
glDrawArrays(GL_TRIANGLES, 0, 3); // XFB inactive
glBeginTransformFeedback(GL_TRIANGLES);
glDrawArrays(GL_TRIANGLES, 0, 3); // captured
glEndTransformFeedback();
glDrawArrays(GL_TRIANGLES, 0, 3); // XFB inactive again
});
glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN);
GLuint written = 0xFFFFFFFFu;
glGetQueryObjectuiv(m_queries[0], GL_QUERY_RESULT, &written);
glDeleteBuffers(1, &captureBuffer);
EXPECT_EQ(DrainGLErrors(), 0u);
EXPECT_EQ(generated, 3u) << "one triangle before the span, one inside it, one after";
EXPECT_EQ(written, 1u) << "only the draw inside the span writes anything";
}
// ===================== DRAWS INSIDE A PAUSED SPAN =====================
//
// glPauseTransformFeedback closes the capture without closing the span, so a
// draw made while paused is XFB-INACTIVE at the backend - the stream query is
// exactly as silent for it as for a draw with no span at all - while
// GL_PRIMITIVES_GENERATED must still count what the last vertex processing
// stage emitted (GL 4.6 core 13.4; the WRITTEN query is the one the pause
// silences). The frontend does keep a CPU counter for paused draws, but it can
// price only 3 of the ~15 draw entry points and answers 0 for GL_PATCHES, so
// these draws are the reroute's business like any other - and the trap on the
// other side is counting them TWICE, once in each accounting.
//
// Each case measures the SAME draw twice: once with no span open at all (the
// capability control - what this stack can count) and once inside the paused
// span, and requires the two to agree. That differential is what makes these
// cases falsifying rather than vacuous: a stack where no counter reaches a
// capture-less draw fails the control and skips, while a stack that counts the
// unpaused draw and answers 0 for the paused one - which is what excluding
// paused draws from the reroute produced - fails, instead of skipping into
// green.
// The draw the CPU counter CAN price: if the span both reroutes it and adds the
// CPU delta, this reads 2.
TEST_F(PrimitivesGeneratedNoXfbScenario, APausedSpanCountsACpuPricedDrawExactlyOnce) {
if (!Ready()) return;
if (AmbientQuirkFromEnvironment("MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE") == AmbientQuirk::Off) {
GTEST_SKIP() << "the negative control replays the pre-probe accounting, whose paused "
"draws are CPU-counted on top of whatever the stream query says";
}
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
const GLuint unpaused = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 3); });
BeginPausedSpan();
const GLuint paused = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 3); });
EndPausedSpan();
EXPECT_EQ(DrainGLErrors(), 0u);
if (unpaused == 0u) {
GTEST_SKIP() << "no counter this backend can reach answers a capture-less draw on this "
"stack, so the paused half of the comparison proves nothing; the "
"bring-up probe measures the same hole and the POST row reports it";
}
EXPECT_EQ(unpaused, 1u) << "the control itself: one triangle is one primitive";
EXPECT_EQ(paused, unpaused)
<< "one triangle drawn while the capture span is paused is still one primitive "
"generated - counted once, by whichever accounting owns it, never by two of them "
"(a reroute slot AND the frontend's CPU paused counter reads 2)";
}
// The draw the CPU counter CANNOT price: GL_PATCHES, whose amplification is not
// knowable on the CPU (CountPrimitivesForDraw answers 0 for it by design) - and
// the CTS's tessellator-measuring shape. Excluding paused draws from the
// reroute left this counted by nothing at all on the affected device.
TEST_F(PrimitivesGeneratedNoXfbScenario, APausedSpanCountsATessellatedPatchExactlyOnce) {
if (!Ready()) return;
const GLuint program = BuildTessellationProgram(/*withCaptureVarying=*/true);
if (program == 0) {
GTEST_SKIP() << "no tessellation stages on this stack: " << BuildLog();
}
glUseProgram(program);
glPatchParameteri(GL_PATCH_VERTICES, 1);
const GLuint unpaused = QueryGenerated([]() { glDrawArrays(GL_PATCHES, 0, 1); });
BeginPausedSpan();
const GLuint paused = QueryGenerated([]() { glDrawArrays(GL_PATCHES, 0, 1); });
EndPausedSpan();
EXPECT_EQ(DrainGLErrors(), 0u);
if (unpaused == 0u) {
GTEST_SKIP() << "no counter this backend can reach answers a capture-less patch draw "
"on this stack, so the paused half proves nothing; the bring-up probe "
"measures the same hole and the POST row reports it";
}
EXPECT_EQ(unpaused, 1u)
<< "the control itself: a triangles-domain patch with every level 1 tessellates to "
"exactly one triangle";
EXPECT_EQ(paused, unpaused)
<< "pausing the capture does not stop the tessellator from generating that triangle, "
"and the frontend's CPU paused counter answers 0 for GL_PATCHES - so a paused "
"patch draw left out of the reroute is counted by nothing at all";
}
// The other half of the same hole: the instanced entry points never reach the
// frontend's paused accounting either, so a paused instanced draw excluded from
// the reroute is likewise counted by nothing.
TEST_F(PrimitivesGeneratedNoXfbScenario, APausedSpanCountsAnInstancedDrawExactlyOnce) {
if (!Ready()) return;
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
const GLuint unpaused =
QueryGenerated([]() { glDrawArraysInstanced(GL_TRIANGLES, 0, 3, 4); });
BeginPausedSpan();
const GLuint paused = QueryGenerated([]() { glDrawArraysInstanced(GL_TRIANGLES, 0, 3, 4); });
EndPausedSpan();
EXPECT_EQ(DrainGLErrors(), 0u);
if (unpaused == 0u) {
GTEST_SKIP() << "no counter this backend can reach answers a capture-less draw on this "
"stack, so the paused half proves nothing";
}
EXPECT_EQ(unpaused, 4u) << "the control itself: four instances of one triangle";
EXPECT_EQ(paused, unpaused)
<< "four instances generate four primitives whether or not the capture span is "
"paused, and no instanced entry point reaches the frontend's paused accounting";
}
// THE ONE CASE THAT CAN FAIL WHEN THE REROUTE SILENTLY STOPS BEING ARMED -
// the UnlocatedIoBlockScenario shape, for the same reason: every case above
// is green here whether the reroute ran or not (that is the "two pools
// agree" point), so none of them can say the pinned lane actually exercised
// a reroute pool. This one asserts a LIBRARY OBSERVABLE against the
// environment: with MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE pinned on, an
// XFB-inactive draw inside a GENERATED span must make the renderer say -
// through its latched MGLOG_I - that it engaged the reroute. It reads
// MG_Config not at all (on Android this module links the shipping library)
// and trusts only the log bytes appended after it started.
TEST_F(PrimitivesGeneratedNoXfbScenario, TheRerouteIsActuallyArmedWhenTheEnvironmentPinsItOn) {
if (!Ready()) return;
if (AmbientQuirkFromEnvironment("MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE") != AmbientQuirk::On) {
GTEST_SKIP() << "this case needs the reroute pinned ON for the whole process, which "
"is what the PrimGenReroute. ctest entry does with "
"MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1; unset, the bring-up probe "
"decides and this machine's verdict is its own business";
}
if (LibraryLogPath().empty()) {
GTEST_SKIP() << "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE is pinned on but "
"MOBILEGL_LOG_FILE_PATH is not set, so the library has nowhere to "
"record that it rerouted anything; the PrimGenReroute. ctest "
"entry sets both";
}
const GLuint program = BuildCaptureProgram();
ASSERT_NE(program, 0u) << BuildLog();
glUseProgram(program);
// Taken BEFORE the draw, so the line this looks for can only be one this
// process wrote for this span. The latch fires on the FIRST rerouted
// draw, which is inside the query below.
const std::uintmax_t before = LibraryLogSize();
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 3); });
EXPECT_EQ(DrainGLErrors(), 0u);
EXPECT_EQ(generated, 1u) << "the pinned-on lane did not even count correctly";
const std::string appended = LibraryLogSince(before);
EXPECT_NE(appended.find("PRIMITIVES_GENERATED reroute engaged"), std::string::npos)
<< "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE is pinned ON, an XFB-inactive draw ran inside "
"a GENERATED query, and the renderer never reported engaging the reroute. The "
"quirk is not armed - check the override mapping "
"(ChoosePrimitivesGeneratedReroute) and the arming gate in "
"VulkanRenderer::BeginXfbQueryForDraw. Log appended by this test:\n"
<< appended;
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,526 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/UnlocatedIoBlockScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - AN INTER-STAGE INTERFACE BLOCK STILL FINDS ITS OTHER END WITH ITS LOCATION
// QUALIFIER REMOVED.
//
// The Mali-G1-Ultra ES driver delivers NOTHING through an interface block that carries an
// explicit layout(location=) once a tessellation or geometry stage is in the pipeline: the
// stages compile, the program links with an empty info log, the draw runs, and the consuming
// stage reads zeroes. Measured with no MobileGL in the process - a bare EGL/GLES 3.2 program
// built from the five ESSL stages MobileGL emits reproduces it, and removing the qualifier
// from the blocks (and changing nothing else) makes the same program carry its payload. The
// locations are not the application's in the first place: these shaders declare none, and
// glslang's cross-stage IO resolver invents them.
//
// DirectGLES answers by dropping the decoration for those programs (StripIoBlockLocationsPass),
// leaving ES to match the blocks by block name and member sequence. THAT is what this scenario
// guards: with the strip forced on, a five-stage pipeline whose four block boundaries carry no
// location must still deliver its payload end to end. It is the assertion the affected device
// cannot make about itself in CI, and the one the healthy machines here CAN make - which is
// the opposite of IoBlockNameCollisionScenario's position, where the machines that run it
// cannot reproduce the defect at all.
//
// The strip is armed for this suite by MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1 on the ctest
// entry, because llvmpipe carries a located block correctly and the driver POST would
// therefore never turn the emulation on here. The SAME cases also run under the ambient
// registrations with the emulation off, so both spellings of the interface are covered and a
// regression in either shows up.
//
// 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. That is what an interface whose two ends stopped matching looks like.
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#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 {
// NOTHING in these five stages declares a location. Every location the emitted ESSL
// carries is invented by the cross-stage resolver, which is exactly the shape the
// affected driver mishandles and exactly what the strip removes.
//
// Two members per block, of different types, because an interface that is matched by
// name and member sequence rather than by location has to agree on the sequence too -
// a repair that silently reordered or dropped a member would still light up green with
// one member in the block.
const char* const kVertexSource = R"(#version 420 core
out VsData {
vec4 payload;
vec2 tint;
} vs_out;
out float vs_tcs_alive;
void main()
{
vs_out.payload = vec4(0.0, 1.0, 0.0, 1.0);
vs_out.tint = vec2(0.25, 0.5);
vs_tcs_alive = 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;
vec2 tint;
} tcs_in[];
in float vs_tcs_alive[];
out TcsData {
vec4 payload;
vec2 tint;
} tcs_out[];
out float tcs_tes_alive[];
void main()
{
tcs_out[gl_InvocationID].payload = tcs_in[gl_InvocationID].payload;
tcs_out[gl_InvocationID].tint = tcs_in[gl_InvocationID].tint;
tcs_tes_alive[gl_InvocationID] = vs_tcs_alive[gl_InvocationID];
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;
}
)";
// Distinct block names, so this case is about the LOCATION and nothing else; the
// one-name-in-both-directions shape is the case below.
const char* const kDistinctTessEvalSource = R"(#version 420 core
layout(isolines, point_mode) in;
in TcsData {
vec4 payload;
vec2 tint;
} tes_in[];
in float tcs_tes_alive[];
out TesData {
vec4 payload;
vec2 tint;
} tes_out;
out float tes_gs_alive;
void main()
{
tes_out.payload = tes_in[0].payload;
tes_out.tint = tes_in[0].tint;
tes_gs_alive = tcs_tes_alive[0];
}
)";
// The 420pack shape: ONE name for the block this stage consumes and the block it
// produces. Legal desktop GLSL, and the case where the two repairs have to compose -
// the rename gives the two blocks one spelling per producing stage, the strip takes
// their locations off, and the interfaces still have to meet.
const char* const kCollidingTessEvalSource = R"(#version 420 core
layout(isolines, point_mode) in;
in TcsData {
vec4 payload;
vec2 tint;
} tes_in[];
in float tcs_tes_alive[];
out TcsData {
vec4 payload;
vec2 tint;
} tes_out;
out float tes_gs_alive;
void main()
{
tes_out.payload = tes_in[0].payload;
tes_out.tint = tes_in[0].tint;
tes_gs_alive = tcs_tes_alive[0];
}
)";
// One geometry source per evaluation stage, because the block it consumes is named
// after the block the evaluation stage produced.
const char* const kDistinctGeometrySource = R"(#version 420 core
layout(points) in;
layout(triangle_strip, max_vertices = 4) out;
in TesData {
vec4 payload;
vec2 tint;
} gs_in[];
in float tes_gs_alive[];
out GsData {
vec4 payload;
vec2 tint;
} gs_out;
out float gs_fs_alive;
void EmitCorner(vec2 corner)
{
gs_out.payload = gs_in[0].payload;
gs_out.tint = gs_in[0].tint;
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 kCollidingGeometrySource = R"(#version 420 core
layout(points) in;
layout(triangle_strip, max_vertices = 4) out;
in TcsData {
vec4 payload;
vec2 tint;
} gs_in[];
in float tes_gs_alive[];
out GsData {
vec4 payload;
vec2 tint;
} gs_out;
out float gs_fs_alive;
void EmitCorner(vec2 corner)
{
gs_out.payload = gs_in[0].payload;
gs_out.tint = gs_in[0].tint;
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));
}
)";
// Green ONLY when both block members arrived: a repair that kept the first member and
// lost the second would otherwise pass. Red when the plain varying is missing too, so
// "the pipeline is broken" and "the block is broken" cannot be confused.
const char* const kFragmentSource = R"(#version 420 core
in GsData {
vec4 payload;
vec2 tint;
} fs_in;
in float gs_fs_alive;
out vec4 fragColor;
void main()
{
if (gs_fs_alive <= 0.5) {
fragColor = vec4(1.0, 0.0, 0.0, 1.0);
} else if (abs(fs_in.tint.x - 0.25) > 0.01 || abs(fs_in.tint.y - 0.5) > 0.01) {
fragColor = vec4(0.0, 0.0, 0.0, 1.0);
} else {
fragColor = fs_in.payload;
}
}
)";
class UnlocatedIoBlockScenario : 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;
}
// Same calibration IoBlockNameCollisionScenario uses, and for the same reason:
// GL_MAX_TESS_GEN_LEVEL is a real backend answer while GL_MAX_GEOMETRY_* are
// frontend constants, so a stack with no five-stage pipeline is recognised by
// trying to build one, not by asking.
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; }
// The library log this process is writing, or an empty path when none was
// configured. MOBILEGL_LOG_FILE_PATH is read at log-init, before anything this
// fixture can reach, so the ctest entry sets it and this only reads it back.
static std::filesystem::path LibraryLogPath() {
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
return (path != nullptr && *path != '\0') ? std::filesystem::path(path)
: std::filesystem::path();
}
// How many bytes the library log already holds. Everything this fixture asserts on
// is searched from here forward, because the file is APPENDED to by every process
// in the lane and a line left behind by an earlier one would otherwise satisfy the
// assertion without this process having done anything at all.
static std::uintmax_t LibraryLogSize() {
std::error_code ec;
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return 0;
const std::uintmax_t size = std::filesystem::file_size(path, ec);
return ec ? 0 : size;
}
static std::string LibraryLogSince(std::uintmax_t offset) {
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return {};
std::ifstream file(path, std::ios::binary);
if (!file.good()) return {};
file.seekg(static_cast<std::streamoff>(offset));
return std::string((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
}
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;
};
TEST_F(UnlocatedIoBlockScenario, BlocksCarryTheirPayloadThroughFiveStages) {
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))
<< "a four-boundary interface-block chain did not deliver its payload: " << centre
<< " (blue: nothing drew; red: the plain varying was lost too; black: a block "
"member arrived wrong, i.e. the interface stopped matching)";
}
// The two repairs together. The rename is what makes the evaluation stage's two
// TcsData blocks one spelling per producing stage; the strip then takes the locations
// off the names the rename just settled. Either one alone leaves a working program on
// these machines, so this case is here to catch the two of them disagreeing.
TEST_F(UnlocatedIoBlockScenario, BlocksNamedInBothDirectionsStillMeetWithoutLocations) {
if (!Ready()) return;
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();
}
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 renamed-and-unlocated interface chain lost its payload: " << centre;
}
// THE ONE CASE THAT CAN FAIL WHEN THE REPAIR SILENTLY STOPS BEING ARMED.
//
// Everything above renders green on llvmpipe whether the blocks were stripped or not -
// this machine carries a located block correctly - so those cases pin that the strip
// does no HARM and can say nothing about whether it happened. That leaves the arming
// itself untested, and the arming is where the cheap mistake lives: Loader.cpp maps
// MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS onto the capability INVERTED (forcing the
// emulation on means declaring located blocks UNSUPPORTED), and a one-line swap of
// those two arms would disable the device repair with every test here still green.
//
// So this case asserts a LIBRARY OBSERVABLE against the environment, the shape
// AsyncCompileScenario::ExtensionStringMatchesTheConfiguration uses: the environment
// says the emulation is pinned on, therefore the library must SAY it stripped
// something. The observable is the latched MGLOG_I DirectGLES emits the first time the
// pass fires (Managers.cpp); it is INFO rather than DEBUG precisely so that this
// assertion is possible in the builds CI runs.
//
// Two things it deliberately does NOT do: it does not read MG_Config (on Android this
// module links the shipping library, which exports nothing internal - the reason
// ViewportArrayScenario's control moved to the environment), and it does not trust the
// whole log file, only the bytes appended after this test started.
TEST_F(UnlocatedIoBlockScenario, TheEmulationIsActuallyArmedWhenTheEnvironmentPinsItOn) {
if (!Ready()) return;
if (AmbientQuirkFromEnvironment("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS") != AmbientQuirk::On) {
GTEST_SKIP() << "this case needs the emulation pinned ON for the whole process, which "
"is what the UnlocatedIoBlocks. ctest entry does with "
"MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1; with the variable unset the "
"driver POST decides, and on this machine it decides the blocks are "
"fine - so there would be nothing to observe";
}
if (LibraryLogPath().empty()) {
GTEST_SKIP() << "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS is pinned on but "
"MOBILEGL_LOG_FILE_PATH is not set, so the library has nowhere to "
"record that it stripped anything; the UnlocatedIoBlocks. ctest entry "
"sets both";
}
if (Gl().BackendName() != std::string("DirectGLES")) {
GTEST_SKIP() << "the strip is DirectGLES's; " << Gl().BackendName()
<< " hands the module to the driver as SPIR-V, where Location is how "
"interfaces are matched";
}
// Taken BEFORE the program is built, so the line this looks for can only be one
// this process wrote. The latch means it is emitted at the FIRST stage of the
// FIRST affected program, which is inside the build below.
const std::uintmax_t before = LibraryLogSize();
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 nothing would arm the strip: " << BuildLog();
}
// Drawn as well as built, so a stack that defers its backend program to first use
// still reaches the transpile this is asserting about.
const Rgba8 centre = DrawAndReadCentre(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_TRUE(IsGreen(centre)) << "the pinned-on lane did not even render correctly: " << centre;
const std::string appended = LibraryLogSince(before);
EXPECT_NE(appended.find("WITHOUT their layout(location) qualifier"), std::string::npos)
<< "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS is pinned ON, a five-stage program with four "
"interface-block boundaries was built and drawn, and DirectGLES never reported "
"stripping a single location. The emulation is not armed - check the override "
"mapping in Loader.cpp (it is inverted on purpose) and the arming gate in "
"Managers.cpp. Log appended by this test:\n"
<< appended;
}
} // namespace
} // namespace MGITest
@@ -525,7 +525,7 @@ void main() { fragColor = vec4(float(gsIndex) * 16.0 / 255.0, 0.0, 0.0, 1.0); }
//
// 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 in a process started with MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION=0
// the SAME program in a process started with MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION=0
// (the NoViewportArrayEmulation. ctest entry) 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
@@ -551,10 +551,10 @@ void main() { fragColor = vec4(float(gsIndex) * 16.0 / 255.0, 0.0, 0.0, 1.0); }
// entry for it, so the control still runs in every ctest run; anywhere else - the
// ambient ctest entries, or the binary run straight from a device shell - the
// emulation is on and this case skips.
if (AmbientQuirkFromEnvironment("MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION") != AmbientQuirk::Off) {
if (AmbientQuirkFromEnvironment("MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION") != AmbientQuirk::Off) {
GTEST_SKIP() << "this is the negative control for the emulation and needs it off for the "
"whole process; the NoViewportArrayEmulation. ctest entry runs it with "
"MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION=0";
"MOBILEGL_ESPRYT_FORCE_VIEWPORT_ARRAY_EMULATION=0";
}
IntTarget target = MakeIntTarget(kSurfaceSide, kSurfaceSide);
@@ -8,6 +8,8 @@
#include "BufferObject.h"
#include <Config.h>
#include <atomic>
namespace MobileGL::MG_State::GLState {
@@ -65,6 +67,14 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::NotifyContentWrite(SizeT offset, SizeT size) {
if (size == 0) {
// An empty write moves the serial and nothing else, exactly as NotifySubData
// and NotifyFlushMappedRange do: it wrote no byte, so it must not promote an
// undefined store to "has content" - that would cost the next orphaning
// respecification a full-size upload of bytes the application never wrote.
++m_changeSerial;
return;
}
m_hasDefinedContent = true;
if (m_resource.IsGpuResident()) {
// The write already landed in coherent GPU memory; the backend has no separate
@@ -109,7 +119,10 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::Respecify(SizeT size, const void* data) {
ReleaseMemory();
// The store a live mapping wrote into is about to be replaced, so landing those
// bytes into it would copy a whole mapped range (an adopted arena's map is the
// arena) into storage the next line hands back.
ReleaseMemory(false);
RedefineStorage(size);
if (data && size > 0) {
Memcpy(m_resource.Bytes(), data, size);
@@ -126,6 +139,7 @@ namespace MobileGL::MG_State::GLState {
// distinguishes the two cases, and it is cleared just above.
m_storageFlags = GL_DYNAMIC_STORAGE_BIT | GL_MAP_READ_BIT | GL_MAP_WRITE_BIT;
NotifyRespecify();
TryAdoptLargeStorage();
}
void BufferObject::Resize(SizeT size) {
@@ -133,7 +147,9 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::AllocateImmutableStorage(SizeT size, const void* data, GLbitfield storageFlags) {
ReleaseMemory();
// Same as Respecify: the bytes a live mapping staged have nowhere to land, the
// store they belong to is being replaced.
ReleaseMemory(false);
RedefineStorage(size);
if (data) {
Memcpy(m_resource.Bytes(), data, size);
@@ -144,6 +160,33 @@ namespace MobileGL::MG_State::GLState {
m_isImmutableStorage = true;
m_storageFlags = storageFlags;
NotifyRespecify();
TryAdoptLargeStorage();
}
// Back a LARGE store with the backend's persistently+coherently mapped GPU
// storage the moment it is (re)defined, without waiting for the app to map it.
// Minecraft 26.3 streams chunk meshes into 128MB vertex arenas with plain
// glNamedBufferSubData - the one write API that carries no synchronization
// hint - and on Mali every route that hands the driver a write into a busy
// MUTABLE store either parks the calling thread (glBufferSubData, and
// glMapBufferRange even with GL_MAP_UNSYNCHRONIZED_BIT) or ghost-copies the
// whole destination on a driver worker (staged glCopyBufferSubData, and a
// range-invalidating map: ~167ms per touched arena, the recurring in-world
// hiccup). An adopted coherent map is the one shape with NO per-write driver
// call at all: every SubData lands as a plain memcpy into GPU-visible memory,
// and the shadow copy is dropped (a 128MB arena stops costing 128MB of RAM).
// Only attempted for stores the size of mesh arenas: small buffers keep the
// shadow model whose draw-time flush already prices them correctly.
void BufferObject::TryAdoptLargeStorage() {
constexpr SizeT kLargeBufferAdoptBytes = 16u * 1024u * 1024u;
if (MG_Config::Features.DisableLargeBufferAdoption) return;
if (m_size < kLargeBufferAdoptBytes) return;
if (m_resource.IsGpuResident()) return;
if (m_isMapped) return;
if (g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) return;
if (void* base = g_bufferBackendOps->AcquirePersistentMap(*this)) {
m_resource.AdoptPersistentMap(base);
}
}
void BufferObject::UploadData(DataPtr data, SizeT atOffset) {
@@ -160,24 +203,45 @@ namespace MobileGL::MG_State::GLState {
m_usage = usage;
}
void BufferObject::ReleaseMemory() {
void BufferObject::ReleaseMemory(Bool landStagedWrites) {
if (!m_isMapped) return;
if (m_mappingAccess & BufferMappingAccessBit::Write) { // if we wrote to the buffer
// A persistent GPU-resident map wrote straight into coherent GPU memory, so
// there is nothing to copy back and no range to push down on unmap.
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() + m_stagingBias,
m_mappedRange.end - m_mappedRange.start);
if (landStagedWrites &&
(m_mappingAccess & BufferMappingAccessBit::Write)) { // if we wrote to the buffer
if (!(m_mappingAccess & BufferMappingAccessBit::FlushExplicit)) { // if we didn't flush explicitly
const SizeT mappedLength = m_mappedRange.end - m_mappedRange.start;
if (m_resource.IsGpuResident()) {
// A persistent map of an adopted store wrote straight into coherent
// GPU memory: nothing to copy back, no range to push down. A
// NON-persistent write map is a different thing: the application
// wrote a staging copy (glMapBuffer and glMapBufferRange hand one out
// regardless of where the store lives), and GL requires those bytes
// to be visible to every later command the moment glUnmapBuffer
// returns. Residency used to come only from a coherent persistent
// map, which never has a staging copy, so the copy-back was simply
// skipped for a resident store; residency now also comes from a
// shader storage binding (EnsureGpuResidentStorage at draw time) and
// from large-store adoption (TryAdoptLargeStorage), both of which an
// application then re-initialises through an ordinary map/write/unmap.
// Skipping the copy-back dropped every one of those writes. Land the
// staged bytes through the same route glBufferSubData takes into an
// adopted store - the backend's flush op is for stores it keeps a
// separate copy of and must not run here.
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
LandBytesIntoResidentStore(m_mappedRange.start,
{m_stagingData.data() + m_stagingBias, mappedLength});
}
} else {
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
Memcpy(m_resource.Bytes() + m_mappedRange.start, m_stagingData.data() + m_stagingBias,
mappedLength);
}
NotifyFlushMappedRange(m_mappedRange, m_mappingAccess);
}
NotifyFlushMappedRange(m_mappedRange, m_mappingAccess);
}
m_stagingData.clear();
}
m_stagingData.clear();
m_isMapped = false;
m_mappingAccess = BufferMappingAccessBit::Null;
m_mappedRange = {0, 0};
@@ -197,8 +261,21 @@ namespace MobileGL::MG_State::GLState {
MOBILEGL_ASSERT(end <= m_mappedRange.end, "Flush range out of bounds: mappedRange.end (%zu) < end (%zu)",
m_mappedRange.end, end);
// 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.
// A FLUSH_EXPLICIT map can sit on an adopted store: the map itself never adopts
// (only a coherent persistent one does), but a shader storage binding or
// large-store adoption may have made the buffer resident before the map. The
// flushed bytes then take the same landing as any other CPU write into an
// adopted store - a persistent map already wrote them in place and only has
// to publish the change, a non-persistent map staged them and has to land
// them. The backend's flush op is for stores it keeps a separate copy of.
if (m_resource.IsGpuResident()) {
if (m_mappingAccess & BufferMappingAccessBit::Persistent) {
NotifyContentWrite(start, length);
} else {
LandBytesIntoResidentStore(start, {m_stagingData.data() + m_stagingBias + offset, length});
}
return;
}
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
Memcpy(m_resource.Bytes() + start, m_stagingData.data() + m_stagingBias + offset, length);
}
@@ -253,10 +330,51 @@ namespace MobileGL::MG_State::GLState {
"UploadSubData out of bounds: atOffset (%zu) + data.size (%zu) > m_size (%zu)", atOffset,
data.size, m_size);
// An adopted store's Bytes() IS the memory in-flight frames are reading, and
// GL orders a glBufferSubData after those already-submitted reads: the write
// has to take the resident landing, never a plain host write into the mapping.
// Shadow-backed stores need none of this: the Memcpy below touches only the
// shadow, and the backend's SubData op does its own ordering against in-flight
// work.
if (m_resource.IsGpuResident()) {
LandBytesIntoResidentStore(atOffset, data);
return;
}
Memcpy(m_resource.Bytes() + atOffset, data.data, data.size);
NotifyContentWrite(atOffset, data.size);
}
// A backend that can land the bytes on the GPU timeline takes them here, untouched
// by the mapping - an in-place host write into coherent memory tore the frames
// still reading the old bytes (Minecraft patches LIVE chunk sections this way).
// The bytes are then not current in the mapping until the backend's ordered copy
// executes, so reads reconcile through the same gate GPU-written buffers use.
//
// Without that op the write lands in place, after retiring the GPU writes this store
// is known to be waiting on: a backend that defers work (DirectVulkan's frame command
// buffer) may still be holding a recorded-but-unsubmitted dispatch that GL orders this
// write AFTER, and writing the mapping now would land the bytes underneath that
// dispatch - its increments then execute on top of the newer data and invert the call
// order. That gate only knows about work that WROTE the store (MarkGpuWritten); work
// that merely READS it - a draw sourcing an adopted vertex arena - is not tracked here,
// so a backend without the op still owes the ordering against its own recorded reads.
// NotifyContentWrite on a resident store only bumps the serial: the backend has no
// separate copy to sync, so no transfer op runs.
void BufferObject::LandBytesIntoResidentStore(SizeT offset, DataPtr bytes) {
if (bytes.size > 0 && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) {
g_bufferBackendOps->ResidentSubData(*this, offset, bytes);
m_hasDefinedContent = true;
++m_changeSerial;
m_gpuWritePending = true;
return;
}
SyncGpuWrites();
Memcpy(m_resource.Bytes() + offset, bytes.data, bytes.size);
NotifyContentWrite(offset, bytes.size);
}
void BufferObject::FillSubData(DataPtr pattern, SizeT atOffset, SizeT size) {
MOBILEGL_ASSERT(pattern.data != nullptr && pattern.size > 0,
"FillSubData requires a non-empty pattern.");
@@ -269,9 +387,29 @@ namespace MobileGL::MG_State::GLState {
"Cannot fill data while buffer is non-persistently mapped.");
if (size == 0) return;
// A clear is ordered after all earlier GPU writes. Partial clears additionally need the
// retained shadow bytes; whole-store clears need the same synchronization before writing
// an adopted persistent mapping that the GPU may still be accessing.
// An adopted store takes the same landing as UploadSubData: the in-place write
// below would tear in-flight readers of the mapping. The pattern is expanded
// first because the landing takes the final bytes, not a repeat rule - which is
// why only a backend that actually takes them comes through here. Without that
// op the landing would memcpy the expansion into the mapping the loop below
// fills in place anyway, so a whole-arena clear would allocate a whole arena
// for nothing.
if (m_resource.IsGpuResident() && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) {
Vector<Uint8> expanded(size);
if (pattern.size == 1) {
Memset(expanded.data(), *static_cast<const Uint8*>(pattern.data), size);
} else {
for (SizeT at = 0; at < size; at += pattern.size) {
Memcpy(expanded.data() + at, pattern.data, pattern.size);
}
}
LandBytesIntoResidentStore(atOffset, {expanded.data(), size});
return;
}
// A clear is ordered after all earlier GPU writes; partial clears additionally need
// the retained shadow bytes, and a resident store the backend cannot take the bytes
// for is written in place, which needs the same synchronization the landing does.
SyncGpuWrites();
Uint8* dst = m_resource.Bytes() + atOffset;
@@ -305,6 +443,14 @@ namespace MobileGL::MG_State::GLState {
size, m_size);
src->SyncGpuWrites();
// An adopted DESTINATION takes the same landing as UploadSubData: the in-place
// write below would tear in-flight readers of the mapping, and pending recorded
// GPU writes to it must retire before the copy lands or they would execute on
// top of it.
if (m_resource.IsGpuResident()) {
LandBytesIntoResidentStore(dstOffset, {src->m_resource.Bytes() + srcOffset, size});
return;
}
Memcpy(m_resource.Bytes() + dstOffset, src->m_resource.Bytes() + srcOffset, size);
NotifyContentWrite(dstOffset, size);
}
@@ -340,6 +486,16 @@ namespace MobileGL::MG_State::GLState {
if (m_resource.IsGpuResident()) {
return true;
}
// Adoption releases the CPU shadow, and a live mapping may BE that shadow: a
// persistent map that did not itself adopt (a FLUSH_EXPLICIT one, or a read map)
// handed the application shadow + offset, and GL keeps that pointer valid while
// the buffer is drawn with - which is exactly when this runs, on the storage
// binding walk. Freeing it under the application is a use-after-free, so a mapped
// buffer keeps the shadow model until it is unmapped; the binding that follows
// adopts then. Same rule as TryAdoptLargeStorage.
if (m_isMapped) {
return false;
}
if (m_size == 0 || g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) {
return false;
}
@@ -358,7 +514,20 @@ namespace MobileGL::MG_State::GLState {
// The app is about to look at the bytes; a shader may have rewritten them since
// the shadow was last authoritative. Also needed for a write map without an
// invalidate bit, whose staging copy is seeded from the shadow.
SyncGpuWrites();
//
// One map shape looks at nothing: a non-persistent write map that discards the
// range it maps gets a staging copy the seeding below skips, so no reader of the
// store exists between here and the unmap. Reconciling an ADOPTED store would
// still cost the backend's full drain-and-wait (its queued landings are made
// visible to the CPU by finishing the pipeline), once per map, on exactly the
// streaming arena the adoption exists to keep cheap. The outstanding-write flag
// stays set, so the first read that DOES look at the bytes still pays for it.
const Bool discardsWhatItMaps =
(access & BufferMappingAccessBit::Write) && !(access & BufferMappingAccessBit::Persistent) &&
(access & (BufferMappingAccessBit::InvalidateRange | BufferMappingAccessBit::InvalidateBuffer));
if (!(m_resource.IsGpuResident() && discardsWhatItMaps)) {
SyncGpuWrites();
}
m_isMapped = true;
m_mappingAccess = access;
m_mappedRange = range;
@@ -80,6 +80,19 @@ namespace MobileGL {
void (*Respecify)(BufferObject& bufferObject) = nullptr;
// Contents update of [offset, offset + size) from the shadow.
void (*SubData)(BufferObject& bufferObject, SizeT offset, SizeT size) = nullptr;
// Contents update of an ADOPTED (GPU-resident) store. `data` holds the app's
// bytes, valid for the duration of the call only (a write map's staging
// store is freed the moment the unmap that lands it returns); the frontend
// has NOT touched the resident mapping. GL orders a
// glBufferSubData after already-submitted GPU reads of the store, and an
// in-place host write into the coherent mapping tears the frames still
// reading the old bytes (Minecraft patches LIVE chunk sections this way -
// the tear shows as one-frame wrong geometry/UVs during fast movement). The
// backend lands the bytes on the GPU timeline instead: after in-flight
// readers, before the next consumer. The frontend marks the buffer
// gpu-write-pending so reads reconcile through ReadbackFromGpu. Backends
// without this op keep the legacy ordered in-place host write.
void (*ResidentSubData)(BufferObject& bufferObject, SizeT offset, DataPtr data) = nullptr;
// Write-map flush (glUnmapBuffer / glFlushMappedBufferRange). Carries the
// app's real mapping flags so the backend can honour INVALIDATE_* /
// UNSYNCHRONIZED semantics per call instead of merging them.
@@ -146,9 +159,14 @@ namespace MobileGL {
// Adopt backend host-visible coherent GPU storage as the source of truth
// (used for GPU-written targets like transform feedback capture, so
// MapBuffer/GetBufferSubData read real GPU results). No-op when already
// resident or when the backend declines.
// resident, while the buffer is mapped (adoption releases the shadow a
// mapping may have handed the application), or when the backend declines.
Bool EnsureGpuResidentStorage();
void ReleaseMemory();
// Unmap. A write map's staged bytes land in the store on the way out, unless
// the caller is about to replace that store (a respecification) and passes
// false - landing them there would copy a whole mapped range into storage
// being handed back on the next line.
void ReleaseMemory(Bool landStagedWrites = true);
void FlushMemoryRange(SizeT offset, SizeT length);
// Pushes the persistently-mapped write range to the backend; called by
@@ -211,6 +229,10 @@ namespace MobileGL {
// Sizes the store for a (re)definition, renewing an adopted GPU-resident
// mapping across it. See the definition for why the renewal is not optional.
void RedefineStorage(SizeT size);
// Backend-initiated coherent adoption for mesh-arena-sized stores; see the
// definition for the driver behavior that makes every other write route to
// a busy large mutable store a frame-scale stall.
void TryAdoptLargeStorage();
void NotifyRespecify();
void NotifySubData(SizeT offset, SizeT size);
void NotifyFlushMappedRange(Range1D range, Flags<BufferMappingAccessBit> appAccess);
@@ -219,6 +241,12 @@ namespace MobileGL {
// so this only bumps the change serial; otherwise it dispatches a backend
// SubData transfer to sync the backend's separate GPU copy.
void NotifyContentWrite(SizeT offset, SizeT size);
// The one route CPU-sourced bytes take into an ADOPTED (GPU-resident) store:
// glBufferSubData, a buffer clear, a buffer copy, and the landing of a
// non-persistent write map at unmap / explicit flush all go through it, so
// the routes cannot drift apart again. Carries no mapping asserts on
// purpose - the unmap landing runs while the buffer is still mapped.
void LandBytesIntoResidentStore(SizeT offset, DataPtr bytes);
static Uint64 AllocateLifetimeId();
@@ -517,7 +517,9 @@ namespace MobileGL::MG_State::GLState {
};
} // namespace
void ProgramLinkTask::DeferLog(String line) { diagnostics.logLines.push_back(Move(line)); }
void ProgramLinkTask::DeferLog(String line, const Int level) {
diagnostics.logLines.push_back({level, Move(line)});
}
void ProgramLinkTask::SubmitAfter(const Vector<SharedPtr<ShaderCompileTask>>& deps) {
// +1 for the guard this function releases itself. Without it, a dependency that
@@ -855,6 +857,18 @@ namespace MobileGL::MG_State::GLState {
spirvHandoff.reflection.uniformReflection = artifacts.uniformReflection;
spirvHandoff.reflection.blockReflection = artifacts.blockReflection;
spirvHandoff.reflection.tProgramBlockIndexToGl = artifacts.tProgramBlockIndexToGl;
// The capture set is NOT part of that slice (see the handoff's own comment), and the
// point-size demotion needs exactly one bit out of it: whether anything asked to
// capture gl_PointSize. Derived here, where ResolveTransformFeedbackVaryings has
// just filled artifacts.xfbVaryings and before the join moves them away, because a
// capture stage that only READS the built-in still has to declare the carrier the
// capture binds to - and phase B has no other way to learn that.
for (const ProgramObject::XfbVarying& varying : artifacts.xfbVaryings) {
if (varying.name == "gl_PointSize") {
spirvHandoff.captureRequestsPointSize = true;
break;
}
}
// 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.
@@ -896,6 +910,11 @@ namespace MobileGL::MG_State::GLState {
// env snapshot ProgramSpirvTask hands the chain, so the key and the bytes can never
// disagree.
keyInputs.nativeFloat64 = env.ConsumesFloat64Natively();
// The second and third capability bits, under exactly the same rule: each arms a
// phase-B rewrite of the cached modules (the point-size demotion), read from the
// same env snapshot that phase B will consult, so key and bytes cannot disagree.
keyInputs.demoteTessellationPointSize = env.DemotesTessellationPointSize();
keyInputs.demoteGeometryPointSize = env.DemotesGeometryPointSize();
keyInputs.stages.reserve(in.shaders.size());
for (const LinkShaderInput& shader : in.shaders) {
const ShaderCompileArtifacts& compiled = CompiledArtifacts(shader.compiled);
@@ -117,6 +117,19 @@ namespace MobileGL::MG_State::GLState {
// for phase B after the join has moved `artifacts` away.
ProgramObject::LinkArtifacts reflection;
// Whether the RESOLVED transform-feedback capture set names gl_PointSize - the
// one fact about `artifacts.xfbVaryings` phase B needs, carried as a derived
// bool rather than by widening the slice above, which is deliberately the five
// (now eight) fields BuildGlobalUboRouting consumes and nothing else.
//
// It has to be here and cannot be re-derived: the point-size demotion forces the
// capture-capable stage to declare its carrier even when that stage never WRITES
// the built-in (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram's
// `captureRequestsPointSize`), and by phase B the only record of the request is
// this bit. No new L1 key material: the key already covers
// `requestedXfbVaryings`, of which this is a function.
Bool captureRequestsPointSize = false;
// 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:
@@ -201,8 +214,10 @@ namespace MobileGL::MG_State::GLState {
// Worker-side MGLOG replacement: appended to diagnostics.logLines and replayed by the
// join, on the GL thread, where a serial implementation would have printed it.
// Logging straight from a worker interleaves mid-line with the GL thread's output and
// lands out of order relative to the glLinkProgram that caused it.
void DeferLog(String line);
// lands out of order relative to the glLinkProgram that caused it. `level` is the
// severity the replay uses; DEBUG (the default) is compiled out of every shipped
// build, so a line that has to survive one names its own.
void DeferLog(String line, Int level = MOBILEGL_LOG_LEVEL_DEBUG);
// Counts down to zero exactly once. Starts at deps + 1: the extra guard is released
// by SubmitAfter itself, so a dependency that settles while the edges are still being
@@ -603,6 +603,11 @@ namespace MobileGL::MG_State::GLState {
// 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; }
// Whether gl_PointSize was demoted out of this program's tessellation/geometry
// modules into the ordinary carrier varying. Joins phase B: it is a fact about the
// generated modules, and its readers (the backends' capture-name respelling) already
// hold the phase-B join.
Bool PointSizeDemoted() const { return Spirv().pointSizeDemoted; }
SizeT GetUniformStorageSpanInBytes(Uint location) const {
return UniformStorageSpanInBytes(GetUniformTypeFacts(location), GetUniformSizesInBytes(location),
UsesNativeFloat64());
@@ -1429,6 +1434,18 @@ namespace MobileGL::MG_State::GLState {
// table's offsets mean, and glUniform*d / glGetUniform*v have to write and read the
// width the shader actually declares.
Bool nativeFloat64 = false;
// Whether gl_PointSize was demoted out of THESE modules' tessellation/geometry
// stages into an ordinary varying (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) because the backend cannot host
// the built-in there. Per PROGRAM by construction - a consumer whose producer
// kept the built-in would read garbage - and recorded here rather than
// re-derived because it cannot be: the rewrite's whole point is that the final
// bytes no longer declare the capability that armed it. The backends read it to
// respell a "gl_PointSize" transform-feedback capture as the carrier
// (ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME). The GL reflection surface
// deliberately keeps answering "gl_PointSize": demotion happens after phase A,
// so every query keeps the truthful GL spelling.
Bool pointSizeDemoted = false;
};
// ---- artifacts-only helpers, shared with ProgramLinkTask ----
@@ -16,10 +16,29 @@
#include <MG_Util/ShaderTranspiler/TranslationCache.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <atomic>
#include <cstring>
namespace MobileGL::MG_State::GLState {
void ProgramSpirvTask::DeferLog(String line) { diagnostics.logLines.push_back(Move(line)); }
namespace {
// The MGLOG_*_ONCE latch, moved to the SOURCE of a deferred line. It cannot live at
// the replay: Async::ApplyDeferredDiagnostics is ONE site shared by every job in the
// tree, so a latch there would silence unrelated lines. And it has to exist: a shader
// pack hands the same refusal to program after program, and a per-program WARN on a
// path like that is exactly the repeated production logging the house rule forbids.
// First occurrence at WARN - the one a bug report needs - every later one back at
// DEBUG, which shipped builds compile out.
Int FirstTimeWarnLevel(std::atomic_flag& latch) {
return latch.test_and_set(std::memory_order_relaxed) ? MOBILEGL_LOG_LEVEL_DEBUG
: MOBILEGL_LOG_LEVEL_WARN;
}
std::atomic_flag g_pointSizeDeclineReported;
std::atomic_flag g_pointSizeOptimizerFailureReported;
} // namespace
void ProgramSpirvTask::DeferLog(String line, const Int level) {
diagnostics.logLines.push_back({level, Move(line)});
}
void ProgramSpirvTask::SubmitAfter(const SharedPtr<ProgramLinkTask>& phaseA) {
MOBILEGL_ASSERT(phaseA != nullptr, "ProgramSpirvTask::SubmitAfter: the phase-A node is missing");
@@ -128,8 +147,15 @@ namespace MobileGL::MG_State::GLState {
// 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();
// The point-size demotion verdicts, read from the SAME snapshot for the same reason
// - and the same bits BuildSpirvCacheKey put in the L1 key, so a memo written under
// one answer can never be handed back under the other.
const Bool demoteTessellationPointSize =
m_phaseA->in.env != nullptr && m_phaseA->in.env->DemotesTessellationPointSize();
const Bool demoteGeometryPointSize =
m_phaseA->in.env != nullptr && m_phaseA->in.env->DemotesGeometryPointSize();
GenerateSpirv(handoff, externalIndex, deferOutputValidationForDirectVulkan, enableSpirvValidation,
nativeFloat64);
nativeFloat64, demoteTessellationPointSize, demoteGeometryPointSize);
// 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
@@ -188,7 +214,9 @@ namespace MobileGL::MG_State::GLState {
void ProgramSpirvTask::GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, const Uint externalIndex,
const Bool deferOutputValidationForDirectVulkan,
const Bool enableSpirvValidation, const Bool nativeFloat64) {
const Bool enableSpirvValidation, const Bool nativeFloat64,
const Bool demoteTessellationPointSize,
const Bool demoteGeometryPointSize) {
/* As we passed first stage compilation/linking,
* we'll assume all the operations here should
* pass. We may be able to employ some optimizations
@@ -267,6 +295,50 @@ namespace MobileGL::MG_State::GLState {
}
}
artifacts.spirvStatus = allOptimized;
// The point-size demotion, program-wide and after the sanitize chain, so it works
// on the final shared bytes both backends consume and nothing downstream can trim
// the carriers it declares. Only the env half of the verdict lives here (and in the
// L1 key); whether the program actually declares the capability is probed inside,
// so the common case on an affected device - a program that never touches point
// size in those stages - pays one module parse per stage and no rewrite.
artifacts.pointSizeDemoted = false;
if (allOptimized && (demoteTessellationPointSize || demoteGeometryPointSize)) {
// Read off the HANDOFF's own derived bit, not off `handoff.reflection`: that
// field is the routing slice phase A fills with eight named members, and
// xfbVaryings is not one of them - reading it there answered "no capture ever
// asks for gl_PointSize" on every production link, which left a read-only
// capture stage without the carrier its capture binds to.
const Bool captureRequestsPointSize = handoff.captureRequestsPointSize;
ShaderCompiler::PointSizeDemotionOutcome outcome;
if (!ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
artifacts.generatedSpirv, handoff.shaderTypes, demoteTessellationPointSize,
demoteGeometryPointSize, captureRequestsPointSize, outcome,
!deferOutputValidationForDirectVulkan, enableSpirvValidation)) {
// Optimizer failure: modules untouched, so the capability is still declared
// and the backends' existing refusals stay in charge - honest, just slower.
DeferLog(std::format("ProgramObject {}: point-size demotion failed in the optimizer; the "
"program keeps its built-in and the device's declines apply",
externalIndex),
FirstTimeWarnLevel(g_pointSizeOptimizerFailureReported));
} else if (outcome.demoted) {
artifacts.pointSizeDemoted = true;
DeferLog(std::format("ProgramObject {}: gl_PointSize demoted to an ordinary varying across "
"the tessellation/geometry chain (value preserved for capture and "
"gl_in reads; rasterized size falls back to 1.0)",
externalIndex));
} else if (!outcome.declineDetail.empty()) {
// THE MOST VALUABLE LINE THIS FEATURE PRODUCES: which module shape the pass
// refused, and therefore why an affected device is still about to lose the
// program. Nothing else records it - `declineDetail` has no other runtime
// surface - so at the deferred channel's DEBUG default it was formatted and
// then dropped by every INFO build, i.e. every device and every CI artifact.
DeferLog(std::format("ProgramObject {}: point-size demotion declined ({}); the program "
"keeps its built-in and the device's declines apply",
externalIndex, outcome.declineDetail),
FirstTimeWarnLevel(g_pointSizeDeclineReported));
}
}
}
void ProgramSpirvTask::BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff,
@@ -67,12 +67,14 @@ namespace MobileGL::MG_State::GLState {
void GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex,
Bool deferOutputValidationForDirectVulkan, Bool enableSpirvValidation,
Bool nativeFloat64);
Bool nativeFloat64, Bool demoteTessellationPointSize,
Bool demoteGeometryPointSize);
void BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex);
// Worker-side MGLOG replacement, replayed by the join on the GL thread. Same reason as
// ProgramLinkTask::DeferLog.
void DeferLog(String line);
// ProgramLinkTask::DeferLog, and the same severity rule: DEBUG is compiled out of
// every shipped build, so a line that has to survive one names its own level.
void DeferLog(String line, Int level = MOBILEGL_LOG_LEVEL_DEBUG);
SharedPtr<ProgramLinkTask> m_phaseA;
};
@@ -384,12 +384,15 @@ namespace MobileGL::MG_State::GLState {
// the log, for every failing shader. The info log is what names the offending
// line; the source is recoverable from the application.
const SizeT firstLineEnd = artifacts.infoLog.find('\n');
diagnostics.logLines.push_back(std::format(
"ShaderCompileTask: shader {} (stage {}) failed to compile; compileStatus = false. "
"Preprocessed source: {} bytes. First log line: {}",
externalIndex, static_cast<Int>(stage), shared.preprocessedSource.length(),
artifacts.infoLog.substr(0, firstLineEnd == String::npos ? artifacts.infoLog.length()
: firstLineEnd)));
diagnostics.logLines.push_back(
{MOBILEGL_LOG_LEVEL_DEBUG,
std::format(
"ShaderCompileTask: shader {} (stage {}) failed to compile; compileStatus = false. "
"Preprocessed source: {} bytes. First log line: {}",
externalIndex, static_cast<Int>(stage), shared.preprocessedSource.length(),
artifacts.infoLog.substr(0, firstLineEnd == String::npos
? artifacts.infoLog.length()
: firstLineEnd))});
if (shouldPopulateCache) {
fresh->outcome = ShaderPreprocessOutcome::ParseFailed;
fresh->infoLog = artifacts.infoLog;
@@ -97,27 +97,31 @@ namespace MobileGL::MG_State::GLState {
return m_storageOwner->HasFixedSampleLocations();
}
Uint TextureObjectView::ViewLayerIndex(TextureUploadTarget viewTarget) const {
if (GetTarget() != TextureTarget::TextureCubeMap) {
// One target, one layer: the view's origin is the whole answer.
return m_viewMinLayer;
}
for (Uint i = 0; i < static_cast<Uint>(m_uploadTargets.size()); ++i) {
if (m_uploadTargets[i] == viewTarget) return m_viewMinLayer + i;
}
return m_viewMinLayer;
}
TextureUploadTarget TextureObjectView::ToOwnerUploadTarget(TextureUploadTarget viewTarget) const {
const auto& ownerTargets = m_storageOwner->GetUploadTargets();
MOBILEGL_ASSERT(!ownerTargets.empty(), "TextureObjectView: storage owner has no upload target");
if (ownerTargets.size() == 1) {
// The owner keeps every layer in one blob, so there is nothing to choose.
// The owner keeps every layer in one blob, so there is nothing to choose HERE - which
// is exactly why a cube-map view over such an owner has to have its face carried by
// LayerByteOffset instead. See ViewLayerIndex.
return ownerTargets[0];
}
// The owner is a cube map: six independent blobs, one per face, and the view's layer
// index selects among them. A cube-map view of a cube map maps face to face; any other
// view target addresses layers, which for a cube-map owner ARE its faces.
// The owner is a cube map: six independent blobs, one per face, and the layer this view
// target names selects among them. A cube-map view of a cube map maps face to face; any
// other view target addresses layers, which for a cube-map owner ARE its faces.
const Uint faceCount = static_cast<Uint>(ownerTargets.size());
Uint face = m_viewMinLayer;
if (GetTarget() == TextureTarget::TextureCubeMap) {
for (Uint i = 0; i < m_uploadTargets.size(); ++i) {
if (m_uploadTargets[i] == viewTarget) {
face = m_viewMinLayer + i;
break;
}
}
}
return ownerTargets[std::min(face, faceCount - 1)];
return ownerTargets[std::min(ViewLayerIndex(viewTarget), faceCount - 1)];
}
IntVec3 TextureObjectView::ToViewLevelSize(const IntVec3& ownerLevelSize) const {
@@ -151,7 +155,13 @@ namespace MobileGL::MG_State::GLState {
}
SizeT TextureObjectView::LayerByteOffset(TextureUploadTarget viewTarget, Uint mipmapLevel) const {
if (m_viewMinLayer == 0 || m_ownerMipmap == nullptr) return 0;
if (m_ownerMipmap == nullptr) return 0;
// The FACE is part of this, not just the view's origin: a cube-map view over a layered
// owner (a 2D array or a cube-map ARRAY) has only one blob to address, so the face its
// target token names lives here or nowhere. It used to live nowhere, and all six face
// tokens read the view's first layer-face - silently, with texels from a real layer.
const Uint layerIndex = ViewLayerIndex(viewTarget);
if (layerIndex == 0) return 0;
const LayerAxis ownerAxis = LayerAxisOf(m_storageOwner->GetTarget());
if (ownerAxis == LayerAxis::None) {
// A cube-map owner keeps each face in its OWN blob, and ToOwnerUploadTarget already
@@ -173,23 +183,26 @@ namespace MobileGL::MG_State::GLState {
? static_cast<SizeT>(std::max(ownerSize.x(), 0))
: static_cast<SizeT>(std::max(ownerSize.x(), 0)) *
static_cast<SizeT>(std::max(ownerSize.y(), 0));
const SizeT offset = static_cast<SizeT>(m_viewMinLayer) * layerTexels * bytesPerTexel;
const SizeT offset = static_cast<SizeT>(layerIndex) * layerTexels * bytesPerTexel;
return offset < ownerBytes ? offset : 0;
}
IntVec3 TextureObjectView::ToOwnerRegionOffset(const IntVec3& viewOffset) const {
if (m_viewMinLayer == 0) return viewOffset;
IntVec3 TextureObjectView::ToOwnerRegionOffset(TextureUploadTarget viewTarget, const IntVec3& viewOffset) const {
const Uint layerIndex = ViewLayerIndex(viewTarget);
if (layerIndex == 0) return viewOffset;
IntVec3 offset = viewOffset;
// The dirty region is recorded in the OWNER's blob coordinates - that is the space its
// upload path walks - so the view's layer origin has to be added here even though
// upload path walks - so the layer this view target names has to be added here even though
// MapMipmapData hands back an already-shifted POINTER. The two are not double-counting:
// one moves the bytes, the other tells the owner which of its layers moved.
// one moves the bytes, the other tells the owner which of its layers moved. They must agree
// on the layer, which is why both ask ViewLayerIndex rather than reading m_viewMinLayer -
// on a cube-map view the face is half the answer.
switch (LayerAxisOf(m_storageOwner->GetTarget())) {
case LayerAxis::Y:
offset.y() += static_cast<Int>(m_viewMinLayer);
offset.y() += static_cast<Int>(layerIndex);
break;
case LayerAxis::Z:
offset.z() += static_cast<Int>(m_viewMinLayer);
offset.z() += static_cast<Int>(layerIndex);
break;
case LayerAxis::None:
break;
@@ -300,7 +313,7 @@ namespace MobileGL::MG_State::GLState {
IntVec3 size) {
if (m_ownerMipmap == nullptr) return;
m_ownerMipmap->MarkStorageDirtyRegion(ToOwnerUploadTarget(uploadTarget), ToOwnerLevel(mipmapLevel),
ToOwnerRegionOffset(offset), size);
ToOwnerRegionOffset(uploadTarget, offset), size);
}
MipmapDirtyRegion TextureObjectView::GetStorageDirtyRegion(TextureUploadTarget uploadTarget,
@@ -103,6 +103,18 @@ namespace MobileGL::MG_State::GLState {
// target - arrays and cube-map arrays included - keeps all its layers in one blob, so
// the mapping is "the owner's only target" unless one of the two sides is a cube map.
TextureUploadTarget ToOwnerUploadTarget(TextureUploadTarget viewTarget) const;
// WHICH of the owner's layers a given view-side upload target names, in the owner's layer
// numbering. For every view target but a cube map that is just this view's layer origin -
// one target, one layer. A GL_TEXTURE_CUBE_MAP view addresses SIX of the owner's layers at
// once (GL 4.6 core 8.18), so the face its target token names is an index on top of that
// origin, and this is the only place that can express it when the owner keeps every layer
// in one blob: ToOwnerUploadTarget has a single blob to choose from there, so the face
// would otherwise vanish and all six tokens would read the view's first layer.
//
// Every place that turns this view into owner-side bytes goes through here - the blob
// choice, the byte offset, and the dirty region - so the three cannot disagree about which
// layer a face is.
Uint ViewLayerIndex(TextureUploadTarget viewTarget) const;
Uint ToOwnerLevel(Uint viewLevel) const { return m_viewMinLevel + viewLevel; }
// The owner's level extent rewritten into this view's shape: the owner's layer axis is
// collapsed to one slice and the view's own layer count is imposed on the view's layer
@@ -114,8 +126,10 @@ namespace MobileGL::MG_State::GLState {
// and a single row for a 1D array; a cube-map owner returns 0 because its faces are
// separate blobs that ToOwnerUploadTarget already selects between.
SizeT LayerByteOffset(TextureUploadTarget viewTarget, Uint mipmapLevel) const;
// A dirty-region origin moved from the view's layer space into the owner's.
IntVec3 ToOwnerRegionOffset(const IntVec3& viewOffset) const;
// A dirty-region origin moved from the view's layer space into the owner's. Takes the view
// target for the same reason LayerByteOffset does: on a cube-map view the target names the
// face, and the region has to name the same owner layer the bytes were written to.
IntVec3 ToOwnerRegionOffset(TextureUploadTarget viewTarget, const IntVec3& viewOffset) const;
SharedPtr<ITextureObject> m_storageOwner;
// Non-owning; m_storageOwner keeps it alive and is never a view, so this is set once in
@@ -7,6 +7,7 @@
// End of Source File Header
#include <gtest/gtest.h>
#include <cstdlib>
#include <cstring>
#include <map>
#include <string>
@@ -14,9 +15,11 @@
#include <algorithm>
#include <Init.h>
#include <MG_Backend/DirectGLES/BackendObject_DirectGLES.h>
#include <MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h>
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
#include <MG_Util/SelfTest/DriverBugProbes.h>
// ProbeIndirectInstanceIdIncludesBaseInstance is driven against a fake GLES driver:
// a GLESFunctionsTable populated with captureless lambdas backed by the file-scope
@@ -25,6 +28,13 @@
// ANGLE-style baseInstance-leaking driver, or a failing one.
namespace {
struct FakeDriverState {
// What each of the located-interface-block probe's draws reads back, in the order the
// probe makes them: unlocated control, located subject, located vertex-to-fragment
// control. Empty means "conforming driver" - every read returns the payload - which is
// what keeps this probe invisible to every other test in this file.
std::vector<bool> ioBlockPayloadArrives;
std::size_t ioBlockReads = 0;
std::size_t ioBlockDraws = 0;
// Behavior knobs, configured per test before running the probe.
GLint maxVertexSsboBlocks = 4;
GLint glesMajorVersion = 3;
@@ -433,6 +443,52 @@ namespace {
};
funcs.glBindFramebuffer = [](GLenum, GLuint) {};
funcs.glBindRenderbuffer = [](GLenum, GLuint) {};
// ---- what the located-interface-block probe draws with -------------------------
// Enough of a rasterizer for ProbeLocatedIoBlocksLosePayload to reach a verdict: it
// builds three programs, draws each to a 1x1 target and reads the pixel back, and the
// fake decides what each read returns. Default behaviour is a CONFORMING driver, so
// every test that predates this one sees the probe reach "not affected" and no
// capability it asserts on moves.
funcs.glCheckFramebufferStatus = [](GLenum) -> GLenum { return GL_FRAMEBUFFER_COMPLETE; };
funcs.glViewport = [](GLint, GLint, GLsizei, GLsizei) {};
funcs.glClearColor = [](GLfloat, GLfloat, GLfloat, GLfloat) {};
funcs.glClear = [](GLbitfield) {};
funcs.glPixelStorei = [](GLenum, GLint) {};
funcs.glColorMask = [](GLboolean, GLboolean, GLboolean, GLboolean) {};
funcs.glIsEnabled = [](GLenum) -> GLboolean { return GL_FALSE; };
funcs.glGetBooleanv = [](GLenum, GLboolean* data) {
if (data == nullptr) return;
for (int i = 0; i < 4; ++i) data[i] = GL_TRUE;
};
funcs.glGetIntegeri_v = [](GLenum, GLuint, GLint* data) {
if (data != nullptr) *data = 0;
};
funcs.glGetProgramInfoLog = [](GLuint, GLsizei bufSize, GLsizei* length, GLchar* infoLog) {
if (infoLog != nullptr && bufSize > 0) infoLog[0] = '\0';
if (length != nullptr) *length = 0;
};
funcs.glGetShaderInfoLog = [](GLuint, GLsizei bufSize, GLsizei* length, GLchar* infoLog) {
if (infoLog != nullptr && bufSize > 0) infoLog[0] = '\0';
if (length != nullptr) *length = 0;
};
funcs.glDrawArrays = [](GLenum, GLint, GLsizei) { ++g_fake.ioBlockDraws; };
// One entry of ioBlockPayloadArrives is consumed per draw, in the order the probe makes
// them: the unlocated CONTROL, then the located SUBJECT, then the located
// vertex-to-fragment second control. Past the end of the list the driver is conforming.
funcs.glReadPixels = [](GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void* pixels) {
auto* out = static_cast<unsigned char*>(pixels);
if (out == nullptr) return;
const std::size_t index = g_fake.ioBlockReads++;
const bool arrives = index < g_fake.ioBlockPayloadArrives.size()
? g_fake.ioBlockPayloadArrives[index]
: true;
// 0.25 and 0.5 as the probe's vertex stage wrote them; zeroes are what a stage that
// received nothing reads.
out[0] = arrives ? 0x40 : 0x00;
out[1] = arrives ? 0x80 : 0x00;
out[2] = 0x00;
out[3] = 0xff;
};
funcs.glRenderbufferStorage = [](GLenum, GLenum, GLsizei, GLsizei) {};
funcs.glFramebufferRenderbuffer = [](GLenum, GLenum, GLenum, GLuint) {};
funcs.glDeleteFramebuffers = [](GLsizei n, const GLuint* framebuffers) {
@@ -1347,3 +1403,151 @@ TEST(BaseInstanceCapabilities, RequiresTheExtensionAndAllThreeEntryPoints) {
MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(missingEntryPointCaps, funcs));
EXPECT_FALSE(missingEntryPointCaps.SupportsBaseInstance);
}
// ===================== LOCATED INTER-STAGE INTERFACE BLOCKS =====================
//
// The capability that decides whether DirectGLES strips the layout(location) qualifier off a
// tessellation/geometry program's interface blocks, and the environment override that forces
// it either way.
//
// THE MAPPING IS INVERTED ON PURPOSE and that is exactly why it is pinned here: the variable
// is named for the EMULATION ("emit them unlocated"), the capability is named for the DRIVER
// ("located blocks work"), so forcing the emulation ON must set the capability to FALSE. A
// one-line swap of those two arms would leave every other test in the tree green - the unit
// tests drive the pass directly, and the integration lane runs on llvmpipe, which carries a
// located block correctly either way - while silently disabling the repair on the only device
// that needs it.
namespace {
void SetEnvVarForTest(const char* name, const char* value) {
#if defined(_WIN32)
_putenv_s(name, value);
#else
setenv(name, value, 1);
#endif
}
void UnsetEnvVarForTest(const char* name) {
#if defined(_WIN32)
_putenv_s(name, "");
#else
unsetenv(name);
#endif
}
// Sets MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS (or clears it), re-reads the configuration the
// way process start would, and runs the capability fill against the fake driver.
MobileGL::MG_External::GLESCapabilities CapabilitiesWithOverride(
const MobileGL::MG_External::GLESFunctionsTable& funcs, const char* value) {
if (value == nullptr) {
UnsetEnvVarForTest("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS");
} else {
SetEnvVarForTest("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS", value);
}
MobileGL::MG_ConfigLoader::Init();
MobileGL::MG_External::GLESCapabilities caps;
EXPECT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
return caps;
}
} // namespace
TEST(LocatedIoBlockCapability, TheOverrideMapsOntoTheCapabilityInverted) {
const auto funcs = MakeFakeGLESFunctions();
// ONE TEST, THREE ARMS, IN THIS ORDER, because the Auto arm consults a probe that is
// memoized for the lifetime of the process - splitting them into three test cases would
// make the answer depend on which one gtest happened to run first.
ResetFakeDriver();
g_fake.glesMinorVersion = 2;
// ForceOn - "emit the blocks unlocated". The driver is NOT probed, and the capability must
// come out FALSE. This is the assertion the inversion swap breaks.
{
const auto caps = CapabilitiesWithOverride(funcs, "1");
EXPECT_FALSE(caps.SupportsLocatedInterStageIoBlocks)
<< "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1 forces the emulation ON, which means "
"declaring that this driver's located interface blocks do NOT work. A true here "
"means the strip is disabled in the one configuration that exists to enable it.";
}
// ForceOff - the negative control. Also unprobed, and the capability must come out TRUE so
// the strip stays off.
{
const auto caps = CapabilitiesWithOverride(funcs, "0");
EXPECT_TRUE(caps.SupportsLocatedInterStageIoBlocks)
<< "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=0 forces located blocks ON, i.e. the "
"emulation off; a false here would strip on every driver regardless of the probe.";
}
// Auto - the setting every real run uses. The capability is the probe's verdict, negated:
// "the blocks lose their payload" is the same statement as "located blocks are not
// supported". On this fake the probe finds a conforming driver, so the capability is true.
{
const auto caps = CapabilitiesWithOverride(funcs, nullptr);
EXPECT_EQ(caps.SupportsLocatedInterStageIoBlocks,
!MobileGL::MG_Util::SelfTest::LocatedIoBlocksLosePayload(funcs).detected)
<< "with the variable unset the capability must follow the driver probe and nothing "
"else";
EXPECT_TRUE(caps.SupportsLocatedInterStageIoBlocks)
<< "the fake driver carries the probe's payload, so Auto must leave the strip off";
}
UnsetEnvVarForTest("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS");
MobileGL::MG_ConfigLoader::Init();
}
// The probe's own verdict logic, driven directly rather than through the memoized accessor so
// each shape gets its own answer. Its two controls are the whole design: without them a driver
// that cannot run the shape at all, or one whose interface blocks are broken generally, would
// be reported as having this very specific defect - and would have its locations stripped for
// nothing.
TEST(LocatedIoBlockProbe, ReportsTheDefectOnlyWhenTheUnlocatedControlCarriesThePayload) {
const auto funcs = MakeFakeGLESFunctions();
using MobileGL::MG_Util::SelfTest::ProbeLocatedIoBlocksLosePayload;
// A CONFORMING driver: every draw delivers. No finding.
ResetFakeDriver();
g_fake.glesMinorVersion = 2;
g_fake.ioBlockPayloadArrives = {true, true, true};
EXPECT_FALSE(ProbeLocatedIoBlocksLosePayload(funcs).detected);
// THE AFFECTED DRIVER: the unlocated control delivers, the located subject does not, and
// the located vertex-to-fragment control does. That last one is what scopes the repair to
// tessellation/geometry programs.
ResetFakeDriver();
g_fake.glesMinorVersion = 2;
g_fake.ioBlockPayloadArrives = {true, false, true};
{
const auto measurement = ProbeLocatedIoBlocksLosePayload(funcs);
EXPECT_TRUE(measurement.detected);
EXPECT_FALSE(measurement.alsoAffectsVertexToFragment);
}
// ...and a driver that loses the payload even without a geometry stage says so, because the
// repair does not reach that shape and the report must not imply it does.
ResetFakeDriver();
g_fake.glesMinorVersion = 2;
g_fake.ioBlockPayloadArrives = {true, false, false};
{
const auto measurement = ProbeLocatedIoBlocksLosePayload(funcs);
EXPECT_TRUE(measurement.detected);
EXPECT_TRUE(measurement.alsoAffectsVertexToFragment);
}
// THE CONTROL FAILING IS NOT A FINDING. A driver that cannot carry an UNLOCATED block
// either has something else wrong with it, and stripping locations would repair nothing
// while changing every tessellation and geometry program on it.
ResetFakeDriver();
g_fake.glesMinorVersion = 2;
g_fake.ioBlockPayloadArrives = {false, false, false};
EXPECT_FALSE(ProbeLocatedIoBlocksLosePayload(funcs).detected);
// Neither is a driver the probe cannot even draw on: an inconclusive probe must leave the
// capability exactly as it was before the probe existed.
ResetFakeDriver();
g_fake.glesMinorVersion = 2;
auto crippled = MakeFakeGLESFunctions();
crippled.glReadPixels = nullptr;
EXPECT_FALSE(ProbeLocatedIoBlocksLosePayload(crippled).detected);
EXPECT_EQ(g_fake.ioBlockDraws, 0u) << "an entry-point-gated probe must not draw at all";
}
+900
View File
@@ -1568,6 +1568,15 @@ namespace {
int respecifyCalls = 0;
int flushCalls = 0;
Bool provideMap = true; // false => backend declines, exercising the shadow fallback
// Only recorded by the variant of the ops table that offers ResidentSubData: the
// bytes a CPU write handed the backend for a GPU-ordered landing into an adopted
// store, held back from `gpu` until a readback "retires" them.
struct ResidentWrite {
SizeT offset = 0;
Vector<Uint8> bytes;
};
Vector<ResidentWrite> residentWrites;
int readbackCalls = 0;
};
ZeroCopyMockBackend* g_zeroCopyMock = nullptr;
@@ -1605,6 +1614,39 @@ namespace {
.AcquirePersistentMap = ZeroCopyMock_AcquirePersistentMap,
};
// The same backend with the GPU-ordered landing ops a staging-ring backend offers: a
// CPU write into an adopted store is queued (the mapping is NOT written through), and
// a readback is what lands the queue before the application reads.
void ZeroCopyMock_ResidentSubData(MG_State::GLState::BufferObject&, SizeT offset, DataPtr data) {
if (!g_zeroCopyMock) return;
auto& write = g_zeroCopyMock->residentWrites.emplace_back();
write.offset = offset;
const auto* bytes = static_cast<const Uint8*>(data.data);
write.bytes.assign(bytes, bytes + data.size);
}
void ZeroCopyMock_ReadbackFromGpu(MG_State::GLState::BufferObject&) {
if (!g_zeroCopyMock) return;
++g_zeroCopyMock->readbackCalls;
for (const auto& write : g_zeroCopyMock->residentWrites) {
// Reported, not asserted: an ASSERT here would return out of the readback and
// leave the remaining landings unapplied, which reads as a different failure.
EXPECT_LE(write.offset + write.bytes.size(), g_zeroCopyMock->gpu.size());
if (write.offset + write.bytes.size() > g_zeroCopyMock->gpu.size()) continue;
Memcpy(g_zeroCopyMock->gpu.data() + write.offset, write.bytes.data(), write.bytes.size());
}
g_zeroCopyMock->residentWrites.clear();
}
const MG_State::GLState::BufferBackendOps kResidentSubDataMockOps = {
.Respecify = ZeroCopyMock_Respecify,
.SubData = ZeroCopyMock_SubData,
.ResidentSubData = ZeroCopyMock_ResidentSubData,
.FlushMappedRange = ZeroCopyMock_Flush,
.OnDestroy = ZeroCopyMock_OnDestroy,
.AcquirePersistentMap = ZeroCopyMock_AcquirePersistentMap,
.ReadbackFromGpu = ZeroCopyMock_ReadbackFromGpu,
};
struct ScopedBackendOps {
explicit ScopedBackendOps(const MG_State::GLState::BufferBackendOps* ops) {
MG_State::GLState::SetBufferBackendOps(ops);
@@ -2074,3 +2116,861 @@ TEST_F(BufferTest, RedefiningANonAdoptedBufferIsUnchanged) {
g_zeroCopyMock = nullptr;
}
// ---------------------------------------------------------------------------
// A NON-persistent write map of an ADOPTED store. glMapBuffer / glMapBufferRange hand
// the application a staging copy regardless of where the store lives, and GL requires
// the bytes it wrote there to be visible to every later command once glUnmapBuffer
// returns. Residency used to come only from a coherent persistent map - which writes
// in place and never has a staging copy - so the unmap simply skipped the copy-back
// for a resident store. Residency now also comes from a shader storage binding
// (EnsureGpuResidentStorage at draw time) and from large-store adoption, both of which
// an application then re-initialises through an ordinary map/write/unmap: the
// conformance suite re-seeds every SSBO that way before each draw, and every re-seed
// after the first draw was dropped on the floor. These pin the landing for each map
// shape, on the backend that writes the coherent mapping in place and on the one that
// takes the bytes for a GPU-ordered landing, plus the shadow path as the control.
namespace {
constexpr SizeT kAdoptedInts = 16;
// A buffer of kAdoptedInts sequential ints, adopted by the mock backend exactly as an
// SSBO binding does at draw time. The per-write counters are zeroed afterwards so a
// test only sees the traffic of the map it makes.
SharedPtr<MG_State::GLState::BufferObject> MakeAdoptedBuffer(ZeroCopyMockBackend& mock, GLenum target,
GLuint& buffer) {
GenBuffers(1, &buffer);
BindBuffer(target, buffer);
Vector<GLint> initial(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) initial[i] = static_cast<GLint>(i);
BufferData(target, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
EXPECT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
EXPECT_NE(bufferObject, nullptr);
if (bufferObject == nullptr) return nullptr;
EXPECT_TRUE(bufferObject->EnsureGpuResidentStorage());
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
EXPECT_EQ(static_cast<const void*>(bufferObject->MappedData()), static_cast<const void*>(mock.gpu.data()));
mock.subDataCalls = 0;
mock.flushCalls = 0;
mock.respecifyCalls = 0;
return bufferObject;
}
const GLint* GpuInts(const ZeroCopyMockBackend& mock) {
return reinterpret_cast<const GLint*>(mock.gpu.data());
}
} // namespace
TEST_F(BufferTest, ANonPersistentReadWriteRangeMapOfAnAdoptedStoreLandsAtUnmap) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
// The conformance suite's shape: the whole store, READ|WRITE, then a full rewrite.
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
{0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Read | BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
// A non-persistent map is a staging copy, seeded from the adopted store...
EXPECT_NE(static_cast<void*>(mapped), static_cast<void*>(mock.gpu.data()));
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(mapped[i], static_cast<GLint>(i));
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = 1000 + static_cast<GLint>(i);
// ...that the store does not see until the unmap.
EXPECT_EQ(GpuInts(mock)[0], 0);
bufferObject->ReleaseMemory();
EXPECT_FALSE(bufferObject->IsMapped());
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
for (SizeT i = 0; i < kAdoptedInts; ++i) {
EXPECT_EQ(GpuInts(mock)[i], 1000 + static_cast<GLint>(i)) << "int " << i;
}
EXPECT_EQ(std::memcmp(bufferObject->MappedData(), mock.gpu.data(), mock.gpu.size()), 0);
// The landing publishes the change for cached consumers...
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
// ...but dispatches no transfer op: the backend keeps no separate copy of an
// adopted store, and its flush op would only upload the mapping onto itself.
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.acquireMapCalls, 1);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, GlMapBufferWriteOnlyAndReadWriteOfAnAdoptedStoreLandAtUnmap) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
// glMapBuffer(GL_WRITE_ONLY): the staging copy is still seeded (no invalidate bit),
// so a partial write keeps the untouched ints.
Uint64 serial = bufferObject->GetChangeSerial();
auto* writeOnly = static_cast<GLint*>(bufferObject->AcquireMemory(true, false, true));
ASSERT_NE(writeOnly, nullptr);
EXPECT_NE(static_cast<void*>(writeOnly), static_cast<void*>(mock.gpu.data()));
writeOnly[0] = 100;
writeOnly[1] = 200;
bufferObject->ReleaseMemory();
EXPECT_EQ(GpuInts(mock)[0], 100);
EXPECT_EQ(GpuInts(mock)[1], 200);
EXPECT_EQ(GpuInts(mock)[2], 2);
EXPECT_EQ(GpuInts(mock)[kAdoptedInts - 1], static_cast<GLint>(kAdoptedInts - 1));
EXPECT_GT(bufferObject->GetChangeSerial(), serial);
// glMapBuffer(GL_READ_WRITE): reads see the previous landing, and the next one lands too.
serial = bufferObject->GetChangeSerial();
auto* readWrite = static_cast<GLint*>(bufferObject->AcquireMemory(true, true, true));
ASSERT_NE(readWrite, nullptr);
EXPECT_EQ(readWrite[0], 100);
EXPECT_EQ(readWrite[1], 200);
readWrite[2] = 300;
bufferObject->ReleaseMemory();
EXPECT_EQ(GpuInts(mock)[0], 100);
EXPECT_EQ(GpuInts(mock)[1], 200);
EXPECT_EQ(GpuInts(mock)[2], 300);
EXPECT_GT(bufferObject->GetChangeSerial(), serial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, AWriteMapInvalidatingAnAdoptedStoreLandsTheWholeRangeAtUnmap) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
{0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Write | BufferMappingAccessBit::InvalidateBuffer));
ASSERT_NE(mapped, nullptr);
EXPECT_NE(static_cast<void*>(mapped), static_cast<void*>(mock.gpu.data()));
// The whole range is undefined by contract, so the application rewrites all of it.
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = -static_cast<GLint>(i) - 1;
bufferObject->ReleaseMemory();
for (SizeT i = 0; i < kAdoptedInts; ++i) {
EXPECT_EQ(GpuInts(mock)[i], -static_cast<GLint>(i) - 1) << "int " << i;
}
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// A range map at an offset off the alignment grid: the staging store is biased by the
// offset's phase (see AcquireMemoryRange), and the landing has to read from the biased
// start and write to the mapped offset - not from data(), not to 0.
TEST_F(BufferTest, ARangeMapAtAnUnalignedOffsetOfAnAdoptedStoreLandsInPlace) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
// Ints 3..6, i.e. byte offset 12 - inside the first alignment, so the bias is non-zero.
constexpr SizeT kFirst = 3;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
ASSERT_NE(range.start % MG_State::GLState::MIN_MAP_BUFFER_ALIGNMENT, 0u);
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(range, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
// Seeded from the right place...
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(mapped[i], static_cast<GLint>(kFirst + i));
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 500 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
// ...and landed in the right place, with everything outside the range untouched.
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? 500 + static_cast<GLint>(i - kFirst)
: static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// FLUSH_EXPLICIT on an adopted store: only the flushed bytes land, at the flush, and the
// unmap lands nothing more - the application promised to flush what it wanted kept.
TEST_F(BufferTest, AnExplicitFlushOfAWriteMapOfAnAdoptedStoreLandsOnlyTheFlushedBytes) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
// Ints 2..13 mapped (offset 8, off the grid again), all of them rewritten...
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 12;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 700 + static_cast<GLint>(i);
// ...but only ints 5..8 (map-relative ints 3..6) flushed.
constexpr SizeT kFlushFirst = 3;
constexpr SizeT kFlushCount = 4;
bufferObject->FlushMemoryRange(kFlushFirst * sizeof(GLint), kFlushCount * sizeof(GLint));
const Uint64 flushSerial = bufferObject->GetChangeSerial();
EXPECT_GT(flushSerial, baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
auto expectOnlyFlushedBytesLanded = [&](const char* when) {
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const Bool flushed = i >= kFirst + kFlushFirst && i < kFirst + kFlushFirst + kFlushCount;
const GLint expected = flushed ? 700 + static_cast<GLint>(i - kFirst) : static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << when << ": int " << i;
}
};
expectOnlyFlushedBytesLanded("after the flush");
bufferObject->ReleaseMemory();
expectOnlyFlushedBytesLanded("after the unmap");
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// The other kind of backend: one that takes the bytes for a GPU-ordered landing instead
// of letting the frontend write the coherent mapping in place. The unmap hands it the
// mapped offset and the bias-adjusted bytes, leaves the mapping alone, and marks a GPU
// write outstanding so the next read reconciles through the readback.
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesTheUnmappedBytesForAGpuOrderedLanding) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
constexpr SizeT kFirst = 3;
constexpr SizeT kCount = 5;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Read | BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 900 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
// The op got exactly the mapped range's bytes at the mapped offset...
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, range.start);
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(handed[i], 900 + static_cast<GLint>(i)) << "int " << i;
// ...the mapping itself was not written through...
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.readbackCalls, 0);
// ...and the pending flag makes the next read pull the landing back first.
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
EXPECT_TRUE(mock.residentWrites.empty());
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? 900 + static_cast<GLint>(i - kFirst)
: static_cast<GLint>(i);
EXPECT_EQ(readBack[i], expected) << "int " << i;
}
// A second read has nothing outstanding to reconcile.
bufferObject->AcquireMemory(false, true, false);
EXPECT_EQ(mock.readbackCalls, 1);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesAnExplicitlyFlushedRangeTheSameWay) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 8;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 800 + static_cast<GLint>(i);
constexpr SizeT kFlushFirst = 5;
constexpr SizeT kFlushCount = 2;
bufferObject->FlushMemoryRange(kFlushFirst * sizeof(GLint), kFlushCount * sizeof(GLint));
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, (kFirst + kFlushFirst) * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kFlushCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
EXPECT_EQ(handed[0], 800 + static_cast<GLint>(kFlushFirst));
EXPECT_EQ(handed[1], 800 + static_cast<GLint>(kFlushFirst + 1));
EXPECT_EQ(mock.flushCalls, 0);
// The unmap of a FLUSH_EXPLICIT map adds nothing.
bufferObject->ReleaseMemory();
EXPECT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// The CTS idiom end to end through the GL entry points: an SSBO made resident by a
// draw, re-seeded with glMapBufferRange(READ|WRITE) + glUnmapBuffer.
TEST_F(BufferTest, MapBufferRangeAndUnmapBufferReseedAnAdoptedShaderStorageBuffer) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
for (GLint pass = 1; pass <= 3; ++pass) {
auto* mapped = static_cast<GLint*>(
MapBufferRange(GL_SHADER_STORAGE_BUFFER, 0, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)),
GL_MAP_READ_BIT | GL_MAP_WRITE_BIT));
ASSERT_NE(mapped, nullptr);
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = pass * 100 + static_cast<GLint>(i);
EXPECT_TRUE(UnmapBuffer(GL_SHADER_STORAGE_BUFFER));
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
EXPECT_EQ(GpuInts(mock)[i], pass * 100 + static_cast<GLint>(i)) << "pass " << pass << " int " << i;
}
}
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// The control: a store the backend declined to adopt keeps the shadow model exactly as
// before - the staging copy is written back into the shadow and the backend's flush op
// carries the range down.
TEST_F(BufferTest, ANonPersistentWriteMapOfAShadowBackedStoreStillFlushesThroughTheBackend) {
ZeroCopyMockBackend mock;
mock.provideMap = false;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
Vector<GLint> initial(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) initial[i] = static_cast<GLint>(i);
BufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
EXPECT_FALSE(bufferObject->EnsureGpuResidentStorage());
EXPECT_FALSE(bufferObject->IsBackendPersistentMapped());
mock.flushCalls = 0;
mock.subDataCalls = 0;
const Uint64 baseSerial = bufferObject->GetChangeSerial();
constexpr SizeT kFirst = 3;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(range, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 600 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
EXPECT_EQ(mock.flushCalls, 1);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
const auto* shadow = reinterpret_cast<const GLint*>(bufferObject->MappedData());
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? 600 + static_cast<GLint>(i - kFirst)
: static_cast<GLint>(i);
EXPECT_EQ(shadow[i], expected) << "int " << i;
}
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// ---------------------------------------------------------------------------
// The other three CPU-sourced writes that share the unmap landing's route into an
// adopted store - glBufferSubData, a clear, and a copy - on both kinds of backend: the
// one that lets the frontend write the coherent mapping in place, and the one that takes
// the bytes for a GPU-ordered landing, where the offset it is handed is the only thing
// deciding where they end up.
TEST_F(BufferTest, GlBufferSubDataIntoAnAdoptedStoreLandsInPlaceWithoutABackendTransfer) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 4;
const GLint updated[] = {70, 71, 72};
BufferSubData(GL_ARRAY_BUFFER, static_cast<GLintptr>(kFirst * sizeof(GLint)), sizeof(updated), updated);
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + 3) ? updated[i - kFirst] : static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesAGlBufferSubDataAtItsOffset) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 4;
const GLint updated[] = {70, 71, 72};
BufferSubData(GL_ARRAY_BUFFER, static_cast<GLintptr>(kFirst * sizeof(GLint)), sizeof(updated), updated);
ASSERT_EQ(GetError(), GL_NO_ERROR);
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, kFirst * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), sizeof(updated));
EXPECT_EQ(std::memcmp(mock.residentWrites[0].bytes.data(), updated, sizeof(updated)), 0);
// The mapping itself is left alone until the backend's ordered copy runs.
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + 3) ? updated[i - kFirst] : static_cast<GLint>(i);
EXPECT_EQ(readBack[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, GlClearBufferSubDataRepeatsItsPatternThroughAnAdoptedStoreInPlace) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
// A four-byte pattern, so the repeat - not a memset - is what fills the range.
constexpr SizeT kFirst = 5;
constexpr SizeT kCount = 6;
const GLint value = 0x0A0B0C0D;
ClearBufferSubData(GL_SHADER_STORAGE_BUFFER, GL_R32I, static_cast<GLintptr>(kFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)), GL_RED_INTEGER, GL_INT, &value);
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? value : static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesTheExpandedClearPattern) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 5;
constexpr SizeT kCount = 6;
const GLint value = 0x0A0B0C0D;
ClearBufferSubData(GL_SHADER_STORAGE_BUFFER, GL_R32I, static_cast<GLintptr>(kFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)), GL_RED_INTEGER, GL_INT, &value);
ASSERT_EQ(GetError(), GL_NO_ERROR);
// The backend takes the FINAL bytes, so the pattern arrives already repeated.
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, kFirst * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(handed[i], value) << "int " << i;
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? value : static_cast<GLint>(i);
EXPECT_EQ(readBack[i], expected) << "int " << i;
}
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
namespace {
// A plain (never adopted) buffer of kAdoptedInts ints, each `bias` above its index,
// bound to `target` as the source of a copy.
GLuint MakeCopySource(GLenum target, GLint bias) {
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(target, buffer);
Vector<GLint> bytes(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) bytes[i] = bias + static_cast<GLint>(i);
BufferData(target, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), bytes.data(), GL_STATIC_DRAW);
EXPECT_EQ(GetError(), GL_NO_ERROR);
return buffer;
}
} // namespace
TEST_F(BufferTest, GlCopyBufferSubDataIntoAnAdoptedStoreLandsAtTheDestinationOffset) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint destination = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_COPY_WRITE_BUFFER, destination);
ASSERT_NE(bufferObject, nullptr);
const GLuint source = MakeCopySource(GL_COPY_READ_BUFFER, 900);
// Deliberately different source and destination offsets: only the destination one
// may decide where the bytes land.
constexpr SizeT kSrcFirst = 1;
constexpr SizeT kDstFirst = 6;
constexpr SizeT kCount = 3;
CopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, static_cast<GLintptr>(kSrcFirst * sizeof(GLint)),
static_cast<GLintptr>(kDstFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)));
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kDstFirst && i < kDstFirst + kCount)
? 900 + static_cast<GLint>(kSrcFirst + i - kDstFirst)
: static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
GLuint toDelete[] = {destination, source};
DeleteBuffers(2, toDelete);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesACopyAtTheDestinationOffset) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint destination = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_COPY_WRITE_BUFFER, destination);
ASSERT_NE(bufferObject, nullptr);
const GLuint source = MakeCopySource(GL_COPY_READ_BUFFER, 900);
constexpr SizeT kSrcFirst = 1;
constexpr SizeT kDstFirst = 6;
constexpr SizeT kCount = 3;
CopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, static_cast<GLintptr>(kSrcFirst * sizeof(GLint)),
static_cast<GLintptr>(kDstFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)));
ASSERT_EQ(GetError(), GL_NO_ERROR);
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, kDstFirst * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
for (SizeT i = 0; i < kCount; ++i) {
EXPECT_EQ(handed[i], 900 + static_cast<GLint>(kSrcFirst + i)) << "int " << i;
}
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
GLuint toDelete[] = {destination, source};
DeleteBuffers(2, toDelete);
g_zeroCopyMock = nullptr;
}
// A PERSISTENT map of an adopted store is the one write shape that needs no landing at
// all: it wrote the coherent mapping in place. Its explicit flush therefore publishes the
// change and dispatches nothing - not the backend's flush op (whose upload would be the
// mapping onto itself) and not the resident landing op (whose bytes are already there).
TEST_F(BufferTest, AnExplicitFlushOfAPersistentMapOfAnAdoptedStoreOnlyPublishesTheChange) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 8;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::Persistent |
BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
// The application writes the store itself: the map IS the adopted memory.
EXPECT_EQ(static_cast<void*>(mapped), static_cast<void*>(mock.gpu.data() + range.start));
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 400 + static_cast<GLint>(i);
constexpr SizeT kFlushFirst = 3;
constexpr SizeT kFlushCount = 2;
bufferObject->FlushMemoryRange(kFlushFirst * sizeof(GLint), kFlushCount * sizeof(GLint));
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(mock.residentWrites.empty());
EXPECT_TRUE(bufferObject->HasDefinedContent());
// Every byte the map wrote is in the store, flushed or not - it was written there.
for (SizeT i = 0; i < kCount; ++i) {
EXPECT_EQ(GpuInts(mock)[kFirst + i], 400 + static_cast<GLint>(i)) << "int " << i;
}
const Uint64 flushSerial = bufferObject->GetChangeSerial();
bufferObject->ReleaseMemory();
EXPECT_EQ(bufferObject->GetChangeSerial(), flushSerial); // the unmap adds nothing
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(mock.residentWrites.empty());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// A flush of nothing wrote no byte, so it may not report the store as written: an
// orphaning respecification prices a "has content" store as a full-size upload.
TEST_F(BufferTest, AZeroLengthExplicitFlushOfAnAdoptedStoreLeavesItUndefined) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
BufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), nullptr,
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
ASSERT_FALSE(bufferObject->HasDefinedContent());
ASSERT_TRUE(bufferObject->EnsureGpuResidentStorage());
const Uint64 baseSerial = bufferObject->GetChangeSerial();
auto* mapped = bufferObject->AcquireMemoryRange({0, kAdoptedInts * sizeof(GLint)},
BufferMappingAccessBit::Write |
BufferMappingAccessBit::Persistent |
BufferMappingAccessBit::FlushExplicit);
ASSERT_NE(mapped, nullptr);
bufferObject->FlushMemoryRange(0, 0);
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_FALSE(bufferObject->HasDefinedContent());
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
bufferObject->ReleaseMemory();
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// A write map that discards the range it maps reads nothing of the store: its staging
// copy is not seeded from it. Reconciling an adopted store at map time would run the
// backend's drain-and-wait for no reader, once per map, on the streaming arena the
// adoption exists to keep cheap - so it is deferred, not dropped: the first map that DOES
// read the bytes still pays for it, and every queued landing is still applied, in order.
TEST_F(BufferTest, AWriteMapThatDiscardsWhatItMapsDoesNotReconcileAnAdoptedStoreAtMapTime) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
// An earlier write is queued for its GPU-ordered landing...
const GLint firstInt = 55;
BufferSubData(GL_ARRAY_BUFFER, 0, sizeof(firstInt), &firstInt);
ASSERT_EQ(GetError(), GL_NO_ERROR);
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.readbackCalls, 0);
// ...and the map that discards its range does not wait for it.
constexpr SizeT kFirst = 8;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::InvalidateRange));
ASSERT_NE(mapped, nullptr);
EXPECT_EQ(mock.readbackCalls, 0);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 300 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
ASSERT_EQ(mock.residentWrites.size(), 2u);
// The first read reconciles both landings, oldest first.
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
EXPECT_EQ(readBack[0], firstInt);
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(readBack[kFirst + i], 300 + static_cast<GLint>(i)) << "int " << i;
// The control: a map that keeps what it maps still reconciles before seeding.
BufferSubData(GL_ARRAY_BUFFER, 0, sizeof(firstInt), &firstInt);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto* seeded = static_cast<GLint*>(
bufferObject->AcquireMemoryRange(range, BufferMappingAccessBit::Read | BufferMappingAccessBit::Write));
ASSERT_NE(seeded, nullptr);
EXPECT_EQ(mock.readbackCalls, 2);
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(seeded[i], 300 + static_cast<GLint>(i)) << "int " << i;
bufferObject->ReleaseMemory();
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// Adoption releases the CPU shadow, and a persistent map that did not itself adopt
// (FLUSH_EXPLICIT is excluded from adoption) handed the application a pointer into that
// shadow which GL keeps valid while the buffer is drawn with - which is exactly when a
// storage binding asks for residency. So a mapped buffer keeps the shadow model.
TEST_F(BufferTest, AStorageBindingDoesNotAdoptTheStoreWhileTheApplicationHoldsAMapping) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
Vector<GLint> initial(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) initial[i] = static_cast<GLint>(i);
BufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
const auto* shadowBase = bufferObject->MappedData();
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::Persistent |
BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
ASSERT_EQ(static_cast<const void*>(mapped), static_cast<const void*>(shadowBase + range.start));
EXPECT_FALSE(bufferObject->EnsureGpuResidentStorage());
EXPECT_FALSE(bufferObject->IsBackendPersistentMapped());
EXPECT_EQ(mock.acquireMapCalls, 0);
// The application's pointer is still the store's: it survived the binding.
EXPECT_EQ(static_cast<const void*>(bufferObject->MappedData()), static_cast<const void*>(shadowBase));
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 250 + static_cast<GLint>(i);
bufferObject->FlushMemoryRange(0, kCount * sizeof(GLint));
EXPECT_EQ(mock.flushCalls, 1);
const auto* shadowInts = reinterpret_cast<const GLint*>(bufferObject->MappedData());
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(shadowInts[kFirst + i], 250 + static_cast<GLint>(i));
// Unmapped, the next binding adopts as usual.
bufferObject->ReleaseMemory();
EXPECT_TRUE(bufferObject->EnsureGpuResidentStorage());
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// Respecifying a store hands any adoption back and replaces the bytes, so the staged
// bytes of a map that is still live have nowhere to land: copying a whole mapped range
// into storage that is released on the next line is pure waste.
TEST_F(BufferTest, RespecifyingAStoreWhileItIsMappedDoesNotLandTheStagedBytesIntoIt) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
auto* mapped = static_cast<GLint*>(
bufferObject->AcquireMemoryRange({0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = 1234;
bufferObject->Respecify(kAdoptedInts * sizeof(GLint), nullptr);
EXPECT_TRUE(mock.residentWrites.empty());
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.respecifyCalls, 1);
EXPECT_FALSE(bufferObject->IsMapped());
EXPECT_FALSE(bufferObject->IsBackendPersistentMapped());
EXPECT_FALSE(bufferObject->HasDefinedContent());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, RespecifyingAShadowBackedStoreWhileItIsMappedPushesNoRangeDown) {
ZeroCopyMockBackend mock;
mock.provideMap = false;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_ARRAY_BUFFER, buffer);
Vector<GLint> initial(kAdoptedInts, 7);
BufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
mock.flushCalls = 0;
mock.subDataCalls = 0;
mock.respecifyCalls = 0;
auto* mapped = static_cast<GLint*>(
bufferObject->AcquireMemoryRange({0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = 1234;
bufferObject->Respecify(kAdoptedInts * sizeof(GLint), nullptr);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.respecifyCalls, 1);
EXPECT_FALSE(bufferObject->IsMapped());
EXPECT_FALSE(bufferObject->HasDefinedContent());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
+9
View File
@@ -64,6 +64,15 @@ set(LINK_LIBRARIES
include(GoogleTest)
gtest_discover_tests(SanityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
if (MSVC)
# The GL headers declare gl* as dllimport on Windows, so any test that pulls
# GetProcAddress.cpp out of the static library references __imp_gl*, which only
# resolves when the in-library entry-point definitions are part of the link.
# Applies to every test executable below, the way the DirectVulkan and
# integration test targets already do it for themselves.
add_link_options(/WHOLEARCHIVE:MobileGL_s)
endif()
add_subdirectory(BackendLoader)
add_subdirectory(Buffer)
# The heap-address-is-not-an-identity invariant the backends' per-object memos
+11
View File
@@ -23,6 +23,17 @@ target_link_libraries(
${LINK_LIBRARIES}
)
if (MSVC)
# This test compiles library sources of its own; pulling the whole static
# library in as well (the directory-wide MG_Test link option) would define
# them twice, so that option is dropped for this one target.
get_target_property(_program_util_link_options ProgramUtilTest LINK_OPTIONS)
if (_program_util_link_options)
list(REMOVE_ITEM _program_util_link_options /WHOLEARCHIVE:MobileGL_s)
set_target_properties(ProgramUtilTest PROPERTIES LINK_OPTIONS "${_program_util_link_options}")
endif()
endif()
add_executable(
ProgramTest
ProgramTest.cpp
+16
View File
@@ -30,6 +30,22 @@ target_link_libraries(DriverBugProbesTest PRIVATE
${LINK_LIBRARIES}
)
add_executable(
PrimitivesGeneratedNoXfbProbeTest
PrimitivesGeneratedNoXfbProbeTest.cpp
)
target_include_directories(PrimitivesGeneratedNoXfbProbeTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(PrimitivesGeneratedNoXfbProbeTest 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)
gtest_discover_tests(PrimitivesGeneratedNoXfbProbeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -26,6 +26,7 @@ using MobileGL::MG_Util::SelfTest::ProbeGeometryStageSsboWriteAfterEmitDropped;
using MobileGL::MG_Util::SelfTest::ProbeImageLocationPerNameBudget;
using MobileGL::MG_Util::SelfTest::ProbeImageWriteReadCoherencyResidual;
using MobileGL::MG_Util::SelfTest::ProbeBlitIgnoresDestinationArrayLayer;
using MobileGL::MG_Util::SelfTest::ProbeCopyImageMirrorsPacked16FieldOrder;
using MobileGL::MG_Util::SelfTest::ProbeExplicitVertexInputLocationCeiling;
using MobileGL::MG_Util::SelfTest::ProbeR32FMultisampleSwizzleCorruption;
@@ -107,6 +108,28 @@ namespace {
bool blitIgnoresDestinationLayer = false;
bool blitIgnoresSourceLayer = false;
// Probe 7: the driver stores a WHOLE 16-bit packed ALLOCATION with its fields packed
// from the other end of the word - on the measured device, every level of the probe's
// 30x30x12 three-level array, while same-shape plain-2D images stay in the canonical
// order. Modelled at the raw copy, which is the only path that can observe it (uploads
// and readbacks of the same image decode the driver's own layout consistently): a copy
// whose SOURCE is any level of the mirrored allocation delivers the mirrored
// re-encoding, which the plain-2D readback then decodes with the non-REV order -
// exactly the 0x0047 -> 0x8C20 arithmetic the affected Mali hands back. The mirror
// only engages for the allocation the failures were measured on - a THREE-level
// 30x30x12 array - so a probe that stopped building the triggering shape (fewer
// levels, other dimensions) stops detecting, which is exactly what these tests are
// for.
bool packed16ArrayAllocationMirrored = false;
// "Not this bug": the UPLOAD corrupts, so the array's own direct readback is already
// wrong. The probe's round-trip control must veto the verdict - the widening's
// raw-copy reasoning says nothing about an upload defect.
bool packed16UploadCorrupted = false;
// The inconclusive path: the copy silently lands nothing, so every destination keeps
// its 0xFFFF fill - a value that is neither the word nor its mirror - and the 2D-to-2D
// machinery control fails first.
bool packed16CopyDoesNothing = false;
// ---- object bookkeeping ---------------------------------------------
GLenum pendingError = GL_NO_ERROR;
GLuint nextShaderId = 1;
@@ -136,7 +159,34 @@ namespace {
std::map<GLuint, std::array<GLubyte, 2>> arrayLayerFill;
// framebuffer id -> the (2D array texture, layer) glFramebufferTextureLayer attached.
std::map<GLuint, std::pair<GLuint, GLint>> framebufferLayerAttachment;
// framebuffer id -> the LEVEL that same call named. Kept apart so the layered-blit
// bookkeeping above keeps its shape; the packed16 probe reads array LEVELS directly.
std::map<GLuint, GLint> framebufferLayerLevel;
// (texture, level) -> the PHYSICAL 16-bit word every texel of that 5551 image holds.
// One word per level is all the packed16 probe distinguishes: it uploads a uniform
// fill and reads one texel.
std::map<std::pair<GLuint, GLint>, GLushort> packedTexelWords;
// 2D-array texture id -> its allocation shape, as glTexImage3D built it. What the
// packed16 mirror is gated on: level-0 dimensions plus a mask of the levels actually
// allocated, so only the measured three-level 30x30x12 chain diverges.
struct FakeArrayAllocation {
GLsizei width = 0;
GLsizei height = 0;
GLsizei layers = 0;
unsigned levelMask = 0;
// The device rule the probe reproduces: the mirrored layout is only picked when
// the levels were uploaded onto a texture still at the driver defaults - any
// glTexParameteri BEFORE the first upload steers the driver to the plain layout.
// Modelling it makes a params-first probe (the round-one regression: it measured
// "clean" in the very context whose params-after textures mirrored) stop
// detecting, which turns that mistake into a red test instead of a silent miss.
bool paramsTouchedBeforeUpload = false;
};
std::map<GLuint, FakeArrayAllocation> packedArrayAllocations;
// framebuffer id -> the plain 2D texture glFramebufferTexture2D attached.
std::map<GLuint, GLuint> framebuffer2DAttachment;
GLuint boundArrayTexture = 0;
GLuint boundTexture2D = 0;
GLuint boundDrawFramebuffer = 0;
GLuint boundReadFramebuffer = 0;
@@ -164,6 +214,17 @@ namespace {
return haystack.find(needle) != std::string::npos;
}
// The 5_5_5_1 <-> 1_5_5_5_REV field-order mirror: the same fields, packed from the other
// end of the word. 0x0047 (R,G,B,A = 0,1,3,1) becomes 0x8C20 - the exact pair every
// failing KHR-GL4x.copy_image body printed on the affected Mali.
GLushort MirrorPacked5551(GLushort word) {
const GLushort r = (word >> 11) & 0x1F;
const GLushort g = (word >> 6) & 0x1F;
const GLushort b = (word >> 1) & 0x1F;
const GLushort a = word & 0x1;
return static_cast<GLushort>((a << 15) | (b << 10) | (g << 5) | r);
}
// Every `image2D <name>` the program declares, across all its stages.
std::vector<std::string> DeclaredImageNames(GLuint program) {
std::vector<std::string> names;
@@ -401,6 +462,7 @@ namespace {
funcs.glBindTexture = [](GLenum target, GLuint texture) {
if (target == GL_TEXTURE_2D_MULTISAMPLE) g_fake.boundMultisampleTexture = texture;
if (target == GL_TEXTURE_2D_ARRAY) g_fake.boundArrayTexture = texture;
if (target == GL_TEXTURE_2D) g_fake.boundTexture2D = texture;
};
funcs.glTexStorage3D = [](GLenum target, GLsizei, GLenum, GLsizei, GLsizei, GLsizei) {
if (target == GL_TEXTURE_2D_ARRAY) g_fake.arrayLayerFill[g_fake.boundArrayTexture] = {0, 0};
@@ -418,6 +480,14 @@ namespace {
funcs.glDeleteTextures = [](GLsizei n, const GLuint* textures) {
for (GLsizei i = 0; i < n; ++i) {
if (textures[i] != 0) --g_fake.aliveTextures;
for (auto it = g_fake.packedTexelWords.begin(); it != g_fake.packedTexelWords.end();) {
if (it->first.first == textures[i]) {
it = g_fake.packedTexelWords.erase(it);
} else {
++it;
}
}
g_fake.packedArrayAllocations.erase(textures[i]);
}
};
funcs.glTexParameteri = [](GLenum target, GLenum pname, GLint param) {
@@ -425,9 +495,63 @@ namespace {
g_fake.multisampleAlphaSwizzle[g_fake.boundMultisampleTexture] =
static_cast<GLenum>(param);
}
// A parameter write on a 2D array that has no uploaded level yet steers the
// driver's layout choice to the plain order (see FakeArrayAllocation).
if (target == GL_TEXTURE_2D_ARRAY &&
g_fake.packedArrayAllocations.count(g_fake.boundArrayTexture) == 0) {
g_fake.packedArrayAllocations[g_fake.boundArrayTexture].paramsTouchedBeforeUpload = true;
}
};
// The packed16 probe's endpoints. A plain 2D image stores its 5551 words in the
// canonical (non-REV) order on every knob setting - the defect is confined to array
// mip levels, and keeping the 2D side clean is what lets the readback below decode
// with one order and still reproduce the mirror.
funcs.glTexImage2D = [](GLenum target, GLint level, GLint, GLsizei, GLsizei, GLint, GLenum,
GLenum type, const void* pixels) {
if (target != GL_TEXTURE_2D || type != GL_UNSIGNED_SHORT_5_5_5_1 || pixels == nullptr) return;
GLushort word = 0;
std::memcpy(&word, pixels, sizeof(word));
g_fake.packedTexelWords[{g_fake.boundTexture2D, level}] = word;
};
// Records the allocation shape the mirror below is gated on, and the uploaded word.
// Under the upload-corruption knob the STORED word is already wrong - the "not this
// bug" shape the probe's round-trip control must catch.
funcs.glTexImage3D = [](GLenum target, GLint level, GLint, GLsizei width, GLsizei height,
GLsizei depth, GLint, GLenum, GLenum type, const void* pixels) {
if (target != GL_TEXTURE_2D_ARRAY || type != GL_UNSIGNED_SHORT_5_5_5_1 || pixels == nullptr) return;
GLushort word = 0;
std::memcpy(&word, pixels, sizeof(word));
g_fake.packedTexelWords[{g_fake.boundArrayTexture, level}] =
g_fake.packed16UploadCorrupted ? MirrorPacked5551(word) : word;
auto& allocation = g_fake.packedArrayAllocations[g_fake.boundArrayTexture];
if (level == 0) {
allocation.width = width;
allocation.height = height;
allocation.layers = depth;
}
if (level >= 0 && level < 8) allocation.levelMask |= 1u << level;
};
// A raw texel-block move: the PHYSICAL word travels. The defect lives here - a source
// in the mirrored ALLOCATION delivers the re-encoded word from EVERY level - and it
// only exists for the allocation it was measured on: three levels of a 30x30x12 array.
funcs.glCopyImageSubData = [](GLuint srcName, GLenum, GLint srcLevel, GLint, GLint, GLint,
GLuint dstName, GLenum, GLint dstLevel, GLint, GLint, GLint,
GLsizei, GLsizei, GLsizei) {
if (g_fake.packed16CopyDoesNothing) return;
const auto source = g_fake.packedTexelWords.find({srcName, srcLevel});
if (source == g_fake.packedTexelWords.end()) return;
GLushort word = source->second;
const auto allocation = g_fake.packedArrayAllocations.find(srcName);
const bool measuredShape = allocation != g_fake.packedArrayAllocations.end() &&
allocation->second.width == 30 && allocation->second.height == 30 &&
allocation->second.layers == 12 &&
allocation->second.levelMask == 0b111u &&
!allocation->second.paramsTouchedBeforeUpload;
if (measuredShape && g_fake.packed16ArrayAllocationMirrored) {
word = MirrorPacked5551(word);
}
g_fake.packedTexelWords[{dstName, dstLevel}] = word;
};
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) {};
@@ -446,11 +570,16 @@ namespace {
g_fake.boundReadFramebuffer = framebuffer;
}
};
funcs.glFramebufferTexture2D = [](GLenum, GLenum, GLenum, GLuint, GLint) {};
funcs.glFramebufferTextureLayer = [](GLenum target, GLenum, GLuint texture, GLint, GLint layer) {
funcs.glFramebufferTexture2D = [](GLenum target, GLenum, GLenum, GLuint texture, GLint) {
const GLuint framebuffer = (target == GL_READ_FRAMEBUFFER) ? g_fake.boundReadFramebuffer
: g_fake.boundDrawFramebuffer;
g_fake.framebuffer2DAttachment[framebuffer] = texture;
};
funcs.glFramebufferTextureLayer = [](GLenum target, GLenum, GLuint texture, GLint level, GLint layer) {
const GLuint framebuffer = (target == GL_READ_FRAMEBUFFER) ? g_fake.boundReadFramebuffer
: g_fake.boundDrawFramebuffer;
g_fake.framebufferLayerAttachment[framebuffer] = {texture, layer};
g_fake.framebufferLayerLevel[framebuffer] = level;
};
funcs.glReadBuffer = [](GLenum) {};
// The defect itself: the source layer is read from where the READ framebuffer says (unless
@@ -482,6 +611,8 @@ namespace {
for (GLsizei i = 0; i < n; ++i) {
if (framebuffers[i] != 0) --g_fake.aliveFramebuffers;
g_fake.framebufferLayerAttachment.erase(framebuffers[i]);
g_fake.framebufferLayerLevel.erase(framebuffers[i]);
g_fake.framebuffer2DAttachment.erase(framebuffers[i]);
}
};
funcs.glGenVertexArrays = [](GLsizei n, GLuint* arrays) {
@@ -561,11 +692,54 @@ namespace {
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);
// Answered before anything else: a read framebuffer that names an array LAYER is the
// layered-blit probe asking what that layer holds, and its bytes have nothing to do
// with the pass/fail texel encoding the image probes below share.
// A read framebuffer naming a plain 2D texture that holds a 5551 word is the
// packed16 probe reading its copy destination. The driver decodes its OWN storage
// with the canonical non-REV order and expands each field by bit replication -
// which is exactly how the mirrored word 0x8C20 becomes (140, 132, 132, 0).
if (const auto attached = g_fake.framebuffer2DAttachment.find(g_fake.boundReadFramebuffer);
attached != g_fake.framebuffer2DAttachment.end() &&
g_fake.packedTexelWords.count({attached->second, 0}) != 0) {
// Gated on the texture actually holding a 5551 word, so every OTHER probe that
// attaches a plain 2D texture keeps the pass/fail readback paths below.
const GLushort w = g_fake.packedTexelWords[{attached->second, 0}];
const auto expand5 = [](GLushort v) {
return static_cast<GLubyte>((v << 3) | (v >> 2));
};
GLubyte* out = static_cast<GLubyte*>(pixels);
for (std::size_t i = 0; i < texels; ++i) {
out[i * 4 + 0] = expand5((w >> 11) & 0x1F);
out[i * 4 + 1] = expand5((w >> 6) & 0x1F);
out[i * 4 + 2] = expand5((w >> 1) & 0x1F);
out[i * 4 + 3] = (w & 0x1) ? 255 : 0;
}
return;
}
// A read framebuffer naming an array LEVEL that holds a 5551 word is the packed16
// probe's round-trip control: the driver decodes its OWN storage, so whatever the
// physical word is - mirrored at upload under that knob included - its own decode
// is handed back with the canonical field meaning.
if (const auto layered = g_fake.framebufferLayerAttachment.find(g_fake.boundReadFramebuffer);
layered != g_fake.framebufferLayerAttachment.end()) {
const auto levelIt = g_fake.framebufferLayerLevel.find(g_fake.boundReadFramebuffer);
const GLint attachedLevel = levelIt == g_fake.framebufferLayerLevel.end() ? 0 : levelIt->second;
if (const auto word = g_fake.packedTexelWords.find({layered->second.first, attachedLevel});
word != g_fake.packedTexelWords.end()) {
const GLushort w = word->second;
const auto expand5 = [](GLushort v) {
return static_cast<GLubyte>((v << 3) | (v >> 2));
};
GLubyte* out = static_cast<GLubyte*>(pixels);
for (std::size_t i = 0; i < texels; ++i) {
out[i * 4 + 0] = expand5((w >> 11) & 0x1F);
out[i * 4 + 1] = expand5((w >> 6) & 0x1F);
out[i * 4 + 2] = expand5((w >> 1) & 0x1F);
out[i * 4 + 3] = (w & 0x1) ? 255 : 0;
}
return;
}
// Otherwise it is the layered-blit probe asking what a layer holds, and its
// bytes have nothing to do with the pass/fail texel encoding the image probes
// below share.
const auto& fill = g_fake.arrayLayerFill[layered->second.first];
const GLint layer = layered->second.second;
const GLubyte value =
@@ -626,6 +800,8 @@ TEST(DriverBugProbes, AProbeThatCannotRunReportsNoBug) {
EXPECT_FALSE(ProbeImageLocationPerNameBudget(gl).detected);
EXPECT_FALSE(ProbeCrossStageImageQualifierMergeDropsWrites(gl));
EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected);
EXPECT_FALSE(ProbeCopyImageMirrorsPacked16FieldOrder(gl))
<< "a probe with no entry points has measured nothing";
}
// The section lists only bugs the device HAS, so a driver nothing could be probed on renders
@@ -945,3 +1121,53 @@ TEST(DriverBugProbes, ImageCoherencyNeedsBothHalvesOfTheSplitPairInOneStage) {
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected);
}
TEST(DriverBugProbes, Packed16FieldOrderIsCleanOnAConformingDriver) {
ResetFakeDriver();
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeCopyImageMirrorsPacked16FieldOrder(gl));
ExpectProbeReleasedEverything();
}
// The measured device shape: EVERY level of the mirrored allocation delivers the
// re-encoding, and the machinery/round-trip controls stay clean, so the probe must detect.
TEST(DriverBugProbes, Packed16FieldOrderIsDetectedWhenTheArrayAllocationIsMirrored) {
ResetFakeDriver();
g_fake.packed16ArrayAllocationMirrored = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_TRUE(ProbeCopyImageMirrorsPacked16FieldOrder(gl));
ExpectProbeReleasedEverything();
}
// THE ROUND-TRIP CONTROL. A driver that corrupts the UPLOAD hands the mirror back from the
// array's own direct readback too - a different defect, and one the widening's raw-copy
// reasoning says nothing about - so the probe must reach no verdict rather than claim it.
TEST(DriverBugProbes, Packed16FieldOrderReportsNothingWhenTheUploadItselfCorrupts) {
ResetFakeDriver();
g_fake.packed16UploadCorrupted = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeCopyImageMirrorsPacked16FieldOrder(gl));
ExpectProbeReleasedEverything();
}
// And the shape that is not this bug: a copy that lands nothing leaves every destination's
// 0xFFFF fill, so the 2D-to-2D machinery control fails first - "reached no verdict".
TEST(DriverBugProbes, Packed16FieldOrderReportsNothingWhenTheCopyLandsNothing) {
ResetFakeDriver();
g_fake.packed16ArrayAllocationMirrored = true;
g_fake.packed16CopyDoesNothing = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeCopyImageMirrorsPacked16FieldOrder(gl));
ExpectProbeReleasedEverything();
}
// The byte arithmetic the fake's mirror encodes, pinned against the QPA evidence. The fake
// models the ARRAY-AS-SOURCE direction (decode 5_5_5_1, re-encode 1_5_5_5_REV): 0x0047 must
// deliver 0x8C20, the exact pair every failing array-as-source copy_image body printed. The
// QPA's array-as-destination bodies show the INVERSE transform (enc_5551 of dec_REV: 0x0007
// delivered as 0x3800), and enc_REV(dec_5551(x)) inverts enc_5551(dec_REV(x)), so feeding
// the delivered word back through the fake's mirror must reproduce the original.
TEST(DriverBugProbes, Packed16MirrorArithmeticMatchesTheDeviceEvidence) {
EXPECT_EQ(MirrorPacked5551(0x0047), 0x8C20);
EXPECT_EQ(MirrorPacked5551(0x3800), 0x0007);
}
@@ -0,0 +1,546 @@
// MobileGL - MobileGL/MG_Test/SelfTest/PrimitivesGeneratedNoXfbProbeTest.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
//
// The primitives-generated-without-transform-feedback probe's VERDICT and ARMING
// logic, pinned over synthetic measurements. Recording the probe for real needs a
// GPU; the two pure functions are where the cheap mistakes live - a verdict that reads a
// half-broken driver as healthy, an override arm swapped so ForceOn disarms, a
// substitute ranked below a worse one - and every driver the campaign has
// characterised is written down here as a fake measurement so the mapping cannot
// drift without a red:
// - a conforming driver (stream counts everywhere),
// - Mesa lavapipe as measured 2026-08: stream silent everywhere, the dedicated
// VK_EXT_primitives_generated_query exact everywhere (discard included), and
// the statistics control exact on the plain shape but dead under rasterizer
// discard (llvmpipe's discard short-circuit),
// - the same driver without the dedicated query - the statistics tiers,
// - a device with the defect and no working substitute,
// - a substitute that would be WORSE than the stream query on some shape (the
// never-worse rule the plain-only arm has to prove before it may arm),
// - and the refuse-to-guess shapes (half counts, missing mandatory shapes).
//
// The last section pins the probe's TEARDOWN CONTRACT instead, driving the real
// RunPrimitivesGeneratedNoXfbProbe against a fake Vulkan driver whose fence wait
// can be made to expire: no GPU is needed for that, only the entry points the
// probe is handed, and what it does on that path is what keeps a hung driver from
// hanging the POST.
#include <gtest/gtest.h>
#include <cstdint>
#include <MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h>
using MobileGL::Bool;
using MobileGL::Uint32;
using MobileGL::Uint64;
using MobileGL::MG_Config::QuirkOverride;
using MobileGL::MG_Util::SelfTest::EvaluatePrimitivesGeneratedNoXfbVerdict;
using MobileGL::MG_Util::SelfTest::ChoosePrimitivesGeneratedReroute;
using MobileGL::MG_Util::SelfTest::PrimGenRerouteKind;
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbMeasurement;
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbProbeContext;
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbShapeMeasurement;
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbVerdict;
using MobileGL::MG_Util::SelfTest::RunPrimitivesGeneratedNoXfbProbe;
namespace {
struct ShapeAnswers {
Uint64 stream = 0;
// Negative-free encoding: measured flags separate from values.
Bool pgqMeasured = false;
Uint64 pgq = 0;
Bool statMeasured = false;
Uint64 stat = 0;
};
PrimitivesGeneratedNoXfbShapeMeasurement Shape(const ShapeAnswers& answers) {
PrimitivesGeneratedNoXfbShapeMeasurement shape;
shape.drawn = true;
shape.expectedPrimitives = 1;
shape.streamGenerated = answers.stream;
shape.primitivesGeneratedExtMeasured = answers.pgqMeasured;
shape.primitivesGeneratedExt = answers.pgq;
shape.statisticsMeasured = answers.statMeasured;
shape.statisticsClippingInput = answers.stat;
return shape;
}
PrimitivesGeneratedNoXfbMeasurement Measurement(PrimitivesGeneratedNoXfbShapeMeasurement plain,
PrimitivesGeneratedNoXfbShapeMeasurement discard,
PrimitivesGeneratedNoXfbShapeMeasurement patches) {
PrimitivesGeneratedNoXfbMeasurement measurement;
measurement.ran = true;
measurement.trianglesPlain = plain;
measurement.trianglesDiscard = discard;
measurement.patchesDiscard = patches;
return measurement;
}
PrimitivesGeneratedNoXfbShapeMeasurement NotDrawn() {
return PrimitivesGeneratedNoXfbShapeMeasurement{};
}
constexpr ShapeAnswers kHealthy{1, true, 1, true, 1};
// The lavapipe measurement: stream silent, dedicated query exact, statistics
// exact only where nothing is discarded.
constexpr ShapeAnswers kLavapipePlain{0, true, 1, true, 1};
constexpr ShapeAnswers kLavapipeDiscard{0, true, 1, true, 0};
} // namespace
// A conforming driver: the stream query counts every capture-less shape exactly.
// Controls agreeing changes nothing - health is decided by the subject.
TEST(PrimitivesGeneratedNoXfbVerdictTest, AConformingDriverReadsStreamCounts) {
const auto measurement = Measurement(Shape(kHealthy), Shape(kHealthy), Shape(kHealthy));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
}
// ...and stays healthy with no tessellation stage to draw the patches shape with,
// and with no control at all - a control is only required to QUALIFY a
// substitute, never to certify health.
TEST(PrimitivesGeneratedNoXfbVerdictTest, HealthNeedsNeitherTessellationNorAControl) {
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape(kHealthy), Shape(kHealthy), NotDrawn())),
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({1}), Shape({1}), Shape({1}))),
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
}
// Mesa lavapipe as measured (2026-08): stream silent for every capture-less
// draw, the dedicated primitives-generated query exact on every shape (discard
// included), the statistics control dead under discard. The dedicated query must
// win - it is the only substitute that covers the CTS shape there.
TEST(PrimitivesGeneratedNoXfbVerdictTest, LavapipeShapedMeasurementTakesTheDedicatedQuery) {
const auto measurement =
Measurement(Shape(kLavapipePlain), Shape(kLavapipeDiscard), Shape(kLavapipeDiscard));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute);
}
// The affected-device hypothesis with no dedicated query: statistics exact on
// every shape, the CTS's discarded shapes included.
TEST(PrimitivesGeneratedNoXfbVerdictTest, StatisticsExactEverywhereIsTheFullStatisticsSubstitute) {
const auto measurement = Measurement(Shape({0, false, 0, true, 1}), Shape({0, false, 0, true, 1}),
Shape({0, false, 0, true, 1}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute);
}
// A dedicated query that is silent in the same way the stream query is must not
// be armed - the statistics tier decides instead.
TEST(PrimitivesGeneratedNoXfbVerdictTest, ASilentDedicatedQueryFallsThroughToStatistics) {
const auto measurement = Measurement(Shape({0, true, 0, true, 1}), Shape({0, true, 0, true, 1}),
Shape({0, true, 0, true, 1}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute);
}
// The llvmpipe statistics hole without the dedicated query to rescue it: exact on
// the plain shape, dead under discard. Repairs undiscarded queries only, and the
// verdict must say so.
TEST(PrimitivesGeneratedNoXfbVerdictTest, StatisticsDeadUnderDiscardIsThePlainOnlySubstitute) {
const auto measurement = Measurement(Shape({0, false, 0, true, 1}), Shape({0, false, 0, true, 0}),
Shape({0, false, 0, true, 0}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly);
}
// THE DOMINATION RULE. The plain-only substitute is armed for EVERY XFB-inactive
// draw, so it may only be armed where it is never worse than what it replaces:
// each shape it gets wrong must be one the stream query already answered 0 for.
// Here the discarded triangle is one the stream query answers EXACTLY (a driver
// whose silence is selective) and whose statistics read 0 - rerouting would turn
// that correct 1 into a 0, so the honest verdict is that nothing may be armed.
TEST(PrimitivesGeneratedNoXfbVerdictTest, ASubstituteWorseThanTheStreamOnAnyShapeIsRefused) {
const auto measurement = Measurement(Shape({1, false, 0, true, 1}), Shape({1, false, 0, true, 0}),
Shape({0, false, 0, true, 0}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
// The same shape with the statistics slot MISSING on the stream-exact shape is
// the same trade: an unmeasured control cannot be assumed to answer.
const auto unmeasured = Measurement(Shape({1, false, 0, true, 1}), Shape({1, false, 0, false, 0}),
Shape({0, false, 0, true, 0}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(unmeasured),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
// ...while the same selective silence WITH a substitute that covers the shapes
// it must still qualifies: every shape the statistics miss read 0 anyway.
const auto dominating = Measurement(Shape({1, false, 0, true, 1}), Shape({0, false, 0, true, 1}),
Shape({0, false, 0, true, 0}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(dominating),
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly);
}
// The defect with no substitute: no control, controls silent, or a control that
// OVERCOUNTS the plain shape (as disqualifying as one that reads 0 - an exact
// match is what qualifies a substitute).
TEST(PrimitivesGeneratedNoXfbVerdictTest, StreamSilentWithoutAWorkingPlainControlIsUnfixable) {
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({0}), Shape({0}), Shape({0}))),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({0, true, 0, true, 0}), Shape({0, true, 0, true, 0}),
Shape({0, true, 0, true, 0}))),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({0, true, 2, true, 2}), Shape({0, true, 1, true, 1}),
Shape({0, true, 1, true, 1}))),
PrimitivesGeneratedNoXfbVerdict::Unfixable);
}
// Refuse-to-guess shapes. A nonzero-but-wrong stream answer fits neither the
// defect (exact silence) nor health (the exact count), whichever shape carries
// it; and a probe that never ran, or lost its mandatory shapes, says nothing.
TEST(PrimitivesGeneratedNoXfbVerdictTest, AnswersFittingNeitherHealthNorTheDefectAreInconclusive) {
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({2, true, 1, true, 1}), Shape({0, true, 1, true, 1}),
Shape({0, true, 1, true, 1}))),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
Measurement(Shape({0, true, 1, true, 1}), Shape({3, true, 1, true, 1}),
Shape({0, true, 1, true, 1}))),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
PrimitivesGeneratedNoXfbMeasurement neverRan;
neverRan.ran = false;
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(neverRan),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
const auto missingMandatoryShape = Measurement(Shape({0, true, 1, true, 1}), NotDrawn(), NotDrawn());
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(missingMandatoryShape),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
}
// A partial silence is still the defect: the plain shape counts but the discarded
// ones read 0 (a driver that gates the stream counter on rasterization rather
// than on the capture). With a whole control the substitute is whole.
TEST(PrimitivesGeneratedNoXfbVerdictTest, SilenceOnOnlyTheDiscardShapesIsStillTheDefect) {
const auto measurement = Measurement(Shape({1, true, 1, true, 1}), Shape({0, true, 1, true, 1}),
Shape({0, true, 1, true, 1}));
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute);
}
// ===================== THE OVERRIDE MAPPING =====================
//
// The one-line swap this exists to catch: ForceOn and ForceOff exchanging arms,
// Auto arming on a verdict that never qualified a substitute, or the pool ranking
// inverting. Every cell of the (override x verdict) table is written out.
namespace {
constexpr PrimitivesGeneratedNoXfbVerdict kAllVerdicts[] = {
PrimitivesGeneratedNoXfbVerdict::Inconclusive,
PrimitivesGeneratedNoXfbVerdict::StreamCounts,
PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute,
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute,
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly,
PrimitivesGeneratedNoXfbVerdict::Unfixable,
};
}
TEST(PrimitivesGeneratedNoXfbArmingTest, ForceOffNeverReroutes) {
for (const auto verdict : kAllVerdicts) {
for (const Bool pgqUsable : {false, true}) {
for (const Bool statsUsable : {false, true}) {
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOff, verdict, pgqUsable,
statsUsable),
PrimGenRerouteKind::None);
}
}
}
}
TEST(PrimitivesGeneratedNoXfbArmingTest, ForceOnBypassesTheVerdictButNeverTheStructuralChecks) {
for (const auto verdict : kAllVerdicts) {
// The dedicated query wins where the device can host it...
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, true, true),
PrimGenRerouteKind::PrimitivesGeneratedExt);
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, true, false),
PrimGenRerouteKind::PrimitivesGeneratedExt);
// ...statistics stand in where only they exist...
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, false, true),
PrimGenRerouteKind::ClippingStatistics);
// ...and no pool means no reroute, forced or not.
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, false, false),
PrimGenRerouteKind::None);
}
}
TEST(PrimitivesGeneratedNoXfbArmingTest, AutoFollowsExactlyTheSubstituteVerdicts) {
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute,
true, true),
PrimGenRerouteKind::PrimitivesGeneratedExt);
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute, false, true),
PrimGenRerouteKind::ClippingStatistics);
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly,
false, true),
PrimGenRerouteKind::ClippingStatistics);
// The statistics verdicts never take the dedicated pool: that verdict only
// exists when the dedicated query did NOT qualify.
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute, true, true),
PrimGenRerouteKind::ClippingStatistics);
for (const auto verdict :
{PrimitivesGeneratedNoXfbVerdict::Inconclusive, PrimitivesGeneratedNoXfbVerdict::StreamCounts,
PrimitivesGeneratedNoXfbVerdict::Unfixable}) {
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::Auto, verdict, true, true),
PrimGenRerouteKind::None);
}
// The structural checks bind Auto too.
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute,
false, true),
PrimGenRerouteKind::None);
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute, false, false),
PrimGenRerouteKind::None);
}
// ===================== THE FENCE-TIMEOUT CONTRACT =====================
//
// A driver whose queue never signals the probe's fence inside 5 s is the one case
// where the probe must NOT clean up: the submission may still be executing, so
// vkDeviceWaitIdle can block forever and destroying in-flight objects is
// undefined. It therefore leaks everything it made and says so in the measurement
// (`fenceWaitTimedOut`), which is what lets its callers make the same choice for
// the object THEY own - the driver POST leaks its throwaway VkDevice instead of
// destroying it under live children (vkDestroyDevice would be the very hang the
// bound exists to prevent), and the renderer, whose device is the real one, must
// not idle-wait it either. Neither guard is reachable from a unit test - the POST
// probe lives in an anonymous namespace and the renderer needs a GPU - so this
// pins the contract they both key on, at the boundary where it is produced.
//
// The fake driver below is the whole Vulkan surface the probe touches, with a
// dialable fence-wait result and per-entry-point call counters.
namespace {
struct FakeDriverState {
VkResult fenceWaitResult = VK_SUCCESS;
Uint32 objectsCreated = 0;
Uint32 destroyCalls = 0;
Uint32 deviceWaitIdleCalls = 0;
Uint32 queueSubmitCalls = 0;
Uint64 streamGenerated = 1;
};
FakeDriverState g_fake;
template <typename Handle>
Handle FakeHandle() {
++g_fake.objectsCreated;
// One cast form for both handle flavours: a pointer on 64-bit builds, a
// uint64_t on 32-bit ones. The probe only ever compares against
// VK_NULL_HANDLE, so any distinct nonzero value will do.
return (Handle)(std::uintptr_t)(0x1000u + g_fake.objectsCreated * 0x10u);
}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateCommandPool(VkDevice, const VkCommandPoolCreateInfo*,
const VkAllocationCallbacks*, VkCommandPool* out) {
*out = FakeHandle<VkCommandPool>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyCommandPool(VkDevice, VkCommandPool, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeAllocateCommandBuffers(VkDevice, const VkCommandBufferAllocateInfo*,
VkCommandBuffer* out) {
*out = FakeHandle<VkCommandBuffer>();
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeBeginCommandBuffer(VkCommandBuffer, const VkCommandBufferBeginInfo*) {
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeEndCommandBuffer(VkCommandBuffer) { return VK_SUCCESS; }
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateQueryPool(VkDevice, const VkQueryPoolCreateInfo*,
const VkAllocationCallbacks*, VkQueryPool* out) {
*out = FakeHandle<VkQueryPool>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyQueryPool(VkDevice, VkQueryPool, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR void VKAPI_CALL FakeCmdResetQueryPool(VkCommandBuffer, VkQueryPool, uint32_t, uint32_t) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdBeginQuery(VkCommandBuffer, VkQueryPool, uint32_t, VkQueryControlFlags) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdEndQuery(VkCommandBuffer, VkQueryPool, uint32_t) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdBeginQueryIndexedEXT(VkCommandBuffer, VkQueryPool, uint32_t,
VkQueryControlFlags, uint32_t) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdEndQueryIndexedEXT(VkCommandBuffer, VkQueryPool, uint32_t, uint32_t) {}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateRenderPass(VkDevice, const VkRenderPassCreateInfo*,
const VkAllocationCallbacks*, VkRenderPass* out) {
*out = FakeHandle<VkRenderPass>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyRenderPass(VkDevice, VkRenderPass, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateFramebuffer(VkDevice, const VkFramebufferCreateInfo*,
const VkAllocationCallbacks*, VkFramebuffer* out) {
*out = FakeHandle<VkFramebuffer>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyFramebuffer(VkDevice, VkFramebuffer, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR void VKAPI_CALL FakeCmdBeginRenderPass(VkCommandBuffer, const VkRenderPassBeginInfo*,
VkSubpassContents) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdEndRenderPass(VkCommandBuffer) {}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateShaderModule(VkDevice, const VkShaderModuleCreateInfo*,
const VkAllocationCallbacks*, VkShaderModule* out) {
*out = FakeHandle<VkShaderModule>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyShaderModule(VkDevice, VkShaderModule, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreatePipelineLayout(VkDevice, const VkPipelineLayoutCreateInfo*,
const VkAllocationCallbacks*,
VkPipelineLayout* out) {
*out = FakeHandle<VkPipelineLayout>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyPipelineLayout(VkDevice, VkPipelineLayout,
const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateGraphicsPipelines(VkDevice, VkPipelineCache, uint32_t count,
const VkGraphicsPipelineCreateInfo*,
const VkAllocationCallbacks*, VkPipeline* out) {
for (uint32_t i = 0; i < count; ++i) {
out[i] = FakeHandle<VkPipeline>();
}
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyPipeline(VkDevice, VkPipeline, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR void VKAPI_CALL FakeCmdBindPipeline(VkCommandBuffer, VkPipelineBindPoint, VkPipeline) {}
VKAPI_ATTR void VKAPI_CALL FakeCmdDraw(VkCommandBuffer, uint32_t, uint32_t, uint32_t, uint32_t) {}
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateFence(VkDevice, const VkFenceCreateInfo*,
const VkAllocationCallbacks*, VkFence* out) {
*out = FakeHandle<VkFence>();
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroyFence(VkDevice, VkFence, const VkAllocationCallbacks*) {
++g_fake.destroyCalls;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeQueueSubmit(VkQueue, uint32_t, const VkSubmitInfo*, VkFence) {
++g_fake.queueSubmitCalls;
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeWaitForFences(VkDevice, uint32_t, const VkFence*, VkBool32, uint64_t) {
return g_fake.fenceWaitResult;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeGetQueryPoolResults(VkDevice, VkQueryPool, uint32_t, uint32_t,
size_t dataSize, void* data, VkDeviceSize,
VkQueryResultFlags) {
// The stream pool's {primitivesWritten, primitivesNeeded} pair; the probe
// reads primitivesNeeded, and this fake device counts capture-less draws.
if (data == nullptr || dataSize < 2 * sizeof(Uint64)) {
return VK_INCOMPLETE;
}
auto* pair = static_cast<Uint64*>(data);
pair[0] = 0;
pair[1] = g_fake.streamGenerated;
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL FakeDeviceWaitIdle(VkDevice) {
++g_fake.deviceWaitIdleCalls;
return VK_SUCCESS;
}
PrimitivesGeneratedNoXfbProbeContext FakeProbeContext() {
g_fake = FakeDriverState{};
PrimitivesGeneratedNoXfbProbeContext context;
context.device = (VkDevice)(std::uintptr_t)0xD0D0;
context.queue = (VkQueue)(std::uintptr_t)0xC0C0;
context.transformFeedbackQueriesUsable = true;
// No controls and no tessellation: this fixture is about the teardown
// contract, and the fewer optional slots the fewer moving parts.
auto& fns = context.fns;
fns.vkCreateCommandPool = FakeCreateCommandPool;
fns.vkDestroyCommandPool = FakeDestroyCommandPool;
fns.vkAllocateCommandBuffers = FakeAllocateCommandBuffers;
fns.vkBeginCommandBuffer = FakeBeginCommandBuffer;
fns.vkEndCommandBuffer = FakeEndCommandBuffer;
fns.vkCreateQueryPool = FakeCreateQueryPool;
fns.vkDestroyQueryPool = FakeDestroyQueryPool;
fns.vkCmdResetQueryPool = FakeCmdResetQueryPool;
fns.vkCmdBeginQuery = FakeCmdBeginQuery;
fns.vkCmdEndQuery = FakeCmdEndQuery;
fns.vkCmdBeginQueryIndexedEXT = FakeCmdBeginQueryIndexedEXT;
fns.vkCmdEndQueryIndexedEXT = FakeCmdEndQueryIndexedEXT;
fns.vkCreateRenderPass = FakeCreateRenderPass;
fns.vkDestroyRenderPass = FakeDestroyRenderPass;
fns.vkCreateFramebuffer = FakeCreateFramebuffer;
fns.vkDestroyFramebuffer = FakeDestroyFramebuffer;
fns.vkCmdBeginRenderPass = FakeCmdBeginRenderPass;
fns.vkCmdEndRenderPass = FakeCmdEndRenderPass;
fns.vkCreateShaderModule = FakeCreateShaderModule;
fns.vkDestroyShaderModule = FakeDestroyShaderModule;
fns.vkCreatePipelineLayout = FakeCreatePipelineLayout;
fns.vkDestroyPipelineLayout = FakeDestroyPipelineLayout;
fns.vkCreateGraphicsPipelines = FakeCreateGraphicsPipelines;
fns.vkDestroyPipeline = FakeDestroyPipeline;
fns.vkCmdBindPipeline = FakeCmdBindPipeline;
fns.vkCmdDraw = FakeCmdDraw;
fns.vkCreateFence = FakeCreateFence;
fns.vkDestroyFence = FakeDestroyFence;
fns.vkQueueSubmit = FakeQueueSubmit;
fns.vkWaitForFences = FakeWaitForFences;
fns.vkGetQueryPoolResults = FakeGetQueryPoolResults;
fns.vkDeviceWaitIdle = FakeDeviceWaitIdle;
return context;
}
} // namespace
// The hung driver. Nothing the probe created may be destroyed, the device may not
// be idle-waited, and the measurement must SAY the wait timed out - a caller that
// owns the device reads that flag to leak it too, and `ran == false` alone cannot
// tell this apart from an ordinary setup failure (where teardown already ran and
// destroying the device is correct).
TEST(PrimitivesGeneratedNoXfbProbeTeardownTest, AFenceTimeoutLeaksEverythingAndReportsItself) {
PrimitivesGeneratedNoXfbProbeContext context = FakeProbeContext();
g_fake.fenceWaitResult = VK_TIMEOUT;
const PrimitivesGeneratedNoXfbMeasurement measurement = RunPrimitivesGeneratedNoXfbProbe(context);
EXPECT_FALSE(measurement.ran);
EXPECT_TRUE(measurement.fenceWaitTimedOut)
<< "without this flag the POST destroys its throwaway VkDevice while the probe's children "
"are alive and its submission may still be executing";
EXPECT_GT(g_fake.queueSubmitCalls, 0u) << "the timeout must be the SUBMITTED probe's, not a setup failure";
EXPECT_EQ(g_fake.destroyCalls, 0u)
<< "a probe that timed out must destroy nothing: the submission may still be executing";
EXPECT_EQ(g_fake.deviceWaitIdleCalls, 0u)
<< "vkDeviceWaitIdle on a queue that missed a 5 s deadline is the hang the bound exists to "
"prevent";
// The verdict must not read a timed-out probe as anything but "no verdict".
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
}
// The control: a driver that signals normally gets the ordinary teardown - idle
// wait, every object destroyed, no timeout flag - so the case above is testing the
// timeout branch and not a probe that never cleans up at all.
TEST(PrimitivesGeneratedNoXfbProbeTeardownTest, ASignalledFenceTearsDownNormally) {
PrimitivesGeneratedNoXfbProbeContext context = FakeProbeContext();
g_fake.fenceWaitResult = VK_SUCCESS;
g_fake.streamGenerated = 1; // healthy: the capture-less draws are counted
const PrimitivesGeneratedNoXfbMeasurement measurement = RunPrimitivesGeneratedNoXfbProbe(context);
EXPECT_TRUE(measurement.ran) << measurement.failureReason;
EXPECT_FALSE(measurement.fenceWaitTimedOut);
EXPECT_EQ(g_fake.deviceWaitIdleCalls, 1u);
EXPECT_GT(g_fake.destroyCalls, 0u);
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
}
@@ -11,12 +11,14 @@ add_executable(
FlattenFloat64StorageBlockTest.cpp
FlattenXfbInterfaceBlocksTest.cpp
UniquifyIoBlockNamesTest.cpp
StripIoBlockLocationsTest.cpp
LowerViewportIndexTest.cpp
ClampMultisampleFetchTest.cpp
LegalizeResourceArrayIndexTest.cpp
FlattenAtomicCounterBlockTest.cpp
WidenImageFormatsTest.cpp
GlslangCaptureTest.cpp
DemotePointSizeTest.cpp
)
target_include_directories(SpirvPassTest PRIVATE
@@ -0,0 +1,720 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/DemotePointSizeTest.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 <gtest/gtest.h>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include "spirv-tools/libspirv.hpp"
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::SessionUsageBit;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
using MobileGL::MG_Util::ShaderTranspiler::SpvcSession;
namespace {
// Compiles and LINKS a whole program, then returns one sanitized module per stage - the
// exact bytes ProgramSpirvTask hands the demotion in production, so every shape assertion
// below is made against what the backends would really receive.
Vector<Vector<Uint32>> CompileProgramToSpirv(const Vector<Pair<GLenum, const char*>>& stages) {
using namespace MG_Util::ShaderTranspiler;
Vector<SharedPtr<glslang::TShader>> shaders;
Vector<GLenum> types;
for (const auto& [stage, source] : stages) {
// sourceStr is a StringView; the literals handed in are static, so the view
// stays valid for the whole compile.
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log);
if (!shaderResult) return {};
shaders.push_back(shaderResult.value());
types.push_back(stage);
}
ProgramAttrib programAttrib{.shaders = shaders};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log);
if (!programResult) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = types, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult) return {};
Vector<Vector<Uint32>> modules = Move(binaryResult.value());
for (auto& module : modules) {
EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(module, module, true, true));
}
return modules;
}
String Disassemble(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String text;
EXPECT_TRUE(tools.Disassemble(spirv, &text));
return text;
}
String Transpile(const Vector<Uint32>& spirv) {
SpvcSession session(spirv, SessionUsageBit::Transpile);
auto essl = ShaderCompiler::DecompileShader(session);
EXPECT_TRUE(essl) << (essl ? String{} : essl.error().log);
return essl ? essl.value() : String{};
}
Bool Validates(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
return tools.Validate(spirv);
}
// The five-stage shape of the KHR-GL4x transform-feedback / tessellation capture bodies:
// the value is WRITTEN in the vertex stage, READ from gl_in and re-written in every stage
// after it, and the rasterized size never matters (the captures run under rasterizer
// discard). This is exactly the class the demotion exists to rescue.
const char* kVertexSource = R"(#version 460 core
void main() {
gl_Position = vec4(float(gl_VertexID), 0.0, 0.0, 1.0);
gl_PointSize = 2.0;
}
)";
const char* kTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main() {
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_out[gl_InvocationID].gl_PointSize = gl_in[gl_InvocationID].gl_PointSize + 1.0;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
const char* kTessEvalSource = R"(#version 460 core
layout(triangles, point_mode) in;
void main() {
gl_Position = 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_PointSize = gl_in[0].gl_PointSize + gl_in[1].gl_PointSize + gl_in[2].gl_PointSize;
}
)";
const char* kGeometrySource = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize * 2.0;
EmitVertex();
EndPrimitive();
}
)";
const char* kFragmentSource = R"(#version 460 core
layout(location = 0) out vec4 fragColor;
void main() { fragColor = vec4(1.0); }
)";
// A control chain that never touches point size: the demotion must prove it changed
// NOTHING here, byte for byte, because this is the overwhelming majority of programs on
// an affected device.
const char* kPlainTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main() {
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
const char* kPlainTessEvalSource = R"(#version 460 core
layout(triangles, point_mode) in;
void main() {
gl_Position = gl_in[0].gl_Position;
}
)";
const char* kPlainVertexSource = R"(#version 460 core
void main() {
gl_Position = vec4(float(gl_VertexID), 0.0, 0.0, 1.0);
}
)";
// A control stage that also carries CLIP DISTANCE. SPIRV-Cross force-redeclares the whole
// gl_PerVertex output block for exactly this stage/builtin combination, and prints its
// members from the struct's DECORATIONS rather than from what the module accesses - so a
// demoted module's untouched PointSize member would still reach the driver's ESSL.
const char* kClipDistanceTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main() {
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_out[gl_InvocationID].gl_PointSize = gl_in[gl_InvocationID].gl_PointSize + 1.0;
gl_out[gl_InvocationID].gl_ClipDistance[0] = 0.5;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
// The same clip-distance write and NO point-size access anywhere: the shape a
// successfully demoted module would have been left in. glslang emits the whole
// four-member gl_PerVertex block regardless, which is what makes it the exact
// "declared but unaccessed" state the pass header's premise is about.
const char* kClipDistanceUnusedPointSizeTessControlSource = R"(#version 460 core
layout(vertices = 3) out;
void main() {
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_out[gl_InvocationID].gl_ClipDistance[0] = 0.5;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
// A tessellation evaluation module reaching PointSize through a WHOLE-STRUCT load - the
// one shape the pass must refuse rather than half-rewrite. glslang never emits it, so it
// is assembled by hand.
const char* kWholeStructCopyTessEvalAsm = R"(
OpCapability Tessellation
OpCapability TessellationPointSize
OpMemoryModel Logical GLSL450
OpEntryPoint TessellationEvaluation %main "main" %gl_in %out_block
OpExecutionMode %main Triangles
OpExecutionMode %main SpacingEqual
OpExecutionMode %main VertexOrderCcw
OpMemberDecorate %gl_PerVertex 0 BuiltIn Position
OpMemberDecorate %gl_PerVertex 1 BuiltIn PointSize
OpDecorate %gl_PerVertex Block
%void = OpTypeVoid
%fn_ty = OpTypeFunction %void
%float = OpTypeFloat 32
%v4float = OpTypeVector %float 4
%gl_PerVertex = OpTypeStruct %v4float %float
%uint = OpTypeInt 32 0
%uint_32 = OpConstant %uint 32
%arr = OpTypeArray %gl_PerVertex %uint_32
%ptr_in_arr = OpTypePointer Input %arr
%gl_in = OpVariable %ptr_in_arr Input
%ptr_out_s = OpTypePointer Output %gl_PerVertex
%out_block = OpVariable %ptr_out_s Output
%ptr_in_s = OpTypePointer Input %gl_PerVertex
%int = OpTypeInt 32 1
%int_0 = OpConstant %int 0
%main = OpFunction %void None %fn_ty
%entry = OpLabel
%p = OpAccessChain %ptr_in_s %gl_in %int_0
%v = OpLoad %gl_PerVertex %p
OpStore %out_block %v
OpReturn
OpFunctionEnd
)";
} // namespace
class DemotePointSizeTest : public ::testing::Test {
protected:
void SetUp() override {
MobileGL::Initialize();
m_validationFailuresBefore = ShaderCompiler::SpirvValidationFailureCount();
}
void TearDown() override {
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), m_validationFailuresBefore)
<< "a demoted module did not survive spirv-val";
}
private:
Uint64 m_validationFailuresBefore = 0;
};
TEST_F(DemotePointSizeTest, DemotesAFiveStageProgramWholesale) {
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_GEOMETRY_SHADER, kGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 5u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
// The defect, pinned first: every tessellation/geometry stage really does declare the
// capability the device lacks - the same probe production's declines use.
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]));
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[2]));
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[3]));
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, /*captureRequestsPointSize=*/true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
// THE PRODUCTION GATE, as the arming guard: after demotion neither decline can arm.
// Magma's refusal and Espryt's missing-extension failure both key off exactly these.
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]));
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[2]));
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[3]));
for (const auto& module : modules) {
EXPECT_TRUE(Validates(module));
}
// The carrier chain, boundary by boundary. No user varyings, so the shared location is 0.
const String vs = Disassemble(modules[0]);
EXPECT_NE(vs.find("OpName %mg_PointSizeIo0"), String::npos) << vs;
EXPECT_NE(vs.find("OpStore %mg_PointSizeIo0"), String::npos)
<< "the vertex stage must mirror its built-in into the carrier:\n"
<< vs;
EXPECT_NE(vs.find("BuiltIn PointSize"), String::npos)
<< "the vertex stage KEEPS its core built-in - only tess/geometry stages demote:\n"
<< vs;
const String tcs = Disassemble(modules[1]);
EXPECT_EQ(tcs.find("OpCapability TessellationPointSize"), String::npos) << tcs;
EXPECT_NE(tcs.find("OpName %mg_PointSizeIo0"), String::npos) << tcs;
EXPECT_NE(tcs.find("OpName %mg_PointSizeIo1"), String::npos) << tcs;
const String tes = Disassemble(modules[2]);
EXPECT_EQ(tes.find("OpCapability TessellationPointSize"), String::npos) << tes;
EXPECT_NE(tes.find("OpName %mg_PointSizeIo1"), String::npos) << tes;
EXPECT_NE(tes.find("OpName %mg_PointSizeIo2"), String::npos)
<< "with a geometry stage present the evaluation stage feeds the Io2 boundary, not the "
"capture carrier:\n"
<< tes;
const String gs = Disassemble(modules[3]);
EXPECT_EQ(gs.find("OpCapability GeometryPointSize"), String::npos) << gs;
EXPECT_NE(gs.find("OpName %mg_PointSizeIo2"), String::npos) << gs;
EXPECT_NE(gs.find("OpName %mg_PointSizeCapture"), String::npos) << gs;
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeCapture Location 0"), String::npos) << gs;
EXPECT_NE(gs.find("OpStore %mg_PointSizeCapture"), String::npos) << gs;
// The struct keeps its member - declared, decorated, unaccessed - which is the shape a
// point-size-free glslang module already has on every extension-less driver.
EXPECT_NE(tes.find("BuiltIn PointSize"), String::npos) << tes;
// What SPIRV-Cross then prints: no gl_PointSize anywhere in a demoted stage's ESSL (the
// token DirectGLES's extension gate greps for), the carriers in its place. The CONTROL
// stage is transpiled too, and deliberately: it is the one stage SPIRV-Cross can be made
// to redeclare the whole output block for, which is why the clip-distance combination
// declines instead of demoting.
const String tcsEssl = Transpile(modules[1]);
EXPECT_EQ(tcsEssl.find("gl_PointSize"), String::npos) << tcsEssl;
EXPECT_NE(tcsEssl.find("mg_PointSizeIo1"), String::npos) << tcsEssl;
const String tesEssl = Transpile(modules[2]);
EXPECT_EQ(tesEssl.find("gl_PointSize"), String::npos) << tesEssl;
EXPECT_NE(tesEssl.find("mg_PointSizeIo1"), String::npos) << tesEssl;
const String gsEssl = Transpile(modules[3]);
EXPECT_EQ(gsEssl.find("gl_PointSize"), String::npos) << gsEssl;
EXPECT_NE(gsEssl.find("mg_PointSizeCapture"), String::npos) << gsEssl;
// Demotion is idempotent by construction: with the capability gone, a second pass over
// the same modules finds nothing to arm on and must not touch a byte.
Vector<Vector<Uint32>> again = modules;
ShaderCompiler::PointSizeDemotionOutcome secondOutcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
again, types, true, true, true, secondOutcome, true, true));
EXPECT_FALSE(secondOutcome.demoted);
EXPECT_TRUE(secondOutcome.declineDetail.empty()) << secondOutcome.declineDetail;
EXPECT_EQ(again, modules);
}
TEST_F(DemotePointSizeTest, WithoutAGeometryStageTheEvaluationStageOwnsTheCaptureCarrier) {
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
const String tes = Disassemble(modules[2]);
EXPECT_NE(tes.find("OpName %mg_PointSizeCapture"), String::npos) << tes;
EXPECT_NE(tes.find("OpStore %mg_PointSizeCapture"), String::npos) << tes;
EXPECT_EQ(tes.find("OpName %mg_PointSizeIo2"), String::npos)
<< "no geometry stage, no Io2 boundary:\n"
<< tes;
}
TEST_F(DemotePointSizeTest, AGeometryOnlyProgramReadsTheVertexBoundary) {
const char* geometryReadingVs = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize * 2.0;
EmitVertex();
EndPrimitive();
}
)";
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_GEOMETRY_SHADER, geometryReadingVs},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 3u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, /*demoteTessellation=*/false, /*demoteGeometry=*/true, false, outcome, true,
true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
const String gs = Disassemble(modules[1]);
EXPECT_NE(gs.find("OpName %mg_PointSizeIo0"), String::npos)
<< "the geometry stage's input boundary is fed by the vertex stage:\n"
<< gs;
const String vs = Disassemble(modules[0]);
EXPECT_NE(vs.find("OpStore %mg_PointSizeIo0"), String::npos) << vs;
}
TEST_F(DemotePointSizeTest, TheCarrierLandsPastTheProgramsOwnVaryings) {
const char* vsWithVarying = R"(#version 460 core
out vec4 v_color;
void main() {
gl_Position = vec4(1.0);
gl_PointSize = 3.0;
v_color = vec4(0.5);
}
)";
const char* gsWithVarying = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
in vec4 v_color[];
out vec4 g_color;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize;
g_color = v_color[0];
EmitVertex();
EndPrimitive();
}
)";
const char* fsWithVarying = R"(#version 460 core
in vec4 g_color;
layout(location = 0) out vec4 fragColor;
void main() { fragColor = g_color; }
)";
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, vsWithVarying},
{GL_GEOMETRY_SHADER, gsWithVarying},
{GL_FRAGMENT_SHADER, fsWithVarying}});
ASSERT_EQ(modules.size(), 3u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, false, true, true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
// v_color / g_color occupy location 0, so every carrier must sit at 1 - in every stage,
// because producer and consumer match by location.
const String vs = Disassemble(modules[0]);
EXPECT_NE(vs.find("OpDecorate %mg_PointSizeIo0 Location 1"), String::npos) << vs;
const String gs = Disassemble(modules[1]);
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeIo0 Location 1"), String::npos) << gs;
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeCapture Location 1"), String::npos) << gs;
}
TEST_F(DemotePointSizeTest, APointSizeFreeProgramStaysByteIdentical) {
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kPlainVertexSource},
{GL_TESS_CONTROL_SHADER, kPlainTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPlainTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<Vector<Uint32>> before = modules;
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, false, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_TRUE(outcome.declineDetail.empty()) << outcome.declineDetail;
EXPECT_EQ(modules, before);
}
TEST_F(DemotePointSizeTest, AHostingDeviceStaysByteIdentical) {
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<Vector<Uint32>> before = modules;
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
// Both verdicts say the device hosts the built-in: the un-forced lane's contract.
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, false, false, true, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_EQ(modules, before);
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]))
<< "the un-demoted module must still arm the existing declines";
}
TEST_F(DemotePointSizeTest, ACaptureRequestForcesTheCarrierOnANonWritingCaptureStage) {
// The control stage writes point size (arming the demotion); the evaluation stage never
// does - but a by-name capture must still find the carrier declared there, holding
// whatever an unwritten varying holds, exactly as the unwritten built-in would have.
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPlainTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, /*captureRequestsPointSize=*/true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
const String tes = Disassemble(modules[2]);
EXPECT_NE(tes.find("OpName %mg_PointSizeCapture"), String::npos) << tes;
EXPECT_TRUE(Validates(modules[2]));
// And the driver-side half of the same contract: the ESSL DirectGLES hands its driver
// has to DECLARE the carrier, because DirectGLES respells the glTransformFeedbackVaryings
// request to that name. A carrier the transpile dropped would take the whole capture set
// down with an ES link error naming a variable the application never wrote.
const String tesEssl = Transpile(modules[2]);
EXPECT_NE(tesEssl.find("mg_PointSizeCapture"), String::npos) << tesEssl;
}
// THE PRODUCTION SHAPE THE FORCED CARRIER EXISTS FOR, and the one the flag's own unit test
// could not reach: the capture stage never WRITES gl_PointSize, it only reads the incoming
// one. The demotion still arms - glslang declares GeometryPointSize on a READ - so the
// built-in leaves the module, and only the capture request can put a carrier back. In
// production that request arrives as ProgramLinkTask::SpirvHandoff::captureRequestsPointSize;
// this is the same value one layer down.
TEST_F(DemotePointSizeTest, AReadOnlyCaptureStageStillDeclaresTheCaptureCarrier) {
const char* readOnlyGeometry = R"(#version 460 core
layout(points) in;
layout(points, max_vertices = 1) out;
out float g_echo;
void main() {
gl_Position = gl_in[0].gl_Position;
g_echo = gl_in[0].gl_PointSize;
EmitVertex();
EndPrimitive();
}
)";
const char* echoFragment = R"(#version 460 core
in float g_echo;
layout(location = 0) out vec4 fragColor;
void main() { fragColor = vec4(g_echo); }
)";
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_GEOMETRY_SHADER, readOnlyGeometry},
{GL_FRAGMENT_SHADER, echoFragment}});
ASSERT_EQ(modules.size(), 3u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
// The premise: a stage that only READS the built-in still declares the capability, so the
// device still refuses it and the demotion still arms.
ASSERT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]))
<< "a geometry stage that only reads gl_in[].gl_PointSize must still declare "
"GeometryPointSize, or this whole class of program was never affected";
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, false, true, /*captureRequestsPointSize=*/true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
const String gs = Disassemble(modules[1]);
EXPECT_NE(gs.find("OpName %mg_PointSizeIo0"), String::npos)
<< "the read still has to reach the vertex stage's mirrored value:\n"
<< gs;
EXPECT_NE(gs.find("OpName %mg_PointSizeCapture"), String::npos)
<< "the capture request must force the carrier even though this stage never writes "
"the built-in; without it DirectGLES respells the capture to a name no stage "
"declares and the whole capture set fails to link:\n"
<< gs;
const String gsEssl = Transpile(modules[1]);
EXPECT_NE(gsEssl.find("mg_PointSizeCapture"), String::npos) << gsEssl;
EXPECT_EQ(gsEssl.find("gl_PointSize"), String::npos) << gsEssl;
// Without the request there is nothing to bind a by-name capture to - which is exactly
// what production did on every link while the request never reached this call.
Vector<Vector<Uint32>> unrequested = CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_GEOMETRY_SHADER, readOnlyGeometry},
{GL_FRAGMENT_SHADER, echoFragment}});
ASSERT_EQ(unrequested.size(), 3u);
ShaderCompiler::PointSizeDemotionOutcome unrequestedOutcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
unrequested, types, false, true, /*captureRequestsPointSize=*/false, unrequestedOutcome, true,
true));
EXPECT_TRUE(unrequestedOutcome.demoted) << unrequestedOutcome.declineDetail;
EXPECT_EQ(Disassemble(unrequested[1]).find("OpName %mg_PointSizeCapture"), String::npos)
<< "with no capture asking for it, the carrier must not be declared";
}
// THE PREMISE THE PASS HEADER USED TO STATE UNIVERSALLY: "declared but no longer accessed"
// is invisible to the ES hop. It is not, for one stage/builtin combination - and this case
// pins the mechanism with no demotion involved at all, so a future SPIRV-Cross that emitted
// by ACCESS would fail here first and the decline below could be relaxed.
TEST_F(DemotePointSizeTest, ARedeclaredControlBlockPrintsAnUnaccessedPointSizeMember) {
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kPlainVertexSource},
{GL_TESS_CONTROL_SHADER, kClipDistanceUnusedPointSizeTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPlainTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
// Nothing in this control stage touches point size, so nothing declares the capability -
// it is byte-for-byte the state a demoted module would be left in.
ASSERT_FALSE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]));
const String tcs = Disassemble(modules[1]);
EXPECT_NE(tcs.find("BuiltIn PointSize"), String::npos)
<< "the member has to still be declared for this case to say anything:\n"
<< tcs;
const String tcsEssl = Transpile(modules[1]);
EXPECT_NE(tcsEssl.find("gl_PointSize"), String::npos)
<< "SPIRV-Cross force-redeclares a control stage's gl_PerVertex output block when its "
"clip/cull distances are live, and prints the block's members from their "
"decorations rather than from what is accessed. DirectGLES's extension gate is a "
"text search for this token over exactly this string:\n"
<< tcsEssl;
}
// ... and therefore this program declines rather than demoting: a mutated module that the
// driver still rejects is strictly worse than the honest refusal, because it also flips the
// program-wide verdict and the L1 key.
TEST_F(DemotePointSizeTest, AControlStageCarryingClipDistanceDeclinesTheProgram) {
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_CONTROL_SHADER, kClipDistanceTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 4u);
const Vector<Vector<Uint32>> before = modules;
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER,
GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, true, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_NE(outcome.declineDetail.find("clip/cull"), String::npos) << outcome.declineDetail;
EXPECT_EQ(modules, before) << "a decline must leave every module byte-identical";
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[1]))
<< "the declined program must still arm the existing honest refusals";
}
// A legal desktop-GL shape the passthrough machinery explicitly serves: an evaluation stage
// sitting straight on the vertex stage. Both backends synthesize the missing control stage,
// and that synthesized stage forwards gl_Position and nothing else - so the input carrier the
// demotion would create has no producer, and each backend's "reads a located input" guard
// would decline the program against a varying name the application never wrote. Declining the
// demotion instead keeps the modules, and the diagnostics, honest.
TEST_F(DemotePointSizeTest, AnEvaluationStageWithNoControlStageDeclines) {
const char* readingTessEval = R"(#version 460 core
layout(triangles, point_mode) in;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize + 1.0;
}
)";
Vector<Vector<Uint32>> modules =
CompileProgramToSpirv({{GL_VERTEX_SHADER, kVertexSource},
{GL_TESS_EVALUATION_SHADER, readingTessEval},
{GL_FRAGMENT_SHADER, kFragmentSource}});
ASSERT_EQ(modules.size(), 3u);
const Vector<Vector<Uint32>> before = modules;
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_TESS_EVALUATION_SHADER, GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, true, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_NE(outcome.declineDetail.find("control stage"), String::npos) << outcome.declineDetail;
EXPECT_EQ(modules, before) << "a decline must leave every module byte-identical";
EXPECT_FALSE(ShaderCompiler::ModuleReadsLocatedInput(modules[1]))
<< "the declined evaluation stage must not have acquired the located input carrier "
"that both backends' pass-through guard refuses";
}
// The carrier is placed one past the highest location any stage CONSUMES, and a 64-bit
// vector consumes two of them. GL 4.6 core 11.1.2.1 says so for doubles, and
// ARB_gpu_shader_int64 - which DirectVulkan advertises unconditionally - extends the rule
// verbatim to i64/u64. An i64vec4 counted as one location would put the carrier on the
// SECOND location that varying already owns: two Output variables at one location, an
// invalid Vulkan interface and an ES link error naming a variable the application never
// wrote. This is the one direction the placement is not allowed to be wrong in.
TEST_F(DemotePointSizeTest, TheCarrierClearsA64BitIntegerVectorVarying) {
const char* wideVertex = R"(#version 460 core
#extension GL_ARB_gpu_shader_int64 : require
layout(location = 0) flat out i64vec4 v_wide;
void main() {
gl_Position = vec4(1.0);
gl_PointSize = 3.0;
v_wide = i64vec4(1, 2, 3, 4);
}
)";
const char* wideGeometry = R"(#version 460 core
#extension GL_ARB_gpu_shader_int64 : require
layout(points) in;
layout(points, max_vertices = 1) out;
layout(location = 0) flat in i64vec4 v_wide[];
layout(location = 0) flat out i64vec4 g_wide;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize;
g_wide = v_wide[0];
EmitVertex();
EndPrimitive();
}
)";
const char* wideFragment = R"(#version 460 core
#extension GL_ARB_gpu_shader_int64 : require
layout(location = 0) flat in i64vec4 g_wide;
layout(location = 0) out vec4 fragColor;
void main() { fragColor = vec4(float(g_wide.x)); }
)";
Vector<Vector<Uint32>> modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, wideVertex},
{GL_GEOMETRY_SHADER, wideGeometry},
{GL_FRAGMENT_SHADER, wideFragment}});
ASSERT_EQ(modules.size(), 3u);
const Vector<GLenum> types{GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, false, true, true, outcome, true, true));
EXPECT_TRUE(outcome.demoted) << outcome.declineDetail;
// v_wide / g_wide sit at location 0 and occupy 0 AND 1, so every carrier must clear 2.
const String vs = Disassemble(modules[0]);
EXPECT_NE(vs.find("OpDecorate %mg_PointSizeIo0 Location 2"), String::npos)
<< "the carrier landed on a location the i64vec4 varying already owns:\n"
<< vs;
const String gs = Disassemble(modules[1]);
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeIo0 Location 2"), String::npos) << gs;
EXPECT_NE(gs.find("OpDecorate %mg_PointSizeCapture Location 2"), String::npos) << gs;
}
TEST_F(DemotePointSizeTest, AWholeStructCopyDeclinesTheProgramByteIdentically) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
Vector<Uint32> module;
ASSERT_TRUE(tools.Assemble(kWholeStructCopyTessEvalAsm, &module));
ASSERT_TRUE(tools.Validate(module));
Vector<Vector<Uint32>> modules{module};
const Vector<GLenum> types{GL_TESS_EVALUATION_SHADER};
ShaderCompiler::PointSizeDemotionOutcome outcome;
ASSERT_TRUE(ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
modules, types, true, true, false, outcome, true, true));
EXPECT_FALSE(outcome.demoted);
EXPECT_FALSE(outcome.declineDetail.empty())
<< "a shape the pass cannot express must say so, not silently no-op";
EXPECT_EQ(modules[0], module) << "a decline must not leave a half-demoted module behind";
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(modules[0]))
<< "the declined module must still arm the existing honest refusals";
}
@@ -22,6 +22,8 @@
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
@@ -38,6 +40,7 @@ namespace {
constexpr Uint32 kOpTypeInt = 21;
constexpr Uint32 kOpTypeFloat = 22;
constexpr Uint32 kOpTypeArray = 28;
constexpr Uint32 kOpTypeRuntimeArray = 29;
constexpr Uint32 kOpTypeStruct = 30;
constexpr Uint32 kOpConstant = 43;
constexpr Uint32 kDecorationArrayStride = 6;
@@ -116,6 +119,18 @@ namespace {
return {elementTypeId, length};
}
// The element type id of OpTypeRuntimeArray <arrayId>, or 0 when it is not one - which is
// what a BOUNDED flattened member (an OpTypeArray) answers too, so the two shapes can be told
// apart by the pair of helpers.
Uint32 RuntimeArrayElementOf(const Vector<Uint32>& spirv, Uint32 arrayId) {
Uint32 elementTypeId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpTypeRuntimeArray || wordCount < 3 || words[1] != arrayId) return;
elementTypeId = words[2];
});
return elementTypeId;
}
Bool IsUint32Type(const Vector<Uint32>& spirv, Uint32 typeId) {
Bool isUint = false;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
@@ -140,6 +155,61 @@ namespace {
return text;
}
// How many lines of a disassembly hold BOTH fragments - "OpIMul %uint" and "%uint_8", say -
// which is how the index arithmetic the pass emits is pinned without a host that could run it.
Uint32 CountLinesWith(const String& text, const String& first, const String& second) {
Uint32 count = 0;
SizeT lineStart = 0;
while (lineStart < text.size()) {
SizeT lineEnd = text.find('\n', lineStart);
if (lineEnd == String::npos) lineEnd = text.size();
const String line = text.substr(lineStart, lineEnd - lineStart);
if (line.find(first) != String::npos && line.find(second) != String::npos) ++count;
lineStart = lineEnd + 1;
}
return count;
}
// What every test of the open-ended shape asserts: the block collapsed to ONE member, which
// is a `uint[]` RUNTIME array of stride 4 rather than a bounded one, and nothing 64-bit is
// left for the demotion to find. Returns the disassembly for the arithmetic checks.
String ExpectOpenEndedWordArray(const Vector<Uint32>& output, const String& blockName) {
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, blockName);
EXPECT_NE(structId, 0u) << text;
if (structId == 0) return text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
EXPECT_EQ(members.size(), 1u) << "the block should have collapsed to one member\n" << text;
if (members.size() != 1) return text;
EXPECT_EQ(MemberOffsetsOf(output, structId), (Vector<Uint32>{0}));
const Uint32 elementTypeId = RuntimeArrayElementOf(output, members[0]);
EXPECT_NE(elementTypeId, 0u) << "member 0 is not a runtime array\n" << text;
EXPECT_EQ(ArrayShapeOf(output, members[0]).first, 0u)
<< "an open-ended block must not be given a bounded length\n"
<< text;
EXPECT_TRUE(IsUint32Type(output, elementTypeId)) << text;
EXPECT_EQ(DecorationValueOf(output, members[0], kDecorationArrayStride), 4u) << text;
EXPECT_EQ(CountFloatTypesOfWidth(output, 64), 0u) << text;
return text;
}
// The compute shape every failing KHR-Single-GL45.subgroups fp64 case binds: one runtime
// array of doubles, indexed by an invocation id, read whole-element.
String OpenEndedComputeSource(const String& elementType) {
return String(R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { )") +
elementType + R"( data[]; };
void main() {
)" + elementType +
R"( value = data[gl_LocalInvocationID.x] * data[0];
result[gl_GlobalInvocationID.x] = uint(value)" +
(elementType == "double" ? String{} : String(".x")) + R"();
}
)";
}
Vector<Uint32> CompileToSpirv(GLenum stage, const String& source) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
@@ -345,3 +415,679 @@ TEST_F(FlattenFloat64StorageBlockTest, TheDemotedPathIsUnchangedByTheCapabilityA
EXPECT_EQ(explicitlyDemoted, defaulted);
EXPECT_EQ(CountFloatTypesOfWidth(defaulted, 64), 0u) << Disassemble(defaulted);
}
// ---------------------------------------------------------------------------
// The open-ended shape: a block whose last member is a runtime array. Before this was accepted
// the pass declined it and the demotion re-derived ArrayStride 4 for the now-float element, so
// `double data[]` read the application's 8-byte-stride buffer as 32-bit words - every fp64
// KHR-Single-GL45.subgroups case failed on exactly that.
// ---------------------------------------------------------------------------
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockOfDoublesBecomesAWordRuntimeArray) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource("double"));
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// Element i of the original array starts at word 2i, so the dynamic index is scaled by 2 ...
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 1u) << text;
// ... and the constant `data[0]` is the pair of words at 0 and 1, reached through the one
// member the block has left.
EXPECT_GE(CountLinesWith(text, "OpAccessChain %_ptr_StorageBuffer_uint", "%uint_0 %uint_0"), 1u) << text;
}
TEST_F(FlattenFloat64StorageBlockTest, EachDoubleVectorWidthStepsByItsOwnStride) {
struct Shape {
const char* element;
// std430 strides: dvec2 16 bytes, dvec3 and dvec4 32 bytes - i.e. 4, 8 and 8 words.
const char* strideWords;
// The last component's word offset inside one element, and the first one past it.
const char* lastComponentWords;
const char* firstWordPastIt;
};
const Shape shapes[] = {{"dvec2", "%uint_4", "%uint_2", "%uint_4"},
{"dvec3", "%uint_8", "%uint_4", "%uint_6"},
{"dvec4", "%uint_8", "%uint_6", "%uint_8"}};
for (const Shape& shape : shapes) {
SCOPED_TRACE(shape.element);
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource(shape.element));
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", shape.strideWords), 1u) << text;
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", shape.lastComponentWords), 1u) << text;
// A dvec3 is six words in a stride of eight: nothing may be read from the padding.
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", shape.firstWordPastIt), 0u) << text;
}
}
TEST_F(FlattenFloat64StorageBlockTest, AFixedPrefixBeforeTheRuntimeArrayIsAddedToEveryIndex) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data {
uvec4 head;
double data[];
};
void main() {
result[gl_GlobalInvocationID.x] = head.x + uint(data[gl_LocalInvocationID.x]);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Data");
ASSERT_NE(inputStructId, 0u);
EXPECT_EQ(MemberOffsetsOf(input, inputStructId), (Vector<Uint32>{0, 16})) << Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// The 16-byte prefix is 4 words: element i is at word 4 + 2i.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_4"), 1u) << text;
// And the prefix member itself is still word 0.
EXPECT_GE(CountLinesWith(text, "OpAccessChain %_ptr_StorageBuffer_uint", "%uint_0 %uint_0"), 1u) << text;
}
// OpArrayLength on the flattened member counts WORDS. GL's `.length()` is the number of whole
// elements the bound range holds past the array's offset, so the count has to be rebased and
// divided - in unsigned arithmetic, and clamped rather than wrapped when the range is shorter
// than the prefix.
namespace {
// A prefix, an open-ended array of doubles, and a `.length()` of it - the one shape whose
// rewrite is an instruction SPIRV-Cross has to spell rather than plain arithmetic.
constexpr const char* kOpenEndedLengthSource = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data {
uvec4 head;
double data[];
};
void main() {
result[gl_GlobalInvocationID.x] = uint(data.length()) + head.y;
}
)";
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, TheLengthOfAnOpenEndedBlockIsRewrittenToAnElementCount) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, kOpenEndedLengthSource);
ASSERT_FALSE(input.empty());
// glslang asks for member 1's length and signs the answer.
EXPECT_EQ(CountLinesWith(Disassemble(input), "OpArrayLength %uint", " 1"), 1u) << Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// Re-aimed at the one member left, ...
EXPECT_EQ(CountLinesWith(text, "OpArrayLength %uint", " 0"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpArrayLength %uint", " 1"), 0u) << text;
// ... rebased past the 4-word prefix, clamped at zero when the range does not reach it, ...
EXPECT_EQ(CountLinesWith(text, "OpISub %uint", "%uint_4"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpULessThan %bool", "%uint_4"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpSelect %uint", "%uint_0"), 1u) << text;
// ... and divided by the 2-word stride, with glslang's own sign conversion still downstream.
EXPECT_EQ(CountLinesWith(text, "OpUDiv %uint", "%uint_2"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpBitcast %int", ""), 1u) << text;
}
TEST_F(FlattenFloat64StorageBlockTest, TheLengthOfABlockWithNoPrefixNeedsNoClamp) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { dvec2 data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data.length());
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
EXPECT_EQ(CountLinesWith(text, "OpArrayLength %uint", " 0"), 1u) << text;
// Nothing to subtract, so nothing to clamp: the word count over the 4-word stride is it.
EXPECT_EQ(CountLinesWith(text, "OpISub", ""), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpSelect", ""), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpUDiv %uint", "%uint_4"), 1u) << text;
}
// The graphics shape of the same CTS group: a fragment stage reading a `readonly` block. The
// NonWritable the qualifier became is a promise about the whole block, and has to be on the one
// member the flattened block keeps.
TEST_F(FlattenFloat64StorageBlockTest, AReadOnlyOpenEndedBlockKeepsNonWritable) {
const String source = R"(#version 450 core
layout(binding = 4, std430) readonly buffer Buffer4 { dvec3 data[]; };
layout(location = 0) out vec4 o_color;
void main() {
uint index = uint(gl_FragCoord.x);
o_color = vec4(float(data[index].z));
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_FRAGMENT_SHADER, source);
ASSERT_FALSE(input.empty());
EXPECT_EQ(CountLinesWith(Disassemble(input), "OpMemberDecorate %Buffer4 0 NonWritable", ""), 1u)
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Buffer4");
EXPECT_EQ(CountLinesWith(text, "OpMemberDecorate %Buffer4 0 NonWritable", ""), 1u) << text;
// dvec3: stride 8 words, .z at +4.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_8"), 1u) << text;
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", "%uint_4"), 1u) << text;
}
// Writing through an open-ended block, which no CTS case does but any shader may: the store
// is decomposed into the same words the load would have read, so the bytes the application
// gets back are the ones GL says it wrote.
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockIsWrittenThroughTheSameWords) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 1) buffer Data { double data[]; };
void main() {
data[gl_LocalInvocationID.x] = double(gl_LocalInvocationID.y);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// One dynamic index, scaled to the 2-word element ...
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 1u) << text;
// ... and the double left as exactly two word stores, nothing wider.
EXPECT_EQ(CountLinesWith(text, "OpStore", ""), 2u) << text;
EXPECT_EQ(CountLinesWith(text, "OpAccessChain %_ptr_StorageBuffer_uint", ""), 2u) << text;
}
// The exact compute shader KHR-Single-GL45.subgroups.arithmetic.compute.subgroupmul_double
// generates, so the CTS shape is pinned as it is and not as a paraphrase of it.
TEST_F(FlattenFloat64StorageBlockTest, TheSubgroupMulDoubleComputeShaderIsFlattened) {
const String source = R"(#version 450
#extension GL_KHR_shader_subgroup_arithmetic: enable
#extension GL_KHR_shader_subgroup_ballot: enable
layout (local_size_x = 16, local_size_y = 1, local_size_z = 1) in;
layout(binding = 0, std430) buffer Buffer0
{
uint result[];
};
layout(binding = 1, std430) buffer Buffer1
{
double data[];
};
void main (void)
{
uvec3 globalSize = gl_NumWorkGroups * gl_WorkGroupSize;
highp uint offset = globalSize.x * ((globalSize.y * gl_GlobalInvocationID.z) + gl_GlobalInvocationID.y) + gl_GlobalInvocationID.x;
uvec4 mask = subgroupBallot(true);
uint start = 0u, end = gl_SubgroupSize;
double ref = double(1);
uint tempResult = 0u;
for (uint index = start; index < end; index++)
{
if (subgroupBallotBitExtract(mask, index))
{
ref = ref * data[index];
}
}
tempResult = (abs(ref - subgroupMul(data[gl_SubgroupInvocationID])) < 0.00001) ? 0x1u : 0u;
if (1u == (gl_SubgroupInvocationID % 2u))
{
mask = subgroupBallot(true);
ref = double(1);
for (uint index = start; index < end; index++)
{
if (subgroupBallotBitExtract(mask, index))
{
ref = ref * data[index];
}
}
tempResult |= (abs(ref - subgroupMul(data[gl_SubgroupInvocationID])) < 0.00001) ? 0x2u : 0u;
}
else
{
tempResult |= 0x2u;
}
result[offset] = tempResult;
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Buffer1");
// Four reads of the array, each scaled to the 2-word element.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 4u) << text;
// The result block holds no double and is not the pass's business.
const Uint32 resultStructId = StructIdNamed(output, "Buffer0");
ASSERT_NE(resultStructId, 0u) << text;
const Vector<Uint32> resultMembers = MemberTypesOf(output, resultStructId);
ASSERT_EQ(resultMembers.size(), 1u);
EXPECT_TRUE(IsUint32Type(output, RuntimeArrayElementOf(output, resultMembers[0]))) << text;
}
// A runtime array whose element is a MATRIX. The member's own MatrixStride and RowMajor
// decorations describe those elements, so a row-major one has to be declined - its columns are
// not contiguous, and addressing it in column order against a row-major buffer would be silently
// wrong bytes rather than a refusal. The column-major twin must flatten, stepping by the
// element's stride and then by the column's.
TEST_F(FlattenFloat64StorageBlockTest, ARowMajorMatrixRuntimeArrayIsLeftToTheDemotion) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1, row_major) buffer Data { dmat4 data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data[gl_LocalInvocationID.x][1][2]);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
// The premise: glslang really did mark the member row-major.
EXPECT_EQ(CountLinesWith(Disassemble(input), "OpMemberDecorate %Data 0 RowMajor", ""), 1u)
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, "Data");
ASSERT_NE(structId, 0u) << text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << text;
// Still a runtime array of matrices - narrowed to fp32 by the demotion, not re-addressed.
EXPECT_NE(DecorationValueOf(output, members[0], kDecorationArrayStride), 4u)
<< "a row-major matrix element must not have been flattened into words\n"
<< text;
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", ""), 0u)
<< "nothing should have been re-addressed\n"
<< text;
}
TEST_F(FlattenFloat64StorageBlockTest, AColumnMajorMatrixRuntimeArrayStepsByItsColumnStride) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { dmat2x4 data[]; };
void main() {
dvec4 column = data[gl_LocalInvocationID.x][1];
result[gl_GlobalInvocationID.x] = uint(column.w);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// dmat2x4: two columns of dvec4, column stride 32 bytes, so one element is 64 bytes -
// 16 words - and column 1 starts 8 words into it.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_16"), 1u) << text;
// Exactly one +8: the column's own offset inside the element. A second would mean a word
// past the column was being addressed off that same base.
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_8"), 1u) << text;
// All eight words of that column are read - the last of its four doubles ends at +7 ...
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_7"), 1u) << text;
// ... and the column that was not asked for is not touched: nothing is read at +9 or past.
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_9"), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_10"), 0u) << text;
}
// A runtime array whose element is a STRUCT: the same walk, and the same decline test, as a
// bounded array of them - a shape no other open-ended case reaches.
TEST_F(FlattenFloat64StorageBlockTest, AStructRuntimeArrayStepsByItsElementStride) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
struct Pair { double a; float b; };
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { Pair data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data[gl_LocalInvocationID.x].a) +
uint(data[gl_LocalInvocationID.x].b);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// std430 rounds `{ double a; float b; }` up to its 8-byte alignment: 16 bytes, 4 words,
// with `b` two words in.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_4"), 2u) << text;
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", "%uint_2"), 1u) << text;
}
// The leaf cap bounds ONE load or store, not a member's size: a block whose element is far too
// big to expand whole is still flattened while every access to it names a scalar. Declining it
// would leave the application's 8-byte-stride doubles to the demotion's re-derived stride 4 -
// the exact defect the open-ended shape exists to avoid.
TEST_F(FlattenFloat64StorageBlockTest, AHugeRuntimeArrayElementIsStillFlattenedWhenAccessesAreSmall) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
struct Big { dvec4 v[300]; };
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { Big data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data[gl_LocalInvocationID.x].v[3].y);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// 300 dvec4 of 32 bytes each: 9600 bytes, 2400 words per element - 1200 scalars, well past
// the per-access cap that a whole-element load would have to respect and this never does.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2400"), 1u) << text;
// v[3].y is 3 * 8 + 2 = 26 words into the element.
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", "%uint_26"), 1u) << text;
}
// The flatten preserves a byte layout ACROSS a narrowing; where the backend consumes 64-bit
// floats itself there is nothing to preserve, and the open-ended block has to keep its runtime
// array of doubles exactly as the driver would lay it out.
TEST_F(FlattenFloat64StorageBlockTest, TheNativePathLeavesAnOpenEndedBlockAndItsDoublesAlone) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource("double"));
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output, true, true, true));
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, "Data");
ASSERT_NE(structId, 0u) << text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << text;
EXPECT_NE(RuntimeArrayElementOf(output, members[0]), 0u)
<< "the member should still be a runtime array\n"
<< text;
EXPECT_EQ(DecorationValueOf(output, members[0], kDecorationArrayStride), 8u)
<< "the array must keep the 8-byte stride the application bound\n"
<< text;
EXPECT_GT(CountFloatTypesOfWidth(output, 64), 0u)
<< "nothing narrows here, so the doubles must survive\n"
<< text;
}
// The other backend prints the flattened module through SPIRV-Cross: an open-ended `uint[]`
// member has to come out as ESSL that names no 64-bit type. The `.length()` shape is here too,
// because the OpArrayLength the rewrite re-issues is the one instruction in it whose ESSL
// spelling is not plain arithmetic - if that backend ever refused it on the flattened member,
// a DirectGLES shader asking an fp64 buffer its length would fail at link and nowhere else.
namespace {
String TranspileToEssl(const Vector<Uint32>& spirv) {
using namespace MG_Util::ShaderTranspiler;
SpvcSession session(spirv, SessionUsageBit::Transpile);
spvc_compiler_options options;
EXPECT_EQ(session.CreateOptions(&options), SPVC_SUCCESS);
spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, 320);
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);
EXPECT_EQ(session.SetOptions(options), SPVC_SUCCESS);
auto essl = ShaderCompiler::DecompileShader(session);
EXPECT_TRUE(essl) << (essl ? String{} : essl.error().log);
return essl ? *essl : String{};
}
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockCanBeEmittedAsEssl) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource("dvec4"));
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
ExpectOpenEndedWordArray(output, "Data");
const String essl = TranspileToEssl(output);
ASSERT_FALSE(essl.empty());
EXPECT_EQ(essl.find("double"), String::npos) << essl;
EXPECT_EQ(essl.find("dvec"), String::npos) << essl;
EXPECT_NE(essl.find("uint"), String::npos) << essl;
}
TEST_F(FlattenFloat64StorageBlockTest, TheRewrittenLengthCanBeEmittedAsEssl) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, kOpenEndedLengthSource);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
ExpectOpenEndedWordArray(output, "Data");
const String essl = TranspileToEssl(output);
ASSERT_FALSE(essl.empty());
EXPECT_EQ(essl.find("double"), String::npos) << essl;
EXPECT_EQ(essl.find("dvec"), String::npos) << essl;
// The length survived as a length - it was not folded away or dropped on the floor.
EXPECT_NE(essl.find(".length()"), String::npos) << essl;
}
// ---------------------------------------------------------------------------
// The gate from the other side: a runtime array anywhere but the block's own last member is a
// shape GLSL cannot spell and this pass does not describe. SPIR-V can spell it, so both are
// hand-written, and both are invalid Vulkan SPIR-V - the chain runs without its validator here,
// which is also why neither can be a validation-failure count.
// ---------------------------------------------------------------------------
namespace {
// `buffer Odd { double data[]; uint tail; }`, the runtime array FIRST.
const char* kRuntimeArrayNotLastAsm = R"(
OpCapability Shader
OpCapability Float64
OpMemoryModel Logical GLSL450
OpEntryPoint GLCompute %main "main"
OpExecutionMode %main LocalSize 1 1 1
OpName %Odd "Odd"
OpName %var ""
OpDecorate %_runtimearr_double ArrayStride 8
OpDecorate %Odd Block
OpMemberDecorate %Odd 0 Offset 0
OpMemberDecorate %Odd 1 Offset 8
OpDecorate %var Binding 0
OpDecorate %var DescriptorSet 0
%void = OpTypeVoid
%3 = OpTypeFunction %void
%uint = OpTypeInt 32 0
%int = OpTypeInt 32 1
%int_0 = OpConstant %int 0
%int_1 = OpConstant %int 1
%double = OpTypeFloat 64
%double_2 = OpConstant %double 2
%_runtimearr_double = OpTypeRuntimeArray %double
%Odd = OpTypeStruct %_runtimearr_double %uint
%_ptr_StorageBuffer_Odd = OpTypePointer StorageBuffer %Odd
%var = OpVariable %_ptr_StorageBuffer_Odd StorageBuffer
%_ptr_StorageBuffer_double = OpTypePointer StorageBuffer %double
%main = OpFunction %void None %3
%5 = OpLabel
%6 = OpAccessChain %_ptr_StorageBuffer_double %var %int_0 %int_1
OpStore %6 %double_2
OpReturn
OpFunctionEnd
)";
// `struct Inner { double data[]; }; buffer Outer { uint head; Inner inner; }`: the runtime
// array IS last, but of a member rather than of the block.
const char* kRuntimeArrayNestedAsm = R"(
OpCapability Shader
OpCapability Float64
OpMemoryModel Logical GLSL450
OpEntryPoint GLCompute %main "main"
OpExecutionMode %main LocalSize 1 1 1
OpName %Outer "Outer"
OpName %Inner "Inner"
OpName %var ""
OpDecorate %_runtimearr_double ArrayStride 8
OpMemberDecorate %Inner 0 Offset 0
OpDecorate %Outer Block
OpMemberDecorate %Outer 0 Offset 0
OpMemberDecorate %Outer 1 Offset 8
OpDecorate %var Binding 0
OpDecorate %var DescriptorSet 0
%void = OpTypeVoid
%3 = OpTypeFunction %void
%uint = OpTypeInt 32 0
%int = OpTypeInt 32 1
%int_0 = OpConstant %int 0
%int_1 = OpConstant %int 1
%double = OpTypeFloat 64
%double_2 = OpConstant %double 2
%_runtimearr_double = OpTypeRuntimeArray %double
%Inner = OpTypeStruct %_runtimearr_double
%Outer = OpTypeStruct %uint %Inner
%_ptr_StorageBuffer_Outer = OpTypePointer StorageBuffer %Outer
%var = OpVariable %_ptr_StorageBuffer_Outer StorageBuffer
%_ptr_StorageBuffer_double = OpTypePointer StorageBuffer %double
%main = OpFunction %void None %3
%5 = OpLabel
%6 = OpAccessChain %_ptr_StorageBuffer_double %var %int_1 %int_0 %int_1
OpStore %6 %double_2
OpReturn
OpFunctionEnd
)";
Vector<Uint32> AssembleUnchecked(const char* asmText) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
Vector<Uint32> module;
EXPECT_TRUE(tools.Assemble(asmText, &module));
return module;
}
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, ARuntimeArrayThatIsNotTheBlocksLastMemberIsLeftToTheDemotion) {
struct Shape {
const char* asmText;
const char* blockName;
};
const Shape shapes[] = {{kRuntimeArrayNotLastAsm, "Odd"}, {kRuntimeArrayNestedAsm, "Outer"}};
for (const Shape& shape : shapes) {
SCOPED_TRACE(shape.blockName);
const Vector<Uint32> input = AssembleUnchecked(shape.asmText);
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output, false, false));
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
// Declined: both members are still there, and the demotion narrowed them the old way.
const Uint32 structId = StructIdNamed(output, shape.blockName);
ASSERT_NE(structId, 0u) << text;
EXPECT_EQ(MemberTypesOf(output, structId).size(), 2u) << text;
EXPECT_EQ(CountFloatTypesOfWidth(output, 64), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", ""), 0u)
<< "nothing should have been re-addressed\n"
<< text;
}
}
// ---------------------------------------------------------------------------
// The front end declares types in first-use order, so a block that is the first thing the
// shader touches is declared before the module's `uint` - and the flattened member is an array
// OF `uint`. For an OPEN-ENDED block the pass moves that operand-less type up in front of the
// block rather than declining, so that where a buffer of doubles stands in the shader does not
// decide whether its bytes survive. A BOUNDED block in the same position keeps the decline it
// has always had: widening that is a change to a path this fix does not need, and the pair below
// pins both halves.
// ---------------------------------------------------------------------------
namespace {
// The position of <id>'s declaration in instruction order, or 0 when it has none.
Uint32 DeclarationIndexOf(const Vector<Uint32>& spirv, Uint32 id) {
Uint32 index = 0;
Uint32 found = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
++index;
if (found != 0 || wordCount < 2) return;
// Every OpType* has its result id in word 1; that is all this is asked about.
if (opcode >= kOpTypeInt && opcode <= kOpTypeStruct && words[1] == id) found = index;
});
return found;
}
Uint32 Uint32TypeIdOf(const Vector<Uint32>& spirv) {
Uint32 typeId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode == kOpTypeInt && wordCount >= 4 && words[2] == 32u && words[3] == 0u) typeId = words[1];
});
return typeId;
}
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockDeclaredBeforeTheModulesUintIsStillFlattened) {
// The block is the first thing main touches, and nothing before it needs a uint - not even
// an array length, which is a uint constant and would declare one.
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Data { double data[]; };
layout(std430, binding = 1) buffer Sink { float result[]; };
void main() {
result[0] = float(data[0] + data[1]);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Data");
ASSERT_NE(inputStructId, 0u);
const Uint32 inputUintId = Uint32TypeIdOf(input);
// The premise: the module's uint really is declared after the block (or not at all).
ASSERT_TRUE(inputUintId == 0 ||
DeclarationIndexOf(input, inputUintId) > DeclarationIndexOf(input, inputStructId))
<< "this shader was meant to declare the block before any uint\n"
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
const Uint32 structId = StructIdNamed(output, "Data");
ASSERT_NE(structId, 0u) << text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << text;
// And the uint now stands in front of the block it is an element of.
EXPECT_LT(DeclarationIndexOf(output, RuntimeArrayElementOf(output, members[0])),
DeclarationIndexOf(output, structId))
<< text;
}
TEST_F(FlattenFloat64StorageBlockTest, ABoundedBlockDeclaredBeforeTheModulesUintIsLeftToTheDemotion) {
// The same position, a bounded block: this is the shape that has always been declined, and
// it stays declined - its members and the demotion's own repacking come through untouched.
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Wide {
double data0;
dvec2 data1;
} g_wide;
layout(std430, binding = 1) buffer Sink { float result[]; };
void main() {
double sum = g_wide.data0 + g_wide.data1.y;
result[0] = float(sum);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Wide");
ASSERT_NE(inputStructId, 0u);
const Uint32 inputUintId = Uint32TypeIdOf(input);
ASSERT_TRUE(inputUintId == 0 ||
DeclarationIndexOf(input, inputUintId) > DeclarationIndexOf(input, inputStructId))
<< "this shader was meant to declare the block before any uint\n"
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, "Wide");
ASSERT_NE(structId, 0u) << text;
EXPECT_EQ(MemberTypesOf(output, structId).size(), 2u)
<< "a bounded block in this position must keep the decline it shipped with\n"
<< text;
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", ""), 0u)
<< "nothing should have been re-addressed\n"
<< text;
}
@@ -0,0 +1,289 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/StripIoBlockLocationsTest.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 <gtest/gtest.h>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::SessionUsageBit;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
using MobileGL::MG_Util::ShaderTranspiler::SpvcSession;
namespace {
Vector<Uint32> CompileToSpirv(GLenum stage, const String& source) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log);
if (!shaderResult) return {};
ProgramAttrib programAttrib{.shaders = {shaderResult.value()}};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log);
if (!programResult) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {stage}, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult || binaryResult->empty()) return {};
return binaryResult->front();
}
String Transpile(const Vector<Uint32>& spirv) {
SpvcSession session(spirv, SessionUsageBit::Transpile);
auto essl = ShaderCompiler::DecompileShader(session);
EXPECT_TRUE(essl) << (essl ? String{} : essl.error().log);
return essl ? essl.value() : String{};
}
// How many times `needle` occurs in `haystack`.
SizeT CountOf(const String& haystack, const String& needle) {
SizeT count = 0;
for (SizeT at = haystack.find(needle); at != String::npos; at = haystack.find(needle, at + 1)) {
++count;
}
return count;
}
// The tessellation evaluation stage of
// KHR-GLxx.shading_language_420pack.length_of_vector_and_matrix_*, reduced to what this
// pass is about: one block consumed, one block produced, a plain varying in each
// direction, and NO location written anywhere in the source. Every location in the
// emitted ESSL is invented by glslang's cross-stage IO resolver.
const char* kTessEvalSource = R"(#version 420 core
layout(isolines, point_mode) in;
in vec4 tcs_tes_result[];
out vec4 tes_gs_result;
in TCSOutputBlock {
vec4 tcs_tes_variable;
} input_block[];
out TESOutputBlock {
vec4 tes_gs_variable;
} output_block;
void main()
{
tes_gs_result = tcs_tes_result[0];
output_block.tes_gs_variable = input_block[0].tcs_tes_variable;
}
)";
// The OTHER place a block's location can live. When the application locates the MEMBERS
// rather than the block, glslang emits one OpMemberDecorate Location per member and
// NOTHING on the variable - and SPIRV-Cross then suppresses the block-level qualifier and
// prints the member ones instead. A strip that only looked at the variable would find
// nothing to remove here, report "unchanged", and leave the emitted ESSL carrying exactly
// the located block the driver drops the payload for.
const char* kMemberLocatedTessEvalSource = R"(#version 450 core
layout(isolines, point_mode) in;
in TCSOutputBlock {
layout(location = 4) vec4 tcs_tes_variable;
layout(location = 5) vec4 tcs_tes_second;
} input_block[];
out TESOutputBlock {
layout(location = 6) vec4 tes_gs_variable;
layout(location = 7) vec4 tes_gs_second;
} output_block;
void main()
{
output_block.tes_gs_variable = input_block[0].tcs_tes_variable;
output_block.tes_gs_second = input_block[0].tcs_tes_second;
}
)";
// A stage with no interface block at all: the pass must leave its located varyings alone
// and report that it changed nothing, so the caller declines the re-serialised module.
const char* kNoBlockTessEvalSource = R"(#version 420 core
layout(isolines, point_mode) in;
in vec4 tcs_tes_result[];
out vec4 tes_gs_result;
void main()
{
tes_gs_result = tcs_tes_result[0];
}
)";
} // namespace
// NOTE ON spirv-val, because its absence here is deliberate and every sibling pass test
// asserts the opposite. Vulkan SPIR-V REQUIRES a Location decoration on every user-defined
// Input/Output variable ([VUID-StandaloneSpirv-Location-04915]), so a module whose interface
// blocks have had theirs removed is INVALID Vulkan SPIR-V by construction - that is what the
// pass was asked to produce. It never reaches a driver as SPIR-V: DirectGLES runs this last
// in its chain and hands the result straight to SPIRV-Cross, which needs no location to print
// a block. What the cases below assert instead is the thing that actually matters - that
// SPIRV-Cross still emits a complete, matchable interface from it.
class StripIoBlockLocationsTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
};
TEST_F(StripIoBlockLocationsTest, DropsTheQualifierFromBothBlocksAndLeavesVaryingsAlone) {
const Vector<Uint32> input = CompileToSpirv(GL_TESS_EVALUATION_SHADER, kTessEvalSource);
ASSERT_FALSE(input.empty());
// The defect this exists for, pinned before the repair: SPIRV-Cross really does print a
// location on the blocks, and on this driver that is what loses their payload.
const String before = Transpile(input);
EXPECT_NE(before.find(") in TCSOutputBlock"), String::npos) << before;
EXPECT_NE(before.find(") out TESOutputBlock"), String::npos) << before;
bool strippedAny = false;
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::StripIoBlockLocationsForEssl(input, true, true, strippedAny, output, true));
ASSERT_FALSE(output.empty());
EXPECT_TRUE(strippedAny);
const String after = Transpile(output);
// The blocks come out bare...
EXPECT_NE(after.find("in TCSOutputBlock"), String::npos) << after;
EXPECT_NE(after.find("out TESOutputBlock"), String::npos) << after;
EXPECT_EQ(after.find(") in TCSOutputBlock"), String::npos)
<< "the consumed block still carries a layout qualifier:\n"
<< after;
EXPECT_EQ(after.find(") out TESOutputBlock"), String::npos)
<< "the produced block still carries a layout qualifier:\n"
<< after;
// ...and everything ES matches them by is untouched, which is what makes the unlocated
// interface still find its other end.
EXPECT_NE(after.find("input_block"), String::npos) << after;
EXPECT_NE(after.find("output_block"), String::npos) << after;
EXPECT_NE(after.find("tcs_tes_variable"), String::npos) << after;
EXPECT_NE(after.find("tes_gs_variable"), String::npos) << after;
// The PLAIN varyings keep their locations. They work on the affected driver, and a
// fragment stage's inputs and a vertex stage's attributes are matched by them.
EXPECT_NE(after.find("in vec4 tcs_tes_result"), String::npos) << after;
EXPECT_NE(after.find("out vec4 tes_gs_result"), String::npos) << after;
EXPECT_EQ(CountOf(after, "layout(location"), 2u)
<< "exactly the two plain varyings should still be located:\n"
<< after;
}
TEST_F(StripIoBlockLocationsTest, StripsOnlyTheDirectionTheCallerArmed) {
const Vector<Uint32> input = CompileToSpirv(GL_TESS_EVALUATION_SHADER, kTessEvalSource);
ASSERT_FALSE(input.empty());
// A separate-shader-objects program that ENDS at this stage: the block it produces is
// matched, in another program that never saw this decision, by the location alone. Only
// the consumed side may lose its qualifier.
bool strippedAny = false;
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::StripIoBlockLocationsForEssl(input, true, false, strippedAny, output, true));
ASSERT_FALSE(output.empty());
EXPECT_TRUE(strippedAny);
const String after = Transpile(output);
EXPECT_EQ(after.find(") in TCSOutputBlock"), String::npos) << after;
EXPECT_NE(after.find(") out TESOutputBlock"), String::npos)
<< "the produced block's location was dropped even though its consumer is elsewhere:\n"
<< after;
// And the mirror image, for a program that BEGINS at this stage.
bool strippedOutputOnly = false;
Vector<Uint32> outputOnly;
ASSERT_TRUE(
ShaderCompiler::StripIoBlockLocationsForEssl(input, false, true, strippedOutputOnly, outputOnly, true));
ASSERT_FALSE(outputOnly.empty());
EXPECT_TRUE(strippedOutputOnly);
const String afterOutputOnly = Transpile(outputOnly);
EXPECT_NE(afterOutputOnly.find(") in TCSOutputBlock"), String::npos) << afterOutputOnly;
EXPECT_EQ(afterOutputOnly.find(") out TESOutputBlock"), String::npos) << afterOutputOnly;
}
// The regression guard for the shape a variable-only strip walks straight past.
TEST_F(StripIoBlockLocationsTest, DropsLocationsTheApplicationPutOnTheBlockMembers) {
const Vector<Uint32> input = CompileToSpirv(GL_TESS_EVALUATION_SHADER, kMemberLocatedTessEvalSource);
ASSERT_FALSE(input.empty());
// The defect, pinned first: SPIRV-Cross prints the member locations, and there is no
// block-level qualifier for a variable-level strip to find.
const String before = Transpile(input);
EXPECT_NE(before.find("layout(location = 4)"), String::npos) << before;
EXPECT_NE(before.find("layout(location = 6)"), String::npos) << before;
bool strippedAny = false;
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::StripIoBlockLocationsForEssl(input, true, true, strippedAny, output, true));
ASSERT_FALSE(output.empty());
EXPECT_TRUE(strippedAny) << "the member-located block was passed by, and reporting no change "
"makes the caller decline the module and say nothing about it";
const String after = Transpile(output);
EXPECT_EQ(CountOf(after, "layout(location"), 0u)
<< "a member location survived, so the emitted block is still the shape the driver "
"drops the payload for:\n"
<< after;
// The interface still has to be matchable: same blocks, same members, same order.
EXPECT_NE(after.find("TCSOutputBlock"), String::npos) << after;
EXPECT_NE(after.find("TESOutputBlock"), String::npos) << after;
EXPECT_LT(after.find("tcs_tes_variable"), after.find("tcs_tes_second")) << after;
EXPECT_LT(after.find("tes_gs_variable"), after.find("tes_gs_second")) << after;
}
// ...and the same shape with only ONE direction armed. The member decorations belong to the
// TYPE, so the unarmed block's must survive - it is matched, in another program, by exactly
// those numbers.
TEST_F(StripIoBlockLocationsTest, KeepsMemberLocationsOnTheDirectionTheCallerDidNotArm) {
const Vector<Uint32> input = CompileToSpirv(GL_TESS_EVALUATION_SHADER, kMemberLocatedTessEvalSource);
ASSERT_FALSE(input.empty());
bool strippedAny = false;
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::StripIoBlockLocationsForEssl(input, true, false, strippedAny, output, true));
ASSERT_FALSE(output.empty());
EXPECT_TRUE(strippedAny);
const String after = Transpile(output);
EXPECT_EQ(after.find("layout(location = 4)"), String::npos) << after;
EXPECT_EQ(after.find("layout(location = 5)"), String::npos) << after;
EXPECT_NE(after.find("layout(location = 6)"), String::npos)
<< "the produced block lost its member locations even though its consumer is elsewhere:\n"
<< after;
EXPECT_NE(after.find("layout(location = 7)"), String::npos) << after;
}
TEST_F(StripIoBlockLocationsTest, ReportsNoChangeForAStageWithoutInterfaceBlocks) {
const Vector<Uint32> input = CompileToSpirv(GL_TESS_EVALUATION_SHADER, kNoBlockTessEvalSource);
ASSERT_FALSE(input.empty());
const String before = Transpile(input);
bool strippedAny = true; // deliberately wrong going in; the pass must clear it
Vector<Uint32> output;
ShaderCompiler::StripIoBlockLocationsForEssl(input, true, true, strippedAny, output, true);
EXPECT_FALSE(strippedAny) << "a stage with no interface block must report nothing stripped, or "
"the caller adopts a re-serialised module for nothing";
// gl_PerVertex is an Input AND an Output block in this stage and must not be touched; the
// located plain varyings must not be either. Either way the emitted ESSL is unchanged.
if (!output.empty()) {
EXPECT_EQ(Transpile(output), before);
}
}
TEST_F(StripIoBlockLocationsTest, DeclinesWhenNeitherDirectionIsArmed) {
const Vector<Uint32> input = CompileToSpirv(GL_TESS_EVALUATION_SHADER, kTessEvalSource);
ASSERT_FALSE(input.empty());
bool strippedAny = true;
Vector<Uint32> output;
EXPECT_FALSE(ShaderCompiler::StripIoBlockLocationsForEssl(input, false, false, strippedAny, output, true));
EXPECT_FALSE(strippedAny);
EXPECT_TRUE(output.empty()) << "an unarmed call must not even re-serialise the module";
}
@@ -499,6 +499,19 @@ TEST_F(TranslationCacheTest, L1KeyMovesWithEveryInputThatMovesTheSpirv) {
v.nativeFloat64 = true;
variants.emplace_back("nativeFloat64", BuildSpirvTranslationKey(v));
}
{ // CompileEnv::DemotesTessellationPointSize(): phase B rewrites the cached modules
// under it (the point-size demotion), so one key shape would describe two module
// sets - built-in kept vs carried as a varying with the capability stripped.
SpirvTranslationKeyInputs v = base;
v.demoteTessellationPointSize = true;
variants.emplace_back("demoteTessellationPointSize", BuildSpirvTranslationKey(v));
}
{ // ... and its geometry twin, keyed separately because the ES loader really does
// probe the two extension families independently.
SpirvTranslationKeyInputs v = base;
v.demoteGeometryPointSize = true;
variants.emplace_back("demoteGeometryPointSize", BuildSpirvTranslationKey(v));
}
// ---- inputs the WIDENED payload pulled into the key ----
// They cannot move a word of the generated SPIR-V, but they do shape the reflection the
// payload now carries, so they have to split the key. This is the group that would go
@@ -651,6 +664,48 @@ TEST_F(TranslationCacheTest, NativeFloat64IsOutOfTheFrontendFingerprintAndInside
<< "one L1 entry would then describe two different module sets";
}
// The second and third capability bits under the same placement rule as nativeFloat64:
// out of the front-end fingerprint (glslang produces the same thing either way), inside
// the L1 key (phase B's point-size demotion rewrites the cached modules under them). The
// accessor direction is pinned too, because it is INVERTED relative to the params field
// and a swap of the arms would disable the device repair with every rendering test green.
TEST_F(TranslationCacheTest, PointSizeDemotionBitsAreOutOfTheFrontendFingerprintAndInsideTheL1Key) {
CompileEnv none; // no backend at all: never demote, standalone compiles stay standard
CompileEnv hosting; // a backend that hosts the built-in
CompileEnv demoting; // a backend that cannot
hosting.backend = BackendType::DirectVulkan;
demoting.backend = BackendType::DirectVulkan;
demoting.params.SupportsTessellationPointSize = false;
demoting.params.SupportsGeometryPointSize = false;
EXPECT_FALSE(none.DemotesTessellationPointSize());
EXPECT_FALSE(none.DemotesGeometryPointSize());
EXPECT_FALSE(hosting.DemotesTessellationPointSize());
EXPECT_FALSE(hosting.DemotesGeometryPointSize());
EXPECT_TRUE(demoting.DemotesTessellationPointSize());
EXPECT_TRUE(demoting.DemotesGeometryPointSize());
EXPECT_EQ(ComputeFrontendCompileEnvFingerprint(hosting), ComputeFrontendCompileEnvFingerprint(demoting))
<< "the point-size capability leaked into the front-end fingerprint";
EXPECT_NE(ComputeCompileEnvFingerprint(hosting), ComputeCompileEnvFingerprint(demoting))
<< "the whole-environment fingerprint has to notice it - it is a DynamicBackendParameters "
"field, hashed by object representation";
const Vector<SpirvTranslationKeyInputs::Stage> stages{{GL_VERTEX_SHADER, kVertexSource},
{GL_FRAGMENT_SHADER, kFragmentSource}};
SpirvTranslationKeyInputs demotedKey = BaselineSpirvInputs(stages);
demotedKey.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(demoting);
demotedKey.demoteTessellationPointSize = demoting.DemotesTessellationPointSize();
demotedKey.demoteGeometryPointSize = demoting.DemotesGeometryPointSize();
SpirvTranslationKeyInputs keptKey = BaselineSpirvInputs(stages);
keptKey.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(hosting);
keptKey.demoteTessellationPointSize = hosting.DemotesTessellationPointSize();
keptKey.demoteGeometryPointSize = hosting.DemotesGeometryPointSize();
EXPECT_FALSE(BuildSpirvTranslationKey(demotedKey) == BuildSpirvTranslationKey(keptKey))
<< "one L1 entry would then describe two different module sets";
}
// The other direction, one case per input that was KEPT. Each is a limit the front end
// really consumes - everything BuildTBuiltInResource copies into TBuiltInResource, plus the
// two inputs to the reflection vertex-attrib limit - so each must still split the key.
@@ -1001,6 +1056,20 @@ TEST_F(TranslationCacheTest, L2KeyMovesWithEveryGateThatSteersTheEsslChain) {
v.outputBlockRenames = &otherIoBlockRenames;
variants.emplace_back("outputBlockRenames(other target)", BuildEsslTranslationKey(v));
}
{ // the two arguments to StripIoBlockLocationsForEssl, and separate cases for the same
// reason the rename maps are: a stage strips the blocks it CONSUMES only when the
// producer is in this program and the ones it PRODUCES only when the consumer is, so
// the two directions are independently armed and a key that folded them together
// would serve a fragment stage's ESSL to a vertex stage that needs the opposite.
EsslTranslationKeyInputs v = base;
v.stripInputBlockLocations = true;
variants.emplace_back("stripInputBlockLocations", BuildEsslTranslationKey(v));
}
{
EsslTranslationKeyInputs v = base;
v.stripOutputBlockLocations = true;
variants.emplace_back("stripOutputBlockLocations", BuildEsslTranslationKey(v));
}
{
EsslTranslationKeyInputs v = base;
v.enableSpirvValidation = true;
+115
View File
@@ -2052,6 +2052,81 @@ TEST_F(TextureTest, GetTextureSubImageRejectsPartialReadbackForNow) {
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_OPERATION);
}
// A cube map keeps each face as its own stored image, so a level's texel size reads z = 1 whichever
// face is asked - but GL 4.6 core 8.11.4 addresses the six faces through zoffset, which is the
// by-name spelling of the face token glGetTexImage takes. Both halves of that were missing: the z
// range was measured against the level's 1, so every face but +X came back INVALID_OPERATION as a
// partial read, and the destination-size check summed all six faces, so even face +X could not be
// read into the one face's worth of buffer a single-face read has any reason to pass.
TEST_F(TextureTest, GetTextureSubImageSelectsTheCubeFaceZOffsetNames) {
GLuint texture = 0;
MG_Impl::GLImpl::GenTextures(1, &texture);
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_CUBE_MAP, texture);
// Every face carries its own index in the red channel, so a read that answers the wrong face
// says which one it answered with.
for (int face = 0; face < 6; ++face) {
const Uint8 pixel[] = {static_cast<Uint8>(10 + face), 20, 30, 40};
MG_Impl::GLImpl::TexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA8, 1, 1, 0, GL_RGBA,
GL_UNSIGNED_BYTE, pixel);
}
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "seeding the six faces failed";
for (int face = 0; face < 6; ++face) {
Uint8 output[4] = {};
MG_Impl::GLImpl::GetTextureSubImage(texture, 0, 0, 0, face, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE,
sizeof(output), output);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "reading face " << face << " errored";
EXPECT_EQ(static_cast<int>(output[0]), 10 + face)
<< "zoffset " << face << " answered with face " << (static_cast<int>(output[0]) - 10);
}
// Past the last face. Still a partial read of a level with no sixth-and-beyond image.
Uint8 output[4] = {};
MG_Impl::GLImpl::GetTextureSubImage(texture, 0, 0, 0, 6, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, sizeof(output),
output);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_OPERATION);
}
// glGetTexImage of ONE cube face packs one face, so a PIXEL_PACK_BUFFER holding one face is
// exactly the right size for it. The validator used to measure the bound PBO against all SIX
// faces' worth and refuse - INVALID_OPERATION for a buffer the copy that follows would have filled
// precisely. glGetTexImage passes no bufSize, which skips the destination-size branch but NOT the
// PBO one, so this is the only spelling where the six-face sizing was reachable at all.
TEST_F(TextureTest, GetTexImageOfOneCubeFacePacksIntoAOneFacePixelPackBuffer) {
constexpr GLsizei kEdge = 2;
constexpr SizeT kFaceBytes = static_cast<SizeT>(kEdge) * kEdge * 4;
GLuint texture = 0;
MG_Impl::GLImpl::GenTextures(1, &texture);
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_CUBE_MAP, texture);
MG_Impl::GLImpl::TexStorage2D(GL_TEXTURE_CUBE_MAP, 1, GL_RGBA8, kEdge, kEdge);
for (int face = 0; face < 6; ++face) {
Uint8 seed[kFaceBytes];
for (SizeT i = 0; i < kFaceBytes; ++i) seed[i] = static_cast<Uint8>(10 + face);
MG_Impl::GLImpl::TexSubImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, 0, 0, kEdge, kEdge, GL_RGBA,
GL_UNSIGNED_BYTE, seed);
}
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "seeding the six faces failed";
GLuint buffer = 0;
MG_Impl::GLImpl::GenBuffers(1, &buffer);
MG_Impl::GLImpl::BindBuffer(GL_PIXEL_PACK_BUFFER, buffer);
MG_Impl::GLImpl::BufferData(GL_PIXEL_PACK_BUFFER, static_cast<GLsizeiptr>(kFaceBytes), nullptr, GL_STREAM_READ);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "creating the one-face pixel pack buffer failed";
MG_Impl::GLImpl::GetTexImage(GL_TEXTURE_CUBE_MAP_NEGATIVE_Z, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR)
<< "a pixel pack buffer sized for the one face this call packs was refused";
Uint8 packed[kFaceBytes] = {};
MG_Impl::GLImpl::GetBufferSubData(GL_PIXEL_PACK_BUFFER, 0, static_cast<GLsizeiptr>(kFaceBytes), packed);
EXPECT_EQ(static_cast<int>(packed[0]), 15) << "the PBO holds face " << (static_cast<int>(packed[0]) - 10)
<< ", not -Z";
MG_Impl::GLImpl::BindBuffer(GL_PIXEL_PACK_BUFFER, 0);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
TEST_F(TextureTest, TextureParameteriAndBindTextureUnitAreDirectStateAccess) {
GLuint texture = 0;
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &texture);
@@ -3313,6 +3388,46 @@ TEST_F(TextureTest, NormalizePixelFormatKeepsPackedTransferTypesForPackedSizedFo
}
}
// The packed16 field-order quirk (PixelFormatNormalizeOptionBit::WidenPacked16Norm): where the
// driver stores some packed16 allocations with a mirrored field order (the Mali defect
// behind the KHR-GL4x.copy_image rgb5/rgb5_a1/rgba4 x *2d_array* failures), the
// three ES narrow formats move to 8-bit-per-channel storage. The transfer pair must NOT move
// with the bit - it is already the UNorm8 component layout the canonical shadow holds - and
// no other format may move with it either.
TEST_F(TextureTest, NormalizePixelFormatWidensThePacked16FormatsUnderTheQuirkBit) {
using MG_Util::TextureFormatProcessor::NormalizePixelFormat;
struct {
GLenum requested;
GLenum expectedNarrow;
GLenum expectedWidened;
GLenum expectedFormat;
} cases[] = {
{GL_RGB565, GL_RGB565, GL_RGB8, GL_RGB},
{GL_RGB5_A1, GL_RGB5_A1, GL_RGBA8, GL_RGBA},
{GL_RGBA4, GL_RGBA4, GL_RGBA8, GL_RGBA},
// Negative controls: a 32-bit packed format and an already-8-bit one stay put with
// the bit set - the quirk is about 16-bit packed normalized storage and nothing else.
{GL_RGB10_A2, GL_RGB10_A2, GL_RGB10_A2, GL_RGBA},
{GL_RGBA8, GL_RGBA8, GL_RGBA8, GL_RGBA},
};
for (const auto& c : cases) {
GLenum narrowInternal = 0, narrowFormat = 0, narrowType = 0;
NormalizePixelFormat(c.requested, PixelFormatNormalizeOptionBit::None, &narrowInternal, &narrowFormat,
&narrowType);
EXPECT_EQ(narrowInternal, c.expectedNarrow) << "internalformat 0x" << std::hex << c.requested;
GLenum widenedInternal = 0, widenedFormat = 0, widenedType = 0;
NormalizePixelFormat(c.requested, PixelFormatNormalizeOptionBit::WidenPacked16Norm, &widenedInternal,
&widenedFormat, &widenedType);
EXPECT_EQ(widenedInternal, c.expectedWidened) << "internalformat 0x" << std::hex << c.requested;
// The transfer pair is identical narrow and widened: the widening changes only the ES
// storage, never how client data is described to it.
EXPECT_EQ(widenedFormat, narrowFormat) << "internalformat 0x" << std::hex << c.requested;
EXPECT_EQ(widenedType, narrowType) << "internalformat 0x" << std::hex << c.requested;
EXPECT_EQ(widenedFormat, c.expectedFormat) << "internalformat 0x" << std::hex << c.requested;
}
}
// GL_RGB565 (ARB_ES2_compatibility / GL 4.1, used directly by the GL CTS) must round-trip
// through the internal-format enums; it had no GLToMG mapping at all, so glTexImage* with
// GL_RGB565 was rejected as an unknown internal format.
@@ -505,4 +505,47 @@ namespace {
ExpectSingleGlError(GL_INVALID_OPERATION);
EXPECT_FALSE(MG_State::pGLContext->ValidateTextureObject(view));
}
// ======================= which of the owner's layers a face names =======================
// A GL_TEXTURE_CUBE_MAP view over a LAYERED owner - a 2D array here, a cube-map ARRAY behaves
// identically - is the one shape where the face a target names cannot be carried by the choice
// of blob: the owner keeps every layer in ONE blob, so there is nothing for
// ToOwnerUploadTarget to choose between and the face has to land in the byte offset instead.
// It did not. The offset shifted by the view's layer origin alone, so all six face tokens read
// the view's FIRST layer-face - silently, with real texels from a real layer, on every path
// that answers out of the CPU shadow.
//
// The shadow is exactly what this exercises: the fixture's backend is not DirectVulkan, so the
// by-name readback takes the shadow arm rather than asking a backend. (DirectVulkan's own path
// resolves the face into a Vulkan baseArrayLayer and was always right, which is what made this
// a disagreement between the two backends rather than a uniform wrong answer.)
TEST_F(TextureViewTest, CubeMapViewOfAnArrayReadsTheFaceEachTokenNames) {
constexpr GLint kLayers = 8;
constexpr GLint kViewMinLayer = 2;
const GLuint storage = MakeImmutable2DArray(1, 1, kLayers);
// Every layer carries its own index, so a read that lands on the wrong one says which one
// answered instead of merely failing.
for (GLint layer = 0; layer < kLayers; ++layer) {
const Uint8 texel[] = {static_cast<Uint8>(10 + layer), 20, 30, 40};
MG_Impl::GLImpl::TexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, layer, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE,
texel);
}
DrainPendingGlErrors();
const GLuint view = GenTexture();
MG_Impl::GLImpl::TextureView(view, GL_TEXTURE_CUBE_MAP, storage, GL_RGBA8, 0, 1, kViewMinLayer, 6);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "the cube-map view over the array was refused";
for (GLint face = 0; face < 6; ++face) {
Uint8 output[4] = {};
MG_Impl::GLImpl::GetTextureSubImage(view, 0, 0, 0, face, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE,
sizeof(output), output);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "reading face " << face << " errored";
EXPECT_EQ(static_cast<GLint>(output[0]), 10 + kViewMinLayer + face)
<< "face " << face << " of a view based at layer " << kViewMinLayer << " answered with layer "
<< (static_cast<GLint>(output[0]) - 10);
}
}
} // namespace
+1 -1
View File
@@ -441,7 +441,7 @@ TEST(JobNodeException, AnExceptionEscapingABodyCancelsTheJobInsteadOfTerminating
EXPECT_TRUE(job->IsCancelled());
EXPECT_FALSE(job->IsComplete());
ASSERT_EQ(job->diagnostics.logLines.size(), 1u);
EXPECT_NE(job->diagnostics.logLines[0].find("boom"), String::npos);
EXPECT_NE(job->diagnostics.logLines[0].text.find("boom"), String::npos);
}
TEST(JobNodeException, ANonStandardExceptionIsContainedToo) {
+24 -5
View File
@@ -95,11 +95,13 @@ namespace MobileGL::MG_Util::Async {
// which means std::terminate for the whole process. Every job boundary contains
// it and reports the job as Cancelled; the joining GL thread then sees a node
// that produced no result, which is the same shape as an abandoned node.
diagnostics.logLines.push_back(std::format("Job body threw: {}", e.what()));
diagnostics.logLines.push_back(
{MOBILEGL_LOG_LEVEL_DEBUG, std::format("Job body threw: {}", e.what())});
TryTransition(JobState::Running, JobState::Cancelled);
return;
} catch (...) {
diagnostics.logLines.emplace_back("Job body threw a non-std exception");
diagnostics.logLines.push_back(
{MOBILEGL_LOG_LEVEL_DEBUG, String("Job body threw a non-std exception")});
TryTransition(JobState::Running, JobState::Cancelled);
return;
}
@@ -162,10 +164,27 @@ namespace MobileGL::MG_Util::Async {
"being written");
if (!node.diagnostics.logLines.empty()) {
Vector<String> lines;
Vector<DeferredLogLine> lines;
lines.swap(node.diagnostics.logLines);
for (const String& line : lines) {
MGLOG_D("%s", line.c_str());
for (const DeferredLogLine& line : lines) {
// Per-line severity, because a shipped build compiles MGLOG_D away entirely
// and a verdict that only this channel records would vanish with it. The
// levels are the compile-time constants, so a suppressed one costs nothing
// beyond the string the worker already built.
switch (line.level) {
case MOBILEGL_LOG_LEVEL_INFO:
MGLOG_I("%s", line.text.c_str());
break;
case MOBILEGL_LOG_LEVEL_WARN:
MGLOG_W("%s", line.text.c_str());
break;
case MOBILEGL_LOG_LEVEL_ERROR:
MGLOG_E("%s", line.text.c_str());
break;
default:
MGLOG_D("%s", line.text.c_str());
break;
}
}
}
+19 -2
View File
@@ -9,6 +9,7 @@
#pragma once
#include <Includes.h>
#include <MG_Util/Types.h>
#include <MG_Util/Debug/Log.h>
#include <MG_State/GLState/ErrorState/ErrorCode.h>
#include <MG_State/GLState/ErrorState/ErrorInfo.h>
@@ -35,9 +36,25 @@ namespace MobileGL::MG_Util::Async {
UniquePtr<ErrorInfo> info;
};
// One line of worker-side MGLOG text, with the severity the join replays it at.
//
// DEBUG is the default and stays the default: nearly every deferred line is per-program
// trace that a shipped build compiles out, which is the whole reason this channel could
// be a plain string vector for as long as it was. A line a SHIPPED build has to show -
// the reason a repair refused, which no other surface records - has to name its level
// here, or it is formatted on the worker and then thrown away at replay under the INFO
// level every device and CI build pins. Callers that sit on a repeated path latch at
// the SOURCE (a per-call-site atomic, exactly what MGLOG_*_ONCE does): the replay below
// is one shared site for every job in the tree, so a latch there would silence
// unrelated lines.
struct DeferredLogLine {
Int level = MOBILEGL_LOG_LEVEL_DEBUG;
String text;
};
struct JobDiagnostics {
Vector<DeferredError> errors; // replayed, in ascending `sequence`, by the join
Vector<String> logLines; // worker-side MGLOG text, flushed in order by the join
Vector<DeferredError> errors; // replayed, in ascending `sequence`, by the join
Vector<DeferredLogLine> logLines; // worker-side MGLOG text, flushed in order by the join
};
// The scheduling primitive every asynchronous compile and link is built on. A node owns
@@ -22,7 +22,7 @@
namespace MobileGL::MG_Util::BackendLoader {
static Bool UseAngle() {
return MG_Config::Features.UseAngle;
return MG_Config::Features.EsprytUseAngle;
}
#if defined(MOBILEGL_TRACE_ANGLE_VARIANTS) && defined(__ANDROID__)
@@ -1724,13 +1724,47 @@ namespace MobileGL::MG_Util::BackendLoader {
MGLOG_I(" Indirect draw gl_InstanceID includes baseInstance: %s",
caps.IndirectDrawInstanceIdIncludesBaseInstance ? "true" : "false");
// ForceOn means "emit the blocks unlocated", i.e. treat the driver as NOT supporting
// located blocks - which is why the override reads inverted here. Auto is the probe's
// own answer and is what every real run uses; the two forced settings exist so the
// emulation can be exercised on a healthy driver (the integration lane) and turned
// off again as a negative control.
switch (MG_Config::Features.EsprytUnlocatedIoBlocks) {
case MG_Config::QuirkOverride::ForceOn:
caps.SupportsLocatedInterStageIoBlocks = false;
MGLOG_I(" Located inter-stage interface blocks: forced OFF by "
"MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS; the driver was not probed");
break;
case MG_Config::QuirkOverride::ForceOff:
caps.SupportsLocatedInterStageIoBlocks = true;
MGLOG_I(" Located inter-stage interface blocks: forced ON by "
"MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS; the driver was not probed");
break;
case MG_Config::QuirkOverride::Auto:
default:
// SelfTest::ProbeLocatedIoBlocksLosePayload - the Mali-G1-Ultra ES driver
// delivers nothing through an interface block that carries an explicit
// layout(location=) once a tessellation or geometry stage is in the pipeline.
// Probed with its own controls rather than matched on a renderer string; see
// DriverBugProbes.h for the shape and for why the two controls decide what the
// finding is allowed to claim.
caps.SupportsLocatedInterStageIoBlocks =
!SelfTest::LocatedIoBlocksLosePayload(glesFuncs).detected;
break;
}
MGLOG_I(" Located inter-stage interface blocks transport their payload: %s",
caps.SupportsLocatedInterStageIoBlocks
? "true"
: "false (DirectGLES will emit tessellation/geometry programs' interface "
"blocks without a location qualifier)");
caps.IsAngleRenderer = caps.GLESRendererString.find("ANGLE") != String::npos;
caps.IsAngleLlvmpipeRenderer =
caps.IsAngleRenderer && caps.GLESRendererString.find("llvmpipe") != String::npos;
caps.AvoidSamplerMipmapMinFilter =
caps.IsAngleLlvmpipeRenderer && MG_Config::Features.AvoidSamplerMipmapMinFilter;
caps.IsAngleLlvmpipeRenderer && MG_Config::Features.EsprytAvoidSamplerMipmapMinFilter;
caps.AvoidExplicitLodBias =
caps.IsAngleLlvmpipeRenderer && MG_Config::Features.AvoidExplicitLodBias;
caps.IsAngleLlvmpipeRenderer && MG_Config::Features.EsprytAvoidExplicitLodBias;
MGLOG_I(" GL_EXT_disjoint_timer_query supported: %s",
caps.SupportsDisjointTimerQuery ? "true" : "false");
MGLOG_I(" GL_KHR_parallel_shader_compile supported: %s",
@@ -1235,10 +1235,10 @@ namespace MobileGL {
// GL_RENDERER contains both "ANGLE" and "llvmpipe".
Bool IsAngleLlvmpipeRenderer = false;
// IsAngleLlvmpipeRenderer combined with the
// MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER feature toggle:
// MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER feature toggle:
// sampler min filters should drop their mipmap component.
Bool AvoidSamplerMipmapMinFilter = false;
// IsAngleLlvmpipeRenderer combined with the MOBILEGL_AVOID_EXPLICIT_LOD_BIAS
// IsAngleLlvmpipeRenderer combined with the MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS
// feature toggle: LOD-bias emulation should not touch explicit-LOD lookups.
Bool AvoidExplicitLodBias = false;
// True when indirect draws leak the command's baseInstance word ("reserved,
@@ -1247,6 +1247,17 @@ namespace MobileGL {
// straight to vkCmdDraw*Indirect and compiles gl_InstanceID to SPIR-V
// InstanceIndex, which includes firstInstance.
Bool IndirectDrawInstanceIdIncludesBaseInstance = false;
// True when an inter-stage interface BLOCK carrying an explicit layout(location=)
// actually delivers its payload across a tessellation or geometry boundary. The
// Mali-G1-Ultra ES driver links such a program with an empty info log and then
// hands the consuming stage zeroes; DirectGLES answers by emitting those blocks
// with no location qualifier at all (StripIoBlockLocationsPass), which ES matches
// by block name and member sequence instead.
//
// Defaults TRUE and stays true when the probe cannot run, because that is the
// behaviour every driver had before the probe existed - a capability like this
// must never be assumed broken on a driver nobody measured.
Bool SupportsLocatedInterStageIoBlocks = true;
Int UniformBufferOffsetAlignment = 256;
// Its storage-buffer counterpart, queried separately because it is a separate limit:
// Adreno 830 answers 32 for GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT and 64 for
@@ -92,7 +92,7 @@ namespace MobileGL::MG_Util::BackendLoader {
}
Bool IsShaderSubgroupForcedDisabled() {
return MG_Config::Features.DisableSubgroup;
return MG_Config::Features.MagmaDisableSubgroup;
}
} // namespace
@@ -222,6 +222,12 @@ namespace MobileGL::MG_Util::BackendLoader {
vkGetPhysicalDeviceFeatures(physicalDevice, &supportedFeatures);
caps.SupportsWideLines = supportedFeatures.wideLines == VK_TRUE;
caps.SupportsShaderFloat64 = supportedFeatures.shaderFloat64 == VK_TRUE;
// One feature covers both stage families here, unlike the ES loader's two extension
// tiers; the renderer enables it on the device whenever advertised
// (VulkanRenderer::CreateLogicalDeviceAndQueues), so this probe and that enable can
// never disagree about the physical device.
caps.SupportsTessellationAndGeometryPointSize =
supportedFeatures.shaderTessellationAndGeometryPointSize == VK_TRUE;
caps.SupportsImageCubeArray = supportedFeatures.imageCubeArray == VK_TRUE;
{
// Probe the formats a colour render target actually uses. A driver that refuses the flag
@@ -272,7 +278,7 @@ namespace MobileGL::MG_Util::BackendLoader {
supportsShaderSubgroup ? "true" : "false", caps.SupportsShaderSubgroup ? "true" : "false",
subgroupProps.subgroupSize, subgroupProps.supportedStages, subgroupProps.supportedOperations);
if (supportsShaderSubgroup && forceDisableShaderSubgroup) {
MGLOG_W("Vulkan shader subgroup support forced off by MOBILEGL_DISABLE_SUBGROUP");
MGLOG_W("Vulkan shader subgroup support forced off by MOBILEGL_MAGMA_DISABLE_SUBGROUP");
}
return true;
@@ -350,6 +356,7 @@ namespace MobileGL::MG_Util::BackendLoader {
FillFragmentInterpolationLimits(caps, properties.limits);
caps.SupportsWideLines = false;
caps.SupportsShaderFloat64 = false;
caps.SupportsTessellationAndGeometryPointSize = false;
caps.SupportsImageCubeArray = false;
caps.Supports2DArrayCompatible3DImages = false;
// This helper only receives properties, not VkPhysicalDeviceFeatures. Leave optional
@@ -87,6 +87,13 @@ namespace MobileGL {
// needs it, which includes every 64-bit vertex attribute: the attribute itself arrives
// as 32-bit words, but the bitcast result and everything computed from it is Float64.
Bool SupportsShaderFloat64 = false;
// VkPhysicalDeviceFeatures::shaderTessellationAndGeometryPointSize. Any
// tessellation/geometry module declaring OpCapability TessellationPointSize /
// GeometryPointSize needs it (VUID-VkShaderModuleCreateInfo-pCode-08740's
// capability table); without it the shared phase-B chain demotes the built-in
// to an ordinary varying. One feature for both stage families, unlike the ES
// loader's two extension tiers.
Bool SupportsTessellationAndGeometryPointSize = false;
// VkPhysicalDeviceFeatures::imageCubeArray. Required before a
// VK_IMAGE_VIEW_TYPE_CUBE_ARRAY view may be created at all
// (VUID-VkImageViewCreateInfo-viewType-01004), which is every cube map array texture -
@@ -8,8 +8,10 @@
#include "DriverBugProbes.h"
#include <Config.h>
#include <MG_Util/Debug/Log.h>
#include <algorithm>
#include <cstring>
#include <optional>
#include <string>
@@ -126,6 +128,21 @@ namespace MobileGL::MG_Util::SelfTest {
GLfloat clearColor[4] = {0.0f, 0.0f, 0.0f, 0.0f};
GLint packAlignment = 4;
GLint packRowLength = 0;
// The rest of the pixel-transfer scope. The probes that upload or read back texels
// run under whatever scope their caller left - the lazy ones run from live paths,
// not just the POST screen - and a caller's skip/row-length/PBO would silently
// shear a probe's own data. Saved so a probe can zero them and the caller gets
// them back.
GLint packSkipPixels = 0;
GLint packSkipRows = 0;
GLint unpackAlignment = 4;
GLint unpackRowLength = 0;
GLint unpackImageHeight = 0;
GLint unpackSkipPixels = 0;
GLint unpackSkipRows = 0;
GLint unpackSkipImages = 0;
GLint pixelPackBuffer = 0;
GLint pixelUnpackBuffer = 0;
GLint imageName = 0;
GLint imageLevel = 0;
GLint imageLayered = 0;
@@ -165,6 +182,16 @@ namespace MobileGL::MG_Util::SelfTest {
gl.glGetIntegerv(GL_TEXTURE_BINDING_2D_ARRAY, &state.texture2DArray);
gl.glGetIntegerv(GL_PACK_ALIGNMENT, &state.packAlignment);
gl.glGetIntegerv(GL_PACK_ROW_LENGTH, &state.packRowLength);
gl.glGetIntegerv(GL_PACK_SKIP_PIXELS, &state.packSkipPixels);
gl.glGetIntegerv(GL_PACK_SKIP_ROWS, &state.packSkipRows);
gl.glGetIntegerv(GL_UNPACK_ALIGNMENT, &state.unpackAlignment);
gl.glGetIntegerv(GL_UNPACK_ROW_LENGTH, &state.unpackRowLength);
gl.glGetIntegerv(GL_UNPACK_IMAGE_HEIGHT, &state.unpackImageHeight);
gl.glGetIntegerv(GL_UNPACK_SKIP_PIXELS, &state.unpackSkipPixels);
gl.glGetIntegerv(GL_UNPACK_SKIP_ROWS, &state.unpackSkipRows);
gl.glGetIntegerv(GL_UNPACK_SKIP_IMAGES, &state.unpackSkipImages);
gl.glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &state.pixelPackBuffer);
gl.glGetIntegerv(GL_PIXEL_UNPACK_BUFFER_BINDING, &state.pixelUnpackBuffer);
if (gl.glGetFloatv != nullptr) {
gl.glGetFloatv(GL_COLOR_CLEAR_VALUE, state.clearColor);
}
@@ -220,6 +247,18 @@ namespace MobileGL::MG_Util::SelfTest {
if (gl.glPixelStorei != nullptr) {
gl.glPixelStorei(GL_PACK_ALIGNMENT, state.packAlignment);
gl.glPixelStorei(GL_PACK_ROW_LENGTH, state.packRowLength);
gl.glPixelStorei(GL_PACK_SKIP_PIXELS, state.packSkipPixels);
gl.glPixelStorei(GL_PACK_SKIP_ROWS, state.packSkipRows);
gl.glPixelStorei(GL_UNPACK_ALIGNMENT, state.unpackAlignment);
gl.glPixelStorei(GL_UNPACK_ROW_LENGTH, state.unpackRowLength);
gl.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, state.unpackImageHeight);
gl.glPixelStorei(GL_UNPACK_SKIP_PIXELS, state.unpackSkipPixels);
gl.glPixelStorei(GL_UNPACK_SKIP_ROWS, state.unpackSkipRows);
gl.glPixelStorei(GL_UNPACK_SKIP_IMAGES, state.unpackSkipImages);
}
if (gl.glBindBuffer != nullptr) {
gl.glBindBuffer(GL_PIXEL_PACK_BUFFER, static_cast<GLuint>(state.pixelPackBuffer));
gl.glBindBuffer(GL_PIXEL_UNPACK_BUFFER, static_cast<GLuint>(state.pixelUnpackBuffer));
}
if (gl.glClearColor != nullptr) {
gl.glClearColor(state.clearColor[0], state.clearColor[1], state.clearColor[2],
@@ -1686,6 +1725,519 @@ namespace MobileGL::MG_Util::SelfTest {
return measurement;
}
namespace {
// ===================== LOCATED INTER-STAGE INTERFACE BLOCKS =====================
constexpr const char* kIoBlockProbeName = "located interface block";
// This probe's OWN requirements, not HasEveryEntryPoint's. That one is the geometry
// storage probe's list and asks for storage buffers and buffer mapping, which nothing
// here touches - borrowing it would let one unresolved SSBO pointer leave a driver that
// HAS this defect unrepaired, which is the opposite of what a gate is for. Covers what
// BuildProgram, Save/Restore, PrepareForProbeDraw and the draw below actually call.
Bool HasIoBlockProbeEntryPoints(const GLESFunctionsTable& gl) {
return gl.glCreateShader && gl.glShaderSource && gl.glCompileShader && gl.glGetShaderiv &&
gl.glGetShaderInfoLog && gl.glCreateProgram && gl.glAttachShader &&
gl.glLinkProgram && gl.glGetProgramiv && gl.glGetProgramInfoLog &&
gl.glDeleteShader && gl.glDeleteProgram && gl.glUseProgram && gl.glGenVertexArrays &&
gl.glBindVertexArray && gl.glDeleteVertexArrays && gl.glGenRenderbuffers &&
gl.glBindRenderbuffer && gl.glRenderbufferStorage && gl.glDeleteRenderbuffers &&
gl.glGenFramebuffers && gl.glBindFramebuffer && gl.glFramebufferRenderbuffer &&
gl.glCheckFramebufferStatus && gl.glDeleteFramebuffers && gl.glViewport &&
gl.glClearColor && gl.glClear && gl.glDrawArrays && gl.glReadPixels &&
gl.glPixelStorei && gl.glGetIntegerv && gl.glGetIntegeri_v && gl.glGetError &&
gl.glEnable && gl.glDisable && gl.glIsEnabled;
}
// Two values that survive an 8-bit target exactly, so the read is a comparison and not
// a tolerance: 0.25 -> 64, 0.5 -> 128. A stage that received nothing reads 0/0, which is
// nowhere near either.
constexpr GLubyte kIoBlockExpectedR = 0x40;
constexpr GLubyte kIoBlockExpectedG = 0x80;
// `@BL@` becomes the layout qualifier under test, or nothing at all for the control.
// Position comes from gl_VertexID, so no probe here needs a vertex buffer.
String BuildIoBlockVertexSource(const char* blockQualifier) {
return format("#version 320 es\n"
"precision highp float;\n"
"{}out MgProbeBlock {{ vec2 mg_probeValue; }} mg_probeOut;\n"
"void main() {{\n"
" vec2 mg_p = vec2((gl_VertexID == 1) ? 3.0 : -1.0,\n"
" (gl_VertexID == 2) ? 3.0 : -1.0);\n"
" gl_Position = vec4(mg_p, 0.0, 1.0);\n"
" mg_probeOut.mg_probeValue = vec2(0.25, 0.5);\n"
"}}\n",
blockQualifier);
}
// The block name changes across the geometry stage, because the two boundaries are two
// separate interfaces; one name would also be the in-and-out-under-one-name shape
// UniquifyIoBlockNamesPass exists for, and confusing one defect with the other is
// exactly what this file's control rule is against.
String BuildIoBlockGeometrySource(const char* blockQualifier) {
return format("#version 320 es\n"
"precision highp float;\n"
"layout(triangles) in;\n"
"layout(triangle_strip, max_vertices = 3) out;\n"
"{0}in MgProbeBlock {{ vec2 mg_probeValue; }} mg_probeIn[];\n"
"{0}out MgProbeBlock2 {{ vec2 mg_probeValue; }} mg_probeOut;\n"
"void main() {{\n"
" for (int i = 0; i < 3; ++i) {{\n"
" gl_Position = gl_in[i].gl_Position;\n"
" mg_probeOut.mg_probeValue = mg_probeIn[i].mg_probeValue;\n"
" EmitVertex();\n"
" }}\n"
"}}\n",
blockQualifier);
}
String BuildIoBlockFragmentSource(const char* blockQualifier, const char* blockName) {
return format("#version 320 es\n"
"precision highp float;\n"
"{}in {} {{ vec2 mg_probeValue; }} mg_probeIn;\n"
"layout(location = 0) out vec4 mg_probeColor;\n"
"void main() {{ mg_probeColor = vec4(mg_probeIn.mg_probeValue, 0.0, 1.0); }}\n",
blockQualifier, blockName);
}
// Builds and draws one of the four programs this probe compares and reports whether the
// fragment stage received the payload. `outRan` distinguishes "the payload did not
// arrive" from "this program could not be built or drawn at all" - the second is
// inconclusive and must never become a finding.
Bool IoBlockPayloadArrives(const GLESFunctionsTable& gl, const char* blockQualifier,
Bool withGeometryStage, Bool& outRan) {
outRan = false;
Vector<StageSource> stages;
stages.push_back({GL_VERTEX_SHADER, BuildIoBlockVertexSource(blockQualifier), "vertex"});
if (withGeometryStage) {
stages.push_back(
{GL_GEOMETRY_SHADER, BuildIoBlockGeometrySource(blockQualifier), "geometry"});
}
stages.push_back({GL_FRAGMENT_SHADER,
BuildIoBlockFragmentSource(blockQualifier,
withGeometryStage ? "MgProbeBlock2"
: "MgProbeBlock"),
"fragment"});
const ProgramBuild build = BuildProgram(gl, stages, kIoBlockProbeName);
if (!build.linked) {
if (build.program != 0) gl.glDeleteProgram(build.program);
return false;
}
GLuint renderbuffer = 0;
GLuint framebuffer = 0;
Bool arrives = false;
gl.glGenRenderbuffers(1, &renderbuffer);
gl.glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer);
gl.glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 1, 1);
gl.glGenFramebuffers(1, &framebuffer);
gl.glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
gl.glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER,
renderbuffer);
if (gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE) {
gl.glUseProgram(build.program);
gl.glViewport(0, 0, 1, 1);
gl.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
gl.glClear(GL_COLOR_BUFFER_BIT);
Drain(gl);
gl.glDrawArrays(GL_TRIANGLES, 0, 3);
if (gl.glGetError() == GL_NO_ERROR) {
GLubyte pixel[4] = {0, 0, 0, 0};
gl.glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel);
if (gl.glGetError() == GL_NO_ERROR) {
outRan = true;
// One bit of slack each way, for a driver that rounds the 8-bit
// conversion the other direction.
arrives = pixel[0] + 1 >= kIoBlockExpectedR && pixel[0] <= kIoBlockExpectedR + 1 &&
pixel[1] + 1 >= kIoBlockExpectedG && pixel[1] <= kIoBlockExpectedG + 1;
}
}
}
if (framebuffer != 0) gl.glDeleteFramebuffers(1, &framebuffer);
if (renderbuffer != 0) gl.glDeleteRenderbuffers(1, &renderbuffer);
gl.glDeleteProgram(build.program);
return arrives;
}
} // namespace
LocatedIoBlockMeasurement ProbeLocatedIoBlocksLosePayload(const GLESFunctionsTable& gl) {
LocatedIoBlockMeasurement measurement;
if (!HasIoBlockProbeEntryPoints(gl)) return measurement;
SavedState saved;
Save(gl, saved);
// The colour mask is not in SavedState - no other probe touches it - so this one saves
// and puts back its own. It has to be forced open: a masked channel would read back as
// zero and turn a healthy driver into a "payload lost" verdict.
GLboolean savedColorMask[4] = {GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE};
const Bool canMaskColor = gl.glColorMask != nullptr && gl.glGetBooleanv != nullptr;
if (canMaskColor) {
gl.glGetBooleanv(GL_COLOR_WRITEMASK, savedColorMask);
gl.glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
}
GLuint vao = 0;
gl.glGenVertexArrays(1, &vao);
gl.glBindVertexArray(vao);
PrepareForProbeDraw(gl);
// THE CONTROL, and it runs first: the identical three-stage program with no location on
// the blocks. If THAT cannot carry the payload, this driver's problem is not the
// qualifier and the probe has no finding to make - reporting one would justify dropping
// a qualifier that was never the cause.
Bool controlRan = false;
const Bool controlArrives = IoBlockPayloadArrives(gl, "", true, controlRan);
if (controlRan && controlArrives) {
Bool subjectRan = false;
const Bool subjectArrives =
IoBlockPayloadArrives(gl, "layout(location = 0) ", true, subjectRan);
if (subjectRan && !subjectArrives) {
measurement.detected = true;
// The second control, and the one that scopes the repair: the same located
// block between a vertex and a fragment stage. It arrives on the driver this
// was characterised on, which is why DirectGLES only drops the qualifier for
// programs that have a tessellation or geometry stage. A driver where this one
// ALSO fails is losing payloads the repair does not reach, and the report says
// so rather than implying the fix is complete.
Bool vsFsRan = false;
const Bool vsFsArrives =
IoBlockPayloadArrives(gl, "layout(location = 0) ", false, vsFsRan);
measurement.alsoAffectsVertexToFragment = vsFsRan && !vsFsArrives;
}
}
if (vao != 0) {
gl.glBindVertexArray(0);
gl.glDeleteVertexArrays(1, &vao);
}
if (canMaskColor) {
gl.glColorMask(savedColorMask[0], savedColorMask[1], savedColorMask[2], savedColorMask[3]);
}
Restore(gl, saved);
Drain(gl);
return measurement;
}
const LocatedIoBlockMeasurement& LocatedIoBlocksLosePayload(const GLESFunctionsTable& gl) {
// One driver per process, and the answer is structural rather than sampled.
static const LocatedIoBlockMeasurement measurement = ProbeLocatedIoBlocksLosePayload(gl);
return measurement;
}
namespace {
// ===================== PACKED16 COPY-IMAGE FIELD ORDER =====================
constexpr const char* kPacked16CopyProbeName = "packed16 copy-image field order";
// The shape the KHR-GL4x.copy_image failures pin: a 30x30x12 GL_RGB5_A1 2D array with
// the CTS's three-level chain (FUNCTIONAL_TEST_N_LEVELS = 3, makeTextureComplete(0, 2):
// 30/15/7 x12; the plain endpoints are 7/3/1), against plain-2D endpoints.
//
// WHAT THE DEVICE MEASUREMENTS ACTUALLY SHOWED (round 2): the mirrored field order is
// a property of the WHOLE ALLOCATION, not of a mip level - a 30x30x12 packed16 array
// is born in the mirrored layout at every level, while the small arrays the CTS's
// passing iterations used (7- and 15-texel bases; its src/dst dim loop is {7, 15}, so
// a base-30 array only ever appears at level 1) are born plain, which is why the
// failures looked per-mip-level from the QPA alone. AND the layout is not fixed for
// the allocation's lifetime: FBO-ATTACHING the array transitions it to the plain
// (renderable) layout, content preserved. That transition is what produced every
// seemingly contradictory measurement of this campaign - a probe that direct-read its
// array before copying relayouted its own subject and reported the device clean in
// the very process whose CTS copies kept mirroring, and the raw matrix's one
// "clean" 30x30x12 array was exactly the one that had been direct-read first. It is
// also why the CTS's own "source image was not modified" checks always passed: they
// read through an FBO attach, after the copy already went wrong. So: subject copies
// FIRST, every control that attaches the array AFTER, and because the driver's
// allocation heuristic beyond the size threshold is not fully mapped, the probe tries
// several allocation recipes of the same client-visible texture and a mirror from ANY
// level of ANY recipe is the finding.
constexpr GLsizei kPacked16BaseSize = 30;
constexpr GLsizei kPacked16Layers = 12;
constexpr GLsizei kPacked16DstSize = 7;
constexpr GLint kPacked16Levels = 3;
// One GL_RGB5_A1 texel, as the client word the probe uploads everywhere:
// (R, G, B, A) = (0, 1, 3, 1) under GL_UNSIGNED_SHORT_5_5_5_1. Chosen because 5551 is
// the one 16-bit packed layout whose field widths are not a palindrome - its mirror
// fixes the DIRECTION of the swap - and because this word's mirror differs in every
// channel including alpha, so no expansion rounding can confuse the two predictions.
constexpr Uint16 kPacked16Word = 0x0047;
// What an FBO readback answers for the word, as UNorm8: (0, 1, 3) / 31 and alpha 1.
constexpr GLubyte kPacked16Expected[4] = {0, 8, 25, 255};
// The same readback when the stored bits are the mirrored re-encoding: 0x0047 decoded
// as 5_5_5_1 and re-encoded as 1_5_5_5_REV is 0x8C20, which the destination's non-REV
// layout then decodes as (17, 16, 16) / 31 with alpha 0. This is byte-for-byte the
// arithmetic behind every failing CTS body (src 0x0047 -> got 0x8C20).
constexpr GLubyte kPacked16Mirrored[4] = {140, 132, 132, 0};
// A 5-bit step is 255/31 ~ 8.2 UNorm8 codes; half a step accepts every 5-bit-to-8-bit
// expansion a driver uses (floor, round, bit replication) while still telling two
// adjacent 5-bit values apart.
constexpr Int kPacked16Tolerance = 4;
// The allocation recipes the probe tries. Same client-visible texture, same data -
// only the order and the filter state during the uploads move, because those are the
// knobs the driver's layout heuristic was measured keying on (differently in
// different contexts).
enum class Packed16Recipe : Uint8 {
// glTexImage3D per level on a fresh texture at driver defaults, parameters after:
// the order a freshly minted MobileGL backend texture performs (the storage sync
// runs before the parameter re-push, see SyncTextureObjectToBackend).
UploadsFirst,
// NEAREST and MAX_LEVEL set before the uploads: the shape an application that
// configures its sampler state ahead of its data gets.
ParamsFirst,
// MAX_LEVEL bounded but MIN_FILTER left at its mipmapped default: the CTS
// copy-test texture verbatim - copy tests never touch filters, and the chain is
// complete because all three levels exist under MAX_LEVEL = 2.
CtsShape,
};
constexpr Packed16Recipe kPacked16Recipes[] = {Packed16Recipe::UploadsFirst,
Packed16Recipe::ParamsFirst,
Packed16Recipe::CtsShape};
const char* Packed16RecipeName(Packed16Recipe recipe) {
switch (recipe) {
case Packed16Recipe::UploadsFirst: return "uploads-first";
case Packed16Recipe::ParamsFirst: return "params-first";
case Packed16Recipe::CtsShape: return "cts-shape";
}
return "?";
}
// A three-level GL_RGB5_A1 2D array (30/15/7, twelve layers each, every texel holding
// kPacked16Word) allocated per `recipe`. Every recipe ends mipmap-complete - some
// drivers refuse glCopyImageSubData on an incomplete texture.
GLuint MakePacked16ArrayTexture(const GLESFunctionsTable& gl, Packed16Recipe recipe) {
GLuint texture = 0;
gl.glGenTextures(1, &texture);
if (texture == 0) return 0;
gl.glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
if (recipe == Packed16Recipe::ParamsFirst) {
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
}
if (recipe != Packed16Recipe::UploadsFirst) {
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, kPacked16Levels - 1);
}
for (GLint level = 0; level < kPacked16Levels; ++level) {
const GLsizei size = kPacked16BaseSize >> level;
const Vector<Uint16> words(
static_cast<SizeT>(size) * static_cast<SizeT>(size) * kPacked16Layers, kPacked16Word);
gl.glTexImage3D(GL_TEXTURE_2D_ARRAY, level, GL_RGB5_A1, size, size, kPacked16Layers, 0,
GL_RGBA, GL_UNSIGNED_SHORT_5_5_5_1, words.data());
}
if (recipe == Packed16Recipe::UploadsFirst) {
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, kPacked16Levels - 1);
}
gl.glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
return texture;
}
// The plain-2D destination, three levels (7/3/1) like the CTS's, every level filled
// with 0xFFFF - the CTS's own (1,1,1,1) destination fill - so a copy that silently
// did nothing reads as "no verdict" rather than as either prediction.
// A plain-2D endpoint with the CTS's three-level 7/3/1 chain, every texel of every
// level holding `fill`: 0xFFFF (the CTS's own (1,1,1,1) destination fill, so a copy
// that silently did nothing reads as "no verdict" rather than as either prediction),
// or kPacked16Word for the machinery control's source. Uploads first, parameters
// after, for the same in-situ fidelity as the array above - this is the allocation
// discipline every MobileGL-minted texture gets, and the shape the failing bodies'
// clean plain endpoints had.
GLuint MakePacked16FlatTexture(const GLESFunctionsTable& gl, Uint16 fill) {
GLuint texture = 0;
gl.glGenTextures(1, &texture);
if (texture == 0) return 0;
gl.glBindTexture(GL_TEXTURE_2D, texture);
for (GLint level = 0; level < kPacked16Levels; ++level) {
const GLsizei size = std::max<GLsizei>(kPacked16DstSize >> level, 1);
const Vector<Uint16> texels(static_cast<SizeT>(size) * size, fill);
gl.glTexImage2D(GL_TEXTURE_2D, level, GL_RGB5_A1, size, size, 0, GL_RGBA,
GL_UNSIGNED_SHORT_5_5_5_1, texels.data());
}
gl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
gl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
gl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, kPacked16Levels - 1);
gl.glBindTexture(GL_TEXTURE_2D, 0);
return texture;
}
// Texel (0, 0) of a 2D level 0, or of layer 0 of an array's `level`, through a
// framebuffer of its own. False when the attachment is incomplete or the read errors -
// both are declines, not verdicts.
Bool ReadPacked16Texel(const GLESFunctionsTable& gl, GLuint texture, Bool isArray, GLint level,
GLubyte out[4]) {
GLuint framebuffer = 0;
gl.glGenFramebuffers(1, &framebuffer);
if (framebuffer == 0) return false;
gl.glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
if (isArray) {
gl.glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, level, 0);
} else {
gl.glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, level);
}
Bool read = false;
if (gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE) {
gl.glReadBuffer(GL_COLOR_ATTACHMENT0);
Drain(gl);
gl.glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, out);
read = gl.glGetError() == GL_NO_ERROR;
}
gl.glBindFramebuffer(GL_FRAMEBUFFER, 0);
gl.glDeleteFramebuffers(1, &framebuffer);
Drain(gl);
return read;
}
// Copies a kPacked16DstSize-square region out of (source, sourceTarget, sourceLevel)
// layer 0 onto a freshly 0xFFFF-filled 2D destination and hands back the destination's
// texel (0, 0). False when the copy raised an error or the readback could not run.
Bool Packed16CopyLandsTexel(const GLESFunctionsTable& gl, GLuint source, GLenum sourceTarget,
GLint sourceLevel, GLubyte out[4]) {
const GLuint destination = MakePacked16FlatTexture(gl, Uint16{0xFFFF});
if (destination == 0) return false;
Drain(gl);
gl.glCopyImageSubData(source, sourceTarget, sourceLevel, 0, 0, 0, destination,
GL_TEXTURE_2D, 0, 0, 0, 0, kPacked16DstSize, kPacked16DstSize, 1);
const Bool copied = gl.glGetError() == GL_NO_ERROR;
const Bool read = copied && ReadPacked16Texel(gl, destination, false, 0, out);
gl.glDeleteTextures(1, &destination);
Drain(gl);
return read;
}
Bool Packed16TexelNear(const GLubyte got[4], const GLubyte want[4]) {
for (Int i = 0; i < 4; ++i) {
const Int delta = static_cast<Int>(got[i]) - static_cast<Int>(want[i]);
if (delta > kPacked16Tolerance || delta < -kPacked16Tolerance) return false;
}
return true;
}
// One recipe's whole measurement: allocate, both subject copies, THEN the round-trip
// control. The order is load-bearing: FBO-ATTACHING THE ARRAY TRANSITIONS IT to the
// plain (renderable) layout on the affected driver, so a round-trip read taken before
// the copies RELAYOUTS the subject and measures a texture the application's copy
// never sees - round two's first deployment did exactly that and reported the device
// clean while the CTS bodies kept failing in the same process. Copies first, the
// control after: the attach-driven transition preserves content, so the read still
// answers "the upload was intact" without disturbing what the copies measured. Only a
// mirror that matches the PREDICTION while that control holds counts; everything else
// is that recipe's no-verdict (logged as such).
Bool RunPacked16Recipe(const GLESFunctionsTable& gl, Packed16Recipe recipe) {
Bool mirrored = false;
const GLuint array = MakePacked16ArrayTexture(gl, recipe);
GLubyte direct[4] = {0, 0, 0, 0};
GLubyte level0[4] = {0, 0, 0, 0};
GLubyte level1[4] = {0, 0, 0, 0};
if (array == 0 || !Packed16CopyLandsTexel(gl, array, GL_TEXTURE_2D_ARRAY, 0, level0) ||
!Packed16CopyLandsTexel(gl, array, GL_TEXTURE_2D_ARRAY, 1, level1)) {
MGLOG_I("[driver-bug] %s probe [%s]: no verdict (a subject copy could not run)",
kPacked16CopyProbeName, Packed16RecipeName(recipe));
} else if (!ReadPacked16Texel(gl, array, true, 1, direct) ||
!Packed16TexelNear(direct, kPacked16Expected)) {
// The recipe's own round trip: reading the level directly decodes the driver's
// own storage and must deliver the word whatever layout it picked. A wrong
// answer means the UPLOAD is what corrupts - a different defect, and one the
// widening's raw-copy reasoning says nothing about.
MGLOG_I("[driver-bug] %s probe [%s]: no verdict (the array's own level-1 readback "
"answered (%d, %d, %d, %d) instead of the word - the upload, not the "
"copy, is what diverges)",
kPacked16CopyProbeName, Packed16RecipeName(recipe), direct[0], direct[1],
direct[2], direct[3]);
} else if (Packed16TexelNear(level0, kPacked16Mirrored) ||
Packed16TexelNear(level1, kPacked16Mirrored)) {
mirrored = true;
MGLOG_I("[driver-bug] %s probe [%s]: copies delivered level 0 (%d, %d, %d, %d) / "
"level 1 (%d, %d, %d, %d) - the 1_5_5_5_REV re-encoding of the word - "
"THIS ALLOCATION'S FIELD ORDER IS MIRRORED",
kPacked16CopyProbeName, Packed16RecipeName(recipe), level0[0], level0[1],
level0[2], level0[3], level1[0], level1[1], level1[2], level1[3]);
} else if (Packed16TexelNear(level0, kPacked16Expected) &&
Packed16TexelNear(level1, kPacked16Expected)) {
MGLOG_I("[driver-bug] %s probe [%s]: both levels copied the word intact",
kPacked16CopyProbeName, Packed16RecipeName(recipe));
} else {
MGLOG_I("[driver-bug] %s probe [%s]: no verdict (copies read back level 0 "
"(%d, %d, %d, %d) / level 1 (%d, %d, %d, %d), neither the word nor its "
"mirror)",
kPacked16CopyProbeName, Packed16RecipeName(recipe), level0[0], level0[1],
level0[2], level0[3], level1[0], level1[1], level1[2], level1[3]);
}
if (array != 0) gl.glDeleteTextures(1, &array);
return mirrored;
}
} // namespace
Bool ProbeCopyImageMirrorsPacked16FieldOrder(const GLESFunctionsTable& gl) {
if (!gl.glGenTextures || !gl.glBindTexture || !gl.glTexParameteri || !gl.glTexImage2D ||
!gl.glTexImage3D || !gl.glDeleteTextures || !gl.glCopyImageSubData || !gl.glGenFramebuffers ||
!gl.glBindFramebuffer || !gl.glFramebufferTexture2D || !gl.glFramebufferTextureLayer ||
!gl.glCheckFramebufferStatus || !gl.glDeleteFramebuffers || !gl.glReadBuffer ||
!gl.glReadPixels || !gl.glPixelStorei || !gl.glGetError) {
return false;
}
SavedState saved;
Save(gl, saved);
// The uploads and readbacks below run under the probe's own tight pixel-transfer
// scope - a caller's skip/row-length/PBO would shear the probe's data into a false
// verdict either way. Restore puts the caller's scope back with the rest.
gl.glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
gl.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
gl.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0);
gl.glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0);
gl.glPixelStorei(GL_UNPACK_SKIP_ROWS, 0);
gl.glPixelStorei(GL_UNPACK_SKIP_IMAGES, 0);
gl.glPixelStorei(GL_PACK_ALIGNMENT, 1);
gl.glPixelStorei(GL_PACK_ROW_LENGTH, 0);
gl.glPixelStorei(GL_PACK_SKIP_PIXELS, 0);
gl.glPixelStorei(GL_PACK_SKIP_ROWS, 0);
if (gl.glBindBuffer != nullptr) {
gl.glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
gl.glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
}
Drain(gl);
Bool detected = false;
const GLuint flatSource = MakePacked16FlatTexture(gl, kPacked16Word);
GLubyte machinery[4] = {0, 0, 0, 0};
// THE MACHINERY CONTROL: a copy between two 2D images of the same three-level shape
// and allocation discipline. Two identical allocations share the driver's layout
// whatever it is, so this must deliver the word on ANY driver that can run copy_image
// on these formats at all - a driver that cannot reaches no verdict instead of being
// reported as this.
if (flatSource == 0 || !Packed16CopyLandsTexel(gl, flatSource, GL_TEXTURE_2D, 0, machinery)) {
MGLOG_I("[driver-bug] %s probe reached no verdict (the 2D-to-2D machinery control "
"could not run)",
kPacked16CopyProbeName);
} else if (!Packed16TexelNear(machinery, kPacked16Expected)) {
MGLOG_I("[driver-bug] %s probe reached no verdict (the 2D-to-2D machinery control "
"read back (%d, %d, %d, %d) instead of the word's (%d, %d, %d, %d))",
kPacked16CopyProbeName, machinery[0], machinery[1], machinery[2], machinery[3],
kPacked16Expected[0], kPacked16Expected[1], kPacked16Expected[2], kPacked16Expected[3]);
} else {
// THE SUBJECTS: every allocation recipe of the same array, each with its own
// round-trip control; a mirror from any level of any recipe is the finding. Every
// recipe logs its own verdict either way, so a device run always shows whether
// this probe executed and what each allocation delivered - a silent clean path
// would be indistinguishable from a probe that never ran.
for (const Packed16Recipe recipe : kPacked16Recipes) {
detected = RunPacked16Recipe(gl, recipe) || detected;
}
}
if (flatSource != 0) gl.glDeleteTextures(1, &flatSource);
Restore(gl, saved);
return detected;
}
Bool CopyImageMirrorsPacked16FieldOrder(const GLESFunctionsTable& gl) {
// One driver per process, and the answer is structural (the driver's storage layout
// for a shape), not sampled.
static const Bool mirrored = ProbeCopyImageMirrorsPacked16FieldOrder(gl);
return mirrored;
}
namespace {
Optional<DriverBugFinding> ProbeExplicitVertexInputLocationCeilingBug(const GLESFunctionsTable& gl) {
const VertexInputLocationCeilingMeasurement& measurement = ExplicitVertexInputLocationCeiling(gl);
@@ -1808,6 +2360,91 @@ namespace MobileGL::MG_Util::SelfTest {
percentOf(measurement.emittedShapeMismatchedTexels))};
}
Optional<DriverBugFinding> ProbeLocatedIoBlockPayloadBug(const GLESFunctionsTable& gl) {
const LocatedIoBlockMeasurement& measurement = LocatedIoBlocksLosePayload(gl);
if (!measurement.detected) return std::nullopt;
String detail =
"an inter-stage interface block that carries an explicit layout(location = N) "
"delivers NOTHING once a geometry (or tessellation) stage is in the pipeline: the "
"stages compile, the program links with an empty info log, the draw raises no "
"error, and the consuming stage reads zeroes. The byte-identical program with the "
"qualifier removed from the blocks carries its payload correctly, which is what "
"makes this a LOCATION defect rather than an interface-block one - blocks "
"themselves work here";
detail += measurement.alsoAffectsVertexToFragment
? ". A located block between a VERTEX and a FRAGMENT stage is lost on "
"this driver too, so the defect is wider than the repair below "
"reaches: MobileGL only drops the qualifier for programs that have a "
"tessellation or geometry stage, and a located block in a plain "
"vertex+fragment program is still emitted as the application wrote it"
: ". A located block between a VERTEX and a FRAGMENT stage is delivered "
"correctly on the same driver, which is what scopes the repair";
// The repair can be switched off from the environment, and a report that said
// "Fixed" while the strip was disabled would be describing a build nobody is
// running. The verdict follows what this process will actually do, not what the
// code is capable of.
const Bool repairDisabled =
MG_Config::Features.EsprytUnlocatedIoBlocks == MG_Config::QuirkOverride::ForceOff;
if (repairDisabled) {
detail +=
". THE REPAIR IS DISABLED in this process: MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS "
"is set to force located blocks ON, so DirectGLES emits the location "
"qualifier the driver cannot honour and the payload is lost. Unset the "
"variable to get the repair back";
} else {
detail +=
". MobileGL emits a tessellation/geometry program's interface blocks with no "
"location qualifier at all (StripIoBlockLocationsPass) and lets ES match them "
"by block name and member sequence, which it does; the locations were invented "
"by the cross-stage IO resolver rather than written by the application";
}
return DriverBugFinding{"Located inter-stage interface blocks carry no payload",
(repairDisabled || measurement.alsoAffectsVertexToFragment)
? DriverBugVerdict::Unfixable
: DriverBugVerdict::Fixed,
Move(detail)};
}
Optional<DriverBugFinding> ProbeCopyImagePacked16FieldOrderBug(const GLESFunctionsTable& gl) {
if (!CopyImageMirrorsPacked16FieldOrder(gl)) return std::nullopt;
// The mitigation is a knob (MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE), so the row consults
// it: under ForceOff - the documented negative control - the corruption is
// replayed verbatim, and a hardcoded "Fixed" would be exactly the kind of
// reassurance this file exists to refuse. Auto and ForceOn both widen once this
// probe has fired. Should POST ever run before env parsing, the field still holds
// its Auto default - which is also what the widening itself consults, so the row
// and the behaviour cannot disagree.
const Bool widened = MG_Config::Features.EsprytWidenPacked16Storage !=
MG_Config::QuirkOverride::ForceOff;
String detail =
"the driver stores SOME 16-bit packed images (RGB565 / RGB5_A1 / RGBA4) with the "
"R/G/B/A fields packed from the other end of the word - which allocations get the "
"*_REV layout depends on shape and context history (measured here on a 30x30x12 "
"three-level 2D array, every level of it) - so a glCopyImageSubData, a raw "
"texel-block move, between a mirrored allocation and a plain one lands the fields "
"reversed (a 5551 word 0x0047 arrives as 0x8C20). Uploads and readbacks of the "
"same image are clean - the driver decodes its own layout consistently, which is "
"this probe's second control - so only the raw-copy path ever crosses the two "
"layouts. ";
if (widened) {
detail +=
"MobileGL stores these three formats as 8-bit-per-channel ES storage on this "
"driver instead (GL_RGB8 / GL_RGBA8, the storage their canonical shadow "
"already holds and the client word round-trips through exactly), so no "
"16-bit packed image is left for a copy to disagree about, at twice the "
"memory for images of those formats; override with "
"MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE";
return DriverBugFinding{
"glCopyImageSubData mirrors 16-bit packed texels between differently-laid-out images",
DriverBugVerdict::Fixed, detail};
}
detail += "MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE=0 keeps the native narrow storage, so such "
"copies are left exactly as the driver delivers them, mirrored words included";
return DriverBugFinding{
"glCopyImageSubData mirrors 16-bit packed texels between differently-laid-out images",
DriverBugVerdict::Unfixable, detail};
}
// The table. One row per known driver bug; see the header for how to add a sibling.
using DriverBugProbeFn = Optional<DriverBugFinding> (*)(const GLESFunctionsTable&);
constexpr DriverBugProbeFn kGlesDriverBugProbes[] = {
@@ -1818,6 +2455,8 @@ namespace MobileGL::MG_Util::SelfTest {
&ProbeImageCoherencyResidualBug,
&ProbeExplicitVertexInputLocationCeilingBug,
&ProbeLayeredBlitDestinationBug,
&ProbeLocatedIoBlockPayloadBug,
&ProbeCopyImagePacked16FieldOrderBug,
};
} // namespace
@@ -55,6 +55,44 @@ namespace MobileGL::MG_Util::SelfTest {
String detail;
};
// What the located-interface-block probe measured.
struct LocatedIoBlockMeasurement {
// The driver delivers nothing through an inter-stage interface block that carries an
// explicit layout(location=) once a geometry stage is in the pipeline. The only field
// any caller's behaviour depends on.
Bool detected = false;
// ...and it does the same WITHOUT a geometry stage, i.e. between a vertex and a
// fragment stage. False on the device this was characterised on, and reported because
// DirectGLES's repair is scoped to tessellation/geometry programs: a driver that
// answered true here would be losing block payloads the repair does not reach.
Bool alsoAffectsVertexToFragment = false;
};
// Draws one full-viewport triangle through VS+GS+FS whose two interface blocks carry an
// explicit layout(location = 0), and reports whether the payload the vertex stage wrote
// reached the fragment stage.
//
// The Mali-G1-Ultra ES driver (r54p1) delivers ZEROES: the stages compile, the program
// links with an empty info log, the draw runs without error, and the block is empty. It is
// the whole of the KHR-GLxx.shading_language_420pack interface-block group's failures on
// that device, and of a further 21 tessellation and geometry bodies beside it.
//
// TWO CONTROLS, and the first is why this is a LOCATION finding rather than a block one:
// (1) the identical three-stage program with the qualifier removed from both blocks must
// deliver its payload - without that, "this driver cannot carry an interface block through
// a geometry stage" would be the claim, which is false and would justify flattening every
// block on the device; and (2) a two-stage vertex-to-fragment program with a LOCATED block
// is measured separately, because that one works on the affected driver and is what scopes
// the repair to programs with a tessellation or geometry stage.
//
// Returns `detected` false when an entry point is missing, when the driver has no geometry
// stage, or when the unlocated control fails - an inconclusive probe must never be reported
// as a bug, and must never arm the repair. Restores every piece of GL state it touches.
LocatedIoBlockMeasurement ProbeLocatedIoBlocksLosePayload(const MG_External::GLESFunctionsTable& gl);
// ProbeLocatedIoBlocksLosePayload(), evaluated at most once per process.
const LocatedIoBlockMeasurement& LocatedIoBlocksLosePayload(const MG_External::GLESFunctionsTable& gl);
// Blits one layer of an RGBA8 2D array onto another array's layer 1 and reports whether the
// copy landed where it was asked to. Returns true only when the destination layer is ignored
// while the control lands correctly.
@@ -261,6 +299,46 @@ namespace MobileGL::MG_Util::SelfTest {
const ImageCoherencyResidualMeasurement& ImageWriteReadCoherencyResidual(
const MG_External::GLESFunctionsTable& gl);
// Copies one known GL_UNSIGNED_SHORT_5_5_5_1 word out of both mip levels of a GL_RGB5_A1
// 2D array into plain 2D images with glCopyImageSubData and reads the landed texels back
// - for SEVERAL ALLOCATION RECIPES of the same array. Returns true only when a copy from
// any level of any recipe delivers the word's 5_5_5_1 <-> 1_5_5_5_REV field-order mirror
// while the controls below hold.
//
// The affected Mali stores SOME 16-bit packed allocations (RGB565 / RGB5_A1 / RGBA4) with
// their fields packed from the other end of the word. The mirrored layout is an
// ALLOCATION property, not a mip-level one - the failing device's 30x30x12 array is born
// mirrored at level 0 and level 1 alike, which is what vetoed the first deployment's
// "level 0 is the clean control" design - and it is not fixed for the allocation's
// lifetime either: FBO-ATTACHING the array transitions it to the plain layout, content
// preserved, which is why a probe that direct-reads its array before copying relayouts
// its own subject and measures a texture the application's copies never see (the second
// deployment's miss), and why the CTS's "source not modified" checks always passed. The
// probe builds the CTS's failing shape (three-level chains both endpoints: 30/15/7 x12
// array, 7/3/1 plain, FUNCTIONAL_TEST_N_LEVELS = 3) with several allocation recipes,
// copies FIRST, in situ, and a mirror delivered from any level of any recipe is the
// finding. Uploads and readbacks decode each image's layout of the moment consistently,
// so nothing but a raw texel-block move can see the divergence - which is exactly what
// glCopyImageSubData is defined to be, and why the whole KHR-GL4x.copy_image
// rgb5/rgb5_a1/rgba4 x *2d_array* matrix fails there while every other suite touching
// these formats passes.
//
// CONTROLS. The machinery: an identical copy between two SAME-shape plain-2D images,
// which share a layout whatever it is, so it must deliver the word on any driver that can
// run copy_image on these formats - a driver that cannot reaches no verdict instead of
// being reported as this. And per recipe, AFTER its subject copies, the array's own round
// trip: a direct FBO readback of its level 1 must answer the word, or the UPLOAD is what
// corrupts - a different defect. The subjects must also match the mirror PREDICTION, not
// merely differ from the word - a copy that delivered anything else is a different defect
// and reaches no verdict either. Restores every piece of GL state it touches.
Bool ProbeCopyImageMirrorsPacked16FieldOrder(const MG_External::GLESFunctionsTable& gl);
// ProbeCopyImageMirrorsPacked16FieldOrder(), evaluated at most once per process. The
// DirectGLES format normalization consults this to decide whether the three 16-bit packed
// normalized formats must be stored as 8-bit-per-channel ES storage (see
// PixelFormatNormalizeOptionBit::WidenPacked16Norm).
Bool CopyImageMirrorsPacked16FieldOrder(const MG_External::GLESFunctionsTable& gl);
// Every known driver bug this GLES driver actually has. Bugs it does not have are absent,
// so an unaffected device renders an empty section rather than a wall of "not affected".
Vector<DriverBugFinding> CollectGlesKnownDriverBugs(const MG_External::GLESFunctionsTable& gl);
+377 -1
View File
@@ -9,6 +9,7 @@
#include "DriverPost.h"
#include "DriverPostIterationRPWitness.h"
#include "DriverPostIterationRPWitnessSpv.h"
#include "PrimitivesGeneratedNoXfbProbe.h"
#include "MG_Util/BackendLoaders/OpenGL/Loader.h"
#include <Config.h>
#include <MGGitHash.h>
@@ -1555,9 +1556,380 @@ namespace MobileGL::MG_Util::SelfTest {
disabledNote);
}
// GL_PRIMITIVES_GENERATED for draws made with transform feedback INACTIVE. GL
// defines the query to count them; the DirectVulkan backend serves it from the
// stream query's primitivesNeeded, and an affected Mali driver answers 0 there
// unless a capture span is open - the exact shape the CTS uses to measure the
// tessellator (see PrimitivesGeneratedNoXfbProbe.h). One row:
// PASS - the stream query counts the capture-less draw exactly.
// WARN - it answers 0, and the CLIPPING_INPUT_PRIMITIVES statistics control
// on the same draw answers exactly right, so the renderer substitutes
// a pipeline-statistics pool for such draws (the same probe, run at
// renderer bring-up, is what arms it).
// FAIL (optional) - it answers 0 with no working substitute, or the probe
// could not reach a verdict; applications sizing capture buffers from
// the query get 0.
// Throwaway device on purpose, like every probe here: the row reports the
// driver, not the renderer's configuration - MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE
// steers the renderer, never this row.
void ProbeVulkanPrimitivesGeneratedNoXfb(ReportBuilder& builder,
PFN_vkGetInstanceProcAddr getInstanceProcAddr,
VkInstance instance, VkPhysicalDevice physicalDevice,
Uint32 graphicsQueueFamilyIndex,
const Vector<VkExtensionProperties>& deviceExtensions,
const VkPhysicalDeviceFeatures& features,
PFN_vkGetPhysicalDeviceFeatures2 getFeatures2,
PFN_vkGetPhysicalDeviceProperties2 getProperties2) {
constexpr const char* RowName = "Primitives-generated query without capture";
const auto fail = [&](String detail) { builder.FailOptional(RowName, Move(detail)); };
if (!HasVkExtension(deviceExtensions, VK_EXT_TRANSFORM_FEEDBACK_EXTENSION_NAME) ||
getFeatures2 == nullptr || getProperties2 == nullptr) {
fail("VK_EXT_transform_feedback is unavailable, so the backend has no GPU counter for "
"GL_PRIMITIVES_GENERATED at all - with or without a capture");
return;
}
VkPhysicalDeviceTransformFeedbackFeaturesEXT xfbFeatures{};
xfbFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TRANSFORM_FEEDBACK_FEATURES_EXT;
VkPhysicalDeviceFeatures2 features2{};
features2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
features2.pNext = &xfbFeatures;
getFeatures2(physicalDevice, &features2);
VkPhysicalDeviceTransformFeedbackPropertiesEXT xfbProperties{};
xfbProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TRANSFORM_FEEDBACK_PROPERTIES_EXT;
VkPhysicalDeviceProperties2 properties2{};
properties2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2;
properties2.pNext = &xfbProperties;
getProperties2(physicalDevice, &properties2);
if (xfbFeatures.transformFeedback != VK_TRUE || xfbProperties.transformFeedbackQueries != VK_TRUE) {
fail("the device has VK_EXT_transform_feedback but no usable stream queries "
"(transformFeedbackQueries = false); GL_PRIMITIVES_GENERATED and "
"GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN queries answer 0");
return;
}
const auto vkCreateDeviceFn =
reinterpret_cast<PFN_vkCreateDevice>(getInstanceProcAddr(instance, "vkCreateDevice"));
const auto vkDestroyDeviceFn =
reinterpret_cast<PFN_vkDestroyDevice>(getInstanceProcAddr(instance, "vkDestroyDevice"));
const auto vkGetDeviceQueueFn =
reinterpret_cast<PFN_vkGetDeviceQueue>(getInstanceProcAddr(instance, "vkGetDeviceQueue"));
if (vkCreateDeviceFn == nullptr || vkDestroyDeviceFn == nullptr || vkGetDeviceQueueFn == nullptr) {
fail("vkGetInstanceProcAddr could not resolve the device-creation entry points");
return;
}
const Float queuePriority = 1.0f;
VkDeviceQueueCreateInfo queueInfo{};
queueInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
queueInfo.queueFamilyIndex = graphicsQueueFamilyIndex;
queueInfo.queueCount = 1;
queueInfo.pQueuePriorities = &queuePriority;
// Only what the probe itself needs: the transform feedback feature (a
// stream-query pool may not be created without it), the two candidate
// substitutes' features, and tessellationShader for the PATCHES shape -
// each only where the device has it. The dedicated
// primitives-generated query is taken with BOTH its bits or not at all,
// mirroring the renderer (without the discard bit two of the three
// shapes may not run inside it).
VkPhysicalDeviceFeatures enabledFeatures{};
enabledFeatures.pipelineStatisticsQuery = features.pipelineStatisticsQuery;
enabledFeatures.tessellationShader = features.tessellationShader;
VkPhysicalDeviceTransformFeedbackFeaturesEXT enabledXfbFeatures{};
enabledXfbFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TRANSFORM_FEEDBACK_FEATURES_EXT;
enabledXfbFeatures.transformFeedback = VK_TRUE;
const char* enabledExtensions[2] = {VK_EXT_TRANSFORM_FEEDBACK_EXTENSION_NAME, nullptr};
Uint32 enabledExtensionCount = 1;
Bool primitivesGeneratedQueryUsable = false;
VkPhysicalDevicePrimitivesGeneratedQueryFeaturesEXT enabledPgqFeatures{};
enabledPgqFeatures.sType =
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRIMITIVES_GENERATED_QUERY_FEATURES_EXT;
if (HasVkExtension(deviceExtensions, VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME)) {
VkPhysicalDevicePrimitivesGeneratedQueryFeaturesEXT pgqQuery{};
pgqQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRIMITIVES_GENERATED_QUERY_FEATURES_EXT;
VkPhysicalDeviceFeatures2 pgqFeatures2{};
pgqFeatures2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
pgqFeatures2.pNext = &pgqQuery;
getFeatures2(physicalDevice, &pgqFeatures2);
if (pgqQuery.primitivesGeneratedQuery == VK_TRUE &&
pgqQuery.primitivesGeneratedQueryWithRasterizerDiscard == VK_TRUE) {
primitivesGeneratedQueryUsable = true;
enabledPgqFeatures.primitivesGeneratedQuery = VK_TRUE;
enabledPgqFeatures.primitivesGeneratedQueryWithRasterizerDiscard = VK_TRUE;
enabledPgqFeatures.pNext = &enabledXfbFeatures;
enabledExtensions[enabledExtensionCount++] =
VK_EXT_PRIMITIVES_GENERATED_QUERY_EXTENSION_NAME;
}
}
VkDeviceCreateInfo deviceInfo{};
deviceInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
deviceInfo.pNext = primitivesGeneratedQueryUsable
? static_cast<const void*>(&enabledPgqFeatures)
: static_cast<const void*>(&enabledXfbFeatures);
deviceInfo.queueCreateInfoCount = 1;
deviceInfo.pQueueCreateInfos = &queueInfo;
deviceInfo.enabledExtensionCount = enabledExtensionCount;
deviceInfo.ppEnabledExtensionNames = enabledExtensions;
deviceInfo.pEnabledFeatures = &enabledFeatures;
VkDevice device = VK_NULL_HANDLE;
const VkResult createResult = vkCreateDeviceFn(physicalDevice, &deviceInfo, nullptr, &device);
if (createResult != VK_SUCCESS || device == VK_NULL_HANDLE) {
fail(format("vkCreateDevice failed (VkResult = {})", static_cast<Int>(createResult)));
return;
}
// The probe's own teardown destroys (and idle-waits) everything it created -
// EXCEPT when its bounded fence wait expires, where it deliberately leaks
// every child object rather than touch a possibly hung GPU. This device must
// then leak with them: vkDestroyDevice requires its children destroyed and its
// queues idle, and on the driver that just missed a 5 s deadline the realistic
// outcome is a block inside vkDestroyDevice - the POST hang the bound exists to
// prevent. Same shape as the timestamp probe's guard above and the iterationRP
// witness's below.
Bool probeFenceWaitTimedOut = false;
const ScopeGuard destroyDevice([&]() {
if (probeFenceWaitTimedOut) {
return;
}
vkDestroyDeviceFn(device, nullptr);
});
VkQueue queue = VK_NULL_HANDLE;
vkGetDeviceQueueFn(device, graphicsQueueFamilyIndex, 0, &queue);
if (queue == VK_NULL_HANDLE) {
fail("vkGetDeviceQueue returned a null graphics queue");
return;
}
PrimitivesGeneratedNoXfbProbeContext probeContext;
probeContext.device = device;
probeContext.queue = queue;
probeContext.queueFamilyIndex = graphicsQueueFamilyIndex;
probeContext.transformFeedbackQueriesUsable = true;
probeContext.primitivesGeneratedQueryUsable = primitivesGeneratedQueryUsable;
probeContext.pipelineStatisticsEnabled = enabledFeatures.pipelineStatisticsQuery == VK_TRUE;
probeContext.tessellationEnabled = enabledFeatures.tessellationShader == VK_TRUE;
auto& fns = probeContext.fns;
const auto resolve = [&](const char* name) { return getInstanceProcAddr(instance, name); };
fns.vkCreateCommandPool = reinterpret_cast<PFN_vkCreateCommandPool>(resolve("vkCreateCommandPool"));
fns.vkDestroyCommandPool =
reinterpret_cast<PFN_vkDestroyCommandPool>(resolve("vkDestroyCommandPool"));
fns.vkAllocateCommandBuffers =
reinterpret_cast<PFN_vkAllocateCommandBuffers>(resolve("vkAllocateCommandBuffers"));
fns.vkBeginCommandBuffer =
reinterpret_cast<PFN_vkBeginCommandBuffer>(resolve("vkBeginCommandBuffer"));
fns.vkEndCommandBuffer = reinterpret_cast<PFN_vkEndCommandBuffer>(resolve("vkEndCommandBuffer"));
fns.vkCreateQueryPool = reinterpret_cast<PFN_vkCreateQueryPool>(resolve("vkCreateQueryPool"));
fns.vkDestroyQueryPool = reinterpret_cast<PFN_vkDestroyQueryPool>(resolve("vkDestroyQueryPool"));
fns.vkCmdResetQueryPool = reinterpret_cast<PFN_vkCmdResetQueryPool>(resolve("vkCmdResetQueryPool"));
fns.vkCmdBeginQuery = reinterpret_cast<PFN_vkCmdBeginQuery>(resolve("vkCmdBeginQuery"));
fns.vkCmdEndQuery = reinterpret_cast<PFN_vkCmdEndQuery>(resolve("vkCmdEndQuery"));
fns.vkCmdBeginQueryIndexedEXT =
reinterpret_cast<PFN_vkCmdBeginQueryIndexedEXT>(resolve("vkCmdBeginQueryIndexedEXT"));
fns.vkCmdEndQueryIndexedEXT =
reinterpret_cast<PFN_vkCmdEndQueryIndexedEXT>(resolve("vkCmdEndQueryIndexedEXT"));
fns.vkCreateRenderPass = reinterpret_cast<PFN_vkCreateRenderPass>(resolve("vkCreateRenderPass"));
fns.vkDestroyRenderPass =
reinterpret_cast<PFN_vkDestroyRenderPass>(resolve("vkDestroyRenderPass"));
fns.vkCreateFramebuffer =
reinterpret_cast<PFN_vkCreateFramebuffer>(resolve("vkCreateFramebuffer"));
fns.vkDestroyFramebuffer =
reinterpret_cast<PFN_vkDestroyFramebuffer>(resolve("vkDestroyFramebuffer"));
fns.vkCmdBeginRenderPass =
reinterpret_cast<PFN_vkCmdBeginRenderPass>(resolve("vkCmdBeginRenderPass"));
fns.vkCmdEndRenderPass = reinterpret_cast<PFN_vkCmdEndRenderPass>(resolve("vkCmdEndRenderPass"));
fns.vkCreateShaderModule =
reinterpret_cast<PFN_vkCreateShaderModule>(resolve("vkCreateShaderModule"));
fns.vkDestroyShaderModule =
reinterpret_cast<PFN_vkDestroyShaderModule>(resolve("vkDestroyShaderModule"));
fns.vkCreatePipelineLayout =
reinterpret_cast<PFN_vkCreatePipelineLayout>(resolve("vkCreatePipelineLayout"));
fns.vkDestroyPipelineLayout =
reinterpret_cast<PFN_vkDestroyPipelineLayout>(resolve("vkDestroyPipelineLayout"));
fns.vkCreateGraphicsPipelines =
reinterpret_cast<PFN_vkCreateGraphicsPipelines>(resolve("vkCreateGraphicsPipelines"));
fns.vkDestroyPipeline = reinterpret_cast<PFN_vkDestroyPipeline>(resolve("vkDestroyPipeline"));
fns.vkCmdBindPipeline = reinterpret_cast<PFN_vkCmdBindPipeline>(resolve("vkCmdBindPipeline"));
fns.vkCmdDraw = reinterpret_cast<PFN_vkCmdDraw>(resolve("vkCmdDraw"));
fns.vkCreateFence = reinterpret_cast<PFN_vkCreateFence>(resolve("vkCreateFence"));
fns.vkDestroyFence = reinterpret_cast<PFN_vkDestroyFence>(resolve("vkDestroyFence"));
fns.vkQueueSubmit = reinterpret_cast<PFN_vkQueueSubmit>(resolve("vkQueueSubmit"));
fns.vkWaitForFences = reinterpret_cast<PFN_vkWaitForFences>(resolve("vkWaitForFences"));
fns.vkGetQueryPoolResults =
reinterpret_cast<PFN_vkGetQueryPoolResults>(resolve("vkGetQueryPoolResults"));
fns.vkDeviceWaitIdle = reinterpret_cast<PFN_vkDeviceWaitIdle>(resolve("vkDeviceWaitIdle"));
const PrimitivesGeneratedNoXfbMeasurement measurement =
RunPrimitivesGeneratedNoXfbProbe(probeContext);
// Before any return below: the guard above owns the device and must know.
probeFenceWaitTimedOut = measurement.fenceWaitTimedOut;
if (!measurement.ran) {
fail(format("the probe could not run ({}); the renderer's bring-up probe decides the "
"reroute independently",
measurement.failureReason));
return;
}
const auto shapeFacts = [](const char* name,
const PrimitivesGeneratedNoXfbShapeMeasurement& shape) {
if (!shape.drawn) {
return format("{} not drawn (no tessellationShader)", name);
}
String facts = format("{}: stream answered {} of {} expected", name, shape.streamGenerated,
shape.expectedPrimitives);
if (shape.primitivesGeneratedExtMeasured) {
facts += format(", dedicated query answered {}", shape.primitivesGeneratedExt);
}
if (shape.statisticsMeasured) {
facts += format(", statistics control answered {}", shape.statisticsClippingInput);
}
if (!shape.primitivesGeneratedExtMeasured && !shape.statisticsMeasured) {
facts += ", no control (neither VK_EXT_primitives_generated_query with its "
"discard feature nor pipelineStatisticsQuery is available)";
}
return facts;
};
const String facts = shapeFacts("triangles", measurement.trianglesPlain) + "; " +
shapeFacts("triangles under discard", measurement.trianglesDiscard) +
"; " + shapeFacts("patches under discard", measurement.patchesDiscard);
const auto statisticsExactOn = [](const PrimitivesGeneratedNoXfbShapeMeasurement& shape) {
return shape.statisticsMeasured && shape.statisticsClippingInput == shape.expectedPrimitives;
};
// What the PLAIN-ONLY verdict actually measured, named from the numbers rather
// than assumed: the shape the substitute misses may be the tessellated one
// alone, and a missed shape may read a wrong NONZERO count rather than 0. A
// row that always blamed rasterizer discard would put a false statement about
// the driver into the campaign's evidence artifact, contradicted by the facts
// string printed right after it.
const auto describeMissedStatisticsShapes = [&]() {
String missed;
const auto note = [&](const char* name,
const PrimitivesGeneratedNoXfbShapeMeasurement& shape) {
if (!shape.drawn || statisticsExactOn(shape)) {
return;
}
if (!missed.empty()) {
missed += " and ";
}
missed += name;
missed += shape.statisticsMeasured
? format(" (read {} of {} expected)", shape.statisticsClippingInput,
shape.expectedPrimitives)
: String(" (its statistics slot did not read back)");
};
note("the plain draw", measurement.trianglesPlain);
note("triangles under rasterizer discard", measurement.trianglesDiscard);
note("patches under rasterizer discard", measurement.patchesDiscard);
return missed;
};
// The CTS's tessellator-measuring shape is a PATCHES draw under discard; say
// whether THIS driver's substitute covers it instead of assuming it does not.
const auto describeCtsShape = [&]() -> String {
if (!measurement.patchesDiscard.drawn) {
return "the CTS's tessellator-measuring shape (a PATCHES draw under discard) could "
"not be measured here - this device has no tessellationShader - so whether "
"the substitute covers it is unknown";
}
return statisticsExactOn(measurement.patchesDiscard)
? "the CTS's tessellator-measuring shape (a PATCHES draw under discard) is "
"NOT among them: the substitute answers it exactly, so those tests are "
"repaired"
: "the CTS's tessellator-measuring shape (a PATCHES draw under discard) is "
"among them, so those tests stay broken on this driver";
};
switch (EvaluatePrimitivesGeneratedNoXfbVerdict(measurement)) {
case PrimitivesGeneratedNoXfbVerdict::StreamCounts:
builder.Pass(RowName,
"the stream query counts a draw made with no capture span open, as "
"VK_EXT_transform_feedback defines (" +
facts + ")");
return;
case PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute:
builder.Warn(RowName,
"the stream query answers 0 for a draw made with no capture span open - "
"the shape the CTS measures the tessellator with - while a "
"VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT query around an identical replay answers "
"exactly right, rasterizer discard included, so the renderer "
"accumulates GL_PRIMITIVES_GENERATED for such draws through the "
"dedicated query instead (one extra query slot per XFB-inactive draw "
"inside a GENERATED span; " +
facts + ")");
return;
case PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute:
builder.Warn(RowName,
"the stream query answers 0 for a draw made with no capture span open - "
"the shape the CTS measures the tessellator with - while a "
"clipping-invocations statistics query around an identical replay answers exactly "
"right, rasterizer discard included, so the renderer accumulates "
"GL_PRIMITIVES_GENERATED for such draws through a pipeline-statistics "
"pool instead (one extra query slot per XFB-inactive draw inside a "
"GENERATED span; " +
facts + ")");
return;
case PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly:
fail("the stream query answers 0 for a draw made with no capture span open, and the "
"clipping-invocations statistics substitute counts the plain draw exactly but "
"misses " +
describeMissedStatisticsShapes() +
" - each of them a shape the stream query answered 0 for as well, so the renderer "
"reroutes XFB-inactive draws (repairing every shape the substitute answers, at no "
"cost to the rest, which is what the verdict requires); " +
describeCtsShape() + " (" + facts + ")");
return;
case PrimitivesGeneratedNoXfbVerdict::Unfixable: {
// Two ways to land here, and the report must not conflate them: no
// substitute answers even the plain draw, or one does but it is WRONG on a
// shape the stream query answers EXACTLY - arming it would trade a correct
// answer for a wrong one, so MobileGL refuses (see the verdict's
// domination rule).
String downgradeShapes;
const auto noteDowngrade = [&](const char* name,
const PrimitivesGeneratedNoXfbShapeMeasurement& shape) {
if (!shape.drawn || statisticsExactOn(shape) ||
shape.streamGenerated != shape.expectedPrimitives) {
return;
}
if (!downgradeShapes.empty()) {
downgradeShapes += " and ";
}
downgradeShapes += name;
};
noteDowngrade("the plain draw", measurement.trianglesPlain);
noteDowngrade("triangles under rasterizer discard", measurement.trianglesDiscard);
noteDowngrade("patches under rasterizer discard", measurement.patchesDiscard);
if (statisticsExactOn(measurement.trianglesPlain) && !downgradeShapes.empty()) {
fail("the stream query answers 0 for a draw made with no capture span open, and the "
"clipping-invocations statistics substitute repairs the plain draw but is wrong "
"on " +
downgradeShapes +
", which the stream query answers exactly - rerouting every XFB-inactive draw "
"would trade a correct count for a wrong one, so MobileGL arms nothing and the "
"capture-less query keeps the driver's answers (" +
facts + ")");
return;
}
fail("the stream query answers 0 for a draw made with no capture span open and the "
"device offers no working statistics substitute; an application sizing a capture "
"buffer from GL_PRIMITIVES_GENERATED gets 0 (" +
facts + ")");
return;
}
case PrimitivesGeneratedNoXfbVerdict::Inconclusive:
break;
}
fail("the probe reached no verdict - the answers fit neither the defect nor health, and "
"MobileGL declines to repair a driver it does not understand (" +
facts + ")");
}
// Native iterationRP compute witness. This deliberately uses a separate
// throwaway Vulkan device rather than the real renderer's queues, and it
// treats MOBILEGL_DISABLE_SUBGROUP as irrelevant: the row reports what the
// treats MOBILEGL_MAGMA_DISABLE_SUBGROUP as irrelevant: the row reports what the
// driver does, not what MobileGL elects to advertise to applications.
void ProbeVulkanIterationRPWitness(ReportBuilder& builder, PFN_vkGetInstanceProcAddr getInstanceProcAddr,
VkInstance instance, VkPhysicalDevice physicalDevice,
@@ -2652,6 +3024,10 @@ namespace MobileGL::MG_Util::SelfTest {
ProbeVulkanIterationRPWitness(builder, getInstanceProcAddr, instance, physicalDevice, computeQueueFamilyIndex,
properties, subgroupPropertiesAvailable, subgroupProperties);
ProbeVulkanPrimitivesGeneratedNoXfb(builder, getInstanceProcAddr, instance, physicalDevice,
graphicsQueueFamilyIndex, deviceExtensions, features,
vkGetPhysicalDeviceFeatures2Fn, vkGetPhysicalDeviceProperties2Fn);
if (HasVkExtension(deviceExtensions, VK_KHR_DRAW_INDIRECT_COUNT_EXTENSION_NAME)) {
builder.Pass("VK_KHR_draw_indirect_count",
"supported (count-buffer indirect draws run as single native "
@@ -0,0 +1,556 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.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
//
// See the header for what is being measured and why. The plumbing here is shaped
// like the POST timestamp probe (DriverPost.cpp, ProbeVulkanTimerQuery): one
// throwaway command buffer, a bounded fence wait that deliberately leaks the
// device objects rather than idle-wait a hung GPU, and teardown on every path.
#include "PrimitivesGeneratedNoXfbProbe.h"
#include "PrimitivesGeneratedNoXfbProbeSpv.h"
namespace MobileGL::MG_Util::SelfTest {
namespace {
template <typename Callable>
struct ProbeScopeGuard {
explicit ProbeScopeGuard(Callable callable) : onExit(Move(callable)) {}
ProbeScopeGuard(const ProbeScopeGuard&) = delete;
ProbeScopeGuard& operator=(const ProbeScopeGuard&) = delete;
~ProbeScopeGuard() { onExit(); }
private:
Callable onExit;
};
Bool AllRequiredFnsPresent(const PrimitivesGeneratedNoXfbProbeFns& fns) {
return fns.vkCreateCommandPool != nullptr && fns.vkDestroyCommandPool != nullptr &&
fns.vkAllocateCommandBuffers != nullptr && fns.vkBeginCommandBuffer != nullptr &&
fns.vkEndCommandBuffer != nullptr && fns.vkCreateQueryPool != nullptr &&
fns.vkDestroyQueryPool != nullptr && fns.vkCmdResetQueryPool != nullptr &&
fns.vkCmdBeginQuery != nullptr && fns.vkCmdEndQuery != nullptr &&
fns.vkCmdBeginQueryIndexedEXT != nullptr && fns.vkCmdEndQueryIndexedEXT != nullptr &&
fns.vkCreateRenderPass != nullptr && fns.vkDestroyRenderPass != nullptr &&
fns.vkCreateFramebuffer != nullptr && fns.vkDestroyFramebuffer != nullptr &&
fns.vkCmdBeginRenderPass != nullptr && fns.vkCmdEndRenderPass != nullptr &&
fns.vkCreateShaderModule != nullptr && fns.vkDestroyShaderModule != nullptr &&
fns.vkCreatePipelineLayout != nullptr && fns.vkDestroyPipelineLayout != nullptr &&
fns.vkCreateGraphicsPipelines != nullptr && fns.vkDestroyPipeline != nullptr &&
fns.vkCmdBindPipeline != nullptr && fns.vkCmdDraw != nullptr &&
fns.vkCreateFence != nullptr && fns.vkDestroyFence != nullptr &&
fns.vkQueueSubmit != nullptr && fns.vkWaitForFences != nullptr &&
fns.vkGetQueryPoolResults != nullptr && fns.vkDeviceWaitIdle != nullptr;
}
} // namespace
PrimitivesGeneratedNoXfbMeasurement RunPrimitivesGeneratedNoXfbProbe(
const PrimitivesGeneratedNoXfbProbeContext& context) {
PrimitivesGeneratedNoXfbMeasurement measurement;
const auto fail = [&](String reason) {
measurement.ran = false;
measurement.failureReason = Move(reason);
return measurement;
};
if (!context.transformFeedbackQueriesUsable) {
return fail("transform feedback stream queries are not usable on this device, so the "
"probe has no subject");
}
if (context.device == VK_NULL_HANDLE || context.queue == VK_NULL_HANDLE) {
return fail("no device/queue was supplied");
}
const PrimitivesGeneratedNoXfbProbeFns& fns = context.fns;
if (!AllRequiredFnsPresent(fns)) {
return fail("a required Vulkan entry point was not resolved");
}
VkDevice device = context.device;
// Slot i of each pool belongs to shape i (0 = triangles plain, 1 = triangles
// under discard, 2 = patches under discard). Unused slots are reset either
// way; reset needs no feature and an unqueried reset slot is never read.
constexpr Uint32 kShapeSlots = 3;
const Bool drawPatches = context.tessellationEnabled;
const Bool measureStatistics = context.pipelineStatisticsEnabled;
// Only with BOTH feature bits: without ...WithRasterizerDiscard, a
// discarding draw inside the query is invalid usage
// (VUID-vkCmdDraw-primitivesGeneratedQueryWithRasterizerDiscard-06708),
// and two of the three shapes discard.
const Bool measurePrimitivesGeneratedExt = context.primitivesGeneratedQueryUsable;
VkCommandPool commandPool = VK_NULL_HANDLE;
VkQueryPool streamQueryPool = VK_NULL_HANDLE;
VkQueryPool primitivesGeneratedQueryPool = VK_NULL_HANDLE;
VkQueryPool statisticsQueryPool = VK_NULL_HANDLE;
VkRenderPass renderPass = VK_NULL_HANDLE;
VkFramebuffer framebuffer = VK_NULL_HANDLE;
VkShaderModule vertModule = VK_NULL_HANDLE;
VkShaderModule tescModule = VK_NULL_HANDLE;
VkShaderModule teseModule = VK_NULL_HANDLE;
VkPipelineLayout pipelineLayout = VK_NULL_HANDLE;
VkPipeline trianglePlainPipeline = VK_NULL_HANDLE;
VkPipeline triangleDiscardPipeline = VK_NULL_HANDLE;
VkPipeline patchDiscardPipeline = VK_NULL_HANDLE;
VkFence fence = VK_NULL_HANDLE;
// Teardown on every path. When the fence wait timed out the submission may
// still be executing on a hung GPU: vkDeviceWaitIdle could block forever
// and destroying in-flight objects is undefined, so everything is
// deliberately leaked - a hung GPU must not hang the caller. The same flag
// is returned in the measurement, because a caller that OWNS the device must
// make the same choice for it (see the header): destroying a device whose
// children are alive and whose queue may still be executing is the very hang
// this bound exists to prevent.
const ProbeScopeGuard teardown([&]() {
if (measurement.fenceWaitTimedOut) {
return;
}
fns.vkDeviceWaitIdle(device);
if (fence != VK_NULL_HANDLE) fns.vkDestroyFence(device, fence, nullptr);
if (trianglePlainPipeline != VK_NULL_HANDLE)
fns.vkDestroyPipeline(device, trianglePlainPipeline, nullptr);
if (triangleDiscardPipeline != VK_NULL_HANDLE)
fns.vkDestroyPipeline(device, triangleDiscardPipeline, nullptr);
if (patchDiscardPipeline != VK_NULL_HANDLE)
fns.vkDestroyPipeline(device, patchDiscardPipeline, nullptr);
if (pipelineLayout != VK_NULL_HANDLE) fns.vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
if (vertModule != VK_NULL_HANDLE) fns.vkDestroyShaderModule(device, vertModule, nullptr);
if (tescModule != VK_NULL_HANDLE) fns.vkDestroyShaderModule(device, tescModule, nullptr);
if (teseModule != VK_NULL_HANDLE) fns.vkDestroyShaderModule(device, teseModule, nullptr);
if (framebuffer != VK_NULL_HANDLE) fns.vkDestroyFramebuffer(device, framebuffer, nullptr);
if (renderPass != VK_NULL_HANDLE) fns.vkDestroyRenderPass(device, renderPass, nullptr);
if (statisticsQueryPool != VK_NULL_HANDLE) fns.vkDestroyQueryPool(device, statisticsQueryPool, nullptr);
if (primitivesGeneratedQueryPool != VK_NULL_HANDLE)
fns.vkDestroyQueryPool(device, primitivesGeneratedQueryPool, nullptr);
if (streamQueryPool != VK_NULL_HANDLE) fns.vkDestroyQueryPool(device, streamQueryPool, nullptr);
if (commandPool != VK_NULL_HANDLE) fns.vkDestroyCommandPool(device, commandPool, nullptr);
});
VkCommandPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.queueFamilyIndex = context.queueFamilyIndex;
if (fns.vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool) != VK_SUCCESS) {
return fail("vkCreateCommandPool failed");
}
VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
allocInfo.commandBufferCount = 1;
VkCommandBuffer commandBuffer = VK_NULL_HANDLE;
if (fns.vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer) != VK_SUCCESS) {
return fail("vkAllocateCommandBuffers failed");
}
VkQueryPoolCreateInfo streamPoolInfo{};
streamPoolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
streamPoolInfo.queryType = VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT;
streamPoolInfo.queryCount = kShapeSlots;
if (fns.vkCreateQueryPool(device, &streamPoolInfo, nullptr, &streamQueryPool) != VK_SUCCESS) {
return fail("vkCreateQueryPool(TRANSFORM_FEEDBACK_STREAM) failed");
}
if (measurePrimitivesGeneratedExt) {
VkQueryPoolCreateInfo pgqPoolInfo{};
pgqPoolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
pgqPoolInfo.queryType = VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT;
pgqPoolInfo.queryCount = kShapeSlots;
if (fns.vkCreateQueryPool(device, &pgqPoolInfo, nullptr, &primitivesGeneratedQueryPool) !=
VK_SUCCESS) {
return fail("vkCreateQueryPool(PRIMITIVES_GENERATED_EXT) failed");
}
}
if (measureStatistics) {
VkQueryPoolCreateInfo statPoolInfo{};
statPoolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
statPoolInfo.queryType = VK_QUERY_TYPE_PIPELINE_STATISTICS;
statPoolInfo.queryCount = kShapeSlots;
// CLIPPING_INVOCATIONS counts the primitives PROCESSED BY (i.e. reaching)
// primitive clipping - GL's CLIPPING_INPUT_PRIMITIVES - which is the
// pre-clip, post-vertex-processing set PRIMITIVES_GENERATED is defined
// over. CLIPPING_PRIMITIVES (the stage's OUTPUT count) would be wrong:
// clipping may drop or split primitives.
statPoolInfo.pipelineStatistics = VK_QUERY_PIPELINE_STATISTIC_CLIPPING_INVOCATIONS_BIT;
if (fns.vkCreateQueryPool(device, &statPoolInfo, nullptr, &statisticsQueryPool) != VK_SUCCESS) {
return fail("vkCreateQueryPool(PIPELINE_STATISTICS) failed");
}
}
// Zero-attachment render pass + 1x1 framebuffer: the draw is discarded
// before rasterization, nothing is ever written, but vkCmdDraw needs a
// render pass instance to live in.
VkSubpassDescription subpass{};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
VkRenderPassCreateInfo renderPassInfo{};
renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
renderPassInfo.subpassCount = 1;
renderPassInfo.pSubpasses = &subpass;
if (fns.vkCreateRenderPass(device, &renderPassInfo, nullptr, &renderPass) != VK_SUCCESS) {
return fail("vkCreateRenderPass failed");
}
VkFramebufferCreateInfo framebufferInfo{};
framebufferInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
framebufferInfo.renderPass = renderPass;
framebufferInfo.width = 1;
framebufferInfo.height = 1;
framebufferInfo.layers = 1;
if (fns.vkCreateFramebuffer(device, &framebufferInfo, nullptr, &framebuffer) != VK_SUCCESS) {
return fail("vkCreateFramebuffer failed");
}
const auto makeModule = [&](const std::uint32_t* words, std::size_t wordCount, VkShaderModule& out) {
VkShaderModuleCreateInfo moduleInfo{};
moduleInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
moduleInfo.codeSize = wordCount * sizeof(std::uint32_t);
moduleInfo.pCode = words;
return fns.vkCreateShaderModule(device, &moduleInfo, nullptr, &out) == VK_SUCCESS;
};
if (!makeModule(kPrimitivesGeneratedNoXfbProbeVertSpv, kPrimitivesGeneratedNoXfbProbeVertSpvWordCount,
vertModule)) {
return fail("vkCreateShaderModule(vert) failed");
}
if (drawPatches) {
if (!makeModule(kPrimitivesGeneratedNoXfbProbeTescSpv, kPrimitivesGeneratedNoXfbProbeTescSpvWordCount,
tescModule) ||
!makeModule(kPrimitivesGeneratedNoXfbProbeTeseSpv, kPrimitivesGeneratedNoXfbProbeTeseSpvWordCount,
teseModule)) {
return fail("vkCreateShaderModule(tesc/tese) failed");
}
}
VkPipelineLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
if (fns.vkCreatePipelineLayout(device, &layoutInfo, nullptr, &pipelineLayout) != VK_SUCCESS) {
return fail("vkCreatePipelineLayout failed");
}
// With rasterizerDiscardEnable the viewport and multisample state are
// ignored by the spec, but well-formed ones are supplied anyway: the probe
// must never be the thing that trips a picky driver. The discard-off
// variant rasterizes into the zero-attachment subpass, which writes
// nothing anywhere.
const auto makePipeline = [&](Bool tessellated, Bool rasterizerDiscard, VkPipeline& out) {
VkPipelineShaderStageCreateInfo stages[3] = {};
Uint32 stageCount = 0;
stages[stageCount].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[stageCount].stage = VK_SHADER_STAGE_VERTEX_BIT;
stages[stageCount].module = vertModule;
stages[stageCount].pName = "main";
++stageCount;
if (tessellated) {
stages[stageCount].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[stageCount].stage = VK_SHADER_STAGE_TESSELLATION_CONTROL_BIT;
stages[stageCount].module = tescModule;
stages[stageCount].pName = "main";
++stageCount;
stages[stageCount].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[stageCount].stage = VK_SHADER_STAGE_TESSELLATION_EVALUATION_BIT;
stages[stageCount].module = teseModule;
stages[stageCount].pName = "main";
++stageCount;
}
VkPipelineVertexInputStateCreateInfo vertexInput{};
vertexInput.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
inputAssembly.topology =
tessellated ? VK_PRIMITIVE_TOPOLOGY_PATCH_LIST : VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
VkPipelineTessellationStateCreateInfo tessellation{};
tessellation.sType = VK_STRUCTURE_TYPE_PIPELINE_TESSELLATION_STATE_CREATE_INFO;
tessellation.patchControlPoints = 1;
VkViewport viewport{};
viewport.width = 1.0f;
viewport.height = 1.0f;
viewport.maxDepth = 1.0f;
VkRect2D scissor{};
scissor.extent.width = 1;
scissor.extent.height = 1;
VkPipelineViewportStateCreateInfo viewportState{};
viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
viewportState.viewportCount = 1;
viewportState.pViewports = &viewport;
viewportState.scissorCount = 1;
viewportState.pScissors = &scissor;
VkPipelineRasterizationStateCreateInfo rasterization{};
rasterization.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
rasterization.rasterizerDiscardEnable = rasterizerDiscard ? VK_TRUE : VK_FALSE;
rasterization.polygonMode = VK_POLYGON_MODE_FILL;
rasterization.cullMode = VK_CULL_MODE_NONE;
rasterization.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
rasterization.lineWidth = 1.0f;
VkPipelineMultisampleStateCreateInfo multisample{};
multisample.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
VkGraphicsPipelineCreateInfo pipelineInfo{};
pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
pipelineInfo.stageCount = stageCount;
pipelineInfo.pStages = stages;
pipelineInfo.pVertexInputState = &vertexInput;
pipelineInfo.pInputAssemblyState = &inputAssembly;
pipelineInfo.pTessellationState = tessellated ? &tessellation : nullptr;
pipelineInfo.pViewportState = &viewportState;
pipelineInfo.pRasterizationState = &rasterization;
pipelineInfo.pMultisampleState = &multisample;
pipelineInfo.layout = pipelineLayout;
pipelineInfo.renderPass = renderPass;
pipelineInfo.subpass = 0;
return fns.vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &out) ==
VK_SUCCESS;
};
if (!makePipeline(false, false, trianglePlainPipeline)) {
return fail("vkCreateGraphicsPipelines(triangles) failed");
}
if (!makePipeline(false, true, triangleDiscardPipeline)) {
return fail("vkCreateGraphicsPipelines(triangles, discard) failed");
}
if (drawPatches && !makePipeline(true, true, patchDiscardPipeline)) {
return fail("vkCreateGraphicsPipelines(patches, discard) failed");
}
VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
if (fns.vkBeginCommandBuffer(commandBuffer, &beginInfo) != VK_SUCCESS) {
return fail("vkBeginCommandBuffer failed");
}
fns.vkCmdResetQueryPool(commandBuffer, streamQueryPool, 0, kShapeSlots);
if (measurePrimitivesGeneratedExt) {
fns.vkCmdResetQueryPool(commandBuffer, primitivesGeneratedQueryPool, 0, kShapeSlots);
}
if (measureStatistics) {
fns.vkCmdResetQueryPool(commandBuffer, statisticsQueryPool, 0, kShapeSlots);
}
VkRenderPassBeginInfo renderPassBegin{};
renderPassBegin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
renderPassBegin.renderPass = renderPass;
renderPassBegin.framebuffer = framebuffer;
renderPassBegin.renderArea.extent.width = 1;
renderPassBegin.renderArea.extent.height = 1;
fns.vkCmdBeginRenderPass(commandBuffer, &renderPassBegin, VK_SUBPASS_CONTENTS_INLINE);
// Each query kind wraps ITS OWN replay of the shape's draw, never a shared
// one. Not pedantry - a co-active control CONTAMINATES the subject:
// measured on lavapipe, a dedicated primitives-generated query active
// around the same draw switches llvmpipe's primitive collection on, and
// the stream query on that draw then answers the exact count it answers 0
// for when it is alone - which is how the renderer actually runs it. A
// probe that measured them together certified this driver healthy and
// repaired nothing. The replays are identical recordings of a
// deterministic draw, so the per-shape comparison loses nothing.
const auto recordShape = [&](Uint32 slot, VkPipeline pipeline, Uint32 vertexCount) {
fns.vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
// THE SUBJECT, alone: no vkCmdBeginTransformFeedbackEXT anywhere in
// this command buffer - the stream query wraps a draw with transform
// feedback inactive, exactly the CTS's tessellator-measuring shape.
fns.vkCmdBeginQueryIndexedEXT(commandBuffer, streamQueryPool, slot, 0, 0);
fns.vkCmdDraw(commandBuffer, vertexCount, 1, 0, 0);
fns.vkCmdEndQueryIndexedEXT(commandBuffer, streamQueryPool, slot, 0);
if (measurePrimitivesGeneratedExt) {
// Plain vkCmdBeginQuery: a PRIMITIVES_GENERATED_EXT query begun
// this way counts vertex stream 0, which is where every non-GS
// (and default-stream GS) primitive goes.
fns.vkCmdBeginQuery(commandBuffer, primitivesGeneratedQueryPool, slot, 0);
fns.vkCmdDraw(commandBuffer, vertexCount, 1, 0, 0);
fns.vkCmdEndQuery(commandBuffer, primitivesGeneratedQueryPool, slot);
}
if (measureStatistics) {
fns.vkCmdBeginQuery(commandBuffer, statisticsQueryPool, slot, 0);
fns.vkCmdDraw(commandBuffer, vertexCount, 1, 0, 0);
fns.vkCmdEndQuery(commandBuffer, statisticsQueryPool, slot);
}
};
recordShape(0, trianglePlainPipeline, 3); // one rasterized triangle
recordShape(1, triangleDiscardPipeline, 3); // one discarded triangle
if (drawPatches) {
// one 1-vertex patch -> one tessellated, discarded triangle
recordShape(2, patchDiscardPipeline, 1);
}
fns.vkCmdEndRenderPass(commandBuffer);
if (fns.vkEndCommandBuffer(commandBuffer) != VK_SUCCESS) {
return fail("vkEndCommandBuffer failed");
}
VkFenceCreateInfo fenceInfo{};
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
if (fns.vkCreateFence(device, &fenceInfo, nullptr, &fence) != VK_SUCCESS) {
return fail("vkCreateFence failed");
}
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;
if (fns.vkQueueSubmit(context.queue, 1, &submitInfo, fence) != VK_SUCCESS) {
return fail("vkQueueSubmit failed");
}
constexpr Uint64 kFenceTimeoutNs = 5'000'000'000ull; // a probe must never hang its caller
if (fns.vkWaitForFences(device, 1, &fence, VK_TRUE, kFenceTimeoutNs) != VK_SUCCESS) {
// Set BEFORE failing: the scope guard reads it to skip every destroy, and
// the caller reads it out of the measurement to skip destroying the device.
measurement.fenceWaitTimedOut = true;
return fail("the probe submission did not complete within 5 s");
}
const auto readShape = [&](Uint32 slot, Uint64 expected, PrimitivesGeneratedNoXfbShapeMeasurement& out) {
Uint64 streamPair[2] = {0, 0}; // {primitivesWritten, primitivesNeeded}
if (fns.vkGetQueryPoolResults(device, streamQueryPool, slot, 1, sizeof(streamPair), streamPair,
sizeof(streamPair),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT) != VK_SUCCESS) {
return false;
}
out.drawn = true;
out.expectedPrimitives = expected;
out.streamGenerated = streamPair[1];
if (measurePrimitivesGeneratedExt) {
Uint64 generated = 0;
if (fns.vkGetQueryPoolResults(device, primitivesGeneratedQueryPool, slot, 1, sizeof(generated),
&generated, sizeof(generated),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT) == VK_SUCCESS) {
out.primitivesGeneratedExtMeasured = true;
out.primitivesGeneratedExt = generated;
}
}
if (measureStatistics) {
Uint64 clippingInput = 0;
if (fns.vkGetQueryPoolResults(device, statisticsQueryPool, slot, 1, sizeof(clippingInput),
&clippingInput, sizeof(clippingInput),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT) == VK_SUCCESS) {
out.statisticsMeasured = true;
out.statisticsClippingInput = clippingInput;
}
}
return true;
};
if (!readShape(0, 1, measurement.trianglesPlain)) {
return fail("vkGetQueryPoolResults(triangles) failed");
}
if (!readShape(1, 1, measurement.trianglesDiscard)) {
return fail("vkGetQueryPoolResults(triangles, discard) failed");
}
if (drawPatches && !readShape(2, 1, measurement.patchesDiscard)) {
return fail("vkGetQueryPoolResults(patches, discard) failed");
}
measurement.ran = true;
return measurement;
}
PrimitivesGeneratedNoXfbVerdict EvaluatePrimitivesGeneratedNoXfbVerdict(
const PrimitivesGeneratedNoXfbMeasurement& measurement) {
if (!measurement.ran || !measurement.trianglesPlain.drawn || !measurement.trianglesDiscard.drawn) {
return PrimitivesGeneratedNoXfbVerdict::Inconclusive;
}
const PrimitivesGeneratedNoXfbShapeMeasurement* shapes[3] = {&measurement.trianglesPlain,
&measurement.trianglesDiscard,
&measurement.patchesDiscard};
Bool anyStreamSilent = false;
Bool allStreamExact = true;
Bool allPrimitivesGeneratedExtExact = true;
Bool allStatisticsExact = true;
// Whether the statistics substitute DOMINATES the stream query shape by shape:
// every shape the statistics do not answer exactly must be one the stream query
// answered 0 for anyway. Without this, a plain-shape-only substitute could be
// armed on a device whose stream query was RIGHT on a shape the statistics get
// wrong - and the renderer reroutes every XFB-inactive draw, so that shape would
// be downgraded from correct to wrong. "Never worse per draw" is what makes
// arming on an uncharacterised driver defensible; it has to be measured, not
// assumed.
Bool statisticsDominateStream = true;
for (const auto* shape : shapes) {
if (!shape->drawn) {
continue;
}
if (shape->streamGenerated == 0) {
anyStreamSilent = true;
}
if (shape->streamGenerated != shape->expectedPrimitives) {
allStreamExact = false;
// A nonzero wrong answer is neither the defect nor health: refuse
// a verdict rather than repair a driver the probe does not
// understand.
if (shape->streamGenerated != 0) {
return PrimitivesGeneratedNoXfbVerdict::Inconclusive;
}
}
if (!shape->primitivesGeneratedExtMeasured ||
shape->primitivesGeneratedExt != shape->expectedPrimitives) {
allPrimitivesGeneratedExtExact = false;
}
if (!shape->statisticsMeasured ||
shape->statisticsClippingInput != shape->expectedPrimitives) {
allStatisticsExact = false;
// Only a shape the stream query was silent on may be left behind by
// the substitute; a shape it answered exactly must not be traded away.
if (shape->streamGenerated == shape->expectedPrimitives) {
statisticsDominateStream = false;
}
}
}
if (allStreamExact) {
return PrimitivesGeneratedNoXfbVerdict::StreamCounts;
}
// At this point at least one drawn shape answered exactly 0.
MOBILEGL_ASSERT(anyStreamSilent, "verdict fell through with no silent shape");
if (allPrimitivesGeneratedExtExact) {
return PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute;
}
if (allStatisticsExact) {
return PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute;
}
const auto& plain = measurement.trianglesPlain;
const Bool plainStatisticsExact =
plain.statisticsMeasured && plain.statisticsClippingInput == plain.expectedPrimitives;
// Both halves are required: the substitute must repair the plain shape, AND it
// must not cost any shape an answer the stream query already had right.
return (plainStatisticsExact && statisticsDominateStream)
? PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly
: PrimitivesGeneratedNoXfbVerdict::Unfixable;
}
PrimGenRerouteKind ChoosePrimitivesGeneratedReroute(MG_Config::QuirkOverride overrideSetting,
PrimitivesGeneratedNoXfbVerdict verdict,
Bool primitivesGeneratedQueryUsable,
Bool pipelineStatisticsEnabled) {
switch (overrideSetting) {
case MG_Config::QuirkOverride::ForceOff:
return PrimGenRerouteKind::None;
case MG_Config::QuirkOverride::ForceOn:
// ForceOn bypasses the device verdict, never the structural checks:
// without a hostable pool there is nothing to route through. The
// dedicated query wins where both exist - its semantics are the GL
// target's by definition.
if (primitivesGeneratedQueryUsable) {
return PrimGenRerouteKind::PrimitivesGeneratedExt;
}
return pipelineStatisticsEnabled ? PrimGenRerouteKind::ClippingStatistics
: PrimGenRerouteKind::None;
case MG_Config::QuirkOverride::Auto:
break;
}
switch (verdict) {
case PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute:
return primitivesGeneratedQueryUsable ? PrimGenRerouteKind::PrimitivesGeneratedExt
: PrimGenRerouteKind::None;
case PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute:
case PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly:
return pipelineStatisticsEnabled ? PrimGenRerouteKind::ClippingStatistics
: PrimGenRerouteKind::None;
case PrimitivesGeneratedNoXfbVerdict::Inconclusive:
case PrimitivesGeneratedNoXfbVerdict::StreamCounts:
case PrimitivesGeneratedNoXfbVerdict::Unfixable:
break;
}
return PrimGenRerouteKind::None;
}
} // namespace MobileGL::MG_Util::SelfTest
@@ -0,0 +1,258 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h
// 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
#pragma once
#include <Config.h>
#include <Includes.h>
namespace MobileGL::MG_Util::SelfTest {
// ============ PRIMITIVES GENERATED WITHOUT TRANSFORM FEEDBACK ============
//
// GL_PRIMITIVES_GENERATED counts what the last vertex processing stage emits
// whether or not a transform feedback capture is active (GL 4.6 core 13.4), and
// the DirectVulkan backend serves it from the second result
// (primitivesNeeded) of a VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT pool
// slot wrapped around each draw. VK_EXT_transform_feedback defines that value
// as the primitives the vertex stream produced, capture or no capture - but a
// Mali driver (G1-Ultra, observed against the gl44/gl45/gl46 CTS) answers 0
// for every draw made while no vkCmdBeginTransformFeedbackEXT span is open,
// while answering exactly right as soon as one is. The tessellation suites
// measure the tessellator by exactly that shape (rasterizer discard on,
// transform feedback INACTIVE, a PATCHES draw inside a GENERATED query;
// esextcTessellationShaderUtils.cpp, captureTessellationData), size their
// capture buffers from the answer, and die on the zero-byte buffer the 0
// produces - about 29 tessellation tests per tree plus all 13
// tessellation_shader.vertex bodies.
//
// THE PROBE draws three shapes through pipelines with no Xfb execution mode
// and no transform feedback begun, each inside its own stream-query slot:
// - one triangle, plainly (no rasterizer discard);
// - one triangle with rasterizer discard baked into the pipeline;
// - one PATCHES draw with discard, through a passthrough tessellation
// pipeline whose all-1 levels emit exactly one triangle (when the device
// has tessellationShader) - the CTS shape verbatim.
// Alongside each stream slot it measures the TWO candidate substitutes, each
// around ITS OWN identical replay of the shape's draw - never co-active with
// the subject, because a co-active control contaminates it: on lavapipe a
// dedicated primitives-generated query active around the same draw switches
// the driver's primitive collection on and the stream query then counts a
// draw it answers 0 for when alone, which is how the renderer actually runs
// it. The substitutes:
// - a VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT slot, where the device has
// VK_EXT_primitives_generated_query with BOTH primitivesGeneratedQuery and
// primitivesGeneratedQueryWithRasterizerDiscard (without the discard
// feature the spec forbids the query around a discarding draw at all -
// VUID-vkCmdDraw-...-06708 - and GL applications toggle discard freely, so
// a base-feature-only device cannot use this tier). The extension exists
// precisely because GL needs PRIMITIVES_GENERATED without a capture, so
// its semantics are exact by definition - what remains to prove is that
// the DRIVER's implementation is not silent in the same way its stream
// query is;
// - a VK_QUERY_TYPE_PIPELINE_STATISTICS slot counting CLIPPING_INVOCATIONS
// (when the device has pipelineStatisticsQuery): one invocation of the
// primitive clipping stage per primitive reaching it - GL's
// CLIPPING_INPUT_PRIMITIVES - which sits AFTER every vertex processing
// stage (post-tess, post-GS) and, per spec, BEFORE rasterizer discard, so
// for an XFB-inactive draw it is definitionally the number
// PRIMITIVES_GENERATED must answer. (A geometry stage's non-zero vertex
// streams never reach clipping, but non-indexed GL_PRIMITIVES_GENERATED
// counts stream 0 alone, so the sets still agree. The stage's OUTPUT
// count - CLIPPING_PRIMITIVES - would not: clipping drops and splits.)
//
// THE CONTROL DISCIPLINE (DriverBugProbes.h): the substitute slots are the
// probe's controls, and the DISCARD dimension is measured separately because
// it is a real fault line, not paranoia: Mesa llvmpipe short-circuits its
// clipping statistics under rasterizer discard (reading 0 there while counting
// the identical undiscarded draw exactly) while its dedicated
// primitives-generated query counts both - measured 2026-08, and the reason
// the verdict ranks the dedicated query first. A substitute qualifies only by
// answering the exact expected count on every shape it is required to cover;
// a device where no substitute qualifies even for the plain shape gets none
// (the honest verdict is the current behaviour); anything that fits neither
// the defect nor health is INCONCLUSIVE and must never arm anything. The
// expected counts are exact (1 triangle per shape), not merely nonzero, so a
// driver that half-counts cannot arm a half-right repair.
// What one drawn shape of the probe measured.
struct PrimitivesGeneratedNoXfbShapeMeasurement {
// The shape's draw was recorded and its query slots were read back.
Bool drawn = false;
// Primitives the draw is defined to emit (1 for every shape).
Uint64 expectedPrimitives = 0;
// The stream-query slot's primitivesNeeded answer - what the renderer's
// GL_PRIMITIVES_GENERATED path would have returned.
Uint64 streamGenerated = 0;
// Whether the dedicated primitives-generated slot ran (it needs the
// extension with both feature bits, see above).
Bool primitivesGeneratedExtMeasured = false;
// Its answer for the same draw.
Uint64 primitivesGeneratedExt = 0;
// Whether the statistics slot ran (it needs pipelineStatisticsQuery).
Bool statisticsMeasured = false;
// The clipping-stage invocation count for the same draw.
Uint64 statisticsClippingInput = 0;
};
struct PrimitivesGeneratedNoXfbMeasurement {
// The probe submitted and read back at least the two triangle shapes.
// False when any setup step failed; failureReason then names the step.
Bool ran = false;
// The probe's bounded fence wait expired with the submission possibly
// still executing. The probe then deliberately LEAKED every child object
// it created (no vkDeviceWaitIdle, no destroys - a hung GPU must not hang
// the caller), so a caller that owns the device MUST NOT destroy or
// idle-wait it either: vkDestroyDevice with live children and in-flight
// work is the exact hang the bound exists to prevent. The POST leaks its
// throwaway device on this flag, mirroring its sibling probes.
Bool fenceWaitTimedOut = false;
String failureReason;
PrimitivesGeneratedNoXfbShapeMeasurement trianglesPlain;
PrimitivesGeneratedNoXfbShapeMeasurement trianglesDiscard;
// drawn = false when the device has no tessellationShader.
PrimitivesGeneratedNoXfbShapeMeasurement patchesDiscard;
};
// The device-level entry points the probe records with. Supplied by the caller
// because the two callers resolve them differently: the renderer passes its
// statically linked symbols (and its vkGetDeviceProcAddr-resolved EXT
// pointers), the driver POST passes vkGetInstanceProcAddr trampolines.
struct PrimitivesGeneratedNoXfbProbeFns {
PFN_vkCreateCommandPool vkCreateCommandPool = nullptr;
PFN_vkDestroyCommandPool vkDestroyCommandPool = nullptr;
PFN_vkAllocateCommandBuffers vkAllocateCommandBuffers = nullptr;
PFN_vkBeginCommandBuffer vkBeginCommandBuffer = nullptr;
PFN_vkEndCommandBuffer vkEndCommandBuffer = nullptr;
PFN_vkCreateQueryPool vkCreateQueryPool = nullptr;
PFN_vkDestroyQueryPool vkDestroyQueryPool = nullptr;
PFN_vkCmdResetQueryPool vkCmdResetQueryPool = nullptr;
PFN_vkCmdBeginQuery vkCmdBeginQuery = nullptr;
PFN_vkCmdEndQuery vkCmdEndQuery = nullptr;
PFN_vkCmdBeginQueryIndexedEXT vkCmdBeginQueryIndexedEXT = nullptr;
PFN_vkCmdEndQueryIndexedEXT vkCmdEndQueryIndexedEXT = nullptr;
PFN_vkCreateRenderPass vkCreateRenderPass = nullptr;
PFN_vkDestroyRenderPass vkDestroyRenderPass = nullptr;
PFN_vkCreateFramebuffer vkCreateFramebuffer = nullptr;
PFN_vkDestroyFramebuffer vkDestroyFramebuffer = nullptr;
PFN_vkCmdBeginRenderPass vkCmdBeginRenderPass = nullptr;
PFN_vkCmdEndRenderPass vkCmdEndRenderPass = nullptr;
PFN_vkCreateShaderModule vkCreateShaderModule = nullptr;
PFN_vkDestroyShaderModule vkDestroyShaderModule = nullptr;
PFN_vkCreatePipelineLayout vkCreatePipelineLayout = nullptr;
PFN_vkDestroyPipelineLayout vkDestroyPipelineLayout = nullptr;
PFN_vkCreateGraphicsPipelines vkCreateGraphicsPipelines = nullptr;
PFN_vkDestroyPipeline vkDestroyPipeline = nullptr;
PFN_vkCmdBindPipeline vkCmdBindPipeline = nullptr;
PFN_vkCmdDraw vkCmdDraw = nullptr;
PFN_vkCreateFence vkCreateFence = nullptr;
PFN_vkDestroyFence vkDestroyFence = nullptr;
PFN_vkQueueSubmit vkQueueSubmit = nullptr;
PFN_vkWaitForFences vkWaitForFences = nullptr;
PFN_vkGetQueryPoolResults vkGetQueryPoolResults = nullptr;
PFN_vkDeviceWaitIdle vkDeviceWaitIdle = nullptr;
};
struct PrimitivesGeneratedNoXfbProbeContext {
VkDevice device = VK_NULL_HANDLE;
VkQueue queue = VK_NULL_HANDLE;
Uint32 queueFamilyIndex = 0;
// The device was created with VK_EXT_transform_feedback, its
// transformFeedback feature, and advertises transformFeedbackQueries.
// Without this the probe has no subject and reports "did not run".
Bool transformFeedbackQueriesUsable = false;
// The device was created with VK_EXT_primitives_generated_query and BOTH
// its primitivesGeneratedQuery and ...WithRasterizerDiscard features;
// gates the dedicated-query control slots.
Bool primitivesGeneratedQueryUsable = false;
// The device was created with the pipelineStatisticsQuery feature; gates
// the statistics control slots. A probe with no control at all can still
// DETECT, but never qualifies a substitute.
Bool pipelineStatisticsEnabled = false;
// The device was created with the tessellationShader feature; gates the
// PATCHES shape.
Bool tessellationEnabled = false;
PrimitivesGeneratedNoXfbProbeFns fns;
};
// Records, submits and reads back the probe. Synchronous: waits on its own
// fence (bounded; on timeout it deliberately leaks its device objects rather
// than idle-wait a possibly hung GPU, mirroring the POST timestamp probe) and
// destroys everything it created. Never touches MG_State or renderer state -
// the caller only lends it a device and an otherwise idle queue.
PrimitivesGeneratedNoXfbMeasurement RunPrimitivesGeneratedNoXfbProbe(
const PrimitivesGeneratedNoXfbProbeContext& context);
// The verdict vocabulary. Pure function of the measurement, split from the
// Vulkan plumbing so a unit test can pin every mapping with synthetic numbers.
enum class PrimitivesGeneratedNoXfbVerdict : Uint8 {
// The probe did not run, or answered something that is neither healthy nor
// the defect (a half-count, a nonzero-but-wrong stream answer). Must never
// arm the reroute and must never be reported as the bug.
Inconclusive,
// Every drawn shape's stream query answered its exact expected count: the
// driver counts XFB-inactive draws and the existing path is correct.
StreamCounts,
// The defect is present (a drawn shape's stream query answered exactly 0)
// and the dedicated primitives-generated query answered every drawn shape
// exactly, the rasterizer-discard shapes included: the substitution is
// proven whole through the query Vulkan defines for exactly this GL
// target.
PrimitivesGeneratedExtSubstitute,
// The defect is present, the dedicated query did not qualify (absent, or
// silent like the stream query), and the statistics control answered EVERY
// drawn shape exactly - discard shapes included: the substitution is
// proven whole through clipping statistics.
StatisticsSubstitute,
// The defect is present and the statistics control is exact on the PLAIN
// shape but not on every drawn shape (llvmpipe's discard short-circuit
// does this to its statistics - its dedicated query is what rescues it to
// the verdict above). This verdict additionally GUARANTEES domination:
// every shape the statistics missed measured exactly 0 through the stream
// query too, so rerouting is never worse per draw - it repairs every
// shape the statistics answer exactly and leaves the rest at the 0 they
// already read. A measurement where the stream was EXACT on a shape the
// statistics missed does not qualify (rerouting would downgrade that
// shape) and falls to Unfixable instead. The shapes the substitute
// misses - the CTS's discarded shapes among them wherever they are the
// missed ones - stay broken, and the report must say which.
StatisticsSubstitutePlainOnly,
// The defect is present and no substitute qualifies: none is exact
// everywhere, and the plain-only fallback either misses the plain shape
// or fails the domination rule above. The honest verdict is the current
// behaviour.
Unfixable,
};
PrimitivesGeneratedNoXfbVerdict EvaluatePrimitivesGeneratedNoXfbVerdict(
const PrimitivesGeneratedNoXfbMeasurement& measurement);
// Which query pool the renderer routes GL_PRIMITIVES_GENERATED accumulation
// for XFB-inactive draws through.
enum class PrimGenRerouteKind : Uint8 {
None,
// VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT (needs the extension with both
// feature bits - see the context flag).
PrimitivesGeneratedExt,
// VK_QUERY_TYPE_PIPELINE_STATISTICS over clipping invocations (needs
// pipelineStatisticsQuery).
ClippingStatistics,
};
// The arming decision. Pure, so the override mapping is unit-pinnable:
// - ForceOff never reroutes;
// - ForceOn bypasses the verdict but never the structural checks: it takes
// the dedicated query where the device can host it, the statistics pool
// where only that exists, and nothing where neither does;
// - Auto follows the verdict: the dedicated query on
// PrimitivesGeneratedExtSubstitute, the statistics pool on
// StatisticsSubstitute and StatisticsSubstitutePlainOnly (each already
// implies its feature-backed control), and nothing otherwise.
PrimGenRerouteKind ChoosePrimitivesGeneratedReroute(MG_Config::QuirkOverride overrideSetting,
PrimitivesGeneratedNoXfbVerdict verdict,
Bool primitivesGeneratedQueryUsable,
Bool pipelineStatisticsEnabled);
} // namespace MobileGL::MG_Util::SelfTest
@@ -0,0 +1,24 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.tesc
// 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
//
// Tessellation control stage of the PATCHES variant of the
// primitives-generated-without-transform-feedback probe. Every level is 1, so with
// the evaluation stage's triangles domain the tessellator emits exactly one
// triangle per patch - the expected count the probe checks the queries against.
#version 450
layout(vertices = 1) out;
void main() {
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;
}
@@ -0,0 +1,19 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.tese
// 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
//
// Tessellation evaluation stage of the PATCHES variant of the
// primitives-generated-without-transform-feedback probe. Triangles domain: with the
// control stage's all-1 levels the tessellator emits exactly one triangle per
// patch. Like the vertex stage, it deliberately carries no Xfb execution mode.
#version 450
layout(triangles, equal_spacing, cw) in;
void main() {
gl_Position = vec4(gl_TessCoord.xy * 2.0 - 1.0, 0.0, 1.0);
}
@@ -0,0 +1,22 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.vert
// 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
//
// Vertex stage of the primitives-generated-without-transform-feedback probe
// (PrimitivesGeneratedNoXfbProbe.cpp), used by both triangle shapes (with and
// without rasterizer discard) and as the tessellation shapes' vertex stage.
// Deliberately carries NO Xfb execution mode: the probe's whole subject is what
// the transform-feedback stream query answers for a pipeline that captures
// nothing. Positions are distinct (a full-viewport triangle per three vertices)
// so no driver can excuse the primitive as degenerate before it reaches
// primitive assembly.
#version 450
void main() {
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_VertexIndex % 3], 0.0, 1.0);
}
@@ -0,0 +1,153 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbeSpv.h
// 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
// Generated from PrimitivesGeneratedNoXfbProbe.{vert,tesc,tese} with:
// glslangValidator --target-env vulkan1.1 -V PrimitivesGeneratedNoXfbProbe.<stage>
// (SPIR-V words dumped little-endian, six per line.)
//
// Regenerate whenever a probe shader changes; nothing else in the probe depends on
// the exact binary. None of the modules carries an Xfb execution mode - that is the
// probe's subject, see PrimitivesGeneratedNoXfbProbe.cpp.
#pragma once
#include <cstddef>
#include <cstdint>
namespace MobileGL::MG_Util::SelfTest {
inline constexpr std::uint32_t kPrimitivesGeneratedNoXfbProbeVertSpv[] = {
0x07230203, 0x00010300, 0x0008000b, 0x0000002a, 0x00000000, 0x00020011,
0x00000001, 0x0006000b, 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e,
0x00000000, 0x0003000e, 0x00000000, 0x00000001, 0x0007000f, 0x00000000,
0x00000004, 0x6e69616d, 0x00000000, 0x0000000d, 0x0000001a, 0x00030003,
0x00000002, 0x000001c2, 0x00040005, 0x00000004, 0x6e69616d, 0x00000000,
0x00060005, 0x0000000b, 0x505f6c67, 0x65567265, 0x78657472, 0x00000000,
0x00060006, 0x0000000b, 0x00000000, 0x505f6c67, 0x7469736f, 0x006e6f69,
0x00070006, 0x0000000b, 0x00000001, 0x505f6c67, 0x746e696f, 0x657a6953,
0x00000000, 0x00070006, 0x0000000b, 0x00000002, 0x435f6c67, 0x4470696c,
0x61747369, 0x0065636e, 0x00070006, 0x0000000b, 0x00000003, 0x435f6c67,
0x446c6c75, 0x61747369, 0x0065636e, 0x00030005, 0x0000000d, 0x00000000,
0x00060005, 0x0000001a, 0x565f6c67, 0x65747265, 0x646e4978, 0x00007865,
0x00050005, 0x0000001f, 0x65646e69, 0x6c626178, 0x00000065, 0x00030047,
0x0000000b, 0x00000002, 0x00050048, 0x0000000b, 0x00000000, 0x0000000b,
0x00000000, 0x00050048, 0x0000000b, 0x00000001, 0x0000000b, 0x00000001,
0x00050048, 0x0000000b, 0x00000002, 0x0000000b, 0x00000003, 0x00050048,
0x0000000b, 0x00000003, 0x0000000b, 0x00000004, 0x00040047, 0x0000001a,
0x0000000b, 0x0000002a, 0x00020013, 0x00000002, 0x00030021, 0x00000003,
0x00000002, 0x00030016, 0x00000006, 0x00000020, 0x00040017, 0x00000007,
0x00000006, 0x00000004, 0x00040015, 0x00000008, 0x00000020, 0x00000000,
0x0004002b, 0x00000008, 0x00000009, 0x00000001, 0x0004001c, 0x0000000a,
0x00000006, 0x00000009, 0x0006001e, 0x0000000b, 0x00000007, 0x00000006,
0x0000000a, 0x0000000a, 0x00040020, 0x0000000c, 0x00000003, 0x0000000b,
0x0004003b, 0x0000000c, 0x0000000d, 0x00000003, 0x00040015, 0x0000000e,
0x00000020, 0x00000001, 0x0004002b, 0x0000000e, 0x0000000f, 0x00000000,
0x00040017, 0x00000010, 0x00000006, 0x00000002, 0x0004002b, 0x00000008,
0x00000011, 0x00000003, 0x0004001c, 0x00000012, 0x00000010, 0x00000011,
0x0004002b, 0x00000006, 0x00000013, 0xbf800000, 0x0005002c, 0x00000010,
0x00000014, 0x00000013, 0x00000013, 0x0004002b, 0x00000006, 0x00000015,
0x40400000, 0x0005002c, 0x00000010, 0x00000016, 0x00000015, 0x00000013,
0x0005002c, 0x00000010, 0x00000017, 0x00000013, 0x00000015, 0x0006002c,
0x00000012, 0x00000018, 0x00000014, 0x00000016, 0x00000017, 0x00040020,
0x00000019, 0x00000001, 0x0000000e, 0x0004003b, 0x00000019, 0x0000001a,
0x00000001, 0x0004002b, 0x0000000e, 0x0000001c, 0x00000003, 0x00040020,
0x0000001e, 0x00000007, 0x00000012, 0x00040020, 0x00000020, 0x00000007,
0x00000010, 0x0004002b, 0x00000006, 0x00000023, 0x00000000, 0x0004002b,
0x00000006, 0x00000024, 0x3f800000, 0x00040020, 0x00000028, 0x00000003,
0x00000007, 0x00050036, 0x00000002, 0x00000004, 0x00000000, 0x00000003,
0x000200f8, 0x00000005, 0x0004003b, 0x0000001e, 0x0000001f, 0x00000007,
0x0004003d, 0x0000000e, 0x0000001b, 0x0000001a, 0x0005008b, 0x0000000e,
0x0000001d, 0x0000001b, 0x0000001c, 0x0003003e, 0x0000001f, 0x00000018,
0x00050041, 0x00000020, 0x00000021, 0x0000001f, 0x0000001d, 0x0004003d,
0x00000010, 0x00000022, 0x00000021, 0x00050051, 0x00000006, 0x00000025,
0x00000022, 0x00000000, 0x00050051, 0x00000006, 0x00000026, 0x00000022,
0x00000001, 0x00070050, 0x00000007, 0x00000027, 0x00000025, 0x00000026,
0x00000023, 0x00000024, 0x00050041, 0x00000028, 0x00000029, 0x0000000d,
0x0000000f, 0x0003003e, 0x00000029, 0x00000027, 0x000100fd, 0x00010038,
};
inline constexpr std::size_t kPrimitivesGeneratedNoXfbProbeVertSpvWordCount = sizeof(kPrimitivesGeneratedNoXfbProbeVertSpv) / sizeof(kPrimitivesGeneratedNoXfbProbeVertSpv[0]);
inline constexpr std::uint32_t kPrimitivesGeneratedNoXfbProbeTescSpv[] = {
0x07230203, 0x00010300, 0x0008000b, 0x0000001d, 0x00000000, 0x00020011,
0x00000003, 0x0006000b, 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e,
0x00000000, 0x0003000e, 0x00000000, 0x00000001, 0x0007000f, 0x00000001,
0x00000004, 0x6e69616d, 0x00000000, 0x0000000b, 0x0000001a, 0x00040010,
0x00000004, 0x0000001a, 0x00000001, 0x00030003, 0x00000002, 0x000001c2,
0x00040005, 0x00000004, 0x6e69616d, 0x00000000, 0x00070005, 0x0000000b,
0x545f6c67, 0x4c737365, 0x6c657665, 0x6574754f, 0x00000072, 0x00070005,
0x0000001a, 0x545f6c67, 0x4c737365, 0x6c657665, 0x656e6e49, 0x00000072,
0x00040047, 0x0000000b, 0x0000000b, 0x0000000b, 0x00030047, 0x0000000b,
0x0000000f, 0x00040047, 0x0000001a, 0x0000000b, 0x0000000c, 0x00030047,
0x0000001a, 0x0000000f, 0x00020013, 0x00000002, 0x00030021, 0x00000003,
0x00000002, 0x00030016, 0x00000006, 0x00000020, 0x00040015, 0x00000007,
0x00000020, 0x00000000, 0x0004002b, 0x00000007, 0x00000008, 0x00000004,
0x0004001c, 0x00000009, 0x00000006, 0x00000008, 0x00040020, 0x0000000a,
0x00000003, 0x00000009, 0x0004003b, 0x0000000a, 0x0000000b, 0x00000003,
0x00040015, 0x0000000c, 0x00000020, 0x00000001, 0x0004002b, 0x0000000c,
0x0000000d, 0x00000000, 0x0004002b, 0x00000006, 0x0000000e, 0x3f800000,
0x00040020, 0x0000000f, 0x00000003, 0x00000006, 0x0004002b, 0x0000000c,
0x00000011, 0x00000001, 0x0004002b, 0x0000000c, 0x00000013, 0x00000002,
0x0004002b, 0x0000000c, 0x00000015, 0x00000003, 0x0004002b, 0x00000007,
0x00000017, 0x00000002, 0x0004001c, 0x00000018, 0x00000006, 0x00000017,
0x00040020, 0x00000019, 0x00000003, 0x00000018, 0x0004003b, 0x00000019,
0x0000001a, 0x00000003, 0x00050036, 0x00000002, 0x00000004, 0x00000000,
0x00000003, 0x000200f8, 0x00000005, 0x00050041, 0x0000000f, 0x00000010,
0x0000000b, 0x0000000d, 0x0003003e, 0x00000010, 0x0000000e, 0x00050041,
0x0000000f, 0x00000012, 0x0000000b, 0x00000011, 0x0003003e, 0x00000012,
0x0000000e, 0x00050041, 0x0000000f, 0x00000014, 0x0000000b, 0x00000013,
0x0003003e, 0x00000014, 0x0000000e, 0x00050041, 0x0000000f, 0x00000016,
0x0000000b, 0x00000015, 0x0003003e, 0x00000016, 0x0000000e, 0x00050041,
0x0000000f, 0x0000001b, 0x0000001a, 0x0000000d, 0x0003003e, 0x0000001b,
0x0000000e, 0x00050041, 0x0000000f, 0x0000001c, 0x0000001a, 0x00000011,
0x0003003e, 0x0000001c, 0x0000000e, 0x000100fd, 0x00010038,
};
inline constexpr std::size_t kPrimitivesGeneratedNoXfbProbeTescSpvWordCount = sizeof(kPrimitivesGeneratedNoXfbProbeTescSpv) / sizeof(kPrimitivesGeneratedNoXfbProbeTescSpv[0]);
inline constexpr std::uint32_t kPrimitivesGeneratedNoXfbProbeTeseSpv[] = {
0x07230203, 0x00010300, 0x0008000b, 0x00000021, 0x00000000, 0x00020011,
0x00000003, 0x0006000b, 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e,
0x00000000, 0x0003000e, 0x00000000, 0x00000001, 0x0007000f, 0x00000002,
0x00000004, 0x6e69616d, 0x00000000, 0x0000000d, 0x00000012, 0x00030010,
0x00000004, 0x00000016, 0x00030010, 0x00000004, 0x00000001, 0x00030010,
0x00000004, 0x00000004, 0x00030003, 0x00000002, 0x000001c2, 0x00040005,
0x00000004, 0x6e69616d, 0x00000000, 0x00060005, 0x0000000b, 0x505f6c67,
0x65567265, 0x78657472, 0x00000000, 0x00060006, 0x0000000b, 0x00000000,
0x505f6c67, 0x7469736f, 0x006e6f69, 0x00070006, 0x0000000b, 0x00000001,
0x505f6c67, 0x746e696f, 0x657a6953, 0x00000000, 0x00070006, 0x0000000b,
0x00000002, 0x435f6c67, 0x4470696c, 0x61747369, 0x0065636e, 0x00070006,
0x0000000b, 0x00000003, 0x435f6c67, 0x446c6c75, 0x61747369, 0x0065636e,
0x00030005, 0x0000000d, 0x00000000, 0x00060005, 0x00000012, 0x545f6c67,
0x43737365, 0x64726f6f, 0x00000000, 0x00030047, 0x0000000b, 0x00000002,
0x00050048, 0x0000000b, 0x00000000, 0x0000000b, 0x00000000, 0x00050048,
0x0000000b, 0x00000001, 0x0000000b, 0x00000001, 0x00050048, 0x0000000b,
0x00000002, 0x0000000b, 0x00000003, 0x00050048, 0x0000000b, 0x00000003,
0x0000000b, 0x00000004, 0x00040047, 0x00000012, 0x0000000b, 0x0000000d,
0x00020013, 0x00000002, 0x00030021, 0x00000003, 0x00000002, 0x00030016,
0x00000006, 0x00000020, 0x00040017, 0x00000007, 0x00000006, 0x00000004,
0x00040015, 0x00000008, 0x00000020, 0x00000000, 0x0004002b, 0x00000008,
0x00000009, 0x00000001, 0x0004001c, 0x0000000a, 0x00000006, 0x00000009,
0x0006001e, 0x0000000b, 0x00000007, 0x00000006, 0x0000000a, 0x0000000a,
0x00040020, 0x0000000c, 0x00000003, 0x0000000b, 0x0004003b, 0x0000000c,
0x0000000d, 0x00000003, 0x00040015, 0x0000000e, 0x00000020, 0x00000001,
0x0004002b, 0x0000000e, 0x0000000f, 0x00000000, 0x00040017, 0x00000010,
0x00000006, 0x00000003, 0x00040020, 0x00000011, 0x00000001, 0x00000010,
0x0004003b, 0x00000011, 0x00000012, 0x00000001, 0x00040017, 0x00000013,
0x00000006, 0x00000002, 0x0004002b, 0x00000006, 0x00000016, 0x40000000,
0x0004002b, 0x00000006, 0x00000018, 0x3f800000, 0x0004002b, 0x00000006,
0x0000001b, 0x00000000, 0x00040020, 0x0000001f, 0x00000003, 0x00000007,
0x00050036, 0x00000002, 0x00000004, 0x00000000, 0x00000003, 0x000200f8,
0x00000005, 0x0004003d, 0x00000010, 0x00000014, 0x00000012, 0x0007004f,
0x00000013, 0x00000015, 0x00000014, 0x00000014, 0x00000000, 0x00000001,
0x0005008e, 0x00000013, 0x00000017, 0x00000015, 0x00000016, 0x00050050,
0x00000013, 0x00000019, 0x00000018, 0x00000018, 0x00050083, 0x00000013,
0x0000001a, 0x00000017, 0x00000019, 0x00050051, 0x00000006, 0x0000001c,
0x0000001a, 0x00000000, 0x00050051, 0x00000006, 0x0000001d, 0x0000001a,
0x00000001, 0x00070050, 0x00000007, 0x0000001e, 0x0000001c, 0x0000001d,
0x0000001b, 0x00000018, 0x00050041, 0x0000001f, 0x00000020, 0x0000000d,
0x0000000f, 0x0003003e, 0x00000020, 0x0000001e, 0x000100fd, 0x00010038,
};
inline constexpr std::size_t kPrimitivesGeneratedNoXfbProbeTeseSpvWordCount = sizeof(kPrimitivesGeneratedNoXfbProbeTeseSpv) / sizeof(kPrimitivesGeneratedNoXfbProbeTeseSpv[0]);
} // namespace MobileGL::MG_Util::SelfTest
@@ -154,6 +154,20 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// demoted module is the one that works everywhere, so it is what a standalone compile
// (an internal shader object, a unit test) gets.
Bool ConsumesFloat64Natively() const { return HasBackend() && params.SupportsShaderFloat64; }
// Whether the tessellation / geometry gl_PointSize demotion is ARMED for this env -
// i.e. the backend declared it cannot host the capability. Deliberately requiring a
// backend, opposite in shape to ConsumesFloat64Natively's fallback but for the same
// conservatism: the fp64 demotion is the module that works everywhere, while this
// one rewrites interfaces and capture names, so the no-backend answer (standalone
// compiles, unit tests) is the untouched module. Like nativeFloat64, each bit is L1
// key material of its own (SpirvTranslationKeyInputs), never part of the frontend
// fingerprint: glslang produces the same thing either way.
Bool DemotesTessellationPointSize() const {
return HasBackend() && !params.SupportsTessellationPointSize;
}
Bool DemotesGeometryPointSize() const {
return HasBackend() && !params.SupportsGeometryPointSize;
}
// Matches the historical rule exactly: with no active backend every extension counts
// as advertised, because the frontend then has nothing to gate against.
Bool IsExtensionAdvertised(GLExtension extension) const {
@@ -45,12 +45,14 @@
#include "SpirvPasses/ClampMultisampleFetchPass.h"
#include "SpirvPasses/PrivateToEntryLocalPass.h"
#include "SpirvPasses/StripUniformLocationsPass.h"
#include "SpirvPasses/StripIoBlockLocationsPass.h"
#include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h"
#include "SpirvPasses/StripNoPerspectivePass.h"
#include "SpirvPasses/EmulateNoPerspectivePass.h"
#include "SpirvPasses/LegalizeFragmentOutputIndexPass.h"
#include "SpirvPasses/LegalizeResourceArrayIndexPass.h"
#include "SpirvPasses/FlattenAtomicCounterBlockPass.h"
#include "SpirvPasses/DemotePointSizePass.h"
#include "spirv-tools/libspirv.h"
#include "spirv-tools/optimizer.hpp"
#include "source/opt/build_module.h"
@@ -815,6 +817,326 @@ namespace MobileGL {
return false;
}
namespace {
namespace opt_analysis = spvtools::opt::analysis;
// Locations one value of `type` consumes (GL 4.6 core 11.1.2.1). Unknown
// shapes OVERESTIMATE (4) rather than fail: this feeds the free-location
// choice for the demoted point-size carrier, where an overestimate wastes a
// couple of slots and an underestimate aliases a live varying.
Uint32 ConservativeLocationSpan(const opt_analysis::Type* type) {
constexpr Uint32 kUnknownSpan = 4;
if (type == nullptr) return kUnknownSpan;
if (type->AsFloat() != nullptr || type->AsInteger() != nullptr ||
type->AsBool() != nullptr) {
return 1u;
}
if (const auto* vector = type->AsVector()) {
// 64-bit INTEGER elements count exactly like 64-bit floats:
// ARB_gpu_shader_int64 extends 11.1.2.1's double-precision rule
// verbatim to i64/u64, and DirectVulkan advertises that extension
// unconditionally - so answering "one location" for an i64vec4 would
// place the carrier on the SECOND location that varying already owns,
// which is the underestimate this function's header forbids.
const auto* element = vector->element_type();
const auto* elementFloat = element->AsFloat();
const auto* elementInteger = element->AsInteger();
const Bool is64Bit = (elementFloat != nullptr && elementFloat->width() == 64) ||
(elementInteger != nullptr && elementInteger->width() == 64);
return (is64Bit && vector->element_count() > 2) ? 2u : 1u;
}
if (const auto* matrix = type->AsMatrix()) {
return ConservativeLocationSpan(matrix->element_type()) * matrix->element_count();
}
if (const auto* array = type->AsArray()) {
const auto& lengthWords = array->length_info().words;
if (lengthWords.size() != 2 ||
lengthWords[0] !=
static_cast<Uint32>(opt_analysis::Array::LengthInfo::kConstant)) {
return kUnknownSpan;
}
return ConservativeLocationSpan(array->element_type()) * std::max(lengthWords[1], 1u);
}
if (const auto* strct = type->AsStruct()) {
Uint32 sum = 0;
for (const auto* member : strct->element_types()) {
sum += ConservativeLocationSpan(member);
}
return std::max(sum, 1u);
}
return kUnknownSpan;
}
// One BuildModule per module answers all three questions the program-scoped
// demotion driver asks: which point-size capability the module declares, and
// one past the highest Input/Output location slot it consumes (so the carrier
// can be placed beyond every varying of every stage).
struct PointSizeModuleProbe {
Bool parsed = false;
Bool declaresTessellationPointSize = false;
Bool declaresGeometryPointSize = false;
Uint32 locationSlotEnd = 0;
};
PointSizeModuleProbe ProbePointSizeModule(const Vector<Uint32>& spirv) {
PointSizeModuleProbe probe;
if (spirv.empty()) {
probe.parsed = true; // an absent stage constrains nothing
return probe;
}
std::unique_ptr<spvtools::opt::IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1, MakeSpirvMessageConsumer("ProbePointSizeModule"), spirv.data(),
spirv.size());
if (!context) return probe;
probe.parsed = true;
for (const spvtools::opt::Instruction& capability : context->capabilities()) {
if (capability.NumInOperands() < 1) continue;
const auto declared =
static_cast<spv::Capability>(capability.GetSingleWordInOperand(0));
if (declared == spv::Capability::TessellationPointSize) {
probe.declaresTessellationPointSize = true;
} else if (declared == spv::Capability::GeometryPointSize) {
probe.declaresGeometryPointSize = true;
}
}
spv::ExecutionModel model = spv::ExecutionModel::Max;
for (spvtools::opt::Instruction& entryPoint : context->module()->entry_points()) {
model = static_cast<spv::ExecutionModel>(entryPoint.GetSingleWordInOperand(0));
break;
}
// Per-vertex interfaces are arrayed one level deeper than the locations
// they consume; peel that level, but never off a per-patch output.
const Bool peelInputs = model == spv::ExecutionModel::TessellationControl ||
model == spv::ExecutionModel::TessellationEvaluation ||
model == spv::ExecutionModel::Geometry;
const Bool peelOutputs = model == spv::ExecutionModel::TessellationControl;
std::unordered_set<Uint32> patchDecorated;
for (spvtools::opt::Instruction& annotation : context->annotations()) {
if (annotation.opcode() == spv::Op::OpDecorate && annotation.NumInOperands() >= 2 &&
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) ==
spv::Decoration::Patch) {
patchDecorated.insert(annotation.GetSingleWordInOperand(0));
}
}
auto* defUse = context->get_def_use_mgr();
auto* typeMgr = context->get_type_mgr();
for (spvtools::opt::Instruction& annotation : context->annotations()) {
if (annotation.opcode() == spv::Op::OpDecorate && annotation.NumInOperands() >= 3 &&
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) ==
spv::Decoration::Location) {
const Uint32 location = annotation.GetSingleWordInOperand(2);
Uint32 span = 1;
spvtools::opt::Instruction* var =
defUse->GetDef(annotation.GetSingleWordInOperand(0));
if (var != nullptr && var->opcode() == spv::Op::OpVariable) {
const auto storage =
static_cast<spv::StorageClass>(var->GetSingleWordInOperand(0));
// Two location namespaces are NOT varying slots and must not
// shrink the carrier budget: vertex-stage inputs (attribute
// locations) and fragment-stage outputs (draw buffers).
if ((model == spv::ExecutionModel::Vertex &&
storage == spv::StorageClass::Input) ||
(model == spv::ExecutionModel::Fragment &&
storage == spv::StorageClass::Output)) {
continue;
}
spvtools::opt::Instruction* pointerType = defUse->GetDef(var->type_id());
if (pointerType != nullptr &&
pointerType->opcode() == spv::Op::OpTypePointer) {
const opt_analysis::Type* pointee =
typeMgr->GetType(pointerType->GetSingleWordInOperand(1));
const Bool peel =
((storage == spv::StorageClass::Input && peelInputs) ||
(storage == spv::StorageClass::Output && peelOutputs)) &&
patchDecorated.count(var->result_id()) == 0;
if (peel && pointee != nullptr && pointee->AsArray() != nullptr) {
pointee = pointee->AsArray()->element_type();
}
span = ConservativeLocationSpan(pointee);
}
}
probe.locationSlotEnd = std::max(probe.locationSlotEnd, location + span);
} else if (annotation.opcode() == spv::Op::OpMemberDecorate &&
annotation.NumInOperands() >= 4 &&
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(2)) ==
spv::Decoration::Location) {
const Uint32 member = annotation.GetSingleWordInOperand(1);
const Uint32 location = annotation.GetSingleWordInOperand(3);
Uint32 span = 1;
spvtools::opt::Instruction* structType =
defUse->GetDef(annotation.GetSingleWordInOperand(0));
if (structType != nullptr && structType->opcode() == spv::Op::OpTypeStruct &&
member < structType->NumInOperands()) {
span = ConservativeLocationSpan(
typeMgr->GetType(structType->GetSingleWordInOperand(member)));
}
probe.locationSlotEnd = std::max(probe.locationSlotEnd, location + span);
}
}
return probe;
}
// Interior boundary carrier names, spelled by the PRODUCING stage so both
// sides of one boundary agree textually as well as by location. The capture
// stage's output uses POINT_SIZE_CAPTURE_CARRIER_NAME instead. None of these
// may embed the token "gl_PointSize" - see the constant's comment.
const char* PointSizeBoundaryCarrierName(const GLenum producerStage) {
switch (producerStage) {
case GL_VERTEX_SHADER:
return "mg_PointSizeIo0";
case GL_TESS_CONTROL_SHADER:
return "mg_PointSizeIo1";
case GL_TESS_EVALUATION_SHADER:
return "mg_PointSizeIo2";
default:
return "mg_PointSizeIo0";
}
}
// Past this the carrier would sit above what a minimum-spec varying budget can
// address; such a program keeps its honest decline instead.
constexpr Uint32 kMaxDemotedPointSizeCarrierLocation = 30;
} // namespace
Bool ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
Vector<Vector<Uint32>>& modules, const Vector<GLenum>& shaderTypes,
const Bool demoteTessellation, const Bool demoteGeometry,
const Bool captureRequestsPointSize, PointSizeDemotionOutcome& outcome,
const bool validateOutput, const bool enableSpirvValidation) {
outcome = {};
if (!demoteTessellation && !demoteGeometry) return true;
// The pre-rasterization chain, in pipeline order, as indices into `modules`.
Int stageIndex[4] = {-1, -1, -1, -1}; // VS, TCS, TES, GS
for (SizeT i = 0; i < shaderTypes.size() && i < modules.size(); ++i) {
switch (shaderTypes[i]) {
case GL_VERTEX_SHADER: stageIndex[0] = static_cast<Int>(i); break;
case GL_TESS_CONTROL_SHADER: stageIndex[1] = static_cast<Int>(i); break;
case GL_TESS_EVALUATION_SHADER: stageIndex[2] = static_cast<Int>(i); break;
case GL_GEOMETRY_SHADER: stageIndex[3] = static_cast<Int>(i); break;
default: break;
}
}
if (stageIndex[1] < 0 && stageIndex[2] < 0 && stageIndex[3] < 0) return true;
// One probe per module: the capability facts arm the verdict, the location
// scan places the carrier past every varying of every stage (the location is
// shared program-wide, so it has to clear all of them at once).
Bool anyTessellationUse = false;
Bool anyGeometryUse = false;
Uint32 carrierLocation = 0;
for (const auto& module : modules) {
const PointSizeModuleProbe probe = ProbePointSizeModule(module);
if (!probe.parsed) {
// Unparseable is not a verdict; the module is already broken for
// other reasons and owns its own failure.
return true;
}
anyTessellationUse |= probe.declaresTessellationPointSize;
anyGeometryUse |= probe.declaresGeometryPointSize;
carrierLocation = std::max(carrierLocation, probe.locationSlotEnd);
}
if (!((anyTessellationUse && demoteTessellation) ||
(anyGeometryUse && demoteGeometry))) {
return true;
}
if (carrierLocation > kMaxDemotedPointSizeCarrierLocation) {
outcome.declineDetail = std::format(
"the program's varyings already reach location {}, past the carrier budget",
carrierLocation);
return true;
}
// GL 4.6 core 13.3: capture reads the last capture-capable stage - geometry,
// else evaluation, else the vertex stage (whose built-in needs no demotion).
const Int captureStage = stageIndex[3] >= 0 ? 3 : (stageIndex[2] >= 0 ? 2 : -1);
constexpr GLenum kStageEnum[4] = {GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER,
GL_TESS_EVALUATION_SHADER, GL_GEOMETRY_SHADER};
// Back to front, so each stage's "I now read the carrier" report can force the
// producing stage's output carrier into existence - Vulkan requires every
// consumed input to be produced (VUID-RuntimeSpirv-OpEntryPoint-08743), and an
// ES link may reject a statically read input with no producing output.
Vector<Vector<Uint32>> rewritten(modules.size());
Bool rewrote[4] = {false, false, false, false};
Bool forceOutput[4] = {false, false, false, false};
if (captureStage >= 0 && captureRequestsPointSize) {
forceOutput[captureStage] = true;
}
for (Int stage = 3; stage >= 0; --stage) {
const Int moduleIndex = stageIndex[stage];
if (moduleIndex < 0) continue;
Int producer = stage - 1;
while (producer >= 0 && stageIndex[producer] < 0) --producer;
DemotePointSizeOptions options;
options.location = carrierLocation;
options.inputCarrierName = PointSizeBoundaryCarrierName(
producer >= 0 ? kStageEnum[producer]
// A separable program whose first present stage already
// consumes the carrier: the producer lives in another
// program. Name by the conventional producer of this
// stage's boundary; matching across programs is by
// location and is documented residue either way.
: kStageEnum[stage > 0 ? stage - 1 : 0]);
options.outputCarrierName = stage == captureStage
? String(POINT_SIZE_CAPTURE_CARRIER_NAME)
: String(PointSizeBoundaryCarrierName(kStageEnum[stage]));
options.forceOutputCarrier = forceOutput[stage];
// A vertex stage with nothing downstream consuming the carrier needs no
// mirror and stays byte-identical without an optimizer round trip.
if (stage == 0 && !options.forceOutputCarrier) continue;
DemotePointSizeReport report;
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(
DemotePointSizePass::CreateDemotePointSizePass(options, &report));
if (!RunOptimizerChecked("DemoteTessellationGeometryPointSizeForProgram", optimizer,
modules[moduleIndex], rewritten[moduleIndex],
validateOutput, enableSpirvValidation)) {
return false; // modules untouched: nothing was committed
}
if (report.declined) {
outcome.declineDetail = Move(report.declineReason);
return true; // byte-identical decline; the existing refusals stay armed
}
// AN EVALUATION STAGE WITH NO CONTROL STAGE THAT NOW READS A LOCATED
// INPUT. GL lets the evaluation stage sit straight on the vertex stage,
// and both backends stand a SYNTHESIZED pass-through control stage in
// between - one that forwards gl_Position and nothing else. Their guard
// for that is literally "does this module read a located input"
// (ModuleReadsLocatedInput / ReflectPassthroughTessControlNeed), so the
// carrier this pass just created would turn the very program the demotion
// exists to rescue into a declined one, reported against a varying name
// the application never wrote. Declining here keeps the modules
// byte-identical and leaves the honest built-in refusal in charge; only
// teaching the synthesized stage to forward the carrier could do better.
if (stage == 2 && stageIndex[1] < 0 && report.createdInputCarrier) {
outcome.declineDetail =
"an evaluation stage reads gl_in point size with no control stage to "
"carry it; the synthesized pass-through cannot forward the carrier";
return true;
}
rewrote[stage] = true;
if (report.createdInputCarrier && producer >= 0) {
forceOutput[producer] = true;
}
}
// Atomic commit: every stage rewritten together or none at all.
for (Int stage = 0; stage < 4; ++stage) {
if (!rewrote[stage]) continue;
modules[stageIndex[stage]] = Move(rewritten[stageIndex[stage]]);
}
outcome.demoted = true;
return true;
}
Bool ShaderCompiler::ModuleDeclaresFloat64(const Vector<Uint32>& spirv) {
if (spirv.empty()) {
// Same reasoning as ModuleDeclaresBufferTextureSampler: a stage that produced
@@ -1227,6 +1549,31 @@ namespace MobileGL {
outputBinary, true, enableSpirvValidation);
}
bool ShaderCompiler::StripIoBlockLocationsForEssl(const Vector<Uint32>& inputBinary,
const bool stripInputBlocks,
const bool stripOutputBlocks,
bool& strippedAny,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
using namespace spvtools;
strippedAny = false;
if (!stripInputBlocks && !stripOutputBlocks) return false;
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(StripIoBlockLocationsPass::CreateStripIoBlockLocationsPass(
stripInputBlocks, stripOutputBlocks, &strippedAny));
// NOT VALIDATED, and that is the point of the pass rather than an oversight.
// Vulkan SPIR-V requires a Location on every user-defined Input/Output variable
// ([VUID-StandaloneSpirv-Location-04915]), so a module whose interface blocks
// have deliberately lost theirs fails spirv-val by construction. It never
// reaches a driver as SPIR-V: the caller runs this last in the DirectGLES chain
// and hands the result straight to SPIRV-Cross, which needs no location to
// print a block. Validating here would latch a failure on every affected
// program and teach the counter to cry wolf.
return RunOptimizerChecked("StripIoBlockLocationsForEssl", optimizer, inputBinary,
outputBinary, false, enableSpirvValidation);
}
bool ShaderCompiler::PackDoubleVertexInputsForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
@@ -154,6 +154,20 @@ namespace MobileGL {
std::set<String>& renamedBlockNames,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Drops the Location (and Component) decoration from inter-stage interface
// BLOCK variables, so SPIRV-Cross emits them unqualified and ES matches them
// by block name plus member sequence. `stripInputBlocks` covers the blocks
// this stage consumes and `stripOutputBlocks` the ones it produces - armed
// separately because an interface whose other end is in a DIFFERENT program
// must keep the location that matches it there. `strippedAny` reports whether
// this stage actually had one. The Mali ES driver loses the payload of a
// located block across any tessellation or geometry boundary; only for the
// DirectGLES transpile path, and only when the driver POST says so. See
// StripIoBlockLocationsPass.
static bool StripIoBlockLocationsForEssl(const Vector<Uint32>& inputBinary,
bool stripInputBlocks, bool stripOutputBlocks,
bool& strippedAny, Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Drops RelaxedPrecision member decorations from uniform-block structs so
// SPIRV-Cross prints the same (highp) member precision in every stage; ES
// drivers reject cross-stage uniform blocks whose member precisions differ.
@@ -548,6 +562,61 @@ namespace MobileGL {
// the module parse costs nothing on a device that has it.
static Bool ModuleDeclaresTessellationOrGeometryPointSize(const Vector<Uint32>& spirv);
// ---- gl_PointSize demotion for devices without the capability above ----
// The name of the demoted program's LAST capture-capable stage's point-size
// carrier. It is the contract three parties meet at: the demotion pass names
// the variable, DirectVulkan's XfbCaptureDecoratePass binds a "gl_PointSize"
// capture to it instead of mirroring the (no longer accessed) built-in, and
// DirectGLES respells the driver-side glTransformFeedbackVaryings request
// with it. Deliberately NOT containing the substring "gl_PointSize":
// DirectGLES's extension-request gate is a text search for that token over
// the emitted ESSL, and a carrier name embedding it would re-arm the decline
// this demotion exists to retire.
static constexpr const char* POINT_SIZE_CAPTURE_CARRIER_NAME = "mg_PointSizeCapture";
// What the program-scoped demotion left behind. `demoted` false with an empty
// detail means the program never needed it (no tessellation/geometry stage
// accesses the built-in, or the device hosts it); false WITH a detail means a
// module shape the pass cannot express - the modules are byte-identical and
// the existing decline paths (Espryt's missing-extension compile failure,
// Magma's pointSizeCapabilityUnsupported refusal) stay in charge of it.
struct PointSizeDemotionOutcome {
Bool demoted = false;
String declineDetail;
};
// Demotes gl_PointSize across a WHOLE program's pre-rasterization chain into
// ordinary float varyings at one shared free location, so a device that
// advertises neither ES tessellation/geometry_point_size extension nor
// Vulkan's shaderTessellationAndGeometryPointSize can still run programs
// whose tessellation/geometry stages merely CARRY the value (transform
// feedback and gl_in[].gl_PointSize reads). Runs after
// SanitizeAndOptimizeBinary, on the final shared modules both backends
// consume, and is atomic per program: every stage is rewritten or none is,
// because a consumer whose producer kept the built-in would read garbage.
// `demoteTessellation` / `demoteGeometry` are the env verdicts (the device
// LACKS that capability); the per-program half of the decision - whether any
// module actually declares TessellationPointSize / GeometryPointSize - is
// probed here. `captureRequestsPointSize` forces the capture-capable last
// stage to declare its carrier even when it never writes the built-in, so a
// by-name capture always has something to bind to. Returns false only when
// the optimizer itself failed (modules untouched); a shape decline is
// reported through `outcome` and also leaves the modules untouched. See
// DemotePointSizePass for the per-module rewrite and its honest residue.
//
// Two declines are PROGRAM-shaped and therefore live here rather than in the
// pass: a carrier that would land past the minimum-spec varying budget, and
// an evaluation stage reading gl_in point size with NO control stage - the
// synthesized pass-through control stage both backends stand in that gap
// forwards gl_Position alone, so the input carrier would strand the value and
// trip the backends' own "reads a located input" refusal against a name the
// application never wrote.
static Bool DemoteTessellationGeometryPointSizeForProgram(
Vector<Vector<Uint32>>& modules, const Vector<GLenum>& shaderTypes,
Bool demoteTessellation, Bool demoteGeometry, Bool captureRequestsPointSize,
PointSizeDemotionOutcome& outcome, bool validateOutput = true,
bool enableSpirvValidation = false);
// True when the module still declares a 64-bit float type. After
// SanitizeAndOptimizeBinary that can only mean DemoteFloat64Pass declined the
// module (see its header for the two operations that make it decline), which is
@@ -0,0 +1,805 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.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 "DemotePointSizePass.h"
#include "spirv.hpp"
#include "source/opt/constants.h"
#include "source/opt/def_use_manager.h"
#include "source/opt/instruction.h"
#include "source/opt/ir_context.h"
#include "source/opt/module.h"
#include "source/opt/type_manager.h"
#include "source/opt/types.h"
#include "source/util/make_unique.h"
#include "source/util/string_utils.h"
#include <format>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
using spvtools::opt::Operand;
namespace analysis = spvtools::opt::analysis;
spv::ExecutionModel EntryExecutionModel(IRContext* ctx) {
for (Instruction& ep : ctx->module()->entry_points()) {
return static_cast<spv::ExecutionModel>(ep.GetSingleWordInOperand(0));
}
return spv::ExecutionModel::Max;
}
Instruction* EntryPoint(IRContext* ctx) {
for (Instruction& ep : ctx->module()->entry_points()) {
return &ep;
}
return nullptr;
}
// OpTypePointer <storage-class> <pointee>
uint32_t VariablePointeeType(IRContext* ctx, Instruction* var) {
Instruction* ptrType = ctx->get_def_use_mgr()->GetDef(var->type_id());
if (ptrType == nullptr || ptrType->opcode() != spv::Op::OpTypePointer) return 0;
return ptrType->GetSingleWordInOperand(1);
}
bool IsFloat32Type(IRContext* ctx, uint32_t typeId) {
Instruction* t = ctx->get_def_use_mgr()->GetDef(typeId);
return t != nullptr && t->opcode() == spv::Op::OpTypeFloat &&
t->NumInOperands() >= 1 && t->GetSingleWordInOperand(0) == 32;
}
// The value of a plain 32-bit OpConstant, or false (spec constants and anything
// else make the caller decline rather than guess).
bool PlainConstantValue(IRContext* ctx, uint32_t id, uint32_t& outValue) {
Instruction* def = ctx->get_def_use_mgr()->GetDef(id);
if (def == nullptr || def->opcode() != spv::Op::OpConstant) return false;
if (def->NumInOperands() != 1) return false;
outValue = def->GetSingleWordInOperand(0);
return true;
}
uint32_t Float32Type(IRContext* ctx) {
analysis::Float f(32);
return ctx->get_type_mgr()->GetTypeInstruction(&f);
}
// An OpTypeArray of float32 with the given length constant, reusing an existing
// declaration when one exists.
uint32_t ArrayOfFloat32Type(IRContext* ctx, uint32_t lengthConstId, uint32_t lengthValue) {
analysis::Float f(32);
analysis::Type* floatReg = ctx->get_type_mgr()->GetRegisteredType(&f);
const analysis::Array::LengthInfo lengthInfo{
lengthConstId,
{static_cast<uint32_t>(analysis::Array::LengthInfo::kConstant), lengthValue}};
analysis::Array arr(floatReg, lengthInfo);
return ctx->get_type_mgr()->GetTypeInstruction(&arr);
}
void AddNameFor(IRContext* ctx, uint32_t id, const String& name) {
std::vector<Operand> operands;
operands.push_back({SPV_OPERAND_TYPE_ID, {id}});
operands.push_back(
{SPV_OPERAND_TYPE_LITERAL_STRING, spvtools::utils::MakeVector(name)});
ctx->AddDebug2Inst(
spvtools::MakeUnique<Instruction>(ctx, spv::Op::OpName, 0, 0, operands));
}
void AddLocationDecoration(IRContext* ctx, uint32_t id, uint32_t location) {
ctx->AddAnnotationInst(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpDecorate, 0, 0,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {id}},
{SPV_OPERAND_TYPE_DECORATION,
{static_cast<uint32_t>(spv::Decoration::Location)}},
{SPV_OPERAND_TYPE_LITERAL_INTEGER, {location}}}));
}
// A fresh interface variable: declared, named, located, listed on the entry
// point, and registered with the def-use manager so ReplaceAllUsesWith may name
// it before the end-of-pass invalidation.
uint32_t CreateCarrierVariable(IRContext* ctx, Instruction* entryPoint, uint32_t pointeeTypeId,
spv::StorageClass storage, const String& name,
uint32_t location) {
const uint32_t ptrTypeId = ctx->get_type_mgr()->FindPointerToType(pointeeTypeId, storage);
if (ptrTypeId == 0) return 0;
const uint32_t varId = ctx->TakeNextId();
auto var = spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpVariable, ptrTypeId, varId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_STORAGE_CLASS, {static_cast<uint32_t>(storage)}}});
Instruction* varInst = var.get();
ctx->AddGlobalValue(std::move(var));
ctx->get_def_use_mgr()->AnalyzeInstDefUse(varInst);
AddNameFor(ctx, varId, name);
AddLocationDecoration(ctx, varId, location);
entryPoint->AddOperand({SPV_OPERAND_TYPE_ID, {varId}});
return varId;
}
} // namespace
spvtools::opt::Pass::Status DemotePointSizePass::Process() {
auto* ctx = context();
auto* defUse = ctx->get_def_use_mgr();
const spv::ExecutionModel model = EntryExecutionModel(ctx);
Instruction* entryPoint = EntryPoint(ctx);
if (entryPoint == nullptr) return Status::SuccessWithoutChange;
const bool isVertex = model == spv::ExecutionModel::Vertex;
const bool isTessControl = model == spv::ExecutionModel::TessellationControl;
const bool isTessEval = model == spv::ExecutionModel::TessellationEvaluation;
const bool isGeometry = model == spv::ExecutionModel::Geometry;
if (!isVertex && !isTessControl && !isTessEval && !isGeometry) {
return Status::SuccessWithoutChange;
}
const auto decline = [&](String reason) {
if (m_report != nullptr) {
m_report->declined = true;
m_report->declineReason = Move(reason);
}
return Status::SuccessWithoutChange;
};
// ---- discovery: where does PointSize live in this module ------------------
// Member form: every struct type with a member decorated BuiltIn PointSize.
struct MemberSite {
uint32_t structId = 0;
uint32_t memberIndex = 0;
};
std::vector<MemberSite> memberSites;
// Standalone form: a variable decorated BuiltIn PointSize directly.
std::vector<Instruction*> standaloneVars;
std::vector<Instruction*> standaloneBuiltInDecorations;
// Where clip and cull distance live, by (struct, member). DECLARATION is not
// the question - glslang emits the whole four-member gl_PerVertex block into
// every stage, touched or not - so these sites are only the starting point
// for the ACCESS scan the control-stage decline below performs, which is the
// same thing SPIRV-Cross's own clip_distance_count counts.
std::vector<MemberSite> clipCullSites;
std::vector<Instruction*> standaloneClipCullVars;
const auto isClipOrCull = [](const uint32_t builtIn) {
return static_cast<spv::BuiltIn>(builtIn) == spv::BuiltIn::ClipDistance ||
static_cast<spv::BuiltIn>(builtIn) == spv::BuiltIn::CullDistance;
};
for (Instruction& ann : ctx->annotations()) {
if (ann.opcode() == spv::Op::OpMemberDecorate && ann.NumInOperands() >= 4 &&
static_cast<spv::Decoration>(ann.GetSingleWordInOperand(2)) ==
spv::Decoration::BuiltIn) {
if (static_cast<spv::BuiltIn>(ann.GetSingleWordInOperand(3)) ==
spv::BuiltIn::PointSize) {
memberSites.push_back(
{ann.GetSingleWordInOperand(0), ann.GetSingleWordInOperand(1)});
} else if (isClipOrCull(ann.GetSingleWordInOperand(3))) {
clipCullSites.push_back(
{ann.GetSingleWordInOperand(0), ann.GetSingleWordInOperand(1)});
}
} else if (ann.opcode() == spv::Op::OpDecorate && ann.NumInOperands() >= 3 &&
static_cast<spv::Decoration>(ann.GetSingleWordInOperand(1)) ==
spv::Decoration::BuiltIn) {
if (static_cast<spv::BuiltIn>(ann.GetSingleWordInOperand(2)) ==
spv::BuiltIn::PointSize) {
Instruction* var = defUse->GetDef(ann.GetSingleWordInOperand(0));
if (var != nullptr && var->opcode() == spv::Op::OpVariable) {
standaloneVars.push_back(var);
standaloneBuiltInDecorations.push_back(&ann);
}
} else if (isClipOrCull(ann.GetSingleWordInOperand(2))) {
Instruction* var = defUse->GetDef(ann.GetSingleWordInOperand(0));
if (var != nullptr && var->opcode() == spv::Op::OpVariable) {
standaloneClipCullVars.push_back(var);
}
}
}
}
const auto memberIndexIn = [&](uint32_t structId, uint32_t& outMember) {
for (const MemberSite& site : memberSites) {
if (site.structId == structId) {
outMember = site.memberIndex;
return true;
}
}
return false;
};
// The gl_PerVertex-shaped interface variables: Input/Output variables whose
// pointee is (an array of) a struct carrying a PointSize member.
struct BlockVar {
Instruction* var = nullptr;
spv::StorageClass storage = spv::StorageClass::Output;
bool arrayed = false;
uint32_t arrayLengthConstId = 0;
uint32_t arrayLengthValue = 0;
uint32_t memberIndex = 0;
};
std::vector<BlockVar> blockVars;
for (Instruction& inst : ctx->module()->types_values()) {
if (inst.opcode() != spv::Op::OpVariable) continue;
const auto storage = static_cast<spv::StorageClass>(inst.GetSingleWordInOperand(0));
if (storage != spv::StorageClass::Input && storage != spv::StorageClass::Output) {
continue;
}
uint32_t pointeeId = VariablePointeeType(ctx, &inst);
if (pointeeId == 0) continue;
Instruction* pointee = defUse->GetDef(pointeeId);
if (pointee == nullptr) continue;
BlockVar entry;
entry.var = &inst;
entry.storage = storage;
if (pointee->opcode() == spv::Op::OpTypeArray) {
entry.arrayed = true;
entry.arrayLengthConstId = pointee->GetSingleWordInOperand(1);
if (!PlainConstantValue(ctx, entry.arrayLengthConstId, entry.arrayLengthValue)) {
continue; // spec-constant-sized interface array: not glslang's shape
}
pointee = defUse->GetDef(pointee->GetSingleWordInOperand(0));
if (pointee == nullptr) continue;
}
if (pointee->opcode() != spv::Op::OpTypeStruct) continue;
if (!memberIndexIn(pointee->result_id(), entry.memberIndex)) continue;
blockVars.push_back(entry);
}
// ---- vertex stage: mirror, never demote -----------------------------------
if (isVertex) {
if (!m_options.forceOutputCarrier) return Status::SuccessWithoutChange;
if (m_options.outputCarrierName.empty()) {
return decline("vertex mirror requested without a carrier name");
}
const uint32_t floatTypeId = Float32Type(ctx);
// The source of the mirrored value: the output block's PointSize member,
// a standalone output variable, or - with neither declared - the constant
// 1.0 GL's default point size names.
Instruction* blockVar = nullptr;
uint32_t memberIndex = 0;
for (const BlockVar& candidate : blockVars) {
if (candidate.storage == spv::StorageClass::Output && !candidate.arrayed) {
blockVar = candidate.var;
memberIndex = candidate.memberIndex;
break;
}
}
Instruction* standaloneOut = nullptr;
for (Instruction* candidate : standaloneVars) {
if (static_cast<spv::StorageClass>(candidate->GetSingleWordInOperand(0)) ==
spv::StorageClass::Output &&
IsFloat32Type(ctx, VariablePointeeType(ctx, candidate))) {
standaloneOut = candidate;
break;
}
}
const uint32_t carrierId =
CreateCarrierVariable(ctx, entryPoint, floatTypeId, spv::StorageClass::Output,
m_options.outputCarrierName, m_options.location);
if (carrierId == 0) return decline("could not declare the vertex mirror carrier");
uint32_t memberConstId = 0;
uint32_t ptrOutputFloatId = 0;
if (blockVar != nullptr) {
memberConstId = ctx->get_constant_mgr()->GetSIntConstId(
static_cast<int32_t>(memberIndex));
ptrOutputFloatId =
ctx->get_type_mgr()->FindPointerToType(floatTypeId, spv::StorageClass::Output);
if (ptrOutputFloatId == 0) return decline("no Output float pointer type");
}
uint32_t defaultOneId = 0;
if (blockVar == nullptr && standaloneOut == nullptr) {
defaultOneId = ctx->get_constant_mgr()->GetFloatConstId(1.0f);
}
const uint32_t entryFunctionId = entryPoint->GetSingleWordInOperand(1);
bool mirrored = false;
for (auto funcIt = ctx->module()->begin(); funcIt != ctx->module()->end(); ++funcIt) {
if (funcIt->result_id() != entryFunctionId) continue;
funcIt->ForEachInst([&](Instruction* inst) {
if (inst->opcode() != spv::Op::OpReturn &&
inst->opcode() != spv::Op::OpReturnValue) {
return;
}
uint32_t valueId = 0;
if (blockVar != nullptr) {
const uint32_t chainId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpAccessChain, ptrOutputFloatId, chainId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {blockVar->result_id()}},
{SPV_OPERAND_TYPE_ID, {memberConstId}}}));
valueId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLoad, floatTypeId, valueId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {chainId}}}));
} else if (standaloneOut != nullptr) {
valueId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLoad, floatTypeId, valueId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {standaloneOut->result_id()}}}));
} else {
valueId = defaultOneId;
}
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpStore, 0, 0,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {carrierId}},
{SPV_OPERAND_TYPE_ID, {valueId}}}));
mirrored = true;
});
}
if (!mirrored) {
// An entry function with no return is not a module glslang produces;
// the carrier stays declared (the consumer's read is undefined, as an
// unwritten built-in's would have been).
}
ctx->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
// ---- tessellation / geometry: redirect and strip --------------------------
// Phase 1: ANALYSIS ONLY. Every plan is collected before anything mutates, so
// a decline leaves the module byte-identical.
struct ArrayedRedirect {
Instruction* chain = nullptr;
bool input = false;
};
std::vector<ArrayedRedirect> arrayedRedirects; // gl_in[i].ps / gl_out[i].ps
std::vector<Instruction*> scalarOutputChains; // non-arrayed out block's member
const BlockVar* arrayedInput = nullptr;
const BlockVar* arrayedOutput = nullptr;
for (const BlockVar& blockVar : blockVars) {
if (blockVar.arrayed) {
if (blockVar.storage == spv::StorageClass::Input) {
arrayedInput = &blockVar;
} else {
arrayedOutput = &blockVar;
}
}
bool declined = false;
String reason;
defUse->ForEachUser(blockVar.var, [&](Instruction* user) {
if (declined) return;
switch (user->opcode()) {
case spv::Op::OpEntryPoint:
case spv::Op::OpName:
case spv::Op::OpDecorate:
return;
case spv::Op::OpAccessChain:
case spv::Op::OpInBoundsAccessChain: {
const uint32_t indexCount = user->NumInOperands() - 1;
if (!blockVar.arrayed) {
if (indexCount < 1) {
declined = true;
reason = "an index-less pointer to the whole gl_PerVertex block";
return;
}
uint32_t member = 0;
if (!PlainConstantValue(ctx, user->GetSingleWordInOperand(1), member)) {
declined = true;
reason = "a non-constant gl_PerVertex member index";
return;
}
if (member != blockVar.memberIndex) return; // another member
if (indexCount != 1) {
declined = true;
reason = "an access chain that continues past the PointSize member";
return;
}
scalarOutputChains.push_back(user);
return;
}
// Arrayed (gl_in / gl_out): [vertex, member, ...].
if (indexCount < 2) {
// A pointer that stops at the whole per-vertex struct can still
// reach PointSize through a second chain; following that split
// is not worth the shapes it would have to prove absent.
bool touchesPointSize = false;
defUse->ForEachUser(user, [&](Instruction* chainUser) {
if ((chainUser->opcode() == spv::Op::OpAccessChain ||
chainUser->opcode() == spv::Op::OpInBoundsAccessChain) &&
chainUser->NumInOperands() >= 2) {
uint32_t member = 0;
if (PlainConstantValue(ctx, chainUser->GetSingleWordInOperand(1),
member) &&
member == blockVar.memberIndex) {
touchesPointSize = true;
}
} else if (chainUser->opcode() == spv::Op::OpLoad ||
chainUser->opcode() == spv::Op::OpStore ||
chainUser->opcode() == spv::Op::OpCopyMemory) {
touchesPointSize = true; // whole-struct copy
}
});
if (touchesPointSize) {
declined = true;
reason = "a split access chain or whole-struct copy reaching PointSize";
}
return;
}
uint32_t member = 0;
if (!PlainConstantValue(ctx, user->GetSingleWordInOperand(2), member)) {
declined = true;
reason = "a non-constant gl_PerVertex member index";
return;
}
if (member != blockVar.memberIndex) return; // another member
if (indexCount != 2) {
declined = true;
reason = "an access chain that continues past the PointSize member";
return;
}
arrayedRedirects.push_back(
{user, blockVar.storage == spv::StorageClass::Input});
return;
}
case spv::Op::OpLoad:
case spv::Op::OpStore:
case spv::Op::OpCopyMemory:
declined = true;
reason = "a whole-aggregate load/store/copy of the gl_PerVertex interface";
return;
default:
declined = true;
reason = std::format("SPIR-V opcode {} reaching the gl_PerVertex interface",
static_cast<uint32_t>(user->opcode()));
return;
}
});
if (declined) return decline(Move(reason));
}
// Standalone variables: swapping the decoration is only sound for the float /
// float-array shapes the built-in is allowed to have; mixing forms in one
// direction never comes out of glslang and declines.
struct StandaloneSwap {
Instruction* var = nullptr;
Instruction* builtInDecoration = nullptr;
bool input = false;
};
std::vector<StandaloneSwap> standaloneSwaps;
for (SizeT i = 0; i < standaloneVars.size(); ++i) {
Instruction* var = standaloneVars[i];
const auto storage = static_cast<spv::StorageClass>(var->GetSingleWordInOperand(0));
if (storage != spv::StorageClass::Input && storage != spv::StorageClass::Output) {
continue;
}
const bool input = storage == spv::StorageClass::Input;
uint32_t pointeeId = VariablePointeeType(ctx, var);
Instruction* pointee = defUse->GetDef(pointeeId);
if (pointee != nullptr && pointee->opcode() == spv::Op::OpTypeArray) {
pointee = defUse->GetDef(pointee->GetSingleWordInOperand(0));
}
if (pointee == nullptr || pointee->opcode() != spv::Op::OpTypeFloat) {
return decline("a standalone PointSize variable of an unexpected type");
}
if (input && arrayedInput != nullptr) {
return decline("PointSize declared both as a block member and standalone (input)");
}
if (!input && (arrayedOutput != nullptr || !scalarOutputChains.empty())) {
return decline("PointSize declared both as a block member and standalone (output)");
}
standaloneSwaps.push_back({var, standaloneBuiltInDecorations[i], input});
}
bool needsInputCarrier = false;
bool needsOutputCarrier = m_options.forceOutputCarrier;
for (const ArrayedRedirect& redirect : arrayedRedirects) {
(redirect.input ? needsInputCarrier : needsOutputCarrier) = true;
// Only a control stage has an ARRAYED output block; anywhere else this
// shape would hand a scalar carrier an extra index.
if (!redirect.input && !isTessControl) {
return decline("an arrayed PointSize output outside a control stage");
}
}
if (!scalarOutputChains.empty()) {
needsOutputCarrier = true;
// And only evaluation/geometry stages have the non-arrayed output block.
if (isTessControl) {
return decline("a non-arrayed PointSize output in a control stage");
}
}
bool standaloneInputSwapped = false;
bool standaloneOutputSwapped = false;
for (const StandaloneSwap& swap : standaloneSwaps) {
(swap.input ? standaloneInputSwapped : standaloneOutputSwapped) = true;
}
if (needsInputCarrier && arrayedInput == nullptr) {
return decline("a PointSize read with no arrayed input block to size the carrier by");
}
if (needsInputCarrier && m_options.inputCarrierName.empty()) {
return decline("a PointSize read with no input carrier name to bind it to");
}
if ((needsOutputCarrier && !standaloneOutputSwapped) &&
m_options.outputCarrierName.empty()) {
return decline("a PointSize write with no output carrier name to bind it to");
}
// A FORCED carrier with nothing redirected onto it is a carrier no instruction
// ever writes - and a declared-but-unwritten output does not survive the ES
// hop: the driver's GLSL front end drops it, and a transform-feedback request
// naming it then fails the link with "varying undeclared", taking every other
// capture in the set down with it. The pass therefore SEEDS such a carrier
// (below) with GL's default point size, which is also what an unhosted
// built-in rasterizes at. GL leaves the value of an unwritten output
// undefined, so a defined 1.0 is a legal choice and a far better one than a
// varying the driver deletes.
const bool outputCarrierHasWriter = [&] {
if (!scalarOutputChains.empty()) return true;
for (const ArrayedRedirect& redirect : arrayedRedirects) {
if (!redirect.input) return true;
}
return false;
}();
// The seed the control stage would need is per-INVOCATION - gl_out[
// gl_InvocationID] - and synthesizing that means inventing the InvocationId
// built-in for a stage that may not declare it. A control stage asked to
// produce a value it never computes is also a program reading undefined data
// either way, so this declines rather than growing the pass for it.
if (needsOutputCarrier && !standaloneOutputSwapped && !outputCarrierHasWriter &&
isTessControl) {
return decline("a control stage asked to produce a point-size carrier it never writes");
}
// The TCS output carrier is arrayed per vertex; its length comes from gl_out,
// or - for a forced carrier in a control stage that never declared gl_out -
// from the OutputVertices execution mode.
uint32_t outputArrayLengthConstId = 0;
uint32_t outputArrayLengthValue = 0;
if (isTessControl && needsOutputCarrier && !standaloneOutputSwapped) {
if (arrayedOutput != nullptr) {
outputArrayLengthConstId = arrayedOutput->arrayLengthConstId;
outputArrayLengthValue = arrayedOutput->arrayLengthValue;
} else {
for (Instruction& mode : ctx->module()->execution_modes()) {
if (mode.NumInOperands() >= 3 &&
static_cast<spv::ExecutionMode>(mode.GetSingleWordInOperand(1)) ==
spv::ExecutionMode::OutputVertices) {
outputArrayLengthValue = mode.GetSingleWordInOperand(2);
break;
}
}
if (outputArrayLengthValue == 0) {
return decline("a control stage with neither gl_out nor OutputVertices");
}
outputArrayLengthConstId =
ctx->get_constant_mgr()->GetUIntConstId(outputArrayLengthValue);
}
}
const bool anyWork = needsInputCarrier || needsOutputCarrier ||
!standaloneSwaps.empty();
// Even with no access left to redirect (a dead read the sanitize chain already
// removed), a declared TessellationPointSize/GeometryPointSize capability must
// still be stripped - it alone makes the module unbuildable on the device.
std::vector<Instruction*> capabilitiesToStrip;
for (Instruction& capability : ctx->module()->capabilities()) {
if (capability.NumInOperands() < 1) continue;
const auto declared =
static_cast<spv::Capability>(capability.GetSingleWordInOperand(0));
if (declared == spv::Capability::TessellationPointSize ||
declared == spv::Capability::GeometryPointSize) {
capabilitiesToStrip.push_back(&capability);
}
}
if (!anyWork && capabilitiesToStrip.empty()) return Status::SuccessWithoutChange;
// THE ONE SHAPE WHERE "declared but unaccessed" IS NOT ENOUGH. SPIRV-Cross
// force-emits the whole redeclared gl_PerVertex OUTPUT block for a control
// stage whose clip or cull distances are LIVE (should_force_emit_builtin_block,
// spirv_glsl.cpp), and that emission is driven by the struct's member
// DECORATIONS, never by access - so it prints "float gl_PointSize;" into a
// block no instruction touches any more. On the extension-less ES drivers this
// pass exists for, that redeclaration is exactly as illegal as the access was,
// so the demoted program would still be lost - only now with the capability
// stripped, the program-wide verdict flipped and a diagnostic naming a
// built-in the module no longer mentions. Declining keeps the honest refusal,
// and keeps the header's claim true for every shape that IS demoted.
//
// LIVE, not declared: glslang emits the whole four-member gl_PerVertex block
// into every stage whether or not it is touched, and SPIRV-Cross counts clip
// and cull distance from ACCESSES (Compiler::ActiveBuiltinHandler). Keying
// this on the decorations alone would decline every control stage there is.
// Only a block-MEMBER PointSize can be left behind to be printed; a standalone
// variable leaves with the demotion.
if (isTessControl && !blockVars.empty()) {
bool clipOrCullIsLive = false;
const auto memberIsClipOrCull = [&](const uint32_t structId,
const uint32_t member) {
for (const MemberSite& site : clipCullSites) {
if (site.structId == structId && site.memberIndex == member) return true;
}
return false;
};
for (Instruction& inst : ctx->module()->types_values()) {
if (clipOrCullIsLive) break;
if (inst.opcode() != spv::Op::OpVariable) continue;
const auto storage =
static_cast<spv::StorageClass>(inst.GetSingleWordInOperand(0));
if (storage != spv::StorageClass::Input &&
storage != spv::StorageClass::Output) {
continue;
}
// A standalone clip/cull variable counts the moment anything but its
// own declaration touches it.
bool standaloneClipCull = false;
for (Instruction* candidate : standaloneClipCullVars) {
if (candidate == &inst) standaloneClipCull = true;
}
Instruction* pointee = defUse->GetDef(VariablePointeeType(ctx, &inst));
bool arrayed = false;
if (pointee != nullptr && pointee->opcode() == spv::Op::OpTypeArray) {
arrayed = true;
pointee = defUse->GetDef(pointee->GetSingleWordInOperand(0));
}
const bool blockCarriesClipCull =
pointee != nullptr && pointee->opcode() == spv::Op::OpTypeStruct &&
[&] {
for (const MemberSite& site : clipCullSites) {
if (site.structId == pointee->result_id()) return true;
}
return false;
}();
if (!standaloneClipCull && !blockCarriesClipCull) continue;
const uint32_t structId = blockCarriesClipCull ? pointee->result_id() : 0;
const uint32_t memberOperand = arrayed ? 2u : 1u;
defUse->ForEachUser(&inst, [&](Instruction* user) {
if (clipOrCullIsLive) return;
switch (user->opcode()) {
case spv::Op::OpEntryPoint:
case spv::Op::OpName:
case spv::Op::OpMemberName:
case spv::Op::OpDecorate:
case spv::Op::OpMemberDecorate:
return;
default:
break;
}
if (standaloneClipCull) {
clipOrCullIsLive = true;
return;
}
if (user->opcode() != spv::Op::OpAccessChain &&
user->opcode() != spv::Op::OpInBoundsAccessChain) {
// A whole-block load, copy or anything else that cannot be
// narrowed reaches every member, clip distance included.
clipOrCullIsLive = true;
return;
}
uint32_t member = 0;
if (user->NumInOperands() <= memberOperand ||
!PlainConstantValue(ctx, user->GetSingleWordInOperand(memberOperand),
member)) {
clipOrCullIsLive = true; // cannot prove it misses clip/cull
return;
}
if (memberIsClipOrCull(structId, member)) clipOrCullIsLive = true;
});
}
if (clipOrCullIsLive) {
for (const BlockVar& blockVar : blockVars) {
if (blockVar.storage == spv::StorageClass::Output) {
return decline(
"a control stage with live clip/cull distance, whose "
"redeclared output block would still print gl_PointSize");
}
}
}
}
// Phase 2: MUTATION. Nothing below may decline.
const uint32_t floatTypeId = Float32Type(ctx);
uint32_t inputCarrierId = 0;
if (needsInputCarrier) {
const uint32_t arrayTypeId = ArrayOfFloat32Type(
ctx, arrayedInput->arrayLengthConstId, arrayedInput->arrayLengthValue);
inputCarrierId =
CreateCarrierVariable(ctx, entryPoint, arrayTypeId, spv::StorageClass::Input,
m_options.inputCarrierName, m_options.location);
}
uint32_t outputCarrierId = 0;
if (needsOutputCarrier && !standaloneOutputSwapped) {
uint32_t pointeeTypeId = floatTypeId;
if (isTessControl) {
pointeeTypeId =
ArrayOfFloat32Type(ctx, outputArrayLengthConstId, outputArrayLengthValue);
}
outputCarrierId =
CreateCarrierVariable(ctx, entryPoint, pointeeTypeId, spv::StorageClass::Output,
m_options.outputCarrierName, m_options.location);
}
// Seed a forced carrier nothing writes, so the ES hop keeps it (see the
// reasoning at outputCarrierHasWriter). In a GEOMETRY stage the store has to
// go before every EmitVertex, because that is when the outputs of one vertex
// are latched; anywhere else the ends of the entry function will do.
if (outputCarrierId != 0 && !outputCarrierHasWriter) {
const uint32_t defaultPointSizeId = ctx->get_constant_mgr()->GetFloatConstId(1.0f);
const uint32_t entryFunctionId = entryPoint->GetSingleWordInOperand(1);
std::vector<Instruction*> seedSites;
for (auto funcIt = ctx->module()->begin(); funcIt != ctx->module()->end();
++funcIt) {
if (funcIt->result_id() != entryFunctionId) continue;
funcIt->ForEachInst([&](Instruction* inst) {
const bool emit = inst->opcode() == spv::Op::OpEmitVertex ||
inst->opcode() == spv::Op::OpEmitStreamVertex;
const bool ret = inst->opcode() == spv::Op::OpReturn ||
inst->opcode() == spv::Op::OpReturnValue;
if (isGeometry ? emit : ret) seedSites.push_back(inst);
});
// A geometry stage with no EmitVertex emits nothing at all; seeding the
// ends of the function still keeps the varying alive for the capture.
if (isGeometry && seedSites.empty()) {
funcIt->ForEachInst([&](Instruction* inst) {
if (inst->opcode() == spv::Op::OpReturn ||
inst->opcode() == spv::Op::OpReturnValue) {
seedSites.push_back(inst);
}
});
}
}
for (Instruction* site : seedSites) {
site->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpStore, 0, 0,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {outputCarrierId}},
{SPV_OPERAND_TYPE_ID, {defaultPointSizeId}}}));
}
}
// Scalar output chains first, while the def-use index still knows their uses.
for (Instruction* chain : scalarOutputChains) {
ctx->ReplaceAllUsesWith(chain->result_id(), outputCarrierId);
ctx->KillInst(chain);
}
// Arrayed chains are rewritten in place: same result id, same result type
// (pointer-to-float in the same storage class), one fewer index.
for (const ArrayedRedirect& redirect : arrayedRedirects) {
const uint32_t carrierId = redirect.input ? inputCarrierId : outputCarrierId;
const Operand vertexIndex = redirect.chain->GetInOperand(1);
redirect.chain->SetInOperands(Instruction::OperandList{
{SPV_OPERAND_TYPE_ID, {carrierId}}, vertexIndex});
}
// Standalone form: the variable becomes its own carrier.
for (const StandaloneSwap& swap : standaloneSwaps) {
ctx->KillInst(swap.builtInDecoration);
AddLocationDecoration(ctx, swap.var->result_id(), m_options.location);
std::vector<Instruction*> oldNames;
for (Instruction& debugInst : ctx->module()->debugs2()) {
if (debugInst.opcode() == spv::Op::OpName &&
debugInst.GetSingleWordInOperand(0) == swap.var->result_id()) {
oldNames.push_back(&debugInst);
}
}
for (Instruction* oldName : oldNames) ctx->KillInst(oldName);
AddNameFor(ctx, swap.var->result_id(),
swap.input ? m_options.inputCarrierName : m_options.outputCarrierName);
}
for (Instruction* capability : capabilitiesToStrip) {
ctx->KillInst(capability);
}
if (m_report != nullptr) {
m_report->createdInputCarrier = needsInputCarrier || standaloneInputSwapped;
}
ctx->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken DemotePointSizePass::CreateDemotePointSizePass(
DemotePointSizeOptions options, DemotePointSizeReport* report) {
return spvtools::Optimizer::PassToken(
MakeUnique<DemotePointSizePass>(Move(options), report));
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,118 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.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 "source/opt/pass.h"
#include "spirv-tools/optimizer.hpp"
#include <Includes.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Demotes gl_PointSize traffic in ONE tessellation or geometry module (or mirrors it
// out of a vertex module) into an ordinary inter-stage float varying, for devices
// that cannot host the built-in in those stages at all: no
// EXT/OES_tessellation_point_size / geometry_point_size on the ES driver, and
// shaderTessellationAndGeometryPointSize == VK_FALSE on the Vulkan one. Desktop GL
// treats the built-in as an ordinary per-vertex output, so the programs this rescues
// are legal GL - only the targets cannot spell them.
//
// What "demoted" means, precisely. In a tessellation/geometry stage every access
// chain that reaches the PointSize member of a gl_PerVertex block (gl_in[i]
// .gl_PointSize, gl_out[i].gl_PointSize, the non-arrayed output block's member) is
// redirected onto a plain float varying at the caller-chosen location - an arrayed
// Input for gl_in reads, an arrayed Output for TCS gl_out writes, a scalar Output
// for the TES/GS output - and the TessellationPointSize / GeometryPointSize
// capability is stripped. The gl_PerVertex STRUCT keeps its PointSize member,
// declared and decorated but no longer accessed: that is exactly the shape glslang
// produces for a program that never touches point size (it defers the capability to
// first use). A standalone PointSize VARIABLE (never glslang's shape, but legal
// SPIR-V) is demoted in place: BuiltIn swapped for the Location, and the variable
// renamed to the carrier's name.
//
// "Declared but unaccessed" is only safe while the ES hop PRINTS by access, and
// there is one shape where it does not. SPIRV-Cross redeclares the whole
// gl_PerVertex output block for a CONTROL stage whose clip or cull distances are
// live (should_force_emit_builtin_block), and that redeclaration walks the
// struct's member DECORATIONS - so it would print "float gl_PointSize;" into a
// block nothing touches any more, which an extension-less ES driver rejects
// exactly as it rejected the access. That combination therefore DECLINES, below,
// rather than shipping a module that is mutated and still lost. Every other
// demoted shape leaves the member genuinely invisible to the ES hop, which is
// what the pinned transpile assertions hold.
//
// A VERTEX module is never capability-limited (gl_PointSize is core there on both
// targets), so it keeps its built-in untouched and, when the next stage consumes the
// carrier, MIRRORS the built-in's value into the carrier at every return of the
// entry function - the VS->TCS half of the chain.
//
// The VALUE is what survives: gl_in[].gl_PointSize reads and transform-feedback
// captures see exactly what the upstream stage wrote. The RASTERIZED point size is
// what does not - with the built-in unhosted, both targets rasterize such pipelines
// at the default size 1.0 (Vulkan: the shaderTessellationAndGeometryPointSize
// feature description; ES: PointSizeRange default) - so rasterization-verified
// point_rendering tests keep failing honestly and nothing may be gated on them.
//
// Anything the pass cannot express - a whole gl_PerVertex struct load/store/copy, a
// pointer that escapes into an opcode it cannot follow, an access-chain split across
// two chains - DECLINES the module byte-identically, reported through the report
// struct, so the caller keeps the existing honest refusal paths instead of shipping
// a half-demoted program.
//
// One module per run; the PROGRAM-wide contract (every stage demoted or none, one
// shared location, matching carrier names across each boundary) is owned by
// ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram, the only caller.
struct DemotePointSizeOptions {
// The Location every carrier of this program uses; chosen by the caller past
// every location any stage of the program already consumes.
Uint32 location = 0;
// Name for the arrayed Input carrier (empty forbids creating one: a module that
// reads gl_in[].gl_PointSize with no name to give the carrier declines).
String inputCarrierName;
// Name for the Output carrier (scalar in VS/TES/GS, arrayed in TCS).
String outputCarrierName;
// Create the Output carrier even when this module never writes PointSize: the
// next stage reads it (Vulkan requires every consumed input to be produced,
// VUID-RuntimeSpirv-OpEntryPoint-08743), or a transform-feedback capture of
// gl_PointSize binds to it. Such a carrier is SEEDED with 1.0 - GL's default
// point size, and what an unhosted built-in rasterizes at - rather than left
// unwritten: GL calls the value of an unwritten output undefined, but an ES
// driver's front end DELETES a never-written output, and a capture naming a
// deleted varying fails the link and takes the whole capture set with it. A
// control stage cannot be seeded this way (the write is per-invocation) and
// declines instead.
Bool forceOutputCarrier = false;
};
struct DemotePointSizeReport {
Bool declined = false;
String declineReason;
// The module reads incoming PointSize, so an Input carrier now exists - which
// obliges the PREVIOUS stage to produce the matching Output carrier. The driver
// walks the stages back-to-front off exactly this bit.
Bool createdInputCarrier = false;
};
class DemotePointSizePass : public spvtools::opt::Pass {
public:
DemotePointSizePass(DemotePointSizeOptions options, DemotePointSizeReport* report)
: m_options(Move(options)), m_report(report) {}
const char* name() const override { return "mobilegl-demote-point-size"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateDemotePointSizePass(
DemotePointSizeOptions options, DemotePointSizeReport* report);
private:
DemotePointSizeOptions m_options;
DemotePointSizeReport* m_report;
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -84,8 +84,18 @@ namespace MobileGL {
struct BlockPlan {
Instruction* structType = nullptr;
uint32_t storageClass = 0;
// A bounded block's length in words. For an open-ended block - one whose
// last member is a runtime array - the FIXED PREFIX in words, i.e. the
// runtime array's own offset, which is where its element 0 starts.
uint32_t wordCount = 0;
bool openEnded = false;
// The original runtime array's stride in words; what one element of it
// steps by, and what its word count divides by to become a length.
uint32_t tailStrideWords = 0;
std::vector<ChainPlan> chains;
// The OpArrayLength users of an open-ended block's variables, which count
// WORDS once the member is a `uint[]` and so have to be rewritten too.
std::vector<Instruction*> arrayLengths;
};
bool IsDoubleType(const Instruction* type) {
@@ -178,8 +188,9 @@ namespace MobileGL {
}
// Byte size of a type as it is laid out INSIDE a block, or 0 when this pass
// cannot describe it (a runtime array, a width it does not carry, a matrix with
// no stride or a row-major one).
// cannot describe it (a runtime array - the one place a block may have one is
// its last member, which MeasureBlock handles above this - a width it does not
// carry, a matrix with no stride or a row-major one).
uint32_t LaidOutByteSize(IRContext* context, const TypeCursor& cursor) {
const Instruction* type = context->get_def_use_mgr()->GetDef(cursor.typeId);
if (type == nullptr) return 0;
@@ -231,8 +242,17 @@ namespace MobileGL {
}
// Whether this type decomposes into scalars the rewrite can move one word at a
// time, counting them so a whole-aggregate access can be refused before it is
// expanded.
// time. |leafCount| counts them, so a whole-aggregate access can be refused
// before it is expanded; passing NULL asks the SHAPE question alone - is this
// type addressable at all - and then identical array elements and vector
// components are walked once instead of once each, because the answer cannot
// differ between them and the walk of a big one would not be free.
//
// The two questions are separate because only a LOAD or a STORE expands into
// leaves, and the cap bounds one of those. How large a runtime array's element
// is says nothing about how many scalars a single access to it moves, so
// MeasureBlock asks for the shape and BuildPlans applies the cap where it
// belongs - per chain, to the type that chain actually names.
bool CanDecompose(IRContext* context, const TypeCursor& cursor, uint32_t* leafCount) {
const Instruction* type = context->get_def_use_mgr()->GetDef(cursor.typeId);
if (type == nullptr) return false;
@@ -240,12 +260,15 @@ namespace MobileGL {
case spv::Op::OpTypeInt:
case spv::Op::OpTypeFloat:
if (ScalarByteSize(type) == 0) return false;
if (leafCount == nullptr) return true;
++*leafCount;
return *leafCount <= kMaxLeavesPerAccess;
case spv::Op::OpTypeVector: {
TypeCursor component;
component.typeId = type->GetSingleWordInOperand(0);
for (uint32_t i = 0; i < type->GetSingleWordInOperand(1); ++i) {
const uint32_t repeats =
leafCount == nullptr ? 1u : type->GetSingleWordInOperand(1);
for (uint32_t i = 0; i < repeats; ++i) {
if (!CanDecompose(context, component, leafCount)) return false;
}
return true;
@@ -257,7 +280,9 @@ namespace MobileGL {
}
TypeCursor column;
column.typeId = type->GetSingleWordInOperand(0);
for (uint32_t i = 0; i < type->GetSingleWordInOperand(1); ++i) {
const uint32_t repeats =
leafCount == nullptr ? 1u : type->GetSingleWordInOperand(1);
for (uint32_t i = 0; i < repeats; ++i) {
if (!CanDecompose(context, column, leafCount)) return false;
}
return true;
@@ -273,10 +298,12 @@ namespace MobileGL {
context->get_constant_mgr()->FindDeclaredConstant(type->GetSingleWordInOperand(1));
if (length == nullptr || length->AsIntConstant() == nullptr) return false;
const uint32_t count = length->AsIntConstant()->GetU32BitValue();
if (count == 0 || count > kMaxLeavesPerAccess) return false;
if (count == 0) return false;
if (leafCount != nullptr && count > kMaxLeavesPerAccess) return false;
TypeCursor element = cursor;
element.typeId = type->GetSingleWordInOperand(0);
for (uint32_t i = 0; i < count; ++i) {
const uint32_t repeats = leafCount == nullptr ? 1u : count;
for (uint32_t i = 0; i < repeats; ++i) {
if (!CanDecompose(context, element, leafCount)) return false;
}
return true;
@@ -300,6 +327,63 @@ namespace MobileGL {
}
}
// Measures the block struct itself. A bounded block reports its laid-out byte
// size; a block whose LAST member is a runtime array - the only place GLSL lets
// one stand, and the only place SPIR-V lets a Block have one - reports the byte
// offset that array starts at and says so through |openEnded|, with the array's
// stride alongside. A runtime array anywhere else, one without a stride the
// words can step by, or one whose element the rewrite could not take apart is a
// shape this pass does not describe, and so is a bounded block it cannot size.
bool MeasureBlock(IRContext* context, const Instruction* structType, uint32_t* bytes,
bool* openEnded, uint32_t* tailStrideBytes) {
*bytes = 0;
*openEnded = false;
*tailStrideBytes = 0;
const uint32_t structId = structType->result_id();
const uint32_t memberCount = structType->NumInOperands();
uint64_t end = 0;
for (uint32_t member = 0; member < memberCount; ++member) {
uint32_t offset = 0;
if (!TryGetMemberDecorationLiteral(context, structId, member, spv::Decoration::Offset,
&offset)) {
return false;
}
const TypeCursor cursor = MemberCursor(context, structType, member);
const Instruction* type = context->get_def_use_mgr()->GetDef(cursor.typeId);
if (type == nullptr) return false;
if (type->opcode() == spv::Op::OpTypeRuntimeArray) {
if (member + 1 != memberCount) return false;
uint32_t stride = 0;
if (!TryGetDecorationLiteral(context, cursor.typeId, spv::Decoration::ArrayStride,
&stride) ||
stride == 0 || stride % kWordBytes != 0) {
return false;
}
// The member's own matrix decorations describe the array's ELEMENTS,
// exactly as they do for a bounded array of matrices. Only the shape
// is asked for: how big one element is decides nothing about how
// many scalars one access moves, and a leaf cap here would decline a
// block over a member the shader may never read whole.
TypeCursor element = cursor;
element.typeId = type->GetSingleWordInOperand(0);
if (!CanDecompose(context, element, nullptr)) return false;
// Element 0 has to start past every fixed member, or the words the
// prefix owns and the words the array owns would overlap.
if (offset < end) return false;
end = offset;
*openEnded = true;
*tailStrideBytes = stride;
break;
}
const uint32_t size = LaidOutByteSize(context, cursor);
if (size == 0) return false;
end = std::max<uint64_t>(end, static_cast<uint64_t>(offset) + size);
}
if (end > kMaxBlockBytes) return false;
*bytes = static_cast<uint32_t>(end);
return true;
}
bool TypeContainsFloat64(IRContext* context, uint32_t typeId,
std::unordered_set<uint32_t>& visiting) {
const Instruction* type = context->get_def_use_mgr()->GetDef(typeId);
@@ -366,6 +450,9 @@ namespace MobileGL {
switch (type->opcode()) {
case spv::Op::OpTypeArray:
// A runtime array steps exactly like a bounded one; only its end is
// unknown, and a chain never needs that.
case spv::Op::OpTypeRuntimeArray:
if (!TryGetDecorationLiteral(context, cursor.typeId, spv::Decoration::ArrayStride,
&stride)) {
return false;
@@ -611,6 +698,39 @@ namespace MobileGL {
}
}
// Replaces an OpArrayLength of an open-ended block with the element count
// of the ORIGINAL runtime array. The instruction now counts the words of
// the flattened `uint[]`, so the length is `(words - prefix) / stride`, in
// unsigned arithmetic and clamped at zero when the bound range does not
// even reach the array's offset - a wrapped subtraction would otherwise
// report a few billion elements. The division floors, which is what GL
// defines `.length()` as for a range that is not a whole number of
// elements. A fresh OpArrayLength is issued rather than the old one re-aimed,
// so the uses being redirected are never the ones the arithmetic just made.
void RewriteArrayLength(Instruction* arrayLength, uint32_t prefixWords, uint32_t strideWords) {
InstructionBuilder builder(m_context, arrayLength, kPreservedAnalyses);
const uint32_t variableId = arrayLength->GetSingleWordInOperand(0);
const uint32_t wordsId = m_context->TakeNextId();
builder.AddInstruction(MakeUnique<Instruction>(
m_context, spv::Op::OpArrayLength, m_uintTypeId, wordsId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {variableId}},
{SPV_OPERAND_TYPE_LITERAL_INTEGER, {0u}}}));
uint32_t count = wordsId;
if (prefixWords != 0) {
const uint32_t prefixId = UintConstant(prefixWords);
const uint32_t past = Binary(builder, spv::Op::OpISub, m_uintTypeId, count, prefixId);
const uint32_t tooShort =
Binary(builder, spv::Op::OpULessThan, m_boolTypeId, count, prefixId);
count = Select(builder, tooShort, UintConstant(0), past);
}
if (strideWords != 1) {
count = Binary(builder, spv::Op::OpUDiv, m_uintTypeId, count,
UintConstant(strideWords));
}
m_context->ReplaceAllUsesWith(arrayLength->result_id(), count);
m_context->KillInst(arrayLength);
}
private:
uint32_t ComponentWords(uint32_t componentTypeId) {
return ScalarByteSize(m_context->get_def_use_mgr()->GetDef(componentTypeId)) /
@@ -788,38 +908,72 @@ namespace MobileGL {
return false;
}
// A fresh `uint[length]` with ArrayStride 4, spliced in immediately BEFORE the
// block that will name it - SPIR-V has no forward references between types, so
// appending it at the end of the section would make the module invalid. A
// duplicate OpTypeArray is legal (SPIR-V 2.8 exempts aggregates from the
// uniqueness rule, and so does spirv-val), so no search for an existing one is
// needed; the LENGTH CONSTANT is not exempt, and if the module already declares
// it after the block there is nowhere legal to put the array - the block is then
// declined and keeps today's behaviour. Returns 0 for that, and for a uint type
// that is itself declared too late.
// A fresh `uint[length]` with ArrayStride 4 - or, for an open-ended block, a
// `uint[]` runtime array with the same stride and no length at all - spliced in
// immediately BEFORE the block that will name it: SPIR-V has no forward
// references between types, so appending it at the end of the section would make
// the module invalid. A duplicate OpTypeArray or OpTypeRuntimeArray is legal
// (SPIR-V 2.8 exempts aggregates from the uniqueness rule, and so does
// spirv-val), so no search for an existing one is needed; the LENGTH CONSTANT is
// not exempt, and if the module already declares it after the block there is
// nowhere legal to put the array - the block is then declined and keeps today's
// behaviour. Returns 0 for that; an open-ended block has no length constant to
// place, so that reason cannot reach it.
//
// The `uint` element type is a different matter, and only for an OPEN-ENDED
// block. The front end declares types in first-use order, so a block that is the
// first thing a shader touches sits BEFORE the module's `uint` (or the module has
// none, and the one the pass asked for was appended at the end). Declining there
// would send exactly the buffers this rewrite exists for back to the demotion on
// nothing but where they stand in the source. OpTypeInt has no operands, and
// nothing that names it can precede where it was, so moving it up in front of the
// block is always legal. A BOUNDED block keeps declining instead: that is what it
// has always done, and widening it is a change to a path this one does not need.
//
// NOTHING IS WRITTEN until every reason to decline has been ruled out, so a block
// this returns 0 for leaves the module as it found it - which is what lets
// Process() truthfully report SuccessWithoutChange for a module of only those.
uint32_t CreateWordArrayTypeBefore(IRContext* context, Instruction* structType,
uint32_t uintTypeId, uint32_t length) {
if (!DeclaredBefore(context, uintTypeId, structType->result_id())) return 0;
uint32_t uintTypeId, uint32_t length, bool openEnded) {
Instruction* uintType = context->get_def_use_mgr()->GetDef(uintTypeId);
if (uintType == nullptr || uintType->opcode() != spv::Op::OpTypeInt) return 0;
const bool hoistUint = !DeclaredBefore(context, uintTypeId, structType->result_id());
if (hoistUint && !openEnded) return 0;
auto* constantMgr = context->get_constant_mgr();
const spvtools::opt::analysis::Type* uintType = context->get_type_mgr()->GetType(uintTypeId);
if (uintType == nullptr) return 0;
const spvtools::opt::analysis::Constant* lengthConstant =
constantMgr->GetConstant(uintType, {length});
if (lengthConstant == nullptr) return 0;
uint32_t lengthConstantId = 0;
if (!openEnded) {
auto* constantMgr = context->get_constant_mgr();
const spvtools::opt::analysis::Type* uintDescriptor =
context->get_type_mgr()->GetType(uintTypeId);
if (uintDescriptor == nullptr) return 0;
const spvtools::opt::analysis::Constant* lengthConstant =
constantMgr->GetConstant(uintDescriptor, {length});
if (lengthConstant == nullptr) return 0;
Module::inst_iterator position = PositionOf(context, structType);
if (position == context->types_values_end()) return 0;
Instruction* lengthInst = constantMgr->GetDefiningInstruction(lengthConstant, 0, &position);
if (lengthInst == nullptr) return 0;
if (!DeclaredBefore(context, lengthInst->result_id(), structType->result_id())) return 0;
Module::inst_iterator position = PositionOf(context, structType);
if (position == context->types_values_end()) return 0;
// Created in front of the block when it is not there yet, so the only way
// this declines is a constant the module already declares after it.
Instruction* lengthInst =
constantMgr->GetDefiningInstruction(lengthConstant, 0, &position);
if (lengthInst == nullptr) return 0;
if (!DeclaredBefore(context, lengthInst->result_id(), structType->result_id())) return 0;
lengthConstantId = lengthInst->result_id();
}
const uint32_t arrayTypeId = context->TakeNextId();
if (arrayTypeId == 0) return 0;
auto arrayType = MakeUnique<Instruction>(
context, spv::Op::OpTypeArray, 0, arrayTypeId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {uintTypeId}},
{SPV_OPERAND_TYPE_ID, {lengthInst->result_id()}}});
if (hoistUint) uintType->InsertBefore(structType);
std::unique_ptr<Instruction> arrayType =
openEnded
? MakeUnique<Instruction>(
context, spv::Op::OpTypeRuntimeArray, 0, arrayTypeId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {uintTypeId}}})
: MakeUnique<Instruction>(
context, spv::Op::OpTypeArray, 0, arrayTypeId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {uintTypeId}},
{SPV_OPERAND_TYPE_ID, {lengthConstantId}}});
Instruction* inserted = structType->InsertBefore(std::move(arrayType));
context->AnalyzeDefUse(inserted);
context->get_decoration_mgr()->AddDecorationVal(
@@ -948,8 +1102,14 @@ namespace MobileGL {
Instruction* structType = defUseMgr->GetDef(structId);
TypeCursor blockCursor;
blockCursor.typeId = structId;
const uint32_t blockBytes = LaidOutByteSize(context, blockCursor);
if (blockBytes == 0 || blockBytes % kWordBytes != 0) {
uint32_t blockBytes = 0;
bool openEnded = false;
uint32_t tailStrideBytes = 0;
// An open-ended block whose runtime array is its only member measures a
// prefix of 0 bytes and is perfectly describable; only a BOUNDED block of
// no bytes is not, and MeasureBlock already refuses to size one of those.
if (!MeasureBlock(context, structType, &blockBytes, &openEnded, &tailStrideBytes) ||
blockBytes % kWordBytes != 0 || (!openEnded && blockBytes == 0)) {
MGLOG_D("[spirv] storage block %%%u holds a double but its byte layout cannot be "
"described exactly; left to the fp64 demotion",
structId);
@@ -960,11 +1120,15 @@ namespace MobileGL {
plan.structType = structType;
plan.storageClass = storageClassByStruct[structId];
plan.wordCount = blockBytes / kWordBytes;
plan.openEnded = openEnded;
plan.tailStrideWords = tailStrideBytes / kWordBytes;
const uint32_t lastMember = structType->NumInOperands() - 1;
bool expressible = true;
for (Instruction* variable : variablesByStruct[structId]) {
std::vector<Instruction*> chains;
std::unordered_set<uint32_t> seenChains;
std::unordered_set<uint32_t> seenLengths;
defUseMgr->ForEachUser(variable, [&](Instruction* user) {
if (!expressible) return;
switch (user->opcode()) {
@@ -982,6 +1146,27 @@ namespace MobileGL {
}
expressible = false;
return;
case spv::Op::OpArrayLength: {
// Only an open-ended block has a length to ask for, and
// only of its last member; the result has to be the 32-bit
// uint the rewrite's arithmetic is typed in, which is the
// only result type the instruction allows anyway.
const Instruction* resultType = defUseMgr->GetDef(user->type_id());
const bool isUint = resultType != nullptr &&
resultType->opcode() == spv::Op::OpTypeInt &&
resultType->GetSingleWordInOperand(0) == 32u &&
resultType->GetSingleWordInOperand(1) == 0u;
if (openEnded && isUint && user->NumInOperands() >= 2 &&
user->GetSingleWordInOperand(0) == variable->result_id() &&
user->GetSingleWordInOperand(1) == lastMember) {
if (seenLengths.insert(user->result_id()).second) {
plan.arrayLengths.push_back(user);
}
return;
}
expressible = false;
return;
}
default:
expressible = false;
return;
@@ -1058,16 +1243,25 @@ namespace MobileGL {
Emitter emitter(irContext, uintTypeId, boolTypeId, floatTypeId);
bool modified = false;
// Every block declines before anything is written for it, so |touched| only ever
// parts company with |modified| on a shape that cannot happen without the module
// running out of ids - and even then the status must not claim the bytes are
// untouched, because the caller relies on that to skip invalidating its analyses.
bool touched = false;
for (BlockPlan& plan : plans) {
const uint32_t structId = plan.structType->result_id();
const uint32_t arrayTypeId =
CreateWordArrayTypeBefore(irContext, plan.structType, uintTypeId, plan.wordCount);
const uint32_t arrayTypeId = CreateWordArrayTypeBefore(
irContext, plan.structType, uintTypeId, plan.wordCount, plan.openEnded);
if (arrayTypeId == 0) {
// Nothing was written for it, so the module is still the one that came in.
MGLOG_D("[spirv] storage block %%%u: no legal place for the flattened word array; "
"left to the fp64 demotion",
structId);
continue;
}
// Past this point the module HAS been written to, so an abandoned block would
// leave a dead type behind - the status has to say so even then.
touched = true;
const uint32_t wordPointerTypeId = irContext->get_type_mgr()->FindPointerToType(
uintTypeId, static_cast<spv::StorageClass>(plan.storageClass));
if (wordPointerTypeId == 0) continue;
@@ -1095,6 +1289,9 @@ namespace MobileGL {
}
irContext->KillInst(chainPlan.chain);
}
for (Instruction* arrayLength : plan.arrayLengths) {
emitter.RewriteArrayLength(arrayLength, plan.wordCount, plan.tailStrideWords);
}
const std::vector<spv::Decoration> surviving = SurvivingAccessQualifiers(
irContext, structId, plan.structType->NumInOperands());
@@ -1113,12 +1310,19 @@ namespace MobileGL {
{SPV_OPERAND_TYPE_DECORATION, {static_cast<uint32_t>(kind)}}});
}
modified = true;
MGLOG_D("[spirv] storage block %%%u: flattened into %u words so its 64-bit members keep "
"the byte layout the application bound",
structId, plan.wordCount);
if (plan.openEnded) {
MGLOG_D("[spirv] storage block %%%u: flattened into an open-ended word array (%u-word "
"prefix, %u-word elements) so its 64-bit members keep the byte layout the "
"application bound",
structId, plan.wordCount, plan.tailStrideWords);
} else {
MGLOG_D("[spirv] storage block %%%u: flattened into %u words so its 64-bit members "
"keep the byte layout the application bound",
structId, plan.wordCount);
}
}
if (!modified) {
if (!modified && !touched) {
return Status::SuccessWithoutChange;
}
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
@@ -66,17 +66,32 @@ namespace MobileGL {
// fp32 promise DemoteFloat64Pass already makes - what changes is only that the
// BYTES around the value stay where the application put them.
//
// AN OPEN-ENDED BLOCK - one whose last member is a runtime array, the
// `buffer B { double data[]; }` every unsized storage buffer is spelled as - is
// flattened the same way: the members before the array are the fixed prefix, and
// the flattened member is itself a `uint[]` runtime array, ArrayStride 4, with no
// length for the driver to re-derive. Element i of the original array lives at
// word `prefix + i * stride` of it, which is where the application put it. The
// block's `.length()` is rewritten too, because OpArrayLength on the flattened
// member counts WORDS: it becomes `(words - prefix) / stride` in unsigned
// arithmetic, clamped at zero when the bound range is shorter than the prefix,
// which is the floor GL defines `.length()` as.
//
// DECLINES, leaving the block exactly as it was for DemoteFloat64Pass to handle the
// old way, whenever it meets something it cannot rewrite exactly:
// - a block whose variable is used as anything but an access-chain base (loaded
// whole, handed to a function, asked its OpArrayLength);
// whole, handed to a function), or asked an OpArrayLength it is not open-ended
// for;
// - an access chain that is not rooted at the variable, or whose result feeds
// anything but a plain OpLoad / OpStore (an atomic, OpCopyMemory, a further
// chain);
// - a non-constant index into a struct, a runtime array anywhere in the block, a
// RowMajor matrix (its columns are not contiguous, so a whole-column access is
// not one range), a member width other than 32 or 64 bits, or an offset or
// stride that is not a multiple of 4;
// chain), or one that names a whole runtime array rather than an element of it;
// - a non-constant index into a struct, a runtime array that is not the last
// member of the block itself (nested in a member, or followed by another -
// shapes GLSL cannot spell but SPIR-V can), a runtime array without an
// ArrayStride or whose element the pass cannot decompose, a RowMajor matrix (its
// columns are not contiguous, so a whole-column access is not one range), a
// member width other than 32 or 64 bits, or an offset or stride that is not a
// multiple of 4;
// - a load or store whose type decomposes into more scalars than the cap below,
// so legalizing a block can never explode the module.
//
@@ -78,9 +78,9 @@ namespace MobileGL {
}
// Locations one value of `type` occupies (GL 4.6 core 11.1.2.1 / 15.2): a
// matrix takes one per column, a double-precision vector wider than two takes
// two, an array takes its element's span once per element. 0 means "this pass
// cannot place it", which declines the whole block rather than guessing.
// matrix takes one per column, a 64-bit vector wider than two takes two, an
// array takes its element's span once per element. 0 means "this pass cannot
// place it", which declines the whole block rather than guessing.
Uint32 LocationSpan(const analysis::Type* type) {
if (type == nullptr) return 0;
if (type->AsFloat() != nullptr || type->AsInteger() != nullptr ||
@@ -93,8 +93,14 @@ namespace MobileGL {
element->AsBool() == nullptr) {
return 0;
}
// 64-bit INTEGERS span two locations exactly like doubles do:
// ARB_gpu_shader_int64 extends 11.1.2.1's double-precision rule
// verbatim to i64/u64. Answering 1 for an i64vec4 would pack the
// members after it onto locations that varying already owns.
const auto* elementFloat = element->AsFloat();
const Bool is64Bit = elementFloat != nullptr && elementFloat->width() == 64;
const auto* elementInteger = element->AsInteger();
const Bool is64Bit = (elementFloat != nullptr && elementFloat->width() == 64) ||
(elementInteger != nullptr && elementInteger->width() == 64);
return (is64Bit && vector->element_count() > 2) ? 2u : 1u;
}
if (const auto* matrix = type->AsMatrix()) {
@@ -0,0 +1,188 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripIoBlockLocationsPass.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 "StripIoBlockLocationsPass.h"
#include "spirv.hpp"
#include "source/opt/def_use_manager.h"
#include "source/opt/instruction.h"
#include "source/opt/ir_context.h"
#include "source/opt/module.h"
#include "source/util/make_unique.h"
#include <unordered_set>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
// Every struct type carrying the Block decoration, minus the ones with a builtin
// member (gl_PerVertex and friends): those are spelled by the language, carry no
// user Location, and are not what this pass is about. Same shape as
// UniquifyIoBlockNamesPass::CollectUserBlockStructIds, and deliberately kept
// beside its own pass rather than shared - the two ask the same question of the
// module but are armed by different gates, and one growing a special case must
// not silently move the other.
std::unordered_set<uint32_t> CollectUserBlockStructIds(IRContext* irContext) {
std::unordered_set<uint32_t> blockStructIds;
std::unordered_set<uint32_t> builtinStructIds;
for (Instruction& annotation : irContext->module()->annotations()) {
if (annotation.opcode() == spv::Op::OpDecorate) {
if (static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) ==
spv::Decoration::Block) {
blockStructIds.insert(annotation.GetSingleWordInOperand(0));
}
} else if (annotation.opcode() == spv::Op::OpMemberDecorate) {
if (static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(2)) ==
spv::Decoration::BuiltIn) {
builtinStructIds.insert(annotation.GetSingleWordInOperand(0));
}
}
}
for (const uint32_t builtinStructId : builtinStructIds) {
blockStructIds.erase(builtinStructId);
}
return blockStructIds;
}
// The interface-block struct an Input/Output variable declares, or 0 when the
// variable is not one. Tessellation and geometry interfaces are arrays of the
// block struct, so array levels are unwrapped before the struct is recognised.
uint32_t GetInterfaceBlockStructId(IRContext* irContext, Instruction& variable,
const std::unordered_set<uint32_t>& blockStructIds,
spv::StorageClass& outStorageClass) {
if (variable.opcode() != spv::Op::OpVariable) return 0;
const auto storageClass =
static_cast<spv::StorageClass>(variable.GetSingleWordInOperand(0));
if (storageClass != spv::StorageClass::Input &&
storageClass != spv::StorageClass::Output) {
return 0;
}
auto* defUseMgr = irContext->get_def_use_mgr();
Instruction* pointerType = defUseMgr->GetDef(variable.type_id());
if (pointerType == nullptr || pointerType->opcode() != spv::Op::OpTypePointer) {
return 0;
}
uint32_t pointeeId = pointerType->GetSingleWordInOperand(1);
Instruction* pointee = defUseMgr->GetDef(pointeeId);
while (pointee != nullptr && (pointee->opcode() == spv::Op::OpTypeArray ||
pointee->opcode() == spv::Op::OpTypeRuntimeArray)) {
pointeeId = pointee->GetSingleWordInOperand(0);
pointee = defUseMgr->GetDef(pointeeId);
}
if (pointee == nullptr || pointee->opcode() != spv::Op::OpTypeStruct) return 0;
if (blockStructIds.find(pointeeId) == blockStructIds.end()) return 0;
outStorageClass = storageClass;
return pointeeId;
}
Bool DirectionIsArmed(spv::StorageClass storageClass, Bool stripInputBlocks,
Bool stripOutputBlocks) {
return storageClass == spv::StorageClass::Input ? stripInputBlocks : stripOutputBlocks;
}
} // namespace
spvtools::opt::Pass::Status StripIoBlockLocationsPass::Process() {
if (m_strippedAny != nullptr) *m_strippedAny = false;
if (!m_stripInputBlocks && !m_stripOutputBlocks) return Status::SuccessWithoutChange;
auto* irContext = context();
const std::unordered_set<uint32_t> blockStructIds = CollectUserBlockStructIds(irContext);
if (blockStructIds.empty()) return Status::SuccessWithoutChange;
// What to strip, resolved BEFORE anything is killed: the walk below deletes
// annotations, and deciding what to delete while deleting reads a list that is
// being mutated underneath it.
//
// BOTH LEVELS, because a block carries its location at exactly one of them and
// which one is not the caller's choice. When the location came from the
// cross-stage IO resolver (or from `layout(location=) out Blk {...}`) glslang
// puts it on the VARIABLE; when the application located the members instead
// (`out Blk { layout(location = 4) vec4 v; }`) it puts one OpMemberDecorate per
// member and NOTHING on the variable - and SPIRV-Cross then suppresses the
// block-level qualifier and prints the member ones instead
// (spirv_glsl.cpp:1444 and :2037-2045). Stripping only the variable level would
// leave that second shape emitting exactly the located block this driver drops
// the payload for, and - because there was no variable decoration to remove -
// would report nothing stripped, so the caller would decline the module and
// nothing would say the repair had passed the shader by.
std::unordered_set<uint32_t> armedVariableIds;
std::unordered_set<uint32_t> armedStructIds;
// Block structs reached by an interface variable whose direction is NOT armed.
// A struct in here is left alone even if some armed variable also reaches it:
// member decorations belong to the TYPE, so stripping them would take the
// qualifier off the unarmed side too - the one whose other end is in a
// different program and is matched by exactly that number.
std::unordered_set<uint32_t> unarmedStructIds;
for (Instruction& variable : irContext->module()->types_values()) {
spv::StorageClass storageClass = spv::StorageClass::Input;
const uint32_t structId =
GetInterfaceBlockStructId(irContext, variable, blockStructIds, storageClass);
if (structId == 0) continue;
if (DirectionIsArmed(storageClass, m_stripInputBlocks, m_stripOutputBlocks)) {
armedVariableIds.insert(variable.result_id());
armedStructIds.insert(structId);
} else {
unarmedStructIds.insert(structId);
}
}
for (const uint32_t unarmedStructId : unarmedStructIds) {
armedStructIds.erase(unarmedStructId);
}
if (armedVariableIds.empty()) return Status::SuccessWithoutChange;
// Component travels with Location and is meaningless without it. Leaving one
// behind is not merely untidy: for an ES target SPIRV-Cross THROWS on a block
// member's Component (spirv_glsl.cpp:1447-1460) rather than printing it, which
// costs the whole stage.
const auto isLocationOrComponent = [](uint32_t decoration) {
return static_cast<spv::Decoration>(decoration) == spv::Decoration::Location ||
static_cast<spv::Decoration>(decoration) == spv::Decoration::Component;
};
std::vector<Instruction*> toKill;
for (Instruction& annotation : irContext->module()->annotations()) {
if (annotation.opcode() == spv::Op::OpDecorate) {
if (!isLocationOrComponent(annotation.GetSingleWordInOperand(1))) continue;
if (armedVariableIds.find(annotation.GetSingleWordInOperand(0)) ==
armedVariableIds.end()) {
continue;
}
toKill.push_back(&annotation);
} else if (annotation.opcode() == spv::Op::OpMemberDecorate) {
// OpMemberDecorate <struct> <member> <decoration> ...
if (!isLocationOrComponent(annotation.GetSingleWordInOperand(2))) continue;
if (armedStructIds.find(annotation.GetSingleWordInOperand(0)) ==
armedStructIds.end()) {
continue;
}
toKill.push_back(&annotation);
}
}
for (Instruction* inst : toKill) {
irContext->KillInst(inst);
}
if (m_strippedAny != nullptr) *m_strippedAny = !toKill.empty();
return toKill.empty() ? Status::SuccessWithoutChange : Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken StripIoBlockLocationsPass::CreateStripIoBlockLocationsPass(
Bool stripInputBlocks, Bool stripOutputBlocks, Bool* strippedAny) {
return spvtools::Optimizer::PassToken(spvtools::MakeUnique<StripIoBlockLocationsPass>(
stripInputBlocks, stripOutputBlocks, strippedAny));
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,91 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripIoBlockLocationsPass.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 "source/opt/pass.h"
#include "spirv-tools/optimizer.hpp"
#include <Includes.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Drops the Location (and Component) decoration from an inter-stage interface
// BLOCK variable, so SPIRV-Cross emits `out FOO { ... } x;` instead of
// `layout(location = N) out FOO { ... } x;`.
//
// WHY. On the Mali-G1-Ultra ES driver (r54p1), an interface block that carries an
// explicit layout(location=) transports NOTHING across any boundary that involves
// a tessellation or geometry stage. The stages compile, the program links with an
// empty info log, the draw runs - and the consuming stage reads zeroes. The same
// program with the qualifier removed from the blocks (and nothing else changed)
// carries the payload correctly. Measured with no MobileGL in the process at all:
// a bare EGL/GLES 3.2 program built from the five ESSL stages MobileGL emits for
// KHR-GLxx.shading_language_420pack.length_of_vector_and_matrix_* reproduces it,
// and a three-stage VS->GS->FS reduction isolates it to
// (block carries a location) AND (a tessellation or geometry stage is present).
// A located block between a vertex and a fragment stage is fine on the same
// driver, which is why the caller only arms this for programs that have one of
// those stages.
//
// The locations are not the application's: these blocks carry no location in the
// GLSL source at all (the 420pack cases declare none). glslang's cross-stage IO
// resolver invents them, SPIRV-Cross prints them because ESSL >= 310 allows a
// location on a block, and nothing downstream needs them - ES matches inter-stage
// blocks by block name plus member sequence, which is exactly what
// UniquifyIoBlockNamesPass keeps consistent across the program.
//
// WHAT. Only variables in Input/Output storage whose (array-unwrapped) pointee is
// a Block-decorated struct. Plain varyings keep their locations - they work on
// this driver and are how the fragment stage's inputs and outputs are matched -
// and so do vertex attributes and fragment outputs, which are never blocks.
// Builtin blocks (gl_PerVertex) are skipped; they carry no Location anyway.
//
// BOTH DECORATION LEVELS, because a block carries its location at exactly one of
// them: on the VARIABLE when the cross-stage IO resolver assigned it (or the
// application wrote `layout(location=) out Blk {...}`), and on the MEMBERS when the
// application located those instead - in which case glslang puts nothing on the
// variable at all and SPIRV-Cross suppresses the block-level qualifier in favour of
// the member ones. A variable-only strip would silently pass that second shape by.
// A struct reached by an interface variable whose direction is NOT armed keeps its
// member decorations: they belong to the type, and taking them off would strip the
// unarmed side too.
//
// The two directions are armed SEPARATELY by the caller, because an interface
// whose other end lives in a DIFFERENT program (a separable program pipeline)
// must keep its location: that is the only thing matching it there, and the other
// program never saw this decision. In a monolithic program both ends are present
// and both flags are set.
//
// DirectGLES only: DirectVulkan hands the module to the driver as SPIR-V, where
// Location is how interfaces are matched and removing it would be a miscompile.
class StripIoBlockLocationsPass final : public spvtools::opt::Pass {
public:
// `stripInputBlocks` covers the blocks this stage CONSUMES and
// `stripOutputBlocks` the ones it PRODUCES. `strippedAny`, when non-null,
// receives whether this stage actually had one, so the caller can decline the
// re-serialised module when there was nothing to strip.
StripIoBlockLocationsPass(Bool stripInputBlocks, Bool stripOutputBlocks,
Bool* strippedAny = nullptr)
: m_stripInputBlocks(stripInputBlocks), m_stripOutputBlocks(stripOutputBlocks),
m_strippedAny(strippedAny) {}
const char* name() const override { return "mobilegl-strip-io-block-locations"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateStripIoBlockLocationsPass(
Bool stripInputBlocks, Bool stripOutputBlocks, Bool* strippedAny);
private:
Bool m_stripInputBlocks = false;
Bool m_stripOutputBlocks = false;
Bool* m_strippedAny = nullptr;
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -34,7 +34,11 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// gated, so one L1 key shape can describe two materially different module sets (real
// doubles vs demoted-and-flattened) and a blob written under 4 says nothing about
// which one it holds.
constexpr Uint32 kKeyLayoutVersion = 5u;
// 6: L1 gained the two point-size demotion bits (demoteTessellationPointSize /
// demoteGeometryPointSize). Phase B now rewrites the cached modules on a device
// that cannot host gl_PointSize in tessellation/geometry stages, so a blob
// written under 5 says nothing about whether its modules were demoted.
constexpr Uint32 kKeyLayoutVersion = 6u;
// The repo's existing cache epoch (MG_Config::CacheVersion, the seed
// ProgramFactory::ComputeHash uses). Strictly redundant for an in-memory
@@ -128,6 +132,8 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
builder.Value(inputs.shaderCompileFlags);
builder.Value(static_cast<Uint8>(inputs.enableSpirvValidation));
builder.Value(static_cast<Uint8>(inputs.nativeFloat64));
builder.Value(static_cast<Uint8>(inputs.demoteTessellationPointSize));
builder.Value(static_cast<Uint8>(inputs.demoteGeometryPointSize));
builder.Value(static_cast<Uint64>(inputs.stages.size()));
for (const auto& stage : inputs.stages) {
builder.Value(static_cast<Uint32>(stage.type));
@@ -194,6 +200,8 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
static const std::map<String, String> kEmptyRenames;
builder.StringMap(inputs.inputBlockRenames ? *inputs.inputBlockRenames : kEmptyRenames);
builder.StringMap(inputs.outputBlockRenames ? *inputs.outputBlockRenames : kEmptyRenames);
builder.Value(static_cast<Uint8>(inputs.stripInputBlockLocations));
builder.Value(static_cast<Uint8>(inputs.stripOutputBlockLocations));
static const Vector<Uint32> kEmptyWords;
builder.Words(inputs.spirv ? *inputs.spirv : kEmptyWords);
return MakeTranslationCacheKey(builder);
@@ -411,9 +411,17 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
const UnorderedMap<String, Uint>* explicitFragmentOutIndices = nullptr;
Uint32 shaderCompileFlags = 0;
Bool enableSpirvValidation = false;
// CompileEnv::ConsumesFloat64Natively() - the fp64 tail of the sanitize chain. The
// one backend capability bit in this key; see the note above for why it has to be.
// CompileEnv::ConsumesFloat64Natively() - the fp64 tail of the sanitize chain. See
// the note above for why it has to be here.
Bool nativeFloat64 = false;
// CompileEnv::DemotesTessellationPointSize() / DemotesGeometryPointSize() - the
// second and third capability bits under the same rule as nativeFloat64: each ARMS
// a phase-B rewrite of the cached modules themselves
// (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram), so the same GLSL
// produces materially different module sets under the two answers - built-in
// point size kept, or carried as an ordinary varying with the capability stripped.
Bool demoteTessellationPointSize = false;
Bool demoteGeometryPointSize = false;
// ---- inputs that only matter because the PAYLOAD now carries the reflection ----
// When the payload was SPIR-V alone these were provably irrelevant: transform
// feedback is resolved by READING the linked intermediates and never writes an XFB
@@ -631,7 +639,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// --- driver capability bits that arm or steer a pass ---
// Whether LowerViewportIndexForEssl runs on this module. NOT the raw
// GL_OES_viewport_array bit any more: the routing emulation arms the pass even where the
// extension exists (Config.h, ViewportArrayEmulation), so the extension alone no longer
// extension exists (Config.h, EsprytViewportArrayEmulation), so the extension alone no longer
// decides, and a key carrying only it would serve a module lowered under one setting to a
// link made under the other.
Bool viewportIndexLoweringArmed = false;
@@ -658,6 +666,14 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// arguments, so they are exactly as fine as its behaviour and no finer.
const std::map<String, String>* inputBlockRenames = nullptr;
const std::map<String, String>* outputBlockRenames = nullptr;
// THIS STAGE's share of the interface-block LOCATION strip - the two arguments
// StripIoBlockLocationsForEssl is called with, which decide whether the emitted ESSL
// prints `layout(location = N)` on a block at all. False for every program on a
// driver whose POST said located blocks work, and for every program without a
// tessellation or geometry stage. Armed per direction because an interface whose
// other end is in a different program must keep its location.
Bool stripInputBlockLocations = false;
Bool stripOutputBlockLocations = false;
// The top of the reserved storage-block window atomic-counter blocks are moved into
// (`top - N` for GL binding N). Derived from the driver's
@@ -340,17 +340,39 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
// (VkTextureManager::ResolveTextureFormatInfo resolves all six legacy low-bit formats
// to R8G8B8A8_UNORM), so the two backends now agree here.
//
// Only the DESKTOP-ONLY formats move. GL_RGBA4 and GL_RGB5_A1 are ES formats an
// application can legitimately ask for - the same normalization picks the storage for
// glRenderbufferStorage - so widening them would be a memory decision, not a
// correctness one. Nothing about the REPORTED precision moves either way:
// GL_TEXTURE_*_SIZE and glGetInternalformativ answer from TextureMetrics, keyed on the
// requested format, not on the ES storage.
// Only the DESKTOP-ONLY formats move UNCONDITIONALLY. GL_RGBA4, GL_RGB5_A1 and
// GL_RGB565 are ES formats an application can legitimately ask for - the same
// normalization picks the storage for glRenderbufferStorage - so widening them
// used to be declined as "a memory decision, not a correctness one". The 18
// KHR-GL4x.copy_image.functional bodies on Mali falsified that: the driver
// stores SOME packed16 allocations with a MIRRORED field order (allocation-scoped,
// shape- and context-dependent; the failing 30x30x12 arrays are mirrored at every
// level), so a raw glCopyImageSubData between a mirrored allocation and a plain
// one delivers the channels reversed (0x0007 -> 0x3800 for a 5551 word: the
// 1_5_5_5_REV re-encoding of the same fields). Where that is measured -
// WidenPacked16Norm, set from the POST probe or its ForceOn override - the three
// formats take the same 8-bit widening; everywhere else they stay narrow and the
// memory argument stands. Nothing about the REPORTED precision moves either way:
// GL_TEXTURE_*_SIZE and glGetInternalformativ answer from TextureMetrics, keyed on
// the requested format, not on the ES storage.
case GL_R3_G3_B2:
case GL_RGB4:
case GL_RGB5:
*outInternalFormat = GL_RGB8;
break;
// GL_RGB5 above is nominally the same resolution, but a TEXTURE never arrives here
// as GL_RGB5: ConvertGLEnumToTextureInternalFormat folds GL_RGB5 and GL_RGB565 onto
// one logical format whose GL spelling is GL_RGB565, so this case is the one the
// allocation path actually reaches for both spellings.
case GL_RGB565:
*outInternalFormat =
(options & PixelFormatNormalizeOptionBit::WidenPacked16Norm) ? GL_RGB8 : internalFormat;
break;
case GL_RGB5_A1:
case GL_RGBA4:
*outInternalFormat =
(options & PixelFormatNormalizeOptionBit::WidenPacked16Norm) ? GL_RGBA8 : internalFormat;
break;
case GL_RGB10:
case GL_RGB12:
*outInternalFormat = (options & PixelFormatNormalizeOptionBit::NoNorm16) ||
@@ -44,6 +44,19 @@ namespace MobileGL {
// IS exact - every value in [-127, 127] divided by 127 round-trips through a half - so the
// substitute matches what the always-on GL_RGBA8_SNORM fallback already picks.
NoSnorm8RenderTarget = 1 << 9,
// Store the three 16-bit packed normalized formats (GL_RGB565, GL_RGB5_A1, GL_RGBA4)
// as 8-bit-per-channel ES storage (GL_RGB8 / GL_RGBA8), the way the desktop-only
// narrow formats already are. Set by DirectGLES when the driver's 16-bit packed
// storage cannot be trusted as a raw-copy endpoint: some Mali drivers store SOME
// packed16 allocations with a MIRRORED field order (which ones depends on shape and
// context history - measured on a three-level 30x30x12 2D array, every level of it),
// so glCopyImageSubData (a raw texel-block move) between a mirrored allocation and a
// plain one delivers the channels reversed. The
// (format, type) transfer pair does not move with the bit - it is already the
// UNorm8 component layout the canonical shadow holds for all three formats.
// Reported precision does not move either: GL_TEXTURE_*_SIZE and
// glGetInternalformativ answer from TextureMetrics, keyed on the requested format.
WidenPacked16Norm = 1 << 10,
None = 0,
};
namespace MG_Util::TextureFormatProcessor {
+3 -3
View File
@@ -157,12 +157,12 @@ MobileGL supports runtime configuration via environment variables.
|-------------------------|--------------------------------------------------|--------------------------------------|----------------|
| `MOBILEGL_BACKEND_TYPE` | Select active backend implementation at startup. | `DirectGLES`, `DirectVulkan` | `DirectGLES` |
| `MOBILEGL_DISABLE_TIMERQUERY` | Disable GPU timer-query exposure and use. | `0`, `1` | `0` |
| `MOBILEGL_USE_ANGLE` | Load ANGLE EGL/GLES libraries. | `0`, `1` | `0` |
| `MOBILEGL_DISABLE_SUBGROUP` | Disable Vulkan shader subgroup support. | `0`, `1` | `0` |
| `MOBILEGL_ESPRYT_USE_ANGLE` | Load ANGLE EGL/GLES libraries. | `0`, `1` | `0` |
| `MOBILEGL_MAGMA_DISABLE_SUBGROUP` | Disable Vulkan shader subgroup support. | `0`, `1` | `0` |
| `MOBILEGL_ADVERTISE_FP64` | Advertise `GL_ARB_gpu_shader_fp64`. GLSL `double`/`dvec`/`dmat` compile and run either way - they are narrowed to 32 bits - so this only changes whether an application is told it has 64-bit precision, which it does not. | `0`, `1` | `0` |
| `MOBILEGL_MAGMA_R11G11B10F_FALLBACK` | Use Magma's R11G11B10F format fallback. | `0`, `1` | `0` |
| `MOBILEGL_MAGMA_FRAMESINFLIGHT` | Set Magma frames in flight. | Integer `1``64` | `3` |
| `MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER` | Avoid sampler mipmap minification filters. | `0`, `1` | `0` |
| `MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER` | Avoid sampler mipmap minification filters. | `0`, `1` | `0` |
| `MOBILEGL_COHERENT_AS_FLUSH` | Treat persistent `GL_MAP_FLUSH_EXPLICIT_BIT` maps as coherent (app-compat for engines like Flywheel that never flush them). | `0`, `1` | `0` |
| `MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION` | Always emulate depth/stencil `glReadPixels`/`glGetTexImage` by shader sampling on Espryt, instead of using the driver's own depth/stencil readback where it has one. | `0`, `1` | `0` |
| `VK_ICD_FILENAMES` | Select the Vulkan ICD used by the Vulkan loader. | Path to an ICD JSON file | Loader default |
+1 -1
View File
@@ -51,7 +51,7 @@ Implementation notes:
- `DirectGLES` and `DirectVulkan` replay on the Activity `SurfaceView` by default. DirectGLES can still use the old offscreen EGL pbuffer path by passing `use_pbuffer=true`.
- Golden comparison is implemented in native C++ with libpng RGBA decode and SSIM validation. The Java Activity only passes arguments and displays the native result, so the replay/compare core is not tied to Android UI or Bitmap APIs and can be ported to Linux.
- The plugin profile still excludes `libtrace_replay_runner.so`; normal plugin APK behavior is preserved.
- Set `MOBILEGL_USE_ANGLE=1` and `MOBILEGL_TRACE_ANGLE_VARIANT=<short-hash>` when running `trace-replay-ci.sh` for DirectGLES. The trace APK contains both allowlisted ANGLE builds with short-hash filenames and SONAMEs; MobileGL resolves its signed native library directory and loads the selected pair by absolute path. Set `MOBILEGL_RETRACE_USE_PBUFFER=1` or pass `--use-pbuffer` to keep DirectGLES offscreen.
- Set `MOBILEGL_ESPRYT_USE_ANGLE=1` and `MOBILEGL_TRACE_ANGLE_VARIANT=<short-hash>` when running `trace-replay-ci.sh` for DirectGLES. The trace APK contains both allowlisted ANGLE builds with short-hash filenames and SONAMEs; MobileGL resolves its signed native library directory and loads the selected pair by absolute path. Set `MOBILEGL_RETRACE_USE_PBUFFER=1` or pass `--use-pbuffer` to keep DirectGLES offscreen.
Example core-profile trace smoke command for a debug trace APK:
+3 -3
View File
@@ -89,13 +89,13 @@ val pluginRendererConfig = buildJsonValue {
items = RendererConfig.EnvItems("DirectGLES", listOf("DirectVulkan")),
)
toggleable("MOBILEGL_DISABLE_TIMERQUERY", "1", false, RendererConfig.MetaString("mobilegl_disable_timerquery_title"))
toggleable("MOBILEGL_DISABLE_SUBGROUP", "1", false, RendererConfig.MetaString("mobilegl_disable_subgroup_title"))
toggleable("MOBILEGL_MAGMA_DISABLE_SUBGROUP", "1", false, RendererConfig.MetaString("mobilegl_disable_subgroup_title"))
toggleable("MOBILEGL_MAGMA_R11G11B10F_FALLBACK", "1", false, RendererConfig.MetaString("mobilegl_magma_r11g11b10f_fallback_title"))
customizable("MOBILEGL_MAGMA_FRAMESINFLIGHT", "3", RendererConfig.MetaString("mobilegl_magma_frames_inflight_title"))
toggleable("MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER", "1", false, RendererConfig.MetaString("mobilegl_avoid_sampler_mipmap_min_filter_title"))
toggleable("MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER", "1", false, RendererConfig.MetaString("mobilegl_avoid_sampler_mipmap_min_filter_title"))
toggleable("MOBILEGL_COHERENT_AS_FLUSH", "1", false, RendererConfig.MetaString("mobilegl_coherent_as_flush_title"))
toggleable("MOBILEGL_RELAXED_SEMANTICS", "1", false, RendererConfig.MetaString("mobilegl_relaxed_semantics_title"))
toggleable("MOBILEGL_USE_ANGLE", "1", false, RendererConfig.MetaString("mobilegl_use_angle_title"))
toggleable("MOBILEGL_ESPRYT_USE_ANGLE", "1", false, RendererConfig.MetaString("mobilegl_use_angle_title"))
},
minMCVer = null,
maxMCVer = null,
@@ -88,7 +88,7 @@ bool UseAngleForRequest(const Request& request) {
if (request.useAngle) {
return true;
}
const char* value = getenv("MOBILEGL_USE_ANGLE");
const char* value = getenv("MOBILEGL_ESPRYT_USE_ANGLE");
return value != nullptr && strcmp(value, "1") == 0;
}
@@ -146,21 +146,21 @@ bool LoadMobileGL(const Request& request, std::string& error) {
unsetenv("MOBILEGL_MAGMA_R11G11B10F_FALLBACK");
}
if (UseAngleForRequest(request)) {
setenv("MOBILEGL_USE_ANGLE", "1", 1);
setenv("MOBILEGL_ESPRYT_USE_ANGLE", "1", 1);
setenv("MOBILEGL_TRACE_ANGLE_VARIANT", request.angleVariant.c_str(), 1);
} else {
unsetenv("MOBILEGL_USE_ANGLE");
unsetenv("MOBILEGL_ESPRYT_USE_ANGLE");
unsetenv("MOBILEGL_TRACE_ANGLE_VARIANT");
}
if (request.avoidAngleLlvmpipeSamplerMipmapMinFilter) {
setenv("MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER", "1", 1);
setenv("MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER", "1", 1);
} else {
unsetenv("MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
unsetenv("MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
}
if (request.avoidAngleLlvmpipeExplicitLodBias) {
setenv("MOBILEGL_AVOID_EXPLICIT_LOD_BIAS", "1", 1);
setenv("MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS", "1", 1);
} else {
unsetenv("MOBILEGL_AVOID_EXPLICIT_LOD_BIAS");
unsetenv("MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS");
}
if (request.coherentAsFlush) {
setenv("MOBILEGL_COHERENT_AS_FLUSH", "1", 1);
@@ -168,19 +168,19 @@ bool LoadMobileGL(const Request& request, std::string& error) {
unsetenv("MOBILEGL_COHERENT_AS_FLUSH");
}
if (request.fixIterationRPSubgroupScratch) {
setenv("MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH", "1", 1);
setenv("MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH", "1", 1);
} else {
unsetenv("MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH");
unsetenv("MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH");
}
if (request.deriveNumSubgroups) {
setenv("MOBILEGL_DERIVE_NUM_SUBGROUPS", "1", 1);
setenv("MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS", "1", 1);
} else {
unsetenv("MOBILEGL_DERIVE_NUM_SUBGROUPS");
unsetenv("MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS");
}
if (request.iterationRPFixBarrier) {
setenv("MOBILEGL_ITERATIONRP_FIX_BARRIER", "1", 1);
setenv("MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER", "1", 1);
} else {
unsetenv("MOBILEGL_ITERATIONRP_FIX_BARRIER");
unsetenv("MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER");
}
if (request.fboAttachmentDumps.empty()) {
unsetenv("MOBILEGL_TRACE_DUMP_FBO_ATTACHMENTS");
+8 -8
View File
@@ -38,20 +38,20 @@ Usage:
[--reuse-fixture] \
--timeout-seconds N
Set MOBILEGL_USE_ANGLE=1 to run DirectGLES replay with packaged ANGLE
Set MOBILEGL_ESPRYT_USE_ANGLE=1 to run DirectGLES replay with packaged ANGLE
instead of the device system GLES driver.
Set MOBILEGL_TRACE_ANGLE_VARIANT to the packaged ANGLE short hash used by
DirectGLES replay.
Set MOBILEGL_RETRACE_USE_PBUFFER=1 or pass --use-pbuffer to run DirectGLES
against an offscreen EGL pbuffer instead of the Activity surface.
Set MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH=1,
MOBILEGL_DERIVE_NUM_SUBGROUPS=1, and MOBILEGL_ITERATIONRP_FIX_BARRIER=1 to
Set MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH=1,
MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS=1, and MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER=1 to
forward the corresponding iterationRP SPIR-V repairs into the APK process.
Pass --avoid-angle-llvmpipe-sampler-mipmap-min-filter for DirectGLES traces that
need ANGLE llvmpipe sampler mipmap filters downgraded to avoid driver stalls.
Pass --avoid-angle-llvmpipe-explicit-lod-bias for DirectGLES traces whose shaders
sample with an explicit LOD that ANGLE llvmpipe cannot take a LOD bias on
(MOBILEGL_AVOID_EXPLICIT_LOD_BIAS=1).
(MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS=1).
Pass --coherent-as-flush for traces whose engine writes persistent
GL_MAP_FLUSH_EXPLICIT_BIT maps it never flushes (MOBILEGL_COHERENT_AS_FLUSH=1).
Pass --benchmark to replay the whole trace as a frame-timing benchmark instead of
@@ -348,7 +348,7 @@ run_retrace() {
if [ -n "${alternate_golden_path}" ]; then
alternate_golden_app_path="${app_dir}/input/alternate-golden.png"
fi
if [ "${MOBILEGL_USE_ANGLE:-}" = "1" ] && [ "${backend}" = "DirectGLES" ]; then
if [ "${MOBILEGL_ESPRYT_USE_ANGLE:-}" = "1" ] && [ "${backend}" = "DirectGLES" ]; then
use_angle=1
test -n "${MOBILEGL_TRACE_ANGLE_VARIANT:-}" || die "MOBILEGL_TRACE_ANGLE_VARIANT is required for DirectGLES ANGLE replay"
fi
@@ -388,13 +388,13 @@ run_retrace() {
if [ "${coherent_as_flush}" -eq 1 ]; then
set -- "$@" --ez coherent_as_flush true
fi
if [ "${MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH:-}" = "1" ]; then
if [ "${MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH:-}" = "1" ]; then
set -- "$@" --ez fix_iterationrp_subgroup_scratch true
fi
if [ "${MOBILEGL_DERIVE_NUM_SUBGROUPS:-}" = "1" ]; then
if [ "${MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS:-}" = "1" ]; then
set -- "$@" --ez derive_num_subgroups true
fi
if [ "${MOBILEGL_ITERATIONRP_FIX_BARRIER:-}" = "1" ]; then
if [ "${MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER:-}" = "1" ]; then
set -- "$@" --ez iterationrp_fix_barrier true
fi
if [ -n "${texture_2d_dumps}" ]; then
+47 -4
View File
@@ -51,6 +51,48 @@ def wait_for_device(serial, attempts=20, delay=15):
return False
def device_file_size(serial, path):
r = adb(serial, "shell", f"stat -c %s {path} 2>/dev/null || echo 0", timeout=30)
m = re.search(r"(\d+)", r.stdout or "")
return int(m.group(1)) if m else 0
def run_chunk(serial, cmd, dev_qpa, dev_list, idle_timeout, poll_interval=15):
"""Run one glcts invocation; give up only when the log stops growing.
A chunk is thousands of cases and legitimately runs for an hour, so a fixed
wall-clock cap would kill healthy invocations and record whichever case was
in flight as a crash. A GPU hang, by contrast, stops the .qpa from growing.
The timeout is therefore measured from the last observed growth of the
device-side log. On expiry the device-side glcts is killed (matched by the
caselist path this runner alone uses, so other processes are left alone) and
returncode 124 is reported, the same signal a hard timeout used to give.
"""
proc = subprocess.Popen(["adb", "-s", serial, "shell", cmd],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
last_size = -1
last_growth = time.time()
while True:
try:
out, err = proc.communicate(timeout=poll_interval)
return subprocess.CompletedProcess(proc.args, proc.returncode, out, err)
except subprocess.TimeoutExpired:
pass
size = device_file_size(serial, dev_qpa)
now = time.time()
if size != last_size:
last_size = size
last_growth = now
elif now - last_growth > idle_timeout:
adb(serial, "shell", f"pkill -f {dev_list}", timeout=30)
proc.kill()
try:
proc.communicate(timeout=30)
except subprocess.TimeoutExpired:
pass
return subprocess.CompletedProcess(proc.args, 124, "", "idle timeout")
def mem_available_kb(serial):
r = adb(serial, "shell", "grep MemAvailable /proc/meminfo", timeout=30)
m = re.search(r"(\d+)", r.stdout or "")
@@ -152,7 +194,8 @@ def main():
ap.add_argument("--min-mem-kb", type=int, default=400000,
help="pause when the device drops below this much available memory")
ap.add_argument("--chunk-timeout", type=int, default=900,
help="seconds before giving up on one glcts invocation (a GPU hang never returns)")
help="seconds without any growth of the device-side .qpa before the glcts "
"invocation is declared hung and killed (a GPU hang never returns)")
ap.add_argument("--skip-file", default=None,
help="file of case names to exclude, e.g. cases known to hang the device")
ap.add_argument("--env", action="append", default=[], metavar="K=V",
@@ -239,10 +282,10 @@ def main():
f"--deqp-log-images=disable --deqp-log-shader-sources=disable "
f"--deqp-log-filename={dev_qpa} > /dev/null 2>&1; rc=$?; sync; echo RC=$rc"
)
run = adb(args.serial, "shell", cmd, timeout=args.chunk_timeout)
run = run_chunk(args.serial, cmd, dev_qpa, dev_list, args.chunk_timeout)
if run.returncode == 124:
print(f"[run_cts] chunk {chunk:04d} timed out after {args.chunk_timeout}s "
f"(likely a GPU hang)", file=sys.stderr)
print(f"[run_cts] chunk {chunk:04d}: no log growth for {args.chunk_timeout}s "
f"(likely a GPU hang); killed glcts", file=sys.stderr)
# Some cases hang the GPU hard enough to reboot the device. The log on
# /data/local/tmp survives that, so wait for the device to come back and
+1 -1
View File
@@ -118,7 +118,7 @@ def backend_env(backend, device_dir, use_angle=False):
'MOBILEGL_LOG_FILE_PATH': f'{device_dir}/mobilegl.log',
}
if backend == 'DirectGLES':
env['MOBILEGL_USE_ANGLE'] = '1' if use_angle else '0'
env['MOBILEGL_ESPRYT_USE_ANGLE'] = '1' if use_angle else '0'
if backend == 'DirectVulkan':
# The device ICD usually lacks VK_EXT_headless_surface, which the
# MobileGL pbuffer path needs; use a real ANativeWindow from
+2 -2
View File
@@ -284,7 +284,7 @@ unflushed persistent maps, e.g. the Create fixtures), pass
`MOBILEGL_COHERENT_AS_FLUSH=1`. For cases registered with
`avoid_angle_llvmpipe_explicit_lod_bias` (DirectGLES on ANGLE llvmpipe, e.g. the
sundial-lite fixture), pass `--ez avoid_angle_llvmpipe_explicit_lod_bias true` so
the replay runs with `MOBILEGL_AVOID_EXPLICIT_LOD_BIAS=1`.
the replay runs with `MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS=1`.
## Benchmark mode (frame timing)
@@ -380,7 +380,7 @@ ln -sf $ANGLE/libEGL.so libEGL.so
ln -sf $ANGLE/libGLESv2.so libGLESv2.so
ln -sf $ANGLE/libvulkan.so.1 libvulkan.so.1 # else eglInitialize fails
MOBILEGL_USE_ANGLE=1 \
MOBILEGL_ESPRYT_USE_ANGLE=1 \
LD_LIBRARY_PATH=~/angle-farm:/path/to/build/ \
VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json \
ANGLE_DEFAULT_PLATFORM=vulkan \
@@ -193,12 +193,12 @@ def run_case(case, backend, extra_args=None, timeout_seconds=None):
env["PYTHON"] = "python"
env["MSYS2_ARG_CONV_EXCL"] = "/data/*"
if backend_info["use_angle"]:
env["MOBILEGL_USE_ANGLE"] = "1"
env["MOBILEGL_ESPRYT_USE_ANGLE"] = "1"
env["MOBILEGL_TRACE_ANGLE_VARIANT"] = (
BLISS_ANGLE_VARIANT if case["name"] == BLISS_CASE else DEFAULT_ANGLE_VARIANT
)
else:
env.pop("MOBILEGL_USE_ANGLE", None)
env.pop("MOBILEGL_ESPRYT_USE_ANGLE", None)
env.pop("MOBILEGL_TRACE_ANGLE_VARIANT", None)
result = subprocess.run(command, cwd=ROOT, env=env)
copy_goldens(case, backend)
@@ -489,7 +489,7 @@ uninstall that one package first or the install fails with
`INSTALL_FAILED_UPDATE_INCOMPATIBLE`.
Match the CI environment (`.github/workflows/apk.yml` matrix): the emulator
boots with `--gpu software` + `MOBILEGL_USE_ANGLE=1` for `DirectGLES`
boots with `--gpu software` + `MOBILEGL_ESPRYT_USE_ANGLE=1` for `DirectGLES`
and `--gpu lavapipe` + `MOBILEGL_MAGMA_R11G11B10F_FALLBACK=1` for
`DirectVulkan`. The emulator's ANGLE-on-Vulkan GLES stack exercises genuinely
different driver semantics than physical devices (e.g. indirect-draw
+3 -3
View File
@@ -105,9 +105,9 @@ bool MatchOption(const std::string &arg, const char *name, std::string &inlineVa
bool ParseArgs(int argc, char **argv, mobilegl_trace::Request &request) {
request.backend = "DirectGLES";
request.fixIterationRPSubgroupScratch = ReadEnvFlag("MOBILEGL_FIX_ITERATIONRP_SUBGROUP_SCRATCH");
request.deriveNumSubgroups = ReadEnvFlag("MOBILEGL_DERIVE_NUM_SUBGROUPS");
request.iterationRPFixBarrier = ReadEnvFlag("MOBILEGL_ITERATIONRP_FIX_BARRIER");
request.fixIterationRPSubgroupScratch = ReadEnvFlag("MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH");
request.deriveNumSubgroups = ReadEnvFlag("MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS");
request.iterationRPFixBarrier = ReadEnvFlag("MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER");
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];