Compare commits

...
13 Commits
Author SHA1 Message Date
swung0x48 e4f41e0fd3 [Merge] (ShaderTranspiler, DirectGLES): land the fp64 block layout, colour-index and readonly-writeonly repairs 2026-08-21 22:24:38 -04:00
swung0x48 9cf340cbef [Merge] (DirectGLES, ShaderTranspiler): take the viewport routing and image repairs under the fp64 and qualifier fixes 2026-08-21 22:21:17 -04:00
swung0x48 348a30a816 [Fix, Test] (ShaderTranspiler): keep a storage block with doubles at the byte layout it was bound with 2026-08-21 22:15:05 -04:00
swung0x48 b5e0ada97e [Merge] (DirectGLES, ShaderTranspiler, Config): land the viewport-array routing emulation 2026-08-21 22:13:50 -04:00
swung0x48 cb27ac7761 [Merge] (DirectGLES): take the image-widening repairs under the viewport routing 2026-08-21 22:10:53 -04:00
swung0x48 38c56a3d38 [Test] (DirectGLES, IntegrationTest): run the viewport-array scenarios on Espryt and pin the routing rewrites against a negative control 2026-08-21 22:08:33 -04:00
swung0x48 908172ba0f [Feat] (DirectGLES, Config, ShaderTranspiler): route gl_ViewportIndex on Espryt by replaying a draw per distinct viewport state 2026-08-21 22:08:25 -04:00
swung0x48 e18bac8cb2 [Fix, Test] (ShaderTranspiler, DirectGLES): never widen a buffer image - its texels are the application buffer, not storage we can reallocate 2026-08-21 22:02:23 -04:00
swung0x48 7b0f443d3a [Fix, Test] (ShaderTranspiler, DirectGLES): drop the inert readonly+writeonly pair a storage block cannot carry in ESSL 2026-08-21 21:43:42 -04:00
swung0x48 f1b4a5e07f [Fix, Test] (ShaderTranspiler, DirectGLES): stop printing the default fragment-output colour index into ESSL 2026-08-21 21:42:31 -04:00
swung0x48 f3cd4091bf [Fix, Test] (DirectGLES): decode the packed r11f_g11f_b10f shadow into the float level its rgba16f carrier is uploaded as 2026-08-21 21:38:45 -04:00
swung0x48 529d26f38f [Fix] (DirectGLES): arm the image-format widening for r11f_g11f_b10f in the reflection gate too 2026-08-21 21:27:14 -04:00
swung0x48 9bd125aeec [Fix, Test] (ShaderTranspiler): carry r11f_g11f_b10f storage images in rgba16f instead of losing the stage 2026-08-21 21:21:54 -04:00
30 changed files with 3293 additions and 159 deletions
+1
View File
@@ -279,6 +279,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecomposeWorkgroupVec3Pass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecoratePositionInvariantPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenFloat64StorageBlockPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp
+13
View File
@@ -212,6 +212,19 @@ namespace MobileGL::MG_Config {
// miscompiled shader: if a device ever renders differently with the cache
// on, one run with this falsy says so.
QuirkOverride ShaderTranslationCache = QuirkOverride::Auto;
// MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION: DirectGLES' gl_ViewportIndex routing
// emulation - the builtin becomes a flat varying, the fragment stage gets a
// per-pass gate, and a routed draw is REPLAYED once per distinct viewport state
// with the real glViewport/glScissor/glDepthRangef set for it. Auto is ON, and
// it is ON even where the driver advertises GL_OES_viewport_array, because that
// extension only ever gave the SHADER a compilable name: MobileGL has never
// programmed a driver's INDEXED viewport state (SyncRenderState pushes index 0
// and nothing else), so on an extension-capable driver every index rasterized as
// index 0 exactly as it did without one. ForceOff returns to that behaviour -
// the pre-emulation path, extension passthrough where it exists and
// LowerViewportIndexPass' demote-to-a-plain-global where it does not - and is
// the negative control the emulation is measured against.
QuirkOverride ViewportArrayEmulation = QuirkOverride::Auto;
};
extern FeaturesTable Features;
} // namespace MobileGL::MG_Config
+2
View File
@@ -195,6 +195,8 @@ namespace MobileGL::MG_ConfigLoader {
features.AsyncOptimisticShaderStatus =
QueryEnvQuirkOverride("MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS");
features.ShaderTranslationCache = QueryEnvQuirkOverride("MOBILEGL_SHADER_CACHE");
features.ViewportArrayEmulation =
QueryEnvQuirkOverride("MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION");
}
inline void InitBackendType() {
+256 -35
View File
@@ -1482,8 +1482,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
// a bind format that names a class the storage does not have is left alone: GL
// already calls that undefined, and inventing a carrier for it would only make the
// out-of-class read wider.
//
// A BUFFER texture is excluded on both sides: it has no storage of its own to widen
// (its texels are the application's buffer object), so WidenImageFormatsPass declines
// every buffer image and the bind must decline with it, or the driver would be handed
// a carrier the shader never addressed. See the Dim::Buffer guard there for the
// 32-byte GL_RG32F measurement that pinned it.
GLenum bindFormat = imageBinding.Format;
if (TextureImpl::GetImageBindableStorageWidening(imageBinding.Texture->GetFormat())) {
if (imageBinding.Texture->GetTarget() != TextureTarget::TextureBuffer &&
TextureImpl::GetImageBindableStorageWidening(imageBinding.Texture->GetFormat())) {
const auto boundFormatWidening = TextureImpl::GetImageBindableStorageWidening(
MG_Util::ConvertGLEnumToTextureInternalFormat(imageBinding.Format));
if (boundFormatWidening) {
@@ -3260,6 +3267,188 @@ namespace MobileGL::MG_Backend::DirectGLES {
return program->ReadsDrawID() || (batchCarriesBaseVertices && program->ReadsBaseVertex());
}
// ---- gl_ViewportIndex routing emulation, draw half ---------------------------------------
// See the block comment in Managers.h for what this is and why. Here is the state half: one
// replay pass per DISTINCT viewport state, each pushing that state onto the ES context's one
// viewport / one scissor / one depth range and telling the fragment gate which indices it
// serves.
namespace ViewportRoutingImpl {
// One replay pass: the state to push, and the set of gl_ViewportIndex values whose
// fragments this pass is allowed to keep.
struct RoutingPass {
IntVec4 viewport{};
IntVec4 scissorBox{};
FloatVec2 depthRange{};
Bool scissorTest = false;
Uint32 indexMask = 0;
};
static constexpr Uint32 kAllViewportsMask =
(RenderStateParameters::MAX_VIEWPORTS >= 32)
? 0xFFFFFFFFu
: ((1u << RenderStateParameters::MAX_VIEWPORTS) - 1u);
// The plan for the draw currently being issued. A file-scope buffer rather than a return
// value because Begin/Apply/End are three calls around a draw the caller writes, and a
// fixed array of 16 keeps it allocation-free on a path that is per draw. NOT re-entrant,
// which is a property of the call sites and not an accident: every wrap in this file and
// in MultiDraw.cpp is around the innermost native glDraw*, so no replay can begin inside
// another - and a multi-draw tier that replayed its whole loop would be nesting.
static Array<RoutingPass, RenderStateParameters::MAX_VIEWPORTS> g_passes{};
static Uint g_passCount = 0;
static PrgramImpl::BackendProgramObjectImpl* g_routedProgram = nullptr;
// What index `i` actually rasterizes against, resolved exactly the way SyncRenderState
// resolves index 0 - including both substitutions it makes, which are not cosmetic:
//
// * a viewport of zero extent means "the application has never called glViewport", and
// GL's initial viewport is the whole surface, which the frontend cannot spell before
// a surface exists;
// * a scissor rectangle is read through the WRITTEN flag and not through its extent,
// because glScissor(0, 0, 0, 0) is a legal request meaning "reject every fragment"
// and is byte-identical to the never-written default that means the opposite.
//
// Resolving them here rather than deferring to SyncRenderState is what makes the grouping
// below correct: two indices that differ only in a field that resolves to the same
// rectangle really do rasterize identically and must share one pass.
static RoutingPass ResolveIndexState(const RenderStateParameters& parameters, Uint index,
Int surfaceWidth, Int surfaceHeight) {
RoutingPass pass;
const FloatVec4& viewport = parameters.Viewports[index];
pass.viewport = IntVec4(static_cast<Int>(std::lround(viewport.x())),
static_cast<Int>(std::lround(viewport.y())),
static_cast<Int>(std::lround(viewport.z())),
static_cast<Int>(std::lround(viewport.w())));
if ((pass.viewport.z() <= 0 || pass.viewport.w() <= 0) && surfaceWidth > 0 && surfaceHeight > 0) {
pass.viewport = IntVec4(0, 0, surfaceWidth, surfaceHeight);
}
pass.scissorBox = parameters.ScissorBoxes[index];
if ((parameters.ScissorBoxWrittenMask & (1u << index)) == 0 && surfaceWidth > 0 &&
surfaceHeight > 0) {
pass.scissorBox = IntVec4(0, 0, surfaceWidth, surfaceHeight);
}
pass.depthRange = parameters.DepthRanges[index];
pass.scissorTest = (parameters.ScissorTestEnabledMask & (1u << index)) != 0;
return pass;
}
static Bool SameState(const RoutingPass& a, const RoutingPass& b) {
return a.viewport == b.viewport && a.scissorBox == b.scissorBox &&
a.depthRange == b.depthRange && a.scissorTest == b.scissorTest;
}
} // namespace ViewportRoutingImpl
Uint BeginViewportRoutingPasses() {
using namespace ViewportRoutingImpl;
g_passCount = 1;
g_routedProgram = nullptr;
// The whole emulation behind one static load, for every application that has never built
// a program writing gl_ViewportIndex - which is all of them but the conformance suite.
// Without it every draw in the process would pay GetCurrentBackendProgram's chain of
// frontend lookups for an answer that cannot change.
if (!g_anyProgramRoutesViewportIndex) {
return 1;
}
auto* program = GetCurrentBackendProgram();
if (program == nullptr || !program->RoutesViewportIndex()) {
return 1;
}
g_routedProgram = program;
// The gate reads zero until something writes it, and a zero mask discards every fragment.
// So this is not an optimization that can be skipped in the one-pass case - it is what
// keeps a routing program drawing at all.
program->SetViewportPassMask(kAllViewportsMask);
// Replaying multiplies every side effect the vertex and geometry stages have, and the
// fragment gate can only undo the ones that happen in the FRAGMENT stage. Transform
// feedback records per emitted primitive, so a replayed draw would write its vertices N
// times; rasterizer discard means there are no fragments to gate at all, so replaying
// would be pure cost with nothing to show for it. Both fall back to a single pass with an
// open gate, i.e. to the pre-emulation behaviour, rather than to wrong data.
if (MG_State::pGLContext->IsTransformFeedbackActive() ||
MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) {
return 1;
}
const auto& parameters = MG_State::pGLContext->GetRenderStateParameters();
Int surfaceWidth = 0;
Int surfaceHeight = 0;
if (!QueryCurrentSurfaceSize(surfaceWidth, surfaceHeight)) {
surfaceWidth = 0;
surfaceHeight = 0;
}
Uint count = 0;
for (Uint index = 0; index < RenderStateParameters::MAX_VIEWPORTS; ++index) {
const RoutingPass resolved =
ResolveIndexState(parameters, index, surfaceWidth, surfaceHeight);
Uint existing = 0;
for (; existing < count; ++existing) {
if (SameState(g_passes[existing], resolved)) break;
}
if (existing == count) {
g_passes[count] = resolved;
++count;
}
g_passes[existing].indexMask |= (1u << index);
}
// One group is the overwhelmingly common case - it is what glViewport, glScissor and
// glDepthRange leave behind, because ARB_viewport_array defines all three as writing
// EVERY index. The mask is already open and index 0's state is what SyncRenderState
// pushed, so there is nothing to replay and nothing to restore.
if (count <= 1) {
g_passCount = 1;
return 1;
}
g_passCount = count;
return count;
}
void ApplyViewportRoutingPass(Uint pass) {
using namespace ViewportRoutingImpl;
if (pass >= g_passCount || g_routedProgram == nullptr) {
return;
}
const RoutingPass& entry = g_passes[pass];
g_GLESFuncs.glViewport(entry.viewport.x(), entry.viewport.y(), entry.viewport.z(),
entry.viewport.w());
g_GLESFuncs.glScissor(entry.scissorBox.x(), entry.scissorBox.y(), entry.scissorBox.z(),
entry.scissorBox.w());
// ES has one scissor-test enable where GL has sixteen, so the per-index bit becomes a
// per-pass glEnable/glDisable. This is the half DirectVulkan cannot do at all (Vulkan has
// no per-viewport scissor toggle either and has to widen a disabled index's rectangle to
// the whole framebuffer instead); here the rectangle stays honest.
entry.scissorTest ? g_GLESFuncs.glEnable(GL_SCISSOR_TEST) : g_GLESFuncs.glDisable(GL_SCISSOR_TEST);
g_GLESFuncs.glDepthRangef(entry.depthRange.x(), entry.depthRange.y());
g_routedProgram->SetViewportPassMask(entry.indexMask);
}
void EndViewportRoutingPasses(Uint passCount) {
using namespace ViewportRoutingImpl;
if (passCount <= 1) {
// Nothing was pushed and the mask is already open; leaving the shadow alone here is
// what keeps a non-routing draw at exactly its previous cost.
g_routedProgram = nullptr;
return;
}
if (g_routedProgram != nullptr) {
// Any draw that reaches the driver without going through a replay - an internal blit,
// or a path this emulation has not been taught about - must not inherit the last
// pass's mask and paint nothing.
g_routedProgram->SetViewportPassMask(kAllViewportsMask);
}
g_routedProgram = nullptr;
g_passCount = 0;
// The viewport, scissor, scissor-test enable and depth range now on the ES context belong
// to the last replay pass, and the shadow SyncRenderState diffs against does not know it.
// A full resync is the honest repair and costs one state push on the next draw, which
// only a viewport-routing workload ever pays.
RenderStateImpl::InvalidateSyncedRenderState();
}
static Bool SupportsNativeIndirectDraws() {
return g_GLESCapabilities.SupportsDrawIndirect;
}
@@ -3317,7 +3506,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
SetCurrentBaseInstance(cmd.baseInstance);
SetCurrentBaseVertex(cmd.baseVertex);
}
g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast<const void*>(cmdByteOffset));
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast<const void*>(cmdByteOffset));
});
}
} else {
for (GLsizei i = 0; i < drawcount; ++i) {
@@ -3330,9 +3521,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
SetCurrentBaseInstance(cmd.baseInstance);
SetCurrentBaseVertex(cmd.baseVertex);
const auto indexByteOffset = static_cast<SizeT>(cmd.firstIndex) * indexSize;
g_GLESFuncs.glDrawElementsInstancedBaseVertex(
mode, static_cast<GLsizei>(cmd.count), type, reinterpret_cast<const GLvoid*>(indexByteOffset),
static_cast<GLsizei>(cmd.instanceCount), cmd.baseVertex);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElementsInstancedBaseVertex(
mode, static_cast<GLsizei>(cmd.count), type, reinterpret_cast<const GLvoid*>(indexByteOffset),
static_cast<GLsizei>(cmd.instanceCount), cmd.baseVertex);
});
}
}
SetCurrentDrawID(0);
@@ -3371,7 +3564,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
std::memcpy(&cmd, commandBytes + static_cast<SizeT>(i) * stride, sizeof(cmd));
SetCurrentBaseInstance(cmd.baseInstance);
}
g_GLESFuncs.glDrawArraysIndirect(mode, reinterpret_cast<const void*>(cmdByteOffset));
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawArraysIndirect(mode, reinterpret_cast<const void*>(cmdByteOffset));
});
}
} else {
for (GLsizei i = 0; i < drawcount; ++i) {
@@ -3382,9 +3577,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
SetCurrentDrawID(static_cast<Uint32>(i));
SetCurrentBaseInstance(cmd.baseInstance);
g_GLESFuncs.glDrawArraysInstanced(mode, static_cast<GLint>(cmd.first),
static_cast<GLsizei>(cmd.count),
static_cast<GLsizei>(cmd.instanceCount));
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawArraysInstanced(mode, static_cast<GLint>(cmd.first),
static_cast<GLsizei>(cmd.count),
static_cast<GLsizei>(cmd.instanceCount));
});
}
}
SetCurrentDrawID(0);
@@ -3610,7 +3807,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer;
PrepareForDraw(syncBit);
CheckPrimitiveRestartSupported(type);
g_GLESFuncs.glDrawElements(mode, count, type, indices);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElements(mode, count, type, indices);
});
}
void DrawArrays(GLenum mode, GLint first, GLsizei count) {
@@ -3626,7 +3825,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
(*backendVAOSlot)->SyncClientSideAttributesForDrawArrays(currentVAO, first, count);
}
}
g_GLESFuncs.glDrawArrays(mode, first, count);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawArrays(mode, first, count);
});
}
void DrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, const GLvoid* indices, GLint basevertex) {
@@ -3637,7 +3838,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
PrepareForDraw(syncBit);
CheckPrimitiveRestartSupported(type);
SetCurrentBaseVertex(basevertex);
g_GLESFuncs.glDrawElementsBaseVertex(mode, count, type, indices, basevertex);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElementsBaseVertex(mode, count, type, indices, basevertex);
});
SetCurrentBaseVertex(0);
}
@@ -3663,7 +3866,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
g_GLESFuncs.glDrawArrays(mode, first[i], count[i]);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawArrays(mode, first[i], count[i]);
});
}
if (feedDrawID) SetCurrentDrawID(0);
}
@@ -3900,14 +4105,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer;
PrepareForDraw(syncBit);
SetCurrentBaseVertex(basevertex);
g_GLESFuncs.glDrawRangeElementsBaseVertex(mode, start, end, count, type, indices, basevertex);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawRangeElementsBaseVertex(mode, start, end, count, type, indices, basevertex);
});
SetCurrentBaseVertex(0);
}
void DrawRangeElements(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type, const void* indices) {
DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer;
PrepareForDraw(syncBit);
g_GLESFuncs.glDrawRangeElements(mode, start, end, count, type, indices);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawRangeElements(mode, start, end, count, type, indices);
});
}
// True when the driver will apply baseInstance to the vertex fetch itself, in which case the
@@ -3930,12 +4139,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
PrepareForDraw(syncBit);
SetCurrentBaseInstance(baseinstance);
SetCurrentBaseVertex(basevertex);
if (UseNativeBaseInstance()) {
g_GLESFuncs.glDrawElementsInstancedBaseVertexBaseInstanceEXT(mode, count, type, indices, instancecount,
basevertex, baseinstance);
} else {
g_GLESFuncs.glDrawElementsInstancedBaseVertex(mode, count, type, indices, instancecount, basevertex);
}
ForEachViewportRoutingPass([&] {
if (UseNativeBaseInstance()) {
g_GLESFuncs.glDrawElementsInstancedBaseVertexBaseInstanceEXT(mode, count, type, indices, instancecount,
basevertex, baseinstance);
} else {
g_GLESFuncs.glDrawElementsInstancedBaseVertex(mode, count, type, indices, instancecount, basevertex);
}
});
SetCurrentBaseVertex(0);
SetCurrentBaseInstance(0);
}
@@ -3945,7 +4156,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer | DrawSyncBit::Instancing;
PrepareForDraw(syncBit);
SetCurrentBaseVertex(basevertex);
g_GLESFuncs.glDrawElementsInstancedBaseVertex(mode, count, type, indices, instancecount, basevertex);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElementsInstancedBaseVertex(mode, count, type, indices, instancecount, basevertex);
});
SetCurrentBaseVertex(0);
}
@@ -3955,19 +4168,23 @@ namespace MobileGL::MG_Backend::DirectGLES {
const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance));
PrepareForDraw(syncBit);
SetCurrentBaseInstance(baseinstance);
if (UseNativeBaseInstance()) {
g_GLESFuncs.glDrawElementsInstancedBaseInstanceEXT(mode, count, type, indices, instancecount,
baseinstance);
} else {
g_GLESFuncs.glDrawElementsInstanced(mode, count, type, indices, instancecount);
}
ForEachViewportRoutingPass([&] {
if (UseNativeBaseInstance()) {
g_GLESFuncs.glDrawElementsInstancedBaseInstanceEXT(mode, count, type, indices, instancecount,
baseinstance);
} else {
g_GLESFuncs.glDrawElementsInstanced(mode, count, type, indices, instancecount);
}
});
SetCurrentBaseInstance(0);
}
void DrawElementsInstanced(GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount) {
DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer | DrawSyncBit::Instancing;
PrepareForDraw(syncBit);
g_GLESFuncs.glDrawElementsInstanced(mode, count, type, indices, instancecount);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElementsInstanced(mode, count, type, indices, instancecount);
});
}
void DrawElementsIndirect(GLenum mode, GLenum type, const void* indirect) {
@@ -3999,18 +4216,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance));
PrepareForDraw(syncBit);
SetCurrentBaseInstance(baseinstance);
if (UseNativeBaseInstance()) {
g_GLESFuncs.glDrawArraysInstancedBaseInstanceEXT(mode, first, count, instancecount, baseinstance);
} else {
g_GLESFuncs.glDrawArraysInstanced(mode, first, count, instancecount);
}
ForEachViewportRoutingPass([&] {
if (UseNativeBaseInstance()) {
g_GLESFuncs.glDrawArraysInstancedBaseInstanceEXT(mode, first, count, instancecount, baseinstance);
} else {
g_GLESFuncs.glDrawArraysInstanced(mode, first, count, instancecount);
}
});
SetCurrentBaseInstance(0);
}
void DrawArraysInstanced(GLenum mode, GLint first, GLsizei count, GLsizei instancecount) {
DrawSyncFlags syncBit = DrawSyncBit::Instancing;
PrepareForDraw(syncBit);
g_GLESFuncs.glDrawArraysInstanced(mode, first, count, instancecount);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawArraysInstanced(mode, first, count, instancecount);
});
}
void DrawArraysIndirect(GLenum mode, const void* indirect) {
+368 -23
View File
@@ -29,7 +29,9 @@
#include <MG_State/GLState/FramebufferState/FramebufferObject.h>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdlib>
#include <limits>
#include <map>
#include <mutex>
#include <cstring>
@@ -47,6 +49,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
constexpr const char* INDIRECT_PARAMS_BLOCK_NAME = "mg_IndirectParams";
constexpr const char* ZERO_BASED_INSTANCE_ID_NAME = "mg_ZeroBasedInstanceID";
// See the block comment on ForEachViewportRoutingPass in Managers.h. Auto is ON, including on
// a driver that advertises GL_OES_viewport_array: that extension gives the shader a name, not
// the driver fifteen more rectangles to rasterize against, and nothing in MobileGL has ever
// programmed the indexed state it would need.
Bool ViewportArrayEmulationEnabled() {
return MG_Config::Features.ViewportArrayEmulation != MG_Config::QuirkOverride::ForceOff;
}
Bool g_anyProgramRoutesViewportIndex = false;
// ES has no atomic-counter buffers: glslang lowers every atomic_uint onto a synthesized
// storage block, so one GL counter BUFFER costs one of the driver's shader-storage binding
// points. Those slots are taken from the TOP of the range downwards - below the one
@@ -300,6 +312,126 @@ namespace MobileGL::MG_Backend::DirectGLES {
return source;
}
// ---- gl_ViewportIndex routing emulation, ESSL half ---------------------------------------
//
// LowerViewportIndexPass has already turned the BuiltIn ViewportIndex OUTPUT into a plain
// Private global, so SPIRV-Cross printed `int mg_ViewportIndex;` at file scope and the stage
// still stores the index the application asked for - it just goes nowhere. The two passes
// below give it somewhere to go WITHOUT naming a builtin the language does not have: the
// producing stage's global becomes an ordinary flat varying, and the fragment stage gets a
// gate that discards every fragment whose primitive routed to a viewport the current replay
// pass is not drawing. DirectGLES.cpp's ForEachViewportRoutingPass is the other half - it
// re-issues the draw once per distinct viewport state with the real
// glViewport/glScissor/glDepthRangef pushed for it and this uniform set to the set of
// indices that state serves.
//
// FLAT is semantics, not performance: GL takes a primitive's viewport index from its
// PROVOKING VERTEX, which is exactly what flat interpolation delivers, so a primitive whose
// vertices carry different indices routes the way the spec says with no extra machinery.
//
// NO layout(location = N) on either side, deliberately. The two stages are transpiled
// independently and neither can see the other's location assignment: the producing stage
// knows its own outputs, the fragment stage only the subset it consumes, and a number derived
// from either can disagree with the other. Leaving both unqualified hands the assignment to
// the driver's linker, which then matches them BY NAME - the ordinary GLSL rule, and the only
// one that needs no cross-stage channel. The cost is one varying slot, which a program
// already at GL_MAX_VARYING_VECTORS cannot spare.
constexpr const char* VIEWPORT_INDEX_VARYING_NAME = "mg_ViewportIndex";
constexpr const char* VIEWPORT_PASS_MASK_UNIFORM_NAME = "mg_ViewportPassMask";
constexpr const char* VIEWPORT_GATED_ENTRY_POINT_NAME = "mg_ViewportGatedMain";
constexpr const char* ESSL_ENTRY_POINT_SIGNATURE = "void main()";
static_assert(RenderStateParameters::MAX_VIEWPORTS == 16,
"the fragment gate below spells the index clamp as `& 15` and the pass mask as a "
"16-bit int; both follow MAX_VIEWPORTS and have to be respelled with it");
// Producing stage (vertex / tessellation evaluation / geometry - the three GL lets write the
// builtin). Returns whether the demoted global was found and promoted, which is also the
// answer to "does this program route viewports at all".
Bool PromoteViewportIndexGlobalToVarying(String& source) {
// The same shape PromoteDrawParameterGlobalsToUniforms matches, and for the same reason:
// SPIRV-Cross prints the demoted global with or without a precision qualifier depending
// on what the module carried. Only a declaration that starts its own line may be
// rewritten - `mg_ViewportIndex = gl_InvocationID;` in the body contains the name too and
// has to be left exactly as it is.
const String declared = String(VIEWPORT_INDEX_VARYING_NAME) + ";";
for (const char* declPrefix : {"highp int ", "mediump int ", "lowp int ", "int "}) {
const String declaration = String(declPrefix) + declared;
const SizeT pos = source.find(declaration);
if (pos == String::npos) {
continue;
}
// Column 0 of its own line is what separates the declaration from the tail of any
// other declaration or expression that ends in the same name.
if (pos != 0 && source[pos - 1] != '\n') {
continue;
}
source.replace(pos, declaration.size(),
String("flat out highp int ") + VIEWPORT_INDEX_VARYING_NAME + ";");
return true;
}
return false;
}
// Fragment stage. Returns false when the stage has no entry point to gate onto, which the
// caller reports: the program still links and still renders, it just renders every index
// with the first replay pass's state - i.e. it degrades to the pre-emulation behaviour
// rather than to a black screen.
Bool InjectViewportIndexPassGate(String& source) {
// Built beside the input and swapped in only on success, so a stage this pass declines
// reaches the driver exactly as it arrived rather than half-rewritten.
// A fragment stage that READS gl_ViewportIndex has no ESSL spelling for it either -
// LowerViewportIndexPass deliberately demotes only OUTPUTS, because a demoted INPUT would
// answer from an undefined Private global. Now that the routing varying exists and
// carries the real per-primitive value, that read has somewhere honest to go.
String gated = ReplaceIdentifier(source, "gl_ViewportIndex", VIEWPORT_INDEX_VARYING_NAME);
const SizeT entryPos = gated.find(ESSL_ENTRY_POINT_SIGNATURE);
if (entryPos == String::npos) {
return false;
}
// Declarations go immediately before the entry point rather than after #version: that
// position is already past every #extension directive (which must precede any other
// token) and past everything the body can name, so it can invalidate neither.
//
// Renaming the entry point rather than splicing a prologue into its body keeps the
// application's code byte-identical, including an early `return`.
String preamble = String("flat in highp int ") + VIEWPORT_INDEX_VARYING_NAME + ";\n";
preamble += String("uniform highp int ") + VIEWPORT_PASS_MASK_UNIFORM_NAME + ";\n";
preamble += String("void ") + VIEWPORT_GATED_ENTRY_POINT_NAME + "()";
gated.replace(entryPos, std::strlen(ESSL_ENTRY_POINT_SIGNATURE), preamble);
// `& 15` clamps the shift operand into range for MAX_VIEWPORTS = 16. GL leaves an index
// outside [0, MAX_VIEWPORTS) undefined, but an ESSL shift by >= 32 is undefined in a way
// that can take the whole draw with it, so the emulation picks a defined answer instead.
//
// The mask, not an equality test against a pass number: viewport indices whose whole
// state tuple is identical share ONE replay pass (see BeginViewportRoutingPasses), and
// the overwhelmingly common case - every index still holding what glViewport broadcast -
// is then a single pass with every bit set, i.e. a gate that discards nothing and a draw
// that is issued exactly once.
//
// PERFORMANCE NOTE: a fragment shader containing `discard` cannot take the early-Z fast
// path on a tiler, so a routed draw pays late-Z on top of its N replay passes. Accepted
// deliberately: this runs only for a program that writes gl_ViewportIndex, and that is
// why the gate is injected per program rather than into every fragment shader.
gated += "\n";
gated += String(ESSL_ENTRY_POINT_SIGNATURE) + "\n";
gated += "{\n";
gated += String(" if (((") + VIEWPORT_PASS_MASK_UNIFORM_NAME + " >> (" +
VIEWPORT_INDEX_VARYING_NAME + " & 15)) & 1) == 0)\n";
gated += " {\n";
gated += " discard;\n";
gated += " }\n";
gated += " else\n";
gated += " {\n";
gated += String(" ") + VIEWPORT_GATED_ENTRY_POINT_NAME + "();\n";
gated += " }\n";
gated += "}\n";
source = std::move(gated);
return true;
}
// The transpile pipeline invents image binding numbers: when the GL source declares
// an image uniform without layout(binding), glslang auto-assigns one (desktop GL
// allows that and lets the app pick the unit with glUniform1i, which ES forbids on
@@ -2706,21 +2838,98 @@ namespace MobileGL::MG_Backend::DirectGLES {
widenedData, IsIntegerWidenableFormat(format));
}
// One channel of a packed r11f_g11f_b10f word as a float. The two 11-bit channels are
// e5m6 and the 10-bit one e5m5 - IEEE-shaped but UNSIGNED, so there is no sign bit to
// read and the exponent bias is the 15 a 5-bit exponent always carries.
static Float DecodePackedUnsignedFloat(Uint32 bits, Uint mantissaBits) {
const Uint32 mantissaScale = 1u << mantissaBits;
const Uint32 mantissa = bits & (mantissaScale - 1u);
const Uint32 exponent = bits >> mantissaBits;
if (exponent == 0u) {
// Subnormal, and zero with it: no implied leading 1, and the exponent is the
// smallest NORMAL one rather than the encoded 0.
return std::ldexp(static_cast<Float>(mantissa) / static_cast<Float>(mantissaScale), -14);
}
if (exponent == 31u) {
return mantissa == 0u ? std::numeric_limits<Float>::infinity()
: std::numeric_limits<Float>::quiet_NaN();
}
return std::ldexp(1.0f + static_cast<Float>(mantissa) / static_cast<Float>(mantissaScale),
static_cast<Int>(exponent) - 15);
}
// The r11f_g11f_b10f shadow decoded into the GL_RGBA / GL_FLOAT level its GL_RGBA16F
// carrier is uploaded as. Alpha is the 1 GL defines for a format that has no alpha
// channel, which is the same constant the shader-side mask writes, so a texel this
// function produced and a texel an imageStore produced are indistinguishable.
//
// Sized from the LEVEL, not the source, for the reason PrepareChannelWidenedUpload is:
// the driver reads a full width*height*depth*4 floats for the transfer it was handed.
static const void* PreparePackedFloatWidenedUpload(const IntVec3& texelSize, const void* data,
SizeT byteSize, Vector<Uint8>& widenedData) {
constexpr SizeT kSourceTexelBytes = sizeof(Uint32);
if (data == nullptr || byteSize < kSourceTexelBytes) {
return data;
}
const SizeT texelCount = static_cast<SizeT>(std::max(texelSize.x(), 0)) *
static_cast<SizeT>(std::max(texelSize.y(), 0)) *
static_cast<SizeT>(std::max(texelSize.z(), 1));
if (texelCount == 0) {
return data;
}
const SizeT copyTexelCount = std::min(texelCount, byteSize / kSourceTexelBytes);
widenedData.assign(texelCount * 4u * sizeof(Float), 0);
const auto* src = static_cast<const Uint8*>(data);
auto* dst = reinterpret_cast<Float*>(widenedData.data());
for (SizeT i = 0; i < texelCount; ++i, dst += 4) {
Float rgb[3] = {0.0f, 0.0f, 0.0f};
if (i < copyTexelCount) {
Uint32 packed = 0;
// Through a memcpy rather than a Uint32 read of `src`: the shadow is a byte
// buffer with no alignment promise of its own.
Memcpy(&packed, src + i * kSourceTexelBytes, sizeof(packed));
rgb[0] = DecodePackedUnsignedFloat(packed & 0x7FFu, 6u);
rgb[1] = DecodePackedUnsignedFloat((packed >> 11u) & 0x7FFu, 6u);
rgb[2] = DecodePackedUnsignedFloat((packed >> 22u) & 0x3FFu, 5u);
}
dst[0] = rgb[0];
dst[1] = rgb[1];
dst[2] = rgb[2];
dst[3] = 1.0f;
}
return widenedData.data();
}
// The transfer half of the image-format widening: an image-bindable texture whose ES
// storage was widened to a core carrier is described to the driver as a four-component
// transfer, so its one- or two-component client data has to be repacked the same way the
// three-channel colour-renderable widening repacks its own.
// transfer, so its narrower client data has to be repacked the same way the three-channel
// colour-renderable widening repacks its own.
//
// Two shapes, because the carriers come in two kinds. Seventeen of the eighteen keep the
// frontend format's component TYPE and only add channels, so padding the shadow out to
// four components is the whole conversion. r11f_g11f_b10f does not: its shadow is one
// PACKED 32-bit word per texel and its carrier is GL_RGBA16F, so the word has to be
// DECODED into four floats. Reading it as three components of the carrier's type - what
// the repack below would do - would take twelve bytes from a four-byte texel and shear
// the level, which is what the allFormats LOAD walkers see and the STORE ones do not (a
// store overwrites every texel the upload got wrong).
//
// Composes with PrepareFallbackUpload rather than replacing it, and the composition is a
// no-op by construction: none of the seventeen widened formats is a three-channel one
// (GetWidenableClientComponentCount reports 0 for every one of them), and the SNORM
// shadow-to-float conversion only fires for a GL_FLOAT transfer type, which the widened
// triple never picks for the two SNORM8 formats. So the shadow reaches this untouched and
// one repack is all that runs.
// no-op by construction: none of the widened formats is one GetWidenableClientComponentCount
// reports a count for, and the SNORM shadow-to-float conversion only fires for a GL_FLOAT
// transfer type, which the widened triple never picks for the two SNORM8 formats. So the
// shadow reaches this untouched and one conversion is all that runs.
static const void* PrepareImageWidenedUpload(const TextureImpl::ImageBindableStorageWidening& widening,
const IntVec3& texelSize, const void* data, SizeT byteSize,
Vector<Uint8>& widenedData) {
if (!widening || widening.SourceChannels == 0 || widening.SourceChannels >= 4) {
if (!widening || widening.SourceChannels == 0 || widening.SourceChannels > 4) {
return data;
}
if (widening.PackedFloatSource) {
return PreparePackedFloatWidenedUpload(texelSize, data, byteSize, widenedData);
}
if (widening.SourceChannels == 4) {
return data;
}
return PrepareChannelWidenedUpload(widening.SourceChannels, texelSize, data, byteSize, widening.Type,
@@ -5150,12 +5359,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
// The GL internal format a glslang layout format names, for the seventeen non-core
// formats WidenImageFormatsForEssl carries exactly plus nothing else: the only
// The GL internal format a glslang layout format names, for the eighteen non-core
// formats WidenImageFormatsForEssl carries losslessly plus nothing else: the only
// question asked of it is "does this DECLARED format widen", and answering 0 for
// everything else is the same "no" a non-widenable format gets. Kept as its own
// switch rather than routed through the frontend's enum converters because a
// TLayoutFormat is a glslang value and the reflection snapshot stores it raw.
//
// IT MUST LIST EXACTLY WHAT WideningOfSpirvImageFormat DOES. This table is what arms
// the pass (ImageFormatWillBeWidened -> declaresWidenableImageFormat), so a format the
// pass would carry but this switch answers 0 for never gets the chance: the module
// reaches SPIRV-Cross with its original qualifier, the throw takes the stage, and the
// only visible symptom is the "no GLSL ES spelling" diagnostic for a format that has
// one. That is exactly what r11f_g11f_b10f did until it was added here.
Uint GLInternalFormatOfLayoutFormat(glslang::TLayoutFormat format) {
switch (format) {
case glslang::ElfRg32f: return 0x8230; // GL_RG32F
@@ -5175,6 +5391,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
case glslang::ElfR16ui: return 0x8234; // GL_R16UI
case glslang::ElfRg8ui: return 0x8238; // GL_RG8UI
case glslang::ElfR8ui: return 0x8232; // GL_R8UI
// Not a channel widening but a lossless re-encoding into rgba16f - the one entry
// here whose carrier has a different per-channel layout. See
// WidenImageFormatsPass.h.
case glslang::ElfR11fG11fB10f: return 0x8C3A; // GL_R11F_G11F_B10F
default:
return 0;
}
@@ -5235,11 +5455,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
// spelling still has to become legal ESSL somehow.
const auto declaredFormat = static_cast<glslang::TLayoutFormat>(type.layoutFormat);
if (!IsCoreEsslLayoutFormat(declaredFormat)) {
// Seventeen of the twenty-six non-core formats are re-declared in the core
// format that carries them exactly, with every access masked back to the
// channels GL says they have (WidenImageFormatsForEssl, and the matching
// storage/bind widening in TextureImpl). Those need neither the extension
// nor the diagnostic: there IS a legal spelling for them now.
// Eighteen of the twenty-six non-core formats are re-declared in a core
// format that carries them losslessly, with every access masked back to
// the channels GL says they have (WidenImageFormatsForEssl, and the
// matching storage/bind widening in TextureImpl). Those need neither the
// extension nor the diagnostic: there IS a legal spelling for them now.
if (ImageFormatWillBeWidened(GLInternalFormatOfLayoutFormat(declaredFormat))) {
inputs.declaresWidenableImageFormat = true;
} else {
@@ -5493,7 +5713,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
// keep the two in step.
const Int advertisedMaxSamples =
std::max(g_GLESCapabilities.MaxSamples, kFrontendMaxSamples);
const Bool viewportLoweringArmed = !g_GLESCapabilities.SupportsViewportArray;
// Armed by the EMULATION as well as by the missing extension, and the emulation is on
// by default (MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION). Having the extension is not a
// reason to keep the builtin: it only ever gave the SHADER a compilable name, while
// the driver's INDEXED viewport state was never programmed by anything in MobileGL
// (SyncRenderState pushes index 0 and stops), so an extension-capable driver
// rasterized every index as index 0 exactly like a driver without it. Lowering here
// is what lets the ESSL passes downstream turn the builtin into the flat varying the
// replay gates on.
//
// Restricted to the three stages GL lets WRITE the builtin (4.1 core gives it to the
// geometry stage, ARB_shader_viewport_layer_array adds vertex and tessellation
// evaluation). A fragment stage's gl_ViewportIndex is an INPUT, which the pass
// declines anyway, and a compute stage has none - so arming those two only ever
// bought them the shared probe's BuildModule for nothing.
const Bool stageCanWriteViewportIndex = glShaderType == GL_VERTEX_SHADER ||
glShaderType == GL_TESS_EVALUATION_SHADER ||
glShaderType == GL_GEOMETRY_SHADER;
const Bool viewportLoweringArmed =
stageCanWriteViewportIndex &&
(ViewportArrayEmulationEnabled() || !g_GLESCapabilities.SupportsViewportArray);
const Bool sampleClampArmed =
g_GLESCapabilities.MaxColorTextureSamples < advertisedMaxSamples ||
g_GLESCapabilities.MaxIntegerSamples < advertisedMaxSamples ||
@@ -5517,11 +5756,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
*effectiveSpirv, loweredViewportSpirv, enableSpirvValidation) &&
!loweredViewportSpirv.empty()) {
effectiveSpirv = &loweredViewportSpirv;
MGLOG_D("Program %u stage %s writes gl_ViewportIndex, which this ES driver has "
"no GL_OES_viewport_array for. The builtin was demoted to a plain "
"global; every invocation renders into viewport 0.",
MGLOG_D("Program %u stage %s writes gl_ViewportIndex, which ESSL has no core "
"spelling for. The builtin was demoted to a plain global; %s.",
m_backendProgramId,
MG_Util::ConvertGLEnumToString(glShaderType).c_str());
MG_Util::ConvertGLEnumToString(glShaderType).c_str(),
ViewportArrayEmulationEnabled()
? "the ESSL passes below promote it to a routing varying"
: "every invocation renders into viewport 0");
}
// GL 4.6 core table 23.53 requires GL_MAX_SAMPLES >= 4, so every multisample
@@ -5865,6 +6106,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
spvcSession.SetAtomicCounterBlockBindings(atomicCounterEsslBindingTop,
outAtomicCounterGlBindings);
// `layout(index = 0)` is the GL default spelled out loud, and GLSL ES has no such
// qualifier in core - a stage that prints it is refused with "index layout
// qualifier requires EXT_blend_func_extended" and the whole program then draws
// nothing. Drop the decoration when it carries the default; a REAL dual-source
// index (1) is left alone, because that one genuinely needs the extension and the
// driver has to see it. Fragment stage only: no other stage can carry it.
if (glShaderType == GL_FRAGMENT_SHADER) {
spvcSession.DropDefaultFragmentOutputColorIndex();
}
// `readonly writeonly` together says the buffer variable can only be asked its
// .length(), which the frontend has already enforced - so the pair is inert, and
// printing it is not. Mesa's ES compiler refuses a block spelled that way and the
// stage never reaches the program.
spvcSession.RelaxReadWriteExclusiveStorageBuffers();
const char* result = nullptr;
spvcSession.Compile(&result);
@@ -6233,7 +6490,40 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
const Bool needsPassthroughTessControl = hasTessEvalStage && !hasTessControlStage;
// The stage order the loop below walks, with every FRAGMENT stage moved to the end.
// The viewport-routing gate is the reason: whether a fragment stage needs one is a
// question about the OTHER stages ("does any of them still write gl_ViewportIndex?"),
// and the honest, free answer to it is the promotion the producing stage's own text
// pass just performed. Answering it any other way costs a BuildModule per
// pre-rasterization stage of every program - the parse the shared SpirvGateFeatures
// probe exists to avoid. Nothing else in the loop is order-sensitive: the two
// passthrough-tessellation sources it captures are a vertex and an evaluation stage,
// and the three sets it accumulates are unions.
Vector<SizeT> stageOrder;
stageOrder.reserve(linkedStages.size());
for (SizeT index = 0; index < linkedStages.size(); ++index) {
if (linkedStages[index] != ShaderStage::Fragment) stageOrder.push_back(index);
}
for (SizeT index = 0; index < linkedStages.size(); ++index) {
if (linkedStages[index] == ShaderStage::Fragment) stageOrder.push_back(index);
}
// Set by whichever pre-rasterization stage's demoted mg_ViewportIndex global the text
// pass turned into a varying; read by the fragment stage to decide whether to inject
// the gate that consumes it.
Bool programRoutesViewportIndex = false;
// No fragment stage, no gate - and without a gate the promotion below would only add
// an output nothing can read. That is not merely useless: in a separable program
// pipeline the fragment stage lives in a DIFFERENT program, which never saw this
// build and cannot be given a gate, so promoting there would hang an unmatched
// varying off a program to buy nothing. Both cases keep the pre-emulation behaviour,
// which is what a program with no fragment stage had anyway.
const Bool programHasFragmentStage =
std::find(linkedStages.begin(), linkedStages.end(), ShaderStage::Fragment) !=
linkedStages.end();
const Bool viewportEmulationForThisProgram =
ViewportArrayEmulationEnabled() && programHasFragmentStage;
for (const SizeT index : stageOrder) {
GLenum glShaderType = MG_Util::ConvertShaderStageToGLEnum(linkedStages[index]);
GLuint backendShaderId = g_GLESFuncs.glCreateShader(glShaderType);
@@ -6272,7 +6562,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
MG_Util::ShaderTranspiler::EsslTranslationKeyInputs esslKeyInputs;
esslKeyInputs.spirv = &spirvCode;
esslKeyInputs.shaderType = glShaderType;
esslKeyInputs.supportsViewportArray = g_GLESCapabilities.SupportsViewportArray;
// The EFFECTIVE arming, computed the same way TranspileSpirvToEssl computes it.
// Duplicated rather than shared because the two live on opposite sides of the
// memo boundary - and a key that disagrees with the pass it is keying is the one
// failure mode of this cache that renders wrong pixels instead of being slow.
esslKeyInputs.viewportIndexLoweringArmed =
(glShaderType == GL_VERTEX_SHADER || glShaderType == GL_TESS_EVALUATION_SHADER ||
glShaderType == GL_GEOMETRY_SHADER) &&
(ViewportArrayEmulationEnabled() || !g_GLESCapabilities.SupportsViewportArray);
esslKeyInputs.supportsNoperspectiveInterpolation =
g_GLESCapabilities.SupportsNoperspectiveInterpolation;
esslKeyInputs.supportsExtendedImageFormats =
@@ -6427,8 +6724,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
// name; a driver without the extension took the LowerViewportIndexPass fallback
// above and its source no longer names the builtin at all, so the two are mutually
// exclusive by construction. Read `source` BEFORE it is moved from.
const Bool needsViewportArrayExtension = g_GLESCapabilities.SupportsViewportArray &&
source.find("gl_ViewportIndex") != String::npos;
// The routing emulation is the third way this can be reached and the only one
// that needs no directive: it renames the fragment stage's read onto the varying
// the producing stage now writes, a few passes below.
const Bool needsViewportArrayExtension =
g_GLESCapabilities.SupportsViewportArray &&
!(ViewportArrayEmulationEnabled() && programRoutesViewportIndex) &&
source.find("gl_ViewportIndex") != String::npos;
source = RequestViewportArrayExtension(std::move(source), needsViewportArrayExtension);
source = RebindImageUniformsToFrontendUnits(std::move(source), stateProgramObject);
@@ -6489,6 +6791,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
source = EmulateTextureLodBias(source, ShouldAvoidExplicitLodBiasOnAngleLlvmpipe());
source = EmulateBaseInstanceInVertexShader(std::move(source), glShaderType);
source = PromoteDrawParameterGlobalsToUniforms(std::move(source), glShaderType);
// The two halves of the gl_ViewportIndex routing emulation, next to the draw-
// parameter promotion because they are the same shape: a builtin ESSL cannot
// spell, demoted to a plain global by a SPIR-V pass, given a real interface here.
// BEFORE ForceSupporterOutput, so the `precision highp` statements it hoists to
// the top land above the declarations these inject; AFTER
// ForceFlatIntegerVaryings, which matches only declarations carrying a
// layout(...) qualifier and so cannot touch either of them.
if (viewportEmulationForThisProgram) {
if (glShaderType == GL_FRAGMENT_SHADER) {
if (programRoutesViewportIndex && !InjectViewportIndexPassGate(source)) {
// MGLOG_E, unlatched, like the transpile- and compile-failure
// diagnostics around it: the program still links and still draws, so
// nothing else in the process will ever say that its viewport routing
// silently collapsed back to one rectangle.
MGLOG_E("Program %u routes gl_ViewportIndex but its fragment stage has no "
"entry point to gate, so the routing cannot be emulated: every "
"index will rasterize against viewport 0. State program ID: %u.",
m_backendProgramId, stateProgramObject->GetExternalIndex());
}
} else if (PromoteViewportIndexGlobalToVarying(source)) {
programRoutesViewportIndex = true;
}
}
source = ForceSupporterOutput(source);
source = ClampNormFallbackOutputs(std::move(source), glShaderType,
m_snormFallbackClampOutputMask,
@@ -6691,6 +7016,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
BASE_VERTEX_UNIFORM_NAME);
m_baseInstanceWordIndexUniformLocation =
g_GLESFuncs.glGetUniformLocation(m_backendProgramId, BASE_INSTANCE_WORD_INDEX_UNIFORM_NAME);
// Asked of the DRIVER rather than remembered from the injection, deliberately: the
// gate is only real if the uniform survived compilation and linking, and this is the
// one question whose answer covers both. A gate the driver optimized away would
// otherwise leave the draw path replaying passes whose mask reaches nothing, which
// renders every index's primitives in every pass.
m_viewportPassMaskUniformLocation =
g_GLESFuncs.glGetUniformLocation(m_backendProgramId, VIEWPORT_PASS_MASK_UNIFORM_NAME);
if (m_viewportPassMaskUniformLocation >= 0) {
// Sticky, and never cleared on a relink: it only ever short-circuits a per-draw
// check, so being late to go false costs a pointer compare and being late to go
// true would cost correctness.
g_anyProgramRoutesViewportIndex = true;
}
// The mg_IndirectParams block binding is baked into the ESSL (ES cannot rebind
// SSBO blocks after compile); record it so draws bind the indirect buffer there.
m_indirectParamsBinding = -1;
@@ -6893,6 +7231,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
g_GLESFuncs.glUniform1i(m_drawIdUniformLocation, static_cast<GLint>(drawId));
}
void BackendProgramObjectImpl::SetViewportPassMask(Uint32 indexMask) const {
if (m_viewportPassMaskUniformLocation < 0) {
return;
}
g_GLESFuncs.glUniform1i(m_viewportPassMaskUniformLocation, static_cast<GLint>(indexMask));
}
} // namespace PrgramImpl
namespace SamplerImpl {
+74
View File
@@ -21,6 +21,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
String EmulateBaseInstanceInVertexShader(String source, GLenum shaderType);
String PromoteDrawParameterGlobalsToUniforms(String source, GLenum shaderType);
// The ESSL half of the gl_ViewportIndex routing emulation, in the order a program's stages
// meet it. Both are pure String -> String rewrites over what SPIRV-Cross emitted once
// LowerViewportIndexPass has demoted the builtin to the plain global `mg_ViewportIndex`.
//
// The producing stage's global becomes an ordinary flat varying; true when there was one to
// promote, which is also the answer to "does this program route viewports at all".
Bool PromoteViewportIndexGlobalToVarying(String& source);
// The fragment stage grows a matching flat input, the mg_ViewportPassMask uniform the draw
// path writes, and a wrapper entry point that discards every fragment whose primitive routed
// to an index the current replay pass is not drawing. False when the stage has no entry point
// to wrap, which leaves the program renderable but unrouted.
Bool InjectViewportIndexPassGate(String& source);
// Whether a vertex shader may declare a storage block at all, given what the host driver
// reports for GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS. Pure, and separated from the capability
// global purely so the decision can be tested without one.
@@ -113,6 +126,58 @@ namespace MobileGL::MG_Backend::DirectGLES {
// link.
Bool CurrentProgramMayNeedPerSubDrawBuiltins(Bool batchCarriesBaseVertices);
// ---- gl_ViewportIndex routing emulation, draw half ---------------------------------------
//
// GLES has ONE viewport, ONE scissor rectangle and ONE depth range; GL 4.1 has sixteen of
// each, selected per primitive by gl_ViewportIndex. There is no ES entry point to program the
// other fifteen with (GL_OES_viewport_array exists but Adreno 830 does not have it, verified
// three ways), so the only way to rasterize a primitive against index i's rectangle is to
// make index i's rectangle THE viewport for the duration of a draw - which means issuing the
// draw once per distinct viewport state and letting the fragment stage throw away the
// primitives that belong to the other indices (the gate Managers.cpp injects).
//
// Indices whose whole state tuple (viewport rectangle, scissor rectangle, scissor-test enable,
// depth range) is identical share ONE pass, so the overwhelmingly common case - every index
// still holding what glViewport/glScissor/glDepthRange broadcast to all sixteen - collapses
// to a single pass with an all-ones gate mask, i.e. one draw and no behaviour change at all.
//
// Whether emulation runs. Off only under MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION falsy, which
// restores the pre-emulation path as a negative control.
Bool ViewportArrayEmulationEnabled();
// Whether ANY program built in this process has come out with a viewport gate. Sticky once
// true; it exists so that BeginViewportRoutingPasses - which runs on every draw of every
// workload - can answer with one static load in the case that matters, which is every
// application that has never heard of gl_ViewportIndex.
extern Bool g_anyProgramRoutesViewportIndex;
// Number of times the current draw has to be issued. Always >= 1, and exactly 1 - with no
// state touched - whenever the current program does not route viewports, whenever every
// configured index shares one state, and whenever replaying would multiply a side effect the
// fragment gate cannot undo (transform feedback, rasterizer discard). Also seeds the pass
// mask uniform for that single-pass case, so a gated fragment shader never runs against the
// zero every GLSL uniform starts at - which would discard the whole draw.
Uint BeginViewportRoutingPasses();
// Push pass `pass`'s viewport / scissor / scissor-test / depth range onto the ES context and
// set the gate mask to the indices it serves. Only called when the count above exceeds 1.
void ApplyViewportRoutingPass(Uint pass);
// Restore the gate mask and mark the render-state shadow dirty, so the next ordinary draw
// re-pushes index 0's state. Takes the count so it can do nothing at all in the common case.
void EndViewportRoutingPasses(Uint passCount);
// Issue one draw, replayed once per viewport-routing pass. Every application-visible draw
// entry point wraps its native glDraw* call in this; the internal blit and clear helpers
// deliberately do not, because they bind their own programs, which never route.
template <typename IssueDraw>
inline void ForEachViewportRoutingPass(IssueDraw&& issue) {
const Uint passCount = BeginViewportRoutingPasses();
for (Uint pass = 0; pass < passCount; ++pass) {
if (passCount > 1) {
ApplyViewportRoutingPass(pass);
}
issue();
}
EndViewportRoutingPasses(passCount);
}
template <typename StateObject, typename BackendObject>
class StateBackendObjectRegistry {
public:
@@ -1200,6 +1265,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Same for gl_BaseVertex: only a program that reads it pays for the per-draw
// uniform write, and only such a program needs the reset after one.
Bool ReadsBaseVertex() const { return m_baseVertexUniformLocation >= 0; }
// Which viewport indices the next draw's fragments may keep, one bit each. Written
// once per replay pass; see ForEachViewportRoutingPass.
void SetViewportPassMask(Uint32 indexMask) const;
// True when this build injected the fragment-stage viewport gate, i.e. when a
// pre-rasterization stage routes by gl_ViewportIndex AND the fragment stage can act
// on it. The uniform is the honest test for both halves: it exists only where the
// gate was injected, and the gate is injected only where a stage routes.
Bool RoutesViewportIndex() const { return m_viewportPassMaskUniformLocation >= 0; }
Int GetIndirectParamsBinding() const { return m_indirectParamsBinding; }
Uint GetBackendProgramId() const { return m_backendProgramId; }
// False when the last SyncToBackend could not produce a usable program (a
@@ -1316,6 +1389,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
Int m_drawIdUniformLocation = -1;
Int m_baseVertexUniformLocation = -1;
Int m_baseInstanceWordIndexUniformLocation = -1;
Int m_viewportPassMaskUniformLocation = -1;
Int m_indirectParamsBinding = -1;
Uint32 m_snormFallbackClampOutputMask = 0;
Uint32 m_unormFallbackClampOutputMask = 0;
+18 -8
View File
@@ -414,14 +414,18 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
const Uint previousIndirectBinding = BoundDrawIndirectBufferId();
BufferImpl::BindBufferId(GL_DRAW_INDIRECT_BUFFER, g_indirectCommands.id);
if (batched) {
g_GLESFuncs.glMultiDrawElementsIndirectEXT(mode, type, reinterpret_cast<const void*>(commandBase),
drawcount, 0);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glMultiDrawElementsIndirectEXT(mode, type, reinterpret_cast<const void*>(commandBase),
drawcount, 0);
});
} else {
for (GLsizei i = 0; i < drawcount; ++i) {
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
if (feedBaseVertex) SetCurrentBaseVertex(basevertex ? basevertex[i] : 0);
const SizeT commandOffset = commandBase + static_cast<SizeT>(i) * sizeof(DrawElementsIndirectCommand);
g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast<const void*>(commandOffset));
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast<const void*>(commandOffset));
});
}
if (feedDrawID) SetCurrentDrawID(0);
if (feedBaseVertex) SetCurrentBaseVertex(0);
@@ -442,8 +446,10 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
if (count[i] <= 0) continue;
if (feedDrawID) SetCurrentDrawID(static_cast<Uint32>(i));
if (feedBaseVertex) SetCurrentBaseVertex(basevertex ? basevertex[i] : 0);
g_GLESFuncs.glDrawElementsBaseVertex(mode, count[i], type, indices[i],
basevertex ? basevertex[i] : 0);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElementsBaseVertex(mode, count[i], type, indices[i],
basevertex ? basevertex[i] : 0);
});
}
if (feedDrawID) SetCurrentDrawID(0);
if (feedBaseVertex) SetCurrentBaseVertex(0);
@@ -515,8 +521,10 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
// driver sees none - but gl_BaseVertex still has to report the value the
// application passed for this sub-draw.
if (feedBaseVertex) SetCurrentBaseVertex(basevertex ? basevertex[i] : 0);
g_GLESFuncs.glDrawElements(mode, count[i], GL_UNSIGNED_INT,
reinterpret_cast<const void*>(indexBase + cursor * sizeof(Uint32)));
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElements(mode, count[i], GL_UNSIGNED_INT,
reinterpret_cast<const void*>(indexBase + cursor * sizeof(Uint32)));
});
cursor += static_cast<SizeT>(count[i]);
}
if (feedDrawID) SetCurrentDrawID(0);
@@ -870,7 +878,9 @@ void main() {
if (flattened.indexCount != 0) {
const Uint previousIndexBinding = BoundIndexBufferId();
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, flattened.bufferId);
g_GLESFuncs.glDrawElements(mode, static_cast<GLsizei>(flattened.indexCount), GL_UNSIGNED_INT, nullptr);
ForEachViewportRoutingPass([&] {
g_GLESFuncs.glDrawElements(mode, static_cast<GLsizei>(flattened.indexCount), GL_UNSIGNED_INT, nullptr);
});
BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, previousIndexBinding);
return;
}
+13
View File
@@ -277,6 +277,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
// its own; this call is only here to spell the transfer pair that describes it.
MG_Util::TextureFormatProcessor::NormalizePixelFormat(carrier, Flags<PixelFormatNormalizeOptionBit>{},
nullptr, &widening.Format, &widening.Type);
// r11f_g11f_b10f is the one carrier that is not a channel widening, and the transfer
// pair has to say so. Every other entry keeps the frontend format's own component
// type - a GL_RG16F shadow is halves and so is its GL_RGBA16F carrier, so padding the
// channels is the whole conversion. This shadow is a PACKED 32-bit word (GL_RGB with
// GL_UNSIGNED_INT_10F_11F_11F_REV, TextureFormatProcessor::NormalizePixelFormat), and
// no ES driver accepts that type for a GL_RGBA16F level. GL_FLOAT is asked for
// instead - legal for GL_RGBA16F, and the type the unpack in
// PrepareImageWidenedUpload writes - so the two sides name the same layout.
if (internalFormat == TextureInternalFormat::R11FG11FB10F) {
widening.Format = GL_RGBA;
widening.Type = GL_FLOAT;
widening.PackedFloatSource = true;
}
return widening;
}
} // namespace TextureImpl
+7
View File
@@ -113,6 +113,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
// transfer type cannot tell the two apart (GL_UNSIGNED_BYTE serves both RG8 and
// RG8UI), so the carrier decides.
Bool IntegerData = false;
// The frontend shadow is a PACKED word rather than SourceChannels separate components
// of the carrier's own type, so the upload has to DECODE it instead of padding it out
// (PrepareImageWidenedUpload). True only for r11f_g11f_b10f, whose shadow is one
// GL_UNSIGNED_INT_10F_11F_11F_REV per texel and whose carrier is GL_RGBA16F: the
// channel repack every other entry uses would read three floats out of a four-byte
// texel and shear the level.
Bool PackedFloatSource = false;
explicit operator Bool() const { return InternalFormat != GL_UNKNOWN_MGL; }
};
@@ -27,6 +27,7 @@
// which is the whole point.
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
@@ -153,6 +154,151 @@ void main() {
std::string m_buildLog;
};
// A SHADER STORAGE BLOCK that holds doubles is the one place the narrowing is NOT free:
// demoting `double` to `float` also repacks the block, and the bytes the application
// wrote into the buffer do not move with it. Every member past the first double then
// reads and writes at the wrong offset, and the block is simply shorter than the one
// that was bound - the tail of it is never touched at all
// (KHR-GL43.shader_storage_buffer_object.basic-stdLayout-case3, whose output matched its
// input up to the first double's slot and was zero from there on).
//
// The block layout is fixed by GL 4.6 core 7.6.2.2 and is asserted here as literal byte
// offsets rather than queried, so this says what the SPEC requires and not what MobileGL
// happens to report. Both packings are covered because they differ in exactly the places
// that matter: std140 rounds an array's stride and a matrix's column stride up to 16,
// std430 does not, and only std430 packs the scalars tightly.
//
// Every value is exactly representable in binary32, so a correct implementation copies
// the block BYTE FOR BYTE even though it narrows each double on the way through.
constexpr const char* kBlockCopySource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std140, binding = 0) buffer In140 {
int data0;
float data1[3];
mat3x2 data2;
double data3;
double data4[2];
int data5;
dvec3 data6;
} g_in140;
layout(std430, binding = 1) buffer In430 {
int data0;
float data1[3];
mat3x2 data2;
double data3;
double data4[2];
int data5;
dvec3 data6;
} g_in430;
layout(std140, binding = 2) buffer Out140 {
int data0;
float data1[3];
mat3x2 data2;
double data3;
double data4[2];
int data5;
dvec3 data6;
} g_out140;
layout(std430, binding = 3) buffer Out430 {
int data0;
float data1[3];
mat3x2 data2;
double data3;
double data4[2];
int data5;
dvec3 data6;
} g_out430;
void main() {
g_out140.data0 = g_in140.data0;
for (int i = 0; i < 3; ++i) g_out140.data1[i] = g_in140.data1[i];
g_out140.data2 = g_in140.data2;
g_out140.data3 = g_in140.data3;
for (int i = 0; i < 2; ++i) g_out140.data4[i] = g_in140.data4[i];
g_out140.data5 = g_in140.data5;
g_out140.data6 = g_in140.data6;
g_out430.data0 = g_in430.data0;
for (int i = 0; i < 3; ++i) g_out430.data1[i] = g_in430.data1[i];
g_out430.data2 = g_in430.data2;
g_out430.data3 = g_in430.data3;
for (int i = 0; i < 2; ++i) g_out430.data4[i] = g_in430.data4[i];
g_out430.data5 = g_in430.data5;
g_out430.data6 = g_in430.data6;
}
)";
// GL 4.6 core 7.6.2.2 rule by rule, for the block above.
// std140: an array's element stride and a matrix's column stride round up to 16, a
// double aligns to 8 and a dvec3 to 32.
// std430: the same without the rounding - so the scalars pack tightly and only the
// dvec3's 32-byte alignment leaves a hole.
struct BlockLayout {
int data0;
int data1;
int data1Stride;
int data2;
int data2ColumnStride;
int data3;
int data4;
int data4Stride;
int data5;
int data6;
int size;
};
constexpr BlockLayout kStd140{0, 16, 16, 64, 16, 112, 128, 16, 160, 192, 216};
constexpr BlockLayout kStd430{0, 4, 4, 16, 8, 40, 48, 8, 64, 96, 120};
void PokeInt(std::vector<unsigned char>& bytes, int offset, int value) {
std::memcpy(&bytes[static_cast<std::size_t>(offset)], &value, sizeof(value));
}
void PokeFloat(std::vector<unsigned char>& bytes, int offset, float value) {
std::memcpy(&bytes[static_cast<std::size_t>(offset)], &value, sizeof(value));
}
void PokeDouble(std::vector<unsigned char>& bytes, int offset, double value) {
std::memcpy(&bytes[static_cast<std::size_t>(offset)], &value, sizeof(value));
}
// The block's contents, at the offsets the standard puts them. Padding stays zero, which
// is what makes a byte-for-byte comparison against the (zero-initialised) output buffer
// catch a member that landed somewhere it should not have.
std::vector<unsigned char> MakeBlockContents(const BlockLayout& layout) {
std::vector<unsigned char> bytes(static_cast<std::size_t>(layout.size), 0);
PokeInt(bytes, layout.data0, 1);
for (int i = 0; i < 3; ++i) {
PokeFloat(bytes, layout.data1 + i * layout.data1Stride, 2.0f + static_cast<float>(i));
}
// Column-major, two rows per column.
for (int column = 0; column < 3; ++column) {
for (int row = 0; row < 2; ++row) {
PokeFloat(bytes, layout.data2 + column * layout.data2ColumnStride + row * 4,
5.0f + static_cast<float>(column * 2 + row));
}
}
PokeDouble(bytes, layout.data3, 11.0);
for (int i = 0; i < 2; ++i) {
PokeDouble(bytes, layout.data4 + i * layout.data4Stride, 12.0 + static_cast<double>(i));
}
PokeInt(bytes, layout.data5, 14);
for (int i = 0; i < 3; ++i) {
PokeDouble(bytes, layout.data6 + i * 8, 15.0 + static_cast<double>(i));
}
return bytes;
}
// Names the first byte that differs, and which member owns it, so a failure is a
// diagnosis rather than "the buffer is wrong".
std::string DescribeOffset(const BlockLayout& layout, int offset) {
const std::pair<int, const char*> members[] = {
{layout.data0, "data0"}, {layout.data1, "data1"}, {layout.data2, "data2"},
{layout.data3, "data3"}, {layout.data4, "data4"}, {layout.data5, "data5"},
{layout.data6, "data6"}};
const char* owner = "(padding before data0)";
for (const auto& [start, name] : members) {
if (offset >= start) owner = name;
}
return std::string(owner);
}
// Every double-typed uniform shape GLSL has, all thirteen of them, in one program - the
// shape of KHR-GL43.compute_shader.fp64-case2. The scalar and the square matrices are
// covered by the cases above; what only a set like this reaches is the NON-SQUARE
@@ -819,5 +965,66 @@ void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
EXPECT_EQ(FirstGLError(), 0u);
}
TEST_F(DoublePrecisionScenario, AStorageBlockWithDoublesKeepsTheLayoutItWasBoundWith) {
if (!Ready()) return;
GLint blocks = 0;
glGetIntegerv(GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, &blocks);
if (blocks < 4) {
GTEST_SKIP() << "GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS is " << blocks << "; this needs 4";
}
const unsigned int program = CompileComputeProgram(kBlockCopySource);
ASSERT_NE(program, 0u) << m_buildLog;
const std::vector<unsigned char> in140 = MakeBlockContents(kStd140);
const std::vector<unsigned char> in430 = MakeBlockContents(kStd430);
const std::vector<unsigned char> zero140(in140.size(), 0);
const std::vector<unsigned char> zero430(in430.size(), 0);
GLuint buffers[4] = {};
glGenBuffers(4, buffers);
const std::vector<unsigned char>* contents[4] = {&in140, &in430, &zero140, &zero430};
for (int i = 0; i < 4; ++i) {
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, static_cast<GLuint>(i), buffers[i]);
glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(contents[i]->size()),
contents[i]->data(), GL_DYNAMIC_COPY);
}
ASSERT_EQ(FirstGLError(), 0u);
glUseProgram(program);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
EXPECT_EQ(FirstGLError(), 0u);
for (int pass = 0; pass < 2; ++pass) {
const BlockLayout& layout = pass == 0 ? kStd140 : kStd430;
const std::vector<unsigned char>& expected = pass == 0 ? in140 : in430;
const char* packing = pass == 0 ? "std140" : "std430";
std::vector<unsigned char> observed(expected.size(), 0xEE);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffers[2 + pass]);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0,
static_cast<GLsizeiptr>(observed.size()), observed.data());
int mismatches = 0;
int firstMismatch = -1;
for (std::size_t i = 0; i < expected.size(); ++i) {
if (expected[i] == observed[i]) continue;
++mismatches;
if (firstMismatch < 0) firstMismatch = static_cast<int>(i);
}
EXPECT_EQ(mismatches, 0)
<< packing << " block: " << mismatches << " of " << expected.size()
<< " bytes differ, first at byte " << firstMismatch << " (in "
<< DescribeOffset(layout, firstMismatch < 0 ? 0 : firstMismatch)
<< "); a block that was repacked around its doubles reads and writes every "
"member after the first one at the wrong offset";
}
glUseProgram(0);
glDeleteProgram(program);
glDeleteBuffers(4, buffers);
EXPECT_EQ(FirstGLError(), 0u);
}
} // namespace
} // namespace MGITest
@@ -179,6 +179,13 @@ void main()
in flat uint v_index;
out vec4 o_color;
void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
)";
// The colour index spelled out at its default value. Says nothing that
// `layout(location = 0)` alone does not, and must therefore cost nothing.
constexpr const char* kExplicitColorIndexFS = R"(#version 420 core
layout(location = 0, index = 0) out vec4 o_color;
void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
)";
class Glsl420DeclarationScenario : public ScenarioTest {
@@ -473,4 +480,24 @@ void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
EXPECT_EQ(centre.g, 255) << "the atomic-counter shader linked but painted nothing";
}
// `layout(location = 0, index = 0)` is the GL default written out loud, and an application
// is entitled to write it - KHR-GL43.shader_atomic_counters.basic-program-query does. It has
// to reach the driver as an ORDINARY single-source output: GLSL ES has no `index` qualifier
// in core, so a transpiler that prints the decoration back gets "index layout qualifier
// requires EXT_blend_func_extended", the stage never compiles, the program runs with a stage
// missing and the draw paints nothing at all. Black, not red - which is why the conformance
// case looked like the atomic counters had stopped counting.
TEST_F(Glsl420DeclarationScenario, AnExplicitDefaultColorIndexStillDraws) {
if (!Ready()) return;
const GLuint program = Build(kQuadVS, kExplicitColorIndexFS);
if (program == 0) return;
const Rgba8 centre = DrawAndRead(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(centre.g, 255) << "a fragment output declared layout(location = 0, index = 0) painted "
"nothing; its stage was almost certainly refused by the driver";
EXPECT_EQ(centre.r, 0u);
}
} // namespace MGITest
@@ -262,6 +262,95 @@ void main()
}
}
// GL_R11F_G11F_B10F, the format the allFormats and allTargets walkers stop at once the
// channel widening has carried everything before it - and the one carrier that is NOT a
// channel widening. It has no core format of its own per-channel width, so it is carried
// in GL_RGBA16F, whose 5-bit exponent and longer mantissa hold every 11f (e5m6) and 10f
// (e5m5) value exactly.
//
// What makes this case different from every other one here, and why it is worth its own
// test: the frontend's shadow for this format is ONE PACKED 32-BIT WORD per texel, not
// three components of the carrier's type. The upload therefore has to DECODE it, where
// every other widening only pads channels onto data already in the right component type.
// A widening that reused the channel repack reads three floats out of a four-byte texel
// and shears the whole level - which a STORE test cannot see, because the dispatch
// overwrites every texel the upload got wrong. So the seed here is per-texel distinct and
// is checked through an imageLoad BEFORE anything is stored.
//
// Every constant is chosen to be exact in both encodings, so the comparisons can be
// equality rather than tolerance: the 1/8 steps need three mantissa bits of the 11f
// channels' six, and the 1/16 steps at exponent 1 need one of the 10f channel's five.
TEST_F(NonCoreImageFormatScenario, PackedFloatImageDecodesItsUploadAndDropsSurplusStores) {
if (!Ready()) GTEST_SKIP() << "no GL context";
if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver";
constexpr int kTexels = kExtent * kExtent;
std::vector<float> seed(static_cast<std::size_t>(kTexels) * 3u, 0.0f);
for (int texel = 0; texel < kTexels; ++texel) {
seed[texel * 3 + 0] = 1.0f + static_cast<float>(texel) / 8.0f;
seed[texel * 3 + 1] = 2.0f + static_cast<float>(texel) / 8.0f;
seed[texel * 3 + 2] = 3.0f + static_cast<float>(texel) / 16.0f;
}
const std::vector<float> wideSeed(static_cast<std::size_t>(kTexels) * 4u, -1.0f);
const GLuint narrow = MakeTexture(GL_R11F_G11F_B10F, GL_RGB, GL_FLOAT, seed.data());
const GLuint wide = MakeTexture(GL_RGBA32F, GL_RGBA, GL_FLOAT, wideSeed.data());
if (narrow == 0 || wide == 0) return;
const GLuint loadProgram = MakeComputeProgram(R"(#version 430 core
layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
layout (r11f_g11f_b10f, binding = 0) readonly uniform image2D narrow;
layout (rgba32f, binding = 1) writeonly uniform image2D wide;
void main()
{
ivec2 coord = ivec2(gl_GlobalInvocationID.xy);
imageStore(wide, coord, imageLoad(narrow, coord));
}
)");
const GLuint storeProgram = MakeComputeProgram(R"(#version 430 core
layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
layout (r11f_g11f_b10f, binding = 0) writeonly uniform image2D narrow;
void main()
{
imageStore(narrow, ivec2(gl_GlobalInvocationID.xy), vec4(5.0, 6.0, 7.0, 8.0));
}
)");
if (loadProgram == 0 || storeProgram == 0) return;
// THE UPLOAD, read back through the image. A sheared decode still produces plausible
// floats, so the check is per texel and the seed never repeats a value.
BindImage(kNarrowUnit, narrow, GL_R11F_G11F_B10F, GL_READ_ONLY);
BindImage(kWideUnit, wide, GL_RGBA32F, GL_WRITE_ONLY);
Dispatch(loadProgram);
const std::vector<float> loaded = ReadFloats(wide, GL_RGBA, 4);
for (int texel = 0; texel < kTexels; ++texel) {
EXPECT_FLOAT_EQ(loaded[texel * 4 + 0], seed[texel * 3 + 0]) << "texel " << texel << " red";
EXPECT_FLOAT_EQ(loaded[texel * 4 + 1], seed[texel * 3 + 1]) << "texel " << texel << " green";
EXPECT_FLOAT_EQ(loaded[texel * 4 + 2], seed[texel * 3 + 2]) << "texel " << texel << " blue";
EXPECT_FLOAT_EQ(loaded[texel * 4 + 3], 1.0f)
<< "texel " << texel << ": imageLoad on a format without alpha must report 1";
}
// THE STORE. Three channels survive and the fourth is dropped, which is the mask this
// format needs and no other widened format does - every other carrier here pins two
// or three of the carrier's channels, this one pins only alpha.
BindImage(kNarrowUnit, narrow, GL_R11F_G11F_B10F, GL_WRITE_ONLY);
Dispatch(storeProgram);
const std::vector<float> stored = ReadFloats(narrow, GL_RGB, 3);
for (int texel = 0; texel < kTexels; ++texel) {
EXPECT_FLOAT_EQ(stored[texel * 3 + 0], 5.0f) << "texel " << texel << " red";
EXPECT_FLOAT_EQ(stored[texel * 3 + 1], 6.0f) << "texel " << texel << " green";
EXPECT_FLOAT_EQ(stored[texel * 3 + 2], 7.0f) << "texel " << texel << " blue";
}
}
// GL_R8UI: the only format KHR-GL43.shader_image_load_store.single-byte_data_alignment
// declares, and one SPIRV-Cross refuses to print for ESSL at all, so before the emulation
// no text was produced for the stage and the dispatch could not run.
@@ -64,6 +64,25 @@ void main() {
g_length[2] = g_input23[0].data.length();
g_length[3] = g_input23[1].data.length();
}
)";
// GL 4.6 core 4.10 lets a buffer variable be declared readonly AND writeonly at once:
// it can then be neither read nor written, and `.length()` is the only thing left that
// may be asked of it. The pair is inert - and printing it into ESSL is not, because
// SPIRV-Cross hoists the qualifiers every member shares onto the BLOCK and Mesa's ES
// compiler refuses that spelling ("Interface block sets both readonly and writeonly").
// Lifted from KHR-GL43.shader_storage_buffer_object.basic-readonly-writeonly.
constexpr const char* kReadonlyWriteonlyComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Input {
readonly writeonly int g_in[];
};
layout(std430, binding = 4) buffer Output {
int g_length[];
};
void main() {
g_length[0] = g_in.length();
}
)";
constexpr int kElementBytes = 16; // ivec4, std430
@@ -212,4 +231,33 @@ void main() {
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, input0);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, input3);
}
// A buffer variable qualified readonly AND writeonly can only be asked its length, and that
// question still has to be answered. A stage the driver refused answers 0 - and refuses
// silently, because the program links without it and the dispatch is then a no-op.
TEST_F(SsboArrayLengthScenario, AReadonlyWriteonlyArrayStillReportsItsLength) {
if (!Ready() || IsSkipped()) return;
const GLuint program = CompileComputeProgram(kReadonlyWriteonlyComputeSource);
ASSERT_NE(program, 0u) << m_buildLog;
const GLuint input = MakeStorageBuffer(6); // 6 ivec4 = 24 ints
const GLuint output = MakeStorageBuffer(1);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, input);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, output);
ASSERT_EQ(FirstGLError(), 0u);
glUseProgram(program);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
int length = -1;
glBindBuffer(GL_SHADER_STORAGE_BUFFER, output);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(length), &length);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(length, 24) << "a readonly+writeonly runtime array reported length " << length
<< "; 0 means the stage never reached the program";
glUseProgram(m_program);
glDeleteProgram(program);
}
} // namespace MGITest
@@ -30,15 +30,23 @@
// applies the flip to viewport 0 and forgets the other fifteen renders a correct-looking FBO and
// an upside-down window - the classic multi-viewport bug, and invisible to every FBO-only case.
//
// HONEST LIMIT OF THIS FILE. DirectGLES SKIPS every case: GLES has one viewport, one scissor
// rectangle and no gl_ViewportIndex, so routing to index > 0 is an emulation feature that has
// not been built (the Espryt half of KHR-GL43.viewport_array's rendering group is deliberately
// still red). The skip is explicit rather than silent so a future emulation lands here as a
// failing test and not as a test that was quietly never running. DirectVulkan additionally
// skips when the device lacks the multiViewport feature - Vulkan then forbids a pipeline from
// declaring more than one viewport at all, which is a device limit and not a MobileGL bug;
// lavapipe (every CI lane) and both Mali/Adreno devices support it, so the cases do run where
// it matters.
// BOTH BACKENDS RUN EVERY CASE, by two completely different routes, which is the point of
// keeping them in one file. DirectVulkan declares sixteen viewports on the pipeline and lets the
// hardware route. DirectGLES has one viewport, one scissor rectangle and one depth range and no
// gl_ViewportIndex at all, so it EMULATES: the builtin becomes a flat varying, the fragment stage
// gets a gate, and the draw is replayed once per distinct viewport state (Managers.h,
// ForEachViewportRoutingPass). Every assertion below is about pixels, so it cannot tell the two
// apart - which is exactly what has to be true.
//
// DirectVulkan skips when the device lacks the multiViewport feature - Vulkan then forbids a
// pipeline from declaring more than one viewport at all, which is a device limit and not a
// MobileGL bug; lavapipe (every CI lane) and both Mali/Adreno devices support it, so the cases do
// run where it matters.
//
// The last case is the negative control for the emulation and runs on DirectGLES only: it builds
// the SAME program with the emulation switched off and requires the routing to collapse onto
// viewport 0. Without it every assertion above could be satisfied by a backend that happened to
// be right for some other reason, and the emulation's own switch would be untested.
#include <cmath>
#include <string>
@@ -47,6 +55,10 @@
#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
@@ -142,13 +154,6 @@ void main() { fragColor = gl_FragCoord.z; }
ScenarioTest::SetUp();
if (!Ready()) return;
if (Gl().BackendName() == "DirectGLES") {
GTEST_SKIP() << "gl_ViewportIndex routing is not emulated on DirectGLES: GLES has one viewport "
"and one scissor rectangle, so every index rasterizes as index 0. The indexed "
"STATE is still asserted (MG_Test RenderStateTest); this is the deferred "
"rendering half of KHR-GL43.viewport_array.";
}
GLint maxViewports = 0;
glGetIntegerv(GL_MAX_VIEWPORTS, &maxViewports);
ASSERT_GE(maxViewports, kViewportCount) << "GL 4.3 core requires GL_MAX_VIEWPORTS >= 16";
@@ -520,11 +525,78 @@ void main() { fragColor = vec4(float(gsIndex) * 16.0 / 255.0, 0.0, 0.0, 1.0); }
DestroyIntTarget(target);
}
// --- 4. an explicitly EMPTY scissor box clips, it does not mean "never written" --------
// --- 4. the negative control for the DirectGLES emulation -----------------------------
//
// Deliberately NOT a ViewportArrayScenario case, because it must run on DirectGLES - the
// backend that got it wrong - and that fixture skips there. It needs none of the routing:
// one viewport, one scissor rectangle, no geometry stage.
// 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
// 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
// (and the failure signature KHR-GL43.viewport_array reported on it), pinned here so that
// (a) the three cases above are known to be testing the emulation and not the weather,
// and (b) the switch itself has a test.
//
// DirectGLES only: the flag steers nothing on DirectVulkan, which routes natively.
TEST_F(ViewportArrayScenario, WithoutTheEmulationEveryIndexCollapsesOntoViewportZero) {
if (Gl().BackendName() != "DirectGLES") {
GTEST_SKIP() << "the emulation switch is a DirectGLES concern; DirectVulkan routes "
"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;
};
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.
unroutedProgram = BuildProgram(kGridGeometrySource, kIntFragmentSource);
ASSERT_NE(unroutedProgram, 0u) << "unrouted program failed to build: " << m_buildLog;
glUseProgram(unroutedProgram);
glBindVertexArray(m_vao);
glDrawArrays(GL_POINTS, 0, 1);
ASSERT_EQ(glGetError(), GL_NO_ERROR);
}
const std::vector<GLint> pixels = ReadInts(kSurfaceSide, kSurfaceSide);
// Cell (0, 0) IS viewport 0's rectangle, so it is the one cell an unrouted draw paints
// with something. Everything it holds comes from the last geometry invocation.
EXPECT_EQ(CellCentre(pixels, kSurfaceSide, 0, 0), kViewportCount - 1)
<< "with the emulation off, viewport 0's rectangle must hold the LAST invocation's "
"index - if it holds 0 the routing is still happening and this control proves "
"nothing";
for (int y = 0; y < kGridSide; ++y) {
for (int x = 0; x < kGridSide; ++x) {
if (x == 0 && y == 0) continue;
EXPECT_EQ(CellCentre(pixels, kSurfaceSide, x, y), kUnwritten)
<< "cell (" << x << ", " << y << ") is outside viewport 0's rectangle and an "
<< "unrouted draw cannot reach it";
}
}
glUseProgram(0);
glDeleteProgram(unroutedProgram);
DestroyIntTarget(target);
}
// --- 5. an explicitly EMPTY scissor box clips, it does not mean "never written" --------
//
// Deliberately NOT a ViewportArrayScenario case, because that fixture's geometry stage
// routes and this claim needs none of it: one viewport, one scissor rectangle, no
// geometry stage - and it has to hold identically whether or not anything routes.
//
// glScissor(0, 0, 0, 0) is legal GL meaning "the scissor test rejects every fragment",
// but it is byte-identical to the all-zero rectangle a context starts with, whose meaning
@@ -32,6 +32,23 @@ target_link_libraries(
${LINK_LIBRARIES}
)
add_executable(
ViewportIndexRoutingTest
ViewportIndexRoutingTest.cpp
)
target_include_directories(ViewportIndexRoutingTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
ViewportIndexRoutingTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
include(GoogleTest)
gtest_discover_tests(EsslShaderPassTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(BaseInstanceInjectionTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(ViewportIndexRoutingTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -0,0 +1,179 @@
// MobileGL - MobileGL/MG_Test/Backend/DirectGLES/ViewportIndexRoutingTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// The ESSL half of the gl_ViewportIndex routing emulation (MG_Backend/DirectGLES/Managers.cpp).
// GLES has one viewport, one scissor rectangle and one depth range where GL 4.1 has sixteen of
// each selected per primitive, and the target device has no GL_OES_viewport_array to borrow, so
// DirectGLES turns the builtin into an ordinary flat varying and gives the fragment stage a gate
// the draw path replays against.
//
// Both passes are pure String -> String over what SPIRV-Cross emits once LowerViewportIndexPass
// has demoted the builtin, so no GL context and no driver: the shapes they have to survive - and
// the ones they must refuse - can be pinned here rather than only on a device. What they cannot
// pin is that the routing produces the right pixels; that is
// MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp, which runs the same claim through both
// backends.
#include <gtest/gtest.h>
#include <MG_Backend/DirectGLES/Managers.h>
using MobileGL::Bool;
using MobileGL::String;
using MobileGL::MG_Backend::DirectGLES::InjectViewportIndexPassGate;
using MobileGL::MG_Backend::DirectGLES::PromoteViewportIndexGlobalToVarying;
namespace {
Bool Contains(const String& haystack, const String& needle) {
return haystack.find(needle) != String::npos;
}
// What SPIRV-Cross hands the backend for a geometry stage after LowerViewportIndexPass has
// demoted gl_ViewportIndex: a plain file-scope global the shader still writes and which, until
// this pass runs, nothing anywhere reads.
constexpr const char* kLoweredGeometryShader = R"(#version 320 es
layout(invocations = 16, points) in;
layout(max_vertices = 4, triangle_strip) out;
layout(location = 0) flat out int gsIndex;
int mg_ViewportIndex;
void main()
{
gsIndex = gl_InvocationID;
mg_ViewportIndex = gl_InvocationID;
gl_Position = vec4(-1.0, -1.0, 0.0, 1.0);
EmitVertex();
EndPrimitive();
}
)";
constexpr const char* kFragmentShader = R"(#version 320 es
precision mediump float;
precision highp int;
layout(location = 0) flat in int gsIndex;
layout(location = 0) out highp vec4 fragColor;
void main()
{
fragColor = vec4(float(gsIndex));
}
)";
} // namespace
// The promotion itself. The declaration becomes an interface variable and the STORE is left
// exactly where it was - the pass must not touch the body, because the body is the application's.
TEST(ViewportIndexRoutingTest, TheDemotedGlobalBecomesAFlatVarying) {
String source = kLoweredGeometryShader;
ASSERT_TRUE(PromoteViewportIndexGlobalToVarying(source)) << source;
EXPECT_TRUE(Contains(source, "flat out highp int mg_ViewportIndex;")) << source;
EXPECT_FALSE(Contains(source, "\nint mg_ViewportIndex;")) << source;
EXPECT_TRUE(Contains(source, " mg_ViewportIndex = gl_InvocationID;")) << source;
}
// FLAT is the semantics and not a hint: GL takes a primitive's viewport index from its provoking
// vertex, and flat interpolation is what delivers that. An interpolated integer would not even
// compile in ESSL, so losing the qualifier fails loudly - but silently losing it to a `smooth`
// rewrite somewhere downstream would route by whichever vertex the rasterizer felt like.
TEST(ViewportIndexRoutingTest, ThePromotedVaryingIsFlatAndCarriesNoExplicitLocation) {
String source = kLoweredGeometryShader;
ASSERT_TRUE(PromoteViewportIndexGlobalToVarying(source));
const size_t declPos = source.find("flat out highp int mg_ViewportIndex;");
ASSERT_NE(declPos, String::npos) << source;
// No layout(location = N): the two stages are transpiled independently and cannot agree on a
// number, so the varying is matched by NAME. A location that appeared here would have to
// appear identically in the fragment stage, which nothing can guarantee.
const size_t lineStart = source.rfind('\n', declPos);
const String declLine = source.substr(lineStart + 1, declPos - lineStart - 1);
EXPECT_EQ(declLine, "") << "the declaration must start its own line, with no layout qualifier";
}
// A precision-qualified declaration is the same declaration. SPIRV-Cross prints one or the other
// depending on what the module carried, and a pass that only matched the bare form would leave
// half the drivers unrouted while reporting success.
TEST(ViewportIndexRoutingTest, APrecisionQualifiedDeclarationIsPromotedToo) {
String source = "#version 320 es\nhighp int mg_ViewportIndex;\nvoid main() { mg_ViewportIndex = 3; }\n";
ASSERT_TRUE(PromoteViewportIndexGlobalToVarying(source)) << source;
EXPECT_TRUE(Contains(source, "flat out highp int mg_ViewportIndex;")) << source;
}
// A stage that never routed must come out byte-identical, because every stage of every program on
// this backend goes through the pass.
TEST(ViewportIndexRoutingTest, AStageWithoutTheGlobalIsUntouched) {
const String before = kFragmentShader;
String source = before;
EXPECT_FALSE(PromoteViewportIndexGlobalToVarying(source));
EXPECT_EQ(source, before);
}
// The one shape that would silently break a shader: a name that ends in mg_ViewportIndex but is
// not the declaration. Only a declaration starting its own line may be rewritten.
TEST(ViewportIndexRoutingTest, ADeclarationThatIsNotAtLineStartIsRefused) {
const String before = "#version 320 es\nuniform highp int mg_ViewportIndex;\nvoid main() {}\n";
String source = before;
EXPECT_FALSE(PromoteViewportIndexGlobalToVarying(source));
EXPECT_EQ(source, before);
}
// The fragment gate. Three things have to be true at once: the varying and the uniform are
// declared, the application's entry point survives under a new name, and the new entry point
// discards on a mask miss and calls the old one otherwise.
TEST(ViewportIndexRoutingTest, TheFragmentGateWrapsTheEntryPoint) {
String source = kFragmentShader;
ASSERT_TRUE(InjectViewportIndexPassGate(source)) << source;
EXPECT_TRUE(Contains(source, "flat in highp int mg_ViewportIndex;")) << source;
EXPECT_TRUE(Contains(source, "uniform highp int mg_ViewportPassMask;")) << source;
EXPECT_TRUE(Contains(source, "void mg_ViewportGatedMain()")) << source;
EXPECT_TRUE(Contains(source, "discard;")) << source;
EXPECT_TRUE(Contains(source, "mg_ViewportGatedMain();")) << source;
// The application's body is not edited, only renamed.
EXPECT_TRUE(Contains(source, " fragColor = vec4(float(gsIndex));")) << source;
// Exactly one entry point remains, and it is the wrapper.
EXPECT_EQ(source.find("void main()"), source.rfind("void main()")) << source;
}
// The shift operand has to be clamped. GL leaves a gl_ViewportIndex outside [0, MAX_VIEWPORTS)
// undefined and the emulation is free to pick anything, but an ESSL shift by >= 32 is undefined
// in a way that can take the whole draw with it - so the gate must not be able to reach one.
TEST(ViewportIndexRoutingTest, TheGateClampsTheShiftIntoRange) {
String source = kFragmentShader;
ASSERT_TRUE(InjectViewportIndexPassGate(source));
EXPECT_TRUE(Contains(source, "mg_ViewportPassMask >> (mg_ViewportIndex & 15)")) << source;
}
// A fragment stage that READS gl_ViewportIndex has no ESSL spelling for it either, and the
// routing varying is exactly the value it wanted. This is the only place the read can be repaired
// - LowerViewportIndexPass deliberately demotes outputs only, because a demoted input would
// answer from an undefined global.
TEST(ViewportIndexRoutingTest, AFragmentStageReadOfTheBuiltinIsRedirectedOntoTheVarying) {
String source = R"(#version 320 es
precision highp int;
layout(location = 0) out highp vec4 fragColor;
void main()
{
fragColor = vec4(float(gl_ViewportIndex));
}
)";
ASSERT_TRUE(InjectViewportIndexPassGate(source)) << source;
EXPECT_FALSE(Contains(source, "gl_ViewportIndex")) << source;
EXPECT_TRUE(Contains(source, "fragColor = vec4(float(mg_ViewportIndex));")) << source;
}
// A stage the pass declines must reach the driver exactly as it arrived, not half-rewritten.
// The caller logs the decline and the program still renders - unrouted, which is the old
// behaviour - so a partially edited source here would turn a degradation into a broken shader.
TEST(ViewportIndexRoutingTest, AStageWithNoEntryPointIsDeclinedWithoutBeingEdited) {
const String before = "#version 320 es\nprecision highp int;\nhighp int f() { return gl_ViewportIndex; }\n";
String source = before;
EXPECT_FALSE(InjectViewportIndexPassGate(source));
EXPECT_EQ(source, before);
}
@@ -8,6 +8,7 @@ add_executable(
FixIterationRPSubgroupScratchTest.cpp
EmulateSubgroupsTest.cpp
DemoteFloat64Test.cpp
FlattenFloat64StorageBlockTest.cpp
FlattenXfbInterfaceBlocksTest.cpp
UniquifyIoBlockNamesTest.cpp
LowerViewportIndexTest.cpp
@@ -0,0 +1,305 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/FlattenFloat64StorageBlockTest.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
//
// FlattenFloat64StorageBlockPass, over the module the production chain actually hands it:
// ShaderCompiler::SanitizeAndOptimizeBinary, where the pass sits immediately before the fp64
// demotion. The behavioural half - that a block copied through the flattened words comes back
// byte for byte - is DoublePrecisionScenario's; what only a module walk can say is WHICH blocks
// were flattened, how wide, and that the ones this pass must not touch came through unchanged.
#include <gtest/gtest.h>
#include <cstring>
#include <map>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <spirv-tools/libspirv.hpp>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
namespace {
// A test-side reference walker, deliberately independent of the production code: a bug in
// the pass must not be able to hide behind the same helper.
constexpr Uint32 kSpirvHeaderWordCount = 5;
constexpr Uint32 kOpName = 5;
constexpr Uint32 kOpDecorate = 71;
constexpr Uint32 kOpMemberDecorate = 72;
constexpr Uint32 kOpTypeInt = 21;
constexpr Uint32 kOpTypeFloat = 22;
constexpr Uint32 kOpTypeArray = 28;
constexpr Uint32 kOpTypeStruct = 30;
constexpr Uint32 kOpConstant = 43;
constexpr Uint32 kDecorationArrayStride = 6;
constexpr Uint32 kDecorationOffset = 35;
template <typename Visitor>
void ForEachInstruction(const Vector<Uint32>& spirv, Visitor&& visit) {
for (SizeT i = kSpirvHeaderWordCount; i < spirv.size();) {
const Uint32 wordCount = spirv[i] >> 16;
const Uint32 opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
visit(opcode, &spirv[i], wordCount);
i += wordCount;
}
}
Uint32 StructIdNamed(const Vector<Uint32>& spirv, const String& name) {
Uint32 structId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpName || wordCount < 3 || structId != 0) return;
const char* text = reinterpret_cast<const char*>(&words[2]);
const SizeT available = static_cast<SizeT>(wordCount - 2) * sizeof(Uint32);
// The whole name, not a prefix of it: "Wide" must not match "WideOther".
if (available <= name.size() || text[name.size()] != 0) return;
if (std::strncmp(text, name.c_str(), name.size()) == 0) structId = words[1];
});
return structId;
}
// The operands of OpTypeStruct <structId>, i.e. one type id per member.
Vector<Uint32> MemberTypesOf(const Vector<Uint32>& spirv, Uint32 structId) {
Vector<Uint32> members;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpTypeStruct || wordCount < 2 || words[1] != structId) return;
for (Uint32 i = 2; i < wordCount; ++i) members.push_back(words[i]);
});
return members;
}
Vector<Uint32> MemberOffsetsOf(const Vector<Uint32>& spirv, Uint32 structId) {
std::map<Uint32, Uint32> byMember;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpMemberDecorate || wordCount < 5 || words[1] != structId) return;
if (words[3] != kDecorationOffset) return;
byMember[words[2]] = words[4];
});
Vector<Uint32> offsets;
for (const auto& [member, offset] : byMember) offsets.push_back(offset);
return offsets;
}
Uint32 DecorationValueOf(const Vector<Uint32>& spirv, Uint32 id, Uint32 decoration) {
Uint32 value = 0xFFFFFFFFu;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpDecorate || wordCount < 4 || words[1] != id || words[2] != decoration) return;
value = words[3];
});
return value;
}
// (element type id, declared length) of OpTypeArray <arrayId>, or (0, 0).
std::pair<Uint32, Uint32> ArrayShapeOf(const Vector<Uint32>& spirv, Uint32 arrayId) {
Uint32 elementTypeId = 0;
Uint32 lengthConstantId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpTypeArray || wordCount < 4 || words[1] != arrayId) return;
elementTypeId = words[2];
lengthConstantId = words[3];
});
if (elementTypeId == 0) return {0, 0};
Uint32 length = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpConstant || wordCount < 4 || words[2] != lengthConstantId) return;
length = words[3];
});
return {elementTypeId, length};
}
Bool IsUint32Type(const Vector<Uint32>& spirv, Uint32 typeId) {
Bool isUint = false;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpTypeInt || wordCount < 4 || words[1] != typeId) return;
isUint = words[2] == 32u && words[3] == 0u;
});
return isUint;
}
Uint32 CountFloatTypesOfWidth(const Vector<Uint32>& spirv, Uint32 width) {
Uint32 count = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode == kOpTypeFloat && wordCount >= 3 && words[2] == width) ++count;
});
return count;
}
String Disassemble(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String text;
tools.Disassemble(spirv, &text);
return text;
}
Vector<Uint32> CompileToSpirv(GLenum stage, const String& source) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log);
if (!shaderResult) return {};
ProgramAttrib programAttrib{.shaders = {shaderResult.value()}};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log);
if (!programResult) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {stage}, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult || binaryResult->empty()) return {};
return binaryResult->front();
}
// The whole shared chain, exactly as the frontend runs it at link.
Vector<Uint32> Sanitize(const Vector<Uint32>& input) {
Vector<Uint32> output;
EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output, true, true));
return output;
}
// The block std140 lays out as data0@0, data1[3]@16 stride 16, data2@64 column stride 16,
// data3@112, data4[2]@128 stride 16, data5@160, data6@192 - 216 bytes, i.e. 54 words.
constexpr const char* kStd140BlockSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std140, binding = 0) buffer Wide {
int data0;
float data1[3];
mat3x2 data2;
double data3;
double data4[2];
int data5;
dvec3 data6;
} g_wide;
void main() {
g_wide.data0 = 1;
for (int i = 0; i < 3; ++i) g_wide.data1[i] = float(i);
g_wide.data2 = mat3x2(1.0);
g_wide.data3 = 2.0lf;
for (int i = 0; i < 2; ++i) g_wide.data4[i] = double(i);
g_wide.data5 = 3;
g_wide.data6 = dvec3(4.0lf);
}
)";
} // namespace
class FlattenFloat64StorageBlockTest : public ::testing::Test {
protected:
void SetUp() override {
MobileGL::Initialize();
m_validationFailuresAtStart = ShaderCompiler::SpirvValidationFailureCount();
}
void TearDown() override {
// The wrapper validates its output on every run, so this covers every rewrite the test
// performed without any of them having to say so.
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), m_validationFailuresAtStart)
<< "the flattened module did not survive spirv-val";
}
Uint64 m_validationFailuresAtStart = 0;
};
TEST_F(FlattenFloat64StorageBlockTest, AStorageBlockWithDoublesBecomesOneWordArray) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, kStd140BlockSource);
ASSERT_FALSE(input.empty());
// Before: seven members, at the std140 offsets the standard requires WITH the doubles.
const Uint32 inputStructId = StructIdNamed(input, "Wide");
ASSERT_NE(inputStructId, 0u) << Disassemble(input);
EXPECT_EQ(MemberOffsetsOf(input, inputStructId),
(Vector<Uint32>{0, 16, 64, 112, 128, 160, 192}))
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const Uint32 structId = StructIdNamed(output, "Wide");
ASSERT_NE(structId, 0u) << Disassemble(output);
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << "the block should have collapsed to one member\n"
<< Disassemble(output);
EXPECT_EQ(MemberOffsetsOf(output, structId), (Vector<Uint32>{0}));
const auto [elementTypeId, length] = ArrayShapeOf(output, members[0]);
ASSERT_NE(elementTypeId, 0u) << "member 0 is not an array\n" << Disassemble(output);
EXPECT_TRUE(IsUint32Type(output, elementTypeId)) << Disassemble(output);
// 216 bytes is where the standard puts the end of this block; 216 / 4 = 54 words.
EXPECT_EQ(length, 54u) << Disassemble(output);
EXPECT_EQ(DecorationValueOf(output, members[0], kDecorationArrayStride), 4u);
// And the demotion that runs straight afterwards still has nothing 64-bit left to find.
EXPECT_EQ(CountFloatTypesOfWidth(output, 64), 0u) << Disassemble(output);
}
// The gate, from the other side: a storage block with no 64-bit member keeps every member and
// every offset it was compiled with. This is what makes the pass free for every shader that does
// not use doubles - which is all of them but a handful.
TEST_F(FlattenFloat64StorageBlockTest, AStorageBlockWithoutDoublesIsLeftAlone) {
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std140, binding = 0) buffer Plain {
int data0;
float data1[3];
mat3x2 data2;
int data3;
} g_plain;
void main() {
g_plain.data0 = 1;
for (int i = 0; i < 3; ++i) g_plain.data1[i] = float(i);
g_plain.data2 = mat3x2(1.0);
g_plain.data3 = 2;
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const Uint32 structId = StructIdNamed(output, "Plain");
ASSERT_NE(structId, 0u) << Disassemble(output);
EXPECT_EQ(MemberTypesOf(output, structId).size(), 4u) << Disassemble(output);
EXPECT_EQ(MemberOffsetsOf(output, structId), (Vector<Uint32>{0, 16, 64, 112}))
<< Disassemble(output);
}
// A plain UNIFORM block is deliberately NOT flattened, however many doubles it holds: the
// frontend's glUniform*d routing is built by reflecting the DEMOTED module
// (ProgramSpirvTask::BuildGlobalUboRouting), so a representation change there would have to move
// with it. It keeps its members and takes the demotion's repacking, exactly as before.
TEST_F(FlattenFloat64StorageBlockTest, AUniformBlockWithDoublesIsLeftToTheDemotion) {
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std140, binding = 0) uniform Params {
int data0;
double data1;
int data2;
} g_params;
layout(std430, binding = 0) buffer Sink {
float g_out[];
};
void main() {
g_out[0] = float(g_params.data0) + float(g_params.data1) + float(g_params.data2);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const Uint32 structId = StructIdNamed(output, "Params");
ASSERT_NE(structId, 0u) << Disassemble(output);
EXPECT_EQ(MemberTypesOf(output, structId).size(), 3u)
<< "a uniform block must not be flattened\n"
<< Disassemble(output);
// The demotion's re-derived std140 layout for `int, float, int`, which is what the frontend
// reflects and what glUniform*d then writes into.
EXPECT_EQ(MemberOffsetsOf(output, structId), (Vector<Uint32>{0, 4, 8})) << Disassemble(output);
}
@@ -135,7 +135,7 @@ void main() {
EsslTranslationKeyInputs inputs;
inputs.spirv = &spirv;
inputs.shaderType = GL_FRAGMENT_SHADER;
inputs.supportsViewportArray = false;
inputs.viewportIndexLoweringArmed = false;
inputs.supportsNoperspectiveInterpolation = false;
inputs.maxColorTextureSamples = 4;
inputs.maxIntegerSamples = 1;
@@ -855,8 +855,8 @@ TEST_F(TranslationCacheTest, L2KeyMovesWithEveryGateThatSteersTheEsslChain) {
}
{ // arms LowerViewportIndexForEssl
EsslTranslationKeyInputs v = base;
v.supportsViewportArray = true;
variants.emplace_back("supportsViewportArray", BuildEsslTranslationKey(v));
v.viewportIndexLoweringArmed = true;
variants.emplace_back("viewportIndexLoweringArmed", BuildEsslTranslationKey(v));
}
{ // arms EmulateNoPerspectiveForEssl
EsslTranslationKeyInputs v = base;
@@ -1008,7 +1008,7 @@ TEST_F(TranslationCacheTest, L2RunsTheEmitterOncePerDistinctKey) {
// ... and a gate that only steers the SPIR-V pass chain still moves the key, so the
// emitter runs again even though this stand-in ignores the bit.
inputs = BaselineEsslInputs(spirv);
inputs.supportsViewportArray = true;
inputs.viewportIndexLoweringArmed = true;
(void)translate(inputs);
EXPECT_EQ(emitCount, 3);
}
@@ -14,10 +14,10 @@
// and every draw with the program silently renders nothing while GL_LINK_STATUS still says TRUE.
//
// What has to hold is the emulation's exactness, in three parts at once: the DECLARED format must
// become the core carrier of the same per-channel width, every imageStore through it must have its
// surplus components replaced by GL's own (0.., 1) so the carrier's extra channels never hold
// anything GL has not defined, and every imageLoad must come back masked the same way. A module
// that declares only core formats - or one of the nine formats with no exact carrier - must come
// become a core carrier that loses nothing, every imageStore through it must have its surplus
// components replaced by GL's own (0.., 1) so the carrier's extra channels never hold anything GL
// has not defined, and every imageLoad must come back masked the same way. A module that declares
// only core formats - or one of the eight formats with no lossless carrier at all - must come
// out untouched, because widening those would be an approximation rather than an emulation. Real
// GLSL through the same glslang path the backends use, for the same reason
// ClampMultisampleFetchTest.cpp does it: what matters is what glslang actually emits.
@@ -229,9 +229,35 @@ void main() {
}
)";
// rg16 is one of the NINE with no core carrier of the same per-channel width. Widening it
// would change the quantisation an application sees, so it must be left alone and keep the
// honest "no GLSL ES spelling" diagnostic instead.
// r11f_g11f_b10f: THREE float channels in a packed 32-bit word, and the only format the four
// CTS allFormats/allTargets walkers still aborted on after the channel widening landed - it
// has no core carrier of the same per-channel width, so it took rgba16f, whose 5-bit exponent
// and longer mantissa represent every 11f and 10f value exactly.
const char* const kR11fG11fB10fLoadStore = R"(#version 430 core
layout(r11f_g11f_b10f, binding = 0) uniform image2D img;
out vec4 fragColor;
void main() {
vec4 texel = imageLoad(img, ivec2(gl_FragCoord.xy));
imageStore(img, ivec2(gl_FragCoord.xy), vec4(1.0, 2.0, 3.0, 4.0));
fragColor = texel;
}
)";
// rg32f again, but as a BUFFER image. Same format, same carrier on paper - and it must be
// left alone anyway, because a buffer image's texels are the application's buffer object.
const char* const kRg32fBufferLoadStore = R"(#version 430 core
layout(rg32f, binding = 0) uniform imageBuffer img;
out vec4 fragColor;
void main() {
vec4 texel = imageLoad(img, int(gl_FragCoord.x));
imageStore(img, int(gl_FragCoord.x), vec4(1.0, 2.0, 3.0, 4.0));
fragColor = texel;
}
)";
// rg16 is one of the EIGHT with no core carrier at all - core ESSL has no 16-bit normalized
// format, so every candidate loses range or changes the component type the texture presents.
// It must be left alone and keep the honest "no GLSL ES spelling" diagnostic instead.
const char* const kRg16LoadStore = R"(#version 430 core
layout(rg16, binding = 0) uniform image2D img;
out vec4 fragColor;
@@ -247,7 +273,7 @@ void main() {
// the shader rewrite, the ES texture storage and the glBindImageTexture argument. If it drifts
// the three stop agreeing, and a narrow texture read through a wide image goes out of bounds
// silently on every driver tested.
TEST(WidenImageFormats, SeventeenNonCoreFormatsHaveAnExactSameWidthCarrier) {
TEST(WidenImageFormats, EighteenNonCoreFormatsHaveALosslessCoreCarrier) {
struct Case {
Uint requested;
Uint carrier;
@@ -272,6 +298,10 @@ TEST(WidenImageFormats, SeventeenNonCoreFormatsHaveAnExactSameWidthCarrier) {
{0x8234, 0x8D76, 1, "GL_R16UI -> GL_RGBA16UI"},
{0x8238, 0x8D7C, 2, "GL_RG8UI -> GL_RGBA8UI"},
{0x8232, 0x8D7C, 1, "GL_R8UI -> GL_RGBA8UI"},
// The one entry that is a re-encoding rather than a channel widening: 11f is e5m6 and 10f
// is e5m5 against a half's s1e5m10, so the carrier is still lossless - and three channels,
// so the mask has to pin only alpha.
{0x8C3A, 0x881A, 3, "GL_R11F_G11F_B10F -> GL_RGBA16F"},
};
for (const Case& testCase : cases) {
EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(testCase.requested), testCase.carrier)
@@ -287,7 +317,7 @@ TEST(WidenImageFormats, SeventeenNonCoreFormatsHaveAnExactSameWidthCarrier) {
}
}
TEST(WidenImageFormats, CoreFormatsAndTheNineWithoutAnExactCarrierAreRefused) {
TEST(WidenImageFormats, CoreFormatsAndTheEightWithoutALosslessCarrierAreRefused) {
// The thirteen GLSL ES already has: nothing to carry.
for (const Uint coreFormat : {0x8814u /*RGBA32F*/, 0x881Au /*RGBA16F*/, 0x822Eu /*R32F*/,
0x8058u /*RGBA8*/, 0x8F97u /*RGBA8_SNORM*/, 0x8D82u /*RGBA32I*/,
@@ -297,10 +327,12 @@ TEST(WidenImageFormats, CoreFormatsAndTheNineWithoutAnExactCarrierAreRefused) {
EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(coreFormat), 0u)
<< "core format 0x" << std::hex << coreFormat;
}
// The nine with no core format of the same per-channel width. Carrying these would be an
// approximation - a different quantisation, or a different numeric domain for anything that
// samples the same texture - so they are deliberately left to the honest diagnostic.
for (const Uint hardFormat : {0x8C3Au /*R11F_G11F_B10F*/, 0x8059u /*RGB10_A2*/,
// The eight with no LOSSLESS core carrier: core ESSL has no 16-bit normalized format and no
// 10-bit one, so every candidate for these either loses range or changes the component type
// the texture presents to anything that samples it. Deliberately left to the honest
// diagnostic. r11f_g11f_b10f is NOT among them - rgba16f holds every value it can, so it is
// carried above.
for (const Uint hardFormat : {0x8059u /*RGB10_A2*/,
0x906Fu /*RGB10_A2UI*/, 0x805Bu /*RGBA16*/, 0x822Cu /*RG16*/,
0x822Au /*R16*/, 0x8F9Bu /*RGBA16_SNORM*/, 0x8F99u /*RG16_SNORM*/,
0x8F98u /*R16_SNORM*/}) {
@@ -357,6 +389,101 @@ TEST(WidenImageFormats, TwoChannelFloatImageBecomesRgba32fWithBothAccessesMasked
<< "the mask must be a separate value, or it would feed itself";
}
// The three-channel case, which no format exercised before r11f_g11f_b10f was carried: only ALPHA
// is surplus, so the mask must take r, g and b from the texel and nothing but the fourth component
// from the (0, 0, 0, 1) constant. A mask that zeroed blue here - the shape a two-channel format
// wants - would silently drop the third channel of every store.
TEST(WidenImageFormats, ThreeChannelPackedFloatImageBecomesRgba16fWithOnlyAlphaPinned) {
const Vector<Uint32> spirv = CompileFragment(kR11fG11fB10fLoadStore);
ASSERT_FALSE(spirv.empty());
ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv));
const auto beforeTypes = CollectStorageImageTypes(spirv);
ASSERT_EQ(beforeTypes.size(), 1u);
EXPECT_EQ(beforeTypes.front().format, static_cast<Uint32>(spv::ImageFormat::R11fG11fB10f));
Vector<Uint32> widened;
ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/false,
/*enableSpirvValidation=*/true));
ASSERT_FALSE(widened.empty());
EXPECT_TRUE(Validates(widened));
EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(widened));
const auto afterTypes = CollectStorageImageTypes(widened);
ASSERT_EQ(afterTypes.size(), 1u);
EXPECT_EQ(afterTypes.front().format, static_cast<Uint32>(spv::ImageFormat::Rgba16f));
const auto shuffles = CollectVectorShuffles(widened);
const auto texelIds = CollectImageWriteTexelIds(widened);
ASSERT_EQ(texelIds.size(), 1u);
const VectorShuffle* storeMask = FindShuffleWithResult(shuffles, texelIds.front());
ASSERT_NE(storeMask, nullptr) << "the imageStore texel is not a masked value";
EXPECT_TRUE(HasComponents(*storeMask, {0u, 1u, 2u, 7u}))
<< "expected (r, g, b, 1) - components 0, 1 and 2 of the texel, then 3 of (0,0,0,1)";
const auto readIds = CollectImageReadResultIds(widened);
ASSERT_EQ(readIds.size(), 1u);
const VectorShuffle* loadMask = FindShuffleOver(shuffles, readIds.front());
ASSERT_NE(loadMask, nullptr) << "the imageLoad result is consumed unmasked";
EXPECT_TRUE(HasComponents(*loadMask, {0u, 1u, 2u, 7u}));
}
// ...and the same module through the emitter, which is where the failure actually showed: ESSL has
// no `r11f_g11f_b10f` token, SPIRV-Cross throws for it, and the throw took every image uniform
// declared in the same stage with it.
TEST(WidenImageFormats, PackedFloatImageOnlyReachesEsslThroughTheCarrier) {
const Vector<Uint32> spirv = CompileFragment(kR11fG11fB10fLoadStore);
ASSERT_FALSE(spirv.empty());
const EsslAttempt before = EmitEssl(spirv);
EXPECT_FALSE(before.succeeded)
<< "SPIRV-Cross printed r11f_g11f_b10f for an ES target; the widening's premise has "
"changed:\n"
<< before.text;
Vector<Uint32> widened;
ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/false,
/*enableSpirvValidation=*/true));
const EsslAttempt after = EmitEssl(widened);
ASSERT_TRUE(after.succeeded) << after.error;
EXPECT_NE(after.text.find("rgba16f"), String::npos) << after.text;
EXPECT_EQ(after.text.find("r11f_g11f_b10f"), String::npos) << after.text;
}
// A BUFFER image is declined whatever its format, and the format alone cannot say so - rg32f is
// carried exactly when it is an image2D. What makes the difference is that widening REALLOCATES
// the texture behind the image in the carrier, and a buffer image has no texture storage to
// reallocate: its texels are the application's buffer object, usually also a vertex, index or
// storage buffer. Widening one leaves the shader striding 16 bytes through 8-byte texels - the
// measured symptom on an Adreno 830 was a 32-byte GL_RG32F buffer reading back
// [1,100] [0,1] [2,100] [0,1] instead of [1,100] [2,100] [3,100] [4,100], with the last two texels
// written past the end of the application's buffer.
TEST(WidenImageFormats, BufferImagesAreDeclinedEvenWhenTheirFormatHasACarrier) {
const Vector<Uint32> spirv = CompileFragment(kRg32fBufferLoadStore);
ASSERT_FALSE(spirv.empty());
const auto types = CollectStorageImageTypes(spirv);
ASSERT_EQ(types.size(), 1u);
EXPECT_EQ(types.front().format, static_cast<Uint32>(spv::ImageFormat::Rg32f))
<< "the fixture stopped declaring the format this test is about";
// The gate says no, so the optimizer is never even run for it...
EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(spirv));
// ...and running it anyway changes nothing, which is what keeps the gate and the pass from
// disagreeing about a module.
Vector<Uint32> widened;
ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/false,
/*enableSpirvValidation=*/true);
EXPECT_TRUE(widened.empty() || widened == spirv) << "a buffer image was rewritten";
// The same format in a NON-buffer image still widens, or this test would pass for the wrong
// reason - a widening that had simply stopped working.
const Vector<Uint32> planar = CompileFragment(kRg32fLoadStore);
ASSERT_FALSE(planar.empty());
EXPECT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(planar));
}
TEST(WidenImageFormats, SingleChannelUnsignedImageBecomesRgba8uiWithBothAccessesMasked) {
const Vector<Uint32> spirv = CompileFragment(kR8uiLoadStore);
ASSERT_FALSE(spirv.empty());
@@ -19,6 +19,7 @@
#include "SpirvPasses/DecomposeWorkgroupVec3Pass.h"
#include "SpirvPasses/DecoratePositionInvariantPass.h"
#include "SpirvPasses/DemoteFloat64Pass.h"
#include "SpirvPasses/FlattenFloat64StorageBlockPass.h"
#include "SpirvPasses/LowerDrawParametersPass.h"
#include "SpirvPasses/LowerViewportIndexPass.h"
#include "SpirvPasses/PackDoubleVertexInputsPass.h"
@@ -797,6 +798,17 @@ namespace MobileGL {
// in particular it runs before the backends' PackDoubleVertexInputsPass, whose
// OpBitcast this one would otherwise decline on. Costs one types_values() walk on
// the overwhelming majority of modules, which declare no 64-bit float at all.
// ...but demoting a double that lives in a SHADER STORAGE BLOCK also repacks that
// block, and the bytes an application put in the buffer do not move with it. This
// runs first and takes those blocks out of the demotion's hands: each becomes a
// flat `uint` array whose index arithmetic carries the std140/std430 offsets
// glslang computed WITH the doubles in place, so the layout survives byte for byte
// and only the VALUES narrow. Gated on a block actually holding a 64-bit float, so
// every other module pays one types_values() walk and nothing else, and it declines
// (leaving the block for the demotion to handle the old way) on any shape it cannot
// re-address exactly. See FlattenFloat64StorageBlockPass.h.
optimizer.RegisterPass(
FlattenFloat64StorageBlockPass::CreateFlattenFloat64StorageBlockPass());
optimizer.RegisterPass(DemoteFloat64Pass::CreateDemoteFloat64Pass());
return RunOptimizerChecked("SanitizeAndOptimizeBinary", optimizer, inputBinary,
@@ -50,12 +50,10 @@ namespace MobileGL {
// for the same reason - writes exactly where the demoted shader reads. Blocks with no
// 64-bit member anywhere are never touched.
//
// THE MEASURED COST, so the next wave does not re-diagnose it. Four GL 4.3 conformance
// cases fail on BOTH backends and on both an Adreno 830 and a Mali G925 - i.e. on every
// device, because no device has shaderFloat64 and the demotion therefore always runs:
// WHAT RE-DERIVING STILL COSTS, so the next wave does not re-diagnose it. Two GL 4.3
// conformance cases fail on BOTH backends and on every device, because no device has
// shaderFloat64 and the demotion therefore always runs:
//
// KHR-GL43.shader_storage_buffer_object.basic-stdLayout-case3-cs
// KHR-GL43.shader_storage_buffer_object.basic-stdLayout-case3-vs
// KHR-GL43.compute_shader.fp64-case1
// KHR-GL43.compute_shader.fp64-case3
//
@@ -63,44 +61,28 @@ namespace MobileGL {
// against it: it is blocked on GLSL subroutines ("FP64 support - subroutines"), which
// glslang deletes when targeting SPIR-V, and is out of scope by standing instruction.
//
// The other three fail in the two ways this comment predicts and in no other.
// stdLayout-case3 copies a block byte for byte: the output matches the input for
// bytes [0, 76) and is zero from there on, which is exactly the block's size once
// every double became a float and the layout repacked tightly. Re-derived byte-exactly
// in 2026-08: the block is `int data0; float data1[5]; mat3x2 data2; double data3;
// double data4[2]; int data5; dvec3 data6`, and demoting every double to float and
// repacking std430 gives data0@0, data1@4..23, data2@24..47, data3@48, data4@52..59,
// data5@60, data6@64..75 - 76 bytes. EVERY mismatching byte the QPA reports is >= 76
// and every expected-non-zero byte below 76 matched, on both the std140 output and the
// std430 one.
//
// ONE TRAP FOR THE NEXT READER, because it reads as evidence AGAINST demotion and is
// not: in the std430 output the doubles below the boundary appear to have round-tripped
// BIT-EXACTLY, which looks like fp64 surviving. It is an artifact. The shader reads and
// writes through the SAME demoted offset, so those four bytes are copied verbatim
// whatever they are interpreted as - the copy proves nothing about the width.
//
// fp64-case1 reports ceil(2.2) as 2: the uniform's double 2.0 is 0x4000000000000000,
// the demoted read takes its low 32 bits (0.0), ceil(0.0 + 0.2) = 1.0f = 0x3F800000
// lands in the low half of the 8-byte output slot and the whole thing prints as 2.
// Index 0 of the same case PASSES by accident, for the same reason - writing 0.0f into
// the low half of 1.0 leaves it unchanged - so a partial pass here is not progress.
// Fixing it means carrying a double in the DEFAULT UNIFORM block without re-deriving
// its layout, and that block's routing is built by reflecting the module this pass
// produces, so the representation change ripples into every glUniform*d. Deliberately
// not attempted. compute_shader.fp64-case2 passes today and any attempt has to keep it
// green.
//
// Both backends produce a CHARACTER-FOR-CHARACTER identical QPA byte list, which is
// the cheapest available proof that the defect is in this shared pass and in neither
// backend. A future wave that wants to re-open this should start by re-checking that
// identity rather than by re-deriving the layout.
//
// Fixing them means NOT demoting a double that lives in a buffer block, and carrying
// it as a uvec2 word pair instead - preserving the application's byte layout exactly,
// unpacking to fp32 for arithmetic and repacking on store. That is a large pass with
// the same dmat problem the paragraph above describes (a uvec2 representation cannot
// express a matrix stride either, so it would have to decline dmat types), and the
// default-uniform routing above reflects the demoted module, so a representation
// change there ripples into every glUniform*d. THREE actionable cases of 16085 (the
// fourth, fp64-case3, is subroutine-blocked and unreachable from here); deliberately
// not attempted, and re-confirmed as not worth attempting in the 2026-08 wave.
// compute_shader.fp64-case2 passes today and any attempt has to keep it green.
// SHADER STORAGE BLOCKS ARE NO LONGER IN THAT LIST, and the two cases that used to be
// (shader_storage_buffer_object.basic-stdLayout-case3-cs and -vs, which copy a block
// byte for byte and used to come back zero from the first double's slot onwards) pass
// on both backends. FlattenFloat64StorageBlockPass runs immediately before this one
// and takes every storage block holding a 64-bit float out of its hands, rewriting the
// block into a flat `uint` array whose index arithmetic carries the offsets glslang
// computed WITH the doubles in place. A flat array has no layout for SPIRV-Cross to
// re-derive, which is what makes it expressible where a padded struct is not, and an
// offset in an address computation has none of the dmat trouble the paragraph above
// describes. See that pass's header. Everything below still describes what happens to
// every OTHER block, and to the doubles in the function bodies of all of them.
//
// Declines (leaves the module byte-identical, so the caller's existing "this module
// still declares Float64" failure path reports it) when the module contains an
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenFloat64StorageBlockPass.h
// 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
#pragma once
#include "source/opt/pass.h"
#include "spirv-tools/optimizer.hpp"
#include <Includes.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Rewrites a SHADER STORAGE BLOCK that contains a 64-bit float into a flat
// `uint` word array, and turns every access to it into address arithmetic over
// that array. The application's byte layout survives exactly; the VALUES are
// still narrowed to 32-bit floats, because that is all any target here has.
//
// WHY THIS EXISTS. DemoteFloat64Pass rewrites `double` to `float` in place and
// lets SPIRV-Cross re-derive the block's packing from the declared types, because
// GLSL ES has no member `layout(offset=)` and SPIRV-Cross refuses any block whose
// stated offsets it cannot express as std140 or std430. That re-derivation moves
// every member past the first double: the block a shader reads and writes stops
// being the block the application filled. Byte-for-byte, on the shape
// KHR-GL43.shader_storage_buffer_object.basic-stdLayout-case3 uses, the output
// matched the input up to the first double's slot and was zero from there on -
// the demoted block is simply shorter than the one that was bound.
//
// A flat `uint[]` has no layout to re-derive: one member, offset 0, ArrayStride 4,
// which IS std430, so SPIRV-Cross prints it unconditionally and the driver lays it
// out the only way it can. Every member's real byte offset - the std140 or std430
// one glslang computed WITH the doubles in place - then lives in the index
// arithmetic this pass emits, not in the declaration. The two ways the earlier
// attempt at this was blocked both disappear with it:
//
// * dmat: a `uvec2`-per-double representation cannot express a MatrixStride, so
// it would have had to decline matrices of doubles. Here a stride is a number
// in an address computation and nothing else, so dmat needs no special case.
// * the default-uniform block: its routing is built by reflecting the DEMOTED
// module (ProgramSpirvTask::BuildGlobalUboRouting), so changing how a double
// is carried there would ripple into every glUniform*d. This pass touches
// StorageBuffer blocks only and never that one.
//
// WHAT GL SEES IS UNCHANGED, and becomes CORRECT rather than merely unchanged:
// glGetProgramResourceiv answers from glslang's reflection of the pre-demotion
// module (ProgramInterface.cpp reads TObjectReflection::offset), i.e. the true
// fp64 offsets. Before this pass those offsets described a layout no shader used;
// now they describe the one it does.
//
// PRECISION, stated plainly. A double still becomes a float: the load narrows the
// stored binary64 to binary32 and the store widens it back, so a value that does
// not survive a round trip through 32 bits does not survive this either. The
// narrowing truncates the discarded mantissa bits rather than rounding to nearest,
// and flushes what binary32 can only hold as a subnormal to a signed zero; NaN
// stays NaN and an out-of-range magnitude becomes an infinity. That is the same
// fp32 promise DemoteFloat64Pass already makes - what changes is only that the
// BYTES around the value stay where the application put them.
//
// DECLINES, leaving the block exactly as it was for DemoteFloat64Pass to handle the
// old way, whenever it meets something it cannot rewrite exactly:
// - a block whose variable is used as anything but an access-chain base (loaded
// whole, handed to a function, asked its OpArrayLength);
// - an access chain that is not rooted at the variable, or whose result feeds
// anything but a plain OpLoad / OpStore (an atomic, OpCopyMemory, a further
// chain);
// - a non-constant index into a struct, a runtime array anywhere in the block, a
// RowMajor matrix (its columns are not contiguous, so a whole-column access is
// not one range), a member width other than 32 or 64 bits, or an offset or
// stride that is not a multiple of 4;
// - a load or store whose type decomposes into more scalars than the cap below,
// so legalizing a block can never explode the module.
//
// ORDERING: must run BEFORE DemoteFloat64Pass, which is what turns the doubles this
// pass leaves in the function body into floats - the OpFConvert pairs emitted here
// are width-preserving by then and collapse to their operands. It emits only 32-bit
// OpBitcasts, so it never trips that pass's "bitcast across the 64-bit boundary"
// decline.
class FlattenFloat64StorageBlockPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-flatten-float64-storage-block"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateFlattenFloat64StorageBlockPass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -39,6 +39,7 @@ namespace MobileGL {
// OpTypeImage in-operands: 0 sampled type, 1 Dim, 2 Depth, 3 Arrayed, 4 MS,
// 5 Sampled, 6 Format.
constexpr uint32_t kImageSampledTypeOperand = 0;
constexpr uint32_t kImageDimOperand = 1;
constexpr uint32_t kImageSampledOperand = 5;
constexpr uint32_t kImageFormatOperand = 6;
// A storage image, i.e. one reached through imageLoad/imageStore rather than a
@@ -50,16 +51,40 @@ namespace MobileGL {
constexpr uint32_t kImageAccessImageOperand = 0;
constexpr uint32_t kImageWriteTexelOperand = 2;
// The exact carrier of a non-core image format: the core GLSL ES format with the
// SAME component type and the SAME per-channel width, differing only in channel
// count. `channels` is what the original format really has, which is what every
// access through the carrier is masked back to.
// The carrier of a non-core image format: a core GLSL ES format that represents
// every value the original can hold, WITHOUT LOSS. `channels` is what the original
// format really has, which is what every access through the carrier is masked back
// to.
//
// Only formats that widen EXACTLY appear here. r11f_g11f_b10f, rgb10_a2,
// rgb10_a2ui, rgba16, rg16, r16, rgba16_snorm, rg16_snorm and r16_snorm have no
// same-width core carrier - every candidate is either lossy or changes the numeric
// domain a sampler would read - and are deliberately absent, so they keep the
// honest "no GLSL ES spelling" diagnostic rather than a silent approximation.
// Almost every entry is a pure CHANNEL widening - same component type, same
// per-channel width, more channels (rg32f -> rgba32f) - and for those the carrier
// is bit-exact: the storage holds the identical encoding, only wider.
//
// r11f_g11f_b10f is the one entry that is not. It has no same-width core carrier,
// so it takes rgba16f, and the two encodings differ. What matters is that the
// carrier is still LOSSLESS: an 11-bit float is e5m6 and a 10-bit float is e5m5,
// while a half is s1e5m10 - the SAME 5-bit exponent with a strictly longer
// mantissa - so every value the packed format can represent has an exact half.
// Nothing an application stores is rounded away.
//
// What DOES change is the reverse direction: the carrier can hold values the
// packed format could not - negatives (11f and 10f are unsigned), and mantissa
// bits finer than the 6 and 5 the format quantises to - so a value written through
// the image and then SAMPLED comes back on half's grid rather than the packed
// format's. That is a strictly finer grid, never a lossy one, and it is measured
// against the alternative, which is not a more faithful quantisation but no
// program at all: `layout(r11f_g11f_b10f)` has no ESSL spelling, SPIRV-Cross
// throws for it, and the stage - with every other image uniform declared beside it
// - is lost (KHR-GL43.shader_image_load_store.basic-allFormats-*, which fail on
// this format alone, and multiple-uniforms, where one such declaration killed a
// program holding eight images).
//
// The remaining eight - rgb10_a2, rgb10_a2ui, rgba16, rg16, r16, rgba16_snorm,
// rg16_snorm and r16_snorm - stay absent, and for a stronger reason than
// quantisation: core ESSL has no 16-bit normalized format at all and no 10-bit
// one, so every candidate carrier for them either loses range or changes the
// component TYPE the texture presents. They keep the honest "no GLSL ES spelling"
// diagnostic rather than a silent approximation.
struct ImageFormatWidening {
spv::ImageFormat Carrier = spv::ImageFormat::Unknown;
uint32_t Channels = 0;
@@ -73,6 +98,9 @@ namespace MobileGL {
case spv::ImageFormat::Rg32f: return {spv::ImageFormat::Rgba32f, 2};
case spv::ImageFormat::Rg16f: return {spv::ImageFormat::Rgba16f, 2};
case spv::ImageFormat::R16f: return {spv::ImageFormat::Rgba16f, 1};
// Not a channel widening but a lossless re-encoding - see above. Three
// channels, so the fourth reads as the 1 GL defines for a format without one.
case spv::ImageFormat::R11fG11fB10f: return {spv::ImageFormat::Rgba16f, 3};
// Unsigned normalized.
case spv::ImageFormat::Rg8: return {spv::ImageFormat::Rgba8, 2};
case spv::ImageFormat::R8: return {spv::ImageFormat::Rgba8, 1};
@@ -219,6 +247,24 @@ namespace MobileGL {
bool onlyFormatsSpirvCrossRefusesToPrint) {
if (type == nullptr || type->opcode() != spv::Op::OpTypeImage) return false;
if (type->GetSingleWordInOperand(kImageSampledOperand) != kSampledStorageImage) return false;
// A BUFFER image is never widened, whatever its format. Widening works because
// the ES texture behind the image can be REALLOCATED in the carrier, so the
// texel the shader addresses and the texel the storage holds stay the same
// size. A buffer image has no storage of its own to reallocate: its texels are
// the application's buffer object, at the size and layout the application gave
// it, and that buffer is usually also a vertex, index or storage buffer whose
// contents are not ours to relayout.
//
// Widening one anyway makes the shader stride 16 bytes through 8-byte texels.
// Measured on an Adreno 830 with a 32-byte GL_RG32F buffer and a shader storing
// (i+1, 100) at texel i: the readback came back [1,100] [0,1] [2,100] [0,1] -
// texels 0 and 1 landed on top of all four, texels 2 and 3 ran off the end of
// the application's buffer. Declining leaves the honest "no GLSL ES spelling"
// failure instead, which loses the same stage but corrupts nothing.
if (static_cast<spv::Dim>(type->GetSingleWordInOperand(kImageDimOperand)) ==
spv::Dim::Buffer) {
return false;
}
const auto format =
static_cast<spv::ImageFormat>(type->GetSingleWordInOperand(kImageFormatOperand));
if (!WideningOfSpirvImageFormat(format)) return false;
@@ -54,12 +54,23 @@ namespace MobileGL {
// alone survives storage this shader never wrote (glTexStorage with no upload, whose
// surplus channels are undefined).
//
// The other NINE (r11f_g11f_b10f, rgb10_a2, rgb10_a2ui, rgba16, rg16, r16,
// rgba16_snorm, rg16_snorm, r16_snorm) have NO same-width core carrier and are
// deliberately NOT widened here: every carrier for them is either lossy or changes the
// numeric domain of the texture a `sampler2D` would read from it. They keep the honest
// "no GLSL ES spelling" diagnostic instead of silently changing an application's
// quantisation behaviour.
// r11f_g11f_b10f has no same-width core carrier either, and takes rgba16f anyway,
// because that carrier is still LOSSLESS: 11f is e5m6 and 10f is e5m5 against a half's
// s1e5m10 - the SAME 5-bit exponent with a strictly longer mantissa - so every value
// the packed format can hold has an exact half. Only the reverse direction differs
// (the carrier also holds negatives, which 11f and 10f cannot sign, and mantissa bits
// finer than the 6 and 5 they quantise to, so a value written through the image and
// then SAMPLED lands on half's grid rather than the packed format's). That is measured
// against the alternative, which is not a truer quantisation but no program at all:
// the SPIRV-Cross throw takes the whole stage, every image uniform declared beside it
// included.
//
// The other EIGHT (rgb10_a2, rgb10_a2ui, rgba16, rg16, r16, rgba16_snorm, rg16_snorm,
// r16_snorm) are deliberately NOT widened here: core ESSL has no 16-bit normalized
// format at all and no 10-bit one, so every carrier for them either loses range or
// changes the component TYPE the texture a `sampler2D` would read presents. They keep
// the honest "no GLSL ES spelling" diagnostic instead of silently changing an
// application's numeric domain.
//
// MUST MOVE WITH THE OTHER TWO LAYERS. The widening is not a shader-local rewrite: the
// ES texture behind the image has to be allocated in the carrier format too, and
@@ -442,6 +442,60 @@ namespace MobileGL {
SPVC_CHK_RETURN
}
spvc_result SpvcSession::DropDefaultFragmentOutputColorIndex() {
if (!(usage & SessionUsageBit::Transpile)) return SPVC_ERROR_INVALID_ARGUMENT;
SPVC_CHK_INIT
const spvc_reflected_resource* list = nullptr;
size_t count = 0;
SPVC_CHK_RESULT(spvc_resources_get_resource_list_for_type(
resources, SPVC_RESOURCE_TYPE_STAGE_OUTPUT, &list, &count));
for (size_t i = 0; i < count; ++i) {
const spvc_reflected_resource& resource = list[i];
if (!spvc_compiler_has_decoration(compiler, resource.id, SpvDecorationIndex)) continue;
if (spvc_compiler_get_decoration(compiler, resource.id, SpvDecorationIndex) != 0u) continue;
spvc_compiler_unset_decoration(compiler, resource.id, SpvDecorationIndex);
}
SPVC_CHK_RETURN
}
spvc_result SpvcSession::RelaxReadWriteExclusiveStorageBuffers() {
if (!(usage & SessionUsageBit::Transpile)) return SPVC_ERROR_INVALID_ARGUMENT;
SPVC_CHK_INIT
const spvc_reflected_resource* list = nullptr;
size_t count = 0;
SPVC_CHK_RESULT(spvc_resources_get_resource_list_for_type(
resources, SPVC_RESOURCE_TYPE_STORAGE_BUFFER, &list, &count));
for (size_t i = 0; i < count; ++i) {
const spvc_reflected_resource& resource = list[i];
// The variable itself, for a block the application qualified as a whole.
if (spvc_compiler_has_decoration(compiler, resource.id, SpvDecorationNonReadable) &&
spvc_compiler_has_decoration(compiler, resource.id, SpvDecorationNonWritable)) {
spvc_compiler_unset_decoration(compiler, resource.id, SpvDecorationNonReadable);
spvc_compiler_unset_decoration(compiler, resource.id, SpvDecorationNonWritable);
}
// ...and each member, which is where the qualifiers usually sit and where
// SPIRV-Cross reads them from before hoisting the ones every member shares.
const spvc_type blockType = spvc_compiler_get_type_handle(compiler, resource.base_type_id);
if (blockType == nullptr) continue;
const unsigned memberCount = spvc_type_get_num_member_types(blockType);
for (unsigned member = 0; member < memberCount; ++member) {
if (!spvc_compiler_has_member_decoration(compiler, resource.base_type_id, member,
SpvDecorationNonReadable) ||
!spvc_compiler_has_member_decoration(compiler, resource.base_type_id, member,
SpvDecorationNonWritable)) {
continue;
}
spvc_compiler_unset_member_decoration(compiler, resource.base_type_id, member,
SpvDecorationNonReadable);
spvc_compiler_unset_member_decoration(compiler, resource.base_type_id, member,
SpvDecorationNonWritable);
}
}
SPVC_CHK_RETURN
}
spvc_result SpvcSession::Compile(const char** result) {
if (!(usage & SessionUsageBit::Transpile)) return SPVC_ERROR_INVALID_ARGUMENT;
SPVC_CHK_INIT
@@ -120,6 +120,46 @@ namespace MobileGL {
// `outGlBindings` is appended to, so one vector can collect a whole program's
// stages; it may repeat a binding declared by several of them.
spvc_result SetAtomicCounterBlockBindings(Int topBinding, Vector<Int>& outGlBindings);
// Drops the Index decoration from every fragment output that carries the DEFAULT
// colour index 0, so the emitted ESSL does not print `index = 0`.
//
// Index 0 is what every single-source fragment output already is, in GL and in
// ESSL alike, and SPIR-V carries the decoration only because the application
// spelled the qualifier out - `layout(location = 0, index = 0) out vec4 c;` is
// legal desktop GLSL and says nothing. Printing it back into ESSL is NOT
// harmless: GLSL ES has no `index` layout qualifier in core, so the driver
// answers "index layout qualifier requires EXT_blend_func_extended" and refuses
// the stage. The program then links nothing and every draw with it renders
// NOTHING - verified on Mesa 26.1.4 llvmpipe with no MobileGL in the process,
// and it is why KHR-GL43.shader_atomic_counters.basic-program-query read back a
// black render target.
//
// A NON-zero index is left exactly as it is: that one really does select the
// second dual-source input and cannot be expressed without the extension, so it
// must keep reaching the driver (the frontend's own glBindFragDataLocationIndexed
// path already emits only non-zero indices for the same reason).
spvc_result DropDefaultFragmentOutputColorIndex();
// Drops `readonly` and `writeonly` from every shader storage block - and every
// block member - that carries BOTH of them.
//
// GL 4.6 core 4.10 lets a buffer variable be declared readonly AND writeonly at
// once: it then cannot be read or written at all, and the only thing left that
// it can be used for is `.length()`. The pair is therefore inert by
// construction - the frontend has already rejected any access to it - so
// dropping it cannot change what the shader does.
//
// Emitting it does change whether the shader EXISTS. SPIRV-Cross hoists the
// qualifiers every member shares onto the block, and Mesa's ES compiler rejects
// that spelling outright ("Interface block sets both readonly and writeonly",
// verified on Mesa 26.1.4 llvmpipe with no MobileGL in the process, against the
// exact source this transpiler emitted). The stage then never compiles, the
// program links without it, and every dispatch or draw is a silent no-op -
// which is how KHR-GL43.shader_storage_buffer_object.basic-readonly-writeonly
// read back 0 instead of the array length.
//
// A block carrying only ONE of the two is left exactly as it is: those really do
// constrain the accesses the shader makes, and the driver is entitled to know.
spvc_result RelaxReadWriteExclusiveStorageBuffers();
spvc_result Compile(const char** result);
const SpvcMetadata& GetMetadata() const;
const char* GetLastErrorString() const;
@@ -169,7 +169,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
TranslationKeyBuilder builder;
AppendCommonKeyPrefix(builder, kEsslKeyTag);
builder.Value(static_cast<Uint32>(inputs.shaderType));
builder.Value(static_cast<Uint8>(inputs.supportsViewportArray));
builder.Value(static_cast<Uint8>(inputs.viewportIndexLoweringArmed));
builder.Value(static_cast<Uint8>(inputs.supportsNoperspectiveInterpolation));
builder.Value(static_cast<Uint8>(inputs.supportsExtendedImageFormats));
builder.Value(inputs.maxColorTextureSamples);
@@ -556,7 +556,8 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// * the GL stage enum - three passes are stage-gated (draw parameters and
// array vertex inputs on vertex, fragment-output index legalization on
// fragment);
// * SupportsViewportArray - arms LowerViewportIndexForEssl;
// * the viewport-index lowering arming bit - GL_OES_viewport_array's absence OR the
// routing emulation being on - which arms LowerViewportIndexForEssl;
// * the four sample ceilings (color / integer / depth / advertised) - both
// ARM ClampMultisampleFetchesForEssl and PARAMETERIZE it;
// * SupportsNoperspectiveInterpolation - arms EmulateNoPerspectiveForEssl;
@@ -583,7 +584,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// armed by nothing, so the SPIR-V already in this key covers them completely.
//
// THE TEST FOR THAT CLAIM IS NOT THE SIGNATURE. LowerViewportIndexForEssl is equally
// module-only to look at, yet SupportsViewportArray is in this key because that bit ARMS
// module-only to look at, yet the arming bit is in this key because it ARMS
// it at the call site. So a new pass needs BOTH checks - what it takes, and what decides
// whether it runs - before "no key material" is a conclusion rather than an assumption.
// Note also where an application-authored value can hide: the atomic-counter
@@ -612,7 +613,12 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
GLenum shaderType = 0;
// --- driver capability bits that arm or steer a pass ---
Bool supportsViewportArray = false;
// Whether LowerViewportIndexForEssl runs on this module. NOT the raw
// GL_OES_viewport_array bit any more: the routing emulation arms the pass even where the
// extension exists (Config.h, ViewportArrayEmulation), so the extension alone no longer
// decides, and a key carrying only it would serve a module lowered under one setting to a
// link made under the other.
Bool viewportIndexLoweringArmed = false;
Bool supportsNoperspectiveInterpolation = false;
// GL_NV_image_formats. Arms WidenImageFormatsForEssl, which re-declares every storage
// image whose format GLSL ES core cannot spell in the core format that carries it and