[Fix, Test] (DirectVulkan): keep the draw when a program declares a storage block the application left unbound

This commit is contained in:
2026-08-24 04:57:05 -04:00
parent 602da1d131
commit 4446c861be
5 changed files with 869 additions and 2 deletions
@@ -962,9 +962,31 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto& bindingPoint = MG_State::pGLContext->GetBufferBindingPoint(bufferTarget, frontendBinding);
const auto& bufferObject = bindingPoint.GetBoundObject();
if (bufferObject == nullptr) {
MGLOG_E_ONCE("ResolveStorageBufferDescriptor: no buffer bound at frontend binding %u for block '%s'",
// NOT an error, and above all not a reason to lose the draw. GL 4.6 core 7.8 lets a
// program declare a shader storage block the application never binds a buffer to:
// the block simply has no store, so a read is undefined and a write goes nowhere.
// Refusing here used to take the whole draw or dispatch with it (SetupDraw and
// DispatchCompute both skip on a false return), which is how AcceleratedRendering's
// GUI batch lost its backgrounds: its vertex-transform compute shader declares a
// `Meshes` block it only reads when a vertex comes from a cached server mesh, and a
// batch of plain GUI blits has no meshes and so binds nothing there. The dispatch
// never ran, the transformed vertex buffer stayed as it was, and every hotbar and
// container-screen background quad came out degenerate. A shared zero-filled
// placeholder puts something legal in the descriptor and lets the draw proceed.
const BufferSlice placeholder = m_bufferManager->AcquireUnboundStorageDescriptor();
if (!placeholder.IsValid()) {
MGLOG_E_ONCE("ResolveStorageBufferDescriptor: no buffer bound at frontend binding %u for block "
"'%s', and the placeholder descriptor could not be created",
frontendBinding, blockName.c_str());
return false;
}
MGLOG_D("ResolveStorageBufferDescriptor: frontend binding %u ('%s') is unbound; using the placeholder "
"descriptor",
frontendBinding, blockName.c_str());
return false;
outBufferInfo.buffer = placeholder.buffer;
outBufferInfo.offset = placeholder.offset;
outBufferInfo.range = placeholder.size;
return true;
}
// The shader may write this buffer, and those writes land in GPU memory behind the
@@ -16,6 +16,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT;
constexpr SizeT kLiveResourcePruneThreshold = 256;
// See VkBufferManager::AcquireUnboundStorageDescriptor. 256 bytes: comfortably past
// every minStorageBufferOffsetAlignment in the wild, and free.
constexpr VkDeviceSize kUnboundStorageDescriptorBytes = 256;
// A zero-copy persistent buffer is created once and never recreated (the app holds
// its mapped pointer), and may be bound to any role, so it carries every usage.
// TRANSFER_DST is added by CreateResidentStorage.
@@ -130,6 +134,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
m_transientUploadArena.Shutdown();
m_unboundStorageBuffer.Destroy();
DestroyAllDeferredReleases();
ReleaseAllLiveResources();
m_copyProvider = nullptr;
@@ -706,6 +711,37 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_deferredResourceReleases[frameIndex].clear();
}
BufferSlice VkBufferManager::AcquireUnboundStorageDescriptor() {
if (!m_unboundStorageBuffer.IsValid()) {
if (m_initInfo.allocator == nullptr) {
return {};
}
// Host-visible so the zero fill needs no command buffer: this can be reached from
// descriptor resolution, which runs inside an already-open recording and must not
// start a copy of its own. The size is a whole minStorageBufferOffsetAlignment-safe
// block rather than 4 bytes so that a shader which does read the block gets a
// plausible unsized-array length instead of one that rounds to zero.
const Bool created = m_unboundStorageBuffer.Create({
.allocator = m_initInfo.allocator,
.size = kUnboundStorageDescriptorBytes,
.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT,
.memoryUsage = VMA_MEMORY_USAGE_AUTO,
.allocationFlags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT |
VMA_ALLOCATION_CREATE_MAPPED_BIT,
.requiredFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
});
if (!created) {
MGLOG_E_ONCE("VkBufferManager::AcquireUnboundStorageDescriptor: placeholder creation failed");
m_unboundStorageBuffer.Destroy();
return {};
}
if (void* mapped = m_unboundStorageBuffer.GetMappedData()) {
Memset(mapped, 0, static_cast<SizeT>(kUnboundStorageDescriptorBytes));
}
}
return m_unboundStorageBuffer.GetSlice();
}
VkBufferUsageFlags VkBufferManager::GetVkBufferUsage(BufferKind kind) {
switch (kind) {
case BufferKind::Vertex:
@@ -120,6 +120,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool UploadTransient(BufferKind kind, Uint32 frameIndex, const void* data, VkDeviceSize size,
VkDeviceSize alignment, BufferSlice& outSlice);
// The descriptor a shader storage block gets when the program declares it and the
// application bound no buffer at its GL binding point. GL 4.6 core 7.8 makes that a
// legal state - the block simply has no store, so reads are undefined and writes go
// nowhere - whereas Vulkan has no such thing as an unwritten descriptor, so something
// real has to sit in the set or the whole draw/dispatch is lost. One zero-filled
// buffer, created once and shared by every unbound binding: bindings that are only
// declared (the case this exists for) never touch it, and one that is actually read
// sees zeros, which is inside GL's "undefined". robustBufferAccess bounds anything
// that indexes past it.
BufferSlice AcquireUnboundStorageDescriptor();
// Draw-time acquire for resident (device-storage) buffers: ensures the
// resource exists and is fully uploaded, marks it used this frame.
Bool AcquireResidentSlice(BufferKind kind, const SharedPtr<MG_State::GLState::BufferObject>& bufferObject,
@@ -180,6 +191,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkBufferManagerInitInfo m_initInfo{};
BufferArena m_transientUploadArena;
// See AcquireUnboundStorageDescriptor. Lazily created, never re-created, torn down
// with the manager.
VkBufferObject m_unboundStorageBuffer;
IBufferCopyCommandProvider* m_copyProvider = nullptr;
Vector<Vector<VkBufferObject>> m_deferredBufferReleases;
Vector<Vector<SharedPtr<VkBufferResource>>> m_deferredResourceReleases;
@@ -106,6 +106,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/SsboArrayDynamicIndexScenario.cpp
Scenarios/StorageBufferRegrowScenario.cpp
Scenarios/RelinkStageSetScenario.cpp
Scenarios/GuiBatchScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -0,0 +1,794 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/GuiBatchScenario.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - A GUI QUAD DRAWN THE WAY AcceleratedRendering DRAWS ONE.
//
// The mod replaces every GUI blit with a compute pass: the application writes unit-space
// vertices into a persistently mapped SSBO, a compute shader multiplies them by a shared
// transform into a second buffer, and that second buffer is then bound as GL_ARRAY_BUFFER of a
// DSA vertex array and drawn with glDrawElementsBaseVertex through vanilla's position_tex_color
// program. Every element of that is replayed here, one axis per test, so a failure names the
// element that killed the quad rather than "the GUI is broken".
//
// The axis that mattered is MeshesBlockLeftUnbound. The mod's vertex-transform compute shader
// declares SIX storage blocks, and the last of them - `Meshes`, the cache of pre-uploaded model
// geometry - is read only when a vertex says it comes from a cached mesh. A batch of plain GUI
// blits has no cached meshes, so the mod binds nothing at that point and the shader never reads
// it. GL 4.6 core 7.8 is explicit that this is legal: a storage block with no buffer at its
// binding point simply has no store.
//
// DirectVulkan used to refuse the whole descriptor set over it, and both SetupDraw and
// DispatchCompute skip their work on that refusal - so the transform dispatch never ran, the
// output vertex buffer kept whatever was in it, and every hotbar and container-screen background
// quad came out degenerate. Items were unaffected because item geometry DOES come from cached
// meshes, which is what made the bug look like "only the backgrounds disappear".
//
// The assertions are whole-region, not centre-pixel: a quad that survives with three stale
// vertices still paints its centre.
//
// Reproduces on DirectVulkan only. DirectGLES forwards the unbound binding to the GLES driver,
// which does what GL says, so it is the control - every test here must stay green on both.
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr const char* kTransformComputeSource = R"(#version 460 core
struct Vertex {
float x;
float y;
float z;
float u0;
float v0;
uint color;
};
struct VaryingData {
int offset;
int sharing;
int mesh;
int shouldCull;
};
struct SharingData {
mat4 transform;
mat3 normal;
};
layout(local_size_x = 128) in;
layout(binding=0, std430) restrict readonly buffer VerticesIn {
Vertex verticesIn[];
};
layout(binding=1, std430) restrict writeonly buffer VerticesOut {
Vertex verticesOut[];
};
layout(binding=2, std430) restrict readonly buffer Sharings {
SharingData sharings[];
};
layout(binding=3, std430) restrict readonly buffer VaryingsIn {
VaryingData varyingsIn[];
};
layout(binding=4, std430) restrict writeonly buffer VaryingsOut {
VaryingData varyingsOut[];
};
layout(binding=5, std430) restrict readonly buffer Meshes {
Vertex meshVertices[];
};
layout(location=0) uniform uint vertexCount;
layout(location=1) uniform uint vertexOffset;
layout(location=2) uniform uint varyingOffset;
void main() {
uint indexIn = gl_GlobalInvocationID.x;
uint vertexOut = indexIn + vertexOffset;
uint varyingOut = indexIn + varyingOffset;
if (indexIn >= vertexCount) {
return;
}
int offset = varyingsIn[indexIn] .offset;
uint reference = indexIn - offset;
int sharing = varyingsIn[reference] .sharing;
int mesh = varyingsIn[reference] .mesh;
mat4 transformMatrix;
if (sharing != -1) {
transformMatrix = sharings[sharing].transform;
} else {
transformMatrix = mat4(1.0);
}
Vertex vertexIn;
vec4 colorMesh;
if (mesh != -1) {
vertexIn = meshVertices[mesh + offset];
colorMesh = unpackUnorm4x8 (vertexIn.color);
} else {
vertexIn = verticesIn[indexIn];
colorMesh = vec4 (1.0);
}
vec4 colorIn = unpackUnorm4x8 (verticesIn[reference].color);
vec4 posOut = transformMatrix * vec4 (vertexIn.x, vertexIn.y, vertexIn.z, 1.0);
vec4 colorOut = colorMesh * colorIn;
verticesOut[vertexOut].x = posOut.x;
verticesOut[vertexOut].y = posOut.y;
verticesOut[vertexOut].z = posOut.z;
verticesOut[vertexOut].u0 = vertexIn.u0;
verticesOut[vertexOut].v0 = vertexIn.v0;
verticesOut[vertexOut].color = packUnorm4x8 (colorOut);
varyingsOut[varyingOut].offset = offset;
varyingsOut[varyingOut].shouldCull = varyingsIn[reference].shouldCull;
}
)";
// Vanilla position_tex_color, spelled the way MC ships it: #version 150, no explicit
// attribute locations (they come from glBindAttribLocation in format order) and the
// ProjMat/ModelViewMat pair the mod re-uploads through setDefaultUniforms.
constexpr const char* kBlitVertexSource = R"(#version 150
in vec3 Position;
in vec2 UV0;
in vec4 Color;
uniform mat4 ModelViewMat;
uniform mat4 ProjMat;
out vec2 texCoord0;
out vec4 vertexColor;
void main() {
gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
texCoord0 = UV0;
vertexColor = Color;
}
)";
constexpr const char* kBlitFragmentSource = R"(#version 150
uniform sampler2D Sampler0;
in vec2 texCoord0;
in vec4 vertexColor;
out vec4 fragColor;
void main() {
fragColor = texture(Sampler0, texCoord0) * vertexColor;
}
)";
constexpr int kFboSize = 64;
constexpr int kShaderStorageRestoreRange = 9;
constexpr int kAtomicCounterRestoreRange = 1;
constexpr int kBuilders = 2;
struct GuiVertex {
float x, y, z;
float u, v;
std::uint32_t color;
};
static_assert(sizeof(GuiVertex) == 24, "POSITION_TEX_COLOR is 24 bytes");
struct VaryingData {
std::int32_t offset;
std::int32_t sharing;
std::int32_t mesh;
std::int32_t shouldCull;
};
struct IndexedBinding {
GLint buffer = 0;
GLint start = 0;
GLint size = 0;
};
// Which parts of the mod's real frame this replay reproduces. Each test flips exactly
// one on top of the baseline so a failure names the element that killed the quad.
struct Fidelity {
bool shortIndices = false; // MC's AutoStorageIndexBuffer is USHORT at small counts
bool baseVertex = false; // ...and the second builder draws at a base vertex
bool twoBuilders = false; // two render types share one output buffer
bool blendAndDepth = false; // TRANSLUCENT_TRANSPARENCY + LEQUAL_DEPTH_TEST
bool regrow = false; // MutableBuffer.doExpand replaces the GL name
bool rewriteMapEachFrame = false;
bool skipRelayout = false; // bindDrawBuffers() only re-lays-out when resized
bool leaveMeshesUnbound = false; // a batch with no server meshes never binds binding 5
};
class GuiBatchScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_transform = CompileComputeProgram(kTransformComputeSource);
ASSERT_NE(m_transform, 0u) << m_buildLog;
m_blit = CompileBlitProgram();
ASSERT_NE(m_blit, 0u) << m_buildLog;
m_target = MakeColorFbo(kFboSize, kFboSize);
ASSERT_NE(m_target.fbo, 0u);
MakeTexture();
MakeIndexBuffers();
MakeAcceleratedBuffers();
m_laidOut = false;
ASSERT_EQ(FirstGLError(), 0u) << "setup raised a GL error";
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindVertexArray(0);
glDisable(GL_BLEND);
glDisable(GL_DEPTH_TEST);
DestroyColorFbo(m_target);
if (m_transform) glDeleteProgram(m_transform);
if (m_blit) glDeleteProgram(m_blit);
if (m_texture) glDeleteTextures(1, &m_texture);
if (!m_buffers.empty()) glDeleteBuffers((GLsizei)m_buffers.size(), m_buffers.data());
if (!m_vaos.empty()) glDeleteVertexArrays((GLsizei)m_vaos.size(), m_vaos.data());
m_buffers.clear();
m_vaos.clear();
}
unsigned int CompileComputeProgram(const char* source) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[4096] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("compute shader did not compile: ") + log;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[4096] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("compute program did not link: ") + log;
glDeleteProgram(program);
return 0;
}
return program;
}
GLuint CompileOne(GLenum stage, const char* source) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[4096] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("shader did not compile: ") + log;
glDeleteShader(shader);
return 0;
}
return shader;
}
// glBindAttribLocation in format order, exactly as ShaderInstance does it.
unsigned int CompileBlitProgram() {
const GLuint vs = CompileOne(GL_VERTEX_SHADER, kBlitVertexSource);
if (!vs) return 0;
const GLuint fs = CompileOne(GL_FRAGMENT_SHADER, kBlitFragmentSource);
if (!fs) return 0;
const GLuint program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glBindAttribLocation(program, 0, "Position");
glBindAttribLocation(program, 1, "UV0");
glBindAttribLocation(program, 2, "Color");
glLinkProgram(program);
glDeleteShader(vs);
glDeleteShader(fs);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[4096] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("blit program did not link: ") + log;
glDeleteProgram(program);
return 0;
}
return program;
}
void MakeTexture() {
std::vector<std::uint8_t> pixels(4 * 4 * 4, 0);
for (int i = 0; i < 16; ++i) {
pixels[i * 4 + 2] = 255;
pixels[i * 4 + 3] = 255;
}
glGenTextures(1, &m_texture);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
}
GLuint NewBuffer() {
GLuint b = 0;
glCreateBuffers(1, &b);
m_buffers.push_back(b);
return b;
}
GLuint NewVao() {
GLuint v = 0;
glCreateVertexArrays(1, &v);
m_vaos.push_back(v);
return v;
}
void MakeIndexBuffers() {
std::uint32_t wide[12];
std::uint16_t narrow[12];
for (int quad = 0; quad < 2; ++quad) {
const std::uint32_t base = (std::uint32_t)(quad * 4);
const std::uint32_t pattern[6] = {base, base + 1, base + 2, base + 2, base + 3, base};
for (int i = 0; i < 6; ++i) {
wide[quad * 6 + i] = pattern[i];
narrow[quad * 6 + i] = (std::uint16_t)pattern[i];
}
}
m_wideIndices = NewBuffer();
glNamedBufferStorage(m_wideIndices, sizeof(wide), wide, 0);
m_narrowIndices = NewBuffer();
glNamedBufferStorage(m_narrowIndices, sizeof(narrow), narrow, 0);
}
static void SetupAttributes() {
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 24, (const void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 24, (const void*)12);
glEnableVertexAttribArray(1);
glVertexAttribPointer(2, 4, GL_UNSIGNED_BYTE, GL_TRUE, 24, (const void*)20);
glEnableVertexAttribArray(2);
}
void WriteInputs() {
const GuiVertex unit[4] = {
{0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0xFFFFFFFFu},
{0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0xFFFFFFFFu},
{1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0xFFFFFFFFu},
{1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0xFFFFFFFFu},
};
for (int b = 0; b < kBuilders; ++b) {
std::memcpy(m_inVertexMap[b], unit, sizeof(unit));
VaryingData varyings[4];
for (int i = 0; i < 4; ++i) {
varyings[i].offset = i;
varyings[i].sharing = b;
varyings[i].mesh = -1;
varyings[i].shouldCull = 0;
}
std::memcpy(m_inVaryingMap[b], varyings, sizeof(varyings));
}
}
void MakeAcceleratedBuffers() {
const GLbitfield persistent = GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT;
for (int b = 0; b < kBuilders; ++b) {
m_inVertices[b] = NewBuffer();
glNamedBufferStorage(m_inVertices[b], 4 * (GLsizeiptr)sizeof(GuiVertex), nullptr, persistent);
m_inVertexMap[b] =
glMapNamedBufferRange(m_inVertices[b], 0, 4 * (GLsizeiptr)sizeof(GuiVertex), persistent);
m_inVaryings[b] = NewBuffer();
glNamedBufferStorage(m_inVaryings[b], 4 * (GLsizeiptr)sizeof(VaryingData), nullptr, persistent);
m_inVaryingMap[b] =
glMapNamedBufferRange(m_inVaryings[b], 0, 4 * (GLsizeiptr)sizeof(VaryingData), persistent);
}
// Two SharingData entries: builder 0 lands left of centre, builder 1 right.
float sharing[56] = {};
const float tx[2] = {-0.9f, 0.1f};
for (int b = 0; b < 2; ++b) {
float* m = sharing + b * 28;
m[0] = 0.8f;
m[5] = 1.0f;
m[10] = 1.0f;
m[15] = 1.0f;
m[12] = tx[b];
m[13] = -0.5f;
m[16] = 1.0f;
m[20] = 1.0f;
m[24] = 1.0f;
}
m_sharings = NewBuffer();
glNamedBufferStorage(m_sharings, sizeof(sharing), nullptr, persistent);
void* r = glMapNamedBufferRange(m_sharings, 0, sizeof(sharing), persistent);
std::memcpy(r, sharing, sizeof(sharing));
m_outSize = 8 * (GLsizeiptr)sizeof(GuiVertex);
m_outVertices = NewBuffer();
glNamedBufferStorage(m_outVertices, m_outSize, nullptr, GL_DYNAMIC_STORAGE_BIT);
m_outVaryings = NewBuffer();
glNamedBufferStorage(m_outVaryings, 8 * (GLsizeiptr)sizeof(VaryingData), nullptr,
GL_DYNAMIC_STORAGE_BIT);
m_meshes = NewBuffer();
glNamedBufferStorage(m_meshes, 4 * (GLsizeiptr)sizeof(GuiVertex), nullptr, GL_DYNAMIC_STORAGE_BIT);
m_vao = NewVao();
WriteInputs();
}
std::vector<IndexedBinding> Record(GLenum b, GLenum s, GLenum z, int range) {
std::vector<IndexedBinding> saved((std::size_t)range);
for (int i = 0; i < range; ++i) {
glGetIntegeri_v(b, (GLuint)i, &saved[(std::size_t)i].buffer);
glGetIntegeri_v(s, (GLuint)i, &saved[(std::size_t)i].start);
glGetIntegeri_v(z, (GLuint)i, &saved[(std::size_t)i].size);
}
return saved;
}
void Restore(GLenum target, const std::vector<IndexedBinding>& saved) {
for (std::size_t i = 0; i < saved.size(); ++i) {
if (saved[i].start == 0 && saved[i].size == 0) {
glBindBufferBase(target, (GLuint)i, (GLuint)saved[i].buffer);
} else {
glBindBufferRange(target, (GLuint)i, (GLuint)saved[i].buffer, saved[i].start,
saved[i].size);
}
}
}
// MutableBuffer.doExpand: a NEW immutable store, copied from the old one, old one gone.
void RegrowOutputBuffer() {
const GLsizeiptr newSize = m_outSize * 2;
GLuint grown = 0;
glCreateBuffers(1, &grown);
glNamedBufferStorage(grown, newSize, nullptr, GL_DYNAMIC_STORAGE_BIT);
glCopyNamedBufferSubData(m_outVertices, grown, 0, 0, m_outSize);
glDeleteBuffers(1, &m_outVertices);
for (auto& b : m_buffers) {
if (b == m_outVertices) b = grown;
}
m_outVertices = grown;
m_outSize = newSize;
}
void Frame(const Fidelity& f, int builders) {
if (f.rewriteMapEachFrame) WriteInputs();
// --- prepareBuffers() -------------------------------------------------
const std::vector<IndexedBinding> ssbo =
Record(GL_SHADER_STORAGE_BUFFER_BINDING, GL_SHADER_STORAGE_BUFFER_START,
GL_SHADER_STORAGE_BUFFER_SIZE, kShaderStorageRestoreRange);
const std::vector<IndexedBinding> counters =
Record(GL_ATOMIC_COUNTER_BUFFER_BINDING, GL_ATOMIC_COUNTER_BUFFER_START,
GL_ATOMIC_COUNTER_BUFFER_SIZE, kAtomicCounterRestoreRange);
GLint currentProgram = 0;
glGetIntegerv(GL_CURRENT_PROGRAM, &currentProgram);
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m_outVertices);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m_sharings);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, m_outVaryings);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 5, f.leaveMeshesUnbound ? 0 : m_meshes);
glUseProgram(m_transform);
for (int b = 0; b < builders; ++b) {
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_inVertices[b]);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, m_inVaryings[b]);
glProgramUniform1ui(m_transform, glGetUniformLocation(m_transform, "vertexCount"), 4u);
glProgramUniform1ui(m_transform, glGetUniformLocation(m_transform, "vertexOffset"),
(GLuint)(4 * b));
glProgramUniform1ui(m_transform, glGetUniformLocation(m_transform, "varyingOffset"),
(GLuint)(4 * b));
glDispatchCompute(1, 1, 1);
}
glUseProgram(0);
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT);
glUseProgram((GLuint)currentProgram);
Restore(GL_SHADER_STORAGE_BUFFER, ssbo);
Restore(GL_ATOMIC_COUNTER_BUFFER, counters);
if (f.regrow) {
RegrowOutputBuffer();
m_laidOut = false; // isResized() forces the relayout
}
// --- drawBuffers() ----------------------------------------------------
glMemoryBarrier(GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT | GL_ELEMENT_ARRAY_BARRIER_BIT |
GL_COMMAND_BARRIER_BIT);
glBindVertexArray(m_vao);
if (!m_laidOut || !f.skipRelayout) {
glBindBuffer(GL_ARRAY_BUFFER, m_outVertices);
SetupAttributes();
m_laidOut = true;
}
if (f.blendAndDepth) {
glEnable(GL_BLEND);
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LEQUAL);
}
const GLenum indexType = f.shortIndices ? GL_UNSIGNED_SHORT : GL_UNSIGNED_INT;
const GLuint indexBuffer = f.shortIndices ? m_narrowIndices : m_wideIndices;
const GLsizei indexStride = f.shortIndices ? 2 : 4;
for (int b = 0; b < builders; ++b) {
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer); // AutoStorageIndexBuffer.bind
glUseProgram(m_blit);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_texture);
glUniform1i(glGetUniformLocation(m_blit, "Sampler0"), 0);
UploadIdentityMatrices();
if (f.baseVertex) {
// The mod's BASEVERTEX path: every builder reads the SAME first six
// indices and offsets the vertices with a base vertex.
glDrawElementsBaseVertex(GL_TRIANGLES, 6, indexType, (const void*)0, 4 * b);
} else {
glDrawElements(GL_TRIANGLES, 6, indexType, (const void*)(intptr_t)(b * 6 * indexStride));
}
glUseProgram(0);
}
if (f.blendAndDepth) {
glDisable(GL_BLEND);
glDisable(GL_DEPTH_TEST);
}
glBindVertexArray(0);
}
void UploadIdentityMatrices() {
static const float identity[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
glUniformMatrix4fv(glGetUniformLocation(m_blit, "ModelViewMat"), 1, GL_FALSE, identity);
glUniformMatrix4fv(glGetUniformLocation(m_blit, "ProjMat"), 1, GL_FALSE, identity);
}
void ExpectQuads(const Image& image, int builders, const char* when) {
EXPECT_TRUE(RegionIsMostly(image, 6, 26, 19, 45, "blue", 0.0,
std::string("left accelerated quad, ") + when));
if (builders > 1) {
EXPECT_TRUE(RegionIsMostly(image, 38, 58, 19, 45, "blue", 0.0,
std::string("right accelerated quad, ") + when));
}
}
void RunFrames(const Fidelity& f, int builders, int frames, const char* when) {
for (int i = 0; i < frames; ++i) {
BindFbo(m_target);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
Frame(f, builders);
ASSERT_EQ(FirstGLError(), 0u) << "frame " << i << " raised a GL error (" << when << ")";
const Image image = ReadPixels(kFboSize, kFboSize);
ExpectQuads(image, builders, (std::string(when) + ", frame " + std::to_string(i)).c_str());
Gl().EndFrame();
}
}
unsigned int m_transform = 0;
unsigned int m_blit = 0;
ColorFbo m_target{};
GLuint m_texture = 0;
GLuint m_wideIndices = 0;
GLuint m_narrowIndices = 0;
GLuint m_vao = 0;
GLuint m_inVertices[kBuilders] = {};
GLuint m_inVaryings[kBuilders] = {};
void* m_inVertexMap[kBuilders] = {};
void* m_inVaryingMap[kBuilders] = {};
GLuint m_sharings = 0;
GLuint m_outVertices = 0;
GLuint m_outVaryings = 0;
GLuint m_meshes = 0;
GLsizeiptr m_outSize = 0;
bool m_laidOut = false;
std::vector<GLuint> m_buffers;
std::vector<GLuint> m_vaos;
std::string m_buildLog;
};
} // namespace
TEST_F(GuiBatchScenario, Baseline) {
if (!Ready() || IsSkipped()) return;
RunFrames(Fidelity{}, 1, 1, "baseline");
}
TEST_F(GuiBatchScenario, ShortIndices) {
if (!Ready() || IsSkipped()) return;
Fidelity f;
f.shortIndices = true;
RunFrames(f, 1, 1, "short indices");
}
TEST_F(GuiBatchScenario, TwoBuildersWideIndices) {
if (!Ready() || IsSkipped()) return;
RunFrames(Fidelity{}, 2, 1, "two builders, wide indices");
}
TEST_F(GuiBatchScenario, TwoBuildersBaseVertex) {
if (!Ready() || IsSkipped()) return;
Fidelity f;
f.baseVertex = true;
RunFrames(f, 2, 1, "two builders, base vertex");
}
TEST_F(GuiBatchScenario, TwoBuildersShortIndicesBaseVertex) {
if (!Ready() || IsSkipped()) return;
Fidelity f;
f.baseVertex = true;
f.shortIndices = true;
RunFrames(f, 2, 1, "two builders, short indices, base vertex");
}
TEST_F(GuiBatchScenario, BlendAndDepth) {
if (!Ready() || IsSkipped()) return;
Fidelity f;
f.baseVertex = true;
f.shortIndices = true;
f.blendAndDepth = true;
RunFrames(f, 2, 1, "blend and depth");
}
TEST_F(GuiBatchScenario, ThreeFramesWithoutRelayout) {
if (!Ready() || IsSkipped()) return;
Fidelity f;
f.baseVertex = true;
f.shortIndices = true;
f.blendAndDepth = true;
f.skipRelayout = true;
f.rewriteMapEachFrame = true;
RunFrames(f, 2, 3, "three frames without relayout");
}
TEST_F(GuiBatchScenario, MeshesBlockLeftUnbound) {
if (!Ready() || IsSkipped()) return;
Fidelity f;
f.baseVertex = true;
f.shortIndices = true;
f.blendAndDepth = true;
f.leaveMeshesUnbound = true;
RunFrames(f, 2, 1, "Meshes block left unbound");
}
TEST_F(GuiBatchScenario, FullFidelityWithRegrowth) {
if (!Ready() || IsSkipped()) return;
Fidelity f;
f.baseVertex = true;
f.shortIndices = true;
f.blendAndDepth = true;
f.skipRelayout = true;
f.rewriteMapEachFrame = true;
RunFrames(f, 2, 1, "full fidelity, pre-growth");
f.regrow = true;
RunFrames(f, 2, 1, "full fidelity, growth frame");
f.regrow = false;
RunFrames(f, 2, 2, "full fidelity, post-growth");
}
namespace {
// Atomic counter blocks resolve through the SAME descriptor path as shader storage
// blocks (glslang rewrites every atomic_uint into a synthesized storage block), so an
// unbound GL_ATOMIC_COUNTER_BUFFER point loses the dispatch for exactly the same reason
// an unbound SSBO did. The mod reaches this with its INDIRECT draw method, whose culling
// shaders carry a counter the BASEVERTEX default never binds.
//
// The counter is INCREMENTED, not merely declared: an unreferenced one is optimised out
// before it ever reaches a descriptor, so a shader that only declares it proves nothing.
constexpr const char* kCounterComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(binding = 0, offset = 0) uniform atomic_uint g_unbound;
layout(std430, binding = 0) buffer Output { uint g_data[]; };
void main() {
atomicCounterIncrement(g_unbound);
g_data[gl_GlobalInvocationID.x] = gl_GlobalInvocationID.x + 1u;
}
)";
class UnboundCounterBlockScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
GLint counters = 0;
glGetIntegerv(GL_MAX_COMPUTE_ATOMIC_COUNTERS, &counters);
if (counters < 1) {
GTEST_SKIP() << "GL_MAX_COMPUTE_ATOMIC_COUNTERS is " << counters;
}
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(shader, 1, &kCounterComputeSource, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
ASSERT_NE(compiled, GL_FALSE);
m_program = glCreateProgram();
glAttachShader(m_program, shader);
glLinkProgram(m_program);
glDeleteShader(shader);
GLint linked = 0;
glGetProgramiv(m_program, GL_LINK_STATUS, &linked);
ASSERT_NE(linked, GL_FALSE);
glGenBuffers(1, &m_buffer);
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
if (m_buffer) glDeleteBuffers(1, &m_buffer);
if (m_program) glDeleteProgram(m_program);
}
unsigned int m_program = 0;
GLuint m_buffer = 0;
};
} // namespace
TEST_F(UnboundCounterBlockScenario, ADeclaredButUnboundCounterDoesNotLoseTheDispatch) {
if (!Ready() || IsSkipped()) return;
constexpr int kElements = 4;
const std::vector<unsigned int> zeros((std::size_t)kElements, 0u);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_buffer);
glBufferData(GL_SHADER_STORAGE_BUFFER, (GLsizeiptr)(zeros.size() * sizeof(unsigned int)), zeros.data(),
GL_DYNAMIC_COPY);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_buffer);
// Nothing is bound at GL_ATOMIC_COUNTER_BUFFER point 0 on purpose.
glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, 0, 0);
ASSERT_EQ(FirstGLError(), 0u);
glUseProgram(m_program);
glDispatchCompute(kElements, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
EXPECT_EQ(FirstGLError(), 0u) << "the dispatch raised a GL error";
std::vector<unsigned int> values((std::size_t)kElements, 0xDEADBEEFu);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_buffer);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, (GLsizeiptr)(values.size() * sizeof(unsigned int)),
values.data());
for (int i = 0; i < kElements; ++i) {
EXPECT_EQ(values[(std::size_t)i], (unsigned int)(i + 1))
<< "element " << i << " came back as " << values[(std::size_t)i]
<< "; zero everywhere means the whole dispatch was dropped over the unbound counter block";
}
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
}
} // namespace MGITest