Compare commits

...
11 Commits
Author SHA1 Message Date
swung0x48 12df061e0b [Test] (SelfTest): model the vertex-input-location and layered-blit defects in the fake driver and pin both probes' controls 2026-08-25 05:57:58 -04:00
swung0x48 d83a48da5c [Fix] (SelfTest): read the probe-saved texture bindings off the same unit the restore puts them back on 2026-08-25 05:40:08 -04:00
swung0x48 b3100b0de5 [Fix] (DirectGLES): take the layered-blit substitute's colour destination from the draw buffer that is actually enabled, and leave self-overlapping copies alone 2026-08-25 05:38:12 -04:00
swung0x48 1cde801a01 [Fix] (DirectGLES): blit onto a non-zero array layer with glCopyImageSubData where the driver ignores the destination layer 2026-08-25 05:27:46 -04:00
swung0x48 52c050131e [Fix] (DirectGLES): advertise the vertex attributes whose layout(location) the driver's compiler will actually accept 2026-08-25 05:21:50 -04:00
swung0x48 ebb8a4cebf [Fix] (DirectGLES, DirectVulkan): answer GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT with the storage limit, not the uniform one 2026-08-25 05:21:42 -04:00
swung0x48 5398fb4289 [Fix] (IntegrationTest): report the backend that actually comes up when MOBILEGL_BACKEND_TYPE is unset
HeadlessGL reported the literal "<unset>" as the backend name when the variable was
not set, while MG_ConfigLoader::InitBackendType defaults it to DirectGLES and brought
DirectGLES up. Every ctest entry sets the variable through its ENVIRONMENT property,
which is why nothing ever noticed - but run straight from an adb shell, where nothing
sets it, the name matched neither backend and every case gated on DirectGLES skipped
as though DirectGLES were not running. On an Adreno 830 that silently disabled the
gl_ViewportIndex emulation control and the DirectGLES-only attachment scenarios.
2026-08-25 04:52:21 -04:00
swung0x48 7f2ca68615 [Test] (IntegrationTest): take the shader-compiler and viewport quirks from the environment instead of internal symbols
Android links this module against the SHIPPING libMobileGL.so on purpose, so the
on-device run validates the real artifact - and that library is -fvisibility=hidden.
Six symbols the scenarios reached for were therefore undefined and the executable
could not be linked at all: MG_Config::Features, SetAsyncShaderCompileSuspended,
ShaderCompilePool::Get/GetThreadCount/SetMaxConcurrency and
AsyncShaderCompileEnabled. All six are gone rather than exported.

CompilerThreadScope now restores the pool with glMaxShaderCompilerThreadsKHR
(0xFFFFFFFF), which MaxShaderCompilerThreadsKHR_State defines as exactly the two
steps it used to perform by hand, and AsyncAndSyncProgramsRenderIdenticalFrames
picks its two modes with the same entry point - a zero count compiles inline, a
nonzero one lifts that - so the switching is now itself under test.

ExtensionStringMatchesTheConfiguration stops deriving its expectation from
AsyncShaderCompileEnabled(), the very function the backends gate the extension
string on: it was asserting the implementation against itself and would have passed
however wrong both halves were. The expectation is now MOBILEGL_ASYNC_SHADER_COMPILE
as the process inherited it, and the case skips where that is unset because the
built-in default is a value only the implementation knows.

Both-mode coverage in one ctest run is preserved by registration rather than by
in-process forcing, and is wider than before. New entries, each APPENDING to the
common/Vulkan environment so the EGL-vendor and ICD pinning is not lost - a ctest
ENVIRONMENT property replaces the job environment rather than adding to it:
DirectGLES./DirectVulkan.AsyncOn. run all of AsyncCompileScenario with
MOBILEGL_ASYNC_SHADER_COMPILE=1; .AsyncOff. run the extension case with =0, which
asserts the withdrawn side that nothing covered before;
.OptimisticShaderStatus. run the Iris-shaped case with
MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS=1 (its own entries because the quirk is not
neutral for the rest of the scenario); DirectGLES.NoViewportArrayEmulation. runs the
emulation control with MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION=0. Run from a device
shell with nothing set, the ambient configuration runs and the rest skip cleanly.

1836 -> 1853 ctest entries, all green.
2026-08-25 04:52:14 -04:00
swung0x48 b12ef4d717 [Test] (DirectVulkan): ask each unbound-descriptor case only for the limit its own resource kind needs 2026-08-25 03:44:36 -04:00
swung0x48 724755d9df [Test] (DirectVulkan): pin the image-uniform numeric domain the unbound placeholder is chosen from 2026-08-25 03:44:36 -04:00
swung0x48 59f7059bf4 [Fix, Test] (DirectVulkan): keep the draw when a program declares an image-backed resource the application left unbound 2026-08-24 05:31:15 -04:00
28 changed files with 2326 additions and 118 deletions
+6
View File
@@ -301,6 +301,12 @@ namespace MobileGL {
struct DynamicBackendParameters {
SizeT UniformBufferOffsetAlignment = 256;
// GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, which is a SEPARATE limit from the
// uniform one and is routinely larger: Adreno 830 reports 32 for uniform buffers and
// 64 for storage buffers. Answering the storage query with the uniform value let an
// application bind a storage range at an offset the driver cannot address, which it
// accepted without error and then wrote somewhere else entirely.
SizeT ShaderStorageBufferOffsetAlignment = 256;
// GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT. 1.0 means the backend cannot filter anisotropically,
// which is also why the extension is not advertised in that case.
Float MaxTextureMaxAnisotropy = 1.0f;
@@ -1323,6 +1323,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
void BackendObject_DirectGLES::UpdateDynamicBackendParameters() {
m_dynamicParameters.UniformBufferOffsetAlignment = m_GLESCapabilities.UniformBufferOffsetAlignment;
m_dynamicParameters.ShaderStorageBufferOffsetAlignment =
m_GLESCapabilities.ShaderStorageBufferOffsetAlignment;
m_dynamicParameters.MaxTextureMaxAnisotropy = m_GLESCapabilities.MaxTextureMaxAnisotropy;
m_dynamicParameters.AliasedLineWidthRangeMin = m_GLESCapabilities.AliasedLineWidthRangeMin;
m_dynamicParameters.AliasedLineWidthRangeMax = m_GLESCapabilities.AliasedLineWidthRangeMax;
+174 -2
View File
@@ -20,6 +20,7 @@
#include <MG_State/GLState/TextureState/TextureObjectBuffer.h>
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
#include <MG_Util/SelfTest/DriverBugProbes.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToGL/FramebufferEnumConverter.h>
@@ -5141,6 +5142,164 @@ namespace MobileGL::MG_Backend::DirectGLES {
DrainBlitErrors();
}
// ---- glBlitFramebuffer onto a non-zero array layer -------------------------------------
//
// Some drivers write to layer 0 whatever layer the DRAW framebuffer's
// glFramebufferTextureLayer attachment names, and raise no error doing it (Adreno 830;
// SelfTest::ProbeBlitIgnoresDestinationArrayLayer measures it, with the destination-layer-0
// case as the control). glCopyImageSubData takes the destination layer as an argument rather
// than reading it off an attachment, and honours it on the same driver - so a blit that is a
// plain 1:1 copy is issued that way instead.
//
// ONLY a plain 1:1 copy. glCopyImageSubData cannot scale, flip, convert format or resolve
// samples, and it is not clipped by the scissor, so every one of those is a reason to hand
// the call back to the driver rather than quietly perform a different operation. Those blits
// still land on the wrong layer; a once-per-process line says so rather than leaving it to be
// rediscovered.
//
// Per aspect, not all-or-nothing: the returned mask is the bits this performed itself, and
// the caller passes the rest to the driver. A COLOR|DEPTH blit whose colour half scales and
// whose depth half does not still gets its depth half repaired.
static GLbitfield BlitLayeredDestinationAspects(
const SharedPtr<MG_State::GLState::FramebufferObject>& readFramebuffer,
const SharedPtr<MG_State::GLState::FramebufferObject>& drawFramebuffer, GLint srcX0, GLint srcY0,
GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask) {
if (mask == 0 || !readFramebuffer || !drawFramebuffer) return 0;
if (!g_GLESFuncs.glCopyImageSubData) return 0;
if (!MG_Util::SelfTest::BlitIgnoresDestinationArrayLayer(g_GLESFuncs)) return 0;
// The default framebuffer has no layers to get wrong, and a blit between the two halves
// of the same framebuffer object is not a shape this substitutes for.
const Int width = srcX1 - srcX0;
const Int height = srcY1 - srcY0;
const Bool oneToOne = width > 0 && height > 0 && (dstX1 - dstX0) == width && (dstY1 - dstY0) == height;
// The scissor clips a blit and does not clip a copy, so an enabled scissor makes the two
// different operations no matter how the rectangles line up.
const Bool scissorEnabled =
(RenderStateImpl::g_syncedRenderStateParameters.ScissorTestEnabledMask & 1u) != 0;
using MobileGL::FramebufferAttachmentType;
struct AspectPlan {
GLbitfield bit;
FramebufferAttachmentType source;
FramebufferAttachmentType destination;
};
// The colour aspect follows glReadBuffer on the read side and draw buffer 0 on the write
// side, which is the only draw buffer a blit onto a layered destination can be pinned to
// here: a blit writes EVERY enabled draw buffer, so a framebuffer with more than one is
// left to the driver rather than half-repaired.
const auto& drawBuffers = drawFramebuffer->GetDrawBuffers();
// GL_NONE is what an unwritten draw-buffer slot holds, and it is a different value from
// the "no such attachment" one - counting it as enabled made every framebuffer look like
// it had eight and sent every colour blit to the driver.
//
// The buffer is found rather than assumed to be slot 0: a blit writes every ENABLED draw
// buffer, and glDrawBuffers(GL_NONE, GL_NONE, GL_NONE, GL_COLOR_ATTACHMENT0) leaves slot
// 0 empty while still naming exactly one destination.
Int enabledDrawBuffers = 0;
FramebufferAttachmentType colorDestination = FramebufferAttachmentType::None;
for (const FramebufferAttachmentType buffer : drawBuffers) {
if (buffer != FramebufferAttachmentType::Unknown && buffer != FramebufferAttachmentType::None) {
++enabledDrawBuffers;
if (enabledDrawBuffers == 1) colorDestination = buffer;
}
}
const AspectPlan plans[] = {
{GL_COLOR_BUFFER_BIT, readFramebuffer->GetReadBuffer(), colorDestination},
{GL_DEPTH_BUFFER_BIT, FramebufferAttachmentType::Depth, FramebufferAttachmentType::Depth},
{GL_STENCIL_BUFFER_BIT, FramebufferAttachmentType::Stencil, FramebufferAttachmentType::Stencil},
};
GLbitfield handled = 0;
for (const AspectPlan& plan : plans) {
if ((mask & plan.bit) == 0) continue;
if (plan.source == FramebufferAttachmentType::Unknown ||
plan.destination == FramebufferAttachmentType::Unknown ||
plan.source == FramebufferAttachmentType::None ||
plan.destination == FramebufferAttachmentType::None) {
continue;
}
const auto& sourceAttachment = readFramebuffer->GetAttachment(plan.source);
const auto& destinationAttachment = drawFramebuffer->GetAttachment(plan.destination);
// Renderbuffers have no layers, so a destination that is one cannot be hitting this.
if (!sourceAttachment.IsTexture() || !destinationAttachment.IsTexture()) continue;
// Layer 0 is the case the driver gets right, and a LAYERED attachment (glFramebufferTexture
// with no layer) blits its layer 0 by spec - neither is this defect.
if (destinationAttachment.GetTextureLayer() == 0) continue;
if (destinationAttachment.IsLayered() || sourceAttachment.IsLayered()) continue;
const auto& sourceTexture = sourceAttachment.GetTexture();
const auto& destinationTexture = destinationAttachment.GetTexture();
if (!sourceTexture || !destinationTexture) continue;
// glCopyImageSubData moves texel blocks: same format both ends, or it is a different
// operation. Multisample endpoints would additionally have to agree on sample count,
// which is a resolve the driver still owns.
if (sourceTexture->GetFormat() != destinationTexture->GetFormat()) continue;
if (sourceTexture->GetSamples() > 0 || destinationTexture->GetSamples() > 0) continue;
// Copying an image region onto itself is undefined for glCopyImageSubData, and a blit
// whose source and destination overlap is undefined for GL too - so this is not a
// shape to substitute FOR, it is one to leave exactly as the application wrote it.
if (sourceTexture == destinationTexture &&
sourceAttachment.GetTextureLevel() == destinationAttachment.GetTextureLevel() &&
sourceAttachment.GetTextureLayer() == destinationAttachment.GetTextureLayer()) {
continue;
}
// A combined depth-stencil texture is ONE image to glCopyImageSubData: it carries both
// aspects across whether or not the mask asked for both. Taking only GL_DEPTH_BUFFER_BIT
// on a DEPTH24_STENCIL8 destination would overwrite a stencil the application asked to
// keep, so the copy is only allowed when the mask covers everything the format holds.
const TextureInternalFormat format = destinationTexture->GetFormat();
const Bool hasDepth = MG_Util::IsDepthFormatInternalFormat(format);
const Bool hasStencil = MG_Util::IsStencilFormatInternalFormat(format);
if (hasDepth && (mask & GL_DEPTH_BUFFER_BIT) == 0) continue;
if (hasStencil && (mask & GL_STENCIL_BUFFER_BIT) == 0) continue;
// ... and having carried both, it must be credited with both, or the caller hands the
// stencil half to the driver and it lands on layer 0 after all.
const GLbitfield aspectBits =
hasDepth || hasStencil
? static_cast<GLbitfield>((hasDepth ? GL_DEPTH_BUFFER_BIT : 0) |
(hasStencil ? GL_STENCIL_BUFFER_BIT : 0))
: static_cast<GLbitfield>(GL_COLOR_BUFFER_BIT);
if ((handled & aspectBits) == aspectBits) continue;
if (!oneToOne || scissorEnabled || (plan.bit == GL_COLOR_BUFFER_BIT && enabledDrawBuffers != 1)) {
MGLOG_E_ONCE("BlitFramebuffer: this driver ignores a non-zero destination array layer and this "
"blit cannot be expressed as a copy (%s), so it will land on layer 0",
!oneToOne ? "it scales or flips"
: scissorEnabled ? "the scissor test is enabled"
: "the destination has more than one draw buffer");
continue;
}
auto backendSource = TextureImpl::SyncTextureObjectToBackend(sourceTexture);
auto backendDestination = TextureImpl::SyncTextureObjectToBackend(destinationTexture);
if (!backendSource || !backendDestination) continue;
const GLuint sourceName = backendSource->GetBackendTextureId();
const GLuint destinationName = backendDestination->GetBackendTextureId();
if (sourceName == 0 || destinationName == 0) continue;
const GLenum sourceTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum(sourceTexture->GetTarget());
const GLenum destinationTarget =
TextureImpl::ConvertTextureTargetToBackendGLEnum(destinationTexture->GetTarget());
ClearGLErrors();
g_GLESFuncs.glCopyImageSubData(sourceName, sourceTarget, sourceAttachment.GetTextureLevel(), srcX0, srcY0,
sourceAttachment.GetTextureLayer(), destinationName, destinationTarget,
destinationAttachment.GetTextureLevel(), dstX0, dstY0,
destinationAttachment.GetTextureLayer(), width, height, 1);
if (const GLenum error = g_GLESFuncs.glGetError(); error != GL_NO_ERROR) {
// The driver blit still runs for this aspect - onto the wrong layer, but the
// substitute has to leave the call no worse off than it found it.
MGLOG_E_ONCE("BlitFramebuffer: the layered-destination copy substitute failed with %s; the "
"driver blit will run instead and land on layer 0",
MG_Util::ConvertGLEnumToString(error).c_str());
continue;
}
handled |= aspectBits;
}
return handled & mask;
}
void BlitFramebuffer(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1,
GLint dstY1, GLbitfield mask, GLenum filter) {
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG && MOBILEGL_ENABLE_SCOPE_MARKER
@@ -5167,7 +5326,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
});
MGLOG_D("ES %s(%d, %d, %d, %d, %d, %d, %d, %d, 0x%x, %s)", __func__, srcX0, srcY0, srcX1, srcY1, dstX0, dstY0,
dstX1, dstY1, mask, MG_Util::ConvertGLEnumToString(filter).c_str());
IssueBlitWithResolveFallback(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter);
// A no-op on every driver that honours a non-zero destination array layer, which is all
// of them but the probed one. Whatever it performs itself is taken out of the mask.
mask &= ~BlitLayeredDestinationAspects(
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(),
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(), srcX0, srcY0,
srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask);
if (mask != 0) {
IssueBlitWithResolveFallback(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter);
}
DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) {
MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str());
});
@@ -5189,7 +5356,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
MGLOG_D("ES %s(%d, %d, %d, %d, %d, %d, %d, %d, 0x%x, %s)", __func__, srcX0, srcY0, srcX1, srcY1,
dstX0, dstY0, dstX1, dstY1, mask, MG_Util::ConvertGLEnumToString(filter).c_str());
IssueBlitWithResolveFallback(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter);
// See the DSA-free entry point above: only the probed defect makes this do anything.
mask &= ~BlitLayeredDestinationAspects(readFramebuffer, drawFramebuffer, srcX0, srcY0, srcX1, srcY1, dstX0,
dstY0, dstX1, dstY1, mask);
if (mask != 0) {
IssueBlitWithResolveFallback(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter);
}
// Debug-only diagnostics: which GLES depth texture did this blit write?
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
if (mask & GL_DEPTH_BUFFER_BIT) {
+3 -2
View File
@@ -6341,8 +6341,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
// neither std430 nor std140") and the stage never reaches the driver. Collapse the
// block into one uint array at offset 0 and re-index each counter to the element
// that used to be at its byte offset; the buffer then stays bound whole, which it
// has to (GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT is 32 on this device, so an
// 8-byte bind offset is not expressible). BEFORE SetAtomicCounterBlockBindings
// has to (GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT is 64 on Adreno 830 and 32 or
// more everywhere else, so an 8-byte bind offset is not expressible on any of them).
// BEFORE SetAtomicCounterBlockBindings
// below, which only moves the block's BINDING and needs the block intact.
//
// NO KEY MATERIAL either, for the same reason - and note where the application's
@@ -855,6 +855,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static constexpr SizeT kMaxAdvertisedShaderStorageBlockSize = 512ull * 1024ull * 1024ull;
m_dynamicParameters.UniformBufferOffsetAlignment = m_vulkanCaps.UniformBufferOffsetAlignment;
m_dynamicParameters.ShaderStorageBufferOffsetAlignment = m_vulkanCaps.ShaderStorageBufferOffsetAlignment;
m_dynamicParameters.AliasedLineWidthRangeMin = m_vulkanCaps.AliasedLineWidthRangeMin;
m_dynamicParameters.AliasedLineWidthRangeMax = m_vulkanCaps.AliasedLineWidthRangeMax;
// Without the samplerAnisotropy feature the limit is unusable, so report 1.0 (no anisotropy)
@@ -2186,6 +2186,49 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
SamplerNumericDomain ProgramFactory::UniformTypeToImageNumericDomain(GLenum glType) {
switch (glType) {
case GL_INT_IMAGE_1D:
case GL_INT_IMAGE_2D:
case GL_INT_IMAGE_3D:
case GL_INT_IMAGE_2D_RECT:
case GL_INT_IMAGE_CUBE:
case GL_INT_IMAGE_BUFFER:
case GL_INT_IMAGE_1D_ARRAY:
case GL_INT_IMAGE_2D_ARRAY:
case GL_INT_IMAGE_CUBE_MAP_ARRAY:
case GL_INT_IMAGE_2D_MULTISAMPLE:
case GL_INT_IMAGE_2D_MULTISAMPLE_ARRAY:
return SamplerNumericDomain::SignedInteger;
case GL_UNSIGNED_INT_IMAGE_1D:
case GL_UNSIGNED_INT_IMAGE_2D:
case GL_UNSIGNED_INT_IMAGE_3D:
case GL_UNSIGNED_INT_IMAGE_2D_RECT:
case GL_UNSIGNED_INT_IMAGE_CUBE:
case GL_UNSIGNED_INT_IMAGE_BUFFER:
case GL_UNSIGNED_INT_IMAGE_1D_ARRAY:
case GL_UNSIGNED_INT_IMAGE_2D_ARRAY:
case GL_UNSIGNED_INT_IMAGE_CUBE_MAP_ARRAY:
case GL_UNSIGNED_INT_IMAGE_2D_MULTISAMPLE:
case GL_UNSIGNED_INT_IMAGE_2D_MULTISAMPLE_ARRAY:
return SamplerNumericDomain::UnsignedInteger;
case GL_IMAGE_1D:
case GL_IMAGE_2D:
case GL_IMAGE_3D:
case GL_IMAGE_2D_RECT:
case GL_IMAGE_CUBE:
case GL_IMAGE_BUFFER:
case GL_IMAGE_1D_ARRAY:
case GL_IMAGE_2D_ARRAY:
case GL_IMAGE_CUBE_MAP_ARRAY:
case GL_IMAGE_2D_MULTISAMPLE:
case GL_IMAGE_2D_MULTISAMPLE_ARRAY:
return SamplerNumericDomain::Float;
default:
return SamplerNumericDomain::Unknown;
}
}
ProgramFactory::HashType ProgramFactory::ComputeHash(const MG_State::GLState::ProgramObject& program,
CompileOptionFlags flags) const {
XXHASH_VERIFY(XXH64_reset(m_hashState, m_config.CacheVersion));
@@ -2956,6 +2999,34 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static_cast<Int>(numericDomain));
entry.samplerNumericDomainByBinding[binding] = numericDomain;
}
// Every other opaque kind records its domain too. Only the combined-image-sampler
// path above needs it to pick a sampled view format; the three below need it to
// describe the descriptor a binding gets when its unit is UNBOUND, which is legal
// GL and must not lose the draw (see UniformManager's Resolve*Descriptor). Left
// Unknown, those placeholders would have no way to tell a `samplerBuffer` from a
// `usamplerBuffer` - and a texel buffer view whose numeric type disagrees with the
// shader's is invalid Vulkan, not merely wrong data.
if (descriptorKind == DescriptorBindingKind::UniformTexelBuffer ||
descriptorKind == DescriptorBindingKind::StorageTexelBuffer ||
descriptorKind == DescriptorBindingKind::StorageImage) {
const SamplerNumericDomain opaqueDomain =
descriptorKind == DescriptorBindingKind::UniformTexelBuffer
? UniformTypeToSamplerNumericDomain(uniformType)
: UniformTypeToImageNumericDomain(uniformType);
MOBILEGL_ASSERT(opaqueDomain != SamplerNumericDomain::Unknown,
"ProgramFactory::ReflectLayout: failed to resolve numeric domain for '%s' "
"(uniformType=0x%x)",
uniformName.c_str(), uniformType);
MOBILEGL_ASSERT(entry.samplerNumericDomainByBinding[binding] ==
SamplerNumericDomain::Unknown ||
entry.samplerNumericDomainByBinding[binding] == opaqueDomain,
"ProgramFactory::ReflectLayout: binding %u ('%s') has conflicting numeric "
"domains (%d vs %d)",
binding, uniformName.c_str(),
static_cast<Int>(entry.samplerNumericDomainByBinding[binding]),
static_cast<Int>(opaqueDomain));
entry.samplerNumericDomainByBinding[binding] = opaqueDomain;
}
MOBILEGL_ASSERT(entry.samplerUniformLocationByBinding[binding] < 0 || location < 0 ||
entry.samplerUniformLocationByBinding[binding] == location,
"ProgramFactory::ReflectLayout: texture binding %u maps to conflicting uniform locations (%d vs %d)",
@@ -448,6 +448,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static VkShaderStageFlagBits ToVkStage(ShaderStage stage);
static VkFormat ConvertSpirvImageFormatToVkFormat(SpvImageFormat format);
static SamplerNumericDomain UniformTypeToSamplerNumericDomain(GLenum glType);
// The same question for an IMAGE uniform (`image2D`, `uimageBuffer`, ...), which the
// sampler form above deliberately does not answer. Kept separate rather than folded in
// because the two are asked in different places for different reasons: a sampler's domain
// decides a sampled VIEW format, an image's decides what a placeholder descriptor for an
// UNBOUND image unit must be (see UniformManager::AcquireUnboundTexelBufferView and
// GetUnboundStorageImageTexture) - a formatless `writeonly` declaration reflects no
// format at all, and the numeric domain is then the only thing that constrains it.
static SamplerNumericDomain UniformTypeToImageNumericDomain(GLenum glType);
// True when any entry point declares the DepthReplacing execution mode, i.e. the
// shader assigns gl_FragDepth. Exposed so the blended depth-write quirk's exemption
// can be pinned by tests. A false negative loses the exemption, so such a shader is
@@ -11,14 +11,19 @@
#include "MG_Backend/DirectVulkan/DirectVulkanResourceState.h"
#include "MG_State/GLState/Core.h"
#include "MG_State/GLState/ProgramState/ProgramObject.h"
#include "MG_State/GLState/TextureState/TextureObject1D.h"
#include "MG_State/GLState/TextureState/TextureObject2D.h"
#include "MG_State/GLState/TextureState/TextureObject2DCube.h"
#include "MG_State/GLState/TextureState/TextureObject3D.h"
#include "MG_State/GLState/TextureState/TextureObjectBuffer.h"
#include "MG_State/GLState/TextureState/TextureObjectStubs.h"
#include "MG_Util/Converters/GLToMG/TextureEnumConverter.h"
#include "MG_Util/Converters/MGToStr/FramebufferEnumConverter.h"
#include "MG_Util/Converters/MGToVk/TextureEnumConverter.h"
#include "MG_Util/Metrics/TextureMetrics.h"
#include "MG_Util/ShaderTranspiler/Types.h"
#include <Config.h>
#include <vulkan/utility/vk_format_utils.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
@@ -28,6 +33,136 @@
namespace MobileGL::MG_Backend::DirectVulkan {
namespace {
constexpr Uint kFallbackTexture2DExternalIndex = 0xFFFFFF00u;
// One id for every storage-image placeholder. They are never reachable through GL - no
// glGenTextures ever hands this out, and nothing looks a placeholder up by name - so the
// id only has to stay clear of the application's, exactly like the sampled fallback's.
constexpr Uint kUnboundStorageImageExternalIndex = 0xFFFFFF01u;
// The R32 member of each numeric class. Every one of the three is a MANDATORY-support
// format for uniform texel buffers, storage texel buffers and storage images alike
// (Vulkan 1.0, "Required Format Support"), which is what makes them a fallback that
// cannot itself fail for want of device features.
VkFormat PlaceholderFormatForNumericDomain(SamplerNumericDomain numericDomain) {
switch (numericDomain) {
case SamplerNumericDomain::Float:
return VK_FORMAT_R32_SFLOAT;
case SamplerNumericDomain::SignedInteger:
return VK_FORMAT_R32_SINT;
case SamplerNumericDomain::UnsignedInteger:
return VK_FORMAT_R32_UINT;
case SamplerNumericDomain::Unknown:
break;
}
return VK_FORMAT_UNDEFINED;
}
Bool BufferFormatSupportsFeature(VkPhysicalDevice physicalDevice, VkFormat format,
VkFormatFeatureFlags requiredFeature) {
if (physicalDevice == VK_NULL_HANDLE || format == VK_FORMAT_UNDEFINED) {
return false;
}
VkFormatProperties properties{};
vkGetPhysicalDeviceFormatProperties(physicalDevice, format, &properties);
return (properties.bufferFeatures & requiredFeature) == requiredFeature;
}
// Reverse of MG_Util::ConvertTextureInternalFormatToVkEnum. A placeholder texture is
// built through the ordinary frontend texture object (that is what gets it an image with
// STORAGE usage, a GENERAL transition and a view, for free), and that object is described
// by a GL internal format - while everything upstream of here speaks VkFormat. Scanned
// rather than tabulated: it runs once per (target, format) placeholder ever created, the
// enum is ~70 entries, and a second hand-written table is a second thing to drift.
// Ascending order matters: the sized formats precede the unsized aliases, so a scan
// answers with the sized one.
TextureInternalFormat InternalFormatForVkFormat(VkFormat format) {
if (format == VK_FORMAT_UNDEFINED) {
return TextureInternalFormat::Unknown;
}
for (Int index = 0; index < static_cast<Int>(TextureInternalFormat::TextureInternalFormatCount);
++index) {
const auto candidate = static_cast<TextureInternalFormat>(index);
if (MG_Util::ConvertTextureInternalFormatToVkEnum(candidate) == format) {
return candidate;
}
}
return TextureInternalFormat::Unknown;
}
// What a 1x1 placeholder of a given target has to allocate for the backend to give it the
// Vulkan view type that target's image declaration demands (see
// VkTextureManager's TryResolveTextureShapeInfo, which reads exactly these two things).
struct PlaceholderShape {
Array<TextureUploadTarget, 6> uploadTargets{};
Uint32 uploadTargetCount = 0;
// The GL depth of the single level: the array length for an array target, the depth
// for a 3D one, and 6 for a cube map array (one whole cube).
Int depth = 1;
Bool valid = false;
};
PlaceholderShape PlaceholderShapeForTarget(TextureTarget target) {
PlaceholderShape shape{};
switch (target) {
case TextureTarget::Texture1D:
shape = {{TextureUploadTarget::Texture1D}, 1, 1, true};
break;
case TextureTarget::Texture2D:
shape = {{TextureUploadTarget::Texture2D}, 1, 1, true};
break;
case TextureTarget::TextureRectangle:
shape = {{TextureUploadTarget::TextureRectangle}, 1, 1, true};
break;
case TextureTarget::Texture3D:
shape = {{TextureUploadTarget::Texture3D}, 1, 1, true};
break;
case TextureTarget::Texture1DArray:
shape = {{TextureUploadTarget::Texture1DArray}, 1, 1, true};
break;
case TextureTarget::Texture2DArray:
shape = {{TextureUploadTarget::Texture2DArray}, 1, 1, true};
break;
case TextureTarget::TextureCubeMap:
shape = {{TextureUploadTarget::CubeMapPositiveX, TextureUploadTarget::CubeMapNegativeX,
TextureUploadTarget::CubeMapPositiveY, TextureUploadTarget::CubeMapNegativeY,
TextureUploadTarget::CubeMapPositiveZ, TextureUploadTarget::CubeMapNegativeZ},
6, 1, true};
break;
case TextureTarget::TextureCubeMapArray:
// Layers are cube faces, so the count must be a whole number of cubes.
shape = {{TextureUploadTarget::CubeMapArray}, 1, 6, true};
break;
default:
// Multisample targets above all: their descriptor needs a multisample view.
break;
}
return shape;
}
// TextureObjectMipmap, not ITextureObject: AllocateStorage and MarkStorageDirty live
// there, and every placeholder shape above is one of its subclasses.
SharedPtr<MG_State::GLState::TextureObjectMipmap> MakePlaceholderTextureObject(TextureTarget target,
Uint index) {
switch (target) {
case TextureTarget::Texture1D:
return MakeShared<MG_State::GLState::TextureObject1D>(index);
case TextureTarget::Texture2D:
return MakeShared<MG_State::GLState::TextureObject2D>(index);
case TextureTarget::TextureRectangle:
return MakeShared<MG_State::GLState::TextureObjectRectangle>(index);
case TextureTarget::Texture3D:
return MakeShared<MG_State::GLState::TextureObject3D>(index);
case TextureTarget::Texture1DArray:
return MakeShared<MG_State::GLState::TextureObject1DArray>(index);
case TextureTarget::Texture2DArray:
return MakeShared<MG_State::GLState::TextureObject2DArray>(index);
case TextureTarget::TextureCubeMap:
return MakeShared<MG_State::GLState::TextureObject2DCube>(index);
case TextureTarget::TextureCubeMapArray:
return MakeShared<MG_State::GLState::TextureObjectCubeMapArray>(index);
default:
return nullptr;
}
}
}
static Bool FindFramebufferAttachmentForTexture(const MG_State::GLState::FramebufferObject& framebuffer,
@@ -115,7 +250,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return reflectedFormat != VK_FORMAT_UNDEFINED ? reflectedFormat : resourceFormat;
}
Bool UniformManager::Initialize(VkDevice device, VkBufferManager* bufferManager,
Bool UniformManager::Initialize(VkDevice device, VkPhysicalDevice physicalDevice,
VkBufferManager* bufferManager,
ProgramFactory* programFactory,
VkDeviceSize minUniformBufferOffsetAlignment, Uint32 frameCount,
Uint32 maxBindings, Uint32 setsPerFrame,
@@ -123,6 +259,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Shutdown();
MOBILEGL_ASSERT(device != VK_NULL_HANDLE, "UniformDescriptorBinder::Initialize requires valid VkDevice");
MOBILEGL_ASSERT(physicalDevice != VK_NULL_HANDLE,
"UniformDescriptorBinder::Initialize requires valid VkPhysicalDevice");
MOBILEGL_ASSERT(bufferManager != nullptr, "UniformDescriptorBinder::Initialize requires valid buffer manager");
MOBILEGL_ASSERT(programFactory != nullptr,
"UniformDescriptorBinder::Initialize requires valid program factory");
@@ -135,6 +273,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
"UniformDescriptorBinder::Initialize requires valid sampler manager");
m_device = device;
m_physicalDevice = physicalDevice;
m_bufferManager = bufferManager;
m_programFactory = programFactory;
m_minDynamicOffsetAlignment = std::max<VkDeviceSize>(1, minUniformBufferOffsetAlignment);
@@ -173,6 +312,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
void UniformManager::Shutdown() {
// Before the per-frame loop, because these views are NOT owned by any frame slot (see
// m_unboundTexelBufferViews) and the loop below is what clears m_device.
if (m_device != VK_NULL_HANDLE) {
for (const auto& viewEntry : m_unboundTexelBufferViews) {
if (viewEntry.second != VK_NULL_HANDLE) {
vkDestroyBufferView(m_device, viewEntry.second, nullptr);
}
}
}
m_unboundTexelBufferViews.clear();
m_unboundStorageImageTextures.clear();
for (auto& frame : m_frames) {
if (m_device != VK_NULL_HANDLE) {
for (auto& view : frame.texelBufferViews) {
@@ -199,6 +349,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_bufferManager = nullptr;
m_programFactory = nullptr;
m_device = VK_NULL_HANDLE;
m_physicalDevice = VK_NULL_HANDLE;
m_minDynamicOffsetAlignment = 1;
m_frameCount = 0;
m_maxBindings = 0;
@@ -693,11 +844,29 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MOBILEGL_ASSERT(m_bufferManager != nullptr, "ResolveTexelBufferDescriptor: buffer manager is null");
MOBILEGL_ASSERT(frameIndex < m_frames.size(), "ResolveTexelBufferDescriptor: frame index out of range");
MOBILEGL_ASSERT(binding < programObj.samplerNumericDomainByBinding.size(),
"ResolveTexelBufferDescriptor: numeric domain binding %u out of range", binding);
const SamplerNumericDomain numericDomain = programObj.samplerNumericDomainByBinding[binding];
SharedPtr<MG_State::GLState::ITextureObject> texture;
if (!ResolveSamplerTexture(program, programObj, binding, texture) || texture == nullptr) {
MGLOG_E_ONCE("ResolveTexelBufferDescriptor: texture buffer binding %u ('%s') is unbound", binding,
programObj.samplerNameByBinding[binding].c_str());
return false;
// NOT an error, and not a reason to lose the draw. A texture unit with nothing on it
// is a legal GL state (4.6 core 8.24): the sampler is incomplete, so a fetch through
// it returns undefined values - the same answer the sampled path above gives with its
// fallback texture, which a buffer texture simply cannot use because its descriptor is
// a VkBufferView. A per-format placeholder view is the equivalent for this kind.
const VkBufferView placeholder =
AcquireUnboundTexelBufferView(VK_FORMAT_UNDEFINED, numericDomain, false);
if (placeholder == VK_NULL_HANDLE) {
MGLOG_E_ONCE("ResolveTexelBufferDescriptor: texture buffer binding %u ('%s') is unbound, and the "
"placeholder descriptor could not be created", binding,
programObj.samplerNameByBinding[binding].c_str());
return false;
}
MGLOG_D("ResolveTexelBufferDescriptor: binding %u ('%s') is unbound; using the placeholder descriptor",
binding, programObj.samplerNameByBinding[binding].c_str());
outBufferView = placeholder;
return true;
}
if (texture->GetStorageType() != TextureStorageType::Buffer ||
@@ -712,9 +881,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto* textureBuffer = static_cast<MG_State::GLState::TextureObjectBuffer*>(texture.get());
const auto& bufferObject = textureBuffer->GetBufferBindingSlot().GetBoundObject();
if (bufferObject == nullptr) {
MGLOG_E_ONCE("ResolveTexelBufferDescriptor: texture buffer binding %u ('%s') has no GL buffer bound",
binding, programObj.samplerNameByBinding[binding].c_str());
return false;
// A buffer texture with no buffer object attached is INCOMPLETE, not illegal (GL 4.6
// core 8.9), and sampling an incomplete texture is undefined - so this too keeps the
// draw on a placeholder rather than dropping it.
const VkBufferView placeholder =
AcquireUnboundTexelBufferView(VK_FORMAT_UNDEFINED, numericDomain, false);
if (placeholder == VK_NULL_HANDLE) {
MGLOG_E_ONCE("ResolveTexelBufferDescriptor: texture buffer binding %u ('%s') has no GL buffer bound, "
"and the placeholder descriptor could not be created", binding,
programObj.samplerNameByBinding[binding].c_str());
return false;
}
MGLOG_D("ResolveTexelBufferDescriptor: binding %u ('%s') has no attached GL buffer; using the "
"placeholder descriptor", binding, programObj.samplerNameByBinding[binding].c_str());
outBufferView = placeholder;
return true;
}
BufferSlice slice{};
@@ -804,12 +985,31 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return false;
}
MOBILEGL_ASSERT(binding < programObj.storageImageFormatByBinding.size(),
"ResolveStorageTexelBufferDescriptor: binding %u has no reflected format slot", binding);
MOBILEGL_ASSERT(binding < programObj.samplerNumericDomainByBinding.size(),
"ResolveStorageTexelBufferDescriptor: numeric domain binding %u out of range", binding);
auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(imageUnit);
const auto& texture = imageBinding.Texture;
if (texture == nullptr) {
MGLOG_E_ONCE("ResolveStorageTexelBufferDescriptor: image unit %d is unbound for binding %u", imageUnit,
binding);
return false;
// An image unit with no texture on it is legal GL (4.6 core 8.26): loads return zero
// and stores are discarded. Declining here took the whole draw or dispatch with it -
// the same shape as the unbound storage block fixed alongside this. A placeholder view
// in the shader's own declared format lets the work proceed with the stores landing
// nowhere anyone can observe, which is what GL asks for.
const VkBufferView placeholder =
AcquireUnboundTexelBufferView(programObj.storageImageFormatByBinding[binding],
programObj.samplerNumericDomainByBinding[binding], true);
if (placeholder == VK_NULL_HANDLE) {
MGLOG_E_ONCE("ResolveStorageTexelBufferDescriptor: image unit %d is unbound for binding %u, and the "
"placeholder descriptor could not be created", imageUnit, binding);
return false;
}
MGLOG_D("ResolveStorageTexelBufferDescriptor: image unit %d (binding %u) is unbound; using the "
"placeholder descriptor", imageUnit, binding);
outBufferView = placeholder;
return true;
}
if (texture->GetStorageType() != TextureStorageType::Buffer ||
texture->GetTarget() != TextureTarget::TextureBuffer) {
@@ -824,9 +1024,20 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto* textureBuffer = static_cast<MG_State::GLState::TextureObjectBuffer*>(texture.get());
const auto& bufferObject = textureBuffer->GetBufferBindingSlot().GetBoundObject();
if (bufferObject == nullptr) {
MGLOG_E_ONCE("ResolveStorageTexelBufferDescriptor: texture buffer on image unit %d has no GL buffer bound",
imageUnit);
return false;
// Incomplete buffer texture, same as the sampled path: legal state, undefined data,
// and no reason to drop the work.
const VkBufferView placeholder =
AcquireUnboundTexelBufferView(programObj.storageImageFormatByBinding[binding],
programObj.samplerNumericDomainByBinding[binding], true);
if (placeholder == VK_NULL_HANDLE) {
MGLOG_E_ONCE("ResolveStorageTexelBufferDescriptor: texture buffer on image unit %d has no GL buffer "
"bound, and the placeholder descriptor could not be created", imageUnit);
return false;
}
MGLOG_D("ResolveStorageTexelBufferDescriptor: texture buffer on image unit %d has no attached GL buffer; "
"using the placeholder descriptor", imageUnit);
outBufferView = placeholder;
return true;
}
// Unlike the sampled texel buffer, the shader MAY write this one, and those writes land
@@ -852,8 +1063,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// policy as a storage image: a typed `layout(r32ui) uniform uimageBuffer` must be read as
// r32ui whatever the texture's own attachment format says. Falling back, in order:
// reflected format, then the bind format, then the texture's attached format.
MOBILEGL_ASSERT(binding < programObj.storageImageFormatByBinding.size(),
"ResolveStorageTexelBufferDescriptor: binding %u has no reflected format slot", binding);
const auto internalFormat = textureBuffer->GetFormat();
const VkFormat resourceFormat = MG_Util::ConvertTextureInternalFormatToVkEnum(internalFormat);
const VkFormat reflectedFormat = programObj.storageImageFormatByBinding[binding];
@@ -1062,8 +1271,40 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(imageUnit);
if (imageBinding.Texture == nullptr) {
MGLOG_E_ONCE("ResolveStorageImageDescriptor: image unit %d is unbound for binding %u", imageUnit, binding);
return false;
// Legal GL: an image unit with no texture bound makes loads return zero and discards
// stores (4.6 core 8.26). It is not a reason to lose the draw, which is what returning
// false here did - both SetupDraw and DispatchCompute skip everything on it. The
// placeholder is a 1x1 image of the target and format the shader's declaration asks
// for, so the descriptor is valid and the stores land where nobody can see them.
TextureTarget placeholderTarget = TextureTarget::Unknown;
VkFormat placeholderFormat = VK_FORMAT_UNDEFINED;
SharedPtr<MG_State::GLState::ITextureObject> placeholder;
if (ResolveUnboundStorageImagePlaceholder(programObj, binding, placeholderTarget, placeholderFormat)) {
placeholder = GetUnboundStorageImageTexture(placeholderTarget, placeholderFormat);
}
VkImageView placeholderView = VK_NULL_HANDLE;
if (placeholder != nullptr &&
m_textureManager->TransitionTextureForStorageImage(commandBuffer, *placeholder)) {
// layered=true, layer=0: the placeholder's own view type IS the one the shader's
// image declaration demands, and that is exactly what the layered form asks for
// (see GetOrCreateStorageImageView, which only narrows the view type when a
// non-layered binding names a single layer).
placeholderView =
m_textureManager->GetOrCreateStorageImageView(*placeholder, 0, placeholderFormat, true, 0);
}
if (placeholderView == VK_NULL_HANDLE) {
MGLOG_E_ONCE("ResolveStorageImageDescriptor: image unit %d is unbound for binding %u, and no "
"placeholder descriptor could be built (target=%d format=%d)",
imageUnit, binding, static_cast<Int>(placeholderTarget),
static_cast<Int>(placeholderFormat));
return false;
}
MGLOG_D("ResolveStorageImageDescriptor: image unit %d (binding %u) is unbound; using the placeholder "
"descriptor", imageUnit, binding);
outImageInfo.sampler = VK_NULL_HANDLE;
outImageInfo.imageView = placeholderView;
outImageInfo.imageLayout = VK_IMAGE_LAYOUT_GENERAL;
return true;
}
const Bool ready = m_textureManager->TransitionTextureForStorageImage(commandBuffer, *imageBinding.Texture);
@@ -1155,6 +1396,138 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return m_fallbackTexture2D;
}
VkBufferView UniformManager::AcquireUnboundTexelBufferView(VkFormat declaredFormat,
SamplerNumericDomain numericDomain, Bool storage) {
MOBILEGL_ASSERT(m_bufferManager != nullptr, "AcquireUnboundTexelBufferView: buffer manager is null");
const VkFormatFeatureFlags requiredFeature = storage ? VK_FORMAT_FEATURE_STORAGE_TEXEL_BUFFER_BIT
: VK_FORMAT_FEATURE_UNIFORM_TEXEL_BUFFER_BIT;
const VkFormat fallbackFormat = PlaceholderFormatForNumericDomain(numericDomain);
VkFormat format = declaredFormat;
if (format == VK_FORMAT_UNDEFINED || !BufferFormatSupportsFeature(m_physicalDevice, format, requiredFeature)) {
// The declared format is what a shader that WRITES through this descriptor is
// validated against, so it is tried first and kept whenever the device can use it.
// Falling back is for the two cases where it cannot be: a sampled texel buffer, which
// declares no format at all, and a device that does not list the declared one as a
// texel buffer. The fallback stays inside the shader's numeric class, which is the
// part the descriptor is checked on for a formatless declaration - and the R32
// members of the three classes are mandatory-support formats, so this cannot fail for
// want of device features.
format = fallbackFormat;
}
if (format == VK_FORMAT_UNDEFINED || !BufferFormatSupportsFeature(m_physicalDevice, format, requiredFeature)) {
MGLOG_E_ONCE("AcquireUnboundTexelBufferView: no usable placeholder format (declared=%d fallback=%d "
"storage=%s)",
static_cast<Int>(declaredFormat), static_cast<Int>(fallbackFormat),
storage ? "true" : "false");
return VK_NULL_HANDLE;
}
const Uint64 key = (static_cast<Uint64>(format) << 1) | (storage ? 1ull : 0ull);
const auto cached = m_unboundTexelBufferViews.find(key);
if (cached != m_unboundTexelBufferViews.end()) {
return cached->second;
}
const BufferSlice placeholder = m_bufferManager->AcquireUnboundTexelBufferDescriptor();
if (!placeholder.IsValid()) {
MGLOG_E_ONCE("AcquireUnboundTexelBufferView: placeholder buffer unavailable");
return VK_NULL_HANDLE;
}
// A buffer view's range must be a whole number of texels of its own format, and the
// placeholder is sized for the largest of them - so floor rather than assume.
const VkDeviceSize texelSize = std::max<VkDeviceSize>(1, vkuFormatTexelBlockSize(format));
const VkDeviceSize range = (placeholder.size / texelSize) * texelSize;
if (range == 0) {
MGLOG_E_ONCE("AcquireUnboundTexelBufferView: placeholder holds no whole texel of format=%d",
static_cast<Int>(format));
return VK_NULL_HANDLE;
}
VkBufferViewCreateInfo viewInfo{};
viewInfo.sType = VK_STRUCTURE_TYPE_BUFFER_VIEW_CREATE_INFO;
viewInfo.buffer = placeholder.buffer;
viewInfo.format = format;
viewInfo.offset = placeholder.offset;
viewInfo.range = range;
VkBufferView view = VK_NULL_HANDLE;
const VkResult result = vkCreateBufferView(m_device, &viewInfo, nullptr, &view);
if (result != VK_SUCCESS || view == VK_NULL_HANDLE) {
MGLOG_E_ONCE("AcquireUnboundTexelBufferView: vkCreateBufferView failed result=%d format=%d", result,
static_cast<Int>(format));
return VK_NULL_HANDLE;
}
m_unboundTexelBufferViews.emplace(key, view);
MGLOG_D("AcquireUnboundTexelBufferView: created placeholder view format=%d storage=%s",
static_cast<Int>(format), storage ? "true" : "false");
return view;
}
Bool UniformManager::ResolveUnboundStorageImagePlaceholder(const ProgramFactory::VkProgramObject& programObj,
Uint32 binding, TextureTarget& outTarget,
VkFormat& outFormat) const {
MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(),
"ResolveUnboundStorageImagePlaceholder: binding %u out of range", binding);
MOBILEGL_ASSERT(binding < programObj.storageImageFormatByBinding.size(),
"ResolveUnboundStorageImagePlaceholder: format binding %u out of range", binding);
outTarget = programObj.samplerTextureTargetByBinding[binding];
// The shader's own format qualifier, exactly as the bound path prefers it over the one
// glBindImageTexture named - there is no binding here to name one. A `writeonly` image
// may carry no qualifier at all; its numeric class is then the only constraint, and the
// R32 member of that class is what carries it (see AcquireUnboundTexelBufferView).
outFormat = programObj.storageImageFormatByBinding[binding];
if (outFormat == VK_FORMAT_UNDEFINED) {
outFormat = PlaceholderFormatForNumericDomain(programObj.samplerNumericDomainByBinding[binding]);
}
return outFormat != VK_FORMAT_UNDEFINED && PlaceholderShapeForTarget(outTarget).valid;
}
SharedPtr<MG_State::GLState::ITextureObject> UniformManager::GetUnboundStorageImageTexture(
TextureTarget target, VkFormat format) const {
const Uint64 key = (static_cast<Uint64>(target) << 32) | static_cast<Uint32>(format);
const auto cached = m_unboundStorageImageTextures.find(key);
if (cached != m_unboundStorageImageTextures.end()) {
return cached->second;
}
const PlaceholderShape shape = PlaceholderShapeForTarget(target);
if (!shape.valid) {
// A multisample image uniform is the case with no answer here: its descriptor demands
// a multisample view, and a single-sampled 1x1 image is invalid Vulkan in that slot,
// not a degraded picture. The caller declines the binding exactly as it did before.
MGLOG_D("GetUnboundStorageImageTexture: no placeholder shape for target=%d", static_cast<Int>(target));
return nullptr;
}
const TextureInternalFormat internalFormat = InternalFormatForVkFormat(format);
if (internalFormat == TextureInternalFormat::Unknown) {
MGLOG_E_ONCE("GetUnboundStorageImageTexture: no GL internal format matches VkFormat=%d",
static_cast<Int>(format));
return nullptr;
}
auto texture = MakePlaceholderTextureObject(target, kUnboundStorageImageExternalIndex);
if (texture == nullptr) {
return nullptr;
}
texture->SetInternalFormat(internalFormat);
const SizeT texelBytes = MG_Util::GetSizedInternalFormatSizeInBytes(internalFormat);
for (Uint32 index = 0; index < shape.uploadTargetCount; ++index) {
texture->AllocateStorage(shape.uploadTargets[index], 0,
{.texelSize = {1, 1, shape.depth},
.byteSize = texelBytes * static_cast<SizeT>(shape.depth)});
// Not dirty: there is deliberately nothing to upload. The image is created and
// transitioned to GENERAL by the storage-image preparation pass like any other, and
// its contents are exactly as undefined as GL says a fetch through an unbound image
// unit is.
texture->MarkStorageDirty(shape.uploadTargets[index], 0, false);
}
m_unboundStorageImageTextures.emplace(key, texture);
MGLOG_D("GetUnboundStorageImageTexture: created placeholder target=%d format=%d", static_cast<Int>(target),
static_cast<Int>(format));
return texture;
}
Bool UniformManager::ResolveSampledBinding(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj,
Uint32 binding, Uint32 element,
@@ -1340,9 +1713,23 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto* texture = MG_State::pGLContext->GetImageTextureBinding(imageUnit).Texture.get();
if (texture == nullptr) {
MGLOG_E_ONCE("CollectStorageImageTextures: image unit %d is unbound for binding %u element %u",
imageUnit, binding, element);
return false;
// ResolveStorageImageDescriptor will substitute the placeholder image for this
// binding; include it here for the same reason the sampled walk includes the
// fallback texture - this walk is what gets a storage image created,
// STORAGE-usage-marked and transitioned to GENERAL BEFORE the render pass
// opens, and all three of those are illegal once it has. A target with no
// placeholder shape (multisample) contributes nothing and is declined at
// resolve time exactly as it was.
TextureTarget placeholderTarget = TextureTarget::Unknown;
VkFormat placeholderFormat = VK_FORMAT_UNDEFINED;
if (!ResolveUnboundStorageImagePlaceholder(programObj, binding, placeholderTarget,
placeholderFormat)) {
continue;
}
texture = GetUnboundStorageImageTexture(placeholderTarget, placeholderFormat).get();
if (texture == nullptr) {
continue;
}
}
if (std::find(outTextures.begin(), outTextures.end(), texture) == outTextures.end()) {
outTextures.push_back(texture);
@@ -42,7 +42,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
SamplerNumericDomain numericDomain = SamplerNumericDomain::Unknown;
};
Bool Initialize(VkDevice device, VkBufferManager* bufferManager,
// `physicalDevice` is only ever asked for format properties: a placeholder descriptor for
// an unbound texel-buffer binding has to be built from a format the DEVICE accepts as a
// texel buffer, and there is no other route to that answer from here.
Bool Initialize(VkDevice device, VkPhysicalDevice physicalDevice, VkBufferManager* bufferManager,
ProgramFactory* programFactory,
VkDeviceSize minUniformBufferOffsetAlignment, Uint32 frameCount,
Uint32 maxBindings = 16, Uint32 setsPerFrame = 64,
@@ -177,6 +180,32 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 element);
SharedPtr<MG_State::GLState::ITextureObject> GetFallbackTexture(TextureTarget target) const;
// ---- placeholders for UNBOUND image-backed descriptors -------------------------
// GL lets a program declare `samplerBuffer`, `imageBuffer` or `image2D` and bind nothing
// to the unit it names: the fetch is then undefined (GL 4.6 core 8.9 for an incomplete
// buffer texture, 8.26 for an image unit with no texture) - undefined VALUES, not a
// dropped draw. Vulkan has no unwritten descriptor, so something valid has to sit in the
// set or the whole draw or dispatch is lost, which is what these two build. Same shape as
// VkBufferManager::AcquireUnboundStorageDescriptor, one level up: per FORMAT rather than
// one shared object, because a descriptor whose format disagrees with the shader's
// declaration is invalid Vulkan even when nothing ever reads it.
//
// `declaredFormat` is the format the SHADER declared (VK_FORMAT_UNDEFINED for a sampled
// texel buffer, which never carries one, or for a formatless `writeonly` image);
// `numericDomain` decides the format when there is no declaration and is the fallback
// class when the device cannot use the declared one as a texel buffer.
VkBufferView AcquireUnboundTexelBufferView(VkFormat declaredFormat, SamplerNumericDomain numericDomain,
Bool storage);
// A 1x1 (x1 layer, or 6 faces for a cube) texture of `format`, shaped for `target` so the
// view the descriptor gets has the view type the shader's image declaration demands.
// Null for a target with no single-sampled placeholder shape - multisample images, whose
// descriptor needs a multisample view that this cannot stand in for.
SharedPtr<MG_State::GLState::ITextureObject> GetUnboundStorageImageTexture(TextureTarget target,
VkFormat format) const;
// The (target, format) pair a storage-image binding's placeholder is keyed by, resolved
// from reflection alone. False when the binding has no placeholder shape.
Bool ResolveUnboundStorageImagePlaceholder(const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
TextureTarget& outTarget, VkFormat& outFormat) const;
// `element` indexes a sampler ARRAY inside one binding; each element carries its own
// independently assigned GL texture unit, so it selects the texture, the sampler
// override and the fallback separately from its neighbours.
@@ -251,6 +280,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkDescriptorSet& outDescriptorSet);
VkDevice m_device = VK_NULL_HANDLE;
VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE;
VkBufferManager* m_bufferManager = nullptr;
ProgramFactory* m_programFactory = nullptr;
Vector<FrameResources> m_frames;
@@ -263,6 +293,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkTextureManager* m_textureManager = nullptr;
VkSamplerManager* m_samplerManager = nullptr;
mutable SharedPtr<MG_State::GLState::ITextureObject> m_fallbackTexture2D;
// See AcquireUnboundTexelBufferView / GetUnboundStorageImageTexture. Both are lazily
// populated, never evicted (a program's declared formats are a fixed, tiny set) and torn
// down with the manager. The texel views are keyed by format AND by storage-vs-sampled
// because the two descriptor kinds demand different format FEATURES of the device, so one
// format can be usable for one and not the other. Deliberately NOT the per-frame
// texelBufferViews list: those are destroyed at every frame boundary, and these must
// outlive it or the placeholder would be rebuilt for every unbound binding every frame.
UnorderedMap<Uint64, VkBufferView> m_unboundTexelBufferViews;
mutable UnorderedMap<Uint64, SharedPtr<MG_State::GLState::ITextureObject>> m_unboundStorageImageTextures;
// Per-draw scratch buffers for BindProgramUniformBuffers: reused (clear keeps
// capacity) so the descriptor-write path stops allocating on every draw.
@@ -19,6 +19,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// See VkBufferManager::AcquireUnboundStorageDescriptor. 256 bytes: comfortably past
// every minStorageBufferOffsetAlignment in the wild, and free.
constexpr VkDeviceSize kUnboundStorageDescriptorBytes = 256;
// See VkBufferManager::AcquireUnboundTexelBufferDescriptor. The same 256 bytes, for the
// same reason plus one: a texel buffer view's range must be a whole number of texels of
// whatever format the placeholder is asked for, and 256 divides by every texel size in
// the GL image-format table (1, 2, 4, 8 and 16 bytes).
constexpr VkDeviceSize kUnboundTexelBufferDescriptorBytes = 256;
// A zero-copy persistent buffer is created once and never recreated (the app holds
// its mapped pointer), and may be bound to any role, so it carries every usage.
@@ -135,6 +140,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
m_transientUploadArena.Shutdown();
m_unboundStorageBuffer.Destroy();
m_unboundTexelBuffer.Destroy();
DestroyAllDeferredReleases();
ReleaseAllLiveResources();
m_copyProvider = nullptr;
@@ -742,6 +748,39 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return m_unboundStorageBuffer.GetSlice();
}
BufferSlice VkBufferManager::AcquireUnboundTexelBufferDescriptor() {
if (!m_unboundTexelBuffer.IsValid()) {
if (m_initInfo.allocator == nullptr) {
return {};
}
// A SECOND placeholder rather than more usage bits on the storage-block one. The two
// are independent failure domains: a device that refuses this allocation must not
// take the storage-block placeholder - and with it the fix this one is a sibling of -
// down with it. Host-visible and zero-filled for the same reason as that one: this is
// reached from descriptor resolution, inside an already-open recording, which must
// not start a copy of its own.
const Bool created = m_unboundTexelBuffer.Create({
.allocator = m_initInfo.allocator,
.size = kUnboundTexelBufferDescriptorBytes,
.usage = VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT |
VK_BUFFER_USAGE_TRANSFER_DST_BIT,
.memoryUsage = VMA_MEMORY_USAGE_AUTO,
.allocationFlags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT |
VMA_ALLOCATION_CREATE_MAPPED_BIT,
.requiredFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
});
if (!created) {
MGLOG_E_ONCE("VkBufferManager::AcquireUnboundTexelBufferDescriptor: placeholder creation failed");
m_unboundTexelBuffer.Destroy();
return {};
}
if (void* mapped = m_unboundTexelBuffer.GetMappedData()) {
Memset(mapped, 0, static_cast<SizeT>(kUnboundTexelBufferDescriptorBytes));
}
}
return m_unboundTexelBuffer.GetSlice();
}
VkBufferUsageFlags VkBufferManager::GetVkBufferUsage(BufferKind kind) {
switch (kind) {
case BufferKind::Vertex:
@@ -131,6 +131,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// that indexes past it.
BufferSlice AcquireUnboundStorageDescriptor();
// The store a texel-buffer descriptor - `samplerBuffer` or `imageBuffer` - gets when the
// unit the program's uniform names has no buffer texture on it, or the buffer texture on
// it has no GL buffer attached. Both are legal GL states that make a fetch return
// undefined values (GL 4.6 core 8.9: a buffer texture with no attached buffer object is
// incomplete, and sampling an incomplete texture is undefined - not a lost draw), and both
// used to take the whole draw or dispatch with them. The VIEW over this - one per format,
// and the descriptor is a VkBufferView, not a buffer - is built by
// UniformManager::AcquireUnboundTexelBufferView.
BufferSlice AcquireUnboundTexelBufferDescriptor();
// Draw-time acquire for resident (device-storage) buffers: ensures the
// resource exists and is fully uploaded, marks it used this frame.
Bool AcquireResidentSlice(BufferKind kind, const SharedPtr<MG_State::GLState::BufferObject>& bufferObject,
@@ -194,6 +204,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// See AcquireUnboundStorageDescriptor. Lazily created, never re-created, torn down
// with the manager.
VkBufferObject m_unboundStorageBuffer;
// See AcquireUnboundTexelBufferDescriptor. Same lifetime rules.
VkBufferObject m_unboundTexelBuffer;
IBufferCopyCommandProvider* m_copyProvider = nullptr;
Vector<Vector<VkBufferObject>> m_deferredBufferReleases;
Vector<Vector<SharedPtr<VkBufferResource>>> m_deferredResourceReleases;
@@ -3136,7 +3136,7 @@ void main() {
m_uniformManager = MakeUnique<UniformManager>();
MOBILEGL_ASSERT(m_uniformManager != nullptr, "UniformDescriptorBinder creation failed.");
succeeded = m_uniformManager->Initialize(
m_device, &m_bufferManager, m_programFactory.get(),
m_device, m_physicalDevice.handle, &m_bufferManager, m_programFactory.get(),
m_physicalDevice.properties.limits.minUniformBufferOffsetAlignment, m_config.MaxFramesInFlight,
maxProgramBindings, kDescriptorSetsPerFrame, m_textureManager.get(), m_samplerManager.get());
MOBILEGL_ASSERT(succeeded, "UniformDescriptorBinder initialization failed.");
+6 -1
View File
@@ -2406,7 +2406,12 @@ namespace MobileGL::MG_Impl::GLImpl {
*params = static_cast<GLint>(dynamicParameters.PointSizeGranularity);
break;
case GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT:
*params = static_cast<GLint>(dynamicParameters.UniformBufferOffsetAlignment);
// The STORAGE alignment, which is its own limit - this used to answer with the
// uniform one. They differ on real hardware (Adreno 830: 32 uniform, 64 storage), and
// under-reporting it is silent: ValidateBindBufferRange accepts the offset, the ES
// driver accepts it too without raising an error, and the shader's writes then land
// at an address the application never bound.
*params = static_cast<GLint>(dynamicParameters.ShaderStorageBufferOffsetAlignment);
break;
case GL_SMOOTH_LINE_WIDTH_RANGE:
params[0] = static_cast<GLint>(dynamicParameters.SmoothLineWidthRangeMin);
+132
View File
@@ -107,6 +107,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/StorageBufferRegrowScenario.cpp
Scenarios/RelinkStageSetScenario.cpp
Scenarios/GuiBatchScenario.cpp
Scenarios/UnboundImageDescriptorScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -305,6 +306,40 @@ mgl_itest_join_environment(MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT
mgl_itest_join_environment(MGL_ITEST_GLES_FORCED_DS_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION=1" ${MGL_ITEST_COMMON_ENV})
# The shader-compiler configurations AsyncCompileScenario needs, and the one
# ViewportArrayScenario's negative control needs.
#
# These used to be poked into MG_Config::Features from inside the test bodies. They
# cannot be any more - on Android this module links the SHIPPING libMobileGL.so, which
# exports nothing internal - and they should not have been anyway: half of what each of
# them decides is latched before the first GL call (the compile pool and its threads;
# the advertised extension list, which a backend builds once from the configuration in
# force at its first use), so an in-process write could only ever have moved the other
# half. Every one of them is a whole-process property, and a whole-process property is
# spelled with an environment variable and a ctest entry of its own.
#
# Note the shape of every list here: it APPENDS to MGL_ITEST_COMMON_ENV /
# MGL_ITEST_VULKAN_ENV rather than standing alone. A ctest ENVIRONMENT property REPLACES
# the job environment rather than adding to it, so an entry that lists only its mode
# variable would silently lose the EGL vendor and Vulkan ICD pinning and run against
# whatever the loader found first.
mgl_itest_join_environment(MGL_ITEST_GLES_ASYNC_ON_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ASYNC_SHADER_COMPILE=1" ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_ASYNC_OFF_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ASYNC_SHADER_COMPILE=0" ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_ASYNC_ON_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_ASYNC_SHADER_COMPILE=1" ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_ASYNC_OFF_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_ASYNC_SHADER_COMPILE=0" ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_OPTIMISTIC_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ASYNC_SHADER_COMPILE=1"
"MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS=1" ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_OPTIMISTIC_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_ASYNC_SHADER_COMPILE=1"
"MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS=1" ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_NO_VIEWPORT_EMULATION_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION=0" ${MGL_ITEST_COMMON_ENV})
# TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it.
set(MGL_ITEST_TIMEOUT 120)
@@ -369,3 +404,100 @@ gtest_discover_tests(MobileGLIntegrationTest
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_FORCED_DS_ENVIRONMENT}"
)
# AsyncCompileScenario, with asynchronous compilation PINNED ON per backend.
#
# Not a duplicate of what the two ambient registrations already run: they run whatever
# MobileGL's built-in default happens to be, and the day that default flips they would
# stop covering the asynchronous path without anything going red. These entries are the
# ones that keep the asynchronous half tested no matter what ships. They are also the
# only place ExtensionStringMatchesTheConfiguration can assert that the extension IS
# advertised - the case derives its expectation from this variable and nothing else, and
# skips where it is unset, precisely so that it is not asserting the implementation
# against itself.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.AsyncOn."
TEST_FILTER "AsyncCompileScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_ASYNC_ON_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.AsyncOn."
TEST_FILTER "AsyncCompileScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_ASYNC_ON_ENVIRONMENT}"
)
# The other side of the same switch: asynchronous compilation OFF, so
# GL_KHR_parallel_shader_compile must be WITHDRAWN from both spellings of the extension
# list and GL_MAX_SHADER_COMPILER_THREADS_KHR must read 0. Only that one case is
# registered here because it is the only one that has anything to say in this
# configuration - the other four exist to observe worker-built artifacts, and there are
# none - so registering the whole scenario would buy four guaranteed skips per backend.
# Together with the AsyncOn. entries above, one ctest run still covers both flag states,
# which is what the in-process forcing used to be for.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.AsyncOff."
TEST_FILTER "AsyncCompileScenario.ExtensionStringMatchesTheConfiguration"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_ASYNC_OFF_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.AsyncOff."
TEST_FILTER "AsyncCompileScenario.ExtensionStringMatchesTheConfiguration"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_ASYNC_OFF_ENVIRONMENT}"
)
# The optimistic-status quirk's end-to-end shape. Its own entries and not part of the
# AsyncOn. ones because the quirk is not neutral for the rest of the scenario: with it in
# force glGetShaderiv(GL_COMPILE_STATUS) deliberately answers without joining, which is
# exactly what CompletionStatusPollingThenForcedJoin asserts must NOT happen. Off by
# default and never advertised, so - unlike asynchronous compilation, which announces
# itself through the extension string - the variable is the only thing that can tell the
# case it is in force.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.OptimisticShaderStatus."
TEST_FILTER "AsyncCompileScenario.IrisShapedTwoPhaseBatchRendersCorrectly"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_OPTIMISTIC_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.OptimisticShaderStatus."
TEST_FILTER "AsyncCompileScenario.IrisShapedTwoPhaseBatchRendersCorrectly"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_OPTIMISTIC_ENVIRONMENT}"
)
# The negative control for the DirectGLES gl_ViewportIndex emulation, in a process that
# has it switched off. One case, because it is the only one the switch may touch: with
# the emulation off the three positive cases in the same fixture describe behaviour the
# backend does not have, so a whole-scenario registration would be three guaranteed reds.
# DirectGLES only - the flag steers nothing on DirectVulkan, which routes natively.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.NoViewportArrayEmulation."
TEST_FILTER "ViewportArrayScenario.WithoutTheEmulationEveryIndexCollapsesOntoViewportZero"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_NO_VIEWPORT_EMULATION_ENVIRONMENT}"
)
@@ -552,7 +552,15 @@ namespace MGITest {
// before the pre-flight forks - the child must measure the same platform
// the parent will use.
EnsureHeadlessPlatform();
m_backendName = EnvOr("MOBILEGL_BACKEND_TYPE", "<unset>");
// The backend that is actually about to come up, which is what every
// `BackendName() == "DirectGLES"` gate in the scenarios means by the question.
// MG_ConfigLoader::InitBackendType defaults an unset MOBILEGL_BACKEND_TYPE to
// DirectGLES, so the same default belongs here; this used to report the literal
// "<unset>" instead. Under ctest the variable is always set by the ENVIRONMENT
// property, which is why that never showed - but run straight from a device
// shell, where nothing sets it, DirectGLES came up and every case gated on the
// NAME DirectGLES skipped as though it had not.
m_backendName = EnvOr("MOBILEGL_BACKEND_TYPE", "DirectGLES");
m_usable = BringUp();
}
@@ -21,12 +21,49 @@
#pragma once
#include <cctype>
#include <cstdlib>
#include <string>
#include <gtest/gtest.h>
#include "HeadlessGL.h"
namespace MGITest {
// How a MOBILEGL_* quirk variable reads in THIS process's environment.
//
// A scenario that needs a non-default configuration takes it from here and skips
// when the process it was launched into is not in that configuration, rather than
// writing MG_Config::Features itself. Two reasons, and the second one decides it:
//
// - the feature table is an internal symbol. On Android this module links against
// the SHIPPING libMobileGL.so - deliberately, so the on-device run validates the
// real artifact - and that library is built -fvisibility=hidden, so nothing
// internal is reachable from here at all.
// - a quirk poked in-process is already too late for everything latched at
// initialization: the compile pool and its threads, and the backend's advertised
// extension list, which is built once from the configuration in force at first
// use. The process-wide variable is the only spelling that covers the whole
// configuration instead of the half of it that is still mutable afterwards.
//
// The reading rule is MG_ConfigLoader's, character for character (ConfigLoader.cpp,
// QueryEnvQuirkOverride / IsTruthyValue): unset is Auto - device auto-detection or a
// built-in default, i.e. a value only the implementation knows - a truthy value is
// On, and anything else that IS set ("0", "false", "") is Off.
enum class AmbientQuirk { Auto, On, Off };
inline AmbientQuirk AmbientQuirkFromEnvironment(const char* name) {
const char* value = std::getenv(name);
if (value == nullptr) return AmbientQuirk::Auto;
std::string lowered(value);
for (char& c : lowered) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
if (lowered.empty() || lowered == "0" || lowered == "false") return AmbientQuirk::Off;
return AmbientQuirk::On;
}
class ScenarioTest : public ::testing::Test {
protected:
void SetUp() override {
@@ -25,10 +25,12 @@
// be able to turn this into a red.
// (b) Forcing the join afterwards produces the right answer for every one of them:
// GL_COMPILE_STATUS true, an empty info log, and a program that links.
// (c) The extension string matches the configuration. This is the half a recorded
// trace can never cover - Iris and Sodium change their submission schedule the
// moment they see the string - so it is asserted against a real backend's real
// GL_EXTENSIONS, through both glGetString and glGetStringi.
// (c) The extension string matches the configuration - where "the configuration" is
// MOBILEGL_ASYNC_SHADER_COMPILE as this process inherited it, and NOT anything the
// implementation says about itself. This is the half a recorded trace can never
// cover - Iris and Sodium change their submission schedule the moment they see the
// string - so it is asserted against a real backend's real GL_EXTENSIONS, through
// both glGetString and glGetStringi.
// (d) glMaxShaderCompilerThreadsKHR(0) leaves nothing in flight: every subsequent
// GL_COMPLETION_STATUS_KHR reads GL_TRUE immediately, and compilation after it
// is synchronous. That is what the extension requires of a zero count.
@@ -40,6 +42,27 @@
//
// Backend selection is the module's usual one process, one backend (MOBILEGL_BACKEND_TYPE),
// so this file runs twice per ctest invocation.
//
// COMPILATION MODE IS PER PROCESS TOO. Every case here needs a particular configuration of
// MobileGL's shader compiler, and takes it from the ENVIRONMENT
// (MOBILEGL_ASYNC_SHADER_COMPILE, MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS) rather than by
// writing MG_Config::Features on the way past. Half of what those variables decide is
// latched before the first GL call - the compile pool and its threads, and the advertised
// extension list a backend builds once from the configuration in force at its first use -
// so an in-process poke could only ever have moved the other half; and on Android it could
// move nothing at all, because this module links against the shipping libMobileGL.so, which
// exports no such symbol. A case whose process is not in the configuration it needs SKIPS
// with that as its reason. CMakeLists.txt registers the extra ctest entries that put a
// process into each configuration (AsyncOn., AsyncOff., OptimisticShaderStatus.), so one
// ctest run still covers both sides of every switch. Run straight from a shell with nothing
// set - the on-device shape - the ambient configuration runs and the rest skip cleanly.
//
// WITHIN one process, "compiled on a worker" versus "compiled on this thread" is switched
// through glMaxShaderCompilerThreadsKHR, the extension's own entry point: a zero count joins
// everything outstanding and compiles inline from then on, any nonzero count lifts that
// again, and 0xFFFFFFFF asks for the implementation maximum (GL_Program.cpp,
// MaxShaderCompilerThreadsKHR_State). Doing it through the public call rather than the
// feature table means the switching is itself part of what these cases exercise.
#include <string>
#include <vector>
@@ -47,9 +70,6 @@
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#include "Config.h"
#include "MG_Util/Async/ShaderCompilePool.h"
#ifdef GLAPI
#undef GLAPI
#endif
@@ -76,8 +96,6 @@ extern "C" void glMaxShaderCompilerThreadsKHR(GLuint count);
namespace MGITest {
namespace {
using MobileGL::MG_Config::QuirkOverride;
// Same shape as the other scenarios: a two-attribute pass-through, so the only
// thing that can differ between the two compilation modes is the compilation.
constexpr const char* kVertexSource = R"(#version 330 core
@@ -139,50 +157,40 @@ void main() {
return source;
}
// MOBILEGL_ASYNC_SHADER_COMPILE decides the ambient mode; a scenario that wants
// the other one says so here and gets the ambient one back on scope exit. Forcing
// it in-process is what lets ONE ctest run compare the two modes against each
// other - the whole point of (e).
class AsyncModeScope {
public:
explicit AsyncModeScope(bool async) : m_saved(MobileGL::MG_Config::Features.AsyncShaderCompile) {
MobileGL::MG_Config::Features.AsyncShaderCompile =
async ? QuirkOverride::ForceOn : QuirkOverride::ForceOff;
// Whether this context advertises GL_KHR_parallel_shader_compile, which is exactly
// "MobileGL is configured to compile asynchronously" as an application can see it:
// the backends gate the string on AsyncShaderCompileEnabled() and on nothing else
// (BackendObject_DirectGLES.cpp / BackendObject_DirectVulkan.cpp), and the string
// is the only way MobileGL ever tells anyone. A case that needs asynchronous
// compilation checks for it the way an application would, and skips without it.
//
// The INDEXED form, because that is the one a core-profile application reads.
bool HasParallelShaderCompile() {
GLint count = 0;
glGetIntegerv(GL_NUM_EXTENSIONS, &count);
for (GLint i = 0; i < count; ++i) {
const char* name = reinterpret_cast<const char*>(glGetStringi(GL_EXTENSIONS, GLuint(i)));
if (name != nullptr && std::string(name) == "GL_KHR_parallel_shader_compile") return true;
}
~AsyncModeScope() { MobileGL::MG_Config::Features.AsyncShaderCompile = m_saved; }
AsyncModeScope(const AsyncModeScope&) = delete;
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
return false;
}
private:
const QuirkOverride m_saved;
};
// MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS, forced in-process for the same reason
// as AsyncModeScope: one ctest run asserts the quirk against the ambient default.
class OptimisticStatusScope {
public:
explicit OptimisticStatusScope(const QuirkOverride mode)
: m_saved(MobileGL::MG_Config::Features.AsyncOptimisticShaderStatus) {
MobileGL::MG_Config::Features.AsyncOptimisticShaderStatus = mode;
}
~OptimisticStatusScope() { MobileGL::MG_Config::Features.AsyncOptimisticShaderStatus = m_saved; }
OptimisticStatusScope(const OptimisticStatusScope&) = delete;
OptimisticStatusScope& operator=(const OptimisticStatusScope&) = delete;
private:
const QuirkOverride m_saved;
};
// glMaxShaderCompilerThreadsKHR writes process-wide state; a scenario that calls
// it has to put the pool back or it changes how every scenario after it compiles.
// glMaxShaderCompilerThreadsKHR writes process-wide state; a scenario that calls it
// has to put the pool back or it changes how every scenario after it compiles.
//
// The restore is the extension's own "implementation maximum" spelling rather than a
// hand-rolled poke at the pool. glMaxShaderCompilerThreadsKHR(0xFFFFFFFF) is defined
// (GL_Program.cpp, MaxShaderCompilerThreadsKHR_State) as precisely the two steps this
// used to perform through internal entry points - concurrency := the pool's full
// thread count, then lift any suspension a zero count had armed - in the safer order,
// since it raises the budget before re-admitting work rather than after. Going through
// the public call also puts the restore path itself under test, and it is the only
// spelling available on Android, where this module links the shipping shared library
// and can reach nothing but the GL entry points.
class CompilerThreadScope {
public:
CompilerThreadScope() = default;
~CompilerThreadScope() {
MobileGL::MG_Util::Async::SetAsyncShaderCompileSuspended(false);
auto& pool = MobileGL::MG_Util::Async::ShaderCompilePool::Get();
pool.SetMaxConcurrency(pool.GetThreadCount());
}
~CompilerThreadScope() { glMaxShaderCompilerThreadsKHR(0xFFFFFFFFu); }
CompilerThreadScope(const CompilerThreadScope&) = delete;
CompilerThreadScope& operator=(const CompilerThreadScope&) = delete;
};
@@ -293,7 +301,12 @@ void main() {
// interesting for shaders that (a) proved were genuinely still outstanding.
TEST_F(AsyncCompileScenario, CompletionStatusPollingThenForcedJoin) {
if (!Ready()) return;
const AsyncModeScope async(true);
if (!HasParallelShaderCompile()) {
GTEST_SKIP() << "this process is configured to compile inline "
"(GL_KHR_parallel_shader_compile is not advertised), so no compile can be "
"outstanding; the AsyncOn. ctest entries run this case with "
"MOBILEGL_ASYNC_SHADER_COMPILE=1";
}
const CompilerThreadScope threads;
// One worker, so the queue behind it is what the poll observes.
glMaxShaderCompilerThreadsKHR(1);
@@ -341,14 +354,33 @@ void main() {
}
// ---- (c) ------------------------------------------------------------------
// The extension string, read from a real backend that really brought a driver
// up. No mode forcing here: a backend builds its advertised list once, from the
// configuration in force at its first use, so the meaningful assertion is
// against the AMBIENT configuration - which is exactly what makes this case
// worth running in both of the suite's flag states.
// The extension string, read from a real backend that really brought a driver up.
//
// The expectation comes from the ENVIRONMENT, never from the implementation. This
// case used to derive it by calling AsyncShaderCompileEnabled() - which is the same
// function the backends gate the string on, so the two halves could only ever agree
// and the case would have passed however wrong both of them were. Asserting an
// implementation against itself pins nothing.
//
// MOBILEGL_ASYNC_SHADER_COMPILE is the whole input: the process inherited it before
// any GL call, a backend builds its advertised list once from the configuration in
// force at first use, and nothing in this process can move it afterwards. So reading
// the variable IS reading the configuration, independently. With the variable unset
// the configuration in force is MobileGL's built-in default, which only the
// implementation knows - there is nothing independent left to compare against, and
// this case says so rather than inventing an expectation. The AsyncOn. and AsyncOff.
// ctest entries pin the variable to each of its two values, so one ctest run still
// asserts both the advertised and the withdrawn side.
TEST_F(AsyncCompileScenario, ExtensionStringMatchesTheConfiguration) {
if (!Ready()) return;
const bool expected = MobileGL::MG_Util::Async::AsyncShaderCompileEnabled();
const AmbientQuirk configured = AmbientQuirkFromEnvironment("MOBILEGL_ASYNC_SHADER_COMPILE");
if (configured == AmbientQuirk::Auto) {
GTEST_SKIP() << "MOBILEGL_ASYNC_SHADER_COMPILE is unset, so the configuration in force is "
"MobileGL's built-in default and the only way to learn it would be to ask "
"the implementation this case exists to check; the AsyncOn. and AsyncOff. "
"ctest entries run it with the variable pinned to each of its two values";
}
const bool expected = configured == AmbientQuirk::On;
const char* extensions = reinterpret_cast<const char*>(glGetString(GL_EXTENSIONS));
ASSERT_NE(extensions, nullptr);
@@ -385,7 +417,12 @@ void main() {
// A zero count must leave nothing in flight and keep it that way.
TEST_F(AsyncCompileScenario, ZeroCompilerThreadsSettlesEverythingImmediately) {
if (!Ready()) return;
const AsyncModeScope async(true);
if (!HasParallelShaderCompile()) {
GTEST_SKIP() << "this process is configured to compile inline "
"(GL_KHR_parallel_shader_compile is not advertised), so a zero count has "
"nothing to settle; the AsyncOn. ctest entries run this case with "
"MOBILEGL_ASYNC_SHADER_COMPILE=1";
}
const CompilerThreadScope threads;
glMaxShaderCompilerThreadsKHR(1);
@@ -417,12 +454,28 @@ void main() {
// Compared through the DEFAULT framebuffer deliberately: that is where the
// backend's orientation and present path live, so the comparison covers the
// whole pipeline rather than the reflection tables alone.
//
// The two modes are selected through glMaxShaderCompilerThreadsKHR, the extension's
// own entry point, rather than through the feature table: a zero count joins
// everything outstanding and makes every later glCompileShader/glLinkProgram run its
// body on the calling thread, and 0xFFFFFFFF lifts that again with the pool at its
// full thread count (GL_Program.cpp, MaxShaderCompilerThreadsKHR_State; the compile
// and link paths both gate on AsyncShaderCompileActive(), which is what the zero
// count switches). So this is still one process comparing worker-built artifacts
// against inline-built ones - just asked for the way an application asks.
TEST_F(AsyncCompileScenario, AsyncAndSyncProgramsRenderIdenticalFrames) {
if (!Ready()) return;
if (!HasParallelShaderCompile()) {
GTEST_SKIP() << "this process is configured to compile inline "
"(GL_KHR_parallel_shader_compile is not advertised), so both halves would "
"be the same inline build and the comparison would be vacuous; the "
"AsyncOn. ctest entries run this case with MOBILEGL_ASYNC_SHADER_COMPILE=1";
}
const CompilerThreadScope threads;
Image asyncImage;
{
const AsyncModeScope async(true);
glMaxShaderCompilerThreadsKHR(0xFFFFFFFFu);
const GLuint program = BuildProgram();
ASSERT_NE(program, 0u);
asyncImage = DrawFrameWith(program);
@@ -431,7 +484,7 @@ void main() {
Image syncImage;
{
const AsyncModeScope async(false);
glMaxShaderCompilerThreadsKHR(0);
const GLuint program = BuildProgram();
ASSERT_NE(program, 0u);
syncImage = DrawFrameWith(program);
@@ -456,11 +509,16 @@ void main() {
// candidate) shows up here and not in the single-program case above.
TEST_F(AsyncCompileScenario, ABatchOfAsyncProgramsAllRenderCorrectly) {
if (!Ready()) return;
if (!HasParallelShaderCompile()) {
GTEST_SKIP() << "this process is configured to compile inline "
"(GL_KHR_parallel_shader_compile is not advertised), so nothing would be "
"built on a worker and there is no per-worker state to leak; the AsyncOn. "
"ctest entries run this case with MOBILEGL_ASYNC_SHADER_COMPILE=1";
}
constexpr int kPrograms = 12;
std::vector<GLuint> programs;
{
const AsyncModeScope async(true);
const CompilerThreadScope threads;
glMaxShaderCompilerThreadsKHR(1);
// Everything enqueued before anything is read: the only shape in which
@@ -489,6 +547,21 @@ void main() {
// then mis-renders - shows up here as a wrong quadrant signature.
TEST_F(AsyncCompileScenario, IrisShapedTwoPhaseBatchRendersCorrectly) {
if (!Ready()) return;
// The quirk is off by default and never advertised, so unlike the cases above
// there is no GL observable that says whether it is in force - only the variable
// that put it there. It also has to be set BEFORE this process started for the
// shape to be the real one: the optimistic answer is latched per compile, and a
// quirk switched on mid-process would only cover the compiles after it.
if (AmbientQuirkFromEnvironment("MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS") != AmbientQuirk::On) {
GTEST_SKIP() << "this case is the optimistic-status quirk's end-to-end shape and needs it on "
"for the whole process; the OptimisticShaderStatus. ctest entries run it with "
"MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS=1";
}
if (!HasParallelShaderCompile()) {
GTEST_SKIP() << "the optimistic status only ever applies to a compile that is still in flight "
"(OptimisticShaderStatusActive() requires AsyncShaderCompileActive()), and "
"this process is configured to compile inline";
}
constexpr int kPrograms = 12;
// Distinct per program (so neither the source memo nor the adoption map turns
@@ -508,8 +581,6 @@ void main() {
std::vector<GLuint> programs;
{
const AsyncModeScope async(true);
const OptimisticStatusScope quirk(QuirkOverride::ForceOn);
const CompilerThreadScope threads;
glMaxShaderCompilerThreadsKHR(1);
@@ -0,0 +1,380 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/UnboundImageDescriptorScenario.cpp
// Copyright (c) 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 PROGRAM DECLARES AN IMAGE-BACKED RESOURCE AND THE APPLICATION BINDS NOTHING.
//
// The sibling of GuiBatchScenario's MeshesBlockLeftUnbound, one descriptor kind further out.
// That one pinned an unbound shader storage BLOCK; the same "nothing is bound, so lose the
// whole draw" shape survived in the three image-backed kinds:
//
// * `samplerBuffer` - a texture unit with no buffer texture on it, and a buffer texture with
// no GL buffer attached to it. Both make the sampler INCOMPLETE (GL 4.6
// core 8.9, 8.24), and sampling an incomplete texture returns undefined
// VALUES. It is not an error and it is not a lost draw.
// * `imageBuffer` - an image unit with nothing on it. GL 4.6 core 8.26 is explicit: loads
// return zero and stores are discarded.
// * `image2D` - the same rule, through a VkImageView rather than a VkBufferView.
//
// Vulkan has no such thing as an unwritten descriptor, so DirectVulkan's descriptor resolution
// used to answer "no valid descriptor" and both SetupDraw and DispatchCompute skip everything on
// that answer - the draw or dispatch simply never happened, silently. Every test below asserts
// on the OTHER work in the same shader: the pixels the fragment stage painted, or the buffer the
// dispatch filled. All of it is unrelated to the unbound resource and all of it disappeared.
//
// The unbound resource is STATICALLY USED in every case, because an unreferenced one is
// optimised out before it ever reaches a descriptor and would prove nothing. Where the use is a
// read it sits behind a uniform-controlled branch that is false at runtime - the descriptor is
// declared and must be written, but no undefined value reaches an assertion. Where it is a write
// (the `writeonly` cases, which is how the real workloads spell it) it is unconditional: GL says
// the store is discarded, so there is nothing to guard against.
//
// Reproduces on DirectVulkan only. DirectGLES forwards the unbound unit to the GLES driver,
// which does what GL says, so it is the control - every test here must stay green on both.
#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 int kFboSize = 32;
constexpr int kElements = 4;
// No vertex attributes: the quad's corners come from gl_VertexID, so nothing about the
// vertex fetch can be confused with the descriptor question under test.
constexpr const char* kQuadVertexSource = R"(#version 430 core
void main() {
vec2 corner = vec2((gl_VertexID & 1) == 0 ? -1.0 : 1.0,
(gl_VertexID & 2) == 0 ? -1.0 : 1.0);
gl_Position = vec4(corner, 0.0, 1.0);
}
)";
// The assertion in every draw case: opaque green everywhere. The unbound resource
// contributes nothing to it - u_readUnbound is 0, so the fetch never runs - but the
// descriptor for it still has to exist, which is the point.
constexpr const char* kSamplerBufferFragmentSource = R"(#version 430 core
uniform samplerBuffer u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = texelFetch(u_unbound, 0);
}
o_color = color;
}
)";
constexpr const char* kSamplerBufferComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Output { uint g_data[]; };
uniform samplerBuffer u_unbound;
uniform int u_readUnbound;
void main() {
uint index = gl_GlobalInvocationID.x;
uint value = index + 1u;
if (u_readUnbound != 0) {
value += uint(texelFetch(u_unbound, 0).r);
}
g_data[index] = value;
}
)";
// writeonly, and the store is unconditional: this is how AcceleratedRendering and the
// conformance cases spell an image the shader only produces into. GL discards the store
// when the unit is empty; nothing here reads it back.
constexpr const char* kImageBufferFragmentSource = R"(#version 430 core
layout(binding = 0, r32ui) uniform writeonly uimageBuffer u_unbound;
out vec4 o_color;
void main() {
imageStore(u_unbound, 0, uvec4(7u));
o_color = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
constexpr const char* kImageBufferComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Output { uint g_data[]; };
layout(binding = 0, r32ui) uniform writeonly uimageBuffer u_unbound;
void main() {
uint index = gl_GlobalInvocationID.x;
imageStore(u_unbound, int(index), uvec4(7u));
g_data[index] = index + 1u;
}
)";
constexpr const char* kImage2DFragmentSource = R"(#version 430 core
layout(binding = 0, rgba8) uniform writeonly image2D u_unbound;
out vec4 o_color;
void main() {
imageStore(u_unbound, ivec2(0, 0), vec4(1.0));
o_color = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
constexpr const char* kImage2DComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Output { uint g_data[]; };
layout(binding = 0, rgba8) uniform writeonly image2D u_unbound;
void main() {
uint index = gl_GlobalInvocationID.x;
imageStore(u_unbound, ivec2(int(index), 0), vec4(1.0));
g_data[index] = index + 1u;
}
)";
// No layout format at all, which GLSL 4.20 allows for a write-only image. The reflection
// then carries NO format for the binding, so the placeholder descriptor can only be
// constrained by the declaration's numeric class - a different route through the fix than
// every typed case above.
constexpr const char* kFormatlessImage2DComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Output { uint g_data[]; };
layout(binding = 0) uniform writeonly image2D u_unbound;
void main() {
uint index = gl_GlobalInvocationID.x;
imageStore(u_unbound, ivec2(int(index), 0), vec4(1.0));
g_data[index] = index + 1u;
}
)";
class UnboundImageDescriptorScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_target = MakeColorFbo(kFboSize, kFboSize);
ASSERT_NE(m_target.fbo, 0u) << "could not create the render target";
glGenVertexArrays(1, &m_vao);
glGenBuffers(1, &m_storage);
// The harness shares one context across every scenario in the process, so an
// earlier one may well have left a texture on unit 0 or an image on unit 0. The
// whole subject here is that nothing is bound, so say so rather than assume it.
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_BUFFER, 0);
glBindTexture(GL_TEXTURE_2D, 0);
glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA8);
FirstGLError();
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
if (m_program != 0) glDeleteProgram(m_program);
if (m_storage != 0) glDeleteBuffers(1, &m_storage);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
BindDefaultFramebuffer();
DestroyColorFbo(m_target);
glViewport(0, 0, Gl().Width(), Gl().Height());
}
// Each case needs exactly one kind of opaque uniform in one stage, and a host with
// none of that kind there would report a failure that is about the host, not the fix.
// Asked for by the limit that governs the kind under test and no other: a guard that
// over-asks turns into a silent skip of the very thing the case exists for.
static bool LimitIsAtLeastOne(GLenum limit) {
GLint value = 0;
glGetIntegerv(limit, &value);
while (glGetError() != GL_NO_ERROR) {
}
return value >= 1;
}
unsigned int MakeComputeProgram(const char* source) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[4096] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute shader did not compile: " << log;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[4096] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute program did not link: " << log;
glDeleteProgram(program);
return 0;
}
return program;
}
// Fills a four-element SSBO with 1..4 while the unbound resource is declared and
// statically used. Zeros everywhere mean the dispatch never ran.
void ExpectDispatchStillRuns(const char* source, const char* what) {
m_program = MakeComputeProgram(source);
ASSERT_NE(m_program, 0u);
const std::vector<unsigned int> zeros(static_cast<std::size_t>(kElements), 0u);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_storage);
glBufferData(GL_SHADER_STORAGE_BUFFER,
static_cast<GLsizeiptr>(zeros.size() * sizeof(unsigned int)), zeros.data(),
GL_DYNAMIC_COPY);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_storage);
ASSERT_EQ(FirstGLError(), 0u) << "setting up the output buffer raised a GL error";
glUseProgram(m_program);
const GLint readUnbound = glGetUniformLocation(m_program, "u_readUnbound");
if (readUnbound != -1) {
glUniform1i(readUnbound, 0);
}
glDispatchCompute(kElements, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
EXPECT_EQ(FirstGLError(), 0u) << "the dispatch raised a GL error (" << what << ")";
std::vector<unsigned int> values(static_cast<std::size_t>(kElements), 0xDEADBEEFu);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_storage);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0,
static_cast<GLsizeiptr>(values.size() * sizeof(unsigned int)), values.data());
for (int i = 0; i < kElements; ++i) {
EXPECT_EQ(values[static_cast<std::size_t>(i)], static_cast<unsigned int>(i + 1))
<< "element " << i << " came back as " << values[static_cast<std::size_t>(i)]
<< "; zero everywhere means the whole dispatch was dropped over the unbound " << what;
}
}
// Paints the whole render target green while the unbound resource is declared and
// statically used. A black target means the draw never happened.
void ExpectDrawStillRuns(const char* fragmentSource, const char* what) {
std::string error;
m_program = CompileProgram(kQuadVertexSource, fragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
BindFbo(m_target);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glBindVertexArray(m_vao);
glUseProgram(m_program);
const GLint readUnbound = glGetUniformLocation(m_program, "u_readUnbound");
if (readUnbound != -1) {
glUniform1i(readUnbound, 0);
}
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
EXPECT_EQ(FirstGLError(), 0u) << "the draw raised a GL error (" << what << ")";
const Image image = ReadPixels(kFboSize, kFboSize);
ASSERT_FALSE(image.Empty()) << "the readback came back empty";
// Whole-region, not a centre pixel: the quad covers the target exactly, so
// anything short of all of it is a failure worth naming.
EXPECT_TRUE(RegionIsMostly(image, 0, kFboSize - 1, 0, kFboSize - 1, "green", 0.0,
std::string("the quad drawn with an unbound ") + what))
<< "an all-black target means the draw was dropped over the unbound " << what;
}
ColorFbo m_target{};
GLuint m_vao = 0;
GLuint m_storage = 0;
unsigned int m_program = 0;
};
} // namespace
// ---- uniform samplerBuffer (VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER) --------------------
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSamplerBufferDoesNotLoseTheDispatch) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_COMPUTE_TEXTURE_IMAGE_UNITS)) {
GTEST_SKIP() << "the compute stage has no texture image units";
}
ExpectDispatchStillRuns(kSamplerBufferComputeSource, "samplerBuffer");
}
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSamplerBufferDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kSamplerBufferFragmentSource, "samplerBuffer");
}
// The other way a texel-buffer descriptor comes out empty: the unit HAS a buffer texture, but
// no glTexBuffer ever attached a buffer object to it. GL calls that texture incomplete, which
// is undefined data and not a lost draw - a separate site in the resolve from the one above,
// and it used to return false too.
TEST_F(UnboundImageDescriptorScenario, ABufferTextureWithNoAttachedBufferDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
GLuint texture = 0;
glGenTextures(1, &texture);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_BUFFER, texture);
// Deliberately no glTexBuffer: the texture exists and is bound, and has no store.
ASSERT_EQ(FirstGLError(), 0u) << "binding an empty buffer texture raised a GL error";
ExpectDrawStillRuns(kSamplerBufferFragmentSource, "buffer texture with no attached buffer");
glBindTexture(GL_TEXTURE_BUFFER, 0);
glDeleteTextures(1, &texture);
}
// ---- writeonly imageBuffer (VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER) --------------------
TEST_F(UnboundImageDescriptorScenario, AWriteonlyImageBufferLeftUnboundDoesNotLoseTheDispatch) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_COMPUTE_IMAGE_UNIFORMS)) {
GTEST_SKIP() << "the compute stage has no image uniforms";
}
ExpectDispatchStillRuns(kImageBufferComputeSource, "imageBuffer");
}
TEST_F(UnboundImageDescriptorScenario, AWriteonlyImageBufferLeftUnboundDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_FRAGMENT_IMAGE_UNIFORMS)) {
GTEST_SKIP() << "the fragment stage has no image uniforms";
}
ExpectDrawStillRuns(kImageBufferFragmentSource, "imageBuffer");
}
// ---- writeonly image2D (VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) -------------------------------
TEST_F(UnboundImageDescriptorScenario, AWriteonlyImage2DLeftUnboundDoesNotLoseTheDispatch) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_COMPUTE_IMAGE_UNIFORMS)) {
GTEST_SKIP() << "the compute stage has no image uniforms";
}
ExpectDispatchStillRuns(kImage2DComputeSource, "image2D");
}
TEST_F(UnboundImageDescriptorScenario, AWriteonlyImage2DLeftUnboundDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_FRAGMENT_IMAGE_UNIFORMS)) {
GTEST_SKIP() << "the fragment stage has no image uniforms";
}
ExpectDrawStillRuns(kImage2DFragmentSource, "image2D");
}
TEST_F(UnboundImageDescriptorScenario, AFormatlessWriteonlyImage2DLeftUnboundDoesNotLoseTheDispatch) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_COMPUTE_IMAGE_UNIFORMS)) {
GTEST_SKIP() << "the compute stage has no image uniforms";
}
ExpectDispatchStillRuns(kFormatlessImage2DComputeSource, "format-less image2D");
}
} // namespace MGITest
@@ -55,10 +55,6 @@
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
// For the emulation switch the negative-control case below flips. Nothing else in this file needs
// to know which backend it is running on.
#include <Config.h>
#ifdef GLAPI
#undef GLAPI
#endif
@@ -529,7 +525,8 @@ void main() { fragColor = vec4(float(gsIndex) * 16.0 / 255.0, 0.0, 0.0, 1.0); }
//
// Everything above is a claim about pixels, and a claim about pixels cannot tell an
// emulation that works from a backend that was going to be right anyway. This case builds
// the SAME program with MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION off and requires case 1's
// the SAME program in a process started with MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION=0
// (the NoViewportArrayEmulation. ctest entry) and requires case 1's
// result to COLLAPSE: with no routing, every geometry invocation rasterizes against
// viewport 0's rectangle, so the last invocation paints the whole surface and every cell
// reads 15 instead of its own index. That is the pre-emulation behaviour this backend had
@@ -544,25 +541,31 @@ void main() { fragColor = vec4(float(gsIndex) * 16.0 / 255.0, 0.0, 0.0, 1.0); }
"gl_ViewportIndex natively and ignores it";
}
// The feature table is a process-global and this fixture shares its context with every
// other scenario in the process, so the restore is not optional.
struct ScopedEmulationOff {
ScopedEmulationOff(): saved(MobileGL::MG_Config::Features.ViewportArrayEmulation) {
MobileGL::MG_Config::Features.ViewportArrayEmulation =
MobileGL::MG_Config::QuirkOverride::ForceOff;
}
~ScopedEmulationOff() { MobileGL::MG_Config::Features.ViewportArrayEmulation = saved; }
MobileGL::MG_Config::QuirkOverride saved;
};
// The switch comes from the ENVIRONMENT, and this case runs only in a process that
// was started with it off. It used to write MG_Config::Features directly, which is
// not available to it any more: on Android this module links the shipping
// libMobileGL.so - so that the on-device run validates the real artifact - and that
// library exports no such symbol. The process-wide variable is also the more honest
// spelling of the control, since it is the one a developer chasing this failure
// would actually set. CMakeLists.txt registers the NoViewportArrayEmulation. ctest
// entry for it, so the control still runs in every ctest run; anywhere else - the
// ambient ctest entries, or the binary run straight from a device shell - the
// emulation is on and this case skips.
if (AmbientQuirkFromEnvironment("MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION") != AmbientQuirk::Off) {
GTEST_SKIP() << "this is the negative control for the emulation and needs it off for the "
"whole process; the NoViewportArrayEmulation. ctest entry runs it with "
"MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION=0";
}
IntTarget target = MakeIntTarget(kSurfaceSide, kSurfaceSide);
SetupGridViewports(kCellSize, kCellSize);
GLuint unroutedProgram = 0;
{
const ScopedEmulationOff scopedEmulationOff;
// A FRESH program: the emitted ESSL is decided at link time and memoized on a key
// that carries this flag, so reusing m_program would just replay the routed build.
// A program of its own rather than the fixture's, even though in this process
// the fixture's was built unrouted too: the emitted ESSL is decided at link
// time and memoized on a key that carries this flag, and building it here keeps
// what this case measures independent of when SetUp happened to link.
unroutedProgram = BuildProgram(kGridGeometrySource, kIntFragmentSource);
ASSERT_NE(unroutedProgram, 0u) << "unrouted program failed to build: " << m_buildLog;
glUseProgram(unroutedProgram);
+67
View File
@@ -988,6 +988,49 @@ TEST(GetterSanity, ReportsFragmentInterpolationLimitsForFloatAndIntegerQueries)
MG_State::pGLContext = Move(previousContext);
}
// GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT used to be answered with the UNIFORM buffer
// alignment. The two are separate limits and the storage one is the larger on real hardware
// (Adreno 830: 32 uniform, 64 storage), so the substitution under-reported it - and an
// under-reported alignment is silent all the way down: the frontend validator accepts the
// offset, the ES driver accepts the glBindBufferRange too without raising an error, and the
// shader's stores land at an address the application never bound. The two values are
// deliberately different here so a query that reads the wrong field cannot coincide with the
// right answer.
TEST(GetterSanity, StorageAndUniformBufferOffsetAlignmentsAreSeparateLimits) {
using namespace MobileGL;
auto previousContext = Move(MG_State::pGLContext);
auto previousBackend = Move(MG_Backend::pActiveBackendObject);
MG_State::pGLContext = MakeUnique<MG_State::GLState::GLContext>();
MG_Backend::DynamicBackendParameters params;
params.UniformBufferOffsetAlignment = 32;
params.ShaderStorageBufferOffsetAlignment = 64;
MG_Backend::pActiveBackendObject = MakeUnique<DynamicParameterBackend>(params);
GLint uniformAlignment = 0;
MG_Impl::GLImpl::GetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &uniformAlignment);
EXPECT_EQ(uniformAlignment, 32);
GLint storageAlignment = 0;
MG_Impl::GLImpl::GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &storageAlignment);
EXPECT_EQ(storageAlignment, 64);
// And the other way round, so the test fails on a getter that simply swapped the two fields.
params.UniformBufferOffsetAlignment = 128;
params.ShaderStorageBufferOffsetAlignment = 16;
MG_Backend::pActiveBackendObject = MakeUnique<DynamicParameterBackend>(params);
MG_Impl::GLImpl::GetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &uniformAlignment);
EXPECT_EQ(uniformAlignment, 128);
MG_Impl::GLImpl::GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &storageAlignment);
EXPECT_EQ(storageAlignment, 16);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
MG_Backend::pActiveBackendObject = Move(previousBackend);
MG_State::pGLContext = Move(previousContext);
}
TEST(GetterSanity, PerStageImageUniformQueriesMatchShaderCompilerLimits) {
using namespace MobileGL;
@@ -1550,6 +1593,30 @@ TEST(DirectVulkanSanity, SamplerUniformTypesPreserveTheirNumericDomain) {
SamplerNumericDomain::Unknown);
}
// The image half of the same question, which the sampler form above deliberately answers
// Unknown. It decides the format of the placeholder descriptor an UNBOUND image unit gets, and a
// `writeonly` declaration carries no format qualifier for it to fall back on - so an Unknown here
// is a lost draw, not a cosmetic gap.
TEST(DirectVulkanSanity, ImageUniformTypesPreserveTheirNumericDomain) {
using namespace MobileGL::MG_Backend::DirectVulkan;
EXPECT_EQ(ProgramFactory::UniformTypeToImageNumericDomain(GL_IMAGE_2D), SamplerNumericDomain::Float);
EXPECT_EQ(ProgramFactory::UniformTypeToImageNumericDomain(GL_IMAGE_BUFFER), SamplerNumericDomain::Float);
EXPECT_EQ(ProgramFactory::UniformTypeToImageNumericDomain(GL_IMAGE_CUBE_MAP_ARRAY),
SamplerNumericDomain::Float);
EXPECT_EQ(ProgramFactory::UniformTypeToImageNumericDomain(GL_INT_IMAGE_2D_ARRAY),
SamplerNumericDomain::SignedInteger);
EXPECT_EQ(ProgramFactory::UniformTypeToImageNumericDomain(GL_INT_IMAGE_BUFFER),
SamplerNumericDomain::SignedInteger);
EXPECT_EQ(ProgramFactory::UniformTypeToImageNumericDomain(GL_UNSIGNED_INT_IMAGE_3D),
SamplerNumericDomain::UnsignedInteger);
EXPECT_EQ(ProgramFactory::UniformTypeToImageNumericDomain(GL_UNSIGNED_INT_IMAGE_BUFFER),
SamplerNumericDomain::UnsignedInteger);
// Samplers are the other function's business, and answering for them here would let a
// sampler binding silently take an image binding's placeholder rules.
EXPECT_EQ(ProgramFactory::UniformTypeToImageNumericDomain(GL_SAMPLER_2D), SamplerNumericDomain::Unknown);
}
TEST(DirectVulkanSanity, SampledViewFormatMatchesSamplerNumericDomainWithoutChangingComponentLayout) {
using namespace MobileGL::MG_Backend::DirectVulkan;
@@ -11,6 +11,7 @@
#include <MG_Util/SelfTest/DriverBugProbes.h>
#include <algorithm>
#include <array>
#include <cstdlib>
#include <cstring>
#include <map>
@@ -24,6 +25,8 @@ using MobileGL::MG_Util::SelfTest::ProbeCrossStageImageQualifierMergeDropsWrites
using MobileGL::MG_Util::SelfTest::ProbeGeometryStageSsboWriteAfterEmitDropped;
using MobileGL::MG_Util::SelfTest::ProbeImageLocationPerNameBudget;
using MobileGL::MG_Util::SelfTest::ProbeImageWriteReadCoherencyResidual;
using MobileGL::MG_Util::SelfTest::ProbeBlitIgnoresDestinationArrayLayer;
using MobileGL::MG_Util::SelfTest::ProbeExplicitVertexInputLocationCeiling;
using MobileGL::MG_Util::SelfTest::ProbeR32FMultisampleSwizzleCorruption;
namespace {
@@ -46,6 +49,13 @@ namespace {
// CollectGlesKnownDriverBugs(): the collector goes through the once-per-process memos, and a
// memo latched by one test would decide the answer for every later one.
// The exact text an affected Adreno driver puts in the compile log when it refuses a
// vertex input's layout(location = N). Quoted rather than paraphrased for the same reason the
// link log below is: the report shows it to a human, so a probe that stopped capturing it
// would stop being useful long before it stopped detecting.
const char* const kAttributeRangeCompileLog =
"ERROR: 0:2: '' : the location is not within attribute range [0, MAX_ATTRIBUTES-1] \nERROR: 1 compilation errors. No code generated.";
// The exact text an affected Adreno driver puts in the info log for this refusal.
const char* const kImageLocationLinkLog =
"Error: Image Image location or component exceeds max allowed.\nError: Linking failed.";
@@ -82,6 +92,21 @@ namespace {
int coherencyEmittedShapeFailedTexels = 0;
int coherencyControlFailedTexels = 0;
// Probe 5: GL_MAX_VERTEX_ATTRIBS, and the two separate ceilings the probe has to tell
// apart - how high `layout(location = N)` may go in the ESSL compiler, and how high
// glBindAttribLocation may go at link. On an unaffected driver both are above the
// advertised count.
GLint maxVertexAttribs = 32;
int explicitLocationCeiling = 1000;
int bindAttribLocationCeiling = 1000;
// Probe 5's inconclusive path: nothing compiles, including the location-0 control.
bool everyCompileFails = false;
// Probe 6: a blit writes the destination array layer the framebuffer names, or always
// layer 0. The second knob is the inconclusive path - a driver that does not honour the
// SOURCE layer either fails the probe's control.
bool blitIgnoresDestinationLayer = false;
bool blitIgnoresSourceLayer = false;
// ---- object bookkeeping ---------------------------------------------
GLenum pendingError = GL_NO_ERROR;
GLuint nextShaderId = 1;
@@ -102,6 +127,19 @@ namespace {
// texture id -> GL_TEXTURE_SWIZZLE_A
std::map<GLuint, GLenum> multisampleAlphaSwizzle;
std::map<GLuint, bool> shaderCompiled;
std::map<GLuint, std::string> shaderInfoLogs;
// program -> (attribute name -> location) as glBindAttribLocation left it.
std::map<GLuint, std::map<std::string, GLint>> boundAttribLocations;
// 2D array texture id -> the byte every texel of each layer holds. Two layers is all the
// layered-blit probe uses, and one byte per layer is all it distinguishes.
std::map<GLuint, std::array<GLubyte, 2>> arrayLayerFill;
// framebuffer id -> the (2D array texture, layer) glFramebufferTextureLayer attached.
std::map<GLuint, std::pair<GLuint, GLint>> framebufferLayerAttachment;
GLuint boundArrayTexture = 0;
GLuint boundDrawFramebuffer = 0;
GLuint boundReadFramebuffer = 0;
GLuint boundMultisampleTexture = 0;
GLuint currentProgram = 0;
// How many programs that sample a multisample texture have been linked so far. The
@@ -149,6 +187,22 @@ namespace {
return names;
}
// The N in `layout(location = N) in ...`, or -1 when the source declares no such input.
// Read off the text the probe actually submitted, so a probe that stopped emitting the
// qualifier would stop being detected here too.
int ExplicitVertexInputLocationIn(const std::string& source) {
const std::size_t at = source.find("layout(location = ");
if (at == std::string::npos) return -1;
const std::size_t start = at + std::strlen("layout(location = ");
const std::size_t close = source.find(')', start);
if (close == std::string::npos) return -1;
// Only a VERTEX INPUT counts: `layout(location = 0) out vec4` is a different declaration
// and no driver caps it against GL_MAX_VERTEX_ATTRIBS.
const std::size_t declaration = source.find_first_not_of(" \t", close + 1);
if (declaration == std::string::npos || source.compare(declaration, 3, "in ") != 0) return -1;
return std::atoi(source.c_str() + start);
}
std::string StageSourceContaining(GLuint program, const char* needle) {
const auto attached = g_fake.programShaders.find(program);
if (attached == g_fake.programShaders.end()) return {};
@@ -217,6 +271,9 @@ namespace {
case GL_MAX_GEOMETRY_SHADER_STORAGE_BLOCKS:
*data = g_fake.maxGeometrySsboBlocks;
break;
case GL_MAX_VERTEX_ATTRIBS:
*data = g_fake.maxVertexAttribs;
break;
default:
break;
}
@@ -252,16 +309,41 @@ namespace {
}
g_fake.shaderSources[shader] = std::move(source);
};
funcs.glCompileShader = [](GLuint) {};
funcs.glGetShaderiv = [](GLuint, GLenum pname, GLint* params) {
if (pname == GL_COMPILE_STATUS) *params = GL_TRUE;
funcs.glCompileShader = [](GLuint shader) {
const std::string& source = SourceOf(shader);
const int location = ExplicitVertexInputLocationIn(source);
const bool refused =
g_fake.everyCompileFails || (location >= 0 && location >= g_fake.explicitLocationCeiling);
g_fake.shaderCompiled[shader] = !refused;
g_fake.shaderInfoLogs[shader] = refused ? kAttributeRangeCompileLog : "";
};
funcs.glGetShaderInfoLog = [](GLuint, GLsizei bufSize, GLsizei*, GLchar* infoLog) {
if (bufSize > 0) infoLog[0] = '\0';
funcs.glGetShaderiv = [](GLuint shader, GLenum pname, GLint* params) {
if (pname != GL_COMPILE_STATUS) return;
const auto it = g_fake.shaderCompiled.find(shader);
*params = (it == g_fake.shaderCompiled.end() || it->second) ? GL_TRUE : GL_FALSE;
};
funcs.glGetShaderInfoLog = [](GLuint shader, GLsizei bufSize, GLsizei*, GLchar* infoLog) {
if (bufSize <= 0) return;
const auto it = g_fake.shaderInfoLogs.find(shader);
const std::string& log = it == g_fake.shaderInfoLogs.end() ? std::string() : it->second;
const GLsizei copied = static_cast<GLsizei>(
std::min<std::size_t>(log.size(), static_cast<std::size_t>(bufSize - 1)));
std::memcpy(infoLog, log.data(), static_cast<std::size_t>(copied));
infoLog[copied] = '\0';
};
funcs.glDeleteShader = [](GLuint shader) {
if (shader != 0) --g_fake.aliveShaders;
};
funcs.glBindAttribLocation = [](GLuint program, GLuint index, const GLchar* name) {
g_fake.boundAttribLocations[program][name] = static_cast<GLint>(index);
};
funcs.glGetAttribLocation = [](GLuint program, const GLchar* name) -> GLint {
const auto programEntry = g_fake.boundAttribLocations.find(program);
if (programEntry == g_fake.boundAttribLocations.end()) return -1;
const auto nameEntry = programEntry->second.find(name);
if (nameEntry == programEntry->second.end()) return -1;
return nameEntry->second >= g_fake.bindAttribLocationCeiling ? -1 : nameEntry->second;
};
funcs.glCreateProgram = []() -> GLuint {
++g_fake.alivePrograms;
return g_fake.nextProgramId++;
@@ -272,7 +354,17 @@ namespace {
funcs.glLinkProgram = [](GLuint program) {
const std::vector<std::string> names = DeclaredImageNames(program);
const bool overBudget = static_cast<int>(names.size()) > g_fake.distinctImageNameBudget;
g_fake.programLinked[program] = !overBudget;
// A driver whose glBindAttribLocation ceiling is lower than the location asked for
// refuses the LINK rather than the compile - which is the half of the vertex-input
// probe that decides whether the attribute is reachable another way at all.
bool attributeOutOfRange = false;
if (const auto it = g_fake.boundAttribLocations.find(program);
it != g_fake.boundAttribLocations.end()) {
for (const auto& [attributeName, location] : it->second) {
if (location >= g_fake.bindAttribLocationCeiling) attributeOutOfRange = true;
}
}
g_fake.programLinked[program] = !overBudget && !attributeOutOfRange;
g_fake.programInfoLogs[program] = overBudget ? kImageLocationLinkLog : "";
if (!overBudget && !StageSourceContaining(program, "texelFetch(mg_probeSampler").empty()) {
++g_fake.sampledMultisampleProgramCount;
@@ -308,6 +400,20 @@ namespace {
};
funcs.glBindTexture = [](GLenum target, GLuint texture) {
if (target == GL_TEXTURE_2D_MULTISAMPLE) g_fake.boundMultisampleTexture = texture;
if (target == GL_TEXTURE_2D_ARRAY) g_fake.boundArrayTexture = texture;
};
funcs.glTexStorage3D = [](GLenum target, GLsizei, GLenum, GLsizei, GLsizei, GLsizei) {
if (target == GL_TEXTURE_2D_ARRAY) g_fake.arrayLayerFill[g_fake.boundArrayTexture] = {0, 0};
};
// One byte per layer: the layered-blit probe fills every texel of a layer with the same
// value and only ever asks which layer a value ended up on.
funcs.glTexSubImage3D = [](GLenum target, GLint, GLint, GLint, GLint zoffset, GLsizei, GLsizei,
GLsizei, GLenum, GLenum, const void* pixels) {
if (target != GL_TEXTURE_2D_ARRAY || pixels == nullptr) return;
auto& fill = g_fake.arrayLayerFill[g_fake.boundArrayTexture];
if (zoffset >= 0 && static_cast<std::size_t>(zoffset) < fill.size()) {
fill[static_cast<std::size_t>(zoffset)] = static_cast<const GLubyte*>(pixels)[0];
}
};
funcs.glDeleteTextures = [](GLsizei n, const GLuint* textures) {
for (GLsizei i = 0; i < n; ++i) {
@@ -332,12 +438,50 @@ namespace {
++g_fake.aliveFramebuffers;
}
};
funcs.glBindFramebuffer = [](GLenum, GLuint) {};
funcs.glBindFramebuffer = [](GLenum target, GLuint framebuffer) {
if (target == GL_FRAMEBUFFER || target == GL_DRAW_FRAMEBUFFER) {
g_fake.boundDrawFramebuffer = framebuffer;
}
if (target == GL_FRAMEBUFFER || target == GL_READ_FRAMEBUFFER) {
g_fake.boundReadFramebuffer = framebuffer;
}
};
funcs.glFramebufferTexture2D = [](GLenum, GLenum, GLenum, GLuint, GLint) {};
funcs.glFramebufferTextureLayer = [](GLenum target, GLenum, GLuint texture, GLint, GLint layer) {
const GLuint framebuffer = (target == GL_READ_FRAMEBUFFER) ? g_fake.boundReadFramebuffer
: g_fake.boundDrawFramebuffer;
g_fake.framebufferLayerAttachment[framebuffer] = {texture, layer};
};
funcs.glReadBuffer = [](GLenum) {};
// The defect itself: the source layer is read from where the READ framebuffer says (unless
// that knob is on too), and the result is written to the layer the DRAW framebuffer names -
// or to layer 0 regardless, which is what an affected driver does.
funcs.glBlitFramebuffer = [](GLint, GLint, GLint, GLint, GLint, GLint, GLint, GLint, GLbitfield,
GLenum) {
const auto source = g_fake.framebufferLayerAttachment.find(g_fake.boundReadFramebuffer);
const auto destination = g_fake.framebufferLayerAttachment.find(g_fake.boundDrawFramebuffer);
if (source == g_fake.framebufferLayerAttachment.end() ||
destination == g_fake.framebufferLayerAttachment.end()) {
return;
}
const GLint sourceLayer = g_fake.blitIgnoresSourceLayer ? 0 : source->second.second;
const GLint destinationLayer =
g_fake.blitIgnoresDestinationLayer ? 0 : destination->second.second;
auto& sourceFill = g_fake.arrayLayerFill[source->second.first];
auto& destinationFill = g_fake.arrayLayerFill[destination->second.first];
if (sourceLayer < 0 || static_cast<std::size_t>(sourceLayer) >= sourceFill.size()) return;
if (destinationLayer < 0 ||
static_cast<std::size_t>(destinationLayer) >= destinationFill.size()) {
return;
}
destinationFill[static_cast<std::size_t>(destinationLayer)] =
sourceFill[static_cast<std::size_t>(sourceLayer)];
};
funcs.glCheckFramebufferStatus = [](GLenum) -> GLenum { return GL_FRAMEBUFFER_COMPLETE; };
funcs.glDeleteFramebuffers = [](GLsizei n, const GLuint* framebuffers) {
for (GLsizei i = 0; i < n; ++i) {
if (framebuffers[i] != 0) --g_fake.aliveFramebuffers;
g_fake.framebufferLayerAttachment.erase(framebuffers[i]);
}
};
funcs.glGenVertexArrays = [](GLsizei n, GLuint* arrays) {
@@ -417,6 +561,26 @@ namespace {
funcs.glReadPixels = [](GLint, GLint, GLsizei width, GLsizei height, GLenum format, GLenum type,
void* pixels) {
const std::size_t texels = static_cast<std::size_t>(width) * static_cast<std::size_t>(height);
// Answered before anything else: a read framebuffer that names an array LAYER is the
// layered-blit probe asking what that layer holds, and its bytes have nothing to do
// with the pass/fail texel encoding the image probes below share.
if (const auto layered = g_fake.framebufferLayerAttachment.find(g_fake.boundReadFramebuffer);
layered != g_fake.framebufferLayerAttachment.end()) {
const auto& fill = g_fake.arrayLayerFill[layered->second.first];
const GLint layer = layered->second.second;
const GLubyte value =
(layer >= 0 && static_cast<std::size_t>(layer) < fill.size())
? fill[static_cast<std::size_t>(layer)]
: 0;
GLubyte* out = static_cast<GLubyte*>(pixels);
for (std::size_t i = 0; i < texels; ++i) {
out[i * 4 + 0] = value;
out[i * 4 + 1] = value;
out[i * 4 + 2] = value;
out[i * 4 + 3] = 255;
}
return;
}
if (format == GL_RED && type == GL_FLOAT) {
GLfloat* out = static_cast<GLfloat*>(pixels);
for (std::size_t i = 0; i < texels; ++i) out[i] = g_fake.lastSampledValue;
@@ -452,6 +616,10 @@ namespace {
// section would stop meaning "this device has these bugs".
TEST(DriverBugProbes, AProbeThatCannotRunReportsNoBug) {
const MG_External::GLESFunctionsTable gl = EmptyFunctionTable();
EXPECT_FALSE(ProbeBlitIgnoresDestinationArrayLayer(gl))
<< "a probe with no entry points has measured nothing";
EXPECT_FALSE(ProbeExplicitVertexInputLocationCeiling(gl).detected)
<< "a probe with no entry points has measured nothing";
EXPECT_FALSE(ProbeGeometryStageSsboWriteAfterEmitDropped(gl))
<< "a probe with no entry points to call must not claim the driver is affected";
EXPECT_FALSE(ProbeR32FMultisampleSwizzleCorruption(gl));
@@ -651,6 +819,125 @@ TEST(DriverBugProbes, ImageCoherencyReportsNothingWhenTheFinishSeparatedControlI
EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected);
}
// ===================== EXPLICIT VERTEX INPUT LOCATION CEILING =====================
// The clean case, and the one that has to stay cheap: a driver whose compiler accepts the
// highest location it advertises is measured in a single compile and withdraws nothing.
TEST(DriverBugProbes, VertexInputLocationCeilingIsCleanWhenTheAdvertisedMaximumCompiles) {
ResetFakeDriver();
g_fake.maxVertexAttribs = 32;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
const auto measurement = ProbeExplicitVertexInputLocationCeiling(gl);
EXPECT_FALSE(measurement.detected);
EXPECT_EQ(measurement.advertisedMaxVertexAttribs, 32);
EXPECT_EQ(measurement.usableLocations, 32) << "an unaffected driver must be taken at its word";
ExpectProbeReleasedEverything();
}
// The defect: 32 advertised, the qualifier refused from 16 up. The bisection has to land on the
// boundary exactly - one off in either direction advertises an attribute that cannot be declared,
// or withdraws one that can.
TEST(DriverBugProbes, VertexInputLocationCeilingIsMeasuredWhenTheQualifierIsCapped) {
ResetFakeDriver();
g_fake.maxVertexAttribs = 32;
g_fake.explicitLocationCeiling = 16;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
const auto measurement = ProbeExplicitVertexInputLocationCeiling(gl);
EXPECT_TRUE(measurement.detected);
EXPECT_EQ(measurement.advertisedMaxVertexAttribs, 32);
EXPECT_EQ(measurement.usableLocations, 16);
EXPECT_TRUE(measurement.bindAttribLocationReachesAdvertisedMax)
<< "this driver caps only the qualifier, so the report may say the attribute is still reachable";
EXPECT_NE(measurement.driverMessage.find("attribute range"), std::string::npos)
<< "the driver's own wording is what makes the row evidence rather than an assertion";
ExpectProbeReleasedEverything();
}
// A ceiling that is not a power of two, so a bisection that happened to land on 16 by arithmetic
// rather than by measurement fails here.
TEST(DriverBugProbes, VertexInputLocationCeilingBisectsToAnAwkwardBoundary) {
ResetFakeDriver();
g_fake.maxVertexAttribs = 32;
g_fake.explicitLocationCeiling = 23;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
const auto measurement = ProbeExplicitVertexInputLocationCeiling(gl);
EXPECT_TRUE(measurement.detected);
EXPECT_EQ(measurement.usableLocations, 23);
ExpectProbeReleasedEverything();
}
// THE FIRST CONTROL. A compiler that refuses location 0 refuses everything, and a probe that
// read that as "only one location is usable" would withdraw every vertex attribute the device has.
TEST(DriverBugProbes, VertexInputLocationCeilingReportsNothingWhenTheLocationZeroControlFails) {
ResetFakeDriver();
g_fake.maxVertexAttribs = 32;
g_fake.everyCompileFails = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
const auto measurement = ProbeExplicitVertexInputLocationCeiling(gl);
EXPECT_FALSE(measurement.detected);
EXPECT_EQ(measurement.usableLocations, 32)
<< "an inconclusive probe has to leave the advertised count exactly where it found it";
ExpectProbeReleasedEverything();
}
// THE SECOND CONTROL, which does not change the clamp but does change what the report may claim:
// a driver that cannot reach the location through glBindAttribLocation either has fewer
// attributes than it advertises, rather than merely an unspellable half.
TEST(DriverBugProbes, VertexInputLocationCeilingSaysWhenTheAttributeIsUnreachableAnyWay) {
ResetFakeDriver();
g_fake.maxVertexAttribs = 32;
g_fake.explicitLocationCeiling = 16;
g_fake.bindAttribLocationCeiling = 16;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
const auto measurement = ProbeExplicitVertexInputLocationCeiling(gl);
EXPECT_TRUE(measurement.detected);
EXPECT_EQ(measurement.usableLocations, 16);
EXPECT_FALSE(measurement.bindAttribLocationReachesAdvertisedMax);
ExpectProbeReleasedEverything();
}
// ===================== LAYERED BLIT DESTINATION =====================
TEST(DriverBugProbes, LayeredBlitDestinationIsCleanWhenTheLayerIsHonoured) {
ResetFakeDriver();
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeBlitIgnoresDestinationArrayLayer(gl));
ExpectProbeReleasedEverything();
}
TEST(DriverBugProbes, LayeredBlitDestinationIsDetectedWhenTheCopyLandsOnLayerZero) {
ResetFakeDriver();
g_fake.blitIgnoresDestinationLayer = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_TRUE(ProbeBlitIgnoresDestinationArrayLayer(gl));
ExpectProbeReleasedEverything();
}
// THE CONTROL. A driver that ignores the SOURCE layer too cannot address array layers through a
// framebuffer at all - a bigger defect, and one this probe is not entitled to report as its own.
// The control blit onto destination layer 0 is what catches it: the value it looks for lives only
// on the source's layer 1, so a source read pinned to layer 0 never produces it.
TEST(DriverBugProbes, LayeredBlitDestinationReportsNothingWhenTheSourceLayerIsIgnoredToo) {
ResetFakeDriver();
g_fake.blitIgnoresDestinationLayer = true;
g_fake.blitIgnoresSourceLayer = true;
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
EXPECT_FALSE(ProbeBlitIgnoresDestinationArrayLayer(gl));
ExpectProbeReleasedEverything();
}
// And the shape that is not this bug at all: a blit that moves nothing anywhere. The probe's
// subject then finds its magic byte on no layer, which is "reached no verdict", not "landed on 0".
TEST(DriverBugProbes, LayeredBlitDestinationReportsNothingWhenTheBlitMovesNothing) {
ResetFakeDriver();
const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions();
MG_External::GLESFunctionsTable inert = gl;
inert.glBlitFramebuffer = [](GLint, GLint, GLint, GLint, GLint, GLint, GLint, GLint, GLbitfield,
GLenum) {};
EXPECT_FALSE(ProbeBlitIgnoresDestinationArrayLayer(inert));
ExpectProbeReleasedEverything();
}
TEST(DriverBugProbes, ImageCoherencyNeedsBothHalvesOfTheSplitPairInOneStage) {
ResetFakeDriver();
g_fake.coherencyStrongestShapeFailedTexels = 376;
@@ -7,6 +7,7 @@
// End of Source File Header
#include "Loader.h"
#include "MG_Util/SelfTest/DriverBugProbes.h"
#include "MG_Util/Types.h"
#include <Config.h>
#include <cmath>
@@ -1064,6 +1065,16 @@ namespace MobileGL::MG_Util::BackendLoader {
MGLOG_I("OpenGL ES capabilities:");
glesFuncs.glGetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &caps.UniformBufferOffsetAlignment);
MGLOG_I(" GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT: %d", caps.UniformBufferOffsetAlignment);
// ES 3.1 core, so no extension gate - but a driver that somehow leaves it at zero would
// make every storage-range offset legal, so an unusable answer keeps the 256 default.
GLint shaderStorageOffsetAlignment = 0;
glesFuncs.glGetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &shaderStorageOffsetAlignment);
while (glesFuncs.glGetError() != GL_NO_ERROR) {
}
if (shaderStorageOffsetAlignment > 0) {
caps.ShaderStorageBufferOffsetAlignment = shaderStorageOffsetAlignment;
}
MGLOG_I(" GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT: %d", caps.ShaderStorageBufferOffsetAlignment);
GLfloat aliasedLineWidthRange[2] = {1.0f, 1.0f};
GLfloat smoothLineWidthRange[2] = {1.0f, 1.0f};
GLfloat smoothLineWidthGranularity = 1.0f;
@@ -1495,7 +1506,26 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.MaxVertexTextureImageUnits = maxVertexTextureImageUnits;
caps.MaxComputeTextureImageUnits = maxComputeTextureImageUnits;
caps.MaxCombinedTextureImageUnits = maxCombinedTextureImageUnits;
caps.MaxVertexAttribs = maxVertexAttribs;
// Not the driver's answer alone: MobileGL emits every vertex input as a
// layout(location = N) qualifier, so an attribute the driver counts but its ESSL
// compiler will not let anything DECLARE is not an attribute MobileGL can hand to an
// application. The probe measures where the qualifier actually stops (see
// SelfTest::ProbeExplicitVertexInputLocationCeiling - Adreno 830 advertises 32 and
// refuses the qualifier from 16 up) and answers with the advertised count on every
// driver that has no such gap and on any run that reaches no verdict, so this only ever
// lowers the number, and only on evidence.
const SelfTest::VertexInputLocationCeilingMeasurement& locationCeiling =
SelfTest::ExplicitVertexInputLocationCeiling(glesFuncs);
// Guarded on `detected` rather than on the number alone: a probe that reached no verdict
// has measured nothing, and the clamp must be driven by evidence or not applied at all.
caps.MaxVertexAttribs = locationCeiling.detected
? std::min(maxVertexAttribs, locationCeiling.usableLocations)
: maxVertexAttribs;
if (locationCeiling.detected) {
MGLOG_I(" GL_MAX_VERTEX_ATTRIBS reduced from the driver's %d to %d: "
"layout(location = N) on a vertex input is refused from N = %d upward",
maxVertexAttribs, caps.MaxVertexAttribs, locationCeiling.usableLocations);
}
caps.MaxComputeShaderStorageBlocks = maxComputeShaderStorageBlocks;
caps.MaxCombinedShaderStorageBlocks = maxCombinedShaderStorageBlocks;
caps.MaxVertexShaderStorageBlocks = maxVertexShaderStorageBlocks;
@@ -1231,6 +1231,10 @@ namespace MobileGL {
// InstanceIndex, which includes firstInstance.
Bool IndirectDrawInstanceIdIncludesBaseInstance = false;
Int UniformBufferOffsetAlignment = 256;
// Its storage-buffer counterpart, queried separately because it is a separate limit:
// Adreno 830 answers 32 for GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT and 64 for
// GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT.
Int ShaderStorageBufferOffsetAlignment = 256;
Float AliasedLineWidthRangeMin = 1.0f;
Float AliasedLineWidthRangeMax = 1.0f;
Float SmoothLineWidthRangeMin = 1.0f;
@@ -154,6 +154,7 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.DriverVersionString = DecodeDriverVersion(p.driverVersion);
caps.VendorId = p.vendorID;
caps.UniformBufferOffsetAlignment = static_cast<int>(p.limits.minUniformBufferOffsetAlignment);
caps.ShaderStorageBufferOffsetAlignment = static_cast<int>(p.limits.minStorageBufferOffsetAlignment);
caps.AliasedLineWidthRangeMin = p.limits.lineWidthRange[0];
caps.AliasedLineWidthRangeMax = p.limits.lineWidthRange[1];
caps.MaxSamplerAnisotropy = p.limits.maxSamplerAnisotropy;
@@ -272,6 +273,8 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.DriverVersionString = DecodeDriverVersion(properties.driverVersion);
caps.VendorId = properties.vendorID;
caps.UniformBufferOffsetAlignment = static_cast<int>(properties.limits.minUniformBufferOffsetAlignment);
caps.ShaderStorageBufferOffsetAlignment =
static_cast<int>(properties.limits.minStorageBufferOffsetAlignment);
caps.AliasedLineWidthRangeMin = properties.limits.lineWidthRange[0];
caps.AliasedLineWidthRangeMax = properties.limits.lineWidthRange[1];
caps.MaxSamplerAnisotropy = properties.limits.maxSamplerAnisotropy;
@@ -18,6 +18,10 @@ namespace MobileGL {
// VkPhysicalDeviceProperties::vendorID, for device-quirk vendor gating.
Uint32 VendorId = 0;
Int UniformBufferOffsetAlignment = 256;
// VkPhysicalDeviceLimits::minStorageBufferOffsetAlignment. A separate limit from
// the uniform one on Vulkan too, and the one GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT
// has to answer with.
Int ShaderStorageBufferOffsetAlignment = 256;
Float AliasedLineWidthRangeMin = 1.0f;
Float AliasedLineWidthRangeMax = 1.0f;
// VkPhysicalDeviceLimits::maxSamplerAnisotropy. Whether it can be used at all depends on
@@ -122,6 +122,7 @@ namespace MobileGL::MG_Util::SelfTest {
GLint activeTexture = GL_TEXTURE0;
GLint texture2D = 0;
GLint texture2DMultisample = 0;
GLint texture2DArray = 0;
GLfloat clearColor[4] = {0.0f, 0.0f, 0.0f, 0.0f};
GLint packAlignment = 4;
GLint packRowLength = 0;
@@ -153,8 +154,15 @@ namespace MobileGL::MG_Util::SelfTest {
state.depthTest = gl.glIsEnabled(GL_DEPTH_TEST);
state.blend = gl.glIsEnabled(GL_BLEND);
gl.glGetIntegerv(GL_ACTIVE_TEXTURE, &state.activeTexture);
// Unit 0 is selected BEFORE the per-unit bindings are read, because Restore puts them
// back on unit 0 unconditionally. Reading them off whatever unit happened to be
// active and writing them to unit 0 would corrupt unit 0's binding for whoever runs
// next - harmless while every probe ran from the POST screen with nothing else using
// the context, and not harmless now that one of them runs from a live draw path.
if (gl.glActiveTexture != nullptr) gl.glActiveTexture(GL_TEXTURE0);
gl.glGetIntegerv(GL_TEXTURE_BINDING_2D, &state.texture2D);
gl.glGetIntegerv(GL_TEXTURE_BINDING_2D_MULTISAMPLE, &state.texture2DMultisample);
gl.glGetIntegerv(GL_TEXTURE_BINDING_2D_ARRAY, &state.texture2DArray);
gl.glGetIntegerv(GL_PACK_ALIGNMENT, &state.packAlignment);
gl.glGetIntegerv(GL_PACK_ROW_LENGTH, &state.packRowLength);
if (gl.glGetFloatv != nullptr) {
@@ -205,6 +213,7 @@ namespace MobileGL::MG_Util::SelfTest {
gl.glBindTexture(GL_TEXTURE_2D, static_cast<GLuint>(state.texture2D));
gl.glBindTexture(GL_TEXTURE_2D_MULTISAMPLE,
static_cast<GLuint>(state.texture2DMultisample));
gl.glBindTexture(GL_TEXTURE_2D_ARRAY, static_cast<GLuint>(state.texture2DArray));
}
gl.glActiveTexture(static_cast<GLenum>(state.activeTexture));
}
@@ -1348,6 +1357,365 @@ namespace MobileGL::MG_Util::SelfTest {
}
namespace {
// ===================== LAYERED BLIT DESTINATION =====================
constexpr const char* kLayeredBlitProbeName = "layered blit destination";
// Four texels wide: a 1x1 blit is a shape drivers special-case, and a rectangle keeps
// the probe on the ordinary path. Two layers is all the question needs.
constexpr GLsizei kLayeredBlitSize = 4;
constexpr GLsizei kLayeredBlitLayers = 2;
// One RGBA8 2D array whose every layer is filled with a distinguishable byte.
GLuint MakeLayeredBlitTexture(const GLESFunctionsTable& gl, GLubyte layer0, GLubyte layer1) {
GLuint texture = 0;
gl.glGenTextures(1, &texture);
if (texture == 0) return 0;
gl.glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
gl.glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, kLayeredBlitSize, kLayeredBlitSize,
kLayeredBlitLayers);
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
const GLubyte fills[kLayeredBlitLayers] = {layer0, layer1};
for (GLint layer = 0; layer < kLayeredBlitLayers; ++layer) {
GLubyte texels[kLayeredBlitSize * kLayeredBlitSize * 4];
for (SizeT i = 0; i < sizeof(texels); i += 4) {
texels[i + 0] = fills[layer];
texels[i + 1] = fills[layer];
texels[i + 2] = fills[layer];
texels[i + 3] = 255;
}
gl.glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, layer, kLayeredBlitSize, kLayeredBlitSize, 1,
GL_RGBA, GL_UNSIGNED_BYTE, texels);
}
gl.glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
return texture;
}
// A framebuffer naming exactly one layer of one array texture.
GLuint MakeLayeredBlitFramebuffer(const GLESFunctionsTable& gl, GLuint texture, GLint layer) {
GLuint framebuffer = 0;
gl.glGenFramebuffers(1, &framebuffer);
if (framebuffer == 0) return 0;
gl.glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
gl.glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, 0, layer);
if (gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
gl.glDeleteFramebuffers(1, &framebuffer);
return 0;
}
return framebuffer;
}
// The red byte of texel (0, 0) of one layer, read through a framebuffer that names it.
// 256 is "could not read", which no fill value can be.
Int ReadLayeredBlitTexel(const GLESFunctionsTable& gl, GLuint texture, GLint layer) {
const GLuint framebuffer = MakeLayeredBlitFramebuffer(gl, texture, layer);
if (framebuffer == 0) return 256;
gl.glBindFramebuffer(GL_READ_FRAMEBUFFER, framebuffer);
gl.glReadBuffer(GL_COLOR_ATTACHMENT0);
gl.glPixelStorei(GL_PACK_ALIGNMENT, 1);
GLubyte pixel[4] = {0, 0, 0, 0};
gl.glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel);
gl.glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
gl.glDeleteFramebuffers(1, &framebuffer);
Drain(gl);
return static_cast<Int>(pixel[0]);
}
// Blits source layer 1 onto `destinationLayer` of a freshly filled destination and
// reports which layer actually received it, or -1 when the blit could not be issued.
Int LayeredBlitLandsOnLayer(const GLESFunctionsTable& gl, GLint destinationLayer, GLubyte magic) {
const GLuint source = MakeLayeredBlitTexture(gl, 0x11, magic);
const GLuint destination = MakeLayeredBlitTexture(gl, 0x33, 0x44);
const GLuint sourceFramebuffer = MakeLayeredBlitFramebuffer(gl, source, 1);
const GLuint destinationFramebuffer = MakeLayeredBlitFramebuffer(gl, destination, destinationLayer);
Int landedOn = -1;
if (source != 0 && destination != 0 && sourceFramebuffer != 0 && destinationFramebuffer != 0) {
gl.glBindFramebuffer(GL_READ_FRAMEBUFFER, sourceFramebuffer);
gl.glReadBuffer(GL_COLOR_ATTACHMENT0);
gl.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, destinationFramebuffer);
Drain(gl);
gl.glBlitFramebuffer(0, 0, kLayeredBlitSize, kLayeredBlitSize, 0, 0, kLayeredBlitSize,
kLayeredBlitSize, GL_COLOR_BUFFER_BIT, GL_NEAREST);
if (gl.glGetError() == GL_NO_ERROR) {
landedOn = -2; // issued, but seen on no layer yet
for (GLint layer = 0; layer < kLayeredBlitLayers; ++layer) {
if (ReadLayeredBlitTexel(gl, destination, layer) == static_cast<Int>(magic)) {
landedOn = layer;
break;
}
}
}
}
if (sourceFramebuffer != 0) gl.glDeleteFramebuffers(1, &sourceFramebuffer);
if (destinationFramebuffer != 0) gl.glDeleteFramebuffers(1, &destinationFramebuffer);
if (source != 0) gl.glDeleteTextures(1, &source);
if (destination != 0) gl.glDeleteTextures(1, &destination);
Drain(gl);
return landedOn;
}
} // namespace
Bool ProbeBlitIgnoresDestinationArrayLayer(const GLESFunctionsTable& gl) {
if (!gl.glGenTextures || !gl.glBindTexture || !gl.glTexStorage3D || !gl.glTexSubImage3D ||
!gl.glTexParameteri || !gl.glDeleteTextures || !gl.glGenFramebuffers || !gl.glBindFramebuffer ||
!gl.glFramebufferTextureLayer || !gl.glCheckFramebufferStatus || !gl.glDeleteFramebuffers ||
!gl.glBlitFramebuffer || !gl.glReadBuffer || !gl.glReadPixels || !gl.glPixelStorei || !gl.glGetError ||
!gl.glIsEnabled || !gl.glDisable) {
return false;
}
SavedState saved;
Save(gl, saved);
// A scissor left on by whoever ran before would clip the probe's own blit and make a
// working driver look broken.
gl.glDisable(GL_SCISSOR_TEST);
Drain(gl);
// THE CONTROL: the same blit onto destination layer 0, which is the case no
// implementation gets wrong. It also proves the SOURCE layer is honoured, since the
// magic byte it looks for only exists on source layer 1 - so a driver that cannot blit
// between array layers at all, or that has no working glFramebufferTextureLayer, fails
// here and reaches no verdict rather than being reported as having this bug.
const Int controlLanded = LayeredBlitLandsOnLayer(gl, 0, 0x5Au);
Bool detected = false;
if (controlLanded != 0) {
MGLOG_I("[driver-bug] %s probe reached no verdict (the destination-layer-0 control "
"landed on layer %d instead of 0)",
kLayeredBlitProbeName, controlLanded);
} else {
// THE SUBJECT: the identical blit asking for layer 1. Only the destination layer moved.
const Int subjectLanded = LayeredBlitLandsOnLayer(gl, 1, 0x5Au);
detected = subjectLanded == 0;
if (subjectLanded != 0 && subjectLanded != 1) {
MGLOG_I("[driver-bug] %s probe reached no verdict (the subject blit landed on "
"no layer at all: %d)",
kLayeredBlitProbeName, subjectLanded);
} else {
MGLOG_I("[driver-bug] %s probe: a blit asking for destination layer 1 landed on "
"layer %d%s",
kLayeredBlitProbeName, subjectLanded,
detected ? " - THE DESTINATION LAYER IS IGNORED" : "");
}
}
Restore(gl, saved);
return detected;
}
Bool BlitIgnoresDestinationArrayLayer(const GLESFunctionsTable& gl) {
// One driver per process, and the answer is structural rather than sampled.
static const Bool ignored = ProbeBlitIgnoresDestinationArrayLayer(gl);
return ignored;
}
namespace {
Optional<DriverBugFinding> ProbeLayeredBlitDestinationBug(const GLESFunctionsTable& gl) {
if (!BlitIgnoresDestinationArrayLayer(gl)) return std::nullopt;
return DriverBugFinding{
"glBlitFramebuffer ignores the destination array layer",
DriverBugVerdict::Fixed,
"a glBlitFramebuffer whose DRAW framebuffer attaches a non-zero array layer with "
"glFramebufferTextureLayer writes to layer 0 instead, and raises no error doing "
"it. Measured here on the colour aspect; the depth aspect behaves the same way on "
"the device this was characterised on. The layer is honoured everywhere else on "
"the same driver - the blit's own SOURCE layer is read correctly, which is this "
"probe's control - so neither layered attachments nor blitting is withdrawn. "
"MobileGL performs such a blit with glCopyImageSubData instead, which takes the "
"destination layer explicitly and honours it here, and applies that substitute to "
"the depth and stencil aspects as well; a blit that scales, flips, changes format, "
"resolves samples or is clipped by the scissor cannot be expressed as a copy and "
"is still handed to the driver"};
}
// ===================== EXPLICIT VERTEX INPUT LOCATION CEILING =====================
constexpr const char* kAttributeLocationProbeName = "explicit vertex input location";
// COMPILES ONE VERTEX STAGE and reports nothing else. A link would drag in every other
// reason a program can be refused (varying budgets, the fragment stage, the linker's own
// location rules), and the defect this measures is in the driver's ESSL COMPILER: it
// rejects the declaration itself, before any of that can matter.
Bool ExplicitVertexInputLocationCompiles(const GLESFunctionsTable& gl, Int location,
String* firstRejectionMessage) {
const String source = format("#version 320 es\n"
"layout(location = {}) in vec4 a_probe;\n"
"void main() {{ gl_Position = a_probe; }}\n",
location);
Drain(gl);
const GLuint shader = gl.glCreateShader(GL_VERTEX_SHADER);
if (shader == 0) return false;
const char* text = source.c_str();
gl.glShaderSource(shader, 1, &text, nullptr);
gl.glCompileShader(shader);
GLint compiled = GL_FALSE;
gl.glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE && firstRejectionMessage != nullptr && firstRejectionMessage->empty()) {
char log[256] = {0};
gl.glGetShaderInfoLog(shader, static_cast<GLsizei>(sizeof(log) - 1), nullptr, log);
// One line: the driver's own wording is the report's whole evidential value, and
// the rest of the log is the same sentence repeated per declaration.
String message = log;
if (const SizeT newline = message.find('\n'); newline != String::npos) {
message.resize(newline);
}
while (!message.empty() && (message.back() == ' ' || message.back() == '\r')) message.pop_back();
*firstRejectionMessage = Move(message);
}
gl.glDeleteShader(shader);
Drain(gl);
return compiled != GL_FALSE;
}
// THE SECOND CONTROL, and the one that decides whether the cap is about the LAYOUT
// QUALIFIER or about the attribute itself. The same input, declared with no qualifier at
// all and placed by glBindAttribLocation instead. If this links and glGetAttribLocation
// answers with the location asked for, the driver can address that attribute perfectly
// well and only the qualifier path is capped - which is what makes clamping the
// advertised count the right response rather than a shrug. If it fails too, the driver
// genuinely has fewer attributes than it advertises; the clamp is still correct, but the
// report must not claim the attribute is reachable another way.
Bool BindAttribLocationReaches(const GLESFunctionsTable& gl, Int location) {
if (!gl.glCreateProgram || !gl.glAttachShader || !gl.glBindAttribLocation || !gl.glLinkProgram ||
!gl.glGetProgramiv || !gl.glGetAttribLocation || !gl.glDeleteProgram) {
return false;
}
constexpr const char* kVertexSource = "#version 320 es\n"
"in vec4 a_probe;\n"
"void main() { gl_Position = a_probe; }\n";
constexpr const char* kFragmentSource = "#version 320 es\n"
"precision highp float;\n"
"out vec4 o_color;\n"
"void main() { o_color = vec4(1.0); }\n";
Drain(gl);
const GLuint vertexShader =
CompileStage(gl, GL_VERTEX_SHADER, kVertexSource, "vertex", kAttributeLocationProbeName);
if (vertexShader == 0) return false;
const GLuint fragmentShader =
CompileStage(gl, GL_FRAGMENT_SHADER, kFragmentSource, "fragment", kAttributeLocationProbeName);
if (fragmentShader == 0) {
gl.glDeleteShader(vertexShader);
return false;
}
const GLuint program = gl.glCreateProgram();
gl.glAttachShader(program, vertexShader);
gl.glAttachShader(program, fragmentShader);
gl.glBindAttribLocation(program, static_cast<GLuint>(location), "a_probe");
gl.glLinkProgram(program);
GLint linked = GL_FALSE;
gl.glGetProgramiv(program, GL_LINK_STATUS, &linked);
const Bool reached = linked != GL_FALSE && gl.glGetAttribLocation(program, "a_probe") == location;
gl.glDeleteShader(vertexShader);
gl.glDeleteShader(fragmentShader);
gl.glDeleteProgram(program);
Drain(gl);
return reached;
}
} // namespace
VertexInputLocationCeilingMeasurement ProbeExplicitVertexInputLocationCeiling(const GLESFunctionsTable& gl) {
VertexInputLocationCeilingMeasurement measurement;
// `usableLocations` is the number a caller clamps to, so it carries the driver's own
// answer from the first line onward and every early return below leaves it there. A
// probe that cannot run has to withdraw nothing at all, and a zero here would withdraw
// every attribute the device has.
if (gl.glGetIntegerv != nullptr) {
GLint advertisedEarly = 0;
gl.glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &advertisedEarly);
if (gl.glGetError != nullptr) Drain(gl);
measurement.advertisedMaxVertexAttribs = advertisedEarly;
measurement.usableLocations = advertisedEarly;
}
if (!gl.glCreateShader || !gl.glShaderSource || !gl.glCompileShader || !gl.glGetShaderiv ||
!gl.glGetShaderInfoLog || !gl.glDeleteShader || !gl.glGetIntegerv || !gl.glGetError) {
return measurement;
}
const GLint advertised = measurement.advertisedMaxVertexAttribs;
// Nothing to bisect, and nothing a clamp could usefully say.
if (advertised < 2) return measurement;
// THE CONTROL, and the reason a compiler that is simply unavailable cannot be reported as
// this bug: location 0 is the one every ES driver in existence accepts, so a probe that
// cannot compile even that has measured its own failure, not the driver's.
if (!ExplicitVertexInputLocationCompiles(gl, 0, nullptr)) {
MGLOG_I("[driver-bug] %s probe reached no verdict (the location-0 control did not "
"compile, so nothing higher says anything)",
kAttributeLocationProbeName);
return measurement;
}
// The common case is one compile: a conforming driver takes the highest location it
// advertises and the probe stops there.
if (ExplicitVertexInputLocationCompiles(gl, advertised - 1, nullptr)) return measurement;
// Bisect for the highest location that still compiles. `low` always compiles (the control
// proved location 0 does) and `high` never does, so the loop closes on the boundary in
// ceil(log2(advertised)) compiles - five for the 32 attributes Adreno advertises.
String rejectionMessage;
ExplicitVertexInputLocationCompiles(gl, advertised - 1, &rejectionMessage);
Int low = 0;
Int high = advertised - 1;
while (high - low > 1) {
const Int middle = low + (high - low) / 2;
if (ExplicitVertexInputLocationCompiles(gl, middle, &rejectionMessage)) {
low = middle;
} else {
high = middle;
}
}
measurement.detected = true;
measurement.usableLocations = low + 1;
measurement.driverMessage = Move(rejectionMessage);
measurement.bindAttribLocationReachesAdvertisedMax = BindAttribLocationReaches(gl, advertised - 1);
MGLOG_I("[driver-bug] %s probe: GL_MAX_VERTEX_ATTRIBS is %d but layout(location = N) on a "
"vertex input is refused from N = %d upward - only %d location(s) are usable; "
"glBindAttribLocation(%d) %s%s%s",
kAttributeLocationProbeName, advertised, measurement.usableLocations,
measurement.usableLocations, advertised - 1,
measurement.bindAttribLocationReachesAdvertisedMax ? "still resolves correctly"
: "does not resolve either",
measurement.driverMessage.empty() ? "" : "; the driver says: ",
measurement.driverMessage.c_str());
return measurement;
}
const VertexInputLocationCeilingMeasurement& ExplicitVertexInputLocationCeiling(const GLESFunctionsTable& gl) {
static const VertexInputLocationCeilingMeasurement measurement =
ProbeExplicitVertexInputLocationCeiling(gl);
return measurement;
}
namespace {
Optional<DriverBugFinding> ProbeExplicitVertexInputLocationCeilingBug(const GLESFunctionsTable& gl) {
const VertexInputLocationCeilingMeasurement& measurement = ExplicitVertexInputLocationCeiling(gl);
if (!measurement.detected) return std::nullopt;
String detail =
format("GL_MAX_VERTEX_ATTRIBS is {} but the ESSL compiler refuses "
"layout(location = N) on a vertex input for every N at or above {} - so {} of "
"the {} attributes advertised cannot be declared at all",
measurement.advertisedMaxVertexAttribs, measurement.usableLocations,
measurement.advertisedMaxVertexAttribs - measurement.usableLocations,
measurement.advertisedMaxVertexAttribs);
if (!measurement.driverMessage.empty()) {
detail += format(" - the driver says \"{}\"", measurement.driverMessage);
}
detail += measurement.bindAttribLocationReachesAdvertisedMax
? format(". The same driver ACCEPTS glBindAttribLocation({}) on an unqualified "
"input and resolves it correctly, so the attributes are there and only "
"the layout qualifier is capped",
measurement.advertisedMaxVertexAttribs - 1)
: ". glBindAttribLocation does not reach those locations either, so the "
"attributes appear genuinely absent rather than merely unspellable";
detail += format(". MobileGL emits its vertex inputs as layout qualifiers, so it advertises the "
"{} locations it can actually deliver rather than the {} the driver claims. An "
"application asking for more used to be handed a count it could not build a "
"shader against, which failed at the stage compile with no way back",
measurement.usableLocations, measurement.advertisedMaxVertexAttribs);
return DriverBugFinding{"Vertex input layout(location) capped below GL_MAX_VERTEX_ATTRIBS",
DriverBugVerdict::Fixed, Move(detail)};
}
Optional<DriverBugFinding> ProbeGeometryWriteAfterEmitBug(const GLESFunctionsTable& gl) {
if (!GeometryStageSsboWriteAfterEmitDropped(gl)) return std::nullopt;
return DriverBugFinding{
@@ -1448,6 +1816,8 @@ namespace MobileGL::MG_Util::SelfTest {
&ProbeImageLocationPerNameBug,
&ProbeCrossStageImageQualifierMergeBug,
&ProbeImageCoherencyResidualBug,
&ProbeExplicitVertexInputLocationCeilingBug,
&ProbeLayeredBlitDestinationBug,
};
} // namespace
@@ -55,6 +55,74 @@ namespace MobileGL::MG_Util::SelfTest {
String detail;
};
// Blits one layer of an RGBA8 2D array onto another array's layer 1 and reports whether the
// copy landed where it was asked to. Returns true only when the destination layer is ignored
// while the control lands correctly.
//
// Adreno 830 writes to layer 0 whatever layer the DRAW framebuffer's
// glFramebufferTextureLayer attachment names, for colour and depth alike, and raises no
// error. Everything else about the layer works on the same driver, which is what makes this
// a blit defect rather than a layered-attachment one.
//
// THE CONTROL is the same blit onto destination layer 0. It passes on every implementation
// that can blit between array layers at all, and because the value it looks for exists only
// on the SOURCE's layer 1 it also proves the source layer is honoured - so a driver with no
// working glFramebufferTextureLayer reaches no verdict instead of being reported as this.
//
// Returns false when an entry point is missing, when the probe's own framebuffers come back
// incomplete, or when the control fails. Restores every piece of GL state it touches.
Bool ProbeBlitIgnoresDestinationArrayLayer(const MG_External::GLESFunctionsTable& gl);
// ProbeBlitIgnoresDestinationArrayLayer(), evaluated at most once per process.
Bool BlitIgnoresDestinationArrayLayer(const MG_External::GLESFunctionsTable& gl);
// What the vertex-input location probe measured. The ceiling is reported rather than
// hard-coded: it is a driver property, and a clamp derived from a number measured on some
// other device is exactly the hard-coded vendor quirk this file exists to avoid.
struct VertexInputLocationCeilingMeasurement {
Bool detected = false;
// GL_MAX_VERTEX_ATTRIBS as the driver answers it.
Int advertisedMaxVertexAttribs = 0;
// How many locations `layout(location = N)` on a vertex input actually accepts, i.e. the
// highest N that compiles plus one. Equal to advertisedMaxVertexAttribs when the driver
// is not affected, and when the probe reached no verdict - so a caller can clamp to it
// unconditionally and an inconclusive probe changes nothing.
Int usableLocations = 0;
// Whether glBindAttribLocation(advertisedMaxVertexAttribs - 1) still links and resolves.
// Only measured when `detected`; see the second control in the .cpp for why it decides
// what the finding is allowed to claim.
Bool bindAttribLocationReachesAdvertisedMax = false;
// The first line of the driver's compile log for a refused declaration, so the report
// quotes the driver rather than paraphrasing it.
String driverMessage;
};
// Compiles `layout(location = N) in vec4` on its own at a series of N and finds the highest
// one the driver's ESSL compiler accepts.
//
// Adreno 830 advertises GL_MAX_VERTEX_ATTRIBS = 32 and then refuses the qualifier for every
// N >= 16 ("the location is not within attribute range [0, MAX_ATTRIBUTES-1]"), for float and
// integer inputs alike - so half the attributes it advertises cannot be declared. MobileGL
// emits vertex inputs as layout qualifiers, which makes the advertised count a promise it
// cannot keep; the measured ceiling is what it advertises instead.
//
// TWO CONTROLS. Location 0 must compile, or the probe has measured its own failure rather
// than the driver's. And glBindAttribLocation at the advertised maximum is tried separately,
// because that is what separates "only the layout qualifier is capped" (which is what this
// driver does) from "the attributes are not there at all" - two findings that justify the
// same clamp but very different report text.
//
// Compile-only, and bisected: one shader compile on a conforming driver, about seven on an
// affected one. Returns a measurement with `detected` false and `usableLocations` equal to
// the advertised count when an entry point is missing or a control fails, so an
// inconclusive probe never withdraws anything.
VertexInputLocationCeilingMeasurement ProbeExplicitVertexInputLocationCeiling(
const MG_External::GLESFunctionsTable& gl);
// ProbeExplicitVertexInputLocationCeiling(), evaluated at most once per process.
const VertexInputLocationCeilingMeasurement& ExplicitVertexInputLocationCeiling(
const MG_External::GLESFunctionsTable& gl);
// Draws one point through VS+GS+FS whose geometry stage writes two storage buffers: one
// BEFORE its EmitVertex()/EndPrimitive() and one AFTER. Returns true only when the
// before-emit write lands and the after-emit write does not.
@@ -44,9 +44,10 @@ namespace MobileGL {
//
// Why not simply rebase the offsets to zero and bind the buffer 8 bytes in: because
// glBindBufferRange's offset must be a multiple of
// GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, which the target device reports as 32.
// A byte offset of 8 cannot be expressed as a binding at all, so the correction has
// to live in the shader's indexing, where it costs nothing.
// GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, which is 64 on Adreno 830 and no smaller
// than 32 on the other targets. A byte offset of 8 cannot be expressed as a binding
// on any of them, so the correction has to live in the shader's indexing, where it
// costs nothing.
//
// A block that is ALREADY laid out naturally - which is every shader that omits the
// offset qualifier, and so very nearly all of them - is left byte-identical: the