mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-07 19:58:32 +09:00
237 lines
14 KiB
C++
237 lines
14 KiB
C++
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderObject.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 "ShaderObject.h"
|
|
#include "ShaderPreprocessCache.h"
|
|
#include <MG_Util/Async/ShaderCompilePool.h>
|
|
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
|
|
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
|
|
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
|
|
#include <MG_Util/ShaderTranspiler/Types.h>
|
|
|
|
namespace MobileGL::MG_State::GLState {
|
|
void ShaderObject::SetShaderSource(const String& source) {
|
|
// P0b layer 1. glShaderSource always REPLACES the source, but replacing it with a
|
|
// byte-identical one cannot change what a compile would produce: the whole
|
|
// pipeline (preprocess -> lexical checks -> glslang parse) is a pure function of
|
|
// (stage, source, CompileEnv). So keeping the compiled state is not an optimization
|
|
// that changes observable behaviour - the COMPILE_STATUS, the info log and the
|
|
// reflection a caller can query are exactly what a real recompile would have
|
|
// rebuilt, byte for byte. A compile still IN FLIGHT is left running for the same
|
|
// reason: it is computing the right answer for text this object still holds.
|
|
if (SourceMatchesCompiledState(source)) return;
|
|
// The text genuinely changed, so whatever a running job is computing is now about
|
|
// an old source. Give up our claim on it - it owns its own copy of that old string,
|
|
// so swapping the pointer below cannot race its storage. Note "our claim", not "the
|
|
// job": another shader object may have adopted the same node and still be waiting for
|
|
// exactly this answer, which is what ReleaseCompileNode's count discipline protects.
|
|
ReleaseCompileNode();
|
|
m_source = MakeShared<const String>(source);
|
|
InvalidateCompiledState();
|
|
}
|
|
|
|
void ShaderObject::SetShaderSource(String&& source) {
|
|
if (SourceMatchesCompiledState(source)) return;
|
|
ReleaseCompileNode();
|
|
m_source = MakeShared<const String>(Move(source));
|
|
InvalidateCompiledState();
|
|
}
|
|
|
|
Bool ShaderObject::SourceMatchesCompiledState(const String& candidate) const {
|
|
// The memo is armed exactly while a job exists that was built from the string this
|
|
// object still points at - pending or finished, success or failure.
|
|
if (!HasMemoizedCompile()) return false;
|
|
if (candidate.length() != m_source->length()) return false;
|
|
// Never let correctness ride on a hash: the answer is the full text comparison.
|
|
// (The stored hash on the node is a cache-lookup accelerator, not a substitute.)
|
|
return candidate == *m_source;
|
|
}
|
|
|
|
void ShaderObject::JoinPendingCompile() const {
|
|
MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(),
|
|
"ShaderObject::EnsureCompileJoined() reached from a pool thread; a job body must never read "
|
|
"GL-thread-owned objects");
|
|
m_compiled->Wait();
|
|
m_compileJoined = true;
|
|
// Errors and worker-side log lines are raised HERE, on the GL thread, at the first
|
|
// join of the job that produced them - which for a single shader is trivially the
|
|
// order a serial implementation would have produced them in.
|
|
//
|
|
// ApplyDeferredDiagnostics DRAINS, so a node shared by several shader objects
|
|
// (stage 6) replays its worker-side log line exactly once, at whichever object joins
|
|
// first. That is the honest report - one compile ran - and it is log text only: the
|
|
// GL-observable half of a failure, COMPILE_STATUS and the info log, lives in
|
|
// `artifacts` and every sharer reads the identical copy of it.
|
|
MG_Util::Async::ApplyDeferredDiagnostics(*m_compiled);
|
|
// A node that settled as Cancelled published nothing. Dropping it here is what keeps
|
|
// the object's state machine to two reachable cases - "no job" and "a job that
|
|
// completed" - so every reader below can treat a live node as authoritative.
|
|
//
|
|
// Through DropCompileNode, not a bare reset: this object is letting the node go, so
|
|
// its adopter slot has to go with it. A node shared with another object stays alive
|
|
// and gets dropped once more when that object joins - once per holder, never twice
|
|
// for the same one, because DropCompileNode is null-guarded.
|
|
if (!m_compiled->IsComplete()) DropCompileNode();
|
|
}
|
|
|
|
void ShaderObject::AdoptCompileNode(SharedPtr<ShaderCompileTask> node) const {
|
|
// Never overwrite a hold without giving its slot back first.
|
|
DropCompileNode();
|
|
m_compiled = Move(node);
|
|
m_compiled->AddAdopter();
|
|
// Re-arm the join gate: whether this node was just created or just adopted from
|
|
// another object, THIS object has not pulled its result yet. (An adopted node may
|
|
// already be terminal - the join then only replays what is left of its diagnostics.)
|
|
m_compileJoined = false;
|
|
// A new compile is a new story: whatever the optimistic getters promised about the
|
|
// previous node does not carry over.
|
|
m_optimisticAnswerLatched = false;
|
|
}
|
|
|
|
void ShaderObject::DropCompileNode() const {
|
|
if (!m_compiled) return;
|
|
m_compiled->ReleaseAdopter();
|
|
m_compiled.reset();
|
|
// No node means IsCompileComplete() is trivially true and the truthful answers are
|
|
// "not compiled"; a stale latch would keep reporting a compile that no longer
|
|
// exists as GL_TRUE.
|
|
m_optimisticAnswerLatched = false;
|
|
}
|
|
|
|
void ShaderObject::InvalidateCompiledState() {
|
|
// The job node holds exactly what one Compile() produces, so discarding it IS the
|
|
// invalidation - and it re-arms nothing, so the next Compile() genuinely recompiles.
|
|
DropCompileNode();
|
|
}
|
|
|
|
void ShaderObject::ReleaseCompileNode() {
|
|
if (!m_compiled) return;
|
|
// Already terminal: there is nothing left to stop, so this is not a release at all -
|
|
// the node and this object's claim on it both stay. That early return is older than
|
|
// stage 6 and it is load-bearing: ProgramState::ReleaseShaderNameIfOrphaned calls
|
|
// this on a shader whose name is going away but whose object a ProgramObject may
|
|
// still hold, and dropping a COMPLETED compile there would turn that program's link
|
|
// into GL_FALSE.
|
|
if (m_compiled->IsTerminal()) return;
|
|
// Two independent claimants have to be checked before a cancel, and this object is
|
|
// authorized to cancel only if BOTH say the result has become unobservable.
|
|
//
|
|
// 1. Other shader objects. From stage 6 a node can be SHARED by several GL shader
|
|
// names that were handed byte-identical source; cancelling here would turn a
|
|
// compile they must still see as GL_TRUE into GL_FALSE. Only the releaser that
|
|
// takes the count to zero - i.e. the last holder - may cancel. See
|
|
// ShaderCompileTask::AddAdopter for why a plain Int is sound here and for the
|
|
// exactness argument under the worker race.
|
|
// 2. A pending LINK. An enqueued ProgramLinkTask holds the node in its input snapshot
|
|
// and a cancel would turn its link into GL_FALSE; reached by the ordinary
|
|
// link-then-detach-then-delete shader teardown. See MarkLinkReferenced. Never
|
|
// cleared, so this is a one-way pin.
|
|
//
|
|
// The cancel itself is cooperative and non-blocking, exactly as before: a node no
|
|
// worker has picked up settles immediately, a running one is flagged and settles when
|
|
// its body returns, writing only into itself the whole time.
|
|
if (m_compiled->AdopterCount() == 1 && !m_compiled->IsLinkReferenced()) m_compiled->Cancel();
|
|
DropCompileNode();
|
|
}
|
|
|
|
void ShaderObject::Compile() {
|
|
// 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.
|
|
// Hoisted above the memo check because the memo must be env-disciplined too (below).
|
|
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env =
|
|
MG_Util::ShaderTranspiler::GetCurrentCompileEnv();
|
|
|
|
// P0b layer 1, as a tri-state: the memo is "the node in m_compiled was built from
|
|
// the string m_source still points at". SetShaderSource only swaps that pointer when
|
|
// the text actually differs, so this is a pointer compare, and it covers Pending as
|
|
// well as Complete - a second glCompileShader on an in-flight object is a no-op, not
|
|
// a duplicate job racing to write the same fields.
|
|
//
|
|
// The failure case is covered too: the info log stays queryable because nothing is
|
|
// cleared. And if the stored TShader already fed a link, the no-op leaves
|
|
// preprocessedSource and the explicit-location snapshot intact, which is precisely what
|
|
// ClaimParsedShader's on-demand re-parse needs - a real recompile would have handed
|
|
// the next link a fresh parse, the no-op hands it a fresh re-parse of the identical
|
|
// source instead. Same result, one parse either way.
|
|
//
|
|
// The environment joins the check (ShaderSourceKey.h's memo-hazard rule: a memo
|
|
// must never be handed back under an environment other than the one it was
|
|
// computed against). Layers 2 and 3 key on the fingerprint, but this memo sits
|
|
// ABOVE both, so without this compare a node computed against a dead environment
|
|
// - e.g. a compute shader rejected against the pre-capability fallback limits -
|
|
// would keep answering forever while a fresh object with byte-identical source
|
|
// compiles fine. The fingerprint is a content hash, so a republish of identical
|
|
// capabilities still hits.
|
|
if (HasMemoizedCompile() && m_compiled->env != nullptr && m_compiled->env->fingerprint == env->fingerprint) {
|
|
return;
|
|
}
|
|
|
|
// 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);
|
|
|
|
// ---- P1 stage 6: adopt an equivalent compile instead of enqueueing a duplicate ----
|
|
// ~21% of all glCompileShader calls in the shaderpack corpus are a DIFFERENT shader
|
|
// object handed byte-identical source. P0b's memo only pays off once one of them has
|
|
// finished; under async they are all enqueued in the same burst, so without this each
|
|
// one runs the whole pipeline on its own worker. The map hands back the node the
|
|
// first of them created - in flight or already complete - and this object simply
|
|
// holds it too.
|
|
if (runOnPool && m_adoptionMap) {
|
|
if (SharedPtr<ShaderCompileTask> shared =
|
|
m_adoptionMap->FindAdoptable(m_stage, sourceHash, *m_source, env->fingerprint)) {
|
|
// Take the node's own source snapshot as ours. FindAdoptable just compared
|
|
// the two strings in full, so this changes nothing observable - but it is not
|
|
// optional: the layer-1 memo (HasMemoizedCompile) is a POINTER comparison
|
|
// against the node's snapshot, so leaving our own equal-but-distinct copy in
|
|
// place would make the very next glCompileShader on this object decide it had
|
|
// no memo and enqueue the duplicate this whole stage exists to avoid - and
|
|
// would make an identical glShaderSource re-source cancel a shared compile.
|
|
// It also collapses N copies of a ~100 KB shaderpack stage into one.
|
|
m_source = shared->source;
|
|
AdoptCompileNode(Move(shared));
|
|
return;
|
|
}
|
|
}
|
|
|
|
AdoptCompileNode(MakeShared<ShaderCompileTask>(m_stage, m_source, sourceHash, env, m_preprocessCache,
|
|
m_externalIndex));
|
|
|
|
if (!runOnPool) {
|
|
m_compiled->RunInline();
|
|
// Inline means the node is already terminal, so this join only replays
|
|
// diagnostics; it is here so the synchronous and asynchronous paths publish
|
|
// through the identical code.
|
|
EnsureCompileJoined();
|
|
return;
|
|
}
|
|
// 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() {
|
|
m_deleteStatus = true;
|
|
}
|
|
} // namespace MobileGL::MG_State::GLState
|