mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-14 23:28:30 +09:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3025284a6e | ||
|
|
d8d7530011 | ||
|
|
0f394fa46f | ||
|
|
107669b3db | ||
|
|
3e0460e472 | ||
|
|
33ff177bb2 | ||
|
|
2e6fc1ffc0 | ||
|
|
c6299f754f | ||
|
|
dcf918b9ee | ||
|
|
d98f72447d | ||
|
|
f15cb8900f | ||
|
|
bd0def6133 | ||
|
|
6f8b7fbc40 | ||
|
|
e5fb57f7eb | ||
|
|
c93e5fa409 | ||
|
|
8191075133 | ||
|
|
d6caed7822 | ||
|
|
9152e88734 | ||
|
|
2406e2d219 | ||
|
|
b228f813c0 | ||
|
|
0d0527192a | ||
|
|
81bcbd6c14 | ||
|
|
867fe3e0ef | ||
|
|
0ec487c993 | ||
|
|
23b880c8be | ||
|
|
ebc5bff9b1 | ||
|
|
231d5c90e4 | ||
|
|
d5f5e6405b | ||
|
|
ac3a83b207 | ||
|
|
335f2decbd | ||
|
|
fb1ad96c04 | ||
|
|
313b75a7c0 | ||
|
|
d7976326fa | ||
|
|
72ee7c439c | ||
|
|
cdea275227 | ||
|
|
6a02c5fea0 | ||
|
|
7db5b35a3e |
@@ -31,3 +31,9 @@
|
||||
[submodule "3rdparty/apitrace"]
|
||||
path = 3rdparty/apitrace
|
||||
url = https://github.com/MobileGL-Dev/apitrace.git
|
||||
[submodule "3rdparty/asio"]
|
||||
path = 3rdparty/asio
|
||||
url = https://github.com/chriskohlhoff/asio.git
|
||||
[submodule "3rdparty/libfork"]
|
||||
path = 3rdparty/libfork
|
||||
url = https://github.com/ConorWilliams/libfork.git
|
||||
|
||||
+1
Submodule 3rdparty/asio added at 8806a6803c
+1
Submodule 3rdparty/libfork added at 9b2b844a5f
+46
-1
@@ -4,6 +4,11 @@ project("MobileGL")
|
||||
|
||||
option(MOBILEGL_BUILD_TEST "Build MobileGL tests" ON )
|
||||
option(MOBILEGL_BUILD_BENCHMARK "Build MobileGL benchmarks" ON )
|
||||
# Headless end-to-end GPU scenarios (MobileGL/MG_IntegrationTest). They need a
|
||||
# real GPU/ICD to do anything, so they are off by default for CI; every scenario
|
||||
# skips cleanly where there is none. Registered under the `integration-gpu`
|
||||
# ctest label so a run can select or exclude them.
|
||||
option(MOBILEGL_BUILD_INTEGRATION_TEST "Build MobileGL headless GPU integration tests" OFF)
|
||||
option(MOBILEGL_FORCE_RELEASE_OPT "Enable Release optimization flags in Debug build" ON )
|
||||
option(MOBILEGL_ENABLE_TRACY "Enable tracy for profiling" OFF)
|
||||
option(MOBILEGL_BUILD_TRACE_REPLAY "Build desktop apitrace replay runner" OFF)
|
||||
@@ -17,7 +22,9 @@ if (ANDROID)
|
||||
set(MOBILEGL_BUILD_BENCHMARK OFF CACHE BOOL "Build MobileGL benchmarks" FORCE)
|
||||
endif()
|
||||
|
||||
if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug" OR MOBILEGL_FORCE_RELEASE_OPT)
|
||||
option(MOBILEGL_ENABLE_LTO "Build with ThinLTO/IPO" OFF)
|
||||
|
||||
if ((NOT CMAKE_BUILD_TYPE STREQUAL "Debug" OR MOBILEGL_FORCE_RELEASE_OPT) AND MOBILEGL_ENABLE_LTO)
|
||||
# Check if ThinLTO or LTO is suppported
|
||||
include(CheckIPOSupported)
|
||||
include(CheckCCompilerFlag)
|
||||
@@ -147,6 +154,9 @@ set(SOURCE_FILES
|
||||
|
||||
MobileGL/MG_Util/Debug/Log.cpp
|
||||
|
||||
MobileGL/MG_Util/Async/JobNode.cpp
|
||||
MobileGL/MG_Util/Async/ShaderCompilePool.cpp
|
||||
|
||||
MobileGL/MG_Util/Math/VectorTypes.cpp
|
||||
MobileGL/MG_Util/Metrics/TextureMetrics.cpp
|
||||
|
||||
@@ -180,6 +190,7 @@ set(SOURCE_FILES
|
||||
|
||||
MobileGL/MG_Util/Classifiers/TextureEnumClassifier.cpp
|
||||
|
||||
MobileGL/MG_Util/ShaderTranspiler/CompileEnv.cpp
|
||||
MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp
|
||||
MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp
|
||||
MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp
|
||||
@@ -187,6 +198,7 @@ set(SOURCE_FILES
|
||||
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenInterfaceStructPass.cpp
|
||||
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EliminateFloatEqualsZeroPass.cpp
|
||||
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameSamplerFunctionParameterPass.cpp
|
||||
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.cpp
|
||||
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecomposeWorkgroupVec3Pass.cpp
|
||||
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecoratePositionInvariantPass.cpp
|
||||
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp
|
||||
@@ -220,6 +232,7 @@ set(SOURCE_FILES
|
||||
MobileGL/MG_Impl/GLImpl/Framebuffer/Validators.cpp
|
||||
MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp
|
||||
MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp
|
||||
MobileGL/MG_Impl/GLImpl/Program/ProgramInterface.cpp
|
||||
MobileGL/MG_Impl/GLImpl/Program/GL_ProgramPipeline.cpp
|
||||
MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp
|
||||
MobileGL/MG_Impl/GLImpl/Texture/Validators.cpp
|
||||
@@ -243,6 +256,7 @@ set(SOURCE_FILES
|
||||
MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp
|
||||
MobileGL/MG_Backend/DirectGLES/Utils.cpp
|
||||
MobileGL/MG_Backend/DirectGLES/Managers.cpp
|
||||
MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp
|
||||
|
||||
MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp
|
||||
MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp
|
||||
@@ -281,7 +295,11 @@ set(SOURCE_FILES
|
||||
MobileGL/MG_State/GLState/TextureState/TextureUnit.cpp
|
||||
MobileGL/MG_State/GLState/TextureState/TextureState.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp
|
||||
MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp
|
||||
MobileGL/MG_State/GLState/RenderState/RenderState.cpp
|
||||
MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp
|
||||
@@ -315,6 +333,11 @@ if (WIN32)
|
||||
)
|
||||
endif()
|
||||
|
||||
# The shader-compile pool runs standalone Asio on real threads. This host's glibc (>= 2.34)
|
||||
# merged pthread into libc, so it links without asking, but the NDK and musl are not
|
||||
# guaranteed to be as forgiving - ask for it explicitly rather than rely on the accident.
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
set(MOBILEGL_LINK_LIBRARIES
|
||||
glslang::glslang
|
||||
spirv-cross-c
|
||||
@@ -324,12 +347,17 @@ set(MOBILEGL_LINK_LIBRARIES
|
||||
GPUOpen::VulkanMemoryAllocator
|
||||
Vulkan::UtilityHeaders
|
||||
spirv-reflect-static
|
||||
Threads::Threads
|
||||
)
|
||||
|
||||
set(MOBILEGL_COMPILE_DEF
|
||||
-DVMA_STATIC_VULKAN_FUNCTIONS=0
|
||||
-DVMA_DYNAMIC_VULKAN_FUNCTIONS=1
|
||||
-DVMA_VULKAN_VERSION=1001000
|
||||
# Header-only Asio, no Boost, no deprecated interfaces. Set on the definition list
|
||||
# rather than per-target so the shared library and the _s static target agree.
|
||||
-DASIO_STANDALONE
|
||||
-DASIO_NO_DEPRECATED
|
||||
)
|
||||
|
||||
message(STATUS "MOBILEGL_COMPILE_DEF=${MOBILEGL_COMPILE_DEF}")
|
||||
@@ -341,6 +369,17 @@ set(MOBILEGL_INCLUDE_DIR
|
||||
${spirv-tools_SOURCE_DIR}/include
|
||||
${spirv-tools_BINARY_DIR}
|
||||
${SPIRV-Headers_SOURCE_DIR}/include
|
||||
# Header-only submodule: no add_subdirectory, no link target. Only
|
||||
# MG_Util/Async/ShaderCompilePool.cpp includes it, and it stays behind that file's
|
||||
# pimpl so no consumer target needs this path.
|
||||
${CMAKE_SOURCE_DIR}/3rdparty/asio/asio/include
|
||||
# The second shader-compile execution engine (MOBILEGL_ASYNC_POOL=libfork), on the
|
||||
# same terms as Asio above: header-only, no add_subdirectory (its CMakeLists only
|
||||
# declares an INTERFACE target plus install/test scaffolding we do not want), no link
|
||||
# target, and reachable from exactly one translation unit. libfork's own
|
||||
# target_compile_features asks for cxx_std_23, which this project already sets
|
||||
# globally, so its C++20 coroutines need no per-source standard override.
|
||||
${CMAKE_SOURCE_DIR}/3rdparty/libfork/include
|
||||
)
|
||||
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
@@ -538,6 +577,12 @@ if (NOT ANDROID)
|
||||
add_subdirectory(MobileGL/MG_Test)
|
||||
endif()
|
||||
|
||||
# After MG_Test so googletest is already available when the unit tests are
|
||||
# built; the module fetches its own copy when they are not.
|
||||
if (MOBILEGL_BUILD_INTEGRATION_TEST)
|
||||
add_subdirectory(MobileGL/MG_IntegrationTest)
|
||||
endif()
|
||||
|
||||
if (MOBILEGL_BUILD_BENCHMARK)
|
||||
add_subdirectory(MobileGL/MG_Benchmark)
|
||||
endif()
|
||||
|
||||
@@ -29,6 +29,33 @@ namespace MobileGL::MG_Config {
|
||||
ForceOff,
|
||||
};
|
||||
|
||||
// Preferred DirectVulkan dispatch tier for the glMultiDraw* families. A preference,
|
||||
// never a demand: the renderer clamps it to what the device supports at device
|
||||
// creation, falling down the chain ext -> indirect -> unroll with one log line.
|
||||
enum class MultiDrawMode : Uint8 {
|
||||
Auto = 0, // unset: best supported tier
|
||||
Ext, // VK_EXT_multi_draw: one vkCmdDrawMultiEXT / vkCmdDrawMultiIndexedEXT
|
||||
Indirect, // multiDrawIndirect feature: one vkCmdDraw*Indirect over a transient command array
|
||||
Unroll, // one vkCmdDraw* per sub-draw
|
||||
};
|
||||
|
||||
// Preferred DirectGLES emulation tier for glMultiDrawElements(BaseVertex). GLES has no
|
||||
// such entry point in core, so every tier below is an emulation; they differ only in
|
||||
// which driver capability they lean on and how many driver calls a batch costs. Like
|
||||
// the Magma knob this is a preference, clamped at resolution time to what the ES
|
||||
// driver actually supports, with one log line when it falls back.
|
||||
enum class GLESMultiDrawMode : Uint8 {
|
||||
Auto = 0, // unset: best supported tier
|
||||
Ext, // one glMultiDrawElementsBaseVertexEXT
|
||||
MultiIndirect, // one glMultiDrawElementsIndirectEXT over a scratch command buffer
|
||||
Indirect, // one glDrawElementsIndirect per sub-draw over that same buffer
|
||||
BaseVertex, // one glDrawElementsBaseVertex per sub-draw
|
||||
DrawElements, // baseVertex folded into a scratch index buffer on the CPU, then plain
|
||||
// glDrawElements per sub-draw (for drivers with no base-vertex draw at all)
|
||||
Compute, // a compute shader flattens every sub-draw into one rebased index buffer,
|
||||
// drawn by a single glDrawElements
|
||||
};
|
||||
|
||||
// Feature toggles parsed once from environment variables in MG_ConfigLoader::Init()
|
||||
// (ConfigLoader.cpp), before the accepted-env map is destroyed. All Bool fields share
|
||||
// one truthy rule: the variable is set, non-empty, not "0", and not "false"
|
||||
@@ -39,6 +66,12 @@ namespace MobileGL::MG_Config {
|
||||
// - DISPLAY: X11 session variable, not MobileGL configuration.
|
||||
// - MOBILEGL_LOG_FILE_PATH: log-file init runs before MG_ConfigLoader::Init
|
||||
// (see MG_Util/Debug/Log.cpp).
|
||||
// - MOBILEGL_ASYNC_POOL: a ShaderCompilePool is constructed by binaries that never call
|
||||
// MobileGL::Initialize() and so never run MG_ConfigLoader::Init - MG_Test's
|
||||
// JobNodeTest builds pools directly, and it is the suite that runs the whole async
|
||||
// matrix against both execution engines. Mirroring it here would resolve to the
|
||||
// default in exactly the tests that exist to tell the engines apart (see
|
||||
// MG_Util/Async/ShaderCompilePool.cpp, DetectAsyncPoolEngine).
|
||||
struct FeaturesTable {
|
||||
// MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries.
|
||||
Bool DisableTimerQuery = false;
|
||||
@@ -91,6 +124,24 @@ namespace MobileGL::MG_Config {
|
||||
// feature off. It is enabled by default to match GL's defined out-of-range fetch
|
||||
// behavior; this escape hatch exists to measure or dodge its GPU cost on a device.
|
||||
Bool DisableRobustBufferAccess = false;
|
||||
// MOBILEGL_MAGMA_MULTIDRAW_MODE: preferred DirectVulkan multi-draw dispatch tier
|
||||
// ("ext" | "indirect" | "unroll", see MultiDrawMode). Clamped to device support;
|
||||
// unset picks the best supported tier.
|
||||
MultiDrawMode MagmaMultiDrawMode = MultiDrawMode::Auto;
|
||||
// MOBILEGL_ESPRYT_MULTIDRAW_MODE: preferred DirectGLES glMultiDrawElements emulation
|
||||
// tier ("ext" | "multiindirect" | "indirect" | "basevertex" | "drawelements" |
|
||||
// "compute", see GLESMultiDrawMode). Clamped to driver support; unset picks the best
|
||||
// supported tier, which never includes "compute" - see the note on its resolution.
|
||||
GLESMultiDrawMode EsprytMultiDrawMode = GLESMultiDrawMode::Auto;
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE: overrides asynchronous shader compilation. Unset
|
||||
// keeps the built-in default (MG_Util::Async::kAsyncShaderCompileDefault); falsy
|
||||
// forces every glCompileShader/glLinkProgram to run synchronously on the calling
|
||||
// thread AND withdraws GL_KHR_parallel_shader_compile, so the single switch reverts
|
||||
// both the threading and the application-visible behaviour change.
|
||||
QuirkOverride AsyncShaderCompile = QuirkOverride::Auto;
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE_THREADS: shader-compile worker count. 0 (unset) means
|
||||
// auto, which is min(4, big cores); an explicit value is honoured as given.
|
||||
Uint32 AsyncShaderCompileThreads = 0;
|
||||
};
|
||||
extern FeaturesTable Features;
|
||||
} // namespace MobileGL::MG_Config
|
||||
|
||||
@@ -97,6 +97,47 @@ namespace MobileGL::MG_ConfigLoader {
|
||||
: MG_Config::QuirkOverride::ForceOff;
|
||||
}
|
||||
|
||||
// Multi-draw mode is a named-value preference: unset keeps Auto (best supported tier),
|
||||
// a recognized name selects that tier as the ceiling, anything else warns and keeps Auto.
|
||||
inline MG_Config::MultiDrawMode QueryEnvMultiDrawMode(const String& key) {
|
||||
auto it = acceptedEnvVariablesMap->find(key);
|
||||
if (it == acceptedEnvVariablesMap->end()) {
|
||||
return MG_Config::MultiDrawMode::Auto;
|
||||
}
|
||||
String lowered = it->second;
|
||||
std::transform(lowered.begin(), lowered.end(), lowered.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
if (lowered == "ext") return MG_Config::MultiDrawMode::Ext;
|
||||
if (lowered == "indirect") return MG_Config::MultiDrawMode::Indirect;
|
||||
if (lowered == "unroll") return MG_Config::MultiDrawMode::Unroll;
|
||||
if (lowered.empty() || lowered == "auto") return MG_Config::MultiDrawMode::Auto;
|
||||
MGLOG_W("Config: Ignoring invalid env variable %s='%s'; expected ext|indirect|unroll|auto, using auto",
|
||||
key.c_str(), it->second.c_str());
|
||||
return MG_Config::MultiDrawMode::Auto;
|
||||
}
|
||||
|
||||
// Same contract as QueryEnvMultiDrawMode, over the DirectGLES tier names.
|
||||
inline MG_Config::GLESMultiDrawMode QueryEnvGLESMultiDrawMode(const String& key) {
|
||||
auto it = acceptedEnvVariablesMap->find(key);
|
||||
if (it == acceptedEnvVariablesMap->end()) {
|
||||
return MG_Config::GLESMultiDrawMode::Auto;
|
||||
}
|
||||
String lowered = it->second;
|
||||
std::transform(lowered.begin(), lowered.end(), lowered.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
if (lowered == "ext") return MG_Config::GLESMultiDrawMode::Ext;
|
||||
if (lowered == "multiindirect") return MG_Config::GLESMultiDrawMode::MultiIndirect;
|
||||
if (lowered == "indirect") return MG_Config::GLESMultiDrawMode::Indirect;
|
||||
if (lowered == "basevertex") return MG_Config::GLESMultiDrawMode::BaseVertex;
|
||||
if (lowered == "drawelements") return MG_Config::GLESMultiDrawMode::DrawElements;
|
||||
if (lowered == "compute") return MG_Config::GLESMultiDrawMode::Compute;
|
||||
if (lowered.empty() || lowered == "auto") return MG_Config::GLESMultiDrawMode::Auto;
|
||||
MGLOG_W("Config: Ignoring invalid env variable %s='%s'; expected "
|
||||
"ext|multiindirect|indirect|basevertex|drawelements|compute|auto, using auto",
|
||||
key.c_str(), it->second.c_str());
|
||||
return MG_Config::GLESMultiDrawMode::Auto;
|
||||
}
|
||||
|
||||
inline Uint32 QueryEnvUint32(const String& key, Uint32 defaultValue, Uint32 minValue, Uint32 maxValue) {
|
||||
auto it = acceptedEnvVariablesMap->find(key);
|
||||
if (it == acceptedEnvVariablesMap->end()) {
|
||||
@@ -138,6 +179,10 @@ namespace MobileGL::MG_ConfigLoader {
|
||||
features.MagmaDisableBlendedDepthWriteQuirk =
|
||||
QueryEnvQuirkOverride("MOBILEGL_MAGMA_DISABLE_BLENDED_DEPTH_WRITE");
|
||||
features.DisableRobustBufferAccess = QueryEnvFlag("MOBILEGL_DISABLE_ROBUST_BUFFER_ACCESS");
|
||||
features.MagmaMultiDrawMode = QueryEnvMultiDrawMode("MOBILEGL_MAGMA_MULTIDRAW_MODE");
|
||||
features.EsprytMultiDrawMode = QueryEnvGLESMultiDrawMode("MOBILEGL_ESPRYT_MULTIDRAW_MODE");
|
||||
features.AsyncShaderCompile = QueryEnvQuirkOverride("MOBILEGL_ASYNC_SHADER_COMPILE");
|
||||
features.AsyncShaderCompileThreads = QueryEnvUint32("MOBILEGL_ASYNC_SHADER_COMPILE_THREADS", 0, 0, 64);
|
||||
}
|
||||
|
||||
inline void InitBackendType() {
|
||||
|
||||
+31
-1
@@ -15,6 +15,8 @@
|
||||
#include <MG_Impl/GLImpl/Texture/ProxyTexture.h>
|
||||
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
|
||||
#include <MG_Impl/GLImpl/Sync/GL_Sync.h>
|
||||
#include <MG_Util/Async/ShaderCompilePool.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
@@ -37,7 +39,12 @@ namespace MobileGL {
|
||||
if (logLifecycle) {
|
||||
MGLOG_I("MobileGL closing...");
|
||||
}
|
||||
glslang::FinalizeProcess();
|
||||
// First, before anything else is torn down. In-flight compile/link jobs own
|
||||
// their own inputs and are safe against everything below EXCEPT glslang's
|
||||
// process globals and the TShader/TProgram objects hanging off pGLContext,
|
||||
// both of which this function is about to destroy. This is the one
|
||||
// cancellation path in the whole design that waits.
|
||||
MG_Util::Async::ShaderCompilePool::Get().StopAndDrain();
|
||||
// GL syncs die with their contexts, and every context is gone by the
|
||||
// time full teardown runs: drain the live-sync registry while the
|
||||
// backend function table can still release the backend handles (and
|
||||
@@ -49,6 +56,16 @@ namespace MobileGL {
|
||||
MG_State::pEGLContext.reset();
|
||||
MG_Impl::GLImpl::TextureImpl::pProxyTextureManager.reset();
|
||||
MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo.reset();
|
||||
// Must run AFTER pGLContext.reset(). FinalizeProcess -> ShFinalize deletes
|
||||
// glslang's process-wide pool allocator and every cached built-in symbol table,
|
||||
// while the TShader/TProgram objects owned by the shader and program objects
|
||||
// still reference levels adopted from those tables. Finalizing first left live
|
||||
// glslang objects pointing at freed memory for the rest of the teardown.
|
||||
glslang::FinalizeProcess();
|
||||
// Immediately after, and never apart from it: FinalizeProcess just deleted the
|
||||
// built-in symbol tables the prewarm latch stands for, so leaving it set would
|
||||
// make the next Initialize() skip a prewarm it genuinely needs.
|
||||
MG_Util::ShaderTranspiler::ShaderCompiler::ResetPrewarmLatch();
|
||||
MG_Backend::gBackendFunctionsTable = {};
|
||||
g_isInitialized = false;
|
||||
if (logLifecycle) {
|
||||
@@ -76,6 +93,19 @@ namespace MobileGL {
|
||||
MG_Impl::Init();
|
||||
MGLOG_D("MG_Impl initialized");
|
||||
glslang::InitializeProcess();
|
||||
// On the GL thread, before any worker can exist. glslang builds its built-in symbol
|
||||
// tables lazily under a process-wide lock held for the whole build, so without this
|
||||
// the first concurrent compiles of a shaderpack all serialize behind the very first
|
||||
// parse and asynchronous compilation looks like it is doing nothing.
|
||||
//
|
||||
// Gated on the flag, because the problem it solves only exists when there are
|
||||
// workers: with compilation synchronous, nothing ever contends for that lock and the
|
||||
// three throwaway parses buy nothing - they just add to every eglInitialize. Read the
|
||||
// flag here rather than inside PrewarmBuiltins so ShaderCompiler keeps no dependency
|
||||
// on the async subsystem (ProgramUtilTest compiles that file without it).
|
||||
if (MG_Util::Async::AsyncShaderCompileEnabled()) {
|
||||
MG_Util::ShaderTranspiler::ShaderCompiler::PrewarmBuiltins();
|
||||
}
|
||||
MGLOG_D("glslang initialized");
|
||||
g_isInitialized = true;
|
||||
MGLOG_I("MobileGL initialized");
|
||||
|
||||
@@ -181,15 +181,18 @@ namespace MobileGL {
|
||||
void (*GetIntegeri_v)(GLenum target, GLuint index, GLint* data);
|
||||
void (*GetInteger64i_v)(GLenum target, GLuint index, GLint64* data);
|
||||
void (*GetProgramiv)(GLuint program, GLenum pname, GLint* params);
|
||||
void (*GetProgramInterfaceiv)(GLuint program, GLenum programInterface, GLenum pname, GLint* params);
|
||||
GLuint (*GetProgramResourceIndex)(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
void (*GetProgramResourceName)(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize,
|
||||
GLsizei* length, GLchar* name);
|
||||
void (*GetProgramResourceiv)(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
|
||||
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params);
|
||||
GLint (*GetProgramResourceLocation)(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
GLint (*GetProgramResourceLocationIndex)(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
void (*ShaderStorageBlockBinding)(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding);
|
||||
// The GL program interface (glGetProgramInterfaceiv / glGetProgramResource*) is NOT
|
||||
// a backend query: it describes the program the application wrote, in the
|
||||
// application's namespace, which neither backend program is in. It is answered
|
||||
// entirely by MG_Impl/GLImpl/Program/ProgramInterface from the frontend reflection.
|
||||
// Takes the block's GL NAME, not glShaderStorageBlockBinding's index. The index
|
||||
// the application passes is the frontend interface-query enumeration's, and no
|
||||
// backend shares that index space: DirectVulkan enumerates SPIR-V descriptor
|
||||
// bindings and DirectGLES asks a real driver about SPIRV-Cross-generated ESSL.
|
||||
// The name is the one coordinate all three agree on, so the frontend resolves the
|
||||
// index against its own enumeration and each backend maps the name to its own.
|
||||
void (*ShaderStorageBlockBinding)(GLuint program, const GLchar* storageBlockName,
|
||||
GLuint storageBlockBinding);
|
||||
// GL fence sync objects. All entries are optional (may be null); the
|
||||
// frontend then falls back to always-signaled sync semantics.
|
||||
// FenceSync may itself return null when the backend cannot create a
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
|
||||
#include <MG_Util/Converters/MGToStr/TextureEnumConverter.h>
|
||||
#include <MG_Util/Texture/TextureFormatProcessor.h>
|
||||
#include <MG_Util/Async/ShaderCompilePool.h>
|
||||
#include <Config.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
@@ -209,7 +210,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
reasons.push_back("GL_DEPTH_COMPONENT32 native probe failed on OpenGL ES");
|
||||
}
|
||||
if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) {
|
||||
reasons.push_back("no three-channel multisample storage format on OpenGL ES");
|
||||
reasons.push_back("no colour-renderable three-channel format on OpenGL ES");
|
||||
}
|
||||
if (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) {
|
||||
reasons.push_back("EXT_render_snorm not supported");
|
||||
@@ -552,26 +553,60 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
|
||||
for (SizeT targetIndex = 0; targetIndex < kFormatCapabilityTextureTargetCount; ++targetIndex) {
|
||||
const auto target = static_cast<TextureTarget>(targetIndex);
|
||||
// A multisample texture can only ever be rendered into, so its storage format
|
||||
// has to stay colour-renderable; the ordinary fallback for a three-channel
|
||||
// format is a three-channel one, which ES accepts as a texture but rejects as
|
||||
// multisample storage. Recompute the fallback per target so those formats get
|
||||
// widened here and nowhere else.
|
||||
Flags<PixelFormatNormalizeOptionBit> targetOptions;
|
||||
if (IsGLESProbeMultisampleTarget(target)) {
|
||||
targetOptions |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
|
||||
if (!capabilities.SupportsRenderSnorm || !capabilities.SupportsNorm16Texture) {
|
||||
targetOptions |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
|
||||
}
|
||||
}
|
||||
// Colour-attachable targets need a colour-renderable fallback; the ordinary
|
||||
// fallback for a three-channel format is another three-channel one, which ES
|
||||
// accepts as a texture but never as an attachment. Recompute the fallback per
|
||||
// target so those formats get widened where the target demands it.
|
||||
const Flags<PixelFormatNormalizeOptionBit> renderTargetOptions =
|
||||
TextureImpl::GetRenderTargetNormalizeOptions(capabilities, targetIndex);
|
||||
// Multisample storage has no three-channel form on ES at all, so its widening
|
||||
// is unconditional and skips the native probe (which cannot succeed). Every
|
||||
// other target keeps the widening on the DRIVER branch, behind the native
|
||||
// probe: `shouldProbeFallback = !nativeCreated || !nativeRenderable` below is
|
||||
// what makes the substitution conditional on the driver actually refusing, so
|
||||
// a driver that does render to a three-channel image keeps allocating it byte
|
||||
// for byte. That is a per-format runtime answer, NOT a desktop-vs-device
|
||||
// split: llvmpipe renders to GL_RGB16F but refuses GL_RGB8_SNORM, GL_SRGB8,
|
||||
// GL_RGB32F and the RGB integer formats, so the CI driver widens those eight
|
||||
// too. Re-run the retrace fixtures and the glcts suites on any change here.
|
||||
const Bool widenUnconditionally = IsGLESProbeMultisampleTarget(target);
|
||||
GLESProbeFormatInfo fallbackInfo = outerFallbackInfo;
|
||||
Bool hasForcedFallback = outerHasForcedFallback;
|
||||
if (targetOptions) {
|
||||
hasForcedFallback = BuildFallbackProbeFormatInfo(
|
||||
requestedInternalFormat, forcedOptions | targetOptions, true, fallbackInfo);
|
||||
if (!hasForcedFallback) {
|
||||
BuildFallbackProbeFormatInfo(requestedInternalFormat, driverOptions | targetOptions, false,
|
||||
if (renderTargetOptions) {
|
||||
// Folded into the forced options only when a forced fallback already
|
||||
// applies, so the render-target bits never *create* one: ANGLE's forced
|
||||
// GL_RGB8_SNORM -> GL_RGB16F is still three-channel and still needs
|
||||
// widening, but a non-ANGLE driver must not lose its native probe.
|
||||
const Flags<PixelFormatNormalizeOptionBit> forcedProbeOptions =
|
||||
(outerHasForcedFallback || widenUnconditionally) ? forcedOptions | renderTargetOptions
|
||||
: forcedOptions;
|
||||
hasForcedFallback =
|
||||
BuildFallbackProbeFormatInfo(requestedInternalFormat, forcedProbeOptions, true,
|
||||
fallbackInfo);
|
||||
if (!hasForcedFallback) {
|
||||
BuildFallbackProbeFormatInfo(requestedInternalFormat,
|
||||
driverOptions | renderTargetOptions, false, fallbackInfo);
|
||||
}
|
||||
// HONEST STATUS OF THE FORCED PATH. A forced fallback is only ever built
|
||||
// for ANGLE (GetForcedPixelFormatNormalizeOptions returns nothing for any
|
||||
// other renderer), and it SKIPS the native probe entirely - the widened
|
||||
// format is asserted rather than measured on this device. That assertion
|
||||
// is validated on exactly one configuration, the android-angle retrace
|
||||
// golden; it is NOT covered by the headless llvmpipe suites, which take
|
||||
// the driver branch below and prove nothing about ANGLE's answers. So log
|
||||
// the choice at INFO rather than the usual MGLOG_D caveat: on any other
|
||||
// ANGLE device the device report is the only evidence there is of which
|
||||
// storage format the image really got. Once per format on the ordinary 2D
|
||||
// target - repeating it for all ten targets would bury the report.
|
||||
if (hasForcedFallback && target == TextureTarget::Texture2D &&
|
||||
(MG_Util::TextureFormatProcessor::GetApplicablePixelFormatNormalizeOptions(
|
||||
requestedInternalFormat, renderTargetOptions) &
|
||||
PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)) {
|
||||
MGLOG_I("Three-channel widening (FORCED path, no native probe): %s stored as %s. "
|
||||
"Reason: %s. Device-validated on the android-angle golden only.",
|
||||
MG_Util::ConvertTextureInternalFormatToString(logicalFormat).c_str(),
|
||||
ConvertFallbackInternalFormatToString(fallbackInfo.InternalFormat).c_str(),
|
||||
fallbackInfo.Reason.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,8 +652,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
const SizeT renderbufferTargetIndex = GetRenderbufferFormatCapabilityTargetIndex();
|
||||
Bool shouldProbeFallbackRenderbuffer = outerHasForcedFallback;
|
||||
if (!outerHasForcedFallback) {
|
||||
// A renderbuffer exists only to be attached, so it needs the same three-channel
|
||||
// widening the colour-attachable texture targets get - and on the same terms: the
|
||||
// native storage is probed first, so a driver that renders to it keeps it.
|
||||
const Flags<PixelFormatNormalizeOptionBit> renderbufferOptions =
|
||||
TextureImpl::GetRenderTargetNormalizeOptions(capabilities, renderbufferTargetIndex);
|
||||
GLESProbeFormatInfo renderbufferFallbackInfo = outerFallbackInfo;
|
||||
Bool renderbufferHasForcedFallback = outerHasForcedFallback;
|
||||
if (renderbufferOptions) {
|
||||
const Flags<PixelFormatNormalizeOptionBit> forcedProbeOptions =
|
||||
outerHasForcedFallback ? forcedOptions | renderbufferOptions : forcedOptions;
|
||||
renderbufferHasForcedFallback = BuildFallbackProbeFormatInfo(
|
||||
requestedInternalFormat, forcedProbeOptions, true, renderbufferFallbackInfo);
|
||||
if (!renderbufferHasForcedFallback) {
|
||||
BuildFallbackProbeFormatInfo(requestedInternalFormat, driverOptions | renderbufferOptions,
|
||||
false, renderbufferFallbackInfo);
|
||||
}
|
||||
}
|
||||
|
||||
Bool shouldProbeFallbackRenderbuffer = renderbufferHasForcedFallback;
|
||||
if (!renderbufferHasForcedFallback) {
|
||||
const Bool nativeRenderbufferComplete =
|
||||
ProbeRenderbuffer(gl, nativeInfo.InternalFormat, logicalFormat, false, 1);
|
||||
if (nativeRenderbufferComplete) {
|
||||
@@ -632,16 +685,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
shouldProbeFallbackRenderbuffer = true;
|
||||
}
|
||||
}
|
||||
if (shouldProbeFallbackRenderbuffer && outerFallbackInfo.InternalFormat != GL_UNKNOWN_MGL &&
|
||||
ProbeRenderbuffer(gl, outerFallbackInfo.InternalFormat, logicalFormat, false, 1)) {
|
||||
if (shouldProbeFallbackRenderbuffer && renderbufferFallbackInfo.InternalFormat != GL_UNKNOWN_MGL &&
|
||||
ProbeRenderbuffer(gl, renderbufferFallbackInfo.InternalFormat, logicalFormat, false, 1)) {
|
||||
if (AddCaveatFormatCaps(cache, renderbufferTargetIndex, formatIndex,
|
||||
GetRenderbufferFeatureCaps(logicalFormat))) {
|
||||
LogGLESFormatCaveat(logicalFormat, renderbufferTargetIndex, outerFallbackInfo);
|
||||
LogGLESFormatCaveat(logicalFormat, renderbufferTargetIndex, renderbufferFallbackInfo);
|
||||
}
|
||||
const Int maxSamples =
|
||||
GetGLESFormatMaxSamples(capabilities, logicalFormat, outerFallbackInfo.ImageFormat);
|
||||
cache.SampleCounts[renderbufferTargetIndex][formatIndex] =
|
||||
ProbeRenderbufferSampleCounts(gl, outerFallbackInfo.InternalFormat, logicalFormat, maxSamples);
|
||||
GetGLESFormatMaxSamples(capabilities, logicalFormat, renderbufferFallbackInfo.ImageFormat);
|
||||
cache.SampleCounts[renderbufferTargetIndex][formatIndex] = ProbeRenderbufferSampleCounts(
|
||||
gl, renderbufferFallbackInfo.InternalFormat, logicalFormat, maxSamples);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -891,6 +944,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// extension explicitly permits. It is also the only thing that
|
||||
// exposes glProgramParameteri before GL 4.1.
|
||||
E_GL_ARB_get_program_binary};
|
||||
// GL_KHR_parallel_shader_compile is MobileGL's own capability, not the host ES
|
||||
// driver's: the compiler threads are MobileGL's, and glCompileShader/glLinkProgram
|
||||
// are serviced entirely inside the frontend. Whether the device driver advertises
|
||||
// the string is irrelevant here (the POST reports it separately, for the day the
|
||||
// driver-side link is what gets parallelised).
|
||||
//
|
||||
// Gated on the async flag deliberately, and this is the whole reason the gate
|
||||
// exists. Advertising the string is the one part of asynchronous compilation that a
|
||||
// recorded trace can never cover: Iris and Sodium change their SUBMISSION SCHEDULE
|
||||
// the moment they see it - they enqueue whole pipeline batches and poll
|
||||
// GL_COMPLETION_STATUS_KHR instead of compiling one program at a time - so
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE=0 has to withdraw the application-visible behaviour
|
||||
// change as well as the threading, or the kill switch would only be half a switch.
|
||||
if (MG_Util::Async::AsyncShaderCompileEnabled()) {
|
||||
extensions.push_back(E_GL_KHR_parallel_shader_compile);
|
||||
}
|
||||
// Only advertised when the device driver actually has usable timer queries
|
||||
// (GL_EXT_disjoint_timer_query plus its entry points) and the
|
||||
// MOBILEGL_DISABLE_TIMERQUERY escape hatch is off.
|
||||
@@ -951,12 +1020,6 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
funcsTable.GL.GetIntegeri_v = GetIntegeri_v;
|
||||
funcsTable.GL.GetInteger64i_v = GetInteger64i_v;
|
||||
funcsTable.GL.GetProgramiv = GetProgramiv;
|
||||
funcsTable.GL.GetProgramInterfaceiv = GetProgramInterfaceiv;
|
||||
funcsTable.GL.GetProgramResourceIndex = GetProgramResourceIndex;
|
||||
funcsTable.GL.GetProgramResourceName = GetProgramResourceName;
|
||||
funcsTable.GL.GetProgramResourceiv = GetProgramResourceiv;
|
||||
funcsTable.GL.GetProgramResourceLocation = GetProgramResourceLocation;
|
||||
funcsTable.GL.GetProgramResourceLocationIndex = GetProgramResourceLocationIndex;
|
||||
funcsTable.GL.ShaderStorageBlockBinding = ShaderStorageBlockBinding;
|
||||
funcsTable.GL.Clear = Clear;
|
||||
funcsTable.GL.ClearBufferfi = ClearBufferfi;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,15 +92,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
void GetIntegeri_v(GLenum target, GLuint index, GLint* data);
|
||||
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
|
||||
void GetProgramiv(GLuint program, GLenum pname, GLint* params);
|
||||
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params);
|
||||
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei* length,
|
||||
GLchar* name);
|
||||
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
|
||||
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params);
|
||||
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding);
|
||||
void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding);
|
||||
Bool InitWindowSurface(NativeWindowType window);
|
||||
Bool InitPbufferSurface(EGLint width, EGLint height);
|
||||
Bool MakeCurrent();
|
||||
@@ -182,6 +174,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
|
||||
namespace XfbImpl {
|
||||
Bool AreTransformFeedbacksSupported();
|
||||
// True while a capture span is open on the current transform feedback object
|
||||
// (frontend Begin seen and not paused), whether or not the deferred driver-side
|
||||
// Begin has been issued yet. Draw paths that would restructure the primitive
|
||||
// stream, or that need to dispatch compute mid-draw, decline while it is set.
|
||||
Bool IsCaptureSpanOpen();
|
||||
void BeginTransformFeedback(GLenum primitiveMode);
|
||||
void EndTransformFeedback();
|
||||
void PauseTransformFeedback();
|
||||
@@ -191,6 +188,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
void OnBackendContextDestroyed();
|
||||
} // namespace XfbImpl
|
||||
|
||||
namespace RenderStateImpl {
|
||||
// Pushes the frontend's render-state block to the ES driver, diffed against what was
|
||||
// last pushed.
|
||||
//
|
||||
// `forColorClear` names the CALLER, and the only thing it changes is the colour write
|
||||
// mask handed to the driver. A draw into a colour attachment the backend widened from
|
||||
// three channels to four gets that buffer's alpha channel masked OFF, so nothing can
|
||||
// move the stored alpha away from the 1.0 the application's three-channel format
|
||||
// implies (see FramebufferImpl::g_alphaWidenedDrawBufferMask). A CLEAR is how that 1.0
|
||||
// gets there in the first place, so it must be allowed to write alpha - hence the flag
|
||||
// rather than an unconditional doctoring. It is part of the sync memo, so a clear
|
||||
// followed by a draw re-pushes the mask instead of early-outing on an unchanged
|
||||
// frontend version.
|
||||
//
|
||||
// The application's own colour mask is never modified: glGet(GL_COLOR_WRITEMASK)
|
||||
// answers from the frontend state, which this function only reads.
|
||||
void SyncRenderState(Bool forColorClear = false);
|
||||
void InvalidateSyncedRenderState();
|
||||
} // namespace RenderStateImpl
|
||||
|
||||
extern MG_External::EGLFunctionsTable g_EGLFuncs;
|
||||
extern MG_External::GLESFunctionsTable g_GLESFuncs;
|
||||
extern MG_External::GLESCapabilities g_GLESCapabilities;
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <mutex>
|
||||
#include <cstring>
|
||||
#include <regex>
|
||||
|
||||
@@ -114,6 +115,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return source;
|
||||
}
|
||||
|
||||
namespace {
|
||||
Bool g_processTeardown = false;
|
||||
std::once_flag g_teardownSentinelOnce;
|
||||
} // namespace
|
||||
|
||||
Bool InProcessTeardown() { return g_processTeardown; }
|
||||
void EnsureProcessTeardownSentinel() {
|
||||
std::call_once(g_teardownSentinelOnce,
|
||||
[] { std::atexit(+[] { g_processTeardown = true; }); });
|
||||
}
|
||||
|
||||
String EmulateBaseInstanceInVertexShader(String source, GLenum shaderType) {
|
||||
if (shaderType != GL_VERTEX_SHADER || source.find("gl_BaseInstance") == String::npos) {
|
||||
return source;
|
||||
@@ -1388,6 +1400,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
BackendVertexArrayObject::~BackendVertexArrayObject() {
|
||||
if (InProcessTeardown()) {
|
||||
return; // see InProcessTeardown(): the driver may be unloaded already
|
||||
}
|
||||
if (m_backendVAOId != 0) {
|
||||
NoteVAOIdDeleted(m_backendVAOId);
|
||||
g_GLESFuncs.glDeleteVertexArrays(1, &m_backendVAOId);
|
||||
@@ -1641,6 +1656,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
BackendTextureObject::~BackendTextureObject() {
|
||||
if (InProcessTeardown()) {
|
||||
return; // see InProcessTeardown(): the driver may be unloaded already
|
||||
}
|
||||
if (m_backendTextureId == 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1851,6 +1869,154 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
}
|
||||
|
||||
// Components per texel the frontend format's client data carries. Only the three-channel
|
||||
// formats that can be widened to a four-channel render target need an answer (see
|
||||
// PrepareChannelWidenedUpload); everything else keeps its own layout and reports 0.
|
||||
Uint GetWidenableClientComponentCount(TextureInternalFormat format) {
|
||||
switch (format) {
|
||||
case TextureInternalFormat::RGB8Snorm:
|
||||
case TextureInternalFormat::RGB16Snorm:
|
||||
case TextureInternalFormat::RGB16:
|
||||
case TextureInternalFormat::RGB10: // stored as RGB16 (UNorm16 shadow)
|
||||
case TextureInternalFormat::RGB12: // stored as RGB16 (UNorm16 shadow)
|
||||
case TextureInternalFormat::RGB16F:
|
||||
case TextureInternalFormat::RGB32F:
|
||||
case TextureInternalFormat::SRGB8:
|
||||
case TextureInternalFormat::RGB8I:
|
||||
case TextureInternalFormat::RGB8UI:
|
||||
case TextureInternalFormat::RGB16I:
|
||||
case TextureInternalFormat::RGB16UI:
|
||||
case TextureInternalFormat::RGB32I:
|
||||
case TextureInternalFormat::RGB32UI:
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// True when the widened format's client data is integer rather than normalized. The two
|
||||
// classes share every narrow component type - GL_RGB8I and GL_RGB8_SNORM are both uploaded
|
||||
// as GL_BYTE - but their "1.0" differs: an integer channel's one is the integer 1, a
|
||||
// normalized channel's is the saturated field. The type alone cannot tell them apart, so
|
||||
// the source format has to.
|
||||
Bool IsIntegerWidenableFormat(TextureInternalFormat format) {
|
||||
switch (format) {
|
||||
case TextureInternalFormat::RGB8I:
|
||||
case TextureInternalFormat::RGB8UI:
|
||||
case TextureInternalFormat::RGB16I:
|
||||
case TextureInternalFormat::RGB16UI:
|
||||
case TextureInternalFormat::RGB32I:
|
||||
case TextureInternalFormat::RGB32UI:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The bit pattern of 1.0 in an upload component type: what a format without alpha reads
|
||||
// back as, and therefore what the synthetic fourth channel of a widened render target has
|
||||
// to hold. Integer components carry the integer one, not a saturated field - and since
|
||||
// GL_BYTE/GL_SHORT/GL_UNSIGNED_BYTE/GL_UNSIGNED_SHORT serve both classes, `integerData`
|
||||
// is what decides, not the type.
|
||||
static Bool GetUploadComponentOneBits(GLenum uploadType, Bool integerData, Uint8* outOneBits,
|
||||
SizeT* outComponentSize) {
|
||||
switch (uploadType) {
|
||||
case GL_BYTE: {
|
||||
const Int8 one = integerData ? Int8(1) : Int8(0x7F);
|
||||
Memcpy(outOneBits, &one, sizeof(one));
|
||||
*outComponentSize = sizeof(one);
|
||||
return true;
|
||||
}
|
||||
case GL_UNSIGNED_BYTE: {
|
||||
const Uint8 one = integerData ? Uint8(1) : Uint8(0xFF);
|
||||
Memcpy(outOneBits, &one, sizeof(one));
|
||||
*outComponentSize = sizeof(one);
|
||||
return true;
|
||||
}
|
||||
case GL_SHORT: {
|
||||
const Int16 one = integerData ? Int16(1) : Int16(0x7FFF);
|
||||
Memcpy(outOneBits, &one, sizeof(one));
|
||||
*outComponentSize = sizeof(one);
|
||||
return true;
|
||||
}
|
||||
case GL_UNSIGNED_SHORT: {
|
||||
const Uint16 one = integerData ? Uint16(1) : Uint16(0xFFFF);
|
||||
Memcpy(outOneBits, &one, sizeof(one));
|
||||
*outComponentSize = sizeof(one);
|
||||
return true;
|
||||
}
|
||||
case GL_HALF_FLOAT: {
|
||||
const Uint16 one = 0x3C00; // half 1.0
|
||||
Memcpy(outOneBits, &one, sizeof(one));
|
||||
*outComponentSize = sizeof(one);
|
||||
return true;
|
||||
}
|
||||
case GL_FLOAT: {
|
||||
const Float one = 1.0f;
|
||||
Memcpy(outOneBits, &one, sizeof(one));
|
||||
*outComponentSize = sizeof(one);
|
||||
return true;
|
||||
}
|
||||
case GL_INT: {
|
||||
const Int32 one = 1;
|
||||
Memcpy(outOneBits, &one, sizeof(one));
|
||||
*outComponentSize = sizeof(one);
|
||||
return true;
|
||||
}
|
||||
case GL_UNSIGNED_INT: {
|
||||
const Uint32 one = 1;
|
||||
Memcpy(outOneBits, &one, sizeof(one));
|
||||
*outComponentSize = sizeof(one);
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// A three-channel format widened to four to keep a colour attachment renderable (see
|
||||
// NormalizePixelFormat) is described to the driver as a four-component transfer, so the
|
||||
// three-component client data has to be repacked with an alpha of 1.0 - otherwise the
|
||||
// driver walks three texels' worth of data per four-texel row and the image shears.
|
||||
// `componentCount` is the SOURCE component count and `byteSize` the source's size, so this
|
||||
// runs after any type conversion (which keeps the component count) has already happened.
|
||||
const void* PrepareChannelWidenedUpload(Uint componentCount, const IntVec3& texelSize,
|
||||
const void* data, SizeT byteSize, GLenum uploadType,
|
||||
Vector<Uint8>& widenedData, Bool integerData) {
|
||||
Uint8 oneBits[8] = {};
|
||||
SizeT componentSize = 0;
|
||||
if (componentCount != 3 || data == nullptr || byteSize == 0 ||
|
||||
!GetUploadComponentOneBits(uploadType, integerData, oneBits, &componentSize)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const SizeT srcTexelBytes = componentSize * componentCount;
|
||||
// Sized from the level, never from the source: the driver reads a full
|
||||
// width*height*depth*4 components for the transfer it was handed, so a source that
|
||||
// somehow holds fewer texels must still leave a full destination behind (its tail
|
||||
// reads as transparent black with the format's implied opaque alpha) rather than a
|
||||
// short buffer the driver would run off the end of.
|
||||
const SizeT texelCount = static_cast<SizeT>(std::max(texelSize.x(), 0)) *
|
||||
static_cast<SizeT>(std::max(texelSize.y(), 0)) *
|
||||
static_cast<SizeT>(std::max(texelSize.z(), 1));
|
||||
if (texelCount == 0) {
|
||||
return data;
|
||||
}
|
||||
const SizeT copyTexelCount = std::min(texelCount, byteSize / srcTexelBytes);
|
||||
|
||||
widenedData.assign(texelCount * componentSize * 4, 0);
|
||||
const auto* src = static_cast<const Uint8*>(data);
|
||||
Uint8* dst = widenedData.data();
|
||||
for (SizeT i = 0; i < texelCount; ++i, dst += componentSize * 4) {
|
||||
if (i < copyTexelCount) {
|
||||
Memcpy(dst, src, srcTexelBytes);
|
||||
src += srcTexelBytes;
|
||||
}
|
||||
Memcpy(dst + srcTexelBytes, oneBits, componentSize);
|
||||
}
|
||||
return widenedData.data();
|
||||
}
|
||||
|
||||
static const void* PrepareNormFloatFallbackUpload(TextureInternalFormat format,
|
||||
const IntVec3& texelSize,
|
||||
const void* data,
|
||||
@@ -1896,6 +2062,39 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return convertedData.data();
|
||||
}
|
||||
|
||||
// The two shadow -> upload conversions a fallback storage format can need, in order:
|
||||
// the component type first (SNORM/UNORM shadows into the float the fallback stores), then
|
||||
// the component count (three-channel client data into a four-channel widened render
|
||||
// target). They compose: GL_RGB8_SNORM on a driver with no renderable three-channel
|
||||
// format becomes GL_RGBA16F, so its Int8x3 shadow is converted to Float x3 and then
|
||||
// repacked as Float x4 with alpha 1.0.
|
||||
//
|
||||
// Both scratch buffers belong to the caller so they outlive the returned pointer; the
|
||||
// return value is `data` itself whenever neither conversion applies, which is what the
|
||||
// sub-rect upload fast path tests for.
|
||||
static const void* PrepareFallbackUpload(TextureInternalFormat format, TextureTarget target,
|
||||
const IntVec3& texelSize, const void* data, SizeT byteSize,
|
||||
GLenum uploadType, Vector<Float>& convertedData,
|
||||
Vector<Uint8>& widenedData) {
|
||||
const void* uploadData =
|
||||
PrepareNormFloatFallbackUpload(format, texelSize, data, byteSize, uploadType, convertedData);
|
||||
// The component-count switch first: it rules out every format that cannot be widened
|
||||
// (which is nearly all of them, including GL_RGBA8) without touching the capability
|
||||
// cache, so an ordinary atlas upload does not pay for a per-level cache lookup.
|
||||
const Uint componentCount = GetWidenableClientComponentCount(format);
|
||||
if (componentCount == 0 || !TextureImpl::BackendTextureFormatAddsAlpha(format, target)) {
|
||||
return uploadData;
|
||||
}
|
||||
// The type conversion above rewrites the level into `convertedData` at four bytes per
|
||||
// component while keeping the component count, so the widening's source size is that
|
||||
// buffer's, not the shadow's.
|
||||
const SizeT uploadByteSize = (!convertedData.empty() && uploadData == convertedData.data())
|
||||
? convertedData.size() * sizeof(Float)
|
||||
: byteSize;
|
||||
return PrepareChannelWidenedUpload(componentCount, texelSize, uploadData, uploadByteSize, uploadType,
|
||||
widenedData, IsIntegerWidenableFormat(format));
|
||||
}
|
||||
|
||||
// RGB565/RGB5_A1 shadow data is stored as 8-bit unorm; uploading it as GL_UNSIGNED_BYTE
|
||||
// leaves the 8-bit -> 5/6-bit requantization to the driver, whose rounding direction is
|
||||
// implementation-defined: Adreno rounds to nearest (lossless round trip) but Mali floors,
|
||||
@@ -2103,9 +2302,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
? textureMipmapObject->MapMipmapData(uploadTarget, level)
|
||||
: nullptr;
|
||||
Vector<Float> convertedUploadData;
|
||||
const void* uploadData = PrepareNormFloatFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType,
|
||||
convertedUploadData);
|
||||
Vector<Uint8> widenedUploadData;
|
||||
const void* uploadData = PrepareFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData,
|
||||
levelByteSize, glType, convertedUploadData, widenedUploadData);
|
||||
Vector<Uint8> packedUploadData;
|
||||
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
|
||||
uploadData, levelByteSize, &glType, packedUploadData);
|
||||
@@ -2235,9 +2435,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget);
|
||||
auto* pData = textureMipmapObject->MapMipmapData(uploadTarget, level);
|
||||
Vector<Float> convertedUploadData;
|
||||
const void* uploadData = PrepareNormFloatFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType,
|
||||
convertedUploadData);
|
||||
Vector<Uint8> widenedUploadData;
|
||||
const void* uploadData = PrepareFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData,
|
||||
levelByteSize, glType, convertedUploadData, widenedUploadData);
|
||||
Vector<Uint8> packedUploadData;
|
||||
uploadData =
|
||||
PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
|
||||
@@ -2294,9 +2495,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
? textureMipmapObject->MapMipmapData(uploadTarget, level)
|
||||
: nullptr;
|
||||
Vector<Float> convertedUploadData;
|
||||
const void* uploadData = PrepareNormFloatFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType,
|
||||
convertedUploadData);
|
||||
Vector<Uint8> widenedUploadData;
|
||||
const void* uploadData = PrepareFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData,
|
||||
levelByteSize, glType, convertedUploadData, widenedUploadData);
|
||||
Vector<Uint8> packedUploadData;
|
||||
uploadData =
|
||||
PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
|
||||
@@ -2404,9 +2606,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
auto texelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, level);
|
||||
const void* mipData = textureMipmapObject->MapMipmapData(uploadTarget, level);
|
||||
Vector<Float> convertedUploadData;
|
||||
const void* uploadData = PrepareNormFloatFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), texelSize, mipData, byteSize, glType,
|
||||
convertedUploadData);
|
||||
Vector<Uint8> widenedUploadData;
|
||||
const void* uploadData = PrepareFallbackUpload(
|
||||
textureMipmapObject->GetFormat(), targetInternal, texelSize, mipData, byteSize,
|
||||
glType, convertedUploadData, widenedUploadData);
|
||||
Vector<Uint8> packedUploadData;
|
||||
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), texelSize,
|
||||
uploadData, byteSize, &glType, packedUploadData);
|
||||
@@ -2440,10 +2643,45 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
static_cast<SizeT>(dirtyRegion.lo.z()) * levelSliceBytes +
|
||||
static_cast<SizeT>(dirtyRegion.lo.y()) * levelRowBytes +
|
||||
static_cast<SizeT>(dirtyRegion.lo.x()) * bpp;
|
||||
// Scatter refinement behind the union box: ~100 sprite
|
||||
// writes into an atlas leave a box that spans nearly the
|
||||
// whole level while the touched texels are a few percent of
|
||||
// it. The storage's bounded rect list recovers the true
|
||||
// footprint; each rect is uploaded with the same
|
||||
// ROW_LENGTH striding into the level shadow as the box
|
||||
// path. The storage only hands the list out when its
|
||||
// summed area is materially smaller than the box (0
|
||||
// otherwise), so the extra calls always move fewer bytes.
|
||||
MG_State::GLState::MipmapDirtyRegion
|
||||
dirtyRects[MG_State::GLState::MipmapStorage::kMaxDirtyRects];
|
||||
SizeT dirtyRectCount = 0;
|
||||
if (subRectEligible) {
|
||||
dirtyRectCount = textureMipmapObject->GetStorageDirtyRects(
|
||||
uploadTarget, level, dirtyRects,
|
||||
MG_State::GLState::MipmapStorage::kMaxDirtyRects);
|
||||
}
|
||||
const auto rectShadowPtr = [&](const MG_State::GLState::MipmapDirtyRegion& rect) {
|
||||
return static_cast<const Uint8*>(uploadData) +
|
||||
static_cast<SizeT>(rect.lo.z()) * levelSliceBytes +
|
||||
static_cast<SizeT>(rect.lo.y()) * levelRowBytes +
|
||||
static_cast<SizeT>(rect.lo.x()) * bpp;
|
||||
};
|
||||
switch (MapToBackendTextureTarget(stateTextureObject->GetTarget())) {
|
||||
case TextureTarget::Texture2D:
|
||||
case TextureTarget::TextureCubeMap:
|
||||
if (subRectEligible) {
|
||||
if (subRectEligible && dirtyRectCount >= 2) {
|
||||
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, texelSize.x());
|
||||
for (SizeT r = 0; r < dirtyRectCount; ++r) {
|
||||
const auto& rect = dirtyRects[r];
|
||||
g_GLESFuncs.glTexSubImage2D(
|
||||
glUploadTarget, static_cast<GLint>(level), rect.lo.x(),
|
||||
rect.lo.y(), static_cast<GLsizei>(rect.hi.x() - rect.lo.x()),
|
||||
static_cast<GLsizei>(rect.hi.y() - rect.lo.y()), glFormat,
|
||||
glType, rectShadowPtr(rect));
|
||||
}
|
||||
// The surrounding ScopedDefaultUnpackState shadow says 0.
|
||||
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
|
||||
} else if (subRectEligible) {
|
||||
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, texelSize.x());
|
||||
g_GLESFuncs.glTexSubImage2D(
|
||||
glUploadTarget, static_cast<GLint>(level), dirtyRegion.lo.x(),
|
||||
@@ -2463,7 +2701,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// ES 3.2 has GL_TEXTURE_CUBE_MAP_ARRAY natively and it stores exactly
|
||||
// like a 2D array whose depth is 6 * the cube count.
|
||||
case TextureTarget::TextureCubeMapArray:
|
||||
if (subRectEligible) {
|
||||
if (subRectEligible && dirtyRectCount >= 2) {
|
||||
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, texelSize.x());
|
||||
g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, texelSize.y());
|
||||
for (SizeT r = 0; r < dirtyRectCount; ++r) {
|
||||
const auto& rect = dirtyRects[r];
|
||||
g_GLESFuncs.glTexSubImage3D(
|
||||
glUploadTarget, static_cast<GLint>(level), rect.lo.x(),
|
||||
rect.lo.y(), rect.lo.z(),
|
||||
static_cast<GLsizei>(rect.hi.x() - rect.lo.x()),
|
||||
static_cast<GLsizei>(rect.hi.y() - rect.lo.y()),
|
||||
static_cast<GLsizei>(rect.hi.z() - rect.lo.z()), glFormat,
|
||||
glType, rectShadowPtr(rect));
|
||||
}
|
||||
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
|
||||
g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0);
|
||||
} else if (subRectEligible) {
|
||||
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, texelSize.x());
|
||||
g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, texelSize.y());
|
||||
g_GLESFuncs.glTexSubImage3D(
|
||||
@@ -2759,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
|
||||
@@ -3071,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();
|
||||
@@ -3276,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 ||
|
||||
@@ -3288,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
|
||||
@@ -3730,6 +4104,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
#ifdef TRACY_ENABLE
|
||||
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
|
||||
#endif
|
||||
if (InProcessTeardown()) {
|
||||
return; // see InProcessTeardown(): the driver may be unloaded already
|
||||
}
|
||||
if (m_backendProgramId != 0) {
|
||||
MGLOG_D("Deleting backend program object with ID: %u", m_backendProgramId);
|
||||
g_GLESFuncs.glDeleteProgram(m_backendProgramId);
|
||||
@@ -3741,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
|
||||
@@ -3775,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) {
|
||||
@@ -3915,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;
|
||||
@@ -3957,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 {
|
||||
@@ -3997,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;
|
||||
|
||||
@@ -21,9 +21,72 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
String EmulateBaseInstanceInVertexShader(String source, GLenum shaderType);
|
||||
String PromoteDrawParameterGlobalsToUniforms(String source, GLenum shaderType);
|
||||
|
||||
// True once the process has entered exit(): past that point the EGL library and
|
||||
// the driver may already be unloaded, so a backend twin's destructor must not
|
||||
// call into g_GLESFuncs (the observed crash is a jump through an unmapped driver
|
||||
// pointer from __run_exit_handlers) nor touch statics in other TUs (cross-TU
|
||||
// destruction order is unspecified). Deliberate leak: the process is exiting and
|
||||
// the driver reclaims GPU objects. The flag is set by a std::atexit handler that
|
||||
// EnsureProcessTeardownSentinel() registers lazily on first registry use - by
|
||||
// then every static everywhere has finished constructing, so this handler is
|
||||
// guaranteed to run BEFORE any static destructor (atexit is LIFO). A destructor
|
||||
// hook on the registry itself was tried first and is WRONG: tests and cache
|
||||
// resets destroy temporary registry instances mid-run, which would latch the
|
||||
// flag while the process is very much alive.
|
||||
Bool InProcessTeardown();
|
||||
void EnsureProcessTeardownSentinel();
|
||||
|
||||
// Which optional pieces of state a draw needs synchronized before it is issued.
|
||||
// Index/indirect buffer syncs and the instancing-related work are skipped for
|
||||
// draws that provably cannot read them.
|
||||
enum class DrawSyncBit : Uint32 {
|
||||
None = 0,
|
||||
IndexBuffer = 1 << 0,
|
||||
IndirectBuffer = 1 << 1,
|
||||
Instancing = 1 << 2
|
||||
};
|
||||
// Deliberately the shared Flags<> rather than hand-written operators for this enum:
|
||||
// a namespace-local operator| here would hide MobileGL::operator|(Bit, Bit) from
|
||||
// every other scoped-enum flag set used inside this namespace.
|
||||
using DrawSyncFlags = Flags<DrawSyncBit>;
|
||||
|
||||
// The GL-defined indirect command layouts, byte-identical to what the driver reads
|
||||
// out of a GL_DRAW_INDIRECT_BUFFER. Also the staging layout the multi-draw emulation
|
||||
// synthesizes commands into.
|
||||
struct DrawElementsIndirectCommand {
|
||||
Uint32 count = 0;
|
||||
Uint32 instanceCount = 0;
|
||||
Uint32 firstIndex = 0;
|
||||
Int32 baseVertex = 0;
|
||||
Uint32 baseInstance = 0;
|
||||
};
|
||||
|
||||
struct DrawArraysIndirectCommand {
|
||||
Uint32 count = 0;
|
||||
Uint32 instanceCount = 0;
|
||||
Uint32 first = 0;
|
||||
Uint32 baseInstance = 0;
|
||||
};
|
||||
|
||||
// Brings the whole draw-relevant frontend state onto the native ES context and binds
|
||||
// the program; every GL draw entry point calls it exactly once before issuing draws.
|
||||
void PrepareForDraw(DrawSyncFlags syncBits);
|
||||
// GLES core supports only GL_PRIMITIVE_RESTART_FIXED_INDEX. Throws when the app enabled
|
||||
// the arbitrary GL_PRIMITIVE_RESTART with a non-fixed index for this index type.
|
||||
void CheckPrimitiveRestartSupported(GLenum indexType);
|
||||
// Feed the current program's gl_BaseInstance / gl_DrawID emulation uniforms. Both are
|
||||
// no-ops when the program does not read the corresponding builtin.
|
||||
void SetCurrentBaseInstance(Uint32 baseInstance);
|
||||
void SetCurrentDrawID(Uint32 drawId);
|
||||
// True when the current program actually reads gl_DrawID, i.e. when a batched
|
||||
// (single driver call) multi-draw tier would have to feed it one value for the whole
|
||||
// batch and would therefore be wrong.
|
||||
Bool CurrentProgramReadsDrawID();
|
||||
|
||||
template <typename StateObject, typename BackendObject>
|
||||
class StateBackendObjectRegistry {
|
||||
public:
|
||||
|
||||
using StatePtr = SharedPtr<StateObject>;
|
||||
using StateWeakPtr = std::weak_ptr<StateObject>;
|
||||
using BackendPtr = SharedPtr<BackendObject>;
|
||||
@@ -43,6 +106,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
BackendPtr& GetOrCreate(const StatePtr& stateObj) {
|
||||
MOBILEGL_ASSERT(stateObj != nullptr, "State object must not be null");
|
||||
|
||||
// Twin creation is the moment a driver-owned id starts needing a guarded
|
||||
// destructor; cold path, so the once-guard costs nothing per draw.
|
||||
EnsureProcessTeardownSentinel();
|
||||
auto& entry = m_entries[stateObj.get()];
|
||||
if (entry.stateRef.expired()) {
|
||||
// The previous owner of this address is gone and the allocator handed it
|
||||
@@ -449,6 +515,25 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return target == TextureTarget::Texture3D || target == TextureTarget::TextureCubeMap;
|
||||
}
|
||||
|
||||
// Components per texel the frontend format's client data carries, for the three-channel
|
||||
// formats that can be widened to a four-channel colour-renderable target; 0 for everything
|
||||
// else. See PrepareChannelWidenedUpload.
|
||||
Uint GetWidenableClientComponentCount(TextureInternalFormat format);
|
||||
|
||||
// True when a widenable format's components are integer rather than normalized, which is
|
||||
// what decides the synthetic alpha's value: GL_RGB8I and GL_RGB8_SNORM are both uploaded
|
||||
// as GL_BYTE, but their 1.0 is 1 and 0x7F respectively.
|
||||
Bool IsIntegerWidenableFormat(TextureInternalFormat format);
|
||||
|
||||
// Repacks three-component client data as four components with an alpha of 1.0 in
|
||||
// `uploadType`, for a format the backend widened to keep a colour attachment renderable.
|
||||
// Returns `data` untouched when no widening applies. Pure CPU and context-free so a unit
|
||||
// test can exercise the exact packing the driver is handed; `widenedData` is the caller's
|
||||
// scratch buffer and has to outlive the returned pointer.
|
||||
const void* PrepareChannelWidenedUpload(Uint componentCount, const IntVec3& texelSize, const void* data,
|
||||
SizeT byteSize, GLenum uploadType, Vector<Uint8>& widenedData,
|
||||
Bool integerData = false);
|
||||
|
||||
struct StateTextureBasicInfo { // Used for tracking texture state changes
|
||||
TextureInternalFormat internalFormat = TextureInternalFormat::Unknown;
|
||||
SizeT width = 0;
|
||||
@@ -564,6 +649,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
SharedPtr<BackendTextureObject>& SyncTextureObjectToBackend(
|
||||
const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
|
||||
Bool imageBindableStorageRequired = false);
|
||||
// Brings every texture the next draw reads - the touched units' bindings and the draw
|
||||
// FBO's texture attachments - onto the backend, through the two borrowed-pair memos
|
||||
// documented at their definitions. Declared here so tests can drive those memos directly.
|
||||
void SyncNeccessaryTextures();
|
||||
extern Array<Array<BackendTextureObject*, (SizeT)TextureTarget::TextureTargetCount>,
|
||||
MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
|
||||
g_boundTexturesCache;
|
||||
@@ -644,6 +733,67 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// has to apply the clamp itself.
|
||||
Bool IsFixedPointFallbackReadAttachment();
|
||||
|
||||
// True when the read buffer names a three-channel attachment the backend actually stores
|
||||
// in a four-channel format (the colour-renderable widening). A format without alpha reads
|
||||
// back as 1.0, so the readback path has to overwrite the alpha the draw left behind -
|
||||
// unconditionally, since this is the format's own semantics rather than the
|
||||
// GL_CLAMP_READ_COLOR rule the clamp above implements.
|
||||
Bool IsAlphaWidenedFallbackReadAttachment();
|
||||
|
||||
// True when this attachment's storage carries an alpha channel its frontend format does
|
||||
// not (the three-channel colour-renderable widening).
|
||||
Bool IsAlphaWidenedColorAttachment(const MG_State::GLState::FramebufferAttachmentObject& attachmentObject);
|
||||
|
||||
// Bit i set = DRAW BUFFER i of `fbo` resolves to a colour attachment the backend widened
|
||||
// from three channels to four. Indexed by draw-buffer slot, not by attachment point,
|
||||
// because that is what glColorMaski / glClearBufferfv address.
|
||||
Uint32 ComputeAlphaWidenedDrawBufferMask(const MG_State::GLState::FramebufferObject& fbo);
|
||||
|
||||
// The same mask for whatever is currently bound to GL_DRAW_FRAMEBUFFER, recomputed by
|
||||
// SyncCurrentFBO (BackendFramebufferObject::SyncToBackend for the DRAW target, and reset
|
||||
// to 0 on the default framebuffer). Read by the draw/clear state sync, so it is only
|
||||
// trustworthy after SyncCurrentFBO has run in the same entry point.
|
||||
//
|
||||
// WHY IT EXISTS (the dst-alpha discipline). A widened attachment has a real alpha channel
|
||||
// the application's format does not, and GL says a missing channel reads as 1.0. Readback
|
||||
// can paper over that (ForceWideReadAlphaToOne), but GL_DST_ALPHA /
|
||||
// GL_ONE_MINUS_DST_ALPHA blending and glBlitFramebuffer read the STORED alpha inside the
|
||||
// driver where no interception is possible. So the stored alpha is kept at 1.0 instead:
|
||||
// a clear touching a widened buffer writes alpha 1.0, and every draw into it has its
|
||||
// alpha write mask forced off, so nothing can ever move it again. The application's own
|
||||
// colour mask is untouched - glGet(GL_COLOR_WRITEMASK) still reports what it set.
|
||||
extern Uint32 g_alphaWidenedDrawBufferMask;
|
||||
|
||||
// Bit i set = DRAW BUFFER i of the framebuffer bound as DRAW resolves to a colour
|
||||
// attachment with an INTEGER format. Recomputed beside the mask above and for its sake:
|
||||
// glClearBufferfv on an integer colour buffer is GL_INVALID_OPERATION, so the
|
||||
// per-draw-buffer clear route the widening needs has to stand down when one is present.
|
||||
// (glClear on an integer colour buffer is left undefined by ES in the first place, and
|
||||
// an application that wants a defined answer has to call glClearBufferuiv/iv - which does
|
||||
// carry the widened alpha substitution.)
|
||||
extern Uint32 g_integerColorDrawBufferMask;
|
||||
|
||||
// The colour a clear has to hand the driver for one draw buffer: the application's value,
|
||||
// except that a widened attachment's alpha is replaced by the 1.0 its three-channel
|
||||
// format implies. `one` is 1.0 encoded in the clear call's own component type - the
|
||||
// integer clears carry the integer 1, the float clear carries 1.0f.
|
||||
//
|
||||
// Returns `value` itself when nothing is substituted, so the ordinary path allocates and
|
||||
// copies nothing; `scratch` is the caller's buffer and has to outlive the returned
|
||||
// pointer. Free of GL state on purpose, so the substitution can be unit-tested exactly as
|
||||
// the driver sees it.
|
||||
template <typename T>
|
||||
const T* SubstituteWidenedClearAlpha(const T* value, Bool widened, T one, T (&scratch)[4]) {
|
||||
if (!widened || value == nullptr) {
|
||||
return value;
|
||||
}
|
||||
scratch[0] = value[0];
|
||||
scratch[1] = value[1];
|
||||
scratch[2] = value[2];
|
||||
scratch[3] = one;
|
||||
return scratch;
|
||||
}
|
||||
|
||||
// What SyncCurrentFBO last pushed for each target, as a (binding, object, revision)
|
||||
// triple; it re-syncs unless all three still match. Stamped by SyncCurrentFBO and
|
||||
// ForceBindCurrentFBO, cleared by InvalidateFramebufferBindingCache. The three are
|
||||
@@ -847,6 +997,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
void SetBaseInstance(Uint32 baseInstance) const;
|
||||
void SetBaseInstanceWordIndex(Int32 wordIndex) const;
|
||||
void SetDrawID(Uint32 drawId) const;
|
||||
// True when the transpiled program kept a gl_DrawID uniform, i.e. SetDrawID
|
||||
// actually reaches a shader read rather than being discarded.
|
||||
Bool ReadsDrawID() const { return m_drawIdUniformLocation >= 0; }
|
||||
Int GetIndirectParamsBinding() const { return m_indirectParamsBinding; }
|
||||
Uint GetBackendProgramId() const { return m_backendProgramId; }
|
||||
// False when the last SyncToBackend could not produce a usable program (a
|
||||
@@ -912,6 +1065,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
extern Uint g_lastUsedBackendProgramId;
|
||||
extern StateBackendObjectRegistry<MG_State::GLState::ProgramObject, BackendProgramObjectImpl>
|
||||
g_backendProgramObjects;
|
||||
|
||||
// Points one shader storage block of an ALREADY-LINKED backend program at
|
||||
// `binding`. `blockName` is the frontend interface-query spelling; the real
|
||||
// driver's own index for it is looked up here, because the transpiled ESSL's
|
||||
// block order is not the frontend's. Returns false when the block does not exist
|
||||
// on the backend program (eliminated as unused, or the driver lacks the entry
|
||||
// points), which is not an error - GL_BUFFER_BINDING is served from the frontend
|
||||
// record either way.
|
||||
Bool ApplyShaderStorageBlockBinding(Uint backendProgramId, const String& blockName, Uint binding);
|
||||
// Replays every glShaderStorageBlockBinding recorded on the program onto a backend
|
||||
// program that was just built. The frontend record is authoritative (only the
|
||||
// shader's DECLARED binding survives in the SPIR-V), so without this replay any
|
||||
// rebuild would silently revert rebound blocks. Mirrors DirectVulkan's
|
||||
// reseed-on-rebuild in BuildProgramResourceCache.
|
||||
void ReseedShaderStorageBlockBindings(Uint backendProgramId,
|
||||
const MG_State::GLState::ProgramObject& stateProgramObject);
|
||||
} // namespace PrgramImpl
|
||||
|
||||
namespace SamplerImpl {
|
||||
|
||||
@@ -0,0 +1,894 @@
|
||||
// MobileGL - MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#include "MultiDraw.h"
|
||||
#include "Managers.h"
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
|
||||
namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
|
||||
using MG_Config::GLESMultiDrawMode;
|
||||
|
||||
namespace {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
SizeT IndexTypeSize(GLenum type) {
|
||||
switch (type) {
|
||||
case GL_UNSIGNED_BYTE: return 1;
|
||||
case GL_UNSIGNED_SHORT: return 2;
|
||||
case GL_UNSIGNED_INT: return 4;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The all-ones value of an index type, which is what GL restarts on once
|
||||
// primitive restart is in play. CheckPrimitiveRestartSupported has already
|
||||
// rejected the arbitrary-index form of GL_PRIMITIVE_RESTART, so an enabled
|
||||
// restart always restarts here and nowhere else.
|
||||
Uint32 RestartSentinelFor(GLenum type) {
|
||||
switch (type) {
|
||||
case GL_UNSIGNED_BYTE: return 0xFFu;
|
||||
case GL_UNSIGNED_SHORT: return 0xFFFFu;
|
||||
default: return 0xFFFFFFFFu;
|
||||
}
|
||||
}
|
||||
|
||||
Bool RestartActive() {
|
||||
return MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) ||
|
||||
MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex);
|
||||
}
|
||||
|
||||
// Vertices per primitive for the modes whose sub-draws may be concatenated into a
|
||||
// single draw without changing the primitive stream. Zero for strip/loop/fan modes
|
||||
// (concatenation would weld one sub-draw's last primitive to the next sub-draw's
|
||||
// first) and for GL_PATCHES, whose primitive size is dynamic tessellation state.
|
||||
Uint32 ConcatenablePrimitiveSize(GLenum mode) {
|
||||
switch (mode) {
|
||||
case GL_POINTS: return 1;
|
||||
case GL_LINES: return 2;
|
||||
case GL_TRIANGLES: return 3;
|
||||
case GL_LINES_ADJACENCY: return 4;
|
||||
case GL_TRIANGLES_ADJACENCY: return 6;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Beyond this an emulated batch would ask for a scratch allocation measured in
|
||||
// hundreds of megabytes (and the scratch ring never shrinks again); decline and let
|
||||
// a per-sub-draw tier handle it instead of trying and failing inside the driver.
|
||||
constexpr SizeT kMaxFlattenedIndices = SizeT{1} << 24;
|
||||
|
||||
// The flattening dispatch is one invocation per output index. ES 3.1 only
|
||||
// guarantees 65535 work groups per dimension, and exceeding it makes
|
||||
// glDispatchCompute an INVALID_VALUE no-op - which would leave the draw reading an
|
||||
// uninitialised index buffer rather than failing visibly. Cap the tier there
|
||||
// instead of querying: 4.19M indices is far past any real multi-draw batch, and
|
||||
// beyond it the per-sub-draw tiers are the better answer anyway.
|
||||
constexpr SizeT kComputeWorkGroupSize = 64;
|
||||
constexpr SizeT kMaxComputeWorkGroups = 65535;
|
||||
constexpr SizeT kMaxComputeFlattenedIndices = kMaxComputeWorkGroups * kComputeWorkGroupSize;
|
||||
|
||||
Uint BoundDrawIndirectBufferId() {
|
||||
const auto& indirect =
|
||||
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();
|
||||
if (!indirect) return 0;
|
||||
const auto* resource = BufferImpl::EnsureBufferResource(indirect);
|
||||
return resource ? resource->id : 0;
|
||||
}
|
||||
|
||||
const SharedPtr<MG_State::GLState::BufferObject>& BoundIndexBuffer() {
|
||||
static const SharedPtr<MG_State::GLState::BufferObject> none;
|
||||
const auto& vao = MG_State::pGLContext->GetBoundVertexArray();
|
||||
if (!vao) return none;
|
||||
return vao->GetIndexBufferBindingSlot().GetBoundObject();
|
||||
}
|
||||
|
||||
// The GL name PrepareForDraw left on GL_ELEMENT_ARRAY_BUFFER, i.e. what a tier
|
||||
// that swaps in a scratch index buffer has to put back. Restoring the exact name
|
||||
// matters beyond tidiness: the VAO twin memoises that it already synced this
|
||||
// index binding and will not re-issue it on the next draw.
|
||||
Uint BoundIndexBufferId() {
|
||||
const auto& ibo = BoundIndexBuffer();
|
||||
if (!ibo) return 0;
|
||||
const auto* resource = BufferImpl::EnsureBufferResource(ibo);
|
||||
return resource ? resource->id : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scratch GL objects
|
||||
//
|
||||
// All of them belong to the ES context and are abandoned (not deleted) when it
|
||||
// dies, exactly like XfbImpl's scatter buffer: the names are the dead context's
|
||||
// to reclaim, and deleting them would target whatever the successor context
|
||||
// handed out for the same name.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ScratchBuffer {
|
||||
Uint id = 0;
|
||||
SizeT capacity = 0;
|
||||
SizeT cursor = 0; // ring buffers only: next free byte
|
||||
};
|
||||
|
||||
ScratchBuffer g_indirectCommands; // synthesized DrawElementsIndirectCommand array
|
||||
ScratchBuffer g_rebasedIndices; // CPU-rebased index stream
|
||||
ScratchBuffer g_drawInfo; // compute tier: per-sub-draw descriptors
|
||||
ScratchBuffer g_flattenedIndices; // compute tier: flattened index stream
|
||||
|
||||
Uint g_computeProgram = 0;
|
||||
Bool g_computeProgramFailed = false;
|
||||
GLint g_uElementSize = -1;
|
||||
GLint g_uDrawCount = -1;
|
||||
GLint g_uTotalIndices = -1;
|
||||
|
||||
// Reused staging, so a steady stream of batches allocates nothing.
|
||||
Vector<DrawElementsIndirectCommand> g_commandStaging;
|
||||
Vector<Uint32> g_indexStaging;
|
||||
Vector<Uint32> g_drawInfoStaging;
|
||||
Vector<GLint> g_zeroBaseVertices;
|
||||
|
||||
// Everything below stages through GL_ARRAY_BUFFER, the manager-wide staging target
|
||||
// (BufferImpl::TempBufferTarget); binding it disturbs no VAO state.
|
||||
Bool EnsureScratchName(ScratchBuffer& buffer) {
|
||||
if (buffer.id != 0) return true;
|
||||
GLuint id = 0;
|
||||
g_GLESFuncs.glGenBuffers(1, &id);
|
||||
if (id == 0) return false;
|
||||
buffer.id = id;
|
||||
buffer.capacity = 0;
|
||||
buffer.cursor = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Whole-buffer upload, for the two buffers that are read from offset 0 because they
|
||||
// are bound as storage blocks. Respecifies rather than sub-updates: glBufferData
|
||||
// orphans the previous store, so the upload never waits on a dispatch still reading
|
||||
// the old contents out of the same name.
|
||||
Bool UploadScratch(ScratchBuffer& buffer, SizeT bytes, const void* data) {
|
||||
if (bytes == 0) return true;
|
||||
if (!EnsureScratchName(buffer)) return false;
|
||||
BufferImpl::BindBufferId(BufferImpl::TempBufferTarget, buffer.id);
|
||||
// Grow in powers of two so a batch that creeps up in size stops respecifying.
|
||||
SizeT capacity = buffer.capacity == 0 ? bytes : buffer.capacity;
|
||||
while (capacity < bytes) capacity *= 2;
|
||||
g_GLESFuncs.glBufferData(BufferImpl::TempBufferTarget, static_cast<GLsizeiptr>(capacity), nullptr,
|
||||
GL_STREAM_DRAW);
|
||||
buffer.capacity = capacity;
|
||||
buffer.cursor = 0;
|
||||
if (data) {
|
||||
g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, 0, static_cast<GLsizeiptr>(bytes), data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ring upload, for the buffers whose consumers can address a byte offset (indirect
|
||||
// commands and rewritten index streams). Respecifying per batch is what an
|
||||
// orphan-every-time scheme costs, and on a desktop-class driver that allocation
|
||||
// dominated the tiers that use these buffers - a multi-draw of 32 sub-draws stages
|
||||
// 640 bytes and paid for a fresh store to hold them. Bump-allocating instead means
|
||||
// one respecify per wrap; every byte between two wraps is written exactly once, so
|
||||
// nothing in flight is overwritten, and the wrap itself orphans.
|
||||
constexpr SizeT kRingAlignment = 16; // >= 4, so both command and uint32-index offsets stay legal
|
||||
constexpr SizeT kMinRingBytes = 1u << 16;
|
||||
|
||||
Bool UploadScratchRing(ScratchBuffer& buffer, SizeT bytes, const void* data, SizeT& outOffset) {
|
||||
outOffset = 0;
|
||||
if (bytes == 0) return true;
|
||||
if (!EnsureScratchName(buffer)) return false;
|
||||
BufferImpl::BindBufferId(BufferImpl::TempBufferTarget, buffer.id);
|
||||
|
||||
const SizeT aligned = (bytes + kRingAlignment - 1) & ~(kRingAlignment - 1);
|
||||
if (buffer.capacity < aligned) {
|
||||
SizeT capacity = buffer.capacity == 0 ? kMinRingBytes : buffer.capacity;
|
||||
while (capacity < aligned) capacity *= 2;
|
||||
g_GLESFuncs.glBufferData(BufferImpl::TempBufferTarget, static_cast<GLsizeiptr>(capacity), nullptr,
|
||||
GL_STREAM_DRAW);
|
||||
buffer.capacity = capacity;
|
||||
buffer.cursor = 0;
|
||||
} else if (buffer.cursor + aligned > buffer.capacity) {
|
||||
g_GLESFuncs.glBufferData(BufferImpl::TempBufferTarget, static_cast<GLsizeiptr>(buffer.capacity),
|
||||
nullptr, GL_STREAM_DRAW);
|
||||
buffer.cursor = 0;
|
||||
}
|
||||
|
||||
outOffset = buffer.cursor;
|
||||
if (data) {
|
||||
g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, static_cast<GLintptr>(outOffset),
|
||||
static_cast<GLsizeiptr>(bytes), data);
|
||||
}
|
||||
buffer.cursor += aligned;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Best-first, and measured rather than assumed. MobileGlues orders its own Auto
|
||||
// multiindirect -> indirect -> basevertex; on both ES drivers available here that
|
||||
// is backwards, because staging a command buffer per batch costs more than the
|
||||
// driver entries it saves. mc_sodium_multidraw (132 batches x 32 sub-draws),
|
||||
// ns/op, median of three:
|
||||
//
|
||||
// NVIDIA ES 3.2 Mesa llvmpipe ES 3.2
|
||||
// ext n/a 19300
|
||||
// basevertex 2500 25200
|
||||
// multiindirect 5700 27600
|
||||
// drawelements 5600 28700
|
||||
// indirect 5800 31000
|
||||
//
|
||||
// Ring-allocating the command staging (instead of respecifying per batch) was
|
||||
// tried first and moved the indirect tiers by less than noise, so the cost is the
|
||||
// indirect draw path itself, not the upload. Only "ext" - a real multi-draw entry
|
||||
// point rather than an indirect one - actually beats replaying the sub-draws.
|
||||
//
|
||||
// The compute tier is deliberately absent from the ladder: it rewrites the
|
||||
// primitive stream rather than replaying it, and it measured slowest of all here,
|
||||
// so it stays opt-in behind the env knob (the same call MobileGlues makes - its
|
||||
// Auto never selects Compute either).
|
||||
constexpr GLESMultiDrawMode kAutoLadder[] = {
|
||||
GLESMultiDrawMode::Ext, GLESMultiDrawMode::BaseVertex, GLESMultiDrawMode::MultiIndirect,
|
||||
GLESMultiDrawMode::Indirect, GLESMultiDrawMode::DrawElements,
|
||||
};
|
||||
|
||||
Bool SupportsTier(GLESMultiDrawMode tier) {
|
||||
return IsTierSupported(g_GLESCapabilities, g_GLESFuncs, tier);
|
||||
}
|
||||
|
||||
GLESMultiDrawMode g_resolvedTier = GLESMultiDrawMode::Auto;
|
||||
Bool g_tierResolved = false;
|
||||
String g_tierResolution;
|
||||
|
||||
void ResolveTierOnce() {
|
||||
if (g_tierResolved) return;
|
||||
g_tierResolved = true;
|
||||
g_resolvedTier =
|
||||
ResolveTier(g_GLESCapabilities, g_GLESFuncs, MG_Config::Features.EsprytMultiDrawMode,
|
||||
&g_tierResolution);
|
||||
MGLOG_I("DirectGLES multi-draw: %s", g_tierResolution.c_str());
|
||||
}
|
||||
|
||||
// Which tiers have already announced themselves, one bit per GLESMultiDrawMode.
|
||||
// The resolution line above says which tier was CHOSEN; this says which one a
|
||||
// batch actually went through, and the two differ whenever a batch's shape
|
||||
// demotes it. Worth a line each: a multi-draw path that resolves to a tier and
|
||||
// then quietly runs a different one is exactly how "the batch drew nothing"
|
||||
// hides.
|
||||
Uint32 g_announcedTiers = 0;
|
||||
|
||||
void NoteTierExecuted(GLESMultiDrawMode tier) {
|
||||
const Uint32 bit = 1u << static_cast<Uint32>(tier);
|
||||
if (g_announcedTiers & bit) return;
|
||||
g_announcedTiers |= bit;
|
||||
MGLOG_I("DirectGLES multi-draw: first batch executed via tier \"%s\"", TierName(tier));
|
||||
}
|
||||
|
||||
// The tier this particular batch can actually take. A tier is demoted here when
|
||||
// the batch's own shape - not the driver - rules it out; the compute tier keeps
|
||||
// its remaining feasibility checks inside its implementation, where the data it
|
||||
// has to walk is already in hand.
|
||||
GLESMultiDrawMode ResolveTierForBatch(Bool programReadsDrawID, Bool hasIndexBuffer) {
|
||||
ResolveTierOnce();
|
||||
GLESMultiDrawMode tier = g_resolvedTier;
|
||||
|
||||
// Batched tiers issue one driver entry for the whole batch, so the emulated
|
||||
// gl_DrawID uniform can only hold one value across every sub-draw. A program
|
||||
// that reads gl_DrawID gets an unrolled tier, which feeds each sub-draw its
|
||||
// own index (the spec's value); nothing else observes the difference.
|
||||
const Bool batched = tier == GLESMultiDrawMode::Ext || tier == GLESMultiDrawMode::MultiIndirect ||
|
||||
tier == GLESMultiDrawMode::Compute;
|
||||
if (batched && programReadsDrawID) {
|
||||
tier = SupportsTier(GLESMultiDrawMode::BaseVertex) ? GLESMultiDrawMode::BaseVertex
|
||||
: GLESMultiDrawMode::DrawElements;
|
||||
}
|
||||
|
||||
// The indirect tiers describe each sub-draw as an element offset into the
|
||||
// bound element array buffer. A client-memory index array has no such buffer,
|
||||
// and indirect draws are not defined without one.
|
||||
if (!hasIndexBuffer &&
|
||||
(tier == GLESMultiDrawMode::MultiIndirect || tier == GLESMultiDrawMode::Indirect)) {
|
||||
tier = SupportsTier(GLESMultiDrawMode::BaseVertex) ? GLESMultiDrawMode::BaseVertex
|
||||
: GLESMultiDrawMode::DrawElements;
|
||||
}
|
||||
return tier;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index rewriting, shared by the two tiers that fold base vertices into indices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Both of those tiers emit GL_UNSIGNED_INT regardless of the source type. Keeping
|
||||
// the source width would be wrong, not merely tight: GL adds baseVertex to the
|
||||
// index at full precision, so a GL_UNSIGNED_SHORT index plus a base vertex past
|
||||
// 65535 addresses a vertex the source type cannot spell. Widening also gives the
|
||||
// rewritten stream a restart sentinel (0xFFFFFFFF) that survives the rebase.
|
||||
void RebaseIndices(const Uint8* source, SizeT sourceIndexCount, SizeT indexSize, Int32 baseVertex,
|
||||
Bool restartActive, Uint32 restartSentinel, Uint32* out) {
|
||||
const Uint32 baseVertexBits = static_cast<Uint32>(baseVertex);
|
||||
for (SizeT i = 0; i < sourceIndexCount; ++i) {
|
||||
Uint32 value = 0;
|
||||
switch (indexSize) {
|
||||
case 1: value = source[i]; break;
|
||||
case 2: {
|
||||
Uint16 narrow = 0;
|
||||
std::memcpy(&narrow, source + i * 2, sizeof(narrow));
|
||||
value = narrow;
|
||||
break;
|
||||
}
|
||||
default: std::memcpy(&value, source + i * 4, sizeof(value)); break;
|
||||
}
|
||||
// Unsigned wraparound is the defined behaviour for a negative base vertex.
|
||||
out[i] = (restartActive && value == restartSentinel) ? 0xFFFFFFFFu : value + baseVertexBits;
|
||||
}
|
||||
}
|
||||
|
||||
// CPU-readable bytes of one sub-draw's indices, from the frontend shadow of the
|
||||
// bound index buffer or straight from the client array. Null when the sub-draw
|
||||
// would read outside the buffer.
|
||||
const Uint8* ResolveSubDrawIndices(const SharedPtr<MG_State::GLState::BufferObject>& indexBuffer,
|
||||
const Uint8* indexBufferBytes, SizeT indexBufferSize, const void* indices,
|
||||
SizeT indexCount, SizeT indexSize) {
|
||||
if (!indexBuffer) {
|
||||
return static_cast<const Uint8*>(indices);
|
||||
}
|
||||
if (!indexBufferBytes) return nullptr;
|
||||
const SizeT byteOffset = reinterpret_cast<SizeT>(indices);
|
||||
const SizeT byteEnd = byteOffset + indexCount * indexSize;
|
||||
if (byteEnd > indexBufferSize || byteEnd < byteOffset) return nullptr;
|
||||
return indexBufferBytes + byteOffset;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier: Ext - one glMultiDrawElementsBaseVertexEXT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Bool RunExt(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices, GLsizei drawcount,
|
||||
const GLint* basevertex) {
|
||||
if (!SupportsTier(GLESMultiDrawMode::Ext)) return false;
|
||||
const GLint* baseVertices = basevertex;
|
||||
if (!baseVertices) {
|
||||
// glMultiDrawElements: every base vertex is 0, but the entry point still
|
||||
// wants an array. One permanently-zero vector serves every such batch.
|
||||
if (g_zeroBaseVertices.size() < static_cast<SizeT>(drawcount)) {
|
||||
g_zeroBaseVertices.resize(static_cast<SizeT>(drawcount), 0);
|
||||
}
|
||||
baseVertices = g_zeroBaseVertices.data();
|
||||
}
|
||||
g_GLESFuncs.glMultiDrawElementsBaseVertexEXT(mode, count, type, indices, drawcount, baseVertices);
|
||||
NoteTierExecuted(GLESMultiDrawMode::Ext);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiers: MultiIndirect / Indirect - synthesized indirect commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Bool RunIndirect(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
|
||||
GLsizei drawcount, const GLint* basevertex, Bool batched, Bool feedDrawID) {
|
||||
if (!SupportsTier(batched ? GLESMultiDrawMode::MultiIndirect : GLESMultiDrawMode::Indirect)) return false;
|
||||
const SizeT indexSize = IndexTypeSize(type);
|
||||
if (indexSize == 0) return false;
|
||||
// Indirect commands address indices as an element offset into the bound element
|
||||
// array buffer, and an indirect draw is not defined without one.
|
||||
const auto& indexBuffer = BoundIndexBuffer();
|
||||
if (!indexBuffer) return false;
|
||||
|
||||
g_commandStaging.resize(static_cast<SizeT>(drawcount));
|
||||
for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
const SizeT byteOffset = reinterpret_cast<SizeT>(indices[i]);
|
||||
// firstIndex counts elements, so an offset that is not a whole number of
|
||||
// them cannot be expressed as a command at all.
|
||||
if (byteOffset % indexSize != 0) return false;
|
||||
auto& command = g_commandStaging[static_cast<SizeT>(i)];
|
||||
command.count = count[i] > 0 ? static_cast<Uint32>(count[i]) : 0u;
|
||||
command.instanceCount = 1;
|
||||
command.firstIndex = static_cast<Uint32>(byteOffset / indexSize);
|
||||
command.baseVertex = basevertex ? basevertex[i] : 0;
|
||||
command.baseInstance = 0;
|
||||
}
|
||||
|
||||
const SizeT commandBytes = g_commandStaging.size() * sizeof(DrawElementsIndirectCommand);
|
||||
SizeT commandBase = 0;
|
||||
if (!UploadScratchRing(g_indirectCommands, commandBytes, g_commandStaging.data(), commandBase)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Every synthesized command carries baseInstance 0. Say so through the direct
|
||||
// path, which also clears the indirect-params word index a preceding real
|
||||
// indirect draw may have left pointing into its own command buffer.
|
||||
SetCurrentBaseInstance(0);
|
||||
|
||||
const Uint previousIndirectBinding = BoundDrawIndirectBufferId();
|
||||
BufferImpl::BindBufferId(GL_DRAW_INDIRECT_BUFFER, g_indirectCommands.id);
|
||||
if (batched) {
|
||||
g_GLESFuncs.glMultiDrawElementsIndirectEXT(mode, type, reinterpret_cast<const void*>(commandBase),
|
||||
drawcount, 0);
|
||||
} else {
|
||||
for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
|
||||
const SizeT commandOffset = commandBase + static_cast<SizeT>(i) * sizeof(DrawElementsIndirectCommand);
|
||||
g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast<const void*>(commandOffset));
|
||||
}
|
||||
if (feedDrawID) SetCurrentDrawID(0);
|
||||
}
|
||||
BufferImpl::BindBufferId(GL_DRAW_INDIRECT_BUFFER, previousIndirectBinding);
|
||||
NoteTierExecuted(batched ? GLESMultiDrawMode::MultiIndirect : GLESMultiDrawMode::Indirect);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier: BaseVertex - the per-sub-draw replay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Bool RunBaseVertexLoop(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
|
||||
GLsizei drawcount, const GLint* basevertex, Bool feedDrawID) {
|
||||
if (!SupportsTier(GLESMultiDrawMode::BaseVertex)) return false;
|
||||
for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
if (count[i] <= 0) continue;
|
||||
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
|
||||
g_GLESFuncs.glDrawElementsBaseVertex(mode, count[i], type, indices[i],
|
||||
basevertex ? basevertex[i] : 0);
|
||||
}
|
||||
if (feedDrawID) SetCurrentDrawID(0);
|
||||
NoteTierExecuted(GLESMultiDrawMode::BaseVertex);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier: DrawElements - base vertices folded into a scratch index stream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Bool RunRebasedDrawElements(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
|
||||
GLsizei drawcount, const GLint* basevertex, Bool feedDrawID) {
|
||||
const SizeT indexSize = IndexTypeSize(type);
|
||||
if (indexSize == 0) return false;
|
||||
|
||||
SizeT total = 0;
|
||||
for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
if (count[i] > 0) total += static_cast<SizeT>(count[i]);
|
||||
}
|
||||
if (total == 0) return true;
|
||||
if (total > kMaxFlattenedIndices) return false;
|
||||
|
||||
const auto& indexBuffer = BoundIndexBuffer();
|
||||
const Uint8* indexBufferBytes = nullptr;
|
||||
SizeT indexBufferSize = 0;
|
||||
if (indexBuffer) {
|
||||
// The shadow is the source of truth for CPU reads, but a persistent map or
|
||||
// a shader write may have moved past it since the last sync.
|
||||
indexBuffer->SyncPersistentMappedRange();
|
||||
indexBuffer->SyncGpuWrites();
|
||||
indexBufferBytes = indexBuffer->MappedData();
|
||||
indexBufferSize = indexBuffer->GetSize();
|
||||
}
|
||||
|
||||
const Bool restartActive = RestartActive();
|
||||
const Uint32 restartSentinel = RestartSentinelFor(type);
|
||||
g_indexStaging.resize(total);
|
||||
SizeT cursor = 0;
|
||||
for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
if (count[i] <= 0) continue;
|
||||
const SizeT subDrawCount = static_cast<SizeT>(count[i]);
|
||||
const Uint8* source = ResolveSubDrawIndices(indexBuffer, indexBufferBytes, indexBufferSize, indices[i],
|
||||
subDrawCount, indexSize);
|
||||
if (!source) {
|
||||
MGLOG_E("DirectGLES multi-draw (drawelements tier): sub-draw %d reads outside the bound index "
|
||||
"buffer; skipping the batch",
|
||||
i);
|
||||
return false;
|
||||
}
|
||||
RebaseIndices(source, subDrawCount, indexSize, basevertex ? basevertex[i] : 0, restartActive,
|
||||
restartSentinel, g_indexStaging.data() + cursor);
|
||||
cursor += subDrawCount;
|
||||
}
|
||||
|
||||
SizeT indexBase = 0;
|
||||
if (!UploadScratchRing(g_rebasedIndices, total * sizeof(Uint32), g_indexStaging.data(), indexBase)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const Uint previousIndexBinding = BoundIndexBufferId();
|
||||
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, g_rebasedIndices.id);
|
||||
cursor = 0;
|
||||
for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
if (count[i] <= 0) continue;
|
||||
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
|
||||
g_GLESFuncs.glDrawElements(mode, count[i], GL_UNSIGNED_INT,
|
||||
reinterpret_cast<const void*>(indexBase + cursor * sizeof(Uint32)));
|
||||
cursor += static_cast<SizeT>(count[i]);
|
||||
}
|
||||
if (feedDrawID) SetCurrentDrawID(0);
|
||||
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, previousIndexBinding);
|
||||
NoteTierExecuted(GLESMultiDrawMode::DrawElements);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier: Compute - the whole batch flattened into one rebased index stream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// One index per invocation. The sub-draw an output slot belongs to is found by
|
||||
// binary search over the inclusive prefix sums of the sub-draw counts, which is
|
||||
// why the descriptors are sorted by construction. Sub-draws with a zero count
|
||||
// repeat the previous prefix sum and are therefore skipped by the search.
|
||||
//
|
||||
// Three storage blocks, not the five the shape suggests: ES 3.1 only guarantees
|
||||
// four per compute stage, so the per-sub-draw descriptors share one buffer.
|
||||
constexpr const char* kFlattenComputeSource = R"(#version 310 es
|
||||
layout(local_size_x = 64) in;
|
||||
|
||||
uniform uint uElementSize;
|
||||
uniform uint uDrawCount;
|
||||
uniform uint uTotalIndices;
|
||||
|
||||
layout(std430, binding = 0) readonly buffer SourceIndices { uint sourceWords[]; };
|
||||
layout(std430, binding = 1) readonly buffer DrawInfo { uint drawInfo[]; };
|
||||
layout(std430, binding = 2) writeonly buffer FlatIndices { uint flatIndices[]; };
|
||||
|
||||
uint ReadSourceIndex(uint element) {
|
||||
if (uElementSize == 4u) {
|
||||
return sourceWords[element];
|
||||
}
|
||||
if (uElementSize == 2u) {
|
||||
uint word = sourceWords[element >> 1u];
|
||||
return (word >> ((element & 1u) * 16u)) & 0xFFFFu;
|
||||
}
|
||||
uint word = sourceWords[element >> 2u];
|
||||
return (word >> ((element & 3u) * 8u)) & 0xFFu;
|
||||
}
|
||||
|
||||
void main() {
|
||||
uint outIndex = gl_GlobalInvocationID.x;
|
||||
if (outIndex >= uTotalIndices) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint low = 0u;
|
||||
uint high = uDrawCount - 1u;
|
||||
while (low < high) {
|
||||
uint mid = low + (high - low) / 2u;
|
||||
if (drawInfo[mid * 3u + 2u] > outIndex) {
|
||||
high = mid;
|
||||
} else {
|
||||
low = mid + 1u;
|
||||
}
|
||||
}
|
||||
|
||||
uint localIndex = outIndex - (low == 0u ? 0u : drawInfo[(low - 1u) * 3u + 2u]);
|
||||
// Unsigned wraparound is the defined behaviour for a negative base vertex. No
|
||||
// restart sentinel handling: the tier declines outright while restart is enabled.
|
||||
flatIndices[outIndex] = ReadSourceIndex(localIndex + drawInfo[low * 3u]) + drawInfo[low * 3u + 1u];
|
||||
}
|
||||
)";
|
||||
|
||||
struct FlattenedStream {
|
||||
Uint bufferId = 0;
|
||||
SizeT indexCount = 0;
|
||||
};
|
||||
|
||||
Bool EnsureComputeProgram() {
|
||||
if (g_computeProgram != 0) return true;
|
||||
if (g_computeProgramFailed) return false;
|
||||
g_computeProgramFailed = true; // cleared again only on a complete success
|
||||
|
||||
const GLuint shader = g_GLESFuncs.glCreateShader(GL_COMPUTE_SHADER);
|
||||
if (shader == 0) {
|
||||
MGLOG_E("DirectGLES multi-draw (compute tier): glCreateShader(GL_COMPUTE_SHADER) failed");
|
||||
return false;
|
||||
}
|
||||
const char* source = kFlattenComputeSource;
|
||||
g_GLESFuncs.glShaderSource(shader, 1, &source, nullptr);
|
||||
g_GLESFuncs.glCompileShader(shader);
|
||||
GLint status = GL_FALSE;
|
||||
g_GLESFuncs.glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
|
||||
if (status != GL_TRUE) {
|
||||
char log[1024] = {};
|
||||
g_GLESFuncs.glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
|
||||
MGLOG_E("DirectGLES multi-draw (compute tier): index-flattening shader failed to compile: %s", log);
|
||||
g_GLESFuncs.glDeleteShader(shader);
|
||||
return false;
|
||||
}
|
||||
|
||||
const GLuint program = g_GLESFuncs.glCreateProgram();
|
||||
if (program == 0) {
|
||||
MGLOG_E("DirectGLES multi-draw (compute tier): glCreateProgram failed");
|
||||
g_GLESFuncs.glDeleteShader(shader);
|
||||
return false;
|
||||
}
|
||||
g_GLESFuncs.glAttachShader(program, shader);
|
||||
g_GLESFuncs.glLinkProgram(program);
|
||||
g_GLESFuncs.glDeleteShader(shader);
|
||||
g_GLESFuncs.glGetProgramiv(program, GL_LINK_STATUS, &status);
|
||||
if (status != GL_TRUE) {
|
||||
char log[1024] = {};
|
||||
g_GLESFuncs.glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
|
||||
MGLOG_E("DirectGLES multi-draw (compute tier): index-flattening program failed to link: %s", log);
|
||||
g_GLESFuncs.glDeleteProgram(program);
|
||||
return false;
|
||||
}
|
||||
|
||||
g_computeProgram = program;
|
||||
g_uElementSize = g_GLESFuncs.glGetUniformLocation(program, "uElementSize");
|
||||
g_uDrawCount = g_GLESFuncs.glGetUniformLocation(program, "uDrawCount");
|
||||
g_uTotalIndices = g_GLESFuncs.glGetUniformLocation(program, "uTotalIndices");
|
||||
g_computeProgramFailed = false;
|
||||
MGLOG_I("DirectGLES multi-draw: index-flattening compute program ready (id %u)", program);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Builds the flattened stream, or leaves `out` empty when this batch's shape rules
|
||||
// the tier out. Runs BEFORE PrepareForDraw - see the call site - so it may leave
|
||||
// the compute program current and the first storage points unbound; the
|
||||
// preparation that follows re-establishes both.
|
||||
void FlattenWithCompute(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
|
||||
GLsizei drawcount, const GLint* basevertex, FlattenedStream& out) {
|
||||
if (!SupportsTier(GLESMultiDrawMode::Compute)) return;
|
||||
const SizeT indexSize = IndexTypeSize(type);
|
||||
if (indexSize == 0) return;
|
||||
|
||||
// Merging sub-draws into a single draw only reproduces the original primitive
|
||||
// stream for list-shaped modes: a strip, loop or fan would gain primitives
|
||||
// spanning the seam between two sub-draws.
|
||||
const Uint32 primitiveSize = ConcatenablePrimitiveSize(mode);
|
||||
if (primitiveSize == 0) return;
|
||||
|
||||
// Primitive restart defeats the whole-multiple-of-a-primitive argument below,
|
||||
// even for a list mode. A restart ends the current primitive, so a sub-draw of
|
||||
// six GL_TRIANGLES indices with a restart after the third emits ONE triangle
|
||||
// and drops the two leftover vertices - and once concatenated those leftovers
|
||||
// find a third vertex in the next sub-draw and become a triangle that GL never
|
||||
// draws. Splicing separator sentinels into the flattened stream could fix it,
|
||||
// at the cost of a per-sub-draw offset the prefix-sum layout does not carry;
|
||||
// declining is the honest trade for a tier that is already opt-in.
|
||||
if (RestartActive()) return;
|
||||
|
||||
// The shader reads the source indices as a storage buffer, so there has to be
|
||||
// a real buffer to read - a client-memory index array has none.
|
||||
const auto& indexBuffer = BoundIndexBuffer();
|
||||
if (!indexBuffer) return;
|
||||
|
||||
// A dispatch inside an open capture span is not legal, and the span would also
|
||||
// observe one merged draw rather than the batch it asked for.
|
||||
if (XfbImpl::IsCaptureSpanOpen()) return;
|
||||
|
||||
auto* sourceResource = BufferImpl::EnsureBufferResource(indexBuffer);
|
||||
if (!sourceResource || sourceResource->id == 0) return;
|
||||
const SizeT sourceSize = indexBuffer->GetSize();
|
||||
// std430 addresses the source as uint[]; a tail shorter than a word is not
|
||||
// reachable, so a narrow index type needs a word-multiple buffer.
|
||||
if (indexSize < 4 && (sourceSize % 4) != 0) return;
|
||||
|
||||
g_drawInfoStaging.resize(3 * static_cast<SizeT>(drawcount));
|
||||
SizeT total = 0;
|
||||
for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
const SizeT subDrawCount = count[i] > 0 ? static_cast<SizeT>(count[i]) : 0;
|
||||
// GL drops a trailing partial primitive per sub-draw; concatenation would
|
||||
// instead splice it onto the next sub-draw's first vertices.
|
||||
if (subDrawCount % primitiveSize != 0) return;
|
||||
const SizeT byteOffset = reinterpret_cast<SizeT>(indices[i]);
|
||||
if (byteOffset % indexSize != 0) return;
|
||||
if (subDrawCount != 0) {
|
||||
const SizeT byteEnd = byteOffset + subDrawCount * indexSize;
|
||||
if (byteEnd > sourceSize || byteEnd < byteOffset) return;
|
||||
}
|
||||
total += subDrawCount;
|
||||
if (total > kMaxComputeFlattenedIndices) return;
|
||||
const SizeT slot = 3 * static_cast<SizeT>(i);
|
||||
g_drawInfoStaging[slot] = static_cast<Uint32>(byteOffset / indexSize);
|
||||
g_drawInfoStaging[slot + 1] = static_cast<Uint32>(basevertex ? basevertex[i] : 0);
|
||||
g_drawInfoStaging[slot + 2] = static_cast<Uint32>(total);
|
||||
}
|
||||
if (total == 0) return; // nothing to draw; the ordinary tiers no-op just as well
|
||||
|
||||
if (!EnsureComputeProgram()) return;
|
||||
if (!UploadScratch(g_drawInfo, g_drawInfoStaging.size() * sizeof(Uint32), g_drawInfoStaging.data())) {
|
||||
return;
|
||||
}
|
||||
if (!UploadScratch(g_flattenedIndices, total * sizeof(Uint32), nullptr)) return;
|
||||
|
||||
BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 0, sourceResource->id);
|
||||
BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 1, g_drawInfo.id);
|
||||
BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 2, g_flattenedIndices.id);
|
||||
|
||||
g_GLESFuncs.glUseProgram(g_computeProgram);
|
||||
PrgramImpl::g_lastUsedBackendProgramId = g_computeProgram;
|
||||
if (g_uElementSize >= 0) g_GLESFuncs.glUniform1ui(g_uElementSize, static_cast<GLuint>(indexSize));
|
||||
if (g_uDrawCount >= 0) g_GLESFuncs.glUniform1ui(g_uDrawCount, static_cast<GLuint>(drawcount));
|
||||
if (g_uTotalIndices >= 0) g_GLESFuncs.glUniform1ui(g_uTotalIndices, static_cast<GLuint>(total));
|
||||
|
||||
g_GLESFuncs.glDispatchCompute(
|
||||
static_cast<GLuint>((total + kComputeWorkGroupSize - 1) / kComputeWorkGroupSize), 1, 1);
|
||||
g_GLESFuncs.glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT | GL_ELEMENT_ARRAY_BARRIER_BIT);
|
||||
|
||||
// Hand the storage points back to their GL default. PrepareForDraw re-syncs
|
||||
// only the points the app has actually touched, so leaving a scratch buffer on
|
||||
// an untouched point would keep it visible to the next shader that declares one.
|
||||
for (Uint point = 0; point < 3; ++point) {
|
||||
BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, point, 0);
|
||||
}
|
||||
|
||||
NoteTierExecuted(GLESMultiDrawMode::Compute);
|
||||
out.bufferId = g_flattenedIndices.id;
|
||||
out.indexCount = total;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// -------------------------------------------------------------------------------
|
||||
// Public surface
|
||||
// -------------------------------------------------------------------------------
|
||||
|
||||
Bool IsTierSupported(const MG_External::GLESCapabilities& caps, const MG_External::GLESFunctionsTable& funcs,
|
||||
GLESMultiDrawMode tier) {
|
||||
const Bool esAtLeast31 =
|
||||
caps.GLESVersion.Major > 3 || (caps.GLESVersion.Major == 3 && caps.GLESVersion.Minor >= 1);
|
||||
switch (tier) {
|
||||
case GLESMultiDrawMode::Ext:
|
||||
return caps.SupportsMultiDrawElementsBaseVertex;
|
||||
case GLESMultiDrawMode::MultiIndirect:
|
||||
return caps.SupportsMultiDrawIndirect && esAtLeast31 && funcs.glDrawElementsIndirect != nullptr;
|
||||
case GLESMultiDrawMode::Indirect:
|
||||
return esAtLeast31 && funcs.glDrawElementsIndirect != nullptr;
|
||||
case GLESMultiDrawMode::BaseVertex:
|
||||
return caps.SupportsDrawElementsBaseVertex;
|
||||
case GLESMultiDrawMode::DrawElements:
|
||||
// Plain glDrawElements over a rewritten index stream: ES 2 core, so this is
|
||||
// the floor every other tier can fall back to.
|
||||
return true;
|
||||
case GLESMultiDrawMode::Compute:
|
||||
// Three storage blocks, which is inside the four ES 3.1 guarantees per stage.
|
||||
return caps.SupportsComputeShader && caps.MaxComputeShaderStorageBlocks >= 3 &&
|
||||
funcs.glBindBufferBase != nullptr;
|
||||
case GLESMultiDrawMode::Auto:
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
GLESMultiDrawMode ResolveTier(const MG_External::GLESCapabilities& caps,
|
||||
const MG_External::GLESFunctionsTable& funcs, GLESMultiDrawMode requested,
|
||||
String* explanation) {
|
||||
const auto bestAuto = [&]() {
|
||||
for (const GLESMultiDrawMode tier : kAutoLadder) {
|
||||
if (IsTierSupported(caps, funcs, tier)) return tier;
|
||||
}
|
||||
return GLESMultiDrawMode::DrawElements;
|
||||
};
|
||||
|
||||
GLESMultiDrawMode resolved = GLESMultiDrawMode::DrawElements;
|
||||
String line;
|
||||
if (requested == GLESMultiDrawMode::Auto) {
|
||||
resolved = bestAuto();
|
||||
line = String("auto -> ") + TierName(resolved);
|
||||
} else if (IsTierSupported(caps, funcs, requested)) {
|
||||
resolved = requested;
|
||||
line = String("MOBILEGL_ESPRYT_MULTIDRAW_MODE=") + TierName(requested) + " -> " + TierName(resolved);
|
||||
} else {
|
||||
resolved = bestAuto();
|
||||
line = String("MOBILEGL_ESPRYT_MULTIDRAW_MODE=") + TierName(requested) +
|
||||
" requested but unsupported by this driver -> " + TierName(resolved);
|
||||
}
|
||||
|
||||
if (explanation) {
|
||||
String supported;
|
||||
for (const GLESMultiDrawMode tier : kAutoLadder) {
|
||||
if (!IsTierSupported(caps, funcs, tier)) continue;
|
||||
if (!supported.empty()) supported += ", ";
|
||||
supported += TierName(tier);
|
||||
}
|
||||
if (IsTierSupported(caps, funcs, GLESMultiDrawMode::Compute)) {
|
||||
supported += supported.empty() ? "compute (opt-in)" : ", compute (opt-in)";
|
||||
}
|
||||
*explanation = line + " (driver supports: " + supported + ")";
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const char* TierName(GLESMultiDrawMode tier) {
|
||||
switch (tier) {
|
||||
case GLESMultiDrawMode::Auto: return "auto";
|
||||
case GLESMultiDrawMode::Ext: return "ext";
|
||||
case GLESMultiDrawMode::MultiIndirect: return "multiindirect";
|
||||
case GLESMultiDrawMode::Indirect: return "indirect";
|
||||
case GLESMultiDrawMode::BaseVertex: return "basevertex";
|
||||
case GLESMultiDrawMode::DrawElements: return "drawelements";
|
||||
case GLESMultiDrawMode::Compute: return "compute";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
GLESMultiDrawMode ResolvedTier() {
|
||||
ResolveTierOnce();
|
||||
return g_resolvedTier;
|
||||
}
|
||||
|
||||
String DescribeTierResolution() {
|
||||
ResolveTierOnce();
|
||||
return g_tierResolution;
|
||||
}
|
||||
|
||||
void OnBackendContextDestroyed() {
|
||||
g_indirectCommands = {};
|
||||
g_rebasedIndices = {};
|
||||
g_drawInfo = {};
|
||||
g_flattenedIndices = {};
|
||||
g_computeProgram = 0;
|
||||
g_computeProgramFailed = false;
|
||||
g_uElementSize = -1;
|
||||
g_uDrawCount = -1;
|
||||
g_uTotalIndices = -1;
|
||||
}
|
||||
|
||||
void DrawElementsBatch(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
|
||||
GLsizei drawcount, const GLint* basevertex) {
|
||||
if (drawcount <= 0 || !count || !indices) return;
|
||||
// State-independent and possibly throwing, so it runs before any GL work.
|
||||
CheckPrimitiveRestartSupported(type);
|
||||
|
||||
const Bool hasIndexBuffer = BoundIndexBuffer() != nullptr;
|
||||
|
||||
// The compute tier dispatches BEFORE the draw state is established: doing it
|
||||
// afterwards would mean unpicking the program, SSBO and index bindings
|
||||
// PrepareForDraw just made, and a dispatch inside an open transform feedback
|
||||
// span is not legal at all. On success it hands back a flattened index stream.
|
||||
FlattenedStream flattened;
|
||||
if (ResolvedTier() == GLESMultiDrawMode::Compute && !CurrentProgramReadsDrawID()) {
|
||||
FlattenWithCompute(mode, count, type, indices, drawcount, basevertex, flattened);
|
||||
}
|
||||
|
||||
PrepareForDraw(DrawSyncBit::IndexBuffer);
|
||||
|
||||
if (flattened.indexCount != 0) {
|
||||
const Uint previousIndexBinding = BoundIndexBufferId();
|
||||
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, flattened.bufferId);
|
||||
g_GLESFuncs.glDrawElements(mode, static_cast<GLsizei>(flattened.indexCount), GL_UNSIGNED_INT, nullptr);
|
||||
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, previousIndexBinding);
|
||||
return;
|
||||
}
|
||||
|
||||
const Bool feedDrawID = CurrentProgramReadsDrawID();
|
||||
const GLESMultiDrawMode tier = ResolveTierForBatch(feedDrawID, hasIndexBuffer);
|
||||
|
||||
Bool drawn = false;
|
||||
switch (tier) {
|
||||
case GLESMultiDrawMode::Ext:
|
||||
drawn = RunExt(mode, count, type, indices, drawcount, basevertex);
|
||||
break;
|
||||
case GLESMultiDrawMode::MultiIndirect:
|
||||
drawn = RunIndirect(mode, count, type, indices, drawcount, basevertex, /*batched=*/true, feedDrawID);
|
||||
break;
|
||||
case GLESMultiDrawMode::Indirect:
|
||||
drawn = RunIndirect(mode, count, type, indices, drawcount, basevertex, /*batched=*/false, feedDrawID);
|
||||
break;
|
||||
case GLESMultiDrawMode::BaseVertex:
|
||||
drawn = RunBaseVertexLoop(mode, count, type, indices, drawcount, basevertex, feedDrawID);
|
||||
break;
|
||||
case GLESMultiDrawMode::DrawElements:
|
||||
drawn = RunRebasedDrawElements(mode, count, type, indices, drawcount, basevertex, feedDrawID);
|
||||
break;
|
||||
case GLESMultiDrawMode::Compute:
|
||||
// Its pre-pass ran above; reaching here means it declined this batch's shape.
|
||||
break;
|
||||
case GLESMultiDrawMode::Auto:
|
||||
break; // resolution never yields Auto
|
||||
}
|
||||
|
||||
// Every tier above may decline a batch whose shape it cannot express. The two
|
||||
// below are the floor: a base-vertex replay where the driver has one, and the
|
||||
// rewritten index stream where it does not. Both are safe for any batch these
|
||||
// entry points can receive.
|
||||
if (!drawn) drawn = RunBaseVertexLoop(mode, count, type, indices, drawcount, basevertex, feedDrawID);
|
||||
if (!drawn) drawn = RunRebasedDrawElements(mode, count, type, indices, drawcount, basevertex, feedDrawID);
|
||||
if (!drawn) {
|
||||
MGLOG_E("DirectGLES multi-draw: no usable tier for a %d sub-draw batch (mode 0x%x, type 0x%x); "
|
||||
"the batch was dropped",
|
||||
drawcount, mode, type);
|
||||
}
|
||||
}
|
||||
} // namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl
|
||||
@@ -0,0 +1,64 @@
|
||||
// MobileGL - MobileGL/MG_Backend/DirectGLES/MultiDraw.h
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
#include <Config.h>
|
||||
#include "DirectGLES.h"
|
||||
|
||||
// Emulation of the desktop glMultiDrawElements / glMultiDrawElementsBaseVertex entry
|
||||
// points on OpenGL ES, which has neither in core.
|
||||
//
|
||||
// Every strategy below is an emulation; they differ only in which driver capability
|
||||
// they lean on and in how many driver entries a batch of N sub-draws costs. The design
|
||||
// follows MobileGlues (MobileGL-Dev/MobileGlues, gl/multidraw.cpp) tier for tier, plus
|
||||
// the native GL_EXT_multi_draw_arrays interaction that MobileGL already had:
|
||||
//
|
||||
// Ext one glMultiDrawElementsBaseVertexEXT 1 driver entry
|
||||
// MultiIndirect one glMultiDrawElementsIndirectEXT 1 driver entry + 1 upload
|
||||
// Indirect N x glDrawElementsIndirect N + 1 upload
|
||||
// BaseVertex N x glDrawElementsBaseVertex N
|
||||
// DrawElements N x glDrawElements over CPU-rebased indices N + 1 upload
|
||||
// Compute 1 x glDrawElements over a GPU-flattened, 1 dispatch + 1 entry
|
||||
// rebased index stream
|
||||
//
|
||||
// Which one runs is resolved once per ES context from the driver's capabilities,
|
||||
// capped by MOBILEGL_ESPRYT_MULTIDRAW_MODE, and can additionally be demoted per batch
|
||||
// when the batch's own shape rules a tier out (see ResolveTierForBatch in the .cpp).
|
||||
namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
|
||||
// The tier this ES context resolved to, computed on first use and stable after.
|
||||
MG_Config::GLESMultiDrawMode ResolvedTier();
|
||||
// "multiindirect", "compute", ... - stable identifiers, also used by the POST row.
|
||||
const char* TierName(MG_Config::GLESMultiDrawMode tier);
|
||||
// One line naming the resolved tier, the tiers the driver can support, and the env
|
||||
// clamp if one applied. For DriverPost and the startup log.
|
||||
String DescribeTierResolution();
|
||||
|
||||
// The resolution itself, as a pure function of a capability set: the backend feeds
|
||||
// it the live ES context's capabilities, DriverPost feeds it the ones it probed
|
||||
// standalone, and both therefore report the same tier. `explanation`, when non-null,
|
||||
// receives the "requested -> resolved (driver supports: ...)" line.
|
||||
MG_Config::GLESMultiDrawMode ResolveTier(const MG_External::GLESCapabilities& caps,
|
||||
const MG_External::GLESFunctionsTable& funcs,
|
||||
MG_Config::GLESMultiDrawMode requested, String* explanation);
|
||||
// Whether one tier is runnable on the given capability set, for per-row POST output.
|
||||
Bool IsTierSupported(const MG_External::GLESCapabilities& caps, const MG_External::GLESFunctionsTable& funcs,
|
||||
MG_Config::GLESMultiDrawMode tier);
|
||||
|
||||
// Runs `drawcount` indexed sub-draws as one glMultiDrawElements(BaseVertex) call
|
||||
// would. `basevertex` is null for the plain glMultiDrawElements entry point (every
|
||||
// base vertex is 0). Owns the whole draw, preparation included: callers must not
|
||||
// have run PrepareForDraw, because the compute tier has to dispatch before the
|
||||
// draw state is established.
|
||||
void DrawElementsBatch(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
|
||||
GLsizei drawcount, const GLint* basevertex);
|
||||
|
||||
// The ES context is gone: every scratch buffer and the compute program belonged to
|
||||
// it, so drop the names without deleting them (the dead context reclaims them).
|
||||
void OnBackendContextDestroyed();
|
||||
} // namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl
|
||||
@@ -61,29 +61,6 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
requestedInternalFormat, GetDriverPixelFormatNormalizeOptions() | extraOptions);
|
||||
}
|
||||
|
||||
// Multisample textures can only ever be rendered into, never uploaded to, so a fallback
|
||||
// format for them has to stay colour-renderable - a three-channel float fallback is a legal
|
||||
// ES texture format but not a legal multisample storage format. Widening to four channels
|
||||
// is safe here precisely because there is no transfer path that would have to expand
|
||||
// three-channel client data, and the alpha the draw writes for a three-channel source is
|
||||
// already the 1.0 the frontend format implies.
|
||||
Bool TargetRequiresRenderableFormat(SizeT targetIndex) {
|
||||
return targetIndex == static_cast<SizeT>(TextureTarget::Texture2DMultisample) ||
|
||||
targetIndex == static_cast<SizeT>(TextureTarget::Texture2DMultisampleArray);
|
||||
}
|
||||
|
||||
Flags<PixelFormatNormalizeOptionBit> GetRenderTargetNormalizeOptions(SizeT targetIndex) {
|
||||
Flags<PixelFormatNormalizeOptionBit> options;
|
||||
if (!TargetRequiresRenderableFormat(targetIndex)) {
|
||||
return options;
|
||||
}
|
||||
options |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
|
||||
if (!g_GLESCapabilities.SupportsRenderSnorm || !g_GLESCapabilities.SupportsNorm16Texture) {
|
||||
options |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
Bool HasCachedFormatCapability(TextureInternalFormat internalFormat,
|
||||
SizeT targetIndex,
|
||||
Bool caveat,
|
||||
@@ -141,14 +118,61 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
|
||||
Flags<PixelFormatNormalizeOptionBit> options;
|
||||
if (!pActiveBackendObject || ShouldUseCaveatFormat(internalFormat, targetIndex)) {
|
||||
options = GetRuntimeFallbackNormalizeOptions(requestedInternalFormat,
|
||||
GetRenderTargetNormalizeOptions(targetIndex));
|
||||
options = GetRuntimeFallbackNormalizeOptions(
|
||||
requestedInternalFormat,
|
||||
TextureImpl::GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
|
||||
}
|
||||
NormalizePixelFormat(requestedInternalFormat, options, outInternalFormat, outFormat, outType);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace TextureImpl {
|
||||
// Every image that can back a colour attachment needs a colour-renderable storage format,
|
||||
// and ES has no renderable three-channel format at all: a three-channel float fallback is
|
||||
// a legal ES texture but neither legal multisample storage nor a legal attachment, so
|
||||
// GL_RGB8_SNORM / GL_RGB16F / ... have to be widened to four channels for any of them.
|
||||
// This used to cover the multisample pair alone, on the grounds that only those can never
|
||||
// be uploaded to; the transfer paths now expand three-channel client data themselves
|
||||
// (Managers.cpp PrepareFallbackUpload) and hide the added alpha again on sample and
|
||||
// readback, so the same substitution is available everywhere.
|
||||
//
|
||||
// The widening only ever *happens* where the driver refuses the native form (see
|
||||
// PopulateFormatCapabilitiesImpl: outside multisample storage it rides the driver branch,
|
||||
// behind the native probe), so a driver that does render to a three-channel image keeps
|
||||
// allocating it byte for byte.
|
||||
//
|
||||
// Do NOT read that as "nothing changes off-device". Measured on Mesa 26.1.6 llvmpipe
|
||||
// (the headless CI driver), an ES 3.2 GL_TEXTURE_2D colour attachment is COMPLETE for
|
||||
// GL_RGB8 and GL_RGB16F but INCOMPLETE_ATTACHMENT for GL_RGB8_SNORM, GL_SRGB8 and every
|
||||
// RGB integer format, and UNSUPPORTED for GL_RGB32F. Those eight formats therefore DO
|
||||
// take the widened path on llvmpipe, which is where the retrace fixtures and the glcts
|
||||
// green suites run - the substitution is driver-conditional, not desktop-exempt.
|
||||
//
|
||||
// A buffer texture is the one image that can never be an attachment; its storage is the
|
||||
// buffer object's, and widening it would misdescribe the application's data.
|
||||
Bool TargetRequiresRenderableFormat(SizeT targetIndex) {
|
||||
if (targetIndex >= kFormatCapabilityTargetCount) {
|
||||
return false;
|
||||
}
|
||||
if (targetIndex == kFormatCapabilityRenderbufferTargetIndex) {
|
||||
return true;
|
||||
}
|
||||
return static_cast<TextureTarget>(targetIndex) != TextureTarget::TextureBuffer;
|
||||
}
|
||||
|
||||
Flags<PixelFormatNormalizeOptionBit> GetRenderTargetNormalizeOptions(
|
||||
const MG_External::GLESCapabilities& capabilities, SizeT targetIndex) {
|
||||
Flags<PixelFormatNormalizeOptionBit> options;
|
||||
if (!TargetRequiresRenderableFormat(targetIndex)) {
|
||||
return options;
|
||||
}
|
||||
options |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
|
||||
if (!capabilities.SupportsRenderSnorm || !capabilities.SupportsNorm16Texture) {
|
||||
options |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
|
||||
GLenum* outFormat, GLenum* outType, TextureTarget target) {
|
||||
#ifdef TRACY_ENABLE
|
||||
@@ -178,9 +202,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return ShouldUseCaveatFormat(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
|
||||
}
|
||||
|
||||
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target) {
|
||||
const SizeT targetIndex =
|
||||
target == TextureTarget::Unknown ? kFormatCapabilityTargetCount : GetFormatCapabilityTargetIndex(target);
|
||||
namespace {
|
||||
Bool BackendFormatAddsAlpha(TextureInternalFormat internalFormat, SizeT targetIndex) {
|
||||
if (!TargetRequiresRenderableFormat(targetIndex)) {
|
||||
return false;
|
||||
}
|
||||
@@ -188,11 +211,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return false;
|
||||
}
|
||||
const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
|
||||
const Flags<PixelFormatNormalizeOptionBit> options =
|
||||
GetRuntimeFallbackNormalizeOptions(requestedInternalFormat,
|
||||
GetRenderTargetNormalizeOptions(targetIndex));
|
||||
const Flags<PixelFormatNormalizeOptionBit> options = GetRuntimeFallbackNormalizeOptions(
|
||||
requestedInternalFormat, GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
|
||||
return static_cast<Bool>(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target) {
|
||||
const SizeT targetIndex =
|
||||
target == TextureTarget::Unknown ? kFormatCapabilityTargetCount : GetFormatCapabilityTargetIndex(target);
|
||||
return BackendFormatAddsAlpha(internalFormat, targetIndex);
|
||||
}
|
||||
|
||||
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat) {
|
||||
return BackendFormatAddsAlpha(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
|
||||
}
|
||||
} // namespace TextureImpl
|
||||
namespace PrgramImpl {
|
||||
String ProcessOutColorLocations(const String& glslCode) {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
|
||||
#include <MG_Util/Texture/TextureFormatProcessor.h>
|
||||
|
||||
namespace MobileGL::MG_Backend::DirectGLES {
|
||||
namespace DebugImpl {
|
||||
@@ -34,6 +36,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
} // namespace VertexArrayImpl
|
||||
|
||||
namespace TextureImpl {
|
||||
// Whether images on this format-capability target can back a colour attachment, and so
|
||||
// need a colour-renderable storage format even when the frontend asked for a
|
||||
// three-channel one ES never renders to. Shared by the capability probe (which passes the
|
||||
// capabilities it has just queried, before the globals are published) and by the
|
||||
// allocation path (which reads the active backend's), so the format the cache was probed
|
||||
// with is always the format the image is created with.
|
||||
Bool TargetRequiresRenderableFormat(SizeT targetIndex);
|
||||
Flags<PixelFormatNormalizeOptionBit> GetRenderTargetNormalizeOptions(
|
||||
const MG_External::GLESCapabilities& capabilities, SizeT targetIndex);
|
||||
|
||||
void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
|
||||
GLenum* outFormat, GLenum* outType,
|
||||
TextureTarget target = TextureTarget::Unknown);
|
||||
@@ -41,10 +53,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
GLenum* outFormat, GLenum* outType);
|
||||
Bool ShouldUseCaveatTextureFormat(TextureInternalFormat internalFormat, TextureTarget target);
|
||||
|
||||
// True when the format the texture is actually created with has an alpha channel the
|
||||
// frontend format does not (the three-channel multisample widening). GL reads such a
|
||||
// channel back as 1.0, so any swizzle source of ALPHA has to be answered with ONE.
|
||||
// True when the format the image is actually created with has an alpha channel the
|
||||
// frontend format does not (the three-channel colour-renderable widening). GL reads such
|
||||
// a channel back as 1.0, so any swizzle source of ALPHA has to be answered with ONE and
|
||||
// any readback of the image has to overwrite the alpha the draw happened to leave there.
|
||||
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target);
|
||||
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat);
|
||||
Bool ShouldUseCaveatRenderbufferFormat(TextureInternalFormat internalFormat);
|
||||
} // namespace TextureImpl
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "MG_Util/Converters/MGToStr/TextureEnumConverter.h"
|
||||
#include "MG_Util/Converters/MGToVk/TextureEnumConverter.h"
|
||||
#include "MG_Util/Texture/TextureFormatProcessor.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
|
||||
#include <Config.h>
|
||||
#include <cmath>
|
||||
@@ -523,6 +524,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
if (shaderSubgroupSupported && !MG_Config::Features.DisableSubgroup) {
|
||||
extensions.push_back(E_GL_KHR_shader_subgroup);
|
||||
}
|
||||
// GL_KHR_parallel_shader_compile is MobileGL's own capability, not the Vulkan
|
||||
// device's: the compiler threads belong to MobileGL's shader pool and
|
||||
// glCompileShader/glLinkProgram are serviced entirely inside the frontend, so there
|
||||
// is no device feature to condition this on.
|
||||
//
|
||||
// Gated on the async flag deliberately, and this is the whole reason the gate
|
||||
// exists. Advertising the string is the one part of asynchronous compilation that a
|
||||
// recorded trace can never cover: Iris and Sodium change their SUBMISSION SCHEDULE
|
||||
// the moment they see it - they enqueue whole pipeline batches and poll
|
||||
// GL_COMPLETION_STATUS_KHR instead of compiling one program at a time - so
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE=0 has to withdraw the application-visible behaviour
|
||||
// change as well as the threading, or the kill switch would only be half a switch.
|
||||
if (MG_Util::Async::AsyncShaderCompileEnabled()) {
|
||||
extensions.push_back(E_GL_KHR_parallel_shader_compile);
|
||||
}
|
||||
// GL_ARB_timer_query gates MC's F3 GPU% (LWJGL checks the extension string);
|
||||
// only advertised when the device actually supports timestamp queries and the
|
||||
// MOBILEGL_DISABLE_TIMERQUERY escape hatch is off.
|
||||
@@ -601,12 +617,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
funcsTable.GL.GetIntegeri_v = GetIntegeri_v;
|
||||
funcsTable.GL.GetInteger64i_v = GetInteger64i_v;
|
||||
funcsTable.GL.GetProgramiv = GetProgramiv;
|
||||
funcsTable.GL.GetProgramInterfaceiv = GetProgramInterfaceiv;
|
||||
funcsTable.GL.GetProgramResourceIndex = GetProgramResourceIndex;
|
||||
funcsTable.GL.GetProgramResourceName = GetProgramResourceName;
|
||||
funcsTable.GL.GetProgramResourceiv = GetProgramResourceiv;
|
||||
funcsTable.GL.GetProgramResourceLocation = GetProgramResourceLocation;
|
||||
funcsTable.GL.GetProgramResourceLocationIndex = GetProgramResourceLocationIndex;
|
||||
funcsTable.GL.ShaderStorageBlockBinding = ShaderStorageBlockBinding;
|
||||
funcsTable.GL.FenceSync = FenceSync;
|
||||
funcsTable.GL.ClientWaitSync = ClientWaitSync;
|
||||
|
||||
@@ -232,6 +232,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
StorageBlockResource block{};
|
||||
block.name = blockName;
|
||||
block.binding = binding->binding;
|
||||
// glShaderStorageBlockBinding survives every rebuild of this cache: the
|
||||
// authoritative record of a rebound block lives on the program (it is what
|
||||
// GL_BUFFER_BINDING reports), and only the shader's declared binding is
|
||||
// recoverable from the SPIR-V. Without this, any unrelated state-version
|
||||
// bump would silently revert the block to its declared binding.
|
||||
const Int rebound = program.GetShaderStorageBlockBindingOverride(blockName);
|
||||
if (rebound >= 0) block.binding = static_cast<Uint32>(rebound);
|
||||
block.dataSize = static_cast<GLint>(binding->block.size);
|
||||
const GLuint blockIndex = static_cast<GLuint>(cache.storageBlocks.size());
|
||||
AddBufferVariablesRecursive(binding->block, blockName, blockIndex, cache.bufferVariables,
|
||||
@@ -256,18 +263,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
return programObject.get();
|
||||
}
|
||||
|
||||
void CopyResourceName(const String& source, GLsizei bufSize, GLsizei* length, GLchar* name) {
|
||||
const GLsizei writtenLength = static_cast<GLsizei>(source.size());
|
||||
if (length) {
|
||||
*length = writtenLength;
|
||||
}
|
||||
if (name && bufSize > 0) {
|
||||
const GLsizei copyLength = std::min<GLsizei>(bufSize - 1, writtenLength);
|
||||
std::memcpy(name, source.data(), static_cast<SizeT>(copyLength));
|
||||
name[copyLength] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
const Uint8* ResolveIndirectCommandBytes(const void* indirect, SizeT requiredBytes, const char* label) {
|
||||
auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();
|
||||
if (drawBuffer) {
|
||||
@@ -288,100 +283,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
return reinterpret_cast<const Uint8*>(indirect);
|
||||
}
|
||||
|
||||
Vector<GLuint> GetUniformBlockActiveVariables(const MG_State::GLState::ProgramObject& program,
|
||||
GLuint blockIndex) {
|
||||
Vector<GLuint> activeVariables;
|
||||
const Uint uniformCount = program.GetUniformCount();
|
||||
activeVariables.reserve(uniformCount);
|
||||
for (Uint uniformIndex = 0; uniformIndex < uniformCount; ++uniformIndex) {
|
||||
if (program.GetActiveUniformBlockIndex(uniformIndex) == static_cast<Int>(blockIndex)) {
|
||||
activeVariables.push_back(uniformIndex);
|
||||
}
|
||||
}
|
||||
return activeVariables;
|
||||
}
|
||||
|
||||
GLuint FindProgramInputIndex(const MG_State::GLState::ProgramObject& program, const String& name) {
|
||||
const Int activeCount = program.GetActiveAttributesCount();
|
||||
for (Int index = 0; index < activeCount; ++index) {
|
||||
if (program.GetActiveAttribName(index) == name) {
|
||||
return static_cast<GLuint>(index);
|
||||
}
|
||||
}
|
||||
return GL_INVALID_INDEX;
|
||||
}
|
||||
|
||||
GLuint FindProgramOutputIndex(const MG_State::GLState::ProgramObject& program, const String& name) {
|
||||
const Int activeCount = program.GetActiveFragmentOutputCount();
|
||||
for (Int index = 0; index < activeCount; ++index) {
|
||||
if (program.GetActiveFragmentOutputName(index) == name) {
|
||||
return static_cast<GLuint>(index);
|
||||
}
|
||||
}
|
||||
return GL_INVALID_INDEX;
|
||||
}
|
||||
|
||||
GLint GetProgramOutputLocation(const MG_State::GLState::ProgramObject& program, const String& name) {
|
||||
const Int activeCount = program.GetActiveFragmentOutputCount();
|
||||
for (Int index = 0; index < activeCount; ++index) {
|
||||
if (program.GetActiveFragmentOutputName(index) == name) {
|
||||
return program.GetFragmentOutputLocation(index);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
GLint GetProgramResourceActiveCount(const MG_State::GLState::ProgramObject& program, GLenum programInterface,
|
||||
const ProgramResourceCache& cache) {
|
||||
switch (programInterface) {
|
||||
case GL_SHADER_STORAGE_BLOCK:
|
||||
return static_cast<GLint>(cache.storageBlocks.size());
|
||||
case GL_BUFFER_VARIABLE:
|
||||
return static_cast<GLint>(cache.bufferVariables.size());
|
||||
case GL_UNIFORM_BLOCK:
|
||||
return program.GetActiveUniformBlocksCount();
|
||||
case GL_UNIFORM:
|
||||
return static_cast<GLint>(program.GetUniformCount());
|
||||
case GL_PROGRAM_INPUT:
|
||||
return program.GetActiveAttributesCount();
|
||||
case GL_PROGRAM_OUTPUT:
|
||||
return program.GetActiveFragmentOutputCount();
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
GLint GetProgramResourceMaxNameLength(const MG_State::GLState::ProgramObject& program, GLenum programInterface,
|
||||
const ProgramResourceCache& cache) {
|
||||
switch (programInterface) {
|
||||
case GL_SHADER_STORAGE_BLOCK: {
|
||||
SizeT maxLength = 0;
|
||||
for (const auto& block : cache.storageBlocks) maxLength = std::max(maxLength, block.name.size() + 1);
|
||||
return static_cast<GLint>(maxLength);
|
||||
}
|
||||
case GL_BUFFER_VARIABLE: {
|
||||
SizeT maxLength = 0;
|
||||
for (const auto& var : cache.bufferVariables) maxLength = std::max(maxLength, var.name.size() + 1);
|
||||
return static_cast<GLint>(maxLength);
|
||||
}
|
||||
case GL_UNIFORM_BLOCK:
|
||||
return program.GetActiveUniformBlocksMaxNameLength() + 1;
|
||||
case GL_UNIFORM:
|
||||
return program.GetUniformMaxLength() + 1;
|
||||
case GL_PROGRAM_INPUT:
|
||||
return program.GetActiveAttributesMaxLength() + 1;
|
||||
case GL_PROGRAM_OUTPUT: {
|
||||
SizeT maxLength = 0;
|
||||
const Int activeCount = program.GetActiveFragmentOutputCount();
|
||||
for (Int index = 0; index < activeCount; ++index) {
|
||||
maxLength = std::max(maxLength, program.GetActiveFragmentOutputName(index).size() + 1);
|
||||
}
|
||||
return static_cast<GLint>(maxLength);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void ClearProgramResourceCaches() {
|
||||
@@ -395,11 +296,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
|
||||
GLuint GetShaderStorageBlockIndex(const MG_State::GLState::ProgramObject& program, const String& name) {
|
||||
auto& cache = GetProgramResourceCache(program);
|
||||
const auto it = std::find_if(cache.storageBlocks.begin(), cache.storageBlocks.end(),
|
||||
[&](const StorageBlockResource& block) { return block.name == name; });
|
||||
return it == cache.storageBlocks.end()
|
||||
? GL_INVALID_INDEX
|
||||
: static_cast<GLuint>(std::distance(cache.storageBlocks.begin(), it));
|
||||
auto find = [&cache](const String& key) {
|
||||
return std::find_if(cache.storageBlocks.begin(), cache.storageBlocks.end(),
|
||||
[&](const StorageBlockResource& block) { return block.name == key; });
|
||||
};
|
||||
auto it = find(name);
|
||||
if (it == cache.storageBlocks.end()) {
|
||||
// Cache names are normalized (NormalizeDescriptorName drops the array suffix), so
|
||||
// an arrayed block that GL enumerates per element - "B[0]", "B[1]" - is one entry
|
||||
// here, spelled "B". Retry against the bare name before giving up.
|
||||
const auto bracket = name.rfind('[');
|
||||
if (bracket == String::npos || name.empty() || name.back() != ']') return GL_INVALID_INDEX;
|
||||
it = find(name.substr(0, bracket));
|
||||
if (it == cache.storageBlocks.end()) return GL_INVALID_INDEX;
|
||||
}
|
||||
return static_cast<GLuint>(std::distance(cache.storageBlocks.begin(), it));
|
||||
}
|
||||
|
||||
GLuint GetShaderStorageBlockBinding(const MG_State::GLState::ProgramObject& program, GLuint blockIndex) {
|
||||
@@ -874,357 +785,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
}
|
||||
}
|
||||
|
||||
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params) {
|
||||
if (!params) return;
|
||||
void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding) {
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject) return;
|
||||
auto& cache = GetProgramResourceCache(*programObject);
|
||||
switch (pname) {
|
||||
case GL_ACTIVE_RESOURCES:
|
||||
*params = GetProgramResourceActiveCount(*programObject, programInterface, cache);
|
||||
return;
|
||||
case GL_MAX_NAME_LENGTH:
|
||||
*params = GetProgramResourceMaxNameLength(*programObject, programInterface, cache);
|
||||
return;
|
||||
case GL_MAX_NUM_ACTIVE_VARIABLES:
|
||||
if (programInterface == GL_SHADER_STORAGE_BLOCK) {
|
||||
SizeT maxCount = 0;
|
||||
for (const auto& block : cache.storageBlocks) {
|
||||
maxCount = std::max(maxCount, block.activeVariables.size());
|
||||
}
|
||||
*params = static_cast<GLint>(maxCount);
|
||||
} else if (programInterface == GL_UNIFORM_BLOCK) {
|
||||
GLint maxCount = 0;
|
||||
const Int activeBlocks = programObject->GetActiveUniformBlocksCount();
|
||||
for (Int index = 0; index < activeBlocks; ++index) {
|
||||
maxCount = std::max(maxCount, programObject->GetUniformBlockActiveUniformCount(index));
|
||||
}
|
||||
*params = maxCount;
|
||||
} else {
|
||||
*params = 0;
|
||||
}
|
||||
return;
|
||||
default:
|
||||
*params = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
if (!name) return GL_INVALID_INDEX;
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject) return GL_INVALID_INDEX;
|
||||
auto& cache = GetProgramResourceCache(*programObject);
|
||||
const String resourceName = name;
|
||||
if (programInterface == GL_SHADER_STORAGE_BLOCK) {
|
||||
return GetShaderStorageBlockIndex(*programObject, name);
|
||||
}
|
||||
if (programInterface == GL_BUFFER_VARIABLE) {
|
||||
const auto it = std::find_if(cache.bufferVariables.begin(), cache.bufferVariables.end(),
|
||||
[&](const BufferVariableResource& var) { return var.name == resourceName; });
|
||||
return it == cache.bufferVariables.end()
|
||||
? GL_INVALID_INDEX
|
||||
: static_cast<GLuint>(std::distance(cache.bufferVariables.begin(), it));
|
||||
}
|
||||
if (programInterface == GL_UNIFORM_BLOCK) {
|
||||
return programObject->GetUniformBlockIndex(name);
|
||||
}
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
const Int activeUniformIndex = programObject->GetActiveUniformIndex(resourceName);
|
||||
return activeUniformIndex >= 0 ? static_cast<GLuint>(activeUniformIndex) : GL_INVALID_INDEX;
|
||||
}
|
||||
if (programInterface == GL_PROGRAM_INPUT) {
|
||||
return FindProgramInputIndex(*programObject, resourceName);
|
||||
}
|
||||
if (programInterface == GL_PROGRAM_OUTPUT) {
|
||||
return FindProgramOutputIndex(*programObject, resourceName);
|
||||
}
|
||||
return GL_INVALID_INDEX;
|
||||
}
|
||||
|
||||
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize,
|
||||
GLsizei* length, GLchar* name) {
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject) return;
|
||||
auto& cache = GetProgramResourceCache(*programObject);
|
||||
if (programInterface == GL_SHADER_STORAGE_BLOCK && index < cache.storageBlocks.size()) {
|
||||
CopyResourceName(cache.storageBlocks[index].name, bufSize, length, name);
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_BUFFER_VARIABLE && index < cache.bufferVariables.size()) {
|
||||
CopyResourceName(cache.bufferVariables[index].name, bufSize, length, name);
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_UNIFORM_BLOCK && programObject->IsActiveUniformBlock(index)) {
|
||||
CopyResourceName(programObject->GetUniformBlockName(index), bufSize, length, name);
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_UNIFORM && index < programObject->GetUniformCount()) {
|
||||
CopyResourceName(programObject->GetActiveUniformName(index), bufSize, length, name);
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_PROGRAM_INPUT && index < static_cast<GLuint>(programObject->GetActiveAttributesCount())) {
|
||||
CopyResourceName(programObject->GetActiveAttribName(index), bufSize, length, name);
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_PROGRAM_OUTPUT &&
|
||||
index < static_cast<GLuint>(programObject->GetActiveFragmentOutputCount())) {
|
||||
CopyResourceName(programObject->GetActiveFragmentOutputName(index), bufSize, length, name);
|
||||
return;
|
||||
}
|
||||
if (length) *length = 0;
|
||||
if (name && bufSize > 0) name[0] = '\0';
|
||||
}
|
||||
|
||||
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
|
||||
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject || !props || !params || bufSize <= 0) return;
|
||||
auto& cache = GetProgramResourceCache(*programObject);
|
||||
GLsizei written = 0;
|
||||
auto writeValue = [&](GLint value) {
|
||||
if (written < bufSize) {
|
||||
params[written++] = value;
|
||||
}
|
||||
};
|
||||
|
||||
for (GLsizei propIndex = 0; propIndex < propCount; ++propIndex) {
|
||||
const GLenum prop = props[propIndex];
|
||||
if (programInterface == GL_SHADER_STORAGE_BLOCK && index < cache.storageBlocks.size()) {
|
||||
const auto& block = cache.storageBlocks[index];
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
writeValue(static_cast<GLint>(block.name.size() + 1));
|
||||
break;
|
||||
case GL_BUFFER_BINDING:
|
||||
writeValue(static_cast<GLint>(block.binding));
|
||||
break;
|
||||
case GL_BUFFER_DATA_SIZE:
|
||||
writeValue(block.dataSize);
|
||||
break;
|
||||
case GL_NUM_ACTIVE_VARIABLES:
|
||||
writeValue(static_cast<GLint>(block.activeVariables.size()));
|
||||
break;
|
||||
case GL_ACTIVE_VARIABLES:
|
||||
for (const auto variable : block.activeVariables) writeValue(static_cast<GLint>(variable));
|
||||
break;
|
||||
default:
|
||||
writeValue(0);
|
||||
break;
|
||||
}
|
||||
} else if (programInterface == GL_BUFFER_VARIABLE && index < cache.bufferVariables.size()) {
|
||||
const auto& var = cache.bufferVariables[index];
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
writeValue(static_cast<GLint>(var.name.size() + 1));
|
||||
break;
|
||||
case GL_TYPE:
|
||||
writeValue(GL_FLOAT);
|
||||
break;
|
||||
case GL_ARRAY_SIZE:
|
||||
writeValue(1);
|
||||
break;
|
||||
case GL_OFFSET:
|
||||
writeValue(var.offset);
|
||||
break;
|
||||
case GL_BLOCK_INDEX:
|
||||
writeValue(static_cast<GLint>(var.blockIndex));
|
||||
break;
|
||||
case GL_ARRAY_STRIDE:
|
||||
case GL_MATRIX_STRIDE:
|
||||
case GL_TOP_LEVEL_ARRAY_SIZE:
|
||||
case GL_TOP_LEVEL_ARRAY_STRIDE:
|
||||
case GL_IS_ROW_MAJOR:
|
||||
writeValue(0);
|
||||
break;
|
||||
default:
|
||||
writeValue(0);
|
||||
break;
|
||||
}
|
||||
} else if (programInterface == GL_UNIFORM_BLOCK &&
|
||||
programObject->IsActiveUniformBlock(index)) {
|
||||
const auto activeVariables = GetUniformBlockActiveVariables(*programObject, index);
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
writeValue(static_cast<GLint>(programObject->GetUniformBlockName(index).size() + 1));
|
||||
break;
|
||||
case GL_BUFFER_BINDING:
|
||||
writeValue(static_cast<GLint>(programObject->GetUniformBlockBinding(index)));
|
||||
break;
|
||||
case GL_BUFFER_DATA_SIZE:
|
||||
writeValue(static_cast<GLint>(programObject->GetUBOSizeAt(index)));
|
||||
break;
|
||||
case GL_NUM_ACTIVE_VARIABLES:
|
||||
writeValue(static_cast<GLint>(activeVariables.size()));
|
||||
break;
|
||||
case GL_ACTIVE_VARIABLES:
|
||||
for (const GLuint variableIndex : activeVariables) {
|
||||
writeValue(static_cast<GLint>(variableIndex));
|
||||
}
|
||||
break;
|
||||
case GL_REFERENCED_BY_VERTEX_SHADER:
|
||||
writeValue(programObject->IsUniformBlockReferencedByStage(index, EShLangVertex) ? GL_TRUE
|
||||
: GL_FALSE);
|
||||
break;
|
||||
case GL_REFERENCED_BY_FRAGMENT_SHADER:
|
||||
writeValue(programObject->IsUniformBlockReferencedByStage(index, EShLangFragment) ? GL_TRUE
|
||||
: GL_FALSE);
|
||||
break;
|
||||
case GL_REFERENCED_BY_COMPUTE_SHADER:
|
||||
writeValue(programObject->IsUniformBlockReferencedByStage(index, EShLangCompute) ? GL_TRUE
|
||||
: GL_FALSE);
|
||||
break;
|
||||
case GL_REFERENCED_BY_GEOMETRY_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
|
||||
writeValue(GL_FALSE);
|
||||
break;
|
||||
default:
|
||||
writeValue(0);
|
||||
break;
|
||||
}
|
||||
} else if (programInterface == GL_UNIFORM && index < programObject->GetUniformCount()) {
|
||||
const auto& uniformName = programObject->GetActiveUniformName(index);
|
||||
const GLint location = programObject->GetUniformLocation(uniformName);
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
writeValue(static_cast<GLint>(uniformName.size() + 1));
|
||||
break;
|
||||
case GL_TYPE:
|
||||
writeValue(static_cast<GLint>(programObject->GetActiveUniformType(index)));
|
||||
break;
|
||||
case GL_ARRAY_SIZE:
|
||||
writeValue(programObject->GetActiveUniformArraySize(index));
|
||||
break;
|
||||
case GL_BLOCK_INDEX:
|
||||
writeValue(programObject->GetActiveUniformBlockIndex(index));
|
||||
break;
|
||||
case GL_LOCATION:
|
||||
writeValue(location);
|
||||
break;
|
||||
case GL_OFFSET:
|
||||
writeValue(location >= 0 && programObject->IsValidUniformLocation(location)
|
||||
? static_cast<GLint>(programObject->GetUniformOffset(location))
|
||||
: 0);
|
||||
break;
|
||||
case GL_ARRAY_STRIDE:
|
||||
case GL_MATRIX_STRIDE:
|
||||
case GL_IS_ROW_MAJOR:
|
||||
case GL_TOP_LEVEL_ARRAY_SIZE:
|
||||
case GL_TOP_LEVEL_ARRAY_STRIDE:
|
||||
case GL_REFERENCED_BY_VERTEX_SHADER:
|
||||
case GL_REFERENCED_BY_FRAGMENT_SHADER:
|
||||
case GL_REFERENCED_BY_COMPUTE_SHADER:
|
||||
case GL_REFERENCED_BY_GEOMETRY_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
|
||||
writeValue(0);
|
||||
break;
|
||||
default:
|
||||
writeValue(0);
|
||||
break;
|
||||
}
|
||||
} else if (programInterface == GL_PROGRAM_INPUT &&
|
||||
index < static_cast<GLuint>(programObject->GetActiveAttributesCount())) {
|
||||
const auto& resourceName = programObject->GetActiveAttribName(index);
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
writeValue(static_cast<GLint>(resourceName.size() + 1));
|
||||
break;
|
||||
case GL_TYPE:
|
||||
writeValue(static_cast<GLint>(programObject->GetActiveAttribType(index)));
|
||||
break;
|
||||
case GL_ARRAY_SIZE:
|
||||
writeValue(programObject->GetActiveAttribArraySize(index));
|
||||
break;
|
||||
case GL_LOCATION:
|
||||
writeValue(programObject->GetAttributeLocation(resourceName));
|
||||
break;
|
||||
case GL_REFERENCED_BY_VERTEX_SHADER:
|
||||
writeValue(GL_TRUE);
|
||||
break;
|
||||
case GL_REFERENCED_BY_FRAGMENT_SHADER:
|
||||
case GL_REFERENCED_BY_COMPUTE_SHADER:
|
||||
case GL_REFERENCED_BY_GEOMETRY_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
|
||||
case GL_IS_PER_PATCH:
|
||||
case GL_LOCATION_INDEX:
|
||||
writeValue(0);
|
||||
break;
|
||||
default:
|
||||
writeValue(0);
|
||||
break;
|
||||
}
|
||||
} else if (programInterface == GL_PROGRAM_OUTPUT &&
|
||||
index < static_cast<GLuint>(programObject->GetActiveFragmentOutputCount())) {
|
||||
const auto& resourceName = programObject->GetActiveFragmentOutputName(index);
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
writeValue(static_cast<GLint>(resourceName.size() + 1));
|
||||
break;
|
||||
case GL_TYPE:
|
||||
writeValue(static_cast<GLint>(programObject->GetFragmentOutputType(index)));
|
||||
break;
|
||||
case GL_ARRAY_SIZE:
|
||||
writeValue(programObject->GetActiveFragmentOutputArraySize(index));
|
||||
break;
|
||||
case GL_LOCATION:
|
||||
writeValue(programObject->GetFragmentOutputLocation(index));
|
||||
break;
|
||||
case GL_LOCATION_INDEX:
|
||||
writeValue(0);
|
||||
break;
|
||||
case GL_REFERENCED_BY_FRAGMENT_SHADER:
|
||||
writeValue(GL_TRUE);
|
||||
break;
|
||||
case GL_REFERENCED_BY_VERTEX_SHADER:
|
||||
case GL_REFERENCED_BY_COMPUTE_SHADER:
|
||||
case GL_REFERENCED_BY_GEOMETRY_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
|
||||
case GL_IS_PER_PATCH:
|
||||
writeValue(0);
|
||||
break;
|
||||
default:
|
||||
writeValue(0);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
writeValue(0);
|
||||
}
|
||||
}
|
||||
if (length) *length = written;
|
||||
}
|
||||
|
||||
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject || !name) return -1;
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
return programObject->GetUniformLocation(name);
|
||||
}
|
||||
if (programInterface == GL_PROGRAM_INPUT) {
|
||||
return programObject->GetAttributeLocation(name);
|
||||
}
|
||||
if (programInterface == GL_PROGRAM_OUTPUT) {
|
||||
return GetProgramOutputLocation(*programObject, name);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject || !name) return -1;
|
||||
if (programInterface == GL_PROGRAM_OUTPUT) {
|
||||
return GetProgramOutputLocation(*programObject, name) >= 0 ? 0 : -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding) {
|
||||
auto* programObject = TryGetDirectVulkanProgram(program);
|
||||
if (!programObject) return;
|
||||
auto& cache = GetProgramResourceCache(*programObject);
|
||||
if (!programObject || storageBlockName == nullptr) return;
|
||||
const Int maxBindings = pActiveBackendObject
|
||||
? pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings
|
||||
: 0;
|
||||
@@ -1234,13 +797,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
MakeUnique<GenericErrorInfo>("DirectVulkan", __func__, "Shader storage binding is out of range."));
|
||||
return;
|
||||
}
|
||||
if (storageBlockIndex >= cache.storageBlocks.size()) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("DirectVulkan", __func__, "Shader storage block index is not active."));
|
||||
return;
|
||||
}
|
||||
cache.storageBlocks[storageBlockIndex].binding = storageBlockBinding;
|
||||
// The frontend already validated that the name denotes an active block, and has
|
||||
// already recorded the new binding on the program - which is what reseeds this cache
|
||||
// whenever it is rebuilt. Writing the entry here as well keeps an ALREADY-BUILT cache
|
||||
// (the common case: the very next draw reads it) from having to be thrown away.
|
||||
auto& cache = GetProgramResourceCache(*programObject);
|
||||
const GLuint blockIndex = GetShaderStorageBlockIndex(*programObject, storageBlockName);
|
||||
if (blockIndex == GL_INVALID_INDEX) return;
|
||||
cache.storageBlocks[blockIndex].binding = storageBlockBinding;
|
||||
}
|
||||
void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) {
|
||||
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ReadPixels called with null VulkanRenderer");
|
||||
@@ -1393,31 +957,66 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
pVulkanRenderer->MultiDrawArrays(payload);
|
||||
}
|
||||
|
||||
// Shared body of glMultiDrawElements (basevertex == nullptr) and
|
||||
// glMultiDrawElementsBaseVertex: identical calls except for the per-draw
|
||||
// vertex offset, which VkMultiDrawIndexedInfoEXT / VkDrawIndexedIndirectCommand /
|
||||
// vkCmdDrawIndexed all carry natively.
|
||||
static void MultiDrawElementsImpl(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
|
||||
GLsizei drawcount, const GLint* basevertex) {
|
||||
if (drawcount <= 0) {
|
||||
return;
|
||||
}
|
||||
MultiDrawIndexedCmd payload{};
|
||||
payload.mode = mode;
|
||||
payload.indexBufferView.indexType = type;
|
||||
|
||||
// Loop-invariant: the index type is fixed for the whole multi-draw, so resolve
|
||||
// its byte size once instead of twice per sub-draw (a cross-TU switch that
|
||||
// showed up in per-frame profiles of sodium-style 132x32 multi-draws). Index
|
||||
// sizes are 1/2/4, so the per-sub-draw offset division below reduces to a
|
||||
// shift - the hardware divide was the hottest instruction of this loop.
|
||||
const SizeT indexSize = MG_Util::GetGLTypeSize(type);
|
||||
if (indexSize == 0) {
|
||||
MGLOG_E("MultiDrawElements skipped: unsupported index type 0x%x", type);
|
||||
return;
|
||||
}
|
||||
const Uint32 indexSizeShift = static_cast<Uint32>(std::countr_zero(indexSize));
|
||||
|
||||
// TODO: allocate draw cmd buf elsewhere
|
||||
static Vector<DrawIndexedCmdParam> params;
|
||||
params.clear();
|
||||
params.resize(drawcount);
|
||||
|
||||
for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
if (count[i] == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: this index view needs a redesign, now there's a lotta redundant uploads
|
||||
|
||||
payload.indexBufferView.indexByteOffset = 0;
|
||||
payload.indexBufferView.indexByteSize =
|
||||
std::max(reinterpret_cast<SizeT>(indices[i]) + count[i] * indexSize,
|
||||
payload.indexBufferView.indexByteSize);
|
||||
|
||||
auto& param = params[i];
|
||||
|
||||
param.indexCount = count[i];
|
||||
param.instanceCount = 1;
|
||||
param.firstIndex = reinterpret_cast<SizeT>(indices[i]) >> indexSizeShift;
|
||||
param.vertexOffset = basevertex != nullptr ? basevertex[i] : 0;
|
||||
param.firstInstance = 0;
|
||||
}
|
||||
payload.drawCount = drawcount;
|
||||
payload.pParams = params.data();
|
||||
pVulkanRenderer->MultiDrawElements(payload);
|
||||
}
|
||||
|
||||
void MultiDrawElements(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
|
||||
GLsizei drawcount) {
|
||||
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElements called with null VulkanRenderer");
|
||||
MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElements called with null GL context");
|
||||
|
||||
// Vector<DrawElementCmd> cmds;
|
||||
// cmds.reserve(static_cast<SizeT>(drawcount));
|
||||
// for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
// if (count[i] == 0) {
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// DrawElementCmd payload{};
|
||||
// payload.mode = mode;
|
||||
// payload.firstVertex = 0;
|
||||
// payload.indexCount = count[i];
|
||||
// payload.indexType = type;
|
||||
// payload.indexByteOffset = reinterpret_cast<SizeT>(indices[i]);
|
||||
// cmds.push_back(payload);
|
||||
// }
|
||||
//
|
||||
// if (cmds.empty()) {
|
||||
// return;
|
||||
// }
|
||||
// pVulkanRenderer->MultiDrawElements(cmds);
|
||||
MultiDrawElementsImpl(mode, count, type, indices, drawcount, nullptr);
|
||||
}
|
||||
|
||||
void DrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, const GLvoid* indices, GLint basevertex) {
|
||||
@@ -1445,52 +1044,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
|
||||
void MultiDrawElementsBaseVertex(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
|
||||
GLsizei drawcount, const GLint* basevertex) {
|
||||
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElements called with null VulkanRenderer");
|
||||
MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElements called with null GL context");
|
||||
MultiDrawIndexedCmd payload{};
|
||||
payload.mode = mode;
|
||||
payload.indexBufferView.indexType = type;
|
||||
|
||||
// Loop-invariant: the index type is fixed for the whole multi-draw, so resolve
|
||||
// its byte size once instead of twice per sub-draw (a cross-TU switch that
|
||||
// showed up in per-frame profiles of sodium-style 132x32 multi-draws). Index
|
||||
// sizes are 1/2/4, so the per-sub-draw offset division below reduces to a
|
||||
// shift - the hardware divide was the hottest instruction of this loop.
|
||||
const SizeT indexSize = MG_Util::GetGLTypeSize(type);
|
||||
if (indexSize == 0) {
|
||||
MGLOG_E("MultiDrawElementsBaseVertex skipped: unsupported index type 0x%x", type);
|
||||
return;
|
||||
}
|
||||
const Uint32 indexSizeShift = static_cast<Uint32>(std::countr_zero(indexSize));
|
||||
|
||||
// TODO: allocate draw cmd buf elsewhere
|
||||
static Vector<DrawIndexedCmdParam> params;
|
||||
params.clear();
|
||||
params.resize(drawcount);
|
||||
|
||||
for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
if (count[i] == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: this index view needs a redesign, now there's a lotta redundant uploads
|
||||
|
||||
payload.indexBufferView.indexByteOffset = 0;
|
||||
payload.indexBufferView.indexByteSize =
|
||||
std::max(reinterpret_cast<SizeT>(indices[i]) + count[i] * indexSize,
|
||||
payload.indexBufferView.indexByteSize);
|
||||
|
||||
auto& param = params[i];
|
||||
|
||||
param.indexCount = count[i];
|
||||
param.instanceCount = 1;
|
||||
param.firstIndex = reinterpret_cast<SizeT>(indices[i]) >> indexSizeShift;
|
||||
param.vertexOffset = basevertex[i];
|
||||
param.firstInstance = 0;
|
||||
}
|
||||
payload.drawCount = drawcount;
|
||||
payload.pParams = params.data();
|
||||
pVulkanRenderer->MultiDrawElements(payload);
|
||||
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElementsBaseVertex called with null VulkanRenderer");
|
||||
MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElementsBaseVertex called with null GL context");
|
||||
MultiDrawElementsImpl(mode, count, type, indices, drawcount, basevertex);
|
||||
}
|
||||
|
||||
void BlitFramebuffer(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1,
|
||||
|
||||
@@ -97,15 +97,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
void GetIntegeri_v(GLenum target, GLuint index, GLint* data);
|
||||
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
|
||||
void GetProgramiv(GLuint program, GLenum pname, GLint* params);
|
||||
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params);
|
||||
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize,
|
||||
GLsizei* length, GLchar* name);
|
||||
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
|
||||
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params);
|
||||
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name);
|
||||
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding);
|
||||
void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding);
|
||||
void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels);
|
||||
void GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels);
|
||||
void GetTextureImage(const SharedPtr<MG_State::GLState::ITextureObject>& texture, TextureUploadTarget uploadTarget,
|
||||
|
||||
@@ -116,6 +116,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
m_frameCount = frameCount;
|
||||
m_maxBindings = maxBindings;
|
||||
m_samplerResolveMemo.assign(m_maxBindings, SamplerResolveMemo{});
|
||||
// Every entry is freshly constructed (all-invalid), so nothing needs sweeping until
|
||||
// a resolve writes one.
|
||||
m_samplerResolveMemoHighWater = 0;
|
||||
m_setsPerFrame = setsPerFrame;
|
||||
m_peakDescriptorSetsObserved = 0;
|
||||
m_textureManager = textureManager;
|
||||
@@ -174,6 +177,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
m_minDynamicOffsetAlignment = 1;
|
||||
m_frameCount = 0;
|
||||
m_maxBindings = 0;
|
||||
m_samplerResolveMemo.clear();
|
||||
m_samplerResolveMemoHighWater = 0;
|
||||
m_setsPerFrame = 0;
|
||||
m_peakDescriptorSetsObserved = 0;
|
||||
m_textureManager = nullptr;
|
||||
@@ -210,10 +215,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
m_fastRebindMemo.valid = false;
|
||||
m_lastBindValid = false;
|
||||
// Re-fingerprint the bound sampler set fresh this frame so any GL object address
|
||||
// reuse cannot outlive a single frame (see SamplerResolveMemo).
|
||||
for (auto& memo : m_samplerResolveMemo) {
|
||||
memo.valid = false;
|
||||
memo.infoValid = false;
|
||||
// reuse cannot outlive a single frame (see SamplerResolveMemo). Only the entries a
|
||||
// resolve has actually written can be valid, so the high-water mark bounds the
|
||||
// sweep - the vector itself is sized to the device's binding cap (256 here), which
|
||||
// is ~30x more entries than any program declares.
|
||||
const Uint32 touchedBindings =
|
||||
std::min<Uint32>(m_samplerResolveMemoHighWater, static_cast<Uint32>(m_samplerResolveMemo.size()));
|
||||
for (Uint32 binding = 0; binding < touchedBindings; ++binding) {
|
||||
m_samplerResolveMemo[binding].valid = false;
|
||||
m_samplerResolveMemo[binding].infoValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +389,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
viewFormatMemo->viewFormatDomain = numericDomain;
|
||||
viewFormatMemo->viewFormat = sampledViewFormat;
|
||||
viewFormatMemo->viewFormatValid = true;
|
||||
NoteSamplerResolveMemoTouched(binding);
|
||||
}
|
||||
}
|
||||
if (sampledViewFormat == VK_FORMAT_UNDEFINED) {
|
||||
@@ -434,6 +445,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
memo.viewLevelCount = viewLevelCount;
|
||||
memo.sampler = resolvedSampler;
|
||||
memo.valid = true;
|
||||
NoteSamplerResolveMemoTouched(binding);
|
||||
}
|
||||
} else {
|
||||
resolvedSampler = m_samplerManager->GetOrCreateSampler(*samplerToUse, *texture, forceNearestFiltering,
|
||||
@@ -450,6 +462,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
if (binding < m_samplerResolveMemo.size()) {
|
||||
m_samplerResolveMemo[binding].info = outImageInfo;
|
||||
m_samplerResolveMemo[binding].infoValid = true;
|
||||
NoteSamplerResolveMemoTouched(binding);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -345,5 +345,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Bool infoValid = false;
|
||||
};
|
||||
mutable Vector<SamplerResolveMemo> m_samplerResolveMemo;
|
||||
// Exclusive upper bound on the entries of m_samplerResolveMemo that any resolve
|
||||
// has ever written. The vector is sized to the DEVICE binding cap (256 on desktop
|
||||
// NVIDIA), but a program declares 1-8 bindings, so the per-frame reset below was
|
||||
// memsetting ~22 KB of never-touched entries every frame - a measurable slice of
|
||||
// the per-frame fixed cost on draw-light frames. Every site that can turn any of
|
||||
// an entry's *Valid flags on raises this mark first, so entries at or above it are
|
||||
// provably still in their constructed (all-invalid) state and clearing them is a
|
||||
// no-op. Never lowered except by Initialize/Shutdown, which rebuild the vector.
|
||||
mutable Uint32 m_samplerResolveMemoHighWater = 0;
|
||||
void NoteSamplerResolveMemoTouched(Uint32 binding) const {
|
||||
if (binding >= m_samplerResolveMemoHighWater) {
|
||||
m_samplerResolveMemoHighWater = binding + 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
} // namespace MobileGL::MG_Backend::DirectVulkan
|
||||
|
||||
@@ -33,14 +33,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.IsBgra, sizeof(attr.IsBgra)));
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Divisor, sizeof(attr.Divisor)));
|
||||
|
||||
// The buffer's heap address is an identity component of the key: a freed
|
||||
// buffer's reused address can alias an old cache entry, but only under a
|
||||
// byte-identical attribute layout - and the entry payload is a pure function
|
||||
// of the hashed inputs, with the draw path re-resolving bindingBufferKeys
|
||||
// against the live VAO attribute pointers, so an aliased hit returns exactly
|
||||
// what a rebuild would. Address drift only grows the map; the OnFrameBoundary
|
||||
// aging sweep bounds that.
|
||||
const SizeT bufferKey = reinterpret_cast<SizeT>(attr.Buffer.get());
|
||||
// The bound buffer's IDENTITY is a component of the key, and it has to be the
|
||||
// buffer's never-reused lifetime id - NOT its heap address, which this used to
|
||||
// hash. An address is recycled by the allocator, so a deleted-and-recreated
|
||||
// buffer reproduces it; combined with a byte-identical attribute layout that
|
||||
// reproduces the WHOLE content hash, and the hash is what
|
||||
// TryBindResolvedVertexBindings accepts as proof that a memoised binding still
|
||||
// reads the buffer it was resolved from. It did not: a destroyed buffer's GPU
|
||||
// slice was bound for its successor's draw, which is how a transform-feedback
|
||||
// capture came back holding a dead VAO's vertex data (0,0,0,1 - the previous
|
||||
// test's positions) instead of its own.
|
||||
// Zero for client memory (no buffer), which is a distinct identity of its own.
|
||||
const Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0;
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey)));
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
struct BackendVertexInputState {
|
||||
HashType hash = 0;
|
||||
// Hash of the resolved Vulkan vertex layout only (bindings, attributes,
|
||||
// unsupported mask) - NO buffer identities. `hash` mixes buffer heap
|
||||
// addresses so per-chunk VBOs mint a fresh identity per buffer; keying
|
||||
// pipelines on that minted one VkPipeline per chunk section for an
|
||||
// identical layout, defeating pipeline reuse and the per-draw memo.
|
||||
// Pipelines depend only on the layout, so they key on this instead.
|
||||
// unsupported mask) - NO buffer identities. `hash` mixes each bound
|
||||
// buffer's never-reused LIFETIME ID, so per-chunk VBOs mint a fresh
|
||||
// identity per buffer; keying pipelines on that minted one VkPipeline per
|
||||
// chunk section for an identical layout, defeating pipeline reuse and the
|
||||
// per-draw memo. Pipelines depend only on the layout, so they key on this
|
||||
// instead.
|
||||
HashType layoutHash = 0;
|
||||
// Frame boundary of the last cache hit; entries idle past the
|
||||
// OnFrameBoundary retirement age are evicted (CPU heap only).
|
||||
@@ -86,8 +87,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
const MG_State::GLState::VertexArrayObject& vao, HashType hash);
|
||||
const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao);
|
||||
// Frame boundary hook: ages the cache and evicts entries not hit for many
|
||||
// frames. The key mixes buffer heap addresses, so buffer/VAO churn keeps
|
||||
// minting fresh keys; without eviction the map grows for the whole session.
|
||||
// frames. The key mixes each bound buffer's never-reused lifetime id, so
|
||||
// buffer/VAO churn keeps minting fresh keys - and does so by construction,
|
||||
// not by luck: a recreated buffer can no longer land back on its dead
|
||||
// predecessor's key. Without eviction the map grows for the whole session.
|
||||
// Entries hold no Vulkan handles (pipeline creation copies the descriptions)
|
||||
// and the draw path's entry reference never spans a frame boundary, so
|
||||
// eviction here needs no GPU-idle proof. Self-gated: one counter bump and
|
||||
|
||||
@@ -2305,6 +2305,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
IntVec3 regionLo = {0, 0, 0};
|
||||
IntVec3 regionSize = {0, 0, 0};
|
||||
SizeT texelBytes = 0;
|
||||
// Scatter refinement of the single dirty box: when the storage's rect
|
||||
// list reports the writes' true footprint (~100 sprites whose union box
|
||||
// spans the whole atlas), each rect is staged tightly and copied with
|
||||
// its own VkBufferImageCopy in ONE vkCmdCopyBufferToImage. Empty means
|
||||
// "stage the one box above". Only set while subRegion.
|
||||
Vector<MG_State::GLState::MipmapDirtyRegion> rects;
|
||||
};
|
||||
|
||||
Vector<UploadItem> uploadItems;
|
||||
@@ -2368,6 +2374,23 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
static_cast<SizeT>(uploadItem.regionSize.y()) *
|
||||
static_cast<SizeT>(uploadItem.regionSize.z()) *
|
||||
uploadItem.texelBytes;
|
||||
// Scatter refinement: the storage only hands out its rect list
|
||||
// when the rects' summed area is materially smaller than the
|
||||
// union box (0 otherwise), so taking it always stages fewer
|
||||
// bytes than the box - the very amplification this path exists
|
||||
// to avoid paying twice.
|
||||
MG_State::GLState::MipmapDirtyRegion
|
||||
dirtyRects[MG_State::GLState::MipmapStorage::kMaxDirtyRects];
|
||||
const SizeT dirtyRectCount = mipmapTexture.GetStorageDirtyRects(
|
||||
target, level, dirtyRects, MG_State::GLState::MipmapStorage::kMaxDirtyRects);
|
||||
if (dirtyRectCount >= 2) {
|
||||
uploadItem.rects.assign(dirtyRects, dirtyRects + dirtyRectCount);
|
||||
SizeT rectTexels = 0;
|
||||
for (const auto& rect : uploadItem.rects) {
|
||||
rectTexels += rect.TexelCount();
|
||||
}
|
||||
uploadItem.uploadByteSize = rectTexels * uploadItem.texelBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (formatInfo.expandRgbToRgba) {
|
||||
@@ -2534,22 +2557,37 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
std::memcpy(dst, item.source, item.uploadByteSize);
|
||||
continue;
|
||||
}
|
||||
// Tight-pack the dirty box: the shadow keeps whole-level rows, the
|
||||
// staging slice holds only the region (bufferRowLength stays 0).
|
||||
// Tight-pack the dirty box(es): the shadow keeps whole-level rows, the
|
||||
// staging slice holds only the region (bufferRowLength stays 0). Multi-
|
||||
// rect items pack their rects back to back in list order; the copy loop
|
||||
// below recomputes the same running offsets.
|
||||
const SizeT levelRowBytes = static_cast<SizeT>(item.texelSize.x()) * item.texelBytes;
|
||||
const SizeT levelSliceBytes = static_cast<SizeT>(item.texelSize.y()) * levelRowBytes;
|
||||
const SizeT regionRowBytes = static_cast<SizeT>(item.regionSize.x()) * item.texelBytes;
|
||||
const Uint8* src = static_cast<const Uint8*>(item.source);
|
||||
for (Int z = 0; z < item.regionSize.z(); ++z) {
|
||||
for (Int y = 0; y < item.regionSize.y(); ++y) {
|
||||
const Uint8* srcRow = src +
|
||||
static_cast<SizeT>(item.regionLo.z() + z) * levelSliceBytes +
|
||||
static_cast<SizeT>(item.regionLo.y() + y) * levelRowBytes +
|
||||
static_cast<SizeT>(item.regionLo.x()) * item.texelBytes;
|
||||
std::memcpy(dst + (static_cast<SizeT>(z) * item.regionSize.y() + y) * regionRowBytes,
|
||||
srcRow, regionRowBytes);
|
||||
const auto packBox = [&](Uint8* out, const IntVec3& lo, const IntVec3& boxSize) {
|
||||
const SizeT boxRowBytes = static_cast<SizeT>(boxSize.x()) * item.texelBytes;
|
||||
for (Int z = 0; z < boxSize.z(); ++z) {
|
||||
for (Int y = 0; y < boxSize.y(); ++y) {
|
||||
const Uint8* srcRow = src + static_cast<SizeT>(lo.z() + z) * levelSliceBytes +
|
||||
static_cast<SizeT>(lo.y() + y) * levelRowBytes +
|
||||
static_cast<SizeT>(lo.x()) * item.texelBytes;
|
||||
std::memcpy(out + (static_cast<SizeT>(z) * static_cast<SizeT>(boxSize.y()) + y) *
|
||||
boxRowBytes,
|
||||
srcRow, boxRowBytes);
|
||||
}
|
||||
}
|
||||
return static_cast<SizeT>(boxSize.x()) * static_cast<SizeT>(boxSize.y()) *
|
||||
static_cast<SizeT>(boxSize.z()) * item.texelBytes;
|
||||
};
|
||||
if (!item.rects.empty()) {
|
||||
for (const auto& rect : item.rects) {
|
||||
dst += packBox(dst, rect.lo,
|
||||
IntVec3{rect.hi.x() - rect.lo.x(), rect.hi.y() - rect.lo.y(),
|
||||
rect.hi.z() - rect.lo.z()});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
packBox(dst, item.regionLo, item.regionSize);
|
||||
}
|
||||
|
||||
const VkImageAspectFlags aspectMask = GetAspectMaskForFormat(outResource.format);
|
||||
@@ -2573,6 +2611,46 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
outResource.viewType == VK_IMAGE_VIEW_TYPE_2D_ARRAY ||
|
||||
outResource.viewType == VK_IMAGE_VIEW_TYPE_CUBE_ARRAY;
|
||||
for (const auto& item : uploadItems) {
|
||||
if (!item.rects.empty()) {
|
||||
// Multi-rect item: one VkBufferImageCopy per rect, all submitted in a
|
||||
// single vkCmdCopyBufferToImage. The rect list is pairwise disjoint by
|
||||
// construction, so no two copies write the same texels. Multi-rect
|
||||
// implies subRegion, which implies a plain color aspect - the combined
|
||||
// depth-stencil split below can never see one of these.
|
||||
VkBufferImageCopy rectCopies[MG_State::GLState::MipmapStorage::kMaxDirtyRects];
|
||||
Uint32 rectCopyCount = 0;
|
||||
VkDeviceSize runningOffset = item.offset;
|
||||
for (const auto& rect : item.rects) {
|
||||
const IntVec3 rectSize = {rect.hi.x() - rect.lo.x(), rect.hi.y() - rect.lo.y(),
|
||||
rect.hi.z() - rect.lo.z()};
|
||||
const Uint32 rectDepth = static_cast<Uint32>(std::max(rectSize.z(), 1));
|
||||
VkBufferImageCopy rectCopy{};
|
||||
rectCopy.bufferOffset = stagingBase + runningOffset;
|
||||
rectCopy.bufferRowLength = 0;
|
||||
rectCopy.bufferImageHeight = 0;
|
||||
rectCopy.imageSubresource.aspectMask = aspectMask;
|
||||
rectCopy.imageSubresource.mipLevel = item.level;
|
||||
rectCopy.imageSubresource.baseArrayLayer = item.baseArrayLayer;
|
||||
rectCopy.imageSubresource.layerCount = 1;
|
||||
rectCopy.imageOffset = {rect.lo.x(), rect.lo.y(),
|
||||
depthSelectsArrayLayer ? 0 : rect.lo.z()};
|
||||
rectCopy.imageExtent = {static_cast<Uint32>(rectSize.x()),
|
||||
static_cast<Uint32>(rectSize.y()),
|
||||
depthSelectsArrayLayer ? 1u : rectDepth};
|
||||
if (depthSelectsArrayLayer) {
|
||||
// The GL "depth" axis addresses array layers here, so a partial
|
||||
// z-range narrows the layer span rather than the extent.
|
||||
rectCopy.imageSubresource.baseArrayLayer =
|
||||
item.baseArrayLayer + static_cast<Uint32>(rect.lo.z());
|
||||
rectCopy.imageSubresource.layerCount = rectDepth;
|
||||
}
|
||||
rectCopies[rectCopyCount++] = rectCopy;
|
||||
runningOffset += static_cast<VkDeviceSize>(rect.TexelCount() * item.texelBytes);
|
||||
}
|
||||
vkCmdCopyBufferToImage(commandBuffer, stagingBuffer, outResource.image,
|
||||
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, rectCopyCount, rectCopies);
|
||||
continue;
|
||||
}
|
||||
const Uint32 depthOrLayers = item.texelSize.z() > 0 ? static_cast<Uint32>(item.texelSize.z()) : 1u;
|
||||
VkBufferImageCopy copy{};
|
||||
copy.bufferOffset = stagingBase + item.offset;
|
||||
|
||||
@@ -273,12 +273,76 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// re-derive the exact values already applied on this command buffer. The
|
||||
// remaining input, the swapchain pre-transform, cannot change mid-recording
|
||||
// (a swapchain recreate retires the command buffer, and recording begin resets
|
||||
// this whole shadow).
|
||||
// this whole shadow); the value key below pins it anyway.
|
||||
Bool dynamicTailValid = false;
|
||||
Uint dynamicTailParamsVersion = 0;
|
||||
Int dynamicTailExtentX = 0;
|
||||
Int dynamicTailExtentY = 0;
|
||||
Bool dynamicTailIsDefaultFbo = false;
|
||||
// VALUE key over the tail's inputs, as a second-level gate behind the version.
|
||||
// The parameters version is ONE counter for all of RenderState, so anything that
|
||||
// is not tail input - a GL_BLEND toggle, a glBlendFuncSeparate, a glColorMask -
|
||||
// moves it and forced a full tail re-run. Blaze3D toggles blend around every
|
||||
// batch, so that was a per-draw re-derivation of six dynamic states that could
|
||||
// not have changed. Equal key => the six Apply* below would each re-derive the
|
||||
// value their shadow already holds and emit nothing, so the tail is skippable.
|
||||
//
|
||||
// Complete input inventory of ApplyDynamicDrawStateTail, one line per reader
|
||||
// (each accessor it replaces is a verified plain field read of the same
|
||||
// RenderStateParameters field - RenderState.cpp):
|
||||
// ApplyGLViewportState : Viewport, DepthRange, + extent/isDefaultFbo/preTransform
|
||||
// ApplyBlendConstants : BlendColor
|
||||
// ApplyPolygonOffsetState : PolygonOffsetUnits, PolygonOffsetFactor
|
||||
// ApplyLineWidthState : LineWidth (see the caveat below)
|
||||
// ApplyStencilState : StencilStates[0..1].{ValueMask, WriteMask, Ref}
|
||||
// scissor rect : ScissorTestEnabled, ScissorBox,
|
||||
// + extent/isDefaultFbo/preTransform
|
||||
// Caveat, unchanged from the version-only gate: ApplyLineWidthState also clamps
|
||||
// to the ACTIVE BACKEND OBJECT's aliased line-width range. Those are device
|
||||
// limits queried once at backend init and constant for the renderer's lifetime,
|
||||
// so they are not part of the key (the version gate never covered them either).
|
||||
struct DynamicTailKey {
|
||||
Int viewport[4] = {0, 0, 0, 0};
|
||||
Float depthRange[2] = {0.0f, 0.0f};
|
||||
Float blendColor[4] = {0.0f, 0.0f, 0.0f, 0.0f};
|
||||
Float polygonOffsetFactor = 0.0f;
|
||||
Float polygonOffsetUnits = 0.0f;
|
||||
Float lineWidth = 0.0f;
|
||||
Uint32 stencilValueMask[2] = {0, 0};
|
||||
Uint32 stencilWriteMask[2] = {0, 0};
|
||||
Int stencilRef[2] = {0, 0};
|
||||
Int scissorBox[4] = {0, 0, 0, 0};
|
||||
Int extentX = 0;
|
||||
Int extentY = 0;
|
||||
Uint32 preTransform = 0;
|
||||
Bool scissorEnabled = false;
|
||||
Bool isDefaultFbo = false;
|
||||
|
||||
Bool operator==(const DynamicTailKey& other) const {
|
||||
// NaN in any float input makes this false, which only costs a redundant
|
||||
// tail run - never a skipped one.
|
||||
for (Uint32 i = 0; i < 4; ++i) {
|
||||
if (viewport[i] != other.viewport[i] || blendColor[i] != other.blendColor[i] ||
|
||||
scissorBox[i] != other.scissorBox[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (Uint32 i = 0; i < 2; ++i) {
|
||||
if (depthRange[i] != other.depthRange[i] ||
|
||||
stencilValueMask[i] != other.stencilValueMask[i] ||
|
||||
stencilWriteMask[i] != other.stencilWriteMask[i] ||
|
||||
stencilRef[i] != other.stencilRef[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return polygonOffsetFactor == other.polygonOffsetFactor &&
|
||||
polygonOffsetUnits == other.polygonOffsetUnits && lineWidth == other.lineWidth &&
|
||||
extentX == other.extentX && extentY == other.extentY &&
|
||||
preTransform == other.preTransform && scissorEnabled == other.scissorEnabled &&
|
||||
isDefaultFbo == other.isDefaultFbo;
|
||||
}
|
||||
};
|
||||
DynamicTailKey dynamicTailKey{};
|
||||
};
|
||||
static DynamicStateShadow g_dynamicStateShadow;
|
||||
|
||||
@@ -2924,6 +2988,8 @@ void main() {
|
||||
m_device = VK_NULL_HANDLE;
|
||||
}
|
||||
s_vkCmdDrawIndexedIndirectCount = nullptr;
|
||||
s_vkCmdDrawMultiEXT = nullptr;
|
||||
s_vkCmdDrawMultiIndexedEXT = nullptr;
|
||||
|
||||
if (m_instance != VK_NULL_HANDLE && m_surface != VK_NULL_HANDLE) {
|
||||
vkDestroySurfaceKHR(m_instance, m_surface, nullptr);
|
||||
@@ -3114,48 +3180,17 @@ void main() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cross-frame revalidation. Only all-resident, unmapped layouts qualify:
|
||||
// - a streamed binding's slice moves to a new arena block every frame BY DESIGN,
|
||||
// but every such move funnels through BumpSliceEpoch, so the per-binding epoch
|
||||
// compares below catch it (as they do a respecify, a sub-data update, a
|
||||
// resident<->streamed promotion and a buffer becoming persistently mapped);
|
||||
// - a mapped buffer mutates its shadow with no API call and must re-run the
|
||||
// Sync/acquire path every frame, so it is excluded outright;
|
||||
// - epochs are minted from a process-lifetime counter, so a deleted buffer (or
|
||||
// resource) recycled at the same address can never reproduce a recorded epoch.
|
||||
if (entry.anyBufferMapped) {
|
||||
// NO cross-frame trust: a memo recorded in an earlier frame declines here and
|
||||
// the draw re-resolves through the full acquire path. The epoch-compare
|
||||
// revalidation that used to sit here shipped visible corruption (journeymap /
|
||||
// common-mods retraces, vertex anomalies on Adreno): the acquire path is the
|
||||
// frame's content-sync point, and skipping it across frames trusted the
|
||||
// BumpSliceEpoch inventory to cover every way a buffer's GPU copy can go stale.
|
||||
// At least one path escapes it. Until that inventory is proven complete the
|
||||
// hot layout memo above (same-frame) keeps the factory-chase win, and the
|
||||
// first draw of each (VAO, frame) pays one full resolve.
|
||||
return false;
|
||||
}
|
||||
const auto& attributes = vao.GetAllAttributes();
|
||||
VkBufferResource* resources[ResolvedVertexBindings::kMaxBindings] = {};
|
||||
for (Uint32 binding = 0; binding < entry.bindingCount; ++binding) {
|
||||
// Compare against the LIVE pointer before any dereference: entry.buffers may
|
||||
// dangle if the app deleted a buffer since (the VAO unbind that deletion
|
||||
// performs bumps the config version, so the hash compare above already
|
||||
// declined - this is defense in depth for the aliased-address case).
|
||||
auto* bufferObject = attributes[entry.attributeLocations[binding]].Buffer.get();
|
||||
if (bufferObject != entry.buffers[binding]) {
|
||||
return false;
|
||||
}
|
||||
auto* resource = static_cast<VkBufferResource*>(bufferObject->GetBackendResource().get());
|
||||
if (resource == nullptr || resource->sliceEpoch != entry.sliceEpochs[binding]) {
|
||||
return false;
|
||||
}
|
||||
resources[binding] = resource;
|
||||
}
|
||||
// Every binding still resolves to the recorded slice. The per-frame resolve this
|
||||
// replaces had one side effect the busy tracking depends on (glBufferSubData's
|
||||
// host-write-vs-staged-copy choice): stamping each resource's GPU-use serial.
|
||||
// Do exactly that, then re-arm the entry so the rest of the frame's draws take
|
||||
// the one-compare path above.
|
||||
for (Uint32 binding = 0; binding < entry.bindingCount; ++binding) {
|
||||
resources[binding]->lastUseSerial = frameSerial;
|
||||
}
|
||||
entry.frameSerial = frameSerial;
|
||||
entry.sliceEpochCounter = m_bufferManager.GetSliceEpochCounter();
|
||||
ShadowedBindVertexBuffers(commandBuffer, entry.vkBuffers, entry.vkOffsets, entry.bindingCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
VulkanRenderer::VaoDrawMemo* VulkanRenderer::LookupVaoDrawMemo(
|
||||
const MG_State::GLState::VertexArrayObject* vao) {
|
||||
@@ -3166,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
|
||||
@@ -3182,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
|
||||
@@ -3709,18 +3750,10 @@ void main() {
|
||||
if (indexMemo->indexFrameSerial == frameSerial &&
|
||||
indexMemo->indexSliceEpochCounter == m_bufferManager.GetSliceEpochCounter()) {
|
||||
sliceStillValid = true;
|
||||
} else {
|
||||
auto* resource = static_cast<VkBufferResource*>(
|
||||
indexBufferShared->GetBackendResource().get());
|
||||
if (resource != nullptr && resource->sliceEpoch == indexMemo->indexSliceEpoch) {
|
||||
sliceStillValid = true;
|
||||
// Same busy-tracking stamp the skipped acquire would have made,
|
||||
// then re-arm the one-compare path for the rest of the frame.
|
||||
resource->lastUseSerial = frameSerial;
|
||||
indexMemo->indexFrameSerial = frameSerial;
|
||||
indexMemo->indexSliceEpochCounter = m_bufferManager.GetSliceEpochCounter();
|
||||
}
|
||||
}
|
||||
// NO cross-frame trust for the EBO either (same corruption class as the
|
||||
// vertex half, see TryBindResolvedVertexBindings): a memo from an earlier
|
||||
// frame declines and the draw re-runs the acquire, which is the sync point.
|
||||
if (sliceStillValid) {
|
||||
const VkDeviceSize memoBindOffset = indexMemo->indexSliceOffset +
|
||||
static_cast<VkDeviceSize>(pIndexBufferView->indexByteOffset);
|
||||
@@ -4448,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;
|
||||
@@ -5088,17 +5122,60 @@ void main() {
|
||||
shadow.dynamicTailIsDefaultFbo == isDefaultFbo) {
|
||||
return;
|
||||
}
|
||||
ApplyGLViewportState(frame.commandBuffer, extent, m_swapchainObject.GetPreTransform(), isDefaultFbo);
|
||||
const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform();
|
||||
// Second-level VALUE gate: the version moved, but RenderState's version counts
|
||||
// every parameter, most of which this tail never reads. Build the key over
|
||||
// exactly the tail's inputs (inventory in DynamicTailKey) out of one bulk
|
||||
// parameters fetch and compare; an equal key means every Apply* below would
|
||||
// re-derive the value its shadow already holds.
|
||||
DynamicStateShadow::DynamicTailKey key;
|
||||
{
|
||||
const RenderStateParameters& p = MG_State::pGLContext->GetRenderStateParameters();
|
||||
key.viewport[0] = p.Viewport.x();
|
||||
key.viewport[1] = p.Viewport.y();
|
||||
key.viewport[2] = p.Viewport.z();
|
||||
key.viewport[3] = p.Viewport.w();
|
||||
key.depthRange[0] = p.DepthRange.x();
|
||||
key.depthRange[1] = p.DepthRange.y();
|
||||
key.blendColor[0] = p.BlendColor.x();
|
||||
key.blendColor[1] = p.BlendColor.y();
|
||||
key.blendColor[2] = p.BlendColor.z();
|
||||
key.blendColor[3] = p.BlendColor.w();
|
||||
key.polygonOffsetFactor = p.PolygonOffsetFactor;
|
||||
key.polygonOffsetUnits = p.PolygonOffsetUnits;
|
||||
key.lineWidth = p.LineWidth;
|
||||
// StencilStates[0] is Front, [1] is Back (RenderState::GetStencilFaceIndex),
|
||||
// the same order ApplyStencilState reads them in.
|
||||
for (Uint32 face = 0; face < 2; ++face) {
|
||||
key.stencilValueMask[face] = p.StencilStates[face].ValueMask;
|
||||
key.stencilWriteMask[face] = p.StencilStates[face].WriteMask;
|
||||
key.stencilRef[face] = p.StencilStates[face].Ref;
|
||||
}
|
||||
key.scissorEnabled = p.ScissorTestEnabled;
|
||||
key.scissorBox[0] = p.ScissorBox.x();
|
||||
key.scissorBox[1] = p.ScissorBox.y();
|
||||
key.scissorBox[2] = p.ScissorBox.z();
|
||||
key.scissorBox[3] = p.ScissorBox.w();
|
||||
key.extentX = extent.x();
|
||||
key.extentY = extent.y();
|
||||
key.preTransform = static_cast<Uint32>(preTransform);
|
||||
key.isDefaultFbo = isDefaultFbo;
|
||||
}
|
||||
if (shadow.dynamicTailValid && shadow.dynamicTailKey == key) {
|
||||
// Re-arm the cheap version gate so an unchanged-parameters run of draws after
|
||||
// this one costs the four-integer compare again.
|
||||
shadow.dynamicTailParamsVersion = paramsVersion;
|
||||
return;
|
||||
}
|
||||
ApplyGLViewportState(frame.commandBuffer, extent, preTransform, isDefaultFbo);
|
||||
ApplyBlendConstants(frame.commandBuffer);
|
||||
ApplyPolygonOffsetState(frame.commandBuffer);
|
||||
ApplyLineWidthState(frame.commandBuffer);
|
||||
ApplyStencilState(frame.commandBuffer);
|
||||
const Bool scissorEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ScissorTest);
|
||||
VkRect2D scissor{};
|
||||
if (scissorEnabled) {
|
||||
const auto& scissorBox = MG_State::pGLContext->GetScissorBox();
|
||||
scissor = isDefaultFbo
|
||||
? MakeDefaultFramebufferScissorRect(scissorBox, extent, m_swapchainObject.GetPreTransform())
|
||||
if (key.scissorEnabled) {
|
||||
const IntVec4 scissorBox(key.scissorBox[0], key.scissorBox[1], key.scissorBox[2], key.scissorBox[3]);
|
||||
scissor = isDefaultFbo ? MakeDefaultFramebufferScissorRect(scissorBox, extent, preTransform)
|
||||
: MakeClampedScissorRect(scissorBox, extent);
|
||||
} else {
|
||||
scissor.offset = {0, 0};
|
||||
@@ -5110,16 +5187,34 @@ void main() {
|
||||
shadow.dynamicTailExtentX = extent.x();
|
||||
shadow.dynamicTailExtentY = extent.y();
|
||||
shadow.dynamicTailIsDefaultFbo = isDefaultFbo;
|
||||
shadow.dynamicTailKey = key;
|
||||
}
|
||||
|
||||
Uint32 VulkanRenderer::GetBaseTransformFlagsRaw() {
|
||||
// GetShaderTransformFlags is a pure function of the pre-transform, which only
|
||||
// changes on surface rotation - memoised so the per-draw path pays one field
|
||||
// compare instead of the call + switch.
|
||||
Uint32 VulkanRenderer::GetBaseTransformFlagsRaw(Bool isDefaultFbo) {
|
||||
// GetShaderTransformFlags is a function of the pre-transform AND of whether
|
||||
// the bound draw framebuffer is the default one (the Y-flip/rotation bits
|
||||
// apply only when presenting). Memo keyed on both; keying on the
|
||||
// pre-transform alone served an FBO pass's unflipped flags to the following
|
||||
// default-framebuffer pass and flipped the whole frame.
|
||||
// isDefaultFbo is supplied by the caller: every draw-path caller has already
|
||||
// resolved the bound draw framebuffer (and its default-ness) for its own
|
||||
// guards, and re-walking the binding slot + the virtual IsDefaultFramebuffer
|
||||
// per draw showed up in the profile. Callers MUST pass the value derived from
|
||||
// the SAME draw-framebuffer binding the draw uses - see the assert below.
|
||||
MOBILEGL_ASSERT(
|
||||
[&] {
|
||||
const auto& fbo =
|
||||
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
|
||||
return isDefaultFbo == (fbo != nullptr && fbo->IsDefaultFramebuffer());
|
||||
}(),
|
||||
"GetBaseTransformFlagsRaw: isDefaultFbo does not match the bound draw framebuffer");
|
||||
const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform();
|
||||
if (preTransform != m_baseTransformFlagsPreTransform) {
|
||||
if (!m_baseTransformFlagsKeyValid || preTransform != m_baseTransformFlagsPreTransform ||
|
||||
isDefaultFbo != m_baseTransformFlagsIsDefaultFbo) {
|
||||
m_baseTransformFlagsCache = GetShaderTransformFlags(preTransform).GetRaw();
|
||||
m_baseTransformFlagsPreTransform = preTransform;
|
||||
m_baseTransformFlagsIsDefaultFbo = isDefaultFbo;
|
||||
m_baseTransformFlagsKeyValid = true;
|
||||
}
|
||||
return m_baseTransformFlagsCache;
|
||||
}
|
||||
@@ -5127,10 +5222,34 @@ void main() {
|
||||
Bool VulkanRenderer::TrySetupDrawFastPath(FrameContext::FrameData& frame, GLenum mode,
|
||||
Flags<DrawSetupAspect> aspects, const DrawCmdParam& drawParams,
|
||||
const IndexBufferView* pIndexBufferView) {
|
||||
SetupDrawSnapshot& snap = m_setupDrawSnapshot;
|
||||
if (!snap.valid || !frame.isCommandRecording) {
|
||||
if (!frame.isCommandRecording) {
|
||||
return false;
|
||||
}
|
||||
// Entry select: by the draw program's lifetime id, MRU first (the id pins
|
||||
// the entry; every other fact is re-guarded below, so probing a stale
|
||||
// entry can only decline, never serve stale state).
|
||||
const auto& program = *MG_State::pGLContext->GetProgramForDraw();
|
||||
const Uint64 programLifetimeId = program.GetLifetimeId();
|
||||
SetupDrawSnapshot* snapPtr = nullptr;
|
||||
{
|
||||
SetupDrawSnapshot& mru = m_setupDrawSnapshots[m_setupDrawSnapshotMru];
|
||||
if (mru.valid && mru.programLifetimeId == programLifetimeId) {
|
||||
snapPtr = &mru;
|
||||
} else {
|
||||
for (Uint32 i = 0; i < kSetupDrawSnapshotCount; ++i) {
|
||||
SetupDrawSnapshot& candidate = m_setupDrawSnapshots[i];
|
||||
if (candidate.valid && candidate.programLifetimeId == programLifetimeId) {
|
||||
snapPtr = &candidate;
|
||||
m_setupDrawSnapshotMru = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (snapPtr == nullptr) {
|
||||
return false;
|
||||
}
|
||||
SetupDrawSnapshot& snap = *snapPtr;
|
||||
if (snap.aspects != aspects.GetRaw() || snap.mode != mode) {
|
||||
return false;
|
||||
}
|
||||
@@ -5142,9 +5261,7 @@ void main() {
|
||||
snap.imageIndex != m_imageIndexAcquired) {
|
||||
return false;
|
||||
}
|
||||
const auto& program = *MG_State::pGLContext->GetProgramForDraw();
|
||||
if (program.GetLifetimeId() != snap.programLifetimeId ||
|
||||
program.GetBackendStateVersion() != snap.programVersion) {
|
||||
if (program.GetBackendStateVersion() != snap.programVersion) {
|
||||
return false;
|
||||
}
|
||||
// A changed VAO does NOT decline: the VAO only feeds the pipeline's vertex
|
||||
@@ -5155,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 ||
|
||||
@@ -5182,7 +5300,10 @@ void main() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (GetBaseTransformFlagsRaw() != snap.baseTransformFlags) {
|
||||
// The FBO identity+version compare above proved this draw's framebuffer is the
|
||||
// snapshotting draw's, so its default-ness is the snapshot's too - no second walk
|
||||
// of the binding slot and no virtual IsDefaultFramebuffer call.
|
||||
if (GetBaseTransformFlagsRaw(snap.drawFboIsDefault) != snap.baseTransformFlags) {
|
||||
return false;
|
||||
}
|
||||
if (m_textureManager->GetResourceEraseEpoch() != snap.textureEraseEpoch ||
|
||||
@@ -5221,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;
|
||||
@@ -5282,7 +5405,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
if (bindsMoved &&
|
||||
!m_uniformManager->SampledBindingsUnchanged(program, programObj, m_sampledBindingRecordsScratch)) {
|
||||
!m_uniformManager->SampledBindingsUnchanged(program, programObj, snap.sampledBindingRecords)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5291,8 +5414,11 @@ void main() {
|
||||
// sampleable, then stamp recording use exactly as the full path would.
|
||||
// A feedback case (sampled texture written by the active pass) fails the
|
||||
// layout check and falls back to the full path's end-pass handling.
|
||||
const auto& sampledTextures = m_sampledTexturesScratch;
|
||||
const auto& sampledResources = m_sampledResourcesScratch;
|
||||
// The ENTRY's copies, not the scratch vectors: with more than one entry
|
||||
// the scratch holds only the last full-path draw's set, which may belong
|
||||
// to a different program.
|
||||
const auto& sampledTextures = snap.sampledTextures;
|
||||
const auto& sampledResources = snap.sampledResources;
|
||||
if (sampledResources.size() != sampledTextures.size()) {
|
||||
return false;
|
||||
}
|
||||
@@ -5306,7 +5432,7 @@ void main() {
|
||||
// generation means SampledBindingsUnchanged proved the per-binding (texture,
|
||||
// sampler) pairs identical, and the sums/generation checks below cover every
|
||||
// remaining descriptor input.
|
||||
const Bool layoutSnapshotUsable = m_sampledLayoutSnapshots.size() == sampledTextures.size();
|
||||
const Bool layoutSnapshotUsable = snap.sampledLayouts.size() == sampledTextures.size();
|
||||
Bool samplerDescriptorsUnchanged = layoutSnapshotUsable;
|
||||
for (SizeT i = 0; i < sampledTextures.size(); ++i) {
|
||||
const auto* sampledTexture = sampledTextures[i];
|
||||
@@ -5317,8 +5443,8 @@ void main() {
|
||||
if (resource == nullptr || !IsValidSampledImageLayout(resource->layout)) {
|
||||
return false;
|
||||
}
|
||||
if (layoutSnapshotUsable && m_sampledLayoutSnapshots[i] != resource->layout) {
|
||||
m_sampledLayoutSnapshots[i] = resource->layout;
|
||||
if (layoutSnapshotUsable && snap.sampledLayouts[i] != resource->layout) {
|
||||
snap.sampledLayouts[i] = resource->layout;
|
||||
samplerDescriptorsUnchanged = false;
|
||||
}
|
||||
contentSum += sampledTexture->GetContentVersion();
|
||||
@@ -5399,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;
|
||||
@@ -5437,19 +5564,54 @@ void main() {
|
||||
if (TrySetupDrawFastPath(frame, mode, aspects, drawParams, pIndexBufferView)) {
|
||||
return true;
|
||||
}
|
||||
// The fast path declined: whatever it saw may be stale. The full path
|
||||
// below re-resolves everything and refreshes the snapshot on success.
|
||||
m_setupDrawSnapshot.valid = false;
|
||||
const auto& drawFbo =
|
||||
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
|
||||
if (drawFbo != nullptr && IsUnsupportedFramebufferForDirectVulkan(*drawFbo)) {
|
||||
// Nothing was mutated: other entries' per-probe guards (FBO identity +
|
||||
// version among them) stay authoritative, so none need invalidating.
|
||||
RecordUnsupportedFramebufferError(__func__);
|
||||
return false;
|
||||
}
|
||||
const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();
|
||||
const auto& program = *MG_State::pGLContext->GetProgramForDraw();
|
||||
// The fast path declined (or had no entry for this program): whatever THIS
|
||||
// program's entry saw may be stale, and the full path below mutates state as
|
||||
// it goes, so the entry must not stay matchable if that path fails mid-way.
|
||||
// Select it now - the program's own entry when one exists, else an invalid
|
||||
// slot, else a round-robin victim - and invalidate it until the successful
|
||||
// refill at the end. Other programs' entries keep their validity: every fact
|
||||
// they carry is re-guarded per probe (live pass hash, epochs, versions,
|
||||
// sums), so a full path run in between can only make them decline.
|
||||
SetupDrawSnapshot* fillSnap = nullptr;
|
||||
{
|
||||
Uint32 fillIndex = kSetupDrawSnapshotCount;
|
||||
const Uint64 fillProgramLifetimeId = program.GetLifetimeId();
|
||||
for (Uint32 i = 0; i < kSetupDrawSnapshotCount; ++i) {
|
||||
if (m_setupDrawSnapshots[i].valid &&
|
||||
m_setupDrawSnapshots[i].programLifetimeId == fillProgramLifetimeId) {
|
||||
fillIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fillIndex == kSetupDrawSnapshotCount) {
|
||||
for (Uint32 i = 0; i < kSetupDrawSnapshotCount; ++i) {
|
||||
if (!m_setupDrawSnapshots[i].valid) {
|
||||
fillIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fillIndex == kSetupDrawSnapshotCount) {
|
||||
fillIndex = m_setupDrawSnapshotVictim;
|
||||
m_setupDrawSnapshotVictim = (m_setupDrawSnapshotVictim + 1) % kSetupDrawSnapshotCount;
|
||||
}
|
||||
fillSnap = &m_setupDrawSnapshots[fillIndex];
|
||||
fillSnap->valid = false;
|
||||
m_setupDrawSnapshotMru = fillIndex;
|
||||
}
|
||||
const Bool drawFboIsDefault = drawFbo != nullptr && drawFbo->IsDefaultFramebuffer();
|
||||
ProgramFactory::CompileOptionFlags transformFlags =
|
||||
ProgramFactory::CompileOptionFlags(GetBaseTransformFlagsRaw());
|
||||
ProgramFactory::CompileOptionFlags(GetBaseTransformFlagsRaw(drawFboIsDefault));
|
||||
// Captured draws take the xfb-decorated program variant.
|
||||
if (m_transformFeedbackFeatureEnabled && MG_State::pGLContext->IsTransformFeedbackActive() &&
|
||||
program.GetTransformFeedbackVaryingCount() > 0) {
|
||||
@@ -5773,7 +5935,7 @@ void main() {
|
||||
// Snapshot the fully resolved configuration for the consecutive-draw
|
||||
// fast path (see TrySetupDrawFastPath).
|
||||
{
|
||||
auto& snap = m_setupDrawSnapshot;
|
||||
auto& snap = *fillSnap;
|
||||
const auto* nowActiveRenderPass = VkRenderPassManager::GetActiveRenderPass();
|
||||
if (nowActiveRenderPass != nullptr && !programObj.hasStorageImages) {
|
||||
snap.valid = true;
|
||||
@@ -5782,13 +5944,14 @@ void main() {
|
||||
snap.programLifetimeId = program.GetLifetimeId();
|
||||
snap.programVersion = program.GetBackendStateVersion();
|
||||
snap.vao = &vao;
|
||||
snap.vaoLifetimeId = vao.GetLifetimeId();
|
||||
snap.vaoConfigVersion = vao.GetConfigVersion();
|
||||
snap.drawFbo = drawFbo.get();
|
||||
snap.fboVersion = drawFbo->GetObjectVersion();
|
||||
snap.drawFboIsDefault = drawFbo->IsDefaultFramebuffer();
|
||||
snap.drawFboIsDefault = drawFboIsDefault;
|
||||
snap.renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion();
|
||||
snap.bindGeneration = MG_State::pGLContext->GetTextureBindGeneration();
|
||||
snap.baseTransformFlags = GetBaseTransformFlagsRaw();
|
||||
snap.baseTransformFlags = GetBaseTransformFlagsRaw(drawFboIsDefault);
|
||||
snap.resolvedTransformFlags = transformFlags.GetRaw();
|
||||
snap.renderPassHash = nowActiveRenderPass->hash;
|
||||
snap.imageIndex = m_imageIndexAcquired;
|
||||
@@ -5815,10 +5978,15 @@ void main() {
|
||||
snap.samplingResolutionGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();
|
||||
Uint64 snapContentSum = 0;
|
||||
Uint64 snapParamsSum = 0;
|
||||
// Per-entry copies of this draw's sampled set (the scratch vectors
|
||||
// will be overwritten by the next full-path draw of ANY program).
|
||||
// Record each resource's layout VALUE for the descriptor-reuse hint;
|
||||
// transitions above updated the resources in place, so this reads the
|
||||
// layouts the descriptors just resolved against.
|
||||
m_sampledLayoutSnapshots.assign(sampledTextures.size(), VK_IMAGE_LAYOUT_UNDEFINED);
|
||||
snap.sampledTextures = sampledTextures;
|
||||
snap.sampledResources = sampledResources;
|
||||
snap.sampledBindingRecords = m_sampledBindingRecordsScratch;
|
||||
snap.sampledLayouts.assign(sampledTextures.size(), VK_IMAGE_LAYOUT_UNDEFINED);
|
||||
for (SizeT i = 0; i < sampledTextures.size(); ++i) {
|
||||
const auto* sampledTexture = sampledTextures[i];
|
||||
if (sampledTexture == nullptr) {
|
||||
@@ -5827,7 +5995,7 @@ void main() {
|
||||
snapContentSum += sampledTexture->GetContentVersion();
|
||||
snapParamsSum += sampledTexture->GetTextureParamsVersion();
|
||||
if (sampledResources[i] != nullptr) {
|
||||
m_sampledLayoutSnapshots[i] = sampledResources[i]->layout;
|
||||
snap.sampledLayouts[i] = sampledResources[i]->layout;
|
||||
}
|
||||
}
|
||||
snap.sampledContentSum = snapContentSum;
|
||||
@@ -9408,14 +9576,191 @@ void main() {
|
||||
|
||||
MOBILEGL_ASSERT(frame.isCommandRecording, "%s: frame recording was not started", __func__);
|
||||
|
||||
VkCommandBuffer& commandBuffer = frame.commandBuffer;
|
||||
EmitMultiDraw(frame.commandBuffer, payload.pParams, payload.drawCount);
|
||||
}
|
||||
|
||||
for (Uint32 idraw = 0; idraw < payload.drawCount; ++idraw) {
|
||||
vkCmdDraw(commandBuffer,
|
||||
payload.pParams[idraw].vertexCount,
|
||||
payload.pParams[idraw].instanceCount,
|
||||
payload.pParams[idraw].firstVertex,
|
||||
payload.pParams[idraw].firstInstance);
|
||||
// The tier-2 indirect batch uploads the param arrays as-is: the leading members of the
|
||||
// renderer's draw-parameter structs are exactly Vulkan's indirect command layouts, and
|
||||
// vkCmdDraw(Indexed)Indirect accepts any 4-aligned stride >= the command size, so the
|
||||
// trailing CPU-side metadata rides along unread instead of forcing a repack.
|
||||
static_assert(sizeof(DrawIndexedCmdParam) == sizeof(VkDrawIndexedIndirectCommand) &&
|
||||
offsetof(DrawIndexedCmdParam, indexCount) == offsetof(VkDrawIndexedIndirectCommand, indexCount) &&
|
||||
offsetof(DrawIndexedCmdParam, instanceCount) ==
|
||||
offsetof(VkDrawIndexedIndirectCommand, instanceCount) &&
|
||||
offsetof(DrawIndexedCmdParam, firstIndex) == offsetof(VkDrawIndexedIndirectCommand, firstIndex) &&
|
||||
offsetof(DrawIndexedCmdParam, vertexOffset) ==
|
||||
offsetof(VkDrawIndexedIndirectCommand, vertexOffset) &&
|
||||
offsetof(DrawIndexedCmdParam, firstInstance) ==
|
||||
offsetof(VkDrawIndexedIndirectCommand, firstInstance),
|
||||
"DrawIndexedCmdParam must alias VkDrawIndexedIndirectCommand for the tier-2 multi-draw upload");
|
||||
static_assert(sizeof(DrawCmdParam) % 4 == 0 && sizeof(DrawCmdParam) >= sizeof(VkDrawIndirectCommand) &&
|
||||
offsetof(DrawCmdParam, vertexCount) == offsetof(VkDrawIndirectCommand, vertexCount) &&
|
||||
offsetof(DrawCmdParam, instanceCount) == offsetof(VkDrawIndirectCommand, instanceCount) &&
|
||||
offsetof(DrawCmdParam, firstVertex) == offsetof(VkDrawIndirectCommand, firstVertex) &&
|
||||
offsetof(DrawCmdParam, firstInstance) == offsetof(VkDrawIndirectCommand, firstInstance),
|
||||
"DrawCmdParam must lead with VkDrawIndirectCommand for the tier-2 multi-draw upload");
|
||||
|
||||
void VulkanRenderer::EmitMultiDraw(VkCommandBuffer commandBuffer, const DrawCmdParam* pParams, Uint32 drawCount) {
|
||||
if (drawCount == 0) {
|
||||
return;
|
||||
}
|
||||
if (drawCount == 1) {
|
||||
vkCmdDraw(commandBuffer, pParams[0].vertexCount, pParams[0].instanceCount, pParams[0].firstVertex,
|
||||
pParams[0].firstInstance);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tier 1: VK_EXT_multi_draw. vkCmdDrawMultiEXT shares one instanceCount/firstInstance
|
||||
// across the whole batch, so the batch must be uniform in both (GL's glMultiDrawArrays
|
||||
// always is: 1/0).
|
||||
if (m_multiDrawAllowExt) {
|
||||
Bool uniformInstances = true;
|
||||
for (Uint32 idraw = 1; idraw < drawCount; ++idraw) {
|
||||
if (pParams[idraw].instanceCount != pParams[0].instanceCount ||
|
||||
pParams[idraw].firstInstance != pParams[0].firstInstance) {
|
||||
uniformInstances = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (uniformInstances) {
|
||||
static Vector<VkMultiDrawInfoEXT> infos;
|
||||
infos.resize(drawCount);
|
||||
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
|
||||
infos[idraw].firstVertex = pParams[idraw].firstVertex;
|
||||
infos[idraw].vertexCount = pParams[idraw].vertexCount;
|
||||
}
|
||||
for (Uint32 base = 0; base < drawCount; base += m_maxMultiDrawCount) {
|
||||
const Uint32 chunk = std::min(drawCount - base, m_maxMultiDrawCount);
|
||||
s_vkCmdDrawMultiEXT(commandBuffer, chunk, infos.data() + base, pParams[0].instanceCount,
|
||||
pParams[0].firstInstance, sizeof(VkMultiDrawInfoEXT));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: multiDrawIndirect - one vkCmdDrawIndirect over a transient command array.
|
||||
// A sub-draw with firstInstance != 0 is illegal in an indirect command without the
|
||||
// drawIndirectFirstInstance feature; such a batch falls to the unrolled tier.
|
||||
if (m_multiDrawAllowIndirect) {
|
||||
Bool firstInstanceLegal = m_drawIndirectFirstInstanceFeatureEnabled;
|
||||
if (!firstInstanceLegal) {
|
||||
firstInstanceLegal = true;
|
||||
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
|
||||
if (pParams[idraw].firstInstance != 0) {
|
||||
firstInstanceLegal = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const Uint32 maxIndirectCount = m_physicalDevice.properties.limits.maxDrawIndirectCount;
|
||||
if (firstInstanceLegal && maxIndirectCount > 0) {
|
||||
BufferSlice commandSlice{};
|
||||
if (m_bufferManager.UploadTransient(BufferKind::Indirect, m_frameContext.GetCurrentFrameIndex(),
|
||||
pParams,
|
||||
static_cast<VkDeviceSize>(drawCount) * sizeof(DrawCmdParam),
|
||||
sizeof(Uint32), commandSlice)) {
|
||||
for (Uint32 base = 0; base < drawCount; base += maxIndirectCount) {
|
||||
const Uint32 chunk = std::min(drawCount - base, maxIndirectCount);
|
||||
vkCmdDrawIndirect(commandBuffer, commandSlice.buffer,
|
||||
commandSlice.offset +
|
||||
static_cast<VkDeviceSize>(base) * sizeof(DrawCmdParam),
|
||||
chunk, sizeof(DrawCmdParam));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Transient arena refused the upload: fall through to the unrolled tier.
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: unrolled loop, byte-identical fallback (and the only tier where a SPIR-V
|
||||
// DrawIndex consumer sees 0 for every sub-draw instead of the sub-draw index).
|
||||
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
|
||||
vkCmdDraw(commandBuffer, pParams[idraw].vertexCount, pParams[idraw].instanceCount,
|
||||
pParams[idraw].firstVertex, pParams[idraw].firstInstance);
|
||||
}
|
||||
}
|
||||
|
||||
void VulkanRenderer::EmitMultiDrawIndexed(VkCommandBuffer commandBuffer, const DrawIndexedCmdParam* pParams,
|
||||
Uint32 drawCount) {
|
||||
if (drawCount == 0) {
|
||||
return;
|
||||
}
|
||||
if (drawCount == 1) {
|
||||
vkCmdDrawIndexed(commandBuffer, pParams[0].indexCount, pParams[0].instanceCount, pParams[0].firstIndex,
|
||||
pParams[0].vertexOffset, pParams[0].firstInstance);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tier 1: VK_EXT_multi_draw. VkMultiDrawIndexedInfoEXT carries per-draw
|
||||
// firstIndex/indexCount/vertexOffset (pVertexOffset = nullptr keeps the per-draw
|
||||
// offsets), but instanceCount/firstInstance are batch-wide, so the batch must be
|
||||
// uniform in both (GL's glMultiDrawElements* always is: 1/0).
|
||||
if (m_multiDrawAllowExt) {
|
||||
Bool uniformInstances = true;
|
||||
for (Uint32 idraw = 1; idraw < drawCount; ++idraw) {
|
||||
if (pParams[idraw].instanceCount != pParams[0].instanceCount ||
|
||||
pParams[idraw].firstInstance != pParams[0].firstInstance) {
|
||||
uniformInstances = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (uniformInstances) {
|
||||
static Vector<VkMultiDrawIndexedInfoEXT> infos;
|
||||
infos.resize(drawCount);
|
||||
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
|
||||
infos[idraw].firstIndex = pParams[idraw].firstIndex;
|
||||
infos[idraw].indexCount = pParams[idraw].indexCount;
|
||||
infos[idraw].vertexOffset = pParams[idraw].vertexOffset;
|
||||
}
|
||||
for (Uint32 base = 0; base < drawCount; base += m_maxMultiDrawCount) {
|
||||
const Uint32 chunk = std::min(drawCount - base, m_maxMultiDrawCount);
|
||||
s_vkCmdDrawMultiIndexedEXT(commandBuffer, chunk, infos.data() + base,
|
||||
pParams[0].instanceCount,
|
||||
static_cast<Uint32>(pParams[0].firstInstance),
|
||||
sizeof(VkMultiDrawIndexedInfoEXT), nullptr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: multiDrawIndirect - one vkCmdDrawIndexedIndirect over a transient command
|
||||
// array (DrawIndexedCmdParam aliases VkDrawIndexedIndirectCommand, see static_assert).
|
||||
if (m_multiDrawAllowIndirect) {
|
||||
Bool firstInstanceLegal = m_drawIndirectFirstInstanceFeatureEnabled;
|
||||
if (!firstInstanceLegal) {
|
||||
firstInstanceLegal = true;
|
||||
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
|
||||
if (pParams[idraw].firstInstance != 0) {
|
||||
firstInstanceLegal = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const Uint32 maxIndirectCount = m_physicalDevice.properties.limits.maxDrawIndirectCount;
|
||||
if (firstInstanceLegal && maxIndirectCount > 0) {
|
||||
BufferSlice commandSlice{};
|
||||
if (m_bufferManager.UploadTransient(BufferKind::Indirect, m_frameContext.GetCurrentFrameIndex(),
|
||||
pParams,
|
||||
static_cast<VkDeviceSize>(drawCount) *
|
||||
sizeof(DrawIndexedCmdParam),
|
||||
sizeof(Uint32), commandSlice)) {
|
||||
for (Uint32 base = 0; base < drawCount; base += maxIndirectCount) {
|
||||
const Uint32 chunk = std::min(drawCount - base, maxIndirectCount);
|
||||
vkCmdDrawIndexedIndirect(commandBuffer, commandSlice.buffer,
|
||||
commandSlice.offset +
|
||||
static_cast<VkDeviceSize>(base) * sizeof(DrawIndexedCmdParam),
|
||||
chunk, sizeof(DrawIndexedCmdParam));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: unrolled loop, byte-identical fallback (and the only tier where a SPIR-V
|
||||
// DrawIndex consumer sees 0 for every sub-draw instead of the sub-draw index).
|
||||
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
|
||||
vkCmdDrawIndexed(commandBuffer, pParams[idraw].indexCount, pParams[idraw].instanceCount,
|
||||
pParams[idraw].firstIndex, pParams[idraw].vertexOffset, pParams[idraw].firstInstance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9437,16 +9782,76 @@ void main() {
|
||||
|
||||
MOBILEGL_ASSERT(frame.isCommandRecording, "%s: frame recording was not started", __func__);
|
||||
|
||||
VkCommandBuffer& commandBuffer = frame.commandBuffer;
|
||||
|
||||
for (Uint32 idraw = 0; idraw < payload.drawCount; ++idraw) {
|
||||
vkCmdDrawIndexed(commandBuffer,
|
||||
payload.pParams[idraw].indexCount,
|
||||
payload.pParams[idraw].instanceCount,
|
||||
payload.pParams[idraw].firstIndex,
|
||||
payload.pParams[idraw].vertexOffset,
|
||||
payload.pParams[idraw].firstInstance);
|
||||
// Collapse contiguous sub-draw runs BEFORE tier dispatch: merging shrinks the
|
||||
// param span every tier consumes (fewer VkMultiDrawIndexedInfoEXT entries, a
|
||||
// smaller transient command array, fewer unrolled vkCmdDrawIndexed). Per-sub-draw
|
||||
// command emission in the driver dominates a Sodium-shaped multi-draw
|
||||
// (steady-state profile: >60% of the case inside the Vulkan driver's
|
||||
// vkCmdDrawIndexed encoding for 132x32 sub-draws/frame), and a chunk
|
||||
// renderer's sub-draws are runs of adjacent index ranges over one buffer.
|
||||
// Two draws are one iff they concatenate to an identical index stream:
|
||||
// - a LIST topology (points/lines/triangles). Strips/fans/loops would
|
||||
// weld primitives across the seam.
|
||||
// - the accumulated count ends on a primitive boundary, otherwise GL
|
||||
// discards the dangling indices at the sub-draw's end but the merged
|
||||
// stream would assemble them with the next sub-draw's indices.
|
||||
// - primitive restart is off: with restart on, a sentinel mid-stream
|
||||
// resets assembly, so a partial primitive before the seam would
|
||||
// otherwise be discarded per sub-draw (same dangling-index argument).
|
||||
// - identical baseVertex/instancing and firstIndex adjacency, so the
|
||||
// merged range fetches exactly the two sub-draws' indices in order.
|
||||
Uint32 mergeGranularity = 0;
|
||||
switch (payload.mode) {
|
||||
case GL_POINTS: mergeGranularity = 1; break;
|
||||
case GL_LINES: mergeGranularity = 2; break;
|
||||
case GL_TRIANGLES: mergeGranularity = 3; break;
|
||||
default: break;
|
||||
}
|
||||
if (mergeGranularity != 0) {
|
||||
const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters();
|
||||
if (rsp.PrimitiveRestartEnabled || rsp.PrimitiveRestartFixedIndexEnabled) {
|
||||
mergeGranularity = 0;
|
||||
}
|
||||
}
|
||||
const DrawIndexedCmdParam* pParams = payload.pParams;
|
||||
Uint32 drawCount = payload.drawCount;
|
||||
static Vector<DrawIndexedCmdParam> mergedParams;
|
||||
if (mergeGranularity != 0) {
|
||||
mergedParams.clear();
|
||||
mergedParams.reserve(drawCount);
|
||||
Uint32 idraw = 0;
|
||||
while (idraw < drawCount) {
|
||||
DrawIndexedCmdParam head = pParams[idraw];
|
||||
++idraw;
|
||||
if (head.indexCount == 0) {
|
||||
continue; // draws nothing, contributes nothing to a run
|
||||
}
|
||||
if (head.instanceCount == 1) {
|
||||
while (idraw < drawCount) {
|
||||
const DrawIndexedCmdParam& next = pParams[idraw];
|
||||
if (next.indexCount == 0) {
|
||||
++idraw;
|
||||
continue;
|
||||
}
|
||||
if (head.indexCount % mergeGranularity != 0 ||
|
||||
next.instanceCount != 1 ||
|
||||
next.vertexOffset != head.vertexOffset ||
|
||||
next.firstInstance != head.firstInstance ||
|
||||
next.firstIndex != head.firstIndex + head.indexCount ||
|
||||
head.indexCount + next.indexCount < head.indexCount) {
|
||||
break;
|
||||
}
|
||||
head.indexCount += next.indexCount;
|
||||
++idraw;
|
||||
}
|
||||
}
|
||||
mergedParams.push_back(head);
|
||||
}
|
||||
pParams = mergedParams.data();
|
||||
drawCount = static_cast<Uint32>(mergedParams.size());
|
||||
}
|
||||
|
||||
EmitMultiDrawIndexed(frame.commandBuffer, pParams, drawCount);
|
||||
}
|
||||
|
||||
// Byte size of the command structures GL defines for the indirect draws (GL 4.6 core
|
||||
@@ -9622,7 +10027,8 @@ void main() {
|
||||
|
||||
MOBILEGL_ASSERT(frame.isCommandRecording, "%s: frame recording was not started", __func__);
|
||||
MGLOG_D("DirectVulkan: glMultiDrawElementsIndirect(drawcount=%d stride=%d)", drawcount, stride);
|
||||
if (drawcount == 1 || (m_multiDrawIndirectFeatureEnabled && stride % 4 == 0)) {
|
||||
if (drawcount == 1 ||
|
||||
(!m_multiDrawForceUnrollIndirect && m_multiDrawIndirectFeatureEnabled && stride % 4 == 0)) {
|
||||
vkCmdDrawIndexedIndirect(frame.commandBuffer,
|
||||
drawSlice.buffer,
|
||||
drawSlice.offset + static_cast<VkDeviceSize>(commandOffset),
|
||||
@@ -9685,7 +10091,8 @@ void main() {
|
||||
|
||||
MOBILEGL_ASSERT(frame.isCommandRecording, "%s: frame recording was not started", __func__);
|
||||
MGLOG_D("DirectVulkan: glMultiDrawArraysIndirect(drawcount=%d stride=%d)", drawcount, stride);
|
||||
if (drawcount == 1 || (m_multiDrawIndirectFeatureEnabled && stride % 4 == 0)) {
|
||||
if (drawcount == 1 ||
|
||||
(!m_multiDrawForceUnrollIndirect && m_multiDrawIndirectFeatureEnabled && stride % 4 == 0)) {
|
||||
vkCmdDrawIndirect(frame.commandBuffer,
|
||||
drawSlice.buffer,
|
||||
drawSlice.offset + static_cast<VkDeviceSize>(commandOffset),
|
||||
@@ -10105,7 +10512,7 @@ void main() {
|
||||
void VulkanRenderer::OnFrameCommandRecordingBegan(VkCommandBuffer commandBuffer) {
|
||||
// Dynamic state does not survive a command-buffer boundary.
|
||||
ResetDynamicStateShadow();
|
||||
m_setupDrawSnapshot.valid = false;
|
||||
InvalidateSetupDrawSnapshots();
|
||||
if (m_uniformManager) {
|
||||
m_uniformManager->OnCommandBufferBoundary();
|
||||
}
|
||||
@@ -10271,7 +10678,7 @@ void main() {
|
||||
// A recreated pipeline could reuse a freed handle value and alias
|
||||
// the bind-dedup shadow; force the next draw to re-bind.
|
||||
g_dynamicStateShadow.graphicsPipelineValid = false;
|
||||
m_setupDrawSnapshot.valid = false;
|
||||
InvalidateSetupDrawSnapshots();
|
||||
}
|
||||
m_vertexInputStateFactory->OnFrameBoundary();
|
||||
m_samplerManager->OnFrameBoundary();
|
||||
@@ -10871,6 +11278,7 @@ void main() {
|
||||
supportedDeviceFeatures.shaderStorageImageWriteWithoutFormat);
|
||||
}
|
||||
deviceFeatures.drawIndirectFirstInstance = supportedDeviceFeatures.drawIndirectFirstInstance;
|
||||
m_drawIndirectFirstInstanceFeatureEnabled = deviceFeatures.drawIndirectFirstInstance == VK_TRUE;
|
||||
deviceFeatures.multiDrawIndirect = supportedDeviceFeatures.multiDrawIndirect;
|
||||
m_multiDrawIndirectFeatureEnabled = deviceFeatures.multiDrawIndirect == VK_TRUE;
|
||||
m_logicOpFeatureEnabled = deviceFeatures.logicOp == VK_TRUE;
|
||||
@@ -11152,6 +11560,56 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// VK_EXT_multi_draw: tier 1 of the multi-draw dispatch - one vkCmdDrawMulti(Indexed)EXT
|
||||
// for a whole glMultiDraw* batch (VkMultiDrawIndexedInfoEXT carries per-draw
|
||||
// firstIndex/indexCount/vertexOffset, so glMultiDrawElementsBaseVertex fits natively).
|
||||
// Requested only when both the extension and its multiDraw feature are present;
|
||||
// absent it, the dispatch falls to the multiDrawIndirect tier or the unrolled loop.
|
||||
m_multiDrawExtensionEnabled = false;
|
||||
m_maxMultiDrawCount = 0;
|
||||
VkPhysicalDeviceMultiDrawFeaturesEXT multiDrawFeatures{};
|
||||
multiDrawFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MULTI_DRAW_FEATURES_EXT;
|
||||
if (IsExtensionSupported(availableExtensions, VK_EXT_MULTI_DRAW_EXTENSION_NAME) &&
|
||||
getPhysicalDeviceFeatures2 != nullptr) {
|
||||
VkPhysicalDeviceFeatures2 featureQuery{};
|
||||
featureQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
|
||||
featureQuery.pNext = &multiDrawFeatures;
|
||||
getPhysicalDeviceFeatures2(m_physicalDevice.handle, &featureQuery);
|
||||
if (multiDrawFeatures.multiDraw == VK_TRUE) {
|
||||
if (!IsExtensionAlreadyEnabled(enabledDeviceExtensions, VK_EXT_MULTI_DRAW_EXTENSION_NAME)) {
|
||||
enabledDeviceExtensions.push_back(VK_EXT_MULTI_DRAW_EXTENSION_NAME);
|
||||
}
|
||||
multiDrawFeatures.pNext = const_cast<void*>(deviceCreateInfo.pNext);
|
||||
deviceCreateInfo.pNext = &multiDrawFeatures;
|
||||
m_multiDrawExtensionEnabled = true;
|
||||
|
||||
VkPhysicalDeviceMultiDrawPropertiesEXT multiDrawProperties{};
|
||||
multiDrawProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MULTI_DRAW_PROPERTIES_EXT;
|
||||
auto getPhysicalDeviceProperties2 = reinterpret_cast<PFN_vkGetPhysicalDeviceProperties2>(
|
||||
vkGetInstanceProcAddr(m_instance, "vkGetPhysicalDeviceProperties2"));
|
||||
if (getPhysicalDeviceProperties2 == nullptr) {
|
||||
getPhysicalDeviceProperties2 = reinterpret_cast<PFN_vkGetPhysicalDeviceProperties2>(
|
||||
vkGetInstanceProcAddr(m_instance, "vkGetPhysicalDeviceProperties2KHR"));
|
||||
}
|
||||
if (getPhysicalDeviceProperties2 != nullptr) {
|
||||
VkPhysicalDeviceProperties2 propertyQuery{};
|
||||
propertyQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2;
|
||||
propertyQuery.pNext = &multiDrawProperties;
|
||||
getPhysicalDeviceProperties2(m_physicalDevice.handle, &propertyQuery);
|
||||
}
|
||||
// Spec minimum is 1024; a driver reporting 0 through a failed query must not
|
||||
// zero out every batch, so fall back to the spec minimum.
|
||||
m_maxMultiDrawCount = multiDrawProperties.maxMultiDrawCount != 0
|
||||
? multiDrawProperties.maxMultiDrawCount
|
||||
: 1024;
|
||||
MGLOG_I("Enabled optional device extension: %s (maxMultiDrawCount=%u)",
|
||||
VK_EXT_MULTI_DRAW_EXTENSION_NAME, m_maxMultiDrawCount);
|
||||
} else {
|
||||
MGLOG_I("VK_EXT_multi_draw is advertised but its multiDraw feature is unavailable; "
|
||||
"multi-draw batches use the indirect or unrolled tier");
|
||||
}
|
||||
}
|
||||
|
||||
deviceCreateInfo.enabledExtensionCount = static_cast<Uint32>(enabledDeviceExtensions.size());
|
||||
deviceCreateInfo.ppEnabledExtensionNames = enabledDeviceExtensions.data();
|
||||
MGLOG_I("Device feature support: robustBufferAccess=%s geometryShader=%s independentBlend=%s logicOp=%s shaderClipDistance=%s "
|
||||
@@ -11208,6 +11666,51 @@ void main() {
|
||||
m_drawIndirectCountExtensionEnabled = false;
|
||||
}
|
||||
|
||||
s_vkCmdDrawMultiEXT = nullptr;
|
||||
s_vkCmdDrawMultiIndexedEXT = nullptr;
|
||||
if (m_multiDrawExtensionEnabled) {
|
||||
s_vkCmdDrawMultiEXT =
|
||||
reinterpret_cast<PFN_vkCmdDrawMultiEXT>(vkGetDeviceProcAddr(m_device, "vkCmdDrawMultiEXT"));
|
||||
s_vkCmdDrawMultiIndexedEXT = reinterpret_cast<PFN_vkCmdDrawMultiIndexedEXT>(
|
||||
vkGetDeviceProcAddr(m_device, "vkCmdDrawMultiIndexedEXT"));
|
||||
if (s_vkCmdDrawMultiEXT == nullptr || s_vkCmdDrawMultiIndexedEXT == nullptr) {
|
||||
MGLOG_W("VK_EXT_multi_draw enabled but its entry points are missing, will continue as if "
|
||||
"VK_EXT_multi_draw is not supported!");
|
||||
s_vkCmdDrawMultiEXT = nullptr;
|
||||
s_vkCmdDrawMultiIndexedEXT = nullptr;
|
||||
m_multiDrawExtensionEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the multi-draw dispatch tiers once: device support clamped by the
|
||||
// MOBILEGL_MAGMA_MULTIDRAW_MODE preference. Requesting an unavailable tier is
|
||||
// never an error - the dispatch falls down the chain ext -> indirect -> unroll.
|
||||
{
|
||||
using MG_Config::MultiDrawMode;
|
||||
const MultiDrawMode mode = MG_Config::Features.MagmaMultiDrawMode;
|
||||
m_multiDrawAllowExt =
|
||||
m_multiDrawExtensionEnabled && (mode == MultiDrawMode::Auto || mode == MultiDrawMode::Ext);
|
||||
m_multiDrawAllowIndirect = m_multiDrawIndirectFeatureEnabled && mode != MultiDrawMode::Unroll;
|
||||
m_multiDrawForceUnrollIndirect = mode == MultiDrawMode::Unroll;
|
||||
if (mode == MultiDrawMode::Ext && !m_multiDrawExtensionEnabled) {
|
||||
MGLOG_I("MOBILEGL_MAGMA_MULTIDRAW_MODE=ext requested but VK_EXT_multi_draw is unavailable; "
|
||||
"falling back to the %s tier",
|
||||
m_multiDrawAllowIndirect ? "indirect" : "unroll");
|
||||
}
|
||||
if (mode == MultiDrawMode::Indirect && !m_multiDrawIndirectFeatureEnabled) {
|
||||
MGLOG_I("MOBILEGL_MAGMA_MULTIDRAW_MODE=indirect requested but the multiDrawIndirect device "
|
||||
"feature is unavailable; falling back to the unroll tier");
|
||||
}
|
||||
MGLOG_I("Multi-draw dispatch tier: %s (VK_EXT_multi_draw=%s, multiDrawIndirect=%s, mode=%s)",
|
||||
m_multiDrawAllowExt ? "ext" : (m_multiDrawAllowIndirect ? "indirect" : "unroll"),
|
||||
m_multiDrawExtensionEnabled ? "true" : "false",
|
||||
m_multiDrawIndirectFeatureEnabled ? "true" : "false",
|
||||
mode == MultiDrawMode::Auto ? "auto"
|
||||
: mode == MultiDrawMode::Ext ? "ext"
|
||||
: mode == MultiDrawMode::Indirect ? "indirect"
|
||||
: "unroll");
|
||||
}
|
||||
|
||||
if (m_transformFeedbackFeatureEnabled) {
|
||||
s_vkCmdBindTransformFeedbackBuffersEXT = reinterpret_cast<PFN_vkCmdBindTransformFeedbackBuffersEXT>(
|
||||
vkGetDeviceProcAddr(m_device, "vkCmdBindTransformFeedbackBuffersEXT"));
|
||||
@@ -11653,7 +12156,7 @@ void main() {
|
||||
}
|
||||
InvalidatePipelineMemo(); // pipelines freed -> the memoized handle would dangle
|
||||
g_dynamicStateShadow.graphicsPipelineValid = false;
|
||||
m_setupDrawSnapshot.valid = false;
|
||||
InvalidateSetupDrawSnapshots();
|
||||
DestroyComputePipelines();
|
||||
if (m_frameContext.GetFrameCount() > 0) {
|
||||
m_frameContext.GetCurrent().isCommandRecording = false;
|
||||
|
||||
@@ -327,6 +327,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Bool RecreateSwapchain();
|
||||
|
||||
private:
|
||||
// Tiered emission for an already-set-up multi-draw batch (state bound, index
|
||||
// buffer bound for the indexed form). Tier 1: VK_EXT_multi_draw. Tier 2: one
|
||||
// vkCmdDraw(Indexed)Indirect over a transient command array. Tier 3: unrolled
|
||||
// vkCmdDraw(Indexed) loop. Tier eligibility is per-batch (uniform instance
|
||||
// state for tier 1, firstInstance/feature legality for tier 2); every tier
|
||||
// consumes the same param span, so contiguous-run merging done by the caller
|
||||
// benefits all of them.
|
||||
void EmitMultiDrawIndexed(VkCommandBuffer commandBuffer, const DrawIndexedCmdParam* pParams, Uint32 drawCount);
|
||||
void EmitMultiDraw(VkCommandBuffer commandBuffer, const DrawCmdParam* pParams, Uint32 drawCount);
|
||||
|
||||
struct BlitUniformData {
|
||||
float srcRect[4] = {0.f, 0.f, 1.f, 1.f};
|
||||
float dstRect[4] = {0.f, 0.f, 1.f, 1.f};
|
||||
@@ -477,6 +487,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Bool m_indexTypeUint8ExtensionEnabled = false;
|
||||
Bool m_logicOpFeatureEnabled = false;
|
||||
Bool m_multiDrawIndirectFeatureEnabled = false;
|
||||
// drawIndirectFirstInstance gates indirect commands whose firstInstance != 0;
|
||||
// cached at device creation because the tier-2 multi-draw path (a transient
|
||||
// VkDrawIndexedIndirectCommand array) is illegal for such a sub-draw without it.
|
||||
Bool m_drawIndirectFirstInstanceFeatureEnabled = false;
|
||||
// VK_EXT_multi_draw: native batched submission for the CPU-side glMultiDraw*
|
||||
// families (tier 1 of the multi-draw dispatch).
|
||||
Bool m_multiDrawExtensionEnabled = false;
|
||||
Uint32 m_maxMultiDrawCount = 0;
|
||||
// Multi-draw dispatch tiers, resolved once at device creation from device support
|
||||
// clamped by MOBILEGL_MAGMA_MULTIDRAW_MODE (a preference, never a demand):
|
||||
// tier 1 (ext): one vkCmdDrawMulti(Indexed)EXT - m_multiDrawAllowExt
|
||||
// tier 2 (indirect): one vkCmdDraw(Indexed)Indirect batch - m_multiDrawAllowIndirect
|
||||
// tier 3 (unroll): one vkCmdDraw(Indexed) per sub-draw - always available
|
||||
// m_multiDrawForceUnrollIndirect additionally forces the GPU-parameter
|
||||
// glMultiDraw*Indirect paths onto their per-command loop (mode=unroll only).
|
||||
Bool m_multiDrawAllowExt = false;
|
||||
Bool m_multiDrawAllowIndirect = false;
|
||||
Bool m_multiDrawForceUnrollIndirect = false;
|
||||
Bool m_samplerAnisotropyFeatureEnabled = false;
|
||||
Bool m_shaderDrawParametersExtensionEnabled = false;
|
||||
Bool m_shaderDrawParametersFeatureEnabled = false;
|
||||
@@ -508,6 +536,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
VkDeviceSize countBufferOffset, Uint32 maxDrawCount,
|
||||
Uint32 stride);
|
||||
static inline PFNDrawIndexedIndirectCountFunc s_vkCmdDrawIndexedIndirectCount = nullptr;
|
||||
// VK_EXT_multi_draw entry points, loaded at device creation when the extension
|
||||
// (and its multiDraw feature) is enabled; null otherwise.
|
||||
static inline PFN_vkCmdDrawMultiEXT s_vkCmdDrawMultiEXT = nullptr;
|
||||
static inline PFN_vkCmdDrawMultiIndexedEXT s_vkCmdDrawMultiIndexedEXT = nullptr;
|
||||
|
||||
// VK_EXT_transform_feedback (GL transform feedback capture)
|
||||
Bool m_transformFeedbackFeatureEnabled = false;
|
||||
@@ -648,13 +680,20 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Uint32 m_pipelineStateHashColorCount = 0;
|
||||
Uint64 m_pipelineStateHash = 0;
|
||||
Bool m_pipelineStateHashValid = false;
|
||||
// GetShaderTransformFlags(preTransform) memo: a pure function of the swapchain
|
||||
// pre-transform, re-evaluated only when that value changes (surface rotation).
|
||||
// No other invalidation input exists.
|
||||
// GetShaderTransformFlags memo. NOT pure in the pre-transform alone: the
|
||||
// function also reads whether the bound DRAW framebuffer is the default one
|
||||
// (only the default framebuffer gets the Y-flip and rotation bits - an FBO
|
||||
// pass renders unflipped). Keyed on BOTH inputs; missing the FBO bit shipped
|
||||
// an upside-down default-framebuffer pass after any render-to-texture
|
||||
// (minecraft-1.17-main-menu retrace, whole frame flipped).
|
||||
VkSurfaceTransformFlagBitsKHR m_baseTransformFlagsPreTransform =
|
||||
VK_SURFACE_TRANSFORM_FLAG_BITS_MAX_ENUM_KHR;
|
||||
Bool m_baseTransformFlagsIsDefaultFbo = false;
|
||||
Bool m_baseTransformFlagsKeyValid = false;
|
||||
Uint32 m_baseTransformFlagsCache = 0;
|
||||
Uint32 GetBaseTransformFlagsRaw();
|
||||
// isDefaultFbo must be the default-ness of the CURRENTLY bound draw framebuffer;
|
||||
// every caller already has it in hand from its own guards.
|
||||
Uint32 GetBaseTransformFlagsRaw(Bool isDefaultFbo);
|
||||
// Drops every memoized pipeline handle. Required at command-buffer
|
||||
// boundaries and whenever any pipeline may have been destroyed. Also drops
|
||||
// the cached pipeline-state hash: the same boundaries can retire the GL
|
||||
@@ -719,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;
|
||||
@@ -765,8 +810,36 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// it bypasses GetOrCreateProgram, or the idle sweep could evict a live entry.
|
||||
const ProgramFactory::VkProgramObject* programObj = nullptr;
|
||||
Uint64 programFactoryEpoch = 0;
|
||||
// Per-entry copies of the snapshotting draw's sampled set (the scratch
|
||||
// vectors below hold only the LAST full-path draw's set, which with more
|
||||
// than one snapshot entry is not necessarily this entry's program).
|
||||
// sampledTextures/sampledResources carry the same epoch-guarded pointer
|
||||
// lifetime rules as the scratch originals: textureEraseEpoch (checked
|
||||
// every probe) declines the entry before any erased resource pointer
|
||||
// could be dereferenced. sampledLayouts is the layout VALUE each
|
||||
// resource held when this entry's descriptors were built (the
|
||||
// descriptor-reuse hint needs the SAME layout, not just a sampleable
|
||||
// one), and sampledBindingRecords feeds SampledBindingsUnchanged when
|
||||
// the bind generation moved.
|
||||
Vector<MG_State::GLState::ITextureObject*> sampledTextures;
|
||||
Vector<VkTextureManager::TextureResource*> sampledResources;
|
||||
Vector<VkImageLayout> sampledLayouts;
|
||||
Vector<UniformManager::SampledBindingRecord> sampledBindingRecords;
|
||||
};
|
||||
SetupDrawSnapshot m_setupDrawSnapshot;
|
||||
// Program-keyed snapshot entries: program ping-pong (Sodium switches programs
|
||||
// mid-frame every few draws) would otherwise evict the single snapshot on
|
||||
// every switch and send every draw through the full path. Entries are found
|
||||
// by programLifetimeId (MRU-first probe); every other guard stays per-probe,
|
||||
// so a stale entry declines itself exactly like the old single snapshot did.
|
||||
static constexpr Uint32 kSetupDrawSnapshotCount = 4;
|
||||
SetupDrawSnapshot m_setupDrawSnapshots[kSetupDrawSnapshotCount];
|
||||
Uint32 m_setupDrawSnapshotMru = 0; // last entry that hit or was filled
|
||||
Uint32 m_setupDrawSnapshotVictim = 0; // round-robin fill cursor when all entries are live
|
||||
void InvalidateSetupDrawSnapshots() {
|
||||
for (auto& snapshot : m_setupDrawSnapshots) {
|
||||
snapshot.valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-draw scratch buffers (clear keeps capacity) — these paths run for every
|
||||
// draw call and must not allocate.
|
||||
@@ -782,13 +855,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// post-transition loop can skip re-resolving textures whose layout is
|
||||
// already sampleable.
|
||||
Vector<VkTextureManager::TextureResource*> m_sampledResourcesScratch;
|
||||
// Layout VALUE of each sampled resource when the snapshot (and so the cached
|
||||
// sampler descriptors) was built, parallel to m_sampledResourcesScratch. The
|
||||
// fast path's validity check only proves the layout is still sampleable; the
|
||||
// descriptor-reuse hint additionally needs it to be the SAME sampleable
|
||||
// layout (a mid-frame compute dispatch can move a sampled texture from
|
||||
// READ_ONLY_OPTIMAL to GENERAL, both valid, different descriptor).
|
||||
Vector<VkImageLayout> m_sampledLayoutSnapshots;
|
||||
Vector<MG_State::GLState::ITextureObject*> m_storageImageTexturesScratch;
|
||||
Vector<VkBuffer> m_vertexBuffersScratch;
|
||||
Vector<VkDeviceSize> m_vertexOffsetsScratch;
|
||||
@@ -927,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,7 +53,9 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
|
||||
return true;
|
||||
}
|
||||
|
||||
Bool ValidateBufferBindingPointIndex(BufferTarget target, Uint index) {
|
||||
namespace {
|
||||
// The GL-visible number of indexed binding points for `target`.
|
||||
SizeT GetBufferBindingPointLimit(BufferTarget target) {
|
||||
SizeT pointCount = MG_State::pGLContext->GetBufferBindingPointCount(target);
|
||||
if (target == BufferTarget::ShaderStorage && MG_Backend::pActiveBackendObject) {
|
||||
const Int backendCount =
|
||||
@@ -65,6 +67,32 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
|
||||
// binding points in GL 3.3 (no ARB_transform_feedback3).
|
||||
pointCount = std::min<SizeT>(pointCount, 4);
|
||||
}
|
||||
return pointCount;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Bool ValidateBufferBindingPointRange(BufferTarget target, Uint first, GLsizei count, const char* funcName) {
|
||||
if (count < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl/BufferImpl", funcName,
|
||||
"count must be non-negative."));
|
||||
return false;
|
||||
}
|
||||
const SizeT pointCount = GetBufferBindingPointLimit(target);
|
||||
if (static_cast<Uint64>(first) + static_cast<Uint64>(count) > static_cast<Uint64>(pointCount)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>(
|
||||
"MG_Impl/GLImpl/BufferImpl", funcName,
|
||||
std::format("first + count ({} + {}) exceeds the {} indexed binding points of target {}.", first,
|
||||
count, pointCount, MG_Util::ConvertBufferTargetToString(target))));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Bool ValidateBufferBindingPointIndex(BufferTarget target, Uint index) {
|
||||
const SizeT pointCount = GetBufferBindingPointLimit(target);
|
||||
|
||||
if (index < pointCount) {
|
||||
return true;
|
||||
|
||||
@@ -17,4 +17,8 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
|
||||
Bool ValidateBufferMappingAccess(Flags<BufferMappingAccessBit> accessBits);
|
||||
Bool ValidateBufferBindingPointTarget(BufferTarget target);
|
||||
Bool ValidateBufferBindingPointIndex(BufferTarget target, Uint index);
|
||||
// ARB_multi_bind: glBindBuffersBase/Range validate the whole [first, first + count) range
|
||||
// up front and report INVALID_OPERATION, where a single out-of-range index would be
|
||||
// INVALID_VALUE. Naively looping the single-bind entry points reports the wrong class.
|
||||
Bool ValidateBufferBindingPointRange(BufferTarget target, Uint first, GLsizei count, const char* funcName);
|
||||
} // namespace MobileGL::MG_Impl::GLImpl::BufferImpl
|
||||
|
||||
@@ -474,6 +474,21 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
if (!ValidateCurrentProgramForCompute(__func__)) return;
|
||||
// GL 4.6 core 19: each num_groups_* must be within GL_MAX_COMPUTE_WORK_GROUP_COUNT
|
||||
// for its dimension. GetIntegeri_v already floors that at the spec minimum.
|
||||
const GLuint numGroups[3] = {numGroupsX, numGroupsY, numGroupsZ};
|
||||
for (GLuint dimension = 0; dimension < 3; ++dimension) {
|
||||
GLint maxGroups = 0;
|
||||
GetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, dimension, &maxGroups);
|
||||
if (numGroups[dimension] > static_cast<GLuint>(std::max(maxGroups, 0))) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"num_groups exceeds GL_MAX_COMPUTE_WORK_GROUP_COUNT for dimension " +
|
||||
std::to_string(dimension) + "."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
dispatchCompute(numGroupsX, numGroupsY, numGroupsZ);
|
||||
}
|
||||
|
||||
@@ -487,6 +502,24 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
if (!ValidateCurrentProgramForCompute(__func__)) return;
|
||||
// GL 4.6 core 19: `indirect` is a byte offset into GL_DISPATCH_INDIRECT_BUFFER -
|
||||
// negative or misaligned is INVALID_VALUE, nothing bound is INVALID_OPERATION.
|
||||
if (indirect < 0 || (indirect % 4) != 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"indirect must be non-negative and a multiple of 4."));
|
||||
return;
|
||||
}
|
||||
const auto& indirectBuffer =
|
||||
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DispatchIndirect).GetBoundObject();
|
||||
if (!indirectBuffer) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"No buffer is bound to GL_DISPATCH_INDIRECT_BUFFER."));
|
||||
return;
|
||||
}
|
||||
dispatchComputeIndirect(indirect);
|
||||
}
|
||||
|
||||
|
||||
@@ -977,8 +977,8 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, ScissorIndexed, GLuint index, GLint left, GL
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, ScissorIndexedv, GLuint index, const GLint* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ScissorIndexedv, index, v)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, DepthRangeArrayv, GLuint first, GLsizei count, const GLdouble* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DepthRangeArrayv, first, count, v)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, DepthRangeIndexed, GLuint index, GLdouble n, GLdouble f) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DepthRangeIndexed, index, n, f)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetFloati_v, GLenum target, GLuint index, GLfloat* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetFloati_v, target, index, data)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetDoublei_v, GLenum target, GLuint index, GLdouble* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetDoublei_v, target, index, data)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, GetFloati_v, GLenum target, GLuint index, GLfloat* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, GetFloati_v, target, index, data)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, GetDoublei_v, GLenum target, GLuint index, GLdouble* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, GetDoublei_v, target, index, data)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, DrawArraysInstancedBaseInstance, GLenum mode, GLint first, GLsizei count, GLsizei instancecount, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawArraysInstancedBaseInstance, mode, first, count, instancecount, baseinstance)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseInstance, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseInstance, mode, count, type, indices, instancecount, baseinstance)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseVertexBaseInstance, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLint basevertex, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseVertexBaseInstance, mode, count, type, indices, instancecount, basevertex, baseinstance)
|
||||
@@ -1273,7 +1273,7 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiTexCoord4ivARB, GLenum target, const GL
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiTexCoord4sARB, GLenum target, GLshort s, GLshort t, GLshort r, GLshort q) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MultiTexCoord4sARB, target, s, t, r, q)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiTexCoord4svARB, GLenum target, const GLshort* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MultiTexCoord4svARB, target, v)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetQueryObjectivARB, GLuint id, GLenum pname, GLint* params) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetQueryObjectivARB, id, pname, params)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, MaxShaderCompilerThreadsARB, GLuint count) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MaxShaderCompilerThreadsARB, count)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, MaxShaderCompilerThreadsARB, GLuint count) DECLARE_GL_FUNCTION_END_NO_RETURN(void, MaxShaderCompilerThreadsARB, count)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, PointParameterfARB, GLenum pname, GLfloat param) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, PointParameterfARB, pname, param)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, PointParameterfvARB, GLenum pname, const GLfloat* params) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, PointParameterfvARB, pname, params)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetnTexImageARB, GLenum target, GLint level, GLenum format, GLenum type, GLsizei bufSize, void* img) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetnTexImageARB, target, level, format, type, bufSize, img)
|
||||
@@ -1381,7 +1381,7 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, WindowPos3ivARB, const GLint* v) DECLARE_GL_
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, WindowPos3sARB, GLshort x, GLshort y, GLshort z) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, WindowPos3sARB, x, y, z)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, WindowPos3svARB, const GLshort* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, WindowPos3svARB, v)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, BlendBarrierKHR, void) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, BlendBarrierKHR, )
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, MaxShaderCompilerThreadsKHR, GLuint count) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MaxShaderCompilerThreadsKHR, count)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, MaxShaderCompilerThreadsKHR, GLuint count) DECLARE_GL_FUNCTION_END_NO_RETURN(void, MaxShaderCompilerThreadsKHR, count)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiTexCoord1bOES, GLenum texture, GLbyte s) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MultiTexCoord1bOES, texture, s)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiTexCoord1bvOES, GLenum texture, const GLbyte* coords) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MultiTexCoord1bvOES, texture, coords)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiTexCoord2bOES, GLenum texture, GLbyte s, GLbyte t) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MultiTexCoord2bOES, texture, s, t)
|
||||
|
||||
@@ -106,9 +106,11 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
// `capabilityTargetIndex` is the row of the cache the attachment actually lives in;
|
||||
// kFormatCapabilityTargetCount asks about the format in general. Asking per target matters
|
||||
// because a capability recorded for one of them says nothing about the others: DirectGLES
|
||||
// widens three-channel formats to four channels to keep them renderable as *multisample*
|
||||
// storage, and a format that survives only through that substitution is still texture-only
|
||||
// on every ordinary target.
|
||||
// decides each target's substitution against that target's own probe, and a buffer texture
|
||||
// never gets one at all. This is also where the three-channel widening becomes visible to
|
||||
// the application - a GL_RGB8_SNORM colour attachment on a driver with no renderable
|
||||
// three-channel format answers COMPLETE because the backend stores it as GL_RGBA16F and
|
||||
// recorded FramebufferRenderable in CaveatCaps.
|
||||
Bool IsColorInternalFormatRenderable(TextureInternalFormat format, SizeT capabilityTargetIndex) {
|
||||
const SizeT formatIndex = static_cast<SizeT>(format);
|
||||
if (MG_Backend::pActiveBackendObject && formatIndex < MG_Backend::kFormatCapabilityFormatCount) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <MG_Util/Converters/MGToGL/RenderStateEnumConverter.h>
|
||||
#include <MG_State/GLState/FramebufferState/FramebufferObject.h>
|
||||
#include <MG_Util/Texture/TextureFormatProcessor.h>
|
||||
#include <MG_Util/Async/ShaderCompilePool.h>
|
||||
#include <MG_Backend/BackendObjects.h>
|
||||
|
||||
namespace MobileGL::MG_Impl::GLImpl {
|
||||
@@ -50,6 +51,14 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
constexpr GLint kFrontendMaxTessControlAtomicCounters = 0;
|
||||
constexpr GLint kFrontendMaxTessEvaluationAtomicCounters = 0;
|
||||
constexpr GLint kFrontendMaxVertexAtomicCounters = 0;
|
||||
// One atomic counter is a uint, and a buffer never has to hold more counters than the
|
||||
// combined limit the frontend advertises. GL 4.6 table 23.63 floors this at 32 bytes.
|
||||
constexpr GLint kFrontendMaxAtomicCounterBufferSize =
|
||||
kFrontendMaxCombinedAtomicCounters * static_cast<GLint>(sizeof(GLuint));
|
||||
// KHR_debug minima (GL 4.6 table 23.66); the debug entry points are stubs, but the
|
||||
// limits they advertise still have to be legal.
|
||||
constexpr GLint kFrontendMaxDebugGroupStackDepth = 64;
|
||||
constexpr GLint kFrontendMaxDebugLoggedMessages = 1;
|
||||
constexpr GLint kFrontendMaxVertexUniformComponents = 4096;
|
||||
constexpr GLint kFrontendMaxVertexUniformVectors = 128;
|
||||
constexpr GLint kFrontendMaxVertexUniformBlocks = 14;
|
||||
@@ -263,6 +272,60 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return true;
|
||||
}
|
||||
|
||||
// GL_TEXTURE_BINDING_* is per-texture-unit state: glGetIntegerv answers for the
|
||||
// active unit, glGetIntegeri_v answers for unit `index`. Both need the same
|
||||
// pname -> target decode, so it lives here instead of being spelled out twice.
|
||||
bool TryDecodeTextureUnitBindingPname(GLenum pname, TextureTarget& outTarget) {
|
||||
switch (pname) {
|
||||
case GL_TEXTURE_BINDING_1D: outTarget = TextureTarget::Texture1D; return true;
|
||||
case GL_TEXTURE_BINDING_1D_ARRAY: outTarget = TextureTarget::Texture1DArray; return true;
|
||||
case GL_TEXTURE_BINDING_2D: outTarget = TextureTarget::Texture2D; return true;
|
||||
case GL_TEXTURE_BINDING_2D_ARRAY: outTarget = TextureTarget::Texture2DArray; return true;
|
||||
case GL_TEXTURE_BINDING_2D_MULTISAMPLE: outTarget = TextureTarget::Texture2DMultisample; return true;
|
||||
case GL_TEXTURE_BINDING_2D_MULTISAMPLE_ARRAY:
|
||||
outTarget = TextureTarget::Texture2DMultisampleArray;
|
||||
return true;
|
||||
case GL_TEXTURE_BINDING_3D: outTarget = TextureTarget::Texture3D; return true;
|
||||
case GL_TEXTURE_BINDING_BUFFER: outTarget = TextureTarget::TextureBuffer; return true;
|
||||
case GL_TEXTURE_BINDING_CUBE_MAP: outTarget = TextureTarget::TextureCubeMap; return true;
|
||||
case GL_TEXTURE_BINDING_CUBE_MAP_ARRAY: outTarget = TextureTarget::TextureCubeMapArray; return true;
|
||||
case GL_TEXTURE_BINDING_RECTANGLE: outTarget = TextureTarget::TextureRectangle; return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
GLint QueryTextureBindingOnUnit(Int unit, TextureTarget target) {
|
||||
auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& obj = textureUnit.GetBindingSlot(target).GetBoundObject();
|
||||
return obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
}
|
||||
|
||||
GLint QuerySamplerBindingOnUnit(Int unit) {
|
||||
const auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& sampler = textureUnit.GetSamplerObject();
|
||||
return sampler ? static_cast<GLint>(sampler->GetExternalIndex()) : 0;
|
||||
}
|
||||
|
||||
// The ARB_viewport_array indexed rectangles. MobileGL keeps exactly one viewport, one
|
||||
// scissor box and one depth range, so every in-range index answers with that single
|
||||
// value - but it has to come from the frontend state the non-indexed getters read.
|
||||
// The generic path at the bottom of GetIntegeri_v is a raw backend passthrough that
|
||||
// has no case for these, so routing them through it returned zeros.
|
||||
Bool IsIndexedViewportQuery(GLenum target) {
|
||||
return target == GL_VIEWPORT || target == GL_SCISSOR_BOX || target == GL_DEPTH_RANGE;
|
||||
}
|
||||
|
||||
// ARB_viewport_array: `index` selects a viewport and MAX_VIEWPORTS bounds it.
|
||||
Bool ValidateViewportQueryIndex(GLuint index, const char* caller) {
|
||||
GLint maxViewports = 0;
|
||||
GetIntegerv(GL_MAX_VIEWPORTS, &maxViewports);
|
||||
if (index < static_cast<GLuint>(std::max(maxViewports, 1))) return true;
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller, "Viewport index is out of range."));
|
||||
return false;
|
||||
}
|
||||
|
||||
void CopyIntsToBooleans(const GLint* src, SizeT count, GLboolean* dst) {
|
||||
for (SizeT i = 0; i < count; ++i) {
|
||||
dst[i] = src[i] ? GL_TRUE : GL_FALSE;
|
||||
@@ -670,7 +733,37 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-texture-unit bindings: GL 4.6 core table 23.19 makes every GL_TEXTURE_BINDING_*
|
||||
// and GL_SAMPLER_BINDING indexed by texture unit. Without this they fell through to
|
||||
// the raw backend passthrough at the bottom, which knows nothing about the
|
||||
// frontend's binding state.
|
||||
if (TextureTarget textureBindingTarget = TextureTarget::Unknown;
|
||||
TryDecodeTextureUnitBindingPname(target, textureBindingTarget) || target == GL_SAMPLER_BINDING) {
|
||||
GLint maxUnits = 0;
|
||||
GetIntegerv(GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, &maxUnits);
|
||||
maxUnits = std::min<GLint>(maxUnits, MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS);
|
||||
if (index >= static_cast<GLuint>(std::max(maxUnits, 0))) {
|
||||
*data = 0;
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "Texture unit index is out of range."));
|
||||
return;
|
||||
}
|
||||
*data = target == GL_SAMPLER_BINDING
|
||||
? QuerySamplerBindingOnUnit(static_cast<Int>(index))
|
||||
: QueryTextureBindingOnUnit(static_cast<Int>(index), textureBindingTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (target) {
|
||||
// ARB_viewport_array queries the indexed rectangles through glGetIntegeri_v as well
|
||||
// (gl4cMultiBindTests and the viewport_array group both do). The frontend keeps one
|
||||
// viewport and one scissor box, so every in-range index reports that one.
|
||||
case GL_VIEWPORT:
|
||||
case GL_SCISSOR_BOX:
|
||||
if (!ValidateViewportQueryIndex(index, __func__)) return;
|
||||
GetIntegerv(target, data);
|
||||
return;
|
||||
// The vertex buffer binding points of the vertex array object that is bound. Indexed by
|
||||
// binding point, not by attribute (GL 4.6 core 10.3.1).
|
||||
case GL_VERTEX_BINDING_BUFFER:
|
||||
@@ -782,6 +875,46 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
getIntegeri(target, index, data);
|
||||
}
|
||||
|
||||
// GL_ARB_viewport_array's typed indexed getters. They were no-op stubs, which left the
|
||||
// caller's output buffer holding whatever was on the stack. The multi-component indexed
|
||||
// rectangles are answered from the frontend's own viewport/scissor/depth-range state, via
|
||||
// the non-indexed getter of the matching type - GL_DEPTH_RANGE is float state, so putting
|
||||
// it through the integer query would round it to 0/1. Everything else MobileGL answers
|
||||
// indexed is scalar integer-domain state, where converting the integer query is exact.
|
||||
void GetFloati_v(GLenum target, GLuint index, GLfloat* data) {
|
||||
if (!data) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "data pointer cannot be null"));
|
||||
return;
|
||||
}
|
||||
if (IsIndexedViewportQuery(target)) {
|
||||
if (!ValidateViewportQueryIndex(index, __func__)) return;
|
||||
GetFloatv(target, data);
|
||||
return;
|
||||
}
|
||||
GLint ints[4] = {};
|
||||
GetIntegeri_v(target, index, ints);
|
||||
data[0] = static_cast<GLfloat>(ints[0]);
|
||||
}
|
||||
|
||||
void GetDoublei_v(GLenum target, GLuint index, GLdouble* data) {
|
||||
if (!data) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "data pointer cannot be null"));
|
||||
return;
|
||||
}
|
||||
if (IsIndexedViewportQuery(target)) {
|
||||
if (!ValidateViewportQueryIndex(index, __func__)) return;
|
||||
GetDoublev(target, data);
|
||||
return;
|
||||
}
|
||||
GLint ints[4] = {};
|
||||
GetIntegeri_v(target, index, ints);
|
||||
data[0] = static_cast<GLdouble>(ints[0]);
|
||||
}
|
||||
|
||||
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data) {
|
||||
if (!data) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
@@ -958,6 +1091,13 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-texture-unit bindings: the non-indexed query reports the active unit.
|
||||
if (TextureTarget textureBindingTarget = TextureTarget::Unknown;
|
||||
TryDecodeTextureUnitBindingPname(pname, textureBindingTarget)) {
|
||||
*params = QueryTextureBindingOnUnit(MG_State::pGLContext->GetActiveTextureUnit(), textureBindingTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (pname) {
|
||||
case GL_ACTIVE_TEXTURE:
|
||||
*params = MG_State::pGLContext->GetActiveTextureUnit() + GL_TEXTURE0;
|
||||
@@ -1069,12 +1209,32 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_MAX_SHADER_COMPILER_THREADS_KHR:
|
||||
// GL_KHR_parallel_shader_compile (GL_MAX_SHADER_COMPILER_THREADS_ARB is the same
|
||||
// 0x91B0). The number of threads MobileGL's compile pool would actually use, so
|
||||
// an application sizing its own submission batches gets a real answer.
|
||||
//
|
||||
// Zero when asynchronous compilation is off, which is the honest reply and the
|
||||
// one the extension defines for an implementation with no compiler threads: the
|
||||
// extension string is withdrawn in that configuration too, so a conforming
|
||||
// application never reaches this query, and one that asks anyway is told there
|
||||
// are none rather than being handed a thread count nothing will use.
|
||||
*params = MG_Util::Async::AsyncShaderCompileEnabled()
|
||||
? static_cast<GLint>(MG_Util::Async::ShaderCompilePool::Get().GetThreadCount())
|
||||
: 0;
|
||||
return;
|
||||
case GL_MAX_DEBUG_GROUP_STACK_DEPTH:
|
||||
*params = 0; // debug-group entrypoints are stubbed
|
||||
// KHR_debug floors this at 64 even when the group entry points are stubs: the
|
||||
// limit describes how deep glPushDebugGroup may nest, and 0 is not a legal answer.
|
||||
*params = kFrontendMaxDebugGroupStackDepth;
|
||||
return;
|
||||
case GL_MAX_DEBUG_MESSAGE_LENGTH:
|
||||
*params = 1024; // debug-message entrypoints are stubbed, but KHR_debug requires a valid limit
|
||||
return;
|
||||
case GL_MAX_DEBUG_LOGGED_MESSAGES:
|
||||
// Size of the message log ring; KHR_debug requires at least 1.
|
||||
*params = kFrontendMaxDebugLoggedMessages;
|
||||
return;
|
||||
case GL_DEBUG_GROUP_STACK_DEPTH:
|
||||
*params = 0; // debug-group entrypoints are stubbed
|
||||
return;
|
||||
@@ -1516,13 +1676,9 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_SAMPLE_MASK_VALUE:
|
||||
*params = static_cast<GLint>(MG_State::pGLContext->GetSampleMaskValue());
|
||||
return;
|
||||
case GL_SAMPLER_BINDING: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
const auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& sampler = tu.GetSamplerObject();
|
||||
*params = sampler ? static_cast<GLint>(sampler->GetExternalIndex()) : 0;
|
||||
case GL_SAMPLER_BINDING:
|
||||
*params = QuerySamplerBindingOnUnit(MG_State::pGLContext->GetActiveTextureUnit());
|
||||
return;
|
||||
}
|
||||
case GL_SAMPLES:
|
||||
*params = ResolveDrawFramebufferSampleCount();
|
||||
return;
|
||||
@@ -1618,87 +1774,6 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_STEREO:
|
||||
*params = 0; // stereo surfaces are not exposed
|
||||
return;
|
||||
case GL_TEXTURE_BINDING_1D: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture1D);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_1D_ARRAY: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture1DArray);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_2D: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture2D);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
MGLOG_D("Get GL_TEXTURE_BINDING_2D: %d", *params);
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_2D_ARRAY: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture2DArray);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_2D_MULTISAMPLE: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture2DMultisample);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_2D_MULTISAMPLE_ARRAY: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture2DMultisampleArray);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_3D: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::Texture3D);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_BUFFER: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::TextureBuffer);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_CUBE_MAP: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::TextureCubeMap);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_BINDING_RECTANGLE: {
|
||||
Int unit = MG_State::pGLContext->GetActiveTextureUnit();
|
||||
auto& tu = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const auto& slot = tu.GetBindingSlot(TextureTarget::TextureRectangle);
|
||||
const auto& obj = slot.GetBoundObject();
|
||||
*params = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
|
||||
return;
|
||||
}
|
||||
case GL_TEXTURE_COMPRESSION_HINT:
|
||||
*params = static_cast<GLint>(MG_State::pGLContext->GetHint(pname));
|
||||
return;
|
||||
@@ -1971,6 +2046,18 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS:
|
||||
*params = static_cast<GLint>(GetIndexedBufferQueryPointCount(BufferTarget::ShaderStorage));
|
||||
break;
|
||||
case GL_MAX_SHADER_STORAGE_BLOCK_SIZE:
|
||||
// 64-bit state (see GetInteger64v); the 32-bit query saturates, per the GL
|
||||
// state-query conversion rules.
|
||||
*params = static_cast<GLint>(std::min<Uint64>(dynamicParameters.MaxShaderStorageBlockSize,
|
||||
static_cast<Uint64>(INT32_MAX)));
|
||||
break;
|
||||
case GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS:
|
||||
*params = static_cast<GLint>(GetIndexedBufferQueryPointCount(BufferTarget::AtomicCounter));
|
||||
break;
|
||||
case GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE:
|
||||
*params = kFrontendMaxAtomicCounterBufferSize;
|
||||
break;
|
||||
case GL_MAX_TEXTURE_BUFFER_SIZE:
|
||||
*params = dynamicParameters.MaxTextureBufferSize;
|
||||
break;
|
||||
|
||||
@@ -19,6 +19,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
void GetIntegerv(GLenum pname, GLint* params);
|
||||
void GetInteger64v(GLenum pname, GLint64* params);
|
||||
void GetIntegeri_v(GLenum target, GLuint index, GLint* data);
|
||||
void GetFloati_v(GLenum target, GLuint index, GLfloat* data);
|
||||
void GetDoublei_v(GLenum target, GLuint index, GLdouble* data);
|
||||
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
|
||||
GLenum GetError();
|
||||
GLenum GetGraphicsResetStatus();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// End of Source File Header
|
||||
|
||||
#include "GL_Program.h"
|
||||
#include "ProgramInterface.h"
|
||||
#include "Config.h"
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
@@ -16,6 +17,7 @@
|
||||
#include <MG_Util/Converters/GLToMG/ProgramEnumConverter.h>
|
||||
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
|
||||
#include <MG_Util/Converters/SPIRVCrossToGL/SpvcTypeConverter.h>
|
||||
#include <MG_Util/Async/ShaderCompilePool.h>
|
||||
#include <MG_Backend/BackendObjects.h>
|
||||
|
||||
namespace MobileGL::MG_Impl::GLImpl {
|
||||
@@ -108,32 +110,37 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return programObject;
|
||||
}
|
||||
|
||||
static bool IsProgramInterfaceEnum(GLenum programInterface) {
|
||||
switch (programInterface) {
|
||||
case GL_UNIFORM:
|
||||
case GL_UNIFORM_BLOCK:
|
||||
case GL_PROGRAM_INPUT:
|
||||
case GL_PROGRAM_OUTPUT:
|
||||
case GL_BUFFER_VARIABLE:
|
||||
case GL_SHADER_STORAGE_BLOCK:
|
||||
case GL_ATOMIC_COUNTER_BUFFER:
|
||||
case GL_TRANSFORM_FEEDBACK_VARYING:
|
||||
case GL_VERTEX_SUBROUTINE:
|
||||
case GL_TESS_CONTROL_SUBROUTINE:
|
||||
case GL_TESS_EVALUATION_SUBROUTINE:
|
||||
case GL_GEOMETRY_SUBROUTINE:
|
||||
case GL_FRAGMENT_SUBROUTINE:
|
||||
case GL_COMPUTE_SUBROUTINE:
|
||||
case GL_VERTEX_SUBROUTINE_UNIFORM:
|
||||
case GL_TESS_CONTROL_SUBROUTINE_UNIFORM:
|
||||
case GL_TESS_EVALUATION_SUBROUTINE_UNIFORM:
|
||||
case GL_GEOMETRY_SUBROUTINE_UNIFORM:
|
||||
case GL_FRAGMENT_SUBROUTINE_UNIFORM:
|
||||
case GL_COMPUTE_SUBROUTINE_UNIFORM:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
// The four non-location interface queries validate the NAME only: GL 4.6 imposes the
|
||||
// successful-link requirement on GetProgramResourceLocation/LocationIndex alone, and
|
||||
// requires the others to report a program that has never linked as one with zero active
|
||||
// resources. Being stricter leaves a stray GL_INVALID_OPERATION behind that aborts the
|
||||
// caller's next subcase.
|
||||
static const SharedPtr<MG_State::GLState::ProgramObject>& TryToGetProgramForInterfaceQuery(GLuint program,
|
||||
const char* caller) {
|
||||
static const SharedPtr<MG_State::GLState::ProgramObject> nullProgramObject = nullptr;
|
||||
if (!MG_State::pGLContext->ValidateProgramName(program)) {
|
||||
const ErrorCode error = MG_State::pGLContext->ValidateShaderName(program)
|
||||
? ErrorCode::InvalidOperation
|
||||
: ErrorCode::InvalidValue;
|
||||
MG_State::pGLContext->RecordError(
|
||||
error,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
|
||||
std::to_string(program) + " is not a program object."));
|
||||
return nullProgramObject;
|
||||
}
|
||||
auto& programObject = MG_State::pGLContext->GetProgramObject(program);
|
||||
if (!programObject) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
|
||||
std::to_string(program) + " is not a program object."));
|
||||
return nullProgramObject;
|
||||
}
|
||||
return programObject;
|
||||
}
|
||||
|
||||
static bool IsProgramInterfaceEnum(GLenum programInterface) {
|
||||
return ProgramInterface::IsInterfaceEnum(programInterface);
|
||||
}
|
||||
|
||||
static bool IsSubroutineUniformInterface(GLenum programInterface) {
|
||||
@@ -156,11 +163,16 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_ACTIVE_RESOURCES:
|
||||
break;
|
||||
case GL_MAX_NAME_LENGTH:
|
||||
valid = valid && programInterface != GL_ATOMIC_COUNTER_BUFFER;
|
||||
// Neither buffer interface has resource names. GL_TRANSFORM_FEEDBACK_BUFFER only
|
||||
// became reachable here when IsInterfaceEnum grew the GL 4.4 interfaces, so it
|
||||
// needs the same exclusion GL_ATOMIC_COUNTER_BUFFER already had.
|
||||
valid = valid && programInterface != GL_ATOMIC_COUNTER_BUFFER &&
|
||||
programInterface != GL_TRANSFORM_FEEDBACK_BUFFER;
|
||||
break;
|
||||
case GL_MAX_NUM_ACTIVE_VARIABLES:
|
||||
valid = programInterface == GL_UNIFORM_BLOCK || programInterface == GL_ATOMIC_COUNTER_BUFFER ||
|
||||
programInterface == GL_SHADER_STORAGE_BLOCK;
|
||||
programInterface == GL_SHADER_STORAGE_BLOCK ||
|
||||
programInterface == GL_TRANSFORM_FEEDBACK_BUFFER;
|
||||
break;
|
||||
case GL_MAX_NUM_COMPATIBLE_SUBROUTINES:
|
||||
valid = IsSubroutineUniformInterface(programInterface);
|
||||
@@ -179,7 +191,7 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
static bool ValidateNamedProgramResourceInterface(GLenum programInterface, const char* caller) {
|
||||
if (!IsProgramInterfaceEnum(programInterface) || programInterface == GL_ATOMIC_COUNTER_BUFFER) {
|
||||
if (!ProgramInterface::IsNamedInterface(programInterface)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
|
||||
@@ -189,66 +201,6 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return true;
|
||||
}
|
||||
|
||||
static Int GetKnownProgramResourceCount(const SharedPtr<MG_State::GLState::ProgramObject>& programObject,
|
||||
GLenum programInterface) {
|
||||
switch (programInterface) {
|
||||
case GL_UNIFORM:
|
||||
return programObject->GetUniformCount();
|
||||
case GL_UNIFORM_BLOCK:
|
||||
return programObject->GetActiveUniformBlocksCount();
|
||||
case GL_PROGRAM_INPUT:
|
||||
return programObject->GetActiveAttributesCount();
|
||||
case GL_PROGRAM_OUTPUT:
|
||||
return programObject->GetActiveFragmentOutputCount();
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// The GL_UNIFORM interface and glGetActiveUniform(s)iv are the same query in two
|
||||
// spellings, so they answer from the same place - the frontend reflection. The backend
|
||||
// program is not that place: it does not exist at all for a program whose types its
|
||||
// shading language cannot express (a double-precision uniform has no ESSL form), and
|
||||
// the interface queries would then describe a program with no uniforms.
|
||||
//
|
||||
// Writes the GL_UNIFORM value of `prop` for active uniform `index`; false for a prop
|
||||
// the reflection does not model, which the caller forwards to the backend instead.
|
||||
Bool GetUniformResourceProp(const SharedPtr<MG_State::GLState::ProgramObject>& programObject, Uint index,
|
||||
GLenum prop, GLint* out) {
|
||||
switch (prop) {
|
||||
case GL_TYPE:
|
||||
*out = static_cast<GLint>(programObject->GetActiveUniformType(index));
|
||||
return true;
|
||||
case GL_ARRAY_SIZE:
|
||||
*out = programObject->GetActiveUniformArraySize(index);
|
||||
return true;
|
||||
case GL_NAME_LENGTH:
|
||||
*out = static_cast<GLint>(programObject->GetActiveUniformName(index).length() + 1);
|
||||
return true;
|
||||
case GL_BLOCK_INDEX:
|
||||
*out = programObject->GetActiveUniformBlockIndex(index);
|
||||
return true;
|
||||
case GL_OFFSET:
|
||||
*out = programObject->GetActiveUniformOffset(index);
|
||||
return true;
|
||||
case GL_ARRAY_STRIDE:
|
||||
*out = programObject->GetActiveUniformArrayStride(index);
|
||||
return true;
|
||||
case GL_MATRIX_STRIDE:
|
||||
*out = programObject->GetActiveUniformMatrixStride(index);
|
||||
return true;
|
||||
case GL_IS_ROW_MAJOR:
|
||||
*out = programObject->GetActiveUniformIsRowMajor(index);
|
||||
return true;
|
||||
case GL_LOCATION:
|
||||
// A block member has no location; GetUniformLocation already reports -1 for one.
|
||||
*out = programObject->GetUniformLocation(programObject->GetActiveUniformName(index));
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void CopyStr(GLsizei bufSize, GLsizei* length, GLchar* dst, const char* src, GLsizei srcLength) {
|
||||
if (bufSize <= 0) {
|
||||
if (length) *length = 0;
|
||||
@@ -355,19 +307,72 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
shaderObject->Compile();
|
||||
}
|
||||
|
||||
// glMaxShaderCompilerThreadsKHR / glMaxShaderCompilerThreadsARB - one implementation,
|
||||
// because GL_KHR_parallel_shader_compile and GL_ARB_parallel_shader_compile define the
|
||||
// same entry point with the same semantics and GetProcAddress.cpp maps both spellings.
|
||||
//
|
||||
// The three cases the extension defines, and what each means here:
|
||||
//
|
||||
// count == 0 "no compiler threads": compilation must happen on the
|
||||
// application's thread. Everything already in flight is joined
|
||||
// first, so that after this call returns NOTHING is outstanding
|
||||
// and every GL_COMPLETION_STATUS_KHR reads GL_TRUE - which is
|
||||
// the observable the extension actually specifies. The pool
|
||||
// keeps its worker threads (this is not teardown); what changes
|
||||
// is that AsyncShaderCompileActive() now says no, so
|
||||
// glCompileShader/glLinkProgram run their bodies inline.
|
||||
// count == 0xFFFFFFFF "implementation maximum": the pool's full thread count.
|
||||
// otherwise a concurrency budget, clamped to the thread count - asking for
|
||||
// more threads than exist cannot conjure any.
|
||||
//
|
||||
// A nonzero count is also what LIFTS a previous zero: the suspension lasts exactly until
|
||||
// the application asks for threads again, and nothing else re-arms it (no implicit
|
||||
// restore at eglInitialize, at a context switch or at a join). An application that turned
|
||||
// compiler threads off keeps them off until it says otherwise.
|
||||
//
|
||||
// Legal - and a no-op beyond bookkeeping - while MOBILEGL_ASYNC_SHADER_COMPILE is off:
|
||||
// compilation is already inline, and the call must not fail just because MobileGL had
|
||||
// nothing to suspend.
|
||||
void MaxShaderCompilerThreadsKHR_State(GLuint count) {
|
||||
namespace Async = MG_Util::Async;
|
||||
if (count == 0) {
|
||||
MGLOG_D("%s: count = 0; joining all pending shader work and compiling inline", __func__);
|
||||
Async::SetAsyncShaderCompileSuspended(true);
|
||||
// Suspend BEFORE joining, not after. The post-condition this call owes the
|
||||
// application is "nothing is in flight when I return", and only this order
|
||||
// guarantees it: with the latch already set, anything the join itself causes to
|
||||
// be compiled runs inline and is therefore already settled when the join ends.
|
||||
// Joining first would leave a window in which a fresh enqueue is still legal.
|
||||
if (MG_State::pGLContext) MG_State::pGLContext->JoinAllPendingShaderWork();
|
||||
return;
|
||||
}
|
||||
|
||||
Async::ShaderCompilePool& pool = Async::ShaderCompilePool::Get();
|
||||
const Uint threadCount = pool.GetThreadCount();
|
||||
const Uint requested = count == 0xFFFFFFFFu ? threadCount : std::min<Uint>(count, threadCount);
|
||||
pool.SetMaxConcurrency(requested);
|
||||
Async::SetAsyncShaderCompileSuspended(false);
|
||||
MGLOG_D("%s: count = %u; concurrency = %u of %u threads", __func__, count, requested, threadCount);
|
||||
}
|
||||
|
||||
GLuint CreateProgram_State() {
|
||||
return MG_State::pGLContext->CreateProgram();
|
||||
}
|
||||
|
||||
GLuint CreateShader_State(GLenum type) {
|
||||
auto shaderId = MG_State::pGLContext->CreateShader(MG_Util::ConvertGLEnumToShaderStage(type));
|
||||
if (shaderId == 0) {
|
||||
// GL 4.6 core 7.1: shaderType is an enum, so an unrecognised one is INVALID_ENUM (it
|
||||
// used to be documented as INVALID_VALUE). The check has to happen HERE: the state
|
||||
// layer hands out a name for ShaderStage::Unknown just as happily as for a real
|
||||
// stage, so the old "shaderId == 0 means bad type" test could never fire and an
|
||||
// unknown shaderType silently produced a usable shader name and no error at all.
|
||||
const ShaderStage stage = MG_Util::ConvertGLEnumToShaderStage(type);
|
||||
if (stage == ShaderStage::Unknown) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "`shaderType` is not an accepted value."));
|
||||
return 0;
|
||||
}
|
||||
return shaderId;
|
||||
return MG_State::pGLContext->CreateShader(stage);
|
||||
}
|
||||
|
||||
void DeleteProgram_State(GLuint program) {
|
||||
@@ -693,6 +698,19 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
break;
|
||||
}
|
||||
|
||||
// GL_KHR_parallel_shader_compile. THIS CASE MUST NOT JOIN - it is the one program
|
||||
// query whose entire purpose is to answer without waiting, and routing it through
|
||||
// any of ProgramObject's Artifacts() accessors (the join gate, invariant I5) would
|
||||
// block the caller and make the extension a lie: an application polling it would
|
||||
// serialize itself on the very link it is trying to overlap. IsLinkComplete() is the
|
||||
// node-direct reader that exists for exactly this.
|
||||
//
|
||||
// No link at all reads GL_TRUE, which is what the extension requires: the query
|
||||
// means "is anything still outstanding", not "has this program ever been linked".
|
||||
case GL_COMPLETION_STATUS_KHR:
|
||||
*params = programObject->IsLinkComplete() ? GL_TRUE : GL_FALSE;
|
||||
break;
|
||||
|
||||
case GL_PROGRAM_BINARY_LENGTH:
|
||||
// No program binary format is exposed, so a program never has a retrievable
|
||||
// binary and its length is zero (ARB_get_program_binary).
|
||||
@@ -746,6 +764,13 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
case GL_SHADER_SOURCE_LENGTH:
|
||||
*params = shaderObject->GetShaderSource().empty() ? 0 : (GLint)shaderObject->GetShaderSource().length() + 1;
|
||||
break;
|
||||
// GL_KHR_parallel_shader_compile. THIS CASE MUST NOT JOIN - see the identical case in
|
||||
// GetProgramiv_State. GL_COMPILE_STATUS two cases up deliberately DOES join (it has
|
||||
// to: it reports the outcome); this one reports whether there is an outcome yet, and
|
||||
// reading it through Compiled() would defeat the whole extension.
|
||||
case GL_COMPLETION_STATUS_KHR:
|
||||
*params = shaderObject->IsCompileComplete() ? GL_TRUE : GL_FALSE;
|
||||
break;
|
||||
default:
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
@@ -786,6 +811,31 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return loc;
|
||||
}
|
||||
|
||||
// A float matrix lives in the global UBO under std140 rules - one 16-byte-aligned column
|
||||
// vector per column - while the value glGetUniform* must return is tightly packed
|
||||
// columns * rows floats. Only mat4 is the same either way; every other shape needs the
|
||||
// padding undone, and the readback has to undo exactly what UniformMatrixfv_Object put
|
||||
// there. Returns false when `ttype` is not a float matrix (nothing to unpack).
|
||||
Bool TryGatherFloatMatrixColumns(const glslang::TType* ttype, const char* pBase, void* params) {
|
||||
if (ttype == nullptr || !ttype->isMatrix() || ttype->getBasicType() == glslang::EbtDouble) return false;
|
||||
const Int columns = ttype->getMatrixCols();
|
||||
const Int rows = ttype->getMatrixRows();
|
||||
for (Int column = 0; column < columns; ++column) {
|
||||
Memcpy(static_cast<char*>(params) + static_cast<SizeT>(column) * rows * sizeof(GLfloat),
|
||||
pBase + static_cast<SizeT>(column) * 4 * sizeof(GLfloat), rows * sizeof(GLfloat));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bytes a uniform actually occupies in the global UBO. It is the tight GL type size for
|
||||
// everything except a float matrix, whose padded columns make it wider.
|
||||
SizeT UniformStorageSpanInBytes(const glslang::TType* ttype, SizeT tightSize) {
|
||||
if (ttype != nullptr && ttype->isMatrix() && ttype->getBasicType() != glslang::EbtDouble) {
|
||||
return static_cast<SizeT>(ttype->getMatrixCols()) * 4 * sizeof(GLfloat);
|
||||
}
|
||||
return tightSize;
|
||||
}
|
||||
|
||||
void GetUniform_State(GLuint program, GLint location, void* params) {
|
||||
auto& programObject = TryToGetProgramObject(program);
|
||||
if (!programObject) return;
|
||||
@@ -816,23 +866,16 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
auto size = programObject->GetUniformSizesInBytes(location);
|
||||
char* pUBO = (char*)programObject->MapUBO();
|
||||
auto* ttype = programObject->GetUniformTType(location);
|
||||
const SizeT span = UniformStorageSpanInBytes(ttype, size);
|
||||
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
|
||||
offset + size > programObject->GetUBOSize()) {
|
||||
offset + span > programObject->GetUBOSize()) {
|
||||
MGLOG_E("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
|
||||
program, location);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ttype->isMatrix() || ttype->getMatrixCols() != 3)
|
||||
if (!TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) {
|
||||
Memcpy(params, pUBO + offset, size);
|
||||
else {
|
||||
// TODO: we only deal with mat3 yet, deal with other types later
|
||||
// assuming float here, which may not be the case
|
||||
auto* pBase = pUBO + offset;
|
||||
for (int i = 0; i < ttype->getMatrixRows(); i++) {
|
||||
Memcpy((char*)params + ttype->getMatrixCols() * sizeof(float) * i, pBase + 4 * sizeof(float) * i,
|
||||
ttype->getMatrixCols() * sizeof(float));
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: handle 1i variant as texture unit
|
||||
@@ -871,23 +914,16 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
auto size = programObject->GetUniformSizesInBytes(location);
|
||||
char* pUBO = static_cast<char*>(programObject->MapUBO());
|
||||
auto* ttype = programObject->GetUniformTType(location);
|
||||
const SizeT span = UniformStorageSpanInBytes(ttype, size);
|
||||
if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
|
||||
offset + size > programObject->GetUBOSize()) {
|
||||
offset + span > programObject->GetUBOSize()) {
|
||||
MGLOG_E("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__,
|
||||
program, location);
|
||||
return;
|
||||
}
|
||||
|
||||
if constexpr (std::is_same_v<T, GLfloat>) {
|
||||
if (ttype->getBasicType() != glslang::EbtDouble && ttype->isMatrix() &&
|
||||
ttype->getMatrixCols() == 3) {
|
||||
auto* pBase = pUBO + offset;
|
||||
for (int i = 0; i < ttype->getMatrixRows(); i++) {
|
||||
Memcpy(reinterpret_cast<char*>(params) + ttype->getMatrixCols() * sizeof(GLfloat) * i,
|
||||
pBase + 4 * sizeof(GLfloat) * i, ttype->getMatrixCols() * sizeof(GLfloat));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) return;
|
||||
}
|
||||
|
||||
// A double-precision uniform is the one case where the stored component type can
|
||||
@@ -1188,6 +1224,52 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// glUniformMatrix*fv / glProgramUniformMatrix*fv, every shape (square and non-square).
|
||||
// A float matrix sits in the global UBO under std140 rules: each of its `columns`
|
||||
// column vectors starts on its own 16-byte boundary no matter how many rows it has, so
|
||||
// the only shape that may be written as one contiguous block is mat4. Writing a matNxM
|
||||
// as N*M packed floats puts every column after the first at the wrong byte offset.
|
||||
template <typename Program>
|
||||
void UniformMatrixfv_Object(Program& programObject, const char* caller, GLint location, GLsizei count,
|
||||
GLboolean transpose, const GLfloat* value, Int columns, Int rows,
|
||||
const String& ownerDescription) {
|
||||
// std140: a column vector of a float matrix is padded out to a vec4.
|
||||
constexpr SizeT kColumnStride = 4 * sizeof(GLfloat);
|
||||
const SizeT componentCount = static_cast<SizeT>(columns) * static_cast<SizeT>(rows);
|
||||
GLfloat column[4] = {};
|
||||
for (GLint matrix = 0; matrix < count; ++matrix) {
|
||||
if (matrix > 0 && !programObject.UniformLocationsAliasSameUniform(location, location + matrix)) {
|
||||
// GL 3.3 2.11.4: values for elements beyond the end of the uniform array
|
||||
// are ignored. Never step onto a neighboring uniform's location.
|
||||
break;
|
||||
}
|
||||
if (!programObject.IsValidUniformLocation(location + matrix)) {
|
||||
RecordInvalidUniformLocationError(caller, location + matrix, ownerDescription);
|
||||
return;
|
||||
}
|
||||
if (programObject.IsUniformOpaqueAtLocation(location + matrix)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
|
||||
"Opaque uniforms cannot be set with matrix Uniform calls."));
|
||||
return;
|
||||
}
|
||||
if (value == nullptr) return;
|
||||
const GLfloat* source = value + static_cast<SizeT>(matrix) * componentCount;
|
||||
for (Int c = 0; c < columns; ++c) {
|
||||
for (Int r = 0; r < rows; ++r) {
|
||||
column[r] = transpose == GL_TRUE ? source[r * columns + c] : source[c * rows + r];
|
||||
}
|
||||
const SizeT byteOffset = static_cast<SizeT>(c) * kColumnStride;
|
||||
switch (rows) {
|
||||
case 2: Uniform_State<2>(programObject, location + matrix, column, byteOffset); break;
|
||||
case 3: Uniform_State<3>(programObject, location + matrix, column, byteOffset); break;
|
||||
default: Uniform_State<4>(programObject, location + matrix, column, byteOffset); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to transpose a 2x2 matrix
|
||||
void TransposeMatrix2x2(const GLfloat* input, GLfloat* output) {
|
||||
// Input matrix is in column-major order (OpenGL default)
|
||||
@@ -1293,8 +1375,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
void UniformMatrix2fv_State(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
// For 2x2 matrices, we have 4 elements per matrix
|
||||
// If transpose is GL_TRUE, we need to transpose the matrix data
|
||||
// A mat2 is NOT four contiguous floats in the global UBO: std140 pads each column
|
||||
// vector out to 16 bytes, so column 1 starts at byte 16, not byte 8.
|
||||
if (location == -1) return;
|
||||
|
||||
auto& programObject = MG_State::pGLContext->GetProgramForUniform();
|
||||
@@ -1305,26 +1387,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
|
||||
// For matrix uniforms, we handle each matrix individually
|
||||
for (GLint i = 0; i < count; i++) {
|
||||
if (i > 0 && !programObject->UniformLocationsAliasSameUniform(location, location + i)) {
|
||||
// Values for elements beyond the end of the uniform array are ignored.
|
||||
break;
|
||||
}
|
||||
if (!programObject->IsValidUniformLocation(location + i)) {
|
||||
RecordInvalidUniformLocationError(__func__, location + i, "the current program object");
|
||||
return;
|
||||
}
|
||||
if (transpose == GL_TRUE) {
|
||||
// Transpose the matrix before uploading
|
||||
GLfloat transposedMatrix[4];
|
||||
TransposeMatrix2x2(value + i * 4, transposedMatrix);
|
||||
Uniform_State<4>(*programObject, location + i, transposedMatrix);
|
||||
} else {
|
||||
// No transpose needed, directly copy the matrix data
|
||||
Uniform_State<4>(*programObject, location + i, value + i * 4);
|
||||
}
|
||||
}
|
||||
UniformMatrixfv_Object(*programObject, __func__, location, count, transpose, value, 2, 2,
|
||||
"the current program object");
|
||||
}
|
||||
|
||||
void UniformMatrix3fv_State(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
@@ -1402,7 +1466,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
}
|
||||
|
||||
void UniformMatrixNonSquarefv_State(const char* caller, GLint location, GLsizei count) {
|
||||
void UniformMatrixNonSquarefv_State(const char* caller, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value, Int columns, Int rows) {
|
||||
if (location == -1) return;
|
||||
|
||||
auto& programObject = MG_State::pGLContext->GetProgramForUniform();
|
||||
@@ -1413,21 +1478,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
|
||||
for (GLint i = 0; i < count; i++) {
|
||||
if (!programObject->IsValidUniformLocation(location + i)) {
|
||||
RecordInvalidUniformLocationError(caller, location + i, "the current program object");
|
||||
return;
|
||||
}
|
||||
if (programObject->IsUniformOpaqueAtLocation(location + i)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
|
||||
"Opaque uniforms cannot be set with matrix Uniform calls."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement non-square matrix uniform uploads for non-opaque uniforms.
|
||||
UniformMatrixfv_Object(*programObject, caller, location, count, transpose, value, columns, rows,
|
||||
"the current program object");
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix2fv_State(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
@@ -1445,23 +1497,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
|
||||
for (GLint i = 0; i < count; i++) {
|
||||
if (i > 0 && !programObject->UniformLocationsAliasSameUniform(location, location + i)) {
|
||||
// Values for elements beyond the end of the uniform array are ignored.
|
||||
break;
|
||||
}
|
||||
if (!programObject->IsValidUniformLocation(location + i)) {
|
||||
RecordInvalidUniformLocationError(__func__, location + i, "program " + std::to_string(program));
|
||||
return;
|
||||
}
|
||||
if (transpose == GL_TRUE) {
|
||||
GLfloat transposedMatrix[4];
|
||||
TransposeMatrix2x2(value + i * 4, transposedMatrix);
|
||||
Uniform_State<4>(*programObject, location + i, transposedMatrix);
|
||||
} else {
|
||||
Uniform_State<4>(*programObject, location + i, value + i * 4);
|
||||
}
|
||||
}
|
||||
UniformMatrixfv_Object(*programObject, __func__, location, count, transpose, value, 2, 2,
|
||||
"program " + std::to_string(program));
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix3fv_State(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
@@ -1536,7 +1573,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
}
|
||||
|
||||
void ProgramUniformMatrixNonSquarefv_State(const char* caller, GLuint program, GLint location, GLsizei count) {
|
||||
void ProgramUniformMatrixNonSquarefv_State(const char* caller, GLuint program, GLint location, GLsizei count,
|
||||
GLboolean transpose, const GLfloat* value, Int columns, Int rows) {
|
||||
if (location == -1) return;
|
||||
|
||||
auto& programObject = TryToGetProgramObject(program);
|
||||
@@ -1550,21 +1588,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
|
||||
for (GLint i = 0; i < count; i++) {
|
||||
if (!programObject->IsValidUniformLocation(location + i)) {
|
||||
RecordInvalidUniformLocationError(caller, location + i, "program " + std::to_string(program));
|
||||
return;
|
||||
}
|
||||
if (programObject->IsUniformOpaqueAtLocation(location + i)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
|
||||
"Opaque uniforms cannot be set with matrix Uniform calls."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement non-square matrix uniform uploads for non-opaque uniforms.
|
||||
UniformMatrixfv_Object(*programObject, caller, location, count, transpose, value, columns, rows,
|
||||
"program " + std::to_string(program));
|
||||
}
|
||||
|
||||
GLuint GetUniformBlockIndex_State(GLuint program, const GLchar* uniformBlockName) {
|
||||
@@ -1832,6 +1857,16 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
void CompileShader(GLuint shader) {
|
||||
CompileShader_State(shader);
|
||||
}
|
||||
|
||||
void MaxShaderCompilerThreadsKHR(GLuint count) {
|
||||
MaxShaderCompilerThreadsKHR_State(count);
|
||||
}
|
||||
|
||||
// GL_ARB_parallel_shader_compile's spelling of the same entry point.
|
||||
void MaxShaderCompilerThreadsARB(GLuint count) {
|
||||
MaxShaderCompilerThreadsKHR_State(count);
|
||||
}
|
||||
|
||||
GLuint CreateProgram(void) {
|
||||
return CreateProgram_State();
|
||||
}
|
||||
@@ -2363,27 +2398,27 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
void UniformMatrix2x3fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count);
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 2, 3);
|
||||
}
|
||||
|
||||
void UniformMatrix3x2fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count);
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 3, 2);
|
||||
}
|
||||
|
||||
void UniformMatrix2x4fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count);
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 2, 4);
|
||||
}
|
||||
|
||||
void UniformMatrix4x2fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count);
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 4, 2);
|
||||
}
|
||||
|
||||
void UniformMatrix3x4fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count);
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 3, 4);
|
||||
}
|
||||
|
||||
void UniformMatrix4x3fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat* value) {
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count);
|
||||
UniformMatrixNonSquarefv_State(__func__, location, count, transpose, value, 4, 3);
|
||||
}
|
||||
|
||||
void ProgramUniform1f(GLuint program, GLint location, GLfloat v0) {
|
||||
@@ -2508,32 +2543,32 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
|
||||
void ProgramUniformMatrix2x3fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value) {
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 2, 3);
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix3x2fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value) {
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 3, 2);
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix2x4fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value) {
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 2, 4);
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix4x2fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value) {
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 4, 2);
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix3x4fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value) {
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 3, 4);
|
||||
}
|
||||
|
||||
void ProgramUniformMatrix4x3fv(GLuint program, GLint location, GLsizei count, GLboolean transpose,
|
||||
const GLfloat* value) {
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count);
|
||||
ProgramUniformMatrixNonSquarefv_State(__func__, program, location, count, transpose, value, 4, 3);
|
||||
}
|
||||
|
||||
GLuint GetUniformBlockIndex(GLuint program, const GLchar* uniformBlockName) {
|
||||
@@ -2570,171 +2605,160 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
void GetProgramInterfaceiv(GLuint program, GLenum programInterface, GLenum pname, GLint* params) {
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return;
|
||||
if (!ValidateProgramInterfaceivQuery(programInterface, pname)) return;
|
||||
auto getProgramInterfaceiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramInterfaceiv;
|
||||
if (!getProgramInterfaceiv) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
if (!params) return;
|
||||
switch (pname) {
|
||||
case GL_ACTIVE_RESOURCES:
|
||||
*params = ProgramInterface::GetActiveResourceCount(*programObject, programInterface);
|
||||
return;
|
||||
case GL_MAX_NAME_LENGTH:
|
||||
*params = ProgramInterface::GetMaxNameLength(*programObject, programInterface);
|
||||
return;
|
||||
case GL_MAX_NUM_ACTIVE_VARIABLES:
|
||||
*params = ProgramInterface::GetMaxNumActiveVariables(*programObject, programInterface);
|
||||
return;
|
||||
default:
|
||||
// GL_MAX_NUM_COMPATIBLE_SUBROUTINES: the subroutine interfaces are always empty
|
||||
// here (glslang refuses `subroutine` when generating SPIR-V), so zero it is.
|
||||
*params = 0;
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
if (pname == GL_ACTIVE_RESOURCES) {
|
||||
*params = static_cast<GLint>(programObject->GetUniformCount());
|
||||
return;
|
||||
}
|
||||
if (pname == GL_MAX_NAME_LENGTH) {
|
||||
// Stored as the bare length; GL_MAX_NAME_LENGTH counts the terminator.
|
||||
*params = programObject->GetUniformMaxLength() + 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
getProgramInterfaceiv(program, programInterface, pname, params);
|
||||
}
|
||||
|
||||
GLuint GetProgramResourceIndex(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return GL_INVALID_INDEX;
|
||||
if (!ValidateNamedProgramResourceInterface(programInterface, __func__)) return GL_INVALID_INDEX;
|
||||
if (!name) return GL_INVALID_INDEX;
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
const Int uniformIndex = programObject->GetActiveUniformIndex(name);
|
||||
return uniformIndex < 0 ? GL_INVALID_INDEX : static_cast<GLuint>(uniformIndex);
|
||||
}
|
||||
auto getProgramResourceIndex = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceIndex;
|
||||
if (!getProgramResourceIndex) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
return GL_INVALID_INDEX;
|
||||
}
|
||||
GLuint index = getProgramResourceIndex(program, programInterface, name);
|
||||
const String resourceName = name;
|
||||
if (index == GL_INVALID_INDEX && resourceName.length() > 3 &&
|
||||
resourceName.compare(resourceName.length() - 3, 3, "[0]") == 0) {
|
||||
index = getProgramResourceIndex(program, programInterface,
|
||||
resourceName.substr(0, resourceName.length() - 3).c_str());
|
||||
}
|
||||
return index;
|
||||
return ProgramInterface::GetResourceIndex(*programObject, programInterface, name);
|
||||
}
|
||||
|
||||
void GetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei* length,
|
||||
GLchar* name) {
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return;
|
||||
if (!ValidateNamedProgramResourceInterface(programInterface, __func__)) return;
|
||||
const Int resourceCount = GetKnownProgramResourceCount(programObject, programInterface);
|
||||
if (resourceCount >= 0 && index >= static_cast<GLuint>(resourceCount)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "index is out of range."));
|
||||
return;
|
||||
}
|
||||
if (bufSize < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "bufSize must be non-negative."));
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
// Same index space GetProgramResourceIndex answers in, and the range check above
|
||||
// already used it.
|
||||
const String& uniformName = programObject->GetActiveUniformName(index);
|
||||
CopyStr(bufSize, length, name, uniformName.c_str(), static_cast<GLsizei>(uniformName.length()));
|
||||
return;
|
||||
}
|
||||
auto getProgramResourceName = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceName;
|
||||
if (!getProgramResourceName) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
return;
|
||||
}
|
||||
getProgramResourceName(program, programInterface, index, bufSize, length, name);
|
||||
}
|
||||
|
||||
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
|
||||
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return;
|
||||
if (propCount < 0 || bufSize < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"propCount and bufSize must be non-negative."));
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
if (index >= programObject->GetUniformCount()) {
|
||||
String resourceName;
|
||||
if (!ProgramInterface::GetResourceName(*programObject, programInterface, index, resourceName)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "index is out of range."));
|
||||
return;
|
||||
}
|
||||
if (props == nullptr || params == nullptr) return;
|
||||
GLsizei written = 0;
|
||||
for (GLsizei i = 0; i < propCount && written < bufSize; ++i) {
|
||||
GLint value = 0;
|
||||
if (!GetUniformResourceProp(programObject, index, props[i], &value)) {
|
||||
// GL_ATOMIC_COUNTER_BUFFER_INDEX and the GL_REFERENCED_BY_* stage props are
|
||||
// not modelled here; ask the backend, which indexes resources by name.
|
||||
auto backendGetIndex = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceIndex;
|
||||
auto backendGetiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceiv;
|
||||
if (backendGetIndex && backendGetiv) {
|
||||
const GLuint backendIndex = backendGetIndex(program, GL_UNIFORM,
|
||||
programObject->GetActiveUniformName(index).c_str());
|
||||
if (backendIndex != GL_INVALID_INDEX) {
|
||||
GLsizei one = 0;
|
||||
backendGetiv(program, GL_UNIFORM, backendIndex, 1, &props[i], 1, &one, &value);
|
||||
CopyStr(bufSize, length, name, resourceName.c_str(), static_cast<GLsizei>(resourceName.length()));
|
||||
}
|
||||
}
|
||||
}
|
||||
params[written++] = value;
|
||||
}
|
||||
if (length) *length = written;
|
||||
|
||||
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
|
||||
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
|
||||
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return;
|
||||
if (!ProgramInterface::IsInterfaceEnum(programInterface)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "Unsupported program interface."));
|
||||
return;
|
||||
}
|
||||
auto getProgramResourceiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceiv;
|
||||
if (!getProgramResourceiv) {
|
||||
if (propCount <= 0 || bufSize < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"propCount must be positive and bufSize "
|
||||
"non-negative."));
|
||||
return;
|
||||
}
|
||||
if (props == nullptr) return;
|
||||
// Both prop checks run BEFORE any value is produced: a property this command does
|
||||
// not know at all is INVALID_ENUM, one it knows but the interface does not carry is
|
||||
// INVALID_OPERATION (GL 4.6 Table 7.2). The two are deliberately different errors.
|
||||
for (GLsizei i = 0; i < propCount; ++i) {
|
||||
if (!ProgramInterface::IsResourceProp(props[i])) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "prop is not a valid property name."));
|
||||
return;
|
||||
}
|
||||
if (!ProgramInterface::InterfaceSupportsProp(programInterface, props[i])) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
"prop is not supported for this program interface."));
|
||||
return;
|
||||
}
|
||||
getProgramResourceiv(program, programInterface, index, propCount, props, bufSize, length, params);
|
||||
}
|
||||
|
||||
Vector<GLint> values;
|
||||
for (GLsizei i = 0; i < propCount; ++i) {
|
||||
if (!ProgramInterface::GetResourceProp(*programObject, programInterface, index, props[i], values)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "index is out of range."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (params == nullptr) return;
|
||||
const GLsizei written = static_cast<GLsizei>(std::min<SizeT>(values.size(), static_cast<SizeT>(bufSize)));
|
||||
for (GLsizei i = 0; i < written; ++i) params[i] = values[i];
|
||||
if (length) *length = written;
|
||||
}
|
||||
|
||||
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
// Unlike the four queries above, this one and GetProgramResourceLocationIndex really
|
||||
// do require a successful link (GL 4.6 §7.3.1.3).
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return -1;
|
||||
auto getProgramResourceLocation = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceLocation;
|
||||
if (!getProgramResourceLocation) {
|
||||
if (!ProgramInterface::InterfaceHasLocations(programInterface)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
"Program interface has no locations."));
|
||||
return -1;
|
||||
}
|
||||
return getProgramResourceLocation(program, programInterface, name);
|
||||
return ProgramInterface::GetResourceLocation(*programObject, programInterface, name);
|
||||
}
|
||||
|
||||
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return -1;
|
||||
auto getProgramResourceLocationIndex = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceLocationIndex;
|
||||
if (!getProgramResourceLocationIndex) return -1;
|
||||
return getProgramResourceLocationIndex(program, programInterface, name);
|
||||
if (programInterface != GL_PROGRAM_OUTPUT) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"GetProgramResourceLocationIndex only accepts GL_PROGRAM_OUTPUT."));
|
||||
return -1;
|
||||
}
|
||||
return ProgramInterface::GetResourceLocationIndex(*programObject, programInterface, name);
|
||||
}
|
||||
|
||||
// GL 4.6 §7.6.2: <storageBlockIndex> is an active shader storage block index of <program>
|
||||
// - that is, exactly what glGetProgramResourceIndex(GL_SHADER_STORAGE_BLOCK) returned.
|
||||
// Since wave 2 that index is the interface-query layer's, so this is where the one index
|
||||
// space the application sees gets turned into whatever the backend's is; the backends are
|
||||
// handed the block NAME and do their own lookup. Getting this wrong is silent: the call
|
||||
// succeeds and rebinds a DIFFERENT buffer.
|
||||
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding) {
|
||||
auto& programObject = TryToGetProgramObject(program);
|
||||
if (!programObject || !programObject->GetLinkStatus()) return;
|
||||
if (!ValidateShaderStorageBlockBinding(storageBlockBinding)) return;
|
||||
String blockName;
|
||||
if (!ProgramInterface::GetResourceName(*programObject, GL_SHADER_STORAGE_BLOCK, storageBlockIndex,
|
||||
blockName)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"storageBlockIndex is not an active shader storage block index."));
|
||||
return;
|
||||
}
|
||||
// Recorded before the backend call, and independently of whether a backend is even
|
||||
// present: this is the state GL_BUFFER_BINDING reports, and it is also what reseeds a
|
||||
// backend's own reflection cache after any rebuild.
|
||||
programObject->SetShaderStorageBlockBinding(blockName, storageBlockBinding);
|
||||
auto shaderStorageBlockBinding = MG_Backend::gBackendFunctionsTable.GL.ShaderStorageBlockBinding;
|
||||
if (!shaderStorageBlockBinding) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
@@ -2743,7 +2767,7 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
"Backend does not support shader storage block binding."));
|
||||
return;
|
||||
}
|
||||
shaderStorageBlockBinding(program, storageBlockIndex, storageBlockBinding);
|
||||
shaderStorageBlockBinding(program, blockName.c_str(), storageBlockBinding);
|
||||
}
|
||||
|
||||
void ValidateProgram(GLuint program) {
|
||||
@@ -2780,6 +2804,16 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
// is written as that sequence rather than as a private shortcut - every error it can
|
||||
// raise is one of theirs, raised at the point they would raise it.
|
||||
GLuint CreateShaderProgramv(GLenum type, GLsizei count, const GLchar* const* strings) {
|
||||
// GL 4.6 core 7.3: a negative count is INVALID_VALUE and is checked before anything
|
||||
// is created, so a bad count never leaks a shader name. An unrecognised type is
|
||||
// INVALID_ENUM, which CreateShader_State raises below.
|
||||
if (count < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "count must be non-negative."));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const GLuint shader = CreateShader_State(type);
|
||||
if (shader == 0) return 0;
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
GLboolean IsProgram(GLuint program);
|
||||
GLboolean IsShader(GLuint shader);
|
||||
void LinkProgram(GLuint program);
|
||||
// GL_KHR_parallel_shader_compile / GL_ARB_parallel_shader_compile. Both names are the
|
||||
// same entry point; see MaxShaderCompilerThreadsKHR_State for the semantics of count.
|
||||
void MaxShaderCompilerThreadsKHR(GLuint count);
|
||||
void MaxShaderCompilerThreadsARB(GLuint count);
|
||||
void ShaderSource(GLuint shader, GLsizei count, const GLchar* const* string, const GLint* length);
|
||||
void UseProgram(GLuint program);
|
||||
void Uniform1f(GLint location, GLfloat v0);
|
||||
|
||||
@@ -0,0 +1,841 @@
|
||||
// MobileGL - MobileGL/MG_Impl/GLImpl/Program/ProgramInterface.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#include "ProgramInterface.h"
|
||||
|
||||
#include <MG_State/GLState/ProgramState/ProgramObject.h>
|
||||
#include <MG_Util/ShaderTranspiler/Types.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
|
||||
namespace {
|
||||
// glslang folds atomic counters into synthesized blocks named
|
||||
// "<getAtomicCounterBlockName()>_<binding>" (ParseContextBase.cpp), one per GL
|
||||
// atomic-counter binding point. That block IS the GL_ATOMIC_COUNTER_BUFFER resource
|
||||
// and its trailing number IS GL_BUFFER_BINDING; its members stay GL_UNIFORMs.
|
||||
constexpr const char* kAtomicCounterBlockPrefix = "gl_AtomicCounterBlock";
|
||||
|
||||
enum class BlockKind {
|
||||
Uniform, // a real GL uniform block
|
||||
GlobalUbo, // the synthesized MGL_GLOBAL_UBO: GL sees its members as default-block
|
||||
AtomicCounter, // gl_AtomicCounterBlock_<binding>
|
||||
Storage, // a shader storage block
|
||||
};
|
||||
|
||||
// One row of any interface. Fields a given interface does not have keep the
|
||||
// spec-mandated "not applicable" value, so a prop read never has to special-case
|
||||
// the interface a second time.
|
||||
struct Resource {
|
||||
String name;
|
||||
GLenum type = GL_NONE;
|
||||
GLint arraySize = 1;
|
||||
GLint location = -1;
|
||||
GLint locationIndex = -1;
|
||||
GLint blockIndex = -1;
|
||||
GLint offset = -1;
|
||||
GLint arrayStride = -1;
|
||||
GLint matrixStride = -1;
|
||||
GLint isRowMajor = 0;
|
||||
GLint atomicCounterBufferIndex = -1;
|
||||
GLint topLevelArraySize = 0;
|
||||
GLint topLevelArrayStride = 0;
|
||||
GLint bufferBinding = 0;
|
||||
GLint bufferDataSize = 0;
|
||||
GLint isPerPatch = 0;
|
||||
GLint xfbBufferIndex = 0;
|
||||
Uint32 stages = 0; // EShLanguageMask
|
||||
Vector<GLuint> activeVariables;
|
||||
};
|
||||
|
||||
using ResourceList = Vector<Resource>;
|
||||
|
||||
struct Model {
|
||||
ResourceList uniforms;
|
||||
ResourceList uniformBlocks;
|
||||
ResourceList atomicCounterBuffers;
|
||||
ResourceList bufferVariables;
|
||||
ResourceList storageBlocks;
|
||||
ResourceList programInputs;
|
||||
ResourceList programOutputs;
|
||||
ResourceList xfbVaryings;
|
||||
Bool valid = false;
|
||||
};
|
||||
|
||||
const ResourceList& EmptyList() {
|
||||
static const ResourceList empty;
|
||||
return empty;
|
||||
}
|
||||
|
||||
// ---- name spelling (cluster 6) -------------------------------------------------
|
||||
|
||||
Bool EndsWithZeroSubscript(const String& name) {
|
||||
return name.length() >= 3 && name.compare(name.length() - 3, 3, "[0]") == 0;
|
||||
}
|
||||
|
||||
// The enumerated spelling of an array resource is "name[0]". glslang already applies
|
||||
// that to uniforms and buffer variables (EShReflectionBasicArraySuffix), but never to
|
||||
// stage inputs/outputs, so those get it here.
|
||||
String WithArraySuffix(const String& name, const glslang::TType* type) {
|
||||
if (type == nullptr || !type->isArray() || EndsWithZeroSubscript(name)) return name;
|
||||
return name + "[0]";
|
||||
}
|
||||
|
||||
// GL_ARRAY_SIZE: element count for a sized array, 0 for a runtime-sized one
|
||||
// (a shader storage block's unsized trailing member), 1 for a non-array.
|
||||
GLint ArraySizeOf(const glslang::TType* type, GLint reflectedSize) {
|
||||
if (type != nullptr && type->isArray()) {
|
||||
if (!type->isSizedArray()) return 0;
|
||||
return type->getOuterArraySize();
|
||||
}
|
||||
return reflectedSize < 1 ? 1 : reflectedSize;
|
||||
}
|
||||
|
||||
// Two spellings name the same resource when they are equal, or differ only by the
|
||||
// "[0]" the enumeration appends to an array.
|
||||
Bool NamesMatch(const String& resourceName, const String& query) {
|
||||
if (resourceName == query) return true;
|
||||
if (EndsWithZeroSubscript(resourceName) &&
|
||||
resourceName.compare(0, resourceName.length() - 3, query) == 0) {
|
||||
return true;
|
||||
}
|
||||
return EndsWithZeroSubscript(query) && query.compare(0, query.length() - 3, resourceName) == 0;
|
||||
}
|
||||
|
||||
// Splits "base[k]" into ("base", k). GL 4.6 §7.3.1.1 requires the subscript to be a
|
||||
// decimal integer with no white space and no leading zeros, which is exactly what
|
||||
// separates array-names' "a[1]" (resolves) from "a[01]", "a[0 + 0]" and "a[ 0]" (do
|
||||
// not). Returns false when there is no trailing subscript at all; sets `malformed`
|
||||
// when there is one but it is not a strict decimal.
|
||||
Bool SplitTrailingSubscript(const String& name, String& outBase, Uint& outElement, Bool& outMalformed) {
|
||||
outMalformed = false;
|
||||
if (name.empty() || name.back() != ']') return false;
|
||||
const SizeT bracket = name.rfind('[');
|
||||
if (bracket == String::npos) return false;
|
||||
const SizeT first = bracket + 1;
|
||||
const SizeT last = name.length() - 1; // one past the digits
|
||||
if (first >= last) {
|
||||
outMalformed = true;
|
||||
return false;
|
||||
}
|
||||
// No leading zeros: "0" is the only spelling that may start with '0'.
|
||||
if (name[first] == '0' && last - first > 1) {
|
||||
outMalformed = true;
|
||||
return false;
|
||||
}
|
||||
Uint element = 0;
|
||||
for (SizeT i = first; i < last; ++i) {
|
||||
if (name[i] < '0' || name[i] > '9') {
|
||||
outMalformed = true;
|
||||
return false;
|
||||
}
|
||||
element = element * 10 + static_cast<Uint>(name[i] - '0');
|
||||
if (element > 0x0FFFFFFFu) {
|
||||
outMalformed = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
outBase = name.substr(0, bracket);
|
||||
outElement = element;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- block classification ------------------------------------------------------
|
||||
|
||||
Bool IsAtomicCounterBlockName(const String& name) {
|
||||
return name.compare(0, std::strlen(kAtomicCounterBlockPrefix), kAtomicCounterBlockPrefix) == 0;
|
||||
}
|
||||
|
||||
// "gl_AtomicCounterBlock_5" -> 5. The suffix is the GL binding the counters were
|
||||
// declared with, which glslang does NOT keep in the block's own layout qualifier
|
||||
// (that one is remapped to a plain buffer binding).
|
||||
GLint AtomicCounterBlockBinding(const String& name) {
|
||||
const SizeT underscore = name.rfind('_');
|
||||
if (underscore == String::npos || underscore + 1 >= name.length()) return 0;
|
||||
GLint binding = 0;
|
||||
for (SizeT i = underscore + 1; i < name.length(); ++i) {
|
||||
if (name[i] < '0' || name[i] > '9') return 0;
|
||||
binding = binding * 10 + (name[i] - '0');
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
// Element index of an arrayed block instance ("TrickyBuffer[1]" -> 1).
|
||||
GLint BlockArrayElement(const String& name) {
|
||||
String base;
|
||||
Uint element = 0;
|
||||
Bool malformed = false;
|
||||
if (!SplitTrailingSubscript(name, base, element, malformed)) return 0;
|
||||
return static_cast<GLint>(element);
|
||||
}
|
||||
|
||||
BlockKind ClassifyBlock(const glslang::TObjectReflection& block) {
|
||||
if (std::strstr(block.name.c_str(), MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) != nullptr) {
|
||||
return BlockKind::GlobalUbo;
|
||||
}
|
||||
if (IsAtomicCounterBlockName(block.name)) return BlockKind::AtomicCounter;
|
||||
const glslang::TType* type = block.getType();
|
||||
if (type != nullptr && type->getQualifier().storage == glslang::EvqBuffer) return BlockKind::Storage;
|
||||
return BlockKind::Uniform;
|
||||
}
|
||||
|
||||
// std140/std430 column stride, the same vec4-rounded rule ProgramObject applies to
|
||||
// uniform matrices. 0 for a non-matrix.
|
||||
GLint MatrixStrideOf(const glslang::TType* type) {
|
||||
if (type == nullptr || !type->isMatrix()) return 0;
|
||||
const bool rowMajor = type->getQualifier().layoutMatrix == glslang::ElmRowMajor;
|
||||
const int strideVectorComponents = rowMajor ? type->getMatrixCols() : type->getMatrixRows();
|
||||
constexpr int scalarSize = 4;
|
||||
const int vectorAlignment = (strideVectorComponents <= 1) ? scalarSize
|
||||
: (strideVectorComponents == 2) ? 2 * scalarSize
|
||||
: 4 * scalarSize;
|
||||
return (vectorAlignment + 15) & ~15;
|
||||
}
|
||||
|
||||
GLint IsRowMajorOf(const glslang::TType* type) {
|
||||
if (type == nullptr || !type->isMatrix()) return 0;
|
||||
return type->getQualifier().layoutMatrix == glslang::ElmRowMajor ? 1 : 0;
|
||||
}
|
||||
|
||||
GLint MappedLocation(Int rawLocation) {
|
||||
// glslang parks "no location" at layoutLocationEnd; GL spells it -1.
|
||||
if (rawLocation < 0 || rawLocation >= static_cast<Int>(glslang::TQualifier::layoutLocationEnd)) return -1;
|
||||
return rawLocation;
|
||||
}
|
||||
|
||||
// ---- model construction --------------------------------------------------------
|
||||
|
||||
void BuildBlocks(ProgramObject& program, const glslang::TProgram& reflection, Model& model,
|
||||
Vector<BlockKind>& blockKind, Vector<Int>& blockInterfaceIndex) {
|
||||
const Int blockCount = const_cast<glslang::TProgram&>(reflection).getNumUniformBlocks();
|
||||
blockKind.assign(blockCount, BlockKind::Uniform);
|
||||
blockInterfaceIndex.assign(blockCount, -1);
|
||||
|
||||
for (Int tIndex = 0; tIndex < blockCount; ++tIndex) {
|
||||
const auto& block = const_cast<glslang::TProgram&>(reflection).getUniformBlock(tIndex);
|
||||
const BlockKind kind = ClassifyBlock(block);
|
||||
blockKind[tIndex] = kind;
|
||||
if (kind == BlockKind::AtomicCounter) {
|
||||
Resource resource;
|
||||
// GL_ATOMIC_COUNTER_BUFFER resources have no name (and GetProgramResource
|
||||
// Index/Name reject the interface outright, which is why this stays empty).
|
||||
resource.bufferBinding = AtomicCounterBlockBinding(block.name);
|
||||
resource.bufferDataSize = block.size;
|
||||
resource.stages = static_cast<Uint32>(block.stages);
|
||||
blockInterfaceIndex[tIndex] = static_cast<Int>(model.atomicCounterBuffers.size());
|
||||
model.atomicCounterBuffers.push_back(Move(resource));
|
||||
} else if (kind == BlockKind::Storage) {
|
||||
Resource resource;
|
||||
resource.name = block.name;
|
||||
// glslang reports the DECLARED binding for every instance of an arrayed
|
||||
// block; GL gives element k the binding base + k. That is only the initial
|
||||
// value: GL_BUFFER_BINDING must report the CURRENT binding, so a later
|
||||
// glShaderStorageBlockBinding wins over the declaration (GL 4.6 §7.6.2 -
|
||||
// exactly the same rule GL_UNIFORM_BLOCK follows through
|
||||
// GetUniformBlockBinding below).
|
||||
const GLint declared = block.getBinding();
|
||||
resource.bufferBinding = declared < 0 ? 0 : declared + BlockArrayElement(block.name);
|
||||
const Int rebound = program.GetShaderStorageBlockBindingOverride(block.name);
|
||||
if (rebound >= 0) resource.bufferBinding = static_cast<GLint>(rebound);
|
||||
resource.bufferDataSize = block.size;
|
||||
resource.stages = static_cast<Uint32>(block.stages);
|
||||
blockInterfaceIndex[tIndex] = static_cast<Int>(model.storageBlocks.size());
|
||||
model.storageBlocks.push_back(Move(resource));
|
||||
}
|
||||
}
|
||||
|
||||
// GL_UNIFORM_BLOCK keeps the index space glUniformBlockBinding and
|
||||
// glGetActiveUniformBlockiv already use, so an index handed out here is usable
|
||||
// with them (which is exactly what the CTS does).
|
||||
const Int glBlockCount = program.GetActiveUniformBlocksCount();
|
||||
for (Int glIndex = 0; glIndex < glBlockCount; ++glIndex) {
|
||||
Resource resource;
|
||||
resource.name = program.GetUniformBlockName(glIndex);
|
||||
resource.bufferBinding = static_cast<GLint>(program.GetUniformBlockBinding(glIndex));
|
||||
resource.bufferDataSize = static_cast<GLint>(program.GetUBOSizeAt(glIndex));
|
||||
const Int tIndex = program.TProgramBlockIndex(static_cast<Uint>(glIndex));
|
||||
if (tIndex >= 0 && tIndex < blockCount) {
|
||||
resource.stages =
|
||||
static_cast<Uint32>(const_cast<glslang::TProgram&>(reflection).getUniformBlock(tIndex).stages);
|
||||
}
|
||||
model.uniformBlocks.push_back(Move(resource));
|
||||
}
|
||||
}
|
||||
|
||||
void BuildUniformsAndBufferVariables(ProgramObject& program, const glslang::TProgram& reflection, Model& model,
|
||||
const Vector<BlockKind>& blockKind,
|
||||
const Vector<Int>& blockInterfaceIndex) {
|
||||
const Uint uniformCount = program.GetUniformCount();
|
||||
for (Uint glIndex = 0; glIndex < uniformCount; ++glIndex) {
|
||||
const Int tIndex = program.TProgramUniformIndex(glIndex);
|
||||
const auto& refl = const_cast<glslang::TProgram&>(reflection).getUniform(tIndex);
|
||||
const glslang::TType* type = refl.getType();
|
||||
const Int owner = refl.index;
|
||||
const BlockKind kind = (owner >= 0 && owner < static_cast<Int>(blockKind.size()))
|
||||
? blockKind[owner]
|
||||
: BlockKind::GlobalUbo;
|
||||
|
||||
Resource resource;
|
||||
resource.name = refl.name;
|
||||
resource.type = static_cast<GLenum>(refl.glDefineType);
|
||||
resource.arraySize = ArraySizeOf(type, refl.size);
|
||||
resource.stages = static_cast<Uint32>(refl.stages);
|
||||
|
||||
if (kind == BlockKind::Storage) {
|
||||
resource.blockIndex = blockInterfaceIndex[owner];
|
||||
resource.offset = refl.offset;
|
||||
resource.arrayStride = refl.arrayStride;
|
||||
resource.matrixStride = MatrixStrideOf(type);
|
||||
resource.isRowMajor = IsRowMajorOf(type);
|
||||
// GL requires 1 for a member that is not inside a top-level array (and for
|
||||
// the top-level array itself); glslang leaves 0/-1 there.
|
||||
resource.topLevelArraySize = refl.topLevelArraySize > 0 ? refl.topLevelArraySize : 1;
|
||||
resource.topLevelArrayStride = refl.topLevelArrayStride;
|
||||
model.bufferVariables.push_back(Move(resource));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kind == BlockKind::AtomicCounter) {
|
||||
// An atomic counter is a default-block uniform with no location and no
|
||||
// owning uniform block; what it does have is a buffer to point at.
|
||||
resource.type = GL_UNSIGNED_INT_ATOMIC_COUNTER;
|
||||
resource.blockIndex = -1;
|
||||
resource.offset = refl.offset;
|
||||
resource.arrayStride = refl.arrayStride;
|
||||
resource.matrixStride = 0;
|
||||
resource.atomicCounterBufferIndex = blockInterfaceIndex[owner];
|
||||
resource.location = -1;
|
||||
} else {
|
||||
resource.blockIndex = program.GetActiveUniformBlockIndex(glIndex);
|
||||
resource.offset = program.GetActiveUniformOffset(glIndex);
|
||||
resource.arrayStride = program.GetActiveUniformArrayStride(glIndex);
|
||||
resource.matrixStride = program.GetActiveUniformMatrixStride(glIndex);
|
||||
resource.isRowMajor = program.GetActiveUniformIsRowMajor(glIndex);
|
||||
// A member of a named uniform block has no location, whatever the
|
||||
// frontend's own location table says (it hands one out to every uniform
|
||||
// so glUniform* can address block members through the global UBO).
|
||||
resource.location =
|
||||
resource.blockIndex >= 0 ? -1 : program.GetUniformLocation(refl.name);
|
||||
}
|
||||
model.uniforms.push_back(Move(resource));
|
||||
}
|
||||
|
||||
// GL_ACTIVE_VARIABLES, both directions.
|
||||
for (SizeT i = 0; i < model.uniforms.size(); ++i) {
|
||||
const Resource& uniform = model.uniforms[i];
|
||||
if (uniform.atomicCounterBufferIndex >= 0 &&
|
||||
uniform.atomicCounterBufferIndex < static_cast<GLint>(model.atomicCounterBuffers.size())) {
|
||||
model.atomicCounterBuffers[uniform.atomicCounterBufferIndex].activeVariables.push_back(
|
||||
static_cast<GLuint>(i));
|
||||
}
|
||||
}
|
||||
for (SizeT blockIndex = 0; blockIndex < model.uniformBlocks.size(); ++blockIndex) {
|
||||
// Members of an arrayed block are reflected once, against instance [0].
|
||||
const Int owner = static_cast<Int>(program.GetUniformBlockMemberOwnerIndex(static_cast<Uint>(blockIndex)));
|
||||
for (SizeT i = 0; i < model.uniforms.size(); ++i) {
|
||||
if (model.uniforms[i].blockIndex == owner) {
|
||||
model.uniformBlocks[blockIndex].activeVariables.push_back(static_cast<GLuint>(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (SizeT blockIndex = 0; blockIndex < model.storageBlocks.size(); ++blockIndex) {
|
||||
for (SizeT i = 0; i < model.bufferVariables.size(); ++i) {
|
||||
if (model.bufferVariables[i].blockIndex == static_cast<GLint>(blockIndex)) {
|
||||
model.storageBlocks[blockIndex].activeVariables.push_back(static_cast<GLuint>(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void BuildStageIO(ProgramObject& program, const glslang::TProgram& reflection, Model& model) {
|
||||
auto& mutableReflection = const_cast<glslang::TProgram&>(reflection);
|
||||
|
||||
const Int inputCount = mutableReflection.getNumPipeInputs();
|
||||
for (Int index = 0; index < inputCount; ++index) {
|
||||
const auto& refl = mutableReflection.getPipeInput(index);
|
||||
const glslang::TType* type = refl.getType();
|
||||
Resource resource;
|
||||
// The Vulkan-semantics parse reflects the vertex builtins under their SPIR-V
|
||||
// names; GL enumerates the GL spellings.
|
||||
const String& glName = ProgramObject::NormalizeBuiltinPipeInputName(refl.name);
|
||||
resource.name = WithArraySuffix(glName, type);
|
||||
resource.type = static_cast<GLenum>(refl.glDefineType);
|
||||
resource.arraySize = ArraySizeOf(type, refl.size);
|
||||
resource.location = program.GetAttributeLocation(refl.name);
|
||||
if (resource.location < 0) resource.location = MappedLocation(static_cast<Int>(refl.layoutLocation()));
|
||||
resource.isPerPatch = (type != nullptr && type->getQualifier().patch) ? 1 : 0;
|
||||
resource.stages = static_cast<Uint32>(refl.stages);
|
||||
model.programInputs.push_back(Move(resource));
|
||||
}
|
||||
|
||||
const Int outputCount = mutableReflection.getNumPipeOutputs();
|
||||
for (Int index = 0; index < outputCount; ++index) {
|
||||
const auto& refl = mutableReflection.getPipeOutput(index);
|
||||
const glslang::TType* type = refl.getType();
|
||||
Resource resource;
|
||||
resource.name = WithArraySuffix(refl.name, type);
|
||||
resource.type = static_cast<GLenum>(refl.glDefineType);
|
||||
resource.arraySize = ArraySizeOf(type, refl.size);
|
||||
resource.location = MappedLocation(program.GetFragmentDataLocation(refl.name.c_str()));
|
||||
if (resource.location < 0) {
|
||||
// A built-in output (gl_FragDepth, gl_SampleMask) and a non-fragment stage
|
||||
// output both have no location, and therefore no color index either.
|
||||
resource.locationIndex = -1;
|
||||
} else {
|
||||
resource.locationIndex = program.GetFragmentDataIndex(refl.name.c_str());
|
||||
// glBindFragDataLocationIndexed wins; otherwise the shader's
|
||||
// layout(index = N), which the frag-data maps never saw.
|
||||
if (resource.locationIndex == 0 && type != nullptr && type->getQualifier().hasIndex()) {
|
||||
resource.locationIndex = static_cast<GLint>(type->getQualifier().layoutIndex);
|
||||
}
|
||||
}
|
||||
resource.isPerPatch = (type != nullptr && type->getQualifier().patch) ? 1 : 0;
|
||||
resource.stages = static_cast<Uint32>(refl.stages);
|
||||
model.programOutputs.push_back(Move(resource));
|
||||
}
|
||||
}
|
||||
|
||||
void BuildXfb(ProgramObject& program, Model& model) {
|
||||
const auto& requested = program.GetTransformFeedbackInterfaceNames();
|
||||
const auto& captured = program.GetTransformFeedbackVaryings();
|
||||
for (const String& name : requested) {
|
||||
Resource resource;
|
||||
resource.name = name;
|
||||
// ARB_transform_feedback3's layout controls are enumerated as resources of
|
||||
// type NONE: gl_NextBuffer with array size 0, gl_SkipComponentsN with N.
|
||||
if (name == "gl_NextBuffer") {
|
||||
resource.type = GL_NONE;
|
||||
resource.arraySize = 0;
|
||||
} else if (name.size() == 18 && name.compare(0, 17, "gl_SkipComponents") == 0 && name[17] >= '1' &&
|
||||
name[17] <= '4') {
|
||||
resource.type = GL_NONE;
|
||||
resource.arraySize = name[17] - '0';
|
||||
} else {
|
||||
resource.type = GL_NONE;
|
||||
resource.arraySize = 1;
|
||||
for (const auto& varying : captured) {
|
||||
if (varying.name != name) continue;
|
||||
resource.type = varying.type;
|
||||
resource.arraySize = varying.size < 1 ? 1 : varying.size;
|
||||
resource.offset = static_cast<GLint>(varying.offsetBytes);
|
||||
resource.xfbBufferIndex = static_cast<GLint>(varying.bufferIndex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
model.xfbVaryings.push_back(Move(resource));
|
||||
}
|
||||
}
|
||||
|
||||
Model BuildModel(ProgramObject& program) {
|
||||
Model model;
|
||||
if (!program.GetLinkStatus()) return model;
|
||||
const glslang::TProgram* reflection = program.GetReflection();
|
||||
if (reflection == nullptr) return model;
|
||||
model.valid = true;
|
||||
|
||||
Vector<BlockKind> blockKind;
|
||||
Vector<Int> blockInterfaceIndex;
|
||||
BuildBlocks(program, *reflection, model, blockKind, blockInterfaceIndex);
|
||||
BuildUniformsAndBufferVariables(program, *reflection, model, blockKind, blockInterfaceIndex);
|
||||
BuildStageIO(program, *reflection, model);
|
||||
BuildXfb(program, model);
|
||||
return model;
|
||||
}
|
||||
|
||||
const ResourceList& Select(const Model& model, GLenum programInterface) {
|
||||
switch (programInterface) {
|
||||
case GL_UNIFORM:
|
||||
return model.uniforms;
|
||||
case GL_UNIFORM_BLOCK:
|
||||
return model.uniformBlocks;
|
||||
case GL_ATOMIC_COUNTER_BUFFER:
|
||||
return model.atomicCounterBuffers;
|
||||
case GL_BUFFER_VARIABLE:
|
||||
return model.bufferVariables;
|
||||
case GL_SHADER_STORAGE_BLOCK:
|
||||
return model.storageBlocks;
|
||||
case GL_PROGRAM_INPUT:
|
||||
return model.programInputs;
|
||||
case GL_PROGRAM_OUTPUT:
|
||||
return model.programOutputs;
|
||||
case GL_TRANSFORM_FEEDBACK_VARYING:
|
||||
return model.xfbVaryings;
|
||||
default:
|
||||
// The subroutine interfaces are accepted by the API but nothing can populate
|
||||
// them: glslang refuses `subroutine` when generating SPIR-V, so a program
|
||||
// using one never links. Zero active resources is the honest answer.
|
||||
return EmptyList();
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Bool IsInterfaceEnum(GLenum programInterface) {
|
||||
switch (programInterface) {
|
||||
case GL_UNIFORM:
|
||||
case GL_UNIFORM_BLOCK:
|
||||
case GL_PROGRAM_INPUT:
|
||||
case GL_PROGRAM_OUTPUT:
|
||||
case GL_BUFFER_VARIABLE:
|
||||
case GL_SHADER_STORAGE_BLOCK:
|
||||
case GL_ATOMIC_COUNTER_BUFFER:
|
||||
case GL_TRANSFORM_FEEDBACK_VARYING:
|
||||
case GL_TRANSFORM_FEEDBACK_BUFFER:
|
||||
case GL_VERTEX_SUBROUTINE:
|
||||
case GL_TESS_CONTROL_SUBROUTINE:
|
||||
case GL_TESS_EVALUATION_SUBROUTINE:
|
||||
case GL_GEOMETRY_SUBROUTINE:
|
||||
case GL_FRAGMENT_SUBROUTINE:
|
||||
case GL_COMPUTE_SUBROUTINE:
|
||||
case GL_VERTEX_SUBROUTINE_UNIFORM:
|
||||
case GL_TESS_CONTROL_SUBROUTINE_UNIFORM:
|
||||
case GL_TESS_EVALUATION_SUBROUTINE_UNIFORM:
|
||||
case GL_GEOMETRY_SUBROUTINE_UNIFORM:
|
||||
case GL_FRAGMENT_SUBROUTINE_UNIFORM:
|
||||
case GL_COMPUTE_SUBROUTINE_UNIFORM:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Bool IsNamedInterface(GLenum programInterface) {
|
||||
// GL 4.6 §7.3.1.2: the two buffer interfaces have no resource names, and asking for
|
||||
// one is INVALID_ENUM (deliberately asymmetric with GetProgramInterfaceiv, which
|
||||
// does count them).
|
||||
return IsInterfaceEnum(programInterface) && programInterface != GL_ATOMIC_COUNTER_BUFFER &&
|
||||
programInterface != GL_TRANSFORM_FEEDBACK_BUFFER;
|
||||
}
|
||||
|
||||
Bool InterfaceHasLocations(GLenum programInterface) {
|
||||
switch (programInterface) {
|
||||
case GL_UNIFORM:
|
||||
case GL_PROGRAM_INPUT:
|
||||
case GL_PROGRAM_OUTPUT:
|
||||
case GL_VERTEX_SUBROUTINE_UNIFORM:
|
||||
case GL_TESS_CONTROL_SUBROUTINE_UNIFORM:
|
||||
case GL_TESS_EVALUATION_SUBROUTINE_UNIFORM:
|
||||
case GL_GEOMETRY_SUBROUTINE_UNIFORM:
|
||||
case GL_FRAGMENT_SUBROUTINE_UNIFORM:
|
||||
case GL_COMPUTE_SUBROUTINE_UNIFORM:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Bool IsResourceProp(GLenum prop) {
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
case GL_TYPE:
|
||||
case GL_ARRAY_SIZE:
|
||||
case GL_OFFSET:
|
||||
case GL_BLOCK_INDEX:
|
||||
case GL_ARRAY_STRIDE:
|
||||
case GL_MATRIX_STRIDE:
|
||||
case GL_IS_ROW_MAJOR:
|
||||
case GL_ATOMIC_COUNTER_BUFFER_INDEX:
|
||||
case GL_BUFFER_BINDING:
|
||||
case GL_BUFFER_DATA_SIZE:
|
||||
case GL_NUM_ACTIVE_VARIABLES:
|
||||
case GL_ACTIVE_VARIABLES:
|
||||
case GL_REFERENCED_BY_VERTEX_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
|
||||
case GL_REFERENCED_BY_GEOMETRY_SHADER:
|
||||
case GL_REFERENCED_BY_FRAGMENT_SHADER:
|
||||
case GL_REFERENCED_BY_COMPUTE_SHADER:
|
||||
case GL_TOP_LEVEL_ARRAY_SIZE:
|
||||
case GL_TOP_LEVEL_ARRAY_STRIDE:
|
||||
case GL_LOCATION:
|
||||
case GL_LOCATION_INDEX:
|
||||
case GL_IS_PER_PATCH:
|
||||
case GL_LOCATION_COMPONENT:
|
||||
case GL_TRANSFORM_FEEDBACK_BUFFER_INDEX:
|
||||
case GL_TRANSFORM_FEEDBACK_BUFFER_STRIDE:
|
||||
case GL_NUM_COMPATIBLE_SUBROUTINES:
|
||||
case GL_COMPATIBLE_SUBROUTINES:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// GL 4.6 Table 7.2, transcribed row by row: which interfaces each property applies to.
|
||||
// Too tight a table turns a currently-answered prop into a fresh INVALID_OPERATION, so
|
||||
// the rows below are deliberately no narrower than the spec's.
|
||||
Bool InterfaceSupportsProp(GLenum programInterface, GLenum prop) {
|
||||
const Bool isSubroutine =
|
||||
programInterface == GL_VERTEX_SUBROUTINE || programInterface == GL_TESS_CONTROL_SUBROUTINE ||
|
||||
programInterface == GL_TESS_EVALUATION_SUBROUTINE || programInterface == GL_GEOMETRY_SUBROUTINE ||
|
||||
programInterface == GL_FRAGMENT_SUBROUTINE || programInterface == GL_COMPUTE_SUBROUTINE;
|
||||
const Bool isSubroutineUniform =
|
||||
programInterface == GL_VERTEX_SUBROUTINE_UNIFORM ||
|
||||
programInterface == GL_TESS_CONTROL_SUBROUTINE_UNIFORM ||
|
||||
programInterface == GL_TESS_EVALUATION_SUBROUTINE_UNIFORM ||
|
||||
programInterface == GL_GEOMETRY_SUBROUTINE_UNIFORM ||
|
||||
programInterface == GL_FRAGMENT_SUBROUTINE_UNIFORM || programInterface == GL_COMPUTE_SUBROUTINE_UNIFORM;
|
||||
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
return programInterface != GL_ATOMIC_COUNTER_BUFFER && programInterface != GL_TRANSFORM_FEEDBACK_BUFFER;
|
||||
case GL_TYPE:
|
||||
case GL_ARRAY_SIZE:
|
||||
return programInterface == GL_UNIFORM || programInterface == GL_PROGRAM_INPUT ||
|
||||
programInterface == GL_PROGRAM_OUTPUT || programInterface == GL_BUFFER_VARIABLE ||
|
||||
programInterface == GL_TRANSFORM_FEEDBACK_VARYING ||
|
||||
(prop == GL_ARRAY_SIZE && isSubroutineUniform);
|
||||
case GL_OFFSET:
|
||||
return programInterface == GL_UNIFORM || programInterface == GL_BUFFER_VARIABLE ||
|
||||
programInterface == GL_TRANSFORM_FEEDBACK_VARYING;
|
||||
case GL_BLOCK_INDEX:
|
||||
case GL_ARRAY_STRIDE:
|
||||
case GL_MATRIX_STRIDE:
|
||||
case GL_IS_ROW_MAJOR:
|
||||
return programInterface == GL_UNIFORM || programInterface == GL_BUFFER_VARIABLE;
|
||||
case GL_ATOMIC_COUNTER_BUFFER_INDEX:
|
||||
return programInterface == GL_UNIFORM;
|
||||
case GL_BUFFER_BINDING:
|
||||
case GL_NUM_ACTIVE_VARIABLES:
|
||||
case GL_ACTIVE_VARIABLES:
|
||||
// Table 7.2 lists GL_TRANSFORM_FEEDBACK_BUFFER on these three rows too. This
|
||||
// implementation enumerates no resources on that interface, so the query still
|
||||
// ends in an error - but INVALID_VALUE for the out-of-range index, not the
|
||||
// INVALID_OPERATION a narrower table would invent.
|
||||
return programInterface == GL_UNIFORM_BLOCK || programInterface == GL_ATOMIC_COUNTER_BUFFER ||
|
||||
programInterface == GL_SHADER_STORAGE_BLOCK ||
|
||||
programInterface == GL_TRANSFORM_FEEDBACK_BUFFER;
|
||||
case GL_BUFFER_DATA_SIZE:
|
||||
return programInterface == GL_UNIFORM_BLOCK || programInterface == GL_ATOMIC_COUNTER_BUFFER ||
|
||||
programInterface == GL_SHADER_STORAGE_BLOCK;
|
||||
case GL_REFERENCED_BY_VERTEX_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
|
||||
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
|
||||
case GL_REFERENCED_BY_GEOMETRY_SHADER:
|
||||
case GL_REFERENCED_BY_FRAGMENT_SHADER:
|
||||
case GL_REFERENCED_BY_COMPUTE_SHADER:
|
||||
return programInterface == GL_UNIFORM || programInterface == GL_UNIFORM_BLOCK ||
|
||||
programInterface == GL_ATOMIC_COUNTER_BUFFER || programInterface == GL_BUFFER_VARIABLE ||
|
||||
programInterface == GL_SHADER_STORAGE_BLOCK || programInterface == GL_PROGRAM_INPUT ||
|
||||
programInterface == GL_PROGRAM_OUTPUT || isSubroutineUniform;
|
||||
case GL_TOP_LEVEL_ARRAY_SIZE:
|
||||
case GL_TOP_LEVEL_ARRAY_STRIDE:
|
||||
return programInterface == GL_BUFFER_VARIABLE;
|
||||
case GL_LOCATION:
|
||||
return InterfaceHasLocations(programInterface);
|
||||
case GL_LOCATION_INDEX:
|
||||
return programInterface == GL_PROGRAM_OUTPUT;
|
||||
case GL_IS_PER_PATCH:
|
||||
case GL_LOCATION_COMPONENT:
|
||||
return programInterface == GL_PROGRAM_INPUT || programInterface == GL_PROGRAM_OUTPUT;
|
||||
case GL_TRANSFORM_FEEDBACK_BUFFER_INDEX:
|
||||
return programInterface == GL_TRANSFORM_FEEDBACK_VARYING;
|
||||
case GL_TRANSFORM_FEEDBACK_BUFFER_STRIDE:
|
||||
return programInterface == GL_TRANSFORM_FEEDBACK_BUFFER;
|
||||
case GL_NUM_COMPATIBLE_SUBROUTINES:
|
||||
case GL_COMPATIBLE_SUBROUTINES:
|
||||
return isSubroutineUniform;
|
||||
default:
|
||||
(void)isSubroutine;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Int GetActiveResourceCount(ProgramObject& program, GLenum programInterface) {
|
||||
const Model model = BuildModel(program);
|
||||
return static_cast<Int>(Select(model, programInterface).size());
|
||||
}
|
||||
|
||||
Int GetMaxNameLength(ProgramObject& program, GLenum programInterface) {
|
||||
if (!IsNamedInterface(programInterface)) return 0;
|
||||
const Model model = BuildModel(program);
|
||||
SizeT longest = 0;
|
||||
for (const Resource& resource : Select(model, programInterface)) {
|
||||
longest = std::max(longest, resource.name.length() + 1);
|
||||
}
|
||||
return static_cast<Int>(longest);
|
||||
}
|
||||
|
||||
Int GetMaxNumActiveVariables(ProgramObject& program, GLenum programInterface) {
|
||||
const Model model = BuildModel(program);
|
||||
SizeT longest = 0;
|
||||
for (const Resource& resource : Select(model, programInterface)) {
|
||||
longest = std::max(longest, resource.activeVariables.size());
|
||||
}
|
||||
return static_cast<Int>(longest);
|
||||
}
|
||||
|
||||
GLuint GetResourceIndex(ProgramObject& program, GLenum programInterface, const char* name) {
|
||||
if (name == nullptr || name[0] == '\0') return GL_INVALID_INDEX;
|
||||
const Model model = BuildModel(program);
|
||||
const ResourceList& resources = Select(model, programInterface);
|
||||
const String query = name;
|
||||
// The layout controls of an interleaved capture are enumerable but not addressable
|
||||
// by name (GL 4.6 §7.3.1.1).
|
||||
if (programInterface == GL_TRANSFORM_FEEDBACK_VARYING &&
|
||||
(query == "gl_NextBuffer" ||
|
||||
(query.size() == 18 && query.compare(0, 17, "gl_SkipComponents") == 0))) {
|
||||
return GL_INVALID_INDEX;
|
||||
}
|
||||
for (SizeT i = 0; i < resources.size(); ++i) {
|
||||
if (NamesMatch(resources[i].name, query)) return static_cast<GLuint>(i);
|
||||
}
|
||||
return GL_INVALID_INDEX;
|
||||
}
|
||||
|
||||
Bool GetResourceName(ProgramObject& program, GLenum programInterface, GLuint index, String& outName) {
|
||||
const Model model = BuildModel(program);
|
||||
const ResourceList& resources = Select(model, programInterface);
|
||||
if (index >= resources.size()) return false;
|
||||
outName = resources[index].name;
|
||||
return true;
|
||||
}
|
||||
|
||||
Bool GetResourceProp(ProgramObject& program, GLenum programInterface, GLuint index, GLenum prop,
|
||||
Vector<GLint>& outValues) {
|
||||
const Model model = BuildModel(program);
|
||||
const ResourceList& resources = Select(model, programInterface);
|
||||
if (index >= resources.size()) return false;
|
||||
const Resource& resource = resources[index];
|
||||
|
||||
const auto referencedBy = [&resource](EShLanguage stage) {
|
||||
return (resource.stages & static_cast<Uint32>(1u << stage)) != 0 ? GL_TRUE : GL_FALSE;
|
||||
};
|
||||
|
||||
switch (prop) {
|
||||
case GL_NAME_LENGTH:
|
||||
outValues.push_back(static_cast<GLint>(resource.name.length() + 1));
|
||||
break;
|
||||
case GL_TYPE:
|
||||
outValues.push_back(static_cast<GLint>(resource.type));
|
||||
break;
|
||||
case GL_ARRAY_SIZE:
|
||||
outValues.push_back(resource.arraySize);
|
||||
break;
|
||||
case GL_OFFSET:
|
||||
outValues.push_back(resource.offset);
|
||||
break;
|
||||
case GL_BLOCK_INDEX:
|
||||
outValues.push_back(resource.blockIndex);
|
||||
break;
|
||||
case GL_ARRAY_STRIDE:
|
||||
outValues.push_back(resource.arrayStride);
|
||||
break;
|
||||
case GL_MATRIX_STRIDE:
|
||||
outValues.push_back(resource.matrixStride);
|
||||
break;
|
||||
case GL_IS_ROW_MAJOR:
|
||||
outValues.push_back(resource.isRowMajor);
|
||||
break;
|
||||
case GL_ATOMIC_COUNTER_BUFFER_INDEX:
|
||||
outValues.push_back(resource.atomicCounterBufferIndex);
|
||||
break;
|
||||
case GL_BUFFER_BINDING:
|
||||
outValues.push_back(resource.bufferBinding);
|
||||
break;
|
||||
case GL_BUFFER_DATA_SIZE:
|
||||
outValues.push_back(resource.bufferDataSize);
|
||||
break;
|
||||
case GL_NUM_ACTIVE_VARIABLES:
|
||||
outValues.push_back(static_cast<GLint>(resource.activeVariables.size()));
|
||||
break;
|
||||
case GL_ACTIVE_VARIABLES:
|
||||
for (const GLuint variable : resource.activeVariables) outValues.push_back(static_cast<GLint>(variable));
|
||||
break;
|
||||
case GL_REFERENCED_BY_VERTEX_SHADER:
|
||||
outValues.push_back(referencedBy(EShLangVertex));
|
||||
break;
|
||||
case GL_REFERENCED_BY_TESS_CONTROL_SHADER:
|
||||
outValues.push_back(referencedBy(EShLangTessControl));
|
||||
break;
|
||||
case GL_REFERENCED_BY_TESS_EVALUATION_SHADER:
|
||||
outValues.push_back(referencedBy(EShLangTessEvaluation));
|
||||
break;
|
||||
case GL_REFERENCED_BY_GEOMETRY_SHADER:
|
||||
outValues.push_back(referencedBy(EShLangGeometry));
|
||||
break;
|
||||
case GL_REFERENCED_BY_FRAGMENT_SHADER:
|
||||
outValues.push_back(referencedBy(EShLangFragment));
|
||||
break;
|
||||
case GL_REFERENCED_BY_COMPUTE_SHADER:
|
||||
outValues.push_back(referencedBy(EShLangCompute));
|
||||
break;
|
||||
case GL_TOP_LEVEL_ARRAY_SIZE:
|
||||
outValues.push_back(resource.topLevelArraySize);
|
||||
break;
|
||||
case GL_TOP_LEVEL_ARRAY_STRIDE:
|
||||
outValues.push_back(resource.topLevelArrayStride);
|
||||
break;
|
||||
case GL_LOCATION:
|
||||
outValues.push_back(resource.location);
|
||||
break;
|
||||
case GL_LOCATION_INDEX:
|
||||
outValues.push_back(resource.locationIndex);
|
||||
break;
|
||||
case GL_IS_PER_PATCH:
|
||||
outValues.push_back(resource.isPerPatch);
|
||||
break;
|
||||
case GL_LOCATION_COMPONENT:
|
||||
outValues.push_back(0);
|
||||
break;
|
||||
case GL_TRANSFORM_FEEDBACK_BUFFER_INDEX:
|
||||
outValues.push_back(resource.xfbBufferIndex);
|
||||
break;
|
||||
default:
|
||||
outValues.push_back(0);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
GLint GetResourceLocation(ProgramObject& program, GLenum programInterface, const char* name) {
|
||||
if (name == nullptr || name[0] == '\0') return -1;
|
||||
const String query = name;
|
||||
|
||||
String base;
|
||||
Uint element = 0;
|
||||
Bool malformed = false;
|
||||
const Bool subscripted = SplitTrailingSubscript(query, base, element, malformed);
|
||||
if (malformed) return -1;
|
||||
|
||||
const Model model = BuildModel(program);
|
||||
const ResourceList& resources = Select(model, programInterface);
|
||||
for (const Resource& resource : resources) {
|
||||
if (NamesMatch(resource.name, query)) return resource.location;
|
||||
}
|
||||
if (!subscripted || element == 0) return -1;
|
||||
// "d[1]" addresses the second element of an array resource enumerated as "d[0]".
|
||||
for (const Resource& resource : resources) {
|
||||
if (!NamesMatch(resource.name, base)) continue;
|
||||
if (resource.location < 0 || static_cast<GLint>(element) >= resource.arraySize) return -1;
|
||||
return resource.location + static_cast<GLint>(element);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
GLint GetResourceLocationIndex(ProgramObject& program, GLenum programInterface, const char* name) {
|
||||
if (programInterface != GL_PROGRAM_OUTPUT || name == nullptr || name[0] == '\0') return -1;
|
||||
const String query = name;
|
||||
String base;
|
||||
Uint element = 0;
|
||||
Bool malformed = false;
|
||||
const Bool subscripted = SplitTrailingSubscript(query, base, element, malformed);
|
||||
if (malformed) return -1;
|
||||
|
||||
const Model model = BuildModel(program);
|
||||
for (const Resource& resource : model.programOutputs) {
|
||||
if (NamesMatch(resource.name, query)) return resource.locationIndex;
|
||||
}
|
||||
if (!subscripted) return -1;
|
||||
for (const Resource& resource : model.programOutputs) {
|
||||
if (!NamesMatch(resource.name, base)) continue;
|
||||
if (resource.location < 0 || static_cast<GLint>(element) >= resource.arraySize) return -1;
|
||||
return resource.locationIndex;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
} // namespace MobileGL::MG_Impl::GLImpl::ProgramInterface
|
||||
@@ -0,0 +1,66 @@
|
||||
// MobileGL - MobileGL/MG_Impl/GLImpl/Program/ProgramInterface.h
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
class ProgramObject;
|
||||
}
|
||||
|
||||
// The GL program interface (ARB_program_interface_query / GL 4.3 §7.3.1) as a frontend
|
||||
// resource model.
|
||||
//
|
||||
// WHY IT IS HERE AND NOT IN A BACKEND. glGetProgramResource* describes the program the
|
||||
// APPLICATION wrote, in the application's namespace. Neither backend program is in that
|
||||
// namespace: DirectGLES compiles SPIRV-Cross-generated ESSL where default-block uniforms
|
||||
// live inside the synthesized MGL_GLOBAL_UBO (so a GL_UNIFORM location query against it is
|
||||
// structurally -1) and stage in/out names are rewritten; DirectVulkan has no GL-level
|
||||
// reflection at all and can only re-derive a partial, diverging copy. The one authoritative
|
||||
// source is the frontend glslang reflection a link already produced, which is the same
|
||||
// place glGetActiveUniform answers from. This layer generalizes that rule to every
|
||||
// interface, so the six entry points never consult gBackendFunctionsTable.
|
||||
//
|
||||
// NAMING RULES LIVE HERE, NOT IN ProgramObject. The interface query spells resources
|
||||
// differently from glGetActiveUniform / glGetActiveAttrib (an array is "name[0]", a lookup
|
||||
// accepts both "name" and "name[0]", a subscript must be a strict decimal). Those two
|
||||
// getters are what GL30-33 exercises and they must not move, so every normalization is
|
||||
// applied on the way in and out of THIS file.
|
||||
namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
|
||||
using ProgramObject = MG_State::GLState::ProgramObject;
|
||||
|
||||
// <programInterface> is one of the GL 4.6 Table 7.1 interfaces.
|
||||
Bool IsInterfaceEnum(GLenum programInterface);
|
||||
// Interfaces whose resources have names (everything except GL_ATOMIC_COUNTER_BUFFER).
|
||||
Bool IsNamedInterface(GLenum programInterface);
|
||||
// <prop> is a property token GetProgramResourceiv knows at all (else GL_INVALID_ENUM).
|
||||
Bool IsResourceProp(GLenum prop);
|
||||
// <prop> applies to <programInterface> (else GL_INVALID_OPERATION).
|
||||
Bool InterfaceSupportsProp(GLenum programInterface, GLenum prop);
|
||||
// Interfaces GetProgramResourceLocation accepts (else GL_INVALID_ENUM).
|
||||
Bool InterfaceHasLocations(GLenum programInterface);
|
||||
|
||||
// GL_ACTIVE_RESOURCES / GL_MAX_NAME_LENGTH / GL_MAX_NUM_ACTIVE_VARIABLES. All three
|
||||
// report zero for an interface this implementation cannot enumerate and for a program
|
||||
// that has not linked successfully - which is what the spec requires of a program with
|
||||
// no active resources.
|
||||
Int GetActiveResourceCount(ProgramObject& program, GLenum programInterface);
|
||||
Int GetMaxNameLength(ProgramObject& program, GLenum programInterface);
|
||||
Int GetMaxNumActiveVariables(ProgramObject& program, GLenum programInterface);
|
||||
|
||||
// GL_INVALID_INDEX when <name> names no active resource of the interface.
|
||||
GLuint GetResourceIndex(ProgramObject& program, GLenum programInterface, const char* name);
|
||||
// False when <index> is out of range for the interface (the caller raises INVALID_VALUE).
|
||||
Bool GetResourceName(ProgramObject& program, GLenum programInterface, GLuint index, String& outName);
|
||||
// Appends the value(s) of <prop> for the resource; GL_ACTIVE_VARIABLES appends several.
|
||||
// False when <index> is out of range.
|
||||
Bool GetResourceProp(ProgramObject& program, GLenum programInterface, GLuint index, GLenum prop,
|
||||
Vector<GLint>& outValues);
|
||||
GLint GetResourceLocation(ProgramObject& program, GLenum programInterface, const char* name);
|
||||
GLint GetResourceLocationIndex(ProgramObject& program, GLenum programInterface, const char* name);
|
||||
} // namespace MobileGL::MG_Impl::GLImpl::ProgramInterface
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
|
||||
# MobileGL headless GPU integration tests.
|
||||
#
|
||||
# These are not unit tests: each scenario brings up a real EGL context on a
|
||||
# pbuffer, renders real frames through a real backend and asserts on
|
||||
# glReadPixels output. They need a GPU, so the module is OFF by default
|
||||
# (MOBILEGL_BUILD_INTEGRATION_TEST) and every scenario skips cleanly - never
|
||||
# fails, never hangs - on a machine without one. "Cleanly" is not a hope: the
|
||||
# harness runs the whole bring-up in a forked child first, because MobileGL
|
||||
# ABORTS rather than returning an error on an unusable platform (HeadlessGL.cpp).
|
||||
#
|
||||
# A clean skip is also indistinguishable from a pass, so set
|
||||
# MOBILEGL_ITEST_REQUIRE_GPU wherever the machine is supposed to have a GPU.
|
||||
#
|
||||
# Backend selection is latched at initialization from MOBILEGL_BACKEND_TYPE, so
|
||||
# one process is one backend: the same binary is registered twice, once per
|
||||
# backend, under the `integration-gpu` label.
|
||||
|
||||
message(STATUS "Generating build files for MobileGL Integration Test...")
|
||||
|
||||
set(CMAKE_CXX_STANDARD 23)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
set(MGL_ITEST_ROOT ${CMAKE_CURRENT_LIST_DIR}/../..)
|
||||
|
||||
# Only meaningful where MobileGL_s exists (i.e. not Android).
|
||||
if (NOT TARGET MobileGL_s)
|
||||
message(STATUS "MobileGL_s is not available; skipping the integration test module")
|
||||
return()
|
||||
endif()
|
||||
|
||||
# MG_Test already pulls googletest in when MOBILEGL_BUILD_TEST is ON. Stand on
|
||||
# our own feet when it is not, so this module can be built by itself.
|
||||
if (NOT TARGET GTest::gtest)
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
googletest
|
||||
GIT_REPOSITORY https://github.com/google/googletest.git
|
||||
GIT_TAG v1.17.0
|
||||
)
|
||||
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(googletest)
|
||||
endif()
|
||||
|
||||
add_executable(MobileGLIntegrationTest
|
||||
Main.cpp
|
||||
Harness/HeadlessGL.cpp
|
||||
Scenarios/OrientationScenario.cpp
|
||||
Scenarios/CrossFrameBufferScenario.cpp
|
||||
Scenarios/ResidentIndexScenario.cpp
|
||||
Scenarios/MultiDrawScenario.cpp
|
||||
Scenarios/AsyncCompileScenario.cpp
|
||||
Scenarios/XfbAfterClipDistanceScenario.cpp
|
||||
Scenarios/ThreeChannelAttachmentScenario.cpp
|
||||
)
|
||||
|
||||
target_include_directories(MobileGLIntegrationTest PRIVATE
|
||||
${MGL_ITEST_ROOT}/include
|
||||
${MGL_ITEST_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
# gtest, not gtest_main: Main.cpp installs the harness banner itself.
|
||||
target_link_libraries(MobileGLIntegrationTest PRIVATE
|
||||
GTest::gtest
|
||||
MobileGL_s
|
||||
)
|
||||
|
||||
if (MSVC)
|
||||
# Same reason as MG_Test/Backend/DirectVulkan: the GLES headers declare gl*
|
||||
# as dllimport on Windows, so the in-library GL entry-point definitions only
|
||||
# resolve if the whole static library is part of the link.
|
||||
target_link_options(MobileGLIntegrationTest PRIVATE /WHOLEARCHIVE:MobileGL_s)
|
||||
endif()
|
||||
target_compile_definitions(MobileGLIntegrationTest PRIVATE -DNOMINMAX)
|
||||
|
||||
# --- ctest wiring --------------------------------------------------------
|
||||
# A bare libEGL on a glvnd box resolves to whatever vendor comes first, which is
|
||||
# usually Mesa/llvmpipe - a software rasteriser silently replacing the GPU under
|
||||
# a GPU test. Pin the vendor/ICD json the same way MG_Benchmark's
|
||||
# run_driver_bench.sh does.
|
||||
#
|
||||
# Leaving these empty is not a neutral default, it is the failure mode: an
|
||||
# unpinned libEGL lands on llvmpipe and the suite goes green having tested a
|
||||
# software rasteriser. So they are DETECTED here rather than defaulted to empty,
|
||||
# and an empty result is a loud warning.
|
||||
#
|
||||
# mgl_itest_find_driver_json(<outVar> <description> <glob> [<glob>...])
|
||||
# Picks the first json a real hardware vendor owns, in preference order, and
|
||||
# never picks a software rasteriser (llvmpipe / lavapipe / swrast) - landing on
|
||||
# one of those silently is the exact accident this pinning exists to prevent.
|
||||
function(mgl_itest_find_driver_json outVar)
|
||||
set(candidates "")
|
||||
foreach(pattern IN LISTS ARGN)
|
||||
file(GLOB matches "${pattern}")
|
||||
list(APPEND candidates ${matches})
|
||||
endforeach()
|
||||
list(SORT candidates)
|
||||
# Vendors ship an i686 json beside the x86_64 one and it sorts first. Pinning
|
||||
# the wrong word size is worse than not pinning at all - the loader finds no
|
||||
# driver and the whole suite skips - so drop the mismatched ones outright.
|
||||
if (CMAKE_SIZEOF_VOID_P EQUAL 8)
|
||||
list(FILTER candidates EXCLUDE REGEX "i686|i386")
|
||||
else()
|
||||
list(FILTER candidates EXCLUDE REGEX "x86_64|aarch64")
|
||||
endif()
|
||||
set(software "")
|
||||
foreach(vendor IN ITEMS nvidia amdgpu amd radeon intel_hasvk intel broadcom freedreno panfrost)
|
||||
foreach(candidate IN LISTS candidates)
|
||||
get_filename_component(leaf "${candidate}" NAME)
|
||||
string(TOLOWER "${leaf}" leaf)
|
||||
if (leaf MATCHES "${vendor}")
|
||||
set(${outVar} "${candidate}" PARENT_SCOPE)
|
||||
return()
|
||||
endif()
|
||||
endforeach()
|
||||
endforeach()
|
||||
# Nothing recognised as hardware. Report the first non-software entry if there
|
||||
# is one; otherwise report nothing, so the warning below fires.
|
||||
foreach(candidate IN LISTS candidates)
|
||||
get_filename_component(leaf "${candidate}" NAME)
|
||||
string(TOLOWER "${leaf}" leaf)
|
||||
if (NOT leaf MATCHES "lvp|llvmpipe|lavapipe|swrast|softpipe")
|
||||
set(${outVar} "${candidate}" PARENT_SCOPE)
|
||||
return()
|
||||
endif()
|
||||
set(software "${candidate}")
|
||||
endforeach()
|
||||
set(${outVar} "" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
set(MGL_ITEST_DETECTED_EGL_VENDOR "")
|
||||
set(MGL_ITEST_DETECTED_VK_ICD "")
|
||||
if (UNIX AND NOT APPLE AND NOT ANDROID)
|
||||
mgl_itest_find_driver_json(MGL_ITEST_DETECTED_EGL_VENDOR
|
||||
"/usr/share/glvnd/egl_vendor.d/*.json"
|
||||
"/etc/glvnd/egl_vendor.d/*.json")
|
||||
mgl_itest_find_driver_json(MGL_ITEST_DETECTED_VK_ICD
|
||||
"/usr/share/vulkan/icd.d/*.json"
|
||||
"/etc/vulkan/icd.d/*.json")
|
||||
endif()
|
||||
|
||||
set(MOBILEGL_ITEST_EGL_VENDOR "${MGL_ITEST_DETECTED_EGL_VENDOR}" CACHE FILEPATH
|
||||
"glvnd EGL vendor json to pin for the integration tests (empty: leave the loader alone)")
|
||||
set(MOBILEGL_ITEST_VK_ICD "${MGL_ITEST_DETECTED_VK_ICD}" CACHE FILEPATH
|
||||
"Vulkan ICD json to pin for the DirectVulkan integration tests (empty: leave the loader alone)")
|
||||
|
||||
if (MOBILEGL_ITEST_EGL_VENDOR)
|
||||
message(STATUS "Integration tests: pinning EGL vendor ${MOBILEGL_ITEST_EGL_VENDOR}")
|
||||
else()
|
||||
message(WARNING
|
||||
"Integration tests: no EGL vendor json found or configured (MOBILEGL_ITEST_EGL_VENDOR is empty). "
|
||||
"An unpinned libEGL on a glvnd system resolves to whichever vendor comes first, which is usually "
|
||||
"Mesa/llvmpipe - the scenarios would then go green against a software rasteriser instead of the GPU. "
|
||||
"Set -DMOBILEGL_ITEST_EGL_VENDOR=/usr/share/glvnd/egl_vendor.d/<vendor>.json.")
|
||||
endif()
|
||||
if (MOBILEGL_ITEST_VK_ICD)
|
||||
message(STATUS "Integration tests: pinning Vulkan ICD ${MOBILEGL_ITEST_VK_ICD}")
|
||||
else()
|
||||
message(WARNING
|
||||
"Integration tests: no Vulkan ICD json found or configured (MOBILEGL_ITEST_VK_ICD is empty). "
|
||||
"DirectVulkan would then load whichever ICD the loader enumerates first, quite possibly lavapipe. "
|
||||
"Set -DMOBILEGL_ITEST_VK_ICD=/usr/share/vulkan/icd.d/<vendor>.json.")
|
||||
endif()
|
||||
|
||||
# Turns "no usable GPU" from a clean skip into a failure - see ScenarioFixture.h.
|
||||
# Without it the integration-gpu label is unfalsifiable: a run that skipped every
|
||||
# scenario and a run that passed every scenario are the same green in ctest.
|
||||
option(MOBILEGL_ITEST_REQUIRE_GPU
|
||||
"Fail (rather than skip) the integration scenarios when the headless harness is unusable" OFF)
|
||||
|
||||
# DirectGLES asks the system EGL for a pbuffer config, and on Mesa the default
|
||||
# platform is not X11 unless it is said out loud (run_driver_bench.sh sets the
|
||||
# same variable). Wrong platform here is not a soft failure: eglCreatePbuffer
|
||||
# fails and every scenario skips.
|
||||
if (UNIX AND NOT APPLE AND NOT ANDROID)
|
||||
set(MOBILEGL_ITEST_EGL_PLATFORM "x11" CACHE STRING
|
||||
"EGL_PLATFORM for the integration tests (empty: leave the loader alone)")
|
||||
else()
|
||||
set(MOBILEGL_ITEST_EGL_PLATFORM "" CACHE STRING
|
||||
"EGL_PLATFORM for the integration tests (empty: leave the loader alone)")
|
||||
endif()
|
||||
|
||||
set(MGL_ITEST_COMMON_ENV "")
|
||||
if (MOBILEGL_ITEST_EGL_VENDOR)
|
||||
list(APPEND MGL_ITEST_COMMON_ENV "__EGL_VENDOR_LIBRARY_FILENAMES=${MOBILEGL_ITEST_EGL_VENDOR}")
|
||||
endif()
|
||||
if (MOBILEGL_ITEST_EGL_PLATFORM)
|
||||
list(APPEND MGL_ITEST_COMMON_ENV "EGL_PLATFORM=${MOBILEGL_ITEST_EGL_PLATFORM}")
|
||||
endif()
|
||||
if (MOBILEGL_ITEST_REQUIRE_GPU)
|
||||
list(APPEND MGL_ITEST_COMMON_ENV "MOBILEGL_ITEST_REQUIRE_GPU=1")
|
||||
endif()
|
||||
|
||||
set(MGL_ITEST_VULKAN_ENV ${MGL_ITEST_COMMON_ENV})
|
||||
if (MOBILEGL_ITEST_VK_ICD)
|
||||
list(APPEND MGL_ITEST_VULKAN_ENV "VK_ICD_FILENAMES=${MOBILEGL_ITEST_VK_ICD}")
|
||||
endif()
|
||||
|
||||
# The ENVIRONMENT test property is itself a `;`-list, and gtest_discover_tests
|
||||
# forwards PROPERTIES as a flat list - so a plain `;`-joined value arrives as
|
||||
# four separate arguments and everything after the first is silently read as
|
||||
# another property name. Escaping the separators keeps the whole thing one list
|
||||
# element until set_tests_properties expands it back. Without this only
|
||||
# MOBILEGL_BACKEND_TYPE reaches the test and the vendor/ICD pinning is lost.
|
||||
function(mgl_itest_join_environment outVar)
|
||||
set(joined "")
|
||||
foreach(entry IN LISTS ARGN)
|
||||
if (joined)
|
||||
string(APPEND joined "\\;${entry}")
|
||||
else()
|
||||
set(joined "${entry}")
|
||||
endif()
|
||||
endforeach()
|
||||
set(${outVar} "${joined}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" ${MGL_ITEST_VULKAN_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_ASYNC_SHADER_COMPILE=1" ${MGL_ITEST_VULKAN_ENV})
|
||||
|
||||
# TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it.
|
||||
set(MGL_ITEST_TIMEOUT 120)
|
||||
|
||||
include(GoogleTest)
|
||||
|
||||
# Discovery runs `--gtest_list_tests`, which does not construct the harness and
|
||||
# so needs no GPU. One registration per backend; TEST_PREFIX keeps the two sets
|
||||
# of ctest names apart.
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectGLES."
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectVulkan."
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
# A third registration, of ONE scenario, with asynchronous shader compilation
|
||||
# pinned on. Not a second code path in the renderer: a second ALLOCATION pattern.
|
||||
# The async pipeline's job objects change which of the freed blocks the capture
|
||||
# phase is handed, and that is what decides whether the destroyed-VAO address is
|
||||
# reached at all - on the ablated (pre-fix) tree async=1 reproduced 3 runs out of
|
||||
# 3 where the ambient default reproduced 2 of 3. Pinning it here means the
|
||||
# high-signal configuration runs whatever the shipped default becomes, instead of
|
||||
# the suite quietly weakening the day that default flips. It must be process-wide
|
||||
# (the ENVIRONMENT property), not an in-process scope: the compile pool and its
|
||||
# threads are stood up at initialization, and their allocations are half the
|
||||
# point. DirectVulkan only - the memo this pins is DirectVulkan's.
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectVulkan.AsyncCompile."
|
||||
TEST_FILTER "XfbAfterClipDistanceScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT}"
|
||||
)
|
||||
@@ -0,0 +1,587 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Harness/HeadlessGL.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#include "HeadlessGL.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ostream>
|
||||
#include <sstream>
|
||||
|
||||
// MobileGL's own headers, in the order MobileGL/Includes.h uses them: GL/gl.h
|
||||
// first, then glcorearb.h for the 3.x+ entry points. This binary links
|
||||
// MobileGL_s, so every gl*/egl* below binds to MobileGL's implementation, not
|
||||
// to a system loader.
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#include <EGL/egl.h>
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#undef GL_GLEXT_PROTOTYPES
|
||||
|
||||
// The pre-flight below runs the whole EGL bring-up in a forked child, which is
|
||||
// the only construction that is actually predictive here: MobileGL ABORTS
|
||||
// (MOBILEGL_ASSERT -> SIGTRAP) rather than returning an error on an unusable
|
||||
// platform, so nothing the parent can call in-process is allowed to be wrong.
|
||||
#if !defined(_WIN32) && !defined(__APPLE__) && __has_include(<sys/wait.h>)
|
||||
#define MGITEST_HAVE_FORK_PREFLIGHT 1
|
||||
#include <csignal>
|
||||
#include <ctime>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#else
|
||||
#define MGITEST_HAVE_FORK_PREFLIGHT 0
|
||||
#endif
|
||||
|
||||
namespace MGITest {
|
||||
|
||||
namespace {
|
||||
// Small enough that a readback is cheap, big enough that "top third" and
|
||||
// "bottom third" are unambiguous. Non-square on purpose: a transposing
|
||||
// bug cannot hide behind a square.
|
||||
constexpr int kSurfaceWidth = 128;
|
||||
constexpr int kSurfaceHeight = 96;
|
||||
|
||||
std::string EnvOr(const char* name, const char* fallback) {
|
||||
const char* value = std::getenv(name);
|
||||
return (value != nullptr && value[0] != '\0') ? std::string(value) : std::string(fallback);
|
||||
}
|
||||
|
||||
// A skip reason is only useful if it says which call failed AND why, so
|
||||
// every bring-up step reports the EGL error it left behind.
|
||||
std::string WithEglError(const char* what) {
|
||||
std::ostringstream out;
|
||||
out << what << " (eglGetError=0x" << std::hex << eglGetError() << ")";
|
||||
return out.str();
|
||||
}
|
||||
|
||||
// The EGL objects one bring-up produces.
|
||||
struct EglBringUp {
|
||||
void* display = nullptr;
|
||||
void* surface = nullptr;
|
||||
void* context = nullptr;
|
||||
std::string renderer;
|
||||
};
|
||||
|
||||
// THE bring-up, in one function so the pre-flight child and the parent run
|
||||
// literally the same sequence - a pre-flight that tests something narrower
|
||||
// than what the parent will do is exactly the kind of "predictive" check
|
||||
// that is not.
|
||||
//
|
||||
// Returns 0 on success, or the 1-based index of the step that failed, and
|
||||
// fills outReason either way.
|
||||
int RunEglBringUp(EglBringUp& out, std::string& outReason) {
|
||||
EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||
if (display == EGL_NO_DISPLAY) {
|
||||
outReason = WithEglError("eglGetDisplay(EGL_DEFAULT_DISPLAY) returned EGL_NO_DISPLAY");
|
||||
return 1;
|
||||
}
|
||||
EGLint major = 0, minor = 0;
|
||||
if (eglInitialize(display, &major, &minor) != EGL_TRUE) {
|
||||
outReason = WithEglError("eglInitialize failed: no usable display/driver on this machine");
|
||||
return 2;
|
||||
}
|
||||
if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) {
|
||||
outReason = WithEglError("eglBindAPI(EGL_OPENGL_API) failed");
|
||||
return 3;
|
||||
}
|
||||
|
||||
const EGLint configAttribs[] = {EGL_SURFACE_TYPE,
|
||||
EGL_PBUFFER_BIT,
|
||||
EGL_RED_SIZE,
|
||||
8,
|
||||
EGL_GREEN_SIZE,
|
||||
8,
|
||||
EGL_BLUE_SIZE,
|
||||
8,
|
||||
EGL_ALPHA_SIZE,
|
||||
8,
|
||||
EGL_DEPTH_SIZE,
|
||||
24,
|
||||
EGL_RENDERABLE_TYPE,
|
||||
EGL_OPENGL_BIT,
|
||||
EGL_NONE};
|
||||
EGLConfig config = nullptr;
|
||||
EGLint configCount = 0;
|
||||
if (eglChooseConfig(display, configAttribs, &config, 1, &configCount) != EGL_TRUE || configCount < 1) {
|
||||
outReason = WithEglError("eglChooseConfig found no pbuffer-capable RGBA8/D24 config");
|
||||
return 4;
|
||||
}
|
||||
|
||||
const EGLint contextAttribs[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_CONTEXT_MINOR_VERSION, 3, EGL_NONE};
|
||||
EGLContext context = eglCreateContext(display, config, EGL_NO_CONTEXT, contextAttribs);
|
||||
if (context == EGL_NO_CONTEXT) {
|
||||
context = eglCreateContext(display, config, EGL_NO_CONTEXT, nullptr);
|
||||
}
|
||||
if (context == EGL_NO_CONTEXT) {
|
||||
outReason = WithEglError("eglCreateContext failed: no desktop-GL context available");
|
||||
return 5;
|
||||
}
|
||||
|
||||
const EGLint pbufferAttribs[] = {EGL_WIDTH, kSurfaceWidth, EGL_HEIGHT, kSurfaceHeight, EGL_NONE};
|
||||
EGLSurface surface = eglCreatePbufferSurface(display, config, pbufferAttribs);
|
||||
if (surface == EGL_NO_SURFACE) {
|
||||
outReason = WithEglError("eglCreatePbufferSurface failed");
|
||||
return 6;
|
||||
}
|
||||
// The step that brings the whole backend up (DirectVulkan creates its
|
||||
// instance, device and surface in here) and therefore the step that
|
||||
// aborts instead of returning an error on an unusable platform.
|
||||
if (eglMakeCurrent(display, surface, surface, context) != EGL_TRUE) {
|
||||
outReason = WithEglError("eglMakeCurrent failed");
|
||||
return 7;
|
||||
}
|
||||
|
||||
const GLubyte* renderer = glGetString(GL_RENDERER);
|
||||
if (renderer == nullptr) {
|
||||
outReason = "glGetString(GL_RENDERER) returned null after eglMakeCurrent";
|
||||
return 8;
|
||||
}
|
||||
|
||||
out.display = display;
|
||||
out.surface = surface;
|
||||
out.context = context;
|
||||
out.renderer = reinterpret_cast<const char*>(renderer);
|
||||
outReason.clear();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Platform pre-flight, and the reason this module can claim to skip
|
||||
// cleanly rather than merely hope to.
|
||||
//
|
||||
// MobileGL does not return errors when the platform is unusable - it
|
||||
// ABORTS. MOBILEGL_ASSERT raises SIGTRAP, and the DirectVulkan bring-up
|
||||
// asserts its way through instance, physical-device and surface creation
|
||||
// inside eglMakeCurrent. So there is no in-process question the harness
|
||||
// can ask that is guaranteed to be survivable, and the old form (dlopen
|
||||
// the Vulkan loader, count physical devices, look for
|
||||
// VK_EXT_headless_surface) was a guess at the abort conditions rather
|
||||
// than a test of them: it named three of the ways bring-up can die and
|
||||
// was silent about every other one, including every DirectGLES one.
|
||||
//
|
||||
// What is actually predictive is to run the bring-up itself somewhere a
|
||||
// SIGTRAP is a datum instead of a crash. fork() gives exactly that: the
|
||||
// child performs the identical sequence and _exit(0)s on success, and
|
||||
// ANY non-zero exit or ANY signal in the parent's waitpid() means "this
|
||||
// platform is unusable" - whatever the reason, including reasons nobody
|
||||
// has thought of. Only then does the parent do the real bring-up.
|
||||
//
|
||||
// Returns an empty string when the platform survived a full bring-up.
|
||||
std::string PreflightBringUp() {
|
||||
#if !MGITEST_HAVE_FORK_PREFLIGHT
|
||||
// No fork(): let the in-process bring-up speak for itself, which is
|
||||
// what this module did before. Windows/macOS are not CI targets for
|
||||
// the headless scenarios.
|
||||
return {};
|
||||
#else
|
||||
int channel[2] = {-1, -1};
|
||||
if (pipe(channel) != 0) {
|
||||
return {}; // cannot pre-flight; fall through to the in-process attempt
|
||||
}
|
||||
// The child inherits our stdio buffers; flush so nothing is printed twice.
|
||||
std::fflush(nullptr);
|
||||
const pid_t child = fork();
|
||||
if (child < 0) {
|
||||
close(channel[0]);
|
||||
close(channel[1]);
|
||||
return {};
|
||||
}
|
||||
if (child == 0) {
|
||||
close(channel[0]);
|
||||
// The child is EXPECTED to die on a signal on an unusable
|
||||
// platform; that is the measurement. Do not let each such
|
||||
// measurement drop a core file next to the test binary.
|
||||
const rlimit noCore{0, 0};
|
||||
setrlimit(RLIMIT_CORE, &noCore);
|
||||
std::fprintf(stderr, "[itest] pre-flight child: attempting a full EGL bring-up\n");
|
||||
EglBringUp local;
|
||||
std::string reason;
|
||||
const int step = RunEglBringUp(local, reason);
|
||||
if (!reason.empty()) {
|
||||
const std::size_t bytes = std::min<std::size_t>(reason.size(), 480);
|
||||
const ssize_t written = write(channel[1], reason.data(), bytes);
|
||||
(void)written;
|
||||
}
|
||||
close(channel[1]);
|
||||
// _exit, never exit(): every atexit handler and static destructor
|
||||
// in this address space belongs to the parent's copy of the world,
|
||||
// and the child is holding a live context it must not tear down.
|
||||
_exit(step);
|
||||
}
|
||||
|
||||
close(channel[1]);
|
||||
// Reap first, read after: the message is bounded well below the pipe
|
||||
// buffer so the child can never block writing it, and polling the exit
|
||||
// status is what lets a wedged child be killed instead of hanging the
|
||||
// parent on a read that will never return.
|
||||
constexpr int kPreflightTimeoutMs = 30000;
|
||||
int status = 0;
|
||||
int waitedMs = 0;
|
||||
for (;;) {
|
||||
const pid_t reaped = waitpid(child, &status, WNOHANG);
|
||||
if (reaped == child) break;
|
||||
if (reaped < 0) {
|
||||
close(channel[0]);
|
||||
return "waitpid on the EGL bring-up pre-flight child failed";
|
||||
}
|
||||
if (waitedMs >= kPreflightTimeoutMs) {
|
||||
kill(child, SIGKILL);
|
||||
(void)waitpid(child, &status, 0);
|
||||
close(channel[0]);
|
||||
std::ostringstream out;
|
||||
out << "the EGL bring-up wedged: a forked pre-flight child made no progress in "
|
||||
<< kPreflightTimeoutMs / 1000 << "s and was killed";
|
||||
return out.str();
|
||||
}
|
||||
timespec nap{0, 10 * 1000 * 1000};
|
||||
nanosleep(&nap, nullptr);
|
||||
waitedMs += 10;
|
||||
}
|
||||
|
||||
std::string childSays;
|
||||
char buffer[512];
|
||||
for (;;) {
|
||||
const ssize_t got = read(channel[0], buffer, sizeof(buffer));
|
||||
if (got <= 0) break;
|
||||
childSays.append(buffer, static_cast<std::size_t>(got));
|
||||
}
|
||||
close(channel[0]);
|
||||
|
||||
if (WIFSIGNALED(status)) {
|
||||
const int signalNumber = WTERMSIG(status);
|
||||
const char* signalName = strsignal(signalNumber);
|
||||
std::ostringstream out;
|
||||
out << "the EGL bring-up ABORTS on this platform: a forked pre-flight child died on signal "
|
||||
<< signalNumber << " (" << (signalName != nullptr ? signalName : "?") << ")";
|
||||
if (!childSays.empty()) out << " after: " << childSays;
|
||||
out << ". MobileGL asserts rather than returning an error here, so the scenarios would "
|
||||
"have taken the whole test binary down with them";
|
||||
return out.str();
|
||||
}
|
||||
if (!WIFEXITED(status)) {
|
||||
return "the EGL bring-up pre-flight child neither exited nor was signalled";
|
||||
}
|
||||
const int exitStatus = WEXITSTATUS(status);
|
||||
if (exitStatus != 0) {
|
||||
std::ostringstream out;
|
||||
out << (childSays.empty() ? "the EGL bring-up failed" : childSays)
|
||||
<< " (forked pre-flight child exit status " << exitStatus << ")";
|
||||
return out.str();
|
||||
}
|
||||
return {};
|
||||
#endif
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool RequireGpu() {
|
||||
const char* value = std::getenv("MOBILEGL_ITEST_REQUIRE_GPU");
|
||||
return value != nullptr && value[0] != '\0' && std::strcmp(value, "0") != 0;
|
||||
}
|
||||
|
||||
std::ostream& operator<<(std::ostream& os, const Rgba8& c) {
|
||||
os << "rgba(" << int(c.r) << "," << int(c.g) << "," << int(c.b) << "," << int(c.a) << ")";
|
||||
return os;
|
||||
}
|
||||
|
||||
Rgba8 Image::At(int x, int y) const {
|
||||
if (x < 0 || y < 0 || x >= m_width || y >= m_height) {
|
||||
return Rgba8{};
|
||||
}
|
||||
const std::size_t index = (static_cast<std::size_t>(y) * m_width + x) * 4;
|
||||
return Rgba8{m_pixels[index], m_pixels[index + 1], m_pixels[index + 2], m_pixels[index + 3]};
|
||||
}
|
||||
|
||||
const char* Image::ColorName(int x, int y) const {
|
||||
const Rgba8 c = At(x, y);
|
||||
const bool r = c.r > 160, g = c.g > 160, b = c.b > 160;
|
||||
const bool nr = c.r < 96, ng = c.g < 96, nb = c.b < 96;
|
||||
if (nr && ng && nb) return "black";
|
||||
if (r && g && b) return "white";
|
||||
if (r && ng && nb) return "red";
|
||||
if (nr && g && nb) return "green";
|
||||
if (nr && ng && b) return "blue";
|
||||
if (r && g && nb) return "yellow";
|
||||
return "other";
|
||||
}
|
||||
|
||||
std::size_t Image::ByteDiffCount(const Image& other) const {
|
||||
if (m_width != other.m_width || m_height != other.m_height) {
|
||||
return std::max(m_pixels.size(), other.m_pixels.size());
|
||||
}
|
||||
std::size_t differing = 0;
|
||||
for (std::size_t i = 0; i < m_pixels.size(); ++i) {
|
||||
if (m_pixels[i] != other.m_pixels[i]) ++differing;
|
||||
}
|
||||
return differing;
|
||||
}
|
||||
|
||||
std::string Image::QuadrantSignature() const {
|
||||
if (m_width < 2 || m_height < 2) return "<empty>";
|
||||
// Quadrant CENTRES, so a one-pixel rounding difference at a quadrant edge
|
||||
// never decides the answer. Order is fixed and load-bearing: bottom-left,
|
||||
// bottom-right, top-left, top-right.
|
||||
const int leftX = m_width / 4;
|
||||
const int rightX = m_width * 3 / 4;
|
||||
const int bottomY = m_height / 4;
|
||||
const int topY = m_height * 3 / 4;
|
||||
std::ostringstream out;
|
||||
out << ColorName(leftX, bottomY) << "," << ColorName(rightX, bottomY) << "," << ColorName(leftX, topY) << ","
|
||||
<< ColorName(rightX, topY);
|
||||
return out.str();
|
||||
}
|
||||
|
||||
RegionScan ScanRegion(const Image& image, int x0, int x1, int y0, int y1, const char* expectedColor) {
|
||||
RegionScan scan;
|
||||
x0 = std::max(x0, 0);
|
||||
y0 = std::max(y0, 0);
|
||||
x1 = std::min(x1, image.Width() - 1);
|
||||
y1 = std::min(y1, image.Height() - 1);
|
||||
for (int y = y0; y <= y1; ++y) {
|
||||
for (int x = x0; x <= x1; ++x) {
|
||||
++scan.total;
|
||||
const char* name = image.ColorName(x, y);
|
||||
if (std::strcmp(name, expectedColor) == 0) continue;
|
||||
++scan.offenders;
|
||||
if (scan.firstX < 0) {
|
||||
scan.firstX = x;
|
||||
scan.firstY = y;
|
||||
scan.firstColor = image.At(x, y);
|
||||
scan.firstColorName = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return scan;
|
||||
}
|
||||
|
||||
::testing::AssertionResult RegionIsMostly(const Image& image, int x0, int x1, int y0, int y1,
|
||||
const char* expectedColor, double tolerance,
|
||||
const std::string& when) {
|
||||
const RegionScan scan = ScanRegion(image, x0, x1, y0, y1, expectedColor);
|
||||
if (scan.total == 0) {
|
||||
return ::testing::AssertionFailure()
|
||||
<< when << ": region x[" << x0 << "," << x1 << "] y[" << y0 << "," << y1
|
||||
<< "] is empty against a " << image.Width() << "x" << image.Height() << " readback";
|
||||
}
|
||||
const double offendingFraction = static_cast<double>(scan.offenders) / scan.total;
|
||||
if (offendingFraction <= tolerance) {
|
||||
return ::testing::AssertionSuccess();
|
||||
}
|
||||
return ::testing::AssertionFailure()
|
||||
<< when << ": region x[" << x0 << "," << x1 << "] y[" << y0 << "," << y1 << "] should be all "
|
||||
<< expectedColor << ", but " << scan.offenders << " of " << scan.total << " pixels ("
|
||||
<< static_cast<int>(offendingFraction * 100.0 + 0.5) << "%) are not; first offender at (" << scan.firstX
|
||||
<< "," << scan.firstY << ") is " << scan.firstColorName << " " << scan.firstColor;
|
||||
}
|
||||
|
||||
HeadlessGL& HeadlessGL::Get() {
|
||||
static HeadlessGL instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
HeadlessGL::HeadlessGL() {
|
||||
m_backendName = EnvOr("MOBILEGL_BACKEND_TYPE", "<unset>");
|
||||
m_usable = BringUp();
|
||||
}
|
||||
|
||||
bool HeadlessGL::BringUp() {
|
||||
// Ask a disposable copy of this process first. Only if it survived does
|
||||
// the real one try - see PreflightBringUp for why nothing weaker is
|
||||
// predictive against a stack that aborts instead of returning errors.
|
||||
const std::string preflightProblem = PreflightBringUp();
|
||||
if (!preflightProblem.empty()) {
|
||||
m_skipReason = preflightProblem;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same shape as DriverBench's boot_egl(), minus the dlopen: the provider
|
||||
// is this binary. A pbuffer needs no window system, but MobileGL's own
|
||||
// loader still has to reach a real driver underneath - and the child
|
||||
// above just proved it can.
|
||||
EglBringUp brought;
|
||||
std::string reason;
|
||||
if (RunEglBringUp(brought, reason) != 0) {
|
||||
// The pre-flight passed and the parent's identical attempt did not.
|
||||
// That is a real result, not a machine without a GPU, so say so: it
|
||||
// means something is different between the two attempts (a leaked
|
||||
// exclusive device, an environment the child did not have).
|
||||
m_skipReason = reason + " - although an identical bring-up in a forked pre-flight child succeeded";
|
||||
return false;
|
||||
}
|
||||
|
||||
m_display = brought.display;
|
||||
m_surface = brought.surface;
|
||||
m_context = brought.context;
|
||||
m_width = kSurfaceWidth;
|
||||
m_height = kSurfaceHeight;
|
||||
m_renderer = std::move(brought.renderer);
|
||||
return true;
|
||||
}
|
||||
|
||||
void HeadlessGL::EndFrame() {
|
||||
if (!m_usable) return;
|
||||
eglSwapBuffers(static_cast<EGLDisplay>(m_display), static_cast<EGLSurface>(m_surface));
|
||||
++m_frameIndex;
|
||||
}
|
||||
|
||||
void HeadlessGL::ShutDown() {
|
||||
if (!m_usable) return;
|
||||
EGLDisplay display = static_cast<EGLDisplay>(m_display);
|
||||
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||
if (m_context != nullptr) eglDestroyContext(display, static_cast<EGLContext>(m_context));
|
||||
if (m_surface != nullptr) eglDestroySurface(display, static_cast<EGLSurface>(m_surface));
|
||||
eglTerminate(display);
|
||||
m_context = nullptr;
|
||||
m_surface = nullptr;
|
||||
m_display = nullptr;
|
||||
m_usable = false;
|
||||
m_skipReason = "the headless context has already been torn down";
|
||||
}
|
||||
|
||||
// ---- scenario vocabulary ------------------------------------------------
|
||||
|
||||
namespace {
|
||||
unsigned int CompileStage(GLenum stage, const char* source, std::string* outError) {
|
||||
const GLuint shader = glCreateShader(stage);
|
||||
glShaderSource(shader, 1, &source, nullptr);
|
||||
glCompileShader(shader);
|
||||
GLint compiled = 0;
|
||||
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
|
||||
if (compiled == GL_FALSE) {
|
||||
char log[2048] = {};
|
||||
GLsizei length = 0;
|
||||
glGetShaderInfoLog(shader, sizeof(log) - 1, &length, log);
|
||||
if (outError != nullptr) {
|
||||
*outError = std::string(stage == GL_VERTEX_SHADER ? "vertex" : "fragment") +
|
||||
" shader failed to compile: " + log;
|
||||
}
|
||||
glDeleteShader(shader);
|
||||
return 0;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
unsigned int CompileProgram(const char* vertexSource, const char* fragmentSource, std::string* outError) {
|
||||
const GLuint vs = CompileStage(GL_VERTEX_SHADER, vertexSource, outError);
|
||||
if (vs == 0) return 0;
|
||||
const GLuint fs = CompileStage(GL_FRAGMENT_SHADER, fragmentSource, outError);
|
||||
if (fs == 0) {
|
||||
glDeleteShader(vs);
|
||||
return 0;
|
||||
}
|
||||
const GLuint program = glCreateProgram();
|
||||
glAttachShader(program, vs);
|
||||
glAttachShader(program, fs);
|
||||
// Pinned rather than queried so the scenarios can set up a VAO without a
|
||||
// round trip, and so a driver that reorders attributes cannot change what
|
||||
// the test means.
|
||||
glBindAttribLocation(program, 0, "aPos");
|
||||
glBindAttribLocation(program, 1, "aColor");
|
||||
glLinkProgram(program);
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
GLint linked = 0;
|
||||
glGetProgramiv(program, GL_LINK_STATUS, &linked);
|
||||
if (linked == GL_FALSE) {
|
||||
char log[2048] = {};
|
||||
GLsizei length = 0;
|
||||
glGetProgramInfoLog(program, sizeof(log) - 1, &length, log);
|
||||
if (outError != nullptr) *outError = std::string("program failed to link: ") + log;
|
||||
glDeleteProgram(program);
|
||||
return 0;
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
ColorFbo MakeColorFbo(int width, int height) {
|
||||
ColorFbo target;
|
||||
target.width = width;
|
||||
target.height = height;
|
||||
glGenTextures(1, &target.texture);
|
||||
glBindTexture(GL_TEXTURE_2D, target.texture);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
|
||||
glGenFramebuffers(1, &target.fbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, target.fbo);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target.texture, 0);
|
||||
const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
if (status != GL_FRAMEBUFFER_COMPLETE) {
|
||||
DestroyColorFbo(target);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
void DestroyColorFbo(ColorFbo& target) {
|
||||
if (target.fbo != 0) glDeleteFramebuffers(1, &target.fbo);
|
||||
if (target.texture != 0) glDeleteTextures(1, &target.texture);
|
||||
target.fbo = 0;
|
||||
target.texture = 0;
|
||||
}
|
||||
|
||||
void BindDefaultFramebuffer() {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
glViewport(0, 0, HeadlessGL::Get().Width(), HeadlessGL::Get().Height());
|
||||
}
|
||||
|
||||
void BindFbo(const ColorFbo& target) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, target.fbo);
|
||||
glViewport(0, 0, target.width, target.height);
|
||||
}
|
||||
|
||||
void ClearTo(float r, float g, float b, float a) {
|
||||
glClearColor(r, g, b, a);
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
}
|
||||
|
||||
Image ReadPixels(int width, int height) {
|
||||
Image image(width, height);
|
||||
glPixelStorei(GL_PACK_ALIGNMENT, 1);
|
||||
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.Data());
|
||||
return image;
|
||||
}
|
||||
|
||||
unsigned int FirstGLError() {
|
||||
const GLenum first = glGetError();
|
||||
if (first == GL_NO_ERROR) return GL_NO_ERROR;
|
||||
// Drain, bounded: a broken stack must not turn an error check into a hang.
|
||||
for (int i = 0; i < 64 && glGetError() != GL_NO_ERROR; ++i) {}
|
||||
return first;
|
||||
}
|
||||
|
||||
const char* GLErrorName(unsigned int error) {
|
||||
switch (error) {
|
||||
case GL_NO_ERROR:
|
||||
return "GL_NO_ERROR";
|
||||
case GL_INVALID_ENUM:
|
||||
return "GL_INVALID_ENUM";
|
||||
case GL_INVALID_VALUE:
|
||||
return "GL_INVALID_VALUE";
|
||||
case GL_INVALID_OPERATION:
|
||||
return "GL_INVALID_OPERATION";
|
||||
case GL_OUT_OF_MEMORY:
|
||||
return "GL_OUT_OF_MEMORY";
|
||||
case GL_INVALID_FRAMEBUFFER_OPERATION:
|
||||
return "GL_INVALID_FRAMEBUFFER_OPERATION";
|
||||
default:
|
||||
return "GL_<unknown>";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace MGITest
|
||||
@@ -0,0 +1,218 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Harness/HeadlessGL.h
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// A headless GL context and the small vocabulary the scenarios are written in.
|
||||
//
|
||||
// The scenarios in this module are end-to-end: they drive MobileGL's own GL and
|
||||
// EGL entry points (this binary links MobileGL_s, so gl*/egl* resolve straight
|
||||
// into the implementation) and assert on glReadPixels output. Nothing here
|
||||
// inspects backend state - both bugs this module pins were invisible to
|
||||
// state-level assertions and visible only in pixels.
|
||||
//
|
||||
// Headless by construction, following MG_Benchmark/Driver/DriverBench.c: an EGL
|
||||
// context on a PBUFFER surface. No window, no window manager, no human. Unlike
|
||||
// DriverBench the scenarios do draw to the DEFAULT framebuffer (that is where
|
||||
// the Y-flip lives) and do call eglSwapBuffers (that is the frame boundary the
|
||||
// cross-frame scenarios need to be real).
|
||||
//
|
||||
// One process is one backend: MOBILEGL_BACKEND_TYPE is latched at
|
||||
// initialization, so the CMake wiring runs this binary once per backend rather
|
||||
// than trying to switch in-process.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace MGITest {
|
||||
|
||||
// True when MOBILEGL_ITEST_REQUIRE_GPU is set in the environment: the runner
|
||||
// is asserting that this machine HAS a usable GPU, so "no GPU" stops being a
|
||||
// clean skip and becomes a failure. Without it the integration-gpu label is
|
||||
// unfalsifiable - a CI job that ran nothing reports exactly the same green as
|
||||
// a job that ran everything.
|
||||
bool RequireGpu();
|
||||
|
||||
struct Rgba8 {
|
||||
std::uint8_t r = 0, g = 0, b = 0, a = 0;
|
||||
|
||||
bool operator==(const Rgba8& other) const {
|
||||
return r == other.r && g == other.g && b == other.b && a == other.a;
|
||||
}
|
||||
bool operator!=(const Rgba8& other) const { return !(*this == other); }
|
||||
};
|
||||
|
||||
// Prints as "rgba(255,0,0,255)" so a gtest failure names the colour it saw.
|
||||
std::ostream& operator<<(std::ostream& os, const Rgba8& c);
|
||||
|
||||
// An RGBA8 readback. Row 0 is the BOTTOM row: that is GL's convention for
|
||||
// glReadPixels and it is what "correctly oriented" means everywhere below.
|
||||
class Image {
|
||||
public:
|
||||
Image() = default;
|
||||
Image(int width, int height)
|
||||
: m_width(width), m_height(height), m_pixels(static_cast<std::size_t>(width) * height * 4, 0) {}
|
||||
|
||||
int Width() const { return m_width; }
|
||||
int Height() const { return m_height; }
|
||||
bool Empty() const { return m_pixels.empty(); }
|
||||
std::uint8_t* Data() { return m_pixels.data(); }
|
||||
const std::uint8_t* Data() const { return m_pixels.data(); }
|
||||
|
||||
Rgba8 At(int x, int y) const;
|
||||
// Nearest of {black, red, green, blue, white, other} - the scenarios only
|
||||
// ever draw those, so this turns a pixel into something readable.
|
||||
const char* ColorName(int x, int y) const;
|
||||
|
||||
bool operator==(const Image& other) const {
|
||||
return m_width == other.m_width && m_height == other.m_height && m_pixels == other.m_pixels;
|
||||
}
|
||||
|
||||
// Count of differing bytes, for a failure message that says how wrong.
|
||||
std::size_t ByteDiffCount(const Image& other) const;
|
||||
|
||||
// The four quadrant centres, in the fixed order
|
||||
// bottom-left, bottom-right, top-left, top-right.
|
||||
//
|
||||
// This replaces the old VerticalSignature(bandCount), which read three
|
||||
// full-width horizontal stripes down the centre line and was therefore
|
||||
// blind to an X flip, to a transpose, and to a 180 rotation composed with
|
||||
// a Y flip - all of those left the stripe order alone. Four quadrant
|
||||
// colours are asymmetric in BOTH axes, so each of the eight square
|
||||
// symmetries produces a different string (see OrientationScenario, which
|
||||
// spells all eight out).
|
||||
std::string QuadrantSignature() const;
|
||||
|
||||
private:
|
||||
int m_width = 0;
|
||||
int m_height = 0;
|
||||
std::vector<std::uint8_t> m_pixels;
|
||||
};
|
||||
|
||||
// The process-wide headless context. Brought up lazily on the first Get() so
|
||||
// that `--gtest_list_tests` (which CMake runs at build time to discover the
|
||||
// cases) never touches a GPU.
|
||||
class HeadlessGL {
|
||||
public:
|
||||
static HeadlessGL& Get();
|
||||
|
||||
// False on a machine with no usable GPU/display/ICD. SkipReason() then
|
||||
// says which step failed; every fixture turns that into GTEST_SKIP().
|
||||
bool Usable() const { return m_usable; }
|
||||
const std::string& SkipReason() const { return m_skipReason; }
|
||||
|
||||
// Backend actually in use, as reported by MOBILEGL_BACKEND_TYPE.
|
||||
const std::string& BackendName() const { return m_backendName; }
|
||||
const std::string& RendererString() const { return m_renderer; }
|
||||
|
||||
int Width() const { return m_width; }
|
||||
int Height() const { return m_height; }
|
||||
|
||||
// THE frame boundary. eglSwapBuffers is what retires a frame in the
|
||||
// renderer, and the cross-frame scenarios are meaningless without it.
|
||||
void EndFrame();
|
||||
|
||||
// Frames completed so far, for failure messages.
|
||||
int FrameIndex() const { return m_frameIndex; }
|
||||
|
||||
// Releases the context and surface and terminates the display. Called
|
||||
// once, after the last scenario: MobileGL frees its backend objects
|
||||
// through eglTerminate, and letting a process simply exit on top of a
|
||||
// live context leaves those objects to be torn down from a static
|
||||
// destructor with no driver left underneath.
|
||||
void ShutDown();
|
||||
|
||||
private:
|
||||
HeadlessGL();
|
||||
HeadlessGL(const HeadlessGL&) = delete;
|
||||
HeadlessGL& operator=(const HeadlessGL&) = delete;
|
||||
|
||||
bool BringUp();
|
||||
|
||||
bool m_usable = false;
|
||||
std::string m_skipReason;
|
||||
std::string m_backendName;
|
||||
std::string m_renderer;
|
||||
int m_width = 0;
|
||||
int m_height = 0;
|
||||
int m_frameIndex = 0;
|
||||
void* m_display = nullptr;
|
||||
void* m_surface = nullptr;
|
||||
void* m_context = nullptr;
|
||||
};
|
||||
|
||||
// ---- the scenario vocabulary -------------------------------------------
|
||||
// Deliberately tiny. A scenario should read like a story; anything that
|
||||
// needs a comment about GL mechanics belongs here instead.
|
||||
|
||||
// Compiles and links vs+fs, pinning attribute 0 to "aPos" and 1 to "aColor".
|
||||
// Returns 0 and fills outError on failure.
|
||||
unsigned int CompileProgram(const char* vertexSource, const char* fragmentSource, std::string* outError);
|
||||
|
||||
struct ColorFbo {
|
||||
unsigned int fbo = 0;
|
||||
unsigned int texture = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
// A complete RGBA8 render target. Returns fbo==0 on failure.
|
||||
ColorFbo MakeColorFbo(int width, int height);
|
||||
void DestroyColorFbo(ColorFbo& target);
|
||||
|
||||
// Binds a target and sets the viewport to match. Passing fbo 0 means the
|
||||
// default (presentable) framebuffer.
|
||||
void BindDefaultFramebuffer();
|
||||
void BindFbo(const ColorFbo& target);
|
||||
|
||||
void ClearTo(float r, float g, float b, float a);
|
||||
|
||||
// Reads back the whole currently bound READ framebuffer. width/height must
|
||||
// be the target's full size - DirectVulkan's default-framebuffer readback
|
||||
// only re-orients a full-extent read.
|
||||
Image ReadPixels(int width, int height);
|
||||
|
||||
// Drains any GL error queue and returns the first error, or 0.
|
||||
unsigned int FirstGLError();
|
||||
const char* GLErrorName(unsigned int error);
|
||||
|
||||
// ---- whole-region readback predicates ----------------------------------
|
||||
// The scenarios used to assert on two or three individual pixels, which is
|
||||
// provably too weak: a draw in which 3 of a quad's 4 vertices carry stale
|
||||
// data still paints the sampled centre the expected colour (that exact case
|
||||
// is a standing negative-control test - see CrossFrameBufferScenario). The
|
||||
// readback is already fully in memory, so counting every pixel in a region
|
||||
// costs nothing and turns "the middle looks right" into "all of it is right".
|
||||
|
||||
// Everything a caller needs to say what was wrong and where.
|
||||
struct RegionScan {
|
||||
int total = 0; // pixels examined
|
||||
int offenders = 0; // pixels whose ColorName() != expected
|
||||
int firstX = -1; // first offender in bottom-to-top, left-to-right order
|
||||
int firstY = -1;
|
||||
Rgba8 firstColor{};
|
||||
std::string firstColorName;
|
||||
};
|
||||
|
||||
// Inclusive pixel bounds, clamped to the image. Row 0 is the bottom row.
|
||||
RegionScan ScanRegion(const Image& image, int x0, int x1, int y0, int y1, const char* expectedColor);
|
||||
|
||||
// gtest predicate wrapper: EXPECT_TRUE(RegionIsMostly(...)) reports the
|
||||
// offender count, the offender fraction and the FIRST offending pixel's
|
||||
// coordinates and colour. `tolerance` is the fraction of the region allowed
|
||||
// to disagree; pass 0.0 to demand every pixel (which is what the scenarios
|
||||
// do - they inset their regions away from primitive edges so exactness is
|
||||
// achievable).
|
||||
::testing::AssertionResult RegionIsMostly(const Image& image, int x0, int x1, int y0, int y1,
|
||||
const char* expectedColor, double tolerance,
|
||||
const std::string& when);
|
||||
|
||||
} // namespace MGITest
|
||||
@@ -0,0 +1,84 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// The base fixture every scenario derives from. Its only jobs are to bring the
|
||||
// headless context up once per process and to decide what "this machine has no
|
||||
// usable GPU" means.
|
||||
//
|
||||
// By default it means a clean GTEST_SKIP() - never a failure, never a hang -
|
||||
// because a developer box or a container without a GPU should not fail a run it
|
||||
// was never able to perform. But a skip is indistinguishable from a pass in
|
||||
// every CI summary, so the `integration-gpu` label on its own is unfalsifiable:
|
||||
// a runner whose driver pinning silently broke reports the same green as one
|
||||
// that rendered every frame. MOBILEGL_ITEST_REQUIRE_GPU is the caller saying
|
||||
// "this machine HAS a GPU and I am relying on these scenarios actually running";
|
||||
// with it set, an unusable harness is a FAILURE carrying the pre-flight's reason.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "HeadlessGL.h"
|
||||
|
||||
namespace MGITest {
|
||||
|
||||
class ScenarioTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
m_ready = false;
|
||||
HeadlessGL& gl = HeadlessGL::Get();
|
||||
if (!gl.Usable()) {
|
||||
if (RequireGpu()) {
|
||||
// FAIL() is a FATAL failure but does NOT mark the test skipped,
|
||||
// so a derived SetUp that guards on IsSkipped() alone would run
|
||||
// straight into GL calls with no current context and SIGSEGV -
|
||||
// that exact crash shipped from the first version of this guard.
|
||||
// Derived fixtures must gate on Ready() (below), which is false
|
||||
// on BOTH the skip path and this failure path.
|
||||
FAIL() << "MOBILEGL_ITEST_REQUIRE_GPU is set, so an unusable harness is a failure, not a skip. "
|
||||
<< "Backend " << gl.BackendName() << " could not be brought up: " << gl.SkipReason();
|
||||
}
|
||||
GTEST_SKIP() << "no usable GPU/display/ICD for backend " << gl.BackendName() << ": " << gl.SkipReason();
|
||||
}
|
||||
if (RequireGpu() && LooksLikeSoftwareRasterizer(gl.RendererString())) {
|
||||
// "Ran on llvmpipe" must not be able to pass as "ran on the GPU":
|
||||
// a misconfigured vendor pin silently lands on the software
|
||||
// rasterizer, and REQUIRE_GPU exists precisely to make that loud.
|
||||
FAIL() << "MOBILEGL_ITEST_REQUIRE_GPU is set but the context landed on a software rasterizer: "
|
||||
<< gl.RendererString();
|
||||
}
|
||||
// A scenario starts from a clean slate but shares the context (and so
|
||||
// the renderer's memos) with every other scenario in this process -
|
||||
// which is exactly the situation both shipped bugs needed.
|
||||
RecordProperty("backend", gl.BackendName());
|
||||
RecordProperty("renderer", gl.RendererString());
|
||||
m_ready = true;
|
||||
}
|
||||
|
||||
// The ONLY gate a derived SetUp/TearDown may use: `if (!Ready()) return;`.
|
||||
// True only when the base SetUp brought the context up and neither skipped
|
||||
// nor failed. IsSkipped() alone is WRONG here (see the comment at FAIL()).
|
||||
bool Ready() const { return m_ready; }
|
||||
|
||||
static HeadlessGL& Gl() { return HeadlessGL::Get(); }
|
||||
|
||||
private:
|
||||
static bool LooksLikeSoftwareRasterizer(const std::string& renderer) {
|
||||
static const char* kNames[] = {"llvmpipe", "lavapipe", "softpipe", "SwiftShader", "swrast"};
|
||||
for (const char* name : kNames) {
|
||||
if (renderer.find(name) != std::string::npos) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool m_ready = false;
|
||||
};
|
||||
|
||||
} // namespace MGITest
|
||||
@@ -0,0 +1,53 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Main.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// Entry point for the headless GPU integration scenarios.
|
||||
//
|
||||
// The banner lives in a gtest Environment rather than in main() on purpose:
|
||||
// Environment::SetUp does not run for `--gtest_list_tests`, which is what CMake
|
||||
// invokes at build time to discover the cases. Discovery therefore never brings
|
||||
// up EGL, never needs a GPU and cannot hang.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdio>
|
||||
|
||||
#include "Harness/HeadlessGL.h"
|
||||
|
||||
namespace {
|
||||
|
||||
class HarnessBanner : public ::testing::Environment {
|
||||
public:
|
||||
void SetUp() override {
|
||||
const MGITest::HeadlessGL& gl = MGITest::HeadlessGL::Get();
|
||||
std::fprintf(stderr, "MobileGL integration scenarios: backend=%s\n", gl.BackendName().c_str());
|
||||
if (gl.Usable()) {
|
||||
std::fprintf(stderr, " renderer: %s\n surface: %dx%d pbuffer (headless)\n",
|
||||
gl.RendererString().c_str(), gl.Width(), gl.Height());
|
||||
} else if (MGITest::RequireGpu()) {
|
||||
std::fprintf(stderr,
|
||||
" FAILING every scenario (MOBILEGL_ITEST_REQUIRE_GPU is set): %s\n",
|
||||
gl.SkipReason().c_str());
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
" SKIPPING every scenario: %s\n"
|
||||
" (set MOBILEGL_ITEST_REQUIRE_GPU=1 to make this a failure instead - a run that\n"
|
||||
" skipped everything is otherwise indistinguishable from one that passed)\n",
|
||||
gl.SkipReason().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void TearDown() override { MGITest::HeadlessGL::Get().ShutDown(); }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
::testing::AddGlobalTestEnvironment(new HarnessBanner());
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/AsyncCompileScenario.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// Scenario E - asynchronous shader compilation and GL_KHR_parallel_shader_compile
|
||||
// on a REAL driver.
|
||||
//
|
||||
// WHY THIS EXISTS ALONGSIDE THE UNIT SUITES. MG_Test/Program's async suites already
|
||||
// drive the same GL entry points, but they stop at the frontend: nothing there ever
|
||||
// reaches a driver, so nothing there can catch the failure this scenario is built for
|
||||
// - artifacts produced on a worker thread that the BACKEND then rejects, mis-binds or
|
||||
// renders differently from the ones the GL thread produced. The frontend cannot tell
|
||||
// the two apart; a pixel can.
|
||||
//
|
||||
// The five things it pins, in order:
|
||||
//
|
||||
// (a) 64 heavy compiles are enqueued and polled through GL_COMPLETION_STATUS_KHR.
|
||||
// At least one must be observed GL_FALSE - i.e. the query really answers while
|
||||
// work is outstanding rather than silently joining. Skipped, never failed, when
|
||||
// the machine drained the whole batch before the first poll: a fast box must not
|
||||
// be able to turn this into a red.
|
||||
// (b) Forcing the join afterwards produces the right answer for every one of them:
|
||||
// GL_COMPILE_STATUS true, an empty info log, and a program that links.
|
||||
// (c) The extension string matches the configuration. This is the half a recorded
|
||||
// trace can never cover - Iris and Sodium change their submission schedule the
|
||||
// moment they see the string - so it is asserted against a real backend's real
|
||||
// GL_EXTENSIONS, through both glGetString and glGetStringi.
|
||||
// (d) glMaxShaderCompilerThreadsKHR(0) leaves nothing in flight: every subsequent
|
||||
// GL_COMPLETION_STATUS_KHR reads GL_TRUE immediately, and compilation after it
|
||||
// is synchronous. That is what the extension requires of a zero count.
|
||||
// (e) THE ONE THAT NEEDS A GPU: the same frame, drawn with programs compiled and
|
||||
// linked asynchronously and then with programs compiled and linked inline, must
|
||||
// come out byte-identical under glReadPixels. Anything the worker thread got
|
||||
// wrong about the compile environment, the reflection or the SPIR-V shows up
|
||||
// here as a pixel difference and nowhere else.
|
||||
//
|
||||
// Backend selection is the module's usual one process, one backend (MOBILEGL_BACKEND_TYPE),
|
||||
// so this file runs twice per ctest invocation.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#include "Config.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#undef GL_GLEXT_PROTOTYPES
|
||||
|
||||
// GL_KHR_parallel_shader_compile. Spelled out rather than relying on the host's
|
||||
// glext.h: this module is built against whatever GL headers the machine has, and an
|
||||
// older one has neither token. Both are also GL_*_ARB with identical values.
|
||||
#ifndef GL_MAX_SHADER_COMPILER_THREADS_KHR
|
||||
#define GL_MAX_SHADER_COMPILER_THREADS_KHR 0x91B0
|
||||
#endif
|
||||
#ifndef GL_COMPLETION_STATUS_KHR
|
||||
#define GL_COMPLETION_STATUS_KHR 0x91B1
|
||||
#endif
|
||||
|
||||
// The entry point under test, resolved by the linker straight into MobileGL_s like
|
||||
// every other gl* call in this module. Declared here for the same reason as the
|
||||
// tokens above.
|
||||
extern "C" void glMaxShaderCompilerThreadsKHR(GLuint count);
|
||||
|
||||
namespace MGITest {
|
||||
namespace {
|
||||
|
||||
using MobileGL::MG_Config::QuirkOverride;
|
||||
|
||||
// Same shape as the other scenarios: a two-attribute pass-through, so the only
|
||||
// thing that can differ between the two compilation modes is the compilation.
|
||||
constexpr const char* kVertexSource = R"(#version 330 core
|
||||
in vec2 aPos;
|
||||
in vec3 aColor;
|
||||
out vec3 vColor;
|
||||
void main() {
|
||||
vColor = aColor;
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
constexpr const char* kFragmentSource = R"(#version 330 core
|
||||
in vec3 vColor;
|
||||
out vec4 oColor;
|
||||
void main() {
|
||||
oColor = vec4(vColor, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
// Asymmetric in both axes, so a mode difference that also happens to be a
|
||||
// symmetry of the image cannot hide (the same reason OrientationScenario draws
|
||||
// quadrants rather than stripes).
|
||||
struct Vertex {
|
||||
float x, y;
|
||||
float r, g, b;
|
||||
};
|
||||
|
||||
void AppendQuad(std::vector<Vertex>& out, float x0, float x1, float y0, float y1, float r, float g, float b) {
|
||||
const Vertex bl{x0, y0, r, g, b};
|
||||
const Vertex br{x1, y0, r, g, b};
|
||||
const Vertex tr{x1, y1, r, g, b};
|
||||
const Vertex tl{x0, y1, r, g, b};
|
||||
out.insert(out.end(), {bl, br, tr, bl, tr, tl});
|
||||
}
|
||||
|
||||
std::vector<Vertex> QuadrantGeometry() {
|
||||
std::vector<Vertex> vertices;
|
||||
vertices.reserve(24);
|
||||
AppendQuad(vertices, -1.0f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f); // bottom-left: blue
|
||||
AppendQuad(vertices, 0.0f, 1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f); // bottom-right: green
|
||||
AppendQuad(vertices, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f); // top-left: red
|
||||
AppendQuad(vertices, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f); // top-right: white
|
||||
return vertices;
|
||||
}
|
||||
|
||||
// Expensive enough that a compile is not instantaneous, and distinct per index so
|
||||
// the source-hash memo never turns one into a no-op: without both properties the
|
||||
// pool has no backlog and (a) has nothing to observe.
|
||||
std::string BulkyFragmentSource(int index) {
|
||||
std::string source = "#version 330 core\n";
|
||||
source += "in vec3 vColor;\nout vec4 oColor;\n";
|
||||
source += "uniform float uSeed" + std::to_string(index) + ";\n";
|
||||
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
|
||||
for (int i = 0; i < 320; ++i) {
|
||||
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
|
||||
}
|
||||
source += " oColor = vec4(vColor * acc, 1.0);\n}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE decides the ambient mode; a scenario that wants
|
||||
// the other one says so here and gets the ambient one back on scope exit. Forcing
|
||||
// it in-process is what lets ONE ctest run compare the two modes against each
|
||||
// other - the whole point of (e).
|
||||
class AsyncModeScope {
|
||||
public:
|
||||
explicit AsyncModeScope(bool async) : m_saved(MobileGL::MG_Config::Features.AsyncShaderCompile) {
|
||||
MobileGL::MG_Config::Features.AsyncShaderCompile =
|
||||
async ? QuirkOverride::ForceOn : QuirkOverride::ForceOff;
|
||||
}
|
||||
~AsyncModeScope() { MobileGL::MG_Config::Features.AsyncShaderCompile = m_saved; }
|
||||
AsyncModeScope(const AsyncModeScope&) = delete;
|
||||
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
|
||||
|
||||
private:
|
||||
const QuirkOverride m_saved;
|
||||
};
|
||||
|
||||
// glMaxShaderCompilerThreadsKHR writes process-wide state; a scenario that calls
|
||||
// it has to put the pool back or it changes how every scenario after it compiles.
|
||||
class CompilerThreadScope {
|
||||
public:
|
||||
CompilerThreadScope() = default;
|
||||
~CompilerThreadScope() {
|
||||
MobileGL::MG_Util::Async::SetAsyncShaderCompileSuspended(false);
|
||||
auto& pool = MobileGL::MG_Util::Async::ShaderCompilePool::Get();
|
||||
pool.SetMaxConcurrency(pool.GetThreadCount());
|
||||
}
|
||||
CompilerThreadScope(const CompilerThreadScope&) = delete;
|
||||
CompilerThreadScope& operator=(const CompilerThreadScope&) = delete;
|
||||
};
|
||||
|
||||
GLint ShaderCompletion(GLuint shader) {
|
||||
GLint status = -1;
|
||||
glGetShaderiv(shader, GL_COMPLETION_STATUS_KHR, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
GLint ShaderCompileStatus(GLuint shader) {
|
||||
GLint status = GL_FALSE;
|
||||
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
std::string ShaderInfoLog(GLuint shader) {
|
||||
GLint length = 0;
|
||||
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
|
||||
if (length <= 0) return std::string();
|
||||
std::vector<char> buffer(static_cast<std::size_t>(length));
|
||||
GLsizei written = 0;
|
||||
glGetShaderInfoLog(shader, length, &written, buffer.data());
|
||||
return std::string(buffer.data(), static_cast<std::size_t>(written));
|
||||
}
|
||||
|
||||
class AsyncCompileScenario : public ScenarioTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
ScenarioTest::SetUp();
|
||||
if (!Ready()) return;
|
||||
|
||||
const std::vector<Vertex> vertices = QuadrantGeometry();
|
||||
m_vertexCount = static_cast<int>(vertices.size());
|
||||
glGenVertexArrays(1, &m_vao);
|
||||
glBindVertexArray(m_vao);
|
||||
glGenBuffers(1, &m_vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data(),
|
||||
GL_STATIC_DRAW);
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(0));
|
||||
glEnableVertexAttribArray(1);
|
||||
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(8));
|
||||
glBindVertexArray(0);
|
||||
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "setup left a GL error behind";
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
if (!Ready()) return;
|
||||
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
|
||||
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
|
||||
}
|
||||
|
||||
// A fresh program every time, compiled and linked in whatever mode is in
|
||||
// force. Reusing one would defeat the comparison: the second mode would just
|
||||
// read the first mode's artifacts back out of the memo.
|
||||
GLuint BuildProgram() {
|
||||
std::string error;
|
||||
const GLuint program = CompileProgram(kVertexSource, kFragmentSource, &error);
|
||||
EXPECT_NE(program, 0u) << error;
|
||||
return program;
|
||||
}
|
||||
|
||||
Image DrawFrameWith(GLuint program) {
|
||||
BindDefaultFramebuffer();
|
||||
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
glDisable(GL_BLEND);
|
||||
glUseProgram(program);
|
||||
glBindVertexArray(m_vao);
|
||||
glDrawArrays(GL_TRIANGLES, 0, m_vertexCount);
|
||||
glBindVertexArray(0);
|
||||
Image image = ReadPixels(Gl().Width(), Gl().Height());
|
||||
Gl().EndFrame();
|
||||
return image;
|
||||
}
|
||||
|
||||
// Enqueues `count` distinct heavy compiles and returns their names WITHOUT
|
||||
// reading anything back, so the pool is left with a real backlog.
|
||||
std::vector<GLuint> EnqueueBacklog(int count, int seedBase) {
|
||||
std::vector<GLuint> shaders;
|
||||
shaders.reserve(static_cast<std::size_t>(count));
|
||||
m_sources.reserve(m_sources.size() + static_cast<std::size_t>(count));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
m_sources.push_back(BulkyFragmentSource(seedBase + i));
|
||||
const char* text = m_sources.back().c_str();
|
||||
const GLuint shader = glCreateShader(GL_FRAGMENT_SHADER);
|
||||
glShaderSource(shader, 1, &text, nullptr);
|
||||
glCompileShader(shader);
|
||||
shaders.push_back(shader);
|
||||
}
|
||||
return shaders;
|
||||
}
|
||||
|
||||
GLuint m_vao = 0;
|
||||
GLuint m_vbo = 0;
|
||||
int m_vertexCount = 0;
|
||||
// Kept alive for the whole case: glShaderSource copies, but keeping the
|
||||
// strings makes a failure message able to name the source it came from.
|
||||
std::vector<std::string> m_sources;
|
||||
};
|
||||
|
||||
// ---- (a) + (b) ------------------------------------------------------------
|
||||
// A backlog is enqueued, polled without joining, then forced to settle and
|
||||
// checked for correctness. Both halves in one case on purpose: (b) is only
|
||||
// interesting for shaders that (a) proved were genuinely still outstanding.
|
||||
TEST_F(AsyncCompileScenario, CompletionStatusPollingThenForcedJoin) {
|
||||
if (!Ready()) return;
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope threads;
|
||||
// One worker, so the queue behind it is what the poll observes.
|
||||
glMaxShaderCompilerThreadsKHR(1);
|
||||
|
||||
const std::vector<GLuint> shaders = EnqueueBacklog(64, 6000);
|
||||
|
||||
int outstanding = 0;
|
||||
for (const GLuint shader : shaders) {
|
||||
const GLint completion = ShaderCompletion(shader);
|
||||
ASSERT_TRUE(completion == GL_TRUE || completion == GL_FALSE)
|
||||
<< "GL_COMPLETION_STATUS_KHR returned " << completion;
|
||||
if (completion == GL_FALSE) ++outstanding;
|
||||
}
|
||||
if (outstanding == 0) {
|
||||
GTEST_SKIP() << "this machine drained 64 heavy compiles before the first poll; "
|
||||
"nothing was outstanding to observe";
|
||||
}
|
||||
|
||||
// (b) Forced join: every one of them is correct, and usable.
|
||||
for (const GLuint shader : shaders) {
|
||||
EXPECT_EQ(ShaderCompileStatus(shader), GL_TRUE) << ShaderInfoLog(shader);
|
||||
EXPECT_TRUE(ShaderInfoLog(shader).empty());
|
||||
EXPECT_EQ(ShaderCompletion(shader), GL_TRUE) << "GL_COMPILE_STATUS must have joined";
|
||||
}
|
||||
|
||||
// And a link over one of them really produces a usable program on this driver.
|
||||
const GLuint vs = glCreateShader(GL_VERTEX_SHADER);
|
||||
glShaderSource(vs, 1, &kVertexSource, nullptr);
|
||||
glCompileShader(vs);
|
||||
const GLuint program = glCreateProgram();
|
||||
glAttachShader(program, vs);
|
||||
glAttachShader(program, shaders.front());
|
||||
glBindAttribLocation(program, 0, "aPos");
|
||||
glBindAttribLocation(program, 1, "aColor");
|
||||
glLinkProgram(program);
|
||||
GLint linked = GL_FALSE;
|
||||
glGetProgramiv(program, GL_LINK_STATUS, &linked);
|
||||
EXPECT_EQ(linked, GL_TRUE);
|
||||
EXPECT_GE(glGetUniformLocation(program, "uSeed6000"), 0);
|
||||
|
||||
glDeleteProgram(program);
|
||||
glDeleteShader(vs);
|
||||
for (const GLuint shader : shaders) glDeleteShader(shader);
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
// ---- (c) ------------------------------------------------------------------
|
||||
// The extension string, read from a real backend that really brought a driver
|
||||
// up. No mode forcing here: a backend builds its advertised list once, from the
|
||||
// configuration in force at its first use, so the meaningful assertion is
|
||||
// against the AMBIENT configuration - which is exactly what makes this case
|
||||
// worth running in both of the suite's flag states.
|
||||
TEST_F(AsyncCompileScenario, ExtensionStringMatchesTheConfiguration) {
|
||||
if (!Ready()) return;
|
||||
const bool expected = MobileGL::MG_Util::Async::AsyncShaderCompileEnabled();
|
||||
|
||||
const char* extensions = reinterpret_cast<const char*>(glGetString(GL_EXTENSIONS));
|
||||
ASSERT_NE(extensions, nullptr);
|
||||
const std::string extensionString(extensions);
|
||||
const bool inString = extensionString.find("GL_KHR_parallel_shader_compile") != std::string::npos;
|
||||
EXPECT_EQ(inString, expected)
|
||||
<< "backend " << Gl().BackendName() << " GL_EXTENSIONS = " << extensionString;
|
||||
|
||||
// LWJGL builds GLCapabilities from the INDEXED form on a core profile, so the
|
||||
// two spellings disagreeing would be invisible to the check above and fatal
|
||||
// to a real application.
|
||||
GLint count = 0;
|
||||
glGetIntegerv(GL_NUM_EXTENSIONS, &count);
|
||||
ASSERT_GT(count, 0);
|
||||
bool inIndexed = false;
|
||||
for (GLint i = 0; i < count; ++i) {
|
||||
const char* name = reinterpret_cast<const char*>(glGetStringi(GL_EXTENSIONS, GLuint(i)));
|
||||
if (name != nullptr && std::string(name) == "GL_KHR_parallel_shader_compile") inIndexed = true;
|
||||
}
|
||||
EXPECT_EQ(inIndexed, expected);
|
||||
|
||||
// The companion query, which an application reads right after the string.
|
||||
GLint maxThreads = -1;
|
||||
glGetIntegerv(GL_MAX_SHADER_COMPILER_THREADS_KHR, &maxThreads);
|
||||
if (expected) {
|
||||
EXPECT_GE(maxThreads, 1);
|
||||
} else {
|
||||
EXPECT_EQ(maxThreads, 0);
|
||||
}
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
// ---- (d) ------------------------------------------------------------------
|
||||
// A zero count must leave nothing in flight and keep it that way.
|
||||
TEST_F(AsyncCompileScenario, ZeroCompilerThreadsSettlesEverythingImmediately) {
|
||||
if (!Ready()) return;
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope threads;
|
||||
glMaxShaderCompilerThreadsKHR(1);
|
||||
|
||||
const std::vector<GLuint> backlog = EnqueueBacklog(48, 6200);
|
||||
glMaxShaderCompilerThreadsKHR(0);
|
||||
|
||||
for (const GLuint shader : backlog) {
|
||||
EXPECT_EQ(ShaderCompletion(shader), GL_TRUE)
|
||||
<< "glMaxShaderCompilerThreadsKHR(0) must join everything still in flight";
|
||||
EXPECT_EQ(ShaderCompileStatus(shader), GL_TRUE) << ShaderInfoLog(shader);
|
||||
}
|
||||
|
||||
// Compilation after the zero count is synchronous too.
|
||||
const std::vector<GLuint> serial = EnqueueBacklog(6, 6300);
|
||||
for (const GLuint shader : serial) {
|
||||
EXPECT_EQ(ShaderCompletion(shader), GL_TRUE) << "a compile after a zero count must be synchronous";
|
||||
}
|
||||
|
||||
for (const GLuint shader : backlog) glDeleteShader(shader);
|
||||
for (const GLuint shader : serial) glDeleteShader(shader);
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
// ---- (e) ------------------------------------------------------------------
|
||||
// The one that needs the GPU. Two programs, identical source, one built with
|
||||
// compilation and linking on worker threads and one built inline; the frames
|
||||
// they draw must be byte-identical.
|
||||
//
|
||||
// Compared through the DEFAULT framebuffer deliberately: that is where the
|
||||
// backend's orientation and present path live, so the comparison covers the
|
||||
// whole pipeline rather than the reflection tables alone.
|
||||
TEST_F(AsyncCompileScenario, AsyncAndSyncProgramsRenderIdenticalFrames) {
|
||||
if (!Ready()) return;
|
||||
|
||||
Image asyncImage;
|
||||
{
|
||||
const AsyncModeScope async(true);
|
||||
const GLuint program = BuildProgram();
|
||||
ASSERT_NE(program, 0u);
|
||||
asyncImage = DrawFrameWith(program);
|
||||
glDeleteProgram(program);
|
||||
}
|
||||
|
||||
Image syncImage;
|
||||
{
|
||||
const AsyncModeScope async(false);
|
||||
const GLuint program = BuildProgram();
|
||||
ASSERT_NE(program, 0u);
|
||||
syncImage = DrawFrameWith(program);
|
||||
glDeleteProgram(program);
|
||||
}
|
||||
|
||||
ASSERT_FALSE(asyncImage.Empty());
|
||||
ASSERT_FALSE(syncImage.Empty());
|
||||
// The frame is the expected one in the first place - two identically WRONG
|
||||
// frames would otherwise pass.
|
||||
EXPECT_EQ(asyncImage.QuadrantSignature(), "blue,green,red,white")
|
||||
<< "the asynchronously compiled program did not draw the expected frame";
|
||||
EXPECT_EQ(asyncImage, syncImage)
|
||||
<< "asynchronous and synchronous compilation rendered different frames ("
|
||||
<< asyncImage.ByteDiffCount(syncImage) << " bytes differ); backend " << Gl().BackendName();
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
// The same comparison over a batch, which is the shape a shaderpack load has:
|
||||
// many programs enqueued before any of them is read back, then each one drawn.
|
||||
// A per-worker state leak (glslang's thread-local pools are the obvious
|
||||
// candidate) shows up here and not in the single-program case above.
|
||||
TEST_F(AsyncCompileScenario, ABatchOfAsyncProgramsAllRenderCorrectly) {
|
||||
if (!Ready()) return;
|
||||
constexpr int kPrograms = 12;
|
||||
|
||||
std::vector<GLuint> programs;
|
||||
{
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope threads;
|
||||
glMaxShaderCompilerThreadsKHR(1);
|
||||
// Everything enqueued before anything is read: the only shape in which
|
||||
// more than one job is in flight at a time.
|
||||
for (int i = 0; i < kPrograms; ++i) {
|
||||
programs.push_back(BuildProgram());
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < kPrograms; ++i) {
|
||||
ASSERT_NE(programs[static_cast<std::size_t>(i)], 0u) << "program " << i;
|
||||
const Image image = DrawFrameWith(programs[static_cast<std::size_t>(i)]);
|
||||
EXPECT_EQ(image.QuadrantSignature(), "blue,green,red,white") << "program " << i;
|
||||
}
|
||||
for (const GLuint program : programs) glDeleteProgram(program);
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace MGITest
|
||||
@@ -0,0 +1,761 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CrossFrameBufferScenario.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// Scenario B - "the draw rendered last frame's buffer".
|
||||
//
|
||||
// The shipped bug (DirectVulkan, TryBindResolvedVertexBindings and the EBO
|
||||
// memo in UploadAndBindIndexBuffer): both memos revalidated themselves ACROSS a
|
||||
// frame boundary by comparing recorded per-buffer slice epochs, and on a match
|
||||
// skipped the per-frame buffer acquire. The acquire is the frame's content-sync
|
||||
// point; skipping it trusted the BumpSliceEpoch call-site inventory to cover
|
||||
// every way a buffer's GPU copy can go stale, and at least one path escaped it.
|
||||
// Result: a draw in a later frame renders from a STALE buffer slice - random
|
||||
// triangles in Minecraft/Sodium on Adreno, corrupted journeymap and
|
||||
// common-mods retraces.
|
||||
//
|
||||
// What pins it: mutate a buffer AFTER a frame boundary and BEFORE the next
|
||||
// draw, then prove the pixels show the NEW content. Every mutation API gets its
|
||||
// own test case, so a failure names the culprit rather than saying "buffers".
|
||||
// The index buffer is covered too: the EBO memo had exactly the same hole.
|
||||
//
|
||||
// The scene is deliberately trivial and entirely buffer-driven:
|
||||
//
|
||||
// vertices 0..3 left half of the viewport, RED
|
||||
// vertices 4..7 right half of the viewport, GREEN
|
||||
// indices A {0,1,2, 0,2,3} -> the left, red quad
|
||||
// indices B {4,5,6, 4,6,7} -> the right, green quad
|
||||
//
|
||||
// A vertex-buffer test rewrites the left quad's colour red -> green and expects
|
||||
// the left half to turn green. An index-buffer test rewrites the indices
|
||||
// A -> B and expects the picture to jump from a red left half to a green right
|
||||
// half. Either way "stale" and "fresh" are different colours in different
|
||||
// places; no thresholds, no interpretation.
|
||||
//
|
||||
// Two families of scenario live here, and they catch different halves of the
|
||||
// same rule:
|
||||
//
|
||||
// CrossFrameBufferScenario - one case per buffer-mutation API. Every one of
|
||||
// these APIs is supposed to retire the memo; today they all do (each notify
|
||||
// path bumps the slice epoch), so these pass on the buggy revision too.
|
||||
// They are the standing statement of the contract: whatever a future memo
|
||||
// keys on, a write through ANY of these APIs must reach the next frame's
|
||||
// draw. They are also where a coherent persistent write - the one shape
|
||||
// that changes a buffer with no GL call at all - is pinned.
|
||||
//
|
||||
// StreamedArenaScenario - the case that actually caught the shipped bug. It
|
||||
// attacks the other half of the rule: a buffer nobody wrote at all, whose
|
||||
// GPU-side bytes moved out from under the memo anyway.
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#undef GL_GLEXT_PROTOTYPES
|
||||
|
||||
namespace MGITest {
|
||||
namespace {
|
||||
|
||||
constexpr const char* kVertexSource = R"(#version 330 core
|
||||
in vec2 aPos;
|
||||
in vec3 aColor;
|
||||
out vec3 vColor;
|
||||
void main() {
|
||||
vColor = aColor;
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
constexpr const char* kFragmentSource = R"(#version 330 core
|
||||
in vec3 vColor;
|
||||
out vec4 oColor;
|
||||
void main() {
|
||||
oColor = vec4(vColor, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
struct Vertex {
|
||||
float x, y;
|
||||
float r, g, b;
|
||||
};
|
||||
|
||||
constexpr int kLeftQuadFirstVertex = 0;
|
||||
constexpr int kLeftQuadVertexCount = 4;
|
||||
constexpr int kIndexCount = 6;
|
||||
|
||||
// Enough consecutive frames drawing the same VAO that any per-(VAO, frame)
|
||||
// memo is fully armed before the mutation lands.
|
||||
constexpr int kWarmupFrames = 3;
|
||||
|
||||
std::vector<Vertex> SceneVertices(bool leftQuadIsGreen) {
|
||||
const float lr = leftQuadIsGreen ? 0.0f : 1.0f;
|
||||
const float lg = leftQuadIsGreen ? 1.0f : 0.0f;
|
||||
return {
|
||||
// 0..3: left half
|
||||
{-1.0f, -1.0f, lr, lg, 0.0f},
|
||||
{0.0f, -1.0f, lr, lg, 0.0f},
|
||||
{0.0f, 1.0f, lr, lg, 0.0f},
|
||||
{-1.0f, 1.0f, lr, lg, 0.0f},
|
||||
// 4..7: right half
|
||||
{0.0f, -1.0f, 0.0f, 1.0f, 0.0f},
|
||||
{1.0f, -1.0f, 0.0f, 1.0f, 0.0f},
|
||||
{1.0f, 1.0f, 0.0f, 1.0f, 0.0f},
|
||||
{0.0f, 1.0f, 0.0f, 1.0f, 0.0f},
|
||||
};
|
||||
}
|
||||
|
||||
const GLuint kIndicesLeftQuad[kIndexCount] = {0, 1, 2, 0, 2, 3};
|
||||
const GLuint kIndicesRightQuad[kIndexCount] = {4, 5, 6, 4, 6, 7};
|
||||
|
||||
// How far inside each half the whole-region checks start. The two quads
|
||||
// meet on a pixel boundary, so a couple of pixels of margin makes "every
|
||||
// single pixel in the region" an achievable demand.
|
||||
constexpr int kHalfInset = 2;
|
||||
|
||||
// Asserts the left and right halves of the viewport, with a message that
|
||||
// says what the app had asked GL to draw by then.
|
||||
//
|
||||
// This counts EVERY pixel in each half rather than sampling its centre.
|
||||
// Sampling two pixels was demonstrably too weak: a draw in which three of
|
||||
// the left quad's four vertices still carry stale data paints a centre
|
||||
// pixel of exactly the expected colour and passed the old assertion. That
|
||||
// case is now a standing negative control - see
|
||||
// PartialStalenessIsCaughtByWholeRegionChecks below, which constructs it
|
||||
// deliberately and proves the region scan reports it.
|
||||
void ExpectHalves(const Image& image, const char* expectedLeft, const char* expectedRight,
|
||||
const std::string& when) {
|
||||
const int w = image.Width();
|
||||
const int h = image.Height();
|
||||
EXPECT_TRUE(RegionIsMostly(image, kHalfInset, w / 2 - kHalfInset, kHalfInset, h - kHalfInset, expectedLeft,
|
||||
0.0, when + " [left half]"));
|
||||
EXPECT_TRUE(RegionIsMostly(image, w / 2 + kHalfInset, w - kHalfInset, kHalfInset, h - kHalfInset,
|
||||
expectedRight, 0.0, when + " [right half]"));
|
||||
}
|
||||
|
||||
// How the app hands the new bytes to GL. Each is its own test case.
|
||||
enum class Mutation {
|
||||
SubData, // glBufferSubData
|
||||
MapWriteUnmap, // glMapBufferRange(WRITE) + glUnmapBuffer
|
||||
PersistentFlush, // write through a persistent map + glFlushMappedBufferRange
|
||||
PersistentCoherent, // write through a COHERENT persistent map, no GL call at all
|
||||
OrphanReupload, // glBufferData(NULL) then a full re-upload
|
||||
CopySubData, // glCopyBufferSubData from a staging buffer
|
||||
};
|
||||
|
||||
bool NeedsImmutableStorage(Mutation mutation) {
|
||||
return mutation == Mutation::PersistentFlush || mutation == Mutation::PersistentCoherent;
|
||||
}
|
||||
|
||||
// The coherent variant is the one shape in which an application changes a
|
||||
// buffer's contents with NO GL call whatsoever - the write lands in the
|
||||
// mapping and that is the end of it. Sodium's chunk streaming is written
|
||||
// this way, and it is the case a per-buffer "has anything changed?" epoch
|
||||
// cannot see on its own.
|
||||
bool NeedsCoherentMapping(Mutation mutation) {
|
||||
return mutation == Mutation::PersistentCoherent;
|
||||
}
|
||||
|
||||
class CrossFrameBufferScenario : public ScenarioTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
ScenarioTest::SetUp();
|
||||
if (!Ready()) return;
|
||||
std::string error;
|
||||
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
|
||||
ASSERT_NE(m_program, 0u) << error;
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "program setup left a GL error behind";
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
if (!Ready()) return;
|
||||
ReleaseBuffers();
|
||||
if (m_program != 0) glDeleteProgram(m_program);
|
||||
}
|
||||
|
||||
// Builds the VAO/VBO/EBO. `immutable` switches to glBufferStorage plus a
|
||||
// persistent mapping of both buffers, which is the only shape in which the
|
||||
// persistent-write mutation is legal.
|
||||
void BuildScene(bool immutable, bool coherent = false) {
|
||||
const std::vector<Vertex> vertices = SceneVertices(/*leftQuadIsGreen=*/false);
|
||||
m_vertexBytes = GLsizeiptr(vertices.size() * sizeof(Vertex));
|
||||
m_indexBytes = GLsizeiptr(sizeof(kIndicesLeftQuad));
|
||||
|
||||
glGenVertexArrays(1, &m_vao);
|
||||
glBindVertexArray(m_vao);
|
||||
|
||||
glGenBuffers(1, &m_vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
|
||||
glGenBuffers(1, &m_ebo);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo);
|
||||
|
||||
if (immutable) {
|
||||
const GLbitfield storageFlags = GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_DYNAMIC_STORAGE_BIT |
|
||||
(coherent ? GL_MAP_COHERENT_BIT : 0);
|
||||
glBufferStorage(GL_ARRAY_BUFFER, m_vertexBytes, vertices.data(), storageFlags);
|
||||
glBufferStorage(GL_ELEMENT_ARRAY_BUFFER, m_indexBytes, kIndicesLeftQuad, storageFlags);
|
||||
const GLenum storageError = FirstGLError();
|
||||
if (storageError != GL_NO_ERROR) {
|
||||
m_storageUnsupported = true;
|
||||
m_storageError = storageError;
|
||||
return;
|
||||
}
|
||||
const GLbitfield mapFlags = GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT |
|
||||
(coherent ? GL_MAP_COHERENT_BIT : GL_MAP_FLUSH_EXPLICIT_BIT);
|
||||
m_vertexMap =
|
||||
static_cast<unsigned char*>(glMapBufferRange(GL_ARRAY_BUFFER, 0, m_vertexBytes, mapFlags));
|
||||
m_indexMap = static_cast<unsigned char*>(
|
||||
glMapBufferRange(GL_ELEMENT_ARRAY_BUFFER, 0, m_indexBytes, mapFlags));
|
||||
if (m_vertexMap == nullptr || m_indexMap == nullptr) {
|
||||
m_storageUnsupported = true;
|
||||
m_storageError = FirstGLError();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
glBufferData(GL_ARRAY_BUFFER, m_vertexBytes, vertices.data(), GL_STATIC_DRAW);
|
||||
glBufferData(GL_ELEMENT_ARRAY_BUFFER, m_indexBytes, kIndicesLeftQuad, GL_STATIC_DRAW);
|
||||
}
|
||||
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(0));
|
||||
glEnableVertexAttribArray(1);
|
||||
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(8));
|
||||
glBindVertexArray(0);
|
||||
|
||||
glGenBuffers(1, &m_staging);
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
|
||||
}
|
||||
|
||||
void ReleaseBuffers() {
|
||||
if (m_vertexMap != nullptr || m_indexMap != nullptr) {
|
||||
glBindVertexArray(m_vao);
|
||||
if (m_vertexMap != nullptr) {
|
||||
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
|
||||
glUnmapBuffer(GL_ARRAY_BUFFER);
|
||||
}
|
||||
if (m_indexMap != nullptr) {
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo);
|
||||
glUnmapBuffer(GL_ELEMENT_ARRAY_BUFFER);
|
||||
}
|
||||
glBindVertexArray(0);
|
||||
m_vertexMap = nullptr;
|
||||
m_indexMap = nullptr;
|
||||
}
|
||||
if (m_staging != 0) glDeleteBuffers(1, &m_staging);
|
||||
if (m_ebo != 0) glDeleteBuffers(1, &m_ebo);
|
||||
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
|
||||
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
|
||||
m_staging = m_ebo = m_vbo = m_vao = 0;
|
||||
}
|
||||
|
||||
void DrawScene() {
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
glDisable(GL_BLEND);
|
||||
glUseProgram(m_program);
|
||||
glBindVertexArray(m_vao);
|
||||
glDrawElements(GL_TRIANGLES, kIndexCount, GL_UNSIGNED_INT, nullptr);
|
||||
glBindVertexArray(0);
|
||||
}
|
||||
|
||||
void BeginFrame() {
|
||||
BindDefaultFramebuffer();
|
||||
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
Image ReadFrame() { return ReadPixels(Gl().Width(), Gl().Height()); }
|
||||
|
||||
// ---- the mutations ---------------------------------------------
|
||||
// Each writes `newBytes` over the first `rangeBytes` of `buffer`;
|
||||
// `wholeBytes`/`wholeSize` are the full contents an orphan+re-upload
|
||||
// needs. `target` is the binding point the buffer normally lives at.
|
||||
void ApplyMutation(Mutation mutation, GLenum target, GLuint buffer, unsigned char* persistentMap,
|
||||
const void* newBytes, GLsizeiptr rangeBytes, const void* wholeBytes,
|
||||
GLsizeiptr wholeSize) {
|
||||
// The element-array binding is VAO state, so mutating the EBO happens
|
||||
// with the scene's VAO bound - exactly as an application would.
|
||||
glBindVertexArray(m_vao);
|
||||
switch (mutation) {
|
||||
case Mutation::SubData: {
|
||||
glBindBuffer(target, buffer);
|
||||
glBufferSubData(target, 0, rangeBytes, newBytes);
|
||||
break;
|
||||
}
|
||||
case Mutation::MapWriteUnmap: {
|
||||
glBindBuffer(target, buffer);
|
||||
void* mapped =
|
||||
glMapBufferRange(target, 0, rangeBytes, GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_RANGE_BIT);
|
||||
ASSERT_NE(mapped, nullptr) << "glMapBufferRange(WRITE) returned null";
|
||||
std::memcpy(mapped, newBytes, std::size_t(rangeBytes));
|
||||
ASSERT_EQ(glUnmapBuffer(target), GLboolean(GL_TRUE)) << "glUnmapBuffer reported data loss";
|
||||
break;
|
||||
}
|
||||
case Mutation::PersistentFlush: {
|
||||
ASSERT_NE(persistentMap, nullptr) << "no persistent mapping for this buffer";
|
||||
std::memcpy(persistentMap, newBytes, std::size_t(rangeBytes));
|
||||
glBindBuffer(target, buffer);
|
||||
glFlushMappedBufferRange(target, 0, rangeBytes);
|
||||
break;
|
||||
}
|
||||
case Mutation::PersistentCoherent: {
|
||||
// Deliberately no GL call: a coherent persistent mapping is a
|
||||
// promise that the write alone is enough.
|
||||
ASSERT_NE(persistentMap, nullptr) << "no persistent mapping for this buffer";
|
||||
std::memcpy(persistentMap, newBytes, std::size_t(rangeBytes));
|
||||
break;
|
||||
}
|
||||
case Mutation::OrphanReupload: {
|
||||
glBindBuffer(target, buffer);
|
||||
glBufferData(target, wholeSize, nullptr, GL_STATIC_DRAW);
|
||||
glBufferSubData(target, 0, wholeSize, wholeBytes);
|
||||
break;
|
||||
}
|
||||
case Mutation::CopySubData: {
|
||||
glBindBuffer(GL_COPY_READ_BUFFER, m_staging);
|
||||
glBufferData(GL_COPY_READ_BUFFER, rangeBytes, newBytes, GL_STATIC_DRAW);
|
||||
glBindBuffer(GL_COPY_WRITE_BUFFER, buffer);
|
||||
glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, 0, 0, rangeBytes);
|
||||
glBindBuffer(GL_COPY_WRITE_BUFFER, 0);
|
||||
glBindBuffer(GL_COPY_READ_BUFFER, 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
glBindVertexArray(0);
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the mutation itself raised a GL error";
|
||||
}
|
||||
|
||||
// ---- the story -------------------------------------------------
|
||||
// Steady state for a few frames, one frame boundary, then the
|
||||
// mutation, then the draw that must show the new content.
|
||||
void RunAcrossFrameBoundary(Mutation mutation, const std::function<void()>& mutate,
|
||||
const char* expectedLeftAfter, const char* expectedRightAfter) {
|
||||
ASSERT_NO_FATAL_FAILURE(BuildScene(NeedsImmutableStorage(mutation), NeedsCoherentMapping(mutation)));
|
||||
if (m_storageUnsupported) {
|
||||
GTEST_SKIP() << "immutable/persistent buffer storage is unavailable on this stack ("
|
||||
<< GLErrorName(m_storageError) << "); the persistent-map mutation cannot "
|
||||
<< "be expressed here";
|
||||
}
|
||||
|
||||
for (int frame = 0; frame < kWarmupFrames; ++frame) {
|
||||
BeginFrame();
|
||||
DrawScene();
|
||||
Gl().EndFrame();
|
||||
}
|
||||
|
||||
BeginFrame();
|
||||
DrawScene();
|
||||
const Image before = ReadFrame();
|
||||
ExpectHalves(before, "red", "black", "steady state before the mutation");
|
||||
ASSERT_FALSE(::testing::Test::HasFailure())
|
||||
<< "the scenario never reached its steady state, so nothing after this means anything";
|
||||
|
||||
// >>> a genuine frame boundary. Everything below happens in the NEXT
|
||||
// frame, which is the whole point: a mutation inside one frame proves
|
||||
// nothing about a memo that revalidates itself across frames.
|
||||
Gl().EndFrame();
|
||||
|
||||
BeginFrame();
|
||||
ASSERT_NO_FATAL_FAILURE(mutate());
|
||||
DrawScene();
|
||||
const Image after = ReadFrame();
|
||||
Gl().EndFrame();
|
||||
|
||||
ExpectHalves(after, expectedLeftAfter, expectedRightAfter,
|
||||
"the draw after the mutation drew STALE buffer content");
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
// The two things a scenario mutates.
|
||||
void MutateVertexColorsToGreen(Mutation mutation) {
|
||||
const std::vector<Vertex> updated = SceneVertices(/*leftQuadIsGreen=*/true);
|
||||
const GLsizeiptr leftQuadBytes = GLsizeiptr(kLeftQuadVertexCount * sizeof(Vertex));
|
||||
ApplyMutation(mutation, GL_ARRAY_BUFFER, m_vbo, m_vertexMap, updated.data() + kLeftQuadFirstVertex,
|
||||
leftQuadBytes, updated.data(), m_vertexBytes);
|
||||
}
|
||||
|
||||
void MutateIndicesToRightQuad(Mutation mutation) {
|
||||
ApplyMutation(mutation, GL_ELEMENT_ARRAY_BUFFER, m_ebo, m_indexMap, kIndicesRightQuad, m_indexBytes,
|
||||
kIndicesRightQuad, m_indexBytes);
|
||||
}
|
||||
|
||||
unsigned int m_program = 0;
|
||||
unsigned int m_vao = 0;
|
||||
unsigned int m_vbo = 0;
|
||||
unsigned int m_ebo = 0;
|
||||
unsigned int m_staging = 0;
|
||||
GLsizeiptr m_vertexBytes = 0;
|
||||
GLsizeiptr m_indexBytes = 0;
|
||||
unsigned char* m_vertexMap = nullptr;
|
||||
unsigned char* m_indexMap = nullptr;
|
||||
bool m_storageUnsupported = false;
|
||||
unsigned int m_storageError = 0;
|
||||
};
|
||||
|
||||
// ---- vertex buffer: the left quad must turn green ------------------
|
||||
|
||||
TEST_F(CrossFrameBufferScenario, VertexBufferSubData) {
|
||||
RunAcrossFrameBoundary(
|
||||
Mutation::SubData, [&] { MutateVertexColorsToGreen(Mutation::SubData); }, "green", "black");
|
||||
}
|
||||
|
||||
TEST_F(CrossFrameBufferScenario, VertexMapWriteUnmap) {
|
||||
RunAcrossFrameBoundary(
|
||||
Mutation::MapWriteUnmap, [&] { MutateVertexColorsToGreen(Mutation::MapWriteUnmap); }, "green", "black");
|
||||
}
|
||||
|
||||
TEST_F(CrossFrameBufferScenario, VertexPersistentMapFlush) {
|
||||
RunAcrossFrameBoundary(
|
||||
Mutation::PersistentFlush, [&] { MutateVertexColorsToGreen(Mutation::PersistentFlush); }, "green",
|
||||
"black");
|
||||
}
|
||||
|
||||
TEST_F(CrossFrameBufferScenario, VertexPersistentCoherentWrite) {
|
||||
RunAcrossFrameBoundary(
|
||||
Mutation::PersistentCoherent, [&] { MutateVertexColorsToGreen(Mutation::PersistentCoherent); }, "green",
|
||||
"black");
|
||||
}
|
||||
|
||||
TEST_F(CrossFrameBufferScenario, VertexOrphanAndReupload) {
|
||||
RunAcrossFrameBoundary(
|
||||
Mutation::OrphanReupload, [&] { MutateVertexColorsToGreen(Mutation::OrphanReupload); }, "green",
|
||||
"black");
|
||||
}
|
||||
|
||||
TEST_F(CrossFrameBufferScenario, VertexCopyBufferSubData) {
|
||||
RunAcrossFrameBoundary(
|
||||
Mutation::CopySubData, [&] { MutateVertexColorsToGreen(Mutation::CopySubData); }, "green", "black");
|
||||
}
|
||||
|
||||
// ---- index buffer: the picture must jump to the right, green quad --
|
||||
// The EBO memo had the same cross-frame hole as the vertex one, and no
|
||||
// vertex-only test can see it.
|
||||
|
||||
TEST_F(CrossFrameBufferScenario, IndexBufferSubData) {
|
||||
RunAcrossFrameBoundary(
|
||||
Mutation::SubData, [&] { MutateIndicesToRightQuad(Mutation::SubData); }, "black", "green");
|
||||
}
|
||||
|
||||
TEST_F(CrossFrameBufferScenario, IndexMapWriteUnmap) {
|
||||
RunAcrossFrameBoundary(
|
||||
Mutation::MapWriteUnmap, [&] { MutateIndicesToRightQuad(Mutation::MapWriteUnmap); }, "black", "green");
|
||||
}
|
||||
|
||||
TEST_F(CrossFrameBufferScenario, IndexPersistentMapFlush) {
|
||||
RunAcrossFrameBoundary(
|
||||
Mutation::PersistentFlush, [&] { MutateIndicesToRightQuad(Mutation::PersistentFlush); }, "black",
|
||||
"green");
|
||||
}
|
||||
|
||||
// Kept, with its coverage stated exactly, because it is the one case in
|
||||
// this file that is served a stale slice by the buggy revision and passes
|
||||
// anyway - and a test that reads as coverage without being coverage is
|
||||
// worse than no test.
|
||||
//
|
||||
// COVERS: the coherent-persistent index contract - a write into a coherent
|
||||
// persistent mapping, with no GL call at all, must reach the next frame's
|
||||
// draw. That is a real contract and this is the only case that states it
|
||||
// for indices.
|
||||
//
|
||||
// DOES NOT COVER: the EBO cross-frame memo. Instrumented against the
|
||||
// re-enabled buggy path, it enters the cross-frame branch 4 times and is
|
||||
// served its recorded slice all 4 times - and still passes, because the
|
||||
// backend adopted the persistent map into that very storage
|
||||
// (AcquirePersistentMap succeeded), so the application's writes landed in
|
||||
// the bytes the "stale" slice names. It would only discriminate on a stack
|
||||
// where that adoption is declined and the CPU shadow stays authoritative;
|
||||
// measured over this whole module, 50 of 50 coherent persistent write maps
|
||||
// were adopted. See ResidentIndexScenario.cpp for the full account.
|
||||
TEST_F(CrossFrameBufferScenario, IndexPersistentCoherentWrite) {
|
||||
RunAcrossFrameBoundary(
|
||||
Mutation::PersistentCoherent, [&] { MutateIndicesToRightQuad(Mutation::PersistentCoherent); }, "black",
|
||||
"green");
|
||||
}
|
||||
|
||||
TEST_F(CrossFrameBufferScenario, IndexOrphanAndReupload) {
|
||||
RunAcrossFrameBoundary(
|
||||
Mutation::OrphanReupload, [&] { MutateIndicesToRightQuad(Mutation::OrphanReupload); }, "black",
|
||||
"green");
|
||||
}
|
||||
|
||||
TEST_F(CrossFrameBufferScenario, IndexCopyBufferSubData) {
|
||||
RunAcrossFrameBoundary(
|
||||
Mutation::CopySubData, [&] { MutateIndicesToRightQuad(Mutation::CopySubData); }, "black", "green");
|
||||
}
|
||||
|
||||
// ---- a self-test of the assertions, not of MobileGL ------------------
|
||||
//
|
||||
// Every case above leans on ExpectHalves. ExpectHalves used to sample the
|
||||
// centre pixel of each half - two pixels for a 12288-pixel readback - and
|
||||
// that is measurably too weak to stand behind a claim about buffer
|
||||
// freshness: a quad whose four vertices are only PARTLY updated still
|
||||
// paints a sampled centre the expected colour, because the centre is a
|
||||
// barycentric blend dominated by the vertices that DID update.
|
||||
//
|
||||
// So construct that case on purpose. Update the left quad's colour to
|
||||
// green in the buffer but leave exactly one of its four vertices holding
|
||||
// the old red, once for each vertex, and check two things:
|
||||
//
|
||||
// - the whole-region scan reports every one of the four (the tightening
|
||||
// is real, and this test fails the moment someone loosens it back to
|
||||
// sampling);
|
||||
// - at least one of the four is invisible to a single centre sample
|
||||
// (the blind spot was real, and this records which vertices it hid).
|
||||
//
|
||||
// Nothing here calls a memo path; it is the assertion itself under test.
|
||||
TEST_F(CrossFrameBufferScenario, PartialStalenessIsCaughtByWholeRegionChecks) {
|
||||
ASSERT_NO_FATAL_FAILURE(BuildScene(/*immutable=*/false));
|
||||
|
||||
const std::vector<Vertex> allGreen = SceneVertices(/*leftQuadIsGreen=*/true);
|
||||
const std::vector<Vertex> allRed = SceneVertices(/*leftQuadIsGreen=*/false);
|
||||
const GLsizeiptr leftQuadBytes = GLsizeiptr(kLeftQuadVertexCount * sizeof(Vertex));
|
||||
|
||||
int centreSampleMissed = 0;
|
||||
std::string missedVertices;
|
||||
for (int staleVertex = 0; staleVertex < kLeftQuadVertexCount; ++staleVertex) {
|
||||
// Every left-quad vertex turns green except this one.
|
||||
std::vector<Vertex> partial(allGreen.begin(), allGreen.begin() + kLeftQuadVertexCount);
|
||||
partial[std::size_t(staleVertex)] = allRed[std::size_t(staleVertex)];
|
||||
|
||||
glBindVertexArray(m_vao);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
|
||||
glBufferSubData(GL_ARRAY_BUFFER, 0, leftQuadBytes, partial.data());
|
||||
glBindVertexArray(0);
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the partial update itself raised a GL error";
|
||||
|
||||
BeginFrame();
|
||||
DrawScene();
|
||||
const Image image = ReadFrame();
|
||||
Gl().EndFrame();
|
||||
|
||||
const int w = image.Width();
|
||||
const int h = image.Height();
|
||||
const RegionScan scan =
|
||||
ScanRegion(image, kHalfInset, w / 2 - kHalfInset, kHalfInset, h - kHalfInset, "green");
|
||||
EXPECT_GT(scan.offenders, 0)
|
||||
<< "vertex " << staleVertex << " of the left quad kept its stale red colour and the "
|
||||
<< "whole-region scan saw nothing wrong across " << scan.total << " pixels - the assertion "
|
||||
<< "is not tight enough to stand behind any freshness claim in this file";
|
||||
|
||||
// What the old two-pixel form of ExpectHalves would have concluded.
|
||||
if (std::strcmp(image.ColorName(w / 4, h / 2), "green") == 0) {
|
||||
++centreSampleMissed;
|
||||
if (!missedVertices.empty()) missedVertices += ",";
|
||||
missedVertices += std::to_string(staleVertex);
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_GT(centreSampleMissed, 0)
|
||||
<< "no single-vertex staleness was invisible to a centre sample, so this negative control "
|
||||
<< "is no longer demonstrating anything - re-derive it before trusting it";
|
||||
if (centreSampleMissed > 0) {
|
||||
RecordProperty("centre_sample_blind_to_stale_vertices", missedVertices);
|
||||
std::fprintf(stderr,
|
||||
"[itest] whole-region scan caught all %d single-stale-vertex cases; a centre "
|
||||
"sample alone was blind to %d of them (vertices %s)\n",
|
||||
kLeftQuadVertexCount, centreSampleMissed, missedVertices.c_str());
|
||||
}
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
// ---- the same bug, seen from the other side --------------------------
|
||||
//
|
||||
// The mutation cases above ask "did the new bytes reach the GPU?". This
|
||||
// one asks the question a STREAMED buffer forces: "do the old bytes even
|
||||
// still exist?".
|
||||
//
|
||||
// A GL_STREAM_DRAW / GL_DYNAMIC_DRAW buffer is not given permanent GPU
|
||||
// storage. Every frame its contents are copied into that frame's
|
||||
// transient upload arena, which is a bump allocator reset at the start of
|
||||
// each frame slot - so a slice handed out in frame N names bytes that
|
||||
// frame N+frames-in-flight hands to whoever uploads first. A memo that
|
||||
// revalidates across a frame boundary and skips the acquire never
|
||||
// re-uploads, so it keeps binding an offset the arena has since given
|
||||
// away: the draw reads whatever the next tenant put there. That is the
|
||||
// "random triangles" shape of this bug - the buffer nobody touched is the
|
||||
// one that renders wrong.
|
||||
//
|
||||
// The scene makes the next tenant deterministic instead of arbitrary: a
|
||||
// second streamed object of exactly the same size is uploaded and drawn
|
||||
// FIRST in every frame, so it lands on precisely the bytes the memo still
|
||||
// points at. A draw that renders the decoy's geometry instead of its own
|
||||
// is unmissable.
|
||||
|
||||
class StreamedArenaScenario : public ScenarioTest {
|
||||
protected:
|
||||
static constexpr int kQuietFrames = 2; // frames in which only the subject draws
|
||||
static constexpr int kChurnFrames = 8; // > frames-in-flight, so the ring wraps
|
||||
|
||||
struct StreamedObject {
|
||||
unsigned int vao = 0;
|
||||
unsigned int vbo = 0;
|
||||
unsigned int ebo = 0;
|
||||
};
|
||||
|
||||
void SetUp() override {
|
||||
ScenarioTest::SetUp();
|
||||
if (!Ready()) return;
|
||||
std::string error;
|
||||
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
|
||||
ASSERT_NE(m_program, 0u) << error;
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
if (!Ready()) return;
|
||||
for (StreamedObject* object : {&m_subject, &m_decoy}) {
|
||||
if (object->ebo != 0) glDeleteBuffers(1, &object->ebo);
|
||||
if (object->vbo != 0) glDeleteBuffers(1, &object->vbo);
|
||||
if (object->vao != 0) glDeleteVertexArrays(1, &object->vao);
|
||||
*object = StreamedObject{};
|
||||
}
|
||||
if (m_program != 0) glDeleteProgram(m_program);
|
||||
}
|
||||
|
||||
// GL_STREAM_DRAW is what puts a buffer on the transient arena
|
||||
// (ShouldUseTransientVertexIndexBuffer) - and what Minecraft uses for
|
||||
// exactly this kind of geometry.
|
||||
void BuildStreamedObject(StreamedObject& object, const std::vector<Vertex>& vertices,
|
||||
const GLuint (&indices)[kIndexCount]) {
|
||||
glGenVertexArrays(1, &object.vao);
|
||||
glBindVertexArray(object.vao);
|
||||
glGenBuffers(1, &object.vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, object.vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data(),
|
||||
GL_STREAM_DRAW);
|
||||
glGenBuffers(1, &object.ebo);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, object.ebo);
|
||||
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(indices)), indices, GL_STREAM_DRAW);
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(0));
|
||||
glEnableVertexAttribArray(1);
|
||||
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(8));
|
||||
glBindVertexArray(0);
|
||||
}
|
||||
|
||||
void Draw(const StreamedObject& object) {
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
glDisable(GL_BLEND);
|
||||
glUseProgram(m_program);
|
||||
glBindVertexArray(object.vao);
|
||||
glDrawElements(GL_TRIANGLES, kIndexCount, GL_UNSIGNED_INT, nullptr);
|
||||
glBindVertexArray(0);
|
||||
}
|
||||
|
||||
// Re-uploading the decoy is what forces it onto a fresh arena slice
|
||||
// this frame - i.e. what makes it the arena's next tenant.
|
||||
void RestreamDecoy(const std::vector<Vertex>& vertices, const GLuint (&indices)[kIndexCount]) {
|
||||
glBindVertexArray(m_decoy.vao);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, m_decoy.vbo);
|
||||
glBufferSubData(GL_ARRAY_BUFFER, 0, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_decoy.ebo);
|
||||
glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, GLsizeiptr(sizeof(indices)), indices);
|
||||
glBindVertexArray(0);
|
||||
}
|
||||
|
||||
unsigned int m_program = 0;
|
||||
StreamedObject m_subject;
|
||||
StreamedObject m_decoy;
|
||||
};
|
||||
|
||||
// Vertex data. Subject and decoy differ in geometry AND colour, so a
|
||||
// subject draw that reads the decoy's arena bytes paints the decoy's quad.
|
||||
TEST_F(StreamedArenaScenario, StreamedVertexDataSurvivesArenaRecycling) {
|
||||
const std::vector<Vertex> full = SceneVertices(/*leftQuadIsGreen=*/false);
|
||||
const std::vector<Vertex> subjectVertices(full.begin(), full.begin() + 4); // left, red
|
||||
const std::vector<Vertex> decoyVertices(full.begin() + 4, full.begin() + 8); // right, green
|
||||
ASSERT_EQ(subjectVertices.size(), decoyVertices.size()); // same arena footprint
|
||||
|
||||
BuildStreamedObject(m_subject, subjectVertices, kIndicesLeftQuad);
|
||||
BuildStreamedObject(m_decoy, decoyVertices, kIndicesLeftQuad);
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
|
||||
|
||||
// Quiet frames: the subject is the only thing uploading, so its data
|
||||
// sits at the head of the arena and its memo records that offset.
|
||||
for (int frame = 0; frame < kQuietFrames; ++frame) {
|
||||
BindDefaultFramebuffer();
|
||||
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
Draw(m_subject);
|
||||
Gl().EndFrame();
|
||||
}
|
||||
|
||||
// Churn frames: the decoy re-streams and draws first every frame. The
|
||||
// subject is never touched again - it must still render itself.
|
||||
for (int frame = 0; frame < kChurnFrames; ++frame) {
|
||||
BindDefaultFramebuffer();
|
||||
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
RestreamDecoy(decoyVertices, kIndicesLeftQuad);
|
||||
Draw(m_decoy);
|
||||
Draw(m_subject);
|
||||
const Image image = ReadPixels(Gl().Width(), Gl().Height());
|
||||
ExpectHalves(image, "red", "green",
|
||||
"churn frame " + std::to_string(frame) +
|
||||
": the untouched streamed vertex buffer rendered someone else's arena bytes");
|
||||
Gl().EndFrame();
|
||||
}
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
// Index data. Both objects carry the SAME eight vertices, so only the
|
||||
// element buffer can decide which half is drawn - this isolates the EBO
|
||||
// memo, which had its own copy of the cross-frame hole.
|
||||
//
|
||||
// COVERS: that an untouched streamed index buffer still renders its own
|
||||
// geometry after the arena it lives in has been recycled by another
|
||||
// object - the index-side statement of the invariant the vertex case
|
||||
// above actually catches.
|
||||
//
|
||||
// DOES NOT COVER: the EBO cross-frame memo. Instrumented against the
|
||||
// re-enabled buggy path this case reaches that branch ZERO times: the memo
|
||||
// is recorded only on the RESIDENT index path (UploadAndBindIndexBuffer
|
||||
// stores it in the arm after AcquireResidentSlice), and a streamed EBO
|
||||
// never gets there. So it passes on the buggy revision exactly as it does
|
||||
// on the fixed one, and it is not evidence about the fix.
|
||||
//
|
||||
// It stays because it is the tripwire for the change that would make the
|
||||
// EBO memo dangerous: memoise the streamed index path - the obvious next
|
||||
// step for the same optimisation - and the reach stops being zero and this
|
||||
// test fails on the first churn frame. See ResidentIndexScenario.cpp.
|
||||
TEST_F(StreamedArenaScenario, StreamedIndexDataSurvivesArenaRecycling) {
|
||||
const std::vector<Vertex> shared = SceneVertices(/*leftQuadIsGreen=*/false);
|
||||
|
||||
BuildStreamedObject(m_subject, shared, kIndicesLeftQuad); // draws the left, red quad
|
||||
BuildStreamedObject(m_decoy, shared, kIndicesRightQuad); // draws the right, green quad
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
|
||||
|
||||
for (int frame = 0; frame < kQuietFrames; ++frame) {
|
||||
BindDefaultFramebuffer();
|
||||
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
Draw(m_subject);
|
||||
Gl().EndFrame();
|
||||
}
|
||||
|
||||
for (int frame = 0; frame < kChurnFrames; ++frame) {
|
||||
BindDefaultFramebuffer();
|
||||
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
RestreamDecoy(shared, kIndicesRightQuad);
|
||||
Draw(m_decoy);
|
||||
Draw(m_subject);
|
||||
const Image image = ReadPixels(Gl().Width(), Gl().Height());
|
||||
ExpectHalves(image, "red", "green",
|
||||
"churn frame " + std::to_string(frame) +
|
||||
": the untouched streamed index buffer rendered someone else's arena bytes");
|
||||
Gl().EndFrame();
|
||||
}
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace MGITest
|
||||
@@ -0,0 +1,522 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/MultiDrawScenario.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// Scenario D - glMultiDrawElements(BaseVertex) against the draws it stands for.
|
||||
//
|
||||
// Neither entry point exists in OpenGL ES, so DirectGLES emulates both through a
|
||||
// ladder of tiers (MG_Backend/DirectGLES/MultiDraw.cpp): a native
|
||||
// glMultiDrawElementsBaseVertexEXT, synthesized indirect commands drawn one at a
|
||||
// time or in one batch, a per-sub-draw replay, a CPU rewrite of the index stream,
|
||||
// and a compute shader that flattens the whole batch into a single draw. They
|
||||
// share nothing but their contract, which is the one thing asserted here:
|
||||
//
|
||||
// a multi-draw must paint exactly what the unrolled single draws paint.
|
||||
//
|
||||
// The reference side never enters the emulation - it is a loop of
|
||||
// glDrawElementsBaseVertex / glDrawElements - so a tier cannot make itself look
|
||||
// right by breaking both sides the same way.
|
||||
//
|
||||
// The Minecraft retraces already cover the common shape (GL_UNSIGNED_INT indices
|
||||
// in a bound element array buffer, small base vertices, GL_TRIANGLES) on every
|
||||
// tier. What they contain none of, and what these cases are for, is the set of
|
||||
// shapes where a tier has to decline or compensate rather than replay:
|
||||
//
|
||||
// * narrow index types, where a rewritten stream has to widen (BYTE/SHORT);
|
||||
// * a base vertex past the index type's range, where folding it into the
|
||||
// indices at the source width silently wraps - GL adds base vertices at full
|
||||
// precision, so `ushort index 10 + baseVertex 70000` is vertex 70010 and not
|
||||
// vertex 4474;
|
||||
// * primitive restart, where a rewritten stream must carry the sentinel across
|
||||
// unrebased or the restart is lost and the strip welds shut;
|
||||
// * client-memory index arrays, which have no buffer for the indirect tiers to
|
||||
// address or for the compute tier to read;
|
||||
// * a strip mode, which the flattening tier must decline outright because
|
||||
// concatenation would weld one sub-draw's last primitive to the next
|
||||
// sub-draw's first.
|
||||
//
|
||||
// One process is one tier (MOBILEGL_ESPRYT_MULTIDRAW_MODE is read once at
|
||||
// startup), so a single run exercises whichever tier this driver resolved to.
|
||||
// Running the binary once per mode is what covers the ladder; each run is a
|
||||
// complete, self-contained proof for the tier it landed on.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glext.h>
|
||||
|
||||
namespace MGITest {
|
||||
namespace {
|
||||
|
||||
constexpr const char* kVertexSource = R"(#version 330 core
|
||||
layout(location = 0) in vec2 aPos;
|
||||
layout(location = 1) in vec3 aColor;
|
||||
out vec3 vColor;
|
||||
void main() {
|
||||
vColor = aColor;
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
constexpr const char* kFragmentSource = R"(#version 330 core
|
||||
in vec3 vColor;
|
||||
out vec4 oColor;
|
||||
void main() {
|
||||
oColor = vec4(vColor, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
struct Vertex {
|
||||
float x, y;
|
||||
float r, g, b;
|
||||
};
|
||||
|
||||
// Four column quads spanning the viewport left to right, in four colours,
|
||||
// so a sub-draw that lands in the wrong place, draws the wrong vertices or
|
||||
// does not draw at all changes the picture rather than hiding inside it.
|
||||
constexpr int kColumns = 4;
|
||||
|
||||
const Rgba8 kColumnColors[kColumns] = {
|
||||
{255, 0, 0, 255},
|
||||
{0, 255, 0, 255},
|
||||
{0, 0, 255, 255},
|
||||
{255, 255, 255, 255},
|
||||
};
|
||||
|
||||
// `padVertices` leading dummies force every sub-draw to need its own base
|
||||
// vertex: without one applied, a draw reads the padding and paints black.
|
||||
std::vector<Vertex> ColumnVertices(int padVertices) {
|
||||
std::vector<Vertex> vertices(static_cast<std::size_t>(padVertices), Vertex{0.0f, 0.0f, 0.0f, 0.0f, 0.0f});
|
||||
for (int column = 0; column < kColumns; ++column) {
|
||||
const float x0 = -1.0f + 2.0f * static_cast<float>(column) / kColumns;
|
||||
const float x1 = -1.0f + 2.0f * static_cast<float>(column + 1) / kColumns;
|
||||
const Rgba8 color = kColumnColors[column];
|
||||
const float r = color.r / 255.0f;
|
||||
const float g = color.g / 255.0f;
|
||||
const float b = color.b / 255.0f;
|
||||
vertices.push_back({x0, -1.0f, r, g, b});
|
||||
vertices.push_back({x1, -1.0f, r, g, b});
|
||||
vertices.push_back({x1, 1.0f, r, g, b});
|
||||
vertices.push_back({x0, 1.0f, r, g, b});
|
||||
}
|
||||
return vertices;
|
||||
}
|
||||
|
||||
// Every sub-draw uses the SAME six indices, 0..3 relative to its own quad;
|
||||
// only the base vertex tells the columns apart. That makes the base vertex
|
||||
// the load-bearing part of the batch.
|
||||
const std::uint32_t kQuadIndices[6] = {0, 1, 2, 0, 2, 3};
|
||||
|
||||
// One column, as a restart-separated pair of triangle strips. Two strips in
|
||||
// one sub-draw means the sentinel is genuinely interior: drop it and the two
|
||||
// halves weld into a single strip that paints across the gap between them.
|
||||
// Indices are relative to the sub-draw's own quad, like kQuadIndices.
|
||||
template <typename Index>
|
||||
std::vector<Index> RestartStripIndices(Index restartSentinel) {
|
||||
// 3,0,2,1 is the strip winding of the quad; splitting it around the
|
||||
// sentinel gives two degenerate-free halves that redraw the same area.
|
||||
return {Index{3}, Index{0}, Index{2}, restartSentinel, Index{0}, Index{2}, Index{1}};
|
||||
}
|
||||
|
||||
class MultiDrawScenario : public ScenarioTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
ScenarioTest::SetUp();
|
||||
if (!Ready()) return;
|
||||
std::string error;
|
||||
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
|
||||
ASSERT_NE(m_program, 0u) << error;
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "program setup left a GL error behind";
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
if (!Ready()) return;
|
||||
ReleaseBuffers();
|
||||
if (m_program != 0) glDeleteProgram(m_program);
|
||||
}
|
||||
|
||||
// VAO + VBO, and an EBO only when `indexBytes` is non-null: a null one
|
||||
// leaves GL_ELEMENT_ARRAY_BUFFER unbound so the sub-draws address client
|
||||
// memory, which is the shape that forces the buffer-reading tiers out.
|
||||
void BuildScene(int padVertices, const void* indexBytes, std::size_t indexByteCount) {
|
||||
ReleaseBuffers();
|
||||
const std::vector<Vertex> vertices = ColumnVertices(padVertices);
|
||||
|
||||
glGenVertexArrays(1, &m_vao);
|
||||
glBindVertexArray(m_vao);
|
||||
glGenBuffers(1, &m_vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(vertices.size() * sizeof(Vertex)),
|
||||
vertices.data(), GL_STATIC_DRAW);
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<const void*>(0));
|
||||
glEnableVertexAttribArray(1);
|
||||
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
|
||||
reinterpret_cast<const void*>(sizeof(float) * 2));
|
||||
|
||||
if (indexBytes != nullptr) {
|
||||
glGenBuffers(1, &m_ebo);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo);
|
||||
glBufferData(GL_ELEMENT_ARRAY_BUFFER, static_cast<GLsizeiptr>(indexByteCount), indexBytes,
|
||||
GL_STATIC_DRAW);
|
||||
}
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
|
||||
}
|
||||
|
||||
void ReleaseBuffers() {
|
||||
if (m_ebo != 0) glDeleteBuffers(1, &m_ebo);
|
||||
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
|
||||
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
|
||||
m_ebo = 0;
|
||||
m_vbo = 0;
|
||||
m_vao = 0;
|
||||
}
|
||||
|
||||
GLuint m_program = 0;
|
||||
GLuint m_vao = 0;
|
||||
GLuint m_vbo = 0;
|
||||
GLuint m_ebo = 0;
|
||||
};
|
||||
|
||||
// Runs `draw`, reads the default framebuffer back and returns the image.
|
||||
template <typename DrawFn>
|
||||
Image RenderPass(GLuint program, GLuint vao, DrawFn&& draw) {
|
||||
BindDefaultFramebuffer();
|
||||
glViewport(0, 0, HeadlessGL::Get().Width(), HeadlessGL::Get().Height());
|
||||
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
glUseProgram(program);
|
||||
glBindVertexArray(vao);
|
||||
draw();
|
||||
return ReadPixels(HeadlessGL::Get().Width(), HeadlessGL::Get().Height());
|
||||
}
|
||||
|
||||
// The whole point of the file: two renderings of the same geometry, one
|
||||
// through the multi-draw emulation and one through the single-draw entry
|
||||
// points it stands for, must be identical to the byte.
|
||||
void ExpectSameImage(const Image& multiDraw, const Image& unrolled, const std::string& what) {
|
||||
ASSERT_FALSE(multiDraw.Empty()) << what << ": multi-draw readback was empty";
|
||||
ASSERT_FALSE(unrolled.Empty()) << what << ": reference readback was empty";
|
||||
EXPECT_EQ(multiDraw, unrolled)
|
||||
<< what << ": glMultiDraw* painted something else than the draws it stands for ("
|
||||
<< multiDraw.ByteDiffCount(unrolled) << " bytes differ; multi-draw quadrants "
|
||||
<< multiDraw.QuadrantSignature() << ", unrolled quadrants " << unrolled.QuadrantSignature() << ")";
|
||||
// A pair of blank frames would satisfy the comparison above and prove
|
||||
// nothing at all - the failure mode a multi-draw path most often has is
|
||||
// drawing NOTHING (see the shipped glMultiDrawElementsBaseVertexEXT stub
|
||||
// that silently dropped every draw). Demand the columns really landed.
|
||||
EXPECT_NE(multiDraw.QuadrantSignature(), "black,black,black,black") << what << ": nothing was drawn at all";
|
||||
}
|
||||
|
||||
// ---- GL_UNSIGNED_INT indices in a buffer, per-sub-draw base vertices ----
|
||||
|
||||
TEST_F(MultiDrawScenario, BaseVertexBatchMatchesUnrolledDraws) {
|
||||
if (!Ready()) return;
|
||||
constexpr int kPad = 5; // odd, so nothing lines up by accident
|
||||
BuildScene(kPad, kQuadIndices, sizeof(kQuadIndices));
|
||||
|
||||
GLsizei counts[kColumns];
|
||||
const void* offsets[kColumns];
|
||||
GLint baseVertices[kColumns];
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
counts[i] = 6;
|
||||
offsets[i] = reinterpret_cast<const void*>(0);
|
||||
baseVertices[i] = kPad + i * 4;
|
||||
}
|
||||
|
||||
const Image batched = RenderPass(m_program, m_vao, [&] {
|
||||
glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_INT, offsets, kColumns, baseVertices);
|
||||
});
|
||||
const Image unrolled = RenderPass(m_program, m_vao, [&] {
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
glDrawElementsBaseVertex(GL_TRIANGLES, counts[i], GL_UNSIGNED_INT, offsets[i], baseVertices[i]);
|
||||
}
|
||||
});
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
ExpectSameImage(batched, unrolled, "GL_UNSIGNED_INT indices, per-sub-draw base vertices");
|
||||
}
|
||||
|
||||
// ---- glMultiDrawElements: no base vertices, distinct index offsets ----
|
||||
|
||||
TEST_F(MultiDrawScenario, PlainBatchMatchesUnrolledDraws) {
|
||||
if (!Ready()) return;
|
||||
// No padding and no base vertices: each sub-draw reaches its own column
|
||||
// through its index offset instead.
|
||||
std::vector<std::uint32_t> indices;
|
||||
for (int column = 0; column < kColumns; ++column) {
|
||||
for (const std::uint32_t index : kQuadIndices) {
|
||||
indices.push_back(index + static_cast<std::uint32_t>(column * 4));
|
||||
}
|
||||
}
|
||||
BuildScene(0, indices.data(), indices.size() * sizeof(std::uint32_t));
|
||||
|
||||
GLsizei counts[kColumns];
|
||||
const void* offsets[kColumns];
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
counts[i] = 6;
|
||||
offsets[i] = reinterpret_cast<const void*>(static_cast<std::uintptr_t>(i * 6 * sizeof(std::uint32_t)));
|
||||
}
|
||||
|
||||
const Image batched = RenderPass(m_program, m_vao, [&] {
|
||||
glMultiDrawElements(GL_TRIANGLES, counts, GL_UNSIGNED_INT, offsets, kColumns);
|
||||
});
|
||||
const Image unrolled = RenderPass(m_program, m_vao, [&] {
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
glDrawElements(GL_TRIANGLES, counts[i], GL_UNSIGNED_INT, offsets[i]);
|
||||
}
|
||||
});
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
ExpectSameImage(batched, unrolled, "glMultiDrawElements with no base vertices");
|
||||
}
|
||||
|
||||
// ---- narrow index types ----
|
||||
// A tier that rewrites the stream emits GL_UNSIGNED_INT whatever came in,
|
||||
// so these two say the widening reproduces the original draw exactly.
|
||||
|
||||
TEST_F(MultiDrawScenario, UnsignedShortBatchMatchesUnrolledDraws) {
|
||||
if (!Ready()) return;
|
||||
constexpr int kPad = 3;
|
||||
std::uint16_t indices[6];
|
||||
for (int i = 0; i < 6; ++i)
|
||||
indices[i] = static_cast<std::uint16_t>(kQuadIndices[i]);
|
||||
BuildScene(kPad, indices, sizeof(indices));
|
||||
|
||||
GLsizei counts[kColumns];
|
||||
const void* offsets[kColumns];
|
||||
GLint baseVertices[kColumns];
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
counts[i] = 6;
|
||||
offsets[i] = reinterpret_cast<const void*>(0);
|
||||
baseVertices[i] = kPad + i * 4;
|
||||
}
|
||||
|
||||
const Image batched = RenderPass(m_program, m_vao, [&] {
|
||||
glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_SHORT, offsets, kColumns, baseVertices);
|
||||
});
|
||||
const Image unrolled = RenderPass(m_program, m_vao, [&] {
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
glDrawElementsBaseVertex(GL_TRIANGLES, counts[i], GL_UNSIGNED_SHORT, offsets[i], baseVertices[i]);
|
||||
}
|
||||
});
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
ExpectSameImage(batched, unrolled, "GL_UNSIGNED_SHORT indices");
|
||||
}
|
||||
|
||||
TEST_F(MultiDrawScenario, UnsignedByteBatchMatchesUnrolledDraws) {
|
||||
if (!Ready()) return;
|
||||
constexpr int kPad = 3;
|
||||
std::uint8_t indices[6];
|
||||
for (int i = 0; i < 6; ++i)
|
||||
indices[i] = static_cast<std::uint8_t>(kQuadIndices[i]);
|
||||
// 24 bytes: a word multiple, which the compute tier needs of the source
|
||||
// buffer when the index type is narrower than a word.
|
||||
std::uint8_t padded[24] = {};
|
||||
for (int i = 0; i < 6; ++i)
|
||||
padded[i] = indices[i];
|
||||
BuildScene(kPad, padded, sizeof(padded));
|
||||
|
||||
GLsizei counts[kColumns];
|
||||
const void* offsets[kColumns];
|
||||
GLint baseVertices[kColumns];
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
counts[i] = 6;
|
||||
offsets[i] = reinterpret_cast<const void*>(0);
|
||||
baseVertices[i] = kPad + i * 4;
|
||||
}
|
||||
|
||||
const Image batched = RenderPass(m_program, m_vao, [&] {
|
||||
glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_BYTE, offsets, kColumns, baseVertices);
|
||||
});
|
||||
const Image unrolled = RenderPass(m_program, m_vao, [&] {
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
glDrawElementsBaseVertex(GL_TRIANGLES, counts[i], GL_UNSIGNED_BYTE, offsets[i], baseVertices[i]);
|
||||
}
|
||||
});
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
ExpectSameImage(batched, unrolled, "GL_UNSIGNED_BYTE indices");
|
||||
}
|
||||
|
||||
// ---- a base vertex the index type cannot spell ----
|
||||
// GL adds the base vertex at full precision, so folding it into a
|
||||
// GL_UNSIGNED_SHORT index stream at the source width wraps and addresses the
|
||||
// wrong vertex. The columns here start past 65535, which no ushort index can
|
||||
// reach on its own.
|
||||
|
||||
TEST_F(MultiDrawScenario, BaseVertexBeyondIndexTypeRangeMatchesUnrolledDraws) {
|
||||
if (!Ready()) return;
|
||||
constexpr int kPad = 70000; // > 0xFFFF
|
||||
std::uint16_t indices[6];
|
||||
for (int i = 0; i < 6; ++i)
|
||||
indices[i] = static_cast<std::uint16_t>(kQuadIndices[i]);
|
||||
BuildScene(kPad, indices, sizeof(indices));
|
||||
|
||||
GLsizei counts[kColumns];
|
||||
const void* offsets[kColumns];
|
||||
GLint baseVertices[kColumns];
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
counts[i] = 6;
|
||||
offsets[i] = reinterpret_cast<const void*>(0);
|
||||
baseVertices[i] = kPad + i * 4;
|
||||
}
|
||||
|
||||
const Image batched = RenderPass(m_program, m_vao, [&] {
|
||||
glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_SHORT, offsets, kColumns, baseVertices);
|
||||
});
|
||||
const Image unrolled = RenderPass(m_program, m_vao, [&] {
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
glDrawElementsBaseVertex(GL_TRIANGLES, counts[i], GL_UNSIGNED_SHORT, offsets[i], baseVertices[i]);
|
||||
}
|
||||
});
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
ExpectSameImage(batched, unrolled, "base vertex past the GL_UNSIGNED_SHORT range");
|
||||
}
|
||||
|
||||
// ---- client-memory index arrays ----
|
||||
// No element array buffer, so the indirect tiers have nothing to address and
|
||||
// the compute tier nothing to read; both must decline and hand the batch to
|
||||
// a tier that can replay it.
|
||||
|
||||
TEST_F(MultiDrawScenario, ClientSideIndicesBatchMatchesUnrolledDraws) {
|
||||
if (!Ready()) return;
|
||||
constexpr int kPad = 5;
|
||||
BuildScene(kPad, nullptr, 0);
|
||||
|
||||
GLsizei counts[kColumns];
|
||||
const void* offsets[kColumns];
|
||||
GLint baseVertices[kColumns];
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
counts[i] = 6;
|
||||
offsets[i] = kQuadIndices;
|
||||
baseVertices[i] = kPad + i * 4;
|
||||
}
|
||||
|
||||
const Image batched = RenderPass(m_program, m_vao, [&] {
|
||||
glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_INT, offsets, kColumns, baseVertices);
|
||||
});
|
||||
const Image unrolled = RenderPass(m_program, m_vao, [&] {
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
glDrawElementsBaseVertex(GL_TRIANGLES, counts[i], GL_UNSIGNED_INT, offsets[i], baseVertices[i]);
|
||||
}
|
||||
});
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
ExpectSameImage(batched, unrolled, "client-memory index arrays");
|
||||
}
|
||||
|
||||
// ---- primitive restart inside a strip ----
|
||||
// Two things at once: a strip mode, which the flattening tier must decline
|
||||
// because concatenation would weld sub-draws together, and a restart
|
||||
// sentinel, which any tier that rewrites indices must carry across without
|
||||
// adding the base vertex to it.
|
||||
|
||||
TEST_F(MultiDrawScenario, PrimitiveRestartStripBatchMatchesUnrolledDraws) {
|
||||
if (!Ready()) return;
|
||||
constexpr int kPad = 5;
|
||||
const std::vector<std::uint32_t> indices = RestartStripIndices<std::uint32_t>(0xFFFFFFFFu);
|
||||
BuildScene(kPad, indices.data(), indices.size() * sizeof(std::uint32_t));
|
||||
|
||||
GLsizei counts[kColumns];
|
||||
const void* offsets[kColumns];
|
||||
GLint baseVertices[kColumns];
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
counts[i] = static_cast<GLsizei>(indices.size());
|
||||
offsets[i] = reinterpret_cast<const void*>(0);
|
||||
baseVertices[i] = kPad + i * 4;
|
||||
}
|
||||
|
||||
glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
|
||||
const Image batched = RenderPass(m_program, m_vao, [&] {
|
||||
glMultiDrawElementsBaseVertex(GL_TRIANGLE_STRIP, counts, GL_UNSIGNED_INT, offsets, kColumns,
|
||||
baseVertices);
|
||||
});
|
||||
const Image unrolled = RenderPass(m_program, m_vao, [&] {
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
glDrawElementsBaseVertex(GL_TRIANGLE_STRIP, counts[i], GL_UNSIGNED_INT, offsets[i],
|
||||
baseVertices[i]);
|
||||
}
|
||||
});
|
||||
glDisable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
ExpectSameImage(batched, unrolled, "GL_TRIANGLE_STRIP with primitive restart");
|
||||
}
|
||||
|
||||
// Same, with GL_UNSIGNED_SHORT: the sentinel a rewritten stream has to
|
||||
// recognise is the index TYPE's all-ones value, not the rewritten stream's.
|
||||
TEST_F(MultiDrawScenario, PrimitiveRestartUnsignedShortBatchMatchesUnrolledDraws) {
|
||||
if (!Ready()) return;
|
||||
constexpr int kPad = 5;
|
||||
const std::vector<std::uint16_t> indices = RestartStripIndices<std::uint16_t>(0xFFFFu);
|
||||
BuildScene(kPad, indices.data(), indices.size() * sizeof(std::uint16_t));
|
||||
|
||||
GLsizei counts[kColumns];
|
||||
const void* offsets[kColumns];
|
||||
GLint baseVertices[kColumns];
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
counts[i] = static_cast<GLsizei>(indices.size());
|
||||
offsets[i] = reinterpret_cast<const void*>(0);
|
||||
baseVertices[i] = kPad + i * 4;
|
||||
}
|
||||
|
||||
glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
|
||||
const Image batched = RenderPass(m_program, m_vao, [&] {
|
||||
glMultiDrawElementsBaseVertex(GL_TRIANGLE_STRIP, counts, GL_UNSIGNED_SHORT, offsets, kColumns,
|
||||
baseVertices);
|
||||
});
|
||||
const Image unrolled = RenderPass(m_program, m_vao, [&] {
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
glDrawElementsBaseVertex(GL_TRIANGLE_STRIP, counts[i], GL_UNSIGNED_SHORT, offsets[i],
|
||||
baseVertices[i]);
|
||||
}
|
||||
});
|
||||
glDisable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
ExpectSameImage(batched, unrolled, "GL_TRIANGLE_STRIP with GL_UNSIGNED_SHORT primitive restart");
|
||||
}
|
||||
|
||||
// ---- a batch with holes ----
|
||||
// Zero-count sub-draws draw nothing. The flattening tier's binary search
|
||||
// finds a sub-draw by prefix sum, and a zero-count entry repeats the
|
||||
// previous sum - so a search that resolves ties the other way would attribute
|
||||
// indices to the empty draw and paint the wrong column.
|
||||
|
||||
TEST_F(MultiDrawScenario, ZeroCountSubDrawsMatchUnrolledDraws) {
|
||||
if (!Ready()) return;
|
||||
constexpr int kPad = 5;
|
||||
BuildScene(kPad, kQuadIndices, sizeof(kQuadIndices));
|
||||
|
||||
GLsizei counts[kColumns];
|
||||
const void* offsets[kColumns];
|
||||
GLint baseVertices[kColumns];
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
// Columns 1 and 2 are skipped, leaving the outer two painted.
|
||||
counts[i] = (i == 1 || i == 2) ? 0 : 6;
|
||||
offsets[i] = reinterpret_cast<const void*>(0);
|
||||
baseVertices[i] = kPad + i * 4;
|
||||
}
|
||||
|
||||
const Image batched = RenderPass(m_program, m_vao, [&] {
|
||||
glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_INT, offsets, kColumns, baseVertices);
|
||||
});
|
||||
const Image unrolled = RenderPass(m_program, m_vao, [&] {
|
||||
for (int i = 0; i < kColumns; ++i) {
|
||||
if (counts[i] == 0) continue;
|
||||
glDrawElementsBaseVertex(GL_TRIANGLES, counts[i], GL_UNSIGNED_INT, offsets[i], baseVertices[i]);
|
||||
}
|
||||
});
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
ExpectSameImage(batched, unrolled, "a batch with zero-count sub-draws");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace MGITest
|
||||
@@ -0,0 +1,381 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/OrientationScenario.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// Scenario A - "the frame came out upside down".
|
||||
//
|
||||
// The shipped bug (DirectVulkan, GetBaseTransformFlagsRaw): the shader
|
||||
// transform flags - the Y-flip and surface-rotation bits that apply ONLY when
|
||||
// the bound draw framebuffer is the default one - were memoized on the
|
||||
// swapchain pre-transform alone. The is-default-framebuffer input was not part
|
||||
// of the key, so whichever kind of pass evaluated the memo first decided the
|
||||
// orientation of every pass after it. In a real frame that meant: after any
|
||||
// render-to-texture pass, the next default-framebuffer pass inherited the FBO's
|
||||
// unflipped flags and the whole frame rendered upside down (retrace SSIM 0.052,
|
||||
// deterministic; flickering clouds on device).
|
||||
//
|
||||
// What pins it: a pattern asymmetric in BOTH axes - four quadrants, coloured
|
||||
//
|
||||
// top-left RED | WHITE top-right
|
||||
// bottom-left BLUE | GREEN bottom-right
|
||||
//
|
||||
// - drawn to a target, read back with glReadPixels, and reduced to the four
|
||||
// quadrant-centre colours in the fixed order bottom-left, bottom-right,
|
||||
// top-left, top-right.
|
||||
//
|
||||
// Four quadrants rather than the three horizontal stripes this scenario used to
|
||||
// draw, because stripes only pin ONE axis. Stripes read down the centre line
|
||||
// are unchanged by an X flip, by a transpose, and by a 180 rotation composed
|
||||
// with a Y flip: all three of those bugs would have rendered a green stripe
|
||||
// between a blue one and a red one and passed. Every one of the eight
|
||||
// symmetries of the square now produces a different string:
|
||||
//
|
||||
// identity blue,green,red,white <- correct
|
||||
// Y flip red,white,blue,green <- the shipped bug
|
||||
// X flip green,blue,white,red
|
||||
// 180 rotation white,red,green,blue
|
||||
// transpose blue,red,green,white
|
||||
// anti-transpose white,green,red,blue
|
||||
// rotate 90 CCW red,blue,white,green
|
||||
// rotate 90 CW green,white,blue,red
|
||||
//
|
||||
// The assertions then go further than the signature: every quadrant is checked
|
||||
// pixel by pixel over its whole area (RegionIsMostly), so a partial or torn
|
||||
// draw cannot pass by having the four sampled centres come out right.
|
||||
//
|
||||
// Both orderings are covered, because the memo is poisoned by whichever pass
|
||||
// runs first and these tests share one process:
|
||||
// - default -> FBO -> default (the FBO pass inherits the default's flip)
|
||||
// - FBO -> default (the shipped symptom: the default pass
|
||||
// inherits the FBO's lack of flip)
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#undef GL_GLEXT_PROTOTYPES
|
||||
|
||||
namespace MGITest {
|
||||
namespace {
|
||||
|
||||
constexpr const char* kVertexSource = R"(#version 330 core
|
||||
in vec2 aPos;
|
||||
in vec3 aColor;
|
||||
out vec3 vColor;
|
||||
void main() {
|
||||
vColor = aColor;
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
constexpr const char* kFragmentSource = R"(#version 330 core
|
||||
in vec3 vColor;
|
||||
out vec4 oColor;
|
||||
void main() {
|
||||
oColor = vec4(vColor, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
// The correctly-oriented answer, in glReadPixels order (row 0 is the
|
||||
// bottom row) and in QuadrantSignature's order: bottom-left, bottom-right,
|
||||
// top-left, top-right. Plain GL semantics; holds for every framebuffer,
|
||||
// default or not.
|
||||
constexpr const char* kUprightSignature = "blue,green,red,white";
|
||||
|
||||
// How far inside each quadrant the whole-region checks start. The quadrant
|
||||
// seam sits on a pixel boundary, so one pixel of margin is enough to make
|
||||
// "every single pixel" an achievable (and therefore useful) demand.
|
||||
constexpr int kQuadrantInset = 2;
|
||||
|
||||
struct Vertex {
|
||||
float x, y;
|
||||
float r, g, b;
|
||||
};
|
||||
|
||||
void AppendQuad(std::vector<Vertex>& out, float x0, float x1, float y0, float y1, float r, float g, float b) {
|
||||
const Vertex bl{x0, y0, r, g, b};
|
||||
const Vertex br{x1, y0, r, g, b};
|
||||
const Vertex tr{x1, y1, r, g, b};
|
||||
const Vertex tl{x0, y1, r, g, b};
|
||||
out.insert(out.end(), {bl, br, tr, bl, tr, tl});
|
||||
}
|
||||
|
||||
std::vector<Vertex> QuadrantGeometry() {
|
||||
std::vector<Vertex> vertices;
|
||||
vertices.reserve(24);
|
||||
AppendQuad(vertices, -1.0f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f); // bottom-left: blue
|
||||
AppendQuad(vertices, 0.0f, 1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f); // bottom-right: green
|
||||
AppendQuad(vertices, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f); // top-left: red
|
||||
AppendQuad(vertices, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f); // top-right: white
|
||||
return vertices;
|
||||
}
|
||||
|
||||
class OrientationScenario : public ScenarioTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
ScenarioTest::SetUp();
|
||||
if (!Ready()) return;
|
||||
|
||||
std::string error;
|
||||
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
|
||||
ASSERT_NE(m_program, 0u) << error;
|
||||
|
||||
const std::vector<Vertex> vertices = QuadrantGeometry();
|
||||
m_vertexCount = static_cast<int>(vertices.size());
|
||||
glGenVertexArrays(1, &m_vao);
|
||||
glBindVertexArray(m_vao);
|
||||
glGenBuffers(1, &m_vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data(),
|
||||
GL_STATIC_DRAW);
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(0));
|
||||
glEnableVertexAttribArray(1);
|
||||
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(8));
|
||||
glBindVertexArray(0);
|
||||
|
||||
m_offscreen = MakeColorFbo(Gl().Width(), Gl().Height());
|
||||
ASSERT_NE(m_offscreen.fbo, 0u) << "offscreen FBO is not framebuffer-complete";
|
||||
|
||||
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "setup left a GL error behind";
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
if (!Ready()) return;
|
||||
DestroyColorFbo(m_offscreen);
|
||||
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
|
||||
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
|
||||
if (m_program != 0) glDeleteProgram(m_program);
|
||||
}
|
||||
|
||||
void DrawQuadrants() {
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
glDisable(GL_BLEND);
|
||||
glUseProgram(m_program);
|
||||
glBindVertexArray(m_vao);
|
||||
glDrawArrays(GL_TRIANGLES, 0, m_vertexCount);
|
||||
glBindVertexArray(0);
|
||||
}
|
||||
|
||||
// One pass to the default (presentable) framebuffer.
|
||||
Image DefaultFramebufferPass() {
|
||||
BindDefaultFramebuffer();
|
||||
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
DrawQuadrants();
|
||||
return ReadPixels(Gl().Width(), Gl().Height());
|
||||
}
|
||||
|
||||
// One render-to-texture pass. Real frames do this constantly
|
||||
// (shadow maps, post-processing, Minecraft's main render target).
|
||||
Image OffscreenPass() {
|
||||
BindFbo(m_offscreen);
|
||||
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
DrawQuadrants();
|
||||
return ReadPixels(m_offscreen.width, m_offscreen.height);
|
||||
}
|
||||
|
||||
// The signature says WHICH transform went wrong; this says the whole
|
||||
// image is right, not merely its four sampled centres.
|
||||
void ExpectUprightQuadrants(const Image& image, const std::string& when) {
|
||||
const int w = image.Width();
|
||||
const int h = image.Height();
|
||||
const int inset = kQuadrantInset;
|
||||
EXPECT_TRUE(RegionIsMostly(image, inset, w / 2 - inset, inset, h / 2 - inset, "blue", 0.0, when));
|
||||
EXPECT_TRUE(RegionIsMostly(image, w / 2 + inset, w - inset, inset, h / 2 - inset, "green", 0.0, when));
|
||||
EXPECT_TRUE(RegionIsMostly(image, inset, w / 2 - inset, h / 2 + inset, h - inset, "red", 0.0, when));
|
||||
EXPECT_TRUE(RegionIsMostly(image, w / 2 + inset, w - inset, h / 2 + inset, h - inset, "white", 0.0,
|
||||
when));
|
||||
}
|
||||
|
||||
unsigned int m_program = 0;
|
||||
unsigned int m_vao = 0;
|
||||
unsigned int m_vbo = 0;
|
||||
int m_vertexCount = 0;
|
||||
ColorFbo m_offscreen;
|
||||
};
|
||||
|
||||
// The plain statement of GL semantics that everything else leans on: an
|
||||
// FBO pass is never flipped.
|
||||
TEST_F(OrientationScenario, OffscreenPassRendersUpright) {
|
||||
const Image offscreen = OffscreenPass();
|
||||
EXPECT_EQ(offscreen.QuadrantSignature(), kUprightSignature)
|
||||
<< "a render-to-texture pass must render unflipped";
|
||||
ExpectUprightQuadrants(offscreen, "render-to-texture pass");
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
// The same for the default framebuffer: whatever the backend does with
|
||||
// the swapchain internally, glReadPixels owes the caller GL orientation.
|
||||
TEST_F(OrientationScenario, DefaultFramebufferPassRendersUpright) {
|
||||
const Image presented = DefaultFramebufferPass();
|
||||
EXPECT_EQ(presented.QuadrantSignature(), kUprightSignature)
|
||||
<< "a default-framebuffer pass must read back in GL orientation";
|
||||
ExpectUprightQuadrants(presented, "default-framebuffer pass");
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
// Scenario A proper: default -> FBO -> default in one frame. The third
|
||||
// pass must be pixel-identical to the first; the FBO pass in between
|
||||
// must not have moved anything.
|
||||
TEST_F(OrientationScenario, DefaultFramebufferSurvivesAnOffscreenPass) {
|
||||
const Image before = DefaultFramebufferPass();
|
||||
const Image offscreen = OffscreenPass();
|
||||
const Image after = DefaultFramebufferPass();
|
||||
|
||||
EXPECT_EQ(before.QuadrantSignature(), kUprightSignature)
|
||||
<< "first default-framebuffer pass is already misoriented";
|
||||
EXPECT_EQ(offscreen.QuadrantSignature(), kUprightSignature)
|
||||
<< "the render-to-texture pass in the middle rendered flipped - the "
|
||||
"default framebuffer's transform flags leaked into it";
|
||||
EXPECT_EQ(after.QuadrantSignature(), kUprightSignature)
|
||||
<< "the default-framebuffer pass AFTER a render-to-texture pass is "
|
||||
"misoriented - it inherited the FBO's transform flags";
|
||||
ExpectUprightQuadrants(after, "default-framebuffer pass after a render-to-texture pass");
|
||||
EXPECT_TRUE(after == before) << "the third pass differs from the first in " << after.ByteDiffCount(before)
|
||||
<< " bytes; first=" << before.QuadrantSignature()
|
||||
<< " third=" << after.QuadrantSignature();
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
// The shipped symptom, in its shipped order: an FBO pass, then the
|
||||
// default framebuffer. This is the one that flipped whole Minecraft
|
||||
// frames.
|
||||
TEST_F(OrientationScenario, DefaultFramebufferAfterOffscreenIsNotFlipped) {
|
||||
const Image offscreen = OffscreenPass();
|
||||
const Image presented = DefaultFramebufferPass();
|
||||
|
||||
EXPECT_EQ(offscreen.QuadrantSignature(), kUprightSignature)
|
||||
<< "render-to-texture pass rendered flipped";
|
||||
EXPECT_EQ(presented.QuadrantSignature(), kUprightSignature)
|
||||
<< "the default-framebuffer pass that follows a render-to-texture pass "
|
||||
"rendered upside down";
|
||||
ExpectUprightQuadrants(presented, "default-framebuffer pass following a render-to-texture pass");
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
// And across a real frame boundary, which is how a game actually
|
||||
// alternates the two kinds of pass.
|
||||
TEST_F(OrientationScenario, OrientationIsStableAcrossFrames) {
|
||||
const Image firstFrame = DefaultFramebufferPass();
|
||||
ExpectUprightQuadrants(firstFrame, "frame 0");
|
||||
Gl().EndFrame();
|
||||
|
||||
for (int frame = 0; frame < 3; ++frame) {
|
||||
const Image offscreen = OffscreenPass();
|
||||
EXPECT_EQ(offscreen.QuadrantSignature(), kUprightSignature)
|
||||
<< "frame " << frame + 1 << "'s render-to-texture pass is misoriented";
|
||||
const Image presented = DefaultFramebufferPass();
|
||||
EXPECT_EQ(presented.QuadrantSignature(), kUprightSignature)
|
||||
<< "frame " << frame + 1 << " of the alternating FBO/default loop is misoriented";
|
||||
ExpectUprightQuadrants(presented, "frame " + std::to_string(frame + 1));
|
||||
EXPECT_TRUE(presented == firstFrame) << "frame " << frame + 1 << " differs from frame 0 in "
|
||||
<< presented.ByteDiffCount(firstFrame) << " bytes";
|
||||
Gl().EndFrame();
|
||||
}
|
||||
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
||||
}
|
||||
|
||||
// A standing self-test of the signature, not of MobileGL: it proves the
|
||||
// four-quadrant reduction really does separate all eight symmetries of
|
||||
// the square, so a future "simplify the pattern" change cannot quietly
|
||||
// reintroduce the blind spot the three-stripe version had (X flip,
|
||||
// transpose and 180+Y-flip all left the stripe signature alone).
|
||||
TEST_F(OrientationScenario, QuadrantSignatureSeparatesEverySquareSymmetry) {
|
||||
const Image upright = OffscreenPass();
|
||||
ASSERT_EQ(upright.QuadrantSignature(), kUprightSignature) << "the reference image is not upright";
|
||||
|
||||
const int w = upright.Width();
|
||||
const int h = upright.Height();
|
||||
// Transposes are expressed on the largest centred square the readback
|
||||
// contains, which is enough for the four quadrant centres to move.
|
||||
const int side = std::min(w, h);
|
||||
const int ox = (w - side) / 2;
|
||||
const int oy = (h - side) / 2;
|
||||
|
||||
struct Symmetry {
|
||||
const char* name;
|
||||
const char* expected;
|
||||
int (*mapX)(int x, int y, int w, int h);
|
||||
int (*mapY)(int x, int y, int w, int h);
|
||||
};
|
||||
const Symmetry symmetries[] = {
|
||||
{"Y flip", "red,white,blue,green", [](int x, int, int, int) { return x; },
|
||||
[](int, int y, int, int hh) { return hh - 1 - y; }},
|
||||
{"X flip", "green,blue,white,red", [](int x, int, int ww, int) { return ww - 1 - x; },
|
||||
[](int, int y, int, int) { return y; }},
|
||||
{"180 rotation", "white,red,green,blue", [](int x, int, int ww, int) { return ww - 1 - x; },
|
||||
[](int, int y, int, int hh) { return hh - 1 - y; }},
|
||||
};
|
||||
|
||||
for (const Symmetry& symmetry : symmetries) {
|
||||
Image transformed(w, h);
|
||||
for (int y = 0; y < h; ++y) {
|
||||
for (int x = 0; x < w; ++x) {
|
||||
const Rgba8 source = upright.At(symmetry.mapX(x, y, w, h), symmetry.mapY(x, y, w, h));
|
||||
std::uint8_t* out = transformed.Data() + (std::size_t(y) * w + x) * 4;
|
||||
out[0] = source.r;
|
||||
out[1] = source.g;
|
||||
out[2] = source.b;
|
||||
out[3] = source.a;
|
||||
}
|
||||
}
|
||||
EXPECT_EQ(transformed.QuadrantSignature(), symmetry.expected)
|
||||
<< symmetry.name << " must produce its own signature, or the pattern cannot see it";
|
||||
EXPECT_NE(transformed.QuadrantSignature(), kUprightSignature)
|
||||
<< symmetry.name << " is INDISTINGUISHABLE from an upright frame - the pattern is too symmetric";
|
||||
}
|
||||
|
||||
// The four symmetries that move the axes into each other. They only
|
||||
// make sense on a square, so they run on the largest centred one.
|
||||
struct SquareSymmetry {
|
||||
const char* name;
|
||||
const char* expected;
|
||||
int (*sourceX)(int x, int y, int side);
|
||||
int (*sourceY)(int x, int y, int side);
|
||||
};
|
||||
const SquareSymmetry squareSymmetries[] = {
|
||||
{"transpose", "blue,red,green,white", [](int, int y, int) { return y; },
|
||||
[](int x, int, int) { return x; }},
|
||||
{"anti-transpose", "white,green,red,blue", [](int, int y, int s) { return s - 1 - y; },
|
||||
[](int x, int, int s) { return s - 1 - x; }},
|
||||
{"rotate 90 CCW", "red,blue,white,green", [](int, int y, int) { return y; },
|
||||
[](int x, int, int s) { return s - 1 - x; }},
|
||||
{"rotate 90 CW", "green,white,blue,red", [](int, int y, int s) { return s - 1 - y; },
|
||||
[](int x, int, int) { return x; }},
|
||||
};
|
||||
for (const SquareSymmetry& symmetry : squareSymmetries) {
|
||||
Image square(side, side);
|
||||
for (int y = 0; y < side; ++y) {
|
||||
for (int x = 0; x < side; ++x) {
|
||||
const Rgba8 source =
|
||||
upright.At(ox + symmetry.sourceX(x, y, side), oy + symmetry.sourceY(x, y, side));
|
||||
std::uint8_t* out = square.Data() + (std::size_t(y) * side + x) * 4;
|
||||
out[0] = source.r;
|
||||
out[1] = source.g;
|
||||
out[2] = source.b;
|
||||
out[3] = source.a;
|
||||
}
|
||||
}
|
||||
EXPECT_EQ(square.QuadrantSignature(), symmetry.expected)
|
||||
<< symmetry.name << " must produce its own signature, or the pattern cannot see it";
|
||||
EXPECT_NE(square.QuadrantSignature(), kUprightSignature)
|
||||
<< symmetry.name << " is INDISTINGUISHABLE from an upright frame";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace MGITest
|
||||
@@ -0,0 +1,383 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ResidentIndexScenario.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// Scenario C - RESIDENT index buffers across frame boundaries.
|
||||
//
|
||||
// WHAT THIS FILE DOES AND DOES NOT COVER, stated plainly because the answer is
|
||||
// not the one it was written to find.
|
||||
//
|
||||
// The shipped fix (d7976326) removed cross-frame slice trust from TWO memos: the
|
||||
// vertex-binding one and the EBO one. StreamedArenaScenario pins the vertex
|
||||
// half - re-enable that half alone and it fails. Nothing pinned the EBO half,
|
||||
// and these cases are the result of trying to build something that does.
|
||||
//
|
||||
// The EBO memo lives in UploadAndBindIndexBuffer and is recorded ONLY on the
|
||||
// resident branch, keyed on (BufferObject*, VkBufferResource::sliceEpoch,
|
||||
// frame serial). To fail with only the EBO revalidation re-enabled, a scenario
|
||||
// needs a RESIDENT index buffer whose recorded slice stops describing the right
|
||||
// bytes while the pointer and the epoch still match. Every case below is an
|
||||
// attempt at that, run against the re-enabled buggy path with the branch
|
||||
// instrumented to count reaches, acceptances, and - critically - what the
|
||||
// skipped AcquireResidentSlice WOULD have done. The measurement, over this file
|
||||
// plus every other scenario in the module:
|
||||
//
|
||||
// reached=89 accepted=81 sliceMoved=0 bytesChanged=0 epochBumped=0
|
||||
//
|
||||
// The buggy branch is entered 89 times and serves its recorded slice 81 times,
|
||||
// and in NOT ONE of those 81 would the acquire have moved the slice, changed a
|
||||
// byte of it, or bumped the epoch. The skipped work was a no-op every time.
|
||||
//
|
||||
// That is not luck, it is the shape of the code. A resident slice is
|
||||
// `resource->buffer.GetSlice(0, size)` of a dedicated VkBuffer, so it can only
|
||||
// move when CreateResidentStorage mints new storage - which bumps the epoch. Its
|
||||
// bytes can only change through Respecify / SubData / FlushMappedRange - each of
|
||||
// which bumps the epoch as its first act - or through
|
||||
// BufferObject::SyncPersistentMappedRange, which the acquire calls and the memo
|
||||
// skips. That last one is the real escape, and it is dead here: it early-outs
|
||||
// when the backend has adopted the map into coherent GPU storage, and
|
||||
// AcquirePersistentMap only declines when a host-visible coherent allocation
|
||||
// FAILS. Instrumented across the whole module: 50 persistent coherent write
|
||||
// maps, 50 adopted, 0 dispatches. A 96 MiB EBO did not change that either.
|
||||
//
|
||||
// So on DirectVulkan as it stands, the EBO half of the fix is not reachable from
|
||||
// a GL-level test - not because the guard is sound in principle (it is the same
|
||||
// unsound idea the vertex half shipped corruption with) but because the two
|
||||
// mechanisms that made the vertex half observable are both absent for indices:
|
||||
//
|
||||
// 1. ARENA RELOCATION. The vertex memo records STREAMED slices too, and a
|
||||
// streamed slice moves to a new arena block every frame BY DESIGN - the
|
||||
// epoch that catches it is bumped inside the very acquire the memo skips.
|
||||
// That is what StreamedVertexDataSurvivesArenaRecycling exploits. The index
|
||||
// memo is never recorded on the streamed branch, so no index memo ever
|
||||
// names an arena offset. Measured: StreamedIndexDataSurvivesArenaRecycling
|
||||
// reaches the branch 0 times, and so does PromotedDynamicEbo below (a
|
||||
// promoted DYNAMIC_DRAW buffer is SERVED by AcquireResidentSlice but still
|
||||
// ROUTED as streamed, so it is not memoised either).
|
||||
// 2. HOST-MAP SYNC. Dead, as above.
|
||||
//
|
||||
// These cases therefore stay as what they honestly are: end-to-end regression
|
||||
// tests for resident index-buffer freshness across frame boundaries, and the
|
||||
// standing tripwire for change (1). The moment anyone memoises the streamed or
|
||||
// promoted index path - the natural next step for the same optimisation - these
|
||||
// stop being redundant and start failing. Each case says below what it covers.
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#undef GL_GLEXT_PROTOTYPES
|
||||
|
||||
namespace MGITest {
|
||||
namespace {
|
||||
|
||||
constexpr const char* kVS = R"(#version 330 core
|
||||
in vec2 aPos;
|
||||
in vec3 aColor;
|
||||
out vec3 vColor;
|
||||
void main() { vColor = aColor; gl_Position = vec4(aPos, 0.0, 1.0); }
|
||||
)";
|
||||
constexpr const char* kFS = R"(#version 330 core
|
||||
in vec3 vColor;
|
||||
out vec4 oColor;
|
||||
void main() { oColor = vec4(vColor, 1.0); }
|
||||
)";
|
||||
|
||||
struct V {
|
||||
float x, y, r, g, b;
|
||||
};
|
||||
constexpr int kIdx = 6;
|
||||
const GLuint kLeft[kIdx] = {0, 1, 2, 0, 2, 3};
|
||||
const GLuint kRight[kIdx] = {4, 5, 6, 4, 6, 7};
|
||||
|
||||
std::vector<V> Scene() {
|
||||
return {{-1, -1, 1, 0, 0}, {0, -1, 1, 0, 0}, {0, 1, 1, 0, 0}, {-1, 1, 1, 0, 0},
|
||||
{0, -1, 0, 1, 0}, {1, -1, 0, 1, 0}, {1, 1, 0, 1, 0}, {0, 1, 0, 1, 0}};
|
||||
}
|
||||
|
||||
class ResidentIndexScenario : public ScenarioTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
ScenarioTest::SetUp();
|
||||
if (!Ready()) return;
|
||||
std::string err;
|
||||
m_program = CompileProgram(kVS, kFS, &err);
|
||||
ASSERT_NE(m_program, 0u) << err;
|
||||
}
|
||||
void TearDown() override {
|
||||
if (!Ready()) return;
|
||||
if (m_program != 0) glDeleteProgram(m_program);
|
||||
}
|
||||
|
||||
// A VAO whose VBO is STATIC_DRAW (so it resolves resident and the
|
||||
// vertex memo is recorded) and whose EBO is `eboName`.
|
||||
unsigned int MakeVao(unsigned int vbo, unsigned int ebo) {
|
||||
unsigned int vao = 0;
|
||||
glGenVertexArrays(1, &vao);
|
||||
glBindVertexArray(vao);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, vbo);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast<void*>(0));
|
||||
glEnableVertexAttribArray(1);
|
||||
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast<void*>(8));
|
||||
glBindVertexArray(0);
|
||||
return vao;
|
||||
}
|
||||
|
||||
unsigned int MakeStaticVbo() {
|
||||
const std::vector<V> vertices = Scene();
|
||||
unsigned int vbo = 0;
|
||||
glGenBuffers(1, &vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(V)), vertices.data(),
|
||||
GL_STATIC_DRAW);
|
||||
return vbo;
|
||||
}
|
||||
|
||||
void Draw(unsigned int vao) {
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
glDisable(GL_BLEND);
|
||||
glUseProgram(m_program);
|
||||
glBindVertexArray(vao);
|
||||
glDrawElements(GL_TRIANGLES, kIdx, GL_UNSIGNED_INT, nullptr);
|
||||
glBindVertexArray(0);
|
||||
}
|
||||
void Begin() {
|
||||
BindDefaultFramebuffer();
|
||||
ClearTo(0, 0, 0, 1);
|
||||
}
|
||||
Image Read() { return ReadPixels(Gl().Width(), Gl().Height()); }
|
||||
void Halves(const Image& image, const char* left, const char* right, const std::string& when) {
|
||||
const int w = image.Width(), h = image.Height();
|
||||
EXPECT_TRUE(RegionIsMostly(image, 2, w / 2 - 2, 2, h - 2, left, 0.0, when + " [left]"));
|
||||
EXPECT_TRUE(RegionIsMostly(image, w / 2 + 2, w - 2, 2, h - 2, right, 0.0, when + " [right]"));
|
||||
}
|
||||
|
||||
unsigned int m_program = 0;
|
||||
};
|
||||
|
||||
// A: a coherent persistent EBO rewritten on EVERY frame, with no GL call
|
||||
// between the write and the draw. This is the only shape in which an
|
||||
// application changes index data with nothing for the backend to notice.
|
||||
//
|
||||
// COVERS: the coherent-persistent index contract end to end.
|
||||
// DOES NOT COVER: the EBO memo. Instrumented it reaches the cross-frame
|
||||
// branch 11 times and is served its recorded slice all 11 - but the
|
||||
// backend adopted the map into that same storage, so the "stale" slice IS
|
||||
// where the application's writes landed. It would only discriminate on a
|
||||
// stack where AcquirePersistentMap declines (see the file header). A
|
||||
// 96 MiB variant was tried to force that and did not: it cost 40s and
|
||||
// measured the same zero, so it is not kept.
|
||||
TEST_F(ResidentIndexScenario, PersistentCoherentEboWrittenEveryFrame) {
|
||||
const unsigned int vbo = MakeStaticVbo();
|
||||
unsigned int ebo = 0;
|
||||
glGenBuffers(1, &ebo);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
|
||||
const GLbitfield storageFlags =
|
||||
GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT | GL_DYNAMIC_STORAGE_BIT;
|
||||
glBufferStorage(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kLeft)), kLeft, storageFlags);
|
||||
if (FirstGLError() != GL_NO_ERROR) GTEST_SKIP() << "no immutable storage";
|
||||
auto* map = static_cast<unsigned char*>(glMapBufferRange(
|
||||
GL_ELEMENT_ARRAY_BUFFER, 0, GLsizeiptr(sizeof(kLeft)),
|
||||
GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT));
|
||||
ASSERT_NE(map, nullptr);
|
||||
const unsigned int vao = MakeVao(vbo, ebo);
|
||||
|
||||
for (int frame = 0; frame < 12; ++frame) {
|
||||
Begin();
|
||||
const bool wantRight = (frame % 2) == 1;
|
||||
std::memcpy(map, wantRight ? kRight : kLeft, sizeof(kLeft));
|
||||
Draw(vao);
|
||||
const Image image = Read();
|
||||
Halves(image, wantRight ? "black" : "red", wantRight ? "green" : "black",
|
||||
"frame " + std::to_string(frame) + " of a per-frame coherent EBO rewrite");
|
||||
Gl().EndFrame();
|
||||
}
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
|
||||
glUnmapBuffer(GL_ELEMENT_ARRAY_BUFFER);
|
||||
glDeleteVertexArrays(1, &vao);
|
||||
glDeleteBuffers(1, &ebo);
|
||||
glDeleteBuffers(1, &vbo);
|
||||
}
|
||||
|
||||
// B: usage escalation. The EBO is memoised as an index buffer, then bound
|
||||
// as a VERTEX buffer in a later frame, which forces the backend to
|
||||
// recreate its resident storage carrying the extra usage bit. A memo that
|
||||
// survived that recreate would name a destroyed VkBuffer.
|
||||
//
|
||||
// COVERS: that a storage recreate driven by a DIFFERENT binding point
|
||||
// retires the index memo. Reaches the branch 5 times.
|
||||
TEST_F(ResidentIndexScenario, EboAlsoBoundAsVertexBufferLater) {
|
||||
const unsigned int vbo = MakeStaticVbo();
|
||||
unsigned int ebo = 0;
|
||||
glGenBuffers(1, &ebo);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
|
||||
// Big enough to be a legal (if nonsensical) vertex source too.
|
||||
std::vector<GLuint> indices(64, 0);
|
||||
std::memcpy(indices.data(), kLeft, sizeof(kLeft));
|
||||
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(indices.size() * 4), indices.data(), GL_STATIC_DRAW);
|
||||
const unsigned int vao = MakeVao(vbo, ebo);
|
||||
|
||||
unsigned int vertexUseVao = 0;
|
||||
glGenVertexArrays(1, &vertexUseVao);
|
||||
glBindVertexArray(vertexUseVao);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, ebo); // the EBO, as a vertex source
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast<void*>(0));
|
||||
glEnableVertexAttribArray(1);
|
||||
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast<void*>(8));
|
||||
glBindVertexArray(0);
|
||||
|
||||
for (int frame = 0; frame < 6; ++frame) {
|
||||
Begin();
|
||||
Draw(vao);
|
||||
if (frame == 2) Draw(vertexUseVao); // forces the usage escalation
|
||||
const Image image = Read();
|
||||
if (frame != 2) {
|
||||
Halves(image, "red", "black", "frame " + std::to_string(frame) + " around a usage escalation");
|
||||
}
|
||||
Gl().EndFrame();
|
||||
}
|
||||
glDeleteVertexArrays(1, &vertexUseVao);
|
||||
glDeleteVertexArrays(1, &vao);
|
||||
glDeleteBuffers(1, &ebo);
|
||||
glDeleteBuffers(1, &vbo);
|
||||
}
|
||||
|
||||
// C: delete the EBO and immediately recreate it, so the frontend
|
||||
// BufferObject may well land at the same address - which is all the memo's
|
||||
// identity check compares. What stops it is that a fresh resource cannot
|
||||
// reproduce an epoch from the process-lifetime counter; this is the test
|
||||
// that says so out loud.
|
||||
//
|
||||
// COVERS: address reuse of a deleted index buffer. Reaches 7, accepts 6 -
|
||||
// the one decline is the post-recreate draw.
|
||||
TEST_F(ResidentIndexScenario, EboDeletedAndRecreatedAtTheSameName) {
|
||||
const unsigned int vbo = MakeStaticVbo();
|
||||
unsigned int ebo = 0;
|
||||
glGenBuffers(1, &ebo);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
|
||||
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kLeft)), kLeft, GL_STATIC_DRAW);
|
||||
unsigned int vao = MakeVao(vbo, ebo);
|
||||
|
||||
for (int frame = 0; frame < 4; ++frame) {
|
||||
Begin();
|
||||
Draw(vao);
|
||||
Halves(Read(), "red", "black", "warmup frame " + std::to_string(frame));
|
||||
Gl().EndFrame();
|
||||
}
|
||||
|
||||
// Same VAO, same GL name, different contents.
|
||||
glDeleteVertexArrays(1, &vao);
|
||||
glDeleteBuffers(1, &ebo);
|
||||
glGenBuffers(1, &ebo);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
|
||||
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kRight)), kRight, GL_STATIC_DRAW);
|
||||
vao = MakeVao(vbo, ebo);
|
||||
|
||||
for (int frame = 0; frame < 4; ++frame) {
|
||||
Begin();
|
||||
Draw(vao);
|
||||
Halves(Read(), "black", "green", "post-recreate frame " + std::to_string(frame));
|
||||
Gl().EndFrame();
|
||||
}
|
||||
glDeleteVertexArrays(1, &vao);
|
||||
glDeleteBuffers(1, &ebo);
|
||||
glDeleteBuffers(1, &vbo);
|
||||
}
|
||||
|
||||
// D: one resident EBO shared by two VAOs, so two independent memo entries
|
||||
// hold the same recorded slice, mutated through one of them and drawn
|
||||
// through both across frames.
|
||||
//
|
||||
// COVERS: that a mutation retires EVERY memo naming the buffer, not just
|
||||
// the one whose VAO issued it. Reaches 8, accepts 6.
|
||||
TEST_F(ResidentIndexScenario, OneEboTwoVaosMutatedAcrossFrames) {
|
||||
const unsigned int vbo = MakeStaticVbo();
|
||||
unsigned int ebo = 0;
|
||||
glGenBuffers(1, &ebo);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
|
||||
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kLeft)), kLeft, GL_STATIC_DRAW);
|
||||
const unsigned int vaoA = MakeVao(vbo, ebo);
|
||||
const unsigned int vaoB = MakeVao(vbo, ebo);
|
||||
|
||||
for (int frame = 0; frame < 10; ++frame) {
|
||||
Begin();
|
||||
const bool wantRight = frame >= 5;
|
||||
if (frame == 5) {
|
||||
glBindVertexArray(vaoA);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
|
||||
glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, GLsizeiptr(sizeof(kRight)), kRight);
|
||||
glBindVertexArray(0);
|
||||
}
|
||||
Draw((frame % 2) == 0 ? vaoA : vaoB);
|
||||
Halves(Read(), wantRight ? "black" : "red", wantRight ? "green" : "black",
|
||||
"shared-EBO frame " + std::to_string(frame));
|
||||
Gl().EndFrame();
|
||||
}
|
||||
glDeleteVertexArrays(1, &vaoB);
|
||||
glDeleteVertexArrays(1, &vaoA);
|
||||
glDeleteBuffers(1, &ebo);
|
||||
glDeleteBuffers(1, &vbo);
|
||||
}
|
||||
|
||||
// E: a DYNAMIC_DRAW EBO left untouched long enough for the streaming path
|
||||
// to PROMOTE it onto resident storage, then mutated.
|
||||
//
|
||||
// COVERS: promoted-buffer index freshness across a frame boundary.
|
||||
// DOES NOT COVER: the EBO memo, and this is the useful part - instrumented,
|
||||
// it reaches the cross-frame branch ZERO times. A promoted buffer is SERVED
|
||||
// by AcquireResidentSlice but still ROUTED through the streamed branch of
|
||||
// UploadAndBindIndexBuffer, which never records a memo. That asymmetry is
|
||||
// exactly what makes the EBO half of the shipped fix unobservable, and this
|
||||
// case is the tripwire: memoise the streamed/promoted index path and the
|
||||
// reach stops being zero.
|
||||
TEST_F(ResidentIndexScenario, PromotedDynamicEbo) {
|
||||
const unsigned int vbo = MakeStaticVbo();
|
||||
unsigned int ebo = 0;
|
||||
glGenBuffers(1, &ebo);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
|
||||
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kLeft)), kLeft, GL_DYNAMIC_DRAW);
|
||||
const unsigned int vao = MakeVao(vbo, ebo);
|
||||
|
||||
for (int frame = 0; frame < 10; ++frame) {
|
||||
Begin();
|
||||
Draw(vao);
|
||||
Halves(Read(), "red", "black", "promotion warmup frame " + std::to_string(frame));
|
||||
Gl().EndFrame();
|
||||
}
|
||||
for (int frame = 0; frame < 6; ++frame) {
|
||||
Begin();
|
||||
if (frame == 0) {
|
||||
glBindVertexArray(vao);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
|
||||
glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, GLsizeiptr(sizeof(kRight)), kRight);
|
||||
glBindVertexArray(0);
|
||||
}
|
||||
Draw(vao);
|
||||
Halves(Read(), "black", "green", "post-promotion frame " + std::to_string(frame));
|
||||
Gl().EndFrame();
|
||||
}
|
||||
glDeleteVertexArrays(1, &vao);
|
||||
glDeleteBuffers(1, &ebo);
|
||||
glDeleteBuffers(1, &vbo);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace MGITest
|
||||
@@ -0,0 +1,300 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ThreeChannelAttachmentScenario.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// Scenario - THREE-CHANNEL COLOUR ATTACHMENTS, on a live driver.
|
||||
//
|
||||
// The bug: no OpenGL ES driver renders to a three-channel image. EXT_render_snorm covers
|
||||
// R/RG/RGBA only, EXT_color_buffer_float excludes RGB16F, and RGB integer formats are not
|
||||
// colour-renderable anywhere. Complementary Reimagined declares colortex1 = RGB8_SNORM and
|
||||
// colortex2 = RGB16F, so every framebuffer Iris built from them answered
|
||||
// GL_FRAMEBUFFER_UNSUPPORTED and Iris refused to load the shaderpack. DirectGLES now stores such
|
||||
// an attachment in its four-channel sibling (GL_RGB8_SNORM -> GL_RGBA16F) and reports the
|
||||
// substitution as a caveat capability, which is what makes glCheckFramebufferStatus say COMPLETE.
|
||||
//
|
||||
// WHY THIS SCENARIO EXISTS RATHER THAN A UNIT TEST. The unit tests in
|
||||
// MG_Test/Framebuffer/FramebufferTest.cpp drive a HAND-BUILT capability cache: they prove the
|
||||
// frontend accepts a caveat capability, and prove the colour-mask/clear discipline that keeps a
|
||||
// widened attachment's stored alpha at 1.0, but they cannot prove that a real driver's probe
|
||||
// actually PRODUCES that caveat. Only a live glCheckFramebufferStatus can, and the answer is
|
||||
// per-driver, not per-platform:
|
||||
//
|
||||
// Mesa llvmpipe (the headless CI driver), ES 3.2, GL_TEXTURE_2D colour attachment:
|
||||
// COMPLETE GL_RGB8, GL_RGB16F, GL_R11F_G11F_B10F, every RGBA*
|
||||
// INCOMPLETE_ATTACHMENT GL_RGB8_SNORM, GL_SRGB8, every RGB integer format
|
||||
// UNSUPPORTED GL_RGB32F
|
||||
//
|
||||
// So the widening is LIVE on llvmpipe - "the desktop build is unaffected" was simply wrong, and
|
||||
// the CI retraces were green before the fix only because retrace ignores what
|
||||
// glCheckFramebufferStatus returns. This scenario is the gate that actually looks.
|
||||
//
|
||||
// DirectGLES only. DirectVulkan's format story is its own (Vulkan exposes R8G8B8_SNORM on almost
|
||||
// nothing, and Magma substitutes on different terms); asserting Espryt's answers there would
|
||||
// only pin a coincidence.
|
||||
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#undef GL_GLEXT_PROTOTYPES
|
||||
|
||||
namespace MGITest {
|
||||
namespace {
|
||||
|
||||
constexpr const char* kVS = R"(#version 330 core
|
||||
in vec2 aPos;
|
||||
void main() {
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
// Two outputs so the mixed case is covered: draw buffer 0 is a natively renderable
|
||||
// four-channel format whose alpha the application owns, draw buffer 1 is the widened
|
||||
// three-channel one whose alpha the format says is 1.0. Both alphas are deliberately
|
||||
// NOT 1.0 in the shader, so an implementation that simply passed the value through would
|
||||
// fail the second assertion.
|
||||
constexpr const char* kFS = R"(#version 330 core
|
||||
layout(location = 0) out vec4 oNative;
|
||||
layout(location = 1) out vec4 oWidened;
|
||||
void main() {
|
||||
oNative = vec4(1.0, 0.0, 0.0, 0.25);
|
||||
oWidened = vec4(0.0, 1.0, 0.0, 0.75);
|
||||
}
|
||||
)";
|
||||
|
||||
constexpr int kSize = 16;
|
||||
|
||||
class ThreeChannelAttachmentScenario : public ScenarioTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
ScenarioTest::SetUp();
|
||||
if (!Ready()) return;
|
||||
if (Gl().BackendName() != "DirectGLES") {
|
||||
GTEST_SKIP() << "three-channel widening is a DirectGLES substitution; backend is "
|
||||
<< Gl().BackendName();
|
||||
}
|
||||
}
|
||||
|
||||
// A single-level 2D texture in `internalFormat`, or 0 when the driver rejects the
|
||||
// storage outright (which is a different failure from rejecting the ATTACHMENT).
|
||||
static GLuint MakeTexture(GLenum internalFormat) {
|
||||
GLuint texture = 0;
|
||||
glGenTextures(1, &texture);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glTexStorage2D(GL_TEXTURE_2D, 1, internalFormat, kSize, kSize);
|
||||
if (glGetError() != GL_NO_ERROR) {
|
||||
glDeleteTextures(1, &texture);
|
||||
return 0;
|
||||
}
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
return texture;
|
||||
}
|
||||
|
||||
static GLenum SingleAttachmentStatus(GLenum internalFormat) {
|
||||
const GLuint texture = MakeTexture(internalFormat);
|
||||
if (texture == 0) return GL_NONE;
|
||||
GLuint fbo = 0;
|
||||
glGenFramebuffers(1, &fbo);
|
||||
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo);
|
||||
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
|
||||
const GLenum status = glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER);
|
||||
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
|
||||
glDeleteFramebuffers(1, &fbo);
|
||||
glDeleteTextures(1, &texture);
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
// THE regression gate for the frontend's answer: this is the exact call Iris makes, and
|
||||
// GL_FRAMEBUFFER_UNSUPPORTED here is the whole shaderpack load failure.
|
||||
TEST_F(ThreeChannelAttachmentScenario, ThreeChannelColorAttachmentsReportComplete) {
|
||||
if (!Ready() || IsSkipped()) return;
|
||||
|
||||
// GL_RGB8 is the control: colour-renderable in ES core, so it must pass with or
|
||||
// without any substitution. If it ever fails, nothing below means anything.
|
||||
EXPECT_EQ(SingleAttachmentStatus(GL_RGB8), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
|
||||
<< "GL_RGB8 is ES-core colour-renderable";
|
||||
|
||||
// Complementary Reimagined's colortex1 and colortex2.
|
||||
EXPECT_EQ(SingleAttachmentStatus(GL_RGB8_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
|
||||
<< "colortex1 (RGB8_SNORM) must be renderable through the four-channel widening";
|
||||
EXPECT_EQ(SingleAttachmentStatus(GL_RGB16F), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
|
||||
<< "colortex2 (RGB16F) must be renderable, natively or through the widening";
|
||||
|
||||
// The other formats the widening covers. GL_RGB32F only reaches a renderable
|
||||
// four-channel form when EXT_color_buffer_float is present, so a half-float-only
|
||||
// driver legitimately answers UNSUPPORTED for it - see the POST's per-format row.
|
||||
EXPECT_EQ(SingleAttachmentStatus(GL_SRGB8), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
||||
EXPECT_EQ(SingleAttachmentStatus(GL_RGB8UI), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
||||
|
||||
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
|
||||
}
|
||||
|
||||
// The other half: the substitution has to be INVISIBLE. A three-channel format has no
|
||||
// alpha, so GL answers 1.0 for it - and that answer has to hold after a draw that wrote
|
||||
// something else into the widened storage's real alpha channel, which is what the
|
||||
// colour-mask discipline in SyncRenderState is for. GL_DST_ALPHA blending and
|
||||
// glBlitFramebuffer read that stored alpha inside the driver, where no readback fixup can
|
||||
// reach it, so "the storage really holds 1.0" is the only workable invariant.
|
||||
TEST_F(ThreeChannelAttachmentScenario, WidenedAttachmentReadsBackOpaqueWhileItsNeighbourKeepsItsAlpha) {
|
||||
if (!Ready() || IsSkipped()) return;
|
||||
|
||||
std::string error;
|
||||
const GLuint program = CompileProgram(kVS, kFS, &error);
|
||||
ASSERT_NE(program, 0u) << error;
|
||||
|
||||
const GLuint nativeTexture = MakeTexture(GL_RGBA16F);
|
||||
const GLuint widenedTexture = MakeTexture(GL_RGB8_SNORM);
|
||||
ASSERT_NE(nativeTexture, 0u);
|
||||
ASSERT_NE(widenedTexture, 0u);
|
||||
|
||||
GLuint fbo = 0;
|
||||
glGenFramebuffers(1, &fbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, nativeTexture, 0);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, widenedTexture, 0);
|
||||
const GLenum drawBuffers[2] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1};
|
||||
glDrawBuffers(2, drawBuffers);
|
||||
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
||||
|
||||
glViewport(0, 0, kSize, kSize);
|
||||
// Alpha 0.0 on purpose: the widened attachment must come back 1.0 anyway, and the
|
||||
// native one must come back 0.0 where the draw does not cover it.
|
||||
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
|
||||
const float quad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
|
||||
GLuint vao = 0;
|
||||
GLuint vbo = 0;
|
||||
glGenVertexArrays(1, &vao);
|
||||
glBindVertexArray(vao);
|
||||
glGenBuffers(1, &vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
|
||||
glUseProgram(program);
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
|
||||
std::vector<float> pixels(static_cast<std::size_t>(kSize) * kSize * 4, -1.0f);
|
||||
|
||||
glReadBuffer(GL_COLOR_ATTACHMENT1);
|
||||
glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_FLOAT, pixels.data());
|
||||
EXPECT_NEAR(pixels[0], 0.0f, 0.02f) << "widened attachment red";
|
||||
EXPECT_NEAR(pixels[1], 1.0f, 0.02f) << "widened attachment green";
|
||||
EXPECT_NEAR(pixels[2], 0.0f, 0.02f) << "widened attachment blue";
|
||||
EXPECT_NEAR(pixels[3], 1.0f, 0.001f)
|
||||
<< "a three-channel format has no alpha channel, so GL must report 1.0 for it";
|
||||
|
||||
glReadBuffer(GL_COLOR_ATTACHMENT0);
|
||||
glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_FLOAT, pixels.data());
|
||||
EXPECT_NEAR(pixels[0], 1.0f, 0.02f) << "native attachment red";
|
||||
EXPECT_NEAR(pixels[3], 0.25f, 0.02f)
|
||||
<< "the alpha discipline must not leak onto a natively renderable attachment";
|
||||
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
glDeleteFramebuffers(1, &fbo);
|
||||
glDeleteBuffers(1, &vbo);
|
||||
glDeleteVertexArrays(1, &vao);
|
||||
glDeleteTextures(1, &nativeTexture);
|
||||
glDeleteTextures(1, &widenedTexture);
|
||||
glDeleteProgram(program);
|
||||
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
|
||||
}
|
||||
|
||||
// The case above can be satisfied by the readback fixup alone (ForceWideReadAlphaToOne
|
||||
// rewrites glReadPixels' alpha), so it does NOT prove the STORED alpha is 1.0. This one
|
||||
// does, by asking the driver to read that alpha itself: GL_DST_ALPHA blending multiplies
|
||||
// by the destination alpha inside the raster pipeline, where nothing MobileGL does can
|
||||
// intervene. Same reason GL_ONE_MINUS_DST_ALPHA and glBlitFramebuffer are covered for
|
||||
// free once this holds - and the reason the discipline is a write mask rather than a
|
||||
// readback patch.
|
||||
//
|
||||
// Ablation-checked on llvmpipe, each half separately: disable the alpha doctoring in
|
||||
// SyncRenderState and the opaque draw leaves 0.25 in the stored alpha; disable the clear
|
||||
// substitution in Clear() and it stays at the application's 0.0. Either way this case
|
||||
// reads back the wrong number, which is what makes it a gate rather than a description.
|
||||
TEST_F(ThreeChannelAttachmentScenario, DstAlphaBlendingSeesOneInAWidenedAttachment) {
|
||||
if (!Ready() || IsSkipped()) return;
|
||||
|
||||
static constexpr const char* kSingleOutFS = R"(#version 330 core
|
||||
out vec4 oColor;
|
||||
uniform vec4 uColor;
|
||||
void main() { oColor = uColor; }
|
||||
)";
|
||||
std::string error;
|
||||
const GLuint program = CompileProgram(kVS, kSingleOutFS, &error);
|
||||
ASSERT_NE(program, 0u) << error;
|
||||
const GLint colorLocation = glGetUniformLocation(program, "uColor");
|
||||
ASSERT_GE(colorLocation, 0);
|
||||
|
||||
const GLuint widenedTexture = MakeTexture(GL_RGB8_SNORM);
|
||||
ASSERT_NE(widenedTexture, 0u);
|
||||
GLuint fbo = 0;
|
||||
glGenFramebuffers(1, &fbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, widenedTexture, 0);
|
||||
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
||||
|
||||
const float quad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
|
||||
GLuint vao = 0;
|
||||
GLuint vbo = 0;
|
||||
glGenVertexArrays(1, &vao);
|
||||
glBindVertexArray(vao);
|
||||
glGenBuffers(1, &vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
|
||||
glUseProgram(program);
|
||||
glViewport(0, 0, kSize, kSize);
|
||||
|
||||
// The clear's alpha is 0.0 and the draw's is 0.25 - neither is the 1.0 the format
|
||||
// implies, so both halves of the discipline have to fire for the blend below to see
|
||||
// 1.0: the clear substitutes it, and the draw is masked away from it.
|
||||
glDisable(GL_BLEND);
|
||||
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
glUniform4f(colorLocation, 0.0f, 1.0f, 0.0f, 0.25f);
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
|
||||
// dst = stored alpha; src factor GL_DST_ALPHA, dst factor GL_ZERO, source white
|
||||
// => the destination colour becomes (storedAlpha, storedAlpha, storedAlpha).
|
||||
glEnable(GL_BLEND);
|
||||
glBlendFunc(GL_DST_ALPHA, GL_ZERO);
|
||||
glUniform4f(colorLocation, 1.0f, 1.0f, 1.0f, 1.0f);
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
glDisable(GL_BLEND);
|
||||
|
||||
std::vector<float> pixels(static_cast<std::size_t>(kSize) * kSize * 4, -1.0f);
|
||||
glReadBuffer(GL_COLOR_ATTACHMENT0);
|
||||
glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_FLOAT, pixels.data());
|
||||
EXPECT_NEAR(pixels[0], 1.0f, 0.02f)
|
||||
<< "GL_DST_ALPHA read the stored alpha of a three-channel attachment; it must be 1.0";
|
||||
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
glDeleteFramebuffers(1, &fbo);
|
||||
glDeleteBuffers(1, &vbo);
|
||||
glDeleteVertexArrays(1, &vao);
|
||||
glDeleteTextures(1, &widenedTexture);
|
||||
glDeleteProgram(program);
|
||||
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace MGITest
|
||||
@@ -0,0 +1,584 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// Scenario F - a draw must never read a destroyed object's memoised state.
|
||||
//
|
||||
// Distilled from the order-triggered CTS failure: on DirectVulkan, once
|
||||
// KHR-GLxx.clip_distance.functional had run in the same process, every later
|
||||
// transform_feedback CAPTURE case failed. It looked like a transform feedback
|
||||
// bug and is not one. The capture works; the DRAW being captured fetched its
|
||||
// vertices from the WRONG BUFFER - the one the clip workload had just deleted.
|
||||
//
|
||||
// The mechanism, and why the sequence matters. DirectVulkan memoises a VAO's
|
||||
// resolved Vulkan vertex bindings in a table keyed on the VertexArrayObject's
|
||||
// heap ADDRESS, validated by a content hash that folds in the bound
|
||||
// BufferObject's heap ADDRESS. Both are recycled by the allocator, so when the
|
||||
// workload's VAO and vertex buffer are destroyed and the capture phase's own
|
||||
// VAO and vertex buffer are allocated onto their addresses under a
|
||||
// byte-identical attribute layout (one vec4 float array at location 0 - which
|
||||
// is what both phases use), the key matches, the hash matches, and the memo
|
||||
// hands the new draw the dead buffer's GPU slice. Nothing about transform
|
||||
// feedback is involved: capture just makes the wrong vertices legible, because
|
||||
// the captured record IS the vertex data. The fix gives VertexArrayObject and
|
||||
// BufferObject never-reused lifetime ids and keys the memo on those.
|
||||
//
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE is not part of the defect. It shifts the
|
||||
// allocation pattern, so it changes WHICH stop points below land on a recycled
|
||||
// address - which is why the CTS saw ~100% incidence with it on and ~2% with it
|
||||
// off, and why the sweep case matters more than any single stop point.
|
||||
//
|
||||
// The shapes are the two CTS cases verbatim in structure:
|
||||
// * the workload is glcClipDistance.cpp FunctionalTest's inner loop (a program
|
||||
// per (redeclaration, clip count), glEnable(GL_CLIP_DISTANCEi), an FBO per
|
||||
// primitive type, a draw and a readback), including its early-return
|
||||
// behaviour: on failure the test returns WITHOUT running its "clip clean"
|
||||
// loop, so GL_CLIP_DISTANCE0..N-1 stay enabled for the rest of the process.
|
||||
// That leftover enable state is NOT the carrier (one of the cases below pins
|
||||
// that); the object churn is.
|
||||
// * the victim is gl3cTransformFeedback3Tests.cpp's skip_components: a
|
||||
// gl_SkipComponents capture layout under GL_RASTERIZER_DISCARD, read back
|
||||
// out of a buffer pre-filled with -1-i so that "captured nothing" is
|
||||
// distinguishable from "captured the wrong thing".
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#undef GL_GLEXT_PROTOTYPES
|
||||
|
||||
#ifndef GL_CLIP_DISTANCE0
|
||||
#define GL_CLIP_DISTANCE0 0x3000
|
||||
#endif
|
||||
|
||||
namespace MGITest {
|
||||
namespace {
|
||||
|
||||
GLuint CompileShader(GLenum type, const std::string& source, std::string* log) {
|
||||
const GLuint shader = glCreateShader(type);
|
||||
const char* text = source.c_str();
|
||||
glShaderSource(shader, 1, &text, nullptr);
|
||||
glCompileShader(shader);
|
||||
GLint status = GL_FALSE;
|
||||
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
|
||||
if (status == GL_FALSE) {
|
||||
GLint length = 0;
|
||||
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
|
||||
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
|
||||
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
|
||||
if (log != nullptr) *log = buffer.data();
|
||||
glDeleteShader(shader);
|
||||
return 0;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
// Links a vertex/fragment pair, optionally declaring transform feedback
|
||||
// varyings first (glTransformFeedbackVaryings takes effect at the next link,
|
||||
// exactly as the CTS uses it).
|
||||
GLuint BuildProgram(const std::string& vertexSource, const std::string& fragmentSource,
|
||||
const std::vector<const char*>& xfbVaryings, GLenum bufferMode, std::string* log) {
|
||||
const GLuint vertexShader = CompileShader(GL_VERTEX_SHADER, vertexSource, log);
|
||||
if (vertexShader == 0) return 0;
|
||||
const GLuint fragmentShader = CompileShader(GL_FRAGMENT_SHADER, fragmentSource, log);
|
||||
if (fragmentShader == 0) {
|
||||
glDeleteShader(vertexShader);
|
||||
return 0;
|
||||
}
|
||||
const GLuint program = glCreateProgram();
|
||||
glAttachShader(program, vertexShader);
|
||||
glAttachShader(program, fragmentShader);
|
||||
if (!xfbVaryings.empty()) {
|
||||
glTransformFeedbackVaryings(program, static_cast<GLsizei>(xfbVaryings.size()), xfbVaryings.data(),
|
||||
bufferMode);
|
||||
}
|
||||
glLinkProgram(program);
|
||||
glDeleteShader(vertexShader);
|
||||
glDeleteShader(fragmentShader);
|
||||
GLint status = GL_FALSE;
|
||||
glGetProgramiv(program, GL_LINK_STATUS, &status);
|
||||
if (status == GL_FALSE) {
|
||||
GLint length = 0;
|
||||
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
|
||||
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
|
||||
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
|
||||
if (log != nullptr) *log = buffer.data();
|
||||
glDeleteProgram(program);
|
||||
return 0;
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- poison
|
||||
|
||||
// glcClipDistance.cpp FunctionalTest::m_vertex_shader_code with the same
|
||||
// three substitutions (redeclaration, clip function, array setter).
|
||||
std::string ClipVertexSource(bool redeclaration, unsigned clipCount, unsigned clipFunction,
|
||||
unsigned vertexCount) {
|
||||
const std::string count = std::to_string(clipCount);
|
||||
std::string source = "#version 400 core\n\n";
|
||||
if (redeclaration) {
|
||||
source += "out float gl_ClipDistance[" + count + "];\n";
|
||||
}
|
||||
source += "\n";
|
||||
switch (clipFunction) {
|
||||
case 0:
|
||||
source += "float f(int i)\n{\n return 0.0;\n}\n";
|
||||
break;
|
||||
case 1:
|
||||
source += "float f(int i)\n{\n return 0.25 + 0.75 * (float(i) + 1.0) * (float(gl_VertexID) + 1.0)"
|
||||
" / (float(" + count + ") * float(" + std::to_string(vertexCount) + "));\n}\n";
|
||||
break;
|
||||
default:
|
||||
source += "float f(int i)\n{\n return - 0.25 - 0.75 * (float(i) + 1.0) * (float(gl_VertexID) + 1.0)"
|
||||
" / (float(" + count + ") * float(" + std::to_string(vertexCount) + "));\n}\n";
|
||||
break;
|
||||
}
|
||||
source += "\nin vec4 position;\n\nvoid main()\n{\n";
|
||||
if (redeclaration) {
|
||||
// Dynamic array setter.
|
||||
source += " for(int i = 0; i < " + count + "; i++)\n {\n"
|
||||
" gl_ClipDistance[i] = f(i);\n }\n";
|
||||
} else {
|
||||
// Static array setter, at the highest index this iteration enables.
|
||||
const std::string index = std::to_string(clipCount - 1);
|
||||
source += " gl_ClipDistance[" + index + "] = f(" + index + ");\n";
|
||||
}
|
||||
source += "\n gl_Position = position;\n}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
const char* kClipFragmentSource = R"(#version 400 core
|
||||
|
||||
out vec4 color;
|
||||
|
||||
void main()
|
||||
{
|
||||
color = vec4(1.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
// How far into FunctionalTest's loop nest to get before bailing out the way
|
||||
// the CTS does on a failed check: return immediately, skipping the "clip
|
||||
// clean" loop that would have disabled GL_CLIP_DISTANCEi again.
|
||||
struct ClipStopPoint {
|
||||
unsigned primitiveIndex = 0; // 0 = POINTS, 1 = LINES, 2 = TRIANGLES
|
||||
unsigned clipFunction = 0;
|
||||
bool redeclaration = false;
|
||||
unsigned clipCount = 1; // 1..8, the iteration that "fails"
|
||||
};
|
||||
|
||||
// Runs FunctionalTest's loop nest up to and including `stop`, then returns
|
||||
// leaving exactly the state the CTS leaves behind on a failure.
|
||||
void RunClipDistanceWorkload(const ClipStopPoint& stop) {
|
||||
static const GLenum kPrimitiveTypes[] = {GL_POINTS, GL_LINES, GL_TRIANGLES};
|
||||
static const GLsizei kPrimitiveIndices[] = {1, 2, 3};
|
||||
static const float kPositions[3][12] = {
|
||||
{0.0f, 0.0f, 0.0f, 1.0f},
|
||||
{-1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f},
|
||||
{-1.0f, -1.0f, 0.0f, 1.0f, 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f},
|
||||
};
|
||||
|
||||
for (unsigned primitiveIndex = 0; primitiveIndex <= stop.primitiveIndex; ++primitiveIndex) {
|
||||
const GLenum primitiveType = kPrimitiveTypes[primitiveIndex];
|
||||
const GLsizei vertexCount = kPrimitiveIndices[primitiveIndex];
|
||||
const GLsizei framebufferSize = (primitiveType == GL_POINTS) ? 1 : 32;
|
||||
|
||||
GLuint colorBuffer = 0;
|
||||
GLuint framebuffer = 0;
|
||||
glGenRenderbuffers(1, &colorBuffer);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, colorBuffer);
|
||||
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, framebufferSize, framebufferSize);
|
||||
glGenFramebuffers(1, &framebuffer);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorBuffer);
|
||||
glViewport(0, 0, framebufferSize, framebufferSize);
|
||||
|
||||
const unsigned lastFunction =
|
||||
(primitiveIndex == stop.primitiveIndex) ? stop.clipFunction : 2u;
|
||||
for (unsigned clipFunction = 0; clipFunction <= lastFunction; ++clipFunction) {
|
||||
const bool atStopFunction =
|
||||
primitiveIndex == stop.primitiveIndex && clipFunction == stop.clipFunction;
|
||||
for (unsigned redeclaration = 0; redeclaration < 2; ++redeclaration) {
|
||||
const bool atStopRedeclaration =
|
||||
atStopFunction && (redeclaration != 0) == stop.redeclaration;
|
||||
const unsigned lastCount = atStopRedeclaration ? stop.clipCount : 8u;
|
||||
for (unsigned clipCount = 1; clipCount <= lastCount; ++clipCount) {
|
||||
std::string log;
|
||||
const GLuint program =
|
||||
BuildProgram(ClipVertexSource(redeclaration != 0, clipCount, clipFunction,
|
||||
static_cast<unsigned>(vertexCount)),
|
||||
kClipFragmentSource, {}, GL_INTERLEAVED_ATTRIBS, &log);
|
||||
if (program == 0) continue;
|
||||
glUseProgram(program);
|
||||
|
||||
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
|
||||
glEnable(GL_CLIP_DISTANCE0 + clipCount - 1);
|
||||
|
||||
GLuint vao = 0;
|
||||
GLuint vbo = 0;
|
||||
glGenVertexArrays(1, &vao);
|
||||
glBindVertexArray(vao);
|
||||
glGenBuffers(1, &vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER,
|
||||
static_cast<GLsizeiptr>(sizeof(float) * 4 * vertexCount),
|
||||
kPositions[primitiveIndex], GL_STATIC_DRAW);
|
||||
const GLint location = glGetAttribLocation(program, "position");
|
||||
if (location >= 0) {
|
||||
glEnableVertexAttribArray(static_cast<GLuint>(location));
|
||||
glVertexAttribPointer(static_cast<GLuint>(location), 4, GL_FLOAT, GL_FALSE, 0,
|
||||
nullptr);
|
||||
}
|
||||
|
||||
glDrawArrays(primitiveType, 0, vertexCount);
|
||||
|
||||
std::vector<unsigned char> pixels(
|
||||
static_cast<std::size_t>(framebufferSize) * framebufferSize * 4, 0);
|
||||
glReadPixels(0, 0, framebufferSize, framebufferSize, GL_RGBA, GL_UNSIGNED_BYTE,
|
||||
pixels.data());
|
||||
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
glBindVertexArray(0);
|
||||
glUseProgram(0);
|
||||
// MGL_REPRO_KEEPCLIPOBJ leaks the per-iteration objects so
|
||||
// no GL name and no heap address can be recycled into the
|
||||
// capture phase.
|
||||
// Deleting all three is load-bearing, not tidiness: the defect
|
||||
// this scenario pins needs the VAO's AND its vertex buffer's heap
|
||||
// addresses to be freed here so the capture phase's own objects
|
||||
// can be handed the same ones back.
|
||||
glDeleteBuffers(1, &vbo);
|
||||
glDeleteVertexArrays(1, &vao);
|
||||
glDeleteProgram(program);
|
||||
|
||||
if (atStopRedeclaration && clipCount == stop.clipCount) {
|
||||
// The CTS's early return: the "clip clean" loop below
|
||||
// never runs, so the enables survive.
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
glDeleteFramebuffers(1, &framebuffer);
|
||||
glDeleteRenderbuffers(1, &colorBuffer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
glDisable(GL_CLIP_DISTANCE0 + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
glDeleteFramebuffers(1, &framebuffer);
|
||||
glDeleteRenderbuffers(1, &colorBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- victim
|
||||
|
||||
// gl3cTransformFeedback3Tests.cpp TransformFeedbackBaseTestCase::m_shader_vert.
|
||||
const char* kXfbVertexSource = R"(#version 400 core
|
||||
in vec4 vertex;
|
||||
out vec4 value1;
|
||||
out vec4 value2;
|
||||
out vec4 value3;
|
||||
out vec4 value4;
|
||||
|
||||
void main (void)
|
||||
{
|
||||
vec4 temp = vertex;
|
||||
|
||||
gl_Position = temp;
|
||||
|
||||
value1 = abs(temp) * 1.0;
|
||||
value2 = abs(temp) * 2.0;
|
||||
value3 = abs(temp) * 3.0;
|
||||
value4 = abs(temp) * 4.0;
|
||||
}
|
||||
)";
|
||||
|
||||
const char* kXfbFragmentSource = R"(#version 400 core
|
||||
out vec4 color;
|
||||
void main (void)
|
||||
{
|
||||
color = vec4(0.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
// The skip_components capture layout, verbatim.
|
||||
std::vector<const char*> SkipComponentsVaryings() {
|
||||
return {"gl_SkipComponents1", "value1", "gl_SkipComponents2", "gl_SkipComponents1", "value2",
|
||||
"gl_SkipComponents3", "gl_SkipComponents2", "value3", "gl_SkipComponents4", "value4"};
|
||||
}
|
||||
|
||||
constexpr unsigned kSkipComponentCount = 4 * 4 + (1 + 2 + 3 + 4 + 1 + 2); // 16 values + 13 skipped
|
||||
constexpr unsigned kSkipVertexCount = 6;
|
||||
|
||||
// Runs skip_components and reports what came back. `outCaptured` is the raw
|
||||
// readback so a failure can say whether anything was written at all.
|
||||
void RunSkipComponentsCapture(std::vector<float>& outCaptured, std::string* buildLog) {
|
||||
outCaptured.clear();
|
||||
|
||||
const GLuint program = BuildProgram(kXfbVertexSource, kXfbFragmentSource, SkipComponentsVaryings(),
|
||||
GL_INTERLEAVED_ATTRIBS, buildLog);
|
||||
ASSERT_NE(program, 0u) << "skip_components program failed to link: " << (buildLog ? *buildLog : "");
|
||||
glUseProgram(program);
|
||||
|
||||
const std::vector<float> vertices = {
|
||||
-1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -2.0f, 1.0f, -1.0f, 1.0f, -3.0f, 1.0f,
|
||||
1.0f, 1.0f, 4.0f, 1.0f, -1.0f, 1.0f, 5.0f, 1.0f, 1.0f, -1.0f, 6.0f, 1.0f,
|
||||
};
|
||||
|
||||
GLuint vao = 0;
|
||||
GLuint vbo = 0;
|
||||
glGenVertexArrays(1, &vao);
|
||||
glBindVertexArray(vao);
|
||||
glGenBuffers(1, &vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(sizeof(float) * vertices.size()), vertices.data(),
|
||||
GL_STATIC_DRAW);
|
||||
const GLint location = glGetAttribLocation(program, "vertex");
|
||||
if (location >= 0) {
|
||||
glEnableVertexAttribArray(static_cast<GLuint>(location));
|
||||
glVertexAttribPointer(static_cast<GLuint>(location), 4, GL_FLOAT, GL_FALSE, 0, nullptr);
|
||||
}
|
||||
|
||||
const unsigned floatCount = kSkipVertexCount * kSkipComponentCount;
|
||||
const GLsizeiptr byteSize = static_cast<GLsizeiptr>(sizeof(float) * floatCount);
|
||||
|
||||
GLuint captureBuffer = 0;
|
||||
glGenBuffers(1, &captureBuffer);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, captureBuffer);
|
||||
glBufferData(GL_ARRAY_BUFFER, byteSize, nullptr, GL_STATIC_READ);
|
||||
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
|
||||
// The pre-fill that makes "nothing was captured" recognisable.
|
||||
std::vector<float> prefill(floatCount);
|
||||
for (unsigned i = 0; i < floatCount; ++i) {
|
||||
prefill[i] = -1.0f - static_cast<float>(i);
|
||||
}
|
||||
glBindBuffer(GL_ARRAY_BUFFER, captureBuffer);
|
||||
glBufferData(GL_ARRAY_BUFFER, byteSize, prefill.data(), GL_STATIC_DRAW);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
|
||||
glEnable(GL_RASTERIZER_DISCARD);
|
||||
glClearColor(0.1f, 0.0f, 0.5f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer);
|
||||
glBeginTransformFeedback(GL_TRIANGLES);
|
||||
glDrawArrays(GL_TRIANGLES, 0, static_cast<GLsizei>(kSkipVertexCount));
|
||||
glEndTransformFeedback();
|
||||
glDisable(GL_RASTERIZER_DISCARD);
|
||||
|
||||
outCaptured.resize(floatCount);
|
||||
glBindBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer, 0, byteSize);
|
||||
const void* mapped = glMapBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, byteSize, GL_MAP_READ_BIT);
|
||||
if (mapped != nullptr) {
|
||||
std::memcpy(outCaptured.data(), mapped, static_cast<std::size_t>(byteSize));
|
||||
glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER);
|
||||
}
|
||||
|
||||
glDisableVertexAttribArray(0);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
glDeleteBuffers(1, &vbo);
|
||||
glDeleteBuffers(1, &captureBuffer);
|
||||
glBindVertexArray(0);
|
||||
glDeleteVertexArrays(1, &vao);
|
||||
glUseProgram(0);
|
||||
glDeleteProgram(program);
|
||||
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
|
||||
}
|
||||
|
||||
// skip_components' expected buffer: the 13 skipped components keep their
|
||||
// pre-fill, the 16 captured ones carry |vertex| * n.
|
||||
std::vector<float> SkipComponentsExpected() {
|
||||
const std::vector<float> vertices = {
|
||||
-1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -2.0f, 1.0f, -1.0f, 1.0f, -3.0f, 1.0f,
|
||||
1.0f, 1.0f, 4.0f, 1.0f, -1.0f, 1.0f, 5.0f, 1.0f, 1.0f, -1.0f, 6.0f, 1.0f,
|
||||
};
|
||||
const unsigned floatCount = kSkipVertexCount * kSkipComponentCount;
|
||||
std::vector<float> expected(floatCount);
|
||||
for (unsigned i = 0; i < floatCount; ++i) {
|
||||
expected[i] = -1.0f - static_cast<float>(i);
|
||||
}
|
||||
// Record layout, in floats:
|
||||
// [0] skip1
|
||||
// [1..4] value1
|
||||
// [5..7] skip2 + skip1
|
||||
// [8..11] value2
|
||||
// [12..16] skip3 + skip2
|
||||
// [17..20] value3
|
||||
// [21..24] skip4
|
||||
// [25..28] value4
|
||||
static const unsigned kValueOffsets[4] = {1, 8, 17, 25};
|
||||
for (unsigned v = 0; v < kSkipVertexCount; ++v) {
|
||||
const unsigned base = v * kSkipComponentCount;
|
||||
for (unsigned value = 0; value < 4; ++value) {
|
||||
for (unsigned component = 0; component < 4; ++component) {
|
||||
const float source = vertices[v * 4 + component];
|
||||
expected[base + kValueOffsets[value] + component] =
|
||||
std::fabs(source) * static_cast<float>(value + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
// Reports the first mismatch, and whether the readback is byte-for-byte the
|
||||
// pre-fill (i.e. the capture never happened).
|
||||
::testing::AssertionResult CheckSkipComponents(const std::vector<float>& captured) {
|
||||
const std::vector<float> expected = SkipComponentsExpected();
|
||||
if (captured.size() != expected.size()) {
|
||||
return ::testing::AssertionFailure()
|
||||
<< "readback size " << captured.size() << " != " << expected.size();
|
||||
}
|
||||
bool anyWritten = false;
|
||||
for (std::size_t i = 0; i < captured.size(); ++i) {
|
||||
if (captured[i] != -1.0f - static_cast<float>(i)) {
|
||||
anyWritten = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (std::size_t i = 0; i < expected.size(); ++i) {
|
||||
if (std::fabs(captured[i] - expected[i]) > 0.0125f) {
|
||||
return ::testing::AssertionFailure()
|
||||
<< "capture mismatch at index " << i << ": got " << captured[i] << ", expected "
|
||||
<< expected[i] << (anyWritten ? "" : " (the whole buffer is still the pre-fill: "
|
||||
"NOTHING was captured)");
|
||||
}
|
||||
}
|
||||
return ::testing::AssertionSuccess();
|
||||
}
|
||||
|
||||
// The harness turns "no context came up" into a clean skip, and a skip is
|
||||
// indistinguishable from a pass in a ctest summary. For this scenario that
|
||||
// is a hole rather than a courtesy: the defect it pins is DirectVulkan's
|
||||
// alone, and DirectVulkan now comes up headless on any machine at all - a
|
||||
// surfaceless EGL platform over a software ICD (lavapipe) is enough. So
|
||||
// "DirectVulkan did not initialise" here means the run is MISCONFIGURED,
|
||||
// not that the machine has no GPU, and it must not report green.
|
||||
//
|
||||
// Local on purpose: the harness-wide skip semantics are deliberate
|
||||
// (ScenarioFixture.h states the reasoning), and MOBILEGL_ITEST_REQUIRE_GPU
|
||||
// is the harness-wide lever for the same intent - but that lever also
|
||||
// demands a HARDWARE renderer, which is exactly what a lavapipe-only box
|
||||
// cannot offer. This overrides nothing else: only this scenario, only for
|
||||
// the backend that can regress, and only for the unusable-harness case.
|
||||
class XfbAfterClipDistanceScenario : public ScenarioTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
ScenarioTest::SetUp();
|
||||
// Ready() is false on the base's skip path AND on its REQUIRE_GPU
|
||||
// failure path; the second one has already failed, so leave it alone
|
||||
// rather than burying its reason under a second message.
|
||||
if (Ready() || HasFatalFailure()) return;
|
||||
if (Gl().BackendName() == "DirectVulkan") {
|
||||
FAIL() << "DirectVulkan could not be brought up, so the regression this scenario guards - a "
|
||||
"draw served a destroyed VAO's memoised vertex bindings - was never exercised, and "
|
||||
"that must be a failure rather than a silent skip. Headless bring-up needs only a "
|
||||
"Vulkan ICD and a surfaceless EGL platform (a software ICD such as lavapipe "
|
||||
"qualifies: VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json with "
|
||||
"EGL_PLATFORM=surfaceless). Harness reason: "
|
||||
<< Gl().SkipReason();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Control: the capture on its own must work.
|
||||
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAlone) {
|
||||
if (!Ready()) return;
|
||||
std::vector<float> captured;
|
||||
std::string log;
|
||||
RunSkipComponentsCapture(captured, &log);
|
||||
EXPECT_TRUE(CheckSkipComponents(captured));
|
||||
}
|
||||
|
||||
// Bisection step 1: only the leftover GL_CLIP_DISTANCEi enables.
|
||||
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAfterClipDistanceEnables) {
|
||||
if (!Ready()) return;
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
glEnable(GL_CLIP_DISTANCE0 + i);
|
||||
}
|
||||
std::vector<float> captured;
|
||||
std::string log;
|
||||
RunSkipComponentsCapture(captured, &log);
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
glDisable(GL_CLIP_DISTANCE0 + i);
|
||||
}
|
||||
EXPECT_TRUE(CheckSkipComponents(captured));
|
||||
}
|
||||
|
||||
// Bisection step 2: the whole clip_distance.functional workload, stopped
|
||||
// where the CTS stopped in the runs that went on to break the capture.
|
||||
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAfterClipDistanceWorkloadLines8) {
|
||||
if (!Ready()) return;
|
||||
RunClipDistanceWorkload({.primitiveIndex = 1, .clipFunction = 0, .redeclaration = false, .clipCount = 8});
|
||||
std::vector<float> captured;
|
||||
std::string log;
|
||||
RunSkipComponentsCapture(captured, &log);
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
glDisable(GL_CLIP_DISTANCE0 + i);
|
||||
}
|
||||
EXPECT_TRUE(CheckSkipComponents(captured));
|
||||
}
|
||||
|
||||
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAfterClipDistanceWorkloadPoints1) {
|
||||
if (!Ready()) return;
|
||||
RunClipDistanceWorkload({.primitiveIndex = 0, .clipFunction = 0, .redeclaration = true, .clipCount = 1});
|
||||
std::vector<float> captured;
|
||||
std::string log;
|
||||
RunSkipComponentsCapture(captured, &log);
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
glDisable(GL_CLIP_DISTANCE0 + i);
|
||||
}
|
||||
EXPECT_TRUE(CheckSkipComponents(captured));
|
||||
}
|
||||
|
||||
// A single stop point is not a regression test for this defect: whether the
|
||||
// capture phase's VAO and vertex buffer land on the addresses the workload just
|
||||
// freed is a function of how much the workload allocated, so the two cases above
|
||||
// pin two draws of a lottery. Sweep the grid instead - before the fix, roughly a
|
||||
// third of these stop points came back holding the workload's vertex data.
|
||||
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureSurvivesEveryClipWorkloadStopPoint) {
|
||||
if (!Ready()) return;
|
||||
for (unsigned primitiveIndex = 0; primitiveIndex < 3; ++primitiveIndex) {
|
||||
for (unsigned redeclaration = 0; redeclaration < 2; ++redeclaration) {
|
||||
for (const unsigned clipCount : {1u, 4u, 8u}) {
|
||||
RunClipDistanceWorkload({.primitiveIndex = primitiveIndex,
|
||||
.clipFunction = 0,
|
||||
.redeclaration = redeclaration != 0,
|
||||
.clipCount = clipCount});
|
||||
std::vector<float> captured;
|
||||
std::string log;
|
||||
RunSkipComponentsCapture(captured, &log);
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
glDisable(GL_CLIP_DISTANCE0 + i);
|
||||
}
|
||||
EXPECT_TRUE(CheckSkipComponents(captured))
|
||||
<< " (stop point: primitive " << primitiveIndex << ", redeclaration " << redeclaration
|
||||
<< ", clip count " << clipCount << ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace MGITest
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# Run the headless MobileGL integration scenarios on one backend:
|
||||
# ./run_integration_test.sh espryt [gtest args...] -> DirectGLES
|
||||
# ./run_integration_test.sh magma [gtest args...] -> DirectVulkan
|
||||
#
|
||||
# The backend is latched at initialization from MOBILEGL_BACKEND_TYPE, so one
|
||||
# process is one backend; this script is the dev-box equivalent of the two ctest
|
||||
# registrations in CMakeLists.txt.
|
||||
#
|
||||
# Pin the vendor libraries explicitly, for the same reason
|
||||
# MG_Benchmark/Driver/run_driver_bench.sh does: a bare libEGL on a glvnd system
|
||||
# resolves to whatever vendor comes first, which is usually Mesa/llvmpipe - a
|
||||
# software rasteriser silently replacing the GPU under a GPU test. Override
|
||||
# MGL_EGL_VENDOR / MGL_VK_ICD to test another driver.
|
||||
#
|
||||
# Set MOBILEGL_ITEST_REQUIRE_GPU=1 to turn "the harness is unusable" from a clean
|
||||
# skip into a failure. Do that anywhere the machine is supposed to have a GPU: a
|
||||
# run that skipped everything and a run that passed everything are otherwise the
|
||||
# same green, so without it a broken driver pinning is invisible.
|
||||
set -eu
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
BIN=${MOBILEGL_ITEST_BIN:-$HERE/MobileGLIntegrationTest}
|
||||
EGL_VENDOR=${MGL_EGL_VENDOR:-/usr/share/glvnd/egl_vendor.d/10_nvidia.json}
|
||||
VK_ICD=${MGL_VK_ICD:-/usr/share/vulkan/icd.d/nvidia_icd.x86_64.json}
|
||||
MODE=$1; shift
|
||||
|
||||
if [ ! -x "$BIN" ]; then
|
||||
echo "MobileGLIntegrationTest not found at $BIN"
|
||||
echo "configure with -DMOBILEGL_BUILD_INTEGRATION_TEST=ON and set MOBILEGL_ITEST_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ -r "$EGL_VENDOR" ] && export __EGL_VENDOR_LIBRARY_FILENAMES=$EGL_VENDOR
|
||||
export EGL_PLATFORM=${EGL_PLATFORM:-x11}
|
||||
|
||||
case "$MODE" in
|
||||
espryt|DirectGLES)
|
||||
export MOBILEGL_BACKEND_TYPE=DirectGLES
|
||||
;;
|
||||
magma|DirectVulkan)
|
||||
export MOBILEGL_BACKEND_TYPE=DirectVulkan
|
||||
[ -r "$VK_ICD" ] && export VK_ICD_FILENAMES=$VK_ICD
|
||||
;;
|
||||
*) echo "unknown mode: $MODE (espryt|magma)"; exit 1 ;;
|
||||
esac
|
||||
export MOBILEGL_ITEST_REQUIRE_GPU=${MOBILEGL_ITEST_REQUIRE_GPU:-}
|
||||
exec "$BIN" "$@"
|
||||
@@ -8,9 +8,17 @@
|
||||
|
||||
#include "BufferObject.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
namespace {
|
||||
const BufferBackendOps* g_bufferBackendOps = nullptr;
|
||||
// Starts at 1 so a zero-initialized cache slot can never carry a live buffer's id.
|
||||
std::atomic<Uint64> g_nextBufferLifetimeId{1};
|
||||
}
|
||||
|
||||
Uint64 BufferObject::AllocateLifetimeId() {
|
||||
return g_nextBufferLifetimeId.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void SetBufferBackendOps(const BufferBackendOps* ops) {
|
||||
@@ -198,8 +206,11 @@ namespace MobileGL::MG_State::GLState {
|
||||
}
|
||||
|
||||
void BufferObject::UploadSubData(DataPtr data, SizeT atOffset) {
|
||||
MOBILEGL_ASSERT(!m_isMapped || (m_mappingAccess & BufferMappingAccessBit::Persistent),
|
||||
"Cannot upload sub data while buffer is non-persistently mapped.");
|
||||
// GL 4.6 core 6.5 forbids only the OVERLAPPING write: a glBufferSubData that stays
|
||||
// clear of a non-persistent mapping is legal, and the frontend lets it through.
|
||||
MOBILEGL_ASSERT(!m_isMapped || (m_mappingAccess & BufferMappingAccessBit::Persistent) ||
|
||||
atOffset >= m_mappedRange.end || atOffset + data.size <= m_mappedRange.start,
|
||||
"Cannot upload sub data overlapping a non-persistent mapping.");
|
||||
MOBILEGL_ASSERT(atOffset + data.size <= m_size,
|
||||
"UploadSubData out of bounds: atOffset (%zu) + data.size (%zu) > m_size (%zu)", atOffset,
|
||||
data.size, m_size);
|
||||
|
||||
@@ -185,6 +185,13 @@ namespace MobileGL {
|
||||
Flags<BufferMappingAccessBit> GetMappingAccess() const;
|
||||
GLbitfield GetStorageFlags() const;
|
||||
Uint GetExternalIndex() const;
|
||||
// Globally-unique, never-reused id for THIS object's lifetime - same contract
|
||||
// and same motivation as ProgramObject::GetLifetimeId() and
|
||||
// VertexArrayObject::GetLifetimeId(). A backend that folds a buffer's IDENTITY
|
||||
// into a cache key must use this, never the GL name (LIFO-recycled by
|
||||
// glGenBuffers) and never the heap address (recycled by the allocator): both
|
||||
// let a deleted-and-recreated buffer answer to a dead one's cache entry.
|
||||
Uint64 GetLifetimeId() const { return m_lifetimeId; }
|
||||
// Monotonic counter bumped on every shadow mutation; backends use it to
|
||||
// validate cached transient slices.
|
||||
Uint64 GetChangeSerial() const;
|
||||
@@ -207,7 +214,10 @@ namespace MobileGL {
|
||||
// SubData transfer to sync the backend's separate GPU copy.
|
||||
void NotifyContentWrite(SizeT offset, SizeT size);
|
||||
|
||||
static Uint64 AllocateLifetimeId();
|
||||
|
||||
const Uint m_externalIndex = 0;
|
||||
const Uint64 m_lifetimeId = AllocateLifetimeId();
|
||||
SizeT m_size = 0;
|
||||
BufferUsage m_usage = BufferUsage::StaticDraw;
|
||||
// Owns the buffer's bytes (CPU shadow or backend persistent GPU map) and
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
#include "Core.h"
|
||||
#include "MG_State/GLState/RenderbufferState/RenderbufferObject.h"
|
||||
#include "MG_State/EGLState/Core.h"
|
||||
#include <MG_Backend/BackendObjects.h>
|
||||
#include <MG_Util/Async/ShaderCompilePool.h>
|
||||
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
|
||||
#include <Config.h>
|
||||
|
||||
namespace MobileGL::MG_State {
|
||||
@@ -24,8 +27,27 @@ namespace MobileGL::MG_State {
|
||||
}
|
||||
|
||||
namespace GLState {
|
||||
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv>& GLContext::GetCompileEnv() {
|
||||
const void* backend = static_cast<const void*>(MG_Backend::pActiveBackendObject.get());
|
||||
if (!m_compileEnv || m_compileEnvBackend != backend) {
|
||||
// First use, or the backend was swapped underneath us. Re-capturing rolls the
|
||||
// fingerprint, so every P0b preprocess memo computed against the old backend's
|
||||
// limits becomes structurally unreachable instead of silently reusable.
|
||||
m_compileEnv = MG_Util::ShaderTranspiler::CaptureCompileEnv();
|
||||
m_compileEnvBackend = backend;
|
||||
}
|
||||
return m_compileEnv;
|
||||
}
|
||||
|
||||
// Error
|
||||
void GLContext::RecordError(ErrorCode code, UniquePtr<ErrorInfo> info) {
|
||||
// Invariant I1, mechanically enforced: the GL error state is GL-thread-owned.
|
||||
// A compile or link body that needs to raise an error must append to its node's
|
||||
// JobDiagnostics and let the join replay it here (see the P1 design section 6);
|
||||
// reaching this from a worker would corrupt the sticky-flag set that
|
||||
// glGetError's ordering depends on.
|
||||
MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(),
|
||||
"GLContext::RecordError() called from a shader-compile pool thread");
|
||||
m_errorState.RecordError(code, Move(info));
|
||||
}
|
||||
|
||||
@@ -335,6 +357,10 @@ namespace MobileGL::MG_State {
|
||||
return m_programState.GetShaderObject(index);
|
||||
}
|
||||
|
||||
void GLContext::JoinAllPendingShaderWork() {
|
||||
m_programState.JoinAllPendingWork();
|
||||
}
|
||||
|
||||
void GLContext::UseProgram(Uint program) {
|
||||
return m_programState.UseProgram(program);
|
||||
}
|
||||
@@ -346,11 +372,35 @@ namespace MobileGL::MG_State {
|
||||
const SharedPtr<ProgramObject>& GLContext::GetProgramForDraw() {
|
||||
static const SharedPtr<ProgramObject> nullProgram = nullptr;
|
||||
const auto& currentProgram = m_programState.GetCurrentProgram();
|
||||
if (currentProgram) return currentProgram;
|
||||
if (currentProgram) {
|
||||
// P1 join site J1, plain glUseProgram half. The backends read a program's
|
||||
// lifetimeId / backendStateVersion / UBO content version to decide whether
|
||||
// their per-program caches are still valid, and none of those pass through
|
||||
// ProgramObject's join gate - so a draw could sample a version, join later
|
||||
// inside the same draw when it finally touched an artifact, and cache under a
|
||||
// version the publish had already superseded. Settling here means every
|
||||
// version a backend reads during a draw describes the program it is drawing.
|
||||
// One null check in steady state.
|
||||
currentProgram->JoinLink();
|
||||
return currentProgram;
|
||||
}
|
||||
if (m_boundProgramPipeline == 0) return nullProgram;
|
||||
const auto& pipeline = GetBoundProgramPipeline();
|
||||
if (!pipeline) return nullProgram;
|
||||
|
||||
// P1 join site J1. ComputeDrawProgramSignature() keys the composite cache on each
|
||||
// stage program's lifetimeId and backendStateVersion - NON-artifact fields, so
|
||||
// they do not pass through ProgramObject's join gate and a pending link would
|
||||
// stay pending right through the signature. Since the version is bumped both at
|
||||
// enqueue and at publish, the signature computed inside a pending window is one
|
||||
// that will never be produced again: every draw would miss the cache and rebuild
|
||||
// (and relink) the composite. Join first, so the signature describes settled
|
||||
// programs. In steady state this is a null check per stage.
|
||||
for (SizeT stage = 0; stage < static_cast<SizeT>(ShaderStage::ShaderStageCount); ++stage) {
|
||||
const auto& stageProgram = pipeline->GetStageProgram(static_cast<ShaderStage>(stage));
|
||||
if (stageProgram) stageProgram->JoinLink();
|
||||
}
|
||||
|
||||
const auto signature = pipeline->ComputeDrawProgramSignature();
|
||||
if (const auto& cached = pipeline->GetCachedDrawProgram(signature)) return cached;
|
||||
|
||||
@@ -378,6 +428,10 @@ namespace MobileGL::MG_State {
|
||||
// A pipeline with no fragment stage still rasterises, so the default fragment
|
||||
// shader is wanted here even though the separable stage programs never get one.
|
||||
composite->Link(true);
|
||||
// P1 join site J2. The draw that asked for this program is the very next thing to
|
||||
// happen, so enqueueing the composite's link buys nothing and only moves the wait
|
||||
// to whichever backend accessor happens to touch its artifacts first.
|
||||
composite->JoinLink();
|
||||
pipeline->SetCachedDrawProgram(signature, Move(composite));
|
||||
return pipeline->GetCachedDrawProgram(signature);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
#include "VertexArrayState/VertexArrayState.h"
|
||||
#include "RenderbufferState/RenderbufferState.h"
|
||||
|
||||
namespace MobileGL::MG_Util::ShaderTranspiler {
|
||||
struct CompileEnv;
|
||||
}
|
||||
|
||||
namespace MobileGL {
|
||||
namespace MG_State {
|
||||
void Init();
|
||||
@@ -149,6 +153,14 @@ namespace MobileGL {
|
||||
Bool ValidateShaderName(Uint index) const;
|
||||
const SharedPtr<ProgramObject>& GetProgramObject(Uint index);
|
||||
const SharedPtr<ShaderObject>& GetShaderObject(Uint index);
|
||||
// Settles every compile and link this context still owns; see
|
||||
// ProgramState::JoinAllPendingWork. Called by glMaxShaderCompilerThreadsKHR(0).
|
||||
void JoinAllPendingShaderWork();
|
||||
// P1 stage 6: the per-context index of adoptable compile nodes, for its
|
||||
// adoption counter. Diagnostics and tests only - no GL entry point reads it.
|
||||
ShaderCompileAdoptionMap& GetShaderCompileAdoptionMap() {
|
||||
return m_programState.GetShaderCompileAdoptionMap();
|
||||
}
|
||||
void UseProgram(Uint program);
|
||||
const SharedPtr<ProgramObject>& GetCurrentProgram();
|
||||
// What a draw or dispatch actually executes: the program in use, or - when
|
||||
@@ -380,6 +392,15 @@ namespace MobileGL {
|
||||
Bool ValidateRenderbufferName(Uint index) const;
|
||||
Bool ValidateRenderbufferObject(Uint index) const;
|
||||
|
||||
// P1: the shader compile/link pipeline's snapshot of everything it reads from
|
||||
// outside its own (stage, source) inputs. Captured lazily here because it
|
||||
// cannot be captured in MG_State::Init() - that runs BEFORE MG_Backend::Init(),
|
||||
// so there is no backend to query yet. Re-captured whenever the active backend
|
||||
// object changes, which also rolls the fingerprint and therefore invalidates
|
||||
// every P0b preprocess memo keyed against the old one.
|
||||
// GL thread only.
|
||||
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv>& GetCompileEnv();
|
||||
|
||||
private:
|
||||
// State Components
|
||||
ErrorState m_errorState;
|
||||
@@ -437,6 +458,11 @@ namespace MobileGL {
|
||||
FramebufferState m_framebufferState;
|
||||
SamplerState m_samplerState;
|
||||
RenderbufferState m_renderbufferState;
|
||||
|
||||
mutable SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> m_compileEnv;
|
||||
// Identity of the backend object m_compileEnv was captured against; a plain
|
||||
// pointer compare, never dereferenced.
|
||||
const void* m_compileEnvBackend = nullptr;
|
||||
};
|
||||
} // namespace GLState
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
#include <MG_State/GLState/ProgramState/ProgramObject.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderCompileTask.h>
|
||||
#include <MG_Util/Async/JobNode.h>
|
||||
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// One attached shader, as the link sees it: never the ShaderObject, always a snapshot.
|
||||
//
|
||||
// The ShaderObject is GL-thread-owned and may be re-sourced, detached or destroyed while
|
||||
// this link is still queued; everything below is either immutable or independently owned,
|
||||
// so none of that can reach the worker.
|
||||
struct LinkShaderInput {
|
||||
ShaderStage stage = ShaderStage::Unknown;
|
||||
// For the compile-error diagnostic and the compute local_size check, both of which
|
||||
// quote the ORIGINAL source rather than the preprocessed one.
|
||||
SharedPtr<const String> source;
|
||||
// The authoritative compiled state. Null, or non-Complete, both read as "this shader
|
||||
// did not compile" - the same verdict ShaderObject's join gate produces.
|
||||
SharedPtr<const ShaderCompileTask> compiled;
|
||||
};
|
||||
|
||||
// The unit of asynchronous linking: one glLinkProgram's worth of pure CPU work - glslang
|
||||
// link + mapIO, SPIR-V generation and optimization, the GL-facing reflection surface, the
|
||||
// global-UBO routing tables, fragment-output validation and transform-feedback
|
||||
// resolution - with every input it needs snapshotted at enqueue.
|
||||
//
|
||||
// Same ownership rule as ShaderCompileTask: the body reads nothing but `in` (all of it
|
||||
// owned or immutable) and writes nothing but `artifacts`. No GL call, no
|
||||
// pActiveBackendObject read, no pGLContext->RecordError(); the device limits arrive
|
||||
// through the CompileEnv snapshot and diagnostics are deferred to the join.
|
||||
//
|
||||
// ONE LINK IS ONE HANDLER. RunBody() runs start to finish inside a single pool handler
|
||||
// and is the only place `artifacts` is written. Do not split it across handlers to
|
||||
// "pipeline" the reflection half: the intermediates that GlslangToSpv and buildReflection
|
||||
// share are mutated in a strict order (see the GenerateSpirv-before-DoReflection comment
|
||||
// in Run()), and a second handler would let a cancel land between them and publish a
|
||||
// program whose SPIR-V and reflection describe different things.
|
||||
class ProgramLinkTask final : public MG_Util::Async::JobNode {
|
||||
public:
|
||||
// ---- inputs, snapshotted on the GL thread in ProgramObject::Link()'s prologue ----
|
||||
struct Inputs {
|
||||
Uint externalIndex = 0; // logs only
|
||||
Vector<LinkShaderInput> shaders; // already stage-sorted
|
||||
SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env;
|
||||
// The four "takes effect at the next link" request maps. Snapshotted rather than
|
||||
// referenced, which is precisely what makes glBindAttribLocation and friends
|
||||
// legal to call over a pending link without cancelling it: the pending link keeps
|
||||
// linking the inputs it was given.
|
||||
UnorderedMap<String, Uint> explicitAttribLocations; // glBindAttribLocation
|
||||
UnorderedMap<String, Uint> explicitFragDataLocation; // glBindFragDataLocation
|
||||
UnorderedMap<String, Uint> explicitFragDataIndex; // glBindFragDataLocationIndexed
|
||||
Vector<String> requestedXfbVaryings; // glTransformFeedbackVaryings
|
||||
GLenum requestedXfbBufferMode = GL_INTERLEAVED_ATTRIBS;
|
||||
Int maxFragmentOutputColorNumber = 8; // GL_MAX_DRAW_BUFFERS, stamped in by the entry point
|
||||
} in;
|
||||
|
||||
// ---- output: valid iff IsComplete(), immutable afterwards ----
|
||||
// Moved (never copied) into the ProgramObject by EnsureLinkJoined().
|
||||
ProgramObject::LinkArtifacts artifacts;
|
||||
|
||||
// Posts this job once every compile in `deps` is terminal - and not one moment
|
||||
// earlier, so the body never waits on anything (invariant I4: no job body may block
|
||||
// on another job, or the pool could deadlock with all its workers waiting on each
|
||||
// other). `deps` is the subset of the snapshot's compile nodes that were still
|
||||
// in flight; an already-terminal one needs no edge.
|
||||
//
|
||||
// GL thread only, and only after the caller has stored a SharedPtr to this node:
|
||||
// OnDepSettled takes shared_from_this().
|
||||
void SubmitAfter(const Vector<SharedPtr<ShaderCompileTask>>& deps);
|
||||
|
||||
private:
|
||||
void RunBody() override;
|
||||
|
||||
// Runs when one dependency goes terminal - on whichever thread drove it there, which
|
||||
// is a pool worker for a compile that finished on one. Non-throwing by construction;
|
||||
// see the definition.
|
||||
void OnDepSettled();
|
||||
|
||||
// ---- the link body, split exactly as ProgramObject::Link() had it ----
|
||||
// Each returns false to abort the link with `artifacts.infoLog` already set, which is
|
||||
// GL's definition of a failed link: LINK_STATUS false plus a log, never a GL error.
|
||||
Bool ConsumeShaders(Vector<SharedPtr<glslang::TShader>>& outShaders);
|
||||
Bool DoReflection(const MG_Util::ShaderTranspiler::CompileEnv& env);
|
||||
Bool ValidateFragmentOutputLocations();
|
||||
Bool ResolveTransformFeedbackVaryings();
|
||||
void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate);
|
||||
void GenerateSpirv();
|
||||
void BuildGlobalUboRouting();
|
||||
|
||||
// Worker-side MGLOG replacement: appended to diagnostics.logLines and replayed by the
|
||||
// join, on the GL thread, where a serial implementation would have printed it.
|
||||
// Logging straight from a worker interleaves mid-line with the GL thread's output and
|
||||
// lands out of order relative to the glLinkProgram that caused it.
|
||||
void DeferLog(String line);
|
||||
|
||||
// Counts down to zero exactly once. Starts at deps + 1: the extra guard is released
|
||||
// by SubmitAfter itself, so a dependency that settles while the edges are still being
|
||||
// registered cannot post the job from under a half-built dependency list.
|
||||
std::atomic<Int> m_remainingDeps{0};
|
||||
};
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,22 @@
|
||||
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// The link job. Only ever held by SharedPtr here, so a forward declaration is enough -
|
||||
// ProgramLinkTask.h includes THIS header (it outputs a LinkArtifacts), so including it
|
||||
// back would be circular. The destructor is therefore out of line.
|
||||
class ProgramLinkTask;
|
||||
|
||||
class ProgramObject {
|
||||
public:
|
||||
ProgramObject(Uint externalIndex) : m_externalIndex(externalIndex), m_lifetimeId(AllocateLifetimeId()) {}
|
||||
// Cancel-not-join, exactly like ~ShaderObject: the link job owns its inputs, so an
|
||||
// in-flight link whose program just went away is safe to abandon where it stands.
|
||||
// Nothing can observe its result any more - this object was the only route to it.
|
||||
// Out of line because ProgramLinkTask is incomplete here.
|
||||
~ProgramObject();
|
||||
ProgramObject(const ProgramObject&) = delete;
|
||||
ProgramObject& operator=(const ProgramObject&) = delete;
|
||||
|
||||
bool ShaderIsAttached(const SharedPtr<ShaderObject>& shader);
|
||||
// GL-visible attachment: in the attach list and not pending detach (glDetachShader
|
||||
// defers the actual removal to the next link).
|
||||
@@ -44,31 +57,39 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
Vector<SharedPtr<ShaderObject>>& GetAttachedShaders();
|
||||
const Vector<SharedPtr<ShaderObject>>& GetAttachedShaders() const;
|
||||
const String& GetInfoLog() const { return m_infoLog; }
|
||||
const String& GetInfoLog() const { return Artifacts().infoLog; }
|
||||
// glCreateShaderProgramv folds the shader's compile log into the program's log, which
|
||||
// is the only place a caller can read it from once the shader name is gone.
|
||||
void AppendInfoLog(const String& text) {
|
||||
if (text.empty()) return;
|
||||
if (!m_infoLog.empty() && m_infoLog.back() != '\n') m_infoLog += '\n';
|
||||
m_infoLog += text;
|
||||
if (!Artifacts().infoLog.empty() && Artifacts().infoLog.back() != '\n') Artifacts().infoLog += '\n';
|
||||
Artifacts().infoLog += text;
|
||||
}
|
||||
Int GetUniformMaxLength() const { return m_uniformNameMaxLength; }
|
||||
Uint GetUniformCount() const { return m_activeUniformCount; }
|
||||
Uint GetMaxUniformLocation() const { return m_maxUniformLocation; }
|
||||
Int GetUniformMaxLength() const { return Artifacts().uniformNameMaxLength; }
|
||||
Uint GetUniformCount() const { return Artifacts().activeUniformCount; }
|
||||
Uint GetMaxUniformLocation() const { return Artifacts().maxUniformLocation; }
|
||||
Int GetUniformLocation(const String& name) const {
|
||||
const auto it = m_uniformLocations.find(name);
|
||||
if (it != m_uniformLocations.end()) return (Int)it->second;
|
||||
const auto it = Artifacts().uniformLocations.find(name);
|
||||
if (it != Artifacts().uniformLocations.end()) return (Int)it->second;
|
||||
|
||||
// Reflection stores GL-style names: an array uniform is keyed "arr[0]" (its base
|
||||
// location). A bare "arr" query resolves to that entry; an "arr[k]" query resolves
|
||||
// to base + k because DoReflection reserves one location per array element.
|
||||
if (name.empty()) return -1;
|
||||
if (name.back() != ']') {
|
||||
const auto suffixedIt = m_uniformLocations.find(name + "[0]");
|
||||
if (suffixedIt != m_uniformLocations.end()) return (Int)suffixedIt->second;
|
||||
const auto suffixedIt = Artifacts().uniformLocations.find(name + "[0]");
|
||||
if (suffixedIt != Artifacts().uniformLocations.end()) return (Int)suffixedIt->second;
|
||||
return -1;
|
||||
}
|
||||
if (name.length() < 4) return -1;
|
||||
// An array of arrays is keyed by its full "[0]"-terminated spelling
|
||||
// ("a[2][1][0]"), so a query that already ends in a subscript may still be the
|
||||
// NAME of an array rather than an element of one. Try that first; only then
|
||||
// treat the trailing subscript as an element index.
|
||||
{
|
||||
const auto arrayOfArraysIt = Artifacts().uniformLocations.find(name + "[0]");
|
||||
if (arrayOfArraysIt != Artifacts().uniformLocations.end()) return (Int)arrayOfArraysIt->second;
|
||||
}
|
||||
const SizeT bracket = name.rfind('[');
|
||||
// Require at least one digit between the brackets.
|
||||
if (bracket == String::npos || bracket + 1 >= name.length() - 1) return -1;
|
||||
@@ -78,21 +99,21 @@ namespace MobileGL::MG_State::GLState {
|
||||
element = element * 10 + static_cast<Uint>(name[i] - '0');
|
||||
if (element > 0x0FFFFFFFu) return -1;
|
||||
}
|
||||
auto baseIt = m_uniformLocations.find(name.substr(0, bracket) + "[0]");
|
||||
if (baseIt == m_uniformLocations.end()) {
|
||||
auto baseIt = Artifacts().uniformLocations.find(name.substr(0, bracket) + "[0]");
|
||||
if (baseIt == Artifacts().uniformLocations.end()) {
|
||||
// Legacy key without the "[0]" suffix (defensive; reflection normally
|
||||
// stores the suffixed form for arrays).
|
||||
baseIt = m_uniformLocations.find(name.substr(0, bracket));
|
||||
if (baseIt == m_uniformLocations.end()) return -1;
|
||||
baseIt = Artifacts().uniformLocations.find(name.substr(0, bracket));
|
||||
if (baseIt == Artifacts().uniformLocations.end()) return -1;
|
||||
}
|
||||
const Int base = (Int)baseIt->second;
|
||||
if (!IsValidUniformLocation(base)) return -1;
|
||||
const Int index = m_uniformIndexInTProgram[base];
|
||||
const Int index = Artifacts().uniformIndexInTProgram[base];
|
||||
// "[k]" only addresses arrays ("scalar[0]" is not a uniform name), and only
|
||||
// in-range elements.
|
||||
const glslang::TType* type = m_program->getUniform(index).getType();
|
||||
const glslang::TType* type = Artifacts().program->getUniform(index).getType();
|
||||
if (type == nullptr || !type->isArray()) return -1;
|
||||
if (static_cast<GLint>(element) >= GetActiveUniformArraySize(index)) return -1;
|
||||
if (static_cast<GLint>(element) >= GetUniformArraySizeByTIndex(index)) return -1;
|
||||
const Int location = base + (Int)element;
|
||||
if (!UniformLocationsAliasSameUniform(base, location)) return -1;
|
||||
return location;
|
||||
@@ -101,14 +122,41 @@ namespace MobileGL::MG_State::GLState {
|
||||
// True when both locations are element slots of the same uniform variable.
|
||||
Bool UniformLocationsAliasSameUniform(Int a, Int b) const {
|
||||
if (!IsValidUniformLocation(a) || !IsValidUniformLocation(b)) return false;
|
||||
return m_uniformIndexInTProgram[a] == m_uniformIndexInTProgram[b];
|
||||
return Artifacts().uniformIndexInTProgram[a] == Artifacts().uniformIndexInTProgram[b];
|
||||
}
|
||||
|
||||
// ---- GL index <-> glslang TProgram index translation ----
|
||||
// The single relaxed parse enumerates artifacts GL must not see: every declared
|
||||
// default-block uniform (even dead ones) as a member of the synthesized
|
||||
// MGL_GLOBAL_UBO, and that block itself. DoReflection builds filtered GL-facing
|
||||
// index spaces; every public "index"-taking getter translates through them, so
|
||||
// GL and backend consumers keep seeing exactly the pre-P0a surface.
|
||||
Int TProgramUniformIndex(Uint glIndex) const {
|
||||
return Artifacts().glUniformIndexToTProgram[glIndex];
|
||||
}
|
||||
Int GlUniformIndexFromTProgram(Int tIndex) const {
|
||||
if (tIndex < 0 || tIndex >= static_cast<Int>(Artifacts().tProgramUniformIndexToGl.size())) return -1;
|
||||
return Artifacts().tProgramUniformIndexToGl[tIndex];
|
||||
}
|
||||
// GL uniform-block index -> glslang TProgram block index (the inverse of
|
||||
// GlBlockIndexFromTProgram). The interface-query layer needs it to reach block
|
||||
// properties glslang exposes but no typed getter here does.
|
||||
Int TProgramBlockIndex(Uint glBlockIndex) const {
|
||||
return glBlockIndex < Artifacts().glBlockIndexToTProgram.size()
|
||||
? Artifacts().glBlockIndexToTProgram[glBlockIndex]
|
||||
: -1;
|
||||
}
|
||||
Int GlBlockIndexFromTProgram(Int tBlockIndex) const {
|
||||
if (tBlockIndex < 0 || tBlockIndex >= static_cast<Int>(Artifacts().tProgramBlockIndexToGl.size())) return -1;
|
||||
return Artifacts().tProgramBlockIndexToGl[tBlockIndex];
|
||||
}
|
||||
|
||||
Int GetActiveUniformIndex(const String& name) const {
|
||||
const Int uniformIndex = m_program->getUniformIndex(name.c_str());
|
||||
if (uniformIndex >= 0 && uniformIndex < m_activeUniformCount &&
|
||||
m_program->getUniform(uniformIndex).name == name) {
|
||||
return uniformIndex;
|
||||
const Int tProgramCount = static_cast<Int>(Artifacts().tProgramUniformIndexToGl.size());
|
||||
const Int uniformIndex = Artifacts().program->getUniformIndex(name.c_str());
|
||||
if (uniformIndex >= 0 && uniformIndex < tProgramCount &&
|
||||
Artifacts().program->getUniform(uniformIndex).name == name) {
|
||||
return GlUniformIndexFromTProgram(uniformIndex);
|
||||
}
|
||||
|
||||
// Reflection stores an array uniform under "arr[0]"; accept the bare "arr"
|
||||
@@ -116,61 +164,60 @@ namespace MobileGL::MG_State::GLState {
|
||||
// robustness against non-suffixed reflection entries.
|
||||
if (!name.empty() && name.back() != ']') {
|
||||
const String suffixedName = name + "[0]";
|
||||
const Int suffixedIndex = m_program->getUniformIndex(suffixedName.c_str());
|
||||
if (suffixedIndex >= 0 && suffixedIndex < m_activeUniformCount &&
|
||||
m_program->getUniform(suffixedIndex).name == suffixedName) {
|
||||
return suffixedIndex;
|
||||
const Int suffixedIndex = Artifacts().program->getUniformIndex(suffixedName.c_str());
|
||||
if (suffixedIndex >= 0 && suffixedIndex < tProgramCount &&
|
||||
Artifacts().program->getUniform(suffixedIndex).name == suffixedName) {
|
||||
return GlUniformIndexFromTProgram(suffixedIndex);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (name.length() <= 3 || name.compare(name.length() - 3, 3, "[0]") != 0) return -1;
|
||||
const String baseName = name.substr(0, name.length() - 3);
|
||||
const Int baseIndex = m_program->getUniformIndex(baseName.c_str());
|
||||
if (baseIndex < 0 || baseIndex >= m_activeUniformCount) return -1;
|
||||
return m_program->getUniform(baseIndex).name == baseName ? baseIndex : -1;
|
||||
const Int baseIndex = Artifacts().program->getUniformIndex(baseName.c_str());
|
||||
if (baseIndex < 0 || baseIndex >= tProgramCount) return -1;
|
||||
return Artifacts().program->getUniform(baseIndex).name == baseName ? GlUniformIndexFromTProgram(baseIndex)
|
||||
: -1;
|
||||
}
|
||||
|
||||
Bool IsValidUniformLocation(Int location) const {
|
||||
if (location < 0 || location > static_cast<Int>(m_maxUniformLocation)) return false;
|
||||
if (static_cast<SizeT>(location) >= m_uniformIndexInTProgram.size()) return false;
|
||||
const Int uniformIndexInProgram = m_uniformIndexInTProgram[location];
|
||||
return uniformIndexInProgram != glslang::TQualifier::layoutLocationEnd &&
|
||||
uniformIndexInProgram >= 0 && uniformIndexInProgram < m_activeUniformCount;
|
||||
}
|
||||
Bool IsValidUniformLocation(Int location) const { return IsValidUniformLocation(Artifacts(), location); }
|
||||
|
||||
GLenum GetUniformType(Uint location) const {
|
||||
auto& uniform = m_program->getUniform(m_uniformIndexInTProgram[location]);
|
||||
auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]);
|
||||
return uniform.glDefineType;
|
||||
}
|
||||
|
||||
GLenum GetActiveUniformType(Uint index) const {
|
||||
auto& uniform = m_program->getUniform(static_cast<Int>(index));
|
||||
auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
|
||||
return uniform.glDefineType;
|
||||
}
|
||||
|
||||
// Number of active array elements (GL_UNIFORM_SIZE / GL_ARRAY_SIZE); 1 for a non-array.
|
||||
// glslang's TObjectReflection.size only carries the element count for a NON-block array; for
|
||||
// a block array member it reports 1, so take the count from the TType, which is authoritative
|
||||
// for both. GL 3.3 core uniforms are always sized.
|
||||
GLint GetActiveUniformArraySize(Uint index) const {
|
||||
const auto& uniform = m_program->getUniform(static_cast<Int>(index));
|
||||
const glslang::TType* type = uniform.getType();
|
||||
if (type != nullptr && type->isSizedArray()) {
|
||||
return type->getOuterArraySize();
|
||||
// for both. GL 3.3 core uniforms are always sized. Takes a TProgram uniform index (the space
|
||||
// the artifacts' uniformIndexInTProgram stores).
|
||||
GLint GetUniformArraySizeByTIndex(Int tIndex) const {
|
||||
return GetUniformArraySizeByTIndex(Artifacts(), tIndex);
|
||||
}
|
||||
return uniform.size < 1 ? 1 : uniform.size;
|
||||
|
||||
GLint GetActiveUniformArraySize(Uint index) const {
|
||||
return GetUniformArraySizeByTIndex(TProgramUniformIndex(index));
|
||||
}
|
||||
|
||||
Int GetActiveUniformBlockIndex(Uint index) const {
|
||||
auto& uniform = m_program->getUniform(static_cast<Int>(index));
|
||||
return uniform.index;
|
||||
auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
|
||||
// Members of the synthesized global UBO are default-block uniforms to GL: -1.
|
||||
return GlBlockIndexFromTProgram(uniform.index);
|
||||
}
|
||||
|
||||
// GL_UNIFORM_OFFSET: byte offset within the owning named block. glslang already reports -1
|
||||
// for a default-block uniform, which is exactly the spec value there.
|
||||
// GL_UNIFORM_OFFSET: byte offset within the owning named block; -1 for a default-block
|
||||
// uniform. The relaxed parse gives global-UBO members real byte offsets, but GL must keep
|
||||
// seeing them as default-block uniforms, so gate on the GL-visible block index.
|
||||
GLint GetActiveUniformOffset(Uint index) const {
|
||||
return m_program->getUniform(static_cast<Int>(index)).offset;
|
||||
const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
|
||||
if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1;
|
||||
return uniform.offset;
|
||||
}
|
||||
|
||||
// GL_UNIFORM_ARRAY_STRIDE: byte stride of an array member in a named block; 0 for a non-array
|
||||
@@ -182,8 +229,8 @@ namespace MobileGL::MG_State::GLState {
|
||||
// generated SPIR-V lay the array out with std140 16-byte-rounded strides. MobileGL's UBO
|
||||
// layout is always std140, where every array element stride rounds up to a vec4.
|
||||
GLint GetActiveUniformArrayStride(Uint index) const {
|
||||
const auto& uniform = m_program->getUniform(static_cast<Int>(index));
|
||||
if (uniform.index < 0) return -1;
|
||||
const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
|
||||
if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1;
|
||||
const glslang::TType* type = uniform.getType();
|
||||
if (type == nullptr || !type->isArray()) return 0;
|
||||
if (type->isMatrix()) {
|
||||
@@ -202,13 +249,13 @@ namespace MobileGL::MG_State::GLState {
|
||||
// check suffices; the getUniformBlock() fallback is defensive for a config that instead leaves
|
||||
// an inheriting member's layoutMatrix == ElmNone.
|
||||
GLint GetActiveUniformIsRowMajor(Uint index) const {
|
||||
const auto& uniform = m_program->getUniform(static_cast<Int>(index));
|
||||
if (uniform.index < 0) return 0;
|
||||
const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
|
||||
if (GlBlockIndexFromTProgram(uniform.index) < 0) return 0;
|
||||
const glslang::TType* type = uniform.getType();
|
||||
if (type == nullptr || !type->isMatrix()) return 0;
|
||||
glslang::TLayoutMatrix layoutMatrix = type->getQualifier().layoutMatrix;
|
||||
if (layoutMatrix == glslang::ElmNone) {
|
||||
layoutMatrix = m_program->getUniformBlock(uniform.index).getType()->getQualifier().layoutMatrix;
|
||||
layoutMatrix = Artifacts().program->getUniformBlock(uniform.index).getType()->getQualifier().layoutMatrix;
|
||||
}
|
||||
return (layoutMatrix == glslang::ElmRowMajor) ? 1 : 0;
|
||||
}
|
||||
@@ -220,13 +267,13 @@ namespace MobileGL::MG_State::GLState {
|
||||
// out as std140 (packed/shared are coerced), so this matches the offsets glslang reports. For
|
||||
// every GL 3.3 float matrix this evaluates to 16, independent of majorness.
|
||||
GLint GetActiveUniformMatrixStride(Uint index) const {
|
||||
const auto& uniform = m_program->getUniform(static_cast<Int>(index));
|
||||
if (uniform.index < 0) return -1;
|
||||
const auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
|
||||
if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1;
|
||||
const glslang::TType* type = uniform.getType();
|
||||
if (type == nullptr || !type->isMatrix()) return 0;
|
||||
glslang::TLayoutMatrix layoutMatrix = type->getQualifier().layoutMatrix;
|
||||
if (layoutMatrix == glslang::ElmNone) {
|
||||
layoutMatrix = m_program->getUniformBlock(uniform.index).getType()->getQualifier().layoutMatrix;
|
||||
layoutMatrix = Artifacts().program->getUniformBlock(uniform.index).getType()->getQualifier().layoutMatrix;
|
||||
}
|
||||
const bool rowMajor = (layoutMatrix == glslang::ElmRowMajor);
|
||||
const int strideVectorComponents = rowMajor ? type->getMatrixCols() : type->getMatrixRows();
|
||||
@@ -238,50 +285,50 @@ namespace MobileGL::MG_State::GLState {
|
||||
}
|
||||
|
||||
const glslang::TType* GetUniformTType(Uint location) const {
|
||||
auto& uniform = m_program->getUniform(m_uniformIndexInTProgram[location]);
|
||||
auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]);
|
||||
return uniform.getType();
|
||||
}
|
||||
|
||||
Bool IsUniformOpaqueAtLocation(Uint location) const { return GetUniformTType(location)->isOpaque(); }
|
||||
|
||||
const String& GetUniformName(Uint location) const {
|
||||
auto& uniform = m_program->getUniform(m_uniformIndexInTProgram[location]);
|
||||
auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]);
|
||||
return uniform.name;
|
||||
}
|
||||
|
||||
const String& GetActiveUniformName(Uint index) const {
|
||||
auto& uniform = m_program->getUniform(static_cast<Int>(index));
|
||||
auto& uniform = Artifacts().program->getUniform(TProgramUniformIndex(index));
|
||||
return uniform.name;
|
||||
}
|
||||
// Sentinel for a uniform location without global-UBO backing storage (should not
|
||||
// survive linking: GenerateBinary falls back to tail-allocated scratch storage).
|
||||
static constexpr Uint kInvalidUniformOffset = ~0u;
|
||||
Uint GetUniformOffset(Uint location) const { return m_uniformOffsets[location]; }
|
||||
Uint GetUniformOffset(Uint location) const { return Artifacts().uniformOffsets[location]; }
|
||||
Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); }
|
||||
|
||||
Int GetAttributeLocation(const String& name) {
|
||||
const auto it = std::find(m_attribs.begin(), m_attribs.end(), name);
|
||||
return (it == m_attribs.end()) ? -1 : (Int)std::distance(m_attribs.begin(), it);
|
||||
const auto it = std::find(Artifacts().attribs.begin(), Artifacts().attribs.end(), name);
|
||||
return (it == Artifacts().attribs.end()) ? -1 : (Int)std::distance(Artifacts().attribs.begin(), it);
|
||||
}
|
||||
Uint32 GetActiveAttributeLocationMask() const {
|
||||
Uint32 mask = 0;
|
||||
const SizeT count = std::min<SizeT>(m_attribs.size(), 32);
|
||||
const SizeT count = std::min<SizeT>(Artifacts().attribs.size(), 32);
|
||||
for (SizeT index = 0; index < count; ++index) {
|
||||
if (!m_attribs[index].empty()) {
|
||||
if (!Artifacts().attribs[index].empty()) {
|
||||
mask |= (1u << index);
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
Uint32 GetActiveFragmentOutputLocationMask() const {
|
||||
if (!m_program) {
|
||||
if (!Artifacts().program) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
Uint32 mask = 0;
|
||||
const Int outputCount = m_program->getNumPipeOutputs();
|
||||
const Int outputCount = Artifacts().program->getNumPipeOutputs();
|
||||
for (Int index = 0; index < outputCount; ++index) {
|
||||
const Int location = static_cast<Int>(m_program->getPipeOutput(index).layoutLocation());
|
||||
const Int location = static_cast<Int>(Artifacts().program->getPipeOutput(index).layoutLocation());
|
||||
if (location >= 0 && location < 32) {
|
||||
mask |= (1u << location);
|
||||
}
|
||||
@@ -289,47 +336,59 @@ namespace MobileGL::MG_State::GLState {
|
||||
return mask;
|
||||
}
|
||||
Int GetActiveFragmentOutputCount() const {
|
||||
return m_program ? m_program->getNumPipeOutputs() : 0;
|
||||
return Artifacts().program ? Artifacts().program->getNumPipeOutputs() : 0;
|
||||
}
|
||||
const String& GetActiveFragmentOutputName(Uint index) const {
|
||||
MOBILEGL_ASSERT(m_program != nullptr, "ProgramObject::GetActiveFragmentOutputName: program is null");
|
||||
MOBILEGL_ASSERT(index < static_cast<Uint>(m_program->getNumPipeOutputs()),
|
||||
MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetActiveFragmentOutputName: program is null");
|
||||
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
|
||||
"ProgramObject::GetActiveFragmentOutputName: index=%u out of range", index);
|
||||
return m_program->getPipeOutput(static_cast<Int>(index)).name;
|
||||
return Artifacts().program->getPipeOutput(static_cast<Int>(index)).name;
|
||||
}
|
||||
Int GetFragmentOutputLocation(Uint index) const {
|
||||
MOBILEGL_ASSERT(m_program != nullptr, "ProgramObject::GetFragmentOutputLocation: program is null");
|
||||
MOBILEGL_ASSERT(index < static_cast<Uint>(m_program->getNumPipeOutputs()),
|
||||
MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetFragmentOutputLocation: program is null");
|
||||
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
|
||||
"ProgramObject::GetFragmentOutputLocation: index=%u out of range",
|
||||
index);
|
||||
return static_cast<Int>(m_program->getPipeOutput(static_cast<Int>(index)).layoutLocation());
|
||||
return static_cast<Int>(Artifacts().program->getPipeOutput(static_cast<Int>(index)).layoutLocation());
|
||||
}
|
||||
GLint GetActiveFragmentOutputArraySize(Uint index) const {
|
||||
MOBILEGL_ASSERT(m_program != nullptr, "ProgramObject::GetActiveFragmentOutputArraySize: program is null");
|
||||
MOBILEGL_ASSERT(index < static_cast<Uint>(m_program->getNumPipeOutputs()),
|
||||
MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetActiveFragmentOutputArraySize: program is null");
|
||||
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
|
||||
"ProgramObject::GetActiveFragmentOutputArraySize: index=%u out of range", index);
|
||||
return m_program->getPipeOutput(static_cast<Int>(index)).size;
|
||||
return Artifacts().program->getPipeOutput(static_cast<Int>(index)).size;
|
||||
}
|
||||
GLenum GetFragmentOutputType(Uint index) const {
|
||||
MOBILEGL_ASSERT(m_program != nullptr, "ProgramObject::GetFragmentOutputType: program is null");
|
||||
MOBILEGL_ASSERT(index < static_cast<Uint>(m_program->getNumPipeOutputs()),
|
||||
MOBILEGL_ASSERT(Artifacts().program != nullptr, "ProgramObject::GetFragmentOutputType: program is null");
|
||||
MOBILEGL_ASSERT(index < static_cast<Uint>(Artifacts().program->getNumPipeOutputs()),
|
||||
"ProgramObject::GetFragmentOutputType: index=%u out of range",
|
||||
index);
|
||||
return m_program->getPipeOutput(static_cast<Int>(index)).glDefineType;
|
||||
return Artifacts().program->getPipeOutput(static_cast<Int>(index)).glDefineType;
|
||||
}
|
||||
GLenum GetAttribType(Uint index) const { return m_attribTypes[index]; }
|
||||
const String& GetAttribName(Uint index) const { return m_attribs[index]; }
|
||||
GLenum GetActiveAttribType(Uint index) const { return m_program->getPipeInput(static_cast<Int>(index)).glDefineType; }
|
||||
GLint GetActiveAttribArraySize(Uint index) const { return m_program->getPipeInput(static_cast<Int>(index)).size; }
|
||||
const String& GetActiveAttribName(Uint index) const { return m_program->getPipeInput(static_cast<Int>(index)).name; }
|
||||
void* MapUBO() { return m_globalUboScratch.data(); }
|
||||
const void* GetUBOData() const { return m_globalUboScratch.data(); }
|
||||
Uint GetUBOSize() const { return static_cast<Uint>(m_globalUboScratch.size()); }
|
||||
GLenum GetAttribType(Uint index) const { return Artifacts().attribTypes[index]; }
|
||||
const String& GetAttribName(Uint index) const { return Artifacts().attribs[index]; }
|
||||
GLenum GetActiveAttribType(Uint index) const { return Artifacts().program->getPipeInput(static_cast<Int>(index)).glDefineType; }
|
||||
GLint GetActiveAttribArraySize(Uint index) const { return Artifacts().program->getPipeInput(static_cast<Int>(index)).size; }
|
||||
// The Vulkan-semantics parse reflects the vertex builtins under their SPIR-V names;
|
||||
// GL must keep reporting the GL spellings (glGetActiveAttrib and the program-input
|
||||
// resource queries enumerate builtins).
|
||||
static const String& NormalizeBuiltinPipeInputName(const String& name) {
|
||||
static const String kGlVertexId = "gl_VertexID";
|
||||
static const String kGlInstanceId = "gl_InstanceID";
|
||||
if (name == "gl_VertexIndex") return kGlVertexId;
|
||||
if (name == "gl_InstanceIndex") return kGlInstanceId;
|
||||
return name;
|
||||
}
|
||||
const String& GetActiveAttribName(Uint index) const {
|
||||
return NormalizeBuiltinPipeInputName(Artifacts().program->getPipeInput(static_cast<Int>(index)).name);
|
||||
}
|
||||
void* MapUBO() { return Artifacts().globalUboScratch.data(); }
|
||||
const void* GetUBOData() const { return Artifacts().globalUboScratch.data(); }
|
||||
Uint GetUBOSize() const { return static_cast<Uint>(Artifacts().globalUboScratch.size()); }
|
||||
// Content version of the CPU-side global-UBO shadow: writers bump it so backends
|
||||
// can skip re-uploading an unchanged UBO on every draw. ~0u is reserved as the
|
||||
// backends' "never uploaded" sentinel, so skip over it on wrap.
|
||||
Uint32 GetUBOContentVersion() const { return m_uboContentVersion; }
|
||||
void MarkUBOContentDirty() {
|
||||
void MarkUBOContentDirty() const {
|
||||
if (++m_uboContentVersion == ~0u) m_uboContentVersion = 0;
|
||||
}
|
||||
Uint32 GetBackendStateVersion() const { return m_backendStateVersion; }
|
||||
@@ -370,20 +429,20 @@ namespace MobileGL::MG_State::GLState {
|
||||
}
|
||||
|
||||
void SetUniformSamplerOrImageUnitIndex(Uint location, Int unit) {
|
||||
if (location >= m_uniformSamplerOrImageUnitIndex.size() ||
|
||||
m_uniformSamplerOrImageUnitIndex[location] == unit) {
|
||||
if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size() ||
|
||||
Artifacts().uniformSamplerOrImageUnitIndex[location] == unit) {
|
||||
return;
|
||||
}
|
||||
m_uniformSamplerOrImageUnitIndex[location] = unit;
|
||||
Artifacts().uniformSamplerOrImageUnitIndex[location] = unit;
|
||||
++m_backendStateVersion;
|
||||
}
|
||||
|
||||
Int GetUniformSamplerOrImageUnitIndex(Uint location) const {
|
||||
return m_uniformSamplerOrImageUnitIndex[location];
|
||||
return Artifacts().uniformSamplerOrImageUnitIndex[location];
|
||||
}
|
||||
|
||||
Bool GetDeleteStatus() const { return m_deleteStatus; }
|
||||
Bool GetLinkStatus() const { return m_linkStatus; }
|
||||
Bool GetLinkStatus() const { return Artifacts().linkStatus; }
|
||||
// GL_PROGRAM_BINARY_RETRIEVABLE_HINT. MobileGL exposes no program binary format
|
||||
// (GL_NUM_PROGRAM_BINARY_FORMATS is 0), so the hint is pure state - which is all
|
||||
// ARB_get_program_binary requires of it.
|
||||
@@ -397,24 +456,32 @@ namespace MobileGL::MG_State::GLState {
|
||||
// glProgramBinary always fails here (there is no format it could accept) and the
|
||||
// spec then requires the program's LINK_STATUS to read FALSE.
|
||||
void MarkLinkFailedByProgramBinary() {
|
||||
ResetLinkArtifacts();
|
||||
m_infoLog = "No program binary format is supported.";
|
||||
// Before anything reads m_artifacts: a pending link would otherwise publish its
|
||||
// (possibly successful) result over the failure this call is required to install
|
||||
// - and Artifacts() below would be the thing that let it. Cancel-not-join: GL
|
||||
// gives glProgramBinary no reason to wait for a link it is about to invalidate.
|
||||
CancelLink();
|
||||
BumpLinkObservableVersions();
|
||||
ResetLinkArtifacts(Artifacts());
|
||||
Artifacts().infoLog = "No program binary format is supported.";
|
||||
}
|
||||
Bool GetValidateStatus() const { return m_validateStatus; }
|
||||
Int GetActiveAtomicCounterCount() const { return m_program->getNumAtomicCounters(); }
|
||||
Int GetActiveAttributesCount() const { return m_program->getNumPipeInputs(); }
|
||||
Int GetActiveUniformBlocksCount() const { return m_program->getNumUniformBlocks(); }
|
||||
GLuint GetComputeLocalSize(Uint dim) const { return m_program->getLocalSize(static_cast<Int>(dim)); }
|
||||
Int GetActiveAttributesMaxLength() const { return m_attribInNameMaxLength; }
|
||||
Int GetActiveUniformBlocksMaxNameLength() const { return m_uniformBlockNameMaxLength; }
|
||||
Int GetActiveAtomicCounterCount() const { return Artifacts().program->getNumAtomicCounters(); }
|
||||
Int GetActiveAttributesCount() const { return Artifacts().program->getNumPipeInputs(); }
|
||||
// GL-visible uniform blocks only: the synthesized MGL_GLOBAL_UBO the relaxed parse
|
||||
// materializes for default-block uniforms is filtered out by DoReflection.
|
||||
Int GetActiveUniformBlocksCount() const { return static_cast<Int>(Artifacts().glBlockIndexToTProgram.size()); }
|
||||
GLuint GetComputeLocalSize(Uint dim) const { return Artifacts().program->getLocalSize(static_cast<Int>(dim)); }
|
||||
Int GetActiveAttributesMaxLength() const { return Artifacts().attribInNameMaxLength; }
|
||||
Int GetActiveUniformBlocksMaxNameLength() const { return Artifacts().uniformBlockNameMaxLength; }
|
||||
Uint GetUniformBlockIndex(const char* name) const {
|
||||
auto it = m_uniformBlockIndexByName.find(name);
|
||||
if (it != m_uniformBlockIndexByName.end()) return it->second;
|
||||
auto it = Artifacts().uniformBlockIndexByName.find(name);
|
||||
if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
|
||||
// Instances of an arrayed block are reflected as "Block[0]".."Block[N-1]";
|
||||
// a bare "Block" query resolves to the first instance per GL semantics.
|
||||
const String suffixedName = String(name) + "[0]";
|
||||
it = m_uniformBlockIndexByName.find(suffixedName);
|
||||
if (it != m_uniformBlockIndexByName.end()) return it->second;
|
||||
it = Artifacts().uniformBlockIndexByName.find(suffixedName);
|
||||
if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
|
||||
return 0xFFFFFFFFu; // GL_INVALID_INDEX
|
||||
}
|
||||
Bool IsActiveUniformBlock(Uint index) const {
|
||||
@@ -427,11 +494,11 @@ namespace MobileGL::MG_State::GLState {
|
||||
// (like a std140 struct) occupies a vec4-rounded size, and that is what the
|
||||
// backend compiles: ES drivers reject draws whose bound UBO range is smaller
|
||||
// than the block (a block ending in ivec3 reported 12 while the driver needs 16).
|
||||
return (m_program->getUniformBlock((Int)index).size + 15u) & ~15u;
|
||||
return (Artifacts().program->getUniformBlock(Artifacts().glBlockIndexToTProgram[index]).size + 15u) & ~15u;
|
||||
}
|
||||
|
||||
const String& GetUniformBlockName(Uint index) const {
|
||||
auto& ubo = m_program->getUniformBlock((Int)index);
|
||||
auto& ubo = Artifacts().program->getUniformBlock(Artifacts().glBlockIndexToTProgram[index]);
|
||||
return ubo.name;
|
||||
}
|
||||
|
||||
@@ -443,8 +510,8 @@ namespace MobileGL::MG_State::GLState {
|
||||
if (name.empty() || name.back() != ']') return index;
|
||||
const SizeT bracket = name.rfind('[');
|
||||
if (bracket == String::npos) return index;
|
||||
const auto it = m_uniformBlockIndexByName.find(name.substr(0, bracket) + "[0]");
|
||||
if (it != m_uniformBlockIndexByName.end()) return it->second;
|
||||
const auto it = Artifacts().uniformBlockIndexByName.find(name.substr(0, bracket) + "[0]");
|
||||
if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
|
||||
return index;
|
||||
}
|
||||
|
||||
@@ -455,31 +522,64 @@ namespace MobileGL::MG_State::GLState {
|
||||
Int GetUniformBlockActiveUniformCount(Uint index) const {
|
||||
const Int ownerIndex = static_cast<Int>(GetUniformBlockMemberOwnerIndex(index));
|
||||
Int count = 0;
|
||||
for (Uint uniformIndex = 0; uniformIndex < m_activeUniformCount; ++uniformIndex) {
|
||||
for (Uint uniformIndex = 0; uniformIndex < Artifacts().activeUniformCount; ++uniformIndex) {
|
||||
if (GetActiveUniformBlockIndex(uniformIndex) == ownerIndex) ++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
Bool IsUniformBlockReferencedByStage(Uint index, EShLanguage stage) const {
|
||||
const auto& ubo = m_program->getUniformBlock((Int)index);
|
||||
const auto& ubo = Artifacts().program->getUniformBlock(Artifacts().glBlockIndexToTProgram[index]);
|
||||
const auto stageMask = static_cast<EShLanguageMask>(1 << stage);
|
||||
return (ubo.stages & stageMask) != 0;
|
||||
}
|
||||
|
||||
// Set by glUniformBlockBinding
|
||||
void SetUniformBlockBinding(Uint index, Uint binding) {
|
||||
if (index >= m_uniformBlockBinding.size() || m_uniformBlockBinding[index] == static_cast<Int>(binding)) {
|
||||
if (index >= Artifacts().uniformBlockBinding.size() || Artifacts().uniformBlockBinding[index] == static_cast<Int>(binding)) {
|
||||
return;
|
||||
}
|
||||
m_uniformBlockBinding[index] = static_cast<Int>(binding);
|
||||
Artifacts().uniformBlockBinding[index] = static_cast<Int>(binding);
|
||||
++m_backendStateVersion;
|
||||
}
|
||||
|
||||
Uint GetUniformBlockBinding(Uint index) const { return m_uniformBlockBinding[index]; }
|
||||
Uint GetUniformBlockBinding(Uint index) const { return Artifacts().uniformBlockBinding[index]; }
|
||||
|
||||
Vector<Vector<unsigned>>& GetGeneratedSpirv() { return m_generatedSpirv; }
|
||||
const Vector<Vector<unsigned>>& GetGeneratedSpirv() const { return m_generatedSpirv; }
|
||||
// Set by glShaderStorageBlockBinding, keyed by the block's GL name rather than by any
|
||||
// index. A shader storage block has THREE index spaces - the frontend interface-query
|
||||
// enumeration, DirectVulkan's SPIR-V descriptor order and DirectGLES's real-driver
|
||||
// order - and the name is the only coordinate all three agree on. Absent from the map
|
||||
// means "never rebound", and the shader's declared binding still stands.
|
||||
void SetShaderStorageBlockBinding(const String& blockName, Uint binding) {
|
||||
Artifacts().shaderStorageBlockBinding[blockName] = static_cast<Int>(binding);
|
||||
}
|
||||
// -1 when the block has never been rebound. `blockName` is the interface-query
|
||||
// spelling; an arrayed block's elements ("B[0]", "B[1]") are separate GL resources
|
||||
// with separate bindings, so they are separate keys.
|
||||
Int GetShaderStorageBlockBindingOverride(const String& blockName) const {
|
||||
const auto it = Artifacts().shaderStorageBlockBinding.find(blockName);
|
||||
if (it != Artifacts().shaderStorageBlockBinding.end()) return it->second;
|
||||
// A backend that collapses an arrayed block down to one resource knows it only by
|
||||
// the bare block name; answer that with element zero's binding.
|
||||
const auto zeroth = Artifacts().shaderStorageBlockBinding.find(blockName + "[0]");
|
||||
return zeroth != Artifacts().shaderStorageBlockBinding.end() ? zeroth->second : -1;
|
||||
}
|
||||
// Every rebinding recorded so far, for a backend that has to REPLAY them onto a
|
||||
// driver program it just (re)built. Empty for the overwhelming majority of programs -
|
||||
// check .empty() before doing any per-block work.
|
||||
const UnorderedMap<String, Int>& GetShaderStorageBlockBindingOverrides() const {
|
||||
return Artifacts().shaderStorageBlockBinding;
|
||||
}
|
||||
|
||||
Vector<Vector<unsigned>>& GetGeneratedSpirv() { return Artifacts().generatedSpirv; }
|
||||
const Vector<Vector<unsigned>>& GetGeneratedSpirv() const { return Artifacts().generatedSpirv; }
|
||||
|
||||
// The linked glslang reflection itself, for the ONE consumer that needs resource
|
||||
// lists no typed getter above exposes: the GL program-interface query layer
|
||||
// (MG_Impl/GLImpl/Program/ProgramInterface.cpp), which has to enumerate buffer
|
||||
// blocks, buffer variables, atomic counters and per-stage reference masks. Null
|
||||
// until a link has succeeded. Read through the join gate like everything else.
|
||||
const glslang::TProgram* GetReflection() const { return Artifacts().program.get(); }
|
||||
|
||||
Int GetShaderIndexByStage(ShaderStage stage) const {
|
||||
auto it = std::find_if(m_shaders.begin(), m_shaders.end(), [stage](const SharedPtr<ShaderObject>& shader) {
|
||||
@@ -501,95 +601,54 @@ namespace MobileGL::MG_State::GLState {
|
||||
// layout captures into; see NeedsScatteredTransformFeedbackCapture.
|
||||
Uint32 packedOffsetBytes = 0;
|
||||
};
|
||||
void SetTransformFeedbackVaryings(Vector<String>&& names, GLenum bufferMode) {
|
||||
m_requestedXfbVaryings = Move(names);
|
||||
m_requestedXfbBufferMode = bufferMode;
|
||||
}
|
||||
GLenum GetTransformFeedbackBufferMode() const { return m_xfbBufferMode; }
|
||||
SizeT GetTransformFeedbackVaryingCount() const { return m_xfbVaryings.size(); }
|
||||
const XfbVarying* GetTransformFeedbackVarying(SizeT index) const {
|
||||
return index < m_xfbVaryings.size() ? &m_xfbVaryings[index] : nullptr;
|
||||
}
|
||||
const Vector<XfbVarying>& GetTransformFeedbackVaryings() const { return m_xfbVaryings; }
|
||||
// Stride of one captured vertex in the given capture buffer slot.
|
||||
Uint32 GetTransformFeedbackStride(Uint32 bufferIndex) const {
|
||||
return bufferIndex < m_xfbStrides.size() ? m_xfbStrides[bufferIndex] : 0;
|
||||
}
|
||||
SizeT GetTransformFeedbackBufferCount() const { return m_xfbStrides.size(); }
|
||||
Int GetTransformFeedbackVaryingMaxLength() const { return m_xfbVaryingNameMaxLength; }
|
||||
// True when the capture layout uses gl_SkipComponents / gl_NextBuffer
|
||||
// (ARB_transform_feedback3), which no ES driver can express: it can only pack every
|
||||
// captured varying into one record with no gaps. A backend that captures through
|
||||
// such a driver has to capture into scratch storage and scatter the records into the
|
||||
// application's buffers itself, using packedOffsetBytes as the source offset and
|
||||
// (bufferIndex, offsetBytes, stride) as the destination.
|
||||
Bool NeedsScatteredTransformFeedbackCapture() const { return m_xfbNeedsScatteredCapture; }
|
||||
// Bytes one gap-free captured record occupies.
|
||||
Uint32 GetTransformFeedbackPackedStride() const { return m_xfbPackedStride; }
|
||||
// True when the capture stage is a triangle-strip geometry shader with a
|
||||
// statically-known emit sequence: the Vulkan capture order then needs the GL
|
||||
// odd-triangle vertex swap after EndTransformFeedback.
|
||||
Bool HasGsTriangleStripCaptureFixup() const { return m_gsStripCaptureFixup; }
|
||||
// Triangles per strip, in emission order, for ONE geometry invocation.
|
||||
const Vector<Uint32>& GetGsStripTriangles() const { return m_gsStripTriangles; }
|
||||
// GL_GEOMETRY_INPUT_TYPE of the linked geometry stage (GL_POINTS, GL_LINES,
|
||||
// GL_LINES_ADJACENCY, GL_TRIANGLES or GL_TRIANGLES_ADJACENCY), or GL_NONE when the
|
||||
// program has no geometry stage. Draws must present a compatible primitive type.
|
||||
GLenum GetGeometryInputType() const { return m_gsInputPrimitive; }
|
||||
|
||||
Uint GetExternalIndex() const { return m_externalIndex; }
|
||||
// Globally-unique, never-reused id for this program object's lifetime. Unlike the GL
|
||||
// name (external index), which is freed to a LIFO list and immediately handed back by
|
||||
// the next glCreateProgram, this distinguishes a deleted-and-recreated program from the
|
||||
// original, so an identity cache can't false-hit on name recycling.
|
||||
Uint64 GetLifetimeId() const { return m_lifetimeId; }
|
||||
|
||||
private:
|
||||
void ResetLinkArtifacts();
|
||||
void DoReflection();
|
||||
// Resolves the requested transform feedback varyings against the linked
|
||||
// vertex stage; fails the link (GL semantics) on unknown or duplicate
|
||||
// names or exceeded capture limits.
|
||||
Bool ResolveTransformFeedbackVaryings();
|
||||
void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate);
|
||||
void GenerateBinary();
|
||||
void WaitUntilGenerationCompleted() const;
|
||||
void AddDefaultFragmentShaderIfMissing();
|
||||
Bool ValidateFragmentOutputLocations();
|
||||
|
||||
static Uint64 AllocateLifetimeId();
|
||||
|
||||
const Uint m_externalIndex = 0;
|
||||
const Uint64 m_lifetimeId = 0;
|
||||
Vector<SharedPtr<ShaderObject>> m_shaders;
|
||||
Vector<SharedPtr<ShaderObject>> m_detachedShaders; // Store detached shaders and remove on next link
|
||||
|
||||
SharedPtr<glslang::TProgram> m_program;
|
||||
|
||||
Vector<Vector<unsigned>> m_generatedSpirv;
|
||||
// ---- P1: everything a link PRODUCES, in one movable block ----
|
||||
//
|
||||
// The membership rule is mechanical, not editorial: this is exactly the field list
|
||||
// ResetLinkArtifacts() clears (plus the four it forgot to - infoLog,
|
||||
// linkedFragDataLocation/Index and the geometry strip-capture pair - which are just
|
||||
// as much link output). Nothing else belongs here.
|
||||
//
|
||||
// Why a struct: once glLinkProgram runs on a worker (P1 stage 4) the worker writes
|
||||
// its OWN LinkArtifacts and the GL thread publishes it with a single move, instead
|
||||
// of thirty cross-thread field assignments. Until then this is a pure refactor.
|
||||
//
|
||||
// Access rule (invariant I5): the member below is private and reachable ONLY
|
||||
// through ProgramObject::Artifacts(), which calls EnsureLinkJoined() first. That is
|
||||
// what makes "every read of link output joins the pending link" a property the
|
||||
// compiler checks rather than a review item - a new reader cannot spell the field
|
||||
// without going through the gate.
|
||||
struct LinkArtifacts {
|
||||
SharedPtr<glslang::TProgram> program;
|
||||
Vector<Vector<unsigned>> generatedSpirv;
|
||||
|
||||
// Attributes (Vertex in)
|
||||
UnorderedMap<String, Uint> m_explicitAttribLocations;
|
||||
Vector<String> m_attribs;
|
||||
Vector<GLenum> m_attribTypes;
|
||||
Vector<String> attribs;
|
||||
Vector<GLenum> attribTypes;
|
||||
|
||||
// FragData (Frag out)
|
||||
UnorderedMap<String, Uint> m_explicitFragDataLocation;
|
||||
UnorderedMap<String, Uint> m_linkedFragDataLocation;
|
||||
// Dual-source blend color index per output name (glBindFragDataLocationIndexed); snapshotted
|
||||
// into the linked map at link time, like the location maps above.
|
||||
UnorderedMap<String, Uint> m_explicitFragDataIndex;
|
||||
UnorderedMap<String, Uint> m_linkedFragDataIndex;
|
||||
Int m_maxFragmentOutputColorNumber = 8;
|
||||
// FragData (Frag out): the per-link snapshot of the explicit request maps.
|
||||
UnorderedMap<String, Uint> linkedFragDataLocation;
|
||||
UnorderedMap<String, Uint> linkedFragDataIndex;
|
||||
|
||||
// Uniforms
|
||||
UnorderedMap<String, Uint> m_uniformLocations;
|
||||
// GL-facing index spaces (see the translation helpers above): GL active-uniform
|
||||
// index <-> glslang TProgram uniform index, GL uniform-block index <-> TProgram
|
||||
// block index. -1 marks a TProgram entry GL does not expose (dead default-block
|
||||
// uniforms swept into MGL_GLOBAL_UBO by the relaxed parse, and that block itself).
|
||||
Vector<Int> glUniformIndexToTProgram;
|
||||
Vector<Int> tProgramUniformIndexToGl;
|
||||
Vector<Int> glBlockIndexToTProgram;
|
||||
Vector<Int> tProgramBlockIndexToGl;
|
||||
// Per-link merged snapshot of the attached shaders' lexically extracted
|
||||
// layout(location = N) default-block uniform qualifiers (the relaxed parse drops
|
||||
// them from reflection; the DoReflection assigner restores them from here).
|
||||
UnorderedMap<String, Int> linkedExplicitUniformLocations;
|
||||
UnorderedMap<String, Uint> uniformLocations;
|
||||
// Ordered by location,
|
||||
// aka. m_uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`"
|
||||
Vector<Int> m_uniformIndexInTProgram;
|
||||
// aka. uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`"
|
||||
Vector<Int> uniformIndexInTProgram;
|
||||
// ditto. Will be set at glUniform1i
|
||||
Vector<Int> m_uniformSamplerOrImageUnitIndex;
|
||||
UnorderedMap<String, Uint> m_explicitOpaqueUniformBindings;
|
||||
Vector<Int> uniformSamplerOrImageUnitIndex;
|
||||
UnorderedMap<String, Uint> explicitOpaqueUniformBindings;
|
||||
|
||||
// Ordered by uniform block index
|
||||
// index is DIFFERENT from binding!!!
|
||||
@@ -599,27 +658,222 @@ namespace MobileGL::MG_State::GLState {
|
||||
// `prog->getUniformBlock(i) == "BlockName"`
|
||||
// These stuff are present for GL semantics, not for backend inspection
|
||||
// These may change after-link (because GL spec decided to have `glUniformBlockBinding`)
|
||||
UnorderedMap<String, Uint> m_uniformBlockIndexByName;
|
||||
Vector<Int> m_uniformBlockBinding;
|
||||
UnorderedMap<String, Uint> uniformBlockIndexByName;
|
||||
Vector<Int> uniformBlockBinding;
|
||||
// glShaderStorageBlockBinding overrides, keyed by GL block name. See
|
||||
// SetShaderStorageBlockBinding for why this one is by name and not by index.
|
||||
UnorderedMap<String, Int> shaderStorageBlockBinding;
|
||||
|
||||
// Need to be reflected after linking of SPIR-V binary
|
||||
Vector<Uint> m_uniformOffsets;
|
||||
Vector<Uint> m_uniformSizesInBytes;
|
||||
Vector<Uint8> m_globalUboScratch;
|
||||
Vector<Uint> uniformOffsets;
|
||||
Vector<Uint> uniformSizesInBytes;
|
||||
Vector<Uint8> globalUboScratch;
|
||||
|
||||
Uint m_activeUniformCount = 0;
|
||||
Uint m_maxUniformLocation = 0;
|
||||
Int m_uniformNameMaxLength = 0;
|
||||
Int m_attribInNameMaxLength = 0;
|
||||
Int m_uniformBlockNameMaxLength = 0;
|
||||
Uint activeUniformCount = 0;
|
||||
Uint maxUniformLocation = 0;
|
||||
Int uniformNameMaxLength = 0;
|
||||
Int attribInNameMaxLength = 0;
|
||||
Int uniformBlockNameMaxLength = 0;
|
||||
|
||||
String infoLog;
|
||||
Bool linkStatus = false;
|
||||
|
||||
// Transform feedback: the linked snapshot (the request lives outside, on the
|
||||
// GL-thread-owned side).
|
||||
Vector<XfbVarying> xfbVaryings;
|
||||
// The glTransformFeedbackVaryings request list exactly as this link consumed it,
|
||||
// INCLUDING the gl_NextBuffer / gl_SkipComponentsN pseudo-varyings that
|
||||
// xfbVaryings deliberately drops (they steer the capture layout and must never
|
||||
// reach a backend's varying list). GL_TRANSFORM_FEEDBACK_VARYING enumerates the
|
||||
// full request, pseudo-varyings and all, so the interface query needs its own copy.
|
||||
Vector<String> xfbInterfaceNames;
|
||||
Vector<Uint32> xfbStrides;
|
||||
Vector<Uint32> gsStripTriangles;
|
||||
Bool gsStripCaptureFixup = false;
|
||||
GLenum gsInputPrimitive = GL_NONE;
|
||||
GLenum xfbBufferMode = GL_INTERLEAVED_ATTRIBS;
|
||||
Int xfbVaryingNameMaxLength = 0;
|
||||
Bool xfbNeedsScatteredCapture = false;
|
||||
Uint32 xfbPackedStride = 0;
|
||||
};
|
||||
|
||||
// ---- artifacts-only helpers, shared with ProgramLinkTask ----
|
||||
// Static and taking the block explicitly, because from stage 4 the link BODY needs
|
||||
// them while its artifacts still live on the job node, not on any ProgramObject. The
|
||||
// member overloads above are the same functions read through the join gate.
|
||||
|
||||
// Clears every field one link produces, EXCEPT infoLog, linkedFragDataLocation/Index
|
||||
// and the geometry strip-capture pair. That exception is load-bearing: the callers
|
||||
// that survive (glProgramBinary's mandated failure, and the link body's own mid-link
|
||||
// aborts) write infoLog immediately AFTER calling here. Link()'s prologue does not
|
||||
// use this at all - it assigns a whole default-constructed LinkArtifacts, where the
|
||||
// ordering is explicit and nothing is exempt.
|
||||
static void ResetLinkArtifacts(LinkArtifacts& artifacts);
|
||||
|
||||
static Bool IsValidUniformLocation(const LinkArtifacts& artifacts, Int location) {
|
||||
if (location < 0 || location > static_cast<Int>(artifacts.maxUniformLocation)) return false;
|
||||
if (static_cast<SizeT>(location) >= artifacts.uniformIndexInTProgram.size()) return false;
|
||||
const Int uniformIndexInProgram = artifacts.uniformIndexInTProgram[location];
|
||||
return uniformIndexInProgram != glslang::TQualifier::layoutLocationEnd &&
|
||||
uniformIndexInProgram >= 0 &&
|
||||
uniformIndexInProgram < static_cast<Int>(artifacts.tProgramUniformIndexToGl.size());
|
||||
}
|
||||
|
||||
// Number of active array elements (GL_UNIFORM_SIZE / GL_ARRAY_SIZE); 1 for a non-array.
|
||||
// glslang's TObjectReflection.size only carries the element count for a NON-block array; for
|
||||
// a block array member it reports 1, so take the count from the TType, which is authoritative
|
||||
// for both. GL 3.3 core uniforms are always sized. Takes a TProgram uniform index (the space
|
||||
// the artifacts' uniformIndexInTProgram stores).
|
||||
static GLint GetUniformArraySizeByTIndex(const LinkArtifacts& artifacts, Int tIndex) {
|
||||
const auto& uniform = artifacts.program->getUniform(tIndex);
|
||||
const glslang::TType* type = uniform.getType();
|
||||
if (type != nullptr && type->isSizedArray()) {
|
||||
return type->getOuterArraySize();
|
||||
}
|
||||
return uniform.size < 1 ? 1 : uniform.size;
|
||||
}
|
||||
|
||||
// Blocks until a pending link has published its artifacts. Public because a few call
|
||||
// sites have to join without reading anything - see the explicit-join list (J1-J8) in
|
||||
// the P1 design. GL thread only.
|
||||
void JoinLink() const { EnsureLinkJoined(); }
|
||||
|
||||
// Drops a link that is still in flight, without waiting for it. Called at the points
|
||||
// where the pending link's result stops being the answer to "what did this program
|
||||
// link to": a re-link supersedes it, glProgramBinary must force LINK_STATUS false,
|
||||
// and a destroyed program has no observers left.
|
||||
//
|
||||
// Deliberately NOT called by the "takes effect at the next link" setters
|
||||
// (glBindAttribLocation, glBindFragDataLocation(Indexed), glTransformFeedbackVaryings,
|
||||
// glProgramParameteri) NOR by glAttachShader/glDetachShader. Every one of those is
|
||||
// defined by GL to leave the CURRENT link result alone, and the pending link already
|
||||
// snapshotted its own inputs at enqueue, so it is computing exactly the answer GL
|
||||
// requires. Cancelling on any of them would make
|
||||
// glLinkProgram(p); <setter>; glGetProgramiv(p, GL_LINK_STATUS)
|
||||
// report FALSE for a link that succeeded - and for the attach/detach pair it would
|
||||
// additionally break glCreateShaderProgramv, which detaches immediately after linking.
|
||||
void CancelLink();
|
||||
|
||||
// MUST NOT JOIN - this is what GL_COMPLETION_STATUS_KHR reads when the extension
|
||||
// surface lands. "No job at all" counts as complete: there is nothing outstanding to
|
||||
// wait for.
|
||||
Bool IsLinkComplete() const { return m_pendingLink == nullptr || IsPendingLinkTerminal(); }
|
||||
|
||||
void SetTransformFeedbackVaryings(Vector<String>&& names, GLenum bufferMode) {
|
||||
m_requestedXfbVaryings = Move(names);
|
||||
m_requestedXfbBufferMode = bufferMode;
|
||||
}
|
||||
GLenum GetTransformFeedbackBufferMode() const { return Artifacts().xfbBufferMode; }
|
||||
SizeT GetTransformFeedbackVaryingCount() const { return Artifacts().xfbVaryings.size(); }
|
||||
const XfbVarying* GetTransformFeedbackVarying(SizeT index) const {
|
||||
return index < Artifacts().xfbVaryings.size() ? &Artifacts().xfbVaryings[index] : nullptr;
|
||||
}
|
||||
const Vector<XfbVarying>& GetTransformFeedbackVaryings() const { return Artifacts().xfbVaryings; }
|
||||
// The GL_TRANSFORM_FEEDBACK_VARYING resource list: every name the last successful
|
||||
// link was asked to capture, in request order, pseudo-varyings included.
|
||||
const Vector<String>& GetTransformFeedbackInterfaceNames() const { return Artifacts().xfbInterfaceNames; }
|
||||
// Stride of one captured vertex in the given capture buffer slot.
|
||||
Uint32 GetTransformFeedbackStride(Uint32 bufferIndex) const {
|
||||
return bufferIndex < Artifacts().xfbStrides.size() ? Artifacts().xfbStrides[bufferIndex] : 0;
|
||||
}
|
||||
SizeT GetTransformFeedbackBufferCount() const { return Artifacts().xfbStrides.size(); }
|
||||
Int GetTransformFeedbackVaryingMaxLength() const { return Artifacts().xfbVaryingNameMaxLength; }
|
||||
// True when the capture layout uses gl_SkipComponents / gl_NextBuffer
|
||||
// (ARB_transform_feedback3), which no ES driver can express: it can only pack every
|
||||
// captured varying into one record with no gaps. A backend that captures through
|
||||
// such a driver has to capture into scratch storage and scatter the records into the
|
||||
// application's buffers itself, using packedOffsetBytes as the source offset and
|
||||
// (bufferIndex, offsetBytes, stride) as the destination.
|
||||
Bool NeedsScatteredTransformFeedbackCapture() const { return Artifacts().xfbNeedsScatteredCapture; }
|
||||
// Bytes one gap-free captured record occupies.
|
||||
Uint32 GetTransformFeedbackPackedStride() const { return Artifacts().xfbPackedStride; }
|
||||
// True when the capture stage is a triangle-strip geometry shader with a
|
||||
// statically-known emit sequence: the Vulkan capture order then needs the GL
|
||||
// odd-triangle vertex swap after EndTransformFeedback.
|
||||
Bool HasGsTriangleStripCaptureFixup() const { return Artifacts().gsStripCaptureFixup; }
|
||||
// Triangles per strip, in emission order, for ONE geometry invocation.
|
||||
const Vector<Uint32>& GetGsStripTriangles() const { return Artifacts().gsStripTriangles; }
|
||||
// GL_GEOMETRY_INPUT_TYPE of the linked geometry stage (GL_POINTS, GL_LINES,
|
||||
// GL_LINES_ADJACENCY, GL_TRIANGLES or GL_TRIANGLES_ADJACENCY), or GL_NONE when the
|
||||
// program has no geometry stage. Draws must present a compatible primitive type.
|
||||
GLenum GetGeometryInputType() const { return Artifacts().gsInputPrimitive; }
|
||||
|
||||
Uint GetExternalIndex() const { return m_externalIndex; }
|
||||
// Globally-unique, never-reused id for this program object's lifetime. Unlike the GL
|
||||
// name (external index), which is freed to a LIFO list and immediately handed back by
|
||||
// the next glCreateProgram, this distinguishes a deleted-and-recreated program from the
|
||||
// original, so an identity cache can't false-hit on name recycling.
|
||||
Uint64 GetLifetimeId() const { return m_lifetimeId; }
|
||||
|
||||
private:
|
||||
// ---- The one and only join gate for link output (P1 invariant I5) ----
|
||||
// Blocks until a pending link has finished and its LinkArtifacts have been
|
||||
// published into m_artifacts. It exists so that the ~120 readers of link output are
|
||||
// routed through it by the compiler rather than by review: m_artifacts is private
|
||||
// and Artifacts() is the only spelling that reaches it.
|
||||
//
|
||||
// The fast path - no pending link - is one predictable branch and stays inline: it
|
||||
// runs on every Artifacts() read (~1200 call sites project-wide) and the project
|
||||
// never builds with LTO (MOBILEGL_ENABLE_LTO=OFF), so an out-of-line body would be a
|
||||
// real cross-TU call at every one of them. The blocking half is out of line.
|
||||
void EnsureLinkJoined() const {
|
||||
if (m_pendingLink) JoinPendingLink();
|
||||
}
|
||||
void JoinPendingLink() const;
|
||||
// ProgramLinkTask is incomplete here, so IsLinkComplete()'s non-joining peek at the
|
||||
// node's state goes through this out-of-line helper.
|
||||
Bool IsPendingLinkTerminal() const;
|
||||
|
||||
LinkArtifacts& Artifacts() {
|
||||
EnsureLinkJoined();
|
||||
return m_artifacts;
|
||||
}
|
||||
const LinkArtifacts& Artifacts() const {
|
||||
EnsureLinkJoined();
|
||||
return m_artifacts;
|
||||
}
|
||||
|
||||
// GL-thread-only companion to ResetLinkArtifacts (see its definition). Const because
|
||||
// the publish half of the join calls it; see the mutable counters below.
|
||||
void BumpLinkObservableVersions() const;
|
||||
void AddDefaultFragmentShaderIfMissing();
|
||||
|
||||
static Uint64 AllocateLifetimeId();
|
||||
|
||||
// ---- GL-thread-owned state: never joins ----
|
||||
// Most of this is never produced by a link at all. The three version counters
|
||||
// (m_backendStateVersion / m_uboContentVersion / m_linkVersion) ARE
|
||||
// link-observable, but they are bumped exclusively on the GL thread
|
||||
// (BumpLinkObservableVersions in Link()'s prologue and glProgramBinary's
|
||||
// failure path) - the link BODY, which stage 4 moves to a worker, never
|
||||
// writes them.
|
||||
const Uint m_externalIndex = 0;
|
||||
const Uint64 m_lifetimeId = 0;
|
||||
// The attach lists are mutated only in Link()'s GL-thread prologue, which is why
|
||||
// glGetAttachedShaders / GL_ATTACHED_SHADERS / the orphan-shader sweep need no join.
|
||||
Vector<SharedPtr<ShaderObject>> m_shaders;
|
||||
Vector<SharedPtr<ShaderObject>> m_detachedShaders; // Store detached shaders and remove on next link
|
||||
|
||||
// Link INPUTS (all "take effect at the next link" per GL): glBindAttribLocation,
|
||||
// glBindFragDataLocation(Indexed), glTransformFeedbackVaryings, and the draw-buffer
|
||||
// count stamped in by the entry point. A pending link snapshots these at enqueue.
|
||||
UnorderedMap<String, Uint> m_explicitAttribLocations;
|
||||
UnorderedMap<String, Uint> m_explicitFragDataLocation;
|
||||
// Dual-source blend color index per output name (glBindFragDataLocationIndexed); snapshotted
|
||||
// into the linked map at link time, like the location maps above.
|
||||
UnorderedMap<String, Uint> m_explicitFragDataIndex;
|
||||
Int m_maxFragmentOutputColorNumber = 8;
|
||||
Vector<String> m_requestedXfbVaryings;
|
||||
GLenum m_requestedXfbBufferMode = GL_INTERLEAVED_ATTRIBS;
|
||||
|
||||
String m_infoLog;
|
||||
Bool m_deleteStatus = false;
|
||||
Bool m_linkStatus = false;
|
||||
Bool m_binaryRetrievableHint = false;
|
||||
Bool m_separable = false;
|
||||
Bool m_validateStatus = true;
|
||||
Uint32 m_backendStateVersion = 0;
|
||||
// Mutable, like m_artifacts and for the same reason: publishing a pending link is a
|
||||
// READ-side operation (the first gated getter is what pulls the result in), and the
|
||||
// publish has to bump these. Still GL-thread-only - a worker never touches them.
|
||||
mutable Uint32 m_backendStateVersion = 0;
|
||||
|
||||
// Backend-owned content-hash memo (see GetBackendHashMemo): valid only while
|
||||
// m_backendStateVersion matches. Several slots, not one: a backend may resolve the same
|
||||
@@ -635,20 +889,20 @@ namespace MobileGL::MG_State::GLState {
|
||||
mutable Array<BackendHashMemoSlot, kBackendHashMemoSlotCount> m_backendHashMemoSlots{};
|
||||
mutable SizeT m_backendHashMemoNextSlot = 0;
|
||||
mutable Uint32 m_backendHashMemoVersion = ~0u;
|
||||
Uint32 m_uboContentVersion = 0;
|
||||
Uint32 m_linkVersion = 0;
|
||||
mutable Uint32 m_uboContentVersion = 0;
|
||||
mutable Uint32 m_linkVersion = 0;
|
||||
|
||||
// Transform feedback: request (applies at next link) and linked snapshot.
|
||||
Vector<String> m_requestedXfbVaryings;
|
||||
GLenum m_requestedXfbBufferMode = GL_INTERLEAVED_ATTRIBS;
|
||||
Vector<XfbVarying> m_xfbVaryings;
|
||||
Vector<Uint32> m_xfbStrides;
|
||||
Vector<Uint32> m_gsStripTriangles;
|
||||
Bool m_gsStripCaptureFixup = false;
|
||||
GLenum m_gsInputPrimitive = GL_NONE;
|
||||
GLenum m_xfbBufferMode = GL_INTERLEAVED_ATTRIBS;
|
||||
Int m_xfbVaryingNameMaxLength = 0;
|
||||
Bool m_xfbNeedsScatteredCapture = false;
|
||||
Uint32 m_xfbPackedStride = 0;
|
||||
// ---- Link OUTPUT ----
|
||||
// Written by the link and by the post-link setters GL allows (glUniform1i's sampler
|
||||
// unit, glUniformBlockBinding). Reachable only through Artifacts(); see LinkArtifacts.
|
||||
//
|
||||
// Mutable because publishing is a READ-side operation: a const getter has to be able
|
||||
// to settle an outstanding link before answering it.
|
||||
mutable LinkArtifacts m_artifacts;
|
||||
|
||||
// The link job, from enqueue until the first observable read pulls its result. Null
|
||||
// means m_artifacts is already the answer - which is the state every reader outside
|
||||
// the pending window sees, and the whole reason the gate above is one branch.
|
||||
mutable SharedPtr<ProgramLinkTask> m_pendingLink;
|
||||
};
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
Uint ProgramState::CreateProgram() {
|
||||
Uint programId = 0;
|
||||
m_programIndexGenerator.Generate(1, &programId);
|
||||
m_programShaderNameGenerator.Generate(1, &programId);
|
||||
EnsureIndexAvail(programId, m_programObjects);
|
||||
auto programObject = MakeShared<ProgramObject>(programId);
|
||||
if (programObject == nullptr) return 0;
|
||||
@@ -39,11 +39,19 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
void ProgramState::DestroyProgramSlot(const Uint program) {
|
||||
auto& programObject = m_programObjects[program];
|
||||
// P1 join site J4/J5 (glDeleteProgram, and the deferred destroy UseProgram performs
|
||||
// when a deletion-flagged program stops being current). The program's name is about
|
||||
// to go, so nothing can observe its link any more: cancel-not-join, so a delete never
|
||||
// blocks the GL thread on a worker. Explicit rather than left to ~ProgramObject,
|
||||
// because the reset below only destroys the object if this table held the last
|
||||
// reference - a program still bound as current, or still referenced by a pipeline,
|
||||
// outlives it, and its link should stop the moment the name does.
|
||||
programObject->CancelLink();
|
||||
// Snapshot the attachments: deleting the program is a detach point for shaders
|
||||
// that were flagged with glDeleteShader while still attached.
|
||||
const Vector<SharedPtr<ShaderObject>> attachedShaders = programObject->GetAttachedShaders();
|
||||
programObject.reset();
|
||||
m_programIndexGenerator.Delete(program);
|
||||
m_programShaderNameGenerator.Delete(program);
|
||||
for (const auto& shader : attachedShaders) {
|
||||
const Uint shaderName = shader->GetExternalIndex();
|
||||
if (CheckIndexAvail(shaderName, m_shaderObjects) && m_shaderObjects[shaderName] == shader) {
|
||||
@@ -77,9 +85,10 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
Uint ProgramState::CreateShader(ShaderStage stage) {
|
||||
Uint shaderId = 0;
|
||||
m_shaderIndexGenerator.Generate(1, &shaderId);
|
||||
m_programShaderNameGenerator.Generate(1, &shaderId);
|
||||
EnsureIndexAvail(shaderId, m_shaderObjects);
|
||||
auto shaderObject = MakeShared<ShaderObject>(stage, shaderId);
|
||||
auto shaderObject =
|
||||
MakeShared<ShaderObject>(stage, shaderId, m_shaderPreprocessCache, m_shaderCompileAdoptionMap);
|
||||
if (shaderObject == nullptr) return 0;
|
||||
m_shaderObjects[shaderId] = shaderObject;
|
||||
return shaderId;
|
||||
@@ -91,6 +100,31 @@ namespace MobileGL::MG_State::GLState {
|
||||
return m_shaderObjects[shader];
|
||||
}
|
||||
|
||||
void ProgramState::JoinAllPendingWork() {
|
||||
// Programs first: a link joins the compiles it depends on, so the shader pass that
|
||||
// follows finds most of them already settled. The reverse order would be correct but
|
||||
// would wait on each compile twice - once here, once inside the link's own prologue.
|
||||
//
|
||||
// A copy of each slot rather than a reference into the vector, and an index rather
|
||||
// than an iterator: publishing a link replays deferred diagnostics, which reach
|
||||
// pGLContext->RecordError. That does not touch these tables today, but it is a sink
|
||||
// that can grow, and a reallocation underneath this loop would be a use-after-free
|
||||
// that only shows up on the one GL call that walks the whole table. The copy costs a
|
||||
// refcount bump on a path a mode switch takes at most once.
|
||||
for (SizeT i = 0; i < m_programObjects.size(); ++i) {
|
||||
const SharedPtr<ProgramObject> program = m_programObjects[i];
|
||||
if (program) program->JoinLink();
|
||||
}
|
||||
for (SizeT i = 0; i < m_shaderObjects.size(); ++i) {
|
||||
const SharedPtr<ShaderObject> shader = m_shaderObjects[i];
|
||||
if (shader) shader->JoinCompile();
|
||||
}
|
||||
// The currently-used program is reachable through m_programObjects unless
|
||||
// glDeleteProgram already freed its slot while it stayed current. Nothing else holds
|
||||
// a GL-visible name for it, but a draw would still join it, so settle it here too.
|
||||
if (m_currentProgram) m_currentProgram->JoinLink();
|
||||
}
|
||||
|
||||
void ProgramState::MarkShaderObjectForDeletion(Uint shader) {
|
||||
if (!CheckIndexAvail(shader, m_shaderObjects)) return;
|
||||
auto& shaderObject = m_shaderObjects[shader];
|
||||
@@ -120,8 +154,15 @@ namespace MobileGL::MG_State::GLState {
|
||||
auto& shaderObject = m_shaderObjects[shader];
|
||||
if (shaderObject == nullptr || !shaderObject->GetDeleteStatus()) return;
|
||||
if (ShaderHasGLVisibleAttachment(shaderObject)) return;
|
||||
// The name is about to go, so nothing can observe this shader's compile through THIS
|
||||
// object any more and a job still in flight for it is pure waste - unless another
|
||||
// shader object adopted the same node (stage 6) or a pending link pinned it, which is
|
||||
// exactly what ReleaseCompileNode weighs before it cancels anything. Cancel-not-join
|
||||
// either way: the job owns its inputs, so dropping the object out from under it is
|
||||
// safe and the GL thread never blocks on a delete.
|
||||
shaderObject->ReleaseCompileNode();
|
||||
shaderObject.reset();
|
||||
m_shaderIndexGenerator.Delete(shader);
|
||||
m_programShaderNameGenerator.Delete(shader);
|
||||
}
|
||||
|
||||
Bool ProgramState::ValidateShaderObject(Uint shader) const {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
#include <Includes.h>
|
||||
#include <MG_Util/Miscellany/IndexGenerator.h>
|
||||
#include "ProgramObject.h"
|
||||
#include "ShaderCompileAdoptionMap.h"
|
||||
#include "ShaderPreprocessCache.h"
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
class ProgramState {
|
||||
@@ -33,6 +35,29 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
const SharedPtr<ProgramObject>& GetCurrentProgram() const { return m_currentProgram; }
|
||||
|
||||
// Joins every outstanding compile and link this context still owns, publishing each
|
||||
// one's artifacts through the ordinary gates. The single caller is
|
||||
// glMaxShaderCompilerThreadsKHR(0): GL_KHR_parallel_shader_compile requires a zero
|
||||
// count to leave nothing in flight, so that every subsequent
|
||||
// GL_COMPLETION_STATUS_KHR reads GL_TRUE.
|
||||
//
|
||||
// NOT a teardown path and NOT ShaderCompilePool::StopAndDrain(): the pool keeps its
|
||||
// threads and stays usable, because a later nonzero count has to bring asynchronous
|
||||
// compilation straight back. Nodes belonging to objects this context has already
|
||||
// dropped are not joined - nothing can observe them, and waiting on them would make
|
||||
// a GL call's cost depend on garbage.
|
||||
void JoinAllPendingWork();
|
||||
|
||||
// P0b layer 2. Exposed for tests and diagnostics; the GL frontend never touches it
|
||||
// directly - shader objects reach it through the pointer they are handed at
|
||||
// CreateShader().
|
||||
ShaderPreprocessCache& GetShaderPreprocessCache() { return *m_shaderPreprocessCache; }
|
||||
|
||||
// P1 stage 6, same deal: exposed for tests and diagnostics only. Its adoption counter
|
||||
// is the one number that says how many glCompileShader calls this context turned into
|
||||
// no work at all; nothing in the GL frontend branches on it.
|
||||
ShaderCompileAdoptionMap& GetShaderCompileAdoptionMap() { return *m_shaderCompileAdoptionMap; }
|
||||
|
||||
private:
|
||||
Bool ShaderHasGLVisibleAttachment(const SharedPtr<ShaderObject>& shaderObject) const;
|
||||
// Frees the name slot and releases orphaned attached shaders; the immediate half
|
||||
@@ -52,10 +77,24 @@ namespace MobileGL::MG_State::GLState {
|
||||
vec.resize(idx + 1);
|
||||
}
|
||||
|
||||
IndexGenerator<Uint> m_programIndexGenerator;
|
||||
Vector<SharedPtr<ProgramObject>> m_programObjects;
|
||||
// Programs and shaders share one GL name space (GL 3.3 core 2.11: a shader
|
||||
// name passed where a program is expected must be recognized as a shader and
|
||||
// rejected with INVALID_OPERATION, and vice versa). One generator for both
|
||||
// object kinds keeps the names disjoint; the object tables stay separate.
|
||||
IndexGenerator<Uint> m_programShaderNameGenerator;
|
||||
|
||||
IndexGenerator<Uint> m_shaderIndexGenerator;
|
||||
// P0b layer 2: every shader object created here is handed shared ownership of this
|
||||
// cache, so its lifetime no longer depends on member destruction order (P1: an
|
||||
// in-flight compile job may outlive the context). The FIRST-member declaration is
|
||||
// kept anyway - it costs nothing and documents the intent.
|
||||
SharedPtr<ShaderPreprocessCache> m_shaderPreprocessCache = MakeShared<ShaderPreprocessCache>();
|
||||
// P1 stage 6: the GL-thread-only index of adoptable compile nodes. Shared ownership
|
||||
// for the same reason as the cache above - a ShaderObject held by a ProgramObject can
|
||||
// outlive these tables, and its destructor releases a node - though unlike the cache
|
||||
// no worker ever sees this one, which is why it carries no lock.
|
||||
SharedPtr<ShaderCompileAdoptionMap> m_shaderCompileAdoptionMap = MakeShared<ShaderCompileAdoptionMap>();
|
||||
|
||||
Vector<SharedPtr<ProgramObject>> m_programObjects;
|
||||
Vector<SharedPtr<ShaderObject>> m_shaderObjects;
|
||||
|
||||
SharedPtr<ProgramObject> m_currentProgram;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#include "ShaderCompileAdoptionMap.h"
|
||||
|
||||
#include "ShaderCompileTask.h"
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
SharedPtr<ShaderCompileTask> ShaderCompileAdoptionMap::FindAdoptable(const ShaderStage stage,
|
||||
const Uint64 sourceHash, const String& source,
|
||||
const Uint64 envFingerprint) {
|
||||
const ShaderSourceKey key{.stage = stage,
|
||||
.sourceHash = sourceHash,
|
||||
.sourceLength = source.length(),
|
||||
.envFingerprint = envFingerprint};
|
||||
|
||||
const auto it = m_entries.find(key);
|
||||
if (it == m_entries.end()) return nullptr;
|
||||
|
||||
SharedPtr<ShaderCompileTask> node = it->second.lock();
|
||||
// Expired (every shader object that held it has released it), settled as Cancelled
|
||||
// (the enqueue lost a race with teardown, or the body threw), or CANCELLATION
|
||||
// REQUESTED but not yet settled (a releaser fired Cancel() while a worker was still
|
||||
// inside RunBody(), so the node is stuck at Running until the body returns - see
|
||||
// JobNode::Run: once m_cancelled is set, the node is DOOMED to end up Cancelled no
|
||||
// matter how the body finishes, it just has not gotten there yet). All three can
|
||||
// never publish artifacts a caller may rely on, so all three are misses. Only the
|
||||
// first two are dead weight worth pruning from the index here - a cancellation-
|
||||
// requested-but-still-running node is still reachable from its own (about to
|
||||
// release) ShaderObject and will get pruned once it actually settles, so leave the
|
||||
// entry alone and just refuse to hand this node out.
|
||||
if (!node || node->IsCancelled()) {
|
||||
m_entries.erase(it);
|
||||
return nullptr;
|
||||
}
|
||||
if (node->IsCancellationRequested()) return nullptr;
|
||||
|
||||
// Never let correctness ride on a 64-bit hash. Lengths already matched (they are part
|
||||
// of the key), so this is a plain memcmp - and it is the ONLY thing that authorizes
|
||||
// two GL shader names to share one compile.
|
||||
if (*node->source != source) return nullptr;
|
||||
|
||||
++m_adoptionCount;
|
||||
return node;
|
||||
}
|
||||
|
||||
void ShaderCompileAdoptionMap::Register(const SharedPtr<ShaderCompileTask>& node) {
|
||||
if (!node) return;
|
||||
|
||||
SweepIfCrowded();
|
||||
// operator[] rather than a find/insert pair: an existing entry for this key is either
|
||||
// a re-registration of the same source (the previous node expired or was cancelled)
|
||||
// or an astronomically rare hash collision. The newcomer wins in both cases.
|
||||
m_entries[ShaderSourceKey{.stage = node->stage,
|
||||
.sourceHash = node->sourceHash,
|
||||
.sourceLength = node->source->length(),
|
||||
.envFingerprint = node->env->fingerprint}] = node;
|
||||
}
|
||||
|
||||
void ShaderCompileAdoptionMap::Clear() {
|
||||
m_entries.clear();
|
||||
m_sweepThreshold = kMinSweepThreshold;
|
||||
}
|
||||
|
||||
void ShaderCompileAdoptionMap::SweepIfCrowded() {
|
||||
if (m_entries.size() < m_sweepThreshold) return;
|
||||
|
||||
// Collect first, erase after: FastSTL::unordered_map is open-addressed, so erasing
|
||||
// through an iterator that the same loop is still advancing is not worth reasoning
|
||||
// about on a path this cold.
|
||||
Vector<ShaderSourceKey> dead;
|
||||
for (const auto& entry : m_entries) {
|
||||
const SharedPtr<ShaderCompileTask> node = entry.second.lock();
|
||||
if (!node || node->IsCancelled()) dead.push_back(entry.first);
|
||||
}
|
||||
for (const ShaderSourceKey& key : dead) {
|
||||
m_entries.erase(key);
|
||||
}
|
||||
|
||||
// Amortization: after a sweep the map holds exactly the nodes still reachable from
|
||||
// some shader object, so letting it double before the next sweep makes the whole
|
||||
// scheme O(1) per Register() while keeping the map O(live nodes).
|
||||
m_sweepThreshold = std::max(kMinSweepThreshold, m_entries.size() * 2);
|
||||
}
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
@@ -0,0 +1,97 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderSourceKey.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
class ShaderCompileTask;
|
||||
|
||||
// P1 stage 6: the per-context index of compile job nodes that a NEW shader object may
|
||||
// adopt instead of enqueueing a duplicate of.
|
||||
//
|
||||
// Why it is not the P0b preprocess cache. That cache only helps once a compile has
|
||||
// FINISHED - it memoizes the source-only half of the pipeline, and a worker consults it
|
||||
// from inside the job body. Under asynchronous compilation the dominant shape is
|
||||
// different: a shaderpack load hands N different shader objects byte-identical source
|
||||
// within the same GL-thread burst (measured across bsl/complementary/bliss, ~21% of all
|
||||
// Compile() calls are such cross-object duplicates), and all N are enqueued before any of
|
||||
// them completes. Every one of those workers then misses the cache, runs the whole
|
||||
// pipeline, and races the others to insert the same entry. This map closes that window on
|
||||
// the GL thread, at enqueue: the second object through takes the FIRST object's node.
|
||||
//
|
||||
// What "adopt" means: the two shader objects end up holding the same SharedPtr in their
|
||||
// m_compiled. They are two distinct GL names with two distinct info-log/COMPILE_STATUS
|
||||
// queries, but both queries read one set of artifacts - which is exactly right, because
|
||||
// the pipeline is a pure function of the key below and the full source text. Nothing is
|
||||
// copied and no worker ever waits (P1 invariant I4 is untouched: this only ever REMOVES
|
||||
// work from the pool). The single consume-once resource, the glslang parse, is already
|
||||
// guarded for sharing by ShaderCompileTask::ClaimParsedShader's CAS, which stage 4 built
|
||||
// for exactly this shape - one node, several links.
|
||||
//
|
||||
// ---- Threading: GL thread only, and therefore lock-free ----
|
||||
// Every entry point below is reached from glCompileShader (ShaderObject::Compile) and
|
||||
// from nowhere else. That is one GL entry point on the application's context thread, so
|
||||
// the map needs no mutex, unlike the preprocess cache which several workers hit at once.
|
||||
// The weak pointers are the ONLY thing this class stores, precisely so it can never keep
|
||||
// a node - or the artifacts a node owns - alive past its last real holder.
|
||||
//
|
||||
// ---- Lifetime and pruning ----
|
||||
// WeakPtr, never SharedPtr: the map is an index, not an owner. An entry whose node has
|
||||
// been released by every shader object simply expires, and a node that was CANCELLED
|
||||
// carries no result at all, so both are treated as misses and pruned where they are
|
||||
// found. Pruning is otherwise amortized: Register() sweeps the whole map whenever it has
|
||||
// grown past twice its size at the last sweep, which bounds the map at O(live nodes)
|
||||
// without a per-call cost.
|
||||
class ShaderCompileAdoptionMap {
|
||||
public:
|
||||
// Never sweep below this: a shaderpack burst is a few hundred distinct sources, and
|
||||
// an entry is a key plus a weak pointer.
|
||||
static constexpr SizeT kMinSweepThreshold = 256;
|
||||
|
||||
// The adoptable node for this exact source under this exact environment, or null.
|
||||
//
|
||||
// A hit is honored only after the FULL source text has been compared byte for byte
|
||||
// against the candidate node's own snapshot: the hash in the key is a lookup
|
||||
// accelerator, never the answer (ShaderSourceKey). A node that has settled as
|
||||
// Cancelled is never handed out - it published nothing, so adopting it would give the
|
||||
// new object a compile that can never report anything but GL_FALSE. Nor is a node
|
||||
// whose cancellation has merely been REQUESTED but not yet settled (still Running,
|
||||
// with IsCancellationRequested() true): JobNode::Run forces such a node to Cancelled
|
||||
// the moment its body returns regardless of how the body finished, so it is already
|
||||
// doomed and handing it out would just move the same GL_FALSE-with-no-log outcome to
|
||||
// a second, unrelated shader object.
|
||||
//
|
||||
// A COMPLETED node is adoptable, and deliberately so: the new object gets the right
|
||||
// answer for zero work, which is the same deal the P0b cache offers one layer down.
|
||||
SharedPtr<ShaderCompileTask> FindAdoptable(ShaderStage stage, Uint64 sourceHash, const String& source,
|
||||
Uint64 envFingerprint);
|
||||
|
||||
// Indexes `node` as the adoptable one for its key. A key already present is
|
||||
// overwritten: the newcomer is at least as fresh as whatever was there, and one entry
|
||||
// per key keeps this a plain map.
|
||||
void Register(const SharedPtr<ShaderCompileTask>& node);
|
||||
|
||||
void Clear();
|
||||
|
||||
// ---- diagnostics only; nothing in the GL frontend branches on these ----
|
||||
// Monotonic count of nodes handed out by FindAdoptable, i.e. of glCompileShader calls
|
||||
// that did NOT enqueue a job because an equivalent one already existed. Tests read it
|
||||
// as a delta across a burst.
|
||||
Uint64 GetAdoptionCount() const { return m_adoptionCount; }
|
||||
SizeT GetEntryCount() const { return m_entries.size(); }
|
||||
|
||||
private:
|
||||
void SweepIfCrowded();
|
||||
|
||||
UnorderedMap<ShaderSourceKey, WeakPtr<ShaderCompileTask>, ShaderSourceKeyHasher> m_entries;
|
||||
SizeT m_sweepThreshold = kMinSweepThreshold;
|
||||
Uint64 m_adoptionCount = 0;
|
||||
};
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
@@ -0,0 +1,338 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#include "ShaderCompileTask.h"
|
||||
|
||||
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
|
||||
#include <MG_Util/ShaderTranspiler/Types.h>
|
||||
|
||||
#include <glslang/Include/PoolAlloc.h>
|
||||
|
||||
#include <charconv>
|
||||
|
||||
namespace {
|
||||
struct ComputeLocalSize {
|
||||
MobileGL::Uint x = 1;
|
||||
MobileGL::Uint y = 1;
|
||||
MobileGL::Uint z = 1;
|
||||
bool declared = false;
|
||||
};
|
||||
|
||||
static MobileGL::String StripGlslComments(const MobileGL::String& source) {
|
||||
MobileGL::String result;
|
||||
result.reserve(source.length());
|
||||
|
||||
bool inLineComment = false;
|
||||
bool inBlockComment = false;
|
||||
for (MobileGL::SizeT i = 0; i < source.length(); ++i) {
|
||||
if (inLineComment) {
|
||||
if (source[i] == '\n') {
|
||||
inLineComment = false;
|
||||
result.push_back(source[i]);
|
||||
} else {
|
||||
result.push_back(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (source[i] == '*' && i + 1 < source.length() && source[i + 1] == '/') {
|
||||
inBlockComment = false;
|
||||
result.append(" ");
|
||||
++i;
|
||||
} else {
|
||||
result.push_back(source[i] == '\n' ? '\n' : ' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source[i] == '/' && i + 1 < source.length()) {
|
||||
if (source[i + 1] == '/') {
|
||||
inLineComment = true;
|
||||
result.append(" ");
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (source[i + 1] == '*') {
|
||||
inBlockComment = true;
|
||||
result.append(" ");
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.push_back(source[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Hoisted out of ParseComputeLocalSize: constructing a std::regex costs far more than
|
||||
// running it over a small source, and it was being rebuilt on every compute compile. A
|
||||
// const regex carries no mutable state, so sharing one instance across workers is safe.
|
||||
static const std::regex kComputeLocalSizePattern(R"(local_size_([xyz])\s*=\s*([0-9]+))");
|
||||
|
||||
static ComputeLocalSize ParseComputeLocalSize(const MobileGL::String& source) {
|
||||
ComputeLocalSize localSize;
|
||||
const MobileGL::String uncommentedSource = StripGlslComments(source);
|
||||
|
||||
for (std::sregex_iterator it(uncommentedSource.begin(), uncommentedSource.end(), kComputeLocalSizePattern),
|
||||
end;
|
||||
it != end; ++it) {
|
||||
const char axis = (*it)[1].str()[0];
|
||||
// The [0-9]+ capture is unbounded, so `local_size_x = 99999999999999999999999`
|
||||
// is a legal match. std::stoull would throw std::out_of_range on it and let the
|
||||
// exception escape glCompileShader; std::from_chars reports the overflow instead.
|
||||
// An overflowing literal saturates to UINT_MAX, which the device-limit check
|
||||
// below rejects anyway - the same verdict a non-overflowing huge value gets.
|
||||
const MobileGL::String digits = (*it)[2].str();
|
||||
unsigned long long value = 0;
|
||||
const std::from_chars_result parsed =
|
||||
std::from_chars(digits.data(), digits.data() + digits.size(), value);
|
||||
const MobileGL::Uint clampedValue = (parsed.ec != std::errc() || value > UINT_MAX)
|
||||
? UINT_MAX
|
||||
: static_cast<MobileGL::Uint>(value);
|
||||
|
||||
// TODO: Replace this literal layout scanner with parser/AST-backed validation so expressions and
|
||||
// specialization-id layouts are handled consistently with glslang.
|
||||
localSize.declared = true;
|
||||
if (axis == 'x') {
|
||||
localSize.x = clampedValue;
|
||||
} else if (axis == 'y') {
|
||||
localSize.y = clampedValue;
|
||||
} else {
|
||||
localSize.z = clampedValue;
|
||||
}
|
||||
}
|
||||
|
||||
return localSize;
|
||||
}
|
||||
|
||||
// The device limits come from the CompileEnv snapshot, never from a live driver query.
|
||||
// GL_MAX_COMPUTE_WORK_GROUP_SIZE is a real GLES call on the DirectGLES backend: issued
|
||||
// off the context thread it would silently no-op and turn a legal local_size_z into
|
||||
// COMPILE_STATUS=FALSE. CaptureCompileEnv() issues it once, on the GL thread.
|
||||
static std::optional<MobileGL::String> ValidateComputeLocalSizeLimits(
|
||||
const MobileGL::String& source, const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) {
|
||||
const ComputeLocalSize localSize = ParseComputeLocalSize(source);
|
||||
if (!localSize.declared) return std::nullopt;
|
||||
|
||||
if (localSize.x > env.maxComputeWorkGroupSize[0] || localSize.y > env.maxComputeWorkGroupSize[1] ||
|
||||
localSize.z > env.maxComputeWorkGroupSize[2]) {
|
||||
return "Compute shader local_size exceeds GL_MAX_COMPUTE_WORK_GROUP_SIZE.";
|
||||
}
|
||||
|
||||
const unsigned long long invocations = static_cast<unsigned long long>(localSize.x) * localSize.y * localSize.z;
|
||||
if (invocations > env.maxComputeWorkGroupInvocations) {
|
||||
return "Compute shader local_size product exceeds GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS.";
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// The half of a compile that depends on nothing but the source text, the stage and the
|
||||
// environment snapshot: preprocessing, the two lexical rejections, and the two lexical
|
||||
// side-channel extractions. Split out so P0b layer 2 can memoize exactly this and
|
||||
// nothing else - the glslang parse stays per-object because its TShader is consume-once.
|
||||
// Deliberately free of any per-object state so the memo is sound.
|
||||
//
|
||||
// The compute local-size verdict reads `env` rather than the live backend, and
|
||||
// env.fingerprint is part of the P0b cache key, so a memo can never be returned against
|
||||
// limits other than the ones it was computed against.
|
||||
static MobileGL::MG_State::GLState::ShaderPreprocessResult RunSourceOnlyPipeline(
|
||||
const MobileGL::ShaderStage stage, const MobileGL::String& source,
|
||||
const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) {
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Util::ShaderTranspiler;
|
||||
using MobileGL::MG_State::GLState::ShaderPreprocessOutcome;
|
||||
|
||||
MobileGL::MG_State::GLState::ShaderPreprocessResult result;
|
||||
result.preprocessedSource = source;
|
||||
PreprocessShaderSource(stage, result.preprocessedSource, env);
|
||||
|
||||
if (stage == ShaderStage::Compute) {
|
||||
if (const std::optional<String> localSizeError =
|
||||
ValidateComputeLocalSizeLimits(result.preprocessedSource, env)) {
|
||||
result.outcome = ShaderPreprocessOutcome::ComputeLocalSizeRejected;
|
||||
result.infoLog = *localSizeError;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (const std::optional<String> reservedError = FindReservedIdentifierViolation(result.preprocessedSource)) {
|
||||
result.outcome = ShaderPreprocessOutcome::ReservedIdentifierRejected;
|
||||
result.infoLog = *reservedError;
|
||||
return result;
|
||||
}
|
||||
|
||||
// The parse this feeds runs in the link-compatible configuration (Vulkan-client
|
||||
// env with relaxed rules): the TShader it produces is what glLinkProgram links and
|
||||
// what the backends' SPIR-V is generated from - there is no second, GL-client
|
||||
// parse. The GL frontend semantics the relaxed parse cannot provide are restored
|
||||
// on top: explicit default-block uniform locations through the lexical
|
||||
// side-channels below, dead-uniform/global-UBO filtering in
|
||||
// ProgramObject::DoReflection.
|
||||
result.explicitUniformLocations = ExtractExplicitUniformLocations(result.preprocessedSource);
|
||||
result.explicitOpaqueBindings = ExtractExplicitOpaqueBindings(result.preprocessedSource);
|
||||
result.outcome = ShaderPreprocessOutcome::Preprocessed;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// glslang has no "detach this thread" API in the vendored revision (there is no
|
||||
// InitThread/DetachThread pair any more; thread attachment is implicit through
|
||||
// thread_local state, and glslang::InitializeProcess() is process-wide, refcounted and
|
||||
// mutex-guarded, so it needs no per-worker counterpart). The pool allocator is the part
|
||||
// that needs undoing; see the declaration in ShaderCompileTask.h.
|
||||
GlslangThreadAllocatorGuard::~GlslangThreadAllocatorGuard() { glslang::SetThreadPoolAllocator(nullptr); }
|
||||
|
||||
// Pure CPU work only. Everything this reads is either an input the node owns or a
|
||||
// process-wide constant; everything it writes is `artifacts`. Do not add a GL/EGL call,
|
||||
// a pActiveBackendObject read, or a pGLContext->RecordError() here - the first two are
|
||||
// what CompileEnv exists to replace, and the third is why the design's section 6
|
||||
// deferral mechanism (and JobNode's debug assert on it) exists.
|
||||
void ShaderCompileTask::RunBody() {
|
||||
// Own the failure rather than letting JobNode's backstop settle the node as
|
||||
// Cancelled: an abandoned node publishes nothing, so the shader would report
|
||||
// COMPILE_STATUS false with an EMPTY info log. GL models a failed compile as
|
||||
// status + log, so turn a throw into exactly that - a completed job whose result
|
||||
// is "this shader did not compile", with a log the application can read.
|
||||
// (JobNode still catches: it is the last resort for anything below.)
|
||||
try {
|
||||
RunCompilePipeline();
|
||||
} catch (const std::exception& e) {
|
||||
artifacts = {};
|
||||
artifacts.env = env;
|
||||
artifacts.compileStatus = false;
|
||||
artifacts.infoLog = std::format("Error: shader compilation failed: {}", e.what());
|
||||
} catch (...) {
|
||||
artifacts = {};
|
||||
artifacts.env = env;
|
||||
artifacts.compileStatus = false;
|
||||
artifacts.infoLog = "Error: shader compilation failed: unknown exception";
|
||||
}
|
||||
}
|
||||
|
||||
void ShaderCompileTask::RunCompilePipeline() {
|
||||
using namespace MG_Util::ShaderTranspiler;
|
||||
const GlslangThreadAllocatorGuard glslangGuard;
|
||||
|
||||
const CompileEnv& compileEnv = *env;
|
||||
artifacts.env = env;
|
||||
|
||||
// P0b layer 2: another shader object in this context may already have run the
|
||||
// source-only half over byte-identical text under the same environment.
|
||||
ShaderPreprocessResultPtr cached =
|
||||
cache ? cache->Find(stage, sourceHash, *source, compileEnv.fingerprint) : nullptr;
|
||||
SharedPtr<ShaderPreprocessResult> fresh;
|
||||
if (!cached) fresh = MakeShared<ShaderPreprocessResult>(RunSourceOnlyPipeline(stage, *source, compileEnv));
|
||||
const ShaderPreprocessResult& shared = cached ? *cached : *fresh;
|
||||
const Bool shouldPopulateCache = !cached && cache != nullptr;
|
||||
|
||||
if (!shared.Preprocessed()) {
|
||||
// Rejected lexically, or a glslang failure this context has already seen for
|
||||
// this exact source (ParseFailed) - either way the parse can be skipped.
|
||||
artifacts.infoLog = shared.infoLog;
|
||||
if (shouldPopulateCache) {
|
||||
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(stage),
|
||||
.sourceStr = shared.preprocessedSource,
|
||||
.flags = 0,
|
||||
.env = &compileEnv};
|
||||
|
||||
auto result = ShaderCompiler::CompileShader(attrib);
|
||||
if (result) {
|
||||
artifacts.compileStatus = true;
|
||||
artifacts.shader = result.value();
|
||||
// Copy, not move: `shared` may alias a cache entry that has to outlive us, and
|
||||
// `fresh` is about to be handed to the cache.
|
||||
artifacts.preprocessedSource = shared.preprocessedSource;
|
||||
artifacts.explicitUniformLocations = shared.explicitUniformLocations;
|
||||
artifacts.explicitOpaqueBindings = shared.explicitOpaqueBindings;
|
||||
artifacts.infoLog.clear();
|
||||
if (shouldPopulateCache) {
|
||||
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
|
||||
}
|
||||
} else {
|
||||
artifacts.infoLog = result.error().log;
|
||||
// Deferred, not logged here, for two reasons. MGLOG from a pool thread interleaves
|
||||
// mid-line with the GL thread's own output and lands out of order relative to the
|
||||
// glCompileShader that caused it; diagnostics.logLines is replayed by the join, on
|
||||
// the GL thread, exactly where a serial implementation would have printed it.
|
||||
// And a one-line summary rather than the old full source dump: a shaderpack stage
|
||||
// is ~100KB, so the dump was the single largest thing this driver ever wrote to
|
||||
// the log, for every failing shader. The info log is what names the offending
|
||||
// line; the source is recoverable from the application.
|
||||
const SizeT firstLineEnd = artifacts.infoLog.find('\n');
|
||||
diagnostics.logLines.push_back(std::format(
|
||||
"ShaderCompileTask: shader {} (stage {}) failed to compile; compileStatus = false. "
|
||||
"Preprocessed source: {} bytes. First log line: {}",
|
||||
externalIndex, static_cast<Int>(stage), shared.preprocessedSource.length(),
|
||||
artifacts.infoLog.substr(0, firstLineEnd == String::npos ? artifacts.infoLog.length()
|
||||
: firstLineEnd)));
|
||||
if (shouldPopulateCache) {
|
||||
fresh->outcome = ShaderPreprocessOutcome::ParseFailed;
|
||||
fresh->infoLog = artifacts.infoLog;
|
||||
fresh->explicitUniformLocations.clear();
|
||||
fresh->explicitOpaqueBindings.clear();
|
||||
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SharedPtr<glslang::TShader> ShaderCompileTask::ClaimParsedShader(String& outReparseLog) const {
|
||||
MOBILEGL_ASSERT(IsComplete(),
|
||||
"ShaderCompileTask::ClaimParsedShader() on a job that has not completed; its artifacts "
|
||||
"are still being written");
|
||||
|
||||
if (artifacts.shader) {
|
||||
// The whole race, in one instruction. Acquire-release because the winner is about
|
||||
// to hand the TShader to glslang's linker on a possibly different thread from the
|
||||
// one that parsed it - the node's terminal transition already published the
|
||||
// parse, and this orders the two claimants against each other.
|
||||
Bool expected = false;
|
||||
if (m_parseClaimed.compare_exchange_strong(expected, true, std::memory_order_acq_rel,
|
||||
std::memory_order_acquire)) {
|
||||
return artifacts.shader;
|
||||
}
|
||||
}
|
||||
|
||||
// Either another link already consumed the stored parse (and mapIO mutated its
|
||||
// intermediate), or there never was one. Re-parse the preprocessed source through the
|
||||
// identical configuration; that costs one glslang parse, which is what GenerateBinary
|
||||
// used to spend here on EVERY link rather than only on reuse.
|
||||
//
|
||||
// The guard is not optional on this path: from stage 4 this runs on a pool worker,
|
||||
// and TShader::parse would leave that worker's TLS allocator pointing at a pool the
|
||||
// GL thread is about to free. (ProgramLinkTask::RunBody holds one too; they nest
|
||||
// harmlessly - both just reset the thread to its own default.)
|
||||
const GlslangThreadAllocatorGuard glslangGuard;
|
||||
using namespace MG_Util::ShaderTranspiler;
|
||||
ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(stage),
|
||||
.sourceStr = artifacts.preprocessedSource,
|
||||
.flags = 0,
|
||||
// Re-parse against the SAME environment the original parse used,
|
||||
// not against whatever the backend reports now.
|
||||
.env = artifacts.env.get()};
|
||||
auto result = ShaderCompiler::CompileShader(attrib);
|
||||
if (!result) {
|
||||
// Should be unreachable: the same source parsed successfully at Compile().
|
||||
outReparseLog = result.error().log;
|
||||
return nullptr;
|
||||
}
|
||||
return result.value();
|
||||
}
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
@@ -0,0 +1,186 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
#include <MG_Util/Async/JobNode.h>
|
||||
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderPreprocessCache.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// glslang has no "detach this thread" API in the vendored revision, but TShader::parse
|
||||
// leaves the calling thread's TLS pool allocator pointing at the shader's own pool and
|
||||
// never restores it. Left there, the next allocation this thread makes - in an unrelated
|
||||
// job, or in glslang code reached from a different object - would come out of a pool the
|
||||
// GL thread may already have deleted with the TShader. SetThreadPoolAllocator(nullptr)
|
||||
// reverts the thread to its own thread_local default and is the documented idiom.
|
||||
//
|
||||
// A scope guard, so it also runs when a body throws. Declared here rather than kept
|
||||
// file-local because stage 4 gave it a second user: ProgramLinkTask's body parses (the
|
||||
// claim-CAS loser's re-parse), links and emits SPIR-V, all on a pool thread.
|
||||
struct GlslangThreadAllocatorGuard {
|
||||
GlslangThreadAllocatorGuard() = default;
|
||||
~GlslangThreadAllocatorGuard();
|
||||
GlslangThreadAllocatorGuard(const GlslangThreadAllocatorGuard&) = delete;
|
||||
GlslangThreadAllocatorGuard& operator=(const GlslangThreadAllocatorGuard&) = delete;
|
||||
};
|
||||
|
||||
// Everything one glCompileShader PRODUCES, in one block.
|
||||
//
|
||||
// This is exactly the set a single run of the compile pipeline writes, which is what
|
||||
// makes "discard the artifacts" a complete invalidation and "move the artifacts" a
|
||||
// complete publish. It lives on the job node rather than on ShaderObject: a worker fills
|
||||
// it in, and the GL thread reads it through ShaderObject's join gate.
|
||||
struct ShaderCompileArtifacts {
|
||||
// The CompileEnv snapshot this compile ran against. Held so the consume-once
|
||||
// re-parse in ClaimParsedShader() reproduces the original parse exactly, instead of
|
||||
// re-reading whatever the backend says now.
|
||||
SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env;
|
||||
SharedPtr<glslang::TShader> shader;
|
||||
// The source the parse actually consumed (after PreprocessShaderSource), kept for
|
||||
// ClaimParsedShader's re-parse so a later link never depends on the preprocessor
|
||||
// being deterministic across backend-state changes.
|
||||
String preprocessedSource;
|
||||
UnorderedMap<String, Int> explicitUniformLocations;
|
||||
UnorderedMap<String, Uint> explicitOpaqueBindings;
|
||||
String infoLog;
|
||||
Bool compileStatus = false;
|
||||
};
|
||||
|
||||
// The unit of asynchronous shader compilation: one glCompileShader's worth of pure CPU
|
||||
// work - preprocess, the two lexical rejections, the two lexical extractions, and the
|
||||
// glslang parse - with every input it needs owned by the node itself.
|
||||
//
|
||||
// That ownership is the whole point. The node reads no GL-thread state (the source is a
|
||||
// SharedPtr<const String> snapshot, the device limits come from the CompileEnv snapshot,
|
||||
// the P0b cross-object memo is shared-owned and internally locked) and writes nothing
|
||||
// but its own `artifacts`. So a node whose ShaderObject was re-sourced, deleted, or
|
||||
// destroyed while it was still running is safe to simply abandon - no wait, no
|
||||
// synchronization with the GL thread beyond the node's own terminal state.
|
||||
class ShaderCompileTask final : public MG_Util::Async::JobNode {
|
||||
public:
|
||||
ShaderCompileTask(const ShaderStage stage, SharedPtr<const String> source, const Uint64 sourceHash,
|
||||
SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env,
|
||||
SharedPtr<ShaderPreprocessCache> cache, const Uint externalIndex)
|
||||
: stage(stage), source(Move(source)), sourceHash(sourceHash), env(Move(env)), cache(Move(cache)),
|
||||
externalIndex(externalIndex) {}
|
||||
|
||||
// ---- inputs: immutable after construction, all owned by the node ----
|
||||
const ShaderStage stage;
|
||||
// The exact text at enqueue. ShaderObject compares this pointer against its own
|
||||
// m_source to decide whether its layer-1 memo is armed, which is why glShaderSource
|
||||
// only ever swaps the pointer when the text genuinely differs.
|
||||
const SharedPtr<const String> source;
|
||||
const Uint64 sourceHash;
|
||||
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env;
|
||||
// P0b layer 2, or null. Null is the "no context" case (the default fragment shader,
|
||||
// the backends' internal blit/mipmap shaders) and doubles as the marker for
|
||||
// "compile inline regardless of the async flag" - see ShaderObject::Compile().
|
||||
const SharedPtr<ShaderPreprocessCache> cache;
|
||||
const Uint externalIndex; // logs only
|
||||
|
||||
// ---- output: valid iff IsComplete(), immutable afterwards ----
|
||||
ShaderCompileArtifacts artifacts;
|
||||
|
||||
// Hands out a link-consumable TShader, exactly once for the stored parse.
|
||||
//
|
||||
// glslang's mapIO mutates the TShader's aliased intermediate, so the parse this node
|
||||
// produced may feed exactly ONE link; every later link (a relink, or the same shader
|
||||
// attached to a second program) needs a fresh parse. The claim is a CAS on this
|
||||
// shared node rather than a flag on the ShaderObject because from stage 4 the two
|
||||
// callers can be two ProgramLinkTasks running on two workers: two programs sharing
|
||||
// one shader, linked back to back. Copying the parse out and tracking consumed-ness
|
||||
// per program would let both of them decide they were the first, run mapIO over the
|
||||
// same intermediate twice, and ship silently corrupt SPIR-V.
|
||||
//
|
||||
// The CAS loser re-parses artifacts.preprocessedSource against THIS node's own
|
||||
// CompileEnv (not against whatever the backend reports now), through the identical
|
||||
// CompileShader path - so winner and loser produce byte-identical SPIR-V. Callable
|
||||
// only once IsComplete() and compileStatus are true. Returns null only if that
|
||||
// re-parse fails, and outReparseLog then carries its diagnostics.
|
||||
//
|
||||
// Const because the claim is the node's own synchronization, not a mutation of its
|
||||
// published artifacts: a claim that is taken and then abandoned (its link was
|
||||
// cancelled) costs one extra re-parse later and nothing else.
|
||||
SharedPtr<glslang::TShader> ClaimParsedShader(String& outReparseLog) const;
|
||||
|
||||
// Sticky marker for "a ProgramLinkTask has this node in its input snapshot".
|
||||
//
|
||||
// It exists to keep a cancel from eating a result someone still needs. A pending link
|
||||
// holds its dependencies by SharedPtr, so the NODE always outlives the ShaderObject -
|
||||
// but Cancel() is not about lifetime, it discards the result. The reachable sequence
|
||||
// is the ordinary one: compile, attach, glLinkProgram (enqueued), glDetachShader,
|
||||
// glDeleteShader. The detach makes the shader GL-invisible, so the delete frees its
|
||||
// name, and ReleaseShaderNameIfOrphaned would cancel a compile the enqueued link is
|
||||
// waiting on - turning a link that must report GL_TRUE into GL_FALSE. Set on the GL
|
||||
// thread in Link()'s prologue, read on the GL thread by
|
||||
// ShaderObject::ReleaseCompileNode - which from stage 6 weighs it together with the
|
||||
// adopter count below, because a node can now have both kinds of observer at once.
|
||||
//
|
||||
// Never cleared: the worst case is one stale node compiling to completion for nobody,
|
||||
// which is exactly what the pre-stage-3 implementation always did.
|
||||
void MarkLinkReferenced() { m_linkReferenced.store(true, std::memory_order_release); }
|
||||
Bool IsLinkReferenced() const { return m_linkReferenced.load(std::memory_order_acquire); }
|
||||
|
||||
// ---- P1 stage 6: the adopter count ----
|
||||
// How many live ShaderObjects currently hold this node in their m_compiled.
|
||||
//
|
||||
// It exists because stage 6 lets a node be SHARED: before it, a node had exactly one
|
||||
// shader object, so "this object stopped caring" and "nothing can observe this
|
||||
// result" were the same statement and ShaderObject::CancelCompile could cancel
|
||||
// unconditionally. Once two GL shader names hold one node, that cancel would kill the
|
||||
// other one's pending compile - a compile that must still report GL_TRUE. So a cancel
|
||||
// is now authorized by TWO conditions, both checked by the releaser:
|
||||
// * this release brings the count to zero (no shader object is left), AND
|
||||
// * IsLinkReferenced() is false (no enqueued link took the node into its snapshot).
|
||||
// The second is the stage-4 pin, unchanged; the first is what stage 6 adds.
|
||||
//
|
||||
// ---- Why a plain Int and not an atomic ----
|
||||
// Every mutation is made from ShaderObject, and every ShaderObject mutation site is a
|
||||
// GL entry point on the application's context thread: glCompileShader (adopt/create),
|
||||
// glShaderSource with different text, glDeleteShader's orphan sweep, and
|
||||
// ~ShaderObject. All of them are the SAME thread, so the count is never concurrently
|
||||
// mutated and an atomic would only buy an unneeded lock prefix on the hottest compile
|
||||
// path. Workers cannot touch it by construction: a job body's entire contract (see
|
||||
// this class's header comment) is that it reads only the node's inputs and writes only
|
||||
// `artifacts`, and a plain Int here makes that contract grep-checkable in a way an
|
||||
// atomic would quietly hide.
|
||||
//
|
||||
// The CANCEL that the count authorizes still races the worker, and deliberately so -
|
||||
// that is the settled cancel-not-join semantics from stage 3: JobNode::Cancel is
|
||||
// cooperative and non-blocking, a node already running settles as Cancelled when its
|
||||
// body returns, and a node that has already gone terminal ignores the request.
|
||||
// Nothing about that changes here.
|
||||
//
|
||||
// Exactness under that race: ShaderObject::ReleaseCompileNode returns EARLY, without
|
||||
// decrementing and without dropping its reference, when the node is already terminal
|
||||
// (there is nothing left to stop). Terminality is sticky, so if a releaser observes a
|
||||
// node as NON-terminal then no holder has ever taken that early return on it, and the
|
||||
// count it reads is exactly the number of holders. If the worker finishes in the
|
||||
// window between that observation and the Cancel(), the Cancel is a no-op on a
|
||||
// terminal node - and the count was zero, so there was no other holder to harm.
|
||||
void AddAdopter() { ++m_adopters; }
|
||||
void ReleaseAdopter() {
|
||||
MOBILEGL_ASSERT(m_adopters > 0,
|
||||
"ShaderCompileTask adopter count underflow; a ShaderObject released a node it did not "
|
||||
"hold (every release must pair with exactly one AddAdopter)");
|
||||
--m_adopters;
|
||||
}
|
||||
Int AdopterCount() const { return m_adopters; }
|
||||
|
||||
private:
|
||||
void RunBody() override;
|
||||
// The real body; RunBody wraps it so a throw becomes a GL-visible compile failure.
|
||||
void RunCompilePipeline();
|
||||
|
||||
mutable std::atomic<Bool> m_parseClaimed{false};
|
||||
std::atomic<Bool> m_linkReferenced{false};
|
||||
// GL-thread-owned; see AddAdopter above for why this is not an atomic.
|
||||
Int m_adopters = 0;
|
||||
};
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
@@ -7,197 +7,208 @@
|
||||
// End of Source File Header
|
||||
|
||||
#include "ShaderObject.h"
|
||||
#include <MG_Util/ShaderTranspiler/Types.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
|
||||
#include "ShaderPreprocessCache.h"
|
||||
#include <MG_Util/Async/ShaderCompilePool.h>
|
||||
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
|
||||
#include <MG_Util/ShaderTranspiler/glslang/UniformTraverser.h>
|
||||
#include <MG_Backend/BackendObjects.h>
|
||||
|
||||
namespace {
|
||||
struct ComputeLocalSize {
|
||||
MobileGL::Uint x = 1;
|
||||
MobileGL::Uint y = 1;
|
||||
MobileGL::Uint z = 1;
|
||||
bool declared = false;
|
||||
};
|
||||
|
||||
static MobileGL::String StripGlslComments(const MobileGL::String& source) {
|
||||
MobileGL::String result;
|
||||
result.reserve(source.length());
|
||||
|
||||
bool inLineComment = false;
|
||||
bool inBlockComment = false;
|
||||
for (MobileGL::SizeT i = 0; i < source.length(); ++i) {
|
||||
if (inLineComment) {
|
||||
if (source[i] == '\n') {
|
||||
inLineComment = false;
|
||||
result.push_back(source[i]);
|
||||
} else {
|
||||
result.push_back(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (source[i] == '*' && i + 1 < source.length() && source[i + 1] == '/') {
|
||||
inBlockComment = false;
|
||||
result.append(" ");
|
||||
++i;
|
||||
} else {
|
||||
result.push_back(source[i] == '\n' ? '\n' : ' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source[i] == '/' && i + 1 < source.length()) {
|
||||
if (source[i + 1] == '/') {
|
||||
inLineComment = true;
|
||||
result.append(" ");
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (source[i + 1] == '*') {
|
||||
inBlockComment = true;
|
||||
result.append(" ");
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.push_back(source[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static ComputeLocalSize ParseComputeLocalSize(const MobileGL::String& source) {
|
||||
ComputeLocalSize localSize;
|
||||
const MobileGL::String uncommentedSource = StripGlslComments(source);
|
||||
const std::regex localSizePattern(R"(local_size_([xyz])\s*=\s*([0-9]+))");
|
||||
|
||||
for (std::sregex_iterator it(uncommentedSource.begin(), uncommentedSource.end(), localSizePattern), end;
|
||||
it != end; ++it) {
|
||||
const char axis = (*it)[1].str()[0];
|
||||
const auto value = static_cast<unsigned long long>(std::stoull((*it)[2].str()));
|
||||
const MobileGL::Uint clampedValue = value > UINT_MAX ? UINT_MAX : static_cast<MobileGL::Uint>(value);
|
||||
|
||||
// TODO: Replace this literal layout scanner with parser/AST-backed validation so expressions and
|
||||
// specialization-id layouts are handled consistently with glslang.
|
||||
localSize.declared = true;
|
||||
if (axis == 'x') {
|
||||
localSize.x = clampedValue;
|
||||
} else if (axis == 'y') {
|
||||
localSize.y = clampedValue;
|
||||
} else {
|
||||
localSize.z = clampedValue;
|
||||
}
|
||||
}
|
||||
|
||||
return localSize;
|
||||
}
|
||||
|
||||
static MobileGL::Uint GetComputeWorkGroupSizeLimit(MobileGL::Uint index) {
|
||||
constexpr MobileGL::Uint kFrontendMinComputeWorkGroupSizes[] = {1024, 1024, 64};
|
||||
MobileGL::Int backendValue = 0;
|
||||
if (MobileGL::MG_Backend::gBackendFunctionsTable.GL.GetIntegeri_v) {
|
||||
MobileGL::MG_Backend::gBackendFunctionsTable.GL.GetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, index,
|
||||
&backendValue);
|
||||
}
|
||||
|
||||
// TODO: Share these exposed compute limit helpers with GL_Getter.cpp instead of duplicating the frontend minima.
|
||||
return std::max(static_cast<MobileGL::Uint>(std::max(backendValue, 0)),
|
||||
kFrontendMinComputeWorkGroupSizes[index]);
|
||||
}
|
||||
|
||||
static unsigned long long GetComputeWorkGroupInvocationLimit() {
|
||||
constexpr unsigned long long kFrontendMaxComputeWorkGroupInvocations = 1024;
|
||||
if (!MobileGL::MG_Backend::pActiveBackendObject) return kFrontendMaxComputeWorkGroupInvocations;
|
||||
|
||||
return std::max(static_cast<unsigned long long>(std::max(
|
||||
MobileGL::MG_Backend::pActiveBackendObject->GetDynamicParameters()
|
||||
.MaxComputeWorkGroupInvocations,
|
||||
0)),
|
||||
kFrontendMaxComputeWorkGroupInvocations);
|
||||
}
|
||||
|
||||
static std::optional<MobileGL::String> ValidateComputeLocalSizeLimits(const MobileGL::String& source) {
|
||||
const ComputeLocalSize localSize = ParseComputeLocalSize(source);
|
||||
if (!localSize.declared) return std::nullopt;
|
||||
|
||||
if (localSize.x > GetComputeWorkGroupSizeLimit(0) || localSize.y > GetComputeWorkGroupSizeLimit(1) ||
|
||||
localSize.z > GetComputeWorkGroupSizeLimit(2)) {
|
||||
return "Compute shader local_size exceeds GL_MAX_COMPUTE_WORK_GROUP_SIZE.";
|
||||
}
|
||||
|
||||
const unsigned long long invocations = static_cast<unsigned long long>(localSize.x) * localSize.y * localSize.z;
|
||||
if (invocations > GetComputeWorkGroupInvocationLimit()) {
|
||||
return "Compute shader local_size product exceeds GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS.";
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
|
||||
#include <MG_Util/ShaderTranspiler/Types.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
void ShaderObject::SetShaderSource(const String& source) {
|
||||
m_source = source;
|
||||
m_shader.reset();
|
||||
m_compileStatus = false;
|
||||
m_infoLog.clear();
|
||||
// P0b layer 1. glShaderSource always REPLACES the source, but replacing it with a
|
||||
// byte-identical one cannot change what a compile would produce: the whole
|
||||
// pipeline (preprocess -> lexical checks -> glslang parse) is a pure function of
|
||||
// (stage, source, CompileEnv). So keeping the compiled state is not an optimization
|
||||
// that changes observable behaviour - the COMPILE_STATUS, the info log and the
|
||||
// reflection a caller can query are exactly what a real recompile would have
|
||||
// rebuilt, byte for byte. A compile still IN FLIGHT is left running for the same
|
||||
// reason: it is computing the right answer for text this object still holds.
|
||||
if (SourceMatchesCompiledState(source)) return;
|
||||
// The text genuinely changed, so whatever a running job is computing is now about
|
||||
// an old source. Give up our claim on it - it owns its own copy of that old string,
|
||||
// so swapping the pointer below cannot race its storage. Note "our claim", not "the
|
||||
// job": another shader object may have adopted the same node and still be waiting for
|
||||
// exactly this answer, which is what ReleaseCompileNode's count discipline protects.
|
||||
ReleaseCompileNode();
|
||||
m_source = MakeShared<const String>(source);
|
||||
InvalidateCompiledState();
|
||||
}
|
||||
|
||||
void ShaderObject::SetShaderSource(String&& source) {
|
||||
m_source = Move(source);
|
||||
m_shader.reset();
|
||||
m_compileStatus = false;
|
||||
m_infoLog.clear();
|
||||
if (SourceMatchesCompiledState(source)) return;
|
||||
ReleaseCompileNode();
|
||||
m_source = MakeShared<const String>(Move(source));
|
||||
InvalidateCompiledState();
|
||||
}
|
||||
|
||||
Bool ShaderObject::SourceMatchesCompiledState(const String& candidate) const {
|
||||
// The memo is armed exactly while a job exists that was built from the string this
|
||||
// object still points at - pending or finished, success or failure.
|
||||
if (!HasMemoizedCompile()) return false;
|
||||
if (candidate.length() != m_source->length()) return false;
|
||||
// Never let correctness ride on a hash: the answer is the full text comparison.
|
||||
// (The stored hash on the node is a cache-lookup accelerator, not a substitute.)
|
||||
return candidate == *m_source;
|
||||
}
|
||||
|
||||
void ShaderObject::JoinPendingCompile() const {
|
||||
MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(),
|
||||
"ShaderObject::EnsureCompileJoined() reached from a pool thread; a job body must never read "
|
||||
"GL-thread-owned objects");
|
||||
m_compiled->Wait();
|
||||
m_compileJoined = true;
|
||||
// Errors and worker-side log lines are raised HERE, on the GL thread, at the first
|
||||
// join of the job that produced them - which for a single shader is trivially the
|
||||
// order a serial implementation would have produced them in.
|
||||
//
|
||||
// ApplyDeferredDiagnostics DRAINS, so a node shared by several shader objects
|
||||
// (stage 6) replays its worker-side log line exactly once, at whichever object joins
|
||||
// first. That is the honest report - one compile ran - and it is log text only: the
|
||||
// GL-observable half of a failure, COMPILE_STATUS and the info log, lives in
|
||||
// `artifacts` and every sharer reads the identical copy of it.
|
||||
MG_Util::Async::ApplyDeferredDiagnostics(*m_compiled);
|
||||
// A node that settled as Cancelled published nothing. Dropping it here is what keeps
|
||||
// the object's state machine to two reachable cases - "no job" and "a job that
|
||||
// completed" - so every reader below can treat a live node as authoritative.
|
||||
//
|
||||
// Through DropCompileNode, not a bare reset: this object is letting the node go, so
|
||||
// its adopter slot has to go with it. A node shared with another object stays alive
|
||||
// and gets dropped once more when that object joins - once per holder, never twice
|
||||
// for the same one, because DropCompileNode is null-guarded.
|
||||
if (!m_compiled->IsComplete()) DropCompileNode();
|
||||
}
|
||||
|
||||
void ShaderObject::AdoptCompileNode(SharedPtr<ShaderCompileTask> node) const {
|
||||
// Never overwrite a hold without giving its slot back first.
|
||||
DropCompileNode();
|
||||
m_compiled = Move(node);
|
||||
m_compiled->AddAdopter();
|
||||
// Re-arm the join gate: whether this node was just created or just adopted from
|
||||
// another object, THIS object has not pulled its result yet. (An adopted node may
|
||||
// already be terminal - the join then only replays what is left of its diagnostics.)
|
||||
m_compileJoined = false;
|
||||
}
|
||||
|
||||
void ShaderObject::DropCompileNode() const {
|
||||
if (!m_compiled) return;
|
||||
m_compiled->ReleaseAdopter();
|
||||
m_compiled.reset();
|
||||
}
|
||||
|
||||
void ShaderObject::InvalidateCompiledState() {
|
||||
// The job node holds exactly what one Compile() produces, so discarding it IS the
|
||||
// invalidation - and it re-arms nothing, so the next Compile() genuinely recompiles.
|
||||
DropCompileNode();
|
||||
}
|
||||
|
||||
void ShaderObject::ReleaseCompileNode() {
|
||||
if (!m_compiled) return;
|
||||
// Already terminal: there is nothing left to stop, so this is not a release at all -
|
||||
// the node and this object's claim on it both stay. That early return is older than
|
||||
// stage 6 and it is load-bearing: ProgramState::ReleaseShaderNameIfOrphaned calls
|
||||
// this on a shader whose name is going away but whose object a ProgramObject may
|
||||
// still hold, and dropping a COMPLETED compile there would turn that program's link
|
||||
// into GL_FALSE.
|
||||
if (m_compiled->IsTerminal()) return;
|
||||
// Two independent claimants have to be checked before a cancel, and this object is
|
||||
// authorized to cancel only if BOTH say the result has become unobservable.
|
||||
//
|
||||
// 1. Other shader objects. From stage 6 a node can be SHARED by several GL shader
|
||||
// names that were handed byte-identical source; cancelling here would turn a
|
||||
// compile they must still see as GL_TRUE into GL_FALSE. Only the releaser that
|
||||
// takes the count to zero - i.e. the last holder - may cancel. See
|
||||
// ShaderCompileTask::AddAdopter for why a plain Int is sound here and for the
|
||||
// exactness argument under the worker race.
|
||||
// 2. A pending LINK. An enqueued ProgramLinkTask holds the node in its input snapshot
|
||||
// and a cancel would turn its link into GL_FALSE; reached by the ordinary
|
||||
// link-then-detach-then-delete shader teardown. See MarkLinkReferenced. Never
|
||||
// cleared, so this is a one-way pin.
|
||||
//
|
||||
// The cancel itself is cooperative and non-blocking, exactly as before: a node no
|
||||
// worker has picked up settles immediately, a running one is flagged and settles when
|
||||
// its body returns, writing only into itself the whole time.
|
||||
if (m_compiled->AdopterCount() == 1 && !m_compiled->IsLinkReferenced()) m_compiled->Cancel();
|
||||
DropCompileNode();
|
||||
}
|
||||
|
||||
void ShaderObject::Compile() {
|
||||
using namespace MG_Util::ShaderTranspiler;
|
||||
String compileSource = m_source;
|
||||
MG_Util::ShaderTranspiler::PreprocessShaderSource(m_stage, compileSource);
|
||||
// P0b layer 1, as a tri-state: the memo is "the node in m_compiled was built from
|
||||
// the string m_source still points at". SetShaderSource only swaps that pointer when
|
||||
// the text actually differs, so this is a pointer compare, and it covers Pending as
|
||||
// well as Complete - a second glCompileShader on an in-flight object is a no-op, not
|
||||
// a duplicate job racing to write the same fields.
|
||||
//
|
||||
// The failure case is covered too: the info log stays queryable because nothing is
|
||||
// cleared. And if the stored TShader already fed a link, the no-op leaves
|
||||
// preprocessedSource and both side-channel maps intact, which is precisely what
|
||||
// ClaimParsedShader's on-demand re-parse needs - a real recompile would have handed
|
||||
// the next link a fresh parse, the no-op hands it a fresh re-parse of the identical
|
||||
// source instead. Same result, one parse either way.
|
||||
if (HasMemoizedCompile()) return;
|
||||
|
||||
if (m_stage == ShaderStage::Compute) {
|
||||
const std::optional<String> localSizeError = ValidateComputeLocalSizeLimits(compileSource);
|
||||
if (localSizeError) {
|
||||
m_compileStatus = false;
|
||||
m_shader.reset();
|
||||
m_infoLog = *localSizeError;
|
||||
// Two reasons to stay on this thread, one rule. Without the async flag the whole
|
||||
// path must be byte-identical to the synchronous implementation, and a cache-less
|
||||
// object is an internal shader that compiles and reads its status in the same
|
||||
// breath (see the constructor comment) - a job would only add a round trip.
|
||||
// AsyncShaderCompileActive(), not ...Enabled(): a glMaxShaderCompilerThreadsKHR(0)
|
||||
// has to put compilation back on this thread even though the extension is still
|
||||
// advertised, and that is exactly what makes the GL_COMPLETION_STATUS_KHR the
|
||||
// extension mandates after a zero count (immediately GL_TRUE) fall out for free.
|
||||
//
|
||||
// Hoisted above the node construction because stage 6 keys off it too: this same
|
||||
// answer decides whether the adoption map is consulted at all, so a
|
||||
// glMaxShaderCompilerThreadsKHR(0) and a flag-off build both bypass sharing exactly
|
||||
// as they bypass the pool, and their behaviour stays byte-identical to pre-stage-6.
|
||||
const Bool runOnPool = m_preprocessCache && MG_Util::Async::AsyncShaderCompileActive();
|
||||
|
||||
// The compile-environment snapshot is taken HERE, on the GL thread, and handed to
|
||||
// the job. Everything the pipeline needs to know about the device comes through it,
|
||||
// never through pActiveBackendObject - that is what makes the body movable.
|
||||
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env =
|
||||
MG_Util::ShaderTranspiler::GetCurrentCompileEnv();
|
||||
const Uint64 sourceHash = ShaderPreprocessCache::HashSource(*m_source);
|
||||
|
||||
// ---- P1 stage 6: adopt an equivalent compile instead of enqueueing a duplicate ----
|
||||
// ~21% of all glCompileShader calls in the shaderpack corpus are a DIFFERENT shader
|
||||
// object handed byte-identical source. P0b's memo only pays off once one of them has
|
||||
// finished; under async they are all enqueued in the same burst, so without this each
|
||||
// one runs the whole pipeline on its own worker. The map hands back the node the
|
||||
// first of them created - in flight or already complete - and this object simply
|
||||
// holds it too.
|
||||
if (runOnPool && m_adoptionMap) {
|
||||
if (SharedPtr<ShaderCompileTask> shared =
|
||||
m_adoptionMap->FindAdoptable(m_stage, sourceHash, *m_source, env->fingerprint)) {
|
||||
// Take the node's own source snapshot as ours. FindAdoptable just compared
|
||||
// the two strings in full, so this changes nothing observable - but it is not
|
||||
// optional: the layer-1 memo (HasMemoizedCompile) is a POINTER comparison
|
||||
// against the node's snapshot, so leaving our own equal-but-distinct copy in
|
||||
// place would make the very next glCompileShader on this object decide it had
|
||||
// no memo and enqueue the duplicate this whole stage exists to avoid - and
|
||||
// would make an identical glShaderSource re-source cancel a shared compile.
|
||||
// It also collapses N copies of a ~100 KB shaderpack stage into one.
|
||||
m_source = shared->source;
|
||||
AdoptCompileNode(Move(shared));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const std::optional<String> reservedError =
|
||||
MG_Util::ShaderTranspiler::FindReservedIdentifierViolation(compileSource);
|
||||
if (reservedError) {
|
||||
m_compileStatus = false;
|
||||
m_shader.reset();
|
||||
m_infoLog = *reservedError;
|
||||
AdoptCompileNode(MakeShared<ShaderCompileTask>(m_stage, m_source, sourceHash, env, m_preprocessCache,
|
||||
m_externalIndex));
|
||||
|
||||
if (!runOnPool) {
|
||||
m_compiled->RunInline();
|
||||
// Inline means the node is already terminal, so this join only replays
|
||||
// diagnostics; it is here so the synchronous and asynchronous paths publish
|
||||
// through the identical code.
|
||||
EnsureCompileJoined();
|
||||
return;
|
||||
}
|
||||
|
||||
// Compile for OpenGL here, so that we can do validation and link
|
||||
// like a real OpenGL driver at linking stage
|
||||
// Will compile for other backends later.
|
||||
ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(m_stage),
|
||||
.sourceStr = compileSource,
|
||||
.flags = ShaderCompileBits::CompileForOpenGL};
|
||||
|
||||
auto result = ShaderCompiler::CompileShader(attrib);
|
||||
if (result) {
|
||||
m_compileStatus = true;
|
||||
m_shader = result.value();
|
||||
m_infoLog.clear();
|
||||
} else {
|
||||
m_compileStatus = false;
|
||||
m_shader.reset();
|
||||
m_infoLog = result.error().log;
|
||||
MGLOG_D("ShaderObject::Compile: Shader %d compilation failed.\nSource:\n%s\nInfoLog:\n%s\nSetting "
|
||||
"m_compileStatus = false as a result.",
|
||||
m_externalIndex, compileSource.c_str(), m_infoLog.c_str());
|
||||
}
|
||||
// Registered BEFORE the post, so the very next glCompileShader in this burst can
|
||||
// adopt it however fast a worker picks it up. Registration is an index entry only -
|
||||
// the map holds a WeakPtr and never keeps a node alive.
|
||||
if (m_adoptionMap) m_adoptionMap->Register(m_compiled);
|
||||
MG_Util::Async::ShaderCompilePool::Get().Post(m_compiled);
|
||||
}
|
||||
|
||||
void ShaderObject::MarkAsDeleted() {
|
||||
|
||||
@@ -8,48 +8,229 @@
|
||||
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderStage.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderCompileTask.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h>
|
||||
|
||||
namespace MobileGL {
|
||||
enum class ShaderStage {
|
||||
Vertex,
|
||||
TessControl,
|
||||
TessEval,
|
||||
Geometry,
|
||||
Fragment,
|
||||
Compute,
|
||||
ShaderStageCount,
|
||||
Unknown = -1
|
||||
};
|
||||
|
||||
namespace MG_State::GLState {
|
||||
// The GL-visible shader name. It owns the source text and one compile job node; the
|
||||
// job node owns everything a compile produces.
|
||||
//
|
||||
// Every member below is GL-thread-owned, and every read of worker-produced state
|
||||
// goes through Compiled(), which joins first. That is invariant I5 of the P1 design:
|
||||
// because Compiled() is the SOLE accessor of the node's artifacts, the compiler
|
||||
// enumerates every reader for us and none can be forgotten.
|
||||
class ShaderObject {
|
||||
public:
|
||||
ShaderObject(const ShaderStage stage, Uint externalIndex)
|
||||
: m_stage(stage), m_externalIndex(externalIndex) {}
|
||||
// `preprocessCache` is the owning context's cross-object memo (P0b layer 2).
|
||||
// Null is fully supported and means two things at once: "no sharing", and
|
||||
// "compile inline, never on a worker". Those coincide exactly - the only
|
||||
// cache-less shader objects are the internal ones (ProgramObject's default
|
||||
// fragment shader, the DirectVulkan blit and depth-mipmap shaders) and every one
|
||||
// of them compiles and reads its status in the same breath, so a job would only
|
||||
// add a round trip. Shared ownership rather than a raw pointer: a compile job
|
||||
// outlives neither the object nor the context deterministically, and the cache
|
||||
// has to stay alive for whoever is still reading it.
|
||||
//
|
||||
// `adoptionMap` is the same context's stage-6 index of adoptable compile nodes.
|
||||
// It is non-null exactly when `preprocessCache` is (ProgramState hands both out
|
||||
// together, and nobody else hands out either), which is what makes "no cache"
|
||||
// keep meaning "compile inline, share nothing": an internal shader object has
|
||||
// neither, so it neither adopts nor registers and its path is byte-identical to
|
||||
// the pre-stage-6 one. GL-thread-only, so unlike the cache it carries no lock -
|
||||
// shared ownership only because a ShaderObject may outlive the context's tables.
|
||||
ShaderObject(const ShaderStage stage, Uint externalIndex,
|
||||
SharedPtr<ShaderPreprocessCache> preprocessCache = nullptr,
|
||||
SharedPtr<ShaderCompileAdoptionMap> adoptionMap = nullptr)
|
||||
: m_stage(stage), m_externalIndex(externalIndex), m_preprocessCache(Move(preprocessCache)),
|
||||
m_adoptionMap(Move(adoptionMap)) {}
|
||||
// Cancel-not-join: the node owns its inputs, so an in-flight compile whose
|
||||
// object just went away is safe to abandon where it stands. Nothing can observe
|
||||
// its result any more - unless another shader object adopted the same node, or a
|
||||
// link pinned it, which is precisely what ReleaseCompileNode() checks.
|
||||
~ShaderObject() {
|
||||
ReleaseCompileNode();
|
||||
// ReleaseCompileNode KEEPS a node that has already gone terminal - there is
|
||||
// nothing left to stop, so it is not a release at all. This object is going
|
||||
// away regardless, so hand the adopter slot back here. That is what keeps
|
||||
// ShaderCompileTask::AdopterCount() exactly "how many live ShaderObjects hold
|
||||
// this node" instead of merely an upper bound.
|
||||
DropCompileNode();
|
||||
}
|
||||
|
||||
ShaderObject(const ShaderObject&) = delete;
|
||||
ShaderObject& operator=(const ShaderObject&) = delete;
|
||||
|
||||
void SetShaderSource(const String& source);
|
||||
void SetShaderSource(String&& source);
|
||||
void Compile();
|
||||
// Gives up this object's claim on its compile node, cancelling the node only if
|
||||
// this object was its LAST claimant. Called at the points where the object's
|
||||
// compiled state stops being observable through THIS name: a real source change,
|
||||
// and the release of an orphaned shader name.
|
||||
//
|
||||
// Named for what it does rather than for what it used to do: before stage 6 a
|
||||
// node had exactly one shader object, so giving up the claim and cancelling the
|
||||
// compile were the same act and this was CancelCompile(). They are not the same
|
||||
// act any more - see ShaderCompileTask::AddAdopter for the count discipline and
|
||||
// its single-threadedness argument. Never waits, in either case.
|
||||
void ReleaseCompileNode();
|
||||
void MarkAsDeleted();
|
||||
|
||||
// The compile job node itself, for ProgramObject::Link()'s input snapshot.
|
||||
// DELIBERATELY DOES NOT JOIN, and that is the entire point of stage 4: the link
|
||||
// takes the node as a dependency and is posted only once the node is terminal,
|
||||
// so glLinkProgram never blocks on glCompileShader. Null means this object has
|
||||
// never been compiled (or its last compile was abandoned), which the link reads
|
||||
// as COMPILE_STATUS false - the same verdict the joining path produces.
|
||||
//
|
||||
// The caller must MarkLinkReferenced() whatever it keeps: from here on the node's
|
||||
// result has an observer this object knows nothing about (see the marker's
|
||||
// comment in ShaderCompileTask.h).
|
||||
const SharedPtr<ShaderCompileTask>& CompiledNodeForLink() const { return m_compiled; }
|
||||
|
||||
Uint GetExternalIndex() const { return m_externalIndex; }
|
||||
ShaderStage GetShaderStage() const { return m_stage; }
|
||||
const String& GetShaderSource() const { return m_source; }
|
||||
const SharedPtr<glslang::TShader>& GetCompiledShader() const { return m_shader; }
|
||||
const String& GetInfoLog() const { return m_infoLog; }
|
||||
const UnorderedMap<String, Uint>& GetUniformLocations() const { return m_uniforms; }
|
||||
Bool GetCompileStatus() const { return m_compileStatus; }
|
||||
// No join: the source is GL-thread-owned, and a worker only ever reads the
|
||||
// immutable snapshot it was handed at enqueue.
|
||||
const String& GetShaderSource() const { return *m_source; }
|
||||
// The snapshot itself, for whoever needs to hand it to a job.
|
||||
const SharedPtr<const String>& GetShaderSourcePtr() const { return m_source; }
|
||||
|
||||
const SharedPtr<glslang::TShader>& GetCompiledShader() const { return Compiled().shader; }
|
||||
const String& GetInfoLog() const { return Compiled().infoLog; }
|
||||
// Explicit layout(location = N) qualifiers on this shader's default-block
|
||||
// uniforms, captured lexically at Compile() because the relaxed parse drops
|
||||
// them from reflection (see ExtractExplicitUniformLocations).
|
||||
const UnorderedMap<String, Int>& GetExplicitUniformLocations() const {
|
||||
return Compiled().explicitUniformLocations;
|
||||
}
|
||||
// Explicit layout(binding = N) on sampler/image uniforms - their initial
|
||||
// texture/image units - captured lexically for the same reason (see
|
||||
// ExtractExplicitOpaqueBindings).
|
||||
const UnorderedMap<String, Uint>& GetExplicitOpaqueBindings() const {
|
||||
return Compiled().explicitOpaqueBindings;
|
||||
}
|
||||
Bool GetCompileStatus() const { return Compiled().compileStatus; }
|
||||
Bool GetDeleteStatus() const { return m_deleteStatus; }
|
||||
|
||||
// Blocks until a pending compile has published its artifacts. Public for the
|
||||
// sites that must join without reading anything - ProgramObject::Link's
|
||||
// prologue, which needs every attached shader settled before it runs.
|
||||
void JoinCompile() const { EnsureCompileJoined(); }
|
||||
|
||||
// True while this object holds the outcome (success OR failure) of a Compile()
|
||||
// of exactly the source it currently holds - i.e. while the P0b layer-1 memo is
|
||||
// armed and a glCompileShader would be a no-op. Diagnostics and tests only;
|
||||
// nothing in the GL frontend branches on it.
|
||||
//
|
||||
// Tri-state, and deliberately NOT joining: an in-flight compile of the current
|
||||
// source counts as memoized (a second glCompileShader must not enqueue a
|
||||
// duplicate job), but asking that question must never block.
|
||||
// A node that settled as Cancelled (the job body threw, or the enqueue failed)
|
||||
// carries no result, so it must NOT satisfy the memo: otherwise a second
|
||||
// glCompileShader on the same source enqueues nothing and the eventual join
|
||||
// reports GL_FALSE forever. The synchronous path retries in exactly this case.
|
||||
Bool HasMemoizedCompile() const {
|
||||
return m_compiled != nullptr && m_compiled->source == m_source && !m_compiled->IsCancelled();
|
||||
}
|
||||
|
||||
// MUST NOT JOIN - this is what GL_COMPLETION_STATUS_KHR will read when the
|
||||
// extension surface lands. "No job at all" counts as complete: there is nothing
|
||||
// outstanding to wait for.
|
||||
Bool IsCompileComplete() const { return m_compiled == nullptr || m_compiled->IsTerminal(); }
|
||||
|
||||
private:
|
||||
// ---- The one and only join gate for compile output (P1 invariant I5) ----
|
||||
// The fast path - no job, or a job whose result this object has already pulled -
|
||||
// is two predictable branches and stays inline: it runs on every Compiled() read
|
||||
// and the project never builds with LTO, so an out-of-line body would be a real
|
||||
// cross-TU call at each of those sites. The blocking half is out of line.
|
||||
//
|
||||
// The gate keys on "has this object pulled the job's result yet", NOT on "is the
|
||||
// job terminal". Those differ in the case that matters: a worker can finish a
|
||||
// compile before the GL thread ever looks at it, and the pull is where deferred
|
||||
// diagnostics get replayed and an abandoned node gets dropped. Keying on
|
||||
// terminality would silently skip both.
|
||||
void EnsureCompileJoined() const {
|
||||
if (m_compiled && !m_compileJoined) JoinPendingCompile();
|
||||
}
|
||||
void JoinPendingCompile() const;
|
||||
|
||||
// The artifacts of a compile that ran to completion. A node that was abandoned
|
||||
// (cancelled at teardown, or whose body threw) never publishes: JoinPendingCompile
|
||||
// drops it, so anything reachable here is either Complete or absent, and "absent"
|
||||
// reads as the never-compiled defaults - COMPILE_STATUS false, empty info log,
|
||||
// which is exactly what GL requires before the first glCompileShader.
|
||||
static const ShaderCompileArtifacts& EmptyArtifacts() {
|
||||
static const ShaderCompileArtifacts empty;
|
||||
return empty;
|
||||
}
|
||||
const ShaderCompileArtifacts& Compiled() const {
|
||||
EnsureCompileJoined();
|
||||
return m_compiled ? m_compiled->artifacts : EmptyArtifacts();
|
||||
}
|
||||
|
||||
void InvalidateCompiledState();
|
||||
|
||||
// ---- the ONLY two writers of m_compiled (P1 stage 6) ----
|
||||
// Every adopter-count mutation lives in these two, which is what makes "exactly
|
||||
// one AddAdopter per hold, exactly one ReleaseAdopter per hold" auditable rather
|
||||
// than something review has to re-derive at each call site. DropCompileNode is
|
||||
// null-guarded, so calling it on an object that already let go is a no-op and a
|
||||
// double release is unrepresentable.
|
||||
void AdoptCompileNode(SharedPtr<ShaderCompileTask> node) const;
|
||||
void DropCompileNode() const;
|
||||
|
||||
// ---- P0b layer 1: per-object no-op recompile ----
|
||||
// True iff `candidate` is byte-identical to the source that produced (or is
|
||||
// producing) the compiled state this object currently holds.
|
||||
Bool SourceMatchesCompiledState(const String& candidate) const;
|
||||
|
||||
// ---- GL-thread-owned state: never produced by a compile, so it never joins ----
|
||||
const ShaderStage m_stage;
|
||||
const Uint m_externalIndex = 0;
|
||||
const ShaderStage m_stage;
|
||||
String m_source;
|
||||
SharedPtr<glslang::TShader> m_shader;
|
||||
UnorderedMap<String, Uint> m_uniforms;
|
||||
// The pre-glShaderSource state, shared by every untouched object rather than
|
||||
// allocated per glCreateShader.
|
||||
static const SharedPtr<const String>& EmptySource() {
|
||||
static const SharedPtr<const String> empty = MakeShared<const String>();
|
||||
return empty;
|
||||
}
|
||||
// glShaderSource text, as an immutable snapshot. Never null. A job holds its own
|
||||
// SharedPtr to the exact string it was given, so replacing the source under a
|
||||
// running compile cannot race its storage - and the layer-1 memo collapses to a
|
||||
// pointer comparison against the job's snapshot, because the setter only swaps
|
||||
// the pointer when the text genuinely differs.
|
||||
//
|
||||
// Not necessarily unique to this object from stage 6 on: adopting a node also
|
||||
// takes that node's source snapshot (see Compile()), so N shader objects sharing
|
||||
// one compile share one copy of the text. The string is immutable and shared-
|
||||
// owned, so that is invisible to every reader.
|
||||
SharedPtr<const String> m_source = EmptySource();
|
||||
|
||||
// P0b layer 2: the owning context's cross-object memo, or null. Internally
|
||||
// locked, because several workers hit it at once.
|
||||
const SharedPtr<ShaderPreprocessCache> m_preprocessCache;
|
||||
// P1 stage 6: the owning context's index of adoptable compile nodes, or null.
|
||||
// Touched only from Compile(), i.e. only on the GL thread, so it carries no lock.
|
||||
const SharedPtr<ShaderCompileAdoptionMap> m_adoptionMap;
|
||||
|
||||
String m_infoLog;
|
||||
Bool m_deleteStatus = false;
|
||||
Bool m_compileStatus = false;
|
||||
|
||||
// ---- Compile OUTPUT ---- pending OR completed; reachable only through Compiled().
|
||||
// Mutable because the join is a read-side operation: a const getter has to be
|
||||
// able to settle an outstanding job before answering.
|
||||
//
|
||||
// SHARED from stage 6 on: several shader objects holding byte-identical source
|
||||
// under the same CompileEnv point at one node. Every read below still goes
|
||||
// through the same join gate, and a second joiner finds the node already
|
||||
// terminal, so nothing about the read path changes - only the release path does
|
||||
// (ReleaseCompileNode).
|
||||
mutable SharedPtr<ShaderCompileTask> m_compiled;
|
||||
// Exactly-once latch for the pull above. Armed with every new job node, set by
|
||||
// the one join that consumes it.
|
||||
mutable Bool m_compileJoined = false;
|
||||
};
|
||||
} // namespace MG_State::GLState
|
||||
} // namespace MobileGL
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#include "ShaderPreprocessCache.h"
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
ShaderPreprocessResultPtr ShaderPreprocessCache::Find(const ShaderStage stage, const Uint64 sourceHash,
|
||||
const String& source, const Uint64 envFingerprint) const {
|
||||
const Key key{.stage = stage,
|
||||
.sourceHash = sourceHash,
|
||||
.sourceLength = source.length(),
|
||||
.envFingerprint = envFingerprint};
|
||||
|
||||
const std::lock_guard<std::mutex> lock(m_mutex);
|
||||
const auto it = m_index.find(key);
|
||||
if (it == m_index.end()) return nullptr;
|
||||
|
||||
// Never let correctness ride on a 64-bit hash: confirm the hit byte for byte.
|
||||
// Lengths already matched (they are part of the key), so this is a plain memcmp.
|
||||
const Entry& entry = *it->second;
|
||||
if (entry.originalSource != source) return nullptr;
|
||||
|
||||
// A copy of the SharedPtr, taken under the lock: the payload now outlives any
|
||||
// eviction the caller races with.
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
void ShaderPreprocessCache::Insert(const ShaderStage stage, const Uint64 sourceHash, const String& source,
|
||||
const Uint64 envFingerprint, ShaderPreprocessResultPtr result) {
|
||||
if (!result) return;
|
||||
|
||||
const SizeT entryBytes = EntryBytes(source, *result);
|
||||
// A single source bigger than the whole budget would evict every other entry and
|
||||
// then itself; refuse it instead of thrashing the cache empty.
|
||||
if (entryBytes > kMaxStoredSourceBytes) return;
|
||||
|
||||
const Key key{.stage = stage,
|
||||
.sourceHash = sourceHash,
|
||||
.sourceLength = source.length(),
|
||||
.envFingerprint = envFingerprint};
|
||||
|
||||
const std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (const auto existing = m_index.find(key); existing != m_index.end()) {
|
||||
// Either a re-insert of the same source (harmless) or a genuine hash collision
|
||||
// with a different source. Both are resolved by letting the newcomer win: one
|
||||
// entry per key keeps the index a plain map, and a collision is astronomically
|
||||
// rare enough that the loser simply misses.
|
||||
EraseEntryLocked(existing->second);
|
||||
}
|
||||
|
||||
m_entries.push_back(Entry{.key = key, .originalSource = source, .result = Move(result)});
|
||||
m_index[key] = std::prev(m_entries.end());
|
||||
m_storedSourceBytes += entryBytes;
|
||||
|
||||
EvictUntilWithinBudgetLocked();
|
||||
}
|
||||
|
||||
void ShaderPreprocessCache::Clear() {
|
||||
const std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_entries.clear();
|
||||
m_index.clear();
|
||||
m_storedSourceBytes = 0;
|
||||
}
|
||||
|
||||
void ShaderPreprocessCache::EraseEntryLocked(const EntryList::iterator it) {
|
||||
const SizeT bytes = EntryBytes(it->originalSource, *it->result);
|
||||
m_storedSourceBytes = bytes > m_storedSourceBytes ? 0 : m_storedSourceBytes - bytes;
|
||||
m_index.erase(it->key);
|
||||
m_entries.erase(it);
|
||||
}
|
||||
|
||||
void ShaderPreprocessCache::EvictUntilWithinBudgetLocked() {
|
||||
// FIFO: the oldest insertion goes first. Insert() already refuses entries larger
|
||||
// than the byte budget, so this loop always terminates with at least the entry
|
||||
// that was just added still resident.
|
||||
while (!m_entries.empty() &&
|
||||
(m_entries.size() > kMaxEntries || m_storedSourceBytes > kMaxStoredSourceBytes)) {
|
||||
EraseEntryLocked(m_entries.begin());
|
||||
}
|
||||
}
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
@@ -0,0 +1,149 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.h
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
#include <list>
|
||||
#include <mutex>
|
||||
// Deliberately NOT ShaderObject.h: ShaderCompileTask.h needs this header, and ShaderObject.h
|
||||
// needs ShaderCompileTask.h. Only ShaderStage was ever used from there.
|
||||
#include <MG_State/GLState/ProgramState/ShaderStage.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderSourceKey.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// Where the shared, source-only half of ShaderObject::Compile() stopped. The two
|
||||
// rejection verdicts are kept apart (rather than collapsed into "failed") so a hit
|
||||
// reproduces the original diagnosis, not just the original info log.
|
||||
enum class ShaderPreprocessOutcome : Uint8 {
|
||||
// The source-only half ran clean; preprocessedSource and both maps are valid.
|
||||
Preprocessed,
|
||||
// ValidateComputeLocalSizeLimits rejected it (compute only).
|
||||
ComputeLocalSizeRejected,
|
||||
// FindReservedIdentifierViolation rejected it.
|
||||
ReservedIdentifierRejected,
|
||||
// The source-only half was clean but glslang rejected the preprocessed source.
|
||||
// Memoizing this saves the parse itself on every later object with that source.
|
||||
ParseFailed,
|
||||
};
|
||||
|
||||
// Everything ShaderObject::Compile() derives from the source text alone, i.e.
|
||||
// everything that is identical for two shader objects holding byte-identical source.
|
||||
struct ShaderPreprocessResult {
|
||||
ShaderPreprocessOutcome outcome = ShaderPreprocessOutcome::Preprocessed;
|
||||
// Valid unless the preprocessor itself never ran; kept even for the rejection
|
||||
// outcomes because that is the text the diagnostics refer to.
|
||||
String preprocessedSource;
|
||||
UnorderedMap<String, Int> explicitUniformLocations;
|
||||
UnorderedMap<String, Uint> explicitOpaqueBindings;
|
||||
// The compile info log to publish; empty when outcome == Preprocessed.
|
||||
String infoLog;
|
||||
|
||||
Bool Preprocessed() const { return outcome == ShaderPreprocessOutcome::Preprocessed; }
|
||||
};
|
||||
|
||||
// Cache hits hand out shared ownership, not a raw pointer into the entry list. That is
|
||||
// what makes the cache safe once compiles run concurrently: a reader keeps its payload
|
||||
// alive across any eviction, and a 107 KB preprocessedSource is never copied on a hit.
|
||||
using ShaderPreprocessResultPtr = SharedPtr<const ShaderPreprocessResult>;
|
||||
|
||||
// P0b layer 2: a per-context, bounded memo of the source-only half of shader
|
||||
// compilation, keyed by (stage, xxhash64(source), source length).
|
||||
//
|
||||
// Motivation: in the Iris shader-pack corpus ~21% of every glCompileShader in a trace
|
||||
// is a *different* shader object holding byte-identical source (packs glue the same
|
||||
// common/composite GLSL into many program stages), so the preprocess + reserved-
|
||||
// identifier scan + explicit-location/binding extraction runs over the same megabytes
|
||||
// again and again. Layer 1 (in ShaderObject) covers the same object recompiled with
|
||||
// unchanged source; this covers the cross-object case.
|
||||
//
|
||||
// What is NOT cached: the glslang parse. glslang's TShader is consume-once (mapIO
|
||||
// mutates the aliased intermediate at link), so every shader object still needs its
|
||||
// own parse; only the text-processing half is shared.
|
||||
//
|
||||
// Correctness: the 64-bit hash is a lookup accelerator only. Every hit re-compares the
|
||||
// full stored original source with memcmp before it is honored, so a hash collision
|
||||
// degrades to a miss, never to a wrong answer. That is why the full original text is
|
||||
// stored rather than a prefix/suffix digest - the cache is bounded, so the cost is.
|
||||
//
|
||||
// Eviction: FIFO (insertion order), bounded by BOTH an entry count and a stored-source
|
||||
// byte budget, whichever binds first. FIFO rather than LRU because shader-pack loading
|
||||
// is a burst of mostly-distinct sources whose reuse clusters around insertion time;
|
||||
// LRU's extra list splice on every hit buys nothing measurable here, and FIFO keeps
|
||||
// Find() a genuinely const, read-only operation.
|
||||
class ShaderPreprocessCache {
|
||||
public:
|
||||
static constexpr SizeT kMaxEntries = 128;
|
||||
static constexpr SizeT kMaxStoredSourceBytes = 8u * 1024u * 1024u;
|
||||
|
||||
// Returns the memoized result for this exact source under this exact compile
|
||||
// environment, or null on a miss. The returned SharedPtr owns its payload, so it
|
||||
// stays valid for as long as the caller holds it - across Insert(), Clear(), and
|
||||
// across the destruction of the cache itself.
|
||||
//
|
||||
// envFingerprint joins the key because the source-only pipeline's compute
|
||||
// local-size verdict is computed against CompileEnv's device limits: a memo must
|
||||
// never outlive the environment it was computed against (memo-hazard rule).
|
||||
ShaderPreprocessResultPtr Find(ShaderStage stage, Uint64 sourceHash, const String& source,
|
||||
Uint64 envFingerprint) const;
|
||||
|
||||
// Memoizes `result` for this source. A source whose own storage cost already
|
||||
// exceeds the byte budget is simply not cached (caching it would evict everything
|
||||
// else and then itself).
|
||||
void Insert(ShaderStage stage, Uint64 sourceHash, const String& source, Uint64 envFingerprint,
|
||||
ShaderPreprocessResultPtr result);
|
||||
|
||||
void Clear();
|
||||
|
||||
static Uint64 HashSource(const String& source) {
|
||||
return static_cast<Uint64>(XXH64(source.data(), source.length(), 0));
|
||||
}
|
||||
|
||||
SizeT GetEntryCount() const {
|
||||
const std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_entries.size();
|
||||
}
|
||||
SizeT GetStoredSourceBytes() const {
|
||||
const std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_storedSourceBytes;
|
||||
}
|
||||
|
||||
private:
|
||||
// Shared with ShaderCompileAdoptionMap so the two per-context memos cannot key
|
||||
// themselves on different notions of "the same compile" - see ShaderSourceKey.h.
|
||||
using Key = ShaderSourceKey;
|
||||
using KeyHasher = ShaderSourceKeyHasher;
|
||||
|
||||
struct Entry {
|
||||
Key key;
|
||||
// The full original (pre-preprocess) source, kept so a hit can be confirmed by
|
||||
// comparison instead of trusting the hash.
|
||||
String originalSource;
|
||||
ShaderPreprocessResultPtr result;
|
||||
};
|
||||
|
||||
using EntryList = std::list<Entry>;
|
||||
|
||||
static SizeT EntryBytes(const String& source, const ShaderPreprocessResult& result) {
|
||||
return source.length() + result.preprocessedSource.length();
|
||||
}
|
||||
|
||||
void EvictUntilWithinBudgetLocked();
|
||||
|
||||
void EraseEntryLocked(EntryList::iterator it);
|
||||
|
||||
// P1: every public entry point takes this. The lock alone would NOT have been
|
||||
// enough - the old Find() handed back a raw pointer into an entry that a
|
||||
// concurrent Insert()'s FIFO eviction could erase while the caller was still
|
||||
// reading it. Shared ownership of the payload is what closes that hole; the mutex
|
||||
// only protects the containers below.
|
||||
mutable std::mutex m_mutex;
|
||||
EntryList m_entries; // front = oldest (FIFO victim)
|
||||
UnorderedMap<Key, EntryList::iterator, KeyHasher> m_index;
|
||||
SizeT m_storedSourceBytes = 0;
|
||||
};
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
@@ -0,0 +1,51 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderSourceKey.h
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#pragma once
|
||||
#include <Includes.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderStage.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// The identity of "one glCompileShader's worth of input" - the tuple that decides
|
||||
// whether two compiles must produce byte-identical results. Shared by the two
|
||||
// per-context memos keyed on it, so that neither can drift from the other:
|
||||
// * P0b's ShaderPreprocessCache, which memoizes the source-only half of a compile;
|
||||
// * P1 stage 6's ShaderCompileAdoptionMap, which shares the job NODE itself.
|
||||
//
|
||||
// The 64-bit source hash is a LOOKUP ACCELERATOR ONLY. Every user of this key confirms
|
||||
// a candidate hit with a full byte comparison of the stored source before honoring it,
|
||||
// so a hash collision degrades to a miss and never to a wrong answer. That rule is not
|
||||
// negotiable - see the memo-hazard notes on ShaderPreprocessCache.
|
||||
//
|
||||
// envFingerprint is part of the identity because the pipeline's compute local-size
|
||||
// verdict is computed against CompileEnv's device limits: a memo must never be handed
|
||||
// back under an environment other than the one it was computed against.
|
||||
struct ShaderSourceKey {
|
||||
ShaderStage stage = ShaderStage::Unknown;
|
||||
Uint64 sourceHash = 0;
|
||||
SizeT sourceLength = 0;
|
||||
Uint64 envFingerprint = 0;
|
||||
|
||||
Bool operator==(const ShaderSourceKey& other) const {
|
||||
return stage == other.stage && sourceHash == other.sourceHash &&
|
||||
sourceLength == other.sourceLength && envFingerprint == other.envFingerprint;
|
||||
}
|
||||
};
|
||||
|
||||
struct ShaderSourceKeyHasher {
|
||||
SizeT operator()(const ShaderSourceKey& key) const {
|
||||
// The source hash already spreads well; fold the three discriminators in so
|
||||
// that same-hash-different-stage/length/env keys land in different buckets.
|
||||
Uint64 mixed = key.sourceHash;
|
||||
mixed ^= static_cast<Uint64>(key.sourceLength) + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2);
|
||||
mixed ^= static_cast<Uint64>(static_cast<Int>(key.stage)) * 0xff51afd7ed558ccdull;
|
||||
mixed ^= key.envFingerprint + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2);
|
||||
return static_cast<SizeT>(mixed);
|
||||
}
|
||||
};
|
||||
} // namespace MobileGL::MG_State::GLState
|
||||
@@ -0,0 +1,25 @@
|
||||
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderStage.h
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace MobileGL {
|
||||
// Split out of ShaderObject.h so the compile pipeline's headers form a DAG:
|
||||
// ShaderStage.h <- ShaderPreprocessCache.h <- ShaderCompileTask.h <- ShaderObject.h.
|
||||
// Every existing includer of ShaderObject.h still sees this type unchanged.
|
||||
enum class ShaderStage {
|
||||
Vertex,
|
||||
TessControl,
|
||||
TessEval,
|
||||
Geometry,
|
||||
Fragment,
|
||||
Compute,
|
||||
ShaderStageCount,
|
||||
Unknown = -1
|
||||
};
|
||||
} // namespace MobileGL
|
||||
@@ -11,6 +11,23 @@
|
||||
namespace MobileGL {
|
||||
namespace MG_State {
|
||||
namespace GLState {
|
||||
namespace {
|
||||
// Overlapping OR abutting ([lo, hi) intervals meeting edge-to-edge) in
|
||||
// every axis: merging abutting boxes keeps scanline/tile write patterns
|
||||
// as one rect instead of a picket fence.
|
||||
Bool RegionsTouch(const MipmapDirtyRegion& a, const MipmapDirtyRegion& b) {
|
||||
return a.lo.x() <= b.hi.x() && b.lo.x() <= a.hi.x() && a.lo.y() <= b.hi.y() &&
|
||||
b.lo.y() <= a.hi.y() && a.lo.z() <= b.hi.z() && b.lo.z() <= a.hi.z();
|
||||
}
|
||||
|
||||
MipmapDirtyRegion RegionUnion(const MipmapDirtyRegion& a, const MipmapDirtyRegion& b) {
|
||||
return {IntVec3{std::min(a.lo.x(), b.lo.x()), std::min(a.lo.y(), b.lo.y()),
|
||||
std::min(a.lo.z(), b.lo.z())},
|
||||
IntVec3{std::max(a.hi.x(), b.hi.x()), std::max(a.hi.y(), b.hi.y()),
|
||||
std::max(a.hi.z(), b.hi.z())}};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
SizeT MipmapStorage::GetLevelCount() const {
|
||||
return m_data.size();
|
||||
}
|
||||
@@ -28,6 +45,7 @@ namespace MobileGL {
|
||||
m_texelSizes.resize(requiredLevelCount);
|
||||
m_isDirty.resize(requiredLevelCount, false);
|
||||
m_dirtyRegions.resize(requiredLevelCount);
|
||||
m_dirtyRects.resize(requiredLevelCount);
|
||||
m_compressedData.resize(requiredLevelCount);
|
||||
m_compressedFormats.resize(requiredLevelCount, GL_NONE);
|
||||
}
|
||||
@@ -44,6 +62,11 @@ namespace MobileGL {
|
||||
std::max(input.texelSize.z(), 1)}}
|
||||
: MipmapDirtyRegion{};
|
||||
}
|
||||
// The rect list mirrors the union box's reset: whatever rects were
|
||||
// pending measured the OLD extents. Empty list = union box tells all.
|
||||
if (level < m_dirtyRects.size()) {
|
||||
m_dirtyRects[level].clear();
|
||||
}
|
||||
auto& data = m_data[level];
|
||||
data.resize(input.byteSize, 0);
|
||||
|
||||
@@ -94,6 +117,7 @@ namespace MobileGL {
|
||||
m_texelSizes.resize(levelCount);
|
||||
m_isDirty.resize(levelCount);
|
||||
m_dirtyRegions.resize(levelCount);
|
||||
m_dirtyRects.resize(levelCount);
|
||||
m_compressedData.resize(levelCount);
|
||||
m_compressedFormats.resize(levelCount);
|
||||
}
|
||||
@@ -142,6 +166,13 @@ namespace MobileGL {
|
||||
m_dirtyRegions[level] = {};
|
||||
}
|
||||
}
|
||||
// Both directions collapse the rect list to "just the union box": a
|
||||
// whole-level dirty IS the union box, a clean level has nothing to say.
|
||||
// clear() keeps the vector's capacity, so per-frame streaming levels
|
||||
// allocate their slots once and reuse them.
|
||||
if (level < m_dirtyRects.size()) {
|
||||
m_dirtyRects[level].clear();
|
||||
}
|
||||
}
|
||||
|
||||
bool MipmapStorage::IsDirty(Uint level) const {
|
||||
@@ -158,6 +189,20 @@ namespace MobileGL {
|
||||
std::min(offset.y() + size.y(), levelSize.y()),
|
||||
std::min(offset.z() + std::max(size.z(), 1), std::max(levelSize.z(), 1))};
|
||||
if (incoming.Empty()) return;
|
||||
// Rect list first, while the union box still holds only the PREVIOUS
|
||||
// writes: a level that is already dirty with an empty list is in the
|
||||
// "union box tells all" resting state, so that box seeds the list
|
||||
// before the incoming rect refines it.
|
||||
if (level < m_dirtyRects.size()) {
|
||||
auto& rects = m_dirtyRects[level];
|
||||
if (!m_isDirty[level]) {
|
||||
rects.clear(); // stale-safety; MarkDirty(false) already cleared it
|
||||
} else if (rects.empty() && level < m_dirtyRegions.size() &&
|
||||
!m_dirtyRegions[level].Empty()) {
|
||||
rects.push_back(m_dirtyRegions[level]);
|
||||
}
|
||||
InsertDirtyRect(level, incoming);
|
||||
}
|
||||
if (level < m_dirtyRegions.size()) {
|
||||
MipmapDirtyRegion& region = m_dirtyRegions[level];
|
||||
if (m_isDirty[level] && !region.Empty()) {
|
||||
@@ -174,10 +219,82 @@ namespace MobileGL {
|
||||
m_isDirty[level] = true;
|
||||
}
|
||||
|
||||
void MipmapStorage::InsertDirtyRect(Uint level, MipmapDirtyRegion incoming) {
|
||||
auto& rects = m_dirtyRects[level];
|
||||
if (rects.capacity() < kMaxDirtyRects) {
|
||||
rects.reserve(kMaxDirtyRects);
|
||||
}
|
||||
// Cascade-merge: absorb every rect the incoming touches. The absorbed
|
||||
// union can reach rects a smaller box did not, so rescan until stable;
|
||||
// every merge shrinks the list, so this terminates. Swap-with-back keeps
|
||||
// removal O(1) - the list is unordered by design.
|
||||
Bool merged = true;
|
||||
while (merged) {
|
||||
merged = false;
|
||||
for (SizeT i = 0; i < rects.size(); ++i) {
|
||||
if (RegionsTouch(rects[i], incoming)) {
|
||||
incoming = RegionUnion(rects[i], incoming);
|
||||
rects[i] = rects.back();
|
||||
rects.pop_back();
|
||||
merged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rects.size() < kMaxDirtyRects) {
|
||||
rects.push_back(incoming);
|
||||
return;
|
||||
}
|
||||
// Full: fold the incoming rect into the neighbour whose box grows least
|
||||
// (least new area dragged into the upload), then re-insert the grown
|
||||
// box - it may now touch others. The removal above guarantees the
|
||||
// recursion appends on the second pass at the latest.
|
||||
SizeT best = 0;
|
||||
SizeT bestGrowth = ~static_cast<SizeT>(0);
|
||||
for (SizeT i = 0; i < rects.size(); ++i) {
|
||||
const SizeT growth = RegionUnion(rects[i], incoming).TexelCount() - rects[i].TexelCount();
|
||||
if (growth < bestGrowth) {
|
||||
bestGrowth = growth;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
incoming = RegionUnion(rects[best], incoming);
|
||||
rects[best] = rects.back();
|
||||
rects.pop_back();
|
||||
InsertDirtyRect(level, incoming);
|
||||
}
|
||||
|
||||
MipmapDirtyRegion MipmapStorage::GetDirtyRegion(Uint level) const {
|
||||
if (level >= m_dirtyRegions.size()) return {};
|
||||
return m_dirtyRegions[level];
|
||||
}
|
||||
|
||||
SizeT MipmapStorage::GetDirtyRects(Uint level, MipmapDirtyRegion* outRects, SizeT maxRects) const {
|
||||
if (outRects == nullptr || level >= m_dirtyRects.size() || level >= m_dirtyRegions.size()) {
|
||||
return 0;
|
||||
}
|
||||
const auto& rects = m_dirtyRects[level];
|
||||
// 0 or 1 rects: the union box already says exactly this. More than the
|
||||
// caller can take: never truncate - a dropped rect is a dropped write.
|
||||
if (rects.size() < 2 || rects.size() > maxRects) {
|
||||
return 0;
|
||||
}
|
||||
// Total-bytes accounting: when the scattered rects add up to most of
|
||||
// the union box anyway (>= 3/4), one driver call on the box beats many
|
||||
// calls moving nearly the same bytes.
|
||||
SizeT summedArea = 0;
|
||||
for (const auto& rect : rects) {
|
||||
summedArea += rect.TexelCount();
|
||||
}
|
||||
const SizeT unionArea = m_dirtyRegions[level].TexelCount();
|
||||
if (summedArea * 4 >= unionArea * 3) {
|
||||
return 0;
|
||||
}
|
||||
for (SizeT i = 0; i < rects.size(); ++i) {
|
||||
outRects[i] = rects[i];
|
||||
}
|
||||
return rects.size();
|
||||
}
|
||||
} // namespace GLState
|
||||
} // namespace MG_State
|
||||
} // namespace MobileGL
|
||||
|
||||
@@ -31,6 +31,11 @@ namespace MobileGL {
|
||||
return lo.x() <= 0 && lo.y() <= 0 && lo.z() <= 0 && hi.x() >= levelSize.x() &&
|
||||
hi.y() >= levelSize.y() && hi.z() >= std::max(levelSize.z(), 1);
|
||||
}
|
||||
SizeT TexelCount() const {
|
||||
if (Empty()) return 0;
|
||||
return static_cast<SizeT>(hi.x() - lo.x()) * static_cast<SizeT>(hi.y() - lo.y()) *
|
||||
static_cast<SizeT>(hi.z() - lo.z());
|
||||
}
|
||||
};
|
||||
|
||||
class MipmapStorage {
|
||||
@@ -54,6 +59,28 @@ namespace MobileGL {
|
||||
// Meaningful only while IsDirty(level).
|
||||
MipmapDirtyRegion GetDirtyRegion(Uint level) const;
|
||||
|
||||
// Behind the union box, the level keeps up to kMaxDirtyRects pairwise
|
||||
// disjoint rects recording WHERE the writes actually landed. A frame of
|
||||
// ~100 scattered sprite updates in a big atlas has a union box that
|
||||
// covers nearly the whole level while the touched texels are ~5% of it;
|
||||
// the union box stays the source of truth (every write funnels through
|
||||
// MarkDirty/MarkDirtyRegion into BOTH representations), backends OPT IN
|
||||
// to the list purely as an upload-size refinement. 96 slots because the
|
||||
// pattern this exists for is Minecraft's ~100 sprites/frame: a 16-slot
|
||||
// list forced into far-apart merges was measured at >90% of the union
|
||||
// box's area on exactly that pattern, i.e. worthless. Inserts merge any
|
||||
// touching/overlapping rect (cascading, so the list stays disjoint);
|
||||
// when full, the incoming rect folds into the neighbour whose box grows
|
||||
// least and the list degrades gracefully toward the union box.
|
||||
static constexpr SizeT kMaxDirtyRects = 96;
|
||||
// Copies the level's dirty rects into outRects and returns how many were
|
||||
// written. 0 means "upload the union box instead" and covers every
|
||||
// reason at once: tracking unavailable, a single rect (identical to the
|
||||
// union box by construction), more rects than maxRects, or a summed
|
||||
// area so close to the union box's that one big upload beats many small
|
||||
// ones (fewer driver calls wins when the bytes are nearly equal).
|
||||
SizeT GetDirtyRects(Uint level, MipmapDirtyRegion* outRects, SizeT maxRects) const;
|
||||
|
||||
// The bytes an application handed to glCompressedTexImage*, kept verbatim beside the
|
||||
// (uncompressed) texel shadow rather than in place of it. GL 4.6 core 8.11 requires
|
||||
// glGetCompressedTexImage to return the image *as stored*, and no backend here has a
|
||||
@@ -70,10 +97,22 @@ namespace MobileGL {
|
||||
const void* MapCompressedData(Uint level) const;
|
||||
|
||||
protected:
|
||||
// Insert one clamped, non-empty write box, keeping the list disjoint
|
||||
// and bounded (see kMaxDirtyRects).
|
||||
void InsertDirtyRect(Uint level, MipmapDirtyRegion incoming);
|
||||
|
||||
Vector<IntVec3> m_texelSizes;
|
||||
Vector<Vector<Uint8>> m_data;
|
||||
Vector<bool> m_isDirty;
|
||||
Vector<MipmapDirtyRegion> m_dirtyRegions;
|
||||
// Per level, the disjoint rect list behind m_dirtyRegions' union box.
|
||||
// An EMPTY list is the common resting state and always means "the union
|
||||
// box is the whole story" - clean levels, whole-level dirties and
|
||||
// respecifies all just clear it, so plain full-level uploads never pay
|
||||
// a heap allocation; the first scattered MarkDirtyRegion on an
|
||||
// already-dirty level seeds the list from the union box accumulated so
|
||||
// far and refines from there.
|
||||
Vector<Vector<MipmapDirtyRegion>> m_dirtyRects;
|
||||
Vector<Vector<Uint8>> m_compressedData;
|
||||
Vector<GLenum> m_compressedFormats;
|
||||
};
|
||||
|
||||
@@ -84,6 +84,12 @@ namespace MobileGL {
|
||||
return m_storage[targetIndex].GetDirtyRegion(level);
|
||||
}
|
||||
|
||||
SizeT GetDirtyRects(Uint targetIndex, Uint level, MipmapDirtyRegion* outRects,
|
||||
SizeT maxRects) const {
|
||||
MOBILEGL_ASSERT(targetIndex < TargetCount, "GetDirtyRects: target invalid");
|
||||
return m_storage[targetIndex].GetDirtyRects(level, outRects, maxRects);
|
||||
}
|
||||
|
||||
void SetCompressedImage(Uint targetIndex, Uint level, GLenum internalFormat, const void* data,
|
||||
SizeT size) {
|
||||
MOBILEGL_ASSERT(targetIndex < TargetCount, "SetCompressedImage: target invalid");
|
||||
|
||||
@@ -345,6 +345,13 @@ namespace MobileGL {
|
||||
return m_textureStorage.GetDirtyRegion(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel);
|
||||
}
|
||||
|
||||
SizeT TextureObjectWithOneMipmap::GetStorageDirtyRects(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
MipmapDirtyRegion* outRects,
|
||||
SizeT maxRects) const {
|
||||
return m_textureStorage.GetDirtyRects(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel,
|
||||
outRects, maxRects);
|
||||
}
|
||||
|
||||
void TextureObjectWithOneMipmap::SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
GLenum internalFormat, const void* data, SizeT size) {
|
||||
m_textureStorage.SetCompressedImage(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel,
|
||||
|
||||
@@ -186,6 +186,20 @@ namespace MobileGL::MG_State::GLState {
|
||||
const IntVec3 size = GetMipmapTexelSize(uploadTarget, mipmapLevel);
|
||||
return {IntVec3{0, 0, 0}, IntVec3{size.x(), size.y(), std::max(size.z(), 1)}};
|
||||
}
|
||||
// Scatter detail behind GetStorageDirtyRegion: up to maxRects disjoint rects
|
||||
// that together cover every dirty texel, so ~100 sprite writes in a big atlas
|
||||
// need not be uploaded as one atlas-sized box. Returns how many rects were
|
||||
// written to outRects; 0 means "no list, upload the union box" and is always a
|
||||
// safe answer - this base fallback keeps whole-level semantics for storage
|
||||
// classes that do not track rects, and backends OPT IN by calling this.
|
||||
virtual SizeT GetStorageDirtyRects(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
MipmapDirtyRegion* outRects, SizeT maxRects) const {
|
||||
(void)uploadTarget;
|
||||
(void)mipmapLevel;
|
||||
(void)outRects;
|
||||
(void)maxRects;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The compressed image a glCompressedTexImage* call shadowed for this level, kept verbatim
|
||||
// next to the texel data rather than instead of it - see MipmapStorage. The texel shadow
|
||||
@@ -257,6 +271,8 @@ namespace MobileGL::MG_State::GLState {
|
||||
void MarkStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel, IntVec3 offset,
|
||||
IntVec3 size) override;
|
||||
MipmapDirtyRegion GetStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel) const override;
|
||||
SizeT GetStorageDirtyRects(TextureUploadTarget uploadTarget, Uint mipmapLevel, MipmapDirtyRegion* outRects,
|
||||
SizeT maxRects) const override;
|
||||
void SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel, GLenum internalFormat,
|
||||
const void* data, SizeT size) override;
|
||||
GLenum GetMipmapCompressedFormat(TextureUploadTarget uploadTarget, Uint mipmapLevel) const override;
|
||||
|
||||
@@ -69,6 +69,12 @@ namespace MobileGL {
|
||||
return m_textureStorage.GetDirtyRegion(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel);
|
||||
}
|
||||
|
||||
SizeT TextureObject2DCube::GetStorageDirtyRects(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
MipmapDirtyRegion* outRects, SizeT maxRects) const {
|
||||
return m_textureStorage.GetDirtyRects(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel,
|
||||
outRects, maxRects);
|
||||
}
|
||||
|
||||
void TextureObject2DCube::SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
GLenum internalFormat, const void* data, SizeT size) {
|
||||
m_textureStorage.SetCompressedImage(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel,
|
||||
|
||||
@@ -31,6 +31,8 @@ namespace MobileGL {
|
||||
IntVec3 size) override;
|
||||
MipmapDirtyRegion GetStorageDirtyRegion(TextureUploadTarget uploadTarget,
|
||||
Uint mipmapLevel) const override;
|
||||
SizeT GetStorageDirtyRects(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
MipmapDirtyRegion* outRects, SizeT maxRects) const override;
|
||||
void SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
GLenum internalFormat, const void* data, SizeT size) override;
|
||||
GLenum GetMipmapCompressedFormat(TextureUploadTarget uploadTarget, Uint mipmapLevel) const override;
|
||||
|
||||
@@ -8,7 +8,18 @@
|
||||
|
||||
#include "VertexArrayObject.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// Starts at 1 so a zero-initialized memo slot can never carry a live object's id.
|
||||
// Atomic because VAOs are GL-thread-only today but the counter costs nothing to
|
||||
// make safe, and a duplicate id would resurrect exactly the bug it exists to kill.
|
||||
static std::atomic<Uint64> s_nextVertexArrayLifetimeId{1};
|
||||
|
||||
Uint64 VertexArrayObject::AllocateLifetimeId() {
|
||||
return s_nextVertexArrayLifetimeId.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
VertexArrayObject::VertexArrayObject(Uint externIndex) : m_externalIndex(externIndex) {
|
||||
for (int index = 0; index < MAX_VERTEX_ATTRIBS; ++index) {
|
||||
auto& attr = m_attributes[index];
|
||||
|
||||
@@ -84,6 +84,18 @@ namespace MobileGL {
|
||||
|
||||
Uint GetExternalIndex() const;
|
||||
|
||||
// Globally-unique, never-reused id for THIS object's lifetime - the same
|
||||
// contract as ProgramObject::GetLifetimeId(), and needed for the same
|
||||
// reason. Neither the GL name (freed to a LIFO list and handed straight
|
||||
// back by the next glGenVertexArrays) nor the heap address (freed to the
|
||||
// allocator and handed straight back by the next allocation of this size)
|
||||
// can tell a deleted-and-recreated VAO from the original, so a backend
|
||||
// memo keyed on either one silently inherits the dead object's contents.
|
||||
// That is not hypothetical: it is what let a transform-feedback capture
|
||||
// fetch a destroyed VAO's vertex buffer slice (see the VaoDrawMemo key in
|
||||
// DirectVulkan's VulkanRenderer).
|
||||
Uint64 GetLifetimeId() const { return m_lifetimeId; }
|
||||
|
||||
void SetAttributeDivisor(Uint index, Uint divisor);
|
||||
Uint GetAttributeDivisor(Uint index) const;
|
||||
|
||||
@@ -185,7 +197,10 @@ namespace MobileGL {
|
||||
return mapping;
|
||||
}
|
||||
|
||||
static Uint64 AllocateLifetimeId();
|
||||
|
||||
const Uint m_externalIndex = 0;
|
||||
const Uint64 m_lifetimeId = AllocateLifetimeId();
|
||||
Array<VertexAttribute, MAX_VERTEX_ATTRIBS> m_attributes;
|
||||
Array<VertexAttributeVersion, MAX_VERTEX_ATTRIBS> m_attributeVersions;
|
||||
BindingSlot<BufferObject> m_indexBufferBindingSlot;
|
||||
|
||||
@@ -598,3 +598,61 @@ TEST(TextureAnisotropyCapabilities, ExtensionPresenceIsDetectedExactly) {
|
||||
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(presentCaps, funcs));
|
||||
EXPECT_TRUE(presentCaps.SupportsTextureFilterAnisotropy);
|
||||
}
|
||||
|
||||
// eglGetProcAddress may return a non-NULL stub for an entry point the context does not
|
||||
// implement (NVIDIA's ES driver does exactly that for glMultiDrawElementsBaseVertexEXT and
|
||||
// the stub silently drops draws), so a resolved pointer must NEVER flip these flags on its
|
||||
// own: the extension string is the authority, and the pointer only confirms callability.
|
||||
TEST(MultiDrawCapabilities, PointerAloneNeverCountsAsSupport) {
|
||||
ResetFakeDriver();
|
||||
g_fake.maxVertexSsboBlocks = 0;
|
||||
auto funcs = MakeFakeGLESFunctions();
|
||||
// Simulate the stub hazard: every pointer resolved, no extension advertised.
|
||||
funcs.glMultiDrawArraysIndirectEXT = [](GLenum, const void*, GLsizei, GLsizei) {};
|
||||
funcs.glMultiDrawElementsIndirectEXT = [](GLenum, GLenum, const void*, GLsizei, GLsizei) {};
|
||||
funcs.glMultiDrawElementsBaseVertexEXT = [](GLenum, const GLsizei*, GLenum, const void* const*,
|
||||
GLsizei, const GLint*) {};
|
||||
|
||||
MobileGL::MG_External::GLESCapabilities stubCaps;
|
||||
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(stubCaps, funcs));
|
||||
EXPECT_FALSE(stubCaps.SupportsMultiDrawIndirect);
|
||||
EXPECT_FALSE(stubCaps.SupportsMultiDrawElementsBaseVertex);
|
||||
|
||||
// The real NVIDIA shape: both draw_elements_base_vertex extensions advertised but
|
||||
// GL_EXT_multi_draw_arrays missing, so glMultiDrawElementsBaseVertexEXT (added only by
|
||||
// their interaction with GL_EXT_multi_draw_arrays) is still a stub.
|
||||
ResetFakeDriver();
|
||||
g_fake.maxVertexSsboBlocks = 0;
|
||||
g_fake.extensions.emplace_back("GL_EXT_draw_elements_base_vertex");
|
||||
g_fake.extensions.emplace_back("GL_OES_draw_elements_base_vertex");
|
||||
MobileGL::MG_External::GLESCapabilities nvidiaShapedCaps;
|
||||
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(nvidiaShapedCaps, funcs));
|
||||
EXPECT_FALSE(nvidiaShapedCaps.SupportsMultiDrawElementsBaseVertex);
|
||||
|
||||
// Fully supported: extensions advertised and pointers resolved.
|
||||
ResetFakeDriver();
|
||||
g_fake.maxVertexSsboBlocks = 0;
|
||||
g_fake.extensions.emplace_back("GL_EXT_multi_draw_indirect");
|
||||
g_fake.extensions.emplace_back("GL_OES_draw_elements_base_vertex");
|
||||
g_fake.extensions.emplace_back("GL_EXT_multi_draw_arrays");
|
||||
MobileGL::MG_External::GLESCapabilities supportedCaps;
|
||||
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(supportedCaps, funcs));
|
||||
EXPECT_TRUE(supportedCaps.SupportsMultiDrawIndirect);
|
||||
EXPECT_TRUE(supportedCaps.SupportsMultiDrawElementsBaseVertex);
|
||||
}
|
||||
|
||||
TEST(MultiDrawCapabilities, ExtensionWithoutResolvedPointerIsNotSupport) {
|
||||
// Extensions advertised but the loader could not resolve the entry points (default fake
|
||||
// table leaves them null): the flags must stay false so no caller dereferences null.
|
||||
ResetFakeDriver();
|
||||
g_fake.maxVertexSsboBlocks = 0;
|
||||
g_fake.extensions.emplace_back("GL_EXT_multi_draw_indirect");
|
||||
g_fake.extensions.emplace_back("GL_EXT_draw_elements_base_vertex");
|
||||
g_fake.extensions.emplace_back("GL_EXT_multi_draw_arrays");
|
||||
const auto funcs = MakeFakeGLESFunctions();
|
||||
|
||||
MobileGL::MG_External::GLESCapabilities caps;
|
||||
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
|
||||
EXPECT_FALSE(caps.SupportsMultiDrawIndirect);
|
||||
EXPECT_FALSE(caps.SupportsMultiDrawElementsBaseVertex);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -74,6 +77,7 @@ add_subdirectory(Program)
|
||||
add_subdirectory(Query)
|
||||
add_subdirectory(Pipeline)
|
||||
add_subdirectory(ShaderTranspiler)
|
||||
add_subdirectory(Util)
|
||||
if (ENABLE_INTEGRATION_TESTS)
|
||||
add_subdirectory(Backend/DirectVulkan)
|
||||
endif()
|
||||
|
||||
@@ -13,10 +13,13 @@
|
||||
#include "Includes.h"
|
||||
#include "Init.h"
|
||||
#include <MG_Backend/BackendObjects.h>
|
||||
#include <MG_Backend/DirectGLES/DirectGLES.h>
|
||||
#include <MG_Backend/DirectGLES/Managers.h>
|
||||
#include <MG_Backend/DirectGLES/Utils.h>
|
||||
#include <MG_Impl/GLImpl/Buffer/GL_Buffer.h>
|
||||
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
|
||||
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
|
||||
#include <MG_Impl/GLImpl/RenderState/GL_RenderState.h>
|
||||
#include <MG_Impl/GLImpl/Texture/GL_Texture.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
|
||||
@@ -858,3 +861,394 @@ TEST_F(FramebufferTest, NonRenderableColorFormatsReportUnsupportedFramebuffer) {
|
||||
MG_Impl::GLImpl::ReadPixels(0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, pixelStorage);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_FRAMEBUFFER_OPERATION);
|
||||
}
|
||||
|
||||
// ---- Three-channel colour attachments: the Complementary Reimagined / Iris load failure --------
|
||||
//
|
||||
// Complementary declares colortex1 = RGB8_SNORM and colortex2 = RGB16F. No real OpenGL ES driver
|
||||
// renders to a three-channel image (EXT_render_snorm covers R/RG/RGBA only; EXT_color_buffer_float
|
||||
// excludes RGB16F), so the DirectGLES probe records those formats as creatable-but-not-renderable
|
||||
// and the frontend answered every framebuffer built from them GL_FRAMEBUFFER_UNSUPPORTED - which
|
||||
// Iris turns into a hard "Draw buffers [0, 1] Status: 36061" load failure. The backend now records
|
||||
// the four-channel substitution it will actually allocate as a caveat capability, and the frontend
|
||||
// has to accept that as renderable.
|
||||
namespace {
|
||||
class ThreeChannelAttachmentBackend final : public MG_Backend::BackendObject {
|
||||
public:
|
||||
// `substituted` stands in for a driver where the four-channel widening probe succeeded, i.e.
|
||||
// for what PopulateFormatCapabilitiesImpl records on Mali. false is the pre-fix state: the
|
||||
// native form is creatable, nothing is renderable, and no fallback was ever built.
|
||||
explicit ThreeChannelAttachmentBackend(Bool substituted) {
|
||||
auto& cache = MutableFormatCapabilities();
|
||||
const auto texture2DIndex = MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::Texture2D);
|
||||
|
||||
// IsColorInternalFormatRenderable only trusts the cache once it looks populated, which
|
||||
// it decides from RGBA8 being creatable somewhere. Without this the static deny-list
|
||||
// answers instead and the caveat below would never be consulted.
|
||||
const auto rgba8Index = static_cast<SizeT>(TextureInternalFormat::RGBA8);
|
||||
cache.FullCaps[texture2DIndex][rgba8Index] |= MG_Backend::FormatCapability::Creatable;
|
||||
cache.FullCaps[texture2DIndex][rgba8Index] |= MG_Backend::FormatCapability::FramebufferRenderable;
|
||||
cache.FullCaps[texture2DIndex][rgba8Index] |= MG_Backend::FormatCapability::ColorAttachment;
|
||||
|
||||
for (const TextureInternalFormat format :
|
||||
{TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16F}) {
|
||||
const auto formatIndex = static_cast<SizeT>(format);
|
||||
// Creatable and samplable as an ordinary texture, but the driver's
|
||||
// glCheckFramebufferStatus said no - exactly Mali r32p1's answer.
|
||||
cache.FullCaps[texture2DIndex][formatIndex] |= MG_Backend::FormatCapability::Creatable;
|
||||
cache.FullCaps[texture2DIndex][formatIndex] |= MG_Backend::FormatCapability::Sampled;
|
||||
if (substituted) {
|
||||
cache.CaveatCaps[texture2DIndex][formatIndex] |=
|
||||
MG_Backend::FormatCapability::FramebufferRenderable;
|
||||
cache.CaveatCaps[texture2DIndex][formatIndex] |= MG_Backend::FormatCapability::ColorAttachment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Initialize() override {}
|
||||
Bool InitCapabilities() override { return true; }
|
||||
Bool InitWindowSurface() override { return true; }
|
||||
const RendererInfo& GetRendererInfo() const override {
|
||||
static RendererInfo info = {};
|
||||
return info;
|
||||
}
|
||||
String GetBackendAPIVersionString() const override { return {}; }
|
||||
const MG_Backend::GlobalBackendFunctionsTable& GetBackendFunctions() const override {
|
||||
static MG_Backend::GlobalBackendFunctionsTable table = {};
|
||||
return table;
|
||||
}
|
||||
const MG_Backend::DynamicBackendParameters& GetDynamicParameters() const override {
|
||||
static MG_Backend::DynamicBackendParameters params = {};
|
||||
return params;
|
||||
}
|
||||
BackendType GetBackendType() const override { return BackendType::Unknown; }
|
||||
};
|
||||
|
||||
class ScopedBackendOverride {
|
||||
public:
|
||||
explicit ScopedBackendOverride(UniquePtr<MG_Backend::BackendObject> backend):
|
||||
m_previous(Move(MG_Backend::pActiveBackendObject)) {
|
||||
MG_Backend::pActiveBackendObject = Move(backend);
|
||||
}
|
||||
|
||||
~ScopedBackendOverride() { MG_Backend::pActiveBackendObject = Move(m_previous); }
|
||||
|
||||
private:
|
||||
UniquePtr<MG_Backend::BackendObject> m_previous;
|
||||
};
|
||||
|
||||
GLenum CheckSingleColorAttachmentStatus(GLenum internalFormat) {
|
||||
GLuint framebuffer = 0;
|
||||
GLuint texture = 0;
|
||||
MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer);
|
||||
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &texture);
|
||||
MG_Impl::GLImpl::TextureStorage2D(texture, 1, internalFormat, 4, 4);
|
||||
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, texture, 0);
|
||||
return MG_Impl::GLImpl::CheckNamedFramebufferStatus(framebuffer, GL_DRAW_FRAMEBUFFER);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_F(FramebufferTest, ThreeChannelColorAttachmentsAreUnsupportedWithoutTheWidenedSubstitution) {
|
||||
// The pre-fix behaviour, pinned so a regression is a red test rather than a shaderpack that
|
||||
// silently stops loading: no caveat capability, so nothing makes these renderable.
|
||||
ScopedBackendOverride backend(MakeUnique<ThreeChannelAttachmentBackend>(/*substituted=*/false));
|
||||
|
||||
EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB8_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_UNSUPPORTED));
|
||||
EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB16F), static_cast<GLenum>(GL_FRAMEBUFFER_UNSUPPORTED));
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
TEST_F(FramebufferTest, ThreeChannelColorAttachmentsAreCompleteThroughTheWidenedSubstitution) {
|
||||
ScopedBackendOverride backend(MakeUnique<ThreeChannelAttachmentBackend>(/*substituted=*/true));
|
||||
|
||||
// Complementary's colortex1 (RGB8_SNORM) and colortex2 (RGB16F): both must come out COMPLETE,
|
||||
// because the backend stores them as GL_RGBA16F. Shipping only the first would move the
|
||||
// failure one composite pass down instead of fixing it.
|
||||
EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB8_SNORM), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
||||
EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB16F), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
TEST_F(FramebufferTest, TwoAttachmentCompositeFramebufferMatchesIrisComplementaryPass) {
|
||||
// The exact framebuffer Iris failed on: Complementary's `composite` pass draws to colortex7
|
||||
// (RGBA16F, natively renderable) and colortex1 (RGB8_SNORM, only renderable widened). Iris
|
||||
// logs it as "Draw buffers [0, 1]" - a two-attachment FBO, not colortex 0 and 1.
|
||||
ScopedBackendOverride backend(MakeUnique<ThreeChannelAttachmentBackend>(/*substituted=*/true));
|
||||
|
||||
GLuint framebuffer = 0;
|
||||
GLuint colortex7 = 0;
|
||||
GLuint colortex1 = 0;
|
||||
MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer);
|
||||
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex7);
|
||||
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex1);
|
||||
MG_Impl::GLImpl::TextureStorage2D(colortex7, 1, GL_RGBA8, 4, 4);
|
||||
MG_Impl::GLImpl::TextureStorage2D(colortex1, 1, GL_RGB8_SNORM, 4, 4);
|
||||
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, colortex7, 0);
|
||||
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT1, colortex1, 0);
|
||||
|
||||
EXPECT_EQ(MG_Impl::GLImpl::CheckNamedFramebufferStatus(framebuffer, GL_DRAW_FRAMEBUFFER),
|
||||
static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
||||
|
||||
// Both entry points answer from the same helpers, and CheckFramebufferStatus is what Iris
|
||||
// actually calls; they are near-verbatim duplicates, so assert they agree.
|
||||
MG_Impl::GLImpl::BindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffer);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::CheckFramebufferStatus(GL_DRAW_FRAMEBUFFER),
|
||||
static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---- Widened attachments: the stored-alpha discipline -----------------------------------------
|
||||
//
|
||||
// A widened attachment has a real alpha channel the application's three-channel format does not,
|
||||
// and GL says a channel a format lacks reads back as 1.0. glReadPixels and glGetTexImage can be
|
||||
// made to say that (ForceWideReadAlphaToOne), but GL_DST_ALPHA / GL_ONE_MINUS_DST_ALPHA blending
|
||||
// and glBlitFramebuffer read the STORED alpha inside the driver, where nothing can intercept it.
|
||||
// So the stored alpha is held at 1.0 instead: a clear writes 1.0 into it, and every draw has that
|
||||
// buffer's alpha write mask forced off so nothing can move it again.
|
||||
//
|
||||
// These cases pin the two halves of that pairing at the seam where they are visible - what the ES
|
||||
// driver is actually handed - and pin the invariant that the application's own colour mask is
|
||||
// never touched.
|
||||
namespace {
|
||||
struct RecordedColorMask {
|
||||
Bool seen = false;
|
||||
GLboolean r = GL_FALSE, g = GL_FALSE, b = GL_FALSE, a = GL_FALSE;
|
||||
};
|
||||
|
||||
constexpr Uint kRecordedDrawBuffers = 8;
|
||||
RecordedColorMask g_driverIndexedColorMasks[kRecordedDrawBuffers];
|
||||
RecordedColorMask g_driverUniformColorMask;
|
||||
|
||||
void ResetRecordedColorMasks() {
|
||||
for (auto& recorded : g_driverIndexedColorMasks) recorded = {};
|
||||
g_driverUniformColorMask = {};
|
||||
}
|
||||
|
||||
void StubColorMask(GLboolean r, GLboolean g, GLboolean b, GLboolean a) {
|
||||
g_driverUniformColorMask = {true, r, g, b, a};
|
||||
// The non-indexed call sets every draw buffer, so record it as such: a later assertion
|
||||
// about draw buffer 1 must not read a stale indexed record the uniform push overwrote.
|
||||
for (auto& recorded : g_driverIndexedColorMasks) recorded = {true, r, g, b, a};
|
||||
}
|
||||
|
||||
void StubColorMaski(GLuint index, GLboolean r, GLboolean g, GLboolean b, GLboolean a) {
|
||||
if (index < kRecordedDrawBuffers) g_driverIndexedColorMasks[index] = {true, r, g, b, a};
|
||||
}
|
||||
|
||||
void StubViewport(GLint, GLint, GLsizei, GLsizei) {}
|
||||
void StubScissor(GLint, GLint, GLsizei, GLsizei) {}
|
||||
void StubEnable(GLenum) {}
|
||||
void StubDisable(GLenum) {}
|
||||
void StubEnablei(GLenum, GLuint) {}
|
||||
void StubDisablei(GLenum, GLuint) {}
|
||||
void StubBlendFuncSeparate(GLenum, GLenum, GLenum, GLenum) {}
|
||||
void StubBlendFuncSeparatei(GLuint, GLenum, GLenum, GLenum, GLenum) {}
|
||||
void StubBlendEquationSeparate(GLenum, GLenum) {}
|
||||
void StubBlendEquationSeparatei(GLuint, GLenum, GLenum) {}
|
||||
void StubBlendColor(GLfloat, GLfloat, GLfloat, GLfloat) {}
|
||||
void StubDepthFunc(GLenum) {}
|
||||
void StubDepthMask(GLboolean) {}
|
||||
void StubDepthRangef(GLfloat, GLfloat) {}
|
||||
void StubStencilFuncSeparate(GLenum, GLenum, GLint, GLuint) {}
|
||||
void StubStencilMaskSeparate(GLenum, GLuint) {}
|
||||
void StubStencilOpSeparate(GLenum, GLenum, GLenum, GLenum) {}
|
||||
void StubClearColor(GLfloat, GLfloat, GLfloat, GLfloat) {}
|
||||
void StubClearDepthf(GLfloat) {}
|
||||
void StubClearStencil(GLint) {}
|
||||
void StubCullFace(GLenum) {}
|
||||
void StubFrontFace(GLenum) {}
|
||||
void StubPolygonOffset(GLfloat, GLfloat) {}
|
||||
void StubLineWidth(GLfloat) {}
|
||||
void StubSampleCoverage(GLfloat, GLboolean) {}
|
||||
|
||||
// Replaces the ES function table with no-ops that record only what these cases assert on.
|
||||
// The table is ZEROED first on purpose: SyncRenderState is long, and a call it makes that
|
||||
// this fixture did not anticipate must crash here rather than silently reach a stale pointer
|
||||
// into a driver that this process never made current.
|
||||
class ScopedRenderStateDriverStubs {
|
||||
public:
|
||||
ScopedRenderStateDriverStubs():
|
||||
m_funcs(MG_Backend::DirectGLES::g_GLESFuncs), m_caps(MG_Backend::DirectGLES::g_GLESCapabilities) {
|
||||
auto& gl = MG_Backend::DirectGLES::g_GLESFuncs;
|
||||
gl = MG_External::GLESFunctionsTable{};
|
||||
gl.glViewport = StubViewport;
|
||||
gl.glScissor = StubScissor;
|
||||
gl.glEnable = StubEnable;
|
||||
gl.glDisable = StubDisable;
|
||||
gl.glEnablei = StubEnablei;
|
||||
gl.glDisablei = StubDisablei;
|
||||
gl.glBlendFuncSeparate = StubBlendFuncSeparate;
|
||||
gl.glBlendFuncSeparatei = StubBlendFuncSeparatei;
|
||||
gl.glBlendEquationSeparate = StubBlendEquationSeparate;
|
||||
gl.glBlendEquationSeparatei = StubBlendEquationSeparatei;
|
||||
gl.glBlendColor = StubBlendColor;
|
||||
gl.glDepthFunc = StubDepthFunc;
|
||||
gl.glDepthMask = StubDepthMask;
|
||||
gl.glDepthRangef = StubDepthRangef;
|
||||
gl.glStencilFuncSeparate = StubStencilFuncSeparate;
|
||||
gl.glStencilMaskSeparate = StubStencilMaskSeparate;
|
||||
gl.glStencilOpSeparate = StubStencilOpSeparate;
|
||||
gl.glClearColor = StubClearColor;
|
||||
gl.glClearDepthf = StubClearDepthf;
|
||||
gl.glClearStencil = StubClearStencil;
|
||||
gl.glCullFace = StubCullFace;
|
||||
gl.glFrontFace = StubFrontFace;
|
||||
gl.glPolygonOffset = StubPolygonOffset;
|
||||
gl.glLineWidth = StubLineWidth;
|
||||
gl.glSampleCoverage = StubSampleCoverage;
|
||||
gl.glColorMask = StubColorMask;
|
||||
gl.glColorMaski = StubColorMaski;
|
||||
|
||||
auto& caps = MG_Backend::DirectGLES::g_GLESCapabilities;
|
||||
caps.SupportsIndexedColorMask = true;
|
||||
caps.SupportsSrgbWriteControl = false;
|
||||
caps.SupportsPolygonMode = false;
|
||||
caps.SupportsDualSourceBlend = true;
|
||||
|
||||
ResetRecordedColorMasks();
|
||||
// The viewport and scissor blocks fall back to querying the surface size when the
|
||||
// frontend's rectangle is degenerate, and there is no surface in this process.
|
||||
MG_Impl::GLImpl::Viewport(0, 0, 4, 4);
|
||||
MG_Impl::GLImpl::Scissor(0, 0, 4, 4);
|
||||
MG_Backend::DirectGLES::RenderStateImpl::InvalidateSyncedRenderState();
|
||||
}
|
||||
|
||||
~ScopedRenderStateDriverStubs() {
|
||||
MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 0;
|
||||
MG_Backend::DirectGLES::g_GLESFuncs = m_funcs;
|
||||
MG_Backend::DirectGLES::g_GLESCapabilities = m_caps;
|
||||
// The shadow now describes pushes that went to the stubs, not to any driver.
|
||||
MG_Backend::DirectGLES::RenderStateImpl::InvalidateSyncedRenderState();
|
||||
MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
|
||||
}
|
||||
|
||||
private:
|
||||
MG_External::GLESFunctionsTable m_funcs;
|
||||
MG_External::GLESCapabilities m_caps;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
TEST_F(FramebufferTest, WidenedDrawBufferIsIdentifiedPerDrawBufferSlotNotPerAttachmentPoint) {
|
||||
ScopedBackendOverride backend(MakeUnique<ThreeChannelAttachmentBackend>(/*substituted=*/true));
|
||||
|
||||
// Complementary's `composite` framebuffer again: draw buffer 0 is a natively renderable
|
||||
// RGBA8, draw buffer 1 is the widened RGB8_SNORM. Only the second may be doctored.
|
||||
GLuint framebuffer = 0;
|
||||
GLuint colortex7 = 0;
|
||||
GLuint colortex1 = 0;
|
||||
MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer);
|
||||
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex7);
|
||||
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex1);
|
||||
MG_Impl::GLImpl::TextureStorage2D(colortex7, 1, GL_RGBA8, 4, 4);
|
||||
MG_Impl::GLImpl::TextureStorage2D(colortex1, 1, GL_RGB8_SNORM, 4, 4);
|
||||
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, colortex7, 0);
|
||||
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT1, colortex1, 0);
|
||||
|
||||
auto& framebufferObject = MG_State::pGLContext->GetFramebufferObject(framebuffer);
|
||||
ASSERT_NE(framebufferObject, nullptr);
|
||||
framebufferObject->SetDrawBuffer(0, FramebufferAttachmentType::Color0);
|
||||
framebufferObject->SetDrawBuffer(1, FramebufferAttachmentType::Color1);
|
||||
|
||||
EXPECT_EQ(MG_Backend::DirectGLES::FramebufferImpl::ComputeAlphaWidenedDrawBufferMask(*framebufferObject),
|
||||
1u << 1);
|
||||
|
||||
// Swapping the draw-buffer array moves the bit with the SLOT, not with the attachment point:
|
||||
// glColorMaski and glClearBufferfv both address slots.
|
||||
framebufferObject->SetDrawBuffer(0, FramebufferAttachmentType::Color1);
|
||||
framebufferObject->SetDrawBuffer(1, FramebufferAttachmentType::Color0);
|
||||
EXPECT_EQ(MG_Backend::DirectGLES::FramebufferImpl::ComputeAlphaWidenedDrawBufferMask(*framebufferObject),
|
||||
1u << 0);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
TEST_F(FramebufferTest, DrawIntoAWidenedDrawBufferReachesTheDriverWithAlphaWritesMaskedOff) {
|
||||
ScopedRenderStateDriverStubs driver;
|
||||
MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 1u << 1;
|
||||
|
||||
// What the application asked for: write every channel of every draw buffer.
|
||||
MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
|
||||
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
|
||||
|
||||
// What the driver was told. Draw buffer 0 is untouched; draw buffer 1 loses alpha.
|
||||
ASSERT_TRUE(g_driverIndexedColorMasks[0].seen);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[0].r, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[0].g, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[0].b, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[0].a, GL_TRUE);
|
||||
ASSERT_TRUE(g_driverIndexedColorMasks[1].seen);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[1].r, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[1].g, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[1].b, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE) << "a widened draw buffer must not take alpha writes";
|
||||
|
||||
// And what the application sees back. The doctoring lives entirely on the push; the frontend
|
||||
// state it is derived from is never written, so glGet still answers with the app's value.
|
||||
GLboolean appMask[4] = {GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE};
|
||||
MG_Impl::GLImpl::GetBooleanv(GL_COLOR_WRITEMASK, appMask);
|
||||
EXPECT_EQ(appMask[0], GL_TRUE);
|
||||
EXPECT_EQ(appMask[1], GL_TRUE);
|
||||
EXPECT_EQ(appMask[2], GL_TRUE);
|
||||
EXPECT_EQ(appMask[3], GL_TRUE) << "glGet(GL_COLOR_WRITEMASK) must report the application's mask";
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
TEST_F(FramebufferTest, ClearIntoAWidenedDrawBufferKeepsAlphaWritableAndSubstitutesOne) {
|
||||
ScopedRenderStateDriverStubs driver;
|
||||
MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 1u << 1;
|
||||
MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
|
||||
|
||||
// A draw first, so the mask really is doctored when the clear arrives...
|
||||
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
|
||||
ASSERT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE);
|
||||
|
||||
// ...and now the clear, with NOTHING changed in the frontend parameter block. The frontend's
|
||||
// render-state version has not moved, so only the purpose-aware memo can force this push -
|
||||
// without it the clear would inherit the draw's alpha-off mask and never write the 1.0.
|
||||
ResetRecordedColorMasks();
|
||||
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true);
|
||||
ASSERT_TRUE(g_driverIndexedColorMasks[1].seen) << "the clear must re-push the colour mask";
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_TRUE) << "a clear is what puts the 1.0 in the stored alpha";
|
||||
|
||||
// The value that clear writes: the application's RGB, alpha replaced by the 1.0 the
|
||||
// three-channel format implies, and only on the widened buffer.
|
||||
const GLfloat appColor[4] = {0.25f, 0.5f, 0.75f, 0.0f};
|
||||
GLfloat scratch[4] = {};
|
||||
const GLfloat* widened =
|
||||
MG_Backend::DirectGLES::FramebufferImpl::SubstituteWidenedClearAlpha(appColor, true, 1.0f, scratch);
|
||||
EXPECT_EQ(widened[0], 0.25f);
|
||||
EXPECT_EQ(widened[1], 0.5f);
|
||||
EXPECT_EQ(widened[2], 0.75f);
|
||||
EXPECT_EQ(widened[3], 1.0f);
|
||||
|
||||
const GLfloat* untouched =
|
||||
MG_Backend::DirectGLES::FramebufferImpl::SubstituteWidenedClearAlpha(appColor, false, 1.0f, scratch);
|
||||
EXPECT_EQ(untouched, appColor) << "a native attachment's clear must not even be copied";
|
||||
|
||||
// An integer widened format (GL_RGB8UI -> GL_RGBA8UI) carries the INTEGER one, not a
|
||||
// saturated field: glClearBufferuiv takes the value verbatim.
|
||||
const GLuint appIntegerColor[4] = {7u, 8u, 9u, 0u};
|
||||
GLuint integerScratch[4] = {};
|
||||
const GLuint* widenedInteger = MG_Backend::DirectGLES::FramebufferImpl::SubstituteWidenedClearAlpha(
|
||||
appIntegerColor, true, GLuint(1), integerScratch);
|
||||
EXPECT_EQ(widenedInteger[3], 1u);
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
TEST_F(FramebufferTest, ApplicationAlphaMaskOffIsStillHonouredOnANativeDrawBuffer) {
|
||||
// The doctoring only ever REMOVES alpha writes on a widened buffer; it must never add them
|
||||
// back on a buffer the application masked itself, and must never touch a native one.
|
||||
ScopedRenderStateDriverStubs driver;
|
||||
MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 1u << 1;
|
||||
|
||||
MG_Impl::GLImpl::ColorMaski(0, GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE);
|
||||
MG_Impl::GLImpl::ColorMaski(1, GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
|
||||
MG_Impl::GLImpl::ColorMaski(2, GL_FALSE, GL_TRUE, GL_FALSE, GL_TRUE);
|
||||
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
|
||||
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[0].a, GL_FALSE) << "the application's own alpha mask survives";
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE) << "the widened buffer loses alpha";
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[2].r, GL_FALSE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[2].g, GL_TRUE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[2].b, GL_FALSE);
|
||||
EXPECT_EQ(g_driverIndexedColorMasks[2].a, GL_TRUE) << "a native buffer keeps its alpha writes";
|
||||
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
// MobileGL - MobileGL/MG_Test/Program/AsyncCompileTest.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
// P1 stage 3: glCompileShader enqueues, and every observable read joins.
|
||||
//
|
||||
// Every test here drives the real GL entry points and flips
|
||||
// MG_Config::Features.AsyncShaderCompile itself rather than reading the environment. That is
|
||||
// what lets one binary assert the property that actually matters - the async path and the
|
||||
// synchronous path are indistinguishable through the GL surface - and it makes the file
|
||||
// behave identically whether or not the suite was launched with
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE=1.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Config.h"
|
||||
#include "Includes.h"
|
||||
#include "Init.h"
|
||||
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
|
||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||
#include "MG_State/GLState/Core.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Impl::GLImpl;
|
||||
|
||||
namespace {
|
||||
// Restores whatever the environment asked for when the test ends, so a case that forces
|
||||
// one mode cannot leak into the next.
|
||||
class AsyncModeScope {
|
||||
public:
|
||||
explicit AsyncModeScope(const Bool async)
|
||||
: m_saved(MG_Config::Features.AsyncShaderCompile) {
|
||||
MG_Config::Features.AsyncShaderCompile =
|
||||
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
|
||||
}
|
||||
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
|
||||
AsyncModeScope(const AsyncModeScope&) = delete;
|
||||
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
|
||||
|
||||
private:
|
||||
const MG_Config::QuirkOverride m_saved;
|
||||
};
|
||||
|
||||
const char* kVs = R"(#version 460
|
||||
layout(location = 0) in vec3 aPos;
|
||||
uniform mat4 uModel;
|
||||
uniform vec4 uColor;
|
||||
out vec4 vColor;
|
||||
void main() {
|
||||
vColor = uColor;
|
||||
gl_Position = uModel * vec4(aPos, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
const char* kFs = R"(#version 460
|
||||
in vec4 vColor;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
uniform float uAlpha;
|
||||
void main() { fragColor = vec4(vColor.rgb, vColor.a * uAlpha); }
|
||||
)";
|
||||
|
||||
// Fails in glslang, not in the lexical pre-checks: that routes through the same
|
||||
// ParseFailed path a real broken shaderpack source takes.
|
||||
const char* kBrokenFs = R"(#version 460
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
void main() { fragColor = thisIdentifierWasNeverDeclared; }
|
||||
)";
|
||||
|
||||
// Rejected by the lexical reserved-identifier scan, before glslang is ever reached - the
|
||||
// other half of the "compile failed" surface, and the one that never allocates a parse.
|
||||
const char* kReservedIdentifierFs = R"(#version 460
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
float gl_NotAllowedToDeclareThis = 1.0;
|
||||
void main() { fragColor = vec4(gl_NotAllowedToDeclareThis); }
|
||||
)";
|
||||
|
||||
// Big enough that a compile is not instantaneous, so the pool actually has a backlog to
|
||||
// observe. Templated on an index so every instance is a distinct source (no P0b hit).
|
||||
String MakeBulkySource(const int index) {
|
||||
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
|
||||
source += "uniform float uSeed" + std::to_string(index) + ";\n";
|
||||
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
|
||||
for (int i = 0; i < 220; ++i) {
|
||||
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
|
||||
}
|
||||
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
GLuint MakeShader(const GLenum type, const char* source) {
|
||||
const GLuint shader = CreateShader(type);
|
||||
ShaderSource(shader, 1, &source, nullptr);
|
||||
return shader;
|
||||
}
|
||||
|
||||
GLint QueryCompileStatus(const GLuint shader) {
|
||||
GLint status = GL_FALSE;
|
||||
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
String QueryShaderInfoLog(const GLuint shader) {
|
||||
GLint length = 0;
|
||||
GetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
|
||||
if (length <= 0) return String();
|
||||
std::vector<GLchar> buffer(static_cast<size_t>(length));
|
||||
GLsizei written = 0;
|
||||
GetShaderInfoLog(shader, length, &written, buffer.data());
|
||||
return String(buffer.data(), static_cast<size_t>(written));
|
||||
}
|
||||
|
||||
GLint QueryLinkStatus(const GLuint program) {
|
||||
GLint status = GL_FALSE;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
// The non-joining view of the object, i.e. what GL_COMPLETION_STATUS_KHR will report.
|
||||
Bool CompileIsSettled(const GLuint shader) {
|
||||
const auto& object = MG_State::pGLContext->GetShaderObject(shader);
|
||||
return object == nullptr || object->IsCompileComplete();
|
||||
}
|
||||
|
||||
Bool HasMemoizedCompile(const GLuint shader) {
|
||||
const auto& object = MG_State::pGLContext->GetShaderObject(shader);
|
||||
return object != nullptr && object->HasMemoizedCompile();
|
||||
}
|
||||
|
||||
// Enqueues `count` distinct heavy compiles and returns their names WITHOUT reading
|
||||
// anything back, so the pool is left with a real backlog for the caller to race against.
|
||||
Vector<GLuint> SaturatePool(const int count, Vector<String>& sourceStorage) {
|
||||
Vector<GLuint> shaders;
|
||||
shaders.reserve(static_cast<SizeT>(count));
|
||||
sourceStorage.reserve(sourceStorage.size() + static_cast<SizeT>(count));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
sourceStorage.push_back(MakeBulkySource(1000 + i));
|
||||
const char* text = sourceStorage.back().c_str();
|
||||
const GLuint shader = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(shader, 1, &text, nullptr);
|
||||
CompileShader(shader);
|
||||
shaders.push_back(shader);
|
||||
}
|
||||
return shaders;
|
||||
}
|
||||
|
||||
class AsyncCompileTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override { MobileGL::Initialize(); }
|
||||
};
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Correctness through the full GL surface
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// N shaders compiled with the flag on: every status, every info log and every link has to
|
||||
// come out the same as the synchronous path produces.
|
||||
TEST_F(AsyncCompileTest, ManyShadersCompileAndLinkCorrectlyWithAsyncOn) {
|
||||
const AsyncModeScope async(true);
|
||||
ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled());
|
||||
|
||||
constexpr int kCount = 24;
|
||||
Vector<GLuint> vertexShaders;
|
||||
Vector<GLuint> fragmentShaders;
|
||||
Vector<String> sources;
|
||||
sources.reserve(kCount);
|
||||
|
||||
// Enqueue everything first, read nothing: this is the shape a shaderpack load has, and
|
||||
// the only shape where the pool has more than one job in flight at a time.
|
||||
for (int i = 0; i < kCount; ++i) {
|
||||
vertexShaders.push_back(MakeShader(GL_VERTEX_SHADER, kVs));
|
||||
sources.push_back(MakeBulkySource(i));
|
||||
const char* text = sources.back().c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
fragmentShaders.push_back(fs);
|
||||
CompileShader(vertexShaders.back());
|
||||
}
|
||||
|
||||
for (int i = 0; i < kCount; ++i) {
|
||||
EXPECT_EQ(QueryCompileStatus(vertexShaders[i]), GL_TRUE) << QueryShaderInfoLog(vertexShaders[i]);
|
||||
EXPECT_EQ(QueryCompileStatus(fragmentShaders[i]), GL_TRUE) << QueryShaderInfoLog(fragmentShaders[i]);
|
||||
EXPECT_TRUE(QueryShaderInfoLog(vertexShaders[i]).empty());
|
||||
EXPECT_TRUE(QueryShaderInfoLog(fragmentShaders[i]).empty());
|
||||
}
|
||||
|
||||
// And the artifacts are actually usable: link, and reflect a uniform out of each stage.
|
||||
for (int i = 0; i < kCount; ++i) {
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vertexShaders[i]);
|
||||
AttachShader(program, fragmentShaders[i]);
|
||||
LinkProgram(program);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "program " << i;
|
||||
EXPECT_GE(GetUniformLocation(program, "uColor"), 0);
|
||||
EXPECT_GE(GetUniformLocation(program, ("uSeed" + std::to_string(i)).c_str()), 0);
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glCompileShader must return before the work is done. Timing-based assertions flake, so
|
||||
// this observes the state machine instead: with a saturated pool at least one of the just
|
||||
// -enqueued shaders has to be unsettled at the moment we ask. Skipped rather than failed if
|
||||
// the machine drained the whole batch first - it can then never be a false red.
|
||||
TEST_F(AsyncCompileTest, CompileShaderReturnsBeforeTheWorkIsDone) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> sources;
|
||||
const Vector<GLuint> shaders = SaturatePool(64, sources);
|
||||
|
||||
int unsettled = 0;
|
||||
for (const GLuint shader : shaders) {
|
||||
if (!CompileIsSettled(shader)) ++unsettled;
|
||||
}
|
||||
if (unsettled == 0) {
|
||||
GTEST_SKIP() << "the pool drained 64 compiles before the first observation; nothing to prove here";
|
||||
}
|
||||
|
||||
// Whatever was outstanding still has to produce the right answer once asked.
|
||||
for (const GLuint shader : shaders) {
|
||||
EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE) << QueryShaderInfoLog(shader);
|
||||
EXPECT_TRUE(CompileIsSettled(shader)) << "reading COMPILE_STATUS must have joined";
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The synchronous path must stay synchronous: with the flag off, a compile is finished by
|
||||
// the time glCompileShader returns. This is the guard that keeps the default shippable.
|
||||
TEST_F(AsyncCompileTest, CompileIsFullySynchronousWithAsyncOff) {
|
||||
const AsyncModeScope async(false);
|
||||
ASSERT_FALSE(MG_Util::Async::AsyncShaderCompileEnabled());
|
||||
|
||||
Vector<String> sources;
|
||||
const Vector<GLuint> shaders = SaturatePool(8, sources);
|
||||
for (const GLuint shader : shaders) {
|
||||
EXPECT_TRUE(CompileIsSettled(shader));
|
||||
EXPECT_TRUE(HasMemoizedCompile(shader));
|
||||
EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE) << QueryShaderInfoLog(shader);
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Diagnostics: the failing paths must read identically in both modes
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// A compile failure is reported through COMPILE_STATUS and the info log, never through
|
||||
// glGetError - that is exactly why moving the work off-thread is legal. Both failure
|
||||
// classes are covered: the glslang parse failure and the lexical reserved-identifier
|
||||
// rejection (which never reaches glslang at all).
|
||||
TEST_F(AsyncCompileTest, FailingCompileLogIsByteIdenticalAcrossModes) {
|
||||
for (const char* source : {kBrokenFs, kReservedIdentifierFs}) {
|
||||
String syncLog;
|
||||
{
|
||||
const AsyncModeScope async(false);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, source);
|
||||
CompileShader(fs);
|
||||
ASSERT_EQ(QueryCompileStatus(fs), GL_FALSE);
|
||||
syncLog = QueryShaderInfoLog(fs);
|
||||
EXPECT_FALSE(syncLog.empty());
|
||||
// GL defines compile FAILURE as a status plus a log, not as a GL error.
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
{
|
||||
const AsyncModeScope async(true);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, source);
|
||||
CompileShader(fs);
|
||||
EXPECT_EQ(QueryCompileStatus(fs), GL_FALSE);
|
||||
EXPECT_EQ(QueryShaderInfoLog(fs), syncLog);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A link whose vertex shader failed to compile has to reproduce that shader's log verbatim
|
||||
// inside the program info log, whichever thread produced it.
|
||||
TEST_F(AsyncCompileTest, LinkDiagnosticsQuoteTheAsyncCompileLog) {
|
||||
const AsyncModeScope async(true);
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
|
||||
CompileShader(vs);
|
||||
CompileShader(fs);
|
||||
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
// No status read between the enqueue and the link: the link's own prologue is what has
|
||||
// to join the two compiles.
|
||||
LinkProgram(program);
|
||||
EXPECT_EQ(QueryLinkStatus(program), GL_FALSE);
|
||||
|
||||
GLint length = 0;
|
||||
GetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
|
||||
ASSERT_GT(length, 1);
|
||||
std::vector<GLchar> buffer(static_cast<size_t>(length));
|
||||
GLsizei written = 0;
|
||||
GetProgramInfoLog(program, length, &written, buffer.data());
|
||||
const String programLog(buffer.data(), static_cast<size_t>(written));
|
||||
const String shaderLog = QueryShaderInfoLog(fs);
|
||||
ASSERT_FALSE(shaderLog.empty());
|
||||
EXPECT_NE(programLog.find(shaderLog), String::npos)
|
||||
<< "program log:\n" << programLog << "\nshader log:\n" << shaderLog;
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Mutation over an in-flight compile
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// glShaderSource with DIFFERENT text over a pending compile: the running job is abandoned
|
||||
// and the next compile reflects the new source. The re-source happens with the pool
|
||||
// saturated, so the job it replaces is very likely still queued or running.
|
||||
TEST_F(AsyncCompileTest, ShaderSourceOverAPendingCompileCancelsAndTheNewSourceWins) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String firstSource = MakeBulkySource(7001);
|
||||
const char* firstText = firstSource.c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &firstText, nullptr);
|
||||
CompileShader(fs);
|
||||
|
||||
// Replace the text while that compile is (very probably) still outstanding. This must
|
||||
// not wait, must not corrupt the abandoned job's view of the old string, and must
|
||||
// disarm the layer-1 memo.
|
||||
const String secondSource = MakeBulkySource(7002);
|
||||
const char* secondText = secondSource.c_str();
|
||||
ShaderSource(fs, 1, &secondText, nullptr);
|
||||
EXPECT_FALSE(HasMemoizedCompile(fs)) << "a real source change must invalidate the compiled state";
|
||||
EXPECT_EQ(QueryCompileStatus(fs), GL_FALSE) << "the replaced compile must not publish";
|
||||
|
||||
CompileShader(fs);
|
||||
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
|
||||
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
CompileShader(vs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE);
|
||||
// The SECOND source's uniform is the one that exists.
|
||||
EXPECT_GE(GetUniformLocation(program, "uSeed7002"), 0);
|
||||
EXPECT_EQ(GetUniformLocation(program, "uSeed7001"), -1);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glShaderSource with byte-identical text over a pending compile is a no-op: the job stays,
|
||||
// the memo stays armed, and the result is still the right one.
|
||||
TEST_F(AsyncCompileTest, IdenticalShaderSourceOverAPendingCompileKeepsTheJob) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String source = MakeBulkySource(7100);
|
||||
const char* text = source.c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
EXPECT_TRUE(HasMemoizedCompile(fs)) << "identical re-source must not disturb an in-flight compile";
|
||||
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// A second glCompileShader on a pending object must be a no-op, not a duplicate job racing
|
||||
// the first one to write the same fields. Observed through the object identity of the node:
|
||||
// HasMemoizedCompile stays true across the second call, and the result is still correct.
|
||||
TEST_F(AsyncCompileTest, RepeatedCompileShaderOnAPendingObjectEnqueuesOneJob) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String source = MakeBulkySource(7200);
|
||||
const char* text = source.c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
|
||||
// A copy, not the slot reference: creating another shader can reallocate the table.
|
||||
const SharedPtr<MG_State::GLState::ShaderObject> object = MG_State::pGLContext->GetShaderObject(fs);
|
||||
ASSERT_NE(object, nullptr);
|
||||
EXPECT_FALSE(object->HasMemoizedCompile());
|
||||
CompileShader(fs);
|
||||
EXPECT_TRUE(object->HasMemoizedCompile());
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
CompileShader(fs);
|
||||
EXPECT_TRUE(object->HasMemoizedCompile());
|
||||
}
|
||||
|
||||
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glDeleteShader on an unattached object with a compile still in flight. The name goes away
|
||||
// immediately - no wait for a worker - and the abandoned job must neither crash nor keep the
|
||||
// object alive in a way anything can observe.
|
||||
TEST_F(AsyncCompileTest, DeleteShaderWhileACompileIsPending) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
Vector<GLuint> doomed;
|
||||
Vector<String> sources;
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
sources.push_back(MakeBulkySource(7300 + i));
|
||||
const char* text = sources.back().c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
doomed.push_back(fs);
|
||||
}
|
||||
for (const GLuint fs : doomed) {
|
||||
DeleteShader(fs);
|
||||
EXPECT_EQ(IsShader(fs), GL_FALSE) << "an unattached deleted shader's name goes immediately";
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// The context still works afterwards - the abandoned jobs did not take the pool, the
|
||||
// preprocess cache or the glslang process state down with them.
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
|
||||
CompileShader(vs);
|
||||
CompileShader(fs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glDeleteShader on a shader still ATTACHED to a program only flags it: the pending compile
|
||||
// has to survive, because the link that follows still needs its artifacts.
|
||||
TEST_F(AsyncCompileTest, DeleteShaderWhileAttachedKeepsThePendingCompileAlive) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
CompileShader(vs);
|
||||
CompileShader(fs);
|
||||
DeleteShader(vs);
|
||||
DeleteShader(fs);
|
||||
|
||||
LinkProgram(program);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(program, "uColor"), 0);
|
||||
EXPECT_GE(GetUniformLocation(program, "uAlpha"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Stress
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The adversarial interleaving: enqueue, query, re-source, re-enqueue, delete, all with the
|
||||
// pool busy. Nothing here asserts timing - what it hunts for is a missed join or a use of an
|
||||
// abandoned node, both of which surface as a wrong status, a wrong log, or a crash.
|
||||
TEST_F(AsyncCompileTest, StressCompileQueryResourceDeleteInterleaved) {
|
||||
const AsyncModeScope async(true);
|
||||
constexpr int kRounds = 6;
|
||||
constexpr int kPerRound = 12;
|
||||
|
||||
for (int round = 0; round < kRounds; ++round) {
|
||||
Vector<String> sources;
|
||||
Vector<GLuint> shaders;
|
||||
sources.reserve(kPerRound * 2);
|
||||
|
||||
for (int i = 0; i < kPerRound; ++i) {
|
||||
sources.push_back(MakeBulkySource(round * 1000 + i));
|
||||
const char* text = sources.back().c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
shaders.push_back(fs);
|
||||
|
||||
// Immediately query a PREVIOUS one while this one is still outstanding: the
|
||||
// join has to settle exactly the object asked about and no other.
|
||||
if (i > 0) {
|
||||
const GLuint earlier = shaders[static_cast<SizeT>(i - 1)];
|
||||
EXPECT_EQ(QueryCompileStatus(earlier), GL_TRUE) << QueryShaderInfoLog(earlier);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-source half of them mid-flight, then recompile.
|
||||
for (int i = 0; i < kPerRound; i += 2) {
|
||||
sources.push_back(MakeBulkySource(round * 1000 + 500 + i));
|
||||
const char* text = sources.back().c_str();
|
||||
ShaderSource(shaders[static_cast<SizeT>(i)], 1, &text, nullptr);
|
||||
CompileShader(shaders[static_cast<SizeT>(i)]);
|
||||
}
|
||||
|
||||
for (int i = 0; i < kPerRound; ++i) {
|
||||
const GLuint shader = shaders[static_cast<SizeT>(i)];
|
||||
EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE) << QueryShaderInfoLog(shader);
|
||||
const String expectedUniform =
|
||||
"uSeed" + std::to_string(round * 1000 + (i % 2 == 0 ? 500 + i : i));
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
CompileShader(vs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, shader);
|
||||
LinkProgram(program);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "round " << round << " shader " << i;
|
||||
EXPECT_GE(GetUniformLocation(program, expectedUniform.c_str()), 0)
|
||||
<< "round " << round << " shader " << i << " expected " << expectedUniform;
|
||||
DeleteProgram(program);
|
||||
DeleteShader(vs);
|
||||
}
|
||||
for (const GLuint shader : shaders) {
|
||||
DeleteShader(shader);
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// The P0b cross-object memo is hit from several workers at once here: 8 objects share each
|
||||
// of 6 distinct sources, all enqueued before anything is read. Every object must still end
|
||||
// up with its own parse and its own correct reflection - a torn cache entry or an entry
|
||||
// evicted from under a reader shows up as a link failure or a missing uniform.
|
||||
TEST_F(AsyncCompileTest, ConcurrentCompilesShareThePreprocessCacheSafely) {
|
||||
const AsyncModeScope async(true);
|
||||
constexpr int kDistinct = 6;
|
||||
constexpr int kDuplicates = 8;
|
||||
|
||||
Vector<String> sources;
|
||||
sources.reserve(kDistinct);
|
||||
for (int i = 0; i < kDistinct; ++i) {
|
||||
sources.push_back(MakeBulkySource(8100 + i));
|
||||
}
|
||||
|
||||
Vector<GLuint> shaders;
|
||||
for (int duplicate = 0; duplicate < kDuplicates; ++duplicate) {
|
||||
for (int i = 0; i < kDistinct; ++i) {
|
||||
const char* text = sources[static_cast<SizeT>(i)].c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
shaders.push_back(fs);
|
||||
}
|
||||
}
|
||||
|
||||
for (SizeT s = 0; s < shaders.size(); ++s) {
|
||||
const GLuint fs = shaders[s];
|
||||
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
CompileShader(vs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "shader index " << s;
|
||||
const String uniform = "uSeed" + std::to_string(8100 + static_cast<int>(s % kDistinct));
|
||||
EXPECT_GE(GetUniformLocation(program, uniform.c_str()), 0) << uniform;
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
// MobileGL - MobileGL/MG_Test/Program/AsyncLinkTest.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
// P1 stage 4: glLinkProgram enqueues a ProgramLinkTask behind its shaders' compiles, and
|
||||
// every observable read of link output joins.
|
||||
//
|
||||
// Like AsyncCompileTest, every case here drives the real GL entry points and flips
|
||||
// MG_Config::Features.AsyncShaderCompile itself rather than reading the environment - so one
|
||||
// binary can assert the property that actually matters (the async and synchronous paths are
|
||||
// indistinguishable through the GL surface) regardless of how the suite was launched.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Config.h"
|
||||
#include "Includes.h"
|
||||
#include "Init.h"
|
||||
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
|
||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||
#include "MG_Impl/GLImpl/Program/GL_ProgramPipeline.h"
|
||||
#include "MG_State/GLState/Core.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Impl::GLImpl;
|
||||
|
||||
namespace {
|
||||
class AsyncModeScope {
|
||||
public:
|
||||
explicit AsyncModeScope(const Bool async) : m_saved(MG_Config::Features.AsyncShaderCompile) {
|
||||
MG_Config::Features.AsyncShaderCompile =
|
||||
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
|
||||
}
|
||||
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
|
||||
AsyncModeScope(const AsyncModeScope&) = delete;
|
||||
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
|
||||
|
||||
private:
|
||||
const MG_Config::QuirkOverride m_saved;
|
||||
};
|
||||
|
||||
const char* kVs = R"(#version 460
|
||||
layout(location = 0) in vec3 aPos;
|
||||
uniform mat4 uModel;
|
||||
uniform vec4 uColor;
|
||||
out vec4 vColor;
|
||||
void main() {
|
||||
vColor = uColor;
|
||||
gl_Position = uModel * vec4(aPos, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
const char* kFs = R"(#version 460
|
||||
in vec4 vColor;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
uniform float uAlpha;
|
||||
void main() { fragColor = vec4(vColor.rgb, vColor.a * uAlpha); }
|
||||
)";
|
||||
|
||||
// A vertex shader that captures something transform feedback can name.
|
||||
const char* kXfbVs = R"(#version 460
|
||||
layout(location = 0) in vec3 aPos;
|
||||
out vec3 vWorld;
|
||||
void main() {
|
||||
vWorld = aPos * 2.0;
|
||||
gl_Position = vec4(aPos, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
const char* kBrokenFs = R"(#version 460
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
void main() { fragColor = thisIdentifierWasNeverDeclared; }
|
||||
)";
|
||||
|
||||
// Big enough that neither the compile nor the link is instantaneous, so the pool has a
|
||||
// real backlog to race against. Templated on an index so every instance is distinct
|
||||
// source text (no P0b memo hit).
|
||||
String MakeBulkySource(const int index) {
|
||||
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
|
||||
source += "uniform float uSeed" + std::to_string(index) + ";\n";
|
||||
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
|
||||
for (int i = 0; i < 220; ++i) {
|
||||
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
|
||||
}
|
||||
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
GLuint MakeShader(const GLenum type, const char* source) {
|
||||
const GLuint shader = CreateShader(type);
|
||||
ShaderSource(shader, 1, &source, nullptr);
|
||||
CompileShader(shader);
|
||||
return shader;
|
||||
}
|
||||
|
||||
GLint QueryLinkStatus(const GLuint program) {
|
||||
GLint status = GL_FALSE;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
String QueryProgramInfoLog(const GLuint program) {
|
||||
GLint length = 0;
|
||||
GetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
|
||||
if (length <= 0) return String();
|
||||
std::vector<GLchar> buffer(static_cast<size_t>(length));
|
||||
GLsizei written = 0;
|
||||
GetProgramInfoLog(program, length, &written, buffer.data());
|
||||
return String(buffer.data(), static_cast<size_t>(written));
|
||||
}
|
||||
|
||||
// The non-joining view of the program, i.e. what GL_COMPLETION_STATUS_KHR will report.
|
||||
Bool LinkIsSettled(const GLuint program) {
|
||||
const auto& object = MG_State::pGLContext->GetProgramObject(program);
|
||||
return object == nullptr || object->IsLinkComplete();
|
||||
}
|
||||
|
||||
// Enqueues `count` distinct heavy compiles without reading anything back, so the pool is
|
||||
// left with a real backlog for the caller to race against.
|
||||
Vector<GLuint> SaturatePool(const int count, Vector<String>& sourceStorage) {
|
||||
Vector<GLuint> shaders;
|
||||
shaders.reserve(static_cast<SizeT>(count));
|
||||
sourceStorage.reserve(sourceStorage.size() + static_cast<SizeT>(count));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
sourceStorage.push_back(MakeBulkySource(20000 + i));
|
||||
const char* text = sourceStorage.back().c_str();
|
||||
const GLuint shader = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(shader, 1, &text, nullptr);
|
||||
CompileShader(shader);
|
||||
shaders.push_back(shader);
|
||||
}
|
||||
return shaders;
|
||||
}
|
||||
|
||||
// Content hash of a linked program's generated SPIR-V, through the state layer (there is
|
||||
// no GL query for it). Joins, like every other artifact read.
|
||||
Vector<Uint64> SpirvDigest(const GLuint program) {
|
||||
const auto& object = MG_State::pGLContext->GetProgramObject(program);
|
||||
Vector<Uint64> digest;
|
||||
if (!object) return digest;
|
||||
for (const auto& module : object->GetGeneratedSpirv()) {
|
||||
Uint64 hash = 1469598103934665603ull;
|
||||
for (const unsigned word : module) {
|
||||
hash = (hash ^ static_cast<Uint64>(word)) * 1099511628211ull;
|
||||
}
|
||||
digest.push_back(hash);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
class AsyncLinkTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override { MobileGL::Initialize(); }
|
||||
};
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The consume-once claim
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The stage-4 headline risk: two programs share one shader and are linked back to back, so
|
||||
// two ProgramLinkTasks race for that shader's single glslang parse. Exactly one may win the
|
||||
// claim; the loser must re-parse the same preprocessed source against the same CompileEnv.
|
||||
// If either half of that is wrong the two programs get DIFFERENT SPIR-V for the same shader,
|
||||
// which is the silent-corruption class this whole mechanism exists to prevent.
|
||||
TEST_F(AsyncLinkTest, TwoProgramsSharingAShaderGenerateIdenticalSpirv) {
|
||||
for (const Bool async : {false, true}) {
|
||||
const AsyncModeScope scope(async);
|
||||
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
|
||||
|
||||
// Both links enqueued before either result is read: with the flag on this is the
|
||||
// window in which two workers can hold the same node at once.
|
||||
const GLuint programA = CreateProgram();
|
||||
AttachShader(programA, vs);
|
||||
AttachShader(programA, fs);
|
||||
LinkProgram(programA);
|
||||
|
||||
const GLuint programB = CreateProgram();
|
||||
AttachShader(programB, vs);
|
||||
AttachShader(programB, fs);
|
||||
LinkProgram(programB);
|
||||
|
||||
ASSERT_EQ(QueryLinkStatus(programA), GL_TRUE) << QueryProgramInfoLog(programA);
|
||||
ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE) << QueryProgramInfoLog(programB);
|
||||
|
||||
const Vector<Uint64> digestA = SpirvDigest(programA);
|
||||
const Vector<Uint64> digestB = SpirvDigest(programB);
|
||||
ASSERT_EQ(digestA.size(), 2u) << "async=" << async;
|
||||
EXPECT_EQ(digestA, digestB)
|
||||
<< "the claim winner and the re-parsing loser must produce identical SPIR-V (async=" << async << ")";
|
||||
|
||||
// And the two programs really are usable independently.
|
||||
EXPECT_GE(GetUniformLocation(programA, "uColor"), 0);
|
||||
EXPECT_GE(GetUniformLocation(programB, "uColor"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// The same property many ways at once, with the pool loaded: N programs over the SAME shader
|
||||
// pair, all enqueued before anything is read, so one claim winner is racing N-1 re-parsers.
|
||||
// Every program must come out byte-identical.
|
||||
//
|
||||
// The shader pair has to be identical across the programs for this to mean anything: glslang
|
||||
// links the stages together, so a stage's SPIR-V is legitimately a function of the WHOLE
|
||||
// program (mapIO's cross-stage location assignment, live-variable analysis). Comparing one
|
||||
// shared vertex shader across programs with different fragment stages would compare things
|
||||
// that are allowed to differ.
|
||||
TEST_F(AsyncLinkTest, ManyProgramsSharingOneShaderPairAgreeOnTheirSpirv) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
constexpr int kPrograms = 12;
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
|
||||
|
||||
Vector<GLuint> programs;
|
||||
for (int i = 0; i < kPrograms; ++i) {
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
programs.push_back(program);
|
||||
}
|
||||
|
||||
Vector<Uint64> reference;
|
||||
for (int i = 0; i < kPrograms; ++i) {
|
||||
const GLuint program = programs[static_cast<SizeT>(i)];
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "program " << i << ": " << QueryProgramInfoLog(program);
|
||||
const Vector<Uint64> digest = SpirvDigest(program);
|
||||
ASSERT_EQ(digest.size(), 2u);
|
||||
if (i == 0) {
|
||||
reference = digest;
|
||||
} else {
|
||||
EXPECT_EQ(digest, reference) << "SPIR-V differs in program " << i;
|
||||
}
|
||||
EXPECT_GE(GetUniformLocation(program, "uColor"), 0) << "program " << i;
|
||||
EXPECT_GE(GetUniformLocation(program, "uAlpha"), 0) << "program " << i;
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Mutation over a pending link (the cancel matrix)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The last link wins. A re-link over a pending one cancels it and enqueues afresh; the
|
||||
// result the application eventually reads must be the SECOND link's.
|
||||
TEST_F(AsyncLinkTest, RelinkOverAPendingLinkPublishesTheSecondLink) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const String firstSource = MakeBulkySource(7001);
|
||||
const char* firstText = firstSource.c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &firstText, nullptr);
|
||||
CompileShader(fs);
|
||||
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
|
||||
// Swap the fragment shader's source and relink, all without ever reading the first
|
||||
// link's status - so the first link is very probably still queued or running.
|
||||
const String secondSource = MakeBulkySource(7002);
|
||||
const char* secondText = secondSource.c_str();
|
||||
ShaderSource(fs, 1, &secondText, nullptr);
|
||||
CompileShader(fs);
|
||||
LinkProgram(program);
|
||||
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
|
||||
EXPECT_GE(GetUniformLocation(program, "uSeed7002"), 0);
|
||||
EXPECT_EQ(GetUniformLocation(program, "uSeed7001"), -1);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The take-effect-at-next-link setters must NOT disturb a pending link: the pending link
|
||||
// snapshotted its own inputs at enqueue, so
|
||||
// glLinkProgram; glTransformFeedbackVaryings; glGetProgramiv(LINK_STATUS)
|
||||
// has to report the FIRST link - which captured nothing.
|
||||
TEST_F(AsyncLinkTest, TransformFeedbackVaryingsOverAPendingLinkReportsTheFirstLink) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kXfbVs);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
|
||||
const char* varyings[] = {"vWorld"};
|
||||
TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
|
||||
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
|
||||
GLint captured = -1;
|
||||
GetProgramiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS, &captured);
|
||||
EXPECT_EQ(captured, 0) << "the pending link must publish the request set it snapshotted, not a later one";
|
||||
|
||||
// And the request does take effect at the NEXT link.
|
||||
LinkProgram(program);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
|
||||
GetProgramiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS, &captured);
|
||||
EXPECT_EQ(captured, 1);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glBindAttribLocation is the same family and must likewise leave a pending link alone.
|
||||
TEST_F(AsyncLinkTest, BindAttribLocationOverAPendingLinkDoesNotDisturbIt) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
|
||||
BindAttribLocation(program, 5, "aPos");
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
|
||||
EXPECT_EQ(GetAttribLocation(program, "aPos"), 0) << "the first link's layout(location = 0) must survive";
|
||||
|
||||
LinkProgram(program);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glAttachShader after glLinkProgram is defined to leave the current link status alone (it
|
||||
// takes effect at the next link). It must therefore NOT cancel a pending link - the failure
|
||||
// mode being guarded here is a program that linked fine reporting GL_FALSE.
|
||||
TEST_F(AsyncLinkTest, AttachShaderOverAPendingLinkKeepsTheLinkResult) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
|
||||
// A second, unrelated fragment shader attached over the pending link. (Attaching two
|
||||
// shaders of one stage is legal; only the next link would have to reconcile them.)
|
||||
const GLuint extraFs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
|
||||
AttachShader(program, extraFs);
|
||||
|
||||
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
|
||||
EXPECT_GE(GetUniformLocation(program, "uAlpha"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The link-then-detach-then-delete teardown every LWJGL/Blaze3D-shaped app performs. The
|
||||
// detach makes the shader GL-invisible, so glDeleteShader frees its name and would otherwise
|
||||
// cancel a compile the enqueued link is still waiting on - flipping a link that must report
|
||||
// GL_TRUE to GL_FALSE. Runs with the pool saturated so the compiles really are outstanding.
|
||||
TEST_F(AsyncLinkTest, DetachAndDeleteShadersOverAPendingLinkKeepsTheLinkResult) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String source = MakeBulkySource(7400);
|
||||
const char* text = source.c_str();
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
|
||||
DetachShader(program, vs);
|
||||
DetachShader(program, fs);
|
||||
DeleteShader(vs);
|
||||
DeleteShader(fs);
|
||||
EXPECT_EQ(IsShader(vs), GL_FALSE);
|
||||
EXPECT_EQ(IsShader(fs), GL_FALSE);
|
||||
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
|
||||
EXPECT_GE(GetUniformLocation(program, "uSeed7400"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glCreateShaderProgramv is specified as create-source-compile-create-attach-LINK-detach, so
|
||||
// it is the in-tree caller that exercises the detach-immediately-after-link ordering. It
|
||||
// self-joins through its status queries (design join site J7) and needs no edit of its own -
|
||||
// this is the guard that says so.
|
||||
TEST_F(AsyncLinkTest, CreateShaderProgramvLinksUnderAsync) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const char* sources[] = {kVs};
|
||||
const GLuint program = CreateShaderProgramv(GL_VERTEX_SHADER, 1, sources);
|
||||
ASSERT_NE(program, 0u);
|
||||
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
|
||||
EXPECT_GE(GetUniformLocation(program, "uColor"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glProgramBinary over a pending link: no format is supported, so the spec requires
|
||||
// LINK_STATUS to read FALSE afterwards. The pending link must not publish over that.
|
||||
TEST_F(AsyncLinkTest, ProgramBinaryOverAPendingLinkForcesLinkFalse) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
|
||||
const GLuint dummy = 0;
|
||||
ProgramBinary(program, 0, &dummy, static_cast<GLsizei>(sizeof(dummy)));
|
||||
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
|
||||
|
||||
EXPECT_EQ(QueryLinkStatus(program), GL_FALSE) << "glProgramBinary must win over the pending link";
|
||||
EXPECT_FALSE(QueryProgramInfoLog(program).empty());
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glDeleteProgram over a pending link. The name goes away immediately - no wait for a worker
|
||||
// - and the abandoned job must neither crash nor keep anything observable alive.
|
||||
TEST_F(AsyncLinkTest, DeleteProgramWhileALinkIsPending) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
Vector<GLuint> doomed;
|
||||
Vector<String> sources;
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
sources.push_back(MakeBulkySource(7500 + i));
|
||||
const char* text = sources.back().c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
doomed.push_back(program);
|
||||
}
|
||||
for (const GLuint program : doomed) {
|
||||
DeleteProgram(program);
|
||||
EXPECT_EQ(IsProgram(program), GL_FALSE) << "an unused deleted program's name goes immediately";
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// The context still works afterwards: the abandoned links did not take the pool, the
|
||||
// preprocess cache or the glslang process state down with them.
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The join gates
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// glLinkProgram must return before the work is done, and the first observable read must
|
||||
// join. Observed through the state machine rather than through timing, so it can never be a
|
||||
// false red: with a saturated pool at least one of the just-enqueued links has to be
|
||||
// unsettled at the moment we ask; skipped if the machine drained everything first.
|
||||
TEST_F(AsyncLinkTest, LinkProgramReturnsBeforeTheWorkIsDone) {
|
||||
const AsyncModeScope async(true);
|
||||
constexpr int kPrograms = 32;
|
||||
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
Vector<GLuint> programs;
|
||||
Vector<String> sources;
|
||||
for (int i = 0; i < kPrograms; ++i) {
|
||||
sources.push_back(MakeBulkySource(7600 + i));
|
||||
const char* text = sources.back().c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
programs.push_back(program);
|
||||
}
|
||||
|
||||
int unsettled = 0;
|
||||
for (const GLuint program : programs) {
|
||||
if (!LinkIsSettled(program)) ++unsettled;
|
||||
}
|
||||
if (unsettled == 0) {
|
||||
GTEST_SKIP() << "the pool drained every link before the first observation; nothing to prove here";
|
||||
}
|
||||
|
||||
for (const GLuint program : programs) {
|
||||
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
|
||||
EXPECT_TRUE(LinkIsSettled(program)) << "reading LINK_STATUS must have joined";
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// With the flag off, a link is finished by the time glLinkProgram returns. This is the guard
|
||||
// that keeps the default shippable.
|
||||
TEST_F(AsyncLinkTest, LinkIsFullySynchronousWithAsyncOff) {
|
||||
const AsyncModeScope async(false);
|
||||
ASSERT_FALSE(MG_Util::Async::AsyncShaderCompileEnabled());
|
||||
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
EXPECT_TRUE(LinkIsSettled(program));
|
||||
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// P1 join site J1: the composite draw program for a pipeline is cached against a signature
|
||||
// built from each stage program's lifetime id and backend state version - NON-artifact
|
||||
// fields, which do not pass through the join gate. GetProgramForDraw has to settle the stage
|
||||
// programs first, or the signature describes a link generation that no longer exists and the
|
||||
// composite is rebuilt on every draw.
|
||||
TEST_F(AsyncLinkTest, DrawThroughAPipelineWithAPendingStageProgramJoinsFirst) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
// Built by hand rather than through glCreateShaderProgramv: that entry point detaches the
|
||||
// shader immediately after linking, so the next link would remove it and leave the stage
|
||||
// program with nothing attached to composite from.
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint vsProgram = CreateProgram();
|
||||
ProgramParameteri(vsProgram, GL_PROGRAM_SEPARABLE, GL_TRUE);
|
||||
AttachShader(vsProgram, vs);
|
||||
LinkProgram(vsProgram);
|
||||
ASSERT_EQ(QueryLinkStatus(vsProgram), GL_TRUE) << QueryProgramInfoLog(vsProgram);
|
||||
|
||||
GLuint pipeline = 0;
|
||||
GenProgramPipelines(1, &pipeline);
|
||||
ASSERT_NE(pipeline, 0u);
|
||||
// Bind before UseProgramStages: glGenProgramPipelines only reserves the name, and the
|
||||
// first bind is what turns it into an object glUseProgramStages can find.
|
||||
BindProgramPipeline(pipeline);
|
||||
UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram);
|
||||
ASSERT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// Re-link the stage program and immediately ask for the draw program, without reading
|
||||
// the link's status in between: the pending link is what J1 has to settle.
|
||||
LinkProgram(vsProgram);
|
||||
const SharedPtr<MG_State::GLState::ProgramObject> drawProgram = MG_State::pGLContext->GetProgramForDraw();
|
||||
ASSERT_NE(drawProgram, nullptr);
|
||||
EXPECT_TRUE(LinkIsSettled(vsProgram)) << "GetProgramForDraw must have joined the stage program";
|
||||
EXPECT_TRUE(drawProgram->GetLinkStatus()) << drawProgram->GetInfoLog();
|
||||
|
||||
// Asking again with nothing changed must hit the composite cache, which is only possible
|
||||
// if the signature was computed against settled programs both times.
|
||||
const SharedPtr<MG_State::GLState::ProgramObject> again = MG_State::pGLContext->GetProgramForDraw();
|
||||
EXPECT_EQ(again.get(), drawProgram.get()) << "the composite draw program must be cached across draws";
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Diagnostics
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// A link whose fragment shader failed to compile has to reproduce that shader's log verbatim
|
||||
// inside the program info log, whichever thread produced it - and the failure must be
|
||||
// reported as LINK_STATUS plus a log, never as a GL error.
|
||||
TEST_F(AsyncLinkTest, FailingLinkLogIsIdenticalAcrossModes) {
|
||||
String syncLog;
|
||||
{
|
||||
const AsyncModeScope scope(false);
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_FALSE);
|
||||
syncLog = QueryProgramInfoLog(program);
|
||||
EXPECT_FALSE(syncLog.empty());
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
{
|
||||
const AsyncModeScope scope(true);
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
EXPECT_EQ(QueryLinkStatus(program), GL_FALSE);
|
||||
EXPECT_EQ(QueryProgramInfoLog(program), syncLog);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// A program with nothing attached fails in the GL-thread prologue, before any job exists.
|
||||
// That path has to reach the same info log in both modes.
|
||||
TEST_F(AsyncLinkTest, LinkWithNoShadersFailsIdenticallyInBothModes) {
|
||||
String syncLog;
|
||||
for (const Bool async : {false, true}) {
|
||||
const AsyncModeScope scope(async);
|
||||
const GLuint program = CreateProgram();
|
||||
LinkProgram(program);
|
||||
EXPECT_EQ(QueryLinkStatus(program), GL_FALSE);
|
||||
const String log = QueryProgramInfoLog(program);
|
||||
EXPECT_FALSE(log.empty());
|
||||
if (!async) {
|
||||
syncLog = log;
|
||||
} else {
|
||||
EXPECT_EQ(log, syncLog);
|
||||
}
|
||||
EXPECT_TRUE(LinkIsSettled(program)) << "a prologue failure leaves no job pending";
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// End to end
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The shape a shaderpack load actually has: compile N shaders, link M programs, read
|
||||
// NOTHING until the end, then query everything. This is the only shape in which the pool has
|
||||
// many compiles and many links in flight simultaneously, with the link jobs chained behind
|
||||
// compile jobs that are themselves still queued.
|
||||
TEST_F(AsyncLinkTest, PackShapedBurstCompilesLinksAndQueriesEverything) {
|
||||
const AsyncModeScope async(true);
|
||||
constexpr int kShaders = 24;
|
||||
constexpr int kPrograms = 24;
|
||||
|
||||
Vector<String> sources;
|
||||
Vector<GLuint> vertexShaders;
|
||||
Vector<GLuint> fragmentShaders;
|
||||
for (int i = 0; i < kShaders; ++i) {
|
||||
vertexShaders.push_back(MakeShader(GL_VERTEX_SHADER, kVs));
|
||||
sources.push_back(MakeBulkySource(8000 + i));
|
||||
const char* text = sources.back().c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
fragmentShaders.push_back(fs);
|
||||
}
|
||||
|
||||
Vector<GLuint> programs;
|
||||
for (int i = 0; i < kPrograms; ++i) {
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vertexShaders[static_cast<SizeT>(i % kShaders)]);
|
||||
AttachShader(program, fragmentShaders[static_cast<SizeT>(i % kShaders)]);
|
||||
LinkProgram(program);
|
||||
programs.push_back(program);
|
||||
}
|
||||
|
||||
for (int i = 0; i < kPrograms; ++i) {
|
||||
const GLuint program = programs[static_cast<SizeT>(i)];
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "program " << i << ": " << QueryProgramInfoLog(program);
|
||||
EXPECT_GE(GetUniformLocation(program, "uColor"), 0) << "program " << i;
|
||||
EXPECT_GE(GetUniformLocation(program, ("uSeed" + std::to_string(8000 + i % kShaders)).c_str()), 0)
|
||||
<< "program " << i;
|
||||
EXPECT_EQ(GetAttribLocation(program, "aPos"), 0) << "program " << i;
|
||||
EXPECT_EQ(SpirvDigest(program).size(), 2u) << "program " << i;
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The adversarial interleaving: link, query a previous one, re-source, re-link, delete, all
|
||||
// with the pool busy. Nothing here asserts timing - what it hunts for is a missed join, a
|
||||
// consumed-twice parse, or a use of an abandoned node, all of which surface as a wrong
|
||||
// status, a missing uniform, or a crash.
|
||||
TEST_F(AsyncLinkTest, StressLinkQueryRelinkDeleteInterleaved) {
|
||||
const AsyncModeScope async(true);
|
||||
constexpr int kRounds = 5;
|
||||
constexpr int kPerRound = 10;
|
||||
|
||||
for (int round = 0; round < kRounds; ++round) {
|
||||
Vector<String> sources;
|
||||
Vector<GLuint> programs;
|
||||
Vector<GLuint> fragmentShaders;
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
|
||||
for (int i = 0; i < kPerRound; ++i) {
|
||||
sources.push_back(MakeBulkySource(round * 1000 + 300 + i));
|
||||
const char* text = sources.back().c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
fragmentShaders.push_back(fs);
|
||||
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
programs.push_back(program);
|
||||
|
||||
// Query a PREVIOUS program while this one is still outstanding: the join has to
|
||||
// settle exactly the program asked about and no other.
|
||||
if (i > 0) {
|
||||
const GLuint earlier = programs[static_cast<SizeT>(i - 1)];
|
||||
EXPECT_EQ(QueryLinkStatus(earlier), GL_TRUE) << QueryProgramInfoLog(earlier);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-source half of them mid-flight and relink over the pending link.
|
||||
for (int i = 0; i < kPerRound; i += 2) {
|
||||
sources.push_back(MakeBulkySource(round * 1000 + 700 + i));
|
||||
const char* text = sources.back().c_str();
|
||||
ShaderSource(fragmentShaders[static_cast<SizeT>(i)], 1, &text, nullptr);
|
||||
CompileShader(fragmentShaders[static_cast<SizeT>(i)]);
|
||||
LinkProgram(programs[static_cast<SizeT>(i)]);
|
||||
}
|
||||
|
||||
for (int i = 0; i < kPerRound; ++i) {
|
||||
const GLuint program = programs[static_cast<SizeT>(i)];
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE)
|
||||
<< "round " << round << " program " << i << ": " << QueryProgramInfoLog(program);
|
||||
const String expected =
|
||||
"uSeed" + std::to_string(round * 1000 + (i % 2 == 0 ? 700 + i : 300 + i));
|
||||
EXPECT_GE(GetUniformLocation(program, expected.c_str()), 0)
|
||||
<< "round " << round << " program " << i << " expected " << expected;
|
||||
DeleteProgram(program);
|
||||
}
|
||||
for (const GLuint fs : fragmentShaders) DeleteShader(fs);
|
||||
DeleteShader(vs);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// MobileGL - MobileGL/MG_Test/Program/AsyncTeardownTest.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
// P1 stage 4, item S6: MobileGL::Destroy() with compile AND link jobs still in flight.
|
||||
//
|
||||
// This is the one cancellation path in the whole design that WAITS, and the order it waits
|
||||
// in is load-bearing: in-flight jobs own their own inputs and are safe against everything
|
||||
// teardown does EXCEPT glslang's process globals and the TShader/TProgram objects hanging off
|
||||
// pGLContext - both of which DestroyImpl is about to free. StopAndDrain() therefore runs
|
||||
// first, before pGLContext.reset() and before glslang::FinalizeProcess().
|
||||
//
|
||||
// ITS OWN BINARY, deliberately. ShaderCompilePool::StopAndDrain() is a one-way latch: from
|
||||
// the first eglTerminate onwards every job in the process runs inline on the calling thread.
|
||||
// Sharing a binary with AsyncCompileTest/AsyncLinkTest would silently turn every case
|
||||
// declared after this one synchronous, and they would keep passing while testing nothing.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Config.h"
|
||||
#include "Includes.h"
|
||||
#include "Init.h"
|
||||
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
|
||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||
#include "MG_State/GLState/Core.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Impl::GLImpl;
|
||||
|
||||
namespace {
|
||||
const char* kVs = R"(#version 460
|
||||
layout(location = 0) in vec3 aPos;
|
||||
uniform vec4 uColor;
|
||||
out vec4 vColor;
|
||||
void main() {
|
||||
vColor = uColor;
|
||||
gl_Position = vec4(aPos, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
String MakeBulkySource(const int index) {
|
||||
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
|
||||
source += "uniform float uSeed" + std::to_string(index) + ";\n";
|
||||
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
|
||||
for (int i = 0; i < 220; ++i) {
|
||||
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
|
||||
}
|
||||
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
GLuint MakeShader(const GLenum type, const char* source) {
|
||||
const GLuint shader = CreateShader(type);
|
||||
ShaderSource(shader, 1, &source, nullptr);
|
||||
CompileShader(shader);
|
||||
return shader;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// Fills the pool with compiles, chains links behind them, and tears the library down without
|
||||
// reading a single result. Nothing here can assert on the jobs' outcomes - by design there is
|
||||
// no one left to ask - so what it asserts is that teardown COMPLETES: it must not hang
|
||||
// (StopAndDrain joining a worker that is itself waiting on something), must not crash (a
|
||||
// worker inside glslang while FinalizeProcess frees its symbol tables, or a link job reading
|
||||
// a shader node the GL thread has dropped), and must leave the process able to come back up.
|
||||
TEST(AsyncTeardown, DestroyWithCompilesAndLinksInFlight) {
|
||||
// After Initialize(), not before: MG_ConfigLoader::Init() re-reads the whole feature
|
||||
// block from the environment and would overwrite the override.
|
||||
MobileGL::Initialize();
|
||||
MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::ForceOn;
|
||||
ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled());
|
||||
|
||||
constexpr int kCount = 64;
|
||||
Vector<String> sources;
|
||||
Vector<GLuint> shaders;
|
||||
Vector<GLuint> programs;
|
||||
sources.reserve(kCount);
|
||||
|
||||
// Bare compiles first, so the pool has a backlog the links below will queue behind.
|
||||
for (int i = 0; i < kCount; ++i) {
|
||||
sources.push_back(MakeBulkySource(30000 + i));
|
||||
const char* text = sources.back().c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
shaders.push_back(fs);
|
||||
}
|
||||
|
||||
// Then links, each chained behind a compile that is very probably still outstanding: at
|
||||
// the moment Destroy() runs there are queued compiles, running compiles, links waiting on
|
||||
// a dependency edge, and links already handed to the pool.
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
for (int i = 0; i < kCount; ++i) {
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, shaders[static_cast<SizeT>(i)]);
|
||||
LinkProgram(program);
|
||||
programs.push_back(program);
|
||||
}
|
||||
|
||||
// No status read anywhere above - the jobs are genuinely in flight.
|
||||
MobileGL::Destroy();
|
||||
|
||||
// Back up again. The pool stays stopped for the rest of the process (a one-way latch), so
|
||||
// this second life is synchronous - which is exactly the documented behaviour, and it has
|
||||
// to still be a WORKING one.
|
||||
MobileGL::Initialize();
|
||||
const GLuint vs2 = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
const char* fsSource = R"(#version 460
|
||||
in vec4 vColor;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
void main() { fragColor = vColor; }
|
||||
)";
|
||||
const GLuint fs2 = MakeShader(GL_FRAGMENT_SHADER, fsSource);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs2);
|
||||
AttachShader(program, fs2);
|
||||
LinkProgram(program);
|
||||
|
||||
GLint status = GL_FALSE;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &status);
|
||||
EXPECT_EQ(status, GL_TRUE) << "the library must be usable after a teardown that drained jobs in flight";
|
||||
EXPECT_GE(GetUniformLocation(program, "uColor"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ add_executable(
|
||||
${MGL_ROOT}/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp
|
||||
${MGL_ROOT}/MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp
|
||||
${MGL_ROOT}/MobileGL/MG_Util/ShaderTranspiler/glslang/UniformTraverser.cpp
|
||||
${MGL_ROOT}/MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ProgramUtilTest PRIVATE
|
||||
@@ -27,6 +28,124 @@ add_executable(
|
||||
ProgramTest.cpp
|
||||
)
|
||||
|
||||
add_executable(
|
||||
AsyncCompileTest
|
||||
AsyncCompileTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(AsyncCompileTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
target_link_libraries(
|
||||
AsyncCompileTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
add_executable(
|
||||
AsyncLinkTest
|
||||
AsyncLinkTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(AsyncLinkTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
target_link_libraries(
|
||||
AsyncLinkTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
add_executable(
|
||||
ShaderCompileAdoptionTest
|
||||
ShaderCompileAdoptionTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ShaderCompileAdoptionTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
target_link_libraries(
|
||||
ShaderCompileAdoptionTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
add_executable(
|
||||
ParallelShaderCompileTest
|
||||
ParallelShaderCompileTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ParallelShaderCompileTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
target_link_libraries(
|
||||
ParallelShaderCompileTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
# Its own binary so the "fresh process" isolation level in it is really available
|
||||
# through --gtest_filter, and so its 60 A->B link pairs cannot perturb another
|
||||
# suite's per-context caches.
|
||||
add_executable(
|
||||
XfbFrontendOrderInvarianceTest
|
||||
XfbFrontendOrderInvarianceTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(XfbFrontendOrderInvarianceTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
target_link_libraries(
|
||||
XfbFrontendOrderInvarianceTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
# Its own binary on purpose: this one calls MobileGL::Destroy(), and ShaderCompilePool's
|
||||
# stop is a one-way latch for the whole process - every case declared after it in the same
|
||||
# binary would silently run its compiles and links inline.
|
||||
add_executable(
|
||||
AsyncTeardownTest
|
||||
AsyncTeardownTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(AsyncTeardownTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
target_link_libraries(
|
||||
AsyncTeardownTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
add_executable(
|
||||
ProgramInterfaceTest
|
||||
ProgramInterfaceTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ProgramInterfaceTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
target_link_libraries(
|
||||
ProgramInterfaceTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
target_include_directories(ProgramTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
@@ -41,3 +160,16 @@ target_link_libraries(
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(ProgramUtilTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
gtest_discover_tests(ProgramInterfaceTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
# Heavier than the rest of the unit suite by design: several cases deliberately saturate the
|
||||
# compile pool so there is something in flight to race against.
|
||||
gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
gtest_discover_tests(AsyncLinkTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
# Same reason: the stage-6 cases keep a backlog in flight so a release really can race a
|
||||
# worker, and the 48-object stress links every one of them.
|
||||
gtest_discover_tests(ShaderCompileAdoptionTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
# Same reason: the GL_COMPLETION_STATUS_KHR cases saturate a one-worker pool on purpose.
|
||||
gtest_discover_tests(ParallelShaderCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
gtest_discover_tests(AsyncTeardownTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
# Same reason again: several cases leave A links outstanding while B compiles and links.
|
||||
gtest_discover_tests(XfbFrontendOrderInvarianceTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
// MobileGL - MobileGL/MG_Test/Program/ParallelShaderCompileTest.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
// P1 stage 5: the GL_KHR_parallel_shader_compile application surface.
|
||||
//
|
||||
// Four things are under test, and they are the four an application actually touches:
|
||||
// * GL_COMPLETION_STATUS_KHR on shaders and programs, which MUST NOT JOIN - the whole
|
||||
// point of the query is to answer while the work is still outstanding;
|
||||
// * glMaxShaderCompilerThreadsKHR / ...ARB, including the count == 0 mode switch the
|
||||
// extension mandates and what lifts it again;
|
||||
// * GL_MAX_SHADER_COMPILER_THREADS_KHR;
|
||||
// * the extension string itself, which must appear if and only if asynchronous
|
||||
// compilation is enabled - the kill switch has to revert the application-visible
|
||||
// behaviour change, not only the threading.
|
||||
//
|
||||
// Like the other async suites, every case drives the real GL entry points and flips
|
||||
// MG_Config::Features.AsyncShaderCompile itself, so the file behaves identically whether or
|
||||
// not the suite was launched with MOBILEGL_ASYNC_SHADER_COMPILE=1.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Config.h"
|
||||
#include "Includes.h"
|
||||
#include "Init.h"
|
||||
#include "MG_Backend/BackendObjects.h"
|
||||
#include "MG_Backend/DirectGLES/BackendObject_DirectGLES.h"
|
||||
#include "MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h"
|
||||
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
|
||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||
#include "MG_State/GLState/Core.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Impl::GLImpl;
|
||||
|
||||
namespace {
|
||||
class AsyncModeScope {
|
||||
public:
|
||||
explicit AsyncModeScope(const Bool async) : m_saved(MG_Config::Features.AsyncShaderCompile) {
|
||||
MG_Config::Features.AsyncShaderCompile =
|
||||
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
|
||||
}
|
||||
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
|
||||
AsyncModeScope(const AsyncModeScope&) = delete;
|
||||
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
|
||||
|
||||
private:
|
||||
const MG_Config::QuirkOverride m_saved;
|
||||
};
|
||||
|
||||
// glMaxShaderCompilerThreadsKHR writes PROCESS-wide state (the pool's concurrency budget
|
||||
// and the suspension latch), so a case that touches it has to put both back or it
|
||||
// poisons every case declared after it in this binary.
|
||||
class CompilerThreadScope {
|
||||
public:
|
||||
CompilerThreadScope() = default;
|
||||
~CompilerThreadScope() {
|
||||
MG_Util::Async::SetAsyncShaderCompileSuspended(false);
|
||||
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(
|
||||
MG_Util::Async::ShaderCompilePool::Get().GetThreadCount());
|
||||
}
|
||||
CompilerThreadScope(const CompilerThreadScope&) = delete;
|
||||
CompilerThreadScope& operator=(const CompilerThreadScope&) = delete;
|
||||
};
|
||||
|
||||
const char* kVs = R"(#version 460
|
||||
layout(location = 0) in vec3 aPos;
|
||||
uniform vec4 uColor;
|
||||
out vec4 vColor;
|
||||
void main() {
|
||||
vColor = uColor;
|
||||
gl_Position = vec4(aPos, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
// Deliberately expensive, and distinct per index so the source-hash memo never turns a
|
||||
// second instance into a no-op: a saturated pool is the only way to observe an
|
||||
// outstanding job without asserting on timing.
|
||||
String MakeBulkySource(const int index) {
|
||||
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
|
||||
source += "uniform float uSeed" + std::to_string(index) + ";\n";
|
||||
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
|
||||
for (int i = 0; i < 320; ++i) {
|
||||
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
|
||||
}
|
||||
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
GLuint MakeShader(const GLenum type, const char* source) {
|
||||
const GLuint shader = CreateShader(type);
|
||||
ShaderSource(shader, 1, &source, nullptr);
|
||||
return shader;
|
||||
}
|
||||
|
||||
GLint QueryShaderCompletion(const GLuint shader) {
|
||||
GLint status = -1;
|
||||
GetShaderiv(shader, GL_COMPLETION_STATUS_KHR, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
GLint QueryProgramCompletion(const GLuint program) {
|
||||
GLint status = -1;
|
||||
GetProgramiv(program, GL_COMPLETION_STATUS_KHR, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
GLint QueryCompileStatus(const GLuint shader) {
|
||||
GLint status = GL_FALSE;
|
||||
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
GLint QueryLinkStatus(const GLuint program) {
|
||||
GLint status = GL_FALSE;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
// Enqueues `count` distinct heavy compiles and returns their names without reading
|
||||
// anything back, leaving the pool with a real backlog.
|
||||
Vector<GLuint> EnqueueBacklog(const int count, const int seedBase, Vector<String>& sourceStorage) {
|
||||
Vector<GLuint> shaders;
|
||||
shaders.reserve(static_cast<SizeT>(count));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
sourceStorage.push_back(MakeBulkySource(seedBase + i));
|
||||
const char* text = sourceStorage.back().c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
shaders.push_back(fs);
|
||||
}
|
||||
return shaders;
|
||||
}
|
||||
|
||||
Bool Advertises(const Vector<GLExtension>& extensions, const GLExtension wanted) {
|
||||
return std::find(extensions.begin(), extensions.end(), wanted) != extensions.end();
|
||||
}
|
||||
|
||||
class ParallelShaderCompileTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override { MobileGL::Initialize(); }
|
||||
};
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// GL_COMPLETION_STATUS_KHR must not join
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The load-bearing case of the whole stage. A single-worker pool is saturated with heavy
|
||||
// compiles, so jobs are demonstrably still queued; GL_COMPLETION_STATUS_KHR then has to
|
||||
// report GL_FALSE for at least one of them *and leave it outstanding*. If the query joined -
|
||||
// which is what happens if it is ever routed through the ordinary Compiled() gate - it could
|
||||
// only ever return GL_TRUE, and the extension would be a lie that costs an application the
|
||||
// exact stall it added the polling loop to avoid.
|
||||
//
|
||||
// Skipped rather than failed when the machine drained the backlog first, so it can never be
|
||||
// a false red on a fast box.
|
||||
TEST_F(ParallelShaderCompileTest, ShaderCompletionStatusReportsFalseWithoutJoining) {
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope threads;
|
||||
// One worker: the queue behind it is the thing being observed.
|
||||
MaxShaderCompilerThreadsKHR(1);
|
||||
|
||||
Vector<String> sources;
|
||||
const Vector<GLuint> shaders = EnqueueBacklog(64, 4000, sources);
|
||||
|
||||
int outstanding = 0;
|
||||
for (const GLuint shader : shaders) {
|
||||
const GLint completion = QueryShaderCompletion(shader);
|
||||
ASSERT_TRUE(completion == GL_TRUE || completion == GL_FALSE) << "completion = " << completion;
|
||||
if (completion == GL_FALSE) ++outstanding;
|
||||
}
|
||||
if (outstanding == 0) {
|
||||
GTEST_SKIP() << "the pool drained 64 heavy compiles before the first query; nothing outstanding to observe";
|
||||
}
|
||||
|
||||
// Asking again must still not have settled anything: the query is a peek, so a second
|
||||
// one cannot have made progress happen. (A joining implementation would report every
|
||||
// shader complete by now.)
|
||||
int stillOutstanding = 0;
|
||||
for (const GLuint shader : shaders) {
|
||||
if (QueryShaderCompletion(shader) == GL_FALSE) ++stillOutstanding;
|
||||
}
|
||||
EXPECT_GT(stillOutstanding, 0) << "GL_COMPLETION_STATUS_KHR joined - every shader settled just by being asked";
|
||||
|
||||
// And once the real (joining) query is used, everything is complete and correct.
|
||||
for (const GLuint shader : shaders) {
|
||||
EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE);
|
||||
EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE) << "GL_COMPILE_STATUS must have joined";
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The program half: a link enqueued behind a saturated pool cannot be complete either, and
|
||||
// asking must not drag it forward.
|
||||
TEST_F(ParallelShaderCompileTest, ProgramCompletionStatusReportsFalseWithoutJoining) {
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope threads;
|
||||
MaxShaderCompilerThreadsKHR(1);
|
||||
|
||||
Vector<String> sources;
|
||||
EnqueueBacklog(48, 4200, sources);
|
||||
|
||||
Vector<GLuint> programs;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
sources.push_back(MakeBulkySource(4400 + i));
|
||||
const char* text = sources.back().c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
CompileShader(vs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
programs.push_back(program);
|
||||
}
|
||||
|
||||
int outstanding = 0;
|
||||
for (const GLuint program : programs) {
|
||||
const GLint completion = QueryProgramCompletion(program);
|
||||
ASSERT_TRUE(completion == GL_TRUE || completion == GL_FALSE) << "completion = " << completion;
|
||||
if (completion == GL_FALSE) ++outstanding;
|
||||
}
|
||||
if (outstanding == 0) {
|
||||
GTEST_SKIP() << "the pool drained the whole backlog before the first query; nothing outstanding to observe";
|
||||
}
|
||||
|
||||
for (const GLuint program : programs) {
|
||||
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE);
|
||||
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "GL_LINK_STATUS must have joined";
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// "Nothing outstanding" is the answer for an object that was never compiled or linked at
|
||||
// all: the query asks whether work is pending, not whether work ever happened.
|
||||
TEST_F(ParallelShaderCompileTest, CompletionStatusIsTrueForUntouchedObjects) {
|
||||
const AsyncModeScope async(true);
|
||||
const GLuint shader = MakeShader(GL_FRAGMENT_SHADER, "#version 460\nvoid main() {}\n");
|
||||
const GLuint program = CreateProgram();
|
||||
EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE);
|
||||
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// With the flag off nothing is ever in flight, so the query is constant GL_TRUE - and, just
|
||||
// as importantly, still a recognized pname rather than a GL_INVALID_ENUM.
|
||||
TEST_F(ParallelShaderCompileTest, CompletionStatusIsAlwaysTrueWithAsyncOff) {
|
||||
const AsyncModeScope async(false);
|
||||
Vector<String> sources;
|
||||
const Vector<GLuint> shaders = EnqueueBacklog(8, 4600, sources);
|
||||
for (const GLuint shader : shaders) {
|
||||
EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE);
|
||||
}
|
||||
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
CompileShader(vs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, shaders.front());
|
||||
LinkProgram(program);
|
||||
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The pname is new; the rejection of everything else must be untouched.
|
||||
TEST_F(ParallelShaderCompileTest, UnknownPnamesStillRaiseInvalidEnum) {
|
||||
const GLuint shader = MakeShader(GL_FRAGMENT_SHADER, "#version 460\nvoid main() {}\n");
|
||||
const GLuint program = CreateProgram();
|
||||
GLint value = 0;
|
||||
GetShaderiv(shader, GL_TEXTURE_2D, &value);
|
||||
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
|
||||
GetProgramiv(program, GL_TEXTURE_2D, &value);
|
||||
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// glMaxShaderCompilerThreadsKHR / ...ARB
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// count == 0 is the mode switch the extension defines: no compiler threads. Two obligations
|
||||
// follow, and both are asserted here - everything already in flight is settled by the time
|
||||
// the call returns (so every GL_COMPLETION_STATUS_KHR reads GL_TRUE straight away), and
|
||||
// compilation that happens AFTERWARDS is synchronous too.
|
||||
TEST_F(ParallelShaderCompileTest, ZeroCompilerThreadsJoinsEverythingAndCompilesInline) {
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope threads;
|
||||
MaxShaderCompilerThreadsKHR(1);
|
||||
|
||||
Vector<String> sources;
|
||||
const Vector<GLuint> backlog = EnqueueBacklog(48, 4800, sources);
|
||||
|
||||
MaxShaderCompilerThreadsKHR(0);
|
||||
EXPECT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended());
|
||||
EXPECT_FALSE(MG_Util::Async::AsyncShaderCompileActive());
|
||||
// The configuration flag itself is untouched: the extension is still advertised, the
|
||||
// application just asked for serial compilation.
|
||||
EXPECT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled());
|
||||
|
||||
for (const GLuint shader : backlog) {
|
||||
EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE)
|
||||
<< "glMaxShaderCompilerThreadsKHR(0) must leave nothing in flight";
|
||||
EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE);
|
||||
}
|
||||
|
||||
// Anything compiled from here on is finished before its glCompileShader returns.
|
||||
Vector<String> serialSources;
|
||||
const Vector<GLuint> serial = EnqueueBacklog(6, 4900, serialSources);
|
||||
for (const GLuint shader : serial) {
|
||||
EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE) << "a compile after a zero count must be synchronous";
|
||||
}
|
||||
// Links too, not just compiles.
|
||||
const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs);
|
||||
CompileShader(vs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, serial.front());
|
||||
LinkProgram(program);
|
||||
EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "a link after a zero count must be synchronous";
|
||||
EXPECT_EQ(QueryLinkStatus(program), GL_TRUE);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ...and a later NONZERO count is what lifts it. Nothing else does: not a new context, not a
|
||||
// join, not eglInitialize. That is the documented contract, so it gets an assertion.
|
||||
TEST_F(ParallelShaderCompileTest, NonzeroCompilerThreadsRestoresAsynchronousCompilation) {
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope threads;
|
||||
|
||||
MaxShaderCompilerThreadsKHR(0);
|
||||
ASSERT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended());
|
||||
|
||||
// Re-initializing must NOT quietly re-arm it - the application asked for serial
|
||||
// compilation and has not taken that back.
|
||||
MobileGL::Initialize();
|
||||
EXPECT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended());
|
||||
|
||||
MaxShaderCompilerThreadsKHR(4);
|
||||
EXPECT_FALSE(MG_Util::Async::IsAsyncShaderCompileSuspended());
|
||||
EXPECT_TRUE(MG_Util::Async::AsyncShaderCompileActive());
|
||||
|
||||
// And work really is being enqueued again: with the budget back at one worker a heavy
|
||||
// backlog leaves something outstanding (skip-not-fail if the box drained it first).
|
||||
MaxShaderCompilerThreadsKHR(1);
|
||||
Vector<String> sources;
|
||||
const Vector<GLuint> shaders = EnqueueBacklog(64, 5000, sources);
|
||||
const Bool anyOutstanding = std::any_of(shaders.begin(), shaders.end(), [](const GLuint shader) {
|
||||
return QueryShaderCompletion(shader) == GL_FALSE;
|
||||
});
|
||||
if (!anyOutstanding) {
|
||||
GTEST_SKIP() << "the pool drained the backlog before the first query; asynchrony not observable here";
|
||||
}
|
||||
for (const GLuint shader : shaders) {
|
||||
EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE);
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The three count cases map onto the pool's concurrency budget: a request above the thread
|
||||
// count cannot conjure threads, 0xFFFFFFFF means "implementation maximum", and an ordinary
|
||||
// value is taken as given (clamped to at least one).
|
||||
TEST_F(ParallelShaderCompileTest, CompilerThreadCountIsClampedToTheThreadCount) {
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope threads;
|
||||
auto& pool = MG_Util::Async::ShaderCompilePool::Get();
|
||||
const Uint threadCount = pool.GetThreadCount();
|
||||
ASSERT_GE(threadCount, 1u);
|
||||
|
||||
MaxShaderCompilerThreadsKHR(1);
|
||||
EXPECT_EQ(pool.GetMaxConcurrency(), 1u);
|
||||
|
||||
MaxShaderCompilerThreadsKHR(threadCount + 1000);
|
||||
EXPECT_EQ(pool.GetMaxConcurrency(), threadCount) << "asking for more threads than exist cannot create any";
|
||||
|
||||
MaxShaderCompilerThreadsKHR(1);
|
||||
ASSERT_EQ(pool.GetMaxConcurrency(), 1u);
|
||||
MaxShaderCompilerThreadsKHR(0xFFFFFFFFu);
|
||||
EXPECT_EQ(pool.GetMaxConcurrency(), threadCount) << "0xFFFFFFFF is the implementation maximum";
|
||||
|
||||
// The ARB spelling is the same entry point, not a second piece of state.
|
||||
MaxShaderCompilerThreadsARB(1);
|
||||
EXPECT_EQ(pool.GetMaxConcurrency(), 1u);
|
||||
MaxShaderCompilerThreadsARB(0);
|
||||
EXPECT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended());
|
||||
MaxShaderCompilerThreadsKHR(threadCount);
|
||||
EXPECT_FALSE(MG_Util::Async::IsAsyncShaderCompileSuspended())
|
||||
<< "the KHR and ARB names must share one piece of state";
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// A zero count with the feature switched off is legal and does nothing observable: there is
|
||||
// nothing to suspend, and the call must not fail just because MobileGL never had threads.
|
||||
TEST_F(ParallelShaderCompileTest, CompilerThreadCallsAreHarmlessWithAsyncOff) {
|
||||
const AsyncModeScope async(false);
|
||||
const CompilerThreadScope threads;
|
||||
MaxShaderCompilerThreadsKHR(0);
|
||||
MaxShaderCompilerThreadsKHR(8);
|
||||
MaxShaderCompilerThreadsARB(0xFFFFFFFFu);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// GL_MAX_SHADER_COMPILER_THREADS_KHR
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
TEST_F(ParallelShaderCompileTest, MaxShaderCompilerThreadsGetter) {
|
||||
{
|
||||
const AsyncModeScope async(true);
|
||||
GLint value = -1;
|
||||
GetIntegerv(GL_MAX_SHADER_COMPILER_THREADS_KHR, &value);
|
||||
EXPECT_EQ(value, static_cast<GLint>(MG_Util::Async::ShaderCompilePool::Get().GetThreadCount()));
|
||||
EXPECT_GE(value, 1);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
{
|
||||
// No compiler threads exist in this configuration, and the extension is not
|
||||
// advertised either, so zero is the honest answer.
|
||||
const AsyncModeScope async(false);
|
||||
GLint value = -1;
|
||||
GetIntegerv(GL_MAX_SHADER_COMPILER_THREADS_KHR, &value);
|
||||
EXPECT_EQ(value, 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// The reported maximum is the pool's THREAD count, not its current concurrency budget: an
|
||||
// application that lowered the budget still wants to know what the implementation can do.
|
||||
TEST_F(ParallelShaderCompileTest, MaxShaderCompilerThreadsIgnoresTheCurrentBudget) {
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope threads;
|
||||
const Uint threadCount = MG_Util::Async::ShaderCompilePool::Get().GetThreadCount();
|
||||
MaxShaderCompilerThreadsKHR(1);
|
||||
GLint value = -1;
|
||||
GetIntegerv(GL_MAX_SHADER_COMPILER_THREADS_KHR, &value);
|
||||
EXPECT_EQ(value, static_cast<GLint>(threadCount));
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The extension string
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The advertisement is the riskiest half of P1 - a recorded trace cannot cover it, because
|
||||
// Iris and Sodium change their submission schedule the moment they see the string - so
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE=0 has to withdraw it. Asserted on both backends' own
|
||||
// BuildAdvertisedExtensions, which is the single source of truth each of them (and the
|
||||
// driver POST) builds the list from.
|
||||
TEST_F(ParallelShaderCompileTest, BothBackendsAdvertiseTheExtensionIffAsyncIsEnabled) {
|
||||
{
|
||||
const AsyncModeScope async(true);
|
||||
EXPECT_TRUE(Advertises(MG_Backend::DirectGLES::BuildAdvertisedExtensions(false, false),
|
||||
E_GL_KHR_parallel_shader_compile));
|
||||
EXPECT_TRUE(Advertises(MG_Backend::DirectVulkan::BuildAdvertisedExtensions(false, false, false),
|
||||
E_GL_KHR_parallel_shader_compile));
|
||||
}
|
||||
{
|
||||
const AsyncModeScope async(false);
|
||||
EXPECT_FALSE(Advertises(MG_Backend::DirectGLES::BuildAdvertisedExtensions(false, false),
|
||||
E_GL_KHR_parallel_shader_compile))
|
||||
<< "MOBILEGL_ASYNC_SHADER_COMPILE=0 must withdraw the extension, not only the threading";
|
||||
EXPECT_FALSE(Advertises(MG_Backend::DirectVulkan::BuildAdvertisedExtensions(false, false, false),
|
||||
E_GL_KHR_parallel_shader_compile))
|
||||
<< "MOBILEGL_ASYNC_SHADER_COMPILE=0 must withdraw the extension, not only the threading";
|
||||
}
|
||||
}
|
||||
|
||||
// The same fact through the GL surface an application actually reads. No flag flipping here:
|
||||
// a backend's advertised list is built once, at its first use, from the configuration that
|
||||
// was in force then - so this case asserts against the AMBIENT configuration, which is
|
||||
// exactly what makes it meaningful in both of the suite's two runs (with and without
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE=1 exported).
|
||||
TEST_F(ParallelShaderCompileTest, GLExtensionStringTracksTheAmbientConfiguration) {
|
||||
UniquePtr<MG_Backend::BackendObject> previousBackend = Move(MG_Backend::pActiveBackendObject);
|
||||
MG_Backend::pActiveBackendObject = MakeUnique<MG_Backend::DirectGLES::BackendObject_DirectGLES>();
|
||||
|
||||
const char* extensions = reinterpret_cast<const char*>(GetString(GL_EXTENSIONS));
|
||||
ASSERT_NE(extensions, nullptr);
|
||||
const String extensionString(extensions);
|
||||
const Bool advertised = extensionString.find("GL_KHR_parallel_shader_compile") != String::npos;
|
||||
EXPECT_EQ(advertised, MG_Util::Async::AsyncShaderCompileEnabled()) << "GL_EXTENSIONS = " << extensionString;
|
||||
|
||||
// glGetStringi must agree with the monolithic string - LWJGL builds GLCapabilities from
|
||||
// the indexed form on a core profile.
|
||||
GLint count = 0;
|
||||
GetIntegerv(GL_NUM_EXTENSIONS, &count);
|
||||
ASSERT_GT(count, 0);
|
||||
Bool foundIndexed = false;
|
||||
for (GLint i = 0; i < count; ++i) {
|
||||
const char* name = reinterpret_cast<const char*>(GetStringi(GL_EXTENSIONS, static_cast<GLuint>(i)));
|
||||
if (name != nullptr && std::string(name) == "GL_KHR_parallel_shader_compile") foundIndexed = true;
|
||||
}
|
||||
EXPECT_EQ(foundIndexed, advertised);
|
||||
|
||||
MG_Backend::pActiveBackendObject = Move(previousBackend);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,8 @@
|
||||
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
|
||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||
#include "MG_State/GLState/Core.h"
|
||||
#include "MG_State/GLState/ProgramState/ShaderPreprocessCache.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
|
||||
|
||||
using namespace MobileGL;
|
||||
@@ -346,6 +348,29 @@ void main() {
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
TEST_F(ProgramTest, OutOfRangeComputeLocalSizeLiteralFailsCompileInsteadOfThrowing) {
|
||||
// The layout scanner's digit capture is unbounded, so a literal wider than 64 bits is a
|
||||
// legal match. It must saturate and be rejected through COMPILE_STATUS; if the integer
|
||||
// conversion throws instead, the exception escapes glCompileShader entirely.
|
||||
char infoLog[1024] = "";
|
||||
const char* csSrc = R"(#version 460 core
|
||||
layout(local_size_x = 99999999999999999999999) in;
|
||||
void main() {
|
||||
}
|
||||
)";
|
||||
|
||||
GLuint cs = CreateShader(GL_COMPUTE_SHADER);
|
||||
ShaderSource(cs, 1, &csSrc, nullptr);
|
||||
CompileShader(cs);
|
||||
|
||||
GLint csStatus = GL_TRUE;
|
||||
GetShaderiv(cs, GL_COMPILE_STATUS, &csStatus);
|
||||
EXPECT_EQ(csStatus, GL_FALSE);
|
||||
GetShaderInfoLog(cs, sizeof(infoLog), nullptr, infoLog);
|
||||
EXPECT_NE(String(infoLog).find("GL_MAX_COMPUTE_WORK_GROUP_SIZE"), String::npos) << infoLog;
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
TEST_F(ProgramTest, DirectVulkanStorageBlockUsesShaderLayoutBinding) {
|
||||
char infoLog[1024] = "";
|
||||
const char* csSrc = R"(#version 460 core
|
||||
@@ -2365,3 +2390,849 @@ void main() { o_color = vec4(1.0); }
|
||||
EXPECT_EQ(IsShader(fs), GL_FALSE);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---- P0a single-parse regression tests ----
|
||||
// glCompileShader now performs the one link-compatible (relaxed Vulkan-rules) parse;
|
||||
// these pin the GL frontend semantics that parse cannot provide by itself.
|
||||
|
||||
namespace {
|
||||
GLuint CompileShaderChecked(GLenum type, const char* source) {
|
||||
char infoLog[1024] = "";
|
||||
GLuint shader = CreateShader(type);
|
||||
ShaderSource(shader, 1, &source, nullptr);
|
||||
CompileShader(shader);
|
||||
GLint status = GL_FALSE;
|
||||
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
|
||||
GetShaderInfoLog(shader, sizeof(infoLog), nullptr, infoLog);
|
||||
EXPECT_EQ(status, GL_TRUE) << infoLog;
|
||||
return shader;
|
||||
}
|
||||
|
||||
GLuint LinkVsFs(GLuint vs, GLuint fs, GLint expectedLinkStatus) {
|
||||
char infoLog[2048] = "";
|
||||
GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, fs);
|
||||
LinkProgram(program);
|
||||
GLint linkStatus = GL_FALSE;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
|
||||
GetProgramInfoLog(program, sizeof(infoLog), nullptr, infoLog);
|
||||
EXPECT_EQ(linkStatus, expectedLinkStatus) << infoLog;
|
||||
return program;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// The relaxed parse sweeps every DECLARED default-block uniform into MGL_GLOBAL_UBO,
|
||||
// including ones no stage reads. GL requires those to be inactive: absent from the
|
||||
// glGetActiveUniform enumeration and -1 from glGetUniformLocation. The synthesized
|
||||
// MGL_GLOBAL_UBO itself must not surface as a GL uniform block either.
|
||||
TEST_F(ProgramTest, DeclaredButUnreadUniformIsInactiveAndGlobalUboStaysHidden) {
|
||||
const char* vsSource = R"(#version 330 core
|
||||
uniform mat4 uUsedMat;
|
||||
uniform vec4 uDeadVec;
|
||||
void main() { gl_Position = uUsedMat * vec4(1.0); }
|
||||
)";
|
||||
const char* fsSource = R"(#version 330 core
|
||||
uniform vec4 uUsedColor;
|
||||
uniform float uDeadFloat;
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = uUsedColor; }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
|
||||
GLint activeUniforms = 0;
|
||||
GetProgramiv(program, GL_ACTIVE_UNIFORMS, &activeUniforms);
|
||||
EXPECT_EQ(activeUniforms, 2);
|
||||
|
||||
EXPECT_NE(GetUniformLocation(program, "uUsedMat"), -1);
|
||||
EXPECT_NE(GetUniformLocation(program, "uUsedColor"), -1);
|
||||
EXPECT_EQ(GetUniformLocation(program, "uDeadVec"), -1);
|
||||
EXPECT_EQ(GetUniformLocation(program, "uDeadFloat"), -1);
|
||||
EXPECT_EQ(UniformIndexByName(program, "uDeadVec"), GL_INVALID_INDEX);
|
||||
|
||||
char nameBuf[64] = "";
|
||||
for (GLint i = 0; i < activeUniforms; ++i) {
|
||||
GLsizei nameLen = 0;
|
||||
GLint size = 0;
|
||||
GLenum type = 0;
|
||||
GetActiveUniform(program, static_cast<GLuint>(i), sizeof(nameBuf), &nameLen, &size, &type, nameBuf);
|
||||
EXPECT_TRUE(std::strcmp(nameBuf, "uDeadVec") != 0 && std::strcmp(nameBuf, "uDeadFloat") != 0)
|
||||
<< nameBuf;
|
||||
}
|
||||
|
||||
// No named blocks are declared, so GL must see zero uniform blocks - the global
|
||||
// UBO the transpiler materializes is an implementation artifact.
|
||||
GLint activeBlocks = 0;
|
||||
GetProgramiv(program, GL_ACTIVE_UNIFORM_BLOCKS, &activeBlocks);
|
||||
EXPECT_EQ(activeBlocks, 0);
|
||||
EXPECT_EQ(GetUniformBlockIndex(program, "MGL_GLOBAL_UBO"), GL_INVALID_INDEX);
|
||||
|
||||
// Default-block uniforms report block index -1 and offset -1 even though the
|
||||
// relaxed parse physically placed them in the global UBO.
|
||||
const GLuint usedMat = UniformIndexByName(program, "uUsedMat");
|
||||
ASSERT_NE(usedMat, GL_INVALID_INDEX);
|
||||
EXPECT_EQ(QueryUniformiv(program, usedMat, GL_UNIFORM_BLOCK_INDEX), -1);
|
||||
EXPECT_EQ(QueryUniformiv(program, usedMat, GL_UNIFORM_OFFSET), -1);
|
||||
EXPECT_EQ(QueryUniformiv(program, usedMat, GL_UNIFORM_ARRAY_STRIDE), -1);
|
||||
EXPECT_EQ(QueryUniformiv(program, usedMat, GL_UNIFORM_MATRIX_STRIDE), -1);
|
||||
EXPECT_EQ(QueryUniformiv(program, usedMat, GL_UNIFORM_IS_ROW_MAJOR), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Distinct uniforms whose explicit locations overlap across stages must fail the
|
||||
// link (ARB_explicit_uniform_location). The GL-client parse used to reject this at
|
||||
// glslang mapIO; the relaxed parse drops the qualifiers, so the location assigner
|
||||
// enforces it - this is the experiment's synthetic divergence case.
|
||||
TEST_F(ProgramTest, ExplicitUniformLocationOverlapAcrossStagesFailsLink) {
|
||||
const char* vsSource = R"(#version 460 core
|
||||
layout(location = 3) uniform vec4 uVec[4];
|
||||
void main() { gl_Position = uVec[0] + uVec[3]; }
|
||||
)";
|
||||
const char* fsSource = R"(#version 460 core
|
||||
layout(location = 5) uniform float uF;
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = vec4(uF); }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_FALSE);
|
||||
|
||||
char infoLog[1024] = "";
|
||||
GLsizei logLength = 0;
|
||||
GetProgramInfoLog(program, sizeof(infoLog), &logLength, infoLog);
|
||||
EXPECT_GT(logLength, 0);
|
||||
}
|
||||
|
||||
// The same uniform declared with different explicit locations in two stages is a
|
||||
// link error as well.
|
||||
TEST_F(ProgramTest, ConflictingExplicitUniformLocationsOnSameUniformFailLink) {
|
||||
const char* vsSource = R"(#version 460 core
|
||||
layout(location = 2) uniform vec4 uShared;
|
||||
void main() { gl_Position = uShared; }
|
||||
)";
|
||||
const char* fsSource = R"(#version 460 core
|
||||
layout(location = 4) uniform vec4 uShared;
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = uShared; }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
(void)LinkVsFs(vs, fs, GL_FALSE);
|
||||
}
|
||||
|
||||
// Same-location explicit declarations of the SAME uniform in both stages stay
|
||||
// linkable, and both explicit locations (opaque and non-opaque) are honored.
|
||||
TEST_F(ProgramTest, ExplicitUniformLocationsHonoredForPlainAndOpaqueUniforms) {
|
||||
const char* vsSource = R"(#version 460 core
|
||||
layout(location = 11) uniform mat4 uMvp;
|
||||
void main() { gl_Position = uMvp * vec4(1.0); }
|
||||
)";
|
||||
const char* fsSource = R"(#version 460 core
|
||||
layout(location = 7) uniform sampler2D uTex;
|
||||
layout(location = 11) uniform mat4 uMvp;
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = texture(uTex, uMvp[0].xy); }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
|
||||
EXPECT_EQ(GetUniformLocation(program, "uMvp"), 11);
|
||||
EXPECT_EQ(GetUniformLocation(program, "uTex"), 7);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// A glslang-auto-assigned opaque location may collide with a source-explicit plain
|
||||
// uniform location under the relaxed parse (glslang no longer sees the plain
|
||||
// uniform's qualifier). The assigner must relocate the auto one, not fail the link.
|
||||
TEST_F(ProgramTest, AutoOpaqueLocationCollidingWithExplicitPlainLocationRelocates) {
|
||||
const char* vsSource = R"(#version 460 core
|
||||
layout(location = 0) uniform mat4 uM;
|
||||
void main() { gl_Position = uM * vec4(1.0); }
|
||||
)";
|
||||
const char* fsSource = R"(#version 460 core
|
||||
uniform sampler2D uTex;
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = texture(uTex, vec2(0.5)); }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
|
||||
const GLint mLoc = GetUniformLocation(program, "uM");
|
||||
const GLint texLoc = GetUniformLocation(program, "uTex");
|
||||
EXPECT_EQ(mLoc, 0);
|
||||
ASSERT_NE(texLoc, -1);
|
||||
EXPECT_NE(texLoc, mLoc);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Relinking a program and linking the same compiled shaders into a second program
|
||||
// both re-consume the stored single parse (glslang mapIO mutates a linked TShader,
|
||||
// so reuse goes through the consume-once re-parse path). Reflection must be intact
|
||||
// every time, without any glCompileShader in between.
|
||||
TEST_F(ProgramTest, RelinkAndSecondProgramReuseCompiledShaders) {
|
||||
const char* vsSource = R"(#version 330 core
|
||||
uniform mat4 uMvp;
|
||||
in vec3 aPos;
|
||||
void main() { gl_Position = uMvp * vec4(aPos, 1.0); }
|
||||
)";
|
||||
const char* fsSource = R"(#version 330 core
|
||||
uniform sampler2D uTex;
|
||||
uniform vec4 uTint;
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = texture(uTex, vec2(0.5)) * uTint; }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
|
||||
GLuint program1 = LinkVsFs(vs, fs, GL_TRUE);
|
||||
GLint activeUniforms1 = 0;
|
||||
GetProgramiv(program1, GL_ACTIVE_UNIFORMS, &activeUniforms1);
|
||||
EXPECT_EQ(activeUniforms1, 3);
|
||||
EXPECT_NE(GetUniformLocation(program1, "uMvp"), -1);
|
||||
|
||||
// Relink: consumes the re-parse path.
|
||||
LinkProgram(program1);
|
||||
GLint relinkStatus = GL_FALSE;
|
||||
char infoLog[1024] = "";
|
||||
GetProgramiv(program1, GL_LINK_STATUS, &relinkStatus);
|
||||
GetProgramInfoLog(program1, sizeof(infoLog), nullptr, infoLog);
|
||||
ASSERT_EQ(relinkStatus, GL_TRUE) << infoLog;
|
||||
GLint activeUniformsRelink = 0;
|
||||
GetProgramiv(program1, GL_ACTIVE_UNIFORMS, &activeUniformsRelink);
|
||||
EXPECT_EQ(activeUniformsRelink, 3);
|
||||
EXPECT_NE(GetUniformLocation(program1, "uTint"), -1);
|
||||
|
||||
// Same shaders into a fresh program.
|
||||
GLuint program2 = LinkVsFs(vs, fs, GL_TRUE);
|
||||
GLint activeUniforms2 = 0;
|
||||
GetProgramiv(program2, GL_ACTIVE_UNIFORMS, &activeUniforms2);
|
||||
EXPECT_EQ(activeUniforms2, 3);
|
||||
EXPECT_NE(GetUniformLocation(program2, "uTex"), -1);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Programs and shaders share one GL name space (GL 3.3 core 2.11). A name must
|
||||
// never be handed out as both, and a shader name passed where a program is
|
||||
// expected is INVALID_OPERATION (KHR-GL30.get_uniform_tests.get_uniform relies
|
||||
// on this; a name-collided linked program used to swallow the error).
|
||||
TEST_F(ProgramTest, ProgramAndShaderNamesShareOneNameSpace) {
|
||||
GLuint program = CreateProgram();
|
||||
GLuint vs = CreateShader(GL_VERTEX_SHADER);
|
||||
GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
EXPECT_NE(program, vs);
|
||||
EXPECT_NE(program, fs);
|
||||
EXPECT_NE(vs, fs);
|
||||
EXPECT_EQ(IsProgram(vs), GL_FALSE);
|
||||
EXPECT_EQ(IsShader(program), GL_FALSE);
|
||||
|
||||
GLfloat floatValue = 0.0f;
|
||||
GetUniformfv(vs, 0, &floatValue);
|
||||
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_INVALID_OPERATION));
|
||||
GLint intValue = 0;
|
||||
GetUniformiv(fs, 0, &intValue);
|
||||
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_INVALID_OPERATION));
|
||||
|
||||
// A never-allocated name is INVALID_VALUE, distinguishing the two cases.
|
||||
GetUniformfv(program + vs + fs + 100, 0, &floatValue);
|
||||
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_INVALID_VALUE));
|
||||
|
||||
DeleteShader(vs);
|
||||
DeleteShader(fs);
|
||||
DeleteProgram(program);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---- builtin-shadowing OpName pass (P0c) ----
|
||||
// Desktop GLSL lets a pack redefine builtins; ESSL 3.x forbids it, so the rename
|
||||
// now happens as a SPIR-V OpName pass in SanitizeAndOptimizeBinary instead of the
|
||||
// old whole-source string scan. These pin the pass end-to-end: real sources through
|
||||
// glCompileShader/glLinkProgram, generated SPIR-V transpiled to the ESSL the Espryt
|
||||
// driver would see.
|
||||
|
||||
namespace {
|
||||
Vector<MobileGL::String> TranspileProgramSpirvToEssl(GLuint program) {
|
||||
Vector<MobileGL::String> esslModules;
|
||||
auto programObj = MG_State::pGLContext->GetProgramObject(program);
|
||||
for (auto& spirvCode : programObj->GetGeneratedSpirv()) {
|
||||
MG_Util::ShaderTranspiler::SpvcSession spvcSession(
|
||||
spirvCode, MG_Util::ShaderTranspiler::SessionUsageBit::Transpile);
|
||||
spvc_compiler_options options;
|
||||
spvcSession.CreateOptions(&options);
|
||||
spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, 320);
|
||||
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_ES, SPVC_TRUE);
|
||||
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_VULKAN_SEMANTICS, SPVC_FALSE);
|
||||
spvcSession.SetOptions(options);
|
||||
const char* result = nullptr;
|
||||
spvcSession.Compile(&result);
|
||||
EXPECT_NE(result, nullptr) << spvcSession.GetLastErrorString();
|
||||
esslModules.push_back(result ? result : "");
|
||||
}
|
||||
return esslModules;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// The two blind spots of the old string scan, eliminated by construction: a
|
||||
// MULTILINE definition (bliss-shaped "float fma\n(...)"), and names outside the
|
||||
// old 5-entry list: sinh, as a NEW overload no builtin signature matches, so it
|
||||
// parses fine and the SPIR-V OpName backstop does the rename. (An EXACT-signature
|
||||
// sinh redefinition is parse-rejected by glslang - on HEAD too - and is therefore
|
||||
// deliberately NOT lexically rescued; see kLexicalPreemptRenameNames.)
|
||||
// min3/max3 keep their historical coverage.
|
||||
TEST_F(ProgramTest, BuiltinShadowingFunctionsRenamedInEsslOutput) {
|
||||
const char* vsSource = R"(#version 330 core
|
||||
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
|
||||
)";
|
||||
const char* fsSource = R"(#version 330 core
|
||||
out vec4 fragColor;
|
||||
|
||||
float fma
|
||||
(float a, float b, float c) { return a * b + c; }
|
||||
float sinh(float x, float y) { return x * y; }
|
||||
float round(float x) { return floor(x + 0.5); }
|
||||
float min3(float a, float b, float c) { return min(min(a, b), c); }
|
||||
|
||||
void main() {
|
||||
fragColor = vec4(fma(0.1, 0.2, 0.3), sinh(0.4, 2.0), round(1.25), min3(0.1, 0.2, 0.3));
|
||||
}
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
|
||||
for (const auto& essl : TranspileProgramSpirvToEssl(program)) {
|
||||
if (essl.find("fragColor") == String::npos) continue; // fragment module only
|
||||
EXPECT_NE(essl.find("mg_fma("), String::npos) << essl;
|
||||
EXPECT_NE(essl.find("mg_sinh("), String::npos) << essl;
|
||||
EXPECT_NE(essl.find("mg_round("), String::npos) << essl;
|
||||
EXPECT_NE(essl.find("mg_min3("), String::npos) << essl;
|
||||
EXPECT_EQ(essl.find("float fma("), String::npos) << essl;
|
||||
EXPECT_EQ(essl.find("float sinh("), String::npos) << essl;
|
||||
EXPECT_EQ(essl.find("float round("), String::npos) << essl;
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Pure builtin USAGE (plus a commented-out definition) must stay untouched: builtin
|
||||
// calls never resolve to a user function id in SPIR-V, so no mg_ name may appear.
|
||||
TEST_F(ProgramTest, BuiltinUsageWithoutShadowingDefinitionKeepsBuiltinCalls) {
|
||||
const char* vsSource = R"(#version 330 core
|
||||
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
|
||||
)";
|
||||
// 400, not 330: the builtin fma() really is called here, and it is only core from GLSL 4.00
|
||||
// (at 330 it needs GL_ARB_gpu_shader5). The shadowing case above can stay at 330 precisely
|
||||
// because the rename means no call to the builtin survives.
|
||||
const char* fsSource = R"(#version 400 core
|
||||
// float round(float x) { return floor(x + 0.5); }
|
||||
out vec4 fragColor;
|
||||
void main() {
|
||||
fragColor = vec4(round(1.25), fma(0.1, 0.2, 0.3), tanh(0.5), 1.0);
|
||||
}
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
|
||||
for (const auto& essl : TranspileProgramSpirvToEssl(program)) {
|
||||
EXPECT_EQ(essl.find("mg_"), String::npos) << essl;
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---- the three shapes the lexical pre-empt pass must NOT touch (P0c) ----
|
||||
// The source-level rename runs only for the handful of names glslang's relaxed
|
||||
// parse rejects outright; everything else waits for the OpName pass, which cannot
|
||||
// over-fire. These pin the three ways a lexical scan gets it wrong. All of them
|
||||
// would fail as "no matching overloaded function found" - an over-detection is
|
||||
// unrecoverable because the source never reaches SPIR-V.
|
||||
|
||||
namespace {
|
||||
// "pow(" as a real builtin call, i.e. not the tail of "mg_pow(".
|
||||
bool ContainsUnprefixedCall(const MobileGL::String& essl, const MobileGL::String& name) {
|
||||
const MobileGL::String needle = name + "(";
|
||||
for (SizeT pos = essl.find(needle); pos != String::npos; pos = essl.find(needle, pos + 1)) {
|
||||
const char before = pos == 0 ? ' ' : essl[pos - 1];
|
||||
const bool isIdentifierChar =
|
||||
std::isalnum(static_cast<unsigned char>(before)) != 0 || before == '_';
|
||||
if (!isIdentifierChar) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// B1: preprocessor-asymmetric braces desync a raw brace-depth counter (each arm of
|
||||
// the #ifdef closes the function), and "return" is lexically an identifier - so
|
||||
// "return clamp(...)" reads as a top-level definition "<type> <builtin> (". A
|
||||
// shader that shadows nothing must survive intact.
|
||||
TEST_F(ProgramTest, StatementKeywordCallInPreprocessorAsymmetricBracesIsNotAShadowingDefinition) {
|
||||
const char* vsSource = R"(#version 330 core
|
||||
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
|
||||
)";
|
||||
const char* fsSource = R"(#version 330 core
|
||||
uniform vec3 uP;
|
||||
out vec4 fragColor;
|
||||
|
||||
float getShadow(vec3 v) {
|
||||
#ifdef SHADOW_OFF
|
||||
return 1.0;
|
||||
}
|
||||
#else
|
||||
return round(dot(v, v));
|
||||
}
|
||||
#endif
|
||||
|
||||
void main() { fragColor = vec4(getShadow(uP) * clamp(uP.x, 0.0, 1.0)); }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
|
||||
for (const auto& essl : TranspileProgramSpirvToEssl(program)) {
|
||||
if (essl.find("fragColor") == String::npos) continue; // fragment module only
|
||||
EXPECT_EQ(essl.find("mg_"), String::npos) << essl;
|
||||
// SPIRV-Cross lowers GLSL.std.450 FClamp to its NaN-correct min/max/isnan form, so the
|
||||
// surviving evidence of the builtin call is that pair, not the spelling "clamp(". The
|
||||
// stronger guard is above it: a renamed mg_clamp would not have compiled at all.
|
||||
EXPECT_TRUE(ContainsUnprefixedCall(essl, "min")) << essl;
|
||||
EXPECT_TRUE(ContainsUnprefixedCall(essl, "max")) << essl;
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// B2: the scan is preprocessor-blind, so a definition in a DEAD #if branch would
|
||||
// poison every live call to the real builtin. #version 120 normalizes to 330, so
|
||||
// __VERSION__ is 330 and the compat shim is dropped by glslang - the definition
|
||||
// never exists, and nothing may be renamed.
|
||||
TEST_F(ProgramTest, ShadowingDefinitionInDeadPreprocessorBranchLeavesLiveBuiltinCalls) {
|
||||
const char* vsSource = R"(#version 120
|
||||
#if __VERSION__ < 140
|
||||
mat4 inverse(mat4 m) { return m; }
|
||||
#endif
|
||||
uniform mat4 uM;
|
||||
uniform vec4 uV;
|
||||
void main() { gl_Position = inverse(uM) * uV; }
|
||||
)";
|
||||
const char* fsSource = R"(#version 330 core
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = vec4(1.0); }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
|
||||
for (const auto& essl : TranspileProgramSpirvToEssl(program)) {
|
||||
if (essl.find("gl_Position") == String::npos) continue; // vertex module only
|
||||
EXPECT_EQ(essl.find("mg_"), String::npos) << essl;
|
||||
EXPECT_TRUE(ContainsUnprefixedCall(essl, "inverse")) << essl;
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// B3: the idiomatic reason to shadow a builtin is to ADD an overload and delegate
|
||||
// to the real one. A blanket call-site rewrite would turn the body's builtin call
|
||||
// into mg_pow(vec3, vec3), which has no overload. The OpName backstop renames the
|
||||
// user function id only, so the delegation still resolves to GLSL.std.450 Pow.
|
||||
TEST_F(ProgramTest, OverloadDelegatingToShadowedBuiltinKeepsItsBuiltinCall) {
|
||||
const char* vsSource = R"(#version 330 core
|
||||
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
|
||||
)";
|
||||
const char* fsSource = R"(#version 330 core
|
||||
uniform vec3 uBase;
|
||||
out vec4 fragColor;
|
||||
|
||||
vec3 pow(vec3 v, float e) { return pow(v, vec3(e)); }
|
||||
|
||||
void main() { fragColor = vec4(pow(uBase, 2.2), 1.0); }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
|
||||
for (const auto& essl : TranspileProgramSpirvToEssl(program)) {
|
||||
if (essl.find("fragColor") == String::npos) continue; // fragment module only
|
||||
EXPECT_NE(essl.find("mg_pow("), String::npos) << essl;
|
||||
EXPECT_TRUE(ContainsUnprefixedCall(essl, "pow")) << essl;
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P0b: source-hash dedupe for shader recompiles.
|
||||
// Layer 1 - the same shader object re-sourced with byte-identical text keeps its
|
||||
// compiled state, and glCompileShader on it is a no-op.
|
||||
// Layer 2 - two DIFFERENT shader objects holding byte-identical text share the
|
||||
// source-only half of the pipeline (preprocess + lexical checks +
|
||||
// side-channel extraction) through the context's ShaderPreprocessCache,
|
||||
// while each still gets its own glslang parse.
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace {
|
||||
const char* kP0bVs = R"(#version 330 core
|
||||
uniform mat4 uModel;
|
||||
uniform vec4 uTint;
|
||||
void main() { gl_Position = uModel * uTint; }
|
||||
)";
|
||||
const char* kP0bFs = R"(#version 330 core
|
||||
uniform vec4 uColor;
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = uColor; }
|
||||
)";
|
||||
// Same stage, different declared uniform: makes "did it actually recompile?"
|
||||
// observable through reflection rather than through internal state.
|
||||
const char* kP0bAltFs = R"(#version 330 core
|
||||
uniform vec4 uOtherColor;
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = uOtherColor; }
|
||||
)";
|
||||
const char* kP0bBrokenFs = R"(#version 330 core
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = notADeclaredThing; }
|
||||
)";
|
||||
|
||||
GLuint MakeShaderWithSource(GLenum type, const char* source) {
|
||||
GLuint shader = CreateShader(type);
|
||||
ShaderSource(shader, 1, &source, nullptr);
|
||||
return shader;
|
||||
}
|
||||
|
||||
GLint QueryCompileStatus(GLuint shader) {
|
||||
GLint status = GL_FALSE;
|
||||
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
String QueryShaderInfoLog(GLuint shader) {
|
||||
GLint length = 0;
|
||||
GetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
|
||||
if (length <= 0) return String();
|
||||
std::vector<GLchar> buffer(static_cast<size_t>(length));
|
||||
GLsizei written = 0;
|
||||
GetShaderInfoLog(shader, length, &written, buffer.data());
|
||||
return String(buffer.data(), static_cast<size_t>(written));
|
||||
}
|
||||
|
||||
Bool ShaderHasMemoizedCompile(GLuint shader) {
|
||||
const auto& shaderObject = MG_State::pGLContext->GetShaderObject(shader);
|
||||
EXPECT_NE(shaderObject, nullptr);
|
||||
return shaderObject != nullptr && shaderObject->HasMemoizedCompile();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// Layer 1, success path: re-sourcing with identical text and recompiling must leave
|
||||
// COMPILE_STATUS, the info log and every downstream consumer exactly as they were -
|
||||
// including a program that links the shader AFTER the redundant recompile.
|
||||
TEST_F(ProgramTest, RecompileWithIdenticalSourceKeepsCompiledStateAndStillLinks) {
|
||||
GLuint vs = MakeShaderWithSource(GL_VERTEX_SHADER, kP0bVs);
|
||||
GLuint fs = MakeShaderWithSource(GL_FRAGMENT_SHADER, kP0bFs);
|
||||
CompileShader(vs);
|
||||
CompileShader(fs);
|
||||
ASSERT_EQ(QueryCompileStatus(vs), GL_TRUE) << QueryShaderInfoLog(vs);
|
||||
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
|
||||
const String vsLogBefore = QueryShaderInfoLog(vs);
|
||||
EXPECT_TRUE(ShaderHasMemoizedCompile(vs));
|
||||
|
||||
// A first link consumes the stored TShader; the redundant recompile below must not
|
||||
// disturb the preprocessed source that ClaimParsedShader re-parses from.
|
||||
GLuint firstProgram = LinkVsFs(vs, fs, GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(firstProgram, "uColor"), 0);
|
||||
|
||||
// glShaderSource with byte-identical text, then glCompileShader: both no-ops.
|
||||
ShaderSource(vs, 1, &kP0bVs, nullptr);
|
||||
EXPECT_TRUE(ShaderHasMemoizedCompile(vs)) << "identical re-source must not invalidate the compiled state";
|
||||
CompileShader(vs);
|
||||
ShaderSource(fs, 1, &kP0bFs, nullptr);
|
||||
CompileShader(fs);
|
||||
|
||||
EXPECT_EQ(QueryCompileStatus(vs), GL_TRUE);
|
||||
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE);
|
||||
EXPECT_EQ(QueryShaderInfoLog(vs), vsLogBefore);
|
||||
|
||||
// The original source text is still what glGetShaderSource reports.
|
||||
GLint sourceLength = 0;
|
||||
GetShaderiv(vs, GL_SHADER_SOURCE_LENGTH, &sourceLength);
|
||||
ASSERT_GT(sourceLength, 1);
|
||||
std::vector<GLchar> sourceBuffer(static_cast<size_t>(sourceLength));
|
||||
GLsizei written = 0;
|
||||
GetShaderSource(vs, sourceLength, &written, sourceBuffer.data());
|
||||
EXPECT_EQ(String(sourceBuffer.data(), static_cast<size_t>(written)), String(kP0bVs));
|
||||
|
||||
// A second program built from the same, redundantly recompiled shaders links and
|
||||
// reflects - i.e. ClaimParsedShader's re-parse path survived the no-op.
|
||||
GLuint secondProgram = LinkVsFs(vs, fs, GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(secondProgram, "uColor"), 0);
|
||||
EXPECT_GE(GetUniformLocation(secondProgram, "uModel"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Layer 1 must not swallow a REAL source change: different text invalidates, and the
|
||||
// change is visible in what the next link reflects.
|
||||
TEST_F(ProgramTest, DifferentSourceAfterCompileInvalidatesCompiledState) {
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, kP0bVs);
|
||||
GLuint fs = MakeShaderWithSource(GL_FRAGMENT_SHADER, kP0bFs);
|
||||
CompileShader(fs);
|
||||
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
|
||||
GLuint firstProgram = LinkVsFs(vs, fs, GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(firstProgram, "uColor"), 0);
|
||||
EXPECT_EQ(GetUniformLocation(firstProgram, "uOtherColor"), -1);
|
||||
|
||||
// New text -> compiled state gone, and glCompileShader is mandatory again.
|
||||
ShaderSource(fs, 1, &kP0bAltFs, nullptr);
|
||||
EXPECT_FALSE(ShaderHasMemoizedCompile(fs));
|
||||
EXPECT_EQ(QueryCompileStatus(fs), GL_FALSE);
|
||||
|
||||
CompileShader(fs);
|
||||
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
|
||||
GLuint secondProgram = LinkVsFs(vs, fs, GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(secondProgram, "uOtherColor"), 0);
|
||||
EXPECT_EQ(GetUniformLocation(secondProgram, "uColor"), -1);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Layer 2: byte-identical source in two distinct shader objects. Both must compile,
|
||||
// and each must own an independent TShader - if the parse were shared, the second
|
||||
// link would be handed an intermediate that the first link's mapIO already mutated.
|
||||
TEST_F(ProgramTest, TwoShaderObjectsWithIdenticalSourceLinkIndependently) {
|
||||
GLuint vsA = CompileShaderChecked(GL_VERTEX_SHADER, kP0bVs);
|
||||
GLuint fsA = CompileShaderChecked(GL_FRAGMENT_SHADER, kP0bFs);
|
||||
GLuint vsB = CompileShaderChecked(GL_VERTEX_SHADER, kP0bVs);
|
||||
GLuint fsB = CompileShaderChecked(GL_FRAGMENT_SHADER, kP0bFs);
|
||||
ASSERT_NE(vsA, vsB);
|
||||
ASSERT_NE(fsA, fsB);
|
||||
|
||||
const auto& objectA = MG_State::pGLContext->GetShaderObject(vsA);
|
||||
const auto& objectB = MG_State::pGLContext->GetShaderObject(vsB);
|
||||
ASSERT_NE(objectA, nullptr);
|
||||
ASSERT_NE(objectB, nullptr);
|
||||
EXPECT_EQ(objectA->GetShaderSource(), objectB->GetShaderSource());
|
||||
// P0b's layer 2 shares the PREPROCESS and never the parse: glslang's TShader is
|
||||
// consume-once, so a memo hit still has to parse for itself.
|
||||
//
|
||||
// P1 stage 6 shares something stronger when it is active - the whole compile JOB, and
|
||||
// therefore the single parse that job produced - and that sharing is made safe by
|
||||
// ShaderCompileTask::ClaimParsedShader's CAS instead, exactly as it already was for one
|
||||
// shader object attached to two programs. ShaderCompileAdoptionTest is where that is
|
||||
// pinned down (it links both objects and compares the generated SPIR-V). So the
|
||||
// one-parse-per-object assertion belongs to the non-adopting path; the two independent
|
||||
// LINKS below are what both modes have to agree on, and they are the point of this case.
|
||||
if (!MG_Util::Async::AsyncShaderCompileActive()) {
|
||||
EXPECT_NE(objectA->GetCompiledShader(), objectB->GetCompiledShader());
|
||||
}
|
||||
EXPECT_NE(objectA->GetCompiledShader(), nullptr);
|
||||
EXPECT_NE(objectB->GetCompiledShader(), nullptr);
|
||||
|
||||
GLuint programA = LinkVsFs(vsA, fsA, GL_TRUE);
|
||||
GLuint programB = LinkVsFs(vsB, fsB, GL_TRUE);
|
||||
for (GLuint program : {programA, programB}) {
|
||||
EXPECT_GE(GetUniformLocation(program, "uColor"), 0);
|
||||
EXPECT_GE(GetUniformLocation(program, "uModel"), 0);
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Failure memoization: a compile that failed stays failed, with the SAME log, when
|
||||
// recompiled against the same source; a real fix to the source still takes effect.
|
||||
// The second object pins the cached-ParseFailed path (layer 2), which skips the parse
|
||||
// entirely and must reproduce the identical verdict.
|
||||
TEST_F(ProgramTest, FailedCompileIsMemoizedAndStillRecoversOnGoodSource) {
|
||||
GLuint fs = MakeShaderWithSource(GL_FRAGMENT_SHADER, kP0bBrokenFs);
|
||||
CompileShader(fs);
|
||||
ASSERT_EQ(QueryCompileStatus(fs), GL_FALSE);
|
||||
const String failureLog = QueryShaderInfoLog(fs);
|
||||
EXPECT_FALSE(failureLog.empty());
|
||||
|
||||
// Layer 1: identical re-source + recompile keeps the failure AND the log queryable.
|
||||
ShaderSource(fs, 1, &kP0bBrokenFs, nullptr);
|
||||
CompileShader(fs);
|
||||
EXPECT_EQ(QueryCompileStatus(fs), GL_FALSE);
|
||||
EXPECT_EQ(QueryShaderInfoLog(fs), failureLog);
|
||||
|
||||
// Layer 2: a second object with the same broken source reports the same failure.
|
||||
GLuint otherFs = MakeShaderWithSource(GL_FRAGMENT_SHADER, kP0bBrokenFs);
|
||||
CompileShader(otherFs);
|
||||
EXPECT_EQ(QueryCompileStatus(otherFs), GL_FALSE);
|
||||
EXPECT_EQ(QueryShaderInfoLog(otherFs), failureLog);
|
||||
|
||||
// A genuine fix still compiles and links.
|
||||
ShaderSource(fs, 1, &kP0bFs, nullptr);
|
||||
CompileShader(fs);
|
||||
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
|
||||
EXPECT_TRUE(QueryShaderInfoLog(fs).empty());
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, kP0bVs);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(program, "uColor"), 0);
|
||||
}
|
||||
|
||||
// Layer 2 under eviction: push more distinct sources through the context than the
|
||||
// cache can hold, then confirm nothing broke and a fresh duplicate pair still works.
|
||||
TEST_F(ProgramTest, PreprocessCacheOverflowKeepsCompilingCorrectly) {
|
||||
const SizeT overflow = MG_State::GLState::ShaderPreprocessCache::kMaxEntries + 8;
|
||||
for (SizeT i = 0; i < overflow; ++i) {
|
||||
const String source = "#version 330 core\nuniform vec4 uColor" + ToString(i) +
|
||||
";\nout vec4 fragColor;\nvoid main() { fragColor = uColor" + ToString(i) + "; }\n";
|
||||
const char* sourcePtr = source.c_str();
|
||||
GLuint shader = MakeShaderWithSource(GL_FRAGMENT_SHADER, sourcePtr);
|
||||
CompileShader(shader);
|
||||
ASSERT_EQ(QueryCompileStatus(shader), GL_TRUE) << QueryShaderInfoLog(shader) << "\n" << source;
|
||||
DeleteShader(shader);
|
||||
}
|
||||
|
||||
// Everything inserted above has long since been evicted; a brand-new duplicate
|
||||
// pair must still take the layer-2 path and produce two working programs.
|
||||
GLuint vsA = CompileShaderChecked(GL_VERTEX_SHADER, kP0bVs);
|
||||
GLuint fsA = CompileShaderChecked(GL_FRAGMENT_SHADER, kP0bFs);
|
||||
GLuint vsB = CompileShaderChecked(GL_VERTEX_SHADER, kP0bVs);
|
||||
GLuint fsB = CompileShaderChecked(GL_FRAGMENT_SHADER, kP0bFs);
|
||||
GLuint programA = LinkVsFs(vsA, fsA, GL_TRUE);
|
||||
GLuint programB = LinkVsFs(vsB, fsB, GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(programA, "uColor"), 0);
|
||||
EXPECT_GE(GetUniformLocation(programB, "uColor"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glUniformMatrix{2x3,2x4,3x2,3x4,4x2,4x3}fv and their twelve glProgramUniformMatrix* twins were
|
||||
// validate-only no-ops: they never took the value pointer at all. They upload column-at-a-time at
|
||||
// the std140 16-byte column stride, honouring `transpose`, and glGetUniformfv undoes that padding.
|
||||
TEST_F(ProgramTest, NonSquareMatrixUniformsRoundTripThroughTheGlobalUbo) {
|
||||
const char* vsSource = R"(#version 430 core
|
||||
uniform mat2x3 uM2x3;
|
||||
uniform mat3x2 uM3x2;
|
||||
uniform mat4x3 uM4x3;
|
||||
uniform mat2 uM2;
|
||||
void main() {
|
||||
vec3 a = uM2x3 * vec2(1.0);
|
||||
vec2 b = uM3x2 * vec3(1.0);
|
||||
vec3 c = uM4x3 * vec4(1.0);
|
||||
vec2 d = uM2 * vec2(1.0);
|
||||
gl_Position = vec4(a.xy + b + c.xy + d, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
const char* fsSource = R"(#version 430 core
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = vec4(1.0); }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
UseProgram(program);
|
||||
ASSERT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// matCxR is C columns of R rows, column-major: value[c * R + r].
|
||||
const GLfloat m2x3[6] = {1, 2, 3, 4, 5, 6};
|
||||
const GLfloat m3x2[6] = {1, 2, 3, 4, 5, 6};
|
||||
const GLfloat m4x3[12] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
|
||||
|
||||
const GLint loc2x3 = GetUniformLocation(program, "uM2x3");
|
||||
const GLint loc3x2 = GetUniformLocation(program, "uM3x2");
|
||||
const GLint loc4x3 = GetUniformLocation(program, "uM4x3");
|
||||
ASSERT_GE(loc2x3, 0);
|
||||
ASSERT_GE(loc3x2, 0);
|
||||
ASSERT_GE(loc4x3, 0);
|
||||
|
||||
UniformMatrix2x3fv(loc2x3, 1, GL_FALSE, m2x3);
|
||||
UniformMatrix3x2fv(loc3x2, 1, GL_FALSE, m3x2);
|
||||
UniformMatrix4x3fv(loc4x3, 1, GL_FALSE, m4x3);
|
||||
ASSERT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
GLfloat readBack[12] = {};
|
||||
GetUniformfv(program, loc2x3, readBack);
|
||||
EXPECT_EQ(std::memcmp(readBack, m2x3, sizeof(m2x3)), 0);
|
||||
std::memset(readBack, 0, sizeof(readBack));
|
||||
GetUniformfv(program, loc3x2, readBack);
|
||||
EXPECT_EQ(std::memcmp(readBack, m3x2, sizeof(m3x2)), 0);
|
||||
std::memset(readBack, 0, sizeof(readBack));
|
||||
GetUniformfv(program, loc4x3, readBack);
|
||||
EXPECT_EQ(std::memcmp(readBack, m4x3, sizeof(m4x3)), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// transpose = GL_TRUE means the source is row-major: a mat3x2 (3 columns, 2 rows) is then
|
||||
// given as 2 rows of 3, so {1,2,3, 4,5,6} is the column-major {1,4, 2,5, 3,6}.
|
||||
UniformMatrix3x2fv(loc3x2, 1, GL_TRUE, m3x2);
|
||||
const GLfloat expectedTransposed3x2[6] = {1, 4, 2, 5, 3, 6};
|
||||
std::memset(readBack, 0, sizeof(readBack));
|
||||
GetUniformfv(program, loc3x2, readBack);
|
||||
EXPECT_EQ(std::memcmp(readBack, expectedTransposed3x2, sizeof(expectedTransposed3x2)), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// The glProgramUniform* twin writes the same bytes without the program being current.
|
||||
UseProgram(0);
|
||||
const GLfloat other2x3[6] = {9, 8, 7, 6, 5, 4};
|
||||
ProgramUniformMatrix2x3fv(program, loc2x3, 1, GL_FALSE, other2x3);
|
||||
std::memset(readBack, 0, sizeof(readBack));
|
||||
GetUniformfv(program, loc2x3, readBack);
|
||||
EXPECT_EQ(std::memcmp(readBack, other2x3, sizeof(other2x3)), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// A mat2 is not four contiguous floats in the global UBO: std140 pads each column vector out to
|
||||
// 16 bytes, so column 1 starts at byte 16. Writing it packed put column 1 on top of column 0's
|
||||
// padding, where the shader never reads it.
|
||||
TEST_F(ProgramTest, Mat2UniformUsesTheStd140ColumnStride) {
|
||||
const char* vsSource = R"(#version 430 core
|
||||
uniform mat2 uM2;
|
||||
void main() { gl_Position = vec4(uM2 * vec2(1.0), 0.0, 1.0); }
|
||||
)";
|
||||
const char* fsSource = R"(#version 430 core
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = vec4(1.0); }
|
||||
)";
|
||||
GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
|
||||
GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
|
||||
GLuint program = LinkVsFs(vs, fs, GL_TRUE);
|
||||
UseProgram(program);
|
||||
const GLint loc = GetUniformLocation(program, "uM2");
|
||||
ASSERT_GE(loc, 0);
|
||||
|
||||
const GLfloat m2[4] = {1, 2, 3, 4};
|
||||
UniformMatrix2fv(loc, 1, GL_FALSE, m2);
|
||||
ASSERT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
// The GL-visible value is tightly packed...
|
||||
GLfloat readBack[4] = {};
|
||||
GetUniformfv(program, loc, readBack);
|
||||
EXPECT_EQ(std::memcmp(readBack, m2, sizeof(m2)), 0);
|
||||
|
||||
// ...while the bytes in the UBO put column 1 at offset 16, not 8.
|
||||
const auto& programObject = MG_State::pGLContext->GetProgramObject(program);
|
||||
ASSERT_NE(programObject, nullptr);
|
||||
const auto* ubo = static_cast<const char*>(programObject->MapUBO());
|
||||
ASSERT_NE(ubo, nullptr);
|
||||
const Uint offset = programObject->GetUniformOffset(static_cast<Uint>(loc));
|
||||
ASSERT_NE(offset, MG_State::GLState::ProgramObject::kInvalidUniformOffset);
|
||||
GLfloat column0[2] = {};
|
||||
GLfloat column1[2] = {};
|
||||
std::memcpy(column0, ubo + offset, sizeof(column0));
|
||||
std::memcpy(column1, ubo + offset + 16, sizeof(column1));
|
||||
EXPECT_FLOAT_EQ(column0[0], 1.0f);
|
||||
EXPECT_FLOAT_EQ(column0[1], 2.0f);
|
||||
EXPECT_FLOAT_EQ(column1[0], 3.0f);
|
||||
EXPECT_FLOAT_EQ(column1[1], 4.0f);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// GL 4.6 core 7.1: shaderType is an enum, so an unrecognised one is INVALID_ENUM - it used to be
|
||||
// reported as INVALID_VALUE. glCreateShaderProgramv adds a count < 0 gate ahead of everything.
|
||||
TEST_F(ProgramTest, CreateShaderAndCreateShaderProgramvReportTheRightErrorClasses) {
|
||||
while (GetError() != GL_NO_ERROR) {
|
||||
}
|
||||
|
||||
EXPECT_EQ(CreateShader(GL_FLOAT), 0u);
|
||||
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR) << "the call recorded more than one error";
|
||||
|
||||
const char* source = "#version 330 core\nvoid main() { gl_Position = vec4(1.0); }\n";
|
||||
EXPECT_EQ(CreateShaderProgramv(GL_FLOAT, 1, &source), 0u);
|
||||
EXPECT_EQ(GetError(), GL_INVALID_ENUM);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR) << "the call recorded more than one error";
|
||||
|
||||
EXPECT_EQ(CreateShaderProgramv(GL_VERTEX_SHADER, -1, &source), 0u);
|
||||
EXPECT_EQ(GetError(), GL_INVALID_VALUE);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR) << "the call recorded more than one error";
|
||||
|
||||
// A well-formed call still works.
|
||||
const GLuint program = CreateShaderProgramv(GL_VERTEX_SHADER, 1, &source);
|
||||
EXPECT_NE(program, 0u);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
#include <MG_Util/ShaderTranspiler/SpirvPasses/RenameSamplerFunctionParameterPass.h>
|
||||
#include <MG_Util/ShaderTranspiler/Types.h>
|
||||
#include <MG_Util/ShaderTranspiler/glslang/UniformTraverser.h>
|
||||
#include <MG_State/GLState/ProgramState/ShaderPreprocessCache.h>
|
||||
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
|
||||
#include <spirv-tools/libspirv.hpp>
|
||||
#include <spirv-tools/optimizer.hpp>
|
||||
|
||||
@@ -615,25 +617,6 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// The builtin-shadowing rename only fires when the shader really defines its own round/tanh/etc.
|
||||
// Deciding that from a commented-out definition renames every genuine call to the builtin to a
|
||||
// mg_ name that nothing defines, which fails to link.
|
||||
TEST_F(ProgramUtilTest, PreprocessIgnoresCommentedOutBuiltinShadowingDefinition) {
|
||||
using namespace MG_Util::ShaderTranspiler;
|
||||
|
||||
String source = R"(#version 330 core
|
||||
// float round(float x) { return floor(x + 0.5); }
|
||||
out vec4 fragColor;
|
||||
void main() {
|
||||
fragColor = vec4(round(1.25));
|
||||
}
|
||||
)";
|
||||
PreprocessShaderSource(ShaderStage::Fragment, source);
|
||||
|
||||
EXPECT_NE(source.find("round(1.25)"), String::npos) << "call was renamed from a comment:\n" << source;
|
||||
EXPECT_EQ(source.find("mg_round"), String::npos);
|
||||
}
|
||||
|
||||
// A block-commented extension directive must not be treated as a real one - the int64 filter turns
|
||||
// unsupported directives into #error, so reading one out of a comment manufactures a compile
|
||||
// failure for a shader that never asked for the extension.
|
||||
@@ -844,36 +827,6 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(ProgramUtilTest, PreprocessFragmentShaderRenamesMin3Max3Helpers) {
|
||||
using namespace MG_Util::ShaderTranspiler;
|
||||
|
||||
String source = R"(#version 460 core
|
||||
out vec4 fragColor;
|
||||
|
||||
float min3(float a, float b, float c) { return min(min(a, b), c); }
|
||||
float max3(float a, float b, float c) { return max(max(a, b), c); }
|
||||
|
||||
void main() {
|
||||
float dark = min3(0.1, 0.2, 0.3);
|
||||
float bright = max3(max3(0.1, 0.2, 0.3), 0.4, 0.5);
|
||||
fragColor = vec4(dark, bright, 0.0, 1.0);
|
||||
})";
|
||||
|
||||
PreprocessShaderSource(ShaderStage::Fragment, source);
|
||||
|
||||
EXPECT_NE(source.find("float mg_min3("), String::npos);
|
||||
EXPECT_NE(source.find("float mg_max3("), String::npos);
|
||||
EXPECT_NE(source.find("mg_min3(0.1, 0.2, 0.3)"), String::npos);
|
||||
EXPECT_NE(source.find("mg_max3(mg_max3(0.1, 0.2, 0.3), 0.4, 0.5)"), String::npos);
|
||||
EXPECT_EQ(source.find("float min3("), String::npos);
|
||||
EXPECT_EQ(source.find("float max3("), String::npos);
|
||||
|
||||
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
|
||||
auto res = ShaderCompiler::CompileShader(attrib);
|
||||
if (!res) {
|
||||
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
|
||||
}
|
||||
}
|
||||
|
||||
const char* vs = R"(#version 150
|
||||
|
||||
@@ -2248,3 +2201,451 @@ TEST_F(ProgramUtilTest, RewriteLinearSubgroupPrefixScanRejectsPartialOrUnsafeTem
|
||||
"float other = shuffleNV(1.0f, 0u, 32u);\n ");
|
||||
expectUnchanged(std::move(nvShuffleCall));
|
||||
}
|
||||
|
||||
// The LEXICAL half must fire at the source level (before the parse) for the
|
||||
// preempt-list names - the end-to-end ESSL tests cannot tell which half did the
|
||||
// rename, and for these names the parse would fail without the source rewrite.
|
||||
TEST_F(ProgramUtilTest, PreprocessRenamesLexicalPreemptShadowingInSource) {
|
||||
using namespace MG_Util::ShaderTranspiler;
|
||||
|
||||
String source = R"(#version 460 core
|
||||
out vec4 fragColor;
|
||||
|
||||
float min3(float a, float b, float c) { return min(min(a, b), c); }
|
||||
|
||||
void main() {
|
||||
fragColor = vec4(min3(0.1, 0.2, 0.3));
|
||||
}
|
||||
)";
|
||||
PreprocessShaderSource(ShaderStage::Fragment, source);
|
||||
|
||||
EXPECT_NE(source.find("float mg_min3("), String::npos) << source;
|
||||
EXPECT_NE(source.find("mg_min3(0.1, 0.2, 0.3)"), String::npos) << source;
|
||||
EXPECT_EQ(source.find("float min3("), String::npos) << source;
|
||||
}
|
||||
|
||||
// GLSL has no multi-line string or character literal, so a lone apostrophe never opens one - it is
|
||||
// an English contraction, in a comment or in a diagnostic directive. MaskCommentsAndQuotedText used
|
||||
// to disagree: it entered its quoted-text region on the apostrophe and, having no end-of-line rule,
|
||||
// stayed there to the end of the file, blanking everything after it for every consumer of the mask
|
||||
// (the tokenizer, the #version inspection, the explicit-location and opaque-binding extractors).
|
||||
//
|
||||
// Apostrophes inside comments were never affected - the comment region claims them first - but that
|
||||
// is exactly the property the fix must not break, so pin it.
|
||||
TEST_F(ProgramUtilTest, PreprocessKeepsApostrophesInsideCommentsHarmless) {
|
||||
using namespace MG_Util::ShaderTranspiler;
|
||||
|
||||
String source = R"(#version 460 core
|
||||
// don't do this: the sampler isn't bound before the first frame
|
||||
/* and here's a block comment whose apostrophes shouldn't matter either */
|
||||
uniform sampler2D tex;
|
||||
in vec2 uv;
|
||||
out vec4 fragColor;
|
||||
|
||||
void main() {
|
||||
fragColor = texture(tex, uv);
|
||||
}
|
||||
)";
|
||||
PreprocessShaderSource(ShaderStage::Fragment, source);
|
||||
|
||||
EXPECT_NE(source.find("void main()"), String::npos) << "shader body was blanked:\n" << source;
|
||||
EXPECT_NE(source.find("fragColor = texture(tex, uv);"), String::npos) << source;
|
||||
|
||||
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
|
||||
auto res = ShaderCompiler::CompileShader(attrib);
|
||||
if (!res) {
|
||||
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
|
||||
}
|
||||
}
|
||||
|
||||
// The case the old masker actually broke: an apostrophe in real (non-comment) text. Everything after
|
||||
// it looked like string interior, so ExtractExplicitUniformLocations tokenized a blank source and
|
||||
// handed the GL location assigner an empty map - the uniform silently lost its explicit location.
|
||||
TEST_F(ProgramUtilTest, PreprocessApostropheInDirectiveKeepsLaterCodeVisibleToExtractors) {
|
||||
using namespace MG_Util::ShaderTranspiler;
|
||||
|
||||
String source = R"(#version 460 core
|
||||
#pragma MG_NOTE(this pack can't run without explicit locations)
|
||||
layout(location = 7) uniform vec4 tint;
|
||||
in vec2 uv;
|
||||
out vec4 fragColor;
|
||||
|
||||
void main() {
|
||||
fragColor = tint * uv.x;
|
||||
}
|
||||
)";
|
||||
PreprocessShaderSource(ShaderStage::Fragment, source);
|
||||
|
||||
const UnorderedMap<String, Int> locations = ExtractExplicitUniformLocations(source);
|
||||
ASSERT_EQ(locations.count("tint"), 1u) << "extractor went blind past the apostrophe:\n" << source;
|
||||
EXPECT_EQ(locations.at("tint"), 7);
|
||||
|
||||
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
|
||||
auto res = ShaderCompiler::CompileShader(attrib);
|
||||
if (!res) {
|
||||
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
|
||||
}
|
||||
}
|
||||
|
||||
// PreprocessShaderSource used to rediscover "where does the #version directive end?" once per
|
||||
// injection - up to five whole-source masks and line scans per compile for one offset. It now takes
|
||||
// the anchor once, from the pass that creates it, and tracks it.
|
||||
//
|
||||
// These three sources drive every consumer of that anchor: NormalizeLineDirectives (both the
|
||||
// keep branch and the drop-ahead-of-#version branch), ModernizeLegacyGLSL's gl_FragColor
|
||||
// injection, and InjectDepthRangeBuiltinShim's. The expected texts are the byte-exact output of
|
||||
// the pre-memo implementation, captured from it - the change is pure memoization and is allowed to
|
||||
// move no byte at all.
|
||||
//
|
||||
// Case B and case C are the two ways the anchor moves out from under the memo, and are why it is
|
||||
// tracked rather than simply cached: B deletes a #line that precedes the version directive, and C
|
||||
// has ModernizeLegacyGLSL's raw ReplaceIdentifier rewrite "varying"/"texture2D" inside a comment
|
||||
// banner ahead of it, pulling the anchor six bytes left. An offset cached blindly would put the
|
||||
// injected declaration six bytes inside the version line.
|
||||
TEST_F(ProgramUtilTest, PreprocessLegacyFragmentShaderOutputIsByteStableAcrossTheVersionAnchor) {
|
||||
using namespace MG_Util::ShaderTranspiler;
|
||||
|
||||
const char* kLegacyBody = R"(#line 30
|
||||
varying vec2 uv;
|
||||
uniform sampler2D tex;
|
||||
|
||||
void main() {
|
||||
float d = gl_DepthRange.diff;
|
||||
gl_FragColor = texture2D(tex, uv) * d;
|
||||
}
|
||||
)";
|
||||
const char* kExpectedBody =
|
||||
"#version 330 core /*mobilegl-normalized-legacy*/\n"
|
||||
"struct mg_DepthRangeParameters { float near; float far; float diff; };\n"
|
||||
"const mg_DepthRangeParameters mg_DepthRange = mg_DepthRangeParameters(0.0, 1.0, 1.0);\n"
|
||||
"#define gl_DepthRange mg_DepthRange\n"
|
||||
"out vec4 mg_FragColor;\n"
|
||||
"#line 30\n"
|
||||
"in vec2 uv;\n"
|
||||
"uniform sampler2D tex;\n"
|
||||
"\n"
|
||||
"void main() {\n"
|
||||
" float d = gl_DepthRange.diff;\n"
|
||||
" mg_FragColor = texture(tex, uv) * d;\n"
|
||||
"}\n";
|
||||
|
||||
{
|
||||
SCOPED_TRACE("A: version directive at offset 0");
|
||||
String source = String("#version 120\n") + kLegacyBody;
|
||||
PreprocessShaderSource(ShaderStage::Fragment, source);
|
||||
EXPECT_EQ(source, String(kExpectedBody));
|
||||
|
||||
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
|
||||
auto res = ShaderCompiler::CompileShader(attrib);
|
||||
if (!res) {
|
||||
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
SCOPED_TRACE("B: a #line ahead of the version directive is dropped, shortening the prefix");
|
||||
String source = String("// pack preamble\n#line 1 \"world.fsh\"\n#version 120\n") + kLegacyBody;
|
||||
PreprocessShaderSource(ShaderStage::Fragment, source);
|
||||
// The dropped directive leaves its newline behind, so line numbering is untouched.
|
||||
EXPECT_EQ(source, String("// pack preamble\n\n") + kExpectedBody);
|
||||
}
|
||||
|
||||
{
|
||||
SCOPED_TRACE("C: a comment banner ahead of the version directive is itself rewritten");
|
||||
String source = R"(/* legacy varying / texture2D helpers */
|
||||
#version 120
|
||||
varying vec2 uv;
|
||||
uniform sampler2D tex;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(tex, uv);
|
||||
}
|
||||
)";
|
||||
PreprocessShaderSource(ShaderStage::Fragment, source);
|
||||
EXPECT_EQ(source, String("/* legacy in / texture helpers */\n"
|
||||
"#version 330 core /*mobilegl-normalized-legacy*/\n"
|
||||
"out vec4 mg_FragColor;\n"
|
||||
"in vec2 uv;\n"
|
||||
"uniform sampler2D tex;\n"
|
||||
"void main() {\n"
|
||||
" mg_FragColor = texture(tex, uv);\n"
|
||||
"}\n"));
|
||||
|
||||
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
|
||||
auto res = ShaderCompiler::CompileShader(attrib);
|
||||
if (!res) {
|
||||
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1: CompileEnv - the compile pipeline's snapshot of everything outside
|
||||
// (stage, source). These pin the two properties the rest of P1 rides on: the
|
||||
// compute limits really are carried in the snapshot (an off-thread
|
||||
// GL_MAX_COMPUTE_WORK_GROUP_SIZE query would silently return 0 and reject a
|
||||
// legal local_size), and the fingerprint really does move when they do.
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_F(ProgramUtilTest, CompileEnvCarriesComputeLimitsAndFrontendMinima) {
|
||||
using MobileGL::MG_Util::ShaderTranspiler::CaptureCompileEnv;
|
||||
|
||||
const auto env = CaptureCompileEnv();
|
||||
ASSERT_NE(env, nullptr);
|
||||
// With no backend the snapshot is the frontend minimum, never zero - the value an
|
||||
// off-thread GetIntegeri_v would have left behind.
|
||||
EXPECT_GE(env->maxComputeWorkGroupSize[0], 1024u);
|
||||
EXPECT_GE(env->maxComputeWorkGroupSize[1], 1024u);
|
||||
EXPECT_GE(env->maxComputeWorkGroupSize[2], 64u);
|
||||
EXPECT_GE(env->maxComputeWorkGroupInvocations, 1024u);
|
||||
EXPECT_NE(env->fingerprint, 0u);
|
||||
}
|
||||
|
||||
TEST_F(ProgramUtilTest, CompileEnvFingerprintTracksEveryInput) {
|
||||
using MobileGL::MG_Util::ShaderTranspiler::CompileEnv;
|
||||
using MobileGL::MG_Util::ShaderTranspiler::ComputeCompileEnvFingerprint;
|
||||
|
||||
CompileEnv base;
|
||||
const Uint64 baseline = ComputeCompileEnvFingerprint(base);
|
||||
EXPECT_EQ(ComputeCompileEnvFingerprint(base), baseline) << "fingerprint must be deterministic";
|
||||
|
||||
// A device that allows a bigger workgroup than the frontend minimum is a DIFFERENT
|
||||
// compile environment: a memo taken under the smaller limit must not be reusable.
|
||||
CompileEnv biggerZ = base;
|
||||
biggerZ.maxComputeWorkGroupSize[2] = 256;
|
||||
EXPECT_NE(ComputeCompileEnvFingerprint(biggerZ), baseline);
|
||||
|
||||
CompileEnv moreInvocations = base;
|
||||
moreInvocations.maxComputeWorkGroupInvocations = 2048;
|
||||
EXPECT_NE(ComputeCompileEnvFingerprint(moreInvocations), baseline);
|
||||
|
||||
CompileEnv otherBackend = base;
|
||||
otherBackend.backend = MobileGL::BackendType::DirectVulkan;
|
||||
EXPECT_NE(ComputeCompileEnvFingerprint(otherBackend), baseline);
|
||||
|
||||
CompileEnv otherLimits = base;
|
||||
otherLimits.params.MaxVertexAttribs = 31;
|
||||
EXPECT_NE(ComputeCompileEnvFingerprint(otherLimits), baseline);
|
||||
|
||||
CompileEnv otherExtensions = base;
|
||||
otherExtensions.advertisedExtensions.push_back(MobileGL::E_GL_ARB_gpu_shader_int64);
|
||||
EXPECT_NE(ComputeCompileEnvFingerprint(otherExtensions), baseline);
|
||||
|
||||
CompileEnv otherQuirk = base;
|
||||
otherQuirk.subgroupPrefixScanQuirk = MobileGL::MG_Config::QuirkOverride::ForceOn;
|
||||
EXPECT_NE(ComputeCompileEnvFingerprint(otherQuirk), baseline);
|
||||
}
|
||||
|
||||
// The no-backend fallback must stay exactly what the pipeline used to do inline:
|
||||
// everything counts as advertised, because there is nothing to gate against.
|
||||
TEST_F(ProgramUtilTest, CompileEnvWithoutBackendAdvertisesEverything) {
|
||||
using MobileGL::MG_Util::ShaderTranspiler::CompileEnv;
|
||||
|
||||
CompileEnv env;
|
||||
EXPECT_FALSE(env.HasBackend());
|
||||
EXPECT_TRUE(env.IsExtensionAdvertised(MobileGL::E_GL_ARB_gpu_shader_int64));
|
||||
|
||||
env.backend = MobileGL::BackendType::DirectGLES;
|
||||
EXPECT_TRUE(env.HasBackend());
|
||||
EXPECT_FALSE(env.IsExtensionAdvertised(MobileGL::E_GL_ARB_gpu_shader_int64));
|
||||
env.advertisedExtensions.push_back(MobileGL::E_GL_ARB_gpu_shader_int64);
|
||||
EXPECT_TRUE(env.IsExtensionAdvertised(MobileGL::E_GL_ARB_gpu_shader_int64));
|
||||
}
|
||||
|
||||
// P0b layer 2: ShaderPreprocessCache, tested directly. The GL-level behaviour it
|
||||
// enables is covered end to end in ProgramTest; these pin the container itself,
|
||||
// where the interesting cases (hash collisions, both eviction budgets) are hard
|
||||
// to provoke through glCompileShader.
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace {
|
||||
using MobileGL::MG_State::GLState::ShaderPreprocessCache;
|
||||
using MobileGL::MG_State::GLState::ShaderPreprocessOutcome;
|
||||
using MobileGL::MG_State::GLState::ShaderPreprocessResult;
|
||||
using MobileGL::MG_State::GLState::ShaderPreprocessResultPtr;
|
||||
|
||||
// The env fingerprint every test below keys against, unless it is specifically
|
||||
// exercising the fingerprint itself.
|
||||
constexpr MobileGL::Uint64 kEnvA = 0x1111'2222'3333'4444ull;
|
||||
constexpr MobileGL::Uint64 kEnvB = 0x5555'6666'7777'8888ull;
|
||||
|
||||
ShaderPreprocessResultPtr MakeResult(const String& preprocessed) {
|
||||
auto result = MakeShared<ShaderPreprocessResult>();
|
||||
result->outcome = ShaderPreprocessOutcome::Preprocessed;
|
||||
result->preprocessedSource = preprocessed;
|
||||
result->explicitUniformLocations["uMarker"] = 7;
|
||||
result->explicitOpaqueBindings["sMarker"] = 3;
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_F(ProgramUtilTest, ShaderPreprocessCacheRoundTripsAndSeparatesStages) {
|
||||
ShaderPreprocessCache cache;
|
||||
const String source = "// a shader\nvoid main() {}\n";
|
||||
const Uint64 hash = ShaderPreprocessCache::HashSource(source);
|
||||
|
||||
EXPECT_EQ(cache.Find(ShaderStage::Vertex, hash, source, kEnvA), nullptr);
|
||||
|
||||
cache.Insert(ShaderStage::Vertex, hash, source, kEnvA, MakeResult("vertex-preprocessed"));
|
||||
const ShaderPreprocessResultPtr hit = cache.Find(ShaderStage::Vertex, hash, source, kEnvA);
|
||||
ASSERT_NE(hit, nullptr);
|
||||
EXPECT_TRUE(hit->Preprocessed());
|
||||
EXPECT_EQ(hit->preprocessedSource, "vertex-preprocessed");
|
||||
const auto uniformIt = hit->explicitUniformLocations.find("uMarker");
|
||||
ASSERT_NE(uniformIt, hit->explicitUniformLocations.end());
|
||||
EXPECT_EQ(uniformIt->second, 7);
|
||||
const auto bindingIt = hit->explicitOpaqueBindings.find("sMarker");
|
||||
ASSERT_NE(bindingIt, hit->explicitOpaqueBindings.end());
|
||||
EXPECT_EQ(bindingIt->second, 3u);
|
||||
|
||||
// Byte-identical source, different stage: a different key, so still a miss. Two
|
||||
// stages sharing one entry would hand a fragment shader a vertex preprocess.
|
||||
EXPECT_EQ(cache.Find(ShaderStage::Fragment, hash, source, kEnvA), nullptr);
|
||||
cache.Insert(ShaderStage::Fragment, hash, source, kEnvA, MakeResult("fragment-preprocessed"));
|
||||
const ShaderPreprocessResultPtr fragmentHit = cache.Find(ShaderStage::Fragment, hash, source, kEnvA);
|
||||
ASSERT_NE(fragmentHit, nullptr);
|
||||
EXPECT_EQ(fragmentHit->preprocessedSource, "fragment-preprocessed");
|
||||
EXPECT_EQ(cache.Find(ShaderStage::Vertex, hash, source, kEnvA)->preprocessedSource, "vertex-preprocessed");
|
||||
EXPECT_EQ(cache.GetEntryCount(), 2u);
|
||||
}
|
||||
|
||||
TEST_F(ProgramUtilTest, ShaderPreprocessCacheMemoizesRejectionVerdictsDistinctly) {
|
||||
ShaderPreprocessCache cache;
|
||||
const String reservedSource = "int packed;\n";
|
||||
const String localSizeSource = "layout(local_size_x = 99999) in;\n";
|
||||
|
||||
auto reserved = MakeShared<ShaderPreprocessResult>();
|
||||
reserved->outcome = ShaderPreprocessOutcome::ReservedIdentifierRejected;
|
||||
reserved->infoLog = "reserved identifier";
|
||||
auto localSize = MakeShared<ShaderPreprocessResult>();
|
||||
localSize->outcome = ShaderPreprocessOutcome::ComputeLocalSizeRejected;
|
||||
localSize->infoLog = "local_size too big";
|
||||
|
||||
cache.Insert(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(reservedSource), reservedSource, kEnvA,
|
||||
Move(reserved));
|
||||
cache.Insert(ShaderStage::Compute, ShaderPreprocessCache::HashSource(localSizeSource), localSizeSource, kEnvA,
|
||||
Move(localSize));
|
||||
|
||||
const ShaderPreprocessResultPtr reservedHit =
|
||||
cache.Find(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(reservedSource), reservedSource, kEnvA);
|
||||
ASSERT_NE(reservedHit, nullptr);
|
||||
EXPECT_FALSE(reservedHit->Preprocessed());
|
||||
EXPECT_EQ(reservedHit->outcome, ShaderPreprocessOutcome::ReservedIdentifierRejected);
|
||||
EXPECT_EQ(reservedHit->infoLog, "reserved identifier");
|
||||
|
||||
const ShaderPreprocessResultPtr localSizeHit =
|
||||
cache.Find(ShaderStage::Compute, ShaderPreprocessCache::HashSource(localSizeSource), localSizeSource, kEnvA);
|
||||
ASSERT_NE(localSizeHit, nullptr);
|
||||
EXPECT_EQ(localSizeHit->outcome, ShaderPreprocessOutcome::ComputeLocalSizeRejected);
|
||||
EXPECT_EQ(localSizeHit->infoLog, "local_size too big");
|
||||
}
|
||||
|
||||
// Correctness must not ride on a 64-bit hash. Feed two different sources of the same
|
||||
// length under a forged, identical hash: the entry stores the full original text, so
|
||||
// the impostor lookup must miss instead of returning the wrong preprocess.
|
||||
TEST_F(ProgramUtilTest, ShaderPreprocessCacheRejectsForgedHashCollision) {
|
||||
ShaderPreprocessCache cache;
|
||||
const String real = "void main() { int a = 1; }\n";
|
||||
const String impostor = "void main() { int a = 2; }\n";
|
||||
ASSERT_EQ(real.length(), impostor.length());
|
||||
ASSERT_NE(real, impostor);
|
||||
const Uint64 forgedHash = 0xdeadbeefcafef00dull;
|
||||
|
||||
cache.Insert(ShaderStage::Vertex, forgedHash, real, kEnvA, MakeResult("real-preprocessed"));
|
||||
|
||||
ASSERT_NE(cache.Find(ShaderStage::Vertex, forgedHash, real, kEnvA), nullptr);
|
||||
EXPECT_EQ(cache.Find(ShaderStage::Vertex, forgedHash, impostor, kEnvA), nullptr);
|
||||
|
||||
// The colliding newcomer wins the slot rather than being silently dropped, so it
|
||||
// is the previous occupant that degrades to a miss - never a wrong hit.
|
||||
cache.Insert(ShaderStage::Vertex, forgedHash, impostor, kEnvA, MakeResult("impostor-preprocessed"));
|
||||
const ShaderPreprocessResultPtr impostorHit = cache.Find(ShaderStage::Vertex, forgedHash, impostor, kEnvA);
|
||||
ASSERT_NE(impostorHit, nullptr);
|
||||
EXPECT_EQ(impostorHit->preprocessedSource, "impostor-preprocessed");
|
||||
EXPECT_EQ(cache.Find(ShaderStage::Vertex, forgedHash, real, kEnvA), nullptr);
|
||||
EXPECT_EQ(cache.GetEntryCount(), 1u);
|
||||
}
|
||||
|
||||
// P1: the compile environment joins the key. A memo computed against one backend's
|
||||
// GL_MAX_COMPUTE_WORK_GROUP_* limits must never be handed back after the environment
|
||||
// changed (backend swap), which is exactly what CompileEnv::fingerprint keys on.
|
||||
TEST_F(ProgramUtilTest, ShaderPreprocessCacheMissesOnChangedEnvFingerprint) {
|
||||
ShaderPreprocessCache cache;
|
||||
const String source = "layout(local_size_x = 512) in;\nvoid main() {}\n";
|
||||
const Uint64 hash = ShaderPreprocessCache::HashSource(source);
|
||||
|
||||
cache.Insert(ShaderStage::Compute, hash, source, kEnvA, MakeResult("env-a-preprocessed"));
|
||||
ASSERT_NE(cache.Find(ShaderStage::Compute, hash, source, kEnvA), nullptr);
|
||||
EXPECT_EQ(cache.Find(ShaderStage::Compute, hash, source, kEnvB), nullptr);
|
||||
|
||||
// Both environments can coexist; neither can see the other's verdict.
|
||||
cache.Insert(ShaderStage::Compute, hash, source, kEnvB, MakeResult("env-b-preprocessed"));
|
||||
EXPECT_EQ(cache.Find(ShaderStage::Compute, hash, source, kEnvA)->preprocessedSource, "env-a-preprocessed");
|
||||
EXPECT_EQ(cache.Find(ShaderStage::Compute, hash, source, kEnvB)->preprocessedSource, "env-b-preprocessed");
|
||||
EXPECT_EQ(cache.GetEntryCount(), 2u);
|
||||
}
|
||||
|
||||
// A hit hands out shared ownership, so the payload survives the eviction of its entry.
|
||||
// Under the old raw-pointer API this read was a use-after-free the moment two compiles
|
||||
// ran concurrently.
|
||||
TEST_F(ProgramUtilTest, ShaderPreprocessCacheHitOutlivesEviction) {
|
||||
ShaderPreprocessCache cache;
|
||||
const String source = "void main() { int keep = 1; }\n";
|
||||
const Uint64 hash = ShaderPreprocessCache::HashSource(source);
|
||||
cache.Insert(ShaderStage::Vertex, hash, source, kEnvA, MakeResult("survivor"));
|
||||
|
||||
const ShaderPreprocessResultPtr held = cache.Find(ShaderStage::Vertex, hash, source, kEnvA);
|
||||
ASSERT_NE(held, nullptr);
|
||||
|
||||
cache.Clear();
|
||||
EXPECT_EQ(cache.Find(ShaderStage::Vertex, hash, source, kEnvA), nullptr);
|
||||
EXPECT_EQ(held->preprocessedSource, "survivor");
|
||||
}
|
||||
|
||||
TEST_F(ProgramUtilTest, ShaderPreprocessCacheEvictsFifoOnEntryCap) {
|
||||
ShaderPreprocessCache cache;
|
||||
Vector<String> sources;
|
||||
const SizeT overflow = ShaderPreprocessCache::kMaxEntries + 8;
|
||||
for (SizeT i = 0; i < overflow; ++i) {
|
||||
sources.push_back("void main() { int a = " + ToString(i) + "; }\n");
|
||||
cache.Insert(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(sources.back()), sources.back(), kEnvA,
|
||||
MakeResult("pp" + ToString(i)));
|
||||
EXPECT_LE(cache.GetEntryCount(), ShaderPreprocessCache::kMaxEntries);
|
||||
}
|
||||
EXPECT_EQ(cache.GetEntryCount(), ShaderPreprocessCache::kMaxEntries);
|
||||
|
||||
// FIFO: the first `overflow - kMaxEntries` insertions are gone, the rest resident.
|
||||
for (SizeT i = 0; i < overflow; ++i) {
|
||||
const ShaderPreprocessResultPtr hit =
|
||||
cache.Find(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(sources[i]), sources[i], kEnvA);
|
||||
if (i < overflow - ShaderPreprocessCache::kMaxEntries) {
|
||||
EXPECT_EQ(hit, nullptr) << "entry " << i << " should have been evicted";
|
||||
} else {
|
||||
ASSERT_NE(hit, nullptr) << "entry " << i << " should still be resident";
|
||||
EXPECT_EQ(hit->preprocessedSource, "pp" + ToString(i));
|
||||
}
|
||||
}
|
||||
|
||||
cache.Clear();
|
||||
EXPECT_EQ(cache.GetEntryCount(), 0u);
|
||||
EXPECT_EQ(cache.GetStoredSourceBytes(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(ProgramUtilTest, ShaderPreprocessCacheHonorsByteBudget) {
|
||||
ShaderPreprocessCache cache;
|
||||
// Well under the entry cap, well over the byte budget: the byte budget must be the
|
||||
// one that binds, and the accounting must come back down as entries are evicted.
|
||||
const SizeT chunk = ShaderPreprocessCache::kMaxStoredSourceBytes / 8;
|
||||
for (SizeT i = 0; i < 24; ++i) {
|
||||
String source(chunk, static_cast<char>('a' + (i % 26)));
|
||||
cache.Insert(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(source), source, kEnvA, MakeResult(""));
|
||||
EXPECT_LE(cache.GetStoredSourceBytes(), ShaderPreprocessCache::kMaxStoredSourceBytes);
|
||||
EXPECT_LT(cache.GetEntryCount(), ShaderPreprocessCache::kMaxEntries);
|
||||
}
|
||||
|
||||
// A single source larger than the whole budget is refused outright: caching it
|
||||
// would evict every other entry and then immediately itself.
|
||||
const SizeT before = cache.GetEntryCount();
|
||||
const String oversized(ShaderPreprocessCache::kMaxStoredSourceBytes + 1, 'z');
|
||||
cache.Insert(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(oversized), oversized, kEnvA, MakeResult(""));
|
||||
EXPECT_EQ(cache.GetEntryCount(), before);
|
||||
EXPECT_EQ(cache.Find(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(oversized), oversized, kEnvA), nullptr);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
// MobileGL - MobileGL/MG_Test/Program/ShaderCompileAdoptionTest.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
// P1 stage 6: two shader objects handed byte-identical source share ONE compile job.
|
||||
//
|
||||
// The property under test is a conjunction, and every case here attacks one half of it:
|
||||
// * the sharing itself - one job, one node, both GL names reporting the same answer, and
|
||||
// two programs linking that one node to byte-identical SPIR-V;
|
||||
// * that sharing did not make a cancel dangerous. Before this stage a node had exactly one
|
||||
// shader object, so "this object stopped caring" and "nothing can observe this result"
|
||||
// were the same statement and CancelCompile() cancelled unconditionally. They are not the
|
||||
// same statement any more, and the four mutation paths that used to reach that cancel -
|
||||
// re-source, delete, the orphan-name sweep, the destructor - are each covered below with
|
||||
// a second object still holding the node.
|
||||
//
|
||||
// Like the other async suites, every case flips MG_Config::Features.AsyncShaderCompile itself
|
||||
// and drives the real GL entry points, so the file behaves identically whether or not the
|
||||
// suite was launched with MOBILEGL_ASYNC_SHADER_COMPILE=1.
|
||||
//
|
||||
// Adoption is decided ON THE GL THREAD, before anything is posted, so the counter assertions
|
||||
// here are deterministic rather than timing-dependent: whether the first object's compile has
|
||||
// already finished changes nothing about whether the second one adopts it.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "Config.h"
|
||||
#include "Includes.h"
|
||||
#include "Init.h"
|
||||
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
|
||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||
#include "MG_State/GLState/Core.h"
|
||||
#include "MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h"
|
||||
#include "MG_State/GLState/ProgramState/ShaderCompileTask.h"
|
||||
#include "MG_State/GLState/ProgramState/ShaderPreprocessCache.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
#include "MG_Util/ShaderTranspiler/CompileEnv.h"
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Impl::GLImpl;
|
||||
using MobileGL::MG_State::GLState::ShaderCompileAdoptionMap;
|
||||
using MobileGL::MG_State::GLState::ShaderCompileTask;
|
||||
using MobileGL::MG_State::GLState::ShaderObject;
|
||||
using MobileGL::MG_State::GLState::ShaderPreprocessCache;
|
||||
|
||||
namespace {
|
||||
class AsyncModeScope {
|
||||
public:
|
||||
explicit AsyncModeScope(const Bool async) : m_saved(MG_Config::Features.AsyncShaderCompile) {
|
||||
MG_Config::Features.AsyncShaderCompile =
|
||||
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
|
||||
}
|
||||
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
|
||||
AsyncModeScope(const AsyncModeScope&) = delete;
|
||||
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
|
||||
|
||||
private:
|
||||
const MG_Config::QuirkOverride m_saved;
|
||||
};
|
||||
|
||||
// The suspension latch and the concurrency budget are PROCESS-wide, so a case that
|
||||
// touches either has to put both back or it poisons every case after it in this binary.
|
||||
class CompilerThreadScope {
|
||||
public:
|
||||
CompilerThreadScope() = default;
|
||||
~CompilerThreadScope() {
|
||||
MG_Util::Async::SetAsyncShaderCompileSuspended(false);
|
||||
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(
|
||||
MG_Util::Async::ShaderCompilePool::Get().GetThreadCount());
|
||||
}
|
||||
CompilerThreadScope(const CompilerThreadScope&) = delete;
|
||||
CompilerThreadScope& operator=(const CompilerThreadScope&) = delete;
|
||||
};
|
||||
|
||||
const char* kVs = R"(#version 460
|
||||
layout(location = 0) in vec3 aPos;
|
||||
uniform mat4 uModel;
|
||||
uniform vec4 uColor;
|
||||
out vec4 vColor;
|
||||
void main() {
|
||||
vColor = uColor;
|
||||
gl_Position = uModel * vec4(aPos, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
// Fails inside glslang rather than in the lexical pre-checks, so it exercises the same
|
||||
// ParseFailed path a real broken shaderpack source takes.
|
||||
const char* kBrokenFs = R"(#version 460
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
void main() { fragColor = thisIdentifierWasNeverDeclared; }
|
||||
)";
|
||||
|
||||
// Big enough that a compile is not instantaneous, so a duplicate really would cost
|
||||
// something. Templated on an index so every instance is a distinct source.
|
||||
String MakeBulkySource(const int index) {
|
||||
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
|
||||
source += "uniform float uSeed" + std::to_string(index) + ";\n";
|
||||
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
|
||||
for (int i = 0; i < 220; ++i) {
|
||||
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
|
||||
}
|
||||
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
// Heavy enough that a spinning GL thread can reliably observe the compile Running on a
|
||||
// single-worker pool, for RunningCancelRequestedNodeIsNotAdopted below - MakeBulkySource
|
||||
// is tuned for "not instantaneous", this one is tuned for "actually spin-observable".
|
||||
String MakeVeryHeavySource(const int index) {
|
||||
String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n";
|
||||
source += "uniform float uSeed" + std::to_string(index) + ";\n";
|
||||
source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n";
|
||||
for (int i = 0; i < 4000; ++i) {
|
||||
source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n";
|
||||
}
|
||||
source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
Uint64 AdoptionCount() {
|
||||
return MG_State::pGLContext->GetShaderCompileAdoptionMap().GetAdoptionCount();
|
||||
}
|
||||
|
||||
// A copy of the slot, never the reference: creating another shader can reallocate the
|
||||
// context's object table.
|
||||
SharedPtr<ShaderObject> Object(const GLuint shader) {
|
||||
return MG_State::pGLContext->GetShaderObject(shader);
|
||||
}
|
||||
|
||||
// The node identity, WITHOUT joining - this is what "they share one job" means, and
|
||||
// asking must not settle anything.
|
||||
const ShaderCompileTask* NodeOf(const GLuint shader) {
|
||||
const SharedPtr<ShaderObject> object = Object(shader);
|
||||
return object ? object->CompiledNodeForLink().get() : nullptr;
|
||||
}
|
||||
|
||||
GLuint MakeShader(const GLenum type, const char* source) {
|
||||
const GLuint shader = CreateShader(type);
|
||||
ShaderSource(shader, 1, &source, nullptr);
|
||||
return shader;
|
||||
}
|
||||
|
||||
GLuint MakeAndCompile(const GLenum type, const char* source) {
|
||||
const GLuint shader = MakeShader(type, source);
|
||||
CompileShader(shader);
|
||||
return shader;
|
||||
}
|
||||
|
||||
GLint QueryCompileStatus(const GLuint shader) {
|
||||
GLint status = GL_FALSE;
|
||||
GetShaderiv(shader, GL_COMPILE_STATUS, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
String QueryShaderInfoLog(const GLuint shader) {
|
||||
GLint length = 0;
|
||||
GetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
|
||||
if (length <= 0) return String();
|
||||
std::vector<GLchar> buffer(static_cast<size_t>(length));
|
||||
GLsizei written = 0;
|
||||
GetShaderInfoLog(shader, length, &written, buffer.data());
|
||||
return String(buffer.data(), static_cast<size_t>(written));
|
||||
}
|
||||
|
||||
GLint QueryLinkStatus(const GLuint program) {
|
||||
GLint status = GL_FALSE;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &status);
|
||||
return status;
|
||||
}
|
||||
|
||||
// Content hash of a linked program's generated SPIR-V, through the state layer (there is
|
||||
// no GL query for it). This is what catches a mis-shared parse: if the claim CAS on a
|
||||
// SHARED node let two links both run mapIO over the same intermediate, the two programs
|
||||
// would disagree here.
|
||||
Vector<Uint64> SpirvDigest(const GLuint program) {
|
||||
const auto& object = MG_State::pGLContext->GetProgramObject(program);
|
||||
Vector<Uint64> digest;
|
||||
if (!object) return digest;
|
||||
for (const auto& module : object->GetGeneratedSpirv()) {
|
||||
Uint64 hash = 1469598103934665603ull;
|
||||
for (const unsigned word : module) {
|
||||
hash = (hash ^ static_cast<Uint64>(word)) * 1099511628211ull;
|
||||
}
|
||||
digest.push_back(hash);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
// Enqueues `count` distinct heavy compiles and reads nothing back, so the pool is left
|
||||
// with a real backlog for the caller's mutations to race against.
|
||||
void SaturatePool(const int count, Vector<String>& sourceStorage) {
|
||||
sourceStorage.reserve(sourceStorage.size() + static_cast<SizeT>(count));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
sourceStorage.push_back(MakeBulkySource(90000 + i));
|
||||
const char* text = sourceStorage.back().c_str();
|
||||
const GLuint shader = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(shader, 1, &text, nullptr);
|
||||
CompileShader(shader);
|
||||
}
|
||||
}
|
||||
|
||||
// Links `shader` against a freshly compiled vertex stage and returns the program.
|
||||
GLuint LinkWith(const GLuint shader) {
|
||||
const GLuint vs = MakeAndCompile(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, shader);
|
||||
LinkProgram(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
class ShaderCompileAdoptionTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override { MobileGL::Initialize(); }
|
||||
};
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The sharing itself
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The headline: two GL shader names, byte-identical source, exactly one job. Both names must
|
||||
// answer every query correctly, and the ONE parse they share must link into two separate
|
||||
// programs with byte-identical SPIR-V - which is the stage-4 claim CAS being exercised on a
|
||||
// shared node for the first time.
|
||||
TEST_F(ShaderCompileAdoptionTest, TwoObjectsWithIdenticalSourceShareOneCompileJob) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(32, backlog);
|
||||
|
||||
const String source = MakeBulkySource(100);
|
||||
const char* text = source.c_str();
|
||||
|
||||
const Uint64 before = AdoptionCount();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
CompileShader(b);
|
||||
|
||||
EXPECT_EQ(AdoptionCount() - before, 1u) << "the second glCompileShader must not enqueue a duplicate";
|
||||
ASSERT_NE(NodeOf(a), nullptr);
|
||||
EXPECT_EQ(NodeOf(a), NodeOf(b)) << "both objects must hold the very same job node";
|
||||
|
||||
// Both names still answer for themselves.
|
||||
EXPECT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a);
|
||||
EXPECT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
EXPECT_EQ(QueryShaderInfoLog(a), QueryShaderInfoLog(b));
|
||||
EXPECT_TRUE(QueryShaderInfoLog(a).empty());
|
||||
|
||||
// One node, two links: exactly one of them wins ClaimParsedShader, the other re-parses,
|
||||
// and the two must agree bit for bit.
|
||||
const GLuint programA = LinkWith(a);
|
||||
const GLuint programB = LinkWith(b);
|
||||
ASSERT_EQ(QueryLinkStatus(programA), GL_TRUE);
|
||||
ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE);
|
||||
const Vector<Uint64> digestA = SpirvDigest(programA);
|
||||
const Vector<Uint64> digestB = SpirvDigest(programB);
|
||||
ASSERT_EQ(digestA.size(), 2u);
|
||||
EXPECT_EQ(digestA, digestB) << "a shared node linked twice produced different SPIR-V";
|
||||
EXPECT_GE(GetUniformLocation(programA, "uSeed100"), 0);
|
||||
EXPECT_GE(GetUniformLocation(programB, "uSeed100"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Adoption must also re-arm the adopter's layer-1 memo. It is a POINTER comparison against
|
||||
// the node's own source snapshot, so an adopter that kept its own equal-but-distinct copy
|
||||
// would decide on the very next glCompileShader that it had no memo and enqueue the exact
|
||||
// duplicate this stage exists to remove - and an identical glShaderSource would cancel a
|
||||
// compile another object is still waiting on.
|
||||
TEST_F(ShaderCompileAdoptionTest, AdoptingAlsoArmsTheLayerOneMemo) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(32, backlog);
|
||||
|
||||
const String source = MakeBulkySource(110);
|
||||
const char* text = source.c_str();
|
||||
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
CompileShader(b);
|
||||
|
||||
const SharedPtr<ShaderObject> objectB = Object(b);
|
||||
ASSERT_NE(objectB, nullptr);
|
||||
EXPECT_TRUE(objectB->HasMemoizedCompile()) << "an adopted node must satisfy the layer-1 memo";
|
||||
|
||||
const ShaderCompileTask* shared = NodeOf(b);
|
||||
const Uint64 before = AdoptionCount();
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
CompileShader(b);
|
||||
EXPECT_EQ(NodeOf(b), shared) << "a repeat glCompileShader on an adopter must be a no-op";
|
||||
}
|
||||
// A byte-identical re-source is a no-op too, so it must not disturb the shared node.
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
EXPECT_EQ(NodeOf(b), shared);
|
||||
EXPECT_EQ(AdoptionCount(), before) << "no-op calls must not even reach the adoption map";
|
||||
|
||||
EXPECT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a);
|
||||
EXPECT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Different source, and same source in a different STAGE, are different keys. This is the
|
||||
// guard against the map ever handing out a node that does not belong to the caller.
|
||||
TEST_F(ShaderCompileAdoptionTest, DifferentSourceOrStageIsNotAdopted) {
|
||||
const AsyncModeScope async(true);
|
||||
|
||||
const String first = MakeBulkySource(120);
|
||||
const String second = MakeBulkySource(121);
|
||||
const char* firstText = first.c_str();
|
||||
const char* secondText = second.c_str();
|
||||
|
||||
const Uint64 before = AdoptionCount();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &firstText, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &secondText, nullptr);
|
||||
CompileShader(b);
|
||||
EXPECT_EQ(AdoptionCount(), before) << "different text must not adopt";
|
||||
EXPECT_NE(NodeOf(a), NodeOf(b));
|
||||
|
||||
// The same text in two stages: the vertex/fragment pair below shares no node either,
|
||||
// because the stage is part of the key.
|
||||
const GLuint vsA = MakeAndCompile(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint vsB = MakeAndCompile(GL_VERTEX_SHADER, kVs);
|
||||
EXPECT_EQ(NodeOf(vsA), NodeOf(vsB)) << "same stage, same text: must share";
|
||||
EXPECT_NE(NodeOf(vsA), NodeOf(a));
|
||||
|
||||
EXPECT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a);
|
||||
EXPECT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// A failed compile is shared exactly like a successful one, and both names must report the
|
||||
// identical status and the identical log - the info log lives in the node's artifacts, so
|
||||
// this is also the guard that a second joiner is not left with an empty one.
|
||||
TEST_F(ShaderCompileAdoptionTest, AdoptedFailingCompileReportsTheIdenticalLogToBothObjects) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(32, backlog);
|
||||
|
||||
const Uint64 before = AdoptionCount();
|
||||
const GLuint a = MakeAndCompile(GL_FRAGMENT_SHADER, kBrokenFs);
|
||||
const GLuint b = MakeAndCompile(GL_FRAGMENT_SHADER, kBrokenFs);
|
||||
EXPECT_EQ(AdoptionCount() - before, 1u);
|
||||
EXPECT_EQ(NodeOf(a), NodeOf(b));
|
||||
|
||||
EXPECT_EQ(QueryCompileStatus(a), GL_FALSE);
|
||||
EXPECT_EQ(QueryCompileStatus(b), GL_FALSE);
|
||||
const String logA = QueryShaderInfoLog(a);
|
||||
EXPECT_FALSE(logA.empty());
|
||||
EXPECT_EQ(QueryShaderInfoLog(b), logA);
|
||||
// GL models a failed compile as status + log, never as a GL error - which is what makes
|
||||
// moving the work off-thread (and sharing it) legal at all.
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The four release paths, each with a second object still holding the node
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// glShaderSource with DIFFERENT text on one sharer. Its release must NOT cancel the node the
|
||||
// other one is still waiting on; the re-sourced object gets a fresh compile of its own.
|
||||
TEST_F(ShaderCompileAdoptionTest, ResourcingOneSharerLeavesTheOtherIntact) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String shared = MakeBulkySource(200);
|
||||
const char* sharedText = shared.c_str();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &sharedText, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &sharedText, nullptr);
|
||||
CompileShader(b);
|
||||
const ShaderCompileTask* sharedNode = NodeOf(b);
|
||||
ASSERT_NE(sharedNode, nullptr);
|
||||
ASSERT_EQ(NodeOf(a), sharedNode);
|
||||
|
||||
// Replace A's text while the shared compile is very probably still outstanding.
|
||||
const String replacement = MakeBulkySource(201);
|
||||
const char* replacementText = replacement.c_str();
|
||||
ShaderSource(a, 1, &replacementText, nullptr);
|
||||
EXPECT_EQ(NodeOf(a), nullptr) << "a real source change must drop the object's node";
|
||||
EXPECT_EQ(NodeOf(b), sharedNode) << "B must still hold the shared node";
|
||||
|
||||
// B is untouched: the compile it is waiting on still publishes, and its artifacts are
|
||||
// the ones that source really produces.
|
||||
ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
const GLuint programB = LinkWith(b);
|
||||
ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(programB, "uSeed200"), 0);
|
||||
|
||||
// A gets a genuinely fresh compile of the new text.
|
||||
CompileShader(a);
|
||||
EXPECT_NE(NodeOf(a), sharedNode);
|
||||
ASSERT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a);
|
||||
const GLuint programA = LinkWith(a);
|
||||
ASSERT_EQ(QueryLinkStatus(programA), GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(programA, "uSeed201"), 0);
|
||||
EXPECT_EQ(GetUniformLocation(programA, "uSeed200"), -1);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glDeleteShader on one sharer. The name goes immediately (no wait for a worker) and the
|
||||
// object is destroyed, so this covers the DESTRUCTOR release as well as the orphan sweep's.
|
||||
TEST_F(ShaderCompileAdoptionTest, DeletingOneSharerLeavesTheOtherIntact) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String source = MakeBulkySource(210);
|
||||
const char* text = source.c_str();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
CompileShader(b);
|
||||
const ShaderCompileTask* sharedNode = NodeOf(b);
|
||||
ASSERT_NE(sharedNode, nullptr);
|
||||
ASSERT_EQ(NodeOf(a), sharedNode);
|
||||
|
||||
DeleteShader(a);
|
||||
EXPECT_EQ(IsShader(a), GL_FALSE) << "an unattached deleted shader's name goes immediately";
|
||||
EXPECT_EQ(NodeOf(b), sharedNode);
|
||||
|
||||
ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
const GLuint program = LinkWith(b);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(program, "uSeed210"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The window DeletingOneSharerLeavesTheOtherIntact cannot reach: there, A's compile has
|
||||
// always already finished (or not yet started) by the time B adopts, because the pool is
|
||||
// merely BUSY with other backlog. Here A's OWN node is still Running - a worker is inside
|
||||
// RunBody() for it - when the last holder releases it. ReleaseCompileNode fires Cancel(),
|
||||
// but JobNode::Cancel on a Running node only sets the cancellation-REQUEST flag; the state
|
||||
// stays Running until the worker's body returns and JobNode::Run forces the final transition
|
||||
// to Cancelled (see JobNode::Run's tail: it takes Cancelled instead of Complete whenever
|
||||
// m_cancelled is set, regardless of how the body finished). FindAdoptable must refuse a node
|
||||
// in that in-between state - not just one already settled as Cancelled - or C inherits a
|
||||
// doomed node and glGetShaderiv reports GL_FALSE with an empty info log for valid source.
|
||||
TEST_F(ShaderCompileAdoptionTest, RunningCancelRequestedNodeIsNotAdopted) {
|
||||
const AsyncModeScope async(true);
|
||||
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(1);
|
||||
|
||||
const String source = MakeVeryHeavySource(310);
|
||||
const char* text = source.c_str();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
|
||||
// Spin on the GL thread until the single worker is actually inside A's body. The source
|
||||
// is sized to make that window observable rather than instantaneous.
|
||||
const ShaderCompileTask* node = NodeOf(a);
|
||||
ASSERT_NE(node, nullptr);
|
||||
bool sawRunning = false;
|
||||
for (int i = 0; i < 200000 && !node->IsTerminal(); ++i) {
|
||||
if (node->State() == MG_Util::Async::JobState::Running) {
|
||||
sawRunning = true;
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(20));
|
||||
}
|
||||
ASSERT_TRUE(sawRunning) << "could not observe A's compile Running; the synthetic source "
|
||||
"needs to be heavier, or the pool did not have a free worker";
|
||||
|
||||
// A is the ONLY holder, so this release brings the adopter count to zero and (with no
|
||||
// link pin) fires Cancel() on a node that is still Running.
|
||||
DeleteShader(a);
|
||||
ASSERT_EQ(node->State(), MG_Util::Async::JobState::Running)
|
||||
<< "the node already settled; the race window closed before the assertions below "
|
||||
"could observe it - widen MakeVeryHeavySource's loop count";
|
||||
ASSERT_TRUE(node->IsCancellationRequested());
|
||||
ASSERT_FALSE(node->IsCancelled()) << "the window this test targets does not exist here";
|
||||
|
||||
// A brand-new shader name, byte-identical source, nothing wrong with it.
|
||||
const GLuint c = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(c, 1, &text, nullptr);
|
||||
CompileShader(c);
|
||||
EXPECT_NE(NodeOf(c), node) << "C adopted a cancellation-requested, still-Running node";
|
||||
|
||||
ASSERT_EQ(QueryCompileStatus(c), GL_TRUE)
|
||||
<< "valid source reported GL_FALSE; info log: [" << QueryShaderInfoLog(c) << "]";
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
|
||||
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(
|
||||
MG_Util::Async::ShaderCompilePool::Get().GetThreadCount());
|
||||
}
|
||||
|
||||
// The deferred half of glDeleteShader: A is ATTACHED, so the delete only flags it and the
|
||||
// name is freed by ReleaseShaderNameIfOrphaned when the detach removes the last GL-visible
|
||||
// attachment. That sweep is the other caller of the release path, and it must not cancel the
|
||||
// node B is sharing.
|
||||
TEST_F(ShaderCompileAdoptionTest, OrphanSweepOnOneSharerLeavesTheOtherIntact) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String source = MakeBulkySource(220);
|
||||
const char* text = source.c_str();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
CompileShader(b);
|
||||
const ShaderCompileTask* sharedNode = NodeOf(b);
|
||||
ASSERT_NE(sharedNode, nullptr);
|
||||
ASSERT_EQ(NodeOf(a), sharedNode);
|
||||
|
||||
// Attach A, flag it for deletion (name survives), then detach: the sweep fires here, with
|
||||
// NO link ever posted, so the stage-4 pin is NOT what is protecting the node - only the
|
||||
// adopter count is.
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, a);
|
||||
DeleteShader(a);
|
||||
EXPECT_EQ(IsShader(a), GL_TRUE) << "an attached deleted shader keeps its name";
|
||||
DetachShader(program, a);
|
||||
EXPECT_EQ(IsShader(a), GL_FALSE) << "the detach must free the flagged shader's name";
|
||||
EXPECT_EQ(NodeOf(b), sharedNode);
|
||||
|
||||
ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
const GLuint programB = LinkWith(b);
|
||||
ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(programB, "uSeed220"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The same sweep, now with the stage-4 link pin also in play: A's program is LINKED (so the
|
||||
// node is MarkLinkReferenced) and then A is detached and deleted, while B still shares the
|
||||
// node. Both protections have to hold at once - the link must report GL_TRUE and B must
|
||||
// still compile.
|
||||
TEST_F(ShaderCompileAdoptionTest, OrphanSweepWithALinkPinnedSharedNodeHoldsBoth) {
|
||||
const AsyncModeScope async(true);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String source = MakeBulkySource(230);
|
||||
const char* text = source.c_str();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
CompileShader(b);
|
||||
const ShaderCompileTask* sharedNode = NodeOf(b);
|
||||
ASSERT_NE(sharedNode, nullptr);
|
||||
ASSERT_EQ(NodeOf(a), sharedNode);
|
||||
|
||||
// The ordinary teardown order: link, then detach, then delete. No status read in between,
|
||||
// so the link's own prologue is what joins the shared compile.
|
||||
const GLuint vs = MakeAndCompile(GL_VERTEX_SHADER, kVs);
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vs);
|
||||
AttachShader(program, a);
|
||||
LinkProgram(program);
|
||||
DetachShader(program, a);
|
||||
DeleteShader(a);
|
||||
EXPECT_EQ(IsShader(a), GL_FALSE);
|
||||
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "the pinned shared compile must still publish";
|
||||
EXPECT_GE(GetUniformLocation(program, "uSeed230"), 0);
|
||||
EXPECT_EQ(NodeOf(b), sharedNode);
|
||||
ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// Every sharer released, in turn, with nothing pinning the node: the LAST release is the one
|
||||
// that may cancel, and afterwards the map must not hand the cancelled node to anybody. The
|
||||
// property asserted is the one that matters and it is timing-free: whatever happened to the
|
||||
// old node, a later object with the same source must end up with a CORRECT compile.
|
||||
TEST_F(ShaderCompileAdoptionTest, AfterEverySharerIsGoneTheNextCompileIsStillCorrect) {
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope compilerThreads;
|
||||
// One worker and a deep backlog: a node posted now is overwhelmingly likely to still be
|
||||
// queued when its last holder drops it, which is the state in which the cancel bites.
|
||||
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(1);
|
||||
Vector<String> backlog;
|
||||
SaturatePool(48, backlog);
|
||||
|
||||
const String source = MakeBulkySource(240);
|
||||
const char* text = source.c_str();
|
||||
const GLuint a = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(a, 1, &text, nullptr);
|
||||
CompileShader(a);
|
||||
const GLuint b = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(b, 1, &text, nullptr);
|
||||
CompileShader(b);
|
||||
ASSERT_EQ(NodeOf(a), NodeOf(b));
|
||||
|
||||
DeleteShader(a);
|
||||
DeleteShader(b); // the last holder: this one is authorized to cancel
|
||||
|
||||
const GLuint c = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(c, 1, &text, nullptr);
|
||||
CompileShader(c);
|
||||
ASSERT_EQ(QueryCompileStatus(c), GL_TRUE)
|
||||
<< "a cancelled node must never be adopted - it can only ever report GL_FALSE. "
|
||||
<< QueryShaderInfoLog(c);
|
||||
const GLuint program = LinkWith(c);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE);
|
||||
EXPECT_GE(GetUniformLocation(program, "uSeed240"), 0);
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The bypasses: both must be byte-identical to the pre-stage-6 behaviour
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The kill switch. With the flag off, compilation is synchronous and NOTHING is adopted -
|
||||
// the map is not even consulted, so the counter cannot move.
|
||||
TEST_F(ShaderCompileAdoptionTest, FlagOffAdoptsNothing) {
|
||||
const AsyncModeScope async(false);
|
||||
ASSERT_FALSE(MG_Util::Async::AsyncShaderCompileEnabled());
|
||||
|
||||
const String source = MakeBulkySource(300);
|
||||
const char* text = source.c_str();
|
||||
|
||||
const Uint64 before = AdoptionCount();
|
||||
Vector<GLuint> shaders;
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
shaders.push_back(fs);
|
||||
}
|
||||
EXPECT_EQ(AdoptionCount(), before) << "the flag-off path must not consult the adoption map";
|
||||
for (SizeT i = 1; i < shaders.size(); ++i) {
|
||||
EXPECT_NE(NodeOf(shaders[i]), NodeOf(shaders[0])) << "flag off means one node per object";
|
||||
}
|
||||
for (const GLuint fs : shaders) {
|
||||
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// glMaxShaderCompilerThreadsKHR(0) puts compilation back on the application's thread even
|
||||
// though the extension stays advertised. Adoption keys off the same predicate, so a
|
||||
// suspended context shares nothing either - which is what keeps a subsequent
|
||||
// GL_COMPLETION_STATUS_KHR immediately GL_TRUE without any reasoning about shared nodes.
|
||||
TEST_F(ShaderCompileAdoptionTest, SuspendedCompilationAdoptsNothing) {
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope compilerThreads;
|
||||
MaxShaderCompilerThreadsKHR(0);
|
||||
ASSERT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended());
|
||||
|
||||
const String source = MakeBulkySource(310);
|
||||
const char* text = source.c_str();
|
||||
|
||||
const Uint64 before = AdoptionCount();
|
||||
Vector<GLuint> shaders;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
shaders.push_back(fs);
|
||||
GLint complete = GL_FALSE;
|
||||
GetShaderiv(fs, GL_COMPLETION_STATUS_KHR, &complete);
|
||||
EXPECT_EQ(complete, GL_TRUE) << "a zero compiler-thread count leaves nothing in flight";
|
||||
}
|
||||
EXPECT_EQ(AdoptionCount(), before);
|
||||
for (SizeT i = 1; i < shaders.size(); ++i) {
|
||||
EXPECT_NE(NodeOf(shaders[i]), NodeOf(shaders[0]));
|
||||
}
|
||||
for (const GLuint fs : shaders) {
|
||||
EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs);
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Stress
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The shaderpack shape: 48 objects over 6 distinct sources, all enqueued before anything is
|
||||
// read, on a two-worker pool. 42 of the 48 compiles must simply vanish, and all 48 objects
|
||||
// must still be individually correct - each with its own name, its own status, and its own
|
||||
// link (which means 48 claims against 6 shared parses).
|
||||
TEST_F(ShaderCompileAdoptionTest, StressFortyEightObjectsOverSixSources) {
|
||||
const AsyncModeScope async(true);
|
||||
const CompilerThreadScope compilerThreads;
|
||||
MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(2);
|
||||
|
||||
constexpr int kDistinct = 6;
|
||||
constexpr int kDuplicates = 8;
|
||||
Vector<String> sources;
|
||||
sources.reserve(kDistinct);
|
||||
for (int i = 0; i < kDistinct; ++i) {
|
||||
sources.push_back(MakeBulkySource(400 + i));
|
||||
}
|
||||
|
||||
const Uint64 before = AdoptionCount();
|
||||
Vector<GLuint> shaders;
|
||||
for (int duplicate = 0; duplicate < kDuplicates; ++duplicate) {
|
||||
for (int i = 0; i < kDistinct; ++i) {
|
||||
const char* text = sources[static_cast<SizeT>(i)].c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
shaders.push_back(fs);
|
||||
}
|
||||
}
|
||||
const Uint64 adoptions = AdoptionCount() - before;
|
||||
// The floor the stage contracts for, with room for any future scheduling slack...
|
||||
ASSERT_GE(adoptions, 30u) << "48 objects over 6 sources adopted only " << adoptions << " times";
|
||||
// ...and the number this design actually produces, because the decision is made on the GL
|
||||
// thread before anything is posted and therefore does not depend on the workers at all.
|
||||
EXPECT_EQ(adoptions, static_cast<Uint64>(kDistinct * (kDuplicates - 1)));
|
||||
|
||||
for (SizeT s = 0; s < shaders.size(); ++s) {
|
||||
const GLuint fs = shaders[s];
|
||||
ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << "shader " << s << ": " << QueryShaderInfoLog(fs);
|
||||
const GLuint program = LinkWith(fs);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "shader index " << s;
|
||||
const String uniform = "uSeed" + std::to_string(400 + static_cast<int>(s % kDistinct));
|
||||
EXPECT_GE(GetUniformLocation(program, uniform.c_str()), 0) << uniform;
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
|
||||
// The adversarial interleaving, with duplicates everywhere: compile, query, re-source,
|
||||
// re-compile, delete, all with the pool busy and most objects sharing nodes. Nothing here
|
||||
// asserts timing - what it hunts for is a node cancelled out from under a sharer, which
|
||||
// surfaces as a wrong status, a wrong uniform, or a crash.
|
||||
TEST_F(ShaderCompileAdoptionTest, StressSharedNodesUnderResourceAndDelete) {
|
||||
const AsyncModeScope async(true);
|
||||
constexpr int kRounds = 6;
|
||||
constexpr int kPerRound = 12;
|
||||
|
||||
for (int round = 0; round < kRounds; ++round) {
|
||||
Vector<String> sources;
|
||||
sources.reserve(4);
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
sources.push_back(MakeBulkySource(round * 100 + i));
|
||||
}
|
||||
const String replacement = MakeBulkySource(round * 100 + 50);
|
||||
const char* replacementText = replacement.c_str();
|
||||
|
||||
Vector<GLuint> shaders;
|
||||
for (int i = 0; i < kPerRound; ++i) {
|
||||
const char* text = sources[static_cast<SizeT>(i % 4)].c_str();
|
||||
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||
ShaderSource(fs, 1, &text, nullptr);
|
||||
CompileShader(fs);
|
||||
shaders.push_back(fs);
|
||||
}
|
||||
|
||||
// Re-source a third of them onto ONE new shared source, so the survivors of each
|
||||
// original node keep waiting on it while the movers pile onto a new one.
|
||||
for (int i = 0; i < kPerRound; i += 3) {
|
||||
ShaderSource(shaders[static_cast<SizeT>(i)], 1, &replacementText, nullptr);
|
||||
CompileShader(shaders[static_cast<SizeT>(i)]);
|
||||
}
|
||||
// And delete another third outright, while their nodes are still shared.
|
||||
for (int i = 1; i < kPerRound; i += 3) {
|
||||
DeleteShader(shaders[static_cast<SizeT>(i)]);
|
||||
}
|
||||
|
||||
for (int i = 0; i < kPerRound; ++i) {
|
||||
if (i % 3 == 1) continue; // deleted
|
||||
const GLuint shader = shaders[static_cast<SizeT>(i)];
|
||||
ASSERT_EQ(QueryCompileStatus(shader), GL_TRUE)
|
||||
<< "round " << round << " shader " << i << ": " << QueryShaderInfoLog(shader);
|
||||
const GLuint program = LinkWith(shader);
|
||||
ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "round " << round << " shader " << i;
|
||||
const String expected =
|
||||
"uSeed" + std::to_string(i % 3 == 0 ? round * 100 + 50 : round * 100 + (i % 4));
|
||||
EXPECT_GE(GetUniformLocation(program, expected.c_str()), 0)
|
||||
<< "round " << round << " shader " << i << " expected " << expected;
|
||||
DeleteProgram(program);
|
||||
}
|
||||
for (int i = 0; i < kPerRound; ++i) {
|
||||
if (i % 3 != 1) DeleteShader(shaders[static_cast<SizeT>(i)]);
|
||||
}
|
||||
EXPECT_EQ(GetError(), GL_NO_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The map itself, driven directly
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Two of the map's rules cannot be forced deterministically through the GL surface - a
|
||||
// cancelled node depends on beating a worker to it, and a CompileEnv re-capture needs a
|
||||
// backend swap. Both are unconditional properties of the class, so they are asserted here
|
||||
// against the class.
|
||||
|
||||
namespace {
|
||||
SharedPtr<ShaderCompileTask> MakeNode(const String& text, const ShaderStage stage,
|
||||
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv>& env) {
|
||||
auto source = MakeShared<const String>(text);
|
||||
const Uint64 hash = ShaderPreprocessCache::HashSource(*source);
|
||||
return MakeShared<ShaderCompileTask>(stage, source, hash, env, nullptr, 0);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(ShaderCompileAdoptionMapTest, RegisteredNodeIsAdoptedOnAnExactMatch) {
|
||||
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
|
||||
ShaderCompileAdoptionMap map;
|
||||
const String text = "#version 460\nvoid main() {}\n";
|
||||
const SharedPtr<ShaderCompileTask> node = MakeNode(text, ShaderStage::Fragment, env);
|
||||
map.Register(node);
|
||||
|
||||
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text,
|
||||
env->fingerprint),
|
||||
node);
|
||||
EXPECT_EQ(map.GetAdoptionCount(), 1u);
|
||||
|
||||
// Every discriminator in the key is load-bearing.
|
||||
EXPECT_EQ(map.FindAdoptable(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(text), text,
|
||||
env->fingerprint),
|
||||
nullptr);
|
||||
const String other = text + "\n";
|
||||
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(other), other,
|
||||
env->fingerprint),
|
||||
nullptr);
|
||||
EXPECT_EQ(map.GetAdoptionCount(), 1u) << "a miss must not count as an adoption";
|
||||
}
|
||||
|
||||
// A memo must never be handed back under an environment other than the one it was computed
|
||||
// against: the compute local-size verdict inside the pipeline reads CompileEnv's device
|
||||
// limits, so a node captured under one backend's limits is not a valid answer under
|
||||
// another's. The fingerprint is what enforces that, and it is part of the key.
|
||||
TEST(ShaderCompileAdoptionMapTest, EnvFingerprintMismatchIsNotAdopted) {
|
||||
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
|
||||
ShaderCompileAdoptionMap map;
|
||||
const String text = "#version 460\nvoid main() {}\n";
|
||||
map.Register(MakeNode(text, ShaderStage::Fragment, env));
|
||||
|
||||
// A genuinely different environment: different device limits, hence a different
|
||||
// fingerprint, hence a different key.
|
||||
auto otherEnv = MakeShared<MG_Util::ShaderTranspiler::CompileEnv>(*env);
|
||||
otherEnv->maxComputeWorkGroupInvocations = env->maxComputeWorkGroupInvocations + 1;
|
||||
otherEnv->fingerprint = MG_Util::ShaderTranspiler::ComputeCompileEnvFingerprint(*otherEnv);
|
||||
ASSERT_NE(otherEnv->fingerprint, env->fingerprint);
|
||||
|
||||
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text,
|
||||
otherEnv->fingerprint),
|
||||
nullptr);
|
||||
EXPECT_EQ(map.GetAdoptionCount(), 0u);
|
||||
}
|
||||
|
||||
// A node that settled as Cancelled published nothing, so adopting it would hand the new
|
||||
// object a compile that can only ever report GL_FALSE. It must be a miss, and the dead entry
|
||||
// must be pruned where it is found rather than waiting for the amortized sweep.
|
||||
TEST(ShaderCompileAdoptionMapTest, CancelledNodeIsNotAdoptedAndIsPruned) {
|
||||
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
|
||||
ShaderCompileAdoptionMap map;
|
||||
const String text = "#version 460\nvoid main() {}\n";
|
||||
const SharedPtr<ShaderCompileTask> node = MakeNode(text, ShaderStage::Fragment, env);
|
||||
map.Register(node);
|
||||
// Never posted, so this settles the node as Cancelled right here.
|
||||
node->Cancel();
|
||||
ASSERT_TRUE(node->IsCancelled());
|
||||
ASSERT_EQ(map.GetEntryCount(), 1u);
|
||||
|
||||
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text,
|
||||
env->fingerprint),
|
||||
nullptr);
|
||||
EXPECT_EQ(map.GetEntryCount(), 0u) << "the dead entry must be pruned on the lookup that found it";
|
||||
EXPECT_EQ(map.GetAdoptionCount(), 0u);
|
||||
}
|
||||
|
||||
// The map is an index, never an owner: once the last real holder is gone the entry expires
|
||||
// and is pruned, so a node's artifacts can never be kept alive by the map alone.
|
||||
TEST(ShaderCompileAdoptionMapTest, ExpiredNodeIsNotAdoptedAndIsPruned) {
|
||||
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
|
||||
ShaderCompileAdoptionMap map;
|
||||
const String text = "#version 460\nvoid main() {}\n";
|
||||
{
|
||||
map.Register(MakeNode(text, ShaderStage::Fragment, env));
|
||||
}
|
||||
ASSERT_EQ(map.GetEntryCount(), 1u);
|
||||
|
||||
EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text,
|
||||
env->fingerprint),
|
||||
nullptr);
|
||||
EXPECT_EQ(map.GetEntryCount(), 0u);
|
||||
}
|
||||
|
||||
// The amortized sweep keeps the index O(live nodes) instead of O(compiles ever issued).
|
||||
TEST(ShaderCompileAdoptionMapTest, SweepReclaimsDeadEntries) {
|
||||
const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv();
|
||||
ShaderCompileAdoptionMap map;
|
||||
// Every one of these dies immediately, so nothing but dead weight accumulates - and the
|
||||
// map must not grow without bound because of it.
|
||||
for (SizeT i = 0; i < ShaderCompileAdoptionMap::kMinSweepThreshold * 4; ++i) {
|
||||
map.Register(MakeNode("#version 460\nvoid main() { float x" + std::to_string(i) + " = 0.0; }\n",
|
||||
ShaderStage::Fragment, env));
|
||||
}
|
||||
EXPECT_LE(map.GetEntryCount(), ShaderCompileAdoptionMap::kMinSweepThreshold)
|
||||
<< "expired entries must be reclaimed, not accumulated";
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
// MobileGL - MobileGL/MG_Test/Program/XfbFrontendOrderInvarianceTest.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// The frontend's answer for a transform-feedback program must not depend on what
|
||||
// was linked before it. This binary asserts exactly that, headlessly: it links a
|
||||
// clip_distance-shaped program A, then an XFB-shaped program B, and diffs B's
|
||||
// whole frontend output (xfb varyings and their offsets, strides, buffer mode,
|
||||
// scattered-capture and geometry-strip verdicts, uniform blocks, attribute and
|
||||
// uniform counts, and every SPIR-V module byte-for-byte plus its Location /
|
||||
// Component / Index / Offset / XfbBuffer / XfbStride / BuiltIn / Binding /
|
||||
// DescriptorSet decorations) against the same B linked with no A ahead of it.
|
||||
//
|
||||
// It was written to arbitrate an order-triggered CTS failure - after
|
||||
// KHR-GLxx.clip_distance.functional ran, every later transform_feedback capture
|
||||
// case failed on DirectVulkan - and its verdict was NEGATIVE, which is what made
|
||||
// it worth keeping: B's frontend output is bit-identical under every ordering,
|
||||
// every flag state (MOBILEGL_ASYNC_SHADER_COMPILE on/off, THREADS unset/1/8) and
|
||||
// every isolation level below. That ruled out the whole frontend - the P0b
|
||||
// preprocess cache, the stage-6 adoption map, ProgramState, glslang's shared
|
||||
// built-in symbol tables, the pool workers' thread_locals - and sent the hunt
|
||||
// downstream, where the defect actually was: DirectVulkan's per-VAO vertex
|
||||
// binding memo keyed on recycled heap addresses (see
|
||||
// MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp). Keep it as the
|
||||
// standing guard on the negative half of that split: if the frontend ever DOES
|
||||
// acquire cross-program order sensitivity, this is what says so.
|
||||
//
|
||||
// Isolation model - three levels, all in one binary:
|
||||
// * FRESH CONTEXT MG_State::Init() reinstalls pGLContext, which is what
|
||||
// drops the P0b cache, the adoption map and ProgramState.
|
||||
// Process globals (glslang tables, prewarm latch, pool
|
||||
// worker thread_locals) deliberately SURVIVE it, which is
|
||||
// what makes the fresh-context control a bisection step
|
||||
// rather than just a reset.
|
||||
// * FRESH PROCESS ctest runs each gtest case in this binary in the same
|
||||
// process, so the "control first, poisoned second" and
|
||||
// "poisoned first, control second" orderings are split
|
||||
// into two cases whose names sort in opposite orders and
|
||||
// which each capture BOTH snapshots themselves. A truly
|
||||
// fresh process is available by running one case with
|
||||
// --gtest_filter (see the FreshProcess* cases).
|
||||
// * CACHE-CLEARED context kept, but the source text of B is made unique
|
||||
// per run so no P0b/adoption hit is possible at all.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Config.h"
|
||||
#include "Includes.h"
|
||||
#include "Init.h"
|
||||
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
|
||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||
#include "MG_State/GLState/Core.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Impl::GLImpl;
|
||||
|
||||
namespace {
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Flag plumbing (same shape AsyncCompileTest uses)
|
||||
// ---------------------------------------------------------------------------------
|
||||
class AsyncModeScope {
|
||||
public:
|
||||
explicit AsyncModeScope(const Bool async)
|
||||
: m_saved(MG_Config::Features.AsyncShaderCompile) {
|
||||
MG_Config::Features.AsyncShaderCompile =
|
||||
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
|
||||
}
|
||||
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
|
||||
AsyncModeScope(const AsyncModeScope&) = delete;
|
||||
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
|
||||
|
||||
private:
|
||||
const MG_Config::QuirkOverride m_saved;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// A: the clip_distance.functional shape
|
||||
// glcClipDistance.cpp, FunctionalTest::m_vertex_shader_code with
|
||||
// CLIP_DISTANCE_REDECLARATION = m_explicit_redeclaration and
|
||||
// CLIP_DISTANCE_SETUP = m_dynamic_array_setter, clip function 0.
|
||||
// ${VERSION} for a KHR-GL40 run is "#version 400".
|
||||
// ---------------------------------------------------------------------------------
|
||||
String ClipDistanceVs(const int clipCount, const char* version) {
|
||||
const String n = std::to_string(clipCount);
|
||||
return String(version) +
|
||||
"\n"
|
||||
"\n"
|
||||
"out float gl_ClipDistance[" + n + "];\n"
|
||||
"\n"
|
||||
"float f(int i)\n"
|
||||
"{\n"
|
||||
" return 0.0;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"in vec4 position;\n"
|
||||
"\n"
|
||||
"void main()\n"
|
||||
"{\n"
|
||||
" for(int i = 0; i < " + n + "; i++)\n"
|
||||
" {\n"
|
||||
" gl_ClipDistance[i] = f(i);\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" gl_Position = position;\n"
|
||||
"}\n";
|
||||
}
|
||||
|
||||
String ClipDistanceFs(const char* version) {
|
||||
return String(version) +
|
||||
"\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"out highp vec4 color;\n"
|
||||
"\n"
|
||||
"void main()\n"
|
||||
"{\n"
|
||||
" color = vec4(1.0, 0.0, 0.0, 1.0);\n"
|
||||
"}\n";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// B1: the transform_feedback3 skip_components shape
|
||||
// gl3cTransformFeedback3Tests.cpp, TransformFeedbackBaseTestCase::m_shader_vert
|
||||
// at "#version 150", captured with the gl_SkipComponents* varying list.
|
||||
// This is the case whose failure text is the crispest:
|
||||
// "compareArrays(GLfloat):index 1 value -2 != 1"
|
||||
// ---------------------------------------------------------------------------------
|
||||
String SkipComponentsVs(const char* version, const String& saltComment = String()) {
|
||||
return String(version) + "\n" + saltComment +
|
||||
" in vec4 vertex;\n"
|
||||
" out vec4 value1;\n"
|
||||
" out vec4 value2;\n"
|
||||
" out vec4 value3;\n"
|
||||
" out vec4 value4;\n"
|
||||
"\n"
|
||||
" void main (void)\n"
|
||||
" {\n"
|
||||
" vec4 temp = vertex;\n"
|
||||
"\n"
|
||||
" gl_Position = temp;\n"
|
||||
"\n"
|
||||
" value1 = abs(temp) * 1.0;\n"
|
||||
" value2 = abs(temp) * 2.0;\n"
|
||||
" value3 = abs(temp) * 3.0;\n"
|
||||
" value4 = abs(temp) * 4.0;\n"
|
||||
" }\n";
|
||||
}
|
||||
|
||||
String SkipComponentsFs(const char* version) {
|
||||
return String(version) +
|
||||
"\n"
|
||||
" out vec4 fragColor;\n"
|
||||
" void main (void)\n"
|
||||
" {\n"
|
||||
" fragColor = vec4(0.0, 0.0, 0.0, 1.0);\n"
|
||||
" }\n";
|
||||
}
|
||||
|
||||
Vector<String> SkipComponentsVaryings() {
|
||||
return {"gl_SkipComponents1", "value1", "gl_SkipComponents2", "gl_SkipComponents1", "value2",
|
||||
"gl_SkipComponents3", "gl_SkipComponents2", "value3", "gl_SkipComponents4", "value4"};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// B2: the capture_vertex_interleaved shape
|
||||
// gl3cTransformFeedbackTests.cpp, CaptureVertexInterleaved::
|
||||
// s_vertex_shader_source_code_template at "#version 130", with
|
||||
// MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS/4 - 1 user vec4 outputs
|
||||
// plus gl_Position as the final captured varying.
|
||||
// ---------------------------------------------------------------------------------
|
||||
String CaptureInterleavedVs(const int userVaryings, const char* version) {
|
||||
String declarations;
|
||||
String setters;
|
||||
for (int i = 0; i < userVaryings; ++i) {
|
||||
const String name = "result_" + std::to_string(i);
|
||||
declarations += "out vec4 " + name + ";\n";
|
||||
setters += " " + name + " = vec4(" + std::to_string(i * 4) + ".0, " +
|
||||
std::to_string(i * 4 + 1) + ".0, " + std::to_string(i * 4 + 2) + ".0, " +
|
||||
std::to_string(i * 4 + 3) + ".0);\n";
|
||||
}
|
||||
return String(version) + "\n\n" + declarations + "\n" +
|
||||
"void main()\n"
|
||||
"{\n" +
|
||||
setters +
|
||||
"\n"
|
||||
" vec4 position = vec4(0.0);\n"
|
||||
"\n"
|
||||
" switch(gl_VertexID)\n"
|
||||
" {\n"
|
||||
" case 0:\n"
|
||||
" position = vec4(-1.0 + 0.0625, 1.0 - 0.0625, 0.0, 1.0);\n"
|
||||
" break;\n"
|
||||
" case 1:\n"
|
||||
" position = vec4( 1.0 - 0.0625, 1.0 - 0.0625, 0.0, 1.0);\n"
|
||||
" break;\n"
|
||||
" case 2:\n"
|
||||
" position = vec4(-1.0 + 0.0625, -1.0 + 0.0625, 0.0, 1.0);\n"
|
||||
" break;\n"
|
||||
" case 3:\n"
|
||||
" position = vec4( 1.0 - 0.0625, -1.0 + 0.0625, 0.0, 1.0);\n"
|
||||
" break;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" gl_Position = position;\n"
|
||||
"}\n";
|
||||
}
|
||||
|
||||
String CaptureInterleavedFs(const char* version) {
|
||||
return String(version) +
|
||||
"\n"
|
||||
"\n"
|
||||
"out vec4 color;\n"
|
||||
"\n"
|
||||
"void main()\n"
|
||||
"{\n"
|
||||
" color = vec4(0.5);\n"
|
||||
"}\n";
|
||||
}
|
||||
|
||||
Vector<String> CaptureInterleavedVaryings(const int userVaryings) {
|
||||
Vector<String> names;
|
||||
for (int i = 0; i < userVaryings; ++i) names.push_back("result_" + std::to_string(i));
|
||||
names.push_back("gl_Position");
|
||||
return names;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// B3: the capture_geometry_interleaved shape. The only shape that reaches
|
||||
// ResolveGsTriangleStripCapture, i.e. the gsStripTriangles / gsStripCaptureFixup
|
||||
// artifacts - and triangle_strip is the sub-case that needs the fixup.
|
||||
// ---------------------------------------------------------------------------------
|
||||
const char* kGeometryBlankVs = "#version 130\n"
|
||||
"\n"
|
||||
"void main()\n"
|
||||
"{\n"
|
||||
"}\n";
|
||||
|
||||
String CaptureGeometryGs(const int userVaryings, const char* outPrimitive) {
|
||||
String declarations;
|
||||
String setters;
|
||||
for (int i = 0; i < userVaryings; ++i) {
|
||||
const String name = "result_" + std::to_string(i);
|
||||
declarations += "out vec4 " + name + ";\n";
|
||||
setters += " " + name + " = vec4(" + std::to_string(i * 4) + ".0, " +
|
||||
std::to_string(i * 4 + 1) + ".0, " + std::to_string(i * 4 + 2) + ".0, " +
|
||||
std::to_string(i * 4 + 3) + ".0);\n";
|
||||
}
|
||||
String source = "#version 150\n"
|
||||
"\n"
|
||||
"layout(points) in;\n"
|
||||
"layout(" +
|
||||
String(outPrimitive) +
|
||||
", max_vertices = 4) out;\n"
|
||||
"\n" +
|
||||
declarations + "\n" +
|
||||
"void main()\n"
|
||||
"{\n";
|
||||
const char* positions[] = {"vec4(-1.0 + 0.0625, 1.0 - 0.0625, 0.0, 1.0)",
|
||||
"vec4( 1.0 - 0.0625, 1.0 - 0.0625, 0.0, 1.0)",
|
||||
"vec4(-1.0 + 0.0625, -1.0 + 0.0625, 0.0, 1.0)",
|
||||
"vec4( 1.0 - 0.0625, -1.0 + 0.0625, 0.0, 1.0)"};
|
||||
for (const char* position : positions) {
|
||||
source += String("\n gl_Position = ") + position + ";\n";
|
||||
source += setters;
|
||||
source += " EmitVertex();\n";
|
||||
}
|
||||
source += "}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
GLuint BuildProgramWithGeometry(const String& vertexSource, const String& geometrySource,
|
||||
const String& fragmentSource, const Vector<String>& xfbVaryings);
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// SPIR-V digest: hash + every decoration that could express a slot shift, resolved
|
||||
// through OpName so the text is stable across id renumbering.
|
||||
// ---------------------------------------------------------------------------------
|
||||
constexpr Uint32 kOpName = 5;
|
||||
constexpr Uint32 kOpMemberName = 6;
|
||||
constexpr Uint32 kOpEntryPoint = 15;
|
||||
constexpr Uint32 kOpDecorate = 71;
|
||||
constexpr Uint32 kOpMemberDecorate = 72;
|
||||
|
||||
const char* DecorationName(const Uint32 decoration) {
|
||||
switch (decoration) {
|
||||
case 11: return "BuiltIn";
|
||||
case 30: return "Location";
|
||||
case 31: return "Component";
|
||||
case 32: return "Index";
|
||||
case 33: return "Binding";
|
||||
case 34: return "DescriptorSet";
|
||||
case 35: return "Offset";
|
||||
case 36: return "XfbBuffer";
|
||||
case 37: return "XfbStride";
|
||||
case 38: return "FuncParamAttr";
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
const char* BuiltInName(const Uint32 builtIn) {
|
||||
switch (builtIn) {
|
||||
case 0: return "Position";
|
||||
case 1: return "PointSize";
|
||||
case 3: return "ClipDistance";
|
||||
case 4: return "CullDistance";
|
||||
case 5: return "VertexId";
|
||||
case 42: return "VertexIndex";
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
String ReadSpirvString(const Vector<unsigned>& words, const SizeT firstWord, const SizeT endWord,
|
||||
SizeT& outNextWord) {
|
||||
String text;
|
||||
SizeT w = firstWord;
|
||||
for (; w < endWord; ++w) {
|
||||
const Uint32 word = words[w];
|
||||
Bool done = false;
|
||||
for (int b = 0; b < 4; ++b) {
|
||||
const char c = static_cast<char>((word >> (8 * b)) & 0xFF);
|
||||
if (c == '\0') {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
text.push_back(c);
|
||||
}
|
||||
if (done) {
|
||||
++w;
|
||||
break;
|
||||
}
|
||||
}
|
||||
outNextWord = w;
|
||||
return text;
|
||||
}
|
||||
|
||||
Uint64 Fnv1a(const Vector<unsigned>& words) {
|
||||
Uint64 hash = 1469598103934665603ULL;
|
||||
for (const unsigned word : words) {
|
||||
for (int b = 0; b < 4; ++b) {
|
||||
hash ^= static_cast<Uint64>((word >> (8 * b)) & 0xFF);
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
struct SpirvDigest {
|
||||
Uint64 hash = 0;
|
||||
SizeT wordCount = 0;
|
||||
Vector<String> decorations;
|
||||
Vector<String> interfaceNames;
|
||||
};
|
||||
|
||||
SpirvDigest DigestSpirv(const Vector<unsigned>& words) {
|
||||
SpirvDigest digest;
|
||||
digest.hash = Fnv1a(words);
|
||||
digest.wordCount = words.size();
|
||||
if (words.size() < 5 || words[0] != 0x07230203u) {
|
||||
digest.decorations.push_back("<not a SPIR-V module>");
|
||||
return digest;
|
||||
}
|
||||
|
||||
UnorderedMap<Uint32, String> names;
|
||||
Vector<Uint32> interfaceIds;
|
||||
struct PendingDecoration {
|
||||
Uint32 target;
|
||||
Int member; // -1 for OpDecorate
|
||||
Uint32 decoration;
|
||||
Vector<Uint32> operands;
|
||||
};
|
||||
Vector<PendingDecoration> pending;
|
||||
|
||||
SizeT w = 5;
|
||||
while (w < words.size()) {
|
||||
const Uint32 header = words[w];
|
||||
const Uint32 wordCount = header >> 16;
|
||||
const Uint32 opcode = header & 0xFFFFu;
|
||||
if (wordCount == 0 || w + wordCount > words.size()) break;
|
||||
|
||||
if (opcode == kOpName && wordCount >= 3) {
|
||||
SizeT next = 0;
|
||||
names[words[w + 1]] = ReadSpirvString(words, w + 2, w + wordCount, next);
|
||||
} else if (opcode == kOpMemberName && wordCount >= 4) {
|
||||
SizeT next = 0;
|
||||
const String member = ReadSpirvString(words, w + 3, w + wordCount, next);
|
||||
names[words[w + 1]] = names.count(words[w + 1]) ? names[words[w + 1]] : String("<struct>");
|
||||
(void)member;
|
||||
} else if (opcode == kOpEntryPoint && wordCount >= 4) {
|
||||
SizeT next = 0;
|
||||
(void)ReadSpirvString(words, w + 3, w + wordCount, next);
|
||||
for (SizeT i = next; i < w + wordCount; ++i) interfaceIds.push_back(words[i]);
|
||||
} else if (opcode == kOpDecorate && wordCount >= 3) {
|
||||
PendingDecoration entry{words[w + 1], -1, words[w + 2], {}};
|
||||
for (SizeT i = w + 3; i < w + wordCount; ++i) entry.operands.push_back(words[i]);
|
||||
pending.push_back(Move(entry));
|
||||
} else if (opcode == kOpMemberDecorate && wordCount >= 4) {
|
||||
PendingDecoration entry{words[w + 1], static_cast<Int>(words[w + 2]), words[w + 3], {}};
|
||||
for (SizeT i = w + 4; i < w + wordCount; ++i) entry.operands.push_back(words[i]);
|
||||
pending.push_back(Move(entry));
|
||||
}
|
||||
w += wordCount;
|
||||
}
|
||||
|
||||
const auto label = [&](const Uint32 id) {
|
||||
const auto it = names.find(id);
|
||||
if (it != names.end() && !it->second.empty()) return it->second;
|
||||
return String("%") + std::to_string(id);
|
||||
};
|
||||
|
||||
for (const auto& entry : pending) {
|
||||
const char* decorationName = DecorationName(entry.decoration);
|
||||
if (decorationName == nullptr) continue; // relocation-irrelevant decorations
|
||||
String line = label(entry.target);
|
||||
if (entry.member >= 0) line += "[member " + std::to_string(entry.member) + "]";
|
||||
line += " ";
|
||||
line += decorationName;
|
||||
line += " =";
|
||||
for (const Uint32 operand : entry.operands) {
|
||||
if (entry.decoration == 11) {
|
||||
const char* builtIn = BuiltInName(operand);
|
||||
line += String(" ") + (builtIn != nullptr ? builtIn : std::to_string(operand));
|
||||
} else {
|
||||
line += " " + std::to_string(operand);
|
||||
}
|
||||
}
|
||||
digest.decorations.push_back(line);
|
||||
}
|
||||
std::sort(digest.decorations.begin(), digest.decorations.end());
|
||||
|
||||
for (const Uint32 id : interfaceIds) digest.interfaceNames.push_back(label(id));
|
||||
std::sort(digest.interfaceNames.begin(), digest.interfaceNames.end());
|
||||
return digest;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// The snapshot under test
|
||||
// ---------------------------------------------------------------------------------
|
||||
struct XfbSnapshot {
|
||||
GLint linkStatus = GL_FALSE;
|
||||
String infoLog;
|
||||
GLenum bufferMode = 0;
|
||||
Uint32 packedStride = 0;
|
||||
Bool needsScattered = false;
|
||||
Int varyingNameMaxLength = 0;
|
||||
Vector<Uint32> strides;
|
||||
Vector<String> varyings;
|
||||
GLenum gsInputPrimitive = 0;
|
||||
Bool gsStripCaptureFixup = false;
|
||||
Vector<Uint32> gsStripTriangles;
|
||||
Int uniformBlockCount = 0;
|
||||
Vector<Uint> uniformBlockBindings;
|
||||
Uint maxUniformLocation = 0;
|
||||
GLint activeAttributes = 0;
|
||||
GLint activeUniforms = 0;
|
||||
Vector<SpirvDigest> spirv;
|
||||
};
|
||||
|
||||
String QueryProgramInfoLog(const GLuint program) {
|
||||
GLint length = 0;
|
||||
GetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
|
||||
if (length <= 0) return String();
|
||||
std::vector<GLchar> buffer(static_cast<size_t>(length));
|
||||
GLsizei written = 0;
|
||||
GetProgramInfoLog(program, length, &written, buffer.data());
|
||||
return String(buffer.data(), static_cast<size_t>(written));
|
||||
}
|
||||
|
||||
XfbSnapshot Capture(const GLuint program) {
|
||||
XfbSnapshot snapshot;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &snapshot.linkStatus);
|
||||
snapshot.infoLog = QueryProgramInfoLog(program);
|
||||
|
||||
const auto& object = MG_State::pGLContext->GetProgramObject(program);
|
||||
if (object == nullptr) {
|
||||
snapshot.infoLog += "<no program object>";
|
||||
return snapshot;
|
||||
}
|
||||
snapshot.bufferMode = object->GetTransformFeedbackBufferMode();
|
||||
snapshot.packedStride = object->GetTransformFeedbackPackedStride();
|
||||
snapshot.needsScattered = object->NeedsScatteredTransformFeedbackCapture();
|
||||
snapshot.varyingNameMaxLength = object->GetTransformFeedbackVaryingMaxLength();
|
||||
snapshot.gsInputPrimitive = object->GetGeometryInputType();
|
||||
snapshot.gsStripCaptureFixup = object->HasGsTriangleStripCaptureFixup();
|
||||
snapshot.gsStripTriangles = object->GetGsStripTriangles();
|
||||
// The rest of ProgramFactory::ComputeHash's input set, so "the backend cache key is
|
||||
// unchanged" is something this binary measures rather than assumes.
|
||||
snapshot.uniformBlockCount = object->GetActiveUniformBlocksCount();
|
||||
for (Int i = 0; i < snapshot.uniformBlockCount; ++i) {
|
||||
snapshot.uniformBlockBindings.push_back(object->GetUniformBlockBinding(static_cast<Uint>(i)));
|
||||
}
|
||||
snapshot.maxUniformLocation = object->GetMaxUniformLocation();
|
||||
GetProgramiv(program, GL_ACTIVE_ATTRIBUTES, &snapshot.activeAttributes);
|
||||
GetProgramiv(program, GL_ACTIVE_UNIFORMS, &snapshot.activeUniforms);
|
||||
for (SizeT i = 0; i < object->GetTransformFeedbackBufferCount(); ++i) {
|
||||
snapshot.strides.push_back(object->GetTransformFeedbackStride(static_cast<Uint32>(i)));
|
||||
}
|
||||
for (const auto& varying : object->GetTransformFeedbackVaryings()) {
|
||||
snapshot.varyings.push_back(varying.name + " type=0x" + [&] {
|
||||
char buffer[16];
|
||||
std::snprintf(buffer, sizeof(buffer), "%04X", static_cast<unsigned>(varying.type));
|
||||
return String(buffer);
|
||||
}() + " size=" + std::to_string(varying.size) + " buf=" + std::to_string(varying.bufferIndex) +
|
||||
" off=" + std::to_string(varying.offsetBytes) +
|
||||
" bytes=" + std::to_string(varying.byteSize) +
|
||||
" packedOff=" + std::to_string(varying.packedOffsetBytes));
|
||||
}
|
||||
for (const auto& module : object->GetGeneratedSpirv()) {
|
||||
snapshot.spirv.push_back(DigestSpirv(module));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// One text blob per snapshot, so a mismatch shows up as a readable gtest diff.
|
||||
String Render(const XfbSnapshot& snapshot, const Bool includeSpirvHash) {
|
||||
String text;
|
||||
text += "linkStatus = " + std::to_string(snapshot.linkStatus) + "\n";
|
||||
if (!snapshot.infoLog.empty()) text += "infoLog = " + snapshot.infoLog + "\n";
|
||||
text += "xfbBufferMode = " + std::to_string(snapshot.bufferMode) + "\n";
|
||||
text += "xfbPackedStride = " + std::to_string(snapshot.packedStride) + "\n";
|
||||
text += "xfbNeedsScatter = " + std::to_string(static_cast<int>(snapshot.needsScattered)) + "\n";
|
||||
text += "xfbNameMaxLength = " + std::to_string(snapshot.varyingNameMaxLength) + "\n";
|
||||
text += "xfbStrides =";
|
||||
for (const Uint32 stride : snapshot.strides) text += " " + std::to_string(stride);
|
||||
text += "\n";
|
||||
text += "xfbVaryings (" + std::to_string(snapshot.varyings.size()) + "):\n";
|
||||
for (const String& varying : snapshot.varyings) text += " " + varying + "\n";
|
||||
text += "gsInputPrimitive = " + std::to_string(snapshot.gsInputPrimitive) + "\n";
|
||||
text += "gsStripFixup = " + std::to_string(static_cast<int>(snapshot.gsStripCaptureFixup)) + "\n";
|
||||
text += "gsStripTriangles =";
|
||||
for (const Uint32 triangle : snapshot.gsStripTriangles) text += " " + std::to_string(triangle);
|
||||
text += "\n";
|
||||
text += "uniformBlocks = " + std::to_string(snapshot.uniformBlockCount) + " bindings:";
|
||||
for (const Uint binding : snapshot.uniformBlockBindings) text += " " + std::to_string(binding);
|
||||
text += "\n";
|
||||
text += "maxUniformLoc = " + std::to_string(snapshot.maxUniformLocation) + "\n";
|
||||
text += "activeAttribs = " + std::to_string(snapshot.activeAttributes) + "\n";
|
||||
text += "activeUniforms = " + std::to_string(snapshot.activeUniforms) + "\n";
|
||||
for (SizeT i = 0; i < snapshot.spirv.size(); ++i) {
|
||||
const SpirvDigest& digest = snapshot.spirv[i];
|
||||
text += "spirv[" + std::to_string(i) + "] words=" + std::to_string(digest.wordCount);
|
||||
if (includeSpirvHash) {
|
||||
char buffer[32];
|
||||
std::snprintf(buffer, sizeof(buffer), " hash=%016llX",
|
||||
static_cast<unsigned long long>(digest.hash));
|
||||
text += buffer;
|
||||
}
|
||||
text += "\n";
|
||||
text += " interface:";
|
||||
for (const String& name : digest.interfaceNames) text += " " + name;
|
||||
text += "\n";
|
||||
for (const String& decoration : digest.decorations) text += " " + decoration + "\n";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Program construction through the real GL entry points
|
||||
// ---------------------------------------------------------------------------------
|
||||
GLuint BuildProgram(const String& vertexSource, const String& fragmentSource,
|
||||
const Vector<String>& xfbVaryings, const GLenum bufferMode) {
|
||||
const GLuint vertexShader = CreateShader(GL_VERTEX_SHADER);
|
||||
const char* vertexText = vertexSource.c_str();
|
||||
ShaderSource(vertexShader, 1, &vertexText, nullptr);
|
||||
CompileShader(vertexShader);
|
||||
|
||||
const GLuint fragmentShader = CreateShader(GL_FRAGMENT_SHADER);
|
||||
const char* fragmentText = fragmentSource.c_str();
|
||||
ShaderSource(fragmentShader, 1, &fragmentText, nullptr);
|
||||
CompileShader(fragmentShader);
|
||||
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vertexShader);
|
||||
AttachShader(program, fragmentShader);
|
||||
if (!xfbVaryings.empty()) {
|
||||
std::vector<const GLchar*> names;
|
||||
names.reserve(xfbVaryings.size());
|
||||
for (const String& name : xfbVaryings) names.push_back(name.c_str());
|
||||
TransformFeedbackVaryings(program, static_cast<GLsizei>(names.size()), names.data(), bufferMode);
|
||||
}
|
||||
LinkProgram(program);
|
||||
DeleteShader(vertexShader);
|
||||
DeleteShader(fragmentShader);
|
||||
return program;
|
||||
}
|
||||
|
||||
// A, exactly as the CTS builds it for the failing sub-case (1 clip distance, dynamic
|
||||
// setter, clip function 0). Returns the program so the caller can keep it alive, which
|
||||
// is what the CTS does too (it holds m_program across the whole case).
|
||||
GLuint LinkClipDistanceProgram(const int clipCount, const char* version) {
|
||||
return BuildProgram(ClipDistanceVs(clipCount, version), ClipDistanceFs(version), {},
|
||||
GL_INTERLEAVED_ATTRIBS);
|
||||
}
|
||||
|
||||
GLuint LinkSkipComponentsProgram(const char* version, const String& salt = String()) {
|
||||
return BuildProgram(SkipComponentsVs(version, salt), SkipComponentsFs(version),
|
||||
SkipComponentsVaryings(), GL_INTERLEAVED_ATTRIBS);
|
||||
}
|
||||
|
||||
GLuint LinkCaptureInterleavedProgram(const int userVaryings, const char* version) {
|
||||
return BuildProgram(CaptureInterleavedVs(userVaryings, version), CaptureInterleavedFs(version),
|
||||
CaptureInterleavedVaryings(userVaryings), GL_INTERLEAVED_ATTRIBS);
|
||||
}
|
||||
|
||||
GLuint BuildProgramWithGeometry(const String& vertexSource, const String& geometrySource,
|
||||
const String& fragmentSource, const Vector<String>& xfbVaryings) {
|
||||
const auto makeShader = [](const GLenum type, const String& source) {
|
||||
const GLuint shader = CreateShader(type);
|
||||
const char* text = source.c_str();
|
||||
ShaderSource(shader, 1, &text, nullptr);
|
||||
CompileShader(shader);
|
||||
return shader;
|
||||
};
|
||||
const GLuint vertexShader = makeShader(GL_VERTEX_SHADER, vertexSource);
|
||||
const GLuint geometryShader = makeShader(GL_GEOMETRY_SHADER, geometrySource);
|
||||
const GLuint fragmentShader = makeShader(GL_FRAGMENT_SHADER, fragmentSource);
|
||||
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vertexShader);
|
||||
AttachShader(program, geometryShader);
|
||||
AttachShader(program, fragmentShader);
|
||||
std::vector<const GLchar*> names;
|
||||
names.reserve(xfbVaryings.size());
|
||||
for (const String& name : xfbVaryings) names.push_back(name.c_str());
|
||||
TransformFeedbackVaryings(program, static_cast<GLsizei>(names.size()), names.data(),
|
||||
GL_INTERLEAVED_ATTRIBS);
|
||||
LinkProgram(program);
|
||||
DeleteShader(vertexShader);
|
||||
DeleteShader(geometryShader);
|
||||
DeleteShader(fragmentShader);
|
||||
return program;
|
||||
}
|
||||
|
||||
GLuint LinkCaptureGeometryProgram(const int userVaryings, const char* outPrimitive) {
|
||||
return BuildProgramWithGeometry(kGeometryBlankVs, CaptureGeometryGs(userVaryings, outPrimitive),
|
||||
CaptureInterleavedFs("#version 130"),
|
||||
CaptureInterleavedVaryings(userVaryings));
|
||||
}
|
||||
|
||||
// Reinstalls pGLContext: new ProgramState, new P0b preprocess cache, new stage-6
|
||||
// adoption map. glslang's process globals are untouched on purpose.
|
||||
void FreshContext() { MG_State::Init(); }
|
||||
|
||||
class XfbFrontendOrderInvarianceTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override { MobileGL::Initialize(); }
|
||||
void TearDown() override { FreshContext(); }
|
||||
};
|
||||
|
||||
// The two B shapes, run through one lambda so every case tests both.
|
||||
struct BCase {
|
||||
const char* label;
|
||||
GLuint (*link)();
|
||||
};
|
||||
|
||||
GLuint LinkSkip150() { return LinkSkipComponentsProgram("#version 150"); }
|
||||
GLuint LinkCapture130() { return LinkCaptureInterleavedProgram(15, "#version 130"); }
|
||||
GLuint LinkSkip400() { return LinkSkipComponentsProgram("#version 400"); }
|
||||
GLuint LinkCapture400() { return LinkCaptureInterleavedProgram(15, "#version 400"); }
|
||||
|
||||
GLuint LinkGeometryPoints() { return LinkCaptureGeometryProgram(15, "points"); }
|
||||
GLuint LinkGeometryTriangleStrip() { return LinkCaptureGeometryProgram(15, "triangle_strip"); }
|
||||
|
||||
const BCase kBCases[] = {
|
||||
{"skip_components@150", &LinkSkip150},
|
||||
{"capture_interleaved@130", &LinkCapture130},
|
||||
{"skip_components@400", &LinkSkip400},
|
||||
{"capture_interleaved@400", &LinkCapture400},
|
||||
{"capture_geometry@points", &LinkGeometryPoints},
|
||||
{"capture_geometry@triangle_strip", &LinkGeometryTriangleStrip},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// The core A/B comparison, parameterized on everything that could matter.
|
||||
// ---------------------------------------------------------------------------------
|
||||
struct AbResult {
|
||||
String control;
|
||||
String poisoned;
|
||||
};
|
||||
|
||||
AbResult RunAb(const BCase& bCase, const int clipCount, const char* clipVersion,
|
||||
const Bool freshContextForControl, const Bool includeSpirvHash) {
|
||||
AbResult result;
|
||||
|
||||
// CONTROL: B alone, in a context that has never seen A.
|
||||
if (freshContextForControl) FreshContext();
|
||||
{
|
||||
const GLuint program = bCase.link();
|
||||
result.control = Render(Capture(program), includeSpirvHash);
|
||||
DeleteProgram(program);
|
||||
}
|
||||
|
||||
// POISONED: A first, then B, in ONE context - the glcts shape.
|
||||
FreshContext();
|
||||
{
|
||||
const GLuint clipProgram = LinkClipDistanceProgram(clipCount, clipVersion);
|
||||
GLint clipLinked = GL_FALSE;
|
||||
GetProgramiv(clipProgram, GL_LINK_STATUS, &clipLinked);
|
||||
// The A program is deliberately kept alive across B's link, exactly as the CTS
|
||||
// holds its program object for the duration of the case.
|
||||
const GLuint program = bCase.link();
|
||||
result.poisoned = Render(Capture(program), includeSpirvHash);
|
||||
if (clipLinked != GL_TRUE) {
|
||||
result.poisoned += "\n<<< A DID NOT LINK: " + QueryProgramInfoLog(clipProgram) + " >>>\n";
|
||||
}
|
||||
DeleteProgram(program);
|
||||
DeleteProgram(clipProgram);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 1. The headline question, both flag states, both B shapes, several clip counts.
|
||||
// -------------------------------------------------------------------------------------
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, AsyncOn_ClipDistanceBeforeXfbChangesNothingInTheFrontend) {
|
||||
const AsyncModeScope async(true);
|
||||
ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled());
|
||||
|
||||
for (const BCase& bCase : kBCases) {
|
||||
for (const int clipCount : {1, 4, 8}) {
|
||||
for (const char* clipVersion : {"#version 400", "#version 150", "#version 130"}) {
|
||||
const AbResult result = RunAb(bCase, clipCount, clipVersion, true, true);
|
||||
EXPECT_EQ(result.control, result.poisoned)
|
||||
<< "async=1 B=" << bCase.label << " clipCount=" << clipCount
|
||||
<< " clipVersion=" << clipVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, AsyncOff_ClipDistanceBeforeXfbChangesNothingInTheFrontend) {
|
||||
const AsyncModeScope async(false);
|
||||
|
||||
for (const BCase& bCase : kBCases) {
|
||||
for (const int clipCount : {1, 4, 8}) {
|
||||
for (const char* clipVersion : {"#version 400", "#version 150", "#version 130"}) {
|
||||
const AbResult result = RunAb(bCase, clipCount, clipVersion, true, true);
|
||||
EXPECT_EQ(result.control, result.poisoned)
|
||||
<< "async=0 B=" << bCase.label << " clipCount=" << clipCount
|
||||
<< " clipVersion=" << clipVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 2. Repetition: the CTS incidence with async off is ~2.4%, i.e. roughly 1 in 40 runs, so
|
||||
// a single comparison would miss it. 60 repetitions of the same A->B pair inside one
|
||||
// process, each with its own fresh context, is the headless equivalent.
|
||||
// -------------------------------------------------------------------------------------
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, RepeatedAbPairsAreBitStable) {
|
||||
for (const Bool async : {true, false}) {
|
||||
const AsyncModeScope scope(async);
|
||||
String reference;
|
||||
for (int repetition = 0; repetition < 60; ++repetition) {
|
||||
const AbResult result = RunAb(kBCases[0], 1, "#version 400", repetition == 0, true);
|
||||
if (repetition == 0) {
|
||||
reference = result.control;
|
||||
ASSERT_EQ(reference, result.poisoned) << "async=" << async << " first repetition";
|
||||
}
|
||||
EXPECT_EQ(reference, result.poisoned) << "async=" << async << " repetition " << repetition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 3. Same context, no reset between A and B, and B's source made unique so neither the
|
||||
// P0b preprocess cache nor the stage-6 adoption map can serve it. If the divergence
|
||||
// survives this, no per-source memo is carrying it.
|
||||
// -------------------------------------------------------------------------------------
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, NoMemoHitPossibleForB) {
|
||||
for (const Bool async : {true, false}) {
|
||||
const AsyncModeScope scope(async);
|
||||
|
||||
FreshContext();
|
||||
const GLuint controlProgram = LinkSkipComponentsProgram("#version 150", "// salt control\n");
|
||||
const String control = Render(Capture(controlProgram), false);
|
||||
DeleteProgram(controlProgram);
|
||||
|
||||
FreshContext();
|
||||
const GLuint clipProgram = LinkClipDistanceProgram(1, "#version 400");
|
||||
const GLuint poisonedProgram = LinkSkipComponentsProgram("#version 150", "// salt poisoned\n");
|
||||
const String poisoned = Render(Capture(poisonedProgram), false);
|
||||
DeleteProgram(poisonedProgram);
|
||||
DeleteProgram(clipProgram);
|
||||
|
||||
EXPECT_EQ(control, poisoned) << "async=" << async << " (SPIR-V hash excluded: the salt comment "
|
||||
"is stripped by the preprocessor but ids can still renumber)";
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 4. Bisection: A and B in one context WITHOUT the reset in between, so ProgramState, the
|
||||
// P0b cache and the adoption map all carry over exactly as they do in glcts, compared
|
||||
// against A and B separated by a fresh context. A difference here but not in case 1
|
||||
// would put the poison in per-context state; no difference in either puts it outside
|
||||
// the frontend entirely.
|
||||
// -------------------------------------------------------------------------------------
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, PerContextStateBisection) {
|
||||
for (const Bool async : {true, false}) {
|
||||
const AsyncModeScope scope(async);
|
||||
|
||||
// (a) A, fresh context, then B: per-context state cleared, process globals kept.
|
||||
FreshContext();
|
||||
const GLuint clipA = LinkClipDistanceProgram(1, "#version 400");
|
||||
DeleteProgram(clipA);
|
||||
FreshContext();
|
||||
const GLuint separated = LinkSkipComponentsProgram("#version 150");
|
||||
const String separatedText = Render(Capture(separated), true);
|
||||
DeleteProgram(separated);
|
||||
|
||||
// (b) A then B, same context, A kept alive.
|
||||
FreshContext();
|
||||
const GLuint clipB = LinkClipDistanceProgram(1, "#version 400");
|
||||
const GLuint together = LinkSkipComponentsProgram("#version 150");
|
||||
const String togetherText = Render(Capture(together), true);
|
||||
DeleteProgram(together);
|
||||
DeleteProgram(clipB);
|
||||
|
||||
EXPECT_EQ(separatedText, togetherText) << "async=" << async;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 5. The interleaving glcts actually produces: many cases in a row, A somewhere in the
|
||||
// middle, every B compared against the very first B. This is the one that catches a
|
||||
// poison that needs more than one link to develop.
|
||||
// -------------------------------------------------------------------------------------
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, LongCaseSequenceLikeGlcts) {
|
||||
for (const Bool async : {true, false}) {
|
||||
const AsyncModeScope scope(async);
|
||||
FreshContext();
|
||||
|
||||
String reference;
|
||||
Vector<GLuint> keepAlive;
|
||||
for (int step = 0; step < 12; ++step) {
|
||||
if (step == 4) {
|
||||
// The clip_distance case: every clip count, both setters' shapes.
|
||||
for (const int clipCount : {1, 2, 4, 8}) {
|
||||
keepAlive.push_back(LinkClipDistanceProgram(clipCount, "#version 400"));
|
||||
}
|
||||
}
|
||||
const GLuint program = LinkSkipComponentsProgram("#version 150");
|
||||
const String text = Render(Capture(program), true);
|
||||
if (step == 0) {
|
||||
reference = text;
|
||||
} else {
|
||||
EXPECT_EQ(reference, text) << "async=" << async << " step " << step;
|
||||
}
|
||||
keepAlive.push_back(program);
|
||||
}
|
||||
for (const GLuint program : keepAlive) DeleteProgram(program);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 6. Fresh-process controls. Run exactly one of these with --gtest_filter to get a
|
||||
// process that has linked nothing else, then diff the two printed blobs by hand:
|
||||
// ./XfbFrontendOrderInvarianceTest --gtest_filter='*FreshProcessControlB*'
|
||||
// ./XfbFrontendOrderInvarianceTest --gtest_filter='*FreshProcessAThenB*'
|
||||
// Both print their snapshot to stdout; they never fail on their own.
|
||||
// -------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 7. The one shape only async can produce: A's link is still IN FLIGHT when B's shaders
|
||||
// are compiled and B is linked. Nothing joins A until after B has published. If the
|
||||
// poison rode a worker thread_local (glslang's pool allocator, its TLS parse context)
|
||||
// rather than any per-context container, this is where it would show.
|
||||
// N copies of A are enqueued first so the pool really has a backlog.
|
||||
// -------------------------------------------------------------------------------------
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, BLinksWhileAIsStillInFlight) {
|
||||
const AsyncModeScope async(true);
|
||||
ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled());
|
||||
|
||||
FreshContext();
|
||||
const GLuint controlProgram = LinkSkipComponentsProgram("#version 150");
|
||||
const String control = Render(Capture(controlProgram), true);
|
||||
DeleteProgram(controlProgram);
|
||||
|
||||
for (int repetition = 0; repetition < 20; ++repetition) {
|
||||
FreshContext();
|
||||
Vector<GLuint> clipPrograms;
|
||||
// Enqueued, never read: every one of these links is outstanding while B goes
|
||||
// through compile + link on the same pool.
|
||||
for (const int clipCount : {1, 2, 3, 4, 5, 6, 7, 8}) {
|
||||
clipPrograms.push_back(LinkClipDistanceProgram(clipCount, "#version 400"));
|
||||
}
|
||||
const GLuint program = LinkSkipComponentsProgram("#version 150");
|
||||
const String poisoned = Render(Capture(program), true);
|
||||
EXPECT_EQ(control, poisoned) << "repetition " << repetition;
|
||||
DeleteProgram(program);
|
||||
for (const GLuint clipProgram : clipPrograms) DeleteProgram(clipProgram);
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: every shape this binary compares must actually LINK, otherwise "control ==
|
||||
// poisoned" is the trivially true statement that two failures look alike.
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, EveryShapeActuallyLinks) {
|
||||
const AsyncModeScope async(true);
|
||||
for (const int clipCount : {1, 2, 4, 8}) {
|
||||
for (const char* version : {"#version 400", "#version 150", "#version 130"}) {
|
||||
FreshContext();
|
||||
const GLuint program = LinkClipDistanceProgram(clipCount, version);
|
||||
GLint linked = GL_FALSE;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &linked);
|
||||
EXPECT_EQ(linked, GL_TRUE) << "A clipCount=" << clipCount << " " << version << ": "
|
||||
<< QueryProgramInfoLog(program);
|
||||
DeleteProgram(program);
|
||||
}
|
||||
}
|
||||
for (const BCase& bCase : kBCases) {
|
||||
FreshContext();
|
||||
const GLuint program = bCase.link();
|
||||
GLint linked = GL_FALSE;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &linked);
|
||||
EXPECT_EQ(linked, GL_TRUE) << "B " << bCase.label << ": " << QueryProgramInfoLog(program);
|
||||
std::printf("=== B shape %s ===\n%s\n", bCase.label, Render(Capture(program), true).c_str());
|
||||
DeleteProgram(program);
|
||||
}
|
||||
}
|
||||
|
||||
// Writes B's raw SPIR-V modules next to the binary so they can be run through spirv-dis
|
||||
// by hand. MOBILEGL_XFB_INVARIANCE_DUMP_DIR selects the directory; unset means no dump.
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, DumpBSpirvForDisassembly) {
|
||||
const char* directory = std::getenv("MOBILEGL_XFB_INVARIANCE_DUMP_DIR");
|
||||
if (directory == nullptr) {
|
||||
GTEST_SKIP() << "set MOBILEGL_XFB_INVARIANCE_DUMP_DIR to dump";
|
||||
}
|
||||
const AsyncModeScope async(true);
|
||||
struct Dump {
|
||||
const char* tag;
|
||||
Bool withClipDistanceFirst;
|
||||
};
|
||||
for (const Dump& dump : {Dump{"control", false}, Dump{"poisoned", true}}) {
|
||||
for (const BCase& bCase : kBCases) {
|
||||
FreshContext();
|
||||
GLuint clipProgram = 0;
|
||||
if (dump.withClipDistanceFirst) clipProgram = LinkClipDistanceProgram(1, "#version 400");
|
||||
const GLuint program = bCase.link();
|
||||
const auto& object = MG_State::pGLContext->GetProgramObject(program);
|
||||
const auto& modules = object->GetGeneratedSpirv();
|
||||
for (SizeT i = 0; i < modules.size(); ++i) {
|
||||
String path = String(directory) + "/" + dump.tag + "-" + bCase.label + "-" +
|
||||
std::to_string(i) + ".spv";
|
||||
std::replace(path.begin() + std::strlen(directory) + 1, path.end(), '@', '_');
|
||||
std::FILE* file = std::fopen(path.c_str(), "wb");
|
||||
ASSERT_NE(file, nullptr) << path;
|
||||
std::fwrite(modules[i].data(), sizeof(unsigned), modules[i].size(), file);
|
||||
std::fclose(file);
|
||||
}
|
||||
DeleteProgram(program);
|
||||
if (clipProgram != 0) DeleteProgram(clipProgram);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, FreshProcessControlB) {
|
||||
const AsyncModeScope async(true);
|
||||
const GLuint program = LinkSkipComponentsProgram("#version 150");
|
||||
std::printf("=== FreshProcessControlB ===\n%s\n", Render(Capture(program), true).c_str());
|
||||
DeleteProgram(program);
|
||||
}
|
||||
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, FreshProcessAThenB) {
|
||||
const AsyncModeScope async(true);
|
||||
const GLuint clipProgram = LinkClipDistanceProgram(1, "#version 400");
|
||||
const GLuint program = LinkSkipComponentsProgram("#version 150");
|
||||
std::printf("=== FreshProcessAThenB ===\n%s\n", Render(Capture(program), true).c_str());
|
||||
DeleteProgram(program);
|
||||
DeleteProgram(clipProgram);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user