Part 5 · Chapter 12 · ~8 min prose skim
The main pass and the scene
What the scene is made of, how models get into it, how the camera turns it into a picture, and the loop that draws it all each frame.
Overview The big picture
A scene is a list of lightweight references
Everything drawn by the main pass's ordinary draw loop is a scene object: a position, rotation and size in the world, plus one or more parts. Each part names a mesh and a material by number, with its own small offset. The actual GPU resources (vertex buffers, textures, pipelines) live in two managers, which hand out those numbers and make sure nothing is loaded twice (Chapter 8). So the forty-eight rocks, drawn from five rock models, cost five sets of buffers, not forty-eight.
The scene in the screenshot is built once at start-up:
- a generated terrain mesh for the valley (Chapter 13);
- the hut (a
.glbmodel) and the marble sphere (a.gltfmodel), loaded with Assimp, a widely used model-loading library; - two textured test cubes;
- clusters of rocks, made from five imported rock models;
- a large, flat debug grid, shown (without grass) only when the terrain is switched off in the UI.
The sky and the grass are drawn by their own specialised renderers inside the same pass (Chapters 13 and 14).
The camera
The camera is two matrices. The view matrix says where the eye is and which way it looks, re-expressing the world relative to the eye. The projection matrix is the lens: a 45° field of view, with nothing drawn nearer than 0.5 m or further than 220 m (the terrain is 200 m across). It's a first-person camera: WASD walks over the ground, the mouse looks around, and the eye stays 1.8 m above the terrain.
Vulkan disagrees with OpenGL on two conventions, so the projection is corrected for both. In Vulkan's clip space, Y points down, and depth runs from 0 to 1 rather than −1 to 1.
One frame of the main pass
After clearing, the pass draws the sky first. It doesn't touch the depth buffer, so everything else paints over it. Then it walks the scene: for each visible object and each part, it switches pipeline and textures if the material changed, sends that part's matrices as push constants (a small block of per-draw data recorded straight into the command buffer), and draws its mesh. Finally the grass is drawn, and rendering ends with the anti-aliasing resolve (Chapter 10).
Transforms, parts and objects
/// A simple 3D transform: position, rotation (as a quaternion), uniform scale.
/// Shaders rely on the scale being uniform: they transform normals with `mat3(mv)`.
#[derive(Clone, Copy)]
pub struct Transform {
pub translation: Vector3<f32>,
pub rotation: Quaternion<f32>,
pub scale: f32,
}
// ...
/// Builds a 4x4 model matrix (Translation * Rotation * Scale).
pub fn model_matrix(&self) -> Matrix4<f32> {
let t = Matrix4::from_translation(self.translation);
let r = Matrix4::from(self.rotation);
let s = Matrix4::from_scale(self.scale);
t * r * s
}A transform is translate × rotate × scale, with a quaternion for rotation and a single uniform scale. The uniform scale is a deliberate restriction: the shaders transform normals with the plain model-view matrix, which is only correct without stretching (Chapter 6).
#[derive(Clone, Copy)]
pub struct ScenePart {
/// Transform in the object's local/model space
pub transform: Transform,
pub material_id: usize,
pub mesh_id: usize,
}
/// One thing in your world that you can draw. Holds a world-space transform and N parts.
#[derive(Clone)]
pub struct SceneObject {
/// World/scene-space transform for the object root
pub transform: Transform,
/// Collection of draw parts (mesh+material) with optional local transforms
pub parts: Vec<ScenePart>,
/// Whether this object should be drawn
pub visible: bool,
}Both types derive Clone, and ScenePart is even Copy, because
all it holds is two indices and a transform. Cloning a rock's parts to place another copy of it costs a few
bytes.
The camera's matrices
fn rebuild_projection(&mut self) {
let mut proj = perspective(Deg(self.fov_deg), self.aspect, self.near, self.far);
// Flip Y to match Vulkan framebuffer coordinate conventions used here
proj.y.y *= -1.0;
// Map GL clip-space Z [-1,1] to Vulkan's [0,1]
pub const OPENGL_TO_VULKAN_MATRIX: Matrix4<f32> = Matrix4::new(
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.5, 1.0,
);
self.projection = OPENGL_TO_VULKAN_MATRIX * proj;
}cgmath's perspective builds an OpenGL-style matrix. Negating proj.y.y flips Y
so the image comes out upright under Vulkan's Y-down convention. Without it, the mirror image would also
reverse triangle winding; with it, OpenGL's counter-clockwise front faces stay counter-clockwise (Chapter 7). The fixed
matrix then halves Z and adds half, mapping depth from −1..1 to 0..1. The shadow cascades reuse that depth
fix-up but not the flip (Chapter 11). The view matrix is a standard
right-handed look-at, built from yaw and pitch angles in set_view_yaw_pitch.
Pushing per-object matrices
For each part, the main pass computes model-view and model-view-projection matrices and packs them, with the material's UV tiling, into the 144-byte push-constant block of Chapter 7:
/// main.vert: two column-major mat4s and a vec4 (xy are UV tiling, zw are padding).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct ScenePushConstants {
pub mvp: [[f32; 4]; 4],
pub mv: [[f32; 4]; 4],
pub uv_tiling: [f32; 4],
}
impl ScenePushConstants {
pub fn new(camera: &Camera, model: &Matrix4<f32>, uv_tiling: [f32; 2]) -> Self {
let mv = camera.get_view() * model;
let mvp = camera.get_projection() * mv;
Self {
mvp: mvp.into(),
mv: mv.into(),
uv_tiling: [uv_tiling[0], uv_tiling[1], 0.0, 0.0],
}
}
}ScenePushConstants names the two matrices and the padded UV field. cgmath already
stores matrix columns, so conversion to [[f32; 4]; 4] preserves GLSL's column-major order.
bytemuck::bytes_of borrows the struct's bytes for the Vulkan command; no heap allocation
or manual byte offsets are needed.
The draw loop
let mut current_pipeline_id = usize::MAX;
for obj in &ctx.scene.objects {
if !obj.visible {
continue;
}
let obj_model = obj.transform.model_matrix();
for part in &obj.parts {
let model_matrix = obj_model * part.transform.model_matrix();
let material = &ctx.material_manager.materials[part.material_id];
let push =
ScenePushConstants::new(ctx.camera, &model_matrix, material.uv_tiling);
if current_pipeline_id != part.material_id {
// ...
current_pipeline_id = part.material_id;
}
device.cmd_push_constants(
cmd,
material.pipeline.vk_layout,
vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
0,
bytemuck::bytes_of(&push),
);
ctx.mesh_manager.meshes[part.mesh_id].record(device, cmd);
}
}Objects are drawn in scene order, not sorted, and the pipeline and descriptors are rebound only when a
part's material differs from the previous part's (the binding itself was shown in
Chapter 8). Consecutive parts sharing a material therefore cost
one push and one draw each; the rocks alternate between two materials, so they rebind more often. record binds the mesh's vertex and index
buffers and issues cmd_draw_indexed.
Loading models with Assimp
import.rs is a one-line front door to assimp_loader.rs, which uses the
russimp-ng bindings to the Assimp C++ library:
// 1) Load the scene with typical preprocessing flags
use russimp_ng::scene::{PostProcess, Scene};
let scene = Scene::from_file(
path,
vec![
PostProcess::Triangulate,
PostProcess::CalculateTangentSpace,
PostProcess::ImproveCacheLocality,
],
)?;Assimp turns every polygon into triangles, computes the tangent and bitangent vectors that normal mapping needs, and reorders vertices for the GPU's cache. The loader then works through the file in three steps.
Materials. For each material it looks for a colour texture and a normal map, resolving file names relative to the model's folder, and chooses shaders accordingly:
// Choose shader based on texture availability
// A normal-map-only material is still textured: `Material::new`
// supplies a neutral diffuse texture for its required albedo binding.
let use_textured = diffuse_tex.is_some() || normal_tex.is_some();
let (vertex_shader, fragment_shader) = if use_textured {
shared_material.map_or_else(
|| ("main.vert.spv".into(), "main.frag.spv".into()),
|(_, paths)| (paths[0].into(), paths[1].into()),
)
} else {
// The colour-only path still receives the global shadow map.
(
"vertex_color.vert.spv".into(),
"vertex_color.frag.spv".into(),
)
};Textured materials use main.vert/main.frag. Untextured ones use the
vertex_color pair, which lights a colour carried in each vertex. The loader copies the
material's base colour into every vertex for exactly that reason.
Meshes. Each Assimp mesh becomes a CPU Mesh, with safe defaults for
missing normals, UVs or tangents, and is uploaded through the mesh manager, which reuses it if the name has
been seen before:
pub fn request_mesh_from_cpu(
&mut self,
name: String,
vb: &VulkanBase,
mesh: Mesh,
) -> Result<usize, Box<dyn Error>> {
// If we already have one by this name, return it.
if let Some(idx) = self.meshes.iter().position(|m| m.name == name) {
return Ok(idx);
}
// Otherwise upload to GPU...
let loaded = crate::graphics::mesh::LoadedMesh::load(
name.clone(),
vb.allocator.as_ref().unwrap(),
&mesh,
)?;
self.meshes.push(loaded);
Ok(self.meshes.len() - 1)
}Nodes. A model file is a tree of nodes, each with a transform and some meshes. The
loader walks the tree with an explicit stack, combining transforms on the way down, and emits one part per
mesh with its transform relative to the root. Assimp gives each node a general 4×4 matrix; the renderer's
Transform only allows uniform scale, so decompose averages the three axis scales:
fn decompose(transform: &russimp_ng::Matrix4x4) -> SceneTransform {
// Interpret as row-major with last column = translation (a4,b4,c4)
let t = CVec3::new(transform.a4, transform.b4, transform.c4);
// Upper-left 3x3 contains rotation*scale. Treat its columns as basis vectors.
let c0 = CVec3::new(transform.a1, transform.b1, transform.c1);
let c1 = CVec3::new(transform.a2, transform.b2, transform.c2);
let c2 = CVec3::new(transform.a3, transform.b3, transform.c3);
let s0 = c0.magnitude();
let s1 = c1.magnitude();
let s2 = c2.magnitude();
// Uniform scale approximation to match SceneTransform design
let scale = ((s0 + s1 + s2) / 3.0).max(1e-6);That's an approximation: a model built with stretched nodes would come out slightly wrong, a trade-off the comment owns.
Building the scene
App::set_up_scene requests materials and meshes, loads models, places everything on the
terrain, and adds it all to the scene. Each object is stood on the ground using the terrain's height
function and the lowest point of its mesh's bounding box, which the mesh manager keeps after the CPU
vertices are discarded. The rocks show the reuse at work: each of the five models is imported once as a
template, then placed many times by cloning its parts:
// A shallow burial hides perfectly sharp model bottoms and makes each rock
// feel embedded in the heightfield rather than balanced on top of it.
transform.translation.y -= member.size * 0.07;
rock_clusters.push(SceneObject {
transform,
// Cloning parts copies indices into manager-owned resources; it does not
// duplicate the Vulkan buffers or atlas image.
parts: template.parts.clone(),
visible: true,
});The last steps flush every queued texture upload in one GPU submission (Chapter 5) and put the camera at eye height:
// All scene materials have now recorded their texture uploads. Submit
// them together once, before the first frame can sample the images.
self.material_manager.finish_loading(vulkan_base)?;
self.camera = Camera::new();
let camera_position = self.camera.position();
self.camera.set_height_above_ground(
terrain_settings.height_at(camera_position.x, camera_position.z),
FIRST_PERSON_EYE_HEIGHT,
);Recap
- A scene object is a transform plus parts. Parts refer to manager-owned meshes and materials by index, so reuse is nearly free.
- The camera's projection flips Y and remaps depth to 0..1 for Vulkan. The view is a first-person look-at, kept 1.8 m above the terrain.
- The main pass draws the sky, then each part (rebinding only when the material changes, pushing 144 bytes of matrices), then the grass.
- Assimp loads glTF/GLB, triangulating and generating tangents. Materials get textured or vertex-colour shaders, and node transforms are flattened to uniform-scale parts.