mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
[Merge] (P11): dev@c60639db - the Espryt image-bindable re-mint reads GPU levels back on the monolith arm too, partial writes into converted or reshaped levels upload only their region on every arm, Magma orders an upload after the recording's writes and queued clears, and Magma's eglSwapInterval reaches the swapchain
This commit is contained in:
@@ -15169,9 +15169,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return true;
|
||||
}
|
||||
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
#include "WireTextureReadback.inc"
|
||||
#endif
|
||||
|
||||
static Bool IsLegacyNativeReadPixelsFormat(GLenum format) {
|
||||
return format == GL_RGBA || format == GL_RGBA_INTEGER || format == GL_RED || format == GL_RED_INTEGER ||
|
||||
|
||||
@@ -23,15 +23,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// Content uploads use scratch bindings, so draws and dispatches call this after
|
||||
// texture synchronization.
|
||||
void BindCurrentTextures();
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
Bool ReadTextureImageWire(const MG_Pipe::MGPReadbackInfo& info, Vector<Uint8>& bytes);
|
||||
// Native/server-only color level readback. Returns tightly packed, owned
|
||||
// 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 resolves identity/extent from its resource record and supplies
|
||||
// whether the SOURCE allocation already uses an image carrier.
|
||||
// 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);
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
Bool ReadTextureImageWire(const MG_Pipe::MGPReadbackInfo& info, Vector<Uint8>& bytes);
|
||||
// Whether the NATIVE texture currently holds `level` of `uploadTarget` at `logicalExtent`
|
||||
// (glGetTexLevelParameteriv). False for a level the driver never allocated - one defined after
|
||||
// the texture's last sync - so a re-mint does not read what is not there. A query the driver
|
||||
|
||||
@@ -6456,20 +6456,122 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Replaces one level's shadow bytes with what the driver texture holds for it, then copies
|
||||
// back the client writes the driver has not been sent yet (the level's pending rects, the
|
||||
// same boxes a plain sync would upload over the driver's copy). Returns false, leaving the
|
||||
// shadow untouched, when the driver's copy cannot stand in for the shadow: the client owns
|
||||
// every byte of the level, the level is stored compressed, the driver has no image at this
|
||||
// level and extent (defined after the last sync), or the readback cannot be served (a
|
||||
// multisample image, a format the driver will not attach to a framebuffer).
|
||||
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 Bool dirty = mipmapObject.IsStorageDirty(uploadTarget, level);
|
||||
const MG_State::GLState::MipmapDirtyRegion dirtyRegion =
|
||||
dirty ? mipmapObject.GetStorageDirtyRegion(uploadTarget, level)
|
||||
: MG_State::GLState::MipmapDirtyRegion{};
|
||||
if (dirty && dirtyRegion.CoversWholeLevel(extent)) return false;
|
||||
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);
|
||||
const SizeT texelCount =
|
||||
static_cast<SizeT>(extent.x()) * static_cast<SizeT>(extent.y()) * static_cast<SizeT>(extent.z());
|
||||
auto* shadow = static_cast<Uint8*>(mipmapObject.MapMipmapData(uploadTarget, level));
|
||||
if (shadow == nullptr || snapshot.size() != shadowBytes || texelCount == 0 || shadowBytes % texelCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
// The rects index the SHADOW's texel grid (a 1D array's layers are its rows), which
|
||||
// is also the snapshot's byte order.
|
||||
const SizeT bpp = shadowBytes / texelCount;
|
||||
const SizeT rowBytes = static_cast<SizeT>(extent.x()) * bpp;
|
||||
const SizeT sliceBytes = static_cast<SizeT>(extent.y()) * rowBytes;
|
||||
constexpr SizeT kMaxPending = MG_State::GLState::MipmapStorage::kMaxDirtyRects;
|
||||
MG_State::GLState::MipmapDirtyRegion pending[kMaxPending];
|
||||
SizeT pendingCount = mipmapObject.GetStorageDirtyRects(uploadTarget, level, pending, kMaxPending);
|
||||
if (pendingCount == 0) {
|
||||
pending[0] = dirtyRegion;
|
||||
pendingCount = 1;
|
||||
}
|
||||
for (SizeT i = 0; i < pendingCount; ++i) {
|
||||
const auto& box = pending[i];
|
||||
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;
|
||||
}
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// Whether this transition re-mints storage that ALREADY EXISTED on the backend (the
|
||||
// levels below are replayed from the client's shadow to fill the new carrier): what
|
||||
// `trp=` counts. A texture reaching here uninitialised is allocated image-bindable up
|
||||
// front and replays nothing.
|
||||
const Bool hadBackendStorage = m_isInitialized;
|
||||
#endif
|
||||
// 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.
|
||||
// Also what `trp=` counts on the push builds: a transition that re-mints storage that
|
||||
// ALREADY EXISTED on the backend. A texture reaching here uninitialised is allocated
|
||||
// image-bindable up front and replays nothing.
|
||||
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
|
||||
@@ -6481,6 +6583,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// 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. (This was the known monolith defect of
|
||||
// W2-REMINT.md §6, the half of ID-P9-8 left to dev; dev's 568090f0 fixed it and P11 M
|
||||
// merged it.)
|
||||
//
|
||||
// THIS IS THE MONOLITH ARM ONLY. Under a transport the re-mint is the server's own
|
||||
// (RequireImageBindableStorageByHandle: the GPU level read back, the staged store for
|
||||
// what the client uploaded), and nothing selects this function there - the image
|
||||
@@ -6489,10 +6601,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// (Fatal{RoleViolation, "texture-legacy-arm"}) first. P9 W2 retired the
|
||||
// `texture-remint-pull` marker this comment used to describe: no pull protocol exists
|
||||
// because no byte of a re-mint has to come from the client (notes/p9/W2-REMINT.md).
|
||||
//
|
||||
// KNOWN MONOLITH DEFECT, NOT FIXED HERE (W2-REMINT.md §6): replaying the client's shadow
|
||||
// loses whatever the GPU wrote into the level since the upload - a render target, a
|
||||
// clear, a generated mip - because the shadow never saw it. The split arm reads the GPU.
|
||||
// The two arms now answer the same way: both read the GPU's level back first.
|
||||
//
|
||||
// THE COUNT IS TAKEN WHERE A LEVEL IS ACTUALLY REPLAYED, not on entry (review N-4): a
|
||||
// glBindImageTexture on a texture whose storage is not yet defined reaches here, finds
|
||||
@@ -6537,6 +6646,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);
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
// THE COUNTER BEHIND ROADMAP OPEN QUESTION 2 (final review M-A): once per
|
||||
@@ -6736,7 +6852,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)
|
||||
@@ -8592,22 +8708,51 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Vector<Uint8> packedUploadData;
|
||||
uploadData = PreparePackedNormUpload(MGB_STORAGE_FORMAT(textureMipmapObject), 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(MGB_TEXTURE_TARGET(stateTextureObject), 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.
|
||||
//
|
||||
// THE SAME ON EVERY ARM (P11 M). `mipData` is the frontend shadow on
|
||||
// the pull and push-monolith arms and the server's staged level on the
|
||||
// split arms - the same tight client layout either way - and the
|
||||
// conversions run on whichever side uploads, so the handle arm takes
|
||||
// its region from the converted buffer exactly as the shadow arm
|
||||
// does. What it must NOT take from the record then is the carried
|
||||
// pitch and offset (below): those index the client's layout, not the
|
||||
// converted one.
|
||||
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. (The
|
||||
// target is the descriptor's on the split arms: the server may not
|
||||
// name the client's texture object.)
|
||||
const Bool layersAsRows =
|
||||
MGB_TEXTURE_TARGET(stateTextureObject) == TextureTarget::Texture1DArray;
|
||||
// P4a (D-D6, and ARCHITECTURE.md:321's ONE item moved out of the
|
||||
// Espryt do-not-touch list): the DECISION below is unchanged and
|
||||
// stays on the server - it is the side that pays the GPU cost, and
|
||||
@@ -8659,12 +8804,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
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;
|
||||
!dirtyRegion.Empty() && !dirtyRegion.CoversWholeLevel(texelSize) && texelCount > 0 &&
|
||||
uploadBytes > 0 && uploadBytes % texelCount == 0 &&
|
||||
(layersAsRows ? std::max(texelSize.z(), 1) == 1
|
||||
: uploadSize.x() == texelSize.x() && uploadSize.y() == texelSize.y() &&
|
||||
std::max(uploadSize.z(), 1) == std::max(texelSize.z(), 1));
|
||||
const SizeT bpp = subRectEligible ? uploadBytes / texelCount : 0;
|
||||
const IntVec3 regionSize = {dirtyRegion.hi.x() - dirtyRegion.lo.x(),
|
||||
dirtyRegion.hi.y() - dirtyRegion.lo.y(),
|
||||
dirtyRegion.hi.z() - dirtyRegion.lo.z()};
|
||||
@@ -8704,14 +8849,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// P11 M: THE CARRIED PITCH AND OFFSET INDEX THE CLIENT'S LAYOUT - the
|
||||
// level shadow, or the server's staged copy of it, which is `mipData`.
|
||||
// A converted level's region is cut from the conversion's own tight
|
||||
// buffer (above), whose texels are wider, so there the record's numbers
|
||||
// name the wrong bytes and the pitch is derived from the upload
|
||||
// buffer's bpp instead - which is what the shadow arm does.
|
||||
const Bool carriedLayoutApplies = uploadData == mipData;
|
||||
const SizeT carriedRowBytes =
|
||||
(pendingUpload != nullptr && pendingStridesAgree &&
|
||||
(pendingUpload != nullptr && pendingStridesAgree && carriedLayoutApplies &&
|
||||
!pendingUpload->Regions.empty() &&
|
||||
pendingUpload->Regions.front().SrcRowStride != 0)
|
||||
? static_cast<SizeT>(pendingUpload->Regions.front().SrcRowStride)
|
||||
: 0;
|
||||
const SizeT carriedSliceBytes =
|
||||
(pendingUpload != nullptr && pendingStridesAgree &&
|
||||
(pendingUpload != nullptr && pendingStridesAgree && carriedLayoutApplies &&
|
||||
!pendingUpload->Regions.empty() &&
|
||||
pendingUpload->Regions.front().SrcSliceStride != 0)
|
||||
? static_cast<SizeT>(pendingUpload->Regions.front().SrcSliceStride)
|
||||
@@ -8726,13 +8878,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// refusal in the commit that started reading them while the offset -
|
||||
// which decides the ADDRESS glTexSubImage reads (w*bpp x h x d) bytes
|
||||
// from - was consumed raw. The cross-check is free, because
|
||||
// rectShadowPtr four lines down computes exactly the same number from
|
||||
// rectUploadPtr four lines down computes exactly the same number from
|
||||
// the region origin and the two pitches. A record whose regions
|
||||
// disagree with their own origins is corrupt, so on disagreement the
|
||||
// carried offsets are DROPPED - every use site falls back to the
|
||||
// derived pointer, which is what the legacy arm sends - and one named
|
||||
// line says so. Gated on subRectEligible because bpp is 0 otherwise
|
||||
// and the regions are not read at all.
|
||||
// and the regions are not read at all, and on carriedLayoutApplies
|
||||
// because a converted level never reads them (MGB_RECT_SRC_PTR).
|
||||
//
|
||||
// FIX A2 IS THE SECOND LEGITIMATE DISAGREEMENT, and the fallback is
|
||||
// exactly right for it: a level too large to stage whole crosses as
|
||||
@@ -8743,7 +8896,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// which is what the derived pointer names - and those piece-relative
|
||||
// offsets are precisely the ones that must not be used here.
|
||||
Bool pendingOffsetsAgree = true;
|
||||
if (pendingUpload != nullptr && subRectEligible) {
|
||||
if (pendingUpload != nullptr && subRectEligible && carriedLayoutApplies) {
|
||||
for (const auto& region : pendingUpload->Regions) {
|
||||
const SizeT derivedOffset = static_cast<SizeT>(region.Z) * levelSliceBytes +
|
||||
static_cast<SizeT>(region.Y) * levelRowBytes +
|
||||
@@ -8778,7 +8931,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.
|
||||
@@ -8825,7 +8978,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
dirtyRectCount = 0;
|
||||
}
|
||||
}
|
||||
const auto rectShadowPtr = [&](const MG_State::GLState::MipmapDirtyRegion& rect) {
|
||||
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 +
|
||||
@@ -8834,9 +8987,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// m-1 / D-D3, "carried, never inferred": MGPSubRegion::SrcOffset is the byte offset of that
|
||||
// region's origin into the level shadow - the same number the lambda above derives from the
|
||||
// origin and the two strides - and on the handle arm it is READ rather than re-derived, after
|
||||
// the cross-check above has agreed that the two spellings say the same thing (N-5). Every site
|
||||
// that uses it is inside `subRectEligible`, which requires uploadData == mipData, so the base
|
||||
// the offset is relative to is the level shadow itself and never a conversion buffer.
|
||||
// the cross-check above has agreed that the two spellings say the same thing (N-5). It is read
|
||||
// only while carriedLayoutApplies (uploadData == mipData), so the base the offset is relative to
|
||||
// is the level shadow itself and never a conversion buffer: since dev's 8966d5c2 a converted
|
||||
// level is sub-rect eligible too, and its rects are cut at the conversion's bpp (rectUploadPtr).
|
||||
//
|
||||
// THREE USE SITES, AND THE FIRST OF THEM IS UNREACHABLE (N-5's second half, and the correction
|
||||
// to what esprytobj-v2.md m-1 claimed): dirtyRectCount is forced to 0 whenever
|
||||
@@ -8851,14 +9005,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// pre-P4a call with one pair of parentheses. #undef'd with the rest.
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#define MGB_RECT_SRC_PTR(idx, rect) \
|
||||
((pendingUpload != nullptr && pendingOffsetsAgree && \
|
||||
((pendingUpload != nullptr && pendingOffsetsAgree && carriedLayoutApplies && \
|
||||
static_cast<SizeT>(idx) < pendingUpload->Regions.size()) \
|
||||
? static_cast<const Uint8*>(uploadData) + \
|
||||
static_cast<SizeT>(pendingUpload->Regions[static_cast<SizeT>(idx)].SrcOffset) \
|
||||
: rectShadowPtr(rect))
|
||||
: rectUploadPtr(rect))
|
||||
#else
|
||||
#define MGB_RECT_SRC_PTR(idx, rect) (rectShadowPtr(rect))
|
||||
#define MGB_RECT_SRC_PTR(idx, rect) (rectUploadPtr(rect))
|
||||
#endif
|
||||
// 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
|
||||
@@ -9010,19 +9179,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
if (subRectEligible && dirtyRectCount >= 2) {
|
||||
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];
|
||||
const auto box = ToBackendBox(dirtyRects[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*>(MGB_RECT_SRC_PTR(r, rect)));
|
||||
: static_cast<const void*>(MGB_RECT_SRC_PTR(r, dirtyRects[r])));
|
||||
}
|
||||
if (!ringStaged) {
|
||||
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
|
||||
@@ -9031,14 +9196,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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Included in DirectGLES.cpp after the native packed-word readback helper.
|
||||
// The server needs owned tight rows for a storage promotion, not client PACK
|
||||
// layout or a fallback into a frontend texture's potentially stale shadow.
|
||||
// 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)
|
||||
@@ -280,6 +281,7 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
// P9 W2-a. The store's Defined-ness follows the client's respecifies; the driver's follows the
|
||||
// twin's last sync. A re-mint asks this before reading a level back, because a level defined
|
||||
// after that sync (null data, or null data plus a partial upload) is not on the driver yet and
|
||||
@@ -345,3 +347,4 @@
|
||||
bytes = Move(result);
|
||||
return true;
|
||||
}
|
||||
#endif // MOBILEGL_BUILD_DISAGGREGATED
|
||||
|
||||
@@ -363,7 +363,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;
|
||||
@@ -486,6 +488,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);
|
||||
@@ -727,6 +735,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;
|
||||
|
||||
|
||||
@@ -1741,4 +1741,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
MG_Util::PipeStats::OnPresent();
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
Optional<Int> g_requestedSwapInterval;
|
||||
} // namespace
|
||||
|
||||
void SetSwapInterval(Int interval) {
|
||||
g_requestedSwapInterval = interval;
|
||||
if (pVulkanRenderer) {
|
||||
pVulkanRenderer->SetSwapInterval(interval);
|
||||
}
|
||||
}
|
||||
|
||||
Optional<Int> GetRequestedSwapInterval() {
|
||||
return g_requestedSwapInterval;
|
||||
}
|
||||
} // namespace MobileGL::MG_Backend::DirectVulkan
|
||||
|
||||
@@ -147,4 +147,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"
|
||||
|
||||
@@ -140,12 +142,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,9 +169,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");
|
||||
@@ -174,8 +194,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{};
|
||||
|
||||
@@ -1333,6 +1333,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));
|
||||
@@ -1361,7 +1397,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;
|
||||
@@ -1475,8 +1511,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;
|
||||
}
|
||||
|
||||
@@ -4077,9 +4114,9 @@ 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};
|
||||
@@ -4142,19 +4179,32 @@ 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) {
|
||||
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()) *
|
||||
@@ -4195,15 +4245,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();
|
||||
|
||||
@@ -332,6 +332,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;
|
||||
@@ -381,6 +386,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);
|
||||
@@ -623,6 +629,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
|
||||
|
||||
@@ -2003,6 +2003,9 @@ void main() {
|
||||
outBinding.label, texture->GetExternalIndex());
|
||||
return false;
|
||||
}
|
||||
if (!isReadFramebuffer) {
|
||||
textureManager.StampResourceRecordingWrite(*resource);
|
||||
}
|
||||
|
||||
outBinding.image = resource->image;
|
||||
outBinding.trackedLayout = &resource->layout;
|
||||
@@ -2119,6 +2122,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;
|
||||
@@ -2159,6 +2165,7 @@ void main() {
|
||||
texture.GetExternalIndex(), mipLevel, resource->mipLevels);
|
||||
return false;
|
||||
}
|
||||
textureManager.StampResourceRecordingWrite(*resource);
|
||||
|
||||
outBinding.image = resource->image;
|
||||
outBinding.trackedLayout = &resource->layout;
|
||||
@@ -3175,7 +3182,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();
|
||||
}
|
||||
|
||||
@@ -7381,6 +7391,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);
|
||||
@@ -8203,6 +8222,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);
|
||||
}
|
||||
@@ -8214,6 +8234,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);
|
||||
}
|
||||
@@ -8291,6 +8324,7 @@ void main() {
|
||||
if (!attachment.IsTexture()) {
|
||||
return;
|
||||
}
|
||||
SettleTexelsBeforeQueuedClear(framebuffer, attachment);
|
||||
m_clearManager->QueueClear(payload, attachment);
|
||||
};
|
||||
|
||||
@@ -8657,7 +8691,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;
|
||||
@@ -8669,8 +8704,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.
|
||||
@@ -8822,6 +8858,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",
|
||||
@@ -8829,6 +8868,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,
|
||||
@@ -10782,6 +10877,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;
|
||||
@@ -11066,6 +11162,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();
|
||||
}
|
||||
@@ -11791,6 +11894,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
|
||||
@@ -12136,6 +12242,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(
|
||||
@@ -14406,8 +14513,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;
|
||||
@@ -16085,7 +16206,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).
|
||||
@@ -16541,6 +16667,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;
|
||||
}
|
||||
|
||||
@@ -390,6 +390,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.
|
||||
@@ -760,6 +766,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
|
||||
@@ -1861,8 +1872,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;
|
||||
|
||||
@@ -114,6 +114,8 @@ add_executable(MobileGLIntegrationTest
|
||||
Scenarios/NonCoreImageFormatScenario.cpp
|
||||
Scenarios/ImageSizeAfterRespecScenario.cpp
|
||||
Scenarios/EmptyTextureLevelScenario.cpp
|
||||
Scenarios/ImageBindableRemintScenario.cpp
|
||||
Scenarios/PartialUploadOverGpuContentScenario.cpp
|
||||
Scenarios/SsboDeclarationFormScenario.cpp
|
||||
Scenarios/Glsl420DeclarationScenario.cpp
|
||||
Scenarios/IoBlockNameCollisionScenario.cpp
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
// 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 <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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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,428 @@
|
||||
// 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.
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
} // namespace
|
||||
} // namespace MGITest
|
||||
@@ -91,6 +91,7 @@ add_subdirectory(Pipe)
|
||||
# The P0.5 interface-purity gate: a python walk of #include lines, so it registers with no
|
||||
# MobileGL build, no GL context and no submodules (scripts/check_include_closure.py).
|
||||
add_subdirectory(Purity)
|
||||
add_subdirectory(Swapchain)
|
||||
add_subdirectory(ShaderTranspiler)
|
||||
add_subdirectory(Util)
|
||||
add_subdirectory(SelfTest)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user