Unified SRE scene and hierarchy-driven Vessel API
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. Legacygfx.stress3d.*,gfx.indirect.*and their CSV files stay as they are. - Replace the scene contract.
ISceneSource,FrameStateandk_SceneContractVersion = 1go. The newSceneis the only representation, with the contract bumped to v2. - Regenerate ELF shaders. Every shader change ships its
.sp3and 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:
- "SRE creates separate mesh geometry per instance" is only partly true.
- The per-instance copies are in legacy
gfx_scene:loadNodeVertices/loadNodeIndicesinengine/gfx/support/scene/src/scene_renderer_scene.cpp:195-207append 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.
- The per-instance copies are in legacy
- .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.rstandscene_contract_test.cpp. Per your decision the contract is replaced, andsre.mdcand sre.rst change in the same commits. sre.mdcforbids changingengine/gfx/support/scene,scene_indirectandapp/scene_viewer. The plan respects this. SRE keeps usinggfx_sceneonly forSceneAsset(reading the.scenefile) andCamera(projection math).- The shader sources are shared with the legacy renderers.
pbr.vert.spv,pbr_amdllpc.*.bin,fur.*,hair.*andmeshlet.*are referenced byengine/gfx/support/scene/asset/*_pipeline.json, andpbr_indirect.*is used byIndirectVessel.- Editing them would break the legacy goldens. All new shaders are therefore new
sre_*files underengine/gfx/graphics_core/test/shaders/.
InstancedDrawis 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_InstanceIndexinhair.vert:38-41,fur.vert:51-55). - The plan renames it to
ShellDrawand adds a separateBatchedDrawfor real object instancing.
- Furry orb uses its instance count for fur shells (
- Runtime mutation runs into one-shot GPU setup.
FrameGraphcompiles and realizes once, with no reset (frame_graph.h lines 407-420).SreVessel::setUpdoes this once (sre_vessel.cpp lines 119-124).SreVesselrecords 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
RenderSceneexists. Entities, components and materials stay mutable within the configured capacities.
- The Stress3D design docs are stale.
scene/doc/stress3d_vessel.rstandscene_rendering_engine.rstdescribe aRenderEngine/AutoConstantDataSourcedesign that does not exist in code. The plan is based on the code. - 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). firstInstancewith instance-rate attributes is proven byIndirectPbrTechnique.gl_DrawIDandgl_BaseInstanceare 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
drawIndexedper 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 withinstanceCount = N, issued as a singledrawIndexedIndirect(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:
GpuGroupstores each mesh once per group. A mesh used in two groups is stored twice. - Instances and transforms:
- The
Instancelist 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.
- The
- Draws:
DirectDrawissues one draw perDrawItem, which is one primitive of one instance.IndirectDrawuses commands built insideIndirectPbrTechnique(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
NodeUbodescriptor set, bound in everybindDrawState.- 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).
- Unskinned matrices are stored column-major and skinned ones row-major (a Golden parity quirk in
- Hair: the world matrix is written into
SceneUbo.model, taken from the group's first instance.gl_InstanceIndexis the shell index. - Meshlet: no per-instance transform; vertices sit pre-baked in a group-wide pool.
- IndirectPbr: instance-rate
R32_SINTattributes 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 fromsampler2D[128].
#1.3 Concrete duplication and waste in SRE
- Per-instance node UBOs:
NodeUbois 8260 bytes, with one buffer and one descriptor set per instance per technique. EachStress3dUniformsholds 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.
- Geometry duplicated per group: a mesh referenced from two glTF scenes is uploaded twice, because
uploadGroupspacks per group. - 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. - Two loaders for the same
.sceneformat, plus the separate asset setsscenes/andscenes_indirect/built from the same glTF files. - Duplicated light, camera and view uniform buffers in every technique.
- Scenario logic inside techniques and sources rather than the script:
- zoom-to-fit spin (
computeZoomToFitModel); cameras.back()andlights.back()selection;- the camera Y-flip in the indirect loader;
- the hairmark torus animation callback.
- zoom-to-fit spin (
#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.handscene_systems.h(animation and bounds): pure CPU code, no Vulkan.asset_library.handgltf_loader.h: CPU code, usinggfx_scene'sSceneAsset.render_scene.h: GPU sync, using vkutil.
frame/stays belowscene/. Becausescene/may not includeframe/,RenderSceneexposes its buffers and the recipe/technique layers import them into the frame graph. This mirrors how techniques importGpuGroupbuffers today.- Deleted at the end:
gltf_scene_source.*,indirect_scene_source.*,procedural_scene_source.*,gpu_scene.*,scene_conformance.*, and theISceneSource/FrameStatepart ofscene_contract.h. That file is renamedscene_types.h; it keepsMeshData,MaterialData,TextureDataandLightData, addsSkinDataandAnimationClip, 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 offrame::ResourceHandle.- Destroying an entity bumps its generation, so stale handles are detected:
isAlive()returns false and accessors throwstd::invalid_argument. - Freed indices are reused lowest first, so allocation is deterministic.
- Destroying an entity bumps its generation, so stale handles are detected:
- Required
Transformper node: local TRS plus an optional glTFmatrix(local = TRS × matrix, as both loaders compute today). The node also cachesworldand adirtyflag. - Hierarchy: each node stores
parent,firstChild,lastChild,prevSiblingandnextSiblingas 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::buildInstancestoday.
- Components:
ComponentPool<T>is a sparse-set pool (dense values plus an entity-to-slot map), type-erased behindIComponentPool, with a per-type id fromcomponentTypeId<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.
- Built-in components:
- Dirty tracking:
- Mutable access (
editTransform(),edit<T>()) sets a per-entity dirty bit; constgetdoes not. - Structural changes (create, destroy, reparent, add or remove a renderable component) bump
structureVersion, which tellsRenderSceneto rebuild its draw lists.
- Mutable access (
#2.3 Asset and resource layer
AssetLibraryowns resources:MeshData,MaterialData,TextureData,SkinData,AnimationClipandPrefab, addressed by the existing ids (MeshId,MaterialId,TextureId) plus newSkinId,AnimationIdandPrefabId.- Deduplication:
- Within an asset, by glTF mesh, material, texture and skin index.
- Across loads, by asset path: loading the same
.scenetwice returns the samePrefabId.
- 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 byinstantiateandclone.- glTF scenes become layer bits (
GltfLoadOptions::layerPerScene, default true): scene i getsMeshRenderer::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.materialsemantics):AssetLibrary::cloneMaterial(id)returns a newMaterialId.MeshRenderer::materials[i]overrides the mesh primitive's default material. Materials may be added or edited after the GPU upload, up toRenderSceneCapacity::materials. - Procedural meshes (hairmark, Donut's cube) use
AssetLibrary::addMesh(makeMeshData(mesh)), withmakeMeshDatakept 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 currentPbrOptions::baseColorImagespecial 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.
- One global geometry pool: six vertex streams plus u32 indices rebased per mesh, with
- Fixed-capacity buffers (
RenderSceneCapacity), each a single-binding descriptor set shared by every technique:ViewUBO: view, projection, view-projection, camera position;InstancesSSBO:InstanceData= world mat4 (column-major, one convention), joint offset, joint count, flags; 80 bytes;JointsSSBO;MaterialsSSBO;Lights;Textures:sampler2D[k_MaxTextureCount], one binding holding an array.
sync(Scene&, StepTime), once per step on the CPU:- Propagate world transforms in pre-order, only for subtrees with dirty nodes.
- Allocate a stable instance slot per renderable entity (lowest free slot first) and free it on destroy.
- Collect dirty slots, material edits, joints (computed relative to the instance, as the contract does today) and lights.
- If
structureVersionchanged, rebuild every requestedDrawList.
recordUpdates(cmd):vkCmdUpdateBufferin chunks of at most 64 KiB (asindirect_pbr_technique.cpp:470-475does), for dirty ranges only. The first step writes everything.Recipe::build()records theRenderSceneupdates first in itsupdatepass, then the techniques' updates.- Draw-list record and command buffers are updated the same way, so recording ahead with
SreVesselstays correct.
DrawList, requested by a technique beforeimportResources:- Requested with
DrawFilter{layers, order}, whereorderisHierarchy(pre-order, required for blending) orByMesh(mesh, then primitive, then instance; the batching order). - Output:
DrawBatches, each{mesh, primitive, firstIndex, indexCount, vertexCount, material, firstRecord, recordCount, hasMeshlets};- a
DrawRecordstream{instanceSlot, material}, an instance-rate vertex buffer ofR32G32_UINT; - an indirect command buffer with one
DrawIndexedIndirectCommandper indexed batch, wherefirstInstance = firstRecord.
- In
Hierarchyorder each batch hasrecordCount = 1; inByMeshorder it covers every instance of the mesh primitive.
- Requested with
- Draw strategies (one shared data source; draw_strategy.h):
DirectDraw: for each batch and record,bindDrawState, thendrawIndexed(count, 1, first, 0, firstInstance = record).BatchedDraw(new): onedrawIndexed(count, recordCount, first, 0, firstRecord)per batch.IndirectDraw:drawIndexedIndirectover the list's command buffer. It now takes aDrawListrather than a technique-built buffer.ShellDraw(renamedInstancedDraw): per record, push{instance, material}, thendrawIndexed(count, shellCount(), first, 0, 0).gl_InstanceIndexstays the shell index.MeshletDraw: per record, push{instance, meshletOffset, meshletCount}, thendrawMeshTasksEXT(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.glsldeclares:- 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 throughfirstInstance. - The world matrix is
instances[inDrawRecord.x].worldand the material ismaterials[inDrawRecord.y].
- Bindings 0-5 are the per-vertex streams. Binding 6 is the per-instance
- Shell and mesh techniques (
sre_furunderShellDraw,sre_hair,sre_meshlet):{uint instance; uint material; ...}comes from a vertex/mesh-stage push constant, sogl_InstanceIndexstays the shell index.- Fur is drawn by
ShellDrawonly; the Pbr technique picks the push-constant path when its program is Fur.
- Fur is drawn by
- 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
.pipefile (userDataNodeper set,INPUT_RATE_INSTANCEbinding 6, push-constant dwords); add_shader_executabletargets 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, producingshaders/{gfx11,gfx11_5,gfx12,gfx13}/sre_*.sp3pluspipeline_metadata/<gen>/sre_*.toml; add_elf_shader_pipelineentries.
#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()becomesgetDrawList().bindDrawState(cmd, const scene::DrawBatch&, uint32_t record).getDrawRepeatCount()/bindRepeatState()stay; caustic draws each primitive twice.importResourcesimports theRenderScenebuffers once, through a helper intechniques/common/scene_resources.h.
SreVessel gains a script hook (sre_vessel.h):
Scenebecomes{AssetLibrary assets; scene::Scene world; unique_ptr<RenderScene> renderScene; techniques}.- New
virtual void onStep(scene::Scene&, scene::AssetLibrary&, const StepTime&) {}. recordStep()runsonStep(), thenscene::animate(), thenrenderScene->sync(), thenframeGraph->record().buildRecipe()is the "Start" phase: load prefabs, instantiate, create techniques and passes.loadGltfSourceandloadIndirectSourcebecome a singleloadPrefab(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):buildRecipeinstantiates the prefab under aturntableentity.- The zoom-to-fit scale and offset come from
computeWorldBounds, computed once. onStepsets 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
PbrTechniquewithDirectDraw; the indirect variant usesIndirectPbrTechniquewithIndirectDraw. - glTF animations run through
AnimationPlayerwith no per-test code. - The camera is the glTF camera entity, chosen by
setActiveCamera(world.find(root, "...")), or by the loader's ordered camera listprefab.cameras[i]for index-based TCDs.
- Both load the same prefab: the indirect TCDs move to
- Hairmark (
gfx.sre.5):addMesh(makeMeshData(asset.makeMesh(0))), then one entity with aMeshRenderer.onStepwrites the torus transform. The camera entity is created inbuildRecipe. The technique usesShellDraw{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:
addMeshandaddMaterialwith a texture. - Entities
CubeNode(withMeshRenderer) and its childSun(withLight, 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.
- Procedural cube:
bindless_rendering:- Load Sponza and instantiate it once.
- Donut's instance, geometry and material structured buffers map to set 1 (Instances), the
DrawBatch/DrawRecordstream and set 3 (Materials). Its push constantint2(instance, geometry)is exactlyDrawRecord. - 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 inTransform::matrixwith TRS reset, and a debug log is written.KeepLocalleaves the local untouched.- Parenting to self or to a descendant throws. An empty
parentmakes 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,
structureVersionis 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::jointsandAnimationPlayer::targets. References to entities outside the subtree are kept as they are. Nameis copied unchanged; names are not unique.- User component types are copied with their copy constructor, so a user component holding an
Entitygets no automatic remapping. AremapEntitieshook is optional and documented.
- Handle invalidation: generational handles never dangle. Pointers or references returned by
get/edit/tryGetare 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
VieworforEachInSubtreeholds an iteration guard. Structural changes while one is live trigger aTNG_ASSERTin debug builds and are undefined in release. - Value edits through
editare allowed. - Scripts that spawn or destroy while iterating collect handles first, a pattern shown in the doc.
- A
- Determinism for CRC tests:
- No
unordered_mapiteration 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
StepTimeonly; there are no wall-clock or thread-pool-dependent results (texture transcoding stays parallel, but each result lands in its own fixed slot). RenderScenewrites its full state on the first step.
- No
- Capacity overflow:
sync()throwsstd::length_errornaming the exceeded capacity. Asset additions afterfreezeResources()throwstd::logic_error. - Mesh, primitive and material validity: the asset checks from
scene_conformancemove tocheckAssets(const AssetLibrary&).RenderSceneruns it at construction, and a debug-onlycheckScene(const Scene&)covers hierarchy invariants.
- CPU scene core:
entity.h,components.h,scene.h/.cpp,scene_systems(animation, bounds), with unit tests. No render changes. - Asset library and unified glTF loader:
- Add
scene_types.h, a copy of the contract types plusSkinData/AnimationClip, at version 2. scene_contract.hstays temporarily and aliases the shared types.- Tests use procedural assets plus a small glTF fixture.
- Add
RenderScene:- Geometry pool, scene sets, slots, draw lists, stats.
- New draw strategies
BatchedDraw,ShellDraw, and anIndirectDraw(DrawList)overload. Recipe::build()recordsRenderSceneupdates.- Old
GpuSceneand techniques stay.
- Shared shader include plus
sre_pbr_indirect(SPIR-V only, as today):- Port
IndirectPbrTechniquetoRenderSceneand switch the food-store and mountain-range indirect vessels. - New goldens for
gfx.sre.110,.111,.120and.121.
- Port
- Stress3D family, one vessel per change, each with its shaders,
.pipefiles, 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).
- water bottle (
- New coverage: the prefab-hierarchy vessel (the Unity example), the dedup/equivalence vessel (section 6), and their TCDs and goldens.
- Delete the old path:
GltfSceneSource,IndirectSceneSource,ProceduralSceneSource,GpuScene/GpuGroup/DrawItem,scene_conformance,Stress3dUniforms'sNodeUbopath,InstancedDraw, theISceneSource/FrameStatepart ofscene_contract.h, andSreVessel::loadGltfSource/loadIndirectSource.- Replace
scene_contract_test.cppwithscene_types_test.cpp, which guards v2 the same way. - Delete
procedural_scene_source_test.cppandscene_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.1and.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.
- new vertex and fragment interface: instance SSBO plus
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 fromSceneUbo.modelto 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 fromSceneUbo.modelto a turntable entity, and the matrix order of operations changes.gfx.sre.110,.111,.120,.121:- the
.scenefile may move fromscenes_indirect/toscenes/(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.
- the
- Unchanged: all
gfx.stress3d.*,gfx.indirect.*,gfx.gfxoff,gfx.harvest.3and 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, lazygetWorldMatrix, dirty subtree only,setWorldMatrix.scene_reparent_test:KeepWorldandKeepLocal, 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;freezeResourcesthrowing.gltf_loader_test: a fixture with two nodes sharing a mesh gives oneMeshData; layers per scene; defaults.animation_test: sampling at step fractions; bindings after clone.draw_list_test: no Vulkan needed. TestbuildDrawLists()(a pure function) forHierarchyversusByMeshorder, batch merging,firstRecordoffsets, indirect command contents, layer filters.render_scene_capacity_test: overflow messages.- Port
recipe_test,pbr_technique_testandindirect_pbr_technique_testtoRenderScenefakes.
- Deduplication metrics, from
RenderScene::getStats()and logged by the new dedup vessel:- instantiate the water bottle N = 1, 10, 100 and 1000 times;
geometryBytesmust stay constant;instanceBytesmust equal 80 × N;- descriptor sets must stay constant (previously N per technique);
- draw calls: N × primitives for
DirectDraw, primitives forBatchedDrawandIndirectDraw. - 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.DirectDrawwithByMeshorder;BatchedDraw;IndirectDraw;DirectDrawwithHierarchyorder (opaque only).
- All four cases must match one golden column, so if each matches the golden they match each other. Same technique, same
DrawListdata, so the outputs must be byte-identical. - The
Hierarchy-order case is allowed only on opaque content withLessOrEqualdepth and no coplanar ties. If it differs, it gets its own column and is excluded from the equality claim.
- New test
- Merge the two PBR families? Stress3D
pbr.fragand indirectpbr_indirect.fragshade 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. - Runtime asset loading: the plan freezes meshes and textures once
RenderSceneexists, 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 requireFrameGraphreset and rebuild support plus a fence-safe upload path. - One
.sceneper model: move thegfx.sre.11xand12xTCDs fromscenes_indirect/toscenes/(same glTF source; this halves asset loads). If the two generator outputs differ in content, keep both. - 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.pipedword counts. - 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.
- ELF regeneration:
disassemble_elfis not in the repo. Please confirm the amdllpc version and flags that match the existing.sp3headers, so the 4 generations are reproducible. - Default capacities (4096 instances, 16384 records, 1024 materials, 8192 joints): accept these, or make them TCD parameters (
render.capacity.*)? - Notify the ECS owner named in
scene_contract.h, even though no code of theirs exists. - 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.