RustyVulkan GuideContents

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:

The sky and the grass are drawn by their own specialised renderers inside the same pass (Chapters 13 and 14).

From model file to draw call hut.glb model file Assimp loader meshes, materials, nodes MeshManager: vertex/index buffers MaterialManager: pipeline + textures SceneObject transform + parts (mesh_id, material_id, local transform) Main pass bind material, push matrices, draw mesh
Loading fills the managers and produces a scene object that refers to their contents by index (dashed). The main pass follows those indices back to the GPU resources.

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).

Skip to next overview →

Deeper dive

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
    }
src/app/scene.rs:13-67

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,
}
src/app/scene.rs:80-97

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;
}
src/graphics/camera.rs:70-79

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],
        }
    }
}
src/graphics/gpu_data.rs:8-27

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);
    }
}
src/vulkan/main_pass.rs:143-155,171-184

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,
    ],
)?;
src/graphics/assimp_loader.rs:76-85

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(),
    )
};
src/graphics/assimp_loader.rs:133-148

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)
}
src/graphics/meshmanager.rs:69-87

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);
src/graphics/assimp_loader.rs:263-274

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,
});
src/app/app.rs:397-406

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,
);
src/app/app.rs:449-458

Recap