Unified SRE scene and hierarchy-driven Vessel API

publicv1
4h ago
2 views0 comments0 reviews26 min read
raw .md ↗

name: SRE unified scene overview: Replace SRE's three scene sources, ISceneSource contract and per-group GpuScene with one entity hierarchy (generational handles, a required transform, typed components), an asset library whose glTF prefabs are instantiated without copying GPU data, and a RenderScene that syncs the hierarchy into one geometry pool, one instance/material/joint buffer set and draw lists usable by Direct, batched-instanced and Indirect strategies. Vessels own the per-step script. All 12 gfx.sre goldens are regenerated (spirv and elf); legacy libraries and tests are untouched. todos:

  • id: cpu-scene content: "Add CPU scene core: Entity handles, node table with Transform + hierarchy, component pools, views, dirty tracking, scene_systems (animate, bounds) + unit tests" status: pending
  • id: assets content: Add scene_types.h (contract v2), AssetLibrary, Prefab-as-Scene, instantiate(), cloneMaterial, unified gltf_loader on gltf_accessors.h + tests status: pending
  • id: render-scene content: "Add RenderScene: global geometry pool, scene sets (View/Instances/Joints/Materials/Textures/Lights), slot allocator, DrawList (Hierarchy/ByMesh), stats; BatchedDraw, ShellDraw, IndirectDraw(DrawList); Recipe records RenderScene updates" status: pending
  • id: indirect-port content: Add sre_scene.glsl + sre_pbr_indirect shaders/.pipe/CMake/device lists; port IndirectPbrTechnique and food store/mountain range indirect vessels; new goldens gfx.sre.110/111/120/121 status: pending
  • id: stress3d-port content: Port Stress3D techniques vessel by vessel (20, 1, 2, 11, 12, 5, 3, 4) with sre_* shaders, .pipe, elf .sp3+TOML for gfx11/11_5/12/13, and new goldens status: pending
  • id: vessel-api content: "SreVessel: Scene holds AssetLibrary/Scene/RenderScene, onStep hook, loadPrefab, addDefaultCameraAndLight; add prefab-hierarchy vessel and gfx.sre.30 strategy-equivalence/dedup vessel" status: pending
  • id: delete-old content: Delete GltfSceneSource, IndirectSceneSource, ProceduralSceneSource, GpuScene, scene_conformance, NodeUbo path, InstancedDraw, ISceneSource; replace scene_contract_test; update sre.rst and sre.mdc status: pending

Unified SRE scene and hierarchy-driven Vessel API

Your decisions:

  • Full unification. All 12 gfx.sre.* cases get new goldens. Legacy gfx.stress3d.*, gfx.indirect.* and their CSV files stay as they are.
  • Replace the scene contract. ISceneSource, FrameState and k_SceneContractVersion = 1 go. The new Scene is the only representation, with the contract bumped to v2.
  • Regenerate ELF shaders. Every shader change ships its .sp3 and TOML files for gfx11, gfx11_5, gfx12 and gfx13 in the same change.

Conflicts between the prompt and the code, and how the plan resolves each:

  1. "SRE creates separate mesh geometry per instance" is only partly true.
    • The per-instance copies are in legacy gfx_scene: loadNodeVertices/loadNodeIndices in engine/gfx/support/scene/src/scene_renderer_scene.cpp:195-207 append the mesh again for every node. Three 100k-vertex cars take about 25 MB instead of 8.4 MB.
    • SRE already reuses meshes by glTF mesh index (gltf_scene_source.cpp loadMesh, line 433) and packs each mesh once per group (gpu_scene.cpp lines 194-222).
    • The waste that SRE does have is listed in section 1.3. The plan targets those items.
  2. .cursor/rules/sre.mdc says the scene contract is frozen and shared with an ECS source's owner. No ECS source exists anywhere in the repo; it appears only in comments in scene_contract.h, sre.rst and scene_contract_test.cpp. Per your decision the contract is replaced, and sre.mdc and sre.rst change in the same commits.
  3. sre.mdc forbids changing engine/gfx/support/scene, scene_indirect and app/scene_viewer. The plan respects this. SRE keeps using gfx_scene only for SceneAsset (reading the .scene file) and Camera (projection math).
  4. The shader sources are shared with the legacy renderers.
    • pbr.vert.spv, pbr_amdllpc.*.bin, fur.*, hair.* and meshlet.* are referenced by engine/gfx/support/scene/asset/*_pipeline.json, and pbr_indirect.* is used by IndirectVessel.
    • Editing them would break the legacy goldens. All new shaders are therefore new sre_* files under engine/gfx/graphics_core/test/shaders/.
  5. InstancedDraw is not object instancing.
    • Furry orb uses its instance count for fur shells (max(stepIndex % 150, 1), sre_furry_orb_vessel.cpp lines 109-111). Hairmark uses it for 500 hair shells (gl_InstanceIndex in hair.vert:38-41, fur.vert:51-55).
    • The plan renames it to ShellDraw and adds a separate BatchedDraw for real object instancing.
  6. Runtime mutation runs into one-shot GPU setup.
    • FrameGraph compiles and realizes once, with no reset (frame_graph.h lines 407-420). SreVessel::setUp does this once (sre_vessel.cpp lines 119-124).
    • SreVessel records step N+1 while step N is still on the GPU (lines 200-207).
    • The plan therefore uses fixed-capacity GPU buffers updated with vkCmdUpdateBuffer. The data is captured into the command buffer at record time, so recording ahead stays safe.
    • Assets (meshes and textures) are frozen once the RenderScene exists. Entities, components and materials stay mutable within the configured capacities.
  7. The Stress3D design docs are stale. scene/doc/stress3d_vessel.rst and scene_rendering_engine.rst describe a RenderEngine/AutoConstantDataSource design that does not exist in code. The plan is based on the code.
  8. Driver limits (sre_driver_constraints.rst):
    • Every descriptor set has a single binding; a single binding holding an array works.
    • No buffer device address.
    • Dynamic UBO offsets are dropped by the driver (vk_command_buffer.cpp:1383-1397).
    • firstInstance with instance-rate attributes is proven by IndirectPbrTechnique.
    • gl_DrawID and gl_BaseInstance are unverified on the TNG driver, so the design does not use them.

#1.1 How each implementation works

Legacy Stress3D (gfx_scene)

  • Loading: reads the glTF protobuf from .scene.
  • GPU buffers: 7 merged buffers per glTF scene. Each mesh is copied again for every node that references it.
  • Instances and transforms:
    • The glTF node tree is kept, and world transforms are recomputed by walking parent pointers.
    • Each node has its own 8260-byte UBO and descriptor set.
    • A scene-wide UBO carries a zoom-to-fit model matrix.
  • Draws: one drawIndexed per primitive per node, rebinding 8 descriptor sets and a 28-byte push constant each time (scene_renderer_scene.cpp:1463-1515).

Legacy indirect (gfx_scene_indirect)

  • Loading: the same protobuf, through its own indirect3d::SceneAsset.
  • GPU buffers: one global vertex and index pool with rebased indices.
  • Instances and transforms:
    • The node tree's world transforms are uploaded every frame, for every node, into a node-matrix buffer.
    • Per-instance node, material and skin indices are instance-rate attributes located through firstInstance.
  • Draws: CPU-built DrawIndexedIndirectCommands, one per primitive with instanceCount = N, issued as a single drawIndexedIndirect (scene_indirect_renderer.cpp:246-290, 607-660).

SRE (GltfSceneSource, IndirectSceneSource and ProceduralSceneSource, feeding GpuScene)

  • Loading: two separate glTF loaders: gltf_scene_source.cpp (1024 lines) and indirect_scene_source.cpp (742 lines). Each has its own "Golden parity" quirks.
  • GPU buffers: GpuGroup stores each mesh once per group. A mesh used in two groups is stored twice.
  • Instances and transforms:
    • The Instance list is fixed: count, order, mesh and group never change (scene_contract.h line 292).
    • The hierarchy is private to each source.
    • Stress3D techniques give each instance its own NodeUbo (stress3d_uniforms.h lines 48-55).
    • The indirect technique uses an SSBO plus instance streams.
  • Draws:
    • DirectDraw issues one draw per DrawItem, which is one primitive of one instance.
    • IndirectDraw uses commands built inside IndirectPbrTechnique (indirect_pbr_technique.cpp lines 91-150).

#1.2 Per-instance data path in SRE today

  • Pbr, Caustic, Tessellation: set 1 is a per-instance NodeUbo descriptor set, bound in every bindDrawState.
    • Unskinned matrices are stored column-major and skinned ones row-major (a Golden parity quirk in packNodeMatrix).
    • Pbr's material goes through 5 single-sampler sets (sets 3-7) plus a fragment push constant (pbr_technique.cpp lines 350-379).
  • Hair: the world matrix is written into SceneUbo.model, taken from the group's first instance. gl_InstanceIndex is the shell index.
  • Meshlet: no per-instance transform; vertices sit pre-baked in a group-wide pool.
  • IndirectPbr: instance-rate R32_SINT attributes for node slot, material and skin offset (bindings 6-8), read from a node-matrix SSBO. The material comes from a material SSBO and textures from sampler2D[128].

#1.3 Concrete duplication and waste in SRE

  1. Per-instance node UBOs: NodeUbo is 8260 bytes, with one buffer and one descriptor set per instance per technique. Each Stress3dUniforms holds its own copy (stress3d_uniforms.h:130-133).
    • Example: pet_cabaret's 4 techniques each allocate their own copies, and 1000 instances cost 8.26 MB per technique.
    • The new design costs 80 bytes per instance, shared by all techniques.
  2. Geometry duplicated per group: a mesh referenced from two glTF scenes is uploaded twice, because uploadGroups packs per group.
  3. Meshlet pool parity quirk: every meshlet primitive re-appends all positions and normals met so far in the group (gpu_scene.h:61). Storage grows quadratically with the number of meshlet primitives.
  4. Two loaders for the same .scene format, plus the separate asset sets scenes/ and scenes_indirect/ built from the same glTF files.
  5. Duplicated light, camera and view uniform buffers in every technique.
  6. Scenario logic inside techniques and sources rather than the script:
    • zoom-to-fit spin (computeZoomToFitModel);
    • cameras.back() and lights.back() selection;
    • the camera Y-flip in the indirect loader;
    • the hairmark torus animation callback.

#1.4 Dependency map

Consumers of ip/gfx/sre/scene/*: only gfx_sre itself, gfx_sre_test, and the vessel/sre/ and vessel/include/test/gfx/sre/ files. vessel_viewer and scene_viewer do not use SRE.

#2.1 Layering (sre.rst "Layers" stays true)

  • scene/ (may include vkutil):
    • entity.h, components.h, scene.h and scene_systems.h (animation and bounds): pure CPU code, no Vulkan.
    • asset_library.h and gltf_loader.h: CPU code, using gfx_scene's SceneAsset.
    • render_scene.h: GPU sync, using vkutil.
  • frame/ stays below scene/. Because scene/ may not include frame/, RenderScene exposes its buffers and the recipe/technique layers import them into the frame graph. This mirrors how techniques import GpuGroup buffers today.
  • Deleted at the end: gltf_scene_source.*, indirect_scene_source.*, procedural_scene_source.*, gpu_scene.*, scene_conformance.*, and the ISceneSource/FrameState part of scene_contract.h. That file is renamed scene_types.h; it keeps MeshData, MaterialData, TextureData and LightData, adds SkinData and AnimationClip, and sets the version to 2.

#2.2 Core data model: a lightweight ECS with the hierarchy in the node table

The design is entity handles plus one dense node table holding the required transform and the hierarchy links, plus one typed component pool per component type.

Why this rather than a pointer-based node tree like GltfSceneSource::Node (unique_ptr nodes with parent and child pointers):

  • Handles survive clones and destruction safely: stale handles are detected rather than dangling.
  • Cloning becomes a table copy plus a handle remap.
  • GPU sync can walk dense arrays.
  • "Query components by type" becomes a pool lookup instead of a scan of each node's list.

Why not a full ECS library: the repo has no entt or flecs in its Conan dependencies, archetype storage is unnecessary at test scale, and determinism is easier with simple ordered storage.

  • Entity: {uint32_t index; uint32_t generation;}, following the style of frame::ResourceHandle.
    • Destroying an entity bumps its generation, so stale handles are detected: isAlive() returns false and accessors throw std::invalid_argument.
    • Freed indices are reused lowest first, so allocation is deterministic.
  • Required Transform per node: local TRS plus an optional glTF matrix (local = TRS × matrix, as both loaders compute today). The node also caches world and a dirty flag.
  • Hierarchy: each node stores parent, firstChild, lastChild, prevSibling and nextSibling as entity indices, so child order is explicit and sibling order is the order children were added.
    • Roots form an ordered list of their own.
    • Draw order and every traversal are pre-order: roots in order, children in order. This matches GltfSceneSource::buildInstances today.
  • Components: ComponentPool<T> is a sparse-set pool (dense values plus an entity-to-slot map), type-erased behind IComponentPool, with a per-type id from componentTypeId<T>().
    • Built-in components: Name, MeshRenderer, SkinnedMesh, Light, CameraComponent, AnimationPlayer.
    • Users may define their own component types; no registration call is needed.
    • Pools iterate in ascending entity index order (the dense array is kept sorted on insert), so views are deterministic.
  • Dirty tracking:
    • Mutable access (editTransform(), edit<T>()) sets a per-entity dirty bit; const get does not.
    • Structural changes (create, destroy, reparent, add or remove a renderable component) bump structureVersion, which tells RenderScene to rebuild its draw lists.

#2.3 Asset and resource layer

  • AssetLibrary owns resources: MeshData, MaterialData, TextureData, SkinData, AnimationClip and Prefab, addressed by the existing ids (MeshId, MaterialId, TextureId) plus new SkinId, AnimationId and PrefabId.
  • Deduplication:
    • Within an asset, by glTF mesh, material, texture and skin index.
    • Across loads, by asset path: loading the same .scene twice returns the same PrefabId.
  • A prefab is itself a Scene (the template) owned by the library, with a root entity holding one child root per glTF scene.
    • instantiate() is a cross-scene subtree clone. Resource ids are copied, never the data, so there is one clone implementation shared by instantiate and clone.
    • glTF scenes become layer bits (GltfLoadOptions::layerPerScene, default true): scene i gets MeshRenderer::layers = 1 << i. This replaces the old "group" concept; furry orb uses groups 0-3.
  • One glTF loader (gltf_loader.cpp) built on the existing gltf_accessors.h:
    • Applies glTF-correct defaults: normalized normals, missing streams zero-filled rather than reading accessor 0, primitives without a material use a default material, and animations use linear, step or slerp sampling over glTF key times.
    • The camera Y-flip and the legacy left-handed light and camera fixes become a load option, GltfLoadOptions::legacyLeftHanded, applied to camera and light entities. They do not live in technique code.
  • Material instances (Unity renderer.material semantics): AssetLibrary::cloneMaterial(id) returns a new MaterialId. MeshRenderer::materials[i] overrides the mesh primitive's default material. Materials may be added or edited after the GPU upload, up to RenderSceneCapacity::materials.
  • Procedural meshes (hairmark, Donut's cube) use AssetLibrary::addMesh(makeMeshData(mesh)), with makeMeshData kept from procedural_scene_source.h.
  • External textures: RenderScene::registerImageTexture(name) reserves a texture-array slot whose descriptor is written once the frame graph is realized. Furry orb's render-to-texture floor then becomes a cloned material referencing that slot, replacing the current PbrOptions::baseColorImage special case.

#2.4 Render-sync layer (RenderScene)

  • Built once from the frozen AssetLibrary:
    • One global geometry pool: six vertex streams plus u32 indices rebased per mesh, with vertexOffset = 0, as in the indirect path. Each mesh is stored exactly once whatever its layers.
    • Per-mesh meshlet ranges, with no cumulative re-append.
    • The material SSBO and the texture array.
  • Fixed-capacity buffers (RenderSceneCapacity), each a single-binding descriptor set shared by every technique:
    • View UBO: view, projection, view-projection, camera position;
    • Instances SSBO: InstanceData = world mat4 (column-major, one convention), joint offset, joint count, flags; 80 bytes;
    • Joints SSBO;
    • Materials SSBO;
    • Lights;
    • Textures: sampler2D[k_MaxTextureCount], one binding holding an array.
  • sync(Scene&, StepTime), once per step on the CPU:
    1. Propagate world transforms in pre-order, only for subtrees with dirty nodes.
    2. Allocate a stable instance slot per renderable entity (lowest free slot first) and free it on destroy.
    3. Collect dirty slots, material edits, joints (computed relative to the instance, as the contract does today) and lights.
    4. If structureVersion changed, rebuild every requested DrawList.
  • recordUpdates(cmd): vkCmdUpdateBuffer in chunks of at most 64 KiB (as indirect_pbr_technique.cpp:470-475 does), for dirty ranges only. The first step writes everything.
    • Recipe::build() records the RenderScene updates first in its update pass, then the techniques' updates.
    • Draw-list record and command buffers are updated the same way, so recording ahead with SreVessel stays correct.
  • DrawList, requested by a technique before importResources:
    • Requested with DrawFilter{layers, order}, where order is Hierarchy (pre-order, required for blending) or ByMesh (mesh, then primitive, then instance; the batching order).
    • Output:
      • DrawBatches, each {mesh, primitive, firstIndex, indexCount, vertexCount, material, firstRecord, recordCount, hasMeshlets};
      • a DrawRecord stream {instanceSlot, material}, an instance-rate vertex buffer of R32G32_UINT;
      • an indirect command buffer with one DrawIndexedIndirectCommand per indexed batch, where firstInstance = firstRecord.
    • In Hierarchy order each batch has recordCount = 1; in ByMesh order it covers every instance of the mesh primitive.
  • Draw strategies (one shared data source; draw_strategy.h):
    • DirectDraw: for each batch and record, bindDrawState, then drawIndexed(count, 1, first, 0, firstInstance = record).
    • BatchedDraw (new): one drawIndexed(count, recordCount, first, 0, firstRecord) per batch.
    • IndirectDraw: drawIndexedIndirect over the list's command buffer. It now takes a DrawList rather than a technique-built buffer.
    • ShellDraw (renamed InstancedDraw): per record, push {instance, material}, then drawIndexed(count, shellCount(), first, 0, 0). gl_InstanceIndex stays the shell index.
    • MeshletDraw: per record, push {instance, meshletOffset, meshletCount}, then drawMeshTasksEXT(meshletCount). Only this mesh's meshlets are dispatched.

#2.5 Shader changes (new sre_* files; legacy files untouched)

  • New shared include shaders/glsl/sre_scene.glsl declares:
    • set 0 View, set 1 Instances, set 2 Joints, set 3 Materials, set 4 Textures, set 5 Lights;
    • sets 6 and up for technique-specific bindings (caustic sampler2DArray, hair texture array, meshlet SSBOs).
  • Vertex-input techniques (sre_pbr, sre_fur, sre_caustic, sre_pn_tess, sre_stress_tess, sre_spotlights):
    • Bindings 0-5 are the per-vertex streams. Binding 6 is the per-instance layout(location = 6) in uvec2 inDrawRecord; read through firstInstance.
    • The world matrix is instances[inDrawRecord.x].world and the material is materials[inDrawRecord.y].
  • Shell and mesh techniques (sre_fur under ShellDraw, sre_hair, sre_meshlet): {uint instance; uint material; ...} comes from a vertex/mesh-stage push constant, so gl_InstanceIndex stays the shell index.
    • Fur is drawn by ShellDraw only; the Pbr technique picks the push-constant path when its program is Fur.
  • PBR materials move to the material SSBO plus the texture array, replacing per-material sampler sets 3-7 and the 28-byte push constant.
    • Stress3D PBR shading and indirect PBR shading stay as two technique families (two fragment shaders) unless you choose to merge them (see open questions). Both read the same scene sets.
  • Skinning: joint matrices relative to the instance come from the Joints SSBO at jointOffset, with no 128-joint UBO cap.
  • The zoom-to-fit spin leaves the shader UBO. The vessel script spins a turntable entity instead (section 3).
  • Per shader family, each change includes:
    • the GLSL;
    • a new .pipe file (userDataNode per set, INPUT_RATE_INSTANCE binding 6, push-constant dwords);
    • add_shader_executable targets in graphics_core/test/CMakeLists.txt;
    • entries in the 7 device lists device/{navi48,strix1_b0,medusa1,medusa2,krackan2e,alphatrion2,phoenix}/CMakeLists.txt;
    • amdllpc, then disassemble_elf, producing shaders/{gfx11,gfx11_5,gfx12,gfx13}/sre_*.sp3 plus pipeline_metadata/<gen>/sre_*.toml;
    • add_elf_shader_pipeline entries.

#2.6 Public API sketch (signatures only)

namespace diag::ip::gfx::sre::scene
{
struct Entity
{
    uint32_t index      = std::numeric_limits<uint32_t>::max();
    uint32_t generation = 0U;
    [[nodiscard]] bool isValid() const noexcept;
    friend bool operator==(const Entity&, const Entity&) = default;
};

struct Transform
{
    Vector3    translation{Vector3::k_Zero};
    Quaternion rotation{Quaternion::k_Identity};
    Vector3    scale{Vector3::k_UnitScale};
    // NOLINTBEGIN(readability-redundant-member-init)
    std::optional<Matrix4> matrix{};  ///< glTF node matrix, applied after TRS
    // NOLINTEND(readability-redundant-member-init)
    [[nodiscard]] Matrix4 getLocalMatrix() const;
};

enum class ParentMode : uint8_t { KeepWorld, KeepLocal };
enum class DrawOrder : uint8_t { Hierarchy, ByMesh };

struct Name           { std::string value{}; };
struct MeshRenderer   { MeshId mesh = 0U; std::vector<MaterialId> materials{}; uint32_t layers = 1U;
                        bool visible = true; std::optional<vk::Extent2D> fragmentShadingRate{}; };
struct SkinnedMesh    { SkinId skin = 0U; std::vector<Entity> joints{}; };
struct Light          { LightData data{}; };          ///< pose from the entity's world transform
struct CameraComponent{ Camera camera{}; };           ///< pose from the entity's world transform
struct AnimationPlayer{ AnimationId clip = 0U; std::vector<Entity> targets{}; bool loop = true; };

template<typename... Ts> class View;  ///< ascending entity order; begin()/end() yield (Entity, Ts&...)

class Scene final
{
public:
    Scene();
    ~Scene();
    TNG_UNCOPYABLE_MOVABLE(Scene)

    // Entities
    Entity createEntity(std::string_view name = {}, Entity parent = {}, const Transform& local = {});
    void   destroy(Entity entity);                            ///< entity and all descendants
    [[nodiscard]] bool   isAlive(Entity entity) const noexcept;
    Entity clone(Entity source, Entity parent = {});          ///< deep subtree clone, resources shared
    Entity cloneFrom(const Scene& source, Entity sourceRoot, Entity parent = {}, const Transform& local = {});

    // Hierarchy
    void setParent(Entity child, Entity parent, ParentMode mode = ParentMode::KeepWorld);
    void setSiblingIndex(Entity entity, uint32_t index);
    [[nodiscard]] Entity              getParent(Entity entity) const;
    [[nodiscard]] std::vector<Entity> getChildren(Entity entity) const;
    [[nodiscard]] std::span<const Entity> getRoots() const noexcept;
    [[nodiscard]] Entity find(Entity root, std::string_view path) const;  ///< "a/b/c" by Name
    template<typename F> void forEachInSubtree(Entity root, F&& visit) const;  ///< pre-order

    // Transform
    [[nodiscard]] const Transform& getLocalTransform(Entity entity) const;
    Transform& editTransform(Entity entity);                  ///< marks the subtree dirty
    void setLocalTransform(Entity entity, const Transform& local);
    void setLocalPosition(Entity entity, const Vector3& position);
    void setLocalRotation(Entity entity, const Quaternion& rotation);
    void setLocalScale(Entity entity, const Vector3& scale);
    [[nodiscard]] Matrix4 getWorldMatrix(Entity entity);      ///< propagates lazily
    void setWorldMatrix(Entity entity, const Matrix4& world);
    void updateWorldTransforms();

    // Components
    template<typename T> T& add(Entity entity, T component = {});
    template<typename T> void remove(Entity entity);
    template<typename T> [[nodiscard]] bool has(Entity entity) const;
    template<typename T> [[nodiscard]] const T& get(Entity entity) const;
    template<typename T> [[nodiscard]] const T* tryGet(Entity entity) const;
    template<typename T> T& edit(Entity entity);              ///< marks the component dirty
    template<typename T> T* tryEdit(Entity entity);
    template<typename T> [[nodiscard]] T* findInChildren(Entity root);  ///< first in pre-order
    template<typename... Ts> [[nodiscard]] View<Ts...> view();

    [[nodiscard]] uint64_t getStructureVersion() const noexcept;
};

struct GltfLoadOptions { bool layerPerScene = true; bool legacyLeftHanded = true; float defaultCameraAspectRatio = 1.0F; };

class AssetLibrary final
{
public:
    PrefabId   loadGltf(std::string_view assetPath, const vkutil::DeviceContext& device, const GltfLoadOptions& options);
    MeshId     addMesh(MeshData mesh);
    MaterialId addMaterial(MaterialData material);
    TextureId  addTexture(TextureData texture);
    MaterialId cloneMaterial(MaterialId source);
    MaterialData& editMaterial(MaterialId material);          ///< marks it dirty for RenderScene
    [[nodiscard]] const Prefab&   getPrefab(PrefabId prefab) const;
    [[nodiscard]] const MeshData& getMesh(MeshId mesh) const;
    [[nodiscard]] std::span<const MaterialData> getMaterials() const noexcept;
    void freezeResources();                                   ///< called by RenderScene; later mesh/texture adds throw
};

Entity instantiate(Scene& scene, const AssetLibrary& assets, PrefabId prefab,
                   Entity parent = {}, const Transform& local = {});
void animate(Scene& scene, const AssetLibrary& assets, const StepTime& time);  ///< every AnimationPlayer
[[nodiscard]] BoundingBox computeWorldBounds(Scene& scene, const AssetLibrary& assets, Entity root);

struct RenderSceneCapacity { uint32_t instances = 4096U; uint32_t drawRecords = 16384U; uint32_t materials = 1024U;
                             uint32_t joints = 8192U; uint32_t lights = 16U; };
struct DrawFilter { uint32_t layers = ~0U; DrawOrder order = DrawOrder::Hierarchy; };
struct DrawListHandle { uint32_t index = std::numeric_limits<uint32_t>::max(); };
struct RenderStats { size_t geometryBytes; size_t instanceBytes; size_t materialBytes; uint32_t instances;
                     uint32_t batches; uint32_t records; };

class RenderScene final
{
public:
    RenderScene(const vkutil::DeviceContext& context, AssetLibrary& assets, const RenderSceneCapacity& capacity);
    DrawListHandle requestDrawList(const DrawFilter& filter);
    TextureId      registerImageTexture(std::string name);
    void bindImageTexture(TextureId texture, vk::ImageView view);
    void setActiveCamera(Entity camera);
    void sync(Scene& scene, const StepTime& time);
    void recordUpdates(vk::raii::CommandBuffer& commandBuffer);
    [[nodiscard]] const GpuGeometry& getGeometry() const noexcept;
    [[nodiscard]] const SceneSets&   getSets() const noexcept;      ///< View..Lights sets and layouts
    [[nodiscard]] std::span<const vkutil::GpuBuffer* const> getUpdatedBuffers() const;  ///< for frame-graph import
    [[nodiscard]] const DrawList& getDrawList(DrawListHandle handle) const;
    [[nodiscard]] const StepTime& getTime() const noexcept;
    [[nodiscard]] RenderStats getStats() const noexcept;
};
}  // namespace diag::ip::gfx::sre::scene

Technique interface changes (technique.h):

  • getGroup() becomes getDrawList().
  • bindDrawState(cmd, const scene::DrawBatch&, uint32_t record).
  • getDrawRepeatCount()/bindRepeatState() stay; caustic draws each primitive twice.
  • importResources imports the RenderScene buffers once, through a helper in techniques/common/scene_resources.h.

SreVessel gains a script hook (sre_vessel.h):

  • Scene becomes {AssetLibrary assets; scene::Scene world; unique_ptr<RenderScene> renderScene; techniques}.
  • New virtual void onStep(scene::Scene&, scene::AssetLibrary&, const StepTime&) {}.
  • recordStep() runs onStep(), then scene::animate(), then renderScene->sync(), then frameGraph->record().
  • buildRecipe() is the "Start" phase: load prefabs, instantiate, create techniques and passes.
  • loadGltfSource and loadIndirectSource become a single loadPrefab(context, options).

Unity example in Vessel pseudo-C++:

SreVessel::Scene SrePrefabHierarchyVessel::buildRecipe(RecipeContext& context)
{
    Scene s = makeScene(context);  // assets, world, renderScene (capacity defaults)
    const auto prefab = s.assets.loadGltf(context.asset, context.device, {});

    const auto parent = instantiate(s.world, s.assets, prefab, {}, {.translation = {0, 0, 0}});
    s.world.edit<sre::scene::Name>(parent).value = "Parent_Instance";
    if (auto* renderer = s.world.findInChildren<sre::scene::MeshRenderer>(parent))
    {
        const auto material = s.assets.cloneMaterial(s.assets.getMesh(renderer->mesh).primitives[0].material);
        s.assets.editMaterial(material).baseColorFactor = {0, 0, 1, 1};
        renderer->materials = {material};  // like Unity's renderer.material: a per-instance copy
    }

    const auto child = instantiate(s.world, s.assets, prefab, {}, {.translation = {2, 0, 0}});
    s.world.edit<sre::scene::Name>(child).value = "Child_Instance";
    s.world.setLocalScale(child, {0.5F, 0.5F, 0.5F});
    s.world.setParent(child, parent, ParentMode::KeepWorld);  // Unity SetParent default
    s.world.setLocalPosition(child, Vector3::k_Zero);

    auto& pbr = s.addTechnique<PbrTechnique>(context.device, *s.renderScene, PbrOptions{.flavor = context.shaderFlavor,
                                             .drawList = {.order = DrawOrder::ByMesh}, .extent = context.extent});
    context.recipe.addRasterPass("forward").colorTarget(context.swapchainColor, getSceneClearColor())
        .depthTarget(context.swapchainDepth, sre::frame::clearDepth(1.0F)).draw(pbr, sre::draw::BatchedDraw{});
    return s;
}

Existing vessels rewritten. None needs scene-specific engine code.

  • Water bottle (gfx.sre.20):
    • buildRecipe instantiates the prefab under a turntable entity.
    • The zoom-to-fit scale and offset come from computeWorldBounds, computed once.
    • onStep sets the turntable rotation to 2π × fraction.
    • A default camera entity and a default directional light entity are created by SreVessel::addDefaultCameraAndLight().
  • Food store and mountain range, direct and indirect (gfx.sre.1, .2, .110, .111, .120, .121):
    • Both load the same prefab: the indirect TCDs move to media/common/gfx/scenes/*.scene (open question 3).
    • The direct variant uses PbrTechnique with DirectDraw; the indirect variant uses IndirectPbrTechnique with IndirectDraw.
    • glTF animations run through AnimationPlayer with no per-test code.
    • The camera is the glTF camera entity, chosen by setActiveCamera(world.find(root, "...")), or by the loader's ordered camera list prefab.cameras[i] for index-based TCDs.
  • Hairmark (gfx.sre.5): addMesh(makeMeshData(asset.makeMesh(0))), then one entity with a MeshRenderer. onStep writes the torus transform. The camera entity is created in buildRecipe. The technique uses ShellDraw{500}.
  • Furry orb (gfx.sre.3):
    • Layers 0-3 come from the glTF scenes.
    • The floor entities get a cloned material whose base color is registerImageTexture("furryOrbTexture").
    • The orb uses ShellDraw{step % 150}.
  • Pet cabaret, stress tess, dragon taskmesh (gfx.sre.4, .11, .12): a technique per layer with matching filters. Meshlets come from per-mesh ranges.

Donut generality check:

  • deferred_shading:
    • Procedural cube: addMesh and addMaterial with a texture.
    • Entities CubeNode (with MeshRenderer) and its child Sun (with Light, directional).
    • A G-buffer technique (multiple render targets) with DirectDraw, then a lighting compute pass added to the frame graph directly, which SRE already supports.
    • The final blit is not implemented on the TNG driver and becomes a fullscreen raster pass.
    • No scene-specific code is needed. The G-buffer and lighting techniques would be new techniques, not part of this plan.
  • bindless_rendering:
    • Load Sponza and instantiate it once.
    • Donut's instance, geometry and material structured buffers map to set 1 (Instances), the DrawBatch/DrawRecord stream and set 3 (Materials). Its push constant int2(instance, geometry) is exactly DrawRecord.
    • Differences: SRE has a 128-texture array versus Donut's 1024, and uses vertex attributes instead of raw-buffer vertex pulling, since there is no buffer device address. Pulling would still be possible with SSBO arrays.
  • Reparenting:
    • KeepWorld (the default, as in Unity) recomputes local = inverse(parentWorld) × world and decomposes it into TRS. If the result is not representable as TRS (shear from non-uniform scale under rotation), the local is stored in Transform::matrix with TRS reset, and a debug log is written.
    • KeepLocal leaves the local untouched.
    • Parenting to self or to a descendant throws. An empty parent makes the entity a root, appended last.
  • Destroying a node with children destroys the whole subtree (Unity semantics), in post-order. Their slots and handles are freed, structureVersion is bumped, and there is no orphaning option. To keep children, reparent them first.
  • Cloning:
    • Deep copy of nodes and all components. Resource ids (meshes, materials including cloned ones, skins and clips) are shared.
    • Entity references inside the subtree are remapped: SkinnedMesh::joints and AnimationPlayer::targets. References to entities outside the subtree are kept as they are.
    • Name is copied unchanged; names are not unique.
    • User component types are copied with their copy constructor, so a user component holding an Entity gets no automatic remapping. A remapEntities hook is optional and documented.
  • Handle invalidation: generational handles never dangle. Pointers or references returned by get/edit/tryGet are valid only until the next structural change to that component pool; this is documented, and debug builds check it with the guard below.
  • Mutation during iteration:
    • A View or forEachInSubtree holds an iteration guard. Structural changes while one is live trigger a TNG_ASSERT in debug builds and are undefined in release.
    • Value edits through edit are allowed.
    • Scripts that spawn or destroy while iterating collect handles first, a pattern shown in the doc.
  • Determinism for CRC tests:
    • No unordered_map iteration feeds output.
    • Draw order follows the pre-order hierarchy, or a stable mesh, primitive, entity order.
    • Slots are allocated lowest first; transforms are propagated in a fixed order.
    • Animation uses StepTime only; there are no wall-clock or thread-pool-dependent results (texture transcoding stays parallel, but each result lands in its own fixed slot).
    • RenderScene writes its full state on the first step.
  • Capacity overflow: sync() throws std::length_error naming the exceeded capacity. Asset additions after freezeResources() throw std::logic_error.
  • Mesh, primitive and material validity: the asset checks from scene_conformance move to checkAssets(const AssetLibrary&). RenderScene runs it at construction, and a debug-only checkScene(const Scene&) covers hierarchy invariants.
  1. CPU scene core: entity.h, components.h, scene.h/.cpp, scene_systems (animation, bounds), with unit tests. No render changes.
  2. Asset library and unified glTF loader:
    • Add scene_types.h, a copy of the contract types plus SkinData/AnimationClip, at version 2.
    • scene_contract.h stays temporarily and aliases the shared types.
    • Tests use procedural assets plus a small glTF fixture.
  3. RenderScene:
    • Geometry pool, scene sets, slots, draw lists, stats.
    • New draw strategies BatchedDraw, ShellDraw, and an IndirectDraw(DrawList) overload.
    • Recipe::build() records RenderScene updates.
    • Old GpuScene and techniques stay.
  4. Shared shader include plus sre_pbr_indirect (SPIR-V only, as today):
    • Port IndirectPbrTechnique to RenderScene and switch the food-store and mountain-range indirect vessels.
    • New goldens for gfx.sre.110, .111, .120 and .121.
  5. Stress3D family, one vessel per change, each with its shaders, .pipe files, CMake and device entries, regenerated .sp3/TOML for 4 generations, and new goldens:
    • water bottle (gfx.sre.20; Pbr);
    • food store (gfx.sre.1);
    • mountain range (gfx.sre.2);
    • stress tess (gfx.sre.11; Pbr plus PnTriangles);
    • dragon (gfx.sre.12; Meshlet);
    • hairmark (gfx.sre.5; Hair, ShellDraw);
    • furry orb (gfx.sre.3; Pbr, Fur, render-to-texture);
    • pet cabaret (gfx.sre.4; Caustic, StressTess, Spotlights, 4× MSAA).
  6. New coverage: the prefab-hierarchy vessel (the Unity example), the dedup/equivalence vessel (section 6), and their TCDs and goldens.
  7. Delete the old path:
    • GltfSceneSource, IndirectSceneSource, ProceduralSceneSource, GpuScene/GpuGroup/DrawItem, scene_conformance, Stress3dUniforms's NodeUbo path, InstancedDraw, the ISceneSource/FrameState part of scene_contract.h, and SreVessel::loadGltfSource/loadIndirectSource.
    • Replace scene_contract_test.cpp with scene_types_test.cpp, which guards v2 the same way.
    • Delete procedural_scene_source_test.cpp and scene_conformance_test.cpp, replaced by the step 1-3 tests.
    • Update sre.rst (layers, scene section, the "Golden parity" section narrowed to legacy goldens no longer applying, test numbering, how to extend) and .cursor/rules/sre.mdc, removing the frozen-contract and Golden parity rules.

Goldens and CRCs expected to change (all gfx.sre.*; new CSVs under engine/gfx/graphics_core/test/composite/reference/, e.g. gfx-sre.csv, gfx-sre-hairmark.csv, gfx-sre-hairmark_prbs.csv, gfx-sre-indirect-crc32.csv, gfx-sre-indirect-hhash.csv; TCDs repointed):

  • gfx.sre.1 and .2 (food store, mountain range; spirv and elf, 4K and 1080p):
    • new vertex and fragment interface: instance SSBO plus firstInstance, material SSBO plus texture array;
    • one matrix convention, replacing the column-major/row-major quirk;
    • default-material and texture-coordinate fallbacks;
    • unified animation sampling.
  • gfx.sre.3 (furry orb): all of the above, plus the fur shell push-constant path and the render-to-texture floor as a material instance.
  • gfx.sre.4 (pet cabaret): the Caustic, Tessellation and Spotlights interfaces; joints move from a UBO to an SSBO.
  • gfx.sre.5 (hairmark, every resolution and PRBS case): hair moves from SceneUbo.model to the instance SSBO plus a push constant; the camera becomes an entity.
  • gfx.sre.11 (stress tess): Pbr and PnTriangles interfaces; the scene set's stage mask is fixed.
  • gfx.sre.12 (dragon): per-mesh meshlet ranges replace the cumulative pool, and the instance transform now applies.
  • gfx.sre.20 (water bottle): the spin moves from SceneUbo.model to a turntable entity, and the matrix order of operations changes.
  • gfx.sre.110, .111, .120, .121:
    • the .scene file may move from scenes_indirect/ to scenes/ (open question 3);
    • the indirect loader quirks go: camera Y-flip, last-material default, accessor-0 fallback, "unset" material factors, the light position taken from the camera's unflipped Y;
    • node slot numbering and command order change.
  • Unchanged: all gfx.stress3d.*, gfx.indirect.*, gfx.gfxoff, gfx.harvest.3 and the cube tests, plus their CSV files.
  • Approval process: images dumped with dump_image.when = "Always" on hardware are compared visually against the legacy dumps. Visual differences must be explained by the intentional fixes above.
  • Unit tests (engine/gfx/support/sre/test/unit/software/, listed in its CMakeLists):
    • scene_hierarchy_test: create and destroy a subtree, sibling order, roots, pre-order, stale handle detection, deterministic index reuse.
    • scene_transform_test: world propagation, lazy getWorldMatrix, dirty subtree only, setWorldMatrix.
    • scene_reparent_test: KeepWorld and KeepLocal, the shear fallback, cycle rejection.
    • scene_clone_test: deep clone, shared resource ids, internal reference remap (joints, animation targets), outside references kept.
    • scene_component_test: add, remove, get, tryGet, edit dirty bits, views in ascending order, user component types, the iteration guard (death test in debug).
    • asset_library_test: dedup by glTF index and by path; cloneMaterial; freezeResources throwing.
    • gltf_loader_test: a fixture with two nodes sharing a mesh gives one MeshData; layers per scene; defaults.
    • animation_test: sampling at step fractions; bindings after clone.
    • draw_list_test: no Vulkan needed. Test buildDrawLists() (a pure function) for Hierarchy versus ByMesh order, batch merging, firstRecord offsets, indirect command contents, layer filters.
    • render_scene_capacity_test: overflow messages.
    • Port recipe_test, pbr_technique_test and indirect_pbr_technique_test to RenderScene fakes.
  • Deduplication metrics, from RenderScene::getStats() and logged by the new dedup vessel:
    • instantiate the water bottle N = 1, 10, 100 and 1000 times;
    • geometryBytes must stay constant;
    • instanceBytes must equal 80 × N;
    • descriptor sets must stay constant (previously N per technique);
    • draw calls: N × primitives for DirectDraw, primitives for BatchedDraw and IndirectDraw.
    • The same counts are asserted in a unit test on buildDrawLists.
    • Comparison point: a legacy NodeUbo footprint of 8260 × N × techniques bytes.
  • Both strategies render identically:
    • New test gfx.sre.30: one scene (food store plus 3 extra clones of a vehicle subtree) with four cases.
      • DirectDraw with ByMesh order;
      • BatchedDraw;
      • IndirectDraw;
      • DirectDraw with Hierarchy order (opaque only).
    • All four cases must match one golden column, so if each matches the golden they match each other. Same technique, same DrawList data, so the outputs must be byte-identical.
    • The Hierarchy-order case is allowed only on opaque content with LessOrEqual depth and no coplanar ties. If it differs, it gets its own column and is excluded from the equality claim.
  1. Merge the two PBR families? Stress3D pbr.frag and indirect pbr_indirect.frag shade differently. The plan keeps two techniques on one scene. Merging into one technique would remove another shader set but changes the look of one family.
  2. Runtime asset loading: the plan freezes meshes and textures once RenderScene exists, because the frame graph is one-shot and uploads would overlap with in-flight steps. Is loading new glTF files mid-run needed? It would require FrameGraph reset and rebuild support plus a fence-safe upload path.
  3. One .scene per model: move the gfx.sre.11x and 12x TCDs from scenes_indirect/ to scenes/ (same glTF source; this halves asset loads). If the two generator outputs differ in content, keep both.
  4. Texture array size: is 128 enough for food store, pet cabaret and furry orb under the unified path? This is checked in step 4; otherwise raise k_MaxTextureCount, together with the .pipe dword counts.
  5. Golden capture procedure and owners: who records and approves the new CSV files on hardware, and which devices? The repo has no capture tool; only the CRC and HighwayHash dumpers that compare.
  6. ELF regeneration: disassemble_elf is not in the repo. Please confirm the amdllpc version and flags that match the existing .sp3 headers, so the 4 generations are reproducible.
  7. Default capacities (4096 instances, 16384 records, 1024 materials, 8192 joints): accept these, or make them TCD parameters (render.capacity.*)?
  8. Notify the ECS owner named in scene_contract.h, even though no code of theirs exists.
  9. Risk: GPU validation cost. About 9 shader families × 4 generations, and 12 test families re-goldened. Mitigated by moving one vessel per change, and by keeping the legacy vessels as a visual reference.

comments (0)

reviews (0)