[Feat] (Pipe): push the bound VAO's format, its vertex buffers with an explicit baseInstance, and its index binding as their own calls

This commit is contained in:
2026-09-08 04:47:51 -04:00
parent 874d1ee77d
commit 14efd6eb24
6 changed files with 559 additions and 42 deletions
+64 -19
View File
@@ -20,6 +20,7 @@
#include <MG_Impl/Pipe/ResourceTracker.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/Tracker.h>
#include <MG_Impl/Pipe/VertexInputEmit.h>
#include <MG_Pipe/MGPipeRenderStateSpans.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_Pipe/PipeMutation.h>
@@ -770,6 +771,21 @@ namespace MobileGL::MG_Pipe {
ctx->RecordError(code, Move(info));
}
// P3a D-H2.1. The draw's RAW vertex-fetch base instance, set immediately before the fill
// at the three *BaseInstance draw entry points. It replaces the ambient process global
// the backend used to read, which is a shape that cannot cross a pushed boundary; the
// value travels as an explicit field of set_vertex_buffers and the SERVER decides
// whether to emulate the fetch shift or let GL_EXT_base_instance do it.
//
// Indirect draws pass nothing: none of the three sites is in an indirect loop, per-command
// base instances are resolved server-side out of the indirect commands, and the client
// emits 0 for every indirect path.
void MGPipeSetPendingBaseInstance(Uint32 baseInstance) {
MGPipeTrackerInstance().SetPendingBaseInstance(baseInstance);
}
Uint32 MGPipePendingBaseInstance() { return MGPipeTrackerInstance().PendingBaseInstance(); }
void MGPipeLeaveVerb() {
PipeInputs& inputs = gPipeInputs;
#if MOBILEGL_PIPE_POISON
@@ -778,6 +794,11 @@ namespace MobileGL::MG_Pipe {
++MGPipeFillAccess::Filled(inputs).CurrentVerbSerial;
#endif
MGPipeFillAccess::SetVerb(inputs, MGPipeVerb::kVerbCount);
// The pending base instance belongs to the verb that was about to run, so leaving
// one drops it. The validate point clears it too, after the emission consumed it -
// the two together are what make a plain draw after a base-instanced one see 0
// again, and neither of them relies on the other being called.
MGPipeTrackerInstance().ClearPendingBaseInstance();
}
@@ -898,7 +919,8 @@ namespace MobileGL::MG_Pipe {
kMGPipeSubsystemPixelPack |
kMGPipeSubsystemPatchState |
kMGPipeSubsystemVertexAttribDefaults |
kMGPipeSubsystemResources;
kMGPipeSubsystemResources |
kMGPipeSubsystemVertexInput;
// A field an emitted call supplies COMPLETELY, so the residual fill may stop pulling
// it. Two rows of Coverage.def's emitted list do not qualify and each has its reason
@@ -919,10 +941,30 @@ namespace MobileGL::MG_Pipe {
// retiring that pull is blocked on A teaching the applier to switch on
// ValueClass. EmitVertexAttribDefaults checks rather than trusts, and repairs the
// mirror when the applier's write does not reproduce the value.
//
// GetBoundVertexArray is P3a's row, and Coverage.def asks for the decision to be
// taken HERE, deliberately, rather than inherited from the row's presence. THE
// ANSWER IS NO, and it is not a matter of degree: the field's storage is a
// SharedPtr<VertexArrayObject> - a frontend heap reference - and the call that
// supplies it, bind_vertex_elements, carries an eight-byte {slot, gen} handle
// and nothing else. The applier stores that handle in
// MGPipeApplierState::BoundVertexElements; it has no way to produce the pointer,
// and P3a deliberately does not give it one (a payload never contains a pointer,
// and the whole point of the conversion is that the server stops holding
// frontend references). Skipping the pull would leave m_boundVertexArray null on
// every draw of every push build - which is not a subtle staleness, it is every
// backend read of the bound VAO reading nothing.
//
// So the row is EMITTED-AND-STILL-PULLED, exactly like GetPixelStoreParameters:
// the call goes out because the server needs the format, and the field keeps
// coming through the residual fill because the mirror is a pointer only the
// client can hold. What retires the pull is not a better applier - it is P8,
// where the backend stops reading a frontend VAO at all.
constexpr Bool EmittedCallSuppliesTheWholeField(MGPipeInputField field) {
switch (field) {
case MGPipeInputField::GetPixelStoreParameters:
case MGPipeInputField::GetCurrentVertexAttribute:
case MGPipeInputField::GetBoundVertexArray:
return false;
default:
return true;
@@ -1274,33 +1316,27 @@ namespace MobileGL::MG_Pipe {
return payloadBytes;
}
// ---- P3a's three vertex-input emitters. STUBS AT THE CONTRACT COMMIT. ----
// ---- P3a's three vertex-input emitters (D-G3, D-H3, D-I) ----
//
// They exist here, and are called from the validate point below, for the same reason
// the applier's fourteen entry points exist as stubs: this file's enum-coupled block
// is the contract commit's and everything else in it belongs to the commit that
// fills the bodies in, so the two must not have to touch the same lines. What lands
// here is the SHAPE - three functions, in the emission order the design fixes
// (elements, then buffers, then index, after the four P2 emitters) - and the bodies
// replace `return 0` without moving a call site.
// The shape - three functions in the fixed order elements, then buffers, then index,
// after the four P2 emitters - is the contract commit's, so that the commit which
// fills the bodies in does not also have to edit the validate point. All three now
// have bodies and MGPipeSubsystemForDirty maps their bits onto the vertex-input
// subsystem, so `wants()` can be true.
//
// THEY ARE UNREACHABLE, not merely empty: MGPipeSubsystemForDirty maps NEW_VERTEX_
// ELEMENTS / _BUFFERS / _INDEX_BUFFER onto no subsystem yet, so `wants()` is false
// for all three whatever MOBILEGL_PIPE_PUSH says. Returning 0 keeps them out of the
// payload histogram, which must not gain a bucket for bytes nobody sent.
// Everything they do lives in MG_Impl/Pipe/VertexInputEmit.h; what is here is the
// adaptation to the validate point's byte-counting contract.
Uint64 EmitVertexElements(GLContext& ctx) {
(void)ctx;
return 0;
return MGPipeVertexInputEmitterInstance().EmitVertexElements(ctx);
}
Uint64 EmitVertexBuffers(GLContext& ctx) {
(void)ctx;
return 0;
MGPipeTracker& tracker = MGPipeTrackerInstance();
return MGPipeVertexInputEmitterInstance().EmitVertexBuffers(ctx, tracker.PendingBaseInstance());
}
Uint64 EmitIndexBuffer(GLContext& ctx) {
(void)ctx;
return 0;
return MGPipeVertexInputEmitterInstance().EmitIndexBuffer(ctx);
}
} // namespace
@@ -1369,6 +1405,10 @@ namespace MobileGL::MG_Pipe {
MGPipeCsoCacheInstance().Reset();
MGPipeApplierReset();
MGPipeSetHashSuppressorInstance().InvalidateAll();
// P3a: and the vertex-input emitter's latches, for the same reason - they say
// "this handle has already published this configuration" about an applier whose
// vertex-elements records the reset above has just dropped.
MGPipeVertexInputEmitterInstance().Reset();
g_residualDue = true;
}
@@ -1399,6 +1439,11 @@ namespace MobileGL::MG_Pipe {
if (wants(MGPipeDirty::NewIndexBuffer)) {
payloadBytes += EmitIndexBuffer(*ctx);
}
// CONSUMED, so the next verb starts from zero. The tracker's bit-9 shutter read it
// above and EmitVertexBuffers put it on the wire; leaving it set would give the next
// draw the previous draw's fetch shift, which is the exact defect the explicit field
// exists to remove.
tracker.ClearPendingBaseInstance();
// ---- step 4: the residual fill, for what an emitted call did NOT supply ----
const Bool applierDerives = ApplierDerivesRenderStateFields();
+26
View File
@@ -43,6 +43,32 @@ namespace MobileGL::MG_Pipe {
// stop where it says it stops (MG_Test/ScopedPipeVerb.h).
void MGPipeLeaveVerb();
// PipeFill.cpp. P3a D-H2.1: the DRAW's raw vertex-fetch base instance, which
// set_vertex_buffers now carries as an explicit field.
//
// It replaces an ambient process global the backend read at VAO sync time, which is a
// shape that cannot cross a pushed boundary. The client sends the raw value and never a
// pre-shifted offset: whether to emulate the fetch shift or let GL_EXT_base_instance do
// it is the SERVER's decision. It is also an input to set_vertex_buffers' content hash
// and to the tracker's bit-9 shutter, so a draw whose only change is its base instance
// still reaches the emitter and still goes out.
//
// Call it immediately before MGP_FILL at a draw entry point that takes a baseinstance.
// The validate point consumes and clears it, and MGPipeLeaveVerb clears it too, so a
// plain draw that follows a base-instanced one sees 0 again.
//
// [HANDED ON, not done here] The three GL entry points that owe this call are
// MG_Impl/GLImpl/Drawing/GL_Drawing.cpp's DrawElementsInstancedBaseVertexBaseInstance,
// DrawElementsInstancedBaseInstance and DrawArraysInstancedBaseInstance. That file is
// outside this package's ownership (C.5 assigns it to nobody and C.1 does not list it),
// so the setter, its consumption, its shutter and its hash all land here and the three
// one-line call sites are recorded for the integrator. Until they exist the emitted
// BaseInstance is 0 on every draw, which is what the tree does today.
void MGPipeSetPendingBaseInstance(Uint32 baseInstance);
// What the next set_vertex_buffers will carry. Exists for the unit gate, which drives
// the emitter without a draw entry point to set it.
Uint32 MGPipePendingBaseInstance();
// PipeFill.cpp. Negative control B (P1 brief D6): the filler withholds the STAMP - never
// the value - of `field` at `verb`, so that verb's read of it is
// Fatal{UnmigratedPipeInput, "Field@Verb"} while every other verb is unaffected. The
+10 -2
View File
@@ -18,7 +18,15 @@
// them answers it against a shape the backend rediscovered. P2 lands the MECHANISM and ONE
// real consumer (SetVertexAttribDefaults) so the shape is pinned by a test rather than by a
// plan; the other six slots exist, are unit-tested, and are wired by the phase that moves
// the set they name.
// the set they name. P3a wires the second, SetVertexBuffers.
//
// A WIRED SLOT PUTS A REQUIREMENT ON ITS HASH, and SetVertexBuffers is where that first
// bites: the hash has to cover EVERY input the record carries, not only the set. Its
// baseInstance is DRAW state and moves without the buffer set moving, so a hash over the
// entries alone would suppress a record whose one changed field is the fetch shift and the
// server would keep the previous one. MG_Impl/Pipe/VertexInputEmit.h's
// MGPipeVertexBufferSetContentHash mixes Start, Count and BaseInstance in for exactly that
// reason, and VertexInputEmit's base-instance pair is the test that says so.
//
// A hash of 0 is reserved for "never emitted", so the first emission always goes out; a
// computed 0 is remapped to 1, which costs one collision in 2^64 an extra emission and
@@ -34,7 +42,7 @@ namespace MobileGL::MG_Pipe {
// One slot per kVarTail set_* (ARCHITECTURE.md 5.1's call list).
enum class MGPipeSuppressorSlot : Uint32 {
SetVertexBuffers = 0, // P3b
SetVertexBuffers = 0, // P3a - wired, and its hash includes BaseInstance
SetSamplerViews, // P3b
BindSamplerStates, // P3b
SetShaderImages, // P4b
+95 -20
View File
@@ -19,10 +19,10 @@
// (ARCHITECTURE.md 5.1).
//
// WHAT IT DOES. One Uint32 dirty mask per verb, one bit per row of ARCHITECTURE.md 5.2,
// computed by comparing a shutter against what the tracker last pushed. P2 EMITS for bits
// 0..4 only (the value-class ones); bits 5..17 are computed, latched and counted so the
// per-bit fire rate is a measurement rather than a plan, and their fields keep going
// through the residual fill until P3a/P3b/P4a/P4b.
// computed by comparing a shutter against what the tracker last pushed. P2 emitted for bits
// 0..4 (the value-class ones); P3a adds bits 5, 9 and 10 - the vertex-input family - and the
// rest are still computed, latched and counted so the per-bit fire rate is a measurement
// rather than a plan, with their fields going through the residual fill until P3b/P4a/P4b.
//
// WHY EVERY SHUTTER OVER-FIRES. A bit that fires too often costs one extra push. A bit
// that fires too rarely renders stale, and ARCHITECTURE.md 13.2 names that as the
@@ -61,14 +61,18 @@ namespace MobileGL::MG_Pipe {
NewPixelPack, // PixelStoreParameters (pack) -> set_pixel_pack_state
NewPatchState, // the patch trio, NaN legal -> set_patch_state
NewVertexAttribDefaults, // glVertexAttrib* defaults -> set_vertex_attrib_defaults
// ---- value class: computed and counted, emitted from P3a on ----
NewVertexElements, // the bound VAO's attribute configuration
// ---- value class: NEW_VERTEX_ELEMENTS is emitted from P3a; the other three are
// still computed and counted, and are emitted from P3b/P4a on ----
NewVertexElements, // the bound VAO's attribute configuration -> create/bind_vertex_elements
NewShader, // the current program's link version
NewShaderBindings, // image units, block bindings, uniform write set
NewGlobalConstants, // the default-uniform-block image
// ---- object class: computed and counted, emitted from P3b/P4b on ----
NewVertexBuffers,
NewIndexBuffer,
// ---- object class. THE FIRST TWO ARE P3a's, not P3b/P4b's: the roadmap puts
// set_vertex_buffers and set_index_buffer in the same phase as the vertex-elements
// trio, and this comment said otherwise until the commit that wired them. The rest
// are still computed and counted only. ----
NewVertexBuffers, // -> set_vertex_buffers (P3a)
NewIndexBuffer, // -> set_index_buffer (P3a)
NewFramebuffer,
NewSamplerViews,
NewSamplers,
@@ -86,12 +90,19 @@ namespace MobileGL::MG_Pipe {
return Uint32{1} << static_cast<Uint32>(bit);
}
// The five P2 emits for.
// The five P2 emits for. Each phase's constant survives as the next phase's A/B control
// and as what a test compares the subsystem map against, so none of them is edited in
// place when a later phase takes more bits over.
inline constexpr Uint32 kMGPipeDirtyEmittedAtP2 =
MGPipeDirtyBit(MGPipeDirty::NewRenderState) | MGPipeDirtyBit(MGPipeDirty::NewPipelineState) |
MGPipeDirtyBit(MGPipeDirty::NewPixelPack) | MGPipeDirtyBit(MGPipeDirty::NewPatchState) |
MGPipeDirtyBit(MGPipeDirty::NewVertexAttribDefaults);
// The three P3a adds: the vertex-input family, all on one subsystem.
inline constexpr Uint32 kMGPipeDirtyEmittedAtP3a =
kMGPipeDirtyEmittedAtP2 | MGPipeDirtyBit(MGPipeDirty::NewVertexElements) |
MGPipeDirtyBit(MGPipeDirty::NewVertexBuffers) | MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer);
inline constexpr const char* kMGPipeDirtyNames[kMGPipeDirtyCount] = {
"NEW_RENDER_STATE",
"NEW_PIPELINE_STATE",
@@ -127,8 +138,16 @@ namespace MobileGL::MG_Pipe {
return kMGPipeSubsystemPatchState;
case MGPipeDirty::NewVertexAttribDefaults:
return kMGPipeSubsystemVertexAttribDefaults;
// P3a's three, all one subsystem: create/bind_vertex_elements, set_vertex_buffers
// and set_index_buffer are the vertex-input family and an operator switching it off
// has to get the whole family's legacy arm, not two thirds of it.
// PipeFill.cpp's SubsystemForEmitter carries the pairing static_asserts.
case MGPipeDirty::NewVertexElements:
case MGPipeDirty::NewVertexBuffers:
case MGPipeDirty::NewIndexBuffer:
return kMGPipeSubsystemVertexInput;
default:
// Bits 5..17 have no call of their own until P3a/P3b/P4a/P4b, so there is no
// The remaining bits have no call of their own until P3b/P4a/P4b, so there is no
// subsystem to switch and the residual fill keeps supplying their fields.
return 0;
}
@@ -136,10 +155,18 @@ namespace MobileGL::MG_Pipe {
// A COMPOSITE shutter, for the bits whose "did anything move" is more than one counter.
// It is a hash, so two different states can in principle collide and cost a MISSED fire.
// That is acceptable for bits 5..17 and only for them: nothing consumes those bits in
// P2, and P3 replaces each with its own exact shutter as it takes the subsystem over.
// The five bits P2 EMITS for are never composed - they are widened counters and byte
// The five bits P2 emits for are never composed - they are widened counters and byte
// compares, neither of which can collide.
//
// P3a's three ARE composed, so the risk is now real rather than academic, and it is
// accepted with its size stated: each mix takes a 64-bit input into a 64-bit
// accumulator, so two DIFFERENT vertex configurations collide with probability ~2^-64
// per pair, and the inputs are a monotone lifetime id, a monotone configuration version
// and a widened slot version - none of which an application can steer. The alternative,
// comparing the whole 32-attribute configuration byte for byte on every verb, is the
// per-draw cost the shutter exists to avoid. The narrowing that removes the composition
// for bit 10 - its own slot version plus the bound object's identity - is what this
// phase already did to the one shutter that was composed over an unrelated aggregate.
inline constexpr Uint64 MGPipeMixShutter(Uint64 accumulator, Uint64 value) {
accumulator ^= value + 0x9e3779b97f4a7c15ull + (accumulator << 6) + (accumulator >> 2);
return accumulator;
@@ -240,12 +267,39 @@ namespace MobileGL::MG_Pipe {
const Uint64 textureParams = ctx.GetAnyTextureParamsGeneration();
const Uint64 buffers = ctx.GetAnyBufferChangeGeneration();
now[Index(MGPipeDirty::NewVertexBuffers)] =
MGPipeMixShutter(ctx.GetAnyVaoAttributeGeneration(), vaoIdentity);
// The index buffer lives in the bound VAO's element slot and P2 has no cheap
// shutter for that slot alone, so it shares the buffer aggregate and over-fires
// on any buffer write anywhere. P3b narrows it when it takes the subsystem over.
now[Index(MGPipeDirty::NewIndexBuffer)] = MGPipeMixShutter(buffers, vaoIdentity);
// Bit 9. The VAO attribute aggregate mixed with the bound VAO's identity is
// already exact for the SET - it is bumped by all three Bump*Version functions,
// which are the only writers of an attribute's format, buffer or enable state -
// and a driver-id re-mint that moves no client counter is caught server-side by
// the backend's own id generation.
//
// THE PENDING BASE INSTANCE IS MIXED IN, and this is a deviation from the design
// note that said "keep the shutter" (recorded in client-v1.md): the draw's
// baseInstance is now an EXPLICIT field of set_vertex_buffers and a
// ContentHash input, and it moves neither the attribute aggregate nor the VAO
// identity. Without it here, a draw whose only change is its base instance would
// never reach the emitter at all and the server would keep the previous fetch
// shift - which is the same silently-wrong-geometry the backend's
// baseInstanceDirty flag exists to prevent, one level further out. It fires
// extra only on the draws that actually carry one.
now[Index(MGPipeDirty::NewVertexBuffers)] = MGPipeMixShutter(
MGPipeMixShutter(ctx.GetAnyVaoAttributeGeneration(), vaoIdentity), m_pendingBaseInstance);
// Bit 10, NARROWED (P3a, D-I). It used to mix the whole buffer-CONTENT aggregate
// with the VAO identity and therefore fired on any buffer write anywhere; what
// it guards is one binding slot, so it now reads that slot's own version and the
// identity of what is bound to it. The version is a WRAPPING Uint16 bumped only
// on a real change, so it goes through the widened counter at this boundary; the
// bound object's lifetime id joins it because identity is what closes the wrap
// hole. The VAO identity stays in the mix because the element slot BELONGS to
// the bound VAO - switching VAOs switches slots.
Uint64 indexShutter = 0;
if (vao) {
const auto& indexSlot = vao->GetIndexBufferBindingSlot();
const auto& indexObject = indexSlot.GetBoundObject();
indexShutter = MGPipeMixShutter(m_indexSlotVersion.Observe(indexSlot.GetVersion()),
indexObject ? indexObject->GetLifetimeId() : 0);
}
now[Index(MGPipeDirty::NewIndexBuffer)] = MGPipeMixShutter(vaoIdentity, indexShutter);
now[Index(MGPipeDirty::NewFramebuffer)] = MGPipeMixShutter(
ctx.GetAnyFramebufferAttachmentGeneration(),
m_framebufferBind.Observe(
@@ -315,6 +369,8 @@ namespace MobileGL::MG_Pipe {
m_renderStateVersion.Reset();
m_pipelineStateVersion.Reset();
m_framebufferBind.Reset();
m_indexSlotVersion.Reset();
m_pendingBaseInstance = 0;
m_pack = PixelStoreParameters{};
m_patch = PatchTrio{};
m_staged = RenderStateParameters{};
@@ -368,6 +424,19 @@ namespace MobileGL::MG_Pipe {
AttribDefaults& StagedAttribDefaults() { return m_stagedAttribs; }
const AttribDefaults& StagedAttribDefaults() const { return m_stagedAttribs; }
// ---- P3a D-H2: the draw's vertex-FETCH base instance ----
//
// It lives HERE rather than in a file static because bit 9's shutter has to see it:
// an ambient process global cannot cross a pushed boundary, and the value is now an
// explicit field of set_vertex_buffers and an input to its content hash, so a draw
// whose only change is its base instance has to reach the emitter. Set immediately
// before the fill at the three *BaseInstance draw entry points; CONSUMED and cleared
// by the validate point once it has been emitted, so a plain draw that follows one
// sees 0 again whether or not anything called MGPipeLeaveVerb in between.
void SetPendingBaseInstance(Uint32 baseInstance) { m_pendingBaseInstance = baseInstance; }
Uint32 PendingBaseInstance() const { return m_pendingBaseInstance; }
void ClearPendingBaseInstance() { m_pendingBaseInstance = 0; }
private:
static constexpr SizeT Index(MGPipeDirty bit) { return static_cast<SizeT>(bit); }
@@ -383,6 +452,12 @@ namespace MobileGL::MG_Pipe {
// The draw framebuffer BINDING slot version, widened for the same reason: a Uint16
// that wrapped would let a composite shutter repeat and cost a missed fire.
MGPipeWidenedCounter m_framebufferBind;
// The BOUND VAO's element-array slot version, widened for the same reason. One
// counter over a slot that changes with the bound VAO: a stale high word can only
// ADD a fire, never drop one, and the VAO identity in the same mix is what makes a
// switch between two VAOs differ whatever their slot versions read.
MGPipeWidenedCounter m_indexSlotVersion;
Uint32 m_pendingBaseInstance = 0;
// Bits 2 and 3 are BitwiseEqual shutters, not counters.
PixelStoreParameters m_pack{};
PatchTrio m_patch{};
+351
View File
@@ -0,0 +1,351 @@
// MobileGL - MobileGL/MG_Impl/Pipe/VertexInputEmit.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
// The CLIENT side of P3a's vertex-input family (brief D-G, D-H, D-I): the bound VAO's
// format as create/bind_vertex_elements, its buffers as set_vertex_buffers with an explicit
// baseInstance, and its element binding as set_index_buffer.
//
// UNLIKE THE RESOURCE FAMILY, these three emit at the VALIDATE POINT, from
// MGPipeValidateForVerb's step 3 in the fixed order elements -> buffers -> index. That is
// the ordinary rule (ARCHITECTURE.md 5.1); the resource family is the one exception to it.
//
// THE CSO IS IDENTITY-ADDRESSED, NOT CONTENT-ADDRESSED (D-G1, a recorded deviation from
// ARCHITECTURE.md's 1024-entry content-addressed scheme). One handle per frontend
// VertexArrayObject, minted off its lifetime id, and create_vertex_elements is RE-ISSUED on
// the same handle whenever the configuration moves - legal, because MGPipeHandle::Gen
// increments only on slot reuse and never on a respecify. Espryt has no vertex-elements CSO
// to share: its twin owns one driver VAO name plus 64 scratch buffer ids, which two frontend
// VAOs cannot share, so content addressing would be strictly slower on the only backend this
// phase touches. P7 adds the hash-probe-memcmp layer above these same three calls when
// Magma's VertexInputStateFactory takes the CSO over.
//
// WHAT THE UNIT GATE READS. G6 is "the emitted blob + set + index record reproduce exactly
// what the backend's VAO twin reads from the frontend today, field by field, for all 32
// slots", and G7 is a scripted control that stops the conversion copying ONE field and
// expects the suite to go red NAMING it. So the conversion is a pure function per field
// (MGPipeBuildVertexAttribWire / MGPipeBuildVertexBindingPointWire) and the staging buffers
// the emitter builds into are readable afterwards - the emitter passes m_blob and m_entries
// straight to the applier, so "what was emitted" costs no copy at all.
//
// HEADER-ONLY, for the ownership reason Tracker.h states in full.
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Impl/Pipe/Tracker.h>
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <xxhash.h>
#include <cstring>
namespace MobileGL::MG_Pipe {
// ---------------------------------------------------------------------------------
// D-G2: the wire conversion, one pure function per view
// ---------------------------------------------------------------------------------
// EVERY FIELD OF VertexAttribute THE WIRE FORM CARRIES, and nothing else:
//
// Divisor is deliberately absent - it is resolved per binding point and travels in
// MGPVertexBuffer::Divisor, which is where the backend's glVertexAttribDivisor reads
// it. Carrying it twice would let a malformed record disagree with itself.
// LegacyStride / LegacyPointer are deliberately absent - they are the
// glGetVertexAttrib* query answers and nothing but the query path reads them, so
// they stay client-side.
// Buffer is deliberately absent - identity travels in set_vertex_buffers, which is
// what keeps this record stable while the buffers under it change.
// Stride is the RESOLVED distance and a surviving 0 is MEANINGFUL: a pointer call's 0
// was already resolved to the element size by the frontend, so a 0 here can only
// have come from the binding model, where it means every vertex reads the SAME
// element. Collapsing it back into the element size is what made
// KHR-GL43.vertex_attrib_binding.basic-input-case7/8 read past the buffer.
// IsLong travels SEPARATELY from Type == Float64: VertexAttribFormat(GL_DOUBLE) reads
// doubles and asks for them converted to float, VertexAttribLFormat keeps all 64
// bits, and the backend's fp64 narrowing and its Adreno disabled-attribute
// workaround both key on telling the two apart.
inline MGPVertexAttribWire MGPipeBuildVertexAttribWire(const MG_State::GLState::VertexAttribute& attrib,
Uint32 bindingIndex) {
MGPVertexAttribWire wire{};
wire.Offset = static_cast<Uint64>(attrib.Offset);
wire.Stride = static_cast<Int32>(attrib.Stride);
wire.Type = static_cast<Uint32>(attrib.Type);
wire.Size = static_cast<Uint8>(attrib.Size);
wire.Enabled = attrib.Enabled ? 1 : 0;
wire.Normalized = attrib.Normalized ? 1 : 0;
wire.IsInteger = attrib.IsInteger ? 1 : 0;
wire.IsLong = attrib.IsLong ? 1 : 0;
wire.IsBgra = attrib.IsBgra ? 1 : 0;
wire.BindingIndex = static_cast<Uint8>(bindingIndex);
return wire;
}
// The ARB_vertex_attrib_binding view. Its initial Stride is 16, not 0 (GL 4.6 core table
// 23.4), which is why the wire form keeps it signed and copies it verbatim.
inline MGPVertexBindingPointWire
MGPipeBuildVertexBindingPointWire(const MG_State::GLState::VertexBufferBindingPoint& point) {
MGPVertexBindingPointWire wire{};
wire.Offset = static_cast<Uint64>(point.Offset);
wire.Stride = static_cast<Int32>(point.Stride);
wire.Divisor = static_cast<Uint32>(point.Divisor);
return wire;
}
// ---------------------------------------------------------------------------------
// D-H2.3: the content hash, WITH BaseInstance in it
// ---------------------------------------------------------------------------------
//
// A HARD REQUIREMENT, not a nicety. set_vertex_buffers is suppressed on an unchanged
// hash (SetHashSuppressor.h's SetVertexBuffers slot), so a baseInstance that moved while
// the buffer set did not would be suppressed and the server would keep the previous
// fetch shift - exactly the bug the backend's baseInstanceDirty flag exists to prevent.
inline Uint64 MGPipeVertexBufferSetContentHash(const MGPVertexBuffer* entries, Uint32 start, Uint32 count,
Uint32 baseInstance) {
Uint64 hash = XXH64(entries, static_cast<SizeT>(count) * sizeof(MGPVertexBuffer), 0);
hash = MGPipeMixShutter(hash, start);
hash = MGPipeMixShutter(hash, count);
hash = MGPipeMixShutter(hash, baseInstance);
return hash;
}
// ---------------------------------------------------------------------------------
// The emitter
// ---------------------------------------------------------------------------------
class MGPipeVertexInputEmitter {
public:
using GLContext = MG_State::GLState::GLContext;
using VertexArrayObject = MG_State::GLState::VertexArrayObject;
static constexpr SizeT kAttribs = static_cast<SizeT>(VertexArrayObject::MAX_VERTEX_ATTRIBS);
static constexpr SizeT kBindings = static_cast<SizeT>(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS);
static_assert(kAttribs <= kMGPipeMaxVertexAttribs && kBindings <= kMGPipeMaxVertexAttribs,
"both declared counts are bounded by kMGPipeMaxVertexAttribs");
// create/bind_vertex_elements. D-G3's three arms, verbatim:
//
// no VAO bound -> bind the null handle (legal, and it means
// exactly "no VAO bound")
// the bound VAO CHANGED -> (re)create if its configuration moved since
// this handle last published one, then bind
// the same VAO, configuration MOVED-> create on the SAME handle, and do NOT rebind
//
// The latch is PER HANDLE, in a slot-indexed table, so ping-ponging between two VAOs
// re-binds but never re-creates either. A Uint32 configuration version does not wrap
// in any realistic run and is compared directly; the tracker's widened counter is
// for the Uint16s and is not needed here.
Uint64 EmitVertexElements(GLContext& ctx) {
const auto& vao = ctx.GetBoundVertexArray();
if (!vao) {
if (!MGPipeHandleIsNull(m_boundHandle)) {
MGPipeApplyBindVertexElements(HandleOnly(kMGPipeNullHandle));
++m_binds;
m_boundHandle = kMGPipeNullHandle;
m_boundLifetimeId = 0;
}
return 0;
}
const Uint64 lifetimeId = vao->GetLifetimeId();
const Uint32 configVersion = vao->GetConfigVersion();
const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::VertexElementsCso, lifetimeId);
const SizeT slot = handle.Slot;
if (slot >= m_latch.size()) m_latch.resize(slot + 1);
Latch& latch = m_latch[slot];
Uint64 bytes = 0;
const Bool configMoved = !latch.Published || latch.ConfigVersion != configVersion ||
latch.Gen != handle.Gen;
if (configMoved) bytes += EmitCreate(*vao, handle, latch, configVersion);
if (lifetimeId != m_boundLifetimeId || m_boundHandle != handle) {
MGPipeApplyBindVertexElements(HandleOnly(handle));
++m_binds;
bytes += sizeof(MGPHandleOnly);
m_boundHandle = handle;
m_boundLifetimeId = lifetimeId;
}
return bytes;
}
// set_vertex_buffers. Espryt consumes RESOLVED attributes, so the set is one entry
// per attribute slot with BindingIndex == the attribute index; Start is 0 and Count
// is the highest ENABLED attribute plus one, which is the 32-slot prefix walk the
// dirty bit is specified over.
//
// A client-memory array is Res == kMGPipeNullHandle, and that is not a hole: it is
// exactly how the server learns "this attribute is client-sourced, upload it
// yourself". Its store genuinely does not exist at this moment - the client-array
// uploader runs after PrepareForDraw, at the draw entry point - and moving that
// resolution to the client is P8's.
Uint64 EmitVertexBuffers(GLContext& ctx, Uint32 baseInstance) {
const auto& vao = ctx.GetBoundVertexArray();
Uint32 count = 0;
if (vao) {
for (SizeT i = 0; i < kAttribs; ++i) {
if (vao->GetAttribute(static_cast<Uint>(i)).Enabled) count = static_cast<Uint32>(i) + 1;
}
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;
// 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;
entry.Stride = static_cast<Uint32>(attrib.Stride);
entry.Divisor = static_cast<Uint32>(attrib.Divisor);
entry.BindingIndex = static_cast<Uint32>(i);
}
}
const Uint64 hash = MGPipeVertexBufferSetContentHash(m_entries.data(), 0, count, baseInstance);
if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::SetVertexBuffers, hash)) {
return 0;
}
m_lastBuffers = MGPVertexBuffers{};
m_lastBuffers.Start = 0;
m_lastBuffers.Count = count;
// THE DRAW'S RAW value. The client never pre-shifts an offset and never learns
// whether the server emulated the shift or let GL_EXT_base_instance do it -
// emulation is server-owned.
m_lastBuffers.BaseInstance = baseInstance;
m_lastBuffers.ContentHash = hash;
MGPipeApplySetVertexBuffers(m_lastBuffers, m_entries.data());
++m_bufferSets;
return sizeof(MGPVertexBuffers) + static_cast<Uint64>(count) * sizeof(MGPVertexBuffer);
}
// set_index_buffer. An INDEPENDENT call, not a subset of the vertex-elements
// configuration version (D5) - the index slot is explicitly outside the VAO's
// m_configVersion, and the shutter for it is bit 10's, narrowed in Tracker.h.
//
// Offset and IndexSize are 0 here and the draw verb overrides them: at the validate
// point there is no draw to read them from, and the applier stores what it is given.
Uint64 EmitIndexBuffer(GLContext& ctx) {
const auto& vao = ctx.GetBoundVertexArray();
m_lastIndex = MGPIndexBuffer{};
if (vao) {
if (const auto& bound = vao->GetIndexBufferBindingSlot().GetBoundObject()) {
m_lastIndex.Res = MGPipeSlots().Acquire(MGPipeKind::Buffer, bound->GetLifetimeId());
}
}
MGPipeApplySetIndexBuffer(m_lastIndex);
++m_indexSets;
return sizeof(MGPIndexBuffer);
}
// ---- what a unit case reads. None of it costs a copy: the emitter builds INTO
// these and hands the applier the same pointers. ----
const Array<MGPVertexAttribWire, kMGPipeMaxVertexAttribs>& LastAttributes() const { return m_attributes; }
const Array<MGPVertexBindingPointWire, kMGPipeMaxVertexAttribs>& LastBindingPoints() const {
return m_bindingPoints;
}
const MGPVertexElements& LastElements() const { return m_lastElements; }
const MGPVertexBuffers& LastVertexBuffers() const { return m_lastBuffers; }
const Array<MGPVertexBuffer, kMGPipeMaxVertexAttribs>& LastEntries() const { return m_entries; }
const MGPIndexBuffer& LastIndexBuffer() const { return m_lastIndex; }
MGPipeHandle BoundHandle() const { return m_boundHandle; }
Uint64 CreateCount() const { return m_creates; }
Uint64 BindCount() const { return m_binds; }
Uint64 VertexBufferSetCount() const { return m_bufferSets; }
Uint64 IndexBufferSetCount() const { return m_indexSets; }
// A fresh context is a fresh server: the applier's records are gone, so every latch
// this emitter holds describes objects the server no longer has. Called from the
// validate point's FreshlyPrimed arm beside MGPipeApplierReset and the suppressor's
// InvalidateAll, for the same reason they are.
void Reset() {
m_latch.clear();
m_boundHandle = kMGPipeNullHandle;
m_boundLifetimeId = 0;
}
void ResetCounters() { m_creates = m_binds = m_bufferSets = m_indexSets = 0; }
private:
struct Latch {
Bool Published = false;
Uint32 Gen = 0;
Uint32 ConfigVersion = 0;
};
static MGPHandleOnly HandleOnly(MGPipeHandle handle) {
MGPHandleOnly only{};
only.Handle = handle;
only.Kind = static_cast<Uint32>(MGPipeKind::VertexElementsCso);
return only;
}
Uint64 EmitCreate(const VertexArrayObject& vao, MGPipeHandle handle, Latch& latch, Uint32 configVersion) {
// ALL 32 OF EACH, deliberately. The record DECLARES both counts and the applier
// refuses one whose counts do not describe its own blob, so a self-describing
// record is the cheap shape - and G6 is stated over all 32 slots, which a
// truncated set could not answer. It rides create_vertex_elements only, i.e.
// once per configuration change, never per draw.
for (SizeT i = 0; i < kAttribs; ++i) {
m_attributes[i] = MGPipeBuildVertexAttribWire(vao.GetAttribute(static_cast<Uint>(i)),
vao.GetAttributeBindingIndex(static_cast<Uint>(i)));
}
for (SizeT i = 0; i < kBindings; ++i) {
m_bindingPoints[i] = MGPipeBuildVertexBindingPointWire(vao.GetBindingPoint(static_cast<Uint>(i)));
}
// Attributes first, then binding points, both ascending and contiguous.
constexpr SizeT kAttribBytes = kAttribs * sizeof(MGPVertexAttribWire);
constexpr SizeT kBindingBytes = kBindings * sizeof(MGPVertexBindingPointWire);
std::memcpy(m_blob.data(), m_attributes.data(), kAttribBytes);
std::memcpy(m_blob.data() + kAttribBytes, m_bindingPoints.data(), kBindingBytes);
m_lastElements = MGPVertexElements{};
m_lastElements.Cso = handle;
m_lastElements.AttributeCount = static_cast<Uint32>(kAttribs);
m_lastElements.BindingPointCount = static_cast<Uint32>(kBindings);
m_lastElements.Blob.Seg = kMGHostSpanSegNone;
m_lastElements.Blob.Offset = 0;
m_lastElements.Blob.Size = kAttribBytes + kBindingBytes;
MGPipeApplyCreateVertexElements(m_lastElements, m_blob.data());
++m_creates;
latch.Published = true;
latch.Gen = handle.Gen;
latch.ConfigVersion = configVersion;
return sizeof(MGPVertexElements) + kAttribBytes + kBindingBytes;
}
Array<MGPVertexAttribWire, kMGPipeMaxVertexAttribs> m_attributes{};
Array<MGPVertexBindingPointWire, kMGPipeMaxVertexAttribs> m_bindingPoints{};
Array<Uint8, kMGPipeMaxVertexAttribs *(sizeof(MGPVertexAttribWire) + sizeof(MGPVertexBindingPointWire))>
m_blob{};
Array<MGPVertexBuffer, kMGPipeMaxVertexAttribs> m_entries{};
MGPVertexElements m_lastElements{};
MGPVertexBuffers m_lastBuffers{};
MGPIndexBuffer m_lastIndex{};
Vector<Latch> m_latch;
MGPipeHandle m_boundHandle = kMGPipeNullHandle;
Uint64 m_boundLifetimeId = 0;
Uint64 m_creates = 0;
Uint64 m_binds = 0;
Uint64 m_bufferSets = 0;
Uint64 m_indexSets = 0;
};
// The monolith's one vertex-input emitter, beside the tracker, the CSO cache, the
// set-hash suppressor and the resource tracker.
inline MGPipeVertexInputEmitter& MGPipeVertexInputEmitterInstance() {
static MGPipeVertexInputEmitter emitter;
return emitter;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+13 -1
View File
@@ -302,10 +302,17 @@ namespace {
// The five P2 emits for each name their own subsystem; the rest name none, which is what
// makes MOBILEGL_PIPE_PUSH a per-subsystem A/B instead of one switch.
// THE NAME IS P2's AND IT STAYS. A test name is never removed (only added), so this case
// keeps the name it was born with and follows the phase constant instead of a literal
// five: what it has always asserted is "a bit names a subsystem if and only if this build
// emits a call for it", which is the property the emission gate and the residual-fill
// skip both rest on. P3a took the vertex-input family over, so the set it compares
// against is now kMGPipeDirtyEmittedAtP3a - and a bit that gained an arm without gaining
// an emitter, or the reverse, still fails here.
TEST_F(TrackerWalk, OnlyTheFiveEmittedBitsNameASubsystem) {
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
const auto bit = static_cast<MGPipeDirty>(i);
const Bool emitted = (kMGPipeDirtyEmittedAtP2 & MGPipeDirtyBit(bit)) != 0;
const Bool emitted = (kMGPipeDirtyEmittedAtP3a & MGPipeDirtyBit(bit)) != 0;
EXPECT_EQ(MGPipeSubsystemForDirty(bit) != 0, emitted) << kMGPipeDirtyNames[i];
}
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewRenderState), kMGPipeSubsystemRenderState);
@@ -313,6 +320,11 @@ namespace {
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPatchState), kMGPipeSubsystemPatchState);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexAttribDefaults),
kMGPipeSubsystemVertexAttribDefaults);
// P3a's three, one subsystem: an operator who clears bit 8 gets the whole legacy
// vertex-input arm rather than two thirds of it.
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexElements), kMGPipeSubsystemVertexInput);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexBuffers), kMGPipeSubsystemVertexInput);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewIndexBuffer), kMGPipeSubsystemVertexInput);
}
TEST_F(TrackerWalk, TheFirstWalkOnAFreshContextPublishesEverything) {