From 14efd6eb2428bc29a411098f36559519d2cd3f5f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 8 Sep 2026 02:50:53 -0400 Subject: [PATCH] [Feat] (Pipe): push the bound VAO's format, its vertex buffers with an explicit baseInstance, and its index binding as their own calls --- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 83 +++-- MobileGL/MG_Impl/Pipe/PipeFill.h | 26 ++ MobileGL/MG_Impl/Pipe/SetHashSuppressor.h | 12 +- MobileGL/MG_Impl/Pipe/Tracker.h | 115 +++++-- MobileGL/MG_Impl/Pipe/VertexInputEmit.h | 351 ++++++++++++++++++++++ MobileGL/MG_Test/Pipe/TrackerTest.cpp | 14 +- 6 files changed, 559 insertions(+), 42 deletions(-) create mode 100644 MobileGL/MG_Impl/Pipe/VertexInputEmit.h diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index de97b20a..9034cc16 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -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 - 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(); diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.h b/MobileGL/MG_Impl/Pipe/PipeFill.h index f85a07b2..0eeba017 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.h +++ b/MobileGL/MG_Impl/Pipe/PipeFill.h @@ -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 diff --git a/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h b/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h index 20081bd2..70881257 100644 --- a/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h +++ b/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h @@ -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 diff --git a/MobileGL/MG_Impl/Pipe/Tracker.h b/MobileGL/MG_Impl/Pipe/Tracker.h index 4ec0d6e8..e8ac9902 100644 --- a/MobileGL/MG_Impl/Pipe/Tracker.h +++ b/MobileGL/MG_Impl/Pipe/Tracker.h @@ -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(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(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{}; diff --git a/MobileGL/MG_Impl/Pipe/VertexInputEmit.h b/MobileGL/MG_Impl/Pipe/VertexInputEmit.h new file mode 100644 index 00000000..00a35c28 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/VertexInputEmit.h @@ -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 + +// 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 +#include +#include +#include +#include +#include +#include + +#include + +#include + +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(attrib.Offset); + wire.Stride = static_cast(attrib.Stride); + wire.Type = static_cast(attrib.Type); + wire.Size = static_cast(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(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(point.Offset); + wire.Stride = static_cast(point.Stride); + wire.Divisor = static_cast(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(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(VertexArrayObject::MAX_VERTEX_ATTRIBS); + static constexpr SizeT kBindings = static_cast(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(i)).Enabled) count = static_cast(i) + 1; + } + for (SizeT i = 0; i < count; ++i) { + const auto& attrib = vao->GetAttribute(static_cast(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(attrib.Stride); + entry.Divisor = static_cast(attrib.Divisor); + entry.BindingIndex = static_cast(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(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& LastAttributes() const { return m_attributes; } + const Array& LastBindingPoints() const { + return m_bindingPoints; + } + const MGPVertexElements& LastElements() const { return m_lastElements; } + const MGPVertexBuffers& LastVertexBuffers() const { return m_lastBuffers; } + const Array& 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(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(i)), + vao.GetAttributeBindingIndex(static_cast(i))); + } + for (SizeT i = 0; i < kBindings; ++i) { + m_bindingPoints[i] = MGPipeBuildVertexBindingPointWire(vao.GetBindingPoint(static_cast(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(kAttribs); + m_lastElements.BindingPointCount = static_cast(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 m_attributes{}; + Array m_bindingPoints{}; + Array + m_blob{}; + Array m_entries{}; + + MGPVertexElements m_lastElements{}; + MGPVertexBuffers m_lastBuffers{}; + MGPIndexBuffer m_lastIndex{}; + + Vector 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 diff --git a/MobileGL/MG_Test/Pipe/TrackerTest.cpp b/MobileGL/MG_Test/Pipe/TrackerTest.cpp index 6cfd1453..0e6e82dc 100644 --- a/MobileGL/MG_Test/Pipe/TrackerTest.cpp +++ b/MobileGL/MG_Test/Pipe/TrackerTest.cpp @@ -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(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) {