mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
[Merge] (P11): dev@c14283ee - scattered sub-image writes upload exactly their texels, Magma submits a frame's recording every 16384 draws with backpressure, and Magma grows descriptor pools geometrically and trims a quiet slot whole; the handle arm notes driver-side writes by handle and the wire arm splits recordings and keeps the trim's epoch peak across descriptor rewinds
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -230,6 +230,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");
|
||||
|
||||
@@ -4345,12 +4345,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
const auto textureFormat = static_cast<TextureInternalFormat>(resource->Desc.InternalFormat);
|
||||
const TextureTarget textureTarget = PipeTextureTargetForHandle(view.Res);
|
||||
const GLenum access = GLAccessForImageView(view);
|
||||
// Before the sync, for the frontend arm's reason: the note may adopt a twin, and
|
||||
// `backendTexture` below is a reference into the same registry.
|
||||
if (access != GL_READ_ONLY) {
|
||||
NoteDriverSideTextureWriteByHandle(view.Res);
|
||||
}
|
||||
auto& backendTexture = SyncTextureToBackendByHandle(view.Res, /*imageBindableStorageRequired=*/true);
|
||||
if (!backendTexture) return;
|
||||
IssueImageTextureBind(unit, *backendTexture, textureTarget, textureFormat,
|
||||
static_cast<GLenum>(view.InternalFormat), static_cast<GLint>(view.Level),
|
||||
view.Layered != 0, static_cast<GLint>(view.Layer),
|
||||
GLAccessForImageView(view));
|
||||
view.Layered != 0, static_cast<GLint>(view.Layer), access);
|
||||
}
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
|
||||
@@ -4375,6 +4380,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);
|
||||
if (!backendTexture) return;
|
||||
IssueImageTextureBind(unit, *backendTexture, imageBinding.Texture->GetTarget(),
|
||||
@@ -10929,6 +10939,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
continue;
|
||||
}
|
||||
|
||||
TextureImpl::NoteDriverSideTextureWrite(destinationTexture);
|
||||
auto backendSource = TextureImpl::SyncTextureObjectToBackend(sourceTexture);
|
||||
auto backendDestination = TextureImpl::SyncTextureObjectToBackend(destinationTexture);
|
||||
if (!backendSource || !backendDestination) continue;
|
||||
@@ -12591,6 +12602,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// touched unit happened to sync it as a side effect. The named narrowing (P5e-5) says
|
||||
// the union that still covers every texture includes "the waited texture ops", and
|
||||
// this is one of them: it syncs its own endpoint by handle.
|
||||
TextureImpl::NoteDriverSideTextureWriteByHandle(dstHandle);
|
||||
auto& backendTexture = TextureImpl::SyncTextureToBackendByHandle(dstHandle);
|
||||
if (!backendTexture || dstRecord == nullptr) {
|
||||
MGLOG_E_ONCE("CopyTexImage2D: the verb's destination texture {%u, %u} has no twin "
|
||||
@@ -12606,6 +12618,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
const auto& textureObject = MGB_CTX->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.",
|
||||
@@ -12708,6 +12721,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
const MG_Pipe::MGPipeHandle dstHandle = MG_Pipe::MGPipeApplier().VerbCopyTexDst;
|
||||
// P5e (tx2): synced by handle - see CopyTexImage2D's note. A copy destination is not
|
||||
// in the sampler-view window, so nothing else on this path would build its twin.
|
||||
TextureImpl::NoteDriverSideTextureWriteByHandle(dstHandle);
|
||||
auto& backendTexture = TextureImpl::SyncTextureToBackendByHandle(dstHandle);
|
||||
if (!backendTexture) {
|
||||
MGLOG_E_ONCE("CopyTexSubImage2D: the verb's destination texture {%u, %u} has no "
|
||||
@@ -12722,6 +12736,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
const auto& textureObject = MGB_CTX->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.",
|
||||
@@ -12943,6 +12958,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// for GenerateMipmap move to kWaitNone.
|
||||
if (MG_Config::Transport != MG_Config::TransportMode::Monolith) {
|
||||
const auto mipRes = MG_Pipe::MGPipeApplier().VerbMipRes;
|
||||
// Every arm GenerateMipmapByRecord takes writes the levels above the base on the
|
||||
// driver only (its CPU filter is unmigrated and aborts), so the note is unconditional.
|
||||
TextureImpl::NoteDriverSideTextureWriteByHandle(mipRes);
|
||||
auto& backendTexture = TextureImpl::SyncTextureToBackendByHandle(mipRes);
|
||||
const auto* record = PipeTextureRecordForHandle(mipRes);
|
||||
if (!backendTexture || record == nullptr) {
|
||||
@@ -12970,6 +12988,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);
|
||||
|
||||
// Asked for before the format arms below, which are 2D-only emulations of the same
|
||||
@@ -13316,6 +13336,16 @@ 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 MOBILEGL_BUILD_DISAGGREGATED
|
||||
// The handle arm's endpoint names its texture by handle, and the server has no frontend
|
||||
// object to note.
|
||||
if (!dstEndpoint.IsRenderbuffer() && !MG_Pipe::MGPipeHandleIsNull(dstEndpoint.TextureHandle)) {
|
||||
TextureImpl::NoteDriverSideTextureWriteByHandle(dstEndpoint.TextureHandle);
|
||||
}
|
||||
#endif
|
||||
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__);
|
||||
|
||||
@@ -6457,20 +6457,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
#endif
|
||||
|
||||
// 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 (the level's pending rects, the
|
||||
// same boxes a plain sync would upload over the driver's copy). Returns false, leaving the
|
||||
// shadow untouched, when the driver's copy cannot stand in for the shadow: the client owns
|
||||
// every byte 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).
|
||||
// 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).
|
||||
//
|
||||
// P11 M, `pipePending` (push builds): THE CLIENT WRITES THE DRIVER HAS NOT BEEN SENT MAY
|
||||
// LIVE IN THE APPLIER'S PENDING SET INSTEAD. On the texture handle arm the client clears its
|
||||
// own dirty flags when it emits a level (D-D5), so the frontend model below answers "clean"
|
||||
// for a write that is still only in the shadow; the entry the applier accumulated for this
|
||||
// (uploadTarget, level) names the same shadow-grid boxes. Both are merged over the
|
||||
// snapshot, and an entry whose union box covers the level owns every byte of it, exactly as
|
||||
// a covering dirty region does. Null on the pull build's arm and the legacy arm.
|
||||
// snapshot. Only a box-only entry whose box IS the level owns every byte of it (dev's rule:
|
||||
// a union box spanning the level with gaps in it is not a whole-level write); an entry with
|
||||
// regions never short-circuits, since accumulated regions may overlap and their texels do
|
||||
// not add up - the readback and the merge below give the same bytes when they do cover it.
|
||||
// Null on the pull build's arm and the legacy arm.
|
||||
static Bool AdoptDriverLevelIntoShadow(GLuint backendTextureId, BackendTextureObject& backendTexture,
|
||||
const MG_State::GLState::ITextureObject& stateTextureObject,
|
||||
MG_State::GLState::TextureObjectMipmap& mipmapObject,
|
||||
@@ -6482,11 +6486,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
) {
|
||||
const IntVec3 texelSize = mipmapObject.GetMipmapTexelSize(uploadTarget, level);
|
||||
const IntVec3 extent{texelSize.x(), texelSize.y(), std::max(texelSize.z(), 1)};
|
||||
const Bool dirty = mipmapObject.IsStorageDirty(uploadTarget, level);
|
||||
const MG_State::GLState::MipmapDirtyRegion dirtyRegion =
|
||||
dirty ? mipmapObject.GetStorageDirtyRegion(uploadTarget, level)
|
||||
: MG_State::GLState::MipmapDirtyRegion{};
|
||||
if (dirty && dirtyRegion.CoversWholeLevel(extent)) return false;
|
||||
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 MOBILEGL_PIPE_PUSH
|
||||
// Every box the entry names, checked against the level before anything is read: a box
|
||||
// outside it is a corrupt record, and the answer is the pre-readback one (keep the
|
||||
@@ -6511,8 +6520,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (box.X == 0 && box.Y == 0 && box.Z == 0 && box.W == static_cast<Uint32>(extent.x()) &&
|
||||
box.H == static_cast<Uint32>(extent.y()) && box.D == static_cast<Uint32>(extent.z())) {
|
||||
if (pipePending->Regions.empty() && box.X == 0 && box.Y == 0 && box.Z == 0 &&
|
||||
box.W == static_cast<Uint32>(extent.x()) && box.H == static_cast<Uint32>(extent.y()) &&
|
||||
box.D == static_cast<Uint32>(extent.z())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -6546,28 +6556,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return false;
|
||||
}
|
||||
const SizeT shadowBytes = mipmapObject.GetMipmapByteSize(uploadTarget, level);
|
||||
const SizeT texelCount =
|
||||
static_cast<SizeT>(extent.x()) * static_cast<SizeT>(extent.y()) * static_cast<SizeT>(extent.z());
|
||||
auto* shadow = static_cast<Uint8*>(mipmapObject.MapMipmapData(uploadTarget, level));
|
||||
if (shadow == nullptr || snapshot.size() != shadowBytes || texelCount == 0 || shadowBytes % texelCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
// The rects index the SHADOW's texel grid (a 1D array's layers are its rows), which
|
||||
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;
|
||||
constexpr SizeT kMaxPending = MG_State::GLState::MipmapStorage::kMaxDirtyRects;
|
||||
MG_State::GLState::MipmapDirtyRegion pending[kMaxPending];
|
||||
SizeT pendingCount = mipmapObject.GetStorageDirtyRects(uploadTarget, level, pending, kMaxPending);
|
||||
if (pendingCount == 0) {
|
||||
pending[0] = dirtyRegion;
|
||||
pendingCount = 1;
|
||||
}
|
||||
for (SizeT i = 0; i < pendingCount; ++i) {
|
||||
const auto& box = pending[i];
|
||||
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) {
|
||||
@@ -8758,6 +8758,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
ApplyImageBindableStorageWidening(imageWidening, &glInternalFormat, &glFormat, &glType);
|
||||
const auto& uploadTargets = MGB_UPLOAD_TARGETS(textureMipmapObject);
|
||||
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 (!MGB_LEVEL_NEEDS_UPLOAD(textureMipmapObject, uploadTarget, level)) {
|
||||
@@ -8890,13 +8893,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
const SizeT texelCount = static_cast<SizeT>(texelSize.x()) *
|
||||
static_cast<SizeT>(texelSize.y()) *
|
||||
static_cast<SizeT>(std::max(texelSize.z(), 1));
|
||||
const Bool subRectEligible =
|
||||
!dirtyRegion.Empty() && !dirtyRegion.CoversWholeLevel(texelSize) && texelCount > 0 &&
|
||||
uploadBytes > 0 && uploadBytes % 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 SizeT bpp = subRectEligible ? uploadBytes / texelCount : 0;
|
||||
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()};
|
||||
@@ -9065,6 +9071,50 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
dirtyRectCount = 0;
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
if (pendingUpload != nullptr) {
|
||||
// The handle arm's footprint is the record's region list, all
|
||||
// of it and in order (MGB_RECT_SRC_PTR indexes the regions by
|
||||
// position); a box-only entry is its union box.
|
||||
dirtyFootprint.clear();
|
||||
for (const auto& region : pendingUpload->Regions) {
|
||||
dirtyFootprint.push_back(MG_State::GLState::MipmapDirtyRegion{
|
||||
IntVec3{region.X, region.Y, region.Z},
|
||||
IntVec3{region.X + static_cast<Int32>(region.W),
|
||||
region.Y + static_cast<Int32>(region.H),
|
||||
region.Z + static_cast<Int32>(region.D)}});
|
||||
}
|
||||
} else
|
||||
#endif
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
// With an active transport and no record behind the level, the
|
||||
// server's own whole-level GPU-dirty mark is the whole story (the
|
||||
// same reason the rect list above is not read here).
|
||||
if (MGB_STAGED_TEXTURE_LIVE) {
|
||||
dirtyFootprint.clear();
|
||||
} else
|
||||
#endif
|
||||
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 +
|
||||
@@ -9129,15 +9179,21 @@ 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] = {
|
||||
MGB_RECT_SRC_PTR(r, rect),
|
||||
static_cast<SizeT>(rect.hi.x() - rect.lo.x()) * bpp,
|
||||
@@ -9147,7 +9203,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
levelSliceBytes,
|
||||
0};
|
||||
}
|
||||
stagingBlockCount = dirtyRectCount;
|
||||
stagingBlockCount = uploadRectCount;
|
||||
} else if (subRectEligible) {
|
||||
stagingBlocks[0] = {regionPtr,
|
||||
static_cast<SizeT>(regionSize.x()) * bpp,
|
||||
@@ -9224,10 +9280,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
switch (MapToBackendTextureTarget(MGB_TEXTURE_TARGET(stateTextureObject))) {
|
||||
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()),
|
||||
@@ -9263,18 +9319,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, imageHeight);
|
||||
}
|
||||
for (SizeT r = 0; r < dirtyRectCount; ++r) {
|
||||
const auto box = ToBackendBox(dirtyRects[r]);
|
||||
for (SizeT r = 0; r < uploadRectCount; ++r) {
|
||||
const auto box = ToBackendBox(uploadRects[r]);
|
||||
g_GLESFuncs.glTexSubImage3D(
|
||||
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*>(MGB_RECT_SRC_PTR(r, dirtyRects[r])));
|
||||
: static_cast<const void*>(MGB_RECT_SRC_PTR(r, uploadRects[r])));
|
||||
}
|
||||
if (!ringStaged) {
|
||||
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
|
||||
@@ -10273,6 +10329,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
g_boundTexturesCache;
|
||||
TwinRegistry<MG_State::GLState::ITextureObject, BackendTextureObject, MG_Pipe::MGPipeKind::Texture> 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();
|
||||
}
|
||||
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// P5e (id), CONTRACT-P5E §4.1. See Managers.h for the contract.
|
||||
BackendTextureObject* ResolveTextureTwin(MG_Pipe::MGPipeHandle res) {
|
||||
@@ -10293,6 +10364,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
if (!*slot) *slot = MakeShared<BackendTextureObject>();
|
||||
return slot->get();
|
||||
}
|
||||
|
||||
void NoteDriverSideTextureWriteByHandle(MG_Pipe::MGPipeHandle res) {
|
||||
// The storage owner's handle, one hop per view like the frontend walk above (the
|
||||
// client composes a view of a view onto its root, so one hop is the real shape).
|
||||
MG_Pipe::MGPipeHandle storage = res;
|
||||
for (Uint hop = 0; hop < 4 && !MG_Pipe::MGPipeHandleIsNull(storage); ++hop) {
|
||||
const auto* record = PipeTextureRecordForHandle(storage);
|
||||
if (record == nullptr || MG_Pipe::MGPipeHandleIsNull(record->Desc.ViewOf)) break;
|
||||
storage = record->Desc.ViewOf;
|
||||
}
|
||||
if (auto* twin = ResolveTextureTwin(storage)) twin->NoteDriverSideWrite();
|
||||
}
|
||||
#endif
|
||||
} // namespace TextureImpl
|
||||
|
||||
@@ -10477,6 +10560,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(),
|
||||
@@ -10856,6 +10942,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
if (MG_Config::Transport != MG_Config::TransportMode::Monolith) {
|
||||
backendTextureObject->SyncMipmapsToBackendByHandle(surface.Res);
|
||||
// From here on every draw, clear and blit into this framebuffer can write the
|
||||
// texture behind the staged shadow's back (the frontend arm's note, by handle).
|
||||
TextureImpl::NoteDriverSideTextureWriteByHandle(surface.Res);
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
@@ -10872,6 +10961,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return false;
|
||||
}
|
||||
backendTextureObject->SyncMipmapsToBackend(monolithTexture);
|
||||
// From here on every draw, clear and blit into this framebuffer can write the
|
||||
// texture behind the shadow's back.
|
||||
TextureImpl::NoteDriverSideTextureWrite(monolithTexture);
|
||||
}
|
||||
const auto uploadTarget = static_cast<TextureUploadTarget>(surface.UploadTarget);
|
||||
if (surface.Layered != 0) {
|
||||
|
||||
@@ -1975,6 +1975,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;
|
||||
|
||||
@@ -2091,6 +2094,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
|
||||
@@ -2260,6 +2272,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);
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// ---- P5e SEAM (MG_Remote/CONTRACT-P5E.md §4.2, §5.2; declared by c0e, bodied by
|
||||
// id/tx2) --------------------------------------------------------------------------
|
||||
@@ -2276,6 +2293,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
SharedPtr<BackendTextureObject>& SyncTextureToBackendByHandle(
|
||||
MG_Pipe::MGPipeHandle texture, Bool imageBindableStorageRequired = false);
|
||||
BackendTextureObject* ResolveTextureTwin(MG_Pipe::MGPipeHandle texture);
|
||||
// NoteDriverSideTextureWrite's HANDLE ARM, for the same paths when the caller holds a
|
||||
// Texture handle (a framebuffer surface's Res, an image unit's Res, VerbMipRes,
|
||||
// VerbCopyTexDst, a copy-image endpoint): the twin the handle's record names is marked, or
|
||||
// its storage owner's (Desc.ViewOf) for a view. Nothing on the apply thread reads a
|
||||
// frontend object for it.
|
||||
void NoteDriverSideTextureWriteByHandle(MG_Pipe::MGPipeHandle texture);
|
||||
#endif
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
@@ -1762,4 +1762,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
g_requestedSwapInterval.reset();
|
||||
}
|
||||
#endif
|
||||
|
||||
DescriptorPoolCensus GetDescriptorPoolCensus() {
|
||||
return pVulkanRenderer ? pVulkanRenderer->GetDescriptorPoolCensus() : DescriptorPoolCensus{};
|
||||
}
|
||||
} // namespace MobileGL::MG_Backend::DirectVulkan
|
||||
|
||||
@@ -591,6 +591,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;
|
||||
|
||||
@@ -600,6 +605,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;
|
||||
@@ -654,6 +661,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
frame.activeDescriptorPoolIndex = 0;
|
||||
frame.allocatedSetsThisFrame = 0;
|
||||
frame.peakAllocatedSetsThisFrame = 0;
|
||||
frame.capacitySets = 0;
|
||||
frame.quietEpochs = 0;
|
||||
}
|
||||
m_frames.clear();
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
@@ -662,6 +671,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
m_wirePrivateStoragePlaceholders = 0;
|
||||
m_wireInvalidStorageImagesBindNull = false;
|
||||
#endif
|
||||
m_epochSlot = 0;
|
||||
m_epochOpen = false;
|
||||
|
||||
m_bufferManager = nullptr;
|
||||
m_programFactory = nullptr;
|
||||
@@ -701,6 +712,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;
|
||||
@@ -709,11 +734,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
|
||||
@@ -747,10 +768,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
entry.second.cursor = 0;
|
||||
}
|
||||
frame.allocatedSetsThisFrame = 0;
|
||||
frame.peakAllocatedSetsThisFrame = 0;
|
||||
for (auto& entry : m_descriptorReuseMemo) entry.valid = false;
|
||||
m_fastRebindMemo.valid = false;
|
||||
m_lastBindValid = false;
|
||||
// The PEAK is not reset: a rewind ends a window of the epoch, not the epoch. The trim scores
|
||||
// an epoch by the most sets the slot held at once in it (NoteEndedEpochForTrim at the next
|
||||
// BeginFrame), and on this arm that is the largest window - resetting here would score a
|
||||
// burst frame by its last window alone and trim a slot that re-grows every frame.
|
||||
InvalidateDescriptorSetMemos();
|
||||
const Uint32 touchedBindings =
|
||||
std::min<Uint32>(m_samplerResolveMemoHighWater, static_cast<Uint32>(m_samplerResolveMemo.size()));
|
||||
for (Uint32 binding = 0; binding < touchedBindings; ++binding) {
|
||||
@@ -761,6 +783,68 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
}
|
||||
#endif
|
||||
|
||||
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) {
|
||||
@@ -2831,32 +2915,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "VkSamplerManager.h"
|
||||
#include "VkTextureManager.h"
|
||||
#include "WirePlaceholderKey.h"
|
||||
#include "../DescriptorPoolCensus.h"
|
||||
#include "../VkIncludes.h"
|
||||
#include <Includes.h>
|
||||
|
||||
@@ -84,9 +85,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
|
||||
@@ -158,13 +161,46 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
static Bool ProgramSamplesOnlySingleLevelTextures(const MagmaProgramSource& 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
|
||||
@@ -239,6 +275,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
void DestroyWirePlaceholderImage(WirePlaceholderImage& image) const;
|
||||
#endif
|
||||
|
||||
// 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;
|
||||
@@ -252,6 +292,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 MagmaProgramSource& program,
|
||||
@@ -395,6 +439,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,
|
||||
@@ -412,6 +478,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;
|
||||
|
||||
@@ -4121,11 +4121,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -4137,6 +4136,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) {
|
||||
@@ -4198,8 +4198,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
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 &&
|
||||
levelBytes % 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(),
|
||||
@@ -4209,17 +4217,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
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();
|
||||
@@ -4479,6 +4480,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
|
||||
@@ -4486,7 +4488,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) {
|
||||
@@ -4517,7 +4519,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;
|
||||
|
||||
@@ -7083,6 +7083,10 @@ 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 - on the wire arm too: a
|
||||
// split server records every draw its client sends into the same frame command buffer, so
|
||||
// the same loading frame grows it the same way there.
|
||||
SplitOversizedRecording();
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
if (MG_Config::Transport != MG_Config::TransportMode::Monolith) {
|
||||
RewindWireDescriptorSetsIfDue();
|
||||
@@ -7718,6 +7722,8 @@ void main() {
|
||||
}
|
||||
|
||||
void VulkanRenderer::DispatchCompute(GLuint numGroupsX, GLuint numGroupsY, GLuint numGroupsZ) {
|
||||
// Before the wire branch, for SetupDraw's reason.
|
||||
SplitOversizedRecording();
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
if (MG_Config::Transport != MG_Config::TransportMode::Monolith) {
|
||||
RewindWireDescriptorSetsIfDue();
|
||||
@@ -7779,6 +7785,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.
|
||||
@@ -14198,6 +14205,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;
|
||||
@@ -14243,6 +14298,7 @@ void main() {
|
||||
}
|
||||
|
||||
void VulkanRenderer::OnFrameCommandRecordingBegan(VkCommandBuffer commandBuffer) {
|
||||
m_drawsInRecording = 0;
|
||||
// Dynamic state does not survive a command-buffer boundary.
|
||||
ResetDynamicStateShadow();
|
||||
InvalidateSetupDrawSnapshots();
|
||||
|
||||
@@ -327,6 +327,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);
|
||||
@@ -736,6 +740,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;
|
||||
@@ -747,6 +760,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;
|
||||
|
||||
@@ -158,6 +158,8 @@ add_executable(MobileGLIntegrationTest
|
||||
Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp
|
||||
Scenarios/RenderbufferBlendFormatScenario.cpp
|
||||
Scenarios/DualSourceBlendScenario.cpp
|
||||
Scenarios/OversizedRecordingSplitScenario.cpp
|
||||
Scenarios/DescriptorPoolGrowthScenario.cpp
|
||||
Scenarios/PipeVerifyArmingScenario.cpp
|
||||
Scenarios/PoisonOmissionScenario.cpp
|
||||
Scenarios/HandleRecycleScenario.cpp
|
||||
@@ -753,6 +755,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
|
||||
@@ -1052,6 +1060,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
|
||||
@@ -29,6 +29,7 @@
|
||||
// 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>
|
||||
@@ -248,6 +249,35 @@ void main() { o_color = texelFetch(u_tex, ivec2(gl_FragCoord.xy) % 4, 0); }
|
||||
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;
|
||||
@@ -334,6 +364,43 @@ void main() { o_color = texelFetch(u_tex, ivec2(gl_FragCoord.xy) % 4, 0); }
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
@@ -30,6 +30,15 @@
|
||||
// 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>
|
||||
@@ -377,6 +386,60 @@ void main() {
|
||||
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;
|
||||
@@ -503,5 +566,35 @@ void main() {
|
||||
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
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
#include "MipmapStorage.h"
|
||||
|
||||
#include <bit>
|
||||
#include <utility>
|
||||
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
#include <Config.h>
|
||||
#include <MG_Remote/Server/ServerLoop.h>
|
||||
@@ -104,6 +107,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 {
|
||||
@@ -130,6 +262,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);
|
||||
@@ -152,6 +285,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);
|
||||
|
||||
@@ -231,6 +367,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);
|
||||
@@ -302,6 +439,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 {
|
||||
@@ -332,11 +472,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];
|
||||
@@ -368,6 +514,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();
|
||||
@@ -380,6 +529,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
|
||||
@@ -436,6 +586,33 @@ 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 {
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
RefuseLegacyTextureArmFromApplyThread("GetStorageDirtyFootprint");
|
||||
#endif
|
||||
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");
|
||||
|
||||
@@ -582,6 +582,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,
|
||||
|
||||
@@ -325,6 +325,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
|
||||
@@ -407,6 +419,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;
|
||||
|
||||
@@ -96,6 +96,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;
|
||||
|
||||
@@ -334,6 +334,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;
|
||||
|
||||
@@ -3444,6 +3444,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
|
||||
|
||||
@@ -119,3 +119,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
|
||||
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user