mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-12 06:08:30 +09:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3025284a6e | ||
|
|
d8d7530011 | ||
|
|
0f394fa46f | ||
|
|
107669b3db | ||
|
|
3e0460e472 | ||
|
|
33ff177bb2 | ||
|
|
2e6fc1ffc0 | ||
|
|
c6299f754f | ||
|
|
dcf918b9ee |
@@ -34,3 +34,6 @@
|
||||
[submodule "3rdparty/asio"]
|
||||
path = 3rdparty/asio
|
||||
url = https://github.com/chriskohlhoff/asio.git
|
||||
[submodule "3rdparty/libfork"]
|
||||
path = 3rdparty/libfork
|
||||
url = https://github.com/ConorWilliams/libfork.git
|
||||
|
||||
+1
Submodule 3rdparty/libfork added at 9b2b844a5f
@@ -232,6 +232,7 @@ set(SOURCE_FILES
|
||||
MobileGL/MG_Impl/GLImpl/Framebuffer/Validators.cpp
|
||||
MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp
|
||||
MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp
|
||||
MobileGL/MG_Impl/GLImpl/Program/ProgramInterface.cpp
|
||||
MobileGL/MG_Impl/GLImpl/Program/GL_ProgramPipeline.cpp
|
||||
MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp
|
||||
MobileGL/MG_Impl/GLImpl/Texture/Validators.cpp
|
||||
@@ -298,6 +299,7 @@ set(SOURCE_FILES
|
||||
MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp
|
||||
MobileGL/MG_State/GLState/RenderState/RenderState.cpp
|
||||
MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp
|
||||
@@ -371,6 +373,13 @@ set(MOBILEGL_INCLUDE_DIR
|
||||
# MG_Util/Async/ShaderCompilePool.cpp includes it, and it stays behind that file's
|
||||
# pimpl so no consumer target needs this path.
|
||||
${CMAKE_SOURCE_DIR}/3rdparty/asio/asio/include
|
||||
# The second shader-compile execution engine (MOBILEGL_ASYNC_POOL=libfork), on the
|
||||
# same terms as Asio above: header-only, no add_subdirectory (its CMakeLists only
|
||||
# declares an INTERFACE target plus install/test scaffolding we do not want), no link
|
||||
# target, and reachable from exactly one translation unit. libfork's own
|
||||
# target_compile_features asks for cxx_std_23, which this project already sets
|
||||
# globally, so its C++20 coroutines need no per-source standard override.
|
||||
${CMAKE_SOURCE_DIR}/3rdparty/libfork/include
|
||||
)
|
||||
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
|
||||
@@ -66,6 +66,12 @@ namespace MobileGL::MG_Config {
|
||||
// - DISPLAY: X11 session variable, not MobileGL configuration.
|
||||
// - MOBILEGL_LOG_FILE_PATH: log-file init runs before MG_ConfigLoader::Init
|
||||
// (see MG_Util/Debug/Log.cpp).
|
||||
// - MOBILEGL_ASYNC_POOL: a ShaderCompilePool is constructed by binaries that never call
|
||||
// MobileGL::Initialize() and so never run MG_ConfigLoader::Init - MG_Test's
|
||||
// JobNodeTest builds pools directly, and it is the suite that runs the whole async
|
||||
// matrix against both execution engines. Mirroring it here would resolve to the
|
||||
// default in exactly the tests that exist to tell the engines apart (see
|
||||
// MG_Util/Async/ShaderCompilePool.cpp, DetectAsyncPoolEngine).
|
||||
struct FeaturesTable {
|
||||
// MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries.
|
||||
Bool DisableTimerQuery = false;
|
||||
|
||||
@@ -181,15 +181,18 @@ namespace MobileGL {
|
||||
void (*GetIntegeri_v)(GLenum target, GLuint index, GLint* data);
|
||||
void (*GetInteger64i_v)(GLenum target, GLuint index, GLint64* data);
|
||||
void (*GetProgramiv)(GLuint program, GLenum pname, GLint* params);
|
||||
void (*GetProgramInterfaceiv)(GLuint program, GLenum programInterface, GLenum pname, GLint* params);
|
||||
GLuint (*GetProgramResourceIndex)(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
void (*GetProgramResourceName)(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize,
|
||||
GLsizei* length, GLchar* name);
|
||||
void (*GetProgramResourceiv)(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
|
||||
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params);
|
||||
GLint (*GetProgramResourceLocation)(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
GLint (*GetProgramResourceLocationIndex)(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
void (*ShaderStorageBlockBinding)(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding);
|
||||
// The GL program interface (glGetProgramInterfaceiv / glGetProgramResource*) is NOT
|
||||
// a backend query: it describes the program the application wrote, in the
|
||||
// application's namespace, which neither backend program is in. It is answered
|
||||
// entirely by MG_Impl/GLImpl/Program/ProgramInterface from the frontend reflection.
|
||||
// Takes the block's GL NAME, not glShaderStorageBlockBinding's index. The index
|
||||
// the application passes is the frontend interface-query enumeration's, and no
|
||||
// backend shares that index space: DirectVulkan enumerates SPIR-V descriptor
|
||||
// bindings and DirectGLES asks a real driver about SPIRV-Cross-generated ESSL.
|
||||
// The name is the one coordinate all three agree on, so the frontend resolves the
|
||||
// index against its own enumeration and each backend maps the name to its own.
|
||||
void (*ShaderStorageBlockBinding)(GLuint program, const GLchar* storageBlockName,
|
||||
GLuint storageBlockBinding);
|
||||
// GL fence sync objects. All entries are optional (may be null); the
|
||||
// frontend then falls back to always-signaled sync semantics.
|
||||
// FenceSync may itself return null when the backend cannot create a
|
||||
|
||||
@@ -210,7 +210,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
reasons.push_back("GL_DEPTH_COMPONENT32 native probe failed on OpenGL ES");
|
||||
}
|
||||
if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) {
|
||||
reasons.push_back("no three-channel multisample storage format on OpenGL ES");
|
||||
reasons.push_back("no colour-renderable three-channel format on OpenGL ES");
|
||||
}
|
||||
if (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) {
|
||||
reasons.push_back("EXT_render_snorm not supported");
|
||||
@@ -553,26 +553,60 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
|
||||
for (SizeT targetIndex = 0; targetIndex < kFormatCapabilityTextureTargetCount; ++targetIndex) {
|
||||
const auto target = static_cast<TextureTarget>(targetIndex);
|
||||
// A multisample texture can only ever be rendered into, so its storage format
|
||||
// has to stay colour-renderable; the ordinary fallback for a three-channel
|
||||
// format is a three-channel one, which ES accepts as a texture but rejects as
|
||||
// multisample storage. Recompute the fallback per target so those formats get
|
||||
// widened here and nowhere else.
|
||||
Flags<PixelFormatNormalizeOptionBit> targetOptions;
|
||||
if (IsGLESProbeMultisampleTarget(target)) {
|
||||
targetOptions |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
|
||||
if (!capabilities.SupportsRenderSnorm || !capabilities.SupportsNorm16Texture) {
|
||||
targetOptions |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
|
||||
}
|
||||
}
|
||||
// Colour-attachable targets need a colour-renderable fallback; the ordinary
|
||||
// fallback for a three-channel format is another three-channel one, which ES
|
||||
// accepts as a texture but never as an attachment. Recompute the fallback per
|
||||
// target so those formats get widened where the target demands it.
|
||||
const Flags<PixelFormatNormalizeOptionBit> renderTargetOptions =
|
||||
TextureImpl::GetRenderTargetNormalizeOptions(capabilities, targetIndex);
|
||||
// Multisample storage has no three-channel form on ES at all, so its widening
|
||||
// is unconditional and skips the native probe (which cannot succeed). Every
|
||||
// other target keeps the widening on the DRIVER branch, behind the native
|
||||
// probe: `shouldProbeFallback = !nativeCreated || !nativeRenderable` below is
|
||||
// what makes the substitution conditional on the driver actually refusing, so
|
||||
// a driver that does render to a three-channel image keeps allocating it byte
|
||||
// for byte. That is a per-format runtime answer, NOT a desktop-vs-device
|
||||
// split: llvmpipe renders to GL_RGB16F but refuses GL_RGB8_SNORM, GL_SRGB8,
|
||||
// GL_RGB32F and the RGB integer formats, so the CI driver widens those eight
|
||||
// too. Re-run the retrace fixtures and the glcts suites on any change here.
|
||||
const Bool widenUnconditionally = IsGLESProbeMultisampleTarget(target);
|
||||
GLESProbeFormatInfo fallbackInfo = outerFallbackInfo;
|
||||
Bool hasForcedFallback = outerHasForcedFallback;
|
||||
if (targetOptions) {
|
||||
hasForcedFallback = BuildFallbackProbeFormatInfo(
|
||||
requestedInternalFormat, forcedOptions | targetOptions, true, fallbackInfo);
|
||||
if (!hasForcedFallback) {
|
||||
BuildFallbackProbeFormatInfo(requestedInternalFormat, driverOptions | targetOptions, false,
|
||||
if (renderTargetOptions) {
|
||||
// Folded into the forced options only when a forced fallback already
|
||||
// applies, so the render-target bits never *create* one: ANGLE's forced
|
||||
// GL_RGB8_SNORM -> GL_RGB16F is still three-channel and still needs
|
||||
// widening, but a non-ANGLE driver must not lose its native probe.
|
||||
const Flags<PixelFormatNormalizeOptionBit> forcedProbeOptions =
|
||||
(outerHasForcedFallback || widenUnconditionally) ? forcedOptions | renderTargetOptions
|
||||
: forcedOptions;
|
||||
hasForcedFallback =
|
||||
BuildFallbackProbeFormatInfo(requestedInternalFormat, forcedProbeOptions, true,
|
||||
fallbackInfo);
|
||||
if (!hasForcedFallback) {
|
||||
BuildFallbackProbeFormatInfo(requestedInternalFormat,
|
||||
driverOptions | renderTargetOptions, false, fallbackInfo);
|
||||
}
|
||||
// HONEST STATUS OF THE FORCED PATH. A forced fallback is only ever built
|
||||
// for ANGLE (GetForcedPixelFormatNormalizeOptions returns nothing for any
|
||||
// other renderer), and it SKIPS the native probe entirely - the widened
|
||||
// format is asserted rather than measured on this device. That assertion
|
||||
// is validated on exactly one configuration, the android-angle retrace
|
||||
// golden; it is NOT covered by the headless llvmpipe suites, which take
|
||||
// the driver branch below and prove nothing about ANGLE's answers. So log
|
||||
// the choice at INFO rather than the usual MGLOG_D caveat: on any other
|
||||
// ANGLE device the device report is the only evidence there is of which
|
||||
// storage format the image really got. Once per format on the ordinary 2D
|
||||
// target - repeating it for all ten targets would bury the report.
|
||||
if (hasForcedFallback && target == TextureTarget::Texture2D &&
|
||||
(MG_Util::TextureFormatProcessor::GetApplicablePixelFormatNormalizeOptions(
|
||||
requestedInternalFormat, renderTargetOptions) &
|
||||
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)) {
|
||||
MGLOG_I("Three-channel widening (FORCED path, no native probe): %s stored as %s. "
|
||||
"Reason: %s. Device-validated on the android-angle golden only.",
|
||||
MG_Util::ConvertTextureInternalFormatToString(logicalFormat).c_str(),
|
||||
ConvertFallbackInternalFormatToString(fallbackInfo.InternalFormat).c_str(),
|
||||
fallbackInfo.Reason.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,8 +652,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
const SizeT renderbufferTargetIndex = GetRenderbufferFormatCapabilityTargetIndex();
|
||||
Bool shouldProbeFallbackRenderbuffer = outerHasForcedFallback;
|
||||
if (!outerHasForcedFallback) {
|
||||
// A renderbuffer exists only to be attached, so it needs the same three-channel
|
||||
// widening the colour-attachable texture targets get - and on the same terms: the
|
||||
// native storage is probed first, so a driver that renders to it keeps it.
|
||||
const Flags<PixelFormatNormalizeOptionBit> renderbufferOptions =
|
||||
TextureImpl::GetRenderTargetNormalizeOptions(capabilities, renderbufferTargetIndex);
|
||||
GLESProbeFormatInfo renderbufferFallbackInfo = outerFallbackInfo;
|
||||
Bool renderbufferHasForcedFallback = outerHasForcedFallback;
|
||||
if (renderbufferOptions) {
|
||||
const Flags<PixelFormatNormalizeOptionBit> forcedProbeOptions =
|
||||
outerHasForcedFallback ? forcedOptions | renderbufferOptions : forcedOptions;
|
||||
renderbufferHasForcedFallback = BuildFallbackProbeFormatInfo(
|
||||
requestedInternalFormat, forcedProbeOptions, true, renderbufferFallbackInfo);
|
||||
if (!renderbufferHasForcedFallback) {
|
||||
BuildFallbackProbeFormatInfo(requestedInternalFormat, driverOptions | renderbufferOptions,
|
||||
false, renderbufferFallbackInfo);
|
||||
}
|
||||
}
|
||||
|
||||
Bool shouldProbeFallbackRenderbuffer = renderbufferHasForcedFallback;
|
||||
if (!renderbufferHasForcedFallback) {
|
||||
const Bool nativeRenderbufferComplete =
|
||||
ProbeRenderbuffer(gl, nativeInfo.InternalFormat, logicalFormat, false, 1);
|
||||
if (nativeRenderbufferComplete) {
|
||||
@@ -633,16 +685,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
shouldProbeFallbackRenderbuffer = true;
|
||||
}
|
||||
}
|
||||
if (shouldProbeFallbackRenderbuffer && outerFallbackInfo.InternalFormat != GL_UNKNOWN_MGL &&
|
||||
ProbeRenderbuffer(gl, outerFallbackInfo.InternalFormat, logicalFormat, false, 1)) {
|
||||
if (shouldProbeFallbackRenderbuffer && renderbufferFallbackInfo.InternalFormat != GL_UNKNOWN_MGL &&
|
||||
ProbeRenderbuffer(gl, renderbufferFallbackInfo.InternalFormat, logicalFormat, false, 1)) {
|
||||
if (AddCaveatFormatCaps(cache, renderbufferTargetIndex, formatIndex,
|
||||
GetRenderbufferFeatureCaps(logicalFormat))) {
|
||||
LogGLESFormatCaveat(logicalFormat, renderbufferTargetIndex, outerFallbackInfo);
|
||||
LogGLESFormatCaveat(logicalFormat, renderbufferTargetIndex, renderbufferFallbackInfo);
|
||||
}
|
||||
const Int maxSamples =
|
||||
GetGLESFormatMaxSamples(capabilities, logicalFormat, outerFallbackInfo.ImageFormat);
|
||||
cache.SampleCounts[renderbufferTargetIndex][formatIndex] =
|
||||
ProbeRenderbufferSampleCounts(gl, outerFallbackInfo.InternalFormat, logicalFormat, maxSamples);
|
||||
GetGLESFormatMaxSamples(capabilities, logicalFormat, renderbufferFallbackInfo.ImageFormat);
|
||||
cache.SampleCounts[renderbufferTargetIndex][formatIndex] = ProbeRenderbufferSampleCounts(
|
||||
gl, renderbufferFallbackInfo.InternalFormat, logicalFormat, maxSamples);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -968,12 +1020,6 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
funcsTable.GL.GetIntegeri_v = GetIntegeri_v;
|
||||
funcsTable.GL.GetInteger64i_v = GetInteger64i_v;
|
||||
funcsTable.GL.GetProgramiv = GetProgramiv;
|
||||
funcsTable.GL.GetProgramInterfaceiv = GetProgramInterfaceiv;
|
||||
funcsTable.GL.GetProgramResourceIndex = GetProgramResourceIndex;
|
||||
funcsTable.GL.GetProgramResourceName = GetProgramResourceName;
|
||||
funcsTable.GL.GetProgramResourceiv = GetProgramResourceiv;
|
||||
funcsTable.GL.GetProgramResourceLocation = GetProgramResourceLocation;
|
||||
funcsTable.GL.GetProgramResourceLocationIndex = GetProgramResourceLocationIndex;
|
||||
funcsTable.GL.ShaderStorageBlockBinding = ShaderStorageBlockBinding;
|
||||
funcsTable.GL.Clear = Clear;
|
||||
funcsTable.GL.ClearBufferfi = ClearBufferfi;
|
||||
|
||||
@@ -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
|
||||
// instead would keep dead frontend textures alive and defeat the registry's
|
||||
// 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 {
|
||||
const SharedPtr<MG_State::GLState::ITextureObject>* slot = nullptr;
|
||||
MG_State::GLState::ITextureObject* texture = 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 Bool g_unitTextureSyncListValid = false;
|
||||
static Uint64 g_unitTextureSyncListContextId = 0;
|
||||
@@ -1197,7 +1216,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
g_unitTextureSyncListMaxUnit == maxTouchedUnit &&
|
||||
g_unitTextureSyncListContextGeneration == g_textureContextGeneration &&
|
||||
g_unitTextureSyncListEpoch == unitBindingsEpoch &&
|
||||
g_unitTextureSyncListSamplingGeneration == samplingGeneration) {
|
||||
g_unitTextureSyncListSamplingGeneration == samplingGeneration &&
|
||||
PairingsIntact(g_unitTextureSyncList)) {
|
||||
for (const auto& entry : g_unitTextureSyncList) {
|
||||
// Aggregate gate == the conjunction of the three callees' own
|
||||
// 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"
|
||||
// state; it has nothing to sync, so skip it as cheaply as the old null slot.
|
||||
if (textureObject && !MG_State::GLState::IsUndefinedDefaultTexture(textureObject.get())) {
|
||||
g_unitTextureSyncList.push_back(
|
||||
{&textureObject, SyncTextureObjectToBackend(textureObject).get()});
|
||||
g_unitTextureSyncList.push_back({&textureObject, textureObject.get(),
|
||||
SyncTextureObjectToBackend(textureObject).get()});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1254,7 +1274,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
g_fboTextureSyncListSlotVersion == fboSlotVersion &&
|
||||
g_fboTextureSyncListObjectVersion == fboObjectVersion &&
|
||||
g_fboTextureSyncListContextId == keys.contextId &&
|
||||
g_fboTextureSyncListContextGeneration == g_textureContextGeneration;
|
||||
g_fboTextureSyncListContextGeneration == g_textureContextGeneration &&
|
||||
PairingsIntact(g_fboTextureSyncList);
|
||||
if (fboListValid) {
|
||||
for (const auto& entry : g_fboTextureSyncList) {
|
||||
// Same aggregate gate as the unit list above.
|
||||
@@ -1273,8 +1294,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
if (!attachment.IsTexture()) continue;
|
||||
auto& textureObject = attachment.GetTexture();
|
||||
if (textureObject) {
|
||||
g_fboTextureSyncList.push_back(
|
||||
{&textureObject, SyncTextureObjectToBackend(textureObject).get()});
|
||||
g_fboTextureSyncList.push_back({&textureObject, textureObject.get(),
|
||||
SyncTextureObjectToBackend(textureObject).get()});
|
||||
}
|
||||
}
|
||||
g_fboTextureSyncListFbo = currentFBO.get();
|
||||
@@ -1380,7 +1401,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
@@ -1422,15 +1450,53 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
static Bool g_hasSyncedRenderState = false;
|
||||
static RenderStateParameters g_syncedRenderStateParameters;
|
||||
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
|
||||
// frontend's (desktop-GL default) disabled state down.
|
||||
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
|
||||
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
|
||||
#endif
|
||||
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();
|
||||
|
||||
@@ -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.
|
||||
if (tailSpanDirty) {
|
||||
#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) { \
|
||||
g_GLESFuncs.glEnable(cap_gl); \
|
||||
} 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
|
||||
// decode on the next fetch then leaves the value one conversion short).
|
||||
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)
|
||||
: g_GLESFuncs.glDisable(GL_FRAMEBUFFER_SRGB);
|
||||
g_syncedSrgbFramebufferWrites = srgbWrites;
|
||||
@@ -1520,7 +1587,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
const Bool restart = parameters.PrimitiveRestartFixedIndexEnabled || parameters.PrimitiveRestartEnabled;
|
||||
const Bool syncedRestart = g_syncedRenderStateParameters.PrimitiveRestartFixedIndexEnabled ||
|
||||
g_syncedRenderStateParameters.PrimitiveRestartEnabled;
|
||||
if (restart != syncedRestart) {
|
||||
if (forceFullPush || restart != syncedRestart) {
|
||||
restart ? g_GLESFuncs.glEnable(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 allDisabled = true;
|
||||
Bool anyCapDirty = false;
|
||||
Bool anyCapDirty = forceFullPush;
|
||||
|
||||
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
|
||||
Bool enabled = targetStates[i].Enabled;
|
||||
@@ -1581,7 +1648,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
s.Enabled = false;
|
||||
} else {
|
||||
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 ? g_GLESFuncs.glEnablei(GL_BLEND, i)
|
||||
: g_GLESFuncs.glDisablei(GL_BLEND, i);
|
||||
@@ -1591,7 +1658,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
Bool allFuncsSame = true;
|
||||
Bool anyFuncDirty = false;
|
||||
Bool anyFuncDirty = forceFullPush;
|
||||
const auto& first = targetStates[0];
|
||||
|
||||
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
|
||||
@@ -1630,8 +1697,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
const auto& cur = targetStates[i];
|
||||
auto& syn = syncedStates[i];
|
||||
|
||||
if (cur.SrcFactorRGB != syn.SrcFactorRGB || cur.DstFactorRGB != syn.DstFactorRGB ||
|
||||
cur.SrcFactorAlpha != syn.SrcFactorAlpha || cur.DstFactorAlpha != syn.DstFactorAlpha) {
|
||||
if (forceFullPush || cur.SrcFactorRGB != syn.SrcFactorRGB ||
|
||||
cur.DstFactorRGB != syn.DstFactorRGB ||
|
||||
cur.SrcFactorAlpha != syn.SrcFactorAlpha ||
|
||||
cur.DstFactorAlpha != syn.DstFactorAlpha) {
|
||||
syn.SrcFactorRGB = cur.SrcFactorRGB;
|
||||
syn.DstFactorRGB = cur.DstFactorRGB;
|
||||
syn.SrcFactorAlpha = cur.SrcFactorAlpha;
|
||||
@@ -1648,7 +1717,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
Bool allEquationsSame = true;
|
||||
Bool anyEquationDirty = false;
|
||||
Bool anyEquationDirty = forceFullPush;
|
||||
|
||||
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
|
||||
const auto& cur = targetStates[i];
|
||||
@@ -1679,7 +1748,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
const auto& cur = targetStates[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.AlphaEquation = cur.AlphaEquation;
|
||||
|
||||
@@ -1693,13 +1763,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
if (parameters.DepthMask != g_syncedRenderStateParameters.DepthMask) {
|
||||
if (forceFullPush || parameters.DepthMask != g_syncedRenderStateParameters.DepthMask) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1710,16 +1780,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
const StencilFaceState& synced = g_syncedRenderStateParameters.StencilStates[faceIndex];
|
||||
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) {
|
||||
g_GLESFuncs.glStencilFuncSeparate(
|
||||
glFace, MG_Util::ConvertDepthTestFuncToGLEnum(current.Func), current.Ref,
|
||||
current.ValueMask);
|
||||
}
|
||||
if (current.WriteMask != synced.WriteMask) {
|
||||
if (forceFullPush || current.WriteMask != synced.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) {
|
||||
g_GLESFuncs.glStencilOpSeparate(
|
||||
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
|
||||
// per-draw-buffer masks use the indexed glColorMaski when draw_buffers_indexed is
|
||||
// available, otherwise fall back to broadcasting draw buffer 0. Mirrors the blend block.
|
||||
if (tailSpanDirty || colorMaskWidenDirty) { // Color mask. Uniform masks use the non-indexed glColorMask
|
||||
// (works everywhere); divergent per-draw-buffer masks use the indexed glColorMaski when
|
||||
// draw_buffers_indexed is available, otherwise fall back to broadcasting draw buffer 0.
|
||||
// Mirrors the blend block.
|
||||
using FBO = MG_State::GLState::FramebufferObject;
|
||||
const auto& targetMasks = parameters.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;
|
||||
const BoolVec4 driverMask0 = driverMask(0);
|
||||
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
|
||||
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 (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()),
|
||||
ToGLBoolean(m.w()));
|
||||
} else {
|
||||
@@ -1753,19 +1843,23 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
: g_GLESFuncs.glColorMaskiEXT ? g_GLESFuncs.glColorMaskiEXT
|
||||
: g_GLESFuncs.glColorMaskiOES;
|
||||
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
|
||||
if (targetMasks[i] != syncedMasks[i]) {
|
||||
const BoolVec4& m = targetMasks[i];
|
||||
// colorMaskWidenDirty forces every slot: the previous push may have
|
||||
// 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()),
|
||||
ToGLBoolean(m.w()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
g_syncedColorMaskAlphaWidenMask = appliedWidenMask;
|
||||
}
|
||||
|
||||
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.
|
||||
if (parameters.PolygonModeFront != g_syncedRenderStateParameters.PolygonModeFront &&
|
||||
if ((forceFullPush || parameters.PolygonModeFront != g_syncedRenderStateParameters.PolygonModeFront) &&
|
||||
g_GLESCapabilities.SupportsPolygonMode) {
|
||||
const auto polygonModeFn =
|
||||
g_GLESFuncs.glPolygonModeNV ? g_GLESFuncs.glPolygonModeNV : g_GLESFuncs.glPolygonModeANGLE;
|
||||
@@ -1774,67 +1868,97 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
if (tailSpanDirty) { // Clear values
|
||||
if (parameters.ClearColor != g_syncedRenderStateParameters.ClearColor) {
|
||||
if (forceFullPush || parameters.ClearColor != g_syncedRenderStateParameters.ClearColor) {
|
||||
const FloatVec4& clearCol = parameters.ClearColor;
|
||||
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);
|
||||
}
|
||||
if (parameters.ClearStencil != g_syncedRenderStateParameters.ClearStencil) {
|
||||
if (forceFullPush || parameters.ClearStencil != g_syncedRenderStateParameters.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;
|
||||
g_GLESFuncs.glBlendColor(blendColor.x(), blendColor.y(), blendColor.z(), blendColor.w());
|
||||
}
|
||||
}
|
||||
|
||||
if (tailSpanDirty) { // Cull face mode
|
||||
if (parameters.CullFaceModeSetting != g_syncedRenderStateParameters.CullFaceModeSetting) {
|
||||
if (forceFullPush || parameters.CullFaceModeSetting != g_syncedRenderStateParameters.CullFaceModeSetting) {
|
||||
const CullFaceMode& cfm = parameters.CullFaceModeSetting;
|
||||
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;
|
||||
g_GLESFuncs.glFrontFace(MG_Util::ConvertFrontFaceModeToGLEnum(ffm));
|
||||
}
|
||||
}
|
||||
|
||||
if (tailSpanDirty) { // Scissor box
|
||||
if (parameters.ScissorBox != g_syncedRenderStateParameters.ScissorBox) {
|
||||
const IntVec4& scissorBox = parameters.ScissorBox;
|
||||
g_GLESFuncs.glScissor(scissorBox.x(), scissorBox.y(), scissorBox.z(), scissorBox.w());
|
||||
if (tailSpanDirty) { // Scissor box. Resolved and shadowed like the viewport above, and
|
||||
// for the same reason: what has to reach the driver is NOT simply the parameter
|
||||
// field. (0,0,0,0) is where RenderStateParameters::ScissorBox starts and the only
|
||||
// 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 (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));
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
g_GLESFuncs.glPolygonOffset(parameters.PolygonOffsetFactor, parameters.PolygonOffsetUnits);
|
||||
}
|
||||
}
|
||||
|
||||
if (headSpanDirty) { // Line width
|
||||
if (parameters.LineWidth != g_syncedRenderStateParameters.LineWidth) {
|
||||
if (forceFullPush || parameters.LineWidth != g_syncedRenderStateParameters.LineWidth) {
|
||||
g_GLESFuncs.glLineWidth(parameters.LineWidth);
|
||||
}
|
||||
}
|
||||
|
||||
if (headSpanDirty) { // Point size
|
||||
if (parameters.PointSize != g_syncedRenderStateParameters.PointSize) {
|
||||
if (headSpanDirty) { // Point size (GLES 1.x only - ES 2+ sets it from gl_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);
|
||||
}
|
||||
}
|
||||
|
||||
if (tailSpanDirty) { // Sample coverage
|
||||
if (parameters.SampleCoverageValue != g_syncedRenderStateParameters.SampleCoverageValue ||
|
||||
if (forceFullPush || parameters.SampleCoverageValue != g_syncedRenderStateParameters.SampleCoverageValue ||
|
||||
parameters.SampleCoverageInvert != g_syncedRenderStateParameters.SampleCoverageInvert) {
|
||||
g_GLESFuncs.glSampleCoverage(parameters.SampleCoverageValue,
|
||||
ToGLBoolean(parameters.SampleCoverageInvert));
|
||||
@@ -1842,7 +1966,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -2009,6 +2134,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
|
||||
#endif
|
||||
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(
|
||||
target == FramebufferTarget::Draw ? GL_DRAW_FRAMEBUFFER : GL_READ_FRAMEBUFFER, 0);
|
||||
return;
|
||||
@@ -2929,7 +3063,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
#endif
|
||||
TextureImpl::SyncNeccessaryTextures();
|
||||
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);
|
||||
|
||||
@@ -2967,10 +3105,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
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 ||
|
||||
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;
|
||||
g_GLESFuncs.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &clearDrawFbo);
|
||||
if (outOfRange && clearDrawFbo != 0) {
|
||||
const GLfloat value[4] = {cc.x(), cc.y(), cc.z(), cc.w()};
|
||||
if ((outOfRange || widenedColorClear) && clearDrawFbo != 0) {
|
||||
GLint maxDrawBuffers = 0;
|
||||
GLint clearedCount = 0;
|
||||
GLint firstDb = -1;
|
||||
@@ -2980,6 +3134,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
g_GLESFuncs.glGetIntegerv(GL_DRAW_BUFFER0 + static_cast<GLenum>(i), &db);
|
||||
if (i == 0) firstDb = db;
|
||||
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);
|
||||
++clearedCount;
|
||||
}
|
||||
@@ -5189,50 +5345,41 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
g_GLESFuncs.glGetProgramiv(backendProgramId, pname, params);
|
||||
}
|
||||
|
||||
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params) {
|
||||
GLuint backendProgramId = GetBackendProgramId(program);
|
||||
if (!backendProgramId) return;
|
||||
g_GLESFuncs.glGetProgramInterfaceiv(backendProgramId, programInterface, pname, params);
|
||||
}
|
||||
// NOTE the shape here, and do not "simplify" it back to GetBackendProgramId(): this entry
|
||||
// point must never be the thing that BUILDS a backend program.
|
||||
//
|
||||
// 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) {
|
||||
GLuint backendProgramId = GetBackendProgramId(program);
|
||||
if (!backendProgramId) return GL_INVALID_INDEX;
|
||||
return g_GLESFuncs.glGetProgramResourceIndex(backendProgramId, programInterface, name);
|
||||
}
|
||||
|
||||
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei* length,
|
||||
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);
|
||||
auto* backendProgramSlot = PrgramImpl::g_backendProgramObjects.Find(programObject.get());
|
||||
if (!backendProgramSlot || !*backendProgramSlot) return;
|
||||
auto& backendObj = *backendProgramSlot;
|
||||
// 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,
|
||||
storageBlockBinding);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
TextureImpl::SyncNeccessaryTextures();
|
||||
FramebufferImpl::SyncCurrentFBO();
|
||||
RenderStateImpl::SyncRenderState();
|
||||
RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR);
|
||||
|
||||
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) {
|
||||
TextureImpl::SyncNeccessaryTextures();
|
||||
FramebufferImpl::SyncCurrentFBO();
|
||||
RenderStateImpl::SyncRenderState();
|
||||
RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR);
|
||||
|
||||
// SyncCurrentFBO early-outs for the default framebuffer, so without this
|
||||
// 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).
|
||||
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) {
|
||||
TextureImpl::SyncNeccessaryTextures();
|
||||
FramebufferImpl::SyncCurrentFBO();
|
||||
RenderStateImpl::SyncRenderState();
|
||||
RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR);
|
||||
|
||||
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,
|
||||
@@ -5284,9 +5476,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
DebugImpl::OpenGLScopeMarker marker(__func__);
|
||||
#endif
|
||||
TextureImpl::SyncNeccessaryTextures();
|
||||
RenderStateImpl::SyncRenderState();
|
||||
RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR);
|
||||
|
||||
SyncAndBindFramebufferObject(framebuffer, FramebufferTarget::Draw, true);
|
||||
GLfloat widenedValue[4] = {};
|
||||
value = SubstituteWidenedClearAlpha(value, IsWidenedNamedDrawBuffer(framebuffer, buffer, drawbuffer), 1.0f,
|
||||
widenedValue);
|
||||
g_GLESFuncs.glClearBufferfv(buffer, drawbuffer, value);
|
||||
DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) {
|
||||
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__);
|
||||
#endif
|
||||
TextureImpl::SyncNeccessaryTextures();
|
||||
RenderStateImpl::SyncRenderState();
|
||||
RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR);
|
||||
|
||||
SyncAndBindFramebufferObject(framebuffer, FramebufferTarget::Draw, true);
|
||||
GLint widenedValue[4] = {};
|
||||
value = SubstituteWidenedClearAlpha(value, IsWidenedNamedDrawBuffer(framebuffer, buffer, drawbuffer),
|
||||
GLint(1), widenedValue);
|
||||
g_GLESFuncs.glClearBufferiv(buffer, drawbuffer, value);
|
||||
DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) {
|
||||
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__);
|
||||
#endif
|
||||
TextureImpl::SyncNeccessaryTextures();
|
||||
RenderStateImpl::SyncRenderState();
|
||||
RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR);
|
||||
|
||||
SyncAndBindFramebufferObject(framebuffer, FramebufferTarget::Draw, true);
|
||||
GLuint widenedValue[4] = {};
|
||||
value = SubstituteWidenedClearAlpha(value, IsWidenedNamedDrawBuffer(framebuffer, buffer, drawbuffer),
|
||||
GLuint(1), widenedValue);
|
||||
g_GLESFuncs.glClearBufferuiv(buffer, drawbuffer, value);
|
||||
DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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};
|
||||
// The bit pattern of 1.0 in a wide-read component type: what GL reports for a channel the
|
||||
// attachment's format does not have.
|
||||
static void FillWideReadOneBits(GLenum componentType, Uint8* oneBits) {
|
||||
switch (componentType) {
|
||||
case GL_UNSIGNED_BYTE:
|
||||
oneBits[0] = 0xFF;
|
||||
@@ -5656,6 +5850,35 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
default:
|
||||
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);
|
||||
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
|
||||
// (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.
|
||||
// `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,
|
||||
GLenum type, void* pixels, Bool honorPackImageParams = false,
|
||||
Bool applyFixedPointReadClamp = true) {
|
||||
GLenum type, void* pixels, Bool honorPackImageParams,
|
||||
Bool applyFixedPointReadClamp, Bool forceOpaqueAlpha) {
|
||||
ReadbackChannelMapping mapping{};
|
||||
if (!GetReadbackChannelMapping(format, mapping)) {
|
||||
return false;
|
||||
@@ -5829,6 +6059,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
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
|
||||
// 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
|
||||
@@ -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
|
||||
// it always takes the conversion path (which swaps on the CPU).
|
||||
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_INTEGER && (type == GL_UNSIGNED_INT || type == GL_INT)));
|
||||
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");
|
||||
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",
|
||||
MG_Util::ConvertGLEnumToString(format).c_str(), MG_Util::ConvertGLEnumToString(type).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");
|
||||
return;
|
||||
}
|
||||
@@ -6231,6 +6479,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// for normalized attachments), while the conversion path reads a wide format that is always
|
||||
// accepted and repacks on the CPU.
|
||||
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
|
||||
// readbacks (cube-map arrays address as arrays); 2D targets must ignore
|
||||
// them (GL 3.3 section 6.1.4).
|
||||
@@ -6276,7 +6528,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
void* sliceDst = static_cast<Uint8*>(pixels) + sliceOffset;
|
||||
if (!ReadPixelsViaFormatConversion(0, 0, size.x(), size.y(), format, type, sliceDst,
|
||||
/*honorPackImageParams=*/false,
|
||||
/*applyFixedPointReadClamp=*/false)) {
|
||||
/*applyFixedPointReadClamp=*/false, forceOpaqueAlpha)) {
|
||||
allSlicesRead = false;
|
||||
break;
|
||||
}
|
||||
@@ -6298,7 +6550,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
if (tempFBOComplete && ReadPixelsViaFormatConversion(0, 0, size.x(), size.y(), format, type, pixels,
|
||||
applyPackImageParams,
|
||||
/*applyFixedPointReadClamp=*/false)) {
|
||||
/*applyFixedPointReadClamp=*/false,
|
||||
forceOpaqueAlpha)) {
|
||||
MGLOG_D("GetTexImage: finished via client-format conversion");
|
||||
return;
|
||||
}
|
||||
@@ -6778,6 +7031,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
BufferImpl::InvalidatePixelBufferBindingCaches();
|
||||
FramebufferImpl::InvalidateFramebufferBindingCache();
|
||||
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
|
||||
// current (and dropped by the driver) is retried here.
|
||||
ApplyRequestedSwapInterval();
|
||||
|
||||
@@ -92,15 +92,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
void GetIntegeri_v(GLenum target, GLuint index, GLint* data);
|
||||
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
|
||||
void GetProgramiv(GLuint program, GLenum pname, GLint* params);
|
||||
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params);
|
||||
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei* length,
|
||||
GLchar* name);
|
||||
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
|
||||
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params);
|
||||
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding);
|
||||
void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding);
|
||||
Bool InitWindowSurface(NativeWindowType window);
|
||||
Bool InitPbufferSurface(EGLint width, EGLint height);
|
||||
Bool MakeCurrent();
|
||||
@@ -196,6 +188,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
void OnBackendContextDestroyed();
|
||||
} // namespace XfbImpl
|
||||
|
||||
namespace RenderStateImpl {
|
||||
// Pushes the frontend's render-state block to the ES driver, diffed against what was
|
||||
// last pushed.
|
||||
//
|
||||
// `forColorClear` names the CALLER, and the only thing it changes is the colour write
|
||||
// mask handed to the driver. A draw into a colour attachment the backend widened from
|
||||
// three channels to four gets that buffer's alpha channel masked OFF, so nothing can
|
||||
// move the stored alpha away from the 1.0 the application's three-channel format
|
||||
// implies (see FramebufferImpl::g_alphaWidenedDrawBufferMask). A CLEAR is how that 1.0
|
||||
// gets there in the first place, so it must be allowed to write alpha - hence the flag
|
||||
// rather than an unconditional doctoring. It is part of the sync memo, so a clear
|
||||
// followed by a draw re-pushes the mask instead of early-outing on an unchanged
|
||||
// frontend version.
|
||||
//
|
||||
// The application's own colour mask is never modified: glGet(GL_COLOR_WRITEMASK)
|
||||
// answers from the frontend state, which this function only reads.
|
||||
void SyncRenderState(Bool forColorClear = false);
|
||||
void InvalidateSyncedRenderState();
|
||||
} // namespace RenderStateImpl
|
||||
|
||||
extern MG_External::EGLFunctionsTable g_EGLFuncs;
|
||||
extern MG_External::GLESFunctionsTable g_GLESFuncs;
|
||||
extern MG_External::GLESCapabilities g_GLESCapabilities;
|
||||
|
||||
@@ -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,
|
||||
const IntVec3& texelSize,
|
||||
const void* data,
|
||||
@@ -1914,6 +2062,39 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return convertedData.data();
|
||||
}
|
||||
|
||||
// The two shadow -> upload conversions a fallback storage format can need, in order:
|
||||
// the component type first (SNORM/UNORM shadows into the float the fallback stores), then
|
||||
// the component count (three-channel client data into a four-channel widened render
|
||||
// target). They compose: GL_RGB8_SNORM on a driver with no renderable three-channel
|
||||
// format becomes GL_RGBA16F, so its Int8x3 shadow is converted to Float x3 and then
|
||||
// repacked as Float x4 with alpha 1.0.
|
||||
//
|
||||
// Both scratch buffers belong to the caller so they outlive the returned pointer; the
|
||||
// return value is `data` itself whenever neither conversion applies, which is what the
|
||||
// sub-rect upload fast path tests for.
|
||||
static const void* PrepareFallbackUpload(TextureInternalFormat format, TextureTarget target,
|
||||
const IntVec3& texelSize, const void* data, SizeT byteSize,
|
||||
GLenum uploadType, Vector<Float>& convertedData,
|
||||
Vector<Uint8>& widenedData) {
|
||||
const void* uploadData =
|
||||
PrepareNormFloatFallbackUpload(format, texelSize, data, byteSize, uploadType, convertedData);
|
||||
// The component-count switch first: it rules out every format that cannot be widened
|
||||
// (which is nearly all of them, including GL_RGBA8) without touching the capability
|
||||
// cache, so an ordinary atlas upload does not pay for a per-level cache lookup.
|
||||
const Uint componentCount = GetWidenableClientComponentCount(format);
|
||||
if (componentCount == 0 || !TextureImpl::BackendTextureFormatAddsAlpha(format, target)) {
|
||||
return uploadData;
|
||||
}
|
||||
// The type conversion above rewrites the level into `convertedData` at four bytes per
|
||||
// component while keeping the component count, so the widening's source size is that
|
||||
// buffer's, not the shadow's.
|
||||
const SizeT uploadByteSize = (!convertedData.empty() && uploadData == convertedData.data())
|
||||
? convertedData.size() * sizeof(Float)
|
||||
: byteSize;
|
||||
return PrepareChannelWidenedUpload(componentCount, texelSize, uploadData, uploadByteSize, uploadType,
|
||||
widenedData, IsIntegerWidenableFormat(format));
|
||||
}
|
||||
|
||||
// RGB565/RGB5_A1 shadow data is stored as 8-bit unorm; uploading it as GL_UNSIGNED_BYTE
|
||||
// leaves the 8-bit -> 5/6-bit requantization to the driver, whose rounding direction is
|
||||
// implementation-defined: Adreno rounds to nearest (lossless round trip) but Mali floors,
|
||||
@@ -2121,9 +2302,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
? textureMipmapObject->MapMipmapData(uploadTarget, level)
|
||||
: nullptr;
|
||||
Vector<Float> convertedUploadData;
|
||||
const void* uploadData = PrepareNormFloatFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType,
|
||||
convertedUploadData);
|
||||
Vector<Uint8> widenedUploadData;
|
||||
const void* uploadData = PrepareFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData,
|
||||
levelByteSize, glType, convertedUploadData, widenedUploadData);
|
||||
Vector<Uint8> packedUploadData;
|
||||
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
|
||||
uploadData, levelByteSize, &glType, packedUploadData);
|
||||
@@ -2253,9 +2435,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget);
|
||||
auto* pData = textureMipmapObject->MapMipmapData(uploadTarget, level);
|
||||
Vector<Float> convertedUploadData;
|
||||
const void* uploadData = PrepareNormFloatFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType,
|
||||
convertedUploadData);
|
||||
Vector<Uint8> widenedUploadData;
|
||||
const void* uploadData = PrepareFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData,
|
||||
levelByteSize, glType, convertedUploadData, widenedUploadData);
|
||||
Vector<Uint8> packedUploadData;
|
||||
uploadData =
|
||||
PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
|
||||
@@ -2312,9 +2495,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
? textureMipmapObject->MapMipmapData(uploadTarget, level)
|
||||
: nullptr;
|
||||
Vector<Float> convertedUploadData;
|
||||
const void* uploadData = PrepareNormFloatFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType,
|
||||
convertedUploadData);
|
||||
Vector<Uint8> widenedUploadData;
|
||||
const void* uploadData = PrepareFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData,
|
||||
levelByteSize, glType, convertedUploadData, widenedUploadData);
|
||||
Vector<Uint8> packedUploadData;
|
||||
uploadData =
|
||||
PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
|
||||
@@ -2422,9 +2606,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
auto texelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, level);
|
||||
const void* mipData = textureMipmapObject->MapMipmapData(uploadTarget, level);
|
||||
Vector<Float> convertedUploadData;
|
||||
const void* uploadData = PrepareNormFloatFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), texelSize, mipData, byteSize, glType,
|
||||
convertedUploadData);
|
||||
Vector<Uint8> widenedUploadData;
|
||||
const void* uploadData = PrepareFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), targetInternal, texelSize, mipData, byteSize,
|
||||
glType, convertedUploadData, widenedUploadData);
|
||||
Vector<Uint8> packedUploadData;
|
||||
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), texelSize,
|
||||
uploadData, byteSize, &glType, packedUploadData);
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
// A three-channel format widened to four for a multisample target (see
|
||||
// A three-channel format widened to four to keep the image colour-renderable (see
|
||||
// NormalizePixelFormat) gains an alpha channel the frontend format does not have, and
|
||||
// whatever the draw that filled it wrote there is not what GL would report: a format
|
||||
// without alpha reads back as 1.0. Answer the ALPHA swizzle source with ONE so the
|
||||
@@ -3139,6 +3324,113 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The colour attachment glReadPixels/glGetTexImage would read from, or nullptr when the
|
||||
// read buffer names no colour attachment at all.
|
||||
static const MG_State::GLState::FramebufferAttachmentObject* GetReadColorAttachment() {
|
||||
const auto& readFBO =
|
||||
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject();
|
||||
if (!readFBO) {
|
||||
return nullptr;
|
||||
}
|
||||
const auto readBuffer = readFBO->GetReadBuffer();
|
||||
if (readBuffer < FramebufferAttachmentType::Color0 || readBuffer > FramebufferAttachmentType::Color31) {
|
||||
return nullptr;
|
||||
}
|
||||
return &readFBO->GetAttachment(readBuffer);
|
||||
}
|
||||
|
||||
Bool IsAlphaWidenedColorAttachment(
|
||||
const MG_State::GLState::FramebufferAttachmentObject& attachmentObject) {
|
||||
if (attachmentObject.IsTexture()) {
|
||||
const auto& textureObject = attachmentObject.GetTexture();
|
||||
return textureObject && TextureImpl::BackendTextureFormatAddsAlpha(textureObject->GetFormat(),
|
||||
textureObject->GetTarget());
|
||||
}
|
||||
if (attachmentObject.IsRenderbuffer()) {
|
||||
const auto& renderbufferObject = attachmentObject.GetRenderbuffer();
|
||||
return renderbufferObject &&
|
||||
TextureImpl::BackendRenderbufferFormatAddsAlpha(renderbufferObject->GetInternalFormat());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Uint32 g_alphaWidenedDrawBufferMask = 0;
|
||||
Uint32 g_integerColorDrawBufferMask = 0;
|
||||
|
||||
static Bool IsIntegerColorFormat(TextureInternalFormat format) {
|
||||
switch (format) {
|
||||
case TextureInternalFormat::R8I:
|
||||
case TextureInternalFormat::R8UI:
|
||||
case TextureInternalFormat::R16I:
|
||||
case TextureInternalFormat::R16UI:
|
||||
case TextureInternalFormat::R32I:
|
||||
case TextureInternalFormat::R32UI:
|
||||
case TextureInternalFormat::RG8I:
|
||||
case TextureInternalFormat::RG8UI:
|
||||
case TextureInternalFormat::RG16I:
|
||||
case TextureInternalFormat::RG16UI:
|
||||
case TextureInternalFormat::RG32I:
|
||||
case TextureInternalFormat::RG32UI:
|
||||
case TextureInternalFormat::RGB8I:
|
||||
case TextureInternalFormat::RGB8UI:
|
||||
case TextureInternalFormat::RGB16I:
|
||||
case TextureInternalFormat::RGB16UI:
|
||||
case TextureInternalFormat::RGB32I:
|
||||
case TextureInternalFormat::RGB32UI:
|
||||
case TextureInternalFormat::RGBA8I:
|
||||
case TextureInternalFormat::RGBA8UI:
|
||||
case TextureInternalFormat::RGBA16I:
|
||||
case TextureInternalFormat::RGBA16UI:
|
||||
case TextureInternalFormat::RGBA32I:
|
||||
case TextureInternalFormat::RGBA32UI:
|
||||
case TextureInternalFormat::RGB10A2UI:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Bool IsIntegerColorAttachment(
|
||||
const MG_State::GLState::FramebufferAttachmentObject& attachmentObject) {
|
||||
if (attachmentObject.IsTexture()) {
|
||||
const auto& textureObject = attachmentObject.GetTexture();
|
||||
return textureObject && IsIntegerColorFormat(textureObject->GetFormat());
|
||||
}
|
||||
if (attachmentObject.IsRenderbuffer()) {
|
||||
const auto& renderbufferObject = attachmentObject.GetRenderbuffer();
|
||||
return renderbufferObject && IsIntegerColorFormat(renderbufferObject->GetInternalFormat());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Uint32 ComputeAlphaWidenedDrawBufferMask(const MG_State::GLState::FramebufferObject& fbo) {
|
||||
using FBO = MG_State::GLState::FramebufferObject;
|
||||
const auto& drawBuffers = fbo.GetDrawBuffers();
|
||||
Uint32 mask = 0;
|
||||
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS && i < 32; ++i) {
|
||||
const auto frontendBuf = drawBuffers[i];
|
||||
if (frontendBuf < FramebufferAttachmentType::Color0 ||
|
||||
frontendBuf > FramebufferAttachmentType::Color31) {
|
||||
continue;
|
||||
}
|
||||
if (IsAlphaWidenedColorAttachment(fbo.GetAttachment(frontendBuf))) {
|
||||
mask |= (1u << i);
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
// The read attachment's storage carries an alpha channel its frontend format does not
|
||||
// (the three-channel colour-renderable widening). GL answers such a read with 1.0, but
|
||||
// the storage holds whatever the draw wrote there, so the readback has to overwrite it.
|
||||
Bool IsAlphaWidenedFallbackReadAttachment() {
|
||||
const auto* attachmentObject = GetReadColorAttachment();
|
||||
if (attachmentObject == nullptr) {
|
||||
return false;
|
||||
}
|
||||
return IsAlphaWidenedColorAttachment(*attachmentObject);
|
||||
}
|
||||
|
||||
Bool IsFixedPointFallbackReadAttachment() {
|
||||
const auto& readFBO =
|
||||
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject();
|
||||
@@ -3344,6 +3636,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
if (asTarget == FramebufferTarget::Draw) {
|
||||
Uint32 snormClampOutputMask = 0;
|
||||
Uint32 unormClampOutputMask = 0;
|
||||
Uint32 alphaWidenedMask = 0;
|
||||
Uint32 integerColorMask = 0;
|
||||
for (Uint i = 0; i < FramebufferObject::MAX_DRAW_BUFFERS && i < 32; ++i) {
|
||||
const auto frontendBuf = stateDrawBuffers[i];
|
||||
if (frontendBuf < FramebufferAttachmentType::Color0 ||
|
||||
@@ -3356,9 +3650,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
} else if (IsUnormFallbackAttachment(attachmentObject)) {
|
||||
unormClampOutputMask |= (1u << i);
|
||||
}
|
||||
// Independent of the two above: a widened attachment can be SNORM
|
||||
// (GL_RGB8_SNORM -> GL_RGBA16F, which also clamps) or not (GL_SRGB8 ->
|
||||
// GL_SRGB8_ALPHA8, which does not), so it gets its own bit rather than an
|
||||
// `else if` branch of theirs.
|
||||
if (IsAlphaWidenedColorAttachment(attachmentObject)) {
|
||||
alphaWidenedMask |= (1u << i);
|
||||
}
|
||||
if (IsIntegerColorAttachment(attachmentObject)) {
|
||||
integerColorMask |= (1u << i);
|
||||
}
|
||||
}
|
||||
PrgramImpl::g_snormFallbackClampOutputMask = snormClampOutputMask;
|
||||
PrgramImpl::g_unormFallbackClampOutputMask = unormClampOutputMask;
|
||||
g_alphaWidenedDrawBufferMask = alphaWidenedMask;
|
||||
g_integerColorDrawBufferMask = integerColorMask;
|
||||
}
|
||||
|
||||
// 2. Remap read buffer. glReadBuffer writes the READ-bound FBO's state, so
|
||||
@@ -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(
|
||||
const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject) {
|
||||
#ifdef TRACY_ENABLE
|
||||
@@ -3846,9 +4180,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
|
||||
if (attachedCount > 0) {
|
||||
Vector<GLuint> attachedShaders(attachedCount);
|
||||
GLsizei actualCount;
|
||||
// Every GL out-param in this function is pre-initialized and every count is
|
||||
// re-clamped after the query. A driver that returns without writing the
|
||||
// out-param (no current context, a lost context, a stubbed entry point) would
|
||||
// otherwise leak an uninitialized stack value straight into a container size
|
||||
// or a loop bound - which is exactly how this path used to throw
|
||||
// length_error out of a Vector fill-ctor.
|
||||
GLsizei actualCount = 0;
|
||||
g_GLESFuncs.glGetAttachedShaders(m_backendProgramId, attachedCount, &actualCount,
|
||||
attachedShaders.data());
|
||||
actualCount = std::clamp<GLsizei>(actualCount, 0, static_cast<GLsizei>(attachedShaders.size()));
|
||||
MGLOG_D("Detaching %d existing shaders from program %u", actualCount, m_backendProgramId);
|
||||
|
||||
for (GLsizei i = 0; i < actualCount; ++i) {
|
||||
@@ -3986,13 +4327,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
g_GLESFuncs.glShaderSource(backendShaderId, 1, &sourceCStr, nullptr);
|
||||
g_GLESFuncs.glCompileShader(backendShaderId);
|
||||
|
||||
GLint compileStatus;
|
||||
// GL_FALSE, not GL_TRUE: an unwritten out-param must read as "compile failed"
|
||||
// and take the diagnostic path, never as a silent success that attaches an
|
||||
// uncompiled shader.
|
||||
GLint compileStatus = GL_FALSE;
|
||||
g_GLESFuncs.glGetShaderiv(backendShaderId, GL_COMPILE_STATUS, &compileStatus);
|
||||
if (compileStatus == GL_FALSE) {
|
||||
GLint logLength;
|
||||
GLint logLength = 0;
|
||||
g_GLESFuncs.glGetShaderiv(backendShaderId, GL_INFO_LOG_LENGTH, &logLength);
|
||||
Vector<GLchar> log(logLength);
|
||||
if (logLength < 0) logLength = 0;
|
||||
// +1 and zero-filled: GL_INFO_LOG_LENGTH already counts the terminator,
|
||||
// but a driver that reports 0 (or fails the query) must still leave
|
||||
// log.data() a readable empty C string for the %s below.
|
||||
Vector<GLchar> log(static_cast<SizeT>(logLength) + 1, '\0');
|
||||
g_GLESFuncs.glGetShaderInfoLog(backendShaderId, logLength, nullptr, log.data());
|
||||
log.back() = '\0';
|
||||
MGLOG_E("Shader compilation failed for backend ID %u: %s", backendShaderId, log.data());
|
||||
m_backendProgramUsable = false;
|
||||
continue;
|
||||
@@ -4028,14 +4377,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
MGLOG_D("Linking program %u", m_backendProgramId);
|
||||
g_GLESFuncs.glLinkProgram(m_backendProgramId);
|
||||
|
||||
GLint linkStatus;
|
||||
GLint linkStatus = GL_FALSE;
|
||||
g_GLESFuncs.glGetProgramiv(m_backendProgramId, GL_LINK_STATUS, &linkStatus);
|
||||
m_backendProgramUsable = m_backendProgramUsable && linkStatus == GL_TRUE;
|
||||
if (linkStatus != GL_TRUE) {
|
||||
GLint logLength;
|
||||
GLint logLength = 0;
|
||||
g_GLESFuncs.glGetProgramiv(m_backendProgramId, GL_INFO_LOG_LENGTH, &logLength);
|
||||
Vector<GLchar> log(logLength);
|
||||
if (logLength < 0) logLength = 0;
|
||||
Vector<GLchar> log(static_cast<SizeT>(logLength) + 1, '\0');
|
||||
g_GLESFuncs.glGetProgramInfoLog(m_backendProgramId, logLength, nullptr, log.data());
|
||||
log.back() = '\0';
|
||||
MGLOG_E("Program %u linking failed for %u: %s", stateProgramObject->GetExternalIndex(),
|
||||
m_backendProgramId, log.data());
|
||||
} else {
|
||||
@@ -4068,6 +4419,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
CacheResourceLocations(stateProgramObject);
|
||||
// AFTER the link, because glShaderStorageBlockBinding needs the driver's linked
|
||||
// interface. This is the only place Espryt applies a rebinding: the frontend
|
||||
// record is authoritative and the glShaderStorageBlockBinding entry point itself
|
||||
// deliberately never forces a program build (see DirectGLES.cpp), so a rebinding
|
||||
// requested while no backend program existed yet arrives here instead.
|
||||
ReseedShaderStorageBlockBindings(m_backendProgramId, *stateProgramObject);
|
||||
m_syncedLinkVersion = stateProgramObject->GetLinkVersion();
|
||||
|
||||
m_isInitialized = true;
|
||||
|
||||
@@ -515,6 +515,25 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return target == TextureTarget::Texture3D || target == TextureTarget::TextureCubeMap;
|
||||
}
|
||||
|
||||
// Components per texel the frontend format's client data carries, for the three-channel
|
||||
// formats that can be widened to a four-channel colour-renderable target; 0 for everything
|
||||
// else. See PrepareChannelWidenedUpload.
|
||||
Uint GetWidenableClientComponentCount(TextureInternalFormat format);
|
||||
|
||||
// True when a widenable format's components are integer rather than normalized, which is
|
||||
// what decides the synthetic alpha's value: GL_RGB8I and GL_RGB8_SNORM are both uploaded
|
||||
// as GL_BYTE, but their 1.0 is 1 and 0x7F respectively.
|
||||
Bool IsIntegerWidenableFormat(TextureInternalFormat format);
|
||||
|
||||
// Repacks three-component client data as four components with an alpha of 1.0 in
|
||||
// `uploadType`, for a format the backend widened to keep a colour attachment renderable.
|
||||
// Returns `data` untouched when no widening applies. Pure CPU and context-free so a unit
|
||||
// test can exercise the exact packing the driver is handed; `widenedData` is the caller's
|
||||
// scratch buffer and has to outlive the returned pointer.
|
||||
const void* PrepareChannelWidenedUpload(Uint componentCount, const IntVec3& texelSize, const void* data,
|
||||
SizeT byteSize, GLenum uploadType, Vector<Uint8>& widenedData,
|
||||
Bool integerData = false);
|
||||
|
||||
struct StateTextureBasicInfo { // Used for tracking texture state changes
|
||||
TextureInternalFormat internalFormat = TextureInternalFormat::Unknown;
|
||||
SizeT width = 0;
|
||||
@@ -630,6 +649,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
SharedPtr<BackendTextureObject>& SyncTextureObjectToBackend(
|
||||
const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
|
||||
Bool imageBindableStorageRequired = false);
|
||||
// Brings every texture the next draw reads - the touched units' bindings and the draw
|
||||
// FBO's texture attachments - onto the backend, through the two borrowed-pair memos
|
||||
// documented at their definitions. Declared here so tests can drive those memos directly.
|
||||
void SyncNeccessaryTextures();
|
||||
extern Array<Array<BackendTextureObject*, (SizeT)TextureTarget::TextureTargetCount>,
|
||||
MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
|
||||
g_boundTexturesCache;
|
||||
@@ -710,6 +733,67 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// has to apply the clamp itself.
|
||||
Bool IsFixedPointFallbackReadAttachment();
|
||||
|
||||
// True when the read buffer names a three-channel attachment the backend actually stores
|
||||
// in a four-channel format (the colour-renderable widening). A format without alpha reads
|
||||
// back as 1.0, so the readback path has to overwrite the alpha the draw left behind -
|
||||
// unconditionally, since this is the format's own semantics rather than the
|
||||
// GL_CLAMP_READ_COLOR rule the clamp above implements.
|
||||
Bool IsAlphaWidenedFallbackReadAttachment();
|
||||
|
||||
// True when this attachment's storage carries an alpha channel its frontend format does
|
||||
// not (the three-channel colour-renderable widening).
|
||||
Bool IsAlphaWidenedColorAttachment(const MG_State::GLState::FramebufferAttachmentObject& attachmentObject);
|
||||
|
||||
// Bit i set = DRAW BUFFER i of `fbo` resolves to a colour attachment the backend widened
|
||||
// from three channels to four. Indexed by draw-buffer slot, not by attachment point,
|
||||
// because that is what glColorMaski / glClearBufferfv address.
|
||||
Uint32 ComputeAlphaWidenedDrawBufferMask(const MG_State::GLState::FramebufferObject& fbo);
|
||||
|
||||
// The same mask for whatever is currently bound to GL_DRAW_FRAMEBUFFER, recomputed by
|
||||
// SyncCurrentFBO (BackendFramebufferObject::SyncToBackend for the DRAW target, and reset
|
||||
// to 0 on the default framebuffer). Read by the draw/clear state sync, so it is only
|
||||
// trustworthy after SyncCurrentFBO has run in the same entry point.
|
||||
//
|
||||
// WHY IT EXISTS (the dst-alpha discipline). A widened attachment has a real alpha channel
|
||||
// the application's format does not, and GL says a missing channel reads as 1.0. Readback
|
||||
// can paper over that (ForceWideReadAlphaToOne), but GL_DST_ALPHA /
|
||||
// GL_ONE_MINUS_DST_ALPHA blending and glBlitFramebuffer read the STORED alpha inside the
|
||||
// driver where no interception is possible. So the stored alpha is kept at 1.0 instead:
|
||||
// a clear touching a widened buffer writes alpha 1.0, and every draw into it has its
|
||||
// alpha write mask forced off, so nothing can ever move it again. The application's own
|
||||
// colour mask is untouched - glGet(GL_COLOR_WRITEMASK) still reports what it set.
|
||||
extern Uint32 g_alphaWidenedDrawBufferMask;
|
||||
|
||||
// Bit i set = DRAW BUFFER i of the framebuffer bound as DRAW resolves to a colour
|
||||
// attachment with an INTEGER format. Recomputed beside the mask above and for its sake:
|
||||
// glClearBufferfv on an integer colour buffer is GL_INVALID_OPERATION, so the
|
||||
// per-draw-buffer clear route the widening needs has to stand down when one is present.
|
||||
// (glClear on an integer colour buffer is left undefined by ES in the first place, and
|
||||
// an application that wants a defined answer has to call glClearBufferuiv/iv - which does
|
||||
// carry the widened alpha substitution.)
|
||||
extern Uint32 g_integerColorDrawBufferMask;
|
||||
|
||||
// The colour a clear has to hand the driver for one draw buffer: the application's value,
|
||||
// except that a widened attachment's alpha is replaced by the 1.0 its three-channel
|
||||
// format implies. `one` is 1.0 encoded in the clear call's own component type - the
|
||||
// integer clears carry the integer 1, the float clear carries 1.0f.
|
||||
//
|
||||
// Returns `value` itself when nothing is substituted, so the ordinary path allocates and
|
||||
// copies nothing; `scratch` is the caller's buffer and has to outlive the returned
|
||||
// pointer. Free of GL state on purpose, so the substitution can be unit-tested exactly as
|
||||
// the driver sees it.
|
||||
template <typename T>
|
||||
const T* SubstituteWidenedClearAlpha(const T* value, Bool widened, T one, T (&scratch)[4]) {
|
||||
if (!widened || value == nullptr) {
|
||||
return value;
|
||||
}
|
||||
scratch[0] = value[0];
|
||||
scratch[1] = value[1];
|
||||
scratch[2] = value[2];
|
||||
scratch[3] = one;
|
||||
return scratch;
|
||||
}
|
||||
|
||||
// What SyncCurrentFBO last pushed for each target, as a (binding, object, revision)
|
||||
// triple; it re-syncs unless all three still match. Stamped by SyncCurrentFBO and
|
||||
// ForceBindCurrentFBO, cleared by InvalidateFramebufferBindingCache. The three are
|
||||
@@ -981,6 +1065,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
extern Uint g_lastUsedBackendProgramId;
|
||||
extern StateBackendObjectRegistry<MG_State::GLState::ProgramObject, BackendProgramObjectImpl>
|
||||
g_backendProgramObjects;
|
||||
|
||||
// Points one shader storage block of an ALREADY-LINKED backend program at
|
||||
// `binding`. `blockName` is the frontend interface-query spelling; the real
|
||||
// driver's own index for it is looked up here, because the transpiled ESSL's
|
||||
// block order is not the frontend's. Returns false when the block does not exist
|
||||
// on the backend program (eliminated as unused, or the driver lacks the entry
|
||||
// points), which is not an error - GL_BUFFER_BINDING is served from the frontend
|
||||
// record either way.
|
||||
Bool ApplyShaderStorageBlockBinding(Uint backendProgramId, const String& blockName, Uint binding);
|
||||
// Replays every glShaderStorageBlockBinding recorded on the program onto a backend
|
||||
// program that was just built. The frontend record is authoritative (only the
|
||||
// shader's DECLARED binding survives in the SPIR-V), so without this replay any
|
||||
// rebuild would silently revert rebound blocks. Mirrors DirectVulkan's
|
||||
// reseed-on-rebuild in BuildProgramResourceCache.
|
||||
void ReseedShaderStorageBlockBindings(Uint backendProgramId,
|
||||
const MG_State::GLState::ProgramObject& stateProgramObject);
|
||||
} // namespace PrgramImpl
|
||||
|
||||
namespace SamplerImpl {
|
||||
|
||||
@@ -61,29 +61,6 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
requestedInternalFormat, GetDriverPixelFormatNormalizeOptions() | extraOptions);
|
||||
}
|
||||
|
||||
// Multisample textures can only ever be rendered into, never uploaded to, so a fallback
|
||||
// format for them has to stay colour-renderable - a three-channel float fallback is a legal
|
||||
// ES texture format but not a legal multisample storage format. Widening to four channels
|
||||
// is safe here precisely because there is no transfer path that would have to expand
|
||||
// three-channel client data, and the alpha the draw writes for a three-channel source is
|
||||
// already the 1.0 the frontend format implies.
|
||||
Bool TargetRequiresRenderableFormat(SizeT targetIndex) {
|
||||
return targetIndex == static_cast<SizeT>(TextureTarget::Texture2DMultisample) ||
|
||||
targetIndex == static_cast<SizeT>(TextureTarget::Texture2DMultisampleArray);
|
||||
}
|
||||
|
||||
Flags<PixelFormatNormalizeOptionBit> GetRenderTargetNormalizeOptions(SizeT targetIndex) {
|
||||
Flags<PixelFormatNormalizeOptionBit> options;
|
||||
if (!TargetRequiresRenderableFormat(targetIndex)) {
|
||||
return options;
|
||||
}
|
||||
options |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
|
||||
if (!g_GLESCapabilities.SupportsRenderSnorm || !g_GLESCapabilities.SupportsNorm16Texture) {
|
||||
options |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
Bool HasCachedFormatCapability(TextureInternalFormat internalFormat,
|
||||
SizeT targetIndex,
|
||||
Bool caveat,
|
||||
@@ -141,14 +118,61 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
|
||||
Flags<PixelFormatNormalizeOptionBit> options;
|
||||
if (!pActiveBackendObject || ShouldUseCaveatFormat(internalFormat, targetIndex)) {
|
||||
options = GetRuntimeFallbackNormalizeOptions(requestedInternalFormat,
|
||||
GetRenderTargetNormalizeOptions(targetIndex));
|
||||
options = GetRuntimeFallbackNormalizeOptions(
|
||||
requestedInternalFormat,
|
||||
TextureImpl::GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
|
||||
}
|
||||
NormalizePixelFormat(requestedInternalFormat, options, outInternalFormat, outFormat, outType);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace TextureImpl {
|
||||
// Every image that can back a colour attachment needs a colour-renderable storage format,
|
||||
// and ES has no renderable three-channel format at all: a three-channel float fallback is
|
||||
// a legal ES texture but neither legal multisample storage nor a legal attachment, so
|
||||
// GL_RGB8_SNORM / GL_RGB16F / ... have to be widened to four channels for any of them.
|
||||
// This used to cover the multisample pair alone, on the grounds that only those can never
|
||||
// be uploaded to; the transfer paths now expand three-channel client data themselves
|
||||
// (Managers.cpp PrepareFallbackUpload) and hide the added alpha again on sample and
|
||||
// readback, so the same substitution is available everywhere.
|
||||
//
|
||||
// The widening only ever *happens* where the driver refuses the native form (see
|
||||
// PopulateFormatCapabilitiesImpl: outside multisample storage it rides the driver branch,
|
||||
// behind the native probe), so a driver that does render to a three-channel image keeps
|
||||
// allocating it byte for byte.
|
||||
//
|
||||
// Do NOT read that as "nothing changes off-device". Measured on Mesa 26.1.6 llvmpipe
|
||||
// (the headless CI driver), an ES 3.2 GL_TEXTURE_2D colour attachment is COMPLETE for
|
||||
// GL_RGB8 and GL_RGB16F but INCOMPLETE_ATTACHMENT for GL_RGB8_SNORM, GL_SRGB8 and every
|
||||
// RGB integer format, and UNSUPPORTED for GL_RGB32F. Those eight formats therefore DO
|
||||
// take the widened path on llvmpipe, which is where the retrace fixtures and the glcts
|
||||
// green suites run - the substitution is driver-conditional, not desktop-exempt.
|
||||
//
|
||||
// A buffer texture is the one image that can never be an attachment; its storage is the
|
||||
// buffer object's, and widening it would misdescribe the application's data.
|
||||
Bool TargetRequiresRenderableFormat(SizeT targetIndex) {
|
||||
if (targetIndex >= kFormatCapabilityTargetCount) {
|
||||
return false;
|
||||
}
|
||||
if (targetIndex == kFormatCapabilityRenderbufferTargetIndex) {
|
||||
return true;
|
||||
}
|
||||
return static_cast<TextureTarget>(targetIndex) != TextureTarget::TextureBuffer;
|
||||
}
|
||||
|
||||
Flags<PixelFormatNormalizeOptionBit> GetRenderTargetNormalizeOptions(
|
||||
const MG_External::GLESCapabilities& capabilities, SizeT targetIndex) {
|
||||
Flags<PixelFormatNormalizeOptionBit> options;
|
||||
if (!TargetRequiresRenderableFormat(targetIndex)) {
|
||||
return options;
|
||||
}
|
||||
options |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
|
||||
if (!capabilities.SupportsRenderSnorm || !capabilities.SupportsNorm16Texture) {
|
||||
options |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
|
||||
GLenum* outFormat, GLenum* outType, TextureTarget target) {
|
||||
#ifdef TRACY_ENABLE
|
||||
@@ -178,20 +202,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return ShouldUseCaveatFormat(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
|
||||
}
|
||||
|
||||
namespace {
|
||||
Bool BackendFormatAddsAlpha(TextureInternalFormat internalFormat, SizeT targetIndex) {
|
||||
if (!TargetRequiresRenderableFormat(targetIndex)) {
|
||||
return false;
|
||||
}
|
||||
if (pActiveBackendObject && !ShouldUseCaveatFormat(internalFormat, targetIndex)) {
|
||||
return false;
|
||||
}
|
||||
const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
|
||||
const Flags<PixelFormatNormalizeOptionBit> options = GetRuntimeFallbackNormalizeOptions(
|
||||
requestedInternalFormat, GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
|
||||
return static_cast<Bool>(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target) {
|
||||
const SizeT targetIndex =
|
||||
target == TextureTarget::Unknown ? kFormatCapabilityTargetCount : GetFormatCapabilityTargetIndex(target);
|
||||
if (!TargetRequiresRenderableFormat(targetIndex)) {
|
||||
return false;
|
||||
}
|
||||
if (pActiveBackendObject && !ShouldUseCaveatFormat(internalFormat, targetIndex)) {
|
||||
return false;
|
||||
}
|
||||
const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
|
||||
const Flags<PixelFormatNormalizeOptionBit> options =
|
||||
GetRuntimeFallbackNormalizeOptions(requestedInternalFormat,
|
||||
GetRenderTargetNormalizeOptions(targetIndex));
|
||||
return static_cast<Bool>(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget);
|
||||
return BackendFormatAddsAlpha(internalFormat, targetIndex);
|
||||
}
|
||||
|
||||
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat) {
|
||||
return BackendFormatAddsAlpha(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
|
||||
}
|
||||
} // namespace TextureImpl
|
||||
namespace PrgramImpl {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
|
||||
#include <MG_Util/Texture/TextureFormatProcessor.h>
|
||||
|
||||
namespace MobileGL::MG_Backend::DirectGLES {
|
||||
namespace DebugImpl {
|
||||
@@ -34,6 +36,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
} // namespace VertexArrayImpl
|
||||
|
||||
namespace TextureImpl {
|
||||
// Whether images on this format-capability target can back a colour attachment, and so
|
||||
// need a colour-renderable storage format even when the frontend asked for a
|
||||
// three-channel one ES never renders to. Shared by the capability probe (which passes the
|
||||
// capabilities it has just queried, before the globals are published) and by the
|
||||
// allocation path (which reads the active backend's), so the format the cache was probed
|
||||
// with is always the format the image is created with.
|
||||
Bool TargetRequiresRenderableFormat(SizeT targetIndex);
|
||||
Flags<PixelFormatNormalizeOptionBit> GetRenderTargetNormalizeOptions(
|
||||
const MG_External::GLESCapabilities& capabilities, SizeT targetIndex);
|
||||
|
||||
void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
|
||||
GLenum* outFormat, GLenum* outType,
|
||||
TextureTarget target = TextureTarget::Unknown);
|
||||
@@ -41,10 +53,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
GLenum* outFormat, GLenum* outType);
|
||||
Bool ShouldUseCaveatTextureFormat(TextureInternalFormat internalFormat, TextureTarget target);
|
||||
|
||||
// True when the format the texture is actually created with has an alpha channel the
|
||||
// frontend format does not (the three-channel multisample widening). GL reads such a
|
||||
// channel back as 1.0, so any swizzle source of ALPHA has to be answered with ONE.
|
||||
// True when the format the image is actually created with has an alpha channel the
|
||||
// frontend format does not (the three-channel colour-renderable widening). GL reads such
|
||||
// a channel back as 1.0, so any swizzle source of ALPHA has to be answered with ONE and
|
||||
// any readback of the image has to overwrite the alpha the draw happened to leave there.
|
||||
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target);
|
||||
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat);
|
||||
Bool ShouldUseCaveatRenderbufferFormat(TextureInternalFormat internalFormat);
|
||||
} // namespace TextureImpl
|
||||
|
||||
|
||||
@@ -617,12 +617,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
funcsTable.GL.GetIntegeri_v = GetIntegeri_v;
|
||||
funcsTable.GL.GetInteger64i_v = GetInteger64i_v;
|
||||
funcsTable.GL.GetProgramiv = GetProgramiv;
|
||||
funcsTable.GL.GetProgramInterfaceiv = GetProgramInterfaceiv;
|
||||
funcsTable.GL.GetProgramResourceIndex = GetProgramResourceIndex;
|
||||
funcsTable.GL.GetProgramResourceName = GetProgramResourceName;
|
||||
funcsTable.GL.GetProgramResourceiv = GetProgramResourceiv;
|
||||
funcsTable.GL.GetProgramResourceLocation = GetProgramResourceLocation;
|
||||
funcsTable.GL.GetProgramResourceLocationIndex = GetProgramResourceLocationIndex;
|
||||
funcsTable.GL.ShaderStorageBlockBinding = ShaderStorageBlockBinding;
|
||||
funcsTable.GL.FenceSync = FenceSync;
|
||||
funcsTable.GL.ClientWaitSync = ClientWaitSync;
|
||||
|
||||
@@ -232,6 +232,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
StorageBlockResource block{};
|
||||
block.name = blockName;
|
||||
block.binding = binding->binding;
|
||||
// glShaderStorageBlockBinding survives every rebuild of this cache: the
|
||||
// authoritative record of a rebound block lives on the program (it is what
|
||||
// GL_BUFFER_BINDING reports), and only the shader's declared binding is
|
||||
// recoverable from the SPIR-V. Without this, any unrelated state-version
|
||||
// bump would silently revert the block to its declared binding.
|
||||
const Int rebound = program.GetShaderStorageBlockBindingOverride(blockName);
|
||||
if (rebound >= 0) block.binding = static_cast<Uint32>(rebound);
|
||||
block.dataSize = static_cast<GLint>(binding->block.size);
|
||||
const GLuint blockIndex = static_cast<GLuint>(cache.storageBlocks.size());
|
||||
AddBufferVariablesRecursive(binding->block, blockName, blockIndex, cache.bufferVariables,
|
||||
@@ -256,18 +263,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
return programObject.get();
|
||||
}
|
||||
|
||||
void CopyResourceName(const String& source, GLsizei bufSize, GLsizei* length, GLchar* name) {
|
||||
const GLsizei writtenLength = static_cast<GLsizei>(source.size());
|
||||
if (length) {
|
||||
*length = writtenLength;
|
||||
}
|
||||
if (name && bufSize > 0) {
|
||||
const GLsizei copyLength = std::min<GLsizei>(bufSize - 1, writtenLength);
|
||||
std::memcpy(name, source.data(), static_cast<SizeT>(copyLength));
|
||||
name[copyLength] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
const Uint8* ResolveIndirectCommandBytes(const void* indirect, SizeT requiredBytes, const char* label) {
|
||||
auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();
|
||||
if (drawBuffer) {
|
||||
@@ -288,100 +283,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
return reinterpret_cast<const Uint8*>(indirect);
|
||||
}
|
||||
|
||||
Vector<GLuint> GetUniformBlockActiveVariables(const MG_State::GLState::ProgramObject& program,
|
||||
GLuint blockIndex) {
|
||||
Vector<GLuint> activeVariables;
|
||||
const Uint uniformCount = program.GetUniformCount();
|
||||
activeVariables.reserve(uniformCount);
|
||||
for (Uint uniformIndex = 0; uniformIndex < uniformCount; ++uniformIndex) {
|
||||
if (program.GetActiveUniformBlockIndex(uniformIndex) == static_cast<Int>(blockIndex)) {
|
||||
activeVariables.push_back(uniformIndex);
|
||||
}
|
||||
}
|
||||
return activeVariables;
|
||||
}
|
||||
|
||||
GLuint FindProgramInputIndex(const MG_State::GLState::ProgramObject& program, const String& name) {
|
||||
const Int activeCount = program.GetActiveAttributesCount();
|
||||
for (Int index = 0; index < activeCount; ++index) {
|
||||
if (program.GetActiveAttribName(index) == name) {
|
||||
return static_cast<GLuint>(index);
|
||||
}
|
||||
}
|
||||
return GL_INVALID_INDEX;
|
||||
}
|
||||
|
||||
GLuint FindProgramOutputIndex(const MG_State::GLState::ProgramObject& program, const String& name) {
|
||||
const Int activeCount = program.GetActiveFragmentOutputCount();
|
||||
for (Int index = 0; index < activeCount; ++index) {
|
||||
if (program.GetActiveFragmentOutputName(index) == name) {
|
||||
return static_cast<GLuint>(index);
|
||||
}
|
||||
}
|
||||
return GL_INVALID_INDEX;
|
||||
}
|
||||
|
||||
GLint GetProgramOutputLocation(const MG_State::GLState::ProgramObject& program, const String& name) {
|
||||
const Int activeCount = program.GetActiveFragmentOutputCount();
|
||||
for (Int index = 0; index < activeCount; ++index) {
|
||||
if (program.GetActiveFragmentOutputName(index) == name) {
|
||||
return program.GetFragmentOutputLocation(index);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
GLint GetProgramResourceActiveCount(const MG_State::GLState::ProgramObject& program, GLenum programInterface,
|
||||
const ProgramResourceCache& cache) {
|
||||
switch (programInterface) {
|
||||
case GL_SHADER_STORAGE_BLOCK:
|
||||
return static_cast<GLint>(cache.storageBlocks.size());
|
||||
case GL_BUFFER_VARIABLE:
|
||||
return static_cast<GLint>(cache.bufferVariables.size());
|
||||
case GL_UNIFORM_BLOCK:
|
||||
return program.GetActiveUniformBlocksCount();
|
||||
case GL_UNIFORM:
|
||||
return static_cast<GLint>(program.GetUniformCount());
|
||||
case GL_PROGRAM_INPUT:
|
||||
return program.GetActiveAttributesCount();
|
||||
case GL_PROGRAM_OUTPUT:
|
||||
return program.GetActiveFragmentOutputCount();
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
GLint GetProgramResourceMaxNameLength(const MG_State::GLState::ProgramObject& program, GLenum programInterface,
|
||||
const ProgramResourceCache& cache) {
|
||||
switch (programInterface) {
|
||||
case GL_SHADER_STORAGE_BLOCK: {
|
||||
SizeT maxLength = 0;
|
||||
for (const auto& block : cache.storageBlocks) maxLength = std::max(maxLength, block.name.size() + 1);
|
||||
return static_cast<GLint>(maxLength);
|
||||
}
|
||||
case GL_BUFFER_VARIABLE: {
|
||||
SizeT maxLength = 0;
|
||||
for (const auto& var : cache.bufferVariables) maxLength = std::max(maxLength, var.name.size() + 1);
|
||||
return static_cast<GLint>(maxLength);
|
||||
}
|
||||
case GL_UNIFORM_BLOCK:
|
||||
return program.GetActiveUniformBlocksMaxNameLength() + 1;
|
||||
case GL_UNIFORM:
|
||||
return program.GetUniformMaxLength() + 1;
|
||||
case GL_PROGRAM_INPUT:
|
||||
return program.GetActiveAttributesMaxLength() + 1;
|
||||
case GL_PROGRAM_OUTPUT: {
|
||||
SizeT maxLength = 0;
|
||||
const Int activeCount = program.GetActiveFragmentOutputCount();
|
||||
for (Int index = 0; index < activeCount; ++index) {
|
||||
maxLength = std::max(maxLength, program.GetActiveFragmentOutputName(index).size() + 1);
|
||||
}
|
||||
return static_cast<GLint>(maxLength);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void ClearProgramResourceCaches() {
|
||||
@@ -395,11 +296,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
|
||||
GLuint GetShaderStorageBlockIndex(const MG_State::GLState::ProgramObject& program, const String& name) {
|
||||
auto& cache = GetProgramResourceCache(program);
|
||||
const auto it = std::find_if(cache.storageBlocks.begin(), cache.storageBlocks.end(),
|
||||
[&](const StorageBlockResource& block) { return block.name == name; });
|
||||
return it == cache.storageBlocks.end()
|
||||
? GL_INVALID_INDEX
|
||||
: static_cast<GLuint>(std::distance(cache.storageBlocks.begin(), it));
|
||||
auto find = [&cache](const String& key) {
|
||||
return std::find_if(cache.storageBlocks.begin(), cache.storageBlocks.end(),
|
||||
[&](const StorageBlockResource& block) { return block.name == key; });
|
||||
};
|
||||
auto it = find(name);
|
||||
if (it == cache.storageBlocks.end()) {
|
||||
// Cache names are normalized (NormalizeDescriptorName drops the array suffix), so
|
||||
// an arrayed block that GL enumerates per element - "B[0]", "B[1]" - is one entry
|
||||
// here, spelled "B". Retry against the bare name before giving up.
|
||||
const auto bracket = name.rfind('[');
|
||||
if (bracket == String::npos || name.empty() || name.back() != ']') return GL_INVALID_INDEX;
|
||||
it = find(name.substr(0, bracket));
|
||||
if (it == cache.storageBlocks.end()) return GL_INVALID_INDEX;
|
||||
}
|
||||
return static_cast<GLuint>(std::distance(cache.storageBlocks.begin(), it));
|
||||
}
|
||||
|
||||
GLuint GetShaderStorageBlockBinding(const MG_State::GLState::ProgramObject& program, GLuint blockIndex) {
|
||||
@@ -874,357 +785,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
}
|
||||
}
|
||||
|
||||
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params) {
|
||||
if (!params) return;
|
||||
void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding) {
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject) return;
|
||||
auto& cache = GetProgramResourceCache(*programObject);
|
||||
switch (pname) {
|
||||
case GL_ACTIVE_RESOURCES:
|
||||
*params = GetProgramResourceActiveCount(*programObject, programInterface, cache);
|
||||
return;
|
||||
case GL_MAX_NAME_LENGTH:
|
||||
*params = GetProgramResourceMaxNameLength(*programObject, programInterface, cache);
|
||||
return;
|
||||
case GL_MAX_NUM_ACTIVE_VARIABLES:
|
||||
if (programInterface == GL_SHADER_STORAGE_BLOCK) {
|
||||
SizeT maxCount = 0;
|
||||
for (const auto& block : cache.storageBlocks) {
|
||||
maxCount = std::max(maxCount, block.activeVariables.size());
|
||||
}
|
||||
*params = static_cast<GLint>(maxCount);
|
||||
} else if (programInterface == GL_UNIFORM_BLOCK) {
|
||||
GLint maxCount = 0;
|
||||
const Int activeBlocks = programObject->GetActiveUniformBlocksCount();
|
||||
for (Int index = 0; index < activeBlocks; ++index) {
|
||||
maxCount = std::max(maxCount, programObject->GetUniformBlockActiveUniformCount(index));
|
||||
}
|
||||
*params = maxCount;
|
||||
} else {
|
||||
*params = 0;
|
||||
}
|
||||
return;
|
||||
default:
|
||||
*params = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
if (!name) return GL_INVALID_INDEX;
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject) return GL_INVALID_INDEX;
|
||||
auto& cache = GetProgramResourceCache(*programObject);
|
||||
const String resourceName = name;
|
||||
if (programInterface == GL_SHADER_STORAGE_BLOCK) {
|
||||
return GetShaderStorageBlockIndex(*programObject, name);
|
||||
}
|
||||
if (programInterface == GL_BUFFER_VARIABLE) {
|
||||
const auto it = std::find_if(cache.bufferVariables.begin(), cache.bufferVariables.end(),
|
||||
[&](const BufferVariableResource& var) { return var.name == resourceName; });
|
||||
return it == cache.bufferVariables.end()
|
||||
? GL_INVALID_INDEX
|
||||
: static_cast<GLuint>(std::distance(cache.bufferVariables.begin(), it));
|
||||
}
|
||||
if (programInterface == GL_UNIFORM_BLOCK) {
|
||||
return programObject->GetUniformBlockIndex(name);
|
||||
}
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
const Int activeUniformIndex = programObject->GetActiveUniformIndex(resourceName);
|
||||
return activeUniformIndex >= 0 ? static_cast<GLuint>(activeUniformIndex) : GL_INVALID_INDEX;
|
||||
}
|
||||
if (programInterface == GL_PROGRAM_INPUT) {
|
||||
return FindProgramInputIndex(*programObject, resourceName);
|
||||
}
|
||||
if (programInterface == GL_PROGRAM_OUTPUT) {
|
||||
return FindProgramOutputIndex(*programObject, resourceName);
|
||||
}
|
||||
return GL_INVALID_INDEX;
|
||||
}
|
||||
|
||||
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize,
|
||||
GLsizei* length, GLchar* name) {
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject) return;
|
||||
auto& cache = GetProgramResourceCache(*programObject);
|
||||
if (programInterface == GL_SHADER_STORAGE_BLOCK && index < cache.storageBlocks.size()) {
|
||||
CopyResourceName(cache.storageBlocks[index].name, bufSize, length, name);
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_BUFFER_VARIABLE && index < cache.bufferVariables.size()) {
|
||||
CopyResourceName(cache.bufferVariables[index].name, bufSize, length, name);
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_UNIFORM_BLOCK && programObject->IsActiveUniformBlock(index)) {
|
||||
CopyResourceName(programObject->GetUniformBlockName(index), bufSize, length, name);
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_UNIFORM && index < programObject->GetUniformCount()) {
|
||||
CopyResourceName(programObject->GetActiveUniformName(index), bufSize, length, name);
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_PROGRAM_INPUT && index < static_cast<GLuint>(programObject->GetActiveAttributesCount())) {
|
||||
CopyResourceName(programObject->GetActiveAttribName(index), bufSize, length, name);
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_PROGRAM_OUTPUT &&
|
||||
index < static_cast<GLuint>(programObject->GetActiveFragmentOutputCount())) {
|
||||
CopyResourceName(programObject->GetActiveFragmentOutputName(index), bufSize, length, name);
|
||||
return;
|
||||
}
|
||||
if (length) *length = 0;
|
||||
if (name && bufSize > 0) name[0] = '\0';
|
||||
}
|
||||
|
||||
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
|
||||
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject || !props || !params || bufSize <= 0) return;
|
||||
auto& cache = GetProgramResourceCache(*programObject);
|
||||
GLsizei written = 0;
|
||||
auto writeValue = [&](GLint value) {
|
||||
if (written < bufSize) {
|
||||
params[written++] = value;
|
||||
}
|
||||
};
|
||||
|
||||
for (GLsizei propIndex = 0; propIndex < propCount; ++propIndex) {
|
||||
const GLenum prop = props[propIndex];
|
||||
if (programInterface == GL_SHADER_STORAGE_BLOCK && index < cache.storageBlocks.size()) {
|
||||
const auto& block = cache.storageBlocks[index];
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
writeValue(static_cast<GLint>(block.name.size() + 1));
|
||||
break;
|
||||
case GL_BUFFER_BINDING:
|
||||
writeValue(static_cast<GLint>(block.binding));
|
||||
break;
|
||||
case GL_BUFFER_DATA_SIZE:
|
||||
writeValue(block.dataSize);
|
||||
break;
|
||||
case GL_NUM_ACTIVE_VARIABLES:
|
||||
writeValue(static_cast<GLint>(block.activeVariables.size()));
|
||||
break;
|
||||
case GL_ACTIVE_VARIABLES:
|
||||
for (const auto variable : block.activeVariables) writeValue(static_cast<GLint>(variable));
|
||||
break;
|
||||
default:
|
||||
writeValue(0);
|
||||
break;
|
||||
}
|
||||
} else if (programInterface == GL_BUFFER_VARIABLE && index < cache.bufferVariables.size()) {
|
||||
const auto& var = cache.bufferVariables[index];
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
writeValue(static_cast<GLint>(var.name.size() + 1));
|
||||
break;
|
||||
case GL_TYPE:
|
||||
writeValue(GL_FLOAT);
|
||||
break;
|
||||
case GL_ARRAY_SIZE:
|
||||
writeValue(1);
|
||||
break;
|
||||
case GL_OFFSET:
|
||||
writeValue(var.offset);
|
||||
break;
|
||||
case GL_BLOCK_INDEX:
|
||||
writeValue(static_cast<GLint>(var.blockIndex));
|
||||
break;
|
||||
case GL_ARRAY_STRIDE:
|
||||
case GL_MATRIX_STRIDE:
|
||||
case GL_TOP_LEVEL_ARRAY_SIZE:
|
||||
case GL_TOP_LEVEL_ARRAY_STRIDE:
|
||||
case GL_IS_ROW_MAJOR:
|
||||
writeValue(0);
|
||||
break;
|
||||
default:
|
||||
writeValue(0);
|
||||
break;
|
||||
}
|
||||
} else if (programInterface == GL_UNIFORM_BLOCK &&
|
||||
programObject->IsActiveUniformBlock(index)) {
|
||||
const auto activeVariables = GetUniformBlockActiveVariables(*programObject, index);
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
writeValue(static_cast<GLint>(programObject->GetUniformBlockName(index).size() + 1));
|
||||
break;
|
||||
case GL_BUFFER_BINDING:
|
||||
writeValue(static_cast<GLint>(programObject->GetUniformBlockBinding(index)));
|
||||
break;
|
||||
case GL_BUFFER_DATA_SIZE:
|
||||
writeValue(static_cast<GLint>(programObject->GetUBOSizeAt(index)));
|
||||
break;
|
||||
case GL_NUM_ACTIVE_VARIABLES:
|
||||
writeValue(static_cast<GLint>(activeVariables.size()));
|
||||
break;
|
||||
case GL_ACTIVE_VARIABLES:
|
||||
for (const GLuint variableIndex : activeVariables) {
|
||||
writeValue(static_cast<GLint>(variableIndex));
|
||||
}
|
||||
break;
|
||||
case GL_REFERENCED_BY_VERTEX_SHADER:
|
||||
writeValue(programObject->IsUniformBlockReferencedByStage(index, EShLangVertex) ? GL_TRUE
|
||||
: GL_FALSE);
|
||||
break;
|
||||
case GL_REFERENCED_BY_FRAGMENT_SHADER:
|
||||
writeValue(programObject->IsUniformBlockReferencedByStage(index, EShLangFragment) ? GL_TRUE
|
||||
: GL_FALSE);
|
||||
break;
|
||||
case GL_REFERENCED_BY_COMPUTE_SHADER:
|
||||
writeValue(programObject->IsUniformBlockReferencedByStage(index, EShLangCompute) ? GL_TRUE
|
||||
: GL_FALSE);
|
||||
break;
|
||||
case GL_REFERENCED_BY_GEOMETRY_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
|
||||
writeValue(GL_FALSE);
|
||||
break;
|
||||
default:
|
||||
writeValue(0);
|
||||
break;
|
||||
}
|
||||
} else if (programInterface == GL_UNIFORM && index < programObject->GetUniformCount()) {
|
||||
const auto& uniformName = programObject->GetActiveUniformName(index);
|
||||
const GLint location = programObject->GetUniformLocation(uniformName);
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
writeValue(static_cast<GLint>(uniformName.size() + 1));
|
||||
break;
|
||||
case GL_TYPE:
|
||||
writeValue(static_cast<GLint>(programObject->GetActiveUniformType(index)));
|
||||
break;
|
||||
case GL_ARRAY_SIZE:
|
||||
writeValue(programObject->GetActiveUniformArraySize(index));
|
||||
break;
|
||||
case GL_BLOCK_INDEX:
|
||||
writeValue(programObject->GetActiveUniformBlockIndex(index));
|
||||
break;
|
||||
case GL_LOCATION:
|
||||
writeValue(location);
|
||||
break;
|
||||
case GL_OFFSET:
|
||||
writeValue(location >= 0 && programObject->IsValidUniformLocation(location)
|
||||
? static_cast<GLint>(programObject->GetUniformOffset(location))
|
||||
: 0);
|
||||
break;
|
||||
case GL_ARRAY_STRIDE:
|
||||
case GL_MATRIX_STRIDE:
|
||||
case GL_IS_ROW_MAJOR:
|
||||
case GL_TOP_LEVEL_ARRAY_SIZE:
|
||||
case GL_TOP_LEVEL_ARRAY_STRIDE:
|
||||
case GL_REFERENCED_BY_VERTEX_SHADER:
|
||||
case GL_REFERENCED_BY_FRAGMENT_SHADER:
|
||||
case GL_REFERENCED_BY_COMPUTE_SHADER:
|
||||
case GL_REFERENCED_BY_GEOMETRY_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
|
||||
writeValue(0);
|
||||
break;
|
||||
default:
|
||||
writeValue(0);
|
||||
break;
|
||||
}
|
||||
} else if (programInterface == GL_PROGRAM_INPUT &&
|
||||
index < static_cast<GLuint>(programObject->GetActiveAttributesCount())) {
|
||||
const auto& resourceName = programObject->GetActiveAttribName(index);
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
writeValue(static_cast<GLint>(resourceName.size() + 1));
|
||||
break;
|
||||
case GL_TYPE:
|
||||
writeValue(static_cast<GLint>(programObject->GetActiveAttribType(index)));
|
||||
break;
|
||||
case GL_ARRAY_SIZE:
|
||||
writeValue(programObject->GetActiveAttribArraySize(index));
|
||||
break;
|
||||
case GL_LOCATION:
|
||||
writeValue(programObject->GetAttributeLocation(resourceName));
|
||||
break;
|
||||
case GL_REFERENCED_BY_VERTEX_SHADER:
|
||||
writeValue(GL_TRUE);
|
||||
break;
|
||||
case GL_REFERENCED_BY_FRAGMENT_SHADER:
|
||||
case GL_REFERENCED_BY_COMPUTE_SHADER:
|
||||
case GL_REFERENCED_BY_GEOMETRY_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
|
||||
case GL_IS_PER_PATCH:
|
||||
case GL_LOCATION_INDEX:
|
||||
writeValue(0);
|
||||
break;
|
||||
default:
|
||||
writeValue(0);
|
||||
break;
|
||||
}
|
||||
} else if (programInterface == GL_PROGRAM_OUTPUT &&
|
||||
index < static_cast<GLuint>(programObject->GetActiveFragmentOutputCount())) {
|
||||
const auto& resourceName = programObject->GetActiveFragmentOutputName(index);
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
writeValue(static_cast<GLint>(resourceName.size() + 1));
|
||||
break;
|
||||
case GL_TYPE:
|
||||
writeValue(static_cast<GLint>(programObject->GetFragmentOutputType(index)));
|
||||
break;
|
||||
case GL_ARRAY_SIZE:
|
||||
writeValue(programObject->GetActiveFragmentOutputArraySize(index));
|
||||
break;
|
||||
case GL_LOCATION:
|
||||
writeValue(programObject->GetFragmentOutputLocation(index));
|
||||
break;
|
||||
case GL_LOCATION_INDEX:
|
||||
writeValue(0);
|
||||
break;
|
||||
case GL_REFERENCED_BY_FRAGMENT_SHADER:
|
||||
writeValue(GL_TRUE);
|
||||
break;
|
||||
case GL_REFERENCED_BY_VERTEX_SHADER:
|
||||
case GL_REFERENCED_BY_COMPUTE_SHADER:
|
||||
case GL_REFERENCED_BY_GEOMETRY_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
|
||||
case GL_IS_PER_PATCH:
|
||||
writeValue(0);
|
||||
break;
|
||||
default:
|
||||
writeValue(0);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
writeValue(0);
|
||||
}
|
||||
}
|
||||
if (length) *length = written;
|
||||
}
|
||||
|
||||
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject || !name) return -1;
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
return programObject->GetUniformLocation(name);
|
||||
}
|
||||
if (programInterface == GL_PROGRAM_INPUT) {
|
||||
return programObject->GetAttributeLocation(name);
|
||||
}
|
||||
if (programInterface == GL_PROGRAM_OUTPUT) {
|
||||
return GetProgramOutputLocation(*programObject, name);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject || !name) return -1;
|
||||
if (programInterface == GL_PROGRAM_OUTPUT) {
|
||||
return GetProgramOutputLocation(*programObject, name) >= 0 ? 0 : -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding) {
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject) return;
|
||||
auto& cache = GetProgramResourceCache(*programObject);
|
||||
if (!programObject || storageBlockName == nullptr) return;
|
||||
const Int maxBindings = pActiveBackendObject
|
||||
? pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings
|
||||
: 0;
|
||||
@@ -1234,13 +797,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
MakeUnique<GenericErrorInfo>("DirectVulkan", __func__, "Shader storage binding is out of range."));
|
||||
return;
|
||||
}
|
||||
if (storageBlockIndex >= cache.storageBlocks.size()) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("DirectVulkan", __func__, "Shader storage block index is not active."));
|
||||
return;
|
||||
}
|
||||
cache.storageBlocks[storageBlockIndex].binding = storageBlockBinding;
|
||||
// The frontend already validated that the name denotes an active block, and has
|
||||
// already recorded the new binding on the program - which is what reseeds this cache
|
||||
// whenever it is rebuilt. Writing the entry here as well keeps an ALREADY-BUILT cache
|
||||
// (the common case: the very next draw reads it) from having to be thrown away.
|
||||
auto& cache = GetProgramResourceCache(*programObject);
|
||||
const GLuint blockIndex = GetShaderStorageBlockIndex(*programObject, storageBlockName);
|
||||
if (blockIndex == GL_INVALID_INDEX) return;
|
||||
cache.storageBlocks[blockIndex].binding = storageBlockBinding;
|
||||
}
|
||||
void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) {
|
||||
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ReadPixels called with null VulkanRenderer");
|
||||
|
||||
@@ -97,15 +97,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
void GetIntegeri_v(GLenum target, GLuint index, GLint* data);
|
||||
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
|
||||
void GetProgramiv(GLuint program, GLenum pname, GLint* params);
|
||||
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params);
|
||||
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize,
|
||||
GLsizei* length, GLchar* name);
|
||||
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
|
||||
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params);
|
||||
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding);
|
||||
void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding);
|
||||
void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels);
|
||||
void GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels);
|
||||
void GetTextureImage(const SharedPtr<MG_State::GLState::ITextureObject>& texture, TextureUploadTarget uploadTarget,
|
||||
|
||||
@@ -33,14 +33,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.IsBgra, sizeof(attr.IsBgra)));
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Divisor, sizeof(attr.Divisor)));
|
||||
|
||||
// The buffer's heap address is an identity component of the key: a freed
|
||||
// buffer's reused address can alias an old cache entry, but only under a
|
||||
// byte-identical attribute layout - and the entry payload is a pure function
|
||||
// of the hashed inputs, with the draw path re-resolving bindingBufferKeys
|
||||
// against the live VAO attribute pointers, so an aliased hit returns exactly
|
||||
// what a rebuild would. Address drift only grows the map; the OnFrameBoundary
|
||||
// aging sweep bounds that.
|
||||
const SizeT bufferKey = reinterpret_cast<SizeT>(attr.Buffer.get());
|
||||
// The bound buffer's IDENTITY is a component of the key, and it has to be the
|
||||
// buffer's never-reused lifetime id - NOT its heap address, which this used to
|
||||
// hash. An address is recycled by the allocator, so a deleted-and-recreated
|
||||
// buffer reproduces it; combined with a byte-identical attribute layout that
|
||||
// reproduces the WHOLE content hash, and the hash is what
|
||||
// TryBindResolvedVertexBindings accepts as proof that a memoised binding still
|
||||
// reads the buffer it was resolved from. It did not: a destroyed buffer's GPU
|
||||
// slice was bound for its successor's draw, which is how a transform-feedback
|
||||
// capture came back holding a dead VAO's vertex data (0,0,0,1 - the previous
|
||||
// test's positions) instead of its own.
|
||||
// Zero for client memory (no buffer), which is a distinct identity of its own.
|
||||
const Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0;
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey)));
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
struct BackendVertexInputState {
|
||||
HashType hash = 0;
|
||||
// Hash of the resolved Vulkan vertex layout only (bindings, attributes,
|
||||
// unsupported mask) - NO buffer identities. `hash` mixes buffer heap
|
||||
// addresses so per-chunk VBOs mint a fresh identity per buffer; keying
|
||||
// pipelines on that minted one VkPipeline per chunk section for an
|
||||
// identical layout, defeating pipeline reuse and the per-draw memo.
|
||||
// Pipelines depend only on the layout, so they key on this instead.
|
||||
// unsupported mask) - NO buffer identities. `hash` mixes each bound
|
||||
// buffer's never-reused LIFETIME ID, so per-chunk VBOs mint a fresh
|
||||
// identity per buffer; keying pipelines on that minted one VkPipeline per
|
||||
// chunk section for an identical layout, defeating pipeline reuse and the
|
||||
// per-draw memo. Pipelines depend only on the layout, so they key on this
|
||||
// instead.
|
||||
HashType layoutHash = 0;
|
||||
// Frame boundary of the last cache hit; entries idle past the
|
||||
// OnFrameBoundary retirement age are evicted (CPU heap only).
|
||||
@@ -86,8 +87,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
const MG_State::GLState::VertexArrayObject& vao, HashType hash);
|
||||
const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao);
|
||||
// Frame boundary hook: ages the cache and evicts entries not hit for many
|
||||
// frames. The key mixes buffer heap addresses, so buffer/VAO churn keeps
|
||||
// minting fresh keys; without eviction the map grows for the whole session.
|
||||
// frames. The key mixes each bound buffer's never-reused lifetime id, so
|
||||
// buffer/VAO churn keeps minting fresh keys - and does so by construction,
|
||||
// not by luck: a recreated buffer can no longer land back on its dead
|
||||
// predecessor's key. Without eviction the map grows for the whole session.
|
||||
// Entries hold no Vulkan handles (pipeline creation copies the descriptions)
|
||||
// and the draw path's entry reference never spans a frame boundary, so
|
||||
// eviction here needs no GPU-idle proof. Self-gated: one counter bump and
|
||||
|
||||
@@ -3201,12 +3201,17 @@ void main() {
|
||||
// carry the most entropy of a multiply.
|
||||
const Uint64 mixed = static_cast<Uint64>(reinterpret_cast<SizeT>(vao) >> 4) * 0x9E3779B97F4A7C15ull;
|
||||
const Uint32 index = static_cast<Uint32>(mixed >> 32) & (kVaoDrawMemoSlotCount - 1);
|
||||
// The address still picks the slot (it is what the caller has in hand), but it is
|
||||
// the lifetime id that decides whether the slot is THIS object's: an address on
|
||||
// its own is recycled, and a slot matched on a recycled address hands the new VAO
|
||||
// the dead one's resolved bindings.
|
||||
const Uint64 lifetimeId = vao->GetLifetimeId();
|
||||
VaoDrawMemo& first = m_vaoDrawMemoTable[index];
|
||||
if (first.vaoKey == vao) {
|
||||
if (first.vaoKey == vao && first.vaoLifetimeId == lifetimeId) {
|
||||
return &first;
|
||||
}
|
||||
VaoDrawMemo& second = m_vaoDrawMemoTable[index ^ 1u];
|
||||
if (second.vaoKey == vao) {
|
||||
if (second.vaoKey == vao && second.vaoLifetimeId == lifetimeId) {
|
||||
return &second;
|
||||
}
|
||||
// Miss: recycle a slot. Prefer an empty one; otherwise evict the entry whose
|
||||
@@ -3217,6 +3222,7 @@ void main() {
|
||||
victim = &second;
|
||||
}
|
||||
victim->vaoKey = vao;
|
||||
victim->vaoLifetimeId = lifetimeId;
|
||||
victim->contentHash = 0;
|
||||
victim->layoutFactsValid = false;
|
||||
// Unmatchable until a resolve completes (same rule as before: a bailed-out
|
||||
@@ -4475,9 +4481,10 @@ void main() {
|
||||
// vertex-input hash (VAO layout), render-pass hash (render targets + the draw-buffer/format
|
||||
// driven blend & write-mask gating), and the pipeline-state value hash (all fixed-function state).
|
||||
// Reset per-frame and on pipeline destruction so a memoized handle can never dangle.
|
||||
// The identity hash mixes buffer heap addresses (per-chunk VBOs mint a new
|
||||
// one per buffer); the memo and the pipeline payload key on the resolved
|
||||
// LAYOUT hash instead, so draws over identical layouts share one pipeline.
|
||||
// The identity hash mixes each bound buffer's never-reused lifetime id
|
||||
// (per-chunk VBOs mint a new one per buffer); the memo and the pipeline
|
||||
// payload key on the resolved LAYOUT hash instead, so draws over identical
|
||||
// layouts share one pipeline.
|
||||
// The one-arg fetch rides the VAO's state-pointer memo (no hash, no map).
|
||||
auto& vis = m_vertexInputStateFactory->GetOrCreateVertexInputState(vao);
|
||||
const Uint64 vertexLayoutHash = vis.layoutHash;
|
||||
@@ -5265,7 +5272,8 @@ void main() {
|
||||
// path, re-resolving descriptors and texture layouts nothing invalidated.
|
||||
const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();
|
||||
const Bool vaoMoved =
|
||||
static_cast<const void*>(&vao) != snap.vao || vao.GetConfigVersion() != snap.vaoConfigVersion;
|
||||
static_cast<const void*>(&vao) != snap.vao || vao.GetLifetimeId() != snap.vaoLifetimeId ||
|
||||
vao.GetConfigVersion() != snap.vaoConfigVersion;
|
||||
const auto& drawFbo =
|
||||
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
|
||||
if (static_cast<const void*>(drawFbo.get()) != snap.drawFbo ||
|
||||
@@ -5334,9 +5342,11 @@ void main() {
|
||||
// VAO's content-hash memo. The hash memo shares the cache line this compare
|
||||
// chain already loaded (the config version), and the table slot is compact
|
||||
// and hot - unlike the VAO's aux-memo words, which start a second cold line
|
||||
// of every object in a VAO-cycling frame. The facts are pure functions of
|
||||
// the content hash, so a slot whose contentHash equals the live memoised
|
||||
// hash serves them for ANY VAO object, recycled addresses included.
|
||||
// of every object in a VAO-cycling frame. The slot only ever answers for
|
||||
// THIS object: LookupVaoDrawMemo matches (address, lifetime id), so a slot
|
||||
// a destroyed VAO left behind at a recycled address misses and the facts
|
||||
// are re-resolved. The contentHash compare is the second gate on top of
|
||||
// that identity check, catching a reconfiguration of the same live object.
|
||||
Uint64 auxMasks = 0;
|
||||
Bool factsKnown = false;
|
||||
Uint64 contentHash = 0;
|
||||
@@ -5515,6 +5525,7 @@ void main() {
|
||||
snap.renderStateVersion = renderStateVersion;
|
||||
snap.bindGeneration = bindGeneration;
|
||||
snap.vao = static_cast<const void*>(&vao);
|
||||
snap.vaoLifetimeId = vao.GetLifetimeId();
|
||||
snap.vaoConfigVersion = vao.GetConfigVersion();
|
||||
snap.vaoLayoutHash = vaoLayoutHash;
|
||||
snap.pipeline = pipeline;
|
||||
@@ -5933,6 +5944,7 @@ void main() {
|
||||
snap.programLifetimeId = program.GetLifetimeId();
|
||||
snap.programVersion = program.GetBackendStateVersion();
|
||||
snap.vao = &vao;
|
||||
snap.vaoLifetimeId = vao.GetLifetimeId();
|
||||
snap.vaoConfigVersion = vao.GetConfigVersion();
|
||||
snap.drawFbo = drawFbo.get();
|
||||
snap.fboVersion = drawFbo->GetObjectVersion();
|
||||
|
||||
@@ -758,6 +758,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Uint64 programLifetimeId = 0;
|
||||
Uint32 programVersion = 0;
|
||||
const void* vao = nullptr;
|
||||
// Same rule as VaoDrawMemo::vaoLifetimeId: (address, config version) is not an
|
||||
// identity, because a recycled address can arrive carrying a config version
|
||||
// the dead VAO also had (two mutations to configure one attribute is the
|
||||
// common shape), and "the VAO did not move" would then skip the layout
|
||||
// re-resolve for a different VAO.
|
||||
Uint64 vaoLifetimeId = 0;
|
||||
Uint32 vaoConfigVersion = 0;
|
||||
const void* drawFbo = nullptr;
|
||||
Uint16 fboVersion = 0;
|
||||
@@ -987,19 +993,30 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
const MG_State::GLState::BufferObject* buffers[kMaxBindings] = {};
|
||||
Uint64 sliceEpochs[kMaxBindings] = {};
|
||||
};
|
||||
// One direct-mapped slot of the per-VAO draw-memo table below. The key is a
|
||||
// lookup hint only - a slot is never dereferenced through vaoKey; every fact it
|
||||
// carries is validated against live state before use:
|
||||
// One direct-mapped slot of the per-VAO draw-memo table below. A slot belongs to
|
||||
// the object whose (vaoKey, vaoLifetimeId) pair it carries: the address alone
|
||||
// only picks the slot, and the never-reused lifetime id is what proves the slot
|
||||
// is THIS VAO's, so the successor allocated onto a destroyed VAO's address
|
||||
// always misses. That identity check is load-bearing and the content-hash
|
||||
// validations below do NOT stand in for it - a recycled address under a
|
||||
// byte-identical configuration reproduces the content hash exactly, which is
|
||||
// how a destroyed VAO's resolved bindings were once handed to its successor's
|
||||
// draw. The slot is still never dereferenced through vaoKey, and every fact it
|
||||
// carries is still validated against live state before use:
|
||||
// - layoutHash/layoutAuxMasks are valid only while contentHash equals the LIVE
|
||||
// VAO's own hash memo (which the VAO's config version guards), so a config
|
||||
// change, a buffer rebind, or a recycled VAO address with a different
|
||||
// configuration all miss. A recycled address with a byte-identical
|
||||
// configuration AND identical bound buffers reproduces the content hash, and
|
||||
// then the facts are correct by construction (they are a pure function of it).
|
||||
// change or a buffer rebind misses even for the same object.
|
||||
// - bindings revalidates per draw exactly as before (frame serial, content
|
||||
// hash, per-binding live buffer pointers and slice epochs).
|
||||
struct alignas(64) VaoDrawMemo {
|
||||
const MG_State::GLState::VertexArrayObject* vaoKey = nullptr;
|
||||
// The VAO's never-reused lifetime id, checked alongside vaoKey. The pointer
|
||||
// ALONE is not an identity: a deleted VAO's heap address is handed straight
|
||||
// back by the next glGenVertexArrays-shaped allocation, and the successor then
|
||||
// matched this slot and inherited the dead object's memos. Both stated
|
||||
// defences failed with it, because both reduce to the content hash and the
|
||||
// content hash's buffer-identity component was itself a recycled heap address.
|
||||
Uint64 vaoLifetimeId = 0;
|
||||
// The VAO content hash (VertexInputStateFactory::GetOrComputeHash) the two
|
||||
// layout facts below were derived from; 0 while nothing valid is stored.
|
||||
Uint64 contentHash = 0;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "GL_Buffer.h"
|
||||
#include "Validators.h"
|
||||
#include "../Texture/GL_Texture.h"
|
||||
#include "../Getter/GL_Getter.h"
|
||||
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
|
||||
#include <MG_Util/Metrics/TextureMetrics.h>
|
||||
#include <Config.h>
|
||||
@@ -861,6 +862,10 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
|
||||
Range1D mappedRange = bufferObject->GetMappedRange();
|
||||
auto mappingAccess = bufferObject->GetMappingAccess();
|
||||
// GL 4.6 6.5: the error is on OVERLAP with the mapped range, i.e. a half-open
|
||||
// intersection test. There used to be a second test below this one asking only
|
||||
// `offset + size >= mappedRange.start`, which rejects every write that starts
|
||||
// before a mapped tail as well - it made a legal disjoint glBufferSubData fail.
|
||||
if (bufferObject->IsMapped() && !(mappingAccess & BufferMappingAccessBit::Persistent) &&
|
||||
(offset < mappedRange.end) && (offset + size > mappedRange.start)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
@@ -871,18 +876,6 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bufferObject->IsMapped() && !(mappingAccess & BufferMappingAccessBit::Persistent)) {
|
||||
Range1D mappedRange = bufferObject->GetMappedRange();
|
||||
if (offset + size >= mappedRange.start) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "BufferSubData_State",
|
||||
"Cannot modify a mapped buffer object unless it was "
|
||||
"mapped with GL_MAP_PERSISTENT_BIT."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bufferObject->UploadSubData({(void*)data, (SizeT)size}, offset);
|
||||
}
|
||||
|
||||
@@ -1013,6 +1006,11 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
void BufferStorage_State(GLenum target, GLsizeiptr size, const void* data, GLbitfield flags) {
|
||||
// Error precedence: "no buffer is bound to target" outranks a bad size or bad
|
||||
// flags, so the binding has to be resolved before either is validated.
|
||||
auto bufferObject = GetBoundBufferObject(target, BufferOp::BufferStorage);
|
||||
if (!bufferObject) return;
|
||||
|
||||
if (size <= 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
@@ -1021,8 +1019,6 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
if (!ValidateStorageFlags(flags, BufferOp::BufferStorage)) return;
|
||||
|
||||
auto bufferObject = GetBoundBufferObject(target, BufferOp::BufferStorage);
|
||||
if (!bufferObject) return;
|
||||
if (bufferObject->IsImmutableStorage()) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
@@ -1057,6 +1053,10 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
void NamedBufferStorage_State(GLuint buffer, GLsizeiptr size, const void* data, GLbitfield flags) {
|
||||
// Same precedence as BufferStorage_State: the buffer-name error comes first.
|
||||
auto bufferObject = GetNamedBufferObject(buffer, BufferOp::NamedBufferStorage);
|
||||
if (!bufferObject) return;
|
||||
|
||||
if (size <= 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
@@ -1065,8 +1065,6 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
if (!ValidateStorageFlags(flags, BufferOp::NamedBufferStorage)) return;
|
||||
|
||||
auto bufferObject = GetNamedBufferObject(buffer, BufferOp::NamedBufferStorage);
|
||||
if (!bufferObject) return;
|
||||
if (bufferObject->IsImmutableStorage()) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
@@ -1486,12 +1484,71 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
GetBufferBindingSlot(bufferTarget).Bind(bufferObject);
|
||||
}
|
||||
|
||||
// GL 4.6 core 6.1.1: the constraints glBindBufferRange puts on the (offset, size) pair.
|
||||
// Every one of them is INVALID_VALUE, and all of them are checked before a single piece
|
||||
// of state is written - a rejected bind must leave the binding point exactly as it was.
|
||||
// They apply only to a non-zero buffer: buffer 0 detaches the binding point and ignores
|
||||
// offset and size, which is also how glBindBuffersRange spells "reset this element"
|
||||
// (a NULL buffers array, or a zero entry inside one).
|
||||
static Bool ValidateBufferRangeOffsetAndSize(GLenum target, GLintptr offset, GLsizeiptr size,
|
||||
const char* funcName) {
|
||||
if (size <= 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
|
||||
std::format("size ({}) must be greater than zero.", size)));
|
||||
return false;
|
||||
}
|
||||
if (offset < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
|
||||
std::format("offset ({}) must not be negative.", offset)));
|
||||
return false;
|
||||
}
|
||||
// GL_UNIFORM_BUFFER and GL_SHADER_STORAGE_BUFFER each constrain the offset to their own
|
||||
// implementation-defined alignment, which glGetIntegerv already answers.
|
||||
GLenum alignmentQuery = GL_NONE;
|
||||
if (target == GL_SHADER_STORAGE_BUFFER) {
|
||||
alignmentQuery = GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT;
|
||||
} else if (target == GL_UNIFORM_BUFFER) {
|
||||
alignmentQuery = GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT;
|
||||
}
|
||||
if (alignmentQuery != GL_NONE) {
|
||||
GLint alignment = 0;
|
||||
GetIntegerv(alignmentQuery, &alignment);
|
||||
if (alignment > 0 && (offset % static_cast<GLintptr>(alignment)) != 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>(
|
||||
"MG_Impl/GLImpl", funcName,
|
||||
std::format("offset ({}) must be a multiple of {} ({}).", offset,
|
||||
MG_Util::ConvertGLEnumToString(alignmentQuery), alignment)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// A transform feedback capture binding is addressed in 32-bit components, so BOTH the
|
||||
// offset and the size must be multiples of 4.
|
||||
if (target == GL_TRANSFORM_FEEDBACK_BUFFER && ((offset % 4) != 0 || (size % 4) != 0)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>(
|
||||
"MG_Impl/GLImpl", funcName,
|
||||
std::format("offset ({}) and size ({}) must both be multiples of 4 for "
|
||||
"GL_TRANSFORM_FEEDBACK_BUFFER.",
|
||||
offset, size)));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void BindBufferRange_State(GLenum target, GLuint index, GLuint buffer, GLintptr offset, GLsizeiptr size) {
|
||||
MGLOG_D("%s: target = %s, index = %u, buffer = %u, offset = %d, size = %d", __func__,
|
||||
MG_Util::ConvertGLEnumToString(target).c_str(), index, buffer, offset, size);
|
||||
BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target);
|
||||
if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return;
|
||||
if (!BufferImpl::ValidateBufferBindingPointIndex(bufferTarget, index)) return;
|
||||
if (buffer != 0 && !ValidateBufferRangeOffsetAndSize(target, offset, size, __func__)) return;
|
||||
if (bufferTarget == BufferTarget::TransformFeedback && MG_State::pGLContext->IsTransformFeedbackActive()) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
@@ -1665,15 +1722,32 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
// ARB_multi_bind: defined by the spec as equivalent to a loop over the single-bind entry
|
||||
// points (with buffer 0 resetting the binding point).
|
||||
// points (with buffer 0 resetting the binding point) - but only AFTER an up-front check
|
||||
// of the whole [first, first + count) range. Looping straight into the single-bind entry
|
||||
// points reports the single-bind INVALID_VALUE for an out-of-range index instead of the
|
||||
// multi-bind INVALID_OPERATION, and binds the in-range prefix before failing.
|
||||
static Bool ValidateMultiBindBufferRange(GLenum target, GLuint first, GLsizei count, const char* funcName) {
|
||||
BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target);
|
||||
if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return false;
|
||||
return BufferImpl::ValidateBufferBindingPointRange(bufferTarget, first, count, funcName);
|
||||
}
|
||||
|
||||
void BindBuffersBase(GLenum target, GLuint first, GLsizei count, const GLuint* buffers) {
|
||||
if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return;
|
||||
for (GLsizei i = 0; i < count; ++i) {
|
||||
BindBufferBase_State(target, first + i, buffers ? buffers[i] : 0);
|
||||
}
|
||||
}
|
||||
|
||||
// The (offset, size) constraints are the one part of glBindBuffersRange that stays
|
||||
// per-element: ARB_multi_bind checks them separately for each binding point, leaves that
|
||||
// point unchanged on failure, and still applies the remaining elements - which is exactly
|
||||
// what looping into BindBufferRange_State does. Only the [first, first + count) range is
|
||||
// an up-front, all-or-nothing check. Elements that name buffer 0 (or a NULL buffers array)
|
||||
// reset the binding point through BindBufferBase_State and carry no offset/size to check.
|
||||
void BindBuffersRange(GLenum target, GLuint first, GLsizei count, const GLuint* buffers, const GLintptr* offsets,
|
||||
const GLsizeiptr* sizes) {
|
||||
if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return;
|
||||
for (GLsizei i = 0; i < count; ++i) {
|
||||
if (!buffers || buffers[i] == 0) {
|
||||
BindBufferBase_State(target, first + i, 0);
|
||||
|
||||
@@ -53,18 +53,46 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// The GL-visible number of indexed binding points for `target`.
|
||||
SizeT GetBufferBindingPointLimit(BufferTarget target) {
|
||||
SizeT pointCount = MG_State::pGLContext->GetBufferBindingPointCount(target);
|
||||
if (target == BufferTarget::ShaderStorage && MG_Backend::pActiveBackendObject) {
|
||||
const Int backendCount =
|
||||
MG_Backend::pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings;
|
||||
pointCount = std::min(pointCount, static_cast<SizeT>(std::max(backendCount, 0)));
|
||||
}
|
||||
if (target == BufferTarget::TransformFeedback) {
|
||||
// GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS bounds the indexed capture
|
||||
// binding points in GL 3.3 (no ARB_transform_feedback3).
|
||||
pointCount = std::min<SizeT>(pointCount, 4);
|
||||
}
|
||||
return pointCount;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Bool ValidateBufferBindingPointRange(BufferTarget target, Uint first, GLsizei count, const char* funcName) {
|
||||
if (count < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl/BufferImpl", funcName,
|
||||
"count must be non-negative."));
|
||||
return false;
|
||||
}
|
||||
const SizeT pointCount = GetBufferBindingPointLimit(target);
|
||||
if (static_cast<Uint64>(first) + static_cast<Uint64>(count) > static_cast<Uint64>(pointCount)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>(
|
||||
"MG_Impl/GLImpl/BufferImpl", funcName,
|
||||
std::format("first + count ({} + {}) exceeds the {} indexed binding points of target {}.", first,
|
||||
count, pointCount, MG_Util::ConvertBufferTargetToString(target))));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Bool ValidateBufferBindingPointIndex(BufferTarget target, Uint index) {
|
||||
SizeT pointCount = MG_State::pGLContext->GetBufferBindingPointCount(target);
|
||||
if (target == BufferTarget::ShaderStorage && MG_Backend::pActiveBackendObject) {
|
||||
const Int backendCount =
|
||||
MG_Backend::pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings;
|
||||
pointCount = std::min(pointCount, static_cast<SizeT>(std::max(backendCount, 0)));
|
||||
}
|
||||
if (target == BufferTarget::TransformFeedback) {
|
||||
// GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS bounds the indexed capture
|
||||
// binding points in GL 3.3 (no ARB_transform_feedback3).
|
||||
pointCount = std::min<SizeT>(pointCount, 4);
|
||||
}
|
||||
const SizeT pointCount = GetBufferBindingPointLimit(target);
|
||||
|
||||
if (index < pointCount) {
|
||||
return true;
|
||||
|
||||
@@ -17,4 +17,8 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
|
||||
Bool ValidateBufferMappingAccess(Flags<BufferMappingAccessBit> accessBits);
|
||||
Bool ValidateBufferBindingPointTarget(BufferTarget target);
|
||||
Bool ValidateBufferBindingPointIndex(BufferTarget target, Uint index);
|
||||
// ARB_multi_bind: glBindBuffersBase/Range validate the whole [first, first + count) range
|
||||
// up front and report INVALID_OPERATION, where a single out-of-range index would be
|
||||
// INVALID_VALUE. Naively looping the single-bind entry points reports the wrong class.
|
||||
Bool ValidateBufferBindingPointRange(BufferTarget target, Uint first, GLsizei count, const char* funcName);
|
||||
} // namespace MobileGL::MG_Impl::GLImpl::BufferImpl
|
||||
|
||||
@@ -474,6 +474,21 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
if (!ValidateCurrentProgramForCompute(__func__)) return;
|
||||
// GL 4.6 core 19: each num_groups_* must be within GL_MAX_COMPUTE_WORK_GROUP_COUNT
|
||||
// for its dimension. GetIntegeri_v already floors that at the spec minimum.
|
||||
const GLuint numGroups[3] = {numGroupsX, numGroupsY, numGroupsZ};
|
||||
for (GLuint dimension = 0; dimension < 3; ++dimension) {
|
||||
GLint maxGroups = 0;
|
||||
GetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, dimension, &maxGroups);
|
||||
if (numGroups[dimension] > static_cast<GLuint>(std::max(maxGroups, 0))) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"num_groups exceeds GL_MAX_COMPUTE_WORK_GROUP_COUNT for dimension " +
|
||||
std::to_string(dimension) + "."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
dispatchCompute(numGroupsX, numGroupsY, numGroupsZ);
|
||||
}
|
||||
|
||||
@@ -487,6 +502,24 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
if (!ValidateCurrentProgramForCompute(__func__)) return;
|
||||
// GL 4.6 core 19: `indirect` is a byte offset into GL_DISPATCH_INDIRECT_BUFFER -
|
||||
// negative or misaligned is INVALID_VALUE, nothing bound is INVALID_OPERATION.
|
||||
if (indirect < 0 || (indirect % 4) != 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"indirect must be non-negative and a multiple of 4."));
|
||||
return;
|
||||
}
|
||||
const auto& indirectBuffer =
|
||||
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DispatchIndirect).GetBoundObject();
|
||||
if (!indirectBuffer) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"No buffer is bound to GL_DISPATCH_INDIRECT_BUFFER."));
|
||||
return;
|
||||
}
|
||||
dispatchComputeIndirect(indirect);
|
||||
}
|
||||
|
||||
|
||||
@@ -977,8 +977,8 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, ScissorIndexed, GLuint index, GLint left, GL
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, ScissorIndexedv, GLuint index, const GLint* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ScissorIndexedv, index, v)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, DepthRangeArrayv, GLuint first, GLsizei count, const GLdouble* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DepthRangeArrayv, first, count, v)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, DepthRangeIndexed, GLuint index, GLdouble n, GLdouble f) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DepthRangeIndexed, index, n, f)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetFloati_v, GLenum target, GLuint index, GLfloat* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetFloati_v, target, index, data)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetDoublei_v, GLenum target, GLuint index, GLdouble* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetDoublei_v, target, index, data)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, GetFloati_v, GLenum target, GLuint index, GLfloat* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, GetFloati_v, target, index, data)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, GetDoublei_v, GLenum target, GLuint index, GLdouble* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, GetDoublei_v, target, index, data)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, DrawArraysInstancedBaseInstance, GLenum mode, GLint first, GLsizei count, GLsizei instancecount, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawArraysInstancedBaseInstance, mode, first, count, instancecount, baseinstance)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseInstance, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseInstance, mode, count, type, indices, instancecount, baseinstance)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseVertexBaseInstance, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLint basevertex, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseVertexBaseInstance, mode, count, type, indices, instancecount, basevertex, baseinstance)
|
||||
|
||||
@@ -106,9 +106,11 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
// `capabilityTargetIndex` is the row of the cache the attachment actually lives in;
|
||||
// kFormatCapabilityTargetCount asks about the format in general. Asking per target matters
|
||||
// because a capability recorded for one of them says nothing about the others: DirectGLES
|
||||
// widens three-channel formats to four channels to keep them renderable as *multisample*
|
||||
// storage, and a format that survives only through that substitution is still texture-only
|
||||
// on every ordinary target.
|
||||
// decides each target's substitution against that target's own probe, and a buffer texture
|
||||
// never gets one at all. This is also where the three-channel widening becomes visible to
|
||||
// the application - a GL_RGB8_SNORM colour attachment on a driver with no renderable
|
||||
// three-channel format answers COMPLETE because the backend stores it as GL_RGBA16F and
|
||||
// recorded FramebufferRenderable in CaveatCaps.
|
||||
Bool IsColorInternalFormatRenderable(TextureInternalFormat format, SizeT capabilityTargetIndex) {
|
||||
const SizeT formatIndex = static_cast<SizeT>(format);
|
||||
if (MG_Backend::pActiveBackendObject && formatIndex < MG_Backend::kFormatCapabilityFormatCount) {
|
||||
|
||||
@@ -51,6 +51,14 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
constexpr GLint kFrontendMaxTessControlAtomicCounters = 0;
|
||||
constexpr GLint kFrontendMaxTessEvaluationAtomicCounters = 0;
|
||||
constexpr GLint kFrontendMaxVertexAtomicCounters = 0;
|
||||
// One atomic counter is a uint, and a buffer never has to hold more counters than the
|
||||
// combined limit the frontend advertises. GL 4.6 table 23.63 floors this at 32 bytes.
|
||||
constexpr GLint kFrontendMaxAtomicCounterBufferSize =
|
||||
kFrontendMaxCombinedAtomicCounters * static_cast<GLint>(sizeof(GLuint));
|
||||
// KHR_debug minima (GL 4.6 table 23.66); the debug entry points are stubs, but the
|
||||
// limits they advertise still have to be legal.
|
||||
constexpr GLint kFrontendMaxDebugGroupStackDepth = 64;
|
||||
constexpr GLint kFrontendMaxDebugLoggedMessages = 1;
|
||||
constexpr GLint kFrontendMaxVertexUniformComponents = 4096;
|
||||
constexpr GLint kFrontendMaxVertexUniformVectors = 128;
|
||||
constexpr GLint kFrontendMaxVertexUniformBlocks = 14;
|
||||
@@ -264,6 +272,60 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return true;
|
||||
}
|
||||
|
||||
// GL_TEXTURE_BINDING_* is per-texture-unit state: glGetIntegerv answers for the
|
||||
// active unit, glGetIntegeri_v answers for unit `index`. Both need the same
|
||||
// pname -> target decode, so it lives here instead of being spelled out twice.
|
||||
bool TryDecodeTextureUnitBindingPname(GLenum pname, TextureTarget& outTarget) {
|
||||
switch (pname) {
|
||||
case GL_TEXTURE_BINDING_1D: outTarget = TextureTarget::Texture1D; return true;
|
||||
case GL_TEXTURE_BINDING_1D_ARRAY: outTarget = TextureTarget::Texture1DArray; return true;
|
||||
case GL_TEXTURE_BINDING_2D: outTarget = TextureTarget::Texture2D; return true;
|
||||
case GL_TEXTURE_BINDING_2D_ARRAY: outTarget = TextureTarget::Texture2DArray; return true;
|
||||
case GL_TEXTURE_BINDING_2D_MULTISAMPLE: outTarget = TextureTarget::Texture2DMultisample; return true;
|
||||
case GL_TEXTURE_BINDING_2D_MULTISAMPLE_ARRAY:
|
||||
outTarget = TextureTarget::Texture2DMultisampleArray;
|
||||
return true;
|
||||
case GL_TEXTURE_BINDING_3D: outTarget = TextureTarget::Texture3D; return true;
|
||||
case GL_TEXTURE_BINDING_BUFFER: outTarget = TextureTarget::TextureBuffer; return true;
|
||||
case GL_TEXTURE_BINDING_CUBE_MAP: outTarget = TextureTarget::TextureCubeMap; return true;
|
||||
case GL_TEXTURE_BINDING_CUBE_MAP_ARRAY: outTarget = TextureTarget::TextureCubeMapArray; return true;
|
||||
case GL_TEXTURE_BINDING_RECTANGLE: outTarget = TextureTarget::TextureRectangle; return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
GLint QueryTextureBindingOnUnit(Int unit, TextureTarget target) {
|
||||
auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& obj = textureUnit.GetBindingSlot(target).GetBoundObject();
|
||||
return obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
}
|
||||
|
||||
GLint QuerySamplerBindingOnUnit(Int unit) {
|
||||
const auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& sampler = textureUnit.GetSamplerObject();
|
||||
return sampler ? static_cast<GLint>(sampler->GetExternalIndex()) : 0;
|
||||
}
|
||||
|
||||
// The ARB_viewport_array indexed rectangles. MobileGL keeps exactly one viewport, one
|
||||
// scissor box and one depth range, so every in-range index answers with that single
|
||||
// value - but it has to come from the frontend state the non-indexed getters read.
|
||||
// The generic path at the bottom of GetIntegeri_v is a raw backend passthrough that
|
||||
// has no case for these, so routing them through it returned zeros.
|
||||
Bool IsIndexedViewportQuery(GLenum target) {
|
||||
return target == GL_VIEWPORT || target == GL_SCISSOR_BOX || target == GL_DEPTH_RANGE;
|
||||
}
|
||||
|
||||
// ARB_viewport_array: `index` selects a viewport and MAX_VIEWPORTS bounds it.
|
||||
Bool ValidateViewportQueryIndex(GLuint index, const char* caller) {
|
||||
GLint maxViewports = 0;
|
||||
GetIntegerv(GL_MAX_VIEWPORTS, &maxViewports);
|
||||
if (index < static_cast<GLuint>(std::max(maxViewports, 1))) return true;
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller, "Viewport index is out of range."));
|
||||
return false;
|
||||
}
|
||||
|
||||
void CopyIntsToBooleans(const GLint* src, SizeT count, GLboolean* dst) {
|
||||
for (SizeT i = 0; i < count; ++i) {
|
||||
dst[i] = src[i] ? GL_TRUE : GL_FALSE;
|
||||
@@ -671,7 +733,37 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-texture-unit bindings: GL 4.6 core table 23.19 makes every GL_TEXTURE_BINDING_*
|
||||
// and GL_SAMPLER_BINDING indexed by texture unit. Without this they fell through to
|
||||
// the raw backend passthrough at the bottom, which knows nothing about the
|
||||
// frontend's binding state.
|
||||
if (TextureTarget textureBindingTarget = TextureTarget::Unknown;
|
||||
TryDecodeTextureUnitBindingPname(target, textureBindingTarget) || target == GL_SAMPLER_BINDING) {
|
||||
GLint maxUnits = 0;
|
||||
GetIntegerv(GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, &maxUnits);
|
||||
maxUnits = std::min<GLint>(maxUnits, MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS);
|
||||
if (index >= static_cast<GLuint>(std::max(maxUnits, 0))) {
|
||||
*data = 0;
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "Texture unit index is out of range."));
|
||||
return;
|
||||
}
|
||||
*data = target == GL_SAMPLER_BINDING
|
||||
? QuerySamplerBindingOnUnit(static_cast<Int>(index))
|
||||
: QueryTextureBindingOnUnit(static_cast<Int>(index), textureBindingTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (target) {
|
||||
// ARB_viewport_array queries the indexed rectangles through glGetIntegeri_v as well
|
||||
// (gl4cMultiBindTests and the viewport_array group both do). The frontend keeps one
|
||||
// viewport and one scissor box, so every in-range index reports that one.
|
||||
case GL_VIEWPORT:
|
||||
case GL_SCISSOR_BOX:
|
||||
if (!ValidateViewportQueryIndex(index, __func__)) return;
|
||||
GetIntegerv(target, data);
|
||||
return;
|
||||
// The vertex buffer binding points of the vertex array object that is bound. Indexed by
|
||||
// binding point, not by attribute (GL 4.6 core 10.3.1).
|
||||
case GL_VERTEX_BINDING_BUFFER:
|
||||
@@ -783,6 +875,46 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
getIntegeri(target, index, data);
|
||||
}
|
||||
|
||||
// GL_ARB_viewport_array's typed indexed getters. They were no-op stubs, which left the
|
||||
// caller's output buffer holding whatever was on the stack. The multi-component indexed
|
||||
// rectangles are answered from the frontend's own viewport/scissor/depth-range state, via
|
||||
// the non-indexed getter of the matching type - GL_DEPTH_RANGE is float state, so putting
|
||||
// it through the integer query would round it to 0/1. Everything else MobileGL answers
|
||||
// indexed is scalar integer-domain state, where converting the integer query is exact.
|
||||
void GetFloati_v(GLenum target, GLuint index, GLfloat* data) {
|
||||
if (!data) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "data pointer cannot be null"));
|
||||
return;
|
||||
}
|
||||
if (IsIndexedViewportQuery(target)) {
|
||||
if (!ValidateViewportQueryIndex(index, __func__)) return;
|
||||
GetFloatv(target, data);
|
||||
return;
|
||||
}
|
||||
GLint ints[4] = {};
|
||||
GetIntegeri_v(target, index, ints);
|
||||
data[0] = static_cast<GLfloat>(ints[0]);
|
||||
}
|
||||
|
||||
void GetDoublei_v(GLenum target, GLuint index, GLdouble* data) {
|
||||
if (!data) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "data pointer cannot be null"));
|
||||
return;
|
||||
}
|
||||
if (IsIndexedViewportQuery(target)) {
|
||||
if (!ValidateViewportQueryIndex(index, __func__)) return;
|
||||
GetDoublev(target, data);
|
||||
return;
|
||||
}
|
||||
GLint ints[4] = {};
|
||||
GetIntegeri_v(target, index, ints);
|
||||
data[0] = static_cast<GLdouble>(ints[0]);
|
||||
}
|
||||
|
||||
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data) {
|
||||
if (!data) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
@@ -959,6 +1091,13 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-texture-unit bindings: the non-indexed query reports the active unit.
|
||||
if (TextureTarget textureBindingTarget = TextureTarget::Unknown;
|
||||
TryDecodeTextureUnitBindingPname(pname, textureBindingTarget)) {
|
||||
*params = QueryTextureBindingOnUnit(MG_State::pGLContext->GetActiveTextureUnit(), textureBindingTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (pname) {
|
||||
case GL_ACTIVE_TEXTURE:
|
||||
*params = MG_State::pGLContext->GetActiveTextureUnit() + GL_TEXTURE0;
|
||||
@@ -1085,11 +1224,17 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
: 0;
|
||||
return;
|
||||
case GL_MAX_DEBUG_GROUP_STACK_DEPTH:
|
||||
*params = 0; // debug-group entrypoints are stubbed
|
||||
// KHR_debug floors this at 64 even when the group entry points are stubs: the
|
||||
// limit describes how deep glPushDebugGroup may nest, and 0 is not a legal answer.
|
||||
*params = kFrontendMaxDebugGroupStackDepth;
|
||||
return;
|
||||
case GL_MAX_DEBUG_MESSAGE_LENGTH:
|
||||
*params = 1024; // debug-message entrypoints are stubbed, but KHR_debug requires a valid limit
|
||||
return;
|
||||
case GL_MAX_DEBUG_LOGGED_MESSAGES:
|
||||
// Size of the message log ring; KHR_debug requires at least 1.
|
||||
*params = kFrontendMaxDebugLoggedMessages;
|
||||
return;
|
||||
case GL_DEBUG_GROUP_STACK_DEPTH:
|
||||
*params = 0; // debug-group entrypoints are stubbed
|
||||
return;
|
||||
@@ -1531,13 +1676,9 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_SAMPLE_MASK_VALUE:
|
||||
*params = static_cast<GLint>(MG_State::pGLContext->GetSampleMaskValue());
|
||||
return;
|
||||
case GL_SAMPLER_BINDING: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
const auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& sampler = tu.GetSamplerObject();
|
||||
*params = sampler ? static_cast<GLint>(sampler->GetExternalIndex()) : 0;
|
||||
case GL_SAMPLER_BINDING:
|
||||
*params = QuerySamplerBindingOnUnit(MG_State::pGLContext->GetActiveTextureUnit());
|
||||
return;
|
||||
}
|
||||
case GL_SAMPLES:
|
||||
*params = ResolveDrawFramebufferSampleCount();
|
||||
return;
|
||||
@@ -1633,87 +1774,6 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_STEREO:
|
||||
*params = 0; // stereo surfaces are not exposed
|
||||
return;
|
||||
case GL_TEXTURE_BINDING_1D: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture1D);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_1D_ARRAY: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture1DArray);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_2D: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture2D);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
MGLOG_D("Get GL_TEXTURE_BINDING_2D: %d", *params);
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_2D_ARRAY: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture2DArray);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_2D_MULTISAMPLE: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture2DMultisample);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_2D_MULTISAMPLE_ARRAY: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture2DMultisampleArray);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_3D: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture3D);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_BUFFER: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::TextureBuffer);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_CUBE_MAP: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::TextureCubeMap);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_RECTANGLE: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::TextureRectangle);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_COMPRESSION_HINT:
|
||||
*params = static_cast<GLint>(MG_State::pGLContext->GetHint(pname));
|
||||
return;
|
||||
@@ -1986,6 +2046,18 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS:
|
||||
*params = static_cast<GLint>(GetIndexedBufferQueryPointCount(BufferTarget::ShaderStorage));
|
||||
break;
|
||||
case GL_MAX_SHADER_STORAGE_BLOCK_SIZE:
|
||||
// 64-bit state (see GetInteger64v); the 32-bit query saturates, per the GL
|
||||
// state-query conversion rules.
|
||||
*params = static_cast<GLint>(std::min<Uint64>(dynamicParameters.MaxShaderStorageBlockSize,
|
||||
static_cast<Uint64>(INT32_MAX)));
|
||||
break;
|
||||
case GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS:
|
||||
*params = static_cast<GLint>(GetIndexedBufferQueryPointCount(BufferTarget::AtomicCounter));
|
||||
break;
|
||||
case GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE:
|
||||
*params = kFrontendMaxAtomicCounterBufferSize;
|
||||
break;
|
||||
case GL_MAX_TEXTURE_BUFFER_SIZE:
|
||||
*params = dynamicParameters.MaxTextureBufferSize;
|
||||
break;
|
||||
|
||||
@@ -19,6 +19,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
void GetIntegerv(GLenum pname, GLint* params);
|
||||
void GetInteger64v(GLenum pname, GLint64* params);
|
||||
void GetIntegeri_v(GLenum target, GLuint index, GLint* data);
|
||||
void GetFloati_v(GLenum target, GLuint index, GLfloat* data);
|
||||
void GetDoublei_v(GLenum target, GLuint index, GLdouble* data);
|
||||
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
|
||||
GLenum GetError();
|
||||
GLenum GetGraphicsResetStatus();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// End of Source File Header
|
||||
|
||||
#include "GL_Program.h"
|
||||
#include "ProgramInterface.h"
|
||||
#include "Config.h"
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
@@ -109,32 +110,37 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return programObject;
|
||||
}
|
||||
|
||||
static bool IsProgramInterfaceEnum(GLenum programInterface) {
|
||||
switch (programInterface) {
|
||||
case GL_UNIFORM:
|
||||
case GL_UNIFORM_BLOCK:
|
||||
case GL_PROGRAM_INPUT:
|
||||
case GL_PROGRAM_OUTPUT:
|
||||
case GL_BUFFER_VARIABLE:
|
||||
case GL_SHADER_STORAGE_BLOCK:
|
||||
case GL_ATOMIC_COUNTER_BUFFER:
|
||||
case GL_TRANSFORM_FEEDBACK_VARYING:
|
||||
case GL_VERTEX_SUBROUTINE:
|
||||
case GL_TESS_CONTROL_SUBROUTINE:
|
||||
case GL_TESS_EVALUATION_SUBROUTINE:
|
||||
case GL_GEOMETRY_SUBROUTINE:
|
||||
case GL_FRAGMENT_SUBROUTINE:
|
||||
case GL_COMPUTE_SUBROUTINE:
|
||||
case GL_VERTEX_SUBROUTINE_UNIFORM:
|
||||
case GL_TESS_CONTROL_SUBROUTINE_UNIFORM:
|
||||
case GL_TESS_EVALUATION_SUBROUTINE_UNIFORM:
|
||||
case GL_GEOMETRY_SUBROUTINE_UNIFORM:
|
||||
case GL_FRAGMENT_SUBROUTINE_UNIFORM:
|
||||
case GL_COMPUTE_SUBROUTINE_UNIFORM:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
// The four non-location interface queries validate the NAME only: GL 4.6 imposes the
|
||||
// successful-link requirement on GetProgramResourceLocation/LocationIndex alone, and
|
||||
// requires the others to report a program that has never linked as one with zero active
|
||||
// resources. Being stricter leaves a stray GL_INVALID_OPERATION behind that aborts the
|
||||
// caller's next subcase.
|
||||
static const SharedPtr<MG_State::GLState::ProgramObject>& TryToGetProgramForInterfaceQuery(GLuint program,
|
||||
const char* caller) {
|
||||
static const SharedPtr<MG_State::GLState::ProgramObject> nullProgramObject = nullptr;
|
||||
if (!MG_State::pGLContext->ValidateProgramName(program)) {
|
||||
const ErrorCode error = MG_State::pGLContext->ValidateShaderName(program)
|
||||
? ErrorCode::InvalidOperation
|
||||
: ErrorCode::InvalidValue;
|
||||
MG_State::pGLContext->RecordError(
|
||||
error,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
|
||||
std::to_string(program) + " is not a program object."));
|
||||
return nullProgramObject;
|
||||
}
|
||||
auto& programObject = MG_State::pGLContext->GetProgramObject(program);
|
||||
if (!programObject) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
|
||||
std::to_string(program) + " is not a program object."));
|
||||
return nullProgramObject;
|
||||
}
|
||||
return programObject;
|
||||
}
|
||||
|
||||
static bool IsProgramInterfaceEnum(GLenum programInterface) {
|
||||
return ProgramInterface::IsInterfaceEnum(programInterface);
|
||||
}
|
||||
|
||||
static bool IsSubroutineUniformInterface(GLenum programInterface) {
|
||||
@@ -157,11 +163,16 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_ACTIVE_RESOURCES:
|
||||
break;
|
||||
case GL_MAX_NAME_LENGTH:
|
||||
valid = valid && programInterface != GL_ATOMIC_COUNTER_BUFFER;
|
||||
// Neither buffer interface has resource names. GL_TRANSFORM_FEEDBACK_BUFFER only
|
||||
// became reachable here when IsInterfaceEnum grew the GL 4.4 interfaces, so it
|
||||
// needs the same exclusion GL_ATOMIC_COUNTER_BUFFER already had.
|
||||
valid = valid && programInterface != GL_ATOMIC_COUNTER_BUFFER &&
|
||||
programInterface != GL_TRANSFORM_FEEDBACK_BUFFER;
|
||||
break;
|
||||
case GL_MAX_NUM_ACTIVE_VARIABLES:
|
||||
valid = programInterface == GL_UNIFORM_BLOCK || programInterface == GL_ATOMIC_COUNTER_BUFFER ||
|
||||
programInterface == GL_SHADER_STORAGE_BLOCK;
|
||||
programInterface == GL_SHADER_STORAGE_BLOCK ||
|
||||
programInterface == GL_TRANSFORM_FEEDBACK_BUFFER;
|
||||
break;
|
||||
case GL_MAX_NUM_COMPATIBLE_SUBROUTINES:
|
||||
valid = IsSubroutineUniformInterface(programInterface);
|
||||
@@ -180,7 +191,7 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
static bool ValidateNamedProgramResourceInterface(GLenum programInterface, const char* caller) {
|
||||
if (!IsProgramInterfaceEnum(programInterface) || programInterface == GL_ATOMIC_COUNTER_BUFFER) {
|
||||
if (!ProgramInterface::IsNamedInterface(programInterface)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
|
||||
@@ -190,66 +201,6 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return true;
|
||||
}
|
||||
|
||||
static Int GetKnownProgramResourceCount(const SharedPtr<MG_State::GLState::ProgramObject>& programObject,
|
||||
GLenum programInterface) {
|
||||
switch (programInterface) {
|
||||
case GL_UNIFORM:
|
||||
return programObject->GetUniformCount();
|
||||
case GL_UNIFORM_BLOCK:
|
||||
return programObject->GetActiveUniformBlocksCount();
|
||||
case GL_PROGRAM_INPUT:
|
||||
return programObject->GetActiveAttributesCount();
|
||||
case GL_PROGRAM_OUTPUT:
|
||||
return programObject->GetActiveFragmentOutputCount();
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// The GL_UNIFORM interface and glGetActiveUniform(s)iv are the same query in two
|
||||
// spellings, so they answer from the same place - the frontend reflection. The backend
|
||||
// program is not that place: it does not exist at all for a program whose types its
|
||||
// shading language cannot express (a double-precision uniform has no ESSL form), and
|
||||
// the interface queries would then describe a program with no uniforms.
|
||||
//
|
||||
// Writes the GL_UNIFORM value of `prop` for active uniform `index`; false for a prop
|
||||
// the reflection does not model, which the caller forwards to the backend instead.
|
||||
Bool GetUniformResourceProp(const SharedPtr<MG_State::GLState::ProgramObject>& programObject, Uint index,
|
||||
GLenum prop, GLint* out) {
|
||||
switch (prop) {
|
||||
case GL_TYPE:
|
||||
*out = static_cast<GLint>(programObject->GetActiveUniformType(index));
|
||||
return true;
|
||||
case GL_ARRAY_SIZE:
|
||||
*out = programObject->GetActiveUniformArraySize(index);
|
||||
return true;
|
||||
case GL_NAME_LENGTH:
|
||||
*out = static_cast<GLint>(programObject->GetActiveUniformName(index).length() + 1);
|
||||
return true;
|
||||
case GL_BLOCK_INDEX:
|
||||
*out = programObject->GetActiveUniformBlockIndex(index);
|
||||
return true;
|
||||
case GL_OFFSET:
|
||||
*out = programObject->GetActiveUniformOffset(index);
|
||||
return true;
|
||||
case GL_ARRAY_STRIDE:
|
||||
*out = programObject->GetActiveUniformArrayStride(index);
|
||||
return true;
|
||||
case GL_MATRIX_STRIDE:
|
||||
*out = programObject->GetActiveUniformMatrixStride(index);
|
||||
return true;
|
||||
case GL_IS_ROW_MAJOR:
|
||||
*out = programObject->GetActiveUniformIsRowMajor(index);
|
||||
return true;
|
||||
case GL_LOCATION:
|
||||
// A block member has no location; GetUniformLocation already reports -1 for one.
|
||||
*out = programObject->GetUniformLocation(programObject->GetActiveUniformName(index));
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void CopyStr(GLsizei bufSize, GLsizei* length, GLchar* dst, const char* src, GLsizei srcLength) {
|
||||
if (bufSize <= 0) {
|
||||
if (length) *length = 0;
|
||||
@@ -409,14 +360,19 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
GLuint CreateShader_State(GLenum type) {
|
||||
auto shaderId = MG_State::pGLContext->CreateShader(MG_Util::ConvertGLEnumToShaderStage(type));
|
||||
if (shaderId == 0) {
|
||||
// GL 4.6 core 7.1: shaderType is an enum, so an unrecognised one is INVALID_ENUM (it
|
||||
// used to be documented as INVALID_VALUE). The check has to happen HERE: the state
|
||||
// layer hands out a name for ShaderStage::Unknown just as happily as for a real
|
||||
// stage, so the old "shaderId == 0 means bad type" test could never fire and an
|
||||
// unknown shaderType silently produced a usable shader name and no error at all.
|
||||
const ShaderStage stage = MG_Util::ConvertGLEnumToShaderStage(type);
|
||||
if (stage == ShaderStage::Unknown) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "`shaderType` is not an accepted value."));
|
||||
return 0;
|
||||
}
|
||||
return shaderId;
|
||||
return MG_State::pGLContext->CreateShader(stage);
|
||||
}
|
||||
|
||||
void DeleteProgram_State(GLuint program) {
|
||||
@@ -855,6 +811,31 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return loc;
|
||||
}
|
||||
|
||||
// A float matrix lives in the global UBO under std140 rules - one 16-byte-aligned column
|
||||
// vector per column - while the value glGetUniform* must return is tightly packed
|
||||
// columns * rows floats. Only mat4 is the same either way; every other shape needs the
|
||||
// padding undone, and the readback has to undo exactly what UniformMatrixfv_Object put
|
||||
// there. Returns false when `ttype` is not a float matrix (nothing to unpack).
|
||||
Bool TryGatherFloatMatrixColumns(const glslang::TType* ttype, const char* pBase, void* params) {
|
||||
if (ttype == nullptr || !ttype->isMatrix() || ttype->getBasicType() == glslang::EbtDouble) return false;
|
||||
const Int columns = ttype->getMatrixCols();
|
||||
const Int rows = ttype->getMatrixRows();
|
||||
for (Int column = 0; column < columns; ++column) {
|
||||
Memcpy(static_cast<char*>(params) + static_cast<SizeT>(column) * rows * sizeof(GLfloat),
|
||||
pBase + static_cast<SizeT>(column) * 4 * sizeof(GLfloat), rows * sizeof(GLfloat));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bytes a uniform actually occupies in the global UBO. It is the tight GL type size for
|
||||
// everything except a float matrix, whose padded columns make it wider.
|
||||
SizeT UniformStorageSpanInBytes(const glslang::TType* ttype, SizeT tightSize) {
|
||||
if (ttype != nullptr && ttype->isMatrix() && ttype->getBasicType() != glslang::EbtDouble) {
|
||||
return static_cast<SizeT>(ttype->getMatrixCols()) * 4 * sizeof(GLfloat);
|
||||
}
|
||||
return tightSize;
|
||||
}
|
||||
|
||||
void GetUniform_State(GLuint program, GLint location, void* params) {
|
||||
auto& programObject = TryToGetProgramObject(program);
|
||||
if (!programObject) return;
|
||||
@@ -885,23 +866,16 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
auto size = programObject->GetUniformSizesInBytes(location);
|
||||
char* pUBO = (char*)programObject->MapUBO();
|
||||
auto* ttype = programObject->GetUniformTType(location);
|
||||
const SizeT span = UniformStorageSpanInBytes(ttype, size);
|
||||
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
|
||||
offset + size > programObject->GetUBOSize()) {
|
||||
offset + span > programObject->GetUBOSize()) {
|
||||
MGLOG_E("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
|
||||
program, location);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ttype->isMatrix() || ttype->getMatrixCols() != 3)
|
||||
if (!TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) {
|
||||
Memcpy(params, pUBO + offset, size);
|
||||
else {
|
||||
// TODO: we only deal with mat3 yet, deal with other types later
|
||||
// assuming float here, which may not be the case
|
||||
auto* pBase = pUBO + offset;
|
||||
for (int i = 0; i < ttype->getMatrixRows(); i++) {
|
||||
Memcpy((char*)params + ttype->getMatrixCols() * sizeof(float) * i, pBase + 4 * sizeof(float) * i,
|
||||
ttype->getMatrixCols() * sizeof(float));
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: handle 1i variant as texture unit
|
||||
@@ -940,23 +914,16 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
auto size = programObject->GetUniformSizesInBytes(location);
|
||||
char* pUBO = static_cast<char*>(programObject->MapUBO());
|
||||
auto* ttype = programObject->GetUniformTType(location);
|
||||
const SizeT span = UniformStorageSpanInBytes(ttype, size);
|
||||
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
|
||||
offset + size > programObject->GetUBOSize()) {
|
||||
offset + span > programObject->GetUBOSize()) {
|
||||
MGLOG_E("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
|
||||
program, location);
|
||||
return;
|
||||
}
|
||||
|
||||
if constexpr (std::is_same_v<T, GLfloat>) {
|
||||
if (ttype->getBasicType() != glslang::EbtDouble && ttype->isMatrix() &&
|
||||
ttype->getMatrixCols() == 3) {
|
||||
auto* pBase = pUBO + offset;
|
||||
for (int i = 0; i < ttype->getMatrixRows(); i++) {
|
||||
Memcpy(reinterpret_cast<char*>(params) + ttype->getMatrixCols() * sizeof(GLfloat) * i,
|
||||
pBase + 4 * sizeof(GLfloat) * i, ttype->getMatrixCols() * sizeof(GLfloat));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) return;
|
||||
}
|
||||
|
||||
// A double-precision uniform is the one case where the stored component type can
|
||||
@@ -1257,6 +1224,52 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// glUniformMatrix*fv / glProgramUniformMatrix*fv, every shape (square and non-square).
|
||||
// A float matrix sits in the global UBO under std140 rules: each of its `columns`
|
||||
// column vectors starts on its own 16-byte boundary no matter how many rows it has, so
|
||||
// the only shape that may be written as one contiguous block is mat4. Writing a matNxM
|
||||
// as N*M packed floats puts every column after the first at the wrong byte offset.
|
||||
template <typename Program>
|
||||
void UniformMatrixfv_Object(Program& programObject, const char* caller, GLint location, GLsizei count,
|
||||
GLboolean transpose, const GLfloat* value, Int columns, Int rows,
|
||||
const String& ownerDescription) {
|
||||
// std140: a column vector of a float matrix is padded out to a vec4.
|
||||
constexpr SizeT kColumnStride = 4 * sizeof(GLfloat);
|
||||
const SizeT componentCount = static_cast<SizeT>(columns) * static_cast<SizeT>(rows);
|
||||
GLfloat column[4] = {};
|
||||
for (GLint matrix = 0; matrix < count; ++matrix) {
|
||||
if (matrix > 0 && !programObject.UniformLocationsAliasSameUniform(location, location + matrix)) {
|
||||
// GL 3.3 2.11.4: values for elements beyond the end of the uniform array
|
||||
// are ignored. Never step onto a neighboring uniform's location.
|
||||
break;
|
||||
}
|
||||
if (!programObject.IsValidUniformLocation(location + matrix)) {
|
||||
RecordInvalidUniformLocationError(caller, location + matrix, ownerDescription);
|
||||
return;
|
||||
}
|
||||
if (programObject.IsUniformOpaqueAtLocation(location + matrix)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
|
||||
"Opaque uniforms cannot be set with matrix Uniform calls."));
|
||||
return;
|
||||
}
|
||||
if (value == nullptr) return;
|
||||
const GLfloat* source = value + static_cast<SizeT>(matrix) * componentCount;
|
||||
for (Int c = 0; c < columns; ++c) {
|
||||
for (Int r = 0; r < rows; ++r) {
|
||||
column[r] = transpose == GL_TRUE ? source[r * columns + c] : source[c * rows + r];
|
||||
}
|
||||
const SizeT byteOffset = static_cast<SizeT>(c) * kColumnStride;
|
||||
switch (rows) {
|
||||
case 2: Uniform_State<2>(programObject, location + matrix, column, byteOffset); break;
|
||||
case 3: Uniform_State<3>(programObject, location + matrix, column, byteOffset); break;
|
||||
default: Uniform_State<4>(programObject, location + matrix, column, byteOffset); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to transpose a 2x2 matrix
|
||||
void TransposeMatrix2x2(const GLfloat* input, GLfloat* output) {
|
||||
// Input matrix is in column-major order (OpenGL default)
|
||||
@@ -1362,8 +1375,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
void UniformMatrix2fv_State(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
// For 2x2 matrices, we have 4 elements per matrix
|
||||
// If transpose is GL_TRUE, we need to transpose the matrix data
|
||||
// A mat2 is NOT four contiguous floats in the global UBO: std140 pads each column
|
||||
// vector out to 16 bytes, so column 1 starts at byte 16, not byte 8.
|
||||
if (location == -1) return;
|
||||
|
||||
auto& programObject = MG_State::pGLContext->GetProgramForUniform();
|
||||
@@ -1374,26 +1387,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
|
||||
// For matrix uniforms, we handle each matrix individually
|
||||
for (GLint i = 0; i < count; i++) {
|
||||
if (i > 0 && !programObject->UniformLocationsAliasSameUniform(location, location + i)) {
|
||||
// Values for elements beyond the end of the uniform array are ignored.
|
||||
break;
|
||||
}
|
||||
if (!programObject->IsValidUniformLocation(location + i)) {
|
||||
RecordInvalidUniformLocationError(__func__, location + i, "the current program object");
|
||||
return;
|
||||
}
|
||||
if (transpose == GL_TRUE) {
|
||||
// Transpose the matrix before uploading
|
||||
GLfloat transposedMatrix[4];
|
||||
TransposeMatrix2x2(value + i * 4, transposedMatrix);
|
||||
Uniform_State<4>(*programObject, location + i, transposedMatrix);
|
||||
} else {
|
||||
// No transpose needed, directly copy the matrix data
|
||||
Uniform_State<4>(*programObject, location + i, value + i * 4);
|
||||
}
|
||||
}
|
||||
UniformMatrixfv_Object(*programObject, __func__, location, count, transpose, value, 2, 2,
|
||||
"the current program object");
|
||||
}
|
||||
|
||||
void UniformMatrix3fv_State(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
@@ -1471,7 +1466,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
}
|
||||
|
||||
void UniformMatrixNonSquarefv_State(const char* caller, GLint location, GLsizei count) {
|
||||
void UniformMatrixNonSquarefv_State(const char* caller, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value, Int columns, Int rows) {
|
||||
if (location == -1) return;
|
||||
|
||||
auto& programObject = MG_State::pGLContext->GetProgramForUniform();
|
||||
@@ -1482,21 +1478,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
|
||||
for (GLint i = 0; i < count; i++) {
|
||||
if (!programObject->IsValidUniformLocation(location + i)) {
|
||||
RecordInvalidUniformLocationError(caller, location + i, "the current program object");
|
||||
return;
|
||||
}
|
||||
if (programObject->IsUniformOpaqueAtLocation(location + i)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
|
||||
"Opaque uniforms cannot be set with matrix Uniform calls."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement non-square matrix uniform uploads for non-opaque uniforms.
|
||||
UniformMatrixfv_Object(*programObject, caller, location, count, transpose, value, columns, rows,
|
||||
"the current program object");
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix2fv_State(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
@@ -1514,23 +1497,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
|
||||
for (GLint i = 0; i < count; i++) {
|
||||
if (i > 0 && !programObject->UniformLocationsAliasSameUniform(location, location + i)) {
|
||||
// Values for elements beyond the end of the uniform array are ignored.
|
||||
break;
|
||||
}
|
||||
if (!programObject->IsValidUniformLocation(location + i)) {
|
||||
RecordInvalidUniformLocationError(__func__, location + i, "program " + std::to_string(program));
|
||||
return;
|
||||
}
|
||||
if (transpose == GL_TRUE) {
|
||||
GLfloat transposedMatrix[4];
|
||||
TransposeMatrix2x2(value + i * 4, transposedMatrix);
|
||||
Uniform_State<4>(*programObject, location + i, transposedMatrix);
|
||||
} else {
|
||||
Uniform_State<4>(*programObject, location + i, value + i * 4);
|
||||
}
|
||||
}
|
||||
UniformMatrixfv_Object(*programObject, __func__, location, count, transpose, value, 2, 2,
|
||||
"program " + std::to_string(program));
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix3fv_State(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
@@ -1605,7 +1573,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
}
|
||||
|
||||
void ProgramUniformMatrixNonSquarefv_State(const char* caller, GLuint program, GLint location, GLsizei count) {
|
||||
void ProgramUniformMatrixNonSquarefv_State(const char* caller, GLuint program, GLint location, GLsizei count,
|
||||
GLboolean transpose, const GLfloat* value, Int columns, Int rows) {
|
||||
if (location == -1) return;
|
||||
|
||||
auto& programObject = TryToGetProgramObject(program);
|
||||
@@ -1619,21 +1588,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
|
||||
for (GLint i = 0; i < count; i++) {
|
||||
if (!programObject->IsValidUniformLocation(location + i)) {
|
||||
RecordInvalidUniformLocationError(caller, location + i, "program " + std::to_string(program));
|
||||
return;
|
||||
}
|
||||
if (programObject->IsUniformOpaqueAtLocation(location + i)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
|
||||
"Opaque uniforms cannot be set with matrix Uniform calls."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement non-square matrix uniform uploads for non-opaque uniforms.
|
||||
UniformMatrixfv_Object(*programObject, caller, location, count, transpose, value, columns, rows,
|
||||
"program " + std::to_string(program));
|
||||
}
|
||||
|
||||
GLuint GetUniformBlockIndex_State(GLuint program, const GLchar* uniformBlockName) {
|
||||
@@ -2442,27 +2398,27 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
void UniformMatrix2x3fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count);
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 2, 3);
|
||||
}
|
||||
|
||||
void UniformMatrix3x2fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count);
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 3, 2);
|
||||
}
|
||||
|
||||
void UniformMatrix2x4fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count);
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 2, 4);
|
||||
}
|
||||
|
||||
void UniformMatrix4x2fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count);
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 4, 2);
|
||||
}
|
||||
|
||||
void UniformMatrix3x4fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count);
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 3, 4);
|
||||
}
|
||||
|
||||
void UniformMatrix4x3fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count);
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 4, 3);
|
||||
}
|
||||
|
||||
void ProgramUniform1f(GLuint program, GLint location, GLfloat v0) {
|
||||
@@ -2587,32 +2543,32 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
|
||||
void ProgramUniformMatrix2x3fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value) {
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 2, 3);
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix3x2fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value) {
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 3, 2);
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix2x4fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value) {
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 2, 4);
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix4x2fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value) {
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 4, 2);
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix3x4fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value) {
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 3, 4);
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix4x3fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value) {
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 4, 3);
|
||||
}
|
||||
|
||||
GLuint GetUniformBlockIndex(GLuint program, const GLchar* uniformBlockName) {
|
||||
@@ -2649,171 +2605,160 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params) {
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return;
|
||||
if (!ValidateProgramInterfaceivQuery(programInterface, pname)) return;
|
||||
auto getProgramInterfaceiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramInterfaceiv;
|
||||
if (!getProgramInterfaceiv) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
if (!params) return;
|
||||
switch (pname) {
|
||||
case GL_ACTIVE_RESOURCES:
|
||||
*params = ProgramInterface::GetActiveResourceCount(*programObject, programInterface);
|
||||
return;
|
||||
case GL_MAX_NAME_LENGTH:
|
||||
*params = ProgramInterface::GetMaxNameLength(*programObject, programInterface);
|
||||
return;
|
||||
case GL_MAX_NUM_ACTIVE_VARIABLES:
|
||||
*params = ProgramInterface::GetMaxNumActiveVariables(*programObject, programInterface);
|
||||
return;
|
||||
default:
|
||||
// GL_MAX_NUM_COMPATIBLE_SUBROUTINES: the subroutine interfaces are always empty
|
||||
// here (glslang refuses `subroutine` when generating SPIR-V), so zero it is.
|
||||
*params = 0;
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
if (pname == GL_ACTIVE_RESOURCES) {
|
||||
*params = static_cast<GLint>(programObject->GetUniformCount());
|
||||
return;
|
||||
}
|
||||
if (pname == GL_MAX_NAME_LENGTH) {
|
||||
// Stored as the bare length; GL_MAX_NAME_LENGTH counts the terminator.
|
||||
*params = programObject->GetUniformMaxLength() + 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
getProgramInterfaceiv(program, programInterface, pname, params);
|
||||
}
|
||||
|
||||
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return GL_INVALID_INDEX;
|
||||
if (!ValidateNamedProgramResourceInterface(programInterface, __func__)) return GL_INVALID_INDEX;
|
||||
if (!name) return GL_INVALID_INDEX;
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
const Int uniformIndex = programObject->GetActiveUniformIndex(name);
|
||||
return uniformIndex < 0 ? GL_INVALID_INDEX : static_cast<GLuint>(uniformIndex);
|
||||
}
|
||||
auto getProgramResourceIndex = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceIndex;
|
||||
if (!getProgramResourceIndex) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
return GL_INVALID_INDEX;
|
||||
}
|
||||
GLuint index = getProgramResourceIndex(program, programInterface, name);
|
||||
const String resourceName = name;
|
||||
if (index == GL_INVALID_INDEX && resourceName.length() > 3 &&
|
||||
resourceName.compare(resourceName.length() - 3, 3, "[0]") == 0) {
|
||||
index = getProgramResourceIndex(program, programInterface,
|
||||
resourceName.substr(0, resourceName.length() - 3).c_str());
|
||||
}
|
||||
return index;
|
||||
return ProgramInterface::GetResourceIndex(*programObject, programInterface, name);
|
||||
}
|
||||
|
||||
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei* length,
|
||||
GLchar* name) {
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return;
|
||||
if (!ValidateNamedProgramResourceInterface(programInterface, __func__)) return;
|
||||
const Int resourceCount = GetKnownProgramResourceCount(programObject, programInterface);
|
||||
if (resourceCount >= 0 && index >= static_cast<GLuint>(resourceCount)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "index is out of range."));
|
||||
return;
|
||||
}
|
||||
if (bufSize < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "bufSize must be non-negative."));
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
// Same index space GetProgramResourceIndex answers in, and the range check above
|
||||
// already used it.
|
||||
const String& uniformName = programObject->GetActiveUniformName(index);
|
||||
CopyStr(bufSize, length, name, uniformName.c_str(), static_cast<GLsizei>(uniformName.length()));
|
||||
return;
|
||||
}
|
||||
auto getProgramResourceName = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceName;
|
||||
if (!getProgramResourceName) {
|
||||
String resourceName;
|
||||
if (!ProgramInterface::GetResourceName(*programObject, programInterface, index, resourceName)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "index is out of range."));
|
||||
return;
|
||||
}
|
||||
getProgramResourceName(program, programInterface, index, bufSize, length, name);
|
||||
CopyStr(bufSize, length, name, resourceName.c_str(), static_cast<GLsizei>(resourceName.length()));
|
||||
}
|
||||
|
||||
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
|
||||
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return;
|
||||
if (propCount < 0 || bufSize < 0) {
|
||||
if (!ProgramInterface::IsInterfaceEnum(programInterface)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"propCount and bufSize must be non-negative."));
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "Unsupported program interface."));
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
if (index >= programObject->GetUniformCount()) {
|
||||
if (propCount <= 0 || bufSize < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"propCount must be positive and bufSize "
|
||||
"non-negative."));
|
||||
return;
|
||||
}
|
||||
if (props == nullptr) return;
|
||||
// Both prop checks run BEFORE any value is produced: a property this command does
|
||||
// not know at all is INVALID_ENUM, one it knows but the interface does not carry is
|
||||
// INVALID_OPERATION (GL 4.6 Table 7.2). The two are deliberately different errors.
|
||||
for (GLsizei i = 0; i < propCount; ++i) {
|
||||
if (!ProgramInterface::IsResourceProp(props[i])) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "prop is not a valid property name."));
|
||||
return;
|
||||
}
|
||||
if (!ProgramInterface::InterfaceSupportsProp(programInterface, props[i])) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"prop is not supported for this program interface."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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 (props == nullptr || params == nullptr) return;
|
||||
GLsizei written = 0;
|
||||
for (GLsizei i = 0; i < propCount && written < bufSize; ++i) {
|
||||
GLint value = 0;
|
||||
if (!GetUniformResourceProp(programObject, index, props[i], &value)) {
|
||||
// GL_ATOMIC_COUNTER_BUFFER_INDEX and the GL_REFERENCED_BY_* stage props are
|
||||
// not modelled here; ask the backend, which indexes resources by name.
|
||||
auto backendGetIndex = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceIndex;
|
||||
auto backendGetiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceiv;
|
||||
if (backendGetIndex && backendGetiv) {
|
||||
const GLuint backendIndex = backendGetIndex(program, GL_UNIFORM,
|
||||
programObject->GetActiveUniformName(index).c_str());
|
||||
if (backendIndex != GL_INVALID_INDEX) {
|
||||
GLsizei one = 0;
|
||||
backendGetiv(program, GL_UNIFORM, backendIndex, 1, &props[i], 1, &one, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
params[written++] = value;
|
||||
}
|
||||
if (length) *length = written;
|
||||
return;
|
||||
}
|
||||
auto getProgramResourceiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceiv;
|
||||
if (!getProgramResourceiv) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
return;
|
||||
}
|
||||
getProgramResourceiv(program, programInterface, index, propCount, props, bufSize, length, params);
|
||||
if (params == nullptr) return;
|
||||
const GLsizei written = static_cast<GLsizei>(std::min<SizeT>(values.size(), static_cast<SizeT>(bufSize)));
|
||||
for (GLsizei i = 0; i < written; ++i) params[i] = values[i];
|
||||
if (length) *length = written;
|
||||
}
|
||||
|
||||
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
// Unlike the four queries above, this one and GetProgramResourceLocationIndex really
|
||||
// do require a successful link (GL 4.6 §7.3.1.3).
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return -1;
|
||||
auto getProgramResourceLocation = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceLocation;
|
||||
if (!getProgramResourceLocation) {
|
||||
if (!ProgramInterface::InterfaceHasLocations(programInterface)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
"Program interface has no locations."));
|
||||
return -1;
|
||||
}
|
||||
return getProgramResourceLocation(program, programInterface, name);
|
||||
return ProgramInterface::GetResourceLocation(*programObject, programInterface, name);
|
||||
}
|
||||
|
||||
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return -1;
|
||||
auto getProgramResourceLocationIndex = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceLocationIndex;
|
||||
if (!getProgramResourceLocationIndex) return -1;
|
||||
return getProgramResourceLocationIndex(program, programInterface, name);
|
||||
if (programInterface != GL_PROGRAM_OUTPUT) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"GetProgramResourceLocationIndex only accepts GL_PROGRAM_OUTPUT."));
|
||||
return -1;
|
||||
}
|
||||
return ProgramInterface::GetResourceLocationIndex(*programObject, programInterface, name);
|
||||
}
|
||||
|
||||
// GL 4.6 §7.6.2: <storageBlockIndex> is an active shader storage block index of <program>
|
||||
// - that is, exactly what glGetProgramResourceIndex(GL_SHADER_STORAGE_BLOCK) returned.
|
||||
// Since wave 2 that index is the interface-query layer's, so this is where the one index
|
||||
// space the application sees gets turned into whatever the backend's is; the backends are
|
||||
// handed the block NAME and do their own lookup. Getting this wrong is silent: the call
|
||||
// succeeds and rebinds a DIFFERENT buffer.
|
||||
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding) {
|
||||
auto& programObject = TryToGetProgramObject(program);
|
||||
if (!programObject || !programObject->GetLinkStatus()) return;
|
||||
if (!ValidateShaderStorageBlockBinding(storageBlockBinding)) return;
|
||||
String blockName;
|
||||
if (!ProgramInterface::GetResourceName(*programObject, GL_SHADER_STORAGE_BLOCK, storageBlockIndex,
|
||||
blockName)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"storageBlockIndex is not an active shader storage block index."));
|
||||
return;
|
||||
}
|
||||
// Recorded before the backend call, and independently of whether a backend is even
|
||||
// present: this is the state GL_BUFFER_BINDING reports, and it is also what reseeds a
|
||||
// backend's own reflection cache after any rebuild.
|
||||
programObject->SetShaderStorageBlockBinding(blockName, storageBlockBinding);
|
||||
auto shaderStorageBlockBinding = MG_Backend::gBackendFunctionsTable.GL.ShaderStorageBlockBinding;
|
||||
if (!shaderStorageBlockBinding) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
@@ -2822,7 +2767,7 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
"Backend does not support shader storage block binding."));
|
||||
return;
|
||||
}
|
||||
shaderStorageBlockBinding(program, storageBlockIndex, storageBlockBinding);
|
||||
shaderStorageBlockBinding(program, blockName.c_str(), storageBlockBinding);
|
||||
}
|
||||
|
||||
void ValidateProgram(GLuint program) {
|
||||
@@ -2859,6 +2804,16 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
// is written as that sequence rather than as a private shortcut - every error it can
|
||||
// raise is one of theirs, raised at the point they would raise it.
|
||||
GLuint CreateShaderProgramv(GLenum type, GLsizei count, const GLchar* const* strings) {
|
||||
// GL 4.6 core 7.3: a negative count is INVALID_VALUE and is checked before anything
|
||||
// is created, so a bad count never leaks a shader name. An unrecognised type is
|
||||
// INVALID_ENUM, which CreateShader_State raises below.
|
||||
if (count < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "count must be non-negative."));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const GLuint shader = CreateShader_State(type);
|
||||
if (shader == 0) return 0;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include "GL_Sampler.h"
|
||||
#include "Validators.h"
|
||||
#include "../Getter/GL_Getter.h"
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
|
||||
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
|
||||
@@ -268,9 +269,20 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// The number of texture units a sampler may be bound to. GL 3.3 core 3.8.2 names
|
||||
// GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, which is what the backend advertises; the frontend's
|
||||
// MAX_TEXTURE_IMAGE_UNITS is only the capacity of the unit array, so it is a clamp on the
|
||||
// answer and never the answer itself - gating on it alone accepts every unit up to 192 no
|
||||
// matter what the driver reports.
|
||||
static GLint GetSamplerBindableTextureUnitCount() {
|
||||
GLint maxTextureUnits = 0;
|
||||
GetIntegerv(GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, &maxTextureUnits);
|
||||
return std::min<GLint>(std::max(maxTextureUnits, 0), MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS);
|
||||
}
|
||||
|
||||
void BindSampler_State(GLuint unit, GLuint sampler) {
|
||||
MGLOG_D("BindSampler_State: unit = %u, sampler = %u", unit, sampler);
|
||||
if (unit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) {
|
||||
if (static_cast<Uint64>(unit) >= static_cast<Uint64>(GetSamplerBindableTextureUnitCount())) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "BindSampler", "texture unit out of range"));
|
||||
@@ -309,6 +321,20 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "BindSamplers", "count must be non-negative"));
|
||||
return;
|
||||
}
|
||||
// ARB_multi_bind: the whole [first, first + count) range is checked up front and a
|
||||
// range that runs past the last texture unit is INVALID_OPERATION - not the
|
||||
// INVALID_VALUE the single-bind BindSampler_State reports per element, and nothing is
|
||||
// bound when it fails. Both gates read the same limit (see
|
||||
// GetSamplerBindableTextureUnitCount), so an out-of-range multi-bind can no longer slip
|
||||
// past this check and be caught one element at a time with the wrong error class.
|
||||
const GLint maxTextureUnits = GetSamplerBindableTextureUnitCount();
|
||||
if (static_cast<Uint64>(first) + static_cast<Uint64>(count) > static_cast<Uint64>(maxTextureUnits)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "BindSamplers",
|
||||
"first + count exceeds the number of texture units."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (GLsizei i = 0; i < count; ++i) {
|
||||
BindSampler_State(first + i, samplers ? samplers[i] : 0);
|
||||
|
||||
@@ -75,7 +75,11 @@ namespace MobileGL::MG_Impl::GLImpl::SamplerImpl {
|
||||
break;
|
||||
|
||||
case GL_TEXTURE_COMPARE_FUNC:
|
||||
if (param < GL_LEQUAL || param > GL_ALWAYS) {
|
||||
// The eight depth-compare functions are contiguous from GL_NEVER (0x0200) to
|
||||
// GL_ALWAYS (0x0207); GL_LEQUAL sits in the middle of that block, so starting
|
||||
// the range there rejected NEVER/LESS/EQUAL and let GREATER/NOTEQUAL/GEQUAL
|
||||
// through only by accident of them being above LEQUAL.
|
||||
if (param < GL_NEVER || param > GL_ALWAYS) {
|
||||
MG_State::pGLContext->RecordError(ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "ValidateSamplerParam",
|
||||
"Invalid compare function parameter"));
|
||||
|
||||
@@ -86,17 +86,65 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return false;
|
||||
}
|
||||
|
||||
// DSA emulation: the by-name entry points are implemented by putting the named texture
|
||||
// on the active unit's slot for their target, running the classic bound-texture code,
|
||||
// then putting the previous binding back.
|
||||
//
|
||||
// Both of those binds are REAL changes to "which texture is bound at this unit" for as
|
||||
// long as `fn` runs, so both have to move the texture bind generation. Backends memoise
|
||||
// per-unit work keyed on that generation and BORROW the binding slot (they hold a
|
||||
// pointer to the slot's shared_ptr, not a copy); a slot swap the generation never saw
|
||||
// let such a memo replay texture A's backend twin against texture B now sitting in the
|
||||
// slot - which re-specified A's backend storage with B's shape and silently destroyed
|
||||
// A's GPU-rendered contents (Minecraft's lightmap, blanked by a by-name upload to an
|
||||
// Iris shadow map, which then discarded every glyph).
|
||||
//
|
||||
// The generation is bumped directly rather than through NoteTextureUnitTouched because
|
||||
// the touched-unit HIGH-WATER MARK must NOT move: glActiveTexture does not advance it,
|
||||
// so a DSA-only app would otherwise have every later draw walk up to the highest unit it
|
||||
// ever aimed a by-name call at. Not advancing it is also sufficient - a unit above the
|
||||
// mark is outside every memo's coverage and outside the epoch walk, so nothing can
|
||||
// observe the transient swap there; at or below it, the bump is exactly what makes the
|
||||
// epoch re-derive. Bumping only on a real change keeps the very common redundant case (a
|
||||
// by-name call on the texture already bound to the active unit) free.
|
||||
//
|
||||
// The restore is a scope guard because `fn` can throw (the unsupported-state paths use
|
||||
// THROW_EXCEPTION): leaking the temporary binding would leave the wrong texture bound to
|
||||
// a live unit for the rest of the context's life.
|
||||
template <typename Fn>
|
||||
void WithTemporarilyBoundNamedTexture(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
|
||||
Fn&& fn) {
|
||||
if (!textureObject) return;
|
||||
|
||||
auto& activeUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit());
|
||||
const Int activeUnitIndex = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& activeUnit = MG_State::pGLContext->GetTextureUnitObject(activeUnitIndex);
|
||||
auto& bindingSlot = activeUnit.GetBindingSlot(textureObject->GetTarget());
|
||||
const auto previousBinding = bindingSlot.GetBoundObject();
|
||||
bindingSlot.Bind(textureObject);
|
||||
|
||||
using SlotType = std::remove_reference_t<decltype(bindingSlot)>;
|
||||
class ScopedSlotRestore {
|
||||
public:
|
||||
ScopedSlotRestore(SlotType& slot, SharedPtr<MG_State::GLState::ITextureObject> previous)
|
||||
: m_slot(slot), m_previous(Move(previous)) {}
|
||||
~ScopedSlotRestore() {
|
||||
if (m_slot.Bind(m_previous)) {
|
||||
MG_State::pGLContext->BumpTextureBindGeneration();
|
||||
}
|
||||
}
|
||||
ScopedSlotRestore(const ScopedSlotRestore&) = delete;
|
||||
ScopedSlotRestore& operator=(const ScopedSlotRestore&) = delete;
|
||||
|
||||
private:
|
||||
SlotType& m_slot;
|
||||
SharedPtr<MG_State::GLState::ITextureObject> m_previous;
|
||||
};
|
||||
|
||||
if (bindingSlot.Bind(textureObject)) {
|
||||
MG_State::pGLContext->BumpTextureBindGeneration();
|
||||
}
|
||||
ScopedSlotRestore restore(bindingSlot, previousBinding);
|
||||
|
||||
fn(MG_Util::ConvertTextureTargetToGLEnum(textureObject->GetTarget()));
|
||||
bindingSlot.Bind(previousBinding);
|
||||
}
|
||||
|
||||
SizeT ComputeTextureStorageByteSize(TextureInternalFormat textureInternalFormat, GLsizei width, GLsizei height,
|
||||
|
||||
@@ -106,6 +106,21 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return pname == GL_CURRENT_VERTEX_ATTRIB;
|
||||
}
|
||||
|
||||
// The two ARB_vertex_attrib_binding per-attribute queries. They do not live on the
|
||||
// resolved VertexAttribute (which is the flat, already-combined view) but on the VAO's
|
||||
// binding-point mapping, so they need the object, not the attribute.
|
||||
static bool TryGetVertexAttribBindingQuery(GLuint index, GLenum pname, GLint& out) {
|
||||
if (pname != GL_VERTEX_ATTRIB_BINDING && pname != GL_VERTEX_ATTRIB_RELATIVE_OFFSET) return false;
|
||||
const auto& vao = MG_State::pGLContext->GetBoundVertexArray();
|
||||
if (!vao) {
|
||||
out = 0;
|
||||
return true;
|
||||
}
|
||||
out = pname == GL_VERTEX_ATTRIB_BINDING ? static_cast<GLint>(vao->GetAttributeBindingIndex(index))
|
||||
: static_cast<GLint>(vao->GetAttributeRelativeOffset(index));
|
||||
return true;
|
||||
}
|
||||
|
||||
// The stride a pointer-style call gives its binding point: the argument when it is non-zero,
|
||||
// otherwise the tightly packed element size (GL 4.6 core 10.3.2). A packed 2_10_10_10 or
|
||||
// 10F_11F_11F attribute is one 32-bit word regardless of its component count.
|
||||
@@ -179,6 +194,11 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_VERTEX_ATTRIB_ARRAY_LONG:
|
||||
case GL_VERTEX_ATTRIB_ARRAY_DIVISOR:
|
||||
case GL_VERTEX_ATTRIB_ARRAY_POINTER:
|
||||
// ARB_vertex_attrib_binding (core since GL 4.3). The binding-point view is real
|
||||
// state on the VAO (GetAttributeBindingIndex / GetAttributeRelativeOffset), so
|
||||
// both of its per-attribute queries are answerable.
|
||||
case GL_VERTEX_ATTRIB_BINDING:
|
||||
case GL_VERTEX_ATTRIB_RELATIVE_OFFSET:
|
||||
return true;
|
||||
default:
|
||||
MG_State::pGLContext->RecordError(
|
||||
@@ -944,6 +964,13 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_VERTEX_ATTRIB_ARRAY_DIVISOR:
|
||||
params[0] = static_cast<GLfloat>(attr->Divisor);
|
||||
return;
|
||||
case GL_VERTEX_ATTRIB_BINDING:
|
||||
case GL_VERTEX_ATTRIB_RELATIVE_OFFSET: {
|
||||
GLint value = 0;
|
||||
TryGetVertexAttribBindingQuery(index, pname, value);
|
||||
params[0] = static_cast<GLfloat>(value);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
@@ -1007,6 +1034,13 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_VERTEX_ATTRIB_ARRAY_DIVISOR:
|
||||
params[0] = static_cast<GLdouble>(attr->Divisor);
|
||||
return;
|
||||
case GL_VERTEX_ATTRIB_BINDING:
|
||||
case GL_VERTEX_ATTRIB_RELATIVE_OFFSET: {
|
||||
GLint value = 0;
|
||||
TryGetVertexAttribBindingQuery(index, pname, value);
|
||||
params[0] = static_cast<GLdouble>(value);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
@@ -1066,6 +1100,10 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_VERTEX_ATTRIB_ARRAY_DIVISOR:
|
||||
params[0] = static_cast<GLint>(attr->Divisor);
|
||||
return;
|
||||
case GL_VERTEX_ATTRIB_BINDING:
|
||||
case GL_VERTEX_ATTRIB_RELATIVE_OFFSET:
|
||||
TryGetVertexAttribBindingQuery(index, pname, params[0]);
|
||||
return;
|
||||
default:
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
@@ -1204,6 +1242,9 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_VERTEX_ATTRIB_RELATIVE_OFFSET:
|
||||
*param = static_cast<GLint>(vao->GetAttributeRelativeOffset(index));
|
||||
return;
|
||||
case GL_VERTEX_ATTRIB_BINDING:
|
||||
*param = static_cast<GLint>(vao->GetAttributeBindingIndex(index));
|
||||
return;
|
||||
default:
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
|
||||
@@ -147,6 +147,30 @@ namespace MobileGL::MG_Impl::GLImpl::VertexArrayImpl {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The integer path takes exactly the six signed/unsigned integer types (GL 4.6
|
||||
// core 10.3.2): BYTE, UNSIGNED_BYTE, SHORT, UNSIGNED_SHORT, INT, UNSIGNED_INT.
|
||||
// A blacklist could not express that: GL_FLOAT, GL_HALF_FLOAT,
|
||||
// GL_DOUBLE and GL_FIXED all convert to a perfectly valid DataType, so they slipped
|
||||
// through and were recorded as integer attributes.
|
||||
if (integerPath) {
|
||||
switch (type) {
|
||||
case DataType::Int8:
|
||||
case DataType::Uint8:
|
||||
case DataType::Int16:
|
||||
case DataType::Uint16:
|
||||
case DataType::Int32:
|
||||
case DataType::Uint32:
|
||||
break;
|
||||
default:
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>(
|
||||
"MG_Impl/GLImpl", fn,
|
||||
std::format("Type is not an integer vertex attribute type (attribute {}).", index)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (sizeRaw == static_cast<GLint>(GL_BGRA)) {
|
||||
// GL_BGRA is a float-path-only size: it needs GL_UNSIGNED_BYTE or a 2_10_10_10 type and
|
||||
// normalized == GL_TRUE. On the integer path it is simply an out-of-range size.
|
||||
|
||||
@@ -51,6 +51,8 @@ add_executable(MobileGLIntegrationTest
|
||||
Scenarios/ResidentIndexScenario.cpp
|
||||
Scenarios/MultiDrawScenario.cpp
|
||||
Scenarios/AsyncCompileScenario.cpp
|
||||
Scenarios/XfbAfterClipDistanceScenario.cpp
|
||||
Scenarios/ThreeChannelAttachmentScenario.cpp
|
||||
)
|
||||
|
||||
target_include_directories(MobileGLIntegrationTest PRIVATE
|
||||
@@ -217,6 +219,8 @@ mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" ${MGL_ITEST_VULKAN_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_ASYNC_SHADER_COMPILE=1" ${MGL_ITEST_VULKAN_ENV})
|
||||
|
||||
# TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it.
|
||||
set(MGL_ITEST_TIMEOUT 120)
|
||||
@@ -243,3 +247,24 @@ gtest_discover_tests(MobileGLIntegrationTest
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
# A third registration, of ONE scenario, with asynchronous shader compilation
|
||||
# pinned on. Not a second code path in the renderer: a second ALLOCATION pattern.
|
||||
# The async pipeline's job objects change which of the freed blocks the capture
|
||||
# phase is handed, and that is what decides whether the destroyed-VAO address is
|
||||
# reached at all - on the ablated (pre-fix) tree async=1 reproduced 3 runs out of
|
||||
# 3 where the ambient default reproduced 2 of 3. Pinning it here means the
|
||||
# high-signal configuration runs whatever the shipped default becomes, instead of
|
||||
# the suite quietly weakening the day that default flips. It must be process-wide
|
||||
# (the ENVIRONMENT property), not an in-process scope: the compile pool and its
|
||||
# threads are stood up at initialization, and their allocations are half the
|
||||
# point. DirectVulkan only - the memo this pins is DirectVulkan's.
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectVulkan.AsyncCompile."
|
||||
TEST_FILTER "XfbAfterClipDistanceScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,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 <atomic>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
namespace {
|
||||
const BufferBackendOps* g_bufferBackendOps = nullptr;
|
||||
// Starts at 1 so a zero-initialized cache slot can never carry a live buffer's id.
|
||||
std::atomic<Uint64> g_nextBufferLifetimeId{1};
|
||||
}
|
||||
|
||||
Uint64 BufferObject::AllocateLifetimeId() {
|
||||
return g_nextBufferLifetimeId.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void SetBufferBackendOps(const BufferBackendOps* ops) {
|
||||
@@ -198,8 +206,11 @@ namespace MobileGL::MG_State::GLState {
|
||||
}
|
||||
|
||||
void BufferObject::UploadSubData(DataPtr data, SizeT atOffset) {
|
||||
MOBILEGL_ASSERT(!m_isMapped || (m_mappingAccess & BufferMappingAccessBit::Persistent),
|
||||
"Cannot upload sub data while buffer is non-persistently mapped.");
|
||||
// GL 4.6 core 6.5 forbids only the OVERLAPPING write: a glBufferSubData that stays
|
||||
// clear of a non-persistent mapping is legal, and the frontend lets it through.
|
||||
MOBILEGL_ASSERT(!m_isMapped || (m_mappingAccess & BufferMappingAccessBit::Persistent) ||
|
||||
atOffset >= m_mappedRange.end || atOffset + data.size <= m_mappedRange.start,
|
||||
"Cannot upload sub data overlapping a non-persistent mapping.");
|
||||
MOBILEGL_ASSERT(atOffset + data.size <= m_size,
|
||||
"UploadSubData out of bounds: atOffset (%zu) + data.size (%zu) > m_size (%zu)", atOffset,
|
||||
data.size, m_size);
|
||||
|
||||
@@ -185,6 +185,13 @@ namespace MobileGL {
|
||||
Flags<BufferMappingAccessBit> GetMappingAccess() const;
|
||||
GLbitfield GetStorageFlags() const;
|
||||
Uint GetExternalIndex() const;
|
||||
// Globally-unique, never-reused id for THIS object's lifetime - same contract
|
||||
// and same motivation as ProgramObject::GetLifetimeId() and
|
||||
// VertexArrayObject::GetLifetimeId(). A backend that folds a buffer's IDENTITY
|
||||
// into a cache key must use this, never the GL name (LIFO-recycled by
|
||||
// glGenBuffers) and never the heap address (recycled by the allocator): both
|
||||
// let a deleted-and-recreated buffer answer to a dead one's cache entry.
|
||||
Uint64 GetLifetimeId() const { return m_lifetimeId; }
|
||||
// Monotonic counter bumped on every shadow mutation; backends use it to
|
||||
// validate cached transient slices.
|
||||
Uint64 GetChangeSerial() const;
|
||||
@@ -207,7 +214,10 @@ namespace MobileGL {
|
||||
// SubData transfer to sync the backend's separate GPU copy.
|
||||
void NotifyContentWrite(SizeT offset, SizeT size);
|
||||
|
||||
static Uint64 AllocateLifetimeId();
|
||||
|
||||
const Uint m_externalIndex = 0;
|
||||
const Uint64 m_lifetimeId = AllocateLifetimeId();
|
||||
SizeT m_size = 0;
|
||||
BufferUsage m_usage = BufferUsage::StaticDraw;
|
||||
// Owns the buffer's bytes (CPU shadow or backend persistent GPU map) and
|
||||
|
||||
@@ -156,6 +156,11 @@ namespace MobileGL {
|
||||
// Settles every compile and link this context still owns; see
|
||||
// ProgramState::JoinAllPendingWork. Called by glMaxShaderCompilerThreadsKHR(0).
|
||||
void JoinAllPendingShaderWork();
|
||||
// P1 stage 6: the per-context index of adoptable compile nodes, for its
|
||||
// adoption counter. Diagnostics and tests only - no GL entry point reads it.
|
||||
ShaderCompileAdoptionMap& GetShaderCompileAdoptionMap() {
|
||||
return m_programState.GetShaderCompileAdoptionMap();
|
||||
}
|
||||
void UseProgram(Uint program);
|
||||
const SharedPtr<ProgramObject>& GetCurrentProgram();
|
||||
// What a draw or dispatch actually executes: the program in use, or - when
|
||||
|
||||
@@ -1057,6 +1057,10 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
Bool ProgramLinkTask::ResolveTransformFeedbackVaryings() {
|
||||
artifacts.xfbVaryings.clear();
|
||||
// The GL_TRANSFORM_FEEDBACK_VARYING interface enumerates the request verbatim -
|
||||
// pseudo-varyings included - while xfbVaryings below keeps only what is actually
|
||||
// captured. Snapshot it before the loop consumes gl_NextBuffer/gl_SkipComponentsN.
|
||||
artifacts.xfbInterfaceNames = in.requestedXfbVaryings;
|
||||
artifacts.xfbStrides.clear();
|
||||
artifacts.xfbBufferMode = in.requestedXfbBufferMode;
|
||||
artifacts.xfbVaryingNameMaxLength = 0;
|
||||
@@ -1127,15 +1131,45 @@ namespace MobileGL::MG_State::GLState {
|
||||
bytesPerElement = 4;
|
||||
resolved = true;
|
||||
} else if (linkerObjects != nullptr) {
|
||||
// GL lets a capture name a single element of an output array ("b[0]"), which
|
||||
// captures one element of the element type - not the whole array. Strip a
|
||||
// trailing strict-decimal subscript and look the base declaration up.
|
||||
String declaredName = name;
|
||||
Bool singleElement = false;
|
||||
Uint element = 0;
|
||||
if (name.size() > 3 && name.back() == ']') {
|
||||
const SizeT bracket = name.rfind('[');
|
||||
if (bracket != String::npos && bracket + 1 < name.size() - 1) {
|
||||
Bool digitsOnly = true;
|
||||
for (SizeT c = bracket + 1; c + 1 < name.size(); ++c) {
|
||||
if (name[c] < '0' || name[c] > '9') {
|
||||
digitsOnly = false;
|
||||
break;
|
||||
}
|
||||
element = element * 10 + static_cast<Uint>(name[c] - '0');
|
||||
}
|
||||
if (digitsOnly) {
|
||||
declaredName = name.substr(0, bracket);
|
||||
singleElement = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const auto* node : linkerObjects->getSequence()) {
|
||||
const glslang::TIntermSymbol* symbol = node->getAsSymbolNode();
|
||||
if (symbol == nullptr || symbol->getType().getQualifier().storage != glslang::EvqVaryingOut) {
|
||||
continue;
|
||||
}
|
||||
if (symbol->getName() != name.c_str()) {
|
||||
if (symbol->getName() != declaredName.c_str()) {
|
||||
continue;
|
||||
}
|
||||
resolved = ResolveXfbSymbolType(symbol->getType(), varying.type, varying.size, bytesPerElement);
|
||||
if (resolved && singleElement) {
|
||||
if (static_cast<Int>(element) >= varying.size) {
|
||||
resolved = false;
|
||||
break;
|
||||
}
|
||||
varying.size = 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
artifacts.explicitOpaqueUniformBindings.clear();
|
||||
artifacts.uniformBlockIndexByName.clear();
|
||||
artifacts.uniformBlockBinding.clear();
|
||||
artifacts.shaderStorageBlockBinding.clear();
|
||||
artifacts.uniformOffsets.clear();
|
||||
artifacts.uniformSizesInBytes.clear();
|
||||
artifacts.globalUboScratch.clear();
|
||||
@@ -127,6 +128,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
artifacts.attribInNameMaxLength = 0;
|
||||
artifacts.uniformBlockNameMaxLength = 0;
|
||||
artifacts.xfbVaryings.clear();
|
||||
artifacts.xfbInterfaceNames.clear();
|
||||
artifacts.xfbStrides.clear();
|
||||
artifacts.xfbBufferMode = GL_INTERLEAVED_ATTRIBS;
|
||||
artifacts.xfbVaryingNameMaxLength = 0;
|
||||
|
||||
@@ -82,6 +82,14 @@ namespace MobileGL::MG_State::GLState {
|
||||
return -1;
|
||||
}
|
||||
if (name.length() < 4) return -1;
|
||||
// An array of arrays is keyed by its full "[0]"-terminated spelling
|
||||
// ("a[2][1][0]"), so a query that already ends in a subscript may still be the
|
||||
// NAME of an array rather than an element of one. Try that first; only then
|
||||
// treat the trailing subscript as an element index.
|
||||
{
|
||||
const auto arrayOfArraysIt = Artifacts().uniformLocations.find(name + "[0]");
|
||||
if (arrayOfArraysIt != Artifacts().uniformLocations.end()) return (Int)arrayOfArraysIt->second;
|
||||
}
|
||||
const SizeT bracket = name.rfind('[');
|
||||
// Require at least one digit between the brackets.
|
||||
if (bracket == String::npos || bracket + 1 >= name.length() - 1) return -1;
|
||||
@@ -130,6 +138,14 @@ namespace MobileGL::MG_State::GLState {
|
||||
if (tIndex < 0 || tIndex >= static_cast<Int>(Artifacts().tProgramUniformIndexToGl.size())) return -1;
|
||||
return Artifacts().tProgramUniformIndexToGl[tIndex];
|
||||
}
|
||||
// GL uniform-block index -> glslang TProgram block index (the inverse of
|
||||
// GlBlockIndexFromTProgram). The interface-query layer needs it to reach block
|
||||
// properties glslang exposes but no typed getter here does.
|
||||
Int TProgramBlockIndex(Uint glBlockIndex) const {
|
||||
return glBlockIndex < Artifacts().glBlockIndexToTProgram.size()
|
||||
? Artifacts().glBlockIndexToTProgram[glBlockIndex]
|
||||
: -1;
|
||||
}
|
||||
Int GlBlockIndexFromTProgram(Int tBlockIndex) const {
|
||||
if (tBlockIndex < 0 || tBlockIndex >= static_cast<Int>(Artifacts().tProgramBlockIndexToGl.size())) return -1;
|
||||
return Artifacts().tProgramBlockIndexToGl[tBlockIndex];
|
||||
@@ -529,9 +545,42 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
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; }
|
||||
const Vector<Vector<unsigned>>& GetGeneratedSpirv() const { return Artifacts().generatedSpirv; }
|
||||
|
||||
// The linked glslang reflection itself, for the ONE consumer that needs resource
|
||||
// lists no typed getter above exposes: the GL program-interface query layer
|
||||
// (MG_Impl/GLImpl/Program/ProgramInterface.cpp), which has to enumerate buffer
|
||||
// blocks, buffer variables, atomic counters and per-stage reference masks. Null
|
||||
// until a link has succeeded. Read through the join gate like everything else.
|
||||
const glslang::TProgram* GetReflection() const { return Artifacts().program.get(); }
|
||||
|
||||
Int GetShaderIndexByStage(ShaderStage stage) const {
|
||||
auto it = std::find_if(m_shaders.begin(), m_shaders.end(), [stage](const SharedPtr<ShaderObject>& shader) {
|
||||
return shader->GetShaderStage() == stage;
|
||||
@@ -611,6 +660,9 @@ namespace MobileGL::MG_State::GLState {
|
||||
// These may change after-link (because GL spec decided to have `glUniformBlockBinding`)
|
||||
UnorderedMap<String, Uint> uniformBlockIndexByName;
|
||||
Vector<Int> uniformBlockBinding;
|
||||
// glShaderStorageBlockBinding overrides, keyed by GL block name. See
|
||||
// SetShaderStorageBlockBinding for why this one is by name and not by index.
|
||||
UnorderedMap<String, Int> shaderStorageBlockBinding;
|
||||
|
||||
// Need to be reflected after linking of SPIR-V binary
|
||||
Vector<Uint> uniformOffsets;
|
||||
@@ -629,6 +681,12 @@ namespace MobileGL::MG_State::GLState {
|
||||
// Transform feedback: the linked snapshot (the request lives outside, on the
|
||||
// GL-thread-owned side).
|
||||
Vector<XfbVarying> xfbVaryings;
|
||||
// The glTransformFeedbackVaryings request list exactly as this link consumed it,
|
||||
// INCLUDING the gl_NextBuffer / gl_SkipComponentsN pseudo-varyings that
|
||||
// xfbVaryings deliberately drops (they steer the capture layout and must never
|
||||
// reach a backend's varying list). GL_TRANSFORM_FEEDBACK_VARYING enumerates the
|
||||
// full request, pseudo-varyings and all, so the interface query needs its own copy.
|
||||
Vector<String> xfbInterfaceNames;
|
||||
Vector<Uint32> xfbStrides;
|
||||
Vector<Uint32> gsStripTriangles;
|
||||
Bool gsStripCaptureFixup = false;
|
||||
@@ -711,6 +769,9 @@ namespace MobileGL::MG_State::GLState {
|
||||
return index < Artifacts().xfbVaryings.size() ? &Artifacts().xfbVaryings[index] : nullptr;
|
||||
}
|
||||
const Vector<XfbVarying>& GetTransformFeedbackVaryings() const { return Artifacts().xfbVaryings; }
|
||||
// The GL_TRANSFORM_FEEDBACK_VARYING resource list: every name the last successful
|
||||
// link was asked to capture, in request order, pseudo-varyings included.
|
||||
const Vector<String>& GetTransformFeedbackInterfaceNames() const { return Artifacts().xfbInterfaceNames; }
|
||||
// Stride of one captured vertex in the given capture buffer slot.
|
||||
Uint32 GetTransformFeedbackStride(Uint32 bufferIndex) const {
|
||||
return bufferIndex < Artifacts().xfbStrides.size() ? Artifacts().xfbStrides[bufferIndex] : 0;
|
||||
|
||||
@@ -87,7 +87,8 @@ namespace MobileGL::MG_State::GLState {
|
||||
Uint shaderId = 0;
|
||||
m_programShaderNameGenerator.Generate(1, &shaderId);
|
||||
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;
|
||||
m_shaderObjects[shaderId] = shaderObject;
|
||||
return shaderId;
|
||||
@@ -153,11 +154,13 @@ namespace MobileGL::MG_State::GLState {
|
||||
auto& shaderObject = m_shaderObjects[shader];
|
||||
if (shaderObject == nullptr || !shaderObject->GetDeleteStatus()) return;
|
||||
if (ShaderHasGLVisibleAttachment(shaderObject)) return;
|
||||
// The name is about to go: nothing can observe this shader's compile any more, so a
|
||||
// job still in flight for it is pure waste. Cancel-not-join - 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->CancelCompile();
|
||||
// The name is about to go, so nothing can observe this shader's compile through THIS
|
||||
// object any more and a job still in flight for it is pure waste - unless another
|
||||
// shader object adopted the same node (stage 6) or a pending link pinned it, which is
|
||||
// exactly what ReleaseCompileNode weighs before it cancels anything. Cancel-not-join
|
||||
// either way: the job owns its inputs, so dropping the object out from under it is
|
||||
// safe and the GL thread never blocks on a delete.
|
||||
shaderObject->ReleaseCompileNode();
|
||||
shaderObject.reset();
|
||||
m_programShaderNameGenerator.Delete(shader);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <Includes.h>
|
||||
#include <MG_Util/Miscellany/IndexGenerator.h>
|
||||
#include "ProgramObject.h"
|
||||
#include "ShaderCompileAdoptionMap.h"
|
||||
#include "ShaderPreprocessCache.h"
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
@@ -52,6 +53,11 @@ namespace MobileGL::MG_State::GLState {
|
||||
// CreateShader().
|
||||
ShaderPreprocessCache& GetShaderPreprocessCache() { return *m_shaderPreprocessCache; }
|
||||
|
||||
// P1 stage 6, same deal: exposed for tests and diagnostics only. Its adoption counter
|
||||
// is the one number that says how many glCompileShader calls this context turned into
|
||||
// no work at all; nothing in the GL frontend branches on it.
|
||||
ShaderCompileAdoptionMap& GetShaderCompileAdoptionMap() { return *m_shaderCompileAdoptionMap; }
|
||||
|
||||
private:
|
||||
Bool ShaderHasGLVisibleAttachment(const SharedPtr<ShaderObject>& shaderObject) const;
|
||||
// Frees the name slot and releases orphaned attached shaders; the immediate half
|
||||
@@ -82,6 +88,11 @@ namespace MobileGL::MG_State::GLState {
|
||||
// in-flight compile job may outlive the context). The FIRST-member declaration is
|
||||
// kept anyway - it costs nothing and documents the intent.
|
||||
SharedPtr<ShaderPreprocessCache> m_shaderPreprocessCache = MakeShared<ShaderPreprocessCache>();
|
||||
// P1 stage 6: the GL-thread-only index of adoptable compile nodes. Shared ownership
|
||||
// for the same reason as the cache above - a ShaderObject held by a ProgramObject can
|
||||
// outlive these tables, and its destructor releases a node - though unlike the cache
|
||||
// no worker ever sees this one, which is why it carries no lock.
|
||||
SharedPtr<ShaderCompileAdoptionMap> m_shaderCompileAdoptionMap = MakeShared<ShaderCompileAdoptionMap>();
|
||||
|
||||
Vector<SharedPtr<ProgramObject>> m_programObjects;
|
||||
Vector<SharedPtr<ShaderObject>> m_shaderObjects;
|
||||
|
||||
@@ -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
|
||||
@@ -118,13 +118,61 @@ namespace MobileGL::MG_State::GLState {
|
||||
// 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::CancelCompile.
|
||||
// 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.
|
||||
@@ -132,5 +180,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
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
|
||||
|
||||
@@ -26,16 +26,18 @@ namespace MobileGL::MG_State::GLState {
|
||||
// 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. Drop it where it stands - it owns its own copy of that old string,
|
||||
// so swapping the pointer below cannot race its storage.
|
||||
CancelCompile();
|
||||
// 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;
|
||||
CancelCompile();
|
||||
ReleaseCompileNode();
|
||||
m_source = MakeShared<const String>(Move(source));
|
||||
InvalidateCompiledState();
|
||||
}
|
||||
@@ -59,33 +61,75 @@ namespace MobileGL::MG_State::GLState {
|
||||
// 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.
|
||||
if (!m_compiled->IsComplete()) m_compiled.reset();
|
||||
//
|
||||
// Through DropCompileNode, not a bare reset: this object is letting the node go, so
|
||||
// its adopter slot has to go with it. A node shared with another object stays alive
|
||||
// and gets dropped once more when that object joins - once per holder, never twice
|
||||
// for the same one, because DropCompileNode is null-guarded.
|
||||
if (!m_compiled->IsComplete()) DropCompileNode();
|
||||
}
|
||||
|
||||
void ShaderObject::AdoptCompileNode(SharedPtr<ShaderCompileTask> node) const {
|
||||
// Never overwrite a hold without giving its slot back first.
|
||||
DropCompileNode();
|
||||
m_compiled = Move(node);
|
||||
m_compiled->AddAdopter();
|
||||
// Re-arm the join gate: whether this node was just created or just adopted from
|
||||
// another object, THIS object has not pulled its result yet. (An adopted node may
|
||||
// already be terminal - the join then only replays what is left of its diagnostics.)
|
||||
m_compileJoined = false;
|
||||
}
|
||||
|
||||
void ShaderObject::DropCompileNode() const {
|
||||
if (!m_compiled) return;
|
||||
m_compiled->ReleaseAdopter();
|
||||
m_compiled.reset();
|
||||
}
|
||||
|
||||
void ShaderObject::InvalidateCompiledState() {
|
||||
// The job node holds exactly what one Compile() produces, so discarding it IS the
|
||||
// invalidation - and it re-arms nothing, so the next Compile() genuinely recompiles.
|
||||
m_compiled.reset();
|
||||
DropCompileNode();
|
||||
}
|
||||
|
||||
void ShaderObject::CancelCompile() {
|
||||
if (!m_compiled || m_compiled->IsTerminal()) return;
|
||||
// Cooperative and non-blocking. A node that no worker has picked up settles
|
||||
// immediately; one that is running is flagged and settles when its body returns,
|
||||
// writing only into itself the whole time.
|
||||
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.
|
||||
//
|
||||
// Unless a pending LINK is waiting on it. Cancelling is about discarding a result
|
||||
// nothing can observe any more, and this object is no longer the only route to this
|
||||
// one: an enqueued ProgramLinkTask holds the node as a dependency, and a cancel would
|
||||
// turn its link into GL_FALSE. Reached by the ordinary link-then-detach-then-delete
|
||||
// shader teardown - see ShaderCompileTask::MarkLinkReferenced. Dropping our own
|
||||
// reference is still right; the link keeps the node alive and finishes it.
|
||||
if (!m_compiled->IsLinkReferenced()) m_compiled->Cancel();
|
||||
m_compiled.reset();
|
||||
// 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() {
|
||||
@@ -103,14 +147,6 @@ namespace MobileGL::MG_State::GLState {
|
||||
// source instead. Same result, one parse either way.
|
||||
if (HasMemoizedCompile()) return;
|
||||
|
||||
// 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.
|
||||
m_compiled = MakeShared<ShaderCompileTask>(m_stage, m_source, ShaderPreprocessCache::HashSource(*m_source),
|
||||
MG_Util::ShaderTranspiler::GetCurrentCompileEnv(),
|
||||
m_preprocessCache, m_externalIndex);
|
||||
m_compileJoined = false;
|
||||
|
||||
// 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
|
||||
@@ -119,7 +155,48 @@ namespace MobileGL::MG_State::GLState {
|
||||
// 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.
|
||||
if (!m_preprocessCache || !MG_Util::Async::AsyncShaderCompileActive()) {
|
||||
//
|
||||
// Hoisted above the node construction because stage 6 keys off it too: this same
|
||||
// answer decides whether the adoption map is consulted at all, so a
|
||||
// glMaxShaderCompilerThreadsKHR(0) and a flag-off build both bypass sharing exactly
|
||||
// as they bypass the pool, and their behaviour stays byte-identical to pre-stage-6.
|
||||
const Bool runOnPool = m_preprocessCache && MG_Util::Async::AsyncShaderCompileActive();
|
||||
|
||||
// The compile-environment snapshot is taken HERE, on the GL thread, and handed to
|
||||
// the job. Everything the pipeline needs to know about the device comes through it,
|
||||
// never through pActiveBackendObject - that is what makes the body movable.
|
||||
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env =
|
||||
MG_Util::ShaderTranspiler::GetCurrentCompileEnv();
|
||||
const Uint64 sourceHash = ShaderPreprocessCache::HashSource(*m_source);
|
||||
|
||||
// ---- P1 stage 6: adopt an equivalent compile instead of enqueueing a duplicate ----
|
||||
// ~21% of all glCompileShader calls in the shaderpack corpus are a DIFFERENT shader
|
||||
// object handed byte-identical source. P0b's memo only pays off once one of them has
|
||||
// finished; under async they are all enqueued in the same burst, so without this each
|
||||
// one runs the whole pipeline on its own worker. The map hands back the node the
|
||||
// first of them created - in flight or already complete - and this object simply
|
||||
// holds it too.
|
||||
if (runOnPool && m_adoptionMap) {
|
||||
if (SharedPtr<ShaderCompileTask> shared =
|
||||
m_adoptionMap->FindAdoptable(m_stage, sourceHash, *m_source, env->fingerprint)) {
|
||||
// Take the node's own source snapshot as ours. FindAdoptable just compared
|
||||
// the two strings in full, so this changes nothing observable - but it is not
|
||||
// optional: the layer-1 memo (HasMemoizedCompile) is a POINTER comparison
|
||||
// against the node's snapshot, so leaving our own equal-but-distinct copy in
|
||||
// place would make the very next glCompileShader on this object decide it had
|
||||
// no memo and enqueue the duplicate this whole stage exists to avoid - and
|
||||
// would make an identical glShaderSource re-source cancel a shared compile.
|
||||
// It also collapses N copies of a ~100 KB shaderpack stage into one.
|
||||
m_source = shared->source;
|
||||
AdoptCompileNode(Move(shared));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -127,6 +204,10 @@ namespace MobileGL::MG_State::GLState {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <Includes.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderStage.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderCompileTask.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h>
|
||||
|
||||
namespace MobileGL {
|
||||
namespace MG_State::GLState {
|
||||
@@ -31,13 +32,32 @@ namespace MobileGL {
|
||||
// add a round trip. Shared ownership rather than a raw pointer: a compile job
|
||||
// outlives neither the object nor the context deterministically, and the cache
|
||||
// has to stay alive for whoever is still reading it.
|
||||
//
|
||||
// `adoptionMap` is the same context's stage-6 index of adoptable compile nodes.
|
||||
// It is non-null exactly when `preprocessCache` is (ProgramState hands both out
|
||||
// together, and nobody else hands out either), which is what makes "no cache"
|
||||
// keep meaning "compile inline, share nothing": an internal shader object has
|
||||
// neither, so it neither adopts nor registers and its path is byte-identical to
|
||||
// the pre-stage-6 one. GL-thread-only, so unlike the cache it carries no lock -
|
||||
// shared ownership only because a ShaderObject may outlive the context's tables.
|
||||
ShaderObject(const ShaderStage stage, Uint externalIndex,
|
||||
SharedPtr<ShaderPreprocessCache> preprocessCache = nullptr)
|
||||
: m_stage(stage), m_externalIndex(externalIndex), m_preprocessCache(Move(preprocessCache)) {}
|
||||
SharedPtr<ShaderPreprocessCache> preprocessCache = nullptr,
|
||||
SharedPtr<ShaderCompileAdoptionMap> adoptionMap = nullptr)
|
||||
: m_stage(stage), m_externalIndex(externalIndex), m_preprocessCache(Move(preprocessCache)),
|
||||
m_adoptionMap(Move(adoptionMap)) {}
|
||||
// Cancel-not-join: the node owns its inputs, so an in-flight compile whose
|
||||
// object just went away is safe to abandon where it stands. Nothing can observe
|
||||
// its result any more - this object was the only route to it.
|
||||
~ShaderObject() { CancelCompile(); }
|
||||
// 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;
|
||||
@@ -45,10 +65,17 @@ namespace MobileGL {
|
||||
void SetShaderSource(const String& source);
|
||||
void SetShaderSource(String&& source);
|
||||
void Compile();
|
||||
// Drops a compile that is still in flight, without waiting for it. Called at the
|
||||
// points where the object's compiled state stops being observable: a real source
|
||||
// change, and the release of an orphaned shader name.
|
||||
void CancelCompile();
|
||||
// Gives up this object's claim on its compile node, cancelling the node only if
|
||||
// this object was its LAST claimant. Called at the points where the object's
|
||||
// compiled state stops being observable through THIS name: a real source change,
|
||||
// and the release of an orphaned shader name.
|
||||
//
|
||||
// Named for what it does rather than for what it used to do: before stage 6 a
|
||||
// node had exactly one shader object, so giving up the claim and cancelling the
|
||||
// compile were the same act and this was CancelCompile(). They are not the same
|
||||
// act any more - see ShaderCompileTask::AddAdopter for the count discipline and
|
||||
// its single-threadedness argument. Never waits, in either case.
|
||||
void ReleaseCompileNode();
|
||||
void MarkAsDeleted();
|
||||
|
||||
// The compile job node itself, for ProgramObject::Link()'s input snapshot.
|
||||
@@ -146,6 +173,16 @@ namespace MobileGL {
|
||||
}
|
||||
|
||||
void InvalidateCompiledState();
|
||||
|
||||
// ---- the ONLY two writers of m_compiled (P1 stage 6) ----
|
||||
// Every adopter-count mutation lives in these two, which is what makes "exactly
|
||||
// one AddAdopter per hold, exactly one ReleaseAdopter per hold" auditable rather
|
||||
// than something review has to re-derive at each call site. DropCompileNode is
|
||||
// null-guarded, so calling it on an object that already let go is a no-op and a
|
||||
// double release is unrepresentable.
|
||||
void AdoptCompileNode(SharedPtr<ShaderCompileTask> node) const;
|
||||
void DropCompileNode() const;
|
||||
|
||||
// ---- P0b layer 1: per-object no-op recompile ----
|
||||
// True iff `candidate` is byte-identical to the source that produced (or is
|
||||
// producing) the compiled state this object currently holds.
|
||||
@@ -165,17 +202,31 @@ namespace MobileGL {
|
||||
// running compile cannot race its storage - and the layer-1 memo collapses to a
|
||||
// pointer comparison against the job's snapshot, because the setter only swaps
|
||||
// the pointer when the text genuinely differs.
|
||||
//
|
||||
// Not necessarily unique to this object from stage 6 on: adopting a node also
|
||||
// takes that node's source snapshot (see Compile()), so N shader objects sharing
|
||||
// one compile share one copy of the text. The string is immutable and shared-
|
||||
// owned, so that is invisible to every reader.
|
||||
SharedPtr<const String> m_source = EmptySource();
|
||||
|
||||
// P0b layer 2: the owning context's cross-object memo, or null. Internally
|
||||
// locked, because several workers hit it at once.
|
||||
const SharedPtr<ShaderPreprocessCache> m_preprocessCache;
|
||||
// P1 stage 6: the owning context's index of adoptable compile nodes, or null.
|
||||
// Touched only from Compile(), i.e. only on the GL thread, so it carries no lock.
|
||||
const SharedPtr<ShaderCompileAdoptionMap> m_adoptionMap;
|
||||
|
||||
Bool m_deleteStatus = false;
|
||||
|
||||
// ---- Compile OUTPUT ---- pending OR completed; reachable only through Compiled().
|
||||
// Mutable because the join is a read-side operation: a const getter has to be
|
||||
// able to settle an outstanding job before answering.
|
||||
//
|
||||
// SHARED from stage 6 on: several shader objects holding byte-identical source
|
||||
// under the same CompileEnv point at one node. Every read below still goes
|
||||
// through the same join gate, and a second joiner finds the node already
|
||||
// terminal, so nothing about the read path changes - only the release path does
|
||||
// (ReleaseCompileNode).
|
||||
mutable SharedPtr<ShaderCompileTask> m_compiled;
|
||||
// Exactly-once latch for the pull above. Armed with every new job node, set by
|
||||
// the one join that consumes it.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// Deliberately NOT ShaderObject.h: ShaderCompileTask.h needs this header, and ShaderObject.h
|
||||
// needs ShaderCompileTask.h. Only ShaderStage was ever used from there.
|
||||
#include <MG_State/GLState/ProgramState/ShaderStage.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderSourceKey.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// Where the shared, source-only half of ShaderObject::Compile() stopped. The two
|
||||
@@ -112,29 +113,10 @@ namespace MobileGL::MG_State::GLState {
|
||||
}
|
||||
|
||||
private:
|
||||
struct Key {
|
||||
ShaderStage stage = ShaderStage::Unknown;
|
||||
Uint64 sourceHash = 0;
|
||||
SizeT sourceLength = 0;
|
||||
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);
|
||||
}
|
||||
};
|
||||
// Shared with ShaderCompileAdoptionMap so the two per-context memos cannot key
|
||||
// themselves on different notions of "the same compile" - see ShaderSourceKey.h.
|
||||
using Key = ShaderSourceKey;
|
||||
using KeyHasher = ShaderSourceKeyHasher;
|
||||
|
||||
struct Entry {
|
||||
Key key;
|
||||
|
||||
@@ -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
|
||||
@@ -8,7 +8,18 @@
|
||||
|
||||
#include "VertexArrayObject.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// Starts at 1 so a zero-initialized memo slot can never carry a live object's id.
|
||||
// Atomic because VAOs are GL-thread-only today but the counter costs nothing to
|
||||
// make safe, and a duplicate id would resurrect exactly the bug it exists to kill.
|
||||
static std::atomic<Uint64> s_nextVertexArrayLifetimeId{1};
|
||||
|
||||
Uint64 VertexArrayObject::AllocateLifetimeId() {
|
||||
return s_nextVertexArrayLifetimeId.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
VertexArrayObject::VertexArrayObject(Uint externIndex) : m_externalIndex(externIndex) {
|
||||
for (int index = 0; index < MAX_VERTEX_ATTRIBS; ++index) {
|
||||
auto& attr = m_attributes[index];
|
||||
|
||||
@@ -84,6 +84,18 @@ namespace MobileGL {
|
||||
|
||||
Uint GetExternalIndex() const;
|
||||
|
||||
// Globally-unique, never-reused id for THIS object's lifetime - the same
|
||||
// contract as ProgramObject::GetLifetimeId(), and needed for the same
|
||||
// reason. Neither the GL name (freed to a LIFO list and handed straight
|
||||
// back by the next glGenVertexArrays) nor the heap address (freed to the
|
||||
// allocator and handed straight back by the next allocation of this size)
|
||||
// can tell a deleted-and-recreated VAO from the original, so a backend
|
||||
// memo keyed on either one silently inherits the dead object's contents.
|
||||
// That is not hypothetical: it is what let a transform-feedback capture
|
||||
// fetch a destroyed VAO's vertex buffer slice (see the VaoDrawMemo key in
|
||||
// DirectVulkan's VulkanRenderer).
|
||||
Uint64 GetLifetimeId() const { return m_lifetimeId; }
|
||||
|
||||
void SetAttributeDivisor(Uint index, Uint divisor);
|
||||
Uint GetAttributeDivisor(Uint index) const;
|
||||
|
||||
@@ -185,7 +197,10 @@ namespace MobileGL {
|
||||
return mapping;
|
||||
}
|
||||
|
||||
static Uint64 AllocateLifetimeId();
|
||||
|
||||
const Uint m_externalIndex = 0;
|
||||
const Uint64 m_lifetimeId = AllocateLifetimeId();
|
||||
Array<VertexAttribute, MAX_VERTEX_ATTRIBS> m_attributes;
|
||||
Array<VertexAttributeVersion, MAX_VERTEX_ATTRIBS> m_attributeVersions;
|
||||
BindingSlot<BufferObject> m_indexBufferBindingSlot;
|
||||
|
||||
@@ -455,16 +455,24 @@ TEST_F(BufferTest, BindBufferBaseZeroUnbindsBindingPoint) {
|
||||
}
|
||||
|
||||
TEST_F(BufferTest, BindBufferRangeZeroUnbindsBindingPoint) {
|
||||
// GL_SHADER_STORAGE_BUFFER offsets must be a multiple of
|
||||
// GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, so the offset cannot be a literal.
|
||||
GLint ssboAlignment = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &ssboAlignment);
|
||||
ASSERT_GT(ssboAlignment, 0);
|
||||
const GLintptr offset = ssboAlignment;
|
||||
const GLsizeiptr size = 8;
|
||||
|
||||
GLuint buffer = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, 16, nullptr, GL_DYNAMIC_DRAW);
|
||||
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, offset + size, nullptr, GL_DYNAMIC_DRAW);
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 3, buffer, 4, 8);
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 3, buffer, offset, size);
|
||||
auto& point = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, 3);
|
||||
ASSERT_NE(point.GetBoundObject(), nullptr);
|
||||
EXPECT_EQ(point.GetRange().start, 4);
|
||||
EXPECT_EQ(point.GetRange().end, 12);
|
||||
EXPECT_EQ(point.GetRange().start, static_cast<SizeT>(offset));
|
||||
EXPECT_EQ(point.GetRange().end, static_cast<SizeT>(offset + size));
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 3, 0, 0, 0);
|
||||
EXPECT_EQ(point.GetBoundObject(), nullptr);
|
||||
@@ -540,6 +548,305 @@ TEST_F(BufferTest, ClearNamedBufferSubDataRepeatsPattern) {
|
||||
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
|
||||
// GL 4.6 core 6.5: glBufferSubData fails only when the written range OVERLAPS the mapped range.
|
||||
// A second, wrong test used to sit next to the correct one and reject any write whose end reached
|
||||
// the start of the mapping - which killed every legal disjoint update in front of a mapped tail.
|
||||
TEST_F(BufferTest, BufferSubDataRejectsOnlyRangesOverlappingTheMapping) {
|
||||
GLuint buffer = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_ARRAY_BUFFER, buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BufferData(GL_ARRAY_BUFFER, 64, nullptr, GL_DYNAMIC_DRAW);
|
||||
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
void* mapped = MobileGL::MG_Impl::GLImpl::MapBufferRange(GL_ARRAY_BUFFER, 32, 32, GL_MAP_WRITE_BIT);
|
||||
ASSERT_NE(mapped, nullptr);
|
||||
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
// Entirely before the mapping: legal, and the bytes must land.
|
||||
const Uint32 payload[4] = {1u, 2u, 3u, 4u};
|
||||
MobileGL::MG_Impl::GLImpl::BufferSubData(GL_ARRAY_BUFFER, 0, sizeof(payload), payload);
|
||||
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
// Touching the first mapped byte: overlap, so INVALID_OPERATION.
|
||||
MobileGL::MG_Impl::GLImpl::BufferSubData(GL_ARRAY_BUFFER, 16, 32, payload);
|
||||
ExpectSingleGlError(GL_INVALID_OPERATION);
|
||||
|
||||
EXPECT_TRUE(MobileGL::MG_Impl::GLImpl::UnmapBuffer(GL_ARRAY_BUFFER));
|
||||
|
||||
Vector<Uint32> actual(4, 0);
|
||||
auto bufferObject = MobileGL::MG_State::pGLContext->GetBufferObject(buffer);
|
||||
ASSERT_NE(bufferObject, nullptr);
|
||||
Memcpy(actual.data(), bufferObject->AcquireMemory(false, true, false), sizeof(payload));
|
||||
EXPECT_EQ(actual, (Vector<Uint32>{1u, 2u, 3u, 4u}));
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
|
||||
DrainPendingGlErrors();
|
||||
}
|
||||
|
||||
// GL 4.6 core 6.2: "no buffer bound to target" outranks a bad size or bad flags, so the binding has
|
||||
// to be resolved before either is validated. It used to be checked last, which turned every
|
||||
// unbound-target call into INVALID_VALUE.
|
||||
TEST_F(BufferTest, BufferStorageReportsTheUnboundTargetBeforeSizeAndFlags) {
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
DrainPendingGlErrors();
|
||||
|
||||
// Both a zero size and a nonsense flag set are present; the unbound target still wins.
|
||||
MobileGL::MG_Impl::GLImpl::BufferStorage(GL_ARRAY_BUFFER, 0, nullptr, GL_MAP_PERSISTENT_BIT);
|
||||
ExpectSingleGlError(GL_INVALID_OPERATION);
|
||||
|
||||
// With a buffer bound, the size check is reachable again.
|
||||
GLuint buffer = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_ARRAY_BUFFER, buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BufferStorage(GL_ARRAY_BUFFER, 0, nullptr, GL_MAP_READ_BIT);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
|
||||
DrainPendingGlErrors();
|
||||
}
|
||||
|
||||
// GL 4.6 core 6.1.1: glBindBufferRange on GL_SHADER_STORAGE_BUFFER must reject an offset that is
|
||||
// not a multiple of GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT.
|
||||
TEST_F(BufferTest, BindBufferRangeRejectsMisalignedShaderStorageOffset) {
|
||||
GLint ssboAlignment = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &ssboAlignment);
|
||||
ASSERT_GT(ssboAlignment, 1) << "a 1-byte alignment cannot express a misaligned offset";
|
||||
|
||||
GLuint buffer = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, ssboAlignment * 4, nullptr, GL_DYNAMIC_DRAW);
|
||||
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 1, buffer, 1, ssboAlignment);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
auto& point = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, 1);
|
||||
EXPECT_EQ(point.GetBoundObject(), nullptr) << "a rejected bind must not take effect";
|
||||
|
||||
// The uniform target has its own alignment and must not inherit the SSBO rule's rejection.
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 1, buffer, ssboAlignment, ssboAlignment);
|
||||
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
EXPECT_NE(point.GetBoundObject(), nullptr);
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 1, 0, 0, 0);
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
|
||||
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
|
||||
DrainPendingGlErrors();
|
||||
}
|
||||
|
||||
// ARB_multi_bind: the [first, first + count) range is checked up front and reports
|
||||
// INVALID_OPERATION - not the per-element INVALID_VALUE a naive loop over glBindBufferBase would
|
||||
// produce, and nothing may be bound when it fails.
|
||||
TEST_F(BufferTest, BindBuffersBaseChecksTheWholeRangeBeforeBindingAnything) {
|
||||
GLint maxBindings = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS, &maxBindings);
|
||||
ASSERT_GT(maxBindings, 1);
|
||||
|
||||
GLuint buffer = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, 16, nullptr, GL_DYNAMIC_DRAW);
|
||||
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
// first is in range but first + count is not: one error, of the multi-bind class.
|
||||
const GLuint first = static_cast<GLuint>(maxBindings - 1);
|
||||
const GLuint buffers[2] = {buffer, buffer};
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffersBase(GL_SHADER_STORAGE_BUFFER, first, 2, buffers);
|
||||
ExpectSingleGlError(GL_INVALID_OPERATION);
|
||||
auto& point = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, first);
|
||||
EXPECT_EQ(point.GetBoundObject(), nullptr) << "the in-range prefix must not be bound either";
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffersRange(GL_SHADER_STORAGE_BUFFER, first, 2, buffers, nullptr, nullptr);
|
||||
ExpectSingleGlError(GL_INVALID_OPERATION);
|
||||
|
||||
// A range that fits binds normally.
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffersBase(GL_SHADER_STORAGE_BUFFER, first, 1, buffers);
|
||||
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
ASSERT_NE(point.GetBoundObject(), nullptr);
|
||||
EXPECT_EQ(point.GetBoundObject()->GetExternalIndex(), buffer);
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, first, 0);
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
|
||||
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
|
||||
DrainPendingGlErrors();
|
||||
}
|
||||
|
||||
// These limits were reachable only through glGetInteger64v (SSBO block size) or not at all (the
|
||||
// atomic-counter pair), so glGetIntegerv answered them with INVALID_ENUM out of its default arm.
|
||||
TEST_F(BufferTest, GetIntegervAnswersSsboAndAtomicCounterLimits) {
|
||||
GLint ssboBlockSize = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MAX_SHADER_STORAGE_BLOCK_SIZE, &ssboBlockSize);
|
||||
EXPECT_GT(ssboBlockSize, 0);
|
||||
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
// The 32-bit query saturates rather than truncating what glGetInteger64v reports.
|
||||
GLint64 ssboBlockSize64 = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GetInteger64v(GL_MAX_SHADER_STORAGE_BLOCK_SIZE, &ssboBlockSize64);
|
||||
EXPECT_EQ(static_cast<GLint64>(ssboBlockSize), std::min<GLint64>(ssboBlockSize64, INT32_MAX));
|
||||
|
||||
GLint atomicBindings = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS, &atomicBindings);
|
||||
EXPECT_GE(atomicBindings, 1);
|
||||
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
GLint atomicBufferSize = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE, &atomicBufferSize);
|
||||
EXPECT_GE(atomicBufferSize, 32); // GL 4.6 table 23.63 minimum
|
||||
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
// KHR_debug requires these to be legal even while the debug entry points are stubs.
|
||||
GLint debugGroupDepth = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MAX_DEBUG_GROUP_STACK_DEPTH, &debugGroupDepth);
|
||||
EXPECT_GE(debugGroupDepth, 64);
|
||||
GLint debugLoggedMessages = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_MAX_DEBUG_LOGGED_MESSAGES, &debugLoggedMessages);
|
||||
EXPECT_GE(debugLoggedMessages, 1);
|
||||
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// GL 4.6 core 6.1.1: glBindBufferRange validates the (offset, size) pair before it writes any
|
||||
// state. Nothing validated either one, so a negative offset reached Range1D(offset, offset + size)
|
||||
// - which has no ordering check of its own - and a zero or negative size installed an empty or
|
||||
// backwards range on the binding point.
|
||||
TEST_F(BufferTest, BindBufferRangeRejectsNegativeOffsetAndNonPositiveSize) {
|
||||
GLint ssboAlignment = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &ssboAlignment);
|
||||
ASSERT_GT(ssboAlignment, 0);
|
||||
|
||||
GLuint buffer = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, ssboAlignment * 4, nullptr, GL_DYNAMIC_DRAW);
|
||||
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
auto& point = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, 2);
|
||||
|
||||
// A negative offset is INVALID_VALUE - including one that is a multiple of the alignment, which
|
||||
// the modulo gate alone waves through (-alignment % alignment == 0).
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, buffer, -ssboAlignment, ssboAlignment);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
EXPECT_EQ(point.GetBoundObject(), nullptr) << "a rejected bind must not take effect";
|
||||
|
||||
// size must be strictly positive.
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, buffer, 0, 0);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, buffer, 0, -4);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
EXPECT_EQ(point.GetBoundObject(), nullptr);
|
||||
|
||||
// The well-formed bind still goes through.
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, buffer, ssboAlignment, ssboAlignment);
|
||||
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
ASSERT_NE(point.GetBoundObject(), nullptr);
|
||||
EXPECT_EQ(point.GetRange().start, static_cast<SizeT>(ssboAlignment));
|
||||
EXPECT_EQ(point.GetRange().end, static_cast<SizeT>(ssboAlignment * 2));
|
||||
|
||||
// Buffer 0 detaches with offset and size ignored: the one case the size rule must not fire on,
|
||||
// and the shape glBindBuffersRange uses to reset an element.
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, 0, 0, 0);
|
||||
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
EXPECT_EQ(point.GetBoundObject(), nullptr);
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
|
||||
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
|
||||
DrainPendingGlErrors();
|
||||
}
|
||||
|
||||
// GL 4.6 core 6.1.1 gives GL_UNIFORM_BUFFER its own offset alignment
|
||||
// (GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT) and requires BOTH offset and size to be multiples of 4 on
|
||||
// GL_TRANSFORM_FEEDBACK_BUFFER. Only the shader-storage half of the rule was implemented, so a
|
||||
// misaligned uniform range bound happily.
|
||||
TEST_F(BufferTest, BindBufferRangeEnforcesUniformAndTransformFeedbackAlignment) {
|
||||
GLint uboAlignment = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &uboAlignment);
|
||||
ASSERT_GT(uboAlignment, 1) << "a 1-byte alignment cannot express a misaligned offset";
|
||||
|
||||
GLuint buffer = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_UNIFORM_BUFFER, buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BufferData(GL_UNIFORM_BUFFER, uboAlignment * 4, nullptr, GL_DYNAMIC_DRAW);
|
||||
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
auto& uniformPoint = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::Uniform, 1);
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_UNIFORM_BUFFER, 1, buffer, 1, uboAlignment);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
EXPECT_EQ(uniformPoint.GetBoundObject(), nullptr) << "a misaligned uniform range must not bind";
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_UNIFORM_BUFFER, 1, buffer, uboAlignment, uboAlignment);
|
||||
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
EXPECT_NE(uniformPoint.GetBoundObject(), nullptr);
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_UNIFORM_BUFFER, 1, 0, 0, 0);
|
||||
EXPECT_EQ(uniformPoint.GetBoundObject(), nullptr);
|
||||
|
||||
// Transform feedback captures 32-bit components: offset and size are both constrained, and the
|
||||
// size half has no analogue on any other target.
|
||||
auto& feedbackPoint =
|
||||
MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback, 0);
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, buffer, 2, 4);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
EXPECT_EQ(feedbackPoint.GetBoundObject(), nullptr);
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, buffer, 4, 2);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
EXPECT_EQ(feedbackPoint.GetBoundObject(), nullptr);
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, buffer, 4, 4);
|
||||
EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
EXPECT_NE(feedbackPoint.GetBoundObject(), nullptr);
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0, 0, 0);
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_UNIFORM_BUFFER, 0);
|
||||
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
|
||||
DrainPendingGlErrors();
|
||||
}
|
||||
|
||||
// ARB_multi_bind checks offsets and sizes separately for each binding point: the offending element
|
||||
// is left unchanged and reports INVALID_VALUE while every other element still binds. Only the
|
||||
// [first, first + count) range is the up-front, all-or-nothing check - so glBindBuffersRange gets
|
||||
// the new gates by looping over the single-bind entry point, and must keep going after one fails.
|
||||
TEST_F(BufferTest, BindBuffersRangeAppliesTheOffsetAndSizeGatesPerElement) {
|
||||
GLint ssboAlignment = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &ssboAlignment);
|
||||
ASSERT_GT(ssboAlignment, 1) << "a 1-byte alignment cannot express a misaligned offset";
|
||||
|
||||
GLuint buffer = 0;
|
||||
MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
|
||||
MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, ssboAlignment * 8, nullptr, GL_DYNAMIC_DRAW);
|
||||
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
auto& firstPoint = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, 0);
|
||||
auto& secondPoint = MobileGL::MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, 1);
|
||||
const GLuint buffers[2] = {buffer, buffer};
|
||||
|
||||
// Element 0 is misaligned; element 1 is well formed and must still be bound.
|
||||
const GLintptr misalignedOffsets[2] = {1, ssboAlignment};
|
||||
const GLsizeiptr sizes[2] = {ssboAlignment, ssboAlignment};
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffersRange(GL_SHADER_STORAGE_BUFFER, 0, 2, buffers, misalignedOffsets, sizes);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
EXPECT_EQ(firstPoint.GetBoundObject(), nullptr) << "the rejected element must not bind";
|
||||
ASSERT_NE(secondPoint.GetBoundObject(), nullptr) << "a per-element error must not abort the rest of the range";
|
||||
EXPECT_EQ(secondPoint.GetRange().start, static_cast<SizeT>(ssboAlignment));
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, 0);
|
||||
ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
// Same for a non-positive size, on the other element this time.
|
||||
const GLintptr offsets[2] = {0, ssboAlignment};
|
||||
const GLsizeiptr badSizes[2] = {ssboAlignment, 0};
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffersRange(GL_SHADER_STORAGE_BUFFER, 0, 2, buffers, offsets, badSizes);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
EXPECT_NE(firstPoint.GetBoundObject(), nullptr);
|
||||
EXPECT_EQ(secondPoint.GetBoundObject(), nullptr);
|
||||
|
||||
MobileGL::MG_Impl::GLImpl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
|
||||
MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
|
||||
MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer);
|
||||
DrainPendingGlErrors();
|
||||
}
|
||||
|
||||
using namespace MobileGL::MG_Impl::GLImpl;
|
||||
|
||||
class GeneralBufferTest : public ::testing::Test {
|
||||
|
||||
@@ -66,6 +66,9 @@ gtest_discover_tests(SanityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
|
||||
add_subdirectory(BackendLoader)
|
||||
add_subdirectory(Buffer)
|
||||
# The heap-address-is-not-an-identity invariant the backends' per-object memos
|
||||
# rest on. No GL context, no driver: it only needs the allocator.
|
||||
add_subdirectory(State)
|
||||
add_subdirectory(EGLState)
|
||||
add_subdirectory(Framebuffer)
|
||||
add_subdirectory(Texture)
|
||||
|
||||
@@ -13,10 +13,13 @@
|
||||
#include "Includes.h"
|
||||
#include "Init.h"
|
||||
#include <MG_Backend/BackendObjects.h>
|
||||
#include <MG_Backend/DirectGLES/DirectGLES.h>
|
||||
#include <MG_Backend/DirectGLES/Managers.h>
|
||||
#include <MG_Backend/DirectGLES/Utils.h>
|
||||
#include <MG_Impl/GLImpl/Buffer/GL_Buffer.h>
|
||||
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
|
||||
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
|
||||
#include <MG_Impl/GLImpl/RenderState/GL_RenderState.h>
|
||||
#include <MG_Impl/GLImpl/Texture/GL_Texture.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
|
||||
@@ -858,3 +861,394 @@ TEST_F(FramebufferTest, NonRenderableColorFormatsReportUnsupportedFramebuffer) {
|
||||
MG_Impl::GLImpl::ReadPixels(0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, pixelStorage);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_FRAMEBUFFER_OPERATION);
|
||||
}
|
||||
|
||||
// ---- Three-channel colour attachments: the Complementary Reimagined / Iris load failure --------
|
||||
//
|
||||
// Complementary declares colortex1 = RGB8_SNORM and colortex2 = RGB16F. No real OpenGL ES driver
|
||||
// renders to a three-channel image (EXT_render_snorm covers R/RG/RGBA only; EXT_color_buffer_float
|
||||
// excludes RGB16F), so the DirectGLES probe records those formats as creatable-but-not-renderable
|
||||
// and the frontend answered every framebuffer built from them GL_FRAMEBUFFER_UNSUPPORTED - which
|
||||
// Iris turns into a hard "Draw buffers [0, 1] Status: 36061" load failure. The backend now records
|
||||
// the four-channel substitution it will actually allocate as a caveat capability, and the frontend
|
||||
// has to accept that as renderable.
|
||||
namespace {
|
||||
class ThreeChannelAttachmentBackend final : public MG_Backend::BackendObject {
|
||||
public:
|
||||
// `substituted` stands in for a driver where the four-channel widening probe succeeded, i.e.
|
||||
// for what PopulateFormatCapabilitiesImpl records on Mali. false is the pre-fix state: the
|
||||
// native form is creatable, nothing is renderable, and no fallback was ever built.
|
||||
explicit ThreeChannelAttachmentBackend(Bool substituted) {
|
||||
auto& cache = MutableFormatCapabilities();
|
||||
const auto texture2DIndex = MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::Texture2D);
|
||||
|
||||
// IsColorInternalFormatRenderable only trusts the cache once it looks populated, which
|
||||
// it decides from RGBA8 being creatable somewhere. Without this the static deny-list
|
||||
// answers instead and the caveat below would never be consulted.
|
||||
const auto rgba8Index = static_cast<SizeT>(TextureInternalFormat::RGBA8);
|
||||
cache.FullCaps[texture2DIndex][rgba8Index] |= MG_Backend::FormatCapability::Creatable;
|
||||
cache.FullCaps[texture2DIndex][rgba8Index] |= MG_Backend::FormatCapability::FramebufferRenderable;
|
||||
cache.FullCaps[texture2DIndex][rgba8Index] |= MG_Backend::FormatCapability::ColorAttachment;
|
||||
|
||||
for (const TextureInternalFormat format :
|
||||
{TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16F}) {
|
||||
const auto formatIndex = static_cast<SizeT>(format);
|
||||
// Creatable and samplable as an ordinary texture, but the driver's
|
||||
// glCheckFramebufferStatus said no - exactly Mali r32p1's answer.
|
||||
cache.FullCaps[texture2DIndex][formatIndex] |= MG_Backend::FormatCapability::Creatable;
|
||||
cache.FullCaps[texture2DIndex][formatIndex] |= MG_Backend::FormatCapability::Sampled;
|
||||
if (substituted) {
|
||||
cache.CaveatCaps[texture2DIndex][formatIndex] |=
|
||||
MG_Backend::FormatCapability::FramebufferRenderable;
|
||||
cache.CaveatCaps[texture2DIndex][formatIndex] |= MG_Backend::FormatCapability::ColorAttachment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Initialize() override {}
|
||||
Bool InitCapabilities() override { return true; }
|
||||
Bool InitWindowSurface() override { return true; }
|
||||
const RendererInfo& GetRendererInfo() const override {
|
||||
static RendererInfo info = {};
|
||||
return info;
|
||||
}
|
||||
String GetBackendAPIVersionString() const override { return {}; }
|
||||
const MG_Backend::GlobalBackendFunctionsTable& GetBackendFunctions() const override {
|
||||
static MG_Backend::GlobalBackendFunctionsTable table = {};
|
||||
return table;
|
||||
}
|
||||
const MG_Backend::DynamicBackendParameters& GetDynamicParameters() const override {
|
||||
static MG_Backend::DynamicBackendParameters params = {};
|
||||
return params;
|
||||
}
|
||||
BackendType GetBackendType() const override { return BackendType::Unknown; }
|
||||
};
|
||||
|
||||
class ScopedBackendOverride {
|
||||
public:
|
||||
explicit ScopedBackendOverride(UniquePtr<MG_Backend::BackendObject> backend):
|
||||
m_previous(Move(MG_Backend::pActiveBackendObject)) {
|
||||
MG_Backend::pActiveBackendObject = Move(backend);
|
||||
}
|
||||
|
||||
~ScopedBackendOverride() { MG_Backend::pActiveBackendObject = Move(m_previous); }
|
||||
|
||||
private:
|
||||
UniquePtr<MG_Backend::BackendObject> m_previous;
|
||||
};
|
||||
|
||||
GLenum CheckSingleColorAttachmentStatus(GLenum internalFormat) {
|
||||
GLuint framebuffer = 0;
|
||||
GLuint texture = 0;
|
||||
MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer);
|
||||
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &texture);
|
||||
MG_Impl::GLImpl::TextureStorage2D(texture, 1, internalFormat, 4, 4);
|
||||
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, texture, 0);
|
||||
return MG_Impl::GLImpl::CheckNamedFramebufferStatus(framebuffer, GL_DRAW_FRAMEBUFFER);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_F(FramebufferTest, ThreeChannelColorAttachmentsAreUnsupportedWithoutTheWidenedSubstitution) {
|
||||
// The pre-fix behaviour, pinned so a regression is a red test rather than a shaderpack that
|
||||
// silently stops loading: no caveat capability, so nothing makes these renderable.
|
||||
ScopedBackendOverride backend(MakeUnique<ThreeChannelAttachmentBackend>(/*substituted=*/false));
|
||||
|
||||
EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB8_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_UNSUPPORTED));
|
||||
EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB16F), static_cast<GLenum>(GL_FRAMEBUFFER_UNSUPPORTED));
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
TEST_F(FramebufferTest, ThreeChannelColorAttachmentsAreCompleteThroughTheWidenedSubstitution) {
|
||||
ScopedBackendOverride backend(MakeUnique<ThreeChannelAttachmentBackend>(/*substituted=*/true));
|
||||
|
||||
// Complementary's colortex1 (RGB8_SNORM) and colortex2 (RGB16F): both must come out COMPLETE,
|
||||
// because the backend stores them as GL_RGBA16F. Shipping only the first would move the
|
||||
// failure one composite pass down instead of fixing it.
|
||||
EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB8_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
||||
EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB16F), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
TEST_F(FramebufferTest, TwoAttachmentCompositeFramebufferMatchesIrisComplementaryPass) {
|
||||
// The exact framebuffer Iris failed on: Complementary's `composite` pass draws to colortex7
|
||||
// (RGBA16F, natively renderable) and colortex1 (RGB8_SNORM, only renderable widened). Iris
|
||||
// logs it as "Draw buffers [0, 1]" - a two-attachment FBO, not colortex 0 and 1.
|
||||
ScopedBackendOverride backend(MakeUnique<ThreeChannelAttachmentBackend>(/*substituted=*/true));
|
||||
|
||||
GLuint framebuffer = 0;
|
||||
GLuint colortex7 = 0;
|
||||
GLuint colortex1 = 0;
|
||||
MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer);
|
||||
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex7);
|
||||
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex1);
|
||||
MG_Impl::GLImpl::TextureStorage2D(colortex7, 1, GL_RGBA8, 4, 4);
|
||||
MG_Impl::GLImpl::TextureStorage2D(colortex1, 1, GL_RGB8_SNORM, 4, 4);
|
||||
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, colortex7, 0);
|
||||
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT1, colortex1, 0);
|
||||
|
||||
EXPECT_EQ(MG_Impl::GLImpl::CheckNamedFramebufferStatus(framebuffer, GL_DRAW_FRAMEBUFFER),
|
||||
static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
||||
|
||||
// Both entry points answer from the same helpers, and CheckFramebufferStatus is what Iris
|
||||
// actually calls; they are near-verbatim duplicates, so assert they agree.
|
||||
MG_Impl::GLImpl::BindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffer);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::CheckFramebufferStatus(GL_DRAW_FRAMEBUFFER),
|
||||
static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---- Widened attachments: the stored-alpha discipline -----------------------------------------
|
||||
//
|
||||
// A widened attachment has a real alpha channel the application's three-channel format does not,
|
||||
// and GL says a channel a format lacks reads back as 1.0. glReadPixels and glGetTexImage can be
|
||||
// made to say that (ForceWideReadAlphaToOne), but GL_DST_ALPHA / GL_ONE_MINUS_DST_ALPHA blending
|
||||
// and glBlitFramebuffer read the STORED alpha inside the driver, where nothing can intercept it.
|
||||
// So the stored alpha is held at 1.0 instead: a clear writes 1.0 into it, and every draw has that
|
||||
// buffer's alpha write mask forced off so nothing can move it again.
|
||||
//
|
||||
// These cases pin the two halves of that pairing at the seam where they are visible - what the ES
|
||||
// driver is actually handed - and pin the invariant that the application's own colour mask is
|
||||
// never touched.
|
||||
namespace {
|
||||
struct RecordedColorMask {
|
||||
Bool seen = false;
|
||||
GLboolean r = GL_FALSE, g = GL_FALSE, b = GL_FALSE, a = GL_FALSE;
|
||||
};
|
||||
|
||||
constexpr Uint kRecordedDrawBuffers = 8;
|
||||
RecordedColorMask g_driverIndexedColorMasks[kRecordedDrawBuffers];
|
||||
RecordedColorMask g_driverUniformColorMask;
|
||||
|
||||
void ResetRecordedColorMasks() {
|
||||
for (auto& recorded : g_driverIndexedColorMasks) recorded = {};
|
||||
g_driverUniformColorMask = {};
|
||||
}
|
||||
|
||||
void StubColorMask(GLboolean r, GLboolean g, GLboolean b, GLboolean a) {
|
||||
g_driverUniformColorMask = {true, r, g, b, a};
|
||||
// The non-indexed call sets every draw buffer, so record it as such: a later assertion
|
||||
// about draw buffer 1 must not read a stale indexed record the uniform push overwrote.
|
||||
for (auto& recorded : g_driverIndexedColorMasks) recorded = {true, r, g, b, a};
|
||||
}
|
||||
|
||||
void StubColorMaski(GLuint index, GLboolean r, GLboolean g, GLboolean b, GLboolean a) {
|
||||
if (index < kRecordedDrawBuffers) g_driverIndexedColorMasks[index] = {true, r, g, b, a};
|
||||
}
|
||||
|
||||
void StubViewport(GLint, GLint, GLsizei, GLsizei) {}
|
||||
void StubScissor(GLint, GLint, GLsizei, GLsizei) {}
|
||||
void StubEnable(GLenum) {}
|
||||
void StubDisable(GLenum) {}
|
||||
void StubEnablei(GLenum, GLuint) {}
|
||||
void StubDisablei(GLenum, GLuint) {}
|
||||
void StubBlendFuncSeparate(GLenum, GLenum, GLenum, GLenum) {}
|
||||
void StubBlendFuncSeparatei(GLuint, GLenum, GLenum, GLenum, GLenum) {}
|
||||
void StubBlendEquationSeparate(GLenum, GLenum) {}
|
||||
void StubBlendEquationSeparatei(GLuint, GLenum, GLenum) {}
|
||||
void StubBlendColor(GLfloat, GLfloat, GLfloat, GLfloat) {}
|
||||
void StubDepthFunc(GLenum) {}
|
||||
void StubDepthMask(GLboolean) {}
|
||||
void StubDepthRangef(GLfloat, GLfloat) {}
|
||||
void StubStencilFuncSeparate(GLenum, GLenum, GLint, GLuint) {}
|
||||
void StubStencilMaskSeparate(GLenum, GLuint) {}
|
||||
void StubStencilOpSeparate(GLenum, GLenum, GLenum, GLenum) {}
|
||||
void StubClearColor(GLfloat, GLfloat, GLfloat, GLfloat) {}
|
||||
void StubClearDepthf(GLfloat) {}
|
||||
void StubClearStencil(GLint) {}
|
||||
void StubCullFace(GLenum) {}
|
||||
void StubFrontFace(GLenum) {}
|
||||
void StubPolygonOffset(GLfloat, GLfloat) {}
|
||||
void StubLineWidth(GLfloat) {}
|
||||
void StubSampleCoverage(GLfloat, GLboolean) {}
|
||||
|
||||
// Replaces the ES function table with no-ops that record only what these cases assert on.
|
||||
// The table is ZEROED first on purpose: SyncRenderState is long, and a call it makes that
|
||||
// this fixture did not anticipate must crash here rather than silently reach a stale pointer
|
||||
// into a driver that this process never made current.
|
||||
class ScopedRenderStateDriverStubs {
|
||||
public:
|
||||
ScopedRenderStateDriverStubs():
|
||||
m_funcs(MG_Backend::DirectGLES::g_GLESFuncs), m_caps(MG_Backend::DirectGLES::g_GLESCapabilities) {
|
||||
auto& gl = MG_Backend::DirectGLES::g_GLESFuncs;
|
||||
gl = MG_External::GLESFunctionsTable{};
|
||||
gl.glViewport = StubViewport;
|
||||
gl.glScissor = StubScissor;
|
||||
gl.glEnable = StubEnable;
|
||||
gl.glDisable = StubDisable;
|
||||
gl.glEnablei = StubEnablei;
|
||||
gl.glDisablei = StubDisablei;
|
||||
gl.glBlendFuncSeparate = StubBlendFuncSeparate;
|
||||
gl.glBlendFuncSeparatei = StubBlendFuncSeparatei;
|
||||
gl.glBlendEquationSeparate = StubBlendEquationSeparate;
|
||||
gl.glBlendEquationSeparatei = StubBlendEquationSeparatei;
|
||||
gl.glBlendColor = StubBlendColor;
|
||||
gl.glDepthFunc = StubDepthFunc;
|
||||
gl.glDepthMask = StubDepthMask;
|
||||
gl.glDepthRangef = StubDepthRangef;
|
||||
gl.glStencilFuncSeparate = StubStencilFuncSeparate;
|
||||
gl.glStencilMaskSeparate = StubStencilMaskSeparate;
|
||||
gl.glStencilOpSeparate = StubStencilOpSeparate;
|
||||
gl.glClearColor = StubClearColor;
|
||||
gl.glClearDepthf = StubClearDepthf;
|
||||
gl.glClearStencil = StubClearStencil;
|
||||
gl.glCullFace = StubCullFace;
|
||||
gl.glFrontFace = StubFrontFace;
|
||||
gl.glPolygonOffset = StubPolygonOffset;
|
||||
gl.glLineWidth = StubLineWidth;
|
||||
gl.glSampleCoverage = StubSampleCoverage;
|
||||
gl.glColorMask = StubColorMask;
|
||||
gl.glColorMaski = StubColorMaski;
|
||||
|
||||
auto& caps = MG_Backend::DirectGLES::g_GLESCapabilities;
|
||||
caps.SupportsIndexedColorMask = true;
|
||||
caps.SupportsSrgbWriteControl = false;
|
||||
caps.SupportsPolygonMode = false;
|
||||
caps.SupportsDualSourceBlend = true;
|
||||
|
||||
ResetRecordedColorMasks();
|
||||
// The viewport and scissor blocks fall back to querying the surface size when the
|
||||
// frontend's rectangle is degenerate, and there is no surface in this process.
|
||||
MG_Impl::GLImpl::Viewport(0, 0, 4, 4);
|
||||
MG_Impl::GLImpl::Scissor(0, 0, 4, 4);
|
||||
MG_Backend::DirectGLES::RenderStateImpl::InvalidateSyncedRenderState();
|
||||
}
|
||||
|
||||
~ScopedRenderStateDriverStubs() {
|
||||
MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 0;
|
||||
MG_Backend::DirectGLES::g_GLESFuncs = m_funcs;
|
||||
MG_Backend::DirectGLES::g_GLESCapabilities = m_caps;
|
||||
// The shadow now describes pushes that went to the stubs, not to any driver.
|
||||
MG_Backend::DirectGLES::RenderStateImpl::InvalidateSyncedRenderState();
|
||||
MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
|
||||
}
|
||||
|
||||
private:
|
||||
MG_External::GLESFunctionsTable m_funcs;
|
||||
MG_External::GLESCapabilities m_caps;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
TEST_F(FramebufferTest, WidenedDrawBufferIsIdentifiedPerDrawBufferSlotNotPerAttachmentPoint) {
|
||||
ScopedBackendOverride backend(MakeUnique<ThreeChannelAttachmentBackend>(/*substituted=*/true));
|
||||
|
||||
// Complementary's `composite` framebuffer again: draw buffer 0 is a natively renderable
|
||||
// RGBA8, draw buffer 1 is the widened RGB8_SNORM. Only the second may be doctored.
|
||||
GLuint framebuffer = 0;
|
||||
GLuint colortex7 = 0;
|
||||
GLuint colortex1 = 0;
|
||||
MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer);
|
||||
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex7);
|
||||
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex1);
|
||||
MG_Impl::GLImpl::TextureStorage2D(colortex7, 1, GL_RGBA8, 4, 4);
|
||||
MG_Impl::GLImpl::TextureStorage2D(colortex1, 1, GL_RGB8_SNORM, 4, 4);
|
||||
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, colortex7, 0);
|
||||
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT1, colortex1, 0);
|
||||
|
||||
auto& framebufferObject = MG_State::pGLContext->GetFramebufferObject(framebuffer);
|
||||
ASSERT_NE(framebufferObject, nullptr);
|
||||
framebufferObject->SetDrawBuffer(0, FramebufferAttachmentType::Color0);
|
||||
framebufferObject->SetDrawBuffer(1, FramebufferAttachmentType::Color1);
|
||||
|
||||
EXPECT_EQ(MG_Backend::DirectGLES::FramebufferImpl::ComputeAlphaWidenedDrawBufferMask(*framebufferObject),
|
||||
1u << 1);
|
||||
|
||||
// Swapping the draw-buffer array moves the bit with the SLOT, not with the attachment point:
|
||||
// glColorMaski and glClearBufferfv both address slots.
|
||||
framebufferObject->SetDrawBuffer(0, FramebufferAttachmentType::Color1);
|
||||
framebufferObject->SetDrawBuffer(1, FramebufferAttachmentType::Color0);
|
||||
EXPECT_EQ(MG_Backend::DirectGLES::FramebufferImpl::ComputeAlphaWidenedDrawBufferMask(*framebufferObject),
|
||||
1u << 0);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
TEST_F(FramebufferTest, DrawIntoAWidenedDrawBufferReachesTheDriverWithAlphaWritesMaskedOff) {
|
||||
ScopedRenderStateDriverStubs driver;
|
||||
MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 1u << 1;
|
||||
|
||||
// What the application asked for: write every channel of every draw buffer.
|
||||
MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
|
||||
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
|
||||
|
||||
// What the driver was told. Draw buffer 0 is untouched; draw buffer 1 loses alpha.
|
||||
ASSERT_TRUE(g_driverIndexedColorMasks[0].seen);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[0].r, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[0].g, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[0].b, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[0].a, GL_TRUE);
|
||||
ASSERT_TRUE(g_driverIndexedColorMasks[1].seen);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[1].r, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[1].g, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[1].b, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE) << "a widened draw buffer must not take alpha writes";
|
||||
|
||||
// And what the application sees back. The doctoring lives entirely on the push; the frontend
|
||||
// state it is derived from is never written, so glGet still answers with the app's value.
|
||||
GLboolean appMask[4] = {GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE};
|
||||
MG_Impl::GLImpl::GetBooleanv(GL_COLOR_WRITEMASK, appMask);
|
||||
EXPECT_EQ(appMask[0], GL_TRUE);
|
||||
EXPECT_EQ(appMask[1], GL_TRUE);
|
||||
EXPECT_EQ(appMask[2], GL_TRUE);
|
||||
EXPECT_EQ(appMask[3], GL_TRUE) << "glGet(GL_COLOR_WRITEMASK) must report the application's mask";
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
TEST_F(FramebufferTest, ClearIntoAWidenedDrawBufferKeepsAlphaWritableAndSubstitutesOne) {
|
||||
ScopedRenderStateDriverStubs driver;
|
||||
MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 1u << 1;
|
||||
MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
|
||||
|
||||
// A draw first, so the mask really is doctored when the clear arrives...
|
||||
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
|
||||
ASSERT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE);
|
||||
|
||||
// ...and now the clear, with NOTHING changed in the frontend parameter block. The frontend's
|
||||
// render-state version has not moved, so only the purpose-aware memo can force this push -
|
||||
// without it the clear would inherit the draw's alpha-off mask and never write the 1.0.
|
||||
ResetRecordedColorMasks();
|
||||
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true);
|
||||
ASSERT_TRUE(g_driverIndexedColorMasks[1].seen) << "the clear must re-push the colour mask";
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_TRUE) << "a clear is what puts the 1.0 in the stored alpha";
|
||||
|
||||
// The value that clear writes: the application's RGB, alpha replaced by the 1.0 the
|
||||
// three-channel format implies, and only on the widened buffer.
|
||||
const GLfloat appColor[4] = {0.25f, 0.5f, 0.75f, 0.0f};
|
||||
GLfloat scratch[4] = {};
|
||||
const GLfloat* widened =
|
||||
MG_Backend::DirectGLES::FramebufferImpl::SubstituteWidenedClearAlpha(appColor, true, 1.0f, scratch);
|
||||
EXPECT_EQ(widened[0], 0.25f);
|
||||
EXPECT_EQ(widened[1], 0.5f);
|
||||
EXPECT_EQ(widened[2], 0.75f);
|
||||
EXPECT_EQ(widened[3], 1.0f);
|
||||
|
||||
const GLfloat* untouched =
|
||||
MG_Backend::DirectGLES::FramebufferImpl::SubstituteWidenedClearAlpha(appColor, false, 1.0f, scratch);
|
||||
EXPECT_EQ(untouched, appColor) << "a native attachment's clear must not even be copied";
|
||||
|
||||
// An integer widened format (GL_RGB8UI -> GL_RGBA8UI) carries the INTEGER one, not a
|
||||
// saturated field: glClearBufferuiv takes the value verbatim.
|
||||
const GLuint appIntegerColor[4] = {7u, 8u, 9u, 0u};
|
||||
GLuint integerScratch[4] = {};
|
||||
const GLuint* widenedInteger = MG_Backend::DirectGLES::FramebufferImpl::SubstituteWidenedClearAlpha(
|
||||
appIntegerColor, true, GLuint(1), integerScratch);
|
||||
EXPECT_EQ(widenedInteger[3], 1u);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
TEST_F(FramebufferTest, ApplicationAlphaMaskOffIsStillHonouredOnANativeDrawBuffer) {
|
||||
// The doctoring only ever REMOVES alpha writes on a widened buffer; it must never add them
|
||||
// back on a buffer the application masked itself, and must never touch a native one.
|
||||
ScopedRenderStateDriverStubs driver;
|
||||
MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 1u << 1;
|
||||
|
||||
MG_Impl::GLImpl::ColorMaski(0, GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE);
|
||||
MG_Impl::GLImpl::ColorMaski(1, GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
|
||||
MG_Impl::GLImpl::ColorMaski(2, GL_FALSE, GL_TRUE, GL_FALSE, GL_TRUE);
|
||||
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
|
||||
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[0].a, GL_FALSE) << "the application's own alpha mask survives";
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE) << "the widened buffer loses alpha";
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[2].r, GL_FALSE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[2].g, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[2].b, GL_FALSE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[2].a, GL_TRUE) << "a native buffer keeps its alpha writes";
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,22 @@ target_link_libraries(
|
||||
${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
|
||||
@@ -76,6 +92,25 @@ target_link_libraries(
|
||||
${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.
|
||||
@@ -95,6 +130,22 @@ target_link_libraries(
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
add_executable(
|
||||
ProgramInterfaceTest
|
||||
ProgramInterfaceTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ProgramInterfaceTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
target_link_libraries(
|
||||
ProgramInterfaceTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
target_include_directories(ProgramTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
@@ -109,10 +160,16 @@ target_link_libraries(
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(ProgramUtilTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
gtest_discover_tests(ProgramInterfaceTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
# Heavier than the rest of the unit suite by design: several cases deliberately saturate the
|
||||
# compile pool so there is something in flight to race against.
|
||||
gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
gtest_discover_tests(AsyncLinkTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
# Same reason: the stage-6 cases keep a backlog in flight so a release really can race a
|
||||
# worker, and the 48-object stress links every one of them.
|
||||
gtest_discover_tests(ShaderCompileAdoptionTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
# Same reason: the GL_COMPLETION_STATUS_KHR cases saturate a one-worker pool on purpose.
|
||||
gtest_discover_tests(ParallelShaderCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
gtest_discover_tests(AsyncTeardownTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
# Same reason again: several cases leave A links outstanding while B compiles and links.
|
||||
gtest_discover_tests(XfbFrontendOrderInvarianceTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@
|
||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||
#include "MG_State/GLState/Core.h"
|
||||
#include "MG_State/GLState/ProgramState/ShaderPreprocessCache.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
|
||||
|
||||
using namespace MobileGL;
|
||||
@@ -3006,8 +3007,19 @@ TEST_F(ProgramTest, TwoShaderObjectsWithIdenticalSourceLinkIndependently) {
|
||||
ASSERT_NE(objectA, nullptr);
|
||||
ASSERT_NE(objectB, nullptr);
|
||||
EXPECT_EQ(objectA->GetShaderSource(), objectB->GetShaderSource());
|
||||
// Independent parses despite the shared preprocess.
|
||||
EXPECT_NE(objectA->GetCompiledShader(), objectB->GetCompiledShader());
|
||||
// P0b's layer 2 shares the PREPROCESS and never the parse: glslang's TShader is
|
||||
// consume-once, so a memo hit still has to parse for itself.
|
||||
//
|
||||
// P1 stage 6 shares something stronger when it is active - the whole compile JOB, and
|
||||
// therefore the single parse that job produced - and that sharing is made safe by
|
||||
// ShaderCompileTask::ClaimParsedShader's CAS instead, exactly as it already was for one
|
||||
// shader object attached to two programs. ShaderCompileAdoptionTest is where that is
|
||||
// pinned down (it links both objects and compares the generated SPIR-V). So the
|
||||
// one-parse-per-object assertion belongs to the non-adopting path; the two independent
|
||||
// LINKS below are what both modes have to agree on, and they are the point of this case.
|
||||
if (!MG_Util::Async::AsyncShaderCompileActive()) {
|
||||
EXPECT_NE(objectA->GetCompiledShader(), objectB->GetCompiledShader());
|
||||
}
|
||||
EXPECT_NE(objectA->GetCompiledShader(), nullptr);
|
||||
EXPECT_NE(objectB->GetCompiledShader(), nullptr);
|
||||
|
||||
@@ -3079,3 +3091,148 @@ TEST_F(ProgramTest, PreprocessCacheOverflowKeepsCompilingCorrectly) {
|
||||
EXPECT_GE(GetUniformLocation(programB, "uColor"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glUniformMatrix{2x3,2x4,3x2,3x4,4x2,4x3}fv and their twelve glProgramUniformMatrix* twins were
|
||||
// validate-only no-ops: they never took the value pointer at all. They upload column-at-a-time at
|
||||
// the std140 16-byte column stride, honouring `transpose`, and glGetUniformfv undoes that padding.
|
||||
TEST_F(ProgramTest, NonSquareMatrixUniformsRoundTripThroughTheGlobalUbo) {
|
||||
const char* vsSource = R"(#version 430 core
|
||||
uniform mat2x3 uM2x3;
|
||||
uniform mat3x2 uM3x2;
|
||||
uniform mat4x3 uM4x3;
|
||||
uniform mat2 uM2;
|
||||
void main() {
|
||||
vec3 a = uM2x3 * vec2(1.0);
|
||||
vec2 b = uM3x2 * vec3(1.0);
|
||||
vec3 c = uM4x3 * vec4(1.0);
|
||||
vec2 d = uM2 * vec2(1.0);
|
||||
gl_Position = vec4(a.xy + b + c.xy + d, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
const char* fsSource = R"(#version 430 core
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = vec4(1.0); }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
UseProgram(program);
|
||||
ASSERT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// matCxR is C columns of R rows, column-major: value[c * R + r].
|
||||
const GLfloat m2x3[6] = {1, 2, 3, 4, 5, 6};
|
||||
const GLfloat m3x2[6] = {1, 2, 3, 4, 5, 6};
|
||||
const GLfloat m4x3[12] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
|
||||
|
||||
const GLint loc2x3 = GetUniformLocation(program, "uM2x3");
|
||||
const GLint loc3x2 = GetUniformLocation(program, "uM3x2");
|
||||
const GLint loc4x3 = GetUniformLocation(program, "uM4x3");
|
||||
ASSERT_GE(loc2x3, 0);
|
||||
ASSERT_GE(loc3x2, 0);
|
||||
ASSERT_GE(loc4x3, 0);
|
||||
|
||||
UniformMatrix2x3fv(loc2x3, 1, GL_FALSE, m2x3);
|
||||
UniformMatrix3x2fv(loc3x2, 1, GL_FALSE, m3x2);
|
||||
UniformMatrix4x3fv(loc4x3, 1, GL_FALSE, m4x3);
|
||||
ASSERT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
GLfloat readBack[12] = {};
|
||||
GetUniformfv(program, loc2x3, readBack);
|
||||
EXPECT_EQ(std::memcmp(readBack, m2x3, sizeof(m2x3)), 0);
|
||||
std::memset(readBack, 0, sizeof(readBack));
|
||||
GetUniformfv(program, loc3x2, readBack);
|
||||
EXPECT_EQ(std::memcmp(readBack, m3x2, sizeof(m3x2)), 0);
|
||||
std::memset(readBack, 0, sizeof(readBack));
|
||||
GetUniformfv(program, loc4x3, readBack);
|
||||
EXPECT_EQ(std::memcmp(readBack, m4x3, sizeof(m4x3)), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// transpose = GL_TRUE means the source is row-major: a mat3x2 (3 columns, 2 rows) is then
|
||||
// given as 2 rows of 3, so {1,2,3, 4,5,6} is the column-major {1,4, 2,5, 3,6}.
|
||||
UniformMatrix3x2fv(loc3x2, 1, GL_TRUE, m3x2);
|
||||
const GLfloat expectedTransposed3x2[6] = {1, 4, 2, 5, 3, 6};
|
||||
std::memset(readBack, 0, sizeof(readBack));
|
||||
GetUniformfv(program, loc3x2, readBack);
|
||||
EXPECT_EQ(std::memcmp(readBack, expectedTransposed3x2, sizeof(expectedTransposed3x2)), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// The glProgramUniform* twin writes the same bytes without the program being current.
|
||||
UseProgram(0);
|
||||
const GLfloat other2x3[6] = {9, 8, 7, 6, 5, 4};
|
||||
ProgramUniformMatrix2x3fv(program, loc2x3, 1, GL_FALSE, other2x3);
|
||||
std::memset(readBack, 0, sizeof(readBack));
|
||||
GetUniformfv(program, loc2x3, readBack);
|
||||
EXPECT_EQ(std::memcmp(readBack, other2x3, sizeof(other2x3)), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// A mat2 is not four contiguous floats in the global UBO: std140 pads each column vector out to
|
||||
// 16 bytes, so column 1 starts at byte 16. Writing it packed put column 1 on top of column 0's
|
||||
// padding, where the shader never reads it.
|
||||
TEST_F(ProgramTest, Mat2UniformUsesTheStd140ColumnStride) {
|
||||
const char* vsSource = R"(#version 430 core
|
||||
uniform mat2 uM2;
|
||||
void main() { gl_Position = vec4(uM2 * vec2(1.0), 0.0, 1.0); }
|
||||
)";
|
||||
const char* fsSource = R"(#version 430 core
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = vec4(1.0); }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
UseProgram(program);
|
||||
const GLint loc = GetUniformLocation(program, "uM2");
|
||||
ASSERT_GE(loc, 0);
|
||||
|
||||
const GLfloat m2[4] = {1, 2, 3, 4};
|
||||
UniformMatrix2fv(loc, 1, GL_FALSE, m2);
|
||||
ASSERT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// The GL-visible value is tightly packed...
|
||||
GLfloat readBack[4] = {};
|
||||
GetUniformfv(program, loc, readBack);
|
||||
EXPECT_EQ(std::memcmp(readBack, m2, sizeof(m2)), 0);
|
||||
|
||||
// ...while the bytes in the UBO put column 1 at offset 16, not 8.
|
||||
const auto& programObject = MG_State::pGLContext->GetProgramObject(program);
|
||||
ASSERT_NE(programObject, nullptr);
|
||||
const auto* ubo = static_cast<const char*>(programObject->MapUBO());
|
||||
ASSERT_NE(ubo, nullptr);
|
||||
const Uint offset = programObject->GetUniformOffset(static_cast<Uint>(loc));
|
||||
ASSERT_NE(offset, MG_State::GLState::ProgramObject::kInvalidUniformOffset);
|
||||
GLfloat column0[2] = {};
|
||||
GLfloat column1[2] = {};
|
||||
std::memcpy(column0, ubo + offset, sizeof(column0));
|
||||
std::memcpy(column1, ubo + offset + 16, sizeof(column1));
|
||||
EXPECT_FLOAT_EQ(column0[0], 1.0f);
|
||||
EXPECT_FLOAT_EQ(column0[1], 2.0f);
|
||||
EXPECT_FLOAT_EQ(column1[0], 3.0f);
|
||||
EXPECT_FLOAT_EQ(column1[1], 4.0f);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// GL 4.6 core 7.1: shaderType is an enum, so an unrecognised one is INVALID_ENUM - it used to be
|
||||
// reported as INVALID_VALUE. glCreateShaderProgramv adds a count < 0 gate ahead of everything.
|
||||
TEST_F(ProgramTest, CreateShaderAndCreateShaderProgramvReportTheRightErrorClasses) {
|
||||
while (GetError() != GL_NO_ERROR) {
|
||||
}
|
||||
|
||||
EXPECT_EQ(CreateShader(GL_FLOAT), 0u);
|
||||
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR) << "the call recorded more than one error";
|
||||
|
||||
const char* source = "#version 330 core\nvoid main() { gl_Position = vec4(1.0); }\n";
|
||||
EXPECT_EQ(CreateShaderProgramv(GL_FLOAT, 1, &source), 0u);
|
||||
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR) << "the call recorded more than one error";
|
||||
|
||||
EXPECT_EQ(CreateShaderProgramv(GL_VERTEX_SHADER, -1, &source), 0u);
|
||||
EXPECT_EQ(GetError(), GL_INVALID_VALUE);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR) << "the call recorded more than one error";
|
||||
|
||||
// A well-formed call still works.
|
||||
const GLuint program = CreateShaderProgramv(GL_VERTEX_SHADER, 1, &source);
|
||||
EXPECT_NE(program, 0u);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1898,3 +1898,132 @@ TEST(FastSTLSanity, ErasingTheOnlyElementReturnsEnd) {
|
||||
EXPECT_EQ(next, map.end());
|
||||
EXPECT_TRUE(map.empty());
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Records what the per-unit texture sync actually pushed at the driver: which backend
|
||||
// texture id was current when each glTexImage2D landed, and the shape it was given.
|
||||
struct TexSpecCall {
|
||||
GLuint texture;
|
||||
GLsizei width;
|
||||
GLsizei height;
|
||||
};
|
||||
MobileGL::Vector<TexSpecCall>* g_texSpecCalls = nullptr;
|
||||
GLuint g_texSpecBoundTexture = 0;
|
||||
|
||||
void TS_BindTexture(GLenum, GLuint texture) { g_texSpecBoundTexture = texture; }
|
||||
void TS_ActiveTexture(GLenum) {}
|
||||
void TS_TexParameteri(GLenum, GLenum, GLint) {}
|
||||
void TS_TexParameterf(GLenum, GLenum, GLfloat) {}
|
||||
void TS_TexParameterfv(GLenum, GLenum, const GLfloat*) {}
|
||||
void TS_PixelStorei(GLenum, GLint) {}
|
||||
void TS_BindBuffer(GLenum, GLuint) {}
|
||||
void TS_TexImage2D(GLenum, GLint level, GLint, GLsizei width, GLsizei height, GLint, GLenum, GLenum,
|
||||
const void*) {
|
||||
if (g_texSpecCalls && level == 0) {
|
||||
g_texSpecCalls->push_back({g_texSpecBoundTexture, width, height});
|
||||
}
|
||||
}
|
||||
|
||||
// Clears the recording hook even when a gtest assertion unwinds the test body.
|
||||
struct ScopedTexSpecRecording {
|
||||
explicit ScopedTexSpecRecording(MobileGL::Vector<TexSpecCall>& sink) {
|
||||
g_texSpecCalls = &sink;
|
||||
g_texSpecBoundTexture = 0;
|
||||
}
|
||||
~ScopedTexSpecRecording() { g_texSpecCalls = nullptr; }
|
||||
ScopedTexSpecRecording(const ScopedTexSpecRecording&) = delete;
|
||||
ScopedTexSpecRecording& operator=(const ScopedTexSpecRecording&) = delete;
|
||||
};
|
||||
|
||||
// Gives `name` a complete single-level 2D image of the requested size without going through
|
||||
// the frontend upload path (the mock table below wires only the state-pushing entry points).
|
||||
MobileGL::SharedPtr<MobileGL::MG_State::GLState::ITextureObject> MakeComplete2DTexture(GLuint name,
|
||||
MobileGL::Int size) {
|
||||
using namespace MobileGL;
|
||||
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, name);
|
||||
auto object = MG_State::pGLContext->GetTextureUnitObject(0)
|
||||
.GetBindingSlot(TextureTarget::Texture2D)
|
||||
.GetBoundObject();
|
||||
object->SetInternalFormat(TextureInternalFormat::RGBA8);
|
||||
MG_State::GLState::AsMipmapTexture(object.get())
|
||||
->AllocateStorage(TextureUploadTarget::Texture2D, 0, {{size, size, 1}, 4});
|
||||
return object;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// The per-unit texture sync memo BORROWS the binding slot: an entry holds a pointer to the
|
||||
// slot's shared_ptr plus the backend twin of whatever was in it when the entry was built. Its
|
||||
// keys (context id, bind-generation epoch, high-water mark, sampling generation) are the primary
|
||||
// guard, but they are all derived state - so the memo also has to survive a slot swap that never
|
||||
// reached them.
|
||||
//
|
||||
// It did not. The DSA by-name emulation swapped a slot silently, every key still matched, and
|
||||
// the replay drove texture A's backend twin from texture B's frontend object: A's backend
|
||||
// storage was re-specified with B's shape, destroying anything A only ever had on the GPU. On
|
||||
// Espryt + Iris/BSL that blanked Minecraft's 16x16 lightmap the moment a 2048x2048 shadow map
|
||||
// was uploaded through a by-name call, and since the text shader multiplies by the lightmap,
|
||||
// `if (color.a < 0.1) discard` then threw away every glyph in the process - HUD, menus and the
|
||||
// vanilla title screen alike.
|
||||
TEST(DirectGLESTextureSync, UnitMemoRefusesToDriveATwinFromAnotherTexture) {
|
||||
using namespace MobileGL;
|
||||
ScopedDirectGLESTextureBindings scoped; // fresh GLContext + registry + binding caches
|
||||
Vector<TexSpecCall> specs;
|
||||
ScopedTexSpecRecording recording(specs);
|
||||
|
||||
auto functions = MG_Backend::DirectGLES::g_GLESFuncs;
|
||||
functions.glBindTexture = TS_BindTexture;
|
||||
functions.glActiveTexture = TS_ActiveTexture;
|
||||
functions.glTexImage2D = TS_TexImage2D;
|
||||
functions.glTexParameteri = TS_TexParameteri;
|
||||
functions.glTexParameterf = TS_TexParameterf;
|
||||
functions.glTexParameterfv = TS_TexParameterfv;
|
||||
functions.glPixelStorei = TS_PixelStorei;
|
||||
functions.glBindBuffer = TS_BindBuffer;
|
||||
MG_Backend::DirectGLES::SetGLESFuncsTable(functions);
|
||||
|
||||
GLuint names[2] = {};
|
||||
MG_Impl::GLImpl::GenTextures(2, names);
|
||||
// `foreign` stands in for the shadow map, `resident` for the lightmap. Both are fully
|
||||
// specified BEFORE the first sync so that nothing between the two syncs can move the
|
||||
// sampling-resolution generation and invalidate the memo for an unrelated reason.
|
||||
const auto foreign = MakeComplete2DTexture(names[1], 32);
|
||||
const auto resident = MakeComplete2DTexture(names[0], 16);
|
||||
ASSERT_NE(foreign, nullptr);
|
||||
ASSERT_NE(resident, nullptr);
|
||||
|
||||
// First sync: builds the memo with unit 0 -> `resident`, and gives `resident`'s twin its
|
||||
// 16x16 backend storage.
|
||||
MG_Backend::DirectGLES::TextureImpl::SyncNeccessaryTextures();
|
||||
auto* residentSlot = MG_Backend::DirectGLES::TextureImpl::g_backendTextureObjects.Find(resident.get());
|
||||
ASSERT_NE(residentSlot, nullptr);
|
||||
ASSERT_NE(*residentSlot, nullptr);
|
||||
const GLuint residentBackendId = (*residentSlot)->GetBackendTextureId();
|
||||
ASSERT_NE(residentBackendId, 0u);
|
||||
ASSERT_FALSE(specs.empty());
|
||||
EXPECT_EQ(specs.back().texture, residentBackendId);
|
||||
EXPECT_EQ(specs.back().width, 16);
|
||||
|
||||
// The hazard, reproduced at the state level: put `foreign` on the slot the memo borrows
|
||||
// WITHOUT telling the binding accounting, exactly as the by-name emulation used to.
|
||||
MG_State::pGLContext->GetTextureUnitObject(0).GetBindingSlot(TextureTarget::Texture2D).Bind(foreign);
|
||||
|
||||
const SizeT specsBeforeReplay = specs.size();
|
||||
MG_Backend::DirectGLES::TextureImpl::SyncNeccessaryTextures();
|
||||
|
||||
// `foreign` must have been synced through its OWN twin...
|
||||
auto* foreignSlot = MG_Backend::DirectGLES::TextureImpl::g_backendTextureObjects.Find(foreign.get());
|
||||
ASSERT_NE(foreignSlot, nullptr);
|
||||
ASSERT_NE(*foreignSlot, nullptr);
|
||||
const GLuint foreignBackendId = (*foreignSlot)->GetBackendTextureId();
|
||||
EXPECT_NE(foreignBackendId, residentBackendId);
|
||||
|
||||
// ...and above all, nothing may have re-specified the RESIDENT texture's backend storage.
|
||||
// That single call is what destroyed the lightmap.
|
||||
for (SizeT i = specsBeforeReplay; i < specs.size(); ++i) {
|
||||
EXPECT_NE(specs[i].texture, residentBackendId)
|
||||
<< "the stale memo entry re-specified the resident texture's backend storage with "
|
||||
<< specs[i].width << "x" << specs[i].height;
|
||||
}
|
||||
|
||||
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, 0);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <Config.h>
|
||||
#include <MG_Backend/BackendObjects.h>
|
||||
#include <MG_Backend/DirectGLES/Managers.h>
|
||||
#include <MG_Backend/DirectGLES/Utils.h>
|
||||
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
|
||||
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
|
||||
#include <MG_Impl/GLImpl/RenderState/GL_RenderState.h>
|
||||
@@ -27,6 +28,7 @@
|
||||
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
|
||||
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
|
||||
#include <MG_Util/Converters/MGToMG/TextureEnumConverter.h>
|
||||
#include <MG_Util/Converters/MGToStr/TextureEnumConverter.h>
|
||||
#include <MG_Util/Math/SmallFloat.h>
|
||||
#include <MG_Util/Texture/PixelStoreProcessor.h>
|
||||
#include <MG_Util/Texture/TextureFormatProcessor.h>
|
||||
@@ -2672,3 +2674,505 @@ TEST_F(TextureTest, DecodeShadowDataToWideRGBACoversComponentAndPackedLayouts) {
|
||||
EXPECT_EQ(rgba[3], 2u);
|
||||
}
|
||||
}
|
||||
|
||||
// GL 4.6 core table 23.18: GL_TEXTURE_COMPARE_FUNC takes the whole eight-function depth-compare
|
||||
// range. The validator used to start it at GL_LEQUAL, which sits in the middle of the contiguous
|
||||
// GL_NEVER..GL_ALWAYS block, so NEVER/LESS/EQUAL were rejected while GREATER/NOTEQUAL/GEQUAL only
|
||||
// got through because they happen to be numerically above LEQUAL.
|
||||
TEST_F(TextureTest, SamplerCompareFuncAcceptsTheWholeNeverToAlwaysRange) {
|
||||
GLuint sampler = 0;
|
||||
MG_Impl::GLImpl::GenSamplers(1, &sampler);
|
||||
ASSERT_NE(sampler, 0u);
|
||||
|
||||
const GLenum compareFuncs[] = {GL_NEVER, GL_LESS, GL_EQUAL, GL_LEQUAL,
|
||||
GL_GREATER, GL_NOTEQUAL, GL_GEQUAL, GL_ALWAYS};
|
||||
for (GLenum func : compareFuncs) {
|
||||
MG_Impl::GLImpl::SamplerParameteri(sampler, GL_TEXTURE_COMPARE_FUNC, static_cast<GLint>(func));
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "compare func " << func << " was rejected";
|
||||
GLint readBack = 0;
|
||||
MG_Impl::GLImpl::GetSamplerParameteriv(sampler, GL_TEXTURE_COMPARE_FUNC, &readBack);
|
||||
EXPECT_EQ(static_cast<GLenum>(readBack), func);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Just outside the block on both sides is still INVALID_ENUM.
|
||||
MG_Impl::GLImpl::SamplerParameteri(sampler, GL_TEXTURE_COMPARE_FUNC, GL_NEVER - 1);
|
||||
ExpectSingleGlError(GL_INVALID_ENUM);
|
||||
MG_Impl::GLImpl::SamplerParameteri(sampler, GL_TEXTURE_COMPARE_FUNC, GL_ALWAYS + 1);
|
||||
ExpectSingleGlError(GL_INVALID_ENUM);
|
||||
|
||||
MG_Impl::GLImpl::DeleteSamplers(1, &sampler);
|
||||
}
|
||||
|
||||
// GL 4.6 core table 23.19: GL_TEXTURE_BINDING_* and GL_SAMPLER_BINDING are per-texture-unit, so
|
||||
// glGetIntegeri_v must answer for unit `index` - not fall through to the backend, which knows
|
||||
// nothing about the frontend's binding state.
|
||||
TEST_F(TextureTest, GetIntegeriVReportsPerUnitTextureAndSamplerBindings) {
|
||||
GLuint textures[2] = {0, 0};
|
||||
MG_Impl::GLImpl::GenTextures(2, textures);
|
||||
ASSERT_NE(textures[0], 0u);
|
||||
ASSERT_NE(textures[1], 0u);
|
||||
|
||||
MG_Impl::GLImpl::ActiveTexture(GL_TEXTURE0);
|
||||
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, textures[0]);
|
||||
MG_Impl::GLImpl::ActiveTexture(GL_TEXTURE3);
|
||||
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, textures[1]);
|
||||
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
GLint binding = -1;
|
||||
MG_Impl::GLImpl::GetIntegeri_v(GL_TEXTURE_BINDING_2D, 0, &binding);
|
||||
EXPECT_EQ(static_cast<GLuint>(binding), textures[0]);
|
||||
MG_Impl::GLImpl::GetIntegeri_v(GL_TEXTURE_BINDING_2D, 3, &binding);
|
||||
EXPECT_EQ(static_cast<GLuint>(binding), textures[1]);
|
||||
// An unbound unit reports 0, and a target nothing was bound to reports 0 as well.
|
||||
MG_Impl::GLImpl::GetIntegeri_v(GL_TEXTURE_BINDING_2D, 2, &binding);
|
||||
EXPECT_EQ(binding, 0);
|
||||
MG_Impl::GLImpl::GetIntegeri_v(GL_TEXTURE_BINDING_3D, 0, &binding);
|
||||
EXPECT_EQ(binding, 0);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
// The non-indexed query keeps reporting the ACTIVE unit, which is still unit 3.
|
||||
GLint activeUnitBinding = -1;
|
||||
MG_Impl::GLImpl::GetIntegerv(GL_TEXTURE_BINDING_2D, &activeUnitBinding);
|
||||
EXPECT_EQ(static_cast<GLuint>(activeUnitBinding), textures[1]);
|
||||
|
||||
GLuint sampler = 0;
|
||||
MG_Impl::GLImpl::GenSamplers(1, &sampler);
|
||||
ASSERT_NE(sampler, 0u);
|
||||
MG_Impl::GLImpl::BindSampler(2, sampler);
|
||||
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
MG_Impl::GLImpl::GetIntegeri_v(GL_SAMPLER_BINDING, 2, &binding);
|
||||
EXPECT_EQ(static_cast<GLuint>(binding), sampler);
|
||||
MG_Impl::GLImpl::GetIntegeri_v(GL_SAMPLER_BINDING, 1, &binding);
|
||||
EXPECT_EQ(binding, 0);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
// Out of range is INVALID_VALUE, not a backend passthrough.
|
||||
GLint maxUnits = 0;
|
||||
MG_Impl::GLImpl::GetIntegerv(GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, &maxUnits);
|
||||
ASSERT_GT(maxUnits, 0);
|
||||
MG_Impl::GLImpl::GetIntegeri_v(GL_TEXTURE_BINDING_2D, static_cast<GLuint>(maxUnits) + 1024u, &binding);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
|
||||
MG_Impl::GLImpl::BindSampler(2, 0);
|
||||
MG_Impl::GLImpl::DeleteSamplers(1, &sampler);
|
||||
MG_Impl::GLImpl::ActiveTexture(GL_TEXTURE0);
|
||||
MG_Impl::GLImpl::DeleteTextures(2, textures);
|
||||
DrainPendingGlErrors();
|
||||
}
|
||||
|
||||
// glGetFloati_v / glGetDoublei_v were no-op stubs: they left the caller's buffer holding whatever
|
||||
// was on the stack. They are converters over the integer indexed query.
|
||||
TEST_F(TextureTest, GetFloatiVAndGetDoubleiVConvertTheIndexedIntegerQuery) {
|
||||
GLuint texture = 0;
|
||||
MG_Impl::GLImpl::GenTextures(1, &texture);
|
||||
ASSERT_NE(texture, 0u);
|
||||
MG_Impl::GLImpl::ActiveTexture(GL_TEXTURE1);
|
||||
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, texture);
|
||||
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
GLfloat asFloat = -1.0f;
|
||||
MG_Impl::GLImpl::GetFloati_v(GL_TEXTURE_BINDING_2D, 1, &asFloat);
|
||||
EXPECT_FLOAT_EQ(asFloat, static_cast<GLfloat>(texture));
|
||||
|
||||
GLdouble asDouble = -1.0;
|
||||
MG_Impl::GLImpl::GetDoublei_v(GL_TEXTURE_BINDING_2D, 1, &asDouble);
|
||||
EXPECT_DOUBLE_EQ(asDouble, static_cast<GLdouble>(texture));
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
MG_Impl::GLImpl::ActiveTexture(GL_TEXTURE0);
|
||||
MG_Impl::GLImpl::DeleteTextures(1, &texture);
|
||||
DrainPendingGlErrors();
|
||||
}
|
||||
|
||||
// GL 3.3 core 3.8.2: the unit glBindSampler accepts is bounded by
|
||||
// GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS. The gate read the frontend's MAX_TEXTURE_IMAGE_UNITS
|
||||
// instead - the capacity of the unit array, 192 - so every unit the backend does not have was
|
||||
// accepted, and the single-bind path disagreed with the multi-bind twin about where the units end.
|
||||
// The backend is stood in so the two limits are distinguishable no matter what the real one
|
||||
// advertises.
|
||||
TEST_F(TextureTest, BindSamplerRejectsUnitsBeyondMaxCombinedTextureImageUnits) {
|
||||
GLuint sampler = 0;
|
||||
MG_Impl::GLImpl::GenSamplers(1, &sampler);
|
||||
ASSERT_NE(sampler, 0u);
|
||||
|
||||
constexpr GLint kCombinedUnits = 24;
|
||||
static_assert(kCombinedUnits < MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS,
|
||||
"the stand-in limit has to be below the unit array capacity to tell the two apart");
|
||||
auto backend = MakeUnique<FormatCapabilityBackend>();
|
||||
FormatCapabilityBackend::MutableDynamicParameters().MaxCombinedTextureImageUnits = kCombinedUnits;
|
||||
ScopedBackendOverride backendOverride(Move(backend));
|
||||
|
||||
GLint reportedUnits = 0;
|
||||
MG_Impl::GLImpl::GetIntegerv(GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, &reportedUnits);
|
||||
ASSERT_EQ(reportedUnits, kCombinedUnits);
|
||||
|
||||
// The last unit that exists still binds.
|
||||
const GLuint lastUnit = static_cast<GLuint>(kCombinedUnits - 1);
|
||||
MG_Impl::GLImpl::BindSampler(lastUnit, sampler);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
EXPECT_NE(MG_State::pGLContext->GetTextureUnitObject(static_cast<Int>(lastUnit)).GetSamplerObject(), nullptr);
|
||||
|
||||
// One past it does not - this is the unit the old gate accepted.
|
||||
MG_Impl::GLImpl::BindSampler(static_cast<GLuint>(kCombinedUnits), sampler);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
EXPECT_EQ(MG_State::pGLContext->GetTextureUnitObject(kCombinedUnits).GetSamplerObject(), nullptr);
|
||||
|
||||
// Past the unit array as well is the same error, not an out-of-bounds index.
|
||||
MG_Impl::GLImpl::BindSampler(
|
||||
static_cast<GLuint>(MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) + 4u, sampler);
|
||||
ExpectSingleGlError(GL_INVALID_VALUE);
|
||||
|
||||
// Both gates now read the same limit: a multi-bind that ends exactly at it binds, and one that
|
||||
// runs a single unit past it is the multi-bind's INVALID_OPERATION, reported up front - not the
|
||||
// single-bind INVALID_VALUE from somewhere inside the loop.
|
||||
const GLuint samplers[2] = {sampler, sampler};
|
||||
MG_Impl::GLImpl::BindSamplers(static_cast<GLuint>(kCombinedUnits - 2), 2, samplers);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
MG_Impl::GLImpl::BindSamplers(lastUnit, 2, samplers);
|
||||
ExpectSingleGlError(GL_INVALID_OPERATION);
|
||||
|
||||
MG_Impl::GLImpl::BindSampler(lastUnit, 0);
|
||||
MG_Impl::GLImpl::BindSampler(static_cast<GLuint>(kCombinedUnits - 2), 0);
|
||||
MG_Impl::GLImpl::DeleteSamplers(1, &sampler);
|
||||
DrainPendingGlErrors();
|
||||
}
|
||||
|
||||
// The DSA by-name entry points are emulated by temporarily binding the named texture onto the
|
||||
// active unit's slot for its target, running the classic bound-texture code, then putting the
|
||||
// previous binding back. For as long as the emulated call runs, that swap is a REAL change to
|
||||
// which texture is bound at that unit, so both transitions have to move the texture bind
|
||||
// generation.
|
||||
//
|
||||
// They used to move nothing. Backends memoise per-unit work keyed on the bind generation and
|
||||
// BORROW the binding slot (they hold a pointer to the slot's shared_ptr, not a copy), so a memo
|
||||
// built while texture A sat in the slot stayed "valid" while B was temporarily in it - and the
|
||||
// backend then drove A's backend twin from B's frontend state, re-specifying A's backend storage
|
||||
// with B's shape. Any content A only ever had on the GPU was gone. That is what blanked
|
||||
// Minecraft's lightmap when Iris uploaded to a BSL shadow map: the text shader multiplies by the
|
||||
// lightmap, so `if (color.a < 0.1) discard` then threw away every glyph in the process.
|
||||
TEST_F(TextureTest, NamedTextureCallKeepsUnitBindingAccountingCoherent) {
|
||||
GLuint names[2] = {};
|
||||
MG_Impl::GLImpl::GenTextures(2, names);
|
||||
const GLuint boundName = names[0];
|
||||
const GLuint namedName = names[1];
|
||||
|
||||
MG_Impl::GLImpl::ActiveTexture(GL_TEXTURE0);
|
||||
// Instantiate both as 2D objects, then leave `boundName` on the unit.
|
||||
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, namedName);
|
||||
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, boundName);
|
||||
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
auto& slot = MG_State::pGLContext->GetTextureUnitObject(0).GetBindingSlot(TextureTarget::Texture2D);
|
||||
const auto boundObject = slot.GetBoundObject();
|
||||
ASSERT_NE(boundObject, nullptr);
|
||||
ASSERT_EQ(boundObject->GetExternalIndex(), boundName);
|
||||
|
||||
// TextureParameteriv is one of the by-name calls that is emulated by binding: it reaches
|
||||
// WithTemporarilyBoundNamedTexture, unlike the scalar TextureParameteri, which edits the
|
||||
// object directly and never touches a unit.
|
||||
const Uint64 base = MG_State::pGLContext->GetTextureBindGeneration();
|
||||
const GLint maxLevel = 0;
|
||||
MG_Impl::GLImpl::TextureParameteriv(namedName, GL_TEXTURE_MAX_LEVEL, &maxLevel);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
|
||||
// The emulation put `namedName` on the unit and took it off again. A generation-keyed memo
|
||||
// must be able to see that the slot it borrows was not stable across the call.
|
||||
EXPECT_GT(MG_State::pGLContext->GetTextureBindGeneration(), base)
|
||||
<< "a by-name texture call swapped a live unit binding without moving the bind generation";
|
||||
// ...and the application-visible binding is exactly what it was before the call.
|
||||
EXPECT_EQ(slot.GetBoundObject(), boundObject);
|
||||
|
||||
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, 0);
|
||||
MG_Impl::GLImpl::DeleteTextures(2, names);
|
||||
DrainPendingGlErrors();
|
||||
}
|
||||
|
||||
// ---- Three-channel colour-renderable widening (Complementary Reimagined / Iris) ----------------
|
||||
//
|
||||
// No real OpenGL ES driver renders to a three-channel image, so a colour attachment the
|
||||
// application asked for as GL_RGB8_SNORM or GL_RGB16F has to be stored in the four-channel
|
||||
// sibling. The bit that says so used to be reachable for multisample storage only, which is why
|
||||
// an ordinary GL_TEXTURE_2D attachment in one of those formats had no fallback at all and the
|
||||
// frontend could only answer GL_FRAMEBUFFER_UNSUPPORTED.
|
||||
|
||||
TEST_F(TextureTest, ColorAttachableTargetsRequestTheThreeChannelWidening) {
|
||||
using MobileGL::MG_Backend::DirectGLES::TextureImpl::GetRenderTargetNormalizeOptions;
|
||||
using MobileGL::MG_Backend::DirectGLES::TextureImpl::TargetRequiresRenderableFormat;
|
||||
|
||||
MG_External::GLESCapabilities capabilities{};
|
||||
capabilities.SupportsRenderSnorm = true;
|
||||
capabilities.SupportsNorm16Texture = true;
|
||||
|
||||
// Every image that can be a colour attachment, not just the multisample pair: an ordinary 2D
|
||||
// texture is what Iris attaches, and it used to be excluded.
|
||||
for (const TextureTarget target : {TextureTarget::Texture2D, TextureTarget::Texture3D,
|
||||
TextureTarget::TextureCubeMap, TextureTarget::Texture2DArray,
|
||||
TextureTarget::TextureCubeMapArray, TextureTarget::Texture2DMultisample,
|
||||
TextureTarget::Texture2DMultisampleArray, TextureTarget::Texture1D,
|
||||
TextureTarget::Texture1DArray, TextureTarget::TextureRectangle}) {
|
||||
const SizeT targetIndex = MobileGL::MG_Backend::GetFormatCapabilityTargetIndex(target);
|
||||
EXPECT_TRUE(TargetRequiresRenderableFormat(targetIndex))
|
||||
<< "target " << MG_Util::ConvertTextureTargetToString(target);
|
||||
EXPECT_TRUE(GetRenderTargetNormalizeOptions(capabilities, targetIndex) &
|
||||
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)
|
||||
<< "target " << MG_Util::ConvertTextureTargetToString(target);
|
||||
}
|
||||
// A renderbuffer exists only to be attached.
|
||||
EXPECT_TRUE(TargetRequiresRenderableFormat(MobileGL::MG_Backend::GetRenderbufferFormatCapabilityTargetIndex()));
|
||||
|
||||
// A buffer texture is the one image that can never be an attachment; its storage belongs to
|
||||
// the buffer object, so widening it would misdescribe the application's data.
|
||||
const SizeT bufferIndex = MobileGL::MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::TextureBuffer);
|
||||
EXPECT_FALSE(TargetRequiresRenderableFormat(bufferIndex));
|
||||
EXPECT_FALSE(GetRenderTargetNormalizeOptions(capabilities, bufferIndex));
|
||||
|
||||
// Without EXT_render_snorm a 16-bit SNORM render target cannot keep its encoding either.
|
||||
MG_External::GLESCapabilities noSnormCapabilities{};
|
||||
const SizeT texture2DIndex = MobileGL::MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::Texture2D);
|
||||
EXPECT_TRUE(GetRenderTargetNormalizeOptions(noSnormCapabilities, texture2DIndex) &
|
||||
PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget);
|
||||
EXPECT_FALSE(GetRenderTargetNormalizeOptions(capabilities, texture2DIndex) &
|
||||
PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget);
|
||||
}
|
||||
|
||||
TEST_F(TextureTest, ThreeChannelRenderTargetOptionAppliesToEveryDeniedThreeChannelFormat) {
|
||||
using MG_Util::TextureFormatProcessor::GetApplicablePixelFormatNormalizeOptions;
|
||||
const Flags<PixelFormatNormalizeOptionBit> requested =
|
||||
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
|
||||
|
||||
// GL_RGB16F in particular matched no case at all, so no option could ever apply to it and it
|
||||
// fell through NormalizePixelFormat's default passthrough unchanged.
|
||||
for (const GLenum internalFormat : {GL_RGB8_SNORM, GL_RGB16_SNORM, GL_RGB16, GL_RGB10, GL_RGB12, GL_RGB16F,
|
||||
GL_RGB32F, GL_SRGB8, GL_RGB8I, GL_RGB8UI, GL_RGB16I, GL_RGB16UI, GL_RGB32I,
|
||||
GL_RGB32UI}) {
|
||||
EXPECT_TRUE(GetApplicablePixelFormatNormalizeOptions(internalFormat, requested) &
|
||||
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)
|
||||
<< "internalformat 0x" << std::hex << internalFormat;
|
||||
}
|
||||
|
||||
// Four-channel and shared-exponent formats are not widened: RGBA8_SNORM has its own always-on
|
||||
// fallback, and GL_RGB9_E5 has no four-channel sibling that would not need the shared exponent
|
||||
// unpacked on every transfer (nothing renders to it on desktop GL either).
|
||||
for (const GLenum internalFormat : {GL_RGBA8_SNORM, GL_RGBA16F, GL_RGBA8, GL_RGB8, GL_RGB9_E5}) {
|
||||
EXPECT_FALSE(GetApplicablePixelFormatNormalizeOptions(internalFormat, requested) &
|
||||
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)
|
||||
<< "internalformat 0x" << std::hex << internalFormat;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(TextureTest, ThreeChannelWideningRetargetsInternalFormatAndTransferPairTogether) {
|
||||
using MG_Util::TextureFormatProcessor::NormalizePixelFormat;
|
||||
struct Case {
|
||||
GLenum requested;
|
||||
Flags<PixelFormatNormalizeOptionBit> options;
|
||||
GLenum internalFormat;
|
||||
GLenum format;
|
||||
GLenum type;
|
||||
};
|
||||
const Flags<PixelFormatNormalizeOptionBit> widen = PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
|
||||
const Flags<PixelFormatNormalizeOptionBit> widenNoSnorm16 =
|
||||
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget |
|
||||
PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
|
||||
|
||||
const Case cases[] = {
|
||||
// Complementary's colortex1 and colortex2. The transfer pair used to stay three-channel
|
||||
// and keep the *source* component type, emitting (GL_RGBA16F, GL_RGB, GL_BYTE) - which ES
|
||||
// rejects for glTexImage2D outright, and which only went unnoticed because the bit was
|
||||
// reachable for multisample storage alone (glTexStorage*Multisample takes no pair).
|
||||
{GL_RGB8_SNORM, widen, GL_RGBA16F, GL_RGBA, GL_FLOAT},
|
||||
{GL_RGB16F, widen, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT},
|
||||
{GL_RGB32F, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT},
|
||||
// 16-bit SNORM keeps its encoding where EXT_render_snorm can render to it; a half float's
|
||||
// 11-bit mantissa cannot represent a 16-bit SNORM channel exactly.
|
||||
{GL_RGB16_SNORM, widen, GL_RGBA16_SNORM, GL_RGBA, GL_SHORT},
|
||||
{GL_RGB16_SNORM, widenNoSnorm16, GL_RGBA16F, GL_RGBA, GL_FLOAT},
|
||||
// 16-bit UNORM and the legacy 10/12-bit formats stored as RGB16.
|
||||
{GL_RGB16, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT},
|
||||
{GL_RGB10, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT},
|
||||
{GL_RGB12, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT},
|
||||
// sRGB and the integer formats: the base format has to move to the four-channel one of the
|
||||
// right class, GL_RGBA_INTEGER included.
|
||||
{GL_SRGB8, widen, GL_SRGB8_ALPHA8, GL_RGBA, GL_UNSIGNED_BYTE},
|
||||
{GL_RGB8I, widen, GL_RGBA8I, GL_RGBA_INTEGER, GL_BYTE},
|
||||
{GL_RGB8UI, widen, GL_RGBA8UI, GL_RGBA_INTEGER, GL_UNSIGNED_BYTE},
|
||||
{GL_RGB16I, widen, GL_RGBA16I, GL_RGBA_INTEGER, GL_SHORT},
|
||||
{GL_RGB16UI, widen, GL_RGBA16UI, GL_RGBA_INTEGER, GL_UNSIGNED_SHORT},
|
||||
{GL_RGB32I, widen, GL_RGBA32I, GL_RGBA_INTEGER, GL_INT},
|
||||
{GL_RGB32UI, widen, GL_RGBA32UI, GL_RGBA_INTEGER, GL_UNSIGNED_INT},
|
||||
// The widening outranks the other fallbacks, which all pick a three-channel storage the
|
||||
// driver still refuses to render to (GL_RGB8_SNORM -> GL_RGB16F, GL_RGB16 -> GL_RGB32F).
|
||||
{GL_RGB8_SNORM, widen | PixelFormatNormalizeOptionBit::NoSnorm8, GL_RGBA16F, GL_RGBA, GL_FLOAT},
|
||||
{GL_RGB16, widen | PixelFormatNormalizeOptionBit::NoNorm16, GL_RGBA32F, GL_RGBA, GL_FLOAT},
|
||||
// Control: without the bit nothing moves. The bit is only ever set for a target whose
|
||||
// native probe failed, so this is the shape every driver that does render to the
|
||||
// three-channel form keeps - per format, not per platform (llvmpipe renders to GL_RGB16F
|
||||
// but not to GL_RGB8_SNORM, GL_SRGB8, GL_RGB32F or the RGB integer formats).
|
||||
{GL_RGB8_SNORM, PixelFormatNormalizeOptionBit::None, GL_RGB8_SNORM, GL_RGB, GL_BYTE},
|
||||
{GL_RGB16F, PixelFormatNormalizeOptionBit::None, GL_RGB16F, GL_RGB, GL_HALF_FLOAT},
|
||||
{GL_RGB32F, PixelFormatNormalizeOptionBit::None, GL_RGB32F, GL_RGB, GL_FLOAT},
|
||||
{GL_SRGB8, PixelFormatNormalizeOptionBit::None, GL_SRGB8, GL_RGB, GL_UNSIGNED_BYTE},
|
||||
// Not widened even under the bit: no four-channel shared-exponent sibling exists.
|
||||
{GL_RGB9_E5, widen, GL_RGB9_E5, GL_RGB, GL_UNSIGNED_INT_5_9_9_9_REV},
|
||||
// Four-channel formats are unaffected by the bit; RGBA8_SNORM keeps its own fallback.
|
||||
{GL_RGBA8_SNORM, widen, GL_RGBA8_SNORM, GL_RGBA, GL_BYTE},
|
||||
{GL_RGBA8_SNORM, widen | PixelFormatNormalizeOptionBit::NoRGBA8Snorm, GL_RGBA16F, GL_RGBA, GL_FLOAT},
|
||||
};
|
||||
|
||||
for (const auto& testCase : cases) {
|
||||
GLenum internalFormat = 0;
|
||||
GLenum format = 0;
|
||||
GLenum type = 0;
|
||||
NormalizePixelFormat(testCase.requested, testCase.options, &internalFormat, &format, &type);
|
||||
EXPECT_EQ(internalFormat, testCase.internalFormat) << "requested 0x" << std::hex << testCase.requested;
|
||||
EXPECT_EQ(format, testCase.format) << "requested 0x" << std::hex << testCase.requested;
|
||||
EXPECT_EQ(type, testCase.type) << "requested 0x" << std::hex << testCase.requested;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(TextureTest, WidenedRenderTargetUploadExpandsThreeChannelDataWithOpaqueAlpha) {
|
||||
using MobileGL::MG_Backend::DirectGLES::TextureImpl::GetWidenableClientComponentCount;
|
||||
using MobileGL::MG_Backend::DirectGLES::TextureImpl::PrepareChannelWidenedUpload;
|
||||
|
||||
// Only the three-channel formats that can be widened report a source component count; the
|
||||
// repack is what keeps the driver from walking three texels' worth of data per four-texel row.
|
||||
for (const TextureInternalFormat format :
|
||||
{TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16F, TextureInternalFormat::RGB32F,
|
||||
TextureInternalFormat::RGB16Snorm, TextureInternalFormat::RGB16, TextureInternalFormat::SRGB8,
|
||||
TextureInternalFormat::RGB8UI, TextureInternalFormat::RGB32I}) {
|
||||
EXPECT_EQ(GetWidenableClientComponentCount(format), 3u)
|
||||
<< MG_Util::ConvertTextureInternalFormatToString(format);
|
||||
}
|
||||
EXPECT_EQ(GetWidenableClientComponentCount(TextureInternalFormat::RGBA8), 0u);
|
||||
EXPECT_EQ(GetWidenableClientComponentCount(TextureInternalFormat::RGBA8Snorm), 0u);
|
||||
EXPECT_EQ(GetWidenableClientComponentCount(TextureInternalFormat::RGB9E5), 0u);
|
||||
|
||||
const IntVec3 texelSize(2, 1, 1);
|
||||
|
||||
// GL_RGB8_SNORM -> GL_RGBA16F: PrepareNormFloatFallbackUpload has already turned the Int8
|
||||
// shadow into floats, so what arrives here is three floats per texel.
|
||||
{
|
||||
const Float source[] = {0.25f, -0.5f, 0.75f, -1.0f, 0.0f, 1.0f};
|
||||
Vector<Uint8> widened;
|
||||
const auto* result = static_cast<const Float*>(PrepareChannelWidenedUpload(
|
||||
3, texelSize, source, sizeof(source), GL_FLOAT, widened));
|
||||
ASSERT_NE(result, static_cast<const void*>(source));
|
||||
ASSERT_EQ(widened.size(), 8 * sizeof(Float));
|
||||
const Float expected[] = {0.25f, -0.5f, 0.75f, 1.0f, -1.0f, 0.0f, 1.0f, 1.0f};
|
||||
for (SizeT i = 0; i < 8; ++i) {
|
||||
EXPECT_FLOAT_EQ(result[i], expected[i]) << "component " << i;
|
||||
}
|
||||
}
|
||||
|
||||
// GL_RGB16F -> GL_RGBA16F uploads halves untouched, so the synthetic alpha is the half
|
||||
// encoding of 1.0 rather than a saturated field.
|
||||
{
|
||||
const Uint16 source[] = {0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006};
|
||||
Vector<Uint8> widened;
|
||||
const auto* result = static_cast<const Uint16*>(PrepareChannelWidenedUpload(
|
||||
3, texelSize, source, sizeof(source), GL_HALF_FLOAT, widened));
|
||||
ASSERT_NE(result, static_cast<const void*>(source));
|
||||
const Uint16 expected[] = {0x0001, 0x0002, 0x0003, 0x3C00, 0x0004, 0x0005, 0x0006, 0x3C00};
|
||||
for (SizeT i = 0; i < 8; ++i) {
|
||||
EXPECT_EQ(result[i], expected[i]) << "component " << i;
|
||||
}
|
||||
}
|
||||
|
||||
// GL_SRGB8 -> GL_SRGB8_ALPHA8: fixed-point one is the saturated field.
|
||||
{
|
||||
const Uint8 source[] = {1, 2, 3, 4, 5, 6};
|
||||
Vector<Uint8> widened;
|
||||
const auto* result = static_cast<const Uint8*>(PrepareChannelWidenedUpload(
|
||||
3, texelSize, source, sizeof(source), GL_UNSIGNED_BYTE, widened));
|
||||
const Uint8 expected[] = {1, 2, 3, 0xFF, 4, 5, 6, 0xFF};
|
||||
ASSERT_NE(result, static_cast<const void*>(source));
|
||||
EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0);
|
||||
}
|
||||
|
||||
// GL_RGB16_SNORM -> GL_RGBA16_SNORM keeps GL_SHORT, whose 1.0 is the positive maximum.
|
||||
{
|
||||
const Int16 source[] = {-1, 2, -3, 4, -5, 6};
|
||||
Vector<Uint8> widened;
|
||||
const auto* result = static_cast<const Int16*>(PrepareChannelWidenedUpload(
|
||||
3, texelSize, source, sizeof(source), GL_SHORT, widened));
|
||||
const Int16 expected[] = {-1, 2, -3, 0x7FFF, 4, -5, 6, 0x7FFF};
|
||||
ASSERT_NE(result, static_cast<const void*>(source));
|
||||
EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0);
|
||||
}
|
||||
|
||||
// An integer format's added channel carries the integer one, not a saturated field.
|
||||
{
|
||||
const Uint32 source[] = {10, 20, 30, 40, 50, 60};
|
||||
Vector<Uint8> widened;
|
||||
const auto* result = static_cast<const Uint32*>(PrepareChannelWidenedUpload(
|
||||
3, texelSize, source, sizeof(source), GL_UNSIGNED_INT, widened, /*integerData=*/true));
|
||||
const Uint32 expected[] = {10, 20, 30, 1, 40, 50, 60, 1};
|
||||
ASSERT_NE(result, static_cast<const void*>(source));
|
||||
EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0);
|
||||
}
|
||||
|
||||
// GL_RGB8I -> GL_RGBA8I uploads as GL_BYTE, the very type GL_RGB8_SNORM uses, so the type
|
||||
// alone cannot decide the added channel's value: the integer format's one is 1, the
|
||||
// signed-normalized format's is 0x7F. Getting this wrong is invisible through sampling and
|
||||
// glGetTexImage (both answer the alpha with the format's implied one) but escapes through a
|
||||
// blit or glCopyTexSubImage out of the widened attachment.
|
||||
{
|
||||
const Int8 source[] = {-1, 2, -3, 4, -5, 6};
|
||||
Vector<Uint8> widened;
|
||||
const auto* asInteger = static_cast<const Int8*>(PrepareChannelWidenedUpload(
|
||||
3, texelSize, source, sizeof(source), GL_BYTE, widened, /*integerData=*/true));
|
||||
const Int8 expectedInteger[] = {-1, 2, -3, 1, 4, -5, 6, 1};
|
||||
ASSERT_NE(asInteger, static_cast<const void*>(source));
|
||||
EXPECT_EQ(std::memcmp(asInteger, expectedInteger, sizeof(expectedInteger)), 0);
|
||||
|
||||
Vector<Uint8> widenedNorm;
|
||||
const auto* asNormalized = static_cast<const Int8*>(PrepareChannelWidenedUpload(
|
||||
3, texelSize, source, sizeof(source), GL_BYTE, widenedNorm, /*integerData=*/false));
|
||||
const Int8 expectedNormalized[] = {-1, 2, -3, 0x7F, 4, -5, 6, 0x7F};
|
||||
EXPECT_EQ(std::memcmp(asNormalized, expectedNormalized, sizeof(expectedNormalized)), 0);
|
||||
}
|
||||
|
||||
// Which class a widenable format belongs to.
|
||||
for (const TextureInternalFormat format :
|
||||
{TextureInternalFormat::RGB8I, TextureInternalFormat::RGB8UI, TextureInternalFormat::RGB16I,
|
||||
TextureInternalFormat::RGB16UI, TextureInternalFormat::RGB32I, TextureInternalFormat::RGB32UI}) {
|
||||
EXPECT_TRUE(MobileGL::MG_Backend::DirectGLES::TextureImpl::IsIntegerWidenableFormat(format))
|
||||
<< MG_Util::ConvertTextureInternalFormatToString(format);
|
||||
}
|
||||
for (const TextureInternalFormat format :
|
||||
{TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16Snorm, TextureInternalFormat::RGB16,
|
||||
TextureInternalFormat::RGB16F, TextureInternalFormat::RGB32F, TextureInternalFormat::SRGB8}) {
|
||||
EXPECT_FALSE(MobileGL::MG_Backend::DirectGLES::TextureImpl::IsIntegerWidenableFormat(format))
|
||||
<< MG_Util::ConvertTextureInternalFormatToString(format);
|
||||
}
|
||||
|
||||
// The destination is sized from the level, never from the source. The driver reads a full
|
||||
// width*height*4 components for the transfer it was handed, so a short source must still
|
||||
// leave a full buffer behind - sizing it from the source would hand the driver a buffer it
|
||||
// runs off the end of.
|
||||
{
|
||||
const Float shortSource[] = {0.5f, 0.25f, 0.125f};
|
||||
Vector<Uint8> widened;
|
||||
const auto* result = static_cast<const Float*>(PrepareChannelWidenedUpload(
|
||||
3, IntVec3(2, 2, 1), shortSource, sizeof(shortSource), GL_FLOAT, widened));
|
||||
ASSERT_NE(result, static_cast<const void*>(shortSource));
|
||||
ASSERT_EQ(widened.size(), 4 * 4 * sizeof(Float));
|
||||
const Float expected[] = {0.5f, 0.25f, 0.125f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f,
|
||||
0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f};
|
||||
for (SizeT i = 0; i < 16; ++i) {
|
||||
EXPECT_FLOAT_EQ(result[i], expected[i]) << "component " << i;
|
||||
}
|
||||
}
|
||||
|
||||
// No widening in effect (or nothing to convert): the caller's pointer comes straight back, so
|
||||
// the sub-rect upload fast path still recognises an unconverted level.
|
||||
{
|
||||
const Float source[] = {1.0f, 2.0f, 3.0f, 4.0f};
|
||||
Vector<Uint8> widened;
|
||||
EXPECT_EQ(PrepareChannelWidenedUpload(4, texelSize, source, sizeof(source), GL_FLOAT, widened),
|
||||
static_cast<const void*>(source));
|
||||
EXPECT_EQ(PrepareChannelWidenedUpload(0, texelSize, source, sizeof(source), GL_FLOAT, widened),
|
||||
static_cast<const void*>(source));
|
||||
EXPECT_EQ(PrepareChannelWidenedUpload(3, texelSize, nullptr, 0, GL_FLOAT, widened), nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,11 +10,35 @@ target_include_directories(JobNodeTest PRIVATE
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
# GTest::gtest, not GTest::gtest_main: JobNodeTest supplies its own main so that
|
||||
# MOBILEGL_LOG_FILE_PATH is set before the first log write in the process. The engine
|
||||
# -selection cases read the log back to assert that an unrecognized MOBILEGL_ASYNC_POOL value
|
||||
# warns, and the desktop log sink is the file (MOBILEGL_LOG_ENABLE_CONSOLE is 0).
|
||||
target_link_libraries(
|
||||
JobNodeTest PRIVATE
|
||||
GTest::gtest_main
|
||||
GTest::gtest
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(JobNodeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
|
||||
# The engine comparison harness. Deliberately NOT registered with add_test: it measures wall
|
||||
# time, so it has no pass/fail verdict to give CI, and it is driven by a script that varies
|
||||
# MOBILEGL_ASYNC_POOL and MOBILEGL_ASYNC_SHADER_COMPILE_THREADS across a matrix. It lives
|
||||
# beside JobNodeTest because it drives the same pool through the same two engines; it links
|
||||
# MobileGL_s for the real glCompileShader/glLinkProgram frontend path.
|
||||
add_executable(
|
||||
AsyncPoolBench
|
||||
AsyncPoolBench.cpp
|
||||
)
|
||||
|
||||
target_include_directories(AsyncPoolBench PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
target_link_libraries(
|
||||
AsyncPoolBench PRIVATE
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
@@ -9,8 +9,19 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <stdexcept>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <process.h>
|
||||
#define MGL_TEST_GETPID _getpid
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#define MGL_TEST_GETPID getpid
|
||||
#endif
|
||||
|
||||
#include "Includes.h"
|
||||
#include <Config.h>
|
||||
|
||||
@@ -21,6 +32,29 @@ using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Util::Async;
|
||||
|
||||
namespace {
|
||||
// Where this binary's MobileGL log lands, set by main() below. The engine-selection cases
|
||||
// read it back: MobileGL's desktop log sink is the FILE, not the console
|
||||
// (MOBILEGL_LOG_ENABLE_CONSOLE is 0 in Defines.h), so gtest's stdout capture would see
|
||||
// nothing, and "unrecognized value warns" is a contract worth pinning rather than
|
||||
// assuming - a silent fallback makes a misspelt engine name look exactly like an unset
|
||||
// variable.
|
||||
String g_logFilePath;
|
||||
|
||||
// Log.cpp flushes the file after every line, so everything written before this call is
|
||||
// already visible.
|
||||
String ReadLogFrom(const std::streamoff offset) {
|
||||
std::ifstream file(g_logFilePath, std::ios::binary);
|
||||
if (!file) return {};
|
||||
file.seekg(offset);
|
||||
return String((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
std::streamoff LogSize() {
|
||||
std::error_code error;
|
||||
const auto size = std::filesystem::file_size(g_logFilePath, error);
|
||||
return error ? 0 : static_cast<std::streamoff>(size);
|
||||
}
|
||||
|
||||
// Every test drives its own pool instance rather than ShaderCompilePool::Get(): the
|
||||
// process-wide pool is stopped permanently by StopAndDrain (that is the teardown
|
||||
// contract), so a test that drained the singleton would poison every test after it.
|
||||
@@ -73,6 +107,21 @@ namespace {
|
||||
Bool m_open = false;
|
||||
};
|
||||
|
||||
// Live thread count of this process. Linux only - /proc/self/task has one entry per
|
||||
// thread - and 0 where that is not available, which is how the one case that uses it
|
||||
// decides to skip rather than to assert something it cannot see.
|
||||
SizeT LiveThreadCount() {
|
||||
#ifdef __linux__
|
||||
std::error_code error;
|
||||
const auto count = static_cast<SizeT>(
|
||||
std::distance(std::filesystem::directory_iterator("/proc/self/task", error),
|
||||
std::filesystem::directory_iterator()));
|
||||
return error ? 0 : count;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
Bool WaitUntil(const std::function<Bool()>& predicate,
|
||||
const std::chrono::milliseconds timeout = std::chrono::seconds(10)) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||
@@ -89,11 +138,33 @@ namespace {
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
TEST(ShaderCompilePoolLifecycle, ConstructingAPoolStartsNoThreadUntilSomethingIsPosted) {
|
||||
const SizeT before = LiveThreadCount();
|
||||
|
||||
ShaderCompilePool pool(kTestThreads);
|
||||
EXPECT_EQ(pool.GetThreadCount(), 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) {
|
||||
@@ -135,16 +206,18 @@ TEST(ShaderCompilePoolLifecycle, DetectedThreadCountIsPositive) {
|
||||
EXPECT_GE(DetectShaderCompileThreadCount(), 1u);
|
||||
}
|
||||
|
||||
TEST(ShaderCompilePoolLifecycle, AsyncIsOffByDefaultAndTheOverrideDecidesEitherWay) {
|
||||
// The shipped default is still off, and an unset MOBILEGL_ASYNC_SHADER_COMPILE resolves
|
||||
// to it. If the first expectation ever fails without the constant having been
|
||||
// deliberately flipped, something enabled async by accident.
|
||||
TEST(ShaderCompilePoolLifecycle, AsyncIsOnByDefaultAndTheOverrideDecidesEitherWay) {
|
||||
// The shipped default flipped to ON at stage 7 (the GL30-40 + parallel_shader_compile
|
||||
// gate found zero async-attributable failures), and an unset
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE resolves to it. If the first expectation ever fails
|
||||
// without the constant having been deliberately flipped back, something disabled async
|
||||
// by accident - the kill switch below is the supported way off.
|
||||
//
|
||||
// Driven through Features rather than read from it: from stage 3 on, the whole suite is
|
||||
// also run with MOBILEGL_ASYNC_SHADER_COMPILE=1 exported, so a test that simply asserted
|
||||
// "the resolved answer is false" would either fail there or - worse - silently pass in a
|
||||
// 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_FALSE(kAsyncShaderCompileDefault);
|
||||
EXPECT_TRUE(kAsyncShaderCompileDefault);
|
||||
|
||||
const MG_Config::QuirkOverride saved = MG_Config::Features.AsyncShaderCompile;
|
||||
MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::Auto;
|
||||
@@ -215,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) {
|
||||
constexpr Uint kBudget = 2;
|
||||
constexpr Uint kJobs = 64;
|
||||
@@ -470,30 +631,58 @@ TEST(JobNodeException, AThrowingJobDoesNotPoisonTheWorkerForLaterJobs) {
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
TEST(ShaderCompilePoolDrain, StopAndDrainWithAThousandQueuedJobsLeavesNoneRunningOrPending) {
|
||||
constexpr Uint kJobs = 1000;
|
||||
constexpr Uint kQueued = 1000;
|
||||
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;
|
||||
jobs.reserve(kJobs);
|
||||
for (Uint i = 0; i < kJobs; ++i) {
|
||||
jobs.push_back(MakeShared<TestJob>());
|
||||
pool.Post(jobs.back());
|
||||
// Pin that slot with a job that will not return until this test says so. Everything
|
||||
// posted behind it is then PROVABLY still in the queue, which is what makes the counts
|
||||
// below exact.
|
||||
//
|
||||
// This case used to post a thousand trivial jobs and drain immediately, hoping the drain
|
||||
// would beat the workers to some of them - and then assert only that "some" were
|
||||
// cancelled. That hope does not survive an engine whose workers take their next job
|
||||
// without a scheduler round trip: the libfork engine drained all thousand before the
|
||||
// posting loop had finished, so the assertion failed about one run in fifty. The property
|
||||
// being tested (a drain ABANDONS queued work rather than running it) is real and
|
||||
// engine-independent; only the way it was provoked was a race.
|
||||
Gate gate;
|
||||
std::atomic<Bool> entered{false};
|
||||
auto blocker = MakeShared<TestJob>([&](TestJob&) {
|
||||
entered.store(true, std::memory_order_release);
|
||||
gate.Wait();
|
||||
});
|
||||
pool.Post(blocker);
|
||||
ASSERT_TRUE(WaitUntil([&] { return entered.load(); }));
|
||||
|
||||
Vector<SharedPtr<TestJob>> queued;
|
||||
queued.reserve(kQueued);
|
||||
for (Uint i = 0; i < kQueued; ++i) {
|
||||
queued.push_back(MakeShared<TestJob>());
|
||||
pool.Post(queued.back());
|
||||
}
|
||||
for (const auto& job : queued) ASSERT_FALSE(job->IsTerminal());
|
||||
|
||||
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.
|
||||
Uint complete = 0;
|
||||
Uint cancelled = 0;
|
||||
for (const auto& job : jobs) {
|
||||
// The job that was already running still finished: an in-flight body is waited for, not
|
||||
// interrupted.
|
||||
EXPECT_TRUE(blocker->IsComplete());
|
||||
EXPECT_EQ(blocker->ran.load(), 1u);
|
||||
|
||||
// And every queued node is terminal, so nothing is left waiting on a worker that will
|
||||
// never come - settled as cancelled, with its body never entered.
|
||||
for (const auto& job : queued) {
|
||||
ASSERT_TRUE(job->IsTerminal());
|
||||
if (job->IsComplete()) ++complete;
|
||||
if (job->IsCancelled()) ++cancelled;
|
||||
EXPECT_LE(job->ran.load(), 1u);
|
||||
EXPECT_TRUE(job->IsCancelled());
|
||||
EXPECT_EQ(job->ran.load(), 0u);
|
||||
}
|
||||
EXPECT_EQ(complete + cancelled, kJobs);
|
||||
EXPECT_GT(cancelled, 0u); // the drain really did abandon queued work rather than run it
|
||||
}
|
||||
|
||||
TEST(ShaderCompilePoolDrain, StopAndDrainWaitsForARunningBodyToReturn) {
|
||||
@@ -533,3 +722,141 @@ TEST(ShaderCompilePoolDrain, JobsPostedAfterADrainStillRun) {
|
||||
EXPECT_TRUE(job->IsComplete());
|
||||
EXPECT_EQ(job->ran.load(), 1u);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Execution engine selection (MOBILEGL_ASYNC_POOL)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
//
|
||||
// The engine decides only HOW a job that the concurrency budget has already cleared reaches a
|
||||
// worker thread. Everything else in this file - the budget, cancel request-vs-outcome, the
|
||||
// continuation machinery, the inline fallback after a stop, the drain - is engine-independent
|
||||
// by construction, which is why the whole suite is expected to pass unchanged with
|
||||
// MOBILEGL_ASYNC_POOL unset and with it set to libfork. These cases pin the selection itself,
|
||||
// so that a run of the matrix cannot silently test asio twice.
|
||||
|
||||
TEST(AsyncPoolEngineSelection, EveryAcceptedSpellingParsesToItsEngine) {
|
||||
EXPECT_EQ(ParseAsyncPoolEngine("asio"), AsyncPoolEngine::Asio);
|
||||
EXPECT_EQ(ParseAsyncPoolEngine("libfork"), AsyncPoolEngine::Libfork);
|
||||
// Case-insensitive, like the other named-value variables (MOBILEGL_*_MULTIDRAW_MODE).
|
||||
EXPECT_EQ(ParseAsyncPoolEngine("Libfork"), AsyncPoolEngine::Libfork);
|
||||
EXPECT_EQ(ParseAsyncPoolEngine("LIBFORK"), AsyncPoolEngine::Libfork);
|
||||
EXPECT_EQ(ParseAsyncPoolEngine("ASIO"), AsyncPoolEngine::Asio);
|
||||
|
||||
EXPECT_STREQ(AsyncPoolEngineName(AsyncPoolEngine::Asio), "asio");
|
||||
EXPECT_STREQ(AsyncPoolEngineName(AsyncPoolEngine::Libfork), "libfork");
|
||||
// Round trip: whatever the name prints is a spelling the variable accepts back.
|
||||
EXPECT_EQ(ParseAsyncPoolEngine(AsyncPoolEngineName(AsyncPoolEngine::Asio)), AsyncPoolEngine::Asio);
|
||||
EXPECT_EQ(ParseAsyncPoolEngine(AsyncPoolEngineName(AsyncPoolEngine::Libfork)), AsyncPoolEngine::Libfork);
|
||||
}
|
||||
|
||||
TEST(AsyncPoolEngineSelection, EmptyAndAutoAreTheDefaultEngineAndSaySoSilently) {
|
||||
// Unset resolves through the empty string, and "auto" is the spelling the other named
|
||||
// -value variables accept for "no preference". Neither is a mistake, so neither warns.
|
||||
const std::streamoff before = LogSize();
|
||||
EXPECT_EQ(ParseAsyncPoolEngine(""), AsyncPoolEngine::Asio);
|
||||
EXPECT_EQ(ParseAsyncPoolEngine("auto"), AsyncPoolEngine::Asio);
|
||||
EXPECT_EQ(ReadLogFrom(before).find("MOBILEGL_ASYNC_POOL"), String::npos)
|
||||
<< "a legitimate value warned; only an unrecognized one may";
|
||||
}
|
||||
|
||||
TEST(AsyncPoolEngineSelection, AnUnrecognizedEngineNameFallsBackToAsioAndWarns) {
|
||||
const std::streamoff before = LogSize();
|
||||
EXPECT_EQ(ParseAsyncPoolEngine("libfrok"), AsyncPoolEngine::Asio);
|
||||
|
||||
// The warning is the other half of the contract: a misspelt engine name that fell back
|
||||
// silently would be indistinguishable from an unset variable, and a scaling measurement
|
||||
// taken against the wrong engine is worse than no measurement.
|
||||
//
|
||||
// Guarded because MGLOG_W is a compile-time no-op unless the build's log level admits it -
|
||||
// and the shipped level does not (Log.h orders the levels DEBUG=0, WARN=1, ERROR=2, INFO=3,
|
||||
// FATAL=4 and gates on `ACTIVE <= LEVEL`, so the default INFO build enables only INFO and
|
||||
// FATAL). Nothing is skipped: the fallback above is pinned in every build, and this half is
|
||||
// checked by a build configured with
|
||||
// -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_WARN. The same guard is what makes the
|
||||
// preceding "says so silently" case honest rather than vacuously true.
|
||||
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_WARN
|
||||
const String logged = ReadLogFrom(before);
|
||||
EXPECT_NE(logged.find("MOBILEGL_ASYNC_POOL"), String::npos) << "no warning names the variable; log tail: " << logged;
|
||||
EXPECT_NE(logged.find("libfrok"), String::npos)
|
||||
<< "the warning does not quote the rejected value; log tail: " << logged;
|
||||
EXPECT_NE(logged.find("asio"), String::npos)
|
||||
<< "the warning does not say what it fell back to; log tail: " << logged;
|
||||
#else
|
||||
(void)before;
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST(AsyncPoolEngineSelection, TheDetectedEngineIsTheOneTheEnvironmentAskedFor) {
|
||||
// Read the variable directly rather than through the pool, so this really compares the
|
||||
// process's answer against the environment the runner exported. This is the case that
|
||||
// makes "the suite passed with MOBILEGL_ASYNC_POOL=libfork" mean something.
|
||||
const char* const raw = std::getenv("MOBILEGL_ASYNC_POOL");
|
||||
const AsyncPoolEngine expected = ParseAsyncPoolEngine(raw != nullptr ? String(raw) : String());
|
||||
EXPECT_EQ(DetectAsyncPoolEngine(), expected);
|
||||
|
||||
// Stable: resolved once per process, so it cannot drift between calls.
|
||||
EXPECT_EQ(DetectAsyncPoolEngine(), DetectAsyncPoolEngine());
|
||||
|
||||
if (DetectAsyncPoolEngine() != AsyncPoolEngine::Asio) {
|
||||
// Selecting a non-default engine announces itself at INFO, which the shipped log level
|
||||
// does admit - so on the libfork half of the matrix this doubles as the positive
|
||||
// control for the log plumbing the preceding two cases read: it proves
|
||||
// MOBILEGL_LOG_FILE_PATH took effect and that ReadLogFrom really sees MobileGL's
|
||||
// output, rather than passing because the file is always empty.
|
||||
const String logged = ReadLogFrom(0);
|
||||
EXPECT_NE(logged.find("MOBILEGL_ASYNC_POOL"), String::npos)
|
||||
<< "the selected engine was never announced, so this binary's log capture proves nothing";
|
||||
EXPECT_NE(logged.find(AsyncPoolEngineName(DetectAsyncPoolEngine())), String::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(AsyncPoolEngineSelection, EveryPoolReportsTheProcessEngineAndRunsWorkOnIt) {
|
||||
ShaderCompilePool first(kTestThreads);
|
||||
ShaderCompilePool second(kTestThreads);
|
||||
EXPECT_EQ(first.GetEngine(), DetectAsyncPoolEngine());
|
||||
EXPECT_EQ(second.GetEngine(), first.GetEngine())
|
||||
<< "two pools in one process disagree about the engine; a process must never run both";
|
||||
|
||||
// And the engine it reports is the one that actually executed the work: the body ran off
|
||||
// the calling thread, on a thread the pool owns.
|
||||
const auto callingThread = std::this_thread::get_id();
|
||||
std::atomic<Bool> sawPoolThread{false};
|
||||
std::thread::id bodyThread{};
|
||||
auto job = MakeShared<TestJob>([&](TestJob&) {
|
||||
sawPoolThread.store(ShaderCompilePool::IsPoolThread(), std::memory_order_release);
|
||||
bodyThread = std::this_thread::get_id();
|
||||
});
|
||||
first.Post(job);
|
||||
job->Wait();
|
||||
|
||||
ASSERT_TRUE(job->IsComplete());
|
||||
EXPECT_TRUE(sawPoolThread.load());
|
||||
EXPECT_NE(bodyThread, callingThread);
|
||||
}
|
||||
|
||||
// gtest_main is replaced here for one reason: the engine-selection cases above assert that an
|
||||
// unrecognized MOBILEGL_ASYNC_POOL value WARNS, and MobileGL's desktop log sink is the log
|
||||
// file - MOBILEGL_LOG_ENABLE_CONSOLE is 0 in Defines.h, so there is nothing on stdout to
|
||||
// capture. MOBILEGL_LOG_FILE_PATH is read by Log.cpp's InitFile() at the first log write in
|
||||
// the process, so it has to be set before any test body runs.
|
||||
int main(int argc, char** argv) {
|
||||
const std::filesystem::path logPath =
|
||||
std::filesystem::temp_directory_path() /
|
||||
("mobilegl-jobnodetest-" + std::to_string(static_cast<long long>(MGL_TEST_GETPID())) + ".log");
|
||||
g_logFilePath = logPath.string();
|
||||
std::filesystem::remove(logPath);
|
||||
#ifdef _WIN32
|
||||
::_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logFilePath.c_str());
|
||||
#else
|
||||
::setenv("MOBILEGL_LOG_FILE_PATH", g_logFilePath.c_str(), 1);
|
||||
#endif
|
||||
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
const int result = RUN_ALL_TESTS();
|
||||
|
||||
// Best-effort: leaving a log file per test process in the temp directory would be litter,
|
||||
// and a failed run has already printed the tail it needed into the gtest output.
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove(logPath, ignored);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1290,3 +1290,77 @@ TEST_F(GeneralVertexArrayTest, ArrayFormat_IntegerPathRejectsPackedAndBgra) {
|
||||
EXPECT_FALSE(a0.IsBgra);
|
||||
}
|
||||
|
||||
|
||||
// The integer path takes exactly the eight signed/unsigned integer types (GL 4.6 core 10.3.2).
|
||||
// The old check was a blacklist of Unknown + the packed types, so GL_FLOAT / GL_HALF_FLOAT /
|
||||
// GL_DOUBLE / GL_FIXED all converted to a valid DataType and were silently recorded as integer
|
||||
// attributes.
|
||||
TEST_F(GeneralVertexArrayTest, ArrayFormat_IntegerPathRejectsFloatTypes) {
|
||||
CreateVAO();
|
||||
CreateVBO(GL_ARRAY_BUFFER, 64);
|
||||
|
||||
// Establish a known-good integer format first, so a rejected call is visible as "unchanged".
|
||||
VertexAttribIPointer(0, 4, GL_INT, 0, nullptr);
|
||||
ASSERT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
const GLenum floatTypes[] = {GL_FLOAT, GL_HALF_FLOAT, GL_DOUBLE, GL_FIXED};
|
||||
for (GLenum type : floatTypes) {
|
||||
VertexAttribIPointer(0, 4, type, 0, nullptr);
|
||||
EXPECT_EQ(GetError(), GL_INVALID_ENUM) << "type " << type << " was accepted on the integer path";
|
||||
const auto& attribute = MG_State::pGLContext->GetBoundVertexArray()->GetAttribute(0);
|
||||
EXPECT_EQ(attribute.Type, DataType::Int32) << "a rejected format must not take effect";
|
||||
EXPECT_TRUE(attribute.IsInteger);
|
||||
}
|
||||
|
||||
// The float path still accepts them.
|
||||
VertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, nullptr);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
EXPECT_EQ(MG_State::pGLContext->GetBoundVertexArray()->GetAttribute(0).Type, DataType::Float32);
|
||||
}
|
||||
|
||||
// ARB_vertex_attrib_binding's two per-attribute queries. They were missing from
|
||||
// ValidateVertexAttribPname (so glGetVertexAttribiv answered INVALID_ENUM) and from
|
||||
// glGetVertexArrayIndexediv's switch (GL_VERTEX_ATTRIB_BINDING only).
|
||||
TEST_F(GeneralVertexArrayTest, ArrayFormat_BindingAndRelativeOffsetAreQueryable) {
|
||||
const GLuint vao = CreateVAO();
|
||||
CreateVBO(GL_ARRAY_BUFFER, 256);
|
||||
|
||||
VertexAttribFormat(2, 3, GL_FLOAT, GL_FALSE, 12);
|
||||
VertexAttribBinding(2, 5);
|
||||
ASSERT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
GLint binding = -1;
|
||||
GetVertexAttribiv(2, GL_VERTEX_ATTRIB_BINDING, &binding);
|
||||
EXPECT_EQ(binding, 5);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
GLint relativeOffset = -1;
|
||||
GetVertexAttribiv(2, GL_VERTEX_ATTRIB_RELATIVE_OFFSET, &relativeOffset);
|
||||
EXPECT_EQ(relativeOffset, 12);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// The float and double views convert the same value.
|
||||
GLfloat bindingAsFloat = -1.0f;
|
||||
GetVertexAttribfv(2, GL_VERTEX_ATTRIB_BINDING, &bindingAsFloat);
|
||||
EXPECT_FLOAT_EQ(bindingAsFloat, 5.0f);
|
||||
GLdouble offsetAsDouble = -1.0;
|
||||
GetVertexAttribdv(2, GL_VERTEX_ATTRIB_RELATIVE_OFFSET, &offsetAsDouble);
|
||||
EXPECT_DOUBLE_EQ(offsetAsDouble, 12.0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// The by-name (DSA) indexed query answers both as well.
|
||||
GLint namedBinding = -1;
|
||||
GetVertexArrayIndexediv(vao, 2, GL_VERTEX_ATTRIB_BINDING, &namedBinding);
|
||||
EXPECT_EQ(namedBinding, 5);
|
||||
GLint namedRelativeOffset = -1;
|
||||
GetVertexArrayIndexediv(vao, 2, GL_VERTEX_ATTRIB_RELATIVE_OFFSET, &namedRelativeOffset);
|
||||
EXPECT_EQ(namedRelativeOffset, 12);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// An attribute nobody re-bound keeps the default attribute-i -> binding-i mapping.
|
||||
GetVertexAttribiv(1, GL_VERTEX_ATTRIB_BINDING, &binding);
|
||||
EXPECT_EQ(binding, 1);
|
||||
GetVertexAttribiv(1, GL_VERTEX_ATTRIB_RELATIVE_OFFSET, &relativeOffset);
|
||||
EXPECT_EQ(relativeOffset, 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,14 @@
|
||||
#include <asio/post.hpp>
|
||||
#include <asio/thread_pool.hpp>
|
||||
|
||||
#include <libfork/core.hpp>
|
||||
#include <libfork/schedule/lazy_pool.hpp>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <span>
|
||||
|
||||
namespace MobileGL::MG_Util::Async {
|
||||
namespace {
|
||||
@@ -135,46 +141,506 @@ namespace MobileGL::MG_Util::Async {
|
||||
return std::clamp(DetectBigCoreCount(), 1u, kMaxAutoShaderCompileThreads);
|
||||
}
|
||||
|
||||
// ---- Engine selection -----------------------------------------------------------------
|
||||
|
||||
const char* AsyncPoolEngineName(const AsyncPoolEngine engine) {
|
||||
switch (engine) {
|
||||
case AsyncPoolEngine::Libfork: return "libfork";
|
||||
case AsyncPoolEngine::Asio: break;
|
||||
}
|
||||
return "asio";
|
||||
}
|
||||
|
||||
AsyncPoolEngine ParseAsyncPoolEngine(const String& value) {
|
||||
String lowered = value;
|
||||
std::transform(lowered.begin(), lowered.end(), lowered.begin(),
|
||||
[](const unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
if (lowered == "libfork") return AsyncPoolEngine::Libfork;
|
||||
if (lowered == "asio" || lowered == "auto" || lowered.empty()) return AsyncPoolEngine::Asio;
|
||||
// Not silent: a misspelt engine name resolving to the default would be
|
||||
// indistinguishable from not having set the variable at all, and the only reason to
|
||||
// set it is to know which engine ran.
|
||||
MGLOG_W("Config: Ignoring invalid env variable MOBILEGL_ASYNC_POOL='%s'; expected asio|libfork, "
|
||||
"using asio",
|
||||
value.c_str());
|
||||
return AsyncPoolEngine::Asio;
|
||||
}
|
||||
|
||||
AsyncPoolEngine DetectAsyncPoolEngine() {
|
||||
// A live std::getenv rather than an MG_Config::Features mirror, and deliberately so:
|
||||
// a ShaderCompilePool is constructed by binaries that never call MobileGL::Initialize()
|
||||
// and therefore never run MG_ConfigLoader::Init() - MG_Test/Util/JobNodeTest builds
|
||||
// pools directly, and it is the suite that exercises the engines against each other.
|
||||
// Reading Features there would silently resolve to the default and the libfork half of
|
||||
// the test matrix would prove nothing. See the exemption list in Config.h.
|
||||
//
|
||||
// Resolved once per process (a function-local static): every pool in a process gets
|
||||
// the same engine, so a process can never end up running two.
|
||||
static const AsyncPoolEngine engine = [] {
|
||||
const char* value = std::getenv("MOBILEGL_ASYNC_POOL");
|
||||
const AsyncPoolEngine resolved = ParseAsyncPoolEngine(value != nullptr ? String(value) : String());
|
||||
if (resolved != AsyncPoolEngine::Asio) {
|
||||
MGLOG_I("ShaderCompilePool: MOBILEGL_ASYNC_POOL selected the %s execution engine",
|
||||
AsyncPoolEngineName(resolved));
|
||||
}
|
||||
return resolved;
|
||||
}();
|
||||
return engine;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// ---- The engine boundary ----------------------------------------------------------
|
||||
// Submit() has exactly asio::post's contract, and ShaderCompilePool::Impl leans on all
|
||||
// four halves of it:
|
||||
// * it NEVER runs `fn` on the calling thread. DispatchLocked calls it while holding
|
||||
// the pool's plain, non-recursive mutex, and a job body (or a terminal
|
||||
// continuation it releases) is free to call Post() again - an inline run would
|
||||
// deadlock on the lock this frame already owns.
|
||||
// * it is callable from ANY thread, a worker of this very pool included:
|
||||
// ProgramLinkTask::OnDepSettled posts the link job from whichever thread drove the
|
||||
// last compile terminal, which is a worker.
|
||||
// * it may throw, and when it does it must not have consumed the caller's job node,
|
||||
// so Post/DispatchLocked can settle the node instead of stranding it Pending with
|
||||
// a joiner blocked forever.
|
||||
// * once it has accepted `fn`, `fn` WILL run. A dropped callable is a node nothing
|
||||
// ever settles, so the engines run it themselves rather than discard it.
|
||||
class JobExecutor {
|
||||
public:
|
||||
virtual ~JobExecutor() = default;
|
||||
JobExecutor() = default;
|
||||
JobExecutor(const JobExecutor&) = delete;
|
||||
JobExecutor& operator=(const JobExecutor&) = delete;
|
||||
|
||||
virtual void Submit(std::function<void()> fn) = 0;
|
||||
|
||||
// Returns once every callable ever handed to Submit has finished running. The
|
||||
// guarantee StopAndDrain sells to library teardown: after it returns, no worker is
|
||||
// still inside a job body that could touch glslang's process globals.
|
||||
virtual void JoinAll() = 0;
|
||||
};
|
||||
|
||||
// ---- Engine 1: Asio (the shipped default) -----------------------------------------
|
||||
class AsioJobExecutor final : public JobExecutor {
|
||||
public:
|
||||
explicit AsioJobExecutor(const Uint threads) : m_pool(threads) {}
|
||||
|
||||
// asio::post only enqueues; it never runs the handler on the calling thread, which
|
||||
// is what makes calling it under the pool mutex safe.
|
||||
void Submit(std::function<void()> fn) override { asio::post(m_pool, Move(fn)); }
|
||||
|
||||
void JoinAll() override { m_pool.join(); }
|
||||
|
||||
private:
|
||||
asio::thread_pool m_pool;
|
||||
};
|
||||
|
||||
// ---- Engine 2: libfork ------------------------------------------------------------
|
||||
//
|
||||
// libfork is a continuation-stealing fork-join runtime, and the shape that fits here is
|
||||
// NOT fork-join: a job body is one coarse, blocking, non-forking unit (a glslang
|
||||
// compile), and the concurrency budget that bounds peak RSS is Impl's, not the
|
||||
// scheduler's. So libfork is used as a job executor - each dispatched job is a detached
|
||||
// root task - and what it is being asked to beat is Asio's single scheduler queue with
|
||||
// its per-worker work-stealing deques and sleeping workers.
|
||||
//
|
||||
// The one thing libfork forbids is the thing this pool does constantly: lf::schedule
|
||||
// (which lf::detach is built on) THROWS lf::schedule_in_worker when the calling thread
|
||||
// is a libfork worker, because workers may never block. Yet a worker submits on every
|
||||
// job completion - RunOnWorker's tail refills the budget - and again whenever a
|
||||
// terminal continuation posts (ProgramLinkTask::OnDepSettled). Routing those through a
|
||||
// separate dispatch thread works but costs two thread wakeups per job, which measured
|
||||
// 4x worse than Asio on short jobs. So instead a dispatched root is a CHAIN: when its
|
||||
// body returns it takes the next queued job itself and runs it in the same coroutine
|
||||
// on the same worker. The refill a worker submits is therefore absorbed by the very
|
||||
// chain that submitted it - no scheduler round trip, no wakeup - and libfork is only
|
||||
// entered for work that arrives from outside the pool.
|
||||
//
|
||||
// Absorption is bounded at one job per running chain, though, because a chain is one
|
||||
// worker: past that bound the queue would be jobs the budget has already cleared,
|
||||
// waiting behind each other on a single thread. See Submit.
|
||||
//
|
||||
// Why none of this can strand a job: the queue below is only ever added to from inside
|
||||
// a running chain (tl_chainOwner == this), and a chain exits only when it finds the
|
||||
// queue empty - unconditionally, whatever the bound says. Every other submitter goes
|
||||
// to the dispatch thread or straight to lf::detach.
|
||||
class LibforkJobExecutor;
|
||||
|
||||
// Which executor's chain, if any, is running on this thread. Deliberately narrower
|
||||
// than ShaderCompilePool::IsPoolThread(): that flag is process-wide and latched
|
||||
// forever, so a worker of a DIFFERENT pool would read as "mine" and queue a job into a
|
||||
// chain that will never drain it. This says exactly "a chain of *this* executor is
|
||||
// executing on this thread, and it will look at the queue again before it exits".
|
||||
thread_local LibforkJobExecutor* tl_chainOwner = nullptr;
|
||||
|
||||
// One dispatched job, heap-owned. It reaches its coroutine as a POINTER passed BY
|
||||
// VALUE: libfork forwards a root task's arguments into the coroutine frame, so a
|
||||
// by-value pointer is copied into the frame, whereas anything passed by reference
|
||||
// would dangle the moment lf::detach returns - and detach, unlike sync_wait, does not
|
||||
// outlive the task.
|
||||
struct LibforkJob {
|
||||
std::function<void()> body;
|
||||
LibforkJobExecutor* owner;
|
||||
};
|
||||
|
||||
// A scheduler adaptor for lf::detach: it places external submissions round-robin over
|
||||
// lf::lazy_pool's worker contexts instead of letting the pool pick one at random.
|
||||
// Both reasons are load-bearing, and the second was worth 1.3x at a budget equal to
|
||||
// the worker count - the configuration MobileGL actually ships, since maxConcurrency
|
||||
// is clamped to the thread count:
|
||||
// * lf::lazy_pool::schedule chooses its victim with a
|
||||
// std::uniform_int_distribution over a lazy_pool-member xoshiro generator -
|
||||
// unsynchronized mutable state, so two concurrent submissions are a data race
|
||||
// inside libfork itself. An atomic cursor is not.
|
||||
// * A worker's SUBMISSION list is drained only by that worker
|
||||
// (worker_context::try_pop_all is documented "for use only by the owning worker
|
||||
// thread"); a thief takes from the task deque, which is a different queue. So a
|
||||
// job placed on a worker that is inside a long blocking body waits for that body
|
||||
// rather than being stolen - and random placement of `budget` submissions over
|
||||
// `budget` workers collides by the birthday rule. Round-robin lands the GL
|
||||
// thread's burst one per worker, which is exactly the intended shape.
|
||||
struct RoundRobinSubmitter {
|
||||
std::span<lf::worker_context*> contexts;
|
||||
std::atomic<Uint64>* cursor;
|
||||
|
||||
void schedule(const lf::submit_handle job) const {
|
||||
const Uint64 index = cursor->fetch_add(1, std::memory_order_relaxed);
|
||||
contexts[static_cast<SizeT>(index % contexts.size())]->schedule(job);
|
||||
}
|
||||
};
|
||||
|
||||
void RunLibforkChain(LibforkJob* raw) noexcept;
|
||||
|
||||
// The root task every dispatched chain runs as. libfork async function objects are
|
||||
// copyable, captureless callables returning lf::task<>, whose first parameter is the
|
||||
// combinator's synthesized first argument (unused here: this task neither forks nor
|
||||
// joins). The coroutine exists purely as libfork's entry protocol; the loop is in
|
||||
// RunLibforkChain.
|
||||
inline constexpr auto kLibforkChainTask = [](auto /*self*/, LibforkJob* job) -> lf::task<void> {
|
||||
RunLibforkChain(job);
|
||||
co_return;
|
||||
};
|
||||
|
||||
class LibforkJobExecutor final : public JobExecutor {
|
||||
public:
|
||||
explicit LibforkJobExecutor(const Uint threads)
|
||||
: m_pool(static_cast<std::size_t>(std::max(1u, threads))), m_contexts(m_pool.contexts()),
|
||||
m_fallback([this] { FallbackLoop(); }) {}
|
||||
|
||||
~LibforkJobExecutor() override {
|
||||
JoinAll();
|
||||
{
|
||||
const std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_fallbackStop = true;
|
||||
}
|
||||
m_fallbackCv.notify_all();
|
||||
if (m_fallback.joinable()) m_fallback.join();
|
||||
// m_pool is destroyed last, and only here: lf::lazy_pool may not be destructed
|
||||
// while any submitted task can still run or submit more. JoinAll() has
|
||||
// established the first and the joined fallback thread the second. Its
|
||||
// destructor then joins the worker threads, so a worker still unwinding a
|
||||
// finished coroutine frame is waited for rather than pulled out from under.
|
||||
}
|
||||
|
||||
void Submit(std::function<void()> fn) override {
|
||||
if (tl_chainOwner == this) {
|
||||
const std::lock_guard<std::mutex> lock(m_mutex);
|
||||
// The hot path: ONE job per running chain. A chain picks up exactly one
|
||||
// queued job each time its body returns, so a queue no longer than the
|
||||
// number of live chains is a queue every entry of which has a distinct
|
||||
// worker waiting to take it - which is precisely the steady state this
|
||||
// absorption exists for (every worker finishes a job and refills its own
|
||||
// slot, all at once, with no scheduler round trip between them).
|
||||
//
|
||||
// Past that it is oversubscription, and absorbing it would be a
|
||||
// correctness-preserving way to destroy the pool's parallelism: the
|
||||
// budget would still say `maxConcurrency` jobs are in flight while one
|
||||
// worker ran them one behind another. That is not hypothetical - it is
|
||||
// the tail of a pack load, where one compile going terminal releases
|
||||
// several programs at once (ShaderCompileAdoptionMap lets a single
|
||||
// compile settle many) and the worker that drove it posts the whole
|
||||
// burst into an otherwise idle pool. Measured before this branch existed:
|
||||
// four such jobs took 4x one job's wall time on libfork and 1x on Asio.
|
||||
//
|
||||
// The overflow cannot go to lf::detach from here - a libfork worker may
|
||||
// not schedule - so it goes to the dispatch thread, which detaches it to
|
||||
// a worker of its own. That costs one thread wakeup; serializing costs a
|
||||
// whole compile.
|
||||
//
|
||||
// The count is taken AFTER the push, not before: deque::push_back is
|
||||
// strongly exception-safe, so an allocation failure here leaves `fn`
|
||||
// intact for DispatchLocked to settle - but a count incremented in front
|
||||
// of it would be a count nothing ever gives back, and JoinAll would wait
|
||||
// on it forever.
|
||||
const Bool takeable = m_chainQueue.size() < m_liveChains;
|
||||
if (takeable) {
|
||||
m_chainQueue.push_back(Move(fn));
|
||||
++m_outstanding;
|
||||
} else {
|
||||
m_fallbackQueue.push_back(Move(fn));
|
||||
++m_outstanding;
|
||||
m_fallbackCv.notify_one();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
// Counted before anything can run it, so JoinAll cannot observe a zero
|
||||
// that this job would have broken.
|
||||
const std::lock_guard<std::mutex> lock(m_mutex);
|
||||
++m_outstanding;
|
||||
}
|
||||
try {
|
||||
DetachChain(Move(fn));
|
||||
} catch (const lf::schedule_in_worker&) {
|
||||
// Submitted from a libfork worker that is not running one of my chains -
|
||||
// a worker of another ShaderCompilePool. libfork will not take a
|
||||
// submission from there at all, and the queue above is not safe for it
|
||||
// (no chain of mine is running on that thread to drain it), so it goes to
|
||||
// the fallback thread, which is neither. DetachChain restored `fn` before
|
||||
// it threw.
|
||||
const std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_fallbackQueue.push_back(Move(fn));
|
||||
m_fallbackCv.notify_one();
|
||||
} catch (...) {
|
||||
// Out of memory. Give the count back and let the caller settle its node:
|
||||
// that is Submit's contract and what DispatchLocked is written against.
|
||||
Retire();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
void JoinAll() override {
|
||||
std::unique_lock<std::mutex> lock(m_mutex);
|
||||
m_idleCv.wait(lock, [this] { return m_outstanding == 0; });
|
||||
}
|
||||
|
||||
// A chain announces itself before it runs its first body, so that Submit's
|
||||
// absorption rule can count the workers that are going to come back and ask for
|
||||
// more. Under-counting is the only direction this can be wrong in (a detached
|
||||
// chain is not counted until it starts), and under-counting only sends work to
|
||||
// the dispatch thread that a chain could have taken - never the reverse.
|
||||
void EnterChain() noexcept {
|
||||
const std::lock_guard<std::mutex> lock(m_mutex);
|
||||
++m_liveChains;
|
||||
}
|
||||
|
||||
// The end of one job in a chain. Returns true having loaded `body` with the next
|
||||
// job to run on this same worker, false when there is nothing left - after which
|
||||
// the caller must touch neither `this` nor anything owned by it, because the
|
||||
// count this drops to zero may be the one JoinAll is waiting for.
|
||||
//
|
||||
// `body` must arrive empty: the finished job's captures (a strong reference to its
|
||||
// JobNode) are released by the chain, outside this lock, so that no JobNode
|
||||
// destructor ever runs inside the executor's critical section.
|
||||
Bool RetireAndTakeNext(std::function<void()>& body) noexcept {
|
||||
const std::lock_guard<std::mutex> lock(m_mutex);
|
||||
--m_outstanding;
|
||||
if (!m_chainQueue.empty()) {
|
||||
// Unconditional, and it has to stay that way: a chain that exited while
|
||||
// the queue was non-empty could be the last one, and the entry would then
|
||||
// be waiting on a worker that never comes. That is what makes the
|
||||
// absorption bound in Submit a scheduling policy rather than a liveness
|
||||
// requirement.
|
||||
//
|
||||
// swap, not move-assign: std::function's move assignment is not noexcept,
|
||||
// and this function is.
|
||||
body.swap(m_chainQueue.front());
|
||||
m_chainQueue.pop_front();
|
||||
return true; // the taken job's own count stays held
|
||||
}
|
||||
--m_liveChains;
|
||||
// Notified while STILL HOLDING the lock, which is the whole reason this is not
|
||||
// the usual notify-after-unlock. The wakeup this sends can be the one that
|
||||
// lets JoinAll return and ~LibforkJobExecutor destroy m_idleCv - and a
|
||||
// std::condition_variable may not be destroyed while another thread is inside
|
||||
// notify_all() on it. Holding the lock across the notify means the waiter
|
||||
// cannot re-acquire the mutex, and therefore cannot leave wait(), until this
|
||||
// thread is out of both the notify and the unlock. ThreadSanitizer catches the
|
||||
// other order immediately (pthread_cond_destroy vs pthread_cond_broadcast).
|
||||
if (m_outstanding == 0) m_idleCv.notify_all();
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
// Builds the root task and hands it to libfork. On any failure `fn` is restored,
|
||||
// so the caller can still decide what to do with the job.
|
||||
void DetachChain(std::function<void()>&& fn) {
|
||||
// `new T{...}` allocates before it constructs, so a throwing operator new
|
||||
// leaves `fn` untouched; the member move is std::function's noexcept one.
|
||||
LibforkJob* job = new LibforkJob{Move(fn), this};
|
||||
try {
|
||||
lf::detach(RoundRobinSubmitter{m_contexts, &m_cursor}, kLibforkChainTask, job);
|
||||
} catch (...) {
|
||||
// lf::schedule upholds the strong exception guarantee, so nothing was
|
||||
// scheduled and the payload is still ours.
|
||||
const UniquePtr<LibforkJob> owned(job);
|
||||
fn = Move(owned->body);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
void Retire() noexcept {
|
||||
// Under the lock, for the reason RetireAndTakeNext spells out.
|
||||
const std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (--m_outstanding == 0) m_idleCv.notify_all();
|
||||
}
|
||||
|
||||
// The dispatch thread. It exists because lf::detach is illegal on a libfork worker
|
||||
// and legal here, and it serves the two cases Submit cannot take itself: a
|
||||
// submission from another pool's worker, and a chain's overflow past the
|
||||
// one-job-per-chain bound. It sleeps otherwise, and it dispatches rather than
|
||||
// executes - a body only ever runs here if libfork refuses the job outright.
|
||||
void FallbackLoop() {
|
||||
for (;;) {
|
||||
std::function<void()> fn;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_mutex);
|
||||
m_fallbackCv.wait(lock, [this] { return !m_fallbackQueue.empty() || m_fallbackStop; });
|
||||
// Emptiness is checked before the stop flag so that a stop can never
|
||||
// strand accepted work: an accepted job always runs, because the node
|
||||
// behind it has a joiner that would otherwise block forever.
|
||||
if (m_fallbackQueue.empty()) return;
|
||||
fn.swap(m_fallbackQueue.front());
|
||||
m_fallbackQueue.pop_front();
|
||||
}
|
||||
try {
|
||||
DetachChain(Move(fn));
|
||||
} catch (...) {
|
||||
MGLOG_E("ShaderCompilePool: libfork refused a fallback dispatch; running the job on "
|
||||
"the dispatch thread instead of dropping it");
|
||||
RunHere(Move(fn));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort. Running the body here costs this engine its parallelism for one
|
||||
// job; dropping it would cost a joiner its wakeup forever.
|
||||
void RunHere(std::function<void()>&& fn) noexcept {
|
||||
try {
|
||||
if (fn) fn();
|
||||
} catch (...) {
|
||||
MGLOG_E("ShaderCompilePool: a job body escaped its own containment on the dispatch "
|
||||
"thread; it has been swallowed to keep the thread alive");
|
||||
}
|
||||
fn = nullptr;
|
||||
Retire();
|
||||
}
|
||||
|
||||
lf::lazy_pool m_pool;
|
||||
// Fixed for the pool's lifetime, so it is read once rather than per submission.
|
||||
std::span<lf::worker_context*> m_contexts;
|
||||
std::atomic<Uint64> m_cursor{0};
|
||||
|
||||
std::mutex m_mutex;
|
||||
std::condition_variable m_fallbackCv;
|
||||
std::condition_variable m_idleCv;
|
||||
// Refills and continuations submitted from inside a chain: drained by the chains.
|
||||
std::deque<std::function<void()>> m_chainQueue;
|
||||
// Chains currently executing, i.e. workers that will look at m_chainQueue again
|
||||
// before they exit. The bound on how much Submit may absorb into a chain.
|
||||
Uint m_liveChains = 0;
|
||||
// Submissions from another pool's libfork worker, and the overflow of the rule
|
||||
// above: drained by m_fallback, which detaches each one to a worker.
|
||||
std::deque<std::function<void()>> m_fallbackQueue;
|
||||
// Everything submitted and not yet finished, whichever queue it is in and whether
|
||||
// or not it has reached a worker, so JoinAll needs a single predicate.
|
||||
Uint m_outstanding = 0;
|
||||
Bool m_fallbackStop = false;
|
||||
std::thread m_fallback;
|
||||
};
|
||||
|
||||
void RunLibforkChain(LibforkJob* const raw) noexcept {
|
||||
UniquePtr<LibforkJob> job(raw);
|
||||
LibforkJobExecutor* const owner = job->owner;
|
||||
std::function<void()> body;
|
||||
body.swap(job->body);
|
||||
job.reset();
|
||||
|
||||
LibforkJobExecutor* const savedOwner = tl_chainOwner;
|
||||
tl_chainOwner = owner;
|
||||
owner->EnterChain();
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
if (body) body();
|
||||
} catch (...) {
|
||||
// JobNode::Run contains every body exception already; this is the backstop
|
||||
// for the wrapper itself. An exception escaping here would be stashed in
|
||||
// the root task's shared state, which lf::detach discards - i.e. silently
|
||||
// lost - and would abandon the rest of the chain.
|
||||
MGLOG_E("ShaderCompilePool: a job body escaped its own containment on a libfork worker; "
|
||||
"it has been swallowed to keep the chain alive");
|
||||
}
|
||||
// Release the finished job's captures (its strong JobNode reference) HERE,
|
||||
// outside the executor's lock: a JobNode destructor is arbitrary code.
|
||||
body = nullptr;
|
||||
if (!owner->RetireAndTakeNext(body)) break;
|
||||
}
|
||||
|
||||
// `owner` may already be destroyed - RetireAndTakeNext returning false can be the
|
||||
// call that releases a JoinAll. Nothing below touches it.
|
||||
tl_chainOwner = savedOwner;
|
||||
}
|
||||
|
||||
UniquePtr<JobExecutor> MakeJobExecutor(const AsyncPoolEngine engine, const Uint threads) {
|
||||
switch (engine) {
|
||||
case AsyncPoolEngine::Libfork: return MakeUnique<LibforkJobExecutor>(threads);
|
||||
case AsyncPoolEngine::Asio: break;
|
||||
}
|
||||
return MakeUnique<AsioJobExecutor>(threads);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
struct ShaderCompilePool::Impl {
|
||||
explicit Impl(const Uint threads) : threadCount(std::max(1u, threads)), maxConcurrency(threadCount) {}
|
||||
explicit Impl(const Uint threads)
|
||||
: threadCount(std::max(1u, threads)), engine(DetectAsyncPoolEngine()), maxConcurrency(threadCount) {}
|
||||
|
||||
const Uint threadCount;
|
||||
// Latched at construction, not re-read: a pool may not change engines under its own
|
||||
// workers, and GetEngine() is what the tests compare against the environment.
|
||||
const AsyncPoolEngine engine;
|
||||
|
||||
std::mutex mutex;
|
||||
// Created on the first dispatched Post, never in the constructor: asio::thread_pool
|
||||
// spawns its threads eagerly, and a build with async off must not pay for threads it
|
||||
// Created on the first dispatched Post, never in the constructor: both engines spawn
|
||||
// their threads eagerly (asio::thread_pool its workers, lf::lazy_pool its workers plus
|
||||
// this file's dispatch thread), and a build with async off must not pay for threads it
|
||||
// will never use.
|
||||
UniquePtr<asio::thread_pool> pool;
|
||||
UniquePtr<JobExecutor> executor;
|
||||
std::deque<SharedPtr<JobNode>> queue;
|
||||
Uint inFlight = 0;
|
||||
Uint maxConcurrency;
|
||||
std::atomic<Bool> stopped{false};
|
||||
|
||||
// Callers hold `mutex`. Hands as many queued nodes to Asio as the concurrency budget
|
||||
// allows. Posting under the lock is safe and is what keeps `pool` from being moved
|
||||
// out by a concurrent StopAndDrain between the decision and the dispatch: asio::post
|
||||
// only enqueues, it never runs the handler on the calling thread, so it cannot
|
||||
// Callers hold `mutex`. Hands as many queued nodes to the engine as the concurrency
|
||||
// budget allows. Submitting under the lock is safe and is what keeps `executor` from
|
||||
// being moved out by a concurrent StopAndDrain between the decision and the dispatch:
|
||||
// Submit only enqueues, it never runs the callable on the calling thread, so it cannot
|
||||
// re-enter this mutex.
|
||||
//
|
||||
// A node asio::post fails to hand off is appended to `toCancel` instead of being
|
||||
// A node the engine fails to accept is appended to `toCancel` instead of being
|
||||
// Cancel()'d here: Cancel() runs the node's OnTerminal continuations inline (stage 4
|
||||
// added ProgramLinkTask::OnDepSettled as a real one), and a continuation is free to
|
||||
// call ShaderCompilePool::Post() again. Every caller of DispatchLocked holds `mutex`
|
||||
// (a plain, non-recursive std::mutex) - Cancel()'ing in here would let that
|
||||
// re-entrant Post() deadlock on the very lock this frame already owns. The caller
|
||||
// drains `toCancel` after releasing the lock.
|
||||
//
|
||||
// The `stopped` check is also what keeps this loop from dereferencing a null
|
||||
// `executor`: StopAndDrain sets the flag and moves the executor out in the same
|
||||
// critical section, so a stopped pool never reaches the Submit below.
|
||||
void DispatchLocked(Vector<SharedPtr<JobNode>>& toCancel) {
|
||||
while (!queue.empty() && inFlight < maxConcurrency && !stopped.load(std::memory_order_acquire)) {
|
||||
// Copy rather than move into the handler: if asio::post throws (it allocates)
|
||||
// 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.
|
||||
// Copy rather than move into the callable: if Submit throws (both engines
|
||||
// allocate) the local SharedPtr is still valid, so the node can be settled
|
||||
// instead of being stranded Pending in a queue nothing will dispatch from
|
||||
// again - a joiner would block on it forever. Reclaiming the slot matters just
|
||||
// as much: a leaked `inFlight` shrinks the pool's concurrency budget
|
||||
// permanently.
|
||||
SharedPtr<JobNode> node = queue.front();
|
||||
queue.pop_front();
|
||||
++inFlight;
|
||||
try {
|
||||
asio::post(*pool, [this, node]() mutable { RunOnWorker(Move(node)); });
|
||||
executor->Submit([this, node]() mutable { RunOnWorker(Move(node)); });
|
||||
} catch (...) {
|
||||
--inFlight;
|
||||
toCancel.push_back(Move(node));
|
||||
@@ -184,7 +650,7 @@ namespace MobileGL::MG_Util::Async {
|
||||
|
||||
void RunOnWorker(SharedPtr<JobNode> node) {
|
||||
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
|
||||
// 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
|
||||
@@ -234,13 +700,15 @@ namespace MobileGL::MG_Util::Async {
|
||||
return m_impl->maxConcurrency;
|
||||
}
|
||||
|
||||
AsyncPoolEngine ShaderCompilePool::GetEngine() const { return m_impl->engine; }
|
||||
|
||||
void ShaderCompilePool::SetMaxConcurrency(const Uint n) {
|
||||
Vector<SharedPtr<JobNode>> toCancel;
|
||||
{
|
||||
const std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount);
|
||||
// Raising the budget releases whatever the old one was holding back.
|
||||
if (m_impl->pool) m_impl->DispatchLocked(toCancel);
|
||||
if (m_impl->executor) m_impl->DispatchLocked(toCancel);
|
||||
}
|
||||
// Outside the lock: see DispatchLocked's comment.
|
||||
for (const auto& n2 : toCancel) {
|
||||
@@ -252,23 +720,23 @@ namespace MobileGL::MG_Util::Async {
|
||||
if (!node) return;
|
||||
EnsureProcessTeardownSentinel();
|
||||
|
||||
// Enqueueing can throw: the thread_pool construction and asio::post both allocate,
|
||||
// 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.
|
||||
// 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
|
||||
// thread_pool 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 asio::post
|
||||
// failures rather than propagating them (see above). Keep it that way.
|
||||
// `node` is still valid in the catch for every throw this try can produce. The engine
|
||||
// construction runs before the move; deque::push_back is strongly exception-safe and
|
||||
// SharedPtr's move constructor is noexcept, so a throwing push_back never consumed it;
|
||||
// and DispatchLocked contains its own Submit failures rather than propagating them
|
||||
// (see above). Keep it that way.
|
||||
Bool enqueued = false;
|
||||
Vector<SharedPtr<JobNode>> toCancel;
|
||||
try {
|
||||
const std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
if (!m_impl->stopped.load(std::memory_order_acquire) && !InProcessTeardown()) {
|
||||
if (!m_impl->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->DispatchLocked(toCancel);
|
||||
enqueued = true;
|
||||
@@ -304,17 +772,17 @@ namespace MobileGL::MG_Util::Async {
|
||||
}
|
||||
|
||||
void ShaderCompilePool::StopAndDrain() {
|
||||
// asio::thread_pool::join() from a pool thread would deadlock on itself, and the
|
||||
// whole point of this call is that the GL thread waits for the workers.
|
||||
// Waiting for the workers from a worker would deadlock on itself (asio's join() says
|
||||
// so outright), and the whole point of this call is that the GL thread waits.
|
||||
MOBILEGL_ASSERT(!IsPoolThread(), "ShaderCompilePool::StopAndDrain() called from a pool thread");
|
||||
|
||||
std::deque<SharedPtr<JobNode>> abandoned;
|
||||
UniquePtr<asio::thread_pool> pool;
|
||||
UniquePtr<JobExecutor> executor;
|
||||
{
|
||||
const std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
m_impl->stopped.store(true, std::memory_order_release);
|
||||
abandoned.swap(m_impl->queue);
|
||||
pool = Move(m_impl->pool);
|
||||
executor = Move(m_impl->executor);
|
||||
}
|
||||
|
||||
// Queued but never dispatched: settle them so anything chained behind them is
|
||||
@@ -323,9 +791,9 @@ namespace MobileGL::MG_Util::Async {
|
||||
if (node) node->Cancel();
|
||||
}
|
||||
|
||||
if (pool) {
|
||||
pool->join(); // returns once every handler already handed to Asio has finished
|
||||
pool.reset();
|
||||
if (executor) {
|
||||
executor->JoinAll(); // returns once every job already handed to the engine is done
|
||||
executor.reset(); // and this stops the engine's threads
|
||||
}
|
||||
|
||||
const std::lock_guard<std::mutex> lock(m_impl->mutex);
|
||||
|
||||
@@ -11,20 +11,22 @@
|
||||
#include <MG_Util/Types.h>
|
||||
#include <MG_Util/Async/JobNode.h>
|
||||
|
||||
// This header deliberately includes NO Asio header: asio::thread_pool lives behind the pimpl
|
||||
// in ShaderCompilePool.cpp. Asio stays a private implementation detail of one translation
|
||||
// unit, so no consumer target (MG_Test, MG_IntegrationTest, MG_Benchmark - each with its own
|
||||
// target_include_directories) needs the Asio include path, and no consumer pays its compile
|
||||
// time. Do not add one here.
|
||||
// This header deliberately includes NO Asio and NO libfork header: both execution engines
|
||||
// live behind the pimpl in ShaderCompilePool.cpp. They stay private implementation details of
|
||||
// one translation unit, so no consumer target (MG_Test, MG_IntegrationTest, MG_Benchmark -
|
||||
// each with its own target_include_directories) needs either include path, and no consumer
|
||||
// pays their compile time. libfork in particular is a C++20-coroutine header set whose
|
||||
// instantiation cost nothing outside the pool has any reason to carry. Do not add one here.
|
||||
|
||||
namespace MobileGL::MG_Util::Async {
|
||||
// Stage 1 ships the whole machinery switched off: the pool is constructible and tested,
|
||||
// but nothing in the GL pipeline posts to it. The flip to true happens only after the
|
||||
// real-client soak in the final stage, because the riskiest part of asynchronous
|
||||
// compilation is not the joins - it is that Iris and Sodium change their submission
|
||||
// schedule the moment GL_KHR_parallel_shader_compile is advertised, and a recorded trace
|
||||
// can never cover that path.
|
||||
inline constexpr Bool kAsyncShaderCompileDefault = false;
|
||||
// Stage 7: on by default. The gate behind the flip (2026-08-09, headless Mesa, both
|
||||
// backends): GL30-40 mustpass + KHR-GL46.parallel_shader_compile at async=1 with the
|
||||
// extension advertised - 58,344 case-runs, 8 failures, and every one of the 8 also
|
||||
// fails standalone at async=0 and under the pre-P1 library, i.e. zero async-attributable
|
||||
// deltas. The risk this comment used to name - Iris and Sodium changing their submission
|
||||
// schedule the moment GL_KHR_parallel_shader_compile is advertised - remains the one
|
||||
// thing a recorded trace cannot cover, which is why the kill switch below stays.
|
||||
inline constexpr Bool kAsyncShaderCompileDefault = true;
|
||||
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE forces the answer either way; unset keeps the built-in
|
||||
// default above. Falsy is a complete kill switch: it reverts the threading *and*
|
||||
@@ -65,6 +67,31 @@ namespace MobileGL::MG_Util::Async {
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE_THREADS overrides it outright.
|
||||
Uint DetectShaderCompileThreadCount();
|
||||
|
||||
// ---- MOBILEGL_ASYNC_POOL: which engine drives the worker threads ----------------------
|
||||
// The engine is ONLY the execution engine. The job queue, the concurrency budget and its
|
||||
// clamping, the suspension latch, cancel request-vs-outcome, the stopped-is-synchronous
|
||||
// fallback and the drain are all engine-independent - they live in ShaderCompilePool::Impl
|
||||
// and are shared verbatim by both engines, which is what lets the whole async suite run
|
||||
// unchanged against either one. An engine answers exactly one question: how does a job
|
||||
// that the budget has already cleared reach a worker thread?
|
||||
enum class AsyncPoolEngine : Uint8 {
|
||||
Asio, // asio::thread_pool: one shared queue behind Asio's scheduler lock
|
||||
Libfork, // lf::lazy_pool: per-worker work-stealing deques, workers sleep when idle
|
||||
};
|
||||
|
||||
// "asio" / "libfork" - the spelling the environment variable accepts and the log prints.
|
||||
const char* AsyncPoolEngineName(AsyncPoolEngine engine);
|
||||
|
||||
// Parses one MOBILEGL_ASYNC_POOL value. Case-insensitive; empty, "auto" and anything
|
||||
// unrecognized resolve to Asio, and an unrecognized value warns (a misspelt engine name
|
||||
// would otherwise be indistinguishable from the default, and the whole point of the
|
||||
// variable is to know which engine ran).
|
||||
AsyncPoolEngine ParseAsyncPoolEngine(const String& value);
|
||||
|
||||
// The process's engine, resolved from MOBILEGL_ASYNC_POOL on first call and cached. Every
|
||||
// pool constructed afterwards reports the same answer, so a process never mixes engines.
|
||||
AsyncPoolEngine DetectAsyncPoolEngine();
|
||||
|
||||
class ShaderCompilePool {
|
||||
public:
|
||||
explicit ShaderCompilePool(Uint threadCount);
|
||||
@@ -96,6 +123,11 @@ namespace MobileGL::MG_Util::Async {
|
||||
Uint GetThreadCount() const;
|
||||
Uint GetMaxConcurrency() const;
|
||||
|
||||
// The engine this pool was built with, latched at construction from
|
||||
// DetectAsyncPoolEngine(). Reported rather than re-resolved so that a pool cannot
|
||||
// change engines under its own workers.
|
||||
AsyncPoolEngine GetEngine() const;
|
||||
|
||||
// Bounded concurrency doubles as the memory bound, and is how
|
||||
// glMaxShaderCompilerThreadsKHR(n) is honoured: a 300-program pack load cannot put
|
||||
// 300 glslang arenas in flight at once. Clamped to [1, thread count].
|
||||
|
||||
@@ -838,6 +838,12 @@ namespace MobileGL::MG_Util::BackendLoader {
|
||||
if (std::strcmp(extension, "GL_EXT_render_snorm") == 0) {
|
||||
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) {
|
||||
caps.SupportsSrgbWriteControl = true;
|
||||
}
|
||||
|
||||
@@ -1034,6 +1034,16 @@ namespace MobileGL {
|
||||
// GL_EXT_render_snorm is present, so the signed-normalized formats are colour-renderable
|
||||
// (and usable as multisample texture storage) rather than texture-only.
|
||||
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.
|
||||
// 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.
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
// 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().
|
||||
#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/TextureEnumConverter.h>
|
||||
#include <MG_Util/Texture/TextureFormatProcessor.h>
|
||||
#include <MG_Util/Async/ShaderCompilePool.h>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
@@ -146,12 +151,20 @@ namespace MobileGL::MG_Util::SelfTest {
|
||||
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{}; GL_KHR_parallel_shader_compile is advertised "
|
||||
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, or "
|
||||
"MOBILEGL_ASYNC_SHADER_COMPILE_THREADS=n to change the count)",
|
||||
threads, threads == 1 ? "" : "s", threads));
|
||||
"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.
|
||||
@@ -450,6 +463,35 @@ namespace MobileGL::MG_Util::SelfTest {
|
||||
builder.Warn("GL_EXT_texture_norm16",
|
||||
"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
|
||||
@@ -783,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.
|
||||
struct GlesProbeSummary {
|
||||
Bool capsValid = false;
|
||||
@@ -913,6 +1066,7 @@ namespace MobileGL::MG_Util::SelfTest {
|
||||
builder.report.formatCapabilities.emplace();
|
||||
MG_Backend::DirectGLES::PopulateFormatCapabilities(
|
||||
glesFuncs, caps, builder.report.formatCapabilities.value());
|
||||
ReportThreeChannelColorAttachments(builder, caps, builder.report.formatCapabilities.value());
|
||||
} while (false);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,12 +58,99 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
|
||||
case GL_R8_SNORM:
|
||||
applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm8;
|
||||
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:
|
||||
break;
|
||||
}
|
||||
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,
|
||||
GLenum* outInternalFormat, GLenum* outFormat, GLenum* outType) {
|
||||
#ifdef TRACY_ENABLE
|
||||
@@ -95,13 +182,6 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
|
||||
*outInternalFormat = internalFormat;
|
||||
break;
|
||||
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) ||
|
||||
(options & PixelFormatNormalizeOptionBit::NoRgb16)) {
|
||||
*outInternalFormat = GL_RGB32F;
|
||||
@@ -132,14 +212,6 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
|
||||
*outInternalFormat = internalFormat;
|
||||
break;
|
||||
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) ||
|
||||
(options & PixelFormatNormalizeOptionBit::NoRGB16Snorm) ||
|
||||
(options & PixelFormatNormalizeOptionBit::NoSnorm16)) {
|
||||
@@ -173,10 +245,6 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
|
||||
*outInternalFormat = internalFormat;
|
||||
break;
|
||||
case GL_RGB8_SNORM:
|
||||
if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) {
|
||||
*outInternalFormat = GL_RGBA16F;
|
||||
break;
|
||||
}
|
||||
if (options & PixelFormatNormalizeOptionBit::NoSnorm8) {
|
||||
*outInternalFormat = GL_RGB16F;
|
||||
break;
|
||||
@@ -615,5 +683,18 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
|
||||
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
|
||||
|
||||
@@ -20,9 +20,14 @@ namespace MobileGL {
|
||||
NoRGB16Snorm = 1 << 6,
|
||||
// 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.
|
||||
// Only meaningful for multisample textures: those can never be uploaded to, only
|
||||
// rendered into, so the extra alpha comes from the draw (1.0 for an RGB source)
|
||||
// and no transfer path has to expand three-channel client data.
|
||||
// Set for any colour-attachable target whose native three-channel form the driver
|
||||
// refused to render to (multisample storage always, since ES has no three-channel
|
||||
// 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,
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
Shaders after entering a singleplayer world.
|
||||

|
||||
- 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-makeup-ultrafast-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and
|
||||
MakeUP UltraFast after entering a singleplayer world.
|
||||

|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
@@ -289,6 +289,12 @@
|
||||
"golden": "minecraft-1.21.4-fabric-iris-iterationrp-in-world.0000202020.png",
|
||||
"target_call": 202020,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user