[Fix] (MG_Backend/DirectGLES): rebase gl_InstanceID for native indirect draws on ANGLE

ES keeps gl_InstanceID zero-based and ignores the indirect command's
'reserved, must be zero' word, but ANGLE-on-Vulkan forwards the command
verbatim to vkCmdDraw*Indirect and compiles gl_InstanceID to SPIR-V
InstanceIndex, which includes firstInstance. Shaders computing
gl_BaseInstance + gl_InstanceID (Flywheel indirect) then add the base
twice, scrambling instance-to-mesh association.

Probe the actual driver semantics at capability-fill time with a tiny
indirect draw (an ES indirect draw needs a non-default VAO) and, on
leaking drivers, rewrite vertex shaders that use the native indirect
SSBO machinery so gl_InstanceID subtracts the command's baseInstance
word during native indirect draws.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 12:14:02 +00:00
co-authored by Claude Fable 5
parent a6a5edf573
commit 85b68a9969
4 changed files with 183 additions and 3 deletions
@@ -1078,8 +1078,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
// shader emulation. Without GL_EXT_base_instance a non-zero baseInstance in the command is
// technically undefined in ES; mobile drivers ignore the reserved word, instanced-array
// fetches were never baseInstance-offset here anyway, and the CPU fallback cannot see
// GPU-written command fields at all - so native is never worse. Only client-memory
// commands take the CPU per-command loop.
// GPU-written command fields at all - so native is never worse. ANGLE-on-Vulkan instead
// leaks the word into gl_InstanceID (it becomes vkCmdDrawIndexedIndirect's firstInstance);
// the shader rewrite compensates by rebasing gl_InstanceID during these draws when
// IndirectDrawInstanceIdIncludesBaseInstance is set (PromoteDrawParameterGlobalsToUniforms).
// Only client-memory commands take the CPU per-command loop.
static void ExecuteIndexedIndirectCommands(GLenum mode, GLenum type, SizeT indexSize, const Uint8* commandBytes,
SizeT commandOffset,
const SharedPtr<MG_State::GLState::BufferObject>& drawIndirectBuffer,
+18 -1
View File
@@ -38,6 +38,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
constexpr const char* BASE_INSTANCE_LOWERED_NAME = "mg_BaseInstanceLowered";
constexpr const char* BASE_INSTANCE_WORD_INDEX_UNIFORM_NAME = "mg_BaseInstanceWordIndex";
constexpr const char* INDIRECT_PARAMS_BLOCK_NAME = "mg_IndirectParams";
constexpr const char* ZERO_BASED_INSTANCE_ID_NAME = "mg_ZeroBasedInstanceID";
static Bool IsAngleLlvmpipeRenderer() {
return g_GLESCapabilities.GLESRendererString.find("ANGLE") != String::npos &&
@@ -157,10 +158,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
for (const char* declPrefix : {"highp int ", "mediump int ", "lowp int ", "int "}) {
const String declaration = String(declPrefix) + BASE_INSTANCE_LOWERED_NAME + ";";
const SizeT pos = source.find(declaration);
SizeT pos = source.find(declaration);
if (pos == String::npos) {
continue;
}
// On drivers where native indirect draws leak the command's baseInstance into
// gl_InstanceID (ANGLE-on-Vulkan; IndirectDrawInstanceIdIncludesBaseInstance),
// rebase gl_InstanceID back to zero during those draws so shaders computing
// gl_BaseInstance + gl_InstanceID don't add the base twice. Scoped to shaders
// using gl_BaseInstance: only they take the native indirect SSBO machinery.
const Bool rebaseInstanceId = g_GLESCapabilities.IndirectDrawInstanceIdIncludesBaseInstance &&
source.find("gl_InstanceID") != String::npos;
if (rebaseInstanceId) {
source = ReplaceIdentifier(source, "gl_InstanceID", ZERO_BASED_INSTANCE_ID_NAME);
pos = source.find(declaration); // the declaration contains no gl_InstanceID
}
const Int paramsBinding = g_GLESCapabilities.MaxShaderStorageBufferBindings > 0
? g_GLESCapabilities.MaxShaderStorageBufferBindings - 1
: 0;
@@ -172,6 +184,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
machinery += String("layout(std430, binding = ") + std::to_string(paramsBinding) +
") readonly buffer " + INDIRECT_PARAMS_BLOCK_NAME +
" { highp uint mg_indirectWords[]; };\n";
if (rebaseInstanceId) {
machinery += String("#define ") + ZERO_BASED_INSTANCE_ID_NAME + " (gl_InstanceID - ((" +
BASE_INSTANCE_WORD_INDEX_UNIFORM_NAME + " >= 0) ? int(mg_indirectWords[uint(" +
BASE_INSTANCE_WORD_INDEX_UNIFORM_NAME + ")]) : 0))\n";
}
machinery += String("#define ") + BASE_INSTANCE_LOWERED_NAME + " ((" +
BASE_INSTANCE_WORD_INDEX_UNIFORM_NAME + " >= 0) ? int(mg_indirectWords[uint(" +
BASE_INSTANCE_WORD_INDEX_UNIFORM_NAME + ")]) : " + BASE_INSTANCE_UNIFORM_NAME + ")";
@@ -543,6 +543,155 @@ namespace MobileGL::MG_Util::BackendLoader {
}
}
// Detects whether indirect draws leak the command's baseInstance word ("reserved, must
// be zero" in unextended ES) into gl_InstanceID. Conforming ES drivers keep
// gl_InstanceID zero-based, but ANGLE's Vulkan backend forwards the command verbatim to
// vkCmdDraw*Indirect and compiles gl_InstanceID to SPIR-V InstanceIndex, which includes
// firstInstance. The DirectGLES native indirect-draw path uses this answer to keep
// gl_InstanceID zero-based in rewritten shaders (PromoteDrawParameterGlobalsToUniforms).
static Bool ProbeIndirectInstanceIdIncludesBaseInstance(const MG_External::GLESCapabilities& caps,
const MG_External::GLESFunctionsTable& f) {
const Bool esVersionOk =
caps.GLESVersion.Major > 3 || (caps.GLESVersion.Major == 3 && caps.GLESVersion.Minor >= 1);
if (!esVersionOk || !f.glDrawArraysIndirect || !f.glBindBufferBase || !f.glMapBufferRange ||
!f.glUnmapBuffer || !f.glMemoryBarrier || !f.glCreateShader || !f.glCreateProgram) {
return false;
}
GLint maxVertexSsboBlocks = 0;
f.glGetIntegerv(GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS, &maxVertexSsboBlocks);
if (maxVertexSsboBlocks < 1) {
// The native indirect machinery cannot read the command buffer from the vertex
// stage on this driver anyway; assume conforming zero-based gl_InstanceID.
MGLOG_I("baseInstance probe skipped: GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS = %d", maxVertexSsboBlocks);
return false;
}
while (f.glGetError() != GL_NO_ERROR) {
}
const char* vsSource = "#version 310 es\n"
"layout(std430, binding = 0) buffer MgProbeResult { highp int mg_probeValue; };\n"
"void main() {\n"
" mg_probeValue = gl_InstanceID;\n"
" gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n"
" gl_PointSize = 1.0;\n"
"}\n";
const char* fsSource = "#version 310 es\n"
"void main() {}\n";
const auto compileShader = [&f](GLenum type, const char* src) -> GLuint {
const GLuint shader = f.glCreateShader(type);
if (shader == 0) {
return 0;
}
f.glShaderSource(shader, 1, &src, nullptr);
f.glCompileShader(shader);
GLint status = GL_FALSE;
f.glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status != GL_TRUE) {
f.glDeleteShader(shader);
return 0;
}
return shader;
};
const GLuint vs = compileShader(GL_VERTEX_SHADER, vsSource);
const GLuint fs = compileShader(GL_FRAGMENT_SHADER, fsSource);
GLuint program = 0;
if (vs != 0 && fs != 0) {
program = f.glCreateProgram();
if (program != 0) {
f.glAttachShader(program, vs);
f.glAttachShader(program, fs);
f.glLinkProgram(program);
GLint status = GL_FALSE;
f.glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status != GL_TRUE) {
f.glDeleteProgram(program);
program = 0;
}
}
}
if (vs != 0) f.glDeleteShader(vs);
if (fs != 0) f.glDeleteShader(fs);
if (program == 0) {
MGLOG_I("baseInstance probe skipped: probe program failed to build (vs=%u fs=%u)", vs, fs);
while (f.glGetError() != GL_NO_ERROR) {
}
return false;
}
constexpr GLuint kProbeBaseInstance = 7;
struct {
GLuint count;
GLuint instanceCount;
GLuint first;
GLuint baseInstance;
} command = {1, 1, 0, kProbeBaseInstance};
const GLint sentinel = -1;
// ES makes indirect draws INVALID_OPERATION on the default vertex array object.
GLuint vao = 0;
f.glGenVertexArrays(1, &vao);
f.glBindVertexArray(vao);
GLuint buffers[2] = {0, 0}; // [0] = result SSBO, [1] = indirect command buffer
f.glGenBuffers(2, buffers);
f.glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffers[0]);
f.glBufferData(GL_SHADER_STORAGE_BUFFER, sizeof(sentinel), &sentinel, GL_STATIC_DRAW);
f.glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffers[0]);
f.glBindBuffer(GL_DRAW_INDIRECT_BUFFER, buffers[1]);
f.glBufferData(GL_DRAW_INDIRECT_BUFFER, sizeof(command), &command, GL_STATIC_DRAW);
// The default framebuffer may be incomplete (e.g. surfaceless contexts) and draws
// are validated against completeness even under GL_RASTERIZER_DISCARD, so give the
// probe its own 1x1 target.
GLuint framebuffer = 0;
GLuint renderbuffer = 0;
f.glGenFramebuffers(1, &framebuffer);
f.glGenRenderbuffers(1, &renderbuffer);
f.glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer);
f.glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 1, 1);
f.glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
f.glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer);
f.glUseProgram(program);
f.glEnable(GL_RASTERIZER_DISCARD);
f.glDrawArraysIndirect(GL_POINTS, nullptr);
f.glDisable(GL_RASTERIZER_DISCARD);
f.glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
Bool includesBase = false;
const GLenum drawError = f.glGetError();
if (drawError == GL_NO_ERROR) {
f.glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffers[0]);
const void* mapped = f.glMapBufferRange(GL_SHADER_STORAGE_BUFFER, 0, sizeof(GLint), GL_MAP_READ_BIT);
if (mapped != nullptr) {
GLint written = -1;
std::memcpy(&written, mapped, sizeof(written));
f.glUnmapBuffer(GL_SHADER_STORAGE_BUFFER);
includesBase = written == static_cast<GLint>(kProbeBaseInstance);
MGLOG_I("baseInstance probe: shader observed gl_InstanceID = %d (baseInstance was %u)", written,
kProbeBaseInstance);
} else {
MGLOG_I("baseInstance probe inconclusive: result map failed");
}
} else {
MGLOG_I("baseInstance probe inconclusive: draw raised GL error 0x%x", drawError);
}
f.glUseProgram(0);
f.glBindFramebuffer(GL_FRAMEBUFFER, 0);
f.glBindRenderbuffer(GL_RENDERBUFFER, 0);
f.glDeleteFramebuffers(1, &framebuffer);
f.glDeleteRenderbuffers(1, &renderbuffer);
f.glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
f.glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
f.glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0);
f.glBindVertexArray(0);
f.glDeleteVertexArrays(1, &vao);
f.glDeleteBuffers(2, buffers);
f.glDeleteProgram(program);
while (f.glGetError() != GL_NO_ERROR) {
}
return includesBase;
}
Bool FillInGLESCapabilities(MG_External::GLESCapabilities& caps, const MG_External::GLESFunctionsTable& glesFuncs) {
if (!glesFuncs.glGetString || !glesFuncs.glGetIntegerv) {
MGLOG_E("Required GLES functions are not loaded, cannot query capabilities");
@@ -757,6 +906,11 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.ViewportBoundsRangeMax);
MGLOG_I(" GL_VIEWPORT_SUBPIXEL_BITS: %d", caps.ViewportSubpixelBits);
caps.IndirectDrawInstanceIdIncludesBaseInstance =
ProbeIndirectInstanceIdIncludesBaseInstance(caps, glesFuncs);
MGLOG_I(" Indirect draw gl_InstanceID includes baseInstance: %s",
caps.IndirectDrawInstanceIdIncludesBaseInstance ? "true" : "false");
return true;
}
} // namespace MobileGL::MG_Util::BackendLoader
@@ -1019,6 +1019,12 @@ namespace MobileGL {
Bool SupportsPersistentMapping = false;
Bool SupportsNorm16Texture = false;
Bool SupportsBaseInstance = false;
// True when indirect draws leak the command's baseInstance word ("reserved,
// must be zero" in unextended ES) into gl_InstanceID. Conforming ES drivers
// keep gl_InstanceID zero-based; ANGLE's Vulkan backend hands the command
// straight to vkCmdDraw*Indirect and compiles gl_InstanceID to SPIR-V
// InstanceIndex, which includes firstInstance.
Bool IndirectDrawInstanceIdIncludesBaseInstance = false;
Int UniformBufferOffsetAlignment = 256;
Float AliasedLineWidthRangeMin = 1.0f;
Float AliasedLineWidthRangeMax = 1.0f;