Loading...
Searching...
No Matches
GpuResourceRegistry.hpp
1#pragma once
2
3#include <score_plugin_gfx_export.h>
4
5#include <ossia/dataflow/geometry_port.hpp> // ossia::gpu_slot_ref
6#include <ossia/detail/flat_map.hpp>
7#include <ossia/detail/hash_map.hpp>
8
9#ifndef OFFSETALLOCATOR_HPP_2026_04_24
10#define OFFSETALLOCATOR_HPP_2026_04_24
11#include <offsetAllocator.hpp>
12#endif
13
14#include <QtGui/private/qrhi_p.h>
15
16#include <array>
17#include <cstdint>
18#include <memory>
19#include <vector>
20
21namespace score::gfx
22{
23class RenderList;
24
43class SCORE_PLUGIN_GFX_EXPORT GpuResourceRegistry
44{
45public:
46 // Well-known arenas. Size tables live in GpuResourceRegistry.cpp and match the
47 // packed GPU layouts in SceneGPUState.hpp and CameraMath.hpp. Every entry
48 // implies a QRhiBuffer allocation at init time.
49 //
50 // The Raw* arenas are written by source halp nodes at their own operator()()
51 // time: view-independent, aspect-ratio-agnostic, pre-composition. The cooked
52 // arenas are populated by ScenePreprocessor's transform passes, which combine
53 // Raw inputs with the render target's aspect ratio and the scene-graph parent
54 // chain, and are what consumer shaders bind. Material and Env have no
55 // composition dependency, so source nodes write their cooked slot directly.
56 enum class Arena : uint8_t
57 {
58 // ── Shared / source-authored ──────────────────────────────────
59 // These arenas hold view- and filter-independent bytes: every
60 // preprocessor reads the same data regardless of its camera /
61 // render target / upstream scene filtering. The producer owns the
62 // slot; multiple preprocessors consume via gpu_slot_ref + isLive().
63 RawCamera, // RawCameraData — 64 B per slot, UBO
64 RawLight, // RawLightData — 64 B per slot, SSBO
65 RawTransform, // RawLocalTransform — 64 B per slot, SSBO
66 Material, // MaterialGPU — 64 B per slot, SSBO
67 Env, // EnvParamsUBO — 64 B per slot, UBO
68
69 // Cooked outputs (camera UBOs, composed world matrices, per-draw structs,
70 // LightGPU with world direction, MaterialGPU with resolved textureRefs) are
71 // preprocessor-private: they are view- and filter-dependent, so a shared arena
72 // would be wrong when two preprocessors see different filtered views.
73
74 Count_
75 };
76
77 // Fixed-stride slot: the arena buffer is a packed array of stride-byte slots,
78 // slot i at byte offset i * stride. The slot index is the arena-level identity
79 // consumer shaders use, e.g. scene_materials.entries[slot_index]. Allocation is
80 // O(1) through a free-list stack. Trades OffsetAllocator's variable-size
81 // flexibility for a shader-indexable layout and a 1:1 mapping between
82 // internal_index and byte offset, so arena reads need no translation table.
83 struct Slot
84 {
85 static constexpr uint32_t kInvalidIndex = 0xFFFFFFFFu;
86
87 Arena arena{Arena::RawCamera};
88 uint32_t slot_index{kInvalidIndex};
89 uint32_t size{0}; // requested payload size (≤ arena stride)
90 uint32_t generation{}; // stamped on allocate; bumps on free
91
92 bool valid() const noexcept { return slot_index != kInvalidIndex; }
93 };
94
95 GpuResourceRegistry() = default;
97 GpuResourceRegistry& operator=(const GpuResourceRegistry&) = delete;
99
114 void init(QRhi& rhi, QRhiResourceUpdateBatch& batch);
115
121 bool isInitialized() const noexcept { return m_rhi != nullptr; }
122
129 QRhi* boundRhi() const noexcept { return m_rhi; }
130
144 void seedDefaults(QRhiResourceUpdateBatch& batch);
145
155 void destroy(RenderList& renderer);
156
164 void destroy();
165
179 void destroyOwned();
180
185 Slot allocate(Arena arena, uint32_t size);
186
190 void free(Slot& slot);
191
198 QRhiBuffer* buffer(Arena arena) const noexcept;
199
203 uint32_t slotOffset(const Slot& slot) const noexcept;
204
210 uint32_t arenaSlotStride(Arena arena) const noexcept;
211
215 uint32_t arenaSlotCount(Arena arena) const noexcept;
216
225 void updateSlot(
226 QRhiResourceUpdateBatch& res, const Slot& slot, const void* data,
227 uint32_t size) noexcept;
228
237 ossia::gpu_slot_ref toOssiaRef(const Slot& slot) const noexcept
238 {
239 if(!slot.valid())
240 return {};
241 ossia::gpu_slot_ref r;
242 r.arena = (uint32_t)slot.arena;
243 r.offset = slotOffset(slot);
244 r.size = slot.size;
245 r.internal_index = slot.slot_index;
246 r.generation = slot.generation;
247 return r;
248 }
249
264 bool isLiveIn(const ossia::gpu_slot_ref& r, Arena expected) const noexcept
265 {
266 return r.arena == (uint32_t)expected && isLive(r);
267 }
268
276 bool isLive(const ossia::gpu_slot_ref& r) const noexcept
277 {
278 if(r.arena >= (uint32_t)Arena::Count_ || r.size == 0)
279 return false;
280 const auto& a = m_arenas[r.arena];
281 if(r.internal_index >= a.slot_generations.size())
282 return false;
283 return a.slot_generations[r.internal_index] == r.generation;
284 }
285
286 // ─── Material texture arrays ──────────────────────────────────────
287 //
288 // Per-channel static texture arrays shared across every preprocessor in this
289 // RenderList. Static textures dedup by texture_source pointer, so every producer
290 // referencing an asset gets the same layer; dynamic handles (video textures,
291 // runtime GPU outputs) get per-slot bindings in `dynamicTextures`, named
292 // <channel>Dyn<slot> in consumer shaders.
293 //
294 // Sharing is safe because a texture belongs to an asset or a wired GPU handle,
295 // independent of which preprocessor is looking.
296
297 enum class TextureChannel : uint8_t
298 {
299 BaseColor = 0,
300 MetalRough = 1,
301 Normal = 2,
302 Emissive = 3,
303 Occlusion = 4, // Separate glTF occlusionTexture (when distinct from MR).
304 Count_ = 5
305 };
306
307 // Default layer size + max dynamic slots. Matched across channels so
308 // samplers are interchangeable and consumer shaders can declare a
309 // fixed sampler count.
310 static constexpr int kTextureLayerSize = 1024;
311 // 4 slots: high enough for scenes that legitimately use 3-4 distinct dynamic
312 // textures per channel (multi-camera capture, layered video), while 4 channels
313 // x 4 slots plus the static arrays and skybox/IBL stays under the
314 // 16-samplers-per-stage RHI floor. LRU eviction covers the rest.
315 static constexpr int kMaxDynamicSlots = 4;
316
317 // Per-channel static buckets, each holding textures of one (format, pixelSize)
318 // tuple. Consumer shaders declare N sampler2DArrays per channel and switch on
319 // the bucket field decoded from MaterialGPU::textureRefs.
320 //
321 // The cap of 16 keeps 5 channels x 16 buckets plus ~10 dynamic slots at about 90
322 // samplers per pipeline, well inside Vulkan's default combined-image-sampler
323 // pool budget. Real scenes need 1-3 buckets per channel, and buckets are
324 // allocated lazily as uploads discover new (format, size) combinations.
325 //
326 // The tex_ref_static encoding reserves a 7-bit bucket field, so the cap can grow
327 // to 128 without changing the packed layout or the shader decode masks -- but
328 // the shader sampler arrays in classic_pbr_full.frag must be enlarged to match,
329 // and the descriptor pool budget re-checked. GLES 3.1 / WebGL 2 guarantee only
330 // 16 textures per stage and would need a reduced-bucket preset.
331 static constexpr int kMaxBuckets = 16;
332
347 {
348 struct Bucket
349 {
350 QRhiTexture* array{}; // QRhiTexture::TextureArray + channel flags
351 QRhiTexture::Format format{QRhiTexture::RGBA8};
352 QSize pixelSize; // all layers in a bucket share this size
353 int layers{}; // current layer count
354
355 // Per-bucket sampler config. Bucket key extended to include this:
356 // distinct (format, size, sampler_config) tuples land in distinct
357 // buckets so per-glTF-texture wrap/filter modes are honoured even
358 // when multiple materials share a channel array.
359 ossia::texture_sampler_config sampler_config{};
360 QRhiSampler* sampler{}; // created on first allocation; owned
361
362 // Dedup: texture_source shared_ptr pointer → layer index in
363 // this bucket's `array`. Append-only within a materials list;
364 // cleared when the list changes.
365 ossia::flat_map<const ossia::texture_source*, int> layerMap;
366 };
367
368 // One bucket per distinct (format, pixelSize, sampler config), up to
369 // kMaxBuckets.
370 std::vector<Bucket> buckets;
371
372 // Dynamic (runtime-GPU) slot map, keyed by QRhiResource::globalResourceId()
373 // rather than the raw QRhiTexture* -- the allocator recycles freed pointer
374 // values, and qrhivulkan.cpp documents the same hazard for SRB tracking, so a
375 // recycled address must look like a fresh resource here too.
376 //
377 // Slots are recycled by LRU: when the map is full and a new id arrives, the slot
378 // with the smallest dynamicSlotLastUse is evicted. lastUse is bumped on every
379 // access, so the evicted slot is the one no live material references.
380 ossia::flat_map<quint64, int> dynamicSlotMap;
381 std::vector<QRhiTexture*> dynamicTextures; // slot idx → texture
382 std::vector<uint64_t> dynamicSlotLastUse; // slot idx → access counter at last lookup
383 uint64_t dynamicSlotCounter{0}; // monotonic, bumped on each resolve
384 // Value of dynamicSlotCounter at the previous sweepStaleDynamicTextureSlots()
385 // pass. A slot whose dynamicSlotLastUse is <= this was not re-resolved by any
386 // live material since, so its stored QRhiTexture* is orphaned and must be
387 // cleared before it can be bound.
388 uint64_t dynamicSweepCheckpoint{0};
389
390 // Single-bucket accessors for callers that do not loop over buckets[].
391 // Return null / 0 when no bucket has been allocated yet.
392 QRhiTexture* primaryArray() const noexcept
393 {
394 return buckets.empty() ? nullptr : buckets[0].array;
395 }
396 int primaryLayers() const noexcept
397 {
398 return buckets.empty() ? 0 : buckets[0].layers;
399 }
400
401 // Access or lazily create bucket 0 with an owned (format, size).
402 // Kept for init-time fallback allocation only — production code
403 // goes through findOrCreateBucket() which selects the right bucket
404 // for the texture's actual (format, size).
405 Bucket& ensurePrimary(QRhiTexture::Format fmt, QSize sz)
406 {
407 if(buckets.empty())
408 buckets.emplace_back();
409 auto& b = buckets[0];
410 b.format = fmt;
411 b.pixelSize = sz;
412 return b;
413 }
414
415 // Find a bucket matching (fmt, sz), creating one if none matches and kMaxBuckets
416 // is not reached. Returns {bucket_index, pointer}, or {-1, nullptr} on overflow,
417 // which the caller reports and turns into tex_ref_none. Bucket identity is the
418 // exact tuple, no rounding.
419 std::pair<int, Bucket*>
420 findOrCreateBucket(QRhiTexture::Format fmt, QSize sz)
421 {
422 for(std::size_t i = 0; i < buckets.size(); ++i)
423 {
424 if(buckets[i].format == fmt && buckets[i].pixelSize == sz)
425 return {(int)i, &buckets[i]};
426 }
427 if((int)buckets.size() >= kMaxBuckets)
428 return {-1, nullptr};
429 buckets.emplace_back();
430 auto& b = buckets.back();
431 b.format = fmt;
432 b.pixelSize = sz;
433 return {(int)buckets.size() - 1, &b};
434 }
435
436 // Sampler-config-aware variant, keyed on (format, pixelSize, sampler_config).
437 // Used by the glTF path so a scene mixing wrap modes splits across buckets, each
438 // with its own QRhiSampler. Falls back to the 2-tuple variant when the sampler
439 // config is the default.
440 std::pair<int, Bucket*>
441 findOrCreateBucket(
442 QRhiTexture::Format fmt, QSize sz,
443 const ossia::texture_sampler_config& sampler_cfg)
444 {
445 for(std::size_t i = 0; i < buckets.size(); ++i)
446 {
447 if(buckets[i].format == fmt && buckets[i].pixelSize == sz
448 && buckets[i].sampler_config == sampler_cfg)
449 return {(int)i, &buckets[i]};
450 }
451 if((int)buckets.size() >= kMaxBuckets)
452 return {-1, nullptr};
453 buckets.emplace_back();
454 auto& b = buckets.back();
455 b.format = fmt;
456 b.pixelSize = sz;
457 b.sampler_config = sampler_cfg;
458 return {(int)buckets.size() - 1, &b};
459 }
460 };
461
468 TextureChannelState& textureChannel(TextureChannel ch) noexcept
469 {
470 return m_textureChannels[(std::size_t)ch];
471 }
472 const TextureChannelState& textureChannel(TextureChannel ch) const noexcept
473 {
474 return m_textureChannels[(std::size_t)ch];
475 }
476
482 static const char* textureChannelArrayName(TextureChannel ch) noexcept;
483
490 static const char* textureChannelDynBaseName(TextureChannel ch) noexcept;
491
497 static QRhiTexture::Flags textureChannelFlags(TextureChannel ch) noexcept;
498
511 int resolveDynamicSlot(TextureChannel channel, void* native_handle) noexcept;
512
513 // ─── Mesh arena manager ───────────────────────────────────────────
514 //
515 // Per-mesh slab allocator over the attribute streams of the MDI concatenated
516 // geometry -- positions, normals, texcoords, tangents, colors, texcoords1,
517 // indices -- each a single growth-capped QRhiBuffer.
518 //
519 // Indirect-draw correctness invariant: one baseVertex is applied to ALL vertex
520 // bindings (VkDrawIndexedIndirectCommand::vertexOffset), so per-mesh byte
521 // offsets must satisfy pos/nrm/tan = baseVertex * 16 and uv = baseVertex * 8.
522 // Independent per-stream allocators cannot guarantee that once alloc/free
523 // traffic fragments the streams: they pick free blocks from different size bins
524 // and the offsets diverge, so the vertex shader reads attributes from the wrong
525 // slab.
526 //
527 // Hence TWO shared allocators: m_vertexAllocator in vertex units and
528 // m_indexAllocator in index units, both capped at 8M slots. Each slab carries
529 // one vertex_slot and one index_slot, and per-stream byte offsets derive as
530 // vertex_slot.offset * stride and index_slot.offset * 4, so lockstep is
531 // structural. Indirect draw takes baseVertex = vertex_slot.offset and
532 // firstIndex = index_slot.offset.
533 //
534 // Cache: a stable_id hit reuses the slab and skips the upload; a miss allocates.
535 // The sweep frees slabs unseen for `grace` frames.
536 //
537 // Backing buffers are pointer-stable for the registry's lifetime, so downstream
538 // bindings resolve once: 128 MB each for positions, normals, tangents and
539 // colors, 64 MB per texcoord stream, 32 MB indices.
540
541 enum class MeshStream : uint8_t
542 {
543 Positions = 0,
544 Normals = 1,
545 Texcoords = 2, // TEXCOORD_0 (primary UV).
546 Tangents = 3,
547 Colors = 4, // glTF COLOR_0, vec4 (vec3 sources padded with alpha=1).
548 Texcoords1 = 5, // glTF TEXCOORD_1 (lightmap / secondary UV).
549 Indices = 6,
550 Count_ = 7
551 };
552
553 // Bytes per element per stream, matching the MDI output layout the rasterizer
554 // presets consume: positions and normals are vec3 padded to vec4 for std430,
555 // tangents and colors vec4 (vec3 sources padded with alpha=1), texcoords vec2,
556 // indices uint32.
557 static constexpr uint32_t kMeshStride[(std::size_t)MeshStream::Count_]
558 = {16, 16, 8, 16, 16, 8, 4};
559
560 // Capacity reserved per stream at init: 128 MB for positions, normals, tangents
561 // and colors (16 B stride, 8M verts), 64 MB for each texcoord stream (8 B),
562 // 32 MB for indices (4 B). A scene exceeding these gets a sentinel allocation
563 // from allocate() and the caller skips the mesh.
564 static constexpr uint32_t kMeshCapBytes[(std::size_t)MeshStream::Count_]
565 = {
566 128u * 1024u * 1024u,
567 128u * 1024u * 1024u,
568 64u * 1024u * 1024u,
569 128u * 1024u * 1024u,
570 128u * 1024u * 1024u, // colors
571 64u * 1024u * 1024u, // texcoords1
572 32u * 1024u * 1024u,
573 };
574
590 struct MeshSlab
591 {
592 uint64_t stable_id{};
593 OffsetAllocator::Allocation vertex_slot{}; // offset/size in vertex units
594 OffsetAllocator::Allocation index_slot{}; // offset/size in index units
595 uint32_t vertex_count{};
596 uint32_t index_count{};
597 uint32_t last_seen_frame{};
598 bool freshly_allocated{}; // true on the frame the slab was created
599 };
600
609 MeshSlab* acquireMeshSlab(
610 uint64_t stable_id,
611 uint32_t vertex_count,
612 uint32_t index_count,
613 uint32_t current_frame) noexcept;
614
616 void markMeshSlabSeen(uint64_t stable_id, uint32_t current_frame) noexcept;
617
620 void sweepMeshSlabs(uint32_t current_frame, uint32_t grace = 2) noexcept;
621
628 void sweepStaleDynamicTextureSlots() noexcept;
629
635 void drainExpiredPendingReleases(
636 uint32_t current_frame, uint32_t grace = 2) noexcept;
637
643 void releaseMeshSlab(uint64_t stable_id, uint32_t current_frame) noexcept;
644
647 uint32_t meshSlabOffsetBytes(
648 const MeshSlab& slab, MeshStream stream) const noexcept;
649
652 QRhiBuffer* meshStreamBuffer(MeshStream s) const noexcept;
653
657 void uploadMeshStream(
658 QRhiResourceUpdateBatch& res, const MeshSlab& slab,
659 MeshStream s, const void* data, uint32_t size) noexcept;
660
662 uint32_t meshStreamUsedBytes(MeshStream s) const noexcept;
663 uint32_t meshStreamFreeBytes(MeshStream s) const noexcept;
664
665private:
666 struct ArenaState
667 {
668 QRhiBuffer* buffer{};
669 uint32_t slot_stride{0}; // bytes per slot (arena layout is a packed
670 // std430-compatible array of this stride)
671 uint32_t slot_count{0}; // total slots (capacity_bytes = stride × count)
672 QRhiBuffer::UsageFlags usage{};
673 QRhiBuffer::Type type{QRhiBuffer::Dynamic};
674
675 // LIFO stack of free slot indices. Push on free, pop on allocate.
676 // O(1) alloc / free, no fragmentation (every slot is the same size).
677 std::vector<uint32_t> free_slots;
678
679 // Per-slot generation, indexed by slot_index. Sized to slot_count
680 // at init() and bumped on every allocate()/free() to that slot.
681 // Consumers check the stamped generation in their gpu_slot_ref via
682 // isLive().
683 std::vector<uint32_t> slot_generations;
684 };
685
686 std::array<ArenaState, (std::size_t)Arena::Count_> m_arenas{};
687
688 std::array<TextureChannelState, (std::size_t)TextureChannel::Count_>
689 m_textureChannels{};
690
691 // Per-stream backing buffers, one QRhiBuffer per attribute. Allocation is not
692 // per-stream: a single m_vertexAllocator hands out vertex-unit slots that every
693 // vertex stream interprets through its own stride, and m_indexAllocator
694 // handles indices, keeping byte offsets in lockstep for baseVertex.
695 struct MeshStreamState
696 {
697 QRhiBuffer* buffer{};
698 uint32_t capacity_bytes{};
699 QRhiBuffer::UsageFlags usage{};
700 };
701 std::array<MeshStreamState, (std::size_t)MeshStream::Count_> m_meshStreams{};
702
703 // Shared vertex / index allocators (slot units, not bytes).
704 // capacity_slots = min(stream_capacity_bytes / stream_stride) across
705 // the vertex streams = 8M for the default sizes; index pool
706 // capacity = 8M slots.
707 std::unique_ptr<OffsetAllocator::Allocator> m_vertexAllocator;
708 std::unique_ptr<OffsetAllocator::Allocator> m_indexAllocator;
709 uint32_t m_vertexSlotsCapacity{};
710 uint32_t m_indexSlotsCapacity{};
711 uint32_t m_vertexSlotsUsed{};
712 uint32_t m_indexSlotsUsed{};
713
714 ossia::hash_map<uint64_t, MeshSlab> m_meshSlabs;
715
716 // Slabs whose `released_frame` is set are waiting out the grace
717 // period before their OffsetAllocator allocations return to the
718 // free list. Prevents use-after-free when an in-flight draw still
719 // references the old offset.
720 struct PendingRelease
721 {
722 uint64_t stable_id{};
723 uint32_t released_frame{};
724 OffsetAllocator::Allocation vertex_slot{};
725 OffsetAllocator::Allocation index_slot{};
726 };
727 std::vector<PendingRelease> m_pendingReleases;
728
729 QRhi* m_rhi{};
730
731 // Set by seedDefaults() after writing the default-MaterialGPU bytes
732 // into Material arena slot 0. Idempotent guard so repeated calls are
733 // free.
734 bool m_defaults_seeded{false};
735};
736
737} // namespace score::gfx
Per-RenderList arena store for GPU-resident scene data.
Definition GpuResourceRegistry.hpp:44
QRhi * boundRhi() const noexcept
QRhi this registry was init()'d against. Null when not initialised. The owning OutputNode uses this t...
Definition GpuResourceRegistry.hpp:129
bool isInitialized() const noexcept
True if init() has been called and destroyOwned()/destroy() has not. Used by RenderList::init to gate...
Definition GpuResourceRegistry.hpp:121
bool isLiveIn(const ossia::gpu_slot_ref &r, Arena expected) const noexcept
isLive, additionally requiring the ref to name the expected arena.
Definition GpuResourceRegistry.hpp:264
ossia::gpu_slot_ref toOssiaRef(const Slot &slot) const noexcept
Produce an ossia::gpu_slot_ref that can be stamped on a scene-graph component for the downstream prep...
Definition GpuResourceRegistry.hpp:237
bool isLive(const ossia::gpu_slot_ref &r) const noexcept
Return true if the ref still points at a live allocation.
Definition GpuResourceRegistry.hpp:276
TextureChannelState & textureChannel(TextureChannel ch) noexcept
Shared state for one of the PBR texture channels. Preprocessors / producers read-modify this in place...
Definition GpuResourceRegistry.hpp:468
List of nodes to be rendered to an output.
Definition RenderList.hpp:30
Graphics rendering pipeline for ossia score.
Definition Filter/PreviewWidget.hpp:11
Slab handle returned by MeshArenaManager::acquire.
Definition GpuResourceRegistry.hpp:591
Definition GpuResourceRegistry.hpp:84
Channel texture state with multi-bucket support.
Definition GpuResourceRegistry.hpp:347