Skip to content

3D scenes on top of shaders

FragmentColor started by deleting a scene graph. Version 0.12.0 adds 3D scenes, and the release notes list what shipped. We built the new types from Shader, Mesh, and Pass. The renderer draws a scene the way it draws everything else.

Start with the material. Here is the whole struct:

pub struct Material {
pub(crate) shader: Shader,
}

Material::pbr() fills that field with the array form of Shader::new, composing the library’s mesh/transform and material/pbr snippets with the main PBR source. A setter such as roughness writes a material.* uniform by name, exactly as shader.set does for WGSL you wrote by hand. material.shader() hands the shader back, and Material::custom turns any shader into a material.

Each model needs its own 4×4 matrix. Finding it a home took three designs.

A wishlist item asked for Mesh::set_transform. That would have put shader state on the mesh, and uniforms belong to the shader.

The first implementation gave every model a private copy of its material’s shader, with the model’s matrix in a mesh.model uniform. That defeated batching. One pipeline and one bind group setup normally serve all the meshes attached to a shader, and copies turned that into one setup per model.

What shipped treats the matrix as vertex data, one instance per model. pass.add(&model) keeps a live reference to it, and at draw time the renderer packs the models that share a mesh and a material into one instanced draw. The Model page has the attribute layout.

translate and rotate edit the matrix on the CPU. The GPU sees the new value on the next frame.

Anything Renderable can go to Renderer::render, and the trait has one required method:

pub trait Renderable {
fn passes(&self) -> Arc<[Arc<PassObject>]>;
fn roots(&self) -> Arc<[Arc<PassObject>]> { self.passes() }
}

A Shader returns its own internal pass. A Scene returns its list of passes, including the default one that add fills.

Cameras and lights also enter through that pass. They started closer to the shader: camera.bind(&shader) came first and wrote into one. Then material.add(&camera) let the material absorb the camera, in line with the add_* methods the core already had. The call ended up on Pass::add, since a camera describes the viewpoint for every model in a pass and belongs inside none of them.

Their job there is small: a camera writes two uniforms into each shader on the pass and rewrites them when it moves, and a light claims a slot in the lights array of those shaders.

Neither type draws anything.

The trait behind add is SceneObject, renamed from Component to stay clear of the ECS term. In Rust, any type with an attach(pass) implementation enters a scene through the same add. Even the default camera and light are ordinary Camera and Light objects, attached to the scene’s default pass on the first render.

A model can also go into a Pass you build yourself with pass.add(&model), and that pass renders in a list beside any other. A scene reaches the GPU through the draw loop a bare shader uses.

The renderer did learn a few things. Besides the instance buffers, it sorts blended models back to front over all the materials in a pass at once. It also binds 1×1 defaults to any of the five PBR texture slots a material leaves empty. An early draft of Material::pbr() was async and took a &Renderer to create those textures. It never shipped, because the renderer consumes everything else in the API and shouldn’t appear as a constructor argument.

RenderPipelineKey gained alpha mode and face culling. The draw loop walks shaders and never sees a material, which is why those two flags live on the shader. A shader rendered without a material gets their defaults, and the upgrade notes say what that changed.

The reference pages for Scene, Model, and Material list the full API.

Rafael