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

|
||||
- minecraft-1.21.1-neoforge-create-indirect-in-world: captured from Minecraft 1.21.1 NeoForge with Create, Sodium,
|
||||
and Iris (no shader pack) in a world facing Create water wheels and a large cogwheel, with Flywheel's
|
||||
`flywheel:indirect` backend (compute-shader culling, glMultiDrawElementsIndirect, persistent-mapped staging).
|
||||

|
||||
- minecraft-1.21.1-neoforge-create-indirect-in-world-align1024: **not an original capture** - derived from the retired
|
||||
`minecraft-1.21.1-neoforge-create-indirect-in-world` capture (Minecraft 1.21.1 NeoForge with Create, Sodium, and
|
||||
Iris (no shader pack) in a world facing Create water wheels and a large cogwheel, with Flywheel's
|
||||
`flywheel:indirect` backend: compute-shader culling, glMultiDrawElementsIndirect, persistent-mapped staging). That
|
||||
capture came from a desktop driver whose `GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT` is 16, so Flywheel bound its
|
||||
scatter lists at 16-byte offsets, and a driver that requires more (Adreno 830: 64) rejects every one of those binds
|
||||
with `GL_INVALID_VALUE`. [`fixture_tools/realign_buffer_ranges.cpp`](fixture_tools/realign_buffer_ranges.cpp)
|
||||
`--align 1024` rewrote it: each of the 1,270 range binds off a 1024-byte boundary now binds a 1024-aligned slot of
|
||||
an added buffer that is filled with the same bytes right before the dispatch that reads them. The 3,812 inserted
|
||||
calls renumber the trace (target call 504631 -> 508443); the golden is the original one (the same frame), and the
|
||||
host replay matches it with the original's SSIM to 9 digits on both backends.
|
||||

|
||||
- minecraft-1.21.1-neoforge-create-instancing-in-world: same world and camera as the indirect case, with Flywheel's
|
||||
`flywheel:instancing` backend (texture-buffer instance data, glDrawElementsInstancedBaseVertex).
|
||||

|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
// realign_buffer_ranges: make a GL trace's indexed uniform / storage buffer range binds portable.
|
||||
//
|
||||
// An app sizes glBindBufferRange offsets by the GL_{UNIFORM,SHADER_STORAGE}_BUFFER_OFFSET_ALIGNMENT
|
||||
// of the driver it was captured on (Flywheel's staging scatter lists: 16 on desktop). A driver that
|
||||
// requires more rejects those binds with GL_INVALID_VALUE. This tool rewrites every such bind whose
|
||||
// offset is not a multiple of --align:
|
||||
// - the bind itself becomes glBindBuffer(target, buffer) (the generic binding it also set);
|
||||
// - right before the next draw / dispatch, the bytes the range holds at that point are uploaded into a
|
||||
// fresh --align-aligned slot of one relocation buffer, and that slot is bound at the same index.
|
||||
// The bytes come from the trace itself (buffer data / subdata and the persistent-map memcpy commits), so a
|
||||
// source buffer written any other way (GPU copies, clears) is refused rather than guessed.
|
||||
//
|
||||
// Inserting calls renumbers every later call: --map-call N prints where original call N landed (a case's
|
||||
// target_call). The output is snappy-compressed; `apitrace repack --brotli` it before packaging.
|
||||
//
|
||||
// Build (A = 3rdparty/apitrace, B = a Release build of it, e.g. `cmake -S $A -B $B -G Ninja -DENABLE_GUI=OFF
|
||||
// && ninja -C $B apitrace`):
|
||||
// g++ -std=c++17 -O2 -flto=auto -fno-rtti -fno-exceptions -I$A/lib/trace -I$A/lib/os -I$B -I$B/lib/trace \
|
||||
// realign_buffer_ranges.cpp -o realign_buffer_ranges $B/lib/trace/libcommon.a $B/lib/guids/libguids.a \
|
||||
// $B/lib/highlight/libhighlight.a $B/lib/os/libos.a $B/thirdparty/libbacktrace.a $B/thirdparty/libsnappy.a \
|
||||
// $B/thirdparty/libzstd_seekable.a -lbrotlidec -lbrotlienc -lz -lzstd -lpthread -ldl
|
||||
// Usage: realign_buffer_ranges [--align N] [--map-call N]... in.trace out.trace
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "trace_model.hpp"
|
||||
#include "trace_parser.hpp"
|
||||
#include "trace_writer.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr unsigned kUniformBuffer = 0x8A11;
|
||||
constexpr unsigned kShaderStorageBuffer = 0x90D2;
|
||||
constexpr unsigned kDynamicStorageBit = 0x0100;
|
||||
constexpr unsigned kRelocBufferName = 1000000;
|
||||
|
||||
[[noreturn]] void Fail(const trace::Call *call, const std::string &what) {
|
||||
std::fprintf(stderr, "error: call %u %s: %s\n", call ? call->no : 0u, call ? call->name() : "-", what.c_str());
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
bool Is(const trace::Call *call, const char *name) { return std::strcmp(call->name(), name) == 0; }
|
||||
|
||||
bool StartsWith(const char *s, const char *prefix) { return std::strncmp(s, prefix, std::strlen(prefix)) == 0; }
|
||||
|
||||
bool ReadsIndexedBuffers(const trace::Call *call) {
|
||||
const char *n = call->name();
|
||||
return StartsWith(n, "glDraw") || StartsWith(n, "glMultiDraw") || StartsWith(n, "glDispatchCompute");
|
||||
}
|
||||
|
||||
bool IsRangeTarget(unsigned target) { return target == kUniformBuffer || target == kShaderStorageBuffer; }
|
||||
|
||||
struct MapRegion {
|
||||
unsigned buffer;
|
||||
uint64_t offset;
|
||||
uint64_t length;
|
||||
uint64_t ptr;
|
||||
};
|
||||
|
||||
struct Reloc {
|
||||
unsigned src = 0;
|
||||
uint64_t offset = 0;
|
||||
uint64_t size = 0;
|
||||
bool dirty = true;
|
||||
std::vector<char> last;
|
||||
const trace::EnumSig *targetSig = nullptr;
|
||||
};
|
||||
|
||||
// Signatures for the calls this tool inserts; ids are allocated past the trace's own.
|
||||
struct Sigs {
|
||||
const char *createNames[2] = {"n", "buffers"};
|
||||
const char *storageNames[4] = {"buffer", "size", "data", "flags"};
|
||||
const char *subDataNames[4] = {"buffer", "offset", "size", "data"};
|
||||
const char *bindRangeNames[5] = {"target", "index", "buffer", "offset", "size"};
|
||||
const char *bindNames[2] = {"target", "buffer"};
|
||||
trace::FunctionSig create{}, storage{}, subData{}, bindRange{}, bind{};
|
||||
|
||||
void Init(trace::Id firstId) {
|
||||
create = {firstId + 0, "glCreateBuffers", 2, createNames};
|
||||
storage = {firstId + 1, "glNamedBufferStorage", 4, storageNames};
|
||||
subData = {firstId + 2, "glNamedBufferSubData", 4, subDataNames};
|
||||
bindRange = {firstId + 3, "glBindBufferRange", 5, bindRangeNames};
|
||||
bind = {firstId + 4, "glBindBuffer", 2, bindNames};
|
||||
}
|
||||
};
|
||||
|
||||
class Rewriter {
|
||||
public:
|
||||
uint64_t align = 1024;
|
||||
std::set<unsigned> mapCalls;
|
||||
|
||||
int Run(const char *in, const char *out) {
|
||||
Scan(in);
|
||||
if (relocSrc_.empty()) {
|
||||
std::fprintf(stderr, "nothing to realign at %llu bytes\n", (unsigned long long)align);
|
||||
return 2;
|
||||
}
|
||||
sigs_.Init(maxSigId_ + 1);
|
||||
stride_ = (maxRelocSize_ + align - 1) / align * align;
|
||||
Pass(in, nullptr); // dry run: count the slots
|
||||
slotCap_ = slotsUsed_;
|
||||
Pass(in, out);
|
||||
std::fprintf(stderr,
|
||||
"realigned %llu binds of %zu source buffer(s) to %llu bytes: %llu uploads, relocation buffer %llu "
|
||||
"bytes, %llu calls inserted\n",
|
||||
(unsigned long long)relocBinds_, relocSrc_.size(), (unsigned long long)align,
|
||||
(unsigned long long)slotsUsed_, (unsigned long long)(slotCap_ * stride_),
|
||||
(unsigned long long)inserted_);
|
||||
for (const auto &m : callMap_) {
|
||||
std::printf("call %u -> %llu\n", m.first, (unsigned long long)m.second);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private:
|
||||
// Pass 0: which buffers need relocating, and the sizes involved.
|
||||
void Scan(const char *in) {
|
||||
trace::Parser p;
|
||||
if (!p.open(in)) Fail(nullptr, std::string("cannot open ") + in);
|
||||
trace::Call *call;
|
||||
while ((call = p.parse_call())) {
|
||||
maxSigId_ = std::max(maxSigId_, call->sig->id);
|
||||
if (Is(call, "glBindBufferRange")) {
|
||||
unsigned target = unsigned(call->arg(0).toSInt());
|
||||
uint64_t offset = uint64_t(call->arg(3).toSInt());
|
||||
if (IsRangeTarget(target) && offset % align != 0) {
|
||||
relocSrc_.insert(unsigned(call->arg(2).toUInt()));
|
||||
maxRelocSize_ = std::max(maxRelocSize_, uint64_t(call->arg(4).toSInt()));
|
||||
}
|
||||
} else if (Is(call, "glBindBuffersRange")) {
|
||||
unsigned target = unsigned(call->arg(0).toSInt());
|
||||
const trace::Array *offsets = call->arg(4).toArray();
|
||||
if (IsRangeTarget(target) && offsets) {
|
||||
for (const trace::Value *v : offsets->values) {
|
||||
if (uint64_t(v->toSInt()) % align != 0) Fail(call, "misaligned glBindBuffersRange is not handled");
|
||||
}
|
||||
}
|
||||
}
|
||||
delete call;
|
||||
}
|
||||
}
|
||||
|
||||
void Pass(const char *in, const char *out) {
|
||||
trace::Parser p;
|
||||
if (!p.open(in)) Fail(nullptr, std::string("cannot open ") + in);
|
||||
trace::Writer writer;
|
||||
writer_ = nullptr;
|
||||
if (out) {
|
||||
if (!writer.open(out, p.getVersion(), p.getProperties())) Fail(nullptr, std::string("cannot create ") + out);
|
||||
writer_ = &writer;
|
||||
}
|
||||
shadow_.clear();
|
||||
maps_.clear();
|
||||
generic_.clear();
|
||||
relocs_.clear();
|
||||
slotsUsed_ = 0;
|
||||
relocBinds_ = 0;
|
||||
inserted_ = 0;
|
||||
created_ = false;
|
||||
|
||||
trace::Call *call;
|
||||
while ((call = p.parse_call())) {
|
||||
Process(call);
|
||||
delete call;
|
||||
}
|
||||
if (writer_) writer.close();
|
||||
writer_ = nullptr;
|
||||
}
|
||||
|
||||
unsigned BufferArg(trace::Call *call, unsigned index) { return unsigned(call->arg(index).toUInt()); }
|
||||
|
||||
unsigned Generic(unsigned target) {
|
||||
auto it = generic_.find(target);
|
||||
return it == generic_.end() ? 0 : it->second;
|
||||
}
|
||||
|
||||
std::vector<char> *Shadow(unsigned buffer) {
|
||||
auto it = shadow_.find(buffer);
|
||||
return it == shadow_.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
void Specify(trace::Call *call, unsigned buffer, uint64_t size, trace::Value *data) {
|
||||
if (!relocSrc_.count(buffer)) return;
|
||||
std::vector<char> &s = shadow_[buffer];
|
||||
s.assign(size, 0);
|
||||
const trace::Blob *blob = data ? data->toBlob() : nullptr;
|
||||
if (blob) std::memcpy(s.data(), blob->buf, std::min<uint64_t>(size, blob->size));
|
||||
for (auto &r : relocs_) {
|
||||
if (r.second.src == buffer) r.second.dirty = true;
|
||||
}
|
||||
(void)call;
|
||||
}
|
||||
|
||||
void Write(trace::Call *call, unsigned buffer, uint64_t offset, const char *bytes, uint64_t size) {
|
||||
std::vector<char> *s = Shadow(buffer);
|
||||
if (!s) return;
|
||||
if (offset + size > s->size()) Fail(call, "write past the end of a tracked buffer");
|
||||
std::memcpy(s->data() + offset, bytes, size);
|
||||
}
|
||||
|
||||
void Refuse(trace::Call *call, unsigned buffer) {
|
||||
if (relocSrc_.count(buffer)) Fail(call, "writes a relocated source buffer in a way the trace does not carry the bytes of");
|
||||
}
|
||||
|
||||
void Emit(trace::Call *call) {
|
||||
if (!writer_) return;
|
||||
if (mapCalls.count(call->no)) callMap_[call->no] = writtenCalls_;
|
||||
writer_->writeCall(call);
|
||||
++writtenCalls_;
|
||||
}
|
||||
|
||||
void EmitNew(trace::Call *call) {
|
||||
++inserted_;
|
||||
if (writer_) {
|
||||
writer_->writeCall(call);
|
||||
++writtenCalls_;
|
||||
}
|
||||
delete call;
|
||||
}
|
||||
|
||||
trace::Call *NewCall(const trace::FunctionSig *sig, unsigned thread) {
|
||||
return new trace::Call(sig, trace::CallFlags(0), thread);
|
||||
}
|
||||
|
||||
void EmitBind(unsigned thread, const trace::EnumSig *targetSig, unsigned target, unsigned buffer) {
|
||||
trace::Call *c = NewCall(&sigs_.bind, thread);
|
||||
c->args[0].value = targetSig ? static_cast<trace::Value *>(new trace::Enum(targetSig, target))
|
||||
: static_cast<trace::Value *>(new trace::SInt(target));
|
||||
c->args[1].value = new trace::UInt(buffer);
|
||||
EmitNew(c);
|
||||
}
|
||||
|
||||
void CreateRelocBuffer(unsigned thread) {
|
||||
trace::Call *c = NewCall(&sigs_.create, thread);
|
||||
c->args[0].value = new trace::SInt(1);
|
||||
trace::Array *names = new trace::Array(1);
|
||||
names->values[0] = new trace::UInt(kRelocBufferName);
|
||||
c->args[1].value = names;
|
||||
EmitNew(c);
|
||||
|
||||
c = NewCall(&sigs_.storage, thread);
|
||||
c->args[0].value = new trace::UInt(kRelocBufferName);
|
||||
c->args[1].value = new trace::SInt(int64_t(slotCap_ * stride_));
|
||||
c->args[2].value = new trace::Null();
|
||||
c->args[3].value = new trace::UInt(kDynamicStorageBit);
|
||||
EmitNew(c);
|
||||
created_ = true;
|
||||
}
|
||||
|
||||
void Upload(trace::Call *before, unsigned target, unsigned index, Reloc &r) {
|
||||
std::vector<char> *s = Shadow(r.src);
|
||||
if (!s) Fail(before, "a relocated range reads a buffer whose storage the trace never specified");
|
||||
if (r.offset + r.size > s->size()) Fail(before, "a relocated range runs past its buffer");
|
||||
std::vector<char> bytes(s->begin() + r.offset, s->begin() + r.offset + r.size);
|
||||
if (!r.dirty && bytes == r.last) return;
|
||||
|
||||
unsigned thread = before->thread_id;
|
||||
if (writer_ && slotsUsed_ >= slotCap_) Fail(before, "relocation slots exceeded the dry run");
|
||||
uint64_t slotOffset = slotsUsed_++ * stride_;
|
||||
if (!created_) CreateRelocBuffer(thread);
|
||||
|
||||
trace::Call *c = NewCall(&sigs_.subData, thread);
|
||||
c->args[0].value = new trace::UInt(kRelocBufferName);
|
||||
c->args[1].value = new trace::SInt(int64_t(slotOffset));
|
||||
c->args[2].value = new trace::SInt(int64_t(r.size));
|
||||
trace::Blob *blob = new trace::Blob(r.size);
|
||||
std::memcpy(blob->buf, bytes.data(), r.size);
|
||||
c->args[3].value = blob;
|
||||
EmitNew(c);
|
||||
|
||||
c = NewCall(&sigs_.bindRange, thread);
|
||||
c->args[0].value = r.targetSig ? static_cast<trace::Value *>(new trace::Enum(r.targetSig, target))
|
||||
: static_cast<trace::Value *>(new trace::SInt(target));
|
||||
c->args[1].value = new trace::UInt(index);
|
||||
c->args[2].value = new trace::UInt(kRelocBufferName);
|
||||
c->args[3].value = new trace::SInt(int64_t(slotOffset));
|
||||
c->args[4].value = new trace::SInt(int64_t(r.size));
|
||||
EmitNew(c);
|
||||
|
||||
// glBindBufferRange also moved the generic binding; put back the app's.
|
||||
EmitBind(thread, r.targetSig, target, Generic(target));
|
||||
|
||||
r.last = std::move(bytes);
|
||||
r.dirty = false;
|
||||
}
|
||||
|
||||
void Process(trace::Call *call) {
|
||||
const char *n = call->name();
|
||||
|
||||
if (Is(call, "glBindBuffer")) {
|
||||
generic_[unsigned(call->arg(0).toSInt())] = BufferArg(call, 1);
|
||||
} else if (Is(call, "glBindBufferBase")) {
|
||||
unsigned target = unsigned(call->arg(0).toSInt());
|
||||
generic_[target] = BufferArg(call, 2);
|
||||
relocs_.erase({target, unsigned(call->arg(1).toUInt())});
|
||||
} else if (Is(call, "glBindBufferRange")) {
|
||||
unsigned target = unsigned(call->arg(0).toSInt());
|
||||
unsigned index = unsigned(call->arg(1).toUInt());
|
||||
unsigned buffer = BufferArg(call, 2);
|
||||
uint64_t offset = uint64_t(call->arg(3).toSInt());
|
||||
generic_[target] = buffer;
|
||||
if (IsRangeTarget(target) && offset % align != 0) {
|
||||
Reloc r;
|
||||
r.src = buffer;
|
||||
r.offset = offset;
|
||||
r.size = uint64_t(call->arg(4).toSInt());
|
||||
trace::Value &t = call->arg(0);
|
||||
trace::Enum *e = nullptr;
|
||||
// Enum values print by name in dumps; keep the trace's own enum signature when it has one.
|
||||
struct EnumProbe : trace::Visitor {
|
||||
trace::Enum *found = nullptr;
|
||||
void visit(trace::Enum *node) override { found = node; }
|
||||
} probe;
|
||||
t.visit(probe);
|
||||
e = probe.found;
|
||||
r.targetSig = e ? e->sig : nullptr;
|
||||
relocs_[{target, index}] = std::move(r);
|
||||
++relocBinds_;
|
||||
if (mapCalls.count(call->no) && writer_) callMap_[call->no] = writtenCalls_;
|
||||
// Only the generic binding survives at this point; the indexed one is bound before its first read.
|
||||
EmitBind(call->thread_id, e ? e->sig : nullptr, target, buffer);
|
||||
--inserted_; // a replacement, not an insertion
|
||||
return;
|
||||
}
|
||||
relocs_.erase({target, index});
|
||||
} else if (Is(call, "glBindBuffersBase") || Is(call, "glBindBuffersRange")) {
|
||||
unsigned target = unsigned(call->arg(0).toSInt());
|
||||
unsigned first = unsigned(call->arg(1).toUInt());
|
||||
unsigned count = unsigned(call->arg(2).toSInt());
|
||||
for (unsigned i = first; i < first + count; ++i) relocs_.erase({target, i});
|
||||
} else if (Is(call, "glNamedBufferStorage") || Is(call, "glNamedBufferData")) {
|
||||
Specify(call, BufferArg(call, 0), uint64_t(call->arg(1).toSInt()), &call->arg(2));
|
||||
} else if (Is(call, "glBufferStorage") || Is(call, "glBufferData")) {
|
||||
Specify(call, Generic(unsigned(call->arg(0).toSInt())), uint64_t(call->arg(1).toSInt()), &call->arg(2));
|
||||
} else if (Is(call, "glNamedBufferSubData") || Is(call, "glBufferSubData")) {
|
||||
unsigned buffer = Is(call, "glNamedBufferSubData") ? BufferArg(call, 0) : Generic(unsigned(call->arg(0).toSInt()));
|
||||
if (relocSrc_.count(buffer)) {
|
||||
const trace::Blob *blob = call->arg(3).toBlob();
|
||||
if (!blob) Fail(call, "subdata without bytes");
|
||||
Write(call, buffer, uint64_t(call->arg(1).toSInt()), blob->buf,
|
||||
std::min<uint64_t>(uint64_t(call->arg(2).toSInt()), blob->size));
|
||||
}
|
||||
} else if (Is(call, "glCopyNamedBufferSubData")) {
|
||||
Refuse(call, BufferArg(call, 1));
|
||||
} else if (Is(call, "glCopyBufferSubData")) {
|
||||
Refuse(call, Generic(unsigned(call->arg(1).toSInt())));
|
||||
} else if (StartsWith(n, "glClearNamedBuffer")) {
|
||||
Refuse(call, BufferArg(call, 0));
|
||||
} else if (StartsWith(n, "glClearBuffer") && (StartsWith(n, "glClearBufferData") || StartsWith(n, "glClearBufferSubData"))) {
|
||||
Refuse(call, Generic(unsigned(call->arg(0).toSInt())));
|
||||
} else if (Is(call, "glMapNamedBufferRange") || Is(call, "glMapBufferRange")) {
|
||||
unsigned buffer = Is(call, "glMapNamedBufferRange") ? BufferArg(call, 0) : Generic(unsigned(call->arg(0).toSInt()));
|
||||
if (relocSrc_.count(buffer) && call->ret) {
|
||||
maps_.push_back({buffer, uint64_t(call->arg(1).toSInt()), uint64_t(call->arg(2).toSInt()),
|
||||
call->ret->toUIntPtr()});
|
||||
}
|
||||
} else if (Is(call, "glMapNamedBuffer") || Is(call, "glMapBuffer")) {
|
||||
unsigned buffer = Is(call, "glMapNamedBuffer") ? BufferArg(call, 0) : Generic(unsigned(call->arg(0).toSInt()));
|
||||
std::vector<char> *s = Shadow(buffer);
|
||||
if (s && call->ret) maps_.push_back({buffer, 0, s->size(), call->ret->toUIntPtr()});
|
||||
} else if (Is(call, "glUnmapNamedBuffer") || Is(call, "glUnmapBuffer")) {
|
||||
unsigned buffer = Is(call, "glUnmapNamedBuffer") ? BufferArg(call, 0) : Generic(unsigned(call->arg(0).toSInt()));
|
||||
maps_.erase(std::remove_if(maps_.begin(), maps_.end(), [&](const MapRegion &m) { return m.buffer == buffer; }),
|
||||
maps_.end());
|
||||
} else if (Is(call, "memcpy")) {
|
||||
uint64_t dest = call->arg(0).toUIntPtr();
|
||||
const trace::Blob *blob = call->arg(1).toBlob();
|
||||
uint64_t size = call->arg(2).toUInt();
|
||||
if (blob) {
|
||||
size = std::min<uint64_t>(size, blob->size);
|
||||
for (const MapRegion &m : maps_) {
|
||||
uint64_t lo = std::max(dest, m.ptr);
|
||||
uint64_t hi = std::min(dest + size, m.ptr + m.length);
|
||||
if (lo >= hi) continue;
|
||||
Write(call, m.buffer, m.offset + (lo - m.ptr), blob->buf + (lo - dest), hi - lo);
|
||||
}
|
||||
}
|
||||
} else if (ReadsIndexedBuffers(call)) {
|
||||
for (auto &r : relocs_) Upload(call, r.first.first, r.first.second, r.second);
|
||||
}
|
||||
|
||||
Emit(call);
|
||||
}
|
||||
|
||||
Sigs sigs_;
|
||||
trace::Id maxSigId_ = 0;
|
||||
std::set<unsigned> relocSrc_;
|
||||
uint64_t maxRelocSize_ = 0;
|
||||
uint64_t stride_ = 0;
|
||||
uint64_t slotCap_ = 0;
|
||||
uint64_t slotsUsed_ = 0;
|
||||
uint64_t relocBinds_ = 0;
|
||||
uint64_t inserted_ = 0;
|
||||
uint64_t writtenCalls_ = 0;
|
||||
bool created_ = false;
|
||||
trace::Writer *writer_ = nullptr;
|
||||
std::unordered_map<unsigned, std::vector<char>> shadow_;
|
||||
std::vector<MapRegion> maps_;
|
||||
std::unordered_map<unsigned, unsigned> generic_;
|
||||
std::map<std::pair<unsigned, unsigned>, Reloc> relocs_;
|
||||
std::map<unsigned, uint64_t> callMap_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
Rewriter rw;
|
||||
std::vector<const char *> files;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
if (!std::strcmp(argv[i], "--align") && i + 1 < argc) {
|
||||
rw.align = std::strtoull(argv[++i], nullptr, 0);
|
||||
} else if (!std::strcmp(argv[i], "--map-call") && i + 1 < argc) {
|
||||
rw.mapCalls.insert(unsigned(std::strtoul(argv[++i], nullptr, 0)));
|
||||
} else {
|
||||
files.push_back(argv[i]);
|
||||
}
|
||||
}
|
||||
if (files.size() != 2 || rw.align == 0) {
|
||||
std::fprintf(stderr, "usage: %s [--align N] [--map-call N]... in.trace out.trace\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
return rw.Run(files[0], files[1]);
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -260,10 +260,11 @@
|
||||
"target_call": 145353
|
||||
},
|
||||
{
|
||||
"name": "minecraft-1.21.1-neoforge-create-indirect-in-world",
|
||||
"trace_archive": "minecraft-1.21.1-neoforge-create-indirect-in-world.tgz",
|
||||
"golden": "minecraft-1.21.1-neoforge-create-indirect-in-world.0000504631.png",
|
||||
"target_call": 504631,
|
||||
"name": "minecraft-1.21.1-neoforge-create-indirect-in-world-align1024",
|
||||
"derived_from": "minecraft-1.21.1-neoforge-create-indirect-in-world (the original desktop capture, retired): NOT the same trace - its 16-byte-aligned uniform/storage buffer range binds were relocated to 1024-aligned slots by tools/trace_replay/fixture_tools/realign_buffer_ranges.cpp --align 1024, which inserts calls and renumbers them (original target_call 504631)",
|
||||
"trace_archive": "minecraft-1.21.1-neoforge-create-indirect-in-world-align1024.tgz",
|
||||
"golden": "minecraft-1.21.1-neoforge-create-indirect-in-world-align1024.0000508443.png",
|
||||
"target_call": 508443,
|
||||
"timeout_seconds": 1800,
|
||||
"coherent_as_flush": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user