mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-12 06:08:30 +09:00
Compare commits
53
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 | ||
|
|
990e518e33 | ||
|
|
25a8f51db5 | ||
|
|
8f2b766b56 | ||
|
|
b9d8ad0421 | ||
|
|
f8069c0624 | ||
|
|
d0aae85da2 | ||
|
|
b904658b10 | ||
|
|
4b3fd11462 | ||
|
|
9be5d95440 | ||
|
|
d49d79a64b | ||
|
|
f5761ea1f3 | ||
|
|
b3f774d2c0 | ||
|
|
d524330032 | ||
|
|
f2d210b12d | ||
|
|
fd40960f70 | ||
|
|
49aab57f03 |
@@ -177,9 +177,13 @@ jobs:
|
||||
uses: lukka/get-cmake@v4.3.3
|
||||
|
||||
- name: Install runtime dependencies
|
||||
# libegl-mesa0 is the EGL vendor library itself: DriverBench brings up a
|
||||
# real GL context, and libegl1 is only glvnd's dispatch. It normally
|
||||
# arrives as a Recommends of libegl1, which is too quiet a dependency for
|
||||
# the one job that needs a working driver.
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libvulkan1 libegl1 libgles2 libgl1-mesa-dri mesa-vulkan-drivers
|
||||
sudo apt-get install -y libvulkan1 libegl1 libegl-mesa0 libgles2 libgl1-mesa-dri mesa-vulkan-drivers
|
||||
|
||||
- name: Download Linux runtime
|
||||
uses: actions/download-artifact@v8
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,45 +21,126 @@ 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>;
|
||||
using BackendMap = UnorderedMap<StateObject*, BackendPtr>;
|
||||
using StateRefMap = UnorderedMap<StateObject*, StateWeakPtr>;
|
||||
|
||||
// The backend twin and the weak reference that decides whether the raw key still
|
||||
// names the state object the twin was built for. Both live in one entry: a
|
||||
// separate liveness map answered nothing the backend probe had not already found
|
||||
// and cost a second hash lookup on every Find, which the draw path runs ~10 times.
|
||||
struct Entry {
|
||||
BackendPtr backend;
|
||||
StateWeakPtr stateRef;
|
||||
};
|
||||
using BackendMap = UnorderedMap<StateObject*, Entry>;
|
||||
using iterator = typename BackendMap::iterator;
|
||||
using const_iterator = typename BackendMap::const_iterator;
|
||||
|
||||
BackendPtr& GetOrCreate(const StatePtr& stateObj) {
|
||||
MOBILEGL_ASSERT(stateObj != nullptr, "State object must not be null");
|
||||
|
||||
auto* key = stateObj.get();
|
||||
auto trackedStateIt = m_stateRefs.find(key);
|
||||
if (trackedStateIt != m_stateRefs.end() && trackedStateIt->second.expired()) {
|
||||
EraseByKey(key);
|
||||
// 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
|
||||
// to a new object: its twin describes ids the new state object never made.
|
||||
entry.backend.reset();
|
||||
}
|
||||
m_stateRefs[key] = stateObj;
|
||||
return m_backendObjects[key];
|
||||
entry.stateRef = stateObj;
|
||||
return entry.backend;
|
||||
}
|
||||
|
||||
iterator find(StateObject* stateObj) {
|
||||
if (!IsAlive(stateObj)) {
|
||||
EraseByKey(stateObj);
|
||||
return m_backendObjects.end();
|
||||
// Null when no live state object owns this key. The result points into the map, so
|
||||
// it stays valid only until the next GetOrCreate/Find/CollectGarbage on this registry.
|
||||
BackendPtr* Find(StateObject* stateObj) {
|
||||
const auto entryIt = m_entries.find(stateObj);
|
||||
if (entryIt == m_entries.end()) {
|
||||
return nullptr;
|
||||
}
|
||||
return m_backendObjects.find(stateObj);
|
||||
if (entryIt->second.stateRef.expired()) {
|
||||
m_entries.erase(entryIt);
|
||||
return nullptr;
|
||||
}
|
||||
return &entryIt->second.backend;
|
||||
}
|
||||
|
||||
const_iterator find(StateObject* stateObj) const {
|
||||
return const_cast<StateBackendObjectRegistry*>(this)->find(stateObj);
|
||||
const BackendPtr* Find(StateObject* stateObj) const {
|
||||
return const_cast<StateBackendObjectRegistry*>(this)->Find(stateObj);
|
||||
}
|
||||
|
||||
iterator begin() { return m_backendObjects.begin(); }
|
||||
const_iterator begin() const { return m_backendObjects.begin(); }
|
||||
iterator end() { return m_backendObjects.end(); }
|
||||
const_iterator end() const { return m_backendObjects.end(); }
|
||||
iterator begin() { return m_entries.begin(); }
|
||||
const_iterator begin() const { return m_entries.begin(); }
|
||||
iterator end() { return m_entries.end(); }
|
||||
const_iterator end() const { return m_entries.end(); }
|
||||
|
||||
void CollectGarbageIfNeeded() {
|
||||
++m_gcTick;
|
||||
@@ -73,19 +154,6 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
void CollectGarbageNow() { CollectGarbage(); }
|
||||
|
||||
private:
|
||||
bool IsAlive(StateObject* stateObj) const {
|
||||
const auto trackedStateIt = m_stateRefs.find(stateObj);
|
||||
if (trackedStateIt == m_stateRefs.end()) {
|
||||
return false;
|
||||
}
|
||||
return !trackedStateIt->second.expired();
|
||||
}
|
||||
|
||||
void EraseByKey(StateObject* stateObj) {
|
||||
m_stateRefs.erase(stateObj);
|
||||
m_backendObjects.erase(stateObj);
|
||||
}
|
||||
|
||||
void CollectGarbage() {
|
||||
if (m_isCollecting) {
|
||||
return;
|
||||
@@ -94,16 +162,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
m_isCollecting = true;
|
||||
|
||||
Vector<StateObject*> staleKeys;
|
||||
staleKeys.reserve(m_stateRefs.size());
|
||||
for (const auto& [stateKey, stateWeakRef] : m_stateRefs) {
|
||||
if (stateWeakRef.expired()) {
|
||||
staleKeys.reserve(m_entries.size());
|
||||
for (const auto& [stateKey, entry] : m_entries) {
|
||||
if (entry.stateRef.expired()) {
|
||||
staleKeys.push_back(stateKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (auto* stateKey : staleKeys) {
|
||||
m_stateRefs.erase(stateKey);
|
||||
m_backendObjects.erase(stateKey);
|
||||
m_entries.erase(stateKey);
|
||||
}
|
||||
|
||||
m_isCollecting = false;
|
||||
@@ -111,8 +178,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
|
||||
private:
|
||||
static constexpr Uint32 kGCInterval = 1024;
|
||||
StateRefMap m_stateRefs;
|
||||
BackendMap m_backendObjects;
|
||||
BackendMap m_entries;
|
||||
Uint32 m_gcTick = 0;
|
||||
Bool m_isCollecting = false;
|
||||
};
|
||||
@@ -120,6 +186,43 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
namespace BufferImpl {
|
||||
const GLenum TempBufferTarget = GL_ARRAY_BUFFER;
|
||||
|
||||
// --- Buffer-mutation epoch -------------------------------------------------
|
||||
// Manager-wide monotonic counter: it moves whenever ANY buffer resource may
|
||||
// have gone from draw-clean to dirty. Draw-path memos read it once per pass
|
||||
// (CurrentBufferMutationEpoch, acquire), re-run their IsBufferDrawClean
|
||||
// probes only when it moved, and stamp the PRE-pass value after a pass in
|
||||
// which every probe came up clean - so a concurrent bump lands strictly
|
||||
// after the stamped value and forces a re-probe on the next pass no matter
|
||||
// how the probe interleaved with the mutation. Conservative-correct: a bump
|
||||
// never skips work, it only re-runs the probes once.
|
||||
//
|
||||
// Every clean->dirty transition path bumps it (BumpBufferMutationEpoch,
|
||||
// release, AFTER the mutation lands so an acquire reader that still sees
|
||||
// the old epoch cannot have missed the mutation):
|
||||
// * the frontend BufferBackendOps table - Respecify, SubData,
|
||||
// FlushMappedRange, AcquirePersistentMap, ReadbackFromGpu, OnDestroy -
|
||||
// which every frontend change-serial bump and every pending-range
|
||||
// queueing reaches while ops are registered (upload, orphan/respecify,
|
||||
// map flush/unmap writeback, persistent-map adoption, delete/pooling);
|
||||
// * backend-initiated shadow writebacks that bump the frontend change
|
||||
// serial without an op: transform-feedback capture readback
|
||||
// (XfbImpl::ReadbackCapturedRanges and the scatter path) and every
|
||||
// pack-PBO WritebackFromBackend site (glReadPixels/glGetTexImage);
|
||||
// * RegisterBufferBackendOps/UnregisterBufferBackendOps - while ops are
|
||||
// unregistered, frontend writes advance serials silently, so both edges
|
||||
// of that window re-open every memo;
|
||||
// * OnBackendContextDestroyed - the buffer context generation moved, so
|
||||
// every previously clean resource is invalid.
|
||||
// NOT bumped (cleanliness provably unchanged): MarkGpuWritten (the backend
|
||||
// copy is authoritative; IsBufferDrawClean does not consult it),
|
||||
// NotifyContentWrite on a GPU-resident buffer (persistent-mapped resources
|
||||
// are clean by construction), and EnsureBufferResource itself (it only
|
||||
// repairs toward clean). A non-persistent map (draws on it are GL errors
|
||||
// the frontend rejects) sets IsMapped without an op; persistent maps reach
|
||||
// AcquirePersistentMap or (FLUSH_EXPLICIT) publish only via FlushMappedRange.
|
||||
Uint64 CurrentBufferMutationEpoch();
|
||||
void BumpBufferMutationEpoch();
|
||||
|
||||
// The DirectGLES storage behind one frontend buffer. Owned (refcounted) by
|
||||
// the frontend BufferObject; immediate BufferBackendOps keep it current, so
|
||||
// draw-time "sync" reduces to ensuring the storage exists.
|
||||
@@ -145,6 +248,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Bool pendingRespecify = false;
|
||||
VecRange1D pendingRanges;
|
||||
std::mutex pendingMutex;
|
||||
// Buffer-mutation epoch (see CurrentBufferMutationEpoch) at which this
|
||||
// resource last probed IsBufferDrawClean == true, 0 = never (epochs start
|
||||
// at 1). Written only on the draw thread; per-draw resource consumers
|
||||
// (the UBO binding walk) skip the probe while their pre-pass epoch read
|
||||
// matches, exactly like the per-VAO memo stamps.
|
||||
Uint64 drawCleanEpoch = 0;
|
||||
// Zero-copy coherent persistent map (EXT_buffer_storage): the GL store is
|
||||
// immutable, persistently+coherently mapped, and persistentPtr is what the app
|
||||
// (and the frontend PipeResource) write into directly. While set, draw-time
|
||||
@@ -170,6 +279,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
GLESBufferResource* EnsureBufferResource(const SharedPtr<MG_State::GLState::BufferObject>& bufferObject);
|
||||
// Existing resource or nullptr; performs no GL calls.
|
||||
GLESBufferResource* GetBufferResource(MG_State::GLState::BufferObject* bufferObject);
|
||||
// True when EnsureBufferResource(frontend) would provably fall straight through
|
||||
// every branch and do no work — i.e. `resource` is still the frontend's own
|
||||
// resource, its id belongs to the live ES context, and either it is the
|
||||
// zero-copy coherent persistent store (draw-time sync is a no-op by design) or
|
||||
// the storage is initialized at the right size with no pending ops and a synced
|
||||
// change serial while the buffer is not mapped (an active map may owe a
|
||||
// per-draw persistent-range push, so it always takes the full path).
|
||||
// `frontend` must be non-null and alive; the caller guarantees that by holding
|
||||
// (or shadowing something that holds) a SharedPtr to it. Enables the per-VAO
|
||||
// resolved-buffers memo to skip EnsureBufferResource on clean static buffers.
|
||||
Bool IsBufferDrawClean(const MG_State::GLState::BufferObject* frontend, const GLESBufferResource* resource);
|
||||
|
||||
// Deletes GL buffers whose owning frontend objects died (possibly on a
|
||||
// thread without a current ES context). Called from draw-time sync.
|
||||
@@ -255,11 +375,70 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Uint GetBackendVertexArrayId() const { return m_backendVAOId; }
|
||||
void Bind() const;
|
||||
|
||||
// Draw-path memo of SyncNeccessaryBuffers' attribute walk for this VAO: the
|
||||
// distinct enabled-attribute buffers (deduped) and the index buffer, resolved
|
||||
// to their backend resources once. Valid while the VAO's config version is
|
||||
// unchanged — every attach/enable/disable/format mutation bumps it (the same
|
||||
// invariant SyncToBackend's gate already leans on), and the VAO's attribute
|
||||
// SharedPtrs pin each memoed frontend buffer for exactly that long, so the raw
|
||||
// pointers cannot dangle on a hit. Per-buffer cleanliness is NOT memoed here:
|
||||
// each hit re-checks IsBufferDrawClean (resource identity, context generation,
|
||||
// pending ops, change serial) and falls back to EnsureBufferResource for just
|
||||
// the dirty entries via their attribute index. The IBO entry is keyed on the
|
||||
// slot's bound-object identity instead (its slot version is a wrapping Uint16
|
||||
// and is not covered by the config version).
|
||||
struct ResolvedDrawBuffers {
|
||||
struct Entry {
|
||||
MG_State::GLState::BufferObject* frontend = nullptr;
|
||||
BufferImpl::GLESBufferResource* resource = nullptr;
|
||||
Uint8 attribIndex = 0;
|
||||
};
|
||||
Bool valid = false;
|
||||
Uint32 configVersion = 0;
|
||||
Uint count = 0;
|
||||
Array<Entry, MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS> entries;
|
||||
MG_State::GLState::BufferObject* iboFrontend = nullptr;
|
||||
BufferImpl::GLESBufferResource* iboResource = nullptr;
|
||||
// Buffer-mutation epoch (BufferImpl::CurrentBufferMutationEpoch) at which
|
||||
// the LAST probe pass found every entry / the IBO clean; 0 = not stamped
|
||||
// (epochs start at 1). While a stamp matches the pre-pass epoch read, the
|
||||
// probes are skipped outright: any path that can dirty ANY buffer bumps
|
||||
// the epoch (the exhaustive site list lives at the epoch declaration).
|
||||
// The IBO stamp is only trusted together with the bound-object identity
|
||||
// compare - the VAO's index slot can rebind with no epoch or config move.
|
||||
Uint64 vboCleanEpoch = 0;
|
||||
Uint64 iboCleanEpoch = 0;
|
||||
};
|
||||
ResolvedDrawBuffers& GetResolvedDrawBuffersMemo() { return m_resolvedDrawBuffers; }
|
||||
|
||||
// Memo for SyncCurrentVertexAttributeValues: which of a program's ACTIVE
|
||||
// attribute locations lack an enabled array in this VAO (those read the
|
||||
// context's current generic value instead of a buffer). Keyed on the VAO
|
||||
// config version (enable/disable bumps it) and the program's active-location
|
||||
// mask. Hosted per twin — the former function-static single entry missed on
|
||||
// every draw once the app cycled VAOs, re-reading the cold attribute slots.
|
||||
struct PendingAttribValueMask {
|
||||
Bool valid = false;
|
||||
Uint32 configVersion = 0;
|
||||
Uint32 activeMask = 0;
|
||||
Uint32 pendingMask = 0;
|
||||
};
|
||||
PendingAttribValueMask& GetPendingAttribValueMaskMemo() { return m_pendingAttribValueMask; }
|
||||
|
||||
private:
|
||||
ResolvedDrawBuffers m_resolvedDrawBuffers;
|
||||
PendingAttribValueMask m_pendingAttribValueMask;
|
||||
Uint m_backendVAOId = 0;
|
||||
Array<Uint, MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS> m_clientAttributeBufferIds;
|
||||
Bool m_isInitialized = false;
|
||||
Uint16 m_syncedIndexBufferVersion = 0;
|
||||
// Aggregate gate over the per-attribute walk below: the frontend bumps its config
|
||||
// version on every per-attribute version bump (the three Bump*Version functions are
|
||||
// its only writers), so an unchanged config version proves every per-attribute
|
||||
// compare in SyncToBackend would come up clean. The index-buffer slot has its own
|
||||
// version and is NOT covered. The Bool (not a sentinel value) marks "never synced".
|
||||
Bool m_hasSyncedConfigVersion = false;
|
||||
Uint32 m_syncedConfigVersion = 0;
|
||||
Array<MG_State::GLState::VertexAttributeVersion, MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS>
|
||||
m_syncedAttributeVersions;
|
||||
};
|
||||
@@ -336,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;
|
||||
@@ -373,6 +571,38 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
void Bind(GLenum target, Uint unit = TempTextureUnit);
|
||||
Uint GetBackendTextureId() const;
|
||||
|
||||
// Aggregate first-level clean gate for the per-draw trio
|
||||
// SyncTextureParamsToBackend + SyncBuiltinSamplerToBackend +
|
||||
// SyncMipmapsToBackend: EXACTLY the conjunction of their own early-outs
|
||||
// (params version == synced params version; builtin-sampler version ==
|
||||
// synced sampler version; and SyncMipmapsToBackend's cheap gate - stamped
|
||||
// trio + content version + Mipmap storage). True means each of the three
|
||||
// would provably return without work, so the caller may skip the calls;
|
||||
// false only falls through to the three calls, whose own gates re-decide
|
||||
// individually - this gate must never be MORE permissive than they are.
|
||||
// `contextId`/`samplingGeneration` are the frontend context's current
|
||||
// values, hoisted by the caller so a per-draw list walk reads them once
|
||||
// instead of per texture. `t` must be the live frontend texture.
|
||||
Bool IsDrawSyncClean(const MG_State::GLState::ITextureObject* t, Uint64 contextId,
|
||||
Uint64 samplingGeneration) const {
|
||||
if (!m_isInitialized || m_syncedShapeContextId == 0 || m_syncedShapeContextId != contextId ||
|
||||
m_syncedShapeGeneration != samplingGeneration) {
|
||||
return false;
|
||||
}
|
||||
const Uint16 paramsVersion = t->GetTextureParamsVersion();
|
||||
if (m_syncedShapeParamsVersion != paramsVersion || m_syncedTextureParamsVersion != paramsVersion) {
|
||||
return false;
|
||||
}
|
||||
if (m_syncedContentVersion == 0 || m_syncedContentVersion != t->GetContentVersion()) {
|
||||
return false;
|
||||
}
|
||||
const auto& samplerObject = t->GetSamplerObject();
|
||||
if (!samplerObject || m_syncedSamplerVersion != samplerObject->GetVersion()) {
|
||||
return false;
|
||||
}
|
||||
return t->GetStorageType() == TextureStorageType::Mipmap;
|
||||
}
|
||||
|
||||
private:
|
||||
void RecreateBackendTexture();
|
||||
|
||||
@@ -388,6 +618,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// clean probe compares this before rebuilding shape info and scanning
|
||||
// per-level dirty flags; 0 never matches a real version (they start at 1).
|
||||
Uint64 m_syncedContentVersion = 0;
|
||||
// First-level clean gate for SyncMipmapsToBackend, checked before even the
|
||||
// IsComplete()/shape-probe walk. Valid only as a trio with the content and
|
||||
// texture-params versions: the context's sampling-resolution generation moves on
|
||||
// EVERY texture-shape mutation (BumpShapeVersion is the only writer of shape and
|
||||
// unconditionally bumps it), the content version on every CPU pixel mutation, and
|
||||
// the params version covers SetSamples/SetFixedSampleLocations, which bump neither
|
||||
// of the other two but feed the shape probe. The context id pins the generation to
|
||||
// the context that produced it - generations restart at 0 with a new context, and a
|
||||
// texture is owned by exactly one context (share groups are not implemented), so a
|
||||
// mutation can never happen under a context this key does not name. 0 = never
|
||||
// stamped (real context ids start at 1). Backend-side invalidation rides on
|
||||
// m_isInitialized: RequireImageBindableStorage and RecreateBackendTexture clear it.
|
||||
Uint64 m_syncedShapeContextId = 0;
|
||||
Uint64 m_syncedShapeGeneration = 0;
|
||||
Uint16 m_syncedShapeParamsVersion = 0;
|
||||
SamplerParameters m_cacheSamplerParameters;
|
||||
UintVec2 m_cacheLodRange = {0, 1000};
|
||||
FloatVec4 m_cacheBorderColor = {0.0f, 0.0f, 0.0f, 0.0f};
|
||||
@@ -404,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;
|
||||
@@ -484,11 +733,81 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// has to apply the clamp itself.
|
||||
Bool IsFixedPointFallbackReadAttachment();
|
||||
|
||||
extern Array<Uint16, SizeT(FramebufferTarget::FramebufferTargetCount)> g_fboBindVersions;
|
||||
// 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
|
||||
// only meaningful together - see SyncCurrentFBO.
|
||||
//
|
||||
// The binding slot's own version, which changes whenever a different object is bound
|
||||
// to this target. Distinguishes a rebind from an in-place edit, and keeps the raw
|
||||
// pointer below from matching an address the allocator recycled for a new FBO.
|
||||
extern Array<Uint16, SizeT(FramebufferTarget::FramebufferTargetCount)> g_fboSyncedSlotVersions;
|
||||
// Tracks the bound FBO's object version (bumped on any attachment/drawbuffer change)
|
||||
// per target: re-attaching textures or changing draw buffers on an already-bound FBO
|
||||
// must re-sync it even when the binding-slot version has not moved.
|
||||
extern Array<Uint16, SizeT(FramebufferTarget::FramebufferTargetCount)> g_fboSyncedObjectVersions;
|
||||
// Which object was synced. Raw and never dereferenced: only compared for identity.
|
||||
extern Array<MG_State::GLState::FramebufferObject*, SizeT(FramebufferTarget::FramebufferTargetCount)>
|
||||
g_fboSyncedObjects;
|
||||
|
||||
@@ -584,6 +903,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
void InvalidatePackStateCache();
|
||||
} // namespace PixelStoreImpl
|
||||
|
||||
namespace SamplerImpl {
|
||||
class BackendSamplerObject; // for PrgramImpl's sampler-pass memo rows below
|
||||
}
|
||||
|
||||
// Image uniforms take their unit from the layout(binding=N) qualifier baked into
|
||||
// the transpiled ESSL; unlike samplers they must not (and in ES cannot) be
|
||||
// assigned through glUniform1i.
|
||||
@@ -630,6 +953,43 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Float lastAssignedLodBias = 0.0f;
|
||||
};
|
||||
|
||||
// Memo of the whole per-draw sampler-uniform pass (glUniform1i unit
|
||||
// assignments, lod-bias uniform, raw-depth-fetch substitution and the
|
||||
// per-unit sampler-object binds) in BindCurrentProgramWithResources.
|
||||
// The pass is a pure function of the keys below, and its only driver-side
|
||||
// effect is the sampler binding of each sampled unit, so replaying it as
|
||||
// "do nothing" additionally requires those bindings to still be on the
|
||||
// driver - the per-entry row compare against g_boundSamplersCache (the
|
||||
// shadow every sampler bind in this backend already routes through).
|
||||
//
|
||||
// Invalidation enumeration:
|
||||
// * sampler-uniform unit assignment (glUniform1i) and uniform-block
|
||||
// binding edits -> frontend backendStateVersion;
|
||||
// * any texture/sampler bind moving on any unit (incl. the high-water
|
||||
// mark moving) -> unitBindingsEpoch;
|
||||
// * any sampler parameter (incl. lod bias, compare mode) or texture
|
||||
// shape/format change -> samplingGeneration;
|
||||
// * another frontend context -> contextId (never-reused id);
|
||||
// * ES context recreation -> textureContextGeneration;
|
||||
// * relink / backend program rebuild -> SyncToBackend resets `valid`
|
||||
// (it rebuilds m_samplerUniformBindings, whose lastAssignedUnit /
|
||||
// lastAssignedLodBias dedup state this memo leans on);
|
||||
// * any other writer moving a sampled unit's sampler binding
|
||||
// (BindCurrentUnitSamplers on a unit-sampler change, scratch binds)
|
||||
// -> the row snapshot compare.
|
||||
struct SamplerPassMemo {
|
||||
static constexpr SizeT kMaxEntries = 16;
|
||||
Bool valid = false;
|
||||
Uint8 count = 0;
|
||||
Uint64 contextId = 0;
|
||||
Uint64 unitBindingsEpoch = 0;
|
||||
Uint64 samplingGeneration = 0;
|
||||
Uint32 backendStateVersion = 0;
|
||||
Uint textureContextGeneration = 0;
|
||||
Array<Uint8, kMaxEntries> units{};
|
||||
Array<SamplerImpl::BackendSamplerObject*, kMaxEntries> rows{};
|
||||
};
|
||||
|
||||
BackendProgramObjectImpl();
|
||||
~BackendProgramObjectImpl();
|
||||
void SyncToBackend(const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject);
|
||||
@@ -637,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
|
||||
@@ -658,6 +1021,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// reflected size when the transpiled block pads differently).
|
||||
Int GetGlobalUboBackendBlockSize() const { return m_globalUboBackendBlockSize; }
|
||||
BufferImpl::UboRingAllocation& GetGlobalUboRingAllocation() { return m_globalUboRingAllocation; }
|
||||
SamplerPassMemo& GetSamplerPassMemo() { return m_samplerPassMemo; }
|
||||
// Frontend link version this backend program (and its resource caches) was
|
||||
// built from; a mismatch means every link-derived cache here is stale.
|
||||
Uint32 GetSyncedLinkVersion() const { return m_syncedLinkVersion; }
|
||||
@@ -686,6 +1050,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Uint32 m_lastUploadedGlobalUboVersion = ~0u;
|
||||
BufferImpl::UboRingAllocation m_globalUboRingAllocation;
|
||||
Uint32 m_syncedLinkVersion = ~0u;
|
||||
SamplerPassMemo m_samplerPassMemo;
|
||||
};
|
||||
|
||||
extern Uint32 g_snormFallbackClampOutputMask;
|
||||
@@ -700,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,20 +202,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return ShouldUseCaveatFormat(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
|
||||
}
|
||||
|
||||
namespace {
|
||||
Bool BackendFormatAddsAlpha(TextureInternalFormat internalFormat, SizeT targetIndex) {
|
||||
if (!TargetRequiresRenderableFormat(targetIndex)) {
|
||||
return false;
|
||||
}
|
||||
if (pActiveBackendObject && !ShouldUseCaveatFormat(internalFormat, targetIndex)) {
|
||||
return false;
|
||||
}
|
||||
const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
|
||||
const Flags<PixelFormatNormalizeOptionBit> options = GetRuntimeFallbackNormalizeOptions(
|
||||
requestedInternalFormat, GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
|
||||
return static_cast<Bool>(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target) {
|
||||
const SizeT targetIndex =
|
||||
target == TextureTarget::Unknown ? kFormatCapabilityTargetCount : GetFormatCapabilityTargetIndex(target);
|
||||
if (!TargetRequiresRenderableFormat(targetIndex)) {
|
||||
return false;
|
||||
}
|
||||
if (pActiveBackendObject && !ShouldUseCaveatFormat(internalFormat, targetIndex)) {
|
||||
return false;
|
||||
}
|
||||
const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
|
||||
const Flags<PixelFormatNormalizeOptionBit> options =
|
||||
GetRuntimeFallbackNormalizeOptions(requestedInternalFormat,
|
||||
GetRenderTargetNormalizeOptions(targetIndex));
|
||||
return static_cast<Bool>(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget);
|
||||
return BackendFormatAddsAlpha(internalFormat, targetIndex);
|
||||
}
|
||||
|
||||
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat) {
|
||||
return BackendFormatAddsAlpha(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
|
||||
}
|
||||
} // namespace TextureImpl
|
||||
namespace PrgramImpl {
|
||||
@@ -1107,6 +1140,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pixelPackBufferObject) {
|
||||
// WritebackFromBackend bumps change serials with no backend op; re-open
|
||||
// the buffer draw-clean memos (once for the whole row loop).
|
||||
BufferImpl::BumpBufferMutationEpoch();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace ReadbackImpl
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "MG_Util/Metrics/TextureMetrics.h"
|
||||
#include "MG_Util/Miscellany/IndexGenerator.h"
|
||||
#include <atomic>
|
||||
#include <bit>
|
||||
#include <cstring>
|
||||
#include <spirv_reflect.h>
|
||||
|
||||
@@ -231,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,
|
||||
@@ -255,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) {
|
||||
@@ -287,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() {
|
||||
@@ -394,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) {
|
||||
@@ -873,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;
|
||||
@@ -1233,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");
|
||||
@@ -1392,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) {
|
||||
@@ -1444,40 +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;
|
||||
|
||||
// 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] * MG_Util::GetGLTypeSize(type),
|
||||
payload.indexBufferView.indexByteSize);
|
||||
|
||||
auto& param = params[i];
|
||||
|
||||
param.indexCount = count[i];
|
||||
param.instanceCount = 1;
|
||||
param.firstIndex = reinterpret_cast<SizeT>(indices[i]) / MG_Util::GetGLTypeSize(type);
|
||||
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,
|
||||
|
||||
@@ -2379,6 +2379,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
return it->second;
|
||||
}
|
||||
|
||||
// Structural change: the insert below can move every entry of this
|
||||
// open-addressing map, so all memoised entry pointers die here.
|
||||
++m_cacheStructureEpoch;
|
||||
auto& entry = m_cache[hash];
|
||||
entry.hash = hash;
|
||||
entry.lastUsedFrame = m_frameCounter;
|
||||
@@ -2580,6 +2583,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// erase runs ~VkProgramObject (modules/layouts destroyed); notify after
|
||||
// so an observer never observes a half-destroyed entry through a lookup.
|
||||
// Observers only need the handle values to purge their keyed caches.
|
||||
++m_cacheStructureEpoch; // erase moves/kills entries: memoised pointers die
|
||||
it = m_cache.erase(it);
|
||||
if (m_evictionObserver != nullptr) {
|
||||
m_evictionObserver->OnProgramEvicted(hash, descriptorSetLayout);
|
||||
|
||||
@@ -105,8 +105,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// position-invariance quirk (see PipelineFactory::ShouldSuppressDepthWrite).
|
||||
Bool fragmentReplacesDepth = false;
|
||||
// Frame-boundary counter value of the last GetOrCreateProgram hit; drives
|
||||
// cache eviction (see OnFrameBoundary).
|
||||
Uint64 lastUsedFrame = 0;
|
||||
// cache eviction (see OnFrameBoundary). Mutable: the draw snapshot's memoised
|
||||
// entry pointer re-stamps use through a const reference (StampProgramUse).
|
||||
mutable Uint64 lastUsedFrame = 0;
|
||||
|
||||
static inline VkDevice s_device = VK_NULL_HANDLE;
|
||||
|
||||
@@ -262,6 +263,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
const VkProgramObject& GetOrCreateProgram(
|
||||
const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags);
|
||||
|
||||
// Bumped whenever m_cache's STRUCTURE changes (any insert or erase): the cache is
|
||||
// an open-addressing map holding entries by value, so both moves existing entries.
|
||||
// A caller that memoised a VkProgramObject* may keep dereferencing it only while
|
||||
// this is unchanged; on a bump it must re-run GetOrCreateProgram.
|
||||
Uint64 GetCacheStructureEpoch() const { return m_cacheStructureEpoch; }
|
||||
// A memoised entry pointer bypasses GetOrCreateProgram, whose per-lookup stamp is
|
||||
// what keeps an in-use entry out of OnFrameBoundary's idle sweep - so such a
|
||||
// caller must re-stamp the entry itself, at least once per frame boundary.
|
||||
void StampProgramUse(const VkProgramObject& entry) const { entry.lastUsedFrame = m_frameCounter; }
|
||||
|
||||
// Observer may be null (no notifications). Not owned.
|
||||
void SetEvictionObserver(IEvictionObserver* observer) { m_evictionObserver = observer; }
|
||||
// Frame boundary hook: ages the program cache and evicts long-unused entries
|
||||
@@ -312,6 +323,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
mutable ProgramLookupCache m_lastLookup;
|
||||
// Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging.
|
||||
Uint64 m_frameCounter = 0;
|
||||
// See GetCacheStructureEpoch(). Starts at 1 so a zero-initialized memo can never match.
|
||||
Uint64 m_cacheStructureEpoch = 1;
|
||||
IEvictionObserver* m_evictionObserver = nullptr;
|
||||
static inline XXH64_state_t* m_hashState = XXH64_createState();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -207,11 +212,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
for (auto& entry : m_descriptorReuseMemo) {
|
||||
entry.valid = false;
|
||||
}
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +259,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
for (auto& entry : m_descriptorReuseMemo) {
|
||||
entry.valid = false;
|
||||
}
|
||||
// The rebind memo's set may be among the freed ones.
|
||||
m_fastRebindMemo.valid = false;
|
||||
MGLOG_D("UniformDescriptorBinder: freed %zu descriptor sets for destroyed layout", purgedSets);
|
||||
}
|
||||
}
|
||||
@@ -254,9 +268,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Bool UniformManager::ResolveSamplerDescriptor(VkCommandBuffer commandBuffer,
|
||||
const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
Uint32 binding, VkDescriptorImageInfo& outImageInfo) const {
|
||||
Uint32 binding, VkDescriptorImageInfo& outImageInfo,
|
||||
Bool trustUnchangedHint) const {
|
||||
MOBILEGL_ASSERT(m_textureManager != nullptr, "ResolveSamplerDescriptor: texture manager is null");
|
||||
MOBILEGL_ASSERT(m_samplerManager != nullptr, "ResolveSamplerDescriptor: sampler manager is null");
|
||||
// The caller proved every input of this binding's resolution unchanged since the
|
||||
// last full resolve (which also filled the cache), so the whole chain below -
|
||||
// texture/sampler resolution, completeness probe, sync, layout handling, sampler
|
||||
// and view lookups - would recompute the identical descriptor.
|
||||
if (trustUnchangedHint && binding < m_samplerResolveMemo.size() &&
|
||||
m_samplerResolveMemo[binding].infoValid) {
|
||||
outImageInfo = m_samplerResolveMemo[binding].info;
|
||||
return true;
|
||||
}
|
||||
MOBILEGL_ASSERT(binding < programObj.samplerNameByBinding.size(),
|
||||
"ResolveSamplerDescriptor: sampler binding %u name lookup out of range", binding);
|
||||
// Raw-pointer resolve to skip the SharedPtr atomic refcount churn: the bound texture stays
|
||||
@@ -365,6 +389,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
viewFormatMemo->viewFormatDomain = numericDomain;
|
||||
viewFormatMemo->viewFormat = sampledViewFormat;
|
||||
viewFormatMemo->viewFormatValid = true;
|
||||
NoteSamplerResolveMemoTouched(binding);
|
||||
}
|
||||
}
|
||||
if (sampledViewFormat == VK_FORMAT_UNDEFINED) {
|
||||
@@ -420,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,
|
||||
@@ -430,7 +456,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
.imageView = sampledImageView,
|
||||
.imageLayout = resource->layout,
|
||||
};
|
||||
return outImageInfo.sampler != VK_NULL_HANDLE;
|
||||
if (outImageInfo.sampler == VK_NULL_HANDLE) {
|
||||
return false;
|
||||
}
|
||||
if (binding < m_samplerResolveMemo.size()) {
|
||||
m_samplerResolveMemo[binding].info = outImageInfo;
|
||||
m_samplerResolveMemo[binding].infoValid = true;
|
||||
NoteSamplerResolveMemoTouched(binding);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Bool UniformManager::ResolveSamplerDescriptorOverride(
|
||||
@@ -808,10 +842,55 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
return m_fallbackTexture2D;
|
||||
}
|
||||
|
||||
Bool UniformManager::ResolveSampledBinding(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
Uint32 binding,
|
||||
MG_State::GLState::ITextureObject*& outTexture,
|
||||
const MG_State::GLState::SamplerObject*& outSampler) const {
|
||||
// Open-coded ResolveSamplerTextureRaw so the unit is resolved once for both the
|
||||
// texture and the sampler override - this runs per binding per full-path draw,
|
||||
// and program-alternating draw streams take the full path on every draw.
|
||||
MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveSampledBinding: GL context is null");
|
||||
MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(),
|
||||
"ResolveSampledBinding: sampler location binding %u out of range", binding);
|
||||
MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(),
|
||||
"ResolveSampledBinding: sampler target binding %u out of range", binding);
|
||||
const Int location = programObj.samplerUniformLocationByBinding[binding];
|
||||
const Int unit = ResolveSamplerUnitIndex(program, location, binding);
|
||||
auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit);
|
||||
const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding];
|
||||
MG_State::GLState::ITextureObject* texture =
|
||||
textureUnit.GetBindingSlot(preferredTarget).GetBoundObject().get();
|
||||
// Undefined default texture (name 0, no image) resolves as "unbound", exactly
|
||||
// like ResolveSamplerTextureRaw reports it.
|
||||
if (MG_State::GLState::IsUndefinedDefaultTexture(texture)) {
|
||||
texture = nullptr;
|
||||
}
|
||||
if (texture == nullptr) {
|
||||
// ResolveSamplerDescriptor will substitute the fallback texture for this binding;
|
||||
// include it in the sampled set so the pre-render-pass sync/transition pass covers
|
||||
// its first use instead of leaving that work to happen inside an active pass.
|
||||
if (preferredTarget != TextureTarget::Texture2D &&
|
||||
preferredTarget != TextureTarget::TextureRectangle) {
|
||||
return false;
|
||||
}
|
||||
texture = GetFallbackTexture(preferredTarget).get();
|
||||
}
|
||||
const auto& samplerOverride = textureUnit.GetSamplerObject();
|
||||
outTexture = texture;
|
||||
outSampler = samplerOverride ? samplerOverride.get()
|
||||
: (texture != nullptr ? texture->GetSamplerObject().get() : nullptr);
|
||||
return true;
|
||||
}
|
||||
|
||||
Bool UniformManager::CollectSampledTextures(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
Vector<MG_State::GLState::ITextureObject*>& outTextures) {
|
||||
Vector<MG_State::GLState::ITextureObject*>& outTextures,
|
||||
Vector<SampledBindingRecord>* outBindingRecords) {
|
||||
outTextures.clear();
|
||||
if (outBindingRecords != nullptr) {
|
||||
outBindingRecords->clear();
|
||||
}
|
||||
|
||||
const Uint32 bindingCount =
|
||||
std::min<Uint32>(m_maxBindings, static_cast<Uint32>(programObj.bindingKinds.size()));
|
||||
@@ -820,17 +899,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
continue;
|
||||
}
|
||||
|
||||
MG_State::GLState::ITextureObject* texture = ResolveSamplerTextureRaw(program, programObj, binding);
|
||||
if (!texture) {
|
||||
// ResolveSamplerDescriptor will substitute the fallback texture for this binding;
|
||||
// include it in the sampled set so the pre-render-pass sync/transition pass covers
|
||||
// its first use instead of leaving that work to happen inside an active pass.
|
||||
const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding];
|
||||
if (preferredTarget != TextureTarget::Texture2D &&
|
||||
preferredTarget != TextureTarget::TextureRectangle) {
|
||||
continue;
|
||||
}
|
||||
texture = GetFallbackTexture(preferredTarget).get();
|
||||
MG_State::GLState::ITextureObject* texture = nullptr;
|
||||
const MG_State::GLState::SamplerObject* sampler = nullptr;
|
||||
if (!ResolveSampledBinding(program, programObj, binding, texture, sampler)) {
|
||||
continue;
|
||||
}
|
||||
if (outBindingRecords != nullptr) {
|
||||
outBindingRecords->push_back({texture != nullptr ? texture->GetLifetimeId() : 0,
|
||||
sampler != nullptr ? sampler->GetLifetimeId() : 0});
|
||||
}
|
||||
|
||||
auto found = std::find(outTextures.begin(), outTextures.end(), texture);
|
||||
@@ -841,6 +917,38 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
return true;
|
||||
}
|
||||
|
||||
Bool UniformManager::SampledBindingsUnchanged(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
const Vector<SampledBindingRecord>& previousRecords) const {
|
||||
SizeT recordIndex = 0;
|
||||
// Iterate only the bindings this program declares (ascending), exactly like
|
||||
// BindProgramUniformBuffers: this runs per draw whenever the texture bind
|
||||
// generation moved, and walking all m_maxBindings slots to find the 1-8 real
|
||||
// ones dominated it.
|
||||
for (const Uint32 binding : programObj.activeBindings) {
|
||||
if (binding >= m_maxBindings) {
|
||||
break; // ascending, so nothing past the cap can follow
|
||||
}
|
||||
if (programObj.bindingKinds[binding] != ProgramFactory::DescriptorBindingKind::CombinedImageSampler) {
|
||||
continue;
|
||||
}
|
||||
MG_State::GLState::ITextureObject* texture = nullptr;
|
||||
const MG_State::GLState::SamplerObject* sampler = nullptr;
|
||||
if (!ResolveSampledBinding(program, programObj, binding, texture, sampler)) {
|
||||
continue;
|
||||
}
|
||||
if (recordIndex >= previousRecords.size()) {
|
||||
return false;
|
||||
}
|
||||
const SampledBindingRecord& record = previousRecords[recordIndex++];
|
||||
if (record.textureLifetimeId != (texture != nullptr ? texture->GetLifetimeId() : 0) ||
|
||||
record.samplerLifetimeId != (sampler != nullptr ? sampler->GetLifetimeId() : 0)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return recordIndex == previousRecords.size();
|
||||
}
|
||||
|
||||
Bool UniformManager::CollectStorageImageTextures(
|
||||
const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
@@ -1155,12 +1263,101 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
return VK_SUCCESS;
|
||||
}
|
||||
|
||||
Bool UniformManager::ResolveDynamicUboDescriptor(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
Uint32 binding, Uint32 arrayElement, Uint32 frameIndex,
|
||||
VkBuffer& outBuffer, VkDeviceSize& outRange,
|
||||
Uint32& outDynamicOffset) {
|
||||
UboBindResult ubo{};
|
||||
const Bool hasPayload = ResolveUniformBufferPayload(program, programObj, binding, arrayElement, ubo);
|
||||
MOBILEGL_ASSERT(hasPayload && (ubo.directBindable || (ubo.payload != nullptr && ubo.payloadSize > 0)),
|
||||
"UniformDescriptorBinder::ResolveDynamicUboDescriptor failed: missing UBO payload on binding %u element %u",
|
||||
binding, arrayElement);
|
||||
if (ubo.directBindable) {
|
||||
// Zero-copy: bind the app's resident VkBuffer directly, no per-draw memcpy.
|
||||
outBuffer = ubo.buffer;
|
||||
outRange = ubo.range;
|
||||
outDynamicOffset = static_cast<Uint32>(ubo.dynamicOffset);
|
||||
return true;
|
||||
}
|
||||
// Global-UBO slice reuse (see GlobalUboSliceMemo): unchanged
|
||||
// uniform bytes re-use the slice already uploaded this frame.
|
||||
const Bool isGlobalUbo = programObj.globalUboBinding == static_cast<Int>(binding) && arrayElement == 0;
|
||||
const Uint64 uboFrameSerial = m_bufferManager->GetFrameSerial();
|
||||
const Uint64 uboProgramLifetimeId = program.GetLifetimeId();
|
||||
const Uint32 uboContentVersion = program.GetUBOContentVersion();
|
||||
if (isGlobalUbo) {
|
||||
for (const auto& memo : m_globalUboMemo) {
|
||||
if (memo.buffer != VK_NULL_HANDLE && memo.programLifetimeId == uboProgramLifetimeId &&
|
||||
memo.frameSerial == uboFrameSerial && memo.uboContentVersion == uboContentVersion &&
|
||||
memo.range == static_cast<VkDeviceSize>(ubo.payloadSize)) {
|
||||
outBuffer = memo.buffer;
|
||||
outRange = memo.range;
|
||||
outDynamicOffset = static_cast<Uint32>(memo.offset);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
BufferSlice slice{};
|
||||
if (!m_bufferManager->UploadTransient(BufferKind::Uniform, frameIndex, ubo.payload, ubo.payloadSize,
|
||||
m_minDynamicOffsetAlignment, slice)) {
|
||||
MOBILEGL_ASSERT(false,
|
||||
"UniformDescriptorBinder::ResolveDynamicUboDescriptor failed: UBO upload failed on binding %u element %u",
|
||||
binding, arrayElement);
|
||||
return false;
|
||||
}
|
||||
outBuffer = slice.buffer;
|
||||
outRange = ubo.payloadSize;
|
||||
outDynamicOffset = static_cast<Uint32>(slice.offset);
|
||||
if (isGlobalUbo) {
|
||||
m_globalUboMemo[m_globalUboMemoNext] =
|
||||
GlobalUboSliceMemo{uboProgramLifetimeId, uboFrameSerial, uboContentVersion,
|
||||
slice.buffer, slice.offset, static_cast<VkDeviceSize>(ubo.payloadSize)};
|
||||
m_globalUboMemoNext = (m_globalUboMemoNext + 1) % kGlobalUboMemoSize;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void UniformManager::BindDescriptorSetDeduped(VkCommandBuffer commandBuffer, VkPipelineBindPoint bindPoint,
|
||||
VkPipelineLayout pipelineLayout, VkDescriptorSet descriptorSet,
|
||||
const Vector<Uint32>& dynamicOffsets) {
|
||||
// Skip the driver call when this exact binding is already live on the
|
||||
// command buffer (see the bind-dedup shadow in the header).
|
||||
const Uint32 offsetCount = static_cast<Uint32>(dynamicOffsets.size());
|
||||
Bool identicalBind = m_lastBindValid && m_lastBindSet == descriptorSet &&
|
||||
m_lastBindLayout == pipelineLayout && m_lastBindPoint == bindPoint &&
|
||||
m_lastBindOffsetCount == offsetCount && offsetCount <= kMaxShadowedDynamicOffsets;
|
||||
if (identicalBind) {
|
||||
for (Uint32 i = 0; i < offsetCount; ++i) {
|
||||
if (m_lastBindOffsets[i] != dynamicOffsets[i]) {
|
||||
identicalBind = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!identicalBind) {
|
||||
vkCmdBindDescriptorSets(commandBuffer, bindPoint, pipelineLayout, 0, 1,
|
||||
&descriptorSet, offsetCount, dynamicOffsets.data());
|
||||
if (offsetCount <= kMaxShadowedDynamicOffsets) {
|
||||
m_lastBindValid = true;
|
||||
m_lastBindSet = descriptorSet;
|
||||
m_lastBindLayout = pipelineLayout;
|
||||
m_lastBindPoint = bindPoint;
|
||||
m_lastBindOffsetCount = offsetCount;
|
||||
std::copy_n(dynamicOffsets.data(), offsetCount, m_lastBindOffsets);
|
||||
} else {
|
||||
m_lastBindValid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Bool UniformManager::BindProgramUniformBuffers(VkCommandBuffer commandBuffer,
|
||||
const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
Uint32 frameIndex,
|
||||
VkPipelineBindPoint bindPoint,
|
||||
const SamplerBindingOverride* samplerBindingOverride) {
|
||||
const SamplerBindingOverride* samplerBindingOverride,
|
||||
Bool samplerDescriptorsUnchangedHint) {
|
||||
auto& frame = m_frames[frameIndex];
|
||||
if (frame.descriptorPools.empty()) {
|
||||
MGLOG_E("UniformDescriptorBinder::BindProgramUniformBuffers failed: frame descriptor pools are invalid");
|
||||
@@ -1170,6 +1367,34 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
frame.activeDescriptorPoolIndex = 0;
|
||||
}
|
||||
|
||||
// Dynamic-offset-only rebind (see FastRebindMemo in the header): the last
|
||||
// cacheable walk of this exact program selected a set whose contents are
|
||||
// provably still what this walk would write - the hint covers every
|
||||
// sampler binding, and an unchanged (buffer, range) for the single
|
||||
// dynamic UBO covers the rest - except the dynamic offset, which rebinding
|
||||
// the SAME set delivers without any descriptor write.
|
||||
const Bool cacheable = (samplerBindingOverride == nullptr);
|
||||
if (cacheable && samplerDescriptorsUnchangedHint && m_fastRebindMemo.valid &&
|
||||
m_fastRebindMemo.frameIndex == frameIndex &&
|
||||
m_fastRebindMemo.programLifetimeId == program.GetLifetimeId() &&
|
||||
m_fastRebindMemo.programHash == programObj.hash) {
|
||||
VkBuffer uboBuffer = VK_NULL_HANDLE;
|
||||
VkDeviceSize uboRange = 0;
|
||||
Uint32 uboDynamicOffset = 0;
|
||||
if (ResolveDynamicUboDescriptor(program, programObj, m_fastRebindMemo.uboBinding, 0, frameIndex,
|
||||
uboBuffer, uboRange, uboDynamicOffset) &&
|
||||
uboBuffer == m_fastRebindMemo.uboBuffer && uboRange == m_fastRebindMemo.uboRange) {
|
||||
auto& fastOffsets = m_dynamicOffsetsScratch;
|
||||
fastOffsets.clear();
|
||||
fastOffsets.push_back(uboDynamicOffset);
|
||||
BindDescriptorSetDeduped(commandBuffer, bindPoint, programObj.pipelineLayout,
|
||||
m_fastRebindMemo.set, fastOffsets);
|
||||
return true;
|
||||
}
|
||||
// Any mismatch (arena wrap or growth, direct-bind retarget, upload
|
||||
// failure) falls through to the full walk, which re-records the memo.
|
||||
}
|
||||
|
||||
// The descriptor set is chosen AFTER the writes are built (below), so a draw
|
||||
// whose resolved descriptor content matches the previous draw can reuse that
|
||||
// set and skip both AcquireDescriptorSet and vkUpdateDescriptorSets.
|
||||
@@ -1201,6 +1426,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
texelBufferViews.reserve(m_maxBindings);
|
||||
dynamicOffsets.reserve(programObj.dynamicBindings.size() + uboArrayExtra);
|
||||
|
||||
// Eligibility probe for FastRebindMemo, filled by this walk: exactly one
|
||||
// dynamic-UBO descriptor (no arrayed elements) and otherwise only
|
||||
// combined-image samplers, so the whole set's content is pinned by the
|
||||
// sampler hint plus one (buffer, range) compare.
|
||||
Uint32 dynamicUboDescriptorCount = 0;
|
||||
Uint32 fastRebindUboBinding = 0;
|
||||
Bool fastRebindKindsEligible = true;
|
||||
|
||||
// Iterate only the bindings this program declares. The old walk covered all 256 slots of
|
||||
// bindingKinds on every draw to find the 1-8 a real program uses.
|
||||
for (const Uint32 binding : programObj.activeBindings) {
|
||||
@@ -1221,67 +1454,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
binding < programObj.bindingDescriptorCounts.size()
|
||||
? std::max<Uint32>(1, programObj.bindingDescriptorCounts[binding])
|
||||
: 1u;
|
||||
dynamicUboDescriptorCount += descriptorCount;
|
||||
fastRebindUboBinding = binding;
|
||||
const SizeT firstBufferInfoIndex = bufferInfos.size();
|
||||
for (Uint32 element = 0; element < descriptorCount; ++element) {
|
||||
UboBindResult ubo{};
|
||||
const Bool hasPayload =
|
||||
ResolveUniformBufferPayload(program, programObj, binding, element, ubo);
|
||||
MOBILEGL_ASSERT(hasPayload && ubo.payload != nullptr && ubo.payloadSize > 0,
|
||||
"UniformDescriptorBinder::BindProgramUniformBuffers failed: missing UBO payload on binding %u element %u",
|
||||
binding, element);
|
||||
|
||||
VkDescriptorBufferInfo bufferInfo{};
|
||||
// Keep offset 0 (sub-range selected via the dynamic offset) so the hashed bufferInfo
|
||||
// is stable across draws and the descriptor-set reuse cache keeps hitting.
|
||||
bufferInfo.offset = 0;
|
||||
Uint32 dynOffset;
|
||||
if (ubo.directBindable) {
|
||||
// Zero-copy: bind the app's resident VkBuffer directly, no per-draw memcpy.
|
||||
bufferInfo.buffer = ubo.buffer;
|
||||
bufferInfo.range = ubo.range;
|
||||
dynOffset = static_cast<Uint32>(ubo.dynamicOffset);
|
||||
} else {
|
||||
// Global-UBO slice reuse (see GlobalUboSliceMemo): unchanged
|
||||
// uniform bytes re-use the slice already uploaded this frame.
|
||||
const Bool isGlobalUbo =
|
||||
programObj.globalUboBinding == static_cast<Int>(binding) && element == 0;
|
||||
const Uint64 uboFrameSerial = m_bufferManager->GetFrameSerial();
|
||||
const Uint64 uboProgramLifetimeId = program.GetLifetimeId();
|
||||
const Uint32 uboContentVersion = program.GetUBOContentVersion();
|
||||
Bool reusedSlice = false;
|
||||
if (isGlobalUbo) {
|
||||
for (const auto& memo : m_globalUboMemo) {
|
||||
if (memo.buffer != VK_NULL_HANDLE &&
|
||||
memo.programLifetimeId == uboProgramLifetimeId &&
|
||||
memo.frameSerial == uboFrameSerial &&
|
||||
memo.uboContentVersion == uboContentVersion &&
|
||||
memo.range == static_cast<VkDeviceSize>(ubo.payloadSize)) {
|
||||
bufferInfo.buffer = memo.buffer;
|
||||
bufferInfo.range = memo.range;
|
||||
dynOffset = static_cast<Uint32>(memo.offset);
|
||||
reusedSlice = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!reusedSlice) {
|
||||
BufferSlice slice{};
|
||||
if (!m_bufferManager->UploadTransient(BufferKind::Uniform, frameIndex, ubo.payload,
|
||||
ubo.payloadSize, m_minDynamicOffsetAlignment, slice)) {
|
||||
MOBILEGL_ASSERT(false, "UniformDescriptorBinder::BindProgramUniformBuffers failed: UBO upload failed on binding %u element %u",
|
||||
binding, element);
|
||||
return false;
|
||||
}
|
||||
bufferInfo.buffer = slice.buffer;
|
||||
bufferInfo.range = ubo.payloadSize;
|
||||
dynOffset = static_cast<Uint32>(slice.offset);
|
||||
if (isGlobalUbo) {
|
||||
m_globalUboMemo[m_globalUboMemoNext] = GlobalUboSliceMemo{
|
||||
uboProgramLifetimeId, uboFrameSerial, uboContentVersion,
|
||||
slice.buffer, slice.offset, static_cast<VkDeviceSize>(ubo.payloadSize)};
|
||||
m_globalUboMemoNext = (m_globalUboMemoNext + 1) % kGlobalUboMemoSize;
|
||||
}
|
||||
}
|
||||
Uint32 dynOffset = 0;
|
||||
if (!ResolveDynamicUboDescriptor(program, programObj, binding, element, frameIndex,
|
||||
bufferInfo.buffer, bufferInfo.range, dynOffset)) {
|
||||
return false;
|
||||
}
|
||||
bufferInfos.push_back(bufferInfo);
|
||||
// Dynamic offsets are consumed in binding order, then array element order,
|
||||
@@ -1304,6 +1488,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
}
|
||||
|
||||
texelBufferViews.push_back(bufferView);
|
||||
fastRebindKindsEligible = false;
|
||||
write.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER;
|
||||
write.pTexelBufferView = &texelBufferViews.back();
|
||||
writes.push_back(write);
|
||||
@@ -1317,6 +1502,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
}
|
||||
|
||||
bufferInfos.push_back(bufferInfo);
|
||||
fastRebindKindsEligible = false;
|
||||
write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
|
||||
write.pBufferInfo = &bufferInfos.back();
|
||||
writes.push_back(write);
|
||||
@@ -1329,6 +1515,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
return false;
|
||||
}
|
||||
imageInfos.push_back(imageInfo);
|
||||
fastRebindKindsEligible = false;
|
||||
write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE;
|
||||
write.pImageInfo = &imageInfos.back();
|
||||
writes.push_back(write);
|
||||
@@ -1341,7 +1528,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
samplerBindingOverride->sampler != nullptr) {
|
||||
hasImage = ResolveSamplerDescriptorOverride(*samplerBindingOverride, imageInfo);
|
||||
} else {
|
||||
hasImage = ResolveSamplerDescriptor(commandBuffer, program, programObj, binding, imageInfo);
|
||||
hasImage = ResolveSamplerDescriptor(commandBuffer, program, programObj, binding, imageInfo,
|
||||
samplerDescriptorsUnchangedHint);
|
||||
}
|
||||
if (!hasImage) {
|
||||
MGLOG_E(
|
||||
@@ -1372,7 +1560,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// cursor only advances), so its written contents survive; the layout is part of
|
||||
// the signature so reuse never crosses programs. Sampler overrides (blits)
|
||||
// bypass and invalidate the cache.
|
||||
const Bool cacheable = (samplerBindingOverride == nullptr);
|
||||
Uint64 signature = 0xcbf29ce484222325ULL;
|
||||
{
|
||||
const auto mix64 = [&signature](Uint64 word) {
|
||||
@@ -1435,34 +1622,22 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip the driver call when this exact binding is already live on the
|
||||
// command buffer (see the bind-dedup shadow in the header).
|
||||
const Uint32 offsetCount = static_cast<Uint32>(dynamicOffsets.size());
|
||||
Bool identicalBind = m_lastBindValid && m_lastBindSet == descriptorSet &&
|
||||
m_lastBindLayout == programObj.pipelineLayout && m_lastBindPoint == bindPoint &&
|
||||
m_lastBindOffsetCount == offsetCount && offsetCount <= kMaxShadowedDynamicOffsets;
|
||||
if (identicalBind) {
|
||||
for (Uint32 i = 0; i < offsetCount; ++i) {
|
||||
if (m_lastBindOffsets[i] != dynamicOffsets[i]) {
|
||||
identicalBind = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!identicalBind) {
|
||||
vkCmdBindDescriptorSets(commandBuffer, bindPoint, programObj.pipelineLayout, 0, 1,
|
||||
&descriptorSet, offsetCount, dynamicOffsets.data());
|
||||
if (offsetCount <= kMaxShadowedDynamicOffsets) {
|
||||
m_lastBindValid = true;
|
||||
m_lastBindSet = descriptorSet;
|
||||
m_lastBindLayout = programObj.pipelineLayout;
|
||||
m_lastBindPoint = bindPoint;
|
||||
m_lastBindOffsetCount = offsetCount;
|
||||
std::copy_n(dynamicOffsets.data(), offsetCount, m_lastBindOffsets);
|
||||
} else {
|
||||
m_lastBindValid = false;
|
||||
}
|
||||
// (Re)record the dynamic-offset-only rebind memo. Recording on every
|
||||
// cacheable walk (allocated or reused set alike - both hold exactly the
|
||||
// content just computed) keeps the single slot tracking the most recent
|
||||
// program; a non-cacheable override walk drops it alongside the reuse
|
||||
// memo above.
|
||||
if (cacheable && fastRebindKindsEligible && dynamicUboDescriptorCount == 1) {
|
||||
m_fastRebindMemo = FastRebindMemo{
|
||||
/*valid=*/true, frameIndex, program.GetLifetimeId(), programObj.hash,
|
||||
fastRebindUboBinding, bufferInfos[0].buffer,
|
||||
bufferInfos[0].range, descriptorSet};
|
||||
} else {
|
||||
m_fastRebindMemo.valid = false;
|
||||
}
|
||||
|
||||
BindDescriptorSetDeduped(commandBuffer, bindPoint, programObj.pipelineLayout, descriptorSet,
|
||||
dynamicOffsets);
|
||||
return true;
|
||||
}
|
||||
} // namespace MobileGL::MG_Backend::DirectVulkan
|
||||
|
||||
@@ -53,18 +53,42 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// caches - a live layout's entry must never be purged (its sets would be
|
||||
// unreachable pool slots), so there is deliberately no age-based sweep here.
|
||||
void OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout);
|
||||
// One record per visited CombinedImageSampler binding (post fallback substitution,
|
||||
// in binding order): the resolved texture and effective sampler, as never-reused
|
||||
// lifetime ids so a freed-and-reallocated object at the same heap address can only
|
||||
// MISS a comparison, never false-hit it (same ABA rule as SamplerResolveMemo).
|
||||
struct SampledBindingRecord {
|
||||
Uint64 textureLifetimeId = 0;
|
||||
Uint64 samplerLifetimeId = 0;
|
||||
};
|
||||
Bool CollectSampledTextures(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
Vector<MG_State::GLState::ITextureObject*>& outTextures);
|
||||
Vector<MG_State::GLState::ITextureObject*>& outTextures,
|
||||
Vector<SampledBindingRecord>* outBindingRecords = nullptr);
|
||||
// Shadow-compare for the SetupDraw fast path: re-runs the CollectSampledTextures
|
||||
// walk and reports whether every visited binding still resolves to the recorded
|
||||
// (texture, effective sampler) pair. A texture bind generation bump alone (e.g. a
|
||||
// redundant glBindSampler, which always bumps it) does not prove the sampled set
|
||||
// moved; this walk does, without rebuilding the set or falling off the fast path.
|
||||
Bool SampledBindingsUnchanged(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
const Vector<SampledBindingRecord>& previousRecords) const;
|
||||
Bool CollectStorageImageTextures(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
Vector<MG_State::GLState::ITextureObject*>& outTextures) const;
|
||||
// samplerDescriptorsUnchangedHint: the caller (SetupDraw fast path) proved that
|
||||
// every input of every combined-image-sampler resolution is unchanged since the
|
||||
// previous draw's resolve - same (texture, sampler) per binding, texture params
|
||||
// sum, sampling-resolution generation (sampler params + texture shape), image
|
||||
// epochs AND per-resource layout values - so the per-binding cached
|
||||
// VkDescriptorImageInfo may be reused without re-running the resolve chain.
|
||||
Bool BindProgramUniformBuffers(VkCommandBuffer commandBuffer,
|
||||
const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
Uint32 frameIndex,
|
||||
VkPipelineBindPoint bindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS,
|
||||
const SamplerBindingOverride* samplerBindingOverride = nullptr);
|
||||
const SamplerBindingOverride* samplerBindingOverride = nullptr,
|
||||
Bool samplerDescriptorsUnchangedHint = false);
|
||||
|
||||
// Pure format-policy helper kept public for host regression tests. Formatted storage
|
||||
// images use their shader qualifier; transformed float images use glBindImageTexture's
|
||||
@@ -114,6 +138,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
static Bool ResolveSamplerTexture(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
|
||||
SharedPtr<MG_State::GLState::ITextureObject>& outTexture);
|
||||
// Shared per-binding resolution for CollectSampledTextures and
|
||||
// SampledBindingsUnchanged, so membership and comparison can never diverge:
|
||||
// texture after the fallback substitution (may still be null when no fallback
|
||||
// exists), effective sampler = unit override else the texture's own sampler.
|
||||
// False = the binding is skipped (unbound with a non-2D fallback target).
|
||||
Bool ResolveSampledBinding(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
|
||||
MG_State::GLState::ITextureObject*& outTexture,
|
||||
const MG_State::GLState::SamplerObject*& outSampler) const;
|
||||
// Raw-pointer variant for the per-draw sampled-texture walk (CollectSampledTextures):
|
||||
// the bound texture stays alive through the draw via GL binding state, so callers that
|
||||
// only need the pointer skip the SharedPtr copy's atomic refcount churn.
|
||||
@@ -121,9 +154,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj, Uint32 binding);
|
||||
SharedPtr<MG_State::GLState::ITextureObject> GetFallbackTexture(TextureTarget target) const;
|
||||
// trustUnchangedHint: reuse this binding's cached VkDescriptorImageInfo outright
|
||||
// (see BindProgramUniformBuffers' samplerDescriptorsUnchangedHint for the proof
|
||||
// obligations the caller carries).
|
||||
Bool ResolveSamplerDescriptor(VkCommandBuffer commandBuffer, const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
|
||||
VkDescriptorImageInfo& outImageInfo) const;
|
||||
VkDescriptorImageInfo& outImageInfo,
|
||||
Bool trustUnchangedHint = false) const;
|
||||
Bool ResolveSamplerDescriptorOverride(const SamplerBindingOverride& samplerBindingOverride,
|
||||
VkDescriptorImageInfo& outImageInfo) const;
|
||||
Bool ResolveTexelBufferDescriptor(const MG_State::GLState::ProgramObject& program,
|
||||
@@ -149,6 +186,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Bool ResolveUniformBufferPayload(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
|
||||
Uint32 arrayElement, UboBindResult& out) const;
|
||||
// Shared resolution of one dynamic-UBO binding element into the
|
||||
// (buffer, range, dynamicOffset) triple the descriptor consumes: direct
|
||||
// bind, global-slice reuse, or transient upload. Used by the full walk
|
||||
// and by the dynamic-offset-only rebind (see FastRebindMemo).
|
||||
Bool ResolveDynamicUboDescriptor(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
|
||||
Uint32 arrayElement, Uint32 frameIndex, VkBuffer& outBuffer,
|
||||
VkDeviceSize& outRange, Uint32& outDynamicOffset);
|
||||
// The vkCmdBindDescriptorSets tail shared by the full walk and the
|
||||
// dynamic-offset-only rebind: skips the driver call when this exact
|
||||
// binding is already live on the command buffer (see the bind-dedup
|
||||
// shadow below), otherwise binds and refreshes the shadow.
|
||||
void BindDescriptorSetDeduped(VkCommandBuffer commandBuffer, VkPipelineBindPoint bindPoint,
|
||||
VkPipelineLayout pipelineLayout, VkDescriptorSet descriptorSet,
|
||||
const Vector<Uint32>& dynamicOffsets);
|
||||
Bool CreateDescriptorPool(Uint32 maxSets, VkDescriptorPool& outPool) const;
|
||||
Bool GrowFrameDescriptorPool(FrameResources& frame, Uint32 frameIndex);
|
||||
VkResult AllocateDescriptorSetsFromActivePool(
|
||||
@@ -196,6 +248,37 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
DescriptorReuseEntry m_descriptorReuseMemo[kDescriptorReuseMemoSize];
|
||||
Uint32 m_descriptorReuseMemoNext = 0;
|
||||
|
||||
// Dynamic-offset-only rebind (see BindProgramUniformBuffers): records the
|
||||
// descriptor set selected by the last cacheable full walk of a program
|
||||
// whose active bindings are exactly one dynamic UBO (single descriptor)
|
||||
// plus combined-image samplers. When the next call proves every sampler
|
||||
// descriptor input unchanged (samplerDescriptorsUnchangedHint) and the
|
||||
// UBO re-resolves to the SAME VkBuffer+range - only the dynamic offset
|
||||
// moved, the per-draw glUniform case - the walk collapses to: resolve one
|
||||
// offset, rebind the recorded set with new pDynamicOffsets (Vulkan allows
|
||||
// rebinding the same set with different dynamic offsets).
|
||||
// Invalidation inventory: BeginFrame clears it (the frame's sets are
|
||||
// recycled) and the frameIndex field guards cross-frame confusion on top;
|
||||
// OnDescriptorSetLayoutDestroyed clears it (the set may be freed); a
|
||||
// sampler-override walk clears it (mirrors m_descriptorReuseMemo); a
|
||||
// program relink bumps the backend state version and thus programObj.hash
|
||||
// so the key misses; the program lifetime id is never reused, so a
|
||||
// deleted-and-recreated program misses; a texture/sampler/binding change
|
||||
// drops the hint upstream; an arena wrap or growth resolves a different
|
||||
// VkBuffer and misses. AcquireDescriptorSet's per-frame cursor only
|
||||
// advances, so the recorded set is never re-written within its frame.
|
||||
struct FastRebindMemo {
|
||||
Bool valid = false;
|
||||
Uint32 frameIndex = 0;
|
||||
Uint64 programLifetimeId = 0;
|
||||
ProgramFactory::HashType programHash = 0;
|
||||
Uint32 uboBinding = 0;
|
||||
VkBuffer uboBuffer = VK_NULL_HANDLE;
|
||||
VkDeviceSize uboRange = 0;
|
||||
VkDescriptorSet set = VK_NULL_HANDLE;
|
||||
};
|
||||
FastRebindMemo m_fastRebindMemo;
|
||||
|
||||
// vkCmdBindDescriptorSets dedup: consecutive draws with a static uniform
|
||||
// block resolve to the same set AND the same dynamic offsets, so the
|
||||
// driver call can be skipped outright. Command-buffer-scope state; reset
|
||||
@@ -253,7 +336,28 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
SamplerNumericDomain viewFormatDomain = SamplerNumericDomain::Unknown;
|
||||
VkFormat viewFormat = VK_FORMAT_UNDEFINED;
|
||||
Bool viewFormatValid = false;
|
||||
// Whole resolved descriptor from this binding's last full resolve. Reused
|
||||
// ONLY under ResolveSamplerDescriptor's trustUnchangedHint, whose caller
|
||||
// proves every resolve input unchanged; cleared with the per-frame reset
|
||||
// (the cached VkSampler outlives a frame only via a fresh resolve, which
|
||||
// also re-stamps it against VkSamplerManager's frame-boundary sweep).
|
||||
VkDescriptorImageInfo info{};
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -71,6 +75,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
}
|
||||
const BackendVertexInputState& entry = GetOrCreateVertexInputState(vao, GetOrComputeHash(vao));
|
||||
vao.SetBackendStateMemo(&entry, m_evictionEpoch);
|
||||
// Also mirror the layout identity and the two per-draw masks into the VAO's aux
|
||||
// memo (pure VALUES derived from the VAO configuration, so config-version
|
||||
// guarding alone is sound). The draw fast path reads them from the VAO object it
|
||||
// already touched instead of chasing into this entry - see PackVertexInputAuxMemo.
|
||||
vao.SetBackendAuxMemo(entry.layoutHash,
|
||||
PackVertexInputAuxMasks(entry.unsupportedAttribMask, entry.attributeLocationMask));
|
||||
return entry;
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -71,6 +72,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
~VertexInputStateFactory() = default;
|
||||
VertexInputStateFactory(const VertexInputStateFactory&) = delete;
|
||||
|
||||
// The VAO aux-memo payload GetOrCreateVertexInputState(vao) stamps: aux0 is the
|
||||
// entry's layoutHash, aux1 packs (unsupportedAttribMask << 32) | attributeLocationMask.
|
||||
// Readers that find the aux memo valid can use these without resolving the entry.
|
||||
static Uint64 PackVertexInputAuxMasks(Uint32 unsupportedAttribMask, Uint32 attributeLocationMask) {
|
||||
return (static_cast<Uint64>(unsupportedAttribMask) << 32) | attributeLocationMask;
|
||||
}
|
||||
|
||||
HashType ComputeHash(const MG_State::GLState::VertexArrayObject& vao) const;
|
||||
// Memoized ComputeHash: reuses the VAO's cached hash while its config version
|
||||
// is unchanged. Use this on per-draw paths.
|
||||
@@ -79,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
|
||||
|
||||
@@ -258,6 +258,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
void VkBufferManager::ReleaseAllLiveResources() {
|
||||
for (auto& weak : m_liveResources) {
|
||||
if (auto resource = weak.lock()) {
|
||||
BumpSliceEpoch(*resource);
|
||||
resource->buffer.Destroy();
|
||||
resource->storageSize = 0;
|
||||
resource->usageFlags = 0;
|
||||
@@ -272,6 +273,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
|
||||
Bool VkBufferManager::CreateResidentStorage(VkBufferResource& resource, VkDeviceSize size,
|
||||
VkBufferUsageFlags usage, VkMemoryPropertyFlags requiredFlags) {
|
||||
// The only place a resident VkBuffer handle is minted, so every resident slice
|
||||
// change funnels through here (callers release the old handle first).
|
||||
BumpSliceEpoch(resource);
|
||||
// Staged range copies write resident storage with vkCmdCopyBuffer.
|
||||
usage |= VK_BUFFER_USAGE_TRANSFER_DST_BIT;
|
||||
const Bool created = resource.buffer.Create({
|
||||
@@ -358,6 +362,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
if (!resource) {
|
||||
return; // lazy: AcquireResidentSlice performs a full upload on creation
|
||||
}
|
||||
// A respecify can change the size, the usage hint (so the resident/streamed
|
||||
// route), and the contents at once; retire every memo before deciding what to
|
||||
// do about the storage.
|
||||
BumpSliceEpoch(*resource);
|
||||
// Any cached streaming slice refers to the previous contents.
|
||||
resource->transientFrameSerial = 0;
|
||||
if (!resource->buffer.IsValid()) {
|
||||
@@ -390,6 +398,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
if (!resource) {
|
||||
return;
|
||||
}
|
||||
// Drops the streaming memo below and may end in a storage swap or a deferred
|
||||
// full re-upload, so no memoised slice survives this.
|
||||
BumpSliceEpoch(*resource);
|
||||
resource->transientFrameSerial = 0;
|
||||
if (!resource->buffer.IsValid() || resource->pendingFullUpload) {
|
||||
return;
|
||||
@@ -422,6 +433,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
if (!resource) {
|
||||
return;
|
||||
}
|
||||
BumpSliceEpoch(*resource);
|
||||
resource->transientFrameSerial = 0;
|
||||
if (!resource->buffer.IsValid() || resource->pendingFullUpload) {
|
||||
return;
|
||||
@@ -481,6 +493,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
TrackLiveResource(resource);
|
||||
}
|
||||
|
||||
// Bumped for the request, not just for the storage it may create. This is the
|
||||
// one call the frontend makes when a buffer becomes persistently mapped for
|
||||
// writing (BufferObject::AcquireMemoryRange), and a map the backend declines
|
||||
// keeps mutating its shadow with no further API call - so it is what lets
|
||||
// GetSliceEpochCounter stand for "no buffer needs a persistent-map range push".
|
||||
BumpSliceEpoch(*resource);
|
||||
|
||||
// Idempotent: an already-backed buffer returns the same mapped base.
|
||||
if (resource->persistentMapped && resource->buffer.IsValid() && resource->storageSize == size) {
|
||||
return resource->buffer.GetMappedData();
|
||||
@@ -608,8 +627,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
} else if (resource->transientChangeSerial == changeSerial && resource->transientSize == size &&
|
||||
resource->transientFrameSerial != 0) {
|
||||
if (++resource->unchangedStreak >= kStreamedPromotionStreak) {
|
||||
// Promotion moves the buffer off the arena and onto resident storage.
|
||||
resource->promotedResident = true;
|
||||
resource->promotedChangeSerial = changeSerial;
|
||||
BumpSliceEpoch(*resource);
|
||||
if (AcquireResidentSlice(kind, bufferObject, outSlice)) {
|
||||
return true;
|
||||
}
|
||||
@@ -619,6 +640,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
resource->unchangedStreak = 0;
|
||||
}
|
||||
|
||||
// A fresh arena allocation: a different slice than the last call handed back,
|
||||
// and (below) the point where a promoted buffer's resident storage is released.
|
||||
// The stable-promotion exit above returns before this, so a buffer the app has
|
||||
// stopped touching keeps one slice for as long as it keeps its resident storage.
|
||||
BumpSliceEpoch(*resource);
|
||||
if (!m_transientUploadArena.Upload(m_currentFrameIndex, bufferObject->MappedData(), size, 16,
|
||||
outSlice)) {
|
||||
return false;
|
||||
|
||||
@@ -57,6 +57,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// never orphaned or recreated. Draw-time acquire binds it directly, no re-upload.
|
||||
Bool persistentMapped = false;
|
||||
|
||||
// Bumped from a manager-wide counter every time anything that decides which
|
||||
// BufferSlice an Acquire*Slice call hands back changes: storage created or
|
||||
// released, a full re-upload becoming due, a promotion/demotion between
|
||||
// resident and streamed storage, or a new per-frame arena slice. Callers that
|
||||
// memoise a resolved slice compare this to prove the memo still describes the
|
||||
// buffer. The counter is manager-wide (never per-resource) so a freshly
|
||||
// created resource - including one that replaces a destroyed resource at the
|
||||
// same address - can never reproduce a value some memo already holds. 0 means
|
||||
// "no slice has ever been handed out", which no memo can match.
|
||||
Uint64 sliceEpoch = 0;
|
||||
|
||||
// Cached transient (streaming) slice for the current frame.
|
||||
BufferSlice transientSlice{};
|
||||
Uint64 transientFrameSerial = 0;
|
||||
@@ -132,6 +143,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
void OnResourceDestroyed(SharedPtr<MG_State::GLState::BackendBufferResource>&& resource);
|
||||
|
||||
Uint64 GetFrameSerial() const { return m_frameSerial; }
|
||||
// Highest value handed to any VkBufferResource::sliceEpoch. Unchanged since a
|
||||
// memo was taken means no buffer this manager owns changed which slice it hands
|
||||
// back, and none was persistently mapped, in between - so a memo of resolved
|
||||
// slices needs no per-buffer re-check. See AcquirePersistentMap for the mapping half.
|
||||
Uint64 GetSliceEpochCounter() const { return m_sliceEpochCounter; }
|
||||
// Highest frame serial whose GPU work is known complete; serials at or
|
||||
// below it may be considered signaled. Drives IsResourceBusy and the
|
||||
// backend GL fence objects.
|
||||
@@ -158,6 +174,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
void DestroyAllDeferredReleases();
|
||||
void TrackLiveResource(const SharedPtr<VkBufferResource>& resource);
|
||||
void ReleaseAllLiveResources();
|
||||
// See VkBufferResource::sliceEpoch.
|
||||
void BumpSliceEpoch(VkBufferResource& resource) { resource.sliceEpoch = ++m_sliceEpochCounter; }
|
||||
|
||||
VkBufferManagerInitInfo m_initInfo{};
|
||||
BufferArena m_transientUploadArena;
|
||||
@@ -170,5 +188,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Uint32 m_currentFrameIndex = 0;
|
||||
Uint64 m_frameSerial = 1;
|
||||
Uint64 m_completedSerialFloor = 0;
|
||||
// Never reset (not even by Shutdown): a value handed to a resource must stay
|
||||
// unique for the process, or a memo taken before a re-initialize could match
|
||||
// a different resource's state after it.
|
||||
Uint64 m_sliceEpochCounter = 0;
|
||||
};
|
||||
} // namespace MobileGL::MG_Backend::DirectVulkan
|
||||
|
||||
@@ -176,16 +176,4 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
return true;
|
||||
}
|
||||
|
||||
BufferSlice VkBufferObject::GetSlice(VkDeviceSize offset, VkDeviceSize size) const {
|
||||
MOBILEGL_ASSERT(offset <= m_size, "VkBufferObject::GetSlice offset out of range");
|
||||
const VkDeviceSize resolvedSize = (size == VK_WHOLE_SIZE) ? (m_size - offset) : size;
|
||||
MOBILEGL_ASSERT(offset + resolvedSize <= m_size, "VkBufferObject::GetSlice range out of bounds");
|
||||
|
||||
BufferSlice slice{};
|
||||
slice.buffer = m_buffer;
|
||||
slice.offset = offset;
|
||||
slice.size = resolvedSize;
|
||||
slice.mapped = (m_mappedData != nullptr) ? static_cast<Uint8*>(m_mappedData) + offset : nullptr;
|
||||
return slice;
|
||||
}
|
||||
} // namespace MobileGL::MG_Backend::DirectVulkan
|
||||
|
||||
@@ -48,7 +48,20 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
|
||||
VkBuffer GetHandle() const { return m_buffer; }
|
||||
VkDeviceSize GetSize() const { return m_size; }
|
||||
BufferSlice GetSlice(VkDeviceSize offset = 0, VkDeviceSize size = VK_WHOLE_SIZE) const;
|
||||
// Inline: runs on the per-draw acquire path (a resident buffer bind is a
|
||||
// GetSlice per binding), where an out-of-line call was measurable.
|
||||
BufferSlice GetSlice(VkDeviceSize offset = 0, VkDeviceSize size = VK_WHOLE_SIZE) const {
|
||||
MOBILEGL_ASSERT(offset <= m_size, "VkBufferObject::GetSlice offset out of range");
|
||||
const VkDeviceSize resolvedSize = (size == VK_WHOLE_SIZE) ? (m_size - offset) : size;
|
||||
MOBILEGL_ASSERT(offset + resolvedSize <= m_size, "VkBufferObject::GetSlice range out of bounds");
|
||||
|
||||
BufferSlice slice{};
|
||||
slice.buffer = m_buffer;
|
||||
slice.offset = offset;
|
||||
slice.size = resolvedSize;
|
||||
slice.mapped = (m_mappedData != nullptr) ? static_cast<Uint8*>(m_mappedData) + offset : nullptr;
|
||||
return slice;
|
||||
}
|
||||
void* GetMappedData() const { return m_mappedData; }
|
||||
Bool IsMapped() const { return m_mappedData != nullptr; }
|
||||
Bool IsValid() const { return m_allocator != nullptr && m_buffer != VK_NULL_HANDLE && m_allocation != nullptr; }
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "MG_Util/Converters/MGToVk/TextureEnumConverter.h"
|
||||
|
||||
#include <Config.h>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -620,12 +621,34 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
TextureResource::s_device = m_device;
|
||||
TextureResource::s_allocator = m_allocator;
|
||||
|
||||
// Own pool for the recycled upload-batch command buffers. Parking a
|
||||
// dozen reset-but-alive command buffers in the renderer's shared pool
|
||||
// interleaves their retained chunks with the frame command buffers
|
||||
// allocated/freed there every frame; isolating them keeps both pools'
|
||||
// internal allocators dense.
|
||||
VkCommandPoolCreateInfo uploadPoolInfo{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
|
||||
uploadPoolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT |
|
||||
VK_COMMAND_POOL_CREATE_TRANSIENT_BIT;
|
||||
uploadPoolInfo.queueFamilyIndex = initInfo.graphicsQueueFamilyIndex;
|
||||
VK_VERIFY(vkCreateCommandPool(m_device, &uploadPoolInfo, nullptr, &m_uploadCommandPool),
|
||||
"vkCreateCommandPool(texture upload batch)");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void VkTextureManager::Shutdown() {
|
||||
if (m_device != VK_NULL_HANDLE) {
|
||||
// A still-open (never-submitted) batch is discarded, not submitted:
|
||||
// the renderer has already drained the device and the data has no
|
||||
// observer. Submitted batches are waited and recycled, then the
|
||||
// pools they recycled into are destroyed.
|
||||
DiscardPendingUploadBatch();
|
||||
ReclaimCompletedUploads(/*waitAll=*/true);
|
||||
DestroyUploadPools();
|
||||
if (m_uploadCommandPool != VK_NULL_HANDLE) {
|
||||
vkDestroyCommandPool(m_device, m_uploadCommandPool, nullptr);
|
||||
m_uploadCommandPool = VK_NULL_HANDLE;
|
||||
}
|
||||
}
|
||||
DestroyDeferredReleases();
|
||||
++m_resourceEraseEpoch; // every memoized resource pointer dies with the map
|
||||
@@ -1859,6 +1882,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
resource.syncedTextureParamsVersion = 0;
|
||||
|
||||
if (preservedResource) {
|
||||
// The preserve copy reads the OLD image on its own immediately-
|
||||
// submitted-and-waited command buffer; a batched upload into that
|
||||
// image still sitting in the open batch must reach the queue first
|
||||
// or the copy carries pre-upload texels forward.
|
||||
FlushPendingUploads();
|
||||
const Bool preserved = PreserveTextureContentsOnRecreate(
|
||||
m_device, m_commandPool, m_graphicsQueue, *preservedResource, resource);
|
||||
MOBILEGL_ASSERT(preserved,
|
||||
@@ -1869,6 +1897,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
}
|
||||
|
||||
void VkTextureManager::DeferResourceRelease(TextureResource&& resource) {
|
||||
// The deferred-release queues are drained under fence/queue-idle proofs
|
||||
// that only cover SUBMITTED work; a recorded-but-unsubmitted upload
|
||||
// batch referencing this image would escape them. Push the batch onto
|
||||
// the queue first so every later proof covers it. Rare (only recreate/
|
||||
// erase of an image uploaded this very frame), so the flush is cheap.
|
||||
if (m_uploadBatchOpen && resource.image != VK_NULL_HANDLE &&
|
||||
std::find(m_uploadBatchImages.begin(), m_uploadBatchImages.end(), resource.image) !=
|
||||
m_uploadBatchImages.end()) {
|
||||
FlushPendingUploads();
|
||||
}
|
||||
if (resource.image == VK_NULL_HANDLE && resource.fullView == VK_NULL_HANDLE &&
|
||||
resource.sampledView == VK_NULL_HANDLE &&
|
||||
resource.perMipViews.empty() && resource.perMipSampledViews.empty() &&
|
||||
@@ -1919,14 +1957,211 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
} else if (vkGetFenceStatus(m_device, entry.fence) != VK_SUCCESS) {
|
||||
break;
|
||||
}
|
||||
vkDestroyFence(m_device, entry.fence, nullptr);
|
||||
vkFreeCommandBuffers(m_device, m_commandPool, 1, &entry.commandBuffer);
|
||||
vmaDestroyBuffer(m_allocator, entry.stagingBuffer, entry.stagingAllocation);
|
||||
// Recycle, don't destroy: the fence resets into the fence pool,
|
||||
// the command buffer resets into the CB pool (m_uploadCommandPool
|
||||
// carries RESET_COMMAND_BUFFER_BIT), and the staging blocks
|
||||
// return to the block pool for the next batch to bump-allocate.
|
||||
// This is where the mc_tex_stream win comes from: the per-upload
|
||||
// fence create/destroy + command-buffer alloc/free ioctl traffic
|
||||
// was the measured 41%-in-kernel cost, not the submit itself.
|
||||
if (vkResetFences(m_device, 1, &entry.fence) == VK_SUCCESS) {
|
||||
m_freeUploadFences.push_back(entry.fence);
|
||||
} else {
|
||||
vkDestroyFence(m_device, entry.fence, nullptr);
|
||||
}
|
||||
if (vkResetCommandBuffer(entry.commandBuffer, 0) == VK_SUCCESS) {
|
||||
m_freeUploadCommandBuffers.push_back(entry.commandBuffer);
|
||||
} else {
|
||||
vkFreeCommandBuffers(m_device, m_uploadCommandPool, 1, &entry.commandBuffer);
|
||||
}
|
||||
for (auto& block : entry.stagingBlocks) {
|
||||
RecycleUploadStagingBlock(Move(block));
|
||||
}
|
||||
entry.stagingBlocks.clear();
|
||||
}
|
||||
m_pendingUploadReclaims.erase(m_pendingUploadReclaims.begin(),
|
||||
m_pendingUploadReclaims.begin() + static_cast<std::ptrdiff_t>(completed));
|
||||
}
|
||||
|
||||
void VkTextureManager::RecycleUploadStagingBlock(UploadStagingBlock&& block) {
|
||||
if (block.buffer == VK_NULL_HANDLE) {
|
||||
return;
|
||||
}
|
||||
// Bound the idle pool: a one-off giant upload (initial atlas define)
|
||||
// must not pin its staging memory forever.
|
||||
constexpr VkDeviceSize kMaxFreeUploadStagingBytes = 32u * 1024u * 1024u;
|
||||
if (m_allocator == nullptr || m_freeUploadStagingBytes + block.capacity > kMaxFreeUploadStagingBytes) {
|
||||
vmaDestroyBuffer(m_allocator, block.buffer, block.allocation);
|
||||
return;
|
||||
}
|
||||
block.cursor = 0;
|
||||
m_freeUploadStagingBytes += block.capacity;
|
||||
m_freeUploadStagingBlocks.push_back(Move(block));
|
||||
}
|
||||
|
||||
VkCommandBuffer VkTextureManager::EnsureUploadBatchOpen() {
|
||||
if (m_uploadBatchOpen) {
|
||||
return m_uploadBatchCommandBuffer;
|
||||
}
|
||||
if (!m_freeUploadCommandBuffers.empty()) {
|
||||
m_uploadBatchCommandBuffer = m_freeUploadCommandBuffers.back();
|
||||
m_freeUploadCommandBuffers.pop_back();
|
||||
} else {
|
||||
VkCommandBufferAllocateInfo allocInfo{};
|
||||
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
|
||||
allocInfo.commandPool = m_uploadCommandPool;
|
||||
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
|
||||
allocInfo.commandBufferCount = 1;
|
||||
VK_VERIFY(vkAllocateCommandBuffers(m_device, &allocInfo, &m_uploadBatchCommandBuffer),
|
||||
"vkAllocateCommandBuffers(texture upload batch)");
|
||||
}
|
||||
VkCommandBufferBeginInfo beginInfo{};
|
||||
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
|
||||
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
|
||||
VK_VERIFY(vkBeginCommandBuffer(m_uploadBatchCommandBuffer, &beginInfo),
|
||||
"vkBeginCommandBuffer(texture upload batch)");
|
||||
m_uploadBatchOpen = true;
|
||||
return m_uploadBatchCommandBuffer;
|
||||
}
|
||||
|
||||
Uint8* VkTextureManager::AcquireUploadStagingSpace(VkDeviceSize size, VkBuffer& outBuffer,
|
||||
VkDeviceSize& outBaseOffset) {
|
||||
// 16 covers every uncompressed texel size in use (1..16 bytes) and the
|
||||
// bufferOffset multiple-of-4 rule; per-item offsets inside the span
|
||||
// keep the pre-batching tight packing.
|
||||
constexpr VkDeviceSize kUploadStagingAlignment = 16;
|
||||
constexpr VkDeviceSize kUploadStagingBlockSize = 1u * 1024u * 1024u;
|
||||
UploadStagingBlock* current = m_uploadBatchBlocks.empty() ? nullptr : &m_uploadBatchBlocks.back();
|
||||
VkDeviceSize alignedCursor = 0;
|
||||
if (current != nullptr) {
|
||||
alignedCursor = (current->cursor + (kUploadStagingAlignment - 1)) & ~(kUploadStagingAlignment - 1);
|
||||
if (alignedCursor + size > current->capacity) {
|
||||
current = nullptr;
|
||||
}
|
||||
}
|
||||
if (current == nullptr) {
|
||||
UploadStagingBlock block;
|
||||
for (SizeT i = 0; i < m_freeUploadStagingBlocks.size(); ++i) {
|
||||
if (m_freeUploadStagingBlocks[i].capacity >= size) {
|
||||
block = Move(m_freeUploadStagingBlocks[i]);
|
||||
m_freeUploadStagingBytes -= block.capacity;
|
||||
m_freeUploadStagingBlocks.erase(m_freeUploadStagingBlocks.begin() +
|
||||
static_cast<std::ptrdiff_t>(i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (block.buffer == VK_NULL_HANDLE) {
|
||||
VkBufferCreateInfo bufferInfo{};
|
||||
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
|
||||
bufferInfo.size = std::max(kUploadStagingBlockSize, size);
|
||||
bufferInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
|
||||
bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
|
||||
VmaAllocationCreateInfo stagingAllocationInfo{};
|
||||
stagingAllocationInfo.usage = VMA_MEMORY_USAGE_AUTO_PREFER_HOST;
|
||||
stagingAllocationInfo.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT |
|
||||
VMA_ALLOCATION_CREATE_MAPPED_BIT;
|
||||
stagingAllocationInfo.requiredFlags =
|
||||
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
|
||||
VmaAllocationInfo allocationResult{};
|
||||
VK_VERIFY(vmaCreateBuffer(m_allocator, &bufferInfo, &stagingAllocationInfo, &block.buffer,
|
||||
&block.allocation, &allocationResult),
|
||||
"vmaCreateBuffer(texture upload staging block)");
|
||||
block.mapped = static_cast<Uint8*>(allocationResult.pMappedData);
|
||||
block.capacity = bufferInfo.size;
|
||||
MOBILEGL_ASSERT(block.mapped != nullptr,
|
||||
"AcquireUploadStagingSpace: staging block is not persistently mapped");
|
||||
}
|
||||
block.cursor = 0;
|
||||
m_uploadBatchBlocks.push_back(Move(block));
|
||||
current = &m_uploadBatchBlocks.back();
|
||||
alignedCursor = 0;
|
||||
}
|
||||
outBuffer = current->buffer;
|
||||
outBaseOffset = alignedCursor;
|
||||
current->cursor = alignedCursor + size;
|
||||
return current->mapped + alignedCursor;
|
||||
}
|
||||
|
||||
void VkTextureManager::FlushPendingUploads() {
|
||||
if (!m_uploadBatchOpen) {
|
||||
return;
|
||||
}
|
||||
VK_VERIFY(vkEndCommandBuffer(m_uploadBatchCommandBuffer), "vkEndCommandBuffer(texture upload batch)");
|
||||
|
||||
VkFence uploadFence = VK_NULL_HANDLE;
|
||||
if (!m_freeUploadFences.empty()) {
|
||||
uploadFence = m_freeUploadFences.back();
|
||||
m_freeUploadFences.pop_back();
|
||||
} else {
|
||||
VkFenceCreateInfo fenceInfo{};
|
||||
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
|
||||
VK_VERIFY(vkCreateFence(m_device, &fenceInfo, nullptr, &uploadFence), "vkCreateFence(texture upload)");
|
||||
}
|
||||
|
||||
VkSubmitInfo submitInfo{};
|
||||
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
|
||||
submitInfo.commandBufferCount = 1;
|
||||
submitInfo.pCommandBuffers = &m_uploadBatchCommandBuffer;
|
||||
VK_VERIFY(vkQueueSubmit(m_graphicsQueue, 1, &submitInfo, uploadFence), "vkQueueSubmit(texture upload batch)");
|
||||
|
||||
PendingUploadReclaim reclaim;
|
||||
reclaim.fence = uploadFence;
|
||||
reclaim.commandBuffer = m_uploadBatchCommandBuffer;
|
||||
reclaim.stagingBlocks = Move(m_uploadBatchBlocks);
|
||||
m_pendingUploadReclaims.push_back(Move(reclaim));
|
||||
m_uploadBatchCommandBuffer = VK_NULL_HANDLE;
|
||||
m_uploadBatchOpen = false;
|
||||
m_uploadBatchBlocks.clear();
|
||||
m_uploadBatchImages.clear();
|
||||
m_uploadBatchStagingBytes = 0;
|
||||
|
||||
ReclaimCompletedUploads();
|
||||
// Backstop for pathological upload storms: bound in-flight staging
|
||||
// memory by blocking on the oldest batch only once the list is deep.
|
||||
constexpr SizeT kMaxPendingTextureUploads = 16;
|
||||
if (m_pendingUploadReclaims.size() > kMaxPendingTextureUploads) {
|
||||
VK_VERIFY(vkWaitForFences(m_device, 1, &m_pendingUploadReclaims.front().fence, VK_TRUE, UINT64_MAX),
|
||||
"vkWaitForFences(texture upload backstop)");
|
||||
ReclaimCompletedUploads();
|
||||
}
|
||||
}
|
||||
|
||||
void VkTextureManager::DiscardPendingUploadBatch() {
|
||||
if (!m_uploadBatchOpen) {
|
||||
return;
|
||||
}
|
||||
// The batch was never submitted, so the command buffer is in the
|
||||
// recording state, not pending - freeing it is legal.
|
||||
vkFreeCommandBuffers(m_device, m_uploadCommandPool, 1, &m_uploadBatchCommandBuffer);
|
||||
m_uploadBatchCommandBuffer = VK_NULL_HANDLE;
|
||||
m_uploadBatchOpen = false;
|
||||
for (auto& block : m_uploadBatchBlocks) {
|
||||
RecycleUploadStagingBlock(Move(block));
|
||||
}
|
||||
m_uploadBatchBlocks.clear();
|
||||
m_uploadBatchImages.clear();
|
||||
m_uploadBatchStagingBytes = 0;
|
||||
}
|
||||
|
||||
void VkTextureManager::DestroyUploadPools() {
|
||||
for (auto& block : m_freeUploadStagingBlocks) {
|
||||
if (block.buffer != VK_NULL_HANDLE) {
|
||||
vmaDestroyBuffer(m_allocator, block.buffer, block.allocation);
|
||||
}
|
||||
}
|
||||
m_freeUploadStagingBlocks.clear();
|
||||
m_freeUploadStagingBytes = 0;
|
||||
if (!m_freeUploadCommandBuffers.empty()) {
|
||||
vkFreeCommandBuffers(m_device, m_uploadCommandPool, static_cast<Uint32>(m_freeUploadCommandBuffers.size()),
|
||||
m_freeUploadCommandBuffers.data());
|
||||
m_freeUploadCommandBuffers.clear();
|
||||
}
|
||||
for (const VkFence fence : m_freeUploadFences) {
|
||||
vkDestroyFence(m_device, fence, nullptr);
|
||||
}
|
||||
m_freeUploadFences.clear();
|
||||
}
|
||||
|
||||
void VkTextureManager::DestroyDeferredReleases() {
|
||||
for (auto& deferredReleases : m_deferredReleases) {
|
||||
deferredReleases.clear();
|
||||
@@ -2070,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;
|
||||
@@ -2133,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) {
|
||||
@@ -2271,61 +2529,66 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
}
|
||||
}
|
||||
|
||||
// Rare mid-frame hazard, kept at parity with the old per-upload
|
||||
// submits: this image already has an upload recorded in the OPEN batch
|
||||
// and has since been referenced by the frame's open recording (drawn).
|
||||
// Appending here would merge both uploads into the same pre-frame
|
||||
// submission the old code split into two; flush first so the second
|
||||
// upload lands in its own later submission, exactly like before.
|
||||
if (m_uploadBatchOpen && WasTouchedThisRecording(outResource) &&
|
||||
std::find(m_uploadBatchImages.begin(), m_uploadBatchImages.end(), outResource.image) !=
|
||||
m_uploadBatchImages.end()) {
|
||||
FlushPendingUploads();
|
||||
}
|
||||
// Bound the staging bytes a single batch can pin before its fence can
|
||||
// reclaim them.
|
||||
constexpr VkDeviceSize kMaxBatchStagingBytes = 64u * 1024u * 1024u;
|
||||
if (m_uploadBatchOpen && m_uploadBatchStagingBytes + stagingSize > kMaxBatchStagingBytes) {
|
||||
FlushPendingUploads();
|
||||
}
|
||||
|
||||
VkCommandBuffer commandBuffer = EnsureUploadBatchOpen();
|
||||
VkBuffer stagingBuffer = VK_NULL_HANDLE;
|
||||
VmaAllocation stagingAllocation = nullptr;
|
||||
|
||||
VkBufferCreateInfo bufferInfo{};
|
||||
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
|
||||
bufferInfo.size = stagingSize;
|
||||
bufferInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
|
||||
bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
|
||||
VmaAllocationCreateInfo stagingAllocationInfo{};
|
||||
stagingAllocationInfo.usage = VMA_MEMORY_USAGE_AUTO_PREFER_HOST;
|
||||
stagingAllocationInfo.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT;
|
||||
stagingAllocationInfo.requiredFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
|
||||
VK_VERIFY(vmaCreateBuffer(m_allocator, &bufferInfo, &stagingAllocationInfo, &stagingBuffer, &stagingAllocation, nullptr),
|
||||
"vmaCreateBuffer(staging texture)");
|
||||
|
||||
void* mapped = nullptr;
|
||||
VK_VERIFY(vmaMapMemory(m_allocator, stagingAllocation, &mapped), "vmaMapMemory(staging texture)");
|
||||
VkDeviceSize stagingBase = 0;
|
||||
Uint8* mapped = AcquireUploadStagingSpace(stagingSize, stagingBuffer, stagingBase);
|
||||
for (const auto& item : uploadItems) {
|
||||
Uint8* dst = static_cast<Uint8*>(mapped) + item.offset;
|
||||
Uint8* dst = mapped + item.offset;
|
||||
if (!item.subRegion) {
|
||||
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);
|
||||
}
|
||||
vmaUnmapMemory(m_allocator, stagingAllocation);
|
||||
|
||||
VkCommandBufferAllocateInfo allocInfo{};
|
||||
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
|
||||
allocInfo.commandPool = m_commandPool;
|
||||
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
|
||||
allocInfo.commandBufferCount = 1;
|
||||
|
||||
VkCommandBuffer commandBuffer = VK_NULL_HANDLE;
|
||||
VK_VERIFY(vkAllocateCommandBuffers(m_device, &allocInfo, &commandBuffer), "vkAllocateCommandBuffers(texture)");
|
||||
|
||||
VkCommandBufferBeginInfo beginInfo{};
|
||||
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
|
||||
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
|
||||
VK_VERIFY(vkBeginCommandBuffer(commandBuffer, &beginInfo), "vkBeginCommandBuffer(texture)");
|
||||
|
||||
const VkImageAspectFlags aspectMask = GetAspectMaskForFormat(outResource.format);
|
||||
VkPipelineStageFlags uploadSrcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
|
||||
@@ -2348,9 +2611,49 @@ 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 = item.offset;
|
||||
copy.bufferOffset = stagingBase + item.offset;
|
||||
copy.bufferRowLength = 0;
|
||||
copy.bufferImageHeight = 0;
|
||||
copy.imageSubresource.aspectMask = aspectMask;
|
||||
@@ -2383,7 +2686,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
depthCopy.imageSubresource.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;
|
||||
VkBufferImageCopy stencilCopy = copy;
|
||||
stencilCopy.imageSubresource.aspectMask = VK_IMAGE_ASPECT_STENCIL_BIT;
|
||||
stencilCopy.bufferOffset = item.offset + static_cast<VkDeviceSize>(texelCount) * 4;
|
||||
stencilCopy.bufferOffset = stagingBase + item.offset + static_cast<VkDeviceSize>(texelCount) * 4;
|
||||
const VkBufferImageCopy copies[2] = {depthCopy, stencilCopy};
|
||||
vkCmdCopyBufferToImage(commandBuffer, stagingBuffer, outResource.image,
|
||||
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 2, copies);
|
||||
@@ -2406,36 +2709,27 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
MOBILEGL_ASSERT(ok, "TransitionImageLayout to sampled read-only layout failed");
|
||||
outResource.layout = finalLayout;
|
||||
|
||||
VK_VERIFY(vkEndCommandBuffer(commandBuffer), "vkEndCommandBuffer(texture)");
|
||||
|
||||
VkSubmitInfo submitInfo{};
|
||||
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
|
||||
submitInfo.commandBufferCount = 1;
|
||||
submitInfo.pCommandBuffers = &commandBuffer;
|
||||
|
||||
VkFenceCreateInfo fenceInfo{};
|
||||
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
|
||||
VkFence uploadFence = VK_NULL_HANDLE;
|
||||
VK_VERIFY(vkCreateFence(m_device, &fenceInfo, nullptr, &uploadFence), "vkCreateFence(texture upload)");
|
||||
|
||||
VK_VERIFY(vkQueueSubmit(m_graphicsQueue, 1, &submitInfo, uploadFence), "vkQueueSubmit(texture)");
|
||||
// Do NOT wait the fence here: this submit sits behind the previous
|
||||
// frame's rendering on the queue, so a synchronous wait stalls the CPU
|
||||
// until the GPU drains - a per-frame vkQueueWaitIdle for any workload
|
||||
// with animated textures. Ordering against the current frame's draws is
|
||||
// already guaranteed (its command buffer is submitted later, at
|
||||
// present), so only the transient objects need to survive execution;
|
||||
// park them until the fence signals.
|
||||
m_pendingUploadReclaims.push_back({uploadFence, commandBuffer, stagingBuffer, stagingAllocation});
|
||||
ReclaimCompletedUploads();
|
||||
// Backstop for pathological upload storms: bound in-flight staging
|
||||
// memory by blocking on the oldest upload only once the list is deep.
|
||||
constexpr SizeT kMaxPendingTextureUploads = 16;
|
||||
if (m_pendingUploadReclaims.size() > kMaxPendingTextureUploads) {
|
||||
VK_VERIFY(vkWaitForFences(m_device, 1, &m_pendingUploadReclaims.front().fence, VK_TRUE, UINT64_MAX),
|
||||
"vkWaitForFences(texture upload backstop)");
|
||||
ReclaimCompletedUploads();
|
||||
// Ordering argument (replaces the old immediate per-texture submit):
|
||||
// this upload is RECORDED into the shared batch command buffer, which
|
||||
// FlushPendingUploads submits - with one vkQueueSubmit and one pooled
|
||||
// fence for the whole batch - strictly BEFORE any other submission on
|
||||
// the same queue whose commands could consume the image: the renderer
|
||||
// flushes at every frame-command-buffer submit (mid-frame flush,
|
||||
// readback, Present), and the texture manager flushes before the
|
||||
// preserve-on-recreate copy and before deferring an image the batch
|
||||
// references. The frame command buffer therefore still lands behind
|
||||
// the uploads on the queue, so a texture uploaded and then immediately
|
||||
// sampled in the same frame sees its data exactly as it did when each
|
||||
// upload was its own submit. No fence is waited here, for the same
|
||||
// reason as before: the batch queues behind the previous frame's
|
||||
// rendering, and a synchronous wait would drain the GPU; the staging
|
||||
// blocks/command buffer are parked on the reclaim list at flush time
|
||||
// and recycled once the batch fence signals.
|
||||
if (std::find(m_uploadBatchImages.begin(), m_uploadBatchImages.end(), outResource.image) ==
|
||||
m_uploadBatchImages.end()) {
|
||||
m_uploadBatchImages.push_back(outResource.image);
|
||||
}
|
||||
m_uploadBatchStagingBytes += stagingSize;
|
||||
|
||||
if (!ok) {
|
||||
MGLOG_D("%s: texture upload cmd failed", __func__);
|
||||
@@ -2445,6 +2739,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
mipmapTexture.MarkStorageDirty(item.target, item.level, false);
|
||||
}
|
||||
outResource.layout = finalLayout;
|
||||
// Large batches flush right away instead of riding until the frame
|
||||
// submit: a big copy amortizes its own vkQueueSubmit, submitting it
|
||||
// early lets the GPU overlap the copy with the rest of the frame's
|
||||
// CPU recording (measurably faster than a frame-tail burst), and the
|
||||
// frame-tail burst pattern was observed to leave the GPU in a
|
||||
// latency state that taxes whatever runs next. Small uploads keep
|
||||
// accumulating, so a lightmap+sprite frame still costs one submit.
|
||||
constexpr VkDeviceSize kEagerUploadFlushBytes = 128u * 1024u;
|
||||
if (m_uploadBatchStagingBytes >= kEagerUploadFlushBytes) {
|
||||
FlushPendingUploads();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,11 @@ public:
|
||||
VkPipelineStageFlags sampledReadStageMask = VK_PIPELINE_STAGE_VERTEX_SHADER_BIT |
|
||||
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT |
|
||||
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT;
|
||||
// Family of `graphicsQueue`; the manager creates its own command pool
|
||||
// on it for the recycled upload-batch command buffers, so their parked
|
||||
// allocations never sit in (and fragment) the renderer's shared pool
|
||||
// that frame command buffers churn through every frame.
|
||||
Uint32 graphicsQueueFamilyIndex = 0;
|
||||
};
|
||||
|
||||
struct TextureResource {
|
||||
@@ -308,6 +313,14 @@ public:
|
||||
Bool Initialize(const InitInfo& initInfo);
|
||||
void Shutdown();
|
||||
void BeginFrame(Uint32 frameIndex);
|
||||
// Submits the accumulated texture-upload batch (one command buffer, one
|
||||
// vkQueueSubmit, one pooled fence) if any uploads are pending. MUST run
|
||||
// before any other vkQueueSubmit on the shared graphics queue whose
|
||||
// commands may consume an image the batch writes - the frame command
|
||||
// buffer submit (mid-frame flush, readback, Present) and the
|
||||
// preserve-on-recreate copy are the existing callers. No-op when the
|
||||
// batch is empty.
|
||||
void FlushPendingUploads();
|
||||
// Drains every frame slot's deferred image/view releases. Only valid when
|
||||
// the caller has proven every queue submission complete; used by the
|
||||
// present-less frame-boundary drain.
|
||||
@@ -453,6 +466,9 @@ private:
|
||||
VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE;
|
||||
VmaAllocator m_allocator = nullptr;
|
||||
VkCommandPool m_commandPool = VK_NULL_HANDLE;
|
||||
// Dedicated pool for the recycled upload-batch command buffers (see
|
||||
// InitInfo::graphicsQueueFamilyIndex).
|
||||
VkCommandPool m_uploadCommandPool = VK_NULL_HANDLE;
|
||||
VkQueue m_graphicsQueue = VK_NULL_HANDLE;
|
||||
Bool m_imageFormatListSupported = false;
|
||||
Uint32 m_currentFrameIndex = 0;
|
||||
@@ -506,15 +522,58 @@ private:
|
||||
std::unordered_map<VkFormat, VkSampleCountFlags> m_multisampleCountsByFormat;
|
||||
Vector<Vector<TextureResource>> m_deferredReleases;
|
||||
Vector<Vector<VkImageView>> m_deferredViewReleases;
|
||||
|
||||
// --- Batched upload machinery ---
|
||||
// Uploads within a frame are recorded into ONE shared command buffer and
|
||||
// submitted with ONE vkQueueSubmit at FlushPendingUploads (the renderer
|
||||
// flushes before every frame-command-buffer submit). Staging memory comes
|
||||
// from a pool of persistently-mapped, reusable blocks instead of a
|
||||
// vmaCreateBuffer per upload.
|
||||
struct UploadStagingBlock {
|
||||
VkBuffer buffer = VK_NULL_HANDLE;
|
||||
VmaAllocation allocation = nullptr;
|
||||
Uint8* mapped = nullptr; // persistently mapped for the block's lifetime
|
||||
VkDeviceSize capacity = 0;
|
||||
VkDeviceSize cursor = 0; // bump cursor while the block backs the open batch
|
||||
};
|
||||
// Opens the batch command buffer lazily (allocates/reuses + begins recording).
|
||||
VkCommandBuffer EnsureUploadBatchOpen();
|
||||
// Bump-allocates `size` staging bytes for the open batch, growing onto a
|
||||
// new/pooled block when the current one cannot fit. Returns the write
|
||||
// pointer; outBuffer/outBaseOffset locate the space for copy commands.
|
||||
Uint8* AcquireUploadStagingSpace(VkDeviceSize size, VkBuffer& outBuffer, VkDeviceSize& outBaseOffset);
|
||||
void RecycleUploadStagingBlock(UploadStagingBlock&& block);
|
||||
// Drops a recorded-but-unsubmitted batch on the floor. Shutdown only: the
|
||||
// device is being torn down, so the lost texel data is unobservable.
|
||||
void DiscardPendingUploadBatch();
|
||||
void DestroyUploadPools();
|
||||
|
||||
Vector<UploadStagingBlock> m_freeUploadStagingBlocks;
|
||||
VkDeviceSize m_freeUploadStagingBytes = 0;
|
||||
Vector<VkCommandBuffer> m_freeUploadCommandBuffers;
|
||||
Vector<VkFence> m_freeUploadFences;
|
||||
Bool m_uploadBatchOpen = false;
|
||||
VkCommandBuffer m_uploadBatchCommandBuffer = VK_NULL_HANDLE;
|
||||
// Blocks whose staging bytes the open batch's copies reference (last =
|
||||
// the block the bump cursor is currently allocating from).
|
||||
Vector<UploadStagingBlock> m_uploadBatchBlocks;
|
||||
// Images the open batch writes; consulted for the rare re-upload-after-
|
||||
// draw flush and by DeferResourceRelease (an unsubmitted command buffer
|
||||
// referencing a deferred-released image would escape every fence-based
|
||||
// destruction proof, so the batch is flushed before the image is parked).
|
||||
Vector<VkImage> m_uploadBatchImages;
|
||||
VkDeviceSize m_uploadBatchStagingBytes = 0;
|
||||
|
||||
// Texture uploads are submitted out-of-band but NOT waited on (waiting
|
||||
// behind the queue serialized the CPU against the previous frame's GPU
|
||||
// work every time an animated atlas re-uploaded). Their transient objects
|
||||
// are parked here and reclaimed once the upload fence signals.
|
||||
// work every time an animated atlas re-uploaded). Each flushed batch's
|
||||
// transients are parked here and RECYCLED (fence reset to the fence pool,
|
||||
// command buffer reset to the CB pool, staging blocks back to the block
|
||||
// pool) once the batch fence signals.
|
||||
struct PendingUploadReclaim {
|
||||
VkFence fence = VK_NULL_HANDLE;
|
||||
VkCommandBuffer commandBuffer = VK_NULL_HANDLE;
|
||||
VkBuffer stagingBuffer = VK_NULL_HANDLE;
|
||||
VmaAllocation stagingAllocation = nullptr;
|
||||
Vector<UploadStagingBlock> stagingBlocks;
|
||||
};
|
||||
Vector<PendingUploadReclaim> m_pendingUploadReclaims;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -531,7 +563,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// Per-pipeline provoking-vertex mode. capturesXfbFromGeometryStage must be a LINK-TIME
|
||||
// property of the program, never the dynamic "is transform feedback active" flag: the
|
||||
// 8-entry m_pipelineMemo and the SetupDrawSnapshot fast path key on programObj.hash and
|
||||
// GetRenderStateParametersVersion(), neither of which moves when glBeginTransformFeedback is
|
||||
// the pipeline-state value hash, neither of which moves when glBeginTransformFeedback is
|
||||
// called, so a dynamic input here would hand back a stale VkPipeline.
|
||||
VkProvokingVertexModeEXT SelectProvokingVertexMode(VkPrimitiveTopology topology,
|
||||
Bool capturesXfbFromGeometryStage) const;
|
||||
@@ -623,7 +655,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Uint64 programHash = 0;
|
||||
Uint64 vertexInputHash = 0;
|
||||
Uint64 renderPassHash = 0;
|
||||
Uint renderStateVersion = 0;
|
||||
// VALUE hash of the pipeline-relevant fixed-function state (see
|
||||
// ComputePipelineStateHash), not the monotonic pipeline-state version:
|
||||
// the version never repeats, so a per-draw GL_BLEND toggle would miss
|
||||
// all entries forever even though the state alternates between two
|
||||
// values the memo already holds.
|
||||
Uint64 pipelineStateHash = 0;
|
||||
ProgramFactory::CompileOptionFlags transformFlags = {};
|
||||
VkPipeline pipeline = VK_NULL_HANDLE;
|
||||
};
|
||||
@@ -631,11 +668,40 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
PipelineMemoEntry m_pipelineMemo[kPipelineMemoSize];
|
||||
Uint32 m_pipelineMemoCount = 0;
|
||||
Uint32 m_pipelineMemoNext = 0;
|
||||
// Hash of every fixed-function GL state the pipeline payload reads that the
|
||||
// memo key's other fields (mode / program / vertex input / render pass /
|
||||
// transform flags) do not already pin down. Equal hash under an equal rest
|
||||
// of key => byte-identical PipelineCreatePayload. Cached per pipeline-state
|
||||
// version: the version is monotonic and bumps on every pipeline-state
|
||||
// change, so an unchanged (version, colorAttachmentCount) proves the state
|
||||
// bytes are unchanged and the hash can be reused without re-reading them.
|
||||
Uint64 ComputePipelineStateHash(Uint32 colorAttachmentCount) const;
|
||||
Uint m_pipelineStateHashVersion = 0;
|
||||
Uint32 m_pipelineStateHashColorCount = 0;
|
||||
Uint64 m_pipelineStateHash = 0;
|
||||
Bool m_pipelineStateHashValid = false;
|
||||
// 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;
|
||||
// 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.
|
||||
// boundaries and whenever any pipeline may have been destroyed. Also drops
|
||||
// the cached pipeline-state hash: the same boundaries can retire the GL
|
||||
// context whose monotonic version the cache is keyed on.
|
||||
void InvalidatePipelineMemo() {
|
||||
m_pipelineMemoCount = 0;
|
||||
m_pipelineMemoNext = 0;
|
||||
m_pipelineStateHashValid = false;
|
||||
}
|
||||
UnorderedMap<ProgramFactory::HashType, VkPipeline> m_computePipelines;
|
||||
UniquePtr<ProgramFactory> m_programFactory;
|
||||
@@ -692,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;
|
||||
@@ -707,14 +779,77 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Uint64 renderbufferImageEpoch = 0;
|
||||
Uint64 sampledContentSum = 0;
|
||||
Uint64 sampledParamsSum = 0;
|
||||
// Guards the sampler-descriptor reuse hint: bumped by any sampler-object
|
||||
// parameter or texture shape change (see GetSamplingResolutionGeneration),
|
||||
// none of which the sums above cover.
|
||||
Uint64 samplingResolutionGeneration = 0;
|
||||
// Render-pass flavor input (DepthTest || StencilTest at snapshot time).
|
||||
// A pipeline-state change that leaves this equal cannot change which
|
||||
// render pass GetOrCreateRenderPass would pick, so the fast path may
|
||||
// re-resolve just the pipeline against the active pass; a change that
|
||||
// flips it must fall back to the full path's pass selection.
|
||||
Bool drawUsesDepthStencil = false;
|
||||
IntVec2 renderPassExtent = {0, 0};
|
||||
// colorAttachmentCount of the snapshotting draw's render pass: the
|
||||
// pipeline-state hash input, so the fast path can refresh that hash and
|
||||
// probe the pipeline memo after a state change without re-fetching the
|
||||
// render-pass entry (the pass itself is pinned by renderPassHash above).
|
||||
Uint32 renderPassColorCount = 0;
|
||||
VkPipeline pipeline = VK_NULL_HANDLE;
|
||||
// layoutHash of the snapshotting draw's vertex-input state. The pipeline and
|
||||
// the vertex-input pre-flight depend on the VAO only through this (plus the
|
||||
// program, pinned separately), so a changed VAO whose aux memo carries the
|
||||
// same layoutHash re-uses the snapshot's pipeline and pre-flight verdict
|
||||
// outright - the VAO-cycling case Minecraft chunk rendering hits every draw.
|
||||
Uint64 vaoLayoutHash = 0;
|
||||
// Memoised ProgramFactory entry of the snapshotting draw, valid while
|
||||
// (programLifetimeId, programVersion, resolvedTransformFlags) match - all
|
||||
// checked above - AND the factory's cache structure epoch is unchanged (the
|
||||
// cache is open-addressing and holds entries by value, so any insert/erase
|
||||
// moves them). The fast path must re-stamp use through StampProgramUse when
|
||||
// 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.
|
||||
Vector<MG_State::GLState::ITextureObject*> m_sampledTexturesScratch;
|
||||
// Per-binding (texture, effective sampler) lifetime-id records from the same
|
||||
// CollectSampledTextures walk that filled m_sampledTexturesScratch. The fast
|
||||
// path shadow-compares against them (SampledBindingsUnchanged) when the
|
||||
// texture bind generation moved, so a redundant glBindSampler/glBindTexture
|
||||
// storm that resolves to the same bindings keeps the fast path.
|
||||
Vector<UniformManager::SampledBindingRecord> m_sampledBindingRecordsScratch;
|
||||
// Parallel to m_sampledTexturesScratch, refilled by every SetupDraw's
|
||||
// first sampled-texture loop: the resolved backend resources, so the
|
||||
// post-transition loop can skip re-resolving textures whose layout is
|
||||
@@ -779,6 +914,138 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
UnorderedMap<ConvertedVertexStreamKey, ConvertedVertexStream, ConvertedVertexStreamKeyHash>
|
||||
m_convertedVertexStreams;
|
||||
|
||||
// One VAO's resolved vkCmdBindVertexBuffers arguments, reusable by a later draw
|
||||
// that would resolve them to the same thing. Consecutive draws in a chunk-renderer
|
||||
// frame keep the program and the vertex layout and only swap the VAO, so a
|
||||
// per-VAO memo turns the second and later draws through each VAO into a validate
|
||||
// plus (usually skipped) rebind.
|
||||
//
|
||||
// Only whole-buffer bindings are memoised. Client-memory and format-converted
|
||||
// streams re-upload from a range that depends on the draw's own vertex/index
|
||||
// range, and synthetic bindings carry glVertexAttrib* values that are not part
|
||||
// of any key here; a layout using any of them is never stored.
|
||||
// Field order is hit-path cache locality, hot to cold: the per-draw validate
|
||||
// reads the scalars and the EBO memo head, then only the first bindingCount
|
||||
// elements of vkBuffers/vkOffsets; the per-binding revalidation arrays at the
|
||||
// tail are touched once per frame at most.
|
||||
struct ResolvedVertexBindings {
|
||||
// Must equal DynamicStateShadow::kMaxShadowedVertexBindings (static_assert in
|
||||
// the .cpp): past that width the bind shadow cannot skip a redundant bind
|
||||
// either, so a wider layout resolves per draw. Minecraft-shaped layouts use four.
|
||||
static constexpr Uint32 kMaxBindings = 8;
|
||||
|
||||
// Frame serial of the last completed resolve OR cross-frame revalidation.
|
||||
// Zero until a resolve completes, and reset to zero before one starts, so a
|
||||
// resolve that bails out midway cannot leave a half-filled entry matchable.
|
||||
// Unlike the original frame-scoped memo, an entry whose buffers are all
|
||||
// resident and unmapped is revalidated across frames (per-binding slice
|
||||
// epoch compares) instead of re-resolved - see TryBindResolvedVertexBindings.
|
||||
Uint64 frameSerial = 0;
|
||||
// Identity of the resolved Vulkan layout: the VAO's content hash
|
||||
// (VertexInputStateFactory::GetOrComputeHash - the same value the factory
|
||||
// keys its entries on) fixes bindings.size(), each binding's base offset,
|
||||
// which bindings are client/converted, and (through the mixed-in buffer
|
||||
// addresses) which buffer each binding reads. Compared against the VAO's
|
||||
// own hash memo on the hit path, so a hit never touches the factory entry.
|
||||
VertexInputStateFactory::HashType vertexInputHash = 0;
|
||||
// The program's vertex input layout: decides the synthetic-binding set and
|
||||
// hence the total binding count.
|
||||
Uint32 activeAttribMask = 0;
|
||||
Uint32 bindingCount = 0;
|
||||
// VkBufferManager::GetSliceEpochCounter() at resolve time. Still equal means
|
||||
// no buffer anywhere changed its slice or was persistently mapped since, which
|
||||
// settles every per-binding question below in one compare.
|
||||
Uint64 sliceEpochCounter = 0;
|
||||
// Any bound buffer already carrying a host map when the slice was resolved.
|
||||
// Such a buffer can mutate its shadow with no API call, so it has to be
|
||||
// re-pushed per draw and the one-compare path above cannot apply.
|
||||
Bool anyBufferMapped = true;
|
||||
|
||||
// Resident element-buffer slice memo (skips the per-draw AcquireResidentSlice
|
||||
// for the VAO's EBO, which cold-chases 500+ distinct resources in a
|
||||
// chunk-cycling frame). Self-validating exactly like the bindings above: a hit
|
||||
// requires the LIVE bound EBO pointer to equal indexBuffer AND either an
|
||||
// unmoved manager-wide slice-epoch counter (nothing anywhere changed slices
|
||||
// or gained a host map, the same one-compare rescue the vertex half uses) or
|
||||
// that buffer's resource still carrying indexSliceEpoch (epochs are minted
|
||||
// from a process-lifetime counter, so a recycled address can never
|
||||
// revalidate). Restart-substituted and streamed EBOs are never stored.
|
||||
// indexFrameSerial tracks the last frame the resource's GPU-use serial was
|
||||
// stamped through this memo; 0 means no index memo. Independent of the
|
||||
// vertex half: both are (pointer, epoch)-validated, so neither can serve
|
||||
// stale state for the other.
|
||||
const MG_State::GLState::BufferObject* indexBuffer = nullptr;
|
||||
Uint64 indexSliceEpoch = 0;
|
||||
// GetSliceEpochCounter() when the resource's epoch was last verified; only
|
||||
// meaningful while indexFrameSerial matches the current frame serial.
|
||||
Uint64 indexSliceEpochCounter = 0;
|
||||
VkBuffer indexVkBuffer = VK_NULL_HANDLE;
|
||||
VkDeviceSize indexSliceOffset = 0;
|
||||
Uint64 indexFrameSerial = 0;
|
||||
|
||||
// Bound per draw (first bindingCount elements).
|
||||
VkBuffer vkBuffers[kMaxBindings] = {};
|
||||
VkDeviceSize vkOffsets[kMaxBindings] = {};
|
||||
// Per binding: the VAO attribute location its buffer comes from, that buffer,
|
||||
// and the buffer's VkBufferManager slice epoch when the slice was resolved.
|
||||
// Only read by the per-frame revalidation and the something-moved fallback.
|
||||
Uint8 attributeLocations[kMaxBindings] = {};
|
||||
const MG_State::GLState::BufferObject* buffers[kMaxBindings] = {};
|
||||
Uint64 sliceEpochs[kMaxBindings] = {};
|
||||
};
|
||||
// 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 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;
|
||||
Bool layoutFactsValid = false;
|
||||
// The resolved layout identity + packed (unsupported, location) masks -
|
||||
// the exact values GetBackendAuxMemo used to serve, moved here so the
|
||||
// per-draw probe stays inside this table's one hot line instead of
|
||||
// touching a second cold line of every cycled VAO object.
|
||||
Uint64 layoutHash = 0;
|
||||
Uint64 layoutAuxMasks = 0;
|
||||
ResolvedVertexBindings bindings;
|
||||
};
|
||||
// Fixed-size, allocated on first use, never rehashed or swept: entries are
|
||||
// recycled in place on slot collisions (two-slot probe, older frame serial
|
||||
// evicted), and stale entries self-invalidate through the compares above. A
|
||||
// fixed table also makes every VaoDrawMemo/ResolvedVertexBindings pointer
|
||||
// stable for the duration of a draw, which the EBO memo handoff
|
||||
// (m_currentDrawResolvedEntry) relies on.
|
||||
static constexpr Uint32 kVaoDrawMemoSlotCount = 2048; // power of two
|
||||
Vector<VaoDrawMemo> m_vaoDrawMemoTable;
|
||||
// Finds the slot holding `vao`, or recycles the older of its two candidate
|
||||
// slots into an empty memo keyed on `vao`. Never returns null.
|
||||
VaoDrawMemo* LookupVaoDrawMemo(const MG_State::GLState::VertexArrayObject* vao);
|
||||
// The current draw's memo entry, set by UploadAndBindVertexBuffers and consumed
|
||||
// by the same draw's UploadAndBindIndexBuffer (the EBO memo lives in the same
|
||||
// entry). Valid ONLY within that window: the next draw's lookup can recycle the
|
||||
// slot. Null when the draw's layout is not memoisable.
|
||||
ResolvedVertexBindings* m_currentDrawResolvedEntry = nullptr;
|
||||
|
||||
void CreateInstance();
|
||||
VkResult SetupDebugMessenger();
|
||||
VkResult DestroyDebugMessenger();
|
||||
@@ -809,10 +1076,25 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj);
|
||||
|
||||
// The per-draw dynamic-state tail (viewport, scissor, blend constants, depth
|
||||
// bias, line width, stencil), gated behind one render-state-parameters-version
|
||||
// compare per command buffer - see the gate fields in DynamicStateShadow.
|
||||
void ApplyDynamicDrawStateTail(FrameContext::FrameData& frame, const IntVec2& extent, Bool isDefaultFbo);
|
||||
|
||||
Bool UploadAndBindVertexBuffers(VkCommandBuffer commandBuffer, const MG_State::GLState::VertexArrayObject& vao,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
const DrawCmdParam& drawParams,
|
||||
const IndexBufferView* pIndexBufferView);
|
||||
// Binds `entry`'s memoised buffers when every input it was resolved from is
|
||||
// still live and unchanged, else returns false and leaves nothing bound.
|
||||
// vaoContentHash is the VAO's memoised content hash (GetBackendHashMemo), which
|
||||
// pins the layout AND the bound buffers without resolving the factory entry.
|
||||
// Non-const entry: a cross-frame revalidation refreshes its serial/epoch stamps.
|
||||
Bool TryBindResolvedVertexBindings(VkCommandBuffer commandBuffer,
|
||||
const MG_State::GLState::VertexArrayObject& vao,
|
||||
ResolvedVertexBindings& entry,
|
||||
Uint64 vaoContentHash,
|
||||
Uint32 activeAttribMask, Uint64 frameSerial);
|
||||
Bool UploadAndBindIndexBuffer(FrameContext::FrameData& frame,
|
||||
const MG_State::GLState::VertexArrayObject& vao,
|
||||
const IndexBufferView* pIndexBufferView = nullptr);
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
* libEGL.so.1 for the native driver, or a libMobileGL.so path for either
|
||||
* MobileGL backend selected with MOBILEGL_BACKEND_TYPE), creates a desktop-GL
|
||||
* context on a small pbuffer, renders into its own FBO and paces frames with
|
||||
* glFinish. No window system is required beyond what the provider itself
|
||||
* needs - see run_driver_bench.sh.
|
||||
* glFinish. No window system is required: the default display is tried first
|
||||
* so a desktop run reaches the real driver, and a headless box (CI, a build
|
||||
* server) falls back to EGL_MESA_platform_surfaceless - see
|
||||
* run_driver_bench.sh.
|
||||
*
|
||||
* Every case models one hot pattern from captured Minecraft traces:
|
||||
* draw_tiny back-to-back glDrawElements, shared state (chunk batch)
|
||||
@@ -67,6 +69,7 @@ typedef unsigned int EGLenum;
|
||||
#define EGL_CONTEXT_MINOR_VERSION 0x30FB
|
||||
#define EGL_CONTEXT_OPENGL_PROFILE_MASK 0x30FD
|
||||
#define EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT 0x00000001
|
||||
#define EGL_PLATFORM_SURFACELESS_MESA 0x31DD
|
||||
|
||||
/* ---- GL constants ---- */
|
||||
#define GL_COLOR_BUFFER_BIT 0x00004000
|
||||
@@ -89,6 +92,11 @@ typedef unsigned int EGLenum;
|
||||
#define GL_NEAREST 0x2600
|
||||
#define GL_NEAREST_MIPMAP_LINEAR 0x2702
|
||||
#define GL_DEPTH_TEST 0x0B71
|
||||
#define GL_BLEND 0x0BE2
|
||||
#define GL_SRC_ALPHA 0x0302
|
||||
#define GL_ONE_MINUS_SRC_ALPHA 0x0303
|
||||
#define GL_ONE 1
|
||||
#define GL_ZERO 0
|
||||
#define GL_VERTEX_SHADER 0x8B31
|
||||
#define GL_FRAGMENT_SHADER 0x8B30
|
||||
#define GL_COMPILE_STATUS 0x8B81
|
||||
@@ -133,6 +141,9 @@ static void* g_provider;
|
||||
GLF(void, glClear, (unsigned))
|
||||
GLF(void, glClearColor, (float, float, float, float))
|
||||
GLF(void, glEnable, (GLenum))
|
||||
GLF(void, glDisable, (GLenum))
|
||||
GLF(void, glBlendFuncSeparate, (GLenum, GLenum, GLenum, GLenum))
|
||||
GLF(void, glDrawBuffers, (GLsizei, const GLenum*))
|
||||
GLF(void, glViewport, (GLint, GLint, GLsizei, GLsizei))
|
||||
GLF(const unsigned char*, glGetString, (GLenum))
|
||||
GLF(GLenum, glGetError, (void))
|
||||
@@ -280,7 +291,21 @@ static void run_case(const char* name, case_fn body, long a, long b, long opsPer
|
||||
if (glGetError() != GL_NO_ERROR) fprintf(stderr, "WARN: GL error after %s\n", name);
|
||||
}
|
||||
|
||||
/* a = draws per frame */
|
||||
/* A display that needs no window system. eglGetPlatformDisplay is EGL 1.5
|
||||
* core and eglGetPlatformDisplayEXT is the EGL_EXT_platform_base spelling
|
||||
* older loaders ship; both are client entry points, so they resolve before
|
||||
* any display exists. Only the attribute-list types differ between the two
|
||||
* and this passes none, so one cast covers both. */
|
||||
static EGLDisplay surfaceless_display(void) {
|
||||
void* fn = dlsym(g_provider, "eglGetPlatformDisplay");
|
||||
if (!fn) fn = g_eglGetProcAddress("eglGetPlatformDisplay");
|
||||
if (!fn) fn = dlsym(g_provider, "eglGetPlatformDisplayEXT");
|
||||
if (!fn) fn = g_eglGetProcAddress("eglGetPlatformDisplayEXT");
|
||||
if (!fn) return NULL;
|
||||
return ((EGLDisplay(*)(EGLenum, void*, const void*))fn)(EGL_PLATFORM_SURFACELESS_MESA,
|
||||
EGL_DEFAULT_DISPLAY, NULL);
|
||||
}
|
||||
|
||||
/* ---- EGL bootstrap: one provider library, pbuffer, desktop-GL context ---- */
|
||||
static int boot_egl(void) {
|
||||
const char* libpath = getenv("DRIVERBENCH_EGL_LIB");
|
||||
@@ -304,14 +329,30 @@ static int boot_egl(void) {
|
||||
ESYM(eglGetError)
|
||||
g_eglGetProcAddress = (void* (*)(const char*))p_eglGetProcAddress;
|
||||
|
||||
EGLDisplay dpy = ((EGLDisplay(*)(void*))p_eglGetDisplay)(EGL_DEFAULT_DISPLAY);
|
||||
if (!dpy) { fprintf(stderr, "FAIL: eglGetDisplay\n"); return 1; }
|
||||
EGLint (*getError)(void) = (EGLint(*)(void))p_eglGetError;
|
||||
EGLBoolean (*initialize)(EGLDisplay, EGLint*, EGLint*) =
|
||||
(EGLBoolean(*)(EGLDisplay, EGLint*, EGLint*))p_eglInitialize;
|
||||
|
||||
/* The default display first: it is the one a windowed app would get, and
|
||||
* on a desktop it is the one that reaches the real GPU - which is the
|
||||
* driver this bench exists to measure. It does need a window system,
|
||||
* though; Mesa's default platform is X11, so with no $DISPLAY (CI, a
|
||||
* build server, ssh without forwarding) eglInitialize fails. Fall back to
|
||||
* EGL_MESA_platform_surfaceless rather than give up: every case draws into
|
||||
* the FBO built by build_resources(), so no window is needed for any of
|
||||
* the work being timed. */
|
||||
EGLint maj = 0, min = 0;
|
||||
if (!((EGLBoolean(*)(EGLDisplay, EGLint*, EGLint*))p_eglInitialize)(dpy, &maj, &min)) {
|
||||
fprintf(stderr, "FAIL: eglInitialize (0x%x)\n", ((EGLint(*)(void))p_eglGetError)());
|
||||
return 1;
|
||||
const char* how = "default display";
|
||||
EGLDisplay dpy = ((EGLDisplay(*)(void*))p_eglGetDisplay)(EGL_DEFAULT_DISPLAY);
|
||||
if (!dpy || !initialize(dpy, &maj, &min)) {
|
||||
dpy = surfaceless_display();
|
||||
how = "surfaceless display";
|
||||
if (!dpy || !initialize(dpy, &maj, &min)) {
|
||||
fprintf(stderr, "FAIL: eglInitialize (0x%x)\n", getError());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
fprintf(stderr, "EGL %d.%d via %s\n", maj, min, libpath);
|
||||
fprintf(stderr, "EGL %d.%d via %s (%s)\n", maj, min, libpath, how);
|
||||
|
||||
// Desktop GL first (that is what MobileGL exposes and what the cases are
|
||||
// written against), GLES 3 second so the same binary can measure a device's
|
||||
@@ -348,7 +389,10 @@ static int boot_egl(void) {
|
||||
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_NONE};
|
||||
ncfg = 0;
|
||||
if (!chooseConfig(dpy, esCfgAttribs, &cfg, 1, &ncfg) || ncfg < 1) {
|
||||
const EGLint relaxed[] = {EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RED_SIZE, 8, EGL_NONE};
|
||||
// EGL_SURFACE_TYPE 0 matches any config: a stack that offers no
|
||||
// pbuffer at all is still usable through the surfaceless context
|
||||
// path below.
|
||||
const EGLint relaxed[] = {EGL_SURFACE_TYPE, 0, EGL_RED_SIZE, 8, EGL_NONE};
|
||||
if (!chooseConfig(dpy, relaxed, &cfg, 1, &ncfg) || ncfg < 1) {
|
||||
fprintf(stderr, "FAIL: eglChooseConfig\n");
|
||||
return 1;
|
||||
@@ -358,20 +402,22 @@ static int boot_egl(void) {
|
||||
ctx = createContext(dpy, cfg, EGL_NO_CONTEXT, esCtxAttribs);
|
||||
}
|
||||
if (ctx == EGL_NO_CONTEXT) {
|
||||
fprintf(stderr, "FAIL: eglCreateContext (0x%x)\n", ((EGLint(*)(void))p_eglGetError)());
|
||||
fprintf(stderr, "FAIL: eglCreateContext (0x%x)\n", getError());
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* The pbuffer only exists to have something to make current - nothing is
|
||||
* ever drawn to it. Where there is no pbuffer config, EGL_NO_SURFACE is
|
||||
* exactly what EGL_KHR_surfaceless_context takes, so the same call covers
|
||||
* both. */
|
||||
const EGLint pbAttribs[] = {EGL_WIDTH, 64, EGL_HEIGHT, 64, EGL_NONE};
|
||||
EGLSurface surf = ((EGLSurface(*)(EGLDisplay, EGLConfig, const EGLint*))p_eglCreatePbufferSurface)(
|
||||
dpy, cfg, pbAttribs);
|
||||
if (surf == EGL_NO_SURFACE) {
|
||||
fprintf(stderr, "FAIL: eglCreatePbufferSurface (0x%x)\n", ((EGLint(*)(void))p_eglGetError)());
|
||||
return 1;
|
||||
}
|
||||
if (surf == EGL_NO_SURFACE)
|
||||
fprintf(stderr, "no pbuffer (0x%x), using a surfaceless context\n", getError());
|
||||
if (!((EGLBoolean(*)(EGLDisplay, EGLSurface, EGLSurface, EGLContext))p_eglMakeCurrent)(dpy, surf,
|
||||
surf, ctx)) {
|
||||
fprintf(stderr, "FAIL: eglMakeCurrent (0x%x)\n", ((EGLint(*)(void))p_eglGetError)());
|
||||
fprintf(stderr, "FAIL: eglMakeCurrent (0x%x)\n", getError());
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -385,6 +431,7 @@ static int boot_egl(void) {
|
||||
if (!name) { fprintf(stderr, "FAIL: resolve %s\n", #name); return 1; } \
|
||||
} while (0)
|
||||
RESOLVE(glClear); RESOLVE(glClearColor); RESOLVE(glEnable); RESOLVE(glViewport);
|
||||
RESOLVE(glDisable); RESOLVE(glBlendFuncSeparate); RESOLVE(glDrawBuffers);
|
||||
RESOLVE(glGetString); RESOLVE(glGetError); RESOLVE(glFinish); RESOLVE(glFlush);
|
||||
RESOLVE(glGenBuffers); RESOLVE(glBindBuffer); RESOLVE(glBufferData); RESOLVE(glBufferSubData);
|
||||
RESOLVE(glGenVertexArrays); RESOLVE(glBindVertexArray); RESOLVE(glEnableVertexAttribArray);
|
||||
|
||||
@@ -34,6 +34,9 @@ static GLuint g_uboRing;
|
||||
static GLint g_uboAlign = 256;
|
||||
static size_t g_uboSlot = 256;
|
||||
static GLuint g_sampler;
|
||||
/* Two small offscreen targets for the 26.2-style render-pass churn case. */
|
||||
static GLuint g_passFbo[2];
|
||||
static GLuint g_passColor[2];
|
||||
static float g_mvp[16] = {0.002f, 0, 0, 0, 0, 0.002f, 0, 0, 0, 0, -0.001f, 0, -1.f, -1.f, 0.f, 1.f};
|
||||
|
||||
/* Minecraft chunk vertex: pos 3f, color 4ub, uv 2f, packed light 2s -> 32 B */
|
||||
@@ -162,10 +165,13 @@ static void setup_vao(GLuint vao, GLuint vbo, GLuint ibo) {
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
|
||||
}
|
||||
|
||||
static GLuint g_mainFbo;
|
||||
|
||||
static void build_resources(void) {
|
||||
/* offscreen render target: 1280x720 RBO FBO, like CTS fbo surface mode */
|
||||
GLuint fbo, rboColor, rboDepth;
|
||||
glGenFramebuffers(1, &fbo);
|
||||
g_mainFbo = fbo;
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
||||
glGenRenderbuffers(1, &rboColor);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, rboColor);
|
||||
@@ -257,6 +263,21 @@ static void build_resources(void) {
|
||||
glBufferData(GL_UNIFORM_BUFFER, 4 * 1024 * 1024, g_scratch, GL_DYNAMIC_DRAW);
|
||||
glBindBuffer(GL_UNIFORM_BUFFER, 0);
|
||||
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
glGenFramebuffers(1, &g_passFbo[i]);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, g_passFbo[i]);
|
||||
glGenRenderbuffers(1, &g_passColor[i]);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, g_passColor[i]);
|
||||
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 256, 256);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, g_passColor[i]);
|
||||
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
|
||||
bench_gl_failed("pass FBO incomplete", "");
|
||||
return;
|
||||
}
|
||||
}
|
||||
/* back to the main offscreen target the harness set up */
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, g_mainFbo);
|
||||
|
||||
glGenSamplers(1, &g_sampler);
|
||||
glSamplerParameteri(g_sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||
glSamplerParameteri(g_sampler, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||
@@ -516,6 +537,72 @@ static void case_mc_sampler_churn(int frame, long a, long b) {
|
||||
}
|
||||
|
||||
|
||||
/* 26.2 switches render targets constantly: 132 glBindFramebuffer and 198
|
||||
* glDrawBuffers per frame. Pass switching is where a Vulkan backend pays for
|
||||
* render-pass breaks, so this case is the one to watch on Magma. a = passes. */
|
||||
static void case_mc_pass_switch(int frame, long a, long b) {
|
||||
(void)frame; (void)b;
|
||||
static const GLenum kColor0[1] = {GL_COLOR_ATTACHMENT0};
|
||||
glBindVertexArray(g_vao[0]);
|
||||
for (long i = 0; i < a; ++i) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, g_passFbo[i & 1]);
|
||||
glDrawBuffers(1, kColor0);
|
||||
glViewport(0, 0, 256, 256);
|
||||
glDrawElements(GL_TRIANGLES, g_quadsPerSection * 6, GL_UNSIGNED_INT, 0);
|
||||
glDrawElements(GL_TRIANGLES, g_quadsPerSection * 6, GL_UNSIGNED_INT, 0);
|
||||
}
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, g_mainFbo);
|
||||
glViewport(0, 0, 1280, 720);
|
||||
}
|
||||
|
||||
/* Blaze3D toggles blend around batches: 46 glEnable/glDisable pairs and 28
|
||||
* glBlendFuncSeparate per vanilla frame. a = toggle pairs. */
|
||||
static void case_mc_state_toggle(int frame, long a, long b) {
|
||||
(void)frame; (void)b;
|
||||
glBindVertexArray(g_vao[0]);
|
||||
for (long i = 0; i < a; ++i) {
|
||||
glEnable(GL_BLEND);
|
||||
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO);
|
||||
glDrawElements(GL_TRIANGLES, g_quadsPerSection * 6, GL_UNSIGNED_INT, 0);
|
||||
glDisable(GL_BLEND);
|
||||
glDrawElements(GL_TRIANGLES, g_quadsPerSection * 6, GL_UNSIGNED_INT, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 26.2 re-sets texture parameters relentlessly - 612 glTexParameteri per frame,
|
||||
* almost always to the value already in place. Measures redundant-param
|
||||
* filtering. a = parameter writes. */
|
||||
static void case_mc_tex_param(int frame, long a, long b) {
|
||||
(void)frame; (void)b;
|
||||
glBindVertexArray(g_vao[0]);
|
||||
glBindTexture(GL_TEXTURE_2D, g_texAtlas);
|
||||
for (long i = 0; i < a; i += 4) {
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||
}
|
||||
glDrawElements(GL_TRIANGLES, g_quadsPerSection * 6, GL_UNSIGNED_INT, 0);
|
||||
}
|
||||
|
||||
/* Sodium switches programs mid-frame far more than vanilla: 62 glUseProgram and
|
||||
* 60 mat4 uploads per frame. a = program switches. */
|
||||
static void case_mc_use_program(int frame, long a, long b) {
|
||||
(void)frame; (void)b;
|
||||
glBindVertexArray(g_vao[0]);
|
||||
for (long i = 0; i < a; ++i) {
|
||||
if (i & 1) {
|
||||
glUseProgram(g_progEntity);
|
||||
glUniformMatrix4fv(g_uMvpEntity, 1, 0, g_mvp);
|
||||
} else {
|
||||
glUseProgram(g_progChunk);
|
||||
glUniformMatrix4fv(g_uMvpChunk, 1, 0, g_mvp);
|
||||
}
|
||||
glDrawElements(GL_TRIANGLES, g_quadsPerSection * 6, GL_UNSIGNED_INT, 0);
|
||||
}
|
||||
glUseProgram(g_progChunk);
|
||||
}
|
||||
|
||||
/* ---- the case table both harnesses iterate --------------------------------
|
||||
* a/b are the case's own knobs; opsPerFrame is what one bench frame is
|
||||
* normalised by, so ns_per_op compares across renderers. The mc_* rates are
|
||||
@@ -536,6 +623,10 @@ static const BenchCaseDesc kBenchCases[] = {
|
||||
{"mc_tex_stream", case_mc_tex_stream, 95, 0, 95},
|
||||
{"mc_uniform_lookup", case_mc_uniform_lookup, 41, 0, 41},
|
||||
{"mc_sampler_churn", case_mc_sampler_churn, 306, 0, 306},
|
||||
{"mc_pass_switch", case_mc_pass_switch, 132, 0, 132},
|
||||
{"mc_state_toggle", case_mc_state_toggle, 46, 0, 46},
|
||||
{"mc_tex_param", case_mc_tex_param, 612, 0, 612},
|
||||
{"mc_use_program", case_mc_use_program, 62, 0, 62},
|
||||
{"draw_tiny", case_draw_tiny, 2048, 0, 2048},
|
||||
{"draw_uniform", case_draw_uniform, 2048, 0, 2048},
|
||||
{"draw_multi_vao", case_draw_multi_vao, 2048, 0, 2048},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "GL_Buffer.h"
|
||||
#include "Validators.h"
|
||||
#include "../Texture/GL_Texture.h"
|
||||
#include "../Getter/GL_Getter.h"
|
||||
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
|
||||
#include <MG_Util/Metrics/TextureMetrics.h>
|
||||
#include <Config.h>
|
||||
@@ -861,6 +862,10 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
|
||||
Range1D mappedRange = bufferObject->GetMappedRange();
|
||||
auto mappingAccess = bufferObject->GetMappingAccess();
|
||||
// GL 4.6 6.5: the error is on OVERLAP with the mapped range, i.e. a half-open
|
||||
// intersection test. There used to be a second test below this one asking only
|
||||
// `offset + size >= mappedRange.start`, which rejects every write that starts
|
||||
// before a mapped tail as well - it made a legal disjoint glBufferSubData fail.
|
||||
if (bufferObject->IsMapped() && !(mappingAccess & BufferMappingAccessBit::Persistent) &&
|
||||
(offset < mappedRange.end) && (offset + size > mappedRange.start)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
@@ -871,18 +876,6 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bufferObject->IsMapped() && !(mappingAccess & BufferMappingAccessBit::Persistent)) {
|
||||
Range1D mappedRange = bufferObject->GetMappedRange();
|
||||
if (offset + size >= mappedRange.start) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "BufferSubData_State",
|
||||
"Cannot modify a mapped buffer object unless it was "
|
||||
"mapped with GL_MAP_PERSISTENT_BIT."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bufferObject->UploadSubData({(void*)data, (SizeT)size}, offset);
|
||||
}
|
||||
|
||||
@@ -1013,6 +1006,11 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
void BufferStorage_State(GLenum target, GLsizeiptr size, const void* data, GLbitfield flags) {
|
||||
// Error precedence: "no buffer is bound to target" outranks a bad size or bad
|
||||
// flags, so the binding has to be resolved before either is validated.
|
||||
auto bufferObject = GetBoundBufferObject(target, BufferOp::BufferStorage);
|
||||
if (!bufferObject) return;
|
||||
|
||||
if (size <= 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
@@ -1021,8 +1019,6 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
if (!ValidateStorageFlags(flags, BufferOp::BufferStorage)) return;
|
||||
|
||||
auto bufferObject = GetBoundBufferObject(target, BufferOp::BufferStorage);
|
||||
if (!bufferObject) return;
|
||||
if (bufferObject->IsImmutableStorage()) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
@@ -1057,6 +1053,10 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
void NamedBufferStorage_State(GLuint buffer, GLsizeiptr size, const void* data, GLbitfield flags) {
|
||||
// Same precedence as BufferStorage_State: the buffer-name error comes first.
|
||||
auto bufferObject = GetNamedBufferObject(buffer, BufferOp::NamedBufferStorage);
|
||||
if (!bufferObject) return;
|
||||
|
||||
if (size <= 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
@@ -1065,8 +1065,6 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
if (!ValidateStorageFlags(flags, BufferOp::NamedBufferStorage)) return;
|
||||
|
||||
auto bufferObject = GetNamedBufferObject(buffer, BufferOp::NamedBufferStorage);
|
||||
if (!bufferObject) return;
|
||||
if (bufferObject->IsImmutableStorage()) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
@@ -1486,12 +1484,71 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
GetBufferBindingSlot(bufferTarget).Bind(bufferObject);
|
||||
}
|
||||
|
||||
// GL 4.6 core 6.1.1: the constraints glBindBufferRange puts on the (offset, size) pair.
|
||||
// Every one of them is INVALID_VALUE, and all of them are checked before a single piece
|
||||
// of state is written - a rejected bind must leave the binding point exactly as it was.
|
||||
// They apply only to a non-zero buffer: buffer 0 detaches the binding point and ignores
|
||||
// offset and size, which is also how glBindBuffersRange spells "reset this element"
|
||||
// (a NULL buffers array, or a zero entry inside one).
|
||||
static Bool ValidateBufferRangeOffsetAndSize(GLenum target, GLintptr offset, GLsizeiptr size,
|
||||
const char* funcName) {
|
||||
if (size <= 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
|
||||
std::format("size ({}) must be greater than zero.", size)));
|
||||
return false;
|
||||
}
|
||||
if (offset < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
|
||||
std::format("offset ({}) must not be negative.", offset)));
|
||||
return false;
|
||||
}
|
||||
// GL_UNIFORM_BUFFER and GL_SHADER_STORAGE_BUFFER each constrain the offset to their own
|
||||
// implementation-defined alignment, which glGetIntegerv already answers.
|
||||
GLenum alignmentQuery = GL_NONE;
|
||||
if (target == GL_SHADER_STORAGE_BUFFER) {
|
||||
alignmentQuery = GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT;
|
||||
} else if (target == GL_UNIFORM_BUFFER) {
|
||||
alignmentQuery = GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT;
|
||||
}
|
||||
if (alignmentQuery != GL_NONE) {
|
||||
GLint alignment = 0;
|
||||
GetIntegerv(alignmentQuery, &alignment);
|
||||
if (alignment > 0 && (offset % static_cast<GLintptr>(alignment)) != 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>(
|
||||
"MG_Impl/GLImpl", funcName,
|
||||
std::format("offset ({}) must be a multiple of {} ({}).", offset,
|
||||
MG_Util::ConvertGLEnumToString(alignmentQuery), alignment)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// A transform feedback capture binding is addressed in 32-bit components, so BOTH the
|
||||
// offset and the size must be multiples of 4.
|
||||
if (target == GL_TRANSFORM_FEEDBACK_BUFFER && ((offset % 4) != 0 || (size % 4) != 0)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>(
|
||||
"MG_Impl/GLImpl", funcName,
|
||||
std::format("offset ({}) and size ({}) must both be multiples of 4 for "
|
||||
"GL_TRANSFORM_FEEDBACK_BUFFER.",
|
||||
offset, size)));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void BindBufferRange_State(GLenum target, GLuint index, GLuint buffer, GLintptr offset, GLsizeiptr size) {
|
||||
MGLOG_D("%s: target = %s, index = %u, buffer = %u, offset = %d, size = %d", __func__,
|
||||
MG_Util::ConvertGLEnumToString(target).c_str(), index, buffer, offset, size);
|
||||
BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target);
|
||||
if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return;
|
||||
if (!BufferImpl::ValidateBufferBindingPointIndex(bufferTarget, index)) return;
|
||||
if (buffer != 0 && !ValidateBufferRangeOffsetAndSize(target, offset, size, __func__)) return;
|
||||
if (bufferTarget == BufferTarget::TransformFeedback && MG_State::pGLContext->IsTransformFeedbackActive()) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
@@ -1665,15 +1722,32 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
}
|
||||
|
||||
// ARB_multi_bind: defined by the spec as equivalent to a loop over the single-bind entry
|
||||
// points (with buffer 0 resetting the binding point).
|
||||
// points (with buffer 0 resetting the binding point) - but only AFTER an up-front check
|
||||
// of the whole [first, first + count) range. Looping straight into the single-bind entry
|
||||
// points reports the single-bind INVALID_VALUE for an out-of-range index instead of the
|
||||
// multi-bind INVALID_OPERATION, and binds the in-range prefix before failing.
|
||||
static Bool ValidateMultiBindBufferRange(GLenum target, GLuint first, GLsizei count, const char* funcName) {
|
||||
BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target);
|
||||
if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return false;
|
||||
return BufferImpl::ValidateBufferBindingPointRange(bufferTarget, first, count, funcName);
|
||||
}
|
||||
|
||||
void BindBuffersBase(GLenum target, GLuint first, GLsizei count, const GLuint* buffers) {
|
||||
if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return;
|
||||
for (GLsizei i = 0; i < count; ++i) {
|
||||
BindBufferBase_State(target, first + i, buffers ? buffers[i] : 0);
|
||||
}
|
||||
}
|
||||
|
||||
// The (offset, size) constraints are the one part of glBindBuffersRange that stays
|
||||
// per-element: ARB_multi_bind checks them separately for each binding point, leaves that
|
||||
// point unchanged on failure, and still applies the remaining elements - which is exactly
|
||||
// what looping into BindBufferRange_State does. Only the [first, first + count) range is
|
||||
// an up-front, all-or-nothing check. Elements that name buffer 0 (or a NULL buffers array)
|
||||
// reset the binding point through BindBufferBase_State and carry no offset/size to check.
|
||||
void BindBuffersRange(GLenum target, GLuint first, GLsizei count, const GLuint* buffers, const GLintptr* offsets,
|
||||
const GLsizeiptr* sizes) {
|
||||
if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return;
|
||||
for (GLsizei i = 0; i < count; ++i) {
|
||||
if (!buffers || buffers[i] == 0) {
|
||||
BindBufferBase_State(target, first + i, 0);
|
||||
|
||||
@@ -53,18 +53,46 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// The GL-visible number of indexed binding points for `target`.
|
||||
SizeT GetBufferBindingPointLimit(BufferTarget target) {
|
||||
SizeT pointCount = MG_State::pGLContext->GetBufferBindingPointCount(target);
|
||||
if (target == BufferTarget::ShaderStorage && MG_Backend::pActiveBackendObject) {
|
||||
const Int backendCount =
|
||||
MG_Backend::pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings;
|
||||
pointCount = std::min(pointCount, static_cast<SizeT>(std::max(backendCount, 0)));
|
||||
}
|
||||
if (target == BufferTarget::TransformFeedback) {
|
||||
// GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS bounds the indexed capture
|
||||
// binding points in GL 3.3 (no ARB_transform_feedback3).
|
||||
pointCount = std::min<SizeT>(pointCount, 4);
|
||||
}
|
||||
return pointCount;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Bool ValidateBufferBindingPointRange(BufferTarget target, Uint first, GLsizei count, const char* funcName) {
|
||||
if (count < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl/BufferImpl", funcName,
|
||||
"count must be non-negative."));
|
||||
return false;
|
||||
}
|
||||
const SizeT pointCount = GetBufferBindingPointLimit(target);
|
||||
if (static_cast<Uint64>(first) + static_cast<Uint64>(count) > static_cast<Uint64>(pointCount)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>(
|
||||
"MG_Impl/GLImpl/BufferImpl", funcName,
|
||||
std::format("first + count ({} + {}) exceeds the {} indexed binding points of target {}.", first,
|
||||
count, pointCount, MG_Util::ConvertBufferTargetToString(target))));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Bool ValidateBufferBindingPointIndex(BufferTarget target, Uint index) {
|
||||
SizeT pointCount = MG_State::pGLContext->GetBufferBindingPointCount(target);
|
||||
if (target == BufferTarget::ShaderStorage && MG_Backend::pActiveBackendObject) {
|
||||
const Int backendCount =
|
||||
MG_Backend::pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings;
|
||||
pointCount = std::min(pointCount, static_cast<SizeT>(std::max(backendCount, 0)));
|
||||
}
|
||||
if (target == BufferTarget::TransformFeedback) {
|
||||
// GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS bounds the indexed capture
|
||||
// binding points in GL 3.3 (no ARB_transform_feedback3).
|
||||
pointCount = std::min<SizeT>(pointCount, 4);
|
||||
}
|
||||
const SizeT pointCount = GetBufferBindingPointLimit(target);
|
||||
|
||||
if (index < pointCount) {
|
||||
return true;
|
||||
|
||||
@@ -17,4 +17,8 @@ namespace MobileGL::MG_Impl::GLImpl::BufferImpl {
|
||||
Bool ValidateBufferMappingAccess(Flags<BufferMappingAccessBit> accessBits);
|
||||
Bool ValidateBufferBindingPointTarget(BufferTarget target);
|
||||
Bool ValidateBufferBindingPointIndex(BufferTarget target, Uint index);
|
||||
// ARB_multi_bind: glBindBuffersBase/Range validate the whole [first, first + count) range
|
||||
// up front and report INVALID_OPERATION, where a single out-of-range index would be
|
||||
// INVALID_VALUE. Naively looping the single-bind entry points reports the wrong class.
|
||||
Bool ValidateBufferBindingPointRange(BufferTarget target, Uint first, GLsizei count, const char* funcName);
|
||||
} // namespace MobileGL::MG_Impl::GLImpl::BufferImpl
|
||||
|
||||
@@ -474,6 +474,21 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
if (!ValidateCurrentProgramForCompute(__func__)) return;
|
||||
// GL 4.6 core 19: each num_groups_* must be within GL_MAX_COMPUTE_WORK_GROUP_COUNT
|
||||
// for its dimension. GetIntegeri_v already floors that at the spec minimum.
|
||||
const GLuint numGroups[3] = {numGroupsX, numGroupsY, numGroupsZ};
|
||||
for (GLuint dimension = 0; dimension < 3; ++dimension) {
|
||||
GLint maxGroups = 0;
|
||||
GetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, dimension, &maxGroups);
|
||||
if (numGroups[dimension] > static_cast<GLuint>(std::max(maxGroups, 0))) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"num_groups exceeds GL_MAX_COMPUTE_WORK_GROUP_COUNT for dimension " +
|
||||
std::to_string(dimension) + "."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
dispatchCompute(numGroupsX, numGroupsY, numGroupsZ);
|
||||
}
|
||||
|
||||
@@ -487,6 +502,24 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
return;
|
||||
}
|
||||
if (!ValidateCurrentProgramForCompute(__func__)) return;
|
||||
// GL 4.6 core 19: `indirect` is a byte offset into GL_DISPATCH_INDIRECT_BUFFER -
|
||||
// negative or misaligned is INVALID_VALUE, nothing bound is INVALID_OPERATION.
|
||||
if (indirect < 0 || (indirect % 4) != 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"indirect must be non-negative and a multiple of 4."));
|
||||
return;
|
||||
}
|
||||
const auto& indirectBuffer =
|
||||
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DispatchIndirect).GetBoundObject();
|
||||
if (!indirectBuffer) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"No buffer is bound to GL_DISPATCH_INDIRECT_BUFFER."));
|
||||
return;
|
||||
}
|
||||
dispatchComputeIndirect(indirect);
|
||||
}
|
||||
|
||||
|
||||
@@ -977,8 +977,8 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, ScissorIndexed, GLuint index, GLint left, GL
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, ScissorIndexedv, GLuint index, const GLint* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ScissorIndexedv, index, v)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, DepthRangeArrayv, GLuint first, GLsizei count, const GLdouble* v) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DepthRangeArrayv, first, count, v)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, DepthRangeIndexed, GLuint index, GLdouble n, GLdouble f) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DepthRangeIndexed, index, n, f)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetFloati_v, GLenum target, GLuint index, GLfloat* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetFloati_v, target, index, data)
|
||||
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetDoublei_v, GLenum target, GLuint index, GLdouble* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetDoublei_v, target, index, data)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, GetFloati_v, GLenum target, GLuint index, GLfloat* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, GetFloati_v, target, index, data)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, GetDoublei_v, GLenum target, GLuint index, GLdouble* data) DECLARE_GL_FUNCTION_END_NO_RETURN(void, GetDoublei_v, target, index, data)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, DrawArraysInstancedBaseInstance, GLenum mode, GLint first, GLsizei count, GLsizei instancecount, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawArraysInstancedBaseInstance, mode, first, count, instancecount, baseinstance)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseInstance, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseInstance, mode, count, type, indices, instancecount, baseinstance)
|
||||
DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseVertexBaseInstance, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLint basevertex, GLuint baseinstance) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseVertexBaseInstance, mode, count, type, indices, instancecount, basevertex, baseinstance)
|
||||
@@ -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) {
|
||||
String resourceName;
|
||||
if (!ProgramInterface::GetResourceName(*programObject, programInterface, index, resourceName)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "index is out of range."));
|
||||
return;
|
||||
}
|
||||
getProgramResourceName(program, programInterface, index, bufSize, length, name);
|
||||
CopyStr(bufSize, length, name, resourceName.c_str(), static_cast<GLsizei>(resourceName.length()));
|
||||
}
|
||||
|
||||
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
|
||||
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return;
|
||||
if (propCount < 0 || bufSize < 0) {
|
||||
if (!ProgramInterface::IsInterfaceEnum(programInterface)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"propCount and bufSize must be non-negative."));
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "Unsupported program interface."));
|
||||
return;
|
||||
}
|
||||
if (programInterface == GL_UNIFORM) {
|
||||
if (index >= programObject->GetUniformCount()) {
|
||||
if (propCount <= 0 || bufSize < 0) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"propCount must be positive and bufSize "
|
||||
"non-negative."));
|
||||
return;
|
||||
}
|
||||
if (props == nullptr) return;
|
||||
// Both prop checks run BEFORE any value is produced: a property this command does
|
||||
// not know at all is INVALID_ENUM, one it knows but the interface does not carry is
|
||||
// INVALID_OPERATION (GL 4.6 Table 7.2). The two are deliberately different errors.
|
||||
for (GLsizei i = 0; i < propCount; ++i) {
|
||||
if (!ProgramInterface::IsResourceProp(props[i])) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "prop is not a valid property name."));
|
||||
return;
|
||||
}
|
||||
if (!ProgramInterface::InterfaceSupportsProp(programInterface, props[i])) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"prop is not supported for this program interface."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Vector<GLint> values;
|
||||
for (GLsizei i = 0; i < propCount; ++i) {
|
||||
if (!ProgramInterface::GetResourceProp(*programObject, programInterface, index, props[i], values)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "index is out of range."));
|
||||
return;
|
||||
}
|
||||
if (props == nullptr || params == nullptr) return;
|
||||
GLsizei written = 0;
|
||||
for (GLsizei i = 0; i < propCount && written < bufSize; ++i) {
|
||||
GLint value = 0;
|
||||
if (!GetUniformResourceProp(programObject, index, props[i], &value)) {
|
||||
// GL_ATOMIC_COUNTER_BUFFER_INDEX and the GL_REFERENCED_BY_* stage props are
|
||||
// not modelled here; ask the backend, which indexes resources by name.
|
||||
auto backendGetIndex = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceIndex;
|
||||
auto backendGetiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceiv;
|
||||
if (backendGetIndex && backendGetiv) {
|
||||
const GLuint backendIndex = backendGetIndex(program, GL_UNIFORM,
|
||||
programObject->GetActiveUniformName(index).c_str());
|
||||
if (backendIndex != GL_INVALID_INDEX) {
|
||||
GLsizei one = 0;
|
||||
backendGetiv(program, GL_UNIFORM, backendIndex, 1, &props[i], 1, &one, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
params[written++] = value;
|
||||
}
|
||||
if (length) *length = written;
|
||||
return;
|
||||
}
|
||||
auto getProgramResourceiv = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceiv;
|
||||
if (!getProgramResourceiv) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
return;
|
||||
}
|
||||
getProgramResourceiv(program, programInterface, index, propCount, props, bufSize, length, params);
|
||||
if (params == nullptr) return;
|
||||
const GLsizei written = static_cast<GLsizei>(std::min<SizeT>(values.size(), static_cast<SizeT>(bufSize)));
|
||||
for (GLsizei i = 0; i < written; ++i) params[i] = values[i];
|
||||
if (length) *length = written;
|
||||
}
|
||||
|
||||
GLint GetProgramResourceLocation(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
// Unlike the four queries above, this one and GetProgramResourceLocationIndex really
|
||||
// do require a successful link (GL 4.6 §7.3.1.3).
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return -1;
|
||||
auto getProgramResourceLocation = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceLocation;
|
||||
if (!getProgramResourceLocation) {
|
||||
if (!ProgramInterface::InterfaceHasLocations(programInterface)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidOperation,
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"Backend does not support program interface queries."));
|
||||
"Program interface has no locations."));
|
||||
return -1;
|
||||
}
|
||||
return getProgramResourceLocation(program, programInterface, name);
|
||||
return ProgramInterface::GetResourceLocation(*programObject, programInterface, name);
|
||||
}
|
||||
|
||||
GLint GetProgramResourceLocationIndex(GLuint program, GLenum programInterface, const GLchar* name) {
|
||||
auto& programObject = TryToGetLinkedProgramForInterfaceQuery(program, __func__);
|
||||
if (!programObject) return -1;
|
||||
auto getProgramResourceLocationIndex = MG_Backend::gBackendFunctionsTable.GL.GetProgramResourceLocationIndex;
|
||||
if (!getProgramResourceLocationIndex) return -1;
|
||||
return getProgramResourceLocationIndex(program, programInterface, name);
|
||||
if (programInterface != GL_PROGRAM_OUTPUT) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidEnum,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"GetProgramResourceLocationIndex only accepts GL_PROGRAM_OUTPUT."));
|
||||
return -1;
|
||||
}
|
||||
return ProgramInterface::GetResourceLocationIndex(*programObject, programInterface, name);
|
||||
}
|
||||
|
||||
// GL 4.6 §7.6.2: <storageBlockIndex> is an active shader storage block index of <program>
|
||||
// - that is, exactly what glGetProgramResourceIndex(GL_SHADER_STORAGE_BLOCK) returned.
|
||||
// Since wave 2 that index is the interface-query layer's, so this is where the one index
|
||||
// space the application sees gets turned into whatever the backend's is; the backends are
|
||||
// handed the block NAME and do their own lookup. Getting this wrong is silent: the call
|
||||
// succeeds and rebinds a DIFFERENT buffer.
|
||||
void ShaderStorageBlockBinding(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding) {
|
||||
auto& programObject = TryToGetProgramObject(program);
|
||||
if (!programObject || !programObject->GetLinkStatus()) return;
|
||||
if (!ValidateShaderStorageBlockBinding(storageBlockBinding)) return;
|
||||
String blockName;
|
||||
if (!ProgramInterface::GetResourceName(*programObject, GL_SHADER_STORAGE_BLOCK, storageBlockIndex,
|
||||
blockName)) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
ErrorCode::InvalidValue,
|
||||
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
|
||||
"storageBlockIndex is not an active shader storage block index."));
|
||||
return;
|
||||
}
|
||||
// Recorded before the backend call, and independently of whether a backend is even
|
||||
// present: this is the state GL_BUFFER_BINDING reports, and it is also what reseeds a
|
||||
// backend's own reflection cache after any rebuild.
|
||||
programObject->SetShaderStorageBlockBinding(blockName, storageBlockBinding);
|
||||
auto shaderStorageBlockBinding = MG_Backend::gBackendFunctionsTable.GL.ShaderStorageBlockBinding;
|
||||
if (!shaderStorageBlockBinding) {
|
||||
MG_State::pGLContext->RecordError(
|
||||
@@ -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();
|
||||
@@ -120,6 +124,18 @@ namespace MobileGL {
|
||||
// per-draw sampled-texture set.
|
||||
Uint64 GetTextureBindGeneration() const { return m_textureState.GetTextureBindGeneration(); }
|
||||
void BumpTextureBindGeneration() { m_textureState.BumpTextureBindGeneration(); }
|
||||
// Monotonic counter bumped whenever a texture's shape or a sampler object's
|
||||
// parameters change, i.e. whenever a bound texture's mipmap-completeness (and so
|
||||
// whether a backend binds it at all) can have flipped without any bind moving;
|
||||
// see TextureState::GetSamplingResolutionGeneration.
|
||||
Uint64 GetSamplingResolutionGeneration() const {
|
||||
return m_textureState.GetSamplingResolutionGeneration();
|
||||
}
|
||||
void BumpSamplingResolutionGeneration() { m_textureState.BumpSamplingResolutionGeneration(); }
|
||||
// Never-reused id of this context, for backend memos keyed on the two counters
|
||||
// above: both restart at 0 in a new context, and a recreated context can land on
|
||||
// the old heap address. See TextureState::GetContextId.
|
||||
Uint64 GetTextureContextId() const { return m_textureState.GetContextId(); }
|
||||
Bool ValidateTextureName(Uint index) const;
|
||||
Bool ValidateTextureObject(Uint index) const;
|
||||
Int GetActiveTextureUnit() const;
|
||||
@@ -137,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
|
||||
@@ -368,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;
|
||||
@@ -425,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.
|
||||
// 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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
return uniform.size < 1 ? 1 : uniform.size;
|
||||
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,41 +601,202 @@ namespace MobileGL::MG_State::GLState {
|
||||
// layout captures into; see NeedsScatteredTransformFeedbackCapture.
|
||||
Uint32 packedOffsetBytes = 0;
|
||||
};
|
||||
|
||||
// ---- 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)
|
||||
Vector<String> attribs;
|
||||
Vector<GLenum> attribTypes;
|
||||
|
||||
// FragData (Frag out): the per-link snapshot of the explicit request maps.
|
||||
UnorderedMap<String, Uint> linkedFragDataLocation;
|
||||
UnorderedMap<String, Uint> linkedFragDataIndex;
|
||||
|
||||
// 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. uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`"
|
||||
Vector<Int> uniformIndexInTProgram;
|
||||
// ditto. Will be set at glUniform1i
|
||||
Vector<Int> uniformSamplerOrImageUnitIndex;
|
||||
UnorderedMap<String, Uint> explicitOpaqueUniformBindings;
|
||||
|
||||
// Ordered by uniform block index
|
||||
// index is DIFFERENT from binding!!!
|
||||
//
|
||||
// Let's define UniformBlockIndex == the order at glslang getUniformBlock()
|
||||
// aka `i = glGetUniformBlockIndex(prog, "BlockName")` implies:
|
||||
// `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> uniformBlockIndexByName;
|
||||
Vector<Int> uniformBlockBinding;
|
||||
// glShaderStorageBlockBinding overrides, keyed by GL block name. See
|
||||
// SetShaderStorageBlockBinding for why this one is by name and not by index.
|
||||
UnorderedMap<String, Int> shaderStorageBlockBinding;
|
||||
|
||||
// Need to be reflected after linking of SPIR-V binary
|
||||
Vector<Uint> uniformOffsets;
|
||||
Vector<Uint> uniformSizesInBytes;
|
||||
Vector<Uint8> globalUboScratch;
|
||||
|
||||
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 m_xfbBufferMode; }
|
||||
SizeT GetTransformFeedbackVaryingCount() const { return m_xfbVaryings.size(); }
|
||||
GLenum GetTransformFeedbackBufferMode() const { return Artifacts().xfbBufferMode; }
|
||||
SizeT GetTransformFeedbackVaryingCount() const { return Artifacts().xfbVaryings.size(); }
|
||||
const XfbVarying* GetTransformFeedbackVarying(SizeT index) const {
|
||||
return index < m_xfbVaryings.size() ? &m_xfbVaryings[index] : nullptr;
|
||||
return index < Artifacts().xfbVaryings.size() ? &Artifacts().xfbVaryings[index] : nullptr;
|
||||
}
|
||||
const Vector<XfbVarying>& GetTransformFeedbackVaryings() const { return m_xfbVaryings; }
|
||||
const Vector<XfbVarying>& GetTransformFeedbackVaryings() const { return Artifacts().xfbVaryings; }
|
||||
// The GL_TRANSFORM_FEEDBACK_VARYING resource list: every name the last successful
|
||||
// link was asked to capture, in request order, pseudo-varyings included.
|
||||
const Vector<String>& GetTransformFeedbackInterfaceNames() const { return Artifacts().xfbInterfaceNames; }
|
||||
// Stride of one captured vertex in the given capture buffer slot.
|
||||
Uint32 GetTransformFeedbackStride(Uint32 bufferIndex) const {
|
||||
return bufferIndex < m_xfbStrides.size() ? m_xfbStrides[bufferIndex] : 0;
|
||||
return bufferIndex < Artifacts().xfbStrides.size() ? Artifacts().xfbStrides[bufferIndex] : 0;
|
||||
}
|
||||
SizeT GetTransformFeedbackBufferCount() const { return m_xfbStrides.size(); }
|
||||
Int GetTransformFeedbackVaryingMaxLength() const { return m_xfbVaryingNameMaxLength; }
|
||||
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 m_xfbNeedsScatteredCapture; }
|
||||
Bool NeedsScatteredTransformFeedbackCapture() const { return Artifacts().xfbNeedsScatteredCapture; }
|
||||
// Bytes one gap-free captured record occupies.
|
||||
Uint32 GetTransformFeedbackPackedStride() const { return m_xfbPackedStride; }
|
||||
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 m_gsStripCaptureFixup; }
|
||||
Bool HasGsTriangleStripCaptureFixup() const { return Artifacts().gsStripCaptureFixup; }
|
||||
// Triangles per strip, in emission order, for ONE geometry invocation.
|
||||
const Vector<Uint32>& GetGsStripTriangles() const { return m_gsStripTriangles; }
|
||||
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 m_gsInputPrimitive; }
|
||||
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
|
||||
@@ -545,81 +806,74 @@ namespace MobileGL::MG_State::GLState {
|
||||
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;
|
||||
// ---- 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();
|
||||
Bool ValidateFragmentOutputLocations();
|
||||
|
||||
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
|
||||
|
||||
SharedPtr<glslang::TProgram> m_program;
|
||||
|
||||
Vector<Vector<unsigned>> m_generatedSpirv;
|
||||
|
||||
// Attributes (Vertex in)
|
||||
// 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;
|
||||
Vector<String> m_attribs;
|
||||
Vector<GLenum> m_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;
|
||||
Vector<String> m_requestedXfbVaryings;
|
||||
GLenum m_requestedXfbBufferMode = GL_INTERLEAVED_ATTRIBS;
|
||||
|
||||
// Uniforms
|
||||
UnorderedMap<String, Uint> m_uniformLocations;
|
||||
// Ordered by location,
|
||||
// aka. m_uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`"
|
||||
Vector<Int> m_uniformIndexInTProgram;
|
||||
// ditto. Will be set at glUniform1i
|
||||
Vector<Int> m_uniformSamplerOrImageUnitIndex;
|
||||
UnorderedMap<String, Uint> m_explicitOpaqueUniformBindings;
|
||||
|
||||
// Ordered by uniform block index
|
||||
// index is DIFFERENT from binding!!!
|
||||
//
|
||||
// Let's define UniformBlockIndex == the order at glslang getUniformBlock()
|
||||
// aka `i = glGetUniformBlockIndex(prog, "BlockName")` implies:
|
||||
// `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;
|
||||
|
||||
// Need to be reflected after linking of SPIR-V binary
|
||||
Vector<Uint> m_uniformOffsets;
|
||||
Vector<Uint> m_uniformSizesInBytes;
|
||||
Vector<Uint8> m_globalUboScratch;
|
||||
|
||||
Uint m_activeUniformCount = 0;
|
||||
Uint m_maxUniformLocation = 0;
|
||||
Int m_uniformNameMaxLength = 0;
|
||||
Int m_attribInNameMaxLength = 0;
|
||||
Int m_uniformBlockNameMaxLength = 0;
|
||||
|
||||
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
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
#include "SamplerObject.h"
|
||||
|
||||
#include <MG_State/GLState/Core.h>
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace MobileGL {
|
||||
@@ -22,81 +24,91 @@ namespace MobileGL {
|
||||
SamplerObject::SamplerObject(Uint externalIndex)
|
||||
: m_externalIndex(externalIndex), m_lifetimeId(AllocateLifetimeId()) {}
|
||||
|
||||
void SamplerObject::BumpVersion() {
|
||||
++m_version;
|
||||
// Every setter early-outs on an unchanged value, so this only runs on a real
|
||||
// parameter change. The generation is bumped for ALL parameters, not just filter
|
||||
// ones that feed mipmap-completeness: a backend memo of the resolved per-unit
|
||||
// bindings must never miss an invalidation, and over-invalidating on a wrap-mode
|
||||
// write costs one re-resolve.
|
||||
if (pGLContext) pGLContext->BumpSamplingResolutionGeneration();
|
||||
}
|
||||
|
||||
void SamplerObject::SetWrapS(SamplerWrapMode mode) {
|
||||
if (mode == m_samplerParameters.wrapS) return;
|
||||
|
||||
m_samplerParameters.wrapS = mode;
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
void SamplerObject::SetWrapT(SamplerWrapMode mode) {
|
||||
if (mode == m_samplerParameters.wrapT) return;
|
||||
|
||||
m_samplerParameters.wrapT = mode;
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
void SamplerObject::SetWrapR(SamplerWrapMode mode) {
|
||||
if (mode == m_samplerParameters.wrapR) return;
|
||||
|
||||
m_samplerParameters.wrapR = mode;
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
void SamplerObject::SetMinFilter(SamplerFilterMode mode) {
|
||||
if (mode == m_samplerParameters.minFilter) return;
|
||||
|
||||
m_samplerParameters.minFilter = mode;
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
void SamplerObject::SetMagFilter(SamplerFilterMode mode) {
|
||||
if (mode == m_samplerParameters.magFilter) return;
|
||||
|
||||
m_samplerParameters.magFilter = mode;
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
void SamplerObject::SetMipmapMode(SamplerMipmapMode mode) {
|
||||
if (mode == m_samplerParameters.mipmapMode) return;
|
||||
|
||||
m_samplerParameters.mipmapMode = mode;
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
void SamplerObject::SetLodRange(Float minLod, Float maxLod) {
|
||||
if (minLod == m_samplerParameters.minLod && maxLod == m_samplerParameters.maxLod) return;
|
||||
m_samplerParameters.minLod = minLod;
|
||||
m_samplerParameters.maxLod = maxLod;
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
void SamplerObject::SetLodBias(Float bias) {
|
||||
if (bias == m_samplerParameters.lodBias) return;
|
||||
|
||||
m_samplerParameters.lodBias = bias;
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
void SamplerObject::SetMaxAnisotropy(Float maxAnisotropy) {
|
||||
if (maxAnisotropy == m_samplerParameters.maxAnisotropy) return;
|
||||
|
||||
m_samplerParameters.maxAnisotropy = maxAnisotropy;
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
void SamplerObject::SetSamplerCompareFunc(SamplerCompareFunc func) {
|
||||
if (func == m_samplerParameters.compareFunc) return;
|
||||
|
||||
m_samplerParameters.compareFunc = func;
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
void SamplerObject::SetCompareMode(SamplerCompareMode mode) {
|
||||
if (mode == m_samplerParameters.compareMode) return;
|
||||
|
||||
m_samplerParameters.compareMode = mode;
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
SamplerWrapMode SamplerObject::GetWrapS() const {
|
||||
@@ -153,7 +165,7 @@ namespace MobileGL {
|
||||
m_samplerParameters.borderColorUI =
|
||||
UintVec4(static_cast<Uint32>(color.x()), static_cast<Uint32>(color.y()),
|
||||
static_cast<Uint32>(color.z()), static_cast<Uint32>(color.w()));
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
void SamplerObject::SetBorderColorI(const IntVec4& color) {
|
||||
@@ -166,7 +178,7 @@ namespace MobileGL {
|
||||
m_samplerParameters.borderColor =
|
||||
FloatVec4(static_cast<Float>(color.x()), static_cast<Float>(color.y()),
|
||||
static_cast<Float>(color.z()), static_cast<Float>(color.w()));
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
void SamplerObject::SetBorderColorUI(const UintVec4& color) {
|
||||
@@ -179,7 +191,7 @@ namespace MobileGL {
|
||||
m_samplerParameters.borderColor =
|
||||
FloatVec4(static_cast<Float>(color.x()), static_cast<Float>(color.y()),
|
||||
static_cast<Float>(color.z()), static_cast<Float>(color.w()));
|
||||
++m_version;
|
||||
BumpVersion();
|
||||
}
|
||||
|
||||
const FloatVec4& SamplerObject::GetBorderColor() const {
|
||||
|
||||
@@ -128,6 +128,12 @@ namespace MobileGL {
|
||||
|
||||
private:
|
||||
static Uint64 AllocateLifetimeId();
|
||||
// The ONLY way m_version may move. Besides marking this object's parameters
|
||||
// dirty for the backends it bumps the context-wide sampling-resolution
|
||||
// generation: MIN_FILTER decides whether a lookup reads the mip chain, which
|
||||
// decides whether a bound texture is mipmap-complete, which decides whether a
|
||||
// backend binds it on its unit at all.
|
||||
void BumpVersion();
|
||||
|
||||
const Uint m_externalIndex;
|
||||
const Uint64 m_lifetimeId;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -25,6 +25,18 @@ namespace MobileGL {
|
||||
return s_nextTextureLifetimeId.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void TextureObjectBase::BumpShapeVersion() {
|
||||
++m_shapeVersion;
|
||||
// Shape is what mipmap-completeness is computed from, and completeness decides
|
||||
// whether a backend binds this texture on its unit at all. Nothing else tells a
|
||||
// backend memo of the resolved per-unit bindings that the answer moved - no bind
|
||||
// changed and the texel content may be untouched. Proxy textures (used only to
|
||||
// answer PROXY queries) are never bound, so their shape churn costs a memo
|
||||
// invalidation for nothing; that is accepted rather than filtered, because a
|
||||
// missed bump renders wrong pixels while a spare bump only costs one re-resolve.
|
||||
if (pGLContext) pGLContext->BumpSamplingResolutionGeneration();
|
||||
}
|
||||
|
||||
TextureObjectBase::TextureObjectBase(TextureTarget target, Uint externalIndex)
|
||||
: m_externalIndex(externalIndex), m_lifetimeId(AllocateLifetimeId()), m_target(target) {
|
||||
m_sampler = MakeShared<SamplerObject>(0);
|
||||
@@ -85,7 +97,7 @@ namespace MobileGL {
|
||||
}
|
||||
|
||||
m_internalFormat = format;
|
||||
++m_shapeVersion;
|
||||
BumpShapeVersion();
|
||||
++m_textureParamsVersion;
|
||||
}
|
||||
|
||||
@@ -197,7 +209,7 @@ namespace MobileGL {
|
||||
m_levelRange.y() = m_levelRange.x();
|
||||
}
|
||||
++m_textureParamsVersion;
|
||||
++m_shapeVersion;
|
||||
BumpShapeVersion();
|
||||
}
|
||||
|
||||
void TextureObjectBase::SetMaxLevel(Uint maxLevel) {
|
||||
@@ -208,7 +220,7 @@ namespace MobileGL {
|
||||
|
||||
m_levelRange.y() = maxLevel;
|
||||
++m_textureParamsVersion;
|
||||
++m_shapeVersion;
|
||||
BumpShapeVersion();
|
||||
}
|
||||
|
||||
Bool TextureObjectBase::IsImmutable() const {
|
||||
@@ -291,12 +303,12 @@ namespace MobileGL {
|
||||
|
||||
void TextureObjectWithOneMipmap::AllocateStorage(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
MipmapInput input) {
|
||||
++m_shapeVersion;
|
||||
BumpShapeVersion();
|
||||
m_textureStorage.AllocateLevel(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, input);
|
||||
}
|
||||
|
||||
void TextureObjectWithOneMipmap::TruncateMipmapLevels(TextureUploadTarget uploadTarget, Uint levelCount) {
|
||||
++m_shapeVersion;
|
||||
BumpShapeVersion();
|
||||
m_textureStorage.TruncateToLevelCount(GetIndexOfTextureUploadTarget(uploadTarget), levelCount);
|
||||
}
|
||||
|
||||
@@ -333,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,
|
||||
|
||||
@@ -121,6 +121,12 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
protected:
|
||||
static Uint64 AllocateLifetimeId();
|
||||
// The ONLY way m_shapeVersion may move. Besides invalidating this object's own
|
||||
// completeness memo it bumps the context-wide sampling-resolution generation, which is
|
||||
// what a backend memo of the resolved per-unit bindings watches: completeness decides
|
||||
// whether a bound texture reaches its native target at all, and a shape change is
|
||||
// otherwise invisible to such a memo (no bind moved).
|
||||
void BumpShapeVersion();
|
||||
|
||||
const Uint m_externalIndex;
|
||||
const Uint64 m_lifetimeId;
|
||||
@@ -180,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
|
||||
@@ -251,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;
|
||||
|
||||
@@ -28,12 +28,12 @@ namespace MobileGL {
|
||||
|
||||
void TextureObject2DCube::AllocateStorage(TextureUploadTarget uploadTarget, Uint mipmapLevel,
|
||||
MipmapInput input) {
|
||||
++m_shapeVersion;
|
||||
BumpShapeVersion();
|
||||
m_textureStorage.AllocateLevel(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, input);
|
||||
}
|
||||
|
||||
void TextureObject2DCube::TruncateMipmapLevels(TextureUploadTarget uploadTarget, Uint levelCount) {
|
||||
++m_shapeVersion;
|
||||
BumpShapeVersion();
|
||||
m_textureStorage.TruncateToLevelCount(GetIndexOfTextureUploadTarget(uploadTarget), levelCount);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
// End of Source File Header
|
||||
|
||||
#include "TextureState.h"
|
||||
|
||||
#include <atomic>
|
||||
#include "Defines.h"
|
||||
#include "TextureEnum.h"
|
||||
#include "TextureObject.h"
|
||||
@@ -18,6 +20,12 @@
|
||||
#include "TextureObjectStubs.h"
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
static std::atomic<Uint64> s_nextTextureStateContextId = 1;
|
||||
|
||||
Uint64 TextureState::AllocateContextId() {
|
||||
return s_nextTextureStateContextId.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
static SharedPtr<ITextureObject> MakeTextureObjectForTarget(Uint index, TextureTarget target) {
|
||||
switch (target) {
|
||||
case TextureTarget::Texture1D:
|
||||
@@ -50,7 +58,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
}
|
||||
}
|
||||
|
||||
TextureState::TextureState() : m_indexGenerator(1024, 1) {
|
||||
TextureState::TextureState() : m_contextId(AllocateContextId()), m_indexGenerator(1024, 1) {
|
||||
// GL 3.3 core 3.8: each target owns one default texture object (name 0) per context,
|
||||
// shared across all texture units, and it is the initial binding of every unit/target
|
||||
// slot. It is created outside m_textureObjects so name-based paths (glIsTexture,
|
||||
|
||||
@@ -84,8 +84,33 @@ namespace MobileGL::MG_State::GLState {
|
||||
Uint64 GetTextureBindGeneration() const { return m_textureBindGeneration; }
|
||||
void BumpTextureBindGeneration() { ++m_textureBindGeneration; }
|
||||
|
||||
// Sibling of the bind generation for everything that changes WHICH native texture a
|
||||
// backend ends up putting on a unit WITHOUT any binding moving. Two families feed it:
|
||||
// a texture's SHAPE (internal format, stored level set, level range - all that
|
||||
// mipmap-completeness is computed from) and any sampler object's parameters (MIN_FILTER
|
||||
// decides whether the mip chain is read at all, and an incomplete-for-the-filter texture
|
||||
// is deliberately left unbound so it samples as (0,0,0,1)). Deliberately coarse - ANY
|
||||
// texture, ANY sampler - so that no mutation can slip past a per-unit binding memo; the
|
||||
// setters that feed it all early-out when the value is unchanged, so the redundant
|
||||
// glTexParameteri calls applications issue every frame do not churn it. Kept separate
|
||||
// from the bind generation because the sampled texture SET is unaffected by these, and
|
||||
// the Vulkan backend's set memo keys on that one.
|
||||
Uint64 GetSamplingResolutionGeneration() const { return m_samplingResolutionGeneration; }
|
||||
void BumpSamplingResolutionGeneration() { ++m_samplingResolutionGeneration; }
|
||||
|
||||
// Globally-unique, never-reused id of THIS texture state, i.e. of the context that owns
|
||||
// it. Both generations above restart at 0 with a new context, so a backend memo keyed on
|
||||
// them alone would accept a destroyed-and-recreated context whose counters happen to line
|
||||
// up - and the heap address is no help, since a context freed and remade lands on it
|
||||
// again (the unit tests do exactly that between cases).
|
||||
Uint64 GetContextId() const { return m_contextId; }
|
||||
|
||||
private:
|
||||
static Uint64 AllocateContextId();
|
||||
|
||||
const Uint64 m_contextId;
|
||||
Uint64 m_textureBindGeneration = 0;
|
||||
Uint64 m_samplingResolutionGeneration = 0;
|
||||
Int m_maxTouchedUnit = -1;
|
||||
Int m_activeTextureUnit = 0;
|
||||
Array<TextureUnit, MAX_TEXTURE_IMAGE_UNITS> m_textureUnits;
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
#include "TextureUnit.h"
|
||||
|
||||
#include <MG_State/GLState/Core.h>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
TextureUnit::TextureUnit() : m_sampler(nullptr) {
|
||||
for (int i = 0; i < (int)TextureTarget::TextureTargetCount; ++i) {
|
||||
@@ -24,7 +26,17 @@ namespace MobileGL::MG_State::GLState {
|
||||
}
|
||||
|
||||
void TextureUnit::SetSamplerObject(const SharedPtr<SamplerObject>& sampler) {
|
||||
if (m_sampler == sampler) return;
|
||||
|
||||
m_sampler = sampler;
|
||||
// Which sampler object a unit carries is part of "what is bound at this unit": it
|
||||
// overrides the texture's own sampler state, so it selects the filter that decides a
|
||||
// bound texture's mipmap-completeness. glBindSampler already bumps the generation
|
||||
// through NoteUnitTouched, but glDeleteSamplers unbinds the deleted object from every
|
||||
// unit straight through here (GLContext::MarkSamplerObjectForDeletion) and would
|
||||
// otherwise leave a backend memo of the resolved per-unit bindings replaying the
|
||||
// deleted sampler.
|
||||
if (pGLContext) pGLContext->BumpTextureBindGeneration();
|
||||
}
|
||||
|
||||
const SharedPtr<SamplerObject>& TextureUnit::GetSamplerObject() const {
|
||||
|
||||
@@ -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];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user