End-to-end flow
This document traces exactly what happens when you write a simple scene and run it — from the first line of user code to pixels on screen. We'll use a circle as the example throughout.
Unlike the other pages in this section, this page is intentionally example-driven. Its job is to walk one concrete object through the whole system rather than define each subsystem in isolation.
let mut scene = Scene::new();
let id = scene.add_tattva(
Circle::new(1.0, 64, Vec4::new(0.2, 0.6, 1.0, 1.0)),
Vec3::new(0.0, 0.0, 0.0),
);
scene.camera_mut().position = Vec3::new(0.0, 0.0, 10.0);
App::new()?.with_scene(scene).run_app()
Step 1 — User constructs a Circle
Circle::new(1.0, 64, Vec4::new(0.2, 0.6, 1.0, 1.0))
Circle is plain data. No GPU calls, no allocation beyond the struct itself:
pub struct Circle {
pub radius: f32, // 1.0
pub segments: u32, // 64
pub style: Style, // fill color = (0.2, 0.6, 1.0, 1.0)
}
Step 2 — add_tattva wraps it in Tattva<Circle>
scene.add_tattva(circle, Vec3::new(0.0, 0.0, 0.0))
Internally this calls circle.into_tattva() via the IntoTattva blanket impl, producing:
Tattva<Circle> {
id: 0, // assigned next
state: circle, // the Circle struct
props: Arc<RwLock<DrawableProps {
position: Vec3(0.0, 0.0, 0.0),
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
visible: true,
opacity: 1.0,
}>>,
dirty: DirtyFlags::GEOMETRY, // starts dirty — needs first-frame materialization
}
The position is written into props, the tattva is assigned the next available TattvaId, and stored in scene.tattvas as Box<dyn TattvaTrait>.
Step 3 — App::run_app starts the event loop
App::run_app creates a winit event loop and an Engine. The engine owns:
scene— the frontend statebackend— GPU device, renderer, ECS worldsync_boundary— the bridge between them
On each frame, winit fires a RedrawRequested event which calls:
engine.update(dt);
engine.render();
Step 4 — engine.update(dt): frontend tick
pub fn update(&mut self, dt: f32) {
self.scene.update(dt); // 1. advance time, run timelines & updaters
// 2. sync dirty tattvas to GPU
for (_id, tattva) in self.scene.tattvas_iter_mut() {
self.sync_boundary.sync_tattva(
&mut self.backend.world,
&self.backend.renderer.device_mgr.device,
&self.backend.renderer,
tattva.as_mut(),
);
}
}
4a — scene.update(dt)
scene_time advances by dt. Timelines tick and apply any animations to tattva props. Updaters run. For our static circle on frame 1, nothing changes — the circle is still dirty with GEOMETRY from when it was added.
4b — sync_boundary.sync_tattva
The sync boundary checks the dirty flags:
let dirty = tattva.dirty_flags();
if dirty.is_empty() { return; } // nothing to do
if dirty.intersects(REBUILD) { // GEOMETRY is in REBUILD
self.rebuild_render_entities(world, device, renderer, tattva);
tattva.clear_all_dirty();
}
GEOMETRY is part of REBUILD, so a full rebuild is triggered.
Step 5 — Projection: Circle → RenderPrimitive
Inside rebuild_render_entities, the sync boundary calls:
fn project_tattva(&self, tattva: &dyn TattvaTrait) -> Vec<RenderPrimitive> {
let mut ctx = ProjectionCtx::new(tattva.props().clone());
tattva.project(&mut ctx);
ctx.primitives
}
This calls Circle::project:
impl Project for Circle {
fn project(&self, ctx: &mut ProjectionCtx) {
if let Some(fill) = &self.style.fill {
let mesh = Mesh::circle(self.radius, self.segments, fill.clone());
ctx.emit(RenderPrimitive::Mesh(mesh));
}
// stroke would emit RenderPrimitive::Line segments
}
}
Mesh::circle tessellates the circle into 64 triangles using lyon, producing a Mesh with:
MeshData::Mesh(Vec<MeshVertex>)— 65 vertices (center + 64 perimeter points)indices: Vec<u32>— 192 indices (64 triangles × 3)
ctx.emit pushes this as RenderPrimitive::Mesh(mesh) into ctx.primitives.
Step 6 — Materialization: RenderPrimitive → ECS entity
Back in materialize_primitives, the sync boundary processes each RenderPrimitive:
RenderPrimitive::Mesh(mesh) => {
upload_mesh(device, mesh.as_ref(), None).map(|mesh_instance| {
world.spawn((
MeshComponent(Arc::new(mesh_instance)),
tattva.props().clone(), // SharedProps as a component
))
})
}
upload_mesh calls MeshInstance::new which creates two wgpu buffers:
// vertex buffer — uploaded to GPU VRAM
device.create_buffer_init(&BufferInitDescriptor {
contents: bytemuck::cast_slice(&vertices),
usage: BufferUsages::VERTEX,
})
// index buffer — uploaded to GPU VRAM
device.create_buffer_init(&BufferInitDescriptor {
contents: bytemuck::cast_slice(&indices),
usage: BufferUsages::INDEX,
})
world.spawn(...) creates a new hecs entity with two components:
MeshComponent— wraps the GPU buffers in anArc<MeshInstance>SharedProps— the sameArc<RwLock<DrawableProps>>the frontend tattva holds
The entity ID is stored in sync_boundary.entity_cache[tattva_id]. The dirty flags are cleared.
Step 7 — engine.render(): draw the ECS world
pub fn render(&mut self) -> Result<()> {
self.backend.renderer.render_scene(&self.scene, &self.backend.world)
}
Inside render_scene:
7a — Acquire frame
let (frame, view) = self.device_mgr.acquire_frame()?;
Gets the next surface texture from the swapchain.
7b — Compute view-projection matrix
let view_proj = scene.camera.view_proj_matrix();
The camera at (0, 0, 10) looking at the origin produces a perspective projection matrix.
7c — Build draw list
The renderer queries the ECS world for all (MeshComponent, SharedProps) pairs:
for (_, (mesh_comp, props)) in world.query::<(&MeshComponent, &SharedProps)>().iter() {
let props = DrawableProps::read(props);
if !props.visible || props.opacity <= 0.0 { continue; }
list.push((mesh_comp.0.clone(), props.model_matrix(), mesh_comp.0.bind_group.clone(), props.opacity));
}
props.model_matrix() computes Mat4::from_scale_rotation_translation(scale, rotation, position) — for our circle at the origin with default scale/rotation this is the identity matrix.
7d — Encode render pass
A render pass is begun with a clear color (dark background). For each mesh in the draw list:
let mvp = view_proj * model; // perspective * identity = perspective
let offset = draw_idx * uniform_slot_size;
// Write MVP + alpha into the pre-allocated uniform buffer
queue.write_buffer(&uniform_buffer, offset, bytemuck::cast_slice(&[
Uniforms { mvp: mvp.to_cols_array_2d(), alpha: 1.0, .. }
]));
rpass.set_pipeline(&self.mesh_pipeline);
rpass.set_bind_group(0, &self.uniform_bind_group, &[offset as u32]);
rpass.set_bind_group(1, &self.default_texture_bind_group, &[]);
mesh.draw(&mut rpass); // set_vertex_buffer + set_index_buffer + draw_indexed
7e — Submit and present
queue.submit(Some(encoder.finish()));
frame.present();
The GPU executes the render pass. The mesh shader reads the vertex positions, transforms them by the MVP matrix, and outputs the circle's triangles in clip space. The fragment shader reads the vertex color and outputs the final RGBA pixel values. The swapchain presents the frame to the window.
Summary
User code
└─ Circle::new() pure data, no GPU
└─ scene.add_tattva() wraps in Tattva<Circle>, sets DirtyFlags::GEOMETRY
Frame loop (per frame)
└─ scene.update(dt) advance time, run timelines/updaters
└─ sync_boundary.sync_tattva()
└─ dirty & REBUILD?
└─ tattva.project() Circle → Vec<RenderPrimitive::Mesh>
└─ upload_mesh() tessellated vertices → wgpu vertex/index buffers
└─ world.spawn() MeshComponent + SharedProps → hecs entity
└─ renderer.render_scene()
└─ acquire_frame() get swapchain texture
└─ query ECS world collect draw list
└─ for each mesh:
└─ compute MVP view_proj * model_matrix
└─ write uniform buffer MVP + alpha
└─ draw_indexed() GPU draws triangles
└─ present() frame appears on screen
On subsequent frames where nothing changes, dirty_flags is empty and the sync boundary skips the tattva entirely — no re-tessellation, no buffer uploads. The ECS entity from frame 1 is reused as-is.