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
113 changed files with 21744 additions and 2940 deletions
+6
View File
@@ -31,3 +31,9 @@
[submodule "3rdparty/apitrace"] [submodule "3rdparty/apitrace"]
path = 3rdparty/apitrace path = 3rdparty/apitrace
url = https://github.com/MobileGL-Dev/apitrace.git 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
+35 -1
View File
@@ -22,7 +22,9 @@ if (ANDROID)
set(MOBILEGL_BUILD_BENCHMARK OFF CACHE BOOL "Build MobileGL benchmarks" FORCE) set(MOBILEGL_BUILD_BENCHMARK OFF CACHE BOOL "Build MobileGL benchmarks" FORCE)
endif() 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 # Check if ThinLTO or LTO is suppported
include(CheckIPOSupported) include(CheckIPOSupported)
include(CheckCCompilerFlag) include(CheckCCompilerFlag)
@@ -152,6 +154,9 @@ set(SOURCE_FILES
MobileGL/MG_Util/Debug/Log.cpp 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/Math/VectorTypes.cpp
MobileGL/MG_Util/Metrics/TextureMetrics.cpp MobileGL/MG_Util/Metrics/TextureMetrics.cpp
@@ -185,6 +190,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/Classifiers/TextureEnumClassifier.cpp MobileGL/MG_Util/Classifiers/TextureEnumClassifier.cpp
MobileGL/MG_Util/ShaderTranspiler/CompileEnv.cpp
MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp
MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp
MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp
@@ -192,6 +198,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenInterfaceStructPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenInterfaceStructPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EliminateFloatEqualsZeroPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EliminateFloatEqualsZeroPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameSamplerFunctionParameterPass.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/DecomposeWorkgroupVec3Pass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecoratePositionInvariantPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecoratePositionInvariantPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp
@@ -225,6 +232,7 @@ set(SOURCE_FILES
MobileGL/MG_Impl/GLImpl/Framebuffer/Validators.cpp MobileGL/MG_Impl/GLImpl/Framebuffer/Validators.cpp
MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp
MobileGL/MG_Impl/GLImpl/Program/GL_Program.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/Program/GL_ProgramPipeline.cpp
MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp
MobileGL/MG_Impl/GLImpl/Texture/Validators.cpp MobileGL/MG_Impl/GLImpl/Texture/Validators.cpp
@@ -248,6 +256,7 @@ set(SOURCE_FILES
MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp
MobileGL/MG_Backend/DirectGLES/Utils.cpp MobileGL/MG_Backend/DirectGLES/Utils.cpp
MobileGL/MG_Backend/DirectGLES/Managers.cpp MobileGL/MG_Backend/DirectGLES/Managers.cpp
MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp
MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp
MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp
@@ -286,7 +295,11 @@ set(SOURCE_FILES
MobileGL/MG_State/GLState/TextureState/TextureUnit.cpp MobileGL/MG_State/GLState/TextureState/TextureUnit.cpp
MobileGL/MG_State/GLState/TextureState/TextureState.cpp MobileGL/MG_State/GLState/TextureState/TextureState.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp
MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp
MobileGL/MG_State/GLState/RenderState/RenderState.cpp MobileGL/MG_State/GLState/RenderState/RenderState.cpp
MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp
@@ -320,6 +333,11 @@ if (WIN32)
) )
endif() 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 set(MOBILEGL_LINK_LIBRARIES
glslang::glslang glslang::glslang
spirv-cross-c spirv-cross-c
@@ -329,12 +347,17 @@ set(MOBILEGL_LINK_LIBRARIES
GPUOpen::VulkanMemoryAllocator GPUOpen::VulkanMemoryAllocator
Vulkan::UtilityHeaders Vulkan::UtilityHeaders
spirv-reflect-static spirv-reflect-static
Threads::Threads
) )
set(MOBILEGL_COMPILE_DEF set(MOBILEGL_COMPILE_DEF
-DVMA_STATIC_VULKAN_FUNCTIONS=0 -DVMA_STATIC_VULKAN_FUNCTIONS=0
-DVMA_DYNAMIC_VULKAN_FUNCTIONS=1 -DVMA_DYNAMIC_VULKAN_FUNCTIONS=1
-DVMA_VULKAN_VERSION=1001000 -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}") message(STATUS "MOBILEGL_COMPILE_DEF=${MOBILEGL_COMPILE_DEF}")
@@ -346,6 +369,17 @@ set(MOBILEGL_INCLUDE_DIR
${spirv-tools_SOURCE_DIR}/include ${spirv-tools_SOURCE_DIR}/include
${spirv-tools_BINARY_DIR} ${spirv-tools_BINARY_DIR}
${SPIRV-Headers_SOURCE_DIR}/include ${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 add_library(${CMAKE_PROJECT_NAME} SHARED
+37
View File
@@ -39,6 +39,23 @@ namespace MobileGL::MG_Config {
Unroll, // one vkCmdDraw* per sub-draw 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() // Feature toggles parsed once from environment variables in MG_ConfigLoader::Init()
// (ConfigLoader.cpp), before the accepted-env map is destroyed. All Bool fields share // (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" // one truthy rule: the variable is set, non-empty, not "0", and not "false"
@@ -49,6 +66,12 @@ namespace MobileGL::MG_Config {
// - DISPLAY: X11 session variable, not MobileGL configuration. // - DISPLAY: X11 session variable, not MobileGL configuration.
// - MOBILEGL_LOG_FILE_PATH: log-file init runs before MG_ConfigLoader::Init // - MOBILEGL_LOG_FILE_PATH: log-file init runs before MG_ConfigLoader::Init
// (see MG_Util/Debug/Log.cpp). // (see MG_Util/Debug/Log.cpp).
// - MOBILEGL_ASYNC_POOL: a ShaderCompilePool is constructed by binaries that never call
// MobileGL::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 { struct FeaturesTable {
// MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries. // MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries.
Bool DisableTimerQuery = false; Bool DisableTimerQuery = false;
@@ -105,6 +128,20 @@ namespace MobileGL::MG_Config {
// ("ext" | "indirect" | "unroll", see MultiDrawMode). Clamped to device support; // ("ext" | "indirect" | "unroll", see MultiDrawMode). Clamped to device support;
// unset picks the best supported tier. // unset picks the best supported tier.
MultiDrawMode MagmaMultiDrawMode = MultiDrawMode::Auto; 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; extern FeaturesTable Features;
} // namespace MobileGL::MG_Config } // namespace MobileGL::MG_Config
+25
View File
@@ -116,6 +116,28 @@ namespace MobileGL::MG_ConfigLoader {
return MG_Config::MultiDrawMode::Auto; 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) { inline Uint32 QueryEnvUint32(const String& key, Uint32 defaultValue, Uint32 minValue, Uint32 maxValue) {
auto it = acceptedEnvVariablesMap->find(key); auto it = acceptedEnvVariablesMap->find(key);
if (it == acceptedEnvVariablesMap->end()) { if (it == acceptedEnvVariablesMap->end()) {
@@ -158,6 +180,9 @@ namespace MobileGL::MG_ConfigLoader {
QueryEnvQuirkOverride("MOBILEGL_MAGMA_DISABLE_BLENDED_DEPTH_WRITE"); QueryEnvQuirkOverride("MOBILEGL_MAGMA_DISABLE_BLENDED_DEPTH_WRITE");
features.DisableRobustBufferAccess = QueryEnvFlag("MOBILEGL_DISABLE_ROBUST_BUFFER_ACCESS"); features.DisableRobustBufferAccess = QueryEnvFlag("MOBILEGL_DISABLE_ROBUST_BUFFER_ACCESS");
features.MagmaMultiDrawMode = QueryEnvMultiDrawMode("MOBILEGL_MAGMA_MULTIDRAW_MODE"); 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() { inline void InitBackendType() {
+31 -1
View File
@@ -15,6 +15,8 @@
#include <MG_Impl/GLImpl/Texture/ProxyTexture.h> #include <MG_Impl/GLImpl/Texture/ProxyTexture.h>
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h> #include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
#include <MG_Impl/GLImpl/Sync/GL_Sync.h> #include <MG_Impl/GLImpl/Sync/GL_Sync.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <atomic> #include <atomic>
#include <mutex> #include <mutex>
@@ -37,7 +39,12 @@ namespace MobileGL {
if (logLifecycle) { if (logLifecycle) {
MGLOG_I("MobileGL closing..."); 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 // GL syncs die with their contexts, and every context is gone by the
// time full teardown runs: drain the live-sync registry while the // time full teardown runs: drain the live-sync registry while the
// backend function table can still release the backend handles (and // backend function table can still release the backend handles (and
@@ -49,6 +56,16 @@ namespace MobileGL {
MG_State::pEGLContext.reset(); MG_State::pEGLContext.reset();
MG_Impl::GLImpl::TextureImpl::pProxyTextureManager.reset(); MG_Impl::GLImpl::TextureImpl::pProxyTextureManager.reset();
MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo.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 = {}; MG_Backend::gBackendFunctionsTable = {};
g_isInitialized = false; g_isInitialized = false;
if (logLifecycle) { if (logLifecycle) {
@@ -76,6 +93,19 @@ namespace MobileGL {
MG_Impl::Init(); MG_Impl::Init();
MGLOG_D("MG_Impl initialized"); MGLOG_D("MG_Impl initialized");
glslang::InitializeProcess(); 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"); MGLOG_D("glslang initialized");
g_isInitialized = true; g_isInitialized = true;
MGLOG_I("MobileGL initialized"); MGLOG_I("MobileGL initialized");
+12 -9
View File
@@ -181,15 +181,18 @@ namespace MobileGL {
void (*GetIntegeri_v)(GLenum target, GLuint index, GLint* data); void (*GetIntegeri_v)(GLenum target, GLuint index, GLint* data);
void (*GetInteger64i_v)(GLenum target, GLuint index, GLint64* data); void (*GetInteger64i_v)(GLenum target, GLuint index, GLint64* data);
void (*GetProgramiv)(GLuint program, GLenum pname, GLint* params); void (*GetProgramiv)(GLuint program, GLenum pname, GLint* params);
void (*GetProgramInterfaceiv)(GLuint program, GLenum programInterface, GLenum pname, GLint* params); // The GL program interface (glGetProgramInterfaceiv / glGetProgramResource*) is NOT
GLuint (*GetProgramResourceIndex)(GLuint program, GLenum programInterface, const GLchar* name); // a backend query: it describes the program the application wrote, in the
void (*GetProgramResourceName)(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, // application's namespace, which neither backend program is in. It is answered
GLsizei* length, GLchar* name); // entirely by MG_Impl/GLImpl/Program/ProgramInterface from the frontend reflection.
void (*GetProgramResourceiv)(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount, // Takes the block's GL NAME, not glShaderStorageBlockBinding's index. The index
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params); // the application passes is the frontend interface-query enumeration's, and no
GLint (*GetProgramResourceLocation)(GLuint program, GLenum programInterface, const GLchar* name); // backend shares that index space: DirectVulkan enumerates SPIR-V descriptor
GLint (*GetProgramResourceLocationIndex)(GLuint program, GLenum programInterface, const GLchar* name); // bindings and DirectGLES asks a real driver about SPIRV-Cross-generated ESSL.
void (*ShaderStorageBlockBinding)(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding); // 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 // GL fence sync objects. All entries are optional (may be null); the
// frontend then falls back to always-signaled sync semantics. // frontend then falls back to always-signaled sync semantics.
// FenceSync may itself return null when the backend cannot create a // 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/MGToGL/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToStr/TextureEnumConverter.h> #include <MG_Util/Converters/MGToStr/TextureEnumConverter.h>
#include <MG_Util/Texture/TextureFormatProcessor.h> #include <MG_Util/Texture/TextureFormatProcessor.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <Config.h> #include <Config.h>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
@@ -209,7 +210,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
reasons.push_back("GL_DEPTH_COMPONENT32 native probe failed on OpenGL ES"); reasons.push_back("GL_DEPTH_COMPONENT32 native probe failed on OpenGL ES");
} }
if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) { 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) { if (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) {
reasons.push_back("EXT_render_snorm not supported"); reasons.push_back("EXT_render_snorm not supported");
@@ -552,26 +553,60 @@ namespace MobileGL::MG_Backend::DirectGLES {
for (SizeT targetIndex = 0; targetIndex < kFormatCapabilityTextureTargetCount; ++targetIndex) { for (SizeT targetIndex = 0; targetIndex < kFormatCapabilityTextureTargetCount; ++targetIndex) {
const auto target = static_cast<TextureTarget>(targetIndex); const auto target = static_cast<TextureTarget>(targetIndex);
// A multisample texture can only ever be rendered into, so its storage format // Colour-attachable targets need a colour-renderable fallback; the ordinary
// has to stay colour-renderable; the ordinary fallback for a three-channel // fallback for a three-channel format is another three-channel one, which ES
// format is a three-channel one, which ES accepts as a texture but rejects as // accepts as a texture but never as an attachment. Recompute the fallback per
// multisample storage. Recompute the fallback per target so those formats get // target so those formats get widened where the target demands it.
// widened here and nowhere else. const Flags<PixelFormatNormalizeOptionBit> renderTargetOptions =
Flags<PixelFormatNormalizeOptionBit> targetOptions; TextureImpl::GetRenderTargetNormalizeOptions(capabilities, targetIndex);
if (IsGLESProbeMultisampleTarget(target)) { // Multisample storage has no three-channel form on ES at all, so its widening
targetOptions |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget; // is unconditional and skips the native probe (which cannot succeed). Every
if (!capabilities.SupportsRenderSnorm || !capabilities.SupportsNorm16Texture) { // other target keeps the widening on the DRIVER branch, behind the native
targetOptions |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget; // 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; GLESProbeFormatInfo fallbackInfo = outerFallbackInfo;
Bool hasForcedFallback = outerHasForcedFallback; Bool hasForcedFallback = outerHasForcedFallback;
if (targetOptions) { if (renderTargetOptions) {
hasForcedFallback = BuildFallbackProbeFormatInfo( // Folded into the forced options only when a forced fallback already
requestedInternalFormat, forcedOptions | targetOptions, true, fallbackInfo); // applies, so the render-target bits never *create* one: ANGLE's forced
if (!hasForcedFallback) { // GL_RGB8_SNORM -> GL_RGB16F is still three-channel and still needs
BuildFallbackProbeFormatInfo(requestedInternalFormat, driverOptions | targetOptions, false, // 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); 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(); const SizeT renderbufferTargetIndex = GetRenderbufferFormatCapabilityTargetIndex();
Bool shouldProbeFallbackRenderbuffer = outerHasForcedFallback; // A renderbuffer exists only to be attached, so it needs the same three-channel
if (!outerHasForcedFallback) { // 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 = const Bool nativeRenderbufferComplete =
ProbeRenderbuffer(gl, nativeInfo.InternalFormat, logicalFormat, false, 1); ProbeRenderbuffer(gl, nativeInfo.InternalFormat, logicalFormat, false, 1);
if (nativeRenderbufferComplete) { if (nativeRenderbufferComplete) {
@@ -632,16 +685,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
shouldProbeFallbackRenderbuffer = true; shouldProbeFallbackRenderbuffer = true;
} }
} }
if (shouldProbeFallbackRenderbuffer && outerFallbackInfo.InternalFormat != GL_UNKNOWN_MGL && if (shouldProbeFallbackRenderbuffer && renderbufferFallbackInfo.InternalFormat != GL_UNKNOWN_MGL &&
ProbeRenderbuffer(gl, outerFallbackInfo.InternalFormat, logicalFormat, false, 1)) { ProbeRenderbuffer(gl, renderbufferFallbackInfo.InternalFormat, logicalFormat, false, 1)) {
if (AddCaveatFormatCaps(cache, renderbufferTargetIndex, formatIndex, if (AddCaveatFormatCaps(cache, renderbufferTargetIndex, formatIndex,
GetRenderbufferFeatureCaps(logicalFormat))) { GetRenderbufferFeatureCaps(logicalFormat))) {
LogGLESFormatCaveat(logicalFormat, renderbufferTargetIndex, outerFallbackInfo); LogGLESFormatCaveat(logicalFormat, renderbufferTargetIndex, renderbufferFallbackInfo);
} }
const Int maxSamples = const Int maxSamples =
GetGLESFormatMaxSamples(capabilities, logicalFormat, outerFallbackInfo.ImageFormat); GetGLESFormatMaxSamples(capabilities, logicalFormat, renderbufferFallbackInfo.ImageFormat);
cache.SampleCounts[renderbufferTargetIndex][formatIndex] = cache.SampleCounts[renderbufferTargetIndex][formatIndex] = ProbeRenderbufferSampleCounts(
ProbeRenderbufferSampleCounts(gl, outerFallbackInfo.InternalFormat, logicalFormat, maxSamples); gl, renderbufferFallbackInfo.InternalFormat, logicalFormat, maxSamples);
} }
} }
} }
@@ -891,6 +944,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
// extension explicitly permits. It is also the only thing that // extension explicitly permits. It is also the only thing that
// exposes glProgramParameteri before GL 4.1. // exposes glProgramParameteri before GL 4.1.
E_GL_ARB_get_program_binary}; 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 // Only advertised when the device driver actually has usable timer queries
// (GL_EXT_disjoint_timer_query plus its entry points) and the // (GL_EXT_disjoint_timer_query plus its entry points) and the
// MOBILEGL_DISABLE_TIMERQUERY escape hatch is off. // MOBILEGL_DISABLE_TIMERQUERY escape hatch is off.
@@ -951,12 +1020,6 @@ namespace MobileGL::MG_Backend::DirectGLES {
funcsTable.GL.GetIntegeri_v = GetIntegeri_v; funcsTable.GL.GetIntegeri_v = GetIntegeri_v;
funcsTable.GL.GetInteger64i_v = GetInteger64i_v; funcsTable.GL.GetInteger64i_v = GetInteger64i_v;
funcsTable.GL.GetProgramiv = GetProgramiv; 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.ShaderStorageBlockBinding = ShaderStorageBlockBinding;
funcsTable.GL.Clear = Clear; funcsTable.GL.Clear = Clear;
funcsTable.GL.ClearBufferfi = ClearBufferfi; 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 GetIntegeri_v(GLenum target, GLuint index, GLint* data);
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data); void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
void GetProgramiv(GLuint program, GLenum pname, GLint* params); void GetProgramiv(GLuint program, GLenum pname, GLint* params);
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params); void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding);
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);
Bool InitWindowSurface(NativeWindowType window); Bool InitWindowSurface(NativeWindowType window);
Bool InitPbufferSurface(EGLint width, EGLint height); Bool InitPbufferSurface(EGLint width, EGLint height);
Bool MakeCurrent(); Bool MakeCurrent();
@@ -182,6 +174,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
namespace XfbImpl { namespace XfbImpl {
Bool AreTransformFeedbacksSupported(); 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 BeginTransformFeedback(GLenum primitiveMode);
void EndTransformFeedback(); void EndTransformFeedback();
void PauseTransformFeedback(); void PauseTransformFeedback();
@@ -191,6 +188,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
void OnBackendContextDestroyed(); void OnBackendContextDestroyed();
} // namespace XfbImpl } // 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::EGLFunctionsTable g_EGLFuncs;
extern MG_External::GLESFunctionsTable g_GLESFuncs; extern MG_External::GLESFunctionsTable g_GLESFuncs;
extern MG_External::GLESCapabilities g_GLESCapabilities; extern MG_External::GLESCapabilities g_GLESCapabilities;
+377 -20
View File
@@ -1869,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, static const void* PrepareNormFloatFallbackUpload(TextureInternalFormat format,
const IntVec3& texelSize, const IntVec3& texelSize,
const void* data, const void* data,
@@ -1914,6 +2062,39 @@ namespace MobileGL::MG_Backend::DirectGLES {
return convertedData.data(); 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 // 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 // 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, // implementation-defined: Adreno rounds to nearest (lossless round trip) but Mali floors,
@@ -2121,9 +2302,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
? textureMipmapObject->MapMipmapData(uploadTarget, level) ? textureMipmapObject->MapMipmapData(uploadTarget, level)
: nullptr; : nullptr;
Vector<Float> convertedUploadData; Vector<Float> convertedUploadData;
const void* uploadData = PrepareNormFloatFallbackUpload( Vector<Uint8> widenedUploadData;
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType, const void* uploadData = PrepareFallbackUpload(
convertedUploadData); textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData,
levelByteSize, glType, convertedUploadData, widenedUploadData);
Vector<Uint8> packedUploadData; Vector<Uint8> packedUploadData;
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize, uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
uploadData, levelByteSize, &glType, packedUploadData); uploadData, levelByteSize, &glType, packedUploadData);
@@ -2253,9 +2435,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget);
auto* pData = textureMipmapObject->MapMipmapData(uploadTarget, level); auto* pData = textureMipmapObject->MapMipmapData(uploadTarget, level);
Vector<Float> convertedUploadData; Vector<Float> convertedUploadData;
const void* uploadData = PrepareNormFloatFallbackUpload( Vector<Uint8> widenedUploadData;
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType, const void* uploadData = PrepareFallbackUpload(
convertedUploadData); textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData,
levelByteSize, glType, convertedUploadData, widenedUploadData);
Vector<Uint8> packedUploadData; Vector<Uint8> packedUploadData;
uploadData = uploadData =
PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize, PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
@@ -2312,9 +2495,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
? textureMipmapObject->MapMipmapData(uploadTarget, level) ? textureMipmapObject->MapMipmapData(uploadTarget, level)
: nullptr; : nullptr;
Vector<Float> convertedUploadData; Vector<Float> convertedUploadData;
const void* uploadData = PrepareNormFloatFallbackUpload( Vector<Uint8> widenedUploadData;
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType, const void* uploadData = PrepareFallbackUpload(
convertedUploadData); textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData,
levelByteSize, glType, convertedUploadData, widenedUploadData);
Vector<Uint8> packedUploadData; Vector<Uint8> packedUploadData;
uploadData = uploadData =
PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize, PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
@@ -2422,9 +2606,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
auto texelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, level); auto texelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, level);
const void* mipData = textureMipmapObject->MapMipmapData(uploadTarget, level); const void* mipData = textureMipmapObject->MapMipmapData(uploadTarget, level);
Vector<Float> convertedUploadData; Vector<Float> convertedUploadData;
const void* uploadData = PrepareNormFloatFallbackUpload( Vector<Uint8> widenedUploadData;
textureMipmapObject->GetFormat(), texelSize, mipData, byteSize, glType, const void* uploadData = PrepareFallbackUpload(
convertedUploadData); textureMipmapObject->GetFormat(), targetInternal, texelSize, mipData, byteSize,
glType, convertedUploadData, widenedUploadData);
Vector<Uint8> packedUploadData; Vector<Uint8> packedUploadData;
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), texelSize, uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), texelSize,
uploadData, byteSize, &glType, packedUploadData); uploadData, byteSize, &glType, packedUploadData);
@@ -2827,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()); 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 // 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 // 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 // without alpha reads back as 1.0. Answer the ALPHA swizzle source with ONE so the
@@ -3139,6 +3324,113 @@ namespace MobileGL::MG_Backend::DirectGLES {
return false; 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() { Bool IsFixedPointFallbackReadAttachment() {
const auto& readFBO = const auto& readFBO =
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject();
@@ -3344,6 +3636,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (asTarget == FramebufferTarget::Draw) { if (asTarget == FramebufferTarget::Draw) {
Uint32 snormClampOutputMask = 0; Uint32 snormClampOutputMask = 0;
Uint32 unormClampOutputMask = 0; Uint32 unormClampOutputMask = 0;
Uint32 alphaWidenedMask = 0;
Uint32 integerColorMask = 0;
for (Uint i = 0; i < FramebufferObject::MAX_DRAW_BUFFERS && i < 32; ++i) { for (Uint i = 0; i < FramebufferObject::MAX_DRAW_BUFFERS && i < 32; ++i) {
const auto frontendBuf = stateDrawBuffers[i]; const auto frontendBuf = stateDrawBuffers[i];
if (frontendBuf < FramebufferAttachmentType::Color0 || if (frontendBuf < FramebufferAttachmentType::Color0 ||
@@ -3356,9 +3650,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
} else if (IsUnormFallbackAttachment(attachmentObject)) { } else if (IsUnormFallbackAttachment(attachmentObject)) {
unormClampOutputMask |= (1u << i); 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_snormFallbackClampOutputMask = snormClampOutputMask;
PrgramImpl::g_unormFallbackClampOutputMask = unormClampOutputMask; PrgramImpl::g_unormFallbackClampOutputMask = unormClampOutputMask;
g_alphaWidenedDrawBufferMask = alphaWidenedMask;
g_integerColorDrawBufferMask = integerColorMask;
} }
// 2. Remap read buffer. glReadBuffer writes the READ-bound FBO's state, so // 2. Remap read buffer. glReadBuffer writes the READ-bound FBO's state, so
@@ -3812,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( void BackendProgramObjectImpl::SyncToBackend(
const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject) { const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject) {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
@@ -3846,9 +4180,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (attachedCount > 0) { if (attachedCount > 0) {
Vector<GLuint> attachedShaders(attachedCount); 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, g_GLESFuncs.glGetAttachedShaders(m_backendProgramId, attachedCount, &actualCount,
attachedShaders.data()); 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); MGLOG_D("Detaching %d existing shaders from program %u", actualCount, m_backendProgramId);
for (GLsizei i = 0; i < actualCount; ++i) { for (GLsizei i = 0; i < actualCount; ++i) {
@@ -3986,13 +4327,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_GLESFuncs.glShaderSource(backendShaderId, 1, &sourceCStr, nullptr); g_GLESFuncs.glShaderSource(backendShaderId, 1, &sourceCStr, nullptr);
g_GLESFuncs.glCompileShader(backendShaderId); 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); g_GLESFuncs.glGetShaderiv(backendShaderId, GL_COMPILE_STATUS, &compileStatus);
if (compileStatus == GL_FALSE) { if (compileStatus == GL_FALSE) {
GLint logLength; GLint logLength = 0;
g_GLESFuncs.glGetShaderiv(backendShaderId, GL_INFO_LOG_LENGTH, &logLength); 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()); g_GLESFuncs.glGetShaderInfoLog(backendShaderId, logLength, nullptr, log.data());
log.back() = '\0';
MGLOG_E("Shader compilation failed for backend ID %u: %s", backendShaderId, log.data()); MGLOG_E("Shader compilation failed for backend ID %u: %s", backendShaderId, log.data());
m_backendProgramUsable = false; m_backendProgramUsable = false;
continue; continue;
@@ -4028,14 +4377,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
MGLOG_D("Linking program %u", m_backendProgramId); MGLOG_D("Linking program %u", m_backendProgramId);
g_GLESFuncs.glLinkProgram(m_backendProgramId); g_GLESFuncs.glLinkProgram(m_backendProgramId);
GLint linkStatus; GLint linkStatus = GL_FALSE;
g_GLESFuncs.glGetProgramiv(m_backendProgramId, GL_LINK_STATUS, &linkStatus); g_GLESFuncs.glGetProgramiv(m_backendProgramId, GL_LINK_STATUS, &linkStatus);
m_backendProgramUsable = m_backendProgramUsable && linkStatus == GL_TRUE; m_backendProgramUsable = m_backendProgramUsable && linkStatus == GL_TRUE;
if (linkStatus != GL_TRUE) { if (linkStatus != GL_TRUE) {
GLint logLength; GLint logLength = 0;
g_GLESFuncs.glGetProgramiv(m_backendProgramId, GL_INFO_LOG_LENGTH, &logLength); 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()); g_GLESFuncs.glGetProgramInfoLog(m_backendProgramId, logLength, nullptr, log.data());
log.back() = '\0';
MGLOG_E("Program %u linking failed for %u: %s", stateProgramObject->GetExternalIndex(), MGLOG_E("Program %u linking failed for %u: %s", stateProgramObject->GetExternalIndex(),
m_backendProgramId, log.data()); m_backendProgramId, log.data());
} else { } else {
@@ -4068,6 +4419,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
CacheResourceLocations(stateProgramObject); 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_syncedLinkVersion = stateProgramObject->GetLinkVersion();
m_isInitialized = true; m_isInitialized = true;
+150
View File
@@ -36,6 +36,53 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool InProcessTeardown(); Bool InProcessTeardown();
void EnsureProcessTeardownSentinel(); 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> template <typename StateObject, typename BackendObject>
class StateBackendObjectRegistry { class StateBackendObjectRegistry {
public: public:
@@ -468,6 +515,25 @@ namespace MobileGL::MG_Backend::DirectGLES {
return target == TextureTarget::Texture3D || target == TextureTarget::TextureCubeMap; 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 struct StateTextureBasicInfo { // Used for tracking texture state changes
TextureInternalFormat internalFormat = TextureInternalFormat::Unknown; TextureInternalFormat internalFormat = TextureInternalFormat::Unknown;
SizeT width = 0; SizeT width = 0;
@@ -583,6 +649,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
SharedPtr<BackendTextureObject>& SyncTextureObjectToBackend( SharedPtr<BackendTextureObject>& SyncTextureObjectToBackend(
const SharedPtr<MG_State::GLState::ITextureObject>& textureObject, const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
Bool imageBindableStorageRequired = false); 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>, extern Array<Array<BackendTextureObject*, (SizeT)TextureTarget::TextureTargetCount>,
MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS> MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
g_boundTexturesCache; g_boundTexturesCache;
@@ -663,6 +733,67 @@ namespace MobileGL::MG_Backend::DirectGLES {
// has to apply the clamp itself. // has to apply the clamp itself.
Bool IsFixedPointFallbackReadAttachment(); 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) // 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 // triple; it re-syncs unless all three still match. Stamped by SyncCurrentFBO and
// ForceBindCurrentFBO, cleared by InvalidateFramebufferBindingCache. The three are // ForceBindCurrentFBO, cleared by InvalidateFramebufferBindingCache. The three are
@@ -866,6 +997,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
void SetBaseInstance(Uint32 baseInstance) const; void SetBaseInstance(Uint32 baseInstance) const;
void SetBaseInstanceWordIndex(Int32 wordIndex) const; void SetBaseInstanceWordIndex(Int32 wordIndex) const;
void SetDrawID(Uint32 drawId) 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; } Int GetIndirectParamsBinding() const { return m_indirectParamsBinding; }
Uint GetBackendProgramId() const { return m_backendProgramId; } Uint GetBackendProgramId() const { return m_backendProgramId; }
// False when the last SyncToBackend could not produce a usable program (a // False when the last SyncToBackend could not produce a usable program (a
@@ -931,6 +1065,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
extern Uint g_lastUsedBackendProgramId; extern Uint g_lastUsedBackendProgramId;
extern StateBackendObjectRegistry<MG_State::GLState::ProgramObject, BackendProgramObjectImpl> extern StateBackendObjectRegistry<MG_State::GLState::ProgramObject, BackendProgramObjectImpl>
g_backendProgramObjects; 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 PrgramImpl
namespace SamplerImpl { 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); 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, Bool HasCachedFormatCapability(TextureInternalFormat internalFormat,
SizeT targetIndex, SizeT targetIndex,
Bool caveat, Bool caveat,
@@ -141,14 +118,61 @@ namespace MobileGL::MG_Backend::DirectGLES {
const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat); const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
Flags<PixelFormatNormalizeOptionBit> options; Flags<PixelFormatNormalizeOptionBit> options;
if (!pActiveBackendObject || ShouldUseCaveatFormat(internalFormat, targetIndex)) { if (!pActiveBackendObject || ShouldUseCaveatFormat(internalFormat, targetIndex)) {
options = GetRuntimeFallbackNormalizeOptions(requestedInternalFormat, options = GetRuntimeFallbackNormalizeOptions(
GetRenderTargetNormalizeOptions(targetIndex)); requestedInternalFormat,
TextureImpl::GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
} }
NormalizePixelFormat(requestedInternalFormat, options, outInternalFormat, outFormat, outType); NormalizePixelFormat(requestedInternalFormat, options, outInternalFormat, outFormat, outType);
} }
} // namespace } // namespace
namespace TextureImpl { 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, void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
GLenum* outFormat, GLenum* outType, TextureTarget target) { GLenum* outFormat, GLenum* outType, TextureTarget target) {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
@@ -178,9 +202,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
return ShouldUseCaveatFormat(internalFormat, GetRenderbufferFormatCapabilityTargetIndex()); return ShouldUseCaveatFormat(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
} }
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target) { namespace {
const SizeT targetIndex = Bool BackendFormatAddsAlpha(TextureInternalFormat internalFormat, SizeT targetIndex) {
target == TextureTarget::Unknown ? kFormatCapabilityTargetCount : GetFormatCapabilityTargetIndex(target);
if (!TargetRequiresRenderableFormat(targetIndex)) { if (!TargetRequiresRenderableFormat(targetIndex)) {
return false; return false;
} }
@@ -188,11 +211,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
return false; return false;
} }
const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat); const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
const Flags<PixelFormatNormalizeOptionBit> options = const Flags<PixelFormatNormalizeOptionBit> options = GetRuntimeFallbackNormalizeOptions(
GetRuntimeFallbackNormalizeOptions(requestedInternalFormat, requestedInternalFormat, GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
GetRenderTargetNormalizeOptions(targetIndex));
return static_cast<Bool>(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget); 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 TextureImpl
namespace PrgramImpl { namespace PrgramImpl {
String ProcessOutColorLocations(const String& glslCode) { String ProcessOutColorLocations(const String& glslCode) {
+17 -3
View File
@@ -9,6 +9,8 @@
#pragma once #pragma once
#include <Includes.h> #include <Includes.h>
#include <MG_State/GLState/Core.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 MobileGL::MG_Backend::DirectGLES {
namespace DebugImpl { namespace DebugImpl {
@@ -34,6 +36,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
} // namespace VertexArrayImpl } // namespace VertexArrayImpl
namespace TextureImpl { 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, void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
GLenum* outFormat, GLenum* outType, GLenum* outFormat, GLenum* outType,
TextureTarget target = TextureTarget::Unknown); TextureTarget target = TextureTarget::Unknown);
@@ -41,10 +53,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
GLenum* outFormat, GLenum* outType); GLenum* outFormat, GLenum* outType);
Bool ShouldUseCaveatTextureFormat(TextureInternalFormat internalFormat, TextureTarget target); Bool ShouldUseCaveatTextureFormat(TextureInternalFormat internalFormat, TextureTarget target);
// True when the format the texture is actually created with has an alpha channel the // True when the format the image is actually created with has an alpha channel the
// frontend format does not (the three-channel multisample widening). GL reads such a // frontend format does not (the three-channel colour-renderable widening). GL reads such
// channel back as 1.0, so any swizzle source of ALPHA has to be answered with ONE. // 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 BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target);
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat);
Bool ShouldUseCaveatRenderbufferFormat(TextureInternalFormat internalFormat); Bool ShouldUseCaveatRenderbufferFormat(TextureInternalFormat internalFormat);
} // namespace TextureImpl } // namespace TextureImpl
@@ -16,6 +16,7 @@
#include "MG_Util/Converters/MGToStr/TextureEnumConverter.h" #include "MG_Util/Converters/MGToStr/TextureEnumConverter.h"
#include "MG_Util/Converters/MGToVk/TextureEnumConverter.h" #include "MG_Util/Converters/MGToVk/TextureEnumConverter.h"
#include "MG_Util/Texture/TextureFormatProcessor.h" #include "MG_Util/Texture/TextureFormatProcessor.h"
#include "MG_Util/Async/ShaderCompilePool.h"
#include <Config.h> #include <Config.h>
#include <cmath> #include <cmath>
@@ -523,6 +524,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (shaderSubgroupSupported && !MG_Config::Features.DisableSubgroup) { if (shaderSubgroupSupported && !MG_Config::Features.DisableSubgroup) {
extensions.push_back(E_GL_KHR_shader_subgroup); 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); // 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 // only advertised when the device actually supports timestamp queries and the
// MOBILEGL_DISABLE_TIMERQUERY escape hatch is off. // MOBILEGL_DISABLE_TIMERQUERY escape hatch is off.
@@ -601,12 +617,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
funcsTable.GL.GetIntegeri_v = GetIntegeri_v; funcsTable.GL.GetIntegeri_v = GetIntegeri_v;
funcsTable.GL.GetInteger64i_v = GetInteger64i_v; funcsTable.GL.GetInteger64i_v = GetInteger64i_v;
funcsTable.GL.GetProgramiv = GetProgramiv; 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.ShaderStorageBlockBinding = ShaderStorageBlockBinding;
funcsTable.GL.FenceSync = FenceSync; funcsTable.GL.FenceSync = FenceSync;
funcsTable.GL.ClientWaitSync = ClientWaitSync; funcsTable.GL.ClientWaitSync = ClientWaitSync;
+32 -468
View File
@@ -232,6 +232,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
StorageBlockResource block{}; StorageBlockResource block{};
block.name = blockName; block.name = blockName;
block.binding = binding->binding; 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); block.dataSize = static_cast<GLint>(binding->block.size);
const GLuint blockIndex = static_cast<GLuint>(cache.storageBlocks.size()); const GLuint blockIndex = static_cast<GLuint>(cache.storageBlocks.size());
AddBufferVariablesRecursive(binding->block, blockName, blockIndex, cache.bufferVariables, AddBufferVariablesRecursive(binding->block, blockName, blockIndex, cache.bufferVariables,
@@ -256,18 +263,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return programObject.get(); 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) { const Uint8* ResolveIndirectCommandBytes(const void* indirect, SizeT requiredBytes, const char* label) {
auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();
if (drawBuffer) { if (drawBuffer) {
@@ -288,100 +283,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return reinterpret_cast<const Uint8*>(indirect); 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 } // namespace
void ClearProgramResourceCaches() { void ClearProgramResourceCaches() {
@@ -395,11 +296,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
GLuint GetShaderStorageBlockIndex(const MG_State::GLState::ProgramObject& program, const String& name) { GLuint GetShaderStorageBlockIndex(const MG_State::GLState::ProgramObject& program, const String& name) {
auto& cache = GetProgramResourceCache(program); auto& cache = GetProgramResourceCache(program);
const auto it = std::find_if(cache.storageBlocks.begin(), cache.storageBlocks.end(), auto find = [&cache](const String& key) {
[&](const StorageBlockResource& block) { return block.name == name; }); return std::find_if(cache.storageBlocks.begin(), cache.storageBlocks.end(),
return it == cache.storageBlocks.end() [&](const StorageBlockResource& block) { return block.name == key; });
? GL_INVALID_INDEX };
: static_cast<GLuint>(std::distance(cache.storageBlocks.begin(), it)); 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) { 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) { void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding) {
if (!params) return;
auto* programObject = TryGetDirectVulkanProgram(program); auto* programObject = TryGetDirectVulkanProgram(program);
if (!programObject) return; if (!programObject || storageBlockName == nullptr) 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);
const Int maxBindings = pActiveBackendObject const Int maxBindings = pActiveBackendObject
? pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings ? pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings
: 0; : 0;
@@ -1234,13 +797,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MakeUnique<GenericErrorInfo>("DirectVulkan", __func__, "Shader storage binding is out of range.")); MakeUnique<GenericErrorInfo>("DirectVulkan", __func__, "Shader storage binding is out of range."));
return; return;
} }
if (storageBlockIndex >= cache.storageBlocks.size()) { // The frontend already validated that the name denotes an active block, and has
MG_State::pGLContext->RecordError( // already recorded the new binding on the program - which is what reseeds this cache
ErrorCode::InvalidValue, // whenever it is rebuilt. Writing the entry here as well keeps an ALREADY-BUILT cache
MakeUnique<GenericErrorInfo>("DirectVulkan", __func__, "Shader storage block index is not active.")); // (the common case: the very next draw reads it) from having to be thrown away.
return; auto& cache = GetProgramResourceCache(*programObject);
} const GLuint blockIndex = GetShaderStorageBlockIndex(*programObject, storageBlockName);
cache.storageBlocks[storageBlockIndex].binding = storageBlockBinding; 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) { 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"); MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ReadPixels called with null VulkanRenderer");
@@ -97,15 +97,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
void GetIntegeri_v(GLenum target, GLuint index, GLint* data); void GetIntegeri_v(GLenum target, GLuint index, GLint* data);
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data); void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
void GetProgramiv(GLuint program, GLenum pname, GLint* params); void GetProgramiv(GLuint program, GLenum pname, GLint* params);
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params); void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding);
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 ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels); void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels);
void GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels); void GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels);
void GetTextureImage(const SharedPtr<MG_State::GLState::ITextureObject>& texture, TextureUploadTarget uploadTarget, void GetTextureImage(const SharedPtr<MG_State::GLState::ITextureObject>& texture, TextureUploadTarget uploadTarget,
@@ -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.IsBgra, sizeof(attr.IsBgra)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Divisor, sizeof(attr.Divisor))); 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 // The bound buffer's IDENTITY is a component of the key, and it has to be the
// buffer's reused address can alias an old cache entry, but only under a // buffer's never-reused lifetime id - NOT its heap address, which this used to
// byte-identical attribute layout - and the entry payload is a pure function // hash. An address is recycled by the allocator, so a deleted-and-recreated
// of the hashed inputs, with the draw path re-resolving bindingBufferKeys // buffer reproduces it; combined with a byte-identical attribute layout that
// against the live VAO attribute pointers, so an aliased hit returns exactly // reproduces the WHOLE content hash, and the hash is what
// what a rebuild would. Address drift only grows the map; the OnFrameBoundary // TryBindResolvedVertexBindings accepts as proof that a memoised binding still
// aging sweep bounds that. // reads the buffer it was resolved from. It did not: a destroyed buffer's GPU
const SizeT bufferKey = reinterpret_cast<SizeT>(attr.Buffer.get()); // 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))); XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey)));
} }
@@ -28,11 +28,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
struct BackendVertexInputState { struct BackendVertexInputState {
HashType hash = 0; HashType hash = 0;
// Hash of the resolved Vulkan vertex layout only (bindings, attributes, // Hash of the resolved Vulkan vertex layout only (bindings, attributes,
// unsupported mask) - NO buffer identities. `hash` mixes buffer heap // unsupported mask) - NO buffer identities. `hash` mixes each bound
// addresses so per-chunk VBOs mint a fresh identity per buffer; keying // buffer's never-reused LIFETIME ID, so per-chunk VBOs mint a fresh
// pipelines on that minted one VkPipeline per chunk section for an // identity per buffer; keying pipelines on that minted one VkPipeline per
// identical layout, defeating pipeline reuse and the per-draw memo. // chunk section for an identical layout, defeating pipeline reuse and the
// Pipelines depend only on the layout, so they key on this instead. // per-draw memo. Pipelines depend only on the layout, so they key on this
// instead.
HashType layoutHash = 0; HashType layoutHash = 0;
// Frame boundary of the last cache hit; entries idle past the // Frame boundary of the last cache hit; entries idle past the
// OnFrameBoundary retirement age are evicted (CPU heap only). // 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 MG_State::GLState::VertexArrayObject& vao, HashType hash);
const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao); const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao);
// Frame boundary hook: ages the cache and evicts entries not hit for many // 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 // frames. The key mixes each bound buffer's never-reused lifetime id, so
// minting fresh keys; without eviction the map grows for the whole session. // 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) // Entries hold no Vulkan handles (pipeline creation copies the descriptions)
// and the draw path's entry reference never spans a frame boundary, so // 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 // eviction here needs no GPU-idle proof. Self-gated: one counter bump and
@@ -3201,12 +3201,17 @@ void main() {
// carry the most entropy of a multiply. // carry the most entropy of a multiply.
const Uint64 mixed = static_cast<Uint64>(reinterpret_cast<SizeT>(vao) >> 4) * 0x9E3779B97F4A7C15ull; const Uint64 mixed = static_cast<Uint64>(reinterpret_cast<SizeT>(vao) >> 4) * 0x9E3779B97F4A7C15ull;
const Uint32 index = static_cast<Uint32>(mixed >> 32) & (kVaoDrawMemoSlotCount - 1); 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]; VaoDrawMemo& first = m_vaoDrawMemoTable[index];
if (first.vaoKey == vao) { if (first.vaoKey == vao && first.vaoLifetimeId == lifetimeId) {
return &first; return &first;
} }
VaoDrawMemo& second = m_vaoDrawMemoTable[index ^ 1u]; VaoDrawMemo& second = m_vaoDrawMemoTable[index ^ 1u];
if (second.vaoKey == vao) { if (second.vaoKey == vao && second.vaoLifetimeId == lifetimeId) {
return &second; return &second;
} }
// Miss: recycle a slot. Prefer an empty one; otherwise evict the entry whose // Miss: recycle a slot. Prefer an empty one; otherwise evict the entry whose
@@ -3217,6 +3222,7 @@ void main() {
victim = &second; victim = &second;
} }
victim->vaoKey = vao; victim->vaoKey = vao;
victim->vaoLifetimeId = lifetimeId;
victim->contentHash = 0; victim->contentHash = 0;
victim->layoutFactsValid = false; victim->layoutFactsValid = false;
// Unmatchable until a resolve completes (same rule as before: a bailed-out // Unmatchable until a resolve completes (same rule as before: a bailed-out
@@ -4475,9 +4481,10 @@ void main() {
// vertex-input hash (VAO layout), render-pass hash (render targets + the draw-buffer/format // 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). // 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. // 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 // The identity hash mixes each bound buffer's never-reused lifetime id
// one per buffer); the memo and the pipeline payload key on the resolved // (per-chunk VBOs mint a new one per buffer); the memo and the pipeline
// LAYOUT hash instead, so draws over identical layouts share one 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). // The one-arg fetch rides the VAO's state-pointer memo (no hash, no map).
auto& vis = m_vertexInputStateFactory->GetOrCreateVertexInputState(vao); auto& vis = m_vertexInputStateFactory->GetOrCreateVertexInputState(vao);
const Uint64 vertexLayoutHash = vis.layoutHash; const Uint64 vertexLayoutHash = vis.layoutHash;
@@ -5265,7 +5272,8 @@ void main() {
// path, re-resolving descriptors and texture layouts nothing invalidated. // path, re-resolving descriptors and texture layouts nothing invalidated.
const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();
const Bool vaoMoved = 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 = const auto& drawFbo =
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
if (static_cast<const void*>(drawFbo.get()) != snap.drawFbo || if (static_cast<const void*>(drawFbo.get()) != snap.drawFbo ||
@@ -5334,9 +5342,11 @@ void main() {
// VAO's content-hash memo. The hash memo shares the cache line this compare // 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 // 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 // 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 // of every object in a VAO-cycling frame. The slot only ever answers for
// the content hash, so a slot whose contentHash equals the live memoised // THIS object: LookupVaoDrawMemo matches (address, lifetime id), so a slot
// hash serves them for ANY VAO object, recycled addresses included. // 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; Uint64 auxMasks = 0;
Bool factsKnown = false; Bool factsKnown = false;
Uint64 contentHash = 0; Uint64 contentHash = 0;
@@ -5515,6 +5525,7 @@ void main() {
snap.renderStateVersion = renderStateVersion; snap.renderStateVersion = renderStateVersion;
snap.bindGeneration = bindGeneration; snap.bindGeneration = bindGeneration;
snap.vao = static_cast<const void*>(&vao); snap.vao = static_cast<const void*>(&vao);
snap.vaoLifetimeId = vao.GetLifetimeId();
snap.vaoConfigVersion = vao.GetConfigVersion(); snap.vaoConfigVersion = vao.GetConfigVersion();
snap.vaoLayoutHash = vaoLayoutHash; snap.vaoLayoutHash = vaoLayoutHash;
snap.pipeline = pipeline; snap.pipeline = pipeline;
@@ -5933,6 +5944,7 @@ void main() {
snap.programLifetimeId = program.GetLifetimeId(); snap.programLifetimeId = program.GetLifetimeId();
snap.programVersion = program.GetBackendStateVersion(); snap.programVersion = program.GetBackendStateVersion();
snap.vao = &vao; snap.vao = &vao;
snap.vaoLifetimeId = vao.GetLifetimeId();
snap.vaoConfigVersion = vao.GetConfigVersion(); snap.vaoConfigVersion = vao.GetConfigVersion();
snap.drawFbo = drawFbo.get(); snap.drawFbo = drawFbo.get();
snap.fboVersion = drawFbo->GetObjectVersion(); snap.fboVersion = drawFbo->GetObjectVersion();
@@ -758,6 +758,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint64 programLifetimeId = 0; Uint64 programLifetimeId = 0;
Uint32 programVersion = 0; Uint32 programVersion = 0;
const void* vao = nullptr; 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; Uint32 vaoConfigVersion = 0;
const void* drawFbo = nullptr; const void* drawFbo = nullptr;
Uint16 fboVersion = 0; Uint16 fboVersion = 0;
@@ -987,19 +993,30 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const MG_State::GLState::BufferObject* buffers[kMaxBindings] = {}; const MG_State::GLState::BufferObject* buffers[kMaxBindings] = {};
Uint64 sliceEpochs[kMaxBindings] = {}; Uint64 sliceEpochs[kMaxBindings] = {};
}; };
// One direct-mapped slot of the per-VAO draw-memo table below. The key is a // One direct-mapped slot of the per-VAO draw-memo table below. A slot belongs to
// lookup hint only - a slot is never dereferenced through vaoKey; every fact it // the object whose (vaoKey, vaoLifetimeId) pair it carries: the address alone
// carries is validated against live state before use: // 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 // - 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 // 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 // change or a buffer rebind misses even for the same object.
// 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).
// - bindings revalidates per draw exactly as before (frame serial, content // - bindings revalidates per draw exactly as before (frame serial, content
// hash, per-binding live buffer pointers and slice epochs). // hash, per-binding live buffer pointers and slice epochs).
struct alignas(64) VaoDrawMemo { struct alignas(64) VaoDrawMemo {
const MG_State::GLState::VertexArrayObject* vaoKey = nullptr; 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 // The VAO content hash (VertexInputStateFactory::GetOrComputeHash) the two
// layout facts below were derived from; 0 while nothing valid is stored. // layout facts below were derived from; 0 while nothing valid is stored.
Uint64 contentHash = 0; Uint64 contentHash = 0;
+91 -17
View File
@@ -9,6 +9,7 @@
#include "GL_Buffer.h" #include "GL_Buffer.h"
#include "Validators.h" #include "Validators.h"
#include "../Texture/GL_Texture.h" #include "../Texture/GL_Texture.h"
#include "../Getter/GL_Getter.h"
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h> #include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
#include <MG_Util/Metrics/TextureMetrics.h> #include <MG_Util/Metrics/TextureMetrics.h>
#include <Config.h> #include <Config.h>
@@ -861,6 +862,10 @@ namespace MobileGL::MG_Impl::GLImpl {
Range1D mappedRange = bufferObject->GetMappedRange(); Range1D mappedRange = bufferObject->GetMappedRange();
auto mappingAccess = bufferObject->GetMappingAccess(); 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) && if (bufferObject->IsMapped() && !(mappingAccess & BufferMappingAccessBit::Persistent) &&
(offset < mappedRange.end) && (offset + size > mappedRange.start)) { (offset < mappedRange.end) && (offset + size > mappedRange.start)) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
@@ -871,18 +876,6 @@ namespace MobileGL::MG_Impl::GLImpl {
return; 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); 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) { 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) { if (size <= 0) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, ErrorCode::InvalidValue,
@@ -1021,8 +1019,6 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
if (!ValidateStorageFlags(flags, BufferOp::BufferStorage)) return; if (!ValidateStorageFlags(flags, BufferOp::BufferStorage)) return;
auto bufferObject = GetBoundBufferObject(target, BufferOp::BufferStorage);
if (!bufferObject) return;
if (bufferObject->IsImmutableStorage()) { if (bufferObject->IsImmutableStorage()) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation, ErrorCode::InvalidOperation,
@@ -1057,6 +1053,10 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
void NamedBufferStorage_State(GLuint buffer, GLsizeiptr size, const void* data, GLbitfield flags) { 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) { if (size <= 0) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, ErrorCode::InvalidValue,
@@ -1065,8 +1065,6 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
if (!ValidateStorageFlags(flags, BufferOp::NamedBufferStorage)) return; if (!ValidateStorageFlags(flags, BufferOp::NamedBufferStorage)) return;
auto bufferObject = GetNamedBufferObject(buffer, BufferOp::NamedBufferStorage);
if (!bufferObject) return;
if (bufferObject->IsImmutableStorage()) { if (bufferObject->IsImmutableStorage()) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation, ErrorCode::InvalidOperation,
@@ -1486,12 +1484,71 @@ namespace MobileGL::MG_Impl::GLImpl {
GetBufferBindingSlot(bufferTarget).Bind(bufferObject); 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) { 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__, MGLOG_D("%s: target = %s, index = %u, buffer = %u, offset = %d, size = %d", __func__,
MG_Util::ConvertGLEnumToString(target).c_str(), index, buffer, offset, size); MG_Util::ConvertGLEnumToString(target).c_str(), index, buffer, offset, size);
BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target); BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target);
if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return; if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return;
if (!BufferImpl::ValidateBufferBindingPointIndex(bufferTarget, index)) return; if (!BufferImpl::ValidateBufferBindingPointIndex(bufferTarget, index)) return;
if (buffer != 0 && !ValidateBufferRangeOffsetAndSize(target, offset, size, __func__)) return;
if (bufferTarget == BufferTarget::TransformFeedback && MG_State::pGLContext->IsTransformFeedbackActive()) { if (bufferTarget == BufferTarget::TransformFeedback && MG_State::pGLContext->IsTransformFeedbackActive()) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation, ErrorCode::InvalidOperation,
@@ -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 // 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) { 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) { for (GLsizei i = 0; i < count; ++i) {
BindBufferBase_State(target, first + i, buffers ? buffers[i] : 0); 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, void BindBuffersRange(GLenum target, GLuint first, GLsizei count, const GLuint* buffers, const GLintptr* offsets,
const GLsizeiptr* sizes) { const GLsizeiptr* sizes) {
if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return;
for (GLsizei i = 0; i < count; ++i) { for (GLsizei i = 0; i < count; ++i) {
if (!buffers || buffers[i] == 0) { if (!buffers || buffers[i] == 0) {
BindBufferBase_State(target, first + i, 0); BindBufferBase_State(target, first + i, 0);
+29 -1
View File
@@ -53,7 +53,9 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
return true; 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); SizeT pointCount = MG_State::pGLContext->GetBufferBindingPointCount(target);
if (target == BufferTarget::ShaderStorage && MG_Backend::pActiveBackendObject) { if (target == BufferTarget::ShaderStorage && MG_Backend::pActiveBackendObject) {
const Int backendCount = const Int backendCount =
@@ -65,6 +67,32 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
// binding points in GL 3.3 (no ARB_transform_feedback3). // binding points in GL 3.3 (no ARB_transform_feedback3).
pointCount = std::min<SizeT>(pointCount, 4); 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) { if (index < pointCount) {
return true; return true;
@@ -17,4 +17,8 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
Bool ValidateBufferMappingAccess(Flags<BufferMappingAccessBit> accessBits); Bool ValidateBufferMappingAccess(Flags<BufferMappingAccessBit> accessBits);
Bool ValidateBufferBindingPointTarget(BufferTarget target); Bool ValidateBufferBindingPointTarget(BufferTarget target);
Bool ValidateBufferBindingPointIndex(BufferTarget target, Uint index); 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 } // namespace MobileGL::MG_Impl::GLImpl::BufferImpl
@@ -474,6 +474,21 @@ namespace MobileGL::MG_Impl::GLImpl {
return; return;
} }
if (!ValidateCurrentProgramForCompute(__func__)) 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); dispatchCompute(numGroupsX, numGroupsY, numGroupsZ);
} }
@@ -487,6 +502,24 @@ namespace MobileGL::MG_Impl::GLImpl {
return; return;
} }
if (!ValidateCurrentProgramForCompute(__func__)) 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); 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, 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, 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, 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_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_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, 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, 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, DrawElementsInstancedBaseInstance, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseInstance, mode, count, type, indices, instancecount, baseinstance)
DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseVertexBaseInstance, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLint basevertex, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseVertexBaseInstance, mode, count, type, indices, instancecount, basevertex, baseinstance) DECLARE_GL_FUNCTION_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, 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, 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, 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, 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, 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) 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, 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, 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, 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, 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, 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) 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; // `capabilityTargetIndex` is the row of the cache the attachment actually lives in;
// kFormatCapabilityTargetCount asks about the format in general. Asking per target matters // 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 // 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* // decides each target's substitution against that target's own probe, and a buffer texture
// storage, and a format that survives only through that substitution is still texture-only // never gets one at all. This is also where the three-channel widening becomes visible to
// on every ordinary target. // 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) { Bool IsColorInternalFormatRenderable(TextureInternalFormat format, SizeT capabilityTargetIndex) {
const SizeT formatIndex = static_cast<SizeT>(format); const SizeT formatIndex = static_cast<SizeT>(format);
if (MG_Backend::pActiveBackendObject && formatIndex < MG_Backend::kFormatCapabilityFormatCount) { 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_Util/Converters/MGToGL/RenderStateEnumConverter.h>
#include <MG_State/GLState/FramebufferState/FramebufferObject.h> #include <MG_State/GLState/FramebufferState/FramebufferObject.h>
#include <MG_Util/Texture/TextureFormatProcessor.h> #include <MG_Util/Texture/TextureFormatProcessor.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Backend/BackendObjects.h> #include <MG_Backend/BackendObjects.h>
namespace MobileGL::MG_Impl::GLImpl { namespace MobileGL::MG_Impl::GLImpl {
@@ -50,6 +51,14 @@ namespace MobileGL::MG_Impl::GLImpl {
constexpr GLint kFrontendMaxTessControlAtomicCounters = 0; constexpr GLint kFrontendMaxTessControlAtomicCounters = 0;
constexpr GLint kFrontendMaxTessEvaluationAtomicCounters = 0; constexpr GLint kFrontendMaxTessEvaluationAtomicCounters = 0;
constexpr GLint kFrontendMaxVertexAtomicCounters = 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 kFrontendMaxVertexUniformComponents = 4096;
constexpr GLint kFrontendMaxVertexUniformVectors = 128; constexpr GLint kFrontendMaxVertexUniformVectors = 128;
constexpr GLint kFrontendMaxVertexUniformBlocks = 14; constexpr GLint kFrontendMaxVertexUniformBlocks = 14;
@@ -263,6 +272,60 @@ namespace MobileGL::MG_Impl::GLImpl {
return true; 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) { void CopyIntsToBooleans(const GLint* src, SizeT count, GLboolean* dst) {
for (SizeT i = 0; i < count; ++i) { for (SizeT i = 0; i < count; ++i) {
dst[i] = src[i] ? GL_TRUE : GL_FALSE; 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) { 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 // 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). // binding point, not by attribute (GL 4.6 core 10.3.1).
case GL_VERTEX_BINDING_BUFFER: case GL_VERTEX_BINDING_BUFFER:
@@ -782,6 +875,46 @@ namespace MobileGL::MG_Impl::GLImpl {
getIntegeri(target, index, data); 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) { void GetInteger64i_v(GLenum target, GLuint index, GLint64* data) {
if (!data) { if (!data) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
@@ -958,6 +1091,13 @@ namespace MobileGL::MG_Impl::GLImpl {
return; 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) { switch (pname) {
case GL_ACTIVE_TEXTURE: case GL_ACTIVE_TEXTURE:
*params = MG_State::pGLContext->GetActiveTextureUnit() + GL_TEXTURE0; *params = MG_State::pGLContext->GetActiveTextureUnit() + GL_TEXTURE0;
@@ -1069,12 +1209,32 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0; *params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
return; 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: 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; return;
case GL_MAX_DEBUG_MESSAGE_LENGTH: case GL_MAX_DEBUG_MESSAGE_LENGTH:
*params = 1024; // debug-message entrypoints are stubbed, but KHR_debug requires a valid limit *params = 1024; // debug-message entrypoints are stubbed, but KHR_debug requires a valid limit
return; 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: case GL_DEBUG_GROUP_STACK_DEPTH:
*params = 0; // debug-group entrypoints are stubbed *params = 0; // debug-group entrypoints are stubbed
return; return;
@@ -1516,13 +1676,9 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_SAMPLE_MASK_VALUE: case GL_SAMPLE_MASK_VALUE:
*params = static_cast<GLint>(MG_State::pGLContext->GetSampleMaskValue()); *params = static_cast<GLint>(MG_State::pGLContext->GetSampleMaskValue());
return; return;
case GL_SAMPLER_BINDING: { case GL_SAMPLER_BINDING:
Int unit = MG_State::pGLContext->GetActiveTextureUnit(); *params = QuerySamplerBindingOnUnit(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;
return; return;
}
case GL_SAMPLES: case GL_SAMPLES:
*params = ResolveDrawFramebufferSampleCount(); *params = ResolveDrawFramebufferSampleCount();
return; return;
@@ -1618,87 +1774,6 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_STEREO: case GL_STEREO:
*params = 0; // stereo surfaces are not exposed *params = 0; // stereo surfaces are not exposed
return; 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: case GL_TEXTURE_COMPRESSION_HINT:
*params = static_cast<GLint>(MG_State::pGLContext->GetHint(pname)); *params = static_cast<GLint>(MG_State::pGLContext->GetHint(pname));
return; return;
@@ -1971,6 +2046,18 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS: case GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS:
*params = static_cast<GLint>(GetIndexedBufferQueryPointCount(BufferTarget::ShaderStorage)); *params = static_cast<GLint>(GetIndexedBufferQueryPointCount(BufferTarget::ShaderStorage));
break; 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: case GL_MAX_TEXTURE_BUFFER_SIZE:
*params = dynamicParameters.MaxTextureBufferSize; *params = dynamicParameters.MaxTextureBufferSize;
break; break;
@@ -19,6 +19,8 @@ namespace MobileGL::MG_Impl::GLImpl {
void GetIntegerv(GLenum pname, GLint* params); void GetIntegerv(GLenum pname, GLint* params);
void GetInteger64v(GLenum pname, GLint64* params); void GetInteger64v(GLenum pname, GLint64* params);
void GetIntegeri_v(GLenum target, GLuint index, GLint* data); 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); void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
GLenum GetError(); GLenum GetError();
GLenum GetGraphicsResetStatus(); GLenum GetGraphicsResetStatus();
+340 -306
View File
@@ -7,6 +7,7 @@
// End of Source File Header // End of Source File Header
#include "GL_Program.h" #include "GL_Program.h"
#include "ProgramInterface.h"
#include "Config.h" #include "Config.h"
#include <cmath> #include <cmath>
#include <limits> #include <limits>
@@ -16,6 +17,7 @@
#include <MG_Util/Converters/GLToMG/ProgramEnumConverter.h> #include <MG_Util/Converters/GLToMG/ProgramEnumConverter.h>
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h> #include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
#include <MG_Util/Converters/SPIRVCrossToGL/SpvcTypeConverter.h> #include <MG_Util/Converters/SPIRVCrossToGL/SpvcTypeConverter.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Backend/BackendObjects.h> #include <MG_Backend/BackendObjects.h>
namespace MobileGL::MG_Impl::GLImpl { namespace MobileGL::MG_Impl::GLImpl {
@@ -108,32 +110,37 @@ namespace MobileGL::MG_Impl::GLImpl {
return programObject; return programObject;
} }
static bool IsProgramInterfaceEnum(GLenum programInterface) { // The four non-location interface queries validate the NAME only: GL 4.6 imposes the
switch (programInterface) { // successful-link requirement on GetProgramResourceLocation/LocationIndex alone, and
case GL_UNIFORM: // requires the others to report a program that has never linked as one with zero active
case GL_UNIFORM_BLOCK: // resources. Being stricter leaves a stray GL_INVALID_OPERATION behind that aborts the
case GL_PROGRAM_INPUT: // caller's next subcase.
case GL_PROGRAM_OUTPUT: static const SharedPtr<MG_State::GLState::ProgramObject>& TryToGetProgramForInterfaceQuery(GLuint program,
case GL_BUFFER_VARIABLE: const char* caller) {
case GL_SHADER_STORAGE_BLOCK: static const SharedPtr<MG_State::GLState::ProgramObject> nullProgramObject = nullptr;
case GL_ATOMIC_COUNTER_BUFFER: if (!MG_State::pGLContext->ValidateProgramName(program)) {
case GL_TRANSFORM_FEEDBACK_VARYING: const ErrorCode error = MG_State::pGLContext->ValidateShaderName(program)
case GL_VERTEX_SUBROUTINE: ? ErrorCode::InvalidOperation
case GL_TESS_CONTROL_SUBROUTINE: : ErrorCode::InvalidValue;
case GL_TESS_EVALUATION_SUBROUTINE: MG_State::pGLContext->RecordError(
case GL_GEOMETRY_SUBROUTINE: error,
case GL_FRAGMENT_SUBROUTINE: MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
case GL_COMPUTE_SUBROUTINE: std::to_string(program) + " is not a program object."));
case GL_VERTEX_SUBROUTINE_UNIFORM: return nullProgramObject;
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;
} }
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) { static bool IsSubroutineUniformInterface(GLenum programInterface) {
@@ -156,11 +163,16 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_ACTIVE_RESOURCES: case GL_ACTIVE_RESOURCES:
break; break;
case GL_MAX_NAME_LENGTH: 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; break;
case GL_MAX_NUM_ACTIVE_VARIABLES: case GL_MAX_NUM_ACTIVE_VARIABLES:
valid = programInterface == GL_UNIFORM_BLOCK || programInterface == GL_ATOMIC_COUNTER_BUFFER || 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; break;
case GL_MAX_NUM_COMPATIBLE_SUBROUTINES: case GL_MAX_NUM_COMPATIBLE_SUBROUTINES:
valid = IsSubroutineUniformInterface(programInterface); valid = IsSubroutineUniformInterface(programInterface);
@@ -179,7 +191,7 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
static bool ValidateNamedProgramResourceInterface(GLenum programInterface, const char* caller) { static bool ValidateNamedProgramResourceInterface(GLenum programInterface, const char* caller) {
if (!IsProgramInterfaceEnum(programInterface) || programInterface == GL_ATOMIC_COUNTER_BUFFER) { if (!ProgramInterface::IsNamedInterface(programInterface)) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum, ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
@@ -189,66 +201,6 @@ namespace MobileGL::MG_Impl::GLImpl {
return true; 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) { void CopyStr(GLsizei bufSize, GLsizei* length, GLchar* dst, const char* src, GLsizei srcLength) {
if (bufSize <= 0) { if (bufSize <= 0) {
if (length) *length = 0; if (length) *length = 0;
@@ -355,19 +307,72 @@ namespace MobileGL::MG_Impl::GLImpl {
shaderObject->Compile(); 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() { GLuint CreateProgram_State() {
return MG_State::pGLContext->CreateProgram(); return MG_State::pGLContext->CreateProgram();
} }
GLuint CreateShader_State(GLenum type) { GLuint CreateShader_State(GLenum type) {
auto shaderId = MG_State::pGLContext->CreateShader(MG_Util::ConvertGLEnumToShaderStage(type)); // GL 4.6 core 7.1: shaderType is an enum, so an unrecognised one is INVALID_ENUM (it
if (shaderId == 0) { // 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( MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "`shaderType` is not an accepted value.")); MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "`shaderType` is not an accepted value."));
return 0; return 0;
} }
return shaderId; return MG_State::pGLContext->CreateShader(stage);
} }
void DeleteProgram_State(GLuint program) { void DeleteProgram_State(GLuint program) {
@@ -693,6 +698,19 @@ namespace MobileGL::MG_Impl::GLImpl {
break; 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: case GL_PROGRAM_BINARY_LENGTH:
// No program binary format is exposed, so a program never has a retrievable // No program binary format is exposed, so a program never has a retrievable
// binary and its length is zero (ARB_get_program_binary). // binary and its length is zero (ARB_get_program_binary).
@@ -746,6 +764,13 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_SHADER_SOURCE_LENGTH: case GL_SHADER_SOURCE_LENGTH:
*params = shaderObject->GetShaderSource().empty() ? 0 : (GLint)shaderObject->GetShaderSource().length() + 1; *params = shaderObject->GetShaderSource().empty() ? 0 : (GLint)shaderObject->GetShaderSource().length() + 1;
break; 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: default:
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum, ErrorCode::InvalidEnum,
@@ -786,6 +811,31 @@ namespace MobileGL::MG_Impl::GLImpl {
return loc; 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) { void GetUniform_State(GLuint program, GLint location, void* params) {
auto& programObject = TryToGetProgramObject(program); auto& programObject = TryToGetProgramObject(program);
if (!programObject) return; if (!programObject) return;
@@ -816,23 +866,16 @@ namespace MobileGL::MG_Impl::GLImpl {
auto size = programObject->GetUniformSizesInBytes(location); auto size = programObject->GetUniformSizesInBytes(location);
char* pUBO = (char*)programObject->MapUBO(); char* pUBO = (char*)programObject->MapUBO();
auto* ttype = programObject->GetUniformTType(location); auto* ttype = programObject->GetUniformTType(location);
const SizeT span = UniformStorageSpanInBytes(ttype, size);
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset || 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__, MGLOG_E("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
program, location); program, location);
return; return;
} }
if (!ttype->isMatrix() || ttype->getMatrixCols() != 3) if (!TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) {
Memcpy(params, pUBO + offset, size); 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 // TODO: handle 1i variant as texture unit
@@ -871,23 +914,16 @@ namespace MobileGL::MG_Impl::GLImpl {
auto size = programObject->GetUniformSizesInBytes(location); auto size = programObject->GetUniformSizesInBytes(location);
char* pUBO = static_cast<char*>(programObject->MapUBO()); char* pUBO = static_cast<char*>(programObject->MapUBO());
auto* ttype = programObject->GetUniformTType(location); auto* ttype = programObject->GetUniformTType(location);
const SizeT span = UniformStorageSpanInBytes(ttype, size);
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset || 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__, MGLOG_E("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
program, location); program, location);
return; return;
} }
if constexpr (std::is_same_v<T, GLfloat>) { if constexpr (std::is_same_v<T, GLfloat>) {
if (ttype->getBasicType() != glslang::EbtDouble && ttype->isMatrix() && if (TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) return;
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;
}
} }
// A double-precision uniform is the one case where the stored component type can // 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 // Helper function to transpose a 2x2 matrix
void TransposeMatrix2x2(const GLfloat* input, GLfloat* output) { void TransposeMatrix2x2(const GLfloat* input, GLfloat* output) {
// Input matrix is in column-major order (OpenGL default) // 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) { void UniformMatrix2fv_State(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
// For 2x2 matrices, we have 4 elements per matrix // A mat2 is NOT four contiguous floats in the global UBO: std140 pads each column
// If transpose is GL_TRUE, we need to transpose the matrix data // vector out to 16 bytes, so column 1 starts at byte 16, not byte 8.
if (location == -1) return; if (location == -1) return;
auto& programObject = MG_State::pGLContext->GetProgramForUniform(); auto& programObject = MG_State::pGLContext->GetProgramForUniform();
@@ -1305,26 +1387,8 @@ namespace MobileGL::MG_Impl::GLImpl {
return; return;
} }
// For matrix uniforms, we handle each matrix individually UniformMatrixfv_Object(*programObject, __func__, location, count, transpose, value, 2, 2,
for (GLint i = 0; i < count; i++) { "the current program object");
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);
}
}
} }
void UniformMatrix3fv_State(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) { 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; if (location == -1) return;
auto& programObject = MG_State::pGLContext->GetProgramForUniform(); auto& programObject = MG_State::pGLContext->GetProgramForUniform();
@@ -1413,21 +1478,8 @@ namespace MobileGL::MG_Impl::GLImpl {
return; return;
} }
for (GLint i = 0; i < count; i++) { UniformMatrixfv_Object(*programObject, caller, location, count, transpose, value, columns, rows,
if (!programObject->IsValidUniformLocation(location + i)) { "the current program object");
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.
} }
void ProgramUniformMatrix2fv_State(GLuint program, GLint location, GLsizei count, GLboolean transpose, void ProgramUniformMatrix2fv_State(GLuint program, GLint location, GLsizei count, GLboolean transpose,
@@ -1445,23 +1497,8 @@ namespace MobileGL::MG_Impl::GLImpl {
return; return;
} }
for (GLint i = 0; i < count; i++) { UniformMatrixfv_Object(*programObject, __func__, location, count, transpose, value, 2, 2,
if (i > 0 && !programObject->UniformLocationsAliasSameUniform(location, location + i)) { "program " + std::to_string(program));
// 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);
}
}
} }
void ProgramUniformMatrix3fv_State(GLuint program, GLint location, GLsizei count, GLboolean transpose, 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; if (location == -1) return;
auto& programObject = TryToGetProgramObject(program); auto& programObject = TryToGetProgramObject(program);
@@ -1550,21 +1588,8 @@ namespace MobileGL::MG_Impl::GLImpl {
return; return;
} }
for (GLint i = 0; i < count; i++) { UniformMatrixfv_Object(*programObject, caller, location, count, transpose, value, columns, rows,
if (!programObject->IsValidUniformLocation(location + i)) { "program " + std::to_string(program));
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.
} }
GLuint GetUniformBlockIndex_State(GLuint program, const GLchar* uniformBlockName) { GLuint GetUniformBlockIndex_State(GLuint program, const GLchar* uniformBlockName) {
@@ -1832,6 +1857,16 @@ namespace MobileGL::MG_Impl::GLImpl {
void CompileShader(GLuint shader) { void CompileShader(GLuint shader) {
CompileShader_State(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) { GLuint CreateProgram(void) {
return CreateProgram_State(); return CreateProgram_State();
} }
@@ -2363,27 +2398,27 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
void UniformMatrix2x3fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) { 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) { 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) { 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) { 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) { 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) { 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) { 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, void ProgramUniformMatrix2x3fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value) { 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, void ProgramUniformMatrix3x2fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value) { 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, void ProgramUniformMatrix2x4fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value) { 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, void ProgramUniformMatrix4x2fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value) { 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, void ProgramUniformMatrix3x4fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value) { 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, void ProgramUniformMatrix4x3fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
const GLfloat* value) { 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) { 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) { void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params) {
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__); auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
if (!programObject) return; if (!programObject) return;
if (!ValidateProgramInterfaceivQuery(programInterface, pname)) return; if (!ValidateProgramInterfaceivQuery(programInterface, pname)) return;
auto getProgramInterfaceiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramInterfaceiv; if (!params) return;
if (!getProgramInterfaceiv) { switch (pname) {
MG_State::pGLContext->RecordError( case GL_ACTIVE_RESOURCES:
ErrorCode::InvalidOperation, *params = ProgramInterface::GetActiveResourceCount(*programObject, programInterface);
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, return;
"Backend does not support program interface queries.")); 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; 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) { 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 (!programObject) return GL_INVALID_INDEX;
if (!ValidateNamedProgramResourceInterface(programInterface, __func__)) return GL_INVALID_INDEX; if (!ValidateNamedProgramResourceInterface(programInterface, __func__)) return GL_INVALID_INDEX;
if (!name) return GL_INVALID_INDEX; if (!name) return GL_INVALID_INDEX;
if (programInterface == GL_UNIFORM) { return ProgramInterface::GetResourceIndex(*programObject, programInterface, name);
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;
} }
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei* length, void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei* length,
GLchar* name) { GLchar* name) {
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__); auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
if (!programObject) return; if (!programObject) return;
if (!ValidateNamedProgramResourceInterface(programInterface, __func__)) 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) { if (bufSize < 0) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "bufSize must be non-negative.")); MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "bufSize must be non-negative."));
return; return;
} }
if (programInterface == GL_UNIFORM) { String resourceName;
// Same index space GetProgramResourceIndex answers in, and the range check above if (!ProgramInterface::GetResourceName(*programObject, programInterface, index, resourceName)) {
// 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()) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "index is out of range.")); MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "index is out of range."));
return; return;
} }
if (props == nullptr || params == nullptr) return; CopyStr(bufSize, length, name, resourceName.c_str(), static_cast<GLsizei>(resourceName.length()));
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);
} }
}
} void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
params[written++] = value; const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
} auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
if (length) *length = written; if (!programObject) return;
if (!ProgramInterface::IsInterfaceEnum(programInterface)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "Unsupported program interface."));
return; return;
} }
auto getProgramResourceiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceiv; if (propCount <= 0 || bufSize < 0) {
if (!getProgramResourceiv) { 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( MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation, ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Backend does not support program interface queries.")); "prop is not supported for this program interface."));
return; 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) { 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__); auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
if (!programObject) return -1; if (!programObject) return -1;
auto getProgramResourceLocation = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceLocation; if (!ProgramInterface::InterfaceHasLocations(programInterface)) {
if (!getProgramResourceLocation) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation, ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Backend does not support program interface queries.")); "Program interface has no locations."));
return -1; return -1;
} }
return getProgramResourceLocation(program, programInterface, name); return ProgramInterface::GetResourceLocation(*programObject, programInterface, name);
} }
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name) { GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name) {
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__); auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
if (!programObject) return -1; if (!programObject) return -1;
auto getProgramResourceLocationIndex = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceLocationIndex; if (programInterface != GL_PROGRAM_OUTPUT) {
if (!getProgramResourceLocationIndex) return -1; MG_State::pGLContext->RecordError(
return getProgramResourceLocationIndex(program, programInterface, name); 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) { void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding) {
auto& programObject = TryToGetProgramObject(program); auto& programObject = TryToGetProgramObject(program);
if (!programObject || !programObject->GetLinkStatus()) return; if (!programObject || !programObject->GetLinkStatus()) return;
if (!ValidateShaderStorageBlockBinding(storageBlockBinding)) 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; auto shaderStorageBlockBinding = MG_Backend::gBackendFunctionsTable.GL.ShaderStorageBlockBinding;
if (!shaderStorageBlockBinding) { if (!shaderStorageBlockBinding) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
@@ -2743,7 +2767,7 @@ namespace MobileGL::MG_Impl::GLImpl {
"Backend does not support shader storage block binding.")); "Backend does not support shader storage block binding."));
return; return;
} }
shaderStorageBlockBinding(program, storageBlockIndex, storageBlockBinding); shaderStorageBlockBinding(program, blockName.c_str(), storageBlockBinding);
} }
void ValidateProgram(GLuint program) { 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 // 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. // raise is one of theirs, raised at the point they would raise it.
GLuint CreateShaderProgramv(GLenum type, GLsizei count, const GLchar* const* strings) { 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); const GLuint shader = CreateShader_State(type);
if (shader == 0) return 0; if (shader == 0) return 0;
@@ -42,6 +42,10 @@ namespace MobileGL::MG_Impl::GLImpl {
GLboolean IsProgram(GLuint program); GLboolean IsProgram(GLuint program);
GLboolean IsShader(GLuint shader); GLboolean IsShader(GLuint shader);
void LinkProgram(GLuint program); 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 ShaderSource(GLuint shader, GLsizei count, const GLchar* const* string, const GLint* length);
void UseProgram(GLuint program); void UseProgram(GLuint program);
void Uniform1f(GLint location, GLfloat v0); 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 "GL_Sampler.h"
#include "Validators.h" #include "Validators.h"
#include "../Getter/GL_Getter.h"
#include <MG_State/GLState/Core.h> #include <MG_State/GLState/Core.h>
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h> #include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToGL/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) { void BindSampler_State(GLuint unit, GLuint sampler) {
MGLOG_D("BindSampler_State: unit = %u, sampler = %u", unit, 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( MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "BindSampler", "texture unit out of range")); 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")); MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "BindSamplers", "count must be non-negative"));
return; 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) { for (GLsizei i = 0; i < count; ++i) {
BindSampler_State(first + i, samplers ? samplers[i] : 0); BindSampler_State(first + i, samplers ? samplers[i] : 0);
@@ -75,7 +75,11 @@ namespace MobileGL::MG_Impl::GLImpl::SamplerImpl {
break; break;
case GL_TEXTURE_COMPARE_FUNC: 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, MG_State::pGLContext->RecordError(ErrorCode::InvalidEnum,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "ValidateSamplerParam", MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "ValidateSamplerParam",
"Invalid compare function parameter")); "Invalid compare function parameter"));
+51 -3
View File
@@ -86,17 +86,65 @@ namespace MobileGL::MG_Impl::GLImpl {
return false; 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> template <typename Fn>
void WithTemporarilyBoundNamedTexture(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject, void WithTemporarilyBoundNamedTexture(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
Fn&& fn) { Fn&& fn) {
if (!textureObject) return; 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()); auto& bindingSlot = activeUnit.GetBindingSlot(textureObject->GetTarget());
const auto previousBinding = bindingSlot.GetBoundObject(); 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())); fn(MG_Util::ConvertTextureTargetToGLEnum(textureObject->GetTarget()));
bindingSlot.Bind(previousBinding);
} }
SizeT ComputeTextureStorageByteSize(TextureInternalFormat textureInternalFormat, GLsizei width, GLsizei height, SizeT ComputeTextureStorageByteSize(TextureInternalFormat textureInternalFormat, GLsizei width, GLsizei height,
@@ -106,6 +106,21 @@ namespace MobileGL::MG_Impl::GLImpl {
return pname == GL_CURRENT_VERTEX_ATTRIB; 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, // 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 // 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. // 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_LONG:
case GL_VERTEX_ATTRIB_ARRAY_DIVISOR: case GL_VERTEX_ATTRIB_ARRAY_DIVISOR:
case GL_VERTEX_ATTRIB_ARRAY_POINTER: 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; return true;
default: default:
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
@@ -944,6 +964,13 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_VERTEX_ATTRIB_ARRAY_DIVISOR: case GL_VERTEX_ATTRIB_ARRAY_DIVISOR:
params[0] = static_cast<GLfloat>(attr->Divisor); params[0] = static_cast<GLfloat>(attr->Divisor);
return; 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: default:
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum, ErrorCode::InvalidEnum,
@@ -1007,6 +1034,13 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_VERTEX_ATTRIB_ARRAY_DIVISOR: case GL_VERTEX_ATTRIB_ARRAY_DIVISOR:
params[0] = static_cast<GLdouble>(attr->Divisor); params[0] = static_cast<GLdouble>(attr->Divisor);
return; 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: default:
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum, ErrorCode::InvalidEnum,
@@ -1066,6 +1100,10 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_VERTEX_ATTRIB_ARRAY_DIVISOR: case GL_VERTEX_ATTRIB_ARRAY_DIVISOR:
params[0] = static_cast<GLint>(attr->Divisor); params[0] = static_cast<GLint>(attr->Divisor);
return; return;
case GL_VERTEX_ATTRIB_BINDING:
case GL_VERTEX_ATTRIB_RELATIVE_OFFSET:
TryGetVertexAttribBindingQuery(index, pname, params[0]);
return;
default: default:
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum, ErrorCode::InvalidEnum,
@@ -1204,6 +1242,9 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_VERTEX_ATTRIB_RELATIVE_OFFSET: case GL_VERTEX_ATTRIB_RELATIVE_OFFSET:
*param = static_cast<GLint>(vao->GetAttributeRelativeOffset(index)); *param = static_cast<GLint>(vao->GetAttributeRelativeOffset(index));
return; return;
case GL_VERTEX_ATTRIB_BINDING:
*param = static_cast<GLint>(vao->GetAttributeBindingIndex(index));
return;
default: default:
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidEnum, ErrorCode::InvalidEnum,
@@ -147,6 +147,30 @@ namespace MobileGL::MG_Impl::GLImpl::VertexArrayImpl {
return false; 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)) { 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 // 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. // normalized == GL_TRUE. On the integer path it is simply an out-of-range size.
@@ -49,6 +49,10 @@ add_executable(MobileGLIntegrationTest
Scenarios/OrientationScenario.cpp Scenarios/OrientationScenario.cpp
Scenarios/CrossFrameBufferScenario.cpp Scenarios/CrossFrameBufferScenario.cpp
Scenarios/ResidentIndexScenario.cpp Scenarios/ResidentIndexScenario.cpp
Scenarios/MultiDrawScenario.cpp
Scenarios/AsyncCompileScenario.cpp
Scenarios/XfbAfterClipDistanceScenario.cpp
Scenarios/ThreeChannelAttachmentScenario.cpp
) )
target_include_directories(MobileGLIntegrationTest PRIVATE target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -215,6 +219,8 @@ mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV}) "MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" ${MGL_ITEST_VULKAN_ENV}) "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. # TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it.
set(MGL_ITEST_TIMEOUT 120) set(MGL_ITEST_TIMEOUT 120)
@@ -241,3 +247,24 @@ gtest_discover_tests(MobileGLIntegrationTest
TIMEOUT ${MGL_ITEST_TIMEOUT} TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_ENVIRONMENT}" 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,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,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,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
@@ -8,9 +8,17 @@
#include "BufferObject.h" #include "BufferObject.h"
#include <atomic>
namespace MobileGL::MG_State::GLState { namespace MobileGL::MG_State::GLState {
namespace { namespace {
const BufferBackendOps* g_bufferBackendOps = nullptr; 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) { void SetBufferBackendOps(const BufferBackendOps* ops) {
@@ -198,8 +206,11 @@ namespace MobileGL::MG_State::GLState {
} }
void BufferObject::UploadSubData(DataPtr data, SizeT atOffset) { void BufferObject::UploadSubData(DataPtr data, SizeT atOffset) {
MOBILEGL_ASSERT(!m_isMapped || (m_mappingAccess & BufferMappingAccessBit::Persistent), // GL 4.6 core 6.5 forbids only the OVERLAPPING write: a glBufferSubData that stays
"Cannot upload sub data while buffer is non-persistently mapped."); // 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, MOBILEGL_ASSERT(atOffset + data.size <= m_size,
"UploadSubData out of bounds: atOffset (%zu) + data.size (%zu) > m_size (%zu)", atOffset, "UploadSubData out of bounds: atOffset (%zu) + data.size (%zu) > m_size (%zu)", atOffset,
data.size, m_size); data.size, m_size);
@@ -185,6 +185,13 @@ namespace MobileGL {
Flags<BufferMappingAccessBit> GetMappingAccess() const; Flags<BufferMappingAccessBit> GetMappingAccess() const;
GLbitfield GetStorageFlags() const; GLbitfield GetStorageFlags() const;
Uint GetExternalIndex() 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 // Monotonic counter bumped on every shadow mutation; backends use it to
// validate cached transient slices. // validate cached transient slices.
Uint64 GetChangeSerial() const; Uint64 GetChangeSerial() const;
@@ -207,7 +214,10 @@ namespace MobileGL {
// SubData transfer to sync the backend's separate GPU copy. // SubData transfer to sync the backend's separate GPU copy.
void NotifyContentWrite(SizeT offset, SizeT size); void NotifyContentWrite(SizeT offset, SizeT size);
static Uint64 AllocateLifetimeId();
const Uint m_externalIndex = 0; const Uint m_externalIndex = 0;
const Uint64 m_lifetimeId = AllocateLifetimeId();
SizeT m_size = 0; SizeT m_size = 0;
BufferUsage m_usage = BufferUsage::StaticDraw; BufferUsage m_usage = BufferUsage::StaticDraw;
// Owns the buffer's bytes (CPU shadow or backend persistent GPU map) and // 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 "Core.h"
#include "MG_State/GLState/RenderbufferState/RenderbufferObject.h" #include "MG_State/GLState/RenderbufferState/RenderbufferObject.h"
#include "MG_State/EGLState/Core.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> #include <Config.h>
namespace MobileGL::MG_State { namespace MobileGL::MG_State {
@@ -24,8 +27,27 @@ namespace MobileGL::MG_State {
} }
namespace GLState { 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 // Error
void GLContext::RecordError(ErrorCode code, UniquePtr<ErrorInfo> info) { 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)); m_errorState.RecordError(code, Move(info));
} }
@@ -335,6 +357,10 @@ namespace MobileGL::MG_State {
return m_programState.GetShaderObject(index); return m_programState.GetShaderObject(index);
} }
void GLContext::JoinAllPendingShaderWork() {
m_programState.JoinAllPendingWork();
}
void GLContext::UseProgram(Uint program) { void GLContext::UseProgram(Uint program) {
return m_programState.UseProgram(program); return m_programState.UseProgram(program);
} }
@@ -346,11 +372,35 @@ namespace MobileGL::MG_State {
const SharedPtr<ProgramObject>& GLContext::GetProgramForDraw() { const SharedPtr<ProgramObject>& GLContext::GetProgramForDraw() {
static const SharedPtr<ProgramObject> nullProgram = nullptr; static const SharedPtr<ProgramObject> nullProgram = nullptr;
const auto& currentProgram = m_programState.GetCurrentProgram(); const auto& currentProgram = m_programState.GetCurrentProgram();
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; if (m_boundProgramPipeline == 0) return nullProgram;
const auto& pipeline = GetBoundProgramPipeline(); const auto& pipeline = GetBoundProgramPipeline();
if (!pipeline) return nullProgram; 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(); const auto signature = pipeline->ComputeDrawProgramSignature();
if (const auto& cached = pipeline->GetCachedDrawProgram(signature)) return cached; 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 // 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. // shader is wanted here even though the separable stage programs never get one.
composite->Link(true); 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)); pipeline->SetCachedDrawProgram(signature, Move(composite));
return pipeline->GetCachedDrawProgram(signature); return pipeline->GetCachedDrawProgram(signature);
} }
+26
View File
@@ -21,6 +21,10 @@
#include "VertexArrayState/VertexArrayState.h" #include "VertexArrayState/VertexArrayState.h"
#include "RenderbufferState/RenderbufferState.h" #include "RenderbufferState/RenderbufferState.h"
namespace MobileGL::MG_Util::ShaderTranspiler {
struct CompileEnv;
}
namespace MobileGL { namespace MobileGL {
namespace MG_State { namespace MG_State {
void Init(); void Init();
@@ -149,6 +153,14 @@ namespace MobileGL {
Bool ValidateShaderName(Uint index) const; Bool ValidateShaderName(Uint index) const;
const SharedPtr<ProgramObject>& GetProgramObject(Uint index); const SharedPtr<ProgramObject>& GetProgramObject(Uint index);
const SharedPtr<ShaderObject>& GetShaderObject(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); void UseProgram(Uint program);
const SharedPtr<ProgramObject>& GetCurrentProgram(); const SharedPtr<ProgramObject>& GetCurrentProgram();
// What a draw or dispatch actually executes: the program in use, or - when // What a draw or dispatch actually executes: the program in use, or - when
@@ -380,6 +392,15 @@ namespace MobileGL {
Bool ValidateRenderbufferName(Uint index) const; Bool ValidateRenderbufferName(Uint index) const;
Bool ValidateRenderbufferObject(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: private:
// State Components // State Components
ErrorState m_errorState; ErrorState m_errorState;
@@ -437,6 +458,11 @@ namespace MobileGL {
FramebufferState m_framebufferState; FramebufferState m_framebufferState;
SamplerState m_samplerState; SamplerState m_samplerState;
RenderbufferState m_renderbufferState; 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 } // 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> #include <MG_Util/ShaderTranspiler/SpvcSession.h>
namespace MobileGL::MG_State::GLState { 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 { class ProgramObject {
public: public:
ProgramObject(Uint externalIndex) : m_externalIndex(externalIndex), m_lifetimeId(AllocateLifetimeId()) {} 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); bool ShaderIsAttached(const SharedPtr<ShaderObject>& shader);
// GL-visible attachment: in the attach list and not pending detach (glDetachShader // GL-visible attachment: in the attach list and not pending detach (glDetachShader
// defers the actual removal to the next link). // defers the actual removal to the next link).
@@ -44,31 +57,39 @@ namespace MobileGL::MG_State::GLState {
Vector<SharedPtr<ShaderObject>>& GetAttachedShaders(); Vector<SharedPtr<ShaderObject>>& GetAttachedShaders();
const Vector<SharedPtr<ShaderObject>>& GetAttachedShaders() const; 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 // 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. // is the only place a caller can read it from once the shader name is gone.
void AppendInfoLog(const String& text) { void AppendInfoLog(const String& text) {
if (text.empty()) return; if (text.empty()) return;
if (!m_infoLog.empty() && m_infoLog.back() != '\n') m_infoLog += '\n'; if (!Artifacts().infoLog.empty() && Artifacts().infoLog.back() != '\n') Artifacts().infoLog += '\n';
m_infoLog += text; Artifacts().infoLog += text;
} }
Int GetUniformMaxLength() const { return m_uniformNameMaxLength; } Int GetUniformMaxLength() const { return Artifacts().uniformNameMaxLength; }
Uint GetUniformCount() const { return m_activeUniformCount; } Uint GetUniformCount() const { return Artifacts().activeUniformCount; }
Uint GetMaxUniformLocation() const { return m_maxUniformLocation; } Uint GetMaxUniformLocation() const { return Artifacts().maxUniformLocation; }
Int GetUniformLocation(const String& name) const { Int GetUniformLocation(const String& name) const {
const auto it = m_uniformLocations.find(name); const auto it = Artifacts().uniformLocations.find(name);
if (it != m_uniformLocations.end()) return (Int)it->second; if (it != Artifacts().uniformLocations.end()) return (Int)it->second;
// Reflection stores GL-style names: an array uniform is keyed "arr[0]" (its base // 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 // 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. // to base + k because DoReflection reserves one location per array element.
if (name.empty()) return -1; if (name.empty()) return -1;
if (name.back() != ']') { if (name.back() != ']') {
const auto suffixedIt = m_uniformLocations.find(name + "[0]"); const auto suffixedIt = Artifacts().uniformLocations.find(name + "[0]");
if (suffixedIt != m_uniformLocations.end()) return (Int)suffixedIt->second; if (suffixedIt != Artifacts().uniformLocations.end()) return (Int)suffixedIt->second;
return -1; return -1;
} }
if (name.length() < 4) 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('['); const SizeT bracket = name.rfind('[');
// Require at least one digit between the brackets. // Require at least one digit between the brackets.
if (bracket == String::npos || bracket + 1 >= name.length() - 1) return -1; 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'); element = element * 10 + static_cast<Uint>(name[i] - '0');
if (element > 0x0FFFFFFFu) return -1; if (element > 0x0FFFFFFFu) return -1;
} }
auto baseIt = m_uniformLocations.find(name.substr(0, bracket) + "[0]"); auto baseIt = Artifacts().uniformLocations.find(name.substr(0, bracket) + "[0]");
if (baseIt == m_uniformLocations.end()) { if (baseIt == Artifacts().uniformLocations.end()) {
// Legacy key without the "[0]" suffix (defensive; reflection normally // Legacy key without the "[0]" suffix (defensive; reflection normally
// stores the suffixed form for arrays). // stores the suffixed form for arrays).
baseIt = m_uniformLocations.find(name.substr(0, bracket)); baseIt = Artifacts().uniformLocations.find(name.substr(0, bracket));
if (baseIt == m_uniformLocations.end()) return -1; if (baseIt == Artifacts().uniformLocations.end()) return -1;
} }
const Int base = (Int)baseIt->second; const Int base = (Int)baseIt->second;
if (!IsValidUniformLocation(base)) return -1; 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 // "[k]" only addresses arrays ("scalar[0]" is not a uniform name), and only
// in-range elements. // 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 (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; const Int location = base + (Int)element;
if (!UniformLocationsAliasSameUniform(base, location)) return -1; if (!UniformLocationsAliasSameUniform(base, location)) return -1;
return location; return location;
@@ -101,14 +122,41 @@ namespace MobileGL::MG_State::GLState {
// True when both locations are element slots of the same uniform variable. // True when both locations are element slots of the same uniform variable.
Bool UniformLocationsAliasSameUniform(Int a, Int b) const { Bool UniformLocationsAliasSameUniform(Int a, Int b) const {
if (!IsValidUniformLocation(a) || !IsValidUniformLocation(b)) return false; 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 { Int GetActiveUniformIndex(const String& name) const {
const Int uniformIndex = m_program->getUniformIndex(name.c_str()); const Int tProgramCount = static_cast<Int>(Artifacts().tProgramUniformIndexToGl.size());
if (uniformIndex >= 0 && uniformIndex < m_activeUniformCount && const Int uniformIndex = Artifacts().program->getUniformIndex(name.c_str());
m_program->getUniform(uniformIndex).name == name) { if (uniformIndex >= 0 && uniformIndex < tProgramCount &&
return uniformIndex; Artifacts().program->getUniform(uniformIndex).name == name) {
return GlUniformIndexFromTProgram(uniformIndex);
} }
// Reflection stores an array uniform under "arr[0]"; accept the bare "arr" // 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. // robustness against non-suffixed reflection entries.
if (!name.empty() && name.back() != ']') { if (!name.empty() && name.back() != ']') {
const String suffixedName = name + "[0]"; const String suffixedName = name + "[0]";
const Int suffixedIndex = m_program->getUniformIndex(suffixedName.c_str()); const Int suffixedIndex = Artifacts().program->getUniformIndex(suffixedName.c_str());
if (suffixedIndex >= 0 && suffixedIndex < m_activeUniformCount && if (suffixedIndex >= 0 && suffixedIndex < tProgramCount &&
m_program->getUniform(suffixedIndex).name == suffixedName) { Artifacts().program->getUniform(suffixedIndex).name == suffixedName) {
return suffixedIndex; return GlUniformIndexFromTProgram(suffixedIndex);
} }
return -1; return -1;
} }
if (name.length() <= 3 || name.compare(name.length() - 3, 3, "[0]") != 0) 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 String baseName = name.substr(0, name.length() - 3);
const Int baseIndex = m_program->getUniformIndex(baseName.c_str()); const Int baseIndex = Artifacts().program->getUniformIndex(baseName.c_str());
if (baseIndex < 0 || baseIndex >= m_activeUniformCount) return -1; if (baseIndex < 0 || baseIndex >= tProgramCount) return -1;
return m_program->getUniform(baseIndex).name == baseName ? baseIndex : -1; return Artifacts().program->getUniform(baseIndex).name == baseName ? GlUniformIndexFromTProgram(baseIndex)
: -1;
} }
Bool IsValidUniformLocation(Int location) const { Bool IsValidUniformLocation(Int location) const { return IsValidUniformLocation(Artifacts(), location); }
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;
}
GLenum GetUniformType(Uint location) const { GLenum GetUniformType(Uint location) const {
auto& uniform = m_program->getUniform(m_uniformIndexInTProgram[location]); auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]);
return uniform.glDefineType; return uniform.glDefineType;
} }
GLenum GetActiveUniformType(Uint index) const { GLenum GetActiveUniformType(Uint index) const {
auto& uniform = m_program->getUniform(static_cast<Int>(index)); auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
return uniform.glDefineType; return uniform.glDefineType;
} }
// Number of active array elements (GL_UNIFORM_SIZE / GL_ARRAY_SIZE); 1 for a non-array. // 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 // 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 // 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. // for both. GL 3.3 core uniforms are always sized. Takes a TProgram uniform index (the space
GLint GetActiveUniformArraySize(Uint index) const { // the artifacts' uniformIndexInTProgram stores).
const auto& uniform = m_program->getUniform(static_cast<Int>(index)); GLint GetUniformArraySizeByTIndex(Int tIndex) const {
const glslang::TType* type = uniform.getType(); return GetUniformArraySizeByTIndex(Artifacts(), tIndex);
if (type != nullptr && type->isSizedArray()) {
return type->getOuterArraySize();
} }
return uniform.size < 1 ? 1 : uniform.size;
GLint GetActiveUniformArraySize(Uint index) const {
return GetUniformArraySizeByTIndex(TProgramUniformIndex(index));
} }
Int GetActiveUniformBlockIndex(Uint index) const { Int GetActiveUniformBlockIndex(Uint index) const {
auto& uniform = m_program->getUniform(static_cast<Int>(index)); auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
return uniform.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 // GL_UNIFORM_OFFSET: byte offset within the owning named block; -1 for a default-block
// for a default-block uniform, which is exactly the spec value there. // 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 { 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 // 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 // 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. // layout is always std140, where every array element stride rounds up to a vec4.
GLint GetActiveUniformArrayStride(Uint index) const { GLint GetActiveUniformArrayStride(Uint index) const {
const auto& uniform = m_program->getUniform(static_cast<Int>(index)); const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
if (uniform.index < 0) return -1; if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1;
const glslang::TType* type = uniform.getType(); const glslang::TType* type = uniform.getType();
if (type == nullptr || !type->isArray()) return 0; if (type == nullptr || !type->isArray()) return 0;
if (type->isMatrix()) { 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 // check suffices; the getUniformBlock() fallback is defensive for a config that instead leaves
// an inheriting member's layoutMatrix == ElmNone. // an inheriting member's layoutMatrix == ElmNone.
GLint GetActiveUniformIsRowMajor(Uint index) const { GLint GetActiveUniformIsRowMajor(Uint index) const {
const auto& uniform = m_program->getUniform(static_cast<Int>(index)); const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
if (uniform.index < 0) return 0; if (GlBlockIndexFromTProgram(uniform.index) < 0) return 0;
const glslang::TType* type = uniform.getType(); const glslang::TType* type = uniform.getType();
if (type == nullptr || !type->isMatrix()) return 0; if (type == nullptr || !type->isMatrix()) return 0;
glslang::TLayoutMatrix layoutMatrix = type->getQualifier().layoutMatrix; glslang::TLayoutMatrix layoutMatrix = type->getQualifier().layoutMatrix;
if (layoutMatrix == glslang::ElmNone) { 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; 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 // 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. // every GL 3.3 float matrix this evaluates to 16, independent of majorness.
GLint GetActiveUniformMatrixStride(Uint index) const { GLint GetActiveUniformMatrixStride(Uint index) const {
const auto& uniform = m_program->getUniform(static_cast<Int>(index)); const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
if (uniform.index < 0) return -1; if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1;
const glslang::TType* type = uniform.getType(); const glslang::TType* type = uniform.getType();
if (type == nullptr || !type->isMatrix()) return 0; if (type == nullptr || !type->isMatrix()) return 0;
glslang::TLayoutMatrix layoutMatrix = type->getQualifier().layoutMatrix; glslang::TLayoutMatrix layoutMatrix = type->getQualifier().layoutMatrix;
if (layoutMatrix == glslang::ElmNone) { 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 bool rowMajor = (layoutMatrix == glslang::ElmRowMajor);
const int strideVectorComponents = rowMajor ? type->getMatrixCols() : type->getMatrixRows(); const int strideVectorComponents = rowMajor ? type->getMatrixCols() : type->getMatrixRows();
@@ -238,50 +285,50 @@ namespace MobileGL::MG_State::GLState {
} }
const glslang::TType* GetUniformTType(Uint location) const { 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(); return uniform.getType();
} }
Bool IsUniformOpaqueAtLocation(Uint location) const { return GetUniformTType(location)->isOpaque(); } Bool IsUniformOpaqueAtLocation(Uint location) const { return GetUniformTType(location)->isOpaque(); }
const String& GetUniformName(Uint location) const { 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; return uniform.name;
} }
const String& GetActiveUniformName(Uint index) const { 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; return uniform.name;
} }
// Sentinel for a uniform location without global-UBO backing storage (should not // Sentinel for a uniform location without global-UBO backing storage (should not
// survive linking: GenerateBinary falls back to tail-allocated scratch storage). // survive linking: GenerateBinary falls back to tail-allocated scratch storage).
static constexpr Uint kInvalidUniformOffset = ~0u; static constexpr Uint kInvalidUniformOffset = ~0u;
Uint GetUniformOffset(Uint location) const { return m_uniformOffsets[location]; } Uint GetUniformOffset(Uint location) const { return Artifacts().uniformOffsets[location]; }
Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); } Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); }
Int GetAttributeLocation(const String& name) { Int GetAttributeLocation(const String& name) {
const auto it = std::find(m_attribs.begin(), m_attribs.end(), name); const auto it = std::find(Artifacts().attribs.begin(), Artifacts().attribs.end(), name);
return (it == m_attribs.end()) ? -1 : (Int)std::distance(m_attribs.begin(), it); return (it == Artifacts().attribs.end()) ? -1 : (Int)std::distance(Artifacts().attribs.begin(), it);
} }
Uint32 GetActiveAttributeLocationMask() const { Uint32 GetActiveAttributeLocationMask() const {
Uint32 mask = 0; 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) { for (SizeT index = 0; index < count; ++index) {
if (!m_attribs[index].empty()) { if (!Artifacts().attribs[index].empty()) {
mask |= (1u << index); mask |= (1u << index);
} }
} }
return mask; return mask;
} }
Uint32 GetActiveFragmentOutputLocationMask() const { Uint32 GetActiveFragmentOutputLocationMask() const {
if (!m_program) { if (!Artifacts().program) {
return 0; return 0;
} }
Uint32 mask = 0; Uint32 mask = 0;
const Int outputCount = m_program->getNumPipeOutputs(); const Int outputCount = Artifacts().program->getNumPipeOutputs();
for (Int index = 0; index < outputCount; ++index) { 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) { if (location >= 0 && location < 32) {
mask |= (1u << location); mask |= (1u << location);
} }
@@ -289,47 +336,59 @@ namespace MobileGL::MG_State::GLState {
return mask; return mask;
} }
Int GetActiveFragmentOutputCount() const { Int GetActiveFragmentOutputCount() const {
return m_program ? m_program->getNumPipeOutputs() : 0; return Artifacts().program ? Artifacts().program->getNumPipeOutputs() : 0;
} }
const String& GetActiveFragmentOutputName(Uint index) const { const String& GetActiveFragmentOutputName(Uint index) const {
MOBILEGL_ASSERT(m_program != nullptr, "ProgramObject::GetActiveFragmentOutputName: program is null"); MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetActiveFragmentOutputName: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(m_program->getNumPipeOutputs()), MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
"ProgramObject::GetActiveFragmentOutputName: index=%u out of range", index); "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 { Int GetFragmentOutputLocation(Uint index) const {
MOBILEGL_ASSERT(m_program != nullptr, "ProgramObject::GetFragmentOutputLocation: program is null"); MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetFragmentOutputLocation: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(m_program->getNumPipeOutputs()), MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
"ProgramObject::GetFragmentOutputLocation: index=%u out of range", "ProgramObject::GetFragmentOutputLocation: index=%u out of range",
index); 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 { GLint GetActiveFragmentOutputArraySize(Uint index) const {
MOBILEGL_ASSERT(m_program != nullptr, "ProgramObject::GetActiveFragmentOutputArraySize: program is null"); MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetActiveFragmentOutputArraySize: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(m_program->getNumPipeOutputs()), MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
"ProgramObject::GetActiveFragmentOutputArraySize: index=%u out of range", index); "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 { GLenum GetFragmentOutputType(Uint index) const {
MOBILEGL_ASSERT(m_program != nullptr, "ProgramObject::GetFragmentOutputType: program is null"); MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetFragmentOutputType: program is null");
MOBILEGL_ASSERT(index < static_cast<Uint>(m_program->getNumPipeOutputs()), MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
"ProgramObject::GetFragmentOutputType: index=%u out of range", "ProgramObject::GetFragmentOutputType: index=%u out of range",
index); 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]; } GLenum GetAttribType(Uint index) const { return Artifacts().attribTypes[index]; }
const String& GetAttribName(Uint index) const { return m_attribs[index]; } const String& GetAttribName(Uint index) const { return Artifacts().attribs[index]; }
GLenum GetActiveAttribType(Uint index) const { return m_program->getPipeInput(static_cast<Int>(index)).glDefineType; } GLenum GetActiveAttribType(Uint index) const { return Artifacts().program->getPipeInput(static_cast<Int>(index)).glDefineType; }
GLint GetActiveAttribArraySize(Uint index) const { return m_program->getPipeInput(static_cast<Int>(index)).size; } GLint GetActiveAttribArraySize(Uint index) const { return Artifacts().program->getPipeInput(static_cast<Int>(index)).size; }
const String& GetActiveAttribName(Uint index) const { return m_program->getPipeInput(static_cast<Int>(index)).name; } // The Vulkan-semantics parse reflects the vertex builtins under their SPIR-V names;
void* MapUBO() { return m_globalUboScratch.data(); } // GL must keep reporting the GL spellings (glGetActiveAttrib and the program-input
const void* GetUBOData() const { return m_globalUboScratch.data(); } // resource queries enumerate builtins).
Uint GetUBOSize() const { return static_cast<Uint>(m_globalUboScratch.size()); } 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 // Content version of the CPU-side global-UBO shadow: writers bump it so backends
// can skip re-uploading an unchanged UBO on every draw. ~0u is reserved as the // can skip re-uploading an unchanged UBO on every draw. ~0u is reserved as the
// backends' "never uploaded" sentinel, so skip over it on wrap. // backends' "never uploaded" sentinel, so skip over it on wrap.
Uint32 GetUBOContentVersion() const { return m_uboContentVersion; } Uint32 GetUBOContentVersion() const { return m_uboContentVersion; }
void MarkUBOContentDirty() { void MarkUBOContentDirty() const {
if (++m_uboContentVersion == ~0u) m_uboContentVersion = 0; if (++m_uboContentVersion == ~0u) m_uboContentVersion = 0;
} }
Uint32 GetBackendStateVersion() const { return m_backendStateVersion; } Uint32 GetBackendStateVersion() const { return m_backendStateVersion; }
@@ -370,20 +429,20 @@ namespace MobileGL::MG_State::GLState {
} }
void SetUniformSamplerOrImageUnitIndex(Uint location, Int unit) { void SetUniformSamplerOrImageUnitIndex(Uint location, Int unit) {
if (location >= m_uniformSamplerOrImageUnitIndex.size() || if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size() ||
m_uniformSamplerOrImageUnitIndex[location] == unit) { Artifacts().uniformSamplerOrImageUnitIndex[location] == unit) {
return; return;
} }
m_uniformSamplerOrImageUnitIndex[location] = unit; Artifacts().uniformSamplerOrImageUnitIndex[location] = unit;
++m_backendStateVersion; ++m_backendStateVersion;
} }
Int GetUniformSamplerOrImageUnitIndex(Uint location) const { Int GetUniformSamplerOrImageUnitIndex(Uint location) const {
return m_uniformSamplerOrImageUnitIndex[location]; return Artifacts().uniformSamplerOrImageUnitIndex[location];
} }
Bool GetDeleteStatus() const { return m_deleteStatus; } 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_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 // (GL_NUM_PROGRAM_BINARY_FORMATS is 0), so the hint is pure state - which is all
// ARB_get_program_binary requires of it. // 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 // glProgramBinary always fails here (there is no format it could accept) and the
// spec then requires the program's LINK_STATUS to read FALSE. // spec then requires the program's LINK_STATUS to read FALSE.
void MarkLinkFailedByProgramBinary() { void MarkLinkFailedByProgramBinary() {
ResetLinkArtifacts(); // Before anything reads m_artifacts: a pending link would otherwise publish its
m_infoLog = "No program binary format is supported."; // (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; } Bool GetValidateStatus() const { return m_validateStatus; }
Int GetActiveAtomicCounterCount() const { return m_program->getNumAtomicCounters(); } Int GetActiveAtomicCounterCount() const { return Artifacts().program->getNumAtomicCounters(); }
Int GetActiveAttributesCount() const { return m_program->getNumPipeInputs(); } Int GetActiveAttributesCount() const { return Artifacts().program->getNumPipeInputs(); }
Int GetActiveUniformBlocksCount() const { return m_program->getNumUniformBlocks(); } // GL-visible uniform blocks only: the synthesized MGL_GLOBAL_UBO the relaxed parse
GLuint GetComputeLocalSize(Uint dim) const { return m_program->getLocalSize(static_cast<Int>(dim)); } // materializes for default-block uniforms is filtered out by DoReflection.
Int GetActiveAttributesMaxLength() const { return m_attribInNameMaxLength; } Int GetActiveUniformBlocksCount() const { return static_cast<Int>(Artifacts().glBlockIndexToTProgram.size()); }
Int GetActiveUniformBlocksMaxNameLength() const { return m_uniformBlockNameMaxLength; } 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 { Uint GetUniformBlockIndex(const char* name) const {
auto it = m_uniformBlockIndexByName.find(name); auto it = Artifacts().uniformBlockIndexByName.find(name);
if (it != m_uniformBlockIndexByName.end()) return it->second; if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
// Instances of an arrayed block are reflected as "Block[0]".."Block[N-1]"; // 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. // a bare "Block" query resolves to the first instance per GL semantics.
const String suffixedName = String(name) + "[0]"; const String suffixedName = String(name) + "[0]";
it = m_uniformBlockIndexByName.find(suffixedName); it = Artifacts().uniformBlockIndexByName.find(suffixedName);
if (it != m_uniformBlockIndexByName.end()) return it->second; if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
return 0xFFFFFFFFu; // GL_INVALID_INDEX return 0xFFFFFFFFu; // GL_INVALID_INDEX
} }
Bool IsActiveUniformBlock(Uint index) const { 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 // (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 // 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). // 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 { const String& GetUniformBlockName(Uint index) const {
auto& ubo = m_program->getUniformBlock((Int)index); auto& ubo = Artifacts().program->getUniformBlock(Artifacts().glBlockIndexToTProgram[index]);
return ubo.name; return ubo.name;
} }
@@ -443,8 +510,8 @@ namespace MobileGL::MG_State::GLState {
if (name.empty() || name.back() != ']') return index; if (name.empty() || name.back() != ']') return index;
const SizeT bracket = name.rfind('['); const SizeT bracket = name.rfind('[');
if (bracket == String::npos) return index; if (bracket == String::npos) return index;
const auto it = m_uniformBlockIndexByName.find(name.substr(0, bracket) + "[0]"); const auto it = Artifacts().uniformBlockIndexByName.find(name.substr(0, bracket) + "[0]");
if (it != m_uniformBlockIndexByName.end()) return it->second; if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
return index; return index;
} }
@@ -455,31 +522,64 @@ namespace MobileGL::MG_State::GLState {
Int GetUniformBlockActiveUniformCount(Uint index) const { Int GetUniformBlockActiveUniformCount(Uint index) const {
const Int ownerIndex = static_cast<Int>(GetUniformBlockMemberOwnerIndex(index)); const Int ownerIndex = static_cast<Int>(GetUniformBlockMemberOwnerIndex(index));
Int count = 0; Int count = 0;
for (Uint uniformIndex = 0; uniformIndex < m_activeUniformCount; ++uniformIndex) { for (Uint uniformIndex = 0; uniformIndex < Artifacts().activeUniformCount; ++uniformIndex) {
if (GetActiveUniformBlockIndex(uniformIndex) == ownerIndex) ++count; if (GetActiveUniformBlockIndex(uniformIndex) == ownerIndex) ++count;
} }
return count; return count;
} }
Bool IsUniformBlockReferencedByStage(Uint index, EShLanguage stage) const { 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); const auto stageMask = static_cast<EShLanguageMask>(1 << stage);
return (ubo.stages & stageMask) != 0; return (ubo.stages & stageMask) != 0;
} }
// Set by glUniformBlockBinding // Set by glUniformBlockBinding
void SetUniformBlockBinding(Uint index, Uint binding) { 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; return;
} }
m_uniformBlockBinding[index] = static_cast<Int>(binding); Artifacts().uniformBlockBinding[index] = static_cast<Int>(binding);
++m_backendStateVersion; ++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; } // Set by glShaderStorageBlockBinding, keyed by the block's GL name rather than by any
const Vector<Vector<unsigned>>& GetGeneratedSpirv() const { return m_generatedSpirv; } // 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 { Int GetShaderIndexByStage(ShaderStage stage) const {
auto it = std::find_if(m_shaders.begin(), m_shaders.end(), [stage](const SharedPtr<ShaderObject>& shader) { 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. // layout captures into; see NeedsScatteredTransformFeedbackCapture.
Uint32 packedOffsetBytes = 0; 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; } // ---- P1: everything a link PRODUCES, in one movable block ----
// 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 membership rule is mechanical, not editorial: this is exactly the field list
// the next glCreateProgram, this distinguishes a deleted-and-recreated program from the // ResetLinkArtifacts() clears (plus the four it forgot to - infoLog,
// original, so an identity cache can't false-hit on name recycling. // linkedFragDataLocation/Index and the geometry strip-capture pair - which are just
Uint64 GetLifetimeId() const { return m_lifetimeId; } // as much link output). Nothing else belongs here.
//
private: // Why a struct: once glLinkProgram runs on a worker (P1 stage 4) the worker writes
void ResetLinkArtifacts(); // its OWN LinkArtifacts and the GL thread publishes it with a single move, instead
void DoReflection(); // of thirty cross-thread field assignments. Until then this is a pure refactor.
// Resolves the requested transform feedback varyings against the linked //
// vertex stage; fails the link (GL semantics) on unknown or duplicate // Access rule (invariant I5): the member below is private and reachable ONLY
// names or exceeded capture limits. // through ProgramObject::Artifacts(), which calls EnsureLinkJoined() first. That is
Bool ResolveTransformFeedbackVaryings(); // what makes "every read of link output joins the pending link" a property the
void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate); // compiler checks rather than a review item - a new reader cannot spell the field
void GenerateBinary(); // without going through the gate.
void WaitUntilGenerationCompleted() const; struct LinkArtifacts {
void AddDefaultFragmentShaderIfMissing(); SharedPtr<glslang::TProgram> program;
Bool ValidateFragmentOutputLocations(); Vector<Vector<unsigned>> generatedSpirv;
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;
// Attributes (Vertex in) // Attributes (Vertex in)
UnorderedMap<String, Uint> m_explicitAttribLocations; Vector<String> attribs;
Vector<String> m_attribs; Vector<GLenum> attribTypes;
Vector<GLenum> m_attribTypes;
// FragData (Frag out) // FragData (Frag out): the per-link snapshot of the explicit request maps.
UnorderedMap<String, Uint> m_explicitFragDataLocation; UnorderedMap<String, Uint> linkedFragDataLocation;
UnorderedMap<String, Uint> m_linkedFragDataLocation; UnorderedMap<String, Uint> linkedFragDataIndex;
// 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;
// Uniforms // GL-facing index spaces (see the translation helpers above): GL active-uniform
UnorderedMap<String, Uint> m_uniformLocations; // 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, // Ordered by location,
// aka. m_uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`" // aka. uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`"
Vector<Int> m_uniformIndexInTProgram; Vector<Int> uniformIndexInTProgram;
// ditto. Will be set at glUniform1i // ditto. Will be set at glUniform1i
Vector<Int> m_uniformSamplerOrImageUnitIndex; Vector<Int> uniformSamplerOrImageUnitIndex;
UnorderedMap<String, Uint> m_explicitOpaqueUniformBindings; UnorderedMap<String, Uint> explicitOpaqueUniformBindings;
// Ordered by uniform block index // Ordered by uniform block index
// index is DIFFERENT from binding!!! // index is DIFFERENT from binding!!!
@@ -599,27 +658,222 @@ namespace MobileGL::MG_State::GLState {
// `prog->getUniformBlock(i) == "BlockName"` // `prog->getUniformBlock(i) == "BlockName"`
// These stuff are present for GL semantics, not for backend inspection // These stuff are present for GL semantics, not for backend inspection
// These may change after-link (because GL spec decided to have `glUniformBlockBinding`) // These may change after-link (because GL spec decided to have `glUniformBlockBinding`)
UnorderedMap<String, Uint> m_uniformBlockIndexByName; UnorderedMap<String, Uint> uniformBlockIndexByName;
Vector<Int> m_uniformBlockBinding; 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 // Need to be reflected after linking of SPIR-V binary
Vector<Uint> m_uniformOffsets; Vector<Uint> uniformOffsets;
Vector<Uint> m_uniformSizesInBytes; Vector<Uint> uniformSizesInBytes;
Vector<Uint8> m_globalUboScratch; Vector<Uint8> globalUboScratch;
Uint m_activeUniformCount = 0; Uint activeUniformCount = 0;
Uint m_maxUniformLocation = 0; Uint maxUniformLocation = 0;
Int m_uniformNameMaxLength = 0; Int uniformNameMaxLength = 0;
Int m_attribInNameMaxLength = 0; Int attribInNameMaxLength = 0;
Int m_uniformBlockNameMaxLength = 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_deleteStatus = false;
Bool m_linkStatus = false;
Bool m_binaryRetrievableHint = false; Bool m_binaryRetrievableHint = false;
Bool m_separable = false; Bool m_separable = false;
Bool m_validateStatus = true; 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 // Backend-owned content-hash memo (see GetBackendHashMemo): valid only while
// m_backendStateVersion matches. Several slots, not one: a backend may resolve the same // m_backendStateVersion matches. Several slots, not one: a backend may resolve the same
@@ -635,20 +889,20 @@ namespace MobileGL::MG_State::GLState {
mutable Array<BackendHashMemoSlot, kBackendHashMemoSlotCount> m_backendHashMemoSlots{}; mutable Array<BackendHashMemoSlot, kBackendHashMemoSlotCount> m_backendHashMemoSlots{};
mutable SizeT m_backendHashMemoNextSlot = 0; mutable SizeT m_backendHashMemoNextSlot = 0;
mutable Uint32 m_backendHashMemoVersion = ~0u; mutable Uint32 m_backendHashMemoVersion = ~0u;
Uint32 m_uboContentVersion = 0; mutable Uint32 m_uboContentVersion = 0;
Uint32 m_linkVersion = 0; mutable Uint32 m_linkVersion = 0;
// Transform feedback: request (applies at next link) and linked snapshot. // ---- Link OUTPUT ----
Vector<String> m_requestedXfbVaryings; // Written by the link and by the post-link setters GL allows (glUniform1i's sampler
GLenum m_requestedXfbBufferMode = GL_INTERLEAVED_ATTRIBS; // unit, glUniformBlockBinding). Reachable only through Artifacts(); see LinkArtifacts.
Vector<XfbVarying> m_xfbVaryings; //
Vector<Uint32> m_xfbStrides; // Mutable because publishing is a READ-side operation: a const getter has to be able
Vector<Uint32> m_gsStripTriangles; // to settle an outstanding link before answering it.
Bool m_gsStripCaptureFixup = false; mutable LinkArtifacts m_artifacts;
GLenum m_gsInputPrimitive = GL_NONE;
GLenum m_xfbBufferMode = GL_INTERLEAVED_ATTRIBS; // The link job, from enqueue until the first observable read pulls its result. Null
Int m_xfbVaryingNameMaxLength = 0; // means m_artifacts is already the answer - which is the state every reader outside
Bool m_xfbNeedsScatteredCapture = false; // the pending window sees, and the whole reason the gate above is one branch.
Uint32 m_xfbPackedStride = 0; mutable SharedPtr<ProgramLinkTask> m_pendingLink;
}; };
} // namespace MobileGL::MG_State::GLState } // namespace MobileGL::MG_State::GLState
@@ -11,7 +11,7 @@
namespace MobileGL::MG_State::GLState { namespace MobileGL::MG_State::GLState {
Uint ProgramState::CreateProgram() { Uint ProgramState::CreateProgram() {
Uint programId = 0; Uint programId = 0;
m_programIndexGenerator.Generate(1, &programId); m_programShaderNameGenerator.Generate(1, &programId);
EnsureIndexAvail(programId, m_programObjects); EnsureIndexAvail(programId, m_programObjects);
auto programObject = MakeShared<ProgramObject>(programId); auto programObject = MakeShared<ProgramObject>(programId);
if (programObject == nullptr) return 0; if (programObject == nullptr) return 0;
@@ -39,11 +39,19 @@ namespace MobileGL::MG_State::GLState {
void ProgramState::DestroyProgramSlot(const Uint program) { void ProgramState::DestroyProgramSlot(const Uint program) {
auto& programObject = m_programObjects[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 // Snapshot the attachments: deleting the program is a detach point for shaders
// that were flagged with glDeleteShader while still attached. // that were flagged with glDeleteShader while still attached.
const Vector<SharedPtr<ShaderObject>> attachedShaders = programObject->GetAttachedShaders(); const Vector<SharedPtr<ShaderObject>> attachedShaders = programObject->GetAttachedShaders();
programObject.reset(); programObject.reset();
m_programIndexGenerator.Delete(program); m_programShaderNameGenerator.Delete(program);
for (const auto& shader : attachedShaders) { for (const auto& shader : attachedShaders) {
const Uint shaderName = shader->GetExternalIndex(); const Uint shaderName = shader->GetExternalIndex();
if (CheckIndexAvail(shaderName, m_shaderObjects) && m_shaderObjects[shaderName] == shader) { if (CheckIndexAvail(shaderName, m_shaderObjects) && m_shaderObjects[shaderName] == shader) {
@@ -77,9 +85,10 @@ namespace MobileGL::MG_State::GLState {
Uint ProgramState::CreateShader(ShaderStage stage) { Uint ProgramState::CreateShader(ShaderStage stage) {
Uint shaderId = 0; Uint shaderId = 0;
m_shaderIndexGenerator.Generate(1, &shaderId); m_programShaderNameGenerator.Generate(1, &shaderId);
EnsureIndexAvail(shaderId, m_shaderObjects); 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; if (shaderObject == nullptr) return 0;
m_shaderObjects[shaderId] = shaderObject; m_shaderObjects[shaderId] = shaderObject;
return shaderId; return shaderId;
@@ -91,6 +100,31 @@ namespace MobileGL::MG_State::GLState {
return m_shaderObjects[shader]; 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) { void ProgramState::MarkShaderObjectForDeletion(Uint shader) {
if (!CheckIndexAvail(shader, m_shaderObjects)) return; if (!CheckIndexAvail(shader, m_shaderObjects)) return;
auto& shaderObject = m_shaderObjects[shader]; auto& shaderObject = m_shaderObjects[shader];
@@ -120,8 +154,15 @@ namespace MobileGL::MG_State::GLState {
auto& shaderObject = m_shaderObjects[shader]; auto& shaderObject = m_shaderObjects[shader];
if (shaderObject == nullptr || !shaderObject->GetDeleteStatus()) return; if (shaderObject == nullptr || !shaderObject->GetDeleteStatus()) return;
if (ShaderHasGLVisibleAttachment(shaderObject)) 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(); shaderObject.reset();
m_shaderIndexGenerator.Delete(shader); m_programShaderNameGenerator.Delete(shader);
} }
Bool ProgramState::ValidateShaderObject(Uint shader) const { Bool ProgramState::ValidateShaderObject(Uint shader) const {
@@ -10,6 +10,8 @@
#include <Includes.h> #include <Includes.h>
#include <MG_Util/Miscellany/IndexGenerator.h> #include <MG_Util/Miscellany/IndexGenerator.h>
#include "ProgramObject.h" #include "ProgramObject.h"
#include "ShaderCompileAdoptionMap.h"
#include "ShaderPreprocessCache.h"
namespace MobileGL::MG_State::GLState { namespace MobileGL::MG_State::GLState {
class ProgramState { class ProgramState {
@@ -33,6 +35,29 @@ namespace MobileGL::MG_State::GLState {
const SharedPtr<ProgramObject>& GetCurrentProgram() const { return m_currentProgram; } 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: private:
Bool ShaderHasGLVisibleAttachment(const SharedPtr<ShaderObject>& shaderObject) const; Bool ShaderHasGLVisibleAttachment(const SharedPtr<ShaderObject>& shaderObject) const;
// Frees the name slot and releases orphaned attached shaders; the immediate half // 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); vec.resize(idx + 1);
} }
IndexGenerator<Uint> m_programIndexGenerator; // Programs and shaders share one GL name space (GL 3.3 core 2.11: a shader
Vector<SharedPtr<ProgramObject>> m_programObjects; // 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; Vector<SharedPtr<ShaderObject>> m_shaderObjects;
SharedPtr<ProgramObject> m_currentProgram; 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 // End of Source File Header
#include "ShaderObject.h" #include "ShaderObject.h"
#include <MG_Util/ShaderTranspiler/Types.h> #include "ShaderPreprocessCache.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h> #include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h> #include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h> #include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <MG_Util/ShaderTranspiler/glslang/UniformTraverser.h> #include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Backend/BackendObjects.h> #include <MG_Util/ShaderTranspiler/Types.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;
}
}
namespace MobileGL::MG_State::GLState { namespace MobileGL::MG_State::GLState {
void ShaderObject::SetShaderSource(const String& source) { void ShaderObject::SetShaderSource(const String& source) {
m_source = source; // P0b layer 1. glShaderSource always REPLACES the source, but replacing it with a
m_shader.reset(); // byte-identical one cannot change what a compile would produce: the whole
m_compileStatus = false; // pipeline (preprocess -> lexical checks -> glslang parse) is a pure function of
m_infoLog.clear(); // (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) { void ShaderObject::SetShaderSource(String&& source) {
m_source = Move(source); if (SourceMatchesCompiledState(source)) return;
m_shader.reset(); ReleaseCompileNode();
m_compileStatus = false; m_source = MakeShared<const String>(Move(source));
m_infoLog.clear(); 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() { void ShaderObject::Compile() {
using namespace MG_Util::ShaderTranspiler; // P0b layer 1, as a tri-state: the memo is "the node in m_compiled was built from
String compileSource = m_source; // the string m_source still points at". SetShaderSource only swaps that pointer when
MG_Util::ShaderTranspiler::PreprocessShaderSource(m_stage, compileSource); // 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) { // Two reasons to stay on this thread, one rule. Without the async flag the whole
const std::optional<String> localSizeError = ValidateComputeLocalSizeLimits(compileSource); // path must be byte-identical to the synchronous implementation, and a cache-less
if (localSizeError) { // object is an internal shader that compiles and reads its status in the same
m_compileStatus = false; // breath (see the constructor comment) - a job would only add a round trip.
m_shader.reset(); // AsyncShaderCompileActive(), not ...Enabled(): a glMaxShaderCompilerThreadsKHR(0)
m_infoLog = *localSizeError; // 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; return;
} }
} }
const std::optional<String> reservedError = AdoptCompileNode(MakeShared<ShaderCompileTask>(m_stage, m_source, sourceHash, env, m_preprocessCache,
MG_Util::ShaderTranspiler::FindReservedIdentifierViolation(compileSource); m_externalIndex));
if (reservedError) {
m_compileStatus = false; if (!runOnPool) {
m_shader.reset(); m_compiled->RunInline();
m_infoLog = *reservedError; // 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; return;
} }
// Registered BEFORE the post, so the very next glCompileShader in this burst can
// Compile for OpenGL here, so that we can do validation and link // adopt it however fast a worker picks it up. Registration is an index entry only -
// like a real OpenGL driver at linking stage // the map holds a WeakPtr and never keeps a node alive.
// Will compile for other backends later. if (m_adoptionMap) m_adoptionMap->Register(m_compiled);
ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(m_stage), MG_Util::Async::ShaderCompilePool::Get().Post(m_compiled);
.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());
}
} }
void ShaderObject::MarkAsDeleted() { void ShaderObject::MarkAsDeleted() {
@@ -8,48 +8,229 @@
#pragma once #pragma once
#include <Includes.h> #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 { namespace MobileGL {
enum class ShaderStage {
Vertex,
TessControl,
TessEval,
Geometry,
Fragment,
Compute,
ShaderStageCount,
Unknown = -1
};
namespace MG_State::GLState { 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 { class ShaderObject {
public: public:
ShaderObject(const ShaderStage stage, Uint externalIndex) // `preprocessCache` is the owning context's cross-object memo (P0b layer 2).
: m_stage(stage), m_externalIndex(externalIndex) {} // 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(const String& source);
void SetShaderSource(String&& source); void SetShaderSource(String&& source);
void Compile(); 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(); 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; } Uint GetExternalIndex() const { return m_externalIndex; }
ShaderStage GetShaderStage() const { return m_stage; } ShaderStage GetShaderStage() const { return m_stage; }
const String& GetShaderSource() const { return m_source; } // No join: the source is GL-thread-owned, and a worker only ever reads the
const SharedPtr<glslang::TShader>& GetCompiledShader() const { return m_shader; } // immutable snapshot it was handed at enqueue.
const String& GetInfoLog() const { return m_infoLog; } const String& GetShaderSource() const { return *m_source; }
const UnorderedMap<String, Uint>& GetUniformLocations() const { return m_uniforms; } // The snapshot itself, for whoever needs to hand it to a job.
Bool GetCompileStatus() const { return m_compileStatus; } 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; } 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: 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 Uint m_externalIndex = 0;
const ShaderStage m_stage; // The pre-glShaderSource state, shared by every untouched object rather than
String m_source; // allocated per glCreateShader.
SharedPtr<glslang::TShader> m_shader; static const SharedPtr<const String>& EmptySource() {
UnorderedMap<String, Uint> m_uniforms; 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_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 MG_State::GLState
} // namespace MobileGL } // 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 "VertexArrayObject.h"
#include <atomic>
namespace MobileGL::MG_State::GLState { 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) { VertexArrayObject::VertexArrayObject(Uint externIndex) : m_externalIndex(externIndex) {
for (int index = 0; index < MAX_VERTEX_ATTRIBS; ++index) { for (int index = 0; index < MAX_VERTEX_ATTRIBS; ++index) {
auto& attr = m_attributes[index]; auto& attr = m_attributes[index];
@@ -84,6 +84,18 @@ namespace MobileGL {
Uint GetExternalIndex() const; 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); void SetAttributeDivisor(Uint index, Uint divisor);
Uint GetAttributeDivisor(Uint index) const; Uint GetAttributeDivisor(Uint index) const;
@@ -185,7 +197,10 @@ namespace MobileGL {
return mapping; return mapping;
} }
static Uint64 AllocateLifetimeId();
const Uint m_externalIndex = 0; const Uint m_externalIndex = 0;
const Uint64 m_lifetimeId = AllocateLifetimeId();
Array<VertexAttribute, MAX_VERTEX_ATTRIBS> m_attributes; Array<VertexAttribute, MAX_VERTEX_ATTRIBS> m_attributes;
Array<VertexAttributeVersion, MAX_VERTEX_ATTRIBS> m_attributeVersions; Array<VertexAttributeVersion, MAX_VERTEX_ATTRIBS> m_attributeVersions;
BindingSlot<BufferObject> m_indexBufferBindingSlot; BindingSlot<BufferObject> m_indexBufferBindingSlot;
+311 -4
View File
@@ -455,16 +455,24 @@ TEST_F(BufferTest, BindBufferBaseZeroUnbindsBindingPoint) {
} }
TEST_F(BufferTest, BindBufferRangeZeroUnbindsBindingPoint) { 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; GLuint buffer = 0;
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer); MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, 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); auto& point = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, 3);
ASSERT_NE(point.GetBoundObject(), nullptr); ASSERT_NE(point.GetBoundObject(), nullptr);
EXPECT_EQ(point.GetRange().start, 4); EXPECT_EQ(point.GetRange().start, static_cast<SizeT>(offset));
EXPECT_EQ(point.GetRange().end, 12); EXPECT_EQ(point.GetRange().end, static_cast<SizeT>(offset + size));
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 3, 0, 0, 0); MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 3, 0, 0, 0);
EXPECT_EQ(point.GetBoundObject(), nullptr); EXPECT_EQ(point.GetBoundObject(), nullptr);
@@ -540,6 +548,305 @@ TEST_F(BufferTest, ClearNamedBufferSubDataRepeatsPattern) {
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR); 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; using namespace MobileGL::MG_Impl::GLImpl;
class GeneralBufferTest : public ::testing::Test { 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(BackendLoader)
add_subdirectory(Buffer) 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(EGLState)
add_subdirectory(Framebuffer) add_subdirectory(Framebuffer)
add_subdirectory(Texture) add_subdirectory(Texture)
@@ -74,6 +77,7 @@ add_subdirectory(Program)
add_subdirectory(Query) add_subdirectory(Query)
add_subdirectory(Pipeline) add_subdirectory(Pipeline)
add_subdirectory(ShaderTranspiler) add_subdirectory(ShaderTranspiler)
add_subdirectory(Util)
if (ENABLE_INTEGRATION_TESTS) if (ENABLE_INTEGRATION_TESTS)
add_subdirectory(Backend/DirectVulkan) add_subdirectory(Backend/DirectVulkan)
endif() endif()
@@ -13,10 +13,13 @@
#include "Includes.h" #include "Includes.h"
#include "Init.h" #include "Init.h"
#include <MG_Backend/BackendObjects.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_Backend/DirectGLES/Utils.h>
#include <MG_Impl/GLImpl/Buffer/GL_Buffer.h> #include <MG_Impl/GLImpl/Buffer/GL_Buffer.h>
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h> #include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
#include <MG_Impl/GLImpl/Getter/GL_Getter.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_Impl/GLImpl/Texture/GL_Texture.h>
#include <MG_State/GLState/Core.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); MG_Impl::GLImpl::ReadPixels(0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, pixelStorage);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_FRAMEBUFFER_OPERATION); 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/ShaderCompiler.cpp
${MGL_ROOT}/MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp ${MGL_ROOT}/MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp
${MGL_ROOT}/MobileGL/MG_Util/ShaderTranspiler/glslang/UniformTraverser.cpp ${MGL_ROOT}/MobileGL/MG_Util/ShaderTranspiler/glslang/UniformTraverser.cpp
${MGL_ROOT}/MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp
) )
target_include_directories(ProgramUtilTest PRIVATE target_include_directories(ProgramUtilTest PRIVATE
@@ -27,6 +28,124 @@ add_executable(
ProgramTest.cpp 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 target_include_directories(ProgramTest PRIVATE
${MGL_ROOT}/include ${MGL_ROOT}/include
${MGL_ROOT}/MobileGL ${MGL_ROOT}/MobileGL
@@ -41,3 +160,16 @@ target_link_libraries(
include(GoogleTest) include(GoogleTest)
gtest_discover_tests(ProgramUtilTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(ProgramUtilTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ProgramInterfaceTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
# 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/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h" #include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_State/GLState/Core.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" #include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
using namespace MobileGL; using namespace MobileGL;
@@ -346,6 +348,29 @@ void main() {
EXPECT_EQ(GetError(), GL_NO_ERROR); 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) { TEST_F(ProgramTest, DirectVulkanStorageBlockUsesShaderLayoutBinding) {
char infoLog[1024] = ""; char infoLog[1024] = "";
const char* csSrc = R"(#version 460 core 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(IsShader(fs), GL_FALSE);
EXPECT_EQ(GetError(), GL_NO_ERROR); 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/SpirvPasses/RenameSamplerFunctionParameterPass.h>
#include <MG_Util/ShaderTranspiler/Types.h> #include <MG_Util/ShaderTranspiler/Types.h>
#include <MG_Util/ShaderTranspiler/glslang/UniformTraverser.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/libspirv.hpp>
#include <spirv-tools/optimizer.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 // 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 // unsupported directives into #error, so reading one out of a comment manufactures a compile
// failure for a shader that never asked for the extension. // 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 const char* vs = R"(#version 150
@@ -2248,3 +2201,451 @@ TEST_F(ProgramUtilTest, RewriteLinearSubgroupPrefixScanRejectsPartialOrUnsafeTem
"float other = shuffleNV(1.0f, 0u, 32u);\n "); "float other = shuffleNV(1.0f, 0u, 32u);\n ");
expectUnchanged(std::move(nvShuffleCall)); 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_EQ(next, map.end());
EXPECT_TRUE(map.empty()); 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 <Config.h>
#include <MG_Backend/BackendObjects.h> #include <MG_Backend/BackendObjects.h>
#include <MG_Backend/DirectGLES/Managers.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/Framebuffer/GL_Framebuffer.h>
#include <MG_Impl/GLImpl/Getter/GL_Getter.h> #include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <MG_Impl/GLImpl/RenderState/GL_RenderState.h> #include <MG_Impl/GLImpl/RenderState/GL_RenderState.h>
@@ -27,6 +28,7 @@
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h> #include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h> #include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToMG/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/Math/SmallFloat.h>
#include <MG_Util/Texture/PixelStoreProcessor.h> #include <MG_Util/Texture/PixelStoreProcessor.h>
#include <MG_Util/Texture/TextureFormatProcessor.h> #include <MG_Util/Texture/TextureFormatProcessor.h>
@@ -2672,3 +2674,505 @@ TEST_F(TextureTest, DecodeShadowDataToWideRGBACoversComponentAndPackedLayouts) {
EXPECT_EQ(rgba[3], 2u); 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); 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);
}
+186
View File
@@ -0,0 +1,186 @@
// MobileGL - MobileGL/MG_Util/Async/JobNode.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 "JobNode.h"
#include "ShaderCompilePool.h"
#include <MG_State/GLState/Core.h>
namespace MobileGL::MG_Util::Async {
namespace {
Bool IsTerminalState(const JobState state) {
return state == JobState::Complete || state == JobState::Cancelled;
}
// Job BODIES have been contained since stage 1 (JobNode::Run); continuations were
// not, and stage 4 introduces the first real ones. A continuation runs on whichever
// thread drove the node terminal - for a compile that finished on a worker, that is
// inside an Asio handler, where an escaping exception means thread_pool::run()
// rethrows and the process terminates. It would also skip every continuation after
// it in the list, stranding unrelated dependents.
//
// Containing it here is a backstop, not the contract: a continuation cannot be
// repaired from the outside (the dispatcher has no idea what the callback was for),
// so the registrar still owns "this cannot fail". See JobNode::OnTerminal.
void RunContinuation(const std::function<void()>& continuation) {
if (!continuation) return;
try {
continuation();
} catch (const std::exception& e) {
MGLOG_E("JobNode: a terminal continuation threw (%s); it has been contained, but whatever it "
"was going to do did not happen",
e.what());
} catch (...) {
MGLOG_E("JobNode: a terminal continuation threw a non-std exception; it has been contained, "
"but whatever it was going to do did not happen");
}
}
} // namespace
Bool JobNode::IsTerminal() const { return IsTerminalState(m_state.load(std::memory_order_acquire)); }
Bool JobNode::IsComplete() const { return m_state.load(std::memory_order_acquire) == JobState::Complete; }
Bool JobNode::IsCancelled() const { return m_state.load(std::memory_order_acquire) == JobState::Cancelled; }
Bool JobNode::IsCancellationRequested() const { return m_cancelled.load(std::memory_order_acquire); }
JobState JobNode::State() const { return m_state.load(std::memory_order_acquire); }
// The single place a node changes state. Keeping every transition here is what makes the
// continuation list exactly-once: the same critical section that publishes the terminal
// state also takes ownership of the callbacks, so a concurrent OnTerminal either lands in
// the list before the swap or sees the terminal state and runs inline - never neither and
// never both.
Bool JobNode::TryTransition(const JobState from, const JobState to) {
const Bool terminal = IsTerminalState(to);
Vector<std::function<void()>> continuations;
{
const std::lock_guard<std::mutex> lock(m_mutex);
if (m_state.load(std::memory_order_relaxed) != from) return false;
m_state.store(to, std::memory_order_release);
if (terminal) continuations.swap(m_continuations);
}
if (!terminal) return true;
m_cv.notify_all();
// Run continuations OUTSIDE the lock: a continuation is free to call back into this
// node (IsComplete, State) and, in the link-dependency case, to post the dependent
// job to the pool from whichever thread drove this node terminal. Individually
// contained, so one broken dependent cannot strand the rest of the list.
for (auto& continuation : continuations) {
RunContinuation(continuation);
}
return true;
}
void JobNode::Run() {
if (m_cancelled.load(std::memory_order_acquire)) {
TryTransition(JobState::Pending, JobState::Cancelled);
return;
}
// Loses to a concurrent Cancel() that already took the node terminal, and to a second
// dispatch of the same node. Either way there is nothing left to do.
if (!TryTransition(JobState::Pending, JobState::Running)) return;
try {
RunBody();
} catch (const std::exception& e) {
// Asio propagates an exception escaping a handler out of thread_pool::run(),
// which means std::terminate for the whole process. Every job boundary contains
// it and reports the job as Cancelled; the joining GL thread then sees a node
// that produced no result, which is the same shape as an abandoned node.
diagnostics.logLines.push_back(std::format("Job body threw: {}", e.what()));
TryTransition(JobState::Running, JobState::Cancelled);
return;
} catch (...) {
diagnostics.logLines.emplace_back("Job body threw a non-std exception");
TryTransition(JobState::Running, JobState::Cancelled);
return;
}
// Debug-only tripwire for the design's section 6 invariant: a compile or link body
// must not need to raise a GL error. Anything that does belongs in the GL-thread
// prologue of CompileShader_State / LinkProgram_State, next to the active-XFB relink
// rejection that already works that way.
MOBILEGL_ASSERT(diagnostics.errors.empty(),
"JobNode: a job body recorded %zu deferred GL error(s); compile and link bodies must not "
"raise GL errors (see the P1 design, section 6)",
diagnostics.errors.size());
TryTransition(JobState::Running,
m_cancelled.load(std::memory_order_acquire) ? JobState::Cancelled : JobState::Complete);
}
void JobNode::RunInline() { Run(); }
void JobNode::Wait() {
// Invariant I4, mechanically enforced: no job body ever blocks on another job, so the
// pool can never deadlock with all its workers waiting on each other.
MOBILEGL_ASSERT(!ShaderCompilePool::IsPoolThread(),
"JobNode::Wait() called from a pool thread; job dependencies must be resolved by posting "
"late (SubmitAfter), never by waiting from inside a body");
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this] { return IsTerminalState(m_state.load(std::memory_order_relaxed)); });
}
void JobNode::Cancel() {
m_cancelled.store(true, std::memory_order_release);
// A node that never reached a worker settles right here. Doing this rather than
// waiting for a dispatch that may never come is what lets every cancel site
// (glShaderSource over a pending compile, glDeleteProgram, teardown) drop the node
// without a wait and without stranding a dependent link job behind it.
TryTransition(JobState::Pending, JobState::Cancelled);
}
void JobNode::OnTerminal(std::function<void()> fn) {
if (!fn) return;
{
const std::lock_guard<std::mutex> lock(m_mutex);
if (!IsTerminalState(m_state.load(std::memory_order_relaxed))) {
m_continuations.push_back(Move(fn));
return;
}
}
// Already terminal: the caller's thread runs it, through the same guard the deferred
// path uses. OnTerminal is reached from Link()'s GL-thread prologue as well as from a
// worker, and glLinkProgram is not a place an exception may escape from either.
RunContinuation(fn);
}
void ApplyDeferredDiagnostics(JobNode& node) {
MOBILEGL_ASSERT(!ShaderCompilePool::IsPoolThread(),
"ApplyDeferredDiagnostics() called from a pool thread; deferred diagnostics exist precisely "
"so that a worker never touches the GL error state");
MOBILEGL_ASSERT(node.IsTerminal(),
"ApplyDeferredDiagnostics() called on a job that has not settled; its diagnostics are still "
"being written");
if (!node.diagnostics.logLines.empty()) {
Vector<String> lines;
lines.swap(node.diagnostics.logLines);
for (const String& line : lines) {
MGLOG_W("%s", line.c_str());
}
}
if (node.diagnostics.errors.empty()) return;
Vector<DeferredError> errors;
errors.swap(node.diagnostics.errors);
// Ascending sequence == job-enqueue order == the order a serial implementation would
// have recorded them in, which is what decides WHICH payload the application sees:
// MobileGL implements GL's sticky-flag semantics, so a repeat of an already-pending
// code is discarded and only the first occurrence of each code survives.
std::sort(errors.begin(), errors.end(),
[](const DeferredError& a, const DeferredError& b) { return a.sequence < b.sequence; });
if (!MG_State::pGLContext) return;
for (DeferredError& error : errors) {
MG_State::pGLContext->RecordError(error.code, Move(error.info));
}
}
} // namespace MobileGL::MG_Util::Async
+142
View File
@@ -0,0 +1,142 @@
// MobileGL - MobileGL/MG_Util/Async/JobNode.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/Types.h>
#include <MG_State/GLState/ErrorState/ErrorCode.h>
#include <MG_State/GLState/ErrorState/ErrorInfo.h>
#include <condition_variable>
namespace MobileGL::MG_Util::Async {
enum class JobState : Uint8 {
Pending, // constructed, not started; may still be sitting in a queue
Running, // a worker is inside RunBody()
Complete, // RunBody() returned normally and the node's outputs are readable
Cancelled, // abandoned before it started, cancelled mid-run, or threw
};
// A GL error a job body wants to raise. Nothing in the compile/link pipeline produces
// one today (see the design's section 6: GL defines compile/link *failure* as
// COMPILE_STATUS/LINK_STATUS plus an info log, not as a GL error, which is exactly why
// asynchronous compilation is legal at all), and JobNode::Finish asserts the vector is
// still empty in debug builds. The mechanism exists so that the day a body genuinely
// needs to raise one, the fix is to append here and let the join replay it on the GL
// thread - not to reach for pGLContext->RecordError() from a worker.
struct DeferredError {
Uint64 sequence = 0; // job-global monotonic counter, assigned at record time
ErrorCode code = ErrorCode::NoError;
UniquePtr<ErrorInfo> info;
};
struct JobDiagnostics {
Vector<DeferredError> errors; // replayed, in ascending `sequence`, by the join
Vector<String> logLines; // worker-side MGLOG text, flushed in order by the join
};
// The scheduling primitive every asynchronous compile and link is built on. A node owns
// its inputs and its outputs; a worker reads only the former and writes only the latter,
// which is what makes the "no worker touches GL-thread state" invariant structural
// rather than review-enforced.
//
// State machine, and the only legal transitions:
// Pending -> Running (a worker picked the node up)
// Pending -> Cancelled (cancelled before any worker started it)
// Running -> Complete (RunBody() returned normally)
// Running -> Cancelled (cancelled mid-run, or RunBody() threw)
// Complete and Cancelled are terminal and the node is immutable afterwards, so every
// reader that observed IsTerminal() may read the outputs without further synchronization.
//
// enable_shared_from_this because a dependency edge outlives its registrar: a node that
// posts itself from another node's continuation (ProgramLinkTask::OnDepSettled) has to
// hand the pool a strong reference from inside itself. Every JobNode is therefore created
// through MakeShared - a stack-allocated one may not use SubmitAfter-style chaining.
class JobNode : public std::enable_shared_from_this<JobNode> {
public:
JobNode() = default;
virtual ~JobNode() = default;
JobNode(const JobNode&) = delete;
JobNode& operator=(const JobNode&) = delete;
// Lock-free and non-blocking - safe from any thread, including a pool thread.
Bool IsTerminal() const;
Bool IsComplete() const; // Complete only; this is what backs GL_COMPLETION_STATUS_KHR
Bool IsCancelled() const; // settled AS cancelled - the outcome, not the request
JobState State() const;
// The cancellation *request*, which is what a body polls to bail out early: a
// running job stays Running until its body returns, so IsCancelled() is still false
// at that point. Kept separate from IsCancelled() precisely so the two questions
// ("should I stop?" and "did it end up cancelled?") cannot be confused.
Bool IsCancellationRequested() const;
// Blocks until the node is terminal. GL thread only: a job body that waited on
// another job could deadlock the whole pool, so this asserts it is not called from a
// pool thread. Dependencies are resolved by posting late (see ProgramLinkTask::
// SubmitAfter), never by waiting from inside a body.
void Wait();
// Cooperative and non-blocking. A node that has not started yet goes terminal
// immediately, so anything waiting on it or chained behind it is released rather
// than stranded; a running node is flagged and settles as Cancelled when its body
// returns. Because every node owns its inputs and writes only into itself, an
// abandoned node is always safe to simply drop - the caller never waits.
void Cancel();
// Runs `fn` once, when this node goes terminal. If the node is ALREADY terminal,
// `fn` runs on the calling thread before OnTerminal returns. Exactly-once in both
// directions: the callback is either handed to the finishing thread or run inline,
// never both.
//
// A continuation must not throw. It is dispatched from whichever thread drove this
// node terminal, which on the pool side is an Asio handler - an exception escaping
// one propagates out of thread_pool::run() and terminates the process. The dispatcher
// contains a throw anyway (see RunContinuation) so that one broken continuation
// cannot strand the others, but the continuation itself is where the guarantee
// belongs: whoever registers one owns the "and it cannot fail" argument, because the
// dispatcher can only log, never repair. ProgramLinkTask::OnDepSettled is the worked
// example - it catches internally and cancels itself, because a link that is never
// posted is a joiner blocked forever.
void OnTerminal(std::function<void()> fn);
// Runs the body on the calling thread. The synchronous path (async disabled,
// context-less internal shaders, a pool that has been stopped) goes through here, so
// that "inline" and "on a worker" differ only in which thread executes RunBody().
void RunInline();
JobDiagnostics diagnostics;
protected:
virtual void RunBody() = 0;
private:
friend class ShaderCompilePool;
// Pool entry point: cancel check -> RunBody() (exceptions contained) -> Finish().
void Run();
Bool TryTransition(JobState from, JobState to);
mutable std::mutex m_mutex;
std::condition_variable m_cv;
std::atomic<JobState> m_state{JobState::Pending};
std::atomic<Bool> m_cancelled{false};
Vector<std::function<void()>> m_continuations;
};
// Replays a settled node's worker-side diagnostics on the calling thread: log lines
// first, in the order the body produced them, then any deferred GL error in ascending
// `sequence`. GL thread only - it is the join that calls this, which is exactly the
// point at which a deferred error becomes indistinguishable from one a serial
// implementation would have raised inside glCompileShader/glLinkProgram (an application
// cannot observe a pending job's effects by any other route).
//
// Drains what it replays, so calling it twice on one node is a no-op the second time.
// Must be called with the node terminal.
void ApplyDeferredDiagnostics(JobNode& node);
} // namespace MobileGL::MG_Util::Async
@@ -0,0 +1,807 @@
// MobileGL - MobileGL/MG_Util/Async/ShaderCompilePool.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 "ShaderCompilePool.h"
#include <Config.h>
#include <asio/post.hpp>
#include <asio/thread_pool.hpp>
#include <libfork/core.hpp>
#include <libfork/schedule/lazy_pool.hpp>
#include <cstdio>
#include <cstdlib>
#include <deque>
#include <functional>
#include <span>
namespace MobileGL::MG_Util::Async {
namespace {
// The memory ceiling, not a throughput guess: peak RSS during a pack load scales as
// (workers x largest glslang arena), and a shaderpack stage arena is large enough
// that four concurrent ones is already as much as a phone should be asked for.
constexpr Uint kMaxAutoShaderCompileThreads = 4;
// A core counts as "big" if its cpufreq ceiling is within 15% of the fastest core's.
// On a symmetric desktop that is every core; on a big.LITTLE phone it selects the
// cluster the GL thread itself runs on.
constexpr Uint64 kBigCoreFrequencyPercent = 85;
thread_local Bool tl_isPoolThread = false;
// Mirrors DirectGLES's InProcessTeardown()/EnsureProcessTeardownSentinel(): once the
// process has entered exit(), starting a worker thread is unsafe (cross-translation
// -unit static destruction order is unspecified, and glslang's process globals may
// already be gone). The flag is latched by an atexit handler registered lazily on
// first pool use, so it is guaranteed to run before any static destructor.
Bool g_processTeardown = false;
std::once_flag g_teardownSentinelOnce;
// The process-wide pool from Get(), for the atexit handler to stop. Never the
// stack-allocated pools a test builds - those join themselves in their destructor.
std::atomic<ShaderCompilePool*> g_processPool{nullptr};
Bool InProcessTeardown() { return g_processTeardown; }
void EnsureProcessTeardownSentinel() {
std::call_once(g_teardownSentinelOnce, [] {
std::atexit(+[] {
g_processTeardown = true;
// Latching the flag is not enough: a worker that is ALREADY inside
// glslang has to be out of it before static destruction reaches
// glslang's process globals, the SPIRV-Tools tables, or anything else a
// job body touches. This is the same wait Init.cpp's DestroyImpl does -
// it just also has to happen for a process that exits without ever
// calling eglTerminate, which is the norm for a test binary and legal
// for an application. Registered here, during main, so it runs before
// the destructors of statics constructed at load time.
if (ShaderCompilePool* pool = g_processPool.load(std::memory_order_acquire)) {
pool->StopAndDrain();
}
});
});
}
Uint64 ReadCpuMaxFrequencyKHz(const Uint cpu) {
const String path =
std::format("/sys/devices/system/cpu/cpu{}/cpufreq/cpuinfo_max_freq", cpu);
std::FILE* file = std::fopen(path.c_str(), "r");
if (file == nullptr) return 0;
unsigned long long value = 0;
const int scanned = std::fscanf(file, "%llu", &value);
std::fclose(file);
return scanned == 1 ? static_cast<Uint64>(value) : 0;
}
Uint DetectBigCoreCount() {
const Uint cpuCount = std::max(1u, std::thread::hardware_concurrency());
Vector<Uint64> frequencies;
frequencies.reserve(cpuCount);
for (Uint cpu = 0; cpu < cpuCount; ++cpu) {
const Uint64 frequency = ReadCpuMaxFrequencyKHz(cpu);
if (frequency == 0) break;
frequencies.push_back(frequency);
}
// Windows, macOS, and containers that hide the cpufreq tree land here, as does a
// partially readable tree: with no asymmetry information the honest answer is
// "every core is a big core", and the [1, 4] clamp bounds it anyway.
if (frequencies.size() != cpuCount) return cpuCount;
const Uint64 peak = *std::max_element(frequencies.begin(), frequencies.end());
const Uint64 threshold = peak * kBigCoreFrequencyPercent / 100;
Uint bigCores = 0;
for (const Uint64 frequency : frequencies) {
if (frequency >= threshold) ++bigCores;
}
return bigCores > 0 ? bigCores : cpuCount;
}
} // namespace
Bool AsyncShaderCompileEnabled() {
switch (MG_Config::Features.AsyncShaderCompile) {
case MG_Config::QuirkOverride::ForceOn: return true;
case MG_Config::QuirkOverride::ForceOff: return false;
case MG_Config::QuirkOverride::Auto: break;
}
return kAsyncShaderCompileDefault;
}
namespace {
// Written only by glMaxShaderCompilerThreadsKHR/ARB, i.e. only on the GL thread, but
// read by every enqueue decision, so it is atomic rather than plain: a worker never
// reads it, but a second GL thread in another context shares this process-wide pool.
std::atomic<Bool> g_asyncSuspendedByApplication{false};
} // namespace
void SetAsyncShaderCompileSuspended(const Bool suspended) {
g_asyncSuspendedByApplication.store(suspended, std::memory_order_release);
}
Bool IsAsyncShaderCompileSuspended() {
return g_asyncSuspendedByApplication.load(std::memory_order_acquire);
}
Bool AsyncShaderCompileActive() {
return AsyncShaderCompileEnabled() && !IsAsyncShaderCompileSuspended();
}
Uint DetectShaderCompileThreadCount() {
if (const Uint32 configured = MG_Config::Features.AsyncShaderCompileThreads; configured > 0) {
// An explicit request is honoured as given - it is the escape hatch for measuring
// scaling and for working around a device - so it is not squeezed into [1, 4].
return configured;
}
return std::clamp(DetectBigCoreCount(), 1u, kMaxAutoShaderCompileThreads);
}
// ---- Engine selection -----------------------------------------------------------------
const char* AsyncPoolEngineName(const AsyncPoolEngine engine) {
switch (engine) {
case AsyncPoolEngine::Libfork: return "libfork";
case AsyncPoolEngine::Asio: break;
}
return "asio";
}
AsyncPoolEngine ParseAsyncPoolEngine(const String& value) {
String lowered = value;
std::transform(lowered.begin(), lowered.end(), lowered.begin(),
[](const unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (lowered == "libfork") return AsyncPoolEngine::Libfork;
if (lowered == "asio" || lowered == "auto" || lowered.empty()) return AsyncPoolEngine::Asio;
// Not silent: a misspelt engine name resolving to the default would be
// indistinguishable from not having set the variable at all, and the only reason to
// set it is to know which engine ran.
MGLOG_W("Config: Ignoring invalid env variable MOBILEGL_ASYNC_POOL='%s'; expected asio|libfork, "
"using asio",
value.c_str());
return AsyncPoolEngine::Asio;
}
AsyncPoolEngine DetectAsyncPoolEngine() {
// A live std::getenv rather than an MG_Config::Features mirror, and deliberately so:
// a ShaderCompilePool is constructed by binaries that never call MobileGL::Initialize()
// and therefore never run MG_ConfigLoader::Init() - MG_Test/Util/JobNodeTest builds
// pools directly, and it is the suite that exercises the engines against each other.
// Reading Features there would silently resolve to the default and the libfork half of
// the test matrix would prove nothing. See the exemption list in Config.h.
//
// Resolved once per process (a function-local static): every pool in a process gets
// the same engine, so a process can never end up running two.
static const AsyncPoolEngine engine = [] {
const char* value = std::getenv("MOBILEGL_ASYNC_POOL");
const AsyncPoolEngine resolved = ParseAsyncPoolEngine(value != nullptr ? String(value) : String());
if (resolved != AsyncPoolEngine::Asio) {
MGLOG_I("ShaderCompilePool: MOBILEGL_ASYNC_POOL selected the %s execution engine",
AsyncPoolEngineName(resolved));
}
return resolved;
}();
return engine;
}
namespace {
// ---- The engine boundary ----------------------------------------------------------
// Submit() has exactly asio::post's contract, and ShaderCompilePool::Impl leans on all
// four halves of it:
// * it NEVER runs `fn` on the calling thread. DispatchLocked calls it while holding
// the pool's plain, non-recursive mutex, and a job body (or a terminal
// continuation it releases) is free to call Post() again - an inline run would
// deadlock on the lock this frame already owns.
// * it is callable from ANY thread, a worker of this very pool included:
// ProgramLinkTask::OnDepSettled posts the link job from whichever thread drove the
// last compile terminal, which is a worker.
// * it may throw, and when it does it must not have consumed the caller's job node,
// so Post/DispatchLocked can settle the node instead of stranding it Pending with
// a joiner blocked forever.
// * once it has accepted `fn`, `fn` WILL run. A dropped callable is a node nothing
// ever settles, so the engines run it themselves rather than discard it.
class JobExecutor {
public:
virtual ~JobExecutor() = default;
JobExecutor() = default;
JobExecutor(const JobExecutor&) = delete;
JobExecutor& operator=(const JobExecutor&) = delete;
virtual void Submit(std::function<void()> fn) = 0;
// Returns once every callable ever handed to Submit has finished running. The
// guarantee StopAndDrain sells to library teardown: after it returns, no worker is
// still inside a job body that could touch glslang's process globals.
virtual void JoinAll() = 0;
};
// ---- Engine 1: Asio (the shipped default) -----------------------------------------
class AsioJobExecutor final : public JobExecutor {
public:
explicit AsioJobExecutor(const Uint threads) : m_pool(threads) {}
// asio::post only enqueues; it never runs the handler on the calling thread, which
// is what makes calling it under the pool mutex safe.
void Submit(std::function<void()> fn) override { asio::post(m_pool, Move(fn)); }
void JoinAll() override { m_pool.join(); }
private:
asio::thread_pool m_pool;
};
// ---- Engine 2: libfork ------------------------------------------------------------
//
// libfork is a continuation-stealing fork-join runtime, and the shape that fits here is
// NOT fork-join: a job body is one coarse, blocking, non-forking unit (a glslang
// compile), and the concurrency budget that bounds peak RSS is Impl's, not the
// scheduler's. So libfork is used as a job executor - each dispatched job is a detached
// root task - and what it is being asked to beat is Asio's single scheduler queue with
// its per-worker work-stealing deques and sleeping workers.
//
// The one thing libfork forbids is the thing this pool does constantly: lf::schedule
// (which lf::detach is built on) THROWS lf::schedule_in_worker when the calling thread
// is a libfork worker, because workers may never block. Yet a worker submits on every
// job completion - RunOnWorker's tail refills the budget - and again whenever a
// terminal continuation posts (ProgramLinkTask::OnDepSettled). Routing those through a
// separate dispatch thread works but costs two thread wakeups per job, which measured
// 4x worse than Asio on short jobs. So instead a dispatched root is a CHAIN: when its
// body returns it takes the next queued job itself and runs it in the same coroutine
// on the same worker. The refill a worker submits is therefore absorbed by the very
// chain that submitted it - no scheduler round trip, no wakeup - and libfork is only
// entered for work that arrives from outside the pool.
//
// Absorption is bounded at one job per running chain, though, because a chain is one
// worker: past that bound the queue would be jobs the budget has already cleared,
// waiting behind each other on a single thread. See Submit.
//
// Why none of this can strand a job: the queue below is only ever added to from inside
// a running chain (tl_chainOwner == this), and a chain exits only when it finds the
// queue empty - unconditionally, whatever the bound says. Every other submitter goes
// to the dispatch thread or straight to lf::detach.
class LibforkJobExecutor;
// Which executor's chain, if any, is running on this thread. Deliberately narrower
// than ShaderCompilePool::IsPoolThread(): that flag is process-wide and latched
// forever, so a worker of a DIFFERENT pool would read as "mine" and queue a job into a
// chain that will never drain it. This says exactly "a chain of *this* executor is
// executing on this thread, and it will look at the queue again before it exits".
thread_local LibforkJobExecutor* tl_chainOwner = nullptr;
// One dispatched job, heap-owned. It reaches its coroutine as a POINTER passed BY
// VALUE: libfork forwards a root task's arguments into the coroutine frame, so a
// by-value pointer is copied into the frame, whereas anything passed by reference
// would dangle the moment lf::detach returns - and detach, unlike sync_wait, does not
// outlive the task.
struct LibforkJob {
std::function<void()> body;
LibforkJobExecutor* owner;
};
// A scheduler adaptor for lf::detach: it places external submissions round-robin over
// lf::lazy_pool's worker contexts instead of letting the pool pick one at random.
// Both reasons are load-bearing, and the second was worth 1.3x at a budget equal to
// the worker count - the configuration MobileGL actually ships, since maxConcurrency
// is clamped to the thread count:
// * lf::lazy_pool::schedule chooses its victim with a
// std::uniform_int_distribution over a lazy_pool-member xoshiro generator -
// unsynchronized mutable state, so two concurrent submissions are a data race
// inside libfork itself. An atomic cursor is not.
// * A worker's SUBMISSION list is drained only by that worker
// (worker_context::try_pop_all is documented "for use only by the owning worker
// thread"); a thief takes from the task deque, which is a different queue. So a
// job placed on a worker that is inside a long blocking body waits for that body
// rather than being stolen - and random placement of `budget` submissions over
// `budget` workers collides by the birthday rule. Round-robin lands the GL
// thread's burst one per worker, which is exactly the intended shape.
struct RoundRobinSubmitter {
std::span<lf::worker_context*> contexts;
std::atomic<Uint64>* cursor;
void schedule(const lf::submit_handle job) const {
const Uint64 index = cursor->fetch_add(1, std::memory_order_relaxed);
contexts[static_cast<SizeT>(index % contexts.size())]->schedule(job);
}
};
void RunLibforkChain(LibforkJob* raw) noexcept;
// The root task every dispatched chain runs as. libfork async function objects are
// copyable, captureless callables returning lf::task<>, whose first parameter is the
// combinator's synthesized first argument (unused here: this task neither forks nor
// joins). The coroutine exists purely as libfork's entry protocol; the loop is in
// RunLibforkChain.
inline constexpr auto kLibforkChainTask = [](auto /*self*/, LibforkJob* job) -> lf::task<void> {
RunLibforkChain(job);
co_return;
};
class LibforkJobExecutor final : public JobExecutor {
public:
explicit LibforkJobExecutor(const Uint threads)
: m_pool(static_cast<std::size_t>(std::max(1u, threads))), m_contexts(m_pool.contexts()),
m_fallback([this] { FallbackLoop(); }) {}
~LibforkJobExecutor() override {
JoinAll();
{
const std::lock_guard<std::mutex> lock(m_mutex);
m_fallbackStop = true;
}
m_fallbackCv.notify_all();
if (m_fallback.joinable()) m_fallback.join();
// m_pool is destroyed last, and only here: lf::lazy_pool may not be destructed
// while any submitted task can still run or submit more. JoinAll() has
// established the first and the joined fallback thread the second. Its
// destructor then joins the worker threads, so a worker still unwinding a
// finished coroutine frame is waited for rather than pulled out from under.
}
void Submit(std::function<void()> fn) override {
if (tl_chainOwner == this) {
const std::lock_guard<std::mutex> lock(m_mutex);
// The hot path: ONE job per running chain. A chain picks up exactly one
// queued job each time its body returns, so a queue no longer than the
// number of live chains is a queue every entry of which has a distinct
// worker waiting to take it - which is precisely the steady state this
// absorption exists for (every worker finishes a job and refills its own
// slot, all at once, with no scheduler round trip between them).
//
// Past that it is oversubscription, and absorbing it would be a
// correctness-preserving way to destroy the pool's parallelism: the
// budget would still say `maxConcurrency` jobs are in flight while one
// worker ran them one behind another. That is not hypothetical - it is
// the tail of a pack load, where one compile going terminal releases
// several programs at once (ShaderCompileAdoptionMap lets a single
// compile settle many) and the worker that drove it posts the whole
// burst into an otherwise idle pool. Measured before this branch existed:
// four such jobs took 4x one job's wall time on libfork and 1x on Asio.
//
// The overflow cannot go to lf::detach from here - a libfork worker may
// not schedule - so it goes to the dispatch thread, which detaches it to
// a worker of its own. That costs one thread wakeup; serializing costs a
// whole compile.
//
// The count is taken AFTER the push, not before: deque::push_back is
// strongly exception-safe, so an allocation failure here leaves `fn`
// intact for DispatchLocked to settle - but a count incremented in front
// of it would be a count nothing ever gives back, and JoinAll would wait
// on it forever.
const Bool takeable = m_chainQueue.size() < m_liveChains;
if (takeable) {
m_chainQueue.push_back(Move(fn));
++m_outstanding;
} else {
m_fallbackQueue.push_back(Move(fn));
++m_outstanding;
m_fallbackCv.notify_one();
}
return;
}
{
// Counted before anything can run it, so JoinAll cannot observe a zero
// that this job would have broken.
const std::lock_guard<std::mutex> lock(m_mutex);
++m_outstanding;
}
try {
DetachChain(Move(fn));
} catch (const lf::schedule_in_worker&) {
// Submitted from a libfork worker that is not running one of my chains -
// a worker of another ShaderCompilePool. libfork will not take a
// submission from there at all, and the queue above is not safe for it
// (no chain of mine is running on that thread to drain it), so it goes to
// the fallback thread, which is neither. DetachChain restored `fn` before
// it threw.
const std::lock_guard<std::mutex> lock(m_mutex);
m_fallbackQueue.push_back(Move(fn));
m_fallbackCv.notify_one();
} catch (...) {
// Out of memory. Give the count back and let the caller settle its node:
// that is Submit's contract and what DispatchLocked is written against.
Retire();
throw;
}
}
void JoinAll() override {
std::unique_lock<std::mutex> lock(m_mutex);
m_idleCv.wait(lock, [this] { return m_outstanding == 0; });
}
// A chain announces itself before it runs its first body, so that Submit's
// absorption rule can count the workers that are going to come back and ask for
// more. Under-counting is the only direction this can be wrong in (a detached
// chain is not counted until it starts), and under-counting only sends work to
// the dispatch thread that a chain could have taken - never the reverse.
void EnterChain() noexcept {
const std::lock_guard<std::mutex> lock(m_mutex);
++m_liveChains;
}
// The end of one job in a chain. Returns true having loaded `body` with the next
// job to run on this same worker, false when there is nothing left - after which
// the caller must touch neither `this` nor anything owned by it, because the
// count this drops to zero may be the one JoinAll is waiting for.
//
// `body` must arrive empty: the finished job's captures (a strong reference to its
// JobNode) are released by the chain, outside this lock, so that no JobNode
// destructor ever runs inside the executor's critical section.
Bool RetireAndTakeNext(std::function<void()>& body) noexcept {
const std::lock_guard<std::mutex> lock(m_mutex);
--m_outstanding;
if (!m_chainQueue.empty()) {
// Unconditional, and it has to stay that way: a chain that exited while
// the queue was non-empty could be the last one, and the entry would then
// be waiting on a worker that never comes. That is what makes the
// absorption bound in Submit a scheduling policy rather than a liveness
// requirement.
//
// swap, not move-assign: std::function's move assignment is not noexcept,
// and this function is.
body.swap(m_chainQueue.front());
m_chainQueue.pop_front();
return true; // the taken job's own count stays held
}
--m_liveChains;
// Notified while STILL HOLDING the lock, which is the whole reason this is not
// the usual notify-after-unlock. The wakeup this sends can be the one that
// lets JoinAll return and ~LibforkJobExecutor destroy m_idleCv - and a
// std::condition_variable may not be destroyed while another thread is inside
// notify_all() on it. Holding the lock across the notify means the waiter
// cannot re-acquire the mutex, and therefore cannot leave wait(), until this
// thread is out of both the notify and the unlock. ThreadSanitizer catches the
// other order immediately (pthread_cond_destroy vs pthread_cond_broadcast).
if (m_outstanding == 0) m_idleCv.notify_all();
return false;
}
private:
// Builds the root task and hands it to libfork. On any failure `fn` is restored,
// so the caller can still decide what to do with the job.
void DetachChain(std::function<void()>&& fn) {
// `new T{...}` allocates before it constructs, so a throwing operator new
// leaves `fn` untouched; the member move is std::function's noexcept one.
LibforkJob* job = new LibforkJob{Move(fn), this};
try {
lf::detach(RoundRobinSubmitter{m_contexts, &m_cursor}, kLibforkChainTask, job);
} catch (...) {
// lf::schedule upholds the strong exception guarantee, so nothing was
// scheduled and the payload is still ours.
const UniquePtr<LibforkJob> owned(job);
fn = Move(owned->body);
throw;
}
}
void Retire() noexcept {
// Under the lock, for the reason RetireAndTakeNext spells out.
const std::lock_guard<std::mutex> lock(m_mutex);
if (--m_outstanding == 0) m_idleCv.notify_all();
}
// The dispatch thread. It exists because lf::detach is illegal on a libfork worker
// and legal here, and it serves the two cases Submit cannot take itself: a
// submission from another pool's worker, and a chain's overflow past the
// one-job-per-chain bound. It sleeps otherwise, and it dispatches rather than
// executes - a body only ever runs here if libfork refuses the job outright.
void FallbackLoop() {
for (;;) {
std::function<void()> fn;
{
std::unique_lock<std::mutex> lock(m_mutex);
m_fallbackCv.wait(lock, [this] { return !m_fallbackQueue.empty() || m_fallbackStop; });
// Emptiness is checked before the stop flag so that a stop can never
// strand accepted work: an accepted job always runs, because the node
// behind it has a joiner that would otherwise block forever.
if (m_fallbackQueue.empty()) return;
fn.swap(m_fallbackQueue.front());
m_fallbackQueue.pop_front();
}
try {
DetachChain(Move(fn));
} catch (...) {
MGLOG_E("ShaderCompilePool: libfork refused a fallback dispatch; running the job on "
"the dispatch thread instead of dropping it");
RunHere(Move(fn));
}
}
}
// Last resort. Running the body here costs this engine its parallelism for one
// job; dropping it would cost a joiner its wakeup forever.
void RunHere(std::function<void()>&& fn) noexcept {
try {
if (fn) fn();
} catch (...) {
MGLOG_E("ShaderCompilePool: a job body escaped its own containment on the dispatch "
"thread; it has been swallowed to keep the thread alive");
}
fn = nullptr;
Retire();
}
lf::lazy_pool m_pool;
// Fixed for the pool's lifetime, so it is read once rather than per submission.
std::span<lf::worker_context*> m_contexts;
std::atomic<Uint64> m_cursor{0};
std::mutex m_mutex;
std::condition_variable m_fallbackCv;
std::condition_variable m_idleCv;
// Refills and continuations submitted from inside a chain: drained by the chains.
std::deque<std::function<void()>> m_chainQueue;
// Chains currently executing, i.e. workers that will look at m_chainQueue again
// before they exit. The bound on how much Submit may absorb into a chain.
Uint m_liveChains = 0;
// Submissions from another pool's libfork worker, and the overflow of the rule
// above: drained by m_fallback, which detaches each one to a worker.
std::deque<std::function<void()>> m_fallbackQueue;
// Everything submitted and not yet finished, whichever queue it is in and whether
// or not it has reached a worker, so JoinAll needs a single predicate.
Uint m_outstanding = 0;
Bool m_fallbackStop = false;
std::thread m_fallback;
};
void RunLibforkChain(LibforkJob* const raw) noexcept {
UniquePtr<LibforkJob> job(raw);
LibforkJobExecutor* const owner = job->owner;
std::function<void()> body;
body.swap(job->body);
job.reset();
LibforkJobExecutor* const savedOwner = tl_chainOwner;
tl_chainOwner = owner;
owner->EnterChain();
for (;;) {
try {
if (body) body();
} catch (...) {
// JobNode::Run contains every body exception already; this is the backstop
// for the wrapper itself. An exception escaping here would be stashed in
// the root task's shared state, which lf::detach discards - i.e. silently
// lost - and would abandon the rest of the chain.
MGLOG_E("ShaderCompilePool: a job body escaped its own containment on a libfork worker; "
"it has been swallowed to keep the chain alive");
}
// Release the finished job's captures (its strong JobNode reference) HERE,
// outside the executor's lock: a JobNode destructor is arbitrary code.
body = nullptr;
if (!owner->RetireAndTakeNext(body)) break;
}
// `owner` may already be destroyed - RetireAndTakeNext returning false can be the
// call that releases a JoinAll. Nothing below touches it.
tl_chainOwner = savedOwner;
}
UniquePtr<JobExecutor> MakeJobExecutor(const AsyncPoolEngine engine, const Uint threads) {
switch (engine) {
case AsyncPoolEngine::Libfork: return MakeUnique<LibforkJobExecutor>(threads);
case AsyncPoolEngine::Asio: break;
}
return MakeUnique<AsioJobExecutor>(threads);
}
} // namespace
struct ShaderCompilePool::Impl {
explicit Impl(const Uint threads)
: threadCount(std::max(1u, threads)), engine(DetectAsyncPoolEngine()), maxConcurrency(threadCount) {}
const Uint threadCount;
// Latched at construction, not re-read: a pool may not change engines under its own
// workers, and GetEngine() is what the tests compare against the environment.
const AsyncPoolEngine engine;
std::mutex mutex;
// Created on the first dispatched Post, never in the constructor: both engines spawn
// their threads eagerly (asio::thread_pool its workers, lf::lazy_pool its workers plus
// this file's dispatch thread), and a build with async off must not pay for threads it
// will never use.
UniquePtr<JobExecutor> executor;
std::deque<SharedPtr<JobNode>> queue;
Uint inFlight = 0;
Uint maxConcurrency;
std::atomic<Bool> stopped{false};
// Callers hold `mutex`. Hands as many queued nodes to the engine as the concurrency
// budget allows. Submitting under the lock is safe and is what keeps `executor` from
// being moved out by a concurrent StopAndDrain between the decision and the dispatch:
// Submit only enqueues, it never runs the callable on the calling thread, so it cannot
// re-enter this mutex.
//
// A node the engine fails to accept is appended to `toCancel` instead of being
// Cancel()'d here: Cancel() runs the node's OnTerminal continuations inline (stage 4
// added ProgramLinkTask::OnDepSettled as a real one), and a continuation is free to
// call ShaderCompilePool::Post() again. Every caller of DispatchLocked holds `mutex`
// (a plain, non-recursive std::mutex) - Cancel()'ing in here would let that
// re-entrant Post() deadlock on the very lock this frame already owns. The caller
// drains `toCancel` after releasing the lock.
//
// The `stopped` check is also what keeps this loop from dereferencing a null
// `executor`: StopAndDrain sets the flag and moves the executor out in the same
// critical section, so a stopped pool never reaches the Submit below.
void DispatchLocked(Vector<SharedPtr<JobNode>>& toCancel) {
while (!queue.empty() && inFlight < maxConcurrency && !stopped.load(std::memory_order_acquire)) {
// Copy rather than move into the callable: if Submit throws (both engines
// allocate) the local SharedPtr is still valid, so the node can be settled
// instead of being stranded Pending in a queue nothing will dispatch from
// again - a joiner would block on it forever. Reclaiming the slot matters just
// as much: a leaked `inFlight` shrinks the pool's concurrency budget
// permanently.
SharedPtr<JobNode> node = queue.front();
queue.pop_front();
++inFlight;
try {
executor->Submit([this, node]() mutable { RunOnWorker(Move(node)); });
} catch (...) {
--inFlight;
toCancel.push_back(Move(node));
}
}
}
void RunOnWorker(SharedPtr<JobNode> node) {
tl_isPoolThread = true;
// A node that was already handed to the engine when StopAndDrain ran still arrives
// here; cancelling it first turns the dispatch into a state transition instead of
// a full compile, so the drain's join() returns promptly. This Cancel() runs
// before `mutex` is ever taken in this frame, so it is not subject to the
// re-entrancy hazard DispatchLocked's comment describes.
if (stopped.load(std::memory_order_acquire)) node->Cancel();
node->Run();
node.reset();
Vector<SharedPtr<JobNode>> toCancel;
{
const std::lock_guard<std::mutex> lock(mutex);
--inFlight;
DispatchLocked(toCancel);
}
// Outside the lock: see DispatchLocked's comment.
for (const auto& n : toCancel) {
if (n) n->Cancel();
}
}
};
ShaderCompilePool::ShaderCompilePool(const Uint threadCount) : m_impl(MakeUnique<Impl>(threadCount)) {}
ShaderCompilePool::~ShaderCompilePool() { StopAndDrain(); }
ShaderCompilePool& ShaderCompilePool::Get() {
// Leak-at-exit, like the other MobileGL singletons: the object itself is never
// destroyed, so no static destructor can race a late entry point for it. Its THREADS
// are a different matter and are stopped explicitly - by Init.cpp's DestroyImpl on
// the normal path, and by the atexit sentinel below for a process that exits without
// ever calling eglTerminate.
static ShaderCompilePool* pool = [] {
auto* created = new ShaderCompilePool(DetectShaderCompileThreadCount());
g_processPool.store(created, std::memory_order_release);
EnsureProcessTeardownSentinel();
return created;
}();
return *pool;
}
Bool ShaderCompilePool::IsPoolThread() { return tl_isPoolThread; }
Uint ShaderCompilePool::GetThreadCount() const { return m_impl->threadCount; }
Uint ShaderCompilePool::GetMaxConcurrency() const {
const std::lock_guard<std::mutex> lock(m_impl->mutex);
return m_impl->maxConcurrency;
}
AsyncPoolEngine ShaderCompilePool::GetEngine() const { return m_impl->engine; }
void ShaderCompilePool::SetMaxConcurrency(const Uint n) {
Vector<SharedPtr<JobNode>> toCancel;
{
const std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount);
// Raising the budget releases whatever the old one was holding back.
if (m_impl->executor) m_impl->DispatchLocked(toCancel);
}
// Outside the lock: see DispatchLocked's comment.
for (const auto& n2 : toCancel) {
if (n2) n2->Cancel();
}
}
void ShaderCompilePool::Post(SharedPtr<JobNode> node) {
if (!node) return;
EnsureProcessTeardownSentinel();
// Enqueueing can throw: building the engine and submitting to it both allocate (and
// both spawn threads), and under memory pressure a throw here would escape
// glCompileShader leaving the node Pending with nothing left to dispatch it - the
// first observable read would then block the GL thread forever. Settle the node
// instead: a cancelled node is a state every joiner already handles.
//
// `node` is still valid in the catch for every throw this try can produce. The engine
// construction runs before the move; deque::push_back is strongly exception-safe and
// SharedPtr's move constructor is noexcept, so a throwing push_back never consumed it;
// and DispatchLocked contains its own Submit failures rather than propagating them
// (see above). Keep it that way.
Bool enqueued = false;
Vector<SharedPtr<JobNode>> toCancel;
try {
const std::lock_guard<std::mutex> lock(m_impl->mutex);
if (!m_impl->stopped.load(std::memory_order_acquire) && !InProcessTeardown()) {
if (!m_impl->executor) m_impl->executor = MakeJobExecutor(m_impl->engine, m_impl->threadCount);
m_impl->queue.push_back(Move(node));
m_impl->DispatchLocked(toCancel);
enqueued = true;
}
} catch (...) {
MGLOG_E("ShaderCompilePool::Post: enqueue failed; cancelling the job so its joiner "
"cannot block forever");
if (node) node->Cancel();
return;
}
// Outside the lock: see DispatchLocked's comment - a Cancel() here may run a
// continuation (e.g. ProgramLinkTask::OnDepSettled) that calls back into Post().
for (const auto& n : toCancel) {
if (n) n->Cancel();
}
if (enqueued) return;
// A stopped pool is a synchronous pool, not a black hole: the node still runs, just
// on the caller's thread. Everything downstream already handles "terminal by the time
// Post returns", because that is exactly what the inline path looks like. Run it
// outside the lock - a body, or a continuation it releases, is free to Post again.
//
// Say so once. StopAndDrain is a one-way latch (see its tail), so from the first
// eglTerminate onwards EVERY compile in this process silently runs on the GL thread;
// without this line the only symptom is that asynchronous compilation stopped helping,
// with nothing in the log to point at. Once, not per node: a pack load posts hundreds.
static std::atomic<Bool> warnedStopped{false};
if (!warnedStopped.exchange(true, std::memory_order_relaxed)) {
MGLOG_W("ShaderCompilePool::Post: the pool is stopped (eglTerminate, or process exit); shader "
"compilation runs inline on the calling thread until MobileGL is re-initialized");
}
node->RunInline();
}
void ShaderCompilePool::StopAndDrain() {
// Waiting for the workers from a worker would deadlock on itself (asio's join() says
// so outright), and the whole point of this call is that the GL thread waits.
MOBILEGL_ASSERT(!IsPoolThread(), "ShaderCompilePool::StopAndDrain() called from a pool thread");
std::deque<SharedPtr<JobNode>> abandoned;
UniquePtr<JobExecutor> executor;
{
const std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->stopped.store(true, std::memory_order_release);
abandoned.swap(m_impl->queue);
executor = Move(m_impl->executor);
}
// Queued but never dispatched: settle them so anything chained behind them is
// released rather than waiting for a worker that will never pick them up.
for (const auto& node : abandoned) {
if (node) node->Cancel();
}
if (executor) {
executor->JoinAll(); // returns once every job already handed to the engine is done
executor.reset(); // and this stops the engine's threads
}
const std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->inFlight = 0;
// The pool stays stopped, so ShaderCompilePool::Get() keeps returning a stopped,
// synchronous pool for the rest of the process. That is deliberate for the teardown
// path this exists to serve; if a future stage wants eglTerminate followed by a fresh
// eglInitialize to get its worker threads back, the re-arm belongs in
// MobileGL::Initialize(), next to glslang::InitializeProcess().
}
} // namespace MobileGL::MG_Util::Async
+140
View File
@@ -0,0 +1,140 @@
// MobileGL - MobileGL/MG_Util/Async/ShaderCompilePool.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/Types.h>
#include <MG_Util/Async/JobNode.h>
// This header deliberately includes NO Asio and NO libfork header: both execution engines
// live behind the pimpl in ShaderCompilePool.cpp. They stay private implementation details of
// one translation unit, so no consumer target (MG_Test, MG_IntegrationTest, MG_Benchmark -
// each with its own target_include_directories) needs either include path, and no consumer
// pays their compile time. libfork in particular is a C++20-coroutine header set whose
// instantiation cost nothing outside the pool has any reason to carry. Do not add one here.
namespace MobileGL::MG_Util::Async {
// Stage 7: on by default. The gate behind the flip (2026-08-09, headless Mesa, both
// backends): GL30-40 mustpass + KHR-GL46.parallel_shader_compile at async=1 with the
// extension advertised - 58,344 case-runs, 8 failures, and every one of the 8 also
// fails standalone at async=0 and under the pre-P1 library, i.e. zero async-attributable
// deltas. The risk this comment used to name - Iris and Sodium changing their submission
// schedule the moment GL_KHR_parallel_shader_compile is advertised - remains the one
// thing a recorded trace cannot cover, which is why the kill switch below stays.
inline constexpr Bool kAsyncShaderCompileDefault = true;
// MOBILEGL_ASYNC_SHADER_COMPILE forces the answer either way; unset keeps the built-in
// default above. Falsy is a complete kill switch: it reverts the threading *and*
// withdraws GL_KHR_parallel_shader_compile, so the application behaviour change goes
// with it.
//
// This is the pure CONFIGURATION answer, and it is deliberately not affected by
// glMaxShaderCompilerThreadsKHR: it is what decides whether the extension is advertised
// at all, and an application that switched threading off through the extension has not
// made the extension go away. Code deciding whether to enqueue asks
// AsyncShaderCompileActive() instead.
Bool AsyncShaderCompileEnabled();
// ---- GL_KHR_parallel_shader_compile: glMaxShaderCompilerThreadsKHR(count) ----
// The extension defines count == 0 as "no compiler threads": compilation must happen on
// the application's thread. That is a mode switch, not a concurrency budget of one, so it
// is a latch of its own rather than SetMaxConcurrency(1) - a budget of one would still
// move the work off-thread and still report GL_COMPLETION_STATUS_KHR = GL_FALSE, both of
// which the extension forbids after a zero count.
//
// The latch is process-wide, matching the pool it suspends. It is released by the next
// nonzero glMaxShaderCompilerThreadsKHR/ARB, which is the only thing that releases it:
// no implicit re-arm on eglInitialize, on a context switch or at any join, because an
// application that asked for serial compilation gets to keep it until it asks otherwise.
void SetAsyncShaderCompileSuspended(Bool suspended);
Bool IsAsyncShaderCompileSuspended();
// What every enqueue site branches on: the configuration flag AND the absence of a
// glMaxShaderCompilerThreadsKHR(0). False makes glCompileShader/glLinkProgram run their
// bodies inline, exactly as the flag-off path does, which is what makes a subsequent
// GL_COMPLETION_STATUS_KHR read immediately GL_TRUE.
Bool AsyncShaderCompileActive();
// min(4, big cores), where a big core is one whose cpufreq ceiling is within 15% of the
// machine maximum; the whole CPU count where that sysfs tree is absent. Clamped to [1, 4]
// because peak RSS scales as workers x largest glslang arena, and four
// Complementary-sized arenas is already the memory ceiling worth accepting on a phone.
// MOBILEGL_ASYNC_SHADER_COMPILE_THREADS overrides it outright.
Uint DetectShaderCompileThreadCount();
// ---- MOBILEGL_ASYNC_POOL: which engine drives the worker threads ----------------------
// The engine is ONLY the execution engine. The job queue, the concurrency budget and its
// clamping, the suspension latch, cancel request-vs-outcome, the stopped-is-synchronous
// fallback and the drain are all engine-independent - they live in ShaderCompilePool::Impl
// and are shared verbatim by both engines, which is what lets the whole async suite run
// unchanged against either one. An engine answers exactly one question: how does a job
// that the budget has already cleared reach a worker thread?
enum class AsyncPoolEngine : Uint8 {
Asio, // asio::thread_pool: one shared queue behind Asio's scheduler lock
Libfork, // lf::lazy_pool: per-worker work-stealing deques, workers sleep when idle
};
// "asio" / "libfork" - the spelling the environment variable accepts and the log prints.
const char* AsyncPoolEngineName(AsyncPoolEngine engine);
// Parses one MOBILEGL_ASYNC_POOL value. Case-insensitive; empty, "auto" and anything
// unrecognized resolve to Asio, and an unrecognized value warns (a misspelt engine name
// would otherwise be indistinguishable from the default, and the whole point of the
// variable is to know which engine ran).
AsyncPoolEngine ParseAsyncPoolEngine(const String& value);
// The process's engine, resolved from MOBILEGL_ASYNC_POOL on first call and cached. Every
// pool constructed afterwards reports the same answer, so a process never mixes engines.
AsyncPoolEngine DetectAsyncPoolEngine();
class ShaderCompilePool {
public:
explicit ShaderCompilePool(Uint threadCount);
~ShaderCompilePool();
ShaderCompilePool(const ShaderCompilePool&) = delete;
ShaderCompilePool& operator=(const ShaderCompilePool&) = delete;
// Process-wide pool, leak-at-exit like pGLContext. Sized by
// DetectShaderCompileThreadCount() on first use; no thread is created until the first
// Post, so a build that never enables async never starts one.
static ShaderCompilePool& Get();
// True only on a thread owned by some ShaderCompilePool. Backs the two asserts that
// hold the design's invariants up: no GL/EGL reach-back from a worker, and no job
// body waiting on another job.
static Bool IsPoolThread();
// Dispatches the node, or queues it behind the concurrency budget. A stopped pool -
// and one whose process is exiting - runs the node inline on the calling thread
// instead, so a late entry point can never resurrect worker threads.
void Post(SharedPtr<JobNode> node);
// Cancels everything still queued and joins everything already running. This is the
// one cancellation path that waits, and it must run before glslang::FinalizeProcess()
// and before pGLContext is destroyed: in-flight jobs hold their own inputs safely,
// but they share glslang's process globals, which teardown is about to free.
void StopAndDrain();
Uint GetThreadCount() const;
Uint GetMaxConcurrency() const;
// The engine this pool was built with, latched at construction from
// DetectAsyncPoolEngine(). Reported rather than re-resolved so that a pool cannot
// change engines under its own workers.
AsyncPoolEngine GetEngine() const;
// Bounded concurrency doubles as the memory bound, and is how
// glMaxShaderCompilerThreadsKHR(n) is honoured: a 300-program pack load cannot put
// 300 glslang arenas in flight at once. Clamped to [1, thread count].
void SetMaxConcurrency(Uint n);
private:
struct Impl;
UniquePtr<Impl> m_impl;
};
} // namespace MobileGL::MG_Util::Async
@@ -838,6 +838,12 @@ namespace MobileGL::MG_Util::BackendLoader {
if (std::strcmp(extension, "GL_EXT_render_snorm") == 0) { if (std::strcmp(extension, "GL_EXT_render_snorm") == 0) {
caps.SupportsRenderSnorm = true; caps.SupportsRenderSnorm = true;
} }
if (std::strcmp(extension, "GL_EXT_color_buffer_float") == 0) {
caps.SupportsColorBufferFloat = true;
}
if (std::strcmp(extension, "GL_EXT_color_buffer_half_float") == 0) {
caps.SupportsColorBufferHalfFloat = true;
}
if (std::strcmp(extension, "GL_EXT_sRGB_write_control") == 0) { if (std::strcmp(extension, "GL_EXT_sRGB_write_control") == 0) {
caps.SupportsSrgbWriteControl = true; caps.SupportsSrgbWriteControl = true;
} }
@@ -858,6 +864,9 @@ namespace MobileGL::MG_Util::BackendLoader {
if (std::strcmp(extension, "GL_EXT_disjoint_timer_query") == 0) { if (std::strcmp(extension, "GL_EXT_disjoint_timer_query") == 0) {
caps.SupportsDisjointTimerQuery = true; caps.SupportsDisjointTimerQuery = true;
} }
if (std::strcmp(extension, "GL_KHR_parallel_shader_compile") == 0) {
caps.SupportsParallelShaderCompile = true;
}
if (std::strcmp(extension, "GL_EXT_blend_func_extended") == 0) { if (std::strcmp(extension, "GL_EXT_blend_func_extended") == 0) {
caps.SupportsDualSourceBlend = true; caps.SupportsDualSourceBlend = true;
} }
@@ -888,6 +897,18 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.SupportsMultiDrawElementsBaseVertex = hasDrawElementsBaseVertexExtension && caps.SupportsMultiDrawElementsBaseVertex = hasDrawElementsBaseVertexExtension &&
hasMultiDrawArraysExtension && hasMultiDrawArraysExtension &&
glesFuncs.glMultiDrawElementsBaseVertexEXT != nullptr; glesFuncs.glMultiDrawElementsBaseVertexEXT != nullptr;
// Core from ES 3.2 on, so an extension string is not required there; below 3.2 the
// extension is, and the pointer still has to have resolved either way.
const Bool esAtLeast32 = caps.GLESVersion.Major > 3 ||
(caps.GLESVersion.Major == 3 && caps.GLESVersion.Minor >= 2);
const Bool esAtLeast31 = caps.GLESVersion.Major > 3 ||
(caps.GLESVersion.Major == 3 && caps.GLESVersion.Minor >= 1);
caps.SupportsDrawElementsBaseVertex = (esAtLeast32 || hasDrawElementsBaseVertexExtension) &&
glesFuncs.glDrawElementsBaseVertex != nullptr;
caps.SupportsComputeShader = esAtLeast31 && glesFuncs.glDispatchCompute != nullptr &&
glesFuncs.glMemoryBarrier != nullptr &&
glesFuncs.glCreateShader != nullptr &&
glesFuncs.glCreateProgram != nullptr;
caps.SupportsShaderMultisampleInterpolation = caps.SupportsShaderMultisampleInterpolation =
caps.SupportsShaderMultisampleInterpolation || caps.GLESVersion.Major > 3 || caps.SupportsShaderMultisampleInterpolation || caps.GLESVersion.Major > 3 ||
(caps.GLESVersion.Major == 3 && caps.GLESVersion.Minor >= 2); (caps.GLESVersion.Major == 3 && caps.GLESVersion.Minor >= 2);
@@ -907,6 +928,9 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.SupportsMultiDrawIndirect ? "yes" : "no"); caps.SupportsMultiDrawIndirect ? "yes" : "no");
MGLOG_I(" multi-draw base vertex (EXT/OES_draw_elements_base_vertex + EXT_multi_draw_arrays): %s", MGLOG_I(" multi-draw base vertex (EXT/OES_draw_elements_base_vertex + EXT_multi_draw_arrays): %s",
caps.SupportsMultiDrawElementsBaseVertex ? "yes" : "no"); caps.SupportsMultiDrawElementsBaseVertex ? "yes" : "no");
MGLOG_I(" draw elements base vertex (ES 3.2 core or EXT/OES_draw_elements_base_vertex): %s",
caps.SupportsDrawElementsBaseVertex ? "yes" : "no");
MGLOG_I(" compute shaders (ES 3.1 core): %s", caps.SupportsComputeShader ? "yes" : "no");
MGLOG_I("OpenGL ES capabilities:"); MGLOG_I("OpenGL ES capabilities:");
glesFuncs.glGetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &caps.UniformBufferOffsetAlignment); glesFuncs.glGetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &caps.UniformBufferOffsetAlignment);
@@ -1205,6 +1229,8 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.IsAngleLlvmpipeRenderer && MG_Config::Features.AvoidSamplerMipmapMinFilter; caps.IsAngleLlvmpipeRenderer && MG_Config::Features.AvoidSamplerMipmapMinFilter;
MGLOG_I(" GL_EXT_disjoint_timer_query supported: %s", MGLOG_I(" GL_EXT_disjoint_timer_query supported: %s",
caps.SupportsDisjointTimerQuery ? "true" : "false"); caps.SupportsDisjointTimerQuery ? "true" : "false");
MGLOG_I(" GL_KHR_parallel_shader_compile supported: %s",
caps.SupportsParallelShaderCompile ? "true" : "false");
MGLOG_I(" ANGLE renderer: %s", caps.IsAngleRenderer ? "true" : "false"); MGLOG_I(" ANGLE renderer: %s", caps.IsAngleRenderer ? "true" : "false");
MGLOG_I(" ANGLE llvmpipe renderer: %s", caps.IsAngleLlvmpipeRenderer ? "true" : "false"); MGLOG_I(" ANGLE llvmpipe renderer: %s", caps.IsAngleLlvmpipeRenderer ? "true" : "false");
MGLOG_I(" Avoid sampler mipmap min filter: %s", MGLOG_I(" Avoid sampler mipmap min filter: %s",
@@ -1034,6 +1034,16 @@ namespace MobileGL {
// GL_EXT_render_snorm is present, so the signed-normalized formats are colour-renderable // GL_EXT_render_snorm is present, so the signed-normalized formats are colour-renderable
// (and usable as multisample texture storage) rather than texture-only. // (and usable as multisample texture storage) rather than texture-only.
Bool SupportsRenderSnorm = false; Bool SupportsRenderSnorm = false;
// GL_EXT_color_buffer_float is present, so GL_RGBA16F / GL_RGBA32F / GL_R11F_G11F_B10F
// (and the R/RG float formats) are colour-renderable. ES 3.x core makes them
// texture-only, and every Iris shaderpack renders into at least R11F_G11F_B10F, so
// without this no shaderpack can work at all.
Bool SupportsColorBufferFloat = false;
// GL_EXT_color_buffer_half_float is present: the half-float subset of the above, for
// drivers that ship only the smaller extension. Note it does NOT rescue GL_RGB16F -
// the extension nominally lists it but disclaims it under ES 3.x, and real drivers
// reject it, which is why three-channel float attachments are widened instead.
Bool SupportsColorBufferHalfFloat = false;
// GL_EXT_sRGB_write_control is present, so GL_FRAMEBUFFER_SRGB can be turned off. // GL_EXT_sRGB_write_control is present, so GL_FRAMEBUFFER_SRGB can be turned off.
// GLES has no such switch in core: writes into an sRGB attachment are ALWAYS encoded, // GLES has no such switch in core: writes into an sRGB attachment are ALWAYS encoded,
// while desktop GL leaves GL_FRAMEBUFFER_SRGB disabled by default and writes raw. // while desktop GL leaves GL_FRAMEBUFFER_SRGB disabled by default and writes raw.
@@ -1053,6 +1063,16 @@ namespace MobileGL {
Bool SupportsBaseInstance = false; Bool SupportsBaseInstance = false;
// GL_EXT_disjoint_timer_query is present in the extension string. // GL_EXT_disjoint_timer_query is present in the extension string.
Bool SupportsDisjointTimerQuery = false; Bool SupportsDisjointTimerQuery = false;
// GL_KHR_parallel_shader_compile is present in the HOST driver's extension string,
// i.e. the device driver can compile its own (ESSL) shaders on its own threads.
//
// Purely informational today, and NOT what gates MobileGL's advertisement of the
// same extension: MobileGL's parallelism is its own compile pool turning GLSL into
// SPIR-V, which is where the shaderpack time goes, and it works on a driver that
// has never heard of the extension. This flag becomes load-bearing only if the
// driver-side glCompileShader of the translated ESSL is parallelised too, at which
// point it decides whether that half can overlap.
Bool SupportsParallelShaderCompile = false;
// glPolygonModeNV/ANGLE loaded (GL_NV_polygon_mode / GL_ANGLE_polygon_mode). GLES core // glPolygonModeNV/ANGLE loaded (GL_NV_polygon_mode / GL_ANGLE_polygon_mode). GLES core
// has no glPolygonMode, so without this the mode stays FILL. // has no glPolygonMode, so without this the mode stays FILL.
Bool SupportsPolygonMode = false; Bool SupportsPolygonMode = false;
@@ -1084,6 +1104,15 @@ namespace MobileGL {
// requires (EXT or OES draw_elements_base_vertex) AND GL_EXT_multi_draw_arrays // requires (EXT or OES draw_elements_base_vertex) AND GL_EXT_multi_draw_arrays
// AND a resolved pointer; callers must gate on it, never on the pointer. // AND a resolved pointer; callers must gate on it, never on the pointer.
Bool SupportsMultiDrawElementsBaseVertex = false; Bool SupportsMultiDrawElementsBaseVertex = false;
// glDrawElementsBaseVertex is callable: ES 3.2 core, or EXT/OES_draw_elements_base_vertex
// before that, with the pointer resolved. This is the weaker sibling of the flag above -
// it does NOT need GL_EXT_multi_draw_arrays, only the single-draw entry point - and it
// decides whether a multi-draw batch can carry per-sub-draw base vertices at all or has
// to fold them into rewritten indices.
Bool SupportsDrawElementsBaseVertex = false;
// Compute shaders are usable: ES 3.1 core (there is no pre-3.1 extension in ES), with
// the dispatch and barrier entry points resolved.
Bool SupportsComputeShader = false;
// GL_RENDERER contains "ANGLE". // GL_RENDERER contains "ANGLE".
Bool IsAngleRenderer = false; Bool IsAngleRenderer = false;
// GL_RENDERER contains both "ANGLE" and "llvmpipe". // GL_RENDERER contains both "ANGLE" and "llvmpipe".
+242 -3
View File
@@ -11,11 +11,18 @@
#include <Config.h> #include <Config.h>
#include <MGGitHash.h> #include <MGGitHash.h>
#include <MG_Backend/DirectGLES/BackendObject_DirectGLES.h> #include <MG_Backend/DirectGLES/BackendObject_DirectGLES.h>
#include <MG_Backend/DirectGLES/MultiDraw.h>
#include <MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h> #include <MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h>
// Only for the compile-time MAX_VERTEX_ATTRIBS constant asserted below. The POST still executes no // Only for the compile-time MAX_VERTEX_ATTRIBS constant asserted below. The POST still executes no
// MG_State code: it runs standalone, before MG_State::Init(). // MG_State code: it runs standalone, before MG_State::Init().
#include <MG_State/GLState/VertexArrayState/VertexArrayObject.h> #include <MG_State/GLState/VertexArrayState/VertexArrayObject.h>
#include <MG_Backend/DirectGLES/Utils.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToStr/GLExtensionConverter.h> #include <MG_Util/Converters/MGToStr/GLExtensionConverter.h>
#include <MG_Util/Converters/MGToStr/TextureEnumConverter.h>
#include <MG_Util/Texture/TextureFormatProcessor.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <chrono> #include <chrono>
#include <thread> #include <thread>
@@ -121,6 +128,45 @@ namespace MobileGL::MG_Util::SelfTest {
return result; return result;
} }
// ---- Asynchronous shader compilation ------------------------------------
// MobileGL's OWN capability row, appended for both backends: nothing about it comes
// from the device driver, so it is the same fact on Espryt and on Magma. The POST
// rule ("every new capability gets a row") applies to frontend capabilities too -
// and this one especially, because it is the capability that changes what
// applications DO, not just what they can do: with the extension advertised, Iris
// and Sodium batch their pipeline compiles and poll GL_COMPLETION_STATUS_KHR.
//
// PASS when it is on (the intended configuration once the default flips), INFO when
// it is off - "off" is a supported configuration, not a degradation, so it must not
// colour the verdict. Either way the row names MOBILEGL_ASYNC_SHADER_COMPILE, so a
// user reading a POST page can tell which side of the switch they are on and how to
// change it.
void AppendAsyncShaderCompileRow(ReportBuilder& builder) {
constexpr const char* rowName = "Asynchronous shader compilation";
if (!MG_Util::Async::AsyncShaderCompileEnabled()) {
builder.Info(rowName,
"off; glCompileShader and glLinkProgram run on the calling thread and "
"GL_KHR_parallel_shader_compile is not advertised (set environment variable "
"MOBILEGL_ASYNC_SHADER_COMPILE=1 to enable it)");
return;
}
const Uint threads = MG_Util::Async::DetectShaderCompileThreadCount();
// The execution engine is named here too. It changes no observable GL behaviour -
// both engines run the same job queue under the same budget - but when a scaling
// or stall report comes back from a device, "which engine was this?" is the first
// question, and a POST page is the one artefact that always accompanies it.
const char* const engineName =
MG_Util::Async::AsyncPoolEngineName(MG_Util::Async::DetectAsyncPoolEngine());
builder.Pass(rowName,
format("on with {} compiler thread{} on the {} execution engine; "
"GL_KHR_parallel_shader_compile is advertised "
"and GL_MAX_SHADER_COMPILER_THREADS_KHR = {} (set environment variable "
"MOBILEGL_ASYNC_SHADER_COMPILE=0 to disable it, "
"MOBILEGL_ASYNC_SHADER_COMPILE_THREADS=n to change the count, or "
"MOBILEGL_ASYNC_POOL=asio|libfork to change the engine)",
threads, threads == 1 ? "" : "s", engineName, threads));
}
// Appends the four "MobileGL reported ..." rows for one backend section. // Appends the four "MobileGL reported ..." rows for one backend section.
// GL_VENDOR and GL_VERSION only depend on the backend's static identity, so // GL_VENDOR and GL_VERSION only depend on the backend's static identity, so
// they are always concrete; GL_RENDERER and GL_EXTENSIONS need data from the // they are always concrete; GL_RENDERER and GL_EXTENSIONS need data from the
@@ -129,6 +175,9 @@ namespace MobileGL::MG_Util::SelfTest {
const Optional<String>& backendApiVersionString, const Optional<String>& backendApiVersionString,
const Optional<String>& advertisedExtensions) { const Optional<String>& advertisedExtensions) {
static const String Unavailable = "unavailable (backend probe failed)"; static const String Unavailable = "unavailable (backend probe failed)";
// Frontend capability, not a probe result, so it is appended on every path -
// including one where the device probe failed outright.
AppendAsyncShaderCompileRow(builder);
builder.MobileGLReported("MobileGL reported GL_VENDOR", BuildReportedGLVendor(identity)); builder.MobileGLReported("MobileGL reported GL_VENDOR", BuildReportedGLVendor(identity));
builder.MobileGLReported("MobileGL reported GL_VERSION", BuildReportedGLVersion(identity)); builder.MobileGLReported("MobileGL reported GL_VERSION", BuildReportedGLVersion(identity));
builder.MobileGLReported("MobileGL reported GL_RENDERER", builder.MobileGLReported("MobileGL reported GL_RENDERER",
@@ -319,9 +368,46 @@ namespace MobileGL::MG_Util::SelfTest {
} else { } else {
builder.Info("Multi-draw base vertex", builder.Info("Multi-draw base vertex",
"glMultiDrawElementsBaseVertexEXT not supported (needs EXT/OES_" "glMultiDrawElementsBaseVertexEXT not supported (needs EXT/OES_"
"draw_elements_base_vertex plus GL_EXT_multi_draw_arrays); " "draw_elements_base_vertex plus GL_EXT_multi_draw_arrays); the batch "
"glMultiDrawElementsBaseVertex falls back to a per-draw loop with " "takes the next emulation tier instead, with identical output - see "
"identical output"); "\"Multi-draw elements tier\" below for the one that will run");
}
// glMultiDrawElements(BaseVertex) has no ES counterpart at all, so DirectGLES
// emulates it; these rows say which emulation the driver leaves available and
// which one will run. The two capabilities each tier leans on come first.
if (caps.SupportsDrawElementsBaseVertex) {
builder.Pass("Draw elements base vertex",
"glDrawElementsBaseVertex available (ES 3.2 core or EXT/OES_draw_elements_base_"
"vertex); a multi-draw batch can replay its sub-draws with their own base "
"vertices");
} else {
builder.Warn("Draw elements base vertex",
"glDrawElementsBaseVertex not supported (pre-ES 3.2 without EXT/OES_draw_"
"elements_base_vertex); every base-vertex draw has to be emulated by rewriting "
"the index stream on the CPU, which costs an upload per batch");
}
if (caps.SupportsComputeShader) {
builder.Pass("Compute shaders",
"available (ES 3.1 core); the opt-in \"compute\" multi-draw tier can flatten a "
"whole batch into one draw");
} else {
builder.Info("Compute shaders",
"not available (pre-ES 3.1); no impact on the default multi-draw tiers, which "
"never use compute");
}
{
// The same resolution the backend runs, over the capabilities probed here.
// Like the Magma tier row, the preference comes from MG_Config::Features,
// which is only populated once MobileGL::Initialize() has parsed the
// environment - a POST executed standalone before that reports the
// unclamped choice, so the row names the variable rather than implying it
// was consulted.
using MG_Backend::DirectGLES::MultiDrawImpl::ResolveTier;
String resolution;
ResolveTier(caps, glesFuncs, MG_Config::Features.EsprytMultiDrawMode, &resolution);
builder.Info("Multi-draw elements tier",
"glMultiDrawElements(BaseVertex) emulation: " + resolution +
"; override with MOBILEGL_ESPRYT_MULTIDRAW_MODE");
} }
if (caps.SupportsTextureBorderClamp) { if (caps.SupportsTextureBorderClamp) {
builder.Pass("Texture border clamp", builder.Pass("Texture border clamp",
@@ -377,6 +463,47 @@ namespace MobileGL::MG_Util::SelfTest {
builder.Warn("GL_EXT_texture_norm16", builder.Warn("GL_EXT_texture_norm16",
"not supported; 16-bit normalized texture formats need emulation"); "not supported; 16-bit normalized texture formats need emulation");
} }
if (caps.SupportsRenderSnorm) {
builder.Pass("GL_EXT_render_snorm",
"supported (signed-normalized formats are colour-renderable, so an "
"SNORM render target keeps its own encoding instead of a float substitute)");
} else {
builder.Warn("GL_EXT_render_snorm",
"not supported; signed-normalized formats are texture-only, so every SNORM "
"render target is stored as a float (GL_RGBA8_SNORM/GL_RGB8_SNORM -> "
"GL_RGBA16F) and its fragment outputs are clamped to [-1,1] in software");
}
// FAIL, not WARN: ES 3.x core makes every float format texture-only, and every Iris
// shaderpack renders into at least GL_R11F_G11F_B10F (Complementary's colortex0, BSL's
// colortex0). Without this extension there is no substitute format left - a half float
// is not renderable either - so shaderpacks cannot work at all on such a driver.
if (caps.SupportsColorBufferFloat) {
builder.Pass("GL_EXT_color_buffer_float",
"supported (GL_R11F_G11F_B10F / GL_RGBA16F / GL_RGBA32F are "
"colour-renderable, which is what every shaderpack renders into)");
} else if (caps.SupportsColorBufferHalfFloat) {
builder.Warn("GL_EXT_color_buffer_float",
"not supported, but GL_EXT_color_buffer_half_float is; 16-bit float render "
"targets work, 32-bit float ones (GL_RGBA32F, and the GL_RGBA16 fallback "
"that lands on it) do not");
} else {
builder.Fail("GL_EXT_color_buffer_float",
"not supported, and neither is GL_EXT_color_buffer_half_float; no floating-point "
"format is colour-renderable on this driver, so no shaderpack can create its "
"render targets (Iris reports GL_FRAMEBUFFER_UNSUPPORTED and refuses to load)");
}
// INFO, never WARN: this is the HOST driver's ability to compile its own ESSL on
// its own threads, and MobileGL's asynchronous compilation does not depend on it
// in the slightest - the pool parallelises GLSL -> SPIR-V -> ESSL translation,
// which is where a shaderpack load actually spends its time, and it does that on
// a driver that has never heard of the extension. The row exists so that the day
// the driver-side half is overlapped too, the POST already says which devices can.
builder.Info("Driver GL_KHR_parallel_shader_compile",
caps.SupportsParallelShaderCompile
? "supported; the device driver can also compile the translated ESSL off-thread"
: "not supported; the device driver compiles the translated ESSL on the calling "
"thread (MobileGL's own compile pool is unaffected)");
builder.Info("Indirect gl_InstanceID semantics", builder.Info("Indirect gl_InstanceID semantics",
caps.IndirectDrawInstanceIdIncludesBaseInstance caps.IndirectDrawInstanceIdIncludesBaseInstance
@@ -698,6 +825,117 @@ namespace MobileGL::MG_Util::SelfTest {
} }
} }
// No real ES driver renders to a three-channel image, but desktop GL applications ask for
// one constantly - Complementary Reimagined's colortex1 is GL_RGB8_SNORM and its colortex2
// is GL_RGB16F, and Iris refuses to load when a framebuffer built from them is not
// COMPLETE. DirectGLES substitutes the four-channel sibling, and this row names the
// outcome per format so the failure mode is a five-second read instead of an
// investigation. Answered from the capability cache that was just probed on this very
// driver, so it costs no extra GL work.
void ReportThreeChannelColorAttachments(ReportBuilder& builder, const MG_External::GLESCapabilities& caps,
const MG_Backend::FormatCapabilityCache& cache) {
// GL_RGB8 is the control: it is ES-core renderable, and it is exactly why BSL loads on
// the same driver where Complementary does not. The rest are one representative of
// each widening class - signed-normalized, half float, 32-bit float, sRGB, integer -
// so the row says which CLASS of shaderpack target a device cannot serve rather than
// just "three-channel formats".
constexpr TextureInternalFormat kProbedFormats[] = {
TextureInternalFormat::RGB8, TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16F,
TextureInternalFormat::RGB32F, TextureInternalFormat::SRGB8, TextureInternalFormat::RGB8UI};
const SizeT targetIndex = MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::Texture2D);
const Flags<PixelFormatNormalizeOptionBit> renderTargetOptions =
MG_Backend::DirectGLES::TextureImpl::GetRenderTargetNormalizeOptions(caps, targetIndex);
String nativeList;
String widenedList;
String unusableList;
// GL_RGB8 is colour-renderable in ES 3.0 CORE. A driver that answers no to it is
// broken (or the probe itself is), and that is the ONLY three-channel verdict that
// deserves a FAIL on its own - see the verdict block below.
Bool controlFormatBroken = false;
const auto append = [](String& list, const String& entry) {
if (!list.empty()) list += ", ";
list += entry;
};
for (const TextureInternalFormat probedFormat : kProbedFormats) {
const SizeT formatIndex = static_cast<SizeT>(probedFormat);
const String name = MG_Util::ConvertTextureInternalFormatToString(probedFormat);
if (MG_Backend::HasFormatCapability(cache.FullCaps[targetIndex][formatIndex],
MG_Backend::FormatCapability::FramebufferRenderable)) {
append(nativeList, name);
continue;
}
if (probedFormat == TextureInternalFormat::RGB8) {
controlFormatBroken = true;
}
if (MG_Backend::HasFormatCapability(cache.CaveatCaps[targetIndex][formatIndex],
MG_Backend::FormatCapability::FramebufferRenderable)) {
GLenum widenedInternalFormat = GL_UNKNOWN_MGL;
MG_Util::TextureFormatProcessor::NormalizePixelFormat(
MG_Util::ConvertTextureInternalFormatToGLEnum(probedFormat), renderTargetOptions,
&widenedInternalFormat, nullptr, nullptr);
append(widenedList, name + " -> " + MG_Util::ConvertGLEnumToString(widenedInternalFormat));
continue;
}
append(unusableList, name);
}
String detail;
if (!nativeList.empty()) detail += "renderable natively: " + nativeList;
if (!widenedList.empty()) {
if (!detail.empty()) detail += "; ";
detail += "widened to stay renderable: " + widenedList;
}
if (!unusableList.empty()) {
if (!detail.empty()) detail += "; ";
detail += "NOT renderable and not substitutable: " + unusableList;
}
// The verdict deliberately does NOT track "every probed format came out usable".
//
// GL_RGB32F widens to GL_RGBA32F, and GL_RGBA32F is colour-renderable only under
// GL_EXT_color_buffer_float. A perfectly healthy half-float-only driver (the common
// mobile shape: EXT_color_buffer_half_float and nothing more) therefore reports
// GL_RGB32F as unusable while every format a shaderpack actually renders into works.
// FAILing that device would make the POST's hardest verdict fire on a configuration
// MobileGL runs fine on, which is exactly how a report stops being read.
//
// So FAIL is reserved for the two answers that really are broken:
// * the ES-core control (GL_RGB8) is not renderable - the probe or the driver is
// wrong about something much more basic than three-channel widening; and
// * a widenable format has no usable fallback ON A DRIVER THAT ADVERTISES
// GL_EXT_color_buffer_float - the extension promises the widened float targets
// are renderable, so a gap here is a real, unexplained refusal.
// Everything else is a WARN carrying the exact per-format status, which is what the
// row is for. The "no float render targets at all" case is already a FAIL of its own
// on the GL_EXT_color_buffer_float row above; repeating it here would only double-count.
if (controlFormatBroken) {
builder.Fail("Three-channel colour attachments",
detail + " - GL_RGB8 is colour-renderable in OpenGL ES 3.0 core, so a driver "
"that refuses it cannot render to ANY three-channel attachment and the "
"capability probe itself is suspect");
} else if (!unusableList.empty() && caps.SupportsColorBufferFloat) {
builder.Fail("Three-channel colour attachments",
detail + " - GL_EXT_color_buffer_float is supported, so the widened "
"four-channel float targets are required to be renderable; a framebuffer "
"using one of the formats above still reports GL_FRAMEBUFFER_UNSUPPORTED, "
"which Iris turns into a hard load failure");
} else if (!unusableList.empty()) {
builder.Warn("Three-channel colour attachments",
detail + " - without GL_EXT_color_buffer_float the 32-bit float widening has no "
"renderable target left, so a shaderpack asking for one of the formats "
"above gets GL_FRAMEBUFFER_UNSUPPORTED; the half-float and fixed-point "
"ones above still work");
} else if (!widenedList.empty()) {
builder.Warn("Three-channel colour attachments",
detail + " - the substitution costs the extra alpha channel's memory and is "
"hidden from the application by an ALPHA->ONE swizzle");
} else {
builder.Pass("Three-channel colour attachments", detail);
}
}
// Everything the "MobileGL reported ..." rows need from the GLES device probe. // Everything the "MobileGL reported ..." rows need from the GLES device probe.
struct GlesProbeSummary { struct GlesProbeSummary {
Bool capsValid = false; Bool capsValid = false;
@@ -828,6 +1066,7 @@ namespace MobileGL::MG_Util::SelfTest {
builder.report.formatCapabilities.emplace(); builder.report.formatCapabilities.emplace();
MG_Backend::DirectGLES::PopulateFormatCapabilities( MG_Backend::DirectGLES::PopulateFormatCapabilities(
glesFuncs, caps, builder.report.formatCapabilities.value()); glesFuncs, caps, builder.report.formatCapabilities.value());
ReportThreeChannelColorAttachments(builder, caps, builder.report.formatCapabilities.value());
} while (false); } while (false);
} }
@@ -0,0 +1,98 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/CompileEnv.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 "CompileEnv.h"
#include <MG_Backend/BackendObjects.h>
#include <MG_State/GLState/Core.h>
namespace MobileGL::MG_Util::ShaderTranspiler {
namespace {
void HashBytes(Uint64& state, const void* data, const SizeT length) {
state = static_cast<Uint64>(XXH64(data, length, state));
}
template <typename T>
void HashValue(Uint64& state, const T& value) {
static_assert(std::is_trivially_copyable_v<T>);
HashBytes(state, &value, sizeof(T));
}
} // namespace
Uint64 ComputeCompileEnvFingerprint(const CompileEnv& env) {
Uint64 state = 0x9e3779b97f4a7c15ull;
HashValue(state, env.maxComputeWorkGroupSize[0]);
HashValue(state, env.maxComputeWorkGroupSize[1]);
HashValue(state, env.maxComputeWorkGroupSize[2]);
HashValue(state, env.maxComputeWorkGroupInvocations);
HashValue(state, env.backend);
// DynamicBackendParameters is a plain aggregate of scalars; hashing its object
// representation is deliberate - it means a new limit cannot be added without also
// changing the fingerprint, which is exactly the memo-hazard property wanted here.
HashBytes(state, &env.params, sizeof(env.params));
if (!env.advertisedExtensions.empty()) {
HashBytes(state, env.advertisedExtensions.data(),
env.advertisedExtensions.size() * sizeof(GLExtension));
}
HashValue(state, env.subgroupPrefixScanQuirk);
return state;
}
SharedPtr<const CompileEnv> CaptureCompileEnv() {
auto env = MakeShared<CompileEnv>();
const auto& activeBackend = MG_Backend::pActiveBackendObject;
if (activeBackend) {
env->backend = activeBackend->GetBackendType();
env->params = activeBackend->GetDynamicParameters();
env->advertisedExtensions = activeBackend->GetRendererInfo().RendererGLInfo.Extensions;
}
// GL_MAX_COMPUTE_WORK_GROUP_SIZE. This is a REAL driver call on DirectGLES; it must
// happen here, on the context thread, and exactly once per context. The frontend
// minimum is the floor, matching what GL_Getter reports.
// TODO: Share these exposed compute limit helpers with GL_Getter.cpp instead of duplicating the frontend minima.
constexpr Uint kFrontendMinComputeWorkGroupSizes[3] = {1024, 1024, 64};
for (Uint index = 0; index < 3; ++index) {
Int backendValue = 0;
if (MG_Backend::gBackendFunctionsTable.GL.GetIntegeri_v) {
MG_Backend::gBackendFunctionsTable.GL.GetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, index,
&backendValue);
}
env->maxComputeWorkGroupSize[index] =
std::max(static_cast<Uint>(std::max(backendValue, 0)), kFrontendMinComputeWorkGroupSizes[index]);
}
constexpr Uint64 kFrontendMaxComputeWorkGroupInvocations = 1024;
env->maxComputeWorkGroupInvocations =
activeBackend ? std::max(static_cast<Uint64>(std::max(env->params.MaxComputeWorkGroupInvocations, 0)),
kFrontendMaxComputeWorkGroupInvocations)
: kFrontendMaxComputeWorkGroupInvocations;
env->subgroupPrefixScanQuirk = MG_Config::Features.SubgroupPrefixScanQuirk;
env->fingerprint = ComputeCompileEnvFingerprint(*env);
return env;
}
const SharedPtr<const CompileEnv>& GetDefaultCompileEnv() {
// Function-local static, not a namespace-scope one: the fingerprint has to be
// computed, and this must not run before MG_Config is loaded.
static const SharedPtr<const CompileEnv> kDefault = [] {
auto env = MakeShared<CompileEnv>();
env->subgroupPrefixScanQuirk = MG_Config::Features.SubgroupPrefixScanQuirk;
env->fingerprint = ComputeCompileEnvFingerprint(*env);
return SharedPtr<const CompileEnv>(Move(env));
}();
return kDefault;
}
const SharedPtr<const CompileEnv>& GetCurrentCompileEnv() {
if (MG_State::pGLContext) return MG_State::pGLContext->GetCompileEnv();
return GetDefaultCompileEnv();
}
} // namespace MobileGL::MG_Util::ShaderTranspiler
@@ -0,0 +1,81 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/CompileEnv.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 <MG_Backend/BackendObject.h>
namespace MobileGL::MG_Util::ShaderTranspiler {
// Everything the shader compile/link pipeline reads from OUTSIDE its own (stage, source)
// inputs: backend identity, backend limits, the advertised extension list, and the one
// config quirk the source rewriter branches on.
//
// Why it exists (P1): every one of those reads is a reach-back into
// MG_Backend::pActiveBackendObject / gBackendFunctionsTable, and one of them
// (GL_MAX_COMPUTE_WORK_GROUP_SIZE) is a *real driver call* that on the DirectGLES
// backend silently no-ops off the context thread - which would turn a perfectly legal
// `local_size_z` into COMPILE_STATUS=FALSE the moment compilation moved to a worker.
// Snapshotting the whole set once per context, on the GL thread, removes every
// reach-back at once and makes the pipeline a pure function of (stage, source, env).
//
// Lifetime: captured lazily on first use by GLState::GLContext::GetCompileEnv(), and
// RE-captured if the active backend object changes. Immutable once published; held by
// value/`SharedPtr<const CompileEnv>` so a worker can never observe a torn update.
//
// Memo-hazard rule: `fingerprint` hashes every member above it and is part of the P0b
// ShaderPreprocessCache key, so a memo computed against one env can never be returned
// against another. ADDING A FIELD HERE MEANS ADDING IT TO ComputeFingerprint().
struct CompileEnv {
// --- compute limits: the ONLY former real-driver read in the pipeline ---
// GL_MAX_COMPUTE_WORK_GROUP_SIZE, already max()'d with the frontend minimum.
Uint maxComputeWorkGroupSize[3] = {1024, 1024, 64};
// GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS, likewise.
Uint64 maxComputeWorkGroupInvocations = 1024;
// --- backend identity + limits ---
// Unknown means "no backend was active at capture time". Every consumer keeps the
// exact no-backend fallback it had before: extensions read as advertised, limits
// read as the frontend defaults.
BackendType backend = BackendType::Unknown;
MG_Backend::DynamicBackendParameters params{}; // by value, never by reference
Vector<GLExtension> advertisedExtensions;
// --- config the source rewriter branches on ---
MG_Config::QuirkOverride subgroupPrefixScanQuirk = MG_Config::QuirkOverride::Auto;
Uint64 fingerprint = 0; // set by CaptureCompileEnv()
Bool HasBackend() const { return backend != BackendType::Unknown; }
// Matches the historical rule exactly: with no active backend every extension counts
// as advertised, because the frontend then has nothing to gate against.
Bool IsExtensionAdvertised(GLExtension extension) const {
if (!HasBackend()) return true;
return std::find(advertisedExtensions.begin(), advertisedExtensions.end(), extension) !=
advertisedExtensions.end();
}
};
// Hashes every semantically relevant member. Public so a test can assert that two
// different envs really do produce different P0b cache keys.
Uint64 ComputeCompileEnvFingerprint(const CompileEnv& env);
// GL thread only: this is where the GL_MAX_COMPUTE_WORK_GROUP_SIZE queries live now.
SharedPtr<const CompileEnv> CaptureCompileEnv();
// The env a context-less caller gets: exactly what CaptureCompileEnv() would produce
// with no active backend. Used by the unit tests that drive the transpiler directly and
// by the internal shader objects that compile before any context exists.
const SharedPtr<const CompileEnv>& GetDefaultCompileEnv();
// The env of the current GL context, or GetDefaultCompileEnv() when there is none.
// GL thread only (it may trigger a capture). This is the compatibility shim for the
// handful of entry points that still resolve their env implicitly; the pipeline itself
// always takes an explicit `const CompileEnv&`.
const SharedPtr<const CompileEnv>& GetCurrentCompileEnv();
} // namespace MobileGL::MG_Util::ShaderTranspiler
@@ -0,0 +1,123 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/EsslBuiltinFunctionNames.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 <algorithm>
#include <string_view>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Builtin-shadowing rename: TWO tables, split by FAILURE LAYER.
//
// A desktop pack may redefine a builtin; ESSL 3.x forbids the redefinition, so every
// such helper is renamed to mg_<name>. That rename happens in two places, and which
// names belong in which place is decided by *where the failure would occur*, not by
// how thorough the table looks:
//
// - kEsslBuiltinFunctionNames (below, the full ~146-name ESSL 3.20 set plus the
// GL_AMD/EXT trinary min3/mid3/max3) drives the SPIR-V OpName backstop pass. That
// pass is safe BY CONSTRUCTION for any name: it renames function ids, and builtin
// calls are GLSL.std.450 instructions that can never resolve to a user OpFunction.
// Overloads are distinct ids (so an overload delegating to the real builtin keeps
// working), dead preprocessor branches never reach SPIR-V, and there is no lexical
// guessing to over-fire. Everything that CAN wait for the IR belongs here only.
//
// - kLexicalPreemptRenameNames (a strict handful-of-names subset) drives the source-level
// scan in ShaderSourceProcessor::RenameBuiltinShadowingFunctions. That scan exists
// for exactly one reason: glslang's relaxed parse rejects some shadowing overload
// shapes at PARSE time ("overloaded functions must have the same parameter
// precision qualifiers"), and a shadowed builtin can itself need an extension the
// declared #version does not enable (fma() at #version 330 wants
// GL_ARB_gpu_shader5) - such a shader never produces SPIR-V, so the backstop never
// sees it. Only names empirically observed to hit that parse-level rejection go
// here. A lexical scan is preprocessor-blind and cannot see overload sets, so it
// can over-fire (rename a live call whose definition sits in a dead #if branch, or
// rewrite an overload's delegating call to the real builtin) - and over-detection
// is UNRECOVERABLE, because the source never reaches the backstop. Keeping this
// table minimal keeps that exposure at its historical scope.
//
// "main" is deliberately absent from both.
inline constexpr std::string_view kEsslBuiltinFunctionNames[] = {
"EmitVertex", "EndPrimitive",
"abs", "acos", "acosh", "all", "any", "asin", "asinh", "atan", "atanh",
"atomicAdd", "atomicAnd", "atomicCompSwap", "atomicCounter",
"atomicCounterDecrement", "atomicCounterIncrement", "atomicExchange",
"atomicMax", "atomicMin", "atomicOr", "atomicXor",
"barrier", "bitCount", "bitfieldExtract", "bitfieldInsert", "bitfieldReverse",
"ceil", "clamp", "cos", "cosh", "cross",
"dFdx", "dFdy", "degrees", "determinant", "distance", "dot",
"equal", "exp", "exp2",
"faceforward", "findLSB", "findMSB", "floatBitsToInt", "floatBitsToUint",
"floor", "fma", "fract", "frexp", "fwidth",
"greaterThan", "greaterThanEqual", "groupMemoryBarrier",
"imageAtomicAdd", "imageAtomicAnd", "imageAtomicCompSwap",
"imageAtomicExchange", "imageAtomicMax", "imageAtomicMin", "imageAtomicOr",
"imageAtomicXor", "imageLoad", "imageSize", "imageStore", "imulExtended",
"intBitsToFloat", "interpolateAtCentroid", "interpolateAtOffset",
"interpolateAtSample", "inverse", "inversesqrt", "isinf", "isnan",
"ldexp", "length", "lessThan", "lessThanEqual", "log", "log2",
"matrixCompMult", "max", "max3", "memoryBarrier",
"memoryBarrierAtomicCounter", "memoryBarrierBuffer", "memoryBarrierImage",
"memoryBarrierShared", "mid3", "min", "min3", "mix", "mod", "modf",
"normalize", "not", "notEqual",
"outerProduct",
"packHalf2x16", "packSnorm2x16", "packSnorm4x8", "packUnorm2x16",
"packUnorm4x8", "pow",
"radians", "reflect", "refract", "round", "roundEven",
"sign", "sin", "sinh", "smoothstep", "sqrt", "step",
"tan", "tanh", "texelFetch", "texelFetchOffset", "texture",
"textureGather", "textureGatherOffset", "textureGatherOffsets",
"textureGrad", "textureGradOffset", "textureLod", "textureLodOffset",
"textureOffset", "textureProj", "textureProjGrad", "textureProjGradOffset",
"textureProjLod", "textureProjLodOffset", "textureProjOffset", "textureSize",
"transpose", "trunc",
"uaddCarry", "uintBitsToFloat", "umulExtended", "unpackHalf2x16",
"unpackSnorm2x16", "unpackSnorm4x8", "unpackUnorm2x16", "unpackUnorm4x8",
"usubBorrow",
};
inline bool IsEsslBuiltinFunctionName(std::string_view name) {
return std::binary_search(std::begin(kEsslBuiltinFunctionNames),
std::end(kEsslBuiltinFunctionNames), name);
}
// The parse-level subset, sorted for std::binary_search.
//
// What decides membership, measured against this glslang: a redefinition whose
// signature EXACTLY matches a builtin overload is rejected at parse time
// ("overloaded functions must have the same parameter precision qualifiers", because
// the builtin declaration carries precision qualifiers and the user's does not), so
// it never produces SPIR-V and the OpName backstop never gets a turn. A definition
// that merely ADDS an overload (a signature the builtin set does not have, e.g.
// vec3 pow(vec3, float)) parses fine and is the backstop's job. Probed across the
// full table with a float(float) redefinition, 38 names are rejected that way - so
// membership here is not "everything that could ever be rejected", it is the set
// actually seen in shipped content plus whatever the test suite pins:
// fma, tanh - the bliss shaderpack's from-scratch helpers
// round, min3, max3 - the historical string-scan list this pass replaced
// An EXACT-signature redefinition of any of the other 33 probed-rejected names
// (sinh, floor, sqrt, ...) never compiled on MobileGL HEAD either - the old
// 5-name string scan did not rescue them - so leaving them out preserves the
// status quo for that (never-working) shape while keeping the dead-#if /
// overload-delegation exposure at exactly its historical scope.
// Adding a name is not free: it buys a parse-time rescue at the cost of lexical
// over-detection risk on every shader that merely *calls* that builtin (a definition
// in a dead #if branch, or an overload delegating to the real builtin). Add one only
// with evidence that real content redefines it with a builtin-identical signature.
inline constexpr std::string_view kLexicalPreemptRenameNames[] = {
"fma", "max3", "min3", "round", "tanh",
};
inline bool IsLexicalPreemptRenameName(std::string_view name) {
return std::binary_search(std::begin(kLexicalPreemptRenameNames),
std::end(kLexicalPreemptRenameNames), name);
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -15,6 +15,7 @@
#include "SpirvPasses/EliminateFloatEqualsZeroPass.h" #include "SpirvPasses/EliminateFloatEqualsZeroPass.h"
#include "SpirvPasses/FlattenInterfaceStructPass.h" #include "SpirvPasses/FlattenInterfaceStructPass.h"
#include "SpirvPasses/RenameSamplerFunctionParameterPass.h" #include "SpirvPasses/RenameSamplerFunctionParameterPass.h"
#include "SpirvPasses/RenameBuiltinShadowingFunctionsPass.h"
#include "SpirvPasses/DecomposeWorkgroupVec3Pass.h" #include "SpirvPasses/DecomposeWorkgroupVec3Pass.h"
#include "SpirvPasses/DecoratePositionInvariantPass.h" #include "SpirvPasses/DecoratePositionInvariantPass.h"
#include "SpirvPasses/LowerDrawParametersPass.h" #include "SpirvPasses/LowerDrawParametersPass.h"
@@ -36,7 +37,10 @@
namespace MobileGL { namespace MobileGL {
namespace MG_Util { namespace MG_Util {
namespace ShaderTranspiler { namespace ShaderTranspiler {
TBuiltInResource BuildTBuiltInResource() { // `env` is the compile-time backend snapshot; null means "resolve from the live
// backend", which is what the standalone/test entry points do. The pipeline always
// passes one, so a worker never reaches pActiveBackendObject through here.
TBuiltInResource BuildTBuiltInResource(const CompileEnv* env) {
TBuiltInResource Resources{}; TBuiltInResource Resources{};
Resources.maxLights = 32; Resources.maxLights = 32;
Resources.maxClipPlanes = 6; Resources.maxClipPlanes = 6;
@@ -138,7 +142,8 @@ namespace MobileGL {
const MG_Backend::DynamicBackendParameters fallbackParameters{}; const MG_Backend::DynamicBackendParameters fallbackParameters{};
const auto& activeBackend = MG_Backend::pActiveBackendObject; const auto& activeBackend = MG_Backend::pActiveBackendObject;
const auto& dynamicParameters = const auto& dynamicParameters =
activeBackend ? activeBackend->GetDynamicParameters() : fallbackParameters; env ? env->params
: (activeBackend ? activeBackend->GetDynamicParameters() : fallbackParameters);
Resources.maxImageUnits = dynamicParameters.MaxImageUnits; Resources.maxImageUnits = dynamicParameters.MaxImageUnits;
Resources.maxCombinedImageUnitsAndFragmentOutputs = Resources.maxCombinedImageUnitsAndFragmentOutputs =
dynamicParameters.MaxImageUnits + dynamicParameters.MaxDrawBuffers; dynamicParameters.MaxImageUnits + dynamicParameters.MaxDrawBuffers;
@@ -166,7 +171,8 @@ namespace MobileGL {
// copies that could drift apart. // copies that could drift apart.
static Result<SharedPtr<glslang::TShader>> ParseShaderSource(EShLanguage lang, GLenum shaderType, static Result<SharedPtr<glslang::TShader>> ParseShaderSource(EShLanguage lang, GLenum shaderType,
const String& source, const String& source,
Flags<ShaderCompileBits> flags) { Flags<ShaderCompileBits> flags,
const CompileEnv* env) {
SharedPtr<glslang::TShader> res; SharedPtr<glslang::TShader> res;
auto& tshader = res; auto& tshader = res;
tshader = MakeShared<glslang::TShader>(lang); tshader = MakeShared<glslang::TShader>(lang);
@@ -193,7 +199,7 @@ namespace MobileGL {
tshader->setAutoMapLocations(true); tshader->setAutoMapLocations(true);
tshader->setAutoMapBindings(true); tshader->setAutoMapBindings(true);
tshader->setGlobalUniformBlockName(GLOBAL_UBO_NAME); tshader->setGlobalUniformBlockName(GLOBAL_UBO_NAME);
auto resources = BuildTBuiltInResource(); auto resources = BuildTBuiltInResource(env);
if (!tshader->parse(&resources, 460, ECoreProfile, if (!tshader->parse(&resources, 460, ECoreProfile,
/*forceDefaultVersionAndProfile: */ false, /*forceDefaultVersionAndProfile: */ false,
/*forwardCompatible: */ true, EShMsgDefault)) { /*forwardCompatible: */ true, EShMsgDefault)) {
@@ -219,7 +225,7 @@ namespace MobileGL {
} }
const String source(attrib.sourceStr); const String source(attrib.sourceStr);
auto result = ParseShaderSource(lang, shaderType, source, attrib.flags); auto result = ParseShaderSource(lang, shaderType, source, attrib.flags, attrib.env);
if (result) return result; if (result) return result;
// Legacy desktop sources are normalized to "#version 330 core" (with a marker on the // Legacy desktop sources are normalized to "#version 330 core" (with a marker on the
@@ -235,7 +241,7 @@ namespace MobileGL {
return result; return result;
} }
auto retryResult = ParseShaderSource(lang, shaderType, retrySource, attrib.flags); auto retryResult = ParseShaderSource(lang, shaderType, retrySource, attrib.flags, attrib.env);
if (!retryResult) return result; if (!retryResult) return result;
MGLOG_D("CompileShader: %s only parsed after retargeting its legacy #version to 460", MGLOG_D("CompileShader: %s only parsed after retargeting its legacy #version to 460",
@@ -243,6 +249,56 @@ namespace MobileGL {
return retryResult; return retryResult;
} }
// Namespace-level rather than a function-local static, because it has to be
// CLEARABLE: what PrewarmBuiltins latches is not a property of this process, it is
// a property of the built-in symbol tables glslang currently holds, and
// glslang::FinalizeProcess() deletes those. A function-local latch survived the
// teardown that invalidated it, so an Initialize -> Destroy -> Initialize cycle
// came back up with the tables gone and the prewarm skipped - which is exactly the
// serialized-first-parse stall this function exists to prevent, only now
// unfixable for the rest of the process. Reset it from DestroyImpl.
namespace {
Bool g_builtinsPrewarmed = false;
} // namespace
void ShaderCompiler::ResetPrewarmLatch() { g_builtinsPrewarmed = false; }
void ShaderCompiler::PrewarmBuiltins() {
if (g_builtinsPrewarmed) return;
g_builtinsPrewarmed = true;
// One vertex and one fragment shader is enough: the built-in table is cached
// per (version, spvVersion, profile, source), not per stage language, and
// both configurations CompileShader can reach - the declared-460 path and
// the retargeted-legacy path - resolve to the same combination here because
// ParseShaderSource always passes 460/ECoreProfile as the default. Parsing
// both anyway costs microseconds and keeps this honest if that ever changes.
static constexpr const char* kPrewarmVertexSource =
"#version 460\nvoid main() { gl_Position = vec4(0.0); }\n";
static constexpr const char* kPrewarmFragmentSource =
"#version 460\nlayout(location = 0) out vec4 c;\nvoid main() { c = vec4(0.0); }\n";
static constexpr const char* kPrewarmLegacyVertexSource =
"#version 330 core\nvoid main() { gl_Position = vec4(0.0); }\n";
const CompileEnv& env = *GetDefaultCompileEnv();
for (const auto& [type, source] :
{std::pair{GL_VERTEX_SHADER, kPrewarmVertexSource},
std::pair{GL_FRAGMENT_SHADER, kPrewarmFragmentSource},
std::pair{GL_VERTEX_SHADER, kPrewarmLegacyVertexSource}}) {
ShaderAttrib attrib{.shaderType = static_cast<GLenum>(type),
.sourceStr = source,
.flags = 0,
.env = &env};
// The result is deliberately discarded: the value is the symbol table
// glslang cached as a side effect. A failure here is not fatal - it just
// means the first real compile pays for the table, exactly as before.
(void)CompileShader(attrib);
}
// The parses above left this thread's glslang allocator pointing at the last
// TShader's pool, and that TShader is about to be destroyed with it.
glslang::SetThreadPoolAllocator(nullptr);
}
Result<SharedPtr<glslang::TProgram>> ShaderCompiler::LinkProgram(const ProgramAttrib& attrib) { Result<SharedPtr<glslang::TProgram>> ShaderCompiler::LinkProgram(const ProgramAttrib& attrib) {
SharedPtr<glslang::TProgram> program = MakeShared<glslang::TProgram>(); SharedPtr<glslang::TProgram> program = MakeShared<glslang::TProgram>();
for (auto& s : attrib.shaders) { for (auto& s : attrib.shaders) {
@@ -310,6 +366,8 @@ namespace MobileGL {
optimizer.RegisterPass(CreateRemoveUnusedInterfaceVariablesPass()); optimizer.RegisterPass(CreateRemoveUnusedInterfaceVariablesPass());
optimizer.RegisterPass(FlattenInterfaceStructPass::CreateFlattenInterfaceStructPass()); optimizer.RegisterPass(FlattenInterfaceStructPass::CreateFlattenInterfaceStructPass());
optimizer.RegisterPass(RenameSamplerFunctionParameterPass::CreateRenameSamplerFunctionParameterPass()); optimizer.RegisterPass(RenameSamplerFunctionParameterPass::CreateRenameSamplerFunctionParameterPass());
optimizer.RegisterPass(
RenameBuiltinShadowingFunctionsPass::CreateRenameBuiltinShadowingFunctionsPass());
optimizer.RegisterPass(EliminateFloatEqualsZeroPass::CreateEliminateFloatEqualsZeroPass()); optimizer.RegisterPass(EliminateFloatEqualsZeroPass::CreateEliminateFloatEqualsZeroPass());
optimizer.RegisterPass(DecomposeWorkgroupVec3Pass::CreateDecomposeWorkgroupVec3Pass()); optimizer.RegisterPass(DecomposeWorkgroupVec3Pass::CreateDecomposeWorkgroupVec3Pass());

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