mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-12 06:08:30 +09:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebc5bff9b1 | ||
|
|
231d5c90e4 | ||
|
|
d5f5e6405b | ||
|
|
ac3a83b207 | ||
|
|
335f2decbd | ||
|
|
fb1ad96c04 | ||
|
|
313b75a7c0 |
@@ -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)
|
||||
@@ -538,6 +543,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,16 @@ 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
|
||||
};
|
||||
|
||||
// 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"
|
||||
@@ -91,6 +101,10 @@ 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;
|
||||
};
|
||||
extern FeaturesTable Features;
|
||||
} // namespace MobileGL::MG_Config
|
||||
|
||||
@@ -97,6 +97,25 @@ 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;
|
||||
}
|
||||
|
||||
inline Uint32 QueryEnvUint32(const String& key, Uint32 defaultValue, Uint32 minValue, Uint32 maxValue) {
|
||||
auto it = acceptedEnvVariablesMap->find(key);
|
||||
if (it == acceptedEnvVariablesMap->end()) {
|
||||
@@ -138,6 +157,7 @@ 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");
|
||||
}
|
||||
|
||||
inline void InitBackendType() {
|
||||
|
||||
@@ -3155,6 +3155,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
PrepareForDraw(syncBit);
|
||||
CheckPrimitiveRestartSupported(type);
|
||||
|
||||
// Gate on the capability flag, never on the entry-point pointer: eglGetProcAddress
|
||||
// returns a non-NULL stub for glMultiDrawElementsBaseVertexEXT on drivers without the
|
||||
// extension interaction (NVIDIA ES), and that stub silently drops every draw.
|
||||
if (g_GLESCapabilities.SupportsMultiDrawElementsBaseVertex) {
|
||||
g_GLESFuncs.glMultiDrawElementsBaseVertexEXT(mode, count, type, indices, drawcount, basevertex);
|
||||
return;
|
||||
}
|
||||
for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
g_GLESFuncs.glDrawElementsBaseVertex(mode, count[i], type, indices[i], basevertex[i]);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <mutex>
|
||||
#include <cstring>
|
||||
#include <regex>
|
||||
|
||||
@@ -114,6 +115,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return source;
|
||||
}
|
||||
|
||||
namespace {
|
||||
Bool g_processTeardown = false;
|
||||
std::once_flag g_teardownSentinelOnce;
|
||||
} // namespace
|
||||
|
||||
Bool InProcessTeardown() { return g_processTeardown; }
|
||||
void EnsureProcessTeardownSentinel() {
|
||||
std::call_once(g_teardownSentinelOnce,
|
||||
[] { std::atexit(+[] { g_processTeardown = true; }); });
|
||||
}
|
||||
|
||||
String EmulateBaseInstanceInVertexShader(String source, GLenum shaderType) {
|
||||
if (shaderType != GL_VERTEX_SHADER || source.find("gl_BaseInstance") == String::npos) {
|
||||
return source;
|
||||
@@ -1388,6 +1400,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
BackendVertexArrayObject::~BackendVertexArrayObject() {
|
||||
if (InProcessTeardown()) {
|
||||
return; // see InProcessTeardown(): the driver may be unloaded already
|
||||
}
|
||||
if (m_backendVAOId != 0) {
|
||||
NoteVAOIdDeleted(m_backendVAOId);
|
||||
g_GLESFuncs.glDeleteVertexArrays(1, &m_backendVAOId);
|
||||
@@ -1641,6 +1656,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
|
||||
BackendTextureObject::~BackendTextureObject() {
|
||||
if (InProcessTeardown()) {
|
||||
return; // see InProcessTeardown(): the driver may be unloaded already
|
||||
}
|
||||
if (m_backendTextureId == 0) {
|
||||
return;
|
||||
}
|
||||
@@ -3780,6 +3798,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
#ifdef TRACY_ENABLE
|
||||
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
|
||||
#endif
|
||||
if (InProcessTeardown()) {
|
||||
return; // see InProcessTeardown(): the driver may be unloaded already
|
||||
}
|
||||
if (m_backendProgramId != 0) {
|
||||
MGLOG_D("Deleting backend program object with ID: %u", m_backendProgramId);
|
||||
g_GLESFuncs.glDeleteProgram(m_backendProgramId);
|
||||
|
||||
@@ -21,9 +21,25 @@ 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();
|
||||
|
||||
template <typename StateObject, typename BackendObject>
|
||||
class StateBackendObjectRegistry {
|
||||
public:
|
||||
|
||||
using StatePtr = SharedPtr<StateObject>;
|
||||
using StateWeakPtr = std::weak_ptr<StateObject>;
|
||||
using BackendPtr = SharedPtr<BackendObject>;
|
||||
@@ -43,6 +59,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
BackendPtr& GetOrCreate(const StatePtr& stateObj) {
|
||||
MOBILEGL_ASSERT(stateObj != nullptr, "State object must not be null");
|
||||
|
||||
// Twin creation is the moment a driver-owned id starts needing a guarded
|
||||
// destructor; cold path, so the once-guard costs nothing per draw.
|
||||
EnsureProcessTeardownSentinel();
|
||||
auto& entry = m_entries[stateObj.get()];
|
||||
if (entry.stateRef.expired()) {
|
||||
// The previous owner of this address is gone and the allocator handed it
|
||||
|
||||
@@ -1393,31 +1393,66 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
pVulkanRenderer->MultiDrawArrays(payload);
|
||||
}
|
||||
|
||||
// Shared body of glMultiDrawElements (basevertex == nullptr) and
|
||||
// glMultiDrawElementsBaseVertex: identical calls except for the per-draw
|
||||
// vertex offset, which VkMultiDrawIndexedInfoEXT / VkDrawIndexedIndirectCommand /
|
||||
// vkCmdDrawIndexed all carry natively.
|
||||
static void MultiDrawElementsImpl(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
|
||||
GLsizei drawcount, const GLint* basevertex) {
|
||||
if (drawcount <= 0) {
|
||||
return;
|
||||
}
|
||||
MultiDrawIndexedCmd payload{};
|
||||
payload.mode = mode;
|
||||
payload.indexBufferView.indexType = type;
|
||||
|
||||
// Loop-invariant: the index type is fixed for the whole multi-draw, so resolve
|
||||
// its byte size once instead of twice per sub-draw (a cross-TU switch that
|
||||
// showed up in per-frame profiles of sodium-style 132x32 multi-draws). Index
|
||||
// sizes are 1/2/4, so the per-sub-draw offset division below reduces to a
|
||||
// shift - the hardware divide was the hottest instruction of this loop.
|
||||
const SizeT indexSize = MG_Util::GetGLTypeSize(type);
|
||||
if (indexSize == 0) {
|
||||
MGLOG_E("MultiDrawElements skipped: unsupported index type 0x%x", type);
|
||||
return;
|
||||
}
|
||||
const Uint32 indexSizeShift = static_cast<Uint32>(std::countr_zero(indexSize));
|
||||
|
||||
// TODO: allocate draw cmd buf elsewhere
|
||||
static Vector<DrawIndexedCmdParam> params;
|
||||
params.clear();
|
||||
params.resize(drawcount);
|
||||
|
||||
for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
if (count[i] == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: this index view needs a redesign, now there's a lotta redundant uploads
|
||||
|
||||
payload.indexBufferView.indexByteOffset = 0;
|
||||
payload.indexBufferView.indexByteSize =
|
||||
std::max(reinterpret_cast<SizeT>(indices[i]) + count[i] * indexSize,
|
||||
payload.indexBufferView.indexByteSize);
|
||||
|
||||
auto& param = params[i];
|
||||
|
||||
param.indexCount = count[i];
|
||||
param.instanceCount = 1;
|
||||
param.firstIndex = reinterpret_cast<SizeT>(indices[i]) >> indexSizeShift;
|
||||
param.vertexOffset = basevertex != nullptr ? basevertex[i] : 0;
|
||||
param.firstInstance = 0;
|
||||
}
|
||||
payload.drawCount = drawcount;
|
||||
payload.pParams = params.data();
|
||||
pVulkanRenderer->MultiDrawElements(payload);
|
||||
}
|
||||
|
||||
void MultiDrawElements(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
|
||||
GLsizei drawcount) {
|
||||
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElements called with null VulkanRenderer");
|
||||
MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElements called with null GL context");
|
||||
|
||||
// Vector<DrawElementCmd> cmds;
|
||||
// cmds.reserve(static_cast<SizeT>(drawcount));
|
||||
// for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
// if (count[i] == 0) {
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// DrawElementCmd payload{};
|
||||
// payload.mode = mode;
|
||||
// payload.firstVertex = 0;
|
||||
// payload.indexCount = count[i];
|
||||
// payload.indexType = type;
|
||||
// payload.indexByteOffset = reinterpret_cast<SizeT>(indices[i]);
|
||||
// cmds.push_back(payload);
|
||||
// }
|
||||
//
|
||||
// if (cmds.empty()) {
|
||||
// return;
|
||||
// }
|
||||
// pVulkanRenderer->MultiDrawElements(cmds);
|
||||
MultiDrawElementsImpl(mode, count, type, indices, drawcount, nullptr);
|
||||
}
|
||||
|
||||
void DrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, const GLvoid* indices, GLint basevertex) {
|
||||
@@ -1445,52 +1480,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
|
||||
void MultiDrawElementsBaseVertex(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices,
|
||||
GLsizei drawcount, const GLint* basevertex) {
|
||||
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElements called with null VulkanRenderer");
|
||||
MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElements called with null GL context");
|
||||
MultiDrawIndexedCmd payload{};
|
||||
payload.mode = mode;
|
||||
payload.indexBufferView.indexType = type;
|
||||
|
||||
// Loop-invariant: the index type is fixed for the whole multi-draw, so resolve
|
||||
// its byte size once instead of twice per sub-draw (a cross-TU switch that
|
||||
// showed up in per-frame profiles of sodium-style 132x32 multi-draws). Index
|
||||
// sizes are 1/2/4, so the per-sub-draw offset division below reduces to a
|
||||
// shift - the hardware divide was the hottest instruction of this loop.
|
||||
const SizeT indexSize = MG_Util::GetGLTypeSize(type);
|
||||
if (indexSize == 0) {
|
||||
MGLOG_E("MultiDrawElementsBaseVertex skipped: unsupported index type 0x%x", type);
|
||||
return;
|
||||
}
|
||||
const Uint32 indexSizeShift = static_cast<Uint32>(std::countr_zero(indexSize));
|
||||
|
||||
// TODO: allocate draw cmd buf elsewhere
|
||||
static Vector<DrawIndexedCmdParam> params;
|
||||
params.clear();
|
||||
params.resize(drawcount);
|
||||
|
||||
for (GLsizei i = 0; i < drawcount; ++i) {
|
||||
if (count[i] == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: this index view needs a redesign, now there's a lotta redundant uploads
|
||||
|
||||
payload.indexBufferView.indexByteOffset = 0;
|
||||
payload.indexBufferView.indexByteSize =
|
||||
std::max(reinterpret_cast<SizeT>(indices[i]) + count[i] * indexSize,
|
||||
payload.indexBufferView.indexByteSize);
|
||||
|
||||
auto& param = params[i];
|
||||
|
||||
param.indexCount = count[i];
|
||||
param.instanceCount = 1;
|
||||
param.firstIndex = reinterpret_cast<SizeT>(indices[i]) >> indexSizeShift;
|
||||
param.vertexOffset = basevertex[i];
|
||||
param.firstInstance = 0;
|
||||
}
|
||||
payload.drawCount = drawcount;
|
||||
payload.pParams = params.data();
|
||||
pVulkanRenderer->MultiDrawElements(payload);
|
||||
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElementsBaseVertex called with null VulkanRenderer");
|
||||
MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElementsBaseVertex called with null GL context");
|
||||
MultiDrawElementsImpl(mode, count, type, indices, drawcount, basevertex);
|
||||
}
|
||||
|
||||
void BlitFramebuffer(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1,
|
||||
|
||||
@@ -116,6 +116,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
m_frameCount = frameCount;
|
||||
m_maxBindings = maxBindings;
|
||||
m_samplerResolveMemo.assign(m_maxBindings, SamplerResolveMemo{});
|
||||
// Every entry is freshly constructed (all-invalid), so nothing needs sweeping until
|
||||
// a resolve writes one.
|
||||
m_samplerResolveMemoHighWater = 0;
|
||||
m_setsPerFrame = setsPerFrame;
|
||||
m_peakDescriptorSetsObserved = 0;
|
||||
m_textureManager = textureManager;
|
||||
@@ -174,6 +177,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
m_minDynamicOffsetAlignment = 1;
|
||||
m_frameCount = 0;
|
||||
m_maxBindings = 0;
|
||||
m_samplerResolveMemo.clear();
|
||||
m_samplerResolveMemoHighWater = 0;
|
||||
m_setsPerFrame = 0;
|
||||
m_peakDescriptorSetsObserved = 0;
|
||||
m_textureManager = nullptr;
|
||||
@@ -210,10 +215,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
m_fastRebindMemo.valid = false;
|
||||
m_lastBindValid = false;
|
||||
// Re-fingerprint the bound sampler set fresh this frame so any GL object address
|
||||
// reuse cannot outlive a single frame (see SamplerResolveMemo).
|
||||
for (auto& memo : m_samplerResolveMemo) {
|
||||
memo.valid = false;
|
||||
memo.infoValid = false;
|
||||
// reuse cannot outlive a single frame (see SamplerResolveMemo). Only the entries a
|
||||
// resolve has actually written can be valid, so the high-water mark bounds the
|
||||
// sweep - the vector itself is sized to the device's binding cap (256 here), which
|
||||
// is ~30x more entries than any program declares.
|
||||
const Uint32 touchedBindings =
|
||||
std::min<Uint32>(m_samplerResolveMemoHighWater, static_cast<Uint32>(m_samplerResolveMemo.size()));
|
||||
for (Uint32 binding = 0; binding < touchedBindings; ++binding) {
|
||||
m_samplerResolveMemo[binding].valid = false;
|
||||
m_samplerResolveMemo[binding].infoValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +389,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
viewFormatMemo->viewFormatDomain = numericDomain;
|
||||
viewFormatMemo->viewFormat = sampledViewFormat;
|
||||
viewFormatMemo->viewFormatValid = true;
|
||||
NoteSamplerResolveMemoTouched(binding);
|
||||
}
|
||||
}
|
||||
if (sampledViewFormat == VK_FORMAT_UNDEFINED) {
|
||||
@@ -434,6 +445,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
memo.viewLevelCount = viewLevelCount;
|
||||
memo.sampler = resolvedSampler;
|
||||
memo.valid = true;
|
||||
NoteSamplerResolveMemoTouched(binding);
|
||||
}
|
||||
} else {
|
||||
resolvedSampler = m_samplerManager->GetOrCreateSampler(*samplerToUse, *texture, forceNearestFiltering,
|
||||
@@ -450,6 +462,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
if (binding < m_samplerResolveMemo.size()) {
|
||||
m_samplerResolveMemo[binding].info = outImageInfo;
|
||||
m_samplerResolveMemo[binding].infoValid = true;
|
||||
NoteSamplerResolveMemoTouched(binding);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -345,5 +345,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Bool infoValid = false;
|
||||
};
|
||||
mutable Vector<SamplerResolveMemo> m_samplerResolveMemo;
|
||||
// Exclusive upper bound on the entries of m_samplerResolveMemo that any resolve
|
||||
// has ever written. The vector is sized to the DEVICE binding cap (256 on desktop
|
||||
// NVIDIA), but a program declares 1-8 bindings, so the per-frame reset below was
|
||||
// memsetting ~22 KB of never-touched entries every frame - a measurable slice of
|
||||
// the per-frame fixed cost on draw-light frames. Every site that can turn any of
|
||||
// an entry's *Valid flags on raises this mark first, so entries at or above it are
|
||||
// provably still in their constructed (all-invalid) state and clearing them is a
|
||||
// no-op. Never lowered except by Initialize/Shutdown, which rebuild the vector.
|
||||
mutable Uint32 m_samplerResolveMemoHighWater = 0;
|
||||
void NoteSamplerResolveMemoTouched(Uint32 binding) const {
|
||||
if (binding >= m_samplerResolveMemoHighWater) {
|
||||
m_samplerResolveMemoHighWater = binding + 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
} // namespace MobileGL::MG_Backend::DirectVulkan
|
||||
|
||||
@@ -273,12 +273,76 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// re-derive the exact values already applied on this command buffer. The
|
||||
// remaining input, the swapchain pre-transform, cannot change mid-recording
|
||||
// (a swapchain recreate retires the command buffer, and recording begin resets
|
||||
// this whole shadow).
|
||||
// this whole shadow); the value key below pins it anyway.
|
||||
Bool dynamicTailValid = false;
|
||||
Uint dynamicTailParamsVersion = 0;
|
||||
Int dynamicTailExtentX = 0;
|
||||
Int dynamicTailExtentY = 0;
|
||||
Bool dynamicTailIsDefaultFbo = false;
|
||||
// VALUE key over the tail's inputs, as a second-level gate behind the version.
|
||||
// The parameters version is ONE counter for all of RenderState, so anything that
|
||||
// is not tail input - a GL_BLEND toggle, a glBlendFuncSeparate, a glColorMask -
|
||||
// moves it and forced a full tail re-run. Blaze3D toggles blend around every
|
||||
// batch, so that was a per-draw re-derivation of six dynamic states that could
|
||||
// not have changed. Equal key => the six Apply* below would each re-derive the
|
||||
// value their shadow already holds and emit nothing, so the tail is skippable.
|
||||
//
|
||||
// Complete input inventory of ApplyDynamicDrawStateTail, one line per reader
|
||||
// (each accessor it replaces is a verified plain field read of the same
|
||||
// RenderStateParameters field - RenderState.cpp):
|
||||
// ApplyGLViewportState : Viewport, DepthRange, + extent/isDefaultFbo/preTransform
|
||||
// ApplyBlendConstants : BlendColor
|
||||
// ApplyPolygonOffsetState : PolygonOffsetUnits, PolygonOffsetFactor
|
||||
// ApplyLineWidthState : LineWidth (see the caveat below)
|
||||
// ApplyStencilState : StencilStates[0..1].{ValueMask, WriteMask, Ref}
|
||||
// scissor rect : ScissorTestEnabled, ScissorBox,
|
||||
// + extent/isDefaultFbo/preTransform
|
||||
// Caveat, unchanged from the version-only gate: ApplyLineWidthState also clamps
|
||||
// to the ACTIVE BACKEND OBJECT's aliased line-width range. Those are device
|
||||
// limits queried once at backend init and constant for the renderer's lifetime,
|
||||
// so they are not part of the key (the version gate never covered them either).
|
||||
struct DynamicTailKey {
|
||||
Int viewport[4] = {0, 0, 0, 0};
|
||||
Float depthRange[2] = {0.0f, 0.0f};
|
||||
Float blendColor[4] = {0.0f, 0.0f, 0.0f, 0.0f};
|
||||
Float polygonOffsetFactor = 0.0f;
|
||||
Float polygonOffsetUnits = 0.0f;
|
||||
Float lineWidth = 0.0f;
|
||||
Uint32 stencilValueMask[2] = {0, 0};
|
||||
Uint32 stencilWriteMask[2] = {0, 0};
|
||||
Int stencilRef[2] = {0, 0};
|
||||
Int scissorBox[4] = {0, 0, 0, 0};
|
||||
Int extentX = 0;
|
||||
Int extentY = 0;
|
||||
Uint32 preTransform = 0;
|
||||
Bool scissorEnabled = false;
|
||||
Bool isDefaultFbo = false;
|
||||
|
||||
Bool operator==(const DynamicTailKey& other) const {
|
||||
// NaN in any float input makes this false, which only costs a redundant
|
||||
// tail run - never a skipped one.
|
||||
for (Uint32 i = 0; i < 4; ++i) {
|
||||
if (viewport[i] != other.viewport[i] || blendColor[i] != other.blendColor[i] ||
|
||||
scissorBox[i] != other.scissorBox[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (Uint32 i = 0; i < 2; ++i) {
|
||||
if (depthRange[i] != other.depthRange[i] ||
|
||||
stencilValueMask[i] != other.stencilValueMask[i] ||
|
||||
stencilWriteMask[i] != other.stencilWriteMask[i] ||
|
||||
stencilRef[i] != other.stencilRef[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return polygonOffsetFactor == other.polygonOffsetFactor &&
|
||||
polygonOffsetUnits == other.polygonOffsetUnits && lineWidth == other.lineWidth &&
|
||||
extentX == other.extentX && extentY == other.extentY &&
|
||||
preTransform == other.preTransform && scissorEnabled == other.scissorEnabled &&
|
||||
isDefaultFbo == other.isDefaultFbo;
|
||||
}
|
||||
};
|
||||
DynamicTailKey dynamicTailKey{};
|
||||
};
|
||||
static DynamicStateShadow g_dynamicStateShadow;
|
||||
|
||||
@@ -2924,6 +2988,8 @@ void main() {
|
||||
m_device = VK_NULL_HANDLE;
|
||||
}
|
||||
s_vkCmdDrawIndexedIndirectCount = nullptr;
|
||||
s_vkCmdDrawMultiEXT = nullptr;
|
||||
s_vkCmdDrawMultiIndexedEXT = nullptr;
|
||||
|
||||
if (m_instance != VK_NULL_HANDLE && m_surface != VK_NULL_HANDLE) {
|
||||
vkDestroySurfaceKHR(m_instance, m_surface, nullptr);
|
||||
@@ -5049,18 +5115,61 @@ void main() {
|
||||
shadow.dynamicTailIsDefaultFbo == isDefaultFbo) {
|
||||
return;
|
||||
}
|
||||
ApplyGLViewportState(frame.commandBuffer, extent, m_swapchainObject.GetPreTransform(), isDefaultFbo);
|
||||
const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform();
|
||||
// Second-level VALUE gate: the version moved, but RenderState's version counts
|
||||
// every parameter, most of which this tail never reads. Build the key over
|
||||
// exactly the tail's inputs (inventory in DynamicTailKey) out of one bulk
|
||||
// parameters fetch and compare; an equal key means every Apply* below would
|
||||
// re-derive the value its shadow already holds.
|
||||
DynamicStateShadow::DynamicTailKey key;
|
||||
{
|
||||
const RenderStateParameters& p = MG_State::pGLContext->GetRenderStateParameters();
|
||||
key.viewport[0] = p.Viewport.x();
|
||||
key.viewport[1] = p.Viewport.y();
|
||||
key.viewport[2] = p.Viewport.z();
|
||||
key.viewport[3] = p.Viewport.w();
|
||||
key.depthRange[0] = p.DepthRange.x();
|
||||
key.depthRange[1] = p.DepthRange.y();
|
||||
key.blendColor[0] = p.BlendColor.x();
|
||||
key.blendColor[1] = p.BlendColor.y();
|
||||
key.blendColor[2] = p.BlendColor.z();
|
||||
key.blendColor[3] = p.BlendColor.w();
|
||||
key.polygonOffsetFactor = p.PolygonOffsetFactor;
|
||||
key.polygonOffsetUnits = p.PolygonOffsetUnits;
|
||||
key.lineWidth = p.LineWidth;
|
||||
// StencilStates[0] is Front, [1] is Back (RenderState::GetStencilFaceIndex),
|
||||
// the same order ApplyStencilState reads them in.
|
||||
for (Uint32 face = 0; face < 2; ++face) {
|
||||
key.stencilValueMask[face] = p.StencilStates[face].ValueMask;
|
||||
key.stencilWriteMask[face] = p.StencilStates[face].WriteMask;
|
||||
key.stencilRef[face] = p.StencilStates[face].Ref;
|
||||
}
|
||||
key.scissorEnabled = p.ScissorTestEnabled;
|
||||
key.scissorBox[0] = p.ScissorBox.x();
|
||||
key.scissorBox[1] = p.ScissorBox.y();
|
||||
key.scissorBox[2] = p.ScissorBox.z();
|
||||
key.scissorBox[3] = p.ScissorBox.w();
|
||||
key.extentX = extent.x();
|
||||
key.extentY = extent.y();
|
||||
key.preTransform = static_cast<Uint32>(preTransform);
|
||||
key.isDefaultFbo = isDefaultFbo;
|
||||
}
|
||||
if (shadow.dynamicTailValid && shadow.dynamicTailKey == key) {
|
||||
// Re-arm the cheap version gate so an unchanged-parameters run of draws after
|
||||
// this one costs the four-integer compare again.
|
||||
shadow.dynamicTailParamsVersion = paramsVersion;
|
||||
return;
|
||||
}
|
||||
ApplyGLViewportState(frame.commandBuffer, extent, preTransform, isDefaultFbo);
|
||||
ApplyBlendConstants(frame.commandBuffer);
|
||||
ApplyPolygonOffsetState(frame.commandBuffer);
|
||||
ApplyLineWidthState(frame.commandBuffer);
|
||||
ApplyStencilState(frame.commandBuffer);
|
||||
const Bool scissorEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ScissorTest);
|
||||
VkRect2D scissor{};
|
||||
if (scissorEnabled) {
|
||||
const auto& scissorBox = MG_State::pGLContext->GetScissorBox();
|
||||
scissor = isDefaultFbo
|
||||
? MakeDefaultFramebufferScissorRect(scissorBox, extent, m_swapchainObject.GetPreTransform())
|
||||
: MakeClampedScissorRect(scissorBox, extent);
|
||||
if (key.scissorEnabled) {
|
||||
const IntVec4 scissorBox(key.scissorBox[0], key.scissorBox[1], key.scissorBox[2], key.scissorBox[3]);
|
||||
scissor = isDefaultFbo ? MakeDefaultFramebufferScissorRect(scissorBox, extent, preTransform)
|
||||
: MakeClampedScissorRect(scissorBox, extent);
|
||||
} else {
|
||||
scissor.offset = {0, 0};
|
||||
scissor.extent = { (Uint)extent.x(), (Uint)extent.y() };
|
||||
@@ -5071,18 +5180,28 @@ void main() {
|
||||
shadow.dynamicTailExtentX = extent.x();
|
||||
shadow.dynamicTailExtentY = extent.y();
|
||||
shadow.dynamicTailIsDefaultFbo = isDefaultFbo;
|
||||
shadow.dynamicTailKey = key;
|
||||
}
|
||||
|
||||
Uint32 VulkanRenderer::GetBaseTransformFlagsRaw() {
|
||||
Uint32 VulkanRenderer::GetBaseTransformFlagsRaw(Bool isDefaultFbo) {
|
||||
// GetShaderTransformFlags is a function of the pre-transform AND of whether
|
||||
// the bound draw framebuffer is the default one (the Y-flip/rotation bits
|
||||
// apply only when presenting). Memo keyed on both; keying on the
|
||||
// pre-transform alone served an FBO pass's unflipped flags to the following
|
||||
// default-framebuffer pass and flipped the whole frame.
|
||||
// isDefaultFbo is supplied by the caller: every draw-path caller has already
|
||||
// resolved the bound draw framebuffer (and its default-ness) for its own
|
||||
// guards, and re-walking the binding slot + the virtual IsDefaultFramebuffer
|
||||
// per draw showed up in the profile. Callers MUST pass the value derived from
|
||||
// the SAME draw-framebuffer binding the draw uses - see the assert below.
|
||||
MOBILEGL_ASSERT(
|
||||
[&] {
|
||||
const auto& fbo =
|
||||
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
|
||||
return isDefaultFbo == (fbo != nullptr && fbo->IsDefaultFramebuffer());
|
||||
}(),
|
||||
"GetBaseTransformFlagsRaw: isDefaultFbo does not match the bound draw framebuffer");
|
||||
const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform();
|
||||
const auto& currentDrawFBO =
|
||||
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
|
||||
const Bool isDefaultFbo = currentDrawFBO != nullptr && currentDrawFBO->IsDefaultFramebuffer();
|
||||
if (!m_baseTransformFlagsKeyValid || preTransform != m_baseTransformFlagsPreTransform ||
|
||||
isDefaultFbo != m_baseTransformFlagsIsDefaultFbo) {
|
||||
m_baseTransformFlagsCache = GetShaderTransformFlags(preTransform).GetRaw();
|
||||
@@ -5173,7 +5292,10 @@ void main() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (GetBaseTransformFlagsRaw() != snap.baseTransformFlags) {
|
||||
// The FBO identity+version compare above proved this draw's framebuffer is the
|
||||
// snapshotting draw's, so its default-ness is the snapshot's too - no second walk
|
||||
// of the binding slot and no virtual IsDefaultFramebuffer call.
|
||||
if (GetBaseTransformFlagsRaw(snap.drawFboIsDefault) != snap.baseTransformFlags) {
|
||||
return false;
|
||||
}
|
||||
if (m_textureManager->GetResourceEraseEpoch() != snap.textureEraseEpoch ||
|
||||
@@ -5476,8 +5598,9 @@ void main() {
|
||||
fillSnap->valid = false;
|
||||
m_setupDrawSnapshotMru = fillIndex;
|
||||
}
|
||||
const Bool drawFboIsDefault = drawFbo != nullptr && drawFbo->IsDefaultFramebuffer();
|
||||
ProgramFactory::CompileOptionFlags transformFlags =
|
||||
ProgramFactory::CompileOptionFlags(GetBaseTransformFlagsRaw());
|
||||
ProgramFactory::CompileOptionFlags(GetBaseTransformFlagsRaw(drawFboIsDefault));
|
||||
// Captured draws take the xfb-decorated program variant.
|
||||
if (m_transformFeedbackFeatureEnabled && MG_State::pGLContext->IsTransformFeedbackActive() &&
|
||||
program.GetTransformFeedbackVaryingCount() > 0) {
|
||||
@@ -5813,10 +5936,10 @@ void main() {
|
||||
snap.vaoConfigVersion = vao.GetConfigVersion();
|
||||
snap.drawFbo = drawFbo.get();
|
||||
snap.fboVersion = drawFbo->GetObjectVersion();
|
||||
snap.drawFboIsDefault = drawFbo->IsDefaultFramebuffer();
|
||||
snap.drawFboIsDefault = drawFboIsDefault;
|
||||
snap.renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion();
|
||||
snap.bindGeneration = MG_State::pGLContext->GetTextureBindGeneration();
|
||||
snap.baseTransformFlags = GetBaseTransformFlagsRaw();
|
||||
snap.baseTransformFlags = GetBaseTransformFlagsRaw(drawFboIsDefault);
|
||||
snap.resolvedTransformFlags = transformFlags.GetRaw();
|
||||
snap.renderPassHash = nowActiveRenderPass->hash;
|
||||
snap.imageIndex = m_imageIndexAcquired;
|
||||
@@ -9441,14 +9564,191 @@ void main() {
|
||||
|
||||
MOBILEGL_ASSERT(frame.isCommandRecording, "%s: frame recording was not started", __func__);
|
||||
|
||||
VkCommandBuffer& commandBuffer = frame.commandBuffer;
|
||||
EmitMultiDraw(frame.commandBuffer, payload.pParams, payload.drawCount);
|
||||
}
|
||||
|
||||
for (Uint32 idraw = 0; idraw < payload.drawCount; ++idraw) {
|
||||
vkCmdDraw(commandBuffer,
|
||||
payload.pParams[idraw].vertexCount,
|
||||
payload.pParams[idraw].instanceCount,
|
||||
payload.pParams[idraw].firstVertex,
|
||||
payload.pParams[idraw].firstInstance);
|
||||
// The tier-2 indirect batch uploads the param arrays as-is: the leading members of the
|
||||
// renderer's draw-parameter structs are exactly Vulkan's indirect command layouts, and
|
||||
// vkCmdDraw(Indexed)Indirect accepts any 4-aligned stride >= the command size, so the
|
||||
// trailing CPU-side metadata rides along unread instead of forcing a repack.
|
||||
static_assert(sizeof(DrawIndexedCmdParam) == sizeof(VkDrawIndexedIndirectCommand) &&
|
||||
offsetof(DrawIndexedCmdParam, indexCount) == offsetof(VkDrawIndexedIndirectCommand, indexCount) &&
|
||||
offsetof(DrawIndexedCmdParam, instanceCount) ==
|
||||
offsetof(VkDrawIndexedIndirectCommand, instanceCount) &&
|
||||
offsetof(DrawIndexedCmdParam, firstIndex) == offsetof(VkDrawIndexedIndirectCommand, firstIndex) &&
|
||||
offsetof(DrawIndexedCmdParam, vertexOffset) ==
|
||||
offsetof(VkDrawIndexedIndirectCommand, vertexOffset) &&
|
||||
offsetof(DrawIndexedCmdParam, firstInstance) ==
|
||||
offsetof(VkDrawIndexedIndirectCommand, firstInstance),
|
||||
"DrawIndexedCmdParam must alias VkDrawIndexedIndirectCommand for the tier-2 multi-draw upload");
|
||||
static_assert(sizeof(DrawCmdParam) % 4 == 0 && sizeof(DrawCmdParam) >= sizeof(VkDrawIndirectCommand) &&
|
||||
offsetof(DrawCmdParam, vertexCount) == offsetof(VkDrawIndirectCommand, vertexCount) &&
|
||||
offsetof(DrawCmdParam, instanceCount) == offsetof(VkDrawIndirectCommand, instanceCount) &&
|
||||
offsetof(DrawCmdParam, firstVertex) == offsetof(VkDrawIndirectCommand, firstVertex) &&
|
||||
offsetof(DrawCmdParam, firstInstance) == offsetof(VkDrawIndirectCommand, firstInstance),
|
||||
"DrawCmdParam must lead with VkDrawIndirectCommand for the tier-2 multi-draw upload");
|
||||
|
||||
void VulkanRenderer::EmitMultiDraw(VkCommandBuffer commandBuffer, const DrawCmdParam* pParams, Uint32 drawCount) {
|
||||
if (drawCount == 0) {
|
||||
return;
|
||||
}
|
||||
if (drawCount == 1) {
|
||||
vkCmdDraw(commandBuffer, pParams[0].vertexCount, pParams[0].instanceCount, pParams[0].firstVertex,
|
||||
pParams[0].firstInstance);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tier 1: VK_EXT_multi_draw. vkCmdDrawMultiEXT shares one instanceCount/firstInstance
|
||||
// across the whole batch, so the batch must be uniform in both (GL's glMultiDrawArrays
|
||||
// always is: 1/0).
|
||||
if (m_multiDrawAllowExt) {
|
||||
Bool uniformInstances = true;
|
||||
for (Uint32 idraw = 1; idraw < drawCount; ++idraw) {
|
||||
if (pParams[idraw].instanceCount != pParams[0].instanceCount ||
|
||||
pParams[idraw].firstInstance != pParams[0].firstInstance) {
|
||||
uniformInstances = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (uniformInstances) {
|
||||
static Vector<VkMultiDrawInfoEXT> infos;
|
||||
infos.resize(drawCount);
|
||||
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
|
||||
infos[idraw].firstVertex = pParams[idraw].firstVertex;
|
||||
infos[idraw].vertexCount = pParams[idraw].vertexCount;
|
||||
}
|
||||
for (Uint32 base = 0; base < drawCount; base += m_maxMultiDrawCount) {
|
||||
const Uint32 chunk = std::min(drawCount - base, m_maxMultiDrawCount);
|
||||
s_vkCmdDrawMultiEXT(commandBuffer, chunk, infos.data() + base, pParams[0].instanceCount,
|
||||
pParams[0].firstInstance, sizeof(VkMultiDrawInfoEXT));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: multiDrawIndirect - one vkCmdDrawIndirect over a transient command array.
|
||||
// A sub-draw with firstInstance != 0 is illegal in an indirect command without the
|
||||
// drawIndirectFirstInstance feature; such a batch falls to the unrolled tier.
|
||||
if (m_multiDrawAllowIndirect) {
|
||||
Bool firstInstanceLegal = m_drawIndirectFirstInstanceFeatureEnabled;
|
||||
if (!firstInstanceLegal) {
|
||||
firstInstanceLegal = true;
|
||||
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
|
||||
if (pParams[idraw].firstInstance != 0) {
|
||||
firstInstanceLegal = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const Uint32 maxIndirectCount = m_physicalDevice.properties.limits.maxDrawIndirectCount;
|
||||
if (firstInstanceLegal && maxIndirectCount > 0) {
|
||||
BufferSlice commandSlice{};
|
||||
if (m_bufferManager.UploadTransient(BufferKind::Indirect, m_frameContext.GetCurrentFrameIndex(),
|
||||
pParams,
|
||||
static_cast<VkDeviceSize>(drawCount) * sizeof(DrawCmdParam),
|
||||
sizeof(Uint32), commandSlice)) {
|
||||
for (Uint32 base = 0; base < drawCount; base += maxIndirectCount) {
|
||||
const Uint32 chunk = std::min(drawCount - base, maxIndirectCount);
|
||||
vkCmdDrawIndirect(commandBuffer, commandSlice.buffer,
|
||||
commandSlice.offset +
|
||||
static_cast<VkDeviceSize>(base) * sizeof(DrawCmdParam),
|
||||
chunk, sizeof(DrawCmdParam));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Transient arena refused the upload: fall through to the unrolled tier.
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: unrolled loop, byte-identical fallback (and the only tier where a SPIR-V
|
||||
// DrawIndex consumer sees 0 for every sub-draw instead of the sub-draw index).
|
||||
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
|
||||
vkCmdDraw(commandBuffer, pParams[idraw].vertexCount, pParams[idraw].instanceCount,
|
||||
pParams[idraw].firstVertex, pParams[idraw].firstInstance);
|
||||
}
|
||||
}
|
||||
|
||||
void VulkanRenderer::EmitMultiDrawIndexed(VkCommandBuffer commandBuffer, const DrawIndexedCmdParam* pParams,
|
||||
Uint32 drawCount) {
|
||||
if (drawCount == 0) {
|
||||
return;
|
||||
}
|
||||
if (drawCount == 1) {
|
||||
vkCmdDrawIndexed(commandBuffer, pParams[0].indexCount, pParams[0].instanceCount, pParams[0].firstIndex,
|
||||
pParams[0].vertexOffset, pParams[0].firstInstance);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tier 1: VK_EXT_multi_draw. VkMultiDrawIndexedInfoEXT carries per-draw
|
||||
// firstIndex/indexCount/vertexOffset (pVertexOffset = nullptr keeps the per-draw
|
||||
// offsets), but instanceCount/firstInstance are batch-wide, so the batch must be
|
||||
// uniform in both (GL's glMultiDrawElements* always is: 1/0).
|
||||
if (m_multiDrawAllowExt) {
|
||||
Bool uniformInstances = true;
|
||||
for (Uint32 idraw = 1; idraw < drawCount; ++idraw) {
|
||||
if (pParams[idraw].instanceCount != pParams[0].instanceCount ||
|
||||
pParams[idraw].firstInstance != pParams[0].firstInstance) {
|
||||
uniformInstances = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (uniformInstances) {
|
||||
static Vector<VkMultiDrawIndexedInfoEXT> infos;
|
||||
infos.resize(drawCount);
|
||||
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
|
||||
infos[idraw].firstIndex = pParams[idraw].firstIndex;
|
||||
infos[idraw].indexCount = pParams[idraw].indexCount;
|
||||
infos[idraw].vertexOffset = pParams[idraw].vertexOffset;
|
||||
}
|
||||
for (Uint32 base = 0; base < drawCount; base += m_maxMultiDrawCount) {
|
||||
const Uint32 chunk = std::min(drawCount - base, m_maxMultiDrawCount);
|
||||
s_vkCmdDrawMultiIndexedEXT(commandBuffer, chunk, infos.data() + base,
|
||||
pParams[0].instanceCount,
|
||||
static_cast<Uint32>(pParams[0].firstInstance),
|
||||
sizeof(VkMultiDrawIndexedInfoEXT), nullptr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: multiDrawIndirect - one vkCmdDrawIndexedIndirect over a transient command
|
||||
// array (DrawIndexedCmdParam aliases VkDrawIndexedIndirectCommand, see static_assert).
|
||||
if (m_multiDrawAllowIndirect) {
|
||||
Bool firstInstanceLegal = m_drawIndirectFirstInstanceFeatureEnabled;
|
||||
if (!firstInstanceLegal) {
|
||||
firstInstanceLegal = true;
|
||||
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
|
||||
if (pParams[idraw].firstInstance != 0) {
|
||||
firstInstanceLegal = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const Uint32 maxIndirectCount = m_physicalDevice.properties.limits.maxDrawIndirectCount;
|
||||
if (firstInstanceLegal && maxIndirectCount > 0) {
|
||||
BufferSlice commandSlice{};
|
||||
if (m_bufferManager.UploadTransient(BufferKind::Indirect, m_frameContext.GetCurrentFrameIndex(),
|
||||
pParams,
|
||||
static_cast<VkDeviceSize>(drawCount) *
|
||||
sizeof(DrawIndexedCmdParam),
|
||||
sizeof(Uint32), commandSlice)) {
|
||||
for (Uint32 base = 0; base < drawCount; base += maxIndirectCount) {
|
||||
const Uint32 chunk = std::min(drawCount - base, maxIndirectCount);
|
||||
vkCmdDrawIndexedIndirect(commandBuffer, commandSlice.buffer,
|
||||
commandSlice.offset +
|
||||
static_cast<VkDeviceSize>(base) * sizeof(DrawIndexedCmdParam),
|
||||
chunk, sizeof(DrawIndexedCmdParam));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: unrolled loop, byte-identical fallback (and the only tier where a SPIR-V
|
||||
// DrawIndex consumer sees 0 for every sub-draw instead of the sub-draw index).
|
||||
for (Uint32 idraw = 0; idraw < drawCount; ++idraw) {
|
||||
vkCmdDrawIndexed(commandBuffer, pParams[idraw].indexCount, pParams[idraw].instanceCount,
|
||||
pParams[idraw].firstIndex, pParams[idraw].vertexOffset, pParams[idraw].firstInstance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9470,9 +9770,9 @@ void main() {
|
||||
|
||||
MOBILEGL_ASSERT(frame.isCommandRecording, "%s: frame recording was not started", __func__);
|
||||
|
||||
VkCommandBuffer& commandBuffer = frame.commandBuffer;
|
||||
|
||||
// Collapse contiguous sub-draw runs into one vkCmdDrawIndexed. Per-sub-draw
|
||||
// Collapse contiguous sub-draw runs BEFORE tier dispatch: merging shrinks the
|
||||
// param span every tier consumes (fewer VkMultiDrawIndexedInfoEXT entries, a
|
||||
// smaller transient command array, fewer unrolled vkCmdDrawIndexed). Per-sub-draw
|
||||
// command emission in the driver dominates a Sodium-shaped multi-draw
|
||||
// (steady-state profile: >60% of the case inside the Vulkan driver's
|
||||
// vkCmdDrawIndexed encoding for 132x32 sub-draws/frame), and a chunk
|
||||
@@ -9501,47 +9801,45 @@ void main() {
|
||||
mergeGranularity = 0;
|
||||
}
|
||||
}
|
||||
if (mergeGranularity == 0) {
|
||||
for (Uint32 idraw = 0; idraw < payload.drawCount; ++idraw) {
|
||||
vkCmdDrawIndexed(commandBuffer,
|
||||
payload.pParams[idraw].indexCount,
|
||||
payload.pParams[idraw].instanceCount,
|
||||
payload.pParams[idraw].firstIndex,
|
||||
payload.pParams[idraw].vertexOffset,
|
||||
payload.pParams[idraw].firstInstance);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Uint32 idraw = 0;
|
||||
while (idraw < payload.drawCount) {
|
||||
const DrawIndexedCmdParam& head = payload.pParams[idraw];
|
||||
++idraw;
|
||||
if (head.indexCount == 0) {
|
||||
continue; // draws nothing, contributes nothing to a run
|
||||
}
|
||||
Uint32 mergedIndexCount = head.indexCount;
|
||||
if (head.instanceCount == 1) {
|
||||
while (idraw < payload.drawCount) {
|
||||
const DrawIndexedCmdParam& next = payload.pParams[idraw];
|
||||
if (next.indexCount == 0) {
|
||||
++idraw;
|
||||
continue;
|
||||
}
|
||||
if (mergedIndexCount % mergeGranularity != 0 ||
|
||||
next.instanceCount != 1 ||
|
||||
next.vertexOffset != head.vertexOffset ||
|
||||
next.firstInstance != head.firstInstance ||
|
||||
next.firstIndex != head.firstIndex + mergedIndexCount ||
|
||||
mergedIndexCount + next.indexCount < mergedIndexCount) {
|
||||
break;
|
||||
}
|
||||
mergedIndexCount += next.indexCount;
|
||||
++idraw;
|
||||
const DrawIndexedCmdParam* pParams = payload.pParams;
|
||||
Uint32 drawCount = payload.drawCount;
|
||||
static Vector<DrawIndexedCmdParam> mergedParams;
|
||||
if (mergeGranularity != 0) {
|
||||
mergedParams.clear();
|
||||
mergedParams.reserve(drawCount);
|
||||
Uint32 idraw = 0;
|
||||
while (idraw < drawCount) {
|
||||
DrawIndexedCmdParam head = pParams[idraw];
|
||||
++idraw;
|
||||
if (head.indexCount == 0) {
|
||||
continue; // draws nothing, contributes nothing to a run
|
||||
}
|
||||
if (head.instanceCount == 1) {
|
||||
while (idraw < drawCount) {
|
||||
const DrawIndexedCmdParam& next = pParams[idraw];
|
||||
if (next.indexCount == 0) {
|
||||
++idraw;
|
||||
continue;
|
||||
}
|
||||
if (head.indexCount % mergeGranularity != 0 ||
|
||||
next.instanceCount != 1 ||
|
||||
next.vertexOffset != head.vertexOffset ||
|
||||
next.firstInstance != head.firstInstance ||
|
||||
next.firstIndex != head.firstIndex + head.indexCount ||
|
||||
head.indexCount + next.indexCount < head.indexCount) {
|
||||
break;
|
||||
}
|
||||
head.indexCount += next.indexCount;
|
||||
++idraw;
|
||||
}
|
||||
}
|
||||
mergedParams.push_back(head);
|
||||
}
|
||||
vkCmdDrawIndexed(commandBuffer, mergedIndexCount, head.instanceCount, head.firstIndex,
|
||||
head.vertexOffset, head.firstInstance);
|
||||
pParams = mergedParams.data();
|
||||
drawCount = static_cast<Uint32>(mergedParams.size());
|
||||
}
|
||||
|
||||
EmitMultiDrawIndexed(frame.commandBuffer, pParams, drawCount);
|
||||
}
|
||||
|
||||
// Byte size of the command structures GL defines for the indirect draws (GL 4.6 core
|
||||
@@ -9717,7 +10015,8 @@ void main() {
|
||||
|
||||
MOBILEGL_ASSERT(frame.isCommandRecording, "%s: frame recording was not started", __func__);
|
||||
MGLOG_D("DirectVulkan: glMultiDrawElementsIndirect(drawcount=%d stride=%d)", drawcount, stride);
|
||||
if (drawcount == 1 || (m_multiDrawIndirectFeatureEnabled && stride % 4 == 0)) {
|
||||
if (drawcount == 1 ||
|
||||
(!m_multiDrawForceUnrollIndirect && m_multiDrawIndirectFeatureEnabled && stride % 4 == 0)) {
|
||||
vkCmdDrawIndexedIndirect(frame.commandBuffer,
|
||||
drawSlice.buffer,
|
||||
drawSlice.offset + static_cast<VkDeviceSize>(commandOffset),
|
||||
@@ -9780,7 +10079,8 @@ void main() {
|
||||
|
||||
MOBILEGL_ASSERT(frame.isCommandRecording, "%s: frame recording was not started", __func__);
|
||||
MGLOG_D("DirectVulkan: glMultiDrawArraysIndirect(drawcount=%d stride=%d)", drawcount, stride);
|
||||
if (drawcount == 1 || (m_multiDrawIndirectFeatureEnabled && stride % 4 == 0)) {
|
||||
if (drawcount == 1 ||
|
||||
(!m_multiDrawForceUnrollIndirect && m_multiDrawIndirectFeatureEnabled && stride % 4 == 0)) {
|
||||
vkCmdDrawIndirect(frame.commandBuffer,
|
||||
drawSlice.buffer,
|
||||
drawSlice.offset + static_cast<VkDeviceSize>(commandOffset),
|
||||
@@ -10966,6 +11266,7 @@ void main() {
|
||||
supportedDeviceFeatures.shaderStorageImageWriteWithoutFormat);
|
||||
}
|
||||
deviceFeatures.drawIndirectFirstInstance = supportedDeviceFeatures.drawIndirectFirstInstance;
|
||||
m_drawIndirectFirstInstanceFeatureEnabled = deviceFeatures.drawIndirectFirstInstance == VK_TRUE;
|
||||
deviceFeatures.multiDrawIndirect = supportedDeviceFeatures.multiDrawIndirect;
|
||||
m_multiDrawIndirectFeatureEnabled = deviceFeatures.multiDrawIndirect == VK_TRUE;
|
||||
m_logicOpFeatureEnabled = deviceFeatures.logicOp == VK_TRUE;
|
||||
@@ -11247,6 +11548,56 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// VK_EXT_multi_draw: tier 1 of the multi-draw dispatch - one vkCmdDrawMulti(Indexed)EXT
|
||||
// for a whole glMultiDraw* batch (VkMultiDrawIndexedInfoEXT carries per-draw
|
||||
// firstIndex/indexCount/vertexOffset, so glMultiDrawElementsBaseVertex fits natively).
|
||||
// Requested only when both the extension and its multiDraw feature are present;
|
||||
// absent it, the dispatch falls to the multiDrawIndirect tier or the unrolled loop.
|
||||
m_multiDrawExtensionEnabled = false;
|
||||
m_maxMultiDrawCount = 0;
|
||||
VkPhysicalDeviceMultiDrawFeaturesEXT multiDrawFeatures{};
|
||||
multiDrawFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MULTI_DRAW_FEATURES_EXT;
|
||||
if (IsExtensionSupported(availableExtensions, VK_EXT_MULTI_DRAW_EXTENSION_NAME) &&
|
||||
getPhysicalDeviceFeatures2 != nullptr) {
|
||||
VkPhysicalDeviceFeatures2 featureQuery{};
|
||||
featureQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
|
||||
featureQuery.pNext = &multiDrawFeatures;
|
||||
getPhysicalDeviceFeatures2(m_physicalDevice.handle, &featureQuery);
|
||||
if (multiDrawFeatures.multiDraw == VK_TRUE) {
|
||||
if (!IsExtensionAlreadyEnabled(enabledDeviceExtensions, VK_EXT_MULTI_DRAW_EXTENSION_NAME)) {
|
||||
enabledDeviceExtensions.push_back(VK_EXT_MULTI_DRAW_EXTENSION_NAME);
|
||||
}
|
||||
multiDrawFeatures.pNext = const_cast<void*>(deviceCreateInfo.pNext);
|
||||
deviceCreateInfo.pNext = &multiDrawFeatures;
|
||||
m_multiDrawExtensionEnabled = true;
|
||||
|
||||
VkPhysicalDeviceMultiDrawPropertiesEXT multiDrawProperties{};
|
||||
multiDrawProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MULTI_DRAW_PROPERTIES_EXT;
|
||||
auto getPhysicalDeviceProperties2 = reinterpret_cast<PFN_vkGetPhysicalDeviceProperties2>(
|
||||
vkGetInstanceProcAddr(m_instance, "vkGetPhysicalDeviceProperties2"));
|
||||
if (getPhysicalDeviceProperties2 == nullptr) {
|
||||
getPhysicalDeviceProperties2 = reinterpret_cast<PFN_vkGetPhysicalDeviceProperties2>(
|
||||
vkGetInstanceProcAddr(m_instance, "vkGetPhysicalDeviceProperties2KHR"));
|
||||
}
|
||||
if (getPhysicalDeviceProperties2 != nullptr) {
|
||||
VkPhysicalDeviceProperties2 propertyQuery{};
|
||||
propertyQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2;
|
||||
propertyQuery.pNext = &multiDrawProperties;
|
||||
getPhysicalDeviceProperties2(m_physicalDevice.handle, &propertyQuery);
|
||||
}
|
||||
// Spec minimum is 1024; a driver reporting 0 through a failed query must not
|
||||
// zero out every batch, so fall back to the spec minimum.
|
||||
m_maxMultiDrawCount = multiDrawProperties.maxMultiDrawCount != 0
|
||||
? multiDrawProperties.maxMultiDrawCount
|
||||
: 1024;
|
||||
MGLOG_I("Enabled optional device extension: %s (maxMultiDrawCount=%u)",
|
||||
VK_EXT_MULTI_DRAW_EXTENSION_NAME, m_maxMultiDrawCount);
|
||||
} else {
|
||||
MGLOG_I("VK_EXT_multi_draw is advertised but its multiDraw feature is unavailable; "
|
||||
"multi-draw batches use the indirect or unrolled tier");
|
||||
}
|
||||
}
|
||||
|
||||
deviceCreateInfo.enabledExtensionCount = static_cast<Uint32>(enabledDeviceExtensions.size());
|
||||
deviceCreateInfo.ppEnabledExtensionNames = enabledDeviceExtensions.data();
|
||||
MGLOG_I("Device feature support: robustBufferAccess=%s geometryShader=%s independentBlend=%s logicOp=%s shaderClipDistance=%s "
|
||||
@@ -11303,6 +11654,51 @@ void main() {
|
||||
m_drawIndirectCountExtensionEnabled = false;
|
||||
}
|
||||
|
||||
s_vkCmdDrawMultiEXT = nullptr;
|
||||
s_vkCmdDrawMultiIndexedEXT = nullptr;
|
||||
if (m_multiDrawExtensionEnabled) {
|
||||
s_vkCmdDrawMultiEXT =
|
||||
reinterpret_cast<PFN_vkCmdDrawMultiEXT>(vkGetDeviceProcAddr(m_device, "vkCmdDrawMultiEXT"));
|
||||
s_vkCmdDrawMultiIndexedEXT = reinterpret_cast<PFN_vkCmdDrawMultiIndexedEXT>(
|
||||
vkGetDeviceProcAddr(m_device, "vkCmdDrawMultiIndexedEXT"));
|
||||
if (s_vkCmdDrawMultiEXT == nullptr || s_vkCmdDrawMultiIndexedEXT == nullptr) {
|
||||
MGLOG_W("VK_EXT_multi_draw enabled but its entry points are missing, will continue as if "
|
||||
"VK_EXT_multi_draw is not supported!");
|
||||
s_vkCmdDrawMultiEXT = nullptr;
|
||||
s_vkCmdDrawMultiIndexedEXT = nullptr;
|
||||
m_multiDrawExtensionEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the multi-draw dispatch tiers once: device support clamped by the
|
||||
// MOBILEGL_MAGMA_MULTIDRAW_MODE preference. Requesting an unavailable tier is
|
||||
// never an error - the dispatch falls down the chain ext -> indirect -> unroll.
|
||||
{
|
||||
using MG_Config::MultiDrawMode;
|
||||
const MultiDrawMode mode = MG_Config::Features.MagmaMultiDrawMode;
|
||||
m_multiDrawAllowExt =
|
||||
m_multiDrawExtensionEnabled && (mode == MultiDrawMode::Auto || mode == MultiDrawMode::Ext);
|
||||
m_multiDrawAllowIndirect = m_multiDrawIndirectFeatureEnabled && mode != MultiDrawMode::Unroll;
|
||||
m_multiDrawForceUnrollIndirect = mode == MultiDrawMode::Unroll;
|
||||
if (mode == MultiDrawMode::Ext && !m_multiDrawExtensionEnabled) {
|
||||
MGLOG_I("MOBILEGL_MAGMA_MULTIDRAW_MODE=ext requested but VK_EXT_multi_draw is unavailable; "
|
||||
"falling back to the %s tier",
|
||||
m_multiDrawAllowIndirect ? "indirect" : "unroll");
|
||||
}
|
||||
if (mode == MultiDrawMode::Indirect && !m_multiDrawIndirectFeatureEnabled) {
|
||||
MGLOG_I("MOBILEGL_MAGMA_MULTIDRAW_MODE=indirect requested but the multiDrawIndirect device "
|
||||
"feature is unavailable; falling back to the unroll tier");
|
||||
}
|
||||
MGLOG_I("Multi-draw dispatch tier: %s (VK_EXT_multi_draw=%s, multiDrawIndirect=%s, mode=%s)",
|
||||
m_multiDrawAllowExt ? "ext" : (m_multiDrawAllowIndirect ? "indirect" : "unroll"),
|
||||
m_multiDrawExtensionEnabled ? "true" : "false",
|
||||
m_multiDrawIndirectFeatureEnabled ? "true" : "false",
|
||||
mode == MultiDrawMode::Auto ? "auto"
|
||||
: mode == MultiDrawMode::Ext ? "ext"
|
||||
: mode == MultiDrawMode::Indirect ? "indirect"
|
||||
: "unroll");
|
||||
}
|
||||
|
||||
if (m_transformFeedbackFeatureEnabled) {
|
||||
s_vkCmdBindTransformFeedbackBuffersEXT = reinterpret_cast<PFN_vkCmdBindTransformFeedbackBuffersEXT>(
|
||||
vkGetDeviceProcAddr(m_device, "vkCmdBindTransformFeedbackBuffersEXT"));
|
||||
|
||||
@@ -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;
|
||||
@@ -659,7 +691,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Bool m_baseTransformFlagsIsDefaultFbo = false;
|
||||
Bool m_baseTransformFlagsKeyValid = false;
|
||||
Uint32 m_baseTransformFlagsCache = 0;
|
||||
Uint32 GetBaseTransformFlagsRaw();
|
||||
// isDefaultFbo must be the default-ness of the CURRENTLY bound draw framebuffer;
|
||||
// every caller already has it in hand from its own guards.
|
||||
Uint32 GetBaseTransformFlagsRaw(Bool isDefaultFbo);
|
||||
// Drops every memoized pipeline handle. Required at command-buffer
|
||||
// boundaries and whenever any pipeline may have been destroyed. Also drops
|
||||
// the cached pipeline-state hash: the same boundaries can retire the GL
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
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
|
||||
)
|
||||
|
||||
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})
|
||||
|
||||
# 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}"
|
||||
)
|
||||
@@ -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,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,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,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" "$@"
|
||||
@@ -598,3 +598,61 @@ TEST(TextureAnisotropyCapabilities, ExtensionPresenceIsDetectedExactly) {
|
||||
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(presentCaps, funcs));
|
||||
EXPECT_TRUE(presentCaps.SupportsTextureFilterAnisotropy);
|
||||
}
|
||||
|
||||
// eglGetProcAddress may return a non-NULL stub for an entry point the context does not
|
||||
// implement (NVIDIA's ES driver does exactly that for glMultiDrawElementsBaseVertexEXT and
|
||||
// the stub silently drops draws), so a resolved pointer must NEVER flip these flags on its
|
||||
// own: the extension string is the authority, and the pointer only confirms callability.
|
||||
TEST(MultiDrawCapabilities, PointerAloneNeverCountsAsSupport) {
|
||||
ResetFakeDriver();
|
||||
g_fake.maxVertexSsboBlocks = 0;
|
||||
auto funcs = MakeFakeGLESFunctions();
|
||||
// Simulate the stub hazard: every pointer resolved, no extension advertised.
|
||||
funcs.glMultiDrawArraysIndirectEXT = [](GLenum, const void*, GLsizei, GLsizei) {};
|
||||
funcs.glMultiDrawElementsIndirectEXT = [](GLenum, GLenum, const void*, GLsizei, GLsizei) {};
|
||||
funcs.glMultiDrawElementsBaseVertexEXT = [](GLenum, const GLsizei*, GLenum, const void* const*,
|
||||
GLsizei, const GLint*) {};
|
||||
|
||||
MobileGL::MG_External::GLESCapabilities stubCaps;
|
||||
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(stubCaps, funcs));
|
||||
EXPECT_FALSE(stubCaps.SupportsMultiDrawIndirect);
|
||||
EXPECT_FALSE(stubCaps.SupportsMultiDrawElementsBaseVertex);
|
||||
|
||||
// The real NVIDIA shape: both draw_elements_base_vertex extensions advertised but
|
||||
// GL_EXT_multi_draw_arrays missing, so glMultiDrawElementsBaseVertexEXT (added only by
|
||||
// their interaction with GL_EXT_multi_draw_arrays) is still a stub.
|
||||
ResetFakeDriver();
|
||||
g_fake.maxVertexSsboBlocks = 0;
|
||||
g_fake.extensions.emplace_back("GL_EXT_draw_elements_base_vertex");
|
||||
g_fake.extensions.emplace_back("GL_OES_draw_elements_base_vertex");
|
||||
MobileGL::MG_External::GLESCapabilities nvidiaShapedCaps;
|
||||
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(nvidiaShapedCaps, funcs));
|
||||
EXPECT_FALSE(nvidiaShapedCaps.SupportsMultiDrawElementsBaseVertex);
|
||||
|
||||
// Fully supported: extensions advertised and pointers resolved.
|
||||
ResetFakeDriver();
|
||||
g_fake.maxVertexSsboBlocks = 0;
|
||||
g_fake.extensions.emplace_back("GL_EXT_multi_draw_indirect");
|
||||
g_fake.extensions.emplace_back("GL_OES_draw_elements_base_vertex");
|
||||
g_fake.extensions.emplace_back("GL_EXT_multi_draw_arrays");
|
||||
MobileGL::MG_External::GLESCapabilities supportedCaps;
|
||||
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(supportedCaps, funcs));
|
||||
EXPECT_TRUE(supportedCaps.SupportsMultiDrawIndirect);
|
||||
EXPECT_TRUE(supportedCaps.SupportsMultiDrawElementsBaseVertex);
|
||||
}
|
||||
|
||||
TEST(MultiDrawCapabilities, ExtensionWithoutResolvedPointerIsNotSupport) {
|
||||
// Extensions advertised but the loader could not resolve the entry points (default fake
|
||||
// table leaves them null): the flags must stay false so no caller dereferences null.
|
||||
ResetFakeDriver();
|
||||
g_fake.maxVertexSsboBlocks = 0;
|
||||
g_fake.extensions.emplace_back("GL_EXT_multi_draw_indirect");
|
||||
g_fake.extensions.emplace_back("GL_EXT_draw_elements_base_vertex");
|
||||
g_fake.extensions.emplace_back("GL_EXT_multi_draw_arrays");
|
||||
const auto funcs = MakeFakeGLESFunctions();
|
||||
|
||||
MobileGL::MG_External::GLESCapabilities caps;
|
||||
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
|
||||
EXPECT_FALSE(caps.SupportsMultiDrawIndirect);
|
||||
EXPECT_FALSE(caps.SupportsMultiDrawElementsBaseVertex);
|
||||
}
|
||||
|
||||
@@ -524,9 +524,15 @@ namespace MobileGL::MG_Util::BackendLoader {
|
||||
INIT_GLES_FUNC_OPTIONAL(glPolygonModeANGLE)
|
||||
INIT_GLES_FUNC_OPTIONAL(glColorMaskiEXT)
|
||||
INIT_GLES_FUNC_OPTIONAL(glColorMaskiOES)
|
||||
INIT_GLES_FUNC(glMultiDrawArraysIndirectEXT)
|
||||
INIT_GLES_FUNC(glMultiDrawElementsIndirectEXT)
|
||||
INIT_GLES_FUNC(glMultiDrawElementsBaseVertexEXT)
|
||||
// Extension-only multi-draw entry points. eglGetProcAddress may legally return a
|
||||
// non-NULL stub for these on drivers that do not implement them (NVIDIA's ES driver
|
||||
// returns one for glMultiDrawElementsBaseVertexEXT that silently drops every draw),
|
||||
// so pointer presence proves nothing: callers must gate on the extension-derived
|
||||
// SupportsMultiDrawIndirect / SupportsMultiDrawElementsBaseVertex capability flags,
|
||||
// never on these pointers alone.
|
||||
INIT_GLES_FUNC_OPTIONAL(glMultiDrawArraysIndirectEXT)
|
||||
INIT_GLES_FUNC_OPTIONAL(glMultiDrawElementsIndirectEXT)
|
||||
INIT_GLES_FUNC_OPTIONAL(glMultiDrawElementsBaseVertexEXT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,6 +820,11 @@ namespace MobileGL::MG_Util::BackendLoader {
|
||||
GLint extCount = 0;
|
||||
glesFuncs.glGetIntegerv(GL_NUM_EXTENSIONS, &extCount);
|
||||
MGLOG_I("Detected %d OpenGL ES extensions:", extCount);
|
||||
// Combined below: glMultiDrawElementsBaseVertexEXT exists only where EXT/OES
|
||||
// draw_elements_base_vertex interacts with GL_EXT_multi_draw_arrays.
|
||||
Bool hasMultiDrawIndirectExtension = false;
|
||||
Bool hasDrawElementsBaseVertexExtension = false;
|
||||
Bool hasMultiDrawArraysExtension = false;
|
||||
for (GLint i = 0; i < extCount; ++i) {
|
||||
const char* extension = (const char*)glesFuncs.glGetStringi(GL_EXTENSIONS, i);
|
||||
if (extension) {
|
||||
@@ -856,8 +867,27 @@ namespace MobileGL::MG_Util::BackendLoader {
|
||||
if (std::strcmp(extension, "GL_OES_shader_multisample_interpolation") == 0) {
|
||||
caps.SupportsShaderMultisampleInterpolation = true;
|
||||
}
|
||||
if (std::strcmp(extension, "GL_EXT_multi_draw_indirect") == 0) {
|
||||
hasMultiDrawIndirectExtension = true;
|
||||
}
|
||||
if (std::strcmp(extension, "GL_EXT_draw_elements_base_vertex") == 0 ||
|
||||
std::strcmp(extension, "GL_OES_draw_elements_base_vertex") == 0) {
|
||||
hasDrawElementsBaseVertexExtension = true;
|
||||
}
|
||||
if (std::strcmp(extension, "GL_EXT_multi_draw_arrays") == 0) {
|
||||
hasMultiDrawArraysExtension = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The pointer check on top of the extension check makes each flag sufficient on its own
|
||||
// at a call site; the extension check on top of the pointer keeps a stub returned by
|
||||
// eglGetProcAddress (see AcquireGLESFunctions) from ever counting as support.
|
||||
caps.SupportsMultiDrawIndirect = hasMultiDrawIndirectExtension &&
|
||||
glesFuncs.glMultiDrawArraysIndirectEXT != nullptr &&
|
||||
glesFuncs.glMultiDrawElementsIndirectEXT != nullptr;
|
||||
caps.SupportsMultiDrawElementsBaseVertex = hasDrawElementsBaseVertexExtension &&
|
||||
hasMultiDrawArraysExtension &&
|
||||
glesFuncs.glMultiDrawElementsBaseVertexEXT != nullptr;
|
||||
caps.SupportsShaderMultisampleInterpolation =
|
||||
caps.SupportsShaderMultisampleInterpolation || caps.GLESVersion.Major > 3 ||
|
||||
(caps.GLESVersion.Major == 3 && caps.GLESVersion.Minor >= 2);
|
||||
@@ -873,6 +903,10 @@ namespace MobileGL::MG_Util::BackendLoader {
|
||||
MGLOG_I(" indexed glColorMaski: %s", caps.SupportsIndexedColorMask ? "yes" : "no");
|
||||
MGLOG_I(" dual-source blend (EXT_blend_func_extended): %s",
|
||||
caps.SupportsDualSourceBlend ? "yes" : "no");
|
||||
MGLOG_I(" multi-draw indirect (EXT_multi_draw_indirect): %s",
|
||||
caps.SupportsMultiDrawIndirect ? "yes" : "no");
|
||||
MGLOG_I(" multi-draw base vertex (EXT/OES_draw_elements_base_vertex + EXT_multi_draw_arrays): %s",
|
||||
caps.SupportsMultiDrawElementsBaseVertex ? "yes" : "no");
|
||||
|
||||
MGLOG_I("OpenGL ES capabilities:");
|
||||
glesFuncs.glGetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &caps.UniformBufferOffsetAlignment);
|
||||
|
||||
@@ -1071,6 +1071,19 @@ namespace MobileGL {
|
||||
// GLES 3.2 core or GL_OES_shader_multisample_interpolation exposes
|
||||
// interpolateAtOffset and the three fragment-offset limit queries.
|
||||
Bool SupportsShaderMultisampleInterpolation = false;
|
||||
// GL_EXT_multi_draw_indirect is present AND glMultiDrawArraysIndirectEXT /
|
||||
// glMultiDrawElementsIndirectEXT both resolved. Multi-draw is not core in any ES
|
||||
// version, and eglGetProcAddress may return a live-looking stub on drivers without
|
||||
// the extension, so the pointers alone must never be used as the support signal.
|
||||
Bool SupportsMultiDrawIndirect = false;
|
||||
// glMultiDrawElementsBaseVertexEXT is callable. Per the Khronos registry the entry
|
||||
// point is added by GL_EXT/OES_draw_elements_base_vertex ONLY in interaction with
|
||||
// GL_EXT_multi_draw_arrays; NVIDIA's ES driver advertises both base_vertex
|
||||
// extensions but not GL_EXT_multi_draw_arrays, and its eglGetProcAddress still
|
||||
// hands back a non-NULL stub that silently drops every draw. Hence this flag
|
||||
// requires (EXT or OES draw_elements_base_vertex) AND GL_EXT_multi_draw_arrays
|
||||
// AND a resolved pointer; callers must gate on it, never on the pointer.
|
||||
Bool SupportsMultiDrawElementsBaseVertex = false;
|
||||
// GL_RENDERER contains "ANGLE".
|
||||
Bool IsAngleRenderer = false;
|
||||
// GL_RENDERER contains both "ANGLE" and "llvmpipe".
|
||||
|
||||
@@ -298,6 +298,31 @@ namespace MobileGL::MG_Util::SelfTest {
|
||||
"not supported; no impact: the native indirect path deliberately does not "
|
||||
"rely on it (shader-side emulation handles baseInstance semantics)");
|
||||
}
|
||||
// Both multi-draw rows gate on the capability flags, not the entry-point pointers:
|
||||
// eglGetProcAddress may hand back a non-NULL stub for these on drivers without the
|
||||
// extension (NVIDIA ES does, and its glMultiDrawElementsBaseVertexEXT stub silently
|
||||
// drops every draw), so the pointers prove nothing. Absence is INFO in both cases
|
||||
// because MobileGL falls back to an equivalent per-draw loop.
|
||||
if (caps.SupportsMultiDrawIndirect) {
|
||||
builder.Pass("Multi-draw indirect",
|
||||
"glMultiDrawArrays/ElementsIndirectEXT available via GL_EXT_multi_draw_indirect");
|
||||
} else {
|
||||
builder.Info("Multi-draw indirect",
|
||||
"GL_EXT_multi_draw_indirect not supported; no impact today: multi-draw "
|
||||
"indirect is decomposed into per-command indirect draws regardless");
|
||||
}
|
||||
if (caps.SupportsMultiDrawElementsBaseVertex) {
|
||||
builder.Pass("Multi-draw base vertex",
|
||||
"glMultiDrawElementsBaseVertexEXT available (EXT/OES_draw_elements_base_vertex "
|
||||
"with GL_EXT_multi_draw_arrays); glMultiDrawElementsBaseVertex batches into one "
|
||||
"driver call");
|
||||
} else {
|
||||
builder.Info("Multi-draw base vertex",
|
||||
"glMultiDrawElementsBaseVertexEXT not supported (needs EXT/OES_"
|
||||
"draw_elements_base_vertex plus GL_EXT_multi_draw_arrays); "
|
||||
"glMultiDrawElementsBaseVertex falls back to a per-draw loop with "
|
||||
"identical output");
|
||||
}
|
||||
if (caps.SupportsTextureBorderClamp) {
|
||||
builder.Pass("Texture border clamp",
|
||||
"supported (GL_TEXTURE_BORDER_COLOR reaches the driver, so "
|
||||
@@ -1401,8 +1426,9 @@ namespace MobileGL::MG_Util::SelfTest {
|
||||
if (features.multiDrawIndirect == VK_TRUE) {
|
||||
builder.Pass("multiDrawIndirect", "indirect multi-draw batches run as single native commands");
|
||||
} else {
|
||||
builder.Warn("multiDrawIndirect",
|
||||
"unsupported; indirect multi-draw batches fall back to one draw per command");
|
||||
builder.Info("multiDrawIndirect",
|
||||
"unsupported; multi-draw batches fall back to one draw per command (tier "
|
||||
"\"indirect\" of the multi-draw dispatch is unavailable)");
|
||||
}
|
||||
if (features.drawIndirectFirstInstance == VK_TRUE) {
|
||||
builder.Pass("drawIndirectFirstInstance", "indirect commands may carry a non-zero firstInstance");
|
||||
@@ -1410,6 +1436,42 @@ namespace MobileGL::MG_Util::SelfTest {
|
||||
builder.Warn("drawIndirectFirstInstance",
|
||||
"unsupported; indirect commands with a non-zero baseInstance cannot run natively");
|
||||
}
|
||||
// Multi-draw dispatch tiers (ext -> indirect -> unroll). INFO on the missing
|
||||
// pieces: every tier has a fallback, nothing is lost, only batched into more
|
||||
// commands. The renderer resolves the same chain at device creation, clamped
|
||||
// by MOBILEGL_MAGMA_MULTIDRAW_MODE.
|
||||
{
|
||||
Bool multiDrawExtUsable = false;
|
||||
if (HasVkExtension(deviceExtensions, VK_EXT_MULTI_DRAW_EXTENSION_NAME) &&
|
||||
vkGetPhysicalDeviceFeatures2Fn != nullptr) {
|
||||
VkPhysicalDeviceMultiDrawFeaturesEXT multiDrawFeatures{};
|
||||
multiDrawFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MULTI_DRAW_FEATURES_EXT;
|
||||
VkPhysicalDeviceFeatures2 features2{};
|
||||
features2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
|
||||
features2.pNext = &multiDrawFeatures;
|
||||
vkGetPhysicalDeviceFeatures2Fn(physicalDevice, &features2);
|
||||
multiDrawExtUsable = multiDrawFeatures.multiDraw == VK_TRUE;
|
||||
}
|
||||
if (multiDrawExtUsable) {
|
||||
builder.Pass("VK_EXT_multi_draw",
|
||||
"supported; a glMultiDraw* batch runs as one vkCmdDrawMulti(Indexed)EXT");
|
||||
} else {
|
||||
builder.Info("VK_EXT_multi_draw",
|
||||
"unsupported; glMultiDraw* batches use the indirect or unrolled tier");
|
||||
}
|
||||
const char* resolvedTier = multiDrawExtUsable ? "ext"
|
||||
: features.multiDrawIndirect == VK_TRUE ? "indirect"
|
||||
: "unroll";
|
||||
String tierDetail = format("default tier \"{}\" (chain: ext -> indirect -> unroll)", resolvedTier);
|
||||
const MG_Config::MultiDrawMode multiDrawMode = MG_Config::Features.MagmaMultiDrawMode;
|
||||
if (multiDrawMode != MG_Config::MultiDrawMode::Auto) {
|
||||
tierDetail += format("; MOBILEGL_MAGMA_MULTIDRAW_MODE={} caps it (clamped to device support)",
|
||||
multiDrawMode == MG_Config::MultiDrawMode::Ext ? "ext"
|
||||
: multiDrawMode == MG_Config::MultiDrawMode::Indirect ? "indirect"
|
||||
: "unroll");
|
||||
}
|
||||
builder.Info("Multi-draw dispatch tier", tierDetail);
|
||||
}
|
||||
if (features.vertexPipelineStoresAndAtomics == VK_TRUE) {
|
||||
builder.Pass("vertexPipelineStoresAndAtomics",
|
||||
"supported by driver (not currently enabled by the DirectVulkan backend)");
|
||||
|
||||
Reference in New Issue
Block a user