Improving The Scene Graph
Scene graphs are the foundation of 3D rendering systems, enabling the arrangement of objects in a hierarchical structure to facilitate transformation, rendering, and control. However, how they are implemented can have a significant impact on performance, maintainability, and extensibility. In this section, we'll examine the shortcomings of a conventional pointer-based scene graph (as seen in the previous section), and how a refactored, data-oriented approach using flat arrays and indices solves these issues.
Pointer-Based Scene Graph
Here’s the original implementation we started before:
Renderable :: struct {
draw: proc(self: ^Renderable, top_matrix: la.Matrix4f32, ctx: ^Draw_Context),
}
Node :: struct {
using renderable: Renderable,
parent: ^Node,
children: [dynamic]^Node,
local_transform: la.Matrix4x4f32,
world_transform: la.Matrix4x4f32,
}
Mesh_Node :: struct {
using node: Node,
mesh: ^Mesh_Asset,
}
mesh_node_draw :: proc(self: ^Renderable, top_matrix: la.Matrix4x4f32, ctx: ^Draw_Context) {
mesh_node := cast(^Mesh_Node)self
node_matrix := la.matrix_mul(top_matrix, mesh_node.world_transform)
for &surface in mesh_node.mesh.surfaces {
def := Render_Object{ /* ... */ }
append(&ctx.opaque_surfaces, def)
}
node_draw(self, top_matrix, ctx) // Draw children
}
The Problem
Lets explore the limitations of traditional pointer-based scene graphs and their impact on performance and memory usage.
-
Cache Inefficiency:
- Problem: Each
Node
contains pointers (parent
,children
,mesh
), which scatter data across memory. When traversing the scene (e.g., inmesh_node_draw
), the CPU must chase these pointers, causing frequent cache misses. - Impact: Modern CPUs are optimized for accessing contiguous memory. When data is scattered across memory due to pointer indirection, the CPU incurs additional latency from cache misses.
- Problem: Each
-
Memory Overhead:
- Problem: Pointers are 64-bit (8 bytes) on most systems, and
children: [dynamic]^Node
adds per-node allocation overhead (e.g., capacity, length fields). A scene with thousands of nodes wastes significant memory on pointers rather than actual data. - Impact: Increased memory usage reduces scalability, especially on limited resources platforms like consoles or mobile devices.
- Problem: Pointers are 64-bit (8 bytes) on most systems, and
-
Complexity and Fragility:
- Problem: The
Renderable
interface and polymorphicdraw
procedures introduce runtime indirection (via procedure pointers) and require manual management of node types (Node
vs.Mesh_Node
). Pointers also risk dangling references if not carefully managed. - Impact: More code to maintain, harder debugging (e.g., null pointer crashes), and slower execution due to virtual-like calls.
- Problem: The
-
GPU Integration:
- Problem: Transforming
local_transform
andworld_transform
into GPU buffers requires gathering scattered data, complicating uploads to Vulkan. - Impact: Extra CPU work to linearize data, reducing efficiency in the render pipeline.
- Problem: Transforming
-
Traversal Performance:
- Problem: Recursive traversal in
node_draw
andmesh_node_draw
relies on following pointers to child nodes. - Impact: Explicit recursion causes performance issues, which become worse by deep recursion.
- Problem: Recursive traversal in
Component-Based Scene Graphs
The component-based design define nodes as entities composed of distinct, interchangeable components (e.g., transforms, meshes, materials). This fundamentally changes how scene data is organized and handled. Rather than defining nodes as fixed structs with predefined fields—as in the previous implementation—this approach treats each node as a simple entity, identified by a unique integer ID. This ID works as a key to access various modular components stored in different collections.
Before we begin, create a new file called scene.odin
and move the definitions of
Render_Object
and Draw_Context
from drawing.odin
into this new file. Them, update the
material
field in Render_Object
to be of type u32
, representing an index into a materials
array. This change ensures better alignment with the data-oriented design principles discussed
earlier.
package vk_guide
// Core
import "base:builtin"
import la "core:math/linalg"
// Vendor
import vk "vendor:vulkan"
// Define sentinel values for indicating invalid node
NO_MESH :: max(u32)
NO_MATERIAL :: max(u32)
NO_NAME :: max(u32)
// Render object that holds drawing data.
Render_Object :: struct {
index_count: u32,
first_index: u32,
index_buffer: vk.Buffer,
material: u32, // Index into materials array
transform: la.Matrix4f32,
vertex_buffer_address: vk.DeviceAddress,
}
// Define our base drawing context and renderable types.
Draw_Context :: struct {
opaque_surfaces: [dynamic]Render_Object,
}
You can now remove the remain code from drawing.odin
that was added from previous section.
From this point forward, any new code related to the scene graph should be added to
scene.odin
, unless explicitly stated otherwise.
Left Child, Right Sibling
Let's start our refactored code with the Hierarchy
component.
// Hierarchy component for scene nodes
Hierarchy :: struct {
parent: i32, // -1 means no parent
first_child: i32, // -1 means no children
next_sibling: i32, // -1 means no next sibling
last_sibling: i32, // -1 means no siblings, otherwise the last sibling for quick appending
level: i32, // Depth in the hierarchy, root = 0
}
We are transitioning from the Node
structure to the Hierarchy
component, which uses integer
indices that are stored in contiguous arrays to represent relationships between nodes.
This new approach leverages the "Left Child, Right Sibling" representation, a clever way to
flatten a multi-child tree into a binary-like structure without pointers. Each node’s
first_child
index points to its leftmost child, and next_sibling
links to the next child of
the same parent, forming a linked list of siblings. last_sibling
tracks the end of this list
for O(1) child appending, and level
caches depth for quick hierarchy queries.
The picture on the left shows a normal tree where each node can have any number of children, this is what we did before. The picture on the right, on the other hand, shows a new structure that only saves one reference to the first child and another reference to the next "sibling."
For more information on the “Left Child, Right Sibling” representation, you can check out the additional reading available on Wikipedia at https://en.wikipedia.org/wiki/Left-child_right-sibling_binary_tree.
It is important to note one key disadvantage: random access to a child node is now slower on average because we must traverse each node in the list. However, this isn't a huge concern for our purposes because we usually travel through all of the children or none of them.
The New Scene
Now, we can declare a new Scene
structure with logical components:
// Scene container to store all node data in arrays
Scene :: struct {
// Transform components
local_transforms: [dynamic]la.Matrix4f32,
world_transforms: [dynamic]la.Matrix4f32,
// Hierarchy components
hierarchy: [dynamic]Hierarchy,
// Mesh components (Node index -> Mesh index)
mesh_for_node: [dynamic]u32,
// Material components (Node index -> Material index)
material_for_node: [dynamic]u32,
// Optional debug components
name_for_node: [dynamic]u32,
node_names: [dynamic]string,
// Material instances
materials: [dynamic]Material_Instance,
// Mesh assets
meshes: Mesh_Asset_List,
}
The new Scene
structure is essentially a container that organizes all scene-related
information into tightly packed arrays. Each array corresponds to a specific component, such as
transforms, hierarchy relationships, meshes, or materials. Nodes are identified by their unique
IDs (an i32
ranging from 0
to len(hierarchy)-1
), which serve as indices into these
arrays.
local_transforms
- Per-node local transformation matrices (relative to parent)world_transforms
- Per-node world transformation matrices (computed from local and parent transforms)hierarchy
- Per-node hierarchy datamesh_for_node
- Array mapping node indices to mesh indices (NO_MESH
if none)material_for_node
- Array mapping node indices to material indices (NO_MATERIAL
if none)name_for_node
- Map of node indices to name indices (for debugging,NO_NAME
if none)node_names
- Array of node names (indexed byname_for_node
values)materials
- Array of material instances (pipelines, descriptors, etc)meshes
- List of mesh assets (vertices, indices, surfaces)
We also need a way to initialize and destroy scenes:
// Initialize a new scene.
scene_init :: proc(scene: ^Scene, allocator := context.allocator) {
context.allocator = allocator
scene.local_transforms = make([dynamic]la.Matrix4f32)
scene.world_transforms = make([dynamic]la.Matrix4f32)
scene.hierarchy = make([dynamic]Hierarchy)
scene.mesh_for_node = make([dynamic]u32)
scene.material_for_node = make([dynamic]u32)
scene.name_for_node = make([dynamic]u32)
scene.node_names = make([dynamic]string)
scene.materials = make([dynamic]Material_Instance)
scene.meshes = make([dynamic]Mesh_Asset)
}
// Free scene resources.
scene_destroy :: proc(scene: ^Scene, allocator := context.allocator) {
context.allocator = allocator
delete(scene.local_transforms)
delete(scene.world_transforms)
delete(scene.hierarchy)
delete(scene.mesh_for_node)
delete(scene.material_for_node)
delete(scene.name_for_node)
delete(scene.node_names)
delete(scene.materials)
delete(scene.meshes)
}
The scene_init
procedure initializes the Scene
instance, preparing it for use. On the other
hand, the scene_destroy
procedure is responsible for the deallocation of a Scene
’s
resources.
Add Node
Next, we'll implement the scene_add_node
procedure, which handles the insertion of new nodes
into the scene's hierarchical structure.
// Add a new node to the scene
scene_add_node :: proc(scene: ^Scene, #any_int parent, level: i32) -> i32 {
// Create new node ID
node := i32(len(scene.hierarchy))
// Add transform components with identity matrices
append(&scene.local_transforms, la.MATRIX4F32_IDENTITY)
append(&scene.world_transforms, la.MATRIX4F32_IDENTITY)
// Add default associations
append(&scene.name_for_node, NO_NAME)
append(&scene.mesh_for_node, NO_MESH)
append(&scene.material_for_node, NO_MATERIAL)
// Add hierarchy component
new_hierarchy := Hierarchy {
parent = parent,
first_child = -1,
next_sibling = -1,
last_sibling = -1,
level = level,
}
append(&scene.hierarchy, new_hierarchy)
// If we have a parent, update the parent's hierarchy
if parent > -1 {
// Get the first child of the parent
first_child := scene.hierarchy[parent].first_child
if first_child == -1 {
// This is the first child, update parent
scene.hierarchy[parent].first_child = node
scene.hierarchy[parent].last_sibling = node
} else {
// Add as a sibling to the existing children
// Get the last sibling for O(1) insertion instead of traversing
last_sibling := scene.hierarchy[first_child].last_sibling
if last_sibling > -1 {
scene.hierarchy[last_sibling].next_sibling = node
} else {
// Legacy fallback traversal method
dest := first_child
for scene.hierarchy[dest].next_sibling != -1 {
dest = scene.hierarchy[dest].next_sibling
}
scene.hierarchy[dest].next_sibling = node
}
// Update the cached last sibling for future quick additions
scene.hierarchy[first_child].last_sibling = node
}
}
return node
}
The parameters are the Scene
itself, a parent
node index (or -1
for a root node), and a
hierarchical level
.
Let's break down the code:
-
Node Index Assignment: First, the addition process generates a new node identifier based on the current size of the hierarchy array. New identity transformations are then added to both the
local_transforms
andworld_transforms
transform arrays. Also associates the sentinel values for the name, mesh and materials nodes.// Create new node ID
node := i32(len(scene.hierarchy)) -
Transformation Initialization: Next, the procedure initializes the node’s transformation components by appending identity matrices to both the
local_transforms
andworld_transforms
arrays. An identity matrix, represented byla.MATRIX4F32_IDENTITY
, indicates no initial translation, rotation, or scaling.// Add transform components with identity matrices
append(&scene.local_transforms, la.MATRIX4F32_IDENTITY)
append(&scene.world_transforms, la.MATRIX4F32_IDENTITY)noteThe local transform defines the node’s position relative to its parent, while the world transform, will be recomputed later during the first frame to reflect its absolute position in the scene.
-
Default Association Setup: To maintain consistency across the
Scene
’s, the procedure appends default values for the node’s optional associations. Specifically, it addsNO_NAME
toname_for_node
,NO_MESH
tomesh_for_node
, andNO_MATERIAL
tomaterial_for_node
. These sentinel values indicate that the node initially has no associated name, mesh, or material, allowing these attributes to be assigned later as needed.// Add default associations
append(&scene.name_for_node, NO_NAME)
append(&scene.mesh_for_node, NO_MESH)
append(&scene.material_for_node, NO_MATERIAL) -
Hierarchy: The procedure then constructs a
Hierarchy
instance for the new node, defining its place within thescene
’s tree structure. This instance is initialized with the providedparent
index, and itsfirst_child
,next_sibling
, andlast_sibling
fields are set to-1
, indicating no children or siblings at creation. Thelevel
parameter, representing the node’s hierarchical depth (e.g.,0
for roots,1
for their children), is also assigned. This newHierarchy
object is appended to thehierarchy
array, aligning its index with the node’s identifier.// Add hierarchy component
new_hierarchy := Hierarchy {
parent = parent,
first_child = -1,
next_sibling = -1,
last_sibling = -1,
level = level,
}
append(&scene.hierarchy, new_hierarchy) -
Parent-Child Linkage: If the
parent
parameter is greater than-1
, signifying that the new node is not a root, the procedure updates the parent’s hierarchy to include the new node as a child. It first retrieves thefirst_child
index from the parent’sHierarchy
entry. This value guides the subsequent linkage process, determining whether the new node becomes the first child or an additional sibling, ensuring the hierarchical structure remains intact.// If we have a parent, update the parent's hierarchy
if parent > -1 {
// Get the first child of the parent
first_child := scene.hierarchy[parent].first_child -
First Child Assignment: Within the parent linkage logic, if the parent’s
first_child
is-1
(indicating no existing children), the procedure designates the new node as the first child by settingscene.hierarchy[parent].first_child
to the node’s index. Additionally, it updates thelast_sibling
field to the same index, establishing a reference for efficient future sibling additions. This step completes the integration for a parent with no prior children.if first_child == -1 {
// This is the first child, update parent
scene.hierarchy[parent].first_child = node
scene.hierarchy[parent].last_sibling = node
} else { -
Sibling Linkage: If the parent already has children (i.e.,
first_child
is not-1
), the procedure adds the new node as a sibling to the existing child list. It retrieves thelast_sibling
index from thefirst_child
’s hierarchy entry, leveraging this cached value for O(1) insertion efficiency. Iflast_sibling
is valid (greater than-1
), it sets thenext_sibling
field of the last sibling to the new node’s index, linking them directly.if first_child == -1 {
// This is the first child, update parent
scene.hierarchy[parent].first_child = node
scene.hierarchy[parent].last_sibling = node
} else {
// Add as a sibling to the existing children
// Get the last sibling for O(1) insertion instead of traversing
last_sibling := scene.hierarchy[first_child].last_sibling
if last_sibling > -1 {
scene.hierarchy[last_sibling].next_sibling = node
} else { -
Fallback Traversal: In cases where
last_sibling
is-1
(an edge case or legacy scenario), the procedure resorts to a traditional traversal method. It starts at thefirst_child
and iterates through thenext_sibling
links until it finds the last sibling (wherenext_sibling
is-1
), then sets that node’snext_sibling
to the new node’s index. This ensures correct linkage even if thelast_sibling
optimization is unavailable, though it operates at O(n) complexity.if last_sibling > -1 {
scene.hierarchy[last_sibling].next_sibling = node
} else {
// Legacy fallback traversal method
dest := first_child
for scene.hierarchy[dest].next_sibling != -1 {
dest = scene.hierarchy[dest].next_sibling
}
scene.hierarchy[dest].next_sibling = node
} -
Last Sibling Update: Following sibling linkage, the procedure updates the
last_sibling
field of thefirst_child
’s hierarchy entry to the new node’s index. This step maintains the optimization for future insertions, ensuring that the parent’s child list remains efficiently extensible by caching the tail of the sibling chain.for scene.hierarchy[dest].next_sibling != -1 {
dest = scene.hierarchy[dest].next_sibling
}
scene.hierarchy[dest].next_sibling = node
}
// Update the cached last sibling for future quick additions
scene.hierarchy[first_child].last_sibling = node
} -
Return Value: Finally, the procedure returns the new node’s index, providing the caller with a handle to reference or modify the node further (e.g., via
scene_add_mesh_node
or transform updates).
Building upon the scene_add_node
procedure, we can now implement the scene_add_mesh_node
procedure.
// Add a mesh node to the scene.
scene_add_mesh_node :: proc(
scene: ^Scene,
#any_int parent: i32,
#any_int mesh_index, material_index: u32,
name: string = "",
) -> i32 {
// Create a new node
level := parent > -1 ? scene.hierarchy[parent].level + 1 : 0
node := scene_add_node(scene, parent, level)
// Associate the mesh with this node
scene.mesh_for_node[node] = mesh_index
// Associate the material with this node
scene.material_for_node[node] = material_index
// Add name if provided
if len(name) > 0 {
name_idx := append_and_get_id(&scene.node_names, name)
scene.name_for_node[u32(node)] = name_idx
}
return node
}
This procedure extends the functionality of scene_add_node
by associating a mesh and material
with the newly created node, as well as optionally assigning a name for debugimproging purposes. and
computes the node’s level based on its parent’s depth.
The append_and_get_idx
helper procedure simplifies appending elements to dynamic arrays by
returning the index of the newly added element. Add the following code to core.odin
:
// Appends an element to a dynamic array and returns its index.
append_and_get_idx :: #force_inline proc(array: ^$T/[dynamic]$E, arg: E) -> u32 {
append(array, arg)
return u32(len(array) - 1)
}
// Appends an element to a dynamic array and returns a reference to the newly added element.
append_and_get_ref :: #force_inline proc(array: ^$T/[dynamic]$E, arg: E) -> ^E {
append(array, arg)
return &array[len(array) - 1]
}
We also have append_and_get_ref
that can be useful later.
Update Transforms
Like in the previous implementation, the world transform needs to be updated, so whenever the local transform gets changed, we need to update transforms.
// Update all world transforms starting from a specific node.
update_transforms :: proc(scene: ^Scene, #any_int node_index: i32) {
node := scene.hierarchy[node_index]
parent := node.parent
// Calculate world transform
if parent > -1 {
// Node has a parent, multiply with parent's world transform
scene.world_transforms[node_index] = la.matrix_mul(
scene.world_transforms[parent],
scene.local_transforms[node_index],
)
} else {
// Node is a root, world transform equals local transform
scene.world_transforms[node_index] = scene.local_transforms[node_index]
}
// Recursively update all children
child := node.first_child
for child != -1 {
update_transforms(scene, child)
child = scene.hierarchy[child].next_sibling
}
}
// Update all world transforms in the scene.
update_all_transforms :: proc(scene: ^Scene) {
// Find all root nodes and update their hierarchies
for &node, i in scene.hierarchy {
if node.parent == -1 {
// This is a root node
update_transforms(scene, i)
}
}
}
The update_transforms
procedure recalculates the world transformation of a specified node and
its descendants. Given a Scene
and a node_index
, it combines the node’s local transform with
its parent’s world transform (or uses the local transform alone for root nodes) and updates the
world_transforms
array accordingly. It then recursively applies this process to all child
nodes, ensuring that the entire subtree reflects the correct spatial positioning based on the
hierarchy.
To update the entire scene, the update_all_transforms
procedure iterates over all nodes to
identify those without parents (root nodes) and invokes update_transforms
on each.
Draw Node
Now we need to traverses the scene hierarchy starting from a given node and generate
Render_Object
instances for each drawable entity. These instances are then added to the
Draw_Context
, which serves as a collection of all objects to be rendered in the current
frame.
The draw context is just a list of Render_Object
structures, for now. The Render_Object
is
the core of our rendering. The engine itself will not call any vulkan functions on the scene
side, and the renderer is going to take the array of Render_Object
's from the context, built
every frame (or cached), and execute a single vulkan draw function for each.
Knowing this, the scene_draw_node
procedure looks like this:
// Draw a specific node and its children.
scene_draw_node :: proc(scene: ^Scene, #any_int node_index: i32, ctx: ^Draw_Context) {
// Combine top matrix with node's world transform
node_matrix := la.matrix_mul(
scene.local_transforms[node_index],
scene.world_transforms[node_index],
)
// Check if this node has a mesh
if scene.mesh_for_node[node_index] != NO_MESH {
mesh_index := scene.mesh_for_node[node_index]
mesh := &scene.meshes[mesh_index]
// Add render objects for each surface in the mesh
for &surface in mesh.surfaces {
// Get the material index from the node or use the surface's default
material_index := surface.material_index
if scene.material_for_node[node_index] != NO_MATERIAL {
material_index = scene.material_for_node[node_index]
}
// Create render object with a valid material index
def := Render_Object {
index_count = surface.count,
first_index = surface.start_index,
index_buffer = mesh.mesh_buffers.index_buffer.buffer,
material = material_index, // Direct material index
transform = node_matrix,
vertex_buffer_address = mesh.mesh_buffers.vertex_buffer_address,
}
// Add to render context
append(&ctx.opaque_surfaces, def)
}
}
// Draw all children
child := scene.hierarchy[node_index].first_child
for child != -1 {
scene_draw_node(scene, child, ctx)
child = scene.hierarchy[child].next_sibling
}
}
This procedure computes the node’s final transformation matrix, checks for an associated mesh,
and, if present, iterates over the mesh’s surfaces to create render objects. Each object
incorporates the surface’s index data, the chosen material index (preferring the node’s
material over the surface’s default), and the node’s transform, which are then appended to the
Draw_Context
. The procedure recursively processes all child nodes, effectively populating the
context with all drawable entities in the subtree.
Refactor loader.odin
With the new scene graph implementation in place, we need to update the mesh loading process to
align with the data-oriented design. This involves modifying loader.odin
to ensure that
meshes are correctly integrated into the new Scene
structure.
On the Geo_Surface
, replace the material
field with material_index
:
Geo_Surface :: struct {
start_index: u32,
count: u32,
material_index: u32, // Index into the scene's materials array
}
Next, change Mesh_Asset_List
definition to store a simple Mesh_Asset
, not a pointer:
// Mesh_Asset_List :: [dynamic]^Mesh_Asset
Mesh_Asset_List :: [dynamic]Mesh_Asset
We could have done this before, but you need to understand that we should always prefer to work with elements contiguously in memory. This means accessing elements is faster. In contrast, arrays of pointers store references to data, which might be scattered in memory. This can slow things down because the computer needs to follow each pointer to find the actual data.
Also change the signature of load_gltf_meshes
to accept a meshes
out parameter:
Before:
load_gltf_meshes :: proc(
engine: ^Engine,
file_path: string,
allocator := context.allocator,
) -> (
meshes: Mesh_Asset_List,
ok: bool,
) {
After:
load_gltf_meshes :: proc(
engine: ^Engine,
file_path: string,
meshes: ^Mesh_Asset_List,
loc := #caller_location,
) -> (
// meshes: Mesh_Asset_List, // Remove this return value
ok: bool,
) {
Now, the caller is responsible for initializing the meshes
array before invoking the
procedure. Additionally, a #caller_location
parameter has been introduced to facilitate
debugging by providing contextual information for assertions.
At the beginning of the load_gltf_meshes
, make sure meshes
is valid:
log.debugf("Loading GLTF: %s", file_path)
ensure(meshes != nil, "Invalid meshes", loc)
// Configure cgltf parsing options
// Using .invalid type lets cgltf automatically detect if it's .gltf or .glb
options := cgltf.options {
type = .invalid,
}
Next, remove the meshes
initialization, this is now done by the caller:
// Initialize the output mesh list
meshes = make(Mesh_Asset_List, allocator)
defer if !ok {
destroy_mesh_assets(&meshes, allocator)
}
Inside the main for
loop, we now work directly with mesh data stored in the meshes
array
rather than allocating pointers:
// Process each mesh in the glTF file
for &mesh in data.meshes {
// Add a new empty mesh to our collection and get a reference to it
// Instead of allocating a pointer, we work with the actual Mesh_Asset structure
new_mesh := append_and_get_ref(meshes, Mesh_Asset{})
At the end of the procedure, remove the append(meshes, new_mesh)
and change the return value:
// Upload mesh data to GPU
new_mesh.mesh_buffers = upload_mesh(engine, indices_temp[:], vertices_temp[:]) or_return
// Remove this line
// append(meshes, new_mesh)
}
if len(meshes) == 0 {
return
}
return true
}
To finish, let's also modify the procedure used to destroy the mesh assets:
// Destroys a single `Mesh_Asset` and frees all its resources.
destroy_mesh_asset :: proc(mesh: ^Mesh_Asset, allocator := context.allocator) {
context.allocator = allocator
delete(mesh.name)
delete(mesh.surfaces)
}
// Destroys all mesh assets in a list.
destroy_mesh_assets :: proc(meshes: ^Mesh_Asset_List, allocator := context.allocator) {
for &mesh in meshes {
destroy_mesh_asset(&mesh, allocator)
}
}
Refactor engine.odin
Meshes and materials are now stored in the Scene
, that means you can remove test_meses
from
Engine
. Also replace loaded_nodes: map[string]^Node
with name_for_node: map[string]u32
.
Next, add the Scene
field.
Engine :: struct {
// Scene
main_draw_context: Draw_Context,
name_for_node: map[string]u32,
scene: Scene,
scene_data: GPU_Scene_Data,
}
This is the new engine_update_scene
:
// Updates the scene state and prepares render objects.
engine_update_scene :: proc(self: ^Engine) {
// Clear previous render objects
clear(&self.main_draw_context.opaque_surfaces)
// Find and draw all root nodes
for &hierarchy, i in self.scene.hierarchy {
if hierarchy.parent == -1 {
scene_draw_node(&self.scene, i, &self.main_draw_context)
}
}
// Set up Camera
aspect := f32(self.window_extent.width) / f32(self.window_extent.height)
self.scene_data.view = la.matrix4_translate_f32({0, 0, -5})
self.scene_data.proj = matrix4_perspective_reverse_z_f32(
f32(la.to_radians(70.0)),
aspect,
0.1,
true, // Invert Y to match OpenGL/glTF conventions
)
self.scene_data.viewproj = la.matrix_mul(self.scene_data.proj, self.scene_data.view)
// Default lighting parameters
self.scene_data.ambient_color = {0.1, 0.1, 0.1, 1.0}
self.scene_data.sunlight_color = {1.0, 1.0, 1.0, 1.0}
self.scene_data.sunlight_direction = {0, 1, 0.5, 1.0}
}
Note that we removed the code responsible for updating the transforms, as this functionality
will now be handled within init.odin
.
Refactor init.odin
At the beginning of engine_init_default_data
, initialize the engine scene and change the way
we use load_gltf_meshes
to reflect our previous refactor:
engine_init_default_data :: proc(self: ^Engine) -> (ok: bool) {
// Initialize the scene
scene_init(&self.scene)
load_gltf_meshes(self, "assets/basicmesh.glb", &self.scene.meshes) or_return
defer if !ok {
destroy_mesh_assets(&self.scene.meshes)
}
Now, at the end of engine_init_default_data
, replace the code that uses test_meshes
with
this new implementation:
// Add default material to the materials array
default_material_idx := append_and_get_idx(&self.scene.materials, self.default_material_data)
// Process each mesh
for m, i in self.scene.meshes {
// Ignore the Sphere for now
if m.name == "Sphere" {
continue
}
node_idx := scene_add_mesh_node(&self.scene, -1, i, default_material_idx, m.name)
self.name_for_node[m.name] = u32(node_idx)
}
return true
}
We start by adding a default material to the scene’s materials
array and retrieving its
index. Then iterates through each mesh in the scene, and for each mesh, it adds a node with no
parent`.
Assigning a default material to all surfaces isn’t being done here anymore; this responsibility
has been delegated to the Scene
.
Finally, it stores the node’s index in a map using the mesh’s name as the key, enabling easily lookups later, this is how we will get the Suzanne and Cube meshes in the following steps.
For the initial transformations and duplicated cubes, add this after the for
that process
each mesh:
// Find and update Suzanne node
if suzanne_node, suzanne_ok := self.name_for_node["Suzanne"]; suzanne_ok {
self.scene.local_transforms[suzanne_node] = la.MATRIX4F32_IDENTITY
}
// Find and update Cube nodes (create a line of cubes)
if cube_node, cube_ok := self.name_for_node["Cube"]; cube_ok {
for x := -3; x < 3; x += 1 {
scale := la.matrix4_scale(la.Vector3f32{0.2, 0.2, 0.2})
translation := la.matrix4_translate(la.Vector3f32{f32(x), 1, 0})
transform := la.matrix_mul(translation, scale)
// For simplicity, assume one node per cube
if x == -3 {
// Use the original cube node for x = -3
self.scene.local_transforms[cube_node] = transform
} else {
// Add new nodes for additional cubes
new_cube_idx := scene_add_mesh_node(
scene = &self.scene,
parent = cube_node,
mesh_index = cube_node,
material_index = cube_node,
name = "Cube",
)
self.scene.local_transforms[u32(new_cube_idx)] = transform
}
}
}
For the cubes, as you can see in the highlighted lines above, we are reusing the same mesh and
material data from the first cube to create additional instances. The cube_node
is used as
the parent, this will create a hierarchy of cubes where each copy is a child of the first one.
To finish, you need to change how to cleanup the scene:
// Clean up scene nodes
for &mesh in self.scene.meshes {
destroy_buffer(mesh.mesh_buffers.index_buffer)
destroy_buffer(mesh.mesh_buffers.vertex_buffer)
}
destroy_mesh_assets(&self.scene.meshes)
scene_destroy(&self.scene)
delete(self.main_draw_context.opaque_surfaces)
delete(self.name_for_node)
Refactor drawing.odin
Within engine_draw_geometry
, simply change the source of the material data. Note that
draw.material
now represents an ID rather than the material itself.
// Draw all opaque surfaces
for &draw in self.main_draw_context.opaque_surfaces {
material := &self.scene.materials[draw.material] // Use draw.material directly
vk.CmdBindPipeline(cmd, .GRAPHICS, material.pipeline.pipeline)
vk.CmdBindDescriptorSets(
cmd,
.GRAPHICS,
material.pipeline.layout,
0,
1,
&global_descriptor,
0,
nil,
)
vk.CmdBindDescriptorSets(
cmd,
.GRAPHICS,
material.pipeline.layout,
1,
1,
&material.material_set,
0,
nil,
)
vk.CmdBindIndexBuffer(cmd, draw.index_buffer, 0, .UINT32)
push_constants := GPU_Draw_Push_Constants {
vertex_buffer = draw.vertex_buffer_address,
world_matrix = draw.transform,
}
vk.CmdPushConstants(
cmd,
material.pipeline.layout,
{.VERTEX},
0,
size_of(GPU_Draw_Push_Constants),
&push_constants,
)
vk.CmdDrawIndexed(cmd, draw.index_count, 1, draw.first_index, 0, 0)
}
Conclusion
While the component-based approach may initially seem less intuitive than the traditional object-oriented model, its performance benefits make it the preferred choice for modern game engines and graphics applications. By organizing data for how computers actually work—with contiguous memory access and cache-friendly patterns—we can achieve performance improvements without sacrificing functionality.
Remember that this approach aligns well with Entity Component System (ECS) architecture, which has become increasingly popular in game development for similar reasons. The techniques shown here can be expanded to handle not just spatial hierarchies but all aspects of game object management.