git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit7d1dfec3bb12516a22e754bf7db0b27837766b65
parent022b90863f
authorLucas Galante <[email protected]>
date2026-07-14 18:04
feat: ray-traced viewport preview (RT-renderer phase 2 consumer)

"Ray Traced Preview" joins the Main node's Viewport Settings: the pane
renders through cce_ui::vk's compute path tracer instead of the raster
3D pass. The scene is the sphere geometry (+ reference cube when shown)
fed as triangles with color-deduped Lambertian materials — cached from
rebuild_scene_geometry (rt_sphere_verts + a version counter), so
entering RT mode never re-runs the node graph or OpenCL kernels. The
camera is the raster mvp's inverse, staged every frame: camera drags
stay interactive at 1 spp and stillness converges progressively
(render() keeps requesting frames while rt_accumulating).

vk-smoke grows the matching harness: CCE_VK_SMOKE_RT=1 traces a
cube/floor/emissive-panel scene under a static camera, exercising
convergence, the 2D-over-RT composite, and blur plates sampling the
RT backdrop.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01G5djCURa3LVnRU8WacanLC

 src/app.rs         | 42 ++++++++++++++++++++++++++++-
 src/project.rs     |  3 +++
 src/render.rs      | 49 ++++++++++++++++++++++++++++++++++
 src/viewport_3d.rs |  4 +++
 src/vk_smoke.rs    | 78 ++++++++++++++++++++++++++++++++++++++++++++++--------
 5 files changed, 164 insertions(+), 12 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 3c86817..c145ec5 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1103,6 +1103,15 @@ pub struct State {
     pub last_viewport_height: u32,
     pub last_viewport_active_camera: String,
     pub last_viewport_show_viewport: bool,
+    pub last_viewport_rt_mode: bool,
+    /// Sphere-geometry cache for the path tracer (a copy of the last
+    /// `rebuild_scene_geometry` output, so entering RT mode never re-runs
+    /// the node graph / OpenCL kernels).
+    pub rt_sphere_verts: Vec<Vertex3D>,
+    /// Bumped by `rebuild_scene_geometry`; part of the RT-scene cache key.
+    pub rt_geometry_version: u64,
+    /// (show_cube, rt_geometry_version) the RT scene was last built from.
+    pub last_rt_scene_key: Option<(bool, u64)>,
     pub ui_context: cce_ui::context::UiContext,
 }
 
@@ -2735,6 +2744,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             last_viewport_height: 0,
             last_viewport_active_camera: String::new(),
             last_viewport_show_viewport: false,
+            last_viewport_rt_mode: false,
+            rt_sphere_verts: Vec::new(),
+            rt_geometry_version: 0,
+            last_rt_scene_key: None,
             ui_context: cce_ui::context::UiContext::new(),
         };
 
@@ -5109,7 +5122,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     }
                 }
 
+                let rt_mode = self.viewport().rt_mode;
                 let viewport_changed = self.viewport_dirty
+                    || self.last_viewport_rt_mode != rt_mode
                     || self.last_viewport_camera_pos != camera_pos
                     || self.last_viewport_camera_rx != rx
                     || self.last_viewport_camera_ry != ry
@@ -5129,6 +5144,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     || self.last_viewport_show_viewport != self.show_viewport;
 
                 if viewport_changed {
+                    if !rt_mode {
                     let aspect = cw as f32 / ch as f32;
                     let (proj, view_mat, model) = self.viewport().get_matrices(aspect, Some(camera_pos), Some(Vec3::new(rx, ry, rz)), Some(pivot));
                     let mvp = (proj * view_mat * model).to_cols_array_2d();
@@ -5163,6 +5179,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                         draws.push(SceneDraw { mesh: self.mesh_spheres, mvp });
                     }
                     self.renderer.stage_scene((sx, sy, cw, ch), draws);
+                    }
 
                     // Update viewport cache
                     self.last_viewport_camera_pos = camera_pos;
@@ -5182,8 +5199,26 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     self.last_viewport_height = ch;
                     self.last_viewport_active_camera = self.active_camera.clone();
                     self.last_viewport_show_viewport = self.show_viewport;
+                    self.last_viewport_rt_mode = rt_mode;
                     self.viewport_dirty = false;
                 }
+
+                // Path-traced mode: staged EVERY frame (each one adds a
+                // sample); the renderer resets the accumulation itself when
+                // the camera/pane changes, so camera drags stay interactive
+                // (1-spp noise) and stillness converges.
+                if rt_mode {
+                    let key = (self.viewport().show_cube, self.rt_geometry_version);
+                    if self.last_rt_scene_key != Some(key) {
+                        let (rt_tris, rt_mats) = self.collect_rt_scene();
+                        self.renderer.set_rt_scene(&rt_tris, &rt_mats);
+                        self.last_rt_scene_key = Some(key);
+                    }
+                    let aspect = cw as f32 / ch as f32;
+                    let (proj, view_mat, model) = self.viewport().get_matrices(aspect, Some(camera_pos), Some(Vec3::new(rx, ry, rz)), Some(pivot));
+                    let inv_mvp = (proj * view_mat * model).inverse().to_cols_array_2d();
+                    self.renderer.stage_rt((sx, sy, cw, ch), cce_ui::vk::RtCamera { inv_mvp });
+                }
             } else if self.viewport_dirty {
                 // Zero-area pane: clear the backdrop once.
                 self.renderer
@@ -5201,7 +5236,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         }
 
         let _ = self.renderer.draw_frame(&self.vertex_data);
-        tick_changed || panned
+        // Keep frames coming while the path tracer is still refining.
+        let rt_refining = !self.is_detached_network
+            && self.show_viewport
+            && self.viewport().rt_mode
+            && self.renderer.rt_accumulating();
+        tick_changed || panned || rt_refining
     }
 }
 
diff --git a/src/project.rs b/src/project.rs
index 2b6b473..b3bfbcd 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -268,6 +268,7 @@ impl State {
         let vp_show_camera_pivot = self.viewport().show_camera_pivot;
         let vp_bg_color = self.viewport().bg_color;
         let vp_grid_color = self.viewport().grid_color;
+        let vp_rt_mode = self.viewport().rt_mode;
 
         let camera_nodes: Vec<String> = self.current_dir().children.iter()
             .filter(|c| c.node_type == "camera")
@@ -387,6 +388,7 @@ impl State {
         ensure_param(main_node, "Show Reference Cube", "choice", if vp_show_cube { "true" } else { "false" }, &["false", "true"], None, None, None);
         ensure_param(main_node, "Show Origin Axes", "choice", if vp_show_origin { "true" } else { "false" }, &["false", "true"], None, None, None);
         ensure_param(main_node, "Show Camera Pivot", "choice", if vp_show_camera_pivot { "true" } else { "false" }, &["false", "true"], None, None, None);
+        ensure_param(main_node, "Ray Traced Preview", "choice", if vp_rt_mode { "true" } else { "false" }, &["false", "true"], None, None, None);
         ensure_param(main_node, "Grid Thickness", "spinbox", &((self.grid_thickness * 1000.0) as i32).to_string(), &[], Some(2.0), Some(200.0), Some(1.0));
         ensure_param(main_node, "Origin Guide Size", "spinbox", &((self.origin_size * 10.0) as i32).to_string(), &[], Some(1.0), Some(50.0), Some(1.0));
         ensure_param(main_node, "Camera Pivot Size", "spinbox", &((self.camera_pivot_size * 10.0) as i32).to_string(), &[], Some(1.0), Some(50.0), Some(1.0));
@@ -413,6 +415,7 @@ impl State {
                     "Show Reference Cube" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_cube = val; }
                     "Show Origin Axes" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_origin = val; }
                     "Show Camera Pivot" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_camera_pivot = val; }
+                    "Ray Traced Preview" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().rt_mode = val; }
                     "Grid Thickness" => if let Ok(val) = p.default.parse::<f32>() { self.grid_thickness = val / 1000.0; }
                     "Origin Guide Size" => if let Ok(val) = p.default.parse::<f32>() { self.origin_size = val / 10.0; }
                     "Camera Pivot Size" => if let Ok(val) = p.default.parse::<f32>() { self.camera_pivot_size = val / 10.0; }
diff --git a/src/render.rs b/src/render.rs
index 5fef997..6168c3a 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -401,9 +401,58 @@ impl State {
         let verts = geom.to_vertex3d_vec();
         self.vertex_count_spheres = verts.len() as u32;
         self.renderer.update_mesh(self.mesh_spheres, bytemuck::cast_slice(&verts));
+        // Cache for the path tracer, so RT mode never re-runs the node
+        // graph / OpenCL kernels; the version bump invalidates its scene.
+        self.rt_sphere_verts = verts;
+        self.rt_geometry_version += 1;
         self.viewport_dirty = true;
     }
 
+    /// The path tracer's scene: the sphere geometry (and the reference cube if
+    /// shown) as triangles, with one Lambertian material per distinct vertex
+    /// color. Same mesh space as the raster pass, so the raster mvp's inverse
+    /// drives the camera.
+    pub(crate) fn collect_rt_scene(
+        &self,
+    ) -> (Vec<cce_ui::vk::RtTriangle>, Vec<cce_ui::vk::RtMaterial>) {
+        let mut tris: Vec<cce_ui::vk::RtTriangle> = Vec::new();
+        let mut mats: Vec<cce_ui::vk::RtMaterial> = Vec::new();
+        // Dedupe on 8-bit-quantized color: procedural (OpenCL) geometry can
+        // carry per-vertex gradients, and exact-match dedup would mint one
+        // material per triangle.
+        let mut by_color: std::collections::HashMap<[u8; 3], u32> =
+            std::collections::HashMap::new();
+        let mut push_verts = |tris: &mut Vec<cce_ui::vk::RtTriangle>,
+                              mats: &mut Vec<cce_ui::vk::RtMaterial>,
+                              verts: &[crate::geometry::Vertex3D]| {
+            for tri in verts.chunks_exact(3) {
+                let key = [
+                    (tri[0].color[0].clamp(0.0, 1.0) * 255.0) as u8,
+                    (tri[0].color[1].clamp(0.0, 1.0) * 255.0) as u8,
+                    (tri[0].color[2].clamp(0.0, 1.0) * 255.0) as u8,
+                ];
+                let material = *by_color.entry(key).or_insert_with(|| {
+                    mats.push(cce_ui::vk::RtMaterial {
+                        albedo: tri[0].color,
+                        emission: [0.0; 3],
+                    });
+                    (mats.len() - 1) as u32
+                });
+                tris.push(cce_ui::vk::RtTriangle {
+                    p0: tri[0].position,
+                    p1: tri[1].position,
+                    p2: tri[2].position,
+                    material,
+                });
+            }
+        };
+        push_verts(&mut tris, &mut mats, &self.rt_sphere_verts);
+        if self.viewport().show_cube {
+            push_verts(&mut tris, &mut mats, &crate::geometry::cube_vertices());
+        }
+        (tris, mats)
+    }
+
     pub(crate) fn update_status_text(&mut self, text: &str) {
         if self.last_status_text != text {
             self.last_status_text = text.to_string();
diff --git a/src/viewport_3d.rs b/src/viewport_3d.rs
index 8664b18..ac57fd8 100644
--- a/src/viewport_3d.rs
+++ b/src/viewport_3d.rs
@@ -19,6 +19,9 @@ pub struct Viewport3D {
     pub show_cube: bool,
     pub show_origin: bool,
     pub show_camera_pivot: bool,
+    /// Path-traced preview: the pane renders through `cce_ui::vk`'s compute
+    /// tracer instead of the raster 3D pass.
+    pub rt_mode: bool,
 
     // Pending rotation to be consumed by the application when not using the default camera
     pub pending_yaw: f32,
@@ -59,6 +62,7 @@ impl Viewport3D {
             show_cube: true,
             show_origin: true,
             show_camera_pivot: true,
+            rt_mode: false,
             pending_yaw: 0.0,
             pending_pitch: 0.0,
             ctrl_pressed: false,
diff --git a/src/vk_smoke.rs b/src/vk_smoke.rs
index 2eead17..660968e 100644
--- a/src/vk_smoke.rs
+++ b/src/vk_smoke.rs
@@ -34,7 +34,7 @@ use cce_ui::engine::{quad_vertices, Vertex};
 use glam::{Mat4, Vec3};
 use glyphon::cosmic_text::{Attrs, Buffer as TextBuffer, Family, Metrics, Shaping};
 use glyphon::{FontSystem, SwashCache};
-use vk::{Frame2D, ImageQuad, SceneDraw, TextSpan, Vertex3D, VkRenderer};
+use vk::{Frame2D, ImageQuad, RtCamera, RtMaterial, RtTriangle, SceneDraw, TextSpan, Vertex3D, VkRenderer};
 
 struct SmokeApp {
     registry_state: RegistryState,
@@ -210,6 +210,47 @@ fn viewport_bg_verts() -> Vec<Vertex3D> {
     v
 }
 
+/// The RT-mode demo scene (`CCE_VK_SMOKE_RT=1`): the reference cube on a gray
+/// floor beneath an emissive panel — enough for visible indirect light, color
+/// bleed, and soft shadows once the accumulation converges.
+fn rt_demo_scene() -> (Vec<RtTriangle>, Vec<RtMaterial>) {
+    let mut tris: Vec<RtTriangle> = Vec::new();
+    let mut mats: Vec<RtMaterial> = Vec::new();
+    let mat = |mats: &mut Vec<RtMaterial>, albedo: [f32; 3], emission: [f32; 3]| -> u32 {
+        let m = RtMaterial { albedo, emission };
+        if let Some(i) = mats.iter().position(|x| *x == m) {
+            i as u32
+        } else {
+            mats.push(m);
+            (mats.len() - 1) as u32
+        }
+    };
+
+    // The cube, from the same triangle list the raster pass draws.
+    for tri in cube_verts().chunks_exact(3) {
+        let material = mat(&mut mats, tri[0].color, [0.0; 3]);
+        tris.push(RtTriangle { p0: tri[0].position, p1: tri[1].position, p2: tri[2].position, material });
+    }
+
+    let quad = |tris: &mut Vec<RtTriangle>, c: [[f32; 3]; 4], material: u32| {
+        tris.push(RtTriangle { p0: c[0], p1: c[1], p2: c[2], material });
+        tris.push(RtTriangle { p0: c[0], p1: c[2], p2: c[3], material });
+    };
+    let floor = mat(&mut mats, [0.55, 0.55, 0.58], [0.0; 3]);
+    quad(
+        &mut tris,
+        [[-6.0, -0.5, -6.0], [6.0, -0.5, -6.0], [6.0, -0.5, 6.0], [-6.0, -0.5, 6.0]],
+        floor,
+    );
+    let panel = mat(&mut mats, [0.0; 3], [6.0, 5.6, 5.0]);
+    quad(
+        &mut tris,
+        [[-1.0, 2.2, -1.0], [1.0, 2.2, -1.0], [1.0, 2.2, 1.0], [-1.0, 2.2, 1.0]],
+        panel,
+    );
+    (tris, mats)
+}
+
 /// Designer-style 2D chrome in logical coordinates. No full-window background —
 /// the 3D viewport (copied in as the backdrop) shows through everywhere the UI
 /// doesn't paint, exactly like the app.
@@ -339,6 +380,16 @@ fn main() {
     }
     let checker = vk::upload_rgba(px, 64, 64);
 
+    // RT mode: the pane runs the phase-2 compute path tracer instead of the
+    // raster 3D pass. Static camera, so progressive accumulation visibly
+    // converges from noise to a clean render.
+    let rt_mode = std::env::var_os("CCE_VK_SMOKE_RT").is_some();
+    if rt_mode {
+        let (tris, mats) = rt_demo_scene();
+        log::info!("vk-smoke: RT mode — {} triangles, {} materials", tris.len(), mats.len());
+        app.renderer.as_mut().unwrap().set_rt_scene(&tris, &mats);
+    }
+
     let start = std::time::Instant::now();
     while !app.exit {
         event_loop
@@ -411,16 +462,21 @@ fn main() {
             let aspect = (lw - 56.0) / (lh - 36.0);
             let proj = Mat4::perspective_rh(0.9, aspect, 0.1, 100.0);
             let view = Mat4::look_at_rh(Vec3::new(2.5, 1.8, 2.5), Vec3::ZERO, Vec3::Y);
-            let model = Mat4::from_rotation_y(t * 0.8);
-            let mvp = (proj * view * model).to_cols_array_2d();
-            renderer.stage_scene(
-                pane,
-                vec![
-                    SceneDraw { mesh: bg_mesh, mvp: Mat4::IDENTITY.to_cols_array_2d() },
-                    SceneDraw { mesh: grid_mesh, mvp },
-                    SceneDraw { mesh: cube_mesh, mvp },
-                ],
-            );
+            if rt_mode {
+                let inv_mvp = (proj * view).inverse().to_cols_array_2d();
+                renderer.stage_rt(pane, RtCamera { inv_mvp });
+            } else {
+                let model = Mat4::from_rotation_y(t * 0.8);
+                let mvp = (proj * view * model).to_cols_array_2d();
+                renderer.stage_scene(
+                    pane,
+                    vec![
+                        SceneDraw { mesh: bg_mesh, mvp: Mat4::IDENTITY.to_cols_array_2d() },
+                        SceneDraw { mesh: grid_mesh, mvp },
+                        SceneDraw { mesh: cube_mesh, mvp },
+                    ],
+                );
+            }
             renderer.prepare_text(&mut font_system, &mut swash_cache, &spans);
             // FIFO present paces this loop to the display's refresh rate.
             renderer.draw_frame_2d(Frame2D {