Skip to content

Model

A Model pairs a Mesh with a Material and adds a per-Model 4×4 transform. It’s the unit you actually add to a Pass when rendering 3D content; the Material handles shading, the Mesh handles geometry, the Model handles “where in the world”.

Each Model::new takes ownership of the Material it’s given. Cloning the Material before passing it in is cheap: Material::clone is an Arc-clone (handle share), not a deep duplicate. The per-Model transform is not a Material uniform: it’s written as four vec4<f32> columns into the Mesh’s per-instance attribute stream at locations 3..6. Many Models can share one Material’s Shader without colliding because each Model writes its transform to its own Mesh’s instance buffer.

For the fan-out case (one Material, many positioned instances on unique geometries), the pattern is:

template = Material::pbr().base_color(...)
for each item:
model = Model::new(item.mesh, template.clone())
model.set_transform(item.matrix)
pass.add(&model)

Pipeline cached once by shader hash; one bind-group setup per pass; N draws (one per unique Mesh). For batched instancing (one shared Mesh, many transforms in one draw) drop down to Mesh::add_instance(...) directly with a Material::custom(shader_that_reads_instance_attrs). The Model API is for one logical thing per draw, not for managing your own per-instance buffers.

Caveat: the Mesh’s instance buffer is Arc-shared via Mesh::clone. Two Models that share a Mesh handle (mesh.clone()) collide on the same instance buffer; the most recent transform-mutating call wins. Give each Model its own Mesh.


Borrow the Material handle the Model owns. The Material wraps a Shader; reach through model.material().shader() if you need direct uniform manipulation (camera state, custom uniforms not exposed by Material’s setters).

Note: Material::clone is a shallow Arc-share, so the handle stored on this Model points at the same underlying shader state as whatever Material was passed to Model::new. Mutations made through model.material() are visible to every other Model that received the same source handle (and vice versa). To detach a Model’s appearance, construct a fresh Material::pbr() or Material::custom() rather than cloning.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::new([0.0, 0.0, 0.0])
.set(Vertex::NORMAL, [0.0, 1.0, 0.0])
.set(Vertex::UV0, [0.0, 0.0]),
);
let model = Model::new(mesh, Material::pbr());
model.material().shader().set("camera.position", [0.0, 0.0, 5.0])?;
2 collapsed lines
Ok(())
}

Borrow the Mesh the Model owns. Useful for adding more vertices or instances to the geometry after construction without losing access through the Model handle.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let model = Model::new(mesh, Material::pbr());
model.mesh().add_vertex(
Vertex::new([-0.5, -0.5, 0.0])
.set(Vertex::NORMAL, [0.0, 0.0, 1.0])
.set(Vertex::UV0, [0.0, 0.0]),
);
2 collapsed lines
Ok(())
}

Construct a Model from a Mesh and a Material. Both arguments are taken by value; the Model owns them. The transform starts at the 4×4 identity matrix; Model::sync_transform writes it as a single instance into the Mesh’s per-instance attribute stream (four vec4<f32> columns at locations 3..6).

If you want several Models that share a look, clone the Material before each Model::new. Material::clone is a cheap Arc-clone (handle share, not a deep duplicate), so the Models share one pipeline + one bind-group setup. Per-Model transforms don’t collide because each Model owns its own Mesh, and the transform lives on the Mesh’s instance buffer, not the Material’s Shader.

Material::pbr requires the Mesh’s first vertex to declare position (vec3), normal (vec3), and uv0 (vec2) in that exact insertion order, so the locations align with the PBR shader’s vertex inputs. Custom shaders via Material::custom(...) can use any layout.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::new([0.0, 0.0, 0.0])
.set(Vertex::NORMAL, [0.0, 1.0, 0.0])
.set(Vertex::UV0, [0.0, 0.0]),
);
let model = Model::new(mesh, Material::pbr());
3 collapsed lines
let _model = model;
Ok(())
}

Rotate the Model around axis (in local space) by radians. Post-multiplies the current transform by an axis-angle rotation, so a rotated-then-translated Model spins in place around its own origin rather than orbiting the world origin. The axis is normalised internally; a zero-length axis is rejected with a debug log and no change.

For pure world-space rotations (e.g. tumbling around a fixed pivot), compose the matrix yourself and call set_transform.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Renderer, Vertex};
let renderer = Renderer::new();
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::new([0.0, 0.0, 0.0])
.set(Vertex::NORMAL, [0.0, 1.0, 0.0])
.set(Vertex::UV0, [0.0, 0.0]),
);
let model = Model::new(mesh, Material::pbr());
model.rotate([0.0, 1.0, 0.0], 1.571); // 90° around Y
3 collapsed lines
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Scale the Model by per-axis factor (in local space). Post-multiplies the current transform by a scale matrix, so the Model grows or shrinks around its own origin without sliding through space.

Use uniform scales ([s, s, s]) when possible. Non-uniform scale breaks the cheap normal-transform path the default PBR shader uses, so a stretched Model will shade slightly off. For a correct non-uniform-scale normal, use Material::custom with a shader that ships the explicit cofactor matrix.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Renderer, Vertex};
let renderer = Renderer::new();
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::new([0.0, 0.0, 0.0])
.set(Vertex::NORMAL, [0.0, 1.0, 0.0])
.set(Vertex::UV0, [0.0, 0.0]),
);
let model = Model::new(mesh, Material::pbr());
model.scale([2.0, 2.0, 2.0]);
3 collapsed lines
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Replace the Model’s 4×4 transform wholesale, in column-major order. Useful when you already have a matrix from a math library or a glTF node and want to apply it directly without composing through translate / rotate / scale.

Writes the four columns into the Mesh’s per-instance attribute stream immediately (locations 3..6). To read the current transform, see Model::transform.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Renderer, Vertex};
let renderer = Renderer::new();
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::new([0.0, 0.0, 0.0])
.set(Vertex::NORMAL, [0.0, 1.0, 0.0])
.set(Vertex::UV0, [0.0, 0.0]),
);
let model = Model::new(mesh, Material::pbr());
model.set_transform([
[2.0, 0.0, 0.0, 0.0],
[0.0, 2.0, 0.0, 0.0],
[0.0, 0.0, 2.0, 0.0],
[3.0, 0.0, 0.0, 1.0],
]);
3 collapsed lines
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Toggle the Model in or out of the next frame. Hidden Models are skipped by the renderer in both the opaque-batched and blend-sorted draw paths; no Pass rebuild needed.

The flag is Arc-shared with ModelEntry (the renderer’s queue), so toggles take effect on the very next Renderer::render call. Cheap: one bool flip + one entry check during the draw-queue build.

Typical uses:

  • LOD switches that hide whole sets of detail Models at a distance.
  • Level / chapter transitions that swap which subset of a Scene renders without rebuilding the Scene.
  • Temporary hides for editor gizmos, debug helpers, or selection highlights.
1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(Vertex::pbr([0.0, 0.5, 0.0]));
let blob = Model::new(mesh, Material::pbr());
// Wide zoom level — skip the detail blobs.
blob.set_visible(false);
// Zoom back in — turn them on again.
blob.set_visible(true);
2 collapsed lines
Ok(())
}

Read the Model’s current 4×4 transform in column-major order, matching WGSL’s mat4x4<f32> layout and glam’s to_cols_array_2d. The transform starts at the identity matrix and is modified by set_transform, translate, rotate, and scale.

For the setter, see Model::set_transform. Rust doesn’t allow getter/setter overloads on the same name, so the read side is transform and the write side is set_transform.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Renderer, Vertex};
let renderer = Renderer::new();
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::new([0.0, 0.0, 0.0])
.set(Vertex::NORMAL, [0.0, 1.0, 0.0])
.set(Vertex::UV0, [0.0, 0.0]),
);
let model = Model::new(mesh, Material::pbr());
let identity = model.transform();
6 collapsed lines
assert_eq!(identity[0], [1.0, 0.0, 0.0, 0.0]);
assert_eq!(identity[3], [0.0, 0.0, 0.0, 1.0]);
let _ = identity;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Move the Model by offset in world coordinates. Pre-multiplies the current transform by a translation matrix, so the result is independent of the Model’s current rotation. translate([1, 0, 0]) always moves one unit along the world X axis.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Renderer, Vertex};
let renderer = Renderer::new();
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::new([0.0, 0.0, 0.0])
.set(Vertex::NORMAL, [0.0, 1.0, 0.0])
.set(Vertex::UV0, [0.0, 0.0]),
);
let model = Model::new(mesh, Material::pbr());
model.translate([5.0, 0.0, -2.0]);
6 collapsed lines
let m = model.transform();
// The translation lives in the fourth column when column-major.
assert_eq!(m[3], [5.0, 0.0, -2.0, 1.0]);
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Read the current visibility flag. Returns true for newly-built Models; toggle with Model::set_visible.

The renderer reads this every frame. Hidden Models are skipped in both the opaque-batched and blend-sorted draw paths without requiring a Pass rebuild.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(Vertex::pbr([0.0, 0.5, 0.0]));
let model = Model::new(mesh, Material::pbr());
// Models start visible; toggle with `set_visible`.
let visible_now = model.visible();
3 collapsed lines
assert!(visible_now);
Ok(())
}