From a11a5eb2afb233110a75e88e27a5375f51879047 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 8 Sep 2026 12:54:00 -0400 Subject: [PATCH] [Feat] (Pipe): push the bound framebuffers with a resolved read surface, inline attachment formats and a content hash that covers the draw-buffer array --- MobileGL/MG_Impl/Pipe/FramebufferEmit.h | 414 +++++++++++++++++++++++- 1 file changed, 406 insertions(+), 8 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/FramebufferEmit.h b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h index 9d08ee49..0c7ab4de 100644 --- a/MobileGL/MG_Impl/Pipe/FramebufferEmit.h +++ b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h @@ -26,9 +26,18 @@ // CMakeLists.txt that would name a new .cpp is the contract package's and is frozen behind the // tag. MG_Impl/Pipe/PipeFill.cpp is the one translation unit that includes it in the library. #if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include #include #include #include +#include + +#include + +#include namespace MobileGL::MG_Pipe { @@ -39,23 +48,412 @@ namespace MobileGL::MG_Pipe { // before the call that carries it exists. inline constexpr Uint64 kMGPipeWiredFramebufferSubsystem = 0; - // set_framebuffer_state. STUB AT THE CONTRACT COMMIT: it emits nothing and returns 0 - // payload bytes, so the validate point's ladder has its final shape and the package that - // fills this in never edits PipeFill.cpp. + inline Bool MGPipeFramebufferSubsystemEnabled() { + return (kMGPipeWiredFramebufferSubsystem & kMGPipeSubsystemFramebuffer) != 0 && + (MG_Config::Features.PipePush & kMGPipeSubsystemFramebuffer) != 0; + } + + // --------------------------------------------------------------------------------- + // D-C1: the MGPSurface builder, one pure function, one statement per field + // --------------------------------------------------------------------------------- + + // MGPSurface::Kind. MGPipeKind is REUSED rather than a second three-value enum minted + // beside it: it already spells Texture and Renderbuffer, its None is 0, and a + // zero-initialised MGPSurface therefore already IS the empty attachment point the contract + // describes ({Res = kMGPipeNullHandle, Kind = None} and every other field zero). If the + // contract package later wants a dedicated enumeration beside MGPSurface it is a rename, + // not a re-encoding. + inline constexpr Uint8 kMGPipeSurfaceKindNone = static_cast(MGPipeKind::None); + inline constexpr Uint8 kMGPipeSurfaceKindTexture = static_cast(MGPipeKind::Texture); + inline constexpr Uint8 kMGPipeSurfaceKindRenderbuffer = static_cast(MGPipeKind::Renderbuffer); + static_assert(kMGPipeSurfaceKindNone == 0, + "a zero-initialised MGPSurface must already be the empty attachment point"); + + // The upload target an attachment names, RESOLVED: an attachment made through an entry + // point that carries no face token stores TextureUploadTarget::Unknown, and the record goes + // out fully resolved - nothing in it may require a lookup on the far side. This is the same + // fallback FramebufferAttachmentObject::GetSize already applies to answer its own question. + inline MobileGL::TextureUploadTarget MGPipeResolveAttachmentUploadTarget( + const MG_State::GLState::FramebufferAttachmentObject& attachment) { + MobileGL::TextureUploadTarget resolved = attachment.GetTextureUploadTarget(); + if (resolved != MobileGL::TextureUploadTarget::Unknown) return resolved; + const auto& texture = attachment.GetTexture(); + if (!texture) return MobileGL::TextureUploadTarget::Unknown; + const auto& targets = texture->GetUploadTargets(); + return targets.empty() ? MobileGL::TextureUploadTarget::Unknown : targets[0]; + } + + // ONE PURE FUNCTION, ONE STATEMENT PER FIELD, and that shape is a gate requirement rather + // than taste: G7's scripted control stops this conversion copying exactly one member + // (MGPSurface::Layered) and expects the framebuffer suite to go red NAMING that field. A + // loop or a memcpy would make the control unanswerable. + // + // `res` is handed in because resolving it needs the slot allocator and this function stays + // pure; `internalFormat` is INLINE in the record on purpose, so the four cross-object masks + // fall out at push time with no lookup on the far side. + inline MGPSurface MGPipeBuildSurface(const MG_State::GLState::FramebufferAttachmentObject& attachment, + MGPipeHandle res) { + MGPSurface surface{}; + if (attachment.IsEmpty()) return surface; + surface.Res = res; + if (attachment.IsTexture()) { + const auto& texture = attachment.GetTexture(); + surface.Kind = kMGPipeSurfaceKindTexture; + surface.InternalFormat = static_cast(texture->GetFormat()); + surface.Layered = attachment.IsLayered() ? 1 : 0; + surface.Level = static_cast(std::max(attachment.GetTextureLevel(), 0)); + surface.Layer = static_cast(std::max(attachment.GetTextureLayer(), 0)); + surface.UploadTarget = static_cast(MGPipeResolveAttachmentUploadTarget(attachment)); + return surface; + } + const auto& renderbuffer = attachment.GetRenderbuffer(); + surface.Kind = kMGPipeSurfaceKindRenderbuffer; + surface.InternalFormat = static_cast(renderbuffer->GetInternalFormat()); + surface.Layered = 0; + surface.Level = 0; + surface.Layer = 0; + surface.UploadTarget = 0; + return surface; + } + + // MGPFramebufferState::DrawBuffers[i]: an index INTO THIS RECORD'S OWN Color[] array, and + // -1 for NONE, which is the field's documented convention read literally. + // + // THE FOUR DEFAULT-FRAMEBUFFER TOKENS map to 0, and that is a deliberate narrowing rather + // than an oversight: a default framebuffer has one colour surface, this record carries it + // in Color[0] (see MGPipeBuildFramebufferState), and IsDefault is what tells the server + // which framebuffer it is looking at. The distinction the narrowing loses is FRONT versus + // BACK and LEFT versus RIGHT, which MobileGL's frontend never gives a default framebuffer + // in the first place - FramebufferObject's constructor seeds BackLeft and nothing writes + // another. A phase that needs stereo has to widen the field, not re-encode this one. + inline Int8 MGPipeDrawBufferIndex(MobileGL::FramebufferAttachmentType buffer) { + using MobileGL::FramebufferAttachmentType; + if (buffer == FramebufferAttachmentType::None) return -1; + if (buffer >= FramebufferAttachmentType::Color0 && buffer <= FramebufferAttachmentType::ColorMax) { + return static_cast(static_cast(buffer) - static_cast(FramebufferAttachmentType::Color0)); + } + return 0; + } + + // --------------------------------------------------------------------------------- + // D-C4: ContentHash, and the one input it must not swallow + // --------------------------------------------------------------------------------- + // + // XXH64 over the WHOLE record with ContentHash itself zeroed, computed field-wise into a + // zero-initialised staging copy so that no padding byte can enter the hash. Two jobs: the + // server's render-pass memo key, and this client's emission suppressor. + // + // IT MUST COVER Fbo. A recycled framebuffer handle whose successor happens to carry an + // identical attachment set would otherwise be suppressed against its predecessor; Fbo + // carries Gen, so it cannot be. + // + // IT MUST COVER DrawBuffers[8], and this is the trap worth naming. The backend derives the + // fragColor BROADCAST COUNT from the draw-buffer array, and it does that at the verb, from + // the framebuffer state it then holds, precisely so a program can relink inside the same + // draw. A hash that did not cover the array would let a suppressed set_framebuffer_state + // mean "the draw buffers did not move" when they had, and the shader would be specialised + // for the previous output shape. With the array in the hash, a suppression provably means + // the array did not move, which provably means the broadcast count did not move. + inline void MGPipeCopySurfaceForHash(MGPSurface& dst, const MGPSurface& src) { + dst.Res = src.Res; + dst.InternalFormat = src.InternalFormat; + dst.Kind = src.Kind; + dst.Layered = src.Layered; + dst.Level = src.Level; + dst.Layer = src.Layer; + dst.UploadTarget = src.UploadTarget; + } + + inline Uint64 MGPipeFramebufferStateContentHash(const MGPFramebufferState& state) { + MGPFramebufferState staging{}; + staging.Fbo = state.Fbo; + for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) { + MGPipeCopySurfaceForHash(staging.Color[i], state.Color[i]); + } + MGPipeCopySurfaceForHash(staging.Depth, state.Depth); + MGPipeCopySurfaceForHash(staging.Stencil, state.Stencil); + MGPipeCopySurfaceForHash(staging.ReadSurface, state.ReadSurface); + for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) { + staging.DrawBuffers[i] = state.DrawBuffers[i]; + } + staging.Width = state.Width; + staging.Height = state.Height; + staging.Layers = state.Layers; + staging.Samples = state.Samples; + staging.FixedSampleLocations = state.FixedSampleLocations; + staging.IsDefault = state.IsDefault; + staging.Complete = state.Complete; + staging.Target = state.Target; + // staging.ContentHash stays 0 - that is the whole point. + return XXH64(&staging, sizeof(staging), 0); + } + + // --------------------------------------------------------------------------------- + // The emitter + // --------------------------------------------------------------------------------- + class MGPipeFramebufferEmitter { public: using GLContext = MG_State::GLState::GLContext; + using FramebufferObject = MG_State::GLState::FramebufferObject; + using FramebufferAttachmentType = MobileGL::FramebufferAttachmentType; + + // The handle for `fbo`. kMGPipeDefaultFramebuffer ({0,1}) for the default framebuffer, + // which is what retires the four pDefaultFramebufferInfo->defaultFBO identity + // comparisons into an ordinary handle compare; a client-minted {slot, gen} otherwise. + // + // Minted, never gated: a framebuffer handle is CLIENT state and costs one free-list pop. + static MGPipeHandle HandleFor(const FramebufferObject& fbo) { + if (fbo.IsDefaultFramebuffer()) return kMGPipeDefaultFramebuffer; + return MGPipeSlots().Acquire(MGPipeKind::Framebuffer, fbo.GetLifetimeId()); + } // Returns the bytes that went on the wire, for the per-draw payload histogram. Uint64 EmitFramebufferState(GLContext& ctx) { - (void)ctx; - return 0; + if (!MGPipeFramebufferSubsystemEnabled()) return 0; + const auto& drawFbo = ctx.GetFramebufferBindingSlot(MobileGL::FramebufferTarget::Draw).GetBoundObject(); + const auto& readFbo = ctx.GetFramebufferBindingSlot(MobileGL::FramebufferTarget::Read).GetBoundObject(); + if (!drawFbo && !readFbo) return 0; + + // ONE OBJECT BOUND TO BOTH TARGETS IS ONE RECORD WITH Target = Both, and that is + // not an optimisation: Espryt's "same FBO as draw" skip is the habitat of the + // read-buffer defect class, and a record that says which target it describes turns + // "apply the draw buffers only for the draw target" from call-site discipline into + // a one-line test on the far side. + const Bool shared = drawFbo && readFbo && drawFbo.get() == readFbo.get(); + + MGPFramebufferState drawState{}; + MGPFramebufferState readState{}; + Bool drawOk = false; + Bool readOk = false; + if (shared) { + drawOk = BuildFramebufferState(*drawFbo, *drawFbo, MGPipeFramebufferTarget::Both, drawState); + } else { + if (drawFbo) { + drawOk = BuildFramebufferState(*drawFbo, readFbo ? *readFbo : *drawFbo, + MGPipeFramebufferTarget::Draw, drawState); + } + if (readFbo) { + readOk = BuildFramebufferState(*readFbo, *readFbo, MGPipeFramebufferTarget::Read, readState); + } + } + if (!drawOk && !readOk) return 0; + + // THE SUPPRESSOR SLOT IS FED THE COMBINED ANSWER and the per-target latches decide + // which of the two records actually goes out. The slot exists so that + // InvalidateAll() on a fresh context reaches this family like every other, and so + // that "nothing moved" costs one compare rather than two. + const Uint64 drawHash = drawOk ? drawState.ContentHash : 0; + const Uint64 readHash = readOk ? readState.ContentHash : 0; + const Uint64 combined = + MGPipeMixShutter(MGPipeMixShutter(drawHash, readHash), shared ? 1u : 0u); + if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::SetFramebufferState, + combined)) { + return 0; + } + + Uint64 bytes = 0; + if (shared) { + if (drawOk && (drawHash != m_lastEmitted[kDraw] || drawHash != m_lastEmitted[kRead])) { + bytes += Emit(drawState); + m_lastEmitted[kDraw] = drawHash; + m_lastEmitted[kRead] = drawHash; + } + return bytes; + } + if (drawOk && drawHash != m_lastEmitted[kDraw]) { + bytes += Emit(drawState); + m_lastEmitted[kDraw] = drawHash; + } + if (readOk && readHash != m_lastEmitted[kRead]) { + bytes += Emit(readState); + m_lastEmitted[kRead] = readHash; + } + return bytes; } + // ---- what a unit case reads. The emitter builds INTO these and hands the applier the + // same objects, so "what was emitted" costs no copy. ---- + const MGPFramebufferState& LastDraw() const { return m_lastDraw; } + const MGPFramebufferState& LastRead() const { return m_lastRead; } + Uint64 EmissionCount() const { return m_emissions; } + Uint64 RefusedCount() const { return m_refusals; } + // A fresh context: what the server has is no longer what this emitter last sent. Only - // LATCHES reset here - the applier's object records survive a make-current and - // re-publishing them would move their serials for nothing. - void Reset() {} + // LATCHES reset here - MGPipeApplierReset clears the applier's DrawFramebuffer and + // ReadFramebuffer working state, so these mirrors have to go with them or the first + // emission after a make-current would be suppressed as unchanged and the server would + // draw into the previous context's framebuffer. The suppressor slot is invalidated by + // the validate point's own InvalidateAll(), beside this call. + void Reset() { + m_lastEmitted[kDraw] = 0; + m_lastEmitted[kRead] = 0; + } + + void ResetCounters() { m_emissions = m_refusals = 0; } + + void ResetForTest() { + Reset(); + ResetCounters(); + m_lastDraw = MGPFramebufferState{}; + m_lastRead = MGPFramebufferState{}; + } + + private: + static constexpr SizeT kDraw = 0; + static constexpr SizeT kRead = 1; + + Uint64 Emit(const MGPFramebufferState& state) { + if (state.Target == static_cast(MGPipeFramebufferTarget::Read)) { + m_lastRead = state; + } else { + m_lastDraw = state; + if (state.Target == static_cast(MGPipeFramebufferTarget::Both)) m_lastRead = state; + } + MGPipeApplySetFramebufferState(state); + ++m_emissions; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::FramebufferEmissions, 1); + } + return sizeof(MGPFramebufferState); + } + + // `fbo` is the object this record describes; `readFbo` is the object whose OWN read + // buffer resolves ReadSurface, and for a Draw record that is the read-bound object + // rather than this one. + // + // THE RESOLVED READ SURFACE IS WHAT STRUCTURALLY CLOSES THE read-buffer-shared-FBO + // DEFECT CLASS: the record carries the surface, not an index, and it is resolved from + // the READ framebuffer's own read buffer, so the "same FBO as draw" skip that used to + // lose it cannot be expressed. + Bool BuildFramebufferState(const FramebufferObject& fbo, const FramebufferObject& readFbo, + MGPipeFramebufferTarget target, MGPFramebufferState& out) { + // D-C3, THE CLIENT HALF OF THE BRING-UP REFUSAL. The wire array is 8 wide and + // GetDynamicParameters().MaxColorAttachments is the driver's raw ES cap, not + // clamped to 8 on the GLES path. An attachment point at or above the wire width + // cannot be carried at all, so the record is REFUSED and the legacy arm runs - + // truncating it silently is exactly the bug class this phase is closing. The + // backend half of the same refusal (bit 9 declined at its first lookup, with one + // ERROR naming the cap) rides ResolveFramebufferSubsystemArm. + for (Int point = static_cast(FramebufferAttachmentType::Color0) + + static_cast(kMGPipeMaxColorAttachments); + point <= static_cast(FramebufferAttachmentType::ColorMax); ++point) { + if (fbo.GetAttachment(static_cast(point)).IsEmpty()) continue; + MGLOG_E_ONCE("MGPipe: framebuffer %u has an attachment at colour point %d, which is at or " + "above the wire width of %u - set_framebuffer_state is refused rather than " + "truncated and the legacy arm runs", + fbo.GetExternalIndex(), + point - static_cast(FramebufferAttachmentType::Color0), + static_cast(kMGPipeMaxColorAttachments)); + ++m_refusals; + return false; + } + + out = MGPFramebufferState{}; + out.Fbo = HandleFor(fbo); + out.Target = static_cast(target); + out.IsDefault = fbo.IsDefaultFramebuffer() ? 1 : 0; + + // THE COLOUR POINTS. A default framebuffer keeps its one colour surface under + // BackLeft rather than under Color0, and the record has exactly one place to put + // it: Color[0], which is also the index MGPipeDrawBufferIndex maps that token to, + // so the array and the draw-buffer indices agree by construction. + if (out.IsDefault != 0) { + out.Color[0] = SurfaceOf(fbo, FramebufferAttachmentType::BackLeft); + } else { + for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) { + out.Color[i] = SurfaceOf(fbo, static_cast( + static_cast(FramebufferAttachmentType::Color0) + + static_cast(i))); + } + } + out.Depth = SurfaceOf(fbo, FramebufferAttachmentType::Depth); + out.Stencil = SurfaceOf(fbo, FramebufferAttachmentType::Stencil); + out.ReadSurface = SurfaceOf(readFbo, readFbo.GetReadBuffer()); + + const auto& drawBuffers = fbo.GetDrawBuffers(); + for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) { + out.DrawBuffers[i] = MGPipeDrawBufferIndex(drawBuffers[i]); + } + + FillGeometry(fbo, out); + // Complete is FramebufferObject::CheckCompleteness(), the FRONTEND-ONLY answer, and + // never glCheckFramebufferStatus's: that entry point additionally consults the + // backend's probed format-capability cache, and a client emitting it would be + // reading the backend from the client side - the exact coupling this boundary + // exists to remove. glCheckFramebufferStatus keeps answering from the frontend + // exactly as it does today. + out.Complete = fbo.CheckCompleteness() ? 1 : 0; + out.ContentHash = MGPipeFramebufferStateContentHash(out); + return true; + } + + MGPSurface SurfaceOf(const FramebufferObject& fbo, FramebufferAttachmentType type) { + if (type == FramebufferAttachmentType::None || type == FramebufferAttachmentType::Unknown) { + return MGPSurface{}; + } + const auto& attachment = fbo.GetAttachment(type); + if (attachment.IsEmpty()) return MGPSurface{}; + MGPipeTextureEmitter& textures = MGPipeTextureEmitterInstance(); + // D-A4's two producers: an attachment point is what sets RENDER_TARGET and + // DEPTH_STENCIL, the two sticky bind bits nothing set before P4a. Sticky and ORed, + // so a texture that was ever a colour attachment keeps saying so, and the mask is + // republished on the resource's next respecify. + const Uint16 bit = (type == FramebufferAttachmentType::Depth || + type == FramebufferAttachmentType::Stencil) + ? static_cast(kMGPipeBindDepthStencil) + : static_cast(kMGPipeBindRenderTarget); + MGPipeHandle res = kMGPipeNullHandle; + if (attachment.IsTexture()) { + const auto& texture = attachment.GetTexture(); + res = textures.AcquireTexture(texture->GetLifetimeId(), texture.get()); + textures.NoteTextureBoundAs(res, bit); + } else if (attachment.IsRenderbuffer()) { + const auto& renderbuffer = attachment.GetRenderbuffer(); + res = textures.AcquireRenderbuffer(renderbuffer->GetLifetimeId()); + textures.NoteRenderbufferBoundAs(res, bit); + } + return MGPipeBuildSurface(attachment, res); + } + + // The attachments' common extent, and the ARB_framebuffer_no_attachments defaults when + // there is no attachment at all (GL 4.6 core table 23.24 - the shape a framebuffer with + // no attachments rasterizes at). + static void FillGeometry(const FramebufferObject& fbo, MGPFramebufferState& out) { + Bool found = false; + for (const auto& attachment : fbo.GetAllAttachmentObjects()) { + if (attachment.IsEmpty()) continue; + const IntVec3 size = attachment.GetSize(); + if (!found) { + out.Width = static_cast(std::clamp(size.x(), 0, 0xFFFF)); + out.Height = static_cast(std::clamp(size.y(), 0, 0xFFFF)); + out.Layers = static_cast( + attachment.IsLayered() ? std::clamp(size.z(), 1, 0xFFFF) : 1); + if (attachment.IsTexture()) { + const auto& texture = attachment.GetTexture(); + out.Samples = static_cast(std::max(texture->GetSamples(), 0)); + out.FixedSampleLocations = texture->HasFixedSampleLocations() ? 1 : 0; + } else { + out.Samples = static_cast( + std::max(attachment.GetRenderbuffer()->GetSamples(), 0)); + out.FixedSampleLocations = 1; + } + found = true; + } + } + if (found) return; + out.Width = static_cast(std::clamp(fbo.GetDefaultWidth(), 0, 0xFFFF)); + out.Height = static_cast(std::clamp(fbo.GetDefaultHeight(), 0, 0xFFFF)); + out.Layers = static_cast(std::clamp(fbo.GetDefaultLayers(), 0, 0xFFFF)); + out.Samples = static_cast(std::clamp(fbo.GetDefaultSamples(), 0, 0xFFFF)); + out.FixedSampleLocations = fbo.GetDefaultFixedSampleLocations() ? 1 : 0; + } + + Array m_lastEmitted{}; + MGPFramebufferState m_lastDraw{}; + MGPFramebufferState m_lastRead{}; + Uint64 m_emissions = 0; + Uint64 m_refusals = 0; }; inline MGPipeFramebufferEmitter& MGPipeFramebufferEmitterInstance() {