graphic design tool
git clone https://git.lucas.co/cce-designer.git
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 {