3D model viewer: STL and OBJ
git clone https://git.lucas.co/cce-model.git
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()));