Compare commits

...
Author SHA1 Message Date
HH45137 e3fac5e9fa [Fix]: Add Task/Mesh shader enum mapping
Map the Task and Mesh shader stages to their NV OpenGL enum values in ProgramEnumConverter so shader stage conversion handles task and mesh pipelines correctly.
2026-10-12 00:41:40 +08:00
HH45137 3823fe528c [Feat, Test]: Add NV mesh shader support
Advertise GL_NV_mesh_shader in the DirectVulkan backend and expose the new task/mesh shader stages in the GL-to-MG and glslang conversion layers. This also wires the mesh task drawing entry points and adds a focused MeshShader test covering extension discovery and task shader compilation.
2026-10-12 00:20:53 +08:00
swung0x48 08124c99f1 [Test] (trace): replace the create-indirect fixture with minecraft-1.21.1-neoforge-create-indirect-in-world-align1024, a derived trace and not the original capture - the desktop capture bound Flywheel's scatter lists at 16-byte SSBO offsets that Adreno 830 and Mali's Vulkan driver (64) reject, and fixture_tools/realign_buffer_ranges.cpp moves the 1,270 binds off a 1024-byte boundary onto 1024-aligned slots (3,812 calls inserted, target call 504631 -> 508443, host SSIM unchanged to 9 digits on both backends) 2026-09-30 10:00:26 -04:00
swung0x48 f973008c4d [Fix] (Espryt): a partial buffer flush no longer takes the range-invalidating map while an upload-ring copy into the same store may still be queued - Adreno 830 loses the queued copy, and create-instancing drew whole Embeddium terrain sections empty 2026-09-30 04:07:41 -04:00
swung0x48 7030d307be [Test] (Magma): UnboundUniformBlockScenario - a dispatch and a draw whose program declares a uniform block at an empty binding point (never bound, or its buffer deleted while bound) run and produce their fallback, and a buffer bound there afterwards is read, on both backends 2026-09-29 19:58:41 -04:00
swung0x48 853c5f1264 [Fix] (Magma): a uniform block declared at an empty binding point reads a zeroed block instead of dereferencing a null buffer 2026-09-29 19:55:53 -04:00
swung0x48 c14283ee2d [Docs] (Magma): the trim threshold's comment states the bound its argument proves - a growth adds at most twice what the slot held, so the slot stays over a third full 2026-09-29 10:58:33 -04:00
swung0x48 9dccabf03c [Test] (Magma): descriptor pool growth and trim - the pure sizing policy adds O(log N) pools for an N-set frame, and DescriptorPoolGrowthScenario's 4096-distinct-set frame lands whole on both backends, grows at most 7 pools, and is given back after quiet frames and re-grown while they are still in flight 2026-09-29 10:34:16 -04:00
swung0x48 53fa16c2c2 [Fix] (Magma): a frame slot whose descriptor pools stay under a quarter full for 120 frames is handed back whole - its set cache cleared, grown pools destroyed, base pool reset and every memo holding a set handle dropped - so rd12's loading burst no longer lives for the whole process (live sets 15,042 -> 39, pools 65 -> 6); MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES (0 = never) and a test-visible pool census 2026-09-29 10:34:16 -04:00
swung0x48 c2de6576ad [Fix] (Magma): a frame slot's next descriptor pool doubles its LARGEST pool of the same update-after-bind flavour, capped at 4096 sets - doubling the first (oldest, smallest) one made every grown pool the same size, so rd12's 15,016-set loading frame grew one slot to 60 pools (1x128 + 59x256); now 9 (64 ... 4096, 4096, 4096) 2026-09-29 10:34:15 -04:00
swung0x48 9c32d9fa68 [Test] (Magma): OversizedRecordingSplitScenario carries state across the split - a depth occluder, a stencil mark, an additive blend destination, a 4x multisample target resolved after it, occlusion and primitives-generated queries and a transform-feedback capture left open across it, each checked on both backends and in the budget-64 lane 2026-09-29 09:44:58 -04:00
swung0x48 4d3d677a7f [Test] (Magma): OversizedRecordingSplitScenario - a 1024-draw frame, one pixel and one uniform value per draw, lands whole on both backends; the DirectVulkan.SplitRecording lane pins the draw budget to 64 and asserts the split fired 2026-09-29 09:44:58 -04:00
swung0x48 d20c2d01ae [Fix] (Magma): a frame's recording is submitted every 16384 draws/dispatches and continues on a fresh command buffer, with at most frames-in-flight + 1 submissions outstanding - rd12's 981k-draw loading frame no longer grows one command buffer until Adreno's 16 KiB driver mappings exhaust vm.max_map_count (MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER, 0 = unbounded) 2026-09-29 09:44:58 -04:00
swung0x48 21c7e8cd63 [Test] (MG_IntegrationTest, MG_Test): scattered writes over a GPU clear on both backends - two distant 2x2 boxes, a hundred boxes, boxes filling 3/4 of their union and three quadrants spanning the level, the 3/4 and level-spanning cases again at the image-bindable re-mint, and a MipmapStorage footprint fuzz against a reference bitmap 2026-09-29 09:06:07 -04:00
swung0x48 9524c688ca [Fix] (Espryt, Magma): scattered glTexSubImage writes upload exactly their texels - MipmapStorage keeps an exact dirty footprint beside its rect list (a write log once a merge or a fold would widen the list past the writes), Magma always uploads it, Espryt uploads it box by box (through the unpack ring too) once the driver texture may hold GPU writes and merges it at the image-bindable re-mint; the union box, the 3/4 cut-off, the 96-rect fold and a level-spanning union no longer put the stale shadow over GPU-written texels (two 2x2 writes over a GPU clear: 41 of 64 texels red -> 0) 2026-09-29 09:06:07 -04:00
swung0x48 c60639db1d [Fix] (Magma): eglSwapInterval reaches the swapchain - 0 picks IMMEDIATE, else MAILBOX, else FIFO; >= 1 FIFO; < 0 FIFO_RELAXED, else FIFO; rebuilt after the next present when the mode changes (MC 26.3 vsync on: 190-290 fps -> 120) 2026-09-29 05:51:18 -04:00
swung0x48 b1d60523e4 [Fix] (Magma): an upload into an image the open recording already wrote, or that still has a queued clear, is submitted after that work - a glTexSubImage after a clear or a draw into the level no longer ends up under it 2026-09-29 04:28:55 -04:00
swung0x48 8966d5c294 [Fix] (Espryt, Magma): a partial write into a level whose upload is converted (widened RGB formats, Magma's RGB->RGBA expansion) or reshaped (Espryt 1D arrays) uploads only its dirty region - the rest of the stale shadow no longer lands over GPU-drawn texels 2026-09-29 04:28:22 -04:00
swung0x48 568090f09f [Fix] (Espryt): the image-bindable re-mint reads every level back from the driver texture before replaying it, so GPU-written levels survive the first glBindImageTexture - iris-derivative SSIM 0.996196552 -> 0.996376198 2026-09-29 03:06:45 -04:00
60 changed files with 5091 additions and 157 deletions
+12
View File
@@ -132,6 +132,18 @@ namespace MobileGL::MG_Config {
Bool MagmaR11G11B10FFallback = false;
// MOBILEGL_MAGMA_FRAMESINFLIGHT: requested Magma frames in flight, defaulting to 3.
Uint32 MagmaFramesInFlight = 3;
// MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER: GL draws and dispatches Magma records into
// one command buffer before it submits it and continues on a fresh one. 0 = unbounded.
// Drivers back a command buffer with GPU memory that is only returned when the buffer is
// freed; Adreno maps it into the process in 16 KiB chunks, so one ~1M-draw loading frame
// exhausts vm.max_map_count. See VulkanRenderer::SplitOversizedRecording.
Uint32 MagmaMaxDrawsPerCommandBuffer = 16384;
// MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES: how many consecutive frames (strictly, BeginFrame
// epochs) a frame slot's descriptor pools must stay under a quarter full before Magma
// hands the slot's whole set cache back and lets it re-grow. 0 = never trim. A loading
// frame otherwise keeps its burst of sets (rd12: ~15k, where steady frames use a few dozen)
// for the life of the process. See UniformManager::TrimFrameDescriptorPools.
Uint32 MagmaDescriptorTrimFrames = 120;
// MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER: avoid mipmap min filters in samplers,
// resolves certain rendering bugs on ANGLE + llvmpipe.
Bool EsprytAvoidSamplerMipmapMinFilter = false;
+3
View File
@@ -177,6 +177,9 @@ namespace MobileGL::MG_ConfigLoader {
features.AdvertiseFp64 = QueryEnvFlag("MOBILEGL_ADVERTISE_FP64");
features.MagmaR11G11B10FFallback = QueryEnvFlag("MOBILEGL_MAGMA_R11G11B10F_FALLBACK");
features.MagmaFramesInFlight = QueryEnvUint32("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3, 1, 64);
features.MagmaMaxDrawsPerCommandBuffer =
QueryEnvUint32("MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER", 16384, 0, 1u << 24);
features.MagmaDescriptorTrimFrames = QueryEnvUint32("MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES", 120, 0, 1u << 20);
features.EsprytAvoidSamplerMipmapMinFilter =
QueryEnvFlag("MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
features.EsprytAvoidExplicitLodBias = QueryEnvFlag("MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS");
@@ -1707,6 +1707,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
return;
}
// Before the sync: `backendTexture` below is a reference into the registry, which the
// lookup inside the note may reshuffle.
if (imageBinding.Access != GL_READ_ONLY) {
NoteDriverSideTextureWrite(imageBinding.Texture);
}
auto& backendTexture = SyncTextureObjectToBackend(imageBinding.Texture, true);
const Bool layerable = SupportsLayeredImageBinding(imageBinding.Texture->GetTarget());
const GLboolean layered = layerable ? imageBinding.Layered : GL_FALSE;
@@ -5919,6 +5924,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
continue;
}
TextureImpl::NoteDriverSideTextureWrite(destinationTexture);
auto backendSource = TextureImpl::SyncTextureObjectToBackend(sourceTexture);
auto backendDestination = TextureImpl::SyncTextureObjectToBackend(destinationTexture);
if (!backendSource || !backendDestination) continue;
@@ -6635,6 +6641,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const auto& textureObject = MG_State::pGLContext->GetTextureUnitObject((Int)activeTextureUnit)
.GetBindingSlot(MG_Util::ConvertGLEnumToTextureTarget(target))
.GetBoundObject();
if (textureObject) TextureImpl::NoteDriverSideTextureWrite(textureObject);
auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(textureObject.get());
if (!backendTextureSlot || !*backendTextureSlot) {
MGLOG_E_ONCE("CopyTexSubImage2D: No backend texture found for texture %u.",
@@ -6730,6 +6737,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const auto& textureObject = MG_State::pGLContext->GetTextureUnitObject(activeTextureUnit)
.GetBindingSlot(MG_Util::ConvertGLEnumToTextureTarget(target))
.GetBoundObject();
if (textureObject) TextureImpl::NoteDriverSideTextureWrite(textureObject);
auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(textureObject.get());
if (!backendTextureSlot || !*backendTextureSlot) {
MGLOG_E_ONCE("CopyTexSubImage2D: No backend texture found for texture %u.",
@@ -6879,6 +6887,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
TextureImpl::SyncTextureObjectToBackend(texture);
return;
}
// Every path below writes the levels above the base on the driver only.
TextureImpl::NoteDriverSideTextureWrite(texture);
auto& backendTexture = TextureImpl::SyncTextureObjectToBackend(texture);
if (IsDepthOnlyFormat(texture->GetFormat())) {
@@ -7152,6 +7162,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
// dereference. The frontend validator is what keeps this unreachable and what reports
// the error the application is owed; declining is only how a future gap up there stops
// being a crash. See the level guard in VulkanRenderer::CopyImageSubData.
if (!dstEndpoint.IsRenderbuffer() && dstEndpoint.Texture) {
TextureImpl::NoteDriverSideTextureWrite(dstEndpoint.Texture);
}
if (!MakeGLESCopyImageEndpoint(srcEndpoint, srcTarget, srcX, srcY, srcZ, src) ||
!MakeGLESCopyImageEndpoint(dstEndpoint, dstTarget, dstX, dstY, dstZ, dst)) {
MGLOG_E_ONCE("%s: source or destination image failed to sync; declining the copy", __func__);
@@ -9014,6 +9027,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
return true;
}
#include "WireTextureReadback.inc"
static Bool IsLegacyNativeReadPixelsFormat(GLenum format) {
return format == GL_RGBA || format == GL_RGBA_INTEGER || format == GL_RED || format == GL_RED_INTEGER ||
format == GL_DEPTH_COMPONENT || format == GL_STENCIL_INDEX || format == GL_DEPTH_STENCIL;
@@ -23,6 +23,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Content uploads use scratch bindings, so draws and dispatches call this after
// texture synchronization.
void BindCurrentTextures();
// Driver-side color/depth level readback. Returns tightly packed, owned
// bytes in the requested pair; never consults a frontend texture or PACK/PBO.
// The caller supplies identity and extent, and whether the SOURCE allocation
// already uses an image carrier.
Bool ReadTextureLevelTight(GLuint texture, TextureTarget target, TextureUploadTarget uploadTarget,
TextureInternalFormat logicalFormat, GLint level, const IntVec3& logicalExtent,
Bool sourceUsesImageCarrier, GLenum format, GLenum type, Vector<Uint8>& bytes);
void ClearBufferfi(GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil);
void ClearBufferfv(GLenum buffer, GLint drawbuffer, const GLfloat* value);
void ClearBufferuiv(GLenum buffer, GLint drawbuffer, const GLuint* value);
+264 -53
View File
@@ -899,8 +899,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
// shadow's to rewrite. Widening to page bounds looked free and was
// not - the widened bytes clobbered GPU-written data (an SSBO
// counter beside the app's SubData) with the stale shadow.
//
// A partial range-invalidating map must not overtake a ring copy into
// this store the GPU has not run yet. Adreno 830 answers such a map on
// a store with a copy still queued by losing the copy: the Embeddium
// section meshes create-instancing staged through the ring came out
// empty (whole terrain sections missing, SSIM 0.870), and routing the
// map ranges behind the copies - the ring tier, GPU-ordered after them -
// brings every one back. The whole-buffer orphan-map is exempt: it
// rewrites every byte from the shadow the queued copies were taken from.
const Bool wholeBuffer = start == 0 && end == limit && limit == resource.storageSize;
if (mapUsable && (wholeBuffer || size >= kInvalidateRangeMinBytes)) {
const Bool ringCopyInFlight = resource.ringCopyRetireSerial > DirectGLES::CompletedFrameSerial();
if (mapUsable && (wholeBuffer || (size >= kInvalidateRangeMinBytes && !ringCopyInFlight))) {
BindBufferId(TempBufferTarget, resource.id);
const GLbitfield access =
GL_MAP_WRITE_BIT |
@@ -921,6 +931,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
BindBufferId(GL_COPY_WRITE_BUFFER, resource.id);
g_GLESFuncs.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER,
(GLintptr)ringOffset, (GLintptr)start, (GLsizeiptr)size);
// Commands recorded now retire with the fence the next Present inserts.
resource.ringCopyRetireSerial = DirectGLES::CurrentFrameSerial() + 1;
} else {
UploadRangeNow(resource, bufferObject, start, end);
}
@@ -2786,13 +2798,115 @@ namespace MobileGL::MG_Backend::DirectGLES {
return m_backendTextureId;
}
// Replaces one level's shadow bytes with what the driver texture holds for it, then copies
// back the client writes the driver has not been sent yet - exactly the texels written
// (the level's dirty footprint), not the union box or the rect list, which also span texels
// the client never wrote and which only the driver's copy holds correctly. Returns false,
// leaving the shadow untouched, when the driver's copy cannot stand in for the shadow: the
// client wrote every texel of the level, the level is stored compressed, the driver has no
// image at this level and extent (defined after the last sync), or the readback cannot be
// served (a multisample image, a format the driver will not attach to a framebuffer).
static Bool AdoptDriverLevelIntoShadow(GLuint backendTextureId, BackendTextureObject& backendTexture,
const MG_State::GLState::ITextureObject& stateTextureObject,
MG_State::GLState::TextureObjectMipmap& mipmapObject,
TextureUploadTarget uploadTarget, Uint level) {
const IntVec3 texelSize = mipmapObject.GetMipmapTexelSize(uploadTarget, level);
const IntVec3 extent{texelSize.x(), texelSize.y(), std::max(texelSize.z(), 1)};
const SizeT texelCount =
static_cast<SizeT>(extent.x()) * static_cast<SizeT>(extent.y()) * static_cast<SizeT>(extent.z());
Vector<MG_State::GLState::MipmapDirtyRegion> pending;
mipmapObject.GetStorageDirtyFootprint(uploadTarget, level, pending);
// Disjoint boxes, so their texels add up; a union box spanning the level is not enough.
SizeT pendingTexels = 0;
for (const auto& box : pending) {
pendingTexels += box.TexelCount();
}
if (!pending.empty() && pendingTexels >= texelCount) return false;
if (mipmapObject.GetMipmapCompressedFormat(uploadTarget, level) != GL_NONE) return false;
if (!g_GLESFuncs.glGetTexLevelParameteriv) return false;
const TextureTarget target = stateTextureObject.GetTarget();
const GLenum nativeTarget = ConvertTextureTargetToBackendGLEnum(target);
const GLenum faceTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget);
const IntVec3 backendExtent = GetBackendUploadSize(target, texelSize);
GLint driverWidth = 0, driverHeight = 0, driverDepth = 0;
backendTexture.Bind(nativeTarget);
g_GLESFuncs.glGetTexLevelParameteriv(faceTarget, static_cast<GLint>(level), GL_TEXTURE_WIDTH, &driverWidth);
g_GLESFuncs.glGetTexLevelParameteriv(faceTarget, static_cast<GLint>(level), GL_TEXTURE_HEIGHT,
&driverHeight);
g_GLESFuncs.glGetTexLevelParameteriv(faceTarget, static_cast<GLint>(level), GL_TEXTURE_DEPTH, &driverDepth);
if (driverWidth != backendExtent.x() || driverHeight != backendExtent.y() ||
std::max(driverDepth, 1) != std::max(backendExtent.z(), 1)) {
return false;
}
const TextureInternalFormat format = stateTextureObject.GetFormat();
GLenum shadowFormat = GL_NONE, shadowType = GL_NONE;
MG_Util::TextureFormatProcessor::NormalizePixelFormat(MG_Util::ConvertTextureInternalFormatToGLEnum(format),
{}, nullptr, &shadowFormat, &shadowType);
Vector<Uint8> snapshot;
if (!ReadTextureLevelTight(backendTextureId, target, uploadTarget, format, static_cast<GLint>(level),
texelSize, /*sourceUsesImageCarrier=*/false, shadowFormat, shadowType,
snapshot)) {
return false;
}
const SizeT shadowBytes = mipmapObject.GetMipmapByteSize(uploadTarget, level);
auto* shadow = static_cast<Uint8*>(mipmapObject.MapMipmapData(uploadTarget, level));
if (shadow == nullptr || snapshot.size() != shadowBytes || texelCount == 0 || shadowBytes % texelCount) {
return false;
}
if (!pending.empty()) {
// The boxes index the SHADOW's texel grid (a 1D array's layers are its rows), which
// is also the snapshot's byte order.
const SizeT bpp = shadowBytes / texelCount;
const SizeT rowBytes = static_cast<SizeT>(extent.x()) * bpp;
const SizeT sliceBytes = static_cast<SizeT>(extent.y()) * rowBytes;
for (const auto& box : pending) {
if (box.Empty()) continue;
const SizeT spanBytes = static_cast<SizeT>(box.hi.x() - box.lo.x()) * bpp;
for (Int z = box.lo.z(); z < box.hi.z(); ++z) {
for (Int y = box.lo.y(); y < box.hi.y(); ++y) {
const SizeT offset = static_cast<SizeT>(z) * sliceBytes + static_cast<SizeT>(y) * rowBytes +
static_cast<SizeT>(box.lo.x()) * bpp;
Memcpy(snapshot.data() + offset, shadow + offset, spanBytes);
}
}
}
}
Memcpy(shadow, snapshot.data(), shadowBytes);
return true;
}
void BackendTextureObject::RequireImageBindableStorage(
const SharedPtr<MG_State::GLState::ITextureObject>& stateTextureObject) {
if (m_imageBindableStorageRequired) {
return;
}
// Read before the two flags below schedule the re-mint: whether the driver texture
// already holds this object's levels, and so may hold texels the shadow never saw.
const Bool hadBackendStorage = m_isInitialized && m_backendTextureId != 0 &&
m_contextGeneration == g_backendContextGeneration;
m_imageBindableStorageRequired = true;
// ES only needs the storage to be immutable, so an immutable allocation that needs no
// channel widening is already the one a re-mint would produce: keep it, and with it
// every texel the GPU wrote. Pending uploads and real redefinitions still go through
// the normal shape and dirty checks of the next sync.
if (hadBackendStorage && m_backendStorageImmutable && !stateTextureObject->IsTextureView() &&
!IsMultisampleTextureTarget(stateTextureObject->GetTarget()) &&
!GetImageBindableStorageWidening(stateTextureObject->GetFormat())) {
m_forceTextureParamsResync = true;
return;
}
m_isInitialized = false;
// A view owns no levels. Its sync hands the requirement to the storage texture
// (SyncTextureViewToBackend), whose own transition below re-mints and replays them; a
// re-dirty issued here, through the view, would reach that texture first and claim
// every level for the shadow before the driver's copy could be read back.
if (stateTextureObject->IsTextureView()) {
m_forceTextureParamsResync = true;
return;
}
// Every level this object has ALREADY uploaded has to be replayed, because the
// regeneration this transition schedules re-mints the storage in the image carrier and
// only uploads levels the shadow still calls dirty - which, for a texture that was
@@ -2803,6 +2917,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the image loads are wrong. Reached whenever anything syncs the texture first - a
// glGetTexImage, a draw that samples it, an FBO attach - which is why it survived so
// long: the scenario that binds the image immediately after uploading never sees it.
//
// And the shadow is only the CLIENT's copy. Whatever the GPU wrote into a level - a
// render or clear into it, a copy, a glGenerateMipmap - lives in the driver texture
// alone, so replaying the shadow as it stands put the pre-render bytes (or the zeroes
// of a null-data definition) into the new storage. The driver's levels are therefore
// read back into the shadow first, with the client writes still pending merged over
// them, while the old storage still exists; a level the driver cannot hand back keeps
// the shadow, which is all this used to replay.
if (auto* mipmapObject = MG_State::GLState::AsMipmapTexture(stateTextureObject.get())) {
const auto levelCount = mipmapObject->GetMipmapLevelCount();
for (const auto& uploadTarget : stateTextureObject->GetUploadTargets()) {
@@ -2810,6 +2932,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
const auto levelTexelSize = mipmapObject->GetMipmapTexelSize(uploadTarget, level);
if (levelTexelSize.x() <= 0 || levelTexelSize.y() <= 0) continue;
if (mipmapObject->GetMipmapByteSize(uploadTarget, level) == 0) continue;
if (hadBackendStorage &&
!AdoptDriverLevelIntoShadow(m_backendTextureId, *this, *stateTextureObject,
*mipmapObject, uploadTarget, level)) {
MGLOG_D("Texture %u level %u: re-minting from the shadow, the driver's copy is not "
"readable here",
stateTextureObject->GetExternalIndex(), level);
}
mipmapObject->MarkStorageDirty(uploadTarget, level, true);
}
}
@@ -2969,7 +3098,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// they use, so the type stays trivially default-constructible and the
// declaration costs nothing on the levels that never reach the ring.
struct UnpackStagingBlock {
const Uint8* src; // top-left texel of the region in the level shadow
const Uint8* src; // top-left texel of the region in the level's upload bytes
SizeT rowBytes; // bytes per region row (region width * bpp)
SizeT rows; // region height
SizeT slices; // region depth (1 for 2D)
@@ -4219,6 +4348,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
ApplyImageBindableStorageWidening(imageWidening, &glInternalFormat, &glFormat, &glType);
const auto& uploadTargets = textureMipmapObject->GetUploadTargets();
ScopedDefaultUnpackState unpackState;
// Per-level scratch of the exact-footprint path, hoisted to reuse capacity.
Vector<MG_State::GLState::MipmapDirtyRegion> dirtyFootprint;
Vector<UnpackStagingBlock> stagingBlockOverflow;
for (auto& uploadTarget : uploadTargets) {
for (SizeT level = 0; level < mipmapCount; ++level) {
if (!textureMipmapObject->IsStorageDirty(uploadTarget, level)) {
@@ -4255,33 +4387,53 @@ namespace MobileGL::MG_Backend::DirectGLES {
Vector<Uint8> packedUploadData;
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), texelSize,
uploadData, byteSize, &glType, packedUploadData);
// Leaves `uploadData` pointing at its own buffer when it fires, which
// is exactly what takes the sub-rect fast path below out of play: that
// path strides into the SHADOW, and the widened texels are four
// components wide where the shadow's are one or two.
// Leaves `uploadData` pointing at its own buffer when it fires, with
// texels four components wide where the shadow's are one or two.
Vector<Uint8> imageWidenedUploadData;
uploadData = PrepareImageWidenedUpload(imageWidening, texelSize, uploadData, byteSize,
imageWidenedUploadData);
const IntVec3 uploadSize =
GetBackendUploadSize(stateTextureObject->GetTarget(), texelSize);
// Sub-rect upload: when only a region of the level changed (a
// 16x16 sprite in a 1024x512 atlas, the per-frame lightmap) and
// the shadow bytes go to the driver unconverted, upload just that
// region with UNPACK_ROW_LENGTH striding into the level shadow.
// Conversion fallbacks rewrite the whole level into a fresh
// buffer, so they stay on the full-level path, as do targets
// whose backend upload size differs from the shadow's texel size.
// 16x16 sprite in a 1024x512 atlas, the per-frame lightmap), upload
// just that region with UNPACK_ROW_LENGTH striding into the level.
// Not an optimization alone: the shadow never sees a GPU write, so
// the texels OUTSIDE the region may be stale there and must not go
// up over what the GPU drew. Every conversion above rewrites the
// whole level texel for texel into a tight buffer of its own, so the
// region is taken from whichever buffer `uploadData` now names, at
// that buffer's bytes per texel.
SizeT uploadBytes = 0;
if (uploadData == mipData) {
uploadBytes = byteSize;
} else if (!imageWidenedUploadData.empty() &&
uploadData == imageWidenedUploadData.data()) {
uploadBytes = imageWidenedUploadData.size();
} else if (!packedUploadData.empty() && uploadData == packedUploadData.data()) {
uploadBytes = packedUploadData.size();
} else if (!widenedUploadData.empty() && uploadData == widenedUploadData.data()) {
uploadBytes = widenedUploadData.size();
} else if (!convertedUploadData.empty() && uploadData == convertedUploadData.data()) {
uploadBytes = convertedUploadData.size() * sizeof(Float);
}
// A 1D array's ES image is a 2D array one texel high with the layers
// in depth, while the shadow keeps the layers as rows: same texels in
// the same order, so a region maps across by moving its y to z.
const Bool layersAsRows = stateTextureObject->GetTarget() == TextureTarget::Texture1DArray;
const auto dirtyRegion = textureMipmapObject->GetStorageDirtyRegion(uploadTarget, level);
const SizeT texelCount = static_cast<SizeT>(texelSize.x()) *
static_cast<SizeT>(texelSize.y()) *
static_cast<SizeT>(std::max(texelSize.z(), 1));
const Bool subRectEligible =
uploadData == mipData && !dirtyRegion.Empty() &&
!dirtyRegion.CoversWholeLevel(texelSize) && texelCount > 0 &&
byteSize % texelCount == 0 && uploadSize.x() == texelSize.x() &&
uploadSize.y() == texelSize.y() &&
std::max(uploadSize.z(), 1) == std::max(texelSize.z(), 1);
const SizeT bpp = subRectEligible ? byteSize / texelCount : 0;
// Whether a box of the level can be addressed at all: whole texels in
// the upload bytes, and an ES image shaped like the shadow.
const Bool regionsAddressable =
!dirtyRegion.Empty() && texelCount > 0 && uploadBytes > 0 &&
uploadBytes % texelCount == 0 &&
(layersAsRows ? std::max(texelSize.z(), 1) == 1
: uploadSize.x() == texelSize.x() && uploadSize.y() == texelSize.y() &&
std::max(uploadSize.z(), 1) == std::max(texelSize.z(), 1));
const Bool subRectEligible = regionsAddressable && !dirtyRegion.CoversWholeLevel(texelSize);
const SizeT bpp = regionsAddressable ? uploadBytes / texelCount : 0;
const IntVec3 regionSize = {dirtyRegion.hi.x() - dirtyRegion.lo.x(),
dirtyRegion.hi.y() - dirtyRegion.lo.y(),
dirtyRegion.hi.z() - dirtyRegion.lo.z()};
@@ -4297,7 +4449,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// whole level while the touched texels are a few percent of
// it. The storage's bounded rect list recovers the true
// footprint; each rect is uploaded with the same
// ROW_LENGTH striding into the level shadow as the box
// ROW_LENGTH striding into the level as the box
// path. The storage only hands the list out when its
// summed area is materially smaller than the box (0
// otherwise), so the extra calls always move fewer bytes.
@@ -4318,12 +4470,48 @@ namespace MobileGL::MG_Backend::DirectGLES {
dirtyRectCount = 0;
}
}
const auto rectShadowPtr = [&](const MG_State::GLState::MipmapDirtyRegion& rect) {
// Everything above - the union box, the ring's one-box rule, the rect
// list with its merges, fold and 3/4 cut-off - may upload shadow texels
// the client did not write, which is only harmless while the driver
// still holds those texels as they were last uploaded. Once it may hold
// GPU writes, the level goes up as exactly the texels written, one box
// (one ring job) each, whatever that costs: more boxes, and a union box
// that spans the whole level no longer means a whole-level upload.
const MG_State::GLState::MipmapDirtyRegion* uploadRects = dirtyRects;
SizeT uploadRectCount = dirtyRectCount;
if (m_driverMayHoldGpuWrites && regionsAddressable) {
textureMipmapObject->GetStorageDirtyFootprint(uploadTarget, level, dirtyFootprint);
// One box is the union box itself, which the branches below already
// upload exactly (as the whole level when it is the whole level).
if (dirtyFootprint.size() >= 2) {
uploadRects = dirtyFootprint.data();
uploadRectCount = dirtyFootprint.size();
} else {
uploadRectCount = 0;
}
}
const Bool perRectUpload = regionsAddressable && uploadRectCount >= 2;
const auto rectUploadPtr = [&](const MG_State::GLState::MipmapDirtyRegion& rect) {
return static_cast<const Uint8*>(uploadData) +
static_cast<SizeT>(rect.lo.z()) * levelSliceBytes +
static_cast<SizeT>(rect.lo.y()) * levelRowBytes +
static_cast<SizeT>(rect.lo.x()) * bpp;
};
// A shadow-grid box as the ES image addresses it, and the image height
// that strides the source to match; they differ only for a 1D array
// (layersAsRows), whose rows are ES layers of a one-texel-high image.
struct BackendBox {
GLint x, y, z;
GLsizei w, h, d;
};
const auto ToBackendBox = [&](const MG_State::GLState::MipmapDirtyRegion& rect) {
const GLsizei w = static_cast<GLsizei>(rect.hi.x() - rect.lo.x());
const GLsizei h = static_cast<GLsizei>(rect.hi.y() - rect.lo.y());
const GLsizei d = static_cast<GLsizei>(rect.hi.z() - rect.lo.z());
return layersAsRows ? BackendBox{rect.lo.x(), 0, rect.lo.y(), w, 1, h}
: BackendBox{rect.lo.x(), rect.lo.y(), rect.lo.z(), w, h, d};
};
const GLint imageHeight = layersAsRows ? 1 : texelSize.y();
// Unpack-ring staging plan, decided ONCE for whichever branch
// below runs: either every glTexSubImage of this level sources
// from the ring or none does, so the pixel-unpack binding is
@@ -4338,17 +4526,23 @@ namespace MobileGL::MG_Backend::DirectGLES {
// client-pointer path makes the driver walk, which strides over
// the whole level width.
UnpackStagingBlock
stagingBlocks[MG_State::GLState::MipmapStorage::kMaxDirtyRects];
stagingBlockArray[MG_State::GLState::MipmapStorage::kMaxDirtyRects];
UnpackStagingBlock* stagingBlocks = stagingBlockArray;
SizeT stagingBlockCount = 0;
const Bool ringUsable = BufferImpl::UnpackRingAvailable();
if (!ringUsable) {
// Nothing to plan: every branch below keeps the client
// pointer and its ROW_LENGTH striding, unchanged.
} else if (subRectEligible && dirtyRectCount >= 2) {
for (SizeT r = 0; r < dirtyRectCount; ++r) {
const auto& rect = dirtyRects[r];
} else if (perRectUpload) {
// Only an exact footprint outgrows the array (see uploadRects).
if (uploadRectCount > MG_State::GLState::MipmapStorage::kMaxDirtyRects) {
stagingBlockOverflow.resize(uploadRectCount);
stagingBlocks = stagingBlockOverflow.data();
}
for (SizeT r = 0; r < uploadRectCount; ++r) {
const auto& rect = uploadRects[r];
stagingBlocks[r] = {
rectShadowPtr(rect),
rectUploadPtr(rect),
static_cast<SizeT>(rect.hi.x() - rect.lo.x()) * bpp,
static_cast<SizeT>(rect.hi.y() - rect.lo.y()),
static_cast<SizeT>(std::max(rect.hi.z() - rect.lo.z(), 1)),
@@ -4356,7 +4550,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
levelSliceBytes,
0};
}
stagingBlockCount = dirtyRectCount;
stagingBlockCount = uploadRectCount;
} else if (subRectEligible) {
stagingBlocks[0] = {regionPtr,
static_cast<SizeT>(regionSize.x()) * bpp,
@@ -4397,17 +4591,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
switch (MapToBackendTextureTarget(stateTextureObject->GetTarget())) {
case TextureTarget::Texture2D:
case TextureTarget::TextureCubeMap:
if (subRectEligible && dirtyRectCount >= 2) {
if (perRectUpload) {
if (!ringStaged) g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, texelSize.x());
for (SizeT r = 0; r < dirtyRectCount; ++r) {
const auto& rect = dirtyRects[r];
for (SizeT r = 0; r < uploadRectCount; ++r) {
const auto& rect = uploadRects[r];
g_GLESFuncs.glTexSubImage2D(
glUploadTarget, static_cast<GLint>(level), rect.lo.x(),
rect.lo.y(), static_cast<GLsizei>(rect.hi.x() - rect.lo.x()),
static_cast<GLsizei>(rect.hi.y() - rect.lo.y()), glFormat,
glType,
ringStaged ? UnpackRingPixelOffset(stagingBlocks[r].offset)
: static_cast<const void*>(rectShadowPtr(rect)));
: static_cast<const void*>(rectUploadPtr(rect)));
}
// The surrounding ScopedDefaultUnpackState shadow says 0.
if (!ringStaged) g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
@@ -4436,22 +4630,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
// ES 3.2 has GL_TEXTURE_CUBE_MAP_ARRAY natively and it stores exactly
// like a 2D array whose depth is 6 * the cube count.
case TextureTarget::TextureCubeMapArray:
if (subRectEligible && dirtyRectCount >= 2) {
if (perRectUpload) {
if (!ringStaged) {
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, texelSize.x());
g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, texelSize.y());
g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, imageHeight);
}
for (SizeT r = 0; r < dirtyRectCount; ++r) {
const auto& rect = dirtyRects[r];
for (SizeT r = 0; r < uploadRectCount; ++r) {
const auto box = ToBackendBox(uploadRects[r]);
g_GLESFuncs.glTexSubImage3D(
glUploadTarget, static_cast<GLint>(level), rect.lo.x(),
rect.lo.y(), rect.lo.z(),
static_cast<GLsizei>(rect.hi.x() - rect.lo.x()),
static_cast<GLsizei>(rect.hi.y() - rect.lo.y()),
static_cast<GLsizei>(rect.hi.z() - rect.lo.z()), glFormat,
glType,
glUploadTarget, static_cast<GLint>(level), box.x, box.y, box.z,
box.w, box.h, box.d, glFormat, glType,
ringStaged ? UnpackRingPixelOffset(stagingBlocks[r].offset)
: static_cast<const void*>(rectShadowPtr(rect)));
: static_cast<const void*>(rectUploadPtr(uploadRects[r])));
}
if (!ringStaged) {
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
@@ -4460,14 +4650,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
} else if (subRectEligible) {
if (!ringStaged) {
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, texelSize.x());
g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, texelSize.y());
g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, imageHeight);
}
const auto box = ToBackendBox(dirtyRegion);
g_GLESFuncs.glTexSubImage3D(
glUploadTarget, static_cast<GLint>(level), dirtyRegion.lo.x(),
dirtyRegion.lo.y(), dirtyRegion.lo.z(),
static_cast<GLsizei>(regionSize.x()),
static_cast<GLsizei>(regionSize.y()),
static_cast<GLsizei>(regionSize.z()), glFormat, glType,
glUploadTarget, static_cast<GLint>(level), box.x, box.y, box.z, box.w, box.h,
box.d, glFormat, glType,
ringStaged ? UnpackRingPixelOffset(stagingBlocks[0].offset)
: static_cast<const void*>(regionPtr));
if (!ringStaged) {
@@ -5000,6 +5188,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
g_boundTexturesCache;
StateBackendObjectRegistry<MG_State::GLState::ITextureObject, BackendTextureObject> g_backendTextureObjects;
void NoteDriverSideTextureWrite(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject) {
SharedPtr<MG_State::GLState::ITextureObject> storage = textureObject;
// Bounded: GL resolves a view of a view to the original storage, so this takes one step.
for (Uint hop = 0; storage && storage->IsTextureView() && hop < 4; ++hop) {
storage = storage->GetViewStorageOwner();
}
if (!storage) return;
auto* slot = g_backendTextureObjects.Find(storage.get());
auto& backendObj = slot ? *slot : g_backendTextureObjects.GetOrCreate(storage);
if (!backendObj) {
backendObj = MakeShared<BackendTextureObject>();
}
backendObj->NoteDriverSideWrite();
}
} // namespace TextureImpl
namespace FramebufferImpl {
@@ -5160,6 +5363,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
return false;
}
backendTextureObject->SyncMipmapsToBackend(textureObject);
// From here on every draw, clear and blit into this framebuffer can write the
// texture behind the shadow's back.
TextureImpl::NoteDriverSideTextureWrite(textureObject);
if (attachmentObject.IsLayered()) {
g_GLESFuncs.glFramebufferTexture(glFBOTarget, glBackendAttachment,
backendTextureObject->GetBackendTextureId(),
@@ -6702,7 +6908,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
// re-serialised copy either way, and adopting that copy for every vertex
// shader would put every one of them through a round trip they do not need
// - which is not free: it cost the create-indirect retrace 0.15 SSIM the
// first time this gate was missing.
// first time this gate was missing (the original capture; the case is now
// minecraft-1.21.1-neoforge-create-indirect-in-world-align1024).
effectiveSpirv = &splitArrayInputSpirv;
}
@@ -6710,8 +6917,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
// blocks - the optimizer hands back a re-serialised copy either way, and taking
// that copy for a module it did not rewrite is not free (it cost the
// create-indirect retrace 0.15 SSIM when the array-input split first missed
// this gate). The report has to be per stage, not cumulative: a fragment shader
// consuming the same block reports a name the vertex stage already reported,
// this gate; the case is now
// minecraft-1.21.1-neoforge-create-indirect-in-world-align1024). The report has to be
// per stage, not cumulative: a fragment shader consuming the same block reports a
// name the vertex stage already reported,
// and its own rewrite must still be taken or the two stages stop matching.
Vector<unsigned int> flattenedXfbSpirv;
if (!xfbCaptureBlockNames.empty()) {
@@ -7409,8 +7618,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
// per stage below so a producer and its consumer keep naming the same block.
// Gated twice over, because a re-serialised module is not free (it cost the
// create-indirect retrace 0.15 SSIM the first time the array-input split missed
// its gate): only a tessellation or geometry stage can declare blocks in both
// directions at all, and even then the probe has to FIND a collision before any
// its gate; the case is now
// minecraft-1.21.1-neoforge-create-indirect-in-world-align1024): only a tessellation
// or geometry stage can declare blocks in both directions at all, and even then the
// probe has to FIND a collision before any
// stage is rewritten.
std::set<String> collidingIoBlockNames;
std::set<String> declaredIoBlockNames;
+23
View File
@@ -478,6 +478,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
// (the UBO binding walk) skip the probe while their pre-pass epoch read
// matches, exactly like the per-VAO memo stamps.
Uint64 drawCleanEpoch = 0;
// Frame serial whose completion retires the last glCopyBufferSubData the upload
// ring staged INTO this store (FlushPendingRangesNow's ring tier); 0 = none. Until
// it retires, FlushPendingRangesNow keeps every partial range off the
// range-invalidating map: on Adreno 830 that map, on a store with a queued copy,
// loses the copy (see FlushPendingRangesNow). Draw thread only, like the flush.
Uint64 ringCopyRetireSerial = 0;
// Zero-copy coherent persistent map (EXT_buffer_storage): the GL store is
// immutable, persistently+coherently mapped, and persistentPtr is what the app
// (and the frontend PipeResource) write into directly. While set, draw-time
@@ -976,6 +982,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
// carrier the storage is an integer texture holding codes and glGetTexImage still owes
// the application floats.
Bool RequiresImageBindableStorage() const { return m_imageBindableStorageRequired; }
// See m_driverMayHoldGpuWrites. Callers holding the STATE texture use
// NoteDriverSideTextureWrite, which also reaches the storage behind a view.
void NoteDriverSideWrite() { m_driverMayHoldGpuWrites = true; }
void Bind(GLenum target, Uint unit = TempTextureUnit);
Uint GetBackendTextureId() const;
@@ -1059,6 +1068,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool m_isInitialized = false;
Bool m_imageBindableStorageRequired = false;
Bool m_backendStorageImmutable = false;
// Whether the driver texture may hold texels the shadow never saw. Set the first time
// anything other than a texel upload can write it - an application framebuffer
// attachment, a writable image binding, a copy or blit into it, a driver-side mipmap
// generation - and never cleared. Until then the driver holds, outside a level's
// pending writes, exactly the shadow bytes it was last sent, so those writes may go up
// as their union box (one unpack-ring job, which is what Mali needs); from then on
// only the texels the client wrote may go up, since the shadow is stale everywhere
// else. See SyncMipmapsToBackend.
Bool m_driverMayHoldGpuWrites = false;
// Latches the "this driver has no buffer textures" report to once per texture. The
// report is emitted from the respecify path, which bails before recording the state
// it was asked to apply - so without the latch the texture stays permanently dirty
@@ -1126,6 +1144,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
SharedPtr<BackendTextureObject>& SyncTextureObjectToBackend(
const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
Bool imageBindableStorageRequired = false);
// Every path that lets the DRIVER write a texture's texels calls this (see
// BackendTextureObject::m_driverMayHoldGpuWrites): an application framebuffer attachment,
// a writable image binding, a copy, blit or mipmap generation into it. A view writes its
// storage texture's image, so the storage texture is the one marked.
void NoteDriverSideTextureWrite(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject);
// Brings every texture the next draw reads - the touched units' bindings and the draw
// FBO's texture attachments - onto the backend, through the two borrowed-pair memos
// documented at their definitions. Declared here so tests can drive those memos directly.
@@ -0,0 +1,282 @@
// Included in DirectGLES.cpp after the native packed-word readback helper.
// A storage promotion needs the driver's own texels as owned tight rows, not
// client PACK layout or a fallback into a frontend texture's potentially stale
// shadow.
class ScopedWireTextureReadLevel {
public:
ScopedWireTextureReadLevel(GLuint texture, GLenum target, GLint level)
: m_texture(texture), m_target(target) {
switch (target) {
case GL_TEXTURE_2D: m_binding = GL_TEXTURE_BINDING_2D; break;
case GL_TEXTURE_3D: m_binding = GL_TEXTURE_BINDING_3D; break;
case GL_TEXTURE_2D_ARRAY: m_binding = GL_TEXTURE_BINDING_2D_ARRAY; break;
case GL_TEXTURE_CUBE_MAP: m_binding = GL_TEXTURE_BINDING_CUBE_MAP; break;
case GL_TEXTURE_CUBE_MAP_ARRAY: m_binding = GL_TEXTURE_BINDING_CUBE_MAP_ARRAY; break;
default: return;
}
GLint previous = 0;
DrainESErrors();
g_GLESFuncs.glGetIntegerv(m_binding, &previous);
g_GLESFuncs.glBindTexture(m_target, m_texture);
g_GLESFuncs.glGetTexParameteriv(m_target, GL_TEXTURE_BASE_LEVEL, &m_baseLevel);
g_GLESFuncs.glGetTexParameteriv(m_target, GL_TEXTURE_MAX_LEVEL, &m_maxLevel);
m_saved = g_GLESFuncs.glGetError() == GL_NO_ERROR;
if (m_saved) {
// ES framebuffer completeness can reject a defined level whose
// base level or intervening mip chain is missing/inconsistent.
// Read this existing native image as the sole base level. The
// logical GL parameters and every cached copy remain unchanged.
g_GLESFuncs.glTexParameteri(m_target, GL_TEXTURE_BASE_LEVEL, level);
g_GLESFuncs.glTexParameteri(m_target, GL_TEXTURE_MAX_LEVEL, level);
m_valid = g_GLESFuncs.glGetError() == GL_NO_ERROR;
}
g_GLESFuncs.glBindTexture(m_target, static_cast<GLuint>(previous));
}
~ScopedWireTextureReadLevel() {
if (!m_saved) return;
// The packed-word path can use its own texture scratch binding.
// Save the binding at restoration time too; do not assume an active
// texture unit or make the backend's binding cache forget a bind.
GLint previous = 0;
g_GLESFuncs.glGetIntegerv(m_binding, &previous);
g_GLESFuncs.glBindTexture(m_target, m_texture);
g_GLESFuncs.glTexParameteri(m_target, GL_TEXTURE_BASE_LEVEL, m_baseLevel);
g_GLESFuncs.glTexParameteri(m_target, GL_TEXTURE_MAX_LEVEL, m_maxLevel);
g_GLESFuncs.glBindTexture(m_target, static_cast<GLuint>(previous));
}
Bool Valid() const { return m_valid; }
private:
GLuint m_texture;
GLenum m_target;
GLenum m_binding = GL_NONE;
GLint m_baseLevel = 0, m_maxLevel = 0;
Bool m_saved = false, m_valid = false;
};
static Bool ReadWireColorRowsTight(GLsizei width, GLsizei height, GLenum format, GLenum type,
Bool forceOpaqueAlpha, const NormalizedImageCarrierRead& carrier,
Vector<Uint8>& bytes) {
ReadbackChannelMapping mapping{};
if (!GetReadbackChannelMapping(format, mapping)) return false;
const SizeT pixelBytes = GetReadbackDstPixelSize(mapping, type);
if (!pixelBytes || width <= 0 || height <= 0) return false;
const GLenum componentType = QueryReadAttachmentComponentType();
const Bool integerAttachment = componentType == GL_INT || componentType == GL_UNSIGNED_INT;
if (carrier.Active() ? (mapping.isInteger || !integerAttachment)
: mapping.isInteger != integerAttachment) return false;
GLint implFormat = 0, implType = 0;
g_GLESFuncs.glGetIntegerv(GL_IMPLEMENTATION_COLOR_READ_FORMAT, &implFormat);
g_GLESFuncs.glGetIntegerv(GL_IMPLEMENTATION_COLOR_READ_TYPE, &implType);
struct Candidate { GLenum format, type; };
Vector<Candidate> candidates;
if (carrier.Active()) {
candidates.push_back({GL_RGBA_INTEGER, GL_UNSIGNED_INT});
} else if (mapping.isInteger) {
if (GetWideReadChannelCount(implFormat) > 0 && IsIntegerReadFormat(implFormat) &&
(implType == GL_INT || implType == GL_UNSIGNED_INT))
candidates.push_back({static_cast<GLenum>(implFormat), static_cast<GLenum>(implType)});
candidates.push_back({GL_RGBA_INTEGER, componentType == GL_INT ? GLenum(GL_INT) : GLenum(GL_UNSIGNED_INT)});
} else {
if (GetWideReadChannelCount(implFormat) > 0 && !IsIntegerReadFormat(implFormat) &&
(CanDecodeWideSourceType(implType) ||
(implFormat == GL_RGBA && implType == GL_UNSIGNED_INT_2_10_10_10_REV)))
candidates.push_back({static_cast<GLenum>(implFormat), static_cast<GLenum>(implType)});
if (componentType == GL_FLOAT) candidates.push_back({GL_RGBA, GL_FLOAT});
if (componentType == GL_SIGNED_NORMALIZED) {
candidates.push_back({GL_RGBA, GL_SHORT});
candidates.push_back({GL_RGBA, GL_BYTE});
} else candidates.push_back({GL_RGBA, GL_UNSIGNED_BYTE});
}
ScopedPixelPackBuffer packBuffer(0);
ScopedPackState packState(PixelStoreImpl::PackState{1, 0, 0, 0});
const SizeT pixels = static_cast<SizeT>(width) * static_cast<SizeT>(height);
Vector<Uint8> wide;
GLenum wideType = GL_NONE;
Int channels = 0;
DrainESErrors();
for (const auto candidate : candidates) {
channels = GetWideReadChannelCount(candidate.format);
wide.resize(pixels * static_cast<SizeT>(channels) * GetReadbackComponentSize(candidate.type));
g_GLESFuncs.glReadPixels(0, 0, width, height, candidate.format, candidate.type, wide.data());
if (g_GLESFuncs.glGetError() == GL_NO_ERROR) { wideType = candidate.type; break; }
}
if (wideType == GL_NONE) return false;
if (carrier.Active()) {
Vector<Uint8> decoded(pixels * 4 * sizeof(Float));
auto* dst = reinterpret_cast<Float*>(decoded.data());
const auto* src = reinterpret_cast<const Uint32*>(wide.data());
for (SizeT i = 0; i < pixels; ++i) {
for (SizeT c = 0; c < 4; ++c) {
const Float divisor = static_cast<Float>(carrier.ChannelMax[c]);
dst[i * 4 + c] = carrier.SignedNormalized
? std::max(static_cast<Float>(static_cast<Int16>(static_cast<Uint16>(src[i * 4 + c]))) / divisor, -1.0f)
: static_cast<Float>(src[i * 4 + c]) / divisor;
}
}
wide = Move(decoded); wideType = GL_FLOAT; channels = 4;
} else if (wideType == GL_UNSIGNED_INT_2_10_10_10_REV) {
Vector<Uint8> decoded(pixels * 4 * sizeof(Float));
auto* dst = reinterpret_cast<Float*>(decoded.data());
for (SizeT i = 0; i < pixels; ++i) {
Uint32 word = 0;
Memcpy(&word, wide.data() + i * sizeof(word), sizeof(word));
dst[i * 4] = static_cast<Float>(word & 1023u) / 1023.0f;
dst[i * 4 + 1] = static_cast<Float>((word >> 10) & 1023u) / 1023.0f;
dst[i * 4 + 2] = static_cast<Float>((word >> 20) & 1023u) / 1023.0f;
dst[i * 4 + 3] = static_cast<Float>((word >> 30) & 3u) / 3.0f;
}
wide = Move(decoded); wideType = GL_FLOAT; channels = 4;
}
if (channels < 4) ExpandNarrowWideRead(wide, pixels, channels, wideType);
if (forceOpaqueAlpha) ForceWideReadAlphaToOne(wide, pixels, wideType);
Vector<Uint8> converted(pixels * pixelBytes);
const SizeT sourceRowBytes = static_cast<SizeT>(width) * 4 * GetReadbackComponentSize(wideType);
for (GLsizei row = 0; row < height; ++row)
ReadbackImpl::ConvertWideReadbackRow(wide.data() + static_cast<SizeT>(row) * sourceRowBytes,
converted.data() + static_cast<SizeT>(row) * width * pixelBytes, width, wideType, mapping, type);
bytes = Move(converted);
return true;
}
static Bool ReadWireDepthStencilLevelTight(GLuint texture, TextureTarget target,
TextureUploadTarget uploadTarget, GLint level,
const IntVec3& extent, GLenum format, GLenum type,
Vector<Uint8>& bytes) {
const SizeT pixelBytes = MG_Util::GetInputBytesPerPixel(
MG_Util::ConvertGLEnumToTextureInputFormat(format), MG_Util::ConvertGLEnumToTexturePixelDataType(type));
if (!pixelBytes) return false;
const Bool needDepth = format == GL_DEPTH_COMPONENT || format == GL_DEPTH_STENCIL;
const Bool needStencil = format == GL_STENCIL_INDEX || format == GL_DEPTH_STENCIL;
ScopedFramebufferBinding bindings(true, false);
GLuint fbo = 0;
g_GLESFuncs.glGenFramebuffers(1, &fbo);
struct DeleteFbo { GLuint id; ~DeleteFbo() { g_GLESFuncs.glDeleteFramebuffers(1, &id); } } cleanup{fbo};
FramebufferImpl::BindFramebufferId(GL_READ_FRAMEBUFFER, fbo);
ScopedPixelPackBuffer packBuffer(0);
ScopedPackState packState(PixelStoreImpl::PackState{1, 0, 0, 0});
const GLenum nativeTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum(target);
const GLenum point = needDepth ? (needStencil ? GL_DEPTH_STENCIL_ATTACHMENT : GL_DEPTH_ATTACHMENT)
: GL_STENCIL_ATTACHMENT;
const SizeT count = static_cast<SizeT>(extent.x()) * extent.y();
Vector<Uint8> result(count * extent.z() * pixelBytes);
if (g_GLESFuncs.glMemoryBarrier) g_GLESFuncs.glMemoryBarrier(GL_ALL_BARRIER_BITS);
g_GLESFuncs.glFinish();
for (GLint z = 0; z < extent.z(); ++z) {
if (nativeTarget == GL_TEXTURE_3D || nativeTarget == GL_TEXTURE_2D_ARRAY ||
nativeTarget == GL_TEXTURE_CUBE_MAP_ARRAY)
g_GLESFuncs.glFramebufferTextureLayer(GL_READ_FRAMEBUFFER, point, texture, level, z);
else
g_GLESFuncs.glFramebufferTexture2D(GL_READ_FRAMEBUFFER, point,
TextureImpl::ConvertTextureUploadTargetToBackendGLEnum(uploadTarget), texture, level);
g_GLESFuncs.glReadBuffer(GL_NONE);
if (g_GLESFuncs.glCheckFramebufferStatus(GL_READ_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) return false;
Vector<Float> depth;
Vector<Uint8> stencil;
if (needDepth && !ReadDepthValuesNative(0, 0, extent.x(), extent.y(), depth)) return false;
if (needStencil && !ReadStencilBytesNative(0, 0, extent.x(), extent.y(), stencil)) return false;
auto* dst = result.data() + count * z * pixelBytes;
for (SizeT i = 0; i < count; ++i) {
if (format == GL_DEPTH_STENCIL) {
const Uint32 s = stencil[i];
if (type == GL_UNSIGNED_INT_24_8) {
const Uint32 packed = (NormalizedDepthToUnsigned(depth[i], 16777215.0) << 8) | s;
Memcpy(dst + i * pixelBytes, &packed, sizeof(packed));
} else if (type == GL_FLOAT_32_UNSIGNED_INT_24_8_REV) {
Memcpy(dst + i * pixelBytes, &depth[i], sizeof(Float));
Memcpy(dst + i * pixelBytes + sizeof(Float), &s, sizeof(s));
} else return false;
} else if (type == GL_FLOAT) {
const Float value = needDepth ? depth[i] : static_cast<Float>(stencil[i]);
Memcpy(dst + i * pixelBytes, &value, sizeof(value));
} else if (type == GL_HALF_FLOAT) {
const Uint16 value = MG_Util::EncodeFloatToHalfBits(
needDepth ? depth[i] : static_cast<Float>(stencil[i]));
Memcpy(dst + i * pixelBytes, &value, sizeof(value));
} else {
const Bool signedType = type == GL_BYTE || type == GL_SHORT || type == GL_INT;
const Double maximum = signedType
? (pixelBytes == 1 ? 127.0 : pixelBytes == 2 ? 32767.0 : 2147483647.0)
: (pixelBytes == 1 ? 255.0 : pixelBytes == 2 ? 65535.0 : 4294967295.0);
const Uint32 value = needDepth ? NormalizedDepthToUnsigned(depth[i], maximum) : stencil[i];
if (pixelBytes == 1) dst[i] = static_cast<Uint8>(value);
else if (pixelBytes == 2) { const Uint16 v = static_cast<Uint16>(value); Memcpy(dst + i * 2, &v, 2); }
else if (pixelBytes == 4) Memcpy(dst + i * 4, &value, 4);
else return false;
}
}
}
bytes = Move(result);
return true;
}
Bool ReadTextureLevelTight(GLuint texture, TextureTarget target, TextureUploadTarget uploadTarget,
TextureInternalFormat logicalFormat, GLint level, const IntVec3& logicalExtent,
Bool sourceUsesImageCarrier, GLenum format, GLenum type, Vector<Uint8>& bytes) {
if (!texture || level < 0 || TextureImpl::IsMultisampleTextureTarget(target)) return false;
const IntVec3 extent = TextureImpl::GetBackendUploadSize(target, logicalExtent);
const GLenum nativeTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum(target);
ScopedWireTextureReadLevel readLevel(texture, nativeTarget, level);
if (!readLevel.Valid()) return false;
if (format == GL_DEPTH_COMPONENT || format == GL_DEPTH_STENCIL || format == GL_STENCIL_INDEX)
return ReadWireDepthStencilLevelTight(texture, target, uploadTarget, level, extent, format, type, bytes);
ReadbackChannelMapping mapping{};
if (!GetReadbackChannelMapping(format, mapping)) return false;
const SizeT pixelBytes = GetReadbackDstPixelSize(mapping, type);
if (!pixelBytes || extent.x() <= 0 || extent.y() <= 0 || extent.z() <= 0) return false;
const GLenum faceTarget = TextureImpl::ConvertTextureUploadTargetToBackendGLEnum(uploadTarget);
const auto widening = sourceUsesImageCarrier
? TextureImpl::GetImageBindableStorageWidening(logicalFormat) : TextureImpl::ImageBindableStorageWidening{};
const SizeT sliceBytes = static_cast<SizeT>(extent.x()) * extent.y() * pixelBytes;
Vector<Uint8> result(sliceBytes * static_cast<SizeT>(extent.z()));
if (g_GLESFuncs.glMemoryBarrier) g_GLESFuncs.glMemoryBarrier(GL_ALL_BARRIER_BITS);
g_GLESFuncs.glFinish();
// A raw packed word may have redundant encodings or NaN payloads. Read
// it through R32UI when the source really still uses that native layout.
const Bool rawPacked = !widening && MG_Util::PixelStoreProcessor::IsRawPackedPixelTransfer(
logicalFormat, MG_Util::ConvertGLEnumToTextureInputFormat(format),
MG_Util::ConvertGLEnumToTexturePixelDataType(type));
if (rawPacked && pixelBytes == sizeof(Uint32)) {
const GLint face = uploadTarget >= TextureUploadTarget::CubeMapPositiveX &&
uploadTarget <= TextureUploadTarget::CubeMapNegativeZ
? static_cast<GLint>(uploadTarget) - static_cast<GLint>(TextureUploadTarget::CubeMapPositiveX) : 0;
for (GLint layer = 0; layer < extent.z(); ++layer)
if (!ReadPackedLevelWordsViaScratch(texture, nativeTarget, level, face + layer, extent.x(), extent.y(),
reinterpret_cast<Uint32*>(result.data() + static_cast<SizeT>(layer) * sliceBytes))) return false;
bytes = Move(result);
return true;
}
NormalizedImageCarrierRead carrier{};
if (widening && widening.CarriesNormalizedCodes()) {
for (SizeT c = 0; c < 4; ++c) carrier.ChannelMax[c] = widening.ChannelMax[c];
carrier.SignedNormalized = widening.SignedNormalized;
}
TempFBOBinder binder(true);
auto& framebuffer = binder.Framebuffer();
for (GLint layer = 0; layer < extent.z(); ++layer) {
if (nativeTarget == GL_TEXTURE_3D || nativeTarget == GL_TEXTURE_2D_ARRAY || nativeTarget == GL_TEXTURE_CUBE_MAP_ARRAY)
ScratchFBOImpl::EnsureColorAttachmentLayer(framebuffer, GL_READ_FRAMEBUFFER, texture, level, layer);
else
ScratchFBOImpl::EnsureColorAttachment2D(framebuffer, GL_READ_FRAMEBUFFER, texture, faceTarget, level);
ScratchFBOImpl::EnsureReadBuffer(framebuffer, GL_COLOR_ATTACHMENT0);
const GLenum status = g_GLESFuncs.glCheckFramebufferStatus(GL_READ_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) {
MGLOG_E_ONCE("Espryt wire texture readback: framebuffer status=0x%x texture=%u level=%d target=0x%x",
status, texture, level, nativeTarget);
return false;
}
Vector<Uint8> slice;
if (!ReadWireColorRowsTight(extent.x(), extent.y(), format, type,
TextureImpl::BackendTextureFormatAddsAlpha(logicalFormat, target), carrier, slice) ||
slice.size() != sliceBytes) return false;
Memcpy(result.data() + static_cast<SizeT>(layer) * sliceBytes, slice.data(), sliceBytes);
}
bytes = Move(result);
return true;
}
@@ -344,7 +344,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Any renderer instance this assignment replaces is destroyed here;
// fence/timer-query handles stamped with the old generation go stale.
BumpRendererGeneration();
pVulkanRenderer = MakeUnique<MG_Backend::DirectVulkan::VulkanRenderer>(nativeWindow);
VulkanRendererConfig config;
config.SwapInterval = GetRequestedSwapInterval();
pVulkanRenderer = MakeUnique<MG_Backend::DirectVulkan::VulkanRenderer>(nativeWindow, config);
MOBILEGL_ASSERT(pVulkanRenderer != nullptr, "InitWindowSurface: VulkanRenderer creation failed");
pVulkanRenderer->Initialize();
return true;
@@ -455,6 +457,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return BackendObject::SwapEGLBuffers(dpy, draw);
}
void BackendObject_DirectVulkan::SetEGLSwapInterval(Int interval) {
// Same lock as SwapEGLBuffers: the renderer reads the request in Present.
const std::lock_guard<std::recursive_mutex> lock(m_eglStateMutex);
BackendObject::SetEGLSwapInterval(interval);
}
void BackendObject_DirectVulkan::ReleaseEGLSurface(EGLSurface surface) {
const std::lock_guard<std::recursive_mutex> lock(m_eglStateMutex);
BackendObject::ReleaseEGLSurface(surface);
@@ -513,7 +521,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Vector<GLExtension> BuildAdvertisedExtensions(Bool shaderSubgroupSupported, Bool timerQueriesSupported,
Bool anisotropicFilteringSupported,
Bool nonZeroIndirectBaseInstanceSupported,
Bool cubeMapArraySupported) {
Bool cubeMapArraySupported,
Bool nvMeshShader) {
Vector<GLExtension> extensions = {
// The version tokens have to reach the version the backend actually claims:
// TargetGLVersion is {4,6,0}, and a list that stopped at OpenGL40 told an
@@ -677,6 +686,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (cubeMapArraySupported) {
extensions.push_back(E_GL_ARB_texture_cube_map_array);
}
if (nvMeshShader) {
extensions.push_back(E_GL_NV_mesh_shader);
}
return extensions;
}
@@ -696,6 +710,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static Bool funcsTableInitialized = false;
if (!funcsTableInitialized) {
funcsTable.Present = Present;
funcsTable.SetSwapInterval = SetSwapInterval;
funcsTable.GL.DrawArrays = DrawArrays;
funcsTable.GL.DrawElements = DrawElements;
funcsTable.GL.DrawElementsBaseVertex = DrawElementsBaseVertex;
@@ -34,6 +34,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool CreateEGLPbufferSurface(EGLSurface surface, EGLint width, EGLint height) override;
Bool MakeEGLCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) override;
Bool SwapEGLBuffers(EGLDisplay dpy, EGLSurface draw) override;
void SetEGLSwapInterval(Int interval) override;
void ReleaseEGLSurface(EGLSurface surface) override;
void ReleaseEGLResources() override;
@@ -76,7 +77,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Vector<GLExtension> BuildAdvertisedExtensions(Bool shaderSubgroupSupported, Bool timerQueriesSupported,
Bool anisotropicFilteringSupported,
Bool nonZeroIndirectBaseInstanceSupported,
Bool cubeMapArraySupported);
Bool cubeMapArraySupported,
Bool nvMeshShader = true);
// Format: <GPU Name>, Vulkan <Vulkan Version>, Driver <Driver Version> — the exact
// string an initialized backend returns from GetBackendAPIVersionString (and that
@@ -0,0 +1,29 @@
// MobileGL - MobileGL/MG_Backend/DirectVulkan/DescriptorPoolCensus.h
// 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
#pragma once
#include <cstdint>
namespace MobileGL::MG_Backend::DirectVulkan {
// A count of Magma's per-frame descriptor pools and the sets they hold (UniformManager), for
// tests. Dependency-free on purpose: the integration scenarios include it next to the system GL
// headers. They link the static library on desktop; the shipping shared library is built with
// hidden visibility, so on Android nothing outside the library can call GetDescriptorPoolCensus.
struct DescriptorPoolCensus {
bool available = false; // false when no Magma renderer is live (e.g. the DirectGLES backend)
uint32_t frameSlots = 0;
uint32_t pools = 0; // descriptor pools across every frame slot
uint32_t maxPoolsInOneSlot = 0;
uint64_t capacitySets = 0; // sum of maxSets over those pools
uint64_t cachedSets = 0; // sets allocated from them and held in the per-layout caches
uint64_t slotTrims = 0; // whole-slot trims since the renderer came up
};
DescriptorPoolCensus GetDescriptorPoolCensus();
} // namespace MobileGL::MG_Backend::DirectVulkan
@@ -687,6 +687,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::GetIntegeri_v called with null VulkanRenderer");
switch (target) {
case GL_MAX_COMPUTE_WORK_GROUP_COUNT:
if (!pVulkanRenderer) {
*data = 0;
return;
}
if (index >= 3) {
*data = 0;
return;
@@ -695,6 +699,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
pVulkanRenderer->GetPhysicalDevice().properties.limits.maxComputeWorkGroupCount[index]);
return;
case GL_MAX_COMPUTE_WORK_GROUP_SIZE:
if (!pVulkanRenderer) {
*data = 0;
return;
}
if (index >= 3) {
*data = 0;
return;
@@ -1431,4 +1439,23 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::Present called with null VulkanRenderer");
pVulkanRenderer->Present();
}
namespace {
Optional<Int> g_requestedSwapInterval;
} // namespace
void SetSwapInterval(Int interval) {
g_requestedSwapInterval = interval;
if (pVulkanRenderer) {
pVulkanRenderer->SetSwapInterval(interval);
}
}
Optional<Int> GetRequestedSwapInterval() {
return g_requestedSwapInterval;
}
DescriptorPoolCensus GetDescriptorPoolCensus() {
return pVulkanRenderer ? pVulkanRenderer->GetDescriptorPoolCensus() : DescriptorPoolCensus{};
}
} // namespace MobileGL::MG_Backend::DirectVulkan
@@ -137,4 +137,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// only exist as vkCmdWriteTimestamp results); the frontend falls back.
Int64 GetGpuTimestampNs();
void Present();
// eglSwapInterval. Remembered across renderers: one created for a later window surface
// starts from the app's last request (GetRequestedSwapInterval) instead of the default.
void SetSwapInterval(Int interval);
Optional<Int> GetRequestedSwapInterval();
} // namespace MobileGL::MG_Backend::DirectVulkan
@@ -8,6 +8,8 @@
#include "SwapchainObject.h"
#include <span>
#include "MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h"
#include "MG_State/GLState/TextureState/TextureObject2D.h"
@@ -135,12 +137,26 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
VkPresentModeKHR SwapchainObject::ChooseSwapchainPresentMode(
const Vector<VkPresentModeKHR>& availablePresentModes) {
for (auto desiredPresentMode : s_desiredPresentModes) {
for (const auto& presentMode : availablePresentModes) {
if (presentMode == desiredPresentMode) {
return presentMode;
}
const Vector<VkPresentModeKHR>& availablePresentModes, Optional<Int> swapInterval) {
static constexpr VkPresentModeKHR s_unthrottledPresentModes[]{
VK_PRESENT_MODE_IMMEDIATE_KHR, VK_PRESENT_MODE_MAILBOX_KHR, VK_PRESENT_MODE_FIFO_KHR};
static constexpr VkPresentModeKHR s_vsyncPresentModes[]{VK_PRESENT_MODE_FIFO_KHR};
static constexpr VkPresentModeKHR s_adaptiveVsyncPresentModes[]{VK_PRESENT_MODE_FIFO_RELAXED_KHR,
VK_PRESENT_MODE_FIFO_KHR};
std::span<const VkPresentModeKHR> ranked = s_desiredPresentModes;
if (swapInterval) {
if (*swapInterval == 0) {
ranked = s_unthrottledPresentModes;
} else if (*swapInterval > 0) {
ranked = s_vsyncPresentModes;
} else {
ranked = s_adaptiveVsyncPresentModes;
}
}
for (auto desiredPresentMode : ranked) {
if (std::find(availablePresentModes.begin(), availablePresentModes.end(), desiredPresentMode) !=
availablePresentModes.end()) {
return desiredPresentMode;
}
}
@@ -148,9 +164,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return availablePresentModes[0];
}
const char* SwapchainObject::GetPresentModeName(VkPresentModeKHR presentMode) {
return string_VkPresentModeKHR(presentMode);
}
void SwapchainObject::Create(VkDevice device, VkPhysicalDevice physicalDevice, VkSurfaceKHR surface,
Uint32 graphicsQueueFamily, Uint32 presentQueueFamily, Uint32 minImageCountHint,
VkExtent2D desiredExtent) {
VkExtent2D desiredExtent, Optional<Int> swapInterval) {
const auto swapchainCapabilities = GetSwapchainCapabilities(physicalDevice, surface);
MOBILEGL_ASSERT(swapchainCapabilities.IsComplete(),
"SwapchainObject::Create failed: incomplete swapchain capabilities");
@@ -169,8 +189,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MGLOG_D(" %s", string_VkPresentModeKHR(pm));
}
const auto presentMode = ChooseSwapchainPresentMode(swapchainCapabilities.presentModes);
const auto presentMode = ChooseSwapchainPresentMode(swapchainCapabilities.presentModes, swapInterval);
MGLOG_I("Picked present mode: %s", string_VkPresentModeKHR(presentMode));
m_presentMode = presentMode;
m_supportedPresentModes = swapchainCapabilities.presentModes;
const auto& swapchainCaps = swapchainCapabilities.capabilities;
Uint32 targetImageCount = std::max<Uint32>(minImageCountHint, swapchainCaps.minImageCount);
@@ -26,13 +26,23 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static SwapchainCapabilities GetSwapchainCapabilities(VkPhysicalDevice physicalDevice, VkSurfaceKHR surface);
static VkSurfaceFormatKHR ChooseSwapchainSurfaceFormat(const Vector<VkSurfaceFormatKHR>& availableFormats);
static VkPresentModeKHR ChooseSwapchainPresentMode(const Vector<VkPresentModeKHR>& availablePresentModes);
// swapInterval is the app's eglSwapInterval, empty until it asks (s_desiredPresentModes
// ranks the modes then). 0 wants IMMEDIATE, else MAILBOX, else FIFO; >= 1 wants FIFO,
// which cannot skip vblanks, so 2 and up pace like 1; a negative interval (adaptive
// vsync) wants FIFO_RELAXED, else FIFO.
static VkPresentModeKHR ChooseSwapchainPresentMode(const Vector<VkPresentModeKHR>& availablePresentModes,
Optional<Int> swapInterval = Nullopt);
static const char* GetPresentModeName(VkPresentModeKHR presentMode);
void Create(VkDevice device, VkPhysicalDevice physicalDevice, VkSurfaceKHR surface, Uint32 graphicsQueueFamily,
Uint32 presentQueueFamily, Uint32 minImageCountHint, VkExtent2D desiredExtent);
Uint32 presentQueueFamily, Uint32 minImageCountHint, VkExtent2D desiredExtent,
Optional<Int> swapInterval);
void Shutdown(VkDevice device);
VkSwapchainKHR GetHandle() const { return m_swapchain; }
VkPresentModeKHR GetPresentMode() const { return m_presentMode; }
// The surface's present modes as queried when this swapchain was created.
const Vector<VkPresentModeKHR>& GetSupportedPresentModes() const { return m_supportedPresentModes; }
const VkSurfaceFormatKHR& GetSurfaceFormat() const { return m_surfaceFormat; }
VkExtent2D GetExtent() const { return m_extent; }
// Surface-space extent (before the pre-rotation quarter-turn swap) this swapchain was
@@ -79,6 +89,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
};
VkSwapchainKHR m_swapchain = VK_NULL_HANDLE;
VkPresentModeKHR m_presentMode = VK_PRESENT_MODE_FIFO_KHR;
Vector<VkPresentModeKHR> m_supportedPresentModes;
VkSurfaceFormatKHR m_surfaceFormat{};
VkExtent2D m_extent{};
VkExtent2D m_surfaceExtent{};
@@ -303,6 +303,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_samplerResolveMemoHighWater = 0;
m_setsPerFrame = setsPerFrame;
m_peakDescriptorSetsObserved = 0;
m_trimQuietEpochs = MG_Config::Features.MagmaDescriptorTrimFrames;
// The renderer records its first frame into slot 0 before any BeginFrame runs.
m_epochSlot = 0;
m_epochOpen = true;
m_descriptorSlotTrims = 0;
m_textureManager = textureManager;
m_samplerManager = samplerManager;
@@ -312,6 +317,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
frame.activeDescriptorPoolIndex = 0;
frame.allocatedSetsThisFrame = 0;
frame.peakAllocatedSetsThisFrame = 0;
frame.capacitySets = m_setsPerFrame;
frame.quietEpochs = 0;
frame.descriptorPools.clear();
VkDescriptorPool initialPool = VK_NULL_HANDLE;
@@ -361,8 +368,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
frame.activeDescriptorPoolIndex = 0;
frame.allocatedSetsThisFrame = 0;
frame.peakAllocatedSetsThisFrame = 0;
frame.capacitySets = 0;
frame.quietEpochs = 0;
}
m_frames.clear();
m_epochSlot = 0;
m_epochOpen = false;
m_bufferManager = nullptr;
m_programFactory = nullptr;
@@ -396,6 +407,20 @@ namespace MobileGL::MG_Backend::DirectVulkan {
"UniformDescriptorBinder: new descriptor set peak observed=%u (base setsPerFrame=%u, frame=%u, pools=%zu)",
m_peakDescriptorSetsObserved, m_setsPerFrame, frameIndex, frame.descriptorPools.size());
}
// The epoch that just ended is the one of the slot begun last - this slot again on the
// drain path, the previous frame's slot on the Present path. Its peak is still unreset.
if (m_epochOpen && m_epochSlot < m_frames.size()) {
NoteEndedEpochForTrim(m_frames[m_epochSlot].peakAllocatedSetsThisFrame);
}
m_epochSlot = frameIndex;
m_epochOpen = true;
// No pending command buffer references this slot's sets here (see TrimFrameDescriptorPools
// for why that holds at both of BeginFrame's call sites), so a quiet grown slot can be
// handed back whole.
if (m_trimQuietEpochs != 0 && frame.descriptorPools.size() > 1 &&
frame.capacitySets >= kDescriptorTrimMinCapacity && frame.quietEpochs >= m_trimQuietEpochs) {
TrimFrameDescriptorPools(frame, frameIndex);
}
frame.activeDescriptorPoolIndex = 0;
frame.allocatedSetsThisFrame = 0;
frame.peakAllocatedSetsThisFrame = 0;
@@ -404,11 +429,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
// The frame's descriptor sets are recycled above, so last frame's reuse targets
// are gone: start the per-draw descriptor-reuse cache fresh this frame.
for (auto& entry : m_descriptorReuseMemo) {
entry.valid = false;
}
m_fastRebindMemo.valid = false;
m_lastBindValid = false;
InvalidateDescriptorSetMemos();
// Re-fingerprint the bound sampler set fresh this frame so any GL object address
// reuse cannot outlive a single frame (see SamplerResolveMemo). Only the entries a
// resolve has actually written can be valid, so the high-water mark bounds the
@@ -422,6 +443,68 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
void UniformManager::InvalidateDescriptorSetMemos() {
// Every VkDescriptorSet handle held outside the per-layout caches - in this backend they all
// live in this class. A trim can hand a destroyed set's handle value to a fresh allocation,
// so the bind-dedup shadow must drop too, not only the two reuse memos.
for (auto& entry : m_descriptorReuseMemo) {
entry.valid = false;
}
m_fastRebindMemo.valid = false;
m_lastBindValid = false;
}
void UniformManager::NoteEndedEpochForTrim(Uint32 epochPeakSets) {
for (auto& slot : m_frames) {
if (static_cast<Uint64>(epochPeakSets) * kDescriptorTrimHeadroom > slot.capacitySets) {
slot.quietEpochs = 0;
} else if (slot.quietEpochs < std::numeric_limits<Uint32>::max()) {
++slot.quietEpochs;
}
}
}
void UniformManager::TrimFrameDescriptorPools(FrameResources& frame, Uint32 frameIndex) {
const SizeT poolsBefore = frame.descriptorPools.size();
const Uint64 capacityBefore = frame.capacitySets;
// Holders first: the per-layout cache and every memo that may name one of these sets.
frame.descriptorSetCacheByLayout.clear();
InvalidateDescriptorSetMemos();
for (SizeT i = 1; i < frame.descriptorPools.size(); ++i) {
if (frame.descriptorPools[i].handle != VK_NULL_HANDLE) {
vkDestroyDescriptorPool(m_device, frame.descriptorPools[i].handle, nullptr);
}
}
frame.descriptorPools.resize(1);
auto& base = frame.descriptorPools[0];
// Frees every set allocated from it; the spec defines no failure for vkResetDescriptorPool.
vkResetDescriptorPool(m_device, base.handle, 0);
base.allocatedSets = 0;
frame.activeDescriptorPoolIndex = 0;
frame.capacitySets = base.maxSets;
frame.quietEpochs = 0;
++m_descriptorSlotTrims;
MGLOG_D("UniformDescriptorBinder: frame %u trimmed its descriptor pools (%zu pools / %llu sets -> 1 / %u)",
frameIndex, poolsBefore, static_cast<unsigned long long>(capacityBefore), base.maxSets);
}
DescriptorPoolCensus UniformManager::GetDescriptorPoolCensus() const {
DescriptorPoolCensus census;
census.available = true;
census.frameSlots = static_cast<uint32_t>(m_frames.size());
for (const auto& frame : m_frames) {
const auto poolCount = static_cast<uint32_t>(frame.descriptorPools.size());
census.pools += poolCount;
census.maxPoolsInOneSlot = std::max(census.maxPoolsInOneSlot, poolCount);
census.capacitySets += frame.capacitySets;
for (const auto& cacheEntryPair : frame.descriptorSetCacheByLayout) {
census.cachedSets += cacheEntryPair.second.sets.size();
}
}
census.slotTrims = m_descriptorSlotTrims;
return census;
}
void UniformManager::OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout) {
SizeT purgedSets = 0;
for (auto& frame : m_frames) {
@@ -2016,9 +2099,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto& bindingPoint = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::Uniform, frontendBinding);
const auto& bufferObject = bindingPoint.GetBoundObject();
MOBILEGL_ASSERT(bufferObject != nullptr,
"ResolveUniformBufferPayload: no UBO bound at frontend binding %u for block '%s'",
frontendBinding, program.GetUniformBlockName(static_cast<Uint32>(blockIndex)).c_str());
static thread_local Vector<Uint8> paddedUbo;
if (bufferObject == nullptr) {
// A block the program declares at a point with nothing bound - never bound, or its
// buffer was deleted while bound, which unbinds it (UnboundUniformBlockScenario). GL
// leaves what the shader READS undefined and raises no error; this used to dereference
// the null object. Answer it with a zeroed block of the reflected size instead.
const VkDeviceSize blockSize =
static_cast<VkDeviceSize>(program.GetUBOSizeAt(static_cast<Uint32>(blockIndex)));
paddedUbo.assign(static_cast<SizeT>(blockSize > 0 ? blockSize : 16), 0);
out.payload = paddedUbo.data();
out.payloadSize = static_cast<VkDeviceSize>(paddedUbo.size());
return true;
}
bufferObject->SyncPersistentMappedRange();
MOBILEGL_ASSERT(bufferObject->MappedData() != nullptr && bufferObject->GetSize() != 0,
@@ -2051,7 +2144,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
outSize = blockSize;
outData = bufferObject->MappedData() + static_cast<SizeT>(rangeStart);
if (available < blockSize) {
static thread_local Vector<Uint8> paddedUbo;
paddedUbo.assign(static_cast<SizeT>(blockSize), 0);
Memcpy(paddedUbo.data(), outData, static_cast<SizeT>(available));
outData = paddedUbo.data();
@@ -2137,32 +2229,49 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return true;
}
Uint32 UniformManager::NextDescriptorPoolMaxSets(const Vector<DescriptorPoolBucket>& pools, Bool updateAfterBind,
Uint32 initialMaxSets) {
const Uint32 minSets = std::max<Uint32>(1, initialMaxSets);
const Uint32 maxSets = std::max(minSets, kMaxDescriptorPoolSets);
Uint32 largest = 0;
for (const auto& pool : pools) {
if (pool.updateAfterBind == updateAfterBind) {
largest = std::max(largest, pool.maxSets);
}
}
if (largest == 0) {
return minSets;
}
const Uint64 doubled = static_cast<Uint64>(largest) * 2;
return static_cast<Uint32>(std::clamp<Uint64>(doubled, minSets, maxSets));
}
Bool UniformManager::GrowFrameDescriptorPool(FrameResources& frame, Uint32 frameIndex, Bool updateAfterBind) {
if (frame.descriptorPools.empty()) {
return false;
}
const auto matchingBucket = std::find_if(
frame.descriptorPools.begin(), frame.descriptorPools.end(),
[updateAfterBind](const DescriptorPoolBucket& candidate) { return candidate.updateAfterBind == updateAfterBind; });
const Uint32 currentMaxSets = matchingBucket != frame.descriptorPools.end()
? std::max<Uint32>(1, matchingBucket->maxSets)
: m_setsPerFrame;
const Uint32 grownMaxSets = currentMaxSets <= (std::numeric_limits<Uint32>::max() / 2) ? (currentMaxSets * 2)
: currentMaxSets;
Uint32 grownMaxSets = NextDescriptorPoolMaxSets(frame.descriptorPools, updateAfterBind, m_setsPerFrame);
VkDescriptorPool grownPool = VK_NULL_HANDLE;
if (!CreateDescriptorPool(grownMaxSets, updateAfterBind, grownPool)) {
MGLOG_E_ONCE("UniformDescriptorBinder::GrowFrameDescriptorPool failed: cannot create grown pool (%u -> %u sets)",
currentMaxSets, grownMaxSets);
return false;
// A large pool is a large driver allocation; if the device refuses one, settle for less
// rather than failing the draw - halving down to the base size, where the old policy lived.
while (!CreateDescriptorPool(grownMaxSets, updateAfterBind, grownPool)) {
if (grownMaxSets <= m_setsPerFrame) {
MGLOG_E_ONCE("UniformDescriptorBinder::GrowFrameDescriptorPool failed: cannot create grown pool (%u sets)",
grownMaxSets);
return false;
}
grownMaxSets = std::max(m_setsPerFrame, grownMaxSets / 2);
}
frame.descriptorPools.push_back({grownPool, grownMaxSets, 0, updateAfterBind});
frame.activeDescriptorPoolIndex = static_cast<Uint32>(frame.descriptorPools.size() - 1);
MGLOG_D(
"UniformDescriptorBinder: frame %u descriptor pool exhausted, grew pool (%u -> %u sets), poolCount=%zu",
frameIndex, currentMaxSets, grownMaxSets, frame.descriptorPools.size());
frame.capacitySets += grownMaxSets;
// Growing is the opposite of quiet: a trim must wait a full quiet run from here.
frame.quietEpochs = 0;
MGLOG_D("UniformDescriptorBinder: frame %u descriptor pool exhausted, grew a %u-set pool (updateAfterBind=%d), "
"poolCount=%zu",
frameIndex, grownMaxSets, updateAfterBind ? 1 : 0, frame.descriptorPools.size());
return true;
}
@@ -12,6 +12,7 @@
#include "VkBufferManager.h"
#include "VkSamplerManager.h"
#include "VkTextureManager.h"
#include "../DescriptorPoolCensus.h"
#include "../VkIncludes.h"
#include <Includes.h>
@@ -63,9 +64,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// FREE_DESCRIPTOR_SET_BIT) and the pool accounting is credited, so program
// churn recycles pool capacity instead of abandoning it. GPU-safe: the layout
// only dies after >1024 idle frame boundaries, so no in-flight command buffer
// references its sets. This is the only eviction path for the per-layout
// caches - a live layout's entry must never be purged (its sets would be
// unreachable pool slots), so there is deliberately no age-based sweep here.
// references its sets. This is the only PER-LAYOUT eviction path for the
// caches - a live layout's entry must never be purged on its own (its sets would be
// unreachable pool slots), so there is deliberately no age-based sweep here. The
// other path, TrimFrameDescriptorPools, drops a slot's whole cache together with
// the pools behind it.
void OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout);
// One record per visited CombinedImageSampler DESCRIPTOR (post fallback substitution,
// in binding order, and within a binding in array-element order): the resolved texture
@@ -130,13 +133,46 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static Bool ProgramSamplesOnlySingleLevelTextures(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj);
private:
// ---- per-frame descriptor pools ----------------------------------------------------
// Each frame slot owns a list of pools. The first is the base pool (non-update-after-bind,
// setsPerFrame sets) and is never destroyed while the manager lives; every later one was
// added by GrowFrameDescriptorPool when an allocation found no room. A pool serves one
// updateAfterBind flavour, because a set can only come from a pool created with the flag
// its layout was.
struct DescriptorPoolBucket {
VkDescriptorPool handle = VK_NULL_HANDLE;
Uint32 maxSets = 0;
Uint32 allocatedSets = 0;
Bool updateAfterBind = false;
};
// One pool's maxSets never exceeds this. It bounds one pool's driver memory: CreateDescriptorPool
// declares 8 descriptors of each of 6 types per set, so a 4096-set pool asks for 196,608
// descriptors - about 12 MiB at 64 bytes each on a driver that backs every declared one.
static constexpr Uint32 kMaxDescriptorPoolSets = 4096;
// The growth policy, pure so the host tests can drive it: the size of the next pool a slot
// adds for `updateAfterBind` layouts, given the slot's existing pools. Twice the LARGEST pool
// of that flavour, capped at kMaxDescriptorPoolSets; `initialMaxSets` when the slot has no
// pool of that flavour yet. So a frame that needs N sets adds O(log N) pools while N stays
// under the cap, and one more pool per kMaxDescriptorPoolSets sets beyond it. Taking the
// largest (not the first) pool is the point: the first same-flavour pool is the oldest,
// smallest one, and doubling IT made every grown pool the same size.
static Uint32 NextDescriptorPoolMaxSets(const Vector<DescriptorPoolBucket>& pools, Bool updateAfterBind,
Uint32 initialMaxSets);
// Trim policy (see TrimFrameDescriptorPools). A slot is "quiet" in an epoch whose peak set
// count times kDescriptorTrimHeadroom still fits its capacity; a slot that has been quiet for
// MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES consecutive epochs, and holds at least
// kDescriptorTrimMinCapacity sets, is trimmed. The headroom is what keeps the trim from
// thrashing: geometric growth leaves a re-grown slot at least a third full (the pool a growth
// adds is at most twice the largest before it, so at most twice everything the slot held, and
// the epoch that grew it needed more than that), so the epoch that grew a slot is never quiet
// for it. The floor keeps a slot sized for ordinary
// frames from being trimmed at all - the trim exists to give back a burst.
static constexpr Uint32 kDescriptorTrimHeadroom = 4;
static constexpr Uint32 kDescriptorTrimMinCapacity = 1024;
// For tests (DescriptorPoolCensus.h): pools, capacity, cached sets and trims over every slot.
DescriptorPoolCensus GetDescriptorPoolCensus() const;
private:
// A cached descriptor set together with the pool it was allocated from, so a
// layout-destroyed purge can vkFreeDescriptorSets it back and credit the
@@ -151,6 +187,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint32 cursor = 0;
};
// descriptorSetCacheByLayout is the per-frame set cache: BeginFrame rewinds every cursor, so
// a slot re-writes the sets it allocated in earlier frames instead of allocating new ones, and
// the cache only ever grows - except through TrimFrameDescriptorPools, which gives a slot's
// whole burst back at once (and OnDescriptorSetLayoutDestroyed, one dead layout at a time).
struct FrameResources {
Vector<DescriptorPoolBucket> descriptorPools;
UnorderedMap<VkDescriptorSetLayout, DescriptorSetCacheEntry> descriptorSetCacheByLayout;
@@ -158,6 +198,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint32 activeDescriptorPoolIndex = 0;
Uint32 allocatedSetsThisFrame = 0;
Uint32 peakAllocatedSetsThisFrame = 0;
// Sum of maxSets over descriptorPools, kept in step by Initialize, Grow and Trim.
Uint64 capacitySets = 0;
// Consecutive epochs (see NoteEndedEpochForTrim) that were quiet for THIS slot's capacity.
Uint32 quietEpochs = 0;
};
static Bool ResolveSamplerTexture(const MG_State::GLState::ProgramObject& program,
@@ -283,6 +327,28 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const Vector<Uint32>& dynamicOffsets);
Bool CreateDescriptorPool(Uint32 maxSets, Bool updateAfterBind, VkDescriptorPool& outPool) const;
Bool GrowFrameDescriptorPool(FrameResources& frame, Uint32 frameIndex, Bool updateAfterBind);
// An epoch is the stretch between two BeginFrame calls: one frame, or the part of one between
// two drains (VulkanRenderer::TryDrainFrameTransients). Called by BeginFrame with the epoch
// that just ended, it updates every slot's quiet count - a burst in any slot counts against
// all of them, so bursts that land in the other slots still hold a slot's trim off.
void NoteEndedEpochForTrim(Uint32 epochPeakSets);
// Gives a slot's whole descriptor burst back: clears its per-layout cache, destroys every
// grown pool and resets the base pool, so the slot re-grows geometrically from setsPerFrame.
// Whole-slot rather than per-set: vkFreeDescriptorSets fragments a pool and can never empty
// a grown one. Called only from BeginFrame, which runs only when no pending command buffer
// references the slot's sets:
// - Present path (VulkanRenderer::Present, after WaitAndAcquireNextImage): the slot's
// imageInFlightFence has been waited. That fence rides the slot's LAST submission - the
// Present submit - and every mid-frame submission of the slot (FlushPendingCommands,
// SplitOversizedRecording, readbacks) went to the same queue earlier, so the fence's
// signal covers them (vkQueueSubmit's fence scope includes all earlier submissions).
// - Drain path (VulkanRenderer::TryDrainFrameTransients): every submission has been
// observed complete and no recording is open.
// Sets are per slot: a slot's sets are only ever bound into recordings made while it is the
// current slot, so trimming one slot never touches another slot's in-flight work.
void TrimFrameDescriptorPools(FrameResources& frame, Uint32 frameIndex);
// Drops every handle this class holds to a descriptor set outside the per-layout caches.
void InvalidateDescriptorSetMemos();
VkResult AllocateDescriptorSetsFromActivePool(
Uint32 frameIndex, const ProgramFactory::VkProgramObject& programObj, VkDescriptorSet& outDescriptorSet);
VkResult AcquireDescriptorSet(Uint32 frameIndex,
@@ -300,6 +366,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint32 m_maxBindings = 0;
Uint32 m_setsPerFrame = 0;
Uint32 m_peakDescriptorSetsObserved = 0;
// MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES, latched at Initialize (0 = never trim).
Uint32 m_trimQuietEpochs = 0;
// The slot whose epoch is open (the last one BeginFrame was called for); valid once
// m_epochOpen is set. Its peak is the ended epoch's peak at the next BeginFrame.
Uint32 m_epochSlot = 0;
Bool m_epochOpen = false;
Uint64 m_descriptorSlotTrims = 0;
VkTextureManager* m_textureManager = nullptr;
VkSamplerManager* m_samplerManager = nullptr;
mutable SharedPtr<MG_State::GLState::ITextureObject> m_fallbackTexture2D;
@@ -1293,6 +1293,42 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
VkTextureManager::TextureResource* VkTextureManager::FindTextureResource(
MG_State::GLState::ITextureObject& textureOrView) {
auto it = m_textureResources.find(MakeTextureIdentity(&StorageTextureOf(textureOrView)));
return it != m_textureResources.end() ? &it->second : nullptr;
}
Bool VkTextureManager::HasLiveImage(MG_State::GLState::ITextureObject& textureOrView) const {
auto it = m_textureResources.find(MakeTextureIdentity(&StorageTextureOf(textureOrView)));
return it != m_textureResources.end() && it->second.image != VK_NULL_HANDLE;
}
Bool VkTextureManager::HasPendingTexelUpload(MG_State::GLState::ITextureObject& textureOrView) const {
auto& storage = StorageTextureOf(textureOrView);
auto it = m_textureResources.find(MakeTextureIdentity(&storage));
if (it == m_textureResources.end() || it->second.image == VK_NULL_HANDLE) {
return false;
}
return it->second.syncedShapeVersion == storage.GetShapeVersion() &&
it->second.syncedContentVersion != storage.GetContentVersion();
}
Bool VkTextureManager::WasTextureWrittenThisRecording(MG_State::GLState::ITextureObject& textureOrView) const {
auto it = m_textureResources.find(MakeTextureIdentity(&StorageTextureOf(textureOrView)));
return it != m_textureResources.end() && it->second.lastRecordingWriteGeneration == m_recordingGeneration;
}
void VkTextureManager::StampTextureRecordingWrite(MG_State::GLState::ITextureObject* texture) {
if (texture == nullptr) {
return;
}
auto it = m_textureResources.find(MakeTextureIdentity(&StorageTextureOf(*texture)));
if (it != m_textureResources.end()) {
StampResourceRecordingWrite(it->second);
}
}
void VkTextureManager::UpdateTrackedImageLayout(MG_State::GLState::ITextureObject* texture, VkImageLayout newLayout) {
MOBILEGL_ASSERT(texture != nullptr, "UpdateTrackedImageLayout: texture is null");
auto it = m_textureResources.find(MakeTextureIdentity(texture));
@@ -1321,7 +1357,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
"UpdateTrackedImageLayoutAfterAttachmentWrite: textureId=%d mipLevel=%u out of range %u",
texture->GetExternalIndex(), writtenMipLevel, resource.mipLevels);
// Pre-pass stream bookkeeping: the render pass that just ended wrote this image.
StampResourceRecordingUse(resource);
StampResourceRecordingWrite(resource);
if (resource.layout != newLayout && resource.mipLevels > 1) {
VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
@@ -1435,8 +1471,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
resource->aspect, 0, resource->mipLevels);
MOBILEGL_ASSERT(ok, "TransitionTextureForStorageImage: transition failed for textureId=%d",
texture.GetExternalIndex());
// Pre-pass stream bookkeeping: a command referencing the image was recorded.
StampResourceRecordingUse(*resource);
// Pre-pass stream bookkeeping: a command referencing the image was recorded, and an
// image unit may store to it.
StampResourceRecordingWrite(*resource);
return ok;
}
@@ -2835,18 +2872,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkDeviceSize offset = 0;
// Sub-region upload (a small sprite in a big atlas): only the dirty box
// is staged and copied. texelSize keeps the LEVEL extent - the staging
// row copy needs it for the shadow's stride. Plain color formats only;
// the RGB-expand and depth(+stencil) conversion passes rewrite whole
// levels and stay full-size.
// row copy needs it for the stride. Color formats only (an RGB-expanded
// level is cut from its expanded bytes); the depth(+stencil) conversion
// passes rewrite whole levels and stay full-size.
Bool subRegion = false;
IntVec3 regionLo = {0, 0, 0};
IntVec3 regionSize = {0, 0, 0};
SizeT texelBytes = 0;
// Scatter refinement of the single dirty box: when the storage's rect
// list reports the writes' true footprint (~100 sprites whose union box
// spans the whole atlas), each rect is staged tightly and copied with
// its own VkBufferImageCopy in ONE vkCmdCopyBufferToImage. Empty means
// "stage the one box above". Only set while subRegion.
// The writes' exact footprint when it is more than one box (~100 sprites
// whose union box spans the whole atlas): each rect is staged tightly and
// copied with its own VkBufferImageCopy in ONE vkCmdCopyBufferToImage.
// Empty means "stage the one box above". Only set while subRegion.
Vector<MG_State::GLState::MipmapDirtyRegion> rects;
};
@@ -2858,6 +2894,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
targets.push_back(uploadTarget);
}
const TextureFormatInfo formatInfo = ResolveTextureFormatInfo(mipmapTexture.GetFormat());
Vector<MG_State::GLState::MipmapDirtyRegion> dirtyFootprint;
VkDeviceSize stagingSize = 0;
for (const TextureUploadTarget target : targets) {
@@ -2900,34 +2937,48 @@ namespace MobileGL::MG_Backend::DirectVulkan {
uploadItem.source = source;
uploadItem.offset = stagingSize;
uploadItem.uploadByteSize = byteSize;
if (!formatInfo.expandRgbToRgba &&
GetAspectMaskForFormat(outResource.format) == VK_IMAGE_ASPECT_COLOR_BIT) {
// The RGB -> RGBA expansion rewrites the level texel for texel into a tight buffer
// of its own, so it runs first and the dirty region below is taken from whichever
// bytes are staged. Uploading an expanded level WHOLE put the shadow's stale texels
// over whatever the GPU had drawn into the rest of it.
if (formatInfo.expandRgbToRgba) {
const Bool expanded = ExpandRgbSourceToRgba(source, byteSize, texelSize, formatInfo,
uploadItem.expandedData);
MOBILEGL_ASSERT(expanded,
"UploadDirtyMipLevels: failed to expand RGB textureId=%d target=%s level=%u to RGBA staging data",
mipmapTexture.GetExternalIndex(),
MG_Util::ConvertTextureUploadTargetToString(target).c_str(), level);
uploadItem.uploadByteSize = uploadItem.expandedData.size();
}
const SizeT levelBytes = uploadItem.uploadByteSize;
if (GetAspectMaskForFormat(outResource.format) == VK_IMAGE_ASPECT_COLOR_BIT) {
const auto region = mipmapTexture.GetStorageDirtyRegion(target, level);
const SizeT texelCount = static_cast<SizeT>(texelSize.x()) *
static_cast<SizeT>(texelSize.y()) *
static_cast<SizeT>(std::max(texelSize.z(), 1));
if (!region.Empty() && !region.CoversWholeLevel(texelSize) && texelCount > 0 &&
byteSize % texelCount == 0) {
// Exactly the texels the client wrote, never their union box or the storage's
// rect list (which merges touching writes into their bounding box, folds when
// full and is withheld past 3/4 of the box): the image may hold what the GPU
// drew or cleared anywhere else, and the shadow is stale there. Each box is one
// more VkBufferImageCopy in the same vkCmdCopyBufferToImage, which costs next
// to nothing, so there is no cheaper shape worth being wrong for. A union box
// that spans the level with gaps in it is not a whole-level write either.
mipmapTexture.GetStorageDirtyFootprint(target, level, dirtyFootprint);
if (!region.Empty() && (dirtyFootprint.size() >= 2 || !region.CoversWholeLevel(texelSize)) &&
texelCount > 0 && levelBytes % texelCount == 0) {
uploadItem.subRegion = true;
uploadItem.regionLo = region.lo;
uploadItem.regionSize = {region.hi.x() - region.lo.x(), region.hi.y() - region.lo.y(),
region.hi.z() - region.lo.z()};
uploadItem.texelBytes = byteSize / texelCount;
uploadItem.texelBytes = levelBytes / texelCount;
uploadItem.uploadByteSize = static_cast<SizeT>(uploadItem.regionSize.x()) *
static_cast<SizeT>(uploadItem.regionSize.y()) *
static_cast<SizeT>(uploadItem.regionSize.z()) *
uploadItem.texelBytes;
// Scatter refinement: the storage only hands out its rect list
// when the rects' summed area is materially smaller than the
// union box (0 otherwise), so taking it always stages fewer
// bytes than the box - the very amplification this path exists
// to avoid paying twice.
MG_State::GLState::MipmapDirtyRegion
dirtyRects[MG_State::GLState::MipmapStorage::kMaxDirtyRects];
const SizeT dirtyRectCount = mipmapTexture.GetStorageDirtyRects(
target, level, dirtyRects, MG_State::GLState::MipmapStorage::kMaxDirtyRects);
if (dirtyRectCount >= 2) {
uploadItem.rects.assign(dirtyRects, dirtyRects + dirtyRectCount);
// Several boxes: each is staged tightly and copied on its own. One box is
// the union box above.
if (dirtyFootprint.size() >= 2) {
uploadItem.rects.assign(dirtyFootprint.begin(), dirtyFootprint.end());
SizeT rectTexels = 0;
for (const auto& rect : uploadItem.rects) {
rectTexels += rect.TexelCount();
@@ -2953,15 +3004,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
}
if (formatInfo.expandRgbToRgba) {
const Bool expanded = ExpandRgbSourceToRgba(source, byteSize, texelSize, formatInfo,
uploadItem.expandedData);
MOBILEGL_ASSERT(expanded,
"UploadDirtyMipLevels: failed to expand RGB textureId=%d target=%s level=%u to RGBA staging data",
mipmapTexture.GetExternalIndex(),
MG_Util::ConvertTextureUploadTargetToString(target).c_str(), level);
uploadItem.uploadByteSize = uploadItem.expandedData.size();
}
uploadItems.push_back(Move(uploadItem));
if (!uploadItems.back().expandedData.empty()) {
uploadItems.back().source = uploadItems.back().expandedData.data();
@@ -3170,6 +3212,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const Bool depthSelectsArrayLayer = outResource.viewType == VK_IMAGE_VIEW_TYPE_1D_ARRAY ||
outResource.viewType == VK_IMAGE_VIEW_TYPE_2D_ARRAY ||
outResource.viewType == VK_IMAGE_VIEW_TYPE_CUBE_ARRAY;
Vector<VkBufferImageCopy> rectCopies;
for (const auto& item : uploadItems) {
if (!item.rects.empty()) {
// Multi-rect item: one VkBufferImageCopy per rect, all submitted in a
@@ -3177,7 +3220,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// construction, so no two copies write the same texels. Multi-rect
// implies subRegion, which implies a plain color aspect - the combined
// depth-stencil split below can never see one of these.
VkBufferImageCopy rectCopies[MG_State::GLState::MipmapStorage::kMaxDirtyRects];
rectCopies.resize(item.rects.size());
Uint32 rectCopyCount = 0;
VkDeviceSize runningOffset = item.offset;
for (const auto& rect : item.rects) {
@@ -3208,7 +3251,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
runningOffset += static_cast<VkDeviceSize>(rect.TexelCount() * item.texelBytes);
}
vkCmdCopyBufferToImage(commandBuffer, stagingBuffer, outResource.image,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, rectCopyCount, rectCopies);
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, rectCopyCount, rectCopies.data());
continue;
}
const Uint32 depthOrLayers = item.texelSize.z() > 0 ? static_cast<Uint32>(item.texelSize.z()) : 1u;
@@ -320,6 +320,11 @@ public:
// into the frame's PRE command buffer - which executes strictly before the
// frame's commands - instead of splitting the active render pass.
Uint64 lastRecordingGeneration = 0;
// The same, for the last recorded command that WRITES the image (a render pass it is an
// attachment of, a clear, a blit/copy into it, a mip generation, a storage-image binding).
// An upload into an image written this recording has to be ordered after that write
// (VulkanRenderer::OrderPendingUploadAfterRecording).
Uint64 lastRecordingWriteGeneration = 0;
// Snapshot of ITextureObject::GetContentVersion() at the last successful sync;
// lets SyncTexture skip the whole re-check/re-upload when content is unchanged.
Uint64 syncedContentVersion = 0;
@@ -362,6 +367,7 @@ public:
std::swap(this->storageUsageResolved, that.storageUsageResolved);
std::swap(this->syncedTextureParamsVersion, that.syncedTextureParamsVersion);
std::swap(this->lastRecordingGeneration, that.lastRecordingGeneration);
std::swap(this->lastRecordingWriteGeneration, that.lastRecordingWriteGeneration);
std::swap(this->syncedContentVersion, that.syncedContentVersion);
std::swap(this->syncedMipLevelCount, that.syncedMipLevelCount);
std::swap(this->syncedShapeVersion, that.syncedShapeVersion);
@@ -551,6 +557,26 @@ public:
Bool WasTouchedThisRecording(const TextureResource& resource) const {
return resource.lastRecordingGeneration == m_recordingGeneration;
}
// A write is a use too. See TextureResource::lastRecordingWriteGeneration.
void StampResourceRecordingWrite(TextureResource& resource) const {
resource.lastRecordingGeneration = m_recordingGeneration;
resource.lastRecordingWriteGeneration = m_recordingGeneration;
}
void StampTextureRecordingWrite(MG_State::GLState::ITextureObject* texture);
// Upload-ordering queries (VulkanRenderer::OrderPendingUploadAfterRecording). None of them
// syncs, so nothing pending is uploaded by asking. All answer for the STORAGE texture.
//
// Uploads are recorded into a batch submitted AHEAD of the frame's recording
// (FlushPendingUploads), so an upload into an image the open recording already WROTE lands
// before that write, which then overwrites the newer texels. (An image the recording only
// read keeps the old trade: its earlier reads see the newer texels.)
Bool HasLiveImage(MG_State::GLState::ITextureObject& textureOrView) const;
// The next sync would record client texels into the image that is live now: same shape (a
// shape change mints a new image instead) and content moved since the last sync.
Bool HasPendingTexelUpload(MG_State::GLState::ITextureObject& textureOrView) const;
Bool WasTextureWrittenThisRecording(MG_State::GLState::ITextureObject& textureOrView) const;
// The live resource, or null.
TextureResource* FindTextureResource(MG_State::GLState::ITextureObject& textureOrView);
// Records that this texture is bound to a GL image unit, so its image must carry
// VK_IMAGE_USAGE_STORAGE_BIT. Must be called before NeedsStorageImagePreparation, and
// therefore before the render pass is committed: an image that has to be upgraded is
@@ -1801,6 +1801,9 @@ void main() {
outBinding.label, texture->GetExternalIndex());
return false;
}
if (!isReadFramebuffer) {
textureManager.StampResourceRecordingWrite(*resource);
}
outBinding.image = resource->image;
outBinding.trackedLayout = &resource->layout;
@@ -1917,6 +1920,9 @@ void main() {
outBinding.label, texture->GetExternalIndex(), static_cast<Uint32>(requiredAspectMask));
return false;
}
if (!isReadFramebuffer) {
textureManager.StampResourceRecordingWrite(*resource);
}
outBinding.image = resource->image;
outBinding.trackedLayout = &resource->layout;
@@ -1957,6 +1963,7 @@ void main() {
texture.GetExternalIndex(), mipLevel, resource->mipLevels);
return false;
}
textureManager.StampResourceRecordingWrite(*resource);
outBinding.image = resource->image;
outBinding.trackedLayout = &resource->layout;
@@ -2973,7 +2980,10 @@ void main() {
}
VulkanRenderer::VulkanRenderer(NativeWindowType window, const VulkanRendererConfig& cfg)
: m_window(window), m_config(cfg) {
: m_window(window), m_config(cfg), m_presentsToAppWindow(window != NativeWindowType{}) {
if (!m_presentsToAppWindow) {
m_config.SwapInterval.reset();
}
// Initialize();
}
@@ -6371,6 +6381,8 @@ void main() {
Bool VulkanRenderer::SetupDraw(FrameContext::FrameData& frame, GLenum mode, Flags<DrawSetupAspect> aspects,
const DrawCmdParam& drawParams,
const IndexBufferView* pIndexBufferView) {
// First, before anything of this draw is recorded or resolved.
SplitOversizedRecording();
// Sync each sampled texture at most once across this whole draw: the layout
// probe loop, the post-transition loop, and ResolveSamplerDescriptor would
// otherwise each re-run the full SyncTexture path on the same textures.
@@ -6651,6 +6663,15 @@ void main() {
VkRenderPassManager::EndRenderPass(frame.commandBuffer);
activeRenderPass = nullptr;
}
// The syncs below upload each sampled texture's pending texels into a batch that runs
// ahead of this recording; one whose image the recording already cleared or drew into
// would be overwritten by that earlier work. Submit it first, here, before anything below
// records into the frame or decides from the active pass.
for (auto* sampledTexture : sampledTextures) {
if (sampledTexture != nullptr && OrderPendingUploadAfterRecording(frame, *sampledTexture)) {
activeRenderPass = nullptr;
}
}
Bool needSampledTextureTransitions = false;
auto& sampledResources = m_sampledResourcesScratch;
sampledResources.assign(sampledTextures.size(), nullptr);
@@ -6962,6 +6983,7 @@ void main() {
}
void VulkanRenderer::DispatchCompute(GLuint numGroupsX, GLuint numGroupsY, GLuint numGroupsZ) {
SplitOversizedRecording();
m_textureManager->CollectGarbage();
auto& frame = m_frameContext.GetCurrent();
// The DISPATCH accessor: with a pipeline bound this is its compute stage program
@@ -7015,6 +7037,7 @@ void main() {
}
void VulkanRenderer::DispatchComputeIndirect(GLintptr indirect) {
SplitOversizedRecording();
m_textureManager->CollectGarbage();
auto& frame = m_frameContext.GetCurrent();
// See DispatchCompute: the dispatch accessor, not the draw one.
@@ -7349,6 +7372,7 @@ void main() {
m_renderPassManager->QueueRenderbufferClear(
{.mask = GL_COLOR_BUFFER_BIT, .color = payload.color}, attachment);
} else if (attachment.IsTexture()) {
SettleTexelsBeforeQueuedClear(*fbo, attachment);
m_clearManager->QueueClear({.mask = GL_COLOR_BUFFER_BIT, .color = payload.color},
attachment);
}
@@ -7360,6 +7384,19 @@ void main() {
return;
}
if ((deferredMask & GL_COLOR_BUFFER_BIT) != 0) {
for (const auto drawBuffer : fbo->GetDrawBuffers()) {
if (drawBuffer != FramebufferAttachmentType::None) {
SettleTexelsBeforeQueuedClear(*fbo, fbo->GetAttachment(drawBuffer));
}
}
}
if ((deferredMask & GL_DEPTH_BUFFER_BIT) != 0) {
SettleTexelsBeforeQueuedClear(*fbo, fbo->GetAttachment(FramebufferAttachmentType::Depth));
}
if ((deferredMask & GL_STENCIL_BUFFER_BIT) != 0) {
SettleTexelsBeforeQueuedClear(*fbo, fbo->GetAttachment(FramebufferAttachmentType::Stencil));
}
m_clearManager->QueueClear(deferredMask, payload, *fbo);
m_renderPassManager->QueueRenderbufferClear(deferredMask, payload, *fbo);
}
@@ -7437,6 +7474,7 @@ void main() {
if (!attachment.IsTexture()) {
return;
}
SettleTexelsBeforeQueuedClear(framebuffer, attachment);
m_clearManager->QueueClear(payload, attachment);
};
@@ -7795,7 +7833,8 @@ void main() {
}
Bool VulkanRenderer::MaterializePendingClearForTexture(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture) {
MG_State::GLState::ITextureObject& texture,
Bool uploadPendingTexels) {
Vector<PendingClearEntry> pendingClears;
if (!m_clearManager->GetPendingClears(&texture, pendingClears)) {
return true;
@@ -7807,8 +7846,9 @@ void main() {
commandBuffer != m_frameContext.GetCurrent().commandBuffer,
"MaterializePendingClearForTexture requires no active render pass on the target buffer");
auto* resource = m_textureManager->SyncTextureAndGetDescriptor(texture);
if (resource == nullptr) {
auto* resource = uploadPendingTexels ? m_textureManager->SyncTextureAndGetDescriptor(texture)
: m_textureManager->FindTextureResource(texture);
if (resource == nullptr || resource->image == VK_NULL_HANDLE) {
// Declined sync (an incomplete texture, say). Nothing to clear into, and every line
// below dereferences this - the assert that used to stand here is compiled out of
// every build past DEBUG.
@@ -7960,6 +8000,9 @@ void main() {
"MaterializePendingClearForTexture: failed to transition textureId=%d to sampled layout",
texture.GetExternalIndex());
resource->layout = sampledLayout;
// The clear is now a write in this recording, which is what orders a later upload into
// the image after it (OrderPendingUploadAfterRecording).
m_textureManager->StampResourceRecordingWrite(*resource);
m_clearManager->PopPendingClear(&texture);
MGLOG_D("MaterializePendingClearForTexture: textureId=%d pending clear materialized",
@@ -7967,6 +8010,62 @@ void main() {
return true;
}
Bool VulkanRenderer::OrderPendingUploadAfterRecording(FrameContext::FrameData& frame,
MG_State::GLState::ITextureObject& texture) {
if (!m_textureManager->HasPendingTexelUpload(texture)) {
return false;
}
// An open pass drawing into the image stamps that write only when it ends.
if (const auto* activeRenderPass = VkRenderPassManager::GetActiveRenderPass();
activeRenderPass != nullptr && ActiveRenderPassUsesTexture(*activeRenderPass, texture)) {
VkRenderPassManager::EndRenderPass(frame.commandBuffer);
}
const Bool clearFirst = m_clearManager->HasPendingClear(&texture);
if (!clearFirst && !m_textureManager->WasTextureWrittenThisRecording(texture)) {
// Nothing recorded writes the image: the upload may run ahead of the recording.
return false;
}
if (!frame.isCommandRecording) {
m_frameContext.BeginCommandRecording();
}
if (VkRenderPassManager::GetActiveRenderPass() != nullptr) {
VkRenderPassManager::EndRenderPass(frame.commandBuffer);
}
if (clearFirst && !MaterializePendingClearForTexture(frame.commandBuffer, texture,
/*uploadPendingTexels=*/false)) {
MGLOG_E_ONCE("OrderPendingUploadAfterRecording: textureId=%d kept its queued clear; it may land over "
"texels written after it",
texture.GetExternalIndex());
}
if (!FlushPendingCommands()) {
MGLOG_E_ONCE("OrderPendingUploadAfterRecording: submit failed; textureId=%d's upload may run ahead of "
"earlier work on it",
texture.GetExternalIndex());
return false;
}
m_frameContext.BeginCommandRecording();
// New command buffer: the same reset SetupDraw applies when it begins one.
m_lastSampledSetValid = false;
return true;
}
void VulkanRenderer::SettleTexelsBeforeQueuedClear(
const MG_State::GLState::FramebufferObject& framebuffer,
const MG_State::GLState::FramebufferAttachmentObject& attachment) {
// The default framebuffer's attachments are placeholders: its clears land on the
// swapchain image (MaterializePendingClearForDefaultFramebuffer) and it has no texels.
if (framebuffer.IsDefaultFramebuffer() || !attachment.IsTexture() || attachment.IsRenderbuffer()) {
return;
}
const auto texture = attachment.GetTexture();
if (!texture || (m_textureManager->HasLiveImage(*texture) &&
!m_textureManager->HasPendingTexelUpload(*texture))) {
return;
}
OrderPendingUploadAfterRecording(m_frameContext.GetCurrent(), *texture);
m_textureManager->SyncTextureAndGetDescriptor(*texture);
}
Bool VulkanRenderer::MaterializeMultisamplePendingClear(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture,
VkTextureManager::TextureResource& resource,
@@ -9667,6 +9766,7 @@ void main() {
const Bool dstClearReady = materializeClear(dstEndpoint);
MOBILEGL_ASSERT(dstClearReady, "%s: failed to materialize pending clear for destination objectId=%u",
__func__, CopyImageEndpointName(dstEndpoint));
m_textureManager->StampTextureRecordingWrite(dstEndpoint.Texture.get());
const VkImageLayout srcOriginalLayout = *srcImage.trackedLayout;
const VkImageLayout dstOriginalLayout = *dstImage.trackedLayout;
@@ -9875,6 +9975,13 @@ void main() {
}
auto& frame = m_frameContext.GetCurrent();
if (!readFbo->IsDefaultFramebuffer()) {
// The read syncs the source texture, whose pending texels must land after work already
// recorded against it (see OrderPendingUploadAfterRecording).
if (const auto readTexture = readFbo->GetAttachment(readFbo->GetReadBuffer()).GetTexture()) {
OrderPendingUploadAfterRecording(frame, *readTexture);
}
}
if (!frame.isCommandRecording) {
m_frameContext.BeginCommandRecording();
}
@@ -10667,6 +10774,9 @@ void main() {
return;
}
// The sync below uploads pending texels, which must land after work already recorded
// against this image (see OrderPendingUploadAfterRecording).
OrderPendingUploadAfterRecording(m_frameContext.GetCurrent(), *textureObject);
auto* resource = m_textureManager->SyncTextureAndGetDescriptor(*textureObject);
// Two shapes end up in the same place, and for the same reason: the GL level being read has
// no GPU storage, so UploadDirtyMipLevels never wrote it and the CPU shadow is the ONLY copy
@@ -10962,6 +11072,7 @@ void main() {
resource = m_textureManager->SyncTextureAndGetDescriptor(*texture);
MOBILEGL_ASSERT(resource != nullptr && resource->image != VK_NULL_HANDLE,
"GenerateMipmap failed to resync the backend texture after allocating mip storage.");
m_textureManager->StampResourceRecordingWrite(*resource);
if (resource->layout == VK_IMAGE_LAYOUT_UNDEFINED) {
const VkImageLayout finalLayout = ResolveGenerateMipmapFinalLayout(resource->aspect);
Bool transitioned = VkTextureManager::TransitionImageLayout(
@@ -12562,6 +12673,54 @@ void main() {
return FlushPendingCommands();
}
void VulkanRenderer::SplitOversizedRecording() {
const Uint32 maxDraws = MG_Config::Features.MagmaMaxDrawsPerCommandBuffer;
if (maxDraws == 0) {
return;
}
// Counted before the check, so a buffer carries at most maxDraws calls before the one
// that splits it. OnFrameCommandRecordingBegan resets the count on every new recording.
if (++m_drawsInRecording <= maxDraws || !HasPendingRecordedWork()) {
return;
}
// Nothing of the current call is recorded yet, so the split falls between two GL calls,
// exactly where a glFlush would put it: the render pass ends with STORE, the next draw
// re-begins it with LOAD, and queries and transform feedback are bracketed per draw.
if (!FlushPendingCommands()) {
return;
}
// Fresh command buffer: the same reset every other mid-frame flush site applies.
m_lastSampledSetValid = false;
++m_oversizedRecordingSplits;
MGLOG_I_ONCE("DirectVulkan: a frame recorded more than %u draws into one command buffer; submitting it and "
"continuing on a fresh one (MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER, 0 = unbounded)",
maxDraws);
MGLOG_D("DirectVulkan: split an oversized recording (%llu so far, submit index %llu)",
static_cast<unsigned long long>(m_oversizedRecordingSplits),
static_cast<unsigned long long>(m_submitCounter));
// Splitting only bounds the driver's command memory if the submitted buffers are freed
// as fast as they are produced. A GPU that falls behind would park them all on the
// retired list, which is the same growth spread over more buffers - so hold the queue
// to frames-in-flight + 1 outstanding submissions, the steady state of a pipelined
// frame plus this split. Wait on the fence directly rather than through
// WaitForSubmitIndex: its opportunistic TryDrainFrameTransients may rewind the transient
// arena, and this runs at the start of a draw whose caller may already hold slices of it.
const SizeT maxOutstanding = static_cast<SizeT>(m_frameContext.GetFrameCount()) + 1;
while (m_inFlightSubmits.size() > maxOutstanding) {
const SubmitRecord oldest = m_inFlightSubmits.front();
if (oldest.fence == VK_NULL_HANDLE) {
break;
}
const VkResult waitResult = vkWaitForFences(m_device, 1, &oldest.fence, VK_TRUE, UINT64_MAX);
if (waitResult != VK_SUCCESS) {
MGLOG_E_ONCE("SplitOversizedRecording: vkWaitForFences returned %d", waitResult);
break;
}
OnSubmitsCompletedUpTo(oldest.submitIndex);
}
}
Bool VulkanRenderer::WaitForSubmitIndex(Uint64 submitIndex, Uint64 timeoutNs, Bool flushIfPending) {
if (IsSubmitIndexComplete(submitIndex)) {
return true;
@@ -12600,6 +12759,7 @@ void main() {
}
void VulkanRenderer::OnFrameCommandRecordingBegan(VkCommandBuffer commandBuffer) {
m_drawsInRecording = 0;
// Dynamic state does not survive a command-buffer boundary.
ResetDynamicStateShadow();
InvalidateSetupDrawSnapshots();
@@ -12853,8 +13013,22 @@ void main() {
if (!m_swapchainResizeRequested && SwapchainIsOutOfDate()) {
m_swapchainResizeRequested = true;
}
// A new eglSwapInterval lands here too, between frames: this one is on screen and the
// next has recorded nothing. Only a different present mode is worth the rebuild (it
// drops every pipeline); otherwise the live swapchain already paces as asked.
if (m_config.SwapInterval != m_swapchainSwapInterval) {
if (SwapchainObject::ChooseSwapchainPresentMode(m_swapchainObject.GetSupportedPresentModes(),
m_config.SwapInterval) !=
m_swapchainObject.GetPresentMode()) {
m_swapchainResizeRequested = true;
} else {
MGLOG_D("DirectVulkan: swap interval %d -> %s", *m_config.SwapInterval,
SwapchainObject::GetPresentModeName(m_swapchainObject.GetPresentMode()));
m_swapchainSwapInterval = m_config.SwapInterval;
}
}
if (m_swapchainResizeRequested) {
MGLOG_D("Present, processing requested swapchain resize");
MGLOG_D("Present, processing requested swapchain rebuild (resize or swap interval)");
if (!RecreateSwapchain()) {
m_presentSuspended = true;
m_swapchainResizeRequested = false;
@@ -14338,7 +14512,12 @@ void main() {
m_swapchainObject.Create(m_device, m_physicalDevice.handle, m_surface,
static_cast<Uint32>(m_physicalDevice.queueFamilies.graphicsFamily),
static_cast<Uint32>(m_physicalDevice.queueFamilies.presentFamily),
m_config.MaxFramesInFlight, desiredExtent);
m_config.MaxFramesInFlight, desiredExtent, m_config.SwapInterval);
if (m_config.SwapInterval && m_config.SwapInterval != m_swapchainSwapInterval) {
MGLOG_D("DirectVulkan: swap interval %d -> %s", *m_config.SwapInterval,
SwapchainObject::GetPresentModeName(m_swapchainObject.GetPresentMode()));
}
m_swapchainSwapInterval = m_config.SwapInterval;
// The FragCoordYFlip variants bake this height in; it is the only input to a shader
// module that lives outside the GL program, so the factory has to learn it here (and on
// every recreation, which is the only way it can change).
@@ -14782,6 +14961,12 @@ void main() {
m_swapchainResizeRequested = true;
}
void VulkanRenderer::SetSwapInterval(Int interval) {
if (m_presentsToAppWindow) {
m_config.SwapInterval = interval;
}
}
VkInstance VulkanRenderer::GetInstance() const {
return m_instance;
}
@@ -285,6 +285,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// signal as soon as the GPU actually finishes, which MC 1.21.5's
// fence-paced ring buffers rely on to recycle their space.
Uint64 GetSyncPointSubmitIndex() const;
// For tests: the per-frame descriptor pools (see DescriptorPoolCensus.h).
DescriptorPoolCensus GetDescriptorPoolCensus() const {
return m_uniformManager ? m_uniformManager->GetDescriptorPoolCensus() : DescriptorPoolCensus{};
}
// Non-blocking: polls outstanding submission fences and reports
// whether every submission up to `submitIndex` has completed.
Bool IsSubmitIndexComplete(Uint64 submitIndex);
@@ -348,6 +352,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool ResolveOcclusionQueryResult(const Vector<Uint32>& slots, Uint64& outSamples);
void RequestSwapchainResize(Uint32 width, Uint32 height);
// eglSwapInterval. Records the request; Present rebuilds the swapchain for it once the
// current frame is on screen, and only when it maps to a different present mode. A
// renderer created without an app window ignores it: its surface (headless, or an
// AImageReader nobody consumes) is never displayed, and FIFO there would block once
// the reader's queue fills.
void SetSwapInterval(Int interval);
// Re-query the surface and report whether the live swapchain no longer matches it
// (size or orientation). This - not a VK_SUBOPTIMAL_KHR result - is what decides a
// rebuild, so a surface the driver merely considers suboptimal cannot thrash.
@@ -484,6 +494,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// only), so the presenting path's frames-in-flight pipelining is
// untouched. Returns true when the drain ran.
Bool TryDrainFrameTransients();
// Bounded recording: called at the top of every GL draw and dispatch, before anything
// of that call is recorded. Once the open frame command buffer holds
// MagmaMaxDrawsPerCommandBuffer draws/dispatches, submits it (FlushPendingCommands) so
// the call continues on a fresh one, then waits until at most frames-in-flight + 1
// submissions are outstanding. A driver backs a command buffer with GPU memory it only
// returns when the buffer is freed (Adreno: one 16 KiB mapping per chunk), so a frame
// that is never split grows it without bound - rd12's loading frame records ~1M draws
// into one buffer and runs the process out of vm.max_map_count.
void SplitOversizedRecording();
Vector<SubmitRecord> m_inFlightSubmits;
Vector<VkFence> m_freeSubmitFences;
@@ -495,6 +514,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// aging clocks, while present-less loops still cross a boundary every few
// iterations. Reset in Present.
Uint32 m_drainsSinceLastPresent = 0;
// GL draws/dispatches recorded into the open frame command buffer; reset whenever a
// recording begins (OnFrameCommandRecordingBegan). See SplitOversizedRecording.
Uint32 m_drawsInRecording = 0;
Uint64 m_oversizedRecordingSplits = 0;
NativeWindowType m_window = 0;
void* m_platformDisplay = nullptr;
@@ -514,6 +537,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Shutdown() deletes it.
void* m_fallbackImageReader = nullptr;
VulkanRendererConfig m_config;
// Constructed for the app's window rather than a pbuffer (m_window is later filled in
// for the pbuffer fallbacks too, so it cannot tell them apart).
Bool m_presentsToAppWindow = false;
// The m_config.SwapInterval the live swapchain was built for; Present compares the two.
Optional<Int> m_swapchainSwapInterval;
Bool m_swapchainResizeRequested = false;
// Presentation is suspended while the window is zero-area (minimized): the
// swapchain is unusable/out of date, so Present drops frames instead of
@@ -1376,8 +1404,28 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MG_State::GLState::ITextureObject& texture, Uint32 mipLevel,
Uint32 depthSlice, const VkClearValue& clearValue,
VkImageLayout finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
// Records the texture's queued clears. By default the texture is synced first, which
// uploads any pending texels BEFORE the clear - right only for texels written before it.
// `uploadPendingTexels = false` leaves them pending (OrderPendingUploadAfterRecording).
Bool MaterializePendingClearForTexture(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture);
MG_State::GLState::ITextureObject& texture,
Bool uploadPendingTexels = true);
// Texture uploads run in a batch submitted ahead of the frame's recording, so the upload
// the next sync of `texture` records would land BEFORE a write already recorded into the
// same image (a render, clear, blit or copy), and before a clear still queued for it (such
// a clear predates the texels: SettleTexelsBeforeQueuedClear). When either holds, this
// records the queued clear without the texels and submits the recording, so the upload
// follows both. An image the recording only READ keeps the old trade: no submit, and its
// earlier reads see the newer texels. It may end the render pass and replace
// frame.commandBuffer (recording again on return), so call it only where the caller holds
// no render-pass or command-buffer state across it. Returns whether it submitted.
Bool OrderPendingUploadAfterRecording(FrameContext::FrameData& frame,
MG_State::GLState::ITextureObject& texture);
// Called for each texture a glClear/glClearBuffer* is about to queue a clear on: uploads
// whatever texels are still pending (and mints the image if there is none), so that any
// texel pending when the clear is materialized was written after it.
void SettleTexelsBeforeQueuedClear(const MG_State::GLState::FramebufferObject& framebuffer,
const MG_State::GLState::FramebufferAttachmentObject& attachment);
// The multisample arm of the above. Split out rather than branched inline because it
// shares none of the transfer path: a multisample image carries no TRANSFER_DST usage, so
// neither the TRANSFER_DST transition nor vkCmdClearColorImage is legal on one.
@@ -22,6 +22,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint64 CacheVersion = MG_Config::CacheVersion;
Uint32 SurfaceWidth = 1;
Uint32 SurfaceHeight = 1;
// The app's eglSwapInterval; empty until it asks. Window renderers only - see
// VulkanRenderer::SetSwapInterval.
Optional<Int> SwapInterval;
Bool DisablePipelineCache = false;
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
Bool EnableValidationLayers = true;
@@ -1196,6 +1196,15 @@ namespace MobileGL::MG_Impl::GLImpl {
DrawElements_Backend(mode, count, type, indices);
}
void DrawMeshTasksNV(GLuint first, GLuint count) {}
void DrawMeshTasksIndirectNV(GLintptr indirect) {}
void MultiDrawMeshTasksIndirectNV(GLintptr indirect, GLsizei drawcount, GLsizei stride) {}
void MultiDrawMeshTasksIndirectCountNV(GLintptr indirect, GLintptr drawcount, GLsizei maxdrawcount,
GLsizei stride) {}
void BeginTransformFeedback(GLenum primitiveMode) {
if (primitiveMode != GL_POINTS && primitiveMode != GL_LINES && primitiveMode != GL_TRIANGLES) {
MG_State::pGLContext->RecordError(
@@ -66,4 +66,9 @@ namespace MobileGL::MG_Impl::GLImpl {
GLsizei drawcount, const GLint* basevertex);
void Clear(GLbitfield mask);
void DrawElements(GLenum mode, GLsizei count, GLenum type, const void* indices);
void DrawMeshTasksNV(GLuint first, GLuint count);
void DrawMeshTasksIndirectNV(GLintptr indirect);
void MultiDrawMeshTasksIndirectNV(GLintptr indirect, GLsizei drawcount, GLsizei stride);
void MultiDrawMeshTasksIndirectCountNV(GLintptr indirect, GLintptr drawcount, GLsizei maxdrawcount, GLsizei stride);
} // namespace MobileGL::MG_Impl::GLImpl
@@ -2412,10 +2412,10 @@ DECLARE_GL_FUNCTION_STUB_HEAD(void, BufferPageCommitmentMemNV, GLenum target, GL
DECLARE_GL_FUNCTION_STUB_HEAD(void, TexPageCommitmentMemNV, GLenum target, GLint layer, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width, GLsizei height, GLsizei depth, GLuint memory, GLuint64 offset, GLboolean commit) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, TexPageCommitmentMemNV, target, layer, level, xoffset, yoffset, zoffset, width, height, depth, memory, offset, commit)
DECLARE_GL_FUNCTION_STUB_HEAD(void, NamedBufferPageCommitmentMemNV, GLuint buffer, GLintptr offset, GLsizeiptr size, GLuint memory, GLuint64 memOffset, GLboolean commit) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, NamedBufferPageCommitmentMemNV, buffer, offset, size, memory, memOffset, commit)
DECLARE_GL_FUNCTION_STUB_HEAD(void, TexturePageCommitmentMemNV, GLuint texture, GLint layer, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width, GLsizei height, GLsizei depth, GLuint memory, GLuint64 offset, GLboolean commit) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, TexturePageCommitmentMemNV, texture, layer, level, xoffset, yoffset, zoffset, width, height, depth, memory, offset, commit)
DECLARE_GL_FUNCTION_STUB_HEAD(void, DrawMeshTasksNV, GLuint first, GLuint count) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DrawMeshTasksNV, first, count)
DECLARE_GL_FUNCTION_STUB_HEAD(void, DrawMeshTasksIndirectNV, GLintptr indirect) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DrawMeshTasksIndirectNV, indirect)
DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiDrawMeshTasksIndirectNV, GLintptr indirect, GLsizei drawcount, GLsizei stride) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MultiDrawMeshTasksIndirectNV, indirect, drawcount, stride)
DECLARE_GL_FUNCTION_STUB_HEAD(void, MultiDrawMeshTasksIndirectCountNV, GLintptr indirect, GLintptr drawcount, GLsizei maxdrawcount, GLsizei stride) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, MultiDrawMeshTasksIndirectCountNV, indirect, drawcount, maxdrawcount, stride)
DECLARE_GL_FUNCTION_HEAD(void, DrawMeshTasksNV, GLuint first, GLuint count) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawMeshTasksNV, first, count)
DECLARE_GL_FUNCTION_HEAD(void, DrawMeshTasksIndirectNV, GLintptr indirect) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawMeshTasksIndirectNV, indirect)
DECLARE_GL_FUNCTION_HEAD(void, MultiDrawMeshTasksIndirectNV, GLintptr indirect, GLsizei drawcount, GLsizei stride) DECLARE_GL_FUNCTION_END_NO_RETURN(void, MultiDrawMeshTasksIndirectNV, indirect, drawcount, stride)
DECLARE_GL_FUNCTION_HEAD(void, MultiDrawMeshTasksIndirectCountNV, GLintptr indirect, GLintptr drawcount, GLsizei maxdrawcount, GLsizei stride) DECLARE_GL_FUNCTION_END_NO_RETURN(void, MultiDrawMeshTasksIndirectCountNV, indirect, drawcount, maxdrawcount, stride)
DECLARE_GL_FUNCTION_STUB_HEAD(void, GenOcclusionQueriesNV, GLsizei n, GLuint* ids) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GenOcclusionQueriesNV, n, ids)
DECLARE_GL_FUNCTION_STUB_HEAD(void, DeleteOcclusionQueriesNV, GLsizei n, const GLuint* ids) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, DeleteOcclusionQueriesNV, n, ids)
DECLARE_GL_FUNCTION_STUB_HEAD(GLboolean, IsOcclusionQueryNV, GLuint id) DECLARE_GL_FUNCTION_STUB_END(GLboolean, IsOcclusionQueryNV, id)
@@ -88,6 +88,8 @@ add_executable(MobileGLIntegrationTest
Scenarios/ImageFormatQualifierScenario.cpp
Scenarios/NonCoreImageFormatScenario.cpp
Scenarios/ImageSizeAfterRespecScenario.cpp
Scenarios/ImageBindableRemintScenario.cpp
Scenarios/PartialUploadOverGpuContentScenario.cpp
Scenarios/SsboDeclarationFormScenario.cpp
Scenarios/Glsl420DeclarationScenario.cpp
Scenarios/IoBlockNameCollisionScenario.cpp
@@ -122,10 +124,13 @@ add_executable(MobileGLIntegrationTest
Scenarios/RelinkStageSetScenario.cpp
Scenarios/GuiBatchScenario.cpp
Scenarios/UnboundImageDescriptorScenario.cpp
Scenarios/UnboundUniformBlockScenario.cpp
Scenarios/IntegerBorderColorScenario.cpp
Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp
Scenarios/RenderbufferBlendFormatScenario.cpp
Scenarios/DualSourceBlendScenario.cpp
Scenarios/OversizedRecordingSplitScenario.cpp
Scenarios/DescriptorPoolGrowthScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -374,6 +379,12 @@ mgl_itest_join_environment(MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/primgen-query-reroute.log"
${MGL_ITEST_VULKAN_ENV})
# The command-buffer draw budget pinned far below one frame's draws, with the log path for the
# split's latched MGLOG_I - the same arming reasoning as the two entries above.
mgl_itest_join_environment(MGL_ITEST_VULKAN_SPLIT_RECORDING_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER=64"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/oversized-recording-split.log"
${MGL_ITEST_VULKAN_ENV})
# The point-size demotion pinned on, per backend, with a per-lane log file for the arming
# assertion - the same MOBILEGL_LOG_FILE_PATH reasoning as the UnlocatedIoBlocks lane above.
# Two lanes because the demotion runs in the SHARED phase-B chain and each backend then
@@ -586,6 +597,24 @@ gtest_discover_tests(MobileGLIntegrationTest
ENVIRONMENT "${MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT}"
)
# OversizedRecordingSplitScenario with the command-buffer draw budget PINNED to 64. The ambient
# registrations run its cases under the default budget, which never splits them; this entry
# splits every case at least once between the draws that leave state (depth, stencil, a blend
# destination, multisample samples, an open query, an active capture) and the draws that
# consume it, so the results prove a split carries all of it, and the arming assertion (the
# renderer's latched MGLOG_I) proves the split happened at all - on a renderer that records the
# whole frame into one command buffer, it is the assertion that goes red. DirectVulkan only:
# the budget steers nothing on DirectGLES, whose driver flushes on its own.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.SplitRecording."
TEST_FILTER "OversizedRecordingSplitScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_SPLIT_RECORDING_ENVIRONMENT}"
)
# The packed16 copy scenarios again, with the 8-bit storage widening PINNED ON. The ambient
# registrations above cover the narrow storage - on every CI driver the widening's POST
# probe finds no field-order mirror, so Auto keeps the native 16-bit path - which means the
@@ -0,0 +1,346 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DescriptorPoolGrowthScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - ONE FRAME THAT NEEDS THOUSANDS OF DISTINCT DESCRIPTOR SETS.
//
// DirectVulkan takes a draw's descriptor set from a per-frame-slot cache and adds a pool to the
// slot when the cache runs dry (UniformManager). Two things went wrong with a burst like
// Minecraft rd12's loading frame (15,016 sets in one slot):
// - Growth was linear. The next pool was sized at twice the slot's FIRST pool of the flavour,
// which is always its oldest and smallest, so every grown pool came out the same size: the
// loading slot ended with 59 pools of 256 sets.
// - The burst was kept forever. The cache only rewinds, so the slot held all 15k sets (and the
// pools behind them) for the life of the process, while steady frames used a few dozen.
// Now each pool doubles the slot's largest one (capped), and a slot whose pools stay under a
// quarter full for MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES frames is handed back whole and re-grows.
//
// Each of kDraws draws paints its own pixel of a 64x64 target and samples one of kTextures
// textures, a different one from each of the previous draws' so neither the renderer's per-draw
// descriptor reuse memo (four entries) nor its dynamic-offset-only rebind can let two draws share
// a set: one frame needs kDraws sets. Every pixel must hold its own draw's texture colour, on both
// backends. On DirectVulkan the cases also read the pools through GetDescriptorPoolCensus - which
// only the desktop build can reach, since the integration harness links the static library there
// and the hidden-visibility shipping library on Android.
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <sstream>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#if !defined(__ANDROID__)
#include <MG_Backend/DirectVulkan/DescriptorPoolCensus.h>
#endif
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kSide = 64;
constexpr int kDraws = kSide * kSide; // well under the 16384-draw command-buffer split budget
// More than the descriptor reuse memo's four entries, so the texture a draw samples is never
// one of the last four draws'.
constexpr int kTextures = 8;
// The base pool size (VulkanRenderer's kDescriptorSetsPerFrame) and the growth cap
// (UniformManager::kMaxDescriptorPoolSets).
constexpr std::uint64_t kBaseSets = 64;
constexpr std::uint64_t kMaxPoolSets = 4096;
constexpr const char* kVS = R"(#version 330 core
in vec2 aPos;
uniform int uIndex;
void main() {
vec2 cell = vec2(float(uIndex % 64), float(uIndex / 64));
gl_Position = vec4((cell + aPos) / 32.0 - 1.0, 0.0, 1.0);
}
)";
constexpr const char* kFS = R"(#version 330 core
uniform sampler2D uTex;
out vec4 o_color;
void main() { o_color = texture(uTex, vec2(0.5)); }
)";
// Texture t's single texel; every channel differs between any two textures.
Rgba8 TextureColor(int t) {
return Rgba8{static_cast<std::uint8_t>(16 + 32 * t), static_cast<std::uint8_t>(240 - 32 * t),
static_cast<std::uint8_t>(8 + 29 * t), 255};
}
std::string Describe(const Rgba8& c) {
std::ostringstream os;
os << "rgba(" << static_cast<int>(c.r) << ", " << static_cast<int>(c.g) << ", " << static_cast<int>(c.b)
<< ", " << static_cast<int>(c.a) << ")";
return os.str();
}
unsigned long EnvUnsigned(const char* name, unsigned long fallback) {
const char* value = std::getenv(name);
if (value == nullptr || *value == '\0') return fallback;
char* end = nullptr;
const unsigned long parsed = std::strtoul(value, &end, 10);
return (end != nullptr && *end == '\0') ? parsed : fallback;
}
// Pools a geometric policy adds for n sets from an empty flavour: 64, 128, ... up to the cap,
// then one per cap. The old first-pool rule needed n/128 or n/256 of them.
std::uint64_t GeometricPoolBound(std::uint64_t n) {
std::uint64_t pools = 0, capacity = 0, size = kBaseSets;
while (capacity < n) {
capacity += size;
++pools;
size = std::min(size * 2, kMaxPoolSets);
}
return pools;
}
struct Census {
bool available = false;
std::uint64_t pools = 0;
std::uint64_t capacitySets = 0;
std::uint64_t cachedSets = 0;
std::uint64_t slotTrims = 0;
};
Census ReadCensus() {
Census out;
#if !defined(__ANDROID__)
const auto census = MobileGL::MG_Backend::DirectVulkan::GetDescriptorPoolCensus();
out.available = census.available;
out.pools = census.pools;
out.capacitySets = census.capacitySets;
out.cachedSets = census.cachedSets;
out.slotTrims = census.slotTrims;
#endif
return out;
}
std::string Describe(const Census& c) {
std::ostringstream os;
os << "pools=" << c.pools << " capacity=" << c.capacitySets << " cachedSets=" << c.cachedSets
<< " slotTrims=" << c.slotTrims;
return os.str();
}
::testing::AssertionResult EveryPixelHoldsItsDrawsTexture(const Image& image, const char* when) {
int wrong = 0;
int firstX = -1, firstY = -1;
Rgba8 firstSeen{};
for (int y = 0; y < kSide; ++y) {
for (int x = 0; x < kSide; ++x) {
const Rgba8 expected = TextureColor((y * kSide + x) % kTextures);
const Rgba8 seen = image.At(x, y);
if (seen != expected) {
if (wrong == 0) {
firstX = x;
firstY = y;
firstSeen = seen;
}
++wrong;
}
}
}
if (wrong == 0) return ::testing::AssertionSuccess();
const int draw = firstY * kSide + firstX;
return ::testing::AssertionFailure()
<< when << ": " << wrong << " of " << kDraws << " pixels do not hold their own draw's texture. "
<< "First: (" << firstX << ", " << firstY << ") = " << Describe(firstSeen) << ", drawn by draw "
<< draw << " with texture " << (draw % kTextures) << " = "
<< Describe(TextureColor(draw % kTextures)) << " (a pixel no draw reached holds 0,0,0,0)";
}
class DescriptorPoolGrowthScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
static const float kVertices[] = {0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f};
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(kVertices), kVertices, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
m_textures.resize(kTextures);
glGenTextures(kTextures, m_textures.data());
for (int t = 0; t < kTextures; ++t) {
const Rgba8 c = TextureColor(t);
const std::uint8_t texel[4] = {c.r, c.g, c.b, c.a};
glBindTexture(GL_TEXTURE_2D, m_textures[t]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, texel);
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);
}
glBindTexture(GL_TEXTURE_2D, 0);
}
void TearDown() override {
if (Ready()) {
glUseProgram(0);
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
BindDefaultFramebuffer();
if (m_program != 0) glDeleteProgram(m_program);
if (!m_textures.empty()) glDeleteTextures(static_cast<GLsizei>(m_textures.size()), m_textures.data());
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
DestroyColorFbo(m_target);
}
ScenarioTest::TearDown();
}
::testing::AssertionResult Prepare() {
std::string error;
m_program = CompileProgram(kVS, kFS, &error);
if (m_program == 0) return ::testing::AssertionFailure() << error;
m_indexLocation = glGetUniformLocation(m_program, "uIndex");
m_target = MakeColorFbo(kSide, kSide);
if (m_target.fbo == 0) return ::testing::AssertionFailure() << "MakeColorFbo failed";
glUseProgram(m_program);
glUniform1i(glGetUniformLocation(m_program, "uTex"), 0);
glActiveTexture(GL_TEXTURE0);
glBindVertexArray(m_vao);
BindFbo(m_target);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
return ::testing::AssertionSuccess();
}
// Draw i paints pixel (i % 64, i / 64) with texture i % kTextures.
void Draw(int i) {
glBindTexture(GL_TEXTURE_2D, m_textures[i % kTextures]);
glUniform1i(m_indexLocation, i);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
}
// The burst: every pixel, one draw and one descriptor set each.
void DrawBurst() {
ClearTo(0.0f, 0.0f, 0.0f, 0.0f);
for (int i = 0; i < kDraws; ++i) Draw(i);
}
static bool IsVulkan() { return Gl().BackendName() == std::string("DirectVulkan"); }
GLuint m_vao = 0;
GLuint m_vbo = 0;
GLuint m_program = 0;
GLint m_indexLocation = -1;
std::vector<GLuint> m_textures;
ColorFbo m_target;
};
} // namespace
// One frame, kDraws distinct sets. Every pixel lands, and the frame slot grows O(log N) pools
// for it, not N / 128.
TEST_F(DescriptorPoolGrowthScenario, AFrameOfDistinctSetsLandsWholeAndGrowsItsPoolsGeometrically) {
if (!Ready()) return;
ASSERT_TRUE(Prepare());
const Census before = ReadCensus();
DrawBurst();
const Image image = ReadPixels(kSide, kSide);
const Census after = ReadCensus();
EXPECT_TRUE(EveryPixelHoldsItsDrawsTexture(image, "one frame of distinct descriptor sets"));
EXPECT_EQ(FirstGLError(), 0u);
RecordProperty("census_checked", (IsVulkan() && after.available) ? "yes" : "no");
if (!IsVulkan() || !after.available) return;
// Falsifiability first: had the renderer's memos collapsed the draws onto shared sets, the
// slot would never have needed to grow and the bound below would pass on any policy.
ASSERT_GE(after.cachedSets, before.cachedSets + kDraws)
<< "the frame did not allocate one descriptor set per draw, so it does not exercise growth. Before: "
<< Describe(before) << "; after: " << Describe(after);
const std::uint64_t added = after.pools - before.pools;
RecordProperty("census_before", Describe(before));
RecordProperty("census_after", Describe(after));
EXPECT_LE(added, GeometricPoolBound(kDraws + 1))
<< "a frame needing " << kDraws << " descriptor sets added " << added << " pools; doubling the slot's "
<< "largest pool needs at most " << GeometricPoolBound(kDraws + 1) << ". Before: " << Describe(before)
<< "; after: " << Describe(after);
}
// Burst, then quiet frames until the renderer hands the burst back, then the burst again while
// the quiet frames are still in flight. Nothing waits between frames except where noted, so the
// trim runs with other frame slots' work outstanding.
TEST_F(DescriptorPoolGrowthScenario, AGivenBackBurstLeavesNoStaleSetBehind) {
if (!Ready()) return;
ASSERT_TRUE(Prepare());
const unsigned long trimFrames = EnvUnsigned("MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES", 120);
const unsigned long framesInFlight = EnvUnsigned("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3);
// The trim fires once trimFrames quiet frames have followed the burst, at the burst slot's next
// turn; past this many frames it is not coming.
const unsigned long quietFrameLimit = trimFrames + 2 * framesInFlight + 16;
DrawBurst();
Gl().EndFrame();
const Census afterBurst = ReadCensus();
// Quiet frames: one draw each, the same pixel and texture the burst gave draw 0.
const bool observe = IsVulkan() && afterBurst.available && trimFrames != 0;
unsigned long quietFrames = 0;
Census afterQuiet = afterBurst;
for (; quietFrames < quietFrameLimit; ++quietFrames) {
Draw(0);
Gl().EndFrame();
if (observe) {
afterQuiet = ReadCensus();
if (afterQuiet.slotTrims > afterBurst.slotTrims) {
++quietFrames;
break;
}
}
}
// The burst again, straight away. The slot it lands in re-grows from its base pool.
DrawBurst();
const Image image = ReadPixels(kSide, kSide);
const Census afterSecondBurst = ReadCensus();
EXPECT_TRUE(EveryPixelHoldsItsDrawsTexture(image, "the burst drawn after the first one was given back"));
EXPECT_EQ(FirstGLError(), 0u);
RecordProperty("census_checked", observe ? "yes" : "no");
if (!observe) return;
RecordProperty("census_after_burst", Describe(afterBurst));
RecordProperty("quiet_frames", std::to_string(quietFrames));
RecordProperty("census_after_quiet", Describe(afterQuiet));
RecordProperty("census_after_second_burst", Describe(afterSecondBurst));
ASSERT_GE(afterBurst.cachedSets, static_cast<std::uint64_t>(kDraws))
<< "the burst did not allocate one descriptor set per draw: " << Describe(afterBurst);
EXPECT_GT(afterQuiet.slotTrims, afterBurst.slotTrims)
<< quietFrames << " frames of one draw each followed a " << kDraws << "-set frame, but no frame slot was "
<< "trimmed (MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES=" << trimFrames << "). After the burst: "
<< Describe(afterBurst) << "; after the quiet frames: " << Describe(afterQuiet);
EXPECT_LE(afterQuiet.cachedSets + kDraws / 2, afterBurst.cachedSets)
<< "the trim did not give the burst's sets back. After the burst: " << Describe(afterBurst)
<< "; after the quiet frames: " << Describe(afterQuiet);
EXPECT_LT(afterQuiet.pools, afterBurst.pools)
<< "the trim did not give the burst's pools back. After the burst: " << Describe(afterBurst)
<< "; after the quiet frames: " << Describe(afterQuiet);
EXPECT_GE(afterSecondBurst.cachedSets, afterQuiet.cachedSets + kDraws)
<< "the second burst was not served by freshly allocated sets: " << Describe(afterSecondBurst);
}
} // namespace MGITest
@@ -0,0 +1,583 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ImageBindableRemintScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - THE FIRST glBindImageTexture OF A TEXTURE THE GPU HAS ALREADY WRITTEN.
//
// ES only allows immutable storage on an image unit, and a few formats need a wider carrier on
// top (TextureImpl::GetImageBindableStorageWidening), so Espryt re-mints a texture the first time
// it is image-bound after it already has driver storage (RequireImageBindableStorage). The new
// storage has to hold what the old one held. The re-mint used to fill it from the frontend's
// shadow - the CLIENT's copy of each level - and the shadow never sees a GPU write: a render or
// clear into the level, a glGenerateMipmap, a null-data definition only the GPU ever filled. Every
// such level came back as the bytes the application last uploaded, or zeroes. That is the
// ~26.9k-pixel iris-derivative difference: an RGBA16F render target, first image-bound after it
// was rendered into.
//
// Every case gives a texture its content by one means, lets the backend create its storage (a
// sampling draw, or the FBO attach of a GPU clear), writes it on the GPU where the case says so,
// and then binds it to an image unit for the first time: a compute imageLoad copies the level into
// a destination that was image-bound before it was ever synced (so the destination itself never
// re-mints), and the destination is read back through an FBO. The expectation is the GL answer.
//
// The controls are the other half: levels the GPU never wrote (including formats whose driver
// readback may be refused), levels the driver does not have yet because they were defined after
// the last sync, and a pending client write that has to land on top of GPU content. Magma never
// re-mints from a shadow (it copies image to image) and is the control backend.
#include <array>
#include <cstdint>
#include <cstdlib>
#include <sstream>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kEdge = 8;
constexpr const char* kVS = R"(#version 430 core
void main() {
vec2 positions[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0);
}
)";
constexpr const char* kSampleFS = R"(#version 430 core
uniform sampler2D u_tex;
out vec4 o_color;
void main() { o_color = texelFetch(u_tex, ivec2(gl_FragCoord.xy) % 4, 0); }
)";
// Copies an image into an RGBA8 image with imageLoad; a signed-normalized source is mapped
// to [0, 1] so it survives the unsigned destination.
std::string CopyCS(const char* srcFormat, bool snorm) {
std::string s = "#version 430 core\nlayout(local_size_x = 8, local_size_y = 8) in;\n";
s += std::string("layout(") + srcFormat + ", binding = 0) readonly uniform image2D u_src;\n";
s += "layout(rgba8, binding = 1) writeonly uniform image2D u_dst;\n";
s += "void main() { ivec2 p = ivec2(gl_GlobalInvocationID.xy); vec4 v = imageLoad(u_src, p);\n";
if (snorm) s += " v = v * 0.5 + 0.5;\n";
s += " imageStore(u_dst, p, v); }\n";
return s;
}
std::vector<GLubyte> Pattern(int edge, GLubyte magic) {
std::vector<GLubyte> t(static_cast<std::size_t>(edge * edge) * 4u);
for (int y = 0; y < edge; ++y)
for (int x = 0; x < edge; ++x) {
const std::size_t at = static_cast<std::size_t>((y * edge + x) * 4);
t[at + 0] = magic;
t[at + 1] = static_cast<GLubyte>(16 * (x + 1));
t[at + 2] = static_cast<GLubyte>(16 * (y + 1));
t[at + 3] = 0xFF;
}
return t;
}
std::vector<GLubyte> Solid(int edge, GLubyte r, GLubyte g, GLubyte b, GLubyte a) {
std::vector<GLubyte> t(static_cast<std::size_t>(edge * edge) * 4u);
for (std::size_t i = 0; i < t.size(); i += 4) {
t[i] = r;
t[i + 1] = g;
t[i + 2] = b;
t[i + 3] = a;
}
return t;
}
const GLfloat kGreen[4] = {0.0f, 1.0f, 0.0f, 1.0f};
class ImageBindableRemintScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_sampleProgram = CompileProgram(kVS, kSampleFS, &error);
ASSERT_NE(m_sampleProgram, 0u) << error;
m_scratch = MakeColorFbo(4, 4);
glGenVertexArrays(1, &m_vao);
FirstGLError();
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
glBindImageTexture(1, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
if (m_sampleProgram) glDeleteProgram(m_sampleProgram);
if (m_vao) glDeleteVertexArrays(1, &m_vao);
if (!m_textures.empty()) glDeleteTextures(static_cast<GLsizei>(m_textures.size()), m_textures.data());
if (m_fbo) glDeleteFramebuffers(1, &m_fbo);
BindDefaultFramebuffer();
DestroyColorFbo(m_scratch);
glViewport(0, 0, Gl().Width(), Gl().Height());
FirstGLError();
}
GLuint NewTexture() {
GLuint t = 0;
glGenTextures(1, &t);
m_textures.push_back(t);
return t;
}
static void Nearest(GLenum target, GLuint tex, int maxLevel) {
glBindTexture(target, tex);
glTexParameteri(target, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(target, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(target, GL_TEXTURE_MAX_LEVEL, maxLevel);
glBindTexture(target, 0);
}
// A draw that samples the texture: the backend now holds storage for it.
void SyncBySampling(GLuint tex) {
BindFbo(m_scratch);
glUseProgram(m_sampleProgram);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, tex);
glUniform1i(glGetUniformLocation(m_sampleProgram, "u_tex"), 0);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
glUseProgram(0);
BindDefaultFramebuffer();
glFinish();
}
// A GPU write the frontend's shadow never sees. A layer >= 0 names one layer of an
// array texture.
void GpuClear(GLuint tex, int level, const GLfloat* color, int layer = -1) {
if (!m_fbo) glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
if (layer >= 0) {
glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, tex, level, layer);
} else {
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, level);
}
EXPECT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE))
<< "the clear target is incomplete";
glClearBufferfv(GL_COLOR, 0, color);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0);
BindDefaultFramebuffer();
}
// The first image binding of (src, level[, layer]): an imageLoad copy into a fresh RGBA8
// destination, read back through an FBO.
std::vector<GLubyte> ImageCopy(GLuint src, int level, GLenum unitFormat, const char* glslFormat,
bool snorm = false, int layer = 0) {
const GLuint dst = NewTexture();
glBindTexture(GL_TEXTURE_2D, dst);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge, kEdge);
glBindTexture(GL_TEXTURE_2D, 0);
glBindImageTexture(1, dst, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8);
glBindImageTexture(0, src, level, GL_FALSE, layer, GL_READ_ONLY, unitFormat);
EXPECT_EQ(FirstGLError(), 0u) << "glBindImageTexture";
const std::string cs = CopyCS(glslFormat, snorm);
const char* csp = cs.c_str();
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(shader, 1, &csp, nullptr);
glCompileShader(shader);
const GLuint program = glCreateProgram();
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
EXPECT_EQ(linked, GL_TRUE) << "the copy program did not link";
glUseProgram(program);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
glUseProgram(0);
glDeleteProgram(program);
glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
glBindImageTexture(1, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
if (!m_fbo) glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, dst, 0);
std::vector<GLubyte> out(static_cast<std::size_t>(kEdge * kEdge) * 4u, 0xA5);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, kEdge, kEdge, GL_RGBA, GL_UNSIGNED_BYTE, out.data());
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0);
BindDefaultFramebuffer();
EXPECT_EQ(FirstGLError(), 0u) << "copy/readback";
return out;
}
// The edge x edge corner of `got` against an RGBA8 expectation, the first `channels`
// channels only, within 2 codes (a normalized value's round trip).
static void ExpectImage(const std::vector<GLubyte>& got, int edge, const std::vector<GLubyte>& want,
int channels = 4) {
int bad = 0;
std::ostringstream first;
for (int y = 0; y < edge; ++y)
for (int x = 0; x < edge; ++x) {
const std::size_t g = static_cast<std::size_t>((y * kEdge + x) * 4);
const std::size_t w = static_cast<std::size_t>((y * edge + x) * 4);
bool texelBad = false;
for (int c = 0; c < channels; ++c)
texelBad |= std::abs(int(got[g + c]) - int(want[w + c])) > 2;
if (texelBad && bad++ == 0) {
first << "(" << x << "," << y << ") got " << int(got[g]) << "," << int(got[g + 1]) << ","
<< int(got[g + 2]) << "," << int(got[g + 3]) << " want " << int(want[w]) << ","
<< int(want[w + 1]) << "," << int(want[w + 2]) << "," << int(want[w + 3]);
}
}
EXPECT_EQ(bad, 0) << bad << " of " << edge * edge << " texels differ; first " << first.str();
}
// One glTexSubImage of opaque blue per {x, y, w, h} box, left pending.
static void WriteBlueBoxes(GLuint tex, const std::vector<std::array<int, 4>>& boxes) {
glBindTexture(GL_TEXTURE_2D, tex);
for (const auto& b : boxes) {
std::vector<GLubyte> blue(static_cast<std::size_t>(b[2] * b[3]) * 4u, 0);
for (std::size_t i = 0; i < blue.size(); i += 4) {
blue[i + 2] = 255;
blue[i + 3] = 255;
}
glTexSubImage2D(GL_TEXTURE_2D, 0, b[0], b[1], b[2], b[3], GL_RGBA, GL_UNSIGNED_BYTE, blue.data());
}
glBindTexture(GL_TEXTURE_2D, 0);
}
// kEdge x kEdge green, blue inside the boxes.
static std::vector<GLubyte> GreenWithBlueBoxes(const std::vector<std::array<int, 4>>& boxes) {
auto want = Solid(kEdge, 0, 255, 0, 255);
for (const auto& b : boxes)
for (int y = b[1]; y < b[1] + b[3]; ++y)
for (int x = b[0]; x < b[0] + b[2]; ++x) {
const std::size_t at = static_cast<std::size_t>((y * kEdge + x) * 4);
want[at] = 0;
want[at + 1] = 0;
want[at + 2] = 255;
want[at + 3] = 255;
}
return want;
}
GLuint m_sampleProgram = 0, m_vao = 0, m_fbo = 0;
ColorFbo m_scratch{};
std::vector<GLuint> m_textures;
};
// Control: the shadow and the driver agree.
TEST_F(ImageBindableRemintScenario, UploadedLevelKeepsItsUpload) {
if (!Ready()) return;
const GLuint t = NewTexture();
const auto p = Pattern(kEdge, 0x40);
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data());
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, p);
}
TEST_F(ImageBindableRemintScenario, GpuClearOverAnUploadSurvives) {
if (!Ready()) return;
const GLuint t = NewTexture();
const auto p = Pattern(kEdge, 0x40);
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data());
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
GpuClear(t, 0, kGreen);
SyncBySampling(t);
ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, Solid(kEdge, 0, 255, 0, 255));
}
// Immutable in a core image format: the storage is already what an image unit needs.
TEST_F(ImageBindableRemintScenario, GpuClearOverAnImmutableUploadSurvives) {
if (!Ready()) return;
const GLuint t = NewTexture();
const auto p = Pattern(kEdge, 0x40);
glBindTexture(GL_TEXTURE_2D, t);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge, kEdge);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kEdge, kEdge, GL_RGBA, GL_UNSIGNED_BYTE, p.data());
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
GpuClear(t, 0, kGreen);
SyncBySampling(t);
ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, Solid(kEdge, 0, 255, 0, 255));
}
// A null-data definition only the GPU ever filled: the shadow holds zeroes.
TEST_F(ImageBindableRemintScenario, GpuClearOfANullDefinitionSurvives) {
if (!Ready()) return;
const GLuint t = NewTexture();
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
GpuClear(t, 0, kGreen);
SyncBySampling(t);
ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, Solid(kEdge, 0, 255, 0, 255));
}
// A client write still pending when the bind comes lands on top of the GPU's content,
// and only inside its own box.
TEST_F(ImageBindableRemintScenario, PendingSubImageLandsOnTheGpuClear) {
if (!Ready()) return;
const GLuint t = NewTexture();
const auto p = Pattern(kEdge, 0x40);
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data());
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
GpuClear(t, 0, kGreen);
SyncBySampling(t);
const auto blue = Solid(2, 0, 0, 255, 255);
glBindTexture(GL_TEXTURE_2D, t);
glTexSubImage2D(GL_TEXTURE_2D, 0, 2, 2, 2, 2, GL_RGBA, GL_UNSIGNED_BYTE, blue.data());
glBindTexture(GL_TEXTURE_2D, 0);
auto want = Solid(kEdge, 0, 255, 0, 255);
for (int y = 2; y < 4; ++y)
for (int x = 2; x < 4; ++x) {
const std::size_t at = static_cast<std::size_t>((y * kEdge + x) * 4);
want[at] = 0;
want[at + 1] = 0;
want[at + 2] = 255;
want[at + 3] = 255;
}
ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, want);
}
// Several pending writes: only their texels land on the GPU's clear, never the gaps between
// them. The re-mint merged the union box of the shadow over the readback whenever the rect
// list was withheld, and kept the shadow outright when that box spanned the level.
// Two 3x7 columns with a one-texel gap: 42 of their union's 49 texels, past the 3/4 at which
// the storage withholds its rect list.
TEST_F(ImageBindableRemintScenario, PendingSubImagesFillingThreeQuartersOfTheirUnionLandOnTheGpuClear) {
if (!Ready()) return;
const std::vector<std::array<int, 4>> boxes = {{0, 0, 3, 7}, {4, 0, 3, 7}};
const GLuint t = NewTexture();
const auto p = Pattern(kEdge, 0x40);
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data());
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
GpuClear(t, 0, kGreen);
SyncBySampling(t);
WriteBlueBoxes(t, boxes);
ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, GreenWithBlueBoxes(boxes));
}
// Three quadrants: the union box is the whole level; the fourth quadrant is the GPU's.
TEST_F(ImageBindableRemintScenario, PendingSubImagesWhoseUnionSpansTheLevelLandOnTheGpuClear) {
if (!Ready()) return;
const std::vector<std::array<int, 4>> boxes = {{0, 0, 4, 4}, {4, 0, 4, 4}, {0, 4, 4, 4}};
const GLuint t = NewTexture();
const auto p = Pattern(kEdge, 0x40);
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data());
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
GpuClear(t, 0, kGreen);
SyncBySampling(t);
WriteBlueBoxes(t, boxes);
ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, GreenWithBlueBoxes(boxes));
}
TEST_F(ImageBindableRemintScenario, GeneratedMipLevelSurvives) {
if (!Ready()) return;
const GLuint t = NewTexture();
const auto s = Solid(kEdge, 200, 100, 50, 255);
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, s.data());
glGenerateMipmap(GL_TEXTURE_2D);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glBindTexture(GL_TEXTURE_2D, 0);
SyncBySampling(t);
ExpectImage(ImageCopy(t, 1, GL_RGBA8, "rgba8"), kEdge / 2, Solid(kEdge / 2, 200, 100, 50, 255));
}
TEST_F(ImageBindableRemintScenario, GpuClearOfAnUploadedMipLevelSurvives) {
if (!Ready()) return;
const GLuint t = NewTexture();
const auto s0 = Solid(kEdge, 200, 100, 50, 255);
const auto s1 = Solid(kEdge / 2, 10, 20, 30, 255);
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, s0.data());
glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge / 2, kEdge / 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, s1.data());
Nearest(GL_TEXTURE_2D, t, 1);
SyncBySampling(t);
GpuClear(t, 1, kGreen);
SyncBySampling(t);
ExpectImage(ImageCopy(t, 1, GL_RGBA8, "rgba8"), kEdge / 2, Solid(kEdge / 2, 0, 255, 0, 255));
}
// Control: a level defined (null data) after the last sync is not on the driver yet, so
// there is nothing to read back for it; the bound level 0 must still be the upload.
TEST_F(ImageBindableRemintScenario, LevelDefinedAfterTheSyncDoesNotDisturbTheOthers) {
if (!Ready()) return;
const GLuint t = NewTexture();
const auto p = Pattern(kEdge, 0x40);
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data());
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge / 2, kEdge / 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glBindTexture(GL_TEXTURE_2D, 0);
ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, p);
}
// Control: the same late level, partly uploaded - the shadow is the only copy of it.
TEST_F(ImageBindableRemintScenario, LevelDefinedAndUploadedAfterTheSyncKeepsTheUpload) {
if (!Ready()) return;
const GLuint t = NewTexture();
const auto p = Pattern(kEdge, 0x40);
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data());
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge / 2, kEdge / 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
const auto blue = Solid(2, 0, 0, 255, 255);
glTexSubImage2D(GL_TEXTURE_2D, 1, 0, 0, 2, 2, GL_RGBA, GL_UNSIGNED_BYTE, blue.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1);
glBindTexture(GL_TEXTURE_2D, 0);
ExpectImage(ImageCopy(t, 1, GL_RGBA8, "rgba8"), 2, blue);
}
// GL_RG8 is not an ESSL image format, so the re-mint also WIDENS the storage (to RGBA8)
// and cannot keep the old allocation even though it is immutable.
TEST_F(ImageBindableRemintScenario, GpuClearSurvivesAWideningRemint) {
if (!Ready()) return;
const GLuint t = NewTexture();
std::vector<GLubyte> rg(static_cast<std::size_t>(kEdge * kEdge) * 2u, 0x33);
glBindTexture(GL_TEXTURE_2D, t);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RG8, kEdge, kEdge);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kEdge, kEdge, GL_RG, GL_UNSIGNED_BYTE, rg.data());
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
const GLfloat c[4] = {0.5f, 0.25f, 0.0f, 1.0f};
GpuClear(t, 0, c);
SyncBySampling(t);
ExpectImage(ImageCopy(t, 0, GL_RG8, "rg8"), kEdge, Solid(kEdge, 128, 64, 0, 255), 2);
}
// Control: widened, uploaded only.
TEST_F(ImageBindableRemintScenario, UploadSurvivesAWideningRemint) {
if (!Ready()) return;
const GLuint t = NewTexture();
std::vector<GLubyte> rg(static_cast<std::size_t>(kEdge * kEdge) * 2u);
for (std::size_t i = 0; i < rg.size(); i += 2) {
rg[i] = 0x33;
rg[i + 1] = 0x99;
}
glBindTexture(GL_TEXTURE_2D, t);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RG8, kEdge, kEdge);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kEdge, kEdge, GL_RG, GL_UNSIGNED_BYTE, rg.data());
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
ExpectImage(ImageCopy(t, 0, GL_RG8, "rg8"), kEdge, Solid(kEdge, 0x33, 0x99, 0, 255), 2);
}
// Control: a signed-normalized level (not colour-renderable on every ES driver, so its
// readback may be refused and the shadow used instead), uploaded only.
TEST_F(ImageBindableRemintScenario, SnormUploadSurvives) {
if (!Ready()) return;
const GLuint t = NewTexture();
std::vector<GLbyte> s(static_cast<std::size_t>(kEdge * kEdge) * 4u);
for (std::size_t i = 0; i < s.size(); i += 4) {
s[i] = 127;
s[i + 1] = 0;
s[i + 2] = -127;
s[i + 3] = 127;
}
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8_SNORM, kEdge, kEdge, 0, GL_RGBA, GL_BYTE, s.data());
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
ExpectImage(ImageCopy(t, 0, GL_RGBA8_SNORM, "rgba8_snorm", true), kEdge, Solid(kEdge, 255, 128, 0, 255),
3);
}
// Control: RGBA16_SNORM is desktop-only as an image format, so this re-mint widens too.
TEST_F(ImageBindableRemintScenario, Snorm16UploadSurvivesAWideningRemint) {
if (!Ready()) return;
const GLuint t = NewTexture();
std::vector<GLshort> s(static_cast<std::size_t>(kEdge * kEdge) * 4u);
for (std::size_t i = 0; i < s.size(); i += 4) {
s[i] = 32767;
s[i + 1] = 0;
s[i + 2] = -32767;
s[i + 3] = 32767;
}
glBindTexture(GL_TEXTURE_2D, t);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA16_SNORM, kEdge, kEdge);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kEdge, kEdge, GL_RGBA, GL_SHORT, s.data());
Nearest(GL_TEXTURE_2D, t, 0);
SyncBySampling(t);
ExpectImage(ImageCopy(t, 0, GL_RGBA16_SNORM, "rgba16_snorm", true), kEdge,
Solid(kEdge, 255, 128, 0, 255), 3);
}
// An array texture is read back one layer at a time: the GPU-cleared layer and the
// uploaded one both have to come through. The clear's FBO attach is what gives the
// texture its driver storage here.
TEST_F(ImageBindableRemintScenario, GpuClearOfOneArrayLayerSurvivesBesideTheUploadedLayer) {
if (!Ready()) return;
const GLuint t = NewTexture();
const auto p = Pattern(kEdge, 0x40);
std::vector<GLubyte> layers(p);
layers.insert(layers.end(), p.begin(), p.end());
glBindTexture(GL_TEXTURE_2D_ARRAY, t);
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kEdge, kEdge, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE,
layers.data());
glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
Nearest(GL_TEXTURE_2D_ARRAY, t, 0);
GpuClear(t, 0, kGreen, /*layer=*/1);
glFinish();
ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8", false, /*layer=*/1), kEdge, Solid(kEdge, 0, 255, 0, 255));
ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8", false, /*layer=*/0), kEdge, p);
}
// A view owns no storage, so its first image binding is the VIEWED texture's transition,
// and the GPU's texels there have to come through it. Needs glTextureView, which Espryt
// only has where the ES driver does (not on llvmpipe).
TEST_F(ImageBindableRemintScenario, GpuClearSurvivesTheFirstImageBindingOfAView) {
if (!Ready()) return;
const GLuint owner = NewTexture();
const auto p = Pattern(kEdge, 0x40);
glBindTexture(GL_TEXTURE_2D, owner);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge, kEdge);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kEdge, kEdge, GL_RGBA, GL_UNSIGNED_BYTE, p.data());
Nearest(GL_TEXTURE_2D, owner, 0);
SyncBySampling(owner);
GpuClear(owner, 0, kGreen);
SyncBySampling(owner);
const GLuint view = NewTexture();
FirstGLError();
glTextureView(view, GL_TEXTURE_2D, owner, GL_RGBA8, 0, 1, 0, 1);
if (const GLenum error = FirstGLError()) {
GTEST_SKIP() << "glTextureView is unavailable on backend " << Gl().BackendName() << " ("
<< GLErrorName(error) << ")";
}
Nearest(GL_TEXTURE_2D, view, 0);
SyncBySampling(view);
ExpectImage(ImageCopy(view, 0, GL_RGBA8, "rgba8"), kEdge, Solid(kEdge, 0, 255, 0, 255));
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,772 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/OversizedRecordingSplitScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - A FRAME WITH MORE DRAWS THAN ONE COMMAND BUFFER MAY HOLD IS SPLIT, AND LOSES NOTHING.
//
// DirectVulkan records a frame into one command buffer, and used to submit it only at Present.
// A driver keeps a command buffer's GPU memory until the buffer is freed, and the Adreno 830
// driver maps that memory into the process in 16 KiB chunks. Minecraft rd12's loading frame
// records 981,654 draws (3.1M commands) into one buffer. It grew to ~40k such mappings and
// aborted inside the driver's own calloc once vm.max_map_count (65530) was spent. The renderer
// now submits the open buffer after MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER draws and goes
// on recording into a fresh one (VulkanRenderer::SplitOversizedRecording).
//
// The first case writes each pixel of a 32x32 target with its own draw and its own uniform
// value, so it pins that no draw is lost and no per-draw state is left behind on either side of
// a split. The rest put GL state that one draw leaves for a later one ACROSS the split: a depth
// occluder, a stencil mark, a blend destination, the samples of a multisample target, an
// occlusion or primitives-generated query that is still open, and a transform-feedback capture
// that is still active. Each draws its "before" part, then kFillers draws, then its "after"
// part. kFillers is more than twice the SplitRecording lane's budget of 64, so at least one
// split lands between the two parts wherever the recording's count stood.
//
// The ambient registrations run the same draws unsplit, because the default budget is far above
// any of these frames; that is the control. The DirectVulkan.SplitRecording. entry pins the
// budget to 64, and there every case also asserts the renderer's latched MGLOG_I - the one
// assertion that is red on a renderer that never splits.
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <sstream>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kSide = 32;
constexpr int kDraws = kSide * kSide;
// More than twice the SplitRecording lane's budget (64): wherever the count stands when
// they start, at least one split lands among them.
constexpr int kFillers = 150;
// Draws inside the query and capture spans: also more than twice the budget.
constexpr int kSpanDraws = 200;
// aPos spans the unit square; the uniform moves it onto cell (i % 32, i / 32) of a
// 32x32 grid, which is exactly one pixel of a 32x32 target.
constexpr const char* kVS = R"(#version 330 core
in vec2 aPos;
uniform int uIndex;
void main() {
vec2 cell = vec2(float(uIndex % 32), float(uIndex / 32));
gl_Position = vec4((cell + aPos) / 16.0 - 1.0, 0.0, 1.0);
}
)";
// Multiples of 8/255 survive an RGBA8 round trip exactly, so the readback bytes are
// (8x, 8y, 255, 255) for the pixel at (x, y) and nothing else can produce them.
constexpr const char* kFS = R"(#version 330 core
uniform int uIndex;
out vec4 o_color;
void main() {
o_color = vec4(float(uIndex % 32) * 8.0 / 255.0, float(uIndex / 32) * 8.0 / 255.0, 1.0, 1.0);
}
)";
// The state cases: the unit-square vertices (or a triangle of them) stretched over an
// NDC rectangle at a given depth, in a uniform colour. A zero rectangle makes a
// zero-area primitive - a draw that reaches the renderer and rasterizes nothing.
constexpr const char* kRectVS = R"(#version 330 core
in vec2 aPos;
uniform vec4 uRect;
uniform float uDepth;
void main() { gl_Position = vec4(mix(uRect.xy, uRect.zw, aPos), uDepth, 1.0); }
)";
constexpr const char* kRectFS = R"(#version 330 core
uniform vec4 uColor;
out vec4 o_color;
void main() { o_color = uColor; }
)";
// The capture case: one point per draw, carrying 2 * uIndex + 1.
constexpr const char* kCaptureVS = R"(#version 330 core
uniform int uIndex;
out float vs_value;
void main() {
vs_value = float(uIndex) * 2.0 + 1.0;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
constexpr const char* kCaptureFS = R"(#version 330 core
out vec4 o_color;
void main() { o_color = vec4(1.0); }
)";
constexpr float kCapturePoison = -12345.0f;
// The budget the process was launched with, or 0 when it is unset or not a number.
unsigned long PinnedDrawBudget() {
const char* value = std::getenv("MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER");
if (value == nullptr || *value == '\0') return 0;
char* end = nullptr;
const unsigned long parsed = std::strtoul(value, &end, 10);
return (end != nullptr && *end == '\0') ? parsed : 0;
}
// The library log, for the arming assertion. Same machinery and reasoning as
// UnlocatedIoBlockScenario: MOBILEGL_LOG_FILE_PATH is read at log-init, every process in
// the lane appends to the file, and only bytes appended after the snapshot count.
std::filesystem::path LibraryLogPath() {
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
return (path != nullptr && *path != '\0') ? std::filesystem::path(path) : std::filesystem::path();
}
std::uintmax_t LibraryLogSize() {
std::error_code ec;
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return 0;
const std::uintmax_t size = std::filesystem::file_size(path, ec);
return ec ? 0 : size;
}
std::string LibraryLogSince(std::uintmax_t offset) {
const std::filesystem::path path = LibraryLogPath();
if (path.empty()) return {};
std::ifstream file(path, std::ios::binary);
if (!file.good()) return {};
file.seekg(static_cast<std::streamoff>(offset));
return std::string((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
}
std::string Describe(const Rgba8& c) {
std::ostringstream os;
os << "rgba(" << static_cast<int>(c.r) << ", " << static_cast<int>(c.g) << ", " << static_cast<int>(c.b)
<< ", " << static_cast<int>(c.a) << ")";
return os.str();
}
::testing::AssertionResult EveryPixelHoldsItsOwnDraw(const Image& image) {
int wrong = 0;
int firstX = -1, firstY = -1;
Rgba8 firstSeen{};
for (int y = 0; y < kSide; ++y) {
for (int x = 0; x < kSide; ++x) {
const Rgba8 expected{static_cast<std::uint8_t>(8 * x), static_cast<std::uint8_t>(8 * y), 255, 255};
const Rgba8 seen = image.At(x, y);
if (seen != expected) {
if (wrong == 0) {
firstX = x;
firstY = y;
firstSeen = seen;
}
++wrong;
}
}
}
if (wrong == 0) {
return ::testing::AssertionSuccess();
}
return ::testing::AssertionFailure()
<< wrong << " of " << kDraws << " pixels do not hold their own draw's colour. First: (" << firstX
<< ", " << firstY << ") = " << firstSeen << ", drawn by draw " << (firstY * kSide + firstX)
<< " (a pixel no draw reached still holds the clear colour 0,0,0,0)";
}
// Every pixel of columns [x0, x1] (all rows) is `expected`, each channel within `tolerance`.
::testing::AssertionResult ColumnsAre(const Image& image, int x0, int x1, const Rgba8& expected,
int tolerance, const char* what) {
int wrong = 0;
int firstX = -1, firstY = -1;
Rgba8 firstSeen{};
for (int y = 0; y < kSide; ++y) {
for (int x = x0; x <= x1; ++x) {
const Rgba8 seen = image.At(x, y);
const bool close = std::abs(seen.r - expected.r) <= tolerance &&
std::abs(seen.g - expected.g) <= tolerance &&
std::abs(seen.b - expected.b) <= tolerance &&
std::abs(seen.a - expected.a) <= tolerance;
if (!close) {
if (wrong == 0) {
firstX = x;
firstY = y;
firstSeen = seen;
}
++wrong;
}
}
}
if (wrong == 0) return ::testing::AssertionSuccess();
return ::testing::AssertionFailure()
<< what << ": " << wrong << " pixels of columns " << x0 << ".." << x1 << " are not "
<< Describe(expected) << "; first (" << firstX << ", " << firstY << ") = " << Describe(firstSeen);
}
class OversizedRecordingSplitScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
// Taken before the case draws anything, so the line the arming check looks for
// can only have been written by this case. The renderer latches it at its first
// split, and ctest gives every case a process of its own.
m_logBefore = LibraryLogSize();
// 0..3: the unit square as a strip. 4..6: its lower-left triangle. 7..9: its
// upper-right triangle - the two share the diagonal, so together they cover every
// sample of the square exactly once.
static const float kVertices[] = {0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f,
1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f};
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(kVertices), kVertices, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
}
void TearDown() override {
if (Ready()) {
glDisable(GL_DEPTH_TEST);
glDisable(GL_STENCIL_TEST);
glDisable(GL_BLEND);
glDisable(GL_RASTERIZER_DISCARD);
glDepthMask(GL_TRUE);
glStencilMask(0xFF);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
glUseProgram(0);
glBindVertexArray(0);
BindDefaultFramebuffer();
if (m_rectProgram != 0) glDeleteProgram(m_rectProgram);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
for (GLuint rbo : m_renderbuffers) glDeleteRenderbuffers(1, &rbo);
for (GLuint fbo : m_framebuffers) glDeleteFramebuffers(1, &fbo);
for (ColorFbo& target : m_colorTargets) DestroyColorFbo(target);
}
ScenarioTest::TearDown();
}
// In the SplitRecording lane (budget pinned below what every case draws, DirectVulkan,
// a log to read), a split must have happened during this case. Elsewhere this case is
// the unsplit control and there is nothing to arm.
void ExpectTheRecordingWasSplitWhereTheBudgetIsPinned() {
const unsigned long budget = PinnedDrawBudget();
const bool splitExpected = budget > 0 && budget * 2 < static_cast<unsigned long>(kFillers) &&
Gl().BackendName() == std::string("DirectVulkan") &&
!LibraryLogPath().empty();
RecordProperty("arming_checked", splitExpected ? "yes" : "no");
if (!splitExpected) return;
const std::string appended = LibraryLogSince(m_logBefore);
EXPECT_NE(appended.find("draws into one command buffer; submitting it"), std::string::npos)
<< "MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER=" << budget << " and this case recorded more "
<< "than " << kFillers << " draws in one frame, but DirectVulkan never reported splitting the "
"recording. A renderer that does not split grows a single command buffer without bound "
"(rd12 on Adreno: ~40k driver mappings, then SIGABRT). See "
"VulkanRenderer::SplitOversizedRecording. Log appended by this case:\n"
<< appended;
}
bool BuildRectProgram(std::string* error) {
m_rectProgram = CompileProgram(kRectVS, kRectFS, error);
if (m_rectProgram == 0) return false;
m_rectLoc = glGetUniformLocation(m_rectProgram, "uRect");
m_depthLoc = glGetUniformLocation(m_rectProgram, "uDepth");
m_colorLoc = glGetUniformLocation(m_rectProgram, "uColor");
glUseProgram(m_rectProgram);
glBindVertexArray(m_vao);
return true;
}
// The rectangle [x0, x1] x [y0, y1] in NDC; `first`/`count` pick the strip or a triangle.
void DrawRect(float x0, float y0, float x1, float y1, float depth, float r, float g, float b, float a,
GLenum mode = GL_TRIANGLE_STRIP, GLint first = 0, GLsizei count = 4) {
glUniform4f(m_rectLoc, x0, y0, x1, y1);
glUniform1f(m_depthLoc, depth);
glUniform4f(m_colorLoc, r, g, b, a);
glDrawArrays(mode, first, count);
}
// One pixel of the 32x32 grid, by index.
void DrawPixel(int index, float r, float g, float b, float a) {
const float x = static_cast<float>(index % kSide);
const float y = static_cast<float>(index / kSide);
DrawRect(x / 16.0f - 1.0f, y / 16.0f - 1.0f, (x + 1.0f) / 16.0f - 1.0f, (y + 1.0f) / 16.0f - 1.0f, 0.0f,
r, g, b, a);
}
// Draws that reach the renderer and rasterize nothing: zero-area strips.
void DrawFillers(int count) {
for (int i = 0; i < count; ++i) {
DrawRect(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
}
}
// A kSide x kSide RGBA8 target, optionally with a DEPTH24_STENCIL8 renderbuffer.
// Returns fbo 0 when the driver will not complete it.
ColorFbo MakeTarget(bool withDepthStencil) {
ColorFbo target = MakeColorFbo(kSide, kSide);
if (target.fbo == 0) return target;
m_colorTargets.push_back(target);
if (withDepthStencil) {
GLuint rbo = 0;
glGenRenderbuffers(1, &rbo);
m_renderbuffers.push_back(rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, kSide, kSide);
glBindFramebuffer(GL_FRAMEBUFFER, target.fbo);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
target.fbo = 0;
}
}
return target;
}
Image ReadTarget(const ColorFbo& target) {
glBindFramebuffer(GL_FRAMEBUFFER, target.fbo);
return ReadPixels(kSide, kSide);
}
std::uintmax_t m_logBefore = 0;
GLuint m_vao = 0;
GLuint m_vbo = 0;
GLuint m_rectProgram = 0;
GLint m_rectLoc = -1;
GLint m_depthLoc = -1;
GLint m_colorLoc = -1;
std::vector<ColorFbo> m_colorTargets;
std::vector<GLuint> m_renderbuffers;
std::vector<GLuint> m_framebuffers;
};
} // namespace
// kDraws single-pixel quads into a fresh target, one uniform value per draw, with no
// readback, flush or rebind in between. Every pixel must hold its own draw's colour.
TEST_F(OversizedRecordingSplitScenario, EveryDrawOfTheFrameLandsAcrossCommandBufferSplits) {
if (!Ready()) return;
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
ColorFbo target = MakeTarget(false);
ASSERT_NE(target.fbo, 0u) << "MakeColorFbo failed";
BindFbo(target);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
ClearTo(0.0f, 0.0f, 0.0f, 0.0f);
glUseProgram(program);
glBindVertexArray(m_vao);
const GLint indexLocation = glGetUniformLocation(program, "uIndex");
for (int i = 0; i < kDraws; ++i) {
glUniform1i(indexLocation, i);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
}
const Image image = ReadPixels(kSide, kSide);
glUseProgram(0);
glDeleteProgram(program);
EXPECT_TRUE(EveryPixelHoldsItsOwnDraw(image));
EXPECT_EQ(FirstGLError(), 0u);
ExpectTheRecordingWasSplitWhereTheBudgetIsPinned();
}
// (a) An occluder drawn before the split must still fail the depth test of a draw after it:
// the depth attachment's contents have to survive the render pass ending and re-beginning.
TEST_F(OversizedRecordingSplitScenario, ADepthOccluderDrawnBeforeTheSplitHidesADrawAfterIt) {
if (!Ready()) return;
std::string error;
ASSERT_TRUE(BuildRectProgram(&error)) << error;
ColorFbo target = MakeTarget(true);
if (target.fbo == 0) GTEST_SKIP() << "no complete RGBA8 + DEPTH24_STENCIL8 target on this driver";
BindFbo(target);
glClearDepth(1.0);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
glDepthMask(GL_TRUE);
DrawRect(-1.0f, -1.0f, 0.0f, 1.0f, -0.5f, 1.0f, 0.0f, 0.0f, 1.0f); // left half, near, red
DrawFillers(kFillers);
DrawRect(-1.0f, -1.0f, 1.0f, 1.0f, 0.5f, 0.0f, 1.0f, 0.0f, 1.0f); // everything, far, green
const Image image = ReadTarget(target);
EXPECT_TRUE(ColumnsAre(image, 0, kSide / 2 - 1, Rgba8{255, 0, 0, 255}, 0,
"the near occluder drawn before the split must hide the far draw after it"));
EXPECT_TRUE(ColumnsAre(image, kSide / 2, kSide - 1, Rgba8{0, 255, 0, 255}, 0,
"where nothing occludes, the far draw after the split must land"));
EXPECT_EQ(FirstGLError(), 0u);
ExpectTheRecordingWasSplitWhereTheBudgetIsPinned();
}
// (b) A stencil mark made before the split must gate a draw after it.
TEST_F(OversizedRecordingSplitScenario, AStencilMarkMadeBeforeTheSplitGatesADrawAfterIt) {
if (!Ready()) return;
std::string error;
ASSERT_TRUE(BuildRectProgram(&error)) << error;
ColorFbo target = MakeTarget(true);
if (target.fbo == 0) GTEST_SKIP() << "no complete RGBA8 + DEPTH24_STENCIL8 target on this driver";
BindFbo(target);
glDisable(GL_DEPTH_TEST);
glStencilMask(0xFF);
glClearStencil(0);
glClear(GL_STENCIL_BUFFER_BIT);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glEnable(GL_STENCIL_TEST);
glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
DrawRect(-1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f); // left half: stencil 1, blue
glStencilFunc(GL_EQUAL, 1, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
DrawFillers(kFillers);
DrawRect(-1.0f, -1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f); // everything, green, where stencil == 1
const Image image = ReadTarget(target);
EXPECT_TRUE(ColumnsAre(image, 0, kSide / 2 - 1, Rgba8{0, 255, 0, 255}, 0,
"where the mark was made before the split, the draw after it must pass"));
EXPECT_TRUE(ColumnsAre(image, kSide / 2, kSide - 1, Rgba8{0, 0, 0, 255}, 0,
"where no mark was made, the draw after the split must be stencilled out"));
EXPECT_EQ(FirstGLError(), 0u);
ExpectTheRecordingWasSplitWhereTheBudgetIsPinned();
}
// (c) Additive blending onto pixels written before the split: every one of kSpanDraws
// full-target draws adds exactly 1/255 of red onto a target whose green was written first,
// so the result counts every draw on both sides of every split.
TEST_F(OversizedRecordingSplitScenario, BlendingAccumulatesOntoPixelsWrittenBeforeTheSplit) {
if (!Ready()) return;
std::string error;
ASSERT_TRUE(BuildRectProgram(&error)) << error;
ColorFbo target = MakeTarget(false);
ASSERT_NE(target.fbo, 0u) << "MakeColorFbo failed";
BindFbo(target);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 0.0f);
glEnable(GL_BLEND);
glBlendEquation(GL_FUNC_ADD);
glBlendFunc(GL_ONE, GL_ONE);
DrawRect(-1.0f, -1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 55.0f / 255.0f, 0.0f, 0.0f);
for (int i = 0; i < kSpanDraws; ++i) {
DrawRect(-1.0f, -1.0f, 1.0f, 1.0f, 0.0f, 1.0f / 255.0f, 0.0f, 0.0f, 0.0f);
}
const Image image = ReadTarget(target);
EXPECT_TRUE(ColumnsAre(image, 0, kSide - 1, Rgba8{static_cast<std::uint8_t>(kSpanDraws), 55, 0, 0}, 1,
"every additive draw, and the green written before any split, must be in the sum"));
EXPECT_EQ(FirstGLError(), 0u);
ExpectTheRecordingWasSplitWhereTheBudgetIsPinned();
}
// (d) A multisample target drawn on both sides of the split, then resolved. Before the
// split a triangle covers the lower-left half of the target; after it, the complementary
// triangle covers the upper-right half. On the diagonal each pixel's samples are shared
// between the two, so only a target that kept every SAMPLE across the split resolves them to
// red + green = 255. One that was resolved or re-broadcast at the split gives about 190
// there, and one whose samples were dropped gives black or garbage.
TEST_F(OversizedRecordingSplitScenario, AMultisampleTargetKeepsItsSamplesAcrossTheSplit) {
if (!Ready()) return;
std::string error;
ASSERT_TRUE(BuildRectProgram(&error)) << error;
GLint maxSamples = 0;
glGetIntegerv(GL_MAX_SAMPLES, &maxSamples);
if (maxSamples < 2) {
GTEST_SKIP() << "GL_MAX_SAMPLES is " << maxSamples << "; this needs a multisample renderbuffer";
}
const GLsizei samples = std::min<GLint>(4, maxSamples);
GLuint msFbo = 0, msRbo = 0;
glGenFramebuffers(1, &msFbo);
m_framebuffers.push_back(msFbo);
glGenRenderbuffers(1, &msRbo);
m_renderbuffers.push_back(msRbo);
glBindRenderbuffer(GL_RENDERBUFFER, msRbo);
glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, GL_RGBA8, kSide, kSide);
glBindFramebuffer(GL_FRAMEBUFFER, msFbo);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, msRbo);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
GTEST_SKIP() << "no complete " << samples << "x multisample RGBA8 renderbuffer on this driver";
}
ColorFbo resolved = MakeTarget(false);
ASSERT_NE(resolved.fbo, 0u) << "MakeColorFbo failed";
glBindFramebuffer(GL_FRAMEBUFFER, msFbo);
glViewport(0, 0, kSide, kSide);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawRect(-1.0f, -1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, GL_TRIANGLES, 4, 3); // lower-left, red
DrawFillers(kFillers);
DrawRect(-1.0f, -1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, GL_TRIANGLES, 7, 3); // upper-right, green
glBindFramebuffer(GL_READ_FRAMEBUFFER, msFbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, resolved.fbo);
glBlitFramebuffer(0, 0, kSide, kSide, 0, 0, kSide, kSide, GL_COLOR_BUFFER_BIT, GL_NEAREST);
const Image image = ReadTarget(resolved);
int wrong = 0;
std::string first;
for (int y = 0; y < kSide && wrong < 1000; ++y) {
for (int x = 0; x < kSide; ++x) {
const Rgba8 seen = image.At(x, y);
bool ok = false;
// The diagonal x + y = kSide in pixel units passes through the pixels whose
// x + y == kSide - 1; every other pixel lies wholly on one side of it.
if (x + y < kSide - 1) {
ok = seen == Rgba8{255, 0, 0, 255};
} else if (x + y > kSide - 1) {
ok = seen == Rgba8{0, 255, 0, 255};
} else {
ok = seen.b == 0 && seen.a == 255 && seen.r > 0 && seen.g > 0 &&
std::abs(static_cast<int>(seen.r) + static_cast<int>(seen.g) - 255) <= 3;
}
if (!ok) {
if (wrong == 0) {
std::ostringstream os;
os << "(" << x << ", " << y << ") = " << Describe(seen);
first = os.str();
}
++wrong;
}
}
}
EXPECT_EQ(wrong, 0) << wrong << " resolved pixels are wrong; first " << first
<< ". Lower-left of the diagonal must be red, upper-right green, and a pixel on it "
"red + green = 255 with both present (its samples were split between a draw before "
"the command-buffer split and one after it)";
EXPECT_EQ(FirstGLError(), 0u);
ExpectTheRecordingWasSplitWhereTheBudgetIsPinned();
}
// (e) Occlusion queries open across the split. GL_SAMPLES_PASSED counts kSpanDraws
// single-pixel draws on both sides of it; GL_ANY_SAMPLES_PASSED is answered by the one draw
// made before the split, the fillers after it pass nothing - with a fillers-only control
// that must answer false.
TEST_F(OversizedRecordingSplitScenario, OcclusionQueriesOpenAcrossTheSplitCountBothSides) {
if (!Ready()) return;
std::string error;
ASSERT_TRUE(BuildRectProgram(&error)) << error;
ColorFbo target = MakeTarget(false);
ASSERT_NE(target.fbo, 0u) << "MakeColorFbo failed";
BindFbo(target);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
GLuint queries[3] = {};
glGenQueries(3, queries);
FirstGLError();
// Any samples: control first (fillers only), then one real pixel before the split.
glBeginQuery(GL_ANY_SAMPLES_PASSED, queries[0]);
const unsigned int anyBeginError = FirstGLError();
if (anyBeginError != 0u) {
glDeleteQueries(3, queries);
GTEST_SKIP() << Gl().BackendName() << " does not accept GL_ANY_SAMPLES_PASSED ("
<< GLErrorName(anyBeginError) << ")";
}
DrawFillers(kFillers);
glEndQuery(GL_ANY_SAMPLES_PASSED);
glBeginQuery(GL_ANY_SAMPLES_PASSED, queries[1]);
DrawPixel(0, 1.0f, 1.0f, 1.0f, 1.0f);
DrawFillers(kFillers);
glEndQuery(GL_ANY_SAMPLES_PASSED);
GLuint anyControl = 7, anyAcross = 7;
glGetQueryObjectuiv(queries[0], GL_QUERY_RESULT, &anyControl);
glGetQueryObjectuiv(queries[1], GL_QUERY_RESULT, &anyAcross);
EXPECT_EQ(anyControl, static_cast<GLuint>(GL_FALSE)) << "fillers alone rasterize nothing";
EXPECT_EQ(anyAcross, static_cast<GLuint>(GL_TRUE))
<< "the only passing draw was made before the split; the query that spans it lost that answer";
// Samples passed: kSpanDraws distinct single-sample pixels, spanning at least one split.
glBeginQuery(GL_SAMPLES_PASSED, queries[2]);
const unsigned int samplesBeginError = FirstGLError();
if (samplesBeginError == 0u) {
for (int i = 0; i < kSpanDraws; ++i) {
DrawPixel(i, 1.0f, 1.0f, 1.0f, 1.0f);
}
glEndQuery(GL_SAMPLES_PASSED);
GLuint samplesPassed = 0;
glGetQueryObjectuiv(queries[2], GL_QUERY_RESULT, &samplesPassed);
if (Gl().BackendName() == std::string("DirectGLES")) {
// OpenGL ES has no sample-counting occlusion query, only ANY_SAMPLES_PASSED*, so
// DirectGLES answers GL_SAMPLES_PASSED with a 0/1 approximation (DirectGLES.cpp,
// the occlusion-query result path). The exact count is not this backend's to give.
RecordProperty("samples_passed", "0/1 approximation on OpenGL ES; exact count not checked");
EXPECT_EQ(samplesPassed, 1u) << "samples passed, so the approximation must read 1";
} else {
EXPECT_EQ(samplesPassed, static_cast<GLuint>(kSpanDraws))
<< "each of the " << kSpanDraws << " draws covers exactly one single-sample pixel";
}
} else {
RecordProperty("samples_passed", std::string("not accepted: ") + GLErrorName(samplesBeginError));
}
glDeleteQueries(3, queries);
EXPECT_EQ(FirstGLError(), 0u);
ExpectTheRecordingWasSplitWhereTheBudgetIsPinned();
}
// (e) GL_PRIMITIVES_GENERATED open across the split: kSpanDraws strips of two triangles.
// A one-draw query first establishes that this stack counts at all.
TEST_F(OversizedRecordingSplitScenario, APrimitivesGeneratedQueryOpenAcrossTheSplitCountsBothSides) {
if (!Ready()) return;
std::string error;
ASSERT_TRUE(BuildRectProgram(&error)) << error;
ColorFbo target = MakeTarget(false);
ASSERT_NE(target.fbo, 0u) << "MakeColorFbo failed";
BindFbo(target);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
GLuint queries[2] = {};
glGenQueries(2, queries);
FirstGLError();
glBeginQuery(GL_PRIMITIVES_GENERATED, queries[0]);
const unsigned int beginError = FirstGLError();
if (beginError != 0u) {
glDeleteQueries(2, queries);
GTEST_SKIP() << Gl().BackendName() << " does not accept GL_PRIMITIVES_GENERATED ("
<< GLErrorName(beginError) << ")";
}
DrawPixel(0, 1.0f, 1.0f, 1.0f, 1.0f);
glEndQuery(GL_PRIMITIVES_GENERATED);
GLuint control = 0;
glGetQueryObjectuiv(queries[0], GL_QUERY_RESULT, &control);
if (control == 0u) {
glDeleteQueries(2, queries);
GTEST_SKIP() << "GL_PRIMITIVES_GENERATED reads 0 for a single unsplit strip on " << Gl().BackendName()
<< " (" << Gl().RendererString() << "): nothing this stack counts, so nothing to carry";
}
ASSERT_EQ(control, 2u) << "one strip of four vertices is two triangles";
glBeginQuery(GL_PRIMITIVES_GENERATED, queries[1]);
for (int i = 0; i < kSpanDraws; ++i) {
DrawPixel(i, 1.0f, 1.0f, 1.0f, 1.0f);
}
glEndQuery(GL_PRIMITIVES_GENERATED);
GLuint generated = 0;
glGetQueryObjectuiv(queries[1], GL_QUERY_RESULT, &generated);
EXPECT_EQ(generated, static_cast<GLuint>(2 * kSpanDraws))
<< kSpanDraws << " strips of two triangles each, drawn across at least one split";
glDeleteQueries(2, queries);
EXPECT_EQ(FirstGLError(), 0u);
ExpectTheRecordingWasSplitWhereTheBudgetIsPinned();
}
// (f) Transform feedback active across the split: kSpanDraws one-point draws, draw i
// capturing 2i + 1, must append in order into one buffer with nothing past the end, and
// GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN must count every one.
TEST_F(OversizedRecordingSplitScenario, ATransformFeedbackCaptureActiveAcrossTheSplitAppendsEveryDraw) {
if (!Ready()) return;
auto compile = [](GLenum stage, const char* source, std::string* log) -> GLuint {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint ok = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &ok);
if (!ok) {
char buffer[2048] = {};
glGetShaderInfoLog(shader, sizeof(buffer) - 1, nullptr, buffer);
*log = buffer;
glDeleteShader(shader);
return 0;
}
return shader;
};
std::string log;
const GLuint vs = compile(GL_VERTEX_SHADER, kCaptureVS, &log);
ASSERT_NE(vs, 0u) << log;
const GLuint fs = compile(GL_FRAGMENT_SHADER, kCaptureFS, &log);
ASSERT_NE(fs, 0u) << log;
const GLuint program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
const char* varyings[] = {"vs_value"};
glTransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
glLinkProgram(program);
glDeleteShader(vs);
glDeleteShader(fs);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (!linked) {
char buffer[2048] = {};
glGetProgramInfoLog(program, sizeof(buffer) - 1, nullptr, buffer);
glDeleteProgram(program);
FAIL() << "capture program failed to link: " << buffer;
}
constexpr int kSlack = 4;
std::vector<float> poison(kSpanDraws + kSlack, kCapturePoison);
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, buffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLsizeiptr>(poison.size() * sizeof(float)),
poison.data(), GL_STATIC_COPY);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, buffer);
GLuint written = 0;
glGenQueries(1, &written);
glUseProgram(program);
glBindVertexArray(m_vao);
const GLint indexLocation = glGetUniformLocation(program, "uIndex");
glEnable(GL_RASTERIZER_DISCARD);
glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, written);
glBeginTransformFeedback(GL_POINTS);
for (int i = 0; i < kSpanDraws; ++i) {
glUniform1i(indexLocation, i);
glDrawArrays(GL_POINTS, 0, 1);
}
glEndTransformFeedback();
glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN);
glDisable(GL_RASTERIZER_DISCARD);
glUseProgram(0);
GLuint primitivesWritten = 0;
glGetQueryObjectuiv(written, GL_QUERY_RESULT, &primitivesWritten);
std::vector<float> captured(poison.size(), 0.0f);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, buffer);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, static_cast<GLsizeiptr>(captured.size() * sizeof(float)),
captured.data());
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, 0);
glDeleteQueries(1, &written);
glDeleteBuffers(1, &buffer);
glDeleteProgram(program);
EXPECT_EQ(primitivesWritten, static_cast<GLuint>(kSpanDraws));
int wrong = 0;
std::string first;
for (int i = 0; i < static_cast<int>(captured.size()); ++i) {
const float expected = i < kSpanDraws ? static_cast<float>(2 * i + 1) : kCapturePoison;
if (captured[i] != expected) {
if (wrong == 0) {
std::ostringstream os;
os << "record " << i << " = " << captured[i] << ", expected " << expected;
first = os.str();
}
++wrong;
}
}
EXPECT_EQ(wrong, 0) << wrong << " of " << captured.size() << " records are wrong; first " << first
<< " (records past " << kSpanDraws << " must keep the poison)";
EXPECT_EQ(FirstGLError(), 0u);
ExpectTheRecordingWasSplitWhereTheBudgetIsPinned();
}
} // namespace MGITest
@@ -0,0 +1,521 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - A SMALL glTexSubImage INTO A LEVEL THE GPU HAS WRITTEN.
//
// Espryt keeps a CPU shadow of every level and syncs a pending client write by uploading the
// region it covers (SyncMipmapsToBackend's sub-rect path). The shadow never sees a GPU write, so
// only the region may go up: anything more puts the shadow's stale texels over what the GPU drew.
// The region upload used to be skipped whenever the shadow's bytes had to be CONVERTED on the way
// to the driver - a three-channel format the driver cannot render is stored four-channel and every
// upload is repacked - and whenever the ES upload is shaped differently from the shadow (a 1D
// array, stored as a 2D array one texel high): those levels went up WHOLE, and a 2x2 write erased
// the rest of the level.
//
// Every case uploads a level, lets the GPU clear it, writes one 2x2 box, and reads the level back:
// the clear outside the box, the write inside. The formats are picked by what they cost the ES
// upload, which is per driver - on Mesa llvmpipe GL_SRGB8, GL_RGB8_SNORM and GL_RGB8UI are stored
// four-channel (see ThreeChannelAttachmentScenario) while GL_RGB16F is native; ES has no renderable
// three-channel format at all, so on a phone all four are converted. GL_RGBA8 is the control that
// never converts. A format a backend cannot attach is skipped, not failed.
//
// The second half is about ORDER, with GL_RGBA8. Magma queues a clear until the texture's next
// use and records texel uploads into a batch submitted ahead of the frame's recorded commands, so
// a write issued after a clear of (or a draw into) the level reached the image FIRST and the
// earlier clear or draw landed over it: the box read back green. Each order is covered - the
// clear already recorded, still queued, synced by a draw or by the readback, the reverse order
// in which the clear must win, and a draw instead of a clear.
//
// The last part is about SCATTER: several boxes written between two syncs, with the GPU's clear in
// the gaps between them. Everything a backend has to go on is the union box and the storage's
// rect list, and both describe more than the writes - the box spans the gaps, the list merges
// touching rects into their bounding box, folds once it holds 96, and is withheld once its rects
// fill 3/4 of the box; a box spanning the level went up as the whole level. Espryt also uploaded
// the union box whenever its unpack ring was in use. Each case below reaches one of those: two
// distant boxes, a hundred of them, boxes filling more than 3/4 of their union, and boxes whose
// union spans the level.
#include <cmath>
#include <cstdint>
#include <cstring>
#include <sstream>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kEdge = 8;
constexpr int kBoxAt = 2;
constexpr int kBoxEdge = 2;
constexpr const char* kVS = R"(#version 430 core
void main() {
vec2 positions[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0);
}
)";
// How a value in [0, 1] is spelled in the client data, and how the level is cleared and
// read back. Integer levels spell 1 as kIntegerOne.
enum class Encoding { Unorm8, Snorm8, Float32, Uint8 };
constexpr unsigned kIntegerOne = 9;
struct Format {
GLenum internalFormat;
GLenum clientFormat;
GLenum clientType;
int components;
Encoding encoding;
};
std::vector<std::uint8_t> Fill(const Format& f, int texels, float r, float g, float b) {
const float rgba[4] = {r, g, b, 1.0f};
const std::size_t componentBytes = f.encoding == Encoding::Float32 ? 4u : 1u;
std::vector<std::uint8_t> out(static_cast<std::size_t>(texels) * f.components * componentBytes);
for (int t = 0; t < texels; ++t) {
for (int c = 0; c < f.components; ++c) {
std::uint8_t* dst = out.data() + (static_cast<std::size_t>(t) * f.components + c) * componentBytes;
const float v = rgba[c];
switch (f.encoding) {
case Encoding::Unorm8: *dst = static_cast<std::uint8_t>(std::lround(v * 255.0f)); break;
case Encoding::Snorm8:
*dst = static_cast<std::uint8_t>(static_cast<std::int8_t>(std::lround(v * 127.0f)));
break;
case Encoding::Float32: std::memcpy(dst, &v, sizeof(v)); break;
case Encoding::Uint8: *dst = static_cast<std::uint8_t>(std::lround(v * kIntegerOne)); break;
}
}
}
return out;
}
class PartialUploadOverGpuContentScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_scratch = MakeColorFbo(4, 4);
glGenVertexArrays(1, &m_vao);
glGenFramebuffers(1, &m_fbo);
FirstGLError();
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (GLuint p : m_programs) glDeleteProgram(p);
if (!m_textures.empty()) glDeleteTextures(static_cast<GLsizei>(m_textures.size()), m_textures.data());
if (m_vao) glDeleteVertexArrays(1, &m_vao);
if (m_fbo) glDeleteFramebuffers(1, &m_fbo);
BindDefaultFramebuffer();
DestroyColorFbo(m_scratch);
glViewport(0, 0, Gl().Width(), Gl().Height());
FirstGLError();
}
GLuint NewTexture(GLenum target) {
GLuint t = 0;
glGenTextures(1, &t);
m_textures.push_back(t);
glBindTexture(target, t);
glTexParameteri(target, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(target, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(target, GL_TEXTURE_MAX_LEVEL, 0);
glBindTexture(target, 0);
return t;
}
// A draw that samples the texture, which is what syncs its pending writes to the
// driver (an FBO attach alone may find the framebuffer already synced).
void SyncBySampling(GLenum target, GLuint tex, const char* samplerType, const char* coord) {
std::string fs = std::string("#version 430 core\nuniform ") + samplerType +
" u_tex;\nout vec4 o_color;\nvoid main() { o_color = vec4(texelFetch(u_tex, " + coord +
", 0)); }\n";
std::string error;
const GLuint program = CompileProgram(kVS, fs.c_str(), &error);
ASSERT_NE(program, 0u) << error;
m_programs.push_back(program);
BindFbo(m_scratch);
glUseProgram(program);
glActiveTexture(GL_TEXTURE0);
glBindTexture(target, tex);
glUniform1i(glGetUniformLocation(program, "u_tex"), 0);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
glBindTexture(target, 0);
glUseProgram(0);
BindDefaultFramebuffer();
glFinish();
EXPECT_EQ(FirstGLError(), 0u) << "sampling sync";
}
// Attaches (tex, layer) to the scratch framebuffer; false when this backend cannot.
bool Attach(GLuint tex, int layer) {
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
if (layer >= 0) {
glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, tex, 0, layer);
} else {
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0);
}
return glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;
}
void Detach() {
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0);
BindDefaultFramebuffer();
}
// The GPU write the shadow never sees: the whole level (or layer) to green.
void GpuClearGreen(const Format& f) {
if (f.encoding == Encoding::Uint8) {
const GLuint green[4] = {0u, kIntegerOne, 0u, 1u};
glClearBufferuiv(GL_COLOR, 0, green);
} else {
const GLfloat green[4] = {0.0f, 1.0f, 0.0f, 1.0f};
glClearBufferfv(GL_COLOR, 0, green);
}
}
// The attached image's rgb, in units of "one", width x height texels.
std::vector<float> ReadRgb(const Format& f, int width, int height) {
std::vector<float> rgb(static_cast<std::size_t>(width * height) * 3u);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
if (f.encoding == Encoding::Uint8) {
std::vector<GLuint> texels(static_cast<std::size_t>(width * height) * 4u, 0xA5A5A5A5u);
glReadPixels(0, 0, width, height, GL_RGBA_INTEGER, GL_UNSIGNED_INT, texels.data());
for (int i = 0; i < width * height; ++i)
for (int c = 0; c < 3; ++c)
rgb[i * 3 + c] = static_cast<float>(texels[i * 4 + c]) / kIntegerOne;
} else {
std::vector<GLfloat> texels(static_cast<std::size_t>(width * height) * 4u, -7.0f);
glReadPixels(0, 0, width, height, GL_RGBA, GL_FLOAT, texels.data());
for (int i = 0; i < width * height; ++i)
for (int c = 0; c < 3; ++c) rgb[i * 3 + c] = texels[i * 4 + c];
}
EXPECT_EQ(FirstGLError(), 0u) << "readback";
return rgb;
}
// Green everywhere but blue inside [boxX, boxX + boxW) x [boxY, boxY + boxH).
static void ExpectClearAroundBox(const std::vector<float>& rgb, int width, int height, int boxX, int boxY,
int boxW, int boxH) {
int bad = 0;
std::ostringstream first;
for (int y = 0; y < height; ++y)
for (int x = 0; x < width; ++x) {
const bool inBox = x >= boxX && x < boxX + boxW && y >= boxY && y < boxY + boxH;
const float want[3] = {0.0f, inBox ? 0.0f : 1.0f, inBox ? 1.0f : 0.0f};
const float* got = &rgb[static_cast<std::size_t>(y * width + x) * 3u];
bool texelBad = false;
for (int c = 0; c < 3; ++c) texelBad |= std::fabs(got[c] - want[c]) > 0.02f;
if (texelBad && bad++ == 0) {
first << "(" << x << "," << y << ") got " << got[0] << "," << got[1] << "," << got[2]
<< " want " << want[0] << "," << want[1] << "," << want[2];
}
}
EXPECT_EQ(bad, 0) << bad << " of " << width * height << " texels wrong; first " << first.str()
<< " (red outside the box: the stale shadow went back up; green inside it: the "
"write landed before the clear or draw)";
}
// Upload red, sync, clear green on the GPU, sync, write a blue 2x2 box, sync, read.
void Run2D(const Format& f) {
const GLuint t = NewTexture(GL_TEXTURE_2D);
const bool integer = f.encoding == Encoding::Uint8;
const char* sampler = integer ? "usampler2D" : "sampler2D";
const auto red = Fill(f, kEdge * kEdge, 1.0f, 0.0f, 0.0f);
glBindTexture(GL_TEXTURE_2D, t);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(f.internalFormat), kEdge, kEdge, 0, f.clientFormat,
f.clientType, red.data());
glBindTexture(GL_TEXTURE_2D, 0);
ASSERT_EQ(FirstGLError(), 0u) << "definition";
SyncBySampling(GL_TEXTURE_2D, t, sampler, "ivec2(0)");
if (!Attach(t, -1)) {
Detach();
GTEST_SKIP() << "the format does not attach on backend " << Gl().BackendName();
}
GpuClearGreen(f);
Detach();
SyncBySampling(GL_TEXTURE_2D, t, sampler, "ivec2(0)");
const auto blue = Fill(f, kBoxEdge * kBoxEdge, 0.0f, 0.0f, 1.0f);
glBindTexture(GL_TEXTURE_2D, t);
glTexSubImage2D(GL_TEXTURE_2D, 0, kBoxAt, kBoxAt, kBoxEdge, kBoxEdge, f.clientFormat, f.clientType,
blue.data());
glBindTexture(GL_TEXTURE_2D, 0);
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
SyncBySampling(GL_TEXTURE_2D, t, sampler, "ivec2(0)");
ASSERT_TRUE(Attach(t, -1));
const auto rgb = ReadRgb(f, kEdge, kEdge);
Detach();
ExpectClearAroundBox(rgb, kEdge, kEdge, kBoxAt, kBoxAt, kBoxEdge, kBoxEdge);
}
// Building blocks of the RGBA8 ordering cases: the same texels, issued in the orders
// a backend that defers work may get wrong.
static Format Rgba8() { return {GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, 4, Encoding::Unorm8}; }
GLuint NewRedRgba8() {
const GLuint t = NewTexture(GL_TEXTURE_2D);
const auto red = Fill(Rgba8(), kEdge * kEdge, 1.0f, 0.0f, 0.0f);
glBindTexture(GL_TEXTURE_2D, t);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, red.data());
glBindTexture(GL_TEXTURE_2D, 0);
EXPECT_EQ(FirstGLError(), 0u) << "definition";
return t;
}
void SampleRgba8(GLuint t) { SyncBySampling(GL_TEXTURE_2D, t, "sampler2D", "ivec2(0)"); }
void ClearRgba8Green(GLuint t) {
ASSERT_TRUE(Attach(t, -1));
GpuClearGreen(Rgba8());
Detach();
}
// A draw that writes green over the whole level.
void DrawRgba8Green(GLuint t) {
std::string error;
const GLuint program = CompileProgram(
kVS, "#version 430 core\nout vec4 o_color;\nvoid main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }\n",
&error);
ASSERT_NE(program, 0u) << error;
m_programs.push_back(program);
ASSERT_TRUE(Attach(t, -1));
glViewport(0, 0, kEdge, kEdge);
glUseProgram(program);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
glUseProgram(0);
Detach();
glViewport(0, 0, Gl().Width(), Gl().Height());
}
void WriteRgba8BlueBox(GLuint t) {
const auto blue = Fill(Rgba8(), kBoxEdge * kBoxEdge, 0.0f, 0.0f, 1.0f);
glBindTexture(GL_TEXTURE_2D, t);
glTexSubImage2D(GL_TEXTURE_2D, 0, kBoxAt, kBoxAt, kBoxEdge, kBoxEdge, GL_RGBA, GL_UNSIGNED_BYTE,
blue.data());
glBindTexture(GL_TEXTURE_2D, 0);
}
// Green everywhere, and blue in the box when `boxWins`.
void ExpectRgba8(GLuint t, bool boxWins) {
ASSERT_TRUE(Attach(t, -1));
const auto rgb = ReadRgb(Rgba8(), kEdge, kEdge);
Detach();
ExpectClearAroundBox(rgb, kEdge, kEdge, kBoxAt, kBoxAt, boxWins ? kBoxEdge : 0, boxWins ? kBoxEdge : 0);
}
struct Box {
int x, y, w, h;
};
// An edge x edge RGBA8 level uploaded red, cleared green on the GPU, then written blue in
// every box by one glTexSubImage each, with no sync in between; read back after a sync.
// Blue inside the boxes, green everywhere else.
void RunScatteredWrites(int edge, const std::vector<Box>& boxes) {
const GLuint t = NewTexture(GL_TEXTURE_2D);
const auto red = Fill(Rgba8(), edge * edge, 1.0f, 0.0f, 0.0f);
glBindTexture(GL_TEXTURE_2D, t);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, edge, edge, 0, GL_RGBA, GL_UNSIGNED_BYTE, red.data());
glBindTexture(GL_TEXTURE_2D, 0);
ASSERT_EQ(FirstGLError(), 0u) << "definition";
SampleRgba8(t);
ClearRgba8Green(t);
SampleRgba8(t);
glBindTexture(GL_TEXTURE_2D, t);
for (const Box& box : boxes) {
const auto blue = Fill(Rgba8(), box.w * box.h, 0.0f, 0.0f, 1.0f);
glTexSubImage2D(GL_TEXTURE_2D, 0, box.x, box.y, box.w, box.h, GL_RGBA, GL_UNSIGNED_BYTE,
blue.data());
}
glBindTexture(GL_TEXTURE_2D, 0);
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
ASSERT_EQ(FirstGLError(), 0u) << "writes";
SampleRgba8(t);
ASSERT_TRUE(Attach(t, -1));
const auto rgb = ReadRgb(Rgba8(), edge, edge);
Detach();
int bad = 0, stale = 0;
std::ostringstream first;
for (int y = 0; y < edge; ++y)
for (int x = 0; x < edge; ++x) {
bool inBox = false;
for (const Box& box : boxes)
inBox |= x >= box.x && x < box.x + box.w && y >= box.y && y < box.y + box.h;
const float want[3] = {0.0f, inBox ? 0.0f : 1.0f, inBox ? 1.0f : 0.0f};
const float* got = &rgb[static_cast<std::size_t>(y * edge + x) * 3u];
bool texelBad = false;
for (int c = 0; c < 3; ++c) texelBad |= std::fabs(got[c] - want[c]) > 0.02f;
if (!texelBad) continue;
stale += got[0] > 0.5f;
if (bad++ == 0) {
first << "(" << x << "," << y << ") got " << got[0] << "," << got[1] << "," << got[2]
<< " want " << want[0] << "," << want[1] << "," << want[2];
}
}
EXPECT_EQ(bad, 0) << bad << " of " << edge * edge << " texels wrong, " << stale
<< " of them red - the stale shadow went up outside the writes; first "
<< first.str();
}
GLuint m_vao = 0, m_fbo = 0;
ColorFbo m_scratch{};
std::vector<GLuint> m_programs;
std::vector<GLuint> m_textures;
};
// Control: the shadow's bytes go to the driver as they are.
TEST_F(PartialUploadOverGpuContentScenario, Rgba8SubImageKeepsTheGpuClear) {
if (!Ready()) return;
Run2D({GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, 4, Encoding::Unorm8});
}
TEST_F(PartialUploadOverGpuContentScenario, Srgb8SubImageKeepsTheGpuClear) {
if (!Ready()) return;
Run2D({GL_SRGB8, GL_RGB, GL_UNSIGNED_BYTE, 3, Encoding::Unorm8});
}
TEST_F(PartialUploadOverGpuContentScenario, Rgb8SnormSubImageKeepsTheGpuClear) {
if (!Ready()) return;
Run2D({GL_RGB8_SNORM, GL_RGB, GL_BYTE, 3, Encoding::Snorm8});
}
TEST_F(PartialUploadOverGpuContentScenario, Rgb16fSubImageKeepsTheGpuClear) {
if (!Ready()) return;
Run2D({GL_RGB16F, GL_RGB, GL_FLOAT, 3, Encoding::Float32});
}
TEST_F(PartialUploadOverGpuContentScenario, Rgb8uiSubImageKeepsTheGpuClear) {
if (!Ready()) return;
Run2D({GL_RGB8UI, GL_RGB_INTEGER, GL_UNSIGNED_BYTE, 3, Encoding::Uint8});
}
// A 1D array's ES image is a 2D array one texel high with the layers in depth, so the
// upload is shaped differently from the shadow (layers as rows) even with no conversion.
TEST_F(PartialUploadOverGpuContentScenario, OneDimensionalArraySubImageKeepsTheGpuClearedLayer) {
if (!Ready()) return;
constexpr int kLayers = 2;
const Format f{GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, 4, Encoding::Unorm8};
const GLuint t = NewTexture(GL_TEXTURE_1D_ARRAY);
const auto red = Fill(f, kEdge * kLayers, 1.0f, 0.0f, 0.0f);
glBindTexture(GL_TEXTURE_1D_ARRAY, t);
glTexImage2D(GL_TEXTURE_1D_ARRAY, 0, GL_RGBA8, kEdge, kLayers, 0, GL_RGBA, GL_UNSIGNED_BYTE, red.data());
glBindTexture(GL_TEXTURE_1D_ARRAY, 0);
ASSERT_EQ(FirstGLError(), 0u) << "definition";
SyncBySampling(GL_TEXTURE_1D_ARRAY, t, "sampler1DArray", "ivec2(0)");
if (!Attach(t, 1)) {
Detach();
GTEST_SKIP() << "a 1D array layer does not attach on backend " << Gl().BackendName();
}
GpuClearGreen(f);
Detach();
SyncBySampling(GL_TEXTURE_1D_ARRAY, t, "sampler1DArray", "ivec2(0)");
const auto blue = Fill(f, kBoxEdge, 0.0f, 0.0f, 1.0f);
glBindTexture(GL_TEXTURE_1D_ARRAY, t);
glTexSubImage2D(GL_TEXTURE_1D_ARRAY, 0, kBoxAt, 1, kBoxEdge, 1, GL_RGBA, GL_UNSIGNED_BYTE, blue.data());
glBindTexture(GL_TEXTURE_1D_ARRAY, 0);
SyncBySampling(GL_TEXTURE_1D_ARRAY, t, "sampler1DArray", "ivec2(0)");
ASSERT_TRUE(Attach(t, 1));
const auto layer1 = ReadRgb(f, kEdge, 1);
Detach();
ExpectClearAroundBox(layer1, kEdge, 1, kBoxAt, 0, kBoxEdge, 1);
}
// The orders below matter to a backend that defers GPU work (Magma queues clears and runs
// texel uploads in a batch ahead of the frame's recorded commands); every one is plain GL.
// The clear is still queued when the write arrives: it has to reach the level first.
TEST_F(PartialUploadOverGpuContentScenario, SubImageAfterAQueuedClearLandsOnTheClear) {
if (!Ready()) return;
const GLuint t = NewRedRgba8();
SampleRgba8(t);
ClearRgba8Green(t);
WriteRgba8BlueBox(t);
SampleRgba8(t);
ExpectRgba8(t, true);
}
// Same, with the readback as the first thing to sync the write.
TEST_F(PartialUploadOverGpuContentScenario, SubImageAfterAQueuedClearLandsOnTheClearForAReadback) {
if (!Ready()) return;
const GLuint t = NewRedRgba8();
SampleRgba8(t);
ClearRgba8Green(t);
WriteRgba8BlueBox(t);
ExpectRgba8(t, true);
}
// The other order: the write is still pending when the clear arrives, and the clear wins.
TEST_F(PartialUploadOverGpuContentScenario, ClearAfterAPendingSubImageWins) {
if (!Ready()) return;
const GLuint t = NewRedRgba8();
SampleRgba8(t);
WriteRgba8BlueBox(t);
ClearRgba8Green(t);
SampleRgba8(t);
ExpectRgba8(t, false);
}
// A draw into the level instead of a clear.
TEST_F(PartialUploadOverGpuContentScenario, SubImageAfterADrawIntoTheLevelLandsOnTheDraw) {
if (!Ready()) return;
const GLuint t = NewRedRgba8();
SampleRgba8(t);
DrawRgba8Green(t);
WriteRgba8BlueBox(t);
SampleRgba8(t);
ExpectRgba8(t, true);
}
// Two boxes in opposite corners: their union box is 7x7, of which the writes are 8 texels.
TEST_F(PartialUploadOverGpuContentScenario, TwoDistantSubImagesKeepTheGpuClearBetweenThem) {
if (!Ready()) return;
RunScatteredWrites(kEdge, {{0, 0, 2, 2}, {5, 5, 2, 2}});
}
// A hundred 2x2 boxes two texels apart: more than the 96 rects the storage keeps, so its
// list folds neighbours together, gaps and all.
TEST_F(PartialUploadOverGpuContentScenario, AHundredSubImagesKeepTheGpuClearBetweenThem) {
if (!Ready()) return;
std::vector<Box> boxes;
for (int j = 0; j < 10; ++j)
for (int i = 0; i < 10; ++i) boxes.push_back({4 * i + 1, 4 * j + 1, 2, 2});
RunScatteredWrites(40, boxes);
}
// Four 4x4 boxes around a one-texel cross: 64 of their union's 81 texels, past the 3/4 at
// which the storage stops handing its rect list out.
TEST_F(PartialUploadOverGpuContentScenario, SubImagesFillingThreeQuartersOfTheirUnionKeepTheGpuClearBetweenThem) {
if (!Ready()) return;
RunScatteredWrites(16, {{2, 2, 4, 4}, {7, 2, 4, 4}, {2, 7, 4, 4}, {7, 7, 4, 4}});
}
// Three quadrants: the union box is the whole level, and the list merges the touching
// boxes into it; the fourth quadrant is still the GPU's.
TEST_F(PartialUploadOverGpuContentScenario, SubImagesWhoseUnionSpansTheLevelKeepTheGpuClearInTheGap) {
if (!Ready()) return;
RunScatteredWrites(kEdge, {{0, 0, 4, 4}, {4, 0, 4, 4}, {0, 4, 4, 4}});
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,314 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/UnboundUniformBlockScenario.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - A PROGRAM DECLARES A UNIFORM BLOCK AND NOTHING IS BOUND AT ITS BINDING POINT.
//
// The uniform-block sibling of UnboundImageDescriptorScenario. A draw or dispatch whose program
// declares a uniform block at an indexed point with no buffer on it raises no GL error - whether
// the point was never bound or its buffer was deleted while bound (glDeleteBuffers unbinds it
// from every indexed point of the current context). Only what the shader READS from the block is
// undefined.
//
// DirectVulkan's ResolveUniformBufferPayload looked up the buffer at the block's point and
// dereferenced it with nothing there: a SIGSEGV inside the draw or the dispatch, taking the whole
// process with it. It now answers with a zeroed block of the reflected size.
//
// The block is STATICALLY USED in every case, because an unreferenced block is optimised out
// before it ever reaches a descriptor and would prove nothing. Its one read sits behind a
// uniform-controlled branch that is false whenever the point is empty, so no undefined value
// reaches an assertion. Each case also binds a buffer at the same point and reads it back through
// the same program - proof that the block is live and wired to that point - and then empties the
// point again, so the transition back to "nothing bound" is covered as well as the first use.
//
// DirectGLES forwards the empty point to the GLES driver, which does what GL says, so it is the
// control - every test here must stay green on both backends.
#include <array>
#include <string>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kFboSize = 32;
// The point under test. Not 0, so a stray glBindBufferBase(GL_UNIFORM_BUFFER, 0, ...) from
// an earlier scenario in this process is not what decides the case - and SetUp empties
// this one explicitly anyway.
constexpr GLuint kUniformPoint = 5;
constexpr GLuint kResultPoint = 0;
constexpr unsigned int kMarker = 0xC0FFEEu;
constexpr unsigned int kFallback = 7u;
constexpr unsigned int kBoundWord = 0x1234u;
// No vertex attributes: the quad's corners come from gl_VertexID, so nothing about the
// vertex fetch can be confused with the descriptor question under test.
constexpr const char* kQuadVertexSource = R"(#version 430 core
void main() {
vec2 corner = vec2((gl_VertexID & 1) == 0 ? -1.0 : 1.0,
(gl_VertexID & 2) == 0 ? -1.0 : 1.0);
gl_Position = vec4(corner, 0.0, 1.0);
}
)";
// Green unless u_read is set, in which case the block's tint.
constexpr const char* kFragmentSource = R"(#version 430 core
layout(std140, binding = 5) uniform Target { vec4 u_tint; };
uniform int u_read;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_read != 0) {
color = u_tint;
}
o_color = color;
}
)";
// The marker is unconditional, so a zero marker means the dispatch never ran; the value is
// the fallback unless u_read is set, in which case it is the block's word.
constexpr const char* kComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Result { uint g_marker; uint g_value; };
layout(std140, binding = 5) uniform Target { uint u_word; };
uniform int u_read;
void main() {
uint value = 7u;
if (u_read != 0) {
value = u_word;
}
g_marker = 0xC0FFEEu;
g_value = value;
}
)";
class UnboundUniformBlockScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_target = MakeColorFbo(kFboSize, kFboSize);
ASSERT_NE(m_target.fbo, 0u) << "could not create the render target";
glGenVertexArrays(1, &m_vao);
const unsigned int zero[2] = {0u, 0u};
glGenBuffers(1, &m_result);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_result);
glBufferData(GL_SHADER_STORAGE_BUFFER, sizeof(zero), zero, GL_DYNAMIC_COPY);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, kResultPoint, m_result);
// The harness shares one context across every scenario in the process, so an
// earlier one may have left a buffer at this point. The whole subject here is that
// nothing is bound, so say so rather than assume it.
glBindBufferBase(GL_UNIFORM_BUFFER, kUniformPoint, 0);
ASSERT_EQ(FirstGLError(), 0u) << "setting up the scenario raised a GL error";
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindVertexArray(0);
glBindBufferBase(GL_UNIFORM_BUFFER, kUniformPoint, 0);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, kResultPoint, 0);
if (m_block != 0) glDeleteBuffers(1, &m_block);
if (m_result != 0) glDeleteBuffers(1, &m_result);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_draw != 0) glDeleteProgram(m_draw);
if (m_compute != 0) glDeleteProgram(m_compute);
BindDefaultFramebuffer();
DestroyColorFbo(m_target);
glViewport(0, 0, Gl().Width(), Gl().Height());
FirstGLError();
}
unsigned int MakeComputeProgram(const char* source) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[4096] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute shader did not compile: " << log;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[4096] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute program did not link: " << log;
glDeleteProgram(program);
return 0;
}
return program;
}
// The block must be ACTIVE and at kUniformPoint, and the guard uniform must exist:
// if either were optimised away the program would never ask for the empty point and
// every case below would pass without testing anything.
static void ExpectTheBlockIsLiveAtThePoint(GLuint program) {
const GLuint block = glGetUniformBlockIndex(program, "Target");
ASSERT_NE(block, GL_INVALID_INDEX) << "the uniform block was optimised out - the case proves nothing";
GLint binding = -1;
glGetActiveUniformBlockiv(program, block, GL_UNIFORM_BLOCK_BINDING, &binding);
ASSERT_EQ(binding, static_cast<GLint>(kUniformPoint)) << "the block is not at the point under test";
ASSERT_NE(glGetUniformLocation(program, "u_read"), -1) << "the guard uniform was optimised out";
ASSERT_EQ(FirstGLError(), 0u) << "querying the block raised a GL error";
}
static void ExpectThePointIsEmpty(const char* when) {
GLint bound = -1;
glGetIntegeri_v(GL_UNIFORM_BUFFER_BINDING, kUniformPoint, &bound);
ASSERT_EQ(bound, 0) << "uniform point " << kUniformPoint << " is not empty " << when;
}
// A 16-byte std140 block at kUniformPoint holding `words`.
void BindBlock(const std::array<unsigned int, 4>& words) {
if (m_block == 0) glGenBuffers(1, &m_block);
glBindBuffer(GL_UNIFORM_BUFFER, m_block);
glBufferData(GL_UNIFORM_BUFFER, sizeof(words), words.data(), GL_DYNAMIC_DRAW);
glBindBuffer(GL_UNIFORM_BUFFER, 0);
glBindBufferBase(GL_UNIFORM_BUFFER, kUniformPoint, m_block);
}
// Runs the compute program once; returns {marker, value}.
std::array<unsigned int, 2> Dispatch(int read) {
const unsigned int zero[2] = {0u, 0u};
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_result);
glBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(zero), zero);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
glUseProgram(m_compute);
glUniform1i(glGetUniformLocation(m_compute, "u_read"), read);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
std::array<unsigned int, 2> out = {0xDEADBEEFu, 0xDEADBEEFu};
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_result);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(out), out.data());
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
glUseProgram(0);
return out;
}
void ExpectTheDispatchRunsOnAnEmptyPoint(const char* when) {
ASSERT_NO_FATAL_FAILURE(ExpectThePointIsEmpty(when));
const auto result = Dispatch(0);
EXPECT_EQ(FirstGLError(), 0u) << "the dispatch raised a GL error " << when;
EXPECT_EQ(result[0], kMarker) << "the dispatch did not run " << when;
EXPECT_EQ(result[1], kFallback) << "the dispatch took the block's branch " << when;
}
// Draws the full-target quad and expects every pixel to be `color`.
void ExpectTheDraw(int read, const char* color, const std::string& when) {
BindFbo(m_target);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glBindVertexArray(m_vao);
glUseProgram(m_draw);
glUniform1i(glGetUniformLocation(m_draw, "u_read"), read);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
glUseProgram(0);
EXPECT_EQ(FirstGLError(), 0u) << "the draw raised a GL error " << when;
const Image image = ReadPixels(kFboSize, kFboSize);
ASSERT_FALSE(image.Empty()) << "the readback came back empty " << when;
EXPECT_TRUE(RegionIsMostly(image, 0, kFboSize - 1, 0, kFboSize - 1, color, 0.0, when))
<< "an all-black target means the draw was dropped";
}
void ExpectTheDrawRunsOnAnEmptyPoint(const std::string& when) {
ASSERT_NO_FATAL_FAILURE(ExpectThePointIsEmpty(when.c_str()));
ExpectTheDraw(0, "green", when);
}
ColorFbo m_target{};
GLuint m_vao = 0;
GLuint m_result = 0;
GLuint m_block = 0;
unsigned int m_draw = 0;
unsigned int m_compute = 0;
};
} // namespace
TEST_F(UnboundUniformBlockScenario, ADispatchDeclaringAnEmptyUniformPointRuns) {
if (!Ready() || IsSkipped()) return;
m_compute = MakeComputeProgram(kComputeSource);
ASSERT_NE(m_compute, 0u);
ASSERT_NO_FATAL_FAILURE(ExpectTheBlockIsLiveAtThePoint(m_compute));
ExpectTheDispatchRunsOnAnEmptyPoint("with nothing ever bound at the point");
BindBlock({kBoundWord, 0u, 0u, 0u});
const auto bound = Dispatch(1);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(bound[0], kMarker) << "the dispatch did not run with a buffer bound";
EXPECT_EQ(bound[1], kBoundWord) << "the program did not read the buffer bound at the block's point";
glBindBufferBase(GL_UNIFORM_BUFFER, kUniformPoint, 0);
ExpectTheDispatchRunsOnAnEmptyPoint("after the point was emptied again");
}
TEST_F(UnboundUniformBlockScenario, ADrawDeclaringAnEmptyUniformPointPaintsTheFrame) {
if (!Ready() || IsSkipped()) return;
std::string error;
m_draw = CompileProgram(kQuadVertexSource, kFragmentSource, &error);
ASSERT_NE(m_draw, 0u) << error;
ASSERT_NO_FATAL_FAILURE(ExpectTheBlockIsLiveAtThePoint(m_draw));
ExpectTheDrawRunsOnAnEmptyPoint("with nothing ever bound at the point");
// Opaque red: 1.0f and 0.0f as their IEEE-754 words, so the block is one vec4.
BindBlock({0x3F800000u, 0u, 0u, 0x3F800000u});
ExpectTheDraw(1, "red", "reading the buffer bound at the block's point");
glBindBufferBase(GL_UNIFORM_BUFFER, kUniformPoint, 0);
ExpectTheDrawRunsOnAnEmptyPoint("after the point was emptied again");
}
// The shape real applications reach it by: the buffer is bound and used, then deleted while
// still bound, which empties the point without the application ever unbinding it.
TEST_F(UnboundUniformBlockScenario, AUniformBufferDeletedWhileBoundLeavesAnEmptyPointBothPathsSurvive) {
if (!Ready() || IsSkipped()) return;
m_compute = MakeComputeProgram(kComputeSource);
ASSERT_NE(m_compute, 0u);
ASSERT_NO_FATAL_FAILURE(ExpectTheBlockIsLiveAtThePoint(m_compute));
std::string error;
m_draw = CompileProgram(kQuadVertexSource, kFragmentSource, &error);
ASSERT_NE(m_draw, 0u) << error;
ASSERT_NO_FATAL_FAILURE(ExpectTheBlockIsLiveAtThePoint(m_draw));
BindBlock({kBoundWord, 0u, 0u, 0u});
const auto bound = Dispatch(1);
EXPECT_EQ(bound[0], kMarker) << "the dispatch did not run with a buffer bound";
EXPECT_EQ(bound[1], kBoundWord) << "the program did not read the buffer bound at the block's point";
ASSERT_EQ(FirstGLError(), 0u);
glDeleteBuffers(1, &m_block);
m_block = 0;
ExpectTheDispatchRunsOnAnEmptyPoint("after the bound buffer was deleted");
ExpectTheDrawRunsOnAnEmptyPoint("after the bound buffer was deleted");
}
} // namespace MGITest
@@ -18,6 +18,8 @@ namespace MobileGL {
TessEval,
Geometry,
Fragment,
Task,
Mesh,
Compute,
ShaderStageCount,
Unknown = -1
@@ -8,6 +8,9 @@
#include "MipmapStorage.h"
#include <bit>
#include <utility>
namespace MobileGL {
namespace MG_State {
namespace GLState {
@@ -26,6 +29,135 @@ namespace MobileGL {
IntVec3{std::max(a.hi.x(), b.hi.x()), std::max(a.hi.y(), b.hi.y()),
std::max(a.hi.z(), b.hi.z())}};
}
MipmapDirtyRegion RegionIntersection(const MipmapDirtyRegion& a, const MipmapDirtyRegion& b) {
return {IntVec3{std::max(a.lo.x(), b.lo.x()), std::max(a.lo.y(), b.lo.y()),
std::max(a.lo.z(), b.lo.z())},
IntVec3{std::min(a.hi.x(), b.hi.x()), std::min(a.hi.y(), b.hi.y()),
std::min(a.hi.z(), b.hi.z())}};
}
// For two boxes of written texels: whether their bounding box holds no texel
// outside them, i.e. whether merging them keeps the rect list exact.
Bool UnionIsExact(const MipmapDirtyRegion& a, const MipmapDirtyRegion& b) {
return RegionUnion(a, b).TexelCount() ==
a.TexelCount() + b.TexelCount() - RegionIntersection(a, b).TexelCount();
}
Bool SameFootprintXY(const MipmapDirtyRegion& a, const MipmapDirtyRegion& b) {
return a.lo.x() == b.lo.x() && a.hi.x() == b.hi.x() && a.lo.y() == b.lo.y() &&
a.hi.y() == b.hi.y();
}
Bool FootprintXYLess(const MipmapDirtyRegion& a, const MipmapDirtyRegion& b) {
if (a.lo.y() != b.lo.y()) return a.lo.y() < b.lo.y();
if (a.lo.x() != b.lo.x()) return a.lo.x() < b.lo.x();
if (a.hi.y() != b.hi.y()) return a.hi.y() < b.hi.y();
return a.hi.x() < b.hi.x();
}
// Cuts the union of a set of (possibly overlapping) write boxes into disjoint boxes
// holding exactly its texels, at a cost set by the rows written, not by the level.
// Every box is split into row runs, the runs of each row are sorted and merged, and
// a sweep down each slice lets a row's run continue the box above it when it spans
// the same columns on the very next row; a slice's box then continues the previous
// slice's when that slice is the one before and the box covers the same rows and
// columns.
void DecomposeFootprintWrites(const Vector<MipmapDirtyRegion>& writes, Vector<MipmapDirtyRegion>& out) {
struct Run {
Int z, y, x0, x1;
};
Vector<Run> runs;
for (const auto& box : writes) {
for (Int z = box.lo.z(); z < box.hi.z(); ++z)
for (Int y = box.lo.y(); y < box.hi.y(); ++y) runs.push_back({z, y, box.lo.x(), box.hi.x()});
}
std::sort(runs.begin(), runs.end(), [](const Run& a, const Run& b) {
if (a.z != b.z) return a.z < b.z;
if (a.y != b.y) return a.y < b.y;
return a.x0 < b.x0;
});
SizeT merged = 0;
for (const Run& run : runs) {
Run* last = merged > 0 ? &runs[merged - 1] : nullptr;
if (last && last->z == run.z && last->y == run.y && run.x0 <= last->x1) {
last->x1 = std::max(last->x1, run.x1);
} else {
runs[merged++] = run;
}
}
runs.resize(merged);
Vector<MipmapDirtyRegion> open, next, slice;
Vector<SizeT> previousSlice, currentSlice; // indices into `out`, in FootprintXYLess order
Int sliceZ = 0, previousZ = 0, rowY = 0;
Bool haveSlice = false;
// Ends every open box below row `rowY` and folds the slice into `out`.
const auto closeSlice = [&]() {
for (auto& box : open) {
box.hi = {box.hi.x(), rowY + 1, sliceZ + 1};
slice.push_back(box);
}
open.clear();
std::sort(slice.begin(), slice.end(), FootprintXYLess);
if (previousZ + 1 != sliceZ) previousSlice.clear();
currentSlice.clear();
SizeT p = 0;
for (const auto& box : slice) {
while (p < previousSlice.size() && FootprintXYLess(out[previousSlice[p]], box)) ++p;
if (p < previousSlice.size() && SameFootprintXY(out[previousSlice[p]], box)) {
out[previousSlice[p]].hi = {box.hi.x(), box.hi.y(), sliceZ + 1};
currentSlice.push_back(previousSlice[p++]);
} else {
currentSlice.push_back(out.size());
out.push_back(box);
}
}
std::swap(previousSlice, currentSlice);
slice.clear();
previousZ = sliceZ;
};
for (SizeT i = 0; i < runs.size();) {
const Int z = runs[i].z;
const Int y = runs[i].y;
if (haveSlice && z != sliceZ) {
closeSlice();
} else if (haveSlice && y != rowY + 1) {
// A row with no runs in between: nothing open continues across it.
for (auto& box : open) {
box.hi = {box.hi.x(), rowY + 1, sliceZ + 1};
slice.push_back(box);
}
open.clear();
}
haveSlice = true;
sliceZ = z;
next.clear();
SizeT o = 0;
for (; i < runs.size() && runs[i].z == z && runs[i].y == y; ++i) {
const Int x0 = runs[i].x0;
const Int x1 = runs[i].x1;
// `open` is sorted by x and disjoint: every box left of this run that
// it cannot continue ends on the row above.
while (o < open.size() && open[o].lo.x() < x0) {
open[o].hi = {open[o].hi.x(), y, z + 1};
slice.push_back(open[o++]);
}
if (o < open.size() && open[o].lo.x() == x0 && open[o].hi.x() == x1) {
next.push_back(open[o++]);
} else {
next.push_back({IntVec3{x0, y, z}, IntVec3{x1, y + 1, z + 1}});
}
}
for (; o < open.size(); ++o) {
open[o].hi = {open[o].hi.x(), y, z + 1};
slice.push_back(open[o]);
}
std::swap(open, next);
rowY = y;
}
if (haveSlice) closeSlice();
}
} // namespace
SizeT MipmapStorage::GetLevelCount() const {
@@ -46,6 +178,7 @@ namespace MobileGL {
m_isDirty.resize(requiredLevelCount, false);
m_dirtyRegions.resize(requiredLevelCount);
m_dirtyRects.resize(requiredLevelCount);
m_footprintWrites.resize(requiredLevelCount);
m_compressedData.resize(requiredLevelCount);
m_compressedFormats.resize(requiredLevelCount, GL_NONE);
m_requestedCompressedFormats.resize(requiredLevelCount, GL_NONE);
@@ -68,6 +201,9 @@ namespace MobileGL {
if (level < m_dirtyRects.size()) {
m_dirtyRects[level].clear();
}
if (level < m_footprintWrites.size()) {
m_footprintWrites[level].clear();
}
auto& data = m_data[level];
data.resize(input.byteSize, 0);
@@ -132,6 +268,7 @@ namespace MobileGL {
m_isDirty.resize(levelCount);
m_dirtyRegions.resize(levelCount);
m_dirtyRects.resize(levelCount);
m_footprintWrites.resize(levelCount);
m_compressedData.resize(levelCount);
m_compressedFormats.resize(levelCount);
m_requestedCompressedFormats.resize(levelCount);
@@ -188,6 +325,9 @@ namespace MobileGL {
if (level < m_dirtyRects.size()) {
m_dirtyRects[level].clear();
}
if (level < m_footprintWrites.size()) {
m_footprintWrites[level].clear();
}
}
bool MipmapStorage::IsDirty(Uint level) const {
@@ -212,11 +352,17 @@ namespace MobileGL {
auto& rects = m_dirtyRects[level];
if (!m_isDirty[level]) {
rects.clear(); // stale-safety; MarkDirty(false) already cleared it
m_footprintWrites[level].clear();
} else if (rects.empty() && level < m_dirtyRegions.size() &&
!m_dirtyRegions[level].Empty()) {
rects.push_back(m_dirtyRegions[level]);
}
// Entering the log inside the insert already recorded this write.
const Bool logged = !m_footprintWrites[level].empty();
InsertDirtyRect(level, incoming);
if (logged) {
m_footprintWrites[level].push_back(incoming);
}
}
if (level < m_dirtyRegions.size()) {
MipmapDirtyRegion& region = m_dirtyRegions[level];
@@ -248,6 +394,9 @@ namespace MobileGL {
merged = false;
for (SizeT i = 0; i < rects.size(); ++i) {
if (RegionsTouch(rects[i], incoming)) {
if (!UnionIsExact(rects[i], incoming)) {
EnterFootprintLog(level, incoming);
}
incoming = RegionUnion(rects[i], incoming);
rects[i] = rects.back();
rects.pop_back();
@@ -260,6 +409,7 @@ namespace MobileGL {
rects.push_back(incoming);
return;
}
EnterFootprintLog(level, incoming);
// Full: fold the incoming rect into the neighbour whose box grows least
// (least new area dragged into the upload), then re-insert the grown
// box - it may now touch others. The removal above guarantees the
@@ -310,6 +460,30 @@ namespace MobileGL {
}
return rects.size();
}
void MipmapStorage::EnterFootprintLog(Uint level, const MipmapDirtyRegion& pending) {
auto& log = m_footprintWrites[level];
if (!log.empty()) return;
log.assign(m_dirtyRects[level].begin(), m_dirtyRects[level].end());
log.push_back(pending);
}
void MipmapStorage::GetDirtyFootprint(Uint level, Vector<MipmapDirtyRegion>& outRects) const {
outRects.clear();
if (level >= m_isDirty.size() || !m_isDirty[level] || level >= m_dirtyRegions.size()) return;
if (level < m_footprintWrites.size() && !m_footprintWrites[level].empty()) {
DecomposeFootprintWrites(m_footprintWrites[level], outRects);
return;
}
// No log: every merge so far was exact and nothing folded, so the list is the
// footprint - and an empty list leaves the union box, which is then a whole-level
// write (MarkDirty, a respecify) or the single box one write left.
if (level < m_dirtyRects.size() && !m_dirtyRects[level].empty()) {
outRects.assign(m_dirtyRects[level].begin(), m_dirtyRects[level].end());
} else if (!m_dirtyRegions[level].Empty()) {
outRects.push_back(m_dirtyRegions[level]);
}
}
} // namespace GLState
} // namespace MG_State
} // namespace MobileGL
@@ -80,6 +80,15 @@ namespace MobileGL {
// area so close to the union box's that one big upload beats many small
// ones (fewer driver calls wins when the bytes are nearly equal).
SizeT GetDirtyRects(Uint level, MipmapDirtyRegion* outRects, SizeT maxRects) const;
// The level's pending writes EXACTLY: pairwise-disjoint boxes holding every texel
// written since the level was last clean and no other, however many there are and
// however much of the union box they fill. Empty while the level is clean. The
// rect list above only COVERS the writes - a merge replaces two touching rects by
// their bounding box and a full list folds - which is harmless while the texels
// outside the writes are the same in the shadow and in the backend's copy, and
// wrong once the backend's copy may hold texels the GPU wrote: the shadow is stale
// there, and anything uploaded beyond the writes goes up over them.
void GetDirtyFootprint(Uint level, Vector<MipmapDirtyRegion>& outRects) const;
// The bytes an application handed to glCompressedTexImage*, kept verbatim beside the
// (uncompressed) texel shadow rather than in place of it. GL 4.6 core 8.11 requires
@@ -112,6 +121,10 @@ namespace MobileGL {
// Insert one clamped, non-empty write box, keeping the list disjoint
// and bounded (see kMaxDirtyRects).
void InsertDirtyRect(Uint level, MipmapDirtyRegion incoming);
// Starts the level's footprint log (see m_footprintWrites) the moment the rect list
// is about to stop being exact. `pending` is the write being inserted, merged with
// the rects it has absorbed so far - still exact, and no longer in the list.
void EnterFootprintLog(Uint level, const MipmapDirtyRegion& pending);
Vector<IntVec3> m_texelSizes;
Vector<Vector<Uint8>> m_data;
@@ -125,6 +138,14 @@ namespace MobileGL {
// already-dirty level seeds the list from the union box accumulated so
// far and refines from there.
Vector<Vector<MipmapDirtyRegion>> m_dirtyRects;
// Per level, the boxes whose union is exactly the pending writes' footprint, as they
// came (overlaps and all; GetDirtyFootprint cuts them apart). EMPTY is the resting
// state and means the rect list (or, while that is empty, the union box) is itself
// exact; the log starts only at the first inexact merge or fold, from the list as it
// stood just before, and then takes every write verbatim until the level is clean or
// respecified - one box per write, so its cost follows the writes and never the
// level's size. clear() keeps the capacity, like the rect list's.
Vector<Vector<MipmapDirtyRegion>> m_footprintWrites;
Vector<Vector<Uint8>> m_compressedData;
Vector<GLenum> m_compressedFormats;
Vector<GLenum> m_requestedCompressedFormats;
@@ -90,6 +90,11 @@ namespace MobileGL {
return m_storage[targetIndex].GetDirtyRects(level, outRects, maxRects);
}
void GetDirtyFootprint(Uint targetIndex, Uint level, Vector<MipmapDirtyRegion>& outRects) const {
MOBILEGL_ASSERT(targetIndex < TargetCount, "GetDirtyFootprint: target invalid");
m_storage[targetIndex].GetDirtyFootprint(level, outRects);
}
void SetCompressedImage(Uint targetIndex, Uint level, GLenum internalFormat, const void* data,
SizeT size) {
MOBILEGL_ASSERT(targetIndex < TargetCount, "SetCompressedImage: target invalid");
@@ -379,6 +379,12 @@ namespace MobileGL {
outRects, maxRects);
}
void TextureObjectWithOneMipmap::GetStorageDirtyFootprint(TextureUploadTarget uploadTarget,
Uint mipmapLevel,
Vector<MipmapDirtyRegion>& outRects) const {
m_textureStorage.GetDirtyFootprint(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, outRects);
}
void TextureObjectWithOneMipmap::SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel,
GLenum internalFormat, const void* data, SizeT size) {
m_textureStorage.SetCompressedImage(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel,
@@ -269,6 +269,18 @@ namespace MobileGL::MG_State::GLState {
(void)maxRects;
return 0;
}
// Exactly the texels written since the level was last clean, as disjoint boxes - not a
// cover of them like the rects above, and never withheld. See MipmapStorage::
// GetDirtyFootprint for why a backend whose copy of the level may hold GPU-written texels
// uploads these and nothing else. The base fallback answers the whole-level box its
// MarkStorageDirtyRegion recorded.
virtual void GetStorageDirtyFootprint(TextureUploadTarget uploadTarget, Uint mipmapLevel,
Vector<MipmapDirtyRegion>& outRects) const {
outRects.clear();
if (IsStorageDirty(uploadTarget, mipmapLevel)) {
outRects.push_back(GetStorageDirtyRegion(uploadTarget, mipmapLevel));
}
}
// The compressed image a glCompressedTexImage* call shadowed for this level, kept verbatim
// next to the texel data rather than instead of it - see MipmapStorage. The texel shadow
@@ -351,6 +363,8 @@ namespace MobileGL::MG_State::GLState {
MipmapDirtyRegion GetStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel) const override;
SizeT GetStorageDirtyRects(TextureUploadTarget uploadTarget, Uint mipmapLevel, MipmapDirtyRegion* outRects,
SizeT maxRects) const override;
void GetStorageDirtyFootprint(TextureUploadTarget uploadTarget, Uint mipmapLevel,
Vector<MipmapDirtyRegion>& outRects) const override;
void SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel, GLenum internalFormat,
const void* data, SizeT size) override;
GLenum GetMipmapCompressedFormat(TextureUploadTarget uploadTarget, Uint mipmapLevel) const override;
@@ -75,6 +75,11 @@ namespace MobileGL {
outRects, maxRects);
}
void TextureObject2DCube::GetStorageDirtyFootprint(TextureUploadTarget uploadTarget, Uint mipmapLevel,
Vector<MipmapDirtyRegion>& outRects) const {
m_textureStorage.GetDirtyFootprint(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, outRects);
}
void TextureObject2DCube::SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel,
GLenum internalFormat, const void* data, SizeT size) {
m_textureStorage.SetCompressedImage(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel,
@@ -33,6 +33,8 @@ namespace MobileGL {
Uint mipmapLevel) const override;
SizeT GetStorageDirtyRects(TextureUploadTarget uploadTarget, Uint mipmapLevel,
MipmapDirtyRegion* outRects, SizeT maxRects) const override;
void GetStorageDirtyFootprint(TextureUploadTarget uploadTarget, Uint mipmapLevel,
Vector<MipmapDirtyRegion>& outRects) const override;
void SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel,
GLenum internalFormat, const void* data, SizeT size) override;
GLenum GetMipmapCompressedFormat(TextureUploadTarget uploadTarget, Uint mipmapLevel) const override;
@@ -322,6 +322,15 @@ namespace MobileGL::MG_State::GLState {
return m_ownerMipmap->GetStorageDirtyRegion(ToOwnerUploadTarget(uploadTarget), ToOwnerLevel(mipmapLevel));
}
// In the owner's texel space, like GetStorageDirtyRegion.
void TextureObjectView::GetStorageDirtyFootprint(TextureUploadTarget uploadTarget, Uint mipmapLevel,
Vector<MipmapDirtyRegion>& outRects) const {
outRects.clear();
if (m_ownerMipmap == nullptr) return;
m_ownerMipmap->GetStorageDirtyFootprint(ToOwnerUploadTarget(uploadTarget), ToOwnerLevel(mipmapLevel),
outRects);
}
void TextureObjectView::SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel,
GLenum internalFormat, const void* data, SizeT size) {
if (m_ownerMipmap == nullptr) return;
@@ -82,6 +82,8 @@ namespace MobileGL::MG_State::GLState {
void MarkStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel, IntVec3 offset,
IntVec3 size) override;
MipmapDirtyRegion GetStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel) const override;
void GetStorageDirtyFootprint(TextureUploadTarget uploadTarget, Uint mipmapLevel,
Vector<MipmapDirtyRegion>& outRects) const override;
void SetMipmapCompressedImage(TextureUploadTarget uploadTarget, Uint mipmapLevel, GLenum internalFormat,
const void* data, SizeT size) override;
GLenum GetMipmapCompressedFormat(TextureUploadTarget uploadTarget, Uint mipmapLevel) const override;
+2
View File
@@ -85,9 +85,11 @@ add_subdirectory(VertexArray)
add_subdirectory(Program)
add_subdirectory(Query)
add_subdirectory(Pipeline)
add_subdirectory(Swapchain)
add_subdirectory(ShaderTranspiler)
add_subdirectory(Util)
add_subdirectory(SelfTest)
add_subdirectory(MeshShader)
# The DirectGLES post-transpile ESSL passes are pure String -> String, so unlike the
# DirectVulkan suite below this one needs no device and always builds.
add_subdirectory(Backend/DirectGLES)
@@ -0,0 +1,20 @@
cmake_minimum_required(VERSION 3.14)
add_executable(
MeshShaderTest
MeshShaderTest.cpp
)
target_include_directories(MeshShaderTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
MeshShaderTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
include(GoogleTest)
gtest_discover_tests(MeshShaderTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -0,0 +1,69 @@
// MobileGL - MobileGL/MG_Test/MeshShader/MeshShader.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
#include <gtest/gtest.h>
#include <limits>
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include <MG_Impl/GLImpl/Buffer/GL_Buffer.h>
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <MG_Impl/GLImpl/VertexArray/GL_VertexArray.h>
#include <MG_Impl/GLImpl/VertexArray/Validators.h>
#include <MG_State/GLState/Core.h>
using namespace MobileGL;
class MeshShaderTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
void TearDown() override {
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "test left an unconsumed GL error behind";
}
};
TEST_F(MeshShaderTest, IsHaveExt) {
const auto* extensionsString = reinterpret_cast<const char*>(MG_Impl::GLImpl::GetString(GL_EXTENSIONS));
ASSERT_NE(extensionsString, nullptr);
// std::cout << extensionsString << std::endl;
const Bool isHaveExt = String(extensionsString).find("GL_NV_mesh_shader") != String::npos;
ASSERT_EQ(isHaveExt, true);
}
TEST_F(MeshShaderTest, TaskShaderCompile) {
const GLuint shader = MG_Impl::GLImpl::CreateShader(GL_TASK_SHADER_NV);
const char* src = R"GLSL(
#version 460
#extension GL_NV_mesh_shader : require
layout(local_size_x = 32) in;
layout(location = 0) uniform vec4 uParams; // x = grid, y = aspect, z = time
void main()
{
if (gl_LocalInvocationID.x != 0u)
return;
uint grid = uint(uParams.x);
gl_TaskCountNV = grid * grid;
}
)GLSL";
MG_Impl::GLImpl::ShaderSource(shader, 1, &src, nullptr);
MG_Impl::GLImpl::CompileShader(shader);
GLint ok = GL_FALSE;
MG_Impl::GLImpl::GetShaderiv(shader, GL_COMPILE_STATUS, &ok);
ASSERT_EQ(ok, GL_TRUE);
}
+94
View File
@@ -3061,6 +3061,100 @@ TEST(DirectVulkanSanity, GraphicsSamplerFeedbackOnlyAliasesWritableOverlappingMi
EXPECT_FALSE(UniformManager::SamplerOverlapsWritableImageSubresource(1, 3, 4, GL_WRITE_ONLY));
}
// Magma's per-frame descriptor pools (UniformManager::NextDescriptorPoolMaxSets). The old rule
// doubled the FIRST pool of the flavour, which is always the slot's oldest and smallest one, so
// every grown pool came out the same size: rd12's loading frame (15,016 sets) grew one frame slot
// to 59 update-after-bind pools of 256 sets. The policy must double the LARGEST pool of the
// flavour, up to the cap.
namespace {
using MobileGL::MG_Backend::DirectVulkan::UniformManager;
constexpr MobileGL::Uint32 kBaseSets = 64; // VulkanRenderer's kDescriptorSetsPerFrame
// A frame slot as GrowFrameDescriptorPool leaves it after a frame needing `needed` sets of one
// flavour: the base pool (non-update-after-bind, never usable by the other flavour) plus the
// policy's next pool, added until the flavour's pools hold `needed` sets.
std::vector<UniformManager::DescriptorPoolBucket> GrowSlotUntil(MobileGL::Uint64 needed, bool updateAfterBind) {
std::vector<UniformManager::DescriptorPoolBucket> pools{{VK_NULL_HANDLE, kBaseSets, 0, false}};
const auto flavourCapacity = [&]() {
MobileGL::Uint64 sum = 0;
for (const auto& pool : pools) {
if (pool.updateAfterBind == updateAfterBind) sum += pool.maxSets;
}
return sum;
};
while (flavourCapacity() < needed && pools.size() < 100000) {
pools.push_back({VK_NULL_HANDLE, UniformManager::NextDescriptorPoolMaxSets(pools, updateAfterBind, kBaseSets),
0, updateAfterBind});
}
return pools;
}
// Pools a geometric policy needs for n sets: 64, 128, ... up to the cap, then one per cap.
MobileGL::Uint64 GeometricPoolBound(MobileGL::Uint64 n) {
MobileGL::Uint64 pools = 0, capacity = 0, size = kBaseSets;
while (capacity < n) {
capacity += size;
++pools;
size = std::min<MobileGL::Uint64>(size * 2, UniformManager::kMaxDescriptorPoolSets);
}
return pools;
}
} // namespace
TEST(DirectVulkanSanity, DescriptorPoolGrowthIsGeometricInTheSetsAFrameNeeds) {
for (const bool updateAfterBind : {false, true}) {
for (const MobileGL::Uint64 needed : {65ull, 1000ull, 4096ull, 8128ull, 15016ull, 40000ull}) {
const auto pools = GrowSlotUntil(needed, updateAfterBind);
MobileGL::Uint64 flavourPools = 0, capacity = 0;
MobileGL::Uint32 previous = 0;
for (const auto& pool : pools) {
if (pool.updateAfterBind != updateAfterBind) continue;
++flavourPools;
capacity += pool.maxSets;
EXPECT_LE(pool.maxSets, UniformManager::kMaxDescriptorPoolSets) << "one pool past the cap";
EXPECT_GE(pool.maxSets, previous) << "a later pool is smaller than an earlier one";
previous = pool.maxSets;
}
// O(log N) under the cap (8128 = 64 + ... + 4096 is where the cap is reached), then one
// pool per 4096 sets. The first-match rule needed ~N/128 here: 118 pools for 15,016.
EXPECT_LE(flavourPools, GeometricPoolBound(needed))
<< needed << " sets (updateAfterBind=" << updateAfterBind << ") took " << flavourPools << " pools";
EXPECT_GE(capacity, needed);
// The slot does not over-reserve by more than the last pool it had to add.
EXPECT_LT(capacity - needed, static_cast<MobileGL::Uint64>(previous));
}
}
// rd12's loading frame, the case that motivated this: 9 pools in the slot, not 59.
const auto rd12 = GrowSlotUntil(15016, true);
EXPECT_EQ(rd12.size(), 1u + 9u);
}
TEST(DirectVulkanSanity, DescriptorPoolGrowthDoublesTheLargestPoolOfTheSameFlavour) {
using Bucket = UniformManager::DescriptorPoolBucket;
// The largest pool decides, wherever it sits in the list - a trim or a flavour switch can
// leave a small pool after a large one.
EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets(
{{VK_NULL_HANDLE, 64, 0, false}, {VK_NULL_HANDLE, 512, 0, false}, {VK_NULL_HANDLE, 128, 0, false}},
false, kBaseSets),
1024u);
// Flavours never size each other: the base pool is non-update-after-bind, so the first
// update-after-bind pool starts at the base size however large the other flavour grew.
const std::vector<Bucket> mixed{{VK_NULL_HANDLE, 64, 0, false},
{VK_NULL_HANDLE, 2048, 0, false},
{VK_NULL_HANDLE, 64, 0, true},
{VK_NULL_HANDLE, 128, 0, true}};
EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets(mixed, true, kBaseSets), 256u);
EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets(mixed, false, kBaseSets), 4096u);
EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets({{VK_NULL_HANDLE, 64, 0, false}}, true, kBaseSets), 64u);
// The cap holds, and the base size is a floor.
EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets({{VK_NULL_HANDLE, 4096, 0, false}}, false, kBaseSets),
UniformManager::kMaxDescriptorPoolSets);
EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets({{VK_NULL_HANDLE, 3000, 0, false}}, false, kBaseSets),
UniformManager::kMaxDescriptorPoolSets);
EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets({{VK_NULL_HANDLE, 16, 0, false}}, false, kBaseSets), 64u);
EXPECT_EQ(UniformManager::NextDescriptorPoolMaxSets({}, false, kBaseSets), 64u);
}
// GL_MAX_COMBINED_*_UNIFORM_COMPONENTS is components + blocks * (blockSize / 4). The product was
// formed in signed 32-bit, and a Vulkan host that reports a large VkPhysicalDeviceLimits::
// maxUniformBufferRange (a Mali driver answers 0xFFFFFFFF, which the loader saturates to
@@ -634,8 +634,8 @@ void main() { noBinding.c = afterLayout.a + beforeLayout.b + uint(texelFetch(tex
<< "a samplerBuffer is not a buffer block";
}
// THE REGRESSION THIS MIGRATION EXISTS FOR (7de7cfc6,
// minecraft-1.21.1-neoforge-create-indirect-in-world, both backends). Flywheel's indirect
// THE REGRESSION THIS MIGRATION EXISTS FOR (7de7cfc6, the original capture now replayed as
// minecraft-1.21.1-neoforge-create-indirect-in-world-align1024, both backends). Flywheel's indirect
// engine spells every storage-block binding as a macro, and the scan that used to answer this
// question ran on MACRO-UNEXPANDED text: MobileGL's preprocessing rewrites the source, it does
// not run the C preprocessor, so `binding = _FLW_MODEL_BUFFER_BINDING` reached the scanner
+25
View File
@@ -94,3 +94,28 @@ if (MSVC)
endif()
gtest_discover_tests(NegativeApiErrorsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
add_executable(
MipmapFootprintTest
MipmapFootprintTest.cpp
)
target_include_directories(MipmapFootprintTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/3rdparty/xxHash
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
${MGL_ROOT}/3rdparty/SPIRV-Reflect
)
target_link_libraries(
MipmapFootprintTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(MipmapFootprintTest PRIVATE /Zc:preprocessor)
endif()
gtest_discover_tests(MipmapFootprintTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -0,0 +1,214 @@
// MobileGL - MobileGL/MG_Test/State/MipmapFootprintTest.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
//
// MipmapStorage::GetDirtyFootprint against a reference bitmap of the writes.
//
// A backend whose copy of a level may hold GPU-written texels uploads exactly the footprint, so
// the footprint has to be the written texels and nothing else: every texel covered, no texel
// outside the writes, no texel twice. The rect list beside it only has to COVER the writes, and
// its merges, fold and 3/4 cut-off are what the footprint must not inherit - so the patterns
// below are the ones that trip them: touching boxes whose bounding box has gaps, more disjoint
// boxes than the list keeps, and random mixes of both, in 2D and 3D. The cover property of the
// list and the union box's exactness are checked alongside, since the footprint work sits inside
// the same insert.
#include <gtest/gtest.h>
#include <random>
#include <string>
#include <vector>
#include "Includes.h"
#include <MG_State/GLState/TextureState/MipmapStorage.h>
using namespace MobileGL;
using MG_State::GLState::MipmapDirtyRegion;
using MG_State::GLState::MipmapStorage;
namespace {
struct Level {
MipmapStorage storage;
IntVec3 size;
std::vector<bool> written;
explicit Level(IntVec3 levelSize) : size(levelSize) {
storage.AllocateLevel(0, {levelSize, Texels()});
written.assign(Texels(), false);
}
SizeT Texels() const {
return static_cast<SizeT>(size.x()) * static_cast<SizeT>(size.y()) * static_cast<SizeT>(size.z());
}
SizeT Index(Int x, Int y, Int z) const {
return (static_cast<SizeT>(z) * static_cast<SizeT>(size.y()) + static_cast<SizeT>(y)) *
static_cast<SizeT>(size.x()) +
static_cast<SizeT>(x);
}
void Write(IntVec3 offset, IntVec3 extent) {
storage.MarkDirtyRegion(0, offset, extent);
for (Int z = offset.z(); z < offset.z() + extent.z(); ++z)
for (Int y = offset.y(); y < offset.y() + extent.y(); ++y)
for (Int x = offset.x(); x < offset.x() + extent.x(); ++x) written[Index(x, y, z)] = true;
}
void Expect(const char* what) const {
SCOPED_TRACE(what);
ASSERT_TRUE(storage.IsDirty(0));
std::vector<MipmapDirtyRegion> footprint;
storage.GetDirtyFootprint(0, footprint);
std::vector<int> covered(Texels(), 0);
for (const auto& box : footprint) {
ASSERT_FALSE(box.Empty());
ASSERT_GE(box.lo.x(), 0);
ASSERT_GE(box.lo.y(), 0);
ASSERT_GE(box.lo.z(), 0);
ASSERT_LE(box.hi.x(), size.x());
ASSERT_LE(box.hi.y(), size.y());
ASSERT_LE(box.hi.z(), size.z());
for (Int z = box.lo.z(); z < box.hi.z(); ++z)
for (Int y = box.lo.y(); y < box.hi.y(); ++y)
for (Int x = box.lo.x(); x < box.hi.x(); ++x) ++covered[Index(x, y, z)];
}
SizeT missing = 0, extra = 0, twice = 0;
MipmapDirtyRegion bounds{size, IntVec3{0, 0, 0}};
for (Int z = 0; z < size.z(); ++z)
for (Int y = 0; y < size.y(); ++y)
for (Int x = 0; x < size.x(); ++x) {
const SizeT i = Index(x, y, z);
missing += written[i] && covered[i] == 0;
extra += !written[i] && covered[i] > 0;
twice += covered[i] > 1;
if (written[i]) {
bounds.lo = {std::min(bounds.lo.x(), x), std::min(bounds.lo.y(), y),
std::min(bounds.lo.z(), z)};
bounds.hi = {std::max(bounds.hi.x(), x + 1), std::max(bounds.hi.y(), y + 1),
std::max(bounds.hi.z(), z + 1)};
}
}
EXPECT_EQ(missing, 0u) << "written texels outside the footprint";
EXPECT_EQ(extra, 0u) << "unwritten texels inside the footprint";
EXPECT_EQ(twice, 0u) << "texels in more than one footprint box";
const MipmapDirtyRegion region = storage.GetDirtyRegion(0);
EXPECT_TRUE(region.lo == bounds.lo && region.hi == bounds.hi)
<< "the union box is not the writes' bounding box";
MipmapDirtyRegion rects[MipmapStorage::kMaxDirtyRects];
const SizeT rectCount = storage.GetDirtyRects(0, rects, MipmapStorage::kMaxDirtyRects);
SizeT uncovered = 0;
for (Int z = 0; z < size.z(); ++z)
for (Int y = 0; y < size.y(); ++y)
for (Int x = 0; x < size.x(); ++x) {
if (!written[Index(x, y, z)] || rectCount == 0) continue;
bool inRect = false;
for (SizeT r = 0; r < rectCount && !inRect; ++r)
inRect = x >= rects[r].lo.x() && x < rects[r].hi.x() && y >= rects[r].lo.y() &&
y < rects[r].hi.y() && z >= rects[r].lo.z() && z < rects[r].hi.z();
uncovered += !inRect;
}
EXPECT_EQ(uncovered, 0u) << "the rect list no longer covers the writes";
}
};
TEST(MipmapFootprintTest, TwoDistantBoxesAreTwoBoxes) {
Level level({8, 8, 1});
level.Write({0, 0, 0}, {2, 2, 1});
level.Write({5, 5, 0}, {2, 2, 1});
level.Expect("two distant 2x2");
std::vector<MipmapDirtyRegion> footprint;
level.storage.GetDirtyFootprint(0, footprint);
EXPECT_EQ(footprint.size(), 2u);
}
// Touching boxes whose bounding box has a gap: the rect list merges them anyway.
TEST(MipmapFootprintTest, ThreeQuadrantsLeaveTheFourthOut) {
Level level({8, 8, 1});
level.Write({0, 0, 0}, {4, 4, 1});
level.Write({4, 0, 0}, {4, 4, 1});
level.Write({0, 4, 0}, {4, 4, 1});
level.Expect("three quadrants");
}
TEST(MipmapFootprintTest, OverlappingStaggeredBoxes) {
Level level({16, 16, 1});
level.Write({0, 0, 0}, {6, 6, 1});
level.Write({3, 3, 0}, {6, 6, 1});
level.Write({6, 6, 0}, {6, 6, 1});
level.Expect("staggered overlaps");
}
// More disjoint boxes than the list keeps: it folds, the footprint must not.
TEST(MipmapFootprintTest, MoreBoxesThanTheListKeeps) {
Level level({40, 40, 1});
for (int j = 0; j < 10; ++j)
for (int i = 0; i < 10; ++i) level.Write({4 * i + 1, 4 * j + 1, 0}, {2, 2, 1});
level.Expect("a hundred 2x2");
std::vector<MipmapDirtyRegion> footprint;
level.storage.GetDirtyFootprint(0, footprint);
EXPECT_EQ(footprint.size(), 100u);
}
// Scanlines of one width merge exactly and stay one box without ever starting the log.
TEST(MipmapFootprintTest, AbuttingScanlinesStayOneBox) {
Level level({32, 32, 1});
for (int y = 4; y < 20; ++y) level.Write({3, y, 0}, {10, 1, 1});
level.Expect("scanlines");
std::vector<MipmapDirtyRegion> footprint;
level.storage.GetDirtyFootprint(0, footprint);
EXPECT_EQ(footprint.size(), 1u);
}
// A whole-level dirty is a whole-level footprint, and a write on top of it changes nothing.
TEST(MipmapFootprintTest, WholeLevelDirtyIsTheWholeLevel) {
Level level({8, 8, 2});
level.storage.MarkDirty(0, true);
level.written.assign(level.Texels(), true);
level.Write({1, 1, 0}, {2, 2, 1});
level.Expect("whole level");
}
// Clean resets the footprint, log included.
TEST(MipmapFootprintTest, CleanStartsOver) {
Level level({8, 8, 1});
level.Write({0, 0, 0}, {4, 4, 1});
level.Write({4, 0, 0}, {4, 4, 1});
level.Write({0, 4, 0}, {4, 4, 1});
level.storage.MarkDirty(0, false);
std::vector<MipmapDirtyRegion> footprint;
level.storage.GetDirtyFootprint(0, footprint);
EXPECT_TRUE(footprint.empty());
level.written.assign(level.Texels(), false);
level.Write({5, 5, 0}, {2, 2, 1});
level.Expect("after clean");
}
TEST(MipmapFootprintTest, RandomWrites) {
std::mt19937 rng(0x5eed1234u);
const IntVec3 sizes[] = {{8, 8, 1}, {33, 17, 1}, {64, 64, 1}, {12, 9, 5}, {7, 1, 1}};
for (int round = 0; round < 400; ++round) {
const IntVec3 size = sizes[round % 5];
Level level(size);
const int writes = 1 + static_cast<int>(rng() % (round % 3 == 0 ? 160u : 12u));
for (int w = 0; w < writes; ++w) {
const Int maxEdge = round % 2 == 0 ? 3 : std::max(size.x(), size.y());
const Int ex = 1 + static_cast<Int>(rng() % static_cast<unsigned>(std::min(maxEdge, size.x())));
const Int ey = 1 + static_cast<Int>(rng() % static_cast<unsigned>(std::min(maxEdge, size.y())));
const Int ez = 1 + static_cast<Int>(rng() % static_cast<unsigned>(size.z()));
const Int ox = static_cast<Int>(rng() % static_cast<unsigned>(size.x() - ex + 1));
const Int oy = static_cast<Int>(rng() % static_cast<unsigned>(size.y() - ey + 1));
const Int oz = static_cast<Int>(rng() % static_cast<unsigned>(size.z() - ez + 1));
level.Write({ox, oy, oz}, {ex, ey, ez});
}
level.Expect(("round " + std::to_string(round)).c_str());
if (HasFatalFailure() || HasNonfatalFailure()) return;
}
}
} // namespace
+26
View File
@@ -0,0 +1,26 @@
cmake_minimum_required(VERSION 3.14)
add_executable(
SwapchainPresentModeTest
SwapchainPresentModeTest.cpp
)
target_include_directories(SwapchainPresentModeTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/3rdparty/xxHash
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
)
target_link_libraries(
SwapchainPresentModeTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(SwapchainPresentModeTest PRIVATE /Zc:preprocessor)
endif()
include(GoogleTest)
gtest_discover_tests(SwapchainPresentModeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -0,0 +1,57 @@
// MobileGL - MobileGL/MG_Test/Swapchain/SwapchainPresentModeTest.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
#include <gtest/gtest.h>
#include <MG_Backend/DirectVulkan/Renderer/SwapchainObject.h>
using namespace MobileGL;
using MobileGL::MG_Backend::DirectVulkan::SwapchainObject;
namespace {
// What each surface reports. Android's loader exposes MAILBOX and FIFO (plus the shared
// modes with VK_KHR_shared_presentable_image) but never IMMEDIATE; Mesa's X11 WSI has
// all four classic modes.
const Vector<VkPresentModeKHR> kAndroidModes{
VK_PRESENT_MODE_MAILBOX_KHR, VK_PRESENT_MODE_FIFO_KHR, VK_PRESENT_MODE_SHARED_DEMAND_REFRESH_KHR,
VK_PRESENT_MODE_SHARED_CONTINUOUS_REFRESH_KHR};
const Vector<VkPresentModeKHR> kAllClassicModes{VK_PRESENT_MODE_IMMEDIATE_KHR, VK_PRESENT_MODE_MAILBOX_KHR,
VK_PRESENT_MODE_FIFO_KHR, VK_PRESENT_MODE_FIFO_RELAXED_KHR};
const Vector<VkPresentModeKHR> kFifoOnly{VK_PRESENT_MODE_FIFO_KHR};
VkPresentModeKHR Choose(const Vector<VkPresentModeKHR>& modes, Optional<Int> swapInterval) {
return SwapchainObject::ChooseSwapchainPresentMode(modes, swapInterval);
}
} // namespace
// Before the app calls eglSwapInterval the backend keeps its own ranking (MAILBOX first).
TEST(SwapchainPresentMode, NoRequestKeepsTheDefaultRanking) {
EXPECT_EQ(Choose(kAllClassicModes, Nullopt), VK_PRESENT_MODE_MAILBOX_KHR);
EXPECT_EQ(Choose(kAndroidModes, Nullopt), VK_PRESENT_MODE_MAILBOX_KHR);
EXPECT_EQ(Choose(kFifoOnly, Nullopt), VK_PRESENT_MODE_FIFO_KHR);
}
TEST(SwapchainPresentMode, IntervalZeroPrefersImmediateThenMailboxThenFifo) {
EXPECT_EQ(Choose(kAllClassicModes, 0), VK_PRESENT_MODE_IMMEDIATE_KHR);
EXPECT_EQ(Choose(kAndroidModes, 0), VK_PRESENT_MODE_MAILBOX_KHR);
EXPECT_EQ(Choose({VK_PRESENT_MODE_FIFO_RELAXED_KHR, VK_PRESENT_MODE_FIFO_KHR}, 0), VK_PRESENT_MODE_FIFO_KHR);
EXPECT_EQ(Choose(kFifoOnly, 0), VK_PRESENT_MODE_FIFO_KHR);
}
TEST(SwapchainPresentMode, PositiveIntervalIsFifo) {
for (Int interval : {1, 2, 4}) {
EXPECT_EQ(Choose(kAllClassicModes, interval), VK_PRESENT_MODE_FIFO_KHR) << "interval " << interval;
EXPECT_EQ(Choose(kAndroidModes, interval), VK_PRESENT_MODE_FIFO_KHR) << "interval " << interval;
}
}
TEST(SwapchainPresentMode, NegativeIntervalIsFifoRelaxedWhenSupported) {
EXPECT_EQ(Choose(kAllClassicModes, -1), VK_PRESENT_MODE_FIFO_RELAXED_KHR);
EXPECT_EQ(Choose(kAndroidModes, -1), VK_PRESENT_MODE_FIFO_KHR);
EXPECT_EQ(Choose(kFifoOnly, -1), VK_PRESENT_MODE_FIFO_KHR);
}
@@ -18,6 +18,10 @@ namespace MobileGL {
return EShLanguage::EShLangFragment;
case GL_COMPUTE_SHADER:
return EShLanguage::EShLangCompute;
case GL_TASK_SHADER_NV:
return EShLanguage::EShLangTaskNV;
case GL_MESH_SHADER_NV:
return EShLanguage::EShLangMeshNV;
case GL_TESS_CONTROL_SHADER:
return EShLanguage::EShLangTessControl;
case GL_TESS_EVALUATION_SHADER:
@@ -22,6 +22,10 @@ namespace MobileGL {
return ShaderStage::Geometry;
case GL_FRAGMENT_SHADER:
return ShaderStage::Fragment;
case GL_TASK_SHADER_NV:
return ShaderStage::Task;
case GL_MESH_SHADER_NV:
return ShaderStage::Mesh;
case GL_COMPUTE_SHADER:
return ShaderStage::Compute;
default:
@@ -409,6 +409,8 @@ namespace MobileGL {
CASE(GL_MAX_TEXTURE_IMAGE_UNITS)
CASE(GL_FRAGMENT_SHADER)
CASE(GL_VERTEX_SHADER)
CASE(GL_TASK_SHADER_NV)
CASE(GL_MESH_SHADER_NV)
CASE(GL_MAX_FRAGMENT_UNIFORM_COMPONENTS)
CASE(GL_MAX_VERTEX_UNIFORM_COMPONENTS)
CASE(GL_MAX_VARYING_FLOATS)
@@ -22,6 +22,10 @@ namespace MobileGL {
return GL_GEOMETRY_SHADER;
case ShaderStage::Fragment:
return GL_FRAGMENT_SHADER;
case ShaderStage::Task:
return GL_TASK_SHADER_NV;
case ShaderStage::Mesh:
return GL_MESH_SHADER_NV;
case ShaderStage::Compute:
return GL_COMPUTE_SHADER;
default:
+2
View File
@@ -162,6 +162,8 @@ MobileGL supports runtime configuration via environment variables.
| `MOBILEGL_ADVERTISE_FP64` | Advertise `GL_ARB_gpu_shader_fp64`. GLSL `double`/`dvec`/`dmat` compile and run either way - they are narrowed to 32 bits - so this only changes whether an application is told it has 64-bit precision, which it does not. | `0`, `1` | `0` |
| `MOBILEGL_MAGMA_R11G11B10F_FALLBACK` | Use Magma's R11G11B10F format fallback. | `0`, `1` | `0` |
| `MOBILEGL_MAGMA_FRAMESINFLIGHT` | Set Magma frames in flight. | Integer `1`–`64` | `3` |
| `MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER` | Draws and dispatches Magma records into one command buffer before it submits the buffer mid-frame and continues on a fresh one. `0` never splits. | Integer `0`–`16777216` | `16384` |
| `MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES` | Frames a frame slot's descriptor pools must stay under a quarter full before Magma frees the slot's cached descriptor sets and grown pools. `0` never trims. | Integer `0`–`1048576` | `120` |
| `MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER` | Avoid sampler mipmap minification filters. | `0`, `1` | `0` |
| `MOBILEGL_COHERENT_AS_FLUSH` | Treat persistent `GL_MAP_FLUSH_EXPLICIT_BIT` maps as coherent (app-compat for engines like Flywheel that never flush them). | `0`, `1` | `0` |
| `MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION` | Always emulate depth/stencil `glReadPixels`/`glGetTexImage` by shader sampling on Espryt, instead of using the driver's own depth/stencil readback where it has one. | `0`, `1` | `0` |
+12 -4
View File
@@ -124,10 +124,18 @@ The bundled fixtures cover:
- minecraft-1.21.4-fabric-iris-sundial-lite-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and
Sundial Lite after entering a singleplayer world.
![Minecraft 1.21.4 Fabric Iris Sundial Lite in-world golden](fixtures/minecraft-1.21.4-fabric-iris-sundial-lite-in-world.0000150023.png)
- minecraft-1.21.1-neoforge-create-indirect-in-world: captured from Minecraft 1.21.1 NeoForge with Create, Sodium,
and Iris (no shader pack) in a world facing Create water wheels and a large cogwheel, with Flywheel's
`flywheel:indirect` backend (compute-shader culling, glMultiDrawElementsIndirect, persistent-mapped staging).
![Minecraft 1.21.1 NeoForge Create indirect in-world golden](fixtures/minecraft-1.21.1-neoforge-create-indirect-in-world.0000504631.png)
- minecraft-1.21.1-neoforge-create-indirect-in-world-align1024: **not an original capture** - derived from the retired
`minecraft-1.21.1-neoforge-create-indirect-in-world` capture (Minecraft 1.21.1 NeoForge with Create, Sodium, and
Iris (no shader pack) in a world facing Create water wheels and a large cogwheel, with Flywheel's
`flywheel:indirect` backend: compute-shader culling, glMultiDrawElementsIndirect, persistent-mapped staging). That
capture came from a desktop driver whose `GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT` is 16, so Flywheel bound its
scatter lists at 16-byte offsets, and a driver that requires more (Adreno 830: 64) rejects every one of those binds
with `GL_INVALID_VALUE`. [`fixture_tools/realign_buffer_ranges.cpp`](fixture_tools/realign_buffer_ranges.cpp)
`--align 1024` rewrote it: each of the 1,270 range binds off a 1024-byte boundary now binds a 1024-aligned slot of
an added buffer that is filled with the same bytes right before the dispatch that reads them. The 3,812 inserted
calls renumber the trace (target call 504631 -> 508443); the golden is the original one (the same frame), and the
host replay matches it with the original's SSIM to 9 digits on both backends.
![Minecraft 1.21.1 NeoForge Create indirect (align 1024) in-world golden](fixtures/minecraft-1.21.1-neoforge-create-indirect-in-world-align1024.0000508443.png)
- minecraft-1.21.1-neoforge-create-instancing-in-world: same world and camera as the indirect case, with Flywheel's
`flywheel:instancing` backend (texture-buffer instance data, glDrawElementsInstancedBaseVertex).
![Minecraft 1.21.1 NeoForge Create instancing in-world golden](fixtures/minecraft-1.21.1-neoforge-create-instancing-in-world.0000530333.png)
@@ -0,0 +1,432 @@
// realign_buffer_ranges: make a GL trace's indexed uniform / storage buffer range binds portable.
//
// An app sizes glBindBufferRange offsets by the GL_{UNIFORM,SHADER_STORAGE}_BUFFER_OFFSET_ALIGNMENT
// of the driver it was captured on (Flywheel's staging scatter lists: 16 on desktop). A driver that
// requires more rejects those binds with GL_INVALID_VALUE. This tool rewrites every such bind whose
// offset is not a multiple of --align:
// - the bind itself becomes glBindBuffer(target, buffer) (the generic binding it also set);
// - right before the next draw / dispatch, the bytes the range holds at that point are uploaded into a
// fresh --align-aligned slot of one relocation buffer, and that slot is bound at the same index.
// The bytes come from the trace itself (buffer data / subdata and the persistent-map memcpy commits), so a
// source buffer written any other way (GPU copies, clears) is refused rather than guessed.
//
// Inserting calls renumbers every later call: --map-call N prints where original call N landed (a case's
// target_call). The output is snappy-compressed; `apitrace repack --brotli` it before packaging.
//
// Build (A = 3rdparty/apitrace, B = a Release build of it, e.g. `cmake -S $A -B $B -G Ninja -DENABLE_GUI=OFF
// && ninja -C $B apitrace`):
// g++ -std=c++17 -O2 -flto=auto -fno-rtti -fno-exceptions -I$A/lib/trace -I$A/lib/os -I$B -I$B/lib/trace \
// realign_buffer_ranges.cpp -o realign_buffer_ranges $B/lib/trace/libcommon.a $B/lib/guids/libguids.a \
// $B/lib/highlight/libhighlight.a $B/lib/os/libos.a $B/thirdparty/libbacktrace.a $B/thirdparty/libsnappy.a \
// $B/thirdparty/libzstd_seekable.a -lbrotlidec -lbrotlienc -lz -lzstd -lpthread -ldl
// Usage: realign_buffer_ranges [--align N] [--map-call N]... in.trace out.trace
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <map>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
#include "trace_model.hpp"
#include "trace_parser.hpp"
#include "trace_writer.hpp"
namespace {
constexpr unsigned kUniformBuffer = 0x8A11;
constexpr unsigned kShaderStorageBuffer = 0x90D2;
constexpr unsigned kDynamicStorageBit = 0x0100;
constexpr unsigned kRelocBufferName = 1000000;
[[noreturn]] void Fail(const trace::Call *call, const std::string &what) {
std::fprintf(stderr, "error: call %u %s: %s\n", call ? call->no : 0u, call ? call->name() : "-", what.c_str());
std::exit(1);
}
bool Is(const trace::Call *call, const char *name) { return std::strcmp(call->name(), name) == 0; }
bool StartsWith(const char *s, const char *prefix) { return std::strncmp(s, prefix, std::strlen(prefix)) == 0; }
bool ReadsIndexedBuffers(const trace::Call *call) {
const char *n = call->name();
return StartsWith(n, "glDraw") || StartsWith(n, "glMultiDraw") || StartsWith(n, "glDispatchCompute");
}
bool IsRangeTarget(unsigned target) { return target == kUniformBuffer || target == kShaderStorageBuffer; }
struct MapRegion {
unsigned buffer;
uint64_t offset;
uint64_t length;
uint64_t ptr;
};
struct Reloc {
unsigned src = 0;
uint64_t offset = 0;
uint64_t size = 0;
bool dirty = true;
std::vector<char> last;
const trace::EnumSig *targetSig = nullptr;
};
// Signatures for the calls this tool inserts; ids are allocated past the trace's own.
struct Sigs {
const char *createNames[2] = {"n", "buffers"};
const char *storageNames[4] = {"buffer", "size", "data", "flags"};
const char *subDataNames[4] = {"buffer", "offset", "size", "data"};
const char *bindRangeNames[5] = {"target", "index", "buffer", "offset", "size"};
const char *bindNames[2] = {"target", "buffer"};
trace::FunctionSig create{}, storage{}, subData{}, bindRange{}, bind{};
void Init(trace::Id firstId) {
create = {firstId + 0, "glCreateBuffers", 2, createNames};
storage = {firstId + 1, "glNamedBufferStorage", 4, storageNames};
subData = {firstId + 2, "glNamedBufferSubData", 4, subDataNames};
bindRange = {firstId + 3, "glBindBufferRange", 5, bindRangeNames};
bind = {firstId + 4, "glBindBuffer", 2, bindNames};
}
};
class Rewriter {
public:
uint64_t align = 1024;
std::set<unsigned> mapCalls;
int Run(const char *in, const char *out) {
Scan(in);
if (relocSrc_.empty()) {
std::fprintf(stderr, "nothing to realign at %llu bytes\n", (unsigned long long)align);
return 2;
}
sigs_.Init(maxSigId_ + 1);
stride_ = (maxRelocSize_ + align - 1) / align * align;
Pass(in, nullptr); // dry run: count the slots
slotCap_ = slotsUsed_;
Pass(in, out);
std::fprintf(stderr,
"realigned %llu binds of %zu source buffer(s) to %llu bytes: %llu uploads, relocation buffer %llu "
"bytes, %llu calls inserted\n",
(unsigned long long)relocBinds_, relocSrc_.size(), (unsigned long long)align,
(unsigned long long)slotsUsed_, (unsigned long long)(slotCap_ * stride_),
(unsigned long long)inserted_);
for (const auto &m : callMap_) {
std::printf("call %u -> %llu\n", m.first, (unsigned long long)m.second);
}
return 0;
}
private:
// Pass 0: which buffers need relocating, and the sizes involved.
void Scan(const char *in) {
trace::Parser p;
if (!p.open(in)) Fail(nullptr, std::string("cannot open ") + in);
trace::Call *call;
while ((call = p.parse_call())) {
maxSigId_ = std::max(maxSigId_, call->sig->id);
if (Is(call, "glBindBufferRange")) {
unsigned target = unsigned(call->arg(0).toSInt());
uint64_t offset = uint64_t(call->arg(3).toSInt());
if (IsRangeTarget(target) && offset % align != 0) {
relocSrc_.insert(unsigned(call->arg(2).toUInt()));
maxRelocSize_ = std::max(maxRelocSize_, uint64_t(call->arg(4).toSInt()));
}
} else if (Is(call, "glBindBuffersRange")) {
unsigned target = unsigned(call->arg(0).toSInt());
const trace::Array *offsets = call->arg(4).toArray();
if (IsRangeTarget(target) && offsets) {
for (const trace::Value *v : offsets->values) {
if (uint64_t(v->toSInt()) % align != 0) Fail(call, "misaligned glBindBuffersRange is not handled");
}
}
}
delete call;
}
}
void Pass(const char *in, const char *out) {
trace::Parser p;
if (!p.open(in)) Fail(nullptr, std::string("cannot open ") + in);
trace::Writer writer;
writer_ = nullptr;
if (out) {
if (!writer.open(out, p.getVersion(), p.getProperties())) Fail(nullptr, std::string("cannot create ") + out);
writer_ = &writer;
}
shadow_.clear();
maps_.clear();
generic_.clear();
relocs_.clear();
slotsUsed_ = 0;
relocBinds_ = 0;
inserted_ = 0;
created_ = false;
trace::Call *call;
while ((call = p.parse_call())) {
Process(call);
delete call;
}
if (writer_) writer.close();
writer_ = nullptr;
}
unsigned BufferArg(trace::Call *call, unsigned index) { return unsigned(call->arg(index).toUInt()); }
unsigned Generic(unsigned target) {
auto it = generic_.find(target);
return it == generic_.end() ? 0 : it->second;
}
std::vector<char> *Shadow(unsigned buffer) {
auto it = shadow_.find(buffer);
return it == shadow_.end() ? nullptr : &it->second;
}
void Specify(trace::Call *call, unsigned buffer, uint64_t size, trace::Value *data) {
if (!relocSrc_.count(buffer)) return;
std::vector<char> &s = shadow_[buffer];
s.assign(size, 0);
const trace::Blob *blob = data ? data->toBlob() : nullptr;
if (blob) std::memcpy(s.data(), blob->buf, std::min<uint64_t>(size, blob->size));
for (auto &r : relocs_) {
if (r.second.src == buffer) r.second.dirty = true;
}
(void)call;
}
void Write(trace::Call *call, unsigned buffer, uint64_t offset, const char *bytes, uint64_t size) {
std::vector<char> *s = Shadow(buffer);
if (!s) return;
if (offset + size > s->size()) Fail(call, "write past the end of a tracked buffer");
std::memcpy(s->data() + offset, bytes, size);
}
void Refuse(trace::Call *call, unsigned buffer) {
if (relocSrc_.count(buffer)) Fail(call, "writes a relocated source buffer in a way the trace does not carry the bytes of");
}
void Emit(trace::Call *call) {
if (!writer_) return;
if (mapCalls.count(call->no)) callMap_[call->no] = writtenCalls_;
writer_->writeCall(call);
++writtenCalls_;
}
void EmitNew(trace::Call *call) {
++inserted_;
if (writer_) {
writer_->writeCall(call);
++writtenCalls_;
}
delete call;
}
trace::Call *NewCall(const trace::FunctionSig *sig, unsigned thread) {
return new trace::Call(sig, trace::CallFlags(0), thread);
}
void EmitBind(unsigned thread, const trace::EnumSig *targetSig, unsigned target, unsigned buffer) {
trace::Call *c = NewCall(&sigs_.bind, thread);
c->args[0].value = targetSig ? static_cast<trace::Value *>(new trace::Enum(targetSig, target))
: static_cast<trace::Value *>(new trace::SInt(target));
c->args[1].value = new trace::UInt(buffer);
EmitNew(c);
}
void CreateRelocBuffer(unsigned thread) {
trace::Call *c = NewCall(&sigs_.create, thread);
c->args[0].value = new trace::SInt(1);
trace::Array *names = new trace::Array(1);
names->values[0] = new trace::UInt(kRelocBufferName);
c->args[1].value = names;
EmitNew(c);
c = NewCall(&sigs_.storage, thread);
c->args[0].value = new trace::UInt(kRelocBufferName);
c->args[1].value = new trace::SInt(int64_t(slotCap_ * stride_));
c->args[2].value = new trace::Null();
c->args[3].value = new trace::UInt(kDynamicStorageBit);
EmitNew(c);
created_ = true;
}
void Upload(trace::Call *before, unsigned target, unsigned index, Reloc &r) {
std::vector<char> *s = Shadow(r.src);
if (!s) Fail(before, "a relocated range reads a buffer whose storage the trace never specified");
if (r.offset + r.size > s->size()) Fail(before, "a relocated range runs past its buffer");
std::vector<char> bytes(s->begin() + r.offset, s->begin() + r.offset + r.size);
if (!r.dirty && bytes == r.last) return;
unsigned thread = before->thread_id;
if (writer_ && slotsUsed_ >= slotCap_) Fail(before, "relocation slots exceeded the dry run");
uint64_t slotOffset = slotsUsed_++ * stride_;
if (!created_) CreateRelocBuffer(thread);
trace::Call *c = NewCall(&sigs_.subData, thread);
c->args[0].value = new trace::UInt(kRelocBufferName);
c->args[1].value = new trace::SInt(int64_t(slotOffset));
c->args[2].value = new trace::SInt(int64_t(r.size));
trace::Blob *blob = new trace::Blob(r.size);
std::memcpy(blob->buf, bytes.data(), r.size);
c->args[3].value = blob;
EmitNew(c);
c = NewCall(&sigs_.bindRange, thread);
c->args[0].value = r.targetSig ? static_cast<trace::Value *>(new trace::Enum(r.targetSig, target))
: static_cast<trace::Value *>(new trace::SInt(target));
c->args[1].value = new trace::UInt(index);
c->args[2].value = new trace::UInt(kRelocBufferName);
c->args[3].value = new trace::SInt(int64_t(slotOffset));
c->args[4].value = new trace::SInt(int64_t(r.size));
EmitNew(c);
// glBindBufferRange also moved the generic binding; put back the app's.
EmitBind(thread, r.targetSig, target, Generic(target));
r.last = std::move(bytes);
r.dirty = false;
}
void Process(trace::Call *call) {
const char *n = call->name();
if (Is(call, "glBindBuffer")) {
generic_[unsigned(call->arg(0).toSInt())] = BufferArg(call, 1);
} else if (Is(call, "glBindBufferBase")) {
unsigned target = unsigned(call->arg(0).toSInt());
generic_[target] = BufferArg(call, 2);
relocs_.erase({target, unsigned(call->arg(1).toUInt())});
} else if (Is(call, "glBindBufferRange")) {
unsigned target = unsigned(call->arg(0).toSInt());
unsigned index = unsigned(call->arg(1).toUInt());
unsigned buffer = BufferArg(call, 2);
uint64_t offset = uint64_t(call->arg(3).toSInt());
generic_[target] = buffer;
if (IsRangeTarget(target) && offset % align != 0) {
Reloc r;
r.src = buffer;
r.offset = offset;
r.size = uint64_t(call->arg(4).toSInt());
trace::Value &t = call->arg(0);
trace::Enum *e = nullptr;
// Enum values print by name in dumps; keep the trace's own enum signature when it has one.
struct EnumProbe : trace::Visitor {
trace::Enum *found = nullptr;
void visit(trace::Enum *node) override { found = node; }
} probe;
t.visit(probe);
e = probe.found;
r.targetSig = e ? e->sig : nullptr;
relocs_[{target, index}] = std::move(r);
++relocBinds_;
if (mapCalls.count(call->no) && writer_) callMap_[call->no] = writtenCalls_;
// Only the generic binding survives at this point; the indexed one is bound before its first read.
EmitBind(call->thread_id, e ? e->sig : nullptr, target, buffer);
--inserted_; // a replacement, not an insertion
return;
}
relocs_.erase({target, index});
} else if (Is(call, "glBindBuffersBase") || Is(call, "glBindBuffersRange")) {
unsigned target = unsigned(call->arg(0).toSInt());
unsigned first = unsigned(call->arg(1).toUInt());
unsigned count = unsigned(call->arg(2).toSInt());
for (unsigned i = first; i < first + count; ++i) relocs_.erase({target, i});
} else if (Is(call, "glNamedBufferStorage") || Is(call, "glNamedBufferData")) {
Specify(call, BufferArg(call, 0), uint64_t(call->arg(1).toSInt()), &call->arg(2));
} else if (Is(call, "glBufferStorage") || Is(call, "glBufferData")) {
Specify(call, Generic(unsigned(call->arg(0).toSInt())), uint64_t(call->arg(1).toSInt()), &call->arg(2));
} else if (Is(call, "glNamedBufferSubData") || Is(call, "glBufferSubData")) {
unsigned buffer = Is(call, "glNamedBufferSubData") ? BufferArg(call, 0) : Generic(unsigned(call->arg(0).toSInt()));
if (relocSrc_.count(buffer)) {
const trace::Blob *blob = call->arg(3).toBlob();
if (!blob) Fail(call, "subdata without bytes");
Write(call, buffer, uint64_t(call->arg(1).toSInt()), blob->buf,
std::min<uint64_t>(uint64_t(call->arg(2).toSInt()), blob->size));
}
} else if (Is(call, "glCopyNamedBufferSubData")) {
Refuse(call, BufferArg(call, 1));
} else if (Is(call, "glCopyBufferSubData")) {
Refuse(call, Generic(unsigned(call->arg(1).toSInt())));
} else if (StartsWith(n, "glClearNamedBuffer")) {
Refuse(call, BufferArg(call, 0));
} else if (StartsWith(n, "glClearBuffer") && (StartsWith(n, "glClearBufferData") || StartsWith(n, "glClearBufferSubData"))) {
Refuse(call, Generic(unsigned(call->arg(0).toSInt())));
} else if (Is(call, "glMapNamedBufferRange") || Is(call, "glMapBufferRange")) {
unsigned buffer = Is(call, "glMapNamedBufferRange") ? BufferArg(call, 0) : Generic(unsigned(call->arg(0).toSInt()));
if (relocSrc_.count(buffer) && call->ret) {
maps_.push_back({buffer, uint64_t(call->arg(1).toSInt()), uint64_t(call->arg(2).toSInt()),
call->ret->toUIntPtr()});
}
} else if (Is(call, "glMapNamedBuffer") || Is(call, "glMapBuffer")) {
unsigned buffer = Is(call, "glMapNamedBuffer") ? BufferArg(call, 0) : Generic(unsigned(call->arg(0).toSInt()));
std::vector<char> *s = Shadow(buffer);
if (s && call->ret) maps_.push_back({buffer, 0, s->size(), call->ret->toUIntPtr()});
} else if (Is(call, "glUnmapNamedBuffer") || Is(call, "glUnmapBuffer")) {
unsigned buffer = Is(call, "glUnmapNamedBuffer") ? BufferArg(call, 0) : Generic(unsigned(call->arg(0).toSInt()));
maps_.erase(std::remove_if(maps_.begin(), maps_.end(), [&](const MapRegion &m) { return m.buffer == buffer; }),
maps_.end());
} else if (Is(call, "memcpy")) {
uint64_t dest = call->arg(0).toUIntPtr();
const trace::Blob *blob = call->arg(1).toBlob();
uint64_t size = call->arg(2).toUInt();
if (blob) {
size = std::min<uint64_t>(size, blob->size);
for (const MapRegion &m : maps_) {
uint64_t lo = std::max(dest, m.ptr);
uint64_t hi = std::min(dest + size, m.ptr + m.length);
if (lo >= hi) continue;
Write(call, m.buffer, m.offset + (lo - m.ptr), blob->buf + (lo - dest), hi - lo);
}
}
} else if (ReadsIndexedBuffers(call)) {
for (auto &r : relocs_) Upload(call, r.first.first, r.first.second, r.second);
}
Emit(call);
}
Sigs sigs_;
trace::Id maxSigId_ = 0;
std::set<unsigned> relocSrc_;
uint64_t maxRelocSize_ = 0;
uint64_t stride_ = 0;
uint64_t slotCap_ = 0;
uint64_t slotsUsed_ = 0;
uint64_t relocBinds_ = 0;
uint64_t inserted_ = 0;
uint64_t writtenCalls_ = 0;
bool created_ = false;
trace::Writer *writer_ = nullptr;
std::unordered_map<unsigned, std::vector<char>> shadow_;
std::vector<MapRegion> maps_;
std::unordered_map<unsigned, unsigned> generic_;
std::map<std::pair<unsigned, unsigned>, Reloc> relocs_;
std::map<unsigned, uint64_t> callMap_;
};
} // namespace
int main(int argc, char **argv) {
Rewriter rw;
std::vector<const char *> files;
for (int i = 1; i < argc; ++i) {
if (!std::strcmp(argv[i], "--align") && i + 1 < argc) {
rw.align = std::strtoull(argv[++i], nullptr, 0);
} else if (!std::strcmp(argv[i], "--map-call") && i + 1 < argc) {
rw.mapCalls.insert(unsigned(std::strtoul(argv[++i], nullptr, 0)));
} else {
files.push_back(argv[i]);
}
}
if (files.size() != 2 || rw.align == 0) {
std::fprintf(stderr, "usage: %s [--align N] [--map-call N]... in.trace out.trace\n", argv[0]);
return 1;
}
return rw.Run(files[0], files[1]);
}
+5 -4
View File
@@ -260,10 +260,11 @@
"target_call": 145353
},
{
"name": "minecraft-1.21.1-neoforge-create-indirect-in-world",
"trace_archive": "minecraft-1.21.1-neoforge-create-indirect-in-world.tgz",
"golden": "minecraft-1.21.1-neoforge-create-indirect-in-world.0000504631.png",
"target_call": 504631,
"name": "minecraft-1.21.1-neoforge-create-indirect-in-world-align1024",
"derived_from": "minecraft-1.21.1-neoforge-create-indirect-in-world (the original desktop capture, retired): NOT the same trace - its 16-byte-aligned uniform/storage buffer range binds were relocated to 1024-aligned slots by tools/trace_replay/fixture_tools/realign_buffer_ranges.cpp --align 1024, which inserts calls and renumbers them (original target_call 504631)",
"trace_archive": "minecraft-1.21.1-neoforge-create-indirect-in-world-align1024.tgz",
"golden": "minecraft-1.21.1-neoforge-create-indirect-in-world-align1024.0000508443.png",
"target_call": 508443,
"timeout_seconds": 1800,
"coherent_as_flush": true
},