Compare commits

..
Author SHA1 Message Date
BZLZHH 3025284a6e [Perf, Test] (MG_Util): libfork execution engine for the shader compile pool, runtime-selectable
Adds libfork v3.8.0 (3rdparty submodule, header-only, wired like the asio
precedent) as a second execution engine behind ShaderCompilePool, selected
per process by MOBILEGL_ASYNC_POOL=asio|libfork (default asio; unknown
values warn and fall back). The engine boundary is deliberately tiny: the
queue, the concurrency budget and its clamping, the suspension latch,
cancel request-vs-outcome, the stopped-is-synchronous fallback and the
drain all stay in the shared Impl - an engine only answers how a
budget-cleared job reaches a worker.

The libfork engine runs detached root tasks as CHAINS: a finished body
takes the next queued job in the same coroutine on the same worker, so
the refills a worker posts are absorbed without a scheduler round trip
(the naive dispatch-thread shape measured 4x worse than asio on short
jobs). Absorption is bounded at one job per live chain - unbounded
absorption serialized bursts posted from inside the pool, which is the
shipped shape (one compile settling fans out link jobs via SubmitAfter
and the adoption map), caught by the review and pinned by a permanent
peak-concurrency regression test (pre-fix: libfork peak 1 vs asio peak 4
on a 16-job worker-posted burst). External submissions go through a
round-robin adaptor instead of lf::lazy_pool::schedule, which both
avoids a data race on lazy_pool's unsynchronized xoshiro under
concurrent submits and beats birthday-collision placement by ~1.3x at
budget == thread count.

The measured answer to "does asio scale poorly": no - the executor was
never the bottleneck. On real pack corpora extracted from the trace
fixtures (BSL 61 shaders, Complementary 277), interleaved best-of-5 per
cell, the engines are within noise of each other at every thread count
(complementary: 4965/2506/1376/824 ms at 1/2/4/8 threads for asio;
libfork within 1%), both ~6x at 8 threads. perf counters show the
flattening past 4 threads is machine-level (instructions flat at 22.1e9
from 1 to 16 threads - no added work, no lock spinning - while cycles
and LLC misses double: memory-stall bound), and the separating control
- N fully independent single-threaded processes with no shared
scheduler at all - scales WORSE than the pool (5.27x vs 5.94x at 8).
The pool microbenchmark does favor libfork on pure dispatch (518 vs
530 ns/job at 1 worker, growing with worker count), but a real compile
body is 1-100 ms, so dispatch is under 0.1% either way. asio therefore
stays the default; this branch exists to make the comparison
reproducible (MG_Test/Util/AsyncPoolBench drives either engine over a
corpus directory) and to keep the alternative viable.

613/613 unit tests in all four combos ({asio, libfork} x {async default
on, kill switch}), integration scenarios byte-identical between engines
on both backends.
2026-08-09 17:24:01 -04:00
BZLZHH d8d7530011 [Fix, Test] (MG_Backend/DirectGLES, MG_Util, MG_Impl): widen three-channel render targets wherever the driver refuses them
Complementary Reimagined would not load through Espryt on Mali: Iris got
GL_FRAMEBUFFER_UNSUPPORTED building its composite framebuffer, because
colortex1 is RGB8_SNORM and colortex2 is RGB16F - three-channel formats
that no real ES driver can render to (EXT_render_snorm covers R/RG/RGBA
only, and the float extensions exclude the RGB forms). The frontend's
probe cache diagnosed this correctly and then had nothing to offer: the
NoThreeChannelRenderTarget widening machinery existed but was gated to
multisample targets alone. llvmpipe turns out to refuse most of the same
attachments - CI retrace stayed green only because a replay never
branches on glCheckFramebufferStatus - so this was never a desktop-vs-
device split, just an unlit path.

The widening now applies to every color-attachable image, renderbuffers
included, riding the driver-probe branch so the native format is still
tried first and substituted only on refusal. One ThreeChannelWidening
table owns the widened (internalformat, format, type) triple per source
format - the previous per-case branches disagreed with each other and
could emit an unuploadable (RGBA16F, GL_RGB, GL_BYTE) combination or
widen into another three-channel format the driver refuses just the
same. Uploads repack three-component client data to four with the
format's own one in the alpha channel (127 is not 1 for RGB8I - the
integer arms carry integer ones); readback drops the synthetic alpha,
derived from the actual image being read, not the bound framebuffer,
so glGetTexImage through a scratch FBO cannot be confused by an
unrelated widened attachment.

Stored alpha on a widened attachment is now an invariant 1.0 rather
than an accident: the color-mask sync clears the alpha bit per draw
buffer (glColorMaski for MRT mixes), and clears route through
glClearBufferfv with alpha substituted on widened slots only -
scissored clears inherit the discipline for free, integer color
buffers keep their explicit integer-clear path, and glGet still
answers the application's own mask. GL_DST_ALPHA blending, blits and
readback therefore all see 1.0 without further interception.

DriverPost grows the rows this bug earned: EXT_color_buffer_float
detection (previously unreferenced anywhere) with a FAIL row when
absent, the missing EXT_render_snorm row, and a three-channel-
attachment row that reports one representative per widening class -
graded so a half-float-only driver warns about the 32-bit float gap
instead of being declared unsupported.

Gates: 606/606 unit at default and with the async kill switch; full
retrace, both backends - the complementary fixtures now run with the
widening ACTIVE on llvmpipe and pass with a slightly better SSIM than
before; ext caselist DirectGLES holds 3914/4867 with zero set drift
while 54 cases move from NotSupported to genuinely passing; on the
Mali-G77 device, Complementary Reimagined builds its pipeline and
renders in-world through Espryt (md5-verified build), BSL still green.
A new ThreeChannelAttachmentScenario pins the frontend answer -
COMPLETE where it used to say UNSUPPORTED - on the real driver.
2026-08-09 15:28:34 -04:00
BZLZHH 0f394fa46f [Test] (tools/trace_replay): pin the Iris glyph-death bug with a BSL pause-menu fixture
minecraft-1.21.4-fabric-iris-bsl-esc-menu-854: Minecraft 1.21.4 Fabric +
Sodium + Iris + BSL 10.1.3 through Espryt on Mali-G77, paused at the ESC
menu over the BSL-blurred world, captured at 854x480 through the FCL
trace-capture skill. The trimmed trace keeps the whole session preamble
deliberately: the 107669b3 bug class is triggered while Iris BUILDS the
pipeline (a by-name texture call swapping the active unit's binding under
the sync memo), and the pause menu afterwards is where its damage is
legible - every glyph alpha-discards against the zeroed lightmap while
the button frames survive.

Ablation-proven as a net before landing: against a build with both layers
of 107669b3 reverted the case fails at ssim 0.949284 and the diff image
is text and only text - menu title, all button labels, the tutorial
toast; against the fixed library it matches the golden exactly, and
Magma passes the same golden at 0.998402 with no alternate needed.
Replays in about a minute per backend on llvmpipe/lavapipe (the 1.3M-call
preamble; well under the timeout and smaller than several existing
fixtures). Existing fixtures spot-checked green.
2026-08-09 12:49:10 -04:00
BZLZHH 107669b3db [Fix, Test] (MG_Impl, MG_Backend/DirectGLES): DSA by-name texture calls corrupted borrowed-slot memo pairings - process-wide glyph death under Iris
Field report: on Espryt with a BSL Iris pipeline built, every glyph in the
game died - HUD, menu labels, even the vanilla title screen after leaving
the world - while sprites kept rendering. Captured on-device (FCL apitrace
rig), reproduced headlessly on llvmpipe, and pinned with a three-way replay:
the same trace renders full text on raw Mesa desktop GL and on Magma, so
the stream was intact and the execution was Espryt's.

MECHANISM. WithTemporarilyBoundNamedTexture implements the by-name (DSA)
texture entry points by binding the named texture onto the active unit's
real slot, running the bound-texture code, and restoring - without moving
the texture bind generation on either edge. DirectGLES's per-draw texture
sync memo keys on that generation and BORROWS the slot pointer, so a memo
built for texture A kept passing every key while a by-name call had
texture B sitting in the slot: A's backend twin was driven with B's
frontend object, and SyncMipmapsToBackend re-specified A's storage with
B's shape. In the trace, a by-name upload to a BSL 2048x2048 map while
the 16x16 lightmap was bound re-specified the lightmap's GL texture
2048x2048-NULL and back 16x16-NULL. The lightmap exists only as render
output - no glTexSubImage2D ever touches it - so it stayed zero forever,
and rendertype_text (vertexColor = Color * texelFetch(lightmap, ...)),
alpha-discards every glyph. Background quads never sample the lightmap,
which is why only text died.

FIX, class-level, two layers:
- Frontend (shared, closes the same hole for DirectVulkan's generation-
  keyed memos): the temporary bind and the restore each bump the texture
  bind generation (only when the slot actually changed), and the restore
  is an RAII scope guard so a throwing body can no longer leak the
  temporary binding - a second latent bug of the same class. Deliberately
  a generation bump and not a touched-unit note: the high-water mark must
  not chase by-name calls, and a completed bind/restore pair leaves the
  content epoch unchanged, so the cost is an owner-compare re-walk, not a
  memo rebuild.
- DirectGLES defense in depth: both borrowed-pair memos
  (g_unitTextureSyncList, g_fboTextureSyncList) record which frontend
  texture each backend twin was paired with and re-check it before any
  replay (last in the key conjunction, behind the context-id compare). A
  stale pairing now costs a list rebuild instead of silent cross-texture
  storage corruption.

Tests, both red with their own layer reverted:
TextureTest.NamedTextureCallKeepsUnitBindingAccountingCoherent (the
accounting contract) and DirectGLESTextureSync.UnitMemoRefusesToDriveA-
TwinFromAnotherTexture (the corrupting sequence shape against a mock GLES
table, asserting the resident texture's storage is never re-specified).
595/595 unit at default and with the async kill switch. Replay evidence:
the captured BSL ESC-menu trace renders all text through Espryt post-fix,
byte-comparable to the Mesa-direct and Magma replays; the no-shaderpack
control is unchanged. A trace fixture wiring this scene into CI follows
in a separate commit.
2026-08-09 11:28:24 -04:00
BZLZHH 3e0460e472 [Feat, Fix] (MG_Impl, MG_State, MG_Backend): program interface queries from frontend reflection; Espryt state-shadow reset
Wave 2 of the advertised-extension conformance campaign.

PROGRAM INTERFACE QUERIES (the load-bearing piece). glGetProgramInterfaceiv
and the five glGetProgramResource* entry points were answered by the
BACKENDS - Espryt asked the real driver about SPIRV-Cross-generated ESSL
whose namespace is not the GL one (default-block uniforms live in
MGL_GLOBAL_UBO there), and Magma kept a second, partial reflection that
hardcoded types and diverged from the frontend. Both are now deleted; a new
frontend resource-model layer (ProgramInterface.{h,cpp}) answers every
interface - uniforms, uniform blocks, atomic-counter buffers (recovered
from glslang's synthesized gl_AtomicCounterBlock_<binding> lowering),
buffer variables, shader-storage blocks (classified by TType storage
qualifier since glslang reflects them as uniform blocks), program inputs/
outputs (built-ins' layoutLocationEnd sentinel mapped to -1), and
transform-feedback varyings including the gl_NextBuffer/gl_SkipComponentsN
pseudo-varyings - from the glslang reflection the frontend already trusts
for glGetActiveUniform. Name/index round-tripping, the "[0]" array
spelling, and the GL 4.6 table 7.2 prop/error matrix live in the new layer
only; GetActiveUniform*/GetActiveAttrib* are untouched.

glShaderStorageBlockBinding now takes the interface-layer index (the one
GetProgramResourceIndex returns, with a range check it never had), records
the binding on the program keyed by block NAME - the one coordinate all
three index spaces agree on - and delegates by name across the backend
boundary. Both backends reseed the recorded bindings on their own program
rebuilds, so an unrelated resync can no longer silently revert a rebound
block, and GL_BUFFER_BINDING reports the live binding, not the declared
one. The Espryt delegate applies only to an already-synced twin and can no
longer trigger SyncToBackend from a getter; the sync path's GL query
out-params are initialized and clamped (a load-dependent stack-garbage
Vector size crash caught by the gate, reproduced 3/50 pre-fix, 100/100
post-fix under saturating load).

ESPRYT RENDER-STATE SHADOW RESET (rides along because it shares
DirectGLES.cpp): the render-state shadow is file-static and survives
MobileGL context switches, so GL_FRAMEBUFFER_SRGB (and the whole synced-cap
class) leaked between contexts - the cross-test leakage class the CTS maps
have carried for a week. MakeCurrent now invalidates the shadow like it
already invalidates the program/FBO/buffer caches, and the resync resolves
a never-set scissor box to the current surface instead of pushing the
(0,0,0,0) sentinel verbatim (which scissored everything away - caught by
the retrace gate, bisected to the exact field via a bitmask probe, and
fixed by resolving like the viewport path rather than reverting).

Frontend riders exposed by the layer: glGetUniformLocation resolves
arrays-of-arrays element addressing ("a[2][1]"); transform-feedback capture
accepts element-addressed varying names ("b[1]") and snapshots the request
verbatim for the interface (Magma's decorate pass logs loudly that element
capture is unimplemented there - follow-up).

KNOWN GAPS, documented in code and tests: the 6 subroutines-* cases
(glslang refuses subroutine for SPIR-V; wave 3), the 5 separate-programs-*
cases (glslang's pipe-I/O reflection cannot see a separable non-vertex
stage's own inputs; needs stage-aware output validation first), and
uniform-block-types' per-instance stage masks (not derivable from the
reflection).

Gate: 593/593 unit at default and kill-switch, x10 each, plus the SSB race
case 100/100 under 20-way CPU load; ext caselist Espryt 77.87% -> 80.42%,
Magma 77.58% -> 79.39% (+212 fixed, 0 newly broken); program_interface_query
2/43 -> 31/43 unique on Espryt, 9/43 -> 31/43 on Magma, backends now
byte-identical; KHR-GL45.direct_state_access 370/371 + 371/371 with the 4
sRGB leak victims recovered in cross-test ordering; KHR-GL33 held at
9884/9886; full 39x2 CI retrace with zero wave-attributable failures (the
3 failing newly-added fixtures are bit-identical on the pristine baseline).
2026-08-09 08:12:30 -04:00
BZLZHH 33ff177bb2 [Fix, Test] (MG_Impl, MG_State): advertised-extension conformance wave 1 - uniforms, validators, getters
First wave of the advertised-extension CTS campaign (targeted caselist: the
glcts groups of every extension both backends advertise, 4867 cases across the
KHR-GL41..46 namespaces). All frontend, shared by both backends:

- Non-square float matrix uniforms actually upload: glUniformMatrix{2x3,3x2,
  2x4,4x2,3x4,4x3}fv and the six glProgramUniformMatrix* twins were
  validate-only no-ops; they now write column-at-a-time at the global UBO's
  16-byte std140 column stride, honouring transpose. glUniformMatrix2fv had
  the sibling bug - mat2 written as 4 contiguous floats put column 1 at byte
  8 instead of 16. The readback path only ever un-padded mat3, so
  glGetUniformfv is fixed for mat2, mat3x2 (previously mis-gathered) and
  every non-square shape, with the bounds check widened to the padded span.
- glBindBufferRange validates offset/size at last: size <= 0, offset < 0,
  SSBO and UBO offset alignment, transform-feedback offset AND size
  multiples of 4 - all before any state write (a negative offset used to
  reach Range1D unchecked). glBindBuffersRange inherits per element, with
  the ARB_multi_bind up-front [first, first+count) checks added to the
  BindBuffersBase/Range and BindSamplers prologues.
- BufferSubData's second, wrong mapped-overlap test deleted (it rejected
  every write at or after a mapped range's start, mapped or not); the state
  layer's assert relaxed to the same half-open intersection the frontend
  checks. BufferStorage error precedence fixed: no-bound-buffer now beats
  bad-size/flags.
- glSamplerParameteri accepts the full GL_NEVER..GL_ALWAYS compare-func
  range (NEVER/LESS/EQUAL were rejected by a wrong lower bound).
  glBindSampler's unit gate uses GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS instead
  of the frontend array capacity, shared with glBindSamplers by construction.
- Getters: GL_MAX_SHADER_STORAGE_BLOCK_SIZE in glGetIntegerv; atomic-counter
  buffer limits; all 11 per-unit GL_TEXTURE_BINDING_* plus GL_SAMPLER_BINDING
  in glGetIntegeri_v; GL_VERTEX_ATTRIB_BINDING/_RELATIVE_OFFSET across the
  vertex-attrib query family; glGetFloati_v/glGetDoublei_v implemented (were
  stubs); KHR_debug limits raised to spec floors.
- glCreateShader records INVALID_ENUM for an unknown type (it previously
  handed out a usable name with no error at all); glCreateShaderProgramv
  validates count up front. glDispatchCompute/Indirect validate work-group
  counts, offset alignment and indirect-buffer presence.
- glVertexAttribIFormat & friends take a positive integer-type whitelist -
  GL_FLOAT/GL_HALF_FLOAT/GL_DOUBLE/GL_FIXED no longer slip through as
  integer attributes.

Gate (headless Mesa, default config = async on): 570/570 unit at default and
with the kill switch; ext caselist Espryt 76.29% -> 77.87% (+81 fixed, 6
crashes -> 0, the whole list now runs in one glcts process), Magma 75.94% ->
77.58% (+80 fixed, 0 newly broken); KHR-GL33 full mustpass lost nothing
(9884/9886, the 2 known Mesa-drift failures); retrace smoke clean (the
bsl-GLES miss is the documented golden drift, bit-identical on the pristine
baseline). The 4 DirectGLES direct_state_access.renderbuffers_storage* cases
that turned red are a PRE-EXISTING GL_FRAMEBUFFER_SRGB cross-test leak,
A/B-proven on an unpatched 2e6fc1ff build - wave 1 removed the two accidental
maskers (a crash partition and a failing case whose error path reset the
state). Fixing the leak itself is queued.
2026-08-09 04:39:22 -04:00
BZLZHH 2e6fc1ffc0 [Feat] (MG_Util, MG_Test): enable asynchronous shader compilation by default (P1 stage 7)
kAsyncShaderCompileDefault flips to true, which also advertises
GL_KHR_parallel_shader_compile by default on both backends. Unset
MOBILEGL_ASYNC_SHADER_COMPILE now resolves to ON; =0 remains the complete kill
switch (reverts the threading and withdraws the extension together).

The gate behind the flip (headless Mesa - llvmpipe for Espryt, lavapipe for
Magma - at c6299f75): GL30/31/32/33/40 mustpass plus the
KHR-GL46.parallel_shader_compile group, both backends, async=1 with the
extension advertised. 58,344 case-runs, 8 failures - and every one of the 8
also fails standalone at async=0, in the full async=0 suite arms with an
identical per-case failure set, and under the pre-P1-stage-6 library. Zero
async-attributable deltas; the 8 are Mesa-upgrade drift (4 unique signatures:
Espryt GL40 transform_feedback.draw_xfb{,_feedbackk}_test, Magma
texture_size_promotion.functional + packed_pixels rgb9_e5_format_red on
GL32/33/40), recorded for separate follow-up.

Validation under the flipped default (no env var): 553/553 unit at the default
AND with the kill switch; parallel_shader_compile 3/3 on both backends proving
the default advertises; 44+44 integration scenarios; 71/72 CI trace-replay
fixtures (the one failure is the pre-existing create-indirect lavapipe crash,
identical under the pre-fix library). The lifecycle test's contract updates
with the default: AsyncIsOnByDefaultAndTheOverrideDecidesEitherWay.

Not covered by this gate and deliberately left open: SSO/DSA suites (GL41+,
separate follow-up per review), and real-driver confirmation on NVIDIA/Mali -
the Mali-G77 on-device sweep runs separately as a report-only pass.
2026-08-09 02:04:59 -04:00
BZLZHH c6299f754f [Fix] (MG_Backend/DirectVulkan, MG_State): key per-object memos on lifetime ids, not heap addresses
A destroyed VertexArrayObject's heap address is handed straight back by the
next allocation of its size, and so is a destroyed BufferObject's. DirectVulkan
keyed its per-VAO draw memo on the VAO POINTER and folded the bound buffer's
ADDRESS into the content hash that validates the memoised bindings, so a
delete/recreate pair under a byte-identical attribute layout reproduced both
the key and its validating hash at once. The successor VAO then inherited the
dead one's resolved bindings and the draw fetched from a destroyed VkBuffer.
Both stated defences failed together, because both reduce to the content hash
and the hash's buffer-identity component was itself a recycled address.

VertexArrayObject and BufferObject now carry a globally-unique, never-reused
GetLifetimeId() - the same contract as ProgramObject's, minted from an atomic
starting at 1 so a zero-initialised slot can never name a live object.
VaoDrawMemo matches on (address, lifetime id) and stores the id on recycle,
SetupDrawSnapshot's "the VAO did not move" test compares the id alongside the
config version, and VertexInputStateFactory::ComputeHash hashes the bound
buffer's id instead of its pointer (0 for client memory).

Proven: the use-after-free reproduces at 100% incidence headless on lavapipe,
including a SEGV whose backtrace is the driver dereferencing a destroyed vertex
buffer inside lvp_queue_submit, and it is gone with the fix. New coverage -
MG_Test/State/ObjectLifetimeIdTest (deterministic, GPU-free, no context: it
waits for the real allocator to repeat an address and asserts the id differs,
and skips loudly rather than passing quietly if it never gets the chance), and
MG_IntegrationTest XfbAfterClipDistanceScenario, registered for DirectGLES,
DirectVulkan, and a third DirectVulkan run with async shader compilation pinned
on because that is a second allocation pattern. Gates: 553/553 unit green at
async=0 and async=1; the scenario 5/5 headless at both flag states; 71/72 CI
trace-replay fixtures over both backends, the one failure a pre-existing
lavapipe crash proven not a regression (identical SIGSEGV at the identical
call number under the pre-fix library).

Pending NVIDIA/X11 confirmation: the KHR-GL{32,40} transform_feedback failures
that opened this investigation never reproduced on lavapipe - the -2/-101
pre-fill signature appears in zero pre-fix runs there - so whether this clears
them is UNPROVEN and must be re-measured on the NVIDIA rig against a freshly
re-run pre-fix baseline. The residual suspect is deliberately untouched here:
m_xfbCounterSlotByObject keys its counter slot on the raw GL transform-feedback
name, so a recycled name whose generation check happens to pass would RESUME
instead of BEGIN. That path was never exercised on lavapipe and is neither
confirmed nor exonerated.
2026-08-08 23:56:53 -04:00
BZLZHH dcf918b9ee [Perf] (MG_State): adopt in-flight compile jobs across shader objects (P1 stage 6)
~21% of a shaderpack's glCompileShader calls hand different shader objects
byte-identical source; the P0b cache only helps after one finishes, so under
async two workers would run the whole pipeline twice. Now the GL thread
consults a per-context (stage, hash, length, envFingerprint) -> weak-node
map at enqueue and ADOPTS the in-flight (or completed) node instead of
posting a duplicate - a hit is honored only after a full byte comparison
(the hash never decides), a cancel-requested or settled-cancelled node is
never adopted, and no worker ever waits.

Sharing a node makes the unconditional cancel wrong, so release is now
adopter-counted: a plain GL-thread Int (every mutation site is a GL entry
point; the single-threadedness argument and the terminal-early-out that
keeps the count exact are in the header), and the cancel fires only at
count zero AND with no pending link pinning the node (the stage-4
MarkLinkReferenced precedence). Adoption also re-points the object's source
at the node's snapshot so the layer-1 memo's pointer compare stays armed -
without that, an adopter's next glCompileShader would re-enqueue the very
duplicate this stage removes. Both guards are negative-control-proven: each
removed guard fails exactly its own tests. Count discipline was proven with
a temporary hard-abort on underflow/leak across the full suite and retrace
corpus - zero hits.

18 new tests (13 GL-surface incl. shared-node re-source/delete/orphan-sweep
isolation, shared failure logs, 48-over-6 stress with a deterministic
adoption count, flag-off and KHR-suspended zero-adoption guards; 5 direct
map cases incl. fingerprint mismatch and cancelled/expired pruning).
Gates: 538/538 unit both flag states, async suites x5 no flakes, NVIDIA
DirectGLES retrace identical sets both states. Timing: 2-worker
(Android-shaped) 1-3% faster consistently on complementary and BSL;
4-worker unchanged - the win this stage exists for lands where CPU is
scarce.
2026-08-08 20:17:51 -04:00
BZLZHH d98f72447d [Feat, Test] (MG_Backend/DirectVulkan, MG_Test): Magma advertisement + the parallel-compile test net
Same gated push as the Espryt commit. The tests ride here because they
exercise both backends' advertisement paths and every piece of the
extension: ParallelShaderCompileTest (13 unit cases - the held-job proof
that GL_FALSE is observable and a second poll still shows outstanding work,
program equivalent, untouched objects read TRUE, always-TRUE with async
off, unknown pnames still INVALID_ENUM, zero-count join+inline for compiles
AND links, nonzero restores while Initialize() does not, clamping and
0xFFFFFFFF and KHR/ARB sharing one state, the getter vs the budget, the
string tracking configuration through glGetString AND glGetStringi) and
AsyncCompileScenario (5 real-GPU cases per the design: 64-compile polling,
forced-join correctness, string/thread-count checks against a live driver,
zero-count synchronous settlement, and async-vs-sync frames rendered
byte-identical with quadrant signatures so two identically-wrong images
cannot pass). Verified 5/5 on NVIDIA in the full 2x2 backend x flag matrix
with MOBILEGL_ITEST_REQUIRE_GPU=1.
2026-08-08 13:17:58 -04:00
BZLZHH f15cb8900f [Feat] (MG_Backend/DirectGLES): advertise GL_KHR_parallel_shader_compile when async is enabled
Gated on the configuration flag on purpose: the string is the one change a
retrace can never cover (Iris/Sodium pipeline their submissions differently
once they see it), so the kill switch has to withdraw the app-visible
behaviour along with the threading.
2026-08-08 13:17:58 -04:00
BZLZHH bd0def6133 [Feat] (MG_Impl, MG_State, MG_Util): the GL_KHR_parallel_shader_compile surface (P1 stage 5)
GL_COMPLETION_STATUS_KHR in both object getters, reading the non-joining
node-direct state - the one query that must never block is asserted never
to reach a join gate. glMaxShaderCompilerThreadsKHR/ARB share one
implementation: a zero count suspends async FIRST and then joins every
outstanding compile and link this context owns (suspend-before-join is the
only order whose post-condition is 'nothing in flight'), a nonzero count
restores; the suspension is a process latch the extension controls, kept
distinct from the configuration flag that gates the ADVERTISEMENT - an app
that turned threading off has not made the extension disappear.
GL_MAX_SHADER_COMPILER_THREADS_KHR reports the thread count. DriverPost
gains the MobileGL-side async row (PASS/INFO naming the env knob) and an
informational host-driver row backed by a new GLES capability probe.

The extension string itself lands per backend in the two follow-up
commits, keeping this one green stand-alone.
2026-08-08 13:17:58 -04:00
BZLZHH 6f8b7fbc40 [Feat] (MG_State, MG_Util): async program linking on the job graph (P1 stage 4)
glLinkProgram with the flag on snapshots its inputs in a GL-thread prologue
(stage-sorted shaders with their compile nodes taken without joining, env,
explicit locations/fragdata/xfb, draw-buffer count), then runs the whole
link body - glslang link/mapIO, SPIR-V, reflection, routing tables - as a
ProgramLinkTask that auto-posts when its last compile dependency settles
(+1-guarded countdown; no worker ever waits on another job). The publish is
one move of the LinkArtifacts block at the join, with the second version
bump so nothing memoized during the pending window survives.

The consume-once TShader claim moved onto the shared compile node as a CAS:
two link jobs racing for one shader resolve to winner-takes-the-parse,
loser re-parses the preprocessed source against the node's own env -
identical SPIR-V pinned by test for 2 and for 12 sharing programs.

Two deliberate corrections to the design's cancel matrix, both test-proven:
attach/detach do NOT cancel a pending link (the snapshot isolates it, and
glCreateShaderProgramv's link-then-detach would otherwise discard its own
result before anyone read it); and a compile node a pending link depends on
is pinned against the orphan-name sweep - the ordinary LWJGL teardown
compile/attach/link/detach/delete used to cancel the dependency and turn a
must-pass link into GL_FALSE.

Continuations are now throw-contained per-item (a stage-3 leftover made
load-bearing by the first real continuation), and the review's deadlock
find is fixed: the dispatch loop no longer cancels a node while holding the
pool mutex, since that cancel can run OnDepSettled -> Post -> same mutex.

Explicit joins: the draw path (GetProgramForDraw, both the pipeline stage
loop and the plain-UseProgram half) and the composite-link site; destroy
paths cancel-not-join; COMPLETION_STATUS readers stay non-joining.

Gates: 506/506 unit both flag states; AsyncCompile/AsyncLink/AsyncTeardown
suites x10 repeats clean both states (teardown with 128 jobs in flight,
then re-Initialize); full NVIDIA DirectGLES retrace flag on twice - result
sets identical to flag off, zero new deltas. Compile-phase prefix-diff,
flag on vs off: complementary-reimagined 5.21s -> 2.16s, BSL 1.72s ->
0.90s - past the design's final acceptance targets before the KHR
extension is even advertised. Default remains OFF until stage 5+7.
2026-08-08 11:58:38 -04:00
BZLZHH e5fb57f7eb [Feat] (MG_State, MG_Util): async shader compilation behind the default-off flag (P1 stage 3)
glCompileShader with MOBILEGL_ASYNC_SHADER_COMPILE=1 snapshots its inputs on
the GL thread (source SharedPtr, CompileEnv, cache handle) and runs the whole
pure pipeline - preprocess, validators, extractors, glslang parse - as a
ShaderCompileTask on the worker pool, returning immediately. Every read of
compile-produced state joins through the single Compiled() gate; links stay
synchronous this stage and join their attached shaders at the top of the
body. Flag off, the path is the same code run inline.

Mechanics: the job node owns all its inputs (no back-pointer, no lifetime
tie to the shader object), so re-sourcing or deleting a pending shader is
cancel-and-drop, never a wait; glslang worker hygiene is a TLS-allocator
scope guard plus GL-thread builtin prewarm (gated on the flag, latch reset
on Destroy so re-initialization re-warms); worker-side diagnostics defer
through the job and replay on the GL thread at the join, enforced by
IsPoolThread asserts in RecordError and an empty-deferred-errors tripwire.
A body that throws publishes a COMPLETE failed compile (status false, real
info log) rather than an abandoned node, and never memoizes away the retry;
a failed enqueue (OOM) cancels the node instead of stranding the joiner -
including inside the dispatch loop, where the in-flight slot is repaid.
The pool StopAndDrains from an atexit sentinel too: workers still inside
glslang parse while exit() ran static destructors was a real 2-in-5 SIGSEGV,
reproduced and fixed (15/15 clean after).

Backend-internal shader objects (default FS, DirectVulkan blit/mipmap) are
cache-less and always compile inline - compile-and-read-in-one-breath needs
no round trip.

Gates: unit suite 488/488 with the flag off AND on (x5); AsyncCompileTest
(12 e2e cases: pending re-source/delete/recompile, byte-identical failure
logs across modes, 48-compile cache stress) x10 repeats clean both modes;
full NVIDIA DirectGLES retrace identical result sets flag off/on (zero new
deltas); compile-phase timing flat as designed (links still serial - the
parallel win arrives with stage 4's async link + stage 5's
KHR_parallel_shader_compile).
2026-08-08 10:33:50 -04:00
BZLZHH c93e5fa409 [Refactor] (MG_State, MG_Util): join-by-construction link/compile artifacts (P1 stage 2)
Still fully synchronous - EnsureLinkJoined()/EnsureCompileJoined() are empty
inline no-ops (verified to fold away at every one of the ~1200 call sites;
this project builds without LTO) - but every read of link- or compile-produced
state now goes through a private accessor the compiler enforces, so when
stage 4 moves the bodies onto pool workers, 'which reads must join' is a
type-system fact instead of a 400-line audit.

- ProgramObject: the 31 fields ResetLinkArtifacts clears plus the 5 link
  outputs it forgot (infoLog, linkedFragData{Location,Index}, the geometry
  strip-capture pair) move into a nested LinkArtifacts behind Artifacts().
  ResetLinkArtifacts is now a worker-safe pure clear; the link-observable
  version bumps (backendState/link/uboContent) move to a GL-thread-only
  BumpLinkObservableVersions() called once from Link()'s prologue and from
  glProgramBinary's mandated failure - the link body never writes them, so
  a stage-4 worker cannot lose an invalidation against the draw path.
- ShaderObject: compile artifacts (TShader, preprocessed source, side-channel
  maps, status/log, consume-once flag) behind Compiled(); the P0b layer-1
  memo trio deliberately stays outside as the future non-joining
  COMPLETION_STATUS_KHR fast path.
- CompileEnv (new): a GL-thread snapshot of everything the compile pipeline
  used to read live from the backend mid-parse - compute limits (the
  GetIntegeri_v reach-back is gone from the worker path), advertised
  extensions, device quirks, TBuiltInResource inputs. Captured lazily per
  backend activation; the consume-once re-parse now runs against the same
  env as the original parse.
- The GL-thread prologue / worker-body boundary is marked in Link() where
  the stage sort ends; everything below is a pure function of the snapshot.

Public getter signatures unchanged - MG_Impl and both backends compile
untouched. Unit 476/476, Program suites 117/117, DirectGLES retrace 38/39 on
llvmpipe (the one failure is the known pre-existing non-CI iterationrp case;
the NVIDIA userspace driver was updated out from under the running kernel
module mid-session, so GLX there is down until a reboot).
2026-08-08 07:12:37 -04:00
BZLZHH 8191075133 [Feat] (MG_Util): the async-compile pool skeleton behind a default-off flag (P1 stage 1)
Standalone Asio (submodule, asio-1-38-2 @ 8806a680, ASIO_STANDALONE +
ASIO_NO_DEPRECATED, header-only - no linked artifact) and the job machinery
the async shader pipeline will run on: JobNode (state machine with deferred
errors, continuations firing exactly once, dependency counters, cancel
semantics split into request vs outcome) and ShaderCompilePool
(asio::thread_pool behind a pimpl so no header leaks asio; big-core count
via cpufreq at >=85% of peak clamped to [1,4]; lazily constructed, so with
the flag off no worker thread ever exists; StopAndDrain leads DestroyImpl).

MOBILEGL_ASYNC_SHADER_COMPILE / _THREADS config knobs, default OFF. Nothing
in the GL pipeline references the pool yet - grep-verified; the full
DirectGLES retrace and compile benches are byte- and time-identical. 25
threaded unit tests, clean across 20x gtest_repeat.
2026-08-08 05:28:51 -04:00
BZLZHH d6caed7822 [Fix] (MG_Util, MG_State): five latent frontend bugs the async work made load-bearing
- SpvcSession's move constructor and move assignment dropped the parsed
  metadata, so a moved-to session silently reported empty reflection.
- ParseComputeLocalSize used std::stoull, whose std::out_of_range escaped
  glCompileShader on an oversized local_size literal; now std::from_chars
  saturating to UINT_MAX, pinned by a regression test that reproduced the
  escaping exception.
- The compute local_size std::regex was rebuilt on every compile; hoisted.
- LinkProgram dumped every shader's full source through MGLOG_D per link.
- glslang::FinalizeProcess ran before the GL context tore down, leaving the
  context's live TShaders pointing at freed builtin symbol tables.
2026-08-08 05:28:28 -04:00
BZLZHH 9152e88734 [Perf] (MG_State): dedupe shader compiles by source hash
Iris-style packs hand MobileGL the same source text repeatedly: probed across
three shaderpack traces, 28-32% of all glCompileShader work was redundant -
~9% same-object recompiles with byte-identical source, ~21% distinct shader
objects sharing identical source (the same common GLSL chunk glued into many
program stages). Two layers, both keyed by XXH64 + length with a full byte
compare on every hit (correctness never rides on the hash):

- Per-object: a successful (or failed) compile remembers its source hash;
  glShaderSource with byte-identical text keeps the compiled state and
  glCompileShader on unchanged source returns immediately. Deterministic
  (stage, source) pipeline makes the memo observationally identical to
  recompiling; the consume-once TakeShaderForLink re-parse path is untouched.

- Cross-object: a per-context bounded cache (ProgramState-owned, declared to
  outlive every shader object) shares the preprocessed source, both explicit
  side-channel maps, and the validation verdicts between objects with equal
  source; only the glslang parse stays per-object. Single-GL-thread today;
  flagged for a mutex when compiles go async (P1).

Interleaved A/B on the iterationrp trace (the recompile-heavy pack):
5.65s -> 5.46s median total replay, every round faster; BSL/complementary
stay flat (their duplicate sources are the small common shaders, so calls
drop but wall time is parse-bound on unique sources). Full DirectGLES
retrace, 445-test unit suite, and dedupe-semantics tests (no-op recompile,
invalidation on new source, failed-compile memo, cache bounds) all green.
2026-08-08 04:18:32 -04:00
BZLZHH 2406e2d219 [Perf, Fix] (MG_Util): preprocessing cleanups - dead scanners, quote-mask bug, one version inspection per compile
Three scoped changes to ShaderSourceProcessor, none altering any transform's
output (pinned by a byte-stability test across the legacy-shader anchor path):

- Delete BlankBlockComments and RemoveDefineForIdentifier - dead since their
  callers left; the former's newline-terminated quote handling moves into
  MaskCommentsAndQuotedText (below) together with its rationale comment.

- Fix MaskCommentsAndQuotedText treating a quote as running past end-of-line.
  GLSL has no multi-line literals, but a stray apostrophe in a directive or
  comment tail ("#pragma message can't") blanked the REST OF THE FILE for
  every masked consumer - the tokenizer, the version inspection, and the P0a
  explicit-location/binding extractors silently lost everything after it.

- Inspect the shader language once per PreprocessShaderSource run instead of
  up to five times: NormalizeVersionDirective now takes the already-computed
  ShaderLanguageInfo, and the two after-version injections share one
  AfterVersionAnchor instead of re-running a full masked sweep each
  (FindAfterVersionDirective -> InspectShaderLanguage) to find the same spot.

Compile-phase timings hold (BSL 1.848s, complementary-reimagined ~5.7s);
retraces and the 435-test unit suite unchanged.
2026-08-08 03:43:56 -04:00
BZLZHH b228f813c0 [Perf] (MG_Util): replace the builtin-shadowing string scans with one tokenize and a SPIR-V OpName pass
RenameBuiltinShadowingFunction probed the whole source ten times per compile
(5 names x mask + scan, each a full-text pass) and still had two blind spots:
a 5-name list and single-line-definition-only detection. On Complementary-scale
packs (4.5MB of sources) that was ~68% of the compile phase.

The rename is now split by FAILURE LAYER, both halves sharing one name table
header so they cannot drift:

- A SPIR-V OpName pass in SanitizeAndOptimizeBinary covers the full ESSL 3.20
  builtin set (~146 names). Renaming a function id is safe by construction:
  builtin calls are GLSL.std.450 instructions and can never resolve to a user
  OpFunction, overloads are distinct ids (a helper overload delegating to the
  real builtin keeps working), dead preprocessor branches never reach SPIR-V,
  and macro-expanded definitions are covered. ESSL 3.x is the only consumer
  that forbids the redefinitions, and this pass runs before its transpile.

- A lexical pass covers only the 5 names whose exact-signature redefinitions
  glslang's relaxed parse rejects outright (never producing SPIR-V for the
  backstop): the historical fma/max3/min3/round/tanh. One TokenizeCode pass;
  definition detection requires brace depth 0, a type-identifier previous
  token that is neither a statement keyword nor a directive tail, and skips
  files whose token-level braces do not balance (preprocessor-asymmetric
  arms) - over-detection is unrecoverable, so every ambiguity falls through
  to the backstop.

Measured on the compile phase (prefix-diff, 3-run medians, Espryt/NVIDIA):
complementary-reimagined 20.0s -> 5.5s, BSL 2.14s -> 1.85s. bliss (the pack
that ships from-scratch fma/tanh helpers) stays at SSIM 0.999962.

Tests: end-to-end ESSL assertions for the multiline-definition and
new-overload shapes, the three adversarial-review reproductions (statement-
keyword call under asymmetric braces, dead-#if compat shim, overload
delegating to the shadowed builtin), and a source-level assertion pinning
the lexical half specifically.
2026-08-08 03:11:38 -04:00
BZLZHH 0d0527192a [Perf] (MG_State, MG_Util): compile shaders with a single relaxed parse
glCompileShader used to parse every source twice: once under the GL client
(reflection only) and once under the relaxed Vulkan client (SPIR-V + the
plain-uniform global UBO), with GenerateBinary re-preprocessing, re-parsing
and re-linking every attached shader on every glLinkProgram. The GL-client
pass is gone: Compile() performs the one link-compatible relaxed parse and
the linked TProgram serves reflection and codegen both. Measured on the BSL
shaderpack compile phase: Espryt 2.80s -> 2.14s, Magma 3.78s -> 3.07s.

What the relaxed parse cannot provide is restored explicitly:
- explicit layout(location/binding) qualifiers on default-block uniforms and
  samplers are extracted lexically at Compile() (the relaxed parse strips
  them) and merged per link with cross-stage conflict checks;
- uniforms the relaxed parse sweeps into MGL_GLOBAL_UBO but no stage reads
  are filtered from the GL reflection surface through GL<->TProgram index
  translation maps (dead uniforms stay inactive, the synthesized block stays
  hidden, builtins reflect under their GL spellings);
- SPIR-V is generated BEFORE buildReflection touches the program (its
  live-variable analysis perturbs GlslangToSpv output - generated modules
  stay bit-identical to the old pipeline's), while the glUniform*-to-scratch
  routing tables are built strictly AFTER reflection, whose results size and
  key them;
- a TShader feeds exactly one link (mapIO mutates the intermediate); relinks
  and multi-program attachments re-parse the stored preprocessed source.

Validated: DirectGLES retrace suite green (two pre-existing local-driver
failures unchanged old vs new), KHR-GL30 877/878 on Espryt/NVIDIA (the one
failure pre-exists this change), unit tests green, per-module SPIR-V hashes
identical across a full DirectVulkan replay.
2026-08-08 01:25:54 -04:00
BZLZHH 81bcbd6c14 [Fix] (MG_State): allocate program and shader names from one shared name space
GL 3.3 core 2.11 puts program and shader names in one name space: a shader
name passed where a program is expected must fail with INVALID_OPERATION,
and vice versa. Two independent IndexGenerators handed out colliding names
(shader 2 and program 2 could coexist), so CheckProgramNameValidity resolved
a shader handle to an unrelated linked program and the error checks in
KHR-GL30.get_uniform_tests.get_uniform were silently swallowed - the case
only ever passed because the collided program happened to reject the queried
location. One shared generator keeps the names disjoint; the per-kind object
tables are unchanged.
2026-08-08 01:25:37 -04:00
BZLZHH 867fe3e0ef [Feat] (MG_Util, MG_IntegrationTest): POST rows for the Espryt multi-draw tier, and the scenario that pins it
Three DriverPost rows per the POST rule, since the ladder took on two
new driver dependencies: glDrawElementsBaseVertex (WARN when absent -
every base-vertex draw then costs a CPU index rewrite and an upload) and
compute shaders (INFO - the default tiers never use them). The third
names the tier that will actually run, with the full set the driver
supports, resolved by the same function the backend calls so the two can
not drift. The existing "Multi-draw base vertex" row stopped saying the
fallback is a per-draw loop, which is no longer the whole truth.

Scenario D asserts the one contract every tier shares: a multi-draw
paints exactly what the unrolled single draws paint. The reference side
is a loop of glDrawElementsBaseVertex and never enters the emulation, so
a tier cannot make itself look right by breaking both sides alike, and a
blank-frame pair is rejected outright - drawing nothing is the failure
mode this path actually has.

Nine cases, chosen for the shapes the Minecraft retraces contain none
of: narrow index types, a base vertex past the index type's range,
primitive restart inside a strip on two index types, client-memory index
arrays, and a batch with zero-count sub-draws (whose prefix sums the
flattening tier's binary search has to skip). Each of the six tiers
passes all nine on NVIDIA, and ext/auto/compute also pass on Mesa where
the ext tier is reachable.

The suite is falsifiable, not merely green: rewriting the rebase the way
MobileGlues does it - truncate to the source width, no restart
passthrough - fails exactly three cases on the drawelements tier (both
restart cases and the out-of-range base vertex) and leaves basevertex,
which rewrites nothing, passing. That control is also what turned up the
restart hole in the flattening tier fixed in the previous commit.
2026-08-07 08:16:08 -04:00
BZLZHH 0ec487c993 [Feat] (MG_Backend): port MobileGlues' multi-draw emulation to DirectGLES as a tier ladder
ES has neither glMultiDrawElements nor glMultiDrawElementsBaseVertex, so
both are emulated. DirectGLES had two ways of doing it - one
glMultiDrawElementsBaseVertexEXT where the driver has the extension
interaction, otherwise a per-draw loop. This adds the five MobileGlues
uses (gl/multidraw.cpp), so the ladder is now: one
glMultiDrawElementsBaseVertexEXT; one glMultiDrawElementsIndirectEXT
over a synthesized command buffer; one glDrawElementsIndirect per
command over that same buffer; the base-vertex replay; plain
glDrawElements over a CPU-rewritten index stream, for drivers with no
base-vertex draw at all; and a compute shader that flattens the whole
batch into one rebased index buffer drawn by a single glDrawElements.
They live in their own translation unit that owns the entry point
outright, preparation included - the compute tier has to dispatch BEFORE
PrepareForDraw, or it would have to unpick the program, storage-block
and index bindings the preparation just made, and a dispatch inside an
open transform-feedback span is not legal at all.

The auto ladder is ext -> basevertex -> multiindirect -> indirect ->
drawelements, which is NOT MobileGlues' order (it puts the indirect
tiers first). Measured on mc_sodium_multidraw, ns/op, median of three:
NVIDIA ES 3.2 basevertex 2500 vs multiindirect 5700 and indirect 5800;
Mesa llvmpipe ext 19300, basevertex 25200, multiindirect 27600,
drawelements 28700, indirect 31000. Ring-allocating the command staging
instead of respecifying per batch was tried first and moved the indirect
tiers by less than noise, so the cost is the indirect draw path itself,
not the upload; only a real multi-draw entry point beats replaying the
sub-draws. auto therefore resolves to basevertex on this box - byte for
byte the behaviour that shipped - and the new tiers are what a driver
with the ext interaction, or without base vertex at all, now gets.
compute is never chosen by auto (nor by MobileGlues'): it rewrites the
primitive stream rather than replaying it, and it measured slowest here.

Four places this deliberately does not follow MobileGlues, each a
correctness bug there. A rewritten stream is emitted as GL_UNSIGNED_INT
whatever came in, because GL adds baseVertex at full precision and
folding it into ushort indices wraps. The restart sentinel is carried
across a rebase unrebased, or an enabled primitive restart is lost. The
flattening tier declines strip/loop/fan modes, any sub-draw whose count
is not a whole number of primitives, and any batch at all while
primitive restart is enabled - a restart ends a primitive, so leftover
vertices would find a third vertex in the next sub-draw and become a
triangle GL never draws. And the indirect tiers decline client-memory
index arrays, which have no buffer to address.

gl_DrawID gets better rather than worse: the unrolled tiers now feed
each sub-draw its index (the spec's value, where the old loop left the
uniform untouched), and a program that actually reads it demotes the
batched tiers, which can only hold one value for the whole batch. The
per-batch cost is nil for the programs that do not read it.

Verified: the five DirectGLES retraces are byte-identical (md5) across
all six tiers on NVIDIA and on Mesa, each tier proven to have really
executed rather than silently demoted, via a per-tier announcement in
the log. Unit suite 421/421. The full retrace suite's five failures all
reproduce unchanged on a stashed tree, so none are new.
2026-08-07 08:15:54 -04:00
BZLZHH 23b880c8be [Feat] (MG_Config, MG_Util): a tier knob and two capability flags for Espryt multi-draw
MOBILEGL_ESPRYT_MULTIDRAW_MODE=ext|multiindirect|indirect|basevertex|
drawelements|compute|auto names the DirectGLES emulation tier for
glMultiDrawElements(BaseVertex). Same contract as the Magma knob: a
preference, not a demand, clamped at resolution time to what the driver
actually has, and invalid values keep auto. Nothing reads it yet.

The two capability flags the ladder selects on are new because neither
existed in the shape the choice needs. SupportsDrawElementsBaseVertex is
the weaker sibling of SupportsMultiDrawElementsBaseVertex - ES 3.2 core
or EXT/OES_draw_elements_base_vertex, with no GL_EXT_multi_draw_arrays
requirement - and it decides whether a batch can replay its sub-draws
with their own base vertices or has to fold them into rewritten indices.
SupportsComputeShader is ES 3.1 core plus the dispatch, barrier and
shader-object entry points. Both keep the house rule the multi-draw
flags already follow: the extension/version check is what proves
support, the resolved pointer only confirms it, because
eglGetProcAddress may hand back a live-looking stub for a function the
context does not implement.
2026-08-07 08:15:31 -04:00
BZLZHH ebc5bff9b1 [Fix, Feat] (MG_Backend): make DirectVulkan multi-draw actually draw, then pick its best tier
The bug: DirectVulkan.cpp::MultiDrawElements had its entire body
commented out - plain glMultiDrawElements on Magma recorded NOTHING,
no error, no pixels (readback shows the deferred clear never even
materialized). It now shares the tuned base-vertex implementation, and
both plain entries are pixel-proven by a 4-sub-draw harness.

The feature: every CPU-side multi-draw form dispatches through three
tiers after round-9's contiguous-run merge (restructured to merge into
a span BEFORE dispatch, so every tier consumes the shrunken array):
  1. VK_EXT_multi_draw: one vkCmdDrawMulti(Indexed)EXT, chunked by
     maxMultiDrawCount; per-draw vertexOffset rides in the struct. The
     extension is requested only when enumerated and its feature bit
     confirmed, entry points via vkGetDeviceProcAddr, demoted if
     missing.
  2. multiDrawIndirect: the param span uploads DIRECTLY as a transient
     INDIRECT-usage buffer - DrawIndexedCmdParam is layout-identical
     to VkDrawIndexedIndirectCommand and DrawCmdParam's head is a
     legal 24-byte-stride VkDrawIndirectCommand, both static_asserted,
     so no repacking - then one vkCmdDraw(Indexed)Indirect per
     maxDrawIndirectCount chunk. firstInstance!=0 additionally
     requires drawIndirectFirstInstance or the batch drops a tier.
  3. The byte-identical unroll.
gl_DrawID: tiers 1-2 are spec-correct (0,1,2,3 across a probe's
sub-draws); the unroll tier keeps the pre-existing always-0 contract.
The default tiers strictly improve DrawID correctness.

Adversarially verified: the five real DirectVulkan retrace images are
BIT-IDENTICAL (md5) across auto/ext/indirect/unroll; zero validation
VUIDs on every tier; a simulated no-EXT device resolves to indirect
and renders the same bytes; the known-red create-indirect fixture
crashes at the identical call before and after (not worse, not fixed).
Unit suite 423/423 on the rebased tree, retrace subset 10/10. Bench:
mc_sodium_multidraw's contiguous shape merges 32->1 before dispatch,
so no bench delta - the tiers' beneficiaries are non-contiguous real
streams (the sodium RETRACE pushes ~58-sub-draw batches, in=out
243101 with zero merges) and mobile drivers. A reproducible +3-4%
code-layout drift on mc_use_program (zero shared code, I-cache
displacement from +400 lines) stays under the action gate and is
booked here rather than hidden.
2026-08-07 06:41:21 -04:00
BZLZHH 231d5c90e4 [Feat] (MG_Config, MG_Util): a preference knob and POST rows for Magma's multi-draw tiers
MOBILEGL_MAGMA_MULTIDRAW_MODE=ext|indirect|unroll|auto selects the
DirectVulkan multi-draw dispatch tier, clamped to what the device
supports with one INFO line when it falls back; auto (and unset) picks
the best supported tier. Invalid values keep auto. Magma-only: the
variable has no effect on DirectGLES. Note for the escape hatch:
mode=unroll also forces the GL indirect multi-draw paths onto their
per-command loop, where gl_DrawID reads 0 for every sub-draw -
Flywheel-style content that keys on flw_drawId renders accordingly.

Three DriverPost rows per the POST rule: VK_EXT_multi_draw
(PASS/INFO), the multiDrawIndirect feature (WARN downgraded to INFO -
there is always a fallback tier), and the resolved dispatch tier with
the full chain. drawIndirectFirstInstance gains a row too, since the
indirect tier's legality check now relies on it.
2026-08-07 06:41:21 -04:00
BZLZHH d5f5e6405b [Perf] (MG_Backend): batch DirectGLES multi-draw base-vertex where the driver really has it
When SupportsMultiDrawElementsBaseVertex is true, glMultiDrawElements-
BaseVertex issues one glMultiDrawElementsBaseVertexEXT instead of a
per-draw loop; the fallback loop is byte-identical otherwise.

The local NVIDIA ES driver lacks GL_EXT_multi_draw_arrays, so the
batch cannot engage here and no local win is claimed (counter-proven:
batched=0 / fallback=264329 across a sodium retrace). On Mesa llvmpipe,
which implements the full interaction, the batch engages (batched=4566,
~58 sub-draws per call) and is pixel-identical to a forced-fallback
control (same SSIM to the last digit). The beneficiaries are mobile
drivers advertising the interaction - the Sodium chunk path collapses
32 driver entries into one - and the DriverPost row shows which side
any device falls on. A/B on both backends: every case inside the 5%
bar. Unit suite 423/423, retrace subset 10/10.
2026-08-07 06:00:54 -04:00
BZLZHH ac3a83b207 [Fix] (MG_Util, MG_Test): never take a non-null eglGetProcAddress result as support
On GLVND Linux eglGetProcAddress returns a non-NULL trampoline for ANY
name - including a fabricated one - so pointer-nullness can never
signal driver support. The three EXT multi-draw entry points were
registered as required (spurious error logs on drivers without them)
and their pointers were trusted; the NVIDIA ES driver hands back a
stub for glMultiDrawElementsBaseVertexEXT that SILENTLY DROPS draws,
which once made a "77% faster" multi-draw batch that rendered nothing.

The three entries are optional now, and two extension-derived
capability flags follow the established Supports* pattern - each is an
extension-string check AND a resolved pointer, so a flag alone is
sufficient at a call site:
  SupportsMultiDrawIndirect: GL_EXT_multi_draw_indirect + both entry
  points resolved.
  SupportsMultiDrawElementsBaseVertex: (GL_EXT or
  GL_OES_draw_elements_base_vertex) + GL_EXT_multi_draw_arrays + the
  entry point resolved. The multi_draw_arrays conjunct is the registry
  fact the stub exploited: glMultiDrawElementsBaseVertexEXT exists only
  in interaction with GL_EXT_multi_draw_arrays, and this NVIDIA driver
  advertises everything else EXCEPT that one - so the entry point is
  genuinely unsupported while eglGetProcAddress still "resolves" it.

Two DriverPost rows report both capabilities (INFO when absent - a
fallback always exists). Unit tests pin the stub shape, the exact
NVIDIA shape, the supported shape and extension-without-pointer.

Proven load-bearing: forcing the old pointer-only condition on the
NVIDIA ES driver reproduces the silent drop exactly (sodium retrace
SSIM 1.000000 -> 0.329522, no crash, no GL error); with the gate the
same run is a literal 1.000000. Unit suite 423/423 (two new tests),
retrace subset 10/10, integration suite 52/52.
2026-08-07 06:00:54 -04:00
BZLZHH 335f2decbd [Perf] (MG_Backend): stop DirectVulkan re-proving sampler sets and re-walking render passes
Two per-draw costs from the round-10 profiles. A per-program sampled-set
epoch inside UniformManager skips the per-binding descriptor proof walk
when no texture or sampler API ran since that program's previous draw -
the mc_sampler_churn/mc_tex_param pattern. The pass-switch path stops
re-deriving render-pass state that its own value hash already pins.

Load-gated 6-round order-alternating A/B (medians): magma tex_param
-13.3%, pass_switch -10.9%, state_toggle -5.4%; espryt untouched and
unmoved. The two matrix flags (sodium +7.5%, tex_stream +6.2%) reversed
under 10-pair isolated alternating re-runs (-5.5% and +2.5%) - the same
position-bias artifact every previous round's flags showed. Unit tests
421/421; retrace subset and the 52-entry integration suite pass.

Landing note: this diff was authored by a round-10 agent whose session
died before adjudication; the A/B data survived (r10bmag_ab_raw.csv)
and the flags were adjudicated before landing. Its relink also exposed
the pre-existing exit-teardown SIGSEGV fixed in the previous commit.
2026-08-07 04:13:55 -04:00
BZLZHH fb1ad96c04 [Fix] (MG_Backend): stop DirectGLES twin destructors calling a dead driver at exit
The static twin registries destroy their backend objects from
__run_exit_handlers, and a twin destructor then jumps through
g_GLESFuncs into a driver library that exit() may already have torn
down - a latent SIGSEGV that DriverBench has been dumping core with on
every exit, and that any relink shuffling static destructor order can
hand to the trace-replay binary (a byte-perfect replay then "fails with
status Segmentation fault").

A process-teardown flag now short-circuits the program, VAO and texture
twin destructors: past exit() the driver reclaims every GPU object
anyway, so the skip is a deliberate leak of nothing. The flag is set by
a std::atexit handler registered lazily on first registry use - by then
every static everywhere has finished constructing, so the handler runs
BEFORE any static destructor. A registry-destructor hook was tried
first and is wrong: tests and cache resets destroy temporary registry
instances mid-run, which latched the flag while the process was alive
(caught by DirectGLESBackendTexture.DestructorDeletesIdAndScrubsBindingCache).

421/421 unit tests, the retrace subset exits cleanly on both backends,
and the 52-entry integration suite passes.
2026-08-07 04:13:55 -04:00
BZLZHH 313b75a7c0 [Test] (MG_IntegrationTest): pin the two shipped memo bugs with rendered pixels
Both d7976326 bugs passed every unit test while corrupting real frames -
state-level assertions cannot see them. This module renders and reads
back.

A headless EGL-pbuffer harness (no window, no GLFW) linking MobileGL_s
directly, registered once per backend under the ctest label
integration-gpu, behind the default-OFF option
MOBILEGL_BUILD_INTEGRATION_TEST. The platform pre-flight runs the ENTIRE
bring-up in a forked child first - MobileGL aborts rather than returning
errors on an unusable platform, and the child dying on any signal turns
into a clean GTEST_SKIP instead of taking the test binary down.
MOBILEGL_ITEST_REQUIRE_GPU makes the label falsifiable: with it set, an
unusable harness (or a context that lands on a software rasterizer) is a
FAILURE - without it, a CI runner whose driver pinning silently broke
reports the same green as one that rendered every frame. Configure-time
detection pins the EGL vendor and Vulkan ICD jsons, preferring hardware
vendors and never selecting llvmpipe/lavapipe.

Scenarios assert on glReadPixels with whole-region pixel counts (a
2x2 quadrant pattern whose signature distinguishes all eight square
symmetries; every region predicate reports the first offending pixel):
- OrientationScenario: default -> FBO -> default, pinning the
  transform-flags memo key. Keying GetBaseTransformFlagsRaw on the
  pre-transform alone fails exactly 3 entries.
- StreamedArenaScenario: an untouched streamed vertex buffer must
  survive transient-arena recycling. Re-enabling only the cross-frame
  vertex revalidation fails exactly this entry.
- CrossFrameBufferScenario + ResidentIndexScenario: cross-frame
  mutation matrix (SubData, map/unmap, persistent+flush, coherent
  persistent, orphan, CopyBufferSubData; vertex and index) plus six
  adversarial resident-EBO constructions. Instrumentation showed the
  cross-frame EBO memo cannot be made to serve wrong bytes from GL
  level on this stack (89 entries, 81 accepts, zero divergent slices) -
  these cases are freshness tripwires, documented as such in-file; the
  EBO half of d7976326 remains unpinned by a failing test.

At the buggy commit 72ee7c43 the suite fails 4 entries (3 orientation +
1 streamed-arena); at d7976326 all 52 pass, 5 consecutive runs, zero
flakes, and the default build is bit-for-bit unaffected (unit suite
unchanged). Adversarially verified twice, including hostile-platform
sweeps (26 configurations, all clean skips) and hand-edits of each
production hole in isolation.
2026-08-07 03:30:18 -04:00
124 changed files with 25342 additions and 3073 deletions
+6
View File
@@ -31,3 +31,9 @@
[submodule "3rdparty/apitrace"]
path = 3rdparty/apitrace
url = https://github.com/MobileGL-Dev/apitrace.git
[submodule "3rdparty/asio"]
path = 3rdparty/asio
url = https://github.com/chriskohlhoff/asio.git
[submodule "3rdparty/libfork"]
path = 3rdparty/libfork
url = https://github.com/ConorWilliams/libfork.git
Vendored Submodule
+1
Submodule 3rdparty/asio added at 8806a6803c
Vendored Submodule
+1
Submodule 3rdparty/libfork added at 9b2b844a5f
+46 -1
View File
@@ -4,6 +4,11 @@ project("MobileGL")
option(MOBILEGL_BUILD_TEST "Build MobileGL tests" ON )
option(MOBILEGL_BUILD_BENCHMARK "Build MobileGL benchmarks" ON )
# Headless end-to-end GPU scenarios (MobileGL/MG_IntegrationTest). They need a
# real GPU/ICD to do anything, so they are off by default for CI; every scenario
# skips cleanly where there is none. Registered under the `integration-gpu`
# ctest label so a run can select or exclude them.
option(MOBILEGL_BUILD_INTEGRATION_TEST "Build MobileGL headless GPU integration tests" OFF)
option(MOBILEGL_FORCE_RELEASE_OPT "Enable Release optimization flags in Debug build" ON )
option(MOBILEGL_ENABLE_TRACY "Enable tracy for profiling" OFF)
option(MOBILEGL_BUILD_TRACE_REPLAY "Build desktop apitrace replay runner" OFF)
@@ -17,7 +22,9 @@ if (ANDROID)
set(MOBILEGL_BUILD_BENCHMARK OFF CACHE BOOL "Build MobileGL benchmarks" FORCE)
endif()
if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug" OR MOBILEGL_FORCE_RELEASE_OPT)
option(MOBILEGL_ENABLE_LTO "Build with ThinLTO/IPO" OFF)
if ((NOT CMAKE_BUILD_TYPE STREQUAL "Debug" OR MOBILEGL_FORCE_RELEASE_OPT) AND MOBILEGL_ENABLE_LTO)
# Check if ThinLTO or LTO is suppported
include(CheckIPOSupported)
include(CheckCCompilerFlag)
@@ -147,6 +154,9 @@ set(SOURCE_FILES
MobileGL/MG_Util/Debug/Log.cpp
MobileGL/MG_Util/Async/JobNode.cpp
MobileGL/MG_Util/Async/ShaderCompilePool.cpp
MobileGL/MG_Util/Math/VectorTypes.cpp
MobileGL/MG_Util/Metrics/TextureMetrics.cpp
@@ -180,6 +190,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/Classifiers/TextureEnumClassifier.cpp
MobileGL/MG_Util/ShaderTranspiler/CompileEnv.cpp
MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp
MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp
MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp
@@ -187,6 +198,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenInterfaceStructPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EliminateFloatEqualsZeroPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameSamplerFunctionParameterPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecomposeWorkgroupVec3Pass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecoratePositionInvariantPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp
@@ -220,6 +232,7 @@ set(SOURCE_FILES
MobileGL/MG_Impl/GLImpl/Framebuffer/Validators.cpp
MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp
MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp
MobileGL/MG_Impl/GLImpl/Program/ProgramInterface.cpp
MobileGL/MG_Impl/GLImpl/Program/GL_ProgramPipeline.cpp
MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp
MobileGL/MG_Impl/GLImpl/Texture/Validators.cpp
@@ -243,6 +256,7 @@ set(SOURCE_FILES
MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp
MobileGL/MG_Backend/DirectGLES/Utils.cpp
MobileGL/MG_Backend/DirectGLES/Managers.cpp
MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp
MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp
MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp
@@ -281,7 +295,11 @@ set(SOURCE_FILES
MobileGL/MG_State/GLState/TextureState/TextureUnit.cpp
MobileGL/MG_State/GLState/TextureState/TextureState.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp
MobileGL/MG_State/GLState/RenderState/RenderState.cpp
MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp
@@ -315,6 +333,11 @@ if (WIN32)
)
endif()
# The shader-compile pool runs standalone Asio on real threads. This host's glibc (>= 2.34)
# merged pthread into libc, so it links without asking, but the NDK and musl are not
# guaranteed to be as forgiving - ask for it explicitly rather than rely on the accident.
find_package(Threads REQUIRED)
set(MOBILEGL_LINK_LIBRARIES
glslang::glslang
spirv-cross-c
@@ -324,12 +347,17 @@ set(MOBILEGL_LINK_LIBRARIES
GPUOpen::VulkanMemoryAllocator
Vulkan::UtilityHeaders
spirv-reflect-static
Threads::Threads
)
set(MOBILEGL_COMPILE_DEF
-DVMA_STATIC_VULKAN_FUNCTIONS=0
-DVMA_DYNAMIC_VULKAN_FUNCTIONS=1
-DVMA_VULKAN_VERSION=1001000
# Header-only Asio, no Boost, no deprecated interfaces. Set on the definition list
# rather than per-target so the shared library and the _s static target agree.
-DASIO_STANDALONE
-DASIO_NO_DEPRECATED
)
message(STATUS "MOBILEGL_COMPILE_DEF=${MOBILEGL_COMPILE_DEF}")
@@ -341,6 +369,17 @@ set(MOBILEGL_INCLUDE_DIR
${spirv-tools_SOURCE_DIR}/include
${spirv-tools_BINARY_DIR}
${SPIRV-Headers_SOURCE_DIR}/include
# Header-only submodule: no add_subdirectory, no link target. Only
# MG_Util/Async/ShaderCompilePool.cpp includes it, and it stays behind that file's
# pimpl so no consumer target needs this path.
${CMAKE_SOURCE_DIR}/3rdparty/asio/asio/include
# The second shader-compile execution engine (MOBILEGL_ASYNC_POOL=libfork), on the
# same terms as Asio above: header-only, no add_subdirectory (its CMakeLists only
# declares an INTERFACE target plus install/test scaffolding we do not want), no link
# target, and reachable from exactly one translation unit. libfork's own
# target_compile_features asks for cxx_std_23, which this project already sets
# globally, so its C++20 coroutines need no per-source standard override.
${CMAKE_SOURCE_DIR}/3rdparty/libfork/include
)
add_library(${CMAKE_PROJECT_NAME} SHARED
@@ -538,6 +577,12 @@ if (NOT ANDROID)
add_subdirectory(MobileGL/MG_Test)
endif()
# After MG_Test so googletest is already available when the unit tests are
# built; the module fetches its own copy when they are not.
if (MOBILEGL_BUILD_INTEGRATION_TEST)
add_subdirectory(MobileGL/MG_IntegrationTest)
endif()
if (MOBILEGL_BUILD_BENCHMARK)
add_subdirectory(MobileGL/MG_Benchmark)
endif()
+51
View File
@@ -29,6 +29,33 @@ namespace MobileGL::MG_Config {
ForceOff,
};
// Preferred DirectVulkan dispatch tier for the glMultiDraw* families. A preference,
// never a demand: the renderer clamps it to what the device supports at device
// creation, falling down the chain ext -> indirect -> unroll with one log line.
enum class MultiDrawMode : Uint8 {
Auto = 0, // unset: best supported tier
Ext, // VK_EXT_multi_draw: one vkCmdDrawMultiEXT / vkCmdDrawMultiIndexedEXT
Indirect, // multiDrawIndirect feature: one vkCmdDraw*Indirect over a transient command array
Unroll, // one vkCmdDraw* per sub-draw
};
// Preferred DirectGLES emulation tier for glMultiDrawElements(BaseVertex). GLES has no
// such entry point in core, so every tier below is an emulation; they differ only in
// which driver capability they lean on and how many driver calls a batch costs. Like
// the Magma knob this is a preference, clamped at resolution time to what the ES
// driver actually supports, with one log line when it falls back.
enum class GLESMultiDrawMode : Uint8 {
Auto = 0, // unset: best supported tier
Ext, // one glMultiDrawElementsBaseVertexEXT
MultiIndirect, // one glMultiDrawElementsIndirectEXT over a scratch command buffer
Indirect, // one glDrawElementsIndirect per sub-draw over that same buffer
BaseVertex, // one glDrawElementsBaseVertex per sub-draw
DrawElements, // baseVertex folded into a scratch index buffer on the CPU, then plain
// glDrawElements per sub-draw (for drivers with no base-vertex draw at all)
Compute, // a compute shader flattens every sub-draw into one rebased index buffer,
// drawn by a single glDrawElements
};
// Feature toggles parsed once from environment variables in MG_ConfigLoader::Init()
// (ConfigLoader.cpp), before the accepted-env map is destroyed. All Bool fields share
// one truthy rule: the variable is set, non-empty, not "0", and not "false"
@@ -39,6 +66,12 @@ namespace MobileGL::MG_Config {
// - DISPLAY: X11 session variable, not MobileGL configuration.
// - MOBILEGL_LOG_FILE_PATH: log-file init runs before MG_ConfigLoader::Init
// (see MG_Util/Debug/Log.cpp).
// - MOBILEGL_ASYNC_POOL: a ShaderCompilePool is constructed by binaries that never call
// MobileGL::Initialize() and so never run MG_ConfigLoader::Init - MG_Test's
// JobNodeTest builds pools directly, and it is the suite that runs the whole async
// matrix against both execution engines. Mirroring it here would resolve to the
// default in exactly the tests that exist to tell the engines apart (see
// MG_Util/Async/ShaderCompilePool.cpp, DetectAsyncPoolEngine).
struct FeaturesTable {
// MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries.
Bool DisableTimerQuery = false;
@@ -91,6 +124,24 @@ namespace MobileGL::MG_Config {
// 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;
// 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.
MultiDrawMode MagmaMultiDrawMode = MultiDrawMode::Auto;
// MOBILEGL_ESPRYT_MULTIDRAW_MODE: preferred DirectGLES glMultiDrawElements emulation
// tier ("ext" | "multiindirect" | "indirect" | "basevertex" | "drawelements" |
// "compute", see GLESMultiDrawMode). Clamped to driver support; unset picks the best
// supported tier, which never includes "compute" - see the note on its resolution.
GLESMultiDrawMode EsprytMultiDrawMode = GLESMultiDrawMode::Auto;
// MOBILEGL_ASYNC_SHADER_COMPILE: overrides asynchronous shader compilation. Unset
// keeps the built-in default (MG_Util::Async::kAsyncShaderCompileDefault); falsy
// forces every glCompileShader/glLinkProgram to run synchronously on the calling
// thread AND withdraws GL_KHR_parallel_shader_compile, so the single switch reverts
// both the threading and the application-visible behaviour change.
QuirkOverride AsyncShaderCompile = QuirkOverride::Auto;
// MOBILEGL_ASYNC_SHADER_COMPILE_THREADS: shader-compile worker count. 0 (unset) means
// auto, which is min(4, big cores); an explicit value is honoured as given.
Uint32 AsyncShaderCompileThreads = 0;
};
extern FeaturesTable Features;
} // namespace MobileGL::MG_Config
+45
View File
@@ -97,6 +97,47 @@ namespace MobileGL::MG_ConfigLoader {
: MG_Config::QuirkOverride::ForceOff;
}
// Multi-draw mode is a named-value preference: unset keeps Auto (best supported tier),
// a recognized name selects that tier as the ceiling, anything else warns and keeps Auto.
inline MG_Config::MultiDrawMode QueryEnvMultiDrawMode(const String& key) {
auto it = acceptedEnvVariablesMap->find(key);
if (it == acceptedEnvVariablesMap->end()) {
return MG_Config::MultiDrawMode::Auto;
}
String lowered = it->second;
std::transform(lowered.begin(), lowered.end(), lowered.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (lowered == "ext") return MG_Config::MultiDrawMode::Ext;
if (lowered == "indirect") return MG_Config::MultiDrawMode::Indirect;
if (lowered == "unroll") return MG_Config::MultiDrawMode::Unroll;
if (lowered.empty() || lowered == "auto") return MG_Config::MultiDrawMode::Auto;
MGLOG_W("Config: Ignoring invalid env variable %s='%s'; expected ext|indirect|unroll|auto, using auto",
key.c_str(), it->second.c_str());
return MG_Config::MultiDrawMode::Auto;
}
// Same contract as QueryEnvMultiDrawMode, over the DirectGLES tier names.
inline MG_Config::GLESMultiDrawMode QueryEnvGLESMultiDrawMode(const String& key) {
auto it = acceptedEnvVariablesMap->find(key);
if (it == acceptedEnvVariablesMap->end()) {
return MG_Config::GLESMultiDrawMode::Auto;
}
String lowered = it->second;
std::transform(lowered.begin(), lowered.end(), lowered.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (lowered == "ext") return MG_Config::GLESMultiDrawMode::Ext;
if (lowered == "multiindirect") return MG_Config::GLESMultiDrawMode::MultiIndirect;
if (lowered == "indirect") return MG_Config::GLESMultiDrawMode::Indirect;
if (lowered == "basevertex") return MG_Config::GLESMultiDrawMode::BaseVertex;
if (lowered == "drawelements") return MG_Config::GLESMultiDrawMode::DrawElements;
if (lowered == "compute") return MG_Config::GLESMultiDrawMode::Compute;
if (lowered.empty() || lowered == "auto") return MG_Config::GLESMultiDrawMode::Auto;
MGLOG_W("Config: Ignoring invalid env variable %s='%s'; expected "
"ext|multiindirect|indirect|basevertex|drawelements|compute|auto, using auto",
key.c_str(), it->second.c_str());
return MG_Config::GLESMultiDrawMode::Auto;
}
inline Uint32 QueryEnvUint32(const String& key, Uint32 defaultValue, Uint32 minValue, Uint32 maxValue) {
auto it = acceptedEnvVariablesMap->find(key);
if (it == acceptedEnvVariablesMap->end()) {
@@ -138,6 +179,10 @@ namespace MobileGL::MG_ConfigLoader {
features.MagmaDisableBlendedDepthWriteQuirk =
QueryEnvQuirkOverride("MOBILEGL_MAGMA_DISABLE_BLENDED_DEPTH_WRITE");
features.DisableRobustBufferAccess = QueryEnvFlag("MOBILEGL_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");
features.AsyncShaderCompileThreads = QueryEnvUint32("MOBILEGL_ASYNC_SHADER_COMPILE_THREADS", 0, 0, 64);
}
inline void InitBackendType() {
+31 -1
View File
@@ -15,6 +15,8 @@
#include <MG_Impl/GLImpl/Texture/ProxyTexture.h>
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
#include <MG_Impl/GLImpl/Sync/GL_Sync.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <atomic>
#include <mutex>
@@ -37,7 +39,12 @@ namespace MobileGL {
if (logLifecycle) {
MGLOG_I("MobileGL closing...");
}
glslang::FinalizeProcess();
// First, before anything else is torn down. In-flight compile/link jobs own
// their own inputs and are safe against everything below EXCEPT glslang's
// process globals and the TShader/TProgram objects hanging off pGLContext,
// both of which this function is about to destroy. This is the one
// cancellation path in the whole design that waits.
MG_Util::Async::ShaderCompilePool::Get().StopAndDrain();
// GL syncs die with their contexts, and every context is gone by the
// time full teardown runs: drain the live-sync registry while the
// backend function table can still release the backend handles (and
@@ -49,6 +56,16 @@ namespace MobileGL {
MG_State::pEGLContext.reset();
MG_Impl::GLImpl::TextureImpl::pProxyTextureManager.reset();
MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo.reset();
// Must run AFTER pGLContext.reset(). FinalizeProcess -> ShFinalize deletes
// glslang's process-wide pool allocator and every cached built-in symbol table,
// while the TShader/TProgram objects owned by the shader and program objects
// still reference levels adopted from those tables. Finalizing first left live
// glslang objects pointing at freed memory for the rest of the teardown.
glslang::FinalizeProcess();
// Immediately after, and never apart from it: FinalizeProcess just deleted the
// built-in symbol tables the prewarm latch stands for, so leaving it set would
// make the next Initialize() skip a prewarm it genuinely needs.
MG_Util::ShaderTranspiler::ShaderCompiler::ResetPrewarmLatch();
MG_Backend::gBackendFunctionsTable = {};
g_isInitialized = false;
if (logLifecycle) {
@@ -76,6 +93,19 @@ namespace MobileGL {
MG_Impl::Init();
MGLOG_D("MG_Impl initialized");
glslang::InitializeProcess();
// On the GL thread, before any worker can exist. glslang builds its built-in symbol
// tables lazily under a process-wide lock held for the whole build, so without this
// the first concurrent compiles of a shaderpack all serialize behind the very first
// parse and asynchronous compilation looks like it is doing nothing.
//
// Gated on the flag, because the problem it solves only exists when there are
// workers: with compilation synchronous, nothing ever contends for that lock and the
// three throwaway parses buy nothing - they just add to every eglInitialize. Read the
// flag here rather than inside PrewarmBuiltins so ShaderCompiler keeps no dependency
// on the async subsystem (ProgramUtilTest compiles that file without it).
if (MG_Util::Async::AsyncShaderCompileEnabled()) {
MG_Util::ShaderTranspiler::ShaderCompiler::PrewarmBuiltins();
}
MGLOG_D("glslang initialized");
g_isInitialized = true;
MGLOG_I("MobileGL initialized");
+12 -9
View File
@@ -181,15 +181,18 @@ namespace MobileGL {
void (*GetIntegeri_v)(GLenum target, GLuint index, GLint* data);
void (*GetInteger64i_v)(GLenum target, GLuint index, GLint64* data);
void (*GetProgramiv)(GLuint program, GLenum pname, GLint* params);
void (*GetProgramInterfaceiv)(GLuint program, GLenum programInterface, GLenum pname, GLint* params);
GLuint (*GetProgramResourceIndex)(GLuint program, GLenum programInterface, const GLchar* name);
void (*GetProgramResourceName)(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize,
GLsizei* length, GLchar* name);
void (*GetProgramResourceiv)(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params);
GLint (*GetProgramResourceLocation)(GLuint program, GLenum programInterface, const GLchar* name);
GLint (*GetProgramResourceLocationIndex)(GLuint program, GLenum programInterface, const GLchar* name);
void (*ShaderStorageBlockBinding)(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding);
// The GL program interface (glGetProgramInterfaceiv / glGetProgramResource*) is NOT
// a backend query: it describes the program the application wrote, in the
// application's namespace, which neither backend program is in. It is answered
// entirely by MG_Impl/GLImpl/Program/ProgramInterface from the frontend reflection.
// Takes the block's GL NAME, not glShaderStorageBlockBinding's index. The index
// the application passes is the frontend interface-query enumeration's, and no
// backend shares that index space: DirectVulkan enumerates SPIR-V descriptor
// bindings and DirectGLES asks a real driver about SPIRV-Cross-generated ESSL.
// The name is the one coordinate all three agree on, so the frontend resolves the
// index against its own enumeration and each backend maps the name to its own.
void (*ShaderStorageBlockBinding)(GLuint program, const GLchar* storageBlockName,
GLuint storageBlockBinding);
// GL fence sync objects. All entries are optional (may be null); the
// frontend then falls back to always-signaled sync semantics.
// FenceSync may itself return null when the backend cannot create a
@@ -18,6 +18,7 @@
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToStr/TextureEnumConverter.h>
#include <MG_Util/Texture/TextureFormatProcessor.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <Config.h>
#include <algorithm>
#include <cmath>
@@ -209,7 +210,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
reasons.push_back("GL_DEPTH_COMPONENT32 native probe failed on OpenGL ES");
}
if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) {
reasons.push_back("no three-channel multisample storage format on OpenGL ES");
reasons.push_back("no colour-renderable three-channel format on OpenGL ES");
}
if (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) {
reasons.push_back("EXT_render_snorm not supported");
@@ -552,26 +553,60 @@ namespace MobileGL::MG_Backend::DirectGLES {
for (SizeT targetIndex = 0; targetIndex < kFormatCapabilityTextureTargetCount; ++targetIndex) {
const auto target = static_cast<TextureTarget>(targetIndex);
// A multisample texture can only ever be rendered into, so its storage format
// has to stay colour-renderable; the ordinary fallback for a three-channel
// format is a three-channel one, which ES accepts as a texture but rejects as
// multisample storage. Recompute the fallback per target so those formats get
// widened here and nowhere else.
Flags<PixelFormatNormalizeOptionBit> targetOptions;
if (IsGLESProbeMultisampleTarget(target)) {
targetOptions |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
if (!capabilities.SupportsRenderSnorm || !capabilities.SupportsNorm16Texture) {
targetOptions |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
}
}
// Colour-attachable targets need a colour-renderable fallback; the ordinary
// fallback for a three-channel format is another three-channel one, which ES
// accepts as a texture but never as an attachment. Recompute the fallback per
// target so those formats get widened where the target demands it.
const Flags<PixelFormatNormalizeOptionBit> renderTargetOptions =
TextureImpl::GetRenderTargetNormalizeOptions(capabilities, targetIndex);
// Multisample storage has no three-channel form on ES at all, so its widening
// is unconditional and skips the native probe (which cannot succeed). Every
// other target keeps the widening on the DRIVER branch, behind the native
// probe: `shouldProbeFallback = !nativeCreated || !nativeRenderable` below is
// what makes the substitution conditional on the driver actually refusing, so
// a driver that does render to a three-channel image keeps allocating it byte
// for byte. That is a per-format runtime answer, NOT a desktop-vs-device
// split: llvmpipe renders to GL_RGB16F but refuses GL_RGB8_SNORM, GL_SRGB8,
// GL_RGB32F and the RGB integer formats, so the CI driver widens those eight
// too. Re-run the retrace fixtures and the glcts suites on any change here.
const Bool widenUnconditionally = IsGLESProbeMultisampleTarget(target);
GLESProbeFormatInfo fallbackInfo = outerFallbackInfo;
Bool hasForcedFallback = outerHasForcedFallback;
if (targetOptions) {
hasForcedFallback = BuildFallbackProbeFormatInfo(
requestedInternalFormat, forcedOptions | targetOptions, true, fallbackInfo);
if (!hasForcedFallback) {
BuildFallbackProbeFormatInfo(requestedInternalFormat, driverOptions | targetOptions, false,
if (renderTargetOptions) {
// Folded into the forced options only when a forced fallback already
// applies, so the render-target bits never *create* one: ANGLE's forced
// GL_RGB8_SNORM -> GL_RGB16F is still three-channel and still needs
// widening, but a non-ANGLE driver must not lose its native probe.
const Flags<PixelFormatNormalizeOptionBit> forcedProbeOptions =
(outerHasForcedFallback || widenUnconditionally) ? forcedOptions | renderTargetOptions
: forcedOptions;
hasForcedFallback =
BuildFallbackProbeFormatInfo(requestedInternalFormat, forcedProbeOptions, true,
fallbackInfo);
if (!hasForcedFallback) {
BuildFallbackProbeFormatInfo(requestedInternalFormat,
driverOptions | renderTargetOptions, false, fallbackInfo);
}
// HONEST STATUS OF THE FORCED PATH. A forced fallback is only ever built
// for ANGLE (GetForcedPixelFormatNormalizeOptions returns nothing for any
// other renderer), and it SKIPS the native probe entirely - the widened
// format is asserted rather than measured on this device. That assertion
// is validated on exactly one configuration, the android-angle retrace
// golden; it is NOT covered by the headless llvmpipe suites, which take
// the driver branch below and prove nothing about ANGLE's answers. So log
// the choice at INFO rather than the usual MGLOG_D caveat: on any other
// ANGLE device the device report is the only evidence there is of which
// storage format the image really got. Once per format on the ordinary 2D
// target - repeating it for all ten targets would bury the report.
if (hasForcedFallback && target == TextureTarget::Texture2D &&
(MG_Util::TextureFormatProcessor::GetApplicablePixelFormatNormalizeOptions(
requestedInternalFormat, renderTargetOptions) &
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)) {
MGLOG_I("Three-channel widening (FORCED path, no native probe): %s stored as %s. "
"Reason: %s. Device-validated on the android-angle golden only.",
MG_Util::ConvertTextureInternalFormatToString(logicalFormat).c_str(),
ConvertFallbackInternalFormatToString(fallbackInfo.InternalFormat).c_str(),
fallbackInfo.Reason.c_str());
}
}
@@ -617,8 +652,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
const SizeT renderbufferTargetIndex = GetRenderbufferFormatCapabilityTargetIndex();
Bool shouldProbeFallbackRenderbuffer = outerHasForcedFallback;
if (!outerHasForcedFallback) {
// A renderbuffer exists only to be attached, so it needs the same three-channel
// widening the colour-attachable texture targets get - and on the same terms: the
// native storage is probed first, so a driver that renders to it keeps it.
const Flags<PixelFormatNormalizeOptionBit> renderbufferOptions =
TextureImpl::GetRenderTargetNormalizeOptions(capabilities, renderbufferTargetIndex);
GLESProbeFormatInfo renderbufferFallbackInfo = outerFallbackInfo;
Bool renderbufferHasForcedFallback = outerHasForcedFallback;
if (renderbufferOptions) {
const Flags<PixelFormatNormalizeOptionBit> forcedProbeOptions =
outerHasForcedFallback ? forcedOptions | renderbufferOptions : forcedOptions;
renderbufferHasForcedFallback = BuildFallbackProbeFormatInfo(
requestedInternalFormat, forcedProbeOptions, true, renderbufferFallbackInfo);
if (!renderbufferHasForcedFallback) {
BuildFallbackProbeFormatInfo(requestedInternalFormat, driverOptions | renderbufferOptions,
false, renderbufferFallbackInfo);
}
}
Bool shouldProbeFallbackRenderbuffer = renderbufferHasForcedFallback;
if (!renderbufferHasForcedFallback) {
const Bool nativeRenderbufferComplete =
ProbeRenderbuffer(gl, nativeInfo.InternalFormat, logicalFormat, false, 1);
if (nativeRenderbufferComplete) {
@@ -632,16 +685,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
shouldProbeFallbackRenderbuffer = true;
}
}
if (shouldProbeFallbackRenderbuffer && outerFallbackInfo.InternalFormat != GL_UNKNOWN_MGL &&
ProbeRenderbuffer(gl, outerFallbackInfo.InternalFormat, logicalFormat, false, 1)) {
if (shouldProbeFallbackRenderbuffer && renderbufferFallbackInfo.InternalFormat != GL_UNKNOWN_MGL &&
ProbeRenderbuffer(gl, renderbufferFallbackInfo.InternalFormat, logicalFormat, false, 1)) {
if (AddCaveatFormatCaps(cache, renderbufferTargetIndex, formatIndex,
GetRenderbufferFeatureCaps(logicalFormat))) {
LogGLESFormatCaveat(logicalFormat, renderbufferTargetIndex, outerFallbackInfo);
LogGLESFormatCaveat(logicalFormat, renderbufferTargetIndex, renderbufferFallbackInfo);
}
const Int maxSamples =
GetGLESFormatMaxSamples(capabilities, logicalFormat, outerFallbackInfo.ImageFormat);
cache.SampleCounts[renderbufferTargetIndex][formatIndex] =
ProbeRenderbufferSampleCounts(gl, outerFallbackInfo.InternalFormat, logicalFormat, maxSamples);
GetGLESFormatMaxSamples(capabilities, logicalFormat, renderbufferFallbackInfo.ImageFormat);
cache.SampleCounts[renderbufferTargetIndex][formatIndex] = ProbeRenderbufferSampleCounts(
gl, renderbufferFallbackInfo.InternalFormat, logicalFormat, maxSamples);
}
}
}
@@ -891,6 +944,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
// extension explicitly permits. It is also the only thing that
// exposes glProgramParameteri before GL 4.1.
E_GL_ARB_get_program_binary};
// GL_KHR_parallel_shader_compile is MobileGL's own capability, not the host ES
// driver's: the compiler threads are MobileGL's, and glCompileShader/glLinkProgram
// are serviced entirely inside the frontend. Whether the device driver advertises
// the string is irrelevant here (the POST reports it separately, for the day the
// driver-side link is what gets parallelised).
//
// Gated on the async flag deliberately, and this is the whole reason the gate
// exists. Advertising the string is the one part of asynchronous compilation that a
// recorded trace can never cover: Iris and Sodium change their SUBMISSION SCHEDULE
// the moment they see it - they enqueue whole pipeline batches and poll
// GL_COMPLETION_STATUS_KHR instead of compiling one program at a time - so
// MOBILEGL_ASYNC_SHADER_COMPILE=0 has to withdraw the application-visible behaviour
// change as well as the threading, or the kill switch would only be half a switch.
if (MG_Util::Async::AsyncShaderCompileEnabled()) {
extensions.push_back(E_GL_KHR_parallel_shader_compile);
}
// Only advertised when the device driver actually has usable timer queries
// (GL_EXT_disjoint_timer_query plus its entry points) and the
// MOBILEGL_DISABLE_TIMERQUERY escape hatch is off.
@@ -951,12 +1020,6 @@ namespace MobileGL::MG_Backend::DirectGLES {
funcsTable.GL.GetIntegeri_v = GetIntegeri_v;
funcsTable.GL.GetInteger64i_v = GetInteger64i_v;
funcsTable.GL.GetProgramiv = GetProgramiv;
funcsTable.GL.GetProgramInterfaceiv = GetProgramInterfaceiv;
funcsTable.GL.GetProgramResourceIndex = GetProgramResourceIndex;
funcsTable.GL.GetProgramResourceName = GetProgramResourceName;
funcsTable.GL.GetProgramResourceiv = GetProgramResourceiv;
funcsTable.GL.GetProgramResourceLocation = GetProgramResourceLocation;
funcsTable.GL.GetProgramResourceLocationIndex = GetProgramResourceLocationIndex;
funcsTable.GL.ShaderStorageBlockBinding = ShaderStorageBlockBinding;
funcsTable.GL.Clear = Clear;
funcsTable.GL.ClearBufferfi = ClearBufferfi;
File diff suppressed because it is too large Load Diff
+26 -9
View File
@@ -92,15 +92,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
void GetIntegeri_v(GLenum target, GLuint index, GLint* data);
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
void GetProgramiv(GLuint program, GLenum pname, GLint* params);
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params);
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name);
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei* length,
GLchar* name);
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params);
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name);
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name);
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding);
void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding);
Bool InitWindowSurface(NativeWindowType window);
Bool InitPbufferSurface(EGLint width, EGLint height);
Bool MakeCurrent();
@@ -182,6 +174,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
namespace XfbImpl {
Bool AreTransformFeedbacksSupported();
// True while a capture span is open on the current transform feedback object
// (frontend Begin seen and not paused), whether or not the deferred driver-side
// Begin has been issued yet. Draw paths that would restructure the primitive
// stream, or that need to dispatch compute mid-draw, decline while it is set.
Bool IsCaptureSpanOpen();
void BeginTransformFeedback(GLenum primitiveMode);
void EndTransformFeedback();
void PauseTransformFeedback();
@@ -191,6 +188,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
void OnBackendContextDestroyed();
} // namespace XfbImpl
namespace RenderStateImpl {
// Pushes the frontend's render-state block to the ES driver, diffed against what was
// last pushed.
//
// `forColorClear` names the CALLER, and the only thing it changes is the colour write
// mask handed to the driver. A draw into a colour attachment the backend widened from
// three channels to four gets that buffer's alpha channel masked OFF, so nothing can
// move the stored alpha away from the 1.0 the application's three-channel format
// implies (see FramebufferImpl::g_alphaWidenedDrawBufferMask). A CLEAR is how that 1.0
// gets there in the first place, so it must be allowed to write alpha - hence the flag
// rather than an unconditional doctoring. It is part of the sync memo, so a clear
// followed by a draw re-pushes the mask instead of early-outing on an unchanged
// frontend version.
//
// The application's own colour mask is never modified: glGet(GL_COLOR_WRITEMASK)
// answers from the frontend state, which this function only reads.
void SyncRenderState(Bool forColorClear = false);
void InvalidateSyncedRenderState();
} // namespace RenderStateImpl
extern MG_External::EGLFunctionsTable g_EGLFuncs;
extern MG_External::GLESFunctionsTable g_GLESFuncs;
extern MG_External::GLESCapabilities g_GLESCapabilities;
+398 -20
View File
@@ -28,6 +28,7 @@
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <mutex>
#include <cstring>
#include <regex>
@@ -114,6 +115,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
return source;
}
namespace {
Bool g_processTeardown = false;
std::once_flag g_teardownSentinelOnce;
} // namespace
Bool InProcessTeardown() { return g_processTeardown; }
void EnsureProcessTeardownSentinel() {
std::call_once(g_teardownSentinelOnce,
[] { std::atexit(+[] { g_processTeardown = true; }); });
}
String EmulateBaseInstanceInVertexShader(String source, GLenum shaderType) {
if (shaderType != GL_VERTEX_SHADER || source.find("gl_BaseInstance") == String::npos) {
return source;
@@ -1388,6 +1400,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
BackendVertexArrayObject::~BackendVertexArrayObject() {
if (InProcessTeardown()) {
return; // see InProcessTeardown(): the driver may be unloaded already
}
if (m_backendVAOId != 0) {
NoteVAOIdDeleted(m_backendVAOId);
g_GLESFuncs.glDeleteVertexArrays(1, &m_backendVAOId);
@@ -1641,6 +1656,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
BackendTextureObject::~BackendTextureObject() {
if (InProcessTeardown()) {
return; // see InProcessTeardown(): the driver may be unloaded already
}
if (m_backendTextureId == 0) {
return;
}
@@ -1851,6 +1869,154 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
// Components per texel the frontend format's client data carries. Only the three-channel
// formats that can be widened to a four-channel render target need an answer (see
// PrepareChannelWidenedUpload); everything else keeps its own layout and reports 0.
Uint GetWidenableClientComponentCount(TextureInternalFormat format) {
switch (format) {
case TextureInternalFormat::RGB8Snorm:
case TextureInternalFormat::RGB16Snorm:
case TextureInternalFormat::RGB16:
case TextureInternalFormat::RGB10: // stored as RGB16 (UNorm16 shadow)
case TextureInternalFormat::RGB12: // stored as RGB16 (UNorm16 shadow)
case TextureInternalFormat::RGB16F:
case TextureInternalFormat::RGB32F:
case TextureInternalFormat::SRGB8:
case TextureInternalFormat::RGB8I:
case TextureInternalFormat::RGB8UI:
case TextureInternalFormat::RGB16I:
case TextureInternalFormat::RGB16UI:
case TextureInternalFormat::RGB32I:
case TextureInternalFormat::RGB32UI:
return 3;
default:
return 0;
}
}
// True when the widened format's client data is integer rather than normalized. The two
// classes share every narrow component type - GL_RGB8I and GL_RGB8_SNORM are both uploaded
// as GL_BYTE - but their "1.0" differs: an integer channel's one is the integer 1, a
// normalized channel's is the saturated field. The type alone cannot tell them apart, so
// the source format has to.
Bool IsIntegerWidenableFormat(TextureInternalFormat format) {
switch (format) {
case TextureInternalFormat::RGB8I:
case TextureInternalFormat::RGB8UI:
case TextureInternalFormat::RGB16I:
case TextureInternalFormat::RGB16UI:
case TextureInternalFormat::RGB32I:
case TextureInternalFormat::RGB32UI:
return true;
default:
return false;
}
}
// The bit pattern of 1.0 in an upload component type: what a format without alpha reads
// back as, and therefore what the synthetic fourth channel of a widened render target has
// to hold. Integer components carry the integer one, not a saturated field - and since
// GL_BYTE/GL_SHORT/GL_UNSIGNED_BYTE/GL_UNSIGNED_SHORT serve both classes, `integerData`
// is what decides, not the type.
static Bool GetUploadComponentOneBits(GLenum uploadType, Bool integerData, Uint8* outOneBits,
SizeT* outComponentSize) {
switch (uploadType) {
case GL_BYTE: {
const Int8 one = integerData ? Int8(1) : Int8(0x7F);
Memcpy(outOneBits, &one, sizeof(one));
*outComponentSize = sizeof(one);
return true;
}
case GL_UNSIGNED_BYTE: {
const Uint8 one = integerData ? Uint8(1) : Uint8(0xFF);
Memcpy(outOneBits, &one, sizeof(one));
*outComponentSize = sizeof(one);
return true;
}
case GL_SHORT: {
const Int16 one = integerData ? Int16(1) : Int16(0x7FFF);
Memcpy(outOneBits, &one, sizeof(one));
*outComponentSize = sizeof(one);
return true;
}
case GL_UNSIGNED_SHORT: {
const Uint16 one = integerData ? Uint16(1) : Uint16(0xFFFF);
Memcpy(outOneBits, &one, sizeof(one));
*outComponentSize = sizeof(one);
return true;
}
case GL_HALF_FLOAT: {
const Uint16 one = 0x3C00; // half 1.0
Memcpy(outOneBits, &one, sizeof(one));
*outComponentSize = sizeof(one);
return true;
}
case GL_FLOAT: {
const Float one = 1.0f;
Memcpy(outOneBits, &one, sizeof(one));
*outComponentSize = sizeof(one);
return true;
}
case GL_INT: {
const Int32 one = 1;
Memcpy(outOneBits, &one, sizeof(one));
*outComponentSize = sizeof(one);
return true;
}
case GL_UNSIGNED_INT: {
const Uint32 one = 1;
Memcpy(outOneBits, &one, sizeof(one));
*outComponentSize = sizeof(one);
return true;
}
default:
return false;
}
}
// A three-channel format widened to four to keep a colour attachment renderable (see
// NormalizePixelFormat) is described to the driver as a four-component transfer, so the
// three-component client data has to be repacked with an alpha of 1.0 - otherwise the
// driver walks three texels' worth of data per four-texel row and the image shears.
// `componentCount` is the SOURCE component count and `byteSize` the source's size, so this
// runs after any type conversion (which keeps the component count) has already happened.
const void* PrepareChannelWidenedUpload(Uint componentCount, const IntVec3& texelSize,
const void* data, SizeT byteSize, GLenum uploadType,
Vector<Uint8>& widenedData, Bool integerData) {
Uint8 oneBits[8] = {};
SizeT componentSize = 0;
if (componentCount != 3 || data == nullptr || byteSize == 0 ||
!GetUploadComponentOneBits(uploadType, integerData, oneBits, &componentSize)) {
return data;
}
const SizeT srcTexelBytes = componentSize * componentCount;
// Sized from the level, never from the source: the driver reads a full
// width*height*depth*4 components for the transfer it was handed, so a source that
// somehow holds fewer texels must still leave a full destination behind (its tail
// reads as transparent black with the format's implied opaque alpha) rather than a
// short buffer the driver would run off the end of.
const SizeT texelCount = static_cast<SizeT>(std::max(texelSize.x(), 0)) *
static_cast<SizeT>(std::max(texelSize.y(), 0)) *
static_cast<SizeT>(std::max(texelSize.z(), 1));
if (texelCount == 0) {
return data;
}
const SizeT copyTexelCount = std::min(texelCount, byteSize / srcTexelBytes);
widenedData.assign(texelCount * componentSize * 4, 0);
const auto* src = static_cast<const Uint8*>(data);
Uint8* dst = widenedData.data();
for (SizeT i = 0; i < texelCount; ++i, dst += componentSize * 4) {
if (i < copyTexelCount) {
Memcpy(dst, src, srcTexelBytes);
src += srcTexelBytes;
}
Memcpy(dst + srcTexelBytes, oneBits, componentSize);
}
return widenedData.data();
}
static const void* PrepareNormFloatFallbackUpload(TextureInternalFormat format,
const IntVec3& texelSize,
const void* data,
@@ -1896,6 +2062,39 @@ namespace MobileGL::MG_Backend::DirectGLES {
return convertedData.data();
}
// The two shadow -> upload conversions a fallback storage format can need, in order:
// the component type first (SNORM/UNORM shadows into the float the fallback stores), then
// the component count (three-channel client data into a four-channel widened render
// target). They compose: GL_RGB8_SNORM on a driver with no renderable three-channel
// format becomes GL_RGBA16F, so its Int8x3 shadow is converted to Float x3 and then
// repacked as Float x4 with alpha 1.0.
//
// Both scratch buffers belong to the caller so they outlive the returned pointer; the
// return value is `data` itself whenever neither conversion applies, which is what the
// sub-rect upload fast path tests for.
static const void* PrepareFallbackUpload(TextureInternalFormat format, TextureTarget target,
const IntVec3& texelSize, const void* data, SizeT byteSize,
GLenum uploadType, Vector<Float>& convertedData,
Vector<Uint8>& widenedData) {
const void* uploadData =
PrepareNormFloatFallbackUpload(format, texelSize, data, byteSize, uploadType, convertedData);
// The component-count switch first: it rules out every format that cannot be widened
// (which is nearly all of them, including GL_RGBA8) without touching the capability
// cache, so an ordinary atlas upload does not pay for a per-level cache lookup.
const Uint componentCount = GetWidenableClientComponentCount(format);
if (componentCount == 0 || !TextureImpl::BackendTextureFormatAddsAlpha(format, target)) {
return uploadData;
}
// The type conversion above rewrites the level into `convertedData` at four bytes per
// component while keeping the component count, so the widening's source size is that
// buffer's, not the shadow's.
const SizeT uploadByteSize = (!convertedData.empty() && uploadData == convertedData.data())
? convertedData.size() * sizeof(Float)
: byteSize;
return PrepareChannelWidenedUpload(componentCount, texelSize, uploadData, uploadByteSize, uploadType,
widenedData, IsIntegerWidenableFormat(format));
}
// RGB565/RGB5_A1 shadow data is stored as 8-bit unorm; uploading it as GL_UNSIGNED_BYTE
// leaves the 8-bit -> 5/6-bit requantization to the driver, whose rounding direction is
// implementation-defined: Adreno rounds to nearest (lossless round trip) but Mali floors,
@@ -2103,9 +2302,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
? textureMipmapObject->MapMipmapData(uploadTarget, level)
: nullptr;
Vector<Float> convertedUploadData;
const void* uploadData = PrepareNormFloatFallbackUpload(
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType,
convertedUploadData);
Vector<Uint8> widenedUploadData;
const void* uploadData = PrepareFallbackUpload(
textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData,
levelByteSize, glType, convertedUploadData, widenedUploadData);
Vector<Uint8> packedUploadData;
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
uploadData, levelByteSize, &glType, packedUploadData);
@@ -2235,9 +2435,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget);
auto* pData = textureMipmapObject->MapMipmapData(uploadTarget, level);
Vector<Float> convertedUploadData;
const void* uploadData = PrepareNormFloatFallbackUpload(
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType,
convertedUploadData);
Vector<Uint8> widenedUploadData;
const void* uploadData = PrepareFallbackUpload(
textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData,
levelByteSize, glType, convertedUploadData, widenedUploadData);
Vector<Uint8> packedUploadData;
uploadData =
PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
@@ -2294,9 +2495,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
? textureMipmapObject->MapMipmapData(uploadTarget, level)
: nullptr;
Vector<Float> convertedUploadData;
const void* uploadData = PrepareNormFloatFallbackUpload(
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType,
convertedUploadData);
Vector<Uint8> widenedUploadData;
const void* uploadData = PrepareFallbackUpload(
textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData,
levelByteSize, glType, convertedUploadData, widenedUploadData);
Vector<Uint8> packedUploadData;
uploadData =
PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
@@ -2404,9 +2606,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
auto texelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, level);
const void* mipData = textureMipmapObject->MapMipmapData(uploadTarget, level);
Vector<Float> convertedUploadData;
const void* uploadData = PrepareNormFloatFallbackUpload(
textureMipmapObject->GetFormat(), texelSize, mipData, byteSize, glType,
convertedUploadData);
Vector<Uint8> widenedUploadData;
const void* uploadData = PrepareFallbackUpload(
textureMipmapObject->GetFormat(), targetInternal, texelSize, mipData, byteSize,
glType, convertedUploadData, widenedUploadData);
Vector<Uint8> packedUploadData;
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), texelSize,
uploadData, byteSize, &glType, packedUploadData);
@@ -2809,7 +3012,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
MGLOG_D("%s(%s:%d) ES error %s", func, file, line, MG_Util::ConvertGLEnumToString(err).c_str());
});
// A three-channel format widened to four for a multisample target (see
// A three-channel format widened to four to keep the image colour-renderable (see
// NormalizePixelFormat) gains an alpha channel the frontend format does not have, and
// whatever the draw that filled it wrote there is not what GL would report: a format
// without alpha reads back as 1.0. Answer the ALPHA swizzle source with ONE so the
@@ -3121,6 +3324,113 @@ namespace MobileGL::MG_Backend::DirectGLES {
return false;
}
// The colour attachment glReadPixels/glGetTexImage would read from, or nullptr when the
// read buffer names no colour attachment at all.
static const MG_State::GLState::FramebufferAttachmentObject* GetReadColorAttachment() {
const auto& readFBO =
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject();
if (!readFBO) {
return nullptr;
}
const auto readBuffer = readFBO->GetReadBuffer();
if (readBuffer < FramebufferAttachmentType::Color0 || readBuffer > FramebufferAttachmentType::Color31) {
return nullptr;
}
return &readFBO->GetAttachment(readBuffer);
}
Bool IsAlphaWidenedColorAttachment(
const MG_State::GLState::FramebufferAttachmentObject& attachmentObject) {
if (attachmentObject.IsTexture()) {
const auto& textureObject = attachmentObject.GetTexture();
return textureObject && TextureImpl::BackendTextureFormatAddsAlpha(textureObject->GetFormat(),
textureObject->GetTarget());
}
if (attachmentObject.IsRenderbuffer()) {
const auto& renderbufferObject = attachmentObject.GetRenderbuffer();
return renderbufferObject &&
TextureImpl::BackendRenderbufferFormatAddsAlpha(renderbufferObject->GetInternalFormat());
}
return false;
}
Uint32 g_alphaWidenedDrawBufferMask = 0;
Uint32 g_integerColorDrawBufferMask = 0;
static Bool IsIntegerColorFormat(TextureInternalFormat format) {
switch (format) {
case TextureInternalFormat::R8I:
case TextureInternalFormat::R8UI:
case TextureInternalFormat::R16I:
case TextureInternalFormat::R16UI:
case TextureInternalFormat::R32I:
case TextureInternalFormat::R32UI:
case TextureInternalFormat::RG8I:
case TextureInternalFormat::RG8UI:
case TextureInternalFormat::RG16I:
case TextureInternalFormat::RG16UI:
case TextureInternalFormat::RG32I:
case TextureInternalFormat::RG32UI:
case TextureInternalFormat::RGB8I:
case TextureInternalFormat::RGB8UI:
case TextureInternalFormat::RGB16I:
case TextureInternalFormat::RGB16UI:
case TextureInternalFormat::RGB32I:
case TextureInternalFormat::RGB32UI:
case TextureInternalFormat::RGBA8I:
case TextureInternalFormat::RGBA8UI:
case TextureInternalFormat::RGBA16I:
case TextureInternalFormat::RGBA16UI:
case TextureInternalFormat::RGBA32I:
case TextureInternalFormat::RGBA32UI:
case TextureInternalFormat::RGB10A2UI:
return true;
default:
return false;
}
}
static Bool IsIntegerColorAttachment(
const MG_State::GLState::FramebufferAttachmentObject& attachmentObject) {
if (attachmentObject.IsTexture()) {
const auto& textureObject = attachmentObject.GetTexture();
return textureObject && IsIntegerColorFormat(textureObject->GetFormat());
}
if (attachmentObject.IsRenderbuffer()) {
const auto& renderbufferObject = attachmentObject.GetRenderbuffer();
return renderbufferObject && IsIntegerColorFormat(renderbufferObject->GetInternalFormat());
}
return false;
}
Uint32 ComputeAlphaWidenedDrawBufferMask(const MG_State::GLState::FramebufferObject& fbo) {
using FBO = MG_State::GLState::FramebufferObject;
const auto& drawBuffers = fbo.GetDrawBuffers();
Uint32 mask = 0;
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS && i < 32; ++i) {
const auto frontendBuf = drawBuffers[i];
if (frontendBuf < FramebufferAttachmentType::Color0 ||
frontendBuf > FramebufferAttachmentType::Color31) {
continue;
}
if (IsAlphaWidenedColorAttachment(fbo.GetAttachment(frontendBuf))) {
mask |= (1u << i);
}
}
return mask;
}
// The read attachment's storage carries an alpha channel its frontend format does not
// (the three-channel colour-renderable widening). GL answers such a read with 1.0, but
// the storage holds whatever the draw wrote there, so the readback has to overwrite it.
Bool IsAlphaWidenedFallbackReadAttachment() {
const auto* attachmentObject = GetReadColorAttachment();
if (attachmentObject == nullptr) {
return false;
}
return IsAlphaWidenedColorAttachment(*attachmentObject);
}
Bool IsFixedPointFallbackReadAttachment() {
const auto& readFBO =
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject();
@@ -3326,6 +3636,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (asTarget == FramebufferTarget::Draw) {
Uint32 snormClampOutputMask = 0;
Uint32 unormClampOutputMask = 0;
Uint32 alphaWidenedMask = 0;
Uint32 integerColorMask = 0;
for (Uint i = 0; i < FramebufferObject::MAX_DRAW_BUFFERS && i < 32; ++i) {
const auto frontendBuf = stateDrawBuffers[i];
if (frontendBuf < FramebufferAttachmentType::Color0 ||
@@ -3338,9 +3650,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
} else if (IsUnormFallbackAttachment(attachmentObject)) {
unormClampOutputMask |= (1u << i);
}
// Independent of the two above: a widened attachment can be SNORM
// (GL_RGB8_SNORM -> GL_RGBA16F, which also clamps) or not (GL_SRGB8 ->
// GL_SRGB8_ALPHA8, which does not), so it gets its own bit rather than an
// `else if` branch of theirs.
if (IsAlphaWidenedColorAttachment(attachmentObject)) {
alphaWidenedMask |= (1u << i);
}
if (IsIntegerColorAttachment(attachmentObject)) {
integerColorMask |= (1u << i);
}
}
PrgramImpl::g_snormFallbackClampOutputMask = snormClampOutputMask;
PrgramImpl::g_unormFallbackClampOutputMask = unormClampOutputMask;
g_alphaWidenedDrawBufferMask = alphaWidenedMask;
g_integerColorDrawBufferMask = integerColorMask;
}
// 2. Remap read buffer. glReadBuffer writes the READ-bound FBO's state, so
@@ -3780,6 +4104,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (InProcessTeardown()) {
return; // see InProcessTeardown(): the driver may be unloaded already
}
if (m_backendProgramId != 0) {
MGLOG_D("Deleting backend program object with ID: %u", m_backendProgramId);
g_GLESFuncs.glDeleteProgram(m_backendProgramId);
@@ -3791,6 +4118,34 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
Bool ApplyShaderStorageBlockBinding(Uint backendProgramId, const String& blockName, Uint binding) {
if (backendProgramId == 0 || blockName.empty()) return false;
if (!g_GLESFuncs.glGetProgramResourceIndex || !g_GLESFuncs.glShaderStorageBlockBinding) return false;
GLuint driverIndex =
g_GLESFuncs.glGetProgramResourceIndex(backendProgramId, GL_SHADER_STORAGE_BLOCK, blockName.c_str());
if (driverIndex == GL_INVALID_INDEX) {
// An arrayed block is enumerated per element by GL but declared once; the
// generated ESSL carries the bare block name.
const auto bracket = blockName.rfind('[');
if (bracket == String::npos || blockName.back() != ']') return false;
driverIndex = g_GLESFuncs.glGetProgramResourceIndex(backendProgramId, GL_SHADER_STORAGE_BLOCK,
blockName.substr(0, bracket).c_str());
if (driverIndex == GL_INVALID_INDEX) return false;
}
g_GLESFuncs.glShaderStorageBlockBinding(backendProgramId, driverIndex, binding);
return true;
}
void ReseedShaderStorageBlockBindings(Uint backendProgramId,
const MG_State::GLState::ProgramObject& stateProgramObject) {
const auto& overrides = stateProgramObject.GetShaderStorageBlockBindingOverrides();
if (overrides.empty()) return; // the overwhelming majority of programs
for (const auto& [blockName, binding] : overrides) {
if (binding < 0) continue;
ApplyShaderStorageBlockBinding(backendProgramId, blockName, static_cast<Uint>(binding));
}
}
void BackendProgramObjectImpl::SyncToBackend(
const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject) {
#ifdef TRACY_ENABLE
@@ -3825,9 +4180,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (attachedCount > 0) {
Vector<GLuint> attachedShaders(attachedCount);
GLsizei actualCount;
// Every GL out-param in this function is pre-initialized and every count is
// re-clamped after the query. A driver that returns without writing the
// out-param (no current context, a lost context, a stubbed entry point) would
// otherwise leak an uninitialized stack value straight into a container size
// or a loop bound - which is exactly how this path used to throw
// length_error out of a Vector fill-ctor.
GLsizei actualCount = 0;
g_GLESFuncs.glGetAttachedShaders(m_backendProgramId, attachedCount, &actualCount,
attachedShaders.data());
actualCount = std::clamp<GLsizei>(actualCount, 0, static_cast<GLsizei>(attachedShaders.size()));
MGLOG_D("Detaching %d existing shaders from program %u", actualCount, m_backendProgramId);
for (GLsizei i = 0; i < actualCount; ++i) {
@@ -3965,13 +4327,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_GLESFuncs.glShaderSource(backendShaderId, 1, &sourceCStr, nullptr);
g_GLESFuncs.glCompileShader(backendShaderId);
GLint compileStatus;
// GL_FALSE, not GL_TRUE: an unwritten out-param must read as "compile failed"
// and take the diagnostic path, never as a silent success that attaches an
// uncompiled shader.
GLint compileStatus = GL_FALSE;
g_GLESFuncs.glGetShaderiv(backendShaderId, GL_COMPILE_STATUS, &compileStatus);
if (compileStatus == GL_FALSE) {
GLint logLength;
GLint logLength = 0;
g_GLESFuncs.glGetShaderiv(backendShaderId, GL_INFO_LOG_LENGTH, &logLength);
Vector<GLchar> log(logLength);
if (logLength < 0) logLength = 0;
// +1 and zero-filled: GL_INFO_LOG_LENGTH already counts the terminator,
// but a driver that reports 0 (or fails the query) must still leave
// log.data() a readable empty C string for the %s below.
Vector<GLchar> log(static_cast<SizeT>(logLength) + 1, '\0');
g_GLESFuncs.glGetShaderInfoLog(backendShaderId, logLength, nullptr, log.data());
log.back() = '\0';
MGLOG_E("Shader compilation failed for backend ID %u: %s", backendShaderId, log.data());
m_backendProgramUsable = false;
continue;
@@ -4007,14 +4377,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
MGLOG_D("Linking program %u", m_backendProgramId);
g_GLESFuncs.glLinkProgram(m_backendProgramId);
GLint linkStatus;
GLint linkStatus = GL_FALSE;
g_GLESFuncs.glGetProgramiv(m_backendProgramId, GL_LINK_STATUS, &linkStatus);
m_backendProgramUsable = m_backendProgramUsable && linkStatus == GL_TRUE;
if (linkStatus != GL_TRUE) {
GLint logLength;
GLint logLength = 0;
g_GLESFuncs.glGetProgramiv(m_backendProgramId, GL_INFO_LOG_LENGTH, &logLength);
Vector<GLchar> log(logLength);
if (logLength < 0) logLength = 0;
Vector<GLchar> log(static_cast<SizeT>(logLength) + 1, '\0');
g_GLESFuncs.glGetProgramInfoLog(m_backendProgramId, logLength, nullptr, log.data());
log.back() = '\0';
MGLOG_E("Program %u linking failed for %u: %s", stateProgramObject->GetExternalIndex(),
m_backendProgramId, log.data());
} else {
@@ -4047,6 +4419,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
CacheResourceLocations(stateProgramObject);
// AFTER the link, because glShaderStorageBlockBinding needs the driver's linked
// interface. This is the only place Espryt applies a rebinding: the frontend
// record is authoritative and the glShaderStorageBlockBinding entry point itself
// deliberately never forces a program build (see DirectGLES.cpp), so a rebinding
// requested while no backend program existed yet arrives here instead.
ReseedShaderStorageBlockBindings(m_backendProgramId, *stateProgramObject);
m_syncedLinkVersion = stateProgramObject->GetLinkVersion();
m_isInitialized = true;
+169
View File
@@ -21,9 +21,72 @@ namespace MobileGL::MG_Backend::DirectGLES {
String EmulateBaseInstanceInVertexShader(String source, GLenum shaderType);
String PromoteDrawParameterGlobalsToUniforms(String source, GLenum shaderType);
// True once the process has entered exit(): past that point the EGL library and
// the driver may already be unloaded, so a backend twin's destructor must not
// call into g_GLESFuncs (the observed crash is a jump through an unmapped driver
// pointer from __run_exit_handlers) nor touch statics in other TUs (cross-TU
// destruction order is unspecified). Deliberate leak: the process is exiting and
// the driver reclaims GPU objects. The flag is set by a std::atexit handler that
// EnsureProcessTeardownSentinel() registers lazily on first registry use - by
// then every static everywhere has finished constructing, so this handler is
// guaranteed to run BEFORE any static destructor (atexit is LIFO). A destructor
// hook on the registry itself was tried first and is WRONG: tests and cache
// resets destroy temporary registry instances mid-run, which would latch the
// flag while the process is very much alive.
Bool InProcessTeardown();
void EnsureProcessTeardownSentinel();
// Which optional pieces of state a draw needs synchronized before it is issued.
// Index/indirect buffer syncs and the instancing-related work are skipped for
// draws that provably cannot read them.
enum class DrawSyncBit : Uint32 {
None = 0,
IndexBuffer = 1 << 0,
IndirectBuffer = 1 << 1,
Instancing = 1 << 2
};
// Deliberately the shared Flags<> rather than hand-written operators for this enum:
// a namespace-local operator| here would hide MobileGL::operator|(Bit, Bit) from
// every other scoped-enum flag set used inside this namespace.
using DrawSyncFlags = Flags<DrawSyncBit>;
// The GL-defined indirect command layouts, byte-identical to what the driver reads
// out of a GL_DRAW_INDIRECT_BUFFER. Also the staging layout the multi-draw emulation
// synthesizes commands into.
struct DrawElementsIndirectCommand {
Uint32 count = 0;
Uint32 instanceCount = 0;
Uint32 firstIndex = 0;
Int32 baseVertex = 0;
Uint32 baseInstance = 0;
};
struct DrawArraysIndirectCommand {
Uint32 count = 0;
Uint32 instanceCount = 0;
Uint32 first = 0;
Uint32 baseInstance = 0;
};
// Brings the whole draw-relevant frontend state onto the native ES context and binds
// the program; every GL draw entry point calls it exactly once before issuing draws.
void PrepareForDraw(DrawSyncFlags syncBits);
// GLES core supports only GL_PRIMITIVE_RESTART_FIXED_INDEX. Throws when the app enabled
// the arbitrary GL_PRIMITIVE_RESTART with a non-fixed index for this index type.
void CheckPrimitiveRestartSupported(GLenum indexType);
// Feed the current program's gl_BaseInstance / gl_DrawID emulation uniforms. Both are
// no-ops when the program does not read the corresponding builtin.
void SetCurrentBaseInstance(Uint32 baseInstance);
void SetCurrentDrawID(Uint32 drawId);
// True when the current program actually reads gl_DrawID, i.e. when a batched
// (single driver call) multi-draw tier would have to feed it one value for the whole
// batch and would therefore be wrong.
Bool CurrentProgramReadsDrawID();
template <typename StateObject, typename BackendObject>
class StateBackendObjectRegistry {
public:
using StatePtr = SharedPtr<StateObject>;
using StateWeakPtr = std::weak_ptr<StateObject>;
using BackendPtr = SharedPtr<BackendObject>;
@@ -43,6 +106,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
BackendPtr& GetOrCreate(const StatePtr& stateObj) {
MOBILEGL_ASSERT(stateObj != nullptr, "State object must not be null");
// Twin creation is the moment a driver-owned id starts needing a guarded
// destructor; cold path, so the once-guard costs nothing per draw.
EnsureProcessTeardownSentinel();
auto& entry = m_entries[stateObj.get()];
if (entry.stateRef.expired()) {
// The previous owner of this address is gone and the allocator handed it
@@ -449,6 +515,25 @@ namespace MobileGL::MG_Backend::DirectGLES {
return target == TextureTarget::Texture3D || target == TextureTarget::TextureCubeMap;
}
// Components per texel the frontend format's client data carries, for the three-channel
// formats that can be widened to a four-channel colour-renderable target; 0 for everything
// else. See PrepareChannelWidenedUpload.
Uint GetWidenableClientComponentCount(TextureInternalFormat format);
// True when a widenable format's components are integer rather than normalized, which is
// what decides the synthetic alpha's value: GL_RGB8I and GL_RGB8_SNORM are both uploaded
// as GL_BYTE, but their 1.0 is 1 and 0x7F respectively.
Bool IsIntegerWidenableFormat(TextureInternalFormat format);
// Repacks three-component client data as four components with an alpha of 1.0 in
// `uploadType`, for a format the backend widened to keep a colour attachment renderable.
// Returns `data` untouched when no widening applies. Pure CPU and context-free so a unit
// test can exercise the exact packing the driver is handed; `widenedData` is the caller's
// scratch buffer and has to outlive the returned pointer.
const void* PrepareChannelWidenedUpload(Uint componentCount, const IntVec3& texelSize, const void* data,
SizeT byteSize, GLenum uploadType, Vector<Uint8>& widenedData,
Bool integerData = false);
struct StateTextureBasicInfo { // Used for tracking texture state changes
TextureInternalFormat internalFormat = TextureInternalFormat::Unknown;
SizeT width = 0;
@@ -564,6 +649,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
SharedPtr<BackendTextureObject>& SyncTextureObjectToBackend(
const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
Bool imageBindableStorageRequired = false);
// Brings every texture the next draw reads - the touched units' bindings and the draw
// FBO's texture attachments - onto the backend, through the two borrowed-pair memos
// documented at their definitions. Declared here so tests can drive those memos directly.
void SyncNeccessaryTextures();
extern Array<Array<BackendTextureObject*, (SizeT)TextureTarget::TextureTargetCount>,
MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
g_boundTexturesCache;
@@ -644,6 +733,67 @@ namespace MobileGL::MG_Backend::DirectGLES {
// has to apply the clamp itself.
Bool IsFixedPointFallbackReadAttachment();
// True when the read buffer names a three-channel attachment the backend actually stores
// in a four-channel format (the colour-renderable widening). A format without alpha reads
// back as 1.0, so the readback path has to overwrite the alpha the draw left behind -
// unconditionally, since this is the format's own semantics rather than the
// GL_CLAMP_READ_COLOR rule the clamp above implements.
Bool IsAlphaWidenedFallbackReadAttachment();
// True when this attachment's storage carries an alpha channel its frontend format does
// not (the three-channel colour-renderable widening).
Bool IsAlphaWidenedColorAttachment(const MG_State::GLState::FramebufferAttachmentObject& attachmentObject);
// Bit i set = DRAW BUFFER i of `fbo` resolves to a colour attachment the backend widened
// from three channels to four. Indexed by draw-buffer slot, not by attachment point,
// because that is what glColorMaski / glClearBufferfv address.
Uint32 ComputeAlphaWidenedDrawBufferMask(const MG_State::GLState::FramebufferObject& fbo);
// The same mask for whatever is currently bound to GL_DRAW_FRAMEBUFFER, recomputed by
// SyncCurrentFBO (BackendFramebufferObject::SyncToBackend for the DRAW target, and reset
// to 0 on the default framebuffer). Read by the draw/clear state sync, so it is only
// trustworthy after SyncCurrentFBO has run in the same entry point.
//
// WHY IT EXISTS (the dst-alpha discipline). A widened attachment has a real alpha channel
// the application's format does not, and GL says a missing channel reads as 1.0. Readback
// can paper over that (ForceWideReadAlphaToOne), but GL_DST_ALPHA /
// GL_ONE_MINUS_DST_ALPHA blending and glBlitFramebuffer read the STORED alpha inside the
// driver where no interception is possible. So the stored alpha is kept at 1.0 instead:
// a clear touching a widened buffer writes alpha 1.0, and every draw into it has its
// alpha write mask forced off, so nothing can ever move it again. The application's own
// colour mask is untouched - glGet(GL_COLOR_WRITEMASK) still reports what it set.
extern Uint32 g_alphaWidenedDrawBufferMask;
// Bit i set = DRAW BUFFER i of the framebuffer bound as DRAW resolves to a colour
// attachment with an INTEGER format. Recomputed beside the mask above and for its sake:
// glClearBufferfv on an integer colour buffer is GL_INVALID_OPERATION, so the
// per-draw-buffer clear route the widening needs has to stand down when one is present.
// (glClear on an integer colour buffer is left undefined by ES in the first place, and
// an application that wants a defined answer has to call glClearBufferuiv/iv - which does
// carry the widened alpha substitution.)
extern Uint32 g_integerColorDrawBufferMask;
// The colour a clear has to hand the driver for one draw buffer: the application's value,
// except that a widened attachment's alpha is replaced by the 1.0 its three-channel
// format implies. `one` is 1.0 encoded in the clear call's own component type - the
// integer clears carry the integer 1, the float clear carries 1.0f.
//
// Returns `value` itself when nothing is substituted, so the ordinary path allocates and
// copies nothing; `scratch` is the caller's buffer and has to outlive the returned
// pointer. Free of GL state on purpose, so the substitution can be unit-tested exactly as
// the driver sees it.
template <typename T>
const T* SubstituteWidenedClearAlpha(const T* value, Bool widened, T one, T (&scratch)[4]) {
if (!widened || value == nullptr) {
return value;
}
scratch[0] = value[0];
scratch[1] = value[1];
scratch[2] = value[2];
scratch[3] = one;
return scratch;
}
// What SyncCurrentFBO last pushed for each target, as a (binding, object, revision)
// triple; it re-syncs unless all three still match. Stamped by SyncCurrentFBO and
// ForceBindCurrentFBO, cleared by InvalidateFramebufferBindingCache. The three are
@@ -847,6 +997,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
void SetBaseInstance(Uint32 baseInstance) const;
void SetBaseInstanceWordIndex(Int32 wordIndex) const;
void SetDrawID(Uint32 drawId) const;
// True when the transpiled program kept a gl_DrawID uniform, i.e. SetDrawID
// actually reaches a shader read rather than being discarded.
Bool ReadsDrawID() const { return m_drawIdUniformLocation >= 0; }
Int GetIndirectParamsBinding() const { return m_indirectParamsBinding; }
Uint GetBackendProgramId() const { return m_backendProgramId; }
// False when the last SyncToBackend could not produce a usable program (a
@@ -912,6 +1065,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
extern Uint g_lastUsedBackendProgramId;
extern StateBackendObjectRegistry<MG_State::GLState::ProgramObject, BackendProgramObjectImpl>
g_backendProgramObjects;
// Points one shader storage block of an ALREADY-LINKED backend program at
// `binding`. `blockName` is the frontend interface-query spelling; the real
// driver's own index for it is looked up here, because the transpiled ESSL's
// block order is not the frontend's. Returns false when the block does not exist
// on the backend program (eliminated as unused, or the driver lacks the entry
// points), which is not an error - GL_BUFFER_BINDING is served from the frontend
// record either way.
Bool ApplyShaderStorageBlockBinding(Uint backendProgramId, const String& blockName, Uint binding);
// Replays every glShaderStorageBlockBinding recorded on the program onto a backend
// program that was just built. The frontend record is authoritative (only the
// shader's DECLARED binding survives in the SPIR-V), so without this replay any
// rebuild would silently revert rebound blocks. Mirrors DirectVulkan's
// reseed-on-rebuild in BuildProgramResourceCache.
void ReseedShaderStorageBlockBindings(Uint backendProgramId,
const MG_State::GLState::ProgramObject& stateProgramObject);
} // namespace PrgramImpl
namespace SamplerImpl {
@@ -0,0 +1,894 @@
// MobileGL - MobileGL/MG_Backend/DirectGLES/MultiDraw.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 "MultiDraw.h"
#include "Managers.h"
#include <MG_State/GLState/Core.h>
#include <cstring>
#include <limits>
namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
using MG_Config::GLESMultiDrawMode;
namespace {
// ---------------------------------------------------------------------------
// Batch shape
// ---------------------------------------------------------------------------
SizeT IndexTypeSize(GLenum type) {
switch (type) {
case GL_UNSIGNED_BYTE: return 1;
case GL_UNSIGNED_SHORT: return 2;
case GL_UNSIGNED_INT: return 4;
default: return 0;
}
}
// The all-ones value of an index type, which is what GL restarts on once
// primitive restart is in play. CheckPrimitiveRestartSupported has already
// rejected the arbitrary-index form of GL_PRIMITIVE_RESTART, so an enabled
// restart always restarts here and nowhere else.
Uint32 RestartSentinelFor(GLenum type) {
switch (type) {
case GL_UNSIGNED_BYTE: return 0xFFu;
case GL_UNSIGNED_SHORT: return 0xFFFFu;
default: return 0xFFFFFFFFu;
}
}
Bool RestartActive() {
return MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) ||
MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex);
}
// Vertices per primitive for the modes whose sub-draws may be concatenated into a
// single draw without changing the primitive stream. Zero for strip/loop/fan modes
// (concatenation would weld one sub-draw's last primitive to the next sub-draw's
// first) and for GL_PATCHES, whose primitive size is dynamic tessellation state.
Uint32 ConcatenablePrimitiveSize(GLenum mode) {
switch (mode) {
case GL_POINTS: return 1;
case GL_LINES: return 2;
case GL_TRIANGLES: return 3;
case GL_LINES_ADJACENCY: return 4;
case GL_TRIANGLES_ADJACENCY: return 6;
default: return 0;
}
}
// Beyond this an emulated batch would ask for a scratch allocation measured in
// hundreds of megabytes (and the scratch ring never shrinks again); decline and let
// a per-sub-draw tier handle it instead of trying and failing inside the driver.
constexpr SizeT kMaxFlattenedIndices = SizeT{1} << 24;
// The flattening dispatch is one invocation per output index. ES 3.1 only
// guarantees 65535 work groups per dimension, and exceeding it makes
// glDispatchCompute an INVALID_VALUE no-op - which would leave the draw reading an
// uninitialised index buffer rather than failing visibly. Cap the tier there
// instead of querying: 4.19M indices is far past any real multi-draw batch, and
// beyond it the per-sub-draw tiers are the better answer anyway.
constexpr SizeT kComputeWorkGroupSize = 64;
constexpr SizeT kMaxComputeWorkGroups = 65535;
constexpr SizeT kMaxComputeFlattenedIndices = kMaxComputeWorkGroups * kComputeWorkGroupSize;
Uint BoundDrawIndirectBufferId() {
const auto& indirect =
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();
if (!indirect) return 0;
const auto* resource = BufferImpl::EnsureBufferResource(indirect);
return resource ? resource->id : 0;
}
const SharedPtr<MG_State::GLState::BufferObject>& BoundIndexBuffer() {
static const SharedPtr<MG_State::GLState::BufferObject> none;
const auto& vao = MG_State::pGLContext->GetBoundVertexArray();
if (!vao) return none;
return vao->GetIndexBufferBindingSlot().GetBoundObject();
}
// The GL name PrepareForDraw left on GL_ELEMENT_ARRAY_BUFFER, i.e. what a tier
// that swaps in a scratch index buffer has to put back. Restoring the exact name
// matters beyond tidiness: the VAO twin memoises that it already synced this
// index binding and will not re-issue it on the next draw.
Uint BoundIndexBufferId() {
const auto& ibo = BoundIndexBuffer();
if (!ibo) return 0;
const auto* resource = BufferImpl::EnsureBufferResource(ibo);
return resource ? resource->id : 0;
}
// ---------------------------------------------------------------------------
// Scratch GL objects
//
// All of them belong to the ES context and are abandoned (not deleted) when it
// dies, exactly like XfbImpl's scatter buffer: the names are the dead context's
// to reclaim, and deleting them would target whatever the successor context
// handed out for the same name.
// ---------------------------------------------------------------------------
struct ScratchBuffer {
Uint id = 0;
SizeT capacity = 0;
SizeT cursor = 0; // ring buffers only: next free byte
};
ScratchBuffer g_indirectCommands; // synthesized DrawElementsIndirectCommand array
ScratchBuffer g_rebasedIndices; // CPU-rebased index stream
ScratchBuffer g_drawInfo; // compute tier: per-sub-draw descriptors
ScratchBuffer g_flattenedIndices; // compute tier: flattened index stream
Uint g_computeProgram = 0;
Bool g_computeProgramFailed = false;
GLint g_uElementSize = -1;
GLint g_uDrawCount = -1;
GLint g_uTotalIndices = -1;
// Reused staging, so a steady stream of batches allocates nothing.
Vector<DrawElementsIndirectCommand> g_commandStaging;
Vector<Uint32> g_indexStaging;
Vector<Uint32> g_drawInfoStaging;
Vector<GLint> g_zeroBaseVertices;
// Everything below stages through GL_ARRAY_BUFFER, the manager-wide staging target
// (BufferImpl::TempBufferTarget); binding it disturbs no VAO state.
Bool EnsureScratchName(ScratchBuffer& buffer) {
if (buffer.id != 0) return true;
GLuint id = 0;
g_GLESFuncs.glGenBuffers(1, &id);
if (id == 0) return false;
buffer.id = id;
buffer.capacity = 0;
buffer.cursor = 0;
return true;
}
// Whole-buffer upload, for the two buffers that are read from offset 0 because they
// are bound as storage blocks. Respecifies rather than sub-updates: glBufferData
// orphans the previous store, so the upload never waits on a dispatch still reading
// the old contents out of the same name.
Bool UploadScratch(ScratchBuffer& buffer, SizeT bytes, const void* data) {
if (bytes == 0) return true;
if (!EnsureScratchName(buffer)) return false;
BufferImpl::BindBufferId(BufferImpl::TempBufferTarget, buffer.id);
// Grow in powers of two so a batch that creeps up in size stops respecifying.
SizeT capacity = buffer.capacity == 0 ? bytes : buffer.capacity;
while (capacity < bytes) capacity *= 2;
g_GLESFuncs.glBufferData(BufferImpl::TempBufferTarget, static_cast<GLsizeiptr>(capacity), nullptr,
GL_STREAM_DRAW);
buffer.capacity = capacity;
buffer.cursor = 0;
if (data) {
g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, 0, static_cast<GLsizeiptr>(bytes), data);
}
return true;
}
// Ring upload, for the buffers whose consumers can address a byte offset (indirect
// commands and rewritten index streams). Respecifying per batch is what an
// orphan-every-time scheme costs, and on a desktop-class driver that allocation
// dominated the tiers that use these buffers - a multi-draw of 32 sub-draws stages
// 640 bytes and paid for a fresh store to hold them. Bump-allocating instead means
// one respecify per wrap; every byte between two wraps is written exactly once, so
// nothing in flight is overwritten, and the wrap itself orphans.
constexpr SizeT kRingAlignment = 16; // >= 4, so both command and uint32-index offsets stay legal
constexpr SizeT kMinRingBytes = 1u << 16;
Bool UploadScratchRing(ScratchBuffer& buffer, SizeT bytes, const void* data, SizeT& outOffset) {
outOffset = 0;
if (bytes == 0) return true;
if (!EnsureScratchName(buffer)) return false;
BufferImpl::BindBufferId(BufferImpl::TempBufferTarget, buffer.id);
const SizeT aligned = (bytes + kRingAlignment - 1) & ~(kRingAlignment - 1);
if (buffer.capacity < aligned) {
SizeT capacity = buffer.capacity == 0 ? kMinRingBytes : buffer.capacity;
while (capacity < aligned) capacity *= 2;
g_GLESFuncs.glBufferData(BufferImpl::TempBufferTarget, static_cast<GLsizeiptr>(capacity), nullptr,
GL_STREAM_DRAW);
buffer.capacity = capacity;
buffer.cursor = 0;
} else if (buffer.cursor + aligned > buffer.capacity) {
g_GLESFuncs.glBufferData(BufferImpl::TempBufferTarget, static_cast<GLsizeiptr>(buffer.capacity),
nullptr, GL_STREAM_DRAW);
buffer.cursor = 0;
}
outOffset = buffer.cursor;
if (data) {
g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, static_cast<GLintptr>(outOffset),
static_cast<GLsizeiptr>(bytes), data);
}
buffer.cursor += aligned;
return true;
}
// ---------------------------------------------------------------------------
// Tier resolution
// ---------------------------------------------------------------------------
// Best-first, and measured rather than assumed. MobileGlues orders its own Auto
// multiindirect -> indirect -> basevertex; on both ES drivers available here that
// is backwards, because staging a command buffer per batch costs more than the
// driver entries it saves. mc_sodium_multidraw (132 batches x 32 sub-draws),
// ns/op, median of three:
//
// NVIDIA ES 3.2 Mesa llvmpipe ES 3.2
// ext n/a 19300
// basevertex 2500 25200
// multiindirect 5700 27600
// drawelements 5600 28700
// indirect 5800 31000
//
// Ring-allocating the command staging (instead of respecifying per batch) was
// tried first and moved the indirect tiers by less than noise, so the cost is the
// indirect draw path itself, not the upload. Only "ext" - a real multi-draw entry
// point rather than an indirect one - actually beats replaying the sub-draws.
//
// The compute tier is deliberately absent from the ladder: it rewrites the
// primitive stream rather than replaying it, and it measured slowest of all here,
// so it stays opt-in behind the env knob (the same call MobileGlues makes - its
// Auto never selects Compute either).
constexpr GLESMultiDrawMode kAutoLadder[] = {
GLESMultiDrawMode::Ext, GLESMultiDrawMode::BaseVertex, GLESMultiDrawMode::MultiIndirect,
GLESMultiDrawMode::Indirect, GLESMultiDrawMode::DrawElements,
};
Bool SupportsTier(GLESMultiDrawMode tier) {
return IsTierSupported(g_GLESCapabilities, g_GLESFuncs, tier);
}
GLESMultiDrawMode g_resolvedTier = GLESMultiDrawMode::Auto;
Bool g_tierResolved = false;
String g_tierResolution;
void ResolveTierOnce() {
if (g_tierResolved) return;
g_tierResolved = true;
g_resolvedTier =
ResolveTier(g_GLESCapabilities, g_GLESFuncs, MG_Config::Features.EsprytMultiDrawMode,
&g_tierResolution);
MGLOG_I("DirectGLES multi-draw: %s", g_tierResolution.c_str());
}
// Which tiers have already announced themselves, one bit per GLESMultiDrawMode.
// The resolution line above says which tier was CHOSEN; this says which one a
// batch actually went through, and the two differ whenever a batch's shape
// demotes it. Worth a line each: a multi-draw path that resolves to a tier and
// then quietly runs a different one is exactly how "the batch drew nothing"
// hides.
Uint32 g_announcedTiers = 0;
void NoteTierExecuted(GLESMultiDrawMode tier) {
const Uint32 bit = 1u << static_cast<Uint32>(tier);
if (g_announcedTiers & bit) return;
g_announcedTiers |= bit;
MGLOG_I("DirectGLES multi-draw: first batch executed via tier \"%s\"", TierName(tier));
}
// The tier this particular batch can actually take. A tier is demoted here when
// the batch's own shape - not the driver - rules it out; the compute tier keeps
// its remaining feasibility checks inside its implementation, where the data it
// has to walk is already in hand.
GLESMultiDrawMode ResolveTierForBatch(Bool programReadsDrawID, Bool hasIndexBuffer) {
ResolveTierOnce();
GLESMultiDrawMode tier = g_resolvedTier;
// Batched tiers issue one driver entry for the whole batch, so the emulated
// gl_DrawID uniform can only hold one value across every sub-draw. A program
// that reads gl_DrawID gets an unrolled tier, which feeds each sub-draw its
// own index (the spec's value); nothing else observes the difference.
const Bool batched = tier == GLESMultiDrawMode::Ext || tier == GLESMultiDrawMode::MultiIndirect ||
tier == GLESMultiDrawMode::Compute;
if (batched && programReadsDrawID) {
tier = SupportsTier(GLESMultiDrawMode::BaseVertex) ? GLESMultiDrawMode::BaseVertex
: GLESMultiDrawMode::DrawElements;
}
// The indirect tiers describe each sub-draw as an element offset into the
// bound element array buffer. A client-memory index array has no such buffer,
// and indirect draws are not defined without one.
if (!hasIndexBuffer &&
(tier == GLESMultiDrawMode::MultiIndirect || tier == GLESMultiDrawMode::Indirect)) {
tier = SupportsTier(GLESMultiDrawMode::BaseVertex) ? GLESMultiDrawMode::BaseVertex
: GLESMultiDrawMode::DrawElements;
}
return tier;
}
// ---------------------------------------------------------------------------
// Index rewriting, shared by the two tiers that fold base vertices into indices
// ---------------------------------------------------------------------------
// Both of those tiers emit GL_UNSIGNED_INT regardless of the source type. Keeping
// the source width would be wrong, not merely tight: GL adds baseVertex to the
// index at full precision, so a GL_UNSIGNED_SHORT index plus a base vertex past
// 65535 addresses a vertex the source type cannot spell. Widening also gives the
// rewritten stream a restart sentinel (0xFFFFFFFF) that survives the rebase.
void RebaseIndices(const Uint8* source, SizeT sourceIndexCount, SizeT indexSize, Int32 baseVertex,
Bool restartActive, Uint32 restartSentinel, Uint32* out) {
const Uint32 baseVertexBits = static_cast<Uint32>(baseVertex);
for (SizeT i = 0; i < sourceIndexCount; ++i) {
Uint32 value = 0;
switch (indexSize) {
case 1: value = source[i]; break;
case 2: {
Uint16 narrow = 0;
std::memcpy(&narrow, source + i * 2, sizeof(narrow));
value = narrow;
break;
}
default: std::memcpy(&value, source + i * 4, sizeof(value)); break;
}
// Unsigned wraparound is the defined behaviour for a negative base vertex.
out[i] = (restartActive && value == restartSentinel) ? 0xFFFFFFFFu : value + baseVertexBits;
}
}
// CPU-readable bytes of one sub-draw's indices, from the frontend shadow of the
// bound index buffer or straight from the client array. Null when the sub-draw
// would read outside the buffer.
const Uint8* ResolveSubDrawIndices(const SharedPtr<MG_State::GLState::BufferObject>& indexBuffer,
const Uint8* indexBufferBytes, SizeT indexBufferSize, const void* indices,
SizeT indexCount, SizeT indexSize) {
if (!indexBuffer) {
return static_cast<const Uint8*>(indices);
}
if (!indexBufferBytes) return nullptr;
const SizeT byteOffset = reinterpret_cast<SizeT>(indices);
const SizeT byteEnd = byteOffset + indexCount * indexSize;
if (byteEnd > indexBufferSize || byteEnd < byteOffset) return nullptr;
return indexBufferBytes + byteOffset;
}
// ---------------------------------------------------------------------------
// Tier: Ext - one glMultiDrawElementsBaseVertexEXT
// ---------------------------------------------------------------------------
Bool RunExt(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices, GLsizei drawcount,
const GLint* basevertex) {
if (!SupportsTier(GLESMultiDrawMode::Ext)) return false;
const GLint* baseVertices = basevertex;
if (!baseVertices) {
// glMultiDrawElements: every base vertex is 0, but the entry point still
// wants an array. One permanently-zero vector serves every such batch.
if (g_zeroBaseVertices.size() < static_cast<SizeT>(drawcount)) {
g_zeroBaseVertices.resize(static_cast<SizeT>(drawcount), 0);
}
baseVertices = g_zeroBaseVertices.data();
}
g_GLESFuncs.glMultiDrawElementsBaseVertexEXT(mode, count, type, indices, drawcount, baseVertices);
NoteTierExecuted(GLESMultiDrawMode::Ext);
return true;
}
// ---------------------------------------------------------------------------
// Tiers: MultiIndirect / Indirect - synthesized indirect commands
// ---------------------------------------------------------------------------
Bool RunIndirect(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
GLsizei drawcount, const GLint* basevertex, Bool batched, Bool feedDrawID) {
if (!SupportsTier(batched ? GLESMultiDrawMode::MultiIndirect : GLESMultiDrawMode::Indirect)) return false;
const SizeT indexSize = IndexTypeSize(type);
if (indexSize == 0) return false;
// Indirect commands address indices as an element offset into the bound element
// array buffer, and an indirect draw is not defined without one.
const auto& indexBuffer = BoundIndexBuffer();
if (!indexBuffer) return false;
g_commandStaging.resize(static_cast<SizeT>(drawcount));
for (GLsizei i = 0; i < drawcount; ++i) {
const SizeT byteOffset = reinterpret_cast<SizeT>(indices[i]);
// firstIndex counts elements, so an offset that is not a whole number of
// them cannot be expressed as a command at all.
if (byteOffset % indexSize != 0) return false;
auto& command = g_commandStaging[static_cast<SizeT>(i)];
command.count = count[i] > 0 ? static_cast<Uint32>(count[i]) : 0u;
command.instanceCount = 1;
command.firstIndex = static_cast<Uint32>(byteOffset / indexSize);
command.baseVertex = basevertex ? basevertex[i] : 0;
command.baseInstance = 0;
}
const SizeT commandBytes = g_commandStaging.size() * sizeof(DrawElementsIndirectCommand);
SizeT commandBase = 0;
if (!UploadScratchRing(g_indirectCommands, commandBytes, g_commandStaging.data(), commandBase)) {
return false;
}
// Every synthesized command carries baseInstance 0. Say so through the direct
// path, which also clears the indirect-params word index a preceding real
// indirect draw may have left pointing into its own command buffer.
SetCurrentBaseInstance(0);
const Uint previousIndirectBinding = BoundDrawIndirectBufferId();
BufferImpl::BindBufferId(GL_DRAW_INDIRECT_BUFFER, g_indirectCommands.id);
if (batched) {
g_GLESFuncs.glMultiDrawElementsIndirectEXT(mode, type, reinterpret_cast<const void*>(commandBase),
drawcount, 0);
} else {
for (GLsizei i = 0; i < drawcount; ++i) {
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
const SizeT commandOffset = commandBase + static_cast<SizeT>(i) * sizeof(DrawElementsIndirectCommand);
g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast<const void*>(commandOffset));
}
if (feedDrawID) SetCurrentDrawID(0);
}
BufferImpl::BindBufferId(GL_DRAW_INDIRECT_BUFFER, previousIndirectBinding);
NoteTierExecuted(batched ? GLESMultiDrawMode::MultiIndirect : GLESMultiDrawMode::Indirect);
return true;
}
// ---------------------------------------------------------------------------
// Tier: BaseVertex - the per-sub-draw replay
// ---------------------------------------------------------------------------
Bool RunBaseVertexLoop(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
GLsizei drawcount, const GLint* basevertex, Bool feedDrawID) {
if (!SupportsTier(GLESMultiDrawMode::BaseVertex)) return false;
for (GLsizei i = 0; i < drawcount; ++i) {
if (count[i] <= 0) continue;
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
g_GLESFuncs.glDrawElementsBaseVertex(mode, count[i], type, indices[i],
basevertex ? basevertex[i] : 0);
}
if (feedDrawID) SetCurrentDrawID(0);
NoteTierExecuted(GLESMultiDrawMode::BaseVertex);
return true;
}
// ---------------------------------------------------------------------------
// Tier: DrawElements - base vertices folded into a scratch index stream
// ---------------------------------------------------------------------------
Bool RunRebasedDrawElements(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
GLsizei drawcount, const GLint* basevertex, Bool feedDrawID) {
const SizeT indexSize = IndexTypeSize(type);
if (indexSize == 0) return false;
SizeT total = 0;
for (GLsizei i = 0; i < drawcount; ++i) {
if (count[i] > 0) total += static_cast<SizeT>(count[i]);
}
if (total == 0) return true;
if (total > kMaxFlattenedIndices) return false;
const auto& indexBuffer = BoundIndexBuffer();
const Uint8* indexBufferBytes = nullptr;
SizeT indexBufferSize = 0;
if (indexBuffer) {
// The shadow is the source of truth for CPU reads, but a persistent map or
// a shader write may have moved past it since the last sync.
indexBuffer->SyncPersistentMappedRange();
indexBuffer->SyncGpuWrites();
indexBufferBytes = indexBuffer->MappedData();
indexBufferSize = indexBuffer->GetSize();
}
const Bool restartActive = RestartActive();
const Uint32 restartSentinel = RestartSentinelFor(type);
g_indexStaging.resize(total);
SizeT cursor = 0;
for (GLsizei i = 0; i < drawcount; ++i) {
if (count[i] <= 0) continue;
const SizeT subDrawCount = static_cast<SizeT>(count[i]);
const Uint8* source = ResolveSubDrawIndices(indexBuffer, indexBufferBytes, indexBufferSize, indices[i],
subDrawCount, indexSize);
if (!source) {
MGLOG_E("DirectGLES multi-draw (drawelements tier): sub-draw %d reads outside the bound index "
"buffer; skipping the batch",
i);
return false;
}
RebaseIndices(source, subDrawCount, indexSize, basevertex ? basevertex[i] : 0, restartActive,
restartSentinel, g_indexStaging.data() + cursor);
cursor += subDrawCount;
}
SizeT indexBase = 0;
if (!UploadScratchRing(g_rebasedIndices, total * sizeof(Uint32), g_indexStaging.data(), indexBase)) {
return false;
}
const Uint previousIndexBinding = BoundIndexBufferId();
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, g_rebasedIndices.id);
cursor = 0;
for (GLsizei i = 0; i < drawcount; ++i) {
if (count[i] <= 0) continue;
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
g_GLESFuncs.glDrawElements(mode, count[i], GL_UNSIGNED_INT,
reinterpret_cast<const void*>(indexBase + cursor * sizeof(Uint32)));
cursor += static_cast<SizeT>(count[i]);
}
if (feedDrawID) SetCurrentDrawID(0);
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, previousIndexBinding);
NoteTierExecuted(GLESMultiDrawMode::DrawElements);
return true;
}
// ---------------------------------------------------------------------------
// Tier: Compute - the whole batch flattened into one rebased index stream
// ---------------------------------------------------------------------------
// One index per invocation. The sub-draw an output slot belongs to is found by
// binary search over the inclusive prefix sums of the sub-draw counts, which is
// why the descriptors are sorted by construction. Sub-draws with a zero count
// repeat the previous prefix sum and are therefore skipped by the search.
//
// Three storage blocks, not the five the shape suggests: ES 3.1 only guarantees
// four per compute stage, so the per-sub-draw descriptors share one buffer.
constexpr const char* kFlattenComputeSource = R"(#version 310 es
layout(local_size_x = 64) in;
uniform uint uElementSize;
uniform uint uDrawCount;
uniform uint uTotalIndices;
layout(std430, binding = 0) readonly buffer SourceIndices { uint sourceWords[]; };
layout(std430, binding = 1) readonly buffer DrawInfo { uint drawInfo[]; };
layout(std430, binding = 2) writeonly buffer FlatIndices { uint flatIndices[]; };
uint ReadSourceIndex(uint element) {
if (uElementSize == 4u) {
return sourceWords[element];
}
if (uElementSize == 2u) {
uint word = sourceWords[element >> 1u];
return (word >> ((element & 1u) * 16u)) & 0xFFFFu;
}
uint word = sourceWords[element >> 2u];
return (word >> ((element & 3u) * 8u)) & 0xFFu;
}
void main() {
uint outIndex = gl_GlobalInvocationID.x;
if (outIndex >= uTotalIndices) {
return;
}
uint low = 0u;
uint high = uDrawCount - 1u;
while (low < high) {
uint mid = low + (high - low) / 2u;
if (drawInfo[mid * 3u + 2u] > outIndex) {
high = mid;
} else {
low = mid + 1u;
}
}
uint localIndex = outIndex - (low == 0u ? 0u : drawInfo[(low - 1u) * 3u + 2u]);
// Unsigned wraparound is the defined behaviour for a negative base vertex. No
// restart sentinel handling: the tier declines outright while restart is enabled.
flatIndices[outIndex] = ReadSourceIndex(localIndex + drawInfo[low * 3u]) + drawInfo[low * 3u + 1u];
}
)";
struct FlattenedStream {
Uint bufferId = 0;
SizeT indexCount = 0;
};
Bool EnsureComputeProgram() {
if (g_computeProgram != 0) return true;
if (g_computeProgramFailed) return false;
g_computeProgramFailed = true; // cleared again only on a complete success
const GLuint shader = g_GLESFuncs.glCreateShader(GL_COMPUTE_SHADER);
if (shader == 0) {
MGLOG_E("DirectGLES multi-draw (compute tier): glCreateShader(GL_COMPUTE_SHADER) failed");
return false;
}
const char* source = kFlattenComputeSource;
g_GLESFuncs.glShaderSource(shader, 1, &source, nullptr);
g_GLESFuncs.glCompileShader(shader);
GLint status = GL_FALSE;
g_GLESFuncs.glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status != GL_TRUE) {
char log[1024] = {};
g_GLESFuncs.glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
MGLOG_E("DirectGLES multi-draw (compute tier): index-flattening shader failed to compile: %s", log);
g_GLESFuncs.glDeleteShader(shader);
return false;
}
const GLuint program = g_GLESFuncs.glCreateProgram();
if (program == 0) {
MGLOG_E("DirectGLES multi-draw (compute tier): glCreateProgram failed");
g_GLESFuncs.glDeleteShader(shader);
return false;
}
g_GLESFuncs.glAttachShader(program, shader);
g_GLESFuncs.glLinkProgram(program);
g_GLESFuncs.glDeleteShader(shader);
g_GLESFuncs.glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status != GL_TRUE) {
char log[1024] = {};
g_GLESFuncs.glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
MGLOG_E("DirectGLES multi-draw (compute tier): index-flattening program failed to link: %s", log);
g_GLESFuncs.glDeleteProgram(program);
return false;
}
g_computeProgram = program;
g_uElementSize = g_GLESFuncs.glGetUniformLocation(program, "uElementSize");
g_uDrawCount = g_GLESFuncs.glGetUniformLocation(program, "uDrawCount");
g_uTotalIndices = g_GLESFuncs.glGetUniformLocation(program, "uTotalIndices");
g_computeProgramFailed = false;
MGLOG_I("DirectGLES multi-draw: index-flattening compute program ready (id %u)", program);
return true;
}
// Builds the flattened stream, or leaves `out` empty when this batch's shape rules
// the tier out. Runs BEFORE PrepareForDraw - see the call site - so it may leave
// the compute program current and the first storage points unbound; the
// preparation that follows re-establishes both.
void FlattenWithCompute(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
GLsizei drawcount, const GLint* basevertex, FlattenedStream& out) {
if (!SupportsTier(GLESMultiDrawMode::Compute)) return;
const SizeT indexSize = IndexTypeSize(type);
if (indexSize == 0) return;
// Merging sub-draws into a single draw only reproduces the original primitive
// stream for list-shaped modes: a strip, loop or fan would gain primitives
// spanning the seam between two sub-draws.
const Uint32 primitiveSize = ConcatenablePrimitiveSize(mode);
if (primitiveSize == 0) return;
// Primitive restart defeats the whole-multiple-of-a-primitive argument below,
// even for a list mode. A restart ends the current primitive, so a sub-draw of
// six GL_TRIANGLES indices with a restart after the third emits ONE triangle
// and drops the two leftover vertices - and once concatenated those leftovers
// find a third vertex in the next sub-draw and become a triangle that GL never
// draws. Splicing separator sentinels into the flattened stream could fix it,
// at the cost of a per-sub-draw offset the prefix-sum layout does not carry;
// declining is the honest trade for a tier that is already opt-in.
if (RestartActive()) return;
// The shader reads the source indices as a storage buffer, so there has to be
// a real buffer to read - a client-memory index array has none.
const auto& indexBuffer = BoundIndexBuffer();
if (!indexBuffer) return;
// A dispatch inside an open capture span is not legal, and the span would also
// observe one merged draw rather than the batch it asked for.
if (XfbImpl::IsCaptureSpanOpen()) return;
auto* sourceResource = BufferImpl::EnsureBufferResource(indexBuffer);
if (!sourceResource || sourceResource->id == 0) return;
const SizeT sourceSize = indexBuffer->GetSize();
// std430 addresses the source as uint[]; a tail shorter than a word is not
// reachable, so a narrow index type needs a word-multiple buffer.
if (indexSize < 4 && (sourceSize % 4) != 0) return;
g_drawInfoStaging.resize(3 * static_cast<SizeT>(drawcount));
SizeT total = 0;
for (GLsizei i = 0; i < drawcount; ++i) {
const SizeT subDrawCount = count[i] > 0 ? static_cast<SizeT>(count[i]) : 0;
// GL drops a trailing partial primitive per sub-draw; concatenation would
// instead splice it onto the next sub-draw's first vertices.
if (subDrawCount % primitiveSize != 0) return;
const SizeT byteOffset = reinterpret_cast<SizeT>(indices[i]);
if (byteOffset % indexSize != 0) return;
if (subDrawCount != 0) {
const SizeT byteEnd = byteOffset + subDrawCount * indexSize;
if (byteEnd > sourceSize || byteEnd < byteOffset) return;
}
total += subDrawCount;
if (total > kMaxComputeFlattenedIndices) return;
const SizeT slot = 3 * static_cast<SizeT>(i);
g_drawInfoStaging[slot] = static_cast<Uint32>(byteOffset / indexSize);
g_drawInfoStaging[slot + 1] = static_cast<Uint32>(basevertex ? basevertex[i] : 0);
g_drawInfoStaging[slot + 2] = static_cast<Uint32>(total);
}
if (total == 0) return; // nothing to draw; the ordinary tiers no-op just as well
if (!EnsureComputeProgram()) return;
if (!UploadScratch(g_drawInfo, g_drawInfoStaging.size() * sizeof(Uint32), g_drawInfoStaging.data())) {
return;
}
if (!UploadScratch(g_flattenedIndices, total * sizeof(Uint32), nullptr)) return;
BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 0, sourceResource->id);
BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 1, g_drawInfo.id);
BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 2, g_flattenedIndices.id);
g_GLESFuncs.glUseProgram(g_computeProgram);
PrgramImpl::g_lastUsedBackendProgramId = g_computeProgram;
if (g_uElementSize >= 0) g_GLESFuncs.glUniform1ui(g_uElementSize, static_cast<GLuint>(indexSize));
if (g_uDrawCount >= 0) g_GLESFuncs.glUniform1ui(g_uDrawCount, static_cast<GLuint>(drawcount));
if (g_uTotalIndices >= 0) g_GLESFuncs.glUniform1ui(g_uTotalIndices, static_cast<GLuint>(total));
g_GLESFuncs.glDispatchCompute(
static_cast<GLuint>((total + kComputeWorkGroupSize - 1) / kComputeWorkGroupSize), 1, 1);
g_GLESFuncs.glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT | GL_ELEMENT_ARRAY_BARRIER_BIT);
// Hand the storage points back to their GL default. PrepareForDraw re-syncs
// only the points the app has actually touched, so leaving a scratch buffer on
// an untouched point would keep it visible to the next shader that declares one.
for (Uint point = 0; point < 3; ++point) {
BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, point, 0);
}
NoteTierExecuted(GLESMultiDrawMode::Compute);
out.bufferId = g_flattenedIndices.id;
out.indexCount = total;
}
} // namespace
// -------------------------------------------------------------------------------
// Public surface
// -------------------------------------------------------------------------------
Bool IsTierSupported(const MG_External::GLESCapabilities& caps, const MG_External::GLESFunctionsTable& funcs,
GLESMultiDrawMode tier) {
const Bool esAtLeast31 =
caps.GLESVersion.Major > 3 || (caps.GLESVersion.Major == 3 && caps.GLESVersion.Minor >= 1);
switch (tier) {
case GLESMultiDrawMode::Ext:
return caps.SupportsMultiDrawElementsBaseVertex;
case GLESMultiDrawMode::MultiIndirect:
return caps.SupportsMultiDrawIndirect && esAtLeast31 && funcs.glDrawElementsIndirect != nullptr;
case GLESMultiDrawMode::Indirect:
return esAtLeast31 && funcs.glDrawElementsIndirect != nullptr;
case GLESMultiDrawMode::BaseVertex:
return caps.SupportsDrawElementsBaseVertex;
case GLESMultiDrawMode::DrawElements:
// Plain glDrawElements over a rewritten index stream: ES 2 core, so this is
// the floor every other tier can fall back to.
return true;
case GLESMultiDrawMode::Compute:
// Three storage blocks, which is inside the four ES 3.1 guarantees per stage.
return caps.SupportsComputeShader && caps.MaxComputeShaderStorageBlocks >= 3 &&
funcs.glBindBufferBase != nullptr;
case GLESMultiDrawMode::Auto:
break;
}
return false;
}
GLESMultiDrawMode ResolveTier(const MG_External::GLESCapabilities& caps,
const MG_External::GLESFunctionsTable& funcs, GLESMultiDrawMode requested,
String* explanation) {
const auto bestAuto = [&]() {
for (const GLESMultiDrawMode tier : kAutoLadder) {
if (IsTierSupported(caps, funcs, tier)) return tier;
}
return GLESMultiDrawMode::DrawElements;
};
GLESMultiDrawMode resolved = GLESMultiDrawMode::DrawElements;
String line;
if (requested == GLESMultiDrawMode::Auto) {
resolved = bestAuto();
line = String("auto -> ") + TierName(resolved);
} else if (IsTierSupported(caps, funcs, requested)) {
resolved = requested;
line = String("MOBILEGL_ESPRYT_MULTIDRAW_MODE=") + TierName(requested) + " -> " + TierName(resolved);
} else {
resolved = bestAuto();
line = String("MOBILEGL_ESPRYT_MULTIDRAW_MODE=") + TierName(requested) +
" requested but unsupported by this driver -> " + TierName(resolved);
}
if (explanation) {
String supported;
for (const GLESMultiDrawMode tier : kAutoLadder) {
if (!IsTierSupported(caps, funcs, tier)) continue;
if (!supported.empty()) supported += ", ";
supported += TierName(tier);
}
if (IsTierSupported(caps, funcs, GLESMultiDrawMode::Compute)) {
supported += supported.empty() ? "compute (opt-in)" : ", compute (opt-in)";
}
*explanation = line + " (driver supports: " + supported + ")";
}
return resolved;
}
const char* TierName(GLESMultiDrawMode tier) {
switch (tier) {
case GLESMultiDrawMode::Auto: return "auto";
case GLESMultiDrawMode::Ext: return "ext";
case GLESMultiDrawMode::MultiIndirect: return "multiindirect";
case GLESMultiDrawMode::Indirect: return "indirect";
case GLESMultiDrawMode::BaseVertex: return "basevertex";
case GLESMultiDrawMode::DrawElements: return "drawelements";
case GLESMultiDrawMode::Compute: return "compute";
}
return "unknown";
}
GLESMultiDrawMode ResolvedTier() {
ResolveTierOnce();
return g_resolvedTier;
}
String DescribeTierResolution() {
ResolveTierOnce();
return g_tierResolution;
}
void OnBackendContextDestroyed() {
g_indirectCommands = {};
g_rebasedIndices = {};
g_drawInfo = {};
g_flattenedIndices = {};
g_computeProgram = 0;
g_computeProgramFailed = false;
g_uElementSize = -1;
g_uDrawCount = -1;
g_uTotalIndices = -1;
}
void DrawElementsBatch(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
GLsizei drawcount, const GLint* basevertex) {
if (drawcount <= 0 || !count || !indices) return;
// State-independent and possibly throwing, so it runs before any GL work.
CheckPrimitiveRestartSupported(type);
const Bool hasIndexBuffer = BoundIndexBuffer() != nullptr;
// The compute tier dispatches BEFORE the draw state is established: doing it
// afterwards would mean unpicking the program, SSBO and index bindings
// PrepareForDraw just made, and a dispatch inside an open transform feedback
// span is not legal at all. On success it hands back a flattened index stream.
FlattenedStream flattened;
if (ResolvedTier() == GLESMultiDrawMode::Compute && !CurrentProgramReadsDrawID()) {
FlattenWithCompute(mode, count, type, indices, drawcount, basevertex, flattened);
}
PrepareForDraw(DrawSyncBit::IndexBuffer);
if (flattened.indexCount != 0) {
const Uint previousIndexBinding = BoundIndexBufferId();
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, flattened.bufferId);
g_GLESFuncs.glDrawElements(mode, static_cast<GLsizei>(flattened.indexCount), GL_UNSIGNED_INT, nullptr);
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, previousIndexBinding);
return;
}
const Bool feedDrawID = CurrentProgramReadsDrawID();
const GLESMultiDrawMode tier = ResolveTierForBatch(feedDrawID, hasIndexBuffer);
Bool drawn = false;
switch (tier) {
case GLESMultiDrawMode::Ext:
drawn = RunExt(mode, count, type, indices, drawcount, basevertex);
break;
case GLESMultiDrawMode::MultiIndirect:
drawn = RunIndirect(mode, count, type, indices, drawcount, basevertex, /*batched=*/true, feedDrawID);
break;
case GLESMultiDrawMode::Indirect:
drawn = RunIndirect(mode, count, type, indices, drawcount, basevertex, /*batched=*/false, feedDrawID);
break;
case GLESMultiDrawMode::BaseVertex:
drawn = RunBaseVertexLoop(mode, count, type, indices, drawcount, basevertex, feedDrawID);
break;
case GLESMultiDrawMode::DrawElements:
drawn = RunRebasedDrawElements(mode, count, type, indices, drawcount, basevertex, feedDrawID);
break;
case GLESMultiDrawMode::Compute:
// Its pre-pass ran above; reaching here means it declined this batch's shape.
break;
case GLESMultiDrawMode::Auto:
break; // resolution never yields Auto
}
// Every tier above may decline a batch whose shape it cannot express. The two
// below are the floor: a base-vertex replay where the driver has one, and the
// rewritten index stream where it does not. Both are safe for any batch these
// entry points can receive.
if (!drawn) drawn = RunBaseVertexLoop(mode, count, type, indices, drawcount, basevertex, feedDrawID);
if (!drawn) drawn = RunRebasedDrawElements(mode, count, type, indices, drawcount, basevertex, feedDrawID);
if (!drawn) {
MGLOG_E("DirectGLES multi-draw: no usable tier for a %d sub-draw batch (mode 0x%x, type 0x%x); "
"the batch was dropped",
drawcount, mode, type);
}
}
} // namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl
@@ -0,0 +1,64 @@
// MobileGL - MobileGL/MG_Backend/DirectGLES/MultiDraw.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include <Config.h>
#include "DirectGLES.h"
// Emulation of the desktop glMultiDrawElements / glMultiDrawElementsBaseVertex entry
// points on OpenGL ES, which has neither in core.
//
// Every strategy below is an emulation; they differ only in which driver capability
// they lean on and in how many driver entries a batch of N sub-draws costs. The design
// follows MobileGlues (MobileGL-Dev/MobileGlues, gl/multidraw.cpp) tier for tier, plus
// the native GL_EXT_multi_draw_arrays interaction that MobileGL already had:
//
// Ext one glMultiDrawElementsBaseVertexEXT 1 driver entry
// MultiIndirect one glMultiDrawElementsIndirectEXT 1 driver entry + 1 upload
// Indirect N x glDrawElementsIndirect N + 1 upload
// BaseVertex N x glDrawElementsBaseVertex N
// DrawElements N x glDrawElements over CPU-rebased indices N + 1 upload
// Compute 1 x glDrawElements over a GPU-flattened, 1 dispatch + 1 entry
// rebased index stream
//
// Which one runs is resolved once per ES context from the driver's capabilities,
// capped by MOBILEGL_ESPRYT_MULTIDRAW_MODE, and can additionally be demoted per batch
// when the batch's own shape rules a tier out (see ResolveTierForBatch in the .cpp).
namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
// The tier this ES context resolved to, computed on first use and stable after.
MG_Config::GLESMultiDrawMode ResolvedTier();
// "multiindirect", "compute", ... - stable identifiers, also used by the POST row.
const char* TierName(MG_Config::GLESMultiDrawMode tier);
// One line naming the resolved tier, the tiers the driver can support, and the env
// clamp if one applied. For DriverPost and the startup log.
String DescribeTierResolution();
// The resolution itself, as a pure function of a capability set: the backend feeds
// it the live ES context's capabilities, DriverPost feeds it the ones it probed
// standalone, and both therefore report the same tier. `explanation`, when non-null,
// receives the "requested -> resolved (driver supports: ...)" line.
MG_Config::GLESMultiDrawMode ResolveTier(const MG_External::GLESCapabilities& caps,
const MG_External::GLESFunctionsTable& funcs,
MG_Config::GLESMultiDrawMode requested, String* explanation);
// Whether one tier is runnable on the given capability set, for per-row POST output.
Bool IsTierSupported(const MG_External::GLESCapabilities& caps, const MG_External::GLESFunctionsTable& funcs,
MG_Config::GLESMultiDrawMode tier);
// Runs `drawcount` indexed sub-draws as one glMultiDrawElements(BaseVertex) call
// would. `basevertex` is null for the plain glMultiDrawElements entry point (every
// base vertex is 0). Owns the whole draw, preparation included: callers must not
// have run PrepareForDraw, because the compute tier has to dispatch before the
// draw state is established.
void DrawElementsBatch(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
GLsizei drawcount, const GLint* basevertex);
// The ES context is gone: every scratch buffer and the compute program belonged to
// it, so drop the names without deleting them (the dead context reclaims them).
void OnBackendContextDestroyed();
} // namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl
+64 -31
View File
@@ -61,29 +61,6 @@ namespace MobileGL::MG_Backend::DirectGLES {
requestedInternalFormat, GetDriverPixelFormatNormalizeOptions() | extraOptions);
}
// Multisample textures can only ever be rendered into, never uploaded to, so a fallback
// format for them has to stay colour-renderable - a three-channel float fallback is a legal
// ES texture format but not a legal multisample storage format. Widening to four channels
// is safe here precisely because there is no transfer path that would have to expand
// three-channel client data, and the alpha the draw writes for a three-channel source is
// already the 1.0 the frontend format implies.
Bool TargetRequiresRenderableFormat(SizeT targetIndex) {
return targetIndex == static_cast<SizeT>(TextureTarget::Texture2DMultisample) ||
targetIndex == static_cast<SizeT>(TextureTarget::Texture2DMultisampleArray);
}
Flags<PixelFormatNormalizeOptionBit> GetRenderTargetNormalizeOptions(SizeT targetIndex) {
Flags<PixelFormatNormalizeOptionBit> options;
if (!TargetRequiresRenderableFormat(targetIndex)) {
return options;
}
options |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
if (!g_GLESCapabilities.SupportsRenderSnorm || !g_GLESCapabilities.SupportsNorm16Texture) {
options |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
}
return options;
}
Bool HasCachedFormatCapability(TextureInternalFormat internalFormat,
SizeT targetIndex,
Bool caveat,
@@ -141,14 +118,61 @@ namespace MobileGL::MG_Backend::DirectGLES {
const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
Flags<PixelFormatNormalizeOptionBit> options;
if (!pActiveBackendObject || ShouldUseCaveatFormat(internalFormat, targetIndex)) {
options = GetRuntimeFallbackNormalizeOptions(requestedInternalFormat,
GetRenderTargetNormalizeOptions(targetIndex));
options = GetRuntimeFallbackNormalizeOptions(
requestedInternalFormat,
TextureImpl::GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
}
NormalizePixelFormat(requestedInternalFormat, options, outInternalFormat, outFormat, outType);
}
} // namespace
namespace TextureImpl {
// Every image that can back a colour attachment needs a colour-renderable storage format,
// and ES has no renderable three-channel format at all: a three-channel float fallback is
// a legal ES texture but neither legal multisample storage nor a legal attachment, so
// GL_RGB8_SNORM / GL_RGB16F / ... have to be widened to four channels for any of them.
// This used to cover the multisample pair alone, on the grounds that only those can never
// be uploaded to; the transfer paths now expand three-channel client data themselves
// (Managers.cpp PrepareFallbackUpload) and hide the added alpha again on sample and
// readback, so the same substitution is available everywhere.
//
// The widening only ever *happens* where the driver refuses the native form (see
// PopulateFormatCapabilitiesImpl: outside multisample storage it rides the driver branch,
// behind the native probe), so a driver that does render to a three-channel image keeps
// allocating it byte for byte.
//
// Do NOT read that as "nothing changes off-device". Measured on Mesa 26.1.6 llvmpipe
// (the headless CI driver), an ES 3.2 GL_TEXTURE_2D colour attachment is COMPLETE for
// GL_RGB8 and GL_RGB16F but INCOMPLETE_ATTACHMENT for GL_RGB8_SNORM, GL_SRGB8 and every
// RGB integer format, and UNSUPPORTED for GL_RGB32F. Those eight formats therefore DO
// take the widened path on llvmpipe, which is where the retrace fixtures and the glcts
// green suites run - the substitution is driver-conditional, not desktop-exempt.
//
// A buffer texture is the one image that can never be an attachment; its storage is the
// buffer object's, and widening it would misdescribe the application's data.
Bool TargetRequiresRenderableFormat(SizeT targetIndex) {
if (targetIndex >= kFormatCapabilityTargetCount) {
return false;
}
if (targetIndex == kFormatCapabilityRenderbufferTargetIndex) {
return true;
}
return static_cast<TextureTarget>(targetIndex) != TextureTarget::TextureBuffer;
}
Flags<PixelFormatNormalizeOptionBit> GetRenderTargetNormalizeOptions(
const MG_External::GLESCapabilities& capabilities, SizeT targetIndex) {
Flags<PixelFormatNormalizeOptionBit> options;
if (!TargetRequiresRenderableFormat(targetIndex)) {
return options;
}
options |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
if (!capabilities.SupportsRenderSnorm || !capabilities.SupportsNorm16Texture) {
options |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
}
return options;
}
void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
GLenum* outFormat, GLenum* outType, TextureTarget target) {
#ifdef TRACY_ENABLE
@@ -178,9 +202,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
return ShouldUseCaveatFormat(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
}
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target) {
const SizeT targetIndex =
target == TextureTarget::Unknown ? kFormatCapabilityTargetCount : GetFormatCapabilityTargetIndex(target);
namespace {
Bool BackendFormatAddsAlpha(TextureInternalFormat internalFormat, SizeT targetIndex) {
if (!TargetRequiresRenderableFormat(targetIndex)) {
return false;
}
@@ -188,11 +211,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
return false;
}
const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
const Flags<PixelFormatNormalizeOptionBit> options =
GetRuntimeFallbackNormalizeOptions(requestedInternalFormat,
GetRenderTargetNormalizeOptions(targetIndex));
const Flags<PixelFormatNormalizeOptionBit> options = GetRuntimeFallbackNormalizeOptions(
requestedInternalFormat, GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
return static_cast<Bool>(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget);
}
} // namespace
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target) {
const SizeT targetIndex =
target == TextureTarget::Unknown ? kFormatCapabilityTargetCount : GetFormatCapabilityTargetIndex(target);
return BackendFormatAddsAlpha(internalFormat, targetIndex);
}
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat) {
return BackendFormatAddsAlpha(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
}
} // namespace TextureImpl
namespace PrgramImpl {
String ProcessOutColorLocations(const String& glslCode) {
+17 -3
View File
@@ -9,6 +9,8 @@
#pragma once
#include <Includes.h>
#include <MG_State/GLState/Core.h>
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
#include <MG_Util/Texture/TextureFormatProcessor.h>
namespace MobileGL::MG_Backend::DirectGLES {
namespace DebugImpl {
@@ -34,6 +36,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
} // namespace VertexArrayImpl
namespace TextureImpl {
// Whether images on this format-capability target can back a colour attachment, and so
// need a colour-renderable storage format even when the frontend asked for a
// three-channel one ES never renders to. Shared by the capability probe (which passes the
// capabilities it has just queried, before the globals are published) and by the
// allocation path (which reads the active backend's), so the format the cache was probed
// with is always the format the image is created with.
Bool TargetRequiresRenderableFormat(SizeT targetIndex);
Flags<PixelFormatNormalizeOptionBit> GetRenderTargetNormalizeOptions(
const MG_External::GLESCapabilities& capabilities, SizeT targetIndex);
void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
GLenum* outFormat, GLenum* outType,
TextureTarget target = TextureTarget::Unknown);
@@ -41,10 +53,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
GLenum* outFormat, GLenum* outType);
Bool ShouldUseCaveatTextureFormat(TextureInternalFormat internalFormat, TextureTarget target);
// True when the format the texture is actually created with has an alpha channel the
// frontend format does not (the three-channel multisample widening). GL reads such a
// channel back as 1.0, so any swizzle source of ALPHA has to be answered with ONE.
// True when the format the image is actually created with has an alpha channel the
// frontend format does not (the three-channel colour-renderable widening). GL reads such
// a channel back as 1.0, so any swizzle source of ALPHA has to be answered with ONE and
// any readback of the image has to overwrite the alpha the draw happened to leave there.
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target);
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat);
Bool ShouldUseCaveatRenderbufferFormat(TextureInternalFormat internalFormat);
} // namespace TextureImpl
@@ -16,6 +16,7 @@
#include "MG_Util/Converters/MGToStr/TextureEnumConverter.h"
#include "MG_Util/Converters/MGToVk/TextureEnumConverter.h"
#include "MG_Util/Texture/TextureFormatProcessor.h"
#include "MG_Util/Async/ShaderCompilePool.h"
#include <Config.h>
#include <cmath>
@@ -523,6 +524,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (shaderSubgroupSupported && !MG_Config::Features.DisableSubgroup) {
extensions.push_back(E_GL_KHR_shader_subgroup);
}
// GL_KHR_parallel_shader_compile is MobileGL's own capability, not the Vulkan
// device's: the compiler threads belong to MobileGL's shader pool and
// glCompileShader/glLinkProgram are serviced entirely inside the frontend, so there
// is no device feature to condition this on.
//
// Gated on the async flag deliberately, and this is the whole reason the gate
// exists. Advertising the string is the one part of asynchronous compilation that a
// recorded trace can never cover: Iris and Sodium change their SUBMISSION SCHEDULE
// the moment they see it - they enqueue whole pipeline batches and poll
// GL_COMPLETION_STATUS_KHR instead of compiling one program at a time - so
// MOBILEGL_ASYNC_SHADER_COMPILE=0 has to withdraw the application-visible behaviour
// change as well as the threading, or the kill switch would only be half a switch.
if (MG_Util::Async::AsyncShaderCompileEnabled()) {
extensions.push_back(E_GL_KHR_parallel_shader_compile);
}
// GL_ARB_timer_query gates MC's F3 GPU% (LWJGL checks the extension string);
// only advertised when the device actually supports timestamp queries and the
// MOBILEGL_DISABLE_TIMERQUERY escape hatch is off.
@@ -601,12 +617,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
funcsTable.GL.GetIntegeri_v = GetIntegeri_v;
funcsTable.GL.GetInteger64i_v = GetInteger64i_v;
funcsTable.GL.GetProgramiv = GetProgramiv;
funcsTable.GL.GetProgramInterfaceiv = GetProgramInterfaceiv;
funcsTable.GL.GetProgramResourceIndex = GetProgramResourceIndex;
funcsTable.GL.GetProgramResourceName = GetProgramResourceName;
funcsTable.GL.GetProgramResourceiv = GetProgramResourceiv;
funcsTable.GL.GetProgramResourceLocation = GetProgramResourceLocation;
funcsTable.GL.GetProgramResourceLocationIndex = GetProgramResourceLocationIndex;
funcsTable.GL.ShaderStorageBlockBinding = ShaderStorageBlockBinding;
funcsTable.GL.FenceSync = FenceSync;
funcsTable.GL.ClientWaitSync = ClientWaitSync;
+91 -535
View File
@@ -232,6 +232,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
StorageBlockResource block{};
block.name = blockName;
block.binding = binding->binding;
// glShaderStorageBlockBinding survives every rebuild of this cache: the
// authoritative record of a rebound block lives on the program (it is what
// GL_BUFFER_BINDING reports), and only the shader's declared binding is
// recoverable from the SPIR-V. Without this, any unrelated state-version
// bump would silently revert the block to its declared binding.
const Int rebound = program.GetShaderStorageBlockBindingOverride(blockName);
if (rebound >= 0) block.binding = static_cast<Uint32>(rebound);
block.dataSize = static_cast<GLint>(binding->block.size);
const GLuint blockIndex = static_cast<GLuint>(cache.storageBlocks.size());
AddBufferVariablesRecursive(binding->block, blockName, blockIndex, cache.bufferVariables,
@@ -256,18 +263,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return programObject.get();
}
void CopyResourceName(const String& source, GLsizei bufSize, GLsizei* length, GLchar* name) {
const GLsizei writtenLength = static_cast<GLsizei>(source.size());
if (length) {
*length = writtenLength;
}
if (name && bufSize > 0) {
const GLsizei copyLength = std::min<GLsizei>(bufSize - 1, writtenLength);
std::memcpy(name, source.data(), static_cast<SizeT>(copyLength));
name[copyLength] = '\0';
}
}
const Uint8* ResolveIndirectCommandBytes(const void* indirect, SizeT requiredBytes, const char* label) {
auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();
if (drawBuffer) {
@@ -288,100 +283,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return reinterpret_cast<const Uint8*>(indirect);
}
Vector<GLuint> GetUniformBlockActiveVariables(const MG_State::GLState::ProgramObject& program,
GLuint blockIndex) {
Vector<GLuint> activeVariables;
const Uint uniformCount = program.GetUniformCount();
activeVariables.reserve(uniformCount);
for (Uint uniformIndex = 0; uniformIndex < uniformCount; ++uniformIndex) {
if (program.GetActiveUniformBlockIndex(uniformIndex) == static_cast<Int>(blockIndex)) {
activeVariables.push_back(uniformIndex);
}
}
return activeVariables;
}
GLuint FindProgramInputIndex(const MG_State::GLState::ProgramObject& program, const String& name) {
const Int activeCount = program.GetActiveAttributesCount();
for (Int index = 0; index < activeCount; ++index) {
if (program.GetActiveAttribName(index) == name) {
return static_cast<GLuint>(index);
}
}
return GL_INVALID_INDEX;
}
GLuint FindProgramOutputIndex(const MG_State::GLState::ProgramObject& program, const String& name) {
const Int activeCount = program.GetActiveFragmentOutputCount();
for (Int index = 0; index < activeCount; ++index) {
if (program.GetActiveFragmentOutputName(index) == name) {
return static_cast<GLuint>(index);
}
}
return GL_INVALID_INDEX;
}
GLint GetProgramOutputLocation(const MG_State::GLState::ProgramObject& program, const String& name) {
const Int activeCount = program.GetActiveFragmentOutputCount();
for (Int index = 0; index < activeCount; ++index) {
if (program.GetActiveFragmentOutputName(index) == name) {
return program.GetFragmentOutputLocation(index);
}
}
return -1;
}
GLint GetProgramResourceActiveCount(const MG_State::GLState::ProgramObject& program, GLenum programInterface,
const ProgramResourceCache& cache) {
switch (programInterface) {
case GL_SHADER_STORAGE_BLOCK:
return static_cast<GLint>(cache.storageBlocks.size());
case GL_BUFFER_VARIABLE:
return static_cast<GLint>(cache.bufferVariables.size());
case GL_UNIFORM_BLOCK:
return program.GetActiveUniformBlocksCount();
case GL_UNIFORM:
return static_cast<GLint>(program.GetUniformCount());
case GL_PROGRAM_INPUT:
return program.GetActiveAttributesCount();
case GL_PROGRAM_OUTPUT:
return program.GetActiveFragmentOutputCount();
default:
return 0;
}
}
GLint GetProgramResourceMaxNameLength(const MG_State::GLState::ProgramObject& program, GLenum programInterface,
const ProgramResourceCache& cache) {
switch (programInterface) {
case GL_SHADER_STORAGE_BLOCK: {
SizeT maxLength = 0;
for (const auto& block : cache.storageBlocks) maxLength = std::max(maxLength, block.name.size() + 1);
return static_cast<GLint>(maxLength);
}
case GL_BUFFER_VARIABLE: {
SizeT maxLength = 0;
for (const auto& var : cache.bufferVariables) maxLength = std::max(maxLength, var.name.size() + 1);
return static_cast<GLint>(maxLength);
}
case GL_UNIFORM_BLOCK:
return program.GetActiveUniformBlocksMaxNameLength() + 1;
case GL_UNIFORM:
return program.GetUniformMaxLength() + 1;
case GL_PROGRAM_INPUT:
return program.GetActiveAttributesMaxLength() + 1;
case GL_PROGRAM_OUTPUT: {
SizeT maxLength = 0;
const Int activeCount = program.GetActiveFragmentOutputCount();
for (Int index = 0; index < activeCount; ++index) {
maxLength = std::max(maxLength, program.GetActiveFragmentOutputName(index).size() + 1);
}
return static_cast<GLint>(maxLength);
}
default:
return 0;
}
}
} // namespace
void ClearProgramResourceCaches() {
@@ -395,11 +296,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
GLuint GetShaderStorageBlockIndex(const MG_State::GLState::ProgramObject& program, const String& name) {
auto& cache = GetProgramResourceCache(program);
const auto it = std::find_if(cache.storageBlocks.begin(), cache.storageBlocks.end(),
[&](const StorageBlockResource& block) { return block.name == name; });
return it == cache.storageBlocks.end()
? GL_INVALID_INDEX
: static_cast<GLuint>(std::distance(cache.storageBlocks.begin(), it));
auto find = [&cache](const String& key) {
return std::find_if(cache.storageBlocks.begin(), cache.storageBlocks.end(),
[&](const StorageBlockResource& block) { return block.name == key; });
};
auto it = find(name);
if (it == cache.storageBlocks.end()) {
// Cache names are normalized (NormalizeDescriptorName drops the array suffix), so
// an arrayed block that GL enumerates per element - "B[0]", "B[1]" - is one entry
// here, spelled "B". Retry against the bare name before giving up.
const auto bracket = name.rfind('[');
if (bracket == String::npos || name.empty() || name.back() != ']') return GL_INVALID_INDEX;
it = find(name.substr(0, bracket));
if (it == cache.storageBlocks.end()) return GL_INVALID_INDEX;
}
return static_cast<GLuint>(std::distance(cache.storageBlocks.begin(), it));
}
GLuint GetShaderStorageBlockBinding(const MG_State::GLState::ProgramObject& program, GLuint blockIndex) {
@@ -874,357 +785,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params) {
if (!params) return;
void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding) {
auto* programObject = TryGetDirectVulkanProgram(program);
if (!programObject) return;
auto& cache = GetProgramResourceCache(*programObject);
switch (pname) {
case GL_ACTIVE_RESOURCES:
*params = GetProgramResourceActiveCount(*programObject, programInterface, cache);
return;
case GL_MAX_NAME_LENGTH:
*params = GetProgramResourceMaxNameLength(*programObject, programInterface, cache);
return;
case GL_MAX_NUM_ACTIVE_VARIABLES:
if (programInterface == GL_SHADER_STORAGE_BLOCK) {
SizeT maxCount = 0;
for (const auto& block : cache.storageBlocks) {
maxCount = std::max(maxCount, block.activeVariables.size());
}
*params = static_cast<GLint>(maxCount);
} else if (programInterface == GL_UNIFORM_BLOCK) {
GLint maxCount = 0;
const Int activeBlocks = programObject->GetActiveUniformBlocksCount();
for (Int index = 0; index < activeBlocks; ++index) {
maxCount = std::max(maxCount, programObject->GetUniformBlockActiveUniformCount(index));
}
*params = maxCount;
} else {
*params = 0;
}
return;
default:
*params = 0;
return;
}
}
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name) {
if (!name) return GL_INVALID_INDEX;
auto* programObject = TryGetDirectVulkanProgram(program);
if (!programObject) return GL_INVALID_INDEX;
auto& cache = GetProgramResourceCache(*programObject);
const String resourceName = name;
if (programInterface == GL_SHADER_STORAGE_BLOCK) {
return GetShaderStorageBlockIndex(*programObject, name);
}
if (programInterface == GL_BUFFER_VARIABLE) {
const auto it = std::find_if(cache.bufferVariables.begin(), cache.bufferVariables.end(),
[&](const BufferVariableResource& var) { return var.name == resourceName; });
return it == cache.bufferVariables.end()
? GL_INVALID_INDEX
: static_cast<GLuint>(std::distance(cache.bufferVariables.begin(), it));
}
if (programInterface == GL_UNIFORM_BLOCK) {
return programObject->GetUniformBlockIndex(name);
}
if (programInterface == GL_UNIFORM) {
const Int activeUniformIndex = programObject->GetActiveUniformIndex(resourceName);
return activeUniformIndex >= 0 ? static_cast<GLuint>(activeUniformIndex) : GL_INVALID_INDEX;
}
if (programInterface == GL_PROGRAM_INPUT) {
return FindProgramInputIndex(*programObject, resourceName);
}
if (programInterface == GL_PROGRAM_OUTPUT) {
return FindProgramOutputIndex(*programObject, resourceName);
}
return GL_INVALID_INDEX;
}
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize,
GLsizei* length, GLchar* name) {
auto* programObject = TryGetDirectVulkanProgram(program);
if (!programObject) return;
auto& cache = GetProgramResourceCache(*programObject);
if (programInterface == GL_SHADER_STORAGE_BLOCK && index < cache.storageBlocks.size()) {
CopyResourceName(cache.storageBlocks[index].name, bufSize, length, name);
return;
}
if (programInterface == GL_BUFFER_VARIABLE && index < cache.bufferVariables.size()) {
CopyResourceName(cache.bufferVariables[index].name, bufSize, length, name);
return;
}
if (programInterface == GL_UNIFORM_BLOCK && programObject->IsActiveUniformBlock(index)) {
CopyResourceName(programObject->GetUniformBlockName(index), bufSize, length, name);
return;
}
if (programInterface == GL_UNIFORM && index < programObject->GetUniformCount()) {
CopyResourceName(programObject->GetActiveUniformName(index), bufSize, length, name);
return;
}
if (programInterface == GL_PROGRAM_INPUT && index < static_cast<GLuint>(programObject->GetActiveAttributesCount())) {
CopyResourceName(programObject->GetActiveAttribName(index), bufSize, length, name);
return;
}
if (programInterface == GL_PROGRAM_OUTPUT &&
index < static_cast<GLuint>(programObject->GetActiveFragmentOutputCount())) {
CopyResourceName(programObject->GetActiveFragmentOutputName(index), bufSize, length, name);
return;
}
if (length) *length = 0;
if (name && bufSize > 0) name[0] = '\0';
}
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
auto* programObject = TryGetDirectVulkanProgram(program);
if (!programObject || !props || !params || bufSize <= 0) return;
auto& cache = GetProgramResourceCache(*programObject);
GLsizei written = 0;
auto writeValue = [&](GLint value) {
if (written < bufSize) {
params[written++] = value;
}
};
for (GLsizei propIndex = 0; propIndex < propCount; ++propIndex) {
const GLenum prop = props[propIndex];
if (programInterface == GL_SHADER_STORAGE_BLOCK && index < cache.storageBlocks.size()) {
const auto& block = cache.storageBlocks[index];
switch (prop) {
case GL_NAME_LENGTH:
writeValue(static_cast<GLint>(block.name.size() + 1));
break;
case GL_BUFFER_BINDING:
writeValue(static_cast<GLint>(block.binding));
break;
case GL_BUFFER_DATA_SIZE:
writeValue(block.dataSize);
break;
case GL_NUM_ACTIVE_VARIABLES:
writeValue(static_cast<GLint>(block.activeVariables.size()));
break;
case GL_ACTIVE_VARIABLES:
for (const auto variable : block.activeVariables) writeValue(static_cast<GLint>(variable));
break;
default:
writeValue(0);
break;
}
} else if (programInterface == GL_BUFFER_VARIABLE && index < cache.bufferVariables.size()) {
const auto& var = cache.bufferVariables[index];
switch (prop) {
case GL_NAME_LENGTH:
writeValue(static_cast<GLint>(var.name.size() + 1));
break;
case GL_TYPE:
writeValue(GL_FLOAT);
break;
case GL_ARRAY_SIZE:
writeValue(1);
break;
case GL_OFFSET:
writeValue(var.offset);
break;
case GL_BLOCK_INDEX:
writeValue(static_cast<GLint>(var.blockIndex));
break;
case GL_ARRAY_STRIDE:
case GL_MATRIX_STRIDE:
case GL_TOP_LEVEL_ARRAY_SIZE:
case GL_TOP_LEVEL_ARRAY_STRIDE:
case GL_IS_ROW_MAJOR:
writeValue(0);
break;
default:
writeValue(0);
break;
}
} else if (programInterface == GL_UNIFORM_BLOCK &&
programObject->IsActiveUniformBlock(index)) {
const auto activeVariables = GetUniformBlockActiveVariables(*programObject, index);
switch (prop) {
case GL_NAME_LENGTH:
writeValue(static_cast<GLint>(programObject->GetUniformBlockName(index).size() + 1));
break;
case GL_BUFFER_BINDING:
writeValue(static_cast<GLint>(programObject->GetUniformBlockBinding(index)));
break;
case GL_BUFFER_DATA_SIZE:
writeValue(static_cast<GLint>(programObject->GetUBOSizeAt(index)));
break;
case GL_NUM_ACTIVE_VARIABLES:
writeValue(static_cast<GLint>(activeVariables.size()));
break;
case GL_ACTIVE_VARIABLES:
for (const GLuint variableIndex : activeVariables) {
writeValue(static_cast<GLint>(variableIndex));
}
break;
case GL_REFERENCED_BY_VERTEX_SHADER:
writeValue(programObject->IsUniformBlockReferencedByStage(index, EShLangVertex) ? GL_TRUE
: GL_FALSE);
break;
case GL_REFERENCED_BY_FRAGMENT_SHADER:
writeValue(programObject->IsUniformBlockReferencedByStage(index, EShLangFragment) ? GL_TRUE
: GL_FALSE);
break;
case GL_REFERENCED_BY_COMPUTE_SHADER:
writeValue(programObject->IsUniformBlockReferencedByStage(index, EShLangCompute) ? GL_TRUE
: GL_FALSE);
break;
case GL_REFERENCED_BY_GEOMETRY_SHADER:
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
writeValue(GL_FALSE);
break;
default:
writeValue(0);
break;
}
} else if (programInterface == GL_UNIFORM && index < programObject->GetUniformCount()) {
const auto& uniformName = programObject->GetActiveUniformName(index);
const GLint location = programObject->GetUniformLocation(uniformName);
switch (prop) {
case GL_NAME_LENGTH:
writeValue(static_cast<GLint>(uniformName.size() + 1));
break;
case GL_TYPE:
writeValue(static_cast<GLint>(programObject->GetActiveUniformType(index)));
break;
case GL_ARRAY_SIZE:
writeValue(programObject->GetActiveUniformArraySize(index));
break;
case GL_BLOCK_INDEX:
writeValue(programObject->GetActiveUniformBlockIndex(index));
break;
case GL_LOCATION:
writeValue(location);
break;
case GL_OFFSET:
writeValue(location >= 0 && programObject->IsValidUniformLocation(location)
? static_cast<GLint>(programObject->GetUniformOffset(location))
: 0);
break;
case GL_ARRAY_STRIDE:
case GL_MATRIX_STRIDE:
case GL_IS_ROW_MAJOR:
case GL_TOP_LEVEL_ARRAY_SIZE:
case GL_TOP_LEVEL_ARRAY_STRIDE:
case GL_REFERENCED_BY_VERTEX_SHADER:
case GL_REFERENCED_BY_FRAGMENT_SHADER:
case GL_REFERENCED_BY_COMPUTE_SHADER:
case GL_REFERENCED_BY_GEOMETRY_SHADER:
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
writeValue(0);
break;
default:
writeValue(0);
break;
}
} else if (programInterface == GL_PROGRAM_INPUT &&
index < static_cast<GLuint>(programObject->GetActiveAttributesCount())) {
const auto& resourceName = programObject->GetActiveAttribName(index);
switch (prop) {
case GL_NAME_LENGTH:
writeValue(static_cast<GLint>(resourceName.size() + 1));
break;
case GL_TYPE:
writeValue(static_cast<GLint>(programObject->GetActiveAttribType(index)));
break;
case GL_ARRAY_SIZE:
writeValue(programObject->GetActiveAttribArraySize(index));
break;
case GL_LOCATION:
writeValue(programObject->GetAttributeLocation(resourceName));
break;
case GL_REFERENCED_BY_VERTEX_SHADER:
writeValue(GL_TRUE);
break;
case GL_REFERENCED_BY_FRAGMENT_SHADER:
case GL_REFERENCED_BY_COMPUTE_SHADER:
case GL_REFERENCED_BY_GEOMETRY_SHADER:
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
case GL_IS_PER_PATCH:
case GL_LOCATION_INDEX:
writeValue(0);
break;
default:
writeValue(0);
break;
}
} else if (programInterface == GL_PROGRAM_OUTPUT &&
index < static_cast<GLuint>(programObject->GetActiveFragmentOutputCount())) {
const auto& resourceName = programObject->GetActiveFragmentOutputName(index);
switch (prop) {
case GL_NAME_LENGTH:
writeValue(static_cast<GLint>(resourceName.size() + 1));
break;
case GL_TYPE:
writeValue(static_cast<GLint>(programObject->GetFragmentOutputType(index)));
break;
case GL_ARRAY_SIZE:
writeValue(programObject->GetActiveFragmentOutputArraySize(index));
break;
case GL_LOCATION:
writeValue(programObject->GetFragmentOutputLocation(index));
break;
case GL_LOCATION_INDEX:
writeValue(0);
break;
case GL_REFERENCED_BY_FRAGMENT_SHADER:
writeValue(GL_TRUE);
break;
case GL_REFERENCED_BY_VERTEX_SHADER:
case GL_REFERENCED_BY_COMPUTE_SHADER:
case GL_REFERENCED_BY_GEOMETRY_SHADER:
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
case GL_IS_PER_PATCH:
writeValue(0);
break;
default:
writeValue(0);
break;
}
} else {
writeValue(0);
}
}
if (length) *length = written;
}
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name) {
auto* programObject = TryGetDirectVulkanProgram(program);
if (!programObject || !name) return -1;
if (programInterface == GL_UNIFORM) {
return programObject->GetUniformLocation(name);
}
if (programInterface == GL_PROGRAM_INPUT) {
return programObject->GetAttributeLocation(name);
}
if (programInterface == GL_PROGRAM_OUTPUT) {
return GetProgramOutputLocation(*programObject, name);
}
return -1;
}
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name) {
auto* programObject = TryGetDirectVulkanProgram(program);
if (!programObject || !name) return -1;
if (programInterface == GL_PROGRAM_OUTPUT) {
return GetProgramOutputLocation(*programObject, name) >= 0 ? 0 : -1;
}
return -1;
}
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding) {
auto* programObject = TryGetDirectVulkanProgram(program);
if (!programObject) return;
auto& cache = GetProgramResourceCache(*programObject);
if (!programObject || storageBlockName == nullptr) return;
const Int maxBindings = pActiveBackendObject
? pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings
: 0;
@@ -1234,13 +797,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MakeUnique<GenericErrorInfo>("DirectVulkan", __func__, "Shader storage binding is out of range."));
return;
}
if (storageBlockIndex >= cache.storageBlocks.size()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("DirectVulkan", __func__, "Shader storage block index is not active."));
return;
}
cache.storageBlocks[storageBlockIndex].binding = storageBlockBinding;
// The frontend already validated that the name denotes an active block, and has
// already recorded the new binding on the program - which is what reseeds this cache
// whenever it is rebuilt. Writing the entry here as well keeps an ALREADY-BUILT cache
// (the common case: the very next draw reads it) from having to be thrown away.
auto& cache = GetProgramResourceCache(*programObject);
const GLuint blockIndex = GetShaderStorageBlockIndex(*programObject, storageBlockName);
if (blockIndex == GL_INVALID_INDEX) return;
cache.storageBlocks[blockIndex].binding = storageBlockBinding;
}
void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) {
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ReadPixels called with null VulkanRenderer");
@@ -1393,31 +957,66 @@ namespace MobileGL::MG_Backend::DirectVulkan {
pVulkanRenderer->MultiDrawArrays(payload);
}
// Shared body of glMultiDrawElements (basevertex == nullptr) and
// glMultiDrawElementsBaseVertex: identical calls except for the per-draw
// vertex offset, which VkMultiDrawIndexedInfoEXT / VkDrawIndexedIndirectCommand /
// vkCmdDrawIndexed all carry natively.
static void MultiDrawElementsImpl(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
GLsizei drawcount, const GLint* basevertex) {
if (drawcount <= 0) {
return;
}
MultiDrawIndexedCmd payload{};
payload.mode = mode;
payload.indexBufferView.indexType = type;
// Loop-invariant: the index type is fixed for the whole multi-draw, so resolve
// its byte size once instead of twice per sub-draw (a cross-TU switch that
// showed up in per-frame profiles of sodium-style 132x32 multi-draws). Index
// sizes are 1/2/4, so the per-sub-draw offset division below reduces to a
// shift - the hardware divide was the hottest instruction of this loop.
const SizeT indexSize = MG_Util::GetGLTypeSize(type);
if (indexSize == 0) {
MGLOG_E("MultiDrawElements skipped: unsupported index type 0x%x", type);
return;
}
const Uint32 indexSizeShift = static_cast<Uint32>(std::countr_zero(indexSize));
// TODO: allocate draw cmd buf elsewhere
static Vector<DrawIndexedCmdParam> params;
params.clear();
params.resize(drawcount);
for (GLsizei i = 0; i < drawcount; ++i) {
if (count[i] == 0) {
continue;
}
// TODO: this index view needs a redesign, now there's a lotta redundant uploads
payload.indexBufferView.indexByteOffset = 0;
payload.indexBufferView.indexByteSize =
std::max(reinterpret_cast<SizeT>(indices[i]) + count[i] * indexSize,
payload.indexBufferView.indexByteSize);
auto& param = params[i];
param.indexCount = count[i];
param.instanceCount = 1;
param.firstIndex = reinterpret_cast<SizeT>(indices[i]) >> indexSizeShift;
param.vertexOffset = basevertex != nullptr ? basevertex[i] : 0;
param.firstInstance = 0;
}
payload.drawCount = drawcount;
payload.pParams = params.data();
pVulkanRenderer->MultiDrawElements(payload);
}
void MultiDrawElements(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
GLsizei drawcount) {
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElements called with null VulkanRenderer");
MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElements called with null GL context");
// Vector<DrawElementCmd> cmds;
// cmds.reserve(static_cast<SizeT>(drawcount));
// for (GLsizei i = 0; i < drawcount; ++i) {
// if (count[i] == 0) {
// continue;
// }
//
// DrawElementCmd payload{};
// payload.mode = mode;
// payload.firstVertex = 0;
// payload.indexCount = count[i];
// payload.indexType = type;
// payload.indexByteOffset = reinterpret_cast<SizeT>(indices[i]);
// cmds.push_back(payload);
// }
//
// if (cmds.empty()) {
// return;
// }
// pVulkanRenderer->MultiDrawElements(cmds);
MultiDrawElementsImpl(mode, count, type, indices, drawcount, nullptr);
}
void DrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, const GLvoid* indices, GLint basevertex) {
@@ -1445,52 +1044,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
void MultiDrawElementsBaseVertex(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
GLsizei drawcount, const GLint* basevertex) {
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElements called with null VulkanRenderer");
MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElements called with null GL context");
MultiDrawIndexedCmd payload{};
payload.mode = mode;
payload.indexBufferView.indexType = type;
// Loop-invariant: the index type is fixed for the whole multi-draw, so resolve
// its byte size once instead of twice per sub-draw (a cross-TU switch that
// showed up in per-frame profiles of sodium-style 132x32 multi-draws). Index
// sizes are 1/2/4, so the per-sub-draw offset division below reduces to a
// shift - the hardware divide was the hottest instruction of this loop.
const SizeT indexSize = MG_Util::GetGLTypeSize(type);
if (indexSize == 0) {
MGLOG_E("MultiDrawElementsBaseVertex skipped: unsupported index type 0x%x", type);
return;
}
const Uint32 indexSizeShift = static_cast<Uint32>(std::countr_zero(indexSize));
// TODO: allocate draw cmd buf elsewhere
static Vector<DrawIndexedCmdParam> params;
params.clear();
params.resize(drawcount);
for (GLsizei i = 0; i < drawcount; ++i) {
if (count[i] == 0) {
continue;
}
// TODO: this index view needs a redesign, now there's a lotta redundant uploads
payload.indexBufferView.indexByteOffset = 0;
payload.indexBufferView.indexByteSize =
std::max(reinterpret_cast<SizeT>(indices[i]) + count[i] * indexSize,
payload.indexBufferView.indexByteSize);
auto& param = params[i];
param.indexCount = count[i];
param.instanceCount = 1;
param.firstIndex = reinterpret_cast<SizeT>(indices[i]) >> indexSizeShift;
param.vertexOffset = basevertex[i];
param.firstInstance = 0;
}
payload.drawCount = drawcount;
payload.pParams = params.data();
pVulkanRenderer->MultiDrawElements(payload);
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElementsBaseVertex called with null VulkanRenderer");
MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElementsBaseVertex called with null GL context");
MultiDrawElementsImpl(mode, count, type, indices, drawcount, basevertex);
}
void BlitFramebuffer(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1,
@@ -97,15 +97,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
void GetIntegeri_v(GLenum target, GLuint index, GLint* data);
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
void GetProgramiv(GLuint program, GLenum pname, GLint* params);
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params);
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name);
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize,
GLsizei* length, GLchar* name);
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params);
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name);
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name);
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding);
void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding);
void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels);
void GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels);
void GetTextureImage(const SharedPtr<MG_State::GLState::ITextureObject>& texture, TextureUploadTarget uploadTarget,
@@ -116,6 +116,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_frameCount = frameCount;
m_maxBindings = maxBindings;
m_samplerResolveMemo.assign(m_maxBindings, SamplerResolveMemo{});
// Every entry is freshly constructed (all-invalid), so nothing needs sweeping until
// a resolve writes one.
m_samplerResolveMemoHighWater = 0;
m_setsPerFrame = setsPerFrame;
m_peakDescriptorSetsObserved = 0;
m_textureManager = textureManager;
@@ -174,6 +177,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_minDynamicOffsetAlignment = 1;
m_frameCount = 0;
m_maxBindings = 0;
m_samplerResolveMemo.clear();
m_samplerResolveMemoHighWater = 0;
m_setsPerFrame = 0;
m_peakDescriptorSetsObserved = 0;
m_textureManager = nullptr;
@@ -210,10 +215,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_fastRebindMemo.valid = false;
m_lastBindValid = false;
// Re-fingerprint the bound sampler set fresh this frame so any GL object address
// reuse cannot outlive a single frame (see SamplerResolveMemo).
for (auto& memo : m_samplerResolveMemo) {
memo.valid = false;
memo.infoValid = false;
// reuse cannot outlive a single frame (see SamplerResolveMemo). Only the entries a
// resolve has actually written can be valid, so the high-water mark bounds the
// sweep - the vector itself is sized to the device's binding cap (256 here), which
// is ~30x more entries than any program declares.
const Uint32 touchedBindings =
std::min<Uint32>(m_samplerResolveMemoHighWater, static_cast<Uint32>(m_samplerResolveMemo.size()));
for (Uint32 binding = 0; binding < touchedBindings; ++binding) {
m_samplerResolveMemo[binding].valid = false;
m_samplerResolveMemo[binding].infoValid = false;
}
}
@@ -379,6 +389,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
viewFormatMemo->viewFormatDomain = numericDomain;
viewFormatMemo->viewFormat = sampledViewFormat;
viewFormatMemo->viewFormatValid = true;
NoteSamplerResolveMemoTouched(binding);
}
}
if (sampledViewFormat == VK_FORMAT_UNDEFINED) {
@@ -434,6 +445,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
memo.viewLevelCount = viewLevelCount;
memo.sampler = resolvedSampler;
memo.valid = true;
NoteSamplerResolveMemoTouched(binding);
}
} else {
resolvedSampler = m_samplerManager->GetOrCreateSampler(*samplerToUse, *texture, forceNearestFiltering,
@@ -450,6 +462,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (binding < m_samplerResolveMemo.size()) {
m_samplerResolveMemo[binding].info = outImageInfo;
m_samplerResolveMemo[binding].infoValid = true;
NoteSamplerResolveMemoTouched(binding);
}
return true;
}
@@ -345,5 +345,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool infoValid = false;
};
mutable Vector<SamplerResolveMemo> m_samplerResolveMemo;
// Exclusive upper bound on the entries of m_samplerResolveMemo that any resolve
// has ever written. The vector is sized to the DEVICE binding cap (256 on desktop
// NVIDIA), but a program declares 1-8 bindings, so the per-frame reset below was
// memsetting ~22 KB of never-touched entries every frame - a measurable slice of
// the per-frame fixed cost on draw-light frames. Every site that can turn any of
// an entry's *Valid flags on raises this mark first, so entries at or above it are
// provably still in their constructed (all-invalid) state and clearing them is a
// no-op. Never lowered except by Initialize/Shutdown, which rebuild the vector.
mutable Uint32 m_samplerResolveMemoHighWater = 0;
void NoteSamplerResolveMemoTouched(Uint32 binding) const {
if (binding >= m_samplerResolveMemoHighWater) {
m_samplerResolveMemoHighWater = binding + 1;
}
}
};
} // namespace MobileGL::MG_Backend::DirectVulkan
@@ -33,14 +33,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.IsBgra, sizeof(attr.IsBgra)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Divisor, sizeof(attr.Divisor)));
// The buffer's heap address is an identity component of the key: a freed
// buffer's reused address can alias an old cache entry, but only under a
// byte-identical attribute layout - and the entry payload is a pure function
// of the hashed inputs, with the draw path re-resolving bindingBufferKeys
// against the live VAO attribute pointers, so an aliased hit returns exactly
// what a rebuild would. Address drift only grows the map; the OnFrameBoundary
// aging sweep bounds that.
const SizeT bufferKey = reinterpret_cast<SizeT>(attr.Buffer.get());
// The bound buffer's IDENTITY is a component of the key, and it has to be the
// buffer's never-reused lifetime id - NOT its heap address, which this used to
// hash. An address is recycled by the allocator, so a deleted-and-recreated
// buffer reproduces it; combined with a byte-identical attribute layout that
// reproduces the WHOLE content hash, and the hash is what
// TryBindResolvedVertexBindings accepts as proof that a memoised binding still
// reads the buffer it was resolved from. It did not: a destroyed buffer's GPU
// slice was bound for its successor's draw, which is how a transform-feedback
// capture came back holding a dead VAO's vertex data (0,0,0,1 - the previous
// test's positions) instead of its own.
// Zero for client memory (no buffer), which is a distinct identity of its own.
const Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0;
XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey)));
}
@@ -28,11 +28,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
struct BackendVertexInputState {
HashType hash = 0;
// Hash of the resolved Vulkan vertex layout only (bindings, attributes,
// unsupported mask) - NO buffer identities. `hash` mixes buffer heap
// addresses so per-chunk VBOs mint a fresh identity per buffer; keying
// pipelines on that minted one VkPipeline per chunk section for an
// identical layout, defeating pipeline reuse and the per-draw memo.
// Pipelines depend only on the layout, so they key on this instead.
// unsupported mask) - NO buffer identities. `hash` mixes each bound
// buffer's never-reused LIFETIME ID, so per-chunk VBOs mint a fresh
// identity per buffer; keying pipelines on that minted one VkPipeline per
// chunk section for an identical layout, defeating pipeline reuse and the
// per-draw memo. Pipelines depend only on the layout, so they key on this
// instead.
HashType layoutHash = 0;
// Frame boundary of the last cache hit; entries idle past the
// OnFrameBoundary retirement age are evicted (CPU heap only).
@@ -86,8 +87,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const MG_State::GLState::VertexArrayObject& vao, HashType hash);
const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao);
// Frame boundary hook: ages the cache and evicts entries not hit for many
// frames. The key mixes buffer heap addresses, so buffer/VAO churn keeps
// minting fresh keys; without eviction the map grows for the whole session.
// frames. The key mixes each bound buffer's never-reused lifetime id, so
// buffer/VAO churn keeps minting fresh keys - and does so by construction,
// not by luck: a recreated buffer can no longer land back on its dead
// predecessor's key. Without eviction the map grows for the whole session.
// Entries hold no Vulkan handles (pipeline creation copies the descriptions)
// and the draw path's entry reference never spans a frame boundary, so
// eviction here needs no GPU-idle proof. Self-gated: one counter bump and
@@ -273,12 +273,76 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// re-derive the exact values already applied on this command buffer. The
// remaining input, the swapchain pre-transform, cannot change mid-recording
// (a swapchain recreate retires the command buffer, and recording begin resets
// this whole shadow).
// this whole shadow); the value key below pins it anyway.
Bool dynamicTailValid = false;
Uint dynamicTailParamsVersion = 0;
Int dynamicTailExtentX = 0;
Int dynamicTailExtentY = 0;
Bool dynamicTailIsDefaultFbo = false;
// VALUE key over the tail's inputs, as a second-level gate behind the version.
// The parameters version is ONE counter for all of RenderState, so anything that
// is not tail input - a GL_BLEND toggle, a glBlendFuncSeparate, a glColorMask -
// moves it and forced a full tail re-run. Blaze3D toggles blend around every
// batch, so that was a per-draw re-derivation of six dynamic states that could
// not have changed. Equal key => the six Apply* below would each re-derive the
// value their shadow already holds and emit nothing, so the tail is skippable.
//
// Complete input inventory of ApplyDynamicDrawStateTail, one line per reader
// (each accessor it replaces is a verified plain field read of the same
// RenderStateParameters field - RenderState.cpp):
// ApplyGLViewportState : Viewport, DepthRange, + extent/isDefaultFbo/preTransform
// ApplyBlendConstants : BlendColor
// ApplyPolygonOffsetState : PolygonOffsetUnits, PolygonOffsetFactor
// ApplyLineWidthState : LineWidth (see the caveat below)
// ApplyStencilState : StencilStates[0..1].{ValueMask, WriteMask, Ref}
// scissor rect : ScissorTestEnabled, ScissorBox,
// + extent/isDefaultFbo/preTransform
// Caveat, unchanged from the version-only gate: ApplyLineWidthState also clamps
// to the ACTIVE BACKEND OBJECT's aliased line-width range. Those are device
// limits queried once at backend init and constant for the renderer's lifetime,
// so they are not part of the key (the version gate never covered them either).
struct DynamicTailKey {
Int viewport[4] = {0, 0, 0, 0};
Float depthRange[2] = {0.0f, 0.0f};
Float blendColor[4] = {0.0f, 0.0f, 0.0f, 0.0f};
Float polygonOffsetFactor = 0.0f;
Float polygonOffsetUnits = 0.0f;
Float lineWidth = 0.0f;
Uint32 stencilValueMask[2] = {0, 0};
Uint32 stencilWriteMask[2] = {0, 0};
Int stencilRef[2] = {0, 0};
Int scissorBox[4] = {0, 0, 0, 0};
Int extentX = 0;
Int extentY = 0;
Uint32 preTransform = 0;
Bool scissorEnabled = false;
Bool isDefaultFbo = false;
Bool operator==(const DynamicTailKey& other) const {
// NaN in any float input makes this false, which only costs a redundant
// tail run - never a skipped one.
for (Uint32 i = 0; i < 4; ++i) {
if (viewport[i] != other.viewport[i] || blendColor[i] != other.blendColor[i] ||
scissorBox[i] != other.scissorBox[i]) {
return false;
}
}
for (Uint32 i = 0; i < 2; ++i) {
if (depthRange[i] != other.depthRange[i] ||
stencilValueMask[i] != other.stencilValueMask[i] ||
stencilWriteMask[i] != other.stencilWriteMask[i] ||
stencilRef[i] != other.stencilRef[i]) {
return false;
}
}
return polygonOffsetFactor == other.polygonOffsetFactor &&
polygonOffsetUnits == other.polygonOffsetUnits && lineWidth == other.lineWidth &&
extentX == other.extentX && extentY == other.extentY &&
preTransform == other.preTransform && scissorEnabled == other.scissorEnabled &&
isDefaultFbo == other.isDefaultFbo;
}
};
DynamicTailKey dynamicTailKey{};
};
static DynamicStateShadow g_dynamicStateShadow;
@@ -2924,6 +2988,8 @@ void main() {
m_device = VK_NULL_HANDLE;
}
s_vkCmdDrawIndexedIndirectCount = nullptr;
s_vkCmdDrawMultiEXT = nullptr;
s_vkCmdDrawMultiIndexedEXT = nullptr;
if (m_instance != VK_NULL_HANDLE && m_surface != VK_NULL_HANDLE) {
vkDestroySurfaceKHR(m_instance, m_surface, nullptr);
@@ -3135,12 +3201,17 @@ void main() {
// carry the most entropy of a multiply.
const Uint64 mixed = static_cast<Uint64>(reinterpret_cast<SizeT>(vao) >> 4) * 0x9E3779B97F4A7C15ull;
const Uint32 index = static_cast<Uint32>(mixed >> 32) & (kVaoDrawMemoSlotCount - 1);
// The address still picks the slot (it is what the caller has in hand), but it is
// the lifetime id that decides whether the slot is THIS object's: an address on
// its own is recycled, and a slot matched on a recycled address hands the new VAO
// the dead one's resolved bindings.
const Uint64 lifetimeId = vao->GetLifetimeId();
VaoDrawMemo& first = m_vaoDrawMemoTable[index];
if (first.vaoKey == vao) {
if (first.vaoKey == vao && first.vaoLifetimeId == lifetimeId) {
return &first;
}
VaoDrawMemo& second = m_vaoDrawMemoTable[index ^ 1u];
if (second.vaoKey == vao) {
if (second.vaoKey == vao && second.vaoLifetimeId == lifetimeId) {
return &second;
}
// Miss: recycle a slot. Prefer an empty one; otherwise evict the entry whose
@@ -3151,6 +3222,7 @@ void main() {
victim = &second;
}
victim->vaoKey = vao;
victim->vaoLifetimeId = lifetimeId;
victim->contentHash = 0;
victim->layoutFactsValid = false;
// Unmatchable until a resolve completes (same rule as before: a bailed-out
@@ -4409,9 +4481,10 @@ void main() {
// vertex-input hash (VAO layout), render-pass hash (render targets + the draw-buffer/format
// driven blend & write-mask gating), and the pipeline-state value hash (all fixed-function state).
// Reset per-frame and on pipeline destruction so a memoized handle can never dangle.
// The identity hash mixes buffer heap addresses (per-chunk VBOs mint a new
// one per buffer); the memo and the pipeline payload key on the resolved
// LAYOUT hash instead, so draws over identical layouts share one pipeline.
// The identity hash mixes each bound buffer's never-reused lifetime id
// (per-chunk VBOs mint a new one per buffer); the memo and the pipeline
// payload key on the resolved LAYOUT hash instead, so draws over identical
// layouts share one pipeline.
// The one-arg fetch rides the VAO's state-pointer memo (no hash, no map).
auto& vis = m_vertexInputStateFactory->GetOrCreateVertexInputState(vao);
const Uint64 vertexLayoutHash = vis.layoutHash;
@@ -5049,17 +5122,60 @@ void main() {
shadow.dynamicTailIsDefaultFbo == isDefaultFbo) {
return;
}
ApplyGLViewportState(frame.commandBuffer, extent, m_swapchainObject.GetPreTransform(), isDefaultFbo);
const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform();
// Second-level VALUE gate: the version moved, but RenderState's version counts
// every parameter, most of which this tail never reads. Build the key over
// exactly the tail's inputs (inventory in DynamicTailKey) out of one bulk
// parameters fetch and compare; an equal key means every Apply* below would
// re-derive the value its shadow already holds.
DynamicStateShadow::DynamicTailKey key;
{
const RenderStateParameters& p = MG_State::pGLContext->GetRenderStateParameters();
key.viewport[0] = p.Viewport.x();
key.viewport[1] = p.Viewport.y();
key.viewport[2] = p.Viewport.z();
key.viewport[3] = p.Viewport.w();
key.depthRange[0] = p.DepthRange.x();
key.depthRange[1] = p.DepthRange.y();
key.blendColor[0] = p.BlendColor.x();
key.blendColor[1] = p.BlendColor.y();
key.blendColor[2] = p.BlendColor.z();
key.blendColor[3] = p.BlendColor.w();
key.polygonOffsetFactor = p.PolygonOffsetFactor;
key.polygonOffsetUnits = p.PolygonOffsetUnits;
key.lineWidth = p.LineWidth;
// StencilStates[0] is Front, [1] is Back (RenderState::GetStencilFaceIndex),
// the same order ApplyStencilState reads them in.
for (Uint32 face = 0; face < 2; ++face) {
key.stencilValueMask[face] = p.StencilStates[face].ValueMask;
key.stencilWriteMask[face] = p.StencilStates[face].WriteMask;
key.stencilRef[face] = p.StencilStates[face].Ref;
}
key.scissorEnabled = p.ScissorTestEnabled;
key.scissorBox[0] = p.ScissorBox.x();
key.scissorBox[1] = p.ScissorBox.y();
key.scissorBox[2] = p.ScissorBox.z();
key.scissorBox[3] = p.ScissorBox.w();
key.extentX = extent.x();
key.extentY = extent.y();
key.preTransform = static_cast<Uint32>(preTransform);
key.isDefaultFbo = isDefaultFbo;
}
if (shadow.dynamicTailValid && shadow.dynamicTailKey == key) {
// Re-arm the cheap version gate so an unchanged-parameters run of draws after
// this one costs the four-integer compare again.
shadow.dynamicTailParamsVersion = paramsVersion;
return;
}
ApplyGLViewportState(frame.commandBuffer, extent, preTransform, isDefaultFbo);
ApplyBlendConstants(frame.commandBuffer);
ApplyPolygonOffsetState(frame.commandBuffer);
ApplyLineWidthState(frame.commandBuffer);
ApplyStencilState(frame.commandBuffer);
const Bool scissorEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ScissorTest);
VkRect2D scissor{};
if (scissorEnabled) {
const auto& scissorBox = MG_State::pGLContext->GetScissorBox();
scissor = isDefaultFbo
? MakeDefaultFramebufferScissorRect(scissorBox, extent, m_swapchainObject.GetPreTransform())
if (key.scissorEnabled) {
const IntVec4 scissorBox(key.scissorBox[0], key.scissorBox[1], key.scissorBox[2], key.scissorBox[3]);
scissor = isDefaultFbo ? MakeDefaultFramebufferScissorRect(scissorBox, extent, preTransform)
: MakeClampedScissorRect(scissorBox, extent);
} else {
scissor.offset = {0, 0};
@@ -5071,18 +5187,28 @@ void main() {
shadow.dynamicTailExtentX = extent.x();
shadow.dynamicTailExtentY = extent.y();
shadow.dynamicTailIsDefaultFbo = isDefaultFbo;
shadow.dynamicTailKey = key;
}
Uint32 VulkanRenderer::GetBaseTransformFlagsRaw() {
Uint32 VulkanRenderer::GetBaseTransformFlagsRaw(Bool isDefaultFbo) {
// GetShaderTransformFlags is a function of the pre-transform AND of whether
// the bound draw framebuffer is the default one (the Y-flip/rotation bits
// apply only when presenting). Memo keyed on both; keying on the
// pre-transform alone served an FBO pass's unflipped flags to the following
// default-framebuffer pass and flipped the whole frame.
const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform();
const auto& currentDrawFBO =
// isDefaultFbo is supplied by the caller: every draw-path caller has already
// resolved the bound draw framebuffer (and its default-ness) for its own
// guards, and re-walking the binding slot + the virtual IsDefaultFramebuffer
// per draw showed up in the profile. Callers MUST pass the value derived from
// the SAME draw-framebuffer binding the draw uses - see the assert below.
MOBILEGL_ASSERT(
[&] {
const auto& fbo =
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
const Bool isDefaultFbo = currentDrawFBO != nullptr && currentDrawFBO->IsDefaultFramebuffer();
return isDefaultFbo == (fbo != nullptr && fbo->IsDefaultFramebuffer());
}(),
"GetBaseTransformFlagsRaw: isDefaultFbo does not match the bound draw framebuffer");
const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform();
if (!m_baseTransformFlagsKeyValid || preTransform != m_baseTransformFlagsPreTransform ||
isDefaultFbo != m_baseTransformFlagsIsDefaultFbo) {
m_baseTransformFlagsCache = GetShaderTransformFlags(preTransform).GetRaw();
@@ -5146,7 +5272,8 @@ void main() {
// path, re-resolving descriptors and texture layouts nothing invalidated.
const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();
const Bool vaoMoved =
static_cast<const void*>(&vao) != snap.vao || vao.GetConfigVersion() != snap.vaoConfigVersion;
static_cast<const void*>(&vao) != snap.vao || vao.GetLifetimeId() != snap.vaoLifetimeId ||
vao.GetConfigVersion() != snap.vaoConfigVersion;
const auto& drawFbo =
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
if (static_cast<const void*>(drawFbo.get()) != snap.drawFbo ||
@@ -5173,7 +5300,10 @@ void main() {
return false;
}
}
if (GetBaseTransformFlagsRaw() != snap.baseTransformFlags) {
// The FBO identity+version compare above proved this draw's framebuffer is the
// snapshotting draw's, so its default-ness is the snapshot's too - no second walk
// of the binding slot and no virtual IsDefaultFramebuffer call.
if (GetBaseTransformFlagsRaw(snap.drawFboIsDefault) != snap.baseTransformFlags) {
return false;
}
if (m_textureManager->GetResourceEraseEpoch() != snap.textureEraseEpoch ||
@@ -5212,9 +5342,11 @@ void main() {
// VAO's content-hash memo. The hash memo shares the cache line this compare
// chain already loaded (the config version), and the table slot is compact
// and hot - unlike the VAO's aux-memo words, which start a second cold line
// of every object in a VAO-cycling frame. The facts are pure functions of
// the content hash, so a slot whose contentHash equals the live memoised
// hash serves them for ANY VAO object, recycled addresses included.
// of every object in a VAO-cycling frame. The slot only ever answers for
// THIS object: LookupVaoDrawMemo matches (address, lifetime id), so a slot
// a destroyed VAO left behind at a recycled address misses and the facts
// are re-resolved. The contentHash compare is the second gate on top of
// that identity check, catching a reconfiguration of the same live object.
Uint64 auxMasks = 0;
Bool factsKnown = false;
Uint64 contentHash = 0;
@@ -5393,6 +5525,7 @@ void main() {
snap.renderStateVersion = renderStateVersion;
snap.bindGeneration = bindGeneration;
snap.vao = static_cast<const void*>(&vao);
snap.vaoLifetimeId = vao.GetLifetimeId();
snap.vaoConfigVersion = vao.GetConfigVersion();
snap.vaoLayoutHash = vaoLayoutHash;
snap.pipeline = pipeline;
@@ -5476,8 +5609,9 @@ void main() {
fillSnap->valid = false;
m_setupDrawSnapshotMru = fillIndex;
}
const Bool drawFboIsDefault = drawFbo != nullptr && drawFbo->IsDefaultFramebuffer();
ProgramFactory::CompileOptionFlags transformFlags =
ProgramFactory::CompileOptionFlags(GetBaseTransformFlagsRaw());
ProgramFactory::CompileOptionFlags(GetBaseTransformFlagsRaw(drawFboIsDefault));
// Captured draws take the xfb-decorated program variant.
if (m_transformFeedbackFeatureEnabled && MG_State::pGLContext->IsTransformFeedbackActive() &&
program.GetTransformFeedbackVaryingCount() > 0) {
@@ -5810,13 +5944,14 @@ void main() {
snap.programLifetimeId = program.GetLifetimeId();
snap.programVersion = program.GetBackendStateVersion();
snap.vao = &vao;
snap.vaoLifetimeId = vao.GetLifetimeId();
snap.vaoConfigVersion = vao.GetConfigVersion();
snap.drawFbo = drawFbo.get();
snap.fboVersion = drawFbo->GetObjectVersion();
snap.drawFboIsDefault = drawFbo->IsDefaultFramebuffer();
snap.drawFboIsDefault = drawFboIsDefault;
snap.renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion();
snap.bindGeneration = MG_State::pGLContext->GetTextureBindGeneration();
snap.baseTransformFlags = GetBaseTransformFlagsRaw();
snap.baseTransformFlags = GetBaseTransformFlagsRaw(drawFboIsDefault);
snap.resolvedTransformFlags = transformFlags.GetRaw();
snap.renderPassHash = nowActiveRenderPass->hash;
snap.imageIndex = m_imageIndexAcquired;
@@ -9441,14 +9576,191 @@ void main() {
MOBILEGL_ASSERT(frame.isCommandRecording, "%s: frame recording was not started", __func__);
VkCommandBuffer& commandBuffer = frame.commandBuffer;
EmitMultiDraw(frame.commandBuffer, payload.pParams, payload.drawCount);
}
for (Uint32 idraw = 0; idraw < payload.drawCount; ++idraw) {
vkCmdDraw(commandBuffer,
payload.pParams[idraw].vertexCount,
payload.pParams[idraw].instanceCount,
payload.pParams[idraw].firstVertex,
payload.pParams[idraw].firstInstance);
// The tier-2 indirect batch uploads the param arrays as-is: the leading members of the
// renderer's draw-parameter structs are exactly Vulkan's indirect command layouts, and
// vkCmdDraw(Indexed)Indirect accepts any 4-aligned stride >= the command size, so the
// trailing CPU-side metadata rides along unread instead of forcing a repack.
static_assert(sizeof(DrawIndexedCmdParam) == sizeof(VkDrawIndexedIndirectCommand) &&
offsetof(DrawIndexedCmdParam, indexCount) == offsetof(VkDrawIndexedIndirectCommand, indexCount) &&
offsetof(DrawIndexedCmdParam, instanceCount) ==
offsetof(VkDrawIndexedIndirectCommand, instanceCount) &&
offsetof(DrawIndexedCmdParam, firstIndex) == offsetof(VkDrawIndexedIndirectCommand, firstIndex) &&
offsetof(DrawIndexedCmdParam, vertexOffset) ==
offsetof(VkDrawIndexedIndirectCommand, vertexOffset) &&
offsetof(DrawIndexedCmdParam, firstInstance) ==
offsetof(VkDrawIndexedIndirectCommand, firstInstance),
"DrawIndexedCmdParam must alias VkDrawIndexedIndirectCommand for the tier-2 multi-draw upload");
static_assert(sizeof(DrawCmdParam) % 4 == 0 && sizeof(DrawCmdParam) >= sizeof(VkDrawIndirectCommand) &&
offsetof(DrawCmdParam, vertexCount) == offsetof(VkDrawIndirectCommand, vertexCount) &&
offsetof(DrawCmdParam, instanceCount) == offsetof(VkDrawIndirectCommand, instanceCount) &&
offsetof(DrawCmdParam, firstVertex) == offsetof(VkDrawIndirectCommand, firstVertex) &&
offsetof(DrawCmdParam, firstInstance) == offsetof(VkDrawIndirectCommand, firstInstance),
"DrawCmdParam must lead with VkDrawIndirectCommand for the tier-2 multi-draw upload");
void VulkanRenderer::EmitMultiDraw(VkCommandBuffer commandBuffer, const DrawCmdParam* pParams, Uint32 drawCount) {
if (drawCount == 0) {
return;
}
if (drawCount == 1) {
vkCmdDraw(commandBuffer, pParams[0].vertexCount, pParams[0].instanceCount, pParams[0].firstVertex,
pParams[0].firstInstance);
return;
}
// Tier 1: VK_EXT_multi_draw. vkCmdDrawMultiEXT shares one instanceCount/firstInstance
// across the whole batch, so the batch must be uniform in both (GL's glMultiDrawArrays
// always is: 1/0).
if (m_multiDrawAllowExt) {
Bool uniformInstances = true;
for (Uint32 idraw = 1; idraw < drawCount; ++idraw) {
if (pParams[idraw].instanceCount != pParams[0].instanceCount ||
pParams[idraw].firstInstance != pParams[0].firstInstance) {
uniformInstances = false;
break;
}
}
if (uniformInstances) {
static Vector<VkMultiDrawInfoEXT> infos;
infos.resize(drawCount);
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
infos[idraw].firstVertex = pParams[idraw].firstVertex;
infos[idraw].vertexCount = pParams[idraw].vertexCount;
}
for (Uint32 base = 0; base < drawCount; base += m_maxMultiDrawCount) {
const Uint32 chunk = std::min(drawCount - base, m_maxMultiDrawCount);
s_vkCmdDrawMultiEXT(commandBuffer, chunk, infos.data() + base, pParams[0].instanceCount,
pParams[0].firstInstance, sizeof(VkMultiDrawInfoEXT));
}
return;
}
}
// Tier 2: multiDrawIndirect - one vkCmdDrawIndirect over a transient command array.
// A sub-draw with firstInstance != 0 is illegal in an indirect command without the
// drawIndirectFirstInstance feature; such a batch falls to the unrolled tier.
if (m_multiDrawAllowIndirect) {
Bool firstInstanceLegal = m_drawIndirectFirstInstanceFeatureEnabled;
if (!firstInstanceLegal) {
firstInstanceLegal = true;
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
if (pParams[idraw].firstInstance != 0) {
firstInstanceLegal = false;
break;
}
}
}
const Uint32 maxIndirectCount = m_physicalDevice.properties.limits.maxDrawIndirectCount;
if (firstInstanceLegal && maxIndirectCount > 0) {
BufferSlice commandSlice{};
if (m_bufferManager.UploadTransient(BufferKind::Indirect, m_frameContext.GetCurrentFrameIndex(),
pParams,
static_cast<VkDeviceSize>(drawCount) * sizeof(DrawCmdParam),
sizeof(Uint32), commandSlice)) {
for (Uint32 base = 0; base < drawCount; base += maxIndirectCount) {
const Uint32 chunk = std::min(drawCount - base, maxIndirectCount);
vkCmdDrawIndirect(commandBuffer, commandSlice.buffer,
commandSlice.offset +
static_cast<VkDeviceSize>(base) * sizeof(DrawCmdParam),
chunk, sizeof(DrawCmdParam));
}
return;
}
// Transient arena refused the upload: fall through to the unrolled tier.
}
}
// Tier 3: unrolled loop, byte-identical fallback (and the only tier where a SPIR-V
// DrawIndex consumer sees 0 for every sub-draw instead of the sub-draw index).
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
vkCmdDraw(commandBuffer, pParams[idraw].vertexCount, pParams[idraw].instanceCount,
pParams[idraw].firstVertex, pParams[idraw].firstInstance);
}
}
void VulkanRenderer::EmitMultiDrawIndexed(VkCommandBuffer commandBuffer, const DrawIndexedCmdParam* pParams,
Uint32 drawCount) {
if (drawCount == 0) {
return;
}
if (drawCount == 1) {
vkCmdDrawIndexed(commandBuffer, pParams[0].indexCount, pParams[0].instanceCount, pParams[0].firstIndex,
pParams[0].vertexOffset, pParams[0].firstInstance);
return;
}
// Tier 1: VK_EXT_multi_draw. VkMultiDrawIndexedInfoEXT carries per-draw
// firstIndex/indexCount/vertexOffset (pVertexOffset = nullptr keeps the per-draw
// offsets), but instanceCount/firstInstance are batch-wide, so the batch must be
// uniform in both (GL's glMultiDrawElements* always is: 1/0).
if (m_multiDrawAllowExt) {
Bool uniformInstances = true;
for (Uint32 idraw = 1; idraw < drawCount; ++idraw) {
if (pParams[idraw].instanceCount != pParams[0].instanceCount ||
pParams[idraw].firstInstance != pParams[0].firstInstance) {
uniformInstances = false;
break;
}
}
if (uniformInstances) {
static Vector<VkMultiDrawIndexedInfoEXT> infos;
infos.resize(drawCount);
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
infos[idraw].firstIndex = pParams[idraw].firstIndex;
infos[idraw].indexCount = pParams[idraw].indexCount;
infos[idraw].vertexOffset = pParams[idraw].vertexOffset;
}
for (Uint32 base = 0; base < drawCount; base += m_maxMultiDrawCount) {
const Uint32 chunk = std::min(drawCount - base, m_maxMultiDrawCount);
s_vkCmdDrawMultiIndexedEXT(commandBuffer, chunk, infos.data() + base,
pParams[0].instanceCount,
static_cast<Uint32>(pParams[0].firstInstance),
sizeof(VkMultiDrawIndexedInfoEXT), nullptr);
}
return;
}
}
// Tier 2: multiDrawIndirect - one vkCmdDrawIndexedIndirect over a transient command
// array (DrawIndexedCmdParam aliases VkDrawIndexedIndirectCommand, see static_assert).
if (m_multiDrawAllowIndirect) {
Bool firstInstanceLegal = m_drawIndirectFirstInstanceFeatureEnabled;
if (!firstInstanceLegal) {
firstInstanceLegal = true;
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
if (pParams[idraw].firstInstance != 0) {
firstInstanceLegal = false;
break;
}
}
}
const Uint32 maxIndirectCount = m_physicalDevice.properties.limits.maxDrawIndirectCount;
if (firstInstanceLegal && maxIndirectCount > 0) {
BufferSlice commandSlice{};
if (m_bufferManager.UploadTransient(BufferKind::Indirect, m_frameContext.GetCurrentFrameIndex(),
pParams,
static_cast<VkDeviceSize>(drawCount) *
sizeof(DrawIndexedCmdParam),
sizeof(Uint32), commandSlice)) {
for (Uint32 base = 0; base < drawCount; base += maxIndirectCount) {
const Uint32 chunk = std::min(drawCount - base, maxIndirectCount);
vkCmdDrawIndexedIndirect(commandBuffer, commandSlice.buffer,
commandSlice.offset +
static_cast<VkDeviceSize>(base) * sizeof(DrawIndexedCmdParam),
chunk, sizeof(DrawIndexedCmdParam));
}
return;
}
}
}
// Tier 3: unrolled loop, byte-identical fallback (and the only tier where a SPIR-V
// DrawIndex consumer sees 0 for every sub-draw instead of the sub-draw index).
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
vkCmdDrawIndexed(commandBuffer, pParams[idraw].indexCount, pParams[idraw].instanceCount,
pParams[idraw].firstIndex, pParams[idraw].vertexOffset, pParams[idraw].firstInstance);
}
}
@@ -9470,9 +9782,9 @@ void main() {
MOBILEGL_ASSERT(frame.isCommandRecording, "%s: frame recording was not started", __func__);
VkCommandBuffer& commandBuffer = frame.commandBuffer;
// Collapse contiguous sub-draw runs into one vkCmdDrawIndexed. Per-sub-draw
// Collapse contiguous sub-draw runs BEFORE tier dispatch: merging shrinks the
// param span every tier consumes (fewer VkMultiDrawIndexedInfoEXT entries, a
// smaller transient command array, fewer unrolled vkCmdDrawIndexed). Per-sub-draw
// command emission in the driver dominates a Sodium-shaped multi-draw
// (steady-state profile: >60% of the case inside the Vulkan driver's
// vkCmdDrawIndexed encoding for 132x32 sub-draws/frame), and a chunk
@@ -9501,47 +9813,45 @@ void main() {
mergeGranularity = 0;
}
}
if (mergeGranularity == 0) {
for (Uint32 idraw = 0; idraw < payload.drawCount; ++idraw) {
vkCmdDrawIndexed(commandBuffer,
payload.pParams[idraw].indexCount,
payload.pParams[idraw].instanceCount,
payload.pParams[idraw].firstIndex,
payload.pParams[idraw].vertexOffset,
payload.pParams[idraw].firstInstance);
}
return;
}
const DrawIndexedCmdParam* pParams = payload.pParams;
Uint32 drawCount = payload.drawCount;
static Vector<DrawIndexedCmdParam> mergedParams;
if (mergeGranularity != 0) {
mergedParams.clear();
mergedParams.reserve(drawCount);
Uint32 idraw = 0;
while (idraw < payload.drawCount) {
const DrawIndexedCmdParam& head = payload.pParams[idraw];
while (idraw < drawCount) {
DrawIndexedCmdParam head = pParams[idraw];
++idraw;
if (head.indexCount == 0) {
continue; // draws nothing, contributes nothing to a run
}
Uint32 mergedIndexCount = head.indexCount;
if (head.instanceCount == 1) {
while (idraw < payload.drawCount) {
const DrawIndexedCmdParam& next = payload.pParams[idraw];
while (idraw < drawCount) {
const DrawIndexedCmdParam& next = pParams[idraw];
if (next.indexCount == 0) {
++idraw;
continue;
}
if (mergedIndexCount % mergeGranularity != 0 ||
if (head.indexCount % mergeGranularity != 0 ||
next.instanceCount != 1 ||
next.vertexOffset != head.vertexOffset ||
next.firstInstance != head.firstInstance ||
next.firstIndex != head.firstIndex + mergedIndexCount ||
mergedIndexCount + next.indexCount < mergedIndexCount) {
next.firstIndex != head.firstIndex + head.indexCount ||
head.indexCount + next.indexCount < head.indexCount) {
break;
}
mergedIndexCount += next.indexCount;
head.indexCount += next.indexCount;
++idraw;
}
}
vkCmdDrawIndexed(commandBuffer, mergedIndexCount, head.instanceCount, head.firstIndex,
head.vertexOffset, head.firstInstance);
mergedParams.push_back(head);
}
pParams = mergedParams.data();
drawCount = static_cast<Uint32>(mergedParams.size());
}
EmitMultiDrawIndexed(frame.commandBuffer, pParams, drawCount);
}
// Byte size of the command structures GL defines for the indirect draws (GL 4.6 core
@@ -9717,7 +10027,8 @@ void main() {
MOBILEGL_ASSERT(frame.isCommandRecording, "%s: frame recording was not started", __func__);
MGLOG_D("DirectVulkan: glMultiDrawElementsIndirect(drawcount=%d stride=%d)", drawcount, stride);
if (drawcount == 1 || (m_multiDrawIndirectFeatureEnabled && stride % 4 == 0)) {
if (drawcount == 1 ||
(!m_multiDrawForceUnrollIndirect && m_multiDrawIndirectFeatureEnabled && stride % 4 == 0)) {
vkCmdDrawIndexedIndirect(frame.commandBuffer,
drawSlice.buffer,
drawSlice.offset + static_cast<VkDeviceSize>(commandOffset),
@@ -9780,7 +10091,8 @@ void main() {
MOBILEGL_ASSERT(frame.isCommandRecording, "%s: frame recording was not started", __func__);
MGLOG_D("DirectVulkan: glMultiDrawArraysIndirect(drawcount=%d stride=%d)", drawcount, stride);
if (drawcount == 1 || (m_multiDrawIndirectFeatureEnabled && stride % 4 == 0)) {
if (drawcount == 1 ||
(!m_multiDrawForceUnrollIndirect && m_multiDrawIndirectFeatureEnabled && stride % 4 == 0)) {
vkCmdDrawIndirect(frame.commandBuffer,
drawSlice.buffer,
drawSlice.offset + static_cast<VkDeviceSize>(commandOffset),
@@ -10966,6 +11278,7 @@ void main() {
supportedDeviceFeatures.shaderStorageImageWriteWithoutFormat);
}
deviceFeatures.drawIndirectFirstInstance = supportedDeviceFeatures.drawIndirectFirstInstance;
m_drawIndirectFirstInstanceFeatureEnabled = deviceFeatures.drawIndirectFirstInstance == VK_TRUE;
deviceFeatures.multiDrawIndirect = supportedDeviceFeatures.multiDrawIndirect;
m_multiDrawIndirectFeatureEnabled = deviceFeatures.multiDrawIndirect == VK_TRUE;
m_logicOpFeatureEnabled = deviceFeatures.logicOp == VK_TRUE;
@@ -11247,6 +11560,56 @@ void main() {
}
}
// VK_EXT_multi_draw: tier 1 of the multi-draw dispatch - one vkCmdDrawMulti(Indexed)EXT
// for a whole glMultiDraw* batch (VkMultiDrawIndexedInfoEXT carries per-draw
// firstIndex/indexCount/vertexOffset, so glMultiDrawElementsBaseVertex fits natively).
// Requested only when both the extension and its multiDraw feature are present;
// absent it, the dispatch falls to the multiDrawIndirect tier or the unrolled loop.
m_multiDrawExtensionEnabled = false;
m_maxMultiDrawCount = 0;
VkPhysicalDeviceMultiDrawFeaturesEXT multiDrawFeatures{};
multiDrawFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MULTI_DRAW_FEATURES_EXT;
if (IsExtensionSupported(availableExtensions, VK_EXT_MULTI_DRAW_EXTENSION_NAME) &&
getPhysicalDeviceFeatures2 != nullptr) {
VkPhysicalDeviceFeatures2 featureQuery{};
featureQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
featureQuery.pNext = &multiDrawFeatures;
getPhysicalDeviceFeatures2(m_physicalDevice.handle, &featureQuery);
if (multiDrawFeatures.multiDraw == VK_TRUE) {
if (!IsExtensionAlreadyEnabled(enabledDeviceExtensions, VK_EXT_MULTI_DRAW_EXTENSION_NAME)) {
enabledDeviceExtensions.push_back(VK_EXT_MULTI_DRAW_EXTENSION_NAME);
}
multiDrawFeatures.pNext = const_cast<void*>(deviceCreateInfo.pNext);
deviceCreateInfo.pNext = &multiDrawFeatures;
m_multiDrawExtensionEnabled = true;
VkPhysicalDeviceMultiDrawPropertiesEXT multiDrawProperties{};
multiDrawProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MULTI_DRAW_PROPERTIES_EXT;
auto getPhysicalDeviceProperties2 = reinterpret_cast<PFN_vkGetPhysicalDeviceProperties2>(
vkGetInstanceProcAddr(m_instance, "vkGetPhysicalDeviceProperties2"));
if (getPhysicalDeviceProperties2 == nullptr) {
getPhysicalDeviceProperties2 = reinterpret_cast<PFN_vkGetPhysicalDeviceProperties2>(
vkGetInstanceProcAddr(m_instance, "vkGetPhysicalDeviceProperties2KHR"));
}
if (getPhysicalDeviceProperties2 != nullptr) {
VkPhysicalDeviceProperties2 propertyQuery{};
propertyQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2;
propertyQuery.pNext = &multiDrawProperties;
getPhysicalDeviceProperties2(m_physicalDevice.handle, &propertyQuery);
}
// Spec minimum is 1024; a driver reporting 0 through a failed query must not
// zero out every batch, so fall back to the spec minimum.
m_maxMultiDrawCount = multiDrawProperties.maxMultiDrawCount != 0
? multiDrawProperties.maxMultiDrawCount
: 1024;
MGLOG_I("Enabled optional device extension: %s (maxMultiDrawCount=%u)",
VK_EXT_MULTI_DRAW_EXTENSION_NAME, m_maxMultiDrawCount);
} else {
MGLOG_I("VK_EXT_multi_draw is advertised but its multiDraw feature is unavailable; "
"multi-draw batches use the indirect or unrolled tier");
}
}
deviceCreateInfo.enabledExtensionCount = static_cast<Uint32>(enabledDeviceExtensions.size());
deviceCreateInfo.ppEnabledExtensionNames = enabledDeviceExtensions.data();
MGLOG_I("Device feature support: robustBufferAccess=%s geometryShader=%s independentBlend=%s logicOp=%s shaderClipDistance=%s "
@@ -11303,6 +11666,51 @@ void main() {
m_drawIndirectCountExtensionEnabled = false;
}
s_vkCmdDrawMultiEXT = nullptr;
s_vkCmdDrawMultiIndexedEXT = nullptr;
if (m_multiDrawExtensionEnabled) {
s_vkCmdDrawMultiEXT =
reinterpret_cast<PFN_vkCmdDrawMultiEXT>(vkGetDeviceProcAddr(m_device, "vkCmdDrawMultiEXT"));
s_vkCmdDrawMultiIndexedEXT = reinterpret_cast<PFN_vkCmdDrawMultiIndexedEXT>(
vkGetDeviceProcAddr(m_device, "vkCmdDrawMultiIndexedEXT"));
if (s_vkCmdDrawMultiEXT == nullptr || s_vkCmdDrawMultiIndexedEXT == nullptr) {
MGLOG_W("VK_EXT_multi_draw enabled but its entry points are missing, will continue as if "
"VK_EXT_multi_draw is not supported!");
s_vkCmdDrawMultiEXT = nullptr;
s_vkCmdDrawMultiIndexedEXT = nullptr;
m_multiDrawExtensionEnabled = false;
}
}
// Resolve the multi-draw dispatch tiers once: device support clamped by the
// MOBILEGL_MAGMA_MULTIDRAW_MODE preference. Requesting an unavailable tier is
// never an error - the dispatch falls down the chain ext -> indirect -> unroll.
{
using MG_Config::MultiDrawMode;
const MultiDrawMode mode = MG_Config::Features.MagmaMultiDrawMode;
m_multiDrawAllowExt =
m_multiDrawExtensionEnabled && (mode == MultiDrawMode::Auto || mode == MultiDrawMode::Ext);
m_multiDrawAllowIndirect = m_multiDrawIndirectFeatureEnabled && mode != MultiDrawMode::Unroll;
m_multiDrawForceUnrollIndirect = mode == MultiDrawMode::Unroll;
if (mode == MultiDrawMode::Ext && !m_multiDrawExtensionEnabled) {
MGLOG_I("MOBILEGL_MAGMA_MULTIDRAW_MODE=ext requested but VK_EXT_multi_draw is unavailable; "
"falling back to the %s tier",
m_multiDrawAllowIndirect ? "indirect" : "unroll");
}
if (mode == MultiDrawMode::Indirect && !m_multiDrawIndirectFeatureEnabled) {
MGLOG_I("MOBILEGL_MAGMA_MULTIDRAW_MODE=indirect requested but the multiDrawIndirect device "
"feature is unavailable; falling back to the unroll tier");
}
MGLOG_I("Multi-draw dispatch tier: %s (VK_EXT_multi_draw=%s, multiDrawIndirect=%s, mode=%s)",
m_multiDrawAllowExt ? "ext" : (m_multiDrawAllowIndirect ? "indirect" : "unroll"),
m_multiDrawExtensionEnabled ? "true" : "false",
m_multiDrawIndirectFeatureEnabled ? "true" : "false",
mode == MultiDrawMode::Auto ? "auto"
: mode == MultiDrawMode::Ext ? "ext"
: mode == MultiDrawMode::Indirect ? "indirect"
: "unroll");
}
if (m_transformFeedbackFeatureEnabled) {
s_vkCmdBindTransformFeedbackBuffersEXT = reinterpret_cast<PFN_vkCmdBindTransformFeedbackBuffersEXT>(
vkGetDeviceProcAddr(m_device, "vkCmdBindTransformFeedbackBuffersEXT"));
@@ -327,6 +327,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool RecreateSwapchain();
private:
// Tiered emission for an already-set-up multi-draw batch (state bound, index
// buffer bound for the indexed form). Tier 1: VK_EXT_multi_draw. Tier 2: one
// vkCmdDraw(Indexed)Indirect over a transient command array. Tier 3: unrolled
// vkCmdDraw(Indexed) loop. Tier eligibility is per-batch (uniform instance
// state for tier 1, firstInstance/feature legality for tier 2); every tier
// consumes the same param span, so contiguous-run merging done by the caller
// benefits all of them.
void EmitMultiDrawIndexed(VkCommandBuffer commandBuffer, const DrawIndexedCmdParam* pParams, Uint32 drawCount);
void EmitMultiDraw(VkCommandBuffer commandBuffer, const DrawCmdParam* pParams, Uint32 drawCount);
struct BlitUniformData {
float srcRect[4] = {0.f, 0.f, 1.f, 1.f};
float dstRect[4] = {0.f, 0.f, 1.f, 1.f};
@@ -477,6 +487,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool m_indexTypeUint8ExtensionEnabled = false;
Bool m_logicOpFeatureEnabled = false;
Bool m_multiDrawIndirectFeatureEnabled = false;
// drawIndirectFirstInstance gates indirect commands whose firstInstance != 0;
// cached at device creation because the tier-2 multi-draw path (a transient
// VkDrawIndexedIndirectCommand array) is illegal for such a sub-draw without it.
Bool m_drawIndirectFirstInstanceFeatureEnabled = false;
// VK_EXT_multi_draw: native batched submission for the CPU-side glMultiDraw*
// families (tier 1 of the multi-draw dispatch).
Bool m_multiDrawExtensionEnabled = false;
Uint32 m_maxMultiDrawCount = 0;
// Multi-draw dispatch tiers, resolved once at device creation from device support
// clamped by MOBILEGL_MAGMA_MULTIDRAW_MODE (a preference, never a demand):
// tier 1 (ext): one vkCmdDrawMulti(Indexed)EXT - m_multiDrawAllowExt
// tier 2 (indirect): one vkCmdDraw(Indexed)Indirect batch - m_multiDrawAllowIndirect
// tier 3 (unroll): one vkCmdDraw(Indexed) per sub-draw - always available
// m_multiDrawForceUnrollIndirect additionally forces the GPU-parameter
// glMultiDraw*Indirect paths onto their per-command loop (mode=unroll only).
Bool m_multiDrawAllowExt = false;
Bool m_multiDrawAllowIndirect = false;
Bool m_multiDrawForceUnrollIndirect = false;
Bool m_samplerAnisotropyFeatureEnabled = false;
Bool m_shaderDrawParametersExtensionEnabled = false;
Bool m_shaderDrawParametersFeatureEnabled = false;
@@ -508,6 +536,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkDeviceSize countBufferOffset, Uint32 maxDrawCount,
Uint32 stride);
static inline PFNDrawIndexedIndirectCountFunc s_vkCmdDrawIndexedIndirectCount = nullptr;
// VK_EXT_multi_draw entry points, loaded at device creation when the extension
// (and its multiDraw feature) is enabled; null otherwise.
static inline PFN_vkCmdDrawMultiEXT s_vkCmdDrawMultiEXT = nullptr;
static inline PFN_vkCmdDrawMultiIndexedEXT s_vkCmdDrawMultiIndexedEXT = nullptr;
// VK_EXT_transform_feedback (GL transform feedback capture)
Bool m_transformFeedbackFeatureEnabled = false;
@@ -659,7 +691,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool m_baseTransformFlagsIsDefaultFbo = false;
Bool m_baseTransformFlagsKeyValid = false;
Uint32 m_baseTransformFlagsCache = 0;
Uint32 GetBaseTransformFlagsRaw();
// isDefaultFbo must be the default-ness of the CURRENTLY bound draw framebuffer;
// every caller already has it in hand from its own guards.
Uint32 GetBaseTransformFlagsRaw(Bool isDefaultFbo);
// Drops every memoized pipeline handle. Required at command-buffer
// boundaries and whenever any pipeline may have been destroyed. Also drops
// the cached pipeline-state hash: the same boundaries can retire the GL
@@ -724,6 +758,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint64 programLifetimeId = 0;
Uint32 programVersion = 0;
const void* vao = nullptr;
// Same rule as VaoDrawMemo::vaoLifetimeId: (address, config version) is not an
// identity, because a recycled address can arrive carrying a config version
// the dead VAO also had (two mutations to configure one attribute is the
// common shape), and "the VAO did not move" would then skip the layout
// re-resolve for a different VAO.
Uint64 vaoLifetimeId = 0;
Uint32 vaoConfigVersion = 0;
const void* drawFbo = nullptr;
Uint16 fboVersion = 0;
@@ -953,19 +993,30 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const MG_State::GLState::BufferObject* buffers[kMaxBindings] = {};
Uint64 sliceEpochs[kMaxBindings] = {};
};
// One direct-mapped slot of the per-VAO draw-memo table below. The key is a
// lookup hint only - a slot is never dereferenced through vaoKey; every fact it
// carries is validated against live state before use:
// One direct-mapped slot of the per-VAO draw-memo table below. A slot belongs to
// the object whose (vaoKey, vaoLifetimeId) pair it carries: the address alone
// only picks the slot, and the never-reused lifetime id is what proves the slot
// is THIS VAO's, so the successor allocated onto a destroyed VAO's address
// always misses. That identity check is load-bearing and the content-hash
// validations below do NOT stand in for it - a recycled address under a
// byte-identical configuration reproduces the content hash exactly, which is
// how a destroyed VAO's resolved bindings were once handed to its successor's
// draw. The slot is still never dereferenced through vaoKey, and every fact it
// carries is still validated against live state before use:
// - layoutHash/layoutAuxMasks are valid only while contentHash equals the LIVE
// VAO's own hash memo (which the VAO's config version guards), so a config
// change, a buffer rebind, or a recycled VAO address with a different
// configuration all miss. A recycled address with a byte-identical
// configuration AND identical bound buffers reproduces the content hash, and
// then the facts are correct by construction (they are a pure function of it).
// change or a buffer rebind misses even for the same object.
// - bindings revalidates per draw exactly as before (frame serial, content
// hash, per-binding live buffer pointers and slice epochs).
struct alignas(64) VaoDrawMemo {
const MG_State::GLState::VertexArrayObject* vaoKey = nullptr;
// The VAO's never-reused lifetime id, checked alongside vaoKey. The pointer
// ALONE is not an identity: a deleted VAO's heap address is handed straight
// back by the next glGenVertexArrays-shaped allocation, and the successor then
// matched this slot and inherited the dead object's memos. Both stated
// defences failed with it, because both reduce to the content hash and the
// content hash's buffer-identity component was itself a recycled heap address.
Uint64 vaoLifetimeId = 0;
// The VAO content hash (VertexInputStateFactory::GetOrComputeHash) the two
// layout facts below were derived from; 0 while nothing valid is stored.
Uint64 contentHash = 0;
+91 -17
View File
@@ -9,6 +9,7 @@
#include "GL_Buffer.h"
#include "Validators.h"
#include "../Texture/GL_Texture.h"
#include "../Getter/GL_Getter.h"
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
#include <MG_Util/Metrics/TextureMetrics.h>
#include <Config.h>
@@ -861,6 +862,10 @@ namespace MobileGL::MG_Impl::GLImpl {
Range1D mappedRange = bufferObject->GetMappedRange();
auto mappingAccess = bufferObject->GetMappingAccess();
// GL 4.6 6.5: the error is on OVERLAP with the mapped range, i.e. a half-open
// intersection test. There used to be a second test below this one asking only
// `offset + size >= mappedRange.start`, which rejects every write that starts
// before a mapped tail as well - it made a legal disjoint glBufferSubData fail.
if (bufferObject->IsMapped() && !(mappingAccess & BufferMappingAccessBit::Persistent) &&
(offset < mappedRange.end) && (offset + size > mappedRange.start)) {
MG_State::pGLContext->RecordError(
@@ -871,18 +876,6 @@ namespace MobileGL::MG_Impl::GLImpl {
return;
}
if (bufferObject->IsMapped() && !(mappingAccess & BufferMappingAccessBit::Persistent)) {
Range1D mappedRange = bufferObject->GetMappedRange();
if (offset + size >= mappedRange.start) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "BufferSubData_State",
"Cannot modify a mapped buffer object unless it was "
"mapped with GL_MAP_PERSISTENT_BIT."));
return;
}
}
bufferObject->UploadSubData({(void*)data, (SizeT)size}, offset);
}
@@ -1013,6 +1006,11 @@ namespace MobileGL::MG_Impl::GLImpl {
}
void BufferStorage_State(GLenum target, GLsizeiptr size, const void* data, GLbitfield flags) {
// Error precedence: "no buffer is bound to target" outranks a bad size or bad
// flags, so the binding has to be resolved before either is validated.
auto bufferObject = GetBoundBufferObject(target, BufferOp::BufferStorage);
if (!bufferObject) return;
if (size <= 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
@@ -1021,8 +1019,6 @@ namespace MobileGL::MG_Impl::GLImpl {
}
if (!ValidateStorageFlags(flags, BufferOp::BufferStorage)) return;
auto bufferObject = GetBoundBufferObject(target, BufferOp::BufferStorage);
if (!bufferObject) return;
if (bufferObject->IsImmutableStorage()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
@@ -1057,6 +1053,10 @@ namespace MobileGL::MG_Impl::GLImpl {
}
void NamedBufferStorage_State(GLuint buffer, GLsizeiptr size, const void* data, GLbitfield flags) {
// Same precedence as BufferStorage_State: the buffer-name error comes first.
auto bufferObject = GetNamedBufferObject(buffer, BufferOp::NamedBufferStorage);
if (!bufferObject) return;
if (size <= 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
@@ -1065,8 +1065,6 @@ namespace MobileGL::MG_Impl::GLImpl {
}
if (!ValidateStorageFlags(flags, BufferOp::NamedBufferStorage)) return;
auto bufferObject = GetNamedBufferObject(buffer, BufferOp::NamedBufferStorage);
if (!bufferObject) return;
if (bufferObject->IsImmutableStorage()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
@@ -1486,12 +1484,71 @@ namespace MobileGL::MG_Impl::GLImpl {
GetBufferBindingSlot(bufferTarget).Bind(bufferObject);
}
// GL 4.6 core 6.1.1: the constraints glBindBufferRange puts on the (offset, size) pair.
// Every one of them is INVALID_VALUE, and all of them are checked before a single piece
// of state is written - a rejected bind must leave the binding point exactly as it was.
// They apply only to a non-zero buffer: buffer 0 detaches the binding point and ignores
// offset and size, which is also how glBindBuffersRange spells "reset this element"
// (a NULL buffers array, or a zero entry inside one).
static Bool ValidateBufferRangeOffsetAndSize(GLenum target, GLintptr offset, GLsizeiptr size,
const char* funcName) {
if (size <= 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
std::format("size ({}) must be greater than zero.", size)));
return false;
}
if (offset < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
std::format("offset ({}) must not be negative.", offset)));
return false;
}
// GL_UNIFORM_BUFFER and GL_SHADER_STORAGE_BUFFER each constrain the offset to their own
// implementation-defined alignment, which glGetIntegerv already answers.
GLenum alignmentQuery = GL_NONE;
if (target == GL_SHADER_STORAGE_BUFFER) {
alignmentQuery = GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT;
} else if (target == GL_UNIFORM_BUFFER) {
alignmentQuery = GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT;
}
if (alignmentQuery != GL_NONE) {
GLint alignment = 0;
GetIntegerv(alignmentQuery, &alignment);
if (alignment > 0 && (offset % static_cast<GLintptr>(alignment)) != 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", funcName,
std::format("offset ({}) must be a multiple of {} ({}).", offset,
MG_Util::ConvertGLEnumToString(alignmentQuery), alignment)));
return false;
}
}
// A transform feedback capture binding is addressed in 32-bit components, so BOTH the
// offset and the size must be multiples of 4.
if (target == GL_TRANSFORM_FEEDBACK_BUFFER && ((offset % 4) != 0 || (size % 4) != 0)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", funcName,
std::format("offset ({}) and size ({}) must both be multiples of 4 for "
"GL_TRANSFORM_FEEDBACK_BUFFER.",
offset, size)));
return false;
}
return true;
}
void BindBufferRange_State(GLenum target, GLuint index, GLuint buffer, GLintptr offset, GLsizeiptr size) {
MGLOG_D("%s: target = %s, index = %u, buffer = %u, offset = %d, size = %d", __func__,
MG_Util::ConvertGLEnumToString(target).c_str(), index, buffer, offset, size);
BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target);
if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return;
if (!BufferImpl::ValidateBufferBindingPointIndex(bufferTarget, index)) return;
if (buffer != 0 && !ValidateBufferRangeOffsetAndSize(target, offset, size, __func__)) return;
if (bufferTarget == BufferTarget::TransformFeedback && MG_State::pGLContext->IsTransformFeedbackActive()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
@@ -1665,15 +1722,32 @@ namespace MobileGL::MG_Impl::GLImpl {
}
// ARB_multi_bind: defined by the spec as equivalent to a loop over the single-bind entry
// points (with buffer 0 resetting the binding point).
// points (with buffer 0 resetting the binding point) - but only AFTER an up-front check
// of the whole [first, first + count) range. Looping straight into the single-bind entry
// points reports the single-bind INVALID_VALUE for an out-of-range index instead of the
// multi-bind INVALID_OPERATION, and binds the in-range prefix before failing.
static Bool ValidateMultiBindBufferRange(GLenum target, GLuint first, GLsizei count, const char* funcName) {
BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target);
if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return false;
return BufferImpl::ValidateBufferBindingPointRange(bufferTarget, first, count, funcName);
}
void BindBuffersBase(GLenum target, GLuint first, GLsizei count, const GLuint* buffers) {
if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return;
for (GLsizei i = 0; i < count; ++i) {
BindBufferBase_State(target, first + i, buffers ? buffers[i] : 0);
}
}
// The (offset, size) constraints are the one part of glBindBuffersRange that stays
// per-element: ARB_multi_bind checks them separately for each binding point, leaves that
// point unchanged on failure, and still applies the remaining elements - which is exactly
// what looping into BindBufferRange_State does. Only the [first, first + count) range is
// an up-front, all-or-nothing check. Elements that name buffer 0 (or a NULL buffers array)
// reset the binding point through BindBufferBase_State and carry no offset/size to check.
void BindBuffersRange(GLenum target, GLuint first, GLsizei count, const GLuint* buffers, const GLintptr* offsets,
const GLsizeiptr* sizes) {
if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return;
for (GLsizei i = 0; i < count; ++i) {
if (!buffers || buffers[i] == 0) {
BindBufferBase_State(target, first + i, 0);
+29 -1
View File
@@ -53,7 +53,9 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
return true;
}
Bool ValidateBufferBindingPointIndex(BufferTarget target, Uint index) {
namespace {
// The GL-visible number of indexed binding points for `target`.
SizeT GetBufferBindingPointLimit(BufferTarget target) {
SizeT pointCount = MG_State::pGLContext->GetBufferBindingPointCount(target);
if (target == BufferTarget::ShaderStorage && MG_Backend::pActiveBackendObject) {
const Int backendCount =
@@ -65,6 +67,32 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
// binding points in GL 3.3 (no ARB_transform_feedback3).
pointCount = std::min<SizeT>(pointCount, 4);
}
return pointCount;
}
} // namespace
Bool ValidateBufferBindingPointRange(BufferTarget target, Uint first, GLsizei count, const char* funcName) {
if (count < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl/BufferImpl", funcName,
"count must be non-negative."));
return false;
}
const SizeT pointCount = GetBufferBindingPointLimit(target);
if (static_cast<Uint64>(first) + static_cast<Uint64>(count) > static_cast<Uint64>(pointCount)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl/BufferImpl", funcName,
std::format("first + count ({} + {}) exceeds the {} indexed binding points of target {}.", first,
count, pointCount, MG_Util::ConvertBufferTargetToString(target))));
return false;
}
return true;
}
Bool ValidateBufferBindingPointIndex(BufferTarget target, Uint index) {
const SizeT pointCount = GetBufferBindingPointLimit(target);
if (index < pointCount) {
return true;
@@ -17,4 +17,8 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
Bool ValidateBufferMappingAccess(Flags<BufferMappingAccessBit> accessBits);
Bool ValidateBufferBindingPointTarget(BufferTarget target);
Bool ValidateBufferBindingPointIndex(BufferTarget target, Uint index);
// ARB_multi_bind: glBindBuffersBase/Range validate the whole [first, first + count) range
// up front and report INVALID_OPERATION, where a single out-of-range index would be
// INVALID_VALUE. Naively looping the single-bind entry points reports the wrong class.
Bool ValidateBufferBindingPointRange(BufferTarget target, Uint first, GLsizei count, const char* funcName);
} // namespace MobileGL::MG_Impl::GLImpl::BufferImpl
@@ -474,6 +474,21 @@ namespace MobileGL::MG_Impl::GLImpl {
return;
}
if (!ValidateCurrentProgramForCompute(__func__)) return;
// GL 4.6 core 19: each num_groups_* must be within GL_MAX_COMPUTE_WORK_GROUP_COUNT
// for its dimension. GetIntegeri_v already floors that at the spec minimum.
const GLuint numGroups[3] = {numGroupsX, numGroupsY, numGroupsZ};
for (GLuint dimension = 0; dimension < 3; ++dimension) {
GLint maxGroups = 0;
GetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, dimension, &maxGroups);
if (numGroups[dimension] > static_cast<GLuint>(std::max(maxGroups, 0))) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"num_groups exceeds GL_MAX_COMPUTE_WORK_GROUP_COUNT for dimension " +
std::to_string(dimension) + "."));
return;
}
}
dispatchCompute(numGroupsX, numGroupsY, numGroupsZ);
}
@@ -487,6 +502,24 @@ namespace MobileGL::MG_Impl::GLImpl {
return;
}
if (!ValidateCurrentProgramForCompute(__func__)) return;
// GL 4.6 core 19: `indirect` is a byte offset into GL_DISPATCH_INDIRECT_BUFFER -
// negative or misaligned is INVALID_VALUE, nothing bound is INVALID_OPERATION.
if (indirect < 0 || (indirect % 4) != 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"indirect must be non-negative and a multiple of 4."));
return;
}
const auto& indirectBuffer =
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DispatchIndirect).GetBoundObject();
if (!indirectBuffer) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"No buffer is bound to GL_DISPATCH_INDIRECT_BUFFER."));
return;
}
dispatchComputeIndirect(indirect);
}
@@ -977,8 +977,8 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, ScissorIndexed, GLuint index, GLint left, GL
DECLARE_GL_FUNCTION_STUB_HEAD(void, ScissorIndexedv, GLuint index, const GLint* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ScissorIndexedv, index, v)
DECLARE_GL_FUNCTION_STUB_HEAD(void, DepthRangeArrayv, GLuint first, GLsizei count, const GLdouble* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DepthRangeArrayv, first, count, v)
DECLARE_GL_FUNCTION_STUB_HEAD(void, DepthRangeIndexed, GLuint index, GLdouble n, GLdouble f) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DepthRangeIndexed, index, n, f)
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetFloati_v, GLenum target, GLuint index, GLfloat* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetFloati_v, target, index, data)
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetDoublei_v, GLenum target, GLuint index, GLdouble* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetDoublei_v, target, index, data)
DECLARE_GL_FUNCTION_HEAD(void, GetFloati_v, GLenum target, GLuint index, GLfloat* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, GetFloati_v, target, index, data)
DECLARE_GL_FUNCTION_HEAD(void, GetDoublei_v, GLenum target, GLuint index, GLdouble* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, GetDoublei_v, target, index, data)
DECLARE_GL_FUNCTION_HEAD(void, DrawArraysInstancedBaseInstance, GLenum mode, GLint first, GLsizei count, GLsizei instancecount, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawArraysInstancedBaseInstance, mode, first, count, instancecount, baseinstance)
DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseInstance, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseInstance, mode, count, type, indices, instancecount, baseinstance)
DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseVertexBaseInstance, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLint basevertex, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseVertexBaseInstance, mode, count, type, indices, instancecount, basevertex, baseinstance)
@@ -1273,7 +1273,7 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiTexCoord4ivARB, GLenum target, const GL
DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiTexCoord4sARB, GLenum target, GLshort s, GLshort t, GLshort r, GLshort q) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MultiTexCoord4sARB, target, s, t, r, q)
DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiTexCoord4svARB, GLenum target, const GLshort* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MultiTexCoord4svARB, target, v)
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetQueryObjectivARB, GLuint id, GLenum pname, GLint* params) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetQueryObjectivARB, id, pname, params)
DECLARE_GL_FUNCTION_STUB_HEAD(void, MaxShaderCompilerThreadsARB, GLuint count) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MaxShaderCompilerThreadsARB, count)
DECLARE_GL_FUNCTION_HEAD(void, MaxShaderCompilerThreadsARB, GLuint count) DECLARE_GL_FUNCTION_END_NO_RETURN(void, MaxShaderCompilerThreadsARB, count)
DECLARE_GL_FUNCTION_STUB_HEAD(void, PointParameterfARB, GLenum pname, GLfloat param) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, PointParameterfARB, pname, param)
DECLARE_GL_FUNCTION_STUB_HEAD(void, PointParameterfvARB, GLenum pname, const GLfloat* params) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, PointParameterfvARB, pname, params)
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetnTexImageARB, GLenum target, GLint level, GLenum format, GLenum type, GLsizei bufSize, void* img) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetnTexImageARB, target, level, format, type, bufSize, img)
@@ -1381,7 +1381,7 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, WindowPos3ivARB, const GLint* v) DECLARE_GL_
DECLARE_GL_FUNCTION_STUB_HEAD(void, WindowPos3sARB, GLshort x, GLshort y, GLshort z) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, WindowPos3sARB, x, y, z)
DECLARE_GL_FUNCTION_STUB_HEAD(void, WindowPos3svARB, const GLshort* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, WindowPos3svARB, v)
DECLARE_GL_FUNCTION_STUB_HEAD(void, BlendBarrierKHR, void) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, BlendBarrierKHR, )
DECLARE_GL_FUNCTION_STUB_HEAD(void, MaxShaderCompilerThreadsKHR, GLuint count) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MaxShaderCompilerThreadsKHR, count)
DECLARE_GL_FUNCTION_HEAD(void, MaxShaderCompilerThreadsKHR, GLuint count) DECLARE_GL_FUNCTION_END_NO_RETURN(void, MaxShaderCompilerThreadsKHR, count)
DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiTexCoord1bOES, GLenum texture, GLbyte s) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MultiTexCoord1bOES, texture, s)
DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiTexCoord1bvOES, GLenum texture, const GLbyte* coords) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MultiTexCoord1bvOES, texture, coords)
DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiTexCoord2bOES, GLenum texture, GLbyte s, GLbyte t) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MultiTexCoord2bOES, texture, s, t)
@@ -106,9 +106,11 @@ namespace MobileGL::MG_Impl::GLImpl {
// `capabilityTargetIndex` is the row of the cache the attachment actually lives in;
// kFormatCapabilityTargetCount asks about the format in general. Asking per target matters
// because a capability recorded for one of them says nothing about the others: DirectGLES
// widens three-channel formats to four channels to keep them renderable as *multisample*
// storage, and a format that survives only through that substitution is still texture-only
// on every ordinary target.
// decides each target's substitution against that target's own probe, and a buffer texture
// never gets one at all. This is also where the three-channel widening becomes visible to
// the application - a GL_RGB8_SNORM colour attachment on a driver with no renderable
// three-channel format answers COMPLETE because the backend stores it as GL_RGBA16F and
// recorded FramebufferRenderable in CaveatCaps.
Bool IsColorInternalFormatRenderable(TextureInternalFormat format, SizeT capabilityTargetIndex) {
const SizeT formatIndex = static_cast<SizeT>(format);
if (MG_Backend::pActiveBackendObject && formatIndex < MG_Backend::kFormatCapabilityFormatCount) {
+175 -88
View File
@@ -23,6 +23,7 @@
#include <MG_Util/Converters/MGToGL/RenderStateEnumConverter.h>
#include <MG_State/GLState/FramebufferState/FramebufferObject.h>
#include <MG_Util/Texture/TextureFormatProcessor.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Backend/BackendObjects.h>
namespace MobileGL::MG_Impl::GLImpl {
@@ -50,6 +51,14 @@ namespace MobileGL::MG_Impl::GLImpl {
constexpr GLint kFrontendMaxTessControlAtomicCounters = 0;
constexpr GLint kFrontendMaxTessEvaluationAtomicCounters = 0;
constexpr GLint kFrontendMaxVertexAtomicCounters = 0;
// One atomic counter is a uint, and a buffer never has to hold more counters than the
// combined limit the frontend advertises. GL 4.6 table 23.63 floors this at 32 bytes.
constexpr GLint kFrontendMaxAtomicCounterBufferSize =
kFrontendMaxCombinedAtomicCounters * static_cast<GLint>(sizeof(GLuint));
// KHR_debug minima (GL 4.6 table 23.66); the debug entry points are stubs, but the
// limits they advertise still have to be legal.
constexpr GLint kFrontendMaxDebugGroupStackDepth = 64;
constexpr GLint kFrontendMaxDebugLoggedMessages = 1;
constexpr GLint kFrontendMaxVertexUniformComponents = 4096;
constexpr GLint kFrontendMaxVertexUniformVectors = 128;
constexpr GLint kFrontendMaxVertexUniformBlocks = 14;
@@ -263,6 +272,60 @@ namespace MobileGL::MG_Impl::GLImpl {
return true;
}
// GL_TEXTURE_BINDING_* is per-texture-unit state: glGetIntegerv answers for the
// active unit, glGetIntegeri_v answers for unit `index`. Both need the same
// pname -> target decode, so it lives here instead of being spelled out twice.
bool TryDecodeTextureUnitBindingPname(GLenum pname, TextureTarget& outTarget) {
switch (pname) {
case GL_TEXTURE_BINDING_1D: outTarget = TextureTarget::Texture1D; return true;
case GL_TEXTURE_BINDING_1D_ARRAY: outTarget = TextureTarget::Texture1DArray; return true;
case GL_TEXTURE_BINDING_2D: outTarget = TextureTarget::Texture2D; return true;
case GL_TEXTURE_BINDING_2D_ARRAY: outTarget = TextureTarget::Texture2DArray; return true;
case GL_TEXTURE_BINDING_2D_MULTISAMPLE: outTarget = TextureTarget::Texture2DMultisample; return true;
case GL_TEXTURE_BINDING_2D_MULTISAMPLE_ARRAY:
outTarget = TextureTarget::Texture2DMultisampleArray;
return true;
case GL_TEXTURE_BINDING_3D: outTarget = TextureTarget::Texture3D; return true;
case GL_TEXTURE_BINDING_BUFFER: outTarget = TextureTarget::TextureBuffer; return true;
case GL_TEXTURE_BINDING_CUBE_MAP: outTarget = TextureTarget::TextureCubeMap; return true;
case GL_TEXTURE_BINDING_CUBE_MAP_ARRAY: outTarget = TextureTarget::TextureCubeMapArray; return true;
case GL_TEXTURE_BINDING_RECTANGLE: outTarget = TextureTarget::TextureRectangle; return true;
default: return false;
}
}
GLint QueryTextureBindingOnUnit(Int unit, TextureTarget target) {
auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& obj = textureUnit.GetBindingSlot(target).GetBoundObject();
return obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
}
GLint QuerySamplerBindingOnUnit(Int unit) {
const auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& sampler = textureUnit.GetSamplerObject();
return sampler ? static_cast<GLint>(sampler->GetExternalIndex()) : 0;
}
// The ARB_viewport_array indexed rectangles. MobileGL keeps exactly one viewport, one
// scissor box and one depth range, so every in-range index answers with that single
// value - but it has to come from the frontend state the non-indexed getters read.
// The generic path at the bottom of GetIntegeri_v is a raw backend passthrough that
// has no case for these, so routing them through it returned zeros.
Bool IsIndexedViewportQuery(GLenum target) {
return target == GL_VIEWPORT || target == GL_SCISSOR_BOX || target == GL_DEPTH_RANGE;
}
// ARB_viewport_array: `index` selects a viewport and MAX_VIEWPORTS bounds it.
Bool ValidateViewportQueryIndex(GLuint index, const char* caller) {
GLint maxViewports = 0;
GetIntegerv(GL_MAX_VIEWPORTS, &maxViewports);
if (index < static_cast<GLuint>(std::max(maxViewports, 1))) return true;
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller, "Viewport index is out of range."));
return false;
}
void CopyIntsToBooleans(const GLint* src, SizeT count, GLboolean* dst) {
for (SizeT i = 0; i < count; ++i) {
dst[i] = src[i] ? GL_TRUE : GL_FALSE;
@@ -670,7 +733,37 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
// Per-texture-unit bindings: GL 4.6 core table 23.19 makes every GL_TEXTURE_BINDING_*
// and GL_SAMPLER_BINDING indexed by texture unit. Without this they fell through to
// the raw backend passthrough at the bottom, which knows nothing about the
// frontend's binding state.
if (TextureTarget textureBindingTarget = TextureTarget::Unknown;
TryDecodeTextureUnitBindingPname(target, textureBindingTarget) || target == GL_SAMPLER_BINDING) {
GLint maxUnits = 0;
GetIntegerv(GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, &maxUnits);
maxUnits = std::min<GLint>(maxUnits, MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS);
if (index >= static_cast<GLuint>(std::max(maxUnits, 0))) {
*data = 0;
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "Texture unit index is out of range."));
return;
}
*data = target == GL_SAMPLER_BINDING
? QuerySamplerBindingOnUnit(static_cast<Int>(index))
: QueryTextureBindingOnUnit(static_cast<Int>(index), textureBindingTarget);
return;
}
switch (target) {
// ARB_viewport_array queries the indexed rectangles through glGetIntegeri_v as well
// (gl4cMultiBindTests and the viewport_array group both do). The frontend keeps one
// viewport and one scissor box, so every in-range index reports that one.
case GL_VIEWPORT:
case GL_SCISSOR_BOX:
if (!ValidateViewportQueryIndex(index, __func__)) return;
GetIntegerv(target, data);
return;
// The vertex buffer binding points of the vertex array object that is bound. Indexed by
// binding point, not by attribute (GL 4.6 core 10.3.1).
case GL_VERTEX_BINDING_BUFFER:
@@ -782,6 +875,46 @@ namespace MobileGL::MG_Impl::GLImpl {
getIntegeri(target, index, data);
}
// GL_ARB_viewport_array's typed indexed getters. They were no-op stubs, which left the
// caller's output buffer holding whatever was on the stack. The multi-component indexed
// rectangles are answered from the frontend's own viewport/scissor/depth-range state, via
// the non-indexed getter of the matching type - GL_DEPTH_RANGE is float state, so putting
// it through the integer query would round it to 0/1. Everything else MobileGL answers
// indexed is scalar integer-domain state, where converting the integer query is exact.
void GetFloati_v(GLenum target, GLuint index, GLfloat* data) {
if (!data) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "data pointer cannot be null"));
return;
}
if (IsIndexedViewportQuery(target)) {
if (!ValidateViewportQueryIndex(index, __func__)) return;
GetFloatv(target, data);
return;
}
GLint ints[4] = {};
GetIntegeri_v(target, index, ints);
data[0] = static_cast<GLfloat>(ints[0]);
}
void GetDoublei_v(GLenum target, GLuint index, GLdouble* data) {
if (!data) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "data pointer cannot be null"));
return;
}
if (IsIndexedViewportQuery(target)) {
if (!ValidateViewportQueryIndex(index, __func__)) return;
GetDoublev(target, data);
return;
}
GLint ints[4] = {};
GetIntegeri_v(target, index, ints);
data[0] = static_cast<GLdouble>(ints[0]);
}
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data) {
if (!data) {
MG_State::pGLContext->RecordError(
@@ -958,6 +1091,13 @@ namespace MobileGL::MG_Impl::GLImpl {
return;
}
// Per-texture-unit bindings: the non-indexed query reports the active unit.
if (TextureTarget textureBindingTarget = TextureTarget::Unknown;
TryDecodeTextureUnitBindingPname(pname, textureBindingTarget)) {
*params = QueryTextureBindingOnUnit(MG_State::pGLContext->GetActiveTextureUnit(), textureBindingTarget);
return;
}
switch (pname) {
case GL_ACTIVE_TEXTURE:
*params = MG_State::pGLContext->GetActiveTextureUnit() + GL_TEXTURE0;
@@ -1069,12 +1209,32 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
return;
}
case GL_MAX_SHADER_COMPILER_THREADS_KHR:
// GL_KHR_parallel_shader_compile (GL_MAX_SHADER_COMPILER_THREADS_ARB is the same
// 0x91B0). The number of threads MobileGL's compile pool would actually use, so
// an application sizing its own submission batches gets a real answer.
//
// Zero when asynchronous compilation is off, which is the honest reply and the
// one the extension defines for an implementation with no compiler threads: the
// extension string is withdrawn in that configuration too, so a conforming
// application never reaches this query, and one that asks anyway is told there
// are none rather than being handed a thread count nothing will use.
*params = MG_Util::Async::AsyncShaderCompileEnabled()
? static_cast<GLint>(MG_Util::Async::ShaderCompilePool::Get().GetThreadCount())
: 0;
return;
case GL_MAX_DEBUG_GROUP_STACK_DEPTH:
*params = 0; // debug-group entrypoints are stubbed
// KHR_debug floors this at 64 even when the group entry points are stubs: the
// limit describes how deep glPushDebugGroup may nest, and 0 is not a legal answer.
*params = kFrontendMaxDebugGroupStackDepth;
return;
case GL_MAX_DEBUG_MESSAGE_LENGTH:
*params = 1024; // debug-message entrypoints are stubbed, but KHR_debug requires a valid limit
return;
case GL_MAX_DEBUG_LOGGED_MESSAGES:
// Size of the message log ring; KHR_debug requires at least 1.
*params = kFrontendMaxDebugLoggedMessages;
return;
case GL_DEBUG_GROUP_STACK_DEPTH:
*params = 0; // debug-group entrypoints are stubbed
return;
@@ -1516,13 +1676,9 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_SAMPLE_MASK_VALUE:
*params = static_cast<GLint>(MG_State::pGLContext->GetSampleMaskValue());
return;
case GL_SAMPLER_BINDING: {
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
const auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& sampler = tu.GetSamplerObject();
*params = sampler ? static_cast<GLint>(sampler->GetExternalIndex()) : 0;
case GL_SAMPLER_BINDING:
*params = QuerySamplerBindingOnUnit(MG_State::pGLContext->GetActiveTextureUnit());
return;
}
case GL_SAMPLES:
*params = ResolveDrawFramebufferSampleCount();
return;
@@ -1618,87 +1774,6 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_STEREO:
*params = 0; // stereo surfaces are not exposed
return;
case GL_TEXTURE_BINDING_1D: {
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture1D);
const auto& obj = slot.GetBoundObject();
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
return;
}
case GL_TEXTURE_BINDING_1D_ARRAY: {
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture1DArray);
const auto& obj = slot.GetBoundObject();
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
return;
}
case GL_TEXTURE_BINDING_2D: {
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture2D);
const auto& obj = slot.GetBoundObject();
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
MGLOG_D("Get GL_TEXTURE_BINDING_2D: %d", *params);
return;
}
case GL_TEXTURE_BINDING_2D_ARRAY: {
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture2DArray);
const auto& obj = slot.GetBoundObject();
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
return;
}
case GL_TEXTURE_BINDING_2D_MULTISAMPLE: {
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture2DMultisample);
const auto& obj = slot.GetBoundObject();
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
return;
}
case GL_TEXTURE_BINDING_2D_MULTISAMPLE_ARRAY: {
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture2DMultisampleArray);
const auto& obj = slot.GetBoundObject();
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
return;
}
case GL_TEXTURE_BINDING_3D: {
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture3D);
const auto& obj = slot.GetBoundObject();
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
return;
}
case GL_TEXTURE_BINDING_BUFFER: {
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& slot = tu.GetBindingSlot(TextureTarget::TextureBuffer);
const auto& obj = slot.GetBoundObject();
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
return;
}
case GL_TEXTURE_BINDING_CUBE_MAP: {
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& slot = tu.GetBindingSlot(TextureTarget::TextureCubeMap);
const auto& obj = slot.GetBoundObject();
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
return;
}
case GL_TEXTURE_BINDING_RECTANGLE: {
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
const auto& slot = tu.GetBindingSlot(TextureTarget::TextureRectangle);
const auto& obj = slot.GetBoundObject();
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
return;
}
case GL_TEXTURE_COMPRESSION_HINT:
*params = static_cast<GLint>(MG_State::pGLContext->GetHint(pname));
return;
@@ -1971,6 +2046,18 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS:
*params = static_cast<GLint>(GetIndexedBufferQueryPointCount(BufferTarget::ShaderStorage));
break;
case GL_MAX_SHADER_STORAGE_BLOCK_SIZE:
// 64-bit state (see GetInteger64v); the 32-bit query saturates, per the GL
// state-query conversion rules.
*params = static_cast<GLint>(std::min<Uint64>(dynamicParameters.MaxShaderStorageBlockSize,
static_cast<Uint64>(INT32_MAX)));
break;
case GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS:
*params = static_cast<GLint>(GetIndexedBufferQueryPointCount(BufferTarget::AtomicCounter));
break;
case GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE:
*params = kFrontendMaxAtomicCounterBufferSize;
break;
case GL_MAX_TEXTURE_BUFFER_SIZE:
*params = dynamicParameters.MaxTextureBufferSize;
break;
@@ -19,6 +19,8 @@ namespace MobileGL::MG_Impl::GLImpl {
void GetIntegerv(GLenum pname, GLint* params);
void GetInteger64v(GLenum pname, GLint64* params);
void GetIntegeri_v(GLenum target, GLuint index, GLint* data);
void GetFloati_v(GLenum target, GLuint index, GLfloat* data);
void GetDoublei_v(GLenum target, GLuint index, GLdouble* data);
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
GLenum GetError();
GLenum GetGraphicsResetStatus();
+340 -306
View File
@@ -7,6 +7,7 @@
// End of Source File Header
#include "GL_Program.h"
#include "ProgramInterface.h"
#include "Config.h"
#include <cmath>
#include <limits>
@@ -16,6 +17,7 @@
#include <MG_Util/Converters/GLToMG/ProgramEnumConverter.h>
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
#include <MG_Util/Converters/SPIRVCrossToGL/SpvcTypeConverter.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Backend/BackendObjects.h>
namespace MobileGL::MG_Impl::GLImpl {
@@ -108,32 +110,37 @@ namespace MobileGL::MG_Impl::GLImpl {
return programObject;
}
static bool IsProgramInterfaceEnum(GLenum programInterface) {
switch (programInterface) {
case GL_UNIFORM:
case GL_UNIFORM_BLOCK:
case GL_PROGRAM_INPUT:
case GL_PROGRAM_OUTPUT:
case GL_BUFFER_VARIABLE:
case GL_SHADER_STORAGE_BLOCK:
case GL_ATOMIC_COUNTER_BUFFER:
case GL_TRANSFORM_FEEDBACK_VARYING:
case GL_VERTEX_SUBROUTINE:
case GL_TESS_CONTROL_SUBROUTINE:
case GL_TESS_EVALUATION_SUBROUTINE:
case GL_GEOMETRY_SUBROUTINE:
case GL_FRAGMENT_SUBROUTINE:
case GL_COMPUTE_SUBROUTINE:
case GL_VERTEX_SUBROUTINE_UNIFORM:
case GL_TESS_CONTROL_SUBROUTINE_UNIFORM:
case GL_TESS_EVALUATION_SUBROUTINE_UNIFORM:
case GL_GEOMETRY_SUBROUTINE_UNIFORM:
case GL_FRAGMENT_SUBROUTINE_UNIFORM:
case GL_COMPUTE_SUBROUTINE_UNIFORM:
return true;
default:
return false;
// The four non-location interface queries validate the NAME only: GL 4.6 imposes the
// successful-link requirement on GetProgramResourceLocation/LocationIndex alone, and
// requires the others to report a program that has never linked as one with zero active
// resources. Being stricter leaves a stray GL_INVALID_OPERATION behind that aborts the
// caller's next subcase.
static const SharedPtr<MG_State::GLState::ProgramObject>& TryToGetProgramForInterfaceQuery(GLuint program,
const char* caller) {
static const SharedPtr<MG_State::GLState::ProgramObject> nullProgramObject = nullptr;
if (!MG_State::pGLContext->ValidateProgramName(program)) {
const ErrorCode error = MG_State::pGLContext->ValidateShaderName(program)
? ErrorCode::InvalidOperation
: ErrorCode::InvalidValue;
MG_State::pGLContext->RecordError(
error,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
std::to_string(program) + " is not a program object."));
return nullProgramObject;
}
auto& programObject = MG_State::pGLContext->GetProgramObject(program);
if (!programObject) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
std::to_string(program) + " is not a program object."));
return nullProgramObject;
}
return programObject;
}
static bool IsProgramInterfaceEnum(GLenum programInterface) {
return ProgramInterface::IsInterfaceEnum(programInterface);
}
static bool IsSubroutineUniformInterface(GLenum programInterface) {
@@ -156,11 +163,16 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_ACTIVE_RESOURCES:
break;
case GL_MAX_NAME_LENGTH:
valid = valid && programInterface != GL_ATOMIC_COUNTER_BUFFER;
// Neither buffer interface has resource names. GL_TRANSFORM_FEEDBACK_BUFFER only
// became reachable here when IsInterfaceEnum grew the GL 4.4 interfaces, so it
// needs the same exclusion GL_ATOMIC_COUNTER_BUFFER already had.
valid = valid && programInterface != GL_ATOMIC_COUNTER_BUFFER &&
programInterface != GL_TRANSFORM_FEEDBACK_BUFFER;
break;
case GL_MAX_NUM_ACTIVE_VARIABLES:
valid = programInterface == GL_UNIFORM_BLOCK || programInterface == GL_ATOMIC_COUNTER_BUFFER ||
programInterface == GL_SHADER_STORAGE_BLOCK;
programInterface == GL_SHADER_STORAGE_BLOCK ||
programInterface == GL_TRANSFORM_FEEDBACK_BUFFER;
break;
case GL_MAX_NUM_COMPATIBLE_SUBROUTINES:
valid = IsSubroutineUniformInterface(programInterface);
@@ -179,7 +191,7 @@ namespace MobileGL::MG_Impl::GLImpl {
}
static bool ValidateNamedProgramResourceInterface(GLenum programInterface, const char* caller) {
if (!IsProgramInterfaceEnum(programInterface) || programInterface == GL_ATOMIC_COUNTER_BUFFER) {
if (!ProgramInterface::IsNamedInterface(programInterface)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
@@ -189,66 +201,6 @@ namespace MobileGL::MG_Impl::GLImpl {
return true;
}
static Int GetKnownProgramResourceCount(const SharedPtr<MG_State::GLState::ProgramObject>& programObject,
GLenum programInterface) {
switch (programInterface) {
case GL_UNIFORM:
return programObject->GetUniformCount();
case GL_UNIFORM_BLOCK:
return programObject->GetActiveUniformBlocksCount();
case GL_PROGRAM_INPUT:
return programObject->GetActiveAttributesCount();
case GL_PROGRAM_OUTPUT:
return programObject->GetActiveFragmentOutputCount();
default:
return -1;
}
}
// The GL_UNIFORM interface and glGetActiveUniform(s)iv are the same query in two
// spellings, so they answer from the same place - the frontend reflection. The backend
// program is not that place: it does not exist at all for a program whose types its
// shading language cannot express (a double-precision uniform has no ESSL form), and
// the interface queries would then describe a program with no uniforms.
//
// Writes the GL_UNIFORM value of `prop` for active uniform `index`; false for a prop
// the reflection does not model, which the caller forwards to the backend instead.
Bool GetUniformResourceProp(const SharedPtr<MG_State::GLState::ProgramObject>& programObject, Uint index,
GLenum prop, GLint* out) {
switch (prop) {
case GL_TYPE:
*out = static_cast<GLint>(programObject->GetActiveUniformType(index));
return true;
case GL_ARRAY_SIZE:
*out = programObject->GetActiveUniformArraySize(index);
return true;
case GL_NAME_LENGTH:
*out = static_cast<GLint>(programObject->GetActiveUniformName(index).length() + 1);
return true;
case GL_BLOCK_INDEX:
*out = programObject->GetActiveUniformBlockIndex(index);
return true;
case GL_OFFSET:
*out = programObject->GetActiveUniformOffset(index);
return true;
case GL_ARRAY_STRIDE:
*out = programObject->GetActiveUniformArrayStride(index);
return true;
case GL_MATRIX_STRIDE:
*out = programObject->GetActiveUniformMatrixStride(index);
return true;
case GL_IS_ROW_MAJOR:
*out = programObject->GetActiveUniformIsRowMajor(index);
return true;
case GL_LOCATION:
// A block member has no location; GetUniformLocation already reports -1 for one.
*out = programObject->GetUniformLocation(programObject->GetActiveUniformName(index));
return true;
default:
return false;
}
}
void CopyStr(GLsizei bufSize, GLsizei* length, GLchar* dst, const char* src, GLsizei srcLength) {
if (bufSize <= 0) {
if (length) *length = 0;
@@ -355,19 +307,72 @@ namespace MobileGL::MG_Impl::GLImpl {
shaderObject->Compile();
}
// glMaxShaderCompilerThreadsKHR / glMaxShaderCompilerThreadsARB - one implementation,
// because GL_KHR_parallel_shader_compile and GL_ARB_parallel_shader_compile define the
// same entry point with the same semantics and GetProcAddress.cpp maps both spellings.
//
// The three cases the extension defines, and what each means here:
//
// count == 0 "no compiler threads": compilation must happen on the
// application's thread. Everything already in flight is joined
// first, so that after this call returns NOTHING is outstanding
// and every GL_COMPLETION_STATUS_KHR reads GL_TRUE - which is
// the observable the extension actually specifies. The pool
// keeps its worker threads (this is not teardown); what changes
// is that AsyncShaderCompileActive() now says no, so
// glCompileShader/glLinkProgram run their bodies inline.
// count == 0xFFFFFFFF "implementation maximum": the pool's full thread count.
// otherwise a concurrency budget, clamped to the thread count - asking for
// more threads than exist cannot conjure any.
//
// A nonzero count is also what LIFTS a previous zero: the suspension lasts exactly until
// the application asks for threads again, and nothing else re-arms it (no implicit
// restore at eglInitialize, at a context switch or at a join). An application that turned
// compiler threads off keeps them off until it says otherwise.
//
// Legal - and a no-op beyond bookkeeping - while MOBILEGL_ASYNC_SHADER_COMPILE is off:
// compilation is already inline, and the call must not fail just because MobileGL had
// nothing to suspend.
void MaxShaderCompilerThreadsKHR_State(GLuint count) {
namespace Async = MG_Util::Async;
if (count == 0) {
MGLOG_D("%s: count = 0; joining all pending shader work and compiling inline", __func__);
Async::SetAsyncShaderCompileSuspended(true);
// Suspend BEFORE joining, not after. The post-condition this call owes the
// application is "nothing is in flight when I return", and only this order
// guarantees it: with the latch already set, anything the join itself causes to
// be compiled runs inline and is therefore already settled when the join ends.
// Joining first would leave a window in which a fresh enqueue is still legal.
if (MG_State::pGLContext) MG_State::pGLContext->JoinAllPendingShaderWork();
return;
}
Async::ShaderCompilePool& pool = Async::ShaderCompilePool::Get();
const Uint threadCount = pool.GetThreadCount();
const Uint requested = count == 0xFFFFFFFFu ? threadCount : std::min<Uint>(count, threadCount);
pool.SetMaxConcurrency(requested);
Async::SetAsyncShaderCompileSuspended(false);
MGLOG_D("%s: count = %u; concurrency = %u of %u threads", __func__, count, requested, threadCount);
}
GLuint CreateProgram_State() {
return MG_State::pGLContext->CreateProgram();
}
GLuint CreateShader_State(GLenum type) {
auto shaderId = MG_State::pGLContext->CreateShader(MG_Util::ConvertGLEnumToShaderStage(type));
if (shaderId == 0) {
// GL 4.6 core 7.1: shaderType is an enum, so an unrecognised one is INVALID_ENUM (it
// used to be documented as INVALID_VALUE). The check has to happen HERE: the state
// layer hands out a name for ShaderStage::Unknown just as happily as for a real
// stage, so the old "shaderId == 0 means bad type" test could never fire and an
// unknown shaderType silently produced a usable shader name and no error at all.
const ShaderStage stage = MG_Util::ConvertGLEnumToShaderStage(type);
if (stage == ShaderStage::Unknown) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "`shaderType` is not an accepted value."));
return 0;
}
return shaderId;
return MG_State::pGLContext->CreateShader(stage);
}
void DeleteProgram_State(GLuint program) {
@@ -693,6 +698,19 @@ namespace MobileGL::MG_Impl::GLImpl {
break;
}
// GL_KHR_parallel_shader_compile. THIS CASE MUST NOT JOIN - it is the one program
// query whose entire purpose is to answer without waiting, and routing it through
// any of ProgramObject's Artifacts() accessors (the join gate, invariant I5) would
// block the caller and make the extension a lie: an application polling it would
// serialize itself on the very link it is trying to overlap. IsLinkComplete() is the
// node-direct reader that exists for exactly this.
//
// No link at all reads GL_TRUE, which is what the extension requires: the query
// means "is anything still outstanding", not "has this program ever been linked".
case GL_COMPLETION_STATUS_KHR:
*params = programObject->IsLinkComplete() ? GL_TRUE : GL_FALSE;
break;
case GL_PROGRAM_BINARY_LENGTH:
// No program binary format is exposed, so a program never has a retrievable
// binary and its length is zero (ARB_get_program_binary).
@@ -746,6 +764,13 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_SHADER_SOURCE_LENGTH:
*params = shaderObject->GetShaderSource().empty() ? 0 : (GLint)shaderObject->GetShaderSource().length() + 1;
break;
// GL_KHR_parallel_shader_compile. THIS CASE MUST NOT JOIN - see the identical case in
// GetProgramiv_State. GL_COMPILE_STATUS two cases up deliberately DOES join (it has
// to: it reports the outcome); this one reports whether there is an outcome yet, and
// reading it through Compiled() would defeat the whole extension.
case GL_COMPLETION_STATUS_KHR:
*params = shaderObject->IsCompileComplete() ? GL_TRUE : GL_FALSE;
break;
default:
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
@@ -786,6 +811,31 @@ namespace MobileGL::MG_Impl::GLImpl {
return loc;
}
// A float matrix lives in the global UBO under std140 rules - one 16-byte-aligned column
// vector per column - while the value glGetUniform* must return is tightly packed
// columns * rows floats. Only mat4 is the same either way; every other shape needs the
// padding undone, and the readback has to undo exactly what UniformMatrixfv_Object put
// there. Returns false when `ttype` is not a float matrix (nothing to unpack).
Bool TryGatherFloatMatrixColumns(const glslang::TType* ttype, const char* pBase, void* params) {
if (ttype == nullptr || !ttype->isMatrix() || ttype->getBasicType() == glslang::EbtDouble) return false;
const Int columns = ttype->getMatrixCols();
const Int rows = ttype->getMatrixRows();
for (Int column = 0; column < columns; ++column) {
Memcpy(static_cast<char*>(params) + static_cast<SizeT>(column) * rows * sizeof(GLfloat),
pBase + static_cast<SizeT>(column) * 4 * sizeof(GLfloat), rows * sizeof(GLfloat));
}
return true;
}
// Bytes a uniform actually occupies in the global UBO. It is the tight GL type size for
// everything except a float matrix, whose padded columns make it wider.
SizeT UniformStorageSpanInBytes(const glslang::TType* ttype, SizeT tightSize) {
if (ttype != nullptr && ttype->isMatrix() && ttype->getBasicType() != glslang::EbtDouble) {
return static_cast<SizeT>(ttype->getMatrixCols()) * 4 * sizeof(GLfloat);
}
return tightSize;
}
void GetUniform_State(GLuint program, GLint location, void* params) {
auto& programObject = TryToGetProgramObject(program);
if (!programObject) return;
@@ -816,23 +866,16 @@ namespace MobileGL::MG_Impl::GLImpl {
auto size = programObject->GetUniformSizesInBytes(location);
char* pUBO = (char*)programObject->MapUBO();
auto* ttype = programObject->GetUniformTType(location);
const SizeT span = UniformStorageSpanInBytes(ttype, size);
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
offset + size > programObject->GetUBOSize()) {
offset + span > programObject->GetUBOSize()) {
MGLOG_E("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
program, location);
return;
}
if (!ttype->isMatrix() || ttype->getMatrixCols() != 3)
if (!TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) {
Memcpy(params, pUBO + offset, size);
else {
// TODO: we only deal with mat3 yet, deal with other types later
// assuming float here, which may not be the case
auto* pBase = pUBO + offset;
for (int i = 0; i < ttype->getMatrixRows(); i++) {
Memcpy((char*)params + ttype->getMatrixCols() * sizeof(float) * i, pBase + 4 * sizeof(float) * i,
ttype->getMatrixCols() * sizeof(float));
}
}
}
// TODO: handle 1i variant as texture unit
@@ -871,23 +914,16 @@ namespace MobileGL::MG_Impl::GLImpl {
auto size = programObject->GetUniformSizesInBytes(location);
char* pUBO = static_cast<char*>(programObject->MapUBO());
auto* ttype = programObject->GetUniformTType(location);
const SizeT span = UniformStorageSpanInBytes(ttype, size);
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
offset + size > programObject->GetUBOSize()) {
offset + span > programObject->GetUBOSize()) {
MGLOG_E("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
program, location);
return;
}
if constexpr (std::is_same_v<T, GLfloat>) {
if (ttype->getBasicType() != glslang::EbtDouble && ttype->isMatrix() &&
ttype->getMatrixCols() == 3) {
auto* pBase = pUBO + offset;
for (int i = 0; i < ttype->getMatrixRows(); i++) {
Memcpy(reinterpret_cast<char*>(params) + ttype->getMatrixCols() * sizeof(GLfloat) * i,
pBase + 4 * sizeof(GLfloat) * i, ttype->getMatrixCols() * sizeof(GLfloat));
}
return;
}
if (TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) return;
}
// A double-precision uniform is the one case where the stored component type can
@@ -1188,6 +1224,52 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
// glUniformMatrix*fv / glProgramUniformMatrix*fv, every shape (square and non-square).
// A float matrix sits in the global UBO under std140 rules: each of its `columns`
// column vectors starts on its own 16-byte boundary no matter how many rows it has, so
// the only shape that may be written as one contiguous block is mat4. Writing a matNxM
// as N*M packed floats puts every column after the first at the wrong byte offset.
template <typename Program>
void UniformMatrixfv_Object(Program& programObject, const char* caller, GLint location, GLsizei count,
GLboolean transpose, const GLfloat* value, Int columns, Int rows,
const String& ownerDescription) {
// std140: a column vector of a float matrix is padded out to a vec4.
constexpr SizeT kColumnStride = 4 * sizeof(GLfloat);
const SizeT componentCount = static_cast<SizeT>(columns) * static_cast<SizeT>(rows);
GLfloat column[4] = {};
for (GLint matrix = 0; matrix < count; ++matrix) {
if (matrix > 0 && !programObject.UniformLocationsAliasSameUniform(location, location + matrix)) {
// GL 3.3 2.11.4: values for elements beyond the end of the uniform array
// are ignored. Never step onto a neighboring uniform's location.
break;
}
if (!programObject.IsValidUniformLocation(location + matrix)) {
RecordInvalidUniformLocationError(caller, location + matrix, ownerDescription);
return;
}
if (programObject.IsUniformOpaqueAtLocation(location + matrix)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
"Opaque uniforms cannot be set with matrix Uniform calls."));
return;
}
if (value == nullptr) return;
const GLfloat* source = value + static_cast<SizeT>(matrix) * componentCount;
for (Int c = 0; c < columns; ++c) {
for (Int r = 0; r < rows; ++r) {
column[r] = transpose == GL_TRUE ? source[r * columns + c] : source[c * rows + r];
}
const SizeT byteOffset = static_cast<SizeT>(c) * kColumnStride;
switch (rows) {
case 2: Uniform_State<2>(programObject, location + matrix, column, byteOffset); break;
case 3: Uniform_State<3>(programObject, location + matrix, column, byteOffset); break;
default: Uniform_State<4>(programObject, location + matrix, column, byteOffset); break;
}
}
}
}
// Helper function to transpose a 2x2 matrix
void TransposeMatrix2x2(const GLfloat* input, GLfloat* output) {
// Input matrix is in column-major order (OpenGL default)
@@ -1293,8 +1375,8 @@ namespace MobileGL::MG_Impl::GLImpl {
}
void UniformMatrix2fv_State(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
// For 2x2 matrices, we have 4 elements per matrix
// If transpose is GL_TRUE, we need to transpose the matrix data
// A mat2 is NOT four contiguous floats in the global UBO: std140 pads each column
// vector out to 16 bytes, so column 1 starts at byte 16, not byte 8.
if (location == -1) return;
auto& programObject = MG_State::pGLContext->GetProgramForUniform();
@@ -1305,26 +1387,8 @@ namespace MobileGL::MG_Impl::GLImpl {
return;
}
// For matrix uniforms, we handle each matrix individually
for (GLint i = 0; i < count; i++) {
if (i > 0 && !programObject->UniformLocationsAliasSameUniform(location, location + i)) {
// Values for elements beyond the end of the uniform array are ignored.
break;
}
if (!programObject->IsValidUniformLocation(location + i)) {
RecordInvalidUniformLocationError(__func__, location + i, "the current program object");
return;
}
if (transpose == GL_TRUE) {
// Transpose the matrix before uploading
GLfloat transposedMatrix[4];
TransposeMatrix2x2(value + i * 4, transposedMatrix);
Uniform_State<4>(*programObject, location + i, transposedMatrix);
} else {
// No transpose needed, directly copy the matrix data
Uniform_State<4>(*programObject, location + i, value + i * 4);
}
}
UniformMatrixfv_Object(*programObject, __func__, location, count, transpose, value, 2, 2,
"the current program object");
}
void UniformMatrix3fv_State(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
@@ -1402,7 +1466,8 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
void UniformMatrixNonSquarefv_State(const char* caller, GLint location, GLsizei count) {
void UniformMatrixNonSquarefv_State(const char* caller, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value, Int columns, Int rows) {
if (location == -1) return;
auto& programObject = MG_State::pGLContext->GetProgramForUniform();
@@ -1413,21 +1478,8 @@ namespace MobileGL::MG_Impl::GLImpl {
return;
}
for (GLint i = 0; i < count; i++) {
if (!programObject->IsValidUniformLocation(location + i)) {
RecordInvalidUniformLocationError(caller, location + i, "the current program object");
return;
}
if (programObject->IsUniformOpaqueAtLocation(location + i)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
"Opaque uniforms cannot be set with matrix Uniform calls."));
return;
}
}
// TODO: Implement non-square matrix uniform uploads for non-opaque uniforms.
UniformMatrixfv_Object(*programObject, caller, location, count, transpose, value, columns, rows,
"the current program object");
}
void ProgramUniformMatrix2fv_State(GLuint program, GLint location, GLsizei count, GLboolean transpose,
@@ -1445,23 +1497,8 @@ namespace MobileGL::MG_Impl::GLImpl {
return;
}
for (GLint i = 0; i < count; i++) {
if (i > 0 && !programObject->UniformLocationsAliasSameUniform(location, location + i)) {
// Values for elements beyond the end of the uniform array are ignored.
break;
}
if (!programObject->IsValidUniformLocation(location + i)) {
RecordInvalidUniformLocationError(__func__, location + i, "program " + std::to_string(program));
return;
}
if (transpose == GL_TRUE) {
GLfloat transposedMatrix[4];
TransposeMatrix2x2(value + i * 4, transposedMatrix);
Uniform_State<4>(*programObject, location + i, transposedMatrix);
} else {
Uniform_State<4>(*programObject, location + i, value + i * 4);
}
}
UniformMatrixfv_Object(*programObject, __func__, location, count, transpose, value, 2, 2,
"program " + std::to_string(program));
}
void ProgramUniformMatrix3fv_State(GLuint program, GLint location, GLsizei count, GLboolean transpose,
@@ -1536,7 +1573,8 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
void ProgramUniformMatrixNonSquarefv_State(const char* caller, GLuint program, GLint location, GLsizei count) {
void ProgramUniformMatrixNonSquarefv_State(const char* caller, GLuint program, GLint location, GLsizei count,
GLboolean transpose, const GLfloat* value, Int columns, Int rows) {
if (location == -1) return;
auto& programObject = TryToGetProgramObject(program);
@@ -1550,21 +1588,8 @@ namespace MobileGL::MG_Impl::GLImpl {
return;
}
for (GLint i = 0; i < count; i++) {
if (!programObject->IsValidUniformLocation(location + i)) {
RecordInvalidUniformLocationError(caller, location + i, "program " + std::to_string(program));
return;
}
if (programObject->IsUniformOpaqueAtLocation(location + i)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
"Opaque uniforms cannot be set with matrix Uniform calls."));
return;
}
}
// TODO: Implement non-square matrix uniform uploads for non-opaque uniforms.
UniformMatrixfv_Object(*programObject, caller, location, count, transpose, value, columns, rows,
"program " + std::to_string(program));
}
GLuint GetUniformBlockIndex_State(GLuint program, const GLchar* uniformBlockName) {
@@ -1832,6 +1857,16 @@ namespace MobileGL::MG_Impl::GLImpl {
void CompileShader(GLuint shader) {
CompileShader_State(shader);
}
void MaxShaderCompilerThreadsKHR(GLuint count) {
MaxShaderCompilerThreadsKHR_State(count);
}
// GL_ARB_parallel_shader_compile's spelling of the same entry point.
void MaxShaderCompilerThreadsARB(GLuint count) {
MaxShaderCompilerThreadsKHR_State(count);
}
GLuint CreateProgram(void) {
return CreateProgram_State();
}
@@ -2363,27 +2398,27 @@ namespace MobileGL::MG_Impl::GLImpl {
}
void UniformMatrix2x3fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
UniformMatrixNonSquarefv_State(__func__, location, count);
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 2, 3);
}
void UniformMatrix3x2fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
UniformMatrixNonSquarefv_State(__func__, location, count);
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 3, 2);
}
void UniformMatrix2x4fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
UniformMatrixNonSquarefv_State(__func__, location, count);
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 2, 4);
}
void UniformMatrix4x2fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
UniformMatrixNonSquarefv_State(__func__, location, count);
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 4, 2);
}
void UniformMatrix3x4fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
UniformMatrixNonSquarefv_State(__func__, location, count);
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 3, 4);
}
void UniformMatrix4x3fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
UniformMatrixNonSquarefv_State(__func__, location, count);
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 4, 3);
}
void ProgramUniform1f(GLuint program, GLint location, GLfloat v0) {
@@ -2508,32 +2543,32 @@ namespace MobileGL::MG_Impl::GLImpl {
void ProgramUniformMatrix2x3fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value) {
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 2, 3);
}
void ProgramUniformMatrix3x2fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value) {
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 3, 2);
}
void ProgramUniformMatrix2x4fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value) {
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 2, 4);
}
void ProgramUniformMatrix4x2fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value) {
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 4, 2);
}
void ProgramUniformMatrix3x4fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value) {
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 3, 4);
}
void ProgramUniformMatrix4x3fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value) {
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 4, 3);
}
GLuint GetUniformBlockIndex(GLuint program, const GLchar* uniformBlockName) {
@@ -2570,171 +2605,160 @@ namespace MobileGL::MG_Impl::GLImpl {
}
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params) {
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
if (!programObject) return;
if (!ValidateProgramInterfaceivQuery(programInterface, pname)) return;
auto getProgramInterfaceiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramInterfaceiv;
if (!getProgramInterfaceiv) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Backend does not support program interface queries."));
if (!params) return;
switch (pname) {
case GL_ACTIVE_RESOURCES:
*params = ProgramInterface::GetActiveResourceCount(*programObject, programInterface);
return;
case GL_MAX_NAME_LENGTH:
*params = ProgramInterface::GetMaxNameLength(*programObject, programInterface);
return;
case GL_MAX_NUM_ACTIVE_VARIABLES:
*params = ProgramInterface::GetMaxNumActiveVariables(*programObject, programInterface);
return;
default:
// GL_MAX_NUM_COMPATIBLE_SUBROUTINES: the subroutine interfaces are always empty
// here (glslang refuses `subroutine` when generating SPIR-V), so zero it is.
*params = 0;
return;
}
if (programInterface == GL_UNIFORM) {
if (pname == GL_ACTIVE_RESOURCES) {
*params = static_cast<GLint>(programObject->GetUniformCount());
return;
}
if (pname == GL_MAX_NAME_LENGTH) {
// Stored as the bare length; GL_MAX_NAME_LENGTH counts the terminator.
*params = programObject->GetUniformMaxLength() + 1;
return;
}
}
getProgramInterfaceiv(program, programInterface, pname, params);
}
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name) {
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
if (!programObject) return GL_INVALID_INDEX;
if (!ValidateNamedProgramResourceInterface(programInterface, __func__)) return GL_INVALID_INDEX;
if (!name) return GL_INVALID_INDEX;
if (programInterface == GL_UNIFORM) {
const Int uniformIndex = programObject->GetActiveUniformIndex(name);
return uniformIndex < 0 ? GL_INVALID_INDEX : static_cast<GLuint>(uniformIndex);
}
auto getProgramResourceIndex = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceIndex;
if (!getProgramResourceIndex) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Backend does not support program interface queries."));
return GL_INVALID_INDEX;
}
GLuint index = getProgramResourceIndex(program, programInterface, name);
const String resourceName = name;
if (index == GL_INVALID_INDEX && resourceName.length() > 3 &&
resourceName.compare(resourceName.length() - 3, 3, "[0]") == 0) {
index = getProgramResourceIndex(program, programInterface,
resourceName.substr(0, resourceName.length() - 3).c_str());
}
return index;
return ProgramInterface::GetResourceIndex(*programObject, programInterface, name);
}
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei* length,
GLchar* name) {
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
if (!programObject) return;
if (!ValidateNamedProgramResourceInterface(programInterface, __func__)) return;
const Int resourceCount = GetKnownProgramResourceCount(programObject, programInterface);
if (resourceCount >= 0 && index >= static_cast<GLuint>(resourceCount)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "index is out of range."));
return;
}
if (bufSize < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "bufSize must be non-negative."));
return;
}
if (programInterface == GL_UNIFORM) {
// Same index space GetProgramResourceIndex answers in, and the range check above
// already used it.
const String& uniformName = programObject->GetActiveUniformName(index);
CopyStr(bufSize, length, name, uniformName.c_str(), static_cast<GLsizei>(uniformName.length()));
return;
}
auto getProgramResourceName = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceName;
if (!getProgramResourceName) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Backend does not support program interface queries."));
return;
}
getProgramResourceName(program, programInterface, index, bufSize, length, name);
}
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
if (!programObject) return;
if (propCount < 0 || bufSize < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"propCount and bufSize must be non-negative."));
return;
}
if (programInterface == GL_UNIFORM) {
if (index >= programObject->GetUniformCount()) {
String resourceName;
if (!ProgramInterface::GetResourceName(*programObject, programInterface, index, resourceName)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "index is out of range."));
return;
}
if (props == nullptr || params == nullptr) return;
GLsizei written = 0;
for (GLsizei i = 0; i < propCount && written < bufSize; ++i) {
GLint value = 0;
if (!GetUniformResourceProp(programObject, index, props[i], &value)) {
// GL_ATOMIC_COUNTER_BUFFER_INDEX and the GL_REFERENCED_BY_* stage props are
// not modelled here; ask the backend, which indexes resources by name.
auto backendGetIndex = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceIndex;
auto backendGetiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceiv;
if (backendGetIndex && backendGetiv) {
const GLuint backendIndex = backendGetIndex(program, GL_UNIFORM,
programObject->GetActiveUniformName(index).c_str());
if (backendIndex != GL_INVALID_INDEX) {
GLsizei one = 0;
backendGetiv(program, GL_UNIFORM, backendIndex, 1, &props[i], 1, &one, &value);
CopyStr(bufSize, length, name, resourceName.c_str(), static_cast<GLsizei>(resourceName.length()));
}
}
}
params[written++] = value;
}
if (length) *length = written;
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
if (!programObject) return;
if (!ProgramInterface::IsInterfaceEnum(programInterface)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "Unsupported program interface."));
return;
}
auto getProgramResourceiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceiv;
if (!getProgramResourceiv) {
if (propCount <= 0 || bufSize < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"propCount must be positive and bufSize "
"non-negative."));
return;
}
if (props == nullptr) return;
// Both prop checks run BEFORE any value is produced: a property this command does
// not know at all is INVALID_ENUM, one it knows but the interface does not carry is
// INVALID_OPERATION (GL 4.6 Table 7.2). The two are deliberately different errors.
for (GLsizei i = 0; i < propCount; ++i) {
if (!ProgramInterface::IsResourceProp(props[i])) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "prop is not a valid property name."));
return;
}
if (!ProgramInterface::InterfaceSupportsProp(programInterface, props[i])) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Backend does not support program interface queries."));
"prop is not supported for this program interface."));
return;
}
getProgramResourceiv(program, programInterface, index, propCount, props, bufSize, length, params);
}
Vector<GLint> values;
for (GLsizei i = 0; i < propCount; ++i) {
if (!ProgramInterface::GetResourceProp(*programObject, programInterface, index, props[i], values)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "index is out of range."));
return;
}
}
if (params == nullptr) return;
const GLsizei written = static_cast<GLsizei>(std::min<SizeT>(values.size(), static_cast<SizeT>(bufSize)));
for (GLsizei i = 0; i < written; ++i) params[i] = values[i];
if (length) *length = written;
}
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name) {
// Unlike the four queries above, this one and GetProgramResourceLocationIndex really
// do require a successful link (GL 4.6 §7.3.1.3).
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
if (!programObject) return -1;
auto getProgramResourceLocation = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceLocation;
if (!getProgramResourceLocation) {
if (!ProgramInterface::InterfaceHasLocations(programInterface)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Backend does not support program interface queries."));
"Program interface has no locations."));
return -1;
}
return getProgramResourceLocation(program, programInterface, name);
return ProgramInterface::GetResourceLocation(*programObject, programInterface, name);
}
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name) {
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
if (!programObject) return -1;
auto getProgramResourceLocationIndex = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceLocationIndex;
if (!getProgramResourceLocationIndex) return -1;
return getProgramResourceLocationIndex(program, programInterface, name);
if (programInterface != GL_PROGRAM_OUTPUT) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"GetProgramResourceLocationIndex only accepts GL_PROGRAM_OUTPUT."));
return -1;
}
return ProgramInterface::GetResourceLocationIndex(*programObject, programInterface, name);
}
// GL 4.6 §7.6.2: <storageBlockIndex> is an active shader storage block index of <program>
// - that is, exactly what glGetProgramResourceIndex(GL_SHADER_STORAGE_BLOCK) returned.
// Since wave 2 that index is the interface-query layer's, so this is where the one index
// space the application sees gets turned into whatever the backend's is; the backends are
// handed the block NAME and do their own lookup. Getting this wrong is silent: the call
// succeeds and rebinds a DIFFERENT buffer.
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding) {
auto& programObject = TryToGetProgramObject(program);
if (!programObject || !programObject->GetLinkStatus()) return;
if (!ValidateShaderStorageBlockBinding(storageBlockBinding)) return;
String blockName;
if (!ProgramInterface::GetResourceName(*programObject, GL_SHADER_STORAGE_BLOCK, storageBlockIndex,
blockName)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"storageBlockIndex is not an active shader storage block index."));
return;
}
// Recorded before the backend call, and independently of whether a backend is even
// present: this is the state GL_BUFFER_BINDING reports, and it is also what reseeds a
// backend's own reflection cache after any rebuild.
programObject->SetShaderStorageBlockBinding(blockName, storageBlockBinding);
auto shaderStorageBlockBinding = MG_Backend::gBackendFunctionsTable.GL.ShaderStorageBlockBinding;
if (!shaderStorageBlockBinding) {
MG_State::pGLContext->RecordError(
@@ -2743,7 +2767,7 @@ namespace MobileGL::MG_Impl::GLImpl {
"Backend does not support shader storage block binding."));
return;
}
shaderStorageBlockBinding(program, storageBlockIndex, storageBlockBinding);
shaderStorageBlockBinding(program, blockName.c_str(), storageBlockBinding);
}
void ValidateProgram(GLuint program) {
@@ -2780,6 +2804,16 @@ namespace MobileGL::MG_Impl::GLImpl {
// is written as that sequence rather than as a private shortcut - every error it can
// raise is one of theirs, raised at the point they would raise it.
GLuint CreateShaderProgramv(GLenum type, GLsizei count, const GLchar* const* strings) {
// GL 4.6 core 7.3: a negative count is INVALID_VALUE and is checked before anything
// is created, so a bad count never leaks a shader name. An unrecognised type is
// INVALID_ENUM, which CreateShader_State raises below.
if (count < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "count must be non-negative."));
return 0;
}
const GLuint shader = CreateShader_State(type);
if (shader == 0) return 0;
@@ -42,6 +42,10 @@ namespace MobileGL::MG_Impl::GLImpl {
GLboolean IsProgram(GLuint program);
GLboolean IsShader(GLuint shader);
void LinkProgram(GLuint program);
// GL_KHR_parallel_shader_compile / GL_ARB_parallel_shader_compile. Both names are the
// same entry point; see MaxShaderCompilerThreadsKHR_State for the semantics of count.
void MaxShaderCompilerThreadsKHR(GLuint count);
void MaxShaderCompilerThreadsARB(GLuint count);
void ShaderSource(GLuint shader, GLsizei count, const GLchar* const* string, const GLint* length);
void UseProgram(GLuint program);
void Uniform1f(GLint location, GLfloat v0);
@@ -0,0 +1,841 @@
// MobileGL - MobileGL/MG_Impl/GLImpl/Program/ProgramInterface.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 "ProgramInterface.h"
#include <MG_State/GLState/ProgramState/ProgramObject.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <cstring>
namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
namespace {
// glslang folds atomic counters into synthesized blocks named
// "<getAtomicCounterBlockName()>_<binding>" (ParseContextBase.cpp), one per GL
// atomic-counter binding point. That block IS the GL_ATOMIC_COUNTER_BUFFER resource
// and its trailing number IS GL_BUFFER_BINDING; its members stay GL_UNIFORMs.
constexpr const char* kAtomicCounterBlockPrefix = "gl_AtomicCounterBlock";
enum class BlockKind {
Uniform, // a real GL uniform block
GlobalUbo, // the synthesized MGL_GLOBAL_UBO: GL sees its members as default-block
AtomicCounter, // gl_AtomicCounterBlock_<binding>
Storage, // a shader storage block
};
// One row of any interface. Fields a given interface does not have keep the
// spec-mandated "not applicable" value, so a prop read never has to special-case
// the interface a second time.
struct Resource {
String name;
GLenum type = GL_NONE;
GLint arraySize = 1;
GLint location = -1;
GLint locationIndex = -1;
GLint blockIndex = -1;
GLint offset = -1;
GLint arrayStride = -1;
GLint matrixStride = -1;
GLint isRowMajor = 0;
GLint atomicCounterBufferIndex = -1;
GLint topLevelArraySize = 0;
GLint topLevelArrayStride = 0;
GLint bufferBinding = 0;
GLint bufferDataSize = 0;
GLint isPerPatch = 0;
GLint xfbBufferIndex = 0;
Uint32 stages = 0; // EShLanguageMask
Vector<GLuint> activeVariables;
};
using ResourceList = Vector<Resource>;
struct Model {
ResourceList uniforms;
ResourceList uniformBlocks;
ResourceList atomicCounterBuffers;
ResourceList bufferVariables;
ResourceList storageBlocks;
ResourceList programInputs;
ResourceList programOutputs;
ResourceList xfbVaryings;
Bool valid = false;
};
const ResourceList& EmptyList() {
static const ResourceList empty;
return empty;
}
// ---- name spelling (cluster 6) -------------------------------------------------
Bool EndsWithZeroSubscript(const String& name) {
return name.length() >= 3 && name.compare(name.length() - 3, 3, "[0]") == 0;
}
// The enumerated spelling of an array resource is "name[0]". glslang already applies
// that to uniforms and buffer variables (EShReflectionBasicArraySuffix), but never to
// stage inputs/outputs, so those get it here.
String WithArraySuffix(const String& name, const glslang::TType* type) {
if (type == nullptr || !type->isArray() || EndsWithZeroSubscript(name)) return name;
return name + "[0]";
}
// GL_ARRAY_SIZE: element count for a sized array, 0 for a runtime-sized one
// (a shader storage block's unsized trailing member), 1 for a non-array.
GLint ArraySizeOf(const glslang::TType* type, GLint reflectedSize) {
if (type != nullptr && type->isArray()) {
if (!type->isSizedArray()) return 0;
return type->getOuterArraySize();
}
return reflectedSize < 1 ? 1 : reflectedSize;
}
// Two spellings name the same resource when they are equal, or differ only by the
// "[0]" the enumeration appends to an array.
Bool NamesMatch(const String& resourceName, const String& query) {
if (resourceName == query) return true;
if (EndsWithZeroSubscript(resourceName) &&
resourceName.compare(0, resourceName.length() - 3, query) == 0) {
return true;
}
return EndsWithZeroSubscript(query) && query.compare(0, query.length() - 3, resourceName) == 0;
}
// Splits "base[k]" into ("base", k). GL 4.6 §7.3.1.1 requires the subscript to be a
// decimal integer with no white space and no leading zeros, which is exactly what
// separates array-names' "a[1]" (resolves) from "a[01]", "a[0 + 0]" and "a[ 0]" (do
// not). Returns false when there is no trailing subscript at all; sets `malformed`
// when there is one but it is not a strict decimal.
Bool SplitTrailingSubscript(const String& name, String& outBase, Uint& outElement, Bool& outMalformed) {
outMalformed = false;
if (name.empty() || name.back() != ']') return false;
const SizeT bracket = name.rfind('[');
if (bracket == String::npos) return false;
const SizeT first = bracket + 1;
const SizeT last = name.length() - 1; // one past the digits
if (first >= last) {
outMalformed = true;
return false;
}
// No leading zeros: "0" is the only spelling that may start with '0'.
if (name[first] == '0' && last - first > 1) {
outMalformed = true;
return false;
}
Uint element = 0;
for (SizeT i = first; i < last; ++i) {
if (name[i] < '0' || name[i] > '9') {
outMalformed = true;
return false;
}
element = element * 10 + static_cast<Uint>(name[i] - '0');
if (element > 0x0FFFFFFFu) {
outMalformed = true;
return false;
}
}
outBase = name.substr(0, bracket);
outElement = element;
return true;
}
// ---- block classification ------------------------------------------------------
Bool IsAtomicCounterBlockName(const String& name) {
return name.compare(0, std::strlen(kAtomicCounterBlockPrefix), kAtomicCounterBlockPrefix) == 0;
}
// "gl_AtomicCounterBlock_5" -> 5. The suffix is the GL binding the counters were
// declared with, which glslang does NOT keep in the block's own layout qualifier
// (that one is remapped to a plain buffer binding).
GLint AtomicCounterBlockBinding(const String& name) {
const SizeT underscore = name.rfind('_');
if (underscore == String::npos || underscore + 1 >= name.length()) return 0;
GLint binding = 0;
for (SizeT i = underscore + 1; i < name.length(); ++i) {
if (name[i] < '0' || name[i] > '9') return 0;
binding = binding * 10 + (name[i] - '0');
}
return binding;
}
// Element index of an arrayed block instance ("TrickyBuffer[1]" -> 1).
GLint BlockArrayElement(const String& name) {
String base;
Uint element = 0;
Bool malformed = false;
if (!SplitTrailingSubscript(name, base, element, malformed)) return 0;
return static_cast<GLint>(element);
}
BlockKind ClassifyBlock(const glslang::TObjectReflection& block) {
if (std::strstr(block.name.c_str(), MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) != nullptr) {
return BlockKind::GlobalUbo;
}
if (IsAtomicCounterBlockName(block.name)) return BlockKind::AtomicCounter;
const glslang::TType* type = block.getType();
if (type != nullptr && type->getQualifier().storage == glslang::EvqBuffer) return BlockKind::Storage;
return BlockKind::Uniform;
}
// std140/std430 column stride, the same vec4-rounded rule ProgramObject applies to
// uniform matrices. 0 for a non-matrix.
GLint MatrixStrideOf(const glslang::TType* type) {
if (type == nullptr || !type->isMatrix()) return 0;
const bool rowMajor = type->getQualifier().layoutMatrix == glslang::ElmRowMajor;
const int strideVectorComponents = rowMajor ? type->getMatrixCols() : type->getMatrixRows();
constexpr int scalarSize = 4;
const int vectorAlignment = (strideVectorComponents <= 1) ? scalarSize
: (strideVectorComponents == 2) ? 2 * scalarSize
: 4 * scalarSize;
return (vectorAlignment + 15) & ~15;
}
GLint IsRowMajorOf(const glslang::TType* type) {
if (type == nullptr || !type->isMatrix()) return 0;
return type->getQualifier().layoutMatrix == glslang::ElmRowMajor ? 1 : 0;
}
GLint MappedLocation(Int rawLocation) {
// glslang parks "no location" at layoutLocationEnd; GL spells it -1.
if (rawLocation < 0 || rawLocation >= static_cast<Int>(glslang::TQualifier::layoutLocationEnd)) return -1;
return rawLocation;
}
// ---- model construction --------------------------------------------------------
void BuildBlocks(ProgramObject& program, const glslang::TProgram& reflection, Model& model,
Vector<BlockKind>& blockKind, Vector<Int>& blockInterfaceIndex) {
const Int blockCount = const_cast<glslang::TProgram&>(reflection).getNumUniformBlocks();
blockKind.assign(blockCount, BlockKind::Uniform);
blockInterfaceIndex.assign(blockCount, -1);
for (Int tIndex = 0; tIndex < blockCount; ++tIndex) {
const auto& block = const_cast<glslang::TProgram&>(reflection).getUniformBlock(tIndex);
const BlockKind kind = ClassifyBlock(block);
blockKind[tIndex] = kind;
if (kind == BlockKind::AtomicCounter) {
Resource resource;
// GL_ATOMIC_COUNTER_BUFFER resources have no name (and GetProgramResource
// Index/Name reject the interface outright, which is why this stays empty).
resource.bufferBinding = AtomicCounterBlockBinding(block.name);
resource.bufferDataSize = block.size;
resource.stages = static_cast<Uint32>(block.stages);
blockInterfaceIndex[tIndex] = static_cast<Int>(model.atomicCounterBuffers.size());
model.atomicCounterBuffers.push_back(Move(resource));
} else if (kind == BlockKind::Storage) {
Resource resource;
resource.name = block.name;
// glslang reports the DECLARED binding for every instance of an arrayed
// block; GL gives element k the binding base + k. That is only the initial
// value: GL_BUFFER_BINDING must report the CURRENT binding, so a later
// glShaderStorageBlockBinding wins over the declaration (GL 4.6 §7.6.2 -
// exactly the same rule GL_UNIFORM_BLOCK follows through
// GetUniformBlockBinding below).
const GLint declared = block.getBinding();
resource.bufferBinding = declared < 0 ? 0 : declared + BlockArrayElement(block.name);
const Int rebound = program.GetShaderStorageBlockBindingOverride(block.name);
if (rebound >= 0) resource.bufferBinding = static_cast<GLint>(rebound);
resource.bufferDataSize = block.size;
resource.stages = static_cast<Uint32>(block.stages);
blockInterfaceIndex[tIndex] = static_cast<Int>(model.storageBlocks.size());
model.storageBlocks.push_back(Move(resource));
}
}
// GL_UNIFORM_BLOCK keeps the index space glUniformBlockBinding and
// glGetActiveUniformBlockiv already use, so an index handed out here is usable
// with them (which is exactly what the CTS does).
const Int glBlockCount = program.GetActiveUniformBlocksCount();
for (Int glIndex = 0; glIndex < glBlockCount; ++glIndex) {
Resource resource;
resource.name = program.GetUniformBlockName(glIndex);
resource.bufferBinding = static_cast<GLint>(program.GetUniformBlockBinding(glIndex));
resource.bufferDataSize = static_cast<GLint>(program.GetUBOSizeAt(glIndex));
const Int tIndex = program.TProgramBlockIndex(static_cast<Uint>(glIndex));
if (tIndex >= 0 && tIndex < blockCount) {
resource.stages =
static_cast<Uint32>(const_cast<glslang::TProgram&>(reflection).getUniformBlock(tIndex).stages);
}
model.uniformBlocks.push_back(Move(resource));
}
}
void BuildUniformsAndBufferVariables(ProgramObject& program, const glslang::TProgram& reflection, Model& model,
const Vector<BlockKind>& blockKind,
const Vector<Int>& blockInterfaceIndex) {
const Uint uniformCount = program.GetUniformCount();
for (Uint glIndex = 0; glIndex < uniformCount; ++glIndex) {
const Int tIndex = program.TProgramUniformIndex(glIndex);
const auto& refl = const_cast<glslang::TProgram&>(reflection).getUniform(tIndex);
const glslang::TType* type = refl.getType();
const Int owner = refl.index;
const BlockKind kind = (owner >= 0 && owner < static_cast<Int>(blockKind.size()))
? blockKind[owner]
: BlockKind::GlobalUbo;
Resource resource;
resource.name = refl.name;
resource.type = static_cast<GLenum>(refl.glDefineType);
resource.arraySize = ArraySizeOf(type, refl.size);
resource.stages = static_cast<Uint32>(refl.stages);
if (kind == BlockKind::Storage) {
resource.blockIndex = blockInterfaceIndex[owner];
resource.offset = refl.offset;
resource.arrayStride = refl.arrayStride;
resource.matrixStride = MatrixStrideOf(type);
resource.isRowMajor = IsRowMajorOf(type);
// GL requires 1 for a member that is not inside a top-level array (and for
// the top-level array itself); glslang leaves 0/-1 there.
resource.topLevelArraySize = refl.topLevelArraySize > 0 ? refl.topLevelArraySize : 1;
resource.topLevelArrayStride = refl.topLevelArrayStride;
model.bufferVariables.push_back(Move(resource));
continue;
}
if (kind == BlockKind::AtomicCounter) {
// An atomic counter is a default-block uniform with no location and no
// owning uniform block; what it does have is a buffer to point at.
resource.type = GL_UNSIGNED_INT_ATOMIC_COUNTER;
resource.blockIndex = -1;
resource.offset = refl.offset;
resource.arrayStride = refl.arrayStride;
resource.matrixStride = 0;
resource.atomicCounterBufferIndex = blockInterfaceIndex[owner];
resource.location = -1;
} else {
resource.blockIndex = program.GetActiveUniformBlockIndex(glIndex);
resource.offset = program.GetActiveUniformOffset(glIndex);
resource.arrayStride = program.GetActiveUniformArrayStride(glIndex);
resource.matrixStride = program.GetActiveUniformMatrixStride(glIndex);
resource.isRowMajor = program.GetActiveUniformIsRowMajor(glIndex);
// A member of a named uniform block has no location, whatever the
// frontend's own location table says (it hands one out to every uniform
// so glUniform* can address block members through the global UBO).
resource.location =
resource.blockIndex >= 0 ? -1 : program.GetUniformLocation(refl.name);
}
model.uniforms.push_back(Move(resource));
}
// GL_ACTIVE_VARIABLES, both directions.
for (SizeT i = 0; i < model.uniforms.size(); ++i) {
const Resource& uniform = model.uniforms[i];
if (uniform.atomicCounterBufferIndex >= 0 &&
uniform.atomicCounterBufferIndex < static_cast<GLint>(model.atomicCounterBuffers.size())) {
model.atomicCounterBuffers[uniform.atomicCounterBufferIndex].activeVariables.push_back(
static_cast<GLuint>(i));
}
}
for (SizeT blockIndex = 0; blockIndex < model.uniformBlocks.size(); ++blockIndex) {
// Members of an arrayed block are reflected once, against instance [0].
const Int owner = static_cast<Int>(program.GetUniformBlockMemberOwnerIndex(static_cast<Uint>(blockIndex)));
for (SizeT i = 0; i < model.uniforms.size(); ++i) {
if (model.uniforms[i].blockIndex == owner) {
model.uniformBlocks[blockIndex].activeVariables.push_back(static_cast<GLuint>(i));
}
}
}
for (SizeT blockIndex = 0; blockIndex < model.storageBlocks.size(); ++blockIndex) {
for (SizeT i = 0; i < model.bufferVariables.size(); ++i) {
if (model.bufferVariables[i].blockIndex == static_cast<GLint>(blockIndex)) {
model.storageBlocks[blockIndex].activeVariables.push_back(static_cast<GLuint>(i));
}
}
}
}
void BuildStageIO(ProgramObject& program, const glslang::TProgram& reflection, Model& model) {
auto& mutableReflection = const_cast<glslang::TProgram&>(reflection);
const Int inputCount = mutableReflection.getNumPipeInputs();
for (Int index = 0; index < inputCount; ++index) {
const auto& refl = mutableReflection.getPipeInput(index);
const glslang::TType* type = refl.getType();
Resource resource;
// The Vulkan-semantics parse reflects the vertex builtins under their SPIR-V
// names; GL enumerates the GL spellings.
const String& glName = ProgramObject::NormalizeBuiltinPipeInputName(refl.name);
resource.name = WithArraySuffix(glName, type);
resource.type = static_cast<GLenum>(refl.glDefineType);
resource.arraySize = ArraySizeOf(type, refl.size);
resource.location = program.GetAttributeLocation(refl.name);
if (resource.location < 0) resource.location = MappedLocation(static_cast<Int>(refl.layoutLocation()));
resource.isPerPatch = (type != nullptr && type->getQualifier().patch) ? 1 : 0;
resource.stages = static_cast<Uint32>(refl.stages);
model.programInputs.push_back(Move(resource));
}
const Int outputCount = mutableReflection.getNumPipeOutputs();
for (Int index = 0; index < outputCount; ++index) {
const auto& refl = mutableReflection.getPipeOutput(index);
const glslang::TType* type = refl.getType();
Resource resource;
resource.name = WithArraySuffix(refl.name, type);
resource.type = static_cast<GLenum>(refl.glDefineType);
resource.arraySize = ArraySizeOf(type, refl.size);
resource.location = MappedLocation(program.GetFragmentDataLocation(refl.name.c_str()));
if (resource.location < 0) {
// A built-in output (gl_FragDepth, gl_SampleMask) and a non-fragment stage
// output both have no location, and therefore no color index either.
resource.locationIndex = -1;
} else {
resource.locationIndex = program.GetFragmentDataIndex(refl.name.c_str());
// glBindFragDataLocationIndexed wins; otherwise the shader's
// layout(index = N), which the frag-data maps never saw.
if (resource.locationIndex == 0 && type != nullptr && type->getQualifier().hasIndex()) {
resource.locationIndex = static_cast<GLint>(type->getQualifier().layoutIndex);
}
}
resource.isPerPatch = (type != nullptr && type->getQualifier().patch) ? 1 : 0;
resource.stages = static_cast<Uint32>(refl.stages);
model.programOutputs.push_back(Move(resource));
}
}
void BuildXfb(ProgramObject& program, Model& model) {
const auto& requested = program.GetTransformFeedbackInterfaceNames();
const auto& captured = program.GetTransformFeedbackVaryings();
for (const String& name : requested) {
Resource resource;
resource.name = name;
// ARB_transform_feedback3's layout controls are enumerated as resources of
// type NONE: gl_NextBuffer with array size 0, gl_SkipComponentsN with N.
if (name == "gl_NextBuffer") {
resource.type = GL_NONE;
resource.arraySize = 0;
} else if (name.size() == 18 && name.compare(0, 17, "gl_SkipComponents") == 0 && name[17] >= '1' &&
name[17] <= '4') {
resource.type = GL_NONE;
resource.arraySize = name[17] - '0';
} else {
resource.type = GL_NONE;
resource.arraySize = 1;
for (const auto& varying : captured) {
if (varying.name != name) continue;
resource.type = varying.type;
resource.arraySize = varying.size < 1 ? 1 : varying.size;
resource.offset = static_cast<GLint>(varying.offsetBytes);
resource.xfbBufferIndex = static_cast<GLint>(varying.bufferIndex);
break;
}
}
model.xfbVaryings.push_back(Move(resource));
}
}
Model BuildModel(ProgramObject& program) {
Model model;
if (!program.GetLinkStatus()) return model;
const glslang::TProgram* reflection = program.GetReflection();
if (reflection == nullptr) return model;
model.valid = true;
Vector<BlockKind> blockKind;
Vector<Int> blockInterfaceIndex;
BuildBlocks(program, *reflection, model, blockKind, blockInterfaceIndex);
BuildUniformsAndBufferVariables(program, *reflection, model, blockKind, blockInterfaceIndex);
BuildStageIO(program, *reflection, model);
BuildXfb(program, model);
return model;
}
const ResourceList& Select(const Model& model, GLenum programInterface) {
switch (programInterface) {
case GL_UNIFORM:
return model.uniforms;
case GL_UNIFORM_BLOCK:
return model.uniformBlocks;
case GL_ATOMIC_COUNTER_BUFFER:
return model.atomicCounterBuffers;
case GL_BUFFER_VARIABLE:
return model.bufferVariables;
case GL_SHADER_STORAGE_BLOCK:
return model.storageBlocks;
case GL_PROGRAM_INPUT:
return model.programInputs;
case GL_PROGRAM_OUTPUT:
return model.programOutputs;
case GL_TRANSFORM_FEEDBACK_VARYING:
return model.xfbVaryings;
default:
// The subroutine interfaces are accepted by the API but nothing can populate
// them: glslang refuses `subroutine` when generating SPIR-V, so a program
// using one never links. Zero active resources is the honest answer.
return EmptyList();
}
}
} // namespace
Bool IsInterfaceEnum(GLenum programInterface) {
switch (programInterface) {
case GL_UNIFORM:
case GL_UNIFORM_BLOCK:
case GL_PROGRAM_INPUT:
case GL_PROGRAM_OUTPUT:
case GL_BUFFER_VARIABLE:
case GL_SHADER_STORAGE_BLOCK:
case GL_ATOMIC_COUNTER_BUFFER:
case GL_TRANSFORM_FEEDBACK_VARYING:
case GL_TRANSFORM_FEEDBACK_BUFFER:
case GL_VERTEX_SUBROUTINE:
case GL_TESS_CONTROL_SUBROUTINE:
case GL_TESS_EVALUATION_SUBROUTINE:
case GL_GEOMETRY_SUBROUTINE:
case GL_FRAGMENT_SUBROUTINE:
case GL_COMPUTE_SUBROUTINE:
case GL_VERTEX_SUBROUTINE_UNIFORM:
case GL_TESS_CONTROL_SUBROUTINE_UNIFORM:
case GL_TESS_EVALUATION_SUBROUTINE_UNIFORM:
case GL_GEOMETRY_SUBROUTINE_UNIFORM:
case GL_FRAGMENT_SUBROUTINE_UNIFORM:
case GL_COMPUTE_SUBROUTINE_UNIFORM:
return true;
default:
return false;
}
}
Bool IsNamedInterface(GLenum programInterface) {
// GL 4.6 §7.3.1.2: the two buffer interfaces have no resource names, and asking for
// one is INVALID_ENUM (deliberately asymmetric with GetProgramInterfaceiv, which
// does count them).
return IsInterfaceEnum(programInterface) && programInterface != GL_ATOMIC_COUNTER_BUFFER &&
programInterface != GL_TRANSFORM_FEEDBACK_BUFFER;
}
Bool InterfaceHasLocations(GLenum programInterface) {
switch (programInterface) {
case GL_UNIFORM:
case GL_PROGRAM_INPUT:
case GL_PROGRAM_OUTPUT:
case GL_VERTEX_SUBROUTINE_UNIFORM:
case GL_TESS_CONTROL_SUBROUTINE_UNIFORM:
case GL_TESS_EVALUATION_SUBROUTINE_UNIFORM:
case GL_GEOMETRY_SUBROUTINE_UNIFORM:
case GL_FRAGMENT_SUBROUTINE_UNIFORM:
case GL_COMPUTE_SUBROUTINE_UNIFORM:
return true;
default:
return false;
}
}
Bool IsResourceProp(GLenum prop) {
switch (prop) {
case GL_NAME_LENGTH:
case GL_TYPE:
case GL_ARRAY_SIZE:
case GL_OFFSET:
case GL_BLOCK_INDEX:
case GL_ARRAY_STRIDE:
case GL_MATRIX_STRIDE:
case GL_IS_ROW_MAJOR:
case GL_ATOMIC_COUNTER_BUFFER_INDEX:
case GL_BUFFER_BINDING:
case GL_BUFFER_DATA_SIZE:
case GL_NUM_ACTIVE_VARIABLES:
case GL_ACTIVE_VARIABLES:
case GL_REFERENCED_BY_VERTEX_SHADER:
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
case GL_REFERENCED_BY_GEOMETRY_SHADER:
case GL_REFERENCED_BY_FRAGMENT_SHADER:
case GL_REFERENCED_BY_COMPUTE_SHADER:
case GL_TOP_LEVEL_ARRAY_SIZE:
case GL_TOP_LEVEL_ARRAY_STRIDE:
case GL_LOCATION:
case GL_LOCATION_INDEX:
case GL_IS_PER_PATCH:
case GL_LOCATION_COMPONENT:
case GL_TRANSFORM_FEEDBACK_BUFFER_INDEX:
case GL_TRANSFORM_FEEDBACK_BUFFER_STRIDE:
case GL_NUM_COMPATIBLE_SUBROUTINES:
case GL_COMPATIBLE_SUBROUTINES:
return true;
default:
return false;
}
}
// GL 4.6 Table 7.2, transcribed row by row: which interfaces each property applies to.
// Too tight a table turns a currently-answered prop into a fresh INVALID_OPERATION, so
// the rows below are deliberately no narrower than the spec's.
Bool InterfaceSupportsProp(GLenum programInterface, GLenum prop) {
const Bool isSubroutine =
programInterface == GL_VERTEX_SUBROUTINE || programInterface == GL_TESS_CONTROL_SUBROUTINE ||
programInterface == GL_TESS_EVALUATION_SUBROUTINE || programInterface == GL_GEOMETRY_SUBROUTINE ||
programInterface == GL_FRAGMENT_SUBROUTINE || programInterface == GL_COMPUTE_SUBROUTINE;
const Bool isSubroutineUniform =
programInterface == GL_VERTEX_SUBROUTINE_UNIFORM ||
programInterface == GL_TESS_CONTROL_SUBROUTINE_UNIFORM ||
programInterface == GL_TESS_EVALUATION_SUBROUTINE_UNIFORM ||
programInterface == GL_GEOMETRY_SUBROUTINE_UNIFORM ||
programInterface == GL_FRAGMENT_SUBROUTINE_UNIFORM || programInterface == GL_COMPUTE_SUBROUTINE_UNIFORM;
switch (prop) {
case GL_NAME_LENGTH:
return programInterface != GL_ATOMIC_COUNTER_BUFFER && programInterface != GL_TRANSFORM_FEEDBACK_BUFFER;
case GL_TYPE:
case GL_ARRAY_SIZE:
return programInterface == GL_UNIFORM || programInterface == GL_PROGRAM_INPUT ||
programInterface == GL_PROGRAM_OUTPUT || programInterface == GL_BUFFER_VARIABLE ||
programInterface == GL_TRANSFORM_FEEDBACK_VARYING ||
(prop == GL_ARRAY_SIZE && isSubroutineUniform);
case GL_OFFSET:
return programInterface == GL_UNIFORM || programInterface == GL_BUFFER_VARIABLE ||
programInterface == GL_TRANSFORM_FEEDBACK_VARYING;
case GL_BLOCK_INDEX:
case GL_ARRAY_STRIDE:
case GL_MATRIX_STRIDE:
case GL_IS_ROW_MAJOR:
return programInterface == GL_UNIFORM || programInterface == GL_BUFFER_VARIABLE;
case GL_ATOMIC_COUNTER_BUFFER_INDEX:
return programInterface == GL_UNIFORM;
case GL_BUFFER_BINDING:
case GL_NUM_ACTIVE_VARIABLES:
case GL_ACTIVE_VARIABLES:
// Table 7.2 lists GL_TRANSFORM_FEEDBACK_BUFFER on these three rows too. This
// implementation enumerates no resources on that interface, so the query still
// ends in an error - but INVALID_VALUE for the out-of-range index, not the
// INVALID_OPERATION a narrower table would invent.
return programInterface == GL_UNIFORM_BLOCK || programInterface == GL_ATOMIC_COUNTER_BUFFER ||
programInterface == GL_SHADER_STORAGE_BLOCK ||
programInterface == GL_TRANSFORM_FEEDBACK_BUFFER;
case GL_BUFFER_DATA_SIZE:
return programInterface == GL_UNIFORM_BLOCK || programInterface == GL_ATOMIC_COUNTER_BUFFER ||
programInterface == GL_SHADER_STORAGE_BLOCK;
case GL_REFERENCED_BY_VERTEX_SHADER:
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
case GL_REFERENCED_BY_GEOMETRY_SHADER:
case GL_REFERENCED_BY_FRAGMENT_SHADER:
case GL_REFERENCED_BY_COMPUTE_SHADER:
return programInterface == GL_UNIFORM || programInterface == GL_UNIFORM_BLOCK ||
programInterface == GL_ATOMIC_COUNTER_BUFFER || programInterface == GL_BUFFER_VARIABLE ||
programInterface == GL_SHADER_STORAGE_BLOCK || programInterface == GL_PROGRAM_INPUT ||
programInterface == GL_PROGRAM_OUTPUT || isSubroutineUniform;
case GL_TOP_LEVEL_ARRAY_SIZE:
case GL_TOP_LEVEL_ARRAY_STRIDE:
return programInterface == GL_BUFFER_VARIABLE;
case GL_LOCATION:
return InterfaceHasLocations(programInterface);
case GL_LOCATION_INDEX:
return programInterface == GL_PROGRAM_OUTPUT;
case GL_IS_PER_PATCH:
case GL_LOCATION_COMPONENT:
return programInterface == GL_PROGRAM_INPUT || programInterface == GL_PROGRAM_OUTPUT;
case GL_TRANSFORM_FEEDBACK_BUFFER_INDEX:
return programInterface == GL_TRANSFORM_FEEDBACK_VARYING;
case GL_TRANSFORM_FEEDBACK_BUFFER_STRIDE:
return programInterface == GL_TRANSFORM_FEEDBACK_BUFFER;
case GL_NUM_COMPATIBLE_SUBROUTINES:
case GL_COMPATIBLE_SUBROUTINES:
return isSubroutineUniform;
default:
(void)isSubroutine;
return false;
}
}
Int GetActiveResourceCount(ProgramObject& program, GLenum programInterface) {
const Model model = BuildModel(program);
return static_cast<Int>(Select(model, programInterface).size());
}
Int GetMaxNameLength(ProgramObject& program, GLenum programInterface) {
if (!IsNamedInterface(programInterface)) return 0;
const Model model = BuildModel(program);
SizeT longest = 0;
for (const Resource& resource : Select(model, programInterface)) {
longest = std::max(longest, resource.name.length() + 1);
}
return static_cast<Int>(longest);
}
Int GetMaxNumActiveVariables(ProgramObject& program, GLenum programInterface) {
const Model model = BuildModel(program);
SizeT longest = 0;
for (const Resource& resource : Select(model, programInterface)) {
longest = std::max(longest, resource.activeVariables.size());
}
return static_cast<Int>(longest);
}
GLuint GetResourceIndex(ProgramObject& program, GLenum programInterface, const char* name) {
if (name == nullptr || name[0] == '\0') return GL_INVALID_INDEX;
const Model model = BuildModel(program);
const ResourceList& resources = Select(model, programInterface);
const String query = name;
// The layout controls of an interleaved capture are enumerable but not addressable
// by name (GL 4.6 §7.3.1.1).
if (programInterface == GL_TRANSFORM_FEEDBACK_VARYING &&
(query == "gl_NextBuffer" ||
(query.size() == 18 && query.compare(0, 17, "gl_SkipComponents") == 0))) {
return GL_INVALID_INDEX;
}
for (SizeT i = 0; i < resources.size(); ++i) {
if (NamesMatch(resources[i].name, query)) return static_cast<GLuint>(i);
}
return GL_INVALID_INDEX;
}
Bool GetResourceName(ProgramObject& program, GLenum programInterface, GLuint index, String& outName) {
const Model model = BuildModel(program);
const ResourceList& resources = Select(model, programInterface);
if (index >= resources.size()) return false;
outName = resources[index].name;
return true;
}
Bool GetResourceProp(ProgramObject& program, GLenum programInterface, GLuint index, GLenum prop,
Vector<GLint>& outValues) {
const Model model = BuildModel(program);
const ResourceList& resources = Select(model, programInterface);
if (index >= resources.size()) return false;
const Resource& resource = resources[index];
const auto referencedBy = [&resource](EShLanguage stage) {
return (resource.stages & static_cast<Uint32>(1u << stage)) != 0 ? GL_TRUE : GL_FALSE;
};
switch (prop) {
case GL_NAME_LENGTH:
outValues.push_back(static_cast<GLint>(resource.name.length() + 1));
break;
case GL_TYPE:
outValues.push_back(static_cast<GLint>(resource.type));
break;
case GL_ARRAY_SIZE:
outValues.push_back(resource.arraySize);
break;
case GL_OFFSET:
outValues.push_back(resource.offset);
break;
case GL_BLOCK_INDEX:
outValues.push_back(resource.blockIndex);
break;
case GL_ARRAY_STRIDE:
outValues.push_back(resource.arrayStride);
break;
case GL_MATRIX_STRIDE:
outValues.push_back(resource.matrixStride);
break;
case GL_IS_ROW_MAJOR:
outValues.push_back(resource.isRowMajor);
break;
case GL_ATOMIC_COUNTER_BUFFER_INDEX:
outValues.push_back(resource.atomicCounterBufferIndex);
break;
case GL_BUFFER_BINDING:
outValues.push_back(resource.bufferBinding);
break;
case GL_BUFFER_DATA_SIZE:
outValues.push_back(resource.bufferDataSize);
break;
case GL_NUM_ACTIVE_VARIABLES:
outValues.push_back(static_cast<GLint>(resource.activeVariables.size()));
break;
case GL_ACTIVE_VARIABLES:
for (const GLuint variable : resource.activeVariables) outValues.push_back(static_cast<GLint>(variable));
break;
case GL_REFERENCED_BY_VERTEX_SHADER:
outValues.push_back(referencedBy(EShLangVertex));
break;
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
outValues.push_back(referencedBy(EShLangTessControl));
break;
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
outValues.push_back(referencedBy(EShLangTessEvaluation));
break;
case GL_REFERENCED_BY_GEOMETRY_SHADER:
outValues.push_back(referencedBy(EShLangGeometry));
break;
case GL_REFERENCED_BY_FRAGMENT_SHADER:
outValues.push_back(referencedBy(EShLangFragment));
break;
case GL_REFERENCED_BY_COMPUTE_SHADER:
outValues.push_back(referencedBy(EShLangCompute));
break;
case GL_TOP_LEVEL_ARRAY_SIZE:
outValues.push_back(resource.topLevelArraySize);
break;
case GL_TOP_LEVEL_ARRAY_STRIDE:
outValues.push_back(resource.topLevelArrayStride);
break;
case GL_LOCATION:
outValues.push_back(resource.location);
break;
case GL_LOCATION_INDEX:
outValues.push_back(resource.locationIndex);
break;
case GL_IS_PER_PATCH:
outValues.push_back(resource.isPerPatch);
break;
case GL_LOCATION_COMPONENT:
outValues.push_back(0);
break;
case GL_TRANSFORM_FEEDBACK_BUFFER_INDEX:
outValues.push_back(resource.xfbBufferIndex);
break;
default:
outValues.push_back(0);
break;
}
return true;
}
GLint GetResourceLocation(ProgramObject& program, GLenum programInterface, const char* name) {
if (name == nullptr || name[0] == '\0') return -1;
const String query = name;
String base;
Uint element = 0;
Bool malformed = false;
const Bool subscripted = SplitTrailingSubscript(query, base, element, malformed);
if (malformed) return -1;
const Model model = BuildModel(program);
const ResourceList& resources = Select(model, programInterface);
for (const Resource& resource : resources) {
if (NamesMatch(resource.name, query)) return resource.location;
}
if (!subscripted || element == 0) return -1;
// "d[1]" addresses the second element of an array resource enumerated as "d[0]".
for (const Resource& resource : resources) {
if (!NamesMatch(resource.name, base)) continue;
if (resource.location < 0 || static_cast<GLint>(element) >= resource.arraySize) return -1;
return resource.location + static_cast<GLint>(element);
}
return -1;
}
GLint GetResourceLocationIndex(ProgramObject& program, GLenum programInterface, const char* name) {
if (programInterface != GL_PROGRAM_OUTPUT || name == nullptr || name[0] == '\0') return -1;
const String query = name;
String base;
Uint element = 0;
Bool malformed = false;
const Bool subscripted = SplitTrailingSubscript(query, base, element, malformed);
if (malformed) return -1;
const Model model = BuildModel(program);
for (const Resource& resource : model.programOutputs) {
if (NamesMatch(resource.name, query)) return resource.locationIndex;
}
if (!subscripted) return -1;
for (const Resource& resource : model.programOutputs) {
if (!NamesMatch(resource.name, base)) continue;
if (resource.location < 0 || static_cast<GLint>(element) >= resource.arraySize) return -1;
return resource.locationIndex;
}
return -1;
}
} // namespace MobileGL::MG_Impl::GLImpl::ProgramInterface
@@ -0,0 +1,66 @@
// MobileGL - MobileGL/MG_Impl/GLImpl/Program/ProgramInterface.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
namespace MobileGL::MG_State::GLState {
class ProgramObject;
}
// The GL program interface (ARB_program_interface_query / GL 4.3 §7.3.1) as a frontend
// resource model.
//
// WHY IT IS HERE AND NOT IN A BACKEND. glGetProgramResource* describes the program the
// APPLICATION wrote, in the application's namespace. Neither backend program is in that
// namespace: DirectGLES compiles SPIRV-Cross-generated ESSL where default-block uniforms
// live inside the synthesized MGL_GLOBAL_UBO (so a GL_UNIFORM location query against it is
// structurally -1) and stage in/out names are rewritten; DirectVulkan has no GL-level
// reflection at all and can only re-derive a partial, diverging copy. The one authoritative
// source is the frontend glslang reflection a link already produced, which is the same
// place glGetActiveUniform answers from. This layer generalizes that rule to every
// interface, so the six entry points never consult gBackendFunctionsTable.
//
// NAMING RULES LIVE HERE, NOT IN ProgramObject. The interface query spells resources
// differently from glGetActiveUniform / glGetActiveAttrib (an array is "name[0]", a lookup
// accepts both "name" and "name[0]", a subscript must be a strict decimal). Those two
// getters are what GL30-33 exercises and they must not move, so every normalization is
// applied on the way in and out of THIS file.
namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
using ProgramObject = MG_State::GLState::ProgramObject;
// <programInterface> is one of the GL 4.6 Table 7.1 interfaces.
Bool IsInterfaceEnum(GLenum programInterface);
// Interfaces whose resources have names (everything except GL_ATOMIC_COUNTER_BUFFER).
Bool IsNamedInterface(GLenum programInterface);
// <prop> is a property token GetProgramResourceiv knows at all (else GL_INVALID_ENUM).
Bool IsResourceProp(GLenum prop);
// <prop> applies to <programInterface> (else GL_INVALID_OPERATION).
Bool InterfaceSupportsProp(GLenum programInterface, GLenum prop);
// Interfaces GetProgramResourceLocation accepts (else GL_INVALID_ENUM).
Bool InterfaceHasLocations(GLenum programInterface);
// GL_ACTIVE_RESOURCES / GL_MAX_NAME_LENGTH / GL_MAX_NUM_ACTIVE_VARIABLES. All three
// report zero for an interface this implementation cannot enumerate and for a program
// that has not linked successfully - which is what the spec requires of a program with
// no active resources.
Int GetActiveResourceCount(ProgramObject& program, GLenum programInterface);
Int GetMaxNameLength(ProgramObject& program, GLenum programInterface);
Int GetMaxNumActiveVariables(ProgramObject& program, GLenum programInterface);
// GL_INVALID_INDEX when <name> names no active resource of the interface.
GLuint GetResourceIndex(ProgramObject& program, GLenum programInterface, const char* name);
// False when <index> is out of range for the interface (the caller raises INVALID_VALUE).
Bool GetResourceName(ProgramObject& program, GLenum programInterface, GLuint index, String& outName);
// Appends the value(s) of <prop> for the resource; GL_ACTIVE_VARIABLES appends several.
// False when <index> is out of range.
Bool GetResourceProp(ProgramObject& program, GLenum programInterface, GLuint index, GLenum prop,
Vector<GLint>& outValues);
GLint GetResourceLocation(ProgramObject& program, GLenum programInterface, const char* name);
GLint GetResourceLocationIndex(ProgramObject& program, GLenum programInterface, const char* name);
} // namespace MobileGL::MG_Impl::GLImpl::ProgramInterface
+27 -1
View File
@@ -8,6 +8,7 @@
#include "GL_Sampler.h"
#include "Validators.h"
#include "../Getter/GL_Getter.h"
#include <MG_State/GLState/Core.h>
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
@@ -268,9 +269,20 @@ namespace MobileGL::MG_Impl::GLImpl {
}
}
// The number of texture units a sampler may be bound to. GL 3.3 core 3.8.2 names
// GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, which is what the backend advertises; the frontend's
// MAX_TEXTURE_IMAGE_UNITS is only the capacity of the unit array, so it is a clamp on the
// answer and never the answer itself - gating on it alone accepts every unit up to 192 no
// matter what the driver reports.
static GLint GetSamplerBindableTextureUnitCount() {
GLint maxTextureUnits = 0;
GetIntegerv(GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, &maxTextureUnits);
return std::min<GLint>(std::max(maxTextureUnits, 0), MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS);
}
void BindSampler_State(GLuint unit, GLuint sampler) {
MGLOG_D("BindSampler_State: unit = %u, sampler = %u", unit, sampler);
if (unit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) {
if (static_cast<Uint64>(unit) >= static_cast<Uint64>(GetSamplerBindableTextureUnitCount())) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "BindSampler", "texture unit out of range"));
@@ -309,6 +321,20 @@ namespace MobileGL::MG_Impl::GLImpl {
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "BindSamplers", "count must be non-negative"));
return;
}
// ARB_multi_bind: the whole [first, first + count) range is checked up front and a
// range that runs past the last texture unit is INVALID_OPERATION - not the
// INVALID_VALUE the single-bind BindSampler_State reports per element, and nothing is
// bound when it fails. Both gates read the same limit (see
// GetSamplerBindableTextureUnitCount), so an out-of-range multi-bind can no longer slip
// past this check and be caught one element at a time with the wrong error class.
const GLint maxTextureUnits = GetSamplerBindableTextureUnitCount();
if (static_cast<Uint64>(first) + static_cast<Uint64>(count) > static_cast<Uint64>(maxTextureUnits)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "BindSamplers",
"first + count exceeds the number of texture units."));
return;
}
for (GLsizei i = 0; i < count; ++i) {
BindSampler_State(first + i, samplers ? samplers[i] : 0);
@@ -75,7 +75,11 @@ namespace MobileGL::MG_Impl::GLImpl::SamplerImpl {
break;
case GL_TEXTURE_COMPARE_FUNC:
if (param < GL_LEQUAL || param > GL_ALWAYS) {
// The eight depth-compare functions are contiguous from GL_NEVER (0x0200) to
// GL_ALWAYS (0x0207); GL_LEQUAL sits in the middle of that block, so starting
// the range there rejected NEVER/LESS/EQUAL and let GREATER/NOTEQUAL/GEQUAL
// through only by accident of them being above LEQUAL.
if (param < GL_NEVER || param > GL_ALWAYS) {
MG_State::pGLContext->RecordError(ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "ValidateSamplerParam",
"Invalid compare function parameter"));
+51 -3
View File
@@ -86,17 +86,65 @@ namespace MobileGL::MG_Impl::GLImpl {
return false;
}
// DSA emulation: the by-name entry points are implemented by putting the named texture
// on the active unit's slot for their target, running the classic bound-texture code,
// then putting the previous binding back.
//
// Both of those binds are REAL changes to "which texture is bound at this unit" for as
// long as `fn` runs, so both have to move the texture bind generation. Backends memoise
// per-unit work keyed on that generation and BORROW the binding slot (they hold a
// pointer to the slot's shared_ptr, not a copy); a slot swap the generation never saw
// let such a memo replay texture A's backend twin against texture B now sitting in the
// slot - which re-specified A's backend storage with B's shape and silently destroyed
// A's GPU-rendered contents (Minecraft's lightmap, blanked by a by-name upload to an
// Iris shadow map, which then discarded every glyph).
//
// The generation is bumped directly rather than through NoteTextureUnitTouched because
// the touched-unit HIGH-WATER MARK must NOT move: glActiveTexture does not advance it,
// so a DSA-only app would otherwise have every later draw walk up to the highest unit it
// ever aimed a by-name call at. Not advancing it is also sufficient - a unit above the
// mark is outside every memo's coverage and outside the epoch walk, so nothing can
// observe the transient swap there; at or below it, the bump is exactly what makes the
// epoch re-derive. Bumping only on a real change keeps the very common redundant case (a
// by-name call on the texture already bound to the active unit) free.
//
// The restore is a scope guard because `fn` can throw (the unsupported-state paths use
// THROW_EXCEPTION): leaking the temporary binding would leave the wrong texture bound to
// a live unit for the rest of the context's life.
template <typename Fn>
void WithTemporarilyBoundNamedTexture(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
Fn&& fn) {
if (!textureObject) return;
auto& activeUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit());
const Int activeUnitIndex = MG_State::pGLContext->GetActiveTextureUnit();
auto& activeUnit = MG_State::pGLContext->GetTextureUnitObject(activeUnitIndex);
auto& bindingSlot = activeUnit.GetBindingSlot(textureObject->GetTarget());
const auto previousBinding = bindingSlot.GetBoundObject();
bindingSlot.Bind(textureObject);
using SlotType = std::remove_reference_t<decltype(bindingSlot)>;
class ScopedSlotRestore {
public:
ScopedSlotRestore(SlotType& slot, SharedPtr<MG_State::GLState::ITextureObject> previous)
: m_slot(slot), m_previous(Move(previous)) {}
~ScopedSlotRestore() {
if (m_slot.Bind(m_previous)) {
MG_State::pGLContext->BumpTextureBindGeneration();
}
}
ScopedSlotRestore(const ScopedSlotRestore&) = delete;
ScopedSlotRestore& operator=(const ScopedSlotRestore&) = delete;
private:
SlotType& m_slot;
SharedPtr<MG_State::GLState::ITextureObject> m_previous;
};
if (bindingSlot.Bind(textureObject)) {
MG_State::pGLContext->BumpTextureBindGeneration();
}
ScopedSlotRestore restore(bindingSlot, previousBinding);
fn(MG_Util::ConvertTextureTargetToGLEnum(textureObject->GetTarget()));
bindingSlot.Bind(previousBinding);
}
SizeT ComputeTextureStorageByteSize(TextureInternalFormat textureInternalFormat, GLsizei width, GLsizei height,
@@ -106,6 +106,21 @@ namespace MobileGL::MG_Impl::GLImpl {
return pname == GL_CURRENT_VERTEX_ATTRIB;
}
// The two ARB_vertex_attrib_binding per-attribute queries. They do not live on the
// resolved VertexAttribute (which is the flat, already-combined view) but on the VAO's
// binding-point mapping, so they need the object, not the attribute.
static bool TryGetVertexAttribBindingQuery(GLuint index, GLenum pname, GLint& out) {
if (pname != GL_VERTEX_ATTRIB_BINDING && pname != GL_VERTEX_ATTRIB_RELATIVE_OFFSET) return false;
const auto& vao = MG_State::pGLContext->GetBoundVertexArray();
if (!vao) {
out = 0;
return true;
}
out = pname == GL_VERTEX_ATTRIB_BINDING ? static_cast<GLint>(vao->GetAttributeBindingIndex(index))
: static_cast<GLint>(vao->GetAttributeRelativeOffset(index));
return true;
}
// The stride a pointer-style call gives its binding point: the argument when it is non-zero,
// otherwise the tightly packed element size (GL 4.6 core 10.3.2). A packed 2_10_10_10 or
// 10F_11F_11F attribute is one 32-bit word regardless of its component count.
@@ -179,6 +194,11 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_VERTEX_ATTRIB_ARRAY_LONG:
case GL_VERTEX_ATTRIB_ARRAY_DIVISOR:
case GL_VERTEX_ATTRIB_ARRAY_POINTER:
// ARB_vertex_attrib_binding (core since GL 4.3). The binding-point view is real
// state on the VAO (GetAttributeBindingIndex / GetAttributeRelativeOffset), so
// both of its per-attribute queries are answerable.
case GL_VERTEX_ATTRIB_BINDING:
case GL_VERTEX_ATTRIB_RELATIVE_OFFSET:
return true;
default:
MG_State::pGLContext->RecordError(
@@ -944,6 +964,13 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_VERTEX_ATTRIB_ARRAY_DIVISOR:
params[0] = static_cast<GLfloat>(attr->Divisor);
return;
case GL_VERTEX_ATTRIB_BINDING:
case GL_VERTEX_ATTRIB_RELATIVE_OFFSET: {
GLint value = 0;
TryGetVertexAttribBindingQuery(index, pname, value);
params[0] = static_cast<GLfloat>(value);
return;
}
default:
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
@@ -1007,6 +1034,13 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_VERTEX_ATTRIB_ARRAY_DIVISOR:
params[0] = static_cast<GLdouble>(attr->Divisor);
return;
case GL_VERTEX_ATTRIB_BINDING:
case GL_VERTEX_ATTRIB_RELATIVE_OFFSET: {
GLint value = 0;
TryGetVertexAttribBindingQuery(index, pname, value);
params[0] = static_cast<GLdouble>(value);
return;
}
default:
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
@@ -1066,6 +1100,10 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_VERTEX_ATTRIB_ARRAY_DIVISOR:
params[0] = static_cast<GLint>(attr->Divisor);
return;
case GL_VERTEX_ATTRIB_BINDING:
case GL_VERTEX_ATTRIB_RELATIVE_OFFSET:
TryGetVertexAttribBindingQuery(index, pname, params[0]);
return;
default:
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
@@ -1204,6 +1242,9 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_VERTEX_ATTRIB_RELATIVE_OFFSET:
*param = static_cast<GLint>(vao->GetAttributeRelativeOffset(index));
return;
case GL_VERTEX_ATTRIB_BINDING:
*param = static_cast<GLint>(vao->GetAttributeBindingIndex(index));
return;
default:
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
@@ -147,6 +147,30 @@ namespace MobileGL::MG_Impl::GLImpl::VertexArrayImpl {
return false;
}
// The integer path takes exactly the six signed/unsigned integer types (GL 4.6
// core 10.3.2): BYTE, UNSIGNED_BYTE, SHORT, UNSIGNED_SHORT, INT, UNSIGNED_INT.
// A blacklist could not express that: GL_FLOAT, GL_HALF_FLOAT,
// GL_DOUBLE and GL_FIXED all convert to a perfectly valid DataType, so they slipped
// through and were recorded as integer attributes.
if (integerPath) {
switch (type) {
case DataType::Int8:
case DataType::Uint8:
case DataType::Int16:
case DataType::Uint16:
case DataType::Int32:
case DataType::Uint32:
break;
default:
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", fn,
std::format("Type is not an integer vertex attribute type (attribute {}).", index)));
return false;
}
}
if (sizeRaw == static_cast<GLint>(GL_BGRA)) {
// GL_BGRA is a float-path-only size: it needs GL_UNSIGNED_BYTE or a 2_10_10_10 type and
// normalized == GL_TRUE. On the integer path it is simply an out-of-range size.
+270
View File
@@ -0,0 +1,270 @@
cmake_minimum_required(VERSION 3.24)
# MobileGL headless GPU integration tests.
#
# These are not unit tests: each scenario brings up a real EGL context on a
# pbuffer, renders real frames through a real backend and asserts on
# glReadPixels output. They need a GPU, so the module is OFF by default
# (MOBILEGL_BUILD_INTEGRATION_TEST) and every scenario skips cleanly - never
# fails, never hangs - on a machine without one. "Cleanly" is not a hope: the
# harness runs the whole bring-up in a forked child first, because MobileGL
# ABORTS rather than returning an error on an unusable platform (HeadlessGL.cpp).
#
# A clean skip is also indistinguishable from a pass, so set
# MOBILEGL_ITEST_REQUIRE_GPU wherever the machine is supposed to have a GPU.
#
# Backend selection is latched at initialization from MOBILEGL_BACKEND_TYPE, so
# one process is one backend: the same binary is registered twice, once per
# backend, under the `integration-gpu` label.
message(STATUS "Generating build files for MobileGL Integration Test...")
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(MGL_ITEST_ROOT ${CMAKE_CURRENT_LIST_DIR}/../..)
# Only meaningful where MobileGL_s exists (i.e. not Android).
if (NOT TARGET MobileGL_s)
message(STATUS "MobileGL_s is not available; skipping the integration test module")
return()
endif()
# MG_Test already pulls googletest in when MOBILEGL_BUILD_TEST is ON. Stand on
# our own feet when it is not, so this module can be built by itself.
if (NOT TARGET GTest::gtest)
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.17.0
)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
endif()
add_executable(MobileGLIntegrationTest
Main.cpp
Harness/HeadlessGL.cpp
Scenarios/OrientationScenario.cpp
Scenarios/CrossFrameBufferScenario.cpp
Scenarios/ResidentIndexScenario.cpp
Scenarios/MultiDrawScenario.cpp
Scenarios/AsyncCompileScenario.cpp
Scenarios/XfbAfterClipDistanceScenario.cpp
Scenarios/ThreeChannelAttachmentScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
${MGL_ITEST_ROOT}/include
${MGL_ITEST_ROOT}/MobileGL
)
# gtest, not gtest_main: Main.cpp installs the harness banner itself.
target_link_libraries(MobileGLIntegrationTest PRIVATE
GTest::gtest
MobileGL_s
)
if (MSVC)
# Same reason as MG_Test/Backend/DirectVulkan: the GLES headers declare gl*
# as dllimport on Windows, so the in-library GL entry-point definitions only
# resolve if the whole static library is part of the link.
target_link_options(MobileGLIntegrationTest PRIVATE /WHOLEARCHIVE:MobileGL_s)
endif()
target_compile_definitions(MobileGLIntegrationTest PRIVATE -DNOMINMAX)
# --- ctest wiring --------------------------------------------------------
# A bare libEGL on a glvnd box resolves to whatever vendor comes first, which is
# usually Mesa/llvmpipe - a software rasteriser silently replacing the GPU under
# a GPU test. Pin the vendor/ICD json the same way MG_Benchmark's
# run_driver_bench.sh does.
#
# Leaving these empty is not a neutral default, it is the failure mode: an
# unpinned libEGL lands on llvmpipe and the suite goes green having tested a
# software rasteriser. So they are DETECTED here rather than defaulted to empty,
# and an empty result is a loud warning.
#
# mgl_itest_find_driver_json(<outVar> <description> <glob> [<glob>...])
# Picks the first json a real hardware vendor owns, in preference order, and
# never picks a software rasteriser (llvmpipe / lavapipe / swrast) - landing on
# one of those silently is the exact accident this pinning exists to prevent.
function(mgl_itest_find_driver_json outVar)
set(candidates "")
foreach(pattern IN LISTS ARGN)
file(GLOB matches "${pattern}")
list(APPEND candidates ${matches})
endforeach()
list(SORT candidates)
# Vendors ship an i686 json beside the x86_64 one and it sorts first. Pinning
# the wrong word size is worse than not pinning at all - the loader finds no
# driver and the whole suite skips - so drop the mismatched ones outright.
if (CMAKE_SIZEOF_VOID_P EQUAL 8)
list(FILTER candidates EXCLUDE REGEX "i686|i386")
else()
list(FILTER candidates EXCLUDE REGEX "x86_64|aarch64")
endif()
set(software "")
foreach(vendor IN ITEMS nvidia amdgpu amd radeon intel_hasvk intel broadcom freedreno panfrost)
foreach(candidate IN LISTS candidates)
get_filename_component(leaf "${candidate}" NAME)
string(TOLOWER "${leaf}" leaf)
if (leaf MATCHES "${vendor}")
set(${outVar} "${candidate}" PARENT_SCOPE)
return()
endif()
endforeach()
endforeach()
# Nothing recognised as hardware. Report the first non-software entry if there
# is one; otherwise report nothing, so the warning below fires.
foreach(candidate IN LISTS candidates)
get_filename_component(leaf "${candidate}" NAME)
string(TOLOWER "${leaf}" leaf)
if (NOT leaf MATCHES "lvp|llvmpipe|lavapipe|swrast|softpipe")
set(${outVar} "${candidate}" PARENT_SCOPE)
return()
endif()
set(software "${candidate}")
endforeach()
set(${outVar} "" PARENT_SCOPE)
endfunction()
set(MGL_ITEST_DETECTED_EGL_VENDOR "")
set(MGL_ITEST_DETECTED_VK_ICD "")
if (UNIX AND NOT APPLE AND NOT ANDROID)
mgl_itest_find_driver_json(MGL_ITEST_DETECTED_EGL_VENDOR
"/usr/share/glvnd/egl_vendor.d/*.json"
"/etc/glvnd/egl_vendor.d/*.json")
mgl_itest_find_driver_json(MGL_ITEST_DETECTED_VK_ICD
"/usr/share/vulkan/icd.d/*.json"
"/etc/vulkan/icd.d/*.json")
endif()
set(MOBILEGL_ITEST_EGL_VENDOR "${MGL_ITEST_DETECTED_EGL_VENDOR}" CACHE FILEPATH
"glvnd EGL vendor json to pin for the integration tests (empty: leave the loader alone)")
set(MOBILEGL_ITEST_VK_ICD "${MGL_ITEST_DETECTED_VK_ICD}" CACHE FILEPATH
"Vulkan ICD json to pin for the DirectVulkan integration tests (empty: leave the loader alone)")
if (MOBILEGL_ITEST_EGL_VENDOR)
message(STATUS "Integration tests: pinning EGL vendor ${MOBILEGL_ITEST_EGL_VENDOR}")
else()
message(WARNING
"Integration tests: no EGL vendor json found or configured (MOBILEGL_ITEST_EGL_VENDOR is empty). "
"An unpinned libEGL on a glvnd system resolves to whichever vendor comes first, which is usually "
"Mesa/llvmpipe - the scenarios would then go green against a software rasteriser instead of the GPU. "
"Set -DMOBILEGL_ITEST_EGL_VENDOR=/usr/share/glvnd/egl_vendor.d/<vendor>.json.")
endif()
if (MOBILEGL_ITEST_VK_ICD)
message(STATUS "Integration tests: pinning Vulkan ICD ${MOBILEGL_ITEST_VK_ICD}")
else()
message(WARNING
"Integration tests: no Vulkan ICD json found or configured (MOBILEGL_ITEST_VK_ICD is empty). "
"DirectVulkan would then load whichever ICD the loader enumerates first, quite possibly lavapipe. "
"Set -DMOBILEGL_ITEST_VK_ICD=/usr/share/vulkan/icd.d/<vendor>.json.")
endif()
# Turns "no usable GPU" from a clean skip into a failure - see ScenarioFixture.h.
# Without it the integration-gpu label is unfalsifiable: a run that skipped every
# scenario and a run that passed every scenario are the same green in ctest.
option(MOBILEGL_ITEST_REQUIRE_GPU
"Fail (rather than skip) the integration scenarios when the headless harness is unusable" OFF)
# DirectGLES asks the system EGL for a pbuffer config, and on Mesa the default
# platform is not X11 unless it is said out loud (run_driver_bench.sh sets the
# same variable). Wrong platform here is not a soft failure: eglCreatePbuffer
# fails and every scenario skips.
if (UNIX AND NOT APPLE AND NOT ANDROID)
set(MOBILEGL_ITEST_EGL_PLATFORM "x11" CACHE STRING
"EGL_PLATFORM for the integration tests (empty: leave the loader alone)")
else()
set(MOBILEGL_ITEST_EGL_PLATFORM "" CACHE STRING
"EGL_PLATFORM for the integration tests (empty: leave the loader alone)")
endif()
set(MGL_ITEST_COMMON_ENV "")
if (MOBILEGL_ITEST_EGL_VENDOR)
list(APPEND MGL_ITEST_COMMON_ENV "__EGL_VENDOR_LIBRARY_FILENAMES=${MOBILEGL_ITEST_EGL_VENDOR}")
endif()
if (MOBILEGL_ITEST_EGL_PLATFORM)
list(APPEND MGL_ITEST_COMMON_ENV "EGL_PLATFORM=${MOBILEGL_ITEST_EGL_PLATFORM}")
endif()
if (MOBILEGL_ITEST_REQUIRE_GPU)
list(APPEND MGL_ITEST_COMMON_ENV "MOBILEGL_ITEST_REQUIRE_GPU=1")
endif()
set(MGL_ITEST_VULKAN_ENV ${MGL_ITEST_COMMON_ENV})
if (MOBILEGL_ITEST_VK_ICD)
list(APPEND MGL_ITEST_VULKAN_ENV "VK_ICD_FILENAMES=${MOBILEGL_ITEST_VK_ICD}")
endif()
# The ENVIRONMENT test property is itself a `;`-list, and gtest_discover_tests
# forwards PROPERTIES as a flat list - so a plain `;`-joined value arrives as
# four separate arguments and everything after the first is silently read as
# another property name. Escaping the separators keeps the whole thing one list
# element until set_tests_properties expands it back. Without this only
# MOBILEGL_BACKEND_TYPE reaches the test and the vendor/ICD pinning is lost.
function(mgl_itest_join_environment outVar)
set(joined "")
foreach(entry IN LISTS ARGN)
if (joined)
string(APPEND joined "\\;${entry}")
else()
set(joined "${entry}")
endif()
endforeach()
set(${outVar} "${joined}" PARENT_SCOPE)
endfunction()
mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_ASYNC_SHADER_COMPILE=1" ${MGL_ITEST_VULKAN_ENV})
# TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it.
set(MGL_ITEST_TIMEOUT 120)
include(GoogleTest)
# Discovery runs `--gtest_list_tests`, which does not construct the harness and
# so needs no GPU. One registration per backend; TEST_PREFIX keeps the two sets
# of ctest names apart.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES."
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan."
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_ENVIRONMENT}"
)
# A third registration, of ONE scenario, with asynchronous shader compilation
# pinned on. Not a second code path in the renderer: a second ALLOCATION pattern.
# The async pipeline's job objects change which of the freed blocks the capture
# phase is handed, and that is what decides whether the destroyed-VAO address is
# reached at all - on the ablated (pre-fix) tree async=1 reproduced 3 runs out of
# 3 where the ambient default reproduced 2 of 3. Pinning it here means the
# high-signal configuration runs whatever the shipped default becomes, instead of
# the suite quietly weakening the day that default flips. It must be process-wide
# (the ENVIRONMENT property), not an in-process scope: the compile pool and its
# threads are stood up at initialization, and their allocations are half the
# point. DirectVulkan only - the memo this pins is DirectVulkan's.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.AsyncCompile."
TEST_FILTER "XfbAfterClipDistanceScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT}"
)
@@ -0,0 +1,587 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/HeadlessGL.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 "HeadlessGL.h"
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ostream>
#include <sstream>
// MobileGL's own headers, in the order MobileGL/Includes.h uses them: GL/gl.h
// first, then glcorearb.h for the 3.x+ entry points. This binary links
// MobileGL_s, so every gl*/egl* below binds to MobileGL's implementation, not
// to a system loader.
#ifdef GLAPI
#undef GLAPI
#endif
#include <EGL/egl.h>
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
// The pre-flight below runs the whole EGL bring-up in a forked child, which is
// the only construction that is actually predictive here: MobileGL ABORTS
// (MOBILEGL_ASSERT -> SIGTRAP) rather than returning an error on an unusable
// platform, so nothing the parent can call in-process is allowed to be wrong.
#if !defined(_WIN32) && !defined(__APPLE__) && __has_include(<sys/wait.h>)
#define MGITEST_HAVE_FORK_PREFLIGHT 1
#include <csignal>
#include <ctime>
#include <sys/resource.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#else
#define MGITEST_HAVE_FORK_PREFLIGHT 0
#endif
namespace MGITest {
namespace {
// Small enough that a readback is cheap, big enough that "top third" and
// "bottom third" are unambiguous. Non-square on purpose: a transposing
// bug cannot hide behind a square.
constexpr int kSurfaceWidth = 128;
constexpr int kSurfaceHeight = 96;
std::string EnvOr(const char* name, const char* fallback) {
const char* value = std::getenv(name);
return (value != nullptr && value[0] != '\0') ? std::string(value) : std::string(fallback);
}
// A skip reason is only useful if it says which call failed AND why, so
// every bring-up step reports the EGL error it left behind.
std::string WithEglError(const char* what) {
std::ostringstream out;
out << what << " (eglGetError=0x" << std::hex << eglGetError() << ")";
return out.str();
}
// The EGL objects one bring-up produces.
struct EglBringUp {
void* display = nullptr;
void* surface = nullptr;
void* context = nullptr;
std::string renderer;
};
// THE bring-up, in one function so the pre-flight child and the parent run
// literally the same sequence - a pre-flight that tests something narrower
// than what the parent will do is exactly the kind of "predictive" check
// that is not.
//
// Returns 0 on success, or the 1-based index of the step that failed, and
// fills outReason either way.
int RunEglBringUp(EglBringUp& out, std::string& outReason) {
EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (display == EGL_NO_DISPLAY) {
outReason = WithEglError("eglGetDisplay(EGL_DEFAULT_DISPLAY) returned EGL_NO_DISPLAY");
return 1;
}
EGLint major = 0, minor = 0;
if (eglInitialize(display, &major, &minor) != EGL_TRUE) {
outReason = WithEglError("eglInitialize failed: no usable display/driver on this machine");
return 2;
}
if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) {
outReason = WithEglError("eglBindAPI(EGL_OPENGL_API) failed");
return 3;
}
const EGLint configAttribs[] = {EGL_SURFACE_TYPE,
EGL_PBUFFER_BIT,
EGL_RED_SIZE,
8,
EGL_GREEN_SIZE,
8,
EGL_BLUE_SIZE,
8,
EGL_ALPHA_SIZE,
8,
EGL_DEPTH_SIZE,
24,
EGL_RENDERABLE_TYPE,
EGL_OPENGL_BIT,
EGL_NONE};
EGLConfig config = nullptr;
EGLint configCount = 0;
if (eglChooseConfig(display, configAttribs, &config, 1, &configCount) != EGL_TRUE || configCount < 1) {
outReason = WithEglError("eglChooseConfig found no pbuffer-capable RGBA8/D24 config");
return 4;
}
const EGLint contextAttribs[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_CONTEXT_MINOR_VERSION, 3, EGL_NONE};
EGLContext context = eglCreateContext(display, config, EGL_NO_CONTEXT, contextAttribs);
if (context == EGL_NO_CONTEXT) {
context = eglCreateContext(display, config, EGL_NO_CONTEXT, nullptr);
}
if (context == EGL_NO_CONTEXT) {
outReason = WithEglError("eglCreateContext failed: no desktop-GL context available");
return 5;
}
const EGLint pbufferAttribs[] = {EGL_WIDTH, kSurfaceWidth, EGL_HEIGHT, kSurfaceHeight, EGL_NONE};
EGLSurface surface = eglCreatePbufferSurface(display, config, pbufferAttribs);
if (surface == EGL_NO_SURFACE) {
outReason = WithEglError("eglCreatePbufferSurface failed");
return 6;
}
// The step that brings the whole backend up (DirectVulkan creates its
// instance, device and surface in here) and therefore the step that
// aborts instead of returning an error on an unusable platform.
if (eglMakeCurrent(display, surface, surface, context) != EGL_TRUE) {
outReason = WithEglError("eglMakeCurrent failed");
return 7;
}
const GLubyte* renderer = glGetString(GL_RENDERER);
if (renderer == nullptr) {
outReason = "glGetString(GL_RENDERER) returned null after eglMakeCurrent";
return 8;
}
out.display = display;
out.surface = surface;
out.context = context;
out.renderer = reinterpret_cast<const char*>(renderer);
outReason.clear();
return 0;
}
// Platform pre-flight, and the reason this module can claim to skip
// cleanly rather than merely hope to.
//
// MobileGL does not return errors when the platform is unusable - it
// ABORTS. MOBILEGL_ASSERT raises SIGTRAP, and the DirectVulkan bring-up
// asserts its way through instance, physical-device and surface creation
// inside eglMakeCurrent. So there is no in-process question the harness
// can ask that is guaranteed to be survivable, and the old form (dlopen
// the Vulkan loader, count physical devices, look for
// VK_EXT_headless_surface) was a guess at the abort conditions rather
// than a test of them: it named three of the ways bring-up can die and
// was silent about every other one, including every DirectGLES one.
//
// What is actually predictive is to run the bring-up itself somewhere a
// SIGTRAP is a datum instead of a crash. fork() gives exactly that: the
// child performs the identical sequence and _exit(0)s on success, and
// ANY non-zero exit or ANY signal in the parent's waitpid() means "this
// platform is unusable" - whatever the reason, including reasons nobody
// has thought of. Only then does the parent do the real bring-up.
//
// Returns an empty string when the platform survived a full bring-up.
std::string PreflightBringUp() {
#if !MGITEST_HAVE_FORK_PREFLIGHT
// No fork(): let the in-process bring-up speak for itself, which is
// what this module did before. Windows/macOS are not CI targets for
// the headless scenarios.
return {};
#else
int channel[2] = {-1, -1};
if (pipe(channel) != 0) {
return {}; // cannot pre-flight; fall through to the in-process attempt
}
// The child inherits our stdio buffers; flush so nothing is printed twice.
std::fflush(nullptr);
const pid_t child = fork();
if (child < 0) {
close(channel[0]);
close(channel[1]);
return {};
}
if (child == 0) {
close(channel[0]);
// The child is EXPECTED to die on a signal on an unusable
// platform; that is the measurement. Do not let each such
// measurement drop a core file next to the test binary.
const rlimit noCore{0, 0};
setrlimit(RLIMIT_CORE, &noCore);
std::fprintf(stderr, "[itest] pre-flight child: attempting a full EGL bring-up\n");
EglBringUp local;
std::string reason;
const int step = RunEglBringUp(local, reason);
if (!reason.empty()) {
const std::size_t bytes = std::min<std::size_t>(reason.size(), 480);
const ssize_t written = write(channel[1], reason.data(), bytes);
(void)written;
}
close(channel[1]);
// _exit, never exit(): every atexit handler and static destructor
// in this address space belongs to the parent's copy of the world,
// and the child is holding a live context it must not tear down.
_exit(step);
}
close(channel[1]);
// Reap first, read after: the message is bounded well below the pipe
// buffer so the child can never block writing it, and polling the exit
// status is what lets a wedged child be killed instead of hanging the
// parent on a read that will never return.
constexpr int kPreflightTimeoutMs = 30000;
int status = 0;
int waitedMs = 0;
for (;;) {
const pid_t reaped = waitpid(child, &status, WNOHANG);
if (reaped == child) break;
if (reaped < 0) {
close(channel[0]);
return "waitpid on the EGL bring-up pre-flight child failed";
}
if (waitedMs >= kPreflightTimeoutMs) {
kill(child, SIGKILL);
(void)waitpid(child, &status, 0);
close(channel[0]);
std::ostringstream out;
out << "the EGL bring-up wedged: a forked pre-flight child made no progress in "
<< kPreflightTimeoutMs / 1000 << "s and was killed";
return out.str();
}
timespec nap{0, 10 * 1000 * 1000};
nanosleep(&nap, nullptr);
waitedMs += 10;
}
std::string childSays;
char buffer[512];
for (;;) {
const ssize_t got = read(channel[0], buffer, sizeof(buffer));
if (got <= 0) break;
childSays.append(buffer, static_cast<std::size_t>(got));
}
close(channel[0]);
if (WIFSIGNALED(status)) {
const int signalNumber = WTERMSIG(status);
const char* signalName = strsignal(signalNumber);
std::ostringstream out;
out << "the EGL bring-up ABORTS on this platform: a forked pre-flight child died on signal "
<< signalNumber << " (" << (signalName != nullptr ? signalName : "?") << ")";
if (!childSays.empty()) out << " after: " << childSays;
out << ". MobileGL asserts rather than returning an error here, so the scenarios would "
"have taken the whole test binary down with them";
return out.str();
}
if (!WIFEXITED(status)) {
return "the EGL bring-up pre-flight child neither exited nor was signalled";
}
const int exitStatus = WEXITSTATUS(status);
if (exitStatus != 0) {
std::ostringstream out;
out << (childSays.empty() ? "the EGL bring-up failed" : childSays)
<< " (forked pre-flight child exit status " << exitStatus << ")";
return out.str();
}
return {};
#endif
}
} // namespace
bool RequireGpu() {
const char* value = std::getenv("MOBILEGL_ITEST_REQUIRE_GPU");
return value != nullptr && value[0] != '\0' && std::strcmp(value, "0") != 0;
}
std::ostream& operator<<(std::ostream& os, const Rgba8& c) {
os << "rgba(" << int(c.r) << "," << int(c.g) << "," << int(c.b) << "," << int(c.a) << ")";
return os;
}
Rgba8 Image::At(int x, int y) const {
if (x < 0 || y < 0 || x >= m_width || y >= m_height) {
return Rgba8{};
}
const std::size_t index = (static_cast<std::size_t>(y) * m_width + x) * 4;
return Rgba8{m_pixels[index], m_pixels[index + 1], m_pixels[index + 2], m_pixels[index + 3]};
}
const char* Image::ColorName(int x, int y) const {
const Rgba8 c = At(x, y);
const bool r = c.r > 160, g = c.g > 160, b = c.b > 160;
const bool nr = c.r < 96, ng = c.g < 96, nb = c.b < 96;
if (nr && ng && nb) return "black";
if (r && g && b) return "white";
if (r && ng && nb) return "red";
if (nr && g && nb) return "green";
if (nr && ng && b) return "blue";
if (r && g && nb) return "yellow";
return "other";
}
std::size_t Image::ByteDiffCount(const Image& other) const {
if (m_width != other.m_width || m_height != other.m_height) {
return std::max(m_pixels.size(), other.m_pixels.size());
}
std::size_t differing = 0;
for (std::size_t i = 0; i < m_pixels.size(); ++i) {
if (m_pixels[i] != other.m_pixels[i]) ++differing;
}
return differing;
}
std::string Image::QuadrantSignature() const {
if (m_width < 2 || m_height < 2) return "<empty>";
// Quadrant CENTRES, so a one-pixel rounding difference at a quadrant edge
// never decides the answer. Order is fixed and load-bearing: bottom-left,
// bottom-right, top-left, top-right.
const int leftX = m_width / 4;
const int rightX = m_width * 3 / 4;
const int bottomY = m_height / 4;
const int topY = m_height * 3 / 4;
std::ostringstream out;
out << ColorName(leftX, bottomY) << "," << ColorName(rightX, bottomY) << "," << ColorName(leftX, topY) << ","
<< ColorName(rightX, topY);
return out.str();
}
RegionScan ScanRegion(const Image& image, int x0, int x1, int y0, int y1, const char* expectedColor) {
RegionScan scan;
x0 = std::max(x0, 0);
y0 = std::max(y0, 0);
x1 = std::min(x1, image.Width() - 1);
y1 = std::min(y1, image.Height() - 1);
for (int y = y0; y <= y1; ++y) {
for (int x = x0; x <= x1; ++x) {
++scan.total;
const char* name = image.ColorName(x, y);
if (std::strcmp(name, expectedColor) == 0) continue;
++scan.offenders;
if (scan.firstX < 0) {
scan.firstX = x;
scan.firstY = y;
scan.firstColor = image.At(x, y);
scan.firstColorName = name;
}
}
}
return scan;
}
::testing::AssertionResult RegionIsMostly(const Image& image, int x0, int x1, int y0, int y1,
const char* expectedColor, double tolerance,
const std::string& when) {
const RegionScan scan = ScanRegion(image, x0, x1, y0, y1, expectedColor);
if (scan.total == 0) {
return ::testing::AssertionFailure()
<< when << ": region x[" << x0 << "," << x1 << "] y[" << y0 << "," << y1
<< "] is empty against a " << image.Width() << "x" << image.Height() << " readback";
}
const double offendingFraction = static_cast<double>(scan.offenders) / scan.total;
if (offendingFraction <= tolerance) {
return ::testing::AssertionSuccess();
}
return ::testing::AssertionFailure()
<< when << ": region x[" << x0 << "," << x1 << "] y[" << y0 << "," << y1 << "] should be all "
<< expectedColor << ", but " << scan.offenders << " of " << scan.total << " pixels ("
<< static_cast<int>(offendingFraction * 100.0 + 0.5) << "%) are not; first offender at (" << scan.firstX
<< "," << scan.firstY << ") is " << scan.firstColorName << " " << scan.firstColor;
}
HeadlessGL& HeadlessGL::Get() {
static HeadlessGL instance;
return instance;
}
HeadlessGL::HeadlessGL() {
m_backendName = EnvOr("MOBILEGL_BACKEND_TYPE", "<unset>");
m_usable = BringUp();
}
bool HeadlessGL::BringUp() {
// Ask a disposable copy of this process first. Only if it survived does
// the real one try - see PreflightBringUp for why nothing weaker is
// predictive against a stack that aborts instead of returning errors.
const std::string preflightProblem = PreflightBringUp();
if (!preflightProblem.empty()) {
m_skipReason = preflightProblem;
return false;
}
// Same shape as DriverBench's boot_egl(), minus the dlopen: the provider
// is this binary. A pbuffer needs no window system, but MobileGL's own
// loader still has to reach a real driver underneath - and the child
// above just proved it can.
EglBringUp brought;
std::string reason;
if (RunEglBringUp(brought, reason) != 0) {
// The pre-flight passed and the parent's identical attempt did not.
// That is a real result, not a machine without a GPU, so say so: it
// means something is different between the two attempts (a leaked
// exclusive device, an environment the child did not have).
m_skipReason = reason + " - although an identical bring-up in a forked pre-flight child succeeded";
return false;
}
m_display = brought.display;
m_surface = brought.surface;
m_context = brought.context;
m_width = kSurfaceWidth;
m_height = kSurfaceHeight;
m_renderer = std::move(brought.renderer);
return true;
}
void HeadlessGL::EndFrame() {
if (!m_usable) return;
eglSwapBuffers(static_cast<EGLDisplay>(m_display), static_cast<EGLSurface>(m_surface));
++m_frameIndex;
}
void HeadlessGL::ShutDown() {
if (!m_usable) return;
EGLDisplay display = static_cast<EGLDisplay>(m_display);
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (m_context != nullptr) eglDestroyContext(display, static_cast<EGLContext>(m_context));
if (m_surface != nullptr) eglDestroySurface(display, static_cast<EGLSurface>(m_surface));
eglTerminate(display);
m_context = nullptr;
m_surface = nullptr;
m_display = nullptr;
m_usable = false;
m_skipReason = "the headless context has already been torn down";
}
// ---- scenario vocabulary ------------------------------------------------
namespace {
unsigned int CompileStage(GLenum stage, const char* source, std::string* outError) {
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] = {};
GLsizei length = 0;
glGetShaderInfoLog(shader, sizeof(log) - 1, &length, log);
if (outError != nullptr) {
*outError = std::string(stage == GL_VERTEX_SHADER ? "vertex" : "fragment") +
" shader failed to compile: " + log;
}
glDeleteShader(shader);
return 0;
}
return shader;
}
} // namespace
unsigned int CompileProgram(const char* vertexSource, const char* fragmentSource, std::string* outError) {
const GLuint vs = CompileStage(GL_VERTEX_SHADER, vertexSource, outError);
if (vs == 0) return 0;
const GLuint fs = CompileStage(GL_FRAGMENT_SHADER, fragmentSource, outError);
if (fs == 0) {
glDeleteShader(vs);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
// Pinned rather than queried so the scenarios can set up a VAO without a
// round trip, and so a driver that reorders attributes cannot change what
// the test means.
glBindAttribLocation(program, 0, "aPos");
glBindAttribLocation(program, 1, "aColor");
glLinkProgram(program);
glDeleteShader(vs);
glDeleteShader(fs);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[2048] = {};
GLsizei length = 0;
glGetProgramInfoLog(program, sizeof(log) - 1, &length, log);
if (outError != nullptr) *outError = std::string("program failed to link: ") + log;
glDeleteProgram(program);
return 0;
}
return program;
}
ColorFbo MakeColorFbo(int width, int height) {
ColorFbo target;
target.width = width;
target.height = height;
glGenTextures(1, &target.texture);
glBindTexture(GL_TEXTURE_2D, target.texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
glGenFramebuffers(1, &target.fbo);
glBindFramebuffer(GL_FRAMEBUFFER, target.fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target.texture, 0);
const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (status != GL_FRAMEBUFFER_COMPLETE) {
DestroyColorFbo(target);
}
return target;
}
void DestroyColorFbo(ColorFbo& target) {
if (target.fbo != 0) glDeleteFramebuffers(1, &target.fbo);
if (target.texture != 0) glDeleteTextures(1, &target.texture);
target.fbo = 0;
target.texture = 0;
}
void BindDefaultFramebuffer() {
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glViewport(0, 0, HeadlessGL::Get().Width(), HeadlessGL::Get().Height());
}
void BindFbo(const ColorFbo& target) {
glBindFramebuffer(GL_FRAMEBUFFER, target.fbo);
glViewport(0, 0, target.width, target.height);
}
void ClearTo(float r, float g, float b, float a) {
glClearColor(r, g, b, a);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
}
Image ReadPixels(int width, int height) {
Image image(width, height);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.Data());
return image;
}
unsigned int FirstGLError() {
const GLenum first = glGetError();
if (first == GL_NO_ERROR) return GL_NO_ERROR;
// Drain, bounded: a broken stack must not turn an error check into a hang.
for (int i = 0; i < 64 && glGetError() != GL_NO_ERROR; ++i) {}
return first;
}
const char* GLErrorName(unsigned int error) {
switch (error) {
case GL_NO_ERROR:
return "GL_NO_ERROR";
case GL_INVALID_ENUM:
return "GL_INVALID_ENUM";
case GL_INVALID_VALUE:
return "GL_INVALID_VALUE";
case GL_INVALID_OPERATION:
return "GL_INVALID_OPERATION";
case GL_OUT_OF_MEMORY:
return "GL_OUT_OF_MEMORY";
case GL_INVALID_FRAMEBUFFER_OPERATION:
return "GL_INVALID_FRAMEBUFFER_OPERATION";
default:
return "GL_<unknown>";
}
}
} // namespace MGITest
@@ -0,0 +1,218 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/HeadlessGL.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
//
// A headless GL context and the small vocabulary the scenarios are written in.
//
// The scenarios in this module are end-to-end: they drive MobileGL's own GL and
// EGL entry points (this binary links MobileGL_s, so gl*/egl* resolve straight
// into the implementation) and assert on glReadPixels output. Nothing here
// inspects backend state - both bugs this module pins were invisible to
// state-level assertions and visible only in pixels.
//
// Headless by construction, following MG_Benchmark/Driver/DriverBench.c: an EGL
// context on a PBUFFER surface. No window, no window manager, no human. Unlike
// DriverBench the scenarios do draw to the DEFAULT framebuffer (that is where
// the Y-flip lives) and do call eglSwapBuffers (that is the frame boundary the
// cross-frame scenarios need to be real).
//
// One process is one backend: MOBILEGL_BACKEND_TYPE is latched at
// initialization, so the CMake wiring runs this binary once per backend rather
// than trying to switch in-process.
#pragma once
#include <gtest/gtest.h>
#include <cstdint>
#include <string>
#include <vector>
namespace MGITest {
// True when MOBILEGL_ITEST_REQUIRE_GPU is set in the environment: the runner
// is asserting that this machine HAS a usable GPU, so "no GPU" stops being a
// clean skip and becomes a failure. Without it the integration-gpu label is
// unfalsifiable - a CI job that ran nothing reports exactly the same green as
// a job that ran everything.
bool RequireGpu();
struct Rgba8 {
std::uint8_t r = 0, g = 0, b = 0, a = 0;
bool operator==(const Rgba8& other) const {
return r == other.r && g == other.g && b == other.b && a == other.a;
}
bool operator!=(const Rgba8& other) const { return !(*this == other); }
};
// Prints as "rgba(255,0,0,255)" so a gtest failure names the colour it saw.
std::ostream& operator<<(std::ostream& os, const Rgba8& c);
// An RGBA8 readback. Row 0 is the BOTTOM row: that is GL's convention for
// glReadPixels and it is what "correctly oriented" means everywhere below.
class Image {
public:
Image() = default;
Image(int width, int height)
: m_width(width), m_height(height), m_pixels(static_cast<std::size_t>(width) * height * 4, 0) {}
int Width() const { return m_width; }
int Height() const { return m_height; }
bool Empty() const { return m_pixels.empty(); }
std::uint8_t* Data() { return m_pixels.data(); }
const std::uint8_t* Data() const { return m_pixels.data(); }
Rgba8 At(int x, int y) const;
// Nearest of {black, red, green, blue, white, other} - the scenarios only
// ever draw those, so this turns a pixel into something readable.
const char* ColorName(int x, int y) const;
bool operator==(const Image& other) const {
return m_width == other.m_width && m_height == other.m_height && m_pixels == other.m_pixels;
}
// Count of differing bytes, for a failure message that says how wrong.
std::size_t ByteDiffCount(const Image& other) const;
// The four quadrant centres, in the fixed order
// bottom-left, bottom-right, top-left, top-right.
//
// This replaces the old VerticalSignature(bandCount), which read three
// full-width horizontal stripes down the centre line and was therefore
// blind to an X flip, to a transpose, and to a 180 rotation composed with
// a Y flip - all of those left the stripe order alone. Four quadrant
// colours are asymmetric in BOTH axes, so each of the eight square
// symmetries produces a different string (see OrientationScenario, which
// spells all eight out).
std::string QuadrantSignature() const;
private:
int m_width = 0;
int m_height = 0;
std::vector<std::uint8_t> m_pixels;
};
// The process-wide headless context. Brought up lazily on the first Get() so
// that `--gtest_list_tests` (which CMake runs at build time to discover the
// cases) never touches a GPU.
class HeadlessGL {
public:
static HeadlessGL& Get();
// False on a machine with no usable GPU/display/ICD. SkipReason() then
// says which step failed; every fixture turns that into GTEST_SKIP().
bool Usable() const { return m_usable; }
const std::string& SkipReason() const { return m_skipReason; }
// Backend actually in use, as reported by MOBILEGL_BACKEND_TYPE.
const std::string& BackendName() const { return m_backendName; }
const std::string& RendererString() const { return m_renderer; }
int Width() const { return m_width; }
int Height() const { return m_height; }
// THE frame boundary. eglSwapBuffers is what retires a frame in the
// renderer, and the cross-frame scenarios are meaningless without it.
void EndFrame();
// Frames completed so far, for failure messages.
int FrameIndex() const { return m_frameIndex; }
// Releases the context and surface and terminates the display. Called
// once, after the last scenario: MobileGL frees its backend objects
// through eglTerminate, and letting a process simply exit on top of a
// live context leaves those objects to be torn down from a static
// destructor with no driver left underneath.
void ShutDown();
private:
HeadlessGL();
HeadlessGL(const HeadlessGL&) = delete;
HeadlessGL& operator=(const HeadlessGL&) = delete;
bool BringUp();
bool m_usable = false;
std::string m_skipReason;
std::string m_backendName;
std::string m_renderer;
int m_width = 0;
int m_height = 0;
int m_frameIndex = 0;
void* m_display = nullptr;
void* m_surface = nullptr;
void* m_context = nullptr;
};
// ---- the scenario vocabulary -------------------------------------------
// Deliberately tiny. A scenario should read like a story; anything that
// needs a comment about GL mechanics belongs here instead.
// Compiles and links vs+fs, pinning attribute 0 to "aPos" and 1 to "aColor".
// Returns 0 and fills outError on failure.
unsigned int CompileProgram(const char* vertexSource, const char* fragmentSource, std::string* outError);
struct ColorFbo {
unsigned int fbo = 0;
unsigned int texture = 0;
int width = 0;
int height = 0;
};
// A complete RGBA8 render target. Returns fbo==0 on failure.
ColorFbo MakeColorFbo(int width, int height);
void DestroyColorFbo(ColorFbo& target);
// Binds a target and sets the viewport to match. Passing fbo 0 means the
// default (presentable) framebuffer.
void BindDefaultFramebuffer();
void BindFbo(const ColorFbo& target);
void ClearTo(float r, float g, float b, float a);
// Reads back the whole currently bound READ framebuffer. width/height must
// be the target's full size - DirectVulkan's default-framebuffer readback
// only re-orients a full-extent read.
Image ReadPixels(int width, int height);
// Drains any GL error queue and returns the first error, or 0.
unsigned int FirstGLError();
const char* GLErrorName(unsigned int error);
// ---- whole-region readback predicates ----------------------------------
// The scenarios used to assert on two or three individual pixels, which is
// provably too weak: a draw in which 3 of a quad's 4 vertices carry stale
// data still paints the sampled centre the expected colour (that exact case
// is a standing negative-control test - see CrossFrameBufferScenario). The
// readback is already fully in memory, so counting every pixel in a region
// costs nothing and turns "the middle looks right" into "all of it is right".
// Everything a caller needs to say what was wrong and where.
struct RegionScan {
int total = 0; // pixels examined
int offenders = 0; // pixels whose ColorName() != expected
int firstX = -1; // first offender in bottom-to-top, left-to-right order
int firstY = -1;
Rgba8 firstColor{};
std::string firstColorName;
};
// Inclusive pixel bounds, clamped to the image. Row 0 is the bottom row.
RegionScan ScanRegion(const Image& image, int x0, int x1, int y0, int y1, const char* expectedColor);
// gtest predicate wrapper: EXPECT_TRUE(RegionIsMostly(...)) reports the
// offender count, the offender fraction and the FIRST offending pixel's
// coordinates and colour. `tolerance` is the fraction of the region allowed
// to disagree; pass 0.0 to demand every pixel (which is what the scenarios
// do - they inset their regions away from primitive edges so exactness is
// achievable).
::testing::AssertionResult RegionIsMostly(const Image& image, int x0, int x1, int y0, int y1,
const char* expectedColor, double tolerance,
const std::string& when);
} // namespace MGITest
@@ -0,0 +1,84 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.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
//
// The base fixture every scenario derives from. Its only jobs are to bring the
// headless context up once per process and to decide what "this machine has no
// usable GPU" means.
//
// By default it means a clean GTEST_SKIP() - never a failure, never a hang -
// because a developer box or a container without a GPU should not fail a run it
// was never able to perform. But a skip is indistinguishable from a pass in
// every CI summary, so the `integration-gpu` label on its own is unfalsifiable:
// a runner whose driver pinning silently broke reports the same green as one
// that rendered every frame. MOBILEGL_ITEST_REQUIRE_GPU is the caller saying
// "this machine HAS a GPU and I am relying on these scenarios actually running";
// with it set, an unusable harness is a FAILURE carrying the pre-flight's reason.
#pragma once
#include <gtest/gtest.h>
#include "HeadlessGL.h"
namespace MGITest {
class ScenarioTest : public ::testing::Test {
protected:
void SetUp() override {
m_ready = false;
HeadlessGL& gl = HeadlessGL::Get();
if (!gl.Usable()) {
if (RequireGpu()) {
// FAIL() is a FATAL failure but does NOT mark the test skipped,
// so a derived SetUp that guards on IsSkipped() alone would run
// straight into GL calls with no current context and SIGSEGV -
// that exact crash shipped from the first version of this guard.
// Derived fixtures must gate on Ready() (below), which is false
// on BOTH the skip path and this failure path.
FAIL() << "MOBILEGL_ITEST_REQUIRE_GPU is set, so an unusable harness is a failure, not a skip. "
<< "Backend " << gl.BackendName() << " could not be brought up: " << gl.SkipReason();
}
GTEST_SKIP() << "no usable GPU/display/ICD for backend " << gl.BackendName() << ": " << gl.SkipReason();
}
if (RequireGpu() && LooksLikeSoftwareRasterizer(gl.RendererString())) {
// "Ran on llvmpipe" must not be able to pass as "ran on the GPU":
// a misconfigured vendor pin silently lands on the software
// rasterizer, and REQUIRE_GPU exists precisely to make that loud.
FAIL() << "MOBILEGL_ITEST_REQUIRE_GPU is set but the context landed on a software rasterizer: "
<< gl.RendererString();
}
// A scenario starts from a clean slate but shares the context (and so
// the renderer's memos) with every other scenario in this process -
// which is exactly the situation both shipped bugs needed.
RecordProperty("backend", gl.BackendName());
RecordProperty("renderer", gl.RendererString());
m_ready = true;
}
// The ONLY gate a derived SetUp/TearDown may use: `if (!Ready()) return;`.
// True only when the base SetUp brought the context up and neither skipped
// nor failed. IsSkipped() alone is WRONG here (see the comment at FAIL()).
bool Ready() const { return m_ready; }
static HeadlessGL& Gl() { return HeadlessGL::Get(); }
private:
static bool LooksLikeSoftwareRasterizer(const std::string& renderer) {
static const char* kNames[] = {"llvmpipe", "lavapipe", "softpipe", "SwiftShader", "swrast"};
for (const char* name : kNames) {
if (renderer.find(name) != std::string::npos) {
return true;
}
}
return false;
}
bool m_ready = false;
};
} // namespace MGITest
+53
View File
@@ -0,0 +1,53 @@
// MobileGL - MobileGL/MG_IntegrationTest/Main.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
//
// Entry point for the headless GPU integration scenarios.
//
// The banner lives in a gtest Environment rather than in main() on purpose:
// Environment::SetUp does not run for `--gtest_list_tests`, which is what CMake
// invokes at build time to discover the cases. Discovery therefore never brings
// up EGL, never needs a GPU and cannot hang.
#include <gtest/gtest.h>
#include <cstdio>
#include "Harness/HeadlessGL.h"
namespace {
class HarnessBanner : public ::testing::Environment {
public:
void SetUp() override {
const MGITest::HeadlessGL& gl = MGITest::HeadlessGL::Get();
std::fprintf(stderr, "MobileGL integration scenarios: backend=%s\n", gl.BackendName().c_str());
if (gl.Usable()) {
std::fprintf(stderr, " renderer: %s\n surface: %dx%d pbuffer (headless)\n",
gl.RendererString().c_str(), gl.Width(), gl.Height());
} else if (MGITest::RequireGpu()) {
std::fprintf(stderr,
" FAILING every scenario (MOBILEGL_ITEST_REQUIRE_GPU is set): %s\n",
gl.SkipReason().c_str());
} else {
std::fprintf(stderr,
" SKIPPING every scenario: %s\n"
" (set MOBILEGL_ITEST_REQUIRE_GPU=1 to make this a failure instead - a run that\n"
" skipped everything is otherwise indistinguishable from one that passed)\n",
gl.SkipReason().c_str());
}
}
void TearDown() override { MGITest::HeadlessGL::Get().ShutDown(); }
};
} // namespace
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
::testing::AddGlobalTestEnvironment(new HarnessBanner());
return RUN_ALL_TESTS();
}
@@ -0,0 +1,467 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/AsyncCompileScenario.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 E - asynchronous shader compilation and GL_KHR_parallel_shader_compile
// on a REAL driver.
//
// WHY THIS EXISTS ALONGSIDE THE UNIT SUITES. MG_Test/Program's async suites already
// drive the same GL entry points, but they stop at the frontend: nothing there ever
// reaches a driver, so nothing there can catch the failure this scenario is built for
// - artifacts produced on a worker thread that the BACKEND then rejects, mis-binds or
// renders differently from the ones the GL thread produced. The frontend cannot tell
// the two apart; a pixel can.
//
// The five things it pins, in order:
//
// (a) 64 heavy compiles are enqueued and polled through GL_COMPLETION_STATUS_KHR.
// At least one must be observed GL_FALSE - i.e. the query really answers while
// work is outstanding rather than silently joining. Skipped, never failed, when
// the machine drained the whole batch before the first poll: a fast box must not
// be able to turn this into a red.
// (b) Forcing the join afterwards produces the right answer for every one of them:
// GL_COMPILE_STATUS true, an empty info log, and a program that links.
// (c) The extension string matches the configuration. This is the half a recorded
// trace can never cover - Iris and Sodium change their submission schedule the
// moment they see the string - so it is asserted against a real backend's real
// GL_EXTENSIONS, through both glGetString and glGetStringi.
// (d) glMaxShaderCompilerThreadsKHR(0) leaves nothing in flight: every subsequent
// GL_COMPLETION_STATUS_KHR reads GL_TRUE immediately, and compilation after it
// is synchronous. That is what the extension requires of a zero count.
// (e) THE ONE THAT NEEDS A GPU: the same frame, drawn with programs compiled and
// linked asynchronously and then with programs compiled and linked inline, must
// come out byte-identical under glReadPixels. Anything the worker thread got
// wrong about the compile environment, the reflection or the SPIR-V shows up
// here as a pixel difference and nowhere else.
//
// Backend selection is the module's usual one process, one backend (MOBILEGL_BACKEND_TYPE),
// so this file runs twice per ctest invocation.
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#include "Config.h"
#include "MG_Util/Async/ShaderCompilePool.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
// GL_KHR_parallel_shader_compile. Spelled out rather than relying on the host's
// glext.h: this module is built against whatever GL headers the machine has, and an
// older one has neither token. Both are also GL_*_ARB with identical values.
#ifndef GL_MAX_SHADER_COMPILER_THREADS_KHR
#define GL_MAX_SHADER_COMPILER_THREADS_KHR 0x91B0
#endif
#ifndef GL_COMPLETION_STATUS_KHR
#define GL_COMPLETION_STATUS_KHR 0x91B1
#endif
// The entry point under test, resolved by the linker straight into MobileGL_s like
// every other gl* call in this module. Declared here for the same reason as the
// tokens above.
extern "C" void glMaxShaderCompilerThreadsKHR(GLuint count);
namespace MGITest {
namespace {
using MobileGL::MG_Config::QuirkOverride;
// Same shape as the other scenarios: a two-attribute pass-through, so the only
// thing that can differ between the two compilation modes is the compilation.
constexpr const char* kVertexSource = R"(#version 330 core
in vec2 aPos;
in vec3 aColor;
out vec3 vColor;
void main() {
vColor = aColor;
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kFragmentSource = R"(#version 330 core
in vec3 vColor;
out vec4 oColor;
void main() {
oColor = vec4(vColor, 1.0);
}
)";
// Asymmetric in both axes, so a mode difference that also happens to be a
// symmetry of the image cannot hide (the same reason OrientationScenario draws
// quadrants rather than stripes).
struct Vertex {
float x, y;
float r, g, b;
};
void AppendQuad(std::vector<Vertex>& out, float x0, float x1, float y0, float y1, float r, float g, float b) {
const Vertex bl{x0, y0, r, g, b};
const Vertex br{x1, y0, r, g, b};
const Vertex tr{x1, y1, r, g, b};
const Vertex tl{x0, y1, r, g, b};
out.insert(out.end(), {bl, br, tr, bl, tr, tl});
}
std::vector<Vertex> QuadrantGeometry() {
std::vector<Vertex> vertices;
vertices.reserve(24);
AppendQuad(vertices, -1.0f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f); // bottom-left: blue
AppendQuad(vertices, 0.0f, 1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f); // bottom-right: green
AppendQuad(vertices, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f); // top-left: red
AppendQuad(vertices, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f); // top-right: white
return vertices;
}
// Expensive enough that a compile is not instantaneous, and distinct per index so
// the source-hash memo never turns one into a no-op: without both properties the
// pool has no backlog and (a) has nothing to observe.
std::string BulkyFragmentSource(int index) {
std::string source = "#version 330 core\n";
source += "in vec3 vColor;\nout vec4 oColor;\n";
source += "uniform float uSeed" + std::to_string(index) + ";\n";
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
for (int i = 0; i < 320; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
}
source += " oColor = vec4(vColor * acc, 1.0);\n}\n";
return source;
}
// MOBILEGL_ASYNC_SHADER_COMPILE decides the ambient mode; a scenario that wants
// the other one says so here and gets the ambient one back on scope exit. Forcing
// it in-process is what lets ONE ctest run compare the two modes against each
// other - the whole point of (e).
class AsyncModeScope {
public:
explicit AsyncModeScope(bool async) : m_saved(MobileGL::MG_Config::Features.AsyncShaderCompile) {
MobileGL::MG_Config::Features.AsyncShaderCompile =
async ? QuirkOverride::ForceOn : QuirkOverride::ForceOff;
}
~AsyncModeScope() { MobileGL::MG_Config::Features.AsyncShaderCompile = m_saved; }
AsyncModeScope(const AsyncModeScope&) = delete;
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
private:
const QuirkOverride m_saved;
};
// glMaxShaderCompilerThreadsKHR writes process-wide state; a scenario that calls
// it has to put the pool back or it changes how every scenario after it compiles.
class CompilerThreadScope {
public:
CompilerThreadScope() = default;
~CompilerThreadScope() {
MobileGL::MG_Util::Async::SetAsyncShaderCompileSuspended(false);
auto& pool = MobileGL::MG_Util::Async::ShaderCompilePool::Get();
pool.SetMaxConcurrency(pool.GetThreadCount());
}
CompilerThreadScope(const CompilerThreadScope&) = delete;
CompilerThreadScope& operator=(const CompilerThreadScope&) = delete;
};
GLint ShaderCompletion(GLuint shader) {
GLint status = -1;
glGetShaderiv(shader, GL_COMPLETION_STATUS_KHR, &status);
return status;
}
GLint ShaderCompileStatus(GLuint shader) {
GLint status = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
return status;
}
std::string ShaderInfoLog(GLuint shader) {
GLint length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
if (length <= 0) return std::string();
std::vector<char> buffer(static_cast<std::size_t>(length));
GLsizei written = 0;
glGetShaderInfoLog(shader, length, &written, buffer.data());
return std::string(buffer.data(), static_cast<std::size_t>(written));
}
class AsyncCompileScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
const std::vector<Vertex> vertices = QuadrantGeometry();
m_vertexCount = static_cast<int>(vertices.size());
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data(),
GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(0));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(8));
glBindVertexArray(0);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "setup left a GL error behind";
}
void TearDown() override {
if (!Ready()) return;
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
}
// A fresh program every time, compiled and linked in whatever mode is in
// force. Reusing one would defeat the comparison: the second mode would just
// read the first mode's artifacts back out of the memo.
GLuint BuildProgram() {
std::string error;
const GLuint program = CompileProgram(kVertexSource, kFragmentSource, &error);
EXPECT_NE(program, 0u) << error;
return program;
}
Image DrawFrameWith(GLuint program) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(program);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, m_vertexCount);
glBindVertexArray(0);
Image image = ReadPixels(Gl().Width(), Gl().Height());
Gl().EndFrame();
return image;
}
// Enqueues `count` distinct heavy compiles and returns their names WITHOUT
// reading anything back, so the pool is left with a real backlog.
std::vector<GLuint> EnqueueBacklog(int count, int seedBase) {
std::vector<GLuint> shaders;
shaders.reserve(static_cast<std::size_t>(count));
m_sources.reserve(m_sources.size() + static_cast<std::size_t>(count));
for (int i = 0; i < count; ++i) {
m_sources.push_back(BulkyFragmentSource(seedBase + i));
const char* text = m_sources.back().c_str();
const GLuint shader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(shader, 1, &text, nullptr);
glCompileShader(shader);
shaders.push_back(shader);
}
return shaders;
}
GLuint m_vao = 0;
GLuint m_vbo = 0;
int m_vertexCount = 0;
// Kept alive for the whole case: glShaderSource copies, but keeping the
// strings makes a failure message able to name the source it came from.
std::vector<std::string> m_sources;
};
// ---- (a) + (b) ------------------------------------------------------------
// A backlog is enqueued, polled without joining, then forced to settle and
// checked for correctness. Both halves in one case on purpose: (b) is only
// interesting for shaders that (a) proved were genuinely still outstanding.
TEST_F(AsyncCompileScenario, CompletionStatusPollingThenForcedJoin) {
if (!Ready()) return;
const AsyncModeScope async(true);
const CompilerThreadScope threads;
// One worker, so the queue behind it is what the poll observes.
glMaxShaderCompilerThreadsKHR(1);
const std::vector<GLuint> shaders = EnqueueBacklog(64, 6000);
int outstanding = 0;
for (const GLuint shader : shaders) {
const GLint completion = ShaderCompletion(shader);
ASSERT_TRUE(completion == GL_TRUE || completion == GL_FALSE)
<< "GL_COMPLETION_STATUS_KHR returned " << completion;
if (completion == GL_FALSE) ++outstanding;
}
if (outstanding == 0) {
GTEST_SKIP() << "this machine drained 64 heavy compiles before the first poll; "
"nothing was outstanding to observe";
}
// (b) Forced join: every one of them is correct, and usable.
for (const GLuint shader : shaders) {
EXPECT_EQ(ShaderCompileStatus(shader), GL_TRUE) << ShaderInfoLog(shader);
EXPECT_TRUE(ShaderInfoLog(shader).empty());
EXPECT_EQ(ShaderCompletion(shader), GL_TRUE) << "GL_COMPILE_STATUS must have joined";
}
// And a link over one of them really produces a usable program on this driver.
const GLuint vs = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vs, 1, &kVertexSource, nullptr);
glCompileShader(vs);
const GLuint program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, shaders.front());
glBindAttribLocation(program, 0, "aPos");
glBindAttribLocation(program, 1, "aColor");
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
EXPECT_EQ(linked, GL_TRUE);
EXPECT_GE(glGetUniformLocation(program, "uSeed6000"), 0);
glDeleteProgram(program);
glDeleteShader(vs);
for (const GLuint shader : shaders) glDeleteShader(shader);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// ---- (c) ------------------------------------------------------------------
// The extension string, read from a real backend that really brought a driver
// up. No mode forcing here: a backend builds its advertised list once, from the
// configuration in force at its first use, so the meaningful assertion is
// against the AMBIENT configuration - which is exactly what makes this case
// worth running in both of the suite's flag states.
TEST_F(AsyncCompileScenario, ExtensionStringMatchesTheConfiguration) {
if (!Ready()) return;
const bool expected = MobileGL::MG_Util::Async::AsyncShaderCompileEnabled();
const char* extensions = reinterpret_cast<const char*>(glGetString(GL_EXTENSIONS));
ASSERT_NE(extensions, nullptr);
const std::string extensionString(extensions);
const bool inString = extensionString.find("GL_KHR_parallel_shader_compile") != std::string::npos;
EXPECT_EQ(inString, expected)
<< "backend " << Gl().BackendName() << " GL_EXTENSIONS = " << extensionString;
// LWJGL builds GLCapabilities from the INDEXED form on a core profile, so the
// two spellings disagreeing would be invisible to the check above and fatal
// to a real application.
GLint count = 0;
glGetIntegerv(GL_NUM_EXTENSIONS, &count);
ASSERT_GT(count, 0);
bool inIndexed = false;
for (GLint i = 0; i < count; ++i) {
const char* name = reinterpret_cast<const char*>(glGetStringi(GL_EXTENSIONS, GLuint(i)));
if (name != nullptr && std::string(name) == "GL_KHR_parallel_shader_compile") inIndexed = true;
}
EXPECT_EQ(inIndexed, expected);
// The companion query, which an application reads right after the string.
GLint maxThreads = -1;
glGetIntegerv(GL_MAX_SHADER_COMPILER_THREADS_KHR, &maxThreads);
if (expected) {
EXPECT_GE(maxThreads, 1);
} else {
EXPECT_EQ(maxThreads, 0);
}
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// ---- (d) ------------------------------------------------------------------
// A zero count must leave nothing in flight and keep it that way.
TEST_F(AsyncCompileScenario, ZeroCompilerThreadsSettlesEverythingImmediately) {
if (!Ready()) return;
const AsyncModeScope async(true);
const CompilerThreadScope threads;
glMaxShaderCompilerThreadsKHR(1);
const std::vector<GLuint> backlog = EnqueueBacklog(48, 6200);
glMaxShaderCompilerThreadsKHR(0);
for (const GLuint shader : backlog) {
EXPECT_EQ(ShaderCompletion(shader), GL_TRUE)
<< "glMaxShaderCompilerThreadsKHR(0) must join everything still in flight";
EXPECT_EQ(ShaderCompileStatus(shader), GL_TRUE) << ShaderInfoLog(shader);
}
// Compilation after the zero count is synchronous too.
const std::vector<GLuint> serial = EnqueueBacklog(6, 6300);
for (const GLuint shader : serial) {
EXPECT_EQ(ShaderCompletion(shader), GL_TRUE) << "a compile after a zero count must be synchronous";
}
for (const GLuint shader : backlog) glDeleteShader(shader);
for (const GLuint shader : serial) glDeleteShader(shader);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// ---- (e) ------------------------------------------------------------------
// The one that needs the GPU. Two programs, identical source, one built with
// compilation and linking on worker threads and one built inline; the frames
// they draw must be byte-identical.
//
// Compared through the DEFAULT framebuffer deliberately: that is where the
// backend's orientation and present path live, so the comparison covers the
// whole pipeline rather than the reflection tables alone.
TEST_F(AsyncCompileScenario, AsyncAndSyncProgramsRenderIdenticalFrames) {
if (!Ready()) return;
Image asyncImage;
{
const AsyncModeScope async(true);
const GLuint program = BuildProgram();
ASSERT_NE(program, 0u);
asyncImage = DrawFrameWith(program);
glDeleteProgram(program);
}
Image syncImage;
{
const AsyncModeScope async(false);
const GLuint program = BuildProgram();
ASSERT_NE(program, 0u);
syncImage = DrawFrameWith(program);
glDeleteProgram(program);
}
ASSERT_FALSE(asyncImage.Empty());
ASSERT_FALSE(syncImage.Empty());
// The frame is the expected one in the first place - two identically WRONG
// frames would otherwise pass.
EXPECT_EQ(asyncImage.QuadrantSignature(), "blue,green,red,white")
<< "the asynchronously compiled program did not draw the expected frame";
EXPECT_EQ(asyncImage, syncImage)
<< "asynchronous and synchronous compilation rendered different frames ("
<< asyncImage.ByteDiffCount(syncImage) << " bytes differ); backend " << Gl().BackendName();
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// The same comparison over a batch, which is the shape a shaderpack load has:
// many programs enqueued before any of them is read back, then each one drawn.
// A per-worker state leak (glslang's thread-local pools are the obvious
// candidate) shows up here and not in the single-program case above.
TEST_F(AsyncCompileScenario, ABatchOfAsyncProgramsAllRenderCorrectly) {
if (!Ready()) return;
constexpr int kPrograms = 12;
std::vector<GLuint> programs;
{
const AsyncModeScope async(true);
const CompilerThreadScope threads;
glMaxShaderCompilerThreadsKHR(1);
// Everything enqueued before anything is read: the only shape in which
// more than one job is in flight at a time.
for (int i = 0; i < kPrograms; ++i) {
programs.push_back(BuildProgram());
}
}
for (int i = 0; i < kPrograms; ++i) {
ASSERT_NE(programs[static_cast<std::size_t>(i)], 0u) << "program " << i;
const Image image = DrawFrameWith(programs[static_cast<std::size_t>(i)]);
EXPECT_EQ(image.QuadrantSignature(), "blue,green,red,white") << "program " << i;
}
for (const GLuint program : programs) glDeleteProgram(program);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,761 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CrossFrameBufferScenario.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 B - "the draw rendered last frame's buffer".
//
// The shipped bug (DirectVulkan, TryBindResolvedVertexBindings and the EBO
// memo in UploadAndBindIndexBuffer): both memos revalidated themselves ACROSS a
// frame boundary by comparing recorded per-buffer slice epochs, and on a match
// skipped the per-frame buffer acquire. The acquire is the frame's content-sync
// point; skipping it trusted the BumpSliceEpoch call-site inventory to cover
// every way a buffer's GPU copy can go stale, and at least one path escaped it.
// Result: a draw in a later frame renders from a STALE buffer slice - random
// triangles in Minecraft/Sodium on Adreno, corrupted journeymap and
// common-mods retraces.
//
// What pins it: mutate a buffer AFTER a frame boundary and BEFORE the next
// draw, then prove the pixels show the NEW content. Every mutation API gets its
// own test case, so a failure names the culprit rather than saying "buffers".
// The index buffer is covered too: the EBO memo had exactly the same hole.
//
// The scene is deliberately trivial and entirely buffer-driven:
//
// vertices 0..3 left half of the viewport, RED
// vertices 4..7 right half of the viewport, GREEN
// indices A {0,1,2, 0,2,3} -> the left, red quad
// indices B {4,5,6, 4,6,7} -> the right, green quad
//
// A vertex-buffer test rewrites the left quad's colour red -> green and expects
// the left half to turn green. An index-buffer test rewrites the indices
// A -> B and expects the picture to jump from a red left half to a green right
// half. Either way "stale" and "fresh" are different colours in different
// places; no thresholds, no interpretation.
//
// Two families of scenario live here, and they catch different halves of the
// same rule:
//
// CrossFrameBufferScenario - one case per buffer-mutation API. Every one of
// these APIs is supposed to retire the memo; today they all do (each notify
// path bumps the slice epoch), so these pass on the buggy revision too.
// They are the standing statement of the contract: whatever a future memo
// keys on, a write through ANY of these APIs must reach the next frame's
// draw. They are also where a coherent persistent write - the one shape
// that changes a buffer with no GL call at all - is pinned.
//
// StreamedArenaScenario - the case that actually caught the shipped bug. It
// attacks the other half of the rule: a buffer nobody wrote at all, whose
// GPU-side bytes moved out from under the memo anyway.
#include <cstdio>
#include <cstring>
#include <functional>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr const char* kVertexSource = R"(#version 330 core
in vec2 aPos;
in vec3 aColor;
out vec3 vColor;
void main() {
vColor = aColor;
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kFragmentSource = R"(#version 330 core
in vec3 vColor;
out vec4 oColor;
void main() {
oColor = vec4(vColor, 1.0);
}
)";
struct Vertex {
float x, y;
float r, g, b;
};
constexpr int kLeftQuadFirstVertex = 0;
constexpr int kLeftQuadVertexCount = 4;
constexpr int kIndexCount = 6;
// Enough consecutive frames drawing the same VAO that any per-(VAO, frame)
// memo is fully armed before the mutation lands.
constexpr int kWarmupFrames = 3;
std::vector<Vertex> SceneVertices(bool leftQuadIsGreen) {
const float lr = leftQuadIsGreen ? 0.0f : 1.0f;
const float lg = leftQuadIsGreen ? 1.0f : 0.0f;
return {
// 0..3: left half
{-1.0f, -1.0f, lr, lg, 0.0f},
{0.0f, -1.0f, lr, lg, 0.0f},
{0.0f, 1.0f, lr, lg, 0.0f},
{-1.0f, 1.0f, lr, lg, 0.0f},
// 4..7: right half
{0.0f, -1.0f, 0.0f, 1.0f, 0.0f},
{1.0f, -1.0f, 0.0f, 1.0f, 0.0f},
{1.0f, 1.0f, 0.0f, 1.0f, 0.0f},
{0.0f, 1.0f, 0.0f, 1.0f, 0.0f},
};
}
const GLuint kIndicesLeftQuad[kIndexCount] = {0, 1, 2, 0, 2, 3};
const GLuint kIndicesRightQuad[kIndexCount] = {4, 5, 6, 4, 6, 7};
// How far inside each half the whole-region checks start. The two quads
// meet on a pixel boundary, so a couple of pixels of margin makes "every
// single pixel in the region" an achievable demand.
constexpr int kHalfInset = 2;
// Asserts the left and right halves of the viewport, with a message that
// says what the app had asked GL to draw by then.
//
// This counts EVERY pixel in each half rather than sampling its centre.
// Sampling two pixels was demonstrably too weak: a draw in which three of
// the left quad's four vertices still carry stale data paints a centre
// pixel of exactly the expected colour and passed the old assertion. That
// case is now a standing negative control - see
// PartialStalenessIsCaughtByWholeRegionChecks below, which constructs it
// deliberately and proves the region scan reports it.
void ExpectHalves(const Image& image, const char* expectedLeft, const char* expectedRight,
const std::string& when) {
const int w = image.Width();
const int h = image.Height();
EXPECT_TRUE(RegionIsMostly(image, kHalfInset, w / 2 - kHalfInset, kHalfInset, h - kHalfInset, expectedLeft,
0.0, when + " [left half]"));
EXPECT_TRUE(RegionIsMostly(image, w / 2 + kHalfInset, w - kHalfInset, kHalfInset, h - kHalfInset,
expectedRight, 0.0, when + " [right half]"));
}
// How the app hands the new bytes to GL. Each is its own test case.
enum class Mutation {
SubData, // glBufferSubData
MapWriteUnmap, // glMapBufferRange(WRITE) + glUnmapBuffer
PersistentFlush, // write through a persistent map + glFlushMappedBufferRange
PersistentCoherent, // write through a COHERENT persistent map, no GL call at all
OrphanReupload, // glBufferData(NULL) then a full re-upload
CopySubData, // glCopyBufferSubData from a staging buffer
};
bool NeedsImmutableStorage(Mutation mutation) {
return mutation == Mutation::PersistentFlush || mutation == Mutation::PersistentCoherent;
}
// The coherent variant is the one shape in which an application changes a
// buffer's contents with NO GL call whatsoever - the write lands in the
// mapping and that is the end of it. Sodium's chunk streaming is written
// this way, and it is the case a per-buffer "has anything changed?" epoch
// cannot see on its own.
bool NeedsCoherentMapping(Mutation mutation) {
return mutation == Mutation::PersistentCoherent;
}
class CrossFrameBufferScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "program setup left a GL error behind";
}
void TearDown() override {
if (!Ready()) return;
ReleaseBuffers();
if (m_program != 0) glDeleteProgram(m_program);
}
// Builds the VAO/VBO/EBO. `immutable` switches to glBufferStorage plus a
// persistent mapping of both buffers, which is the only shape in which the
// persistent-write mutation is legal.
void BuildScene(bool immutable, bool coherent = false) {
const std::vector<Vertex> vertices = SceneVertices(/*leftQuadIsGreen=*/false);
m_vertexBytes = GLsizeiptr(vertices.size() * sizeof(Vertex));
m_indexBytes = GLsizeiptr(sizeof(kIndicesLeftQuad));
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glGenBuffers(1, &m_ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo);
if (immutable) {
const GLbitfield storageFlags = GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_DYNAMIC_STORAGE_BIT |
(coherent ? GL_MAP_COHERENT_BIT : 0);
glBufferStorage(GL_ARRAY_BUFFER, m_vertexBytes, vertices.data(), storageFlags);
glBufferStorage(GL_ELEMENT_ARRAY_BUFFER, m_indexBytes, kIndicesLeftQuad, storageFlags);
const GLenum storageError = FirstGLError();
if (storageError != GL_NO_ERROR) {
m_storageUnsupported = true;
m_storageError = storageError;
return;
}
const GLbitfield mapFlags = GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT |
(coherent ? GL_MAP_COHERENT_BIT : GL_MAP_FLUSH_EXPLICIT_BIT);
m_vertexMap =
static_cast<unsigned char*>(glMapBufferRange(GL_ARRAY_BUFFER, 0, m_vertexBytes, mapFlags));
m_indexMap = static_cast<unsigned char*>(
glMapBufferRange(GL_ELEMENT_ARRAY_BUFFER, 0, m_indexBytes, mapFlags));
if (m_vertexMap == nullptr || m_indexMap == nullptr) {
m_storageUnsupported = true;
m_storageError = FirstGLError();
return;
}
} else {
glBufferData(GL_ARRAY_BUFFER, m_vertexBytes, vertices.data(), GL_STATIC_DRAW);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, m_indexBytes, kIndicesLeftQuad, GL_STATIC_DRAW);
}
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(0));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(8));
glBindVertexArray(0);
glGenBuffers(1, &m_staging);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
}
void ReleaseBuffers() {
if (m_vertexMap != nullptr || m_indexMap != nullptr) {
glBindVertexArray(m_vao);
if (m_vertexMap != nullptr) {
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glUnmapBuffer(GL_ARRAY_BUFFER);
}
if (m_indexMap != nullptr) {
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo);
glUnmapBuffer(GL_ELEMENT_ARRAY_BUFFER);
}
glBindVertexArray(0);
m_vertexMap = nullptr;
m_indexMap = nullptr;
}
if (m_staging != 0) glDeleteBuffers(1, &m_staging);
if (m_ebo != 0) glDeleteBuffers(1, &m_ebo);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_staging = m_ebo = m_vbo = m_vao = 0;
}
void DrawScene() {
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(m_program);
glBindVertexArray(m_vao);
glDrawElements(GL_TRIANGLES, kIndexCount, GL_UNSIGNED_INT, nullptr);
glBindVertexArray(0);
}
void BeginFrame() {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
}
Image ReadFrame() { return ReadPixels(Gl().Width(), Gl().Height()); }
// ---- the mutations ---------------------------------------------
// Each writes `newBytes` over the first `rangeBytes` of `buffer`;
// `wholeBytes`/`wholeSize` are the full contents an orphan+re-upload
// needs. `target` is the binding point the buffer normally lives at.
void ApplyMutation(Mutation mutation, GLenum target, GLuint buffer, unsigned char* persistentMap,
const void* newBytes, GLsizeiptr rangeBytes, const void* wholeBytes,
GLsizeiptr wholeSize) {
// The element-array binding is VAO state, so mutating the EBO happens
// with the scene's VAO bound - exactly as an application would.
glBindVertexArray(m_vao);
switch (mutation) {
case Mutation::SubData: {
glBindBuffer(target, buffer);
glBufferSubData(target, 0, rangeBytes, newBytes);
break;
}
case Mutation::MapWriteUnmap: {
glBindBuffer(target, buffer);
void* mapped =
glMapBufferRange(target, 0, rangeBytes, GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_RANGE_BIT);
ASSERT_NE(mapped, nullptr) << "glMapBufferRange(WRITE) returned null";
std::memcpy(mapped, newBytes, std::size_t(rangeBytes));
ASSERT_EQ(glUnmapBuffer(target), GLboolean(GL_TRUE)) << "glUnmapBuffer reported data loss";
break;
}
case Mutation::PersistentFlush: {
ASSERT_NE(persistentMap, nullptr) << "no persistent mapping for this buffer";
std::memcpy(persistentMap, newBytes, std::size_t(rangeBytes));
glBindBuffer(target, buffer);
glFlushMappedBufferRange(target, 0, rangeBytes);
break;
}
case Mutation::PersistentCoherent: {
// Deliberately no GL call: a coherent persistent mapping is a
// promise that the write alone is enough.
ASSERT_NE(persistentMap, nullptr) << "no persistent mapping for this buffer";
std::memcpy(persistentMap, newBytes, std::size_t(rangeBytes));
break;
}
case Mutation::OrphanReupload: {
glBindBuffer(target, buffer);
glBufferData(target, wholeSize, nullptr, GL_STATIC_DRAW);
glBufferSubData(target, 0, wholeSize, wholeBytes);
break;
}
case Mutation::CopySubData: {
glBindBuffer(GL_COPY_READ_BUFFER, m_staging);
glBufferData(GL_COPY_READ_BUFFER, rangeBytes, newBytes, GL_STATIC_DRAW);
glBindBuffer(GL_COPY_WRITE_BUFFER, buffer);
glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, 0, 0, rangeBytes);
glBindBuffer(GL_COPY_WRITE_BUFFER, 0);
glBindBuffer(GL_COPY_READ_BUFFER, 0);
break;
}
}
glBindVertexArray(0);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the mutation itself raised a GL error";
}
// ---- the story -------------------------------------------------
// Steady state for a few frames, one frame boundary, then the
// mutation, then the draw that must show the new content.
void RunAcrossFrameBoundary(Mutation mutation, const std::function<void()>& mutate,
const char* expectedLeftAfter, const char* expectedRightAfter) {
ASSERT_NO_FATAL_FAILURE(BuildScene(NeedsImmutableStorage(mutation), NeedsCoherentMapping(mutation)));
if (m_storageUnsupported) {
GTEST_SKIP() << "immutable/persistent buffer storage is unavailable on this stack ("
<< GLErrorName(m_storageError) << "); the persistent-map mutation cannot "
<< "be expressed here";
}
for (int frame = 0; frame < kWarmupFrames; ++frame) {
BeginFrame();
DrawScene();
Gl().EndFrame();
}
BeginFrame();
DrawScene();
const Image before = ReadFrame();
ExpectHalves(before, "red", "black", "steady state before the mutation");
ASSERT_FALSE(::testing::Test::HasFailure())
<< "the scenario never reached its steady state, so nothing after this means anything";
// >>> a genuine frame boundary. Everything below happens in the NEXT
// frame, which is the whole point: a mutation inside one frame proves
// nothing about a memo that revalidates itself across frames.
Gl().EndFrame();
BeginFrame();
ASSERT_NO_FATAL_FAILURE(mutate());
DrawScene();
const Image after = ReadFrame();
Gl().EndFrame();
ExpectHalves(after, expectedLeftAfter, expectedRightAfter,
"the draw after the mutation drew STALE buffer content");
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// The two things a scenario mutates.
void MutateVertexColorsToGreen(Mutation mutation) {
const std::vector<Vertex> updated = SceneVertices(/*leftQuadIsGreen=*/true);
const GLsizeiptr leftQuadBytes = GLsizeiptr(kLeftQuadVertexCount * sizeof(Vertex));
ApplyMutation(mutation, GL_ARRAY_BUFFER, m_vbo, m_vertexMap, updated.data() + kLeftQuadFirstVertex,
leftQuadBytes, updated.data(), m_vertexBytes);
}
void MutateIndicesToRightQuad(Mutation mutation) {
ApplyMutation(mutation, GL_ELEMENT_ARRAY_BUFFER, m_ebo, m_indexMap, kIndicesRightQuad, m_indexBytes,
kIndicesRightQuad, m_indexBytes);
}
unsigned int m_program = 0;
unsigned int m_vao = 0;
unsigned int m_vbo = 0;
unsigned int m_ebo = 0;
unsigned int m_staging = 0;
GLsizeiptr m_vertexBytes = 0;
GLsizeiptr m_indexBytes = 0;
unsigned char* m_vertexMap = nullptr;
unsigned char* m_indexMap = nullptr;
bool m_storageUnsupported = false;
unsigned int m_storageError = 0;
};
// ---- vertex buffer: the left quad must turn green ------------------
TEST_F(CrossFrameBufferScenario, VertexBufferSubData) {
RunAcrossFrameBoundary(
Mutation::SubData, [&] { MutateVertexColorsToGreen(Mutation::SubData); }, "green", "black");
}
TEST_F(CrossFrameBufferScenario, VertexMapWriteUnmap) {
RunAcrossFrameBoundary(
Mutation::MapWriteUnmap, [&] { MutateVertexColorsToGreen(Mutation::MapWriteUnmap); }, "green", "black");
}
TEST_F(CrossFrameBufferScenario, VertexPersistentMapFlush) {
RunAcrossFrameBoundary(
Mutation::PersistentFlush, [&] { MutateVertexColorsToGreen(Mutation::PersistentFlush); }, "green",
"black");
}
TEST_F(CrossFrameBufferScenario, VertexPersistentCoherentWrite) {
RunAcrossFrameBoundary(
Mutation::PersistentCoherent, [&] { MutateVertexColorsToGreen(Mutation::PersistentCoherent); }, "green",
"black");
}
TEST_F(CrossFrameBufferScenario, VertexOrphanAndReupload) {
RunAcrossFrameBoundary(
Mutation::OrphanReupload, [&] { MutateVertexColorsToGreen(Mutation::OrphanReupload); }, "green",
"black");
}
TEST_F(CrossFrameBufferScenario, VertexCopyBufferSubData) {
RunAcrossFrameBoundary(
Mutation::CopySubData, [&] { MutateVertexColorsToGreen(Mutation::CopySubData); }, "green", "black");
}
// ---- index buffer: the picture must jump to the right, green quad --
// The EBO memo had the same cross-frame hole as the vertex one, and no
// vertex-only test can see it.
TEST_F(CrossFrameBufferScenario, IndexBufferSubData) {
RunAcrossFrameBoundary(
Mutation::SubData, [&] { MutateIndicesToRightQuad(Mutation::SubData); }, "black", "green");
}
TEST_F(CrossFrameBufferScenario, IndexMapWriteUnmap) {
RunAcrossFrameBoundary(
Mutation::MapWriteUnmap, [&] { MutateIndicesToRightQuad(Mutation::MapWriteUnmap); }, "black", "green");
}
TEST_F(CrossFrameBufferScenario, IndexPersistentMapFlush) {
RunAcrossFrameBoundary(
Mutation::PersistentFlush, [&] { MutateIndicesToRightQuad(Mutation::PersistentFlush); }, "black",
"green");
}
// Kept, with its coverage stated exactly, because it is the one case in
// this file that is served a stale slice by the buggy revision and passes
// anyway - and a test that reads as coverage without being coverage is
// worse than no test.
//
// COVERS: the coherent-persistent index contract - a write into a coherent
// persistent mapping, with no GL call at all, must reach the next frame's
// draw. That is a real contract and this is the only case that states it
// for indices.
//
// DOES NOT COVER: the EBO cross-frame memo. Instrumented against the
// re-enabled buggy path, it enters the cross-frame branch 4 times and is
// served its recorded slice all 4 times - and still passes, because the
// backend adopted the persistent map into that very storage
// (AcquirePersistentMap succeeded), so the application's writes landed in
// the bytes the "stale" slice names. It would only discriminate on a stack
// where that adoption is declined and the CPU shadow stays authoritative;
// measured over this whole module, 50 of 50 coherent persistent write maps
// were adopted. See ResidentIndexScenario.cpp for the full account.
TEST_F(CrossFrameBufferScenario, IndexPersistentCoherentWrite) {
RunAcrossFrameBoundary(
Mutation::PersistentCoherent, [&] { MutateIndicesToRightQuad(Mutation::PersistentCoherent); }, "black",
"green");
}
TEST_F(CrossFrameBufferScenario, IndexOrphanAndReupload) {
RunAcrossFrameBoundary(
Mutation::OrphanReupload, [&] { MutateIndicesToRightQuad(Mutation::OrphanReupload); }, "black",
"green");
}
TEST_F(CrossFrameBufferScenario, IndexCopyBufferSubData) {
RunAcrossFrameBoundary(
Mutation::CopySubData, [&] { MutateIndicesToRightQuad(Mutation::CopySubData); }, "black", "green");
}
// ---- a self-test of the assertions, not of MobileGL ------------------
//
// Every case above leans on ExpectHalves. ExpectHalves used to sample the
// centre pixel of each half - two pixels for a 12288-pixel readback - and
// that is measurably too weak to stand behind a claim about buffer
// freshness: a quad whose four vertices are only PARTLY updated still
// paints a sampled centre the expected colour, because the centre is a
// barycentric blend dominated by the vertices that DID update.
//
// So construct that case on purpose. Update the left quad's colour to
// green in the buffer but leave exactly one of its four vertices holding
// the old red, once for each vertex, and check two things:
//
// - the whole-region scan reports every one of the four (the tightening
// is real, and this test fails the moment someone loosens it back to
// sampling);
// - at least one of the four is invisible to a single centre sample
// (the blind spot was real, and this records which vertices it hid).
//
// Nothing here calls a memo path; it is the assertion itself under test.
TEST_F(CrossFrameBufferScenario, PartialStalenessIsCaughtByWholeRegionChecks) {
ASSERT_NO_FATAL_FAILURE(BuildScene(/*immutable=*/false));
const std::vector<Vertex> allGreen = SceneVertices(/*leftQuadIsGreen=*/true);
const std::vector<Vertex> allRed = SceneVertices(/*leftQuadIsGreen=*/false);
const GLsizeiptr leftQuadBytes = GLsizeiptr(kLeftQuadVertexCount * sizeof(Vertex));
int centreSampleMissed = 0;
std::string missedVertices;
for (int staleVertex = 0; staleVertex < kLeftQuadVertexCount; ++staleVertex) {
// Every left-quad vertex turns green except this one.
std::vector<Vertex> partial(allGreen.begin(), allGreen.begin() + kLeftQuadVertexCount);
partial[std::size_t(staleVertex)] = allRed[std::size_t(staleVertex)];
glBindVertexArray(m_vao);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferSubData(GL_ARRAY_BUFFER, 0, leftQuadBytes, partial.data());
glBindVertexArray(0);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the partial update itself raised a GL error";
BeginFrame();
DrawScene();
const Image image = ReadFrame();
Gl().EndFrame();
const int w = image.Width();
const int h = image.Height();
const RegionScan scan =
ScanRegion(image, kHalfInset, w / 2 - kHalfInset, kHalfInset, h - kHalfInset, "green");
EXPECT_GT(scan.offenders, 0)
<< "vertex " << staleVertex << " of the left quad kept its stale red colour and the "
<< "whole-region scan saw nothing wrong across " << scan.total << " pixels - the assertion "
<< "is not tight enough to stand behind any freshness claim in this file";
// What the old two-pixel form of ExpectHalves would have concluded.
if (std::strcmp(image.ColorName(w / 4, h / 2), "green") == 0) {
++centreSampleMissed;
if (!missedVertices.empty()) missedVertices += ",";
missedVertices += std::to_string(staleVertex);
}
}
EXPECT_GT(centreSampleMissed, 0)
<< "no single-vertex staleness was invisible to a centre sample, so this negative control "
<< "is no longer demonstrating anything - re-derive it before trusting it";
if (centreSampleMissed > 0) {
RecordProperty("centre_sample_blind_to_stale_vertices", missedVertices);
std::fprintf(stderr,
"[itest] whole-region scan caught all %d single-stale-vertex cases; a centre "
"sample alone was blind to %d of them (vertices %s)\n",
kLeftQuadVertexCount, centreSampleMissed, missedVertices.c_str());
}
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// ---- the same bug, seen from the other side --------------------------
//
// The mutation cases above ask "did the new bytes reach the GPU?". This
// one asks the question a STREAMED buffer forces: "do the old bytes even
// still exist?".
//
// A GL_STREAM_DRAW / GL_DYNAMIC_DRAW buffer is not given permanent GPU
// storage. Every frame its contents are copied into that frame's
// transient upload arena, which is a bump allocator reset at the start of
// each frame slot - so a slice handed out in frame N names bytes that
// frame N+frames-in-flight hands to whoever uploads first. A memo that
// revalidates across a frame boundary and skips the acquire never
// re-uploads, so it keeps binding an offset the arena has since given
// away: the draw reads whatever the next tenant put there. That is the
// "random triangles" shape of this bug - the buffer nobody touched is the
// one that renders wrong.
//
// The scene makes the next tenant deterministic instead of arbitrary: a
// second streamed object of exactly the same size is uploaded and drawn
// FIRST in every frame, so it lands on precisely the bytes the memo still
// points at. A draw that renders the decoy's geometry instead of its own
// is unmissable.
class StreamedArenaScenario : public ScenarioTest {
protected:
static constexpr int kQuietFrames = 2; // frames in which only the subject draws
static constexpr int kChurnFrames = 8; // > frames-in-flight, so the ring wraps
struct StreamedObject {
unsigned int vao = 0;
unsigned int vbo = 0;
unsigned int ebo = 0;
};
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
void TearDown() override {
if (!Ready()) return;
for (StreamedObject* object : {&m_subject, &m_decoy}) {
if (object->ebo != 0) glDeleteBuffers(1, &object->ebo);
if (object->vbo != 0) glDeleteBuffers(1, &object->vbo);
if (object->vao != 0) glDeleteVertexArrays(1, &object->vao);
*object = StreamedObject{};
}
if (m_program != 0) glDeleteProgram(m_program);
}
// GL_STREAM_DRAW is what puts a buffer on the transient arena
// (ShouldUseTransientVertexIndexBuffer) - and what Minecraft uses for
// exactly this kind of geometry.
void BuildStreamedObject(StreamedObject& object, const std::vector<Vertex>& vertices,
const GLuint (&indices)[kIndexCount]) {
glGenVertexArrays(1, &object.vao);
glBindVertexArray(object.vao);
glGenBuffers(1, &object.vbo);
glBindBuffer(GL_ARRAY_BUFFER, object.vbo);
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data(),
GL_STREAM_DRAW);
glGenBuffers(1, &object.ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, object.ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(indices)), indices, GL_STREAM_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(0));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(8));
glBindVertexArray(0);
}
void Draw(const StreamedObject& object) {
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(m_program);
glBindVertexArray(object.vao);
glDrawElements(GL_TRIANGLES, kIndexCount, GL_UNSIGNED_INT, nullptr);
glBindVertexArray(0);
}
// Re-uploading the decoy is what forces it onto a fresh arena slice
// this frame - i.e. what makes it the arena's next tenant.
void RestreamDecoy(const std::vector<Vertex>& vertices, const GLuint (&indices)[kIndexCount]) {
glBindVertexArray(m_decoy.vao);
glBindBuffer(GL_ARRAY_BUFFER, m_decoy.vbo);
glBufferSubData(GL_ARRAY_BUFFER, 0, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_decoy.ebo);
glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, GLsizeiptr(sizeof(indices)), indices);
glBindVertexArray(0);
}
unsigned int m_program = 0;
StreamedObject m_subject;
StreamedObject m_decoy;
};
// Vertex data. Subject and decoy differ in geometry AND colour, so a
// subject draw that reads the decoy's arena bytes paints the decoy's quad.
TEST_F(StreamedArenaScenario, StreamedVertexDataSurvivesArenaRecycling) {
const std::vector<Vertex> full = SceneVertices(/*leftQuadIsGreen=*/false);
const std::vector<Vertex> subjectVertices(full.begin(), full.begin() + 4); // left, red
const std::vector<Vertex> decoyVertices(full.begin() + 4, full.begin() + 8); // right, green
ASSERT_EQ(subjectVertices.size(), decoyVertices.size()); // same arena footprint
BuildStreamedObject(m_subject, subjectVertices, kIndicesLeftQuad);
BuildStreamedObject(m_decoy, decoyVertices, kIndicesLeftQuad);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
// Quiet frames: the subject is the only thing uploading, so its data
// sits at the head of the arena and its memo records that offset.
for (int frame = 0; frame < kQuietFrames; ++frame) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
Draw(m_subject);
Gl().EndFrame();
}
// Churn frames: the decoy re-streams and draws first every frame. The
// subject is never touched again - it must still render itself.
for (int frame = 0; frame < kChurnFrames; ++frame) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
RestreamDecoy(decoyVertices, kIndicesLeftQuad);
Draw(m_decoy);
Draw(m_subject);
const Image image = ReadPixels(Gl().Width(), Gl().Height());
ExpectHalves(image, "red", "green",
"churn frame " + std::to_string(frame) +
": the untouched streamed vertex buffer rendered someone else's arena bytes");
Gl().EndFrame();
}
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// Index data. Both objects carry the SAME eight vertices, so only the
// element buffer can decide which half is drawn - this isolates the EBO
// memo, which had its own copy of the cross-frame hole.
//
// COVERS: that an untouched streamed index buffer still renders its own
// geometry after the arena it lives in has been recycled by another
// object - the index-side statement of the invariant the vertex case
// above actually catches.
//
// DOES NOT COVER: the EBO cross-frame memo. Instrumented against the
// re-enabled buggy path this case reaches that branch ZERO times: the memo
// is recorded only on the RESIDENT index path (UploadAndBindIndexBuffer
// stores it in the arm after AcquireResidentSlice), and a streamed EBO
// never gets there. So it passes on the buggy revision exactly as it does
// on the fixed one, and it is not evidence about the fix.
//
// It stays because it is the tripwire for the change that would make the
// EBO memo dangerous: memoise the streamed index path - the obvious next
// step for the same optimisation - and the reach stops being zero and this
// test fails on the first churn frame. See ResidentIndexScenario.cpp.
TEST_F(StreamedArenaScenario, StreamedIndexDataSurvivesArenaRecycling) {
const std::vector<Vertex> shared = SceneVertices(/*leftQuadIsGreen=*/false);
BuildStreamedObject(m_subject, shared, kIndicesLeftQuad); // draws the left, red quad
BuildStreamedObject(m_decoy, shared, kIndicesRightQuad); // draws the right, green quad
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
for (int frame = 0; frame < kQuietFrames; ++frame) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
Draw(m_subject);
Gl().EndFrame();
}
for (int frame = 0; frame < kChurnFrames; ++frame) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
RestreamDecoy(shared, kIndicesRightQuad);
Draw(m_decoy);
Draw(m_subject);
const Image image = ReadPixels(Gl().Width(), Gl().Height());
ExpectHalves(image, "red", "green",
"churn frame " + std::to_string(frame) +
": the untouched streamed index buffer rendered someone else's arena bytes");
Gl().EndFrame();
}
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,522 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/MultiDrawScenario.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 D - glMultiDrawElements(BaseVertex) against the draws it stands for.
//
// Neither entry point exists in OpenGL ES, so DirectGLES emulates both through a
// ladder of tiers (MG_Backend/DirectGLES/MultiDraw.cpp): a native
// glMultiDrawElementsBaseVertexEXT, synthesized indirect commands drawn one at a
// time or in one batch, a per-sub-draw replay, a CPU rewrite of the index stream,
// and a compute shader that flattens the whole batch into a single draw. They
// share nothing but their contract, which is the one thing asserted here:
//
// a multi-draw must paint exactly what the unrolled single draws paint.
//
// The reference side never enters the emulation - it is a loop of
// glDrawElementsBaseVertex / glDrawElements - so a tier cannot make itself look
// right by breaking both sides the same way.
//
// The Minecraft retraces already cover the common shape (GL_UNSIGNED_INT indices
// in a bound element array buffer, small base vertices, GL_TRIANGLES) on every
// tier. What they contain none of, and what these cases are for, is the set of
// shapes where a tier has to decline or compensate rather than replay:
//
// * narrow index types, where a rewritten stream has to widen (BYTE/SHORT);
// * a base vertex past the index type's range, where folding it into the
// indices at the source width silently wraps - GL adds base vertices at full
// precision, so `ushort index 10 + baseVertex 70000` is vertex 70010 and not
// vertex 4474;
// * primitive restart, where a rewritten stream must carry the sentinel across
// unrebased or the restart is lost and the strip welds shut;
// * client-memory index arrays, which have no buffer for the indirect tiers to
// address or for the compute tier to read;
// * a strip mode, which the flattening tier must decline outright because
// concatenation would weld one sub-draw's last primitive to the next
// sub-draw's first.
//
// One process is one tier (MOBILEGL_ESPRYT_MULTIDRAW_MODE is read once at
// startup), so a single run exercises whichever tier this driver resolved to.
// Running the binary once per mode is what covers the ladder; each run is a
// complete, self-contained proof for the tier it landed on.
#include <cstdint>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glext.h>
namespace MGITest {
namespace {
constexpr const char* kVertexSource = R"(#version 330 core
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec3 aColor;
out vec3 vColor;
void main() {
vColor = aColor;
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kFragmentSource = R"(#version 330 core
in vec3 vColor;
out vec4 oColor;
void main() {
oColor = vec4(vColor, 1.0);
}
)";
struct Vertex {
float x, y;
float r, g, b;
};
// Four column quads spanning the viewport left to right, in four colours,
// so a sub-draw that lands in the wrong place, draws the wrong vertices or
// does not draw at all changes the picture rather than hiding inside it.
constexpr int kColumns = 4;
const Rgba8 kColumnColors[kColumns] = {
{255, 0, 0, 255},
{0, 255, 0, 255},
{0, 0, 255, 255},
{255, 255, 255, 255},
};
// `padVertices` leading dummies force every sub-draw to need its own base
// vertex: without one applied, a draw reads the padding and paints black.
std::vector<Vertex> ColumnVertices(int padVertices) {
std::vector<Vertex> vertices(static_cast<std::size_t>(padVertices), Vertex{0.0f, 0.0f, 0.0f, 0.0f, 0.0f});
for (int column = 0; column < kColumns; ++column) {
const float x0 = -1.0f + 2.0f * static_cast<float>(column) / kColumns;
const float x1 = -1.0f + 2.0f * static_cast<float>(column + 1) / kColumns;
const Rgba8 color = kColumnColors[column];
const float r = color.r / 255.0f;
const float g = color.g / 255.0f;
const float b = color.b / 255.0f;
vertices.push_back({x0, -1.0f, r, g, b});
vertices.push_back({x1, -1.0f, r, g, b});
vertices.push_back({x1, 1.0f, r, g, b});
vertices.push_back({x0, 1.0f, r, g, b});
}
return vertices;
}
// Every sub-draw uses the SAME six indices, 0..3 relative to its own quad;
// only the base vertex tells the columns apart. That makes the base vertex
// the load-bearing part of the batch.
const std::uint32_t kQuadIndices[6] = {0, 1, 2, 0, 2, 3};
// One column, as a restart-separated pair of triangle strips. Two strips in
// one sub-draw means the sentinel is genuinely interior: drop it and the two
// halves weld into a single strip that paints across the gap between them.
// Indices are relative to the sub-draw's own quad, like kQuadIndices.
template <typename Index>
std::vector<Index> RestartStripIndices(Index restartSentinel) {
// 3,0,2,1 is the strip winding of the quad; splitting it around the
// sentinel gives two degenerate-free halves that redraw the same area.
return {Index{3}, Index{0}, Index{2}, restartSentinel, Index{0}, Index{2}, Index{1}};
}
class MultiDrawScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "program setup left a GL error behind";
}
void TearDown() override {
if (!Ready()) return;
ReleaseBuffers();
if (m_program != 0) glDeleteProgram(m_program);
}
// VAO + VBO, and an EBO only when `indexBytes` is non-null: a null one
// leaves GL_ELEMENT_ARRAY_BUFFER unbound so the sub-draws address client
// memory, which is the shape that forces the buffer-reading tiers out.
void BuildScene(int padVertices, const void* indexBytes, std::size_t indexByteCount) {
ReleaseBuffers();
const std::vector<Vertex> vertices = ColumnVertices(padVertices);
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(vertices.size() * sizeof(Vertex)),
vertices.data(), GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<const void*>(0));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<const void*>(sizeof(float) * 2));
if (indexBytes != nullptr) {
glGenBuffers(1, &m_ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, static_cast<GLsizeiptr>(indexByteCount), indexBytes,
GL_STATIC_DRAW);
}
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
}
void ReleaseBuffers() {
if (m_ebo != 0) glDeleteBuffers(1, &m_ebo);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_ebo = 0;
m_vbo = 0;
m_vao = 0;
}
GLuint m_program = 0;
GLuint m_vao = 0;
GLuint m_vbo = 0;
GLuint m_ebo = 0;
};
// Runs `draw`, reads the default framebuffer back and returns the image.
template <typename DrawFn>
Image RenderPass(GLuint program, GLuint vao, DrawFn&& draw) {
BindDefaultFramebuffer();
glViewport(0, 0, HeadlessGL::Get().Width(), HeadlessGL::Get().Height());
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(program);
glBindVertexArray(vao);
draw();
return ReadPixels(HeadlessGL::Get().Width(), HeadlessGL::Get().Height());
}
// The whole point of the file: two renderings of the same geometry, one
// through the multi-draw emulation and one through the single-draw entry
// points it stands for, must be identical to the byte.
void ExpectSameImage(const Image& multiDraw, const Image& unrolled, const std::string& what) {
ASSERT_FALSE(multiDraw.Empty()) << what << ": multi-draw readback was empty";
ASSERT_FALSE(unrolled.Empty()) << what << ": reference readback was empty";
EXPECT_EQ(multiDraw, unrolled)
<< what << ": glMultiDraw* painted something else than the draws it stands for ("
<< multiDraw.ByteDiffCount(unrolled) << " bytes differ; multi-draw quadrants "
<< multiDraw.QuadrantSignature() << ", unrolled quadrants " << unrolled.QuadrantSignature() << ")";
// A pair of blank frames would satisfy the comparison above and prove
// nothing at all - the failure mode a multi-draw path most often has is
// drawing NOTHING (see the shipped glMultiDrawElementsBaseVertexEXT stub
// that silently dropped every draw). Demand the columns really landed.
EXPECT_NE(multiDraw.QuadrantSignature(), "black,black,black,black") << what << ": nothing was drawn at all";
}
// ---- GL_UNSIGNED_INT indices in a buffer, per-sub-draw base vertices ----
TEST_F(MultiDrawScenario, BaseVertexBatchMatchesUnrolledDraws) {
if (!Ready()) return;
constexpr int kPad = 5; // odd, so nothing lines up by accident
BuildScene(kPad, kQuadIndices, sizeof(kQuadIndices));
GLsizei counts[kColumns];
const void* offsets[kColumns];
GLint baseVertices[kColumns];
for (int i = 0; i < kColumns; ++i) {
counts[i] = 6;
offsets[i] = reinterpret_cast<const void*>(0);
baseVertices[i] = kPad + i * 4;
}
const Image batched = RenderPass(m_program, m_vao, [&] {
glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_INT, offsets, kColumns, baseVertices);
});
const Image unrolled = RenderPass(m_program, m_vao, [&] {
for (int i = 0; i < kColumns; ++i) {
glDrawElementsBaseVertex(GL_TRIANGLES, counts[i], GL_UNSIGNED_INT, offsets[i], baseVertices[i]);
}
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectSameImage(batched, unrolled, "GL_UNSIGNED_INT indices, per-sub-draw base vertices");
}
// ---- glMultiDrawElements: no base vertices, distinct index offsets ----
TEST_F(MultiDrawScenario, PlainBatchMatchesUnrolledDraws) {
if (!Ready()) return;
// No padding and no base vertices: each sub-draw reaches its own column
// through its index offset instead.
std::vector<std::uint32_t> indices;
for (int column = 0; column < kColumns; ++column) {
for (const std::uint32_t index : kQuadIndices) {
indices.push_back(index + static_cast<std::uint32_t>(column * 4));
}
}
BuildScene(0, indices.data(), indices.size() * sizeof(std::uint32_t));
GLsizei counts[kColumns];
const void* offsets[kColumns];
for (int i = 0; i < kColumns; ++i) {
counts[i] = 6;
offsets[i] = reinterpret_cast<const void*>(static_cast<std::uintptr_t>(i * 6 * sizeof(std::uint32_t)));
}
const Image batched = RenderPass(m_program, m_vao, [&] {
glMultiDrawElements(GL_TRIANGLES, counts, GL_UNSIGNED_INT, offsets, kColumns);
});
const Image unrolled = RenderPass(m_program, m_vao, [&] {
for (int i = 0; i < kColumns; ++i) {
glDrawElements(GL_TRIANGLES, counts[i], GL_UNSIGNED_INT, offsets[i]);
}
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectSameImage(batched, unrolled, "glMultiDrawElements with no base vertices");
}
// ---- narrow index types ----
// A tier that rewrites the stream emits GL_UNSIGNED_INT whatever came in,
// so these two say the widening reproduces the original draw exactly.
TEST_F(MultiDrawScenario, UnsignedShortBatchMatchesUnrolledDraws) {
if (!Ready()) return;
constexpr int kPad = 3;
std::uint16_t indices[6];
for (int i = 0; i < 6; ++i)
indices[i] = static_cast<std::uint16_t>(kQuadIndices[i]);
BuildScene(kPad, indices, sizeof(indices));
GLsizei counts[kColumns];
const void* offsets[kColumns];
GLint baseVertices[kColumns];
for (int i = 0; i < kColumns; ++i) {
counts[i] = 6;
offsets[i] = reinterpret_cast<const void*>(0);
baseVertices[i] = kPad + i * 4;
}
const Image batched = RenderPass(m_program, m_vao, [&] {
glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_SHORT, offsets, kColumns, baseVertices);
});
const Image unrolled = RenderPass(m_program, m_vao, [&] {
for (int i = 0; i < kColumns; ++i) {
glDrawElementsBaseVertex(GL_TRIANGLES, counts[i], GL_UNSIGNED_SHORT, offsets[i], baseVertices[i]);
}
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectSameImage(batched, unrolled, "GL_UNSIGNED_SHORT indices");
}
TEST_F(MultiDrawScenario, UnsignedByteBatchMatchesUnrolledDraws) {
if (!Ready()) return;
constexpr int kPad = 3;
std::uint8_t indices[6];
for (int i = 0; i < 6; ++i)
indices[i] = static_cast<std::uint8_t>(kQuadIndices[i]);
// 24 bytes: a word multiple, which the compute tier needs of the source
// buffer when the index type is narrower than a word.
std::uint8_t padded[24] = {};
for (int i = 0; i < 6; ++i)
padded[i] = indices[i];
BuildScene(kPad, padded, sizeof(padded));
GLsizei counts[kColumns];
const void* offsets[kColumns];
GLint baseVertices[kColumns];
for (int i = 0; i < kColumns; ++i) {
counts[i] = 6;
offsets[i] = reinterpret_cast<const void*>(0);
baseVertices[i] = kPad + i * 4;
}
const Image batched = RenderPass(m_program, m_vao, [&] {
glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_BYTE, offsets, kColumns, baseVertices);
});
const Image unrolled = RenderPass(m_program, m_vao, [&] {
for (int i = 0; i < kColumns; ++i) {
glDrawElementsBaseVertex(GL_TRIANGLES, counts[i], GL_UNSIGNED_BYTE, offsets[i], baseVertices[i]);
}
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectSameImage(batched, unrolled, "GL_UNSIGNED_BYTE indices");
}
// ---- a base vertex the index type cannot spell ----
// GL adds the base vertex at full precision, so folding it into a
// GL_UNSIGNED_SHORT index stream at the source width wraps and addresses the
// wrong vertex. The columns here start past 65535, which no ushort index can
// reach on its own.
TEST_F(MultiDrawScenario, BaseVertexBeyondIndexTypeRangeMatchesUnrolledDraws) {
if (!Ready()) return;
constexpr int kPad = 70000; // > 0xFFFF
std::uint16_t indices[6];
for (int i = 0; i < 6; ++i)
indices[i] = static_cast<std::uint16_t>(kQuadIndices[i]);
BuildScene(kPad, indices, sizeof(indices));
GLsizei counts[kColumns];
const void* offsets[kColumns];
GLint baseVertices[kColumns];
for (int i = 0; i < kColumns; ++i) {
counts[i] = 6;
offsets[i] = reinterpret_cast<const void*>(0);
baseVertices[i] = kPad + i * 4;
}
const Image batched = RenderPass(m_program, m_vao, [&] {
glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_SHORT, offsets, kColumns, baseVertices);
});
const Image unrolled = RenderPass(m_program, m_vao, [&] {
for (int i = 0; i < kColumns; ++i) {
glDrawElementsBaseVertex(GL_TRIANGLES, counts[i], GL_UNSIGNED_SHORT, offsets[i], baseVertices[i]);
}
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectSameImage(batched, unrolled, "base vertex past the GL_UNSIGNED_SHORT range");
}
// ---- client-memory index arrays ----
// No element array buffer, so the indirect tiers have nothing to address and
// the compute tier nothing to read; both must decline and hand the batch to
// a tier that can replay it.
TEST_F(MultiDrawScenario, ClientSideIndicesBatchMatchesUnrolledDraws) {
if (!Ready()) return;
constexpr int kPad = 5;
BuildScene(kPad, nullptr, 0);
GLsizei counts[kColumns];
const void* offsets[kColumns];
GLint baseVertices[kColumns];
for (int i = 0; i < kColumns; ++i) {
counts[i] = 6;
offsets[i] = kQuadIndices;
baseVertices[i] = kPad + i * 4;
}
const Image batched = RenderPass(m_program, m_vao, [&] {
glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_INT, offsets, kColumns, baseVertices);
});
const Image unrolled = RenderPass(m_program, m_vao, [&] {
for (int i = 0; i < kColumns; ++i) {
glDrawElementsBaseVertex(GL_TRIANGLES, counts[i], GL_UNSIGNED_INT, offsets[i], baseVertices[i]);
}
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectSameImage(batched, unrolled, "client-memory index arrays");
}
// ---- primitive restart inside a strip ----
// Two things at once: a strip mode, which the flattening tier must decline
// because concatenation would weld sub-draws together, and a restart
// sentinel, which any tier that rewrites indices must carry across without
// adding the base vertex to it.
TEST_F(MultiDrawScenario, PrimitiveRestartStripBatchMatchesUnrolledDraws) {
if (!Ready()) return;
constexpr int kPad = 5;
const std::vector<std::uint32_t> indices = RestartStripIndices<std::uint32_t>(0xFFFFFFFFu);
BuildScene(kPad, indices.data(), indices.size() * sizeof(std::uint32_t));
GLsizei counts[kColumns];
const void* offsets[kColumns];
GLint baseVertices[kColumns];
for (int i = 0; i < kColumns; ++i) {
counts[i] = static_cast<GLsizei>(indices.size());
offsets[i] = reinterpret_cast<const void*>(0);
baseVertices[i] = kPad + i * 4;
}
glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
const Image batched = RenderPass(m_program, m_vao, [&] {
glMultiDrawElementsBaseVertex(GL_TRIANGLE_STRIP, counts, GL_UNSIGNED_INT, offsets, kColumns,
baseVertices);
});
const Image unrolled = RenderPass(m_program, m_vao, [&] {
for (int i = 0; i < kColumns; ++i) {
glDrawElementsBaseVertex(GL_TRIANGLE_STRIP, counts[i], GL_UNSIGNED_INT, offsets[i],
baseVertices[i]);
}
});
glDisable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectSameImage(batched, unrolled, "GL_TRIANGLE_STRIP with primitive restart");
}
// Same, with GL_UNSIGNED_SHORT: the sentinel a rewritten stream has to
// recognise is the index TYPE's all-ones value, not the rewritten stream's.
TEST_F(MultiDrawScenario, PrimitiveRestartUnsignedShortBatchMatchesUnrolledDraws) {
if (!Ready()) return;
constexpr int kPad = 5;
const std::vector<std::uint16_t> indices = RestartStripIndices<std::uint16_t>(0xFFFFu);
BuildScene(kPad, indices.data(), indices.size() * sizeof(std::uint16_t));
GLsizei counts[kColumns];
const void* offsets[kColumns];
GLint baseVertices[kColumns];
for (int i = 0; i < kColumns; ++i) {
counts[i] = static_cast<GLsizei>(indices.size());
offsets[i] = reinterpret_cast<const void*>(0);
baseVertices[i] = kPad + i * 4;
}
glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
const Image batched = RenderPass(m_program, m_vao, [&] {
glMultiDrawElementsBaseVertex(GL_TRIANGLE_STRIP, counts, GL_UNSIGNED_SHORT, offsets, kColumns,
baseVertices);
});
const Image unrolled = RenderPass(m_program, m_vao, [&] {
for (int i = 0; i < kColumns; ++i) {
glDrawElementsBaseVertex(GL_TRIANGLE_STRIP, counts[i], GL_UNSIGNED_SHORT, offsets[i],
baseVertices[i]);
}
});
glDisable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectSameImage(batched, unrolled, "GL_TRIANGLE_STRIP with GL_UNSIGNED_SHORT primitive restart");
}
// ---- a batch with holes ----
// Zero-count sub-draws draw nothing. The flattening tier's binary search
// finds a sub-draw by prefix sum, and a zero-count entry repeats the
// previous sum - so a search that resolves ties the other way would attribute
// indices to the empty draw and paint the wrong column.
TEST_F(MultiDrawScenario, ZeroCountSubDrawsMatchUnrolledDraws) {
if (!Ready()) return;
constexpr int kPad = 5;
BuildScene(kPad, kQuadIndices, sizeof(kQuadIndices));
GLsizei counts[kColumns];
const void* offsets[kColumns];
GLint baseVertices[kColumns];
for (int i = 0; i < kColumns; ++i) {
// Columns 1 and 2 are skipped, leaving the outer two painted.
counts[i] = (i == 1 || i == 2) ? 0 : 6;
offsets[i] = reinterpret_cast<const void*>(0);
baseVertices[i] = kPad + i * 4;
}
const Image batched = RenderPass(m_program, m_vao, [&] {
glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_INT, offsets, kColumns, baseVertices);
});
const Image unrolled = RenderPass(m_program, m_vao, [&] {
for (int i = 0; i < kColumns; ++i) {
if (counts[i] == 0) continue;
glDrawElementsBaseVertex(GL_TRIANGLES, counts[i], GL_UNSIGNED_INT, offsets[i], baseVertices[i]);
}
});
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ExpectSameImage(batched, unrolled, "a batch with zero-count sub-draws");
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,381 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/OrientationScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario A - "the frame came out upside down".
//
// The shipped bug (DirectVulkan, GetBaseTransformFlagsRaw): the shader
// transform flags - the Y-flip and surface-rotation bits that apply ONLY when
// the bound draw framebuffer is the default one - were memoized on the
// swapchain pre-transform alone. The is-default-framebuffer input was not part
// of the key, so whichever kind of pass evaluated the memo first decided the
// orientation of every pass after it. In a real frame that meant: after any
// render-to-texture pass, the next default-framebuffer pass inherited the FBO's
// unflipped flags and the whole frame rendered upside down (retrace SSIM 0.052,
// deterministic; flickering clouds on device).
//
// What pins it: a pattern asymmetric in BOTH axes - four quadrants, coloured
//
// top-left RED | WHITE top-right
// bottom-left BLUE | GREEN bottom-right
//
// - drawn to a target, read back with glReadPixels, and reduced to the four
// quadrant-centre colours in the fixed order bottom-left, bottom-right,
// top-left, top-right.
//
// Four quadrants rather than the three horizontal stripes this scenario used to
// draw, because stripes only pin ONE axis. Stripes read down the centre line
// are unchanged by an X flip, by a transpose, and by a 180 rotation composed
// with a Y flip: all three of those bugs would have rendered a green stripe
// between a blue one and a red one and passed. Every one of the eight
// symmetries of the square now produces a different string:
//
// identity blue,green,red,white <- correct
// Y flip red,white,blue,green <- the shipped bug
// X flip green,blue,white,red
// 180 rotation white,red,green,blue
// transpose blue,red,green,white
// anti-transpose white,green,red,blue
// rotate 90 CCW red,blue,white,green
// rotate 90 CW green,white,blue,red
//
// The assertions then go further than the signature: every quadrant is checked
// pixel by pixel over its whole area (RegionIsMostly), so a partial or torn
// draw cannot pass by having the four sampled centres come out right.
//
// Both orderings are covered, because the memo is poisoned by whichever pass
// runs first and these tests share one process:
// - default -> FBO -> default (the FBO pass inherits the default's flip)
// - FBO -> default (the shipped symptom: the default pass
// inherits the FBO's lack of flip)
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr const char* kVertexSource = R"(#version 330 core
in vec2 aPos;
in vec3 aColor;
out vec3 vColor;
void main() {
vColor = aColor;
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kFragmentSource = R"(#version 330 core
in vec3 vColor;
out vec4 oColor;
void main() {
oColor = vec4(vColor, 1.0);
}
)";
// The correctly-oriented answer, in glReadPixels order (row 0 is the
// bottom row) and in QuadrantSignature's order: bottom-left, bottom-right,
// top-left, top-right. Plain GL semantics; holds for every framebuffer,
// default or not.
constexpr const char* kUprightSignature = "blue,green,red,white";
// How far inside each quadrant the whole-region checks start. The quadrant
// seam sits on a pixel boundary, so one pixel of margin is enough to make
// "every single pixel" an achievable (and therefore useful) demand.
constexpr int kQuadrantInset = 2;
struct Vertex {
float x, y;
float r, g, b;
};
void AppendQuad(std::vector<Vertex>& out, float x0, float x1, float y0, float y1, float r, float g, float b) {
const Vertex bl{x0, y0, r, g, b};
const Vertex br{x1, y0, r, g, b};
const Vertex tr{x1, y1, r, g, b};
const Vertex tl{x0, y1, r, g, b};
out.insert(out.end(), {bl, br, tr, bl, tr, tl});
}
std::vector<Vertex> QuadrantGeometry() {
std::vector<Vertex> vertices;
vertices.reserve(24);
AppendQuad(vertices, -1.0f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f); // bottom-left: blue
AppendQuad(vertices, 0.0f, 1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f); // bottom-right: green
AppendQuad(vertices, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f); // top-left: red
AppendQuad(vertices, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f); // top-right: white
return vertices;
}
class OrientationScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
const std::vector<Vertex> vertices = QuadrantGeometry();
m_vertexCount = static_cast<int>(vertices.size());
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data(),
GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(0));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(8));
glBindVertexArray(0);
m_offscreen = MakeColorFbo(Gl().Width(), Gl().Height());
ASSERT_NE(m_offscreen.fbo, 0u) << "offscreen FBO is not framebuffer-complete";
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "setup left a GL error behind";
}
void TearDown() override {
if (!Ready()) return;
DestroyColorFbo(m_offscreen);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_program != 0) glDeleteProgram(m_program);
}
void DrawQuadrants() {
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(m_program);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, m_vertexCount);
glBindVertexArray(0);
}
// One pass to the default (presentable) framebuffer.
Image DefaultFramebufferPass() {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawQuadrants();
return ReadPixels(Gl().Width(), Gl().Height());
}
// One render-to-texture pass. Real frames do this constantly
// (shadow maps, post-processing, Minecraft's main render target).
Image OffscreenPass() {
BindFbo(m_offscreen);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawQuadrants();
return ReadPixels(m_offscreen.width, m_offscreen.height);
}
// The signature says WHICH transform went wrong; this says the whole
// image is right, not merely its four sampled centres.
void ExpectUprightQuadrants(const Image& image, const std::string& when) {
const int w = image.Width();
const int h = image.Height();
const int inset = kQuadrantInset;
EXPECT_TRUE(RegionIsMostly(image, inset, w / 2 - inset, inset, h / 2 - inset, "blue", 0.0, when));
EXPECT_TRUE(RegionIsMostly(image, w / 2 + inset, w - inset, inset, h / 2 - inset, "green", 0.0, when));
EXPECT_TRUE(RegionIsMostly(image, inset, w / 2 - inset, h / 2 + inset, h - inset, "red", 0.0, when));
EXPECT_TRUE(RegionIsMostly(image, w / 2 + inset, w - inset, h / 2 + inset, h - inset, "white", 0.0,
when));
}
unsigned int m_program = 0;
unsigned int m_vao = 0;
unsigned int m_vbo = 0;
int m_vertexCount = 0;
ColorFbo m_offscreen;
};
// The plain statement of GL semantics that everything else leans on: an
// FBO pass is never flipped.
TEST_F(OrientationScenario, OffscreenPassRendersUpright) {
const Image offscreen = OffscreenPass();
EXPECT_EQ(offscreen.QuadrantSignature(), kUprightSignature)
<< "a render-to-texture pass must render unflipped";
ExpectUprightQuadrants(offscreen, "render-to-texture pass");
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// The same for the default framebuffer: whatever the backend does with
// the swapchain internally, glReadPixels owes the caller GL orientation.
TEST_F(OrientationScenario, DefaultFramebufferPassRendersUpright) {
const Image presented = DefaultFramebufferPass();
EXPECT_EQ(presented.QuadrantSignature(), kUprightSignature)
<< "a default-framebuffer pass must read back in GL orientation";
ExpectUprightQuadrants(presented, "default-framebuffer pass");
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// Scenario A proper: default -> FBO -> default in one frame. The third
// pass must be pixel-identical to the first; the FBO pass in between
// must not have moved anything.
TEST_F(OrientationScenario, DefaultFramebufferSurvivesAnOffscreenPass) {
const Image before = DefaultFramebufferPass();
const Image offscreen = OffscreenPass();
const Image after = DefaultFramebufferPass();
EXPECT_EQ(before.QuadrantSignature(), kUprightSignature)
<< "first default-framebuffer pass is already misoriented";
EXPECT_EQ(offscreen.QuadrantSignature(), kUprightSignature)
<< "the render-to-texture pass in the middle rendered flipped - the "
"default framebuffer's transform flags leaked into it";
EXPECT_EQ(after.QuadrantSignature(), kUprightSignature)
<< "the default-framebuffer pass AFTER a render-to-texture pass is "
"misoriented - it inherited the FBO's transform flags";
ExpectUprightQuadrants(after, "default-framebuffer pass after a render-to-texture pass");
EXPECT_TRUE(after == before) << "the third pass differs from the first in " << after.ByteDiffCount(before)
<< " bytes; first=" << before.QuadrantSignature()
<< " third=" << after.QuadrantSignature();
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// The shipped symptom, in its shipped order: an FBO pass, then the
// default framebuffer. This is the one that flipped whole Minecraft
// frames.
TEST_F(OrientationScenario, DefaultFramebufferAfterOffscreenIsNotFlipped) {
const Image offscreen = OffscreenPass();
const Image presented = DefaultFramebufferPass();
EXPECT_EQ(offscreen.QuadrantSignature(), kUprightSignature)
<< "render-to-texture pass rendered flipped";
EXPECT_EQ(presented.QuadrantSignature(), kUprightSignature)
<< "the default-framebuffer pass that follows a render-to-texture pass "
"rendered upside down";
ExpectUprightQuadrants(presented, "default-framebuffer pass following a render-to-texture pass");
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// And across a real frame boundary, which is how a game actually
// alternates the two kinds of pass.
TEST_F(OrientationScenario, OrientationIsStableAcrossFrames) {
const Image firstFrame = DefaultFramebufferPass();
ExpectUprightQuadrants(firstFrame, "frame 0");
Gl().EndFrame();
for (int frame = 0; frame < 3; ++frame) {
const Image offscreen = OffscreenPass();
EXPECT_EQ(offscreen.QuadrantSignature(), kUprightSignature)
<< "frame " << frame + 1 << "'s render-to-texture pass is misoriented";
const Image presented = DefaultFramebufferPass();
EXPECT_EQ(presented.QuadrantSignature(), kUprightSignature)
<< "frame " << frame + 1 << " of the alternating FBO/default loop is misoriented";
ExpectUprightQuadrants(presented, "frame " + std::to_string(frame + 1));
EXPECT_TRUE(presented == firstFrame) << "frame " << frame + 1 << " differs from frame 0 in "
<< presented.ByteDiffCount(firstFrame) << " bytes";
Gl().EndFrame();
}
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// A standing self-test of the signature, not of MobileGL: it proves the
// four-quadrant reduction really does separate all eight symmetries of
// the square, so a future "simplify the pattern" change cannot quietly
// reintroduce the blind spot the three-stripe version had (X flip,
// transpose and 180+Y-flip all left the stripe signature alone).
TEST_F(OrientationScenario, QuadrantSignatureSeparatesEverySquareSymmetry) {
const Image upright = OffscreenPass();
ASSERT_EQ(upright.QuadrantSignature(), kUprightSignature) << "the reference image is not upright";
const int w = upright.Width();
const int h = upright.Height();
// Transposes are expressed on the largest centred square the readback
// contains, which is enough for the four quadrant centres to move.
const int side = std::min(w, h);
const int ox = (w - side) / 2;
const int oy = (h - side) / 2;
struct Symmetry {
const char* name;
const char* expected;
int (*mapX)(int x, int y, int w, int h);
int (*mapY)(int x, int y, int w, int h);
};
const Symmetry symmetries[] = {
{"Y flip", "red,white,blue,green", [](int x, int, int, int) { return x; },
[](int, int y, int, int hh) { return hh - 1 - y; }},
{"X flip", "green,blue,white,red", [](int x, int, int ww, int) { return ww - 1 - x; },
[](int, int y, int, int) { return y; }},
{"180 rotation", "white,red,green,blue", [](int x, int, int ww, int) { return ww - 1 - x; },
[](int, int y, int, int hh) { return hh - 1 - y; }},
};
for (const Symmetry& symmetry : symmetries) {
Image transformed(w, h);
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
const Rgba8 source = upright.At(symmetry.mapX(x, y, w, h), symmetry.mapY(x, y, w, h));
std::uint8_t* out = transformed.Data() + (std::size_t(y) * w + x) * 4;
out[0] = source.r;
out[1] = source.g;
out[2] = source.b;
out[3] = source.a;
}
}
EXPECT_EQ(transformed.QuadrantSignature(), symmetry.expected)
<< symmetry.name << " must produce its own signature, or the pattern cannot see it";
EXPECT_NE(transformed.QuadrantSignature(), kUprightSignature)
<< symmetry.name << " is INDISTINGUISHABLE from an upright frame - the pattern is too symmetric";
}
// The four symmetries that move the axes into each other. They only
// make sense on a square, so they run on the largest centred one.
struct SquareSymmetry {
const char* name;
const char* expected;
int (*sourceX)(int x, int y, int side);
int (*sourceY)(int x, int y, int side);
};
const SquareSymmetry squareSymmetries[] = {
{"transpose", "blue,red,green,white", [](int, int y, int) { return y; },
[](int x, int, int) { return x; }},
{"anti-transpose", "white,green,red,blue", [](int, int y, int s) { return s - 1 - y; },
[](int x, int, int s) { return s - 1 - x; }},
{"rotate 90 CCW", "red,blue,white,green", [](int, int y, int) { return y; },
[](int x, int, int s) { return s - 1 - x; }},
{"rotate 90 CW", "green,white,blue,red", [](int, int y, int s) { return s - 1 - y; },
[](int x, int, int) { return x; }},
};
for (const SquareSymmetry& symmetry : squareSymmetries) {
Image square(side, side);
for (int y = 0; y < side; ++y) {
for (int x = 0; x < side; ++x) {
const Rgba8 source =
upright.At(ox + symmetry.sourceX(x, y, side), oy + symmetry.sourceY(x, y, side));
std::uint8_t* out = square.Data() + (std::size_t(y) * side + x) * 4;
out[0] = source.r;
out[1] = source.g;
out[2] = source.b;
out[3] = source.a;
}
}
EXPECT_EQ(square.QuadrantSignature(), symmetry.expected)
<< symmetry.name << " must produce its own signature, or the pattern cannot see it";
EXPECT_NE(square.QuadrantSignature(), kUprightSignature)
<< symmetry.name << " is INDISTINGUISHABLE from an upright frame";
}
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,383 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ResidentIndexScenario.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 C - RESIDENT index buffers across frame boundaries.
//
// WHAT THIS FILE DOES AND DOES NOT COVER, stated plainly because the answer is
// not the one it was written to find.
//
// The shipped fix (d7976326) removed cross-frame slice trust from TWO memos: the
// vertex-binding one and the EBO one. StreamedArenaScenario pins the vertex
// half - re-enable that half alone and it fails. Nothing pinned the EBO half,
// and these cases are the result of trying to build something that does.
//
// The EBO memo lives in UploadAndBindIndexBuffer and is recorded ONLY on the
// resident branch, keyed on (BufferObject*, VkBufferResource::sliceEpoch,
// frame serial). To fail with only the EBO revalidation re-enabled, a scenario
// needs a RESIDENT index buffer whose recorded slice stops describing the right
// bytes while the pointer and the epoch still match. Every case below is an
// attempt at that, run against the re-enabled buggy path with the branch
// instrumented to count reaches, acceptances, and - critically - what the
// skipped AcquireResidentSlice WOULD have done. The measurement, over this file
// plus every other scenario in the module:
//
// reached=89 accepted=81 sliceMoved=0 bytesChanged=0 epochBumped=0
//
// The buggy branch is entered 89 times and serves its recorded slice 81 times,
// and in NOT ONE of those 81 would the acquire have moved the slice, changed a
// byte of it, or bumped the epoch. The skipped work was a no-op every time.
//
// That is not luck, it is the shape of the code. A resident slice is
// `resource->buffer.GetSlice(0, size)` of a dedicated VkBuffer, so it can only
// move when CreateResidentStorage mints new storage - which bumps the epoch. Its
// bytes can only change through Respecify / SubData / FlushMappedRange - each of
// which bumps the epoch as its first act - or through
// BufferObject::SyncPersistentMappedRange, which the acquire calls and the memo
// skips. That last one is the real escape, and it is dead here: it early-outs
// when the backend has adopted the map into coherent GPU storage, and
// AcquirePersistentMap only declines when a host-visible coherent allocation
// FAILS. Instrumented across the whole module: 50 persistent coherent write
// maps, 50 adopted, 0 dispatches. A 96 MiB EBO did not change that either.
//
// So on DirectVulkan as it stands, the EBO half of the fix is not reachable from
// a GL-level test - not because the guard is sound in principle (it is the same
// unsound idea the vertex half shipped corruption with) but because the two
// mechanisms that made the vertex half observable are both absent for indices:
//
// 1. ARENA RELOCATION. The vertex memo records STREAMED slices too, and a
// streamed slice moves to a new arena block every frame BY DESIGN - the
// epoch that catches it is bumped inside the very acquire the memo skips.
// That is what StreamedVertexDataSurvivesArenaRecycling exploits. The index
// memo is never recorded on the streamed branch, so no index memo ever
// names an arena offset. Measured: StreamedIndexDataSurvivesArenaRecycling
// reaches the branch 0 times, and so does PromotedDynamicEbo below (a
// promoted DYNAMIC_DRAW buffer is SERVED by AcquireResidentSlice but still
// ROUTED as streamed, so it is not memoised either).
// 2. HOST-MAP SYNC. Dead, as above.
//
// These cases therefore stay as what they honestly are: end-to-end regression
// tests for resident index-buffer freshness across frame boundaries, and the
// standing tripwire for change (1). The moment anyone memoises the streamed or
// promoted index path - the natural next step for the same optimisation - these
// stop being redundant and start failing. Each case says below what it covers.
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr const char* kVS = R"(#version 330 core
in vec2 aPos;
in vec3 aColor;
out vec3 vColor;
void main() { vColor = aColor; gl_Position = vec4(aPos, 0.0, 1.0); }
)";
constexpr const char* kFS = R"(#version 330 core
in vec3 vColor;
out vec4 oColor;
void main() { oColor = vec4(vColor, 1.0); }
)";
struct V {
float x, y, r, g, b;
};
constexpr int kIdx = 6;
const GLuint kLeft[kIdx] = {0, 1, 2, 0, 2, 3};
const GLuint kRight[kIdx] = {4, 5, 6, 4, 6, 7};
std::vector<V> Scene() {
return {{-1, -1, 1, 0, 0}, {0, -1, 1, 0, 0}, {0, 1, 1, 0, 0}, {-1, 1, 1, 0, 0},
{0, -1, 0, 1, 0}, {1, -1, 0, 1, 0}, {1, 1, 0, 1, 0}, {0, 1, 0, 1, 0}};
}
class ResidentIndexScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string err;
m_program = CompileProgram(kVS, kFS, &err);
ASSERT_NE(m_program, 0u) << err;
}
void TearDown() override {
if (!Ready()) return;
if (m_program != 0) glDeleteProgram(m_program);
}
// A VAO whose VBO is STATIC_DRAW (so it resolves resident and the
// vertex memo is recorded) and whose EBO is `eboName`.
unsigned int MakeVao(unsigned int vbo, unsigned int ebo) {
unsigned int vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast<void*>(0));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast<void*>(8));
glBindVertexArray(0);
return vao;
}
unsigned int MakeStaticVbo() {
const std::vector<V> vertices = Scene();
unsigned int vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(V)), vertices.data(),
GL_STATIC_DRAW);
return vbo;
}
void Draw(unsigned int vao) {
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(m_program);
glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, kIdx, GL_UNSIGNED_INT, nullptr);
glBindVertexArray(0);
}
void Begin() {
BindDefaultFramebuffer();
ClearTo(0, 0, 0, 1);
}
Image Read() { return ReadPixels(Gl().Width(), Gl().Height()); }
void Halves(const Image& image, const char* left, const char* right, const std::string& when) {
const int w = image.Width(), h = image.Height();
EXPECT_TRUE(RegionIsMostly(image, 2, w / 2 - 2, 2, h - 2, left, 0.0, when + " [left]"));
EXPECT_TRUE(RegionIsMostly(image, w / 2 + 2, w - 2, 2, h - 2, right, 0.0, when + " [right]"));
}
unsigned int m_program = 0;
};
// A: a coherent persistent EBO rewritten on EVERY frame, with no GL call
// between the write and the draw. This is the only shape in which an
// application changes index data with nothing for the backend to notice.
//
// COVERS: the coherent-persistent index contract end to end.
// DOES NOT COVER: the EBO memo. Instrumented it reaches the cross-frame
// branch 11 times and is served its recorded slice all 11 - but the
// backend adopted the map into that same storage, so the "stale" slice IS
// where the application's writes landed. It would only discriminate on a
// stack where AcquirePersistentMap declines (see the file header). A
// 96 MiB variant was tried to force that and did not: it cost 40s and
// measured the same zero, so it is not kept.
TEST_F(ResidentIndexScenario, PersistentCoherentEboWrittenEveryFrame) {
const unsigned int vbo = MakeStaticVbo();
unsigned int ebo = 0;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
const GLbitfield storageFlags =
GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT | GL_DYNAMIC_STORAGE_BIT;
glBufferStorage(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kLeft)), kLeft, storageFlags);
if (FirstGLError() != GL_NO_ERROR) GTEST_SKIP() << "no immutable storage";
auto* map = static_cast<unsigned char*>(glMapBufferRange(
GL_ELEMENT_ARRAY_BUFFER, 0, GLsizeiptr(sizeof(kLeft)),
GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT));
ASSERT_NE(map, nullptr);
const unsigned int vao = MakeVao(vbo, ebo);
for (int frame = 0; frame < 12; ++frame) {
Begin();
const bool wantRight = (frame % 2) == 1;
std::memcpy(map, wantRight ? kRight : kLeft, sizeof(kLeft));
Draw(vao);
const Image image = Read();
Halves(image, wantRight ? "black" : "red", wantRight ? "green" : "black",
"frame " + std::to_string(frame) + " of a per-frame coherent EBO rewrite");
Gl().EndFrame();
}
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glUnmapBuffer(GL_ELEMENT_ARRAY_BUFFER);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &ebo);
glDeleteBuffers(1, &vbo);
}
// B: usage escalation. The EBO is memoised as an index buffer, then bound
// as a VERTEX buffer in a later frame, which forces the backend to
// recreate its resident storage carrying the extra usage bit. A memo that
// survived that recreate would name a destroyed VkBuffer.
//
// COVERS: that a storage recreate driven by a DIFFERENT binding point
// retires the index memo. Reaches the branch 5 times.
TEST_F(ResidentIndexScenario, EboAlsoBoundAsVertexBufferLater) {
const unsigned int vbo = MakeStaticVbo();
unsigned int ebo = 0;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
// Big enough to be a legal (if nonsensical) vertex source too.
std::vector<GLuint> indices(64, 0);
std::memcpy(indices.data(), kLeft, sizeof(kLeft));
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(indices.size() * 4), indices.data(), GL_STATIC_DRAW);
const unsigned int vao = MakeVao(vbo, ebo);
unsigned int vertexUseVao = 0;
glGenVertexArrays(1, &vertexUseVao);
glBindVertexArray(vertexUseVao);
glBindBuffer(GL_ARRAY_BUFFER, ebo); // the EBO, as a vertex source
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast<void*>(0));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast<void*>(8));
glBindVertexArray(0);
for (int frame = 0; frame < 6; ++frame) {
Begin();
Draw(vao);
if (frame == 2) Draw(vertexUseVao); // forces the usage escalation
const Image image = Read();
if (frame != 2) {
Halves(image, "red", "black", "frame " + std::to_string(frame) + " around a usage escalation");
}
Gl().EndFrame();
}
glDeleteVertexArrays(1, &vertexUseVao);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &ebo);
glDeleteBuffers(1, &vbo);
}
// C: delete the EBO and immediately recreate it, so the frontend
// BufferObject may well land at the same address - which is all the memo's
// identity check compares. What stops it is that a fresh resource cannot
// reproduce an epoch from the process-lifetime counter; this is the test
// that says so out loud.
//
// COVERS: address reuse of a deleted index buffer. Reaches 7, accepts 6 -
// the one decline is the post-recreate draw.
TEST_F(ResidentIndexScenario, EboDeletedAndRecreatedAtTheSameName) {
const unsigned int vbo = MakeStaticVbo();
unsigned int ebo = 0;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kLeft)), kLeft, GL_STATIC_DRAW);
unsigned int vao = MakeVao(vbo, ebo);
for (int frame = 0; frame < 4; ++frame) {
Begin();
Draw(vao);
Halves(Read(), "red", "black", "warmup frame " + std::to_string(frame));
Gl().EndFrame();
}
// Same VAO, same GL name, different contents.
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &ebo);
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kRight)), kRight, GL_STATIC_DRAW);
vao = MakeVao(vbo, ebo);
for (int frame = 0; frame < 4; ++frame) {
Begin();
Draw(vao);
Halves(Read(), "black", "green", "post-recreate frame " + std::to_string(frame));
Gl().EndFrame();
}
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &ebo);
glDeleteBuffers(1, &vbo);
}
// D: one resident EBO shared by two VAOs, so two independent memo entries
// hold the same recorded slice, mutated through one of them and drawn
// through both across frames.
//
// COVERS: that a mutation retires EVERY memo naming the buffer, not just
// the one whose VAO issued it. Reaches 8, accepts 6.
TEST_F(ResidentIndexScenario, OneEboTwoVaosMutatedAcrossFrames) {
const unsigned int vbo = MakeStaticVbo();
unsigned int ebo = 0;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kLeft)), kLeft, GL_STATIC_DRAW);
const unsigned int vaoA = MakeVao(vbo, ebo);
const unsigned int vaoB = MakeVao(vbo, ebo);
for (int frame = 0; frame < 10; ++frame) {
Begin();
const bool wantRight = frame >= 5;
if (frame == 5) {
glBindVertexArray(vaoA);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, GLsizeiptr(sizeof(kRight)), kRight);
glBindVertexArray(0);
}
Draw((frame % 2) == 0 ? vaoA : vaoB);
Halves(Read(), wantRight ? "black" : "red", wantRight ? "green" : "black",
"shared-EBO frame " + std::to_string(frame));
Gl().EndFrame();
}
glDeleteVertexArrays(1, &vaoB);
glDeleteVertexArrays(1, &vaoA);
glDeleteBuffers(1, &ebo);
glDeleteBuffers(1, &vbo);
}
// E: a DYNAMIC_DRAW EBO left untouched long enough for the streaming path
// to PROMOTE it onto resident storage, then mutated.
//
// COVERS: promoted-buffer index freshness across a frame boundary.
// DOES NOT COVER: the EBO memo, and this is the useful part - instrumented,
// it reaches the cross-frame branch ZERO times. A promoted buffer is SERVED
// by AcquireResidentSlice but still ROUTED through the streamed branch of
// UploadAndBindIndexBuffer, which never records a memo. That asymmetry is
// exactly what makes the EBO half of the shipped fix unobservable, and this
// case is the tripwire: memoise the streamed/promoted index path and the
// reach stops being zero.
TEST_F(ResidentIndexScenario, PromotedDynamicEbo) {
const unsigned int vbo = MakeStaticVbo();
unsigned int ebo = 0;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kLeft)), kLeft, GL_DYNAMIC_DRAW);
const unsigned int vao = MakeVao(vbo, ebo);
for (int frame = 0; frame < 10; ++frame) {
Begin();
Draw(vao);
Halves(Read(), "red", "black", "promotion warmup frame " + std::to_string(frame));
Gl().EndFrame();
}
for (int frame = 0; frame < 6; ++frame) {
Begin();
if (frame == 0) {
glBindVertexArray(vao);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, GLsizeiptr(sizeof(kRight)), kRight);
glBindVertexArray(0);
}
Draw(vao);
Halves(Read(), "black", "green", "post-promotion frame " + std::to_string(frame));
Gl().EndFrame();
}
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &ebo);
glDeleteBuffers(1, &vbo);
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,300 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ThreeChannelAttachmentScenario.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 - THREE-CHANNEL COLOUR ATTACHMENTS, on a live driver.
//
// The bug: no OpenGL ES driver renders to a three-channel image. EXT_render_snorm covers
// R/RG/RGBA only, EXT_color_buffer_float excludes RGB16F, and RGB integer formats are not
// colour-renderable anywhere. Complementary Reimagined declares colortex1 = RGB8_SNORM and
// colortex2 = RGB16F, so every framebuffer Iris built from them answered
// GL_FRAMEBUFFER_UNSUPPORTED and Iris refused to load the shaderpack. DirectGLES now stores such
// an attachment in its four-channel sibling (GL_RGB8_SNORM -> GL_RGBA16F) and reports the
// substitution as a caveat capability, which is what makes glCheckFramebufferStatus say COMPLETE.
//
// WHY THIS SCENARIO EXISTS RATHER THAN A UNIT TEST. The unit tests in
// MG_Test/Framebuffer/FramebufferTest.cpp drive a HAND-BUILT capability cache: they prove the
// frontend accepts a caveat capability, and prove the colour-mask/clear discipline that keeps a
// widened attachment's stored alpha at 1.0, but they cannot prove that a real driver's probe
// actually PRODUCES that caveat. Only a live glCheckFramebufferStatus can, and the answer is
// per-driver, not per-platform:
//
// Mesa llvmpipe (the headless CI driver), ES 3.2, GL_TEXTURE_2D colour attachment:
// COMPLETE GL_RGB8, GL_RGB16F, GL_R11F_G11F_B10F, every RGBA*
// INCOMPLETE_ATTACHMENT GL_RGB8_SNORM, GL_SRGB8, every RGB integer format
// UNSUPPORTED GL_RGB32F
//
// So the widening is LIVE on llvmpipe - "the desktop build is unaffected" was simply wrong, and
// the CI retraces were green before the fix only because retrace ignores what
// glCheckFramebufferStatus returns. This scenario is the gate that actually looks.
//
// DirectGLES only. DirectVulkan's format story is its own (Vulkan exposes R8G8B8_SNORM on almost
// nothing, and Magma substitutes on different terms); asserting Espryt's answers there would
// only pin a coincidence.
#include <cmath>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr const char* kVS = R"(#version 330 core
in vec2 aPos;
void main() {
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
// Two outputs so the mixed case is covered: draw buffer 0 is a natively renderable
// four-channel format whose alpha the application owns, draw buffer 1 is the widened
// three-channel one whose alpha the format says is 1.0. Both alphas are deliberately
// NOT 1.0 in the shader, so an implementation that simply passed the value through would
// fail the second assertion.
constexpr const char* kFS = R"(#version 330 core
layout(location = 0) out vec4 oNative;
layout(location = 1) out vec4 oWidened;
void main() {
oNative = vec4(1.0, 0.0, 0.0, 0.25);
oWidened = vec4(0.0, 1.0, 0.0, 0.75);
}
)";
constexpr int kSize = 16;
class ThreeChannelAttachmentScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
if (Gl().BackendName() != "DirectGLES") {
GTEST_SKIP() << "three-channel widening is a DirectGLES substitution; backend is "
<< Gl().BackendName();
}
}
// A single-level 2D texture in `internalFormat`, or 0 when the driver rejects the
// storage outright (which is a different failure from rejecting the ATTACHMENT).
static GLuint MakeTexture(GLenum internalFormat) {
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexStorage2D(GL_TEXTURE_2D, 1, internalFormat, kSize, kSize);
if (glGetError() != GL_NO_ERROR) {
glDeleteTextures(1, &texture);
return 0;
}
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glBindTexture(GL_TEXTURE_2D, 0);
return texture;
}
static GLenum SingleAttachmentStatus(GLenum internalFormat) {
const GLuint texture = MakeTexture(internalFormat);
if (texture == 0) return GL_NONE;
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
const GLenum status = glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &fbo);
glDeleteTextures(1, &texture);
return status;
}
};
// THE regression gate for the frontend's answer: this is the exact call Iris makes, and
// GL_FRAMEBUFFER_UNSUPPORTED here is the whole shaderpack load failure.
TEST_F(ThreeChannelAttachmentScenario, ThreeChannelColorAttachmentsReportComplete) {
if (!Ready() || IsSkipped()) return;
// GL_RGB8 is the control: colour-renderable in ES core, so it must pass with or
// without any substitution. If it ever fails, nothing below means anything.
EXPECT_EQ(SingleAttachmentStatus(GL_RGB8), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
<< "GL_RGB8 is ES-core colour-renderable";
// Complementary Reimagined's colortex1 and colortex2.
EXPECT_EQ(SingleAttachmentStatus(GL_RGB8_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
<< "colortex1 (RGB8_SNORM) must be renderable through the four-channel widening";
EXPECT_EQ(SingleAttachmentStatus(GL_RGB16F), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
<< "colortex2 (RGB16F) must be renderable, natively or through the widening";
// The other formats the widening covers. GL_RGB32F only reaches a renderable
// four-channel form when EXT_color_buffer_float is present, so a half-float-only
// driver legitimately answers UNSUPPORTED for it - see the POST's per-format row.
EXPECT_EQ(SingleAttachmentStatus(GL_SRGB8), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
EXPECT_EQ(SingleAttachmentStatus(GL_RGB8UI), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
// The other half: the substitution has to be INVISIBLE. A three-channel format has no
// alpha, so GL answers 1.0 for it - and that answer has to hold after a draw that wrote
// something else into the widened storage's real alpha channel, which is what the
// colour-mask discipline in SyncRenderState is for. GL_DST_ALPHA blending and
// glBlitFramebuffer read that stored alpha inside the driver, where no readback fixup can
// reach it, so "the storage really holds 1.0" is the only workable invariant.
TEST_F(ThreeChannelAttachmentScenario, WidenedAttachmentReadsBackOpaqueWhileItsNeighbourKeepsItsAlpha) {
if (!Ready() || IsSkipped()) return;
std::string error;
const GLuint program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
const GLuint nativeTexture = MakeTexture(GL_RGBA16F);
const GLuint widenedTexture = MakeTexture(GL_RGB8_SNORM);
ASSERT_NE(nativeTexture, 0u);
ASSERT_NE(widenedTexture, 0u);
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, nativeTexture, 0);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, widenedTexture, 0);
const GLenum drawBuffers[2] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1};
glDrawBuffers(2, drawBuffers);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
glViewport(0, 0, kSize, kSize);
// Alpha 0.0 on purpose: the widened attachment must come back 1.0 anyway, and the
// native one must come back 0.0 where the draw does not cover it.
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
const float quad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
GLuint vao = 0;
GLuint vbo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
glUseProgram(program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
std::vector<float> pixels(static_cast<std::size_t>(kSize) * kSize * 4, -1.0f);
glReadBuffer(GL_COLOR_ATTACHMENT1);
glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_FLOAT, pixels.data());
EXPECT_NEAR(pixels[0], 0.0f, 0.02f) << "widened attachment red";
EXPECT_NEAR(pixels[1], 1.0f, 0.02f) << "widened attachment green";
EXPECT_NEAR(pixels[2], 0.0f, 0.02f) << "widened attachment blue";
EXPECT_NEAR(pixels[3], 1.0f, 0.001f)
<< "a three-channel format has no alpha channel, so GL must report 1.0 for it";
glReadBuffer(GL_COLOR_ATTACHMENT0);
glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_FLOAT, pixels.data());
EXPECT_NEAR(pixels[0], 1.0f, 0.02f) << "native attachment red";
EXPECT_NEAR(pixels[3], 0.25f, 0.02f)
<< "the alpha discipline must not leak onto a natively renderable attachment";
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &fbo);
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
glDeleteTextures(1, &nativeTexture);
glDeleteTextures(1, &widenedTexture);
glDeleteProgram(program);
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
// The case above can be satisfied by the readback fixup alone (ForceWideReadAlphaToOne
// rewrites glReadPixels' alpha), so it does NOT prove the STORED alpha is 1.0. This one
// does, by asking the driver to read that alpha itself: GL_DST_ALPHA blending multiplies
// by the destination alpha inside the raster pipeline, where nothing MobileGL does can
// intervene. Same reason GL_ONE_MINUS_DST_ALPHA and glBlitFramebuffer are covered for
// free once this holds - and the reason the discipline is a write mask rather than a
// readback patch.
//
// Ablation-checked on llvmpipe, each half separately: disable the alpha doctoring in
// SyncRenderState and the opaque draw leaves 0.25 in the stored alpha; disable the clear
// substitution in Clear() and it stays at the application's 0.0. Either way this case
// reads back the wrong number, which is what makes it a gate rather than a description.
TEST_F(ThreeChannelAttachmentScenario, DstAlphaBlendingSeesOneInAWidenedAttachment) {
if (!Ready() || IsSkipped()) return;
static constexpr const char* kSingleOutFS = R"(#version 330 core
out vec4 oColor;
uniform vec4 uColor;
void main() { oColor = uColor; }
)";
std::string error;
const GLuint program = CompileProgram(kVS, kSingleOutFS, &error);
ASSERT_NE(program, 0u) << error;
const GLint colorLocation = glGetUniformLocation(program, "uColor");
ASSERT_GE(colorLocation, 0);
const GLuint widenedTexture = MakeTexture(GL_RGB8_SNORM);
ASSERT_NE(widenedTexture, 0u);
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, widenedTexture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
const float quad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
GLuint vao = 0;
GLuint vbo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
glUseProgram(program);
glViewport(0, 0, kSize, kSize);
// The clear's alpha is 0.0 and the draw's is 0.25 - neither is the 1.0 the format
// implies, so both halves of the discipline have to fire for the blend below to see
// 1.0: the clear substitutes it, and the draw is masked away from it.
glDisable(GL_BLEND);
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUniform4f(colorLocation, 0.0f, 1.0f, 0.0f, 0.25f);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
// dst = stored alpha; src factor GL_DST_ALPHA, dst factor GL_ZERO, source white
// => the destination colour becomes (storedAlpha, storedAlpha, storedAlpha).
glEnable(GL_BLEND);
glBlendFunc(GL_DST_ALPHA, GL_ZERO);
glUniform4f(colorLocation, 1.0f, 1.0f, 1.0f, 1.0f);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glDisable(GL_BLEND);
std::vector<float> pixels(static_cast<std::size_t>(kSize) * kSize * 4, -1.0f);
glReadBuffer(GL_COLOR_ATTACHMENT0);
glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_FLOAT, pixels.data());
EXPECT_NEAR(pixels[0], 1.0f, 0.02f)
<< "GL_DST_ALPHA read the stored alpha of a three-channel attachment; it must be 1.0";
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &fbo);
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
glDeleteTextures(1, &widenedTexture);
glDeleteProgram(program);
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,584 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.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 F - a draw must never read a destroyed object's memoised state.
//
// Distilled from the order-triggered CTS failure: on DirectVulkan, once
// KHR-GLxx.clip_distance.functional had run in the same process, every later
// transform_feedback CAPTURE case failed. It looked like a transform feedback
// bug and is not one. The capture works; the DRAW being captured fetched its
// vertices from the WRONG BUFFER - the one the clip workload had just deleted.
//
// The mechanism, and why the sequence matters. DirectVulkan memoises a VAO's
// resolved Vulkan vertex bindings in a table keyed on the VertexArrayObject's
// heap ADDRESS, validated by a content hash that folds in the bound
// BufferObject's heap ADDRESS. Both are recycled by the allocator, so when the
// workload's VAO and vertex buffer are destroyed and the capture phase's own
// VAO and vertex buffer are allocated onto their addresses under a
// byte-identical attribute layout (one vec4 float array at location 0 - which
// is what both phases use), the key matches, the hash matches, and the memo
// hands the new draw the dead buffer's GPU slice. Nothing about transform
// feedback is involved: capture just makes the wrong vertices legible, because
// the captured record IS the vertex data. The fix gives VertexArrayObject and
// BufferObject never-reused lifetime ids and keys the memo on those.
//
// MOBILEGL_ASYNC_SHADER_COMPILE is not part of the defect. It shifts the
// allocation pattern, so it changes WHICH stop points below land on a recycled
// address - which is why the CTS saw ~100% incidence with it on and ~2% with it
// off, and why the sweep case matters more than any single stop point.
//
// The shapes are the two CTS cases verbatim in structure:
// * the workload is glcClipDistance.cpp FunctionalTest's inner loop (a program
// per (redeclaration, clip count), glEnable(GL_CLIP_DISTANCEi), an FBO per
// primitive type, a draw and a readback), including its early-return
// behaviour: on failure the test returns WITHOUT running its "clip clean"
// loop, so GL_CLIP_DISTANCE0..N-1 stay enabled for the rest of the process.
// That leftover enable state is NOT the carrier (one of the cases below pins
// that); the object churn is.
// * the victim is gl3cTransformFeedback3Tests.cpp's skip_components: a
// gl_SkipComponents capture layout under GL_RASTERIZER_DISCARD, read back
// out of a buffer pre-filled with -1-i so that "captured nothing" is
// distinguishable from "captured the wrong thing".
#include <cmath>
#include <cstdio>
#include <cstdlib>
#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
#ifndef GL_CLIP_DISTANCE0
#define GL_CLIP_DISTANCE0 0x3000
#endif
namespace MGITest {
namespace {
GLuint CompileShader(GLenum type, const std::string& source, std::string* log) {
const GLuint shader = glCreateShader(type);
const char* text = source.c_str();
glShaderSource(shader, 1, &text, nullptr);
glCompileShader(shader);
GLint status = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteShader(shader);
return 0;
}
return shader;
}
// Links a vertex/fragment pair, optionally declaring transform feedback
// varyings first (glTransformFeedbackVaryings takes effect at the next link,
// exactly as the CTS uses it).
GLuint BuildProgram(const std::string& vertexSource, const std::string& fragmentSource,
const std::vector<const char*>& xfbVaryings, GLenum bufferMode, std::string* log) {
const GLuint vertexShader = CompileShader(GL_VERTEX_SHADER, vertexSource, log);
if (vertexShader == 0) return 0;
const GLuint fragmentShader = CompileShader(GL_FRAGMENT_SHADER, fragmentSource, log);
if (fragmentShader == 0) {
glDeleteShader(vertexShader);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
if (!xfbVaryings.empty()) {
glTransformFeedbackVaryings(program, static_cast<GLsizei>(xfbVaryings.size()), xfbVaryings.data(),
bufferMode);
}
glLinkProgram(program);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
GLint status = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteProgram(program);
return 0;
}
return program;
}
// ---------------------------------------------------------------- poison
// glcClipDistance.cpp FunctionalTest::m_vertex_shader_code with the same
// three substitutions (redeclaration, clip function, array setter).
std::string ClipVertexSource(bool redeclaration, unsigned clipCount, unsigned clipFunction,
unsigned vertexCount) {
const std::string count = std::to_string(clipCount);
std::string source = "#version 400 core\n\n";
if (redeclaration) {
source += "out float gl_ClipDistance[" + count + "];\n";
}
source += "\n";
switch (clipFunction) {
case 0:
source += "float f(int i)\n{\n return 0.0;\n}\n";
break;
case 1:
source += "float f(int i)\n{\n return 0.25 + 0.75 * (float(i) + 1.0) * (float(gl_VertexID) + 1.0)"
" / (float(" + count + ") * float(" + std::to_string(vertexCount) + "));\n}\n";
break;
default:
source += "float f(int i)\n{\n return - 0.25 - 0.75 * (float(i) + 1.0) * (float(gl_VertexID) + 1.0)"
" / (float(" + count + ") * float(" + std::to_string(vertexCount) + "));\n}\n";
break;
}
source += "\nin vec4 position;\n\nvoid main()\n{\n";
if (redeclaration) {
// Dynamic array setter.
source += " for(int i = 0; i < " + count + "; i++)\n {\n"
" gl_ClipDistance[i] = f(i);\n }\n";
} else {
// Static array setter, at the highest index this iteration enables.
const std::string index = std::to_string(clipCount - 1);
source += " gl_ClipDistance[" + index + "] = f(" + index + ");\n";
}
source += "\n gl_Position = position;\n}\n";
return source;
}
const char* kClipFragmentSource = R"(#version 400 core
out vec4 color;
void main()
{
color = vec4(1.0, 0.0, 0.0, 1.0);
}
)";
// How far into FunctionalTest's loop nest to get before bailing out the way
// the CTS does on a failed check: return immediately, skipping the "clip
// clean" loop that would have disabled GL_CLIP_DISTANCEi again.
struct ClipStopPoint {
unsigned primitiveIndex = 0; // 0 = POINTS, 1 = LINES, 2 = TRIANGLES
unsigned clipFunction = 0;
bool redeclaration = false;
unsigned clipCount = 1; // 1..8, the iteration that "fails"
};
// Runs FunctionalTest's loop nest up to and including `stop`, then returns
// leaving exactly the state the CTS leaves behind on a failure.
void RunClipDistanceWorkload(const ClipStopPoint& stop) {
static const GLenum kPrimitiveTypes[] = {GL_POINTS, GL_LINES, GL_TRIANGLES};
static const GLsizei kPrimitiveIndices[] = {1, 2, 3};
static const float kPositions[3][12] = {
{0.0f, 0.0f, 0.0f, 1.0f},
{-1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f},
{-1.0f, -1.0f, 0.0f, 1.0f, 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f},
};
for (unsigned primitiveIndex = 0; primitiveIndex <= stop.primitiveIndex; ++primitiveIndex) {
const GLenum primitiveType = kPrimitiveTypes[primitiveIndex];
const GLsizei vertexCount = kPrimitiveIndices[primitiveIndex];
const GLsizei framebufferSize = (primitiveType == GL_POINTS) ? 1 : 32;
GLuint colorBuffer = 0;
GLuint framebuffer = 0;
glGenRenderbuffers(1, &colorBuffer);
glBindRenderbuffer(GL_RENDERBUFFER, colorBuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, framebufferSize, framebufferSize);
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorBuffer);
glViewport(0, 0, framebufferSize, framebufferSize);
const unsigned lastFunction =
(primitiveIndex == stop.primitiveIndex) ? stop.clipFunction : 2u;
for (unsigned clipFunction = 0; clipFunction <= lastFunction; ++clipFunction) {
const bool atStopFunction =
primitiveIndex == stop.primitiveIndex && clipFunction == stop.clipFunction;
for (unsigned redeclaration = 0; redeclaration < 2; ++redeclaration) {
const bool atStopRedeclaration =
atStopFunction && (redeclaration != 0) == stop.redeclaration;
const unsigned lastCount = atStopRedeclaration ? stop.clipCount : 8u;
for (unsigned clipCount = 1; clipCount <= lastCount; ++clipCount) {
std::string log;
const GLuint program =
BuildProgram(ClipVertexSource(redeclaration != 0, clipCount, clipFunction,
static_cast<unsigned>(vertexCount)),
kClipFragmentSource, {}, GL_INTERLEAVED_ATTRIBS, &log);
if (program == 0) continue;
glUseProgram(program);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glEnable(GL_CLIP_DISTANCE0 + clipCount - 1);
GLuint vao = 0;
GLuint vbo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER,
static_cast<GLsizeiptr>(sizeof(float) * 4 * vertexCount),
kPositions[primitiveIndex], GL_STATIC_DRAW);
const GLint location = glGetAttribLocation(program, "position");
if (location >= 0) {
glEnableVertexAttribArray(static_cast<GLuint>(location));
glVertexAttribPointer(static_cast<GLuint>(location), 4, GL_FLOAT, GL_FALSE, 0,
nullptr);
}
glDrawArrays(primitiveType, 0, vertexCount);
std::vector<unsigned char> pixels(
static_cast<std::size_t>(framebufferSize) * framebufferSize * 4, 0);
glReadPixels(0, 0, framebufferSize, framebufferSize, GL_RGBA, GL_UNSIGNED_BYTE,
pixels.data());
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
glUseProgram(0);
// MGL_REPRO_KEEPCLIPOBJ leaks the per-iteration objects so
// no GL name and no heap address can be recycled into the
// capture phase.
// Deleting all three is load-bearing, not tidiness: the defect
// this scenario pins needs the VAO's AND its vertex buffer's heap
// addresses to be freed here so the capture phase's own objects
// can be handed the same ones back.
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
glDeleteProgram(program);
if (atStopRedeclaration && clipCount == stop.clipCount) {
// The CTS's early return: the "clip clean" loop below
// never runs, so the enables survive.
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &framebuffer);
glDeleteRenderbuffers(1, &colorBuffer);
return;
}
}
for (unsigned i = 0; i < 8; ++i) {
glDisable(GL_CLIP_DISTANCE0 + i);
}
}
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &framebuffer);
glDeleteRenderbuffers(1, &colorBuffer);
}
}
// ---------------------------------------------------------------- victim
// gl3cTransformFeedback3Tests.cpp TransformFeedbackBaseTestCase::m_shader_vert.
const char* kXfbVertexSource = R"(#version 400 core
in vec4 vertex;
out vec4 value1;
out vec4 value2;
out vec4 value3;
out vec4 value4;
void main (void)
{
vec4 temp = vertex;
gl_Position = temp;
value1 = abs(temp) * 1.0;
value2 = abs(temp) * 2.0;
value3 = abs(temp) * 3.0;
value4 = abs(temp) * 4.0;
}
)";
const char* kXfbFragmentSource = R"(#version 400 core
out vec4 color;
void main (void)
{
color = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
// The skip_components capture layout, verbatim.
std::vector<const char*> SkipComponentsVaryings() {
return {"gl_SkipComponents1", "value1", "gl_SkipComponents2", "gl_SkipComponents1", "value2",
"gl_SkipComponents3", "gl_SkipComponents2", "value3", "gl_SkipComponents4", "value4"};
}
constexpr unsigned kSkipComponentCount = 4 * 4 + (1 + 2 + 3 + 4 + 1 + 2); // 16 values + 13 skipped
constexpr unsigned kSkipVertexCount = 6;
// Runs skip_components and reports what came back. `outCaptured` is the raw
// readback so a failure can say whether anything was written at all.
void RunSkipComponentsCapture(std::vector<float>& outCaptured, std::string* buildLog) {
outCaptured.clear();
const GLuint program = BuildProgram(kXfbVertexSource, kXfbFragmentSource, SkipComponentsVaryings(),
GL_INTERLEAVED_ATTRIBS, buildLog);
ASSERT_NE(program, 0u) << "skip_components program failed to link: " << (buildLog ? *buildLog : "");
glUseProgram(program);
const std::vector<float> vertices = {
-1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -2.0f, 1.0f, -1.0f, 1.0f, -3.0f, 1.0f,
1.0f, 1.0f, 4.0f, 1.0f, -1.0f, 1.0f, 5.0f, 1.0f, 1.0f, -1.0f, 6.0f, 1.0f,
};
GLuint vao = 0;
GLuint vbo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(sizeof(float) * vertices.size()), vertices.data(),
GL_STATIC_DRAW);
const GLint location = glGetAttribLocation(program, "vertex");
if (location >= 0) {
glEnableVertexAttribArray(static_cast<GLuint>(location));
glVertexAttribPointer(static_cast<GLuint>(location), 4, GL_FLOAT, GL_FALSE, 0, nullptr);
}
const unsigned floatCount = kSkipVertexCount * kSkipComponentCount;
const GLsizeiptr byteSize = static_cast<GLsizeiptr>(sizeof(float) * floatCount);
GLuint captureBuffer = 0;
glGenBuffers(1, &captureBuffer);
glBindBuffer(GL_ARRAY_BUFFER, captureBuffer);
glBufferData(GL_ARRAY_BUFFER, byteSize, nullptr, GL_STATIC_READ);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer);
glBindBuffer(GL_ARRAY_BUFFER, 0);
// The pre-fill that makes "nothing was captured" recognisable.
std::vector<float> prefill(floatCount);
for (unsigned i = 0; i < floatCount; ++i) {
prefill[i] = -1.0f - static_cast<float>(i);
}
glBindBuffer(GL_ARRAY_BUFFER, captureBuffer);
glBufferData(GL_ARRAY_BUFFER, byteSize, prefill.data(), GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glEnable(GL_RASTERIZER_DISCARD);
glClearColor(0.1f, 0.0f, 0.5f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer);
glBeginTransformFeedback(GL_TRIANGLES);
glDrawArrays(GL_TRIANGLES, 0, static_cast<GLsizei>(kSkipVertexCount));
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
outCaptured.resize(floatCount);
glBindBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer, 0, byteSize);
const void* mapped = glMapBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, byteSize, GL_MAP_READ_BIT);
if (mapped != nullptr) {
std::memcpy(outCaptured.data(), mapped, static_cast<std::size_t>(byteSize));
glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER);
}
glDisableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glDeleteBuffers(1, &vbo);
glDeleteBuffers(1, &captureBuffer);
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glUseProgram(0);
glDeleteProgram(program);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
}
// skip_components' expected buffer: the 13 skipped components keep their
// pre-fill, the 16 captured ones carry |vertex| * n.
std::vector<float> SkipComponentsExpected() {
const std::vector<float> vertices = {
-1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -2.0f, 1.0f, -1.0f, 1.0f, -3.0f, 1.0f,
1.0f, 1.0f, 4.0f, 1.0f, -1.0f, 1.0f, 5.0f, 1.0f, 1.0f, -1.0f, 6.0f, 1.0f,
};
const unsigned floatCount = kSkipVertexCount * kSkipComponentCount;
std::vector<float> expected(floatCount);
for (unsigned i = 0; i < floatCount; ++i) {
expected[i] = -1.0f - static_cast<float>(i);
}
// Record layout, in floats:
// [0] skip1
// [1..4] value1
// [5..7] skip2 + skip1
// [8..11] value2
// [12..16] skip3 + skip2
// [17..20] value3
// [21..24] skip4
// [25..28] value4
static const unsigned kValueOffsets[4] = {1, 8, 17, 25};
for (unsigned v = 0; v < kSkipVertexCount; ++v) {
const unsigned base = v * kSkipComponentCount;
for (unsigned value = 0; value < 4; ++value) {
for (unsigned component = 0; component < 4; ++component) {
const float source = vertices[v * 4 + component];
expected[base + kValueOffsets[value] + component] =
std::fabs(source) * static_cast<float>(value + 1);
}
}
}
return expected;
}
// Reports the first mismatch, and whether the readback is byte-for-byte the
// pre-fill (i.e. the capture never happened).
::testing::AssertionResult CheckSkipComponents(const std::vector<float>& captured) {
const std::vector<float> expected = SkipComponentsExpected();
if (captured.size() != expected.size()) {
return ::testing::AssertionFailure()
<< "readback size " << captured.size() << " != " << expected.size();
}
bool anyWritten = false;
for (std::size_t i = 0; i < captured.size(); ++i) {
if (captured[i] != -1.0f - static_cast<float>(i)) {
anyWritten = true;
break;
}
}
for (std::size_t i = 0; i < expected.size(); ++i) {
if (std::fabs(captured[i] - expected[i]) > 0.0125f) {
return ::testing::AssertionFailure()
<< "capture mismatch at index " << i << ": got " << captured[i] << ", expected "
<< expected[i] << (anyWritten ? "" : " (the whole buffer is still the pre-fill: "
"NOTHING was captured)");
}
}
return ::testing::AssertionSuccess();
}
// The harness turns "no context came up" into a clean skip, and a skip is
// indistinguishable from a pass in a ctest summary. For this scenario that
// is a hole rather than a courtesy: the defect it pins is DirectVulkan's
// alone, and DirectVulkan now comes up headless on any machine at all - a
// surfaceless EGL platform over a software ICD (lavapipe) is enough. So
// "DirectVulkan did not initialise" here means the run is MISCONFIGURED,
// not that the machine has no GPU, and it must not report green.
//
// Local on purpose: the harness-wide skip semantics are deliberate
// (ScenarioFixture.h states the reasoning), and MOBILEGL_ITEST_REQUIRE_GPU
// is the harness-wide lever for the same intent - but that lever also
// demands a HARDWARE renderer, which is exactly what a lavapipe-only box
// cannot offer. This overrides nothing else: only this scenario, only for
// the backend that can regress, and only for the unusable-harness case.
class XfbAfterClipDistanceScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
// Ready() is false on the base's skip path AND on its REQUIRE_GPU
// failure path; the second one has already failed, so leave it alone
// rather than burying its reason under a second message.
if (Ready() || HasFatalFailure()) return;
if (Gl().BackendName() == "DirectVulkan") {
FAIL() << "DirectVulkan could not be brought up, so the regression this scenario guards - a "
"draw served a destroyed VAO's memoised vertex bindings - was never exercised, and "
"that must be a failure rather than a silent skip. Headless bring-up needs only a "
"Vulkan ICD and a surfaceless EGL platform (a software ICD such as lavapipe "
"qualifies: VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json with "
"EGL_PLATFORM=surfaceless). Harness reason: "
<< Gl().SkipReason();
}
}
};
// Control: the capture on its own must work.
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAlone) {
if (!Ready()) return;
std::vector<float> captured;
std::string log;
RunSkipComponentsCapture(captured, &log);
EXPECT_TRUE(CheckSkipComponents(captured));
}
// Bisection step 1: only the leftover GL_CLIP_DISTANCEi enables.
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAfterClipDistanceEnables) {
if (!Ready()) return;
for (unsigned i = 0; i < 8; ++i) {
glEnable(GL_CLIP_DISTANCE0 + i);
}
std::vector<float> captured;
std::string log;
RunSkipComponentsCapture(captured, &log);
for (unsigned i = 0; i < 8; ++i) {
glDisable(GL_CLIP_DISTANCE0 + i);
}
EXPECT_TRUE(CheckSkipComponents(captured));
}
// Bisection step 2: the whole clip_distance.functional workload, stopped
// where the CTS stopped in the runs that went on to break the capture.
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAfterClipDistanceWorkloadLines8) {
if (!Ready()) return;
RunClipDistanceWorkload({.primitiveIndex = 1, .clipFunction = 0, .redeclaration = false, .clipCount = 8});
std::vector<float> captured;
std::string log;
RunSkipComponentsCapture(captured, &log);
for (unsigned i = 0; i < 8; ++i) {
glDisable(GL_CLIP_DISTANCE0 + i);
}
EXPECT_TRUE(CheckSkipComponents(captured));
}
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAfterClipDistanceWorkloadPoints1) {
if (!Ready()) return;
RunClipDistanceWorkload({.primitiveIndex = 0, .clipFunction = 0, .redeclaration = true, .clipCount = 1});
std::vector<float> captured;
std::string log;
RunSkipComponentsCapture(captured, &log);
for (unsigned i = 0; i < 8; ++i) {
glDisable(GL_CLIP_DISTANCE0 + i);
}
EXPECT_TRUE(CheckSkipComponents(captured));
}
// A single stop point is not a regression test for this defect: whether the
// capture phase's VAO and vertex buffer land on the addresses the workload just
// freed is a function of how much the workload allocated, so the two cases above
// pin two draws of a lottery. Sweep the grid instead - before the fix, roughly a
// third of these stop points came back holding the workload's vertex data.
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureSurvivesEveryClipWorkloadStopPoint) {
if (!Ready()) return;
for (unsigned primitiveIndex = 0; primitiveIndex < 3; ++primitiveIndex) {
for (unsigned redeclaration = 0; redeclaration < 2; ++redeclaration) {
for (const unsigned clipCount : {1u, 4u, 8u}) {
RunClipDistanceWorkload({.primitiveIndex = primitiveIndex,
.clipFunction = 0,
.redeclaration = redeclaration != 0,
.clipCount = clipCount});
std::vector<float> captured;
std::string log;
RunSkipComponentsCapture(captured, &log);
for (unsigned i = 0; i < 8; ++i) {
glDisable(GL_CLIP_DISTANCE0 + i);
}
EXPECT_TRUE(CheckSkipComponents(captured))
<< " (stop point: primitive " << primitiveIndex << ", redeclaration " << redeclaration
<< ", clip count " << clipCount << ")";
}
}
}
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,47 @@
#!/bin/bash
# Run the headless MobileGL integration scenarios on one backend:
# ./run_integration_test.sh espryt [gtest args...] -> DirectGLES
# ./run_integration_test.sh magma [gtest args...] -> DirectVulkan
#
# The backend is latched at initialization from MOBILEGL_BACKEND_TYPE, so one
# process is one backend; this script is the dev-box equivalent of the two ctest
# registrations in CMakeLists.txt.
#
# Pin the vendor libraries explicitly, for the same reason
# MG_Benchmark/Driver/run_driver_bench.sh does: a bare libEGL on a glvnd system
# resolves to whatever vendor comes first, which is usually Mesa/llvmpipe - a
# software rasteriser silently replacing the GPU under a GPU test. Override
# MGL_EGL_VENDOR / MGL_VK_ICD to test another driver.
#
# Set MOBILEGL_ITEST_REQUIRE_GPU=1 to turn "the harness is unusable" from a clean
# skip into a failure. Do that anywhere the machine is supposed to have a GPU: a
# run that skipped everything and a run that passed everything are otherwise the
# same green, so without it a broken driver pinning is invisible.
set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
BIN=${MOBILEGL_ITEST_BIN:-$HERE/MobileGLIntegrationTest}
EGL_VENDOR=${MGL_EGL_VENDOR:-/usr/share/glvnd/egl_vendor.d/10_nvidia.json}
VK_ICD=${MGL_VK_ICD:-/usr/share/vulkan/icd.d/nvidia_icd.x86_64.json}
MODE=$1; shift
if [ ! -x "$BIN" ]; then
echo "MobileGLIntegrationTest not found at $BIN"
echo "configure with -DMOBILEGL_BUILD_INTEGRATION_TEST=ON and set MOBILEGL_ITEST_BIN"
exit 1
fi
[ -r "$EGL_VENDOR" ] && export __EGL_VENDOR_LIBRARY_FILENAMES=$EGL_VENDOR
export EGL_PLATFORM=${EGL_PLATFORM:-x11}
case "$MODE" in
espryt|DirectGLES)
export MOBILEGL_BACKEND_TYPE=DirectGLES
;;
magma|DirectVulkan)
export MOBILEGL_BACKEND_TYPE=DirectVulkan
[ -r "$VK_ICD" ] && export VK_ICD_FILENAMES=$VK_ICD
;;
*) echo "unknown mode: $MODE (espryt|magma)"; exit 1 ;;
esac
export MOBILEGL_ITEST_REQUIRE_GPU=${MOBILEGL_ITEST_REQUIRE_GPU:-}
exec "$BIN" "$@"
@@ -8,9 +8,17 @@
#include "BufferObject.h"
#include <atomic>
namespace MobileGL::MG_State::GLState {
namespace {
const BufferBackendOps* g_bufferBackendOps = nullptr;
// Starts at 1 so a zero-initialized cache slot can never carry a live buffer's id.
std::atomic<Uint64> g_nextBufferLifetimeId{1};
}
Uint64 BufferObject::AllocateLifetimeId() {
return g_nextBufferLifetimeId.fetch_add(1, std::memory_order_relaxed);
}
void SetBufferBackendOps(const BufferBackendOps* ops) {
@@ -198,8 +206,11 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::UploadSubData(DataPtr data, SizeT atOffset) {
MOBILEGL_ASSERT(!m_isMapped || (m_mappingAccess & BufferMappingAccessBit::Persistent),
"Cannot upload sub data while buffer is non-persistently mapped.");
// GL 4.6 core 6.5 forbids only the OVERLAPPING write: a glBufferSubData that stays
// clear of a non-persistent mapping is legal, and the frontend lets it through.
MOBILEGL_ASSERT(!m_isMapped || (m_mappingAccess & BufferMappingAccessBit::Persistent) ||
atOffset >= m_mappedRange.end || atOffset + data.size <= m_mappedRange.start,
"Cannot upload sub data overlapping a non-persistent mapping.");
MOBILEGL_ASSERT(atOffset + data.size <= m_size,
"UploadSubData out of bounds: atOffset (%zu) + data.size (%zu) > m_size (%zu)", atOffset,
data.size, m_size);
@@ -185,6 +185,13 @@ namespace MobileGL {
Flags<BufferMappingAccessBit> GetMappingAccess() const;
GLbitfield GetStorageFlags() const;
Uint GetExternalIndex() const;
// Globally-unique, never-reused id for THIS object's lifetime - same contract
// and same motivation as ProgramObject::GetLifetimeId() and
// VertexArrayObject::GetLifetimeId(). A backend that folds a buffer's IDENTITY
// into a cache key must use this, never the GL name (LIFO-recycled by
// glGenBuffers) and never the heap address (recycled by the allocator): both
// let a deleted-and-recreated buffer answer to a dead one's cache entry.
Uint64 GetLifetimeId() const { return m_lifetimeId; }
// Monotonic counter bumped on every shadow mutation; backends use it to
// validate cached transient slices.
Uint64 GetChangeSerial() const;
@@ -207,7 +214,10 @@ namespace MobileGL {
// SubData transfer to sync the backend's separate GPU copy.
void NotifyContentWrite(SizeT offset, SizeT size);
static Uint64 AllocateLifetimeId();
const Uint m_externalIndex = 0;
const Uint64 m_lifetimeId = AllocateLifetimeId();
SizeT m_size = 0;
BufferUsage m_usage = BufferUsage::StaticDraw;
// Owns the buffer's bytes (CPU shadow or backend persistent GPU map) and
+55 -1
View File
@@ -9,6 +9,9 @@
#include "Core.h"
#include "MG_State/GLState/RenderbufferState/RenderbufferObject.h"
#include "MG_State/EGLState/Core.h"
#include <MG_Backend/BackendObjects.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <Config.h>
namespace MobileGL::MG_State {
@@ -24,8 +27,27 @@ namespace MobileGL::MG_State {
}
namespace GLState {
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv>& GLContext::GetCompileEnv() {
const void* backend = static_cast<const void*>(MG_Backend::pActiveBackendObject.get());
if (!m_compileEnv || m_compileEnvBackend != backend) {
// First use, or the backend was swapped underneath us. Re-capturing rolls the
// fingerprint, so every P0b preprocess memo computed against the old backend's
// limits becomes structurally unreachable instead of silently reusable.
m_compileEnv = MG_Util::ShaderTranspiler::CaptureCompileEnv();
m_compileEnvBackend = backend;
}
return m_compileEnv;
}
// Error
void GLContext::RecordError(ErrorCode code, UniquePtr<ErrorInfo> info) {
// Invariant I1, mechanically enforced: the GL error state is GL-thread-owned.
// A compile or link body that needs to raise an error must append to its node's
// JobDiagnostics and let the join replay it here (see the P1 design section 6);
// reaching this from a worker would corrupt the sticky-flag set that
// glGetError's ordering depends on.
MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(),
"GLContext::RecordError() called from a shader-compile pool thread");
m_errorState.RecordError(code, Move(info));
}
@@ -335,6 +357,10 @@ namespace MobileGL::MG_State {
return m_programState.GetShaderObject(index);
}
void GLContext::JoinAllPendingShaderWork() {
m_programState.JoinAllPendingWork();
}
void GLContext::UseProgram(Uint program) {
return m_programState.UseProgram(program);
}
@@ -346,11 +372,35 @@ namespace MobileGL::MG_State {
const SharedPtr<ProgramObject>& GLContext::GetProgramForDraw() {
static const SharedPtr<ProgramObject> nullProgram = nullptr;
const auto& currentProgram = m_programState.GetCurrentProgram();
if (currentProgram) return currentProgram;
if (currentProgram) {
// P1 join site J1, plain glUseProgram half. The backends read a program's
// lifetimeId / backendStateVersion / UBO content version to decide whether
// their per-program caches are still valid, and none of those pass through
// ProgramObject's join gate - so a draw could sample a version, join later
// inside the same draw when it finally touched an artifact, and cache under a
// version the publish had already superseded. Settling here means every
// version a backend reads during a draw describes the program it is drawing.
// One null check in steady state.
currentProgram->JoinLink();
return currentProgram;
}
if (m_boundProgramPipeline == 0) return nullProgram;
const auto& pipeline = GetBoundProgramPipeline();
if (!pipeline) return nullProgram;
// P1 join site J1. ComputeDrawProgramSignature() keys the composite cache on each
// stage program's lifetimeId and backendStateVersion - NON-artifact fields, so
// they do not pass through ProgramObject's join gate and a pending link would
// stay pending right through the signature. Since the version is bumped both at
// enqueue and at publish, the signature computed inside a pending window is one
// that will never be produced again: every draw would miss the cache and rebuild
// (and relink) the composite. Join first, so the signature describes settled
// programs. In steady state this is a null check per stage.
for (SizeT stage = 0; stage < static_cast<SizeT>(ShaderStage::ShaderStageCount); ++stage) {
const auto& stageProgram = pipeline->GetStageProgram(static_cast<ShaderStage>(stage));
if (stageProgram) stageProgram->JoinLink();
}
const auto signature = pipeline->ComputeDrawProgramSignature();
if (const auto& cached = pipeline->GetCachedDrawProgram(signature)) return cached;
@@ -378,6 +428,10 @@ namespace MobileGL::MG_State {
// A pipeline with no fragment stage still rasterises, so the default fragment
// shader is wanted here even though the separable stage programs never get one.
composite->Link(true);
// P1 join site J2. The draw that asked for this program is the very next thing to
// happen, so enqueueing the composite's link buys nothing and only moves the wait
// to whichever backend accessor happens to touch its artifacts first.
composite->JoinLink();
pipeline->SetCachedDrawProgram(signature, Move(composite));
return pipeline->GetCachedDrawProgram(signature);
}
+26
View File
@@ -21,6 +21,10 @@
#include "VertexArrayState/VertexArrayState.h"
#include "RenderbufferState/RenderbufferState.h"
namespace MobileGL::MG_Util::ShaderTranspiler {
struct CompileEnv;
}
namespace MobileGL {
namespace MG_State {
void Init();
@@ -149,6 +153,14 @@ namespace MobileGL {
Bool ValidateShaderName(Uint index) const;
const SharedPtr<ProgramObject>& GetProgramObject(Uint index);
const SharedPtr<ShaderObject>& GetShaderObject(Uint index);
// Settles every compile and link this context still owns; see
// ProgramState::JoinAllPendingWork. Called by glMaxShaderCompilerThreadsKHR(0).
void JoinAllPendingShaderWork();
// P1 stage 6: the per-context index of adoptable compile nodes, for its
// adoption counter. Diagnostics and tests only - no GL entry point reads it.
ShaderCompileAdoptionMap& GetShaderCompileAdoptionMap() {
return m_programState.GetShaderCompileAdoptionMap();
}
void UseProgram(Uint program);
const SharedPtr<ProgramObject>& GetCurrentProgram();
// What a draw or dispatch actually executes: the program in use, or - when
@@ -380,6 +392,15 @@ namespace MobileGL {
Bool ValidateRenderbufferName(Uint index) const;
Bool ValidateRenderbufferObject(Uint index) const;
// P1: the shader compile/link pipeline's snapshot of everything it reads from
// outside its own (stage, source) inputs. Captured lazily here because it
// cannot be captured in MG_State::Init() - that runs BEFORE MG_Backend::Init(),
// so there is no backend to query yet. Re-captured whenever the active backend
// object changes, which also rolls the fingerprint and therefore invalidates
// every P0b preprocess memo keyed against the old one.
// GL thread only.
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv>& GetCompileEnv();
private:
// State Components
ErrorState m_errorState;
@@ -437,6 +458,11 @@ namespace MobileGL {
FramebufferState m_framebufferState;
SamplerState m_samplerState;
RenderbufferState m_renderbufferState;
mutable SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> m_compileEnv;
// Identity of the backend object m_compileEnv was captured against; a plain
// pointer compare, never dereferenced.
const void* m_compileEnvBackend = nullptr;
};
} // namespace GLState
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include <MG_State/GLState/ProgramState/ProgramObject.h>
#include <MG_State/GLState/ProgramState/ShaderCompileTask.h>
#include <MG_Util/Async/JobNode.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
namespace MobileGL::MG_State::GLState {
// One attached shader, as the link sees it: never the ShaderObject, always a snapshot.
//
// The ShaderObject is GL-thread-owned and may be re-sourced, detached or destroyed while
// this link is still queued; everything below is either immutable or independently owned,
// so none of that can reach the worker.
struct LinkShaderInput {
ShaderStage stage = ShaderStage::Unknown;
// For the compile-error diagnostic and the compute local_size check, both of which
// quote the ORIGINAL source rather than the preprocessed one.
SharedPtr<const String> source;
// The authoritative compiled state. Null, or non-Complete, both read as "this shader
// did not compile" - the same verdict ShaderObject's join gate produces.
SharedPtr<const ShaderCompileTask> compiled;
};
// The unit of asynchronous linking: one glLinkProgram's worth of pure CPU work - glslang
// link + mapIO, SPIR-V generation and optimization, the GL-facing reflection surface, the
// global-UBO routing tables, fragment-output validation and transform-feedback
// resolution - with every input it needs snapshotted at enqueue.
//
// Same ownership rule as ShaderCompileTask: the body reads nothing but `in` (all of it
// owned or immutable) and writes nothing but `artifacts`. No GL call, no
// pActiveBackendObject read, no pGLContext->RecordError(); the device limits arrive
// through the CompileEnv snapshot and diagnostics are deferred to the join.
//
// ONE LINK IS ONE HANDLER. RunBody() runs start to finish inside a single pool handler
// and is the only place `artifacts` is written. Do not split it across handlers to
// "pipeline" the reflection half: the intermediates that GlslangToSpv and buildReflection
// share are mutated in a strict order (see the GenerateSpirv-before-DoReflection comment
// in Run()), and a second handler would let a cancel land between them and publish a
// program whose SPIR-V and reflection describe different things.
class ProgramLinkTask final : public MG_Util::Async::JobNode {
public:
// ---- inputs, snapshotted on the GL thread in ProgramObject::Link()'s prologue ----
struct Inputs {
Uint externalIndex = 0; // logs only
Vector<LinkShaderInput> shaders; // already stage-sorted
SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env;
// The four "takes effect at the next link" request maps. Snapshotted rather than
// referenced, which is precisely what makes glBindAttribLocation and friends
// legal to call over a pending link without cancelling it: the pending link keeps
// linking the inputs it was given.
UnorderedMap<String, Uint> explicitAttribLocations; // glBindAttribLocation
UnorderedMap<String, Uint> explicitFragDataLocation; // glBindFragDataLocation
UnorderedMap<String, Uint> explicitFragDataIndex; // glBindFragDataLocationIndexed
Vector<String> requestedXfbVaryings; // glTransformFeedbackVaryings
GLenum requestedXfbBufferMode = GL_INTERLEAVED_ATTRIBS;
Int maxFragmentOutputColorNumber = 8; // GL_MAX_DRAW_BUFFERS, stamped in by the entry point
} in;
// ---- output: valid iff IsComplete(), immutable afterwards ----
// Moved (never copied) into the ProgramObject by EnsureLinkJoined().
ProgramObject::LinkArtifacts artifacts;
// Posts this job once every compile in `deps` is terminal - and not one moment
// earlier, so the body never waits on anything (invariant I4: no job body may block
// on another job, or the pool could deadlock with all its workers waiting on each
// other). `deps` is the subset of the snapshot's compile nodes that were still
// in flight; an already-terminal one needs no edge.
//
// GL thread only, and only after the caller has stored a SharedPtr to this node:
// OnDepSettled takes shared_from_this().
void SubmitAfter(const Vector<SharedPtr<ShaderCompileTask>>& deps);
private:
void RunBody() override;
// Runs when one dependency goes terminal - on whichever thread drove it there, which
// is a pool worker for a compile that finished on one. Non-throwing by construction;
// see the definition.
void OnDepSettled();
// ---- the link body, split exactly as ProgramObject::Link() had it ----
// Each returns false to abort the link with `artifacts.infoLog` already set, which is
// GL's definition of a failed link: LINK_STATUS false plus a log, never a GL error.
Bool ConsumeShaders(Vector<SharedPtr<glslang::TShader>>& outShaders);
Bool DoReflection(const MG_Util::ShaderTranspiler::CompileEnv& env);
Bool ValidateFragmentOutputLocations();
Bool ResolveTransformFeedbackVaryings();
void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate);
void GenerateSpirv();
void BuildGlobalUboRouting();
// 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);
// 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
// registered cannot post the job from under a half-built dependency list.
std::atomic<Int> m_remainingDeps{0};
};
} // namespace MobileGL::MG_State::GLState
File diff suppressed because it is too large Load Diff
@@ -14,9 +14,22 @@
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
namespace MobileGL::MG_State::GLState {
// The link job. Only ever held by SharedPtr here, so a forward declaration is enough -
// ProgramLinkTask.h includes THIS header (it outputs a LinkArtifacts), so including it
// back would be circular. The destructor is therefore out of line.
class ProgramLinkTask;
class ProgramObject {
public:
ProgramObject(Uint externalIndex) : m_externalIndex(externalIndex), m_lifetimeId(AllocateLifetimeId()) {}
// Cancel-not-join, exactly like ~ShaderObject: the link job owns its inputs, so an
// in-flight link whose program just went away is safe to abandon where it stands.
// Nothing can observe its result any more - this object was the only route to it.
// Out of line because ProgramLinkTask is incomplete here.
~ProgramObject();
ProgramObject(const ProgramObject&) = delete;
ProgramObject& operator=(const ProgramObject&) = delete;
bool ShaderIsAttached(const SharedPtr<ShaderObject>& shader);
// GL-visible attachment: in the attach list and not pending detach (glDetachShader
// defers the actual removal to the next link).
@@ -44,31 +57,39 @@ namespace MobileGL::MG_State::GLState {
Vector<SharedPtr<ShaderObject>>& GetAttachedShaders();
const Vector<SharedPtr<ShaderObject>>& GetAttachedShaders() const;
const String& GetInfoLog() const { return m_infoLog; }
const String& GetInfoLog() const { return Artifacts().infoLog; }
// glCreateShaderProgramv folds the shader's compile log into the program's log, which
// is the only place a caller can read it from once the shader name is gone.
void AppendInfoLog(const String& text) {
if (text.empty()) return;
if (!m_infoLog.empty() && m_infoLog.back() != '\n') m_infoLog += '\n';
m_infoLog += text;
if (!Artifacts().infoLog.empty() && Artifacts().infoLog.back() != '\n') Artifacts().infoLog += '\n';
Artifacts().infoLog += text;
}
Int GetUniformMaxLength() const { return m_uniformNameMaxLength; }
Uint GetUniformCount() const { return m_activeUniformCount; }
Uint GetMaxUniformLocation() const { return m_maxUniformLocation; }
Int GetUniformMaxLength() const { return Artifacts().uniformNameMaxLength; }
Uint GetUniformCount() const { return Artifacts().activeUniformCount; }
Uint GetMaxUniformLocation() const { return Artifacts().maxUniformLocation; }
Int GetUniformLocation(const String& name) const {
const auto it = m_uniformLocations.find(name);
if (it != m_uniformLocations.end()) return (Int)it->second;
const auto it = Artifacts().uniformLocations.find(name);
if (it != Artifacts().uniformLocations.end()) return (Int)it->second;
// Reflection stores GL-style names: an array uniform is keyed "arr[0]" (its base
// location). A bare "arr" query resolves to that entry; an "arr[k]" query resolves
// to base + k because DoReflection reserves one location per array element.
if (name.empty()) return -1;
if (name.back() != ']') {
const auto suffixedIt = m_uniformLocations.find(name + "[0]");
if (suffixedIt != m_uniformLocations.end()) return (Int)suffixedIt->second;
const auto suffixedIt = Artifacts().uniformLocations.find(name + "[0]");
if (suffixedIt != Artifacts().uniformLocations.end()) return (Int)suffixedIt->second;
return -1;
}
if (name.length() < 4) return -1;
// An array of arrays is keyed by its full "[0]"-terminated spelling
// ("a[2][1][0]"), so a query that already ends in a subscript may still be the
// NAME of an array rather than an element of one. Try that first; only then
// treat the trailing subscript as an element index.
{
const auto arrayOfArraysIt = Artifacts().uniformLocations.find(name + "[0]");
if (arrayOfArraysIt != Artifacts().uniformLocations.end()) return (Int)arrayOfArraysIt->second;
}
const SizeT bracket = name.rfind('[');
// Require at least one digit between the brackets.
if (bracket == String::npos || bracket + 1 >= name.length() - 1) return -1;
@@ -78,21 +99,21 @@ namespace MobileGL::MG_State::GLState {
element = element * 10 + static_cast<Uint>(name[i] - '0');
if (element > 0x0FFFFFFFu) return -1;
}
auto baseIt = m_uniformLocations.find(name.substr(0, bracket) + "[0]");
if (baseIt == m_uniformLocations.end()) {
auto baseIt = Artifacts().uniformLocations.find(name.substr(0, bracket) + "[0]");
if (baseIt == Artifacts().uniformLocations.end()) {
// Legacy key without the "[0]" suffix (defensive; reflection normally
// stores the suffixed form for arrays).
baseIt = m_uniformLocations.find(name.substr(0, bracket));
if (baseIt == m_uniformLocations.end()) return -1;
baseIt = Artifacts().uniformLocations.find(name.substr(0, bracket));
if (baseIt == Artifacts().uniformLocations.end()) return -1;
}
const Int base = (Int)baseIt->second;
if (!IsValidUniformLocation(base)) return -1;
const Int index = m_uniformIndexInTProgram[base];
const Int index = Artifacts().uniformIndexInTProgram[base];
// "[k]" only addresses arrays ("scalar[0]" is not a uniform name), and only
// in-range elements.
const glslang::TType* type = m_program->getUniform(index).getType();
const glslang::TType* type = Artifacts().program->getUniform(index).getType();
if (type == nullptr || !type->isArray()) return -1;
if (static_cast<GLint>(element) >= GetActiveUniformArraySize(index)) return -1;
if (static_cast<GLint>(element) >= GetUniformArraySizeByTIndex(index)) return -1;
const Int location = base + (Int)element;
if (!UniformLocationsAliasSameUniform(base, location)) return -1;
return location;
@@ -101,14 +122,41 @@ namespace MobileGL::MG_State::GLState {
// True when both locations are element slots of the same uniform variable.
Bool UniformLocationsAliasSameUniform(Int a, Int b) const {
if (!IsValidUniformLocation(a) || !IsValidUniformLocation(b)) return false;
return m_uniformIndexInTProgram[a] == m_uniformIndexInTProgram[b];
return Artifacts().uniformIndexInTProgram[a] == Artifacts().uniformIndexInTProgram[b];
}
// ---- GL index <-> glslang TProgram index translation ----
// The single relaxed parse enumerates artifacts GL must not see: every declared
// default-block uniform (even dead ones) as a member of the synthesized
// MGL_GLOBAL_UBO, and that block itself. DoReflection builds filtered GL-facing
// index spaces; every public "index"-taking getter translates through them, so
// GL and backend consumers keep seeing exactly the pre-P0a surface.
Int TProgramUniformIndex(Uint glIndex) const {
return Artifacts().glUniformIndexToTProgram[glIndex];
}
Int GlUniformIndexFromTProgram(Int tIndex) const {
if (tIndex < 0 || tIndex >= static_cast<Int>(Artifacts().tProgramUniformIndexToGl.size())) return -1;
return Artifacts().tProgramUniformIndexToGl[tIndex];
}
// GL uniform-block index -> glslang TProgram block index (the inverse of
// GlBlockIndexFromTProgram). The interface-query layer needs it to reach block
// properties glslang exposes but no typed getter here does.
Int TProgramBlockIndex(Uint glBlockIndex) const {
return glBlockIndex < Artifacts().glBlockIndexToTProgram.size()
? Artifacts().glBlockIndexToTProgram[glBlockIndex]
: -1;
}
Int GlBlockIndexFromTProgram(Int tBlockIndex) const {
if (tBlockIndex < 0 || tBlockIndex >= static_cast<Int>(Artifacts().tProgramBlockIndexToGl.size())) return -1;
return Artifacts().tProgramBlockIndexToGl[tBlockIndex];
}
Int GetActiveUniformIndex(const String& name) const {
const Int uniformIndex = m_program->getUniformIndex(name.c_str());
if (uniformIndex >= 0 && uniformIndex < m_activeUniformCount &&
m_program->getUniform(uniformIndex).name == name) {
return uniformIndex;
const Int tProgramCount = static_cast<Int>(Artifacts().tProgramUniformIndexToGl.size());
const Int uniformIndex = Artifacts().program->getUniformIndex(name.c_str());
if (uniformIndex >= 0 && uniformIndex < tProgramCount &&
Artifacts().program->getUniform(uniformIndex).name == name) {
return GlUniformIndexFromTProgram(uniformIndex);
}
// Reflection stores an array uniform under "arr[0]"; accept the bare "arr"
@@ -116,61 +164,60 @@ namespace MobileGL::MG_State::GLState {
// robustness against non-suffixed reflection entries.
if (!name.empty() && name.back() != ']') {
const String suffixedName = name + "[0]";
const Int suffixedIndex = m_program->getUniformIndex(suffixedName.c_str());
if (suffixedIndex >= 0 && suffixedIndex < m_activeUniformCount &&
m_program->getUniform(suffixedIndex).name == suffixedName) {
return suffixedIndex;
const Int suffixedIndex = Artifacts().program->getUniformIndex(suffixedName.c_str());
if (suffixedIndex >= 0 && suffixedIndex < tProgramCount &&
Artifacts().program->getUniform(suffixedIndex).name == suffixedName) {
return GlUniformIndexFromTProgram(suffixedIndex);
}
return -1;
}
if (name.length() <= 3 || name.compare(name.length() - 3, 3, "[0]") != 0) return -1;
const String baseName = name.substr(0, name.length() - 3);
const Int baseIndex = m_program->getUniformIndex(baseName.c_str());
if (baseIndex < 0 || baseIndex >= m_activeUniformCount) return -1;
return m_program->getUniform(baseIndex).name == baseName ? baseIndex : -1;
const Int baseIndex = Artifacts().program->getUniformIndex(baseName.c_str());
if (baseIndex < 0 || baseIndex >= tProgramCount) return -1;
return Artifacts().program->getUniform(baseIndex).name == baseName ? GlUniformIndexFromTProgram(baseIndex)
: -1;
}
Bool IsValidUniformLocation(Int location) const {
if (location < 0 || location > static_cast<Int>(m_maxUniformLocation)) return false;
if (static_cast<SizeT>(location) >= m_uniformIndexInTProgram.size()) return false;
const Int uniformIndexInProgram = m_uniformIndexInTProgram[location];
return uniformIndexInProgram != glslang::TQualifier::layoutLocationEnd &&
uniformIndexInProgram >= 0 && uniformIndexInProgram < m_activeUniformCount;
}
Bool IsValidUniformLocation(Int location) const { return IsValidUniformLocation(Artifacts(), location); }
GLenum GetUniformType(Uint location) const {
auto& uniform = m_program->getUniform(m_uniformIndexInTProgram[location]);
auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]);
return uniform.glDefineType;
}
GLenum GetActiveUniformType(Uint index) const {
auto& uniform = m_program->getUniform(static_cast<Int>(index));
auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
return uniform.glDefineType;
}
// Number of active array elements (GL_UNIFORM_SIZE / GL_ARRAY_SIZE); 1 for a non-array.
// glslang's TObjectReflection.size only carries the element count for a NON-block array; for
// a block array member it reports 1, so take the count from the TType, which is authoritative
// for both. GL 3.3 core uniforms are always sized.
GLint GetActiveUniformArraySize(Uint index) const {
const auto& uniform = m_program->getUniform(static_cast<Int>(index));
const glslang::TType* type = uniform.getType();
if (type != nullptr && type->isSizedArray()) {
return type->getOuterArraySize();
// for both. GL 3.3 core uniforms are always sized. Takes a TProgram uniform index (the space
// the artifacts' uniformIndexInTProgram stores).
GLint GetUniformArraySizeByTIndex(Int tIndex) const {
return GetUniformArraySizeByTIndex(Artifacts(), tIndex);
}
return uniform.size < 1 ? 1 : uniform.size;
GLint GetActiveUniformArraySize(Uint index) const {
return GetUniformArraySizeByTIndex(TProgramUniformIndex(index));
}
Int GetActiveUniformBlockIndex(Uint index) const {
auto& uniform = m_program->getUniform(static_cast<Int>(index));
return uniform.index;
auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
// Members of the synthesized global UBO are default-block uniforms to GL: -1.
return GlBlockIndexFromTProgram(uniform.index);
}
// GL_UNIFORM_OFFSET: byte offset within the owning named block. glslang already reports -1
// for a default-block uniform, which is exactly the spec value there.
// GL_UNIFORM_OFFSET: byte offset within the owning named block; -1 for a default-block
// uniform. The relaxed parse gives global-UBO members real byte offsets, but GL must keep
// seeing them as default-block uniforms, so gate on the GL-visible block index.
GLint GetActiveUniformOffset(Uint index) const {
return m_program->getUniform(static_cast<Int>(index)).offset;
const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1;
return uniform.offset;
}
// GL_UNIFORM_ARRAY_STRIDE: byte stride of an array member in a named block; 0 for a non-array
@@ -182,8 +229,8 @@ namespace MobileGL::MG_State::GLState {
// generated SPIR-V lay the array out with std140 16-byte-rounded strides. MobileGL's UBO
// layout is always std140, where every array element stride rounds up to a vec4.
GLint GetActiveUniformArrayStride(Uint index) const {
const auto& uniform = m_program->getUniform(static_cast<Int>(index));
if (uniform.index < 0) return -1;
const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1;
const glslang::TType* type = uniform.getType();
if (type == nullptr || !type->isArray()) return 0;
if (type->isMatrix()) {
@@ -202,13 +249,13 @@ namespace MobileGL::MG_State::GLState {
// check suffices; the getUniformBlock() fallback is defensive for a config that instead leaves
// an inheriting member's layoutMatrix == ElmNone.
GLint GetActiveUniformIsRowMajor(Uint index) const {
const auto& uniform = m_program->getUniform(static_cast<Int>(index));
if (uniform.index < 0) return 0;
const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
if (GlBlockIndexFromTProgram(uniform.index) < 0) return 0;
const glslang::TType* type = uniform.getType();
if (type == nullptr || !type->isMatrix()) return 0;
glslang::TLayoutMatrix layoutMatrix = type->getQualifier().layoutMatrix;
if (layoutMatrix == glslang::ElmNone) {
layoutMatrix = m_program->getUniformBlock(uniform.index).getType()->getQualifier().layoutMatrix;
layoutMatrix = Artifacts().program->getUniformBlock(uniform.index).getType()->getQualifier().layoutMatrix;
}
return (layoutMatrix == glslang::ElmRowMajor) ? 1 : 0;
}
@@ -220,13 +267,13 @@ namespace MobileGL::MG_State::GLState {
// out as std140 (packed/shared are coerced), so this matches the offsets glslang reports. For
// every GL 3.3 float matrix this evaluates to 16, independent of majorness.
GLint GetActiveUniformMatrixStride(Uint index) const {
const auto& uniform = m_program->getUniform(static_cast<Int>(index));
if (uniform.index < 0) return -1;
const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1;
const glslang::TType* type = uniform.getType();
if (type == nullptr || !type->isMatrix()) return 0;
glslang::TLayoutMatrix layoutMatrix = type->getQualifier().layoutMatrix;
if (layoutMatrix == glslang::ElmNone) {
layoutMatrix = m_program->getUniformBlock(uniform.index).getType()->getQualifier().layoutMatrix;
layoutMatrix = Artifacts().program->getUniformBlock(uniform.index).getType()->getQualifier().layoutMatrix;
}
const bool rowMajor = (layoutMatrix == glslang::ElmRowMajor);
const int strideVectorComponents = rowMajor ? type->getMatrixCols() : type->getMatrixRows();
@@ -238,50 +285,50 @@ namespace MobileGL::MG_State::GLState {
}
const glslang::TType* GetUniformTType(Uint location) const {
auto& uniform = m_program->getUniform(m_uniformIndexInTProgram[location]);
auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]);
return uniform.getType();
}
Bool IsUniformOpaqueAtLocation(Uint location) const { return GetUniformTType(location)->isOpaque(); }
const String& GetUniformName(Uint location) const {
auto& uniform = m_program->getUniform(m_uniformIndexInTProgram[location]);
auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]);
return uniform.name;
}
const String& GetActiveUniformName(Uint index) const {
auto& uniform = m_program->getUniform(static_cast<Int>(index));
auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
return uniform.name;
}
// Sentinel for a uniform location without global-UBO backing storage (should not
// survive linking: GenerateBinary falls back to tail-allocated scratch storage).
static constexpr Uint kInvalidUniformOffset = ~0u;
Uint GetUniformOffset(Uint location) const { return m_uniformOffsets[location]; }
Uint GetUniformOffset(Uint location) const { return Artifacts().uniformOffsets[location]; }
Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); }
Int GetAttributeLocation(const String& name) {
const auto it = std::find(m_attribs.begin(), m_attribs.end(), name);
return (it == m_attribs.end()) ? -1 : (Int)std::distance(m_attribs.begin(), it);
const auto it = std::find(Artifacts().attribs.begin(), Artifacts().attribs.end(), name);
return (it == Artifacts().attribs.end()) ? -1 : (Int)std::distance(Artifacts().attribs.begin(), it);
}
Uint32 GetActiveAttributeLocationMask() const {
Uint32 mask = 0;
const SizeT count = std::min<SizeT>(m_attribs.size(), 32);
const SizeT count = std::min<SizeT>(Artifacts().attribs.size(), 32);
for (SizeT index = 0; index < count; ++index) {
if (!m_attribs[index].empty()) {
if (!Artifacts().attribs[index].empty()) {
mask |= (1u << index);
}
}
return mask;
}
Uint32 GetActiveFragmentOutputLocationMask() const {
if (!m_program) {
if (!Artifacts().program) {
return 0;
}
Uint32 mask = 0;
const Int outputCount = m_program->getNumPipeOutputs();
const Int outputCount = Artifacts().program->getNumPipeOutputs();
for (Int index = 0; index < outputCount; ++index) {
const Int location = static_cast<Int>(m_program->getPipeOutput(index).layoutLocation());
const Int location = static_cast<Int>(Artifacts().program->getPipeOutput(index).layoutLocation());
if (location >= 0 && location < 32) {
mask |= (1u << location);
}
@@ -289,47 +336,59 @@ namespace MobileGL::MG_State::GLState {
return mask;
}
Int GetActiveFragmentOutputCount() const {
return m_program ? m_program->getNumPipeOutputs() : 0;
return Artifacts().program ? Artifacts().program->getNumPipeOutputs() : 0;
}
const String& GetActiveFragmentOutputName(Uint index) const {
MOBILEGL_ASSERT(m_program != nullptr, "ProgramObject::GetActiveFragmentOutputName: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(m_program->getNumPipeOutputs()),
MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetActiveFragmentOutputName: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
"ProgramObject::GetActiveFragmentOutputName: index=%u out of range", index);
return m_program->getPipeOutput(static_cast<Int>(index)).name;
return Artifacts().program->getPipeOutput(static_cast<Int>(index)).name;
}
Int GetFragmentOutputLocation(Uint index) const {
MOBILEGL_ASSERT(m_program != nullptr, "ProgramObject::GetFragmentOutputLocation: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(m_program->getNumPipeOutputs()),
MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetFragmentOutputLocation: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
"ProgramObject::GetFragmentOutputLocation: index=%u out of range",
index);
return static_cast<Int>(m_program->getPipeOutput(static_cast<Int>(index)).layoutLocation());
return static_cast<Int>(Artifacts().program->getPipeOutput(static_cast<Int>(index)).layoutLocation());
}
GLint GetActiveFragmentOutputArraySize(Uint index) const {
MOBILEGL_ASSERT(m_program != nullptr, "ProgramObject::GetActiveFragmentOutputArraySize: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(m_program->getNumPipeOutputs()),
MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetActiveFragmentOutputArraySize: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
"ProgramObject::GetActiveFragmentOutputArraySize: index=%u out of range", index);
return m_program->getPipeOutput(static_cast<Int>(index)).size;
return Artifacts().program->getPipeOutput(static_cast<Int>(index)).size;
}
GLenum GetFragmentOutputType(Uint index) const {
MOBILEGL_ASSERT(m_program != nullptr, "ProgramObject::GetFragmentOutputType: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(m_program->getNumPipeOutputs()),
MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetFragmentOutputType: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
"ProgramObject::GetFragmentOutputType: index=%u out of range",
index);
return m_program->getPipeOutput(static_cast<Int>(index)).glDefineType;
return Artifacts().program->getPipeOutput(static_cast<Int>(index)).glDefineType;
}
GLenum GetAttribType(Uint index) const { return m_attribTypes[index]; }
const String& GetAttribName(Uint index) const { return m_attribs[index]; }
GLenum GetActiveAttribType(Uint index) const { return m_program->getPipeInput(static_cast<Int>(index)).glDefineType; }
GLint GetActiveAttribArraySize(Uint index) const { return m_program->getPipeInput(static_cast<Int>(index)).size; }
const String& GetActiveAttribName(Uint index) const { return m_program->getPipeInput(static_cast<Int>(index)).name; }
void* MapUBO() { return m_globalUboScratch.data(); }
const void* GetUBOData() const { return m_globalUboScratch.data(); }
Uint GetUBOSize() const { return static_cast<Uint>(m_globalUboScratch.size()); }
GLenum GetAttribType(Uint index) const { return Artifacts().attribTypes[index]; }
const String& GetAttribName(Uint index) const { return Artifacts().attribs[index]; }
GLenum GetActiveAttribType(Uint index) const { return Artifacts().program->getPipeInput(static_cast<Int>(index)).glDefineType; }
GLint GetActiveAttribArraySize(Uint index) const { return Artifacts().program->getPipeInput(static_cast<Int>(index)).size; }
// The Vulkan-semantics parse reflects the vertex builtins under their SPIR-V names;
// GL must keep reporting the GL spellings (glGetActiveAttrib and the program-input
// resource queries enumerate builtins).
static const String& NormalizeBuiltinPipeInputName(const String& name) {
static const String kGlVertexId = "gl_VertexID";
static const String kGlInstanceId = "gl_InstanceID";
if (name == "gl_VertexIndex") return kGlVertexId;
if (name == "gl_InstanceIndex") return kGlInstanceId;
return name;
}
const String& GetActiveAttribName(Uint index) const {
return NormalizeBuiltinPipeInputName(Artifacts().program->getPipeInput(static_cast<Int>(index)).name);
}
void* MapUBO() { return Artifacts().globalUboScratch.data(); }
const void* GetUBOData() const { return Artifacts().globalUboScratch.data(); }
Uint GetUBOSize() const { return static_cast<Uint>(Artifacts().globalUboScratch.size()); }
// Content version of the CPU-side global-UBO shadow: writers bump it so backends
// can skip re-uploading an unchanged UBO on every draw. ~0u is reserved as the
// backends' "never uploaded" sentinel, so skip over it on wrap.
Uint32 GetUBOContentVersion() const { return m_uboContentVersion; }
void MarkUBOContentDirty() {
void MarkUBOContentDirty() const {
if (++m_uboContentVersion == ~0u) m_uboContentVersion = 0;
}
Uint32 GetBackendStateVersion() const { return m_backendStateVersion; }
@@ -370,20 +429,20 @@ namespace MobileGL::MG_State::GLState {
}
void SetUniformSamplerOrImageUnitIndex(Uint location, Int unit) {
if (location >= m_uniformSamplerOrImageUnitIndex.size() ||
m_uniformSamplerOrImageUnitIndex[location] == unit) {
if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size() ||
Artifacts().uniformSamplerOrImageUnitIndex[location] == unit) {
return;
}
m_uniformSamplerOrImageUnitIndex[location] = unit;
Artifacts().uniformSamplerOrImageUnitIndex[location] = unit;
++m_backendStateVersion;
}
Int GetUniformSamplerOrImageUnitIndex(Uint location) const {
return m_uniformSamplerOrImageUnitIndex[location];
return Artifacts().uniformSamplerOrImageUnitIndex[location];
}
Bool GetDeleteStatus() const { return m_deleteStatus; }
Bool GetLinkStatus() const { return m_linkStatus; }
Bool GetLinkStatus() const { return Artifacts().linkStatus; }
// GL_PROGRAM_BINARY_RETRIEVABLE_HINT. MobileGL exposes no program binary format
// (GL_NUM_PROGRAM_BINARY_FORMATS is 0), so the hint is pure state - which is all
// ARB_get_program_binary requires of it.
@@ -397,24 +456,32 @@ namespace MobileGL::MG_State::GLState {
// glProgramBinary always fails here (there is no format it could accept) and the
// spec then requires the program's LINK_STATUS to read FALSE.
void MarkLinkFailedByProgramBinary() {
ResetLinkArtifacts();
m_infoLog = "No program binary format is supported.";
// Before anything reads m_artifacts: a pending link would otherwise publish its
// (possibly successful) result over the failure this call is required to install
// - and Artifacts() below would be the thing that let it. Cancel-not-join: GL
// gives glProgramBinary no reason to wait for a link it is about to invalidate.
CancelLink();
BumpLinkObservableVersions();
ResetLinkArtifacts(Artifacts());
Artifacts().infoLog = "No program binary format is supported.";
}
Bool GetValidateStatus() const { return m_validateStatus; }
Int GetActiveAtomicCounterCount() const { return m_program->getNumAtomicCounters(); }
Int GetActiveAttributesCount() const { return m_program->getNumPipeInputs(); }
Int GetActiveUniformBlocksCount() const { return m_program->getNumUniformBlocks(); }
GLuint GetComputeLocalSize(Uint dim) const { return m_program->getLocalSize(static_cast<Int>(dim)); }
Int GetActiveAttributesMaxLength() const { return m_attribInNameMaxLength; }
Int GetActiveUniformBlocksMaxNameLength() const { return m_uniformBlockNameMaxLength; }
Int GetActiveAtomicCounterCount() const { return Artifacts().program->getNumAtomicCounters(); }
Int GetActiveAttributesCount() const { return Artifacts().program->getNumPipeInputs(); }
// GL-visible uniform blocks only: the synthesized MGL_GLOBAL_UBO the relaxed parse
// materializes for default-block uniforms is filtered out by DoReflection.
Int GetActiveUniformBlocksCount() const { return static_cast<Int>(Artifacts().glBlockIndexToTProgram.size()); }
GLuint GetComputeLocalSize(Uint dim) const { return Artifacts().program->getLocalSize(static_cast<Int>(dim)); }
Int GetActiveAttributesMaxLength() const { return Artifacts().attribInNameMaxLength; }
Int GetActiveUniformBlocksMaxNameLength() const { return Artifacts().uniformBlockNameMaxLength; }
Uint GetUniformBlockIndex(const char* name) const {
auto it = m_uniformBlockIndexByName.find(name);
if (it != m_uniformBlockIndexByName.end()) return it->second;
auto it = Artifacts().uniformBlockIndexByName.find(name);
if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
// Instances of an arrayed block are reflected as "Block[0]".."Block[N-1]";
// a bare "Block" query resolves to the first instance per GL semantics.
const String suffixedName = String(name) + "[0]";
it = m_uniformBlockIndexByName.find(suffixedName);
if (it != m_uniformBlockIndexByName.end()) return it->second;
it = Artifacts().uniformBlockIndexByName.find(suffixedName);
if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
return 0xFFFFFFFFu; // GL_INVALID_INDEX
}
Bool IsActiveUniformBlock(Uint index) const {
@@ -427,11 +494,11 @@ namespace MobileGL::MG_State::GLState {
// (like a std140 struct) occupies a vec4-rounded size, and that is what the
// backend compiles: ES drivers reject draws whose bound UBO range is smaller
// than the block (a block ending in ivec3 reported 12 while the driver needs 16).
return (m_program->getUniformBlock((Int)index).size + 15u) & ~15u;
return (Artifacts().program->getUniformBlock(Artifacts().glBlockIndexToTProgram[index]).size + 15u) & ~15u;
}
const String& GetUniformBlockName(Uint index) const {
auto& ubo = m_program->getUniformBlock((Int)index);
auto& ubo = Artifacts().program->getUniformBlock(Artifacts().glBlockIndexToTProgram[index]);
return ubo.name;
}
@@ -443,8 +510,8 @@ namespace MobileGL::MG_State::GLState {
if (name.empty() || name.back() != ']') return index;
const SizeT bracket = name.rfind('[');
if (bracket == String::npos) return index;
const auto it = m_uniformBlockIndexByName.find(name.substr(0, bracket) + "[0]");
if (it != m_uniformBlockIndexByName.end()) return it->second;
const auto it = Artifacts().uniformBlockIndexByName.find(name.substr(0, bracket) + "[0]");
if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
return index;
}
@@ -455,31 +522,64 @@ namespace MobileGL::MG_State::GLState {
Int GetUniformBlockActiveUniformCount(Uint index) const {
const Int ownerIndex = static_cast<Int>(GetUniformBlockMemberOwnerIndex(index));
Int count = 0;
for (Uint uniformIndex = 0; uniformIndex < m_activeUniformCount; ++uniformIndex) {
for (Uint uniformIndex = 0; uniformIndex < Artifacts().activeUniformCount; ++uniformIndex) {
if (GetActiveUniformBlockIndex(uniformIndex) == ownerIndex) ++count;
}
return count;
}
Bool IsUniformBlockReferencedByStage(Uint index, EShLanguage stage) const {
const auto& ubo = m_program->getUniformBlock((Int)index);
const auto& ubo = Artifacts().program->getUniformBlock(Artifacts().glBlockIndexToTProgram[index]);
const auto stageMask = static_cast<EShLanguageMask>(1 << stage);
return (ubo.stages & stageMask) != 0;
}
// Set by glUniformBlockBinding
void SetUniformBlockBinding(Uint index, Uint binding) {
if (index >= m_uniformBlockBinding.size() || m_uniformBlockBinding[index] == static_cast<Int>(binding)) {
if (index >= Artifacts().uniformBlockBinding.size() || Artifacts().uniformBlockBinding[index] == static_cast<Int>(binding)) {
return;
}
m_uniformBlockBinding[index] = static_cast<Int>(binding);
Artifacts().uniformBlockBinding[index] = static_cast<Int>(binding);
++m_backendStateVersion;
}
Uint GetUniformBlockBinding(Uint index) const { return m_uniformBlockBinding[index]; }
Uint GetUniformBlockBinding(Uint index) const { return Artifacts().uniformBlockBinding[index]; }
Vector<Vector<unsigned>>& GetGeneratedSpirv() { return m_generatedSpirv; }
const Vector<Vector<unsigned>>& GetGeneratedSpirv() const { return m_generatedSpirv; }
// Set by glShaderStorageBlockBinding, keyed by the block's GL name rather than by any
// index. A shader storage block has THREE index spaces - the frontend interface-query
// enumeration, DirectVulkan's SPIR-V descriptor order and DirectGLES's real-driver
// order - and the name is the only coordinate all three agree on. Absent from the map
// means "never rebound", and the shader's declared binding still stands.
void SetShaderStorageBlockBinding(const String& blockName, Uint binding) {
Artifacts().shaderStorageBlockBinding[blockName] = static_cast<Int>(binding);
}
// -1 when the block has never been rebound. `blockName` is the interface-query
// spelling; an arrayed block's elements ("B[0]", "B[1]") are separate GL resources
// with separate bindings, so they are separate keys.
Int GetShaderStorageBlockBindingOverride(const String& blockName) const {
const auto it = Artifacts().shaderStorageBlockBinding.find(blockName);
if (it != Artifacts().shaderStorageBlockBinding.end()) return it->second;
// A backend that collapses an arrayed block down to one resource knows it only by
// the bare block name; answer that with element zero's binding.
const auto zeroth = Artifacts().shaderStorageBlockBinding.find(blockName + "[0]");
return zeroth != Artifacts().shaderStorageBlockBinding.end() ? zeroth->second : -1;
}
// Every rebinding recorded so far, for a backend that has to REPLAY them onto a
// driver program it just (re)built. Empty for the overwhelming majority of programs -
// check .empty() before doing any per-block work.
const UnorderedMap<String, Int>& GetShaderStorageBlockBindingOverrides() const {
return Artifacts().shaderStorageBlockBinding;
}
Vector<Vector<unsigned>>& GetGeneratedSpirv() { return Artifacts().generatedSpirv; }
const Vector<Vector<unsigned>>& GetGeneratedSpirv() const { return Artifacts().generatedSpirv; }
// The linked glslang reflection itself, for the ONE consumer that needs resource
// lists no typed getter above exposes: the GL program-interface query layer
// (MG_Impl/GLImpl/Program/ProgramInterface.cpp), which has to enumerate buffer
// blocks, buffer variables, atomic counters and per-stage reference masks. Null
// until a link has succeeded. Read through the join gate like everything else.
const glslang::TProgram* GetReflection() const { return Artifacts().program.get(); }
Int GetShaderIndexByStage(ShaderStage stage) const {
auto it = std::find_if(m_shaders.begin(), m_shaders.end(), [stage](const SharedPtr<ShaderObject>& shader) {
@@ -501,95 +601,54 @@ namespace MobileGL::MG_State::GLState {
// layout captures into; see NeedsScatteredTransformFeedbackCapture.
Uint32 packedOffsetBytes = 0;
};
void SetTransformFeedbackVaryings(Vector<String>&& names, GLenum bufferMode) {
m_requestedXfbVaryings = Move(names);
m_requestedXfbBufferMode = bufferMode;
}
GLenum GetTransformFeedbackBufferMode() const { return m_xfbBufferMode; }
SizeT GetTransformFeedbackVaryingCount() const { return m_xfbVaryings.size(); }
const XfbVarying* GetTransformFeedbackVarying(SizeT index) const {
return index < m_xfbVaryings.size() ? &m_xfbVaryings[index] : nullptr;
}
const Vector<XfbVarying>& GetTransformFeedbackVaryings() const { return m_xfbVaryings; }
// Stride of one captured vertex in the given capture buffer slot.
Uint32 GetTransformFeedbackStride(Uint32 bufferIndex) const {
return bufferIndex < m_xfbStrides.size() ? m_xfbStrides[bufferIndex] : 0;
}
SizeT GetTransformFeedbackBufferCount() const { return m_xfbStrides.size(); }
Int GetTransformFeedbackVaryingMaxLength() const { return m_xfbVaryingNameMaxLength; }
// True when the capture layout uses gl_SkipComponents / gl_NextBuffer
// (ARB_transform_feedback3), which no ES driver can express: it can only pack every
// captured varying into one record with no gaps. A backend that captures through
// such a driver has to capture into scratch storage and scatter the records into the
// application's buffers itself, using packedOffsetBytes as the source offset and
// (bufferIndex, offsetBytes, stride) as the destination.
Bool NeedsScatteredTransformFeedbackCapture() const { return m_xfbNeedsScatteredCapture; }
// Bytes one gap-free captured record occupies.
Uint32 GetTransformFeedbackPackedStride() const { return m_xfbPackedStride; }
// True when the capture stage is a triangle-strip geometry shader with a
// statically-known emit sequence: the Vulkan capture order then needs the GL
// odd-triangle vertex swap after EndTransformFeedback.
Bool HasGsTriangleStripCaptureFixup() const { return m_gsStripCaptureFixup; }
// Triangles per strip, in emission order, for ONE geometry invocation.
const Vector<Uint32>& GetGsStripTriangles() const { return m_gsStripTriangles; }
// GL_GEOMETRY_INPUT_TYPE of the linked geometry stage (GL_POINTS, GL_LINES,
// GL_LINES_ADJACENCY, GL_TRIANGLES or GL_TRIANGLES_ADJACENCY), or GL_NONE when the
// program has no geometry stage. Draws must present a compatible primitive type.
GLenum GetGeometryInputType() const { return m_gsInputPrimitive; }
Uint GetExternalIndex() const { return m_externalIndex; }
// Globally-unique, never-reused id for this program object's lifetime. Unlike the GL
// name (external index), which is freed to a LIFO list and immediately handed back by
// the next glCreateProgram, this distinguishes a deleted-and-recreated program from the
// original, so an identity cache can't false-hit on name recycling.
Uint64 GetLifetimeId() const { return m_lifetimeId; }
private:
void ResetLinkArtifacts();
void DoReflection();
// Resolves the requested transform feedback varyings against the linked
// vertex stage; fails the link (GL semantics) on unknown or duplicate
// names or exceeded capture limits.
Bool ResolveTransformFeedbackVaryings();
void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate);
void GenerateBinary();
void WaitUntilGenerationCompleted() const;
void AddDefaultFragmentShaderIfMissing();
Bool ValidateFragmentOutputLocations();
static Uint64 AllocateLifetimeId();
const Uint m_externalIndex = 0;
const Uint64 m_lifetimeId = 0;
Vector<SharedPtr<ShaderObject>> m_shaders;
Vector<SharedPtr<ShaderObject>> m_detachedShaders; // Store detached shaders and remove on next link
SharedPtr<glslang::TProgram> m_program;
Vector<Vector<unsigned>> m_generatedSpirv;
// ---- P1: everything a link PRODUCES, in one movable block ----
//
// The membership rule is mechanical, not editorial: this is exactly the field list
// ResetLinkArtifacts() clears (plus the four it forgot to - infoLog,
// linkedFragDataLocation/Index and the geometry strip-capture pair - which are just
// as much link output). Nothing else belongs here.
//
// Why a struct: once glLinkProgram runs on a worker (P1 stage 4) the worker writes
// its OWN LinkArtifacts and the GL thread publishes it with a single move, instead
// of thirty cross-thread field assignments. Until then this is a pure refactor.
//
// Access rule (invariant I5): the member below is private and reachable ONLY
// through ProgramObject::Artifacts(), which calls EnsureLinkJoined() first. That is
// what makes "every read of link output joins the pending link" a property the
// compiler checks rather than a review item - a new reader cannot spell the field
// without going through the gate.
struct LinkArtifacts {
SharedPtr<glslang::TProgram> program;
Vector<Vector<unsigned>> generatedSpirv;
// Attributes (Vertex in)
UnorderedMap<String, Uint> m_explicitAttribLocations;
Vector<String> m_attribs;
Vector<GLenum> m_attribTypes;
Vector<String> attribs;
Vector<GLenum> attribTypes;
// FragData (Frag out)
UnorderedMap<String, Uint> m_explicitFragDataLocation;
UnorderedMap<String, Uint> m_linkedFragDataLocation;
// Dual-source blend color index per output name (glBindFragDataLocationIndexed); snapshotted
// into the linked map at link time, like the location maps above.
UnorderedMap<String, Uint> m_explicitFragDataIndex;
UnorderedMap<String, Uint> m_linkedFragDataIndex;
Int m_maxFragmentOutputColorNumber = 8;
// FragData (Frag out): the per-link snapshot of the explicit request maps.
UnorderedMap<String, Uint> linkedFragDataLocation;
UnorderedMap<String, Uint> linkedFragDataIndex;
// Uniforms
UnorderedMap<String, Uint> m_uniformLocations;
// GL-facing index spaces (see the translation helpers above): GL active-uniform
// index <-> glslang TProgram uniform index, GL uniform-block index <-> TProgram
// block index. -1 marks a TProgram entry GL does not expose (dead default-block
// uniforms swept into MGL_GLOBAL_UBO by the relaxed parse, and that block itself).
Vector<Int> glUniformIndexToTProgram;
Vector<Int> tProgramUniformIndexToGl;
Vector<Int> glBlockIndexToTProgram;
Vector<Int> tProgramBlockIndexToGl;
// Per-link merged snapshot of the attached shaders' lexically extracted
// layout(location = N) default-block uniform qualifiers (the relaxed parse drops
// them from reflection; the DoReflection assigner restores them from here).
UnorderedMap<String, Int> linkedExplicitUniformLocations;
UnorderedMap<String, Uint> uniformLocations;
// Ordered by location,
// aka. m_uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`"
Vector<Int> m_uniformIndexInTProgram;
// aka. uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`"
Vector<Int> uniformIndexInTProgram;
// ditto. Will be set at glUniform1i
Vector<Int> m_uniformSamplerOrImageUnitIndex;
UnorderedMap<String, Uint> m_explicitOpaqueUniformBindings;
Vector<Int> uniformSamplerOrImageUnitIndex;
UnorderedMap<String, Uint> explicitOpaqueUniformBindings;
// Ordered by uniform block index
// index is DIFFERENT from binding!!!
@@ -599,27 +658,222 @@ namespace MobileGL::MG_State::GLState {
// `prog->getUniformBlock(i) == "BlockName"`
// These stuff are present for GL semantics, not for backend inspection
// These may change after-link (because GL spec decided to have `glUniformBlockBinding`)
UnorderedMap<String, Uint> m_uniformBlockIndexByName;
Vector<Int> m_uniformBlockBinding;
UnorderedMap<String, Uint> uniformBlockIndexByName;
Vector<Int> uniformBlockBinding;
// glShaderStorageBlockBinding overrides, keyed by GL block name. See
// SetShaderStorageBlockBinding for why this one is by name and not by index.
UnorderedMap<String, Int> shaderStorageBlockBinding;
// Need to be reflected after linking of SPIR-V binary
Vector<Uint> m_uniformOffsets;
Vector<Uint> m_uniformSizesInBytes;
Vector<Uint8> m_globalUboScratch;
Vector<Uint> uniformOffsets;
Vector<Uint> uniformSizesInBytes;
Vector<Uint8> globalUboScratch;
Uint m_activeUniformCount = 0;
Uint m_maxUniformLocation = 0;
Int m_uniformNameMaxLength = 0;
Int m_attribInNameMaxLength = 0;
Int m_uniformBlockNameMaxLength = 0;
Uint activeUniformCount = 0;
Uint maxUniformLocation = 0;
Int uniformNameMaxLength = 0;
Int attribInNameMaxLength = 0;
Int uniformBlockNameMaxLength = 0;
String infoLog;
Bool linkStatus = false;
// Transform feedback: the linked snapshot (the request lives outside, on the
// GL-thread-owned side).
Vector<XfbVarying> xfbVaryings;
// The glTransformFeedbackVaryings request list exactly as this link consumed it,
// INCLUDING the gl_NextBuffer / gl_SkipComponentsN pseudo-varyings that
// xfbVaryings deliberately drops (they steer the capture layout and must never
// reach a backend's varying list). GL_TRANSFORM_FEEDBACK_VARYING enumerates the
// full request, pseudo-varyings and all, so the interface query needs its own copy.
Vector<String> xfbInterfaceNames;
Vector<Uint32> xfbStrides;
Vector<Uint32> gsStripTriangles;
Bool gsStripCaptureFixup = false;
GLenum gsInputPrimitive = GL_NONE;
GLenum xfbBufferMode = GL_INTERLEAVED_ATTRIBS;
Int xfbVaryingNameMaxLength = 0;
Bool xfbNeedsScatteredCapture = false;
Uint32 xfbPackedStride = 0;
};
// ---- artifacts-only helpers, shared with ProgramLinkTask ----
// Static and taking the block explicitly, because from stage 4 the link BODY needs
// them while its artifacts still live on the job node, not on any ProgramObject. The
// member overloads above are the same functions read through the join gate.
// Clears every field one link produces, EXCEPT infoLog, linkedFragDataLocation/Index
// and the geometry strip-capture pair. That exception is load-bearing: the callers
// that survive (glProgramBinary's mandated failure, and the link body's own mid-link
// aborts) write infoLog immediately AFTER calling here. Link()'s prologue does not
// use this at all - it assigns a whole default-constructed LinkArtifacts, where the
// ordering is explicit and nothing is exempt.
static void ResetLinkArtifacts(LinkArtifacts& artifacts);
static Bool IsValidUniformLocation(const LinkArtifacts& artifacts, Int location) {
if (location < 0 || location > static_cast<Int>(artifacts.maxUniformLocation)) return false;
if (static_cast<SizeT>(location) >= artifacts.uniformIndexInTProgram.size()) return false;
const Int uniformIndexInProgram = artifacts.uniformIndexInTProgram[location];
return uniformIndexInProgram != glslang::TQualifier::layoutLocationEnd &&
uniformIndexInProgram >= 0 &&
uniformIndexInProgram < static_cast<Int>(artifacts.tProgramUniformIndexToGl.size());
}
// Number of active array elements (GL_UNIFORM_SIZE / GL_ARRAY_SIZE); 1 for a non-array.
// glslang's TObjectReflection.size only carries the element count for a NON-block array; for
// a block array member it reports 1, so take the count from the TType, which is authoritative
// for both. GL 3.3 core uniforms are always sized. Takes a TProgram uniform index (the space
// the artifacts' uniformIndexInTProgram stores).
static GLint GetUniformArraySizeByTIndex(const LinkArtifacts& artifacts, Int tIndex) {
const auto& uniform = artifacts.program->getUniform(tIndex);
const glslang::TType* type = uniform.getType();
if (type != nullptr && type->isSizedArray()) {
return type->getOuterArraySize();
}
return uniform.size < 1 ? 1 : uniform.size;
}
// Blocks until a pending link has published its artifacts. Public because a few call
// sites have to join without reading anything - see the explicit-join list (J1-J8) in
// the P1 design. GL thread only.
void JoinLink() const { EnsureLinkJoined(); }
// Drops a link that is still in flight, without waiting for it. Called at the points
// where the pending link's result stops being the answer to "what did this program
// link to": a re-link supersedes it, glProgramBinary must force LINK_STATUS false,
// and a destroyed program has no observers left.
//
// Deliberately NOT called by the "takes effect at the next link" setters
// (glBindAttribLocation, glBindFragDataLocation(Indexed), glTransformFeedbackVaryings,
// glProgramParameteri) NOR by glAttachShader/glDetachShader. Every one of those is
// defined by GL to leave the CURRENT link result alone, and the pending link already
// snapshotted its own inputs at enqueue, so it is computing exactly the answer GL
// requires. Cancelling on any of them would make
// glLinkProgram(p); <setter>; glGetProgramiv(p, GL_LINK_STATUS)
// report FALSE for a link that succeeded - and for the attach/detach pair it would
// additionally break glCreateShaderProgramv, which detaches immediately after linking.
void CancelLink();
// MUST NOT JOIN - this is what GL_COMPLETION_STATUS_KHR reads when the extension
// surface lands. "No job at all" counts as complete: there is nothing outstanding to
// wait for.
Bool IsLinkComplete() const { return m_pendingLink == nullptr || IsPendingLinkTerminal(); }
void SetTransformFeedbackVaryings(Vector<String>&& names, GLenum bufferMode) {
m_requestedXfbVaryings = Move(names);
m_requestedXfbBufferMode = bufferMode;
}
GLenum GetTransformFeedbackBufferMode() const { return Artifacts().xfbBufferMode; }
SizeT GetTransformFeedbackVaryingCount() const { return Artifacts().xfbVaryings.size(); }
const XfbVarying* GetTransformFeedbackVarying(SizeT index) const {
return index < Artifacts().xfbVaryings.size() ? &Artifacts().xfbVaryings[index] : nullptr;
}
const Vector<XfbVarying>& GetTransformFeedbackVaryings() const { return Artifacts().xfbVaryings; }
// The GL_TRANSFORM_FEEDBACK_VARYING resource list: every name the last successful
// link was asked to capture, in request order, pseudo-varyings included.
const Vector<String>& GetTransformFeedbackInterfaceNames() const { return Artifacts().xfbInterfaceNames; }
// Stride of one captured vertex in the given capture buffer slot.
Uint32 GetTransformFeedbackStride(Uint32 bufferIndex) const {
return bufferIndex < Artifacts().xfbStrides.size() ? Artifacts().xfbStrides[bufferIndex] : 0;
}
SizeT GetTransformFeedbackBufferCount() const { return Artifacts().xfbStrides.size(); }
Int GetTransformFeedbackVaryingMaxLength() const { return Artifacts().xfbVaryingNameMaxLength; }
// True when the capture layout uses gl_SkipComponents / gl_NextBuffer
// (ARB_transform_feedback3), which no ES driver can express: it can only pack every
// captured varying into one record with no gaps. A backend that captures through
// such a driver has to capture into scratch storage and scatter the records into the
// application's buffers itself, using packedOffsetBytes as the source offset and
// (bufferIndex, offsetBytes, stride) as the destination.
Bool NeedsScatteredTransformFeedbackCapture() const { return Artifacts().xfbNeedsScatteredCapture; }
// Bytes one gap-free captured record occupies.
Uint32 GetTransformFeedbackPackedStride() const { return Artifacts().xfbPackedStride; }
// True when the capture stage is a triangle-strip geometry shader with a
// statically-known emit sequence: the Vulkan capture order then needs the GL
// odd-triangle vertex swap after EndTransformFeedback.
Bool HasGsTriangleStripCaptureFixup() const { return Artifacts().gsStripCaptureFixup; }
// Triangles per strip, in emission order, for ONE geometry invocation.
const Vector<Uint32>& GetGsStripTriangles() const { return Artifacts().gsStripTriangles; }
// GL_GEOMETRY_INPUT_TYPE of the linked geometry stage (GL_POINTS, GL_LINES,
// GL_LINES_ADJACENCY, GL_TRIANGLES or GL_TRIANGLES_ADJACENCY), or GL_NONE when the
// program has no geometry stage. Draws must present a compatible primitive type.
GLenum GetGeometryInputType() const { return Artifacts().gsInputPrimitive; }
Uint GetExternalIndex() const { return m_externalIndex; }
// Globally-unique, never-reused id for this program object's lifetime. Unlike the GL
// name (external index), which is freed to a LIFO list and immediately handed back by
// the next glCreateProgram, this distinguishes a deleted-and-recreated program from the
// original, so an identity cache can't false-hit on name recycling.
Uint64 GetLifetimeId() const { return m_lifetimeId; }
private:
// ---- The one and only join gate for link output (P1 invariant I5) ----
// Blocks until a pending link has finished and its LinkArtifacts have been
// published into m_artifacts. It exists so that the ~120 readers of link output are
// routed through it by the compiler rather than by review: m_artifacts is private
// and Artifacts() is the only spelling that reaches it.
//
// The fast path - no pending link - is one predictable branch and stays inline: it
// runs on every Artifacts() read (~1200 call sites project-wide) and the project
// never builds with LTO (MOBILEGL_ENABLE_LTO=OFF), so an out-of-line body would be a
// real cross-TU call at every one of them. The blocking half is out of line.
void EnsureLinkJoined() const {
if (m_pendingLink) JoinPendingLink();
}
void JoinPendingLink() const;
// ProgramLinkTask is incomplete here, so IsLinkComplete()'s non-joining peek at the
// node's state goes through this out-of-line helper.
Bool IsPendingLinkTerminal() const;
LinkArtifacts& Artifacts() {
EnsureLinkJoined();
return m_artifacts;
}
const LinkArtifacts& Artifacts() const {
EnsureLinkJoined();
return m_artifacts;
}
// GL-thread-only companion to ResetLinkArtifacts (see its definition). Const because
// the publish half of the join calls it; see the mutable counters below.
void BumpLinkObservableVersions() const;
void AddDefaultFragmentShaderIfMissing();
static Uint64 AllocateLifetimeId();
// ---- GL-thread-owned state: never joins ----
// Most of this is never produced by a link at all. The three version counters
// (m_backendStateVersion / m_uboContentVersion / m_linkVersion) ARE
// link-observable, but they are bumped exclusively on the GL thread
// (BumpLinkObservableVersions in Link()'s prologue and glProgramBinary's
// failure path) - the link BODY, which stage 4 moves to a worker, never
// writes them.
const Uint m_externalIndex = 0;
const Uint64 m_lifetimeId = 0;
// The attach lists are mutated only in Link()'s GL-thread prologue, which is why
// glGetAttachedShaders / GL_ATTACHED_SHADERS / the orphan-shader sweep need no join.
Vector<SharedPtr<ShaderObject>> m_shaders;
Vector<SharedPtr<ShaderObject>> m_detachedShaders; // Store detached shaders and remove on next link
// Link INPUTS (all "take effect at the next link" per GL): glBindAttribLocation,
// glBindFragDataLocation(Indexed), glTransformFeedbackVaryings, and the draw-buffer
// count stamped in by the entry point. A pending link snapshots these at enqueue.
UnorderedMap<String, Uint> m_explicitAttribLocations;
UnorderedMap<String, Uint> m_explicitFragDataLocation;
// Dual-source blend color index per output name (glBindFragDataLocationIndexed); snapshotted
// into the linked map at link time, like the location maps above.
UnorderedMap<String, Uint> m_explicitFragDataIndex;
Int m_maxFragmentOutputColorNumber = 8;
Vector<String> m_requestedXfbVaryings;
GLenum m_requestedXfbBufferMode = GL_INTERLEAVED_ATTRIBS;
String m_infoLog;
Bool m_deleteStatus = false;
Bool m_linkStatus = false;
Bool m_binaryRetrievableHint = false;
Bool m_separable = false;
Bool m_validateStatus = true;
Uint32 m_backendStateVersion = 0;
// Mutable, like m_artifacts and for the same reason: publishing a pending link is a
// READ-side operation (the first gated getter is what pulls the result in), and the
// publish has to bump these. Still GL-thread-only - a worker never touches them.
mutable Uint32 m_backendStateVersion = 0;
// Backend-owned content-hash memo (see GetBackendHashMemo): valid only while
// m_backendStateVersion matches. Several slots, not one: a backend may resolve the same
@@ -635,20 +889,20 @@ namespace MobileGL::MG_State::GLState {
mutable Array<BackendHashMemoSlot, kBackendHashMemoSlotCount> m_backendHashMemoSlots{};
mutable SizeT m_backendHashMemoNextSlot = 0;
mutable Uint32 m_backendHashMemoVersion = ~0u;
Uint32 m_uboContentVersion = 0;
Uint32 m_linkVersion = 0;
mutable Uint32 m_uboContentVersion = 0;
mutable Uint32 m_linkVersion = 0;
// Transform feedback: request (applies at next link) and linked snapshot.
Vector<String> m_requestedXfbVaryings;
GLenum m_requestedXfbBufferMode = GL_INTERLEAVED_ATTRIBS;
Vector<XfbVarying> m_xfbVaryings;
Vector<Uint32> m_xfbStrides;
Vector<Uint32> m_gsStripTriangles;
Bool m_gsStripCaptureFixup = false;
GLenum m_gsInputPrimitive = GL_NONE;
GLenum m_xfbBufferMode = GL_INTERLEAVED_ATTRIBS;
Int m_xfbVaryingNameMaxLength = 0;
Bool m_xfbNeedsScatteredCapture = false;
Uint32 m_xfbPackedStride = 0;
// ---- Link OUTPUT ----
// Written by the link and by the post-link setters GL allows (glUniform1i's sampler
// unit, glUniformBlockBinding). Reachable only through Artifacts(); see LinkArtifacts.
//
// Mutable because publishing is a READ-side operation: a const getter has to be able
// to settle an outstanding link before answering it.
mutable LinkArtifacts m_artifacts;
// The link job, from enqueue until the first observable read pulls its result. Null
// means m_artifacts is already the answer - which is the state every reader outside
// the pending window sees, and the whole reason the gate above is one branch.
mutable SharedPtr<ProgramLinkTask> m_pendingLink;
};
} // namespace MobileGL::MG_State::GLState
@@ -11,7 +11,7 @@
namespace MobileGL::MG_State::GLState {
Uint ProgramState::CreateProgram() {
Uint programId = 0;
m_programIndexGenerator.Generate(1, &programId);
m_programShaderNameGenerator.Generate(1, &programId);
EnsureIndexAvail(programId, m_programObjects);
auto programObject = MakeShared<ProgramObject>(programId);
if (programObject == nullptr) return 0;
@@ -39,11 +39,19 @@ namespace MobileGL::MG_State::GLState {
void ProgramState::DestroyProgramSlot(const Uint program) {
auto& programObject = m_programObjects[program];
// P1 join site J4/J5 (glDeleteProgram, and the deferred destroy UseProgram performs
// when a deletion-flagged program stops being current). The program's name is about
// to go, so nothing can observe its link any more: cancel-not-join, so a delete never
// blocks the GL thread on a worker. Explicit rather than left to ~ProgramObject,
// because the reset below only destroys the object if this table held the last
// reference - a program still bound as current, or still referenced by a pipeline,
// outlives it, and its link should stop the moment the name does.
programObject->CancelLink();
// Snapshot the attachments: deleting the program is a detach point for shaders
// that were flagged with glDeleteShader while still attached.
const Vector<SharedPtr<ShaderObject>> attachedShaders = programObject->GetAttachedShaders();
programObject.reset();
m_programIndexGenerator.Delete(program);
m_programShaderNameGenerator.Delete(program);
for (const auto& shader : attachedShaders) {
const Uint shaderName = shader->GetExternalIndex();
if (CheckIndexAvail(shaderName, m_shaderObjects) && m_shaderObjects[shaderName] == shader) {
@@ -77,9 +85,10 @@ namespace MobileGL::MG_State::GLState {
Uint ProgramState::CreateShader(ShaderStage stage) {
Uint shaderId = 0;
m_shaderIndexGenerator.Generate(1, &shaderId);
m_programShaderNameGenerator.Generate(1, &shaderId);
EnsureIndexAvail(shaderId, m_shaderObjects);
auto shaderObject = MakeShared<ShaderObject>(stage, shaderId);
auto shaderObject =
MakeShared<ShaderObject>(stage, shaderId, m_shaderPreprocessCache, m_shaderCompileAdoptionMap);
if (shaderObject == nullptr) return 0;
m_shaderObjects[shaderId] = shaderObject;
return shaderId;
@@ -91,6 +100,31 @@ namespace MobileGL::MG_State::GLState {
return m_shaderObjects[shader];
}
void ProgramState::JoinAllPendingWork() {
// Programs first: a link joins the compiles it depends on, so the shader pass that
// follows finds most of them already settled. The reverse order would be correct but
// would wait on each compile twice - once here, once inside the link's own prologue.
//
// A copy of each slot rather than a reference into the vector, and an index rather
// than an iterator: publishing a link replays deferred diagnostics, which reach
// pGLContext->RecordError. That does not touch these tables today, but it is a sink
// that can grow, and a reallocation underneath this loop would be a use-after-free
// that only shows up on the one GL call that walks the whole table. The copy costs a
// refcount bump on a path a mode switch takes at most once.
for (SizeT i = 0; i < m_programObjects.size(); ++i) {
const SharedPtr<ProgramObject> program = m_programObjects[i];
if (program) program->JoinLink();
}
for (SizeT i = 0; i < m_shaderObjects.size(); ++i) {
const SharedPtr<ShaderObject> shader = m_shaderObjects[i];
if (shader) shader->JoinCompile();
}
// The currently-used program is reachable through m_programObjects unless
// glDeleteProgram already freed its slot while it stayed current. Nothing else holds
// a GL-visible name for it, but a draw would still join it, so settle it here too.
if (m_currentProgram) m_currentProgram->JoinLink();
}
void ProgramState::MarkShaderObjectForDeletion(Uint shader) {
if (!CheckIndexAvail(shader, m_shaderObjects)) return;
auto& shaderObject = m_shaderObjects[shader];
@@ -120,8 +154,15 @@ namespace MobileGL::MG_State::GLState {
auto& shaderObject = m_shaderObjects[shader];
if (shaderObject == nullptr || !shaderObject->GetDeleteStatus()) return;
if (ShaderHasGLVisibleAttachment(shaderObject)) return;
// The name is about to go, so nothing can observe this shader's compile through THIS
// object any more and a job still in flight for it is pure waste - unless another
// shader object adopted the same node (stage 6) or a pending link pinned it, which is
// exactly what ReleaseCompileNode weighs before it cancels anything. Cancel-not-join
// either way: the job owns its inputs, so dropping the object out from under it is
// safe and the GL thread never blocks on a delete.
shaderObject->ReleaseCompileNode();
shaderObject.reset();
m_shaderIndexGenerator.Delete(shader);
m_programShaderNameGenerator.Delete(shader);
}
Bool ProgramState::ValidateShaderObject(Uint shader) const {
@@ -10,6 +10,8 @@
#include <Includes.h>
#include <MG_Util/Miscellany/IndexGenerator.h>
#include "ProgramObject.h"
#include "ShaderCompileAdoptionMap.h"
#include "ShaderPreprocessCache.h"
namespace MobileGL::MG_State::GLState {
class ProgramState {
@@ -33,6 +35,29 @@ namespace MobileGL::MG_State::GLState {
const SharedPtr<ProgramObject>& GetCurrentProgram() const { return m_currentProgram; }
// Joins every outstanding compile and link this context still owns, publishing each
// one's artifacts through the ordinary gates. The single caller is
// glMaxShaderCompilerThreadsKHR(0): GL_KHR_parallel_shader_compile requires a zero
// count to leave nothing in flight, so that every subsequent
// GL_COMPLETION_STATUS_KHR reads GL_TRUE.
//
// NOT a teardown path and NOT ShaderCompilePool::StopAndDrain(): the pool keeps its
// threads and stays usable, because a later nonzero count has to bring asynchronous
// compilation straight back. Nodes belonging to objects this context has already
// dropped are not joined - nothing can observe them, and waiting on them would make
// a GL call's cost depend on garbage.
void JoinAllPendingWork();
// P0b layer 2. Exposed for tests and diagnostics; the GL frontend never touches it
// directly - shader objects reach it through the pointer they are handed at
// CreateShader().
ShaderPreprocessCache& GetShaderPreprocessCache() { return *m_shaderPreprocessCache; }
// P1 stage 6, same deal: exposed for tests and diagnostics only. Its adoption counter
// is the one number that says how many glCompileShader calls this context turned into
// no work at all; nothing in the GL frontend branches on it.
ShaderCompileAdoptionMap& GetShaderCompileAdoptionMap() { return *m_shaderCompileAdoptionMap; }
private:
Bool ShaderHasGLVisibleAttachment(const SharedPtr<ShaderObject>& shaderObject) const;
// Frees the name slot and releases orphaned attached shaders; the immediate half
@@ -52,10 +77,24 @@ namespace MobileGL::MG_State::GLState {
vec.resize(idx + 1);
}
IndexGenerator<Uint> m_programIndexGenerator;
Vector<SharedPtr<ProgramObject>> m_programObjects;
// Programs and shaders share one GL name space (GL 3.3 core 2.11: a shader
// name passed where a program is expected must be recognized as a shader and
// rejected with INVALID_OPERATION, and vice versa). One generator for both
// object kinds keeps the names disjoint; the object tables stay separate.
IndexGenerator<Uint> m_programShaderNameGenerator;
IndexGenerator<Uint> m_shaderIndexGenerator;
// P0b layer 2: every shader object created here is handed shared ownership of this
// cache, so its lifetime no longer depends on member destruction order (P1: an
// in-flight compile job may outlive the context). The FIRST-member declaration is
// kept anyway - it costs nothing and documents the intent.
SharedPtr<ShaderPreprocessCache> m_shaderPreprocessCache = MakeShared<ShaderPreprocessCache>();
// P1 stage 6: the GL-thread-only index of adoptable compile nodes. Shared ownership
// for the same reason as the cache above - a ShaderObject held by a ProgramObject can
// outlive these tables, and its destructor releases a node - though unlike the cache
// no worker ever sees this one, which is why it carries no lock.
SharedPtr<ShaderCompileAdoptionMap> m_shaderCompileAdoptionMap = MakeShared<ShaderCompileAdoptionMap>();
Vector<SharedPtr<ProgramObject>> m_programObjects;
Vector<SharedPtr<ShaderObject>> m_shaderObjects;
SharedPtr<ProgramObject> m_currentProgram;
@@ -0,0 +1,90 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.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 "ShaderCompileAdoptionMap.h"
#include "ShaderCompileTask.h"
namespace MobileGL::MG_State::GLState {
SharedPtr<ShaderCompileTask> ShaderCompileAdoptionMap::FindAdoptable(const ShaderStage stage,
const Uint64 sourceHash, const String& source,
const Uint64 envFingerprint) {
const ShaderSourceKey key{.stage = stage,
.sourceHash = sourceHash,
.sourceLength = source.length(),
.envFingerprint = envFingerprint};
const auto it = m_entries.find(key);
if (it == m_entries.end()) return nullptr;
SharedPtr<ShaderCompileTask> node = it->second.lock();
// Expired (every shader object that held it has released it), settled as Cancelled
// (the enqueue lost a race with teardown, or the body threw), or CANCELLATION
// REQUESTED but not yet settled (a releaser fired Cancel() while a worker was still
// inside RunBody(), so the node is stuck at Running until the body returns - see
// JobNode::Run: once m_cancelled is set, the node is DOOMED to end up Cancelled no
// matter how the body finishes, it just has not gotten there yet). All three can
// never publish artifacts a caller may rely on, so all three are misses. Only the
// first two are dead weight worth pruning from the index here - a cancellation-
// requested-but-still-running node is still reachable from its own (about to
// release) ShaderObject and will get pruned once it actually settles, so leave the
// entry alone and just refuse to hand this node out.
if (!node || node->IsCancelled()) {
m_entries.erase(it);
return nullptr;
}
if (node->IsCancellationRequested()) return nullptr;
// Never let correctness ride on a 64-bit hash. Lengths already matched (they are part
// of the key), so this is a plain memcmp - and it is the ONLY thing that authorizes
// two GL shader names to share one compile.
if (*node->source != source) return nullptr;
++m_adoptionCount;
return node;
}
void ShaderCompileAdoptionMap::Register(const SharedPtr<ShaderCompileTask>& node) {
if (!node) return;
SweepIfCrowded();
// operator[] rather than a find/insert pair: an existing entry for this key is either
// a re-registration of the same source (the previous node expired or was cancelled)
// or an astronomically rare hash collision. The newcomer wins in both cases.
m_entries[ShaderSourceKey{.stage = node->stage,
.sourceHash = node->sourceHash,
.sourceLength = node->source->length(),
.envFingerprint = node->env->fingerprint}] = node;
}
void ShaderCompileAdoptionMap::Clear() {
m_entries.clear();
m_sweepThreshold = kMinSweepThreshold;
}
void ShaderCompileAdoptionMap::SweepIfCrowded() {
if (m_entries.size() < m_sweepThreshold) return;
// Collect first, erase after: FastSTL::unordered_map is open-addressed, so erasing
// through an iterator that the same loop is still advancing is not worth reasoning
// about on a path this cold.
Vector<ShaderSourceKey> dead;
for (const auto& entry : m_entries) {
const SharedPtr<ShaderCompileTask> node = entry.second.lock();
if (!node || node->IsCancelled()) dead.push_back(entry.first);
}
for (const ShaderSourceKey& key : dead) {
m_entries.erase(key);
}
// Amortization: after a sweep the map holds exactly the nodes still reachable from
// some shader object, so letting it double before the next sweep makes the whole
// scheme O(1) per Register() while keeping the map O(live nodes).
m_sweepThreshold = std::max(kMinSweepThreshold, m_entries.size() * 2);
}
} // namespace MobileGL::MG_State::GLState
@@ -0,0 +1,97 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include <MG_State/GLState/ProgramState/ShaderSourceKey.h>
namespace MobileGL::MG_State::GLState {
class ShaderCompileTask;
// P1 stage 6: the per-context index of compile job nodes that a NEW shader object may
// adopt instead of enqueueing a duplicate of.
//
// Why it is not the P0b preprocess cache. That cache only helps once a compile has
// FINISHED - it memoizes the source-only half of the pipeline, and a worker consults it
// from inside the job body. Under asynchronous compilation the dominant shape is
// different: a shaderpack load hands N different shader objects byte-identical source
// within the same GL-thread burst (measured across bsl/complementary/bliss, ~21% of all
// Compile() calls are such cross-object duplicates), and all N are enqueued before any of
// them completes. Every one of those workers then misses the cache, runs the whole
// pipeline, and races the others to insert the same entry. This map closes that window on
// the GL thread, at enqueue: the second object through takes the FIRST object's node.
//
// What "adopt" means: the two shader objects end up holding the same SharedPtr in their
// m_compiled. They are two distinct GL names with two distinct info-log/COMPILE_STATUS
// queries, but both queries read one set of artifacts - which is exactly right, because
// the pipeline is a pure function of the key below and the full source text. Nothing is
// copied and no worker ever waits (P1 invariant I4 is untouched: this only ever REMOVES
// work from the pool). The single consume-once resource, the glslang parse, is already
// guarded for sharing by ShaderCompileTask::ClaimParsedShader's CAS, which stage 4 built
// for exactly this shape - one node, several links.
//
// ---- Threading: GL thread only, and therefore lock-free ----
// Every entry point below is reached from glCompileShader (ShaderObject::Compile) and
// from nowhere else. That is one GL entry point on the application's context thread, so
// the map needs no mutex, unlike the preprocess cache which several workers hit at once.
// The weak pointers are the ONLY thing this class stores, precisely so it can never keep
// a node - or the artifacts a node owns - alive past its last real holder.
//
// ---- Lifetime and pruning ----
// WeakPtr, never SharedPtr: the map is an index, not an owner. An entry whose node has
// been released by every shader object simply expires, and a node that was CANCELLED
// carries no result at all, so both are treated as misses and pruned where they are
// found. Pruning is otherwise amortized: Register() sweeps the whole map whenever it has
// grown past twice its size at the last sweep, which bounds the map at O(live nodes)
// without a per-call cost.
class ShaderCompileAdoptionMap {
public:
// Never sweep below this: a shaderpack burst is a few hundred distinct sources, and
// an entry is a key plus a weak pointer.
static constexpr SizeT kMinSweepThreshold = 256;
// The adoptable node for this exact source under this exact environment, or null.
//
// A hit is honored only after the FULL source text has been compared byte for byte
// against the candidate node's own snapshot: the hash in the key is a lookup
// accelerator, never the answer (ShaderSourceKey). A node that has settled as
// Cancelled is never handed out - it published nothing, so adopting it would give the
// new object a compile that can never report anything but GL_FALSE. Nor is a node
// whose cancellation has merely been REQUESTED but not yet settled (still Running,
// with IsCancellationRequested() true): JobNode::Run forces such a node to Cancelled
// the moment its body returns regardless of how the body finished, so it is already
// doomed and handing it out would just move the same GL_FALSE-with-no-log outcome to
// a second, unrelated shader object.
//
// A COMPLETED node is adoptable, and deliberately so: the new object gets the right
// answer for zero work, which is the same deal the P0b cache offers one layer down.
SharedPtr<ShaderCompileTask> FindAdoptable(ShaderStage stage, Uint64 sourceHash, const String& source,
Uint64 envFingerprint);
// Indexes `node` as the adoptable one for its key. A key already present is
// overwritten: the newcomer is at least as fresh as whatever was there, and one entry
// per key keeps this a plain map.
void Register(const SharedPtr<ShaderCompileTask>& node);
void Clear();
// ---- diagnostics only; nothing in the GL frontend branches on these ----
// Monotonic count of nodes handed out by FindAdoptable, i.e. of glCompileShader calls
// that did NOT enqueue a job because an equivalent one already existed. Tests read it
// as a delta across a burst.
Uint64 GetAdoptionCount() const { return m_adoptionCount; }
SizeT GetEntryCount() const { return m_entries.size(); }
private:
void SweepIfCrowded();
UnorderedMap<ShaderSourceKey, WeakPtr<ShaderCompileTask>, ShaderSourceKeyHasher> m_entries;
SizeT m_sweepThreshold = kMinSweepThreshold;
Uint64 m_adoptionCount = 0;
};
} // namespace MobileGL::MG_State::GLState
@@ -0,0 +1,338 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.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 "ShaderCompileTask.h"
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <glslang/Include/PoolAlloc.h>
#include <charconv>
namespace {
struct ComputeLocalSize {
MobileGL::Uint x = 1;
MobileGL::Uint y = 1;
MobileGL::Uint z = 1;
bool declared = false;
};
static MobileGL::String StripGlslComments(const MobileGL::String& source) {
MobileGL::String result;
result.reserve(source.length());
bool inLineComment = false;
bool inBlockComment = false;
for (MobileGL::SizeT i = 0; i < source.length(); ++i) {
if (inLineComment) {
if (source[i] == '\n') {
inLineComment = false;
result.push_back(source[i]);
} else {
result.push_back(' ');
}
continue;
}
if (inBlockComment) {
if (source[i] == '*' && i + 1 < source.length() && source[i + 1] == '/') {
inBlockComment = false;
result.append(" ");
++i;
} else {
result.push_back(source[i] == '\n' ? '\n' : ' ');
}
continue;
}
if (source[i] == '/' && i + 1 < source.length()) {
if (source[i + 1] == '/') {
inLineComment = true;
result.append(" ");
++i;
continue;
}
if (source[i + 1] == '*') {
inBlockComment = true;
result.append(" ");
++i;
continue;
}
}
result.push_back(source[i]);
}
return result;
}
// Hoisted out of ParseComputeLocalSize: constructing a std::regex costs far more than
// running it over a small source, and it was being rebuilt on every compute compile. A
// const regex carries no mutable state, so sharing one instance across workers is safe.
static const std::regex kComputeLocalSizePattern(R"(local_size_([xyz])\s*=\s*([0-9]+))");
static ComputeLocalSize ParseComputeLocalSize(const MobileGL::String& source) {
ComputeLocalSize localSize;
const MobileGL::String uncommentedSource = StripGlslComments(source);
for (std::sregex_iterator it(uncommentedSource.begin(), uncommentedSource.end(), kComputeLocalSizePattern),
end;
it != end; ++it) {
const char axis = (*it)[1].str()[0];
// The [0-9]+ capture is unbounded, so `local_size_x = 99999999999999999999999`
// is a legal match. std::stoull would throw std::out_of_range on it and let the
// exception escape glCompileShader; std::from_chars reports the overflow instead.
// An overflowing literal saturates to UINT_MAX, which the device-limit check
// below rejects anyway - the same verdict a non-overflowing huge value gets.
const MobileGL::String digits = (*it)[2].str();
unsigned long long value = 0;
const std::from_chars_result parsed =
std::from_chars(digits.data(), digits.data() + digits.size(), value);
const MobileGL::Uint clampedValue = (parsed.ec != std::errc() || value > UINT_MAX)
? UINT_MAX
: static_cast<MobileGL::Uint>(value);
// TODO: Replace this literal layout scanner with parser/AST-backed validation so expressions and
// specialization-id layouts are handled consistently with glslang.
localSize.declared = true;
if (axis == 'x') {
localSize.x = clampedValue;
} else if (axis == 'y') {
localSize.y = clampedValue;
} else {
localSize.z = clampedValue;
}
}
return localSize;
}
// The device limits come from the CompileEnv snapshot, never from a live driver query.
// GL_MAX_COMPUTE_WORK_GROUP_SIZE is a real GLES call on the DirectGLES backend: issued
// off the context thread it would silently no-op and turn a legal local_size_z into
// COMPILE_STATUS=FALSE. CaptureCompileEnv() issues it once, on the GL thread.
static std::optional<MobileGL::String> ValidateComputeLocalSizeLimits(
const MobileGL::String& source, const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) {
const ComputeLocalSize localSize = ParseComputeLocalSize(source);
if (!localSize.declared) return std::nullopt;
if (localSize.x > env.maxComputeWorkGroupSize[0] || localSize.y > env.maxComputeWorkGroupSize[1] ||
localSize.z > env.maxComputeWorkGroupSize[2]) {
return "Compute shader local_size exceeds GL_MAX_COMPUTE_WORK_GROUP_SIZE.";
}
const unsigned long long invocations = static_cast<unsigned long long>(localSize.x) * localSize.y * localSize.z;
if (invocations > env.maxComputeWorkGroupInvocations) {
return "Compute shader local_size product exceeds GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS.";
}
return std::nullopt;
}
// The half of a compile that depends on nothing but the source text, the stage and the
// environment snapshot: preprocessing, the two lexical rejections, and the two lexical
// side-channel extractions. Split out so P0b layer 2 can memoize exactly this and
// nothing else - the glslang parse stays per-object because its TShader is consume-once.
// Deliberately free of any per-object state so the memo is sound.
//
// The compute local-size verdict reads `env` rather than the live backend, and
// env.fingerprint is part of the P0b cache key, so a memo can never be returned against
// limits other than the ones it was computed against.
static MobileGL::MG_State::GLState::ShaderPreprocessResult RunSourceOnlyPipeline(
const MobileGL::ShaderStage stage, const MobileGL::String& source,
const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) {
using namespace MobileGL;
using namespace MobileGL::MG_Util::ShaderTranspiler;
using MobileGL::MG_State::GLState::ShaderPreprocessOutcome;
MobileGL::MG_State::GLState::ShaderPreprocessResult result;
result.preprocessedSource = source;
PreprocessShaderSource(stage, result.preprocessedSource, env);
if (stage == ShaderStage::Compute) {
if (const std::optional<String> localSizeError =
ValidateComputeLocalSizeLimits(result.preprocessedSource, env)) {
result.outcome = ShaderPreprocessOutcome::ComputeLocalSizeRejected;
result.infoLog = *localSizeError;
return result;
}
}
if (const std::optional<String> reservedError = FindReservedIdentifierViolation(result.preprocessedSource)) {
result.outcome = ShaderPreprocessOutcome::ReservedIdentifierRejected;
result.infoLog = *reservedError;
return result;
}
// The parse this feeds runs in the link-compatible configuration (Vulkan-client
// env with relaxed rules): the TShader it produces is what glLinkProgram links and
// what the backends' SPIR-V is generated from - there is no second, GL-client
// parse. The GL frontend semantics the relaxed parse cannot provide are restored
// on top: explicit default-block uniform locations through the lexical
// side-channels below, dead-uniform/global-UBO filtering in
// ProgramObject::DoReflection.
result.explicitUniformLocations = ExtractExplicitUniformLocations(result.preprocessedSource);
result.explicitOpaqueBindings = ExtractExplicitOpaqueBindings(result.preprocessedSource);
result.outcome = ShaderPreprocessOutcome::Preprocessed;
return result;
}
} // namespace
namespace MobileGL::MG_State::GLState {
// glslang has no "detach this thread" API in the vendored revision (there is no
// InitThread/DetachThread pair any more; thread attachment is implicit through
// thread_local state, and glslang::InitializeProcess() is process-wide, refcounted and
// mutex-guarded, so it needs no per-worker counterpart). The pool allocator is the part
// that needs undoing; see the declaration in ShaderCompileTask.h.
GlslangThreadAllocatorGuard::~GlslangThreadAllocatorGuard() { glslang::SetThreadPoolAllocator(nullptr); }
// Pure CPU work only. Everything this reads is either an input the node owns or a
// process-wide constant; everything it writes is `artifacts`. Do not add a GL/EGL call,
// a pActiveBackendObject read, or a pGLContext->RecordError() here - the first two are
// what CompileEnv exists to replace, and the third is why the design's section 6
// deferral mechanism (and JobNode's debug assert on it) exists.
void ShaderCompileTask::RunBody() {
// Own the failure rather than letting JobNode's backstop settle the node as
// Cancelled: an abandoned node publishes nothing, so the shader would report
// COMPILE_STATUS false with an EMPTY info log. GL models a failed compile as
// status + log, so turn a throw into exactly that - a completed job whose result
// is "this shader did not compile", with a log the application can read.
// (JobNode still catches: it is the last resort for anything below.)
try {
RunCompilePipeline();
} catch (const std::exception& e) {
artifacts = {};
artifacts.env = env;
artifacts.compileStatus = false;
artifacts.infoLog = std::format("Error: shader compilation failed: {}", e.what());
} catch (...) {
artifacts = {};
artifacts.env = env;
artifacts.compileStatus = false;
artifacts.infoLog = "Error: shader compilation failed: unknown exception";
}
}
void ShaderCompileTask::RunCompilePipeline() {
using namespace MG_Util::ShaderTranspiler;
const GlslangThreadAllocatorGuard glslangGuard;
const CompileEnv& compileEnv = *env;
artifacts.env = env;
// P0b layer 2: another shader object in this context may already have run the
// source-only half over byte-identical text under the same environment.
ShaderPreprocessResultPtr cached =
cache ? cache->Find(stage, sourceHash, *source, compileEnv.fingerprint) : nullptr;
SharedPtr<ShaderPreprocessResult> fresh;
if (!cached) fresh = MakeShared<ShaderPreprocessResult>(RunSourceOnlyPipeline(stage, *source, compileEnv));
const ShaderPreprocessResult& shared = cached ? *cached : *fresh;
const Bool shouldPopulateCache = !cached && cache != nullptr;
if (!shared.Preprocessed()) {
// Rejected lexically, or a glslang failure this context has already seen for
// this exact source (ParseFailed) - either way the parse can be skipped.
artifacts.infoLog = shared.infoLog;
if (shouldPopulateCache) {
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
}
return;
}
ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(stage),
.sourceStr = shared.preprocessedSource,
.flags = 0,
.env = &compileEnv};
auto result = ShaderCompiler::CompileShader(attrib);
if (result) {
artifacts.compileStatus = true;
artifacts.shader = result.value();
// Copy, not move: `shared` may alias a cache entry that has to outlive us, and
// `fresh` is about to be handed to the cache.
artifacts.preprocessedSource = shared.preprocessedSource;
artifacts.explicitUniformLocations = shared.explicitUniformLocations;
artifacts.explicitOpaqueBindings = shared.explicitOpaqueBindings;
artifacts.infoLog.clear();
if (shouldPopulateCache) {
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
}
} else {
artifacts.infoLog = result.error().log;
// Deferred, not logged here, for two reasons. MGLOG from a pool thread interleaves
// mid-line with the GL thread's own output and lands out of order relative to the
// glCompileShader that caused it; diagnostics.logLines is replayed by the join, on
// the GL thread, exactly where a serial implementation would have printed it.
// And a one-line summary rather than the old full source dump: a shaderpack stage
// is ~100KB, so the dump was the single largest thing this driver ever wrote to
// 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)));
if (shouldPopulateCache) {
fresh->outcome = ShaderPreprocessOutcome::ParseFailed;
fresh->infoLog = artifacts.infoLog;
fresh->explicitUniformLocations.clear();
fresh->explicitOpaqueBindings.clear();
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
}
}
}
SharedPtr<glslang::TShader> ShaderCompileTask::ClaimParsedShader(String& outReparseLog) const {
MOBILEGL_ASSERT(IsComplete(),
"ShaderCompileTask::ClaimParsedShader() on a job that has not completed; its artifacts "
"are still being written");
if (artifacts.shader) {
// The whole race, in one instruction. Acquire-release because the winner is about
// to hand the TShader to glslang's linker on a possibly different thread from the
// one that parsed it - the node's terminal transition already published the
// parse, and this orders the two claimants against each other.
Bool expected = false;
if (m_parseClaimed.compare_exchange_strong(expected, true, std::memory_order_acq_rel,
std::memory_order_acquire)) {
return artifacts.shader;
}
}
// Either another link already consumed the stored parse (and mapIO mutated its
// intermediate), or there never was one. Re-parse the preprocessed source through the
// identical configuration; that costs one glslang parse, which is what GenerateBinary
// used to spend here on EVERY link rather than only on reuse.
//
// The guard is not optional on this path: from stage 4 this runs on a pool worker,
// and TShader::parse would leave that worker's TLS allocator pointing at a pool the
// GL thread is about to free. (ProgramLinkTask::RunBody holds one too; they nest
// harmlessly - both just reset the thread to its own default.)
const GlslangThreadAllocatorGuard glslangGuard;
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(stage),
.sourceStr = artifacts.preprocessedSource,
.flags = 0,
// Re-parse against the SAME environment the original parse used,
// not against whatever the backend reports now.
.env = artifacts.env.get()};
auto result = ShaderCompiler::CompileShader(attrib);
if (!result) {
// Should be unreachable: the same source parsed successfully at Compile().
outReparseLog = result.error().log;
return nullptr;
}
return result.value();
}
} // namespace MobileGL::MG_State::GLState
@@ -0,0 +1,186 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include <MG_Util/Async/JobNode.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <MG_State/GLState/ProgramState/ShaderPreprocessCache.h>
namespace MobileGL::MG_State::GLState {
// glslang has no "detach this thread" API in the vendored revision, but TShader::parse
// leaves the calling thread's TLS pool allocator pointing at the shader's own pool and
// never restores it. Left there, the next allocation this thread makes - in an unrelated
// job, or in glslang code reached from a different object - would come out of a pool the
// GL thread may already have deleted with the TShader. SetThreadPoolAllocator(nullptr)
// reverts the thread to its own thread_local default and is the documented idiom.
//
// A scope guard, so it also runs when a body throws. Declared here rather than kept
// file-local because stage 4 gave it a second user: ProgramLinkTask's body parses (the
// claim-CAS loser's re-parse), links and emits SPIR-V, all on a pool thread.
struct GlslangThreadAllocatorGuard {
GlslangThreadAllocatorGuard() = default;
~GlslangThreadAllocatorGuard();
GlslangThreadAllocatorGuard(const GlslangThreadAllocatorGuard&) = delete;
GlslangThreadAllocatorGuard& operator=(const GlslangThreadAllocatorGuard&) = delete;
};
// Everything one glCompileShader PRODUCES, in one block.
//
// This is exactly the set a single run of the compile pipeline writes, which is what
// makes "discard the artifacts" a complete invalidation and "move the artifacts" a
// complete publish. It lives on the job node rather than on ShaderObject: a worker fills
// it in, and the GL thread reads it through ShaderObject's join gate.
struct ShaderCompileArtifacts {
// The CompileEnv snapshot this compile ran against. Held so the consume-once
// re-parse in ClaimParsedShader() reproduces the original parse exactly, instead of
// re-reading whatever the backend says now.
SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env;
SharedPtr<glslang::TShader> shader;
// The source the parse actually consumed (after PreprocessShaderSource), kept for
// ClaimParsedShader's re-parse so a later link never depends on the preprocessor
// being deterministic across backend-state changes.
String preprocessedSource;
UnorderedMap<String, Int> explicitUniformLocations;
UnorderedMap<String, Uint> explicitOpaqueBindings;
String infoLog;
Bool compileStatus = false;
};
// The unit of asynchronous shader compilation: one glCompileShader's worth of pure CPU
// work - preprocess, the two lexical rejections, the two lexical extractions, and the
// glslang parse - with every input it needs owned by the node itself.
//
// That ownership is the whole point. The node reads no GL-thread state (the source is a
// SharedPtr<const String> snapshot, the device limits come from the CompileEnv snapshot,
// the P0b cross-object memo is shared-owned and internally locked) and writes nothing
// but its own `artifacts`. So a node whose ShaderObject was re-sourced, deleted, or
// destroyed while it was still running is safe to simply abandon - no wait, no
// synchronization with the GL thread beyond the node's own terminal state.
class ShaderCompileTask final : public MG_Util::Async::JobNode {
public:
ShaderCompileTask(const ShaderStage stage, SharedPtr<const String> source, const Uint64 sourceHash,
SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env,
SharedPtr<ShaderPreprocessCache> cache, const Uint externalIndex)
: stage(stage), source(Move(source)), sourceHash(sourceHash), env(Move(env)), cache(Move(cache)),
externalIndex(externalIndex) {}
// ---- inputs: immutable after construction, all owned by the node ----
const ShaderStage stage;
// The exact text at enqueue. ShaderObject compares this pointer against its own
// m_source to decide whether its layer-1 memo is armed, which is why glShaderSource
// only ever swaps the pointer when the text genuinely differs.
const SharedPtr<const String> source;
const Uint64 sourceHash;
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env;
// P0b layer 2, or null. Null is the "no context" case (the default fragment shader,
// the backends' internal blit/mipmap shaders) and doubles as the marker for
// "compile inline regardless of the async flag" - see ShaderObject::Compile().
const SharedPtr<ShaderPreprocessCache> cache;
const Uint externalIndex; // logs only
// ---- output: valid iff IsComplete(), immutable afterwards ----
ShaderCompileArtifacts artifacts;
// Hands out a link-consumable TShader, exactly once for the stored parse.
//
// glslang's mapIO mutates the TShader's aliased intermediate, so the parse this node
// produced may feed exactly ONE link; every later link (a relink, or the same shader
// attached to a second program) needs a fresh parse. The claim is a CAS on this
// shared node rather than a flag on the ShaderObject because from stage 4 the two
// callers can be two ProgramLinkTasks running on two workers: two programs sharing
// one shader, linked back to back. Copying the parse out and tracking consumed-ness
// per program would let both of them decide they were the first, run mapIO over the
// same intermediate twice, and ship silently corrupt SPIR-V.
//
// The CAS loser re-parses artifacts.preprocessedSource against THIS node's own
// CompileEnv (not against whatever the backend reports now), through the identical
// CompileShader path - so winner and loser produce byte-identical SPIR-V. Callable
// only once IsComplete() and compileStatus are true. Returns null only if that
// re-parse fails, and outReparseLog then carries its diagnostics.
//
// Const because the claim is the node's own synchronization, not a mutation of its
// published artifacts: a claim that is taken and then abandoned (its link was
// cancelled) costs one extra re-parse later and nothing else.
SharedPtr<glslang::TShader> ClaimParsedShader(String& outReparseLog) const;
// Sticky marker for "a ProgramLinkTask has this node in its input snapshot".
//
// It exists to keep a cancel from eating a result someone still needs. A pending link
// holds its dependencies by SharedPtr, so the NODE always outlives the ShaderObject -
// but Cancel() is not about lifetime, it discards the result. The reachable sequence
// is the ordinary one: compile, attach, glLinkProgram (enqueued), glDetachShader,
// glDeleteShader. The detach makes the shader GL-invisible, so the delete frees its
// name, and ReleaseShaderNameIfOrphaned would cancel a compile the enqueued link is
// waiting on - turning a link that must report GL_TRUE into GL_FALSE. Set on the GL
// thread in Link()'s prologue, read on the GL thread by
// ShaderObject::ReleaseCompileNode - which from stage 6 weighs it together with the
// adopter count below, because a node can now have both kinds of observer at once.
//
// Never cleared: the worst case is one stale node compiling to completion for nobody,
// which is exactly what the pre-stage-3 implementation always did.
void MarkLinkReferenced() { m_linkReferenced.store(true, std::memory_order_release); }
Bool IsLinkReferenced() const { return m_linkReferenced.load(std::memory_order_acquire); }
// ---- P1 stage 6: the adopter count ----
// How many live ShaderObjects currently hold this node in their m_compiled.
//
// It exists because stage 6 lets a node be SHARED: before it, a node had exactly one
// shader object, so "this object stopped caring" and "nothing can observe this
// result" were the same statement and ShaderObject::CancelCompile could cancel
// unconditionally. Once two GL shader names hold one node, that cancel would kill the
// other one's pending compile - a compile that must still report GL_TRUE. So a cancel
// is now authorized by TWO conditions, both checked by the releaser:
// * this release brings the count to zero (no shader object is left), AND
// * IsLinkReferenced() is false (no enqueued link took the node into its snapshot).
// The second is the stage-4 pin, unchanged; the first is what stage 6 adds.
//
// ---- Why a plain Int and not an atomic ----
// Every mutation is made from ShaderObject, and every ShaderObject mutation site is a
// GL entry point on the application's context thread: glCompileShader (adopt/create),
// glShaderSource with different text, glDeleteShader's orphan sweep, and
// ~ShaderObject. All of them are the SAME thread, so the count is never concurrently
// mutated and an atomic would only buy an unneeded lock prefix on the hottest compile
// path. Workers cannot touch it by construction: a job body's entire contract (see
// this class's header comment) is that it reads only the node's inputs and writes only
// `artifacts`, and a plain Int here makes that contract grep-checkable in a way an
// atomic would quietly hide.
//
// The CANCEL that the count authorizes still races the worker, and deliberately so -
// that is the settled cancel-not-join semantics from stage 3: JobNode::Cancel is
// cooperative and non-blocking, a node already running settles as Cancelled when its
// body returns, and a node that has already gone terminal ignores the request.
// Nothing about that changes here.
//
// Exactness under that race: ShaderObject::ReleaseCompileNode returns EARLY, without
// decrementing and without dropping its reference, when the node is already terminal
// (there is nothing left to stop). Terminality is sticky, so if a releaser observes a
// node as NON-terminal then no holder has ever taken that early return on it, and the
// count it reads is exactly the number of holders. If the worker finishes in the
// window between that observation and the Cancel(), the Cancel is a no-op on a
// terminal node - and the count was zero, so there was no other holder to harm.
void AddAdopter() { ++m_adopters; }
void ReleaseAdopter() {
MOBILEGL_ASSERT(m_adopters > 0,
"ShaderCompileTask adopter count underflow; a ShaderObject released a node it did not "
"hold (every release must pair with exactly one AddAdopter)");
--m_adopters;
}
Int AdopterCount() const { return m_adopters; }
private:
void RunBody() override;
// The real body; RunBody wraps it so a throw becomes a GL-visible compile failure.
void RunCompilePipeline();
mutable std::atomic<Bool> m_parseClaimed{false};
std::atomic<Bool> m_linkReferenced{false};
// GL-thread-owned; see AddAdopter above for why this is not an atomic.
Int m_adopters = 0;
};
} // namespace MobileGL::MG_State::GLState
@@ -7,197 +7,208 @@
// End of Source File Header
#include "ShaderObject.h"
#include <MG_Util/ShaderTranspiler/Types.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include "ShaderPreprocessCache.h"
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
#include <MG_Util/ShaderTranspiler/glslang/UniformTraverser.h>
#include <MG_Backend/BackendObjects.h>
namespace {
struct ComputeLocalSize {
MobileGL::Uint x = 1;
MobileGL::Uint y = 1;
MobileGL::Uint z = 1;
bool declared = false;
};
static MobileGL::String StripGlslComments(const MobileGL::String& source) {
MobileGL::String result;
result.reserve(source.length());
bool inLineComment = false;
bool inBlockComment = false;
for (MobileGL::SizeT i = 0; i < source.length(); ++i) {
if (inLineComment) {
if (source[i] == '\n') {
inLineComment = false;
result.push_back(source[i]);
} else {
result.push_back(' ');
}
continue;
}
if (inBlockComment) {
if (source[i] == '*' && i + 1 < source.length() && source[i + 1] == '/') {
inBlockComment = false;
result.append(" ");
++i;
} else {
result.push_back(source[i] == '\n' ? '\n' : ' ');
}
continue;
}
if (source[i] == '/' && i + 1 < source.length()) {
if (source[i + 1] == '/') {
inLineComment = true;
result.append(" ");
++i;
continue;
}
if (source[i + 1] == '*') {
inBlockComment = true;
result.append(" ");
++i;
continue;
}
}
result.push_back(source[i]);
}
return result;
}
static ComputeLocalSize ParseComputeLocalSize(const MobileGL::String& source) {
ComputeLocalSize localSize;
const MobileGL::String uncommentedSource = StripGlslComments(source);
const std::regex localSizePattern(R"(local_size_([xyz])\s*=\s*([0-9]+))");
for (std::sregex_iterator it(uncommentedSource.begin(), uncommentedSource.end(), localSizePattern), end;
it != end; ++it) {
const char axis = (*it)[1].str()[0];
const auto value = static_cast<unsigned long long>(std::stoull((*it)[2].str()));
const MobileGL::Uint clampedValue = value > UINT_MAX ? UINT_MAX : static_cast<MobileGL::Uint>(value);
// TODO: Replace this literal layout scanner with parser/AST-backed validation so expressions and
// specialization-id layouts are handled consistently with glslang.
localSize.declared = true;
if (axis == 'x') {
localSize.x = clampedValue;
} else if (axis == 'y') {
localSize.y = clampedValue;
} else {
localSize.z = clampedValue;
}
}
return localSize;
}
static MobileGL::Uint GetComputeWorkGroupSizeLimit(MobileGL::Uint index) {
constexpr MobileGL::Uint kFrontendMinComputeWorkGroupSizes[] = {1024, 1024, 64};
MobileGL::Int backendValue = 0;
if (MobileGL::MG_Backend::gBackendFunctionsTable.GL.GetIntegeri_v) {
MobileGL::MG_Backend::gBackendFunctionsTable.GL.GetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, index,
&backendValue);
}
// TODO: Share these exposed compute limit helpers with GL_Getter.cpp instead of duplicating the frontend minima.
return std::max(static_cast<MobileGL::Uint>(std::max(backendValue, 0)),
kFrontendMinComputeWorkGroupSizes[index]);
}
static unsigned long long GetComputeWorkGroupInvocationLimit() {
constexpr unsigned long long kFrontendMaxComputeWorkGroupInvocations = 1024;
if (!MobileGL::MG_Backend::pActiveBackendObject) return kFrontendMaxComputeWorkGroupInvocations;
return std::max(static_cast<unsigned long long>(std::max(
MobileGL::MG_Backend::pActiveBackendObject->GetDynamicParameters()
.MaxComputeWorkGroupInvocations,
0)),
kFrontendMaxComputeWorkGroupInvocations);
}
static std::optional<MobileGL::String> ValidateComputeLocalSizeLimits(const MobileGL::String& source) {
const ComputeLocalSize localSize = ParseComputeLocalSize(source);
if (!localSize.declared) return std::nullopt;
if (localSize.x > GetComputeWorkGroupSizeLimit(0) || localSize.y > GetComputeWorkGroupSizeLimit(1) ||
localSize.z > GetComputeWorkGroupSizeLimit(2)) {
return "Compute shader local_size exceeds GL_MAX_COMPUTE_WORK_GROUP_SIZE.";
}
const unsigned long long invocations = static_cast<unsigned long long>(localSize.x) * localSize.y * localSize.z;
if (invocations > GetComputeWorkGroupInvocationLimit()) {
return "Compute shader local_size product exceeds GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS.";
}
return std::nullopt;
}
}
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/Types.h>
namespace MobileGL::MG_State::GLState {
void ShaderObject::SetShaderSource(const String& source) {
m_source = source;
m_shader.reset();
m_compileStatus = false;
m_infoLog.clear();
// P0b layer 1. glShaderSource always REPLACES the source, but replacing it with a
// byte-identical one cannot change what a compile would produce: the whole
// pipeline (preprocess -> lexical checks -> glslang parse) is a pure function of
// (stage, source, CompileEnv). So keeping the compiled state is not an optimization
// that changes observable behaviour - the COMPILE_STATUS, the info log and the
// reflection a caller can query are exactly what a real recompile would have
// rebuilt, byte for byte. A compile still IN FLIGHT is left running for the same
// reason: it is computing the right answer for text this object still holds.
if (SourceMatchesCompiledState(source)) return;
// The text genuinely changed, so whatever a running job is computing is now about
// an old source. Give up our claim on it - it owns its own copy of that old string,
// so swapping the pointer below cannot race its storage. Note "our claim", not "the
// job": another shader object may have adopted the same node and still be waiting for
// exactly this answer, which is what ReleaseCompileNode's count discipline protects.
ReleaseCompileNode();
m_source = MakeShared<const String>(source);
InvalidateCompiledState();
}
void ShaderObject::SetShaderSource(String&& source) {
m_source = Move(source);
m_shader.reset();
m_compileStatus = false;
m_infoLog.clear();
if (SourceMatchesCompiledState(source)) return;
ReleaseCompileNode();
m_source = MakeShared<const String>(Move(source));
InvalidateCompiledState();
}
Bool ShaderObject::SourceMatchesCompiledState(const String& candidate) const {
// The memo is armed exactly while a job exists that was built from the string this
// object still points at - pending or finished, success or failure.
if (!HasMemoizedCompile()) return false;
if (candidate.length() != m_source->length()) return false;
// Never let correctness ride on a hash: the answer is the full text comparison.
// (The stored hash on the node is a cache-lookup accelerator, not a substitute.)
return candidate == *m_source;
}
void ShaderObject::JoinPendingCompile() const {
MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(),
"ShaderObject::EnsureCompileJoined() reached from a pool thread; a job body must never read "
"GL-thread-owned objects");
m_compiled->Wait();
m_compileJoined = true;
// Errors and worker-side log lines are raised HERE, on the GL thread, at the first
// join of the job that produced them - which for a single shader is trivially the
// order a serial implementation would have produced them in.
//
// ApplyDeferredDiagnostics DRAINS, so a node shared by several shader objects
// (stage 6) replays its worker-side log line exactly once, at whichever object joins
// first. That is the honest report - one compile ran - and it is log text only: the
// GL-observable half of a failure, COMPILE_STATUS and the info log, lives in
// `artifacts` and every sharer reads the identical copy of it.
MG_Util::Async::ApplyDeferredDiagnostics(*m_compiled);
// A node that settled as Cancelled published nothing. Dropping it here is what keeps
// the object's state machine to two reachable cases - "no job" and "a job that
// completed" - so every reader below can treat a live node as authoritative.
//
// Through DropCompileNode, not a bare reset: this object is letting the node go, so
// its adopter slot has to go with it. A node shared with another object stays alive
// and gets dropped once more when that object joins - once per holder, never twice
// for the same one, because DropCompileNode is null-guarded.
if (!m_compiled->IsComplete()) DropCompileNode();
}
void ShaderObject::AdoptCompileNode(SharedPtr<ShaderCompileTask> node) const {
// Never overwrite a hold without giving its slot back first.
DropCompileNode();
m_compiled = Move(node);
m_compiled->AddAdopter();
// Re-arm the join gate: whether this node was just created or just adopted from
// another object, THIS object has not pulled its result yet. (An adopted node may
// already be terminal - the join then only replays what is left of its diagnostics.)
m_compileJoined = false;
}
void ShaderObject::DropCompileNode() const {
if (!m_compiled) return;
m_compiled->ReleaseAdopter();
m_compiled.reset();
}
void ShaderObject::InvalidateCompiledState() {
// The job node holds exactly what one Compile() produces, so discarding it IS the
// invalidation - and it re-arms nothing, so the next Compile() genuinely recompiles.
DropCompileNode();
}
void ShaderObject::ReleaseCompileNode() {
if (!m_compiled) return;
// Already terminal: there is nothing left to stop, so this is not a release at all -
// the node and this object's claim on it both stay. That early return is older than
// stage 6 and it is load-bearing: ProgramState::ReleaseShaderNameIfOrphaned calls
// this on a shader whose name is going away but whose object a ProgramObject may
// still hold, and dropping a COMPLETED compile there would turn that program's link
// into GL_FALSE.
if (m_compiled->IsTerminal()) return;
// Two independent claimants have to be checked before a cancel, and this object is
// authorized to cancel only if BOTH say the result has become unobservable.
//
// 1. Other shader objects. From stage 6 a node can be SHARED by several GL shader
// names that were handed byte-identical source; cancelling here would turn a
// compile they must still see as GL_TRUE into GL_FALSE. Only the releaser that
// takes the count to zero - i.e. the last holder - may cancel. See
// ShaderCompileTask::AddAdopter for why a plain Int is sound here and for the
// exactness argument under the worker race.
// 2. A pending LINK. An enqueued ProgramLinkTask holds the node in its input snapshot
// and a cancel would turn its link into GL_FALSE; reached by the ordinary
// link-then-detach-then-delete shader teardown. See MarkLinkReferenced. Never
// cleared, so this is a one-way pin.
//
// The cancel itself is cooperative and non-blocking, exactly as before: a node no
// worker has picked up settles immediately, a running one is flagged and settles when
// its body returns, writing only into itself the whole time.
if (m_compiled->AdopterCount() == 1 && !m_compiled->IsLinkReferenced()) m_compiled->Cancel();
DropCompileNode();
}
void ShaderObject::Compile() {
using namespace MG_Util::ShaderTranspiler;
String compileSource = m_source;
MG_Util::ShaderTranspiler::PreprocessShaderSource(m_stage, compileSource);
// P0b layer 1, as a tri-state: the memo is "the node in m_compiled was built from
// the string m_source still points at". SetShaderSource only swaps that pointer when
// the text actually differs, so this is a pointer compare, and it covers Pending as
// well as Complete - a second glCompileShader on an in-flight object is a no-op, not
// a duplicate job racing to write the same fields.
//
// The failure case is covered too: the info log stays queryable because nothing is
// cleared. And if the stored TShader already fed a link, the no-op leaves
// preprocessedSource and both side-channel maps intact, which is precisely what
// ClaimParsedShader's on-demand re-parse needs - a real recompile would have handed
// the next link a fresh parse, the no-op hands it a fresh re-parse of the identical
// source instead. Same result, one parse either way.
if (HasMemoizedCompile()) return;
if (m_stage == ShaderStage::Compute) {
const std::optional<String> localSizeError = ValidateComputeLocalSizeLimits(compileSource);
if (localSizeError) {
m_compileStatus = false;
m_shader.reset();
m_infoLog = *localSizeError;
// Two reasons to stay on this thread, one rule. Without the async flag the whole
// path must be byte-identical to the synchronous implementation, and a cache-less
// object is an internal shader that compiles and reads its status in the same
// breath (see the constructor comment) - a job would only add a round trip.
// AsyncShaderCompileActive(), not ...Enabled(): a glMaxShaderCompilerThreadsKHR(0)
// has to put compilation back on this thread even though the extension is still
// advertised, and that is exactly what makes the GL_COMPLETION_STATUS_KHR the
// extension mandates after a zero count (immediately GL_TRUE) fall out for free.
//
// Hoisted above the node construction because stage 6 keys off it too: this same
// answer decides whether the adoption map is consulted at all, so a
// glMaxShaderCompilerThreadsKHR(0) and a flag-off build both bypass sharing exactly
// as they bypass the pool, and their behaviour stays byte-identical to pre-stage-6.
const Bool runOnPool = m_preprocessCache && MG_Util::Async::AsyncShaderCompileActive();
// The compile-environment snapshot is taken HERE, on the GL thread, and handed to
// the job. Everything the pipeline needs to know about the device comes through it,
// never through pActiveBackendObject - that is what makes the body movable.
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env =
MG_Util::ShaderTranspiler::GetCurrentCompileEnv();
const Uint64 sourceHash = ShaderPreprocessCache::HashSource(*m_source);
// ---- P1 stage 6: adopt an equivalent compile instead of enqueueing a duplicate ----
// ~21% of all glCompileShader calls in the shaderpack corpus are a DIFFERENT shader
// object handed byte-identical source. P0b's memo only pays off once one of them has
// finished; under async they are all enqueued in the same burst, so without this each
// one runs the whole pipeline on its own worker. The map hands back the node the
// first of them created - in flight or already complete - and this object simply
// holds it too.
if (runOnPool && m_adoptionMap) {
if (SharedPtr<ShaderCompileTask> shared =
m_adoptionMap->FindAdoptable(m_stage, sourceHash, *m_source, env->fingerprint)) {
// Take the node's own source snapshot as ours. FindAdoptable just compared
// the two strings in full, so this changes nothing observable - but it is not
// optional: the layer-1 memo (HasMemoizedCompile) is a POINTER comparison
// against the node's snapshot, so leaving our own equal-but-distinct copy in
// place would make the very next glCompileShader on this object decide it had
// no memo and enqueue the duplicate this whole stage exists to avoid - and
// would make an identical glShaderSource re-source cancel a shared compile.
// It also collapses N copies of a ~100 KB shaderpack stage into one.
m_source = shared->source;
AdoptCompileNode(Move(shared));
return;
}
}
const std::optional<String> reservedError =
MG_Util::ShaderTranspiler::FindReservedIdentifierViolation(compileSource);
if (reservedError) {
m_compileStatus = false;
m_shader.reset();
m_infoLog = *reservedError;
AdoptCompileNode(MakeShared<ShaderCompileTask>(m_stage, m_source, sourceHash, env, m_preprocessCache,
m_externalIndex));
if (!runOnPool) {
m_compiled->RunInline();
// Inline means the node is already terminal, so this join only replays
// diagnostics; it is here so the synchronous and asynchronous paths publish
// through the identical code.
EnsureCompileJoined();
return;
}
// Compile for OpenGL here, so that we can do validation and link
// like a real OpenGL driver at linking stage
// Will compile for other backends later.
ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(m_stage),
.sourceStr = compileSource,
.flags = ShaderCompileBits::CompileForOpenGL};
auto result = ShaderCompiler::CompileShader(attrib);
if (result) {
m_compileStatus = true;
m_shader = result.value();
m_infoLog.clear();
} else {
m_compileStatus = false;
m_shader.reset();
m_infoLog = result.error().log;
MGLOG_D("ShaderObject::Compile: Shader %d compilation failed.\nSource:\n%s\nInfoLog:\n%s\nSetting "
"m_compileStatus = false as a result.",
m_externalIndex, compileSource.c_str(), m_infoLog.c_str());
}
// Registered BEFORE the post, so the very next glCompileShader in this burst can
// adopt it however fast a worker picks it up. Registration is an index entry only -
// the map holds a WeakPtr and never keeps a node alive.
if (m_adoptionMap) m_adoptionMap->Register(m_compiled);
MG_Util::Async::ShaderCompilePool::Get().Post(m_compiled);
}
void ShaderObject::MarkAsDeleted() {
@@ -8,48 +8,229 @@
#pragma once
#include <Includes.h>
#include <MG_State/GLState/ProgramState/ShaderStage.h>
#include <MG_State/GLState/ProgramState/ShaderCompileTask.h>
#include <MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h>
namespace MobileGL {
enum class ShaderStage {
Vertex,
TessControl,
TessEval,
Geometry,
Fragment,
Compute,
ShaderStageCount,
Unknown = -1
};
namespace MG_State::GLState {
// The GL-visible shader name. It owns the source text and one compile job node; the
// job node owns everything a compile produces.
//
// Every member below is GL-thread-owned, and every read of worker-produced state
// goes through Compiled(), which joins first. That is invariant I5 of the P1 design:
// because Compiled() is the SOLE accessor of the node's artifacts, the compiler
// enumerates every reader for us and none can be forgotten.
class ShaderObject {
public:
ShaderObject(const ShaderStage stage, Uint externalIndex)
: m_stage(stage), m_externalIndex(externalIndex) {}
// `preprocessCache` is the owning context's cross-object memo (P0b layer 2).
// Null is fully supported and means two things at once: "no sharing", and
// "compile inline, never on a worker". Those coincide exactly - the only
// cache-less shader objects are the internal ones (ProgramObject's default
// fragment shader, the DirectVulkan blit and depth-mipmap shaders) and every one
// of them compiles and reads its status in the same breath, so a job would only
// add a round trip. Shared ownership rather than a raw pointer: a compile job
// outlives neither the object nor the context deterministically, and the cache
// has to stay alive for whoever is still reading it.
//
// `adoptionMap` is the same context's stage-6 index of adoptable compile nodes.
// It is non-null exactly when `preprocessCache` is (ProgramState hands both out
// together, and nobody else hands out either), which is what makes "no cache"
// keep meaning "compile inline, share nothing": an internal shader object has
// neither, so it neither adopts nor registers and its path is byte-identical to
// the pre-stage-6 one. GL-thread-only, so unlike the cache it carries no lock -
// shared ownership only because a ShaderObject may outlive the context's tables.
ShaderObject(const ShaderStage stage, Uint externalIndex,
SharedPtr<ShaderPreprocessCache> preprocessCache = nullptr,
SharedPtr<ShaderCompileAdoptionMap> adoptionMap = nullptr)
: m_stage(stage), m_externalIndex(externalIndex), m_preprocessCache(Move(preprocessCache)),
m_adoptionMap(Move(adoptionMap)) {}
// Cancel-not-join: the node owns its inputs, so an in-flight compile whose
// object just went away is safe to abandon where it stands. Nothing can observe
// its result any more - unless another shader object adopted the same node, or a
// link pinned it, which is precisely what ReleaseCompileNode() checks.
~ShaderObject() {
ReleaseCompileNode();
// ReleaseCompileNode KEEPS a node that has already gone terminal - there is
// nothing left to stop, so it is not a release at all. This object is going
// away regardless, so hand the adopter slot back here. That is what keeps
// ShaderCompileTask::AdopterCount() exactly "how many live ShaderObjects hold
// this node" instead of merely an upper bound.
DropCompileNode();
}
ShaderObject(const ShaderObject&) = delete;
ShaderObject& operator=(const ShaderObject&) = delete;
void SetShaderSource(const String& source);
void SetShaderSource(String&& source);
void Compile();
// Gives up this object's claim on its compile node, cancelling the node only if
// this object was its LAST claimant. Called at the points where the object's
// compiled state stops being observable through THIS name: a real source change,
// and the release of an orphaned shader name.
//
// Named for what it does rather than for what it used to do: before stage 6 a
// node had exactly one shader object, so giving up the claim and cancelling the
// compile were the same act and this was CancelCompile(). They are not the same
// act any more - see ShaderCompileTask::AddAdopter for the count discipline and
// its single-threadedness argument. Never waits, in either case.
void ReleaseCompileNode();
void MarkAsDeleted();
// The compile job node itself, for ProgramObject::Link()'s input snapshot.
// DELIBERATELY DOES NOT JOIN, and that is the entire point of stage 4: the link
// takes the node as a dependency and is posted only once the node is terminal,
// so glLinkProgram never blocks on glCompileShader. Null means this object has
// never been compiled (or its last compile was abandoned), which the link reads
// as COMPILE_STATUS false - the same verdict the joining path produces.
//
// The caller must MarkLinkReferenced() whatever it keeps: from here on the node's
// result has an observer this object knows nothing about (see the marker's
// comment in ShaderCompileTask.h).
const SharedPtr<ShaderCompileTask>& CompiledNodeForLink() const { return m_compiled; }
Uint GetExternalIndex() const { return m_externalIndex; }
ShaderStage GetShaderStage() const { return m_stage; }
const String& GetShaderSource() const { return m_source; }
const SharedPtr<glslang::TShader>& GetCompiledShader() const { return m_shader; }
const String& GetInfoLog() const { return m_infoLog; }
const UnorderedMap<String, Uint>& GetUniformLocations() const { return m_uniforms; }
Bool GetCompileStatus() const { return m_compileStatus; }
// No join: the source is GL-thread-owned, and a worker only ever reads the
// immutable snapshot it was handed at enqueue.
const String& GetShaderSource() const { return *m_source; }
// The snapshot itself, for whoever needs to hand it to a job.
const SharedPtr<const String>& GetShaderSourcePtr() const { return m_source; }
const SharedPtr<glslang::TShader>& GetCompiledShader() const { return Compiled().shader; }
const String& GetInfoLog() const { return Compiled().infoLog; }
// Explicit layout(location = N) qualifiers on this shader's default-block
// uniforms, captured lexically at Compile() because the relaxed parse drops
// them from reflection (see ExtractExplicitUniformLocations).
const UnorderedMap<String, Int>& GetExplicitUniformLocations() const {
return Compiled().explicitUniformLocations;
}
// Explicit layout(binding = N) on sampler/image uniforms - their initial
// texture/image units - captured lexically for the same reason (see
// ExtractExplicitOpaqueBindings).
const UnorderedMap<String, Uint>& GetExplicitOpaqueBindings() const {
return Compiled().explicitOpaqueBindings;
}
Bool GetCompileStatus() const { return Compiled().compileStatus; }
Bool GetDeleteStatus() const { return m_deleteStatus; }
// Blocks until a pending compile has published its artifacts. Public for the
// sites that must join without reading anything - ProgramObject::Link's
// prologue, which needs every attached shader settled before it runs.
void JoinCompile() const { EnsureCompileJoined(); }
// True while this object holds the outcome (success OR failure) of a Compile()
// of exactly the source it currently holds - i.e. while the P0b layer-1 memo is
// armed and a glCompileShader would be a no-op. Diagnostics and tests only;
// nothing in the GL frontend branches on it.
//
// Tri-state, and deliberately NOT joining: an in-flight compile of the current
// source counts as memoized (a second glCompileShader must not enqueue a
// duplicate job), but asking that question must never block.
// A node that settled as Cancelled (the job body threw, or the enqueue failed)
// carries no result, so it must NOT satisfy the memo: otherwise a second
// glCompileShader on the same source enqueues nothing and the eventual join
// reports GL_FALSE forever. The synchronous path retries in exactly this case.
Bool HasMemoizedCompile() const {
return m_compiled != nullptr && m_compiled->source == m_source && !m_compiled->IsCancelled();
}
// MUST NOT JOIN - this is what GL_COMPLETION_STATUS_KHR will read when the
// extension surface lands. "No job at all" counts as complete: there is nothing
// outstanding to wait for.
Bool IsCompileComplete() const { return m_compiled == nullptr || m_compiled->IsTerminal(); }
private:
// ---- The one and only join gate for compile output (P1 invariant I5) ----
// The fast path - no job, or a job whose result this object has already pulled -
// is two predictable branches and stays inline: it runs on every Compiled() read
// and the project never builds with LTO, so an out-of-line body would be a real
// cross-TU call at each of those sites. The blocking half is out of line.
//
// The gate keys on "has this object pulled the job's result yet", NOT on "is the
// job terminal". Those differ in the case that matters: a worker can finish a
// compile before the GL thread ever looks at it, and the pull is where deferred
// diagnostics get replayed and an abandoned node gets dropped. Keying on
// terminality would silently skip both.
void EnsureCompileJoined() const {
if (m_compiled && !m_compileJoined) JoinPendingCompile();
}
void JoinPendingCompile() const;
// The artifacts of a compile that ran to completion. A node that was abandoned
// (cancelled at teardown, or whose body threw) never publishes: JoinPendingCompile
// drops it, so anything reachable here is either Complete or absent, and "absent"
// reads as the never-compiled defaults - COMPILE_STATUS false, empty info log,
// which is exactly what GL requires before the first glCompileShader.
static const ShaderCompileArtifacts& EmptyArtifacts() {
static const ShaderCompileArtifacts empty;
return empty;
}
const ShaderCompileArtifacts& Compiled() const {
EnsureCompileJoined();
return m_compiled ? m_compiled->artifacts : EmptyArtifacts();
}
void InvalidateCompiledState();
// ---- the ONLY two writers of m_compiled (P1 stage 6) ----
// Every adopter-count mutation lives in these two, which is what makes "exactly
// one AddAdopter per hold, exactly one ReleaseAdopter per hold" auditable rather
// than something review has to re-derive at each call site. DropCompileNode is
// null-guarded, so calling it on an object that already let go is a no-op and a
// double release is unrepresentable.
void AdoptCompileNode(SharedPtr<ShaderCompileTask> node) const;
void DropCompileNode() const;
// ---- P0b layer 1: per-object no-op recompile ----
// True iff `candidate` is byte-identical to the source that produced (or is
// producing) the compiled state this object currently holds.
Bool SourceMatchesCompiledState(const String& candidate) const;
// ---- GL-thread-owned state: never produced by a compile, so it never joins ----
const ShaderStage m_stage;
const Uint m_externalIndex = 0;
const ShaderStage m_stage;
String m_source;
SharedPtr<glslang::TShader> m_shader;
UnorderedMap<String, Uint> m_uniforms;
// The pre-glShaderSource state, shared by every untouched object rather than
// allocated per glCreateShader.
static const SharedPtr<const String>& EmptySource() {
static const SharedPtr<const String> empty = MakeShared<const String>();
return empty;
}
// glShaderSource text, as an immutable snapshot. Never null. A job holds its own
// SharedPtr to the exact string it was given, so replacing the source under a
// running compile cannot race its storage - and the layer-1 memo collapses to a
// pointer comparison against the job's snapshot, because the setter only swaps
// the pointer when the text genuinely differs.
//
// Not necessarily unique to this object from stage 6 on: adopting a node also
// takes that node's source snapshot (see Compile()), so N shader objects sharing
// one compile share one copy of the text. The string is immutable and shared-
// owned, so that is invisible to every reader.
SharedPtr<const String> m_source = EmptySource();
// P0b layer 2: the owning context's cross-object memo, or null. Internally
// locked, because several workers hit it at once.
const SharedPtr<ShaderPreprocessCache> m_preprocessCache;
// P1 stage 6: the owning context's index of adoptable compile nodes, or null.
// Touched only from Compile(), i.e. only on the GL thread, so it carries no lock.
const SharedPtr<ShaderCompileAdoptionMap> m_adoptionMap;
String m_infoLog;
Bool m_deleteStatus = false;
Bool m_compileStatus = false;
// ---- Compile OUTPUT ---- pending OR completed; reachable only through Compiled().
// Mutable because the join is a read-side operation: a const getter has to be
// able to settle an outstanding job before answering.
//
// SHARED from stage 6 on: several shader objects holding byte-identical source
// under the same CompileEnv point at one node. Every read below still goes
// through the same join gate, and a second joiner finds the node already
// terminal, so nothing about the read path changes - only the release path does
// (ReleaseCompileNode).
mutable SharedPtr<ShaderCompileTask> m_compiled;
// Exactly-once latch for the pull above. Armed with every new job node, set by
// the one join that consumes it.
mutable Bool m_compileJoined = false;
};
} // namespace MG_State::GLState
} // namespace MobileGL
@@ -0,0 +1,86 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.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 "ShaderPreprocessCache.h"
namespace MobileGL::MG_State::GLState {
ShaderPreprocessResultPtr ShaderPreprocessCache::Find(const ShaderStage stage, const Uint64 sourceHash,
const String& source, const Uint64 envFingerprint) const {
const Key key{.stage = stage,
.sourceHash = sourceHash,
.sourceLength = source.length(),
.envFingerprint = envFingerprint};
const std::lock_guard<std::mutex> lock(m_mutex);
const auto it = m_index.find(key);
if (it == m_index.end()) return nullptr;
// Never let correctness ride on a 64-bit hash: confirm the hit byte for byte.
// Lengths already matched (they are part of the key), so this is a plain memcmp.
const Entry& entry = *it->second;
if (entry.originalSource != source) return nullptr;
// A copy of the SharedPtr, taken under the lock: the payload now outlives any
// eviction the caller races with.
return entry.result;
}
void ShaderPreprocessCache::Insert(const ShaderStage stage, const Uint64 sourceHash, const String& source,
const Uint64 envFingerprint, ShaderPreprocessResultPtr result) {
if (!result) return;
const SizeT entryBytes = EntryBytes(source, *result);
// A single source bigger than the whole budget would evict every other entry and
// then itself; refuse it instead of thrashing the cache empty.
if (entryBytes > kMaxStoredSourceBytes) return;
const Key key{.stage = stage,
.sourceHash = sourceHash,
.sourceLength = source.length(),
.envFingerprint = envFingerprint};
const std::lock_guard<std::mutex> lock(m_mutex);
if (const auto existing = m_index.find(key); existing != m_index.end()) {
// Either a re-insert of the same source (harmless) or a genuine hash collision
// with a different source. Both are resolved by letting the newcomer win: one
// entry per key keeps the index a plain map, and a collision is astronomically
// rare enough that the loser simply misses.
EraseEntryLocked(existing->second);
}
m_entries.push_back(Entry{.key = key, .originalSource = source, .result = Move(result)});
m_index[key] = std::prev(m_entries.end());
m_storedSourceBytes += entryBytes;
EvictUntilWithinBudgetLocked();
}
void ShaderPreprocessCache::Clear() {
const std::lock_guard<std::mutex> lock(m_mutex);
m_entries.clear();
m_index.clear();
m_storedSourceBytes = 0;
}
void ShaderPreprocessCache::EraseEntryLocked(const EntryList::iterator it) {
const SizeT bytes = EntryBytes(it->originalSource, *it->result);
m_storedSourceBytes = bytes > m_storedSourceBytes ? 0 : m_storedSourceBytes - bytes;
m_index.erase(it->key);
m_entries.erase(it);
}
void ShaderPreprocessCache::EvictUntilWithinBudgetLocked() {
// FIFO: the oldest insertion goes first. Insert() already refuses entries larger
// than the byte budget, so this loop always terminates with at least the entry
// that was just added still resident.
while (!m_entries.empty() &&
(m_entries.size() > kMaxEntries || m_storedSourceBytes > kMaxStoredSourceBytes)) {
EraseEntryLocked(m_entries.begin());
}
}
} // namespace MobileGL::MG_State::GLState
@@ -0,0 +1,149 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include <list>
#include <mutex>
// Deliberately NOT ShaderObject.h: ShaderCompileTask.h needs this header, and ShaderObject.h
// needs ShaderCompileTask.h. Only ShaderStage was ever used from there.
#include <MG_State/GLState/ProgramState/ShaderStage.h>
#include <MG_State/GLState/ProgramState/ShaderSourceKey.h>
namespace MobileGL::MG_State::GLState {
// Where the shared, source-only half of ShaderObject::Compile() stopped. The two
// rejection verdicts are kept apart (rather than collapsed into "failed") so a hit
// reproduces the original diagnosis, not just the original info log.
enum class ShaderPreprocessOutcome : Uint8 {
// The source-only half ran clean; preprocessedSource and both maps are valid.
Preprocessed,
// ValidateComputeLocalSizeLimits rejected it (compute only).
ComputeLocalSizeRejected,
// FindReservedIdentifierViolation rejected it.
ReservedIdentifierRejected,
// The source-only half was clean but glslang rejected the preprocessed source.
// Memoizing this saves the parse itself on every later object with that source.
ParseFailed,
};
// Everything ShaderObject::Compile() derives from the source text alone, i.e.
// everything that is identical for two shader objects holding byte-identical source.
struct ShaderPreprocessResult {
ShaderPreprocessOutcome outcome = ShaderPreprocessOutcome::Preprocessed;
// Valid unless the preprocessor itself never ran; kept even for the rejection
// outcomes because that is the text the diagnostics refer to.
String preprocessedSource;
UnorderedMap<String, Int> explicitUniformLocations;
UnorderedMap<String, Uint> explicitOpaqueBindings;
// The compile info log to publish; empty when outcome == Preprocessed.
String infoLog;
Bool Preprocessed() const { return outcome == ShaderPreprocessOutcome::Preprocessed; }
};
// Cache hits hand out shared ownership, not a raw pointer into the entry list. That is
// what makes the cache safe once compiles run concurrently: a reader keeps its payload
// alive across any eviction, and a 107 KB preprocessedSource is never copied on a hit.
using ShaderPreprocessResultPtr = SharedPtr<const ShaderPreprocessResult>;
// P0b layer 2: a per-context, bounded memo of the source-only half of shader
// compilation, keyed by (stage, xxhash64(source), source length).
//
// Motivation: in the Iris shader-pack corpus ~21% of every glCompileShader in a trace
// is a *different* shader object holding byte-identical source (packs glue the same
// common/composite GLSL into many program stages), so the preprocess + reserved-
// identifier scan + explicit-location/binding extraction runs over the same megabytes
// again and again. Layer 1 (in ShaderObject) covers the same object recompiled with
// unchanged source; this covers the cross-object case.
//
// What is NOT cached: the glslang parse. glslang's TShader is consume-once (mapIO
// mutates the aliased intermediate at link), so every shader object still needs its
// own parse; only the text-processing half is shared.
//
// Correctness: the 64-bit hash is a lookup accelerator only. Every hit re-compares the
// full stored original source with memcmp before it is honored, so a hash collision
// degrades to a miss, never to a wrong answer. That is why the full original text is
// stored rather than a prefix/suffix digest - the cache is bounded, so the cost is.
//
// Eviction: FIFO (insertion order), bounded by BOTH an entry count and a stored-source
// byte budget, whichever binds first. FIFO rather than LRU because shader-pack loading
// is a burst of mostly-distinct sources whose reuse clusters around insertion time;
// LRU's extra list splice on every hit buys nothing measurable here, and FIFO keeps
// Find() a genuinely const, read-only operation.
class ShaderPreprocessCache {
public:
static constexpr SizeT kMaxEntries = 128;
static constexpr SizeT kMaxStoredSourceBytes = 8u * 1024u * 1024u;
// Returns the memoized result for this exact source under this exact compile
// environment, or null on a miss. The returned SharedPtr owns its payload, so it
// stays valid for as long as the caller holds it - across Insert(), Clear(), and
// across the destruction of the cache itself.
//
// envFingerprint joins the key because the source-only pipeline's compute
// local-size verdict is computed against CompileEnv's device limits: a memo must
// never outlive the environment it was computed against (memo-hazard rule).
ShaderPreprocessResultPtr Find(ShaderStage stage, Uint64 sourceHash, const String& source,
Uint64 envFingerprint) const;
// Memoizes `result` for this source. A source whose own storage cost already
// exceeds the byte budget is simply not cached (caching it would evict everything
// else and then itself).
void Insert(ShaderStage stage, Uint64 sourceHash, const String& source, Uint64 envFingerprint,
ShaderPreprocessResultPtr result);
void Clear();
static Uint64 HashSource(const String& source) {
return static_cast<Uint64>(XXH64(source.data(), source.length(), 0));
}
SizeT GetEntryCount() const {
const std::lock_guard<std::mutex> lock(m_mutex);
return m_entries.size();
}
SizeT GetStoredSourceBytes() const {
const std::lock_guard<std::mutex> lock(m_mutex);
return m_storedSourceBytes;
}
private:
// Shared with ShaderCompileAdoptionMap so the two per-context memos cannot key
// themselves on different notions of "the same compile" - see ShaderSourceKey.h.
using Key = ShaderSourceKey;
using KeyHasher = ShaderSourceKeyHasher;
struct Entry {
Key key;
// The full original (pre-preprocess) source, kept so a hit can be confirmed by
// comparison instead of trusting the hash.
String originalSource;
ShaderPreprocessResultPtr result;
};
using EntryList = std::list<Entry>;
static SizeT EntryBytes(const String& source, const ShaderPreprocessResult& result) {
return source.length() + result.preprocessedSource.length();
}
void EvictUntilWithinBudgetLocked();
void EraseEntryLocked(EntryList::iterator it);
// P1: every public entry point takes this. The lock alone would NOT have been
// enough - the old Find() handed back a raw pointer into an entry that a
// concurrent Insert()'s FIFO eviction could erase while the caller was still
// reading it. Shared ownership of the payload is what closes that hole; the mutex
// only protects the containers below.
mutable std::mutex m_mutex;
EntryList m_entries; // front = oldest (FIFO victim)
UnorderedMap<Key, EntryList::iterator, KeyHasher> m_index;
SizeT m_storedSourceBytes = 0;
};
} // namespace MobileGL::MG_State::GLState
@@ -0,0 +1,51 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderSourceKey.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include <MG_State/GLState/ProgramState/ShaderStage.h>
namespace MobileGL::MG_State::GLState {
// The identity of "one glCompileShader's worth of input" - the tuple that decides
// whether two compiles must produce byte-identical results. Shared by the two
// per-context memos keyed on it, so that neither can drift from the other:
// * P0b's ShaderPreprocessCache, which memoizes the source-only half of a compile;
// * P1 stage 6's ShaderCompileAdoptionMap, which shares the job NODE itself.
//
// The 64-bit source hash is a LOOKUP ACCELERATOR ONLY. Every user of this key confirms
// a candidate hit with a full byte comparison of the stored source before honoring it,
// so a hash collision degrades to a miss and never to a wrong answer. That rule is not
// negotiable - see the memo-hazard notes on ShaderPreprocessCache.
//
// envFingerprint is part of the identity because the pipeline's compute local-size
// verdict is computed against CompileEnv's device limits: a memo must never be handed
// back under an environment other than the one it was computed against.
struct ShaderSourceKey {
ShaderStage stage = ShaderStage::Unknown;
Uint64 sourceHash = 0;
SizeT sourceLength = 0;
Uint64 envFingerprint = 0;
Bool operator==(const ShaderSourceKey& other) const {
return stage == other.stage && sourceHash == other.sourceHash &&
sourceLength == other.sourceLength && envFingerprint == other.envFingerprint;
}
};
struct ShaderSourceKeyHasher {
SizeT operator()(const ShaderSourceKey& key) const {
// The source hash already spreads well; fold the three discriminators in so
// that same-hash-different-stage/length/env keys land in different buckets.
Uint64 mixed = key.sourceHash;
mixed ^= static_cast<Uint64>(key.sourceLength) + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2);
mixed ^= static_cast<Uint64>(static_cast<Int>(key.stage)) * 0xff51afd7ed558ccdull;
mixed ^= key.envFingerprint + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2);
return static_cast<SizeT>(mixed);
}
};
} // namespace MobileGL::MG_State::GLState
@@ -0,0 +1,25 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderStage.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
namespace MobileGL {
// Split out of ShaderObject.h so the compile pipeline's headers form a DAG:
// ShaderStage.h <- ShaderPreprocessCache.h <- ShaderCompileTask.h <- ShaderObject.h.
// Every existing includer of ShaderObject.h still sees this type unchanged.
enum class ShaderStage {
Vertex,
TessControl,
TessEval,
Geometry,
Fragment,
Compute,
ShaderStageCount,
Unknown = -1
};
} // namespace MobileGL
@@ -8,7 +8,18 @@
#include "VertexArrayObject.h"
#include <atomic>
namespace MobileGL::MG_State::GLState {
// Starts at 1 so a zero-initialized memo slot can never carry a live object's id.
// Atomic because VAOs are GL-thread-only today but the counter costs nothing to
// make safe, and a duplicate id would resurrect exactly the bug it exists to kill.
static std::atomic<Uint64> s_nextVertexArrayLifetimeId{1};
Uint64 VertexArrayObject::AllocateLifetimeId() {
return s_nextVertexArrayLifetimeId.fetch_add(1, std::memory_order_relaxed);
}
VertexArrayObject::VertexArrayObject(Uint externIndex) : m_externalIndex(externIndex) {
for (int index = 0; index < MAX_VERTEX_ATTRIBS; ++index) {
auto& attr = m_attributes[index];
@@ -84,6 +84,18 @@ namespace MobileGL {
Uint GetExternalIndex() const;
// Globally-unique, never-reused id for THIS object's lifetime - the same
// contract as ProgramObject::GetLifetimeId(), and needed for the same
// reason. Neither the GL name (freed to a LIFO list and handed straight
// back by the next glGenVertexArrays) nor the heap address (freed to the
// allocator and handed straight back by the next allocation of this size)
// can tell a deleted-and-recreated VAO from the original, so a backend
// memo keyed on either one silently inherits the dead object's contents.
// That is not hypothetical: it is what let a transform-feedback capture
// fetch a destroyed VAO's vertex buffer slice (see the VaoDrawMemo key in
// DirectVulkan's VulkanRenderer).
Uint64 GetLifetimeId() const { return m_lifetimeId; }
void SetAttributeDivisor(Uint index, Uint divisor);
Uint GetAttributeDivisor(Uint index) const;
@@ -185,7 +197,10 @@ namespace MobileGL {
return mapping;
}
static Uint64 AllocateLifetimeId();
const Uint m_externalIndex = 0;
const Uint64 m_lifetimeId = AllocateLifetimeId();
Array<VertexAttribute, MAX_VERTEX_ATTRIBS> m_attributes;
Array<VertexAttributeVersion, MAX_VERTEX_ATTRIBS> m_attributeVersions;
BindingSlot<BufferObject> m_indexBufferBindingSlot;
@@ -598,3 +598,61 @@ TEST(TextureAnisotropyCapabilities, ExtensionPresenceIsDetectedExactly) {
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(presentCaps, funcs));
EXPECT_TRUE(presentCaps.SupportsTextureFilterAnisotropy);
}
// eglGetProcAddress may return a non-NULL stub for an entry point the context does not
// implement (NVIDIA's ES driver does exactly that for glMultiDrawElementsBaseVertexEXT and
// the stub silently drops draws), so a resolved pointer must NEVER flip these flags on its
// own: the extension string is the authority, and the pointer only confirms callability.
TEST(MultiDrawCapabilities, PointerAloneNeverCountsAsSupport) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
auto funcs = MakeFakeGLESFunctions();
// Simulate the stub hazard: every pointer resolved, no extension advertised.
funcs.glMultiDrawArraysIndirectEXT = [](GLenum, const void*, GLsizei, GLsizei) {};
funcs.glMultiDrawElementsIndirectEXT = [](GLenum, GLenum, const void*, GLsizei, GLsizei) {};
funcs.glMultiDrawElementsBaseVertexEXT = [](GLenum, const GLsizei*, GLenum, const void* const*,
GLsizei, const GLint*) {};
MobileGL::MG_External::GLESCapabilities stubCaps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(stubCaps, funcs));
EXPECT_FALSE(stubCaps.SupportsMultiDrawIndirect);
EXPECT_FALSE(stubCaps.SupportsMultiDrawElementsBaseVertex);
// The real NVIDIA shape: both draw_elements_base_vertex extensions advertised but
// GL_EXT_multi_draw_arrays missing, so glMultiDrawElementsBaseVertexEXT (added only by
// their interaction with GL_EXT_multi_draw_arrays) is still a stub.
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_EXT_draw_elements_base_vertex");
g_fake.extensions.emplace_back("GL_OES_draw_elements_base_vertex");
MobileGL::MG_External::GLESCapabilities nvidiaShapedCaps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(nvidiaShapedCaps, funcs));
EXPECT_FALSE(nvidiaShapedCaps.SupportsMultiDrawElementsBaseVertex);
// Fully supported: extensions advertised and pointers resolved.
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_EXT_multi_draw_indirect");
g_fake.extensions.emplace_back("GL_OES_draw_elements_base_vertex");
g_fake.extensions.emplace_back("GL_EXT_multi_draw_arrays");
MobileGL::MG_External::GLESCapabilities supportedCaps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(supportedCaps, funcs));
EXPECT_TRUE(supportedCaps.SupportsMultiDrawIndirect);
EXPECT_TRUE(supportedCaps.SupportsMultiDrawElementsBaseVertex);
}
TEST(MultiDrawCapabilities, ExtensionWithoutResolvedPointerIsNotSupport) {
// Extensions advertised but the loader could not resolve the entry points (default fake
// table leaves them null): the flags must stay false so no caller dereferences null.
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_EXT_multi_draw_indirect");
g_fake.extensions.emplace_back("GL_EXT_draw_elements_base_vertex");
g_fake.extensions.emplace_back("GL_EXT_multi_draw_arrays");
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_FALSE(caps.SupportsMultiDrawIndirect);
EXPECT_FALSE(caps.SupportsMultiDrawElementsBaseVertex);
}
+311 -4
View File
@@ -455,16 +455,24 @@ TEST_F(BufferTest, BindBufferBaseZeroUnbindsBindingPoint) {
}
TEST_F(BufferTest, BindBufferRangeZeroUnbindsBindingPoint) {
// GL_SHADER_STORAGE_BUFFER offsets must be a multiple of
// GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, so the offset cannot be a literal.
GLint ssboAlignment = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &ssboAlignment);
ASSERT_GT(ssboAlignment, 0);
const GLintptr offset = ssboAlignment;
const GLsizeiptr size = 8;
GLuint buffer = 0;
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, 16, nullptr, GL_DYNAMIC_DRAW);
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, offset + size, nullptr, GL_DYNAMIC_DRAW);
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 3, buffer, 4, 8);
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 3, buffer, offset, size);
auto& point = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, 3);
ASSERT_NE(point.GetBoundObject(), nullptr);
EXPECT_EQ(point.GetRange().start, 4);
EXPECT_EQ(point.GetRange().end, 12);
EXPECT_EQ(point.GetRange().start, static_cast<SizeT>(offset));
EXPECT_EQ(point.GetRange().end, static_cast<SizeT>(offset + size));
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 3, 0, 0, 0);
EXPECT_EQ(point.GetBoundObject(), nullptr);
@@ -540,6 +548,305 @@ TEST_F(BufferTest, ClearNamedBufferSubDataRepeatsPattern) {
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
// GL 4.6 core 6.5: glBufferSubData fails only when the written range OVERLAPS the mapped range.
// A second, wrong test used to sit next to the correct one and reject any write whose end reached
// the start of the mapping - which killed every legal disjoint update in front of a mapped tail.
TEST_F(BufferTest, BufferSubDataRejectsOnlyRangesOverlappingTheMapping) {
GLuint buffer = 0;
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_ARRAY_BUFFER, buffer);
MobileGL::MG_Impl::GLImpl::BufferData(GL_ARRAY_BUFFER, 64, nullptr, GL_DYNAMIC_DRAW);
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
void* mapped = MobileGL::MG_Impl::GLImpl::MapBufferRange(GL_ARRAY_BUFFER, 32, 32, GL_MAP_WRITE_BIT);
ASSERT_NE(mapped, nullptr);
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// Entirely before the mapping: legal, and the bytes must land.
const Uint32 payload[4] = {1u, 2u, 3u, 4u};
MobileGL::MG_Impl::GLImpl::BufferSubData(GL_ARRAY_BUFFER, 0, sizeof(payload), payload);
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// Touching the first mapped byte: overlap, so INVALID_OPERATION.
MobileGL::MG_Impl::GLImpl::BufferSubData(GL_ARRAY_BUFFER, 16, 32, payload);
ExpectSingleGlError(GL_INVALID_OPERATION);
EXPECT_TRUE(MobileGL::MG_Impl::GLImpl::UnmapBuffer(GL_ARRAY_BUFFER));
Vector<Uint32> actual(4, 0);
auto bufferObject = MobileGL::MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
Memcpy(actual.data(), bufferObject->AcquireMemory(false, true, false), sizeof(payload));
EXPECT_EQ(actual, (Vector<Uint32>{1u, 2u, 3u, 4u}));
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_ARRAY_BUFFER, 0);
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
DrainPendingGlErrors();
}
// GL 4.6 core 6.2: "no buffer bound to target" outranks a bad size or bad flags, so the binding has
// to be resolved before either is validated. It used to be checked last, which turned every
// unbound-target call into INVALID_VALUE.
TEST_F(BufferTest, BufferStorageReportsTheUnboundTargetBeforeSizeAndFlags) {
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_ARRAY_BUFFER, 0);
DrainPendingGlErrors();
// Both a zero size and a nonsense flag set are present; the unbound target still wins.
MobileGL::MG_Impl::GLImpl::BufferStorage(GL_ARRAY_BUFFER, 0, nullptr, GL_MAP_PERSISTENT_BIT);
ExpectSingleGlError(GL_INVALID_OPERATION);
// With a buffer bound, the size check is reachable again.
GLuint buffer = 0;
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_ARRAY_BUFFER, buffer);
MobileGL::MG_Impl::GLImpl::BufferStorage(GL_ARRAY_BUFFER, 0, nullptr, GL_MAP_READ_BIT);
ExpectSingleGlError(GL_INVALID_VALUE);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_ARRAY_BUFFER, 0);
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
DrainPendingGlErrors();
}
// GL 4.6 core 6.1.1: glBindBufferRange on GL_SHADER_STORAGE_BUFFER must reject an offset that is
// not a multiple of GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT.
TEST_F(BufferTest, BindBufferRangeRejectsMisalignedShaderStorageOffset) {
GLint ssboAlignment = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &ssboAlignment);
ASSERT_GT(ssboAlignment, 1) << "a 1-byte alignment cannot express a misaligned offset";
GLuint buffer = 0;
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, ssboAlignment * 4, nullptr, GL_DYNAMIC_DRAW);
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 1, buffer, 1, ssboAlignment);
ExpectSingleGlError(GL_INVALID_VALUE);
auto& point = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, 1);
EXPECT_EQ(point.GetBoundObject(), nullptr) << "a rejected bind must not take effect";
// The uniform target has its own alignment and must not inherit the SSBO rule's rejection.
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 1, buffer, ssboAlignment, ssboAlignment);
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_NE(point.GetBoundObject(), nullptr);
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 1, 0, 0, 0);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
DrainPendingGlErrors();
}
// ARB_multi_bind: the [first, first + count) range is checked up front and reports
// INVALID_OPERATION - not the per-element INVALID_VALUE a naive loop over glBindBufferBase would
// produce, and nothing may be bound when it fails.
TEST_F(BufferTest, BindBuffersBaseChecksTheWholeRangeBeforeBindingAnything) {
GLint maxBindings = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS, &maxBindings);
ASSERT_GT(maxBindings, 1);
GLuint buffer = 0;
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, 16, nullptr, GL_DYNAMIC_DRAW);
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// first is in range but first + count is not: one error, of the multi-bind class.
const GLuint first = static_cast<GLuint>(maxBindings - 1);
const GLuint buffers[2] = {buffer, buffer};
MobileGL::MG_Impl::GLImpl::BindBuffersBase(GL_SHADER_STORAGE_BUFFER, first, 2, buffers);
ExpectSingleGlError(GL_INVALID_OPERATION);
auto& point = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, first);
EXPECT_EQ(point.GetBoundObject(), nullptr) << "the in-range prefix must not be bound either";
MobileGL::MG_Impl::GLImpl::BindBuffersRange(GL_SHADER_STORAGE_BUFFER, first, 2, buffers, nullptr, nullptr);
ExpectSingleGlError(GL_INVALID_OPERATION);
// A range that fits binds normally.
MobileGL::MG_Impl::GLImpl::BindBuffersBase(GL_SHADER_STORAGE_BUFFER, first, 1, buffers);
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
ASSERT_NE(point.GetBoundObject(), nullptr);
EXPECT_EQ(point.GetBoundObject()->GetExternalIndex(), buffer);
MobileGL::MG_Impl::GLImpl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, first, 0);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
DrainPendingGlErrors();
}
// These limits were reachable only through glGetInteger64v (SSBO block size) or not at all (the
// atomic-counter pair), so glGetIntegerv answered them with INVALID_ENUM out of its default arm.
TEST_F(BufferTest, GetIntegervAnswersSsboAndAtomicCounterLimits) {
GLint ssboBlockSize = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MAX_SHADER_STORAGE_BLOCK_SIZE, &ssboBlockSize);
EXPECT_GT(ssboBlockSize, 0);
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// The 32-bit query saturates rather than truncating what glGetInteger64v reports.
GLint64 ssboBlockSize64 = 0;
MobileGL::MG_Impl::GLImpl::GetInteger64v(GL_MAX_SHADER_STORAGE_BLOCK_SIZE, &ssboBlockSize64);
EXPECT_EQ(static_cast<GLint64>(ssboBlockSize), std::min<GLint64>(ssboBlockSize64, INT32_MAX));
GLint atomicBindings = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS, &atomicBindings);
EXPECT_GE(atomicBindings, 1);
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
GLint atomicBufferSize = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE, &atomicBufferSize);
EXPECT_GE(atomicBufferSize, 32); // GL 4.6 table 23.63 minimum
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// KHR_debug requires these to be legal even while the debug entry points are stubs.
GLint debugGroupDepth = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MAX_DEBUG_GROUP_STACK_DEPTH, &debugGroupDepth);
EXPECT_GE(debugGroupDepth, 64);
GLint debugLoggedMessages = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MAX_DEBUG_LOGGED_MESSAGES, &debugLoggedMessages);
EXPECT_GE(debugLoggedMessages, 1);
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
// GL 4.6 core 6.1.1: glBindBufferRange validates the (offset, size) pair before it writes any
// state. Nothing validated either one, so a negative offset reached Range1D(offset, offset + size)
// - which has no ordering check of its own - and a zero or negative size installed an empty or
// backwards range on the binding point.
TEST_F(BufferTest, BindBufferRangeRejectsNegativeOffsetAndNonPositiveSize) {
GLint ssboAlignment = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &ssboAlignment);
ASSERT_GT(ssboAlignment, 0);
GLuint buffer = 0;
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, ssboAlignment * 4, nullptr, GL_DYNAMIC_DRAW);
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
auto& point = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, 2);
// A negative offset is INVALID_VALUE - including one that is a multiple of the alignment, which
// the modulo gate alone waves through (-alignment % alignment == 0).
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, buffer, -ssboAlignment, ssboAlignment);
ExpectSingleGlError(GL_INVALID_VALUE);
EXPECT_EQ(point.GetBoundObject(), nullptr) << "a rejected bind must not take effect";
// size must be strictly positive.
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, buffer, 0, 0);
ExpectSingleGlError(GL_INVALID_VALUE);
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, buffer, 0, -4);
ExpectSingleGlError(GL_INVALID_VALUE);
EXPECT_EQ(point.GetBoundObject(), nullptr);
// The well-formed bind still goes through.
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, buffer, ssboAlignment, ssboAlignment);
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
ASSERT_NE(point.GetBoundObject(), nullptr);
EXPECT_EQ(point.GetRange().start, static_cast<SizeT>(ssboAlignment));
EXPECT_EQ(point.GetRange().end, static_cast<SizeT>(ssboAlignment * 2));
// Buffer 0 detaches with offset and size ignored: the one case the size rule must not fire on,
// and the shape glBindBuffersRange uses to reset an element.
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, 0, 0, 0);
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_EQ(point.GetBoundObject(), nullptr);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
DrainPendingGlErrors();
}
// GL 4.6 core 6.1.1 gives GL_UNIFORM_BUFFER its own offset alignment
// (GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT) and requires BOTH offset and size to be multiples of 4 on
// GL_TRANSFORM_FEEDBACK_BUFFER. Only the shader-storage half of the rule was implemented, so a
// misaligned uniform range bound happily.
TEST_F(BufferTest, BindBufferRangeEnforcesUniformAndTransformFeedbackAlignment) {
GLint uboAlignment = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &uboAlignment);
ASSERT_GT(uboAlignment, 1) << "a 1-byte alignment cannot express a misaligned offset";
GLuint buffer = 0;
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_UNIFORM_BUFFER, buffer);
MobileGL::MG_Impl::GLImpl::BufferData(GL_UNIFORM_BUFFER, uboAlignment * 4, nullptr, GL_DYNAMIC_DRAW);
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
auto& uniformPoint = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::Uniform, 1);
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_UNIFORM_BUFFER, 1, buffer, 1, uboAlignment);
ExpectSingleGlError(GL_INVALID_VALUE);
EXPECT_EQ(uniformPoint.GetBoundObject(), nullptr) << "a misaligned uniform range must not bind";
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_UNIFORM_BUFFER, 1, buffer, uboAlignment, uboAlignment);
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_NE(uniformPoint.GetBoundObject(), nullptr);
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_UNIFORM_BUFFER, 1, 0, 0, 0);
EXPECT_EQ(uniformPoint.GetBoundObject(), nullptr);
// Transform feedback captures 32-bit components: offset and size are both constrained, and the
// size half has no analogue on any other target.
auto& feedbackPoint =
MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback, 0);
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, buffer, 2, 4);
ExpectSingleGlError(GL_INVALID_VALUE);
EXPECT_EQ(feedbackPoint.GetBoundObject(), nullptr);
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, buffer, 4, 2);
ExpectSingleGlError(GL_INVALID_VALUE);
EXPECT_EQ(feedbackPoint.GetBoundObject(), nullptr);
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, buffer, 4, 4);
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_NE(feedbackPoint.GetBoundObject(), nullptr);
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0, 0, 0);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_UNIFORM_BUFFER, 0);
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
DrainPendingGlErrors();
}
// ARB_multi_bind checks offsets and sizes separately for each binding point: the offending element
// is left unchanged and reports INVALID_VALUE while every other element still binds. Only the
// [first, first + count) range is the up-front, all-or-nothing check - so glBindBuffersRange gets
// the new gates by looping over the single-bind entry point, and must keep going after one fails.
TEST_F(BufferTest, BindBuffersRangeAppliesTheOffsetAndSizeGatesPerElement) {
GLint ssboAlignment = 0;
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &ssboAlignment);
ASSERT_GT(ssboAlignment, 1) << "a 1-byte alignment cannot express a misaligned offset";
GLuint buffer = 0;
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, ssboAlignment * 8, nullptr, GL_DYNAMIC_DRAW);
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
auto& firstPoint = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, 0);
auto& secondPoint = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, 1);
const GLuint buffers[2] = {buffer, buffer};
// Element 0 is misaligned; element 1 is well formed and must still be bound.
const GLintptr misalignedOffsets[2] = {1, ssboAlignment};
const GLsizeiptr sizes[2] = {ssboAlignment, ssboAlignment};
MobileGL::MG_Impl::GLImpl::BindBuffersRange(GL_SHADER_STORAGE_BUFFER, 0, 2, buffers, misalignedOffsets, sizes);
ExpectSingleGlError(GL_INVALID_VALUE);
EXPECT_EQ(firstPoint.GetBoundObject(), nullptr) << "the rejected element must not bind";
ASSERT_NE(secondPoint.GetBoundObject(), nullptr) << "a per-element error must not abort the rest of the range";
EXPECT_EQ(secondPoint.GetRange().start, static_cast<SizeT>(ssboAlignment));
MobileGL::MG_Impl::GLImpl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, 0);
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// Same for a non-positive size, on the other element this time.
const GLintptr offsets[2] = {0, ssboAlignment};
const GLsizeiptr badSizes[2] = {ssboAlignment, 0};
MobileGL::MG_Impl::GLImpl::BindBuffersRange(GL_SHADER_STORAGE_BUFFER, 0, 2, buffers, offsets, badSizes);
ExpectSingleGlError(GL_INVALID_VALUE);
EXPECT_NE(firstPoint.GetBoundObject(), nullptr);
EXPECT_EQ(secondPoint.GetBoundObject(), nullptr);
MobileGL::MG_Impl::GLImpl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
DrainPendingGlErrors();
}
using namespace MobileGL::MG_Impl::GLImpl;
class GeneralBufferTest : public ::testing::Test {
+4
View File
@@ -66,6 +66,9 @@ gtest_discover_tests(SanityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
add_subdirectory(BackendLoader)
add_subdirectory(Buffer)
# The heap-address-is-not-an-identity invariant the backends' per-object memos
# rest on. No GL context, no driver: it only needs the allocator.
add_subdirectory(State)
add_subdirectory(EGLState)
add_subdirectory(Framebuffer)
add_subdirectory(Texture)
@@ -74,6 +77,7 @@ add_subdirectory(Program)
add_subdirectory(Query)
add_subdirectory(Pipeline)
add_subdirectory(ShaderTranspiler)
add_subdirectory(Util)
if (ENABLE_INTEGRATION_TESTS)
add_subdirectory(Backend/DirectVulkan)
endif()
@@ -13,10 +13,13 @@
#include "Includes.h"
#include "Init.h"
#include <MG_Backend/BackendObjects.h>
#include <MG_Backend/DirectGLES/DirectGLES.h>
#include <MG_Backend/DirectGLES/Managers.h>
#include <MG_Backend/DirectGLES/Utils.h>
#include <MG_Impl/GLImpl/Buffer/GL_Buffer.h>
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <MG_Impl/GLImpl/RenderState/GL_RenderState.h>
#include <MG_Impl/GLImpl/Texture/GL_Texture.h>
#include <MG_State/GLState/Core.h>
@@ -858,3 +861,394 @@ TEST_F(FramebufferTest, NonRenderableColorFormatsReportUnsupportedFramebuffer) {
MG_Impl::GLImpl::ReadPixels(0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, pixelStorage);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_FRAMEBUFFER_OPERATION);
}
// ---- Three-channel colour attachments: the Complementary Reimagined / Iris load failure --------
//
// Complementary declares colortex1 = RGB8_SNORM and colortex2 = RGB16F. No real OpenGL ES driver
// renders to a three-channel image (EXT_render_snorm covers R/RG/RGBA only; EXT_color_buffer_float
// excludes RGB16F), so the DirectGLES probe records those formats as creatable-but-not-renderable
// and the frontend answered every framebuffer built from them GL_FRAMEBUFFER_UNSUPPORTED - which
// Iris turns into a hard "Draw buffers [0, 1] Status: 36061" load failure. The backend now records
// the four-channel substitution it will actually allocate as a caveat capability, and the frontend
// has to accept that as renderable.
namespace {
class ThreeChannelAttachmentBackend final : public MG_Backend::BackendObject {
public:
// `substituted` stands in for a driver where the four-channel widening probe succeeded, i.e.
// for what PopulateFormatCapabilitiesImpl records on Mali. false is the pre-fix state: the
// native form is creatable, nothing is renderable, and no fallback was ever built.
explicit ThreeChannelAttachmentBackend(Bool substituted) {
auto& cache = MutableFormatCapabilities();
const auto texture2DIndex = MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::Texture2D);
// IsColorInternalFormatRenderable only trusts the cache once it looks populated, which
// it decides from RGBA8 being creatable somewhere. Without this the static deny-list
// answers instead and the caveat below would never be consulted.
const auto rgba8Index = static_cast<SizeT>(TextureInternalFormat::RGBA8);
cache.FullCaps[texture2DIndex][rgba8Index] |= MG_Backend::FormatCapability::Creatable;
cache.FullCaps[texture2DIndex][rgba8Index] |= MG_Backend::FormatCapability::FramebufferRenderable;
cache.FullCaps[texture2DIndex][rgba8Index] |= MG_Backend::FormatCapability::ColorAttachment;
for (const TextureInternalFormat format :
{TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16F}) {
const auto formatIndex = static_cast<SizeT>(format);
// Creatable and samplable as an ordinary texture, but the driver's
// glCheckFramebufferStatus said no - exactly Mali r32p1's answer.
cache.FullCaps[texture2DIndex][formatIndex] |= MG_Backend::FormatCapability::Creatable;
cache.FullCaps[texture2DIndex][formatIndex] |= MG_Backend::FormatCapability::Sampled;
if (substituted) {
cache.CaveatCaps[texture2DIndex][formatIndex] |=
MG_Backend::FormatCapability::FramebufferRenderable;
cache.CaveatCaps[texture2DIndex][formatIndex] |= MG_Backend::FormatCapability::ColorAttachment;
}
}
}
void Initialize() override {}
Bool InitCapabilities() override { return true; }
Bool InitWindowSurface() override { return true; }
const RendererInfo& GetRendererInfo() const override {
static RendererInfo info = {};
return info;
}
String GetBackendAPIVersionString() const override { return {}; }
const MG_Backend::GlobalBackendFunctionsTable& GetBackendFunctions() const override {
static MG_Backend::GlobalBackendFunctionsTable table = {};
return table;
}
const MG_Backend::DynamicBackendParameters& GetDynamicParameters() const override {
static MG_Backend::DynamicBackendParameters params = {};
return params;
}
BackendType GetBackendType() const override { return BackendType::Unknown; }
};
class ScopedBackendOverride {
public:
explicit ScopedBackendOverride(UniquePtr<MG_Backend::BackendObject> backend):
m_previous(Move(MG_Backend::pActiveBackendObject)) {
MG_Backend::pActiveBackendObject = Move(backend);
}
~ScopedBackendOverride() { MG_Backend::pActiveBackendObject = Move(m_previous); }
private:
UniquePtr<MG_Backend::BackendObject> m_previous;
};
GLenum CheckSingleColorAttachmentStatus(GLenum internalFormat) {
GLuint framebuffer = 0;
GLuint texture = 0;
MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer);
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &texture);
MG_Impl::GLImpl::TextureStorage2D(texture, 1, internalFormat, 4, 4);
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, texture, 0);
return MG_Impl::GLImpl::CheckNamedFramebufferStatus(framebuffer, GL_DRAW_FRAMEBUFFER);
}
} // namespace
TEST_F(FramebufferTest, ThreeChannelColorAttachmentsAreUnsupportedWithoutTheWidenedSubstitution) {
// The pre-fix behaviour, pinned so a regression is a red test rather than a shaderpack that
// silently stops loading: no caveat capability, so nothing makes these renderable.
ScopedBackendOverride backend(MakeUnique<ThreeChannelAttachmentBackend>(/*substituted=*/false));
EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB8_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_UNSUPPORTED));
EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB16F), static_cast<GLenum>(GL_FRAMEBUFFER_UNSUPPORTED));
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
TEST_F(FramebufferTest, ThreeChannelColorAttachmentsAreCompleteThroughTheWidenedSubstitution) {
ScopedBackendOverride backend(MakeUnique<ThreeChannelAttachmentBackend>(/*substituted=*/true));
// Complementary's colortex1 (RGB8_SNORM) and colortex2 (RGB16F): both must come out COMPLETE,
// because the backend stores them as GL_RGBA16F. Shipping only the first would move the
// failure one composite pass down instead of fixing it.
EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB8_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB16F), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
TEST_F(FramebufferTest, TwoAttachmentCompositeFramebufferMatchesIrisComplementaryPass) {
// The exact framebuffer Iris failed on: Complementary's `composite` pass draws to colortex7
// (RGBA16F, natively renderable) and colortex1 (RGB8_SNORM, only renderable widened). Iris
// logs it as "Draw buffers [0, 1]" - a two-attachment FBO, not colortex 0 and 1.
ScopedBackendOverride backend(MakeUnique<ThreeChannelAttachmentBackend>(/*substituted=*/true));
GLuint framebuffer = 0;
GLuint colortex7 = 0;
GLuint colortex1 = 0;
MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer);
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex7);
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex1);
MG_Impl::GLImpl::TextureStorage2D(colortex7, 1, GL_RGBA8, 4, 4);
MG_Impl::GLImpl::TextureStorage2D(colortex1, 1, GL_RGB8_SNORM, 4, 4);
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, colortex7, 0);
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT1, colortex1, 0);
EXPECT_EQ(MG_Impl::GLImpl::CheckNamedFramebufferStatus(framebuffer, GL_DRAW_FRAMEBUFFER),
static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
// Both entry points answer from the same helpers, and CheckFramebufferStatus is what Iris
// actually calls; they are near-verbatim duplicates, so assert they agree.
MG_Impl::GLImpl::BindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffer);
EXPECT_EQ(MG_Impl::GLImpl::CheckFramebufferStatus(GL_DRAW_FRAMEBUFFER),
static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
// ---- Widened attachments: the stored-alpha discipline -----------------------------------------
//
// A widened attachment has a real alpha channel the application's three-channel format does not,
// and GL says a channel a format lacks reads back as 1.0. glReadPixels and glGetTexImage can be
// made to say that (ForceWideReadAlphaToOne), but GL_DST_ALPHA / GL_ONE_MINUS_DST_ALPHA blending
// and glBlitFramebuffer read the STORED alpha inside the driver, where nothing can intercept it.
// So the stored alpha is held at 1.0 instead: a clear writes 1.0 into it, and every draw has that
// buffer's alpha write mask forced off so nothing can move it again.
//
// These cases pin the two halves of that pairing at the seam where they are visible - what the ES
// driver is actually handed - and pin the invariant that the application's own colour mask is
// never touched.
namespace {
struct RecordedColorMask {
Bool seen = false;
GLboolean r = GL_FALSE, g = GL_FALSE, b = GL_FALSE, a = GL_FALSE;
};
constexpr Uint kRecordedDrawBuffers = 8;
RecordedColorMask g_driverIndexedColorMasks[kRecordedDrawBuffers];
RecordedColorMask g_driverUniformColorMask;
void ResetRecordedColorMasks() {
for (auto& recorded : g_driverIndexedColorMasks) recorded = {};
g_driverUniformColorMask = {};
}
void StubColorMask(GLboolean r, GLboolean g, GLboolean b, GLboolean a) {
g_driverUniformColorMask = {true, r, g, b, a};
// The non-indexed call sets every draw buffer, so record it as such: a later assertion
// about draw buffer 1 must not read a stale indexed record the uniform push overwrote.
for (auto& recorded : g_driverIndexedColorMasks) recorded = {true, r, g, b, a};
}
void StubColorMaski(GLuint index, GLboolean r, GLboolean g, GLboolean b, GLboolean a) {
if (index < kRecordedDrawBuffers) g_driverIndexedColorMasks[index] = {true, r, g, b, a};
}
void StubViewport(GLint, GLint, GLsizei, GLsizei) {}
void StubScissor(GLint, GLint, GLsizei, GLsizei) {}
void StubEnable(GLenum) {}
void StubDisable(GLenum) {}
void StubEnablei(GLenum, GLuint) {}
void StubDisablei(GLenum, GLuint) {}
void StubBlendFuncSeparate(GLenum, GLenum, GLenum, GLenum) {}
void StubBlendFuncSeparatei(GLuint, GLenum, GLenum, GLenum, GLenum) {}
void StubBlendEquationSeparate(GLenum, GLenum) {}
void StubBlendEquationSeparatei(GLuint, GLenum, GLenum) {}
void StubBlendColor(GLfloat, GLfloat, GLfloat, GLfloat) {}
void StubDepthFunc(GLenum) {}
void StubDepthMask(GLboolean) {}
void StubDepthRangef(GLfloat, GLfloat) {}
void StubStencilFuncSeparate(GLenum, GLenum, GLint, GLuint) {}
void StubStencilMaskSeparate(GLenum, GLuint) {}
void StubStencilOpSeparate(GLenum, GLenum, GLenum, GLenum) {}
void StubClearColor(GLfloat, GLfloat, GLfloat, GLfloat) {}
void StubClearDepthf(GLfloat) {}
void StubClearStencil(GLint) {}
void StubCullFace(GLenum) {}
void StubFrontFace(GLenum) {}
void StubPolygonOffset(GLfloat, GLfloat) {}
void StubLineWidth(GLfloat) {}
void StubSampleCoverage(GLfloat, GLboolean) {}
// Replaces the ES function table with no-ops that record only what these cases assert on.
// The table is ZEROED first on purpose: SyncRenderState is long, and a call it makes that
// this fixture did not anticipate must crash here rather than silently reach a stale pointer
// into a driver that this process never made current.
class ScopedRenderStateDriverStubs {
public:
ScopedRenderStateDriverStubs():
m_funcs(MG_Backend::DirectGLES::g_GLESFuncs), m_caps(MG_Backend::DirectGLES::g_GLESCapabilities) {
auto& gl = MG_Backend::DirectGLES::g_GLESFuncs;
gl = MG_External::GLESFunctionsTable{};
gl.glViewport = StubViewport;
gl.glScissor = StubScissor;
gl.glEnable = StubEnable;
gl.glDisable = StubDisable;
gl.glEnablei = StubEnablei;
gl.glDisablei = StubDisablei;
gl.glBlendFuncSeparate = StubBlendFuncSeparate;
gl.glBlendFuncSeparatei = StubBlendFuncSeparatei;
gl.glBlendEquationSeparate = StubBlendEquationSeparate;
gl.glBlendEquationSeparatei = StubBlendEquationSeparatei;
gl.glBlendColor = StubBlendColor;
gl.glDepthFunc = StubDepthFunc;
gl.glDepthMask = StubDepthMask;
gl.glDepthRangef = StubDepthRangef;
gl.glStencilFuncSeparate = StubStencilFuncSeparate;
gl.glStencilMaskSeparate = StubStencilMaskSeparate;
gl.glStencilOpSeparate = StubStencilOpSeparate;
gl.glClearColor = StubClearColor;
gl.glClearDepthf = StubClearDepthf;
gl.glClearStencil = StubClearStencil;
gl.glCullFace = StubCullFace;
gl.glFrontFace = StubFrontFace;
gl.glPolygonOffset = StubPolygonOffset;
gl.glLineWidth = StubLineWidth;
gl.glSampleCoverage = StubSampleCoverage;
gl.glColorMask = StubColorMask;
gl.glColorMaski = StubColorMaski;
auto& caps = MG_Backend::DirectGLES::g_GLESCapabilities;
caps.SupportsIndexedColorMask = true;
caps.SupportsSrgbWriteControl = false;
caps.SupportsPolygonMode = false;
caps.SupportsDualSourceBlend = true;
ResetRecordedColorMasks();
// The viewport and scissor blocks fall back to querying the surface size when the
// frontend's rectangle is degenerate, and there is no surface in this process.
MG_Impl::GLImpl::Viewport(0, 0, 4, 4);
MG_Impl::GLImpl::Scissor(0, 0, 4, 4);
MG_Backend::DirectGLES::RenderStateImpl::InvalidateSyncedRenderState();
}
~ScopedRenderStateDriverStubs() {
MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 0;
MG_Backend::DirectGLES::g_GLESFuncs = m_funcs;
MG_Backend::DirectGLES::g_GLESCapabilities = m_caps;
// The shadow now describes pushes that went to the stubs, not to any driver.
MG_Backend::DirectGLES::RenderStateImpl::InvalidateSyncedRenderState();
MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
}
private:
MG_External::GLESFunctionsTable m_funcs;
MG_External::GLESCapabilities m_caps;
};
} // namespace
TEST_F(FramebufferTest, WidenedDrawBufferIsIdentifiedPerDrawBufferSlotNotPerAttachmentPoint) {
ScopedBackendOverride backend(MakeUnique<ThreeChannelAttachmentBackend>(/*substituted=*/true));
// Complementary's `composite` framebuffer again: draw buffer 0 is a natively renderable
// RGBA8, draw buffer 1 is the widened RGB8_SNORM. Only the second may be doctored.
GLuint framebuffer = 0;
GLuint colortex7 = 0;
GLuint colortex1 = 0;
MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer);
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex7);
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex1);
MG_Impl::GLImpl::TextureStorage2D(colortex7, 1, GL_RGBA8, 4, 4);
MG_Impl::GLImpl::TextureStorage2D(colortex1, 1, GL_RGB8_SNORM, 4, 4);
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, colortex7, 0);
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT1, colortex1, 0);
auto& framebufferObject = MG_State::pGLContext->GetFramebufferObject(framebuffer);
ASSERT_NE(framebufferObject, nullptr);
framebufferObject->SetDrawBuffer(0, FramebufferAttachmentType::Color0);
framebufferObject->SetDrawBuffer(1, FramebufferAttachmentType::Color1);
EXPECT_EQ(MG_Backend::DirectGLES::FramebufferImpl::ComputeAlphaWidenedDrawBufferMask(*framebufferObject),
1u << 1);
// Swapping the draw-buffer array moves the bit with the SLOT, not with the attachment point:
// glColorMaski and glClearBufferfv both address slots.
framebufferObject->SetDrawBuffer(0, FramebufferAttachmentType::Color1);
framebufferObject->SetDrawBuffer(1, FramebufferAttachmentType::Color0);
EXPECT_EQ(MG_Backend::DirectGLES::FramebufferImpl::ComputeAlphaWidenedDrawBufferMask(*framebufferObject),
1u << 0);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
TEST_F(FramebufferTest, DrawIntoAWidenedDrawBufferReachesTheDriverWithAlphaWritesMaskedOff) {
ScopedRenderStateDriverStubs driver;
MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 1u << 1;
// What the application asked for: write every channel of every draw buffer.
MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
// What the driver was told. Draw buffer 0 is untouched; draw buffer 1 loses alpha.
ASSERT_TRUE(g_driverIndexedColorMasks[0].seen);
EXPECT_EQ(g_driverIndexedColorMasks[0].r, GL_TRUE);
EXPECT_EQ(g_driverIndexedColorMasks[0].g, GL_TRUE);
EXPECT_EQ(g_driverIndexedColorMasks[0].b, GL_TRUE);
EXPECT_EQ(g_driverIndexedColorMasks[0].a, GL_TRUE);
ASSERT_TRUE(g_driverIndexedColorMasks[1].seen);
EXPECT_EQ(g_driverIndexedColorMasks[1].r, GL_TRUE);
EXPECT_EQ(g_driverIndexedColorMasks[1].g, GL_TRUE);
EXPECT_EQ(g_driverIndexedColorMasks[1].b, GL_TRUE);
EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE) << "a widened draw buffer must not take alpha writes";
// And what the application sees back. The doctoring lives entirely on the push; the frontend
// state it is derived from is never written, so glGet still answers with the app's value.
GLboolean appMask[4] = {GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE};
MG_Impl::GLImpl::GetBooleanv(GL_COLOR_WRITEMASK, appMask);
EXPECT_EQ(appMask[0], GL_TRUE);
EXPECT_EQ(appMask[1], GL_TRUE);
EXPECT_EQ(appMask[2], GL_TRUE);
EXPECT_EQ(appMask[3], GL_TRUE) << "glGet(GL_COLOR_WRITEMASK) must report the application's mask";
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
TEST_F(FramebufferTest, ClearIntoAWidenedDrawBufferKeepsAlphaWritableAndSubstitutesOne) {
ScopedRenderStateDriverStubs driver;
MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 1u << 1;
MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
// A draw first, so the mask really is doctored when the clear arrives...
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
ASSERT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE);
// ...and now the clear, with NOTHING changed in the frontend parameter block. The frontend's
// render-state version has not moved, so only the purpose-aware memo can force this push -
// without it the clear would inherit the draw's alpha-off mask and never write the 1.0.
ResetRecordedColorMasks();
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true);
ASSERT_TRUE(g_driverIndexedColorMasks[1].seen) << "the clear must re-push the colour mask";
EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_TRUE) << "a clear is what puts the 1.0 in the stored alpha";
// The value that clear writes: the application's RGB, alpha replaced by the 1.0 the
// three-channel format implies, and only on the widened buffer.
const GLfloat appColor[4] = {0.25f, 0.5f, 0.75f, 0.0f};
GLfloat scratch[4] = {};
const GLfloat* widened =
MG_Backend::DirectGLES::FramebufferImpl::SubstituteWidenedClearAlpha(appColor, true, 1.0f, scratch);
EXPECT_EQ(widened[0], 0.25f);
EXPECT_EQ(widened[1], 0.5f);
EXPECT_EQ(widened[2], 0.75f);
EXPECT_EQ(widened[3], 1.0f);
const GLfloat* untouched =
MG_Backend::DirectGLES::FramebufferImpl::SubstituteWidenedClearAlpha(appColor, false, 1.0f, scratch);
EXPECT_EQ(untouched, appColor) << "a native attachment's clear must not even be copied";
// An integer widened format (GL_RGB8UI -> GL_RGBA8UI) carries the INTEGER one, not a
// saturated field: glClearBufferuiv takes the value verbatim.
const GLuint appIntegerColor[4] = {7u, 8u, 9u, 0u};
GLuint integerScratch[4] = {};
const GLuint* widenedInteger = MG_Backend::DirectGLES::FramebufferImpl::SubstituteWidenedClearAlpha(
appIntegerColor, true, GLuint(1), integerScratch);
EXPECT_EQ(widenedInteger[3], 1u);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
TEST_F(FramebufferTest, ApplicationAlphaMaskOffIsStillHonouredOnANativeDrawBuffer) {
// The doctoring only ever REMOVES alpha writes on a widened buffer; it must never add them
// back on a buffer the application masked itself, and must never touch a native one.
ScopedRenderStateDriverStubs driver;
MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 1u << 1;
MG_Impl::GLImpl::ColorMaski(0, GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE);
MG_Impl::GLImpl::ColorMaski(1, GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
MG_Impl::GLImpl::ColorMaski(2, GL_FALSE, GL_TRUE, GL_FALSE, GL_TRUE);
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
EXPECT_EQ(g_driverIndexedColorMasks[0].a, GL_FALSE) << "the application's own alpha mask survives";
EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE) << "the widened buffer loses alpha";
EXPECT_EQ(g_driverIndexedColorMasks[2].r, GL_FALSE);
EXPECT_EQ(g_driverIndexedColorMasks[2].g, GL_TRUE);
EXPECT_EQ(g_driverIndexedColorMasks[2].b, GL_FALSE);
EXPECT_EQ(g_driverIndexedColorMasks[2].a, GL_TRUE) << "a native buffer keeps its alpha writes";
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
@@ -0,0 +1,571 @@
// MobileGL - MobileGL/MG_Test/Program/AsyncCompileTest.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
// P1 stage 3: glCompileShader enqueues, and every observable read joins.
//
// Every test here drives the real GL entry points and flips
// MG_Config::Features.AsyncShaderCompile itself rather than reading the environment. That is
// what lets one binary assert the property that actually matters - the async path and the
// synchronous path are indistinguishable through the GL surface - and it makes the file
// behave identically whether or not the suite was launched with
// MOBILEGL_ASYNC_SHADER_COMPILE=1.
#include <gtest/gtest.h>
#include <chrono>
#include <string>
#include <vector>
#include "Config.h"
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_State/GLState/Core.h"
#include "MG_Util/Async/ShaderCompilePool.h"
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
namespace {
// Restores whatever the environment asked for when the test ends, so a case that forces
// one mode cannot leak into the next.
class AsyncModeScope {
public:
explicit AsyncModeScope(const Bool async)
: m_saved(MG_Config::Features.AsyncShaderCompile) {
MG_Config::Features.AsyncShaderCompile =
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
}
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
AsyncModeScope(const AsyncModeScope&) = delete;
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
private:
const MG_Config::QuirkOverride m_saved;
};
const char* kVs = R"(#version 460
layout(location = 0) in vec3 aPos;
uniform mat4 uModel;
uniform vec4 uColor;
out vec4 vColor;
void main() {
vColor = uColor;
gl_Position = uModel * vec4(aPos, 1.0);
}
)";
const char* kFs = R"(#version 460
in vec4 vColor;
layout(location = 0) out vec4 fragColor;
uniform float uAlpha;
void main() { fragColor = vec4(vColor.rgb, vColor.a * uAlpha); }
)";
// Fails in glslang, not in the lexical pre-checks: that routes through the same
// ParseFailed path a real broken shaderpack source takes.
const char* kBrokenFs = R"(#version 460
layout(location = 0) out vec4 fragColor;
void main() { fragColor = thisIdentifierWasNeverDeclared; }
)";
// Rejected by the lexical reserved-identifier scan, before glslang is ever reached - the
// other half of the "compile failed" surface, and the one that never allocates a parse.
const char* kReservedIdentifierFs = R"(#version 460
layout(location = 0) out vec4 fragColor;
float gl_NotAllowedToDeclareThis = 1.0;
void main() { fragColor = vec4(gl_NotAllowedToDeclareThis); }
)";
// Big enough that a compile is not instantaneous, so the pool actually has a backlog to
// observe. Templated on an index so every instance is a distinct source (no P0b hit).
String MakeBulkySource(const int index) {
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
source += "uniform float uSeed" + std::to_string(index) + ";\n";
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
for (int i = 0; i < 220; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
}
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
return source;
}
GLuint MakeShader(const GLenum type, const char* source) {
const GLuint shader = CreateShader(type);
ShaderSource(shader, 1, &source, nullptr);
return shader;
}
GLint QueryCompileStatus(const GLuint shader) {
GLint status = GL_FALSE;
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
return status;
}
String QueryShaderInfoLog(const GLuint shader) {
GLint length = 0;
GetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
if (length <= 0) return String();
std::vector<GLchar> buffer(static_cast<size_t>(length));
GLsizei written = 0;
GetShaderInfoLog(shader, length, &written, buffer.data());
return String(buffer.data(), static_cast<size_t>(written));
}
GLint QueryLinkStatus(const GLuint program) {
GLint status = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &status);
return status;
}
// The non-joining view of the object, i.e. what GL_COMPLETION_STATUS_KHR will report.
Bool CompileIsSettled(const GLuint shader) {
const auto& object = MG_State::pGLContext->GetShaderObject(shader);
return object == nullptr || object->IsCompileComplete();
}
Bool HasMemoizedCompile(const GLuint shader) {
const auto& object = MG_State::pGLContext->GetShaderObject(shader);
return object != nullptr && object->HasMemoizedCompile();
}
// Enqueues `count` distinct heavy compiles and returns their names WITHOUT reading
// anything back, so the pool is left with a real backlog for the caller to race against.
Vector<GLuint> SaturatePool(const int count, Vector<String>& sourceStorage) {
Vector<GLuint> shaders;
shaders.reserve(static_cast<SizeT>(count));
sourceStorage.reserve(sourceStorage.size() + static_cast<SizeT>(count));
for (int i = 0; i < count; ++i) {
sourceStorage.push_back(MakeBulkySource(1000 + i));
const char* text = sourceStorage.back().c_str();
const GLuint shader = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(shader, 1, &text, nullptr);
CompileShader(shader);
shaders.push_back(shader);
}
return shaders;
}
class AsyncCompileTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
};
} // namespace
// ---------------------------------------------------------------------------------------
// Correctness through the full GL surface
// ---------------------------------------------------------------------------------------
// N shaders compiled with the flag on: every status, every info log and every link has to
// come out the same as the synchronous path produces.
TEST_F(AsyncCompileTest, ManyShadersCompileAndLinkCorrectlyWithAsyncOn) {
const AsyncModeScope async(true);
ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled());
constexpr int kCount = 24;
Vector<GLuint> vertexShaders;
Vector<GLuint> fragmentShaders;
Vector<String> sources;
sources.reserve(kCount);
// Enqueue everything first, read nothing: this is the shape a shaderpack load has, and
// the only shape where the pool has more than one job in flight at a time.
for (int i = 0; i < kCount; ++i) {
vertexShaders.push_back(MakeShader(GL_VERTEX_SHADER, kVs));
sources.push_back(MakeBulkySource(i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
fragmentShaders.push_back(fs);
CompileShader(vertexShaders.back());
}
for (int i = 0; i < kCount; ++i) {
EXPECT_EQ(QueryCompileStatus(vertexShaders[i]), GL_TRUE) << QueryShaderInfoLog(vertexShaders[i]);
EXPECT_EQ(QueryCompileStatus(fragmentShaders[i]), GL_TRUE) << QueryShaderInfoLog(fragmentShaders[i]);
EXPECT_TRUE(QueryShaderInfoLog(vertexShaders[i]).empty());
EXPECT_TRUE(QueryShaderInfoLog(fragmentShaders[i]).empty());
}
// And the artifacts are actually usable: link, and reflect a uniform out of each stage.
for (int i = 0; i < kCount; ++i) {
const GLuint program = CreateProgram();
AttachShader(program, vertexShaders[i]);
AttachShader(program, fragmentShaders[i]);
LinkProgram(program);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "program " << i;
EXPECT_GE(GetUniformLocation(program, "uColor"), 0);
EXPECT_GE(GetUniformLocation(program, ("uSeed" + std::to_string(i)).c_str()), 0);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// glCompileShader must return before the work is done. Timing-based assertions flake, so
// this observes the state machine instead: with a saturated pool at least one of the just
// -enqueued shaders has to be unsettled at the moment we ask. Skipped rather than failed if
// the machine drained the whole batch first - it can then never be a false red.
TEST_F(AsyncCompileTest, CompileShaderReturnsBeforeTheWorkIsDone) {
const AsyncModeScope async(true);
Vector<String> sources;
const Vector<GLuint> shaders = SaturatePool(64, sources);
int unsettled = 0;
for (const GLuint shader : shaders) {
if (!CompileIsSettled(shader)) ++unsettled;
}
if (unsettled == 0) {
GTEST_SKIP() << "the pool drained 64 compiles before the first observation; nothing to prove here";
}
// Whatever was outstanding still has to produce the right answer once asked.
for (const GLuint shader : shaders) {
EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE) << QueryShaderInfoLog(shader);
EXPECT_TRUE(CompileIsSettled(shader)) << "reading COMPILE_STATUS must have joined";
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The synchronous path must stay synchronous: with the flag off, a compile is finished by
// the time glCompileShader returns. This is the guard that keeps the default shippable.
TEST_F(AsyncCompileTest, CompileIsFullySynchronousWithAsyncOff) {
const AsyncModeScope async(false);
ASSERT_FALSE(MG_Util::Async::AsyncShaderCompileEnabled());
Vector<String> sources;
const Vector<GLuint> shaders = SaturatePool(8, sources);
for (const GLuint shader : shaders) {
EXPECT_TRUE(CompileIsSettled(shader));
EXPECT_TRUE(HasMemoizedCompile(shader));
EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE) << QueryShaderInfoLog(shader);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// Diagnostics: the failing paths must read identically in both modes
// ---------------------------------------------------------------------------------------
// A compile failure is reported through COMPILE_STATUS and the info log, never through
// glGetError - that is exactly why moving the work off-thread is legal. Both failure
// classes are covered: the glslang parse failure and the lexical reserved-identifier
// rejection (which never reaches glslang at all).
TEST_F(AsyncCompileTest, FailingCompileLogIsByteIdenticalAcrossModes) {
for (const char* source : {kBrokenFs, kReservedIdentifierFs}) {
String syncLog;
{
const AsyncModeScope async(false);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, source);
CompileShader(fs);
ASSERT_EQ(QueryCompileStatus(fs), GL_FALSE);
syncLog = QueryShaderInfoLog(fs);
EXPECT_FALSE(syncLog.empty());
// GL defines compile FAILURE as a status plus a log, not as a GL error.
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
{
const AsyncModeScope async(true);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, source);
CompileShader(fs);
EXPECT_EQ(QueryCompileStatus(fs), GL_FALSE);
EXPECT_EQ(QueryShaderInfoLog(fs), syncLog);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
}
}
// A link whose vertex shader failed to compile has to reproduce that shader's log verbatim
// inside the program info log, whichever thread produced it.
TEST_F(AsyncCompileTest, LinkDiagnosticsQuoteTheAsyncCompileLog) {
const AsyncModeScope async(true);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
CompileShader(vs);
CompileShader(fs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
// No status read between the enqueue and the link: the link's own prologue is what has
// to join the two compiles.
LinkProgram(program);
EXPECT_EQ(QueryLinkStatus(program), GL_FALSE);
GLint length = 0;
GetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
ASSERT_GT(length, 1);
std::vector<GLchar> buffer(static_cast<size_t>(length));
GLsizei written = 0;
GetProgramInfoLog(program, length, &written, buffer.data());
const String programLog(buffer.data(), static_cast<size_t>(written));
const String shaderLog = QueryShaderInfoLog(fs);
ASSERT_FALSE(shaderLog.empty());
EXPECT_NE(programLog.find(shaderLog), String::npos)
<< "program log:\n" << programLog << "\nshader log:\n" << shaderLog;
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// Mutation over an in-flight compile
// ---------------------------------------------------------------------------------------
// glShaderSource with DIFFERENT text over a pending compile: the running job is abandoned
// and the next compile reflects the new source. The re-source happens with the pool
// saturated, so the job it replaces is very likely still queued or running.
TEST_F(AsyncCompileTest, ShaderSourceOverAPendingCompileCancelsAndTheNewSourceWins) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const String firstSource = MakeBulkySource(7001);
const char* firstText = firstSource.c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &firstText, nullptr);
CompileShader(fs);
// Replace the text while that compile is (very probably) still outstanding. This must
// not wait, must not corrupt the abandoned job's view of the old string, and must
// disarm the layer-1 memo.
const String secondSource = MakeBulkySource(7002);
const char* secondText = secondSource.c_str();
ShaderSource(fs, 1, &secondText, nullptr);
EXPECT_FALSE(HasMemoizedCompile(fs)) << "a real source change must invalidate the compiled state";
EXPECT_EQ(QueryCompileStatus(fs), GL_FALSE) << "the replaced compile must not publish";
CompileShader(fs);
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
CompileShader(vs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE);
// The SECOND source's uniform is the one that exists.
EXPECT_GE(GetUniformLocation(program, "uSeed7002"), 0);
EXPECT_EQ(GetUniformLocation(program, "uSeed7001"), -1);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// glShaderSource with byte-identical text over a pending compile is a no-op: the job stays,
// the memo stays armed, and the result is still the right one.
TEST_F(AsyncCompileTest, IdenticalShaderSourceOverAPendingCompileKeepsTheJob) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const String source = MakeBulkySource(7100);
const char* text = source.c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
ShaderSource(fs, 1, &text, nullptr);
EXPECT_TRUE(HasMemoizedCompile(fs)) << "identical re-source must not disturb an in-flight compile";
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// A second glCompileShader on a pending object must be a no-op, not a duplicate job racing
// the first one to write the same fields. Observed through the object identity of the node:
// HasMemoizedCompile stays true across the second call, and the result is still correct.
TEST_F(AsyncCompileTest, RepeatedCompileShaderOnAPendingObjectEnqueuesOneJob) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const String source = MakeBulkySource(7200);
const char* text = source.c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
// A copy, not the slot reference: creating another shader can reallocate the table.
const SharedPtr<MG_State::GLState::ShaderObject> object = MG_State::pGLContext->GetShaderObject(fs);
ASSERT_NE(object, nullptr);
EXPECT_FALSE(object->HasMemoizedCompile());
CompileShader(fs);
EXPECT_TRUE(object->HasMemoizedCompile());
for (int i = 0; i < 8; ++i) {
CompileShader(fs);
EXPECT_TRUE(object->HasMemoizedCompile());
}
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// glDeleteShader on an unattached object with a compile still in flight. The name goes away
// immediately - no wait for a worker - and the abandoned job must neither crash nor keep the
// object alive in a way anything can observe.
TEST_F(AsyncCompileTest, DeleteShaderWhileACompileIsPending) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
Vector<GLuint> doomed;
Vector<String> sources;
for (int i = 0; i < 16; ++i) {
sources.push_back(MakeBulkySource(7300 + i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
doomed.push_back(fs);
}
for (const GLuint fs : doomed) {
DeleteShader(fs);
EXPECT_EQ(IsShader(fs), GL_FALSE) << "an unattached deleted shader's name goes immediately";
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
// The context still works afterwards - the abandoned jobs did not take the pool, the
// preprocess cache or the glslang process state down with them.
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
CompileShader(vs);
CompileShader(fs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// glDeleteShader on a shader still ATTACHED to a program only flags it: the pending compile
// has to survive, because the link that follows still needs its artifacts.
TEST_F(AsyncCompileTest, DeleteShaderWhileAttachedKeepsThePendingCompileAlive) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
CompileShader(vs);
CompileShader(fs);
DeleteShader(vs);
DeleteShader(fs);
LinkProgram(program);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE);
EXPECT_GE(GetUniformLocation(program, "uColor"), 0);
EXPECT_GE(GetUniformLocation(program, "uAlpha"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// Stress
// ---------------------------------------------------------------------------------------
// The adversarial interleaving: enqueue, query, re-source, re-enqueue, delete, all with the
// pool busy. Nothing here asserts timing - what it hunts for is a missed join or a use of an
// abandoned node, both of which surface as a wrong status, a wrong log, or a crash.
TEST_F(AsyncCompileTest, StressCompileQueryResourceDeleteInterleaved) {
const AsyncModeScope async(true);
constexpr int kRounds = 6;
constexpr int kPerRound = 12;
for (int round = 0; round < kRounds; ++round) {
Vector<String> sources;
Vector<GLuint> shaders;
sources.reserve(kPerRound * 2);
for (int i = 0; i < kPerRound; ++i) {
sources.push_back(MakeBulkySource(round * 1000 + i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
shaders.push_back(fs);
// Immediately query a PREVIOUS one while this one is still outstanding: the
// join has to settle exactly the object asked about and no other.
if (i > 0) {
const GLuint earlier = shaders[static_cast<SizeT>(i - 1)];
EXPECT_EQ(QueryCompileStatus(earlier), GL_TRUE) << QueryShaderInfoLog(earlier);
}
}
// Re-source half of them mid-flight, then recompile.
for (int i = 0; i < kPerRound; i += 2) {
sources.push_back(MakeBulkySource(round * 1000 + 500 + i));
const char* text = sources.back().c_str();
ShaderSource(shaders[static_cast<SizeT>(i)], 1, &text, nullptr);
CompileShader(shaders[static_cast<SizeT>(i)]);
}
for (int i = 0; i < kPerRound; ++i) {
const GLuint shader = shaders[static_cast<SizeT>(i)];
EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE) << QueryShaderInfoLog(shader);
const String expectedUniform =
"uSeed" + std::to_string(round * 1000 + (i % 2 == 0 ? 500 + i : i));
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
CompileShader(vs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, shader);
LinkProgram(program);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "round " << round << " shader " << i;
EXPECT_GE(GetUniformLocation(program, expectedUniform.c_str()), 0)
<< "round " << round << " shader " << i << " expected " << expectedUniform;
DeleteProgram(program);
DeleteShader(vs);
}
for (const GLuint shader : shaders) {
DeleteShader(shader);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
}
// The P0b cross-object memo is hit from several workers at once here: 8 objects share each
// of 6 distinct sources, all enqueued before anything is read. Every object must still end
// up with its own parse and its own correct reflection - a torn cache entry or an entry
// evicted from under a reader shows up as a link failure or a missing uniform.
TEST_F(AsyncCompileTest, ConcurrentCompilesShareThePreprocessCacheSafely) {
const AsyncModeScope async(true);
constexpr int kDistinct = 6;
constexpr int kDuplicates = 8;
Vector<String> sources;
sources.reserve(kDistinct);
for (int i = 0; i < kDistinct; ++i) {
sources.push_back(MakeBulkySource(8100 + i));
}
Vector<GLuint> shaders;
for (int duplicate = 0; duplicate < kDuplicates; ++duplicate) {
for (int i = 0; i < kDistinct; ++i) {
const char* text = sources[static_cast<SizeT>(i)].c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
shaders.push_back(fs);
}
}
for (SizeT s = 0; s < shaders.size(); ++s) {
const GLuint fs = shaders[s];
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
CompileShader(vs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "shader index " << s;
const String uniform = "uSeed" + std::to_string(8100 + static_cast<int>(s % kDistinct));
EXPECT_GE(GetUniformLocation(program, uniform.c_str()), 0) << uniform;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
+749
View File
@@ -0,0 +1,749 @@
// MobileGL - MobileGL/MG_Test/Program/AsyncLinkTest.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
// P1 stage 4: glLinkProgram enqueues a ProgramLinkTask behind its shaders' compiles, and
// every observable read of link output joins.
//
// Like AsyncCompileTest, every case here drives the real GL entry points and flips
// MG_Config::Features.AsyncShaderCompile itself rather than reading the environment - so one
// binary can assert the property that actually matters (the async and synchronous paths are
// indistinguishable through the GL surface) regardless of how the suite was launched.
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "Config.h"
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_Impl/GLImpl/Program/GL_ProgramPipeline.h"
#include "MG_State/GLState/Core.h"
#include "MG_Util/Async/ShaderCompilePool.h"
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
namespace {
class AsyncModeScope {
public:
explicit AsyncModeScope(const Bool async) : m_saved(MG_Config::Features.AsyncShaderCompile) {
MG_Config::Features.AsyncShaderCompile =
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
}
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
AsyncModeScope(const AsyncModeScope&) = delete;
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
private:
const MG_Config::QuirkOverride m_saved;
};
const char* kVs = R"(#version 460
layout(location = 0) in vec3 aPos;
uniform mat4 uModel;
uniform vec4 uColor;
out vec4 vColor;
void main() {
vColor = uColor;
gl_Position = uModel * vec4(aPos, 1.0);
}
)";
const char* kFs = R"(#version 460
in vec4 vColor;
layout(location = 0) out vec4 fragColor;
uniform float uAlpha;
void main() { fragColor = vec4(vColor.rgb, vColor.a * uAlpha); }
)";
// A vertex shader that captures something transform feedback can name.
const char* kXfbVs = R"(#version 460
layout(location = 0) in vec3 aPos;
out vec3 vWorld;
void main() {
vWorld = aPos * 2.0;
gl_Position = vec4(aPos, 1.0);
}
)";
const char* kBrokenFs = R"(#version 460
layout(location = 0) out vec4 fragColor;
void main() { fragColor = thisIdentifierWasNeverDeclared; }
)";
// Big enough that neither the compile nor the link is instantaneous, so the pool has a
// real backlog to race against. Templated on an index so every instance is distinct
// source text (no P0b memo hit).
String MakeBulkySource(const int index) {
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
source += "uniform float uSeed" + std::to_string(index) + ";\n";
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
for (int i = 0; i < 220; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
}
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
return source;
}
GLuint MakeShader(const GLenum type, const char* source) {
const GLuint shader = CreateShader(type);
ShaderSource(shader, 1, &source, nullptr);
CompileShader(shader);
return shader;
}
GLint QueryLinkStatus(const GLuint program) {
GLint status = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &status);
return status;
}
String QueryProgramInfoLog(const GLuint program) {
GLint length = 0;
GetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
if (length <= 0) return String();
std::vector<GLchar> buffer(static_cast<size_t>(length));
GLsizei written = 0;
GetProgramInfoLog(program, length, &written, buffer.data());
return String(buffer.data(), static_cast<size_t>(written));
}
// The non-joining view of the program, i.e. what GL_COMPLETION_STATUS_KHR will report.
Bool LinkIsSettled(const GLuint program) {
const auto& object = MG_State::pGLContext->GetProgramObject(program);
return object == nullptr || object->IsLinkComplete();
}
// Enqueues `count` distinct heavy compiles without reading anything back, so the pool is
// left with a real backlog for the caller to race against.
Vector<GLuint> SaturatePool(const int count, Vector<String>& sourceStorage) {
Vector<GLuint> shaders;
shaders.reserve(static_cast<SizeT>(count));
sourceStorage.reserve(sourceStorage.size() + static_cast<SizeT>(count));
for (int i = 0; i < count; ++i) {
sourceStorage.push_back(MakeBulkySource(20000 + i));
const char* text = sourceStorage.back().c_str();
const GLuint shader = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(shader, 1, &text, nullptr);
CompileShader(shader);
shaders.push_back(shader);
}
return shaders;
}
// Content hash of a linked program's generated SPIR-V, through the state layer (there is
// no GL query for it). Joins, like every other artifact read.
Vector<Uint64> SpirvDigest(const GLuint program) {
const auto& object = MG_State::pGLContext->GetProgramObject(program);
Vector<Uint64> digest;
if (!object) return digest;
for (const auto& module : object->GetGeneratedSpirv()) {
Uint64 hash = 1469598103934665603ull;
for (const unsigned word : module) {
hash = (hash ^ static_cast<Uint64>(word)) * 1099511628211ull;
}
digest.push_back(hash);
}
return digest;
}
class AsyncLinkTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
};
} // namespace
// ---------------------------------------------------------------------------------------
// The consume-once claim
// ---------------------------------------------------------------------------------------
// The stage-4 headline risk: two programs share one shader and are linked back to back, so
// two ProgramLinkTasks race for that shader's single glslang parse. Exactly one may win the
// claim; the loser must re-parse the same preprocessed source against the same CompileEnv.
// If either half of that is wrong the two programs get DIFFERENT SPIR-V for the same shader,
// which is the silent-corruption class this whole mechanism exists to prevent.
TEST_F(AsyncLinkTest, TwoProgramsSharingAShaderGenerateIdenticalSpirv) {
for (const Bool async : {false, true}) {
const AsyncModeScope scope(async);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
// Both links enqueued before either result is read: with the flag on this is the
// window in which two workers can hold the same node at once.
const GLuint programA = CreateProgram();
AttachShader(programA, vs);
AttachShader(programA, fs);
LinkProgram(programA);
const GLuint programB = CreateProgram();
AttachShader(programB, vs);
AttachShader(programB, fs);
LinkProgram(programB);
ASSERT_EQ(QueryLinkStatus(programA), GL_TRUE) << QueryProgramInfoLog(programA);
ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE) << QueryProgramInfoLog(programB);
const Vector<Uint64> digestA = SpirvDigest(programA);
const Vector<Uint64> digestB = SpirvDigest(programB);
ASSERT_EQ(digestA.size(), 2u) << "async=" << async;
EXPECT_EQ(digestA, digestB)
<< "the claim winner and the re-parsing loser must produce identical SPIR-V (async=" << async << ")";
// And the two programs really are usable independently.
EXPECT_GE(GetUniformLocation(programA, "uColor"), 0);
EXPECT_GE(GetUniformLocation(programB, "uColor"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
}
// The same property many ways at once, with the pool loaded: N programs over the SAME shader
// pair, all enqueued before anything is read, so one claim winner is racing N-1 re-parsers.
// Every program must come out byte-identical.
//
// The shader pair has to be identical across the programs for this to mean anything: glslang
// links the stages together, so a stage's SPIR-V is legitimately a function of the WHOLE
// program (mapIO's cross-stage location assignment, live-variable analysis). Comparing one
// shared vertex shader across programs with different fragment stages would compare things
// that are allowed to differ.
TEST_F(AsyncLinkTest, ManyProgramsSharingOneShaderPairAgreeOnTheirSpirv) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
constexpr int kPrograms = 12;
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
Vector<GLuint> programs;
for (int i = 0; i < kPrograms; ++i) {
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
programs.push_back(program);
}
Vector<Uint64> reference;
for (int i = 0; i < kPrograms; ++i) {
const GLuint program = programs[static_cast<SizeT>(i)];
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "program " << i << ": " << QueryProgramInfoLog(program);
const Vector<Uint64> digest = SpirvDigest(program);
ASSERT_EQ(digest.size(), 2u);
if (i == 0) {
reference = digest;
} else {
EXPECT_EQ(digest, reference) << "SPIR-V differs in program " << i;
}
EXPECT_GE(GetUniformLocation(program, "uColor"), 0) << "program " << i;
EXPECT_GE(GetUniformLocation(program, "uAlpha"), 0) << "program " << i;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// Mutation over a pending link (the cancel matrix)
// ---------------------------------------------------------------------------------------
// The last link wins. A re-link over a pending one cancels it and enqueues afresh; the
// result the application eventually reads must be the SECOND link's.
TEST_F(AsyncLinkTest, RelinkOverAPendingLinkPublishesTheSecondLink) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const String firstSource = MakeBulkySource(7001);
const char* firstText = firstSource.c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &firstText, nullptr);
CompileShader(fs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
// Swap the fragment shader's source and relink, all without ever reading the first
// link's status - so the first link is very probably still queued or running.
const String secondSource = MakeBulkySource(7002);
const char* secondText = secondSource.c_str();
ShaderSource(fs, 1, &secondText, nullptr);
CompileShader(fs);
LinkProgram(program);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_GE(GetUniformLocation(program, "uSeed7002"), 0);
EXPECT_EQ(GetUniformLocation(program, "uSeed7001"), -1);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The take-effect-at-next-link setters must NOT disturb a pending link: the pending link
// snapshotted its own inputs at enqueue, so
// glLinkProgram; glTransformFeedbackVaryings; glGetProgramiv(LINK_STATUS)
// has to report the FIRST link - which captured nothing.
TEST_F(AsyncLinkTest, TransformFeedbackVaryingsOverAPendingLinkReportsTheFirstLink) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kXfbVs);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
const char* varyings[] = {"vWorld"};
TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
GLint captured = -1;
GetProgramiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS, &captured);
EXPECT_EQ(captured, 0) << "the pending link must publish the request set it snapshotted, not a later one";
// And the request does take effect at the NEXT link.
LinkProgram(program);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
GetProgramiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS, &captured);
EXPECT_EQ(captured, 1);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// glBindAttribLocation is the same family and must likewise leave a pending link alone.
TEST_F(AsyncLinkTest, BindAttribLocationOverAPendingLinkDoesNotDisturbIt) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
BindAttribLocation(program, 5, "aPos");
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_EQ(GetAttribLocation(program, "aPos"), 0) << "the first link's layout(location = 0) must survive";
LinkProgram(program);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// glAttachShader after glLinkProgram is defined to leave the current link status alone (it
// takes effect at the next link). It must therefore NOT cancel a pending link - the failure
// mode being guarded here is a program that linked fine reporting GL_FALSE.
TEST_F(AsyncLinkTest, AttachShaderOverAPendingLinkKeepsTheLinkResult) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
// A second, unrelated fragment shader attached over the pending link. (Attaching two
// shaders of one stage is legal; only the next link would have to reconcile them.)
const GLuint extraFs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
AttachShader(program, extraFs);
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_GE(GetUniformLocation(program, "uAlpha"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The link-then-detach-then-delete teardown every LWJGL/Blaze3D-shaped app performs. The
// detach makes the shader GL-invisible, so glDeleteShader frees its name and would otherwise
// cancel a compile the enqueued link is still waiting on - flipping a link that must report
// GL_TRUE to GL_FALSE. Runs with the pool saturated so the compiles really are outstanding.
TEST_F(AsyncLinkTest, DetachAndDeleteShadersOverAPendingLinkKeepsTheLinkResult) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const String source = MakeBulkySource(7400);
const char* text = source.c_str();
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
DetachShader(program, vs);
DetachShader(program, fs);
DeleteShader(vs);
DeleteShader(fs);
EXPECT_EQ(IsShader(vs), GL_FALSE);
EXPECT_EQ(IsShader(fs), GL_FALSE);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_GE(GetUniformLocation(program, "uSeed7400"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// glCreateShaderProgramv is specified as create-source-compile-create-attach-LINK-detach, so
// it is the in-tree caller that exercises the detach-immediately-after-link ordering. It
// self-joins through its status queries (design join site J7) and needs no edit of its own -
// this is the guard that says so.
TEST_F(AsyncLinkTest, CreateShaderProgramvLinksUnderAsync) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const char* sources[] = {kVs};
const GLuint program = CreateShaderProgramv(GL_VERTEX_SHADER, 1, sources);
ASSERT_NE(program, 0u);
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_GE(GetUniformLocation(program, "uColor"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// glProgramBinary over a pending link: no format is supported, so the spec requires
// LINK_STATUS to read FALSE afterwards. The pending link must not publish over that.
TEST_F(AsyncLinkTest, ProgramBinaryOverAPendingLinkForcesLinkFalse) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
const GLuint dummy = 0;
ProgramBinary(program, 0, &dummy, static_cast<GLsizei>(sizeof(dummy)));
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
EXPECT_EQ(QueryLinkStatus(program), GL_FALSE) << "glProgramBinary must win over the pending link";
EXPECT_FALSE(QueryProgramInfoLog(program).empty());
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// glDeleteProgram over a pending link. The name goes away immediately - no wait for a worker
// - and the abandoned job must neither crash nor keep anything observable alive.
TEST_F(AsyncLinkTest, DeleteProgramWhileALinkIsPending) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
Vector<GLuint> doomed;
Vector<String> sources;
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
for (int i = 0; i < 16; ++i) {
sources.push_back(MakeBulkySource(7500 + i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
doomed.push_back(program);
}
for (const GLuint program : doomed) {
DeleteProgram(program);
EXPECT_EQ(IsProgram(program), GL_FALSE) << "an unused deleted program's name goes immediately";
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
// The context still works afterwards: the abandoned links did not take the pool, the
// preprocess cache or the glslang process state down with them.
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// The join gates
// ---------------------------------------------------------------------------------------
// glLinkProgram must return before the work is done, and the first observable read must
// join. Observed through the state machine rather than through timing, so it can never be a
// false red: with a saturated pool at least one of the just-enqueued links has to be
// unsettled at the moment we ask; skipped if the machine drained everything first.
TEST_F(AsyncLinkTest, LinkProgramReturnsBeforeTheWorkIsDone) {
const AsyncModeScope async(true);
constexpr int kPrograms = 32;
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
Vector<GLuint> programs;
Vector<String> sources;
for (int i = 0; i < kPrograms; ++i) {
sources.push_back(MakeBulkySource(7600 + i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
programs.push_back(program);
}
int unsettled = 0;
for (const GLuint program : programs) {
if (!LinkIsSettled(program)) ++unsettled;
}
if (unsettled == 0) {
GTEST_SKIP() << "the pool drained every link before the first observation; nothing to prove here";
}
for (const GLuint program : programs) {
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_TRUE(LinkIsSettled(program)) << "reading LINK_STATUS must have joined";
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// With the flag off, a link is finished by the time glLinkProgram returns. This is the guard
// that keeps the default shippable.
TEST_F(AsyncLinkTest, LinkIsFullySynchronousWithAsyncOff) {
const AsyncModeScope async(false);
ASSERT_FALSE(MG_Util::Async::AsyncShaderCompileEnabled());
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
EXPECT_TRUE(LinkIsSettled(program));
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// P1 join site J1: the composite draw program for a pipeline is cached against a signature
// built from each stage program's lifetime id and backend state version - NON-artifact
// fields, which do not pass through the join gate. GetProgramForDraw has to settle the stage
// programs first, or the signature describes a link generation that no longer exists and the
// composite is rebuilt on every draw.
TEST_F(AsyncLinkTest, DrawThroughAPipelineWithAPendingStageProgramJoinsFirst) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
// Built by hand rather than through glCreateShaderProgramv: that entry point detaches the
// shader immediately after linking, so the next link would remove it and leave the stage
// program with nothing attached to composite from.
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint vsProgram = CreateProgram();
ProgramParameteri(vsProgram, GL_PROGRAM_SEPARABLE, GL_TRUE);
AttachShader(vsProgram, vs);
LinkProgram(vsProgram);
ASSERT_EQ(QueryLinkStatus(vsProgram), GL_TRUE) << QueryProgramInfoLog(vsProgram);
GLuint pipeline = 0;
GenProgramPipelines(1, &pipeline);
ASSERT_NE(pipeline, 0u);
// Bind before UseProgramStages: glGenProgramPipelines only reserves the name, and the
// first bind is what turns it into an object glUseProgramStages can find.
BindProgramPipeline(pipeline);
UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram);
ASSERT_EQ(GetError(), GL_NO_ERROR);
// Re-link the stage program and immediately ask for the draw program, without reading
// the link's status in between: the pending link is what J1 has to settle.
LinkProgram(vsProgram);
const SharedPtr<MG_State::GLState::ProgramObject> drawProgram = MG_State::pGLContext->GetProgramForDraw();
ASSERT_NE(drawProgram, nullptr);
EXPECT_TRUE(LinkIsSettled(vsProgram)) << "GetProgramForDraw must have joined the stage program";
EXPECT_TRUE(drawProgram->GetLinkStatus()) << drawProgram->GetInfoLog();
// Asking again with nothing changed must hit the composite cache, which is only possible
// if the signature was computed against settled programs both times.
const SharedPtr<MG_State::GLState::ProgramObject> again = MG_State::pGLContext->GetProgramForDraw();
EXPECT_EQ(again.get(), drawProgram.get()) << "the composite draw program must be cached across draws";
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// Diagnostics
// ---------------------------------------------------------------------------------------
// A link whose fragment shader failed to compile has to reproduce that shader's log verbatim
// inside the program info log, whichever thread produced it - and the failure must be
// reported as LINK_STATUS plus a log, never as a GL error.
TEST_F(AsyncLinkTest, FailingLinkLogIsIdenticalAcrossModes) {
String syncLog;
{
const AsyncModeScope scope(false);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
ASSERT_EQ(QueryLinkStatus(program), GL_FALSE);
syncLog = QueryProgramInfoLog(program);
EXPECT_FALSE(syncLog.empty());
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
{
const AsyncModeScope scope(true);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
EXPECT_EQ(QueryLinkStatus(program), GL_FALSE);
EXPECT_EQ(QueryProgramInfoLog(program), syncLog);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
}
// A program with nothing attached fails in the GL-thread prologue, before any job exists.
// That path has to reach the same info log in both modes.
TEST_F(AsyncLinkTest, LinkWithNoShadersFailsIdenticallyInBothModes) {
String syncLog;
for (const Bool async : {false, true}) {
const AsyncModeScope scope(async);
const GLuint program = CreateProgram();
LinkProgram(program);
EXPECT_EQ(QueryLinkStatus(program), GL_FALSE);
const String log = QueryProgramInfoLog(program);
EXPECT_FALSE(log.empty());
if (!async) {
syncLog = log;
} else {
EXPECT_EQ(log, syncLog);
}
EXPECT_TRUE(LinkIsSettled(program)) << "a prologue failure leaves no job pending";
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// End to end
// ---------------------------------------------------------------------------------------
// The shape a shaderpack load actually has: compile N shaders, link M programs, read
// NOTHING until the end, then query everything. This is the only shape in which the pool has
// many compiles and many links in flight simultaneously, with the link jobs chained behind
// compile jobs that are themselves still queued.
TEST_F(AsyncLinkTest, PackShapedBurstCompilesLinksAndQueriesEverything) {
const AsyncModeScope async(true);
constexpr int kShaders = 24;
constexpr int kPrograms = 24;
Vector<String> sources;
Vector<GLuint> vertexShaders;
Vector<GLuint> fragmentShaders;
for (int i = 0; i < kShaders; ++i) {
vertexShaders.push_back(MakeShader(GL_VERTEX_SHADER, kVs));
sources.push_back(MakeBulkySource(8000 + i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
fragmentShaders.push_back(fs);
}
Vector<GLuint> programs;
for (int i = 0; i < kPrograms; ++i) {
const GLuint program = CreateProgram();
AttachShader(program, vertexShaders[static_cast<SizeT>(i % kShaders)]);
AttachShader(program, fragmentShaders[static_cast<SizeT>(i % kShaders)]);
LinkProgram(program);
programs.push_back(program);
}
for (int i = 0; i < kPrograms; ++i) {
const GLuint program = programs[static_cast<SizeT>(i)];
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "program " << i << ": " << QueryProgramInfoLog(program);
EXPECT_GE(GetUniformLocation(program, "uColor"), 0) << "program " << i;
EXPECT_GE(GetUniformLocation(program, ("uSeed" + std::to_string(8000 + i % kShaders)).c_str()), 0)
<< "program " << i;
EXPECT_EQ(GetAttribLocation(program, "aPos"), 0) << "program " << i;
EXPECT_EQ(SpirvDigest(program).size(), 2u) << "program " << i;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The adversarial interleaving: link, query a previous one, re-source, re-link, delete, all
// with the pool busy. Nothing here asserts timing - what it hunts for is a missed join, a
// consumed-twice parse, or a use of an abandoned node, all of which surface as a wrong
// status, a missing uniform, or a crash.
TEST_F(AsyncLinkTest, StressLinkQueryRelinkDeleteInterleaved) {
const AsyncModeScope async(true);
constexpr int kRounds = 5;
constexpr int kPerRound = 10;
for (int round = 0; round < kRounds; ++round) {
Vector<String> sources;
Vector<GLuint> programs;
Vector<GLuint> fragmentShaders;
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
for (int i = 0; i < kPerRound; ++i) {
sources.push_back(MakeBulkySource(round * 1000 + 300 + i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
fragmentShaders.push_back(fs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
programs.push_back(program);
// Query a PREVIOUS program while this one is still outstanding: the join has to
// settle exactly the program asked about and no other.
if (i > 0) {
const GLuint earlier = programs[static_cast<SizeT>(i - 1)];
EXPECT_EQ(QueryLinkStatus(earlier), GL_TRUE) << QueryProgramInfoLog(earlier);
}
}
// Re-source half of them mid-flight and relink over the pending link.
for (int i = 0; i < kPerRound; i += 2) {
sources.push_back(MakeBulkySource(round * 1000 + 700 + i));
const char* text = sources.back().c_str();
ShaderSource(fragmentShaders[static_cast<SizeT>(i)], 1, &text, nullptr);
CompileShader(fragmentShaders[static_cast<SizeT>(i)]);
LinkProgram(programs[static_cast<SizeT>(i)]);
}
for (int i = 0; i < kPerRound; ++i) {
const GLuint program = programs[static_cast<SizeT>(i)];
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE)
<< "round " << round << " program " << i << ": " << QueryProgramInfoLog(program);
const String expected =
"uSeed" + std::to_string(round * 1000 + (i % 2 == 0 ? 700 + i : 300 + i));
EXPECT_GE(GetUniformLocation(program, expected.c_str()), 0)
<< "round " << round << " program " << i << " expected " << expected;
DeleteProgram(program);
}
for (const GLuint fs : fragmentShaders) DeleteShader(fs);
DeleteShader(vs);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
}
@@ -0,0 +1,133 @@
// MobileGL - MobileGL/MG_Test/Program/AsyncTeardownTest.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
// P1 stage 4, item S6: MobileGL::Destroy() with compile AND link jobs still in flight.
//
// This is the one cancellation path in the whole design that WAITS, and the order it waits
// in is load-bearing: in-flight jobs own their own inputs and are safe against everything
// teardown does EXCEPT glslang's process globals and the TShader/TProgram objects hanging off
// pGLContext - both of which DestroyImpl is about to free. StopAndDrain() therefore runs
// first, before pGLContext.reset() and before glslang::FinalizeProcess().
//
// ITS OWN BINARY, deliberately. ShaderCompilePool::StopAndDrain() is a one-way latch: from
// the first eglTerminate onwards every job in the process runs inline on the calling thread.
// Sharing a binary with AsyncCompileTest/AsyncLinkTest would silently turn every case
// declared after this one synchronous, and they would keep passing while testing nothing.
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "Config.h"
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_State/GLState/Core.h"
#include "MG_Util/Async/ShaderCompilePool.h"
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
namespace {
const char* kVs = R"(#version 460
layout(location = 0) in vec3 aPos;
uniform vec4 uColor;
out vec4 vColor;
void main() {
vColor = uColor;
gl_Position = vec4(aPos, 1.0);
}
)";
String MakeBulkySource(const int index) {
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
source += "uniform float uSeed" + std::to_string(index) + ";\n";
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
for (int i = 0; i < 220; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
}
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
return source;
}
GLuint MakeShader(const GLenum type, const char* source) {
const GLuint shader = CreateShader(type);
ShaderSource(shader, 1, &source, nullptr);
CompileShader(shader);
return shader;
}
} // namespace
// Fills the pool with compiles, chains links behind them, and tears the library down without
// reading a single result. Nothing here can assert on the jobs' outcomes - by design there is
// no one left to ask - so what it asserts is that teardown COMPLETES: it must not hang
// (StopAndDrain joining a worker that is itself waiting on something), must not crash (a
// worker inside glslang while FinalizeProcess frees its symbol tables, or a link job reading
// a shader node the GL thread has dropped), and must leave the process able to come back up.
TEST(AsyncTeardown, DestroyWithCompilesAndLinksInFlight) {
// After Initialize(), not before: MG_ConfigLoader::Init() re-reads the whole feature
// block from the environment and would overwrite the override.
MobileGL::Initialize();
MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::ForceOn;
ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled());
constexpr int kCount = 64;
Vector<String> sources;
Vector<GLuint> shaders;
Vector<GLuint> programs;
sources.reserve(kCount);
// Bare compiles first, so the pool has a backlog the links below will queue behind.
for (int i = 0; i < kCount; ++i) {
sources.push_back(MakeBulkySource(30000 + i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
shaders.push_back(fs);
}
// Then links, each chained behind a compile that is very probably still outstanding: at
// the moment Destroy() runs there are queued compiles, running compiles, links waiting on
// a dependency edge, and links already handed to the pool.
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
for (int i = 0; i < kCount; ++i) {
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, shaders[static_cast<SizeT>(i)]);
LinkProgram(program);
programs.push_back(program);
}
// No status read anywhere above - the jobs are genuinely in flight.
MobileGL::Destroy();
// Back up again. The pool stays stopped for the rest of the process (a one-way latch), so
// this second life is synchronous - which is exactly the documented behaviour, and it has
// to still be a WORKING one.
MobileGL::Initialize();
const GLuint vs2 = MakeShader(GL_VERTEX_SHADER, kVs);
const char* fsSource = R"(#version 460
in vec4 vColor;
layout(location = 0) out vec4 fragColor;
void main() { fragColor = vColor; }
)";
const GLuint fs2 = MakeShader(GL_FRAGMENT_SHADER, fsSource);
const GLuint program = CreateProgram();
AttachShader(program, vs2);
AttachShader(program, fs2);
LinkProgram(program);
GLint status = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &status);
EXPECT_EQ(status, GL_TRUE) << "the library must be usable after a teardown that drained jobs in flight";
EXPECT_GE(GetUniformLocation(program, "uColor"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
+132
View File
@@ -9,6 +9,7 @@ add_executable(
${MGL_ROOT}/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp
${MGL_ROOT}/MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp
${MGL_ROOT}/MobileGL/MG_Util/ShaderTranspiler/glslang/UniformTraverser.cpp
${MGL_ROOT}/MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp
)
target_include_directories(ProgramUtilTest PRIVATE
@@ -27,6 +28,124 @@ add_executable(
ProgramTest.cpp
)
add_executable(
AsyncCompileTest
AsyncCompileTest.cpp
)
target_include_directories(AsyncCompileTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
AsyncCompileTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
add_executable(
AsyncLinkTest
AsyncLinkTest.cpp
)
target_include_directories(AsyncLinkTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
AsyncLinkTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
add_executable(
ShaderCompileAdoptionTest
ShaderCompileAdoptionTest.cpp
)
target_include_directories(ShaderCompileAdoptionTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
ShaderCompileAdoptionTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
add_executable(
ParallelShaderCompileTest
ParallelShaderCompileTest.cpp
)
target_include_directories(ParallelShaderCompileTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
ParallelShaderCompileTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
# Its own binary so the "fresh process" isolation level in it is really available
# through --gtest_filter, and so its 60 A->B link pairs cannot perturb another
# suite's per-context caches.
add_executable(
XfbFrontendOrderInvarianceTest
XfbFrontendOrderInvarianceTest.cpp
)
target_include_directories(XfbFrontendOrderInvarianceTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
XfbFrontendOrderInvarianceTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
# Its own binary on purpose: this one calls MobileGL::Destroy(), and ShaderCompilePool's
# stop is a one-way latch for the whole process - every case declared after it in the same
# binary would silently run its compiles and links inline.
add_executable(
AsyncTeardownTest
AsyncTeardownTest.cpp
)
target_include_directories(AsyncTeardownTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
AsyncTeardownTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
add_executable(
ProgramInterfaceTest
ProgramInterfaceTest.cpp
)
target_include_directories(ProgramInterfaceTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
ProgramInterfaceTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
target_include_directories(ProgramTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
@@ -41,3 +160,16 @@ target_link_libraries(
include(GoogleTest)
gtest_discover_tests(ProgramUtilTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ProgramInterfaceTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
# Heavier than the rest of the unit suite by design: several cases deliberately saturate the
# compile pool so there is something in flight to race against.
gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
gtest_discover_tests(AsyncLinkTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
# Same reason: the stage-6 cases keep a backlog in flight so a release really can race a
# worker, and the 48-object stress links every one of them.
gtest_discover_tests(ShaderCompileAdoptionTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
# Same reason: the GL_COMPLETION_STATUS_KHR cases saturate a one-worker pool on purpose.
gtest_discover_tests(ParallelShaderCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
gtest_discover_tests(AsyncTeardownTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
# Same reason again: several cases leave A links outstanding while B compiles and links.
gtest_discover_tests(XfbFrontendOrderInvarianceTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
@@ -0,0 +1,508 @@
// MobileGL - MobileGL/MG_Test/Program/ParallelShaderCompileTest.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
// P1 stage 5: the GL_KHR_parallel_shader_compile application surface.
//
// Four things are under test, and they are the four an application actually touches:
// * GL_COMPLETION_STATUS_KHR on shaders and programs, which MUST NOT JOIN - the whole
// point of the query is to answer while the work is still outstanding;
// * glMaxShaderCompilerThreadsKHR / ...ARB, including the count == 0 mode switch the
// extension mandates and what lifts it again;
// * GL_MAX_SHADER_COMPILER_THREADS_KHR;
// * the extension string itself, which must appear if and only if asynchronous
// compilation is enabled - the kill switch has to revert the application-visible
// behaviour change, not only the threading.
//
// Like the other async suites, every case drives the real GL entry points and flips
// MG_Config::Features.AsyncShaderCompile itself, so the file behaves identically whether or
// not the suite was launched with MOBILEGL_ASYNC_SHADER_COMPILE=1.
#include <gtest/gtest.h>
#include <algorithm>
#include <string>
#include <vector>
#include "Config.h"
#include "Includes.h"
#include "Init.h"
#include "MG_Backend/BackendObjects.h"
#include "MG_Backend/DirectGLES/BackendObject_DirectGLES.h"
#include "MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_State/GLState/Core.h"
#include "MG_Util/Async/ShaderCompilePool.h"
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
namespace {
class AsyncModeScope {
public:
explicit AsyncModeScope(const Bool async) : m_saved(MG_Config::Features.AsyncShaderCompile) {
MG_Config::Features.AsyncShaderCompile =
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
}
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
AsyncModeScope(const AsyncModeScope&) = delete;
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
private:
const MG_Config::QuirkOverride m_saved;
};
// glMaxShaderCompilerThreadsKHR writes PROCESS-wide state (the pool's concurrency budget
// and the suspension latch), so a case that touches it has to put both back or it
// poisons every case declared after it in this binary.
class CompilerThreadScope {
public:
CompilerThreadScope() = default;
~CompilerThreadScope() {
MG_Util::Async::SetAsyncShaderCompileSuspended(false);
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(
MG_Util::Async::ShaderCompilePool::Get().GetThreadCount());
}
CompilerThreadScope(const CompilerThreadScope&) = delete;
CompilerThreadScope& operator=(const CompilerThreadScope&) = delete;
};
const char* kVs = R"(#version 460
layout(location = 0) in vec3 aPos;
uniform vec4 uColor;
out vec4 vColor;
void main() {
vColor = uColor;
gl_Position = vec4(aPos, 1.0);
}
)";
// Deliberately expensive, and distinct per index so the source-hash memo never turns a
// second instance into a no-op: a saturated pool is the only way to observe an
// outstanding job without asserting on timing.
String MakeBulkySource(const int index) {
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
source += "uniform float uSeed" + std::to_string(index) + ";\n";
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
for (int i = 0; i < 320; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
}
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
return source;
}
GLuint MakeShader(const GLenum type, const char* source) {
const GLuint shader = CreateShader(type);
ShaderSource(shader, 1, &source, nullptr);
return shader;
}
GLint QueryShaderCompletion(const GLuint shader) {
GLint status = -1;
GetShaderiv(shader, GL_COMPLETION_STATUS_KHR, &status);
return status;
}
GLint QueryProgramCompletion(const GLuint program) {
GLint status = -1;
GetProgramiv(program, GL_COMPLETION_STATUS_KHR, &status);
return status;
}
GLint QueryCompileStatus(const GLuint shader) {
GLint status = GL_FALSE;
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
return status;
}
GLint QueryLinkStatus(const GLuint program) {
GLint status = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &status);
return status;
}
// Enqueues `count` distinct heavy compiles and returns their names without reading
// anything back, leaving the pool with a real backlog.
Vector<GLuint> EnqueueBacklog(const int count, const int seedBase, Vector<String>& sourceStorage) {
Vector<GLuint> shaders;
shaders.reserve(static_cast<SizeT>(count));
for (int i = 0; i < count; ++i) {
sourceStorage.push_back(MakeBulkySource(seedBase + i));
const char* text = sourceStorage.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
shaders.push_back(fs);
}
return shaders;
}
Bool Advertises(const Vector<GLExtension>& extensions, const GLExtension wanted) {
return std::find(extensions.begin(), extensions.end(), wanted) != extensions.end();
}
class ParallelShaderCompileTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
};
} // namespace
// ---------------------------------------------------------------------------------------
// GL_COMPLETION_STATUS_KHR must not join
// ---------------------------------------------------------------------------------------
// The load-bearing case of the whole stage. A single-worker pool is saturated with heavy
// compiles, so jobs are demonstrably still queued; GL_COMPLETION_STATUS_KHR then has to
// report GL_FALSE for at least one of them *and leave it outstanding*. If the query joined -
// which is what happens if it is ever routed through the ordinary Compiled() gate - it could
// only ever return GL_TRUE, and the extension would be a lie that costs an application the
// exact stall it added the polling loop to avoid.
//
// Skipped rather than failed when the machine drained the backlog first, so it can never be
// a false red on a fast box.
TEST_F(ParallelShaderCompileTest, ShaderCompletionStatusReportsFalseWithoutJoining) {
const AsyncModeScope async(true);
const CompilerThreadScope threads;
// One worker: the queue behind it is the thing being observed.
MaxShaderCompilerThreadsKHR(1);
Vector<String> sources;
const Vector<GLuint> shaders = EnqueueBacklog(64, 4000, sources);
int outstanding = 0;
for (const GLuint shader : shaders) {
const GLint completion = QueryShaderCompletion(shader);
ASSERT_TRUE(completion == GL_TRUE || completion == GL_FALSE) << "completion = " << completion;
if (completion == GL_FALSE) ++outstanding;
}
if (outstanding == 0) {
GTEST_SKIP() << "the pool drained 64 heavy compiles before the first query; nothing outstanding to observe";
}
// Asking again must still not have settled anything: the query is a peek, so a second
// one cannot have made progress happen. (A joining implementation would report every
// shader complete by now.)
int stillOutstanding = 0;
for (const GLuint shader : shaders) {
if (QueryShaderCompletion(shader) == GL_FALSE) ++stillOutstanding;
}
EXPECT_GT(stillOutstanding, 0) << "GL_COMPLETION_STATUS_KHR joined - every shader settled just by being asked";
// And once the real (joining) query is used, everything is complete and correct.
for (const GLuint shader : shaders) {
EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE);
EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE) << "GL_COMPILE_STATUS must have joined";
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The program half: a link enqueued behind a saturated pool cannot be complete either, and
// asking must not drag it forward.
TEST_F(ParallelShaderCompileTest, ProgramCompletionStatusReportsFalseWithoutJoining) {
const AsyncModeScope async(true);
const CompilerThreadScope threads;
MaxShaderCompilerThreadsKHR(1);
Vector<String> sources;
EnqueueBacklog(48, 4200, sources);
Vector<GLuint> programs;
for (int i = 0; i < 8; ++i) {
sources.push_back(MakeBulkySource(4400 + i));
const char* text = sources.back().c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
CompileShader(vs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
programs.push_back(program);
}
int outstanding = 0;
for (const GLuint program : programs) {
const GLint completion = QueryProgramCompletion(program);
ASSERT_TRUE(completion == GL_TRUE || completion == GL_FALSE) << "completion = " << completion;
if (completion == GL_FALSE) ++outstanding;
}
if (outstanding == 0) {
GTEST_SKIP() << "the pool drained the whole backlog before the first query; nothing outstanding to observe";
}
for (const GLuint program : programs) {
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE);
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "GL_LINK_STATUS must have joined";
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// "Nothing outstanding" is the answer for an object that was never compiled or linked at
// all: the query asks whether work is pending, not whether work ever happened.
TEST_F(ParallelShaderCompileTest, CompletionStatusIsTrueForUntouchedObjects) {
const AsyncModeScope async(true);
const GLuint shader = MakeShader(GL_FRAGMENT_SHADER, "#version 460\nvoid main() {}\n");
const GLuint program = CreateProgram();
EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE);
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// With the flag off nothing is ever in flight, so the query is constant GL_TRUE - and, just
// as importantly, still a recognized pname rather than a GL_INVALID_ENUM.
TEST_F(ParallelShaderCompileTest, CompletionStatusIsAlwaysTrueWithAsyncOff) {
const AsyncModeScope async(false);
Vector<String> sources;
const Vector<GLuint> shaders = EnqueueBacklog(8, 4600, sources);
for (const GLuint shader : shaders) {
EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE);
}
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
CompileShader(vs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, shaders.front());
LinkProgram(program);
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The pname is new; the rejection of everything else must be untouched.
TEST_F(ParallelShaderCompileTest, UnknownPnamesStillRaiseInvalidEnum) {
const GLuint shader = MakeShader(GL_FRAGMENT_SHADER, "#version 460\nvoid main() {}\n");
const GLuint program = CreateProgram();
GLint value = 0;
GetShaderiv(shader, GL_TEXTURE_2D, &value);
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
GetProgramiv(program, GL_TEXTURE_2D, &value);
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
}
// ---------------------------------------------------------------------------------------
// glMaxShaderCompilerThreadsKHR / ...ARB
// ---------------------------------------------------------------------------------------
// count == 0 is the mode switch the extension defines: no compiler threads. Two obligations
// follow, and both are asserted here - everything already in flight is settled by the time
// the call returns (so every GL_COMPLETION_STATUS_KHR reads GL_TRUE straight away), and
// compilation that happens AFTERWARDS is synchronous too.
TEST_F(ParallelShaderCompileTest, ZeroCompilerThreadsJoinsEverythingAndCompilesInline) {
const AsyncModeScope async(true);
const CompilerThreadScope threads;
MaxShaderCompilerThreadsKHR(1);
Vector<String> sources;
const Vector<GLuint> backlog = EnqueueBacklog(48, 4800, sources);
MaxShaderCompilerThreadsKHR(0);
EXPECT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended());
EXPECT_FALSE(MG_Util::Async::AsyncShaderCompileActive());
// The configuration flag itself is untouched: the extension is still advertised, the
// application just asked for serial compilation.
EXPECT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled());
for (const GLuint shader : backlog) {
EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE)
<< "glMaxShaderCompilerThreadsKHR(0) must leave nothing in flight";
EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE);
}
// Anything compiled from here on is finished before its glCompileShader returns.
Vector<String> serialSources;
const Vector<GLuint> serial = EnqueueBacklog(6, 4900, serialSources);
for (const GLuint shader : serial) {
EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE) << "a compile after a zero count must be synchronous";
}
// Links too, not just compiles.
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
CompileShader(vs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, serial.front());
LinkProgram(program);
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "a link after a zero count must be synchronous";
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ...and a later NONZERO count is what lifts it. Nothing else does: not a new context, not a
// join, not eglInitialize. That is the documented contract, so it gets an assertion.
TEST_F(ParallelShaderCompileTest, NonzeroCompilerThreadsRestoresAsynchronousCompilation) {
const AsyncModeScope async(true);
const CompilerThreadScope threads;
MaxShaderCompilerThreadsKHR(0);
ASSERT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended());
// Re-initializing must NOT quietly re-arm it - the application asked for serial
// compilation and has not taken that back.
MobileGL::Initialize();
EXPECT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended());
MaxShaderCompilerThreadsKHR(4);
EXPECT_FALSE(MG_Util::Async::IsAsyncShaderCompileSuspended());
EXPECT_TRUE(MG_Util::Async::AsyncShaderCompileActive());
// And work really is being enqueued again: with the budget back at one worker a heavy
// backlog leaves something outstanding (skip-not-fail if the box drained it first).
MaxShaderCompilerThreadsKHR(1);
Vector<String> sources;
const Vector<GLuint> shaders = EnqueueBacklog(64, 5000, sources);
const Bool anyOutstanding = std::any_of(shaders.begin(), shaders.end(), [](const GLuint shader) {
return QueryShaderCompletion(shader) == GL_FALSE;
});
if (!anyOutstanding) {
GTEST_SKIP() << "the pool drained the backlog before the first query; asynchrony not observable here";
}
for (const GLuint shader : shaders) {
EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The three count cases map onto the pool's concurrency budget: a request above the thread
// count cannot conjure threads, 0xFFFFFFFF means "implementation maximum", and an ordinary
// value is taken as given (clamped to at least one).
TEST_F(ParallelShaderCompileTest, CompilerThreadCountIsClampedToTheThreadCount) {
const AsyncModeScope async(true);
const CompilerThreadScope threads;
auto& pool = MG_Util::Async::ShaderCompilePool::Get();
const Uint threadCount = pool.GetThreadCount();
ASSERT_GE(threadCount, 1u);
MaxShaderCompilerThreadsKHR(1);
EXPECT_EQ(pool.GetMaxConcurrency(), 1u);
MaxShaderCompilerThreadsKHR(threadCount + 1000);
EXPECT_EQ(pool.GetMaxConcurrency(), threadCount) << "asking for more threads than exist cannot create any";
MaxShaderCompilerThreadsKHR(1);
ASSERT_EQ(pool.GetMaxConcurrency(), 1u);
MaxShaderCompilerThreadsKHR(0xFFFFFFFFu);
EXPECT_EQ(pool.GetMaxConcurrency(), threadCount) << "0xFFFFFFFF is the implementation maximum";
// The ARB spelling is the same entry point, not a second piece of state.
MaxShaderCompilerThreadsARB(1);
EXPECT_EQ(pool.GetMaxConcurrency(), 1u);
MaxShaderCompilerThreadsARB(0);
EXPECT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended());
MaxShaderCompilerThreadsKHR(threadCount);
EXPECT_FALSE(MG_Util::Async::IsAsyncShaderCompileSuspended())
<< "the KHR and ARB names must share one piece of state";
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// A zero count with the feature switched off is legal and does nothing observable: there is
// nothing to suspend, and the call must not fail just because MobileGL never had threads.
TEST_F(ParallelShaderCompileTest, CompilerThreadCallsAreHarmlessWithAsyncOff) {
const AsyncModeScope async(false);
const CompilerThreadScope threads;
MaxShaderCompilerThreadsKHR(0);
MaxShaderCompilerThreadsKHR(8);
MaxShaderCompilerThreadsARB(0xFFFFFFFFu);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// GL_MAX_SHADER_COMPILER_THREADS_KHR
// ---------------------------------------------------------------------------------------
TEST_F(ParallelShaderCompileTest, MaxShaderCompilerThreadsGetter) {
{
const AsyncModeScope async(true);
GLint value = -1;
GetIntegerv(GL_MAX_SHADER_COMPILER_THREADS_KHR, &value);
EXPECT_EQ(value, static_cast<GLint>(MG_Util::Async::ShaderCompilePool::Get().GetThreadCount()));
EXPECT_GE(value, 1);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
{
// No compiler threads exist in this configuration, and the extension is not
// advertised either, so zero is the honest answer.
const AsyncModeScope async(false);
GLint value = -1;
GetIntegerv(GL_MAX_SHADER_COMPILER_THREADS_KHR, &value);
EXPECT_EQ(value, 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
}
// The reported maximum is the pool's THREAD count, not its current concurrency budget: an
// application that lowered the budget still wants to know what the implementation can do.
TEST_F(ParallelShaderCompileTest, MaxShaderCompilerThreadsIgnoresTheCurrentBudget) {
const AsyncModeScope async(true);
const CompilerThreadScope threads;
const Uint threadCount = MG_Util::Async::ShaderCompilePool::Get().GetThreadCount();
MaxShaderCompilerThreadsKHR(1);
GLint value = -1;
GetIntegerv(GL_MAX_SHADER_COMPILER_THREADS_KHR, &value);
EXPECT_EQ(value, static_cast<GLint>(threadCount));
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// The extension string
// ---------------------------------------------------------------------------------------
// The advertisement is the riskiest half of P1 - a recorded trace cannot cover it, because
// Iris and Sodium change their submission schedule the moment they see the string - so
// MOBILEGL_ASYNC_SHADER_COMPILE=0 has to withdraw it. Asserted on both backends' own
// BuildAdvertisedExtensions, which is the single source of truth each of them (and the
// driver POST) builds the list from.
TEST_F(ParallelShaderCompileTest, BothBackendsAdvertiseTheExtensionIffAsyncIsEnabled) {
{
const AsyncModeScope async(true);
EXPECT_TRUE(Advertises(MG_Backend::DirectGLES::BuildAdvertisedExtensions(false, false),
E_GL_KHR_parallel_shader_compile));
EXPECT_TRUE(Advertises(MG_Backend::DirectVulkan::BuildAdvertisedExtensions(false, false, false),
E_GL_KHR_parallel_shader_compile));
}
{
const AsyncModeScope async(false);
EXPECT_FALSE(Advertises(MG_Backend::DirectGLES::BuildAdvertisedExtensions(false, false),
E_GL_KHR_parallel_shader_compile))
<< "MOBILEGL_ASYNC_SHADER_COMPILE=0 must withdraw the extension, not only the threading";
EXPECT_FALSE(Advertises(MG_Backend::DirectVulkan::BuildAdvertisedExtensions(false, false, false),
E_GL_KHR_parallel_shader_compile))
<< "MOBILEGL_ASYNC_SHADER_COMPILE=0 must withdraw the extension, not only the threading";
}
}
// The same fact through the GL surface an application actually reads. No flag flipping here:
// a backend's advertised list is built once, at its first use, from the configuration that
// was in force then - so this case asserts against the AMBIENT configuration, which is
// exactly what makes it meaningful in both of the suite's two runs (with and without
// MOBILEGL_ASYNC_SHADER_COMPILE=1 exported).
TEST_F(ParallelShaderCompileTest, GLExtensionStringTracksTheAmbientConfiguration) {
UniquePtr<MG_Backend::BackendObject> previousBackend = Move(MG_Backend::pActiveBackendObject);
MG_Backend::pActiveBackendObject = MakeUnique<MG_Backend::DirectGLES::BackendObject_DirectGLES>();
const char* extensions = reinterpret_cast<const char*>(GetString(GL_EXTENSIONS));
ASSERT_NE(extensions, nullptr);
const String extensionString(extensions);
const Bool advertised = extensionString.find("GL_KHR_parallel_shader_compile") != String::npos;
EXPECT_EQ(advertised, MG_Util::Async::AsyncShaderCompileEnabled()) << "GL_EXTENSIONS = " << extensionString;
// glGetStringi must agree with the monolithic string - LWJGL builds GLCapabilities from
// the indexed form on a core profile.
GLint count = 0;
GetIntegerv(GL_NUM_EXTENSIONS, &count);
ASSERT_GT(count, 0);
Bool foundIndexed = false;
for (GLint i = 0; i < count; ++i) {
const char* name = reinterpret_cast<const char*>(GetStringi(GL_EXTENSIONS, static_cast<GLuint>(i)));
if (name != nullptr && std::string(name) == "GL_KHR_parallel_shader_compile") foundIndexed = true;
}
EXPECT_EQ(foundIndexed, advertised);
MG_Backend::pActiveBackendObject = Move(previousBackend);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
File diff suppressed because it is too large Load Diff
+871
View File
@@ -19,6 +19,8 @@
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_State/GLState/Core.h"
#include "MG_State/GLState/ProgramState/ShaderPreprocessCache.h"
#include "MG_Util/Async/ShaderCompilePool.h"
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
using namespace MobileGL;
@@ -346,6 +348,29 @@ void main() {
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
TEST_F(ProgramTest, OutOfRangeComputeLocalSizeLiteralFailsCompileInsteadOfThrowing) {
// The layout scanner's digit capture is unbounded, so a literal wider than 64 bits is a
// legal match. It must saturate and be rejected through COMPILE_STATUS; if the integer
// conversion throws instead, the exception escapes glCompileShader entirely.
char infoLog[1024] = "";
const char* csSrc = R"(#version 460 core
layout(local_size_x = 99999999999999999999999) in;
void main() {
}
)";
GLuint cs = CreateShader(GL_COMPUTE_SHADER);
ShaderSource(cs, 1, &csSrc, nullptr);
CompileShader(cs);
GLint csStatus = GL_TRUE;
GetShaderiv(cs, GL_COMPILE_STATUS, &csStatus);
EXPECT_EQ(csStatus, GL_FALSE);
GetShaderInfoLog(cs, sizeof(infoLog), nullptr, infoLog);
EXPECT_NE(String(infoLog).find("GL_MAX_COMPUTE_WORK_GROUP_SIZE"), String::npos) << infoLog;
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
TEST_F(ProgramTest, DirectVulkanStorageBlockUsesShaderLayoutBinding) {
char infoLog[1024] = "";
const char* csSrc = R"(#version 460 core
@@ -2365,3 +2390,849 @@ void main() { o_color = vec4(1.0); }
EXPECT_EQ(IsShader(fs), GL_FALSE);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---- P0a single-parse regression tests ----
// glCompileShader now performs the one link-compatible (relaxed Vulkan-rules) parse;
// these pin the GL frontend semantics that parse cannot provide by itself.
namespace {
GLuint CompileShaderChecked(GLenum type, const char* source) {
char infoLog[1024] = "";
GLuint shader = CreateShader(type);
ShaderSource(shader, 1, &source, nullptr);
CompileShader(shader);
GLint status = GL_FALSE;
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
GetShaderInfoLog(shader, sizeof(infoLog), nullptr, infoLog);
EXPECT_EQ(status, GL_TRUE) << infoLog;
return shader;
}
GLuint LinkVsFs(GLuint vs, GLuint fs, GLint expectedLinkStatus) {
char infoLog[2048] = "";
GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, fs);
LinkProgram(program);
GLint linkStatus = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
GetProgramInfoLog(program, sizeof(infoLog), nullptr, infoLog);
EXPECT_EQ(linkStatus, expectedLinkStatus) << infoLog;
return program;
}
} // namespace
// The relaxed parse sweeps every DECLARED default-block uniform into MGL_GLOBAL_UBO,
// including ones no stage reads. GL requires those to be inactive: absent from the
// glGetActiveUniform enumeration and -1 from glGetUniformLocation. The synthesized
// MGL_GLOBAL_UBO itself must not surface as a GL uniform block either.
TEST_F(ProgramTest, DeclaredButUnreadUniformIsInactiveAndGlobalUboStaysHidden) {
const char* vsSource = R"(#version 330 core
uniform mat4 uUsedMat;
uniform vec4 uDeadVec;
void main() { gl_Position = uUsedMat * vec4(1.0); }
)";
const char* fsSource = R"(#version 330 core
uniform vec4 uUsedColor;
uniform float uDeadFloat;
out vec4 fragColor;
void main() { fragColor = uUsedColor; }
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
GLint activeUniforms = 0;
GetProgramiv(program, GL_ACTIVE_UNIFORMS, &activeUniforms);
EXPECT_EQ(activeUniforms, 2);
EXPECT_NE(GetUniformLocation(program, "uUsedMat"), -1);
EXPECT_NE(GetUniformLocation(program, "uUsedColor"), -1);
EXPECT_EQ(GetUniformLocation(program, "uDeadVec"), -1);
EXPECT_EQ(GetUniformLocation(program, "uDeadFloat"), -1);
EXPECT_EQ(UniformIndexByName(program, "uDeadVec"), GL_INVALID_INDEX);
char nameBuf[64] = "";
for (GLint i = 0; i < activeUniforms; ++i) {
GLsizei nameLen = 0;
GLint size = 0;
GLenum type = 0;
GetActiveUniform(program, static_cast<GLuint>(i), sizeof(nameBuf), &nameLen, &size, &type, nameBuf);
EXPECT_TRUE(std::strcmp(nameBuf, "uDeadVec") != 0 && std::strcmp(nameBuf, "uDeadFloat") != 0)
<< nameBuf;
}
// No named blocks are declared, so GL must see zero uniform blocks - the global
// UBO the transpiler materializes is an implementation artifact.
GLint activeBlocks = 0;
GetProgramiv(program, GL_ACTIVE_UNIFORM_BLOCKS, &activeBlocks);
EXPECT_EQ(activeBlocks, 0);
EXPECT_EQ(GetUniformBlockIndex(program, "MGL_GLOBAL_UBO"), GL_INVALID_INDEX);
// Default-block uniforms report block index -1 and offset -1 even though the
// relaxed parse physically placed them in the global UBO.
const GLuint usedMat = UniformIndexByName(program, "uUsedMat");
ASSERT_NE(usedMat, GL_INVALID_INDEX);
EXPECT_EQ(QueryUniformiv(program, usedMat, GL_UNIFORM_BLOCK_INDEX), -1);
EXPECT_EQ(QueryUniformiv(program, usedMat, GL_UNIFORM_OFFSET), -1);
EXPECT_EQ(QueryUniformiv(program, usedMat, GL_UNIFORM_ARRAY_STRIDE), -1);
EXPECT_EQ(QueryUniformiv(program, usedMat, GL_UNIFORM_MATRIX_STRIDE), -1);
EXPECT_EQ(QueryUniformiv(program, usedMat, GL_UNIFORM_IS_ROW_MAJOR), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Distinct uniforms whose explicit locations overlap across stages must fail the
// link (ARB_explicit_uniform_location). The GL-client parse used to reject this at
// glslang mapIO; the relaxed parse drops the qualifiers, so the location assigner
// enforces it - this is the experiment's synthetic divergence case.
TEST_F(ProgramTest, ExplicitUniformLocationOverlapAcrossStagesFailsLink) {
const char* vsSource = R"(#version 460 core
layout(location = 3) uniform vec4 uVec[4];
void main() { gl_Position = uVec[0] + uVec[3]; }
)";
const char* fsSource = R"(#version 460 core
layout(location = 5) uniform float uF;
out vec4 fragColor;
void main() { fragColor = vec4(uF); }
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
GLuint program = LinkVsFs(vs, fs, GL_FALSE);
char infoLog[1024] = "";
GLsizei logLength = 0;
GetProgramInfoLog(program, sizeof(infoLog), &logLength, infoLog);
EXPECT_GT(logLength, 0);
}
// The same uniform declared with different explicit locations in two stages is a
// link error as well.
TEST_F(ProgramTest, ConflictingExplicitUniformLocationsOnSameUniformFailLink) {
const char* vsSource = R"(#version 460 core
layout(location = 2) uniform vec4 uShared;
void main() { gl_Position = uShared; }
)";
const char* fsSource = R"(#version 460 core
layout(location = 4) uniform vec4 uShared;
out vec4 fragColor;
void main() { fragColor = uShared; }
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
(void)LinkVsFs(vs, fs, GL_FALSE);
}
// Same-location explicit declarations of the SAME uniform in both stages stay
// linkable, and both explicit locations (opaque and non-opaque) are honored.
TEST_F(ProgramTest, ExplicitUniformLocationsHonoredForPlainAndOpaqueUniforms) {
const char* vsSource = R"(#version 460 core
layout(location = 11) uniform mat4 uMvp;
void main() { gl_Position = uMvp * vec4(1.0); }
)";
const char* fsSource = R"(#version 460 core
layout(location = 7) uniform sampler2D uTex;
layout(location = 11) uniform mat4 uMvp;
out vec4 fragColor;
void main() { fragColor = texture(uTex, uMvp[0].xy); }
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
EXPECT_EQ(GetUniformLocation(program, "uMvp"), 11);
EXPECT_EQ(GetUniformLocation(program, "uTex"), 7);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// A glslang-auto-assigned opaque location may collide with a source-explicit plain
// uniform location under the relaxed parse (glslang no longer sees the plain
// uniform's qualifier). The assigner must relocate the auto one, not fail the link.
TEST_F(ProgramTest, AutoOpaqueLocationCollidingWithExplicitPlainLocationRelocates) {
const char* vsSource = R"(#version 460 core
layout(location = 0) uniform mat4 uM;
void main() { gl_Position = uM * vec4(1.0); }
)";
const char* fsSource = R"(#version 460 core
uniform sampler2D uTex;
out vec4 fragColor;
void main() { fragColor = texture(uTex, vec2(0.5)); }
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
const GLint mLoc = GetUniformLocation(program, "uM");
const GLint texLoc = GetUniformLocation(program, "uTex");
EXPECT_EQ(mLoc, 0);
ASSERT_NE(texLoc, -1);
EXPECT_NE(texLoc, mLoc);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Relinking a program and linking the same compiled shaders into a second program
// both re-consume the stored single parse (glslang mapIO mutates a linked TShader,
// so reuse goes through the consume-once re-parse path). Reflection must be intact
// every time, without any glCompileShader in between.
TEST_F(ProgramTest, RelinkAndSecondProgramReuseCompiledShaders) {
const char* vsSource = R"(#version 330 core
uniform mat4 uMvp;
in vec3 aPos;
void main() { gl_Position = uMvp * vec4(aPos, 1.0); }
)";
const char* fsSource = R"(#version 330 core
uniform sampler2D uTex;
uniform vec4 uTint;
out vec4 fragColor;
void main() { fragColor = texture(uTex, vec2(0.5)) * uTint; }
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
GLuint program1 = LinkVsFs(vs, fs, GL_TRUE);
GLint activeUniforms1 = 0;
GetProgramiv(program1, GL_ACTIVE_UNIFORMS, &activeUniforms1);
EXPECT_EQ(activeUniforms1, 3);
EXPECT_NE(GetUniformLocation(program1, "uMvp"), -1);
// Relink: consumes the re-parse path.
LinkProgram(program1);
GLint relinkStatus = GL_FALSE;
char infoLog[1024] = "";
GetProgramiv(program1, GL_LINK_STATUS, &relinkStatus);
GetProgramInfoLog(program1, sizeof(infoLog), nullptr, infoLog);
ASSERT_EQ(relinkStatus, GL_TRUE) << infoLog;
GLint activeUniformsRelink = 0;
GetProgramiv(program1, GL_ACTIVE_UNIFORMS, &activeUniformsRelink);
EXPECT_EQ(activeUniformsRelink, 3);
EXPECT_NE(GetUniformLocation(program1, "uTint"), -1);
// Same shaders into a fresh program.
GLuint program2 = LinkVsFs(vs, fs, GL_TRUE);
GLint activeUniforms2 = 0;
GetProgramiv(program2, GL_ACTIVE_UNIFORMS, &activeUniforms2);
EXPECT_EQ(activeUniforms2, 3);
EXPECT_NE(GetUniformLocation(program2, "uTex"), -1);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Programs and shaders share one GL name space (GL 3.3 core 2.11). A name must
// never be handed out as both, and a shader name passed where a program is
// expected is INVALID_OPERATION (KHR-GL30.get_uniform_tests.get_uniform relies
// on this; a name-collided linked program used to swallow the error).
TEST_F(ProgramTest, ProgramAndShaderNamesShareOneNameSpace) {
GLuint program = CreateProgram();
GLuint vs = CreateShader(GL_VERTEX_SHADER);
GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
EXPECT_NE(program, vs);
EXPECT_NE(program, fs);
EXPECT_NE(vs, fs);
EXPECT_EQ(IsProgram(vs), GL_FALSE);
EXPECT_EQ(IsShader(program), GL_FALSE);
GLfloat floatValue = 0.0f;
GetUniformfv(vs, 0, &floatValue);
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_INVALID_OPERATION));
GLint intValue = 0;
GetUniformiv(fs, 0, &intValue);
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_INVALID_OPERATION));
// A never-allocated name is INVALID_VALUE, distinguishing the two cases.
GetUniformfv(program + vs + fs + 100, 0, &floatValue);
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_INVALID_VALUE));
DeleteShader(vs);
DeleteShader(fs);
DeleteProgram(program);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---- builtin-shadowing OpName pass (P0c) ----
// Desktop GLSL lets a pack redefine builtins; ESSL 3.x forbids it, so the rename
// now happens as a SPIR-V OpName pass in SanitizeAndOptimizeBinary instead of the
// old whole-source string scan. These pin the pass end-to-end: real sources through
// glCompileShader/glLinkProgram, generated SPIR-V transpiled to the ESSL the Espryt
// driver would see.
namespace {
Vector<MobileGL::String> TranspileProgramSpirvToEssl(GLuint program) {
Vector<MobileGL::String> esslModules;
auto programObj = MG_State::pGLContext->GetProgramObject(program);
for (auto& spirvCode : programObj->GetGeneratedSpirv()) {
MG_Util::ShaderTranspiler::SpvcSession spvcSession(
spirvCode, MG_Util::ShaderTranspiler::SessionUsageBit::Transpile);
spvc_compiler_options options;
spvcSession.CreateOptions(&options);
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);
spvcSession.SetOptions(options);
const char* result = nullptr;
spvcSession.Compile(&result);
EXPECT_NE(result, nullptr) << spvcSession.GetLastErrorString();
esslModules.push_back(result ? result : "");
}
return esslModules;
}
} // namespace
// The two blind spots of the old string scan, eliminated by construction: a
// MULTILINE definition (bliss-shaped "float fma\n(...)"), and names outside the
// old 5-entry list: sinh, as a NEW overload no builtin signature matches, so it
// parses fine and the SPIR-V OpName backstop does the rename. (An EXACT-signature
// sinh redefinition is parse-rejected by glslang - on HEAD too - and is therefore
// deliberately NOT lexically rescued; see kLexicalPreemptRenameNames.)
// min3/max3 keep their historical coverage.
TEST_F(ProgramTest, BuiltinShadowingFunctionsRenamedInEsslOutput) {
const char* vsSource = R"(#version 330 core
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
)";
const char* fsSource = R"(#version 330 core
out vec4 fragColor;
float fma
(float a, float b, float c) { return a * b + c; }
float sinh(float x, float y) { return x * y; }
float round(float x) { return floor(x + 0.5); }
float min3(float a, float b, float c) { return min(min(a, b), c); }
void main() {
fragColor = vec4(fma(0.1, 0.2, 0.3), sinh(0.4, 2.0), round(1.25), min3(0.1, 0.2, 0.3));
}
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
for (const auto& essl : TranspileProgramSpirvToEssl(program)) {
if (essl.find("fragColor") == String::npos) continue; // fragment module only
EXPECT_NE(essl.find("mg_fma("), String::npos) << essl;
EXPECT_NE(essl.find("mg_sinh("), String::npos) << essl;
EXPECT_NE(essl.find("mg_round("), String::npos) << essl;
EXPECT_NE(essl.find("mg_min3("), String::npos) << essl;
EXPECT_EQ(essl.find("float fma("), String::npos) << essl;
EXPECT_EQ(essl.find("float sinh("), String::npos) << essl;
EXPECT_EQ(essl.find("float round("), String::npos) << essl;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Pure builtin USAGE (plus a commented-out definition) must stay untouched: builtin
// calls never resolve to a user function id in SPIR-V, so no mg_ name may appear.
TEST_F(ProgramTest, BuiltinUsageWithoutShadowingDefinitionKeepsBuiltinCalls) {
const char* vsSource = R"(#version 330 core
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
)";
// 400, not 330: the builtin fma() really is called here, and it is only core from GLSL 4.00
// (at 330 it needs GL_ARB_gpu_shader5). The shadowing case above can stay at 330 precisely
// because the rename means no call to the builtin survives.
const char* fsSource = R"(#version 400 core
// float round(float x) { return floor(x + 0.5); }
out vec4 fragColor;
void main() {
fragColor = vec4(round(1.25), fma(0.1, 0.2, 0.3), tanh(0.5), 1.0);
}
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
for (const auto& essl : TranspileProgramSpirvToEssl(program)) {
EXPECT_EQ(essl.find("mg_"), String::npos) << essl;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---- the three shapes the lexical pre-empt pass must NOT touch (P0c) ----
// The source-level rename runs only for the handful of names glslang's relaxed
// parse rejects outright; everything else waits for the OpName pass, which cannot
// over-fire. These pin the three ways a lexical scan gets it wrong. All of them
// would fail as "no matching overloaded function found" - an over-detection is
// unrecoverable because the source never reaches SPIR-V.
namespace {
// "pow(" as a real builtin call, i.e. not the tail of "mg_pow(".
bool ContainsUnprefixedCall(const MobileGL::String& essl, const MobileGL::String& name) {
const MobileGL::String needle = name + "(";
for (SizeT pos = essl.find(needle); pos != String::npos; pos = essl.find(needle, pos + 1)) {
const char before = pos == 0 ? ' ' : essl[pos - 1];
const bool isIdentifierChar =
std::isalnum(static_cast<unsigned char>(before)) != 0 || before == '_';
if (!isIdentifierChar) return true;
}
return false;
}
} // namespace
// B1: preprocessor-asymmetric braces desync a raw brace-depth counter (each arm of
// the #ifdef closes the function), and "return" is lexically an identifier - so
// "return clamp(...)" reads as a top-level definition "<type> <builtin> (". A
// shader that shadows nothing must survive intact.
TEST_F(ProgramTest, StatementKeywordCallInPreprocessorAsymmetricBracesIsNotAShadowingDefinition) {
const char* vsSource = R"(#version 330 core
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
)";
const char* fsSource = R"(#version 330 core
uniform vec3 uP;
out vec4 fragColor;
float getShadow(vec3 v) {
#ifdef SHADOW_OFF
return 1.0;
}
#else
return round(dot(v, v));
}
#endif
void main() { fragColor = vec4(getShadow(uP) * clamp(uP.x, 0.0, 1.0)); }
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
for (const auto& essl : TranspileProgramSpirvToEssl(program)) {
if (essl.find("fragColor") == String::npos) continue; // fragment module only
EXPECT_EQ(essl.find("mg_"), String::npos) << essl;
// SPIRV-Cross lowers GLSL.std.450 FClamp to its NaN-correct min/max/isnan form, so the
// surviving evidence of the builtin call is that pair, not the spelling "clamp(". The
// stronger guard is above it: a renamed mg_clamp would not have compiled at all.
EXPECT_TRUE(ContainsUnprefixedCall(essl, "min")) << essl;
EXPECT_TRUE(ContainsUnprefixedCall(essl, "max")) << essl;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// B2: the scan is preprocessor-blind, so a definition in a DEAD #if branch would
// poison every live call to the real builtin. #version 120 normalizes to 330, so
// __VERSION__ is 330 and the compat shim is dropped by glslang - the definition
// never exists, and nothing may be renamed.
TEST_F(ProgramTest, ShadowingDefinitionInDeadPreprocessorBranchLeavesLiveBuiltinCalls) {
const char* vsSource = R"(#version 120
#if __VERSION__ < 140
mat4 inverse(mat4 m) { return m; }
#endif
uniform mat4 uM;
uniform vec4 uV;
void main() { gl_Position = inverse(uM) * uV; }
)";
const char* fsSource = R"(#version 330 core
out vec4 fragColor;
void main() { fragColor = vec4(1.0); }
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
for (const auto& essl : TranspileProgramSpirvToEssl(program)) {
if (essl.find("gl_Position") == String::npos) continue; // vertex module only
EXPECT_EQ(essl.find("mg_"), String::npos) << essl;
EXPECT_TRUE(ContainsUnprefixedCall(essl, "inverse")) << essl;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// B3: the idiomatic reason to shadow a builtin is to ADD an overload and delegate
// to the real one. A blanket call-site rewrite would turn the body's builtin call
// into mg_pow(vec3, vec3), which has no overload. The OpName backstop renames the
// user function id only, so the delegation still resolves to GLSL.std.450 Pow.
TEST_F(ProgramTest, OverloadDelegatingToShadowedBuiltinKeepsItsBuiltinCall) {
const char* vsSource = R"(#version 330 core
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
)";
const char* fsSource = R"(#version 330 core
uniform vec3 uBase;
out vec4 fragColor;
vec3 pow(vec3 v, float e) { return pow(v, vec3(e)); }
void main() { fragColor = vec4(pow(uBase, 2.2), 1.0); }
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
for (const auto& essl : TranspileProgramSpirvToEssl(program)) {
if (essl.find("fragColor") == String::npos) continue; // fragment module only
EXPECT_NE(essl.find("mg_pow("), String::npos) << essl;
EXPECT_TRUE(ContainsUnprefixedCall(essl, "pow")) << essl;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------
// P0b: source-hash dedupe for shader recompiles.
// Layer 1 - the same shader object re-sourced with byte-identical text keeps its
// compiled state, and glCompileShader on it is a no-op.
// Layer 2 - two DIFFERENT shader objects holding byte-identical text share the
// source-only half of the pipeline (preprocess + lexical checks +
// side-channel extraction) through the context's ShaderPreprocessCache,
// while each still gets its own glslang parse.
// ---------------------------------------------------------------------------
namespace {
const char* kP0bVs = R"(#version 330 core
uniform mat4 uModel;
uniform vec4 uTint;
void main() { gl_Position = uModel * uTint; }
)";
const char* kP0bFs = R"(#version 330 core
uniform vec4 uColor;
out vec4 fragColor;
void main() { fragColor = uColor; }
)";
// Same stage, different declared uniform: makes "did it actually recompile?"
// observable through reflection rather than through internal state.
const char* kP0bAltFs = R"(#version 330 core
uniform vec4 uOtherColor;
out vec4 fragColor;
void main() { fragColor = uOtherColor; }
)";
const char* kP0bBrokenFs = R"(#version 330 core
out vec4 fragColor;
void main() { fragColor = notADeclaredThing; }
)";
GLuint MakeShaderWithSource(GLenum type, const char* source) {
GLuint shader = CreateShader(type);
ShaderSource(shader, 1, &source, nullptr);
return shader;
}
GLint QueryCompileStatus(GLuint shader) {
GLint status = GL_FALSE;
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
return status;
}
String QueryShaderInfoLog(GLuint shader) {
GLint length = 0;
GetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
if (length <= 0) return String();
std::vector<GLchar> buffer(static_cast<size_t>(length));
GLsizei written = 0;
GetShaderInfoLog(shader, length, &written, buffer.data());
return String(buffer.data(), static_cast<size_t>(written));
}
Bool ShaderHasMemoizedCompile(GLuint shader) {
const auto& shaderObject = MG_State::pGLContext->GetShaderObject(shader);
EXPECT_NE(shaderObject, nullptr);
return shaderObject != nullptr && shaderObject->HasMemoizedCompile();
}
} // namespace
// Layer 1, success path: re-sourcing with identical text and recompiling must leave
// COMPILE_STATUS, the info log and every downstream consumer exactly as they were -
// including a program that links the shader AFTER the redundant recompile.
TEST_F(ProgramTest, RecompileWithIdenticalSourceKeepsCompiledStateAndStillLinks) {
GLuint vs = MakeShaderWithSource(GL_VERTEX_SHADER, kP0bVs);
GLuint fs = MakeShaderWithSource(GL_FRAGMENT_SHADER, kP0bFs);
CompileShader(vs);
CompileShader(fs);
ASSERT_EQ(QueryCompileStatus(vs), GL_TRUE) << QueryShaderInfoLog(vs);
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
const String vsLogBefore = QueryShaderInfoLog(vs);
EXPECT_TRUE(ShaderHasMemoizedCompile(vs));
// A first link consumes the stored TShader; the redundant recompile below must not
// disturb the preprocessed source that ClaimParsedShader re-parses from.
GLuint firstProgram = LinkVsFs(vs, fs, GL_TRUE);
EXPECT_GE(GetUniformLocation(firstProgram, "uColor"), 0);
// glShaderSource with byte-identical text, then glCompileShader: both no-ops.
ShaderSource(vs, 1, &kP0bVs, nullptr);
EXPECT_TRUE(ShaderHasMemoizedCompile(vs)) << "identical re-source must not invalidate the compiled state";
CompileShader(vs);
ShaderSource(fs, 1, &kP0bFs, nullptr);
CompileShader(fs);
EXPECT_EQ(QueryCompileStatus(vs), GL_TRUE);
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE);
EXPECT_EQ(QueryShaderInfoLog(vs), vsLogBefore);
// The original source text is still what glGetShaderSource reports.
GLint sourceLength = 0;
GetShaderiv(vs, GL_SHADER_SOURCE_LENGTH, &sourceLength);
ASSERT_GT(sourceLength, 1);
std::vector<GLchar> sourceBuffer(static_cast<size_t>(sourceLength));
GLsizei written = 0;
GetShaderSource(vs, sourceLength, &written, sourceBuffer.data());
EXPECT_EQ(String(sourceBuffer.data(), static_cast<size_t>(written)), String(kP0bVs));
// A second program built from the same, redundantly recompiled shaders links and
// reflects - i.e. ClaimParsedShader's re-parse path survived the no-op.
GLuint secondProgram = LinkVsFs(vs, fs, GL_TRUE);
EXPECT_GE(GetUniformLocation(secondProgram, "uColor"), 0);
EXPECT_GE(GetUniformLocation(secondProgram, "uModel"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Layer 1 must not swallow a REAL source change: different text invalidates, and the
// change is visible in what the next link reflects.
TEST_F(ProgramTest, DifferentSourceAfterCompileInvalidatesCompiledState) {
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, kP0bVs);
GLuint fs = MakeShaderWithSource(GL_FRAGMENT_SHADER, kP0bFs);
CompileShader(fs);
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
GLuint firstProgram = LinkVsFs(vs, fs, GL_TRUE);
EXPECT_GE(GetUniformLocation(firstProgram, "uColor"), 0);
EXPECT_EQ(GetUniformLocation(firstProgram, "uOtherColor"), -1);
// New text -> compiled state gone, and glCompileShader is mandatory again.
ShaderSource(fs, 1, &kP0bAltFs, nullptr);
EXPECT_FALSE(ShaderHasMemoizedCompile(fs));
EXPECT_EQ(QueryCompileStatus(fs), GL_FALSE);
CompileShader(fs);
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
GLuint secondProgram = LinkVsFs(vs, fs, GL_TRUE);
EXPECT_GE(GetUniformLocation(secondProgram, "uOtherColor"), 0);
EXPECT_EQ(GetUniformLocation(secondProgram, "uColor"), -1);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Layer 2: byte-identical source in two distinct shader objects. Both must compile,
// and each must own an independent TShader - if the parse were shared, the second
// link would be handed an intermediate that the first link's mapIO already mutated.
TEST_F(ProgramTest, TwoShaderObjectsWithIdenticalSourceLinkIndependently) {
GLuint vsA = CompileShaderChecked(GL_VERTEX_SHADER, kP0bVs);
GLuint fsA = CompileShaderChecked(GL_FRAGMENT_SHADER, kP0bFs);
GLuint vsB = CompileShaderChecked(GL_VERTEX_SHADER, kP0bVs);
GLuint fsB = CompileShaderChecked(GL_FRAGMENT_SHADER, kP0bFs);
ASSERT_NE(vsA, vsB);
ASSERT_NE(fsA, fsB);
const auto& objectA = MG_State::pGLContext->GetShaderObject(vsA);
const auto& objectB = MG_State::pGLContext->GetShaderObject(vsB);
ASSERT_NE(objectA, nullptr);
ASSERT_NE(objectB, nullptr);
EXPECT_EQ(objectA->GetShaderSource(), objectB->GetShaderSource());
// P0b's layer 2 shares the PREPROCESS and never the parse: glslang's TShader is
// consume-once, so a memo hit still has to parse for itself.
//
// P1 stage 6 shares something stronger when it is active - the whole compile JOB, and
// therefore the single parse that job produced - and that sharing is made safe by
// ShaderCompileTask::ClaimParsedShader's CAS instead, exactly as it already was for one
// shader object attached to two programs. ShaderCompileAdoptionTest is where that is
// pinned down (it links both objects and compares the generated SPIR-V). So the
// one-parse-per-object assertion belongs to the non-adopting path; the two independent
// LINKS below are what both modes have to agree on, and they are the point of this case.
if (!MG_Util::Async::AsyncShaderCompileActive()) {
EXPECT_NE(objectA->GetCompiledShader(), objectB->GetCompiledShader());
}
EXPECT_NE(objectA->GetCompiledShader(), nullptr);
EXPECT_NE(objectB->GetCompiledShader(), nullptr);
GLuint programA = LinkVsFs(vsA, fsA, GL_TRUE);
GLuint programB = LinkVsFs(vsB, fsB, GL_TRUE);
for (GLuint program : {programA, programB}) {
EXPECT_GE(GetUniformLocation(program, "uColor"), 0);
EXPECT_GE(GetUniformLocation(program, "uModel"), 0);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Failure memoization: a compile that failed stays failed, with the SAME log, when
// recompiled against the same source; a real fix to the source still takes effect.
// The second object pins the cached-ParseFailed path (layer 2), which skips the parse
// entirely and must reproduce the identical verdict.
TEST_F(ProgramTest, FailedCompileIsMemoizedAndStillRecoversOnGoodSource) {
GLuint fs = MakeShaderWithSource(GL_FRAGMENT_SHADER, kP0bBrokenFs);
CompileShader(fs);
ASSERT_EQ(QueryCompileStatus(fs), GL_FALSE);
const String failureLog = QueryShaderInfoLog(fs);
EXPECT_FALSE(failureLog.empty());
// Layer 1: identical re-source + recompile keeps the failure AND the log queryable.
ShaderSource(fs, 1, &kP0bBrokenFs, nullptr);
CompileShader(fs);
EXPECT_EQ(QueryCompileStatus(fs), GL_FALSE);
EXPECT_EQ(QueryShaderInfoLog(fs), failureLog);
// Layer 2: a second object with the same broken source reports the same failure.
GLuint otherFs = MakeShaderWithSource(GL_FRAGMENT_SHADER, kP0bBrokenFs);
CompileShader(otherFs);
EXPECT_EQ(QueryCompileStatus(otherFs), GL_FALSE);
EXPECT_EQ(QueryShaderInfoLog(otherFs), failureLog);
// A genuine fix still compiles and links.
ShaderSource(fs, 1, &kP0bFs, nullptr);
CompileShader(fs);
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
EXPECT_TRUE(QueryShaderInfoLog(fs).empty());
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, kP0bVs);
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
EXPECT_GE(GetUniformLocation(program, "uColor"), 0);
}
// Layer 2 under eviction: push more distinct sources through the context than the
// cache can hold, then confirm nothing broke and a fresh duplicate pair still works.
TEST_F(ProgramTest, PreprocessCacheOverflowKeepsCompilingCorrectly) {
const SizeT overflow = MG_State::GLState::ShaderPreprocessCache::kMaxEntries + 8;
for (SizeT i = 0; i < overflow; ++i) {
const String source = "#version 330 core\nuniform vec4 uColor" + ToString(i) +
";\nout vec4 fragColor;\nvoid main() { fragColor = uColor" + ToString(i) + "; }\n";
const char* sourcePtr = source.c_str();
GLuint shader = MakeShaderWithSource(GL_FRAGMENT_SHADER, sourcePtr);
CompileShader(shader);
ASSERT_EQ(QueryCompileStatus(shader), GL_TRUE) << QueryShaderInfoLog(shader) << "\n" << source;
DeleteShader(shader);
}
// Everything inserted above has long since been evicted; a brand-new duplicate
// pair must still take the layer-2 path and produce two working programs.
GLuint vsA = CompileShaderChecked(GL_VERTEX_SHADER, kP0bVs);
GLuint fsA = CompileShaderChecked(GL_FRAGMENT_SHADER, kP0bFs);
GLuint vsB = CompileShaderChecked(GL_VERTEX_SHADER, kP0bVs);
GLuint fsB = CompileShaderChecked(GL_FRAGMENT_SHADER, kP0bFs);
GLuint programA = LinkVsFs(vsA, fsA, GL_TRUE);
GLuint programB = LinkVsFs(vsB, fsB, GL_TRUE);
EXPECT_GE(GetUniformLocation(programA, "uColor"), 0);
EXPECT_GE(GetUniformLocation(programB, "uColor"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// glUniformMatrix{2x3,2x4,3x2,3x4,4x2,4x3}fv and their twelve glProgramUniformMatrix* twins were
// validate-only no-ops: they never took the value pointer at all. They upload column-at-a-time at
// the std140 16-byte column stride, honouring `transpose`, and glGetUniformfv undoes that padding.
TEST_F(ProgramTest, NonSquareMatrixUniformsRoundTripThroughTheGlobalUbo) {
const char* vsSource = R"(#version 430 core
uniform mat2x3 uM2x3;
uniform mat3x2 uM3x2;
uniform mat4x3 uM4x3;
uniform mat2 uM2;
void main() {
vec3 a = uM2x3 * vec2(1.0);
vec2 b = uM3x2 * vec3(1.0);
vec3 c = uM4x3 * vec4(1.0);
vec2 d = uM2 * vec2(1.0);
gl_Position = vec4(a.xy + b + c.xy + d, 0.0, 1.0);
}
)";
const char* fsSource = R"(#version 430 core
out vec4 fragColor;
void main() { fragColor = vec4(1.0); }
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
UseProgram(program);
ASSERT_EQ(GetError(), GL_NO_ERROR);
// matCxR is C columns of R rows, column-major: value[c * R + r].
const GLfloat m2x3[6] = {1, 2, 3, 4, 5, 6};
const GLfloat m3x2[6] = {1, 2, 3, 4, 5, 6};
const GLfloat m4x3[12] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
const GLint loc2x3 = GetUniformLocation(program, "uM2x3");
const GLint loc3x2 = GetUniformLocation(program, "uM3x2");
const GLint loc4x3 = GetUniformLocation(program, "uM4x3");
ASSERT_GE(loc2x3, 0);
ASSERT_GE(loc3x2, 0);
ASSERT_GE(loc4x3, 0);
UniformMatrix2x3fv(loc2x3, 1, GL_FALSE, m2x3);
UniformMatrix3x2fv(loc3x2, 1, GL_FALSE, m3x2);
UniformMatrix4x3fv(loc4x3, 1, GL_FALSE, m4x3);
ASSERT_EQ(GetError(), GL_NO_ERROR);
GLfloat readBack[12] = {};
GetUniformfv(program, loc2x3, readBack);
EXPECT_EQ(std::memcmp(readBack, m2x3, sizeof(m2x3)), 0);
std::memset(readBack, 0, sizeof(readBack));
GetUniformfv(program, loc3x2, readBack);
EXPECT_EQ(std::memcmp(readBack, m3x2, sizeof(m3x2)), 0);
std::memset(readBack, 0, sizeof(readBack));
GetUniformfv(program, loc4x3, readBack);
EXPECT_EQ(std::memcmp(readBack, m4x3, sizeof(m4x3)), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
// transpose = GL_TRUE means the source is row-major: a mat3x2 (3 columns, 2 rows) is then
// given as 2 rows of 3, so {1,2,3, 4,5,6} is the column-major {1,4, 2,5, 3,6}.
UniformMatrix3x2fv(loc3x2, 1, GL_TRUE, m3x2);
const GLfloat expectedTransposed3x2[6] = {1, 4, 2, 5, 3, 6};
std::memset(readBack, 0, sizeof(readBack));
GetUniformfv(program, loc3x2, readBack);
EXPECT_EQ(std::memcmp(readBack, expectedTransposed3x2, sizeof(expectedTransposed3x2)), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
// The glProgramUniform* twin writes the same bytes without the program being current.
UseProgram(0);
const GLfloat other2x3[6] = {9, 8, 7, 6, 5, 4};
ProgramUniformMatrix2x3fv(program, loc2x3, 1, GL_FALSE, other2x3);
std::memset(readBack, 0, sizeof(readBack));
GetUniformfv(program, loc2x3, readBack);
EXPECT_EQ(std::memcmp(readBack, other2x3, sizeof(other2x3)), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// A mat2 is not four contiguous floats in the global UBO: std140 pads each column vector out to
// 16 bytes, so column 1 starts at byte 16. Writing it packed put column 1 on top of column 0's
// padding, where the shader never reads it.
TEST_F(ProgramTest, Mat2UniformUsesTheStd140ColumnStride) {
const char* vsSource = R"(#version 430 core
uniform mat2 uM2;
void main() { gl_Position = vec4(uM2 * vec2(1.0), 0.0, 1.0); }
)";
const char* fsSource = R"(#version 430 core
out vec4 fragColor;
void main() { fragColor = vec4(1.0); }
)";
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
UseProgram(program);
const GLint loc = GetUniformLocation(program, "uM2");
ASSERT_GE(loc, 0);
const GLfloat m2[4] = {1, 2, 3, 4};
UniformMatrix2fv(loc, 1, GL_FALSE, m2);
ASSERT_EQ(GetError(), GL_NO_ERROR);
// The GL-visible value is tightly packed...
GLfloat readBack[4] = {};
GetUniformfv(program, loc, readBack);
EXPECT_EQ(std::memcmp(readBack, m2, sizeof(m2)), 0);
// ...while the bytes in the UBO put column 1 at offset 16, not 8.
const auto& programObject = MG_State::pGLContext->GetProgramObject(program);
ASSERT_NE(programObject, nullptr);
const auto* ubo = static_cast<const char*>(programObject->MapUBO());
ASSERT_NE(ubo, nullptr);
const Uint offset = programObject->GetUniformOffset(static_cast<Uint>(loc));
ASSERT_NE(offset, MG_State::GLState::ProgramObject::kInvalidUniformOffset);
GLfloat column0[2] = {};
GLfloat column1[2] = {};
std::memcpy(column0, ubo + offset, sizeof(column0));
std::memcpy(column1, ubo + offset + 16, sizeof(column1));
EXPECT_FLOAT_EQ(column0[0], 1.0f);
EXPECT_FLOAT_EQ(column0[1], 2.0f);
EXPECT_FLOAT_EQ(column1[0], 3.0f);
EXPECT_FLOAT_EQ(column1[1], 4.0f);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// GL 4.6 core 7.1: shaderType is an enum, so an unrecognised one is INVALID_ENUM - it used to be
// reported as INVALID_VALUE. glCreateShaderProgramv adds a count < 0 gate ahead of everything.
TEST_F(ProgramTest, CreateShaderAndCreateShaderProgramvReportTheRightErrorClasses) {
while (GetError() != GL_NO_ERROR) {
}
EXPECT_EQ(CreateShader(GL_FLOAT), 0u);
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
EXPECT_EQ(GetError(), GL_NO_ERROR) << "the call recorded more than one error";
const char* source = "#version 330 core\nvoid main() { gl_Position = vec4(1.0); }\n";
EXPECT_EQ(CreateShaderProgramv(GL_FLOAT, 1, &source), 0u);
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
EXPECT_EQ(GetError(), GL_NO_ERROR) << "the call recorded more than one error";
EXPECT_EQ(CreateShaderProgramv(GL_VERTEX_SHADER, -1, &source), 0u);
EXPECT_EQ(GetError(), GL_INVALID_VALUE);
EXPECT_EQ(GetError(), GL_NO_ERROR) << "the call recorded more than one error";
// A well-formed call still works.
const GLuint program = CreateShaderProgramv(GL_VERTEX_SHADER, 1, &source);
EXPECT_NE(program, 0u);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
+450 -49
View File
@@ -20,6 +20,8 @@
#include <MG_Util/ShaderTranspiler/SpirvPasses/RenameSamplerFunctionParameterPass.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <MG_Util/ShaderTranspiler/glslang/UniformTraverser.h>
#include <MG_State/GLState/ProgramState/ShaderPreprocessCache.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <spirv-tools/libspirv.hpp>
#include <spirv-tools/optimizer.hpp>
@@ -615,25 +617,6 @@ void main() {
}
}
// The builtin-shadowing rename only fires when the shader really defines its own round/tanh/etc.
// Deciding that from a commented-out definition renames every genuine call to the builtin to a
// mg_ name that nothing defines, which fails to link.
TEST_F(ProgramUtilTest, PreprocessIgnoresCommentedOutBuiltinShadowingDefinition) {
using namespace MG_Util::ShaderTranspiler;
String source = R"(#version 330 core
// float round(float x) { return floor(x + 0.5); }
out vec4 fragColor;
void main() {
fragColor = vec4(round(1.25));
}
)";
PreprocessShaderSource(ShaderStage::Fragment, source);
EXPECT_NE(source.find("round(1.25)"), String::npos) << "call was renamed from a comment:\n" << source;
EXPECT_EQ(source.find("mg_round"), String::npos);
}
// A block-commented extension directive must not be treated as a real one - the int64 filter turns
// unsupported directives into #error, so reading one out of a comment manufactures a compile
// failure for a shader that never asked for the extension.
@@ -844,36 +827,6 @@ void main() {
}
}
TEST_F(ProgramUtilTest, PreprocessFragmentShaderRenamesMin3Max3Helpers) {
using namespace MG_Util::ShaderTranspiler;
String source = R"(#version 460 core
out vec4 fragColor;
float min3(float a, float b, float c) { return min(min(a, b), c); }
float max3(float a, float b, float c) { return max(max(a, b), c); }
void main() {
float dark = min3(0.1, 0.2, 0.3);
float bright = max3(max3(0.1, 0.2, 0.3), 0.4, 0.5);
fragColor = vec4(dark, bright, 0.0, 1.0);
})";
PreprocessShaderSource(ShaderStage::Fragment, source);
EXPECT_NE(source.find("float mg_min3("), String::npos);
EXPECT_NE(source.find("float mg_max3("), String::npos);
EXPECT_NE(source.find("mg_min3(0.1, 0.2, 0.3)"), String::npos);
EXPECT_NE(source.find("mg_max3(mg_max3(0.1, 0.2, 0.3), 0.4, 0.5)"), String::npos);
EXPECT_EQ(source.find("float min3("), String::npos);
EXPECT_EQ(source.find("float max3("), String::npos);
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) {
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
}
}
const char* vs = R"(#version 150
@@ -2248,3 +2201,451 @@ TEST_F(ProgramUtilTest, RewriteLinearSubgroupPrefixScanRejectsPartialOrUnsafeTem
"float other = shuffleNV(1.0f, 0u, 32u);\n ");
expectUnchanged(std::move(nvShuffleCall));
}
// The LEXICAL half must fire at the source level (before the parse) for the
// preempt-list names - the end-to-end ESSL tests cannot tell which half did the
// rename, and for these names the parse would fail without the source rewrite.
TEST_F(ProgramUtilTest, PreprocessRenamesLexicalPreemptShadowingInSource) {
using namespace MG_Util::ShaderTranspiler;
String source = R"(#version 460 core
out vec4 fragColor;
float min3(float a, float b, float c) { return min(min(a, b), c); }
void main() {
fragColor = vec4(min3(0.1, 0.2, 0.3));
}
)";
PreprocessShaderSource(ShaderStage::Fragment, source);
EXPECT_NE(source.find("float mg_min3("), String::npos) << source;
EXPECT_NE(source.find("mg_min3(0.1, 0.2, 0.3)"), String::npos) << source;
EXPECT_EQ(source.find("float min3("), String::npos) << source;
}
// GLSL has no multi-line string or character literal, so a lone apostrophe never opens one - it is
// an English contraction, in a comment or in a diagnostic directive. MaskCommentsAndQuotedText used
// to disagree: it entered its quoted-text region on the apostrophe and, having no end-of-line rule,
// stayed there to the end of the file, blanking everything after it for every consumer of the mask
// (the tokenizer, the #version inspection, the explicit-location and opaque-binding extractors).
//
// Apostrophes inside comments were never affected - the comment region claims them first - but that
// is exactly the property the fix must not break, so pin it.
TEST_F(ProgramUtilTest, PreprocessKeepsApostrophesInsideCommentsHarmless) {
using namespace MG_Util::ShaderTranspiler;
String source = R"(#version 460 core
// don't do this: the sampler isn't bound before the first frame
/* and here's a block comment whose apostrophes shouldn't matter either */
uniform sampler2D tex;
in vec2 uv;
out vec4 fragColor;
void main() {
fragColor = texture(tex, uv);
}
)";
PreprocessShaderSource(ShaderStage::Fragment, source);
EXPECT_NE(source.find("void main()"), String::npos) << "shader body was blanked:\n" << source;
EXPECT_NE(source.find("fragColor = texture(tex, uv);"), String::npos) << source;
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) {
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
}
}
// The case the old masker actually broke: an apostrophe in real (non-comment) text. Everything after
// it looked like string interior, so ExtractExplicitUniformLocations tokenized a blank source and
// handed the GL location assigner an empty map - the uniform silently lost its explicit location.
TEST_F(ProgramUtilTest, PreprocessApostropheInDirectiveKeepsLaterCodeVisibleToExtractors) {
using namespace MG_Util::ShaderTranspiler;
String source = R"(#version 460 core
#pragma MG_NOTE(this pack can't run without explicit locations)
layout(location = 7) uniform vec4 tint;
in vec2 uv;
out vec4 fragColor;
void main() {
fragColor = tint * uv.x;
}
)";
PreprocessShaderSource(ShaderStage::Fragment, source);
const UnorderedMap<String, Int> locations = ExtractExplicitUniformLocations(source);
ASSERT_EQ(locations.count("tint"), 1u) << "extractor went blind past the apostrophe:\n" << source;
EXPECT_EQ(locations.at("tint"), 7);
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) {
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
}
}
// PreprocessShaderSource used to rediscover "where does the #version directive end?" once per
// injection - up to five whole-source masks and line scans per compile for one offset. It now takes
// the anchor once, from the pass that creates it, and tracks it.
//
// These three sources drive every consumer of that anchor: NormalizeLineDirectives (both the
// keep branch and the drop-ahead-of-#version branch), ModernizeLegacyGLSL's gl_FragColor
// injection, and InjectDepthRangeBuiltinShim's. The expected texts are the byte-exact output of
// the pre-memo implementation, captured from it - the change is pure memoization and is allowed to
// move no byte at all.
//
// Case B and case C are the two ways the anchor moves out from under the memo, and are why it is
// tracked rather than simply cached: B deletes a #line that precedes the version directive, and C
// has ModernizeLegacyGLSL's raw ReplaceIdentifier rewrite "varying"/"texture2D" inside a comment
// banner ahead of it, pulling the anchor six bytes left. An offset cached blindly would put the
// injected declaration six bytes inside the version line.
TEST_F(ProgramUtilTest, PreprocessLegacyFragmentShaderOutputIsByteStableAcrossTheVersionAnchor) {
using namespace MG_Util::ShaderTranspiler;
const char* kLegacyBody = R"(#line 30
varying vec2 uv;
uniform sampler2D tex;
void main() {
float d = gl_DepthRange.diff;
gl_FragColor = texture2D(tex, uv) * d;
}
)";
const char* kExpectedBody =
"#version 330 core /*mobilegl-normalized-legacy*/\n"
"struct mg_DepthRangeParameters { float near; float far; float diff; };\n"
"const mg_DepthRangeParameters mg_DepthRange = mg_DepthRangeParameters(0.0, 1.0, 1.0);\n"
"#define gl_DepthRange mg_DepthRange\n"
"out vec4 mg_FragColor;\n"
"#line 30\n"
"in vec2 uv;\n"
"uniform sampler2D tex;\n"
"\n"
"void main() {\n"
" float d = gl_DepthRange.diff;\n"
" mg_FragColor = texture(tex, uv) * d;\n"
"}\n";
{
SCOPED_TRACE("A: version directive at offset 0");
String source = String("#version 120\n") + kLegacyBody;
PreprocessShaderSource(ShaderStage::Fragment, source);
EXPECT_EQ(source, String(kExpectedBody));
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) {
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
}
}
{
SCOPED_TRACE("B: a #line ahead of the version directive is dropped, shortening the prefix");
String source = String("// pack preamble\n#line 1 \"world.fsh\"\n#version 120\n") + kLegacyBody;
PreprocessShaderSource(ShaderStage::Fragment, source);
// The dropped directive leaves its newline behind, so line numbering is untouched.
EXPECT_EQ(source, String("// pack preamble\n\n") + kExpectedBody);
}
{
SCOPED_TRACE("C: a comment banner ahead of the version directive is itself rewritten");
String source = R"(/* legacy varying / texture2D helpers */
#version 120
varying vec2 uv;
uniform sampler2D tex;
void main() {
gl_FragColor = texture2D(tex, uv);
}
)";
PreprocessShaderSource(ShaderStage::Fragment, source);
EXPECT_EQ(source, String("/* legacy in / texture helpers */\n"
"#version 330 core /*mobilegl-normalized-legacy*/\n"
"out vec4 mg_FragColor;\n"
"in vec2 uv;\n"
"uniform sampler2D tex;\n"
"void main() {\n"
" mg_FragColor = texture(tex, uv);\n"
"}\n"));
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) {
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
}
}
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// P1: CompileEnv - the compile pipeline's snapshot of everything outside
// (stage, source). These pin the two properties the rest of P1 rides on: the
// compute limits really are carried in the snapshot (an off-thread
// GL_MAX_COMPUTE_WORK_GROUP_SIZE query would silently return 0 and reject a
// legal local_size), and the fingerprint really does move when they do.
// ---------------------------------------------------------------------------
TEST_F(ProgramUtilTest, CompileEnvCarriesComputeLimitsAndFrontendMinima) {
using MobileGL::MG_Util::ShaderTranspiler::CaptureCompileEnv;
const auto env = CaptureCompileEnv();
ASSERT_NE(env, nullptr);
// With no backend the snapshot is the frontend minimum, never zero - the value an
// off-thread GetIntegeri_v would have left behind.
EXPECT_GE(env->maxComputeWorkGroupSize[0], 1024u);
EXPECT_GE(env->maxComputeWorkGroupSize[1], 1024u);
EXPECT_GE(env->maxComputeWorkGroupSize[2], 64u);
EXPECT_GE(env->maxComputeWorkGroupInvocations, 1024u);
EXPECT_NE(env->fingerprint, 0u);
}
TEST_F(ProgramUtilTest, CompileEnvFingerprintTracksEveryInput) {
using MobileGL::MG_Util::ShaderTranspiler::CompileEnv;
using MobileGL::MG_Util::ShaderTranspiler::ComputeCompileEnvFingerprint;
CompileEnv base;
const Uint64 baseline = ComputeCompileEnvFingerprint(base);
EXPECT_EQ(ComputeCompileEnvFingerprint(base), baseline) << "fingerprint must be deterministic";
// A device that allows a bigger workgroup than the frontend minimum is a DIFFERENT
// compile environment: a memo taken under the smaller limit must not be reusable.
CompileEnv biggerZ = base;
biggerZ.maxComputeWorkGroupSize[2] = 256;
EXPECT_NE(ComputeCompileEnvFingerprint(biggerZ), baseline);
CompileEnv moreInvocations = base;
moreInvocations.maxComputeWorkGroupInvocations = 2048;
EXPECT_NE(ComputeCompileEnvFingerprint(moreInvocations), baseline);
CompileEnv otherBackend = base;
otherBackend.backend = MobileGL::BackendType::DirectVulkan;
EXPECT_NE(ComputeCompileEnvFingerprint(otherBackend), baseline);
CompileEnv otherLimits = base;
otherLimits.params.MaxVertexAttribs = 31;
EXPECT_NE(ComputeCompileEnvFingerprint(otherLimits), baseline);
CompileEnv otherExtensions = base;
otherExtensions.advertisedExtensions.push_back(MobileGL::E_GL_ARB_gpu_shader_int64);
EXPECT_NE(ComputeCompileEnvFingerprint(otherExtensions), baseline);
CompileEnv otherQuirk = base;
otherQuirk.subgroupPrefixScanQuirk = MobileGL::MG_Config::QuirkOverride::ForceOn;
EXPECT_NE(ComputeCompileEnvFingerprint(otherQuirk), baseline);
}
// The no-backend fallback must stay exactly what the pipeline used to do inline:
// everything counts as advertised, because there is nothing to gate against.
TEST_F(ProgramUtilTest, CompileEnvWithoutBackendAdvertisesEverything) {
using MobileGL::MG_Util::ShaderTranspiler::CompileEnv;
CompileEnv env;
EXPECT_FALSE(env.HasBackend());
EXPECT_TRUE(env.IsExtensionAdvertised(MobileGL::E_GL_ARB_gpu_shader_int64));
env.backend = MobileGL::BackendType::DirectGLES;
EXPECT_TRUE(env.HasBackend());
EXPECT_FALSE(env.IsExtensionAdvertised(MobileGL::E_GL_ARB_gpu_shader_int64));
env.advertisedExtensions.push_back(MobileGL::E_GL_ARB_gpu_shader_int64);
EXPECT_TRUE(env.IsExtensionAdvertised(MobileGL::E_GL_ARB_gpu_shader_int64));
}
// P0b layer 2: ShaderPreprocessCache, tested directly. The GL-level behaviour it
// enables is covered end to end in ProgramTest; these pin the container itself,
// where the interesting cases (hash collisions, both eviction budgets) are hard
// to provoke through glCompileShader.
// ---------------------------------------------------------------------------
namespace {
using MobileGL::MG_State::GLState::ShaderPreprocessCache;
using MobileGL::MG_State::GLState::ShaderPreprocessOutcome;
using MobileGL::MG_State::GLState::ShaderPreprocessResult;
using MobileGL::MG_State::GLState::ShaderPreprocessResultPtr;
// The env fingerprint every test below keys against, unless it is specifically
// exercising the fingerprint itself.
constexpr MobileGL::Uint64 kEnvA = 0x1111'2222'3333'4444ull;
constexpr MobileGL::Uint64 kEnvB = 0x5555'6666'7777'8888ull;
ShaderPreprocessResultPtr MakeResult(const String& preprocessed) {
auto result = MakeShared<ShaderPreprocessResult>();
result->outcome = ShaderPreprocessOutcome::Preprocessed;
result->preprocessedSource = preprocessed;
result->explicitUniformLocations["uMarker"] = 7;
result->explicitOpaqueBindings["sMarker"] = 3;
return result;
}
} // namespace
TEST_F(ProgramUtilTest, ShaderPreprocessCacheRoundTripsAndSeparatesStages) {
ShaderPreprocessCache cache;
const String source = "// a shader\nvoid main() {}\n";
const Uint64 hash = ShaderPreprocessCache::HashSource(source);
EXPECT_EQ(cache.Find(ShaderStage::Vertex, hash, source, kEnvA), nullptr);
cache.Insert(ShaderStage::Vertex, hash, source, kEnvA, MakeResult("vertex-preprocessed"));
const ShaderPreprocessResultPtr hit = cache.Find(ShaderStage::Vertex, hash, source, kEnvA);
ASSERT_NE(hit, nullptr);
EXPECT_TRUE(hit->Preprocessed());
EXPECT_EQ(hit->preprocessedSource, "vertex-preprocessed");
const auto uniformIt = hit->explicitUniformLocations.find("uMarker");
ASSERT_NE(uniformIt, hit->explicitUniformLocations.end());
EXPECT_EQ(uniformIt->second, 7);
const auto bindingIt = hit->explicitOpaqueBindings.find("sMarker");
ASSERT_NE(bindingIt, hit->explicitOpaqueBindings.end());
EXPECT_EQ(bindingIt->second, 3u);
// Byte-identical source, different stage: a different key, so still a miss. Two
// stages sharing one entry would hand a fragment shader a vertex preprocess.
EXPECT_EQ(cache.Find(ShaderStage::Fragment, hash, source, kEnvA), nullptr);
cache.Insert(ShaderStage::Fragment, hash, source, kEnvA, MakeResult("fragment-preprocessed"));
const ShaderPreprocessResultPtr fragmentHit = cache.Find(ShaderStage::Fragment, hash, source, kEnvA);
ASSERT_NE(fragmentHit, nullptr);
EXPECT_EQ(fragmentHit->preprocessedSource, "fragment-preprocessed");
EXPECT_EQ(cache.Find(ShaderStage::Vertex, hash, source, kEnvA)->preprocessedSource, "vertex-preprocessed");
EXPECT_EQ(cache.GetEntryCount(), 2u);
}
TEST_F(ProgramUtilTest, ShaderPreprocessCacheMemoizesRejectionVerdictsDistinctly) {
ShaderPreprocessCache cache;
const String reservedSource = "int packed;\n";
const String localSizeSource = "layout(local_size_x = 99999) in;\n";
auto reserved = MakeShared<ShaderPreprocessResult>();
reserved->outcome = ShaderPreprocessOutcome::ReservedIdentifierRejected;
reserved->infoLog = "reserved identifier";
auto localSize = MakeShared<ShaderPreprocessResult>();
localSize->outcome = ShaderPreprocessOutcome::ComputeLocalSizeRejected;
localSize->infoLog = "local_size too big";
cache.Insert(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(reservedSource), reservedSource, kEnvA,
Move(reserved));
cache.Insert(ShaderStage::Compute, ShaderPreprocessCache::HashSource(localSizeSource), localSizeSource, kEnvA,
Move(localSize));
const ShaderPreprocessResultPtr reservedHit =
cache.Find(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(reservedSource), reservedSource, kEnvA);
ASSERT_NE(reservedHit, nullptr);
EXPECT_FALSE(reservedHit->Preprocessed());
EXPECT_EQ(reservedHit->outcome, ShaderPreprocessOutcome::ReservedIdentifierRejected);
EXPECT_EQ(reservedHit->infoLog, "reserved identifier");
const ShaderPreprocessResultPtr localSizeHit =
cache.Find(ShaderStage::Compute, ShaderPreprocessCache::HashSource(localSizeSource), localSizeSource, kEnvA);
ASSERT_NE(localSizeHit, nullptr);
EXPECT_EQ(localSizeHit->outcome, ShaderPreprocessOutcome::ComputeLocalSizeRejected);
EXPECT_EQ(localSizeHit->infoLog, "local_size too big");
}
// Correctness must not ride on a 64-bit hash. Feed two different sources of the same
// length under a forged, identical hash: the entry stores the full original text, so
// the impostor lookup must miss instead of returning the wrong preprocess.
TEST_F(ProgramUtilTest, ShaderPreprocessCacheRejectsForgedHashCollision) {
ShaderPreprocessCache cache;
const String real = "void main() { int a = 1; }\n";
const String impostor = "void main() { int a = 2; }\n";
ASSERT_EQ(real.length(), impostor.length());
ASSERT_NE(real, impostor);
const Uint64 forgedHash = 0xdeadbeefcafef00dull;
cache.Insert(ShaderStage::Vertex, forgedHash, real, kEnvA, MakeResult("real-preprocessed"));
ASSERT_NE(cache.Find(ShaderStage::Vertex, forgedHash, real, kEnvA), nullptr);
EXPECT_EQ(cache.Find(ShaderStage::Vertex, forgedHash, impostor, kEnvA), nullptr);
// The colliding newcomer wins the slot rather than being silently dropped, so it
// is the previous occupant that degrades to a miss - never a wrong hit.
cache.Insert(ShaderStage::Vertex, forgedHash, impostor, kEnvA, MakeResult("impostor-preprocessed"));
const ShaderPreprocessResultPtr impostorHit = cache.Find(ShaderStage::Vertex, forgedHash, impostor, kEnvA);
ASSERT_NE(impostorHit, nullptr);
EXPECT_EQ(impostorHit->preprocessedSource, "impostor-preprocessed");
EXPECT_EQ(cache.Find(ShaderStage::Vertex, forgedHash, real, kEnvA), nullptr);
EXPECT_EQ(cache.GetEntryCount(), 1u);
}
// P1: the compile environment joins the key. A memo computed against one backend's
// GL_MAX_COMPUTE_WORK_GROUP_* limits must never be handed back after the environment
// changed (backend swap), which is exactly what CompileEnv::fingerprint keys on.
TEST_F(ProgramUtilTest, ShaderPreprocessCacheMissesOnChangedEnvFingerprint) {
ShaderPreprocessCache cache;
const String source = "layout(local_size_x = 512) in;\nvoid main() {}\n";
const Uint64 hash = ShaderPreprocessCache::HashSource(source);
cache.Insert(ShaderStage::Compute, hash, source, kEnvA, MakeResult("env-a-preprocessed"));
ASSERT_NE(cache.Find(ShaderStage::Compute, hash, source, kEnvA), nullptr);
EXPECT_EQ(cache.Find(ShaderStage::Compute, hash, source, kEnvB), nullptr);
// Both environments can coexist; neither can see the other's verdict.
cache.Insert(ShaderStage::Compute, hash, source, kEnvB, MakeResult("env-b-preprocessed"));
EXPECT_EQ(cache.Find(ShaderStage::Compute, hash, source, kEnvA)->preprocessedSource, "env-a-preprocessed");
EXPECT_EQ(cache.Find(ShaderStage::Compute, hash, source, kEnvB)->preprocessedSource, "env-b-preprocessed");
EXPECT_EQ(cache.GetEntryCount(), 2u);
}
// A hit hands out shared ownership, so the payload survives the eviction of its entry.
// Under the old raw-pointer API this read was a use-after-free the moment two compiles
// ran concurrently.
TEST_F(ProgramUtilTest, ShaderPreprocessCacheHitOutlivesEviction) {
ShaderPreprocessCache cache;
const String source = "void main() { int keep = 1; }\n";
const Uint64 hash = ShaderPreprocessCache::HashSource(source);
cache.Insert(ShaderStage::Vertex, hash, source, kEnvA, MakeResult("survivor"));
const ShaderPreprocessResultPtr held = cache.Find(ShaderStage::Vertex, hash, source, kEnvA);
ASSERT_NE(held, nullptr);
cache.Clear();
EXPECT_EQ(cache.Find(ShaderStage::Vertex, hash, source, kEnvA), nullptr);
EXPECT_EQ(held->preprocessedSource, "survivor");
}
TEST_F(ProgramUtilTest, ShaderPreprocessCacheEvictsFifoOnEntryCap) {
ShaderPreprocessCache cache;
Vector<String> sources;
const SizeT overflow = ShaderPreprocessCache::kMaxEntries + 8;
for (SizeT i = 0; i < overflow; ++i) {
sources.push_back("void main() { int a = " + ToString(i) + "; }\n");
cache.Insert(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(sources.back()), sources.back(), kEnvA,
MakeResult("pp" + ToString(i)));
EXPECT_LE(cache.GetEntryCount(), ShaderPreprocessCache::kMaxEntries);
}
EXPECT_EQ(cache.GetEntryCount(), ShaderPreprocessCache::kMaxEntries);
// FIFO: the first `overflow - kMaxEntries` insertions are gone, the rest resident.
for (SizeT i = 0; i < overflow; ++i) {
const ShaderPreprocessResultPtr hit =
cache.Find(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(sources[i]), sources[i], kEnvA);
if (i < overflow - ShaderPreprocessCache::kMaxEntries) {
EXPECT_EQ(hit, nullptr) << "entry " << i << " should have been evicted";
} else {
ASSERT_NE(hit, nullptr) << "entry " << i << " should still be resident";
EXPECT_EQ(hit->preprocessedSource, "pp" + ToString(i));
}
}
cache.Clear();
EXPECT_EQ(cache.GetEntryCount(), 0u);
EXPECT_EQ(cache.GetStoredSourceBytes(), 0u);
}
TEST_F(ProgramUtilTest, ShaderPreprocessCacheHonorsByteBudget) {
ShaderPreprocessCache cache;
// Well under the entry cap, well over the byte budget: the byte budget must be the
// one that binds, and the accounting must come back down as entries are evicted.
const SizeT chunk = ShaderPreprocessCache::kMaxStoredSourceBytes / 8;
for (SizeT i = 0; i < 24; ++i) {
String source(chunk, static_cast<char>('a' + (i % 26)));
cache.Insert(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(source), source, kEnvA, MakeResult(""));
EXPECT_LE(cache.GetStoredSourceBytes(), ShaderPreprocessCache::kMaxStoredSourceBytes);
EXPECT_LT(cache.GetEntryCount(), ShaderPreprocessCache::kMaxEntries);
}
// A single source larger than the whole budget is refused outright: caching it
// would evict every other entry and then immediately itself.
const SizeT before = cache.GetEntryCount();
const String oversized(ShaderPreprocessCache::kMaxStoredSourceBytes + 1, 'z');
cache.Insert(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(oversized), oversized, kEnvA, MakeResult(""));
EXPECT_EQ(cache.GetEntryCount(), before);
EXPECT_EQ(cache.Find(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(oversized), oversized, kEnvA), nullptr);
}
@@ -0,0 +1,910 @@
// MobileGL - MobileGL/MG_Test/Program/ShaderCompileAdoptionTest.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
// P1 stage 6: two shader objects handed byte-identical source share ONE compile job.
//
// The property under test is a conjunction, and every case here attacks one half of it:
// * the sharing itself - one job, one node, both GL names reporting the same answer, and
// two programs linking that one node to byte-identical SPIR-V;
// * that sharing did not make a cancel dangerous. Before this stage a node had exactly one
// shader object, so "this object stopped caring" and "nothing can observe this result"
// were the same statement and CancelCompile() cancelled unconditionally. They are not the
// same statement any more, and the four mutation paths that used to reach that cancel -
// re-source, delete, the orphan-name sweep, the destructor - are each covered below with
// a second object still holding the node.
//
// Like the other async suites, every case flips MG_Config::Features.AsyncShaderCompile itself
// and drives the real GL entry points, so the file behaves identically whether or not the
// suite was launched with MOBILEGL_ASYNC_SHADER_COMPILE=1.
//
// Adoption is decided ON THE GL THREAD, before anything is posted, so the counter assertions
// here are deterministic rather than timing-dependent: whether the first object's compile has
// already finished changes nothing about whether the second one adopts it.
#include <gtest/gtest.h>
#include <chrono>
#include <string>
#include <thread>
#include <vector>
#include "Config.h"
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_State/GLState/Core.h"
#include "MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h"
#include "MG_State/GLState/ProgramState/ShaderCompileTask.h"
#include "MG_State/GLState/ProgramState/ShaderPreprocessCache.h"
#include "MG_Util/Async/ShaderCompilePool.h"
#include "MG_Util/ShaderTranspiler/CompileEnv.h"
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
using MobileGL::MG_State::GLState::ShaderCompileAdoptionMap;
using MobileGL::MG_State::GLState::ShaderCompileTask;
using MobileGL::MG_State::GLState::ShaderObject;
using MobileGL::MG_State::GLState::ShaderPreprocessCache;
namespace {
class AsyncModeScope {
public:
explicit AsyncModeScope(const Bool async) : m_saved(MG_Config::Features.AsyncShaderCompile) {
MG_Config::Features.AsyncShaderCompile =
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
}
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
AsyncModeScope(const AsyncModeScope&) = delete;
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
private:
const MG_Config::QuirkOverride m_saved;
};
// The suspension latch and the concurrency budget are PROCESS-wide, so a case that
// touches either has to put both back or it poisons every case after it in this binary.
class CompilerThreadScope {
public:
CompilerThreadScope() = default;
~CompilerThreadScope() {
MG_Util::Async::SetAsyncShaderCompileSuspended(false);
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(
MG_Util::Async::ShaderCompilePool::Get().GetThreadCount());
}
CompilerThreadScope(const CompilerThreadScope&) = delete;
CompilerThreadScope& operator=(const CompilerThreadScope&) = delete;
};
const char* kVs = R"(#version 460
layout(location = 0) in vec3 aPos;
uniform mat4 uModel;
uniform vec4 uColor;
out vec4 vColor;
void main() {
vColor = uColor;
gl_Position = uModel * vec4(aPos, 1.0);
}
)";
// Fails inside glslang rather than in the lexical pre-checks, so it exercises the same
// ParseFailed path a real broken shaderpack source takes.
const char* kBrokenFs = R"(#version 460
layout(location = 0) out vec4 fragColor;
void main() { fragColor = thisIdentifierWasNeverDeclared; }
)";
// Big enough that a compile is not instantaneous, so a duplicate really would cost
// something. Templated on an index so every instance is a distinct source.
String MakeBulkySource(const int index) {
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
source += "uniform float uSeed" + std::to_string(index) + ";\n";
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
for (int i = 0; i < 220; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
}
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
return source;
}
// Heavy enough that a spinning GL thread can reliably observe the compile Running on a
// single-worker pool, for RunningCancelRequestedNodeIsNotAdopted below - MakeBulkySource
// is tuned for "not instantaneous", this one is tuned for "actually spin-observable".
String MakeVeryHeavySource(const int index) {
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
source += "uniform float uSeed" + std::to_string(index) + ";\n";
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
for (int i = 0; i < 4000; ++i) {
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
}
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
return source;
}
Uint64 AdoptionCount() {
return MG_State::pGLContext->GetShaderCompileAdoptionMap().GetAdoptionCount();
}
// A copy of the slot, never the reference: creating another shader can reallocate the
// context's object table.
SharedPtr<ShaderObject> Object(const GLuint shader) {
return MG_State::pGLContext->GetShaderObject(shader);
}
// The node identity, WITHOUT joining - this is what "they share one job" means, and
// asking must not settle anything.
const ShaderCompileTask* NodeOf(const GLuint shader) {
const SharedPtr<ShaderObject> object = Object(shader);
return object ? object->CompiledNodeForLink().get() : nullptr;
}
GLuint MakeShader(const GLenum type, const char* source) {
const GLuint shader = CreateShader(type);
ShaderSource(shader, 1, &source, nullptr);
return shader;
}
GLuint MakeAndCompile(const GLenum type, const char* source) {
const GLuint shader = MakeShader(type, source);
CompileShader(shader);
return shader;
}
GLint QueryCompileStatus(const GLuint shader) {
GLint status = GL_FALSE;
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
return status;
}
String QueryShaderInfoLog(const GLuint shader) {
GLint length = 0;
GetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
if (length <= 0) return String();
std::vector<GLchar> buffer(static_cast<size_t>(length));
GLsizei written = 0;
GetShaderInfoLog(shader, length, &written, buffer.data());
return String(buffer.data(), static_cast<size_t>(written));
}
GLint QueryLinkStatus(const GLuint program) {
GLint status = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &status);
return status;
}
// Content hash of a linked program's generated SPIR-V, through the state layer (there is
// no GL query for it). This is what catches a mis-shared parse: if the claim CAS on a
// SHARED node let two links both run mapIO over the same intermediate, the two programs
// would disagree here.
Vector<Uint64> SpirvDigest(const GLuint program) {
const auto& object = MG_State::pGLContext->GetProgramObject(program);
Vector<Uint64> digest;
if (!object) return digest;
for (const auto& module : object->GetGeneratedSpirv()) {
Uint64 hash = 1469598103934665603ull;
for (const unsigned word : module) {
hash = (hash ^ static_cast<Uint64>(word)) * 1099511628211ull;
}
digest.push_back(hash);
}
return digest;
}
// Enqueues `count` distinct heavy compiles and reads nothing back, so the pool is left
// with a real backlog for the caller's mutations to race against.
void SaturatePool(const int count, Vector<String>& sourceStorage) {
sourceStorage.reserve(sourceStorage.size() + static_cast<SizeT>(count));
for (int i = 0; i < count; ++i) {
sourceStorage.push_back(MakeBulkySource(90000 + i));
const char* text = sourceStorage.back().c_str();
const GLuint shader = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(shader, 1, &text, nullptr);
CompileShader(shader);
}
}
// Links `shader` against a freshly compiled vertex stage and returns the program.
GLuint LinkWith(const GLuint shader) {
const GLuint vs = MakeAndCompile(GL_VERTEX_SHADER, kVs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, shader);
LinkProgram(program);
return program;
}
class ShaderCompileAdoptionTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
};
} // namespace
// ---------------------------------------------------------------------------------------
// The sharing itself
// ---------------------------------------------------------------------------------------
// The headline: two GL shader names, byte-identical source, exactly one job. Both names must
// answer every query correctly, and the ONE parse they share must link into two separate
// programs with byte-identical SPIR-V - which is the stage-4 claim CAS being exercised on a
// shared node for the first time.
TEST_F(ShaderCompileAdoptionTest, TwoObjectsWithIdenticalSourceShareOneCompileJob) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(32, backlog);
const String source = MakeBulkySource(100);
const char* text = source.c_str();
const Uint64 before = AdoptionCount();
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(a, 1, &text, nullptr);
CompileShader(a);
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(b, 1, &text, nullptr);
CompileShader(b);
EXPECT_EQ(AdoptionCount() - before, 1u) << "the second glCompileShader must not enqueue a duplicate";
ASSERT_NE(NodeOf(a), nullptr);
EXPECT_EQ(NodeOf(a), NodeOf(b)) << "both objects must hold the very same job node";
// Both names still answer for themselves.
EXPECT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a);
EXPECT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
EXPECT_EQ(QueryShaderInfoLog(a), QueryShaderInfoLog(b));
EXPECT_TRUE(QueryShaderInfoLog(a).empty());
// One node, two links: exactly one of them wins ClaimParsedShader, the other re-parses,
// and the two must agree bit for bit.
const GLuint programA = LinkWith(a);
const GLuint programB = LinkWith(b);
ASSERT_EQ(QueryLinkStatus(programA), GL_TRUE);
ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE);
const Vector<Uint64> digestA = SpirvDigest(programA);
const Vector<Uint64> digestB = SpirvDigest(programB);
ASSERT_EQ(digestA.size(), 2u);
EXPECT_EQ(digestA, digestB) << "a shared node linked twice produced different SPIR-V";
EXPECT_GE(GetUniformLocation(programA, "uSeed100"), 0);
EXPECT_GE(GetUniformLocation(programB, "uSeed100"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Adoption must also re-arm the adopter's layer-1 memo. It is a POINTER comparison against
// the node's own source snapshot, so an adopter that kept its own equal-but-distinct copy
// would decide on the very next glCompileShader that it had no memo and enqueue the exact
// duplicate this stage exists to remove - and an identical glShaderSource would cancel a
// compile another object is still waiting on.
TEST_F(ShaderCompileAdoptionTest, AdoptingAlsoArmsTheLayerOneMemo) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(32, backlog);
const String source = MakeBulkySource(110);
const char* text = source.c_str();
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(a, 1, &text, nullptr);
CompileShader(a);
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(b, 1, &text, nullptr);
CompileShader(b);
const SharedPtr<ShaderObject> objectB = Object(b);
ASSERT_NE(objectB, nullptr);
EXPECT_TRUE(objectB->HasMemoizedCompile()) << "an adopted node must satisfy the layer-1 memo";
const ShaderCompileTask* shared = NodeOf(b);
const Uint64 before = AdoptionCount();
for (int i = 0; i < 4; ++i) {
CompileShader(b);
EXPECT_EQ(NodeOf(b), shared) << "a repeat glCompileShader on an adopter must be a no-op";
}
// A byte-identical re-source is a no-op too, so it must not disturb the shared node.
ShaderSource(b, 1, &text, nullptr);
EXPECT_EQ(NodeOf(b), shared);
EXPECT_EQ(AdoptionCount(), before) << "no-op calls must not even reach the adoption map";
EXPECT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a);
EXPECT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Different source, and same source in a different STAGE, are different keys. This is the
// guard against the map ever handing out a node that does not belong to the caller.
TEST_F(ShaderCompileAdoptionTest, DifferentSourceOrStageIsNotAdopted) {
const AsyncModeScope async(true);
const String first = MakeBulkySource(120);
const String second = MakeBulkySource(121);
const char* firstText = first.c_str();
const char* secondText = second.c_str();
const Uint64 before = AdoptionCount();
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(a, 1, &firstText, nullptr);
CompileShader(a);
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(b, 1, &secondText, nullptr);
CompileShader(b);
EXPECT_EQ(AdoptionCount(), before) << "different text must not adopt";
EXPECT_NE(NodeOf(a), NodeOf(b));
// The same text in two stages: the vertex/fragment pair below shares no node either,
// because the stage is part of the key.
const GLuint vsA = MakeAndCompile(GL_VERTEX_SHADER, kVs);
const GLuint vsB = MakeAndCompile(GL_VERTEX_SHADER, kVs);
EXPECT_EQ(NodeOf(vsA), NodeOf(vsB)) << "same stage, same text: must share";
EXPECT_NE(NodeOf(vsA), NodeOf(a));
EXPECT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a);
EXPECT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// A failed compile is shared exactly like a successful one, and both names must report the
// identical status and the identical log - the info log lives in the node's artifacts, so
// this is also the guard that a second joiner is not left with an empty one.
TEST_F(ShaderCompileAdoptionTest, AdoptedFailingCompileReportsTheIdenticalLogToBothObjects) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(32, backlog);
const Uint64 before = AdoptionCount();
const GLuint a = MakeAndCompile(GL_FRAGMENT_SHADER, kBrokenFs);
const GLuint b = MakeAndCompile(GL_FRAGMENT_SHADER, kBrokenFs);
EXPECT_EQ(AdoptionCount() - before, 1u);
EXPECT_EQ(NodeOf(a), NodeOf(b));
EXPECT_EQ(QueryCompileStatus(a), GL_FALSE);
EXPECT_EQ(QueryCompileStatus(b), GL_FALSE);
const String logA = QueryShaderInfoLog(a);
EXPECT_FALSE(logA.empty());
EXPECT_EQ(QueryShaderInfoLog(b), logA);
// GL models a failed compile as status + log, never as a GL error - which is what makes
// moving the work off-thread (and sharing it) legal at all.
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// The four release paths, each with a second object still holding the node
// ---------------------------------------------------------------------------------------
// glShaderSource with DIFFERENT text on one sharer. Its release must NOT cancel the node the
// other one is still waiting on; the re-sourced object gets a fresh compile of its own.
TEST_F(ShaderCompileAdoptionTest, ResourcingOneSharerLeavesTheOtherIntact) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const String shared = MakeBulkySource(200);
const char* sharedText = shared.c_str();
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(a, 1, &sharedText, nullptr);
CompileShader(a);
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(b, 1, &sharedText, nullptr);
CompileShader(b);
const ShaderCompileTask* sharedNode = NodeOf(b);
ASSERT_NE(sharedNode, nullptr);
ASSERT_EQ(NodeOf(a), sharedNode);
// Replace A's text while the shared compile is very probably still outstanding.
const String replacement = MakeBulkySource(201);
const char* replacementText = replacement.c_str();
ShaderSource(a, 1, &replacementText, nullptr);
EXPECT_EQ(NodeOf(a), nullptr) << "a real source change must drop the object's node";
EXPECT_EQ(NodeOf(b), sharedNode) << "B must still hold the shared node";
// B is untouched: the compile it is waiting on still publishes, and its artifacts are
// the ones that source really produces.
ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
const GLuint programB = LinkWith(b);
ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE);
EXPECT_GE(GetUniformLocation(programB, "uSeed200"), 0);
// A gets a genuinely fresh compile of the new text.
CompileShader(a);
EXPECT_NE(NodeOf(a), sharedNode);
ASSERT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a);
const GLuint programA = LinkWith(a);
ASSERT_EQ(QueryLinkStatus(programA), GL_TRUE);
EXPECT_GE(GetUniformLocation(programA, "uSeed201"), 0);
EXPECT_EQ(GetUniformLocation(programA, "uSeed200"), -1);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// glDeleteShader on one sharer. The name goes immediately (no wait for a worker) and the
// object is destroyed, so this covers the DESTRUCTOR release as well as the orphan sweep's.
TEST_F(ShaderCompileAdoptionTest, DeletingOneSharerLeavesTheOtherIntact) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const String source = MakeBulkySource(210);
const char* text = source.c_str();
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(a, 1, &text, nullptr);
CompileShader(a);
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(b, 1, &text, nullptr);
CompileShader(b);
const ShaderCompileTask* sharedNode = NodeOf(b);
ASSERT_NE(sharedNode, nullptr);
ASSERT_EQ(NodeOf(a), sharedNode);
DeleteShader(a);
EXPECT_EQ(IsShader(a), GL_FALSE) << "an unattached deleted shader's name goes immediately";
EXPECT_EQ(NodeOf(b), sharedNode);
ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
const GLuint program = LinkWith(b);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE);
EXPECT_GE(GetUniformLocation(program, "uSeed210"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The window DeletingOneSharerLeavesTheOtherIntact cannot reach: there, A's compile has
// always already finished (or not yet started) by the time B adopts, because the pool is
// merely BUSY with other backlog. Here A's OWN node is still Running - a worker is inside
// RunBody() for it - when the last holder releases it. ReleaseCompileNode fires Cancel(),
// but JobNode::Cancel on a Running node only sets the cancellation-REQUEST flag; the state
// stays Running until the worker's body returns and JobNode::Run forces the final transition
// to Cancelled (see JobNode::Run's tail: it takes Cancelled instead of Complete whenever
// m_cancelled is set, regardless of how the body finished). FindAdoptable must refuse a node
// in that in-between state - not just one already settled as Cancelled - or C inherits a
// doomed node and glGetShaderiv reports GL_FALSE with an empty info log for valid source.
TEST_F(ShaderCompileAdoptionTest, RunningCancelRequestedNodeIsNotAdopted) {
const AsyncModeScope async(true);
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(1);
const String source = MakeVeryHeavySource(310);
const char* text = source.c_str();
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(a, 1, &text, nullptr);
CompileShader(a);
// Spin on the GL thread until the single worker is actually inside A's body. The source
// is sized to make that window observable rather than instantaneous.
const ShaderCompileTask* node = NodeOf(a);
ASSERT_NE(node, nullptr);
bool sawRunning = false;
for (int i = 0; i < 200000 && !node->IsTerminal(); ++i) {
if (node->State() == MG_Util::Async::JobState::Running) {
sawRunning = true;
break;
}
std::this_thread::sleep_for(std::chrono::microseconds(20));
}
ASSERT_TRUE(sawRunning) << "could not observe A's compile Running; the synthetic source "
"needs to be heavier, or the pool did not have a free worker";
// A is the ONLY holder, so this release brings the adopter count to zero and (with no
// link pin) fires Cancel() on a node that is still Running.
DeleteShader(a);
ASSERT_EQ(node->State(), MG_Util::Async::JobState::Running)
<< "the node already settled; the race window closed before the assertions below "
"could observe it - widen MakeVeryHeavySource's loop count";
ASSERT_TRUE(node->IsCancellationRequested());
ASSERT_FALSE(node->IsCancelled()) << "the window this test targets does not exist here";
// A brand-new shader name, byte-identical source, nothing wrong with it.
const GLuint c = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(c, 1, &text, nullptr);
CompileShader(c);
EXPECT_NE(NodeOf(c), node) << "C adopted a cancellation-requested, still-Running node";
ASSERT_EQ(QueryCompileStatus(c), GL_TRUE)
<< "valid source reported GL_FALSE; info log: [" << QueryShaderInfoLog(c) << "]";
EXPECT_EQ(GetError(), GL_NO_ERROR);
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(
MG_Util::Async::ShaderCompilePool::Get().GetThreadCount());
}
// The deferred half of glDeleteShader: A is ATTACHED, so the delete only flags it and the
// name is freed by ReleaseShaderNameIfOrphaned when the detach removes the last GL-visible
// attachment. That sweep is the other caller of the release path, and it must not cancel the
// node B is sharing.
TEST_F(ShaderCompileAdoptionTest, OrphanSweepOnOneSharerLeavesTheOtherIntact) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const String source = MakeBulkySource(220);
const char* text = source.c_str();
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(a, 1, &text, nullptr);
CompileShader(a);
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(b, 1, &text, nullptr);
CompileShader(b);
const ShaderCompileTask* sharedNode = NodeOf(b);
ASSERT_NE(sharedNode, nullptr);
ASSERT_EQ(NodeOf(a), sharedNode);
// Attach A, flag it for deletion (name survives), then detach: the sweep fires here, with
// NO link ever posted, so the stage-4 pin is NOT what is protecting the node - only the
// adopter count is.
const GLuint program = CreateProgram();
AttachShader(program, a);
DeleteShader(a);
EXPECT_EQ(IsShader(a), GL_TRUE) << "an attached deleted shader keeps its name";
DetachShader(program, a);
EXPECT_EQ(IsShader(a), GL_FALSE) << "the detach must free the flagged shader's name";
EXPECT_EQ(NodeOf(b), sharedNode);
ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
const GLuint programB = LinkWith(b);
ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE);
EXPECT_GE(GetUniformLocation(programB, "uSeed220"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The same sweep, now with the stage-4 link pin also in play: A's program is LINKED (so the
// node is MarkLinkReferenced) and then A is detached and deleted, while B still shares the
// node. Both protections have to hold at once - the link must report GL_TRUE and B must
// still compile.
TEST_F(ShaderCompileAdoptionTest, OrphanSweepWithALinkPinnedSharedNodeHoldsBoth) {
const AsyncModeScope async(true);
Vector<String> backlog;
SaturatePool(48, backlog);
const String source = MakeBulkySource(230);
const char* text = source.c_str();
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(a, 1, &text, nullptr);
CompileShader(a);
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(b, 1, &text, nullptr);
CompileShader(b);
const ShaderCompileTask* sharedNode = NodeOf(b);
ASSERT_NE(sharedNode, nullptr);
ASSERT_EQ(NodeOf(a), sharedNode);
// The ordinary teardown order: link, then detach, then delete. No status read in between,
// so the link's own prologue is what joins the shared compile.
const GLuint vs = MakeAndCompile(GL_VERTEX_SHADER, kVs);
const GLuint program = CreateProgram();
AttachShader(program, vs);
AttachShader(program, a);
LinkProgram(program);
DetachShader(program, a);
DeleteShader(a);
EXPECT_EQ(IsShader(a), GL_FALSE);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "the pinned shared compile must still publish";
EXPECT_GE(GetUniformLocation(program, "uSeed230"), 0);
EXPECT_EQ(NodeOf(b), sharedNode);
ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// Every sharer released, in turn, with nothing pinning the node: the LAST release is the one
// that may cancel, and afterwards the map must not hand the cancelled node to anybody. The
// property asserted is the one that matters and it is timing-free: whatever happened to the
// old node, a later object with the same source must end up with a CORRECT compile.
TEST_F(ShaderCompileAdoptionTest, AfterEverySharerIsGoneTheNextCompileIsStillCorrect) {
const AsyncModeScope async(true);
const CompilerThreadScope compilerThreads;
// One worker and a deep backlog: a node posted now is overwhelmingly likely to still be
// queued when its last holder drops it, which is the state in which the cancel bites.
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(1);
Vector<String> backlog;
SaturatePool(48, backlog);
const String source = MakeBulkySource(240);
const char* text = source.c_str();
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(a, 1, &text, nullptr);
CompileShader(a);
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(b, 1, &text, nullptr);
CompileShader(b);
ASSERT_EQ(NodeOf(a), NodeOf(b));
DeleteShader(a);
DeleteShader(b); // the last holder: this one is authorized to cancel
const GLuint c = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(c, 1, &text, nullptr);
CompileShader(c);
ASSERT_EQ(QueryCompileStatus(c), GL_TRUE)
<< "a cancelled node must never be adopted - it can only ever report GL_FALSE. "
<< QueryShaderInfoLog(c);
const GLuint program = LinkWith(c);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE);
EXPECT_GE(GetUniformLocation(program, "uSeed240"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// The bypasses: both must be byte-identical to the pre-stage-6 behaviour
// ---------------------------------------------------------------------------------------
// The kill switch. With the flag off, compilation is synchronous and NOTHING is adopted -
// the map is not even consulted, so the counter cannot move.
TEST_F(ShaderCompileAdoptionTest, FlagOffAdoptsNothing) {
const AsyncModeScope async(false);
ASSERT_FALSE(MG_Util::Async::AsyncShaderCompileEnabled());
const String source = MakeBulkySource(300);
const char* text = source.c_str();
const Uint64 before = AdoptionCount();
Vector<GLuint> shaders;
for (int i = 0; i < 6; ++i) {
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
shaders.push_back(fs);
}
EXPECT_EQ(AdoptionCount(), before) << "the flag-off path must not consult the adoption map";
for (SizeT i = 1; i < shaders.size(); ++i) {
EXPECT_NE(NodeOf(shaders[i]), NodeOf(shaders[0])) << "flag off means one node per object";
}
for (const GLuint fs : shaders) {
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// glMaxShaderCompilerThreadsKHR(0) puts compilation back on the application's thread even
// though the extension stays advertised. Adoption keys off the same predicate, so a
// suspended context shares nothing either - which is what keeps a subsequent
// GL_COMPLETION_STATUS_KHR immediately GL_TRUE without any reasoning about shared nodes.
TEST_F(ShaderCompileAdoptionTest, SuspendedCompilationAdoptsNothing) {
const AsyncModeScope async(true);
const CompilerThreadScope compilerThreads;
MaxShaderCompilerThreadsKHR(0);
ASSERT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended());
const String source = MakeBulkySource(310);
const char* text = source.c_str();
const Uint64 before = AdoptionCount();
Vector<GLuint> shaders;
for (int i = 0; i < 4; ++i) {
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
shaders.push_back(fs);
GLint complete = GL_FALSE;
GetShaderiv(fs, GL_COMPLETION_STATUS_KHR, &complete);
EXPECT_EQ(complete, GL_TRUE) << "a zero compiler-thread count leaves nothing in flight";
}
EXPECT_EQ(AdoptionCount(), before);
for (SizeT i = 1; i < shaders.size(); ++i) {
EXPECT_NE(NodeOf(shaders[i]), NodeOf(shaders[0]));
}
for (const GLuint fs : shaders) {
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------------
// Stress
// ---------------------------------------------------------------------------------------
// The shaderpack shape: 48 objects over 6 distinct sources, all enqueued before anything is
// read, on a two-worker pool. 42 of the 48 compiles must simply vanish, and all 48 objects
// must still be individually correct - each with its own name, its own status, and its own
// link (which means 48 claims against 6 shared parses).
TEST_F(ShaderCompileAdoptionTest, StressFortyEightObjectsOverSixSources) {
const AsyncModeScope async(true);
const CompilerThreadScope compilerThreads;
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(2);
constexpr int kDistinct = 6;
constexpr int kDuplicates = 8;
Vector<String> sources;
sources.reserve(kDistinct);
for (int i = 0; i < kDistinct; ++i) {
sources.push_back(MakeBulkySource(400 + i));
}
const Uint64 before = AdoptionCount();
Vector<GLuint> shaders;
for (int duplicate = 0; duplicate < kDuplicates; ++duplicate) {
for (int i = 0; i < kDistinct; ++i) {
const char* text = sources[static_cast<SizeT>(i)].c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
shaders.push_back(fs);
}
}
const Uint64 adoptions = AdoptionCount() - before;
// The floor the stage contracts for, with room for any future scheduling slack...
ASSERT_GE(adoptions, 30u) << "48 objects over 6 sources adopted only " << adoptions << " times";
// ...and the number this design actually produces, because the decision is made on the GL
// thread before anything is posted and therefore does not depend on the workers at all.
EXPECT_EQ(adoptions, static_cast<Uint64>(kDistinct * (kDuplicates - 1)));
for (SizeT s = 0; s < shaders.size(); ++s) {
const GLuint fs = shaders[s];
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << "shader " << s << ": " << QueryShaderInfoLog(fs);
const GLuint program = LinkWith(fs);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "shader index " << s;
const String uniform = "uSeed" + std::to_string(400 + static_cast<int>(s % kDistinct));
EXPECT_GE(GetUniformLocation(program, uniform.c_str()), 0) << uniform;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The adversarial interleaving, with duplicates everywhere: compile, query, re-source,
// re-compile, delete, all with the pool busy and most objects sharing nodes. Nothing here
// asserts timing - what it hunts for is a node cancelled out from under a sharer, which
// surfaces as a wrong status, a wrong uniform, or a crash.
TEST_F(ShaderCompileAdoptionTest, StressSharedNodesUnderResourceAndDelete) {
const AsyncModeScope async(true);
constexpr int kRounds = 6;
constexpr int kPerRound = 12;
for (int round = 0; round < kRounds; ++round) {
Vector<String> sources;
sources.reserve(4);
for (int i = 0; i < 4; ++i) {
sources.push_back(MakeBulkySource(round * 100 + i));
}
const String replacement = MakeBulkySource(round * 100 + 50);
const char* replacementText = replacement.c_str();
Vector<GLuint> shaders;
for (int i = 0; i < kPerRound; ++i) {
const char* text = sources[static_cast<SizeT>(i % 4)].c_str();
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &text, nullptr);
CompileShader(fs);
shaders.push_back(fs);
}
// Re-source a third of them onto ONE new shared source, so the survivors of each
// original node keep waiting on it while the movers pile onto a new one.
for (int i = 0; i < kPerRound; i += 3) {
ShaderSource(shaders[static_cast<SizeT>(i)], 1, &replacementText, nullptr);
CompileShader(shaders[static_cast<SizeT>(i)]);
}
// And delete another third outright, while their nodes are still shared.
for (int i = 1; i < kPerRound; i += 3) {
DeleteShader(shaders[static_cast<SizeT>(i)]);
}
for (int i = 0; i < kPerRound; ++i) {
if (i % 3 == 1) continue; // deleted
const GLuint shader = shaders[static_cast<SizeT>(i)];
ASSERT_EQ(QueryCompileStatus(shader), GL_TRUE)
<< "round " << round << " shader " << i << ": " << QueryShaderInfoLog(shader);
const GLuint program = LinkWith(shader);
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "round " << round << " shader " << i;
const String expected =
"uSeed" + std::to_string(i % 3 == 0 ? round * 100 + 50 : round * 100 + (i % 4));
EXPECT_GE(GetUniformLocation(program, expected.c_str()), 0)
<< "round " << round << " shader " << i << " expected " << expected;
DeleteProgram(program);
}
for (int i = 0; i < kPerRound; ++i) {
if (i % 3 != 1) DeleteShader(shaders[static_cast<SizeT>(i)]);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
}
// ---------------------------------------------------------------------------------------
// The map itself, driven directly
// ---------------------------------------------------------------------------------------
// Two of the map's rules cannot be forced deterministically through the GL surface - a
// cancelled node depends on beating a worker to it, and a CompileEnv re-capture needs a
// backend swap. Both are unconditional properties of the class, so they are asserted here
// against the class.
namespace {
SharedPtr<ShaderCompileTask> MakeNode(const String& text, const ShaderStage stage,
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv>& env) {
auto source = MakeShared<const String>(text);
const Uint64 hash = ShaderPreprocessCache::HashSource(*source);
return MakeShared<ShaderCompileTask>(stage, source, hash, env, nullptr, 0);
}
} // namespace
TEST(ShaderCompileAdoptionMapTest, RegisteredNodeIsAdoptedOnAnExactMatch) {
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
ShaderCompileAdoptionMap map;
const String text = "#version 460\nvoid main() {}\n";
const SharedPtr<ShaderCompileTask> node = MakeNode(text, ShaderStage::Fragment, env);
map.Register(node);
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text,
env->fingerprint),
node);
EXPECT_EQ(map.GetAdoptionCount(), 1u);
// Every discriminator in the key is load-bearing.
EXPECT_EQ(map.FindAdoptable(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(text), text,
env->fingerprint),
nullptr);
const String other = text + "\n";
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(other), other,
env->fingerprint),
nullptr);
EXPECT_EQ(map.GetAdoptionCount(), 1u) << "a miss must not count as an adoption";
}
// A memo must never be handed back under an environment other than the one it was computed
// against: the compute local-size verdict inside the pipeline reads CompileEnv's device
// limits, so a node captured under one backend's limits is not a valid answer under
// another's. The fingerprint is what enforces that, and it is part of the key.
TEST(ShaderCompileAdoptionMapTest, EnvFingerprintMismatchIsNotAdopted) {
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
ShaderCompileAdoptionMap map;
const String text = "#version 460\nvoid main() {}\n";
map.Register(MakeNode(text, ShaderStage::Fragment, env));
// A genuinely different environment: different device limits, hence a different
// fingerprint, hence a different key.
auto otherEnv = MakeShared<MG_Util::ShaderTranspiler::CompileEnv>(*env);
otherEnv->maxComputeWorkGroupInvocations = env->maxComputeWorkGroupInvocations + 1;
otherEnv->fingerprint = MG_Util::ShaderTranspiler::ComputeCompileEnvFingerprint(*otherEnv);
ASSERT_NE(otherEnv->fingerprint, env->fingerprint);
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text,
otherEnv->fingerprint),
nullptr);
EXPECT_EQ(map.GetAdoptionCount(), 0u);
}
// A node that settled as Cancelled published nothing, so adopting it would hand the new
// object a compile that can only ever report GL_FALSE. It must be a miss, and the dead entry
// must be pruned where it is found rather than waiting for the amortized sweep.
TEST(ShaderCompileAdoptionMapTest, CancelledNodeIsNotAdoptedAndIsPruned) {
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
ShaderCompileAdoptionMap map;
const String text = "#version 460\nvoid main() {}\n";
const SharedPtr<ShaderCompileTask> node = MakeNode(text, ShaderStage::Fragment, env);
map.Register(node);
// Never posted, so this settles the node as Cancelled right here.
node->Cancel();
ASSERT_TRUE(node->IsCancelled());
ASSERT_EQ(map.GetEntryCount(), 1u);
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text,
env->fingerprint),
nullptr);
EXPECT_EQ(map.GetEntryCount(), 0u) << "the dead entry must be pruned on the lookup that found it";
EXPECT_EQ(map.GetAdoptionCount(), 0u);
}
// The map is an index, never an owner: once the last real holder is gone the entry expires
// and is pruned, so a node's artifacts can never be kept alive by the map alone.
TEST(ShaderCompileAdoptionMapTest, ExpiredNodeIsNotAdoptedAndIsPruned) {
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
ShaderCompileAdoptionMap map;
const String text = "#version 460\nvoid main() {}\n";
{
map.Register(MakeNode(text, ShaderStage::Fragment, env));
}
ASSERT_EQ(map.GetEntryCount(), 1u);
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text,
env->fingerprint),
nullptr);
EXPECT_EQ(map.GetEntryCount(), 0u);
}
// The amortized sweep keeps the index O(live nodes) instead of O(compiles ever issued).
TEST(ShaderCompileAdoptionMapTest, SweepReclaimsDeadEntries) {
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
ShaderCompileAdoptionMap map;
// Every one of these dies immediately, so nothing but dead weight accumulates - and the
// map must not grow without bound because of it.
for (SizeT i = 0; i < ShaderCompileAdoptionMap::kMinSweepThreshold * 4; ++i) {
map.Register(MakeNode("#version 460\nvoid main() { float x" + std::to_string(i) + " = 0.0; }\n",
ShaderStage::Fragment, env));
}
EXPECT_LE(map.GetEntryCount(), ShaderCompileAdoptionMap::kMinSweepThreshold)
<< "expired entries must be reclaimed, not accumulated";
}
@@ -0,0 +1,984 @@
// MobileGL - MobileGL/MG_Test/Program/XfbFrontendOrderInvarianceTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// The frontend's answer for a transform-feedback program must not depend on what
// was linked before it. This binary asserts exactly that, headlessly: it links a
// clip_distance-shaped program A, then an XFB-shaped program B, and diffs B's
// whole frontend output (xfb varyings and their offsets, strides, buffer mode,
// scattered-capture and geometry-strip verdicts, uniform blocks, attribute and
// uniform counts, and every SPIR-V module byte-for-byte plus its Location /
// Component / Index / Offset / XfbBuffer / XfbStride / BuiltIn / Binding /
// DescriptorSet decorations) against the same B linked with no A ahead of it.
//
// It was written to arbitrate an order-triggered CTS failure - after
// KHR-GLxx.clip_distance.functional ran, every later transform_feedback capture
// case failed on DirectVulkan - and its verdict was NEGATIVE, which is what made
// it worth keeping: B's frontend output is bit-identical under every ordering,
// every flag state (MOBILEGL_ASYNC_SHADER_COMPILE on/off, THREADS unset/1/8) and
// every isolation level below. That ruled out the whole frontend - the P0b
// preprocess cache, the stage-6 adoption map, ProgramState, glslang's shared
// built-in symbol tables, the pool workers' thread_locals - and sent the hunt
// downstream, where the defect actually was: DirectVulkan's per-VAO vertex
// binding memo keyed on recycled heap addresses (see
// MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp). Keep it as the
// standing guard on the negative half of that split: if the frontend ever DOES
// acquire cross-program order sensitivity, this is what says so.
//
// Isolation model - three levels, all in one binary:
// * FRESH CONTEXT MG_State::Init() reinstalls pGLContext, which is what
// drops the P0b cache, the adoption map and ProgramState.
// Process globals (glslang tables, prewarm latch, pool
// worker thread_locals) deliberately SURVIVE it, which is
// what makes the fresh-context control a bisection step
// rather than just a reset.
// * FRESH PROCESS ctest runs each gtest case in this binary in the same
// process, so the "control first, poisoned second" and
// "poisoned first, control second" orderings are split
// into two cases whose names sort in opposite orders and
// which each capture BOTH snapshots themselves. A truly
// fresh process is available by running one case with
// --gtest_filter (see the FreshProcess* cases).
// * CACHE-CLEARED context kept, but the source text of B is made unique
// per run so no P0b/adoption hit is possible at all.
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include "Config.h"
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_State/GLState/Core.h"
#include "MG_Util/Async/ShaderCompilePool.h"
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
namespace {
// ---------------------------------------------------------------------------------
// Flag plumbing (same shape AsyncCompileTest uses)
// ---------------------------------------------------------------------------------
class AsyncModeScope {
public:
explicit AsyncModeScope(const Bool async)
: m_saved(MG_Config::Features.AsyncShaderCompile) {
MG_Config::Features.AsyncShaderCompile =
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
}
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
AsyncModeScope(const AsyncModeScope&) = delete;
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
private:
const MG_Config::QuirkOverride m_saved;
};
// ---------------------------------------------------------------------------------
// A: the clip_distance.functional shape
// glcClipDistance.cpp, FunctionalTest::m_vertex_shader_code with
// CLIP_DISTANCE_REDECLARATION = m_explicit_redeclaration and
// CLIP_DISTANCE_SETUP = m_dynamic_array_setter, clip function 0.
// ${VERSION} for a KHR-GL40 run is "#version 400".
// ---------------------------------------------------------------------------------
String ClipDistanceVs(const int clipCount, const char* version) {
const String n = std::to_string(clipCount);
return String(version) +
"\n"
"\n"
"out float gl_ClipDistance[" + n + "];\n"
"\n"
"float f(int i)\n"
"{\n"
" return 0.0;\n"
"}\n"
"\n"
"in vec4 position;\n"
"\n"
"void main()\n"
"{\n"
" for(int i = 0; i < " + n + "; i++)\n"
" {\n"
" gl_ClipDistance[i] = f(i);\n"
" }\n"
"\n"
" gl_Position = position;\n"
"}\n";
}
String ClipDistanceFs(const char* version) {
return String(version) +
"\n"
"\n"
"\n"
"out highp vec4 color;\n"
"\n"
"void main()\n"
"{\n"
" color = vec4(1.0, 0.0, 0.0, 1.0);\n"
"}\n";
}
// ---------------------------------------------------------------------------------
// B1: the transform_feedback3 skip_components shape
// gl3cTransformFeedback3Tests.cpp, TransformFeedbackBaseTestCase::m_shader_vert
// at "#version 150", captured with the gl_SkipComponents* varying list.
// This is the case whose failure text is the crispest:
// "compareArrays(GLfloat):index 1 value -2 != 1"
// ---------------------------------------------------------------------------------
String SkipComponentsVs(const char* version, const String& saltComment = String()) {
return String(version) + "\n" + saltComment +
" in vec4 vertex;\n"
" out vec4 value1;\n"
" out vec4 value2;\n"
" out vec4 value3;\n"
" out vec4 value4;\n"
"\n"
" void main (void)\n"
" {\n"
" vec4 temp = vertex;\n"
"\n"
" gl_Position = temp;\n"
"\n"
" value1 = abs(temp) * 1.0;\n"
" value2 = abs(temp) * 2.0;\n"
" value3 = abs(temp) * 3.0;\n"
" value4 = abs(temp) * 4.0;\n"
" }\n";
}
String SkipComponentsFs(const char* version) {
return String(version) +
"\n"
" out vec4 fragColor;\n"
" void main (void)\n"
" {\n"
" fragColor = vec4(0.0, 0.0, 0.0, 1.0);\n"
" }\n";
}
Vector<String> SkipComponentsVaryings() {
return {"gl_SkipComponents1", "value1", "gl_SkipComponents2", "gl_SkipComponents1", "value2",
"gl_SkipComponents3", "gl_SkipComponents2", "value3", "gl_SkipComponents4", "value4"};
}
// ---------------------------------------------------------------------------------
// B2: the capture_vertex_interleaved shape
// gl3cTransformFeedbackTests.cpp, CaptureVertexInterleaved::
// s_vertex_shader_source_code_template at "#version 130", with
// MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS/4 - 1 user vec4 outputs
// plus gl_Position as the final captured varying.
// ---------------------------------------------------------------------------------
String CaptureInterleavedVs(const int userVaryings, const char* version) {
String declarations;
String setters;
for (int i = 0; i < userVaryings; ++i) {
const String name = "result_" + std::to_string(i);
declarations += "out vec4 " + name + ";\n";
setters += " " + name + " = vec4(" + std::to_string(i * 4) + ".0, " +
std::to_string(i * 4 + 1) + ".0, " + std::to_string(i * 4 + 2) + ".0, " +
std::to_string(i * 4 + 3) + ".0);\n";
}
return String(version) + "\n\n" + declarations + "\n" +
"void main()\n"
"{\n" +
setters +
"\n"
" vec4 position = vec4(0.0);\n"
"\n"
" switch(gl_VertexID)\n"
" {\n"
" case 0:\n"
" position = vec4(-1.0 + 0.0625, 1.0 - 0.0625, 0.0, 1.0);\n"
" break;\n"
" case 1:\n"
" position = vec4( 1.0 - 0.0625, 1.0 - 0.0625, 0.0, 1.0);\n"
" break;\n"
" case 2:\n"
" position = vec4(-1.0 + 0.0625, -1.0 + 0.0625, 0.0, 1.0);\n"
" break;\n"
" case 3:\n"
" position = vec4( 1.0 - 0.0625, -1.0 + 0.0625, 0.0, 1.0);\n"
" break;\n"
" }\n"
"\n"
" gl_Position = position;\n"
"}\n";
}
String CaptureInterleavedFs(const char* version) {
return String(version) +
"\n"
"\n"
"out vec4 color;\n"
"\n"
"void main()\n"
"{\n"
" color = vec4(0.5);\n"
"}\n";
}
Vector<String> CaptureInterleavedVaryings(const int userVaryings) {
Vector<String> names;
for (int i = 0; i < userVaryings; ++i) names.push_back("result_" + std::to_string(i));
names.push_back("gl_Position");
return names;
}
// ---------------------------------------------------------------------------------
// B3: the capture_geometry_interleaved shape. The only shape that reaches
// ResolveGsTriangleStripCapture, i.e. the gsStripTriangles / gsStripCaptureFixup
// artifacts - and triangle_strip is the sub-case that needs the fixup.
// ---------------------------------------------------------------------------------
const char* kGeometryBlankVs = "#version 130\n"
"\n"
"void main()\n"
"{\n"
"}\n";
String CaptureGeometryGs(const int userVaryings, const char* outPrimitive) {
String declarations;
String setters;
for (int i = 0; i < userVaryings; ++i) {
const String name = "result_" + std::to_string(i);
declarations += "out vec4 " + name + ";\n";
setters += " " + name + " = vec4(" + std::to_string(i * 4) + ".0, " +
std::to_string(i * 4 + 1) + ".0, " + std::to_string(i * 4 + 2) + ".0, " +
std::to_string(i * 4 + 3) + ".0);\n";
}
String source = "#version 150\n"
"\n"
"layout(points) in;\n"
"layout(" +
String(outPrimitive) +
", max_vertices = 4) out;\n"
"\n" +
declarations + "\n" +
"void main()\n"
"{\n";
const char* positions[] = {"vec4(-1.0 + 0.0625, 1.0 - 0.0625, 0.0, 1.0)",
"vec4( 1.0 - 0.0625, 1.0 - 0.0625, 0.0, 1.0)",
"vec4(-1.0 + 0.0625, -1.0 + 0.0625, 0.0, 1.0)",
"vec4( 1.0 - 0.0625, -1.0 + 0.0625, 0.0, 1.0)"};
for (const char* position : positions) {
source += String("\n gl_Position = ") + position + ";\n";
source += setters;
source += " EmitVertex();\n";
}
source += "}\n";
return source;
}
GLuint BuildProgramWithGeometry(const String& vertexSource, const String& geometrySource,
const String& fragmentSource, const Vector<String>& xfbVaryings);
// ---------------------------------------------------------------------------------
// SPIR-V digest: hash + every decoration that could express a slot shift, resolved
// through OpName so the text is stable across id renumbering.
// ---------------------------------------------------------------------------------
constexpr Uint32 kOpName = 5;
constexpr Uint32 kOpMemberName = 6;
constexpr Uint32 kOpEntryPoint = 15;
constexpr Uint32 kOpDecorate = 71;
constexpr Uint32 kOpMemberDecorate = 72;
const char* DecorationName(const Uint32 decoration) {
switch (decoration) {
case 11: return "BuiltIn";
case 30: return "Location";
case 31: return "Component";
case 32: return "Index";
case 33: return "Binding";
case 34: return "DescriptorSet";
case 35: return "Offset";
case 36: return "XfbBuffer";
case 37: return "XfbStride";
case 38: return "FuncParamAttr";
default: return nullptr;
}
}
const char* BuiltInName(const Uint32 builtIn) {
switch (builtIn) {
case 0: return "Position";
case 1: return "PointSize";
case 3: return "ClipDistance";
case 4: return "CullDistance";
case 5: return "VertexId";
case 42: return "VertexIndex";
default: return nullptr;
}
}
String ReadSpirvString(const Vector<unsigned>& words, const SizeT firstWord, const SizeT endWord,
SizeT& outNextWord) {
String text;
SizeT w = firstWord;
for (; w < endWord; ++w) {
const Uint32 word = words[w];
Bool done = false;
for (int b = 0; b < 4; ++b) {
const char c = static_cast<char>((word >> (8 * b)) & 0xFF);
if (c == '\0') {
done = true;
break;
}
text.push_back(c);
}
if (done) {
++w;
break;
}
}
outNextWord = w;
return text;
}
Uint64 Fnv1a(const Vector<unsigned>& words) {
Uint64 hash = 1469598103934665603ULL;
for (const unsigned word : words) {
for (int b = 0; b < 4; ++b) {
hash ^= static_cast<Uint64>((word >> (8 * b)) & 0xFF);
hash *= 1099511628211ULL;
}
}
return hash;
}
struct SpirvDigest {
Uint64 hash = 0;
SizeT wordCount = 0;
Vector<String> decorations;
Vector<String> interfaceNames;
};
SpirvDigest DigestSpirv(const Vector<unsigned>& words) {
SpirvDigest digest;
digest.hash = Fnv1a(words);
digest.wordCount = words.size();
if (words.size() < 5 || words[0] != 0x07230203u) {
digest.decorations.push_back("<not a SPIR-V module>");
return digest;
}
UnorderedMap<Uint32, String> names;
Vector<Uint32> interfaceIds;
struct PendingDecoration {
Uint32 target;
Int member; // -1 for OpDecorate
Uint32 decoration;
Vector<Uint32> operands;
};
Vector<PendingDecoration> pending;
SizeT w = 5;
while (w < words.size()) {
const Uint32 header = words[w];
const Uint32 wordCount = header >> 16;
const Uint32 opcode = header & 0xFFFFu;
if (wordCount == 0 || w + wordCount > words.size()) break;
if (opcode == kOpName && wordCount >= 3) {
SizeT next = 0;
names[words[w + 1]] = ReadSpirvString(words, w + 2, w + wordCount, next);
} else if (opcode == kOpMemberName && wordCount >= 4) {
SizeT next = 0;
const String member = ReadSpirvString(words, w + 3, w + wordCount, next);
names[words[w + 1]] = names.count(words[w + 1]) ? names[words[w + 1]] : String("<struct>");
(void)member;
} else if (opcode == kOpEntryPoint && wordCount >= 4) {
SizeT next = 0;
(void)ReadSpirvString(words, w + 3, w + wordCount, next);
for (SizeT i = next; i < w + wordCount; ++i) interfaceIds.push_back(words[i]);
} else if (opcode == kOpDecorate && wordCount >= 3) {
PendingDecoration entry{words[w + 1], -1, words[w + 2], {}};
for (SizeT i = w + 3; i < w + wordCount; ++i) entry.operands.push_back(words[i]);
pending.push_back(Move(entry));
} else if (opcode == kOpMemberDecorate && wordCount >= 4) {
PendingDecoration entry{words[w + 1], static_cast<Int>(words[w + 2]), words[w + 3], {}};
for (SizeT i = w + 4; i < w + wordCount; ++i) entry.operands.push_back(words[i]);
pending.push_back(Move(entry));
}
w += wordCount;
}
const auto label = [&](const Uint32 id) {
const auto it = names.find(id);
if (it != names.end() && !it->second.empty()) return it->second;
return String("%") + std::to_string(id);
};
for (const auto& entry : pending) {
const char* decorationName = DecorationName(entry.decoration);
if (decorationName == nullptr) continue; // relocation-irrelevant decorations
String line = label(entry.target);
if (entry.member >= 0) line += "[member " + std::to_string(entry.member) + "]";
line += " ";
line += decorationName;
line += " =";
for (const Uint32 operand : entry.operands) {
if (entry.decoration == 11) {
const char* builtIn = BuiltInName(operand);
line += String(" ") + (builtIn != nullptr ? builtIn : std::to_string(operand));
} else {
line += " " + std::to_string(operand);
}
}
digest.decorations.push_back(line);
}
std::sort(digest.decorations.begin(), digest.decorations.end());
for (const Uint32 id : interfaceIds) digest.interfaceNames.push_back(label(id));
std::sort(digest.interfaceNames.begin(), digest.interfaceNames.end());
return digest;
}
// ---------------------------------------------------------------------------------
// The snapshot under test
// ---------------------------------------------------------------------------------
struct XfbSnapshot {
GLint linkStatus = GL_FALSE;
String infoLog;
GLenum bufferMode = 0;
Uint32 packedStride = 0;
Bool needsScattered = false;
Int varyingNameMaxLength = 0;
Vector<Uint32> strides;
Vector<String> varyings;
GLenum gsInputPrimitive = 0;
Bool gsStripCaptureFixup = false;
Vector<Uint32> gsStripTriangles;
Int uniformBlockCount = 0;
Vector<Uint> uniformBlockBindings;
Uint maxUniformLocation = 0;
GLint activeAttributes = 0;
GLint activeUniforms = 0;
Vector<SpirvDigest> spirv;
};
String QueryProgramInfoLog(const GLuint program) {
GLint length = 0;
GetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
if (length <= 0) return String();
std::vector<GLchar> buffer(static_cast<size_t>(length));
GLsizei written = 0;
GetProgramInfoLog(program, length, &written, buffer.data());
return String(buffer.data(), static_cast<size_t>(written));
}
XfbSnapshot Capture(const GLuint program) {
XfbSnapshot snapshot;
GetProgramiv(program, GL_LINK_STATUS, &snapshot.linkStatus);
snapshot.infoLog = QueryProgramInfoLog(program);
const auto& object = MG_State::pGLContext->GetProgramObject(program);
if (object == nullptr) {
snapshot.infoLog += "<no program object>";
return snapshot;
}
snapshot.bufferMode = object->GetTransformFeedbackBufferMode();
snapshot.packedStride = object->GetTransformFeedbackPackedStride();
snapshot.needsScattered = object->NeedsScatteredTransformFeedbackCapture();
snapshot.varyingNameMaxLength = object->GetTransformFeedbackVaryingMaxLength();
snapshot.gsInputPrimitive = object->GetGeometryInputType();
snapshot.gsStripCaptureFixup = object->HasGsTriangleStripCaptureFixup();
snapshot.gsStripTriangles = object->GetGsStripTriangles();
// The rest of ProgramFactory::ComputeHash's input set, so "the backend cache key is
// unchanged" is something this binary measures rather than assumes.
snapshot.uniformBlockCount = object->GetActiveUniformBlocksCount();
for (Int i = 0; i < snapshot.uniformBlockCount; ++i) {
snapshot.uniformBlockBindings.push_back(object->GetUniformBlockBinding(static_cast<Uint>(i)));
}
snapshot.maxUniformLocation = object->GetMaxUniformLocation();
GetProgramiv(program, GL_ACTIVE_ATTRIBUTES, &snapshot.activeAttributes);
GetProgramiv(program, GL_ACTIVE_UNIFORMS, &snapshot.activeUniforms);
for (SizeT i = 0; i < object->GetTransformFeedbackBufferCount(); ++i) {
snapshot.strides.push_back(object->GetTransformFeedbackStride(static_cast<Uint32>(i)));
}
for (const auto& varying : object->GetTransformFeedbackVaryings()) {
snapshot.varyings.push_back(varying.name + " type=0x" + [&] {
char buffer[16];
std::snprintf(buffer, sizeof(buffer), "%04X", static_cast<unsigned>(varying.type));
return String(buffer);
}() + " size=" + std::to_string(varying.size) + " buf=" + std::to_string(varying.bufferIndex) +
" off=" + std::to_string(varying.offsetBytes) +
" bytes=" + std::to_string(varying.byteSize) +
" packedOff=" + std::to_string(varying.packedOffsetBytes));
}
for (const auto& module : object->GetGeneratedSpirv()) {
snapshot.spirv.push_back(DigestSpirv(module));
}
return snapshot;
}
// One text blob per snapshot, so a mismatch shows up as a readable gtest diff.
String Render(const XfbSnapshot& snapshot, const Bool includeSpirvHash) {
String text;
text += "linkStatus = " + std::to_string(snapshot.linkStatus) + "\n";
if (!snapshot.infoLog.empty()) text += "infoLog = " + snapshot.infoLog + "\n";
text += "xfbBufferMode = " + std::to_string(snapshot.bufferMode) + "\n";
text += "xfbPackedStride = " + std::to_string(snapshot.packedStride) + "\n";
text += "xfbNeedsScatter = " + std::to_string(static_cast<int>(snapshot.needsScattered)) + "\n";
text += "xfbNameMaxLength = " + std::to_string(snapshot.varyingNameMaxLength) + "\n";
text += "xfbStrides =";
for (const Uint32 stride : snapshot.strides) text += " " + std::to_string(stride);
text += "\n";
text += "xfbVaryings (" + std::to_string(snapshot.varyings.size()) + "):\n";
for (const String& varying : snapshot.varyings) text += " " + varying + "\n";
text += "gsInputPrimitive = " + std::to_string(snapshot.gsInputPrimitive) + "\n";
text += "gsStripFixup = " + std::to_string(static_cast<int>(snapshot.gsStripCaptureFixup)) + "\n";
text += "gsStripTriangles =";
for (const Uint32 triangle : snapshot.gsStripTriangles) text += " " + std::to_string(triangle);
text += "\n";
text += "uniformBlocks = " + std::to_string(snapshot.uniformBlockCount) + " bindings:";
for (const Uint binding : snapshot.uniformBlockBindings) text += " " + std::to_string(binding);
text += "\n";
text += "maxUniformLoc = " + std::to_string(snapshot.maxUniformLocation) + "\n";
text += "activeAttribs = " + std::to_string(snapshot.activeAttributes) + "\n";
text += "activeUniforms = " + std::to_string(snapshot.activeUniforms) + "\n";
for (SizeT i = 0; i < snapshot.spirv.size(); ++i) {
const SpirvDigest& digest = snapshot.spirv[i];
text += "spirv[" + std::to_string(i) + "] words=" + std::to_string(digest.wordCount);
if (includeSpirvHash) {
char buffer[32];
std::snprintf(buffer, sizeof(buffer), " hash=%016llX",
static_cast<unsigned long long>(digest.hash));
text += buffer;
}
text += "\n";
text += " interface:";
for (const String& name : digest.interfaceNames) text += " " + name;
text += "\n";
for (const String& decoration : digest.decorations) text += " " + decoration + "\n";
}
return text;
}
// ---------------------------------------------------------------------------------
// Program construction through the real GL entry points
// ---------------------------------------------------------------------------------
GLuint BuildProgram(const String& vertexSource, const String& fragmentSource,
const Vector<String>& xfbVaryings, const GLenum bufferMode) {
const GLuint vertexShader = CreateShader(GL_VERTEX_SHADER);
const char* vertexText = vertexSource.c_str();
ShaderSource(vertexShader, 1, &vertexText, nullptr);
CompileShader(vertexShader);
const GLuint fragmentShader = CreateShader(GL_FRAGMENT_SHADER);
const char* fragmentText = fragmentSource.c_str();
ShaderSource(fragmentShader, 1, &fragmentText, nullptr);
CompileShader(fragmentShader);
const GLuint program = CreateProgram();
AttachShader(program, vertexShader);
AttachShader(program, fragmentShader);
if (!xfbVaryings.empty()) {
std::vector<const GLchar*> names;
names.reserve(xfbVaryings.size());
for (const String& name : xfbVaryings) names.push_back(name.c_str());
TransformFeedbackVaryings(program, static_cast<GLsizei>(names.size()), names.data(), bufferMode);
}
LinkProgram(program);
DeleteShader(vertexShader);
DeleteShader(fragmentShader);
return program;
}
// A, exactly as the CTS builds it for the failing sub-case (1 clip distance, dynamic
// setter, clip function 0). Returns the program so the caller can keep it alive, which
// is what the CTS does too (it holds m_program across the whole case).
GLuint LinkClipDistanceProgram(const int clipCount, const char* version) {
return BuildProgram(ClipDistanceVs(clipCount, version), ClipDistanceFs(version), {},
GL_INTERLEAVED_ATTRIBS);
}
GLuint LinkSkipComponentsProgram(const char* version, const String& salt = String()) {
return BuildProgram(SkipComponentsVs(version, salt), SkipComponentsFs(version),
SkipComponentsVaryings(), GL_INTERLEAVED_ATTRIBS);
}
GLuint LinkCaptureInterleavedProgram(const int userVaryings, const char* version) {
return BuildProgram(CaptureInterleavedVs(userVaryings, version), CaptureInterleavedFs(version),
CaptureInterleavedVaryings(userVaryings), GL_INTERLEAVED_ATTRIBS);
}
GLuint BuildProgramWithGeometry(const String& vertexSource, const String& geometrySource,
const String& fragmentSource, const Vector<String>& xfbVaryings) {
const auto makeShader = [](const GLenum type, const String& source) {
const GLuint shader = CreateShader(type);
const char* text = source.c_str();
ShaderSource(shader, 1, &text, nullptr);
CompileShader(shader);
return shader;
};
const GLuint vertexShader = makeShader(GL_VERTEX_SHADER, vertexSource);
const GLuint geometryShader = makeShader(GL_GEOMETRY_SHADER, geometrySource);
const GLuint fragmentShader = makeShader(GL_FRAGMENT_SHADER, fragmentSource);
const GLuint program = CreateProgram();
AttachShader(program, vertexShader);
AttachShader(program, geometryShader);
AttachShader(program, fragmentShader);
std::vector<const GLchar*> names;
names.reserve(xfbVaryings.size());
for (const String& name : xfbVaryings) names.push_back(name.c_str());
TransformFeedbackVaryings(program, static_cast<GLsizei>(names.size()), names.data(),
GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
DeleteShader(vertexShader);
DeleteShader(geometryShader);
DeleteShader(fragmentShader);
return program;
}
GLuint LinkCaptureGeometryProgram(const int userVaryings, const char* outPrimitive) {
return BuildProgramWithGeometry(kGeometryBlankVs, CaptureGeometryGs(userVaryings, outPrimitive),
CaptureInterleavedFs("#version 130"),
CaptureInterleavedVaryings(userVaryings));
}
// Reinstalls pGLContext: new ProgramState, new P0b preprocess cache, new stage-6
// adoption map. glslang's process globals are untouched on purpose.
void FreshContext() { MG_State::Init(); }
class XfbFrontendOrderInvarianceTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
void TearDown() override { FreshContext(); }
};
// The two B shapes, run through one lambda so every case tests both.
struct BCase {
const char* label;
GLuint (*link)();
};
GLuint LinkSkip150() { return LinkSkipComponentsProgram("#version 150"); }
GLuint LinkCapture130() { return LinkCaptureInterleavedProgram(15, "#version 130"); }
GLuint LinkSkip400() { return LinkSkipComponentsProgram("#version 400"); }
GLuint LinkCapture400() { return LinkCaptureInterleavedProgram(15, "#version 400"); }
GLuint LinkGeometryPoints() { return LinkCaptureGeometryProgram(15, "points"); }
GLuint LinkGeometryTriangleStrip() { return LinkCaptureGeometryProgram(15, "triangle_strip"); }
const BCase kBCases[] = {
{"skip_components@150", &LinkSkip150},
{"capture_interleaved@130", &LinkCapture130},
{"skip_components@400", &LinkSkip400},
{"capture_interleaved@400", &LinkCapture400},
{"capture_geometry@points", &LinkGeometryPoints},
{"capture_geometry@triangle_strip", &LinkGeometryTriangleStrip},
};
// ---------------------------------------------------------------------------------
// The core A/B comparison, parameterized on everything that could matter.
// ---------------------------------------------------------------------------------
struct AbResult {
String control;
String poisoned;
};
AbResult RunAb(const BCase& bCase, const int clipCount, const char* clipVersion,
const Bool freshContextForControl, const Bool includeSpirvHash) {
AbResult result;
// CONTROL: B alone, in a context that has never seen A.
if (freshContextForControl) FreshContext();
{
const GLuint program = bCase.link();
result.control = Render(Capture(program), includeSpirvHash);
DeleteProgram(program);
}
// POISONED: A first, then B, in ONE context - the glcts shape.
FreshContext();
{
const GLuint clipProgram = LinkClipDistanceProgram(clipCount, clipVersion);
GLint clipLinked = GL_FALSE;
GetProgramiv(clipProgram, GL_LINK_STATUS, &clipLinked);
// The A program is deliberately kept alive across B's link, exactly as the CTS
// holds its program object for the duration of the case.
const GLuint program = bCase.link();
result.poisoned = Render(Capture(program), includeSpirvHash);
if (clipLinked != GL_TRUE) {
result.poisoned += "\n<<< A DID NOT LINK: " + QueryProgramInfoLog(clipProgram) + " >>>\n";
}
DeleteProgram(program);
DeleteProgram(clipProgram);
}
return result;
}
} // namespace
// -------------------------------------------------------------------------------------
// 1. The headline question, both flag states, both B shapes, several clip counts.
// -------------------------------------------------------------------------------------
TEST_F(XfbFrontendOrderInvarianceTest, AsyncOn_ClipDistanceBeforeXfbChangesNothingInTheFrontend) {
const AsyncModeScope async(true);
ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled());
for (const BCase& bCase : kBCases) {
for (const int clipCount : {1, 4, 8}) {
for (const char* clipVersion : {"#version 400", "#version 150", "#version 130"}) {
const AbResult result = RunAb(bCase, clipCount, clipVersion, true, true);
EXPECT_EQ(result.control, result.poisoned)
<< "async=1 B=" << bCase.label << " clipCount=" << clipCount
<< " clipVersion=" << clipVersion;
}
}
}
}
TEST_F(XfbFrontendOrderInvarianceTest, AsyncOff_ClipDistanceBeforeXfbChangesNothingInTheFrontend) {
const AsyncModeScope async(false);
for (const BCase& bCase : kBCases) {
for (const int clipCount : {1, 4, 8}) {
for (const char* clipVersion : {"#version 400", "#version 150", "#version 130"}) {
const AbResult result = RunAb(bCase, clipCount, clipVersion, true, true);
EXPECT_EQ(result.control, result.poisoned)
<< "async=0 B=" << bCase.label << " clipCount=" << clipCount
<< " clipVersion=" << clipVersion;
}
}
}
}
// -------------------------------------------------------------------------------------
// 2. Repetition: the CTS incidence with async off is ~2.4%, i.e. roughly 1 in 40 runs, so
// a single comparison would miss it. 60 repetitions of the same A->B pair inside one
// process, each with its own fresh context, is the headless equivalent.
// -------------------------------------------------------------------------------------
TEST_F(XfbFrontendOrderInvarianceTest, RepeatedAbPairsAreBitStable) {
for (const Bool async : {true, false}) {
const AsyncModeScope scope(async);
String reference;
for (int repetition = 0; repetition < 60; ++repetition) {
const AbResult result = RunAb(kBCases[0], 1, "#version 400", repetition == 0, true);
if (repetition == 0) {
reference = result.control;
ASSERT_EQ(reference, result.poisoned) << "async=" << async << " first repetition";
}
EXPECT_EQ(reference, result.poisoned) << "async=" << async << " repetition " << repetition;
}
}
}
// -------------------------------------------------------------------------------------
// 3. Same context, no reset between A and B, and B's source made unique so neither the
// P0b preprocess cache nor the stage-6 adoption map can serve it. If the divergence
// survives this, no per-source memo is carrying it.
// -------------------------------------------------------------------------------------
TEST_F(XfbFrontendOrderInvarianceTest, NoMemoHitPossibleForB) {
for (const Bool async : {true, false}) {
const AsyncModeScope scope(async);
FreshContext();
const GLuint controlProgram = LinkSkipComponentsProgram("#version 150", "// salt control\n");
const String control = Render(Capture(controlProgram), false);
DeleteProgram(controlProgram);
FreshContext();
const GLuint clipProgram = LinkClipDistanceProgram(1, "#version 400");
const GLuint poisonedProgram = LinkSkipComponentsProgram("#version 150", "// salt poisoned\n");
const String poisoned = Render(Capture(poisonedProgram), false);
DeleteProgram(poisonedProgram);
DeleteProgram(clipProgram);
EXPECT_EQ(control, poisoned) << "async=" << async << " (SPIR-V hash excluded: the salt comment "
"is stripped by the preprocessor but ids can still renumber)";
}
}
// -------------------------------------------------------------------------------------
// 4. Bisection: A and B in one context WITHOUT the reset in between, so ProgramState, the
// P0b cache and the adoption map all carry over exactly as they do in glcts, compared
// against A and B separated by a fresh context. A difference here but not in case 1
// would put the poison in per-context state; no difference in either puts it outside
// the frontend entirely.
// -------------------------------------------------------------------------------------
TEST_F(XfbFrontendOrderInvarianceTest, PerContextStateBisection) {
for (const Bool async : {true, false}) {
const AsyncModeScope scope(async);
// (a) A, fresh context, then B: per-context state cleared, process globals kept.
FreshContext();
const GLuint clipA = LinkClipDistanceProgram(1, "#version 400");
DeleteProgram(clipA);
FreshContext();
const GLuint separated = LinkSkipComponentsProgram("#version 150");
const String separatedText = Render(Capture(separated), true);
DeleteProgram(separated);
// (b) A then B, same context, A kept alive.
FreshContext();
const GLuint clipB = LinkClipDistanceProgram(1, "#version 400");
const GLuint together = LinkSkipComponentsProgram("#version 150");
const String togetherText = Render(Capture(together), true);
DeleteProgram(together);
DeleteProgram(clipB);
EXPECT_EQ(separatedText, togetherText) << "async=" << async;
}
}
// -------------------------------------------------------------------------------------
// 5. The interleaving glcts actually produces: many cases in a row, A somewhere in the
// middle, every B compared against the very first B. This is the one that catches a
// poison that needs more than one link to develop.
// -------------------------------------------------------------------------------------
TEST_F(XfbFrontendOrderInvarianceTest, LongCaseSequenceLikeGlcts) {
for (const Bool async : {true, false}) {
const AsyncModeScope scope(async);
FreshContext();
String reference;
Vector<GLuint> keepAlive;
for (int step = 0; step < 12; ++step) {
if (step == 4) {
// The clip_distance case: every clip count, both setters' shapes.
for (const int clipCount : {1, 2, 4, 8}) {
keepAlive.push_back(LinkClipDistanceProgram(clipCount, "#version 400"));
}
}
const GLuint program = LinkSkipComponentsProgram("#version 150");
const String text = Render(Capture(program), true);
if (step == 0) {
reference = text;
} else {
EXPECT_EQ(reference, text) << "async=" << async << " step " << step;
}
keepAlive.push_back(program);
}
for (const GLuint program : keepAlive) DeleteProgram(program);
}
}
// -------------------------------------------------------------------------------------
// 6. Fresh-process controls. Run exactly one of these with --gtest_filter to get a
// process that has linked nothing else, then diff the two printed blobs by hand:
// ./XfbFrontendOrderInvarianceTest --gtest_filter='*FreshProcessControlB*'
// ./XfbFrontendOrderInvarianceTest --gtest_filter='*FreshProcessAThenB*'
// Both print their snapshot to stdout; they never fail on their own.
// -------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------
// 7. The one shape only async can produce: A's link is still IN FLIGHT when B's shaders
// are compiled and B is linked. Nothing joins A until after B has published. If the
// poison rode a worker thread_local (glslang's pool allocator, its TLS parse context)
// rather than any per-context container, this is where it would show.
// N copies of A are enqueued first so the pool really has a backlog.
// -------------------------------------------------------------------------------------
TEST_F(XfbFrontendOrderInvarianceTest, BLinksWhileAIsStillInFlight) {
const AsyncModeScope async(true);
ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled());
FreshContext();
const GLuint controlProgram = LinkSkipComponentsProgram("#version 150");
const String control = Render(Capture(controlProgram), true);
DeleteProgram(controlProgram);
for (int repetition = 0; repetition < 20; ++repetition) {
FreshContext();
Vector<GLuint> clipPrograms;
// Enqueued, never read: every one of these links is outstanding while B goes
// through compile + link on the same pool.
for (const int clipCount : {1, 2, 3, 4, 5, 6, 7, 8}) {
clipPrograms.push_back(LinkClipDistanceProgram(clipCount, "#version 400"));
}
const GLuint program = LinkSkipComponentsProgram("#version 150");
const String poisoned = Render(Capture(program), true);
EXPECT_EQ(control, poisoned) << "repetition " << repetition;
DeleteProgram(program);
for (const GLuint clipProgram : clipPrograms) DeleteProgram(clipProgram);
}
}
// Sanity: every shape this binary compares must actually LINK, otherwise "control ==
// poisoned" is the trivially true statement that two failures look alike.
TEST_F(XfbFrontendOrderInvarianceTest, EveryShapeActuallyLinks) {
const AsyncModeScope async(true);
for (const int clipCount : {1, 2, 4, 8}) {
for (const char* version : {"#version 400", "#version 150", "#version 130"}) {
FreshContext();
const GLuint program = LinkClipDistanceProgram(clipCount, version);
GLint linked = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linked);
EXPECT_EQ(linked, GL_TRUE) << "A clipCount=" << clipCount << " " << version << ": "
<< QueryProgramInfoLog(program);
DeleteProgram(program);
}
}
for (const BCase& bCase : kBCases) {
FreshContext();
const GLuint program = bCase.link();
GLint linked = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linked);
EXPECT_EQ(linked, GL_TRUE) << "B " << bCase.label << ": " << QueryProgramInfoLog(program);
std::printf("=== B shape %s ===\n%s\n", bCase.label, Render(Capture(program), true).c_str());
DeleteProgram(program);
}
}
// Writes B's raw SPIR-V modules next to the binary so they can be run through spirv-dis
// by hand. MOBILEGL_XFB_INVARIANCE_DUMP_DIR selects the directory; unset means no dump.
TEST_F(XfbFrontendOrderInvarianceTest, DumpBSpirvForDisassembly) {
const char* directory = std::getenv("MOBILEGL_XFB_INVARIANCE_DUMP_DIR");
if (directory == nullptr) {
GTEST_SKIP() << "set MOBILEGL_XFB_INVARIANCE_DUMP_DIR to dump";
}
const AsyncModeScope async(true);
struct Dump {
const char* tag;
Bool withClipDistanceFirst;
};
for (const Dump& dump : {Dump{"control", false}, Dump{"poisoned", true}}) {
for (const BCase& bCase : kBCases) {
FreshContext();
GLuint clipProgram = 0;
if (dump.withClipDistanceFirst) clipProgram = LinkClipDistanceProgram(1, "#version 400");
const GLuint program = bCase.link();
const auto& object = MG_State::pGLContext->GetProgramObject(program);
const auto& modules = object->GetGeneratedSpirv();
for (SizeT i = 0; i < modules.size(); ++i) {
String path = String(directory) + "/" + dump.tag + "-" + bCase.label + "-" +
std::to_string(i) + ".spv";
std::replace(path.begin() + std::strlen(directory) + 1, path.end(), '@', '_');
std::FILE* file = std::fopen(path.c_str(), "wb");
ASSERT_NE(file, nullptr) << path;
std::fwrite(modules[i].data(), sizeof(unsigned), modules[i].size(), file);
std::fclose(file);
}
DeleteProgram(program);
if (clipProgram != 0) DeleteProgram(clipProgram);
}
}
}
TEST_F(XfbFrontendOrderInvarianceTest, FreshProcessControlB) {
const AsyncModeScope async(true);
const GLuint program = LinkSkipComponentsProgram("#version 150");
std::printf("=== FreshProcessControlB ===\n%s\n", Render(Capture(program), true).c_str());
DeleteProgram(program);
}
TEST_F(XfbFrontendOrderInvarianceTest, FreshProcessAThenB) {
const AsyncModeScope async(true);
const GLuint clipProgram = LinkClipDistanceProgram(1, "#version 400");
const GLuint program = LinkSkipComponentsProgram("#version 150");
std::printf("=== FreshProcessAThenB ===\n%s\n", Render(Capture(program), true).c_str());
DeleteProgram(program);
DeleteProgram(clipProgram);
}
+129
View File
@@ -1898,3 +1898,132 @@ TEST(FastSTLSanity, ErasingTheOnlyElementReturnsEnd) {
EXPECT_EQ(next, map.end());
EXPECT_TRUE(map.empty());
}
namespace {
// Records what the per-unit texture sync actually pushed at the driver: which backend
// texture id was current when each glTexImage2D landed, and the shape it was given.
struct TexSpecCall {
GLuint texture;
GLsizei width;
GLsizei height;
};
MobileGL::Vector<TexSpecCall>* g_texSpecCalls = nullptr;
GLuint g_texSpecBoundTexture = 0;
void TS_BindTexture(GLenum, GLuint texture) { g_texSpecBoundTexture = texture; }
void TS_ActiveTexture(GLenum) {}
void TS_TexParameteri(GLenum, GLenum, GLint) {}
void TS_TexParameterf(GLenum, GLenum, GLfloat) {}
void TS_TexParameterfv(GLenum, GLenum, const GLfloat*) {}
void TS_PixelStorei(GLenum, GLint) {}
void TS_BindBuffer(GLenum, GLuint) {}
void TS_TexImage2D(GLenum, GLint level, GLint, GLsizei width, GLsizei height, GLint, GLenum, GLenum,
const void*) {
if (g_texSpecCalls && level == 0) {
g_texSpecCalls->push_back({g_texSpecBoundTexture, width, height});
}
}
// Clears the recording hook even when a gtest assertion unwinds the test body.
struct ScopedTexSpecRecording {
explicit ScopedTexSpecRecording(MobileGL::Vector<TexSpecCall>& sink) {
g_texSpecCalls = &sink;
g_texSpecBoundTexture = 0;
}
~ScopedTexSpecRecording() { g_texSpecCalls = nullptr; }
ScopedTexSpecRecording(const ScopedTexSpecRecording&) = delete;
ScopedTexSpecRecording& operator=(const ScopedTexSpecRecording&) = delete;
};
// Gives `name` a complete single-level 2D image of the requested size without going through
// the frontend upload path (the mock table below wires only the state-pushing entry points).
MobileGL::SharedPtr<MobileGL::MG_State::GLState::ITextureObject> MakeComplete2DTexture(GLuint name,
MobileGL::Int size) {
using namespace MobileGL;
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, name);
auto object = MG_State::pGLContext->GetTextureUnitObject(0)
.GetBindingSlot(TextureTarget::Texture2D)
.GetBoundObject();
object->SetInternalFormat(TextureInternalFormat::RGBA8);
MG_State::GLState::AsMipmapTexture(object.get())
->AllocateStorage(TextureUploadTarget::Texture2D, 0, {{size, size, 1}, 4});
return object;
}
} // namespace
// The per-unit texture sync memo BORROWS the binding slot: an entry holds a pointer to the
// slot's shared_ptr plus the backend twin of whatever was in it when the entry was built. Its
// keys (context id, bind-generation epoch, high-water mark, sampling generation) are the primary
// guard, but they are all derived state - so the memo also has to survive a slot swap that never
// reached them.
//
// It did not. The DSA by-name emulation swapped a slot silently, every key still matched, and
// the replay drove texture A's backend twin from texture B's frontend object: A's backend
// storage was re-specified with B's shape, destroying anything A only ever had on the GPU. On
// Espryt + Iris/BSL that blanked Minecraft's 16x16 lightmap the moment a 2048x2048 shadow map
// was uploaded through a by-name call, and since the text shader multiplies by the lightmap,
// `if (color.a < 0.1) discard` then threw away every glyph in the process - HUD, menus and the
// vanilla title screen alike.
TEST(DirectGLESTextureSync, UnitMemoRefusesToDriveATwinFromAnotherTexture) {
using namespace MobileGL;
ScopedDirectGLESTextureBindings scoped; // fresh GLContext + registry + binding caches
Vector<TexSpecCall> specs;
ScopedTexSpecRecording recording(specs);
auto functions = MG_Backend::DirectGLES::g_GLESFuncs;
functions.glBindTexture = TS_BindTexture;
functions.glActiveTexture = TS_ActiveTexture;
functions.glTexImage2D = TS_TexImage2D;
functions.glTexParameteri = TS_TexParameteri;
functions.glTexParameterf = TS_TexParameterf;
functions.glTexParameterfv = TS_TexParameterfv;
functions.glPixelStorei = TS_PixelStorei;
functions.glBindBuffer = TS_BindBuffer;
MG_Backend::DirectGLES::SetGLESFuncsTable(functions);
GLuint names[2] = {};
MG_Impl::GLImpl::GenTextures(2, names);
// `foreign` stands in for the shadow map, `resident` for the lightmap. Both are fully
// specified BEFORE the first sync so that nothing between the two syncs can move the
// sampling-resolution generation and invalidate the memo for an unrelated reason.
const auto foreign = MakeComplete2DTexture(names[1], 32);
const auto resident = MakeComplete2DTexture(names[0], 16);
ASSERT_NE(foreign, nullptr);
ASSERT_NE(resident, nullptr);
// First sync: builds the memo with unit 0 -> `resident`, and gives `resident`'s twin its
// 16x16 backend storage.
MG_Backend::DirectGLES::TextureImpl::SyncNeccessaryTextures();
auto* residentSlot = MG_Backend::DirectGLES::TextureImpl::g_backendTextureObjects.Find(resident.get());
ASSERT_NE(residentSlot, nullptr);
ASSERT_NE(*residentSlot, nullptr);
const GLuint residentBackendId = (*residentSlot)->GetBackendTextureId();
ASSERT_NE(residentBackendId, 0u);
ASSERT_FALSE(specs.empty());
EXPECT_EQ(specs.back().texture, residentBackendId);
EXPECT_EQ(specs.back().width, 16);
// The hazard, reproduced at the state level: put `foreign` on the slot the memo borrows
// WITHOUT telling the binding accounting, exactly as the by-name emulation used to.
MG_State::pGLContext->GetTextureUnitObject(0).GetBindingSlot(TextureTarget::Texture2D).Bind(foreign);
const SizeT specsBeforeReplay = specs.size();
MG_Backend::DirectGLES::TextureImpl::SyncNeccessaryTextures();
// `foreign` must have been synced through its OWN twin...
auto* foreignSlot = MG_Backend::DirectGLES::TextureImpl::g_backendTextureObjects.Find(foreign.get());
ASSERT_NE(foreignSlot, nullptr);
ASSERT_NE(*foreignSlot, nullptr);
const GLuint foreignBackendId = (*foreignSlot)->GetBackendTextureId();
EXPECT_NE(foreignBackendId, residentBackendId);
// ...and above all, nothing may have re-specified the RESIDENT texture's backend storage.
// That single call is what destroyed the lightmap.
for (SizeT i = specsBeforeReplay; i < specs.size(); ++i) {
EXPECT_NE(specs[i].texture, residentBackendId)
<< "the stale memo entry re-specified the resident texture's backend storage with "
<< specs[i].width << "x" << specs[i].height;
}
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, 0);
}
+27
View File
@@ -0,0 +1,27 @@
cmake_minimum_required(VERSION 3.14)
add_executable(
ObjectLifetimeIdTest
ObjectLifetimeIdTest.cpp
)
target_include_directories(ObjectLifetimeIdTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/3rdparty/xxHash
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
${MGL_ROOT}/3rdparty/SPIRV-Reflect
)
target_link_libraries(
ObjectLifetimeIdTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(ObjectLifetimeIdTest PRIVATE /Zc:preprocessor)
endif()
include(GoogleTest)
gtest_discover_tests(ObjectLifetimeIdTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -0,0 +1,140 @@
// MobileGL - MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// The invariant every backend memo keyed on a state object now rests on: a heap
// ADDRESS is not an identity, a lifetime id is.
//
// DirectVulkan memoises resolved vertex bindings per VertexArrayObject and folds
// the bound BufferObject's identity into the content hash that validates them.
// Both used to be heap addresses, and the allocator hands a freed address
// straight back: a VAO and a vertex buffer destroyed and immediately recreated
// under a byte-identical attribute layout reproduced BOTH the memo key and the
// validating hash, so the new draw fetched the destroyed buffer's GPU slice.
// GetLifetimeId() is what makes that impossible, so it is worth a test that
// needs no GPU, no context and no driver - only the allocator.
//
// The test does not simulate reuse; it waits for the real allocator to do it
// (which a LIFO free-list does on the very next allocation) and then asserts the
// id differs. If the allocator never repeats an address the run proves nothing,
// and the case says so with a skip rather than passing quietly.
#include <gtest/gtest.h>
#include <cstdint>
#include <memory>
#include <unordered_map>
#include "Includes.h"
#include <MG_State/GLState/BufferState/BufferObject.h>
#include <MG_State/GLState/VertexArrayState/VertexArrayObject.h>
using namespace MobileGL;
namespace {
// The allocation must actually happen: C++ permits eliding a new/delete pair,
// and an elided one would let two objects share an address for reasons that
// have nothing to do with the allocator - which is the only thing under test
// here. Publishing every pointer through a volatile sink keeps the pairs.
void* volatile g_addressSink = nullptr;
// Constructs and destroys `ObjectT` on the heap kAttempts times, watching for
// the allocator to hand back an address it already used. Every repeat must
// carry a lifetime id the dead occupant did not have. Returns how many repeats
// were seen, so the caller can tell "proven" from "never got the chance".
//
// Each object type has its own id counter, so a VertexArrayObject and a
// BufferObject may well both be id 1; ids are only ever compared within a
// type, which is exactly how the memos use them.
template <typename ObjectT>
int ProbeLifetimeIdAcrossAddressReuse(const char* typeName) {
constexpr int kAttempts = 64;
std::unordered_map<std::uintptr_t, Uint64> idAtAddress;
int reuseCount = 0;
Uint64 previousId = 0;
for (int attempt = 0; attempt < kAttempts; ++attempt) {
auto object = std::make_unique<ObjectT>(0u);
g_addressSink = object.get();
const auto address = reinterpret_cast<std::uintptr_t>(object.get());
const Uint64 lifetimeId = object->GetLifetimeId();
// 0 is the "this slot holds nothing" value in every memo that stores an
// id, so a live object must never be able to answer to a zeroed slot.
EXPECT_NE(lifetimeId, 0u) << typeName << " handed out lifetime id 0 (attempt " << attempt
<< "), which is the value a zero-initialised memo slot already carries";
EXPECT_GT(lifetimeId, previousId)
<< typeName << " lifetime ids must be strictly increasing, so an id is never handed out twice "
<< "(attempt " << attempt << ")";
previousId = lifetimeId;
const auto inserted = idAtAddress.emplace(address, lifetimeId);
if (!inserted.second) {
// The allocator reproduced an address: this is precisely the state in
// which a memo keyed on the address alone would hit a dead object's
// entry. The id is the thing that has to say no.
++reuseCount;
EXPECT_NE(lifetimeId, inserted.first->second)
<< typeName << " reconstructed at the address of a destroyed one reports the DEAD object's "
<< "lifetime id - a backend memo keyed on it would serve the dead object's resolved state "
<< "to this object's draws (attempt " << attempt << ")";
inserted.first->second = lifetimeId;
}
// Freed before the next construction on purpose: that ordering is what
// makes the allocator reuse the block, and it is the ordering the GL
// workload has (glDeleteVertexArrays, then the next glGenVertexArrays).
object.reset();
}
return reuseCount;
}
// Guards against a degenerate "id" that is really just the address in disguise:
// objects alive at the same time must differ too.
template <typename ObjectT>
void ExpectDistinctIdsWhileBothAlive(const char* typeName) {
auto first = std::make_unique<ObjectT>(0u);
auto second = std::make_unique<ObjectT>(0u);
g_addressSink = first.get();
g_addressSink = second.get();
EXPECT_NE(first->GetLifetimeId(), second->GetLifetimeId())
<< "two live " << typeName << "s share a lifetime id";
}
} // namespace
TEST(ObjectLifetimeIdTest, VertexArrayObjectAtARecycledAddressCarriesAFreshLifetimeId) {
using MG_State::GLState::VertexArrayObject;
const int reuseCount = ProbeLifetimeIdAcrossAddressReuse<VertexArrayObject>("VertexArrayObject");
if (reuseCount == 0) {
GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 "
"construct/destroy rounds, so the recycled-address case was never exercised";
}
RecordProperty("address_reuses_observed", reuseCount);
}
TEST(ObjectLifetimeIdTest, BufferObjectAtARecycledAddressCarriesAFreshLifetimeId) {
using MG_State::GLState::BufferObject;
const int reuseCount = ProbeLifetimeIdAcrossAddressReuse<BufferObject>("BufferObject");
if (reuseCount == 0) {
GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 "
"construct/destroy rounds, so the recycled-address case was never exercised";
}
RecordProperty("address_reuses_observed", reuseCount);
}
TEST(ObjectLifetimeIdTest, LiveVertexArrayObjectsHaveDistinctLifetimeIds) {
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::VertexArrayObject>("VertexArrayObject");
}
TEST(ObjectLifetimeIdTest, LiveBufferObjectsHaveDistinctLifetimeIds) {
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::BufferObject>("BufferObject");
}
+504
View File
@@ -15,6 +15,7 @@
#include <Config.h>
#include <MG_Backend/BackendObjects.h>
#include <MG_Backend/DirectGLES/Managers.h>
#include <MG_Backend/DirectGLES/Utils.h>
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <MG_Impl/GLImpl/RenderState/GL_RenderState.h>
@@ -27,6 +28,7 @@
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToMG/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToStr/TextureEnumConverter.h>
#include <MG_Util/Math/SmallFloat.h>
#include <MG_Util/Texture/PixelStoreProcessor.h>
#include <MG_Util/Texture/TextureFormatProcessor.h>
@@ -2672,3 +2674,505 @@ TEST_F(TextureTest, DecodeShadowDataToWideRGBACoversComponentAndPackedLayouts) {
EXPECT_EQ(rgba[3], 2u);
}
}
// GL 4.6 core table 23.18: GL_TEXTURE_COMPARE_FUNC takes the whole eight-function depth-compare
// range. The validator used to start it at GL_LEQUAL, which sits in the middle of the contiguous
// GL_NEVER..GL_ALWAYS block, so NEVER/LESS/EQUAL were rejected while GREATER/NOTEQUAL/GEQUAL only
// got through because they happen to be numerically above LEQUAL.
TEST_F(TextureTest, SamplerCompareFuncAcceptsTheWholeNeverToAlwaysRange) {
GLuint sampler = 0;
MG_Impl::GLImpl::GenSamplers(1, &sampler);
ASSERT_NE(sampler, 0u);
const GLenum compareFuncs[] = {GL_NEVER, GL_LESS, GL_EQUAL, GL_LEQUAL,
GL_GREATER, GL_NOTEQUAL, GL_GEQUAL, GL_ALWAYS};
for (GLenum func : compareFuncs) {
MG_Impl::GLImpl::SamplerParameteri(sampler, GL_TEXTURE_COMPARE_FUNC, static_cast<GLint>(func));
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "compare func " << func << " was rejected";
GLint readBack = 0;
MG_Impl::GLImpl::GetSamplerParameteriv(sampler, GL_TEXTURE_COMPARE_FUNC, &readBack);
EXPECT_EQ(static_cast<GLenum>(readBack), func);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
// Just outside the block on both sides is still INVALID_ENUM.
MG_Impl::GLImpl::SamplerParameteri(sampler, GL_TEXTURE_COMPARE_FUNC, GL_NEVER - 1);
ExpectSingleGlError(GL_INVALID_ENUM);
MG_Impl::GLImpl::SamplerParameteri(sampler, GL_TEXTURE_COMPARE_FUNC, GL_ALWAYS + 1);
ExpectSingleGlError(GL_INVALID_ENUM);
MG_Impl::GLImpl::DeleteSamplers(1, &sampler);
}
// GL 4.6 core table 23.19: GL_TEXTURE_BINDING_* and GL_SAMPLER_BINDING are per-texture-unit, so
// glGetIntegeri_v must answer for unit `index` - not fall through to the backend, which knows
// nothing about the frontend's binding state.
TEST_F(TextureTest, GetIntegeriVReportsPerUnitTextureAndSamplerBindings) {
GLuint textures[2] = {0, 0};
MG_Impl::GLImpl::GenTextures(2, textures);
ASSERT_NE(textures[0], 0u);
ASSERT_NE(textures[1], 0u);
MG_Impl::GLImpl::ActiveTexture(GL_TEXTURE0);
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, textures[0]);
MG_Impl::GLImpl::ActiveTexture(GL_TEXTURE3);
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, textures[1]);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
GLint binding = -1;
MG_Impl::GLImpl::GetIntegeri_v(GL_TEXTURE_BINDING_2D, 0, &binding);
EXPECT_EQ(static_cast<GLuint>(binding), textures[0]);
MG_Impl::GLImpl::GetIntegeri_v(GL_TEXTURE_BINDING_2D, 3, &binding);
EXPECT_EQ(static_cast<GLuint>(binding), textures[1]);
// An unbound unit reports 0, and a target nothing was bound to reports 0 as well.
MG_Impl::GLImpl::GetIntegeri_v(GL_TEXTURE_BINDING_2D, 2, &binding);
EXPECT_EQ(binding, 0);
MG_Impl::GLImpl::GetIntegeri_v(GL_TEXTURE_BINDING_3D, 0, &binding);
EXPECT_EQ(binding, 0);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// The non-indexed query keeps reporting the ACTIVE unit, which is still unit 3.
GLint activeUnitBinding = -1;
MG_Impl::GLImpl::GetIntegerv(GL_TEXTURE_BINDING_2D, &activeUnitBinding);
EXPECT_EQ(static_cast<GLuint>(activeUnitBinding), textures[1]);
GLuint sampler = 0;
MG_Impl::GLImpl::GenSamplers(1, &sampler);
ASSERT_NE(sampler, 0u);
MG_Impl::GLImpl::BindSampler(2, sampler);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
MG_Impl::GLImpl::GetIntegeri_v(GL_SAMPLER_BINDING, 2, &binding);
EXPECT_EQ(static_cast<GLuint>(binding), sampler);
MG_Impl::GLImpl::GetIntegeri_v(GL_SAMPLER_BINDING, 1, &binding);
EXPECT_EQ(binding, 0);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// Out of range is INVALID_VALUE, not a backend passthrough.
GLint maxUnits = 0;
MG_Impl::GLImpl::GetIntegerv(GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, &maxUnits);
ASSERT_GT(maxUnits, 0);
MG_Impl::GLImpl::GetIntegeri_v(GL_TEXTURE_BINDING_2D, static_cast<GLuint>(maxUnits) + 1024u, &binding);
ExpectSingleGlError(GL_INVALID_VALUE);
MG_Impl::GLImpl::BindSampler(2, 0);
MG_Impl::GLImpl::DeleteSamplers(1, &sampler);
MG_Impl::GLImpl::ActiveTexture(GL_TEXTURE0);
MG_Impl::GLImpl::DeleteTextures(2, textures);
DrainPendingGlErrors();
}
// glGetFloati_v / glGetDoublei_v were no-op stubs: they left the caller's buffer holding whatever
// was on the stack. They are converters over the integer indexed query.
TEST_F(TextureTest, GetFloatiVAndGetDoubleiVConvertTheIndexedIntegerQuery) {
GLuint texture = 0;
MG_Impl::GLImpl::GenTextures(1, &texture);
ASSERT_NE(texture, 0u);
MG_Impl::GLImpl::ActiveTexture(GL_TEXTURE1);
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, texture);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
GLfloat asFloat = -1.0f;
MG_Impl::GLImpl::GetFloati_v(GL_TEXTURE_BINDING_2D, 1, &asFloat);
EXPECT_FLOAT_EQ(asFloat, static_cast<GLfloat>(texture));
GLdouble asDouble = -1.0;
MG_Impl::GLImpl::GetDoublei_v(GL_TEXTURE_BINDING_2D, 1, &asDouble);
EXPECT_DOUBLE_EQ(asDouble, static_cast<GLdouble>(texture));
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
MG_Impl::GLImpl::ActiveTexture(GL_TEXTURE0);
MG_Impl::GLImpl::DeleteTextures(1, &texture);
DrainPendingGlErrors();
}
// GL 3.3 core 3.8.2: the unit glBindSampler accepts is bounded by
// GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS. The gate read the frontend's MAX_TEXTURE_IMAGE_UNITS
// instead - the capacity of the unit array, 192 - so every unit the backend does not have was
// accepted, and the single-bind path disagreed with the multi-bind twin about where the units end.
// The backend is stood in so the two limits are distinguishable no matter what the real one
// advertises.
TEST_F(TextureTest, BindSamplerRejectsUnitsBeyondMaxCombinedTextureImageUnits) {
GLuint sampler = 0;
MG_Impl::GLImpl::GenSamplers(1, &sampler);
ASSERT_NE(sampler, 0u);
constexpr GLint kCombinedUnits = 24;
static_assert(kCombinedUnits < MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS,
"the stand-in limit has to be below the unit array capacity to tell the two apart");
auto backend = MakeUnique<FormatCapabilityBackend>();
FormatCapabilityBackend::MutableDynamicParameters().MaxCombinedTextureImageUnits = kCombinedUnits;
ScopedBackendOverride backendOverride(Move(backend));
GLint reportedUnits = 0;
MG_Impl::GLImpl::GetIntegerv(GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, &reportedUnits);
ASSERT_EQ(reportedUnits, kCombinedUnits);
// The last unit that exists still binds.
const GLuint lastUnit = static_cast<GLuint>(kCombinedUnits - 1);
MG_Impl::GLImpl::BindSampler(lastUnit, sampler);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_NE(MG_State::pGLContext->GetTextureUnitObject(static_cast<Int>(lastUnit)).GetSamplerObject(), nullptr);
// One past it does not - this is the unit the old gate accepted.
MG_Impl::GLImpl::BindSampler(static_cast<GLuint>(kCombinedUnits), sampler);
ExpectSingleGlError(GL_INVALID_VALUE);
EXPECT_EQ(MG_State::pGLContext->GetTextureUnitObject(kCombinedUnits).GetSamplerObject(), nullptr);
// Past the unit array as well is the same error, not an out-of-bounds index.
MG_Impl::GLImpl::BindSampler(
static_cast<GLuint>(MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) + 4u, sampler);
ExpectSingleGlError(GL_INVALID_VALUE);
// Both gates now read the same limit: a multi-bind that ends exactly at it binds, and one that
// runs a single unit past it is the multi-bind's INVALID_OPERATION, reported up front - not the
// single-bind INVALID_VALUE from somewhere inside the loop.
const GLuint samplers[2] = {sampler, sampler};
MG_Impl::GLImpl::BindSamplers(static_cast<GLuint>(kCombinedUnits - 2), 2, samplers);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
MG_Impl::GLImpl::BindSamplers(lastUnit, 2, samplers);
ExpectSingleGlError(GL_INVALID_OPERATION);
MG_Impl::GLImpl::BindSampler(lastUnit, 0);
MG_Impl::GLImpl::BindSampler(static_cast<GLuint>(kCombinedUnits - 2), 0);
MG_Impl::GLImpl::DeleteSamplers(1, &sampler);
DrainPendingGlErrors();
}
// The DSA by-name entry points are emulated by temporarily binding the named texture onto the
// active unit's slot for its target, running the classic bound-texture code, then putting the
// previous binding back. For as long as the emulated call runs, that swap is a REAL change to
// which texture is bound at that unit, so both transitions have to move the texture bind
// generation.
//
// They used to move nothing. Backends memoise per-unit work keyed on the bind generation and
// BORROW the binding slot (they hold a pointer to the slot's shared_ptr, not a copy), so a memo
// built while texture A sat in the slot stayed "valid" while B was temporarily in it - and the
// backend then drove A's backend twin from B's frontend state, re-specifying A's backend storage
// with B's shape. Any content A only ever had on the GPU was gone. That is what blanked
// Minecraft's lightmap when Iris uploaded to a BSL shadow map: the text shader multiplies by the
// lightmap, so `if (color.a < 0.1) discard` then threw away every glyph in the process.
TEST_F(TextureTest, NamedTextureCallKeepsUnitBindingAccountingCoherent) {
GLuint names[2] = {};
MG_Impl::GLImpl::GenTextures(2, names);
const GLuint boundName = names[0];
const GLuint namedName = names[1];
MG_Impl::GLImpl::ActiveTexture(GL_TEXTURE0);
// Instantiate both as 2D objects, then leave `boundName` on the unit.
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, namedName);
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, boundName);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
auto& slot = MG_State::pGLContext->GetTextureUnitObject(0).GetBindingSlot(TextureTarget::Texture2D);
const auto boundObject = slot.GetBoundObject();
ASSERT_NE(boundObject, nullptr);
ASSERT_EQ(boundObject->GetExternalIndex(), boundName);
// TextureParameteriv is one of the by-name calls that is emulated by binding: it reaches
// WithTemporarilyBoundNamedTexture, unlike the scalar TextureParameteri, which edits the
// object directly and never touches a unit.
const Uint64 base = MG_State::pGLContext->GetTextureBindGeneration();
const GLint maxLevel = 0;
MG_Impl::GLImpl::TextureParameteriv(namedName, GL_TEXTURE_MAX_LEVEL, &maxLevel);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
// The emulation put `namedName` on the unit and took it off again. A generation-keyed memo
// must be able to see that the slot it borrows was not stable across the call.
EXPECT_GT(MG_State::pGLContext->GetTextureBindGeneration(), base)
<< "a by-name texture call swapped a live unit binding without moving the bind generation";
// ...and the application-visible binding is exactly what it was before the call.
EXPECT_EQ(slot.GetBoundObject(), boundObject);
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, 0);
MG_Impl::GLImpl::DeleteTextures(2, names);
DrainPendingGlErrors();
}
// ---- Three-channel colour-renderable widening (Complementary Reimagined / Iris) ----------------
//
// No real OpenGL ES driver renders to a three-channel image, so a colour attachment the
// application asked for as GL_RGB8_SNORM or GL_RGB16F has to be stored in the four-channel
// sibling. The bit that says so used to be reachable for multisample storage only, which is why
// an ordinary GL_TEXTURE_2D attachment in one of those formats had no fallback at all and the
// frontend could only answer GL_FRAMEBUFFER_UNSUPPORTED.
TEST_F(TextureTest, ColorAttachableTargetsRequestTheThreeChannelWidening) {
using MobileGL::MG_Backend::DirectGLES::TextureImpl::GetRenderTargetNormalizeOptions;
using MobileGL::MG_Backend::DirectGLES::TextureImpl::TargetRequiresRenderableFormat;
MG_External::GLESCapabilities capabilities{};
capabilities.SupportsRenderSnorm = true;
capabilities.SupportsNorm16Texture = true;
// Every image that can be a colour attachment, not just the multisample pair: an ordinary 2D
// texture is what Iris attaches, and it used to be excluded.
for (const TextureTarget target : {TextureTarget::Texture2D, TextureTarget::Texture3D,
TextureTarget::TextureCubeMap, TextureTarget::Texture2DArray,
TextureTarget::TextureCubeMapArray, TextureTarget::Texture2DMultisample,
TextureTarget::Texture2DMultisampleArray, TextureTarget::Texture1D,
TextureTarget::Texture1DArray, TextureTarget::TextureRectangle}) {
const SizeT targetIndex = MobileGL::MG_Backend::GetFormatCapabilityTargetIndex(target);
EXPECT_TRUE(TargetRequiresRenderableFormat(targetIndex))
<< "target " << MG_Util::ConvertTextureTargetToString(target);
EXPECT_TRUE(GetRenderTargetNormalizeOptions(capabilities, targetIndex) &
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)
<< "target " << MG_Util::ConvertTextureTargetToString(target);
}
// A renderbuffer exists only to be attached.
EXPECT_TRUE(TargetRequiresRenderableFormat(MobileGL::MG_Backend::GetRenderbufferFormatCapabilityTargetIndex()));
// A buffer texture is the one image that can never be an attachment; its storage belongs to
// the buffer object, so widening it would misdescribe the application's data.
const SizeT bufferIndex = MobileGL::MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::TextureBuffer);
EXPECT_FALSE(TargetRequiresRenderableFormat(bufferIndex));
EXPECT_FALSE(GetRenderTargetNormalizeOptions(capabilities, bufferIndex));
// Without EXT_render_snorm a 16-bit SNORM render target cannot keep its encoding either.
MG_External::GLESCapabilities noSnormCapabilities{};
const SizeT texture2DIndex = MobileGL::MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::Texture2D);
EXPECT_TRUE(GetRenderTargetNormalizeOptions(noSnormCapabilities, texture2DIndex) &
PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget);
EXPECT_FALSE(GetRenderTargetNormalizeOptions(capabilities, texture2DIndex) &
PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget);
}
TEST_F(TextureTest, ThreeChannelRenderTargetOptionAppliesToEveryDeniedThreeChannelFormat) {
using MG_Util::TextureFormatProcessor::GetApplicablePixelFormatNormalizeOptions;
const Flags<PixelFormatNormalizeOptionBit> requested =
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
// GL_RGB16F in particular matched no case at all, so no option could ever apply to it and it
// fell through NormalizePixelFormat's default passthrough unchanged.
for (const GLenum internalFormat : {GL_RGB8_SNORM, GL_RGB16_SNORM, GL_RGB16, GL_RGB10, GL_RGB12, GL_RGB16F,
GL_RGB32F, GL_SRGB8, GL_RGB8I, GL_RGB8UI, GL_RGB16I, GL_RGB16UI, GL_RGB32I,
GL_RGB32UI}) {
EXPECT_TRUE(GetApplicablePixelFormatNormalizeOptions(internalFormat, requested) &
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)
<< "internalformat 0x" << std::hex << internalFormat;
}
// Four-channel and shared-exponent formats are not widened: RGBA8_SNORM has its own always-on
// fallback, and GL_RGB9_E5 has no four-channel sibling that would not need the shared exponent
// unpacked on every transfer (nothing renders to it on desktop GL either).
for (const GLenum internalFormat : {GL_RGBA8_SNORM, GL_RGBA16F, GL_RGBA8, GL_RGB8, GL_RGB9_E5}) {
EXPECT_FALSE(GetApplicablePixelFormatNormalizeOptions(internalFormat, requested) &
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)
<< "internalformat 0x" << std::hex << internalFormat;
}
}
TEST_F(TextureTest, ThreeChannelWideningRetargetsInternalFormatAndTransferPairTogether) {
using MG_Util::TextureFormatProcessor::NormalizePixelFormat;
struct Case {
GLenum requested;
Flags<PixelFormatNormalizeOptionBit> options;
GLenum internalFormat;
GLenum format;
GLenum type;
};
const Flags<PixelFormatNormalizeOptionBit> widen = PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
const Flags<PixelFormatNormalizeOptionBit> widenNoSnorm16 =
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget |
PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
const Case cases[] = {
// Complementary's colortex1 and colortex2. The transfer pair used to stay three-channel
// and keep the *source* component type, emitting (GL_RGBA16F, GL_RGB, GL_BYTE) - which ES
// rejects for glTexImage2D outright, and which only went unnoticed because the bit was
// reachable for multisample storage alone (glTexStorage*Multisample takes no pair).
{GL_RGB8_SNORM, widen, GL_RGBA16F, GL_RGBA, GL_FLOAT},
{GL_RGB16F, widen, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT},
{GL_RGB32F, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT},
// 16-bit SNORM keeps its encoding where EXT_render_snorm can render to it; a half float's
// 11-bit mantissa cannot represent a 16-bit SNORM channel exactly.
{GL_RGB16_SNORM, widen, GL_RGBA16_SNORM, GL_RGBA, GL_SHORT},
{GL_RGB16_SNORM, widenNoSnorm16, GL_RGBA16F, GL_RGBA, GL_FLOAT},
// 16-bit UNORM and the legacy 10/12-bit formats stored as RGB16.
{GL_RGB16, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT},
{GL_RGB10, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT},
{GL_RGB12, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT},
// sRGB and the integer formats: the base format has to move to the four-channel one of the
// right class, GL_RGBA_INTEGER included.
{GL_SRGB8, widen, GL_SRGB8_ALPHA8, GL_RGBA, GL_UNSIGNED_BYTE},
{GL_RGB8I, widen, GL_RGBA8I, GL_RGBA_INTEGER, GL_BYTE},
{GL_RGB8UI, widen, GL_RGBA8UI, GL_RGBA_INTEGER, GL_UNSIGNED_BYTE},
{GL_RGB16I, widen, GL_RGBA16I, GL_RGBA_INTEGER, GL_SHORT},
{GL_RGB16UI, widen, GL_RGBA16UI, GL_RGBA_INTEGER, GL_UNSIGNED_SHORT},
{GL_RGB32I, widen, GL_RGBA32I, GL_RGBA_INTEGER, GL_INT},
{GL_RGB32UI, widen, GL_RGBA32UI, GL_RGBA_INTEGER, GL_UNSIGNED_INT},
// The widening outranks the other fallbacks, which all pick a three-channel storage the
// driver still refuses to render to (GL_RGB8_SNORM -> GL_RGB16F, GL_RGB16 -> GL_RGB32F).
{GL_RGB8_SNORM, widen | PixelFormatNormalizeOptionBit::NoSnorm8, GL_RGBA16F, GL_RGBA, GL_FLOAT},
{GL_RGB16, widen | PixelFormatNormalizeOptionBit::NoNorm16, GL_RGBA32F, GL_RGBA, GL_FLOAT},
// Control: without the bit nothing moves. The bit is only ever set for a target whose
// native probe failed, so this is the shape every driver that does render to the
// three-channel form keeps - per format, not per platform (llvmpipe renders to GL_RGB16F
// but not to GL_RGB8_SNORM, GL_SRGB8, GL_RGB32F or the RGB integer formats).
{GL_RGB8_SNORM, PixelFormatNormalizeOptionBit::None, GL_RGB8_SNORM, GL_RGB, GL_BYTE},
{GL_RGB16F, PixelFormatNormalizeOptionBit::None, GL_RGB16F, GL_RGB, GL_HALF_FLOAT},
{GL_RGB32F, PixelFormatNormalizeOptionBit::None, GL_RGB32F, GL_RGB, GL_FLOAT},
{GL_SRGB8, PixelFormatNormalizeOptionBit::None, GL_SRGB8, GL_RGB, GL_UNSIGNED_BYTE},
// Not widened even under the bit: no four-channel shared-exponent sibling exists.
{GL_RGB9_E5, widen, GL_RGB9_E5, GL_RGB, GL_UNSIGNED_INT_5_9_9_9_REV},
// Four-channel formats are unaffected by the bit; RGBA8_SNORM keeps its own fallback.
{GL_RGBA8_SNORM, widen, GL_RGBA8_SNORM, GL_RGBA, GL_BYTE},
{GL_RGBA8_SNORM, widen | PixelFormatNormalizeOptionBit::NoRGBA8Snorm, GL_RGBA16F, GL_RGBA, GL_FLOAT},
};
for (const auto& testCase : cases) {
GLenum internalFormat = 0;
GLenum format = 0;
GLenum type = 0;
NormalizePixelFormat(testCase.requested, testCase.options, &internalFormat, &format, &type);
EXPECT_EQ(internalFormat, testCase.internalFormat) << "requested 0x" << std::hex << testCase.requested;
EXPECT_EQ(format, testCase.format) << "requested 0x" << std::hex << testCase.requested;
EXPECT_EQ(type, testCase.type) << "requested 0x" << std::hex << testCase.requested;
}
}
TEST_F(TextureTest, WidenedRenderTargetUploadExpandsThreeChannelDataWithOpaqueAlpha) {
using MobileGL::MG_Backend::DirectGLES::TextureImpl::GetWidenableClientComponentCount;
using MobileGL::MG_Backend::DirectGLES::TextureImpl::PrepareChannelWidenedUpload;
// Only the three-channel formats that can be widened report a source component count; the
// repack is what keeps the driver from walking three texels' worth of data per four-texel row.
for (const TextureInternalFormat format :
{TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16F, TextureInternalFormat::RGB32F,
TextureInternalFormat::RGB16Snorm, TextureInternalFormat::RGB16, TextureInternalFormat::SRGB8,
TextureInternalFormat::RGB8UI, TextureInternalFormat::RGB32I}) {
EXPECT_EQ(GetWidenableClientComponentCount(format), 3u)
<< MG_Util::ConvertTextureInternalFormatToString(format);
}
EXPECT_EQ(GetWidenableClientComponentCount(TextureInternalFormat::RGBA8), 0u);
EXPECT_EQ(GetWidenableClientComponentCount(TextureInternalFormat::RGBA8Snorm), 0u);
EXPECT_EQ(GetWidenableClientComponentCount(TextureInternalFormat::RGB9E5), 0u);
const IntVec3 texelSize(2, 1, 1);
// GL_RGB8_SNORM -> GL_RGBA16F: PrepareNormFloatFallbackUpload has already turned the Int8
// shadow into floats, so what arrives here is three floats per texel.
{
const Float source[] = {0.25f, -0.5f, 0.75f, -1.0f, 0.0f, 1.0f};
Vector<Uint8> widened;
const auto* result = static_cast<const Float*>(PrepareChannelWidenedUpload(
3, texelSize, source, sizeof(source), GL_FLOAT, widened));
ASSERT_NE(result, static_cast<const void*>(source));
ASSERT_EQ(widened.size(), 8 * sizeof(Float));
const Float expected[] = {0.25f, -0.5f, 0.75f, 1.0f, -1.0f, 0.0f, 1.0f, 1.0f};
for (SizeT i = 0; i < 8; ++i) {
EXPECT_FLOAT_EQ(result[i], expected[i]) << "component " << i;
}
}
// GL_RGB16F -> GL_RGBA16F uploads halves untouched, so the synthetic alpha is the half
// encoding of 1.0 rather than a saturated field.
{
const Uint16 source[] = {0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006};
Vector<Uint8> widened;
const auto* result = static_cast<const Uint16*>(PrepareChannelWidenedUpload(
3, texelSize, source, sizeof(source), GL_HALF_FLOAT, widened));
ASSERT_NE(result, static_cast<const void*>(source));
const Uint16 expected[] = {0x0001, 0x0002, 0x0003, 0x3C00, 0x0004, 0x0005, 0x0006, 0x3C00};
for (SizeT i = 0; i < 8; ++i) {
EXPECT_EQ(result[i], expected[i]) << "component " << i;
}
}
// GL_SRGB8 -> GL_SRGB8_ALPHA8: fixed-point one is the saturated field.
{
const Uint8 source[] = {1, 2, 3, 4, 5, 6};
Vector<Uint8> widened;
const auto* result = static_cast<const Uint8*>(PrepareChannelWidenedUpload(
3, texelSize, source, sizeof(source), GL_UNSIGNED_BYTE, widened));
const Uint8 expected[] = {1, 2, 3, 0xFF, 4, 5, 6, 0xFF};
ASSERT_NE(result, static_cast<const void*>(source));
EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0);
}
// GL_RGB16_SNORM -> GL_RGBA16_SNORM keeps GL_SHORT, whose 1.0 is the positive maximum.
{
const Int16 source[] = {-1, 2, -3, 4, -5, 6};
Vector<Uint8> widened;
const auto* result = static_cast<const Int16*>(PrepareChannelWidenedUpload(
3, texelSize, source, sizeof(source), GL_SHORT, widened));
const Int16 expected[] = {-1, 2, -3, 0x7FFF, 4, -5, 6, 0x7FFF};
ASSERT_NE(result, static_cast<const void*>(source));
EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0);
}
// An integer format's added channel carries the integer one, not a saturated field.
{
const Uint32 source[] = {10, 20, 30, 40, 50, 60};
Vector<Uint8> widened;
const auto* result = static_cast<const Uint32*>(PrepareChannelWidenedUpload(
3, texelSize, source, sizeof(source), GL_UNSIGNED_INT, widened, /*integerData=*/true));
const Uint32 expected[] = {10, 20, 30, 1, 40, 50, 60, 1};
ASSERT_NE(result, static_cast<const void*>(source));
EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0);
}
// GL_RGB8I -> GL_RGBA8I uploads as GL_BYTE, the very type GL_RGB8_SNORM uses, so the type
// alone cannot decide the added channel's value: the integer format's one is 1, the
// signed-normalized format's is 0x7F. Getting this wrong is invisible through sampling and
// glGetTexImage (both answer the alpha with the format's implied one) but escapes through a
// blit or glCopyTexSubImage out of the widened attachment.
{
const Int8 source[] = {-1, 2, -3, 4, -5, 6};
Vector<Uint8> widened;
const auto* asInteger = static_cast<const Int8*>(PrepareChannelWidenedUpload(
3, texelSize, source, sizeof(source), GL_BYTE, widened, /*integerData=*/true));
const Int8 expectedInteger[] = {-1, 2, -3, 1, 4, -5, 6, 1};
ASSERT_NE(asInteger, static_cast<const void*>(source));
EXPECT_EQ(std::memcmp(asInteger, expectedInteger, sizeof(expectedInteger)), 0);
Vector<Uint8> widenedNorm;
const auto* asNormalized = static_cast<const Int8*>(PrepareChannelWidenedUpload(
3, texelSize, source, sizeof(source), GL_BYTE, widenedNorm, /*integerData=*/false));
const Int8 expectedNormalized[] = {-1, 2, -3, 0x7F, 4, -5, 6, 0x7F};
EXPECT_EQ(std::memcmp(asNormalized, expectedNormalized, sizeof(expectedNormalized)), 0);
}
// Which class a widenable format belongs to.
for (const TextureInternalFormat format :
{TextureInternalFormat::RGB8I, TextureInternalFormat::RGB8UI, TextureInternalFormat::RGB16I,
TextureInternalFormat::RGB16UI, TextureInternalFormat::RGB32I, TextureInternalFormat::RGB32UI}) {
EXPECT_TRUE(MobileGL::MG_Backend::DirectGLES::TextureImpl::IsIntegerWidenableFormat(format))
<< MG_Util::ConvertTextureInternalFormatToString(format);
}
for (const TextureInternalFormat format :
{TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16Snorm, TextureInternalFormat::RGB16,
TextureInternalFormat::RGB16F, TextureInternalFormat::RGB32F, TextureInternalFormat::SRGB8}) {
EXPECT_FALSE(MobileGL::MG_Backend::DirectGLES::TextureImpl::IsIntegerWidenableFormat(format))
<< MG_Util::ConvertTextureInternalFormatToString(format);
}
// The destination is sized from the level, never from the source. The driver reads a full
// width*height*4 components for the transfer it was handed, so a short source must still
// leave a full buffer behind - sizing it from the source would hand the driver a buffer it
// runs off the end of.
{
const Float shortSource[] = {0.5f, 0.25f, 0.125f};
Vector<Uint8> widened;
const auto* result = static_cast<const Float*>(PrepareChannelWidenedUpload(
3, IntVec3(2, 2, 1), shortSource, sizeof(shortSource), GL_FLOAT, widened));
ASSERT_NE(result, static_cast<const void*>(shortSource));
ASSERT_EQ(widened.size(), 4 * 4 * sizeof(Float));
const Float expected[] = {0.5f, 0.25f, 0.125f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f,
0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f};
for (SizeT i = 0; i < 16; ++i) {
EXPECT_FLOAT_EQ(result[i], expected[i]) << "component " << i;
}
}
// No widening in effect (or nothing to convert): the caller's pointer comes straight back, so
// the sub-rect upload fast path still recognises an unconverted level.
{
const Float source[] = {1.0f, 2.0f, 3.0f, 4.0f};
Vector<Uint8> widened;
EXPECT_EQ(PrepareChannelWidenedUpload(4, texelSize, source, sizeof(source), GL_FLOAT, widened),
static_cast<const void*>(source));
EXPECT_EQ(PrepareChannelWidenedUpload(0, texelSize, source, sizeof(source), GL_FLOAT, widened),
static_cast<const void*>(source));
EXPECT_EQ(PrepareChannelWidenedUpload(3, texelSize, nullptr, 0, GL_FLOAT, widened), nullptr);
}
}
+368
View File
@@ -0,0 +1,368 @@
// MobileGL - MobileGL/MG_Test/Util/AsyncPoolBench.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
// A head-to-head harness for the two ShaderCompilePool execution engines
// (MOBILEGL_ASYNC_POOL=asio|libfork). Not a gtest: it measures one wall-clock interval per
// process, because most of what it drives is memoized per process (the shader preprocess
// cache and the compile-adoption map both live for the life of the GL context), so a second
// timed repetition inside one process would measure the cache, not the compiler. The driver
// script re-executes the binary for every repetition instead.
//
// Two modes:
//
// corpus - the REAL frontend path. glCreateShader/glShaderSource are done untimed, then
// the clock starts and glCompileShader/glLinkProgram submit every job, and stops
// once glGetProgramiv(GL_LINK_STATUS) has joined all of them. That is exactly the
// first-submit-to-all-joined interval a shaderpack load pays.
//
// micro - N trivial JobNodes straight through ShaderCompilePool::Post, isolating the
// executor's own dispatch overhead from any workload contention.
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <Config.h>
#include <MG_Impl/GLImpl/Program/GL_Program.h>
#include <MG_Util/Async/JobNode.h>
#include <MG_Util/Async/ShaderCompilePool.h>
using namespace MobileGL;
using namespace MobileGL::MG_Util::Async;
namespace GLImpl = MobileGL::MG_Impl::GLImpl;
namespace fs = std::filesystem;
namespace {
using Clock = std::chrono::steady_clock;
double MillisSince(const Clock::time_point start) {
return std::chrono::duration<double, std::milli>(Clock::now() - start).count();
}
GLenum StageFromExtension(const std::string& ext) {
if (ext == ".vert") return GL_VERTEX_SHADER;
if (ext == ".frag") return GL_FRAGMENT_SHADER;
if (ext == ".geom") return GL_GEOMETRY_SHADER;
if (ext == ".comp") return GL_COMPUTE_SHADER;
if (ext == ".tesc") return GL_TESS_CONTROL_SHADER;
if (ext == ".tese") return GL_TESS_EVALUATION_SHADER;
return 0;
}
std::string ReadFile(const fs::path& path) {
std::ifstream in(path, std::ios::binary);
std::ostringstream buf;
buf << in.rdbuf();
return buf.str();
}
struct CorpusShader {
std::string name;
std::string source;
GLenum stage = 0;
};
// One program's worth of the corpus: the trace's link group. Shaders are indices into
// the flat shader list, because a source shared by several programs must stay ONE entry
// - that sharing is what the compile-adoption map sees in the real path too.
struct CorpusProgram {
std::vector<SizeT> shaders;
};
struct Corpus {
std::vector<CorpusShader> shaders;
std::vector<CorpusProgram> programs;
SizeT totalBytes = 0;
};
// Reads a corpus directory written by extract_corpus.py: one file per compiled shader,
// stage in the extension, plus manifest.txt naming the trace's link groups.
Corpus LoadCorpus(const fs::path& dir) {
Corpus corpus;
std::unordered_map<std::string, SizeT> byName;
const auto intern = [&](const std::string& name) -> SizeT {
if (const auto it = byName.find(name); it != byName.end()) return it->second;
const fs::path path = dir / name;
if (!fs::exists(path)) return static_cast<SizeT>(-1);
CorpusShader shader;
shader.name = name;
shader.source = ReadFile(path);
shader.stage = StageFromExtension(path.extension().string());
if (shader.stage == 0) return static_cast<SizeT>(-1);
corpus.totalBytes += shader.source.size();
corpus.shaders.push_back(Move(shader));
const SizeT index = corpus.shaders.size() - 1;
byName.emplace(name, index);
return index;
};
const fs::path manifest = dir / "manifest.txt";
if (fs::exists(manifest)) {
std::ifstream in(manifest);
std::string line;
while (std::getline(in, line)) {
if (line.empty() || line[0] == '#') continue;
CorpusProgram program;
std::istringstream fields(line);
std::string name;
while (fields >> name) {
const SizeT index = intern(name);
if (index != static_cast<SizeT>(-1)) program.shaders.push_back(index);
}
if (!program.shaders.empty()) corpus.programs.push_back(Move(program));
}
}
// Anything in the directory the manifest never linked still gets compiled, as a
// program-less group, so the corpus on disk and the corpus measured are the same set.
std::vector<fs::path> leftovers;
for (const auto& entry : fs::directory_iterator(dir)) {
if (!entry.is_regular_file()) continue;
const std::string name = entry.path().filename().string();
if (name == "manifest.txt") continue;
if (StageFromExtension(entry.path().extension().string()) == 0) continue;
if (byName.count(name) != 0) continue;
leftovers.push_back(entry.path());
}
std::sort(leftovers.begin(), leftovers.end());
for (const auto& path : leftovers) intern(path.filename().string());
return corpus;
}
struct CorpusResult {
double submitMs = 0; // first glCompileShader -> last glLinkProgram returned
double joinMs = 0; // last submit -> every program joined
double totalMs = 0; // the number that matters: first submit -> all joined
SizeT linkFailures = 0;
SizeT compileFailures = 0;
};
CorpusResult RunCorpus(const Corpus& corpus) {
// ---- Untimed: create every GL object and stage every source ----------------------
// glShaderSource is a memcpy into the shader object and glAttachShader is a pointer
// append; neither touches the pool. Keeping them outside the clock makes the measured
// interval exactly the compile+link critical path, which is what an application's
// loading screen waits on.
std::vector<GLuint> shaderNames(corpus.shaders.size(), 0);
for (SizeT i = 0; i < corpus.shaders.size(); ++i) {
const CorpusShader& shader = corpus.shaders[i];
const GLuint name = GLImpl::CreateShader(shader.stage);
const GLchar* text = shader.source.c_str();
const GLint length = static_cast<GLint>(shader.source.size());
GLImpl::ShaderSource(name, 1, &text, &length);
shaderNames[i] = name;
}
std::vector<GLuint> programNames(corpus.programs.size(), 0);
for (SizeT p = 0; p < corpus.programs.size(); ++p) {
const GLuint program = GLImpl::CreateProgram();
for (const SizeT shaderIndex : corpus.programs[p].shaders) {
GLImpl::AttachShader(program, shaderNames[shaderIndex]);
}
programNames[p] = program;
}
// ---- Timed ------------------------------------------------------------------------
const Clock::time_point start = Clock::now();
// Submission order follows the trace: a program's shaders, then its link. That order
// is what exercises ProgramLinkTask::SubmitAfter's dependency chaining rather than a
// flat burst of independent compiles.
std::vector<Bool> submitted(corpus.shaders.size(), false);
for (SizeT p = 0; p < corpus.programs.size(); ++p) {
for (const SizeT shaderIndex : corpus.programs[p].shaders) {
if (submitted[shaderIndex]) continue;
submitted[shaderIndex] = true;
GLImpl::CompileShader(shaderNames[shaderIndex]);
}
GLImpl::LinkProgram(programNames[p]);
}
for (SizeT i = 0; i < corpus.shaders.size(); ++i) {
if (submitted[i]) continue;
submitted[i] = true;
GLImpl::CompileShader(shaderNames[i]);
}
const Clock::time_point submitted_at = Clock::now();
CorpusResult result;
// GL_LINK_STATUS is a joining query (GL_COMPLETION_STATUS_KHR is the one that must
// not join), so this loop is the all-joined barrier.
for (const GLuint program : programNames) {
GLint status = 0;
GLImpl::GetProgramiv(program, GL_LINK_STATUS, &status);
if (status == GL_FALSE) ++result.linkFailures;
}
for (const GLuint shader : shaderNames) {
GLint status = 0;
GLImpl::GetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE) ++result.compileFailures;
}
result.totalMs = MillisSince(start);
result.submitMs = std::chrono::duration<double, std::milli>(submitted_at - start).count();
result.joinMs = result.totalMs - result.submitMs;
for (const GLuint program : programNames) GLImpl::DeleteProgram(program);
for (const GLuint shader : shaderNames) GLImpl::DeleteShader(shader);
return result;
}
// ---- Executor microbenchmark ----------------------------------------------------------
// The body is deliberately near-empty: what is being measured is Post -> engine ->
// RunOnWorker -> next dispatch, i.e. the executor's own cost per job, with no compiler
// work to hide it.
//
// The barrier is an all-jobs-ran latch, and it has to be. This bench used to stop the
// clock at StopAndDrain(), which is not a "wait for everything" - it is the teardown path,
// and its contract is to ABANDON whatever the budget has not dispatched yet (see
// ShaderCompilePool::StopAndDrain, and the JobNodeTest case that pins exactly that). With
// 100k jobs behind a budget of N, most of them were therefore cancelled rather than run,
// and the fraction that survived was decided by how fast the engine drained the queue
// relative to the posting loop - i.e. by the very quantity under test. Measured on this
// machine at 8 workers: Asio ran 75,906 of 100,000 and libfork 99,998, and both were
// scored as if they had run 100,000. The reported "libfork is 1.36x faster" was libfork
// being charged for 32% more work than Asio.
class TrivialJob final : public JobNode {
public:
TrivialJob(std::atomic<Uint64>* sink, const Uint64 total, std::mutex* mutex,
std::condition_variable* cv)
: m_sink(sink), m_total(total), m_mutex(mutex), m_cv(cv) {}
private:
void RunBody() override {
if (m_sink->fetch_add(1, std::memory_order_acq_rel) + 1 == m_total) {
// The last job wakes the timer. Under the lock, so the waiter cannot miss it
// between its predicate check and its wait.
const std::lock_guard<std::mutex> lock(*m_mutex);
m_cv->notify_all();
}
}
std::atomic<Uint64>* m_sink;
Uint64 m_total;
std::mutex* m_mutex;
std::condition_variable* m_cv;
};
struct MicroResult {
double ms = 0;
Uint64 ran = 0;
};
MicroResult RunMicrobench(const Uint threads, const SizeT jobs) {
ShaderCompilePool pool(threads);
std::atomic<Uint64> counter{0};
std::mutex mutex;
std::condition_variable cv;
const auto total = static_cast<Uint64>(jobs);
// Nodes are allocated up front: MakeShared is not what is under test, and leaving it
// inside the loop would put an allocator on the critical path in front of the
// dispatch path this is meant to isolate.
std::vector<SharedPtr<JobNode>> nodes;
nodes.reserve(jobs);
for (SizeT i = 0; i < jobs; ++i) {
nodes.push_back(MakeShared<TrivialJob>(&counter, total, &mutex, &cv));
}
const Clock::time_point start = Clock::now();
for (auto& node : nodes) pool.Post(Move(node));
{
std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, [&] { return counter.load(std::memory_order_acquire) >= total; });
}
const double ms = MillisSince(start);
MicroResult result;
result.ms = ms;
result.ran = counter.load(std::memory_order_acquire);
return result;
}
[[noreturn]] void Usage() {
std::fprintf(stderr,
"usage: AsyncPoolBench --corpus DIR\n"
" AsyncPoolBench --micro JOBS --threads N\n"
"env: MOBILEGL_ASYNC_POOL=asio|libfork, "
"MOBILEGL_ASYNC_SHADER_COMPILE_THREADS=N\n");
std::exit(2);
}
} // namespace
int main(int argc, char** argv) {
std::string corpusDir;
SizeT microJobs = 0;
Uint microThreads = 0;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
const auto next = [&]() -> std::string {
if (i + 1 >= argc) Usage();
return argv[++i];
};
if (arg == "--corpus") corpusDir = next();
else if (arg == "--micro") microJobs = static_cast<SizeT>(std::stoull(next()));
else if (arg == "--threads") microThreads = static_cast<Uint>(std::stoul(next()));
else Usage();
}
if (corpusDir.empty() && microJobs == 0) Usage();
Initialize();
const AsyncPoolEngine engine = DetectAsyncPoolEngine();
const char* engineName = AsyncPoolEngineName(engine);
if (microJobs != 0) {
const Uint threads = microThreads != 0 ? microThreads : DetectShaderCompileThreadCount();
const MicroResult result = RunMicrobench(threads, microJobs);
// `ran` is printed, not just checked, so that a run in which the arms did different
// amounts of work is visible in the results file rather than on a stderr the driver
// script redirects to /dev/null. ns_per_job divides by what actually ran.
std::printf("RESULT mode=micro engine=%s threads=%u jobs=%zu ran=%llu total_ms=%.3f "
"ns_per_job=%.1f\n",
engineName, threads, microJobs, static_cast<unsigned long long>(result.ran),
result.ms, result.ms * 1e6 / static_cast<double>(result.ran));
return result.ran == microJobs ? 0 : 1;
}
const Corpus corpus = LoadCorpus(corpusDir);
if (corpus.shaders.empty()) {
std::fprintf(stderr, "AsyncPoolBench: no shaders found in %s\n", corpusDir.c_str());
return 1;
}
if (!AsyncShaderCompileActive()) {
std::fprintf(stderr, "AsyncPoolBench: asynchronous compilation is OFF; measuring the "
"inline path\n");
}
const CorpusResult result = RunCorpus(corpus);
const Uint threads = ShaderCompilePool::Get().GetThreadCount();
std::printf("RESULT mode=corpus engine=%s threads=%u corpus=%s shaders=%zu programs=%zu "
"bytes=%zu total_ms=%.3f submit_ms=%.3f join_ms=%.3f link_fail=%zu "
"compile_fail=%zu\n",
engineName, threads, corpusDir.c_str(), corpus.shaders.size(),
corpus.programs.size(), corpus.totalBytes, result.totalMs, result.submitMs,
result.joinMs, result.linkFailures, result.compileFailures);
return 0;
}
+44
View File
@@ -0,0 +1,44 @@
cmake_minimum_required(VERSION 3.14)
add_executable(
JobNodeTest
JobNodeTest.cpp
)
target_include_directories(JobNodeTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
# GTest::gtest, not GTest::gtest_main: JobNodeTest supplies its own main so that
# MOBILEGL_LOG_FILE_PATH is set before the first log write in the process. The engine
# -selection cases read the log back to assert that an unrecognized MOBILEGL_ASYNC_POOL value
# warns, and the desktop log sink is the file (MOBILEGL_LOG_ENABLE_CONSOLE is 0).
target_link_libraries(
JobNodeTest PRIVATE
GTest::gtest
${LINK_LIBRARIES}
)
include(GoogleTest)
gtest_discover_tests(JobNodeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
# The engine comparison harness. Deliberately NOT registered with add_test: it measures wall
# time, so it has no pass/fail verdict to give CI, and it is driven by a script that varies
# MOBILEGL_ASYNC_POOL and MOBILEGL_ASYNC_SHADER_COMPILE_THREADS across a matrix. It lives
# beside JobNodeTest because it drives the same pool through the same two engines; it links
# MobileGL_s for the real glCompileShader/glLinkProgram frontend path.
add_executable(
AsyncPoolBench
AsyncPoolBench.cpp
)
target_include_directories(AsyncPoolBench PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
AsyncPoolBench PRIVATE
${LINK_LIBRARIES}
)
+862
View File
@@ -0,0 +1,862 @@
// MobileGL - MobileGL/MG_Test/Util/JobNodeTest.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 <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#ifdef _WIN32
#include <process.h>
#define MGL_TEST_GETPID _getpid
#else
#include <unistd.h>
#define MGL_TEST_GETPID getpid
#endif
#include "Includes.h"
#include <Config.h>
#include <MG_Util/Async/JobNode.h>
#include <MG_Util/Async/ShaderCompilePool.h>
using namespace MobileGL;
using namespace MobileGL::MG_Util::Async;
namespace {
// Where this binary's MobileGL log lands, set by main() below. The engine-selection cases
// read it back: MobileGL's desktop log sink is the FILE, not the console
// (MOBILEGL_LOG_ENABLE_CONSOLE is 0 in Defines.h), so gtest's stdout capture would see
// nothing, and "unrecognized value warns" is a contract worth pinning rather than
// assuming - a silent fallback makes a misspelt engine name look exactly like an unset
// variable.
String g_logFilePath;
// Log.cpp flushes the file after every line, so everything written before this call is
// already visible.
String ReadLogFrom(const std::streamoff offset) {
std::ifstream file(g_logFilePath, std::ios::binary);
if (!file) return {};
file.seekg(offset);
return String((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
}
std::streamoff LogSize() {
std::error_code error;
const auto size = std::filesystem::file_size(g_logFilePath, error);
return error ? 0 : static_cast<std::streamoff>(size);
}
// Every test drives its own pool instance rather than ShaderCompilePool::Get(): the
// process-wide pool is stopped permanently by StopAndDrain (that is the teardown
// contract), so a test that drained the singleton would poison every test after it.
constexpr Uint kTestThreads = 4;
// A job whose body does exactly what the test tells it to. `ran` counts executions so
// "enqueued once, ran once" is checkable, and the optional gate lets a test hold a job
// inside its body while it inspects the node from the outside.
class TestJob final : public JobNode {
public:
explicit TestJob(std::function<void(TestJob&)> body = {}) : m_body(Move(body)) {}
std::atomic<Uint> ran{0};
std::atomic<Bool> observedCancelledInBody{false};
std::atomic<Bool> observedCancelledStateInBody{false};
protected:
void RunBody() override {
ran.fetch_add(1, std::memory_order_acq_rel);
if (m_body) m_body(*this);
observedCancelledInBody.store(IsCancellationRequested(), std::memory_order_release);
// A running body sees the request, not the outcome: the node is still Running
// until it returns, which is exactly the cooperative contract.
observedCancelledStateInBody.store(IsCancelled(), std::memory_order_release);
}
private:
std::function<void(TestJob&)> m_body;
};
// A manual gate, so a test can pin a job in Running and observe the node meanwhile.
class Gate {
public:
void Open() {
{
const std::lock_guard<std::mutex> lock(m_mutex);
m_open = true;
}
m_cv.notify_all();
}
void Wait() {
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this] { return m_open; });
}
private:
std::mutex m_mutex;
std::condition_variable m_cv;
Bool m_open = false;
};
// Live thread count of this process. Linux only - /proc/self/task has one entry per
// thread - and 0 where that is not available, which is how the one case that uses it
// decides to skip rather than to assert something it cannot see.
SizeT LiveThreadCount() {
#ifdef __linux__
std::error_code error;
const auto count = static_cast<SizeT>(
std::distance(std::filesystem::directory_iterator("/proc/self/task", error),
std::filesystem::directory_iterator()));
return error ? 0 : count;
#else
return 0;
#endif
}
Bool WaitUntil(const std::function<Bool()>& predicate,
const std::chrono::milliseconds timeout = std::chrono::seconds(10)) {
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
if (predicate()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return predicate();
}
} // namespace
// ---------------------------------------------------------------------------------------
// Pool lifecycle
// ---------------------------------------------------------------------------------------
TEST(ShaderCompilePoolLifecycle, ConstructingAPoolStartsNoThreadUntilSomethingIsPosted) {
const SizeT before = LiveThreadCount();
ShaderCompilePool pool(kTestThreads);
EXPECT_EQ(pool.GetThreadCount(), kTestThreads);
EXPECT_EQ(pool.GetMaxConcurrency(), kTestThreads);
if (before == 0) {
// No thread census on this platform. The rest still holds: construction is
// side-effect free and the pool destructs cleanly without ever having run.
SUCCEED();
return;
}
// "A build that never posts pays nothing" is a real requirement, not a stylistic one -
// asynchronous compilation can be switched off entirely, and a switched-off pool that
// still spawned its workers would cost every such process its threads and their stacks.
// Worth asserting rather than asserting-by-comment now that an engine's thread shape is
// selectable: the libfork engine starts its workers AND a dispatch thread of its own, so
// a regression here would cost more than it used to.
EXPECT_EQ(LiveThreadCount(), before) << "constructing a pool started " << (LiveThreadCount() - before)
<< " thread(s) before anything was posted";
auto job = MakeShared<TestJob>();
pool.Post(job);
job->Wait();
EXPECT_GT(LiveThreadCount(), before) << "the first Post started no thread at all, so the engine did not "
"really run the job off the calling thread";
}
TEST(ShaderCompilePoolLifecycle, StopAndDrainIsIdempotentAndSafeOnAnUnusedPool) {
ShaderCompilePool pool(kTestThreads);
pool.StopAndDrain();
pool.StopAndDrain();
SUCCEED();
}
TEST(ShaderCompilePoolLifecycle, AStoppedPoolRunsPostedJobsInlineOnTheCallingThread) {
ShaderCompilePool pool(kTestThreads);
pool.StopAndDrain();
const auto callingThread = std::this_thread::get_id();
std::thread::id bodyThread{};
auto job = MakeShared<TestJob>([&](TestJob&) { bodyThread = std::this_thread::get_id(); });
pool.Post(job);
// Terminal by the time Post returned - the whole point of the stopped-is-synchronous
// rule: a late entry point after teardown still gets a correct result, it just gets it
// without resurrecting a worker thread.
EXPECT_TRUE(job->IsComplete());
EXPECT_EQ(job->ran.load(), 1u);
EXPECT_EQ(bodyThread, callingThread);
}
TEST(ShaderCompilePoolLifecycle, SetMaxConcurrencyIsClampedToTheThreadCount) {
ShaderCompilePool pool(kTestThreads);
pool.SetMaxConcurrency(0);
EXPECT_EQ(pool.GetMaxConcurrency(), 1u);
pool.SetMaxConcurrency(1000);
EXPECT_EQ(pool.GetMaxConcurrency(), kTestThreads);
pool.SetMaxConcurrency(2);
EXPECT_EQ(pool.GetMaxConcurrency(), 2u);
}
TEST(ShaderCompilePoolLifecycle, DetectedThreadCountIsPositive) {
EXPECT_GE(DetectShaderCompileThreadCount(), 1u);
}
TEST(ShaderCompilePoolLifecycle, AsyncIsOnByDefaultAndTheOverrideDecidesEitherWay) {
// The shipped default flipped to ON at stage 7 (the GL30-40 + parallel_shader_compile
// gate found zero async-attributable failures), and an unset
// MOBILEGL_ASYNC_SHADER_COMPILE resolves to it. If the first expectation ever fails
// without the constant having been deliberately flipped back, something disabled async
// by accident - the kill switch below is the supported way off.
//
// Driven through Features rather than read from it: the suite is also run with
// MOBILEGL_ASYNC_SHADER_COMPILE exported both ways, so a test that simply asserted
// the resolved answer would fail in one of those runs or - worse - silently pass in a
// binary that never loaded the config and prove nothing at all.
EXPECT_TRUE(kAsyncShaderCompileDefault);
const MG_Config::QuirkOverride saved = MG_Config::Features.AsyncShaderCompile;
MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::Auto;
EXPECT_EQ(AsyncShaderCompileEnabled(), kAsyncShaderCompileDefault);
MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::ForceOn;
EXPECT_TRUE(AsyncShaderCompileEnabled());
MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::ForceOff;
EXPECT_FALSE(AsyncShaderCompileEnabled());
MG_Config::Features.AsyncShaderCompile = saved;
}
// ---------------------------------------------------------------------------------------
// Submit and join
// ---------------------------------------------------------------------------------------
TEST(JobNodeSubmit, PostedJobRunsOnAPoolThreadAndWaitJoinsIt) {
ShaderCompilePool pool(kTestThreads);
std::atomic<Bool> sawPoolThread{false};
auto job = MakeShared<TestJob>(
[&](TestJob&) { sawPoolThread.store(ShaderCompilePool::IsPoolThread(), std::memory_order_release); });
pool.Post(job);
job->Wait();
EXPECT_TRUE(job->IsTerminal());
EXPECT_TRUE(job->IsComplete());
EXPECT_FALSE(job->IsCancelled());
EXPECT_EQ(job->ran.load(), 1u);
EXPECT_TRUE(sawPoolThread.load());
// The joining thread is not a pool thread - the assert inside Wait() depends on it.
EXPECT_FALSE(ShaderCompilePool::IsPoolThread());
}
TEST(JobNodeSubmit, WaitOnAnAlreadyTerminalJobReturnsImmediately) {
ShaderCompilePool pool(kTestThreads);
auto job = MakeShared<TestJob>();
pool.Post(job);
job->Wait();
job->Wait();
EXPECT_EQ(job->ran.load(), 1u);
}
TEST(JobNodeSubmit, RunInlineExecutesOnTheCallingThreadWithoutAPool) {
auto job = MakeShared<TestJob>();
job->RunInline();
EXPECT_TRUE(job->IsComplete());
EXPECT_EQ(job->ran.load(), 1u);
}
TEST(JobNodeSubmit, ManyJobsAllComplete) {
constexpr Uint kJobs = 256;
ShaderCompilePool pool(kTestThreads);
Vector<SharedPtr<TestJob>> jobs;
jobs.reserve(kJobs);
std::atomic<Uint> completed{0};
for (Uint i = 0; i < kJobs; ++i) {
jobs.push_back(MakeShared<TestJob>([&](TestJob&) { completed.fetch_add(1, std::memory_order_acq_rel); }));
pool.Post(jobs.back());
}
for (const auto& job : jobs) job->Wait();
EXPECT_EQ(completed.load(), kJobs);
for (const auto& job : jobs) {
EXPECT_TRUE(job->IsComplete());
EXPECT_EQ(job->ran.load(), 1u);
}
}
TEST(JobNodeSubmit, AJobBodyMayPostAnotherJobToTheSamePool) {
// The ProgramLinkTask::SubmitAfter shape, reduced to its scheduling core: the dependent is
// posted by whichever thread drove the dependency terminal, which for a job that finished
// on a worker is that WORKER. Every engine therefore has to accept a submission from
// inside its own pool.
//
// Not a hypothetical: libfork refuses this outright at its normal entry point
// (lf::schedule throws lf::schedule_in_worker, because a libfork worker may never block),
// which is why the libfork engine owns a dispatch thread of its own. Without this case a
// naive port passes every other test in the file and turns every dependency-released link
// job into a cancelled one on the real GL path.
ShaderCompilePool pool(kTestThreads);
std::atomic<Bool> innerSawPoolThread{false};
auto inner = MakeShared<TestJob>(
[&](TestJob&) { innerSawPoolThread.store(ShaderCompilePool::IsPoolThread(), std::memory_order_release); });
std::atomic<Bool> postedFromPoolThread{false};
auto outer = MakeShared<TestJob>([&](TestJob&) {
postedFromPoolThread.store(ShaderCompilePool::IsPoolThread(), std::memory_order_release);
pool.Post(inner);
});
pool.Post(outer);
outer->Wait();
inner->Wait();
EXPECT_TRUE(postedFromPoolThread.load()) << "the outer body did not run on a pool thread, so this case "
"did not exercise posting from inside the pool";
EXPECT_TRUE(outer->IsComplete());
// The load-bearing one: the inner job RAN. A dispatch the engine refused would have
// settled it Cancelled instead, and its body would never have executed.
EXPECT_TRUE(inner->IsComplete()) << "a job posted from a pool thread was not dispatched";
EXPECT_FALSE(inner->IsCancelled());
EXPECT_EQ(inner->ran.load(), 1u);
EXPECT_TRUE(innerSawPoolThread.load());
}
TEST(JobNodeSubmit, ABurstPostedFromInsideThePoolStillRunsInParallel) {
// The tail of a pack load: one compile job goes terminal and its continuations release
// several programs at once (ShaderCompileAdoptionMap lets one compile settle many), so a
// WORKER posts a burst into a pool that is otherwise idle. Every one of those posts clears
// the budget immediately, so the engine is handed `kBurst` runnable jobs from inside
// itself - and it has to spread them, not run them one behind another on the thread that
// submitted them.
//
// Asserting on peak concurrency rather than on wall time: the budget is the contract, and
// an engine that dispatches within the budget but executes serially has silently turned
// the budget into an upper bound nothing reaches.
constexpr Uint kBurst = 4; // == kTestThreads, so the budget can hold all of them at once
ShaderCompilePool pool(kTestThreads);
std::atomic<Uint> live{0};
std::atomic<Uint> peak{0};
std::atomic<Uint> finished{0};
Vector<SharedPtr<TestJob>> burst;
burst.reserve(kBurst);
for (Uint i = 0; i < kBurst; ++i) {
burst.push_back(MakeShared<TestJob>([&](TestJob&) {
const Uint now = live.fetch_add(1, std::memory_order_acq_rel) + 1;
Uint seen = peak.load(std::memory_order_acquire);
while (now > seen && !peak.compare_exchange_weak(seen, now, std::memory_order_acq_rel)) {
}
// Long enough that a serial engine cannot fake overlap, short enough to keep the
// case cheap: with any real spread every body is inside this window together.
std::this_thread::sleep_for(std::chrono::milliseconds(120));
live.fetch_sub(1, std::memory_order_acq_rel);
finished.fetch_add(1, std::memory_order_acq_rel);
}));
}
std::atomic<Bool> postedFromPoolThread{false};
auto seeder = MakeShared<TestJob>([&](TestJob&) {
postedFromPoolThread.store(ShaderCompilePool::IsPoolThread(), std::memory_order_release);
for (const auto& job : burst) pool.Post(job);
});
pool.Post(seeder);
seeder->Wait();
for (const auto& job : burst) job->Wait();
ASSERT_TRUE(postedFromPoolThread.load()) << "the burst was not posted from a pool thread";
EXPECT_EQ(finished.load(), kBurst);
EXPECT_GT(peak.load(), 1u) << "a burst posted from inside the pool ran strictly one at a time; the "
"engine serialized work the budget had already cleared";
}
TEST(JobNodeSubmit, ConcurrencyBudgetIsNeverExceeded) {
constexpr Uint kBudget = 2;
constexpr Uint kJobs = 64;
ShaderCompilePool pool(kTestThreads);
pool.SetMaxConcurrency(kBudget);
std::atomic<Uint> inFlight{0};
std::atomic<Uint> peak{0};
Vector<SharedPtr<TestJob>> jobs;
jobs.reserve(kJobs);
for (Uint i = 0; i < kJobs; ++i) {
jobs.push_back(MakeShared<TestJob>([&](TestJob&) {
const Uint current = inFlight.fetch_add(1, std::memory_order_acq_rel) + 1;
Uint observed = peak.load(std::memory_order_acquire);
while (current > observed && !peak.compare_exchange_weak(observed, current)) {
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
inFlight.fetch_sub(1, std::memory_order_acq_rel);
}));
pool.Post(jobs.back());
}
for (const auto& job : jobs) job->Wait();
// This is also the memory bound: it is what stops a 300-program pack load from putting
// 300 glslang arenas in flight at once.
EXPECT_LE(peak.load(), kBudget);
EXPECT_GE(peak.load(), 1u);
}
// ---------------------------------------------------------------------------------------
// OnTerminal and dependency ordering
// ---------------------------------------------------------------------------------------
TEST(JobNodeContinuation, OnTerminalOnAnAlreadyTerminalNodeRunsInlineBeforeItReturns) {
auto job = MakeShared<TestJob>();
job->RunInline();
ASSERT_TRUE(job->IsTerminal());
Bool ranInline = false;
const auto callingThread = std::this_thread::get_id();
std::thread::id continuationThread{};
job->OnTerminal([&] {
ranInline = true;
continuationThread = std::this_thread::get_id();
});
EXPECT_TRUE(ranInline);
EXPECT_EQ(continuationThread, callingThread);
}
TEST(JobNodeContinuation, EveryContinuationFiresExactlyOnce) {
constexpr Uint kContinuations = 8;
ShaderCompilePool pool(kTestThreads);
Gate gate;
auto job = MakeShared<TestJob>([&](TestJob&) { gate.Wait(); });
pool.Post(job);
std::atomic<Uint> fired{0};
for (Uint i = 0; i < kContinuations; ++i) {
job->OnTerminal([&] { fired.fetch_add(1, std::memory_order_acq_rel); });
}
gate.Open();
job->Wait();
// Registered while the job was pending or running, so all of them are handed to the
// finishing thread; a late one would have run inline instead. Either way: once each.
EXPECT_TRUE(WaitUntil([&] { return fired.load() == kContinuations; }));
EXPECT_EQ(fired.load(), kContinuations);
// A continuation registered after the fact still fires, exactly once, inline.
job->OnTerminal([&] { fired.fetch_add(1, std::memory_order_acq_rel); });
EXPECT_EQ(fired.load(), kContinuations + 1);
}
TEST(JobNodeContinuation, DependencyCounterReachesZeroExactlyOnceAndOnlyAfterEveryDependency) {
// The shape ProgramLinkTask::SubmitAfter uses: the dependent is posted by whichever
// thread drives the counter to zero, so it is enqueued only once every dependency is
// terminal - which is why no job body ever has to wait on another job.
constexpr Uint kDeps = 16;
ShaderCompilePool pool(kTestThreads);
Vector<SharedPtr<TestJob>> deps;
deps.reserve(kDeps);
for (Uint i = 0; i < kDeps; ++i) deps.push_back(MakeShared<TestJob>());
std::atomic<Int> remaining{static_cast<Int>(kDeps) + 1}; // +1 guard: nothing fires mid-registration
std::atomic<Uint> released{0};
std::atomic<Bool> allDepsTerminalAtRelease{false};
const auto settle = [&] {
if (remaining.fetch_sub(1, std::memory_order_acq_rel) == 1) {
Bool allTerminal = true;
for (const auto& dep : deps) allTerminal = allTerminal && dep->IsTerminal();
allDepsTerminalAtRelease.store(allTerminal, std::memory_order_release);
released.fetch_add(1, std::memory_order_acq_rel);
}
};
for (const auto& dep : deps) {
pool.Post(dep);
dep->OnTerminal(settle);
}
settle(); // release the guard
EXPECT_TRUE(WaitUntil([&] { return released.load() == 1u; }));
EXPECT_EQ(released.load(), 1u);
EXPECT_TRUE(allDepsTerminalAtRelease.load());
for (const auto& dep : deps) EXPECT_TRUE(dep->IsComplete());
}
TEST(JobNodeContinuation, AlreadyTerminalDependenciesStillSettleTheCounterExactlyOnce) {
// Same counter, but every dependency is terminal before registration, so every
// continuation runs inline on the registering thread.
constexpr Uint kDeps = 4;
Vector<SharedPtr<TestJob>> deps;
for (Uint i = 0; i < kDeps; ++i) {
deps.push_back(MakeShared<TestJob>());
deps.back()->RunInline();
}
std::atomic<Int> remaining{static_cast<Int>(kDeps) + 1};
Uint released = 0;
const auto settle = [&] {
if (remaining.fetch_sub(1, std::memory_order_acq_rel) == 1) ++released;
};
for (const auto& dep : deps) dep->OnTerminal(settle);
settle();
EXPECT_EQ(released, 1u);
}
// ---------------------------------------------------------------------------------------
// Cancellation
// ---------------------------------------------------------------------------------------
TEST(JobNodeCancel, CancelBeforeAnyDispatchSettlesTheNodeAndSkipsTheBody) {
ShaderCompilePool pool(kTestThreads);
auto job = MakeShared<TestJob>();
job->Cancel();
EXPECT_TRUE(job->IsCancelled());
EXPECT_TRUE(job->IsTerminal());
EXPECT_FALSE(job->IsComplete());
// Posting an already-cancelled node is a no-op, not a second run.
pool.Post(job);
job->Wait();
EXPECT_EQ(job->ran.load(), 0u);
EXPECT_TRUE(job->IsCancelled());
}
TEST(JobNodeCancel, CancelWhileTheBodyIsRunningLetsItFinishAndReportsCancelled) {
ShaderCompilePool pool(kTestThreads);
Gate gate;
std::atomic<Bool> entered{false};
auto job = MakeShared<TestJob>([&](TestJob&) {
entered.store(true, std::memory_order_release);
gate.Wait();
});
pool.Post(job);
ASSERT_TRUE(WaitUntil([&] { return entered.load(); }));
job->Cancel();
// A running body is not interrupted - cancellation is cooperative - so the node is
// still Running until the body returns.
EXPECT_FALSE(job->IsTerminal());
gate.Open();
job->Wait();
EXPECT_EQ(job->ran.load(), 1u);
EXPECT_TRUE(job->IsCancelled());
EXPECT_FALSE(job->IsComplete());
EXPECT_TRUE(job->observedCancelledInBody.load());
EXPECT_FALSE(job->observedCancelledStateInBody.load());
}
TEST(JobNodeCancel, CancelAfterCompletionDoesNotUndoTheResult) {
ShaderCompilePool pool(kTestThreads);
auto job = MakeShared<TestJob>();
pool.Post(job);
job->Wait();
ASSERT_TRUE(job->IsComplete());
job->Cancel();
// The request is recorded, but a settled result is never retroactively undone.
EXPECT_TRUE(job->IsCancellationRequested());
EXPECT_TRUE(job->IsComplete());
EXPECT_FALSE(job->IsCancelled());
EXPECT_EQ(job->ran.load(), 1u);
}
TEST(JobNodeCancel, CancelReleasesContinuationsSoDependentsAreNotStranded) {
auto job = MakeShared<TestJob>();
std::atomic<Uint> fired{0};
job->OnTerminal([&] { fired.fetch_add(1, std::memory_order_acq_rel); });
job->Cancel();
EXPECT_EQ(fired.load(), 1u);
job->Wait(); // must not hang: a cancelled pending node is terminal
EXPECT_TRUE(job->IsCancelled());
}
// ---------------------------------------------------------------------------------------
// Exceptions
// ---------------------------------------------------------------------------------------
TEST(JobNodeException, AnExceptionEscapingABodyCancelsTheJobInsteadOfTerminating) {
// Asio propagates an exception out of thread_pool::run(), which is std::terminate for
// the process. Containing it at the job boundary is what makes that impossible.
ShaderCompilePool pool(kTestThreads);
auto job = MakeShared<TestJob>([](TestJob&) { throw std::runtime_error("boom"); });
pool.Post(job);
job->Wait();
EXPECT_TRUE(job->IsTerminal());
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);
}
TEST(JobNodeException, ANonStandardExceptionIsContainedToo) {
ShaderCompilePool pool(kTestThreads);
auto job = MakeShared<TestJob>([](TestJob&) { throw 42; });
pool.Post(job);
job->Wait();
EXPECT_TRUE(job->IsCancelled());
ASSERT_EQ(job->diagnostics.logLines.size(), 1u);
}
TEST(JobNodeException, AThrowingJobDoesNotPoisonTheWorkerForLaterJobs) {
ShaderCompilePool pool(kTestThreads);
auto thrower = MakeShared<TestJob>([](TestJob&) { throw std::runtime_error("boom"); });
pool.Post(thrower);
thrower->Wait();
auto healthy = MakeShared<TestJob>();
pool.Post(healthy);
healthy->Wait();
EXPECT_TRUE(healthy->IsComplete());
}
// ---------------------------------------------------------------------------------------
// Drain
// ---------------------------------------------------------------------------------------
TEST(ShaderCompilePoolDrain, StopAndDrainWithAThousandQueuedJobsLeavesNoneRunningOrPending) {
constexpr Uint kQueued = 1000;
ShaderCompilePool pool(kTestThreads);
pool.SetMaxConcurrency(1); // one slot, so everything behind the first job stays queued
// Pin that slot with a job that will not return until this test says so. Everything
// posted behind it is then PROVABLY still in the queue, which is what makes the counts
// below exact.
//
// This case used to post a thousand trivial jobs and drain immediately, hoping the drain
// would beat the workers to some of them - and then assert only that "some" were
// cancelled. That hope does not survive an engine whose workers take their next job
// without a scheduler round trip: the libfork engine drained all thousand before the
// posting loop had finished, so the assertion failed about one run in fifty. The property
// being tested (a drain ABANDONS queued work rather than running it) is real and
// engine-independent; only the way it was provoked was a race.
Gate gate;
std::atomic<Bool> entered{false};
auto blocker = MakeShared<TestJob>([&](TestJob&) {
entered.store(true, std::memory_order_release);
gate.Wait();
});
pool.Post(blocker);
ASSERT_TRUE(WaitUntil([&] { return entered.load(); }));
Vector<SharedPtr<TestJob>> queued;
queued.reserve(kQueued);
for (Uint i = 0; i < kQueued; ++i) {
queued.push_back(MakeShared<TestJob>());
pool.Post(queued.back());
}
for (const auto& job : queued) ASSERT_FALSE(job->IsTerminal());
std::thread drain([&] { pool.StopAndDrain(); });
// StopAndDrain settles the entire queue before it waits for the running body, so the
// first cancelled node proves it is past that point - and the gate can then be released
// without racing it.
ASSERT_TRUE(WaitUntil([&] { return queued.front()->IsTerminal(); }));
gate.Open();
drain.join();
// The job that was already running still finished: an in-flight body is waited for, not
// interrupted.
EXPECT_TRUE(blocker->IsComplete());
EXPECT_EQ(blocker->ran.load(), 1u);
// And every queued node is terminal, so nothing is left waiting on a worker that will
// never come - settled as cancelled, with its body never entered.
for (const auto& job : queued) {
ASSERT_TRUE(job->IsTerminal());
EXPECT_TRUE(job->IsCancelled());
EXPECT_EQ(job->ran.load(), 0u);
}
}
TEST(ShaderCompilePoolDrain, StopAndDrainWaitsForARunningBodyToReturn) {
ShaderCompilePool pool(kTestThreads);
Gate gate;
std::atomic<Bool> entered{false};
std::atomic<Bool> left{false};
auto job = MakeShared<TestJob>([&](TestJob&) {
entered.store(true, std::memory_order_release);
gate.Wait();
left.store(true, std::memory_order_release);
});
pool.Post(job);
ASSERT_TRUE(WaitUntil([&] { return entered.load(); }));
std::thread opener([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
gate.Open();
});
pool.StopAndDrain();
opener.join();
// This is the guarantee library teardown relies on: once StopAndDrain returns, no worker
// is still inside a body that could touch glslang's process globals.
EXPECT_TRUE(left.load());
EXPECT_TRUE(job->IsTerminal());
}
TEST(ShaderCompilePoolDrain, JobsPostedAfterADrainStillRun) {
ShaderCompilePool pool(kTestThreads);
pool.StopAndDrain();
auto job = MakeShared<TestJob>();
pool.Post(job);
EXPECT_TRUE(job->IsComplete());
EXPECT_EQ(job->ran.load(), 1u);
}
// ---------------------------------------------------------------------------------------
// Execution engine selection (MOBILEGL_ASYNC_POOL)
// ---------------------------------------------------------------------------------------
//
// The engine decides only HOW a job that the concurrency budget has already cleared reaches a
// worker thread. Everything else in this file - the budget, cancel request-vs-outcome, the
// continuation machinery, the inline fallback after a stop, the drain - is engine-independent
// by construction, which is why the whole suite is expected to pass unchanged with
// MOBILEGL_ASYNC_POOL unset and with it set to libfork. These cases pin the selection itself,
// so that a run of the matrix cannot silently test asio twice.
TEST(AsyncPoolEngineSelection, EveryAcceptedSpellingParsesToItsEngine) {
EXPECT_EQ(ParseAsyncPoolEngine("asio"), AsyncPoolEngine::Asio);
EXPECT_EQ(ParseAsyncPoolEngine("libfork"), AsyncPoolEngine::Libfork);
// Case-insensitive, like the other named-value variables (MOBILEGL_*_MULTIDRAW_MODE).
EXPECT_EQ(ParseAsyncPoolEngine("Libfork"), AsyncPoolEngine::Libfork);
EXPECT_EQ(ParseAsyncPoolEngine("LIBFORK"), AsyncPoolEngine::Libfork);
EXPECT_EQ(ParseAsyncPoolEngine("ASIO"), AsyncPoolEngine::Asio);
EXPECT_STREQ(AsyncPoolEngineName(AsyncPoolEngine::Asio), "asio");
EXPECT_STREQ(AsyncPoolEngineName(AsyncPoolEngine::Libfork), "libfork");
// Round trip: whatever the name prints is a spelling the variable accepts back.
EXPECT_EQ(ParseAsyncPoolEngine(AsyncPoolEngineName(AsyncPoolEngine::Asio)), AsyncPoolEngine::Asio);
EXPECT_EQ(ParseAsyncPoolEngine(AsyncPoolEngineName(AsyncPoolEngine::Libfork)), AsyncPoolEngine::Libfork);
}
TEST(AsyncPoolEngineSelection, EmptyAndAutoAreTheDefaultEngineAndSaySoSilently) {
// Unset resolves through the empty string, and "auto" is the spelling the other named
// -value variables accept for "no preference". Neither is a mistake, so neither warns.
const std::streamoff before = LogSize();
EXPECT_EQ(ParseAsyncPoolEngine(""), AsyncPoolEngine::Asio);
EXPECT_EQ(ParseAsyncPoolEngine("auto"), AsyncPoolEngine::Asio);
EXPECT_EQ(ReadLogFrom(before).find("MOBILEGL_ASYNC_POOL"), String::npos)
<< "a legitimate value warned; only an unrecognized one may";
}
TEST(AsyncPoolEngineSelection, AnUnrecognizedEngineNameFallsBackToAsioAndWarns) {
const std::streamoff before = LogSize();
EXPECT_EQ(ParseAsyncPoolEngine("libfrok"), AsyncPoolEngine::Asio);
// The warning is the other half of the contract: a misspelt engine name that fell back
// silently would be indistinguishable from an unset variable, and a scaling measurement
// taken against the wrong engine is worse than no measurement.
//
// Guarded because MGLOG_W is a compile-time no-op unless the build's log level admits it -
// and the shipped level does not (Log.h orders the levels DEBUG=0, WARN=1, ERROR=2, INFO=3,
// FATAL=4 and gates on `ACTIVE <= LEVEL`, so the default INFO build enables only INFO and
// FATAL). Nothing is skipped: the fallback above is pinned in every build, and this half is
// checked by a build configured with
// -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_WARN. The same guard is what makes the
// preceding "says so silently" case honest rather than vacuously true.
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_WARN
const String logged = ReadLogFrom(before);
EXPECT_NE(logged.find("MOBILEGL_ASYNC_POOL"), String::npos) << "no warning names the variable; log tail: " << logged;
EXPECT_NE(logged.find("libfrok"), String::npos)
<< "the warning does not quote the rejected value; log tail: " << logged;
EXPECT_NE(logged.find("asio"), String::npos)
<< "the warning does not say what it fell back to; log tail: " << logged;
#else
(void)before;
#endif
}
TEST(AsyncPoolEngineSelection, TheDetectedEngineIsTheOneTheEnvironmentAskedFor) {
// Read the variable directly rather than through the pool, so this really compares the
// process's answer against the environment the runner exported. This is the case that
// makes "the suite passed with MOBILEGL_ASYNC_POOL=libfork" mean something.
const char* const raw = std::getenv("MOBILEGL_ASYNC_POOL");
const AsyncPoolEngine expected = ParseAsyncPoolEngine(raw != nullptr ? String(raw) : String());
EXPECT_EQ(DetectAsyncPoolEngine(), expected);
// Stable: resolved once per process, so it cannot drift between calls.
EXPECT_EQ(DetectAsyncPoolEngine(), DetectAsyncPoolEngine());
if (DetectAsyncPoolEngine() != AsyncPoolEngine::Asio) {
// Selecting a non-default engine announces itself at INFO, which the shipped log level
// does admit - so on the libfork half of the matrix this doubles as the positive
// control for the log plumbing the preceding two cases read: it proves
// MOBILEGL_LOG_FILE_PATH took effect and that ReadLogFrom really sees MobileGL's
// output, rather than passing because the file is always empty.
const String logged = ReadLogFrom(0);
EXPECT_NE(logged.find("MOBILEGL_ASYNC_POOL"), String::npos)
<< "the selected engine was never announced, so this binary's log capture proves nothing";
EXPECT_NE(logged.find(AsyncPoolEngineName(DetectAsyncPoolEngine())), String::npos);
}
}
TEST(AsyncPoolEngineSelection, EveryPoolReportsTheProcessEngineAndRunsWorkOnIt) {
ShaderCompilePool first(kTestThreads);
ShaderCompilePool second(kTestThreads);
EXPECT_EQ(first.GetEngine(), DetectAsyncPoolEngine());
EXPECT_EQ(second.GetEngine(), first.GetEngine())
<< "two pools in one process disagree about the engine; a process must never run both";
// And the engine it reports is the one that actually executed the work: the body ran off
// the calling thread, on a thread the pool owns.
const auto callingThread = std::this_thread::get_id();
std::atomic<Bool> sawPoolThread{false};
std::thread::id bodyThread{};
auto job = MakeShared<TestJob>([&](TestJob&) {
sawPoolThread.store(ShaderCompilePool::IsPoolThread(), std::memory_order_release);
bodyThread = std::this_thread::get_id();
});
first.Post(job);
job->Wait();
ASSERT_TRUE(job->IsComplete());
EXPECT_TRUE(sawPoolThread.load());
EXPECT_NE(bodyThread, callingThread);
}
// gtest_main is replaced here for one reason: the engine-selection cases above assert that an
// unrecognized MOBILEGL_ASYNC_POOL value WARNS, and MobileGL's desktop log sink is the log
// file - MOBILEGL_LOG_ENABLE_CONSOLE is 0 in Defines.h, so there is nothing on stdout to
// capture. MOBILEGL_LOG_FILE_PATH is read by Log.cpp's InitFile() at the first log write in
// the process, so it has to be set before any test body runs.
int main(int argc, char** argv) {
const std::filesystem::path logPath =
std::filesystem::temp_directory_path() /
("mobilegl-jobnodetest-" + std::to_string(static_cast<long long>(MGL_TEST_GETPID())) + ".log");
g_logFilePath = logPath.string();
std::filesystem::remove(logPath);
#ifdef _WIN32
::_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logFilePath.c_str());
#else
::setenv("MOBILEGL_LOG_FILE_PATH", g_logFilePath.c_str(), 1);
#endif
::testing::InitGoogleTest(&argc, argv);
const int result = RUN_ALL_TESTS();
// Best-effort: leaving a log file per test process in the temp directory would be litter,
// and a failed run has already printed the tail it needed into the gtest output.
std::error_code ignored;
std::filesystem::remove(logPath, ignored);
return result;
}
@@ -1290,3 +1290,77 @@ TEST_F(GeneralVertexArrayTest, ArrayFormat_IntegerPathRejectsPackedAndBgra) {
EXPECT_FALSE(a0.IsBgra);
}
// The integer path takes exactly the eight signed/unsigned integer types (GL 4.6 core 10.3.2).
// The old check was a blacklist of Unknown + the packed types, so GL_FLOAT / GL_HALF_FLOAT /
// GL_DOUBLE / GL_FIXED all converted to a valid DataType and were silently recorded as integer
// attributes.
TEST_F(GeneralVertexArrayTest, ArrayFormat_IntegerPathRejectsFloatTypes) {
CreateVAO();
CreateVBO(GL_ARRAY_BUFFER, 64);
// Establish a known-good integer format first, so a rejected call is visible as "unchanged".
VertexAttribIPointer(0, 4, GL_INT, 0, nullptr);
ASSERT_EQ(GetError(), GL_NO_ERROR);
const GLenum floatTypes[] = {GL_FLOAT, GL_HALF_FLOAT, GL_DOUBLE, GL_FIXED};
for (GLenum type : floatTypes) {
VertexAttribIPointer(0, 4, type, 0, nullptr);
EXPECT_EQ(GetError(), GL_INVALID_ENUM) << "type " << type << " was accepted on the integer path";
const auto& attribute = MG_State::pGLContext->GetBoundVertexArray()->GetAttribute(0);
EXPECT_EQ(attribute.Type, DataType::Int32) << "a rejected format must not take effect";
EXPECT_TRUE(attribute.IsInteger);
}
// The float path still accepts them.
VertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, nullptr);
EXPECT_EQ(GetError(), GL_NO_ERROR);
EXPECT_EQ(MG_State::pGLContext->GetBoundVertexArray()->GetAttribute(0).Type, DataType::Float32);
}
// ARB_vertex_attrib_binding's two per-attribute queries. They were missing from
// ValidateVertexAttribPname (so glGetVertexAttribiv answered INVALID_ENUM) and from
// glGetVertexArrayIndexediv's switch (GL_VERTEX_ATTRIB_BINDING only).
TEST_F(GeneralVertexArrayTest, ArrayFormat_BindingAndRelativeOffsetAreQueryable) {
const GLuint vao = CreateVAO();
CreateVBO(GL_ARRAY_BUFFER, 256);
VertexAttribFormat(2, 3, GL_FLOAT, GL_FALSE, 12);
VertexAttribBinding(2, 5);
ASSERT_EQ(GetError(), GL_NO_ERROR);
GLint binding = -1;
GetVertexAttribiv(2, GL_VERTEX_ATTRIB_BINDING, &binding);
EXPECT_EQ(binding, 5);
EXPECT_EQ(GetError(), GL_NO_ERROR);
GLint relativeOffset = -1;
GetVertexAttribiv(2, GL_VERTEX_ATTRIB_RELATIVE_OFFSET, &relativeOffset);
EXPECT_EQ(relativeOffset, 12);
EXPECT_EQ(GetError(), GL_NO_ERROR);
// The float and double views convert the same value.
GLfloat bindingAsFloat = -1.0f;
GetVertexAttribfv(2, GL_VERTEX_ATTRIB_BINDING, &bindingAsFloat);
EXPECT_FLOAT_EQ(bindingAsFloat, 5.0f);
GLdouble offsetAsDouble = -1.0;
GetVertexAttribdv(2, GL_VERTEX_ATTRIB_RELATIVE_OFFSET, &offsetAsDouble);
EXPECT_DOUBLE_EQ(offsetAsDouble, 12.0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
// The by-name (DSA) indexed query answers both as well.
GLint namedBinding = -1;
GetVertexArrayIndexediv(vao, 2, GL_VERTEX_ATTRIB_BINDING, &namedBinding);
EXPECT_EQ(namedBinding, 5);
GLint namedRelativeOffset = -1;
GetVertexArrayIndexediv(vao, 2, GL_VERTEX_ATTRIB_RELATIVE_OFFSET, &namedRelativeOffset);
EXPECT_EQ(namedRelativeOffset, 12);
EXPECT_EQ(GetError(), GL_NO_ERROR);
// An attribute nobody re-bound keeps the default attribute-i -> binding-i mapping.
GetVertexAttribiv(1, GL_VERTEX_ATTRIB_BINDING, &binding);
EXPECT_EQ(binding, 1);
GetVertexAttribiv(1, GL_VERTEX_ATTRIB_RELATIVE_OFFSET, &relativeOffset);
EXPECT_EQ(relativeOffset, 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}

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