git.lucas.co / cce-model
3D model viewer: STL and OBJ
git clone https://git.lucas.co/cce-model.git

commit8262bcbc16d4561bc3d2670dd681040287fef9f7
parent5f73775b4c
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 13:16
Draw the model lit and textured (milestone 5)

The model goes through cce-ui's new lit pipeline instead of light baked
into vertex colours: one lit mesh per material (a draw binds one texture),
with the file's normals or crease-aware ones, texture coordinates and
corner colours, shaded per fragment with a metallic-roughness specular
under the same studio rig (light::rig). Highlights now move with the
camera, metals read as metal, and base-colour textures show.

Textures are uploaded as mipmapped image ids when a model arrives, freed
when it is replaced, and uploaded again for a replacement renderer. The
lit draws sit after the background and grid and before the wire overlays
(LitDraw::before). The traced view samples each triangle's texture at its
centre, so a textured model traces in its colours too. A renderer without
the lit pipeline (Stage3D::lit() is None) is reported, not drawn.

Pins cce-ui 7f5ae8f (draw::lit) and cce-mesh-io 02e0b6f (materials,
textures, uvs, normals).

Checked in a scale-2 shadow: a textured globe, a gold metal sphere and a
rough red cube, orbited (highlights follow); the 5M-triangle STL uploads in
102 ms on the UI thread, 1.13 GB peak (lit vertices are 44 bytes, were 24).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md      |  31 +++++++-----
 Cargo.toml     |   4 +-
 src/light.rs   | 146 +++++++++++++++++++++++++++++++++++----------------------
 src/main.rs    | 117 ++++++++++++++++++++++++++++++++++++---------
 src/overlay.rs |  22 ++++-----
 src/trace.rs   |  35 ++++++++++----
 6 files changed, 241 insertions(+), 114 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ac40ef4..588256a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,10 +5,11 @@ A viewer for 3D model files. Read the workspace guide
 particular to this crate. The plan it is built to — formats, milestones,
 what each one must pass — is the design doc "cce-model: a general-purpose
 3D viewer" (claude.ai/code/artifact/1ebec744-8990-4884-ad92-73c72e0ea265).
-Milestones 1–4 are here: STL, OBJ, glTF/GLB and PLY read by the shared
+Milestones 1–5 are here: STL, OBJ, glTF/GLB and PLY read by the shared
 `cce-mesh-io` crate, a grid floor, wireframe and normals overlays, sizes in
-the file's units, ←/→ through the folder, drag-and-drop, and a path-traced
-view (`r`).
+the file's units, ←/→ through the folder, drag-and-drop, a path-traced
+view (`r`), and the model drawn through cce-ui's lit pipeline (per-fragment
+light, metallic-roughness, base-colour textures).
 
 ## Shape
 
@@ -19,13 +20,17 @@ view (`r`).
   generation drops a load the user has moved past, and a reader's panic is
   caught there and shown as an error. The model's baked vertices stay on
   the CPU after upload, because `init_3d` runs again for a replacement
-  renderer after a reconnect and every mesh must go back up (360 MB for a
-  5M-triangle model; slimming it is open).
+  renderer after a reconnect and every mesh must go back up (660 MB of lit
+  vertices for a 5M-triangle model; slimming it is open). Textures go up as
+  mipmapped image ids when a model arrives, are freed when it is replaced,
+  and are uploaded again for a replacement renderer (`seen_renderer`).
 - Reading is `cce_mesh_io::load` (see that crate's CLAUDE.md): a `Scene` of
   parts in the file's own coordinates plus its up axis. This app merges the
   parts, turns a Z-up scene upright, fits it to the unit sphere and bakes.
-- `src/light.rs` — the light bake: crease-aware corner normals (from
-  cce-mesh-io) against a fixed key/fill/ambient rig, into `Vertex3D`s.
+- `src/light.rs` — the lit meshes and their light: `lit_parts` turns the
+  mesh into one `LitVertex` list per material (a draw binds one texture),
+  with the file's normals or crease-aware ones; `rig()` is the studio
+  light (key, fill, sky/ground) the lit pipeline shades by.
 - `src/camera.rs` — orbit camera: yaw, pitch, distance about a pivot.
 - `src/overlay.rs` — the line meshes: grid floor (laid out in FILE units on
   1-2-5 steps through the file's origin, then fitted), edges, normals.
@@ -44,12 +49,12 @@ view (`r`).
 
 ## Things that are not obvious
 
-- **Smooth shading is baked.** cce-ui's `Vertex3D` is position + colour, and
-  its own shading is flat (screen-space derivatives). A `prelit` draw shows
-  the vertex colours as they are, so `light::bake` lights every corner from
-  its normal against a fixed studio rig (key, fill, sky/ground ambient). The
-  rig is fixed in world space, so the bake never changes as the camera moves.
-  Faces meeting past `CREASE_DEGREES` keep their own normals.
+- **The model is drawn lit, not baked** (since milestone 5): `LitDraw`s
+  staged after `stage_scene`, each `before` the first wire draw so the
+  background and grid go under and the overlays over. Until then the light
+  was baked into `Vertex3D` colours on the CPU, which could show neither a
+  texture nor a highlight that moves. `Stage3D::lit()` is `None` on a
+  renderer without the pipeline; the app then says it cannot draw.
 - **Every model is moved into the unit sphere** (`Mesh::fit_to_unit`)
   before it is baked, so the camera frames every file alike. (Until
   cce-ui 38bcad1 this was also a workaround: any vertex near z = 9.99 was
diff --git a/Cargo.toml b/Cargo.toml
index c9f572f..41d166a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,8 +4,8 @@ version = "0.1.0"
 edition = "2021"
 
 [dependencies]
-cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "e5fbe9abcb5a9a98849c8393d8edac311ef7b67f" }
-cce-mesh-io = { git = "https://github.com/lsgalante/cce-mesh-io.git", rev = "a95346cc26651f3efbd88b03dc238c6f03feec7d" }
+cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "7f5ae8f6e97aee36932fc80cb7c0f2be2d1a6bb9" }
+cce-mesh-io = { git = "https://github.com/lsgalante/cce-mesh-io.git", rev = "02e0b6fbcc30e92566522bb55c8e2ce18aed65ec" }
 glam = "0.29"
 log = "0.4"
 env_logger = "0.11"
diff --git a/src/light.rs b/src/light.rs
index 624e756..b3e4e85 100644
--- a/src/light.rs
+++ b/src/light.rs
@@ -1,57 +1,82 @@
-//! The light, baked into the vertices the raster pass draws.
+//! The light, and the lit meshes it falls on.
 //!
-//! cce-ui's scene vertex is a position and a colour, nothing more. Its own
-//! shading is flat, from screen-space derivatives, so a curved surface reads
-//! as facets. A `prelit` draw skips that and shows the vertex colours as
-//! they are, so smooth shading is a matter of lighting each corner here,
-//! from its normal, before upload. The lights are fixed in world space (a
-//! key, a fill and a sky/ground ambient), so the bake never changes as the
-//! camera orbits: the model turns under a studio rig, as on a turntable.
+//! The model is drawn through cce-ui's lit pipeline (`draw::lit`): a vertex
+//! with a normal, a texture coordinate and a colour, shaded per fragment
+//! with a metallic-roughness specular, so highlights move with the camera
+//! and textures show. This module turns a mesh into those vertices, one lit
+//! mesh per material (a draw binds one texture), and holds the studio rig
+//! they are lit by: a key, a fill and a sky/ground ambient, fixed in world
+//! space, with the traced view's sun on the same key.
 
-use cce_mesh_io::Mesh;
-use cce_ui::engine::Vertex3D;
+use cce_mesh_io::{Material, Mesh};
+use cce_ui::engine::{LitLight, LitVertex};
 use glam::Vec3;
 
 /// Faces meeting at a sharper angle than this keep separate normals, so a
-/// cube's edges stay edges and a sphere's facets blend.
+/// cube's edges stay edges and a sphere's facets blend. Used when the file
+/// brings no normals of its own.
 pub const CREASE_DEGREES: f32 = 40.0;
 
 /// Direction TOWARD the key light, world space (Y up): above and to the
 /// left of the home three-quarter view, so a model opens with a lit side
 /// and a shaded side rather than lit flat from the camera.
 pub const KEY: Vec3 = Vec3::new(-0.35, 0.75, 0.55);
-const KEY_STRENGTH: f32 = 0.95;
 /// Toward the fill: the home view's right, low, so the shaded side is not black.
 const FILL: Vec3 = Vec3::new(0.75, 0.1, -0.1);
-const FILL_STRENGTH: f32 = 0.25;
-/// Ambient from above and below: a face turned to the sky is lit a little
-/// more than one turned to the floor.
-const SKY: f32 = 0.20;
-const GROUND: f32 = 0.06;
 
-/// The triangle list the raster pass draws, each corner lit.
-pub fn bake(mesh: &Mesh) -> Vec<Vertex3D> {
-    let normals = mesh.corner_normals(CREASE_DEGREES);
-    let mut out = Vec::with_capacity(mesh.triangles.len() * 3);
-    for (f, t) in mesh.triangles.iter().enumerate() {
-        for (k, &v) in t.iter().enumerate() {
-            let light = shade(normals[f * 3 + k]);
-            out.push(Vertex3D {
+/// The rig the lit pipeline shades by.
+pub fn rig() -> LitLight {
+    LitLight {
+        key_toward: KEY.to_array(),
+        key_color: [0.95; 3],
+        fill_toward: FILL.to_array(),
+        fill_color: [0.25; 3],
+        sky: [0.20; 3],
+        ground: [0.06; 3],
+    }
+}
+
+/// A normal per triangle corner: the file's own when it has them, else
+/// derived with the crease angle.
+pub fn normals(mesh: &Mesh) -> Vec<Vec3> {
+    mesh.file_normals.clone().unwrap_or_else(|| mesh.corner_normals(CREASE_DEGREES))
+}
+
+/// The triangles of one material, as lit vertices.
+#[derive(Debug)]
+pub struct LitPart {
+    pub verts: Vec<LitVertex>,
+    pub material: Material,
+}
+
+/// The mesh as one lit part per material it uses. A mesh with corner colours
+/// carries them on the vertices and draws its parts in white (the reader has
+/// already folded the material's colour into them); one without draws white
+/// vertices in its material's colour.
+pub fn lit_parts(mesh: &Mesh) -> Vec<LitPart> {
+    let normals = normals(mesh);
+    let tinted = mesh.corner_colors.is_some();
+    let mut parts: Vec<Option<LitPart>> = (0..mesh.materials.len().max(1)).map(|_| None).collect();
+    for (t, tri) in mesh.triangles.iter().enumerate() {
+        let m = (mesh.tri_material[t] as usize).min(parts.len() - 1);
+        let part = parts[m].get_or_insert_with(|| {
+            let mut material = mesh.material(t);
+            if tinted {
+                material.color = [1.0; 3];
+            }
+            LitPart { verts: Vec::new(), material }
+        });
+        for (k, &v) in tri.iter().enumerate() {
+            let c = t * 3 + k;
+            part.verts.push(LitVertex {
                 position: mesh.positions[v as usize].to_array(),
-                color: mesh.corner_color(f, k).map(|c| (c * light).min(1.0)),
+                normal: normals[c].to_array(),
+                uv: mesh.corner_uvs.as_ref().map_or([0.0; 2], |uv| uv[c]),
+                color: if tinted { mesh.corner_color(t, k) } else { [1.0; 3] },
             });
         }
     }
-    out
-}
-
-/// How much light reaches a surface facing `n`, as a multiplier on its
-/// albedo.
-pub fn shade(n: Vec3) -> f32 {
-    let ambient = GROUND + (SKY - GROUND) * (0.5 + 0.5 * n.y);
-    let key = KEY_STRENGTH * n.dot(KEY.normalize()).max(0.0);
-    let fill = FILL_STRENGTH * n.dot(FILL.normalize()).max(0.0);
-    ambient + key + fill
+    parts.into_iter().flatten().collect()
 }
 
 #[cfg(test)]
@@ -59,41 +84,48 @@ mod tests {
     use super::*;
 
     /// A unit cube, as twelve triangles over eight shared points.
-    fn cube(corner_colors: Option<Vec<[f32; 3]>>) -> Mesh {
+    pub(crate) fn cube() -> Mesh {
         let p = |i: u32| Vec3::new((i & 1) as f32, ((i >> 1) & 1) as f32, ((i >> 2) & 1) as f32);
         let quads = [[1, 3, 7, 5], [4, 6, 2, 0], [2, 6, 7, 3], [4, 0, 1, 5], [4, 5, 7, 6], [1, 0, 2, 3]];
         Mesh {
             positions: (0..8).map(p).collect(),
             triangles: quads.iter().flat_map(|q| [[q[0], q[1], q[2]], [q[0], q[2], q[3]]]).collect(),
-            tri_color: vec![0; 12],
-            colors: vec![cce_mesh_io::CLAY],
-            corner_colors,
+            tri_material: vec![0; 12],
+            materials: vec![Material::default()],
+            ..Mesh::default()
         }
     }
 
     #[test]
-    fn a_cube_bakes_to_six_flat_shades() {
-        let verts = bake(&cube(None));
-        assert_eq!(verts.len(), 36);
-        // Its edges are past the crease, so each face is one shade from its
-        // own normal, and no two faces are lit alike.
-        let mut shades: Vec<u32> = verts.iter().map(|v| (v.color[0] * 1e4) as u32).collect();
-        shades.sort();
-        shades.dedup();
-        assert_eq!(shades.len(), 6, "{shades:?}");
+    fn a_cube_is_one_part_with_its_edges_kept() {
+        let parts = lit_parts(&cube());
+        assert_eq!(parts.len(), 1);
+        assert_eq!(parts[0].verts.len(), 36);
+        assert_eq!(parts[0].material.color, cce_mesh_io::CLAY);
+        // Past the crease: each face's corners carry that face's normal, so
+        // six distinct normals in all.
+        let mut normals: Vec<[i32; 3]> = parts[0].verts.iter().map(|v| v.normal.map(|c| c.round() as i32)).collect();
+        normals.sort();
+        normals.dedup();
+        assert_eq!(normals.len(), 6);
     }
 
     #[test]
-    fn corner_colours_are_lit_as_they_are() {
-        let verts = bake(&cube(Some(vec![[0.0, 0.5, 0.0]; 36])));
-        assert!(verts.iter().all(|v| v.color[0] == 0.0 && v.color[1] > 0.0 && v.color[2] == 0.0));
+    fn each_material_is_its_own_part_and_corner_colours_ride_the_vertices() {
+        let mut m = cube();
+        m.materials.push(Material::colour([0.0, 0.0, 1.0]));
+        m.tri_material[0] = 1;
+        assert_eq!(lit_parts(&m).len(), 2);
+        m.corner_colors = Some(vec![[0.0, 0.5, 0.0]; 36]);
+        let parts = lit_parts(&m);
+        assert!(parts.iter().all(|p| p.material.color == [1.0; 3]), "the colour is on the vertices");
+        assert_eq!(parts[0].verts[0].color, [0.0, 0.5, 0.0]);
     }
 
     #[test]
-    fn no_face_is_left_black() {
-        for n in [Vec3::X, -Vec3::X, Vec3::Y, -Vec3::Y, Vec3::Z, -Vec3::Z] {
-            assert!(shade(n) >= GROUND, "{n} is darker than the ambient floor");
-        }
-        assert!(shade(KEY.normalize()) > 1.0 - 0.01, "the key-lit face should be near full");
+    fn the_files_normals_win() {
+        let mut m = cube();
+        m.file_normals = Some(vec![Vec3::Y; 36]);
+        assert!(lit_parts(&m)[0].verts.iter().all(|v| v.normal == [0.0, 1.0, 0.0]));
     }
 }
diff --git a/src/main.rs b/src/main.rs
index aeb2101..875c54a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,10 +2,11 @@
 //!
 //! Opens STL, OBJ, glTF/GLB and PLY (read by cce-mesh-io), frames the model
 //! in a three-quarter view over a grid floor and lets you turn it. Files are
-//! read and lit on a worker thread (`cce_mesh_io::load` + `light::bake`),
-//! then uploaded once and drawn through cce-ui's scene pass as one prelit
-//! mesh under a screen-space background gradient. The whole window is the
-//! scene. The wireframe and normals overlays are built on a worker the
+//! read and split into lit meshes on a worker thread (`cce_mesh_io::load`
+//! + `light::lit_parts`, one per material), then uploaded once and drawn
+//! through cce-ui's lit pipeline — per-fragment light, metallic-roughness
+//! specular, base-colour textures — between a screen-space background
+//! gradient and the grid. The whole window is the scene. The wireframe and normals overlays are built on a worker the
 //! first time either is asked for, so a big model does not pay for them
 //! unless they are wanted.
 //!
@@ -31,10 +32,10 @@ use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::time::{Duration, Instant};
 
-use cce_mesh_io::{Mesh, Unit, UpAxis};
+use cce_mesh_io::{Mesh, Texture, Unit, UpAxis};
 use cce_ui::engine::{
-    AppSender, Application, LogicalPosition, LogicalSize, MeshId, PreparedRtScene, RtCamera, SceneDraw, Stage3D,
-    Vertex3D, WindowSettings,
+    AppSender, Application, LitDraw, LitMaterial, LitMeshId, LogicalPosition, LogicalSize, MeshId, PreparedRtScene,
+    RtCamera, SceneDraw, Stage3D, Vertex3D, WindowSettings,
 };
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{DisplayList, PaintCtx};
@@ -88,8 +89,9 @@ struct Model {
     size: Vec3,
     unit: Unit,
     /// Kept after upload: a replacement renderer (a reconnect) starts with
-    /// no meshes, and these are what go back up.
-    verts: Vec<Vertex3D>,
+    /// no meshes and no images, and these are what go back up.
+    surfaces: Vec<light::LitPart>,
+    textures: Vec<Texture>,
     grid: Vec<Vertex3D>,
     grid_step: f32,
     /// The grid's height in the fitted space: where the traced ground is.
@@ -98,7 +100,8 @@ struct Model {
 
 impl Model {
     fn read(path: &Path) -> Result<Model, String> {
-        let scene = cce_mesh_io::load(path)?;
+        let mut scene = cce_mesh_io::load(path)?;
+        let textures = std::mem::take(&mut scene.textures);
         let mut mesh = scene.merged();
         // STL is Z-up by convention; the view is Y-up. Turned here, not by
         // the reader, which reports the file as it is.
@@ -115,7 +118,8 @@ impl Model {
             parts: scene.parts.len(),
             size: hi - lo,
             unit: scene.unit,
-            verts: light::bake(&mesh),
+            surfaces: light::lit_parts(&mesh),
+            textures,
             mesh,
             grid: grid.lines,
             grid_step: grid.step,
@@ -135,7 +139,10 @@ struct Overlays {
 /// in place after, so stepping through a folder does not leak meshes.
 struct Gpu {
     background: MeshId,
-    model: Option<MeshId>,
+    /// One lit mesh per material; slots are reused by the next model, and
+    /// only the first `model_parts` belong to the current one.
+    model: Vec<LitMeshId>,
+    model_parts: usize,
     grid: Option<MeshId>,
     edges: Option<MeshId>,
     normals: Option<MeshId>,
@@ -173,6 +180,12 @@ struct ModelApp {
     loading: Option<PathBuf>,
     model: Option<Arc<Model>>,
     error: Option<String>,
+    /// The model's textures as this renderer's image ids, by the scene's
+    /// texture index (`None` for one that is not uploaded).
+    texture_ids: Vec<Option<u32>>,
+    /// Whether a renderer has been handed over yet: a later one is a
+    /// replacement, and every image id died with the old one.
+    seen_renderer: bool,
     /// The folder's models, for ←/→, and where the open one is among them.
     files: Vec<PathBuf>,
     file_at: usize,
@@ -287,7 +300,7 @@ impl ModelApp {
         std::thread::spawn(move || {
             let started = std::time::Instant::now();
             let edges = Arc::new(overlay::edges(&model.mesh));
-            let normals = Arc::new(overlay::normals(&model.mesh, light::CREASE_DEGREES));
+            let normals = Arc::new(overlay::normals(&model.mesh, &light::normals(&model.mesh)));
             log::info!(
                 "[model] {} edges and {} normals in {:.0} ms",
                 edges.len() / 2,
@@ -298,6 +311,21 @@ impl ModelApp {
         });
     }
 
+    /// Upload the model's textures (mipmapped: they are seen small as well
+    /// as large), freeing the ids of the ones before.
+    fn upload_textures(&mut self) {
+        for id in self.texture_ids.drain(..).flatten() {
+            cce_ui::draw::free_image(id);
+        }
+        if let Some(m) = &self.model {
+            self.texture_ids = m
+                .textures
+                .iter()
+                .map(|t| Some(cce_ui::draw::upload_rgba_mipmapped(t.rgba.clone(), t.width, t.height)))
+                .collect();
+        }
+    }
+
     /// Build the tracer's scene on a worker, if the traced view is wanted
     /// and it is not built or being built.
     fn want_trace(&mut self) {
@@ -309,7 +337,7 @@ impl ModelApp {
         let (generation, sender, with_bvh) = (self.generation, self.sender.clone(), self.rt_needs_bvh);
         std::thread::spawn(move || {
             let started = Instant::now();
-            let (triangles, materials) = trace::scene(&model.mesh, model.floor_y);
+            let (triangles, materials) = trace::scene(&model.mesh, &model.textures, model.floor_y);
             let n_materials = materials.len();
             let scene = PreparedRtScene::new(triangles, &materials, None, with_bvh);
             log::info!(
@@ -444,6 +472,8 @@ impl Application for ModelApp {
             loading: None,
             model: None,
             error: None,
+            texture_ids: Vec::new(),
+            seen_renderer: false,
             files: Vec::new(),
             file_at: 0,
             overlays: None,
@@ -498,8 +528,15 @@ impl Application for ModelApp {
                 self.loading = None;
                 match result {
                     Ok(model) => {
-                        log::info!("[model] {}: {} triangles", path.display(), model.mesh.triangles.len());
+                        log::info!(
+                            "[model] {}: {} triangles, {} materials, {} textures",
+                            path.display(),
+                            model.mesh.triangles.len(),
+                            model.surfaces.len(),
+                            model.textures.len()
+                        );
                         self.model = Some(model);
+                        self.upload_textures();
                         self.overlays = None;
                         self.building_overlays = false;
                         self.pending = Pending { model: true, grid: true, edges: false, normals: false };
@@ -579,7 +616,15 @@ impl Application for ModelApp {
         ]);
         stage.set_scene_light(light::KEY.normalize().to_array());
         self.rt_needs_bvh = stage.rt_needs_bvh();
-        self.gpu = Some(Gpu { background, model: None, grid: None, edges: None, normals: None });
+        match stage.lit() {
+            Some(lit) => lit.set_lit_light(light::rig()),
+            None => self.error = Some("this renderer has no lit pipeline; nothing can be drawn".into()),
+        }
+        // A replacement renderer: the images went with the old one.
+        if std::mem::replace(&mut self.seen_renderer, true) {
+            self.upload_textures();
+        }
+        self.gpu = Some(Gpu { background, model: Vec::new(), model_parts: 0, grid: None, edges: None, normals: None });
         let has = self.model.is_some();
         let built = self.overlays.is_some();
         self.pending = Pending { model: has, grid: has, edges: built, normals: built };
@@ -594,12 +639,19 @@ impl Application for ModelApp {
         let Some(gpu) = self.gpu.as_mut() else { return false };
         let pending = std::mem::take(&mut self.pending);
         if let Some(m) = &self.model {
-            if pending.model {
+            if let (true, Some(lit)) = (pending.model, stage.lit()) {
                 // On the UI thread, unavoidably: the stage is only lent here.
                 // Logged, because it is the one part of a load the window waits on.
                 let started = std::time::Instant::now();
-                upload(stage, &mut gpu.model, &m.verts);
-                log::info!("[model] uploaded {} vertices in {:.0} ms", m.verts.len(), started.elapsed().as_secs_f64() * 1e3);
+                for (i, part) in m.surfaces.iter().enumerate() {
+                    match gpu.model.get(i) {
+                        Some(&id) => lit.update_lit_mesh(id, &part.verts),
+                        None => gpu.model.push(lit.create_lit_mesh(&part.verts)),
+                    }
+                }
+                gpu.model_parts = m.surfaces.len();
+                let count: usize = m.surfaces.iter().map(|p| p.verts.len()).sum();
+                log::info!("[model] uploaded {count} vertices in {:.0} ms", started.elapsed().as_secs_f64() * 1e3);
             }
             if pending.grid {
                 upload(stage, &mut gpu.grid, &m.grid);
@@ -669,14 +721,30 @@ impl Application for ModelApp {
         let line_width = scale as f32;
         let lines = |mesh| SceneDraw { wireframe: true, line_width, ..draw(mesh, mvp) };
         let mut draws = vec![SceneDraw { screen_space: true, ..draw(gpu.background, mvp) }];
-        if self.model.is_some() {
+        let mut lit_draws = Vec::new();
+        if let Some(m) = &self.model {
             let wire = self.show_wire && self.overlays.is_some();
             if let (true, Some(id)) = (self.show_grid, gpu.grid) {
                 draws.push(lines(id));
             }
-            if let Some(id) = gpu.model {
-                let base = if wire { line_width } else { 0.0 };
-                draws.push(SceneDraw { prelit: true, wire_base_width: base, ..draw(id, mvp) });
+            // The model, after the background and grid and before the wires.
+            let eye = self.camera.eye().to_array();
+            for (part, &id) in m.surfaces.iter().zip(&gpu.model).take(gpu.model_parts) {
+                let mat = &part.material;
+                lit_draws.push(LitDraw {
+                    mesh: id,
+                    mvp,
+                    eye,
+                    material: LitMaterial {
+                        base_color: mat.color,
+                        texture: mat.texture.and_then(|t| self.texture_ids.get(t).copied().flatten()),
+                        metallic: mat.metallic,
+                        roughness: mat.roughness,
+                    },
+                    opacity: 1.0,
+                    wire_base_width: if wire { line_width } else { 0.0 },
+                    before: draws.len() as u32,
+                });
             }
             if let (true, Some(id)) = (wire, gpu.edges) {
                 draws.push(SceneDraw { opacity: WIRE_OPACITY, ..lines(id) });
@@ -686,6 +754,9 @@ impl Application for ModelApp {
             }
         }
         stage.stage_scene((0, 0, pw, ph), draws);
+        if let Some(lit) = stage.lit() {
+            lit.stage_lit(lit_draws);
+        }
         tracing
     }
 
diff --git a/src/overlay.rs b/src/overlay.rs
index 3b6921a..28a506d 100644
--- a/src/overlay.rs
+++ b/src/overlay.rs
@@ -111,16 +111,15 @@ pub fn edges(mesh: &Mesh) -> Vec<Vertex3D> {
     out
 }
 
-/// A short line out of each point along each normal it is lit with — one
-/// per point on a smooth surface, one per face meeting at a crease — and
-/// every n-th of them past [`MAX_NORMALS`].
+/// A short line out of each point along each normal it is lit with
+/// (`corner`, three per triangle) — one per point on a smooth surface, one
+/// per face meeting at a crease — and every n-th of them past [`MAX_NORMALS`].
 ///
 /// Each is about as long as the gap between the normals drawn (the square
 /// root of the surface's area over their number), up to [`NORMAL_LENGTH`]:
 /// at a fixed length, a dense model's normals overlap into a fur that hides
 /// the surface they describe.
-pub fn normals(mesh: &Mesh, crease_degrees: f32) -> Vec<Vertex3D> {
-    let corner = mesh.corner_normals(crease_degrees);
+pub fn normals(mesh: &Mesh, corner: &[Vec3]) -> Vec<Vertex3D> {
     let mut seen: HashSet<(u32, [i16; 3])> = HashSet::new();
     let mut picked: Vec<(Vec3, Vec3)> = Vec::new();
     for (t, tri) in mesh.triangles.iter().enumerate() {
@@ -165,9 +164,9 @@ mod tests {
         Mesh {
             positions: (0..8).map(p).collect(),
             triangles: quads.iter().flat_map(|q| [[q[0], q[1], q[2]], [q[0], q[2], q[3]]]).collect(),
-            tri_color: vec![0; 12],
-            colors: vec![cce_mesh_io::CLAY],
-            corner_colors: None,
+            tri_material: vec![0; 12],
+            materials: vec![cce_mesh_io::Material::default()],
+            ..Mesh::default()
         }
     }
 
@@ -180,7 +179,8 @@ mod tests {
     #[test]
     fn a_cube_has_three_normals_a_corner() {
         // Every corner meets three faces at 90°, past the crease.
-        assert_eq!(normals(&cube(), 40.0).len(), 8 * 3 * 2);
+        let c = cube();
+        assert_eq!(normals(&c, &c.corner_normals(40.0)).len(), 8 * 3 * 2);
     }
 
     #[test]
@@ -221,8 +221,8 @@ mod tests {
                 m.triangles.push([a + 1, a + n as u32 + 1, a + n as u32]);
             }
         }
-        m.tri_color = vec![0; m.triangles.len()];
-        let lines = normals(&m, 40.0).len() / 2;
+        m.tri_material = vec![0; m.triangles.len()];
+        let lines = normals(&m, &m.corner_normals(40.0)).len() / 2;
         assert!(lines <= MAX_NORMALS && lines > MAX_NORMALS / 3, "{lines}");
     }
 }
diff --git a/src/trace.rs b/src/trace.rs
index 0c52543..7a84dca 100644
--- a/src/trace.rs
+++ b/src/trace.rs
@@ -4,7 +4,10 @@
 //! emission, linear) in the space the camera's inverse mvp unprojects into —
 //! the fitted space the raster pass draws in, so one camera serves both.
 //! The model goes in with a colour per triangle (its corner colours
-//! averaged, or its palette colour), deduplicated into materials, over a
+//! averaged, or its material's colour, times its texture sampled at the
+//! triangle's centre: the tracer has no textures, and at the density a
+//! texture is worth having, one texel a triangle reads well), deduplicated
+//! into materials, over a
 //! wide ground plane at the grid's height, so the traced model stands on
 //! something and casts a shadow; the grid's lines, which are not surfaces,
 //! have no place in it.
@@ -12,7 +15,7 @@
 use std::collections::HashMap;
 use std::path::Path;
 
-use cce_mesh_io::Mesh;
+use cce_mesh_io::{srgb_to_linear, Mesh, Texture};
 use cce_ui::engine::{RtEnvironment, RtMaterial, RtTriangle};
 
 /// Samples to refine to: enough for the tracer's denoiser to settle, and
@@ -50,7 +53,7 @@ pub fn environment(key: [f32; 3]) -> RtEnvironment {
 }
 
 /// The model's triangles and a ground plane under them at `floor_y`.
-pub fn scene(mesh: &Mesh, floor_y: f32) -> (Vec<RtTriangle>, Vec<RtMaterial>) {
+pub fn scene(mesh: &Mesh, textures: &[Texture], floor_y: f32) -> (Vec<RtTriangle>, Vec<RtMaterial>) {
     let mut materials: Vec<RtMaterial> = Vec::new();
     let mut slot: HashMap<[u16; 3], u32> = HashMap::new();
     let mut material_for = |c: [f32; 3]| -> u32 {
@@ -62,12 +65,17 @@ pub fn scene(mesh: &Mesh, floor_y: f32) -> (Vec<RtTriangle>, Vec<RtMaterial>) {
     };
     let mut triangles = Vec::with_capacity(mesh.triangles.len() + 2);
     for (t, tri) in mesh.triangles.iter().enumerate() {
-        let colour = if mesh.corner_colors.is_some() {
+        let mut colour = if mesh.corner_colors.is_some() {
             let [a, b, c] = [0, 1, 2].map(|k| mesh.corner_color(t, k));
             [0, 1, 2].map(|i| (a[i] + b[i] + c[i]) / 3.0)
         } else {
             mesh.corner_color(t, 0)
         };
+        if let (Some(tex), Some(uvs)) = (mesh.material(t).texture.and_then(|i| textures.get(i)), &mesh.corner_uvs) {
+            let [a, b, c] = [uvs[t * 3], uvs[t * 3 + 1], uvs[t * 3 + 2]];
+            let texel = tex.sample([(a[0] + b[0] + c[0]) / 3.0, (a[1] + b[1] + c[1]) / 3.0]);
+            colour = [0, 1, 2].map(|i| colour[i] * srgb_to_linear(texel[i]));
+        }
         let [p0, p1, p2] = tri.map(|i| mesh.positions[i as usize].to_array());
         triangles.push(RtTriangle { p0, p1, p2, material: material_for(colour) });
     }
@@ -107,15 +115,16 @@ mod tests {
         Mesh {
             positions: vec![Vec3::ZERO, Vec3::X, Vec3::new(1.0, 1.0, 0.0), Vec3::Y],
             triangles: vec![[0, 1, 2], [0, 2, 3]],
-            tri_color: vec![0, 0],
-            colors: vec![[0.5, 0.25, 0.0]],
+            tri_material: vec![0, 0],
+            materials: vec![cce_mesh_io::Material::colour([0.5, 0.25, 0.0])],
             corner_colors,
+            ..Mesh::default()
         }
     }
 
     #[test]
     fn the_model_stands_on_a_ground_plane() {
-        let (tris, mats) = scene(&quad(None), -1.0);
+        let (tris, mats) = scene(&quad(None), &[], -1.0);
         assert_eq!(tris.len(), 2 + 2);
         assert_eq!(mats.len(), 2, "one model colour and the ground");
         let ground = &tris[2];
@@ -129,11 +138,21 @@ mod tests {
     #[test]
     fn corner_colours_are_averaged_and_shared() {
         let red = [1.0, 0.0, 0.0];
-        let (tris, mats) = scene(&quad(Some(vec![red; 6])), 0.0);
+        let (tris, mats) = scene(&quad(Some(vec![red; 6])), &[], 0.0);
         assert_eq!(tris[0].material, tris[1].material, "two red triangles share one material");
         assert_eq!(mats[tris[0].material as usize].albedo, red);
     }
 
+    #[test]
+    fn a_textured_triangle_takes_its_texel() {
+        let mut m = quad(None);
+        m.materials[0] = cce_mesh_io::Material { color: [1.0; 3], texture: Some(0), ..Default::default() };
+        m.corner_uvs = Some(vec![[0.1, 0.5]; 6]);
+        let tex = Texture { width: 2, height: 1, rgba: vec![255, 0, 0, 255, 0, 0, 255, 255] };
+        let (tris, mats) = scene(&m, &[tex], 0.0);
+        assert_eq!(mats[tris[0].material as usize].albedo, [1.0, 0.0, 0.0], "the left texel, red");
+    }
+
     #[test]
     fn mains_online_is_not_battery() {
         let root = std::env::temp_dir().join(format!("cce-model-power-{}", std::process::id()));