mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-09 04:38:30 +09:00
[Test] (Pipe): pin the buffer and vertex-input emitters - every attribute field survives the wire, a bare baseInstance change still emits, and the index-buffer bit ignores unrelated writes
This commit is contained in:
@@ -204,29 +204,40 @@ namespace MobileGL::MG_Pipe {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ONE record caps at a 2^31-1 offset and a 2^32-1 size (MGPipeTypes.h), so a range
|
||||
// beyond either has to be split. The pieces are CONTIGUOUS and in ascending order:
|
||||
// ONE record's destination box caps the offset at 2^31-1 and the size at 2^32-1
|
||||
// (MGPipeTypes.h), so a range beyond either has to be split. The pieces are CONTIGUOUS
|
||||
// and in ASCENDING order, and both properties are load-bearing rather than tidy:
|
||||
// splitting a content write into overlapping or reordered pieces would change what the
|
||||
// backend's queue-and-drain sees, and the Mali WAR-stall fix depends on the queue being
|
||||
// backend's queue-and-drain sees, and the Mali WAR-stall fix depends on that queue being
|
||||
// exactly the writes the application made.
|
||||
inline constexpr Uint64 kMGPipeSubDataMaxRecordOffset = 0x7FFFFFFFull;
|
||||
inline constexpr Uint64 kMGPipeSubDataMaxRecordSize = 0xFFFFFFFFull;
|
||||
|
||||
// WITH THE RECORD'S OWN BOUND THE SPLIT IS NOT REACHABLE, and saying so is better than a
|
||||
// loop that reads as if it were: a second piece starts at least 2^32-1 bytes past the
|
||||
// first, which is already past the OFFSET cap, so a range too big for one record is
|
||||
// REFUSED rather than split. The offset cap cannot be split away at all - every piece of
|
||||
// a range that starts past 2^31-1 starts past it too - and a silent truncation is the one
|
||||
// answer that must not happen, so the walk emits nothing and its caller says so once.
|
||||
//
|
||||
// The OFFSET bound cannot be split away - every piece of a range that starts past
|
||||
// 2^31-1 starts past it too - so the walk returns false for such a range and emits
|
||||
// nothing rather than emitting a record whose box the applier's bounds gate would
|
||||
// refuse. That needs a >2 GiB buffer, which nothing in the corpus has; the answer is
|
||||
// still stated rather than assumed, because the alternative is a silent truncation.
|
||||
// `maxChunk` exists because the record's bound is not the tight one for long: a transport
|
||||
// segment is far smaller (tens of MiB), and that is where this walk starts producing real
|
||||
// splits. It is a parameter now, and exercised at a reachable value by the unit gate, so
|
||||
// that lowering it is one argument rather than a new code path written under pressure.
|
||||
template <class Fn>
|
||||
inline Bool MGPipeForEachSubDataRecordRange(Uint64 offset, Uint64 size, Fn&& piece) {
|
||||
constexpr Uint64 kMaxOffset = 0x7FFFFFFFull;
|
||||
constexpr Uint64 kMaxSize = 0xFFFFFFFFull;
|
||||
if (offset > kMaxOffset) return false;
|
||||
inline Bool MGPipeForEachSubDataRecordRange(Uint64 offset, Uint64 size, Fn&& piece,
|
||||
Uint64 maxChunk = kMGPipeSubDataMaxRecordSize) {
|
||||
if (offset > kMGPipeSubDataMaxRecordOffset) return false;
|
||||
if (size == 0) return true;
|
||||
// A single piece may run to the end of the buffer; only its SIZE is split.
|
||||
Uint64 at = offset;
|
||||
Uint64 left = size;
|
||||
while (left > 0) {
|
||||
if (at > kMaxOffset) return false;
|
||||
const Uint64 chunk = left > kMaxSize ? kMaxSize : left;
|
||||
if (maxChunk == 0) return false;
|
||||
// Every piece has to be encodable BEFORE any of them is emitted: a half-emitted range
|
||||
// is a partial content write the backend would land as if it were the whole one.
|
||||
const Uint64 chunkCap = maxChunk < kMGPipeSubDataMaxRecordSize ? maxChunk : kMGPipeSubDataMaxRecordSize;
|
||||
for (Uint64 at = offset; at < offset + size; at += chunkCap) {
|
||||
if (at > kMGPipeSubDataMaxRecordOffset) return false;
|
||||
}
|
||||
for (Uint64 at = offset, left = size; left > 0;) {
|
||||
const Uint64 chunk = left > chunkCap ? chunkCap : left;
|
||||
piece(at, chunk);
|
||||
at += chunk;
|
||||
left -= chunk;
|
||||
|
||||
@@ -55,7 +55,13 @@
|
||||
#include "Includes.h"
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <Config.h>
|
||||
#include <MG_Impl/Pipe/ResourceTracker.h>
|
||||
#include <MG_Impl/Pipe/SlotAllocator.h>
|
||||
#include <MG_Pipe/PipeApply.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
|
||||
#include <vector>
|
||||
#endif
|
||||
|
||||
using namespace MobileGL;
|
||||
@@ -1315,6 +1321,294 @@ namespace {
|
||||
EXPECT_TRUE(MGPipeApplier().VertexElementsCsos.empty());
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !MOBILEGL_PIPE_PUSH
|
||||
// G2 REQUIRES THE PULL AND PUSH ctest NAME SETS TO BE IDENTICAL, name for name, so a
|
||||
// push-only case cannot be ABSENT from a pull build - it has to be there and SKIP. This
|
||||
// list declares exactly the suite.name pairs the push build gets from the real cases
|
||||
// below, the shape PipeInputsTest and TrackerTest established for the same reason.
|
||||
#define MGL_RESOURCE_EMIT_TEST_LIST(X) \
|
||||
X(ResourceEmit, EveryBufferTargetSetsItsBindMaskBit) \
|
||||
X(ResourceEmit, ABindMaskBitIsStickyAcrossARespecifyThatDoesNotRebind) \
|
||||
X(ResourceEmit, ADestroyedBufferReleasesItsSlotAndAStaleHandleResolvesToNothing) \
|
||||
X(ResourceEmit, AWholeBufferSubDataBeyondTheRecordBoundIsSplitIntoContiguousRecords)
|
||||
|
||||
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
|
||||
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
|
||||
MGL_RESOURCE_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP)
|
||||
#undef MGL_DECLARE_PULL_SKIP
|
||||
#else
|
||||
using GLContext = MG_State::GLState::GLContext;
|
||||
using MG_State::GLState::BufferObject;
|
||||
|
||||
// The client emitters run only when the resource subsystem bit is on AND a backend has
|
||||
// installed an op table (that pair is what lets the client half land without changing a
|
||||
// single observable). A unit process has no backend, so a case installs an EMPTY table:
|
||||
// every member is null, the applier's stubs dispatch to nothing, and what the case reads
|
||||
// is what the CLIENT built - which is the only half this package owns.
|
||||
//
|
||||
// AN RAII SCOPE RATHER THAN A gtest FIXTURE, and that is not a style choice: the two
|
||||
// gates grep `ctest -R 'ResourceEmit\.'`, a TEST_F puts its cases under the FIXTURE's
|
||||
// name, and gtest refuses to mix TEST and TEST_F under one suite name - so a fixture
|
||||
// would either rename every case out of the gate's reach or force the contract commit's
|
||||
// placeholder (which must see NO table registered) into the same SetUp.
|
||||
struct PushArm {
|
||||
PushArm() {
|
||||
m_previousPush = MG_Config::Features.PipePush;
|
||||
MG_Config::Features.PipePush |= kMGPipeSubsystemResources;
|
||||
MGPipeSetResourceOps(&m_ops);
|
||||
m_previousContext = Move(MG_State::pGLContext);
|
||||
MG_State::pGLContext = MakeUnique<GLContext>();
|
||||
}
|
||||
~PushArm() {
|
||||
// The context first: its buffer objects emit their destroy and free their slots
|
||||
// on the way out, which is the order D-L fixes and which this teardown therefore
|
||||
// has to respect too.
|
||||
MG_State::pGLContext.reset();
|
||||
MG_State::pGLContext = Move(m_previousContext);
|
||||
MGPipeSetResourceOps(nullptr);
|
||||
MG_Config::Features.PipePush = m_previousPush;
|
||||
}
|
||||
PushArm(const PushArm&) = delete;
|
||||
PushArm& operator=(const PushArm&) = delete;
|
||||
|
||||
MGPipeResourceOps m_ops{};
|
||||
Uint64 m_previousPush = 0;
|
||||
UniquePtr<GLContext> m_previousContext;
|
||||
};
|
||||
|
||||
GLContext& Ctx() { return *MG_State::pGLContext; }
|
||||
|
||||
const SharedPtr<BufferObject>& MakeBuffer(Uint name) { return Ctx().CreateBufferObject(name); }
|
||||
|
||||
// Bind `buffer` to `target` the way the GL entry point for that target does. The index
|
||||
// target is the BOUND VAO's element slot, not one of BufferState's, which is why it
|
||||
// cannot go through GetBufferBindingSlot's global path.
|
||||
void BindTo(BufferTarget target, const SharedPtr<BufferObject>& buffer) {
|
||||
if (target == BufferTarget::Index) {
|
||||
Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(buffer);
|
||||
return;
|
||||
}
|
||||
Ctx().GetBufferBindingSlot(target).Bind(buffer);
|
||||
}
|
||||
|
||||
Bool IsGlobalTarget(BufferTarget target) {
|
||||
for (const auto candidate : MG_State::GLState::GlobalBufferTargets) {
|
||||
if (candidate == target) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// D-A3, and the risk register calls this the one P3a deliverable whose only real gate is
|
||||
// a unit test: a wrong ELEMENT_ARRAY bit silently disables restart rewriting and
|
||||
// multi-draw flattening under split and is invisible in monolith.
|
||||
//
|
||||
// Every enumerator, one fresh buffer each, so the assertion is an EQUALITY rather than a
|
||||
// "has the bit": a target that maps to no bit at all (the transfer and query targets)
|
||||
// must leave the mask empty, and a table row that leaked a neighbour's bit fails here.
|
||||
//
|
||||
// ON CREATE the mask is necessarily empty and that is not a gap in the test: the create
|
||||
// is emitted from the buffer object's CONSTRUCTOR, and nothing can be bound to an object
|
||||
// that does not exist yet. What the create carries is the identity and an undefined
|
||||
// store; the bind then happens; the respecify carries the mask. The case asserts both
|
||||
// halves so that a create which started carrying a stale mask would fail too.
|
||||
TEST(ResourceEmit, EveryBufferTargetSetsItsBindMaskBit) {
|
||||
PushArm arm;
|
||||
MGPipeResourceTracker& tracker = MGPipeResourceTrackerInstance();
|
||||
Uint name = 1;
|
||||
for (SizeT i = 0; i < static_cast<SizeT>(BufferTarget::BufferTargetCount); ++i) {
|
||||
const auto target = static_cast<BufferTarget>(i);
|
||||
if (target != BufferTarget::Index && !IsGlobalTarget(target)) continue;
|
||||
const Uint64 createsBefore = tracker.CreateCount();
|
||||
const SharedPtr<BufferObject> buffer = MakeBuffer(name++);
|
||||
ASSERT_EQ(tracker.CreateCount(), createsBefore + 1)
|
||||
<< "the constructor did not emit resource_create for target " << i;
|
||||
const MGPResourceDesc created = tracker.LastDesc();
|
||||
EXPECT_EQ(created.BindMask, 0u)
|
||||
<< "resource_create carried a binding for an object nothing could have bound yet";
|
||||
EXPECT_EQ(created.Width, 0u) << "resource_create must carry no storage";
|
||||
EXPECT_EQ(created.Target, 0u) << "the buffer arm of the resource discriminator";
|
||||
|
||||
BindTo(target, buffer);
|
||||
buffer->Respecify(64, nullptr);
|
||||
|
||||
const MGPResourceDesc respecified = tracker.LastDesc();
|
||||
const auto expected = static_cast<Uint16>(MGPipeBindMaskForBufferTarget(target));
|
||||
EXPECT_EQ(respecified.BindMask, expected)
|
||||
<< "BindMask for BufferTarget " << i << " (" << respecified.BindMask << " vs " << expected << ")";
|
||||
EXPECT_EQ(respecified.Resource, created.Resource) << "a respecify keeps the handle";
|
||||
EXPECT_EQ(respecified.Width, 64u);
|
||||
// Unbind, so the next iteration's fresh buffer sees an empty binding state.
|
||||
if (target == BufferTarget::Index) {
|
||||
Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(nullptr);
|
||||
} else {
|
||||
Ctx().GetBufferBindingSlot(target).Bind(nullptr);
|
||||
}
|
||||
}
|
||||
// The one bit whose only consumer is in another phase, asserted by name so that a
|
||||
// table edit that moved it is a failure here rather than a silent P8 regression.
|
||||
EXPECT_EQ(MGPipeBindMaskForBufferTarget(BufferTarget::Index),
|
||||
static_cast<Uint32>(kMGPipeBindIndex | kMGPipeBindElementArray));
|
||||
}
|
||||
|
||||
// Sticky means ORed and never cleared, exactly like the image-bindable hint. A buffer
|
||||
// that was an element array once keeps saying so - which is what the split-mode index
|
||||
// mirror keys on, and it must not depend on the buffer still being bound when its store
|
||||
// is next defined.
|
||||
TEST(ResourceEmit, ABindMaskBitIsStickyAcrossARespecifyThatDoesNotRebind) {
|
||||
PushArm arm;
|
||||
MGPipeResourceTracker& tracker = MGPipeResourceTrackerInstance();
|
||||
const SharedPtr<BufferObject> buffer = MakeBuffer(1);
|
||||
|
||||
BindTo(BufferTarget::Index, buffer);
|
||||
buffer->Respecify(32, nullptr);
|
||||
const Uint16 afterIndexBind = tracker.LastDesc().BindMask;
|
||||
ASSERT_TRUE(afterIndexBind & kMGPipeBindElementArray);
|
||||
|
||||
// Unbind it entirely and define the store again: the bit survives.
|
||||
Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(nullptr);
|
||||
buffer->Respecify(48, nullptr);
|
||||
EXPECT_EQ(tracker.LastDesc().BindMask & kMGPipeBindElementArray, kMGPipeBindElementArray)
|
||||
<< "the ELEMENT_ARRAY bit was cleared by an unbind";
|
||||
|
||||
// And a SECOND target ORs in rather than replacing.
|
||||
BindTo(BufferTarget::Vertex, buffer);
|
||||
buffer->Respecify(64, nullptr);
|
||||
const Uint16 both = tracker.LastDesc().BindMask;
|
||||
EXPECT_EQ(both & kMGPipeBindElementArray, kMGPipeBindElementArray);
|
||||
EXPECT_EQ(both & kMGPipeBindVertex, kMGPipeBindVertex);
|
||||
Ctx().GetBufferBindingSlot(BufferTarget::Vertex).Bind(nullptr);
|
||||
}
|
||||
|
||||
// D-L's ORDER, which is not negotiable: the destroy is emitted while the handle still
|
||||
// resolves, and only then does the slot go back. The allocator erases the lifetimeId ->
|
||||
// slot mapping on free, so a notice resolved twice finds nothing the second time - and
|
||||
// the generation moves on the NEXT handout of the slot, never in the free, so a double
|
||||
// free cannot skip one.
|
||||
TEST(ResourceEmit, ADestroyedBufferReleasesItsSlotAndAStaleHandleResolvesToNothing) {
|
||||
PushArm arm;
|
||||
MGPipeResourceTracker& tracker = MGPipeResourceTrackerInstance();
|
||||
// Owned by the case rather than by BufferState, so that "the last reference drops" is
|
||||
// this line and not a chain of unbinds: the death this case is about is the
|
||||
// destructor, not the glDelete* that only marks the name.
|
||||
SharedPtr<BufferObject> buffer = MakeShared<BufferObject>(1);
|
||||
const MGPipeHandle handle = tracker.Find(*buffer);
|
||||
ASSERT_FALSE(MGPipeHandleIsNull(handle));
|
||||
EXPECT_EQ(tracker.Resolve(handle), buffer.get()) << "the slot -> object inverse the reverse channel uses";
|
||||
EXPECT_TRUE(MGPipeSlots().IsLive(MGPipeKind::Buffer, handle));
|
||||
|
||||
const Uint64 destroysBefore = tracker.DestroyCount();
|
||||
buffer.reset();
|
||||
|
||||
EXPECT_EQ(tracker.DestroyCount(), destroysBefore + 1) << "~BufferObject did not emit resource_destroy";
|
||||
EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::Buffer, handle)) << "the slot was not freed";
|
||||
EXPECT_EQ(tracker.Resolve(handle), nullptr) << "a stale handle still resolves to an object";
|
||||
|
||||
// THE SLOT COMES BACK WITH A HIGHER GENERATION, so the stale handle above can never
|
||||
// name the buffer that lands on it next. The allocator's free list is shared with
|
||||
// every other case in this process, so which allocation reclaims THIS slot is not
|
||||
// fixed - the case allocates until one does rather than assuming the next one will,
|
||||
// and the property it is after is about the slot, not about the order.
|
||||
Vector<SharedPtr<BufferObject>> keepAlive;
|
||||
SharedPtr<BufferObject> successor;
|
||||
for (Uint next = 2; next < 96 && !successor; ++next) {
|
||||
SharedPtr<BufferObject> candidate = MakeShared<BufferObject>(next);
|
||||
keepAlive.push_back(candidate);
|
||||
if (tracker.Find(*candidate).Slot == handle.Slot) successor = candidate;
|
||||
}
|
||||
ASSERT_TRUE(successor) << "the freed slot never came back out of the allocator";
|
||||
const MGPipeHandle fresh = tracker.Find(*successor);
|
||||
EXPECT_EQ(fresh.Slot, handle.Slot);
|
||||
EXPECT_NE(fresh.Gen, handle.Gen) << "the generation did not move on reuse";
|
||||
EXPECT_EQ(tracker.Resolve(handle), nullptr) << "the stale handle resolved to its successor";
|
||||
EXPECT_EQ(tracker.Resolve(fresh), successor.get());
|
||||
}
|
||||
|
||||
// One MGPSubData record encodes its destination range in the box's first coordinate and
|
||||
// first extent, which caps the offset at 2^31-1 and the size at 2^32-1, and a range
|
||||
// beyond a bound has to be SPLIT into contiguous ascending pieces or REFUSED - never
|
||||
// silently truncated. Overlapping or reordered pieces would change what the backend's
|
||||
// queue-and-drain sees, and the Mali WAR-stall fix depends on that queue being exactly
|
||||
// the writes the application made.
|
||||
//
|
||||
// WITH THE RECORD'S OWN BOUNDS THE SPLIT IS UNREACHABLE, and this case says so out loud
|
||||
// rather than pretending otherwise: a second piece begins at least 2^32-1 bytes past the
|
||||
// first, which is already past the OFFSET cap, so an over-long range is refused. What
|
||||
// makes the split live is the transport's segment, which is far tighter - so the walk
|
||||
// takes its cap as an argument, and the split half of this case drives it at a reachable
|
||||
// value. That is the same code path the emitter takes, with one constant changed.
|
||||
TEST(ResourceEmit, AWholeBufferSubDataBeyondTheRecordBoundIsSplitIntoContiguousRecords) {
|
||||
std::vector<std::pair<Uint64, Uint64>> pieces;
|
||||
const auto collect = [&](Uint64 at, Uint64 length) { pieces.emplace_back(at, length); };
|
||||
|
||||
// Inside every bound: exactly one record, unsplit.
|
||||
pieces.clear();
|
||||
EXPECT_TRUE(MGPipeForEachSubDataRecordRange(16, 1024, collect));
|
||||
ASSERT_EQ(pieces.size(), 1u);
|
||||
EXPECT_EQ(pieces[0].first, 16u);
|
||||
EXPECT_EQ(pieces[0].second, 1024u);
|
||||
|
||||
// Exactly ON the offset cap: still one record, because the cap is inclusive.
|
||||
pieces.clear();
|
||||
EXPECT_TRUE(MGPipeForEachSubDataRecordRange(kMGPipeSubDataMaxRecordOffset, 64, collect));
|
||||
ASSERT_EQ(pieces.size(), 1u);
|
||||
EXPECT_EQ(pieces[0].first, kMGPipeSubDataMaxRecordOffset);
|
||||
|
||||
// ---- the split, at a reachable cap ----
|
||||
constexpr Uint64 kSegment = 32ull * 1024ull * 1024ull; // a transport segment's shape
|
||||
constexpr Uint64 kWhole = kSegment * 3 + 7;
|
||||
pieces.clear();
|
||||
ASSERT_TRUE(MGPipeForEachSubDataRecordRange(0, kWhole, collect, kSegment));
|
||||
ASSERT_EQ(pieces.size(), 4u);
|
||||
Uint64 covered = 0;
|
||||
Uint64 expectedAt = 0;
|
||||
for (const auto& piece : pieces) {
|
||||
EXPECT_EQ(piece.first, expectedAt) << "the pieces are not contiguous and ascending";
|
||||
EXPECT_LE(piece.second, kSegment) << "a piece is bigger than the cap";
|
||||
EXPECT_GT(piece.second, 0u);
|
||||
covered += piece.second;
|
||||
expectedAt += piece.second;
|
||||
// And every piece the walk produced has to be encodable by the record builder -
|
||||
// a piece the box refuses is a record the applier's bounds gate would abort on.
|
||||
MGPSubData record{};
|
||||
EXPECT_TRUE(MGPipeBuildSubDataRecord(MGPipeHandle{1, 1}, piece.first, piece.second, record))
|
||||
<< "a piece the splitter produced does not fit one record";
|
||||
EXPECT_EQ(MGPipeSubDataBufferOffset(record), piece.first);
|
||||
EXPECT_EQ(MGPipeSubDataBufferSize(record), piece.second);
|
||||
}
|
||||
EXPECT_EQ(covered, kWhole) << "the split covered the range more or less than exactly once";
|
||||
|
||||
// A whole-buffer sub-data that starts at a NON-ZERO offset splits from there, so the
|
||||
// first piece is not special.
|
||||
pieces.clear();
|
||||
ASSERT_TRUE(MGPipeForEachSubDataRecordRange(1024, kSegment + 1, collect, kSegment));
|
||||
ASSERT_EQ(pieces.size(), 2u);
|
||||
EXPECT_EQ(pieces[0].first, 1024u);
|
||||
EXPECT_EQ(pieces[0].second, kSegment);
|
||||
EXPECT_EQ(pieces[1].first, 1024u + kSegment);
|
||||
EXPECT_EQ(pieces[1].second, 1u);
|
||||
|
||||
// ---- the refusals, and NOTHING is emitted before one is decided ----
|
||||
// Past the offset cap: no piece of a range that starts past it starts inside it.
|
||||
pieces.clear();
|
||||
EXPECT_FALSE(MGPipeForEachSubDataRecordRange(kMGPipeSubDataMaxRecordOffset + 1, 16, collect));
|
||||
EXPECT_TRUE(pieces.empty()) << "a refused range still emitted records";
|
||||
|
||||
// Too long for the record's own bounds: the second piece would begin past the offset
|
||||
// cap, so it is refused ENTIRELY rather than emitted up to the point of failure - a
|
||||
// half-emitted range is a partial content write the backend would land as a whole one.
|
||||
pieces.clear();
|
||||
EXPECT_FALSE(MGPipeForEachSubDataRecordRange(0, kMGPipeSubDataMaxRecordSize + 1, collect));
|
||||
EXPECT_TRUE(pieces.empty()) << "the walk emitted a prefix of a range it then refused";
|
||||
|
||||
// The same refusal through the reachable cap, which is what a transport will hit
|
||||
// first: a range whose later pieces cross the offset cap is refused whole.
|
||||
pieces.clear();
|
||||
EXPECT_FALSE(MGPipeForEachSubDataRecordRange(kMGPipeSubDataMaxRecordOffset - kSegment,
|
||||
kSegment * 4, collect, kSegment));
|
||||
EXPECT_TRUE(pieces.empty());
|
||||
}
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
|
||||
@@ -72,6 +72,8 @@ namespace {
|
||||
X(TrackerWalk, ANaNPatchLevelEqualsItselfAndDoesNotFireForever) \
|
||||
X(TrackerWalk, ThePixelPackShutterIsAByteCompareOfThePackHalfOnly) \
|
||||
X(TrackerWalk, TheFireTalliesOnlyRunWhilePipeStatsIsOn) \
|
||||
X(TrackerWalk, TheIndexBufferBitDoesNotFireOnAnUnrelatedBufferWrite) \
|
||||
X(TrackerWalk, TheIndexBufferBitFiresWhenTheSlotVersionWrapsOntoADifferentBuffer) \
|
||||
X(TrackerAttribPayload, AFloatWriteCarriesTheFloatBitsAndNamesItsClass) \
|
||||
X(TrackerAttribPayload, AnIntWriteCarriesTheIntWordsAndNamesItsClass) \
|
||||
X(TrackerAttribPayload, AUintWriteCarriesTheUintWordsAndNamesItsClass) \
|
||||
@@ -463,6 +465,85 @@ namespace {
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
// ===================================================================================
|
||||
// P3a D-I: bit 10's narrowed shutter
|
||||
// ===================================================================================
|
||||
//
|
||||
// NEW_INDEX_BUFFER used to be MixShutter(the whole buffer-CONTENT aggregate, the VAO
|
||||
// identity), so it fired on any buffer write anywhere - a glBufferSubData into a texture
|
||||
// upload staging buffer re-published the index binding. It now reads the bound VAO's own
|
||||
// element-slot version and the identity of whatever is bound to it.
|
||||
//
|
||||
// THIS IS THE ONE CASE THE OLD SHUTTER COULD NOT PASS, which is why it is here rather
|
||||
// than in the narrowing commit's prose.
|
||||
TEST_F(TrackerWalk, TheIndexBufferBitDoesNotFireOnAnUnrelatedBufferWrite) {
|
||||
const SharedPtr<MG_State::GLState::BufferObject> indices = Ctx().CreateBufferObject(1);
|
||||
indices->Respecify(64, nullptr);
|
||||
Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(indices);
|
||||
Walk();
|
||||
Walk();
|
||||
ASSERT_EQ(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u)
|
||||
<< "the steady state must be quiet before the interesting half of this case";
|
||||
|
||||
// An entirely unrelated buffer's contents move. Nothing about the element binding
|
||||
// changed, so the bit must stay down.
|
||||
const SharedPtr<MG_State::GLState::BufferObject> unrelated = Ctx().CreateBufferObject(2);
|
||||
unrelated->Respecify(4096, nullptr);
|
||||
Array<Uint8, 16> bytes{};
|
||||
unrelated->UploadSubData(DataPtr{bytes.data(), bytes.size()}, 0);
|
||||
ASSERT_NE(Ctx().GetAnyBufferChangeGeneration(), 0u) << "the buffer aggregate did move";
|
||||
Walk();
|
||||
EXPECT_EQ(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u)
|
||||
<< "NEW_INDEX_BUFFER fired on a write to a buffer that is not the element binding";
|
||||
|
||||
// And the control, on the same tracker: the binding itself moving DOES fire it, so
|
||||
// the quiet above is a narrowing and not a dead bit.
|
||||
const SharedPtr<MG_State::GLState::BufferObject> other = Ctx().CreateBufferObject(3);
|
||||
other->Respecify(64, nullptr);
|
||||
Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(other);
|
||||
Walk();
|
||||
EXPECT_NE(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u)
|
||||
<< "NEW_INDEX_BUFFER did not fire when the element binding changed";
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
// The slot version is a WRAPPING Uint16 that BindingSlot bumps only on a real change, so
|
||||
// it is widened at this boundary - and the bound object's lifetime id joins it because
|
||||
// identity is what closes the wrap hole. 65536 binds later the version reads the same
|
||||
// number it did at the start; if that number were the whole shutter, a binding that had
|
||||
// moved onto a DIFFERENT buffer would read as unchanged and the draw would fetch indices
|
||||
// from the previous one.
|
||||
TEST_F(TrackerWalk, TheIndexBufferBitFiresWhenTheSlotVersionWrapsOntoADifferentBuffer) {
|
||||
const SharedPtr<MG_State::GLState::BufferObject> objects[3] = {
|
||||
Ctx().CreateBufferObject(1), Ctx().CreateBufferObject(2), Ctx().CreateBufferObject(3)};
|
||||
for (const auto& object : objects) object->Respecify(64, nullptr);
|
||||
auto& slot = Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot();
|
||||
|
||||
slot.Bind(objects[0]);
|
||||
Walk();
|
||||
Walk();
|
||||
ASSERT_EQ(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u);
|
||||
const Uint16 versionAtStart = slot.GetVersion();
|
||||
|
||||
// Drive the Uint16 all the way round WITHOUT the tracker looking, which is exactly
|
||||
// the window a wrap needs: every bind between two walks is invisible to it.
|
||||
//
|
||||
// THREE buffers, not two, and that is the whole construction: BindingSlot bumps its
|
||||
// version only on a real change, so strictly alternating between two objects makes
|
||||
// the version and the bound object share a parity - 65536 changes always land back on
|
||||
// the object they started from, and the wrap is unobservable. Cycling three lands on
|
||||
// objects[65536 % 3] == objects[1] at exactly the same raw version.
|
||||
for (Uint32 i = 1; i <= 65536u; ++i) slot.Bind(objects[i % 3]);
|
||||
ASSERT_EQ(slot.GetVersion(), versionAtStart) << "the version did not come back round";
|
||||
ASSERT_EQ(slot.GetBoundObject(), objects[1]) << "the binding did not land on a different buffer";
|
||||
|
||||
Walk();
|
||||
EXPECT_NE(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u)
|
||||
<< "the slot version wrapped onto a DIFFERENT buffer and the bit stayed down - the "
|
||||
"identity half of the shutter is what has to close that hole";
|
||||
m_cache.Reset();
|
||||
}
|
||||
|
||||
// ===================================================================================
|
||||
// set_vertex_attrib_defaults' payload (P2 brief D10)
|
||||
// ===================================================================================
|
||||
|
||||
@@ -47,7 +47,12 @@
|
||||
#include "Includes.h"
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
#if MOBILEGL_PIPE_PUSH
|
||||
#include <Config.h>
|
||||
#include <MG_Impl/Pipe/SetHashSuppressor.h>
|
||||
#include <MG_Impl/Pipe/SlotAllocator.h>
|
||||
#include <MG_Impl/Pipe/VertexInputEmit.h>
|
||||
#include <MG_Pipe/PipeApply.h>
|
||||
#include <MG_State/GLState/Core.h>
|
||||
#endif
|
||||
|
||||
using namespace MobileGL;
|
||||
@@ -110,6 +115,392 @@ namespace {
|
||||
EXPECT_GT(MGPipeApplier().IndexBufferSerial, 43u);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !MOBILEGL_PIPE_PUSH
|
||||
// G2 requires the pull and push ctest name sets to be identical, name for name, so a
|
||||
// push-only case is present and SKIPS rather than being absent.
|
||||
#define MGL_VERTEX_INPUT_EMIT_TEST_LIST(X) \
|
||||
X(VertexInputEmit, EveryAttributeFieldSurvivesTheWireConversion) \
|
||||
X(VertexInputEmit, ABindingModelStrideOfZeroSurvivesAsZero) \
|
||||
X(VertexInputEmit, IsLongAndFloat64TravelSeparately) \
|
||||
X(VertexInputEmit, ABaseInstanceChangeAloneStillEmitsTheVertexBufferSet) \
|
||||
X(VertexInputEmit, AnUnchangedSetWithAnUnchangedBaseInstanceEmitsNothing) \
|
||||
X(VertexInputEmit, RebindingTheSameVaoEmitsABindAndNoCreate) \
|
||||
X(VertexInputEmit, PingPongingBetweenTwoVaosNeverRecreatesEither)
|
||||
|
||||
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
|
||||
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
|
||||
MGL_VERTEX_INPUT_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP)
|
||||
#undef MGL_DECLARE_PULL_SKIP
|
||||
#else
|
||||
using GLContext = MG_State::GLState::GLContext;
|
||||
using MG_State::GLState::BufferObject;
|
||||
using MG_State::GLState::VertexArrayObject;
|
||||
|
||||
// The emitters are driven DIRECTLY rather than through MGPipeValidateForVerb, and that
|
||||
// is the point: G6 is a statement about the conversion, and a case that went through the
|
||||
// validate point would also be testing the tracker's shutters, which have their own
|
||||
// suite. What is asserted is what the emitter handed the applier - on this tree the
|
||||
// applier's entry points are stubs, so the emitter's own staging buffers ARE the
|
||||
// emitted record, at no copy.
|
||||
//
|
||||
// AN RAII SCOPE RATHER THAN A gtest FIXTURE: both gates grep `ctest -R
|
||||
// 'VertexInputEmit\.'`, 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.
|
||||
struct EmitterScope {
|
||||
EmitterScope() {
|
||||
m_previousContext = Move(MG_State::pGLContext);
|
||||
MG_State::pGLContext = MakeUnique<GLContext>();
|
||||
MGPipeVertexInputEmitterInstance().Reset();
|
||||
MGPipeVertexInputEmitterInstance().ResetCounters();
|
||||
MGPipeSetHashSuppressorInstance().InvalidateAll();
|
||||
}
|
||||
~EmitterScope() {
|
||||
MG_State::pGLContext.reset();
|
||||
MG_State::pGLContext = Move(m_previousContext);
|
||||
MGPipeVertexInputEmitterInstance().Reset();
|
||||
MGPipeVertexInputEmitterInstance().ResetCounters();
|
||||
MGPipeSetHashSuppressorInstance().InvalidateAll();
|
||||
}
|
||||
EmitterScope(const EmitterScope&) = delete;
|
||||
EmitterScope& operator=(const EmitterScope&) = delete;
|
||||
|
||||
UniquePtr<GLContext> m_previousContext;
|
||||
};
|
||||
|
||||
GLContext& Ctx() { return *MG_State::pGLContext; }
|
||||
MGPipeVertexInputEmitter& Emitter() { return MGPipeVertexInputEmitterInstance(); }
|
||||
|
||||
const SharedPtr<VertexArrayObject>& MakeVao(Uint name) {
|
||||
Ctx().CreateVertexArrayObject(name);
|
||||
Ctx().BindVertexArray(name);
|
||||
return Ctx().GetBoundVertexArray();
|
||||
}
|
||||
|
||||
// ============================ G6 ============================
|
||||
//
|
||||
// "For every VAO configuration the emitted MGPVertexElements blob reproduces EXACTLY the
|
||||
// values the backend's VAO twin reads from the frontend today, field by field, for all 32
|
||||
// attribute slots."
|
||||
//
|
||||
// The oracle is the frontend attribute itself, read back through the same getter the twin
|
||||
// uses, so this cannot drift into asserting what the emitter happens to do. Every field is
|
||||
// its own EXPECT naming that field, which is what G7's scripted control needs: it stops
|
||||
// the conversion copying ONE member and expects this case to go red NAMING it.
|
||||
//
|
||||
// All three configuration families are driven, because they resolve differently and a
|
||||
// conversion that works for one is not evidence about the others: the legacy pointer
|
||||
// entry points (which resolve a 0 stride to the element size before it ever reaches the
|
||||
// wire), the ARB_vertex_attrib_binding entry points (where a 0 stride means the opposite
|
||||
// and must survive), and the enable/disable switch.
|
||||
TEST(VertexInputEmit, EveryAttributeFieldSurvivesTheWireConversion) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
|
||||
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
|
||||
buffer->Respecify(4096, nullptr);
|
||||
|
||||
constexpr int kAttribs = VertexArrayObject::MAX_VERTEX_ATTRIBS;
|
||||
const DataType kTypes[] = {DataType::Float32, DataType::Int16, DataType::Uint8,
|
||||
DataType::Int32, DataType::Float64, DataType::Uint2101010Rev};
|
||||
for (int i = 0; i < kAttribs; ++i) {
|
||||
const auto index = static_cast<Uint>(i);
|
||||
const DataType type = kTypes[i % 6];
|
||||
const int size = 1 + (i % 4);
|
||||
const Bool normalized = (i % 3) == 0;
|
||||
const Bool isInteger = (i % 5) == 0;
|
||||
if (i < 12) {
|
||||
// The legacy pointer family: a raw stride, an effective stride and a pointer
|
||||
// offset, all three distinct so a conversion that took the wrong one fails.
|
||||
vao->SetAttributeFormat(index, size, type, normalized, 16 + i, static_cast<SizeT>(64 + i * 4),
|
||||
isInteger, false, 32 + i);
|
||||
vao->MirrorPointerIntoBinding(index, buffer, static_cast<SizeT>(64 + i * 4), 32 + i);
|
||||
vao->BindAttributeBuffer(index, buffer);
|
||||
vao->SetAttributeDivisor(index, static_cast<Uint>(i % 3));
|
||||
} else if (i < 24) {
|
||||
// The binding-model family, with the attribute deliberately fed by a DIFFERENT
|
||||
// binding index than its own - which is the one thing MGPVertexAttribWire::
|
||||
// BindingIndex exists to carry and the one an identity mapping would hide.
|
||||
const Uint binding = static_cast<Uint>((i + 5) % kAttribs);
|
||||
vao->SetAttributeFormatSeparate(index, size, type, normalized, isInteger,
|
||||
static_cast<Uint>(8 * (i % 4)), false, type == DataType::Float64);
|
||||
vao->SetAttributeBinding(index, binding);
|
||||
vao->SetBindingBuffer(binding, buffer, static_cast<SizeT>(128 + i), 48 + i);
|
||||
vao->SetBindingDivisor(binding, static_cast<Uint>(i % 2));
|
||||
} else {
|
||||
// GL_BGRA keeps size 4 and is its own flag; the disabled tail proves Enabled
|
||||
// travels rather than being implied by "has a format".
|
||||
vao->SetAttributeFormat(index, 4, DataType::Uint8, true, 0, static_cast<SizeT>(i), false, true, -1);
|
||||
}
|
||||
if ((i % 2) == 0) {
|
||||
vao->EnableAttribute(index);
|
||||
} else {
|
||||
vao->DisableAttribute(index);
|
||||
}
|
||||
}
|
||||
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u) << "a fresh VAO must publish a create";
|
||||
ASSERT_EQ(Emitter().CreateCount(), 1u);
|
||||
EXPECT_EQ(Emitter().LastElements().AttributeCount, static_cast<Uint32>(kAttribs));
|
||||
EXPECT_EQ(Emitter().LastElements().BindingPointCount,
|
||||
static_cast<Uint32>(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS));
|
||||
EXPECT_EQ(Emitter().LastElements().Blob.Size,
|
||||
static_cast<Uint64>(kAttribs) * sizeof(MGPVertexAttribWire) +
|
||||
static_cast<Uint64>(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS) *
|
||||
sizeof(MGPVertexBindingPointWire))
|
||||
<< "the declared counts must describe the blob's declared size, or the applier refuses it";
|
||||
|
||||
for (int i = 0; i < kAttribs; ++i) {
|
||||
const auto index = static_cast<Uint>(i);
|
||||
const auto& attrib = vao->GetAttribute(index);
|
||||
const MGPVertexAttribWire& wire = Emitter().LastAttributes()[static_cast<SizeT>(i)];
|
||||
SCOPED_TRACE(::testing::Message() << "attribute " << i);
|
||||
EXPECT_EQ(wire.Offset, static_cast<Uint64>(attrib.Offset));
|
||||
EXPECT_EQ(wire.Stride, static_cast<Int32>(attrib.Stride));
|
||||
EXPECT_EQ(wire.Type, static_cast<Uint32>(attrib.Type));
|
||||
EXPECT_EQ(wire.Size, static_cast<Uint8>(attrib.Size));
|
||||
EXPECT_EQ(wire.Enabled, attrib.Enabled ? 1 : 0);
|
||||
EXPECT_EQ(wire.Normalized, attrib.Normalized ? 1 : 0);
|
||||
EXPECT_EQ(wire.IsInteger, attrib.IsInteger ? 1 : 0);
|
||||
EXPECT_EQ(wire.IsLong, attrib.IsLong ? 1 : 0);
|
||||
EXPECT_EQ(wire.IsBgra, attrib.IsBgra ? 1 : 0);
|
||||
EXPECT_EQ(wire.BindingIndex, static_cast<Uint8>(vao->GetAttributeBindingIndex(index)));
|
||||
EXPECT_EQ(wire.Pad0, 0u) << "padding must stay padding";
|
||||
}
|
||||
|
||||
for (int b = 0; b < VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS; ++b) {
|
||||
const auto& point = vao->GetBindingPoint(static_cast<Uint>(b));
|
||||
const MGPVertexBindingPointWire& wire = Emitter().LastBindingPoints()[static_cast<SizeT>(b)];
|
||||
SCOPED_TRACE(::testing::Message() << "binding point " << b);
|
||||
EXPECT_EQ(wire.Offset, static_cast<Uint64>(point.Offset));
|
||||
EXPECT_EQ(wire.Stride, static_cast<Int32>(point.Stride));
|
||||
EXPECT_EQ(wire.Divisor, static_cast<Uint32>(point.Divisor));
|
||||
}
|
||||
|
||||
// The divisor is NOT in the attribute view - it is resolved per binding point and
|
||||
// travels in MGPVertexBuffer::Divisor, which is where the backend reads it. Asserted
|
||||
// here rather than left to a reader of the struct, because carrying it twice is
|
||||
// exactly how a malformed record comes to disagree with itself.
|
||||
Emitter().EmitVertexBuffers(Ctx(), 0);
|
||||
for (Uint32 i = 0; i < Emitter().LastVertexBuffers().Count; ++i) {
|
||||
const auto& attrib = vao->GetAttribute(i);
|
||||
const MGPVertexBuffer& entry = Emitter().LastEntries()[i];
|
||||
SCOPED_TRACE(::testing::Message() << "vertex buffer entry " << i);
|
||||
EXPECT_EQ(entry.Divisor, static_cast<Uint32>(attrib.Divisor));
|
||||
EXPECT_EQ(entry.Stride, static_cast<Uint32>(attrib.Stride));
|
||||
EXPECT_EQ(entry.BindingIndex, i);
|
||||
EXPECT_EQ(entry.Offset, 0u) << "the attribute's own byte offset lives in the wire attribute";
|
||||
}
|
||||
}
|
||||
|
||||
// KHR-GL43.vertex_attrib_binding.basic-input-case7/8: a pointer call's stride 0 means
|
||||
// "tightly packed" and the frontend already resolved it to the element size, so a zero
|
||||
// that reaches the wire can only have come from the binding model - where it means every
|
||||
// vertex reads the SAME element and the fetch address never advances. Collapsing it back
|
||||
// into the element size is what made those two cases read past the buffer.
|
||||
TEST(VertexInputEmit, ABindingModelStrideOfZeroSurvivesAsZero) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
|
||||
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
|
||||
buffer->Respecify(256, nullptr);
|
||||
|
||||
vao->SetAttributeFormatSeparate(0, 4, DataType::Float32, false, false, 0);
|
||||
vao->SetAttributeBinding(0, 0);
|
||||
vao->SetBindingBuffer(0, buffer, 0, 0); // the binding model's zero
|
||||
vao->EnableAttribute(0);
|
||||
|
||||
// The control, on the SAME emission: a pointer-style zero was already resolved to the
|
||||
// tightly packed element size by the GL entry point (which is what the effective
|
||||
// stride argument carries), so it must NOT reach the wire as a zero. The raw argument
|
||||
// stays 0 and is reported verbatim by glGetVertexAttribiv - which is exactly why the
|
||||
// two are stored apart and only the resolved one travels.
|
||||
vao->SetAttributeFormat(1, 4, DataType::Float32, false, 0, 0, false, false, 16);
|
||||
vao->MirrorPointerIntoBinding(1, buffer, 0, 16);
|
||||
vao->BindAttributeBuffer(1, buffer);
|
||||
vao->EnableAttribute(1);
|
||||
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
ASSERT_EQ(vao->GetAttribute(0).Stride, 0) << "the frontend itself no longer resolves this to zero";
|
||||
EXPECT_EQ(Emitter().LastAttributes()[0].Stride, 0)
|
||||
<< "a binding-model stride of 0 was collapsed into the element size";
|
||||
EXPECT_EQ(Emitter().LastBindingPoints()[0].Stride, 0);
|
||||
EXPECT_NE(Emitter().LastAttributes()[1].Stride, 0) << "a resolved pointer stride reached the wire as 0";
|
||||
EXPECT_EQ(Emitter().LastAttributes()[1].Stride, static_cast<Int32>(vao->GetAttribute(1).Stride));
|
||||
EXPECT_EQ(vao->GetAttribute(1).LegacyStride, 0) << "the raw query answer is not the resolved one";
|
||||
|
||||
Emitter().EmitVertexBuffers(Ctx(), 0);
|
||||
EXPECT_EQ(Emitter().LastEntries()[0].Stride, 0u) << "and the set has to agree with the format";
|
||||
}
|
||||
|
||||
// VertexAttribFormat(GL_DOUBLE) reads doubles from memory and asks for them CONVERTED to
|
||||
// float; VertexAttribLFormat keeps all 64 bits. The backend's fp64 narrowing and its
|
||||
// Adreno disabled-attribute workaround both key on telling the two apart, so IsLong may
|
||||
// never be inferred from Type == Float64.
|
||||
TEST(VertexInputEmit, IsLongAndFloat64TravelSeparately) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
|
||||
// Attribute 0: GL_DOUBLE, converted to float. Attribute 1: the same type, kept long.
|
||||
vao->SetAttributeFormatSeparate(0, 4, DataType::Float64, false, false, 0, false, false);
|
||||
vao->SetAttributeFormatSeparate(1, 4, DataType::Float64, false, false, 0, false, true);
|
||||
// Attribute 2: NOT a double, and not long either - so "IsLong implies Float64" is
|
||||
// asserted in both directions.
|
||||
vao->SetAttributeFormatSeparate(2, 4, DataType::Float32, false, false, 0, false, false);
|
||||
vao->EnableAttribute(0);
|
||||
vao->EnableAttribute(1);
|
||||
vao->EnableAttribute(2);
|
||||
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
const auto& wires = Emitter().LastAttributes();
|
||||
EXPECT_EQ(wires[0].Type, static_cast<Uint32>(DataType::Float64));
|
||||
EXPECT_EQ(wires[0].IsLong, 0) << "a converted double must not travel as long";
|
||||
EXPECT_EQ(wires[1].Type, static_cast<Uint32>(DataType::Float64));
|
||||
EXPECT_EQ(wires[1].IsLong, 1) << "an L-format double lost its long flag";
|
||||
EXPECT_EQ(wires[2].Type, static_cast<Uint32>(DataType::Float32));
|
||||
EXPECT_EQ(wires[2].IsLong, 0);
|
||||
// And the frontend agrees, so this is not the emitter asserting its own answer.
|
||||
EXPECT_EQ(vao->GetAttribute(0).IsLong, false);
|
||||
EXPECT_EQ(vao->GetAttribute(1).IsLong, true);
|
||||
}
|
||||
|
||||
// D-H2.3, THE SUPPRESSOR TRAP. set_vertex_buffers is suppressed on an unchanged content
|
||||
// hash. The base instance is DRAW state and moves without the buffer set moving, so a
|
||||
// hash that did not include it would suppress the one record whose changed field is the
|
||||
// fetch shift, and the server would keep the previous one - silently wrong geometry on
|
||||
// instanced draws, and no desktop SSIM case need exercise it.
|
||||
TEST(VertexInputEmit, ABaseInstanceChangeAloneStillEmitsTheVertexBufferSet) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
|
||||
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
|
||||
buffer->Respecify(256, nullptr);
|
||||
vao->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
|
||||
vao->BindAttributeBuffer(0, buffer);
|
||||
vao->SetAttributeDivisor(0, 1);
|
||||
vao->EnableAttribute(0);
|
||||
|
||||
ASSERT_GT(Emitter().EmitVertexBuffers(Ctx(), 0), 0u) << "the first set always goes out";
|
||||
ASSERT_EQ(Emitter().VertexBufferSetCount(), 1u);
|
||||
const Uint64 firstHash = Emitter().LastVertexBuffers().ContentHash;
|
||||
EXPECT_EQ(Emitter().LastVertexBuffers().BaseInstance, 0u);
|
||||
|
||||
// NOTHING about the buffer set changed; only the draw's base instance.
|
||||
EXPECT_GT(Emitter().EmitVertexBuffers(Ctx(), 7), 0u)
|
||||
<< "a base-instance-only change was suppressed - it is not in the content hash";
|
||||
EXPECT_EQ(Emitter().VertexBufferSetCount(), 2u);
|
||||
EXPECT_EQ(Emitter().LastVertexBuffers().BaseInstance, 7u)
|
||||
<< "the RAW value the draw carried, never a pre-shifted offset";
|
||||
EXPECT_NE(Emitter().LastVertexBuffers().ContentHash, firstHash);
|
||||
|
||||
// And back to zero is a change too - which is what makes a plain draw after a
|
||||
// base-instanced one undo the shift.
|
||||
EXPECT_GT(Emitter().EmitVertexBuffers(Ctx(), 0), 0u);
|
||||
EXPECT_EQ(Emitter().LastVertexBuffers().BaseInstance, 0u);
|
||||
EXPECT_EQ(Emitter().LastVertexBuffers().ContentHash, firstHash)
|
||||
<< "the hash is a function of the set and the base instance, so it has to come back";
|
||||
}
|
||||
|
||||
// The counterpart, and the reason the suppressor exists at all: an unchanged set with an
|
||||
// unchanged base instance is not a record worth sending, and the slot must say so.
|
||||
TEST(VertexInputEmit, AnUnchangedSetWithAnUnchangedBaseInstanceEmitsNothing) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
|
||||
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
|
||||
buffer->Respecify(256, nullptr);
|
||||
vao->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
|
||||
vao->BindAttributeBuffer(0, buffer);
|
||||
vao->EnableAttribute(0);
|
||||
|
||||
ASSERT_GT(Emitter().EmitVertexBuffers(Ctx(), 3), 0u);
|
||||
ASSERT_EQ(Emitter().VertexBufferSetCount(), 1u);
|
||||
const Uint64 latched =
|
||||
MGPipeSetHashSuppressorInstance().LastEmitted(MGPipeSuppressorSlot::SetVertexBuffers);
|
||||
EXPECT_NE(latched, 0u) << "0 is reserved for 'never emitted'";
|
||||
|
||||
EXPECT_EQ(Emitter().EmitVertexBuffers(Ctx(), 3), 0u) << "an unchanged set went out again";
|
||||
EXPECT_EQ(Emitter().VertexBufferSetCount(), 1u);
|
||||
EXPECT_EQ(MGPipeSetHashSuppressorInstance().LastEmitted(MGPipeSuppressorSlot::SetVertexBuffers),
|
||||
latched);
|
||||
|
||||
// A real change to the SET still goes out with the same base instance, so the
|
||||
// suppression above is not simply "this slot is stuck".
|
||||
vao->SetAttributeDivisor(0, 4);
|
||||
EXPECT_GT(Emitter().EmitVertexBuffers(Ctx(), 3), 0u);
|
||||
EXPECT_EQ(Emitter().VertexBufferSetCount(), 2u);
|
||||
}
|
||||
|
||||
// D-G3's per-handle latch. create_vertex_elements is re-issued on the SAME handle when a
|
||||
// configuration moves, and the latch is stored per handle rather than globally so that
|
||||
// rebinding cannot look like a configuration change.
|
||||
TEST(VertexInputEmit, RebindingTheSameVaoEmitsABindAndNoCreate) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> a = MakeVao(1);
|
||||
a->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
|
||||
a->EnableAttribute(0);
|
||||
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
EXPECT_EQ(Emitter().CreateCount(), 1u);
|
||||
EXPECT_EQ(Emitter().BindCount(), 1u);
|
||||
|
||||
// Same VAO, same configuration: nothing at all.
|
||||
EXPECT_EQ(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
EXPECT_EQ(Emitter().CreateCount(), 1u);
|
||||
EXPECT_EQ(Emitter().BindCount(), 1u);
|
||||
|
||||
// Away and back. The bind is re-emitted because the server's bound handle moved; the
|
||||
// create is not, because this handle already published this configuration.
|
||||
MakeVao(2);
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
EXPECT_EQ(Emitter().CreateCount(), 2u);
|
||||
EXPECT_EQ(Emitter().BindCount(), 2u);
|
||||
|
||||
Ctx().BindVertexArray(1);
|
||||
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
EXPECT_EQ(Emitter().CreateCount(), 2u) << "a rebind re-created a configuration that had not moved";
|
||||
EXPECT_EQ(Emitter().BindCount(), 3u);
|
||||
|
||||
// A configuration change on the BOUND VAO re-creates on the same handle and does NOT
|
||||
// rebind: the server's bound handle did not move.
|
||||
const MGPipeHandle bound = Emitter().BoundHandle();
|
||||
a->SetAttributeFormat(1, 2, DataType::Int16, true, 8, 4, true);
|
||||
a->EnableAttribute(1);
|
||||
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
EXPECT_EQ(Emitter().CreateCount(), 3u);
|
||||
EXPECT_EQ(Emitter().BindCount(), 3u) << "a re-create must not rebind";
|
||||
EXPECT_EQ(Emitter().LastElements().Cso, bound) << "and it must land on the SAME handle";
|
||||
}
|
||||
|
||||
// The latch is per handle, so alternating between two VAOs re-binds and never re-creates.
|
||||
// A global latch would re-create both on every swap - strictly more work than the tree
|
||||
// does today, which is the trade D-G1's identity-addressed CSO exists to avoid.
|
||||
TEST(VertexInputEmit, PingPongingBetweenTwoVaosNeverRecreatesEither) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> a = MakeVao(1);
|
||||
a->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
|
||||
a->EnableAttribute(0);
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
|
||||
const SharedPtr<VertexArrayObject> b = MakeVao(2);
|
||||
b->SetAttributeFormat(0, 2, DataType::Int16, true, 8, 4, true);
|
||||
b->EnableAttribute(0);
|
||||
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
ASSERT_EQ(Emitter().CreateCount(), 2u);
|
||||
const MGPipeHandle handleB = Emitter().BoundHandle();
|
||||
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
Ctx().BindVertexArray(1);
|
||||
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
Ctx().BindVertexArray(2);
|
||||
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
|
||||
}
|
||||
EXPECT_EQ(Emitter().CreateCount(), 2u) << "ping-ponging re-created a VAO's configuration";
|
||||
EXPECT_EQ(Emitter().BindCount(), 2u + 16u);
|
||||
EXPECT_EQ(Emitter().BoundHandle(), handleB) << "the two VAOs swapped handles";
|
||||
|
||||
// Unbinding entirely publishes the null handle, once.
|
||||
Ctx().BindVertexArray(0);
|
||||
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u) << "the default VAO is a VAO and has a handle";
|
||||
EXPECT_EQ(Emitter().CreateCount(), 3u);
|
||||
}
|
||||
#endif // MOBILEGL_PIPE_PUSH
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
|
||||
Reference in New Issue
Block a user