Compare commits

...
17 Commits
Author SHA1 Message Date
swung0x48 16c010985f [Fix, Test] (MG_Impl): multi-bind name rejection is per element, and only transform feedback constrains the range size to a multiple of four 2026-08-11 09:17:51 -04:00
swung0x48 6f64ec0f51 [Fix, Test] (MG_Impl): validate indirect-dispatch and indirect-count arguments before the backend-availability check 2026-08-11 09:12:49 -04:00
swung0x48 5d47698349 [Fix, Test] (MG_Backend/DirectVulkan): materialize the default framebuffer's pending clear before a readback, alpha included 2026-08-11 09:09:31 -04:00
swung0x48 7b593e39ef [Fix, Test] (MG_Impl, MG_State): negative-path GL errors for multi_bind, indirect_parameters, texture_storage, compute dispatch/link and buffer-range alignment; indexed getters answer the full pname table 2026-08-11 09:02:22 -04:00
swung0x48 d83b4dbbb5 [Fix, Test] (MG_State, MG_Impl): ARB_vertex_attrib_binding state model - spec stride default, legacy stride/pointer shadows, divisor re-binds, core-profile VAO-0 rejection 2026-08-11 08:49:37 -04:00
swung0x48 964a7fcc92 [Fix, Test] (MG_State, MG_Backend/DirectVulkan): resolve transform-feedback captures that name a member of an output interface block 2026-08-11 08:39:39 -04:00
swung0x48 5a7bd9942d [Fix] (MG_Backend/DirectVulkan): print VkShaderModule as a 64-bit value - the const void* cast is ill-formed on 32-bit ABIs where the handle is a plain uint64_t 2026-08-11 08:19:31 -04:00
swung0x48 21a43bf6a4 [Fix, Test] (MG_Backend/DirectVulkan): re-land the gl_FragCoord default-framebuffer origin fix - the suspected slowdown was a mismeasurement, paired timings are within 1% 2026-08-11 07:54:24 -04:00
swung0x48 5b6dec2d81 [Revert] (MG_Backend/DirectVulkan): back out the gl_FragCoord default-framebuffer origin fix - correct, but it costs DirectVulkan a large order-dependent slowdown that is not yet root-caused 2026-08-11 06:30:30 -04:00
swung0x48 543c29bf86 [Fix, Test] (MG_Backend/DirectVulkan): gl_FragCoord on the default framebuffer reports GL's window origin - the stored row is not the window row once the viewport rect is converted 2026-08-11 05:25:32 -04:00
swung0x48 ef562ee9b5 [Fix, Test] (MG_Backend/DirectGLES): backend framebuffer, renderbuffer and sampler twins release their driver ids - a framebuffer per readback leaked the driver into stale pixels 2026-08-11 03:54:05 -04:00
swung0x48 fa0f6693d0 [Fix, Test] (MG_State, MG_Impl): reflection-backed glGetProgramiv queries answer zero instead of dereferencing a null TProgram 2026-08-11 02:59:10 -04:00
swung0x48 a6c362c6ce [Fix, Test] (MG_Backend/DirectVulkan): convert every default-framebuffer rectangle between GL and display Y origins - viewport, scissor, ReadPixels offset, rect-capable readback remap, blit source 2026-08-11 02:51:22 -04:00
swung0x48 921504eccf [Fix, Test] (MG_Backend/DirectVulkan, MG_Util): clamp Vulkan-derived GL buffer limits and saturate the uint32 to Int casts 2026-08-11 02:38:51 -04:00
swung0x48 7c5fc03b26 [Fix, Test] (MG_Backend/DirectVulkan): never bind or cache a null pipeline, and name the modules a failed vkCreateGraphicsPipelines rejected 2026-08-11 02:33:32 -04:00
swung0x48 ed29e63543 [Fix, Test] (MG_Impl): glGetProgramResourceiv reports a written length on every exit path 2026-08-11 02:27:26 -04:00
swung0x48 5c8a9c41d6 [Fix, Test] (MG_Impl, MG_State): GL entry points record errors instead of throwing through the C ABI - CopyTexImage superset rule, TEXTURE_BUFFER level queries, indexed cap toggles 2026-08-11 02:24:41 -04:00
45 changed files with 4219 additions and 240 deletions
+15 -15
View File
@@ -1214,7 +1214,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (g_unitTextureSyncListValid && if (g_unitTextureSyncListValid &&
g_unitTextureSyncListContextId == keys.contextId && g_unitTextureSyncListContextId == keys.contextId &&
g_unitTextureSyncListMaxUnit == maxTouchedUnit && g_unitTextureSyncListMaxUnit == maxTouchedUnit &&
g_unitTextureSyncListContextGeneration == g_textureContextGeneration && g_unitTextureSyncListContextGeneration == g_backendContextGeneration &&
g_unitTextureSyncListEpoch == unitBindingsEpoch && g_unitTextureSyncListEpoch == unitBindingsEpoch &&
g_unitTextureSyncListSamplingGeneration == samplingGeneration && g_unitTextureSyncListSamplingGeneration == samplingGeneration &&
PairingsIntact(g_unitTextureSyncList)) { PairingsIntact(g_unitTextureSyncList)) {
@@ -1246,7 +1246,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
g_unitTextureSyncListContextId = keys.contextId; g_unitTextureSyncListContextId = keys.contextId;
g_unitTextureSyncListMaxUnit = maxTouchedUnit; g_unitTextureSyncListMaxUnit = maxTouchedUnit;
g_unitTextureSyncListContextGeneration = g_textureContextGeneration; g_unitTextureSyncListContextGeneration = g_backendContextGeneration;
g_unitTextureSyncListEpoch = unitBindingsEpoch; g_unitTextureSyncListEpoch = unitBindingsEpoch;
g_unitTextureSyncListSamplingGeneration = samplingGeneration; g_unitTextureSyncListSamplingGeneration = samplingGeneration;
g_unitTextureSyncListValid = true; g_unitTextureSyncListValid = true;
@@ -1274,7 +1274,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_fboTextureSyncListSlotVersion == fboSlotVersion && g_fboTextureSyncListSlotVersion == fboSlotVersion &&
g_fboTextureSyncListObjectVersion == fboObjectVersion && g_fboTextureSyncListObjectVersion == fboObjectVersion &&
g_fboTextureSyncListContextId == keys.contextId && g_fboTextureSyncListContextId == keys.contextId &&
g_fboTextureSyncListContextGeneration == g_textureContextGeneration && g_fboTextureSyncListContextGeneration == g_backendContextGeneration &&
PairingsIntact(g_fboTextureSyncList); PairingsIntact(g_fboTextureSyncList);
if (fboListValid) { if (fboListValid) {
for (const auto& entry : g_fboTextureSyncList) { for (const auto& entry : g_fboTextureSyncList) {
@@ -1302,7 +1302,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_fboTextureSyncListSlotVersion = fboSlotVersion; g_fboTextureSyncListSlotVersion = fboSlotVersion;
g_fboTextureSyncListObjectVersion = fboObjectVersion; g_fboTextureSyncListObjectVersion = fboObjectVersion;
g_fboTextureSyncListContextId = keys.contextId; g_fboTextureSyncListContextId = keys.contextId;
g_fboTextureSyncListContextGeneration = g_textureContextGeneration; g_fboTextureSyncListContextGeneration = g_backendContextGeneration;
} }
} else { } else {
g_fboTextureSyncListFbo = nullptr; g_fboTextureSyncListFbo = nullptr;
@@ -2423,7 +2423,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
static_cast<SizeT>(maxTouchedUnit + 1) * sizeof(SamplerImpl::g_boundSamplersCache[0]); static_cast<SizeT>(maxTouchedUnit + 1) * sizeof(SamplerImpl::g_boundSamplersCache[0]);
if (g_unitSamplerWalkValid && g_unitSamplerWalkContextId == keys.contextId && if (g_unitSamplerWalkValid && g_unitSamplerWalkContextId == keys.contextId &&
g_unitSamplerWalkEpoch == keys.unitBindingsEpoch && g_unitSamplerWalkMaxUnit == maxTouchedUnit && g_unitSamplerWalkEpoch == keys.unitBindingsEpoch && g_unitSamplerWalkMaxUnit == maxTouchedUnit &&
g_unitSamplerWalkContextGeneration == TextureImpl::g_textureContextGeneration && g_unitSamplerWalkContextGeneration == g_backendContextGeneration &&
std::memcmp(g_unitSamplerWalkRows.data(), SamplerImpl::g_boundSamplersCache.data(), rowBytes) == 0) { std::memcmp(g_unitSamplerWalkRows.data(), SamplerImpl::g_boundSamplersCache.data(), rowBytes) == 0) {
return; return;
} }
@@ -2444,7 +2444,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_unitSamplerWalkContextId = keys.contextId; g_unitSamplerWalkContextId = keys.contextId;
g_unitSamplerWalkEpoch = keys.unitBindingsEpoch; g_unitSamplerWalkEpoch = keys.unitBindingsEpoch;
g_unitSamplerWalkMaxUnit = maxTouchedUnit; g_unitSamplerWalkMaxUnit = maxTouchedUnit;
g_unitSamplerWalkContextGeneration = TextureImpl::g_textureContextGeneration; g_unitSamplerWalkContextGeneration = g_backendContextGeneration;
std::memcpy(g_unitSamplerWalkRows.data(), SamplerImpl::g_boundSamplersCache.data(), rowBytes); std::memcpy(g_unitSamplerWalkRows.data(), SamplerImpl::g_boundSamplersCache.data(), rowBytes);
g_unitSamplerWalkValid = true; g_unitSamplerWalkValid = true;
} }
@@ -2554,7 +2554,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
memo.programBackendStateVersion == memo.programBackendStateVersion ==
(currentProgram ? currentProgram->GetBackendStateVersion() : 0) && (currentProgram ? currentProgram->GetBackendStateVersion() : 0) &&
memo.programLinked == (currentProgram && currentProgram->GetLinkStatus()) && memo.programLinked == (currentProgram && currentProgram->GetLinkStatus()) &&
memo.contextGeneration == TextureImpl::g_textureContextGeneration; memo.contextGeneration == g_backendContextGeneration;
// Short-circuited: the shadow compare is only meaningful once the key (and with it the // Short-circuited: the shadow compare is only meaningful once the key (and with it the
// snapshotted row count) matches. // snapshotted row count) matches.
if (!keysMatch || std::memcmp(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(), if (!keysMatch || std::memcmp(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(),
@@ -2569,7 +2569,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
memo.programLifetimeId = currentProgram ? currentProgram->GetLifetimeId() : 0; memo.programLifetimeId = currentProgram ? currentProgram->GetLifetimeId() : 0;
memo.programBackendStateVersion = currentProgram ? currentProgram->GetBackendStateVersion() : 0; memo.programBackendStateVersion = currentProgram ? currentProgram->GetBackendStateVersion() : 0;
memo.programLinked = currentProgram && currentProgram->GetLinkStatus(); memo.programLinked = currentProgram && currentProgram->GetLinkStatus();
memo.contextGeneration = TextureImpl::g_textureContextGeneration; memo.contextGeneration = g_backendContextGeneration;
std::memcpy(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(), shadowBytes); std::memcpy(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(), shadowBytes);
memo.valid = true; memo.valid = true;
} }
@@ -2744,7 +2744,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
samplerPassMemo.unitBindingsEpoch == keys.unitBindingsEpoch && samplerPassMemo.unitBindingsEpoch == keys.unitBindingsEpoch &&
samplerPassMemo.samplingGeneration == keys.samplingGeneration && samplerPassMemo.samplingGeneration == keys.samplingGeneration &&
samplerPassMemo.backendStateVersion == programBackendStateVersion && samplerPassMemo.backendStateVersion == programBackendStateVersion &&
samplerPassMemo.textureContextGeneration == TextureImpl::g_textureContextGeneration; samplerPassMemo.textureContextGeneration == g_backendContextGeneration;
if (samplerPassClean) { if (samplerPassClean) {
for (Uint i = 0; i < samplerPassMemo.count; ++i) { for (Uint i = 0; i < samplerPassMemo.count; ++i) {
if (SamplerImpl::g_boundSamplersCache[samplerPassMemo.units[i]] != if (SamplerImpl::g_boundSamplersCache[samplerPassMemo.units[i]] !=
@@ -2843,7 +2843,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
samplerPassMemo.unitBindingsEpoch = keys.unitBindingsEpoch; samplerPassMemo.unitBindingsEpoch = keys.unitBindingsEpoch;
samplerPassMemo.samplingGeneration = keys.samplingGeneration; samplerPassMemo.samplingGeneration = keys.samplingGeneration;
samplerPassMemo.backendStateVersion = programBackendStateVersion; samplerPassMemo.backendStateVersion = programBackendStateVersion;
samplerPassMemo.textureContextGeneration = TextureImpl::g_textureContextGeneration; samplerPassMemo.textureContextGeneration = g_backendContextGeneration;
samplerPassMemo.valid = true; samplerPassMemo.valid = true;
} }
} }
@@ -3605,12 +3605,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
return false; return false;
} }
if (s_resolveContextGeneration != TextureImpl::g_textureContextGeneration) { if (s_resolveContextGeneration != g_backendContextGeneration) {
// The ids belonged to a dead context; the context reclaimed them with it. // The ids belonged to a dead context; the context reclaimed them with it.
s_resolveFramebuffer = 0; s_resolveFramebuffer = 0;
s_resolveRenderbuffer = 0; s_resolveRenderbuffer = 0;
s_resolveFormat = 0; s_resolveFormat = 0;
s_resolveContextGeneration = TextureImpl::g_textureContextGeneration; s_resolveContextGeneration = g_backendContextGeneration;
} }
if (s_resolveFramebuffer == 0) { if (s_resolveFramebuffer == 0) {
g_GLESFuncs.glGenFramebuffers(1, &s_resolveFramebuffer); g_GLESFuncs.glGenFramebuffers(1, &s_resolveFramebuffer);
@@ -3758,7 +3758,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
static Bool EnsureResources() { static Bool EnsureResources() {
if (s_contextGeneration != TextureImpl::g_textureContextGeneration) { if (s_contextGeneration != g_backendContextGeneration) {
// The ids belonged to a dead context; the context reclaimed them with it. // The ids belonged to a dead context; the context reclaimed them with it.
s_framebuffer = 0; s_framebuffer = 0;
s_texture = 0; s_texture = 0;
@@ -3769,7 +3769,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
s_depthProgram = 0; s_depthProgram = 0;
s_stencilProgram = 0; s_stencilProgram = 0;
s_programsFailed = false; s_programsFailed = false;
s_contextGeneration = TextureImpl::g_textureContextGeneration; s_contextGeneration = g_backendContextGeneration;
} }
if (s_programsFailed) { if (s_programsFailed) {
return false; return false;
@@ -7482,7 +7482,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
PixelStoreImpl::InvalidatePackStateCache(); PixelStoreImpl::InvalidatePackStateCache();
// Texture ids belong to the dying context; wrappers destroyed later must // Texture ids belong to the dying context; wrappers destroyed later must
// not glDeleteTextures a recycled name in a successor context. // not glDeleteTextures a recycled name in a successor context.
++TextureImpl::g_textureContextGeneration; ++g_backendContextGeneration;
g_backendContextOwnerThread.store(std::thread::id{}, std::memory_order_release); g_backendContextOwnerThread.store(std::thread::id{}, std::memory_order_release);
// Outstanding fence handles now refer to a dead context; treat them as // Outstanding fence handles now refer to a dead context; treat them as
// signaled from here on. // signaled from here on.
+79 -6
View File
@@ -33,6 +33,8 @@
#include <regex> #include <regex>
namespace MobileGL::MG_Backend::DirectGLES { namespace MobileGL::MG_Backend::DirectGLES {
Uint g_backendContextGeneration = 1;
constexpr Bool PREFER_MAP_BUFFER_RANGE_FOR_BUFFER_SYNC = false; constexpr Bool PREFER_MAP_BUFFER_RANGE_FOR_BUFFER_SYNC = false;
constexpr const char* BASE_INSTANCE_UNIFORM_NAME = "mg_BaseInstance"; constexpr const char* BASE_INSTANCE_UNIFORM_NAME = "mg_BaseInstance";
constexpr const char* DRAW_ID_UNIFORM_NAME = "mg_DrawID"; constexpr const char* DRAW_ID_UNIFORM_NAME = "mg_DrawID";
@@ -1646,7 +1648,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif #endif
g_GLESFuncs.glGenTextures(1, &m_backendTextureId); g_GLESFuncs.glGenTextures(1, &m_backendTextureId);
m_contextGeneration = g_textureContextGeneration; m_contextGeneration = g_backendContextGeneration;
if (m_backendTextureId == 0) { if (m_backendTextureId == 0) {
MGLOG_E("Failed to generate texture object."); MGLOG_E("Failed to generate texture object.");
MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str());
@@ -1673,7 +1675,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
} }
} }
if (m_contextGeneration == g_textureContextGeneration && g_GLESFuncs.glDeleteTextures) { if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteTextures) {
g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId); g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId);
} }
m_backendTextureId = 0; m_backendTextureId = 0;
@@ -1712,7 +1714,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
void BackendTextureObject::RecreateBackendTexture() { void BackendTextureObject::RecreateBackendTexture() {
if (m_backendTextureId != 0) { if (m_backendTextureId != 0) {
ScratchFBOImpl::NoteTextureIdDeleted(m_backendTextureId); ScratchFBOImpl::NoteTextureIdDeleted(m_backendTextureId);
if (m_contextGeneration == g_textureContextGeneration) { if (m_contextGeneration == g_backendContextGeneration) {
g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId); g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId);
} }
for (auto& unitCache : g_boundTexturesCache) { for (auto& unitCache : g_boundTexturesCache) {
@@ -1725,7 +1727,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
g_GLESFuncs.glGenTextures(1, &m_backendTextureId); g_GLESFuncs.glGenTextures(1, &m_backendTextureId);
m_contextGeneration = g_textureContextGeneration; m_contextGeneration = g_backendContextGeneration;
if (m_backendTextureId == 0) { if (m_backendTextureId == 0) {
MGLOG_E("Failed to regenerate texture object."); MGLOG_E("Failed to regenerate texture object.");
MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str());
@@ -2809,7 +2811,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; // TextureStorageType is {Mipmap, Buffer}, both handled above, so this is a
// backstop for a state object that grew a new storage kind. Skipping the upload
// renders wrong; throwing unwinds through the C GL ABI and kills the process.
MGLOG_I("DirectGLES texture sync: no upload path for storage type %d on texture %u; "
"skipping this sync",
static_cast<int>(stateTextureObject->GetStorageType()),
stateTextureObject->GetExternalIndex());
break;
} }
DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__, func = __func__](GLenum err) { DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__, func = __func__](GLenum err) {
@@ -3074,7 +3083,6 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
Uint g_activeTextureUnit = 0; Uint g_activeTextureUnit = 0;
Uint g_textureContextGeneration = 1;
Array<Array<BackendTextureObject*, (SizeT)TextureTarget::TextureTargetCount>, Array<Array<BackendTextureObject*, (SizeT)TextureTarget::TextureTargetCount>,
MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS> MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
g_boundTexturesCache; g_boundTexturesCache;
@@ -3093,6 +3101,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
m_backendColorSlots[i] = GL_COLOR_ATTACHMENT0 + i; m_backendColorSlots[i] = GL_COLOR_ATTACHMENT0 + i;
} }
g_GLESFuncs.glGenFramebuffers(1, &m_backendFBOId); g_GLESFuncs.glGenFramebuffers(1, &m_backendFBOId);
m_contextGeneration = g_backendContextGeneration;
if (m_backendFBOId == 0) { if (m_backendFBOId == 0) {
MGLOG_E("Failed to generate framebuffer object."); MGLOG_E("Failed to generate framebuffer object.");
MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str());
@@ -3101,6 +3110,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
} }
BackendFramebufferObject::~BackendFramebufferObject() {
if (InProcessTeardown()) {
return; // see InProcessTeardown(): the driver may be unloaded already
}
if (m_backendFBOId == 0) {
return;
}
// Scrub the binding shadow whether or not the id can still be deleted: a
// recycled name must never satisfy the shadow's dedup.
NoteFramebufferIdDeleted(m_backendFBOId);
if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteFramebuffers) {
g_GLESFuncs.glDeleteFramebuffers(1, &m_backendFBOId);
}
m_backendFBOId = 0;
}
void BackendFramebufferObject::Bind(FramebufferTarget target) const { void BackendFramebufferObject::Bind(FramebufferTarget target) const {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
@@ -3156,6 +3181,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
return g_driverFBOBindings[idx]; return g_driverFBOBindings[idx];
} }
void NoteFramebufferIdDeleted(Uint id) {
if (id == 0) {
return;
}
for (SizeT idx = 0; idx < g_driverFBOBindings.size(); ++idx) {
if (g_driverFBOBindingKnown[idx] && g_driverFBOBindings[idx] == id) {
g_driverFBOBindings[idx] = 0; // glDeleteFramebuffers reverts a bound FBO to 0
}
}
}
void InvalidateFramebufferBindingCache() { void InvalidateFramebufferBindingCache() {
g_driverFBOBindings = {0, 0}; g_driverFBOBindings = {0, 0};
g_driverFBOBindingKnown = {false, false}; g_driverFBOBindingKnown = {false, false};
@@ -4563,6 +4599,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif #endif
g_GLESFuncs.glGenSamplers(1, &m_backendSamplerId); g_GLESFuncs.glGenSamplers(1, &m_backendSamplerId);
m_contextGeneration = g_backendContextGeneration;
if (m_backendSamplerId == 0) { if (m_backendSamplerId == 0) {
MGLOG_E("Failed to generate sampler object."); MGLOG_E("Failed to generate sampler object.");
MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str());
@@ -4571,6 +4608,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
} }
} }
BackendSamplerObject::~BackendSamplerObject() {
if (InProcessTeardown()) {
return; // see InProcessTeardown(): the driver may be unloaded already
}
if (m_backendSamplerId == 0) {
return;
}
// Scrub the unit shadow whether or not the id can still be deleted - the next
// twin can land on this heap address and would otherwise false-skip its Bind.
for (auto& boundSampler : g_boundSamplersCache) {
if (boundSampler == this) {
boundSampler = nullptr; // glDeleteSamplers unbinds from every unit
}
}
if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteSamplers) {
g_GLESFuncs.glDeleteSamplers(1, &m_backendSamplerId);
}
m_backendSamplerId = 0;
}
void BackendSamplerObject::SyncToBackend( void BackendSamplerObject::SyncToBackend(
const SharedPtr<MG_State::GLState::SamplerObject>& stateSamplerObject) { const SharedPtr<MG_State::GLState::SamplerObject>& stateSamplerObject) {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
@@ -4686,12 +4743,28 @@ namespace MobileGL::MG_Backend::DirectGLES {
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif #endif
g_GLESFuncs.glGenRenderbuffers(1, &m_backendRBOId); g_GLESFuncs.glGenRenderbuffers(1, &m_backendRBOId);
m_contextGeneration = g_backendContextGeneration;
if (m_backendRBOId == 0) { if (m_backendRBOId == 0) {
MGLOG_E("Failed to generate renderbuffer object."); MGLOG_E("Failed to generate renderbuffer object.");
MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str());
} }
} }
BackendRenderbufferObject::~BackendRenderbufferObject() {
if (InProcessTeardown()) {
return; // see InProcessTeardown(): the driver may be unloaded already
}
if (m_backendRBOId == 0) {
return;
}
// No driver-level renderbuffer-binding shadow exists (Bind() always issues the
// call), so there is nothing to scrub here - only the id to release.
if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteRenderbuffers) {
g_GLESFuncs.glDeleteRenderbuffers(1, &m_backendRBOId);
}
m_backendRBOId = 0;
}
void BackendRenderbufferObject::Bind() const { void BackendRenderbufferObject::Bind() const {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND); ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
+34 -3
View File
@@ -36,6 +36,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool InProcessTeardown(); Bool InProcessTeardown();
void EnsureProcessTeardownSentinel(); void EnsureProcessTeardownSentinel();
// Generation of the backend ES context that owns the driver ids currently handed
// out. Bumped exactly once per DestroyEGLContext. Every backend twin that owns a
// driver name (texture, framebuffer, renderbuffer, sampler) stamps this at
// construction and compares it in its destructor: a twin outliving its context
// must NOT glDelete* its id, because a successor context may already have recycled
// that name and the delete would take out a live object of the new context.
extern Uint g_backendContextGeneration;
// Which optional pieces of state a draw needs synchronized before it is issued. // Which optional pieces of state a draw needs synchronized before it is issued.
// Index/indirect buffer syncs and the instancing-related work are skipped for // Index/indirect buffer syncs and the instancing-related work are skipped for
// draws that provably cannot read them. // draws that provably cannot read them.
@@ -657,15 +665,20 @@ namespace MobileGL::MG_Backend::DirectGLES {
MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS> MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
g_boundTexturesCache; g_boundTexturesCache;
extern Uint g_activeTextureUnit; extern Uint g_activeTextureUnit;
// Bumped when the backend ES context is destroyed; texture ids stamped with
// an older generation belong to a dead context and must not be deleted.
extern Uint g_textureContextGeneration;
} // namespace TextureImpl } // namespace TextureImpl
namespace FramebufferImpl { namespace FramebufferImpl {
class BackendFramebufferObject { class BackendFramebufferObject {
public: public:
BackendFramebufferObject(); BackendFramebufferObject();
// Deletes the driver framebuffer and scrubs the binding shadow. Without it every
// frontend glDeleteFramebuffers leaked one ES framebuffer for the process lifetime;
// an app that creates a framebuffer per readback (GL CTS packed_pixels does ~3300
// per case) walked the driver into hundreds of megabytes of dead framebuffers and
// out of the resources a later attachment needs.
~BackendFramebufferObject();
BackendFramebufferObject(const BackendFramebufferObject&) = delete;
BackendFramebufferObject& operator=(const BackendFramebufferObject&) = delete;
void SyncToBackend(const SharedPtr<MG_State::GLState::FramebufferObject>& stateFBOObject, void SyncToBackend(const SharedPtr<MG_State::GLState::FramebufferObject>& stateFBOObject,
FramebufferTarget asTarget); FramebufferTarget asTarget);
// Apply only this FBO's read buffer (glReadBuffer) to the backend. Split out so it can // Apply only this FBO's read buffer (glReadBuffer) to the backend. Split out so it can
@@ -680,6 +693,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
private: private:
Uint m_backendFBOId = 0; Uint m_backendFBOId = 0;
Uint m_contextGeneration = 0;
/* this will save buffers in its original form, /* this will save buffers in its original form,
reversion, absence or not consecutive are all allowed, as long as GL spec allows it reversion, absence or not consecutive are all allowed, as long as GL spec allows it
@@ -821,6 +835,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
void BindFramebufferId(GLenum fbTarget, Uint id); void BindFramebufferId(GLenum fbTarget, Uint id);
Uint CurrentFramebufferBinding(FramebufferTarget target); Uint CurrentFramebufferBinding(FramebufferTarget target);
void InvalidateFramebufferBindingCache(); void InvalidateFramebufferBindingCache();
// A driver framebuffer id is about to be deleted: ES reverts every target that
// currently binds it to 0, so the binding shadow has to follow or the next
// BindFramebufferId(0) would be deduped away and leave the deleted name bound.
void NoteFramebufferIdDeleted(Uint id);
} // namespace FramebufferImpl } // namespace FramebufferImpl
// Shared scratch framebuffers for the readback/copy/blit emulation paths, with a // Shared scratch framebuffers for the readback/copy/blit emulation paths, with a
@@ -1087,12 +1105,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
class BackendSamplerObject { class BackendSamplerObject {
public: public:
BackendSamplerObject(); BackendSamplerObject();
// Deletes the driver sampler and clears the units whose binding shadow still names
// this twin (a recycled heap address would otherwise false-skip a later Bind).
// Frontend glDeleteSamplers used to leak the backend id for the process lifetime.
~BackendSamplerObject();
BackendSamplerObject(const BackendSamplerObject&) = delete;
BackendSamplerObject& operator=(const BackendSamplerObject&) = delete;
void SyncToBackend(const SharedPtr<MG_State::GLState::SamplerObject>& stateSamplerObject); void SyncToBackend(const SharedPtr<MG_State::GLState::SamplerObject>& stateSamplerObject);
void Bind(Uint unit); void Bind(Uint unit);
Uint GetBackendSamplerId() const; Uint GetBackendSamplerId() const;
private: private:
Uint m_backendSamplerId = 0; Uint m_backendSamplerId = 0;
Uint m_contextGeneration = 0;
Bool m_isInitialized = false; Bool m_isInitialized = false;
SamplerParameters m_cacheSamplerParameters; SamplerParameters m_cacheSamplerParameters;
Uint16 m_syncedSamplerVersion = 0; Uint16 m_syncedSamplerVersion = 0;
@@ -1110,12 +1135,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
class BackendRenderbufferObject { class BackendRenderbufferObject {
public: public:
BackendRenderbufferObject(); BackendRenderbufferObject();
// Deletes the driver renderbuffer; frontend glDeleteRenderbuffers used to leak it
// (with its whole image allocation) for the process lifetime.
~BackendRenderbufferObject();
BackendRenderbufferObject(const BackendRenderbufferObject&) = delete;
BackendRenderbufferObject& operator=(const BackendRenderbufferObject&) = delete;
void SyncToBackend(const SharedPtr<MG_State::GLState::RenderbufferObject>& stateRBOObject); void SyncToBackend(const SharedPtr<MG_State::GLState::RenderbufferObject>& stateRBOObject);
Uint GetBackendRenderbufferId() const { return m_backendRBOId; } Uint GetBackendRenderbufferId() const { return m_backendRBOId; }
void Bind() const; void Bind() const;
private: private:
Uint m_backendRBOId = 0; Uint m_backendRBOId = 0;
Uint m_contextGeneration = 0;
Bool m_isInitialized = false; Bool m_isInitialized = false;
TextureInternalFormat m_cacheInternalFormat = TextureInternalFormat::Unknown; TextureInternalFormat m_cacheInternalFormat = TextureInternalFormat::Unknown;
Int m_cacheWidth = 0; Int m_cacheWidth = 0;
@@ -768,14 +768,48 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// the Uint32 attribute masks the draw path passes around are both bounded by MAX_VERTEX_ATTRIBS. // the Uint32 attribute masks the draw path passes around are both bounded by MAX_VERTEX_ATTRIBS.
m_dynamicParameters.MaxVertexAttribs = std::min( m_dynamicParameters.MaxVertexAttribs = std::min(
m_vulkanCaps.MaxVertexAttribs, static_cast<Int>(MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS)); m_vulkanCaps.MaxVertexAttribs, static_cast<Int>(MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS));
m_dynamicParameters.MaxComputeShaderStorageBlocks = m_vulkanCaps.MaxComputeShaderStorageBlocks; // Vulkan descriptor limits are not GL limits, and a GL application reads an advertised
m_dynamicParameters.MaxCombinedShaderStorageBlocks = m_vulkanCaps.MaxCombinedShaderStorageBlocks; // limit as an amount it may actually USE. Adreno answers the per-stage/per-set descriptor
m_dynamicParameters.MaxComputeUniformBlocks = m_vulkanCaps.MaxComputeUniformBlocks; // queries at descriptor-indexing scale - the same driver whose
// GL_MAX_SHADER_STORAGE_BLOCK_SIZE is clamped from 2147483647 further down - so
// KHR-GL44.multi_bind.dispatch_bind_buffers_base read GL_MAX_COMPUTE_UNIFORM_BLOCKS,
// created that many buffers and spliced that many UBO declarations into a single compute
// shader: ~14 s of allocation, then death on std::bad_alloc. Its sibling
// dispatch_bind_buffers_range hard-codes 4 buffers and passes, which is the clean
// discriminator. Every ceiling below is far above what any desktop driver advertises for
// these (84-96 for the binding families) and far below a descriptor-indexing count, so it
// can only lower a limit that was never usable in the first place. The zero floor is not
// decoration: a driver reporting UINT32_MAX used to arrive here as -1.
const auto clampLimit = [](const char* name, Int reported, Int ceiling) {
const Int clamped = std::min(std::max(reported, 0), ceiling);
if (clamped != reported) {
MGLOG_I("DirectVulkan: clamped %s from %d to %d", name, reported, clamped);
}
return clamped;
};
// GL 4.6 required minimums, for the record: MAX_COMPUTE_UNIFORM_BLOCKS 12,
// MAX_COMPUTE/COMBINED_SHADER_STORAGE_BLOCKS 8, MAX_SHADER_STORAGE_BUFFER_BINDINGS 8,
// MAX_UNIFORM_BUFFER_BINDINGS 84, MAX_TEXTURE_BUFFER_SIZE 65536.
constexpr Int kMaxAdvertisedBufferBlocks = 256;
constexpr Int kMaxAdvertisedTextureBufferSize = 1 << 27; // texels; what desktop GL reports
m_dynamicParameters.MaxComputeShaderStorageBlocks =
clampLimit("GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS", m_vulkanCaps.MaxComputeShaderStorageBlocks,
kMaxAdvertisedBufferBlocks);
m_dynamicParameters.MaxCombinedShaderStorageBlocks =
clampLimit("GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS", m_vulkanCaps.MaxCombinedShaderStorageBlocks,
kMaxAdvertisedBufferBlocks);
m_dynamicParameters.MaxComputeUniformBlocks =
clampLimit("GL_MAX_COMPUTE_UNIFORM_BLOCKS", m_vulkanCaps.MaxComputeUniformBlocks,
kMaxAdvertisedBufferBlocks);
m_dynamicParameters.MaxComputeWorkGroupInvocations = m_vulkanCaps.MaxComputeWorkGroupInvocations; m_dynamicParameters.MaxComputeWorkGroupInvocations = m_vulkanCaps.MaxComputeWorkGroupInvocations;
m_dynamicParameters.MaxShaderStorageBufferBindings = m_vulkanCaps.MaxShaderStorageBufferBindings; m_dynamicParameters.MaxShaderStorageBufferBindings =
m_dynamicParameters.MaxTextureBufferSize = m_vulkanCaps.MaxTextureBufferSize; clampLimit("GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS", m_vulkanCaps.MaxShaderStorageBufferBindings,
kMaxAdvertisedBufferBlocks);
m_dynamicParameters.MaxTextureBufferSize = clampLimit(
"GL_MAX_TEXTURE_BUFFER_SIZE", m_vulkanCaps.MaxTextureBufferSize, kMaxAdvertisedTextureBufferSize);
m_dynamicParameters.TextureBufferOffsetAlignment = m_vulkanCaps.TextureBufferOffsetAlignment; m_dynamicParameters.TextureBufferOffsetAlignment = m_vulkanCaps.TextureBufferOffsetAlignment;
m_dynamicParameters.MaxUniformBufferBindings = m_vulkanCaps.MaxUniformBufferBindings; m_dynamicParameters.MaxUniformBufferBindings = clampLimit(
"GL_MAX_UNIFORM_BUFFER_BINDINGS", m_vulkanCaps.MaxUniformBufferBindings, kMaxAdvertisedBufferBlocks);
m_dynamicParameters.MaxUniformBlockSize = m_vulkanCaps.MaxUniformBlockSize; m_dynamicParameters.MaxUniformBlockSize = m_vulkanCaps.MaxUniformBlockSize;
m_dynamicParameters.MaxImageUnits = std::max(std::min(m_vulkanCaps.MaxImageUnits, maxSupportedTextureUnits), 0); m_dynamicParameters.MaxImageUnits = std::max(std::min(m_vulkanCaps.MaxImageUnits, maxSupportedTextureUnits), 0);
m_dynamicParameters.MaxCombinedImageUniforms = std::max(m_vulkanCaps.MaxCombinedImageUniforms, 0); m_dynamicParameters.MaxCombinedImageUniforms = std::max(m_vulkanCaps.MaxCombinedImageUniforms, 0);
@@ -252,6 +252,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
VkPipeline pipeline = CreatePipeline(payload); VkPipeline pipeline = CreatePipeline(payload);
// A failed creation must never be memoized. Caching VK_NULL_HANDLE served the null back for
// the rest of the process, so one transient driver rejection turned every later draw with
// the same state into a vkCmdBindPipeline(VK_NULL_HANDLE) - the SIGSEGV behind 9 of the 15
// CTS process deaths. Retrying costs one failed vkCreateGraphicsPipelines per draw, which
// is the correct price for a broken pipeline and is bounded by the draw itself being
// skipped.
if (pipeline == VK_NULL_HANDLE) {
MGLOG_I("PipelineFactory::GetOrCreatePipeline: creation failed for hash=0x%llx "
"programHash=0x%llx; not caching the failure",
static_cast<unsigned long long>(hash),
static_cast<unsigned long long>(payload.programHash));
return VK_NULL_HANDLE;
}
m_cache.emplace(hash, PipelineCacheEntry{pipeline, payload.programHash, payload.renderPass, m_cache.emplace(hash, PipelineCacheEntry{pipeline, payload.programHash, payload.renderPass,
m_frameCounter}); m_frameCounter});
return pipeline; return pipeline;
@@ -507,6 +520,35 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MGLOG_F("PipelineFactory::CreatePipeline vertex input: bindingCount=%u attributeCount=%u", MGLOG_F("PipelineFactory::CreatePipeline vertex input: bindingCount=%u attributeCount=%u",
payload.vertexInputState->vertexBindingDescriptionCount, payload.vertexInputState->vertexBindingDescriptionCount,
payload.vertexInputState->vertexAttributeDescriptionCount); payload.vertexInputState->vertexAttributeDescriptionCount);
// The driver's own answer is VK_ERROR_UNKNOWN, i.e. no information at all, so the only
// way to work out WHICH shader it choked on (the open sampler-array-in-struct
// investigation) is to name the modules. MGLOG_I, not _D/_E: this must survive in the
// INFO-level builds that CTS actually runs against.
if (payload.stageSpirvDigests) {
for (SizeT i = 0; i < payload.stageSpirvDigests->size(); ++i) {
const auto& digest = (*payload.stageSpirvDigests)[i];
MGLOG_I("PipelineFactory::CreatePipeline spirv[%zu]: stage=0x%x words=%u bytes=%zu "
"hash=0x%llx",
i, digest.stage, digest.wordCount,
static_cast<SizeT>(digest.wordCount) * sizeof(Uint32),
static_cast<unsigned long long>(digest.hash));
}
} else {
MGLOG_I("PipelineFactory::CreatePipeline: no SPIR-V digests attached to the payload");
}
if (payload.stages) {
for (SizeT i = 0; i < payload.stages->size(); ++i) {
const auto& stage = (*payload.stages)[i];
// VkShaderModule is a non-dispatchable handle: a pointer on 64-bit but a
// plain uint64_t on 32-bit ABIs, where a cast to const void* is ill-formed
// (broke the armeabi-v7a build). Print it as the 64-bit value it is.
MGLOG_I("PipelineFactory::CreatePipeline stage[%zu]: stage=0x%x module=0x%llx entry=%s "
"specialization=%d",
i, static_cast<Uint32>(stage.stage),
static_cast<unsigned long long>(reinterpret_cast<Uint64>(stage.module)),
stage.pName ? stage.pName : "(null)", stage.pSpecializationInfo ? 1 : 0);
}
}
for (Uint32 i = 0; i < payload.colorAttachmentCount; ++i) { for (Uint32 i = 0; i < payload.colorAttachmentCount; ++i) {
const auto& attachment = payload.colorBlendAttachments[i]; const auto& attachment = payload.colorBlendAttachments[i];
MGLOG_F("PipelineFactory::CreatePipeline colorAttachment[%u]: blend=%d colorWriteMask=0x%x srcColor=%d dstColor=%d colorOp=%d srcAlpha=%d dstAlpha=%d alphaOp=%d", MGLOG_F("PipelineFactory::CreatePipeline colorAttachment[%u]: blend=%d colorWriteMask=0x%x srcColor=%d dstColor=%d colorOp=%d srcAlpha=%d dstAlpha=%d alphaOp=%d",
@@ -14,6 +14,16 @@
#include <Includes.h> #include <Includes.h>
namespace MobileGL::MG_Backend::DirectVulkan { namespace MobileGL::MG_Backend::DirectVulkan {
// Enough of a fingerprint to identify the exact module the driver rejected without keeping the
// SPIR-V alive for every program in the cache: a driver that answers VK_ERROR_UNKNOWN tells us
// nothing, so the log has to carry the shader's identity itself. Diagnostic only - never part
// of any pipeline or program hash.
struct ShaderStageSpirvDigest {
Uint32 stage = 0; // VkShaderStageFlagBits
Uint32 wordCount = 0;
Uint64 hash = 0;
};
class PipelineFactory { class PipelineFactory {
public: public:
using HashType = Uint64; using HashType = Uint64;
@@ -62,6 +72,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Array<VkPipelineColorBlendAttachmentState, kMaxColorAttachments> colorBlendAttachments{}; Array<VkPipelineColorBlendAttachmentState, kMaxColorAttachments> colorBlendAttachments{};
const Vector<VkPipelineShaderStageCreateInfo>* stages = nullptr; const Vector<VkPipelineShaderStageCreateInfo>* stages = nullptr;
const VkPipelineVertexInputStateCreateInfo* vertexInputState = nullptr; const VkPipelineVertexInputStateCreateInfo* vertexInputState = nullptr;
// Diagnostic only; may be null. Read solely from the pipeline-creation failure path.
const Vector<ShaderStageSpirvDigest>* stageSpirvDigests = nullptr;
}; };
explicit PipelineFactory(VkDevice device, const VulkanRendererConfig& config); explicit PipelineFactory(VkDevice device, const VulkanRendererConfig& config);
@@ -12,7 +12,10 @@
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h" #include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
#include "MG_Util/ShaderTranspiler/SpvcSession.h" #include "MG_Util/ShaderTranspiler/SpvcSession.h"
#include "MG_Util/ShaderTranspiler/Types.h" #include "MG_Util/ShaderTranspiler/Types.h"
#include <algorithm>
#include <cstring> #include <cstring>
#include <map>
#include <utility>
#include <spirv-tools/libspirv.h> #include <spirv-tools/libspirv.h>
#include <spirv-tools/optimizer.hpp> #include <spirv-tools/optimizer.hpp>
#include <source/opt/build_module.h> #include <source/opt/build_module.h>
@@ -937,6 +940,189 @@ namespace MobileGL::MG_Backend::DirectVulkan {
ProgramFactory::CompileOptionFlags m_transformFlags; ProgramFactory::CompileOptionFlags m_transformFlags;
}; };
// gl_FragCoord back into GL's window space, for default-framebuffer draws only.
//
// Vulkan's gl_FragCoord.y is the framebuffer ROW being written - not a value the
// viewport rect can move independently of placement. The default framebuffer's image is
// stored display-side-up and the vertex stage compensates by negating gl_Position.y, so
// for every default-FBO draw the framebuffer row of a fragment is exactly
// `height - y_GL` (the viewport terms cancel: yf_VK = H - yf_GL for any viewport rect).
// A shader that reads gl_FragCoord therefore sees a flipped Y, and once the viewport
// rect started being converted to the stored orientation it also sees a Y that is
// OUTSIDE the range GL promises - a 32-pixel-tall viewport at GL y=0 reports 224..255 on
// a 256-tall surface. GL CTS shader_image_load_store writes imageStore(image,
// ivec2(gl_FragCoord.xy)) into an image exactly the size of that viewport, so every
// store fell outside the image and the test read back zeroes.
//
// The rewrite redirects every read of the builtin to a Private copy initialised once at
// entry, which is exact for all access forms (whole-vector loads, `.y` access chains,
// OpCopyMemory) and leaves the builtin itself - and its decorations - untouched.
class GlFragCoordYFlipPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-fragcoord-y-flip"; }
explicit GlFragCoordYFlipPass(Uint32 framebufferHeight) : m_framebufferHeight(framebufferHeight) {}
Status Process() override {
using namespace spvtools::opt;
if (m_framebufferHeight == 0) return Status::SuccessWithoutChange;
Instruction* entryPoint = nullptr;
for (auto& candidate : get_module()->entry_points()) {
if (candidate.NumInOperands() >= 2 &&
static_cast<spv::ExecutionModel>(candidate.GetSingleWordInOperand(0)) ==
spv::ExecutionModel::Fragment) {
entryPoint = &candidate;
break;
}
}
if (!entryPoint) return Status::SuccessWithoutChange;
const Uint32 builtinVarId = FindFragCoordVariable();
if (builtinVarId == 0) return Status::SuccessWithoutChange;
Instruction* builtinVar = context()->get_def_use_mgr()->GetDef(builtinVarId);
if (!builtinVar || builtinVar->opcode() != spv::Op::OpVariable) return Status::SuccessWithoutChange;
// The builtin is `Input vec4`; take the vector and component types from its own
// pointer type rather than assuming float32x4, so a module that spells it
// differently declines instead of miscompiling.
Instruction* inputPtrType = context()->get_def_use_mgr()->GetDef(builtinVar->type_id());
if (!inputPtrType || inputPtrType->opcode() != spv::Op::OpTypePointer) {
return Status::SuccessWithoutChange;
}
const Uint32 vectorTypeId = inputPtrType->GetSingleWordInOperand(1);
Instruction* vectorType = context()->get_def_use_mgr()->GetDef(vectorTypeId);
if (!vectorType || vectorType->opcode() != spv::Op::OpTypeVector ||
vectorType->GetSingleWordInOperand(1) != 4) {
return Status::SuccessWithoutChange;
}
const Uint32 floatTypeId = vectorType->GetSingleWordInOperand(0);
auto* floatType = context()->get_type_mgr()->GetType(floatTypeId);
if (!floatType || !floatType->AsFloat() || floatType->AsFloat()->width() != 32) {
return Status::SuccessWithoutChange;
}
const auto heightBits = std::bit_cast<Uint32>(static_cast<float>(m_framebufferHeight));
const auto* heightConst = context()->get_constant_mgr()->GetConstant(floatType, {heightBits});
auto* heightInst = context()->get_constant_mgr()->GetDefiningInstruction(heightConst);
if (!heightInst) return Status::SuccessWithoutChange;
auto* function = context()->GetFunction(entryPoint->GetSingleWordInOperand(1));
if (!function || function->begin() == function->end()) return Status::SuccessWithoutChange;
const Uint32 privatePtrTypeId =
context()->get_type_mgr()->FindPointerToType(vectorTypeId, spv::StorageClass::Private);
if (privatePtrTypeId == 0) return Status::SuccessWithoutChange;
const Uint32 copyVarId = context()->TakeNextId();
if (copyVarId == 0) return Status::SuccessWithoutChange;
auto copyVar = std::make_unique<Instruction>(
context(), spv::Op::OpVariable, privatePtrTypeId, copyVarId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_STORAGE_CLASS, {static_cast<Uint32>(spv::StorageClass::Private)}}});
context()->AddGlobalValue(std::move(copyVar));
// Redirect the reads BEFORE emitting the initialiser, so the initialiser's own
// load of the builtin is not rewritten into a load of the (still empty) copy.
if (!RedirectReads(builtinVarId, copyVarId)) return Status::SuccessWithoutChange;
auto& entryBlock = *function->begin();
auto insertPoint = entryBlock.begin();
while (insertPoint != entryBlock.end() && insertPoint->opcode() == spv::Op::OpVariable) {
++insertPoint;
}
if (insertPoint == entryBlock.end()) return Status::SuccessWithoutChange;
InstructionBuilder builder(context(), &*insertPoint,
IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
auto* raw = builder.AddLoad(vectorTypeId, builtinVarId);
if (!raw) return Status::SuccessWithoutChange;
auto* x = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {0});
auto* y = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {1});
auto* z = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {2});
auto* w = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {3});
if (!x || !y || !z || !w) return Status::SuccessWithoutChange;
auto* flippedY =
builder.AddBinaryOp(floatTypeId, spv::Op::OpFSub, heightInst->result_id(), y->result_id());
if (!flippedY) return Status::SuccessWithoutChange;
auto* corrected = builder.AddCompositeConstruct(
vectorTypeId, {x->result_id(), flippedY->result_id(), z->result_id(), w->result_id()});
if (!corrected) return Status::SuccessWithoutChange;
if (!builder.AddStore(copyVarId, corrected->result_id())) return Status::SuccessWithoutChange;
// SPIR-V 1.4 widened the entry-point interface to every global the entry point
// statically uses, Private included; earlier versions accept Input/Output only,
// so listing it there would be invalid.
if (get_module()->version() >= 0x00010400u) {
entryPoint->AddOperand({SPV_OPERAND_TYPE_ID, {copyVarId}});
context()->AnalyzeUses(entryPoint);
}
context()->InvalidateAnalysesExceptFor(spvtools::opt::IRContext::kAnalysisDefUse |
spvtools::opt::IRContext::kAnalysisInstrToBlockMapping);
return Status::SuccessWithChange;
}
private:
Uint32 FindFragCoordVariable() const {
for (const auto& annotation : get_module()->annotations()) {
if (annotation.opcode() != spv::Op::OpDecorate) continue;
if (annotation.NumInOperands() < 3) continue;
if (static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) !=
spv::Decoration::BuiltIn) {
continue;
}
if (static_cast<spv::BuiltIn>(annotation.GetSingleWordInOperand(2)) != spv::BuiltIn::FragCoord) {
continue;
}
return annotation.GetSingleWordInOperand(0);
}
return 0;
}
// Every instruction that reads through the builtin's POINTER gets the copy instead.
// Decorations, names and the entry-point interface keep naming the builtin.
Bool RedirectReads(Uint32 builtinVarId, Uint32 copyVarId) {
using namespace spvtools::opt;
Bool ok = true;
Vector<Instruction*> users;
context()->get_def_use_mgr()->ForEachUser(builtinVarId, [&](Instruction* user) {
switch (user->opcode()) {
case spv::Op::OpLoad:
case spv::Op::OpAccessChain:
case spv::Op::OpInBoundsAccessChain:
case spv::Op::OpPtrAccessChain:
case spv::Op::OpInBoundsPtrAccessChain:
case spv::Op::OpCopyMemory:
case spv::Op::OpCopyMemorySized:
users.push_back(user);
break;
case spv::Op::OpStore:
// gl_FragCoord is read-only; a store through it means this is not the
// module we think it is.
ok = false;
break;
default:
break;
}
});
if (!ok) return false;
for (Instruction* user : users) {
for (Uint32 i = 0; i < user->NumInOperands(); ++i) {
auto& operand = user->GetInOperand(i);
if (operand.type == SPV_OPERAND_TYPE_ID && !operand.words.empty() &&
operand.words[0] == builtinVarId) {
operand.words[0] = copyVarId;
}
}
context()->AnalyzeUses(user);
}
return true;
}
Uint32 m_framebufferHeight = 0;
};
// Decorates the module's captured varyings for VK_EXT_transform_feedback: // Decorates the module's captured varyings for VK_EXT_transform_feedback:
// user outputs get XfbBuffer/XfbStride/Offset directly; a captured // user outputs get XfbBuffer/XfbStride/Offset directly; a captured
// gl_Position (a gl_PerVertex member) is mirrored into a dedicated output // gl_Position (a gl_PerVertex member) is mirrored into a dedicated output
@@ -948,6 +1134,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
std::string name; std::string name;
Uint32 bufferIndex = 0; Uint32 bufferIndex = 0;
Uint32 offsetBytes = 0; Uint32 offsetBytes = 0;
// Set when the capture names a member of an output interface block
// ("Block.member"): the decoration target is then the block's struct TYPE,
// decorated per member, not the variable. `name` keeps the GL spelling and
// is useless for the id lookup, so the instance name is carried separately.
std::string blockInstanceName;
std::string blockName;
Int blockMemberIndex = -1;
Int blockMemberElement = -1; // array element of that member, -1 = the whole member
Uint32 byteSize = 0;
}; };
const char* name() const override { return "mobilegl-xfb-capture-decorate"; } const char* name() const override { return "mobilegl-xfb-capture-decorate"; }
XfbCaptureDecoratePass(Vector<CapturedVarying> varyings, Vector<Uint32> strides) XfbCaptureDecoratePass(Vector<CapturedVarying> varyings, Vector<Uint32> strides)
@@ -979,6 +1174,33 @@ namespace MobileGL::MG_Backend::DirectVulkan {
decorationManager->AddDecorationVal(targetId, static_cast<Uint32>(spv::Decoration::Offset), decorationManager->AddDecorationVal(targetId, static_cast<Uint32>(spv::Decoration::Offset),
offsetBytes); offsetBytes);
}; };
// SPIR-V puts XfbBuffer/XfbStride/Offset on the struct MEMBER when the
// captured varying lives in an interface block (SPIR-V 1.6 §3.20 lists all
// three as member-decoratable); Offset in particular is illegal on the block
// variable once the type is decorated Block.
const auto decorateMemberForXfb = [&](Uint32 structTypeId, Uint32 memberIndex, Uint32 bufferIndex,
Uint32 offsetBytes) {
const Uint32 stride = bufferIndex < m_strides.size() ? m_strides[bufferIndex] : 0;
decorationManager->AddMemberDecoration(structTypeId, memberIndex,
static_cast<Uint32>(spv::Decoration::XfbBuffer),
bufferIndex);
decorationManager->AddMemberDecoration(structTypeId, memberIndex,
static_cast<Uint32>(spv::Decoration::XfbStride), stride);
decorationManager->AddMemberDecoration(structTypeId, memberIndex,
static_cast<Uint32>(spv::Decoration::Offset), offsetBytes);
};
// A member array captured element by element ("Block.attrib[0]" .. "[15]")
// is one SPIR-V member, so its captures collapse into a single decoration
// placed at the first element's offset - the rest follow from the member's
// own layout. Collected first so the group is complete before it decorates.
struct MemberGroup {
Uint32 bufferIndex = 0;
Uint32 minOffset = 0;
Uint32 elementBytes = 0;
Vector<Uint32> offsets;
};
std::map<std::pair<Uint32, Uint32>, MemberGroup> memberGroups;
Bool modified = false; Bool modified = false;
Bool needsPositionMirror = false; Bool needsPositionMirror = false;
@@ -991,6 +1213,41 @@ namespace MobileGL::MG_Backend::DirectVulkan {
positionOffset = varying.offsetBytes; positionOffset = varying.offsetBytes;
continue; continue;
} }
if (varying.blockMemberIndex >= 0) {
// glslang names the block's instance variable and its struct type
// separately; an anonymous instance leaves only the type named, so
// both spellings are tried before giving up.
Uint32 structTypeId = 0;
if (const auto it = idsByName.find(varying.blockInstanceName); it != idsByName.end()) {
structTypeId = BlockStructTypeOf(it->second);
}
if (structTypeId == 0) {
if (const auto it = idsByName.find(varying.blockName); it != idsByName.end()) {
const spvtools::opt::Instruction* def = context()->get_def_use_mgr()->GetDef(it->second);
if (def != nullptr && def->opcode() == spv::Op::OpTypeStruct) {
structTypeId = it->second;
} else if (def != nullptr && def->opcode() == spv::Op::OpVariable) {
structTypeId = BlockStructTypeOf(it->second);
}
}
}
if (structTypeId == 0) {
MGLOG_E("XfbCaptureDecoratePass: no SPIR-V interface block '%s' (instance '%s') for "
"capture '%s'",
varying.blockName.c_str(), varying.blockInstanceName.c_str(),
varying.name.c_str());
continue;
}
auto& group =
memberGroups[{structTypeId, static_cast<Uint32>(varying.blockMemberIndex)}];
if (group.offsets.empty() || varying.offsetBytes < group.minOffset) {
group.minOffset = varying.offsetBytes;
}
group.bufferIndex = varying.bufferIndex;
group.elementBytes = varying.byteSize;
group.offsets.push_back(varying.offsetBytes);
continue;
}
const auto idIt = idsByName.find(varying.name); const auto idIt = idsByName.find(varying.name);
if (idIt == idsByName.end()) { if (idIt == idsByName.end()) {
MGLOG_E("XfbCaptureDecoratePass: no SPIR-V variable named '%s'", varying.name.c_str()); MGLOG_E("XfbCaptureDecoratePass: no SPIR-V variable named '%s'", varying.name.c_str());
@@ -1000,6 +1257,25 @@ namespace MobileGL::MG_Backend::DirectVulkan {
modified = true; modified = true;
} }
for (auto& [key, group] : memberGroups) {
// The single Offset can only stand for the whole group when the group's
// captures are a gap-free ascending run - that is what SPIR-V lays the
// member's elements out as. Anything else still gets a best-effort
// decoration, but say so, because the capture layout will not match GL.
std::sort(group.offsets.begin(), group.offsets.end());
for (SizeT i = 1; i < group.offsets.size(); ++i) {
if (group.elementBytes == 0 ||
group.offsets[i] != group.offsets[i - 1] + group.elementBytes) {
MGLOG_I("XfbCaptureDecoratePass: block member %u of type %%%u is captured with a "
"non-contiguous element set; the capture layout will differ from GL's",
key.second, key.first);
break;
}
}
decorateMemberForXfb(key.first, key.second, group.bufferIndex, group.minOffset);
modified = true;
}
if (needsPositionMirror) { if (needsPositionMirror) {
modified |= MirrorPositionForCapture(entryFunctionId, *entryPoint, positionBufferIndex, modified |= MirrorPositionForCapture(entryFunctionId, *entryPoint, positionBufferIndex,
positionOffset, decorateForXfb); positionOffset, decorateForXfb);
@@ -1021,6 +1297,27 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
private: private:
// The struct type an interface-block variable points at, peeling an array of
// block instances on the way. 0 when the id is not a block variable at all.
Uint32 BlockStructTypeOf(Uint32 variableId) {
auto* defUse = context()->get_def_use_mgr();
const spvtools::opt::Instruction* variable = defUse->GetDef(variableId);
if (variable == nullptr || variable->opcode() != spv::Op::OpVariable) return 0;
const spvtools::opt::Instruction* pointer = defUse->GetDef(variable->type_id());
if (pointer == nullptr || pointer->opcode() != spv::Op::OpTypePointer) return 0;
Uint32 pointeeId = pointer->GetSingleWordInOperand(1);
for (const spvtools::opt::Instruction* pointee = defUse->GetDef(pointeeId); pointee != nullptr;
pointee = defUse->GetDef(pointeeId)) {
if (pointee->opcode() == spv::Op::OpTypeStruct) return pointeeId;
if (pointee->opcode() != spv::Op::OpTypeArray &&
pointee->opcode() != spv::Op::OpTypeRuntimeArray) {
return 0;
}
pointeeId = pointee->GetSingleWordInOperand(0);
}
return 0;
}
template <typename DecorateFn> template <typename DecorateFn>
Bool MirrorPositionForCapture(Uint32 entryFunctionId, spvtools::opt::Instruction& entryPoint, Bool MirrorPositionForCapture(Uint32 entryFunctionId, spvtools::opt::Instruction& entryPoint,
Uint32 bufferIndex, Uint32 offsetBytes, const DecorateFn& decorateForXfb) { Uint32 bufferIndex, Uint32 offsetBytes, const DecorateFn& decorateForXfb) {
@@ -1301,6 +1598,35 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return spvtools::Optimizer::PassToken(MakeUnique<GlToVulkanPositionFixPass>(transformFlags)); return spvtools::Optimizer::PassToken(MakeUnique<GlToVulkanPositionFixPass>(transformFlags));
} }
Bool TransformSpirvForFragCoordYFlip(const Vector<Uint>& input, Vector<Uint>& output,
Uint32 framebufferHeight) {
if (input.empty()) {
output.clear();
return true;
}
if (framebufferHeight == 0) {
output = input;
return true;
}
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options;
options.set_run_validator(false); // see TransformSpirvForExplicitLod0Sampling
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&,
const char* message) {
MGLOG_E("Vulkan: fragcoord y-flip pass: %s", message != nullptr ? message : "");
});
optimizer.RegisterPass(
spvtools::Optimizer::PassToken(MakeUnique<GlFragCoordYFlipPass>(framebufferHeight)));
const Bool success = optimizer.Run(input.data(), input.size(), &output, options);
if (!success) {
MGLOG_E("Vulkan: failed to run the gl_FragCoord y-flip pass; keeping the original module");
output = input;
}
return success;
}
Bool TransformSpirvForXfbCapture(const Vector<Uint>& input, Vector<Uint>& output, Bool TransformSpirvForXfbCapture(const Vector<Uint>& input, Vector<Uint>& output,
const MG_State::GLState::ProgramObject& program) { const MG_State::GLState::ProgramObject& program) {
if (input.empty()) { if (input.empty()) {
@@ -1310,7 +1636,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Vector<XfbCaptureDecoratePass::CapturedVarying> varyings; Vector<XfbCaptureDecoratePass::CapturedVarying> varyings;
varyings.reserve(program.GetTransformFeedbackVaryingCount()); varyings.reserve(program.GetTransformFeedbackVaryingCount());
for (const auto& varying : program.GetTransformFeedbackVaryings()) { for (const auto& varying : program.GetTransformFeedbackVaryings()) {
varyings.push_back({varying.name, varying.bufferIndex, varying.offsetBytes}); varyings.push_back({varying.name, varying.bufferIndex, varying.offsetBytes,
varying.blockInstanceName, varying.blockName, varying.blockMemberIndex,
varying.blockMemberElement, varying.byteSize});
} }
Vector<Uint32> strides; Vector<Uint32> strides;
strides.reserve(program.GetTransformFeedbackBufferCount()); strides.reserve(program.GetTransformFeedbackBufferCount());
@@ -1772,6 +2100,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
XXHASH_VERIFY(XXH64_update(m_hashState, spv.data(), spv.size() * sizeof(Uint))); XXHASH_VERIFY(XXH64_update(m_hashState, spv.data(), spv.size() * sizeof(Uint)));
} }
XXHASH_VERIFY(XXH64_update(m_hashState, &flags, sizeof(CompileOptionFlags))); XXHASH_VERIFY(XXH64_update(m_hashState, &flags, sizeof(CompileOptionFlags)));
// Only FragCoordYFlip variants bake the height in, so mixing it unconditionally would
// re-key every program in the cache on a resize for no reason.
if (flags & CompileOptionBit::FragCoordYFlip) {
XXHASH_VERIFY(XXH64_update(m_hashState, &m_defaultFramebufferHeight,
sizeof(m_defaultFramebufferHeight)));
}
// Include UBO block bindings in hash so different binding configurations produce different entries // Include UBO block bindings in hash so different binding configurations produce different entries
const Uint32 blockCount = static_cast<Uint32>(program.GetActiveUniformBlocksCount()); const Uint32 blockCount = static_cast<Uint32>(program.GetActiveUniformBlocksCount());
@@ -2380,14 +2714,36 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
} }
void ProgramFactory::SetDefaultFramebufferHeight(Uint32 height) {
if (m_defaultFramebufferHeight == height) {
return;
}
m_defaultFramebufferHeight = height;
// Both memos key on (program, flags) alone, so neither can tell the two heights apart:
// drop the lookup memo, and bump the structure epoch so every caller holding a
// VkProgramObject* re-runs GetOrCreateProgram and lands on the new hash. The cached
// entries themselves stay - they are keyed by a hash that now includes the old height,
// so they can only be reached again if that height comes back, and the frame-boundary
// sweep retires them otherwise.
m_lastLookup = {};
++m_cacheStructureEpoch;
}
const ProgramFactory::VkProgramObject& ProgramFactory::GetOrCreateProgram( const ProgramFactory::VkProgramObject& ProgramFactory::GetOrCreateProgram(
const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags) { const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags) {
// Hashing the full SPIR-V of every stage is far too expensive to repeat per draw; // Hashing the full SPIR-V of every stage is far too expensive to repeat per draw;
// reuse the program's memoized hash while its backend state version is unchanged. // reuse the program's memoized hash while its backend state version is unchanged.
// The memo keys on the flags word, which ComputeHash is no longer a pure function of:
// a FragCoordYFlip variant also depends on the baked default-framebuffer height, so
// that height rides in the free high half of the key. Flags occupy the low bits, and a
// height cannot exceed the 16 bits a swapchain extent fits in.
const Uint memoKey = (flags & CompileOptionBit::FragCoordYFlip)
? (flags.GetRaw() | (m_defaultFramebufferHeight << 16))
: flags.GetRaw();
HashType hash = 0; HashType hash = 0;
if (!program.GetBackendHashMemo(flags.GetRaw(), hash)) { if (!program.GetBackendHashMemo(memoKey, hash)) {
hash = ComputeHash(program, flags); hash = ComputeHash(program, flags);
program.SetBackendHashMemo(flags.GetRaw(), hash); program.SetBackendHashMemo(memoKey, hash);
} }
auto it = m_cache.find(hash); auto it = m_cache.find(hash);
if (it != m_cache.end()) { if (it != m_cache.end()) {
@@ -2440,6 +2796,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
} }
if ((flags & ProgramFactory::CompileOptionBit::FragCoordYFlip) && shaders[i] &&
shaders[i]->GetShaderStage() == ShaderStage::Fragment) {
Vector<Uint> fragCoordSpirv;
if (TransformSpirvForFragCoordYFlip(moduleSpirvs[i], fragCoordSpirv, m_defaultFramebufferHeight)) {
moduleSpirvs[i] = Move(fragCoordSpirv);
}
}
// Vulkan's SPIR-V environment has no rectangle image dimension, so a // Vulkan's SPIR-V environment has no rectangle image dimension, so a
// GL_TEXTURE_RECTANGLE lookup has to become the 2D one the texture is really // GL_TEXTURE_RECTANGLE lookup has to become the 2D one the texture is really
// stored as - which addresses [0,1] where the application addressed texels. // stored as - which addresses [0,1] where the application addressed texels.
@@ -2568,6 +2932,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
entry.modules.push_back(module); entry.modules.push_back(module);
entry.stages.push_back(stage); entry.stages.push_back(stage);
entry.stageSpirvDigests.push_back(ShaderStageSpirvDigest{
static_cast<Uint32>(stage.stage), static_cast<Uint32>(moduleSpv.size()),
XXH64(moduleSpv.data(), moduleSpv.size() * sizeof(Uint), 0)});
} }
// Reflect and create layout as part of the program object // Reflect and create layout as part of the program object
@@ -9,6 +9,7 @@
#pragma once #pragma once
#include "../VkIncludes.h" #include "../VkIncludes.h"
#include "PipelineFactory.h"
#include "MG_State/GLState/ProgramState/ProgramObject.h" #include "MG_State/GLState/ProgramState/ProgramObject.h"
#include "MG_State/GLState/ProgramState/ShaderObject.h" #include "MG_State/GLState/ProgramState/ShaderObject.h"
#include "MG_State/GLState/TextureState/TextureEnum.h" #include "MG_State/GLState/TextureState/TextureEnum.h"
@@ -52,6 +53,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// recorded while GL transform feedback is active, so plain draws keep the // recorded while GL transform feedback is active, so plain draws keep the
// undecorated variant. // undecorated variant.
XfbCapture = 1 << 6, XfbCapture = 1 << 6,
// Rewrites the fragment stage's gl_FragCoord reads to GL's bottom-left window
// origin. Vulkan's gl_FragCoord.y IS the framebuffer row being written, and the
// default framebuffer's image is stored in display (top-left) order, so a shader
// that reads gl_FragCoord there sees `height - y_GL`. Set together with
// PositionYFlip (the two are the same fact about the same draws) except under a
// quarter turn, which this renderer does not convert rectangles for either.
FragCoordYFlip = 1 << 7,
}; };
using CompileOptionFlags = Flags<CompileOptionBit>; using CompileOptionFlags = Flags<CompileOptionBit>;
using HashType = Uint64; using HashType = Uint64;
@@ -62,6 +70,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
HashType hash = 0; HashType hash = 0;
Vector<VkPipelineShaderStageCreateInfo> stages; Vector<VkPipelineShaderStageCreateInfo> stages;
Vector<VkShaderModule> modules; Vector<VkShaderModule> modules;
// Parallel to stages; identifies the exact module bytes handed to the driver when a
// pipeline creation fails. Sixteen bytes per stage instead of keeping the SPIR-V.
Vector<ShaderStageSpirvDigest> stageSpirvDigests;
// Layout data (previously in separate VkProgramLayout) // Layout data (previously in separate VkProgramLayout)
VkDescriptorSetLayout descriptorSetLayout = VK_NULL_HANDLE; VkDescriptorSetLayout descriptorSetLayout = VK_NULL_HANDLE;
@@ -263,6 +274,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const VkProgramObject& GetOrCreateProgram( const VkProgramObject& GetOrCreateProgram(
const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags); const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags);
// The default framebuffer's current image height, baked as a literal into every
// FragCoordYFlip variant (there is no push-constant or specialization channel here, and
// adding one for a value that changes only on swapchain recreation would cost the draw
// path more than a recompile costs a resize). It is therefore part of those variants'
// identity: ComputeHash mixes it in when the bit is set, so a height change re-keys them
// and leaves every other program's hash untouched. Setting a NEW height also bumps the
// cache-structure epoch, because a caller holding a memoised VkProgramObject* would
// otherwise keep using a module compiled against the old height.
void SetDefaultFramebufferHeight(Uint32 height);
Uint32 GetDefaultFramebufferHeight() const { return m_defaultFramebufferHeight; }
// Bumped whenever m_cache's STRUCTURE changes (any insert or erase): the cache is // Bumped whenever m_cache's STRUCTURE changes (any insert or erase): the cache is
// an open-addressing map holding entries by value, so both moves existing entries. // an open-addressing map holding entries by value, so both moves existing entries.
// A caller that memoised a VkProgramObject* may keep dereferencing it only while // A caller that memoised a VkProgramObject* may keep dereferencing it only while
@@ -320,6 +342,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// True only when the logical device enabled both // True only when the logical device enabled both
// shaderStorageImageReadWithoutFormat and shaderStorageImageWriteWithoutFormat. // shaderStorageImageReadWithoutFormat and shaderStorageImageWriteWithoutFormat.
Bool m_unformattedFloatStorageImagesEnabled = false; Bool m_unformattedFloatStorageImagesEnabled = false;
// See SetDefaultFramebufferHeight. 0 means "not known yet"; the FragCoordYFlip bit is
// never set before the swapchain exists, so no variant can be compiled against it.
Uint32 m_defaultFramebufferHeight = 0;
mutable ProgramLookupCache m_lastLookup; mutable ProgramLookupCache m_lastLookup;
// Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging. // Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging.
Uint64 m_frameCounter = 0; Uint64 m_frameCounter = 0;
@@ -220,6 +220,47 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return static_cast<Int>((static_cast<Int64>(value) * toExtent + fromExtent / 2) / fromExtent); return static_cast<Int>((static_cast<Int64>(value) * toExtent + fromExtent / 2) / fromExtent);
} }
// ---------------------------------------------------------------------------------------
// Default-framebuffer rectangles.
//
// GL's window origin is the BOTTOM-left. The default framebuffer's Vulkan image is stored in
// DISPLAY (top-left) orientation, and the difference is reconciled for VERTICES by negating
// gl_Position.y - but only for default-FBO draws (GetShaderTransformFlags ->
// CompileOptionBit::PositionYFlip, applied in ProgramFactory::InsertPositionFixup).
//
// Rectangles were never converted. The viewport, the scissor and the ReadPixels copy offset
// all used the GL bottom-origin Y verbatim as a Vulkan top-origin Y, which is correct only
// when y == H - y - h (full height, or vertically centred) - and full height is the only case
// any test ever exercised. In the conformance suite the errors CANCEL in placement (the draw
// lands in Vulkan rows [y, y+h) and the readback copies the same rows back) and compose into
// an exact vertical flip: 1,759 of Magma's 1,793 non-passing cases, 861 vertical flips and
// nothing else across all of gl33.
//
// The mapping below is derived from - and at full extent exactly reproduces - the pixel
// mapping RemapDefaultFboReadbackToGLOrientation has always used:
// identity : image(x, H-1-y) -> flip Y
// 180 : image(W-1-x, y) -> mirror X (the rotation already flips the rows)
// Quarter turns swap the axes; nothing in this renderer models that (the readback declines to
// remap them and the viewport path only rescales), so they are left exactly as they were.
struct DefaultFramebufferRectMapping {
Bool flipY = false;
Bool mirrorX = false;
};
static DefaultFramebufferRectMapping GetDefaultFramebufferRectMapping(
VkSurfaceTransformFlagBitsKHR preTransform) {
if (preTransform == VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR) return {false, true};
if (IsQuarterTurnPreTransform(preTransform)) return {false, false};
return {true, false};
}
// [origin, origin+size) counted from one end is [extent-origin-size, extent-origin) counted
// from the other. A full-extent rect is a fixed point, which is why this can be introduced
// without moving anything that works today.
static Int MapDefaultFramebufferRectAxis(Int origin, Int size, Int extent, Bool invert) {
return invert ? extent - origin - size : origin;
}
// Redundant dynamic-state elimination for the per-draw hot path: within one // Redundant dynamic-state elimination for the per-draw hot path: within one
// command-buffer recording, a vkCmdSet* whose values already match what the // command-buffer recording, a vkCmdSet* whose values already match what the
// command buffer holds is skipped. Valid because every PipelineFactory // command buffer holds is skipped. Valid because every PipelineFactory
@@ -417,6 +458,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
viewportHeight = ScaleFramebufferCoordinate(viewportHeight, logicalExtent.y(), framebufferExtent.y()); viewportHeight = ScaleFramebufferCoordinate(viewportHeight, logicalExtent.y(), framebufferExtent.y());
} }
// The GL viewport rect, expressed against the default framebuffer's stored orientation.
// A full-height viewport is unchanged by this, which is why every existing scenario keeps
// its exact behaviour.
if (isDefaultFramebuffer) {
const DefaultFramebufferRectMapping mapping = GetDefaultFramebufferRectMapping(preTransform);
viewportX = MapDefaultFramebufferRectAxis(viewportX, viewportWidth, framebufferExtent.x(),
mapping.mirrorX);
viewportY = MapDefaultFramebufferRectAxis(viewportY, viewportHeight, framebufferExtent.y(),
mapping.flipY);
}
VkViewport viewport{}; VkViewport viewport{};
viewport.x = static_cast<float>(viewportX); viewport.x = static_cast<float>(viewportX);
viewport.y = static_cast<float>(viewportY); viewport.y = static_cast<float>(viewportY);
@@ -518,11 +570,25 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return scissor; return scissor;
} }
// The clamped rect, re-expressed against the default framebuffer's stored orientation. Same
// conversion as the viewport - and it must be the same one, or the scissor would cut a band
// the draw never touched.
static VkRect2D MapScissorRectToDefaultFramebuffer(VkRect2D scissor, const IntVec2& framebufferExtent,
VkSurfaceTransformFlagBitsKHR preTransform) {
const DefaultFramebufferRectMapping mapping = GetDefaultFramebufferRectMapping(preTransform);
scissor.offset.x = MapDefaultFramebufferRectAxis(scissor.offset.x, static_cast<Int>(scissor.extent.width),
framebufferExtent.x(), mapping.mirrorX);
scissor.offset.y = MapDefaultFramebufferRectAxis(scissor.offset.y, static_cast<Int>(scissor.extent.height),
framebufferExtent.y(), mapping.flipY);
return scissor;
}
static VkRect2D MakeDefaultFramebufferScissorRect(const IntVec4& scissorBox, static VkRect2D MakeDefaultFramebufferScissorRect(const IntVec4& scissorBox,
const IntVec2& framebufferExtent, const IntVec2& framebufferExtent,
VkSurfaceTransformFlagBitsKHR preTransform) { VkSurfaceTransformFlagBitsKHR preTransform) {
if (!IsQuarterTurnPreTransform(preTransform)) { if (!IsQuarterTurnPreTransform(preTransform)) {
return MakeClampedScissorRect(scissorBox, framebufferExtent); return MapScissorRectToDefaultFramebuffer(MakeClampedScissorRect(scissorBox, framebufferExtent),
framebufferExtent, preTransform);
} }
const IntVec2 logicalExtent = ResolveDefaultFramebufferLogicalExtent(preTransform, framebufferExtent); const IntVec2 logicalExtent = ResolveDefaultFramebufferLogicalExtent(preTransform, framebufferExtent);
@@ -542,7 +608,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static_cast<Uint32>(std::max<Int>(0, rawX1 - rawX0)), static_cast<Uint32>(std::max<Int>(0, rawX1 - rawX0)),
static_cast<Uint32>(std::max<Int>(0, rawY1 - rawY0)), static_cast<Uint32>(std::max<Int>(0, rawY1 - rawY0)),
}; };
return scissor; // A quarter turn maps to {false, false}, so this is a no-op today; it is here so the
// branch cannot drift away from the identity/180 one when quarter turns are modelled.
return MapScissorRectToDefaultFramebuffer(scissor, framebufferExtent, preTransform);
} }
static void ApplyStencilState(VkCommandBuffer commandBuffer) { static void ApplyStencilState(VkCommandBuffer commandBuffer) {
@@ -1928,6 +1996,29 @@ void main() {
} }
} }
// The same conversion on the READ side, which never had one: a blit whose source is the
// default framebuffer used raw GL offsets against a display-oriented image, so it sampled
// the mirrored band and wrote it upside down. Mapping BOTH endpoints inverts the offset
// pair, and an inverted pair is exactly how VkImageBlit spells "flip this axis" - so the
// band and the row order are corrected in one step. A full-extent blit is unchanged in
// band and gains the row flip it always needed.
static void ApplyNativeBlitDefaultFramebufferSourceTransform(VkSurfaceTransformFlagBitsKHR preTransform,
const BlitImageBinding& srcBinding,
VkImageBlit& blitRegion) {
switch (preTransform) {
case VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR:
blitRegion.srcOffsets[0].y = srcBinding.extent.y() - blitRegion.srcOffsets[0].y;
blitRegion.srcOffsets[1].y = srcBinding.extent.y() - blitRegion.srcOffsets[1].y;
break;
case VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR:
blitRegion.srcOffsets[0].x = srcBinding.extent.x() - blitRegion.srcOffsets[0].x;
blitRegion.srcOffsets[1].x = srcBinding.extent.x() - blitRegion.srcOffsets[1].x;
break;
default:
break;
}
}
static Bool DecodeReadbackPixel(const Uint8* source, VkFormat sourceFormat, Float* rgba) { static Bool DecodeReadbackPixel(const Uint8* source, VkFormat sourceFormat, Float* rgba) {
switch (sourceFormat) { switch (sourceFormat) {
case VK_FORMAT_R8G8B8A8_UNORM: case VK_FORMAT_R8G8B8A8_UNORM:
@@ -2033,42 +2124,42 @@ void main() {
return static_cast<Uint8>(value * 255.0f + 0.5f); return static_cast<Uint8>(value * 255.0f + 0.5f);
} }
// Remap raw swapchain pixels (top-left origin, preTransform-rotated) into // Re-order the copied BLOCK - not the whole image - from the default framebuffer's stored
// GL-oriented pixels (bottom-left origin) for the retrace snapshot path. // orientation into GL's. The caller has already aimed the copy at the right place with
// Mirrors the removed GetPresentedDumpPixel mapping plus the Y-origin flip // MapDefaultFramebufferRectAxis, so what arrives here is exactly the requested
// apitrace's flipped=true Image expects. Only identity/180 share the // rectWidth x rectHeight rect, and all that is left is the order of rows (identity) or of
// swapchain extent with the default framebuffer; 90/270 swap extents and // columns (180) WITHIN it.
// are not handled here. //
// This used to iterate the full swapchain extent and index both sides with that stride,
// which is why its caller could only use it on an exact full-extent read - and why every
// partial glReadPixels of the default framebuffer came back in Vulkan row order. Only
// identity/180 share the swapchain extent with the default framebuffer; 90/270 swap
// extents and are still declined.
static Bool RemapDefaultFboReadbackToGLOrientation(const Uint8* rawPixels, static Bool RemapDefaultFboReadbackToGLOrientation(const Uint8* rawPixels,
VkExtent2D rawExtent, Uint32 rectWidth,
Uint32 rectHeight,
VkSurfaceTransformFlagBitsKHR preTransform, VkSurfaceTransformFlagBitsKHR preTransform,
SizeT texelSize, SizeT texelSize,
Uint8* outPixels) { Uint8* outPixels) {
if (IsQuarterTurnPreTransform(preTransform)) { if (IsQuarterTurnPreTransform(preTransform)) {
return false; return false;
} }
const Uint32 w = rawExtent.width; if (rectWidth == 0 || rectHeight == 0 || texelSize == 0) {
const Uint32 h = rawExtent.height;
if (w == 0 || h == 0) {
return false; return false;
} }
for (Uint32 outY = 0; outY < h; ++outY) { const DefaultFramebufferRectMapping mapping = GetDefaultFramebufferRectMapping(preTransform);
const Uint32 displayY = h - 1 - outY; // GL bottom-origin -> display top-origin const SizeT rowBytes = static_cast<SizeT>(rectWidth) * texelSize;
for (Uint32 outX = 0; outX < w; ++outX) { for (Uint32 outY = 0; outY < rectHeight; ++outY) {
const Uint32 displayX = outX; const Uint32 srcY = mapping.flipY ? (rectHeight - 1 - outY) : outY;
Uint32 rawX = displayX; const Uint8* srcRow = rawPixels + static_cast<SizeT>(srcY) * rowBytes;
Uint32 rawY = displayY; Uint8* dstRow = outPixels + static_cast<SizeT>(outY) * rowBytes;
switch (preTransform) { if (!mapping.mirrorX) {
case VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR: Memcpy(dstRow, srcRow, rowBytes);
rawX = w - 1 - displayX; continue;
rawY = h - 1 - displayY; }
break; for (Uint32 outX = 0; outX < rectWidth; ++outX) {
default: Memcpy(dstRow + static_cast<SizeT>(outX) * texelSize,
break; srcRow + static_cast<SizeT>(rectWidth - 1 - outX) * texelSize, texelSize);
}
const Uint8* src = rawPixels + (static_cast<SizeT>(rawY) * w + rawX) * texelSize;
Uint8* dst = outPixels + (static_cast<SizeT>(outY) * w + outX) * texelSize;
Memcpy(dst, src, texelSize);
} }
} }
return true; return true;
@@ -2688,6 +2779,14 @@ void main() {
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
if (currentDrawFBO != nullptr && currentDrawFBO->IsDefaultFramebuffer()) { if (currentDrawFBO != nullptr && currentDrawFBO->IsDefaultFramebuffer()) {
flags |= ProgramFactory::CompileOptionBit::PositionYFlip; flags |= ProgramFactory::CompileOptionBit::PositionYFlip;
// gl_FragCoord follows the same rule the default-framebuffer RECTANGLES follow
// (GetDefaultFramebufferRectMapping): flipped for identity/180, left alone under a
// quarter turn, which this renderer converts nothing for. Keeping the two in step
// is the whole point - a fragment's window Y and the viewport that placed it must
// agree on which end of the image they count from.
if (!IsQuarterTurnPreTransform(preTransform)) {
flags |= ProgramFactory::CompileOptionBit::FragCoordYFlip;
}
switch (preTransform) { switch (preTransform) {
case VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR: case VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR:
flags |= ProgramFactory::CompileOptionBit::SurfaceRotate90; flags |= ProgramFactory::CompileOptionBit::SurfaceRotate90;
@@ -2842,6 +2941,9 @@ void main() {
m_shaderDrawParametersFeatureEnabled, m_shaderDrawParametersFeatureEnabled,
m_unformattedFloatStorageImagesEnabled); m_unformattedFloatStorageImagesEnabled);
MOBILEGL_ASSERT(m_programFactory != nullptr, "ProgramFactory creation failed."); MOBILEGL_ASSERT(m_programFactory != nullptr, "ProgramFactory creation failed.");
// The swapchain already exists at this point (Initialize creates it first), so seed the
// height the factory could not be told about from CreateSwapchain.
m_programFactory->SetDefaultFramebufferHeight(m_swapchainObject.GetExtent().height);
// Aging evictions (render passes and program entries) must purge the dependent // Aging evictions (render passes and program entries) must purge the dependent
// pipeline / compute-pipeline / descriptor-set caches in the same step; both // pipeline / compute-pipeline / descriptor-set caches in the same step; both
// sweeps only run from the frame-boundary seams, long after initialization. // sweeps only run from the frame-boundary seams, long after initialization.
@@ -4049,7 +4151,8 @@ void main() {
.depthWriteEnable = false, .depthWriteEnable = false,
.depthCompareOp = VK_COMPARE_OP_ALWAYS, .depthCompareOp = VK_COMPARE_OP_ALWAYS,
.stages = &programObj.stages, .stages = &programObj.stages,
.vertexInputState = &kEmptyVertexInputState .vertexInputState = &kEmptyVertexInputState,
.stageSpirvDigests = &programObj.stageSpirvDigests
}; };
static constexpr VkColorComponentFlags kColorWriteMask = static constexpr VkColorComponentFlags kColorWriteMask =
VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT |
@@ -4727,7 +4830,8 @@ void main() {
.backStencilCompareOp = MG_Util::ConvertDepthTestFuncToVkEnum(backStencil.Func), .backStencilCompareOp = MG_Util::ConvertDepthTestFuncToVkEnum(backStencil.Func),
.fragmentReplacesDepth = programObj.fragmentReplacesDepth, .fragmentReplacesDepth = programObj.fragmentReplacesDepth,
.stages = &programObj.stages, .stages = &programObj.stages,
.vertexInputState = pipelineVertexInputState .vertexInputState = pipelineVertexInputState,
.stageSpirvDigests = &programObj.stageSpirvDigests
}; };
if (!payload.stencilTestEnable) { if (!payload.stencilTestEnable) {
payload.frontStencilFailOp = VK_STENCIL_OP_KEEP; payload.frontStencilFailOp = VK_STENCIL_OP_KEEP;
@@ -5911,6 +6015,17 @@ void main() {
} }
auto pipeline = GetOrCreatePipeline(mode, program, programObj, transformFlags, vao, *renderPassEntry); auto pipeline = GetOrCreatePipeline(mode, program, programObj, transformFlags, vao, *renderPassEntry);
// GetOrCreatePipeline documents a VK_NULL_HANDLE return (empty stages, or a driver that
// rejected vkCreateGraphicsPipelines). Binding it dereferences null inside the driver -
// 9 of the 15 CTS process deaths were exactly this vkCmdBindPipeline. A draw that has no
// pipeline is a skipped draw, which is what every other failure below already does.
// MGLOG_I so the skip is visible in the INFO builds CTS runs against.
if (pipeline == VK_NULL_HANDLE) {
MGLOG_I("SetupDraw skipped: no graphics pipeline for program=%u (creation failed or the "
"program has no shader stages)",
program.GetExternalIndex());
return false;
}
activeRenderPass = VkRenderPassManager::GetActiveRenderPass(); activeRenderPass = VkRenderPassManager::GetActiveRenderPass();
// Begin render pass, and handle clear // Begin render pass, and handle clear
@@ -7079,6 +7194,93 @@ void main() {
return true; return true;
} }
// A glClear on the DEFAULT framebuffer is parked as a pending clear and folded into the next
// render pass's loadOp. With no draw in between there is no render pass, so a readback that
// followed such a clear blitted the untouched swapchain image and returned the PREVIOUS
// frame's colour - which is exactly what the whole KHR-GL40.draw_indirect.negative-* family
// sees (clear, an erroring draw that never executes, glReadPixels expecting zeroes).
//
// Materializing it means clearing the acquired swapchain image itself, which is why this
// cannot reuse MaterializePendingClearForTexture: the default FBO's colour attachment is a
// placeholder ITextureObject, and syncing it would allocate and clear an unrelated image.
Bool VulkanRenderer::MaterializePendingClearForDefaultFramebuffer(VkCommandBuffer commandBuffer,
MG_State::GLState::FramebufferObject& fbo,
FramebufferAttachmentType attachmentType) {
if (!fbo.IsDefaultFramebuffer() || attachmentType == FramebufferAttachmentType::None) {
return true;
}
const auto& attachment = fbo.GetAttachment(attachmentType);
if (!attachment.IsTexture() || attachment.IsRenderbuffer()) {
return true;
}
ClearAttachmentPayload payload{};
if (!m_clearManager->GetPendingClear(attachment, payload)) {
return true;
}
if ((payload.mask & GL_COLOR_BUFFER_BIT) == 0) {
// Depth/stencil on the default framebuffer keeps the loadOp route; the readback
// path for it declines default framebuffers outright (ReadDepthStencilPixels).
return true;
}
MOBILEGL_ASSERT(VkRenderPassManager::GetActiveRenderPass() == nullptr ||
commandBuffer != m_frameContext.GetCurrent().commandBuffer,
"MaterializePendingClearForDefaultFramebuffer requires no active render pass");
const VkImage swapchainImage = m_swapchainObject.GetImage(m_imageIndexAcquired);
if (swapchainImage == VK_NULL_HANDLE) {
return false;
}
VkImageLayout currentLayout = m_swapchainObject.GetImageLayout(m_imageIndexAcquired);
VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
VkAccessFlags srcAccessMask = 0;
GetImageTransitionSourceState(currentLayout, srcStageMask, srcAccessMask);
VkImageLayout clearLayout = currentLayout;
if (!VkTextureManager::TransitionImageLayout(commandBuffer, swapchainImage, clearLayout,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, srcStageMask,
VK_PIPELINE_STAGE_TRANSFER_BIT, srcAccessMask,
VK_ACCESS_TRANSFER_WRITE_BIT, VK_IMAGE_ASPECT_COLOR_BIT)) {
return false;
}
// The clear colour goes in verbatim, alpha included. Forcing opaque alpha here is what
// makes a glClear(0,0,0,0) read back as (0,0,0,1) - the default framebuffer's placeholder
// attachment can describe an alpha-less format while the swapchain image it stands for
// has a real alpha channel.
VkClearColorValue clearColor{};
clearColor.float32[0] = payload.color.x();
clearColor.float32[1] = payload.color.y();
clearColor.float32[2] = payload.color.z();
clearColor.float32[3] = payload.color.w();
VkImageSubresourceRange range{};
range.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
range.baseMipLevel = 0;
range.levelCount = 1;
range.baseArrayLayer = 0;
range.layerCount = 1;
vkCmdClearColorImage(commandBuffer, swapchainImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &clearColor, 1,
&range);
VkImageLayout settledLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
VkPipelineStageFlags dstStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
VkAccessFlags dstAccessMask = 0;
GetImageTransitionDestinationState(VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, dstStageMask, dstAccessMask);
if (!VkTextureManager::TransitionImageLayout(commandBuffer, swapchainImage, settledLayout,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
VK_PIPELINE_STAGE_TRANSFER_BIT, dstStageMask,
VK_ACCESS_TRANSFER_WRITE_BIT, dstAccessMask,
VK_IMAGE_ASPECT_COLOR_BIT)) {
return false;
}
m_swapchainObject.SetImageLayout(m_imageIndexAcquired, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL);
// Popped, not left behind: the clear has executed, so letting the next render pass load
// it again as a loadOp would erase whatever is drawn between here and there.
m_clearManager->PopPendingClear(attachment);
MGLOG_D("MaterializePendingClearForDefaultFramebuffer: swapchain image %u pending clear materialized",
m_imageIndexAcquired);
return true;
}
Bool VulkanRenderer::TryBlitToDefaultFramebufferWithShader(FrameContext::FrameData& frame, Bool VulkanRenderer::TryBlitToDefaultFramebufferWithShader(FrameContext::FrameData& frame,
MG_State::GLState::FramebufferObject& readFbo, MG_State::GLState::FramebufferObject& readFbo,
MG_State::GLState::FramebufferObject& drawFbo, MG_State::GLState::FramebufferObject& drawFbo,
@@ -7667,11 +7869,19 @@ void main() {
blitRegion.dstSubresource.layerCount = dstBinding.layerCount; blitRegion.dstSubresource.layerCount = dstBinding.layerCount;
blitRegion.dstOffsets[0] = {dstX0, dstY0, 0}; blitRegion.dstOffsets[0] = {dstX0, dstY0, 0};
blitRegion.dstOffsets[1] = {dstX1, dstY1, 1}; blitRegion.dstOffsets[1] = {dstX1, dstY1, 1};
if (readIsDefaultFbo) {
ApplyNativeBlitDefaultFramebufferSourceTransform(m_swapchainObject.GetPreTransform(), srcBinding,
blitRegion);
}
if (drawIsDefaultFbo) { if (drawIsDefaultFbo) {
ApplyNativeBlitDefaultFramebufferTransform(m_swapchainObject.GetPreTransform(), dstBinding, blitRegion); ApplyNativeBlitDefaultFramebufferTransform(m_swapchainObject.GetPreTransform(), dstBinding, blitRegion);
} }
if (srcBinding.sampleCount != VK_SAMPLE_COUNT_1_BIT && dstBinding.sampleCount == VK_SAMPLE_COUNT_1_BIT) { if (srcBinding.sampleCount != VK_SAMPLE_COUNT_1_BIT && dstBinding.sampleCount == VK_SAMPLE_COUNT_1_BIT) {
// NOTE: vkCmdResolveImage cannot flip, and this region is still built from the raw GL
// offsets. A multisample-resolve blit whose source or destination is the default
// framebuffer therefore keeps the pre-fix behaviour; it needs a resolve-then-blit
// (or blit-then-resolve) split, which is its own change.
// GL multisample resolve blits are 1:1 by spec; vkCmdBlitImage cannot read a // GL multisample resolve blits are 1:1 by spec; vkCmdBlitImage cannot read a
// multisampled source. // multisampled source.
VkImageResolve resolveRegion{}; VkImageResolve resolveRegion{};
@@ -7875,6 +8085,19 @@ void main() {
copyRegion.srcSubresource.mipLevel = srcBinding.mipLevel; copyRegion.srcSubresource.mipLevel = srcBinding.mipLevel;
copyRegion.srcSubresource.baseArrayLayer = srcBinding.baseArrayLayer; copyRegion.srcSubresource.baseArrayLayer = srcBinding.baseArrayLayer;
copyRegion.srcSubresource.layerCount = srcBinding.layerCount; copyRegion.srcSubresource.layerCount = srcBinding.layerCount;
// KNOWN GAP, deliberately not half-fixed here: when the read framebuffer is the default
// one this samples GL rows [y, y+h) counted from the TOP of a display-oriented image, so
// it takes the mirrored band AND writes it into the (GL-oriented) destination texture
// upside down. Correcting only the offset would swap one wrong answer for another,
// because vkCmdCopyImage cannot reverse rows: this path has to become a vkCmdBlitImage
// with an inverted source Y pair, the way BlitFramebuffer above now does it. Tracked
// separately; the four sites behind the 1,759-case orientation defect are the viewport,
// the scissor, the ReadPixels copy offset and the readback remap.
if (readIsDefaultFbo) {
MGLOG_I("DirectVulkan::CopyTexSubImage2D: copying from the DEFAULT framebuffer still uses the raw GL "
"Y origin (x=%d y=%d w=%d h=%d); the result is the mirrored band, stored flipped",
x, y, width, height);
}
copyRegion.srcOffset = {x, y, 0}; copyRegion.srcOffset = {x, y, 0};
copyRegion.dstSubresource.aspectMask = dstBinding.aspectMask; copyRegion.dstSubresource.aspectMask = dstBinding.aspectMask;
copyRegion.dstSubresource.mipLevel = dstBinding.mipLevel; copyRegion.dstSubresource.mipLevel = dstBinding.mipLevel;
@@ -8158,7 +8381,18 @@ void main() {
// rehash on that insertion, invalidating any RenderbufferResource*/TextureResource* // rehash on that insertion, invalidating any RenderbufferResource*/TextureResource*
// obtained beforehand - so ResolveColorBlitBinding's cached `trackedLayout` pointer // obtained beforehand - so ResolveColorBlitBinding's cached `trackedLayout` pointer
// must be taken AFTER this, never before it. // must be taken AFTER this, never before it.
if (!readIsDefaultFbo) { //
// The default framebuffer needs this just as much, and used to be excluded: its clear is
// parked the same way, and with no draw between the clear and the readback no render
// pass ever runs to fold it in, so the readback returned the previous frame's image
// (KHR-GL40.draw_indirect.negative-*). It only takes a different materializer because the
// image to clear is the acquired swapchain image, not the attachment's placeholder
// texture.
if (readIsDefaultFbo) {
const Bool clearReady = MaterializePendingClearForDefaultFramebuffer(frame.commandBuffer, *readFbo,
readFbo->GetReadBuffer());
MOBILEGL_ASSERT(clearReady, "ReadPixels: failed to materialize the default framebuffer's pending clear");
} else {
const auto& sourceAttachment = readFbo->GetAttachment(readFbo->GetReadBuffer()); const auto& sourceAttachment = readFbo->GetAttachment(readFbo->GetReadBuffer());
auto sourceTexture = sourceAttachment.GetTexture(); auto sourceTexture = sourceAttachment.GetTexture();
if (sourceTexture != nullptr) { if (sourceTexture != nullptr) {
@@ -8235,7 +8469,21 @@ void main() {
copyRegion.imageSubresource.mipLevel = srcBinding.mipLevel; copyRegion.imageSubresource.mipLevel = srcBinding.mipLevel;
copyRegion.imageSubresource.baseArrayLayer = srcBinding.baseArrayLayer; copyRegion.imageSubresource.baseArrayLayer = srcBinding.baseArrayLayer;
copyRegion.imageSubresource.layerCount = 1; copyRegion.imageSubresource.layerCount = 1;
copyRegion.imageOffset = {x, y, static_cast<Int32>(srcBinding.depthOffset)}; // The GL rect, aimed at the default framebuffer's stored orientation. Using the GL y
// verbatim copied rows [y, y+h) counted from the TOP of the image, i.e. the wrong band for
// every read that was not full-height.
Int32 copyOffsetX = x;
Int32 copyOffsetY = y;
if (readIsDefaultFbo) {
const VkExtent2D defaultFboExtent = m_swapchainObject.GetExtent();
const DefaultFramebufferRectMapping mapping =
GetDefaultFramebufferRectMapping(m_swapchainObject.GetPreTransform());
copyOffsetX = MapDefaultFramebufferRectAxis(x, width, static_cast<Int>(defaultFboExtent.width),
mapping.mirrorX);
copyOffsetY = MapDefaultFramebufferRectAxis(y, height, static_cast<Int>(defaultFboExtent.height),
mapping.flipY);
}
copyRegion.imageOffset = {copyOffsetX, copyOffsetY, static_cast<Int32>(srcBinding.depthOffset)};
copyRegion.imageExtent = {static_cast<Uint32>(width), static_cast<Uint32>(height), 1}; copyRegion.imageExtent = {static_cast<Uint32>(width), static_cast<Uint32>(height), 1};
vkCmdCopyImageToBuffer(frame.commandBuffer, srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, vkCmdCopyImageToBuffer(frame.commandBuffer, srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
readback.GetHandle(), 1, &copyRegion); readback.GetHandle(), 1, &copyRegion);
@@ -8273,23 +8521,23 @@ void main() {
return; return;
} }
if (readIsDefaultFbo) { if (readIsDefaultFbo) {
const VkExtent2D swapchainExtent = m_swapchainObject.GetExtent();
const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform(); const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform();
if (static_cast<Uint32>(width) == swapchainExtent.width && // No full-extent gate any more: the remap works on the copied rect, and the copy was
static_cast<Uint32>(height) == swapchainExtent.height) { // already aimed with the same mapping. The gate is exactly what made every partial
Vector<Uint8> remapped(static_cast<SizeT>(width) * static_cast<SizeT>(height) * sourceTexelSize); // read of the default framebuffer come back in Vulkan row order.
if (RemapDefaultFboReadbackToGLOrientation(mapped, swapchainExtent, preTransform, Vector<Uint8> remapped(static_cast<SizeT>(width) * static_cast<SizeT>(height) * sourceTexelSize);
sourceTexelSize, if (RemapDefaultFboReadbackToGLOrientation(mapped, static_cast<Uint32>(width),
remapped.data())) { static_cast<Uint32>(height), preTransform, sourceTexelSize,
PackReadbackToClientOrPbo(remapped.data(), srcFormat, width, height, 1, format, type, pixels, remapped.data())) {
/*applyPackImageParams=*/false, /*applyReadColorClamp=*/true); PackReadbackToClientOrPbo(remapped.data(), srcFormat, width, height, 1, format, type, pixels,
return; /*applyPackImageParams=*/false, /*applyReadColorClamp=*/true);
} return;
} }
MGLOG_W("DirectVulkan::ReadPixels: default-FBO remap skipped (w=%d h=%d swapchain=%ux%u preTransform=%d); " // Only a quarter-turn pre-transform reaches this, and nothing in this renderer models
"falling back to raw readback", // one. MGLOG_I because the INFO builds are the ones that run conformance.
width, height, swapchainExtent.width, swapchainExtent.height, MGLOG_I("DirectVulkan::ReadPixels: default-FBO remap declined (w=%d h=%d preTransform=%d); falling back "
static_cast<Int>(preTransform)); "to raw readback",
width, height, static_cast<Int>(preTransform));
} }
PackReadbackToClientOrPbo(mapped, srcFormat, width, height, 1, format, type, pixels, PackReadbackToClientOrPbo(mapped, srcFormat, width, height, 1, format, type, pixels,
/*applyPackImageParams=*/false, /*applyReadColorClamp=*/true); /*applyPackImageParams=*/false, /*applyReadColorClamp=*/true);
@@ -11882,6 +12130,12 @@ void main() {
static_cast<Uint32>(m_physicalDevice.queueFamilies.graphicsFamily), static_cast<Uint32>(m_physicalDevice.queueFamilies.graphicsFamily),
static_cast<Uint32>(m_physicalDevice.queueFamilies.presentFamily), static_cast<Uint32>(m_physicalDevice.queueFamilies.presentFamily),
m_config.MaxFramesInFlight, desiredExtent); m_config.MaxFramesInFlight, desiredExtent);
// The FragCoordYFlip variants bake this height in; it is the only input to a shader
// module that lives outside the GL program, so the factory has to learn it here (and on
// every recreation, which is the only way it can change).
if (m_programFactory) {
m_programFactory->SetDefaultFramebufferHeight(m_swapchainObject.GetExtent().height);
}
} }
void VulkanRenderer::CreateCommandPool() { void VulkanRenderer::CreateCommandPool() {
@@ -1118,6 +1118,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool MaterializePendingClearForRenderbuffer( Bool MaterializePendingClearForRenderbuffer(
VkCommandBuffer commandBuffer, VkCommandBuffer commandBuffer,
const SharedPtr<MG_State::GLState::RenderbufferObject>& renderbuffer); const SharedPtr<MG_State::GLState::RenderbufferObject>& renderbuffer);
// The default framebuffer's twin of the two above. It cannot go through
// MaterializePendingClearForTexture: the default FBO's colour attachment is a
// placeholder texture object, and syncing THAT would clear a texture image nobody
// presents instead of the acquired swapchain image.
Bool MaterializePendingClearForDefaultFramebuffer(VkCommandBuffer commandBuffer,
MG_State::GLState::FramebufferObject& fbo,
FramebufferAttachmentType attachmentType);
VkPipeline GetOrCreateBlitPipeline(const RenderPassEntry& renderPassEntry); VkPipeline GetOrCreateBlitPipeline(const RenderPassEntry& renderPassEntry);
Bool GenerateDepthMipmapWithShader(FrameContext::FrameData& frame, Bool GenerateDepthMipmapWithShader(FrameContext::FrameData& frame,
MG_State::GLState::ITextureObject& texture, MG_State::GLState::ITextureObject& texture,
+47 -10
View File
@@ -1491,8 +1491,8 @@ namespace MobileGL::MG_Impl::GLImpl {
// offset and size, which is also how glBindBuffersRange spells "reset this element" // offset and size, which is also how glBindBuffersRange spells "reset this element"
// (a NULL buffers array, or a zero entry inside one). // (a NULL buffers array, or a zero entry inside one).
static Bool ValidateBufferRangeOffsetAndSize(GLenum target, GLintptr offset, GLsizeiptr size, static Bool ValidateBufferRangeOffsetAndSize(GLenum target, GLintptr offset, GLsizeiptr size,
const char* funcName) { const char* funcName, Bool hasBuffer = true) {
if (size <= 0) { if (hasBuffer && size <= 0) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
@@ -1527,16 +1527,27 @@ namespace MobileGL::MG_Impl::GLImpl {
return false; return false;
} }
} }
// A transform feedback capture binding is addressed in 32-bit components, so BOTH the // GL 4.6 core 6.1.1 constrains the OFFSET to a multiple of four for both
// offset and the size must be multiples of 4. // TRANSFORM_FEEDBACK_BUFFER and ATOMIC_COUNTER_BUFFER (the atomic-counter one has no
if (target == GL_TRANSFORM_FEEDBACK_BUFFER && ((offset % 4) != 0 || (size % 4) != 0)) { // queryable alignment pname, which is why it was missing here), and the SIZE only for
// transform feedback, whose capture is written in whole 32-bit components. Extending the
// size rule to atomic counters as well breaks a legal bind: the conformance suite splits
// MAX_ATOMIC_COUNTER_BUFFER_SIZE evenly across the binding points and that quotient is
// not required to land on four.
if ((target == GL_TRANSFORM_FEEDBACK_BUFFER || target == GL_ATOMIC_COUNTER_BUFFER) && (offset % 4) != 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
std::format("offset ({}) must be a multiple of 4 for {}.", offset,
MG_Util::ConvertGLEnumToString(target))));
return false;
}
if (target == GL_TRANSFORM_FEEDBACK_BUFFER && hasBuffer && (size % 4) != 0) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue, ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>( MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", funcName, "MG_Impl/GLImpl", funcName,
std::format("offset ({}) and size ({}) must both be multiples of 4 for " std::format("size ({}) must be a multiple of 4 for GL_TRANSFORM_FEEDBACK_BUFFER.", size)));
"GL_TRANSFORM_FEEDBACK_BUFFER.",
offset, size)));
return false; return false;
} }
return true; return true;
@@ -1548,7 +1559,12 @@ namespace MobileGL::MG_Impl::GLImpl {
BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target); BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target);
if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return; if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return;
if (!BufferImpl::ValidateBufferBindingPointIndex(bufferTarget, index)) return; if (!BufferImpl::ValidateBufferBindingPointIndex(bufferTarget, index)) return;
if (buffer != 0 && !ValidateBufferRangeOffsetAndSize(target, offset, size, __func__)) return; // The target's alignment rules are a property of the BINDING POINT, not of the buffer,
// so they apply even when buffer is zero - which is exactly how
// KHR-GL43.shader_storage_buffer_object.negative-api-bind probes the SSBO alignment
// (glBindBufferRange(SHADER_STORAGE_BUFFER, 0, 0, alignment - 1, 0)). Only the size
// rules need a buffer, since buffer 0 detaches the binding point and ignores size.
if (!ValidateBufferRangeOffsetAndSize(target, offset, size, __func__, /*hasBuffer: */ buffer != 0)) return;
if (bufferTarget == BufferTarget::TransformFeedback && MG_State::pGLContext->IsTransformFeedbackActive()) { if (bufferTarget == BufferTarget::TransformFeedback && MG_State::pGLContext->IsTransformFeedbackActive()) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation, ErrorCode::InvalidOperation,
@@ -1732,10 +1748,30 @@ namespace MobileGL::MG_Impl::GLImpl {
return BufferImpl::ValidateBufferBindingPointRange(bufferTarget, first, count, funcName); return BufferImpl::ValidateBufferBindingPointRange(bufferTarget, first, count, funcName);
} }
// ARB_multi_bind states the equivalence to a loop of single binds "except that ... buffers
// will not be created if they do not exist": glBindBuffer instantiates a name glGenBuffers
// merely reserved, glBindBuffers* must refuse it and raise INVALID_OPERATION instead
// (KHR-GL44.multi_bind.errors_bind_buffers).
//
// Deliberately PER ELEMENT, not all-or-nothing: the equivalence the extension defines is a
// loop, so a bad entry costs its own binding point and nothing else. Rejecting the whole
// call instead cost multi_bind.functional_bind_buffers_base its bindings.
static Bool IsExistingBufferForMultiBind(GLuint buffer, GLsizei index, const char* funcName) {
if (buffer == 0 || MG_State::pGLContext->ValidateBufferObject(buffer)) return true;
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", funcName,
std::format("buffers[{}] ({}) is not the name of an existing buffer object.", index, buffer)));
return false;
}
void BindBuffersBase(GLenum target, GLuint first, GLsizei count, const GLuint* buffers) { void BindBuffersBase(GLenum target, GLuint first, GLsizei count, const GLuint* buffers) {
if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return; if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return;
for (GLsizei i = 0; i < count; ++i) { for (GLsizei i = 0; i < count; ++i) {
BindBufferBase_State(target, first + i, buffers ? buffers[i] : 0); const GLuint buffer = buffers ? buffers[i] : 0;
if (!IsExistingBufferForMultiBind(buffer, i, __func__)) continue;
BindBufferBase_State(target, first + i, buffer);
} }
} }
@@ -1749,6 +1785,7 @@ namespace MobileGL::MG_Impl::GLImpl {
const GLsizeiptr* sizes) { const GLsizeiptr* sizes) {
if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return; if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return;
for (GLsizei i = 0; i < count; ++i) { for (GLsizei i = 0; i < count; ++i) {
if (buffers && !IsExistingBufferForMultiBind(buffers[i], i, __func__)) continue;
if (!buffers || buffers[i] == 0) { if (!buffers || buffers[i] == 0) {
BindBufferBase_State(target, first + i, 0); BindBufferBase_State(target, first + i, 0);
} else { } else {
+96 -9
View File
@@ -493,15 +493,12 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
void DispatchComputeIndirect(GLintptr indirect) { void DispatchComputeIndirect(GLintptr indirect) {
auto dispatchComputeIndirect = MG_Backend::gBackendFunctionsTable.GL.DispatchComputeIndirect; // Argument and binding validation runs FIRST. Both are properties of the call and of GL
if (!dispatchComputeIndirect) { // state, so a context whose backend cannot dispatch at all must still report the
MG_State::pGLContext->RecordError( // argument error the spec names rather than masking every one of them with
ErrorCode::InvalidOperation, // "unsupported" - which is what put GL_INVALID_OPERATION where
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, // KHR-GL43.compute_shader.api-indirect expects GL_INVALID_VALUE.
"Backend does not support indirect compute dispatch.")); //
return;
}
if (!ValidateCurrentProgramForCompute(__func__)) return;
// GL 4.6 core 19: `indirect` is a byte offset into GL_DISPATCH_INDIRECT_BUFFER - // GL 4.6 core 19: `indirect` is a byte offset into GL_DISPATCH_INDIRECT_BUFFER -
// negative or misaligned is INVALID_VALUE, nothing bound is INVALID_OPERATION. // negative or misaligned is INVALID_VALUE, nothing bound is INVALID_OPERATION.
if (indirect < 0 || (indirect % 4) != 0) { if (indirect < 0 || (indirect % 4) != 0) {
@@ -520,6 +517,29 @@ namespace MobileGL::MG_Impl::GLImpl {
"No buffer is bound to GL_DISPATCH_INDIRECT_BUFFER.")); "No buffer is bound to GL_DISPATCH_INDIRECT_BUFFER."));
return; return;
} }
// ...and the same INVALID_OPERATION covers "the command would source data beyond the end
// of the bound buffer object" (GL 4.6 core 19): the dispatch reads three uints starting
// at `indirect`.
constexpr SizeT kDispatchIndirectCommandSize = 3 * sizeof(Uint32);
if (static_cast<SizeT>(indirect) + kDispatchIndirectCommandSize > indirectBuffer->GetSize()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", __func__,
std::format("indirect ({}) + 12 bytes runs past the end of the {}-byte buffer bound to "
"GL_DISPATCH_INDIRECT_BUFFER.",
indirect, indirectBuffer->GetSize())));
return;
}
auto dispatchComputeIndirect = MG_Backend::gBackendFunctionsTable.GL.DispatchComputeIndirect;
if (!dispatchComputeIndirect) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Backend does not support indirect compute dispatch."));
return;
}
if (!ValidateCurrentProgramForCompute(__func__)) return;
dispatchComputeIndirect(indirect); dispatchComputeIndirect(indirect);
} }
@@ -580,8 +600,69 @@ namespace MobileGL::MG_Impl::GLImpl {
MultiDrawArraysIndirect_Backend(mode, indirect, drawcount, stride); MultiDrawArraysIndirect_Backend(mode, indirect, drawcount, stride);
} }
// ARB_indirect_parameters / GL 4.6 core 10.4: `drawcount` is a byte offset into the buffer
// bound to PARAMETER_BUFFER and holds one uint draw count. Three errors have to be raised
// before the call reaches a backend, and none of them was
// (KHR-GL46.indirect_parameters_tests.MultiDraw{Arrays,Elements}IndirectCount):
// * drawcount not a multiple of four INVALID_VALUE
// * nothing bound to PARAMETER_BUFFER, or the uint at `drawcount`
// lies past its end INVALID_OPERATION
// * maxdrawcount commands from `indirect` run past the end of the
// buffer bound to DRAW_INDIRECT_BUFFER INVALID_OPERATION
static Bool ValidateIndirectCountDraw(GLintptr indirect, GLintptr drawcount, GLsizei maxdrawcount,
GLsizei stride, SizeT commandSize, const char* funcName) {
if (drawcount < 0 || (drawcount % 4) != 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
"drawcount must be non-negative and a multiple of four."));
return false;
}
const auto& parameterBuffer =
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject();
if (!parameterBuffer ||
static_cast<SizeT>(drawcount) + sizeof(Uint32) > parameterBuffer->GetSize()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
"No buffer is bound to GL_PARAMETER_BUFFER, or drawcount runs past "
"the end of the one that is."));
return false;
}
if (maxdrawcount < 0 || stride < 0 || indirect < 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
"indirect, maxdrawcount and stride must all be non-negative."));
return false;
}
const SizeT effectiveStride = stride != 0 ? static_cast<SizeT>(stride) : commandSize;
const auto& indirectBuffer =
MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject();
// A zero maxdrawcount sources nothing, so it cannot run past anything.
const SizeT requiredBytes =
maxdrawcount == 0 ? 0
: static_cast<SizeT>(indirect) +
static_cast<SizeT>(maxdrawcount - 1) * effectiveStride + commandSize;
if (!indirectBuffer || requiredBytes > indirectBuffer->GetSize()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", funcName,
"maxdrawcount commands would be sourced from beyond the end of the "
"buffer bound to GL_DRAW_INDIRECT_BUFFER."));
return false;
}
return true;
}
void MultiDrawElementsIndirectCount(GLenum mode, GLenum type, const void* indirect, GLintptr drawcount, void MultiDrawElementsIndirectCount(GLenum mode, GLenum type, const void* indirect, GLintptr drawcount,
GLsizei maxdrawcount, GLsizei stride) { GLsizei maxdrawcount, GLsizei stride) {
// Argument validation before the backend-availability check: see DispatchComputeIndirect.
// DrawElementsIndirectCommand: count, instanceCount, firstIndex, baseVertex, baseInstance.
if (!ValidateIndirectCountDraw(reinterpret_cast<GLintptr>(indirect), drawcount, maxdrawcount, stride,
5 * sizeof(Uint32), __func__)) {
return;
}
auto multiDrawElementsIndirectCount = MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsIndirectCount; auto multiDrawElementsIndirectCount = MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsIndirectCount;
if (!multiDrawElementsIndirectCount) { if (!multiDrawElementsIndirectCount) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
@@ -595,6 +676,12 @@ namespace MobileGL::MG_Impl::GLImpl {
void MultiDrawArraysIndirectCount(GLenum mode, const void* indirect, GLintptr drawcount, void MultiDrawArraysIndirectCount(GLenum mode, const void* indirect, GLintptr drawcount,
GLsizei maxdrawcount, GLsizei stride) { GLsizei maxdrawcount, GLsizei stride) {
// Argument validation before the backend-availability check: see DispatchComputeIndirect.
// DrawArraysIndirectCommand: count, instanceCount, first, baseInstance.
if (!ValidateIndirectCountDraw(reinterpret_cast<GLintptr>(indirect), drawcount, maxdrawcount, stride,
4 * sizeof(Uint32), __func__)) {
return;
}
auto multiDrawArraysIndirectCount = MG_Backend::gBackendFunctionsTable.GL.MultiDrawArraysIndirectCount; auto multiDrawArraysIndirectCount = MG_Backend::gBackendFunctionsTable.GL.MultiDrawArraysIndirectCount;
if (!multiDrawArraysIndirectCount) { if (!multiDrawArraysIndirectCount) {
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
+22 -7
View File
@@ -961,15 +961,30 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
} }
auto getInteger64i = MG_Backend::gBackendFunctionsTable.GL.GetInteger64i_v; // The one indexed pname whose value genuinely needs 64 bits: a vertex buffer binding
if (!getInteger64i) { // offset is an intptr, so taking the 32-bit route below would truncate it.
*data = 0; if (target == GL_VERTEX_BINDING_OFFSET) {
MG_State::pGLContext->RecordError( if (index >= VertexArrayImpl::GetMaxVertexAttribBindings()) {
ErrorCode::InvalidOperation, MG_State::pGLContext->RecordError(
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__, "Backend does not support indexed integer queries.")); ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
"Vertex buffer binding index is out of range."));
return;
}
const auto& vao = MG_State::pGLContext->GetBoundVertexArray();
*data = vao ? static_cast<GLint64>(vao->GetBindingPoint(index).Offset) : 0;
return; return;
} }
getInteger64i(target, index, data);
// Everything else is 32-bit indexed state that the glGetIntegeri_v pname table already
// owns, and GL 4.6 core 22.1 says every indexed query answers every indexed pname.
// Handing the leftovers straight to the backend instead made glGetInteger64i_v disagree
// with glGetIntegeri_v on the very same pname - GL_MAX_COMPUTE_WORK_GROUP_COUNT read
// back 0 while the 32-bit view said 65535 (KHR-GL43.compute_shader.max), because a
// frontend-only value simply is not in the driver's table.
GLint values[4] = {};
GetIntegeri_v(target, index, values);
*data = static_cast<GLint64>(values[0]);
} }
void GetInteger64v(GLenum pname, GLint64* params) { void GetInteger64v(GLenum pname, GLint64* params) {
@@ -2704,6 +2704,15 @@ namespace MobileGL::MG_Impl::GLImpl {
void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount, void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount,
const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) { const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) {
// Every early-out below reports "nothing was written", and it has to say so before it can
// take one: callers legitimately leave *length uninitialised and then loop to it. The CTS
// does exactly that (gl4cProgramInterfaceQueryTests.cpp:2172 declares `GLsizei length;` and
// walks `for (i = 0; i < length; ++i)` over a 1000-entry stack array), so an untouched
// *length turned every error path here into a stack overrun inside the caller -
// KHR-GL43.program_interface_query.subroutines-vertex read 0x20202020 entries and died on
// both backends. The success path overwrites this with the real count.
if (length) *length = 0;
auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__); auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__);
if (!programObject) return; if (!programObject) return;
if (!ProgramInterface::IsInterfaceEnum(programInterface)) { if (!ProgramInterface::IsInterfaceEnum(programInterface)) {
@@ -8,6 +8,7 @@
#include "GL_RenderState.h" #include "GL_RenderState.h"
#include <cmath> #include <cmath>
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <MG_State/GLState/Core.h> #include <MG_State/GLState/Core.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h> #include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/Converters/GLToMG/RenderStateEnumConverter.h> #include <MG_Util/Converters/GLToMG/RenderStateEnumConverter.h>
@@ -380,7 +381,18 @@ namespace MobileGL::MG_Impl::GLImpl {
return; return;
} }
*data = IsEnabledi_State(target, index); // GL 4.6 core 22.1: glGetBooleani_v answers EVERY indexed state, not just the indexed
// capabilities - a non-boolean value simply reads back as "is it non-zero". Routing the
// non-capability enums to the pname table glGetIntegeri_v already owns is what makes
// that true; without it a query like glGetBooleani_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, 0)
// came back GL_INVALID_ENUM (KHR-GL43.compute_shader.max).
if (MG_Util::ConvertGLEnumToCapabilityInput(target) != CapabilityInput::Unknown) {
*data = IsEnabledi_State(target, index);
return;
}
GLint values[4] = {};
GetIntegeri_v(target, index, values);
*data = values[0] != 0 ? GL_TRUE : GL_FALSE;
} }
GLboolean IsEnabled_State(GLenum cap) { GLboolean IsEnabled_State(GLenum cap) {
+15 -1
View File
@@ -336,8 +336,22 @@ namespace MobileGL::MG_Impl::GLImpl {
return; return;
} }
// ARB_multi_bind adds one rule the single-bind path does not have: "samplers will not be
// created if they do not exist", so a name that is not an existing sampler OBJECT is
// INVALID_OPERATION here (KHR-GL44.multi_bind.errors_bind_samplers). Per element, not
// all-or-nothing - the extension defines glBindSamplers as a loop, so a bad entry costs
// its own texture unit and leaves the rest of the range bound.
for (GLsizei i = 0; i < count; ++i) { for (GLsizei i = 0; i < count; ++i) {
BindSampler_State(first + i, samplers ? samplers[i] : 0); const GLuint sampler = samplers ? samplers[i] : 0;
if (sampler != 0 && !MG_State::pGLContext->ValidateSamplerObject(sampler)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "BindSamplers",
std::format("samplers[{}] ({}) is not the name of an existing sampler object.", i, sampler)));
continue;
}
BindSampler_State(first + i, sampler);
} }
} }
+76 -9
View File
@@ -613,6 +613,23 @@ namespace MobileGL::MG_Impl::GLImpl {
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller, MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", caller,
"Compressed texture formats are not supported.")); "Compressed texture formats are not supported."));
} }
// glGetTexLevelParameter{i,f}v answers WIDTH/HEIGHT/DEPTH out of the mipmap chain. The only
// other storage type the state layer knows is GL_TEXTURE_BUFFER (TextureStorageType is
// {Mipmap, Buffer}), whose level geometry this stack does not track yet. Report that instead
// of throwing: THROW_UNIMPL_EXCEPTION unwinds a C++ exception through the C GL ABI and takes
// the process down, which is never an acceptable answer to a query - see the same reasoning
// above for the compressed-format path.
void RecordUnsupportedLevelQueryStorage(const char* caller, GLenum pname) {
MGLOG_I("%s: glGetTexLevelParameter(pname=%s) is not implemented for texture-buffer "
"storage; recording GL_INVALID_OPERATION instead of terminating",
caller, MG_Util::ConvertGLEnumToString(pname).c_str());
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", caller,
"Level queries are not supported for texture-buffer storage."));
}
} // namespace } // namespace
const SharedPtr<MG_State::GLState::ITextureObject>& GetTextureObjectByName(GLuint texture, const char* caller) { const SharedPtr<MG_State::GLState::ITextureObject>& GetTextureObjectByName(GLuint texture, const char* caller) {
@@ -2910,7 +2927,8 @@ namespace MobileGL::MG_Impl::GLImpl {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; RecordUnsupportedLevelQueryStorage("GetTexLevelParameteriv_State", pname);
break;
} }
} }
break; break;
@@ -2924,7 +2942,8 @@ namespace MobileGL::MG_Impl::GLImpl {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; RecordUnsupportedLevelQueryStorage("GetTexLevelParameteriv_State", pname);
break;
} }
} }
break; break;
@@ -2938,7 +2957,8 @@ namespace MobileGL::MG_Impl::GLImpl {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; RecordUnsupportedLevelQueryStorage("GetTexLevelParameteriv_State", pname);
break;
} }
} }
break; break;
@@ -3045,7 +3065,8 @@ namespace MobileGL::MG_Impl::GLImpl {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; RecordUnsupportedLevelQueryStorage("GetTexLevelParameterfv_State", pname);
break;
} }
} }
break; break;
@@ -3059,7 +3080,8 @@ namespace MobileGL::MG_Impl::GLImpl {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; RecordUnsupportedLevelQueryStorage("GetTexLevelParameterfv_State", pname);
break;
} }
} }
break; break;
@@ -3073,7 +3095,8 @@ namespace MobileGL::MG_Impl::GLImpl {
break; break;
} }
default: default:
THROW_UNIMPL_EXCEPTION; RecordUnsupportedLevelQueryStorage("GetTexLevelParameterfv_State", pname);
break;
} }
} }
break; break;
@@ -3403,7 +3426,10 @@ namespace MobileGL::MG_Impl::GLImpl {
GET_SRC_INTERNAL_FORMAT(readBufferType); GET_SRC_INTERNAL_FORMAT(readBufferType);
} }
if (!TextureImpl::ValidateBaseInternalFormatMatch(internalFormat, srcInternalFormat)) THROW_UNIMPL_EXCEPTION; // The validator has already recorded GL_INVALID_OPERATION; just decline. Throwing
// here unwound a C++ exception through the C GL ABI and killed the process (see the
// same reasoning at :604-609).
if (!TextureImpl::ValidateCopyTexImageBaseFormatSubset(internalFormat, srcInternalFormat)) return false;
GLenum outInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(srcInternalFormat); GLenum outInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(srcInternalFormat);
GLenum realInternalFormat = GL_RGBA8; GLenum realInternalFormat = GL_RGBA8;
@@ -3426,8 +3452,13 @@ namespace MobileGL::MG_Impl::GLImpl {
void CopyTexImage1D_State(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width, void CopyTexImage1D_State(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width,
GLint border) { GLint border) {
// TODO: implement // 1D textures are not implemented by this backend set. Record the error the way every
THROW_UNIMPL_EXCEPTION; // other unsupported entry point does - throwing unwinds through the C GL ABI and kills
// the process, which is never an acceptable answer to an unsupported call.
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", "CopyTexImage1D",
"1D textures are not supported by this implementation"));
} }
void CompressedTexSubImage3D_State(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, void CompressedTexSubImage3D_State(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset,
@@ -4079,10 +4110,46 @@ namespace MobileGL::MG_Impl::GLImpl {
textureObject->SetImmutableLevels(static_cast<Uint>(levels)); textureObject->SetImmutableLevels(static_cast<Uint>(levels));
} }
// No block-compressed format is defined for a three-dimensional image, so glTexStorage3D on
// TEXTURE_3D must reject one - and with INVALID_OPERATION, not the INVALID_ENUM an unknown
// sized format gets (GL 4.6 core 8.19 / Khronos bug 11239, KHR-GLxx.texture_storage
// .compressed_data). Written against the enum ranges rather than a name list because the
// families are contiguous and MobileGL's own internal-format enum drops the ones it cannot
// carry, which would make this check silently narrower than the API surface.
static Bool IsCompressedGLInternalFormat(GLenum internalformat) {
switch (internalformat) {
case 0x8225: // GL_COMPRESSED_RED
case 0x8226: // GL_COMPRESSED_RG
case 0x84ED: // GL_COMPRESSED_RGB
case 0x84EE: // GL_COMPRESSED_RGBA
case 0x8C48: // GL_COMPRESSED_SRGB
case 0x8C49: // GL_COMPRESSED_SRGB_ALPHA
return true;
default:
break;
}
return (internalformat >= 0x83F0 && internalformat <= 0x83F3) || // S3TC / DXT
(internalformat >= 0x8DBB && internalformat <= 0x8DBE) || // RGTC
(internalformat >= 0x8E8C && internalformat <= 0x8E8F) || // BPTC
(internalformat >= 0x9270 && internalformat <= 0x9279) || // ETC2 / EAC
(internalformat >= 0x93B0 && internalformat <= 0x93BD) || // ASTC LDR
(internalformat >= 0x93D0 && internalformat <= 0x93DD); // ASTC sRGB
}
void TextureStorage3D(GLuint texture, GLsizei levels, GLenum internalformat, GLsizei width, GLsizei height, void TextureStorage3D(GLuint texture, GLsizei levels, GLenum internalformat, GLsizei width, GLsizei height,
GLsizei depth) { GLsizei depth) {
auto textureObject = GetTextureObjectByName(texture, __func__); auto textureObject = GetTextureObjectByName(texture, __func__);
if (!textureObject) return; if (!textureObject) return;
if (textureObject->GetTarget() == TextureTarget::Texture3D &&
IsCompressedGLInternalFormat(internalformat)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", __func__,
std::format("{} is a compressed internal format and cannot back GL_TEXTURE_3D storage.",
MG_Util::ConvertGLEnumToString(internalformat))));
return;
}
TextureInternalFormat textureInternalFormat = MG_Util::ConvertGLEnumToTextureInternalFormat(internalformat); TextureInternalFormat textureInternalFormat = MG_Util::ConvertGLEnumToTextureInternalFormat(internalformat);
if (!ValidateTextureStorageInternalFormat(textureInternalFormat, __func__)) return; if (!ValidateTextureStorageInternalFormat(textureInternalFormat, __func__)) return;
if (!ValidateTextureStorageShape(textureObject, 3, levels, width, height, depth, __func__)) return; if (!ValidateTextureStorageShape(textureObject, 3, levels, width, height, depth, __func__)) return;
+69 -7
View File
@@ -424,19 +424,81 @@ namespace MobileGL::MG_Impl::GLImpl::TextureImpl {
return true; return true;
} }
namespace {
// Component set of an UNSIZED base internal format, as the bitmask GL 4.6 SS 8.6
// reasons about. Colour components are independent bits so "subset" is a plain
// mask test; depth and stencil are their own components and never satisfy a
// colour request (or each other).
enum : Uint32 {
kComponentR = 1u << 0,
kComponentG = 1u << 1,
kComponentB = 1u << 2,
kComponentA = 1u << 3,
kComponentDepth = 1u << 4,
kComponentStencil = 1u << 5,
};
Uint32 BaseFormatComponents(TextureInternalFormat unsizedFormat) {
switch (unsizedFormat) {
case TextureInternalFormat::Red:
return kComponentR;
case TextureInternalFormat::RG:
return kComponentR | kComponentG;
case TextureInternalFormat::RGB:
return kComponentR | kComponentG | kComponentB;
case TextureInternalFormat::RGBA:
return kComponentR | kComponentG | kComponentB | kComponentA;
case TextureInternalFormat::DepthComponent:
return kComponentDepth;
case TextureInternalFormat::DepthStencil:
return kComponentDepth | kComponentStencil;
default:
return 0;
}
}
} // namespace
Bool ValidateBaseInternalFormatMatch(TextureInternalFormat format1, TextureInternalFormat format2) { Bool ValidateBaseInternalFormatMatch(TextureInternalFormat format1, TextureInternalFormat format2) {
auto unsizedFormat1 = MG_Util::ConvertInternalFormatToUnsized(format1); const auto unsizedFormat1 = MG_Util::ConvertInternalFormatToUnsized(format1);
auto unsizedFormat2 = MG_Util::ConvertInternalFormatToUnsized(format2); const auto unsizedFormat2 = MG_Util::ConvertInternalFormatToUnsized(format2);
if (unsizedFormat1 != unsizedFormat2) { if (unsizedFormat1 != unsizedFormat2) {
// The 3-argument GenericErrorInfo constructor used to be spelled as a single
// std::format() call whose format string was the component name, so every
// diagnostic collapsed to the literal "MG_Impl/GLImpl". Format the message, then
// hand over component/function/message separately.
MG_State::pGLContext->RecordError( MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation, ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>( MakeUnique<GenericErrorInfo>(
std::format("MG_Impl/GLImpl", "ValidateBaseInternalFormatMatch", "MG_Impl/GLImpl", "ValidateBaseInternalFormatMatch",
"The base internal format of the two formats do not match ({} vs. {})", std::format("The base internal format of the two formats do not match ({} vs. {})",
MG_Util::ConvertTextureInternalFormatToString(unsizedFormat1).c_str(), MG_Util::ConvertTextureInternalFormatToString(unsizedFormat1),
MG_Util::ConvertTextureInternalFormatToString(unsizedFormat2).c_str()))); MG_Util::ConvertTextureInternalFormatToString(unsizedFormat2))));
return false; return false;
} }
return true; return true;
} // namespace TextureImpl }
Bool ValidateCopyTexImageBaseFormatSubset(TextureInternalFormat destFormat, TextureInternalFormat srcFormat) {
const auto unsizedDest = MG_Util::ConvertInternalFormatToUnsized(destFormat);
const auto unsizedSrc = MG_Util::ConvertInternalFormatToUnsized(srcFormat);
// GL 4.6 SS 8.6: glCopyTexImage* may request a SUBSET of the read buffer's components,
// not an exact match - GL_RGB from an RGBA8 framebuffer is textbook legal and is what
// Minecraft and its mods do. glCopyTexImage2D used to run the exact-match predicate
// above and turn its rejection into an uncaught exception through the C GL ABI, so the
// app died rather than seeing a GL error.
const Uint32 destComponents = BaseFormatComponents(unsizedDest);
const Uint32 srcComponents = BaseFormatComponents(unsizedSrc);
if (destComponents == 0 || srcComponents == 0 || (destComponents & ~srcComponents) != 0) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", "ValidateCopyTexImageBaseFormatSubset",
std::format("the read buffer's base internal format {} does not provide every component of "
"the requested internal format {}",
MG_Util::ConvertTextureInternalFormatToString(unsizedSrc),
MG_Util::ConvertTextureInternalFormatToString(unsizedDest))));
return false;
}
return true;
}
} // namespace MobileGL::MG_Impl::GLImpl::TextureImpl } // namespace MobileGL::MG_Impl::GLImpl::TextureImpl
@@ -40,5 +40,9 @@ namespace MobileGL::MG_Impl::GLImpl::TextureImpl {
TextureTarget target); TextureTarget target);
Bool ValidateTextureSubImageOffsets(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject, Int xoffset, Bool ValidateTextureSubImageOffsets(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject, Int xoffset,
Int width, Int yoffset = 0, Int height = 0, Int zoffset = 0, Int depth = 0); Int width, Int yoffset = 0, Int height = 0, Int zoffset = 0, Int depth = 0);
// Exact base-format equality - what glCopyImageSubData's format compatibility needs.
Bool ValidateBaseInternalFormatMatch(TextureInternalFormat format1, TextureInternalFormat format2); Bool ValidateBaseInternalFormatMatch(TextureInternalFormat format1, TextureInternalFormat format2);
// GL 4.6 SS 8.6 subset rule for glCopyTexImage*: the read buffer must supply every component
// the requested internalformat asks for, but may supply more.
Bool ValidateCopyTexImageBaseFormatSubset(TextureInternalFormat destFormat, TextureInternalFormat srcFormat);
} // namespace MobileGL::MG_Impl::GLImpl::TextureImpl } // namespace MobileGL::MG_Impl::GLImpl::TextureImpl
@@ -179,6 +179,28 @@ namespace MobileGL::MG_Impl::GLImpl {
return vao; return vao;
} }
// The ARB_vertex_attrib_binding entry points that take no vertex array name modify the
// *bound* vertex array, and in a core profile the default vertex array (name 0) is not
// one: every one of them is INVALID_OPERATION there (GL 4.6 core 10.3.1, and the tail of
// each KHR-GL4x.vertex_attrib_binding.negative-* case checks exactly this). MobileGL
// keeps a real object at name 0 for the compatibility paths, so GetBoundVertexArray
// never returns null and the rule has to be spelled out - behind the same gate the VAO-0
// draw rule already uses (MOBILEGL_RELAXED_SEMANTICS, plus "the context never asked for
// a core profile"), so applications that legitimately run relaxed keep working.
static SharedPtr<MG_State::GLState::VertexArrayObject> GetBoundVertexArrayForBindingApi(const char* funcName) {
auto vao = GetBoundVertexArrayOrError(funcName);
if (!vao) return nullptr;
if (vao->GetExternalIndex() == 0 && !MG_State::IsRelaxedSemanticsActive()) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>(
"MG_Impl/GLImpl", funcName,
"The default vertex array object cannot be modified in a core profile."));
return nullptr;
}
return vao;
}
static bool ValidateVertexAttribPname(GLenum pname) { static bool ValidateVertexAttribPname(GLenum pname) {
switch (pname) { switch (pname) {
case GL_VERTEX_ATTRIB_ARRAY_ENABLED: case GL_VERTEX_ATTRIB_ARRAY_ENABLED:
@@ -944,7 +966,7 @@ namespace MobileGL::MG_Impl::GLImpl {
params[0] = static_cast<GLfloat>(attr->Size); params[0] = static_cast<GLfloat>(attr->Size);
return; return;
case GL_VERTEX_ATTRIB_ARRAY_STRIDE: case GL_VERTEX_ATTRIB_ARRAY_STRIDE:
params[0] = static_cast<GLfloat>(attr->Stride); params[0] = static_cast<GLfloat>(attr->LegacyStride);
return; return;
case GL_VERTEX_ATTRIB_ARRAY_TYPE: case GL_VERTEX_ATTRIB_ARRAY_TYPE:
params[0] = static_cast<GLfloat>(MG_Util::ConvertDataTypeToGLEnum(attr->Type)); params[0] = static_cast<GLfloat>(MG_Util::ConvertDataTypeToGLEnum(attr->Type));
@@ -1014,7 +1036,7 @@ namespace MobileGL::MG_Impl::GLImpl {
params[0] = static_cast<GLdouble>(attr->Size); params[0] = static_cast<GLdouble>(attr->Size);
return; return;
case GL_VERTEX_ATTRIB_ARRAY_STRIDE: case GL_VERTEX_ATTRIB_ARRAY_STRIDE:
params[0] = static_cast<GLdouble>(attr->Stride); params[0] = static_cast<GLdouble>(attr->LegacyStride);
return; return;
case GL_VERTEX_ATTRIB_ARRAY_TYPE: case GL_VERTEX_ATTRIB_ARRAY_TYPE:
params[0] = static_cast<GLdouble>(MG_Util::ConvertDataTypeToGLEnum(attr->Type)); params[0] = static_cast<GLdouble>(MG_Util::ConvertDataTypeToGLEnum(attr->Type));
@@ -1079,8 +1101,11 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_VERTEX_ATTRIB_ARRAY_SIZE: case GL_VERTEX_ATTRIB_ARRAY_SIZE:
params[0] = attr->Size; params[0] = attr->Size;
return; return;
// The legacy shadow, not the resolved draw stride: GL 4.6 core table 23.3 defines this
// as the last glVertexAttrib*Pointer argument, which glBindVertexBuffer must not
// overwrite even though it does overwrite what the backend actually reads.
case GL_VERTEX_ATTRIB_ARRAY_STRIDE: case GL_VERTEX_ATTRIB_ARRAY_STRIDE:
params[0] = attr->Stride; params[0] = attr->LegacyStride;
return; return;
case GL_VERTEX_ATTRIB_ARRAY_TYPE: case GL_VERTEX_ATTRIB_ARRAY_TYPE:
params[0] = static_cast<GLint>(MG_Util::ConvertDataTypeToGLEnum(attr->Type)); params[0] = static_cast<GLint>(MG_Util::ConvertDataTypeToGLEnum(attr->Type));
@@ -1138,7 +1163,7 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
const auto& attr = vao->GetAttribute(index); const auto& attr = vao->GetAttribute(index);
*pointer = reinterpret_cast<void*>(attr.Offset); *pointer = reinterpret_cast<void*>(attr.LegacyPointer);
} }
void GetVertexAttribIiv(GLuint index, GLenum pname, GLint* params) { void GetVertexAttribIiv(GLuint index, GLenum pname, GLint* params) {
@@ -1222,7 +1247,7 @@ namespace MobileGL::MG_Impl::GLImpl {
*param = static_cast<GLint>(attr.Size); *param = static_cast<GLint>(attr.Size);
return; return;
case GL_VERTEX_ATTRIB_ARRAY_STRIDE: case GL_VERTEX_ATTRIB_ARRAY_STRIDE:
*param = static_cast<GLint>(attr.Stride); *param = static_cast<GLint>(attr.LegacyStride);
return; return;
case GL_VERTEX_ATTRIB_ARRAY_TYPE: case GL_VERTEX_ATTRIB_ARRAY_TYPE:
*param = static_cast<GLint>(MG_Util::ConvertDataTypeToGLEnum(attr.Type)); *param = static_cast<GLint>(MG_Util::ConvertDataTypeToGLEnum(attr.Type));
@@ -1294,14 +1319,14 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
void BindVertexBuffer(GLuint bindingindex, GLuint buffer, GLintptr offset, GLsizei stride) { void BindVertexBuffer(GLuint bindingindex, GLuint buffer, GLintptr offset, GLsizei stride) {
auto vao = GetBoundVertexArrayOrError("BindVertexBuffer"); auto vao = GetBoundVertexArrayForBindingApi("BindVertexBuffer");
if (!vao) return; if (!vao) return;
VertexBufferBinding_State(vao, bindingindex, buffer, offset, stride, "BindVertexBuffer"); VertexBufferBinding_State(vao, bindingindex, buffer, offset, stride, "BindVertexBuffer");
} }
void BindVertexBuffers(GLuint first, GLsizei count, const GLuint* buffers, const GLintptr* offsets, void BindVertexBuffers(GLuint first, GLsizei count, const GLuint* buffers, const GLintptr* offsets,
const GLsizei* strides) { const GLsizei* strides) {
auto vao = GetBoundVertexArrayOrError("BindVertexBuffers"); auto vao = GetBoundVertexArrayForBindingApi("BindVertexBuffers");
if (!vao) return; if (!vao) return;
if (!ValidateVertexBindingRange(first, count, "BindVertexBuffers")) return; if (!ValidateVertexBindingRange(first, count, "BindVertexBuffers")) return;
for (GLsizei i = 0; i < count; ++i) { for (GLsizei i = 0; i < count; ++i) {
@@ -1315,21 +1340,21 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
void VertexAttribFormat(GLuint attribindex, GLint size, GLenum type, GLboolean normalized, GLuint relativeoffset) { void VertexAttribFormat(GLuint attribindex, GLint size, GLenum type, GLboolean normalized, GLuint relativeoffset) {
auto vao = GetBoundVertexArrayOrError("VertexAttribFormat"); auto vao = GetBoundVertexArrayForBindingApi("VertexAttribFormat");
if (!vao) return; if (!vao) return;
VertexAttribFormatSeparate_State(vao, attribindex, size, type, normalized, relativeoffset, false, VertexAttribFormatSeparate_State(vao, attribindex, size, type, normalized, relativeoffset, false,
"VertexAttribFormat"); "VertexAttribFormat");
} }
void VertexAttribIFormat(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset) { void VertexAttribIFormat(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset) {
auto vao = GetBoundVertexArrayOrError("VertexAttribIFormat"); auto vao = GetBoundVertexArrayForBindingApi("VertexAttribIFormat");
if (!vao) return; if (!vao) return;
VertexAttribFormatSeparate_State(vao, attribindex, size, type, GL_FALSE, relativeoffset, true, VertexAttribFormatSeparate_State(vao, attribindex, size, type, GL_FALSE, relativeoffset, true,
"VertexAttribIFormat"); "VertexAttribIFormat");
} }
void VertexAttribLFormat(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset) { void VertexAttribLFormat(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset) {
auto vao = GetBoundVertexArrayOrError("VertexAttribLFormat"); auto vao = GetBoundVertexArrayForBindingApi("VertexAttribLFormat");
if (!vao) return; if (!vao) return;
VertexAttribLFormatSeparate_State(vao, attribindex, size, type, relativeoffset); VertexAttribLFormatSeparate_State(vao, attribindex, size, type, relativeoffset);
} }
@@ -1341,7 +1366,7 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
void VertexAttribBinding(GLuint attribindex, GLuint bindingindex) { void VertexAttribBinding(GLuint attribindex, GLuint bindingindex) {
auto vao = GetBoundVertexArrayOrError("VertexAttribBinding"); auto vao = GetBoundVertexArrayForBindingApi("VertexAttribBinding");
if (!vao) return; if (!vao) return;
if (!VertexArrayImpl::ValidateVertexAttributeIndex(attribindex)) return; if (!VertexArrayImpl::ValidateVertexAttributeIndex(attribindex)) return;
if (!ValidateVertexBindingIndex(bindingindex, "VertexAttribBinding")) return; if (!ValidateVertexBindingIndex(bindingindex, "VertexAttribBinding")) return;
@@ -1349,7 +1374,7 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
void VertexBindingDivisor(GLuint bindingindex, GLuint divisor) { void VertexBindingDivisor(GLuint bindingindex, GLuint divisor) {
auto vao = GetBoundVertexArrayOrError("VertexBindingDivisor"); auto vao = GetBoundVertexArrayForBindingApi("VertexBindingDivisor");
if (!vao) return; if (!vao) return;
if (!ValidateVertexBindingIndex(bindingindex, "VertexBindingDivisor")) return; if (!ValidateVertexBindingIndex(bindingindex, "VertexBindingDivisor")) return;
vao->SetBindingDivisor(bindingindex, divisor); vao->SetBindingDivisor(bindingindex, divisor);
@@ -53,6 +53,11 @@ add_executable(MobileGLIntegrationTest
Scenarios/AsyncCompileScenario.cpp Scenarios/AsyncCompileScenario.cpp
Scenarios/XfbAfterClipDistanceScenario.cpp Scenarios/XfbAfterClipDistanceScenario.cpp
Scenarios/ThreeChannelAttachmentScenario.cpp Scenarios/ThreeChannelAttachmentScenario.cpp
Scenarios/PipelineFailureScenario.cpp
Scenarios/AdvertisedLimitsScenario.cpp
Scenarios/PixelStoreSweepScenario.cpp
Scenarios/FragCoordOriginScenario.cpp
Scenarios/ClearThenReadPixelsScenario.cpp
) )
target_include_directories(MobileGLIntegrationTest PRIVATE target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -601,9 +601,13 @@ namespace MGITest {
} }
Image ReadPixels(int width, int height) { Image ReadPixels(int width, int height) {
return ReadPixelsRect(0, 0, width, height);
}
Image ReadPixelsRect(int x, int y, int width, int height) {
Image image(width, height); Image image(width, height);
glPixelStorei(GL_PACK_ALIGNMENT, 1); glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.Data()); glReadPixels(x, y, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.Data());
return image; return image;
} }
@@ -184,11 +184,18 @@ namespace MGITest {
void ClearTo(float r, float g, float b, float a); void ClearTo(float r, float g, float b, float a);
// Reads back the whole currently bound READ framebuffer. width/height must // Reads back the whole currently bound READ framebuffer.
// be the target's full size - DirectVulkan's default-framebuffer readback
// only re-orients a full-extent read.
Image ReadPixels(int width, int height); Image ReadPixels(int width, int height);
// A PARTIAL glReadPixels. Row 0 of the returned image is GL row `y` of the
// framebuffer, i.e. the bottom row of the requested rect - the same
// convention ReadPixels uses, just with an origin. This is the shape the
// conformance suite reads in (a random sub-rect of the default
// framebuffer), and the shape DirectVulkan's default-FBO readback used to
// hand back in Vulkan row order because its re-orientation only ran on an
// exact full-extent read.
Image ReadPixelsRect(int x, int y, int width, int height);
// Drains any GL error queue and returns the first error, or 0. // Drains any GL error queue and returns the first error, or 0.
unsigned int FirstGLError(); unsigned int FirstGLError();
const char* GLErrorName(unsigned int error); const char* GLErrorName(unsigned int error);
@@ -0,0 +1,120 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// "The limit we advertise is a promise, and an application will hold us to it."
//
// DirectVulkan copied Vulkan descriptor limits straight into the GL limit table. Those are not
// the same quantity: Adreno answers maxPerStageDescriptorUniformBuffers at descriptor-indexing
// scale, and GL_MAX_COMPUTE_UNIFORM_BLOCKS is a count an app will allocate. KHR-GL44.multi_bind
// .dispatch_bind_buffers_base does exactly that - createsO(limit) buffers and splices O(limit)
// UBO declarations into one compute shader - and spent ~14 s allocating before dying on
// std::bad_alloc. Its sibling dispatch_bind_buffers_range hard-codes 4 buffers and passes.
//
// Two failure modes, one table:
// - too LARGE: an unusable promise (the OOM above).
// - too SMALL or negative: a uint32 limit that lost its top bit on the way to a signed Int -
// UINT32_MAX arrived as -1, which every downstream std::min then accepted as "small enough".
// A conformant GL 4.x implementation may never advertise below the spec minimum either.
//
// Every bound below is checked on BOTH backends, because the loader casts are shared and the
// DirectGLES lane is the control: it takes its limits from a driver that already reports GL
// quantities, so an entry that only fails on DirectVulkan is a translation bug and one that
// fails on both is a table bug.
#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 {
struct LimitBound {
GLenum pname;
const char* name;
// The GL 4.x required minimum. A value below this is a conformance failure in its own
// right, and is what a sign-flipped uint32 looks like.
int minimum;
// The largest value this implementation is willing to promise. Chosen well above every
// desktop driver's answer, so it can only catch a descriptor-scale number.
int ceiling;
};
const std::vector<LimitBound>& BufferLimitTable() {
static const std::vector<LimitBound> table = {
{GL_MAX_UNIFORM_BUFFER_BINDINGS, "GL_MAX_UNIFORM_BUFFER_BINDINGS", 36, 256},
{GL_MAX_COMPUTE_UNIFORM_BLOCKS, "GL_MAX_COMPUTE_UNIFORM_BLOCKS", 12, 256},
{GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, "GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS", 8, 256},
{GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS, "GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS", 8, 256},
{GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS, "GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS", 8, 256},
{GL_MAX_TEXTURE_BUFFER_SIZE, "GL_MAX_TEXTURE_BUFFER_SIZE", 65536, 1 << 27},
{GL_MAX_UNIFORM_BLOCK_SIZE, "GL_MAX_UNIFORM_BLOCK_SIZE", 16384, 1 << 30},
// Already clamped before this campaign; in the table so a regression there is
// caught by the same case.
{GL_MAX_SHADER_STORAGE_BLOCK_SIZE, "GL_MAX_SHADER_STORAGE_BLOCK_SIZE", 1 << 24, 512 * 1024 * 1024},
{GL_MAX_TEXTURE_IMAGE_UNITS, "GL_MAX_TEXTURE_IMAGE_UNITS", 16, 32},
{GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, "GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS", 48, 192},
};
return table;
}
class AdvertisedLimitsScenario : public ScenarioTest {};
TEST_F(AdvertisedLimitsScenario, EveryBufferLimitIsWithinItsAdvertisedRange) {
for (const LimitBound& bound : BufferLimitTable()) {
GLint value = -424242;
glGetIntegerv(bound.pname, &value);
const unsigned int error = FirstGLError();
EXPECT_EQ(error, GLenum(GL_NO_ERROR))
<< bound.name << " is not answerable: " << GLErrorName(error);
if (error != GL_NO_ERROR) continue;
EXPECT_GE(value, bound.minimum)
<< bound.name << " = " << value << " is below the GL required minimum "
<< bound.minimum << " (a negative or tiny value here is a uint32 limit that lost "
"its top bit on the way to a signed Int)";
EXPECT_LE(value, bound.ceiling)
<< bound.name << " = " << value << " exceeds the ceiling " << bound.ceiling
<< " this implementation is willing to promise - an application that allocates "
"what we advertise will run out of memory";
}
}
// The OOM case in isolation, because it is the one with a known CTS victim and the one a
// future refactor is most likely to reintroduce by copying the Vulkan limit back.
TEST_F(AdvertisedLimitsScenario, ComputeUniformBlocksIsAnAmountAnApplicationCouldActuallyAllocate) {
GLint blocks = -1;
glGetIntegerv(GL_MAX_COMPUTE_UNIFORM_BLOCKS, &blocks);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_GE(blocks, 12);
EXPECT_LE(blocks, 256) << "KHR-GL44.multi_bind.dispatch_bind_buffers_base creates one GL buffer "
"and one UBO declaration per advertised block";
GLint blockSize = -1;
glGetIntegerv(GL_MAX_UNIFORM_BLOCK_SIZE, &blockSize);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_GT(blockSize, 0);
// GL_MAX_COMBINED_COMPUTE_UNIFORM_COMPONENTS is derived from the product of these two,
// so their product has to stay representable.
EXPECT_LE(static_cast<long long>(blocks) * blockSize,
static_cast<long long>(2147483647))
<< "blocks(" << blocks << ") * blockSize(" << blockSize << ") overflows the GLint the "
"derived component limits are computed in";
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,183 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ClearThenReadPixelsScenario.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 CLEAR OF THE DEFAULT FRAMEBUFFER IS VISIBLE TO glReadPixels WITH NO DRAW BETWEEN.
//
// DirectVulkan parks a glClear as a pending clear and folds it into the next render pass's
// loadOp. When nothing is drawn after the clear there is no render pass, and the readback path
// used to materialize pending clears only for USER framebuffers - so a readback right after a
// clear of the DEFAULT framebuffer blitted the untouched swapchain image and handed back the
// previous frame's colour.
//
// That is the whole of KHR-GL40.draw_indirect.negative-* (12 Magma failures): each case clears,
// issues a draw that correctly raises INVALID_OPERATION and therefore never executes, then reads
// the frame back expecting (0,0,0,0) and gets the previous case's (0.1,0.2,0.3,1). The staleness
// cannot appear in one frame, so the scenario paints a frame first and clears in the next.
//
// The alpha assertion is the second half of the same census finding: a cleared default
// framebuffer read back (0,0,0,1) where (0,0,0,0) was written, because the clear was routed
// through the default FBO's placeholder attachment, whose format can lack alpha, rather than
// through the swapchain image that actually has one.
//
// DirectGLES is the built-in control: a native GL driver has no deferred-clear model at all, so
// a failure there would mean the scenario, not the backend.
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr const char* kVS = R"(#version 330 core
in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
)";
// The colour KHR-GL40.draw_indirect's fshSimple paints, so a stale readback shows up as
// the same value the conformance log reports.
constexpr const char* kFS = R"(#version 330 core
out vec4 o_color;
void main() { o_color = vec4(0.1, 0.2, 0.3, 1.0); }
)";
class ClearThenReadPixelsScenario : public ScenarioTest {};
void DrawFullViewportQuad(unsigned int program) {
static const float kQuad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
GLuint vao = 0, vbo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
glUseProgram(program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
}
} // namespace
TEST_F(ClearThenReadPixelsScenario, ClearWithNoDrawIsVisibleToDefaultFramebufferReadPixels) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(width, 8);
ASSERT_GE(height, 8);
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
// Frame 1: paint the whole default framebuffer, so there IS something stale to return.
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(1.0f, 1.0f, 1.0f, 1.0f);
DrawFullViewportQuad(program);
{
const Image painted = ReadPixels(width, height);
const Rgba8 centre = painted.At(width / 2, height / 2);
ASSERT_NEAR(centre.r, 26, 2) << "the setup frame did not paint; the staleness test would be vacuous";
ASSERT_NEAR(centre.g, 51, 2);
ASSERT_NEAR(centre.b, 77, 2);
}
gl.EndFrame();
// Frame 2: clear to transparent black and read back with NO draw at all.
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
ClearTo(0.0f, 0.0f, 0.0f, 0.0f);
const Image cleared = ReadPixels(width, height);
EXPECT_EQ(FirstGLError(), 0u);
int nonZero = 0;
int firstX = -1;
int firstY = -1;
Rgba8 firstOffender{};
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
const Rgba8 pixel = cleared.At(x, y);
if (pixel.r == 0 && pixel.g == 0 && pixel.b == 0 && pixel.a == 0) continue;
if (nonZero == 0) {
firstX = x;
firstY = y;
firstOffender = pixel;
}
++nonZero;
}
}
EXPECT_EQ(nonZero, 0) << "glClear(0,0,0,0) followed by glReadPixels with no draw returned " << nonZero
<< " of " << (width * height) << " non-zero pixels; first at (" << firstX << ", "
<< firstY << ") = (" << static_cast<int>(firstOffender.r) << ", "
<< static_cast<int>(firstOffender.g) << ", " << static_cast<int>(firstOffender.b)
<< ", " << static_cast<int>(firstOffender.a) << ")";
gl.EndFrame();
glDeleteProgram(program);
}
// The same claim for a sub-rect read, which is the shape the conformance suite uses most and
// the one whose orientation handling is separate (see OrientationScenario).
TEST_F(ClearThenReadPixelsScenario, ClearWithNoDrawIsVisibleToASubRectReadback) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(width, 8);
ASSERT_GE(height, 8);
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
DrawFullViewportQuad(program);
gl.EndFrame();
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
ClearTo(0.0f, 0.0f, 0.0f, 0.0f);
const int rectWidth = width / 2;
const int rectHeight = height / 2;
const Image cleared = ReadPixelsRect(width / 4, height / 4, rectWidth, rectHeight);
EXPECT_EQ(FirstGLError(), 0u);
int nonZero = 0;
for (int y = 0; y < rectHeight; ++y) {
for (int x = 0; x < rectWidth; ++x) {
const Rgba8 pixel = cleared.At(x, y);
if (pixel.r != 0 || pixel.g != 0 || pixel.b != 0 || pixel.a != 0) ++nonZero;
}
}
EXPECT_EQ(nonZero, 0) << nonZero << " of " << (rectWidth * rectHeight)
<< " pixels in a sub-rect read after a draw-free clear were not zero";
gl.EndFrame();
glDeleteProgram(program);
}
} // namespace MGITest
@@ -0,0 +1,139 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/FragCoordOriginScenario.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 - gl_FragCoord ON THE DEFAULT FRAMEBUFFER CARRIES GL'S WINDOW ORIGIN.
//
// GL measures gl_FragCoord.y from the BOTTOM of the window. Vulkan's gl_FragCoord.y is the
// framebuffer ROW being written, and DirectVulkan stores the default framebuffer display-side-up
// (compensating for vertices by negating gl_Position.y), so a fragment's reported Y there was
// `height - y_GL` - flipped, and for a viewport that does not span the full height, outside the
// range GL promises entirely. GL CTS
// `KHR-GL42.shader_image_load_store.basic-{allTargets-atomic,glsl-earlyFragTests,glsl-misc}`
// caught it: each sets a small viewport at GL y=0 and does
// `imageStore(image, ivec2(gl_FragCoord.xy), ...)` into an image exactly that size, so on a
// 256-tall surface every store addressed rows 224..255 of a 32-row image and was dropped.
//
// The shader here paints each row with its own GL window Y, which is the whole claim in one
// value: row j of the readback must be j, for a full-height viewport and for a half-height one
// (the case where a flip and an offset can no longer hide each other). DirectGLES is the
// built-in control - a native GL driver gets this right by construction, so a failure there
// would mean the test, not the backend.
#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* kVS = R"(#version 330 core
in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
)";
// floor(gl_FragCoord.y) is the fragment's window row; 1/255 steps survive an RGBA8
// round trip exactly, so the readback byte IS the row the shader believes it is on.
constexpr const char* kFS = R"(#version 330 core
out vec4 o_color;
void main() { o_color = vec4(floor(gl_FragCoord.y) / 255.0, 0.0, 0.0, 1.0); }
)";
class FragCoordOriginScenario : public ScenarioTest {};
// A quad covering the whole viewport, drawn with attribute 0 = aPos.
void DrawFullViewportQuad(unsigned int program) {
static const float kQuad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
GLuint vao = 0, vbo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
glUseProgram(program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
}
// Paints `viewportHeight` rows starting at GL y=0 and returns the red byte of each row.
std::vector<int> RowsPaintedWithTheirOwnWindowY(unsigned int program, int width, int viewportHeight) {
BindDefaultFramebuffer();
glViewport(0, 0, width, viewportHeight);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
DrawFullViewportQuad(program);
const Image image = ReadPixelsRect(0, 0, width, viewportHeight);
std::vector<int> rows;
rows.reserve(static_cast<std::size_t>(viewportHeight));
for (int y = 0; y < viewportHeight; ++y) {
rows.push_back(image.At(width / 2, y).r);
}
return rows;
}
::testing::AssertionResult RowsAreTheirOwnIndex(const std::vector<int>& rows, const char* when) {
for (std::size_t y = 0; y < rows.size(); ++y) {
if (rows[y] != static_cast<int>(y)) {
return ::testing::AssertionFailure()
<< when << ": GL window row " << y << " reported gl_FragCoord.y = " << rows[y]
<< " (expected " << y << "). Rows 0.." << (rows.size() - 1) << " read back as ["
<< rows.front() << " .. " << rows.back() << "].";
}
}
return ::testing::AssertionSuccess();
}
} // namespace
TEST_F(FragCoordOriginScenario, DefaultFramebufferFragCoordCountsFromTheBottom) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
// 1/255 steps only stay distinguishable while the row index fits in a byte.
const int width = gl.Width();
const int fullHeight = std::min(gl.Height(), 256);
ASSERT_GE(fullHeight, 8) << "the harness surface is too small to tell rows apart";
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
// Full height first: this one passed even before the fix (a flip alone maps the row set
// onto itself), so it is the control that the shader and the readback agree at all.
EXPECT_TRUE(RowsAreTheirOwnIndex(RowsPaintedWithTheirOwnWindowY(program, width, fullHeight),
"full-height viewport"));
// Half height at GL y=0: the case the CTS failures were made of. A backend that reports
// the stored row here answers `height - y` for every row - off the bottom of the range,
// not merely reversed within it.
const int halfHeight = fullHeight / 2;
EXPECT_TRUE(RowsAreTheirOwnIndex(RowsPaintedWithTheirOwnWindowY(program, width, halfHeight),
"half-height viewport at GL y=0"));
glUseProgram(0);
glDeleteProgram(program);
glViewport(0, 0, gl.Width(), gl.Height());
EXPECT_EQ(FirstGLError(), 0u);
}
} // namespace MGITest
@@ -55,6 +55,7 @@
#include <algorithm> #include <algorithm>
#include <cstdint> #include <cstdint>
#include <cstring>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -101,6 +102,39 @@ void main() {
// "every single pixel" an achievable (and therefore useful) demand. // "every single pixel" an achievable (and therefore useful) demand.
constexpr int kQuadrantInset = 2; constexpr int kQuadrantInset = 2;
// A deliberately asymmetric sub-rect of the 128x96 surface: neither centred nor
// full-extent in either axis, mirroring the conformance suite's randomised
// sub-viewport geometry (glcShaderRenderCase.cpp:735-741). Asymmetry is the whole
// point - y == H - y - h is exactly the case an unconverted Y origin gets right by
// accident, and it is the only case the shipped code ever exercised.
// correct band = GL rows [13, 55)
// mirrored band = GL rows [41, 83) (what H-y-h produces)
constexpr int kSubX = 17;
constexpr int kSubY = 13;
constexpr int kSubW = 60;
constexpr int kSubH = 42;
Image CropRect(const Image& source, int x0, int y0, int width, int height) {
Image out(width, height);
const std::size_t rowBytes = static_cast<std::size_t>(width) * 4;
for (int y = 0; y < height; ++y) {
const std::uint8_t* sourceRow =
source.Data() + (static_cast<std::size_t>(y0 + y) * source.Width() + x0) * 4;
std::memcpy(out.Data() + static_cast<std::size_t>(y) * rowBytes, sourceRow, rowBytes);
}
return out;
}
Image VFlip(const Image& source) {
Image out(source.Width(), source.Height());
const std::size_t rowBytes = static_cast<std::size_t>(source.Width()) * 4;
for (int y = 0; y < source.Height(); ++y) {
std::memcpy(out.Data() + static_cast<std::size_t>(y) * rowBytes,
source.Data() + static_cast<std::size_t>(source.Height() - 1 - y) * rowBytes, rowBytes);
}
return out;
}
struct Vertex { struct Vertex {
float x, y; float x, y;
float r, g, b; float r, g, b;
@@ -377,5 +411,174 @@ void main() {
} }
} }
// ------------------------------------------------------------------ sub-rect / M-1 ----
//
// Everything above reads the FULL extent of its target, which is the one case
// DirectVulkan's default-framebuffer readback ever re-oriented: the remap at
// VulkanRenderer.cpp:2042 had no rect parameters at all, so :8278 gated it on
// `width == swapchainExtent.width && height == swapchainExtent.height` and fell back to a
// raw copy otherwise. Meanwhile the viewport (:422), the scissor (:506-546) and the
// ReadPixels copy offset (:8238) all used the GL bottom-origin Y verbatim as a Vulkan
// top-origin Y.
//
// In the conformance suite those defects CANCEL in placement - the draw lands in Vulkan
// rows [y, y+h) and the readback copies the same rows back - and compose into an exact
// vertical flip of a correct image. That is 1,759 of Magma's 1,793 non-pass cases, and
// image forensics over all 861 gl33 failures found 861 vertical flips and nothing else.
// Taken apart, they are two independent user-visible bugs, so they are tested apart:
// SubViewportDraw pins placement with a full-extent read, SubRectReadback pins the
// readback rect after a full-viewport draw, and SubViewportSubRectRoundTrip is the CTS
// shape where the two cancel.
// Placement: a sub-viewport draw must land in GL rows [y0, y0+h), not mirrored about the
// surface centre. Read back full-extent, which is the path that already worked, so a
// failure here can only be the viewport's Y origin.
TEST_F(OrientationScenario, SubViewportDrawLandsWhereGLPutsIt) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glViewport(kSubX, kSubY, kSubW, kSubH);
DrawQuadrants();
glViewport(0, 0, Gl().Width(), Gl().Height());
const Image whole = ReadPixels(Gl().Width(), Gl().Height());
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
const Image placed = CropRect(whole, kSubX, kSubY, kSubW, kSubH);
EXPECT_EQ(placed.QuadrantSignature(), kUprightSignature)
<< "the sub-viewport draw is not upright inside its own rect";
ExpectUprightQuadrants(placed, "sub-viewport draw, cropped out of a full-extent read");
// Nothing may have been painted outside the viewport. This is what catches the
// mirrored placement: the drawn band would sit at GL rows [41, 83) instead.
EXPECT_TRUE(RegionIsMostly(whole, 0, Gl().Width() - 1, 0, kSubY - 2, "black", 0.0,
"below the sub-viewport"));
EXPECT_TRUE(RegionIsMostly(whole, 0, Gl().Width() - 1, kSubY + kSubH + 1, Gl().Height() - 1, "black",
0.0, "above the sub-viewport"));
}
// Readback: a full-viewport draw read back through a sub-rect must return the requested
// band, in GL row order. Band and orientation are asserted separately so that fixing only
// one of the two cannot pass this case.
TEST_F(OrientationScenario, SubRectReadbackReturnsTheRequestedBandUpright) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawQuadrants();
const Image whole = ReadPixels(Gl().Width(), Gl().Height());
ASSERT_EQ(whole.QuadrantSignature(), kUprightSignature)
<< "the full-extent read is already wrong, so nothing below can be trusted";
const Image sub = ReadPixelsRect(kSubX, kSubY, kSubW, kSubH);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
ASSERT_EQ(sub.Width(), kSubW);
ASSERT_EQ(sub.Height(), kSubH);
const Image requestedBand = CropRect(whole, kSubX, kSubY, kSubW, kSubH);
const Image mirroredBand = CropRect(whole, kSubX, Gl().Height() - kSubY - kSubH, kSubW, kSubH);
// The geometry has to be able to see both mistakes; if a future surface size made the
// band symmetric these assertions would be vacuous, so say so loudly instead.
ASSERT_FALSE(requestedBand == VFlip(requestedBand))
<< "the chosen sub-rect is vertically symmetric - it cannot detect a row flip";
ASSERT_FALSE(requestedBand == mirroredBand)
<< "the chosen sub-rect equals its mirror band - it cannot detect a wrong band";
EXPECT_FALSE(sub == VFlip(requestedBand))
<< "ORIENTATION: the requested band came back with its rows in Vulkan (top-first) order";
EXPECT_FALSE(sub == mirroredBand || sub == VFlip(mirroredBand))
<< "BAND: the read returned GL rows [H-y-h, H-y) instead of [y, y+h)";
EXPECT_TRUE(sub == requestedBand)
<< "the sub-rect readback differs from the same rect of the full-extent read in "
<< sub.ByteDiffCount(requestedBand) << " bytes";
}
// The exact conformance-suite shape: an asymmetric sub-viewport draw read back through the
// very same sub-rect. The placement and readback errors cancel, leaving an image that is
// correct in every pixel VALUE and vertically flipped - which is precisely the 861-case
// signature. One assertion, and it pins all of them.
TEST_F(OrientationScenario, SubViewportSubRectRoundTripIsUpright) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glViewport(kSubX, kSubY, kSubW, kSubH);
DrawQuadrants();
const Image sub = ReadPixelsRect(kSubX, kSubY, kSubW, kSubH);
glViewport(0, 0, Gl().Width(), Gl().Height());
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_EQ(sub.QuadrantSignature(), kUprightSignature)
<< "sub-viewport draw + same-rect readback came back flipped - this is the shape "
"behind KHR-GL33/GL40.shaders.* (861 cases each)";
ExpectUprightQuadrants(sub, "sub-viewport draw read back through the same sub-rect");
}
// The same conversion, on the other rect consumer that reads the default framebuffer.
// glBlitFramebuffer already converted its DESTINATION rect when the draw framebuffer was
// the default one (ApplyNativeBlitDefaultFramebufferTransform), but never its SOURCE rect,
// so a blit OUT of the default framebuffer took the mirrored band and wrote it upside
// down. Blitting a sub-rect and comparing against the same sub-rect of a direct read pins
// both halves at once.
TEST_F(OrientationScenario, BlitOutOfTheDefaultFramebufferKeepsBandAndOrientation) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawQuadrants();
const Image whole = ReadPixels(Gl().Width(), Gl().Height());
ASSERT_EQ(whole.QuadrantSignature(), kUprightSignature)
<< "the full-extent read is already wrong, so nothing below can be trusted";
BindFbo(m_offscreen);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_offscreen.fbo);
glBlitFramebuffer(kSubX, kSubY, kSubX + kSubW, kSubY + kSubH, kSubX, kSubY, kSubX + kSubW,
kSubY + kSubH, GL_COLOR_BUFFER_BIT, GL_NEAREST);
const unsigned int blitError = FirstGLError();
if (blitError != GL_NO_ERROR) {
GTEST_SKIP() << "this backend refused the default-framebuffer blit: "
<< GLErrorName(blitError);
}
glBindFramebuffer(GL_FRAMEBUFFER, m_offscreen.fbo);
const Image blitted = ReadPixels(m_offscreen.width, m_offscreen.height);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
const Image landed = CropRect(blitted, kSubX, kSubY, kSubW, kSubH);
const Image expected = CropRect(whole, kSubX, kSubY, kSubW, kSubH);
EXPECT_FALSE(landed == VFlip(expected))
<< "ORIENTATION: the blitted band arrived upside down";
EXPECT_TRUE(landed == expected)
<< "the blitted sub-rect differs from the same sub-rect of a direct read in "
<< landed.ByteDiffCount(expected) << " bytes";
}
// Negative control. A non-default framebuffer is already self-consistent - no
// gl_Position.y negation, GL row 0 IS Vulkan row 0 - so none of the fixes above may touch
// it. If this ever starts failing, the default-FBO remap has leaked into the FBO path.
TEST_F(OrientationScenario, FboSubRectReadbackAndSubViewportAreUnaffected) {
BindFbo(m_offscreen);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawQuadrants();
const Image whole = ReadPixels(m_offscreen.width, m_offscreen.height);
const Image sub = ReadPixelsRect(kSubX, kSubY, kSubW, kSubH);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_TRUE(sub == CropRect(whole, kSubX, kSubY, kSubW, kSubH))
<< "an FBO sub-rect readback differs from the same rect of its full-extent read in "
<< sub.ByteDiffCount(CropRect(whole, kSubX, kSubY, kSubW, kSubH)) << " bytes";
BindFbo(m_offscreen);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glViewport(kSubX, kSubY, kSubW, kSubH);
DrawQuadrants();
glViewport(0, 0, m_offscreen.width, m_offscreen.height);
const Image placedWhole = ReadPixels(m_offscreen.width, m_offscreen.height);
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_EQ(CropRect(placedWhole, kSubX, kSubY, kSubW, kSubH).QuadrantSignature(), kUprightSignature)
<< "an FBO sub-viewport draw must land in GL rows [y0, y0+h) upright";
EXPECT_TRUE(RegionIsMostly(placedWhole, 0, m_offscreen.width - 1, 0, kSubY - 2, "black", 0.0,
"below an FBO sub-viewport"));
EXPECT_TRUE(RegionIsMostly(placedWhole, 0, m_offscreen.width - 1, kSubY + kSubH + 1,
m_offscreen.height - 1, "black", 0.0, "above an FBO sub-viewport"));
}
} // namespace } // namespace
} // namespace MGITest } // namespace MGITest
@@ -0,0 +1,197 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PipelineFailureScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// "The draw had no pipeline, so we bound null."
//
// DirectVulkan's SetupDraw called GetOrCreatePipeline - a function that DOCUMENTS a
// VK_NULL_HANDLE return - and passed the result straight to vkCmdBindPipeline. When the
// Adreno driver answered vkCreateGraphicsPipelines with VK_ERROR_UNKNOWN, the next
// instruction dereferenced null inside the driver: SIGSEGV at fault addr 0x8, and that one
// shape accounted for 9 of the 15 process deaths in the 2026-08-10 GL-CTS run
// (KHR-GL33/GL40.shaders.struct.uniform.sampler_array_vertex, six
// KHR-GL42.shader_image_load_store cases, one shader_storage_buffer_object case).
//
// It was made permanent by a second defect: PipelineFactory memoized the failure, so the
// null was served for the rest of the process. Every later draw with the same state died
// too, which is why a single bad program took whole CTS groups down with it.
//
// What this scenario pins, on both backends:
// 1. The GL program shape the CTS crashed on (an array of structs each containing a
// sampler, sampled from the VERTEX stage) draws without killing the process.
// 2. It draws AGAIN and produces the identical image. A second draw is the only thing
// that can tell a working pipeline apart from a poisoned cache entry: if the first
// creation had failed and been memoized, the second draw is where the null would be
// served back.
//
// A deterministic driver-side pipeline-creation FAILURE is not reachable from the GL API on
// the llvmpipe/lavapipe lanes - both accept every pipeline these scenarios can describe - so
// the guard itself is proven structurally (PipelineFactory returns before it can emplace a
// VK_NULL_HANDLE, SetupDraw returns false before it can bind one) and this scenario holds
// the surrounding path honest.
#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 {
// Lifted from KHR-GL33.shaders.struct.uniform.sampler_array_vertex (the QPA records the
// source verbatim): an array of structs, each carrying an opaque sampler, sampled in the
// vertex stage. The fragment sibling of this case only FAILS on Magma; only the vertex one
// takes the process down, so the stage matters and is kept.
constexpr const char* kSamplerArrayVertexSource = R"(#version 330 core
struct S {
float a;
vec3 b;
sampler2D c;
};
uniform S s[2];
in vec2 aPos;
out vec4 vColor;
void main() {
vec2 coords = aPos * 0.5 + 0.5;
vColor = vec4(texture(s[1].c, coords * s[0].b.xy + s[1].b.z).rgb, s[0].a);
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kPassthroughFragmentSource = R"(#version 330 core
in vec4 vColor;
out vec4 oColor;
void main() {
oColor = vColor;
}
)";
struct Vertex {
float x, y;
};
std::vector<Vertex> FullscreenTriangleStrip() {
return {{-1.0f, -1.0f}, {1.0f, -1.0f}, {-1.0f, 1.0f}, {1.0f, 1.0f}};
}
class PipelineFailureScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kSamplerArrayVertexSource, kPassthroughFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
const std::vector<Vertex> vertices = FullscreenTriangleStrip();
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));
glBindVertexArray(0);
// A solid red 2x2 texture, so the sampled colour is the same wherever the
// (deliberately degenerate) coordinates land.
const unsigned char red[] = {255, 0, 0, 255, 255, 0, 0, 255,
255, 0, 0, 255, 255, 0, 0, 255};
glGenTextures(1, &m_texture);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, red);
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);
glUseProgram(m_program);
const int samplerLocation = glGetUniformLocation(m_program, "s[1].c");
if (samplerLocation >= 0) glUniform1i(samplerLocation, 0);
const int alphaLocation = glGetUniformLocation(m_program, "s[0].a");
if (alphaLocation >= 0) glUniform1f(alphaLocation, 1.0f);
glUseProgram(0);
m_target = MakeColorFbo(Gl().Width(), Gl().Height());
ASSERT_NE(m_target.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_target);
if (m_texture != 0) glDeleteTextures(1, &m_texture);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_program != 0) glDeleteProgram(m_program);
}
Image DrawOnce() {
BindFbo(m_target);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(m_program);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_texture);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, m_vertexCount);
glBindVertexArray(0);
return ReadPixels(m_target.width, m_target.height);
}
unsigned int m_program = 0;
unsigned int m_vao = 0;
unsigned int m_vbo = 0;
unsigned int m_texture = 0;
int m_vertexCount = 0;
ColorFbo m_target;
};
// Reaching the assertion at all is most of the point: the shipped code SIGSEGV'd inside
// the driver on this draw.
TEST_F(PipelineFailureScenario, SamplerArrayInAStructDrawsWithoutKillingTheProcess) {
const Image drawn = DrawOnce();
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_TRUE(RegionIsMostly(drawn, 2, drawn.Width() - 3, 2, drawn.Height() - 3, "red", 0.0,
"sampler-array-in-struct draw"));
}
// The second draw is what a poisoned cache entry cannot survive: a memoized
// VK_NULL_HANDLE is served on every subsequent lookup, so a run that dies (or silently
// stops drawing) on the second draw and not the first is exactly the "failed pipeline was
// cached" defect.
TEST_F(PipelineFailureScenario, TheSameDrawRepeatsIdenticallyWithNoPoisonedPipelineCache) {
const Image first = DrawOnce();
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the first draw already errored";
Gl().EndFrame();
const Image second = DrawOnce();
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the second draw errored";
EXPECT_TRUE(RegionIsMostly(second, 2, second.Width() - 3, 2, second.Height() - 3, "red", 0.0,
"second draw"));
EXPECT_TRUE(second == first) << "the second draw differs from the first in "
<< second.ByteDiffCount(first) << " bytes - the pipeline the second "
"draw resolved is not the one the first draw used";
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,249 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PixelStoreSweepScenario.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 - PIXEL-STORE MODES RESTORE, and FRAMEBUFFER CHURN STAYS EXACT.
//
// Both cases here replay the shape of KHR-GL3x.packed_pixels.varied_rectangle, the single
// heaviest polluter in the GL CTS: for each of 46 (pixel-store mode, value) pairs it uploads a
// gradient into a fresh texture, attaches that texture to a FRESH framebuffer, reads it back and
// deletes both - ~3300 texture+framebuffer pairs per test case.
//
// What that found: DirectGLES had no destructor for BackendFramebufferObject (nor for the
// renderbuffer and sampler twins), so every frontend glDeleteFramebuffers leaked one driver
// framebuffer for the process lifetime. On an Adreno 830 the CTS run walked the driver to 1.2 GB
// of dead objects, and from that point on EVERY readback through a freshly attached framebuffer
// came back with someone else's pixels - which is what made ~1,500 otherwise-correct cases fail
// depending only on how much ran before them. The unit-level pin for the missing destructors is
// MG_Test/SanityTest.cpp (DirectGLESBackendFramebuffer/Renderbuffer/Sampler); this file pins the
// end-to-end behaviour they protect.
//
// The mode sweep is the second half of the same story: 46 modes are set and reset per case, so a
// mode that fails to restore is indistinguishable from the leak in a full-batch CTS run. The
// assertion here is RESTORATION - after every single mode is set and put back, a readback at
// default state must be byte-identical to one taken before the sweep ever started.
//
// Backend-agnostic on purpose: both bugs this guards against are frontend/backend bookkeeping,
// and DirectVulkan is the built-in control.
#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 {
// Small enough that the table's row lengths (10, 15) and image heights are all >= the
// image, which is the shape the CTS uses (its gradient is 7x3).
constexpr int kTexSize = 8;
// Every buffer handed to GL is this big regardless of the image size: with row length 15,
// two skipped rows/pixels and alignment 8 the driver strides well past the natural image
// extent, and a tight buffer would be an out-of-bounds access rather than a test. (It was:
// the first version of this scenario passed its assertions and then segfaulted at
// teardown, because glReadPixels had written past a 1 KiB destination.)
constexpr std::size_t kScratchBytes = 64 * 1024;
// Every pixel-store mode GL 4.0 has, so a reset provably covers the whole state and not
// just the subset a particular test happened to touch.
struct PixelStoreMode {
GLenum name;
GLint defaultValue;
};
const PixelStoreMode kAllModes[] = {
{GL_UNPACK_SWAP_BYTES, 0}, {GL_UNPACK_LSB_FIRST, 0}, {GL_UNPACK_ROW_LENGTH, 0},
{GL_UNPACK_IMAGE_HEIGHT, 0}, {GL_UNPACK_SKIP_ROWS, 0}, {GL_UNPACK_SKIP_PIXELS, 0},
{GL_UNPACK_SKIP_IMAGES, 0}, {GL_UNPACK_ALIGNMENT, 4}, {GL_PACK_SWAP_BYTES, 0},
{GL_PACK_LSB_FIRST, 0}, {GL_PACK_ROW_LENGTH, 0}, {GL_PACK_IMAGE_HEIGHT, 0},
{GL_PACK_SKIP_ROWS, 0}, {GL_PACK_SKIP_PIXELS, 0}, {GL_PACK_SKIP_IMAGES, 0},
{GL_PACK_ALIGNMENT, 4},
};
// The CTS table verbatim (glcPackedPixelsTests.cpp VariedRectangleTest::iterate): 32
// common cases plus the 14 core-only ones ES has no equivalent for and MobileGL therefore
// honours on the CPU. IMAGE_WIDTH_1/2 and IMAGE_HEIGHT_1/2 are the CTS's 10 and 15.
struct SweepCase {
GLenum mode;
GLint value;
};
const SweepCase kSweep[] = {
{GL_UNPACK_ROW_LENGTH, 0}, {GL_UNPACK_ROW_LENGTH, 10}, {GL_UNPACK_ROW_LENGTH, 15},
{GL_UNPACK_SKIP_ROWS, 0}, {GL_UNPACK_SKIP_ROWS, 1}, {GL_UNPACK_SKIP_ROWS, 2},
{GL_UNPACK_SKIP_PIXELS, 0}, {GL_UNPACK_SKIP_PIXELS, 1}, {GL_UNPACK_SKIP_PIXELS, 2},
{GL_UNPACK_ALIGNMENT, 1}, {GL_UNPACK_ALIGNMENT, 2}, {GL_UNPACK_ALIGNMENT, 4},
{GL_UNPACK_ALIGNMENT, 8}, {GL_UNPACK_IMAGE_HEIGHT, 0}, {GL_UNPACK_IMAGE_HEIGHT, 10},
{GL_UNPACK_IMAGE_HEIGHT, 15}, {GL_UNPACK_SKIP_IMAGES, 0}, {GL_UNPACK_SKIP_IMAGES, 1},
{GL_UNPACK_SKIP_IMAGES, 2}, {GL_PACK_ROW_LENGTH, 0}, {GL_PACK_ROW_LENGTH, 10},
{GL_PACK_ROW_LENGTH, 15}, {GL_PACK_SKIP_ROWS, 0}, {GL_PACK_SKIP_ROWS, 1},
{GL_PACK_SKIP_ROWS, 2}, {GL_PACK_SKIP_PIXELS, 0}, {GL_PACK_SKIP_PIXELS, 1},
{GL_PACK_SKIP_PIXELS, 2}, {GL_PACK_ALIGNMENT, 1}, {GL_PACK_ALIGNMENT, 2},
{GL_PACK_ALIGNMENT, 4}, {GL_PACK_ALIGNMENT, 8},
// core-only, no ES equivalent
{GL_UNPACK_SWAP_BYTES, GL_FALSE}, {GL_UNPACK_SWAP_BYTES, GL_TRUE},
{GL_UNPACK_LSB_FIRST, GL_FALSE}, {GL_UNPACK_LSB_FIRST, GL_TRUE},
{GL_PACK_SWAP_BYTES, GL_FALSE}, {GL_PACK_SWAP_BYTES, GL_TRUE},
{GL_PACK_LSB_FIRST, GL_FALSE}, {GL_PACK_LSB_FIRST, GL_TRUE},
{GL_PACK_IMAGE_HEIGHT, 0}, {GL_PACK_IMAGE_HEIGHT, 10},
{GL_PACK_IMAGE_HEIGHT, 15}, {GL_PACK_SKIP_IMAGES, 0},
{GL_PACK_SKIP_IMAGES, 1}, {GL_PACK_SKIP_IMAGES, 2},
};
std::size_t ImageBytes(int size) { return static_cast<std::size_t>(size) * size * 4; }
// Padded to kScratchBytes so it is safe to hand to an upload running under any of the
// sweep's stride/skip settings.
std::vector<std::uint8_t> MakeGradient(int size, unsigned seed) {
std::vector<std::uint8_t> pixels(kScratchBytes, 0);
for (int y = 0; y < size; ++y) {
for (int x = 0; x < size; ++x) {
const std::size_t base = (static_cast<std::size_t>(y) * size + x) * 4;
pixels[base + 0] = static_cast<std::uint8_t>((x * 11 + seed) & 0xFF);
pixels[base + 1] = static_cast<std::uint8_t>((y * 13 + seed) & 0xFF);
pixels[base + 2] = static_cast<std::uint8_t>((x * y + seed) & 0xFF);
pixels[base + 3] = 0xFF;
}
}
return pixels;
}
void ResetAllPixelStoreModes() {
for (const PixelStoreMode& mode : kAllModes) {
glPixelStorei(mode.name, mode.defaultValue);
}
}
// The one operation the CTS repeats: a fresh texture, a fresh framebuffer, one readback,
// both deleted. Returns the readback; `outStatus` carries the completeness answer so a
// caller can tell an incomplete framebuffer apart from wrong pixels.
std::vector<std::uint8_t> UploadAndReadBack(const std::vector<std::uint8_t>& source, int size,
GLenum* outStatus) {
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, size, size, 0, GL_RGBA, GL_UNSIGNED_BYTE, source.data());
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
*outStatus = glCheckFramebufferStatus(GL_FRAMEBUFFER);
std::vector<std::uint8_t> read(kScratchBytes, 0);
if (*outStatus == GL_FRAMEBUFFER_COMPLETE) {
glReadPixels(0, 0, size, size, GL_RGBA, GL_UNSIGNED_BYTE, read.data());
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &fbo);
glBindTexture(GL_TEXTURE_2D, 0);
glDeleteTextures(1, &texture);
return read;
}
// Index of the first differing byte within the image, or `bytes` when they agree.
std::size_t FirstDifference(const std::vector<std::uint8_t>& a, const std::vector<std::uint8_t>& b,
std::size_t bytes) {
for (std::size_t i = 0; i < bytes; ++i) {
if (a[i] != b[i]) return i;
}
return bytes;
}
class PixelStoreSweepScenario : public ScenarioTest {};
class FramebufferChurnScenario : public ScenarioTest {};
} // namespace
// Every mode in the CTS table is set, exercised and put back; the readback at default state
// afterwards must be bit-identical to the one taken before the sweep. A mode that silently
// fails to restore corrupts every later case in the batch, which is exactly how the CTS
// failures presented (the FIRST sub-case, at default state, is what failed).
TEST_F(PixelStoreSweepScenario, DefaultStateSurvivesTheFullModeSweep) {
if (!Ready()) return;
ResetAllPixelStoreModes();
ASSERT_EQ(FirstGLError(), 0u) << "resetting the pixel-store modes must be legal on a GL 4.0 context";
const std::vector<std::uint8_t> gradient = MakeGradient(kTexSize, 0);
GLenum status = 0;
const std::vector<std::uint8_t> baseline = UploadAndReadBack(gradient, kTexSize, &status);
ASSERT_EQ(status, static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
ASSERT_EQ(FirstGLError(), 0u);
const std::vector<std::uint8_t> scratchSource(kScratchBytes, 0x5A);
for (const SweepCase& sweep : kSweep) {
glPixelStorei(sweep.mode, sweep.value);
ASSERT_EQ(FirstGLError(), 0u) << "glPixelStorei(0x" << std::hex << sweep.mode << std::dec << ", "
<< sweep.value << ") must be accepted";
// Exercise the mode: an upload and a readback that both run with it in force.
GLenum sweepStatus = 0;
(void)UploadAndReadBack(scratchSource, kTexSize, &sweepStatus);
ResetAllPixelStoreModes();
GLenum afterStatus = 0;
const std::vector<std::uint8_t> after = UploadAndReadBack(gradient, kTexSize, &afterStatus);
ASSERT_EQ(afterStatus, static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
const std::size_t diff = FirstDifference(baseline, after, ImageBytes(kTexSize));
ASSERT_EQ(diff, ImageBytes(kTexSize))
<< "default-state readback changed after setting and resetting 0x" << std::hex << sweep.mode
<< std::dec << " = " << sweep.value << "; first differing byte " << diff << " (baseline "
<< static_cast<int>(baseline[diff]) << ", now " << static_cast<int>(after[diff]) << ")";
}
// And the modes themselves must read back as the defaults the reset asked for.
for (const PixelStoreMode& mode : kAllModes) {
GLint value = -1;
glGetIntegerv(mode.name, &value);
EXPECT_EQ(value, mode.defaultValue)
<< "pixel-store mode 0x" << std::hex << mode.name << std::dec << " did not return to its default";
}
EXPECT_EQ(FirstGLError(), 0u);
}
// The leak regression. Each iteration is one complete CTS inner step, and every readback has
// to be exactly the gradient THIS iteration uploaded - never the previous one's. Before the
// missing destructors were added, the driver-side framebuffer count grew without bound here.
TEST_F(FramebufferChurnScenario, RepeatedFramebufferReadbackStaysExact) {
if (!Ready()) return;
ResetAllPixelStoreModes();
constexpr int kSize = 8;
constexpr int kIterations = 1024;
for (int i = 0; i < kIterations; ++i) {
// A distinct gradient per iteration: a stale attachment or a recycled driver name
// reads back the PREVIOUS iteration's image, which a constant fill could not tell
// apart from a correct read.
const std::vector<std::uint8_t> gradient = MakeGradient(kSize, static_cast<unsigned>(i * 7 + 1));
GLenum status = 0;
const std::vector<std::uint8_t> read = UploadAndReadBack(gradient, kSize, &status);
ASSERT_EQ(status, static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE)) << "iteration " << i;
const std::size_t diff = FirstDifference(gradient, read, ImageBytes(kSize));
ASSERT_EQ(diff, ImageBytes(kSize))
<< "iteration " << i << " read back a different image than it uploaded; first differing byte "
<< diff << " (uploaded " << static_cast<int>(gradient[diff]) << ", read "
<< static_cast<int>(read[diff]) << ")";
ASSERT_EQ(FirstGLError(), 0u) << "iteration " << i;
}
}
} // namespace MGITest
@@ -446,6 +446,23 @@ namespace MobileGL::MG_State::GLState {
Bool ProgramLinkTask::ConsumeShaders(Vector<SharedPtr<glslang::TShader>>& outShaders) { Bool ProgramLinkTask::ConsumeShaders(Vector<SharedPtr<glslang::TShader>>& outShaders) {
outShaders.assign(in.shaders.size(), nullptr); outShaders.assign(in.shaders.size(), nullptr);
// GL 4.6 core 7.3: a compute shader may only be linked with other compute shaders -
// the compute pipeline has no other stages to link against, so a program that mixes
// them must fail to link (KHR-GL43.compute_shader.api-program).
{
Bool hasCompute = false;
Bool hasNonCompute = false;
for (const LinkShaderInput& input : in.shaders) {
(input.stage == ShaderStage::Compute ? hasCompute : hasNonCompute) = true;
}
if (hasCompute && hasNonCompute) {
artifacts.infoLog =
"A compute shader cannot be linked with shaders of any other stage.";
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
return false;
}
}
for (SizeT i = 0; i < in.shaders.size(); i++) { for (SizeT i = 0; i < in.shaders.size(); i++) {
const LinkShaderInput& input = in.shaders[i]; const LinkShaderInput& input = in.shaders[i];
const GLenum shaderType = MG_Util::ConvertShaderStageToGLEnum(input.stage); const GLenum shaderType = MG_Util::ConvertShaderStageToGLEnum(input.stage);
@@ -1030,21 +1047,80 @@ namespace MobileGL::MG_State::GLState {
} }
} }
} }
// GL 4.6 core 11.1.2.1 (and the resource-name rule of 7.3.1.1): a member of
// an output interface block is named "<BLOCK name>.<member>" - the block's
// TYPE name, never the instance name, and that holds for an anonymous
// instance too. glslang's linker object for such a block is the *instance*
// symbol ("vs_out", or "anon@N" when there is none), so the head of the
// dotted path has to be matched against getType().getTypeName() instead of
// getName(). Without this every capture of a block member resolved to
// nothing and the link failed with "is not an output of the vertex stage".
String blockName;
String memberName;
if (const SizeT dot = declaredName.find('.'); dot != String::npos) {
blockName = declaredName.substr(0, dot);
memberName = declaredName.substr(dot + 1);
// An array of block instances is spelled "<block>[i].<member>"; every
// instance shares one member list, so the subscript only has to go.
if (!blockName.empty() && blockName.back() == ']') {
const SizeT bracket = blockName.rfind('[');
if (bracket != String::npos) blockName.resize(bracket);
}
}
for (const auto* node : linkerObjects->getSequence()) { for (const auto* node : linkerObjects->getSequence()) {
const glslang::TIntermSymbol* symbol = node->getAsSymbolNode(); const glslang::TIntermSymbol* symbol = node->getAsSymbolNode();
if (symbol == nullptr || symbol->getType().getQualifier().storage != glslang::EvqVaryingOut) { if (symbol == nullptr || symbol->getType().getQualifier().storage != glslang::EvqVaryingOut) {
continue; continue;
} }
if (symbol->getName() != declaredName.c_str()) { const glslang::TType& symbolType = symbol->getType();
continue; const glslang::TType* capturedType = nullptr;
if (memberName.empty()) {
if (symbol->getName() != declaredName.c_str()) {
continue;
}
capturedType = &symbolType;
} else {
if (symbolType.getBasicType() != glslang::EbtBlock) {
continue;
}
// The spec spelling is the block name; the instance name is accepted
// as a fallback so a request written the (common, non-conformant)
// instance-qualified way resolves instead of failing the whole link.
if (symbolType.getTypeName() != blockName.c_str() &&
symbol->getName() != blockName.c_str()) {
continue;
}
const glslang::TTypeList* members = symbolType.getStruct();
if (members == nullptr) {
continue;
}
for (SizeT m = 0; m < members->size(); ++m) {
const glslang::TType* memberType = (*members)[m].type;
if (memberType == nullptr || memberType->getFieldName() != memberName.c_str()) {
continue;
}
capturedType = memberType;
varying.blockMemberIndex = static_cast<Int>(m);
break;
}
if (capturedType == nullptr) {
// Right block, wrong member: no other linker object can match.
break;
}
varying.blockName = symbolType.getTypeName().c_str();
varying.blockInstanceName = symbol->getName().c_str();
} }
resolved = ResolveXfbSymbolType(symbol->getType(), varying.type, varying.size, bytesPerElement); resolved = ResolveXfbSymbolType(*capturedType, varying.type, varying.size, bytesPerElement);
if (resolved && singleElement) { if (resolved && singleElement) {
if (static_cast<Int>(element) >= varying.size) { if (static_cast<Int>(element) >= varying.size) {
resolved = false; resolved = false;
break; break;
} }
varying.size = 1; varying.size = 1;
if (varying.blockMemberIndex >= 0) {
varying.blockMemberElement = static_cast<Int>(element);
}
} }
break; break;
} }
@@ -516,12 +516,27 @@ namespace MobileGL::MG_State::GLState {
Artifacts().infoLog = "No program binary format is supported."; Artifacts().infoLog = "No program binary format is supported.";
} }
Bool GetValidateStatus() const { return m_validateStatus; } Bool GetValidateStatus() const { return m_validateStatus; }
Int GetActiveAtomicCounterCount() const { return Artifacts().program->getNumAtomicCounters(); } // Artifacts().program is null until a link produces reflection, and glGetProgramiv is
Int GetActiveAttributesCount() const { return Artifacts().program->getNumPipeInputs(); } // perfectly legal on a program that never linked (GL 4.6 sec. 7.3: the queried state is
// simply its initial value, zero). Dereferencing it there took the process down with a
// SIGSEGV inside glslang::TProgram::getNumPipeInputs - KHR-GL30.api.coverage does exactly
// this after a failed glGetAttribLocation, and reached it as soon as the CopyTexImage2D
// throw ahead of it stopped killing the run first.
Int GetActiveAtomicCounterCount() const {
const auto& program = Artifacts().program;
return program ? program->getNumAtomicCounters() : 0;
}
Int GetActiveAttributesCount() const {
const auto& program = Artifacts().program;
return program ? program->getNumPipeInputs() : 0;
}
// GL-visible uniform blocks only: the synthesized MGL_GLOBAL_UBO the relaxed parse // GL-visible uniform blocks only: the synthesized MGL_GLOBAL_UBO the relaxed parse
// materializes for default-block uniforms is filtered out by DoReflection. // materializes for default-block uniforms is filtered out by DoReflection.
Int GetActiveUniformBlocksCount() const { return static_cast<Int>(Artifacts().glBlockIndexToTProgram.size()); } Int GetActiveUniformBlocksCount() const { return static_cast<Int>(Artifacts().glBlockIndexToTProgram.size()); }
GLuint GetComputeLocalSize(Uint dim) const { return Artifacts().program->getLocalSize(static_cast<Int>(dim)); } GLuint GetComputeLocalSize(Uint dim) const {
const auto& program = Artifacts().program;
return program ? program->getLocalSize(static_cast<Int>(dim)) : 0;
}
Int GetActiveAttributesMaxLength() const { return Artifacts().attribInNameMaxLength; } Int GetActiveAttributesMaxLength() const { return Artifacts().attribInNameMaxLength; }
Int GetActiveUniformBlocksMaxNameLength() const { return Artifacts().uniformBlockNameMaxLength; } Int GetActiveUniformBlocksMaxNameLength() const { return Artifacts().uniformBlockNameMaxLength; }
Uint GetUniformBlockIndex(const char* name) const { Uint GetUniformBlockIndex(const char* name) const {
@@ -657,6 +672,21 @@ namespace MobileGL::MG_State::GLState {
// Offset within the gap-free record a backend that cannot express the GL // Offset within the gap-free record a backend that cannot express the GL
// layout captures into; see NeedsScatteredTransformFeedbackCapture. // layout captures into; see NeedsScatteredTransformFeedbackCapture.
Uint32 packedOffsetBytes = 0; Uint32 packedOffsetBytes = 0;
// GL 4.6 core 11.1.2.1 / 7.3.1.1: a member of an output interface block is
// captured under "<block name>.<member>". `name` keeps that GL spelling (it is
// what the interface queries and the ESSL backend's driver-side capture list
// need, since SPIRV-Cross re-emits the block under its own type name), while
// the three fields below carry what a SPIR-V backend needs instead: the
// decoration target is the block's *instance* variable and the member index
// inside it. blockMemberIndex < 0 means "not a block member".
String blockInstanceName;
String blockName;
Int blockMemberIndex = -1;
// Which element of an arrayed block member this capture names, -1 for "the
// member as a whole". SPIR-V cannot decorate a single array element, so a
// backend needs the element index to tell a full run from a partial one.
Int blockMemberElement = -1;
}; };
// ---- P1: everything a link PRODUCES, in one movable block ---- // ---- P1: everything a link PRODUCES, in one movable block ----
@@ -7,6 +7,7 @@
// End of Source File Header // End of Source File Header
#include "RenderState.h" #include "RenderState.h"
#include "MG_Util/Debug/Log.h"
#include "MG_Util/Types.h" #include "MG_Util/Types.h"
namespace MobileGL { namespace MobileGL {
@@ -268,9 +269,14 @@ namespace MobileGL {
} }
void RenderState::SetCapabilityIndexed(CapabilityInput cap, Uint index, Bool enabled) { void RenderState::SetCapabilityIndexed(CapabilityInput cap, Uint index, Bool enabled) {
// Only for BlendState currently // Only for BlendState currently. The GL entry points (glEnablei/glDisablei) already
// reject every non-GL_BLEND target with GL_INVALID_ENUM before reaching here, so this
// is a backstop - but it must stay a backstop: THROW_UNIMPL_EXCEPTION unwinds a C++
// exception through the C GL ABI and terminates the process.
if (cap != CapabilityInput::Blend) { if (cap != CapabilityInput::Blend) {
THROW_UNIMPL_EXCEPTION; MGLOG_I("RenderState::SetCapabilityIndexed: indexed capability state exists only for "
"GL_BLEND (cap=%d, index=%u); ignoring",
static_cast<int>(cap), index);
return; return;
} }
if (index >= MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS) { if (index >= MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS) {
@@ -284,9 +290,13 @@ namespace MobileGL {
} }
Bool RenderState::IsCapabilityEnabledIndexed(CapabilityInput cap, Uint index) const { Bool RenderState::IsCapabilityEnabledIndexed(CapabilityInput cap, Uint index) const {
// Only for BlendState currently // Only for BlendState currently - same backstop reasoning as SetCapabilityIndexed:
// glIsEnabledi has already answered GL_INVALID_ENUM/GL_FALSE for anything else, and a
// query must never be able to terminate the process.
if (cap != CapabilityInput::Blend) { if (cap != CapabilityInput::Blend) {
THROW_UNIMPL_EXCEPTION; MGLOG_I("RenderState::IsCapabilityEnabledIndexed: indexed capability state exists only "
"for GL_BLEND (cap=%d, index=%u); reporting disabled",
static_cast<int>(cap), index);
return false; return false;
} }
if (index >= MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS) { if (index >= MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS) {
@@ -29,6 +29,8 @@ namespace MobileGL::MG_State::GLState {
attr.Normalized = false; attr.Normalized = false;
attr.Stride = 0; attr.Stride = 0;
attr.Offset = 0; attr.Offset = 0;
attr.LegacyStride = 0;
attr.LegacyPointer = 0;
attr.Buffer = nullptr; attr.Buffer = nullptr;
BumpAttributeFormatVersion(index); BumpAttributeFormatVersion(index);
@@ -61,10 +63,19 @@ namespace MobileGL::MG_State::GLState {
void VertexArrayObject::SetAttributeFormat(Uint index, int size, DataType type, Bool normalized, int stride, void VertexArrayObject::SetAttributeFormat(Uint index, int size, DataType type, Bool normalized, int stride,
SizeT offset, Bool isInteger, Bool isBgra) { SizeT offset, Bool isInteger, Bool isBgra) {
if (index >= MAX_VERTEX_ATTRIBS) return; if (index >= MAX_VERTEX_ATTRIBS) return;
if (size < 1 || size > 4) {
return;
}
// The classic pointer-style API takes back full ownership of the resolved fields. // The classic pointer-style API takes back full ownership of the resolved fields.
m_attributeUsesBindingModel[index] = false; m_attributeUsesBindingModel[index] = false;
// The legacy query shadows: written here and nowhere else, so a later binding-model
// mutation cannot leak into VERTEX_ATTRIB_ARRAY_STRIDE / _POINTER. They are pure
// query state, so they carry no version bump of their own.
m_attributes[index].LegacyStride = stride;
m_attributes[index].LegacyPointer = offset;
if (m_attributes[index].Size == size && m_attributes[index].Type == type && if (m_attributes[index].Size == size && m_attributes[index].Type == type &&
m_attributes[index].Normalized == normalized && m_attributes[index].Stride == stride && m_attributes[index].Normalized == normalized && m_attributes[index].Stride == stride &&
m_attributes[index].Offset == offset && m_attributes[index].IsInteger == isInteger && m_attributes[index].Offset == offset && m_attributes[index].IsInteger == isInteger &&
@@ -72,10 +83,6 @@ namespace MobileGL::MG_State::GLState {
return; return;
} }
if (size < 1 || size > 4) {
return;
}
auto& attr = m_attributes[index]; auto& attr = m_attributes[index];
attr.Size = size; attr.Size = size;
attr.Type = type; attr.Type = type;
@@ -111,6 +118,12 @@ namespace MobileGL::MG_State::GLState {
binding.Offset = offset; binding.Offset = offset;
binding.Stride = effectiveStride; binding.Stride = effectiveStride;
binding.Divisor = m_attributes[index].Divisor; binding.Divisor = m_attributes[index].Divisor;
// Other attributes may already be pointed at this binding point through
// glVertexAttribBinding; they see the new buffer/offset/stride too (basic-state3
// checks exactly that after a glVertexAttribPointer). They are not adopted into the
// binding model here - only the ones already in it re-resolve.
ResolveAttributesForBinding(index, /*adopt: */ false);
} }
void VertexArrayObject::BindAttributeBuffer(Uint index, const SharedPtr<BufferObject>& buffer) { void VertexArrayObject::BindAttributeBuffer(Uint index, const SharedPtr<BufferObject>& buffer) {
@@ -147,10 +160,24 @@ namespace MobileGL::MG_State::GLState {
void VertexArrayObject::SetAttributeDivisor(Uint index, Uint divisor) { void VertexArrayObject::SetAttributeDivisor(Uint index, Uint divisor) {
if (index >= MAX_VERTEX_ATTRIBS) return; if (index >= MAX_VERTEX_ATTRIBS) return;
// glVertexAttribDivisor is VertexBindingDivisor on the attribute's own binding point // GL 4.6 core 10.3.2 defines VertexAttribDivisor(i, d) as
// (GL 4.6 core 10.3.2), so the binding-point view has to follow the resolved attribute. // VertexAttribBinding(i, i); VertexBindingDivisor(i, d)
if (index < MAX_VERTEX_ATTRIB_BINDINGS && m_attributeBindingIndex[index] == index) { // - the binding is RE-POINTED at i, it is not merely written through when it already
// happens to be i. Guarding the write on "binding == index" (which is what this did)
// left an attribute that glVertexAttribBinding had moved elsewhere pointing at the old
// binding, so the next resolve restored that binding's divisor and the new one was
// lost (KHR-GL4x.vertex_attrib_binding.basic-state4).
//
// What is deliberately NOT copied from VertexAttribBinding is the adoption into the
// binding model: an attribute configured the classic way keeps its pointer-resolved
// stride/offset, exactly as before. The binding point mirrors that state already
// (MirrorPointerIntoBinding), so nothing observable differs - and adopting it here
// would silently swap the raw pointer stride for the effective one under every
// application that calls glVertexAttribDivisor after glVertexAttribPointer.
if (index < MAX_VERTEX_ATTRIB_BINDINGS) {
m_attributeBindingIndex[index] = index;
m_bindingPoints[index].Divisor = divisor; m_bindingPoints[index].Divisor = divisor;
ResolveAttributesForBinding(index, /*adopt: */ false);
} }
if (m_attributes[index].Divisor == divisor) return; if (m_attributes[index].Divisor == divisor) return;
m_attributes[index].Divisor = divisor; m_attributes[index].Divisor = divisor;
@@ -164,7 +191,6 @@ namespace MobileGL::MG_State::GLState {
void VertexArrayObject::ResolveAttributeFromBinding(Uint attribIndex) { void VertexArrayObject::ResolveAttributeFromBinding(Uint attribIndex) {
if (attribIndex >= MAX_VERTEX_ATTRIBS) return; if (attribIndex >= MAX_VERTEX_ATTRIBS) return;
if (!m_attributeUsesBindingModel[attribIndex]) return;
const Uint bindingIndex = m_attributeBindingIndex[attribIndex]; const Uint bindingIndex = m_attributeBindingIndex[attribIndex];
if (bindingIndex >= MAX_VERTEX_ATTRIB_BINDINGS) return; if (bindingIndex >= MAX_VERTEX_ATTRIB_BINDINGS) return;
@@ -172,11 +198,24 @@ namespace MobileGL::MG_State::GLState {
auto& attr = m_attributes[attribIndex]; auto& attr = m_attributes[attribIndex];
// VERTEX_ATTRIB_ARRAY_DIVISOR is not independent per-attribute state: it IS the divisor
// of the binding point the attribute is attached to (GL 4.6 core 10.3.2), whichever API
// configured the attribute. glVertexBindingDivisor therefore has to reach a classic
// pointer-configured attribute as well - basic-state4 alternates the two spellings on
// the same attribute and expects each to win in turn.
if (attr.Divisor != binding.Divisor) {
attr.Divisor = binding.Divisor;
BumpAttributeFormatVersion(attribIndex);
}
// Everything else stays owned by whichever API configured the attribute: a classic
// glVertexAttrib*Pointer attribute keeps its pointer-resolved stride and offset.
if (!m_attributeUsesBindingModel[attribIndex]) return;
const SizeT resolvedOffset = binding.Offset + m_attributeRelativeOffset[attribIndex]; const SizeT resolvedOffset = binding.Offset + m_attributeRelativeOffset[attribIndex];
if (attr.Stride != binding.Stride || attr.Offset != resolvedOffset || attr.Divisor != binding.Divisor) { if (attr.Stride != binding.Stride || attr.Offset != resolvedOffset) {
attr.Stride = binding.Stride; attr.Stride = binding.Stride;
attr.Offset = resolvedOffset; attr.Offset = resolvedOffset;
attr.Divisor = binding.Divisor;
BumpAttributeFormatVersion(attribIndex); BumpAttributeFormatVersion(attribIndex);
} }
@@ -186,6 +225,14 @@ namespace MobileGL::MG_State::GLState {
} }
} }
void VertexArrayObject::ResolveAttributesForBinding(Uint bindingIndex, Bool adopt) {
for (Uint attribIndex = 0; attribIndex < MAX_VERTEX_ATTRIBS; ++attribIndex) {
if (m_attributeBindingIndex[attribIndex] != bindingIndex) continue;
if (adopt) m_attributeUsesBindingModel[attribIndex] = true;
ResolveAttributeFromBinding(attribIndex);
}
}
void VertexArrayObject::SetBindingBuffer(Uint bindingIndex, const SharedPtr<BufferObject>& buffer, SizeT offset, void VertexArrayObject::SetBindingBuffer(Uint bindingIndex, const SharedPtr<BufferObject>& buffer, SizeT offset,
int stride) { int stride) {
if (bindingIndex >= MAX_VERTEX_ATTRIB_BINDINGS) return; if (bindingIndex >= MAX_VERTEX_ATTRIB_BINDINGS) return;
@@ -195,15 +242,10 @@ namespace MobileGL::MG_State::GLState {
binding.Offset = offset; binding.Offset = offset;
binding.Stride = stride; binding.Stride = stride;
for (Uint attribIndex = 0; attribIndex < MAX_VERTEX_ATTRIBS; ++attribIndex) { // Binding a vertex buffer to a binding point adopts every attribute currently mapped to
if (m_attributeBindingIndex[attribIndex] == bindingIndex) { // that binding point into the binding model (the default mapping is attribute i ->
// Binding a vertex buffer to a binding point adopts every attribute currently // binding i, which matches the GL 4.3 rules for state mixing).
// mapped to that binding point into the binding model (the default mapping is ResolveAttributesForBinding(bindingIndex, /*adopt: */ true);
// attribute i -> binding i, which matches the GL 4.3 rules for state mixing).
m_attributeUsesBindingModel[attribIndex] = true;
ResolveAttributeFromBinding(attribIndex);
}
}
} }
void VertexArrayObject::SetBindingDivisor(Uint bindingIndex, Uint divisor) { void VertexArrayObject::SetBindingDivisor(Uint bindingIndex, Uint divisor) {
@@ -211,11 +253,7 @@ namespace MobileGL::MG_State::GLState {
m_bindingPoints[bindingIndex].Divisor = divisor; m_bindingPoints[bindingIndex].Divisor = divisor;
for (Uint attribIndex = 0; attribIndex < MAX_VERTEX_ATTRIBS; ++attribIndex) { ResolveAttributesForBinding(bindingIndex, /*adopt: */ false);
if (m_attributeBindingIndex[attribIndex] == bindingIndex && m_attributeUsesBindingModel[attribIndex]) {
ResolveAttributeFromBinding(attribIndex);
}
}
} }
void VertexArrayObject::SetAttributeBinding(Uint attribIndex, Uint bindingIndex) { void VertexArrayObject::SetAttributeBinding(Uint attribIndex, Uint bindingIndex) {
@@ -32,6 +32,16 @@ namespace MobileGL {
Bool IsBgra = false; Bool IsBgra = false;
Uint Divisor = 0; Uint Divisor = 0;
SharedPtr<BufferObject> Buffer; SharedPtr<BufferObject> Buffer;
// GL 4.6 core table 23.3: VERTEX_ATTRIB_ARRAY_STRIDE and _POINTER are the
// arguments of the last glVertexAttrib*Pointer call on this attribute,
// reported verbatim, and NOTHING else writes them - not glVertexAttribFormat,
// not glBindVertexBuffer. Stride/Offset above are the *resolved* draw inputs
// and the binding model does overwrite those, so the two views have to be
// stored apart or the binding-model sequence reports a legacy state it never
// set (KHR-GL4x.vertex_attrib_binding.basic-state3).
int LegacyStride = 0;
SizeT LegacyPointer = 0;
}; };
// ARB_vertex_attrib_binding separate binding point. Attributes configured through the // ARB_vertex_attrib_binding separate binding point. Attributes configured through the
@@ -40,7 +50,8 @@ namespace MobileGL {
struct VertexBufferBindingPoint { struct VertexBufferBindingPoint {
SharedPtr<BufferObject> Buffer; SharedPtr<BufferObject> Buffer;
SizeT Offset = 0; SizeT Offset = 0;
int Stride = 0; // GL 4.6 core table 23.4: the initial VERTEX_BINDING_STRIDE is 16, not 0.
int Stride = 16;
Uint Divisor = 0; Uint Divisor = 0;
}; };
@@ -185,6 +196,10 @@ namespace MobileGL {
void BumpAttributeBufferVersion(Uint index); void BumpAttributeBufferVersion(Uint index);
void BumpAttributeSwitchVersion(Uint index); void BumpAttributeSwitchVersion(Uint index);
void ResolveAttributeFromBinding(Uint attribIndex); void ResolveAttributeFromBinding(Uint attribIndex);
// Re-resolve every attribute currently pointed at `bindingIndex`. `adopt` turns
// the ones that are not in the binding model yet into binding-model attributes
// first (what glBindVertexBuffer does, GL 4.3 rules for state mixing).
void ResolveAttributesForBinding(Uint bindingIndex, Bool adopt);
// The default mapping is attribute i -> binding point i. Keep it an iota over // The default mapping is attribute i -> binding point i. Keep it an iota over
// MAX_VERTEX_ATTRIBS rather than a literal list: a literal list silently leaves the // MAX_VERTEX_ATTRIBS rather than a literal list: a literal list silently leaves the
+17
View File
@@ -164,6 +164,22 @@ target_link_libraries(
${LINK_LIBRARIES} ${LINK_LIBRARIES}
) )
add_executable(
XfbBlockVaryingTest
XfbBlockVaryingTest.cpp
)
target_include_directories(XfbBlockVaryingTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
XfbBlockVaryingTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
add_executable( add_executable(
ProgramInterfaceTest ProgramInterfaceTest
ProgramInterfaceTest.cpp ProgramInterfaceTest.cpp
@@ -195,6 +211,7 @@ include(GoogleTest)
gtest_discover_tests(ProgramUtilTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(ProgramUtilTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ProgramInterfaceTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(ProgramInterfaceTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(XfbBlockVaryingTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
# Heavier than the rest of the unit suite by design: several cases deliberately saturate the # Heavier than the rest of the unit suite by design: several cases deliberately saturate the
# compile pool so there is something in flight to race against. # compile pool so there is something in flight to race against.
gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
@@ -1100,4 +1100,119 @@ void main() { color = u + v; }
EXPECT_EQ(viaActiveUniformBlockiv, 5); EXPECT_EQ(viaActiveUniformBlockiv, 5);
EXPECT_EQ(TakeError(), GL_NO_ERROR); EXPECT_EQ(TakeError(), GL_NO_ERROR);
} }
// ---------------------------------------------------- queries on an unlinked program ----
// glGetProgramiv is legal on a program that has never linked - GL 4.6 sec. 7.3 says the
// queried state simply has its initial value - but the reflection-backed pnames read
// Artifacts().program, which is null until a link produces one. That dereference was a
// SIGSEGV inside glslang::TProgram::getNumPipeInputs, and KHR-GL30.api.coverage walks into it
// (it queries GL_ACTIVE_ATTRIBUTES right after a glGetAttribLocation that failed). It only
// became reachable once the glCopyTexImage2D throw ahead of it in the same case stopped
// killing the run first.
TEST_F(ProgramInterfaceTest, ReflectionQueriesOnAnUnlinkedProgramAnswerZero) {
const GLuint neverLinked = CreateProgram();
ASSERT_NE(neverLinked, 0u);
ClearErrors();
for (const GLenum pname : {GL_ACTIVE_ATTRIBUTES, GL_ACTIVE_ATTRIBUTE_MAX_LENGTH, GL_ACTIVE_UNIFORMS,
GL_ACTIVE_UNIFORM_MAX_LENGTH, GL_ACTIVE_UNIFORM_BLOCKS,
GL_ACTIVE_ATOMIC_COUNTER_BUFFERS}) {
GLint value = -1;
GetProgramiv(neverLinked, pname, &value);
ClearErrors();
EXPECT_GE(value, 0) << "pname 0x" << std::hex << pname << " left its output untouched";
}
// A program that was linked and FAILED is the shape api.coverage actually hits.
const GLuint brokenSource = MakeProgram("#version 430\nvoid main() { this is not glsl }\n", kSimpleFs);
LinkProgram(brokenSource);
ClearErrors();
GLint linked = GL_TRUE;
GetProgramiv(brokenSource, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_FALSE) << "the shader was supposed to fail to compile";
ClearErrors();
GLint attributes = -1;
GetProgramiv(brokenSource, GL_ACTIVE_ATTRIBUTES, &attributes);
ClearErrors();
EXPECT_EQ(attributes, 0);
// GL_COMPUTE_WORK_GROUP_SIZE is GL_INVALID_OPERATION on a program that has not linked (GL
// 4.6 sec. 7.13), so it is allowed to leave the output alone - but it still reaches
// GetComputeLocalSize(), and it may not do so through a null reflection.
GLint localSize[3] = {-1, -1, -1};
GetProgramiv(brokenSource, GL_COMPUTE_WORK_GROUP_SIZE, localSize);
const GLenum computeError = TakeError();
ClearErrors();
EXPECT_TRUE(computeError == GL_INVALID_OPERATION || (localSize[0] == 0 && localSize[1] == 0 &&
localSize[2] == 0))
<< "either the query is refused, or it answers the initial value - never both untouched "
"and unreported";
}
// ------------------------------------------------------------- length on every path ----
// glGetProgramResourceiv's *length is the caller's only signal for how many entries params
// holds, and callers are entitled to leave it uninitialised: the CTS declares `GLsizei
// length;` next to a 1000-entry stack array and then loops `for (i = 0; i < length; ++i)`
// (gl4cProgramInterfaceQueryTests.cpp:2172). Leaving it untouched on an error path therefore
// does not "return nothing" - it hands the caller whatever was on its stack and makes it walk
// that far. KHR-GL43.program_interface_query.subroutines-vertex read 0x20202020 (" ")
// entries and took the process down on BOTH backends. So: zero on every exit, real count on
// success. Poisoning with the exact CTS-observed value keeps the assertion honest.
TEST_F(ProgramInterfaceTest, GetProgramResourceivReportsLengthOnEveryExitPath) {
const GLuint p = MakeProgram(kSimpleVs, kSimpleFs);
BindAttribLocation(p, 0, "position");
BindFragDataLocation(p, 0, "color");
LinkProgram(p);
ExpectLinked(p);
ClearErrors();
constexpr GLsizei kPoison = 0x20202020;
constexpr GLsizei kBufSize = 16;
GLint params[kBufSize] = {};
const GLenum nameLengthProp = GL_NAME_LENGTH;
const GLenum compatibleSubroutinesProp = GL_COMPATIBLE_SUBROUTINES;
const GLenum notAProp = GL_TEXTURE_2D;
const auto lengthAfter = [&](GLuint program, GLenum iface, GLuint index, GLsizei propCount,
const GLenum* props, GLsizei bufSize, GLint* out) {
GLsizei length = kPoison;
GetProgramResourceiv(program, iface, index, propCount, props, bufSize, &length, out);
ClearErrors();
return length;
};
// The case that actually crashed: no subroutine reflection exists, so the query errors
// out - and the caller then trusts *length.
EXPECT_EQ(lengthAfter(p, GL_VERTEX_SUBROUTINE_UNIFORM, 0, 1, &compatibleSubroutinesProp, kBufSize, params), 0)
<< "GL_VERTEX_SUBROUTINE_UNIFORM";
// Not a program name.
EXPECT_EQ(lengthAfter(p + 4242, GL_UNIFORM, 0, 1, &nameLengthProp, kBufSize, params), 0) << "bad program";
// Not an interface enum.
EXPECT_EQ(lengthAfter(p, GL_TEXTURE_2D, 0, 1, &nameLengthProp, kBufSize, params), 0) << "bad interface";
// propCount <= 0, bufSize < 0.
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 0, &nameLengthProp, kBufSize, params), 0) << "propCount 0";
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, &nameLengthProp, -1, params), 0) << "negative bufSize";
// props == nullptr.
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, nullptr, kBufSize, params), 0) << "null props";
// A prop this command does not know at all.
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, &notAProp, kBufSize, params), 0) << "unknown prop";
// A prop it knows but this interface does not carry.
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, &compatibleSubroutinesProp, kBufSize, params), 0)
<< "prop/interface mismatch";
// Index past the end of a real interface.
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 9999, 1, &nameLengthProp, kBufSize, params), 0) << "bad index";
// Nowhere to put the values.
EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, &nameLengthProp, kBufSize, nullptr), 0) << "null params";
// ...and the success path still reports the count it actually wrote.
const GLuint outputIndex = GetProgramResourceIndex(p, GL_PROGRAM_OUTPUT, "color");
ASSERT_NE(outputIndex, GL_INVALID_INDEX);
GLsizei length = kPoison;
GetProgramResourceiv(p, GL_PROGRAM_OUTPUT, outputIndex, 1, &nameLengthProp, kBufSize, &length, params);
EXPECT_EQ(TakeError(), GL_NO_ERROR);
EXPECT_EQ(length, 1);
EXPECT_EQ(params[0], 6) << "GL_NAME_LENGTH counts the terminator";
}
} // namespace } // namespace
@@ -0,0 +1,222 @@
// MobileGL - MobileGL/MG_Test/Program/XfbBlockVaryingTest.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
// Transform-feedback capture of a member of an output interface block.
//
// GL 4.6 core 11.1.2.1 names such a varying "<BLOCK name>.<member>" - the block's TYPE
// name, never the instance name - which is exactly what KHR-GL4x.vertex_attrib_binding
// (gl4cVertexAttribBindingTests.cpp:419-437, `out StageData { vec4 attrib[16]; } vs_out;`
// captured as "StageData.attrib[0]".."[15]") relies on. The resolver used to match the
// requested name against glslang's linker-object symbol name, which for a block is the
// INSTANCE ("vs_out"), so every one of those captures came back unresolved and the link
// failed with "is not an output of the vertex stage" + GL_INVALID_VALUE.
//
// GPU-free: everything asserted here is a property of the link, not of any driver.
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_State/GLState/Core.h"
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
namespace {
class XfbBlockVaryingTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
};
GLuint MakeVsOnlyProgram(const char* vs) {
const GLuint program = CreateProgram();
const GLuint shader = CreateShader(GL_VERTEX_SHADER);
ShaderSource(shader, 1, &vs, nullptr);
CompileShader(shader);
GLint compiled = GL_FALSE;
GetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
EXPECT_EQ(compiled, GL_TRUE) << [&] {
char log[4096] = "";
GetShaderInfoLog(shader, sizeof(log), nullptr, log);
return std::string(log);
}();
AttachShader(program, shader);
return program;
}
std::string LinkLog(GLuint program) {
char log[4096] = "";
GetProgramInfoLog(program, sizeof(log), nullptr, log);
return std::string(log);
}
GLint Programiv(GLuint program, GLenum pname) {
GLint value = -1;
GetProgramiv(program, pname, &value);
return value;
}
struct VaryingRecord {
std::string name;
GLsizei size = 0;
GLenum type = 0;
};
VaryingRecord Varying(GLuint program, GLuint index) {
VaryingRecord record;
GLchar buffer[256] = {'\0'};
GLsizei length = 0;
GetTransformFeedbackVarying(program, index, sizeof(buffer), &length, &record.size, &record.type, buffer);
record.name.assign(buffer, buffer + (length < 0 ? 0 : length));
return record;
}
void ClearErrors() {
for (int i = 0; i < 32 && GetError() != GL_NO_ERROR; ++i) {
}
}
// The CTS shader, narrowed to two elements so the expectations stay readable.
const char* kNamedBlockVs = R"(#version 430 core
layout(location = 0) in vec4 vs_in_attrib[2];
out StageData {
vec4 attrib[2];
} vs_out;
void main() {
for (int i = 0; i < vs_in_attrib.length(); ++i) {
vs_out.attrib[i] = vs_in_attrib[i];
}
}
)";
TEST_F(XfbBlockVaryingTest, CapturesBlockMemberElementsByBlockTypeName) {
ClearErrors();
const GLuint program = MakeVsOnlyProgram(kNamedBlockVs);
const GLchar* const varyings[2] = {"StageData.attrib[0]", "StageData.attrib[1]"};
TransformFeedbackVaryings(program, 2, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
EXPECT_EQ(GetError(), GL_NO_ERROR);
EXPECT_EQ(Programiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS), 2);
EXPECT_EQ(Programiv(program, GL_TRANSFORM_FEEDBACK_BUFFER_MODE), GL_INTERLEAVED_ATTRIBS);
for (GLuint i = 0; i < 2; ++i) {
const VaryingRecord record = Varying(program, i);
EXPECT_EQ(record.name, std::string("StageData.attrib[") + std::to_string(i) + "]");
// One element of the member array, not the whole array.
EXPECT_EQ(record.size, 1) << "index " << i;
EXPECT_EQ(record.type, static_cast<GLenum>(GL_FLOAT_VEC4)) << "index " << i;
}
}
// The whole member, no subscript: the array size has to survive.
TEST_F(XfbBlockVaryingTest, CapturesAWholeBlockMemberArray) {
ClearErrors();
const GLuint program = MakeVsOnlyProgram(kNamedBlockVs);
const GLchar* const varyings[1] = {"StageData.attrib"};
TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
const VaryingRecord record = Varying(program, 0);
EXPECT_EQ(record.name, "StageData.attrib");
EXPECT_EQ(record.size, 2);
EXPECT_EQ(record.type, static_cast<GLenum>(GL_FLOAT_VEC4));
}
// Members of an anonymous instance are named the same way - the block name is still
// what identifies them, and there is no instance name to fall back on.
TEST_F(XfbBlockVaryingTest, CapturesAnonymousInstanceBlockMember) {
ClearErrors();
const GLuint program = MakeVsOnlyProgram(R"(#version 430 core
layout(location = 0) in vec4 vs_in_attrib;
out StageData {
vec4 color;
vec2 uv;
};
void main() {
color = vs_in_attrib;
uv = vs_in_attrib.xy;
}
)");
const GLchar* const varyings[2] = {"StageData.color", "StageData.uv"};
TransformFeedbackVaryings(program, 2, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
EXPECT_EQ(Varying(program, 0).type, static_cast<GLenum>(GL_FLOAT_VEC4));
EXPECT_EQ(Varying(program, 1).type, static_cast<GLenum>(GL_FLOAT_VEC2));
}
// The instance-qualified spelling is not what the spec asks for, but it is what a lot of
// application code writes; resolving it too costs nothing and keeps those links alive.
TEST_F(XfbBlockVaryingTest, AlsoAcceptsTheInstanceQualifiedSpelling) {
ClearErrors();
const GLuint program = MakeVsOnlyProgram(kNamedBlockVs);
const GLchar* const varyings[1] = {"vs_out.attrib[1]"};
TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
EXPECT_EQ(Varying(program, 0).size, 1);
EXPECT_EQ(Varying(program, 0).type, static_cast<GLenum>(GL_FLOAT_VEC4));
}
// A dotted path that resolves to nothing must still fail the link, and say so - the
// fix must not turn "unknown member" into a silently dropped capture.
TEST_F(XfbBlockVaryingTest, RejectsAnUnknownBlockMember) {
ClearErrors();
const GLuint program = MakeVsOnlyProgram(kNamedBlockVs);
const GLchar* const varyings[1] = {"StageData.missing"};
TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
EXPECT_EQ(Programiv(program, GL_LINK_STATUS), GL_FALSE);
EXPECT_NE(LinkLog(program).find("StageData.missing"), std::string::npos) << LinkLog(program);
}
TEST_F(XfbBlockVaryingTest, RejectsAnUnknownBlock) {
ClearErrors();
const GLuint program = MakeVsOnlyProgram(kNamedBlockVs);
const GLchar* const varyings[1] = {"NoSuchBlock.attrib[0]"};
TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
EXPECT_EQ(Programiv(program, GL_LINK_STATUS), GL_FALSE);
}
// Plain (non-block) outputs must keep resolving exactly as before.
TEST_F(XfbBlockVaryingTest, StillResolvesPlainOutputs) {
ClearErrors();
const GLuint program = MakeVsOnlyProgram(R"(#version 430 core
layout(location = 0) in vec4 vs_in_attrib;
out vec4 plain[2];
out vec3 single;
void main() {
plain[0] = vs_in_attrib;
plain[1] = vs_in_attrib;
single = vs_in_attrib.xyz;
}
)");
const GLchar* const varyings[3] = {"plain[1]", "single", "gl_Position"};
TransformFeedbackVaryings(program, 3, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
EXPECT_EQ(Varying(program, 0).size, 1);
EXPECT_EQ(Varying(program, 0).type, static_cast<GLenum>(GL_FLOAT_VEC4));
EXPECT_EQ(Varying(program, 1).type, static_cast<GLenum>(GL_FLOAT_VEC3));
EXPECT_EQ(Varying(program, 2).type, static_cast<GLenum>(GL_FLOAT_VEC4));
}
} // namespace
+159 -2
View File
@@ -1822,13 +1822,170 @@ TEST(DirectGLESBackendTexture, DestructorDeletesIdAndScrubsBindingCache) {
// A wrapper whose context died must NOT delete a foreign (recycled) name. // A wrapper whose context died must NOT delete a foreign (recycled) name.
{ {
auto backendTexture = MobileGL::MakeShared<TextureImpl::BackendTextureObject>(); auto backendTexture = MobileGL::MakeShared<TextureImpl::BackendTextureObject>();
++TextureImpl::g_textureContextGeneration; ++g_backendContextGeneration;
backendTexture.reset(); backendTexture.reset();
--TextureImpl::g_textureContextGeneration; // restore for later tests --g_backendContextGeneration; // restore for later tests
EXPECT_EQ(deleted.size(), 1u); EXPECT_EQ(deleted.size(), 1u);
} }
} }
// ---- DirectGLES backend twins release their driver ids --------------------------------------
// Framebuffers, renderbuffers and samplers had no destructor at all: every frontend object the
// application deleted leaked its ES twin for the whole process lifetime. An application that
// creates a framebuffer per readback (GL CTS packed_pixels.varied_rectangle makes ~3300 of them
// per case) walked the driver into a gigabyte of dead framebuffers, and past that point every
// readback through a freshly attached framebuffer came back with stale pixels.
namespace {
struct TwinDeletionSinks {
MobileGL::Vector<GLuint> framebuffers;
MobileGL::Vector<GLuint> renderbuffers;
MobileGL::Vector<GLuint> samplers;
};
TwinDeletionSinks* g_twinDeletionSinks = nullptr;
GLuint g_nextTwinDriverId = 900;
void TW_GenFramebuffers(GLsizei count, GLuint* ids) {
for (GLsizei i = 0; i < count; ++i) ids[i] = g_nextTwinDriverId++;
}
void TW_DeleteFramebuffers(GLsizei count, const GLuint* ids) {
if (!g_twinDeletionSinks) return;
for (GLsizei i = 0; i < count; ++i) g_twinDeletionSinks->framebuffers.push_back(ids[i]);
}
void TW_GenRenderbuffers(GLsizei count, GLuint* ids) {
for (GLsizei i = 0; i < count; ++i) ids[i] = g_nextTwinDriverId++;
}
void TW_DeleteRenderbuffers(GLsizei count, const GLuint* ids) {
if (!g_twinDeletionSinks) return;
for (GLsizei i = 0; i < count; ++i) g_twinDeletionSinks->renderbuffers.push_back(ids[i]);
}
void TW_GenSamplers(GLsizei count, GLuint* ids) {
for (GLsizei i = 0; i < count; ++i) ids[i] = g_nextTwinDriverId++;
}
void TW_DeleteSamplers(GLsizei count, const GLuint* ids) {
if (!g_twinDeletionSinks) return;
for (GLsizei i = 0; i < count; ++i) g_twinDeletionSinks->samplers.push_back(ids[i]);
}
void TW_BindFramebuffer(GLenum target, GLuint framebuffer) {
SG_Log("BindFramebuffer:" + std::to_string(target) + ":" + std::to_string(framebuffer));
}
void TW_BindSampler(GLuint, GLuint) {}
void TW_BindRenderbuffer(GLenum, GLuint) {}
// Installs a table that can create and destroy all three twin kinds, and unwinds it (plus the
// recording pointer) even when an assertion aborts the test body.
struct ScopedBackendTwinMocks {
ScopedBackendTwinMocks(): previousFunctions(MobileGL::MG_Backend::DirectGLES::g_GLESFuncs) {
MobileGL::MG_Backend::DirectGLES::FramebufferImpl::InvalidateFramebufferBindingCache();
MobileGL::MG_External::GLESFunctionsTable functions{};
functions.glGenFramebuffers = TW_GenFramebuffers;
functions.glDeleteFramebuffers = TW_DeleteFramebuffers;
functions.glBindFramebuffer = TW_BindFramebuffer;
functions.glGenRenderbuffers = TW_GenRenderbuffers;
functions.glDeleteRenderbuffers = TW_DeleteRenderbuffers;
functions.glBindRenderbuffer = TW_BindRenderbuffer;
functions.glGenSamplers = TW_GenSamplers;
functions.glDeleteSamplers = TW_DeleteSamplers;
functions.glBindSampler = TW_BindSampler;
functions.glGetError = SG_NoError;
MobileGL::MG_Backend::DirectGLES::SetGLESFuncsTable(functions);
g_twinDeletionSinks = &sinks;
g_stateGuardLog = &log;
}
~ScopedBackendTwinMocks() {
g_stateGuardLog = nullptr;
g_twinDeletionSinks = nullptr;
MobileGL::MG_Backend::DirectGLES::SetGLESFuncsTable(previousFunctions);
MobileGL::MG_Backend::DirectGLES::FramebufferImpl::InvalidateFramebufferBindingCache();
}
ScopedBackendTwinMocks(const ScopedBackendTwinMocks&) = delete;
ScopedBackendTwinMocks& operator=(const ScopedBackendTwinMocks&) = delete;
TwinDeletionSinks sinks;
StateGuardCallLog log;
MobileGL::MG_External::GLESFunctionsTable previousFunctions;
};
} // namespace
TEST(DirectGLESBackendFramebuffer, DestructorDeletesIdAndScrubsBindingShadow) {
using namespace MobileGL::MG_Backend::DirectGLES;
ScopedBackendTwinMocks mocks;
GLuint id = 0;
{
auto backendFBO = MobileGL::MakeShared<FramebufferImpl::BackendFramebufferObject>();
id = backendFBO->GetBackendFramebufferId();
ASSERT_NE(id, 0u);
backendFBO->Bind(MobileGL::FramebufferTarget::Draw);
ASSERT_EQ(FramebufferImpl::CurrentFramebufferBinding(MobileGL::FramebufferTarget::Draw), id);
}
ASSERT_EQ(mocks.sinks.framebuffers.size(), 1u);
EXPECT_EQ(mocks.sinks.framebuffers[0], id);
// ES reverts every target bound to a deleted framebuffer to 0. The shadow has to follow, or
// the next BindFramebufferId(0) is deduped away and the driver keeps the dead name bound.
EXPECT_EQ(FramebufferImpl::CurrentFramebufferBinding(MobileGL::FramebufferTarget::Draw), 0u);
// A twin whose context died must NOT delete a name a successor context may have recycled.
{
auto backendFBO = MobileGL::MakeShared<FramebufferImpl::BackendFramebufferObject>();
++g_backendContextGeneration;
backendFBO.reset();
--g_backendContextGeneration; // restore for later tests
EXPECT_EQ(mocks.sinks.framebuffers.size(), 1u);
}
}
TEST(DirectGLESBackendRenderbuffer, DestructorDeletesId) {
using namespace MobileGL::MG_Backend::DirectGLES;
ScopedBackendTwinMocks mocks;
GLuint id = 0;
{
auto backendRBO = MobileGL::MakeShared<RenderbufferImpl::BackendRenderbufferObject>();
id = backendRBO->GetBackendRenderbufferId();
ASSERT_NE(id, 0u);
}
ASSERT_EQ(mocks.sinks.renderbuffers.size(), 1u);
EXPECT_EQ(mocks.sinks.renderbuffers[0], id);
{
auto backendRBO = MobileGL::MakeShared<RenderbufferImpl::BackendRenderbufferObject>();
++g_backendContextGeneration;
backendRBO.reset();
--g_backendContextGeneration;
EXPECT_EQ(mocks.sinks.renderbuffers.size(), 1u);
}
}
TEST(DirectGLESBackendSampler, DestructorDeletesIdAndScrubsUnitCache) {
using namespace MobileGL::MG_Backend::DirectGLES;
ScopedBackendTwinMocks mocks;
GLuint id = 0;
{
auto backendSampler = MobileGL::MakeShared<SamplerImpl::BackendSamplerObject>();
id = backendSampler->GetBackendSamplerId();
ASSERT_NE(id, 0u);
backendSampler->Bind(3);
ASSERT_EQ(SamplerImpl::g_boundSamplersCache[3], backendSampler.get());
}
ASSERT_EQ(mocks.sinks.samplers.size(), 1u);
EXPECT_EQ(mocks.sinks.samplers[0], id);
// glDeleteSamplers unbinds from every unit, and the next twin can land on this heap
// address - a stale row would false-skip its Bind.
EXPECT_EQ(SamplerImpl::g_boundSamplersCache[3], nullptr);
{
auto backendSampler = MobileGL::MakeShared<SamplerImpl::BackendSamplerObject>();
++g_backendContextGeneration;
backendSampler.reset();
--g_backendContextGeneration;
EXPECT_EQ(mocks.sinks.samplers.size(), 1u);
}
}
TEST(DirectGLESStateGuards, DefaultFramebufferBindGoesThroughShadow) { TEST(DirectGLESStateGuards, DefaultFramebufferBindGoesThroughShadow) {
using namespace MobileGL::MG_Backend::DirectGLES; using namespace MobileGL::MG_Backend::DirectGLES;
ScopedStateGuardMocks mocks; ScopedStateGuardMocks mocks;
+50
View File
@@ -25,3 +25,53 @@ endif()
include(GoogleTest) include(GoogleTest)
gtest_discover_tests(ObjectLifetimeIdTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(ObjectLifetimeIdTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
add_executable(
RenderStateTest
RenderStateTest.cpp
)
target_include_directories(RenderStateTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/3rdparty/xxHash
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
${MGL_ROOT}/3rdparty/SPIRV-Reflect
)
target_link_libraries(
RenderStateTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(RenderStateTest PRIVATE /Zc:preprocessor)
endif()
gtest_discover_tests(RenderStateTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
add_executable(
NegativeApiErrorsTest
NegativeApiErrorsTest.cpp
)
target_include_directories(NegativeApiErrorsTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/3rdparty/xxHash
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
${MGL_ROOT}/3rdparty/SPIRV-Reflect
)
target_link_libraries(
NegativeApiErrorsTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(NegativeApiErrorsTest PRIVATE /Zc:preprocessor)
endif()
gtest_discover_tests(NegativeApiErrorsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -0,0 +1,304 @@
// MobileGL - MobileGL/MG_Test/State/NegativeApiErrorsTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The negative-path GL errors the conformance suite checks and MobileGL used to answer
// GL_NO_ERROR to. Every row here is a call the spec requires to fail, lifted from the CTS case
// that found it:
// * KHR-GL44.multi_bind.errors_bind_buffers / .errors_bind_samplers - ARB_multi_bind's
// "buffers/samplers will not be created if they do not exist" rule, plus the atomic-counter
// offset alignment the single-bind path never had.
// * KHR-GL43.shader_storage_buffer_object.negative-api-bind - the SSBO offset alignment is a
// property of the binding point and applies with buffer 0 too.
// * KHR-GL46.indirect_parameters_tests.MultiDraw{Arrays,Elements}IndirectCount - the three
// errors that guard a parameter-buffer draw.
// * KHR-GL43.compute_shader.api-indirect / .api-program.
// * KHR-GLxx.texture_storage.compressed_data - compressed formats on TEXTURE_3D.
// Plus the indexed-getter parity RC-7b is about: glGetBooleani_v / glGetInteger64i_v /
// glGetFloati_v / glGetDoublei_v must answer every pname glGetIntegeri_v answers.
//
// GPU-free: all of it is frontend validation.
#include <gtest/gtest.h>
#include <functional>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Impl/GLImpl/Buffer/GL_Buffer.h>
#include <MG_Impl/GLImpl/Drawing/GL_Drawing.h>
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <MG_Impl/GLImpl/Program/GL_Program.h>
#include <MG_Impl/GLImpl/RenderState/GL_RenderState.h>
#include <MG_Impl/GLImpl/Sampler/GL_Sampler.h>
#include <MG_Impl/GLImpl/Texture/GL_Texture.h>
#include <MG_Impl/GLImpl/VertexArray/GL_VertexArray.h>
#include <MG_State/GLState/Core.h>
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
namespace {
class NegativeApiErrorsTest : public ::testing::Test {
protected:
void SetUp() override {
MobileGL::Initialize();
MG_State::pGLContext = MakeUnique<MG_State::GLState::GLContext>();
}
void TearDown() override {
EXPECT_EQ(GetError(), GL_NO_ERROR) << "test left an unconsumed GL error behind";
}
static void DrainErrors() {
for (int i = 0; i < 16 && GetError() != GL_NO_ERROR; ++i) {
}
}
static GLuint MakeBuffer(GLenum target, GLsizeiptr size) {
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(target, buffer);
BufferData(target, size, nullptr, GL_STATIC_DRAW);
return buffer;
}
// One table row: run the call, assert exactly the expected error, leave nothing pending.
struct Row {
const char* what;
std::function<void()> call;
GLenum expected;
};
static void RunRows(const std::vector<Row>& rows) {
for (const Row& row : rows) {
DrainErrors();
row.call();
EXPECT_EQ(GetError(), row.expected) << row.what;
DrainErrors();
}
}
};
TEST_F(NegativeApiErrorsTest, MultiBindRejectsNamesThatAreNotObjectsYet) {
const GLuint buffer = MakeBuffer(GL_UNIFORM_BUFFER, 1024);
// Reserved by glGenBuffers but never turned into an object: legal for glBindBuffer,
// which creates it, and illegal for glBindBuffersBase, which must not.
GLuint reservedOnly = 0;
GenBuffers(1, &reservedOnly);
ASSERT_NE(reservedOnly, 0u);
ASSERT_EQ(IsBuffer(reservedOnly), GL_FALSE);
// glGenSamplers, unlike glGenBuffers, creates the objects outright, so a sampler name is
// only "not an existing object" once it has been deleted.
GLuint deadSampler = 0;
GenSamplers(1, &deadSampler);
ASSERT_NE(deadSampler, 0u);
DeleteSamplers(1, &deadSampler);
DrainErrors();
const GLuint mixedBuffers[2] = {buffer, reservedOnly};
const GLuint samplers[1] = {deadSampler};
const GLintptr offsets[2] = {0, 0};
const GLsizeiptr sizes[2] = {256, 256};
RunRows({
{"glBindBuffersBase with a reserved-but-uncreated name",
[&] { BindBuffersBase(GL_UNIFORM_BUFFER, 0, 2, mixedBuffers); }, GL_INVALID_OPERATION},
{"glBindBuffersRange with a reserved-but-uncreated name",
[&] { BindBuffersRange(GL_UNIFORM_BUFFER, 0, 2, mixedBuffers, offsets, sizes); },
GL_INVALID_OPERATION},
{"glBindSamplers with a deleted sampler name", [&] { BindSamplers(0, 1, samplers); },
GL_INVALID_OPERATION},
});
// ARB_multi_bind defines these as a LOOP of single binds, so the bad entry costs its own
// binding point and the good one still binds - only the error is new.
GLint bound = -1;
GetIntegeri_v(GL_UNIFORM_BUFFER_BINDING, 0, &bound);
EXPECT_EQ(static_cast<GLuint>(bound), buffer) << "a rejected element must not take the valid ones with it";
GetIntegeri_v(GL_UNIFORM_BUFFER_BINDING, 1, &bound);
EXPECT_EQ(bound, 0) << "the rejected element must not have bound anything";
DrainErrors();
}
TEST_F(NegativeApiErrorsTest, BufferRangeOffsetAlignmentAppliesToTheBindingPoint) {
GLint ssboAlignment = 0;
GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &ssboAlignment);
ASSERT_GT(ssboAlignment, 1) << "the alignment rule is untestable at alignment 1";
const GLuint atomicBuffer = MakeBuffer(GL_ATOMIC_COUNTER_BUFFER, 1024);
DrainErrors();
RunRows({
// buffer 0 detaches the binding point, but the target's alignment rule still holds.
{"glBindBufferRange(SHADER_STORAGE_BUFFER, buffer 0, misaligned offset)",
[&] { BindBufferRange(GL_SHADER_STORAGE_BUFFER, 0, 0, ssboAlignment - 1, 0); }, GL_INVALID_VALUE},
// An atomic counter binding is addressed in 32-bit counters; it has no queryable
// alignment pname, which is how its rule went missing.
{"glBindBufferRange(ATOMIC_COUNTER_BUFFER, offset 3)",
[&] { BindBufferRange(GL_ATOMIC_COUNTER_BUFFER, 0, atomicBuffer, 3, 16); }, GL_INVALID_VALUE},
});
// ...and the aligned form still works.
DrainErrors();
BindBufferRange(GL_ATOMIC_COUNTER_BUFFER, 0, atomicBuffer, 4, 16);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
TEST_F(NegativeApiErrorsTest, DispatchComputeIndirectChecksTheBoundBufferExtent) {
// Six uints: an indirect dispatch reads three, so offset 16 runs off the end.
const GLuint dispatchBuffer = MakeBuffer(GL_DISPATCH_INDIRECT_BUFFER, 6 * sizeof(GLuint));
DrainErrors();
RunRows({
{"glDispatchComputeIndirect(-2)", [] { DispatchComputeIndirect(-2); }, GL_INVALID_VALUE},
{"glDispatchComputeIndirect(3)", [] { DispatchComputeIndirect(3); }, GL_INVALID_VALUE},
{"glDispatchComputeIndirect(16) past the end of a 24-byte buffer",
[] { DispatchComputeIndirect(16); }, GL_INVALID_OPERATION},
{"glDispatchComputeIndirect(0) with nothing bound",
[&] {
BindBuffer(GL_DISPATCH_INDIRECT_BUFFER, 0);
DispatchComputeIndirect(0);
},
GL_INVALID_OPERATION},
});
static_cast<void>(dispatchBuffer);
}
TEST_F(NegativeApiErrorsTest, IndirectParameterDrawsCheckBothBuffers) {
// Two DrawArraysIndirectCommands (16 bytes each) and a roomy parameter buffer.
MakeBuffer(GL_DRAW_INDIRECT_BUFFER, 2 * 4 * sizeof(GLuint));
const GLuint parameterBuffer = MakeBuffer(GL_PARAMETER_BUFFER, 200);
DrainErrors();
RunRows({
{"glMultiDrawArraysIndirectCount with drawcount 2 (not a multiple of four)",
[] { MultiDrawArraysIndirectCount(GL_TRIANGLE_STRIP, nullptr, 2, 1, 0); }, GL_INVALID_VALUE},
{"glMultiDrawArraysIndirectCount with maxdrawcount past the indirect buffer",
[] { MultiDrawArraysIndirectCount(GL_TRIANGLE_STRIP, nullptr, 0, 4, 0); }, GL_INVALID_OPERATION},
{"glMultiDrawElementsIndirectCount with drawcount 2",
[] { MultiDrawElementsIndirectCount(GL_TRIANGLE_STRIP, GL_UNSIGNED_BYTE, nullptr, 2, 1, 0); },
GL_INVALID_VALUE},
{"glMultiDrawArraysIndirectCount with no parameter buffer bound",
[&] {
BindBuffer(GL_PARAMETER_BUFFER, 0);
MultiDrawArraysIndirectCount(GL_TRIANGLE_STRIP, nullptr, 0, 2, 0);
},
GL_INVALID_OPERATION},
});
static_cast<void>(parameterBuffer);
}
TEST_F(NegativeApiErrorsTest, TexStorage3DRejectsCompressedFormatsOnTexture3D) {
GLuint texture = 0;
GenTextures(1, &texture);
BindTexture(GL_TEXTURE_3D, texture);
DrainErrors();
RunRows({
{"glTexStorage3D(TEXTURE_3D, GL_COMPRESSED_RED_RGTC1)",
[] { TexStorage3D(GL_TEXTURE_3D, 1, 0x8DBB /* GL_COMPRESSED_RED_RGTC1 */, 8, 8, 8); },
GL_INVALID_OPERATION},
{"glTexStorage3D(TEXTURE_3D, GL_COMPRESSED_RG_RGTC2)",
[] { TexStorage3D(GL_TEXTURE_3D, 1, 0x8DBD /* GL_COMPRESSED_RG_RGTC2 */, 8, 8, 8); },
GL_INVALID_OPERATION},
});
// An uncompressed sized format on the same target still allocates.
DrainErrors();
TexStorage3D(GL_TEXTURE_3D, 1, GL_RGBA8, 8, 8, 8);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
TEST_F(NegativeApiErrorsTest, LinkRejectsAComputeAndNonComputeMix) {
const auto attach = [](GLuint program, GLenum stage, const char* source) {
const GLuint shader = CreateShader(stage);
ShaderSource(shader, 1, &source, nullptr);
CompileShader(shader);
AttachShader(program, shader);
};
const GLuint program = CreateProgram();
attach(program, GL_COMPUTE_SHADER, R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430) buffer Output { uint g_output[]; };
void main() { g_output[gl_GlobalInvocationID.x] = 0; }
)");
attach(program, GL_VERTEX_SHADER, R"(#version 430 core
layout(location = 0) in vec4 g_position;
void main() { gl_Position = g_position; }
)");
attach(program, GL_FRAGMENT_SHADER, R"(#version 430 core
layout(location = 0) out vec4 g_color;
void main() { g_color = vec4(1); }
)");
LinkProgram(program);
GLint status = GL_TRUE;
GetProgramiv(program, GL_LINK_STATUS, &status);
EXPECT_EQ(status, GL_FALSE) << "a compute shader must not link with any other stage";
DrainErrors();
}
// RC-7b: the four non-int indexed getters have to answer the same pname table glGetIntegeri_v
// does. glGetBooleani_v used to route everything through the indexed-capability path
// (GL_INVALID_ENUM for anything else) and glGetInteger64i_v straight to the driver, which
// does not have MobileGL's frontend-only values at all.
TEST_F(NegativeApiErrorsTest, IndexedGettersAgreeWithGetIntegeriv) {
DrainErrors();
const GLenum pnames[] = {GL_MAX_COMPUTE_WORK_GROUP_COUNT, GL_MAX_COMPUTE_WORK_GROUP_SIZE};
for (GLenum pname : pnames) {
for (GLuint index = 0; index < 3; ++index) {
GLint reference = -1;
GetIntegeri_v(pname, index, &reference);
ASSERT_EQ(GetError(), GL_NO_ERROR) << "glGetIntegeri_v(" << pname << ", " << index << ")";
ASSERT_GT(reference, 0) << "the reference value has to be non-trivial to compare against";
GLint64 as64 = -1;
GetInteger64i_v(pname, index, &as64);
EXPECT_EQ(as64, static_cast<GLint64>(reference)) << "glGetInteger64i_v(" << pname << ")";
EXPECT_EQ(GetError(), GL_NO_ERROR);
GLfloat asFloat = -1.0f;
GetFloati_v(pname, index, &asFloat);
EXPECT_FLOAT_EQ(asFloat, static_cast<GLfloat>(reference)) << "glGetFloati_v(" << pname << ")";
EXPECT_EQ(GetError(), GL_NO_ERROR);
GLdouble asDouble = -1.0;
GetDoublei_v(pname, index, &asDouble);
EXPECT_DOUBLE_EQ(asDouble, static_cast<GLdouble>(reference)) << "glGetDoublei_v(" << pname << ")";
EXPECT_EQ(GetError(), GL_NO_ERROR);
GLboolean asBool = GL_FALSE;
GetBooleani_v(pname, index, &asBool);
EXPECT_EQ(asBool, GL_TRUE) << "glGetBooleani_v(" << pname << ")";
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
}
}
// ...and the vertex-binding offset keeps its 64-bit width through glGetInteger64i_v, which is
// how KHR-GL4x.vertex_attrib_binding reads it.
TEST_F(NegativeApiErrorsTest, VertexBindingOffsetIsReadableThroughTheSixtyFourBitGetter) {
GLuint vao = 0;
GenVertexArrays(1, &vao);
BindVertexArray(vao);
const GLuint vbo = MakeBuffer(GL_ARRAY_BUFFER, 4096);
DrainErrors();
GLint64 offset = -1;
GetInteger64i_v(GL_VERTEX_BINDING_OFFSET, 0, &offset);
EXPECT_EQ(offset, 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
BindVertexBuffer(0, vbo, 2048, 128);
GetInteger64i_v(GL_VERTEX_BINDING_OFFSET, 0, &offset);
EXPECT_EQ(offset, 2048);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
} // namespace
@@ -0,0 +1,91 @@
// MobileGL - MobileGL/MG_Test/State/RenderStateTest.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
//
// Indexed capability state (glEnablei/glDisablei/glIsEnabledi) exists only for GL_BLEND in this
// stack. Every other capability must come back as GL_INVALID_ENUM per GL 4.6 sec. 17.3.3 - and,
// far more importantly, must come back at all: RenderState::SetCapabilityIndexed and
// IsCapabilityEnabledIndexed used to answer a non-blend capability with THROW_UNIMPL_EXCEPTION,
// which unwinds a C++ exception through the C GL ABI and terminates the process.
#include <gtest/gtest.h>
#include "Includes.h"
#include "Init.h"
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <MG_Impl/GLImpl/RenderState/GL_RenderState.h>
#include <MG_State/GLState/Core.h>
#include <MG_State/GLState/FramebufferState/FramebufferObject.h>
using namespace MobileGL;
namespace {
class RenderStateTest: public ::testing::Test {
protected:
// GL error flags are sticky per code and the context outlives an individual test in this
// binary, so a pending error from an earlier case would be handed to the next GetError().
static void DrainPendingGlErrors() {
for (Int drained = 0; drained < 16 && MG_Impl::GLImpl::GetError() != GL_NO_ERROR; ++drained) {
}
}
static void ExpectSingleGlError(GLenum expected) {
EXPECT_EQ(MG_Impl::GLImpl::GetError(), expected);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "the call recorded more than one error";
}
void SetUp() override {
MobileGL::Initialize();
DrainPendingGlErrors();
}
void TearDown() override {
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "test left an unconsumed GL error behind";
}
};
} // namespace
TEST_F(RenderStateTest, IndexedCapabilityTogglesRejectNonBlendCapabilities) {
// GL_CLIP_DISTANCE0 is a real capability, just not an indexed one - the shape an application or
// a CTS negative test would hit.
for (const GLenum cap : {GL_CLIP_DISTANCE0, GL_DEPTH_TEST, GL_SCISSOR_TEST}) {
MG_Impl::GLImpl::Enablei(cap, 0);
ExpectSingleGlError(GL_INVALID_ENUM);
MG_Impl::GLImpl::Disablei(cap, 0);
ExpectSingleGlError(GL_INVALID_ENUM);
EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(cap, 0), GL_FALSE);
ExpectSingleGlError(GL_INVALID_ENUM);
}
}
TEST_F(RenderStateTest, IndexedCapabilityTogglesRejectAnOutOfRangeBufferIndex) {
const GLuint outOfRange = MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS;
MG_Impl::GLImpl::Enablei(GL_BLEND, outOfRange);
ExpectSingleGlError(GL_INVALID_VALUE);
MG_Impl::GLImpl::Disablei(GL_BLEND, outOfRange);
ExpectSingleGlError(GL_INVALID_VALUE);
EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(GL_BLEND, outOfRange), GL_FALSE);
ExpectSingleGlError(GL_INVALID_VALUE);
}
TEST_F(RenderStateTest, IndexedBlendTogglesStillWork) {
// The rejection path must not have cost the one capability that is genuinely indexed.
MG_Impl::GLImpl::Enablei(GL_BLEND, 1);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(GL_BLEND, 1), GL_TRUE);
MG_Impl::GLImpl::Disablei(GL_BLEND, 1);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(GL_BLEND, 1), GL_FALSE);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
+112
View File
@@ -3176,3 +3176,115 @@ TEST_F(TextureTest, WidenedRenderTargetUploadExpandsThreeChannelDataWithOpaqueAl
EXPECT_EQ(PrepareChannelWidenedUpload(3, texelSize, nullptr, 0, GL_FLOAT, widened), nullptr); EXPECT_EQ(PrepareChannelWidenedUpload(3, texelSize, nullptr, 0, GL_FLOAT, widened), nullptr);
} }
} }
// ---------------------------------------------------------------------------------------------
// A GL entry point may return an error, but it may never throw through the C GL ABI: unwinding a
// C++ exception across it terminates the process. These cover the sites that used to do exactly
// that (KHR-GL30.api.coverage died on the first of them on both backends).
// ---------------------------------------------------------------------------------------------
namespace {
struct CopyTexImage2DCall {
Bool Called = false;
GLenum Target = 0;
GLint Level = 0;
GLenum InternalFormat = 0;
GLsizei Width = 0;
GLsizei Height = 0;
};
CopyTexImage2DCall g_copyTexImage2DCall;
void RecordCopyTexImage2D(GLenum target, GLint level, GLenum internalformat, GLint, GLint, GLsizei width,
GLsizei height, GLint) {
g_copyTexImage2DCall = {true, target, level, internalformat, width, height};
}
// A colour read framebuffer of the requested sized format, bound to GL_READ_FRAMEBUFFER, which
// is what glCopyTexImage2D takes its source base format from.
void BindReadFramebufferWithColorFormat(GLenum sizedInternalFormat) {
GLuint framebuffer = 0;
GLuint texture = 0;
MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer);
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &texture);
MG_Impl::GLImpl::TextureStorage2D(texture, 1, sizedInternalFormat, 16, 16);
MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, texture, 0);
MG_Impl::GLImpl::BindFramebuffer(GL_READ_FRAMEBUFFER, framebuffer);
}
GLuint BindFreshMutableTexture2D() {
GLuint texture = 0;
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &texture);
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, texture);
return texture;
}
} // namespace
TEST_F(TextureTest, CopyTexImage2DAcceptsEveryComponentSubsetOfTheReadBuffer) {
const ScopedTextureBackendFunctionsOverride backendGuard;
MG_Backend::gBackendFunctionsTable.GL.CopyTexImage2D = RecordCopyTexImage2D;
BindReadFramebufferWithColorFormat(GL_RGBA8);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "read framebuffer setup itself failed";
// GL 4.6 sec. 8.6: internalformat may name a SUBSET of the read buffer's components. This is
// exactly the list KHR-GL30.api.coverage walks against an rgba8888 colour buffer, and it is
// also what an ordinary GL app does with glCopyTexImage2D(GL_RGB) from an RGBA8 framebuffer.
for (const GLenum internalFormat : {GL_RED, GL_RG, GL_RGB, GL_RGBA}) {
BindFreshMutableTexture2D();
g_copyTexImage2DCall = {};
MG_Impl::GLImpl::CopyTexImage2D(GL_TEXTURE_2D, 0, internalFormat, 0, 0, 1, 1, 0);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "internalformat " << internalFormat;
EXPECT_TRUE(g_copyTexImage2DCall.Called) << "internalformat " << internalFormat;
EXPECT_EQ(g_copyTexImage2DCall.InternalFormat, internalFormat);
EXPECT_EQ(g_copyTexImage2DCall.Width, 1);
EXPECT_EQ(g_copyTexImage2DCall.Height, 1);
}
}
TEST_F(TextureTest, CopyTexImage2DRejectsAFormatTheReadBufferCannotSupply) {
const ScopedTextureBackendFunctionsOverride backendGuard;
MG_Backend::gBackendFunctionsTable.GL.CopyTexImage2D = RecordCopyTexImage2D;
BindReadFramebufferWithColorFormat(GL_R8);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "read framebuffer setup itself failed";
BindFreshMutableTexture2D();
g_copyTexImage2DCall = {};
// The subset rule still has a wrong side: GL_RGBA asks for components a GL_R8 read buffer does
// not have. That must be GL_INVALID_OPERATION and nothing else - not a throw, not silence.
MG_Impl::GLImpl::CopyTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 0, 0, 1, 1, 0);
ExpectSingleGlError(GL_INVALID_OPERATION);
EXPECT_FALSE(g_copyTexImage2DCall.Called) << "a rejected copy must not reach the backend";
}
TEST_F(TextureTest, CopyTexImage1DReportsUnsupportedInsteadOfTerminating) {
// 1D textures have no upload path in this stack; the entry point used to throw unconditionally.
MG_Impl::GLImpl::CopyTexImage1D(GL_TEXTURE_1D, 0, GL_RGBA, 0, 0, 1, 0);
ExpectSingleGlError(GL_INVALID_OPERATION);
}
TEST_F(TextureTest, GetTexLevelParameterOnBufferStorageReportsErrorInsteadOfTerminating) {
// TextureStorageType is {Mipmap, Buffer} and the level queries only answer out of a mipmap
// chain, so every glGetTexLevelParameter* on a GL_TEXTURE_BUFFER texture reached a
// THROW_UNIMPL_EXCEPTION default: label and killed the process.
GLuint texture = 0;
MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_BUFFER, 1, &texture);
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_BUFFER, texture);
MG_Impl::GLImpl::TexBuffer(GL_TEXTURE_BUFFER, GL_R8, 0);
DrainPendingGlErrors();
for (const GLenum pname : {GL_TEXTURE_WIDTH, GL_TEXTURE_HEIGHT, GL_TEXTURE_DEPTH}) {
GLint intParam = 0x20202020;
MG_Impl::GLImpl::GetTexLevelParameteriv(GL_TEXTURE_BUFFER, 0, pname, &intParam);
ExpectSingleGlError(GL_INVALID_OPERATION);
GLfloat floatParam = 12345.0f;
MG_Impl::GLImpl::GetTexLevelParameterfv(GL_TEXTURE_BUFFER, 0, pname, &floatParam);
ExpectSingleGlError(GL_INVALID_OPERATION);
}
}
@@ -16,5 +16,22 @@ target_link_libraries(
${LINK_LIBRARIES} ${LINK_LIBRARIES}
) )
add_executable(
VertexAttribBindingStateTest
VertexAttribBindingStateTest.cpp
)
target_include_directories(VertexAttribBindingStateTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
VertexAttribBindingStateTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
include(GoogleTest) include(GoogleTest)
gtest_discover_tests(VertexArrayTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(VertexArrayTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(VertexAttribBindingStateTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -0,0 +1,430 @@
// MobileGL - MobileGL/MG_Test/VertexArray/VertexAttribBindingStateTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The ARB_vertex_attrib_binding state model, replayed exactly as
// KHR-GL4x.vertex_attrib_binding.basic-state1/3/4 and .negative-* walk it
// (external/openglcts/modules/gl/gl4cVertexAttribBindingTests.cpp): after each mutation the
// ten per-attribute pnames and the four per-binding-point pnames are read back in full, which
// is what makes a single wrong field visible as itself instead of as a downstream render
// difference.
//
// Four defects are pinned here, all of them frontend-only (both backends reported them
// byte-identically):
// * VERTEX_BINDING_STRIDE defaulted to 0; the spec's initial value is 16.
// * The eager binding -> attribute resolve overwrote VERTEX_ATTRIB_ARRAY_STRIDE / _POINTER,
// which are legacy state only glVertexAttrib*Pointer may write.
// * glVertexAttribDivisor did not re-point the attribute at its own binding point, so a
// later resolve restored the old binding's divisor.
// * The binding entry points accepted the default vertex array (name 0) in a core profile.
//
// GPU-free: this is all GL object state, no backend is consulted.
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <Config.h>
#include <MG_Impl/GLImpl/Buffer/GL_Buffer.h>
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <MG_Impl/GLImpl/VertexArray/GL_VertexArray.h>
#include <MG_State/EGLState/Core.h>
#include <MG_State/GLState/Core.h>
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
namespace {
// Mirrors the CTS's VertexAttribState: the initial per-attribute state, mutated field by
// field as the sequence proceeds, and verified in full after every call.
struct AttribState {
explicit AttribState(GLuint attribIndex) : index(attribIndex), binding(attribIndex) {}
GLuint index = 0;
GLint enabled = 0;
GLint size = 4;
GLint stride = 0;
GLenum type = GL_FLOAT;
GLint normalized = 0;
GLint integer = 0;
GLint isLong = 0;
GLint divisor = 0;
GLuint pointer = 0;
GLuint bufferBinding = 0;
GLuint binding = 0;
GLint relativeOffset = 0;
void Verify(const char* where) const {
GLint p = -1;
GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_ENABLED, &p);
EXPECT_EQ(p, enabled) << where << ": ENABLED(" << index << ")";
GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_SIZE, &p);
EXPECT_EQ(p, size) << where << ": SIZE(" << index << ")";
GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_STRIDE, &p);
EXPECT_EQ(p, stride) << where << ": STRIDE(" << index << ")";
GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_TYPE, &p);
EXPECT_EQ(static_cast<GLenum>(p), type) << where << ": TYPE(" << index << ")";
GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_NORMALIZED, &p);
EXPECT_EQ(p, normalized) << where << ": NORMALIZED(" << index << ")";
GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_INTEGER, &p);
EXPECT_EQ(p, integer) << where << ": INTEGER(" << index << ")";
GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_LONG, &p);
EXPECT_EQ(p, isLong) << where << ": LONG(" << index << ")";
GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_DIVISOR, &p);
EXPECT_EQ(p, divisor) << where << ": DIVISOR(" << index << ")";
void* pp = nullptr;
GetVertexAttribPointerv(index, GL_VERTEX_ATTRIB_ARRAY_POINTER, &pp);
EXPECT_EQ(reinterpret_cast<uintptr_t>(pp), static_cast<uintptr_t>(pointer))
<< where << ": POINTER(" << index << ")";
GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_BUFFER_BINDING, &p);
EXPECT_EQ(static_cast<GLuint>(p), bufferBinding) << where << ": BUFFER_BINDING(" << index << ")";
GetVertexAttribiv(index, GL_VERTEX_ATTRIB_BINDING, &p);
EXPECT_EQ(static_cast<GLuint>(p), binding) << where << ": BINDING(" << index << ")";
GetVertexAttribiv(index, GL_VERTEX_ATTRIB_RELATIVE_OFFSET, &p);
EXPECT_EQ(p, relativeOffset) << where << ": RELATIVE_OFFSET(" << index << ")";
}
};
// Mirrors the CTS's VertexBindingState, initial stride 16 included.
struct BindingState {
explicit BindingState(GLuint bindingIndex) : index(bindingIndex) {}
GLuint index = 0;
GLuint buffer = 0;
GLint offset = 0;
GLint stride = 16;
GLint divisor = 0;
void Verify(const char* where) const {
GLint p = -1;
GetIntegeri_v(GL_VERTEX_BINDING_BUFFER, index, &p);
EXPECT_EQ(static_cast<GLuint>(p), buffer) << where << ": VERTEX_BINDING_BUFFER(" << index << ")";
// The CTS reads the offset through glGetInteger64i_v; that entry point's pname
// routing is a separate defect with its own regression (see the indexed-getter
// parity test), so the state model is pinned through the 32-bit view here.
GetIntegeri_v(GL_VERTEX_BINDING_OFFSET, index, &p);
EXPECT_EQ(p, offset) << where << ": VERTEX_BINDING_OFFSET(" << index << ")";
GetIntegeri_v(GL_VERTEX_BINDING_STRIDE, index, &p);
EXPECT_EQ(p, stride) << where << ": VERTEX_BINDING_STRIDE(" << index << ")";
GetIntegeri_v(GL_VERTEX_BINDING_DIVISOR, index, &p);
EXPECT_EQ(p, divisor) << where << ": VERTEX_BINDING_DIVISOR(" << index << ")";
}
};
// Strict core rules only apply when the current EGL context explicitly asked for a core
// profile; the suite's default (no current context) is relaxed. RAII so a failed
// expectation cannot leave the context current for the rest of the binary.
struct ScopedCoreProfileContext {
ScopedCoreProfileContext() {
auto& egl = *MG_State::pEGLContext;
m_display = egl.GetDisplay(EGL_DEFAULT_DISPLAY);
EXPECT_NE(m_display, EGL_NO_DISPLAY);
EXPECT_TRUE(egl.InitializeDisplay(m_display, nullptr, nullptr));
EGLint configCount = 0;
EXPECT_TRUE(egl.ChooseConfig(m_display, nullptr, &m_config, 1, &configCount));
const EGLint surfaceAttribs[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
m_surface = egl.CreatePbufferSurface(m_display, m_config, surfaceAttribs);
EXPECT_NE(m_surface, EGL_NO_SURFACE);
const EGLint contextAttribs[] = {EGL_CONTEXT_MAJOR_VERSION,
3,
EGL_CONTEXT_MINOR_VERSION,
3,
EGL_CONTEXT_OPENGL_PROFILE_MASK,
EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT,
EGL_NONE};
m_context = egl.CreateContext(m_display, m_config, EGL_NO_CONTEXT, contextAttribs);
EXPECT_NE(m_context, EGL_NO_CONTEXT);
EXPECT_TRUE(egl.MakeCurrent(m_display, m_surface, m_surface, m_context));
}
~ScopedCoreProfileContext() {
auto& egl = *MG_State::pEGLContext;
egl.MakeCurrent(EGL_NO_DISPLAY, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (m_context != EGL_NO_CONTEXT) egl.DestroyContext(m_display, m_context);
if (m_surface != EGL_NO_SURFACE) egl.DestroySurface(m_display, m_surface);
}
ScopedCoreProfileContext(const ScopedCoreProfileContext&) = delete;
ScopedCoreProfileContext& operator=(const ScopedCoreProfileContext&) = delete;
private:
EGLDisplay m_display = EGL_NO_DISPLAY;
EGLConfig m_config = nullptr;
EGLSurface m_surface = EGL_NO_SURFACE;
MG_State::EGLState::EGLContext::EGLContextHandle m_context = EGL_NO_CONTEXT;
};
class VertexAttribBindingStateTest : public ::testing::Test {
protected:
void SetUp() override {
MobileGL::Initialize();
// A fresh context per case: the state model under test is cumulative, so a leftover
// VAO binding from a neighbour would silently change what "default state" means.
MG_State::pGLContext = MakeUnique<MG_State::GLState::GLContext>();
GenVertexArrays(1, &m_vao);
BindVertexArray(m_vao);
}
void TearDown() override {
EXPECT_EQ(GetError(), GL_NO_ERROR) << "test left an unconsumed GL error behind";
}
GLuint CreateVbo(GLsizeiptr size) {
GLuint vbo = 0;
GenBuffers(1, &vbo);
BindBuffer(GL_ARRAY_BUFFER, vbo);
BufferData(GL_ARRAY_BUFFER, size, nullptr, GL_DYNAMIC_COPY);
BindBuffer(GL_ARRAY_BUFFER, 0);
return vbo;
}
static void DrainErrors() {
for (int i = 0; i < 16 && GetError() != GL_NO_ERROR; ++i) {
}
}
GLuint m_vao = 0;
};
// basic-state1's opening block: the initial per-attribute mapping and the per-binding-point
// defaults, VERTEX_BINDING_STRIDE = 16 included. That check is the FIRST thing the CTS case
// does, so a wrong default masked everything the case would have found after it.
TEST_F(VertexAttribBindingStateTest, DefaultsMatchTheSpecInitialState) {
for (GLuint i = 0; i < 16; ++i) {
AttribState(i).Verify("defaults");
BindingState(i).Verify("defaults");
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// basic-state3, verbatim: a full separate-format sequence, then a pointer call, then a
// binding update on top of it. The legacy STRIDE/POINTER pair must stay untouched by every
// step except the glVertexAttribPointer one, and must survive the binding update after it.
TEST_F(VertexAttribBindingStateTest, SeparateFormatSequenceKeepsLegacyStrideAndPointerAtZero) {
const GLuint vbo0 = CreateVbo(10000);
const GLuint vbo1 = CreateVbo(10000);
const GLuint vbo2 = CreateVbo(10000);
ASSERT_EQ(GetError(), GL_NO_ERROR);
AttribState va0(0), va2(2), va15(15);
BindingState vb0(0), vb2(2), vb15(15);
VertexAttribFormat(0, 2, GL_BYTE, GL_TRUE, 16);
va0.size = 2;
va0.type = GL_BYTE;
va0.normalized = 1;
va0.relativeOffset = 16;
va0.Verify("after glVertexAttribFormat");
// The format call says nothing about a buffer, so binding point 0 keeps its defaults -
// stride 16 among them.
vb0.Verify("after glVertexAttribFormat");
VertexAttribIFormat(2, 3, GL_INT, 512);
va2.size = 3;
va2.type = GL_INT;
va2.integer = 1;
va2.relativeOffset = 512;
va2.Verify("after glVertexAttribIFormat");
vb2.Verify("after glVertexAttribIFormat");
BindVertexBuffer(0, vbo0, 2048, 128);
va0.bufferBinding = vbo0;
vb0.buffer = vbo0;
vb0.offset = 2048;
vb0.stride = 128;
va0.Verify("after glBindVertexBuffer(0)");
vb0.Verify("after glBindVertexBuffer(0)");
BindVertexBuffer(2, vbo2, 64, 256);
va2.bufferBinding = vbo2;
vb2.buffer = vbo2;
vb2.offset = 64;
vb2.stride = 256;
va2.Verify("after glBindVertexBuffer(2)");
vb2.Verify("after glBindVertexBuffer(2)");
// Attribute 2 moves onto binding 0 and takes that binding point's buffer with it.
VertexAttribBinding(2, 0);
va2.binding = 0;
va2.bufferBinding = vbo0;
va0.Verify("after glVertexAttribBinding(2,0)");
vb0.Verify("after glVertexAttribBinding(2,0)");
va2.Verify("after glVertexAttribBinding(2,0)");
vb2.Verify("after glVertexAttribBinding(2,0)");
VertexAttribBinding(0, 15);
va0.binding = 15;
va0.bufferBinding = 0;
va0.Verify("after glVertexAttribBinding(0,15)");
vb0.Verify("after glVertexAttribBinding(0,15)");
va15.Verify("after glVertexAttribBinding(0,15)");
vb15.Verify("after glVertexAttribBinding(0,15)");
BindVertexBuffer(15, vbo1, 16, 32);
va0.bufferBinding = vbo1;
va15.bufferBinding = vbo1;
vb15.buffer = vbo1;
vb15.offset = 16;
vb15.stride = 32;
va0.Verify("after glBindVertexBuffer(15)");
va15.Verify("after glBindVertexBuffer(15)");
vb15.Verify("after glBindVertexBuffer(15)");
// The one call that IS allowed to write the legacy pair - and it also re-points the
// attribute at its own binding point and rewrites that binding point.
BindBuffer(GL_ARRAY_BUFFER, vbo2);
VertexAttribPointer(0, 4, GL_UNSIGNED_BYTE, GL_FALSE, 8, reinterpret_cast<const void*>(640));
BindBuffer(GL_ARRAY_BUFFER, 0);
va0.size = 4;
va0.type = GL_UNSIGNED_BYTE;
va0.stride = 8;
va0.pointer = 640;
va0.relativeOffset = 0;
va0.normalized = 0;
va0.binding = 0;
va0.bufferBinding = vbo2;
vb0.buffer = vbo2;
vb0.offset = 640;
vb0.stride = 8;
va2.bufferBinding = vbo2;
va0.Verify("after glVertexAttribPointer");
vb0.Verify("after glVertexAttribPointer");
va2.Verify("after glVertexAttribPointer");
va15.Verify("after glVertexAttribPointer");
vb15.Verify("after glVertexAttribPointer");
// ...and a binding update on top of it leaves the legacy pair exactly where the pointer
// call left it. This is the assertion the eager resolve used to fail.
BindVertexBuffer(0, vbo1, 80, 24);
vb0.buffer = vbo1;
vb0.offset = 80;
vb0.stride = 24;
va0.bufferBinding = vbo1;
va2.bufferBinding = vbo1;
va0.Verify("after the trailing glBindVertexBuffer(0)");
vb0.Verify("after the trailing glBindVertexBuffer(0)");
va2.Verify("after the trailing glBindVertexBuffer(0)");
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// basic-state4: glVertexAttribDivisor is VertexAttribBinding(i,i) + VertexBindingDivisor(i,d),
// and glVertexBindingDivisor reaches the attribute's own DIVISOR query either way.
TEST_F(VertexAttribBindingStateTest, DivisorGoesThroughTheBindingPoint) {
for (GLuint i = 0; i < 16; ++i) {
AttribState va(i);
BindingState vb(i);
VertexAttribDivisor(i, i + 7);
va.divisor = static_cast<GLint>(i + 7);
vb.divisor = static_cast<GLint>(i + 7);
va.Verify("after glVertexAttribDivisor");
vb.Verify("after glVertexAttribDivisor");
}
for (GLuint i = 0; i < 16; ++i) {
AttribState va(i);
BindingState vb(i);
VertexBindingDivisor(i, i);
va.divisor = static_cast<GLint>(i);
vb.divisor = static_cast<GLint>(i);
va.Verify("after glVertexBindingDivisor");
vb.Verify("after glVertexBindingDivisor");
}
// Attribute 2 moves onto binding 5 and inherits binding 5's divisor; binding 2 keeps its
// own.
VertexAttribBinding(2, 5);
AttribState va5(5);
va5.divisor = 5;
BindingState vb5(5);
vb5.divisor = 5;
AttribState va2(2);
va2.divisor = 5;
va2.binding = 5;
BindingState vb2(2);
vb2.divisor = 2;
va5.Verify("after glVertexAttribBinding(2,5)");
vb5.Verify("after glVertexAttribBinding(2,5)");
va2.Verify("after glVertexAttribBinding(2,5)");
vb2.Verify("after glVertexAttribBinding(2,5)");
// ...and glVertexAttribDivisor pulls it back onto binding 2. Guarding the write on
// "binding already == index" left the attribute on binding 5 and threw the divisor away.
VertexAttribDivisor(2, 23);
va2.binding = 2;
va2.divisor = 23;
vb2.divisor = 23;
va5.Verify("after glVertexAttribDivisor(2,23)");
vb5.Verify("after glVertexAttribDivisor(2,23)");
va2.Verify("after glVertexAttribDivisor(2,23)");
vb2.Verify("after glVertexAttribDivisor(2,23)");
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The tail of every negative-* case: with the default vertex array bound, a core profile
// rejects all four binding entry points.
TEST_F(VertexAttribBindingStateTest, BindingApiRejectsTheDefaultVertexArrayInCoreProfile) {
ScopedCoreProfileContext coreContext;
ASSERT_FALSE(MG_State::IsRelaxedSemanticsActive());
DrainErrors();
BindVertexArray(0);
ASSERT_EQ(GetError(), GL_NO_ERROR);
BindVertexBuffer(0, 7, 0, 12);
EXPECT_EQ(GetError(), GL_INVALID_OPERATION) << "glBindVertexBuffer";
VertexAttribFormat(0, 4, GL_FLOAT, GL_FALSE, 0);
EXPECT_EQ(GetError(), GL_INVALID_OPERATION) << "glVertexAttribFormat";
VertexAttribIFormat(0, 4, GL_INT, 0);
EXPECT_EQ(GetError(), GL_INVALID_OPERATION) << "glVertexAttribIFormat";
VertexAttribBinding(0, 0);
EXPECT_EQ(GetError(), GL_INVALID_OPERATION) << "glVertexAttribBinding";
VertexBindingDivisor(0, 1);
EXPECT_EQ(GetError(), GL_INVALID_OPERATION) << "glVertexBindingDivisor";
BindVertexArray(m_vao);
DrainErrors();
}
// ...and the relaxed default - which is what every context that never asked for a core
// profile gets - keeps accepting them, because applications depend on it.
TEST_F(VertexAttribBindingStateTest, BindingApiStillAcceptsTheDefaultVertexArrayWhenRelaxed) {
ASSERT_TRUE(MG_State::IsRelaxedSemanticsActive());
const GLuint vbo = CreateVbo(1024);
DrainErrors();
BindVertexArray(0);
BindVertexBuffer(0, vbo, 0, 12);
EXPECT_EQ(GetError(), GL_NO_ERROR) << "glBindVertexBuffer under relaxed semantics";
VertexAttribFormat(0, 4, GL_FLOAT, GL_FALSE, 0);
EXPECT_EQ(GetError(), GL_NO_ERROR) << "glVertexAttribFormat under relaxed semantics";
VertexAttribBinding(0, 0);
EXPECT_EQ(GetError(), GL_NO_ERROR) << "glVertexAttribBinding under relaxed semantics";
VertexBindingDivisor(0, 1);
EXPECT_EQ(GetError(), GL_NO_ERROR) << "glVertexBindingDivisor under relaxed semantics";
BindVertexArray(m_vao);
DrainErrors();
}
// MOBILEGL_RELAXED_SEMANTICS wins even on an explicit core-profile context.
TEST_F(VertexAttribBindingStateTest, RelaxedSemanticsOverrideReopensTheDefaultVertexArray) {
ScopedCoreProfileContext coreContext;
const Bool saved = MG_Config::Features.RelaxedSemantics;
MG_Config::Features.RelaxedSemantics = true;
const GLuint vbo = CreateVbo(1024);
DrainErrors();
BindVertexArray(0);
BindVertexBuffer(0, vbo, 0, 12);
EXPECT_EQ(GetError(), GL_NO_ERROR);
BindVertexArray(m_vao);
MG_Config::Features.RelaxedSemantics = saved;
DrainErrors();
}
} // namespace
@@ -10,9 +10,20 @@
#include <Config.h> #include <Config.h>
#include <cmath> #include <cmath>
#include <limits>
namespace MobileGL::MG_Util::BackendLoader { namespace MobileGL::MG_Util::BackendLoader {
namespace { namespace {
// A Vulkan limit is an unsigned 32-bit count; a GL limit is a signed Int. Drivers do report
// values with the top bit set (UINT32_MAX is the idiomatic "effectively unlimited"), and a
// plain static_cast turned those into small negatives - which every downstream std::min or
// ceiling comparison then accepted as "already small enough". Saturate instead, so a clamp
// above this can be trusted to be the only thing that lowers a limit.
Int SaturateToInt(Uint32 value) {
constexpr Uint32 kMaxInt = static_cast<Uint32>(std::numeric_limits<Int>::max());
return static_cast<Int>(std::min<Uint32>(value, kMaxInt));
}
struct VulkanDynamicFunctions { struct VulkanDynamicFunctions {
PFN_vkGetPhysicalDeviceProperties vkGetPhysicalDeviceProperties = nullptr; PFN_vkGetPhysicalDeviceProperties vkGetPhysicalDeviceProperties = nullptr;
PFN_vkGetPhysicalDeviceProperties2 vkGetPhysicalDeviceProperties2 = nullptr; PFN_vkGetPhysicalDeviceProperties2 vkGetPhysicalDeviceProperties2 = nullptr;
@@ -152,47 +163,47 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.PointSizeRangeMin = p.limits.pointSizeRange[0]; caps.PointSizeRangeMin = p.limits.pointSizeRange[0];
caps.PointSizeRangeMax = p.limits.pointSizeRange[1]; caps.PointSizeRangeMax = p.limits.pointSizeRange[1];
caps.PointSizeGranularity = p.limits.pointSizeGranularity; caps.PointSizeGranularity = p.limits.pointSizeGranularity;
caps.Max3DTextureSize = static_cast<Int>(p.limits.maxImageDimension3D); caps.Max3DTextureSize = SaturateToInt(p.limits.maxImageDimension3D);
caps.MaxArrayTextureLayers = static_cast<Int>(p.limits.maxImageArrayLayers); caps.MaxArrayTextureLayers = SaturateToInt(p.limits.maxImageArrayLayers);
caps.MaxCubeMapTextureSize = static_cast<Int>(p.limits.maxImageDimensionCube); caps.MaxCubeMapTextureSize = SaturateToInt(p.limits.maxImageDimensionCube);
caps.MaxFramebufferWidth = static_cast<Int>(p.limits.maxFramebufferWidth); caps.MaxFramebufferWidth = SaturateToInt(p.limits.maxFramebufferWidth);
caps.MaxFramebufferHeight = static_cast<Int>(p.limits.maxFramebufferHeight); caps.MaxFramebufferHeight = SaturateToInt(p.limits.maxFramebufferHeight);
caps.MaxFramebufferLayers = static_cast<Int>(p.limits.maxFramebufferLayers); caps.MaxFramebufferLayers = SaturateToInt(p.limits.maxFramebufferLayers);
caps.MaxRenderbufferSize = ResolveMaxRenderbufferSize(p.limits); caps.MaxRenderbufferSize = ResolveMaxRenderbufferSize(p.limits);
caps.MaxTextureSize = static_cast<Int>(p.limits.maxImageDimension2D); caps.MaxTextureSize = SaturateToInt(p.limits.maxImageDimension2D);
caps.MaxColorTextureSamples = MaxSampleCountFromFlags(p.limits.sampledImageColorSampleCounts); caps.MaxColorTextureSamples = MaxSampleCountFromFlags(p.limits.sampledImageColorSampleCounts);
caps.MaxDepthTextureSamples = MaxSampleCountFromFlags(p.limits.sampledImageDepthSampleCounts); caps.MaxDepthTextureSamples = MaxSampleCountFromFlags(p.limits.sampledImageDepthSampleCounts);
caps.MaxFramebufferSamples = ResolveConservativeFramebufferSampleLimit(p.limits); caps.MaxFramebufferSamples = ResolveConservativeFramebufferSampleLimit(p.limits);
caps.MaxIntegerSamples = MaxSampleCountFromFlags(p.limits.sampledImageIntegerSampleCounts); caps.MaxIntegerSamples = MaxSampleCountFromFlags(p.limits.sampledImageIntegerSampleCounts);
caps.MaxSamples = caps.MaxFramebufferSamples; caps.MaxSamples = caps.MaxFramebufferSamples;
caps.MaxSampleMaskWords = static_cast<Int>(p.limits.maxSampleMaskWords); caps.MaxSampleMaskWords = SaturateToInt(p.limits.maxSampleMaskWords);
caps.MaxTextureImageUnits = static_cast<Int>(p.limits.maxPerStageDescriptorSampledImages); caps.MaxTextureImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorSampledImages);
caps.MaxVertexTextureImageUnits = static_cast<Int>(p.limits.maxPerStageDescriptorSampledImages); caps.MaxVertexTextureImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorSampledImages);
caps.MaxComputeTextureImageUnits = static_cast<Int>(p.limits.maxPerStageDescriptorSampledImages); caps.MaxComputeTextureImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorSampledImages);
caps.MaxCombinedTextureImageUnits = static_cast<Int>(p.limits.maxDescriptorSetSampledImages); caps.MaxCombinedTextureImageUnits = SaturateToInt(p.limits.maxDescriptorSetSampledImages);
caps.MaxVertexAttribs = static_cast<Int>(p.limits.maxVertexInputAttributes); caps.MaxVertexAttribs = SaturateToInt(p.limits.maxVertexInputAttributes);
caps.MaxComputeShaderStorageBlocks = static_cast<Int>(p.limits.maxPerStageDescriptorStorageBuffers); caps.MaxComputeShaderStorageBlocks = SaturateToInt(p.limits.maxPerStageDescriptorStorageBuffers);
caps.MaxCombinedShaderStorageBlocks = static_cast<Int>(p.limits.maxDescriptorSetStorageBuffers); caps.MaxCombinedShaderStorageBlocks = SaturateToInt(p.limits.maxDescriptorSetStorageBuffers);
caps.MaxComputeUniformBlocks = static_cast<Int>(p.limits.maxPerStageDescriptorUniformBuffers); caps.MaxComputeUniformBlocks = SaturateToInt(p.limits.maxPerStageDescriptorUniformBuffers);
caps.MaxComputeWorkGroupInvocations = static_cast<Int>(p.limits.maxComputeWorkGroupInvocations); caps.MaxComputeWorkGroupInvocations = SaturateToInt(p.limits.maxComputeWorkGroupInvocations);
caps.MaxShaderStorageBufferBindings = static_cast<Int>(p.limits.maxDescriptorSetStorageBuffers); caps.MaxShaderStorageBufferBindings = SaturateToInt(p.limits.maxDescriptorSetStorageBuffers);
caps.MaxTextureBufferSize = static_cast<Int>(p.limits.maxTexelBufferElements); caps.MaxTextureBufferSize = SaturateToInt(p.limits.maxTexelBufferElements);
caps.TextureBufferOffsetAlignment = caps.TextureBufferOffsetAlignment =
static_cast<Int>(std::max<VkDeviceSize>(1, p.limits.minTexelBufferOffsetAlignment)); static_cast<Int>(std::max<VkDeviceSize>(1, p.limits.minTexelBufferOffsetAlignment));
caps.MaxUniformBufferBindings = static_cast<Int>(p.limits.maxDescriptorSetUniformBuffers); caps.MaxUniformBufferBindings = SaturateToInt(p.limits.maxDescriptorSetUniformBuffers);
caps.MaxUniformBlockSize = static_cast<Int>(p.limits.maxUniformBufferRange); caps.MaxUniformBlockSize = SaturateToInt(p.limits.maxUniformBufferRange);
caps.MaxImageUnits = static_cast<Int>(p.limits.maxPerStageDescriptorStorageImages); caps.MaxImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorStorageImages);
caps.MaxCombinedImageUniforms = static_cast<Int>(p.limits.maxDescriptorSetStorageImages); caps.MaxCombinedImageUniforms = SaturateToInt(p.limits.maxDescriptorSetStorageImages);
caps.MaxComputeImageUniforms = static_cast<Int>(p.limits.maxPerStageDescriptorStorageImages); caps.MaxComputeImageUniforms = SaturateToInt(p.limits.maxPerStageDescriptorStorageImages);
caps.MaxDrawBuffers = static_cast<Int>(p.limits.maxFragmentOutputAttachments); caps.MaxDrawBuffers = SaturateToInt(p.limits.maxFragmentOutputAttachments);
caps.MaxColorAttachments = static_cast<Int>(p.limits.maxColorAttachments); caps.MaxColorAttachments = SaturateToInt(p.limits.maxColorAttachments);
caps.MaxClipDistances = static_cast<Int>(p.limits.maxClipDistances); caps.MaxClipDistances = SaturateToInt(p.limits.maxClipDistances);
caps.MaxViewports = static_cast<Int>(p.limits.maxViewports); caps.MaxViewports = SaturateToInt(p.limits.maxViewports);
caps.MaxViewportWidth = static_cast<Int>(p.limits.maxViewportDimensions[0]); caps.MaxViewportWidth = SaturateToInt(p.limits.maxViewportDimensions[0]);
caps.MaxViewportHeight = static_cast<Int>(p.limits.maxViewportDimensions[1]); caps.MaxViewportHeight = SaturateToInt(p.limits.maxViewportDimensions[1]);
caps.ViewportBoundsRangeMin = p.limits.viewportBoundsRange[0]; caps.ViewportBoundsRangeMin = p.limits.viewportBoundsRange[0];
caps.ViewportBoundsRangeMax = p.limits.viewportBoundsRange[1]; caps.ViewportBoundsRangeMax = p.limits.viewportBoundsRange[1];
caps.ViewportSubpixelBits = static_cast<Int>(p.limits.viewportSubPixelBits); caps.ViewportSubpixelBits = SaturateToInt(p.limits.viewportSubPixelBits);
FillFragmentInterpolationLimits(caps, p.limits); FillFragmentInterpolationLimits(caps, p.limits);
VkPhysicalDeviceFeatures supportedFeatures{}; VkPhysicalDeviceFeatures supportedFeatures{};
@@ -269,47 +280,47 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.PointSizeRangeMin = properties.limits.pointSizeRange[0]; caps.PointSizeRangeMin = properties.limits.pointSizeRange[0];
caps.PointSizeRangeMax = properties.limits.pointSizeRange[1]; caps.PointSizeRangeMax = properties.limits.pointSizeRange[1];
caps.PointSizeGranularity = properties.limits.pointSizeGranularity; caps.PointSizeGranularity = properties.limits.pointSizeGranularity;
caps.Max3DTextureSize = static_cast<Int>(properties.limits.maxImageDimension3D); caps.Max3DTextureSize = SaturateToInt(properties.limits.maxImageDimension3D);
caps.MaxArrayTextureLayers = static_cast<Int>(properties.limits.maxImageArrayLayers); caps.MaxArrayTextureLayers = SaturateToInt(properties.limits.maxImageArrayLayers);
caps.MaxCubeMapTextureSize = static_cast<Int>(properties.limits.maxImageDimensionCube); caps.MaxCubeMapTextureSize = SaturateToInt(properties.limits.maxImageDimensionCube);
caps.MaxFramebufferWidth = static_cast<Int>(properties.limits.maxFramebufferWidth); caps.MaxFramebufferWidth = SaturateToInt(properties.limits.maxFramebufferWidth);
caps.MaxFramebufferHeight = static_cast<Int>(properties.limits.maxFramebufferHeight); caps.MaxFramebufferHeight = SaturateToInt(properties.limits.maxFramebufferHeight);
caps.MaxFramebufferLayers = static_cast<Int>(properties.limits.maxFramebufferLayers); caps.MaxFramebufferLayers = SaturateToInt(properties.limits.maxFramebufferLayers);
caps.MaxRenderbufferSize = ResolveMaxRenderbufferSize(properties.limits); caps.MaxRenderbufferSize = ResolveMaxRenderbufferSize(properties.limits);
caps.MaxTextureSize = static_cast<Int>(properties.limits.maxImageDimension2D); caps.MaxTextureSize = SaturateToInt(properties.limits.maxImageDimension2D);
caps.MaxColorTextureSamples = MaxSampleCountFromFlags(properties.limits.sampledImageColorSampleCounts); caps.MaxColorTextureSamples = MaxSampleCountFromFlags(properties.limits.sampledImageColorSampleCounts);
caps.MaxDepthTextureSamples = MaxSampleCountFromFlags(properties.limits.sampledImageDepthSampleCounts); caps.MaxDepthTextureSamples = MaxSampleCountFromFlags(properties.limits.sampledImageDepthSampleCounts);
caps.MaxFramebufferSamples = ResolveConservativeFramebufferSampleLimit(properties.limits); caps.MaxFramebufferSamples = ResolveConservativeFramebufferSampleLimit(properties.limits);
caps.MaxIntegerSamples = MaxSampleCountFromFlags(properties.limits.sampledImageIntegerSampleCounts); caps.MaxIntegerSamples = MaxSampleCountFromFlags(properties.limits.sampledImageIntegerSampleCounts);
caps.MaxSamples = caps.MaxFramebufferSamples; caps.MaxSamples = caps.MaxFramebufferSamples;
caps.MaxSampleMaskWords = static_cast<Int>(properties.limits.maxSampleMaskWords); caps.MaxSampleMaskWords = SaturateToInt(properties.limits.maxSampleMaskWords);
caps.MaxTextureImageUnits = static_cast<Int>(properties.limits.maxPerStageDescriptorSampledImages); caps.MaxTextureImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorSampledImages);
caps.MaxVertexTextureImageUnits = static_cast<Int>(properties.limits.maxPerStageDescriptorSampledImages); caps.MaxVertexTextureImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorSampledImages);
caps.MaxComputeTextureImageUnits = static_cast<Int>(properties.limits.maxPerStageDescriptorSampledImages); caps.MaxComputeTextureImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorSampledImages);
caps.MaxCombinedTextureImageUnits = static_cast<Int>(properties.limits.maxDescriptorSetSampledImages); caps.MaxCombinedTextureImageUnits = SaturateToInt(properties.limits.maxDescriptorSetSampledImages);
caps.MaxVertexAttribs = static_cast<Int>(properties.limits.maxVertexInputAttributes); caps.MaxVertexAttribs = SaturateToInt(properties.limits.maxVertexInputAttributes);
caps.MaxComputeShaderStorageBlocks = static_cast<Int>(properties.limits.maxPerStageDescriptorStorageBuffers); caps.MaxComputeShaderStorageBlocks = SaturateToInt(properties.limits.maxPerStageDescriptorStorageBuffers);
caps.MaxCombinedShaderStorageBlocks = static_cast<Int>(properties.limits.maxDescriptorSetStorageBuffers); caps.MaxCombinedShaderStorageBlocks = SaturateToInt(properties.limits.maxDescriptorSetStorageBuffers);
caps.MaxComputeUniformBlocks = static_cast<Int>(properties.limits.maxPerStageDescriptorUniformBuffers); caps.MaxComputeUniformBlocks = SaturateToInt(properties.limits.maxPerStageDescriptorUniformBuffers);
caps.MaxComputeWorkGroupInvocations = static_cast<Int>(properties.limits.maxComputeWorkGroupInvocations); caps.MaxComputeWorkGroupInvocations = SaturateToInt(properties.limits.maxComputeWorkGroupInvocations);
caps.MaxShaderStorageBufferBindings = static_cast<Int>(properties.limits.maxDescriptorSetStorageBuffers); caps.MaxShaderStorageBufferBindings = SaturateToInt(properties.limits.maxDescriptorSetStorageBuffers);
caps.MaxTextureBufferSize = static_cast<Int>(properties.limits.maxTexelBufferElements); caps.MaxTextureBufferSize = SaturateToInt(properties.limits.maxTexelBufferElements);
caps.TextureBufferOffsetAlignment = caps.TextureBufferOffsetAlignment =
static_cast<Int>(std::max<VkDeviceSize>(1, properties.limits.minTexelBufferOffsetAlignment)); static_cast<Int>(std::max<VkDeviceSize>(1, properties.limits.minTexelBufferOffsetAlignment));
caps.MaxUniformBufferBindings = static_cast<Int>(properties.limits.maxDescriptorSetUniformBuffers); caps.MaxUniformBufferBindings = SaturateToInt(properties.limits.maxDescriptorSetUniformBuffers);
caps.MaxUniformBlockSize = static_cast<Int>(properties.limits.maxUniformBufferRange); caps.MaxUniformBlockSize = SaturateToInt(properties.limits.maxUniformBufferRange);
caps.MaxImageUnits = static_cast<Int>(properties.limits.maxPerStageDescriptorStorageImages); caps.MaxImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorStorageImages);
caps.MaxCombinedImageUniforms = static_cast<Int>(properties.limits.maxDescriptorSetStorageImages); caps.MaxCombinedImageUniforms = SaturateToInt(properties.limits.maxDescriptorSetStorageImages);
caps.MaxComputeImageUniforms = static_cast<Int>(properties.limits.maxPerStageDescriptorStorageImages); caps.MaxComputeImageUniforms = SaturateToInt(properties.limits.maxPerStageDescriptorStorageImages);
caps.MaxDrawBuffers = static_cast<Int>(properties.limits.maxFragmentOutputAttachments); caps.MaxDrawBuffers = SaturateToInt(properties.limits.maxFragmentOutputAttachments);
caps.MaxColorAttachments = static_cast<Int>(properties.limits.maxColorAttachments); caps.MaxColorAttachments = SaturateToInt(properties.limits.maxColorAttachments);
caps.MaxClipDistances = static_cast<Int>(properties.limits.maxClipDistances); caps.MaxClipDistances = SaturateToInt(properties.limits.maxClipDistances);
caps.MaxViewports = static_cast<Int>(properties.limits.maxViewports); caps.MaxViewports = SaturateToInt(properties.limits.maxViewports);
caps.MaxViewportWidth = static_cast<Int>(properties.limits.maxViewportDimensions[0]); caps.MaxViewportWidth = SaturateToInt(properties.limits.maxViewportDimensions[0]);
caps.MaxViewportHeight = static_cast<Int>(properties.limits.maxViewportDimensions[1]); caps.MaxViewportHeight = SaturateToInt(properties.limits.maxViewportDimensions[1]);
caps.ViewportBoundsRangeMin = properties.limits.viewportBoundsRange[0]; caps.ViewportBoundsRangeMin = properties.limits.viewportBoundsRange[0];
caps.ViewportBoundsRangeMax = properties.limits.viewportBoundsRange[1]; caps.ViewportBoundsRangeMax = properties.limits.viewportBoundsRange[1];
caps.ViewportSubpixelBits = static_cast<Int>(properties.limits.viewportSubPixelBits); caps.ViewportSubpixelBits = SaturateToInt(properties.limits.viewportSubPixelBits);
FillFragmentInterpolationLimits(caps, properties.limits); FillFragmentInterpolationLimits(caps, properties.limits);
caps.SupportsWideLines = false; caps.SupportsWideLines = false;
caps.SupportsShaderFloat64 = false; caps.SupportsShaderFloat64 = false;