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

commit4db30242d0e77d1082366cdbb3feb772341740b7
parentd4c66cb472
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 13:11
Traced view: build the BVH on the worker, upload only in stage_3d

want_trace now builds a cce-ui PreparedRtScene (packing + BVH) beside the
scene's triangles, and stage_3d hands it to set_rt_scene_prepared. The
BVH is built only when the stage's rt_needs_bvh() (asked in init_3d) says
the tracer traverses one; the ray-query tier builds its own on the GPU.

torus5m.stl (5,001,600 triangles), scale-2 shadow, Intel iGPU: the UI
thread's share went from 2141 ms to 61 ms (worker 2032 ms). RTX 4080,
ray-query tier: worker 321 ms, upload with the BLAS build 146 ms.

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

 CLAUDE.md   | 14 +++++++++++---
 src/main.rs | 43 +++++++++++++++++++++++--------------------
 2 files changed, 34 insertions(+), 23 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 879673d..ac40ef4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -73,9 +73,17 @@ view (`r`).
   with the traced image left in the backdrop. One frame past each edge is
   deliberate: the HUD paints BEFORE `stage_3d`, so the cap and the
   "preparing" notice each need a frame of their own to be seen.
-- **`set_rt_scene` builds the BVH on the UI thread** (the stage is only
-  lent there): 2.3 s for 5M triangles on the shadow's iGPU. The window says
-  "traced: preparing…" first; moving it off-thread needs cce-ui.
+- **The tracer's BVH is built on the worker**, with the rest of the scene
+  (`PreparedRtScene::new` in `want_trace`, cce-ui ≥ e5fbe9a); `stage_3d`
+  only uploads it (`set_rt_scene_prepared`). Whether to build one is the
+  stage's `rt_needs_bvh()`, asked in `init_3d`: the hardware ray-query tier
+  builds its own structure on the GPU and skips it. Measured 2026-10-07,
+  torus5m.stl (5,001,600 triangles), scale-2 shadow: Intel iGPU (compute
+  tier) worker 2032 ms, UI thread 61 ms (it was 2141 ms on the UI thread);
+  RTX 4080 (ray-query, `CCE_VK_DEVICE=discrete` plus the live `DISPLAY=:0`
+  in the shadow) worker 321 ms, upload with the BLAS build 146 ms. The
+  prepared scene stays in memory for a reconnect's re-upload (~300 MB at
+  5M with its BVH, on top of the baked vertices).
 - **The tracer has no smooth normals**: a coarse sphere shows its facets
   when traced. Milestone 5's territory (cce-ui's materials and vertices).
 - No root plate, on purpose (`style-audit: opt-out` in `display_list`): the
diff --git a/src/main.rs b/src/main.rs
index ff3ccaa..aeb2101 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -33,7 +33,7 @@ use std::time::{Duration, Instant};
 
 use cce_mesh_io::{Mesh, Unit, UpAxis};
 use cce_ui::engine::{
-    AppSender, Application, LogicalPosition, LogicalSize, MeshId, RtCamera, RtMaterial, RtTriangle, SceneDraw, Stage3D,
+    AppSender, Application, LogicalPosition, LogicalSize, MeshId, PreparedRtScene, RtCamera, SceneDraw, Stage3D,
     Vertex3D, WindowSettings,
 };
 use cce_ui::scene::layout::Rect;
@@ -73,7 +73,7 @@ enum Message {
     /// The wireframe and the normals of the model of `generation`.
     Overlays { generation: u64, edges: Arc<Vec<Vertex3D>>, normals: Arc<Vec<Vertex3D>> },
     /// The traced view's scene for the model of `generation`.
-    TraceScene { generation: u64, scene: Arc<TraceScene> },
+    TraceScene { generation: u64, scene: Arc<PreparedRtScene> },
     Quit,
 }
 
@@ -96,13 +96,6 @@ struct Model {
     floor_y: f32,
 }
 
-/// The tracer's triangles and materials, built on a worker.
-#[derive(Debug)]
-struct TraceScene {
-    triangles: Vec<RtTriangle>,
-    materials: Vec<RtMaterial>,
-}
-
 impl Model {
     fn read(path: &Path) -> Result<Model, String> {
         let scene = cce_mesh_io::load(path)?;
@@ -191,12 +184,18 @@ struct ModelApp {
     /// The path-traced view: asked for, its scene once built, whether the
     /// current renderer holds it, and how far it has refined.
     traced: bool,
-    trace_scene: Option<Arc<TraceScene>>,
+    /// Built on a worker — packed, and its BVH built when the renderer's
+    /// tracer wants one — so the UI thread only uploads it.
+    trace_scene: Option<Arc<PreparedRtScene>>,
+    /// Whether the renderer's tracer traverses a CPU-built BVH (asked in
+    /// `init_3d`): the worker builds one only then.
+    rt_needs_bvh: bool,
     building_trace: bool,
     trace_uploaded: bool,
-    /// A frame saying "preparing" has gone out ahead of the upload, which
-    /// freezes the window while cce-ui builds the tracer's BVH (2.4 s for
-    /// 5M triangles), so the freeze is explained before it happens.
+    /// A frame saying "preparing" has gone out ahead of the upload. The BVH
+    /// is the worker's now, so the upload is short (61 ms for 5M triangles
+    /// on the iGPU, against 2.1 s when it built the BVH too), but the first
+    /// one also makes the tracer's pipelines.
     trace_announced: bool,
     samples: u32,
     sample_cap: u32,
@@ -307,17 +306,20 @@ impl ModelApp {
         }
         let Some(model) = self.model.clone() else { return };
         self.building_trace = true;
-        let (generation, sender) = (self.generation, self.sender.clone());
+        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 n_materials = materials.len();
+            let scene = PreparedRtScene::new(triangles, &materials, None, with_bvh);
             log::info!(
-                "[model] trace scene: {} triangles, {} materials in {:.0} ms",
-                triangles.len(),
-                materials.len(),
+                "[model] trace scene: {} triangles, {} materials{} in {:.0} ms",
+                scene.triangle_count(),
+                n_materials,
+                if with_bvh { ", BVH" } else { "" },
                 started.elapsed().as_secs_f64() * 1e3
             );
-            let _ = sender.send(Message::TraceScene { generation, scene: Arc::new(TraceScene { triangles, materials }) });
+            let _ = sender.send(Message::TraceScene { generation, scene: Arc::new(scene) });
         });
     }
 
@@ -451,6 +453,7 @@ impl Application for ModelApp {
             show_normals: false,
             traced: false,
             trace_scene: None,
+            rt_needs_bvh: true,
             building_trace: false,
             trace_uploaded: false,
             trace_announced: false,
@@ -575,6 +578,7 @@ impl Application for ModelApp {
             bg(-1.0, 1.0, SKY_TOP),
         ]);
         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 });
         let has = self.model.is_some();
         let built = self.overlays.is_some();
@@ -636,9 +640,8 @@ impl Application for ModelApp {
                 if !std::mem::replace(&mut self.trace_announced, true) {
                     return true;
                 }
-                let scene = self.trace_scene.as_ref().unwrap();
                 let started = Instant::now();
-                stage.set_rt_scene(&scene.triangles, &scene.materials);
+                stage.set_rt_scene_prepared(self.trace_scene.as_ref().unwrap());
                 stage.set_rt_environment(trace::environment(light::KEY.normalize().to_array()));
                 stage.set_rt_background(Some(trace::BACKGROUND));
                 log::info!("[model] trace scene uploaded in {:.0} ms", started.elapsed().as_secs_f64() * 1e3);