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
97 changed files with 16119 additions and 2897 deletions
+3
View File
@@ -34,3 +34,6 @@
[submodule "3rdparty/asio"] [submodule "3rdparty/asio"]
path = 3rdparty/asio path = 3rdparty/asio
url = https://github.com/chriskohlhoff/asio.git url = https://github.com/chriskohlhoff/asio.git
[submodule "3rdparty/libfork"]
path = 3rdparty/libfork
url = https://github.com/ConorWilliams/libfork.git
Vendored Submodule
+1
Submodule 3rdparty/libfork added at 9b2b844a5f
+11
View File
@@ -232,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
@@ -294,8 +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/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
@@ -369,6 +373,13 @@ set(MOBILEGL_INCLUDE_DIR
# MG_Util/Async/ShaderCompilePool.cpp includes it, and it stays behind that file's # MG_Util/Async/ShaderCompilePool.cpp includes it, and it stays behind that file's
# pimpl so no consumer target needs this path. # pimpl so no consumer target needs this path.
${CMAKE_SOURCE_DIR}/3rdparty/asio/asio/include ${CMAKE_SOURCE_DIR}/3rdparty/asio/asio/include
# The second shader-compile execution engine (MOBILEGL_ASYNC_POOL=libfork), on the
# same terms as Asio above: header-only, no add_subdirectory (its CMakeLists only
# declares an INTERFACE target plus install/test scaffolding we do not want), no link
# target, and reachable from exactly one translation unit. libfork's own
# target_compile_features asks for cxx_std_23, which this project already sets
# globally, so its C++20 coroutines need no per-source standard override.
${CMAKE_SOURCE_DIR}/3rdparty/libfork/include
) )
add_library(${CMAKE_PROJECT_NAME} SHARED add_library(${CMAKE_PROJECT_NAME} SHARED
+6
View File
@@ -66,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;
+18
View File
@@ -16,6 +16,7 @@
#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/Async/ShaderCompilePool.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <atomic> #include <atomic>
#include <mutex> #include <mutex>
@@ -61,6 +62,10 @@ namespace MobileGL {
// still reference levels adopted from those tables. Finalizing first left live // still reference levels adopted from those tables. Finalizing first left live
// glslang objects pointing at freed memory for the rest of the teardown. // glslang objects pointing at freed memory for the rest of the teardown.
glslang::FinalizeProcess(); 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) {
@@ -88,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;
+379 -122
View File
@@ -1127,10 +1127,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
// slot the key covers still holds a reference to it. Holding either side by shared_ptr // slot the key covers still holds a reference to it. Holding either side by shared_ptr
// instead would keep dead frontend textures alive and defeat the registry's // instead would keep dead frontend textures alive and defeat the registry's
// weak-reference GC. // weak-reference GC.
//
// `texture` records WHICH frontend object `backend` was paired with when the entry was
// built, and PairingsIntact re-checks it before any replay. The keys above are the
// primary guard, but they are all derived state: a slot swap that never reaches the
// bind generation (the DSA by-name emulation used to swap a slot silently) would leave
// every key matching while the borrowed slot pointed at a different texture, and the
// replay would then drive texture A's backend twin from texture B's frontend state -
// re-specifying A's backend storage with B's shape and destroying A's contents. A raw
// pointer compare per entry is far cheaper than the walk it guards, and a stale pairing
// costs only a list rebuild, so this stays as the structural net under the keys.
struct UnitTextureSyncEntry { struct UnitTextureSyncEntry {
const SharedPtr<MG_State::GLState::ITextureObject>* slot = nullptr; const SharedPtr<MG_State::GLState::ITextureObject>* slot = nullptr;
MG_State::GLState::ITextureObject* texture = nullptr;
BackendTextureObject* backend = nullptr; BackendTextureObject* backend = nullptr;
}; };
// True while every entry's borrowed slot still holds the texture the entry was paired
// with. Callers put it LAST in the key conjunction so it only runs on a key hit.
static Bool PairingsIntact(const Vector<UnitTextureSyncEntry>& list) {
for (const auto& entry : list) {
if (entry.slot->get() != entry.texture) return false;
}
return true;
}
static Vector<UnitTextureSyncEntry> g_unitTextureSyncList; static Vector<UnitTextureSyncEntry> g_unitTextureSyncList;
static Bool g_unitTextureSyncListValid = false; static Bool g_unitTextureSyncListValid = false;
static Uint64 g_unitTextureSyncListContextId = 0; static Uint64 g_unitTextureSyncListContextId = 0;
@@ -1197,7 +1216,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_unitTextureSyncListMaxUnit == maxTouchedUnit && g_unitTextureSyncListMaxUnit == maxTouchedUnit &&
g_unitTextureSyncListContextGeneration == g_textureContextGeneration && g_unitTextureSyncListContextGeneration == g_textureContextGeneration &&
g_unitTextureSyncListEpoch == unitBindingsEpoch && g_unitTextureSyncListEpoch == unitBindingsEpoch &&
g_unitTextureSyncListSamplingGeneration == samplingGeneration) { g_unitTextureSyncListSamplingGeneration == samplingGeneration &&
PairingsIntact(g_unitTextureSyncList)) {
for (const auto& entry : g_unitTextureSyncList) { for (const auto& entry : g_unitTextureSyncList) {
// Aggregate gate == the conjunction of the three callees' own // Aggregate gate == the conjunction of the three callees' own
// early-outs (see IsDrawSyncClean); skipping on true is // early-outs (see IsDrawSyncClean); skipping on true is
@@ -1219,8 +1239,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
// An image-less default texture (name 0) is the slot's initial / "unbound" // An image-less default texture (name 0) is the slot's initial / "unbound"
// state; it has nothing to sync, so skip it as cheaply as the old null slot. // state; it has nothing to sync, so skip it as cheaply as the old null slot.
if (textureObject && !MG_State::GLState::IsUndefinedDefaultTexture(textureObject.get())) { if (textureObject && !MG_State::GLState::IsUndefinedDefaultTexture(textureObject.get())) {
g_unitTextureSyncList.push_back( g_unitTextureSyncList.push_back({&textureObject, textureObject.get(),
{&textureObject, SyncTextureObjectToBackend(textureObject).get()}); SyncTextureObjectToBackend(textureObject).get()});
} }
} }
} }
@@ -1254,7 +1274,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_fboTextureSyncListSlotVersion == fboSlotVersion && g_fboTextureSyncListSlotVersion == fboSlotVersion &&
g_fboTextureSyncListObjectVersion == fboObjectVersion && g_fboTextureSyncListObjectVersion == fboObjectVersion &&
g_fboTextureSyncListContextId == keys.contextId && g_fboTextureSyncListContextId == keys.contextId &&
g_fboTextureSyncListContextGeneration == g_textureContextGeneration; g_fboTextureSyncListContextGeneration == g_textureContextGeneration &&
PairingsIntact(g_fboTextureSyncList);
if (fboListValid) { if (fboListValid) {
for (const auto& entry : g_fboTextureSyncList) { for (const auto& entry : g_fboTextureSyncList) {
// Same aggregate gate as the unit list above. // Same aggregate gate as the unit list above.
@@ -1273,8 +1294,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!attachment.IsTexture()) continue; if (!attachment.IsTexture()) continue;
auto& textureObject = attachment.GetTexture(); auto& textureObject = attachment.GetTexture();
if (textureObject) { if (textureObject) {
g_fboTextureSyncList.push_back( g_fboTextureSyncList.push_back({&textureObject, textureObject.get(),
{&textureObject, SyncTextureObjectToBackend(textureObject).get()}); SyncTextureObjectToBackend(textureObject).get()});
} }
} }
g_fboTextureSyncListFbo = currentFBO.get(); g_fboTextureSyncListFbo = currentFBO.get();
@@ -1380,7 +1401,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
if (currentFBO == MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO) { if (currentFBO == MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO) {
// Default FBO, nothing to sync // Default FBO, nothing to sync - except the widened-attachment mask, which is
// only ever WRITTEN by SyncToBackend and would otherwise still describe the
// user FBO that was draw-bound before. The window surface is a real RGBA
// buffer, so nothing here is ever widened.
if (target == FramebufferTarget::Draw) {
g_alphaWidenedDrawBufferMask = 0;
g_integerColorDrawBufferMask = 0;
}
StampSyncedFBO(target, slotVersion, objectVersion, currentPtr); StampSyncedFBO(target, slotVersion, objectVersion, currentPtr);
continue; continue;
} }
@@ -1422,15 +1450,53 @@ namespace MobileGL::MG_Backend::DirectGLES {
static Bool g_hasSyncedRenderState = false; static Bool g_hasSyncedRenderState = false;
static RenderStateParameters g_syncedRenderStateParameters; static RenderStateParameters g_syncedRenderStateParameters;
static IntVec4 g_syncedBackendViewport = IntVec4(-1, -1, -1, -1); static IntVec4 g_syncedBackendViewport = IntVec4(-1, -1, -1, -1);
// The RESOLVED scissor rectangle last pushed (see the scissor block in SyncRenderState);
// an impossible value so the first sync always pushes.
static IntVec4 g_syncedBackendScissorBox = IntVec4(-1, -1, -1, -1);
// GLES starts with sRGB framebuffer encoding on, so the first sync always has to push the // GLES starts with sRGB framebuffer encoding on, so the first sync always has to push the
// frontend's (desktop-GL default) disabled state down. // frontend's (desktop-GL default) disabled state down.
static Bool g_syncedSrgbFramebufferWrites = true; static Bool g_syncedSrgbFramebufferWrites = true;
void SyncRenderState() { // Set when the shadow below stops describing the real ES context. The ES context
// OUTLIVES every MobileGL context, so a MobileGL context switch leaves it holding the
// previous context's enable state while the frontend's parameter block AND its
// version counter both restart from defaults. Every "differs from what I last pushed"
// test in SyncRenderState would then agree that nothing needs pushing, and the
// leftover state silently applies to the new context - the class of bug the CTS
// caught as GL_FRAMEBUFFER_SRGB surviving from vertex_attrib_binding into
// direct_state_access.renderbuffers_storage. One unconditional push settles the whole
// block rather than the one cap that happened to be noticed.
static Bool g_forceFullRenderStateResync = true;
// Which draw buffers' alpha channel the colour mask last pushed to the driver had forced
// OFF - i.e. the value of `appliedWidenMask` in the last SyncRenderState that reached the
// colour-mask block. NOT derivable from the frontend parameter block: it depends on the
// bound DRAW framebuffer's attachment formats and on whether the caller is a draw or a
// clear, neither of which bumps the frontend render-state version. Without it a
// clear-then-draw pair on an unchanged parameter block early-outs and the draw inherits
// the clear's undoctored mask.
static Uint32 g_syncedColorMaskAlphaWidenMask = 0;
void InvalidateSyncedRenderState() {
g_forceFullRenderStateResync = true;
g_hasSyncedRenderState = false;
g_syncedBackendViewport = IntVec4(-1, -1, -1, -1);
g_syncedBackendScissorBox = IntVec4(-1, -1, -1, -1);
}
void SyncRenderState(Bool forColorClear) {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif #endif
Uint16 currentRenderStateVersion = MG_State::pGLContext->GetRenderStateParametersVersion(); Uint16 currentRenderStateVersion = MG_State::pGLContext->GetRenderStateParametersVersion();
if (g_hasSyncedRenderState && currentRenderStateVersion == g_syncedRenderStateVersion) return; const Bool forceFullPush = g_forceFullRenderStateResync;
g_forceFullRenderStateResync = false;
// The alpha discipline for widened colour attachments (see the header comment on
// SyncRenderState): a DRAW must not be able to move the stored alpha off 1.0, a CLEAR
// is what puts it there. So the draw path masks alpha off on every widened draw
// buffer and the clear path masks nothing.
const Uint32 appliedWidenMask = forColorClear ? 0u : FramebufferImpl::g_alphaWidenedDrawBufferMask;
const Bool colorMaskWidenDirty = appliedWidenMask != g_syncedColorMaskAlphaWidenMask;
if (!forceFullPush && !colorMaskWidenDirty && g_hasSyncedRenderState &&
currentRenderStateVersion == g_syncedRenderStateVersion) {
return;
}
const auto& parameters = MG_State::pGLContext->GetRenderStateParameters(); const auto& parameters = MG_State::pGLContext->GetRenderStateParameters();
@@ -1478,7 +1544,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// All 12 capability bools live after LogicOp in the struct, i.e. in the tail span. // All 12 capability bools live after LogicOp in the struct, i.e. in the tail span.
if (tailSpanDirty) { if (tailSpanDirty) {
#define SYNC_CAPABILITY(cap_mg, cap_gl) \ #define SYNC_CAPABILITY(cap_mg, cap_gl) \
if (parameters.cap_mg##Enabled != g_syncedRenderStateParameters.cap_mg##Enabled) { \ if (forceFullPush || parameters.cap_mg##Enabled != g_syncedRenderStateParameters.cap_mg##Enabled) { \
if (parameters.cap_mg##Enabled) { \ if (parameters.cap_mg##Enabled) { \
g_GLESFuncs.glEnable(cap_gl); \ g_GLESFuncs.glEnable(cap_gl); \
} else { \ } else { \
@@ -1507,7 +1573,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
// into an sRGB colour buffer comes back encoded once too often (the shader's own // into an sRGB colour buffer comes back encoded once too often (the shader's own
// decode on the next fetch then leaves the value one conversion short). // decode on the next fetch then leaves the value one conversion short).
const Bool srgbWrites = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::FramebufferSrgb); const Bool srgbWrites = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::FramebufferSrgb);
if (g_GLESCapabilities.SupportsSrgbWriteControl && srgbWrites != g_syncedSrgbFramebufferWrites) { if (g_GLESCapabilities.SupportsSrgbWriteControl &&
(forceFullPush || srgbWrites != g_syncedSrgbFramebufferWrites)) {
srgbWrites ? g_GLESFuncs.glEnable(GL_FRAMEBUFFER_SRGB) srgbWrites ? g_GLESFuncs.glEnable(GL_FRAMEBUFFER_SRGB)
: g_GLESFuncs.glDisable(GL_FRAMEBUFFER_SRGB); : g_GLESFuncs.glDisable(GL_FRAMEBUFFER_SRGB);
g_syncedSrgbFramebufferWrites = srgbWrites; g_syncedSrgbFramebufferWrites = srgbWrites;
@@ -1520,7 +1587,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const Bool restart = parameters.PrimitiveRestartFixedIndexEnabled || parameters.PrimitiveRestartEnabled; const Bool restart = parameters.PrimitiveRestartFixedIndexEnabled || parameters.PrimitiveRestartEnabled;
const Bool syncedRestart = g_syncedRenderStateParameters.PrimitiveRestartFixedIndexEnabled || const Bool syncedRestart = g_syncedRenderStateParameters.PrimitiveRestartFixedIndexEnabled ||
g_syncedRenderStateParameters.PrimitiveRestartEnabled; g_syncedRenderStateParameters.PrimitiveRestartEnabled;
if (restart != syncedRestart) { if (forceFullPush || restart != syncedRestart) {
restart ? g_GLESFuncs.glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX) restart ? g_GLESFuncs.glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX)
: g_GLESFuncs.glDisable(GL_PRIMITIVE_RESTART_FIXED_INDEX); : g_GLESFuncs.glDisable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
} }
@@ -1556,7 +1623,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool allEnabled = true; Bool allEnabled = true;
Bool allDisabled = true; Bool allDisabled = true;
Bool anyCapDirty = false; Bool anyCapDirty = forceFullPush;
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) { for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
Bool enabled = targetStates[i].Enabled; Bool enabled = targetStates[i].Enabled;
@@ -1581,7 +1648,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
s.Enabled = false; s.Enabled = false;
} else { } else {
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) { for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
if (targetStates[i].Enabled != syncedStates[i].Enabled) { if (forceFullPush || targetStates[i].Enabled != syncedStates[i].Enabled) {
syncedStates[i].Enabled = targetStates[i].Enabled; syncedStates[i].Enabled = targetStates[i].Enabled;
syncedStates[i].Enabled ? g_GLESFuncs.glEnablei(GL_BLEND, i) syncedStates[i].Enabled ? g_GLESFuncs.glEnablei(GL_BLEND, i)
: g_GLESFuncs.glDisablei(GL_BLEND, i); : g_GLESFuncs.glDisablei(GL_BLEND, i);
@@ -1591,7 +1658,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
Bool allFuncsSame = true; Bool allFuncsSame = true;
Bool anyFuncDirty = false; Bool anyFuncDirty = forceFullPush;
const auto& first = targetStates[0]; const auto& first = targetStates[0];
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) { for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
@@ -1630,8 +1697,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
const auto& cur = targetStates[i]; const auto& cur = targetStates[i];
auto& syn = syncedStates[i]; auto& syn = syncedStates[i];
if (cur.SrcFactorRGB != syn.SrcFactorRGB || cur.DstFactorRGB != syn.DstFactorRGB || if (forceFullPush || cur.SrcFactorRGB != syn.SrcFactorRGB ||
cur.SrcFactorAlpha != syn.SrcFactorAlpha || cur.DstFactorAlpha != syn.DstFactorAlpha) { cur.DstFactorRGB != syn.DstFactorRGB ||
cur.SrcFactorAlpha != syn.SrcFactorAlpha ||
cur.DstFactorAlpha != syn.DstFactorAlpha) {
syn.SrcFactorRGB = cur.SrcFactorRGB; syn.SrcFactorRGB = cur.SrcFactorRGB;
syn.DstFactorRGB = cur.DstFactorRGB; syn.DstFactorRGB = cur.DstFactorRGB;
syn.SrcFactorAlpha = cur.SrcFactorAlpha; syn.SrcFactorAlpha = cur.SrcFactorAlpha;
@@ -1648,7 +1717,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
Bool allEquationsSame = true; Bool allEquationsSame = true;
Bool anyEquationDirty = false; Bool anyEquationDirty = forceFullPush;
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) { for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
const auto& cur = targetStates[i]; const auto& cur = targetStates[i];
@@ -1679,7 +1748,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
const auto& cur = targetStates[i]; const auto& cur = targetStates[i];
auto& syn = syncedStates[i]; auto& syn = syncedStates[i];
if (cur.ColorEquation != syn.ColorEquation || cur.AlphaEquation != syn.AlphaEquation) { if (forceFullPush || cur.ColorEquation != syn.ColorEquation ||
cur.AlphaEquation != syn.AlphaEquation) {
syn.ColorEquation = cur.ColorEquation; syn.ColorEquation = cur.ColorEquation;
syn.AlphaEquation = cur.AlphaEquation; syn.AlphaEquation = cur.AlphaEquation;
@@ -1693,13 +1763,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
if (tailSpanDirty) { // Depth state if (tailSpanDirty) { // Depth state
if (parameters.DepthFunc != g_syncedRenderStateParameters.DepthFunc) { if (forceFullPush || parameters.DepthFunc != g_syncedRenderStateParameters.DepthFunc) {
g_GLESFuncs.glDepthFunc(MG_Util::ConvertDepthTestFuncToGLEnum(parameters.DepthFunc)); g_GLESFuncs.glDepthFunc(MG_Util::ConvertDepthTestFuncToGLEnum(parameters.DepthFunc));
} }
if (parameters.DepthMask != g_syncedRenderStateParameters.DepthMask) { if (forceFullPush || parameters.DepthMask != g_syncedRenderStateParameters.DepthMask) {
g_GLESFuncs.glDepthMask(parameters.DepthMask ? GL_TRUE : GL_FALSE); g_GLESFuncs.glDepthMask(parameters.DepthMask ? GL_TRUE : GL_FALSE);
} }
if (parameters.DepthRange != g_syncedRenderStateParameters.DepthRange) { if (forceFullPush || parameters.DepthRange != g_syncedRenderStateParameters.DepthRange) {
g_GLESFuncs.glDepthRangef(parameters.DepthRange.x(), parameters.DepthRange.y()); g_GLESFuncs.glDepthRangef(parameters.DepthRange.x(), parameters.DepthRange.y());
} }
} }
@@ -1710,16 +1780,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
const StencilFaceState& synced = g_syncedRenderStateParameters.StencilStates[faceIndex]; const StencilFaceState& synced = g_syncedRenderStateParameters.StencilStates[faceIndex];
const GLenum glFace = faceIndex == 0 ? GL_FRONT : GL_BACK; const GLenum glFace = faceIndex == 0 ? GL_FRONT : GL_BACK;
if (current.Func != synced.Func || current.Ref != synced.Ref || if (forceFullPush || current.Func != synced.Func || current.Ref != synced.Ref ||
current.ValueMask != synced.ValueMask) { current.ValueMask != synced.ValueMask) {
g_GLESFuncs.glStencilFuncSeparate( g_GLESFuncs.glStencilFuncSeparate(
glFace, MG_Util::ConvertDepthTestFuncToGLEnum(current.Func), current.Ref, glFace, MG_Util::ConvertDepthTestFuncToGLEnum(current.Func), current.Ref,
current.ValueMask); current.ValueMask);
} }
if (current.WriteMask != synced.WriteMask) { if (forceFullPush || current.WriteMask != synced.WriteMask) {
g_GLESFuncs.glStencilMaskSeparate(glFace, current.WriteMask); g_GLESFuncs.glStencilMaskSeparate(glFace, current.WriteMask);
} }
if (current.FailOp != synced.FailOp || current.PassDepthFailOp != synced.PassDepthFailOp || if (forceFullPush || current.FailOp != synced.FailOp ||
current.PassDepthFailOp != synced.PassDepthFailOp ||
current.PassDepthPassOp != synced.PassDepthPassOp) { current.PassDepthPassOp != synced.PassDepthPassOp) {
g_GLESFuncs.glStencilOpSeparate( g_GLESFuncs.glStencilOpSeparate(
glFace, MG_Util::ConvertStencilOperationToGLEnum(current.FailOp), glFace, MG_Util::ConvertStencilOperationToGLEnum(current.FailOp),
@@ -1729,23 +1800,42 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
} }
if (tailSpanDirty) { // Color mask. Uniform masks use the non-indexed glColorMask (works everywhere); divergent if (tailSpanDirty || colorMaskWidenDirty) { // Color mask. Uniform masks use the non-indexed glColorMask
// per-draw-buffer masks use the indexed glColorMaski when draw_buffers_indexed is // (works everywhere); divergent per-draw-buffer masks use the indexed glColorMaski when
// available, otherwise fall back to broadcasting draw buffer 0. Mirrors the blend block. // draw_buffers_indexed is available, otherwise fall back to broadcasting draw buffer 0.
// Mirrors the blend block.
using FBO = MG_State::GLState::FramebufferObject; using FBO = MG_State::GLState::FramebufferObject;
const auto& targetMasks = parameters.ColorMasks; const auto& targetMasks = parameters.ColorMasks;
const auto& syncedMasks = g_syncedRenderStateParameters.ColorMasks; const auto& syncedMasks = g_syncedRenderStateParameters.ColorMasks;
Bool anyDirty = false; // What the DRIVER is told for draw buffer i. Identical to the application's mask
// except on a widened attachment during a draw, where alpha is forced off; the
// frontend's own array is never written, so glGet(GL_COLOR_WRITEMASK) keeps
// answering with the application's value.
const auto driverMask = [&](Uint i) -> BoolVec4 {
BoolVec4 m = targetMasks[i];
if (i < 32 && (appliedWidenMask & (1u << i)) != 0) {
m.w() = false;
}
return m;
};
Bool anyDirty = forceFullPush || colorMaskWidenDirty;
Bool allSame = true; Bool allSame = true;
const BoolVec4 driverMask0 = driverMask(0);
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) { for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
if (targetMasks[i] != syncedMasks[i]) anyDirty = true; if (targetMasks[i] != syncedMasks[i]) anyDirty = true;
if (i > 0 && targetMasks[i] != targetMasks[0]) allSame = false; if (i > 0 && driverMask(i) != driverMask0) allSame = false;
} }
if (anyDirty) { if (anyDirty) {
if (allSame || !g_GLESCapabilities.SupportsIndexedColorMask) { if (allSame || !g_GLESCapabilities.SupportsIndexedColorMask) {
const BoolVec4& m = targetMasks[0]; // Without draw_buffers_indexed there is only one mask for the whole
// framebuffer, so a widened draw buffer 0 costs every other buffer its
// alpha writes. ES 3.2 makes glColorMaski core and ES 3.1 has it as
// EXT/OES; the only devices that reach this line are ES 3.0-class, where
// MRT with a mixed widened/native colour attachment set is already rare.
const BoolVec4& m = driverMask0;
g_GLESFuncs.glColorMask(ToGLBoolean(m.x()), ToGLBoolean(m.y()), ToGLBoolean(m.z()), g_GLESFuncs.glColorMask(ToGLBoolean(m.x()), ToGLBoolean(m.y()), ToGLBoolean(m.z()),
ToGLBoolean(m.w())); ToGLBoolean(m.w()));
} else { } else {
@@ -1753,19 +1843,23 @@ namespace MobileGL::MG_Backend::DirectGLES {
: g_GLESFuncs.glColorMaskiEXT ? g_GLESFuncs.glColorMaskiEXT : g_GLESFuncs.glColorMaskiEXT ? g_GLESFuncs.glColorMaskiEXT
: g_GLESFuncs.glColorMaskiOES; : g_GLESFuncs.glColorMaskiOES;
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) { for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
if (targetMasks[i] != syncedMasks[i]) { // colorMaskWidenDirty forces every slot: the previous push may have
const BoolVec4& m = targetMasks[i]; // been the non-indexed glColorMask above (which set all of them), and
// the per-slot diff below only knows about the application's array.
if (forceFullPush || colorMaskWidenDirty || targetMasks[i] != syncedMasks[i]) {
const BoolVec4 m = driverMask(i);
colorMaskiFn(i, ToGLBoolean(m.x()), ToGLBoolean(m.y()), ToGLBoolean(m.z()), colorMaskiFn(i, ToGLBoolean(m.x()), ToGLBoolean(m.y()), ToGLBoolean(m.z()),
ToGLBoolean(m.w())); ToGLBoolean(m.w()));
} }
} }
} }
} }
g_syncedColorMaskAlphaWidenMask = appliedWidenMask;
} }
if (tailSpanDirty) { // Polygon mode. GLES core has no glPolygonMode; use NV/ANGLE_polygon_mode when present. if (tailSpanDirty) { // Polygon mode. GLES core has no glPolygonMode; use NV/ANGLE_polygon_mode when present.
// Without the extension the mode stays FILL and non-FILL requests are dropped. // Without the extension the mode stays FILL and non-FILL requests are dropped.
if (parameters.PolygonModeFront != g_syncedRenderStateParameters.PolygonModeFront && if ((forceFullPush || parameters.PolygonModeFront != g_syncedRenderStateParameters.PolygonModeFront) &&
g_GLESCapabilities.SupportsPolygonMode) { g_GLESCapabilities.SupportsPolygonMode) {
const auto polygonModeFn = const auto polygonModeFn =
g_GLESFuncs.glPolygonModeNV ? g_GLESFuncs.glPolygonModeNV : g_GLESFuncs.glPolygonModeANGLE; g_GLESFuncs.glPolygonModeNV ? g_GLESFuncs.glPolygonModeNV : g_GLESFuncs.glPolygonModeANGLE;
@@ -1774,67 +1868,97 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
if (tailSpanDirty) { // Clear values if (tailSpanDirty) { // Clear values
if (parameters.ClearColor != g_syncedRenderStateParameters.ClearColor) { if (forceFullPush || parameters.ClearColor != g_syncedRenderStateParameters.ClearColor) {
const FloatVec4& clearCol = parameters.ClearColor; const FloatVec4& clearCol = parameters.ClearColor;
g_GLESFuncs.glClearColor(clearCol.x(), clearCol.y(), clearCol.z(), clearCol.w()); g_GLESFuncs.glClearColor(clearCol.x(), clearCol.y(), clearCol.z(), clearCol.w());
} }
if (parameters.ClearDepth != g_syncedRenderStateParameters.ClearDepth) { if (forceFullPush || parameters.ClearDepth != g_syncedRenderStateParameters.ClearDepth) {
g_GLESFuncs.glClearDepthf(parameters.ClearDepth); g_GLESFuncs.glClearDepthf(parameters.ClearDepth);
} }
if (parameters.ClearStencil != g_syncedRenderStateParameters.ClearStencil) { if (forceFullPush || parameters.ClearStencil != g_syncedRenderStateParameters.ClearStencil) {
g_GLESFuncs.glClearStencil(static_cast<GLint>(parameters.ClearStencil)); g_GLESFuncs.glClearStencil(static_cast<GLint>(parameters.ClearStencil));
} }
if (parameters.BlendColor != g_syncedRenderStateParameters.BlendColor) { if (forceFullPush || parameters.BlendColor != g_syncedRenderStateParameters.BlendColor) {
const FloatVec4& blendColor = parameters.BlendColor; const FloatVec4& blendColor = parameters.BlendColor;
g_GLESFuncs.glBlendColor(blendColor.x(), blendColor.y(), blendColor.z(), blendColor.w()); g_GLESFuncs.glBlendColor(blendColor.x(), blendColor.y(), blendColor.z(), blendColor.w());
} }
} }
if (tailSpanDirty) { // Cull face mode if (tailSpanDirty) { // Cull face mode
if (parameters.CullFaceModeSetting != g_syncedRenderStateParameters.CullFaceModeSetting) { if (forceFullPush || parameters.CullFaceModeSetting != g_syncedRenderStateParameters.CullFaceModeSetting) {
const CullFaceMode& cfm = parameters.CullFaceModeSetting; const CullFaceMode& cfm = parameters.CullFaceModeSetting;
g_GLESFuncs.glCullFace(MG_Util::ConvertCullFaceModeToGLEnum(cfm)); g_GLESFuncs.glCullFace(MG_Util::ConvertCullFaceModeToGLEnum(cfm));
} }
if (parameters.FrontFaceModeSetting != g_syncedRenderStateParameters.FrontFaceModeSetting) { if (forceFullPush || parameters.FrontFaceModeSetting != g_syncedRenderStateParameters.FrontFaceModeSetting) {
const FrontFaceMode& ffm = parameters.FrontFaceModeSetting; const FrontFaceMode& ffm = parameters.FrontFaceModeSetting;
g_GLESFuncs.glFrontFace(MG_Util::ConvertFrontFaceModeToGLEnum(ffm)); g_GLESFuncs.glFrontFace(MG_Util::ConvertFrontFaceModeToGLEnum(ffm));
} }
} }
if (tailSpanDirty) { // Scissor box if (tailSpanDirty) { // Scissor box. Resolved and shadowed like the viewport above, and
if (parameters.ScissorBox != g_syncedRenderStateParameters.ScissorBox) { // for the same reason: what has to reach the driver is NOT simply the parameter
const IntVec4& scissorBox = parameters.ScissorBox; // field. (0,0,0,0) is where RenderStateParameters::ScissorBox starts and the only
g_GLESFuncs.glScissor(scissorBox.x(), scissorBox.y(), scissorBox.z(), scissorBox.w()); // thing that ever writes it is glScissor, so that value means "the application has
// never called glScissor" - it is not a GL scissor box. GL's initial box is the
// whole window, which the frontend has no way to spell before a surface exists.
// The pre-resync code got away with pushing the field verbatim only by accident:
// the shadow held the same default, the field never compared unequal, and the ES
// context kept its own correct default. Under the forced full push that accident
// is gone, glScissor(0,0,0,0) shrinks the scissor to an EMPTY rectangle, and
// everything drawn with GL_SCISSOR_TEST enabled before the app's first glScissor
// is clipped away - Minecraft 26.2 keeps only its unscissored sky and hand and
// loses the terrain and the whole GUI.
IntVec4 backendScissorBox = parameters.ScissorBox;
if (backendScissorBox.z() <= 0 || backendScissorBox.w() <= 0) {
Int surfaceWidth = 0;
Int surfaceHeight = 0;
if (QueryCurrentSurfaceSize(surfaceWidth, surfaceHeight)) {
backendScissorBox = IntVec4(0, 0, surfaceWidth, surfaceHeight);
}
}
// Compared against what was actually PUSHED, not against the parameter field, so
// the resolved value and the diff can never disagree.
if (backendScissorBox != g_syncedBackendScissorBox) {
g_GLESFuncs.glScissor(backendScissorBox.x(), backendScissorBox.y(), backendScissorBox.z(),
backendScissorBox.w());
g_syncedBackendScissorBox = backendScissorBox;
} }
} }
if (tailSpanDirty) { // Logic op (first field of the tail span) if (tailSpanDirty) { // Logic op (first field of the tail span)
if (parameters.LogicOp != g_syncedRenderStateParameters.LogicOp) { // glLogicOp is GLES 1.x / EXT only - eglGetProcAddress returns null for it on a
// plain ES 3.x driver. Before the forced resync it was reached only when an app
// actually set a logic op; now every MakeCurrent would call it, so the null check
// is mandatory rather than defensive.
if (g_GLESFuncs.glLogicOp &&
(forceFullPush || parameters.LogicOp != g_syncedRenderStateParameters.LogicOp)) {
g_GLESFuncs.glLogicOp(MG_Util::ConvertLogicOperationToGLEnum(parameters.LogicOp)); g_GLESFuncs.glLogicOp(MG_Util::ConvertLogicOperationToGLEnum(parameters.LogicOp));
} }
} }
if (headSpanDirty) { // Polygon offset (head-span scalars, like line width / point size below) if (headSpanDirty) { // Polygon offset (head-span scalars, like line width / point size below)
if (parameters.PolygonOffsetFactor != g_syncedRenderStateParameters.PolygonOffsetFactor || if (forceFullPush || parameters.PolygonOffsetFactor != g_syncedRenderStateParameters.PolygonOffsetFactor ||
parameters.PolygonOffsetUnits != g_syncedRenderStateParameters.PolygonOffsetUnits) { parameters.PolygonOffsetUnits != g_syncedRenderStateParameters.PolygonOffsetUnits) {
g_GLESFuncs.glPolygonOffset(parameters.PolygonOffsetFactor, parameters.PolygonOffsetUnits); g_GLESFuncs.glPolygonOffset(parameters.PolygonOffsetFactor, parameters.PolygonOffsetUnits);
} }
} }
if (headSpanDirty) { // Line width if (headSpanDirty) { // Line width
if (parameters.LineWidth != g_syncedRenderStateParameters.LineWidth) { if (forceFullPush || parameters.LineWidth != g_syncedRenderStateParameters.LineWidth) {
g_GLESFuncs.glLineWidth(parameters.LineWidth); g_GLESFuncs.glLineWidth(parameters.LineWidth);
} }
} }
if (headSpanDirty) { // Point size if (headSpanDirty) { // Point size (GLES 1.x only - ES 2+ sets it from gl_PointSize,
if (parameters.PointSize != g_syncedRenderStateParameters.PointSize) { // so the entry point is absent on most drivers; see the glLogicOp note above)
if (g_GLESFuncs.glPointSize &&
(forceFullPush || parameters.PointSize != g_syncedRenderStateParameters.PointSize)) {
g_GLESFuncs.glPointSize(parameters.PointSize); g_GLESFuncs.glPointSize(parameters.PointSize);
} }
} }
if (tailSpanDirty) { // Sample coverage if (tailSpanDirty) { // Sample coverage
if (parameters.SampleCoverageValue != g_syncedRenderStateParameters.SampleCoverageValue || if (forceFullPush || parameters.SampleCoverageValue != g_syncedRenderStateParameters.SampleCoverageValue ||
parameters.SampleCoverageInvert != g_syncedRenderStateParameters.SampleCoverageInvert) { parameters.SampleCoverageInvert != g_syncedRenderStateParameters.SampleCoverageInvert) {
g_GLESFuncs.glSampleCoverage(parameters.SampleCoverageValue, g_GLESFuncs.glSampleCoverage(parameters.SampleCoverageValue,
ToGLBoolean(parameters.SampleCoverageInvert)); ToGLBoolean(parameters.SampleCoverageInvert));
@@ -1842,7 +1966,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
if (tailSpanDirty) { // Sample mask if (tailSpanDirty) { // Sample mask
if (g_GLESFuncs.glSampleMaski && parameters.SampleMaskValue != g_syncedRenderStateParameters.SampleMaskValue) { if (g_GLESFuncs.glSampleMaski &&
(forceFullPush || parameters.SampleMaskValue != g_syncedRenderStateParameters.SampleMaskValue)) {
g_GLESFuncs.glSampleMaski(0, parameters.SampleMaskValue); g_GLESFuncs.glSampleMaski(0, parameters.SampleMaskValue);
} }
} }
@@ -2009,6 +2134,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif #endif
if (!framebuffer || framebuffer == MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO) { if (!framebuffer || framebuffer == MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO) {
// Same reset as SyncCurrentFBO's default-framebuffer branch: SyncToBackend is the
// only writer of the widened-attachment mask, so a path that skips it has to say so
// explicitly. It matters here because the DSA clears and glBlitFramebuffer briefly
// sync a DIFFERENT framebuffer as DRAW and then restore the application's through
// ForceBindCurrentFBO - which lands right here when that one is the default.
if (target == FramebufferTarget::Draw) {
FramebufferImpl::g_alphaWidenedDrawBufferMask = 0;
FramebufferImpl::g_integerColorDrawBufferMask = 0;
}
FramebufferImpl::BindFramebufferId( FramebufferImpl::BindFramebufferId(
target == FramebufferTarget::Draw ? GL_DRAW_FRAMEBUFFER : GL_READ_FRAMEBUFFER, 0); target == FramebufferTarget::Draw ? GL_DRAW_FRAMEBUFFER : GL_READ_FRAMEBUFFER, 0);
return; return;
@@ -2929,7 +3063,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
#endif #endif
TextureImpl::SyncNeccessaryTextures(); TextureImpl::SyncNeccessaryTextures();
FramebufferImpl::SyncCurrentFBO(); FramebufferImpl::SyncCurrentFBO();
RenderStateImpl::SyncRenderState(); // A colour clear is exactly the operation that is allowed to write a widened
// attachment's alpha - it is what puts the 1.0 there that every later draw is masked
// away from. SyncCurrentFBO ran first, so g_alphaWidenedDrawBufferMask already describes
// the framebuffer this clear will land on.
RenderStateImpl::SyncRenderState(/*forColorClear=*/(mask & GL_COLOR_BUFFER_BIT) != 0);
BindCurrentFBO(FramebufferTarget::Draw); BindCurrentFBO(FramebufferTarget::Draw);
@@ -2967,10 +3105,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
const FloatVec4& cc = MG_State::pGLContext->GetRenderStateParameters().ClearColor; const FloatVec4& cc = MG_State::pGLContext->GetRenderStateParameters().ClearColor;
const Bool outOfRange = cc.x() < 0.f || cc.x() > 1.f || cc.y() < 0.f || cc.y() > 1.f || cc.z() < 0.f || const Bool outOfRange = cc.x() < 0.f || cc.x() > 1.f || cc.y() < 0.f || cc.y() > 1.f || cc.z() < 0.f ||
cc.z() > 1.f || cc.w() < 0.f || cc.w() > 1.f; cc.z() > 1.f || cc.w() < 0.f || cc.w() > 1.f;
// A widened attachment's stored alpha has to end up 1.0, and glClear applies ONE
// clear colour to every draw buffer - so a framebuffer that mixes a widened
// attachment with a native one cannot be served by doctoring glClearColor. Take the
// same per-draw-buffer glClearBufferfv route the out-of-range case already uses and
// substitute the alpha only where it belongs. Scissor and the colour write mask apply
// to glClearBufferfv exactly as they do to glClear, so a scissored clear stays
// scissored and an application that masked alpha off still gets its way (the storage
// then keeps the 1.0 an earlier clear left, which is the same answer).
//
// glClearBufferfv on an INTEGER colour buffer is GL_INVALID_OPERATION, so a
// framebuffer with one of those as a draw buffer keeps plain glClear - which ES
// leaves undefined for integer colour buffers anyway, and which an application that
// wants a defined answer must replace with glClearBufferuiv/iv (those DO substitute
// the widened alpha). The out-of-range trigger is left exactly as it was.
const Uint32 widenedDrawBuffers = FramebufferImpl::g_alphaWidenedDrawBufferMask;
const Bool widenedColorClear =
widenedDrawBuffers != 0 && FramebufferImpl::g_integerColorDrawBufferMask == 0;
GLint clearDrawFbo = 0; GLint clearDrawFbo = 0;
g_GLESFuncs.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &clearDrawFbo); g_GLESFuncs.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &clearDrawFbo);
if (outOfRange && clearDrawFbo != 0) { if ((outOfRange || widenedColorClear) && clearDrawFbo != 0) {
const GLfloat value[4] = {cc.x(), cc.y(), cc.z(), cc.w()};
GLint maxDrawBuffers = 0; GLint maxDrawBuffers = 0;
GLint clearedCount = 0; GLint clearedCount = 0;
GLint firstDb = -1; GLint firstDb = -1;
@@ -2980,6 +3134,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_GLESFuncs.glGetIntegerv(GL_DRAW_BUFFER0 + static_cast<GLenum>(i), &db); g_GLESFuncs.glGetIntegerv(GL_DRAW_BUFFER0 + static_cast<GLenum>(i), &db);
if (i == 0) firstDb = db; if (i == 0) firstDb = db;
if (db != GL_NONE) { if (db != GL_NONE) {
const Bool widened = i < 32 && (widenedDrawBuffers & (1u << i)) != 0;
const GLfloat value[4] = {cc.x(), cc.y(), cc.z(), widened ? 1.0f : cc.w()};
g_GLESFuncs.glClearBufferfv(GL_COLOR, i, value); g_GLESFuncs.glClearBufferfv(GL_COLOR, i, value);
++clearedCount; ++clearedCount;
} }
@@ -5189,50 +5345,41 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_GLESFuncs.glGetProgramiv(backendProgramId, pname, params); g_GLESFuncs.glGetProgramiv(backendProgramId, pname, params);
} }
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params) { // NOTE the shape here, and do not "simplify" it back to GetBackendProgramId(): this entry
GLuint backendProgramId = GetBackendProgramId(program); // point must never be the thing that BUILDS a backend program.
if (!backendProgramId) return; //
g_GLESFuncs.glGetProgramInterfaceiv(backendProgramId, programInterface, pname, params); // The frontend has already recorded the rebinding on the program object
} // (SetShaderStorageBlockBinding) - that record is what GL_BUFFER_BINDING reports and what
// BackendProgramObjectImpl::SyncToBackend replays onto every driver program it builds. So
// the only work left here is an optimisation: push the change straight onto a driver
// program that is ALREADY built and already current with this link, so the next draw does
// not have to be preceded by a rebuild.
//
// Calling GetBackendProgramId() instead would sync-on-demand from a non-draw entry point,
// i.e. transpile and compile the whole program while the draw-path globals that the ESSL
// is generated against (PrgramImpl::g_fragColorBroadcastCount, the snorm/unorm clamp
// masks - established by SyncCurrentProgram) still hold another program's values. That
// bakes a program against the wrong state, and under CPU load it was also observed to
// fail the driver compile outright. Deferring is spec-fine: a binding only has to take
// effect by the block's next use.
void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding) {
if (!storageBlockName) return;
if (!MG_State::pGLContext->ValidateProgramName(program)) return;
auto& programObject = MG_State::pGLContext->GetProgramObject(program);
if (!programObject) return;
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name) { auto* backendProgramSlot = PrgramImpl::g_backendProgramObjects.Find(programObject.get());
GLuint backendProgramId = GetBackendProgramId(program); if (!backendProgramSlot || !*backendProgramSlot) return;
if (!backendProgramId) return GL_INVALID_INDEX; auto& backendObj = *backendProgramSlot;
return g_GLESFuncs.glGetProgramResourceIndex(backendProgramId, programInterface, name); // Not merely "a program id exists": a backend object whose synced link version has
// fallen behind is about to be rebuilt anyway, and its current driver interface is
// the PREVIOUS link's - applying to it could land the binding on an unrelated block.
if (!backendObj->GetBackendProgramId() ||
backendObj->GetSyncedLinkVersion() != programObject->GetLinkVersion()) {
return; // SyncToBackend's reseed will carry it
} }
PrgramImpl::ApplyShaderStorageBlockBinding(backendObj->GetBackendProgramId(), storageBlockName,
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei* length, storageBlockBinding);
GLchar* name) {
GLuint backendProgramId = GetBackendProgramId(program);
if (!backendProgramId) return;
g_GLESFuncs.glGetProgramResourceName(backendProgramId, programInterface, index, bufSize, length, name);
}
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
GLuint backendProgramId = GetBackendProgramId(program);
if (!backendProgramId) return;
g_GLESFuncs.glGetProgramResourceiv(backendProgramId, programInterface, index, propCount, props, bufSize, length,
params);
}
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name) {
GLuint backendProgramId = GetBackendProgramId(program);
if (!backendProgramId) return -1;
return g_GLESFuncs.glGetProgramResourceLocation(backendProgramId, programInterface, name);
}
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name) {
(void)program;
(void)programInterface;
(void)name;
return -1;
}
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding) {
GLuint backendProgramId = GetBackendProgramId(program);
if (!backendProgramId) return;
g_GLESFuncs.glShaderStorageBlockBinding(backendProgramId, storageBlockIndex, storageBlockBinding);
} }
void ClearBufferfi(GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil) { void ClearBufferfi(GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil) {
@@ -5245,37 +5392,82 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_GLESFuncs.glClearBufferfi(buffer, drawbuffer, depth, stencil); g_GLESFuncs.glClearBufferfi(buffer, drawbuffer, depth, stencil);
} }
namespace {
using FramebufferImpl::SubstituteWidenedClearAlpha;
// A colour attachment the backend widened from three channels to four has to end up
// holding alpha 1.0 - the value GL reports for a channel the application's format does
// not have - so an explicit per-buffer clear of it writes 1.0 rather than whatever the
// application passed (SubstituteWidenedClearAlpha, in Managers.h). Draws can never move
// it again: their alpha write mask is forced off, see SyncRenderState. That pairing is
// what makes GL_DST_ALPHA blending, glReadPixels and glBlitFramebuffer all see the right
// value without any of them being intercepted.
// Whether draw buffer `drawbuffer` of the framebuffer currently bound as DRAW is such an
// attachment. Answered from the mask SyncCurrentFBO just recomputed, so it costs nothing.
Bool IsWidenedBoundDrawBuffer(GLenum buffer, GLint drawbuffer) {
return buffer == GL_COLOR && drawbuffer >= 0 && drawbuffer < 32 &&
(FramebufferImpl::g_alphaWidenedDrawBufferMask & (1u << drawbuffer)) != 0;
}
// The same question for an explicitly named framebuffer (the DSA clears), which is NOT
// the one g_alphaWidenedDrawBufferMask describes at the point these run.
Bool IsWidenedNamedDrawBuffer(const SharedPtr<MG_State::GLState::FramebufferObject>& framebuffer,
GLenum buffer, GLint drawbuffer) {
using FBO = MG_State::GLState::FramebufferObject;
if (buffer != GL_COLOR || !framebuffer || drawbuffer < 0 ||
drawbuffer >= static_cast<GLint>(FBO::MAX_DRAW_BUFFERS)) {
return false;
}
const auto frontendBuf = framebuffer->GetDrawBuffers()[static_cast<SizeT>(drawbuffer)];
if (frontendBuf < FramebufferAttachmentType::Color0 ||
frontendBuf > FramebufferAttachmentType::Color31) {
return false;
}
return FramebufferImpl::IsAlphaWidenedColorAttachment(framebuffer->GetAttachment(frontendBuf));
}
} // namespace
void ClearBufferfv(GLenum buffer, GLint drawbuffer, const GLfloat* value) { void ClearBufferfv(GLenum buffer, GLint drawbuffer, const GLfloat* value) {
TextureImpl::SyncNeccessaryTextures(); TextureImpl::SyncNeccessaryTextures();
FramebufferImpl::SyncCurrentFBO(); FramebufferImpl::SyncCurrentFBO();
RenderStateImpl::SyncRenderState(); RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR);
BindCurrentFBO(FramebufferTarget::Draw); BindCurrentFBO(FramebufferTarget::Draw);
g_GLESFuncs.glClearBufferfv(buffer, drawbuffer, value); GLfloat widenedValue[4] = {};
g_GLESFuncs.glClearBufferfv(
buffer, drawbuffer,
SubstituteWidenedClearAlpha(value, IsWidenedBoundDrawBuffer(buffer, drawbuffer), 1.0f, widenedValue));
} }
void ClearBufferiv(GLenum buffer, GLint drawbuffer, const GLint* value) { void ClearBufferiv(GLenum buffer, GLint drawbuffer, const GLint* value) {
TextureImpl::SyncNeccessaryTextures(); TextureImpl::SyncNeccessaryTextures();
FramebufferImpl::SyncCurrentFBO(); FramebufferImpl::SyncCurrentFBO();
RenderStateImpl::SyncRenderState(); RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR);
// SyncCurrentFBO early-outs for the default framebuffer, so without this // SyncCurrentFBO early-outs for the default framebuffer, so without this
// bind a user-FBO -> default-FBO switch would leave the clear landing on // bind a user-FBO -> default-FBO switch would leave the clear landing on
// the stale driver DRAW binding (the fi/fv/uiv siblings all bind too). // the stale driver DRAW binding (the fi/fv/uiv siblings all bind too).
BindCurrentFBO(FramebufferTarget::Draw); BindCurrentFBO(FramebufferTarget::Draw);
g_GLESFuncs.glClearBufferiv(buffer, drawbuffer, value); GLint widenedValue[4] = {};
g_GLESFuncs.glClearBufferiv(
buffer, drawbuffer,
SubstituteWidenedClearAlpha(value, IsWidenedBoundDrawBuffer(buffer, drawbuffer), GLint(1), widenedValue));
} }
void ClearBufferuiv(GLenum buffer, GLint drawbuffer, const GLuint* value) { void ClearBufferuiv(GLenum buffer, GLint drawbuffer, const GLuint* value) {
TextureImpl::SyncNeccessaryTextures(); TextureImpl::SyncNeccessaryTextures();
FramebufferImpl::SyncCurrentFBO(); FramebufferImpl::SyncCurrentFBO();
RenderStateImpl::SyncRenderState(); RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR);
BindCurrentFBO(FramebufferTarget::Draw); BindCurrentFBO(FramebufferTarget::Draw);
g_GLESFuncs.glClearBufferuiv(buffer, drawbuffer, value); GLuint widenedValue[4] = {};
g_GLESFuncs.glClearBufferuiv(
buffer, drawbuffer,
SubstituteWidenedClearAlpha(value, IsWidenedBoundDrawBuffer(buffer, drawbuffer), GLuint(1), widenedValue));
} }
void ClearNamedFramebufferfv(const SharedPtr<MG_State::GLState::FramebufferObject>& framebuffer, void ClearNamedFramebufferfv(const SharedPtr<MG_State::GLState::FramebufferObject>& framebuffer,
@@ -5284,9 +5476,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
DebugImpl::OpenGLScopeMarker marker(__func__); DebugImpl::OpenGLScopeMarker marker(__func__);
#endif #endif
TextureImpl::SyncNeccessaryTextures(); TextureImpl::SyncNeccessaryTextures();
RenderStateImpl::SyncRenderState(); RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR);
SyncAndBindFramebufferObject(framebuffer, FramebufferTarget::Draw, true); SyncAndBindFramebufferObject(framebuffer, FramebufferTarget::Draw, true);
GLfloat widenedValue[4] = {};
value = SubstituteWidenedClearAlpha(value, IsWidenedNamedDrawBuffer(framebuffer, buffer, drawbuffer), 1.0f,
widenedValue);
g_GLESFuncs.glClearBufferfv(buffer, drawbuffer, value); g_GLESFuncs.glClearBufferfv(buffer, drawbuffer, value);
DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) { DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) {
MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str()); MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str());
@@ -5318,9 +5513,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
DebugImpl::OpenGLScopeMarker marker(__func__); DebugImpl::OpenGLScopeMarker marker(__func__);
#endif #endif
TextureImpl::SyncNeccessaryTextures(); TextureImpl::SyncNeccessaryTextures();
RenderStateImpl::SyncRenderState(); RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR);
SyncAndBindFramebufferObject(framebuffer, FramebufferTarget::Draw, true); SyncAndBindFramebufferObject(framebuffer, FramebufferTarget::Draw, true);
GLint widenedValue[4] = {};
value = SubstituteWidenedClearAlpha(value, IsWidenedNamedDrawBuffer(framebuffer, buffer, drawbuffer),
GLint(1), widenedValue);
g_GLESFuncs.glClearBufferiv(buffer, drawbuffer, value); g_GLESFuncs.glClearBufferiv(buffer, drawbuffer, value);
DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) { DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) {
MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str()); MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str());
@@ -5335,9 +5533,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
DebugImpl::OpenGLScopeMarker marker(__func__); DebugImpl::OpenGLScopeMarker marker(__func__);
#endif #endif
TextureImpl::SyncNeccessaryTextures(); TextureImpl::SyncNeccessaryTextures();
RenderStateImpl::SyncRenderState(); RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR);
SyncAndBindFramebufferObject(framebuffer, FramebufferTarget::Draw, true); SyncAndBindFramebufferObject(framebuffer, FramebufferTarget::Draw, true);
GLuint widenedValue[4] = {};
value = SubstituteWidenedClearAlpha(value, IsWidenedNamedDrawBuffer(framebuffer, buffer, drawbuffer),
GLuint(1), widenedValue);
g_GLESFuncs.glClearBufferuiv(buffer, drawbuffer, value); g_GLESFuncs.glClearBufferuiv(buffer, drawbuffer, value);
DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) { DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) {
MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str()); MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str());
@@ -5610,16 +5811,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
format == GL_RGBA_INTEGER; format == GL_RGBA_INTEGER;
} }
// Expands a tightly-packed narrow read (1-3 channels per texel) into the 4-channel wide RGBA // The bit pattern of 1.0 in a wide-read component type: what GL reports for a channel the
// layout ConvertWideReadbackRow expects. Missing G/B read zero; missing A reads one, encoded in // attachment's format does not have.
// the source component type. static void FillWideReadOneBits(GLenum componentType, Uint8* oneBits) {
static void ExpandNarrowWideRead(Vector<Uint8>& data, SizeT pixelCount, Int srcChannels, GLenum componentType) {
const SizeT componentSize = GetReadbackComponentSize(componentType);
if (componentSize == 0 || srcChannels <= 0 || srcChannels >= 4) {
return;
}
Uint8 zeroBits[4] = {0, 0, 0, 0};
Uint8 oneBits[4] = {0, 0, 0, 0};
switch (componentType) { switch (componentType) {
case GL_UNSIGNED_BYTE: case GL_UNSIGNED_BYTE:
oneBits[0] = 0xFF; oneBits[0] = 0xFF;
@@ -5656,6 +5850,35 @@ namespace MobileGL::MG_Backend::DirectGLES {
default: default:
break; break;
} }
}
// Overwrites the alpha of a 4-channel wide read with the format's implied 1.0. Used for an
// attachment the backend widened from three channels to keep it colour-renderable: the storage
// has a real alpha channel holding whatever the draw wrote, but the format the application
// asked for has none, and GL reads a missing channel back as one.
static void ForceWideReadAlphaToOne(Vector<Uint8>& data, SizeT pixelCount, GLenum componentType) {
const SizeT componentSize = GetReadbackComponentSize(componentType);
if (componentSize == 0 || data.size() < pixelCount * 4 * componentSize) {
return;
}
Uint8 oneBits[4] = {0, 0, 0, 0};
FillWideReadOneBits(componentType, oneBits);
for (SizeT i = 0; i < pixelCount; ++i) {
Memcpy(data.data() + (i * 4 + 3) * componentSize, oneBits, componentSize);
}
}
// Expands a tightly-packed narrow read (1-3 channels per texel) into the 4-channel wide RGBA
// layout ConvertWideReadbackRow expects. Missing G/B read zero; missing A reads one, encoded in
// the source component type.
static void ExpandNarrowWideRead(Vector<Uint8>& data, SizeT pixelCount, Int srcChannels, GLenum componentType) {
const SizeT componentSize = GetReadbackComponentSize(componentType);
if (componentSize == 0 || srcChannels <= 0 || srcChannels >= 4) {
return;
}
Uint8 zeroBits[4] = {0, 0, 0, 0};
Uint8 oneBits[4] = {0, 0, 0, 0};
FillWideReadOneBits(componentType, oneBits);
Vector<Uint8> expanded(pixelCount * 4 * componentSize); Vector<Uint8> expanded(pixelCount * 4 * componentSize);
for (SizeT i = 0; i < pixelCount; ++i) { for (SizeT i = 0; i < pixelCount; ++i) {
@@ -5700,9 +5923,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Reads the current READ framebuffer as wide RGBA(_INTEGER) and repacks the pixels into the client's // Reads the current READ framebuffer as wide RGBA(_INTEGER) and repacks the pixels into the client's
// (format, type) layout. Returns false when the combination is not convertible (the caller keeps its // (format, type) layout. Returns false when the combination is not convertible (the caller keeps its
// "not implemented" skip); returns true when the request was handled, even if it degraded to a logged no-op. // "not implemented" skip); returns true when the request was handled, even if it degraded to a logged no-op.
// `forceOpaqueAlpha`: the source image is a three-channel format the backend widened to four to
// keep it colour-renderable, so its alpha channel holds whatever the draw wrote and has to be
// answered with the 1.0 the application's format implies. Passed in rather than derived here:
// glReadPixels reads the bound READ framebuffer, but glGetTexImage reads a texture through a
// scratch framebuffer, so the frontend's READ binding describes a different image entirely -
// consulting it there would both miss real widenings and corrupt readbacks of ordinary
// textures taken while some unrelated widened attachment happened to be bound.
static Bool ReadPixelsViaFormatConversion(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, static Bool ReadPixelsViaFormatConversion(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format,
GLenum type, void* pixels, Bool honorPackImageParams = false, GLenum type, void* pixels, Bool honorPackImageParams,
Bool applyFixedPointReadClamp = true) { Bool applyFixedPointReadClamp, Bool forceOpaqueAlpha) {
ReadbackChannelMapping mapping{}; ReadbackChannelMapping mapping{};
if (!GetReadbackChannelMapping(format, mapping)) { if (!GetReadbackChannelMapping(format, mapping)) {
return false; return false;
@@ -5829,6 +6059,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
ExpandNarrowWideRead(wide, static_cast<SizeT>(width) * static_cast<SizeT>(height), readChannels, wideType); ExpandNarrowWideRead(wide, static_cast<SizeT>(width) * static_cast<SizeT>(height), readChannels, wideType);
} }
// Undo the three-channel widening (see the parameter's comment). Deliberately not gated on
// applyFixedPointReadClamp: that flag implements GL_CLAMP_READ_COLOR, which glGetTexImage
// is exempt from, whereas "a format without alpha reads as 1.0" is the format's own
// semantics and applies to every read.
if (forceOpaqueAlpha) {
ForceWideReadAlphaToOne(wide, static_cast<SizeT>(width) * static_cast<SizeT>(height), wideType);
}
// GL clamps a read from a fixed-point colour buffer to [0,1] (GL_CLAMP_READ_COLOR // GL clamps a read from a fixed-point colour buffer to [0,1] (GL_CLAMP_READ_COLOR
// defaults to GL_FIXED_ONLY). Formats the backend substitutes with a floating-point // defaults to GL_FIXED_ONLY). Formats the backend substitutes with a floating-point
// one keep the out-of-range value the app stored, so apply the clamp here - a // one keep the out-of-range value the app stored, so apply the clamp here - a
@@ -5987,11 +6225,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the driver accepts for the current attachment. GL_PACK_SWAP_BYTES has no ES equivalent, so // the driver accepts for the current attachment. GL_PACK_SWAP_BYTES has no ES equivalent, so
// it always takes the conversion path (which swaps on the CPU). // it always takes the conversion path (which swaps on the CPU).
const Bool packSwapBytes = MG_State::pGLContext->GetPixelStoreParameters(false).SwapBytes; const Bool packSwapBytes = MG_State::pGLContext->GetPixelStoreParameters(false).SwapBytes;
const Bool nativeFastPair = !packSwapBytes && // The read buffer is what glReadPixels reads, so the frontend's READ binding is exactly
// the right thing to ask here.
const Bool forceOpaqueAlpha = FramebufferImpl::IsAlphaWidenedFallbackReadAttachment();
// An attachment widened from three channels to stay colour-renderable also has to leave
// the fast pair: only the conversion path knows to answer its alpha with the 1.0 the
// application's format implies instead of whatever the draw wrote into the added channel.
const Bool nativeFastPair = !packSwapBytes && !forceOpaqueAlpha &&
((format == GL_RGBA && type == GL_UNSIGNED_BYTE) || ((format == GL_RGBA && type == GL_UNSIGNED_BYTE) ||
(format == GL_RGBA_INTEGER && (type == GL_UNSIGNED_INT || type == GL_INT))); (format == GL_RGBA_INTEGER && (type == GL_UNSIGNED_INT || type == GL_INT)));
if (convertible && !nativeFastPair) { if (convertible && !nativeFastPair) {
if (ReadPixelsViaFormatConversion(x, y, width, height, format, type, pixels)) { if (ReadPixelsViaFormatConversion(x, y, width, height, format, type, pixels,
/*honorPackImageParams=*/false, /*applyFixedPointReadClamp=*/true,
forceOpaqueAlpha)) {
MGLOG_D("ReadPixels: finished via client-format conversion"); MGLOG_D("ReadPixels: finished via client-format conversion");
return; return;
} }
@@ -6050,7 +6296,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
MGLOG_D("ReadPixels: native read of %s/%s failed (%s), retrying via client-format conversion", MGLOG_D("ReadPixels: native read of %s/%s failed (%s), retrying via client-format conversion",
MG_Util::ConvertGLEnumToString(format).c_str(), MG_Util::ConvertGLEnumToString(type).c_str(), MG_Util::ConvertGLEnumToString(format).c_str(), MG_Util::ConvertGLEnumToString(type).c_str(),
MG_Util::ConvertGLEnumToString(nativeReadError).c_str()); MG_Util::ConvertGLEnumToString(nativeReadError).c_str());
if (ReadPixelsViaFormatConversion(x, y, width, height, format, type, pixels)) { if (ReadPixelsViaFormatConversion(x, y, width, height, format, type, pixels,
/*honorPackImageParams=*/false, /*applyFixedPointReadClamp=*/true,
forceOpaqueAlpha)) {
MGLOG_D("ReadPixels: finished via client-format conversion after native failure"); MGLOG_D("ReadPixels: finished via client-format conversion after native failure");
return; return;
} }
@@ -6231,6 +6479,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
// for normalized attachments), while the conversion path reads a wide format that is always // for normalized attachments), while the conversion path reads a wide format that is always
// accepted and repacks on the CPU. // accepted and repacks on the CPU.
if (convertible) { if (convertible) {
// The image being read is this texture, not whatever the application left bound to
// GL_READ_FRAMEBUFFER, so the widening question has to be asked of the texture.
const Bool forceOpaqueAlpha =
TextureImpl::BackendTextureFormatAddsAlpha(textureObject->GetFormat(), textureObject->GetTarget());
// GL_PACK_IMAGE_HEIGHT/GL_PACK_SKIP_IMAGES only apply to 3D/array image // GL_PACK_IMAGE_HEIGHT/GL_PACK_SKIP_IMAGES only apply to 3D/array image
// readbacks (cube-map arrays address as arrays); 2D targets must ignore // readbacks (cube-map arrays address as arrays); 2D targets must ignore
// them (GL 3.3 section 6.1.4). // them (GL 3.3 section 6.1.4).
@@ -6276,7 +6528,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
void* sliceDst = static_cast<Uint8*>(pixels) + sliceOffset; void* sliceDst = static_cast<Uint8*>(pixels) + sliceOffset;
if (!ReadPixelsViaFormatConversion(0, 0, size.x(), size.y(), format, type, sliceDst, if (!ReadPixelsViaFormatConversion(0, 0, size.x(), size.y(), format, type, sliceDst,
/*honorPackImageParams=*/false, /*honorPackImageParams=*/false,
/*applyFixedPointReadClamp=*/false)) { /*applyFixedPointReadClamp=*/false, forceOpaqueAlpha)) {
allSlicesRead = false; allSlicesRead = false;
break; break;
} }
@@ -6298,7 +6550,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
if (tempFBOComplete && ReadPixelsViaFormatConversion(0, 0, size.x(), size.y(), format, type, pixels, if (tempFBOComplete && ReadPixelsViaFormatConversion(0, 0, size.x(), size.y(), format, type, pixels,
applyPackImageParams, applyPackImageParams,
/*applyFixedPointReadClamp=*/false)) { /*applyFixedPointReadClamp=*/false,
forceOpaqueAlpha)) {
MGLOG_D("GetTexImage: finished via client-format conversion"); MGLOG_D("GetTexImage: finished via client-format conversion");
return; return;
} }
@@ -6778,6 +7031,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
BufferImpl::InvalidatePixelBufferBindingCaches(); BufferImpl::InvalidatePixelBufferBindingCaches();
FramebufferImpl::InvalidateFramebufferBindingCache(); FramebufferImpl::InvalidateFramebufferBindingCache();
PixelStoreImpl::InvalidatePackStateCache(); PixelStoreImpl::InvalidatePackStateCache();
// The render-state shadow belongs in this list for the same reason as the ones above:
// it describes the real ES context, which outlives the MobileGL context that is
// becoming current. See InvalidateSyncedRenderState.
RenderStateImpl::InvalidateSyncedRenderState();
// eglSwapInterval requires a current context; a request made while none was // eglSwapInterval requires a current context; a request made while none was
// current (and dropped by the driver) is retried here. // current (and dropped by the driver) is retried here.
ApplyRequestedSwapInterval(); ApplyRequestedSwapInterval();
+21 -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();
@@ -196,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;
+100
View File
@@ -515,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;
@@ -630,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;
@@ -710,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
@@ -981,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 {
+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.
@@ -50,6 +50,9 @@ add_executable(MobileGLIntegrationTest
Scenarios/CrossFrameBufferScenario.cpp Scenarios/CrossFrameBufferScenario.cpp
Scenarios/ResidentIndexScenario.cpp Scenarios/ResidentIndexScenario.cpp
Scenarios/MultiDrawScenario.cpp Scenarios/MultiDrawScenario.cpp
Scenarios/AsyncCompileScenario.cpp
Scenarios/XfbAfterClipDistanceScenario.cpp
Scenarios/ThreeChannelAttachmentScenario.cpp
) )
target_include_directories(MobileGLIntegrationTest PRIVATE target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -216,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)
@@ -242,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,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
+41 -1
View File
@@ -10,6 +10,7 @@
#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_Backend/BackendObjects.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h> #include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <Config.h> #include <Config.h>
@@ -40,6 +41,13 @@ namespace MobileGL::MG_State {
// 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));
} }
@@ -349,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);
} }
@@ -360,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;
@@ -392,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);
} }
+8
View File
@@ -153,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
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).
@@ -69,6 +82,14 @@ namespace MobileGL::MG_State::GLState {
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;
@@ -117,6 +138,14 @@ namespace MobileGL::MG_State::GLState {
if (tIndex < 0 || tIndex >= static_cast<Int>(Artifacts().tProgramUniformIndexToGl.size())) return -1; if (tIndex < 0 || tIndex >= static_cast<Int>(Artifacts().tProgramUniformIndexToGl.size())) return -1;
return Artifacts().tProgramUniformIndexToGl[tIndex]; 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 { Int GlBlockIndexFromTProgram(Int tBlockIndex) const {
if (tBlockIndex < 0 || tBlockIndex >= static_cast<Int>(Artifacts().tProgramBlockIndexToGl.size())) return -1; if (tBlockIndex < 0 || tBlockIndex >= static_cast<Int>(Artifacts().tProgramBlockIndexToGl.size())) return -1;
return Artifacts().tProgramBlockIndexToGl[tBlockIndex]; return Artifacts().tProgramBlockIndexToGl[tBlockIndex];
@@ -151,14 +180,7 @@ namespace MobileGL::MG_State::GLState {
: -1; : -1;
} }
Bool IsValidUniformLocation(Int location) const { Bool IsValidUniformLocation(Int location) const { return IsValidUniformLocation(Artifacts(), 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());
}
GLenum GetUniformType(Uint location) const { GLenum GetUniformType(Uint location) const {
auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]); auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]);
@@ -176,12 +198,7 @@ namespace MobileGL::MG_State::GLState {
// for both. GL 3.3 core uniforms are always sized. Takes a TProgram uniform index (the space // for both. GL 3.3 core uniforms are always sized. Takes a TProgram uniform index (the space
// the artifacts' uniformIndexInTProgram stores). // the artifacts' uniformIndexInTProgram stores).
GLint GetUniformArraySizeByTIndex(Int tIndex) const { GLint GetUniformArraySizeByTIndex(Int tIndex) const {
const auto& uniform = Artifacts().program->getUniform(tIndex); return GetUniformArraySizeByTIndex(Artifacts(), tIndex);
const glslang::TType* type = uniform.getType();
if (type != nullptr && type->isSizedArray()) {
return type->getOuterArraySize();
}
return uniform.size < 1 ? 1 : uniform.size;
} }
GLint GetActiveUniformArraySize(Uint index) const { GLint GetActiveUniformArraySize(Uint index) const {
@@ -371,7 +388,7 @@ namespace MobileGL::MG_State::GLState {
// 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; }
@@ -439,8 +456,13 @@ 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() {
// Before anything reads m_artifacts: a pending link would otherwise publish its
// (possibly successful) result over the failure this call is required to install
// - and Artifacts() below would be the thing that let it. Cancel-not-join: GL
// gives glProgramBinary no reason to wait for a link it is about to invalidate.
CancelLink();
BumpLinkObservableVersions(); BumpLinkObservableVersions();
ResetLinkArtifacts(); ResetLinkArtifacts(Artifacts());
Artifacts().infoLog = "No program binary format is supported."; Artifacts().infoLog = "No program binary format is supported.";
} }
Bool GetValidateStatus() const { return m_validateStatus; } Bool GetValidateStatus() const { return m_validateStatus; }
@@ -523,9 +545,42 @@ namespace MobileGL::MG_State::GLState {
Uint GetUniformBlockBinding(Uint index) const { return Artifacts().uniformBlockBinding[index]; } Uint GetUniformBlockBinding(Uint index) const { return Artifacts().uniformBlockBinding[index]; }
// Set by glShaderStorageBlockBinding, keyed by the block's GL name rather than by any
// index. A shader storage block has THREE index spaces - the frontend interface-query
// enumeration, DirectVulkan's SPIR-V descriptor order and DirectGLES's real-driver
// order - and the name is the only coordinate all three agree on. Absent from the map
// means "never rebound", and the shader's declared binding still stands.
void SetShaderStorageBlockBinding(const String& blockName, Uint binding) {
Artifacts().shaderStorageBlockBinding[blockName] = static_cast<Int>(binding);
}
// -1 when the block has never been rebound. `blockName` is the interface-query
// spelling; an arrayed block's elements ("B[0]", "B[1]") are separate GL resources
// with separate bindings, so they are separate keys.
Int GetShaderStorageBlockBindingOverride(const String& blockName) const {
const auto it = Artifacts().shaderStorageBlockBinding.find(blockName);
if (it != Artifacts().shaderStorageBlockBinding.end()) return it->second;
// A backend that collapses an arrayed block down to one resource knows it only by
// the bare block name; answer that with element zero's binding.
const auto zeroth = Artifacts().shaderStorageBlockBinding.find(blockName + "[0]");
return zeroth != Artifacts().shaderStorageBlockBinding.end() ? zeroth->second : -1;
}
// Every rebinding recorded so far, for a backend that has to REPLAY them onto a
// driver program it just (re)built. Empty for the overwhelming majority of programs -
// check .empty() before doing any per-block work.
const UnorderedMap<String, Int>& GetShaderStorageBlockBindingOverrides() const {
return Artifacts().shaderStorageBlockBinding;
}
Vector<Vector<unsigned>>& GetGeneratedSpirv() { return Artifacts().generatedSpirv; } Vector<Vector<unsigned>>& GetGeneratedSpirv() { return Artifacts().generatedSpirv; }
const Vector<Vector<unsigned>>& GetGeneratedSpirv() const { 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) {
return shader->GetShaderStage() == stage; return shader->GetShaderStage() == stage;
@@ -605,6 +660,9 @@ namespace MobileGL::MG_State::GLState {
// 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> uniformBlockIndexByName; UnorderedMap<String, Uint> uniformBlockIndexByName;
Vector<Int> 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> uniformOffsets; Vector<Uint> uniformOffsets;
@@ -623,6 +681,12 @@ namespace MobileGL::MG_State::GLState {
// Transform feedback: the linked snapshot (the request lives outside, on the // Transform feedback: the linked snapshot (the request lives outside, on the
// GL-thread-owned side). // GL-thread-owned side).
Vector<XfbVarying> xfbVaryings; 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> xfbStrides;
Vector<Uint32> gsStripTriangles; Vector<Uint32> gsStripTriangles;
Bool gsStripCaptureFixup = false; Bool gsStripCaptureFixup = false;
@@ -633,12 +697,68 @@ namespace MobileGL::MG_State::GLState {
Uint32 xfbPackedStride = 0; Uint32 xfbPackedStride = 0;
}; };
// Blocks until a pending link (P1 stage 4 onwards) has published its artifacts. // ---- artifacts-only helpers, shared with ProgramLinkTask ----
// Public because a few call sites have to join without reading anything - see the // Static and taking the block explicitly, because from stage 4 the link BODY needs
// explicit-join list in the P1 design. Today there is never a pending link, so this // them while its artifacts still live on the job node, not on any ProgramObject. The
// is a no-op; it is wired up when glLinkProgram starts enqueueing. // 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(); } 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) { void SetTransformFeedbackVaryings(Vector<String>&& names, GLenum bufferMode) {
m_requestedXfbVaryings = Move(names); m_requestedXfbVaryings = Move(names);
m_requestedXfbBufferMode = bufferMode; m_requestedXfbBufferMode = bufferMode;
@@ -649,6 +769,9 @@ namespace MobileGL::MG_State::GLState {
return index < Artifacts().xfbVaryings.size() ? &Artifacts().xfbVaryings[index] : nullptr; return index < Artifacts().xfbVaryings.size() ? &Artifacts().xfbVaryings[index] : nullptr;
} }
const Vector<XfbVarying>& GetTransformFeedbackVaryings() const { return Artifacts().xfbVaryings; } 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. // Stride of one captured vertex in the given capture buffer slot.
Uint32 GetTransformFeedbackStride(Uint32 bufferIndex) const { Uint32 GetTransformFeedbackStride(Uint32 bufferIndex) const {
return bufferIndex < Artifacts().xfbStrides.size() ? Artifacts().xfbStrides[bufferIndex] : 0; return bufferIndex < Artifacts().xfbStrides.size() ? Artifacts().xfbStrides[bufferIndex] : 0;
@@ -685,19 +808,22 @@ namespace MobileGL::MG_State::GLState {
private: private:
// ---- The one and only join gate for link output (P1 invariant I5) ---- // ---- The one and only join gate for link output (P1 invariant I5) ----
// Blocks until a pending link has finished and its LinkArtifacts have been // Blocks until a pending link has finished and its LinkArtifacts have been
// published into m_artifacts. Today no link is ever pending - glLinkProgram still // published into m_artifacts. It exists so that the ~120 readers of link output are
// runs the whole body inline - so this is an unconditional no-op, and the whole // routed through it by the compiler rather than by review: m_artifacts is private
// Artifacts() indirection compiles away. It exists NOW so that the ~120 readers of // and Artifacts() is the only spelling that reaches it.
// link output are already routed through it when stage 4 makes it block: the edit
// that turns links asynchronous then touches this function and nothing else.
// //
// Defined inline (not in ProgramObject.cpp) on purpose: this is called from every // The fast path - no pending link - is one predictable branch and stays inline: it
// Artifacts() read - ~1200 call sites project-wide - and the project never builds // runs on every Artifacts() read (~1200 call sites project-wide) and the project
// with LTO (MOBILEGL_ENABLE_LTO=OFF), so an out-of-line empty body would leave a // 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 instead of folding away. Stage 4's // real cross-TU call at every one of them. The blocking half is out of line.
// version, which actually blocks, moves the wait itself out-of-line behind a void EnsureLinkJoined() const {
// `m_pendingLink` check that stays inline here. if (m_pendingLink) JoinPendingLink();
void EnsureLinkJoined() const {} }
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() { LinkArtifacts& Artifacts() {
EnsureLinkJoined(); EnsureLinkJoined();
return m_artifacts; return m_artifacts;
@@ -707,28 +833,10 @@ namespace MobileGL::MG_State::GLState {
return m_artifacts; return m_artifacts;
} }
void ResetLinkArtifacts(); // GL-thread-only companion to ResetLinkArtifacts (see its definition). Const because
// GL-thread-only companion to ResetLinkArtifacts (see its definition). // the publish half of the join calls it; see the mutable counters below.
void BumpLinkObservableVersions(); void BumpLinkObservableVersions() const;
// Builds the GL-facing reflection surface from the linked TProgram. Returns false
// (with the artifacts' infoLog set and link artifacts reset) when reflection itself fails or an
// explicit-uniform-location conflict makes the link invalid.
Bool DoReflection(const MG_Util::ShaderTranspiler::CompileEnv& env);
// Resolves the requested transform feedback varyings against the linked
// vertex stage; fails the link (GL semantics) on unknown or duplicate
// names or exceeded capture limits.
Bool ResolveTransformFeedbackVaryings();
void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate);
// The former GenerateBinary, split around DoReflection's data dependencies:
// SPIR-V must be generated BEFORE buildReflection touches the linked TProgram (its
// live-variable analysis mutates the intermediates enough to change
// GlslangToSpv output), while the glUniform*-to-global-UBO routing tables are
// sized and keyed by reflection results (maxUniformLocation, uniformLocations)
// and so must run AFTER it.
void GenerateSpirv();
void BuildGlobalUboRouting();
void AddDefaultFragmentShaderIfMissing(); void AddDefaultFragmentShaderIfMissing();
Bool ValidateFragmentOutputLocations();
static Uint64 AllocateLifetimeId(); static Uint64 AllocateLifetimeId();
@@ -762,7 +870,10 @@ namespace MobileGL::MG_State::GLState {
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
@@ -778,12 +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;
// ---- Link OUTPUT ---- // ---- Link OUTPUT ----
// Written by the link and by the post-link setters GL allows (glUniform1i's sampler // Written by the link and by the post-link setters GL allows (glUniform1i's sampler
// unit, glUniformBlockBinding). Reachable only through Artifacts(); see LinkArtifacts. // unit, glUniformBlockBinding). Reachable only through Artifacts(); see LinkArtifacts.
LinkArtifacts m_artifacts; //
// Mutable because publishing is a READ-side operation: a const getter has to be able
// to settle an outstanding link before answering it.
mutable LinkArtifacts m_artifacts;
// The link job, from enqueue until the first observable read pulls its result. Null
// means m_artifacts is already the answer - which is the state every reader outside
// the pending window sees, and the whole reason the gate above is one branch.
mutable SharedPtr<ProgramLinkTask> m_pendingLink;
}; };
} // namespace MobileGL::MG_State::GLState } // namespace MobileGL::MG_State::GLState
@@ -39,6 +39,14 @@ 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();
@@ -79,7 +87,8 @@ namespace MobileGL::MG_State::GLState {
Uint shaderId = 0; Uint shaderId = 0;
m_programShaderNameGenerator.Generate(1, &shaderId); m_programShaderNameGenerator.Generate(1, &shaderId);
EnsureIndexAvail(shaderId, m_shaderObjects); EnsureIndexAvail(shaderId, m_shaderObjects);
auto shaderObject = MakeShared<ShaderObject>(stage, shaderId, m_shaderPreprocessCache); 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,6 +154,13 @@ 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_programShaderNameGenerator.Delete(shader); m_programShaderNameGenerator.Delete(shader);
} }
@@ -10,6 +10,7 @@
#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" #include "ShaderPreprocessCache.h"
namespace MobileGL::MG_State::GLState { namespace MobileGL::MG_State::GLState {
@@ -34,11 +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 // 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 // directly - shader objects reach it through the pointer they are handed at
// CreateShader(). // CreateShader().
ShaderPreprocessCache& GetShaderPreprocessCache() { return *m_shaderPreprocessCache; } 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
@@ -69,6 +88,11 @@ namespace MobileGL::MG_State::GLState {
// in-flight compile job may outlive the context). The FIRST-member declaration is // in-flight compile job may outlive the context). The FIRST-member declaration is
// kept anyway - it costs nothing and documents the intent. // kept anyway - it costs nothing and documents the intent.
SharedPtr<ShaderPreprocessCache> m_shaderPreprocessCache = MakeShared<ShaderPreprocessCache>(); 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<ProgramObject>> m_programObjects;
Vector<SharedPtr<ShaderObject>> m_shaderObjects; Vector<SharedPtr<ShaderObject>> m_shaderObjects;
@@ -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
@@ -8,338 +8,207 @@
#include "ShaderObject.h" #include "ShaderObject.h"
#include "ShaderPreprocessCache.h" #include "ShaderPreprocessCache.h"
#include <MG_Util/ShaderTranspiler/Types.h> #include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.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/CompileEnv.h>
#include <MG_Util/ShaderTranspiler/glslang/UniformTraverser.h> #include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/Types.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 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 ShaderObject::Compile() that depends on nothing but the source text and
// the stage: 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 former caveat is gone: 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 MobileGL::MG_State::GLState { namespace MobileGL::MG_State::GLState {
void ShaderObject::SetShaderSource(const String& source) { void ShaderObject::SetShaderSource(const String& source) {
// P0b layer 1. glShaderSource always REPLACES the source, but replacing it with a // P0b layer 1. glShaderSource always REPLACES the source, but replacing it with a
// byte-identical one cannot change what a compile would produce: the whole // byte-identical one cannot change what a compile would produce: the whole
// pipeline below (preprocess -> lexical checks -> glslang parse) is a pure // pipeline (preprocess -> lexical checks -> glslang parse) is a pure function of
// function of (stage, source) plus context-lifetime backend limits. So keeping the // (stage, source, CompileEnv). So keeping the compiled state is not an optimization
// compiled state is not an optimization that changes observable behaviour - the // that changes observable behaviour - the COMPILE_STATUS, the info log and the
// COMPILE_STATUS, the info log and the reflection a caller can query are exactly // reflection a caller can query are exactly what a real recompile would have
// what a real recompile would have rebuilt, byte for byte. // 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; if (SourceMatchesCompiledState(source)) return;
m_source = source; // 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(); InvalidateCompiledState();
} }
void ShaderObject::SetShaderSource(String&& source) { void ShaderObject::SetShaderSource(String&& source) {
if (SourceMatchesCompiledState(source)) return; if (SourceMatchesCompiledState(source)) return;
m_source = Move(source); ReleaseCompileNode();
m_source = MakeShared<const String>(Move(source));
InvalidateCompiledState(); InvalidateCompiledState();
} }
Bool ShaderObject::SourceMatchesCompiledState(const String& candidate) const { Bool ShaderObject::SourceMatchesCompiledState(const String& candidate) const {
if (!m_hasCompiledState) return false; // The memo is armed exactly while a job exists that was built from the string this
if (candidate.length() != m_compiledSourceLength) return false; // object still points at - pending or finished, success or failure.
if (ShaderPreprocessCache::HashSource(candidate) != m_compiledSourceHash) return false; if (!HasMemoizedCompile()) return false;
// The hash is a fast reject only; confirm against the actual stored text. While if (candidate.length() != m_source->length()) return false;
// m_hasCompiledState holds, m_source IS the source that produced the state. // Never let correctness ride on a hash: the answer is the full text comparison.
return candidate == m_source; // (The stored hash on the node is a cache-lookup accelerator, not a substitute.)
return candidate == *m_source;
} }
void ShaderObject::RememberCompiledSource(const Uint64 sourceHash) { void ShaderObject::JoinPendingCompile() const {
m_hasCompiledState = true; MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(),
m_compiledSourceHash = sourceHash; "ShaderObject::EnsureCompileJoined() reached from a pool thread; a job body must never read "
m_compiledSourceLength = m_source.length(); "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();
} }
// EnsureCompileJoined() is defined inline in ShaderObject.h (see the comment there for void ShaderObject::AdoptCompileNode(SharedPtr<ShaderCompileTask> node) const {
// why: no LTO, and it is called from every Compiled() read). // 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() { void ShaderObject::InvalidateCompiledState() {
// The compile artifacts are exactly what one Compile() writes, so discarding them // The job node holds exactly what one Compile() produces, so discarding it IS the
// wholesale IS the invalidation. (Kept as an explicit reset rather than a // invalidation - and it re-arms nothing, so the next Compile() genuinely recompiles.
// default-construct so the intent survives a future field addition.) DropCompileNode();
Compiled() = CompileArtifacts{}; }
m_hasCompiledState = false;
m_compiledSourceHash = 0; void ShaderObject::ReleaseCompileNode() {
m_compiledSourceLength = 0; 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
// the string m_source still points at". SetShaderSource only swaps that pointer when
// P0b layer 1: the state this object holds was produced by a previous Compile() of // the text actually differs, so this is a pointer compare, and it covers Pending as
// the exact source it still holds, so a recompile is a no-op. This covers the // well as Complete - a second glCompileShader on an in-flight object is a no-op, not
// failure case too - the info log stays queryable because nothing is cleared. // a duplicate job racing to write the same fields.
// //
// shaderConsumedByLink interaction: if the stored TShader already fed a link, // The failure case is covered too: the info log stays queryable because nothing is
// the no-op leaves preprocessedSource and both side-channel maps intact, which // cleared. And if the stored TShader already fed a link, the no-op leaves
// is precisely what TakeShaderForLink's on-demand re-parse needs. A real recompile // preprocessedSource and both side-channel maps intact, which is precisely what
// would have handed the next link a fresh parse; the no-op hands it a fresh // ClaimParsedShader's on-demand re-parse needs - a real recompile would have handed
// re-parse of the identical source instead. Same result, one parse either way. // the next link a fresh parse, the no-op hands it a fresh re-parse of the identical
if (m_hasCompiledState) return; // source instead. Same result, one parse either way.
if (HasMemoizedCompile()) return;
InvalidateCompiledState(); // Two reasons to stay on this thread, one rule. Without the async flag the whole
// path must be byte-identical to the synchronous implementation, and a cache-less
// object is an internal shader that compiles and reads its status in the same
// breath (see the constructor comment) - a job would only add a round trip.
// AsyncShaderCompileActive(), not ...Enabled(): a glMaxShaderCompilerThreadsKHR(0)
// has to put compilation back on this thread even though the extension is still
// advertised, and that is exactly what makes the GL_COMPLETION_STATUS_KHR the
// extension mandates after a zero count (immediately GL_TRUE) fall out for free.
//
// Hoisted above the node construction because stage 6 keys off it too: this same
// answer decides whether the adoption map is consulted at all, so a
// glMaxShaderCompilerThreadsKHR(0) and a flag-off build both bypass sharing exactly
// as they bypass the pool, and their behaviour stays byte-identical to pre-stage-6.
const Bool runOnPool = m_preprocessCache && MG_Util::Async::AsyncShaderCompileActive();
const Uint64 sourceHash = ShaderPreprocessCache::HashSource(m_source); // 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);
// The compile-environment snapshot, taken here on the GL thread. Everything below // ---- P1 stage 6: adopt an equivalent compile instead of enqueueing a duplicate ----
// reads the device through it and never through pActiveBackendObject, which is what // ~21% of all glCompileShader calls in the shaderpack corpus are a DIFFERENT shader
// makes the whole body movable onto a worker in stage 3. // object handed byte-identical source. P0b's memo only pays off once one of them has
CompileArtifacts& compiled = Compiled(); // finished; under async they are all enqueued in the same burst, so without this each
compiled.env = MG_Util::ShaderTranspiler::GetCurrentCompileEnv(); // one runs the whole pipeline on its own worker. The map hands back the node the
const MG_Util::ShaderTranspiler::CompileEnv& env = *compiled.env; // first of them created - in flight or already complete - and this object simply
// holds it too.
// P0b layer 2: another shader object in this context may already have run the if (runOnPool && m_adoptionMap) {
// source-only half over byte-identical text under the same environment. if (SharedPtr<ShaderCompileTask> shared =
ShaderPreprocessResultPtr cached = m_adoptionMap->FindAdoptable(m_stage, sourceHash, *m_source, env->fingerprint)) {
m_preprocessCache ? m_preprocessCache->Find(m_stage, sourceHash, m_source, env.fingerprint) : nullptr; // Take the node's own source snapshot as ours. FindAdoptable just compared
SharedPtr<ShaderPreprocessResult> fresh; // the two strings in full, so this changes nothing observable - but it is not
if (!cached) fresh = MakeShared<ShaderPreprocessResult>(RunSourceOnlyPipeline(m_stage, m_source, env)); // optional: the layer-1 memo (HasMemoizedCompile) is a POINTER comparison
const ShaderPreprocessResult& shared = cached ? *cached : *fresh; // against the node's snapshot, so leaving our own equal-but-distinct copy in
const Bool shouldPopulateCache = !cached && m_preprocessCache != nullptr; // 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
if (!shared.Preprocessed()) { // would make an identical glShaderSource re-source cancel a shared compile.
// Rejected lexically, or a glslang failure this context has already seen for // It also collapses N copies of a ~100 KB shaderpack stage into one.
// this exact source (ParseFailed) - either way the parse can be skipped. m_source = shared->source;
compiled.infoLog = shared.infoLog; AdoptCompileNode(Move(shared));
if (shouldPopulateCache) {
m_preprocessCache->Insert(m_stage, sourceHash, m_source, env.fingerprint, Move(fresh));
}
RememberCompiledSource(sourceHash);
return; return;
} }
ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(m_stage),
.sourceStr = shared.preprocessedSource,
.flags = 0,
.env = &env};
auto result = ShaderCompiler::CompileShader(attrib);
if (result) {
compiled.compileStatus = true;
compiled.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.
compiled.preprocessedSource = shared.preprocessedSource;
compiled.explicitUniformLocations = shared.explicitUniformLocations;
compiled.explicitOpaqueBindings = shared.explicitOpaqueBindings;
compiled.infoLog.clear();
if (shouldPopulateCache) {
m_preprocessCache->Insert(m_stage, sourceHash, m_source, env.fingerprint, Move(fresh));
}
} else {
compiled.infoLog = result.error().log;
MGLOG_D("ShaderObject::Compile: Shader %d compilation failed.\nSource:\n%s\nInfoLog:\n%s\nSetting "
"compileStatus = false as a result.",
m_externalIndex, shared.preprocessedSource.c_str(), compiled.infoLog.c_str());
if (shouldPopulateCache) {
fresh->outcome = ShaderPreprocessOutcome::ParseFailed;
fresh->infoLog = compiled.infoLog;
fresh->explicitUniformLocations.clear();
fresh->explicitOpaqueBindings.clear();
m_preprocessCache->Insert(m_stage, sourceHash, m_source, env.fingerprint, Move(fresh));
}
}
RememberCompiledSource(sourceHash);
} }
SharedPtr<glslang::TShader> ShaderObject::TakeShaderForLink(String& outReparseLog) { AdoptCompileNode(MakeShared<ShaderCompileTask>(m_stage, m_source, sourceHash, env, m_preprocessCache,
CompileArtifacts& compiled = Compiled(); m_externalIndex));
if (compiled.shader && !compiled.shaderConsumedByLink) {
compiled.shaderConsumedByLink = true;
return compiled.shader;
}
// The stored parse already fed a link, whose mapIO mutated its intermediate. if (!runOnPool) {
// Re-parse the preprocessed source through the identical configuration; this m_compiled->RunInline();
// costs one glslang parse, which is exactly what GenerateBinary used to spend // Inline means the node is already terminal, so this join only replays
// here on EVERY link rather than only on reuse. // diagnostics; it is here so the synchronous and asynchronous paths publish
using namespace MG_Util::ShaderTranspiler; // through the identical code.
ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(m_stage), EnsureCompileJoined();
.sourceStr = compiled.preprocessedSource, return;
.flags = 0,
// Re-parse against the SAME environment the original parse used,
// not against whatever the backend reports now.
.env = compiled.env.get()};
auto result = ShaderCompiler::CompileShader(attrib);
if (!result) {
// Should be unreachable: the same source parsed successfully at Compile().
outReparseLog = result.error().log;
MGLOG_E("ShaderObject::TakeShaderForLink: re-parse of shader %d failed:\n%s", m_externalIndex,
outReparseLog.c_str());
return nullptr;
} }
return result.value(); // Registered BEFORE the post, so the very next glCompileShader in this burst can
// adopt it however fast a worker picks it up. Registration is an index entry only -
// the map holds a WeakPtr and never keeps a node alive.
if (m_adoptionMap) m_adoptionMap->Register(m_compiled);
MG_Util::Async::ShaderCompilePool::Get().Post(m_compiled);
} }
void ShaderObject::MarkAsDeleted() { void ShaderObject::MarkAsDeleted() {
@@ -8,56 +8,98 @@
#pragma once #pragma once
#include <Includes.h> #include <Includes.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.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 {
// P0b layer 2. Declared, not included: the cache keys on ShaderStage, so including // The GL-visible shader name. It owns the source text and one compile job node; the
// its header here would be circular. // job node owns everything a compile produces.
class ShaderPreprocessCache; //
// 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:
// `preprocessCache` is the owning context's cross-object memo (P0b layer 2); // `preprocessCache` is the owning context's cross-object memo (P0b layer 2).
// null is fully supported and simply means "no sharing" - that is what the // Null is fully supported and means two things at once: "no sharing", and
// context-less internal shader objects (the default FS, the blit pipeline) use. // "compile inline, never on a worker". Those coincide exactly - the only
// Shared ownership rather than a raw pointer: once compiles run on a worker the // cache-less shader objects are the internal ones (ProgramObject's default
// job outlives neither the object nor the context deterministically, and the // fragment shader, the DirectVulkan blit and depth-mipmap shaders) and every one
// cache has to stay alive for whoever is still reading it. // 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, ShaderObject(const ShaderStage stage, Uint externalIndex,
SharedPtr<ShaderPreprocessCache> preprocessCache = nullptr) SharedPtr<ShaderPreprocessCache> preprocessCache = nullptr,
: m_stage(stage), m_externalIndex(externalIndex), m_preprocessCache(Move(preprocessCache)) {} 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();
// Hands out a link-consumable TShader. glslang's mapIO mutates the TShader's // The compile job node itself, for ProgramObject::Link()'s input snapshot.
// aliased intermediate, so the parse stored by Compile() may feed exactly one // DELIBERATELY DOES NOT JOIN, and that is the entire point of stage 4: the link
// link; every later link (relink, or the same shader attached to a second // takes the node as a dependency and is posted only once the node is terminal,
// program) gets a fresh parse of the stored preprocessed source through the // so glLinkProgram never blocks on glCompileShader. Null means this object has
// byte-identical CompileShader path (including the legacy-460 retry). Only // never been compiled (or its last compile was abandoned), which the link reads
// callable while GetCompileStatus() is true. Returns null only if that // as COMPILE_STATUS false - the same verdict the joining path produces.
// re-parse fails - outReparseLog then carries its diagnostics. //
SharedPtr<glslang::TShader> TakeShaderForLink(String& outReparseLog); // 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
// immutable snapshot it was handed at enqueue.
const String& GetShaderSource() const { return *m_source; }
// The snapshot itself, for whoever needs to hand it to a job.
const SharedPtr<const String>& GetShaderSourcePtr() const { return m_source; }
const SharedPtr<glslang::TShader>& GetCompiledShader() const { return Compiled().shader; } const SharedPtr<glslang::TShader>& GetCompiledShader() const { return Compiled().shader; }
const String& GetInfoLog() const { return Compiled().infoLog; } const String& GetInfoLog() const { return Compiled().infoLog; }
const UnorderedMap<String, Uint>& GetUniformLocations() const { return Compiled().uniforms; }
// Explicit layout(location = N) qualifiers on this shader's default-block // Explicit layout(location = N) qualifiers on this shader's default-block
// uniforms, captured lexically at Compile() because the relaxed parse drops // uniforms, captured lexically at Compile() because the relaxed parse drops
// them from reflection (see ExtractExplicitUniformLocations). // them from reflection (see ExtractExplicitUniformLocations).
@@ -73,96 +115,122 @@ namespace MobileGL {
Bool GetCompileStatus() const { return Compiled().compileStatus; } Bool GetCompileStatus() const { return Compiled().compileStatus; }
Bool GetDeleteStatus() const { return m_deleteStatus; } Bool GetDeleteStatus() const { return m_deleteStatus; }
// Blocks until a pending compile (P1 stage 3 onwards) has published its // Blocks until a pending compile has published its artifacts. Public for the
// artifacts. Public for the few sites that must join without reading anything. // sites that must join without reading anything - ProgramObject::Link's
// A no-op today - nothing is ever pending. // prologue, which needs every attached shader settled before it runs.
void JoinCompile() const { EnsureCompileJoined(); } void JoinCompile() const { EnsureCompileJoined(); }
// True while this object holds the outcome (success OR failure) of a previous // True while this object holds the outcome (success OR failure) of a Compile()
// Compile() of exactly the source it currently holds - i.e. while the P0b // of exactly the source it currently holds - i.e. while the P0b layer-1 memo is
// layer-1 memo is armed and a glCompileShader would be a no-op. Diagnostics // armed and a glCompileShader would be a no-op. Diagnostics and tests only;
// and tests only; nothing in the GL frontend branches on it. // nothing in the GL frontend branches on it.
// //
// Deliberately does NOT join: the memo bookkeeping below is GL-thread-owned and // Tri-state, and deliberately NOT joining: an in-flight compile of the current
// says nothing about whether a worker has finished, which is exactly the // source counts as memoized (a second glCompileShader must not enqueue a
// property GL_COMPLETION_STATUS_KHR needs when stage 3 lands. // duplicate job), but asking that question must never block.
Bool HasMemoizedCompile() const { return m_hasCompiledState; } // 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:
// ---- P1: everything a compile PRODUCES, in one block ----
//
// Same rule as ProgramObject::LinkArtifacts: this is exactly what
// InvalidateCompiledState() clears, i.e. exactly what one run of Compile()
// writes. Stage 3 lifts this struct wholesale into ShaderCompileTask, where a
// worker fills it in and the GL thread reads it through the same gate.
struct CompileArtifacts {
// The CompileEnv snapshot this compile ran against. Held so the
// consume-once re-parse in TakeShaderForLink() 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 Compile() actually parsed (after PreprocessShaderSource), kept
// for TakeShaderForLink's re-parse so a later link never depends on the
// preprocessor being deterministic across backend-state changes.
String preprocessedSource;
UnorderedMap<String, Uint> uniforms;
UnorderedMap<String, Int> explicitUniformLocations;
UnorderedMap<String, Uint> explicitOpaqueBindings;
Bool shaderConsumedByLink = false;
String infoLog;
Bool compileStatus = false;
};
// ---- The one and only join gate for compile output (P1 invariant I5) ---- // ---- The one and only join gate for compile output (P1 invariant I5) ----
// Blocks until a pending compile has published into m_compiled. Today nothing // The fast path - no job, or a job whose result this object has already pulled -
// is ever pending - glCompileShader still runs the whole body inline - so this // is two predictable branches and stays inline: it runs on every Compiled() read
// is an unconditional no-op. It exists NOW so that every reader of compile // and the project never builds with LTO, so an out-of-line body would be a real
// output is already routed through it when stage 3 makes it block. // cross-TU call at each of those sites. The blocking half is out of line.
// //
// Defined inline (not in ShaderObject.cpp): called from every Compiled() read, // The gate keys on "has this object pulled the job's result yet", NOT on "is the
// and the project never builds with LTO, so an out-of-line empty body would be // job terminal". Those differ in the case that matters: a worker can finish a
// a real cross-TU call at each of those call sites instead of folding away. // compile before the GL thread ever looks at it, and the pull is where deferred
void EnsureCompileJoined() const {} // diagnostics get replayed and an abandoned node gets dropped. Keying on
CompileArtifacts& Compiled() { // terminality would silently skip both.
EnsureCompileJoined(); void EnsureCompileJoined() const {
return m_compiled; if (m_compiled && !m_compileJoined) JoinPendingCompile();
} }
const CompileArtifacts& Compiled() const { 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(); EnsureCompileJoined();
return m_compiled; return m_compiled ? m_compiled->artifacts : EmptyArtifacts();
} }
void InvalidateCompiledState(); 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 ---- // ---- P0b layer 1: per-object no-op recompile ----
// True iff `candidate` is byte-identical to the source that produced the // True iff `candidate` is byte-identical to the source that produced (or is
// compiled state this object is currently holding. The stored hash and length // producing) the compiled state this object currently holds.
// are only a fast reject; the answer is always confirmed against the full
// stored text, so no behaviour rides on a 64-bit hash.
Bool SourceMatchesCompiledState(const String& candidate) const; Bool SourceMatchesCompiledState(const String& candidate) const;
// Arms the layer-1 memo for the source that Compile() just processed.
void RememberCompiledSource(Uint64 sourceHash);
// ---- GL-thread-owned state: never produced by a compile, so it never joins ---- // ---- GL-thread-owned state: never produced by a compile, so it never joins ----
const Uint m_externalIndex = 0;
const ShaderStage m_stage; const ShaderStage m_stage;
// glShaderSource text. A worker only ever reads the snapshot handed to it, so const Uint m_externalIndex = 0;
// GL_SHADER_SOURCE_LENGTH and glGetShaderSource never join. // The pre-glShaderSource state, shared by every untouched object rather than
String m_source; // allocated per glCreateShader.
static const SharedPtr<const String>& EmptySource() {
static const SharedPtr<const String> empty = MakeShared<const String>();
return empty;
}
// glShaderSource text, as an immutable snapshot. Never null. A job holds its own
// SharedPtr to the exact string it was given, so replacing the source under a
// running compile cannot race its storage - and the layer-1 memo collapses to a
// pointer comparison against the job's snapshot, because the setter only swaps
// the pointer when the text genuinely differs.
//
// Not necessarily unique to this object from stage 6 on: adopting a node also
// takes that node's source snapshot (see Compile()), so N shader objects sharing
// one compile share one copy of the text. The string is immutable and shared-
// owned, so that is invisible to every reader.
SharedPtr<const String> m_source = EmptySource();
// P0b layer 2: the owning context's cross-object memo, or null. // 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; const SharedPtr<ShaderPreprocessCache> m_preprocessCache;
// P0b layer 1. m_hasCompiledState is the invariant "m_source is byte-identical // P1 stage 6: the owning context's index of adoptable compile nodes, or null.
// to the source that produced the compile artifacts"; it is armed at the end of // Touched only from Compile(), i.e. only on the GL thread, so it carries no lock.
// every Compile() and disarmed by InvalidateCompiledState(). Stage 3 replaces const SharedPtr<ShaderCompileAdoptionMap> m_adoptionMap;
// all three with a pointer compare against the in-flight job's source snapshot.
Bool m_hasCompiledState = false;
Uint64 m_compiledSourceHash = 0;
SizeT m_compiledSourceLength = 0;
Bool m_deleteStatus = false; Bool m_deleteStatus = false;
// ---- Compile OUTPUT ---- reachable only through Compiled(). // ---- Compile OUTPUT ---- pending OR completed; reachable only through Compiled().
CompileArtifacts m_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
@@ -10,7 +10,10 @@
#include <Includes.h> #include <Includes.h>
#include <list> #include <list>
#include <mutex> #include <mutex>
#include <MG_State/GLState/ProgramState/ShaderObject.h> // 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 { namespace MobileGL::MG_State::GLState {
// Where the shared, source-only half of ShaderObject::Compile() stopped. The two // Where the shared, source-only half of ShaderObject::Compile() stopped. The two
@@ -110,29 +113,10 @@ namespace MobileGL::MG_State::GLState {
} }
private: private:
struct Key { // Shared with ShaderCompileAdoptionMap so the two per-context memos cannot key
ShaderStage stage = ShaderStage::Unknown; // themselves on different notions of "the same compile" - see ShaderSourceKey.h.
Uint64 sourceHash = 0; using Key = ShaderSourceKey;
SizeT sourceLength = 0; using KeyHasher = ShaderSourceKeyHasher;
Uint64 envFingerprint = 0;
Bool operator==(const Key& other) const {
return stage == other.stage && sourceHash == other.sourceHash &&
sourceLength == other.sourceLength && envFingerprint == other.envFingerprint;
}
};
struct KeyHasher {
SizeT operator()(const Key& key) const {
// The source hash already spreads well; fold the two discriminators in so
// that same-hash-different-stage/length 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);
}
};
struct Entry { struct Entry {
Key key; Key key;
@@ -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 {
+3
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)
@@ -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);
}
+131
View File
@@ -28,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
@@ -42,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
+160 -3
View File
@@ -20,6 +20,7 @@
#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_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;
@@ -2934,7 +2935,7 @@ TEST_F(ProgramTest, RecompileWithIdenticalSourceKeepsCompiledStateAndStillLinks)
EXPECT_TRUE(ShaderHasMemoizedCompile(vs)); EXPECT_TRUE(ShaderHasMemoizedCompile(vs));
// A first link consumes the stored TShader; the redundant recompile below must not // A first link consumes the stored TShader; the redundant recompile below must not
// disturb the preprocessed source that TakeShaderForLink re-parses from. // disturb the preprocessed source that ClaimParsedShader re-parses from.
GLuint firstProgram = LinkVsFs(vs, fs, GL_TRUE); GLuint firstProgram = LinkVsFs(vs, fs, GL_TRUE);
EXPECT_GE(GetUniformLocation(firstProgram, "uColor"), 0); EXPECT_GE(GetUniformLocation(firstProgram, "uColor"), 0);
@@ -2959,7 +2960,7 @@ TEST_F(ProgramTest, RecompileWithIdenticalSourceKeepsCompiledStateAndStillLinks)
EXPECT_EQ(String(sourceBuffer.data(), static_cast<size_t>(written)), String(kP0bVs)); EXPECT_EQ(String(sourceBuffer.data(), static_cast<size_t>(written)), String(kP0bVs));
// A second program built from the same, redundantly recompiled shaders links and // A second program built from the same, redundantly recompiled shaders links and
// reflects - i.e. TakeShaderForLink's re-parse path survived the no-op. // reflects - i.e. ClaimParsedShader's re-parse path survived the no-op.
GLuint secondProgram = LinkVsFs(vs, fs, GL_TRUE); GLuint secondProgram = LinkVsFs(vs, fs, GL_TRUE);
EXPECT_GE(GetUniformLocation(secondProgram, "uColor"), 0); EXPECT_GE(GetUniformLocation(secondProgram, "uColor"), 0);
EXPECT_GE(GetUniformLocation(secondProgram, "uModel"), 0); EXPECT_GE(GetUniformLocation(secondProgram, "uModel"), 0);
@@ -3006,8 +3007,19 @@ TEST_F(ProgramTest, TwoShaderObjectsWithIdenticalSourceLinkIndependently) {
ASSERT_NE(objectA, nullptr); ASSERT_NE(objectA, nullptr);
ASSERT_NE(objectB, nullptr); ASSERT_NE(objectB, nullptr);
EXPECT_EQ(objectA->GetShaderSource(), objectB->GetShaderSource()); EXPECT_EQ(objectA->GetShaderSource(), objectB->GetShaderSource());
// Independent parses despite the shared preprocess. // 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(), objectB->GetCompiledShader());
}
EXPECT_NE(objectA->GetCompiledShader(), nullptr); EXPECT_NE(objectA->GetCompiledShader(), nullptr);
EXPECT_NE(objectB->GetCompiledShader(), nullptr); EXPECT_NE(objectB->GetCompiledShader(), nullptr);
@@ -3079,3 +3091,148 @@ TEST_F(ProgramTest, PreprocessCacheOverflowKeepsCompilingCorrectly) {
EXPECT_GE(GetUniformLocation(programB, "uColor"), 0); EXPECT_GE(GetUniformLocation(programB, "uColor"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR); 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);
}
@@ -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;
}
+25 -1
View File
@@ -10,11 +10,35 @@ target_include_directories(JobNodeTest PRIVATE
${MGL_ROOT}/MobileGL ${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( target_link_libraries(
JobNodeTest PRIVATE JobNodeTest PRIVATE
GTest::gtest_main GTest::gtest
${LINK_LIBRARIES} ${LINK_LIBRARIES}
) )
include(GoogleTest) include(GoogleTest)
gtest_discover_tests(JobNodeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) 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}
)
+364 -24
View File
@@ -9,8 +9,19 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <chrono> #include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <stdexcept> #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 "Includes.h"
#include <Config.h> #include <Config.h>
@@ -21,6 +32,29 @@ using namespace MobileGL;
using namespace MobileGL::MG_Util::Async; using namespace MobileGL::MG_Util::Async;
namespace { 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 // Every test drives its own pool instance rather than ShaderCompilePool::Get(): the
// process-wide pool is stopped permanently by StopAndDrain (that is the teardown // 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. // contract), so a test that drained the singleton would poison every test after it.
@@ -73,6 +107,21 @@ namespace {
Bool m_open = false; 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, Bool WaitUntil(const std::function<Bool()>& predicate,
const std::chrono::milliseconds timeout = std::chrono::seconds(10)) { const std::chrono::milliseconds timeout = std::chrono::seconds(10)) {
const auto deadline = std::chrono::steady_clock::now() + timeout; const auto deadline = std::chrono::steady_clock::now() + timeout;
@@ -89,11 +138,33 @@ namespace {
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
TEST(ShaderCompilePoolLifecycle, ConstructingAPoolStartsNoThreadUntilSomethingIsPosted) { TEST(ShaderCompilePoolLifecycle, ConstructingAPoolStartsNoThreadUntilSomethingIsPosted) {
const SizeT before = LiveThreadCount();
ShaderCompilePool pool(kTestThreads); ShaderCompilePool pool(kTestThreads);
EXPECT_EQ(pool.GetThreadCount(), kTestThreads); EXPECT_EQ(pool.GetThreadCount(), kTestThreads);
EXPECT_EQ(pool.GetMaxConcurrency(), kTestThreads); EXPECT_EQ(pool.GetMaxConcurrency(), kTestThreads);
// Nothing observable to assert about thread creation from here; what this pins is that
// construction is side-effect free and the pool destructs cleanly without ever running. 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) { TEST(ShaderCompilePoolLifecycle, StopAndDrainIsIdempotentAndSafeOnAnUnusedPool) {
@@ -135,12 +206,27 @@ TEST(ShaderCompilePoolLifecycle, DetectedThreadCountIsPositive) {
EXPECT_GE(DetectShaderCompileThreadCount(), 1u); EXPECT_GE(DetectShaderCompileThreadCount(), 1u);
} }
TEST(ShaderCompilePoolLifecycle, AsyncIsOffByDefaultInThisStage) { TEST(ShaderCompilePoolLifecycle, AsyncIsOnByDefaultAndTheOverrideDecidesEitherWay) {
// Stage 1 ships the machinery wired to nothing. If this ever fails without the default // The shipped default flipped to ON at stage 7 (the GL30-40 + parallel_shader_compile
// constant having been deliberately flipped, something enabled async by accident. // gate found zero async-attributable failures), and an unset
EXPECT_EQ(MG_Config::Features.AsyncShaderCompile, MG_Config::QuirkOverride::Auto); // MOBILEGL_ASYNC_SHADER_COMPILE resolves to it. If the first expectation ever fails
EXPECT_FALSE(kAsyncShaderCompileDefault); // 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()); EXPECT_FALSE(AsyncShaderCompileEnabled());
MG_Config::Features.AsyncShaderCompile = saved;
} }
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
@@ -202,6 +288,94 @@ TEST(JobNodeSubmit, ManyJobsAllComplete) {
} }
} }
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) { TEST(JobNodeSubmit, ConcurrencyBudgetIsNeverExceeded) {
constexpr Uint kBudget = 2; constexpr Uint kBudget = 2;
constexpr Uint kJobs = 64; constexpr Uint kJobs = 64;
@@ -457,30 +631,58 @@ TEST(JobNodeException, AThrowingJobDoesNotPoisonTheWorkerForLaterJobs) {
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
TEST(ShaderCompilePoolDrain, StopAndDrainWithAThousandQueuedJobsLeavesNoneRunningOrPending) { TEST(ShaderCompilePoolDrain, StopAndDrainWithAThousandQueuedJobsLeavesNoneRunningOrPending) {
constexpr Uint kJobs = 1000; constexpr Uint kQueued = 1000;
ShaderCompilePool pool(kTestThreads); ShaderCompilePool pool(kTestThreads);
pool.SetMaxConcurrency(1); // keep the vast majority queued behind the budget pool.SetMaxConcurrency(1); // one slot, so everything behind the first job stays queued
Vector<SharedPtr<TestJob>> jobs; // Pin that slot with a job that will not return until this test says so. Everything
jobs.reserve(kJobs); // posted behind it is then PROVABLY still in the queue, which is what makes the counts
for (Uint i = 0; i < kJobs; ++i) { // below exact.
jobs.push_back(MakeShared<TestJob>()); //
pool.Post(jobs.back()); // 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());
pool.StopAndDrain(); 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();
// Every node is terminal, so nothing can be waiting on a worker that will never come. // The job that was already running still finished: an in-flight body is waited for, not
Uint complete = 0; // interrupted.
Uint cancelled = 0; EXPECT_TRUE(blocker->IsComplete());
for (const auto& job : jobs) { 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()); ASSERT_TRUE(job->IsTerminal());
if (job->IsComplete()) ++complete; EXPECT_TRUE(job->IsCancelled());
if (job->IsCancelled()) ++cancelled; EXPECT_EQ(job->ran.load(), 0u);
EXPECT_LE(job->ran.load(), 1u);
} }
EXPECT_EQ(complete + cancelled, kJobs);
EXPECT_GT(cancelled, 0u); // the drain really did abandon queued work rather than run it
} }
TEST(ShaderCompilePoolDrain, StopAndDrainWaitsForARunningBodyToReturn) { TEST(ShaderCompilePoolDrain, StopAndDrainWaitsForARunningBodyToReturn) {
@@ -520,3 +722,141 @@ TEST(ShaderCompilePoolDrain, JobsPostedAfterADrainStillRun) {
EXPECT_TRUE(job->IsComplete()); EXPECT_TRUE(job->IsComplete());
EXPECT_EQ(job->ran.load(), 1u); 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);
}
+64 -3
View File
@@ -9,11 +9,37 @@
#include "JobNode.h" #include "JobNode.h"
#include "ShaderCompilePool.h" #include "ShaderCompilePool.h"
#include <MG_State/GLState/Core.h>
namespace MobileGL::MG_Util::Async { namespace MobileGL::MG_Util::Async {
namespace { namespace {
Bool IsTerminalState(const JobState state) { Bool IsTerminalState(const JobState state) {
return state == JobState::Complete || state == JobState::Cancelled; 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 } // namespace
Bool JobNode::IsTerminal() const { return IsTerminalState(m_state.load(std::memory_order_acquire)); } Bool JobNode::IsTerminal() const { return IsTerminalState(m_state.load(std::memory_order_acquire)); }
@@ -45,9 +71,10 @@ namespace MobileGL::MG_Util::Async {
m_cv.notify_all(); m_cv.notify_all();
// Run continuations OUTSIDE the lock: a continuation is free to call back into this // 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 // node (IsComplete, State) and, in the link-dependency case, to post the dependent
// job to the pool from whichever thread drove this node terminal. // 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) { for (auto& continuation : continuations) {
if (continuation) continuation(); RunContinuation(continuation);
} }
return true; return true;
} }
@@ -120,6 +147,40 @@ namespace MobileGL::MG_Util::Async {
return; return;
} }
} }
fn(); // 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 } // namespace MobileGL::MG_Util::Async
+27 -1
View File
@@ -52,7 +52,12 @@ namespace MobileGL::MG_Util::Async {
// Running -> Cancelled (cancelled mid-run, or RunBody() threw) // Running -> Cancelled (cancelled mid-run, or RunBody() threw)
// Complete and Cancelled are terminal and the node is immutable afterwards, so every // Complete and Cancelled are terminal and the node is immutable afterwards, so every
// reader that observed IsTerminal() may read the outputs without further synchronization. // reader that observed IsTerminal() may read the outputs without further synchronization.
class JobNode { //
// 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: public:
JobNode() = default; JobNode() = default;
virtual ~JobNode() = default; virtual ~JobNode() = default;
@@ -88,6 +93,16 @@ namespace MobileGL::MG_Util::Async {
// `fn` runs on the calling thread before OnTerminal returns. Exactly-once in both // `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, // directions: the callback is either handed to the finishing thread or run inline,
// never both. // 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); void OnTerminal(std::function<void()> fn);
// Runs the body on the calling thread. The synchronous path (async disabled, // Runs the body on the calling thread. The synchronous path (async disabled,
@@ -113,4 +128,15 @@ namespace MobileGL::MG_Util::Async {
std::atomic<Bool> m_cancelled{false}; std::atomic<Bool> m_cancelled{false};
Vector<std::function<void()>> m_continuations; 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 } // namespace MobileGL::MG_Util::Async
+610 -30
View File
@@ -12,8 +12,14 @@
#include <asio/post.hpp> #include <asio/post.hpp>
#include <asio/thread_pool.hpp> #include <asio/thread_pool.hpp>
#include <libfork/core.hpp>
#include <libfork/schedule/lazy_pool.hpp>
#include <cstdio> #include <cstdio>
#include <cstdlib>
#include <deque> #include <deque>
#include <functional>
#include <span>
namespace MobileGL::MG_Util::Async { namespace MobileGL::MG_Util::Async {
namespace { namespace {
@@ -36,11 +42,29 @@ namespace MobileGL::MG_Util::Async {
// first pool use, so it is guaranteed to run before any static destructor. // first pool use, so it is guaranteed to run before any static destructor.
Bool g_processTeardown = false; Bool g_processTeardown = false;
std::once_flag g_teardownSentinelOnce; 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; } Bool InProcessTeardown() { return g_processTeardown; }
void EnsureProcessTeardownSentinel() { void EnsureProcessTeardownSentinel() {
std::call_once(g_teardownSentinelOnce, [] { std::atexit(+[] { g_processTeardown = true; }); }); 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) { Uint64 ReadCpuMaxFrequencyKHz(const Uint cpu) {
@@ -89,6 +113,25 @@ namespace MobileGL::MG_Util::Async {
return kAsyncShaderCompileDefault; 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() { Uint DetectShaderCompileThreadCount() {
if (const Uint32 configured = MG_Config::Features.AsyncShaderCompileThreads; configured > 0) { if (const Uint32 configured = MG_Config::Features.AsyncShaderCompileThreads; configured > 0) {
// An explicit request is honoured as given - it is the escape hatch for measuring // An explicit request is honoured as given - it is the escape hatch for measuring
@@ -98,47 +141,534 @@ namespace MobileGL::MG_Util::Async {
return std::clamp(DetectBigCoreCount(), 1u, kMaxAutoShaderCompileThreads); 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 { struct ShaderCompilePool::Impl {
explicit Impl(const Uint threads) : threadCount(std::max(1u, threads)), maxConcurrency(threadCount) {} explicit Impl(const Uint threads)
: threadCount(std::max(1u, threads)), engine(DetectAsyncPoolEngine()), maxConcurrency(threadCount) {}
const Uint 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; std::mutex mutex;
// Created on the first dispatched Post, never in the constructor: asio::thread_pool // Created on the first dispatched Post, never in the constructor: both engines spawn
// spawns its threads eagerly, and a build with async off must not pay for threads it // 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. // will never use.
UniquePtr<asio::thread_pool> pool; UniquePtr<JobExecutor> executor;
std::deque<SharedPtr<JobNode>> queue; std::deque<SharedPtr<JobNode>> queue;
Uint inFlight = 0; Uint inFlight = 0;
Uint maxConcurrency; Uint maxConcurrency;
std::atomic<Bool> stopped{false}; std::atomic<Bool> stopped{false};
// Callers hold `mutex`. Hands as many queued nodes to Asio as the concurrency budget // Callers hold `mutex`. Hands as many queued nodes to the engine as the concurrency
// allows. Posting under the lock is safe and is what keeps `pool` from being moved // budget allows. Submitting under the lock is safe and is what keeps `executor` from
// out by a concurrent StopAndDrain between the decision and the dispatch: asio::post // being moved out by a concurrent StopAndDrain between the decision and the dispatch:
// only enqueues, it never runs the handler on the calling thread, so it cannot // Submit only enqueues, it never runs the callable on the calling thread, so it cannot
// re-enter this mutex. // re-enter this mutex.
void DispatchLocked() { //
// 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)) { while (!queue.empty() && inFlight < maxConcurrency && !stopped.load(std::memory_order_acquire)) {
SharedPtr<JobNode> node = Move(queue.front()); // 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(); queue.pop_front();
++inFlight; ++inFlight;
asio::post(*pool, [this, node = Move(node)]() mutable { RunOnWorker(Move(node)); }); try {
executor->Submit([this, node]() mutable { RunOnWorker(Move(node)); });
} catch (...) {
--inFlight;
toCancel.push_back(Move(node));
}
} }
} }
void RunOnWorker(SharedPtr<JobNode> node) { void RunOnWorker(SharedPtr<JobNode> node) {
tl_isPoolThread = true; tl_isPoolThread = true;
// A node that was already handed to Asio when StopAndDrain ran still arrives // 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 // here; cancelling it first turns the dispatch into a state transition instead of
// a full compile, so the drain's join() returns promptly. // 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(); if (stopped.load(std::memory_order_acquire)) node->Cancel();
node->Run(); node->Run();
node.reset(); node.reset();
Vector<SharedPtr<JobNode>> toCancel;
{
const std::lock_guard<std::mutex> lock(mutex); const std::lock_guard<std::mutex> lock(mutex);
--inFlight; --inFlight;
DispatchLocked(); DispatchLocked(toCancel);
}
// Outside the lock: see DispatchLocked's comment.
for (const auto& n : toCancel) {
if (n) n->Cancel();
}
} }
}; };
@@ -147,10 +677,17 @@ namespace MobileGL::MG_Util::Async {
ShaderCompilePool::~ShaderCompilePool() { StopAndDrain(); } ShaderCompilePool::~ShaderCompilePool() { StopAndDrain(); }
ShaderCompilePool& ShaderCompilePool::Get() { ShaderCompilePool& ShaderCompilePool::Get() {
// Leak-at-exit, like the other MobileGL singletons: a process that exits without // Leak-at-exit, like the other MobileGL singletons: the object itself is never
// eglTerminate hands the threads to the OS rather than joining them from a static // destroyed, so no static destructor can race a late entry point for it. Its THREADS
// destructor, where the rest of the library may already be gone. // are a different matter and are stopped explicitly - by Init.cpp's DestroyImpl on
static ShaderCompilePool* pool = new ShaderCompilePool(DetectShaderCompileThreadCount()); // 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; return *pool;
} }
@@ -163,46 +700,89 @@ namespace MobileGL::MG_Util::Async {
return m_impl->maxConcurrency; return m_impl->maxConcurrency;
} }
AsyncPoolEngine ShaderCompilePool::GetEngine() const { return m_impl->engine; }
void ShaderCompilePool::SetMaxConcurrency(const Uint n) { void ShaderCompilePool::SetMaxConcurrency(const Uint n) {
Vector<SharedPtr<JobNode>> toCancel;
{
const std::lock_guard<std::mutex> lock(m_impl->mutex); const std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount); m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount);
// Raising the budget releases whatever the old one was holding back. // Raising the budget releases whatever the old one was holding back.
if (m_impl->pool) m_impl->DispatchLocked(); 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) { void ShaderCompilePool::Post(SharedPtr<JobNode> node) {
if (!node) return; if (!node) return;
EnsureProcessTeardownSentinel(); 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); const std::lock_guard<std::mutex> lock(m_impl->mutex);
if (!m_impl->stopped.load(std::memory_order_acquire) && !InProcessTeardown()) { if (!m_impl->stopped.load(std::memory_order_acquire) && !InProcessTeardown()) {
if (!m_impl->pool) m_impl->pool = MakeUnique<asio::thread_pool>(m_impl->threadCount); if (!m_impl->executor) m_impl->executor = MakeJobExecutor(m_impl->engine, m_impl->threadCount);
m_impl->queue.push_back(Move(node)); m_impl->queue.push_back(Move(node));
m_impl->DispatchLocked(); 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; 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 // 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 // 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 // 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. // 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(); node->RunInline();
} }
void ShaderCompilePool::StopAndDrain() { void ShaderCompilePool::StopAndDrain() {
// asio::thread_pool::join() from a pool thread would deadlock on itself, and the // Waiting for the workers from a worker would deadlock on itself (asio's join() says
// whole point of this call is that the GL thread waits for the workers. // 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"); MOBILEGL_ASSERT(!IsPoolThread(), "ShaderCompilePool::StopAndDrain() called from a pool thread");
std::deque<SharedPtr<JobNode>> abandoned; std::deque<SharedPtr<JobNode>> abandoned;
UniquePtr<asio::thread_pool> pool; UniquePtr<JobExecutor> executor;
{ {
const std::lock_guard<std::mutex> lock(m_impl->mutex); const std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->stopped.store(true, std::memory_order_release); m_impl->stopped.store(true, std::memory_order_release);
abandoned.swap(m_impl->queue); abandoned.swap(m_impl->queue);
pool = Move(m_impl->pool); executor = Move(m_impl->executor);
} }
// Queued but never dispatched: settle them so anything chained behind them is // Queued but never dispatched: settle them so anything chained behind them is
@@ -211,9 +791,9 @@ namespace MobileGL::MG_Util::Async {
if (node) node->Cancel(); if (node) node->Cancel();
} }
if (pool) { if (executor) {
pool->join(); // returns once every handler already handed to Asio has finished executor->JoinAll(); // returns once every job already handed to the engine is done
pool.reset(); executor.reset(); // and this stops the engine's threads
} }
const std::lock_guard<std::mutex> lock(m_impl->mutex); const std::lock_guard<std::mutex> lock(m_impl->mutex);
+73 -15
View File
@@ -11,27 +11,55 @@
#include <MG_Util/Types.h> #include <MG_Util/Types.h>
#include <MG_Util/Async/JobNode.h> #include <MG_Util/Async/JobNode.h>
// This header deliberately includes NO Asio header: asio::thread_pool lives behind the pimpl // This header deliberately includes NO Asio and NO libfork header: both execution engines
// in ShaderCompilePool.cpp. Asio stays a private implementation detail of one translation // live behind the pimpl in ShaderCompilePool.cpp. They stay private implementation details of
// unit, so no consumer target (MG_Test, MG_IntegrationTest, MG_Benchmark - each with its own // one translation unit, so no consumer target (MG_Test, MG_IntegrationTest, MG_Benchmark -
// target_include_directories) needs the Asio include path, and no consumer pays its compile // each with its own target_include_directories) needs either include path, and no consumer
// time. Do not add one here. // 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 { namespace MobileGL::MG_Util::Async {
// Stage 1 ships the whole machinery switched off: the pool is constructible and tested, // Stage 7: on by default. The gate behind the flip (2026-08-09, headless Mesa, both
// but nothing in the GL pipeline posts to it. The flip to true happens only after the // backends): GL30-40 mustpass + KHR-GL46.parallel_shader_compile at async=1 with the
// real-client soak in the final stage, because the riskiest part of asynchronous // extension advertised - 58,344 case-runs, 8 failures, and every one of the 8 also
// compilation is not the joins - it is that Iris and Sodium change their submission // fails standalone at async=0 and under the pre-P1 library, i.e. zero async-attributable
// schedule the moment GL_KHR_parallel_shader_compile is advertised, and a recorded trace // deltas. The risk this comment used to name - Iris and Sodium changing their submission
// can never cover that path. // schedule the moment GL_KHR_parallel_shader_compile is advertised - remains the one
inline constexpr Bool kAsyncShaderCompileDefault = false; // 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 // 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* (from // default above. Falsy is a complete kill switch: it reverts the threading *and*
// the extension stage on) withdraws GL_KHR_parallel_shader_compile, so the application // withdraws GL_KHR_parallel_shader_compile, so the application behaviour change goes
// behaviour change goes with it. // 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(); 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 // 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] // 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 // because peak RSS scales as workers x largest glslang arena, and four
@@ -39,6 +67,31 @@ namespace MobileGL::MG_Util::Async {
// MOBILEGL_ASYNC_SHADER_COMPILE_THREADS overrides it outright. // MOBILEGL_ASYNC_SHADER_COMPILE_THREADS overrides it outright.
Uint DetectShaderCompileThreadCount(); 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 { class ShaderCompilePool {
public: public:
explicit ShaderCompilePool(Uint threadCount); explicit ShaderCompilePool(Uint threadCount);
@@ -70,6 +123,11 @@ namespace MobileGL::MG_Util::Async {
Uint GetThreadCount() const; Uint GetThreadCount() const;
Uint GetMaxConcurrency() 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 // Bounded concurrency doubles as the memory bound, and is how
// glMaxShaderCompilerThreadsKHR(n) is honoured: a 300-program pack load cannot put // glMaxShaderCompilerThreadsKHR(n) is honoured: a 300-program pack load cannot put
// 300 glslang arenas in flight at once. Clamped to [1, thread count]. // 300 glslang arenas in flight at once. Clamped to [1, thread count].
@@ -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;
} }
@@ -1220,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;
+201
View File
@@ -16,7 +16,13 @@
// 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>
@@ -122,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
@@ -130,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",
@@ -415,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
@@ -736,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;
@@ -866,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);
} }
@@ -249,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) {
@@ -77,6 +77,30 @@ namespace MobileGL {
static bool UseUnformattedFloatStorageImagesForVulkan( static bool UseUnformattedFloatStorageImagesForVulkan(
const Vector<Uint32>& inputBinary, Vector<uint32_t>& outputBinary); const Vector<Uint32>& inputBinary, Vector<uint32_t>& outputBinary);
static Result<String> DecompileShader(SpvcSession& session); static Result<String> DecompileShader(SpvcSession& session);
// Parses one trivial shader in each configuration the production path can
// reach, on the calling thread, so the built-in symbol tables those
// configurations need are already cached before any worker asks for one.
//
// Why it matters: glslang builds a built-in TSymbolTable per distinct
// (version, spvVersion, profile, source) combination, and does it under a
// process-wide lock held for the whole build. Without this, the first
// parallel compiles of a shaderpack load all pile up behind that lock and
// show no speedup at all - which is easy to misread as asynchronous
// compilation not working. Call once, from the GL thread, right after
// glslang::InitializeProcess(). Idempotent and cheap on repeat.
//
// Only worth its cost when compiles can actually run in parallel, so the GL
// frontend calls it only when asynchronous compilation is enabled: a
// synchronous build would pay for three throwaway parses at every
// eglInitialize to prewarm tables the first real compile builds anyway.
static void PrewarmBuiltins();
// Clears the "already prewarmed" latch. MUST be called wherever
// glslang::FinalizeProcess() is, and for the same reason: finalizing deletes
// the cached built-in tables the latch is asserting the existence of. Without
// it, the second eglInitialize of a process comes back up unwarmed and with
// no way left to warm it.
static void ResetPrewarmLatch();
}; };
} // namespace ShaderTranspiler } // namespace ShaderTranspiler
} // namespace MG_Util } // namespace MG_Util
@@ -58,12 +58,99 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
case GL_R8_SNORM: case GL_R8_SNORM:
applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm8; applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm8;
break; break;
// The rest of the three-channel formats no real ES driver renders to. They have no
// other fallback: none of the driver/forced option bits names them, so before the
// render-target widening existed for ordinary targets an FBO attachment in one of
// them could only ever be answered GL_FRAMEBUFFER_UNSUPPORTED (Complementary
// Reimagined's colortex2 = RGB16F).
//
// GL_RGB9_E5 is deliberately absent: its four-channel sibling would have to be a
// half float, which means unpacking the shared exponent on every transfer, and
// nothing renders to a shared-exponent format on desktop GL either.
case GL_RGB16F:
case GL_RGB32F:
case GL_SRGB8:
case GL_RGB8I:
case GL_RGB8UI:
case GL_RGB16I:
case GL_RGB16UI:
case GL_RGB32I:
case GL_RGB32UI:
applicableOptions |= options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
break;
default: default:
break; break;
} }
return applicableOptions; return applicableOptions;
} }
namespace {
// The four-channel sibling a three-channel format is widened to when the target has to
// stay colour-renderable, together with the transfer pair that describes client data for
// it. Kept in one place because all three of NormalizePixelFormat's switches have to agree:
// reporting the widened storage but the original three-channel base format emitted
// inconsistent triples such as (GL_RGBA16F, GL_RGB, GL_BYTE), which is
// GL_INVALID_OPERATION for glTexImage2D on ES. That only ever went unnoticed because the
// bit was reachable for multisample storage alone, and glTexStorage*Multisample takes no
// transfer pair at all.
struct ThreeChannelWidening {
GLenum InternalFormat = GL_UNKNOWN_MGL;
GLenum Format = GL_UNKNOWN_MGL;
GLenum Type = GL_UNKNOWN_MGL;
explicit operator Bool() const { return InternalFormat != GL_UNKNOWN_MGL; }
};
ThreeChannelWidening GetThreeChannelRenderTargetWidening(GLenum internalFormat,
Flags<PixelFormatNormalizeOptionBit> options) {
if (!(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)) {
return {};
}
switch (internalFormat) {
// Signed-normalized: matches what the always-on NoRGBA8Snorm fallback already does to
// GL_RGBA8_SNORM, so the two SNORM8 formats land on the same storage.
case GL_RGB8_SNORM:
return {GL_RGBA16F, GL_RGBA, GL_FLOAT};
case GL_RGB16_SNORM:
// A half float loses the low bits of a 16-bit SNORM channel, so keep the
// signed-normalized encoding whenever the driver can render to it.
return (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget)
? ThreeChannelWidening{GL_RGBA16F, GL_RGBA, GL_FLOAT}
: ThreeChannelWidening{GL_RGBA16_SNORM, GL_RGBA, GL_SHORT};
// Unsigned-normalized 16-bit (and the legacy 10/12-bit formats stored as RGB16):
// GL_RGB32F is a legal ES texture format but is not colour-renderable either.
case GL_RGB16:
case GL_RGB10:
case GL_RGB12:
return {GL_RGBA32F, GL_RGBA, GL_FLOAT};
// Floating point.
case GL_RGB16F:
return {GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT};
case GL_RGB32F:
return {GL_RGBA32F, GL_RGBA, GL_FLOAT};
// sRGB: GL_SRGB8_ALPHA8 keeps the sRGB encoding of the colour channels and stores
// the added alpha linearly, which is exactly the three-channel format's semantics.
case GL_SRGB8:
return {GL_SRGB8_ALPHA8, GL_RGBA, GL_UNSIGNED_BYTE};
// Integer.
case GL_RGB8I:
return {GL_RGBA8I, GL_RGBA_INTEGER, GL_BYTE};
case GL_RGB8UI:
return {GL_RGBA8UI, GL_RGBA_INTEGER, GL_UNSIGNED_BYTE};
case GL_RGB16I:
return {GL_RGBA16I, GL_RGBA_INTEGER, GL_SHORT};
case GL_RGB16UI:
return {GL_RGBA16UI, GL_RGBA_INTEGER, GL_UNSIGNED_SHORT};
case GL_RGB32I:
return {GL_RGBA32I, GL_RGBA_INTEGER, GL_INT};
case GL_RGB32UI:
return {GL_RGBA32UI, GL_RGBA_INTEGER, GL_UNSIGNED_INT};
default:
return {};
}
}
} // namespace
void NormalizePixelFormat(GLenum internalFormat, Flags<PixelFormatNormalizeOptionBit> options, void NormalizePixelFormat(GLenum internalFormat, Flags<PixelFormatNormalizeOptionBit> options,
GLenum* outInternalFormat, GLenum* outFormat, GLenum* outType) { GLenum* outInternalFormat, GLenum* outFormat, GLenum* outType) {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
@@ -95,13 +182,6 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
*outInternalFormat = internalFormat; *outInternalFormat = internalFormat;
break; break;
case GL_RGB16: case GL_RGB16:
if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) {
// GL_RGB32F is a legal ES texture format but is not colour-renderable, so
// glTexStorage2DMultisample rejects it and the attachment ends up with no
// storage at all.
*outInternalFormat = GL_RGBA32F;
break;
}
if ((options & PixelFormatNormalizeOptionBit::NoNorm16) || if ((options & PixelFormatNormalizeOptionBit::NoNorm16) ||
(options & PixelFormatNormalizeOptionBit::NoRgb16)) { (options & PixelFormatNormalizeOptionBit::NoRgb16)) {
*outInternalFormat = GL_RGB32F; *outInternalFormat = GL_RGB32F;
@@ -132,14 +212,6 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
*outInternalFormat = internalFormat; *outInternalFormat = internalFormat;
break; break;
case GL_RGB16_SNORM: case GL_RGB16_SNORM:
if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) {
// A half float loses the low bits of a 16-bit SNORM channel, so keep the
// signed-normalized encoding whenever the driver can render to it.
*outInternalFormat = (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget)
? GL_RGBA16F
: GL_RGBA16_SNORM;
break;
}
if ((options & PixelFormatNormalizeOptionBit::NoNorm16) || if ((options & PixelFormatNormalizeOptionBit::NoNorm16) ||
(options & PixelFormatNormalizeOptionBit::NoRGB16Snorm) || (options & PixelFormatNormalizeOptionBit::NoRGB16Snorm) ||
(options & PixelFormatNormalizeOptionBit::NoSnorm16)) { (options & PixelFormatNormalizeOptionBit::NoSnorm16)) {
@@ -173,10 +245,6 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
*outInternalFormat = internalFormat; *outInternalFormat = internalFormat;
break; break;
case GL_RGB8_SNORM: case GL_RGB8_SNORM:
if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) {
*outInternalFormat = GL_RGBA16F;
break;
}
if (options & PixelFormatNormalizeOptionBit::NoSnorm8) { if (options & PixelFormatNormalizeOptionBit::NoSnorm8) {
*outInternalFormat = GL_RGB16F; *outInternalFormat = GL_RGB16F;
break; break;
@@ -615,5 +683,18 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
break; break;
} }
} }
// Applied last, over whatever the three switches above chose: widening a three-channel
// format to keep a colour attachment renderable outranks every other fallback, because
// the others all pick a three-channel storage the driver still refuses to render to
// (GL_RGB8_SNORM -> GL_RGB16F under NoSnorm8, GL_RGB16 -> GL_RGB32F under NoNorm16).
// All three outputs move together: reporting the widened storage while leaving the
// three-channel base format and its component type in place produced triples like
// (GL_RGBA16F, GL_RGB, GL_BYTE), which ES rejects for glTexImage2D outright.
if (const ThreeChannelWidening widening = GetThreeChannelRenderTargetWidening(internalFormat, options)) {
if (outInternalFormat) *outInternalFormat = widening.InternalFormat;
if (outFormat) *outFormat = widening.Format;
if (outType) *outType = widening.Type;
}
} }
} // namespace MobileGL::MG_Util::TextureFormatProcessor } // namespace MobileGL::MG_Util::TextureFormatProcessor
@@ -20,9 +20,14 @@ namespace MobileGL {
NoRGB16Snorm = 1 << 6, NoRGB16Snorm = 1 << 6,
// The target must be colour-renderable and ES has no renderable three-channel // The target must be colour-renderable and ES has no renderable three-channel
// form of the requested format, so it has to be widened to the four-channel one. // form of the requested format, so it has to be widened to the four-channel one.
// Only meaningful for multisample textures: those can never be uploaded to, only // Set for any colour-attachable target whose native three-channel form the driver
// rendered into, so the extra alpha comes from the draw (1.0 for an RGB source) // refused to render to (multisample storage always, since ES has no three-channel
// and no transfer path has to expand three-channel client data. // multisample format at all; every other target only after its native probe failed).
// The widening is visible to every transfer path, so it also retargets the (format,
// type) pair NormalizePixelFormat reports: the upload has to describe four
// components in the widened storage's component type, the backend has to expand
// three-channel client data with an alpha of 1.0, and sampling/readback has to hide
// the added alpha again (BackendTextureFormatAddsAlpha).
NoThreeChannelRenderTarget = 1 << 7, NoThreeChannelRenderTarget = 1 << 7,
// Pairs with the bit above: the widened four-channel format has to stay renderable AND // Pairs with the bit above: the widened four-channel format has to stay renderable AND
// keep 16-bit signed-normalized precision, which needs both EXT_texture_norm16 and // keep 16-bit signed-normalized precision, which needs both EXT_texture_norm16 and
+6
View File
@@ -63,6 +63,12 @@ The bundled fixtures cover:
- minecraft-1.21.4-fabric-iris-bsl-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and BSL - minecraft-1.21.4-fabric-iris-bsl-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and BSL
Shaders after entering a singleplayer world. Shaders after entering a singleplayer world.
![Minecraft 1.21.4 Fabric Iris BSL in-world golden](fixtures/minecraft-1.21.4-fabric-iris-bsl-in-world.0000110725.png) ![Minecraft 1.21.4 Fabric Iris BSL in-world golden](fixtures/minecraft-1.21.4-fabric-iris-bsl-in-world.0000110725.png)
- minecraft-1.21.4-fabric-iris-bsl-esc-menu-854: captured on an Android device (Mali-G77, FCL MobileGL capture) from
Minecraft 1.21.4 Fabric with Sodium, Iris, and BSL Shaders, at the pause menu over a BSL-blurred world. The frame
pins glyph rendering: every menu label, the menu title and the tutorial toast must be present. Regressions in the
DirectGLES per-draw texture memo have made the whole text path disappear here while sprites kept rendering, so a
failure that leaves the buttons but empties them is the signature to look for in the diff.
![Minecraft 1.21.4 Fabric Iris BSL ESC menu 854x480 golden](fixtures/minecraft-1.21.4-fabric-iris-bsl-esc-menu-854.0001303534.png)
- minecraft-1.21.4-fabric-iris-makeup-ultrafast-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and - minecraft-1.21.4-fabric-iris-makeup-ultrafast-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and
MakeUP UltraFast after entering a singleplayer world. MakeUP UltraFast after entering a singleplayer world.
![Minecraft 1.21.4 Fabric Iris MakeUP UltraFast in-world golden](fixtures/minecraft-1.21.4-fabric-iris-makeup-ultrafast-in-world.0000095322.png) ![Minecraft 1.21.4 Fabric Iris MakeUP UltraFast in-world golden](fixtures/minecraft-1.21.4-fabric-iris-makeup-ultrafast-in-world.0000095322.png)
+6
View File
@@ -289,6 +289,12 @@
"golden": "minecraft-1.21.4-fabric-iris-iterationrp-in-world.0000202020.png", "golden": "minecraft-1.21.4-fabric-iris-iterationrp-in-world.0000202020.png",
"target_call": 202020, "target_call": 202020,
"timeout_seconds": 1800 "timeout_seconds": 1800
},
{
"name": "minecraft-1.21.4-fabric-iris-bsl-esc-menu-854",
"trace_archive": "minecraft-1.21.4-fabric-iris-bsl-esc-menu-854.tgz",
"golden": "minecraft-1.21.4-fabric-iris-bsl-esc-menu-854.0001303534.png",
"target_call": 1303534
} }
] ]
} }