mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
Pipe: per-framebuffer state memo, object-held handle hints, per-buffer handle reuse and a cached per-verb gate (ValidateForVerb 0.328 -> 0.282 ms/frame; FCL GL CPU Magma 2.68 -> 2.62, Espryt 3.03 -> 3.00; red-once per piece in its unit case).
This commit is contained in:
@@ -148,6 +148,9 @@ namespace MobileGL::MG_Config {
|
||||
// record is byte-identical to the one its handle last published (a glBindVertexBuffer
|
||||
// that only changed the buffer) re-publishes nothing. Auto is ON; ForceOff re-creates.
|
||||
QuirkOverride PipeVertexElementsDedup = QuirkOverride::Auto;
|
||||
// P15: the client reuses a bound framebuffer's built set_framebuffer_state while nothing it
|
||||
// reads has moved (MGPipeFramebufferEmitter::BuildBoundFramebufferState).
|
||||
QuirkOverride PipeFramebufferStateMemo = QuirkOverride::Auto;
|
||||
// MOBILEGL_ADVERTISE_FP64: add GL_ARB_gpu_shader_fp64 to the advertised extension
|
||||
// string. `double` in a shader always WORKS - it is narrowed to 32 bits before any
|
||||
// module reaches a backend (ShaderTranspiler::DemoteFloat64Pass) - but the extension
|
||||
|
||||
@@ -374,6 +374,7 @@ namespace MobileGL::MG_ConfigLoader {
|
||||
features.MagmaWriteRename = QueryEnvQuirkOverride("MOBILEGL_MAGMA_WRITE_RENAME");
|
||||
features.EsprytVaoShadow = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_VAO_SHADOW");
|
||||
features.PipeVertexElementsDedup = QueryEnvQuirkOverride("MOBILEGL_PIPE_VERTEX_ELEMENTS_DEDUP");
|
||||
features.PipeFramebufferStateMemo = QueryEnvQuirkOverride("MOBILEGL_PIPE_FRAMEBUFFER_STATE_MEMO");
|
||||
features.AdvertiseFp64 = QueryEnvFlag("MOBILEGL_ADVERTISE_FP64");
|
||||
features.MagmaR11G11B10FFallback = QueryEnvFlag("MOBILEGL_MAGMA_R11G11B10F_FALLBACK");
|
||||
features.MagmaAllowInvalidSpirv = QueryEnvFlag("MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV");
|
||||
|
||||
@@ -275,13 +275,13 @@ namespace MobileGL::MG_Pipe {
|
||||
Bool drawOk = false;
|
||||
Bool readOk = false;
|
||||
if (shared) {
|
||||
drawOk = BuildFramebufferState(*drawFbo, MGPipeFramebufferTarget::Both, drawState);
|
||||
drawOk = BuildBoundFramebufferState(*drawFbo, MGPipeFramebufferTarget::Both, drawState);
|
||||
} else {
|
||||
if (drawFbo) {
|
||||
drawOk = BuildFramebufferState(*drawFbo, MGPipeFramebufferTarget::Draw, drawState);
|
||||
drawOk = BuildBoundFramebufferState(*drawFbo, MGPipeFramebufferTarget::Draw, drawState);
|
||||
}
|
||||
if (readFbo) {
|
||||
readOk = BuildFramebufferState(*readFbo, MGPipeFramebufferTarget::Read, readState);
|
||||
readOk = BuildBoundFramebufferState(*readFbo, MGPipeFramebufferTarget::Read, readState);
|
||||
}
|
||||
}
|
||||
if (!drawOk && !readOk) return 0;
|
||||
@@ -414,6 +414,8 @@ namespace MobileGL::MG_Pipe {
|
||||
const MGPFramebufferState& LastNamed() const { return m_lastNamed; }
|
||||
Uint64 EmissionCount() const { return m_emissions; }
|
||||
Uint64 RefusedCount() const { return m_refusals; }
|
||||
// P15: bound-target builds answered from the memo (BuildBoundFramebufferState).
|
||||
Uint64 MemoHitCount() const { return m_memoHits; }
|
||||
|
||||
// A fresh context: what the server has is no longer what this emitter last sent. Only
|
||||
// LATCHES reset here - MGPipeApplierReset clears the applier's DrawFramebuffer and
|
||||
@@ -438,6 +440,7 @@ namespace MobileGL::MG_Pipe {
|
||||
void ResetForTest() {
|
||||
Reset();
|
||||
ResetCounters();
|
||||
for (auto& memo : m_buildMemo) memo.Valid = false;
|
||||
m_lastDraw = MGPFramebufferState{};
|
||||
m_lastRead = MGPFramebufferState{};
|
||||
m_lastNamed = MGPFramebufferState{};
|
||||
@@ -563,6 +566,40 @@ namespace MobileGL::MG_Pipe {
|
||||
return true;
|
||||
}
|
||||
|
||||
// BuildFramebufferState for a BOUND target, reusing the last record built for the same
|
||||
// framebuffer and target while nothing the build reads has moved. The build runs per draw
|
||||
// and its answer is a function of:
|
||||
// - the framebuffer: lifetime id (identity) and object version (attachments, draw and
|
||||
// read buffers, no-attachment defaults all move it);
|
||||
// - each attached texture: lifetime id, shape version (sizes, level set, internal
|
||||
// format: completeness and geometry) and its samples / fixed-locations pair, which
|
||||
// move the params version rather than the shape;
|
||||
// - each attached renderbuffer: lifetime id plus its size, format and samples, read
|
||||
// directly (its storage has no version);
|
||||
// - the handles it minted: the slot allocator's reset epoch, and the texture emitter's
|
||||
// table epoch for the sticky bind bits SurfaceOf notes (a dropped table must be
|
||||
// re-noted).
|
||||
// Not for the default framebuffer, whose geometry follows the current surface.
|
||||
// Kill switch MOBILEGL_PIPE_FRAMEBUFFER_STATE_MEMO=0.
|
||||
Bool BuildBoundFramebufferState(const FramebufferObject& fbo, MGPipeFramebufferTarget target,
|
||||
MGPFramebufferState& out) {
|
||||
const Bool memoOn = MG_Config::Features.PipeFramebufferStateMemo != MG_Config::QuirkOverride::ForceOff &&
|
||||
!fbo.IsDefaultFramebuffer();
|
||||
// Direct-mapped on (framebuffer, target): the build only runs when the bound framebuffer
|
||||
// changed, so a per-target single entry would miss on every A/B/A switch between targets.
|
||||
const Uint64 key = fbo.GetLifetimeId() * 4u + static_cast<Uint64>(target);
|
||||
BuildMemo& memo = m_buildMemo[static_cast<SizeT>((key * 0x9E3779B97F4A7C15ull) >> 60) % m_buildMemo.size()];
|
||||
if (memoOn && memo.Valid && MemoHolds(memo, fbo, target)) {
|
||||
out = memo.State;
|
||||
++m_memoHits;
|
||||
return true;
|
||||
}
|
||||
memo.Valid = false;
|
||||
if (!BuildFramebufferState(fbo, target, out)) return false;
|
||||
if (memoOn) FillMemo(memo, fbo, target, out);
|
||||
return true;
|
||||
}
|
||||
|
||||
static Bool IsBoundTo(const FramebufferObject& fbo, MobileGL::FramebufferTarget target) {
|
||||
if (MG_State::pGLContext == nullptr) return false;
|
||||
const auto& bound = MG_State::pGLContext->GetFramebufferBindingSlot(target).GetBoundObject();
|
||||
@@ -642,6 +679,96 @@ namespace MobileGL::MG_Pipe {
|
||||
out.FixedSampleLocations = fbo.GetDefaultFixedSampleLocations() ? 1 : 0;
|
||||
}
|
||||
|
||||
struct MemoAttachment {
|
||||
Uint32 Point = 0;
|
||||
Bool IsTexture = false;
|
||||
Uint64 LifetimeId = 0;
|
||||
Uint64 ShapeVersion = 0;
|
||||
Int Width = 0, Height = 0, Samples = 0;
|
||||
Uint32 Format = 0;
|
||||
Bool FixedSampleLocations = false;
|
||||
};
|
||||
struct BuildMemo {
|
||||
Bool Valid = false;
|
||||
Uint8 Target = 0;
|
||||
Uint16 ObjectVersion = 0;
|
||||
Uint64 FboLifetimeId = 0;
|
||||
Uint64 AllocatorEpoch = 0;
|
||||
Uint64 TextureTableEpoch = 0;
|
||||
Uint32 AttachmentCount = 0;
|
||||
Array<MemoAttachment, static_cast<SizeT>(FramebufferAttachmentType::FramebufferAttachmentTypeCount)>
|
||||
Attachments{};
|
||||
MGPFramebufferState State{};
|
||||
};
|
||||
|
||||
static Bool AttachmentMatches(const MemoAttachment& entry,
|
||||
const MG_State::GLState::FramebufferAttachmentObject& attachment) {
|
||||
if (attachment.IsEmpty() || attachment.IsTexture() != entry.IsTexture) return false;
|
||||
if (entry.IsTexture) {
|
||||
const auto& texture = attachment.GetTexture();
|
||||
return texture->GetLifetimeId() == entry.LifetimeId &&
|
||||
texture->GetShapeVersion() == entry.ShapeVersion &&
|
||||
texture->GetSamples() == entry.Samples &&
|
||||
texture->HasFixedSampleLocations() == entry.FixedSampleLocations;
|
||||
}
|
||||
const auto& renderbuffer = attachment.GetRenderbuffer();
|
||||
return renderbuffer->GetLifetimeId() == entry.LifetimeId && renderbuffer->GetWidth() == entry.Width &&
|
||||
renderbuffer->GetHeight() == entry.Height && renderbuffer->GetSamples() == entry.Samples &&
|
||||
static_cast<Uint32>(renderbuffer->GetInternalFormat()) == entry.Format;
|
||||
}
|
||||
|
||||
Bool MemoHolds(const BuildMemo& memo, const FramebufferObject& fbo, MGPipeFramebufferTarget target) const {
|
||||
if (memo.Target != static_cast<Uint8>(target) || memo.FboLifetimeId != fbo.GetLifetimeId() ||
|
||||
memo.ObjectVersion != fbo.GetObjectVersion() || memo.AllocatorEpoch != MGPipeSlots().ResetEpoch() ||
|
||||
memo.TextureTableEpoch != MGPipeTextureEmitterInstance().TableEpoch()) {
|
||||
return false;
|
||||
}
|
||||
const auto& all = fbo.GetAllAttachmentObjects();
|
||||
for (Uint32 i = 0; i < memo.AttachmentCount; ++i) {
|
||||
const MemoAttachment& entry = memo.Attachments[i];
|
||||
if (!AttachmentMatches(entry, all[entry.Point])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void FillMemo(BuildMemo& memo, const FramebufferObject& fbo, MGPipeFramebufferTarget target,
|
||||
const MGPFramebufferState& state) {
|
||||
memo.Target = static_cast<Uint8>(target);
|
||||
memo.FboLifetimeId = fbo.GetLifetimeId();
|
||||
memo.ObjectVersion = fbo.GetObjectVersion();
|
||||
memo.AllocatorEpoch = MGPipeSlots().ResetEpoch();
|
||||
memo.TextureTableEpoch = MGPipeTextureEmitterInstance().TableEpoch();
|
||||
memo.AttachmentCount = 0;
|
||||
const auto& all = fbo.GetAllAttachmentObjects();
|
||||
for (SizeT point = 0; point < all.size(); ++point) {
|
||||
const auto& attachment = all[point];
|
||||
if (attachment.IsEmpty()) continue;
|
||||
MemoAttachment& entry = memo.Attachments[memo.AttachmentCount++];
|
||||
entry = MemoAttachment{};
|
||||
entry.Point = static_cast<Uint32>(point);
|
||||
entry.IsTexture = attachment.IsTexture();
|
||||
if (entry.IsTexture) {
|
||||
const auto& texture = attachment.GetTexture();
|
||||
entry.LifetimeId = texture->GetLifetimeId();
|
||||
entry.ShapeVersion = texture->GetShapeVersion();
|
||||
entry.Samples = texture->GetSamples();
|
||||
entry.FixedSampleLocations = texture->HasFixedSampleLocations();
|
||||
} else {
|
||||
const auto& renderbuffer = attachment.GetRenderbuffer();
|
||||
entry.LifetimeId = renderbuffer->GetLifetimeId();
|
||||
entry.Width = renderbuffer->GetWidth();
|
||||
entry.Height = renderbuffer->GetHeight();
|
||||
entry.Samples = renderbuffer->GetSamples();
|
||||
entry.Format = static_cast<Uint32>(renderbuffer->GetInternalFormat());
|
||||
}
|
||||
}
|
||||
memo.State = state;
|
||||
memo.Valid = true;
|
||||
}
|
||||
|
||||
// Direct-mapped on (framebuffer lifetime id, target); Named never reaches it.
|
||||
Array<BuildMemo, 16> m_buildMemo{};
|
||||
Uint64 m_memoHits = 0;
|
||||
Array<Uint64, 2> m_lastEmitted{};
|
||||
// The per-FRAMEBUFFER suppressor for Named records, slot-indexed with the generation
|
||||
// checked, exactly as the applier's own table is. A framebuffer has no wire lifetime
|
||||
|
||||
@@ -2640,6 +2640,44 @@ namespace MobileGL::MG_Pipe {
|
||||
// consumer mask gains bit 13 in the same
|
||||
// commit, or R-8 withholds the whole family.
|
||||
kMGPipeWiredBufferBindingSubsystem;
|
||||
|
||||
// P15: the dirty bits whose family this build, push mask and consumer emit - every
|
||||
// condition of the per-bit gate in MGPipeValidateForVerb except the dirty bit itself.
|
||||
Uint32 ComputeLiveDirtyBits(Uint64 pushMask) {
|
||||
Uint32 live = 0;
|
||||
for (Uint32 b = 0; b < kMGPipeDirtyCount; ++b) {
|
||||
const Uint64 subsystem = MGPipeSubsystemForDirty(static_cast<MGPipeDirty>(b));
|
||||
if (subsystem != 0 && (pushMask & subsystem) != 0 && (kMGPipeWiredSubsystems & subsystem) != 0 &&
|
||||
P4aFamilyHasItsConsumer(subsystem) && P4aFamilyDependenciesAreSet(subsystem, pushMask) &&
|
||||
P5eFamilyIsLive(subsystem, pushMask)) {
|
||||
live |= MGPipeDirtyBit(static_cast<MGPipeDirty>(b));
|
||||
}
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
// Per verb, these conditions used to be re-asked for every bit (~15 consumer-registration
|
||||
// and dependency-table walks per draw). In monolith their only inputs are the push mask and
|
||||
// the registered consumer, so the answer is kept until either moves. Under a transport the
|
||||
// caps mirror answers and can move with a new session, so it is recomputed every verb.
|
||||
Uint32 LiveDirtyBits(Uint64 pushMask) {
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
if (MG_Config::Transport != MG_Config::TransportMode::Monolith) return ComputeLiveDirtyBits(pushMask);
|
||||
#endif
|
||||
static Bool valid = false;
|
||||
static Uint64 cachedPushMask = 0;
|
||||
static const MGPipeResourceOps* cachedConsumer = nullptr;
|
||||
static Uint32 cachedLive = 0;
|
||||
const MGPipeResourceOps* consumer = MGPipeGetResourceOps();
|
||||
if (!valid || cachedPushMask != pushMask || cachedConsumer != consumer) {
|
||||
cachedLive = ComputeLiveDirtyBits(pushMask);
|
||||
cachedPushMask = pushMask;
|
||||
cachedConsumer = consumer;
|
||||
valid = true;
|
||||
}
|
||||
return cachedLive;
|
||||
}
|
||||
|
||||
// Each family constant is either 0 or its own subsystem bit and nothing else. Without
|
||||
// this a header that set the wrong constant - the sampler bit in the program header,
|
||||
// say - would switch the wrong family on and every gate would still pass.
|
||||
@@ -3621,15 +3659,8 @@ namespace MobileGL::MG_Pipe {
|
||||
// binding-point family and asks bit 13's OWN consumer bit rather than the resource
|
||||
// family's. It answers true for every subsystem but bit 13, so nothing that emitted
|
||||
// before P5e changes.
|
||||
const auto wants = [&](MGPipeDirty bit) {
|
||||
const Uint64 subsystem = MGPipeSubsystemForDirty(bit);
|
||||
return subsystem != 0 && (pushMask & subsystem) != 0 &&
|
||||
(kMGPipeWiredSubsystems & subsystem) != 0 &&
|
||||
P4aFamilyHasItsConsumer(subsystem) &&
|
||||
P4aFamilyDependenciesAreSet(subsystem, pushMask) &&
|
||||
P5eFamilyIsLive(subsystem, pushMask) &&
|
||||
(dirty & MGPipeDirtyBit(bit)) != 0;
|
||||
};
|
||||
const Uint32 liveDirty = LiveDirtyBits(pushMask) & dirty;
|
||||
const auto wants = [&](MGPipeDirty bit) { return (liveDirty & MGPipeDirtyBit(bit)) != 0; };
|
||||
Uint64 payloadBytes = 0;
|
||||
|
||||
// A fresh context is a fresh server, and that is true of EVERY subsystem, so it is
|
||||
|
||||
@@ -663,7 +663,8 @@ namespace MobileGL::MG_Pipe {
|
||||
// composite.
|
||||
MGPipeHandle AcquireShaderCsoHandle(const ProgramObject& program) {
|
||||
const Uint64 lifetimeId = program.GetLifetimeId();
|
||||
const MGPipeHandle existing = MGPipeSlots().FindByLifetimeId(MGPipeKind::ShaderCso, lifetimeId);
|
||||
const MGPipeHandle existing =
|
||||
MGPipeSlots().FindByLifetimeIdHinted(MGPipeKind::ShaderCso, lifetimeId, program.PipeHandleHint());
|
||||
if (!MGPipeHandleIsNull(existing)) return existing;
|
||||
// A composite is minted off ITS OWN lifetime id, out of the reserved band, and is
|
||||
// an ordinary ShaderCso handle in every other respect - the same kind, the same
|
||||
|
||||
@@ -281,7 +281,8 @@ namespace MobileGL::MG_Pipe {
|
||||
// gating it would make the vertex-input subsystem emit null handles whenever the
|
||||
// resource subsystem is off. Only the CALLS are gated (D-A1).
|
||||
MGPipeHandle Acquire(BufferObject& buffer) {
|
||||
const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Buffer, buffer.GetLifetimeId());
|
||||
const MGPipeHandle handle =
|
||||
MGPipeSlots().AcquireHinted(MGPipeKind::Buffer, buffer.GetLifetimeId(), buffer.PipeHandleHint());
|
||||
const SizeT slot = handle.Slot;
|
||||
if (slot >= m_bySlot.size()) m_bySlot.resize(slot + 1);
|
||||
m_bySlot[slot].Object = &buffer;
|
||||
@@ -292,7 +293,8 @@ namespace MobileGL::MG_Pipe {
|
||||
// The handle a buffer already has, or the null handle. Never mints - the emission
|
||||
// path calls Acquire, the query paths call this.
|
||||
MGPipeHandle Find(const BufferObject& buffer) const {
|
||||
return MGPipeSlots().FindByLifetimeId(MGPipeKind::Buffer, buffer.GetLifetimeId());
|
||||
return MGPipeSlots().FindByLifetimeIdHinted(MGPipeKind::Buffer, buffer.GetLifetimeId(),
|
||||
buffer.PipeHandleHint());
|
||||
}
|
||||
|
||||
// D-D's inverse, and a RAW pointer is exact here: the entry exists only between the
|
||||
|
||||
@@ -859,7 +859,9 @@ namespace MobileGL::MG_Pipe {
|
||||
unitSampler ? unitSampler.get() : texture->GetSamplerObject().get();
|
||||
if (MG_State::GLState::SamplesAsIncompleteTexture(texture.get(), effective)) continue;
|
||||
|
||||
entry.Texture = MGPipeSlots().Acquire(MGPipeKind::Texture, texture->GetLifetimeId());
|
||||
entry.Texture = MGPipeSlots().AcquireHinted(
|
||||
MGPipeKind::Texture, texture->GetLifetimeId(),
|
||||
texture->PipeHandleHint(ITextureObject::PipeHintKind::Texture));
|
||||
// D-A4: a texture the sampler-view resolution names in an emitted MGPBoundView
|
||||
// is SAMPLER-bound from then on (sticky; the texture emitter's contract door,
|
||||
// since this header is included BY TextureEmit.h). One early-out per unit per
|
||||
@@ -976,7 +978,8 @@ namespace MobileGL::MG_Pipe {
|
||||
MGPipeHandle AcquireSamplerView(const ITextureObject& texture, MGPipeHandle textureHandle,
|
||||
Uint64& payloadBytes) {
|
||||
const MGPipeHandle handle =
|
||||
MGPipeSlots().Acquire(MGPipeKind::SamplerViewCso, texture.GetLifetimeId());
|
||||
MGPipeSlots().AcquireHinted(MGPipeKind::SamplerViewCso, texture.GetLifetimeId(),
|
||||
texture.PipeHandleHint(ITextureObject::PipeHintKind::SamplerView));
|
||||
const SizeT slot = handle.Slot;
|
||||
if (slot >= m_viewLatch.size()) m_viewLatch.resize(slot + 1);
|
||||
ViewLatch& latch = m_viewLatch[slot];
|
||||
|
||||
@@ -223,6 +223,31 @@ namespace MobileGL::MG_Pipe {
|
||||
return MGPipeHandle{it->second, entry->Gen};
|
||||
}
|
||||
|
||||
MGPipeHandle MGPipeSlotAllocator::FindByLifetimeIdHinted(MGPipeKind kind, Uint64 lifetimeId,
|
||||
Uint64& hint) const {
|
||||
if (hint != 0 && lifetimeId != 0) {
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
MGPipeRefuseAllocatorFromApplyThread("FindByLifetimeIdHinted");
|
||||
#endif
|
||||
const Uint32 slot = static_cast<Uint32>(hint);
|
||||
const Uint32 gen = static_cast<Uint32>(hint >> 32);
|
||||
const SlotState* entry = EntryOf(StateOf(kind), kind, slot);
|
||||
if (entry != nullptr && entry->Live && entry->Gen == gen && entry->LifetimeId == lifetimeId)
|
||||
return MGPipeHandle{slot, gen};
|
||||
}
|
||||
const MGPipeHandle found = FindByLifetimeId(kind, lifetimeId);
|
||||
hint = MGPipeHandleIsNull(found) ? 0 : MGPipePackHandleHint(found);
|
||||
return found;
|
||||
}
|
||||
|
||||
MGPipeHandle MGPipeSlotAllocator::AcquireHinted(MGPipeKind kind, Uint64 lifetimeId, Uint64& hint) {
|
||||
const MGPipeHandle existing = FindByLifetimeIdHinted(kind, lifetimeId, hint);
|
||||
if (!MGPipeHandleIsNull(existing)) return existing;
|
||||
const MGPipeHandle minted = Acquire(kind, lifetimeId);
|
||||
hint = MGPipeHandleIsNull(minted) ? 0 : MGPipePackHandleHint(minted);
|
||||
return minted;
|
||||
}
|
||||
|
||||
MGPipeHandle MGPipeSlotAllocator::Acquire(MGPipeKind kind, Uint64 lifetimeId) {
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
MGPipeRefuseAllocatorFromApplyThread("Acquire");
|
||||
|
||||
@@ -72,6 +72,12 @@ namespace MobileGL::MG_Pipe {
|
||||
MGPipeHandle FindByLifetimeId(MGPipeKind kind, Uint64 lifetimeId) const;
|
||||
// FindByLifetimeId, then AllocateFor when it misses. The ordinary client path.
|
||||
MGPipeHandle Acquire(MGPipeKind kind, Uint64 lifetimeId);
|
||||
// P15: the same two answers, first trying `hint` - the handle this object was last given,
|
||||
// kept on the frontend object itself (MGPipePackHandleHint). The hint is believed only when
|
||||
// the slot it names is live, at that generation, and owned by this very lifetime id, so a
|
||||
// reset, a free or a recycle can only make it miss; the hint is refreshed on every miss.
|
||||
MGPipeHandle FindByLifetimeIdHinted(MGPipeKind kind, Uint64 lifetimeId, Uint64& hint) const;
|
||||
MGPipeHandle AcquireHinted(MGPipeKind kind, Uint64 lifetimeId, Uint64& hint);
|
||||
|
||||
// Returns the slot to the free list. The Gen bump happens on the NEXT handout of that
|
||||
// slot, not here, so a handle that is freed twice cannot skip a generation and the
|
||||
@@ -179,6 +185,12 @@ namespace MobileGL::MG_Pipe {
|
||||
// The monolith's one client allocator. Under split there is one per client context.
|
||||
MGPipeSlotAllocator& MGPipeSlots();
|
||||
|
||||
// A handle packed into the Uint64 a frontend object keeps as its hint (0 = none; slot 0 is
|
||||
// never handed out).
|
||||
inline Uint64 MGPipePackHandleHint(MGPipeHandle handle) {
|
||||
return static_cast<Uint64>(handle.Slot) | (static_cast<Uint64>(handle.Gen) << 32);
|
||||
}
|
||||
|
||||
#if MOBILEGL_BUILD_DISAGGREGATED
|
||||
// P5c (hd, CONTRACT-P5C §3.1 / §6 layer 1): with an active transport this allocator is a
|
||||
// CLIENT-only surface. Its singleton accessor and original three method guards reject apply -
|
||||
|
||||
@@ -590,7 +590,11 @@ namespace MobileGL::MG_Pipe {
|
||||
// gating the mint would make the other subsystems emit null handles in exactly the
|
||||
// A/B arm that exists to isolate them. Only the CALLS are gated.
|
||||
MGPipeHandle AcquireTexture(Uint64 lifetimeId, ITextureObject* object) {
|
||||
const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Texture, lifetimeId);
|
||||
const MGPipeHandle handle =
|
||||
object != nullptr
|
||||
? MGPipeSlots().AcquireHinted(MGPipeKind::Texture, lifetimeId,
|
||||
object->PipeHandleHint(ITextureObject::PipeHintKind::Texture))
|
||||
: MGPipeSlots().Acquire(MGPipeKind::Texture, lifetimeId);
|
||||
Entry& entry = EntryFor(m_textures, handle);
|
||||
RetireIfRecycled(entry, handle);
|
||||
entry.Texture = object;
|
||||
@@ -1287,7 +1291,12 @@ namespace MobileGL::MG_Pipe {
|
||||
// MGPipeResourceTracker::ResetForTest for the rule this restates: a texture handle and
|
||||
// the applier record it names are SHARE-GROUP OBJECT STATE, so nothing here is
|
||||
// per-context and no re-publication path exists or may exist.
|
||||
// Moves whenever the handle table below is dropped (ResetForTest), so a memo that skipped
|
||||
// NoteTextureBoundAs / AcquireTexture for handles it already noted knows to note again.
|
||||
Uint64 TableEpoch() const { return m_tableEpoch; }
|
||||
|
||||
void ResetForTest() {
|
||||
++m_tableEpoch;
|
||||
// EVERY REFERENCE THIS EMITTER OWES IS GIVEN BACK FIRST. A case that dropped the
|
||||
// table without releasing would pin cache entries for the rest of the process and
|
||||
// the next case's LRU would mint over capacity for reasons it cannot see.
|
||||
@@ -1689,6 +1698,7 @@ namespace MobileGL::MG_Pipe {
|
||||
MGPTextureParams m_lastParams{};
|
||||
MGPSubData m_lastSubData{};
|
||||
|
||||
Uint64 m_tableEpoch = 0;
|
||||
Uint64 m_creates = 0;
|
||||
Uint64 m_respecifies = 0;
|
||||
Uint64 m_paramSets = 0;
|
||||
|
||||
@@ -256,13 +256,29 @@ namespace MobileGL::MG_Pipe {
|
||||
for (SizeT i = 0; i < kAttribs; ++i) {
|
||||
if (vao->GetAttribute(static_cast<Uint>(i)).Enabled) count = static_cast<Uint32>(i) + 1;
|
||||
}
|
||||
// P15: interleaved attributes share one buffer (FCL MC 1.21.5: every chunk draw
|
||||
// re-points ~4 attributes at the next chunk's buffer), so the previous attribute's
|
||||
// answer is reused for the same object - one lookup and one sticky-bit note per
|
||||
// buffer instead of per attribute. Scoped to this loop: same object, same handle.
|
||||
const MG_State::GLState::BufferObject* lastBuffer = nullptr;
|
||||
MGPipeHandle lastHandle = kMGPipeNullHandle;
|
||||
for (SizeT i = 0; i < count; ++i) {
|
||||
const auto& attrib = vao->GetAttribute(static_cast<Uint>(i));
|
||||
MGPVertexBuffer& entry = m_entries[i];
|
||||
entry = MGPVertexBuffer{};
|
||||
entry.Res = attrib.Buffer ? MGPipeSlots().Acquire(MGPipeKind::Buffer,
|
||||
attrib.Buffer->GetLifetimeId())
|
||||
: kMGPipeNullHandle;
|
||||
const Bool sameBuffer = attrib.Buffer && attrib.Buffer.get() == lastBuffer;
|
||||
if (sameBuffer) {
|
||||
entry.Res = lastHandle;
|
||||
} else {
|
||||
entry.Res = attrib.Buffer ? MGPipeSlots().AcquireHinted(MGPipeKind::Buffer,
|
||||
attrib.Buffer->GetLifetimeId(),
|
||||
attrib.Buffer->PipeHandleHint())
|
||||
: kMGPipeNullHandle;
|
||||
if (attrib.Buffer) {
|
||||
lastBuffer = attrib.Buffer.get();
|
||||
lastHandle = entry.Res;
|
||||
}
|
||||
}
|
||||
if (ownedClientBuffers != nullptr && attrib.Enabled && !attrib.Buffer) {
|
||||
entry.Res = (*ownedClientBuffers)[i];
|
||||
}
|
||||
@@ -271,7 +287,7 @@ namespace MobileGL::MG_Pipe {
|
||||
// glNamedBuffer* may never be bound at any resource emission, but a draw
|
||||
// that fetches from it resolves it right here, on the GL thread, at every
|
||||
// draw. Sticky, so one draw is enough for the rest of its life.
|
||||
MGPipeResourceTrackerInstance().NoteBoundAs(entry.Res, BufferTarget::Vertex);
|
||||
if (!sameBuffer) MGPipeResourceTrackerInstance().NoteBoundAs(entry.Res, BufferTarget::Vertex);
|
||||
// The attribute's own byte offset lives in MGPVertexAttribWire::Offset,
|
||||
// so the entry's is the BINDING's, which the frontend already folded in.
|
||||
entry.Offset = 0;
|
||||
|
||||
@@ -202,6 +202,9 @@ namespace MobileGL {
|
||||
// glGenBuffers) and never the heap address (recycled by the allocator): both
|
||||
// let a deleted-and-recreated buffer answer to a dead one's cache entry.
|
||||
Uint64 GetLifetimeId() const { return m_lifetimeId; }
|
||||
// P15: the pipe client's last Buffer handle for this object (MGPipeSlotAllocator::
|
||||
// AcquireHinted). Only a hint: it is checked against the allocator on every use.
|
||||
Uint64& PipeHandleHint() const { return m_pipeHandleHint; }
|
||||
// Monotonic counter bumped on every shadow mutation; backends use it to
|
||||
// validate cached transient slices.
|
||||
Uint64 GetChangeSerial() const;
|
||||
@@ -238,6 +241,7 @@ namespace MobileGL {
|
||||
|
||||
const Uint m_externalIndex = 0;
|
||||
const Uint64 m_lifetimeId = AllocateLifetimeId();
|
||||
mutable Uint64 m_pipeHandleHint = 0;
|
||||
SizeT m_size = 0;
|
||||
BufferUsage m_usage = BufferUsage::StaticDraw;
|
||||
// Owns the buffer's bytes (CPU shadow or backend persistent GPU map) and
|
||||
|
||||
@@ -1281,6 +1281,9 @@ namespace MobileGL::MG_State::GLState {
|
||||
// the next glCreateProgram, this distinguishes a deleted-and-recreated program from the
|
||||
// original, so an identity cache can't false-hit on name recycling.
|
||||
Uint64 GetLifetimeId() const { return m_lifetimeId; }
|
||||
// P15: the pipe client's last ShaderCso handle for this program (MGPipeSlotAllocator::
|
||||
// FindByLifetimeIdHinted). Only a hint: it is checked against the allocator on every use.
|
||||
Uint64& PipeHandleHint() const { return m_pipeHandleHint; }
|
||||
|
||||
private:
|
||||
// ---- The one and only join gate for link output (P1 invariant I5) ----
|
||||
@@ -1372,6 +1375,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
// writes them.
|
||||
const Uint m_externalIndex = 0;
|
||||
const Uint64 m_lifetimeId = 0;
|
||||
mutable Uint64 m_pipeHandleHint = 0;
|
||||
// The attach lists are mutated only in Link()'s GL-thread prologue, which is why
|
||||
// glGetAttachedShaders / GL_ATTACHED_SHADERS / the orphan-shader sweep need no join.
|
||||
Vector<SharedPtr<ShaderObject>> m_shaders;
|
||||
|
||||
@@ -82,6 +82,10 @@ namespace MobileGL::MG_State::GLState {
|
||||
virtual Bool HasFixedSampleLocations() const = 0;
|
||||
virtual void SetFixedSampleLocations(Bool fixedSampleLocations) = 0;
|
||||
virtual Uint64 GetLifetimeId() const = 0;
|
||||
// P15: the pipe client's last handles for this texture, one per kind it is looked up under
|
||||
// (MGPipeSlotAllocator::AcquireHinted). Only hints: checked against the allocator on use.
|
||||
enum class PipeHintKind : Uint8 { Texture = 0, SamplerView = 1, Count = 2 };
|
||||
virtual Uint64& PipeHandleHint(PipeHintKind kind) const = 0;
|
||||
// Which aspect of a packed depth/stencil texture a sampler reads (GL 4.6 core 8.10).
|
||||
// DEPTH_COMPONENT until set, and meaningless for every other format.
|
||||
virtual GLenum GetDepthStencilTextureMode() const = 0;
|
||||
@@ -164,6 +168,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
Bool HasFixedSampleLocations() const override;
|
||||
void SetFixedSampleLocations(Bool fixedSampleLocations) override;
|
||||
Uint64 GetLifetimeId() const override;
|
||||
Uint64& PipeHandleHint(PipeHintKind kind) const override { return m_pipeHandleHints[static_cast<SizeT>(kind)]; }
|
||||
// A plain texture owns its storage; TextureObjectView overrides this.
|
||||
const SharedPtr<ITextureObject>& GetViewStorageOwner() const override;
|
||||
Uint GetViewMinLevel() const override { return m_viewMinLevel; }
|
||||
@@ -241,6 +246,7 @@ namespace MobileGL::MG_State::GLState {
|
||||
|
||||
const Uint m_externalIndex;
|
||||
const Uint64 m_lifetimeId;
|
||||
mutable Uint64 m_pipeHandleHints[static_cast<SizeT>(PipeHintKind::Count)] = {};
|
||||
const TextureTarget m_target = TextureTarget::Unknown;
|
||||
TextureInternalFormat m_internalFormat = TextureInternalFormat::Unknown;
|
||||
SharedPtr<SamplerObject> m_sampler = nullptr;
|
||||
|
||||
@@ -1264,6 +1264,111 @@ TEST(FramebufferEmit, AFramebufferRecordThatNamesNoUsableHandleIsRefusedRatherTh
|
||||
<< "an out-of-range slot resized the table instead of being refused";
|
||||
}
|
||||
|
||||
// ============================ P15: the bound-target build memo ============================
|
||||
//
|
||||
// EmitFramebufferState reuses the record it built for the same framebuffer and target while
|
||||
// nothing the build reads has moved (MGPipeFramebufferEmitter::BuildBoundFramebufferState). Each
|
||||
// case first proves the memo ENGAGED (an unchanged second emission is a hit), then moves one input
|
||||
// the framebuffer's own version does not see and expects the record to follow it, by field name.
|
||||
namespace {
|
||||
MGPipeHandle HandleOfTexture(const SharedPtr<TextureObject2D>& texture) {
|
||||
return MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, texture->GetLifetimeId());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(FramebufferEmit, TheBuildMemoEngagesAndFollowsAnAttachedTexturesRespecify) {
|
||||
FramebufferScope scope;
|
||||
const auto color = MakeColorTexture(80, 32);
|
||||
const auto fbo = MakeShared<FramebufferObject>(20);
|
||||
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
|
||||
BindDrawAndRead(fbo, fbo);
|
||||
Framebuffers().EmitFramebufferState(Ctx());
|
||||
const Uint64 hits = Framebuffers().MemoHitCount();
|
||||
EXPECT_EQ(Framebuffers().EmitFramebufferState(Ctx()), 0u);
|
||||
ASSERT_EQ(Framebuffers().MemoHitCount(), hits + 1) << "an unchanged framebuffer did not reuse its built record";
|
||||
|
||||
// The texture's level 0 grows; the framebuffer's object version does not move.
|
||||
color->AllocateStorage(TextureUploadTarget::Texture2D, 0, MipmapInput{IntVec3{64, 64, 1}, 64u * 64u * 4u});
|
||||
EXPECT_EQ(Framebuffers().EmitFramebufferState(Ctx()), sizeof(MGPFramebufferState));
|
||||
EXPECT_EQ(Framebuffers().LastDraw().Width, 64u) << "MGPFramebufferState::Width";
|
||||
EXPECT_EQ(Framebuffers().LastDraw().Height, 64u) << "MGPFramebufferState::Height";
|
||||
}
|
||||
|
||||
// The build runs when the bound framebuffer changes, so the memo has to hold more than the last
|
||||
// framebuffer: an A/B/A switch must find A's record again.
|
||||
TEST(FramebufferEmit, TheBuildMemoKeepsEachFramebufferAcrossABindingSwitch) {
|
||||
FramebufferScope scope;
|
||||
const auto colorA = MakeColorTexture(83, 32);
|
||||
const auto colorB = MakeColorTexture(84, 16);
|
||||
const auto a = MakeShared<FramebufferObject>(24);
|
||||
const auto b = MakeShared<FramebufferObject>(25);
|
||||
a->AttachTexture(FramebufferAttachmentType::Color0, colorA, TextureUploadTarget::Texture2D);
|
||||
b->AttachTexture(FramebufferAttachmentType::Color0, colorB, TextureUploadTarget::Texture2D);
|
||||
BindDrawAndRead(a, a);
|
||||
Framebuffers().EmitFramebufferState(Ctx());
|
||||
BindDrawAndRead(b, b);
|
||||
Framebuffers().EmitFramebufferState(Ctx());
|
||||
const Uint64 hits = Framebuffers().MemoHitCount();
|
||||
BindDrawAndRead(a, a);
|
||||
Framebuffers().EmitFramebufferState(Ctx());
|
||||
EXPECT_EQ(Framebuffers().MemoHitCount(), hits + 1) << "switching back to a framebuffer rebuilt its record";
|
||||
EXPECT_EQ(Framebuffers().LastDraw().Width, 32u) << "MGPFramebufferState::Width";
|
||||
}
|
||||
|
||||
TEST(FramebufferEmit, TheBuildMemoFollowsARestoragedAttachedRenderbuffer) {
|
||||
FramebufferScope scope;
|
||||
const auto renderbuffer = MakeShared<RenderbufferObject>(4);
|
||||
renderbuffer->SetInternalFormat(TextureInternalFormat::RGBA8);
|
||||
renderbuffer->AllocateStorage(IntVec2{32, 32});
|
||||
const auto fbo = MakeShared<FramebufferObject>(21);
|
||||
fbo->AttachRenderbuffer(FramebufferAttachmentType::Color0, renderbuffer);
|
||||
BindDrawAndRead(fbo, fbo);
|
||||
Framebuffers().EmitFramebufferState(Ctx());
|
||||
const Uint64 hits = Framebuffers().MemoHitCount();
|
||||
Framebuffers().EmitFramebufferState(Ctx());
|
||||
ASSERT_EQ(Framebuffers().MemoHitCount(), hits + 1) << "an unchanged framebuffer did not reuse its built record";
|
||||
|
||||
renderbuffer->AllocateStorage(IntVec2{48, 40});
|
||||
Framebuffers().EmitFramebufferState(Ctx());
|
||||
EXPECT_EQ(Framebuffers().LastDraw().Width, 48u) << "MGPFramebufferState::Width";
|
||||
EXPECT_EQ(Framebuffers().LastDraw().Height, 40u) << "MGPFramebufferState::Height";
|
||||
}
|
||||
|
||||
TEST(FramebufferEmit, TheBuildMemoFollowsAnAttachedTexturesSampleCount) {
|
||||
FramebufferScope scope;
|
||||
const auto color = MakeColorTexture(81, 32);
|
||||
const auto fbo = MakeShared<FramebufferObject>(22);
|
||||
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
|
||||
BindDrawAndRead(fbo, fbo);
|
||||
Framebuffers().EmitFramebufferState(Ctx());
|
||||
Framebuffers().EmitFramebufferState(Ctx());
|
||||
|
||||
// Samples move the params version, not the shape.
|
||||
color->SetSamples(4);
|
||||
Framebuffers().EmitFramebufferState(Ctx());
|
||||
EXPECT_EQ(Framebuffers().LastDraw().Samples, 4u) << "MGPFramebufferState::Samples";
|
||||
}
|
||||
|
||||
TEST(FramebufferEmit, TheBuildMemoReNotesBindBitsAfterTheTextureTableIsDropped) {
|
||||
FramebufferScope scope;
|
||||
const auto color = MakeColorTexture(82, 32);
|
||||
const auto fbo = MakeShared<FramebufferObject>(23);
|
||||
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
|
||||
BindDrawAndRead(fbo, fbo);
|
||||
Framebuffers().EmitFramebufferState(Ctx());
|
||||
const MGPipeHandle handle = HandleOfTexture(color);
|
||||
ASSERT_FALSE(MGPipeHandleIsNull(handle));
|
||||
ASSERT_NE(MGPipeTextureEmitterInstance().TextureBindMask(handle) & kMGPipeBindRenderTarget, 0)
|
||||
<< "the attachment's RENDER_TARGET bit was never noted";
|
||||
|
||||
// A dropped table (a replaced session) forgets the sticky bits; the next build must note them again.
|
||||
MGPipeTextureEmitterInstance().ResetForTest();
|
||||
MGPipeSetHashSuppressorInstance().InvalidateAll();
|
||||
Framebuffers().EmitFramebufferState(Ctx());
|
||||
EXPECT_NE(MGPipeTextureEmitterInstance().TextureBindMask(handle) & kMGPipeBindRenderTarget, 0)
|
||||
<< "MGPResourceDesc::BindMask lost RENDER_TARGET after the texture table was dropped";
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
// Before anything logs: the logger reads this variable once, on its first write, and
|
||||
// caches the handle. The name carries this process's pid, and the file is removed on the
|
||||
|
||||
@@ -97,6 +97,38 @@ namespace {
|
||||
EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, reused));
|
||||
}
|
||||
|
||||
// P15: a hint (the handle a frontend object was last given, kept on the object) is a shortcut
|
||||
// and never an answer of its own. It must give the live handle back, and it must miss - never
|
||||
// name another object's handle - after a free, a recycle of its slot, or a reset.
|
||||
TEST(SlotAllocator, AHandleHintIsOnlyBelievedWhileItsSlotStillBelongsToTheObject) {
|
||||
MGPipeSlotAllocator allocator;
|
||||
constexpr Uint64 kFirst = 1001, kSecond = 1002;
|
||||
|
||||
Uint64 hint = 0;
|
||||
const MGPipeHandle minted = allocator.AcquireHinted(MGPipeKind::Buffer, kFirst, hint);
|
||||
ASSERT_FALSE(MGPipeHandleIsNull(minted));
|
||||
EXPECT_EQ(hint, MGPipePackHandleHint(minted)) << "a mint must leave the hint naming the new handle";
|
||||
EXPECT_TRUE(allocator.FindByLifetimeIdHinted(MGPipeKind::Buffer, kFirst, hint) == minted);
|
||||
|
||||
// The slot is freed and handed to another object. The dead object's hint must miss, even
|
||||
// when it is forged to carry the successor's exact slot and generation: the owner moved.
|
||||
allocator.Free(MGPipeKind::Buffer, minted);
|
||||
EXPECT_TRUE(MGPipeHandleIsNull(allocator.FindByLifetimeIdHinted(MGPipeKind::Buffer, kFirst, hint)))
|
||||
<< "a freed object's hint still answered";
|
||||
Uint64 staleHint = MGPipePackHandleHint(minted);
|
||||
const MGPipeHandle successor = allocator.AcquireHinted(MGPipeKind::Buffer, kSecond, staleHint);
|
||||
EXPECT_EQ(successor.Slot, minted.Slot) << "the free list did not hand the slot back";
|
||||
Uint64 forged = MGPipePackHandleHint(successor); // the successor's exact slot and generation
|
||||
EXPECT_TRUE(MGPipeHandleIsNull(allocator.FindByLifetimeIdHinted(MGPipeKind::Buffer, kFirst, forged)))
|
||||
<< "a hint naming another object's live slot answered for the dead object";
|
||||
|
||||
// A reset forgets every handle; a hint from before it can only miss.
|
||||
Uint64 beforeReset = MGPipePackHandleHint(successor);
|
||||
allocator.Reset();
|
||||
EXPECT_TRUE(MGPipeHandleIsNull(allocator.FindByLifetimeIdHinted(MGPipeKind::Buffer, kSecond, beforeReset)))
|
||||
<< "a hint survived the allocator's reset";
|
||||
}
|
||||
|
||||
TEST(SlotAllocator, FreedSlotComesBackBeforeHighWaterGrows) {
|
||||
MGPipeSlotAllocator allocator;
|
||||
|
||||
|
||||
@@ -277,6 +277,34 @@ namespace {
|
||||
}
|
||||
}
|
||||
|
||||
// P15: the rebuild reuses the previous attribute's handle when it names the SAME buffer object
|
||||
// (interleaved attributes), and only then. Two buffers interleaved A, A, B, A: every entry must
|
||||
// carry its own buffer's handle, including the A after the B.
|
||||
TEST(VertexInputEmit, InterleavedAttributesOnTwoBuffersEachCarryTheirOwnBuffersHandle) {
|
||||
EmitterScope scope;
|
||||
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
|
||||
const SharedPtr<BufferObject> a = Ctx().CreateBufferObject(1);
|
||||
const SharedPtr<BufferObject> b = Ctx().CreateBufferObject(2);
|
||||
a->Respecify(256, nullptr);
|
||||
b->Respecify(256, nullptr);
|
||||
const SharedPtr<BufferObject>* owners[] = {&a, &a, &b, &a};
|
||||
for (Uint i = 0; i < 4; ++i) {
|
||||
vao->SetAttributeFormat(i, 4, DataType::Float32, false, 16, static_cast<SizeT>(4 * i), false, false, 16);
|
||||
vao->MirrorPointerIntoBinding(i, *owners[i], static_cast<SizeT>(4 * i), 16);
|
||||
vao->BindAttributeBuffer(i, *owners[i]);
|
||||
vao->EnableAttribute(i);
|
||||
}
|
||||
Emitter().EmitVertexBuffers(Ctx(), 0);
|
||||
ASSERT_EQ(Emitter().LastVertexBuffers().Count, 4u);
|
||||
for (Uint i = 0; i < 4; ++i) {
|
||||
const MGPipeHandle expected =
|
||||
MGPipeSlots().FindByLifetimeId(MGPipeKind::Buffer, (*owners[i])->GetLifetimeId());
|
||||
ASSERT_FALSE(MGPipeHandleIsNull(expected));
|
||||
EXPECT_TRUE(Emitter().LastEntries()[i].Res == expected)
|
||||
<< "MGPVertexBuffer::Res of entry " << i << " is not its own buffer's handle";
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user