[Feat, Test] (Pipe): wire the framebuffer subsystem bit and pin the resolved read surface, the sticky bind mask, the level-shadow strides and the union-box/region-list invariant

This commit is contained in:
2026-09-08 16:52:11 -04:00
parent b5f4660bcb
commit fcfdc41380
5 changed files with 1021 additions and 34 deletions
+486 -2
View File
@@ -57,8 +57,15 @@
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SamplerEmit.h>
#include <MG_Impl/Pipe/TextureEmit.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
#include <MG_State/GLState/RenderbufferState/RenderbufferObject.h>
#include <MG_State/GLState/TextureState/TextureObject2D.h>
#include <MG_State/GLState/TextureState/TextureObjectView.h>
#include <algorithm>
#endif
using namespace MobileGL;
@@ -175,7 +182,6 @@ TEST(TextureEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
#endif
}
// =========================================================================================
// The APPLIER's half of the texture family (the wire commits'): set_texture_params on the
// texture's own record, and the sub-data validator plus the pending-upload set that replaces
// the frontend dirty flags the client clears at emission. The emitter's half - the descriptor
@@ -795,7 +801,485 @@ TEST(TextureEmit, TheCreateAndRespecifyCallsAnswerWhetherTheRecordWasAccepted) {
#endif
}
int main(int argc, char** argv) {
=======
// ============================================================================
// P4a package B's EMITTER-SIDE cases. The contract commit landed the file and its ctest
// registration and one shape pin; the wire package's applier-side cases and these are disjoint
// TEST bodies in one file, and a collision between them is resolved by UNION, never by
// choosing a side.
// ============================================================================
#if !MOBILEGL_PIPE_PUSH
#define MGL_TEXTURE_EMIT_CLIENT_TEST_LIST(X) \
X(TextureEmit, EveryTextureTargetMapsToItsOwnResourceTarget) \
X(TextureEmit, EveryBindKindSetsItsBindMaskBit) \
X(TextureEmit, ABindMaskBitIsStickyAcrossARespecify) \
X(TextureEmit, AnImageBoundTextureCarriesTheImageBindableHintForever) \
X(TextureEmit, TheUnionBoxAndTheRegionListDescribeTheSameTexels) \
X(TextureEmit, AScatteredUploadCarriesTheLevelShadowsStridesAndNotZero) \
X(TextureEmit, AWholeLevelUploadCarriesZeroStrides) \
X(TextureEmit, MoreThanKMaxDirtyRectsCollapsesToTheBoxWithRegionCountZero) \
X(TextureEmit, AnUploadThroughAViewKeysOnTheStorageOwner) \
X(TextureEmit, EveryTexturesParamsNameItsBuiltinSamplerCso) \
X(TextureEmit, TwoTexturesWithIdenticalSamplingShareOneBuiltinCso) \
X(TextureEmit, ADestroyedTextureReleasesItsResourceViewAndBuiltinSamplerSlots) \
X(TextureEmit, ARenderbufferRespecifyPublishesItsExtentWithoutAVersionCounter) \
X(TextureEmit, ABailedLevelStaysDirtyAndStaysOnTheDrainList)
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
MGL_TEXTURE_EMIT_CLIENT_TEST_LIST(MGL_DECLARE_PULL_SKIP)
#undef MGL_DECLARE_PULL_SKIP
#else
namespace {
using GLContext = MG_State::GLState::GLContext;
using MG_State::GLState::MipmapDirtyRegion;
using MG_State::GLState::MipmapInput;
using MG_State::GLState::MipmapStorage;
using MG_State::GLState::RenderbufferObject;
using MG_State::GLState::TextureObject2D;
using MG_State::GLState::TextureObjectView;
// AN RAII SCOPE RATHER THAN A gtest FIXTURE, for VertexInputEmitTest's reason: both gates
// grep `ctest -R 'TextureEmit\.'`, a TEST_F files its cases under the FIXTURE's name, and
// gtest refuses to mix TEST and TEST_F under one suite name - so a fixture would rename
// every case out of the gate's reach.
//
// It ARMS THE SUBSYSTEM BIT, which a unit binary otherwise has cleared:
// MG_Config::Features.PipePush defaults to 0 and only ConfigLoader ever sets the phase mask,
// so without this every emission below would be correctly skipped and every assertion would
// be green for the wrong reason.
struct TextureScope {
TextureScope() {
m_previousPush = MG_Config::Features.PipePush;
MG_Config::Features.PipePush |= kMGPipeSubsystemTextureResources;
m_previousContext = Move(MG_State::pGLContext);
MG_State::pGLContext = MakeUnique<GLContext>();
MGPipeTextureEmitterInstance().ResetForTest();
// ARMED EXPLICITLY. kMGPipeWiredTextureSubsystem is still 0 on this base - P3a's four
// resource_* apply bodies index ONE slot space and validate every record as a buffer
// write, so a texture record there is a dropped buffer write or a
// Fatal{ProtocolCorruption}, and the flip is the wire package's w1 to unblock. The
// conversion itself is finished, so it is gated HERE: the emitter is armed, a frontend
// mutation is driven, and the assertions read the record the emitter BUILT rather than
// any applier state.
MGPipeTextureEmitterInstance().ArmForTest(true);
}
~TextureScope() {
MGPipeTextureEmitterInstance().ResetForTest();
MG_State::pGLContext.reset();
MG_State::pGLContext = Move(m_previousContext);
MG_Config::Features.PipePush = m_previousPush;
}
TextureScope(const TextureScope&) = delete;
TextureScope& operator=(const TextureScope&) = delete;
UniquePtr<GLContext> m_previousContext;
Uint64 m_previousPush = 0;
};
MGPipeTextureEmitter& Textures() { return MGPipeTextureEmitterInstance(); }
GLContext& Ctx() { return *MG_State::pGLContext; }
// A 2D texture with `levels` levels, each RGBA8 and each half the previous one, so the
// bytes-per-texel the emitter derives from (byteSize / texelCount) is exactly 4 and every
// stride assertion below is an exact number rather than a range.
SharedPtr<TextureObject2D> MakeTexture2D(Uint name, Int size, Uint levels = 1) {
auto texture = MakeShared<TextureObject2D>(name);
texture->SetInternalFormat(TextureInternalFormat::RGBA8);
for (Uint level = 0; level < levels; ++level) {
const Int extent = std::max<Int>(size >> level, 1);
texture->AllocateStorage(TextureUploadTarget::Texture2D, level,
MipmapInput{IntVec3{extent, extent, 1},
static_cast<SizeT>(extent) * static_cast<SizeT>(extent) * 4});
}
return texture;
}
} // namespace
// ============================ D-A3 ============================
//
// The exhaustiveness the contract's static_assert already pins, walked at RUNTIME over every
// enumerator - because the assert answers "is every target mapped" and this answers the
// stronger "does every target map to its OWN row". Folding rectangle onto 2D is the one
// collapse anybody would be tempted by, and it is a distinction the frontend keeps and both
// backends switch on.
TEST(TextureEmit, EveryTextureTargetMapsToItsOwnResourceTarget) {
Vector<Uint32> seen;
for (Int i = 0; i < static_cast<Int>(TextureTarget::TextureTargetCount); ++i) {
const auto target = static_cast<TextureTarget>(i);
const Uint32 resourceTarget = MGPipeResourceTargetForTextureTarget(target);
EXPECT_NE(resourceTarget, kMGPipeResourceTargetUnmapped)
<< "TextureTarget " << i << " has no MGPResourceDesc::Target row";
EXPECT_NE(resourceTarget, static_cast<Uint32>(MGPipeResourceTarget::Buffer))
<< "TextureTarget " << i << " maps onto the BUFFER row, which the ack predicate reads";
EXPECT_NE(resourceTarget, static_cast<Uint32>(MGPipeResourceTarget::Renderbuffer))
<< "TextureTarget " << i << " maps onto the RENDERBUFFER row";
for (const Uint32 previous : seen) {
EXPECT_NE(previous, resourceTarget)
<< "TextureTarget " << i << " shares its resource target with an earlier one";
}
seen.push_back(resourceTarget);
}
EXPECT_EQ(seen.size(), static_cast<SizeT>(TextureTarget::TextureTargetCount));
}
// ============================ D-A4 ============================
TEST(TextureEmit, EveryBindKindSetsItsBindMaskBit) {
TextureScope scope;
const auto texture = MakeTexture2D(1, 8);
const MGPipeHandle handle = Textures().FindTexture(*texture);
ASSERT_FALSE(MGPipeHandleIsNull(handle));
const Uint16 bits[] = {kMGPipeBindSampler, kMGPipeBindShaderImage, kMGPipeBindRenderTarget,
kMGPipeBindDepthStencil};
Uint16 expected = 0;
for (const Uint16 bit : bits) {
Textures().NoteTextureBoundAs(handle, bit);
expected = static_cast<Uint16>(expected | bit);
EXPECT_EQ(Textures().TextureBindMask(handle), expected) << "bind bit " << bit;
}
// ORed, never cleared: re-noting one bit cannot drop the others.
Textures().NoteTextureBoundAs(handle, kMGPipeBindSampler);
EXPECT_EQ(Textures().TextureBindMask(handle), expected);
}
TEST(TextureEmit, ABindMaskBitIsStickyAcrossARespecify) {
TextureScope scope;
const auto texture = MakeTexture2D(2, 8);
const MGPipeHandle handle = Textures().FindTexture(*texture);
Textures().NoteTextureBoundAs(handle, kMGPipeBindRenderTarget);
// A storage definition REPUBLISHES the mask; it does not rebuild it.
texture->AllocateStorage(TextureUploadTarget::Texture2D, 0,
MipmapInput{IntVec3{16, 16, 1}, 16 * 16 * 4});
EXPECT_TRUE(Textures().LastDesc().Resource == handle);
EXPECT_NE(Textures().LastDesc().BindMask & kMGPipeBindRenderTarget, 0)
<< "MGPResourceDesc::BindMask lost the RENDER_TARGET bit across a respecify";
EXPECT_EQ(Textures().LastDesc().Width, 16u);
}
TEST(TextureEmit, AnImageBoundTextureCarriesTheImageBindableHintForever) {
TextureScope scope;
const auto texture = MakeTexture2D(3, 8);
const MGPipeHandle handle = Textures().FindTexture(*texture);
EXPECT_EQ(Textures().LastDesc().ImageBindableHint, 0);
Textures().NoteTextureBoundAs(handle, kMGPipeBindShaderImage);
// The next respecify carries the hint - and it is the PREVENTION half of the texture-remint
// stall class, so it must never go back to 0 afterwards.
texture->AllocateStorage(TextureUploadTarget::Texture2D, 1, MipmapInput{IntVec3{4, 4, 1}, 4 * 4 * 4});
EXPECT_EQ(Textures().LastDesc().ImageBindableHint, 1);
texture->AllocateStorage(TextureUploadTarget::Texture2D, 2, MipmapInput{IntVec3{2, 2, 1}, 2 * 2 * 4});
EXPECT_EQ(Textures().LastDesc().ImageBindableHint, 1);
// And the transition armed the ONE resync the client is allowed to ask for (D-E2): the
// widened-channel carrier needs a swizzle override the frontend params version never moves
// for. It is one-shot - the server clears its own copy, the client never clears a server
// flag, and the client must not keep asking.
texture->SetSwizzleParam(TextureSwizzleParam::Red, TextureSwizzleParam::Blue);
EXPECT_EQ(Textures().LastParams().ForceResync, 1);
texture->SetSwizzleParam(TextureSwizzleParam::Green, TextureSwizzleParam::Blue);
EXPECT_EQ(Textures().LastParams().ForceResync, 0);
}
// ============================ D-D3 / D-D6 ============================
//
// THE INVARIANT THAT MAKES THE SERVER'S CHOICE SAFE, and nothing else in the tree can see it:
// SSIM is completely blind to whether the server uploaded one union box or N rects, and the
// Mali cliff behind that choice is ~+6 ms/frame for a hundred one-rect jobs against one box. So
// the two representations have to describe the SAME texels - every rect inside the box, and
// their union exactly the box.
TEST(TextureEmit, TheUnionBoxAndTheRegionListDescribeTheSameTexels) {
TextureScope scope;
const auto texture = MakeTexture2D(4, 256);
texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0}, IntVec3{1, 1, 1});
texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{255, 255, 0},
IntVec3{1, 1, 1});
ASSERT_EQ(Textures().DrainListSize(), 1u);
Textures().DrainTextureSubData(Ctx());
const MGPSubData record = Textures().LastSubData();
const Vector<MGPSubRegion> regions = Textures().LastRegions();
ASSERT_EQ(record.RegionCount, regions.size());
ASSERT_GE(record.RegionCount, 2u) << "two far-apart one-texel writes must survive as two rects";
Int32 loX = record.UnionBox.X + static_cast<Int32>(record.UnionBox.W);
Int32 loY = record.UnionBox.Y + static_cast<Int32>(record.UnionBox.H);
Int32 hiX = record.UnionBox.X;
Int32 hiY = record.UnionBox.Y;
for (const MGPSubRegion& region : regions) {
EXPECT_GE(region.X, record.UnionBox.X) << "a rect starts left of the union box";
EXPECT_GE(region.Y, record.UnionBox.Y) << "a rect starts above the union box";
EXPECT_LE(region.X + static_cast<Int32>(region.W),
record.UnionBox.X + static_cast<Int32>(record.UnionBox.W))
<< "a rect ends right of the union box";
EXPECT_LE(region.Y + static_cast<Int32>(region.H),
record.UnionBox.Y + static_cast<Int32>(record.UnionBox.H))
<< "a rect ends below the union box";
loX = std::min(loX, region.X);
loY = std::min(loY, region.Y);
hiX = std::max(hiX, region.X + static_cast<Int32>(region.W));
hiY = std::max(hiY, region.Y + static_cast<Int32>(region.H));
}
EXPECT_EQ(loX, record.UnionBox.X) << "the rects' union does not reach the box's left edge";
EXPECT_EQ(loY, record.UnionBox.Y) << "the rects' union does not reach the box's top edge";
EXPECT_EQ(hiX, record.UnionBox.X + static_cast<Int32>(record.UnionBox.W));
EXPECT_EQ(hiY, record.UnionBox.Y + static_cast<Int32>(record.UnionBox.H));
}
TEST(TextureEmit, AScatteredUploadCarriesTheLevelShadowsStridesAndNotZero) {
TextureScope scope;
const auto texture = MakeTexture2D(5, 256);
texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{4, 8, 0}, IntVec3{2, 2, 1});
texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{200, 220, 0},
IntVec3{2, 2, 1});
Textures().DrainTextureSubData(Ctx());
const Vector<MGPSubRegion> regions = Textures().LastRegions();
ASSERT_GE(regions.size(), 2u);
// A SUB-RECT'S ROWS ARE NOT CONTIGUOUS IN THE SHADOW, so it must carry the LEVEL's pitches -
// not its own width - or the staging planner on the far side repacks the wrong bytes.
for (const MGPSubRegion& region : regions) {
EXPECT_EQ(region.SrcRowStride, 256u * 4u) << "SrcRowStride is not the LEVEL's row pitch";
EXPECT_EQ(region.SrcSliceStride, 256u * 4u * 256u)
<< "SrcSliceStride is not the LEVEL's slice pitch";
const Uint64 expectedOffset =
static_cast<Uint64>(region.Y) * 256u * 4u + static_cast<Uint64>(region.X) * 4u;
EXPECT_EQ(region.SrcOffset, expectedOffset) << "SrcOffset is not the first texel's byte offset";
}
}
TEST(TextureEmit, AWholeLevelUploadCarriesZeroStrides) {
TextureScope scope;
const auto texture = MakeTexture2D(6, 64);
texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0},
IntVec3{64, 64, 1});
Textures().DrainTextureSubData(Ctx());
const MGPSubData record = Textures().LastSubData();
EXPECT_EQ(record.RegionCount, 0u) << "a single whole-level rect IS the union box, not a list";
EXPECT_EQ(record.UnionBox.X, 0);
EXPECT_EQ(record.UnionBox.Y, 0);
EXPECT_EQ(record.UnionBox.W, 64u);
EXPECT_EQ(record.UnionBox.H, 64u);
EXPECT_EQ(record.SourceIsVerbatimLevelShadow, 1)
<< "the client always declares the level shadow; the server clears it when it converts";
EXPECT_EQ(record.Blob.Seg, kMGHostSpanSegNone);
EXPECT_EQ(record.Blob.Size, 0u) << "a monolith record does not declare its blob";
EXPECT_NE(record.Blob.Offset, 0u) << "Blob.Offset is the level shadow's address in monolith";
// The upload target rides in the record's Target byte beside the resource target, which is
// the only place a cube face could ever be carried.
EXPECT_EQ(MGPipeSubDataResourceTargetOf(record.Target),
static_cast<Uint8>(MGPipeResourceTarget::Tex2D));
EXPECT_EQ(MGPipeSubDataUploadTargetOf(record.Target),
static_cast<Uint8>(TextureUploadTarget::Texture2D));
// And the whole-level builder really does say TIGHTLY PACKED.
const MGPipeLevelPitch pitch = MGPipeLevelPitchOf(IntVec3{64, 64, 1}, 64 * 64 * 4);
const MGPSubRegion whole = MGPipeBuildSubRegion(record.UnionBox, IntVec3{64, 64, 1}, pitch);
EXPECT_EQ(whole.SrcRowStride, 0u);
EXPECT_EQ(whole.SrcSliceStride, 0u);
EXPECT_EQ(whole.SrcOffset, 0u);
}
TEST(TextureEmit, MoreThanKMaxDirtyRectsCollapsesToTheBoxWithRegionCountZero) {
TextureScope scope;
const auto texture = MakeTexture2D(7, 128);
// Far more writes than the rect cap. The storage MERGES rather than truncates - a dropped
// rect is a dropped write - and degrades toward the union box; what the emitter must never
// do is invent or truncate. So the storage's own answer is the oracle, read BEFORE the drain
// clears the level, and 0 means "the union box is the whole story".
for (Int i = 0; i < 4 * static_cast<Int>(MipmapStorage::kMaxDirtyRects); ++i) {
const Int x = (i * 7) % 120;
const Int y = (i * 11) % 120;
texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{x, y, 0},
IntVec3{2, 2, 1});
}
MipmapDirtyRegion oracle[MipmapStorage::kMaxDirtyRects];
const SizeT oracleCount = texture->GetStorageDirtyRects(TextureUploadTarget::Texture2D, 0, oracle,
MipmapStorage::kMaxDirtyRects);
EXPECT_LE(oracleCount, MipmapStorage::kMaxDirtyRects);
Textures().DrainTextureSubData(Ctx());
EXPECT_EQ(Textures().LastSubData().RegionCount, static_cast<Uint32>(oracleCount))
<< "the emitted region count is not the storage's own answer";
EXPECT_EQ(Textures().LastRegions().size(), oracleCount);
}
// ============================ D-D4 ============================
TEST(TextureEmit, AnUploadThroughAViewKeysOnTheStorageOwner) {
TextureScope scope;
const auto owner = MakeTexture2D(8, 64, 3);
owner->SetImmutableLevels(3);
const MGPipeHandle ownerHandle = Textures().FindTexture(*owner);
const auto view = MakeShared<TextureObjectView>(9, TextureTarget::Texture2D, owner, 1, 2, 0, 1);
const MGPipeHandle viewHandle = Textures().FindTexture(*view);
ASSERT_FALSE(MGPipeHandleIsNull(ownerHandle));
ASSERT_FALSE(MGPipeHandleIsNull(viewHandle));
ASSERT_FALSE(ownerHandle == viewHandle);
// ViewOf names the storage owner, and ONE HOP always reaches storage.
EXPECT_TRUE(Textures().LastDesc().Resource == viewHandle);
EXPECT_TRUE(Textures().LastDesc().ViewOf == ownerHandle)
<< "the view's descriptor does not name its storage owner";
// An upload through the VIEW: TextureObjectView forwards the mark to the OWNER's method
// after remapping the level, so the drain list holds exactly one entry and it is the
// owner's - which is what makes an upload through a view and an upload through the owner one
// key rather than two.
view->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0}, IntVec3{4, 4, 1});
ASSERT_EQ(Textures().DrainListSize(), 1u);
Textures().DrainTextureSubData(Ctx());
EXPECT_TRUE(Textures().LastSubData().Res == ownerHandle)
<< "an upload through a view was keyed on the view instead of on its storage owner";
// The view's level 0 IS the owner's level 1.
EXPECT_EQ(Textures().LastSubData().Level, 1u);
}
// ============================ D-E1 ============================
TEST(TextureEmit, EveryTexturesParamsNameItsBuiltinSamplerCso) {
TextureScope scope;
const auto texture = MakeTexture2D(10, 8);
const MGPipeHandle handle = Textures().FindTexture(*texture);
texture->SetSwizzleParamRGBA(Vec4<TextureSwizzleParam>{TextureSwizzleParam::Alpha,
TextureSwizzleParam::Blue,
TextureSwizzleParam::Green,
TextureSwizzleParam::Red});
const MGPTextureParams params = Textures().LastParams();
EXPECT_TRUE(params.Res == handle);
// kMGPipeNullHandle is ILLEGAL here: every texture object owns a sampler object, so a null
// is Fatal{ProtocolCorruption} on the far side rather than "no sampler".
EXPECT_FALSE(MGPipeHandleIsNull(params.BuiltinSampler))
<< "MGPTextureParams::BuiltinSampler must never be the null handle";
EXPECT_TRUE(params.BuiltinSampler ==
MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerCso,
texture->GetSamplerObject()->GetLifetimeId()))
<< "the built-in sampler CSO is not keyed on the SamplerObject's own lifetime id, which is "
"the key ~SamplerObject's death helper resolves through";
EXPECT_EQ(params.Swizzle[0], static_cast<Uint8>(TextureSwizzleParam::Alpha));
EXPECT_EQ(params.Swizzle[1], static_cast<Uint8>(TextureSwizzleParam::Blue));
EXPECT_EQ(params.Swizzle[2], static_cast<Uint8>(TextureSwizzleParam::Green));
EXPECT_EQ(params.Swizzle[3], static_cast<Uint8>(TextureSwizzleParam::Red));
EXPECT_EQ(params.DepthStencilMode, kMGPipeDepthStencilModeDepth);
// D-E3's deliverable at the one site that proves it: a texture nothing has bound - no
// sampler view, no image unit, only ever an attachment - still publishes the mode, because
// set_texture_params is addressed by RESOURCE and is independent of every binding.
texture->SetDepthStencilTextureMode(GL_STENCIL_INDEX);
EXPECT_EQ(Textures().LastParams().DepthStencilMode, kMGPipeDepthStencilModeStencil);
}
TEST(TextureEmit, TwoTexturesWithIdenticalSamplingShareOneBuiltinCso) {
TextureScope scope;
if (kMGPipeWiredSamplerSubsystem == 0) {
GTEST_SKIP() << "the sampler family's content-addressed CSO cache is not wired on this tree. "
"This package resolves MGPTextureParams::BuiltinSampler IDENTITY-addressed, "
"off the SamplerObject's own lifetime id - which is the key "
"~SamplerObject's death helper already resolves through - so two textures "
"get two CSOs here. Sharing is the sampler package's cache (capacity 256, "
"hashed and memcmp-confirmed field-wise over a zero-initialised canonical "
"copy), and this case is its gate on the integrated tree.";
}
const auto first = MakeTexture2D(11, 8);
const auto second = MakeTexture2D(12, 8);
first->SetSwizzleParam(TextureSwizzleParam::Red, TextureSwizzleParam::Green);
const MGPipeHandle firstCso = Textures().LastParams().BuiltinSampler;
second->SetSwizzleParam(TextureSwizzleParam::Red, TextureSwizzleParam::Green);
EXPECT_TRUE(Textures().LastParams().BuiltinSampler == firstCso);
}
// ============================ D-I1 ============================
TEST(TextureEmit, ADestroyedTextureReleasesItsResourceViewAndBuiltinSamplerSlots) {
TextureScope scope;
const Uint32 texturesBefore = MGPipeSlots().LiveCount(MGPipeKind::Texture);
const Uint32 viewsBefore = MGPipeSlots().LiveCount(MGPipeKind::SamplerViewCso);
MGPipeHandle handle{};
{
const auto texture = MakeTexture2D(13, 8);
handle = Textures().FindTexture(*texture);
ASSERT_FALSE(MGPipeHandleIsNull(handle));
EXPECT_TRUE(MGPipeSlots().IsLive(MGPipeKind::Texture, handle));
// The sampler VIEW is minted off the TEXTURE's own lifetime id - one per ITextureObject
// (D-F2) - so the texture's death is the only thing that can release it.
(void)MGPipeSlots().Acquire(MGPipeKind::SamplerViewCso, texture->GetLifetimeId());
}
EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::Texture, handle))
<< "~TextureObjectBase did not return the texture's slot";
EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::Texture), texturesBefore)
<< "a destroyed texture leaked its resource slot";
EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::SamplerViewCso), viewsBefore)
<< "a destroyed texture leaked the sampler view minted off its lifetime id";
// THE BUILT-IN SAMPLER'S CSO SLOT IS NOT CHECKED HERE, and the absence is a statement rather
// than an omission: it belongs to a real SamplerObject with its OWN lifetime id and its own
// #if MOBILEGL_PIPE_PUSH destructor, so releasing it from the texture's id would resolve the
// wrong slot - possibly a live one belonging to another object. ~SamplerObject runs
// immediately after ~TextureObjectBase's body (a member's destructor follows its owner's) and
// is where the sampler package wires MGPipeEmitSamplerCsoDestroyAndFree; until that lands,
// this tree's SamplerCso slots are released by nobody, which is exactly the ordered
// dependency the integration order (clientfb, then clientsp) exists for.
//
// A second release on the same handle is a proven no-op: Free bumps no generation of its
// own, so a double free cannot skip one.
MGPipeSlots().Free(MGPipeKind::Texture, handle);
EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::Texture), texturesBefore);
}
// ============================ D-D2 ============================
TEST(TextureEmit, ARenderbufferRespecifyPublishesItsExtentWithoutAVersionCounter) {
TextureScope scope;
const auto renderbuffer = MakeShared<RenderbufferObject>(1);
const MGPipeHandle handle = Textures().FindRenderbuffer(*renderbuffer);
ASSERT_FALSE(MGPipeHandleIsNull(handle));
EXPECT_TRUE(Textures().LastDesc().Resource == handle);
EXPECT_EQ(Textures().LastDesc().Target, static_cast<Uint8>(MGPipeResourceTarget::Renderbuffer));
EXPECT_EQ(Textures().LastDesc().HasDefinedContent, 0) << "a create carries no storage";
// The three setters bump no version and raise no notice, and the framebuffer bit's shutter
// does not move for an ALREADY-ATTACHED renderbuffer - which is exactly why the hole is
// closed by EMISSION from the storage entry point rather than by a new counter (a member
// would resize the pull build's object) or a wider shutter.
renderbuffer->SetInternalFormat(TextureInternalFormat::Depth24Stencil8);
renderbuffer->AllocateStorage(IntVec2{320, 240});
renderbuffer->SetSamples(4);
const MGPResourceDesc desc = Textures().LastDesc();
EXPECT_TRUE(desc.Resource == handle);
EXPECT_EQ(desc.Width, 320u);
EXPECT_EQ(desc.Height, 240u);
EXPECT_EQ(desc.Samples, 4u);
EXPECT_EQ(desc.HasDefinedContent, 1);
EXPECT_EQ(desc.InternalFormat, static_cast<Uint32>(TextureInternalFormat::Depth24Stencil8));
// THE DEDUPE, which is what keeps one glRenderbufferStorage one record rather than three.
const Uint64 respecifiesBefore = Textures().RespecifyCount();
renderbuffer->AllocateStorage(IntVec2{320, 240});
EXPECT_EQ(Textures().RespecifyCount(), respecifiesBefore);
}
// ============================ D-D5 ============================
TEST(TextureEmit, ABailedLevelStaysDirtyAndStaysOnTheDrainList) {
TextureScope scope;
// A level with no storage at all: there is nothing to upload, the record cannot be built,
// and the texels are still owed. Naively clearing the flag here is exactly how a bail loses
// texels, which is the failure D-D5's three steps exist to make impossible.
// A level with a real EXTENT and no BYTES: the region is non-empty so the level is genuinely
// dirty, and the emitter cannot derive a bytes-per-texel or find a shadow to point at.
const auto texture = MakeShared<TextureObject2D>(14);
texture->SetInternalFormat(TextureInternalFormat::RGBA8);
texture->AllocateStorage(TextureUploadTarget::Texture2D, 0, MipmapInput{IntVec3{8, 8, 1}, 0});
texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0}, IntVec3{4, 4, 1});
ASSERT_TRUE(texture->IsStorageDirty(TextureUploadTarget::Texture2D, 0));
ASSERT_EQ(Textures().DrainListSize(), 1u);
Textures().DrainTextureSubData(Ctx());
EXPECT_EQ(Textures().DrainListSize(), 1u)
<< "a level the emitter could not describe was dropped from the drain list";
EXPECT_EQ(Textures().SubDataCount(), 0u);
// And a level that WAS emitted leaves both the flag and the list entry behind it.
const auto good = MakeTexture2D(15, 16);
good->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0}, IntVec3{4, 4, 1});
Textures().DrainTextureSubData(Ctx());
EXPECT_EQ(Textures().SubDataCount(), 1u);
EXPECT_FALSE(good->IsStorageDirty(TextureUploadTarget::Texture2D, 0))
<< "the client must clear its own flag for a level whose record the applier accepted";
}
#endif // MOBILEGL_PIPE_PUSH
// ==================================================================================int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
fs::temp_directory_path() / ("mobilegl-textureemit-test-" + std::to_string(ProcessId()) + ".log");