git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit8dbe0c40463af75f7ad59050b52e0805ee798f6b
parent3531f86f19
authorLucas Galante <[email protected]>
date2026-07-14 18:28
feat(vk): RtOffscreen — headless path-traced rendering (phase 3 seam)

The offscreen consumer path the RT plan built VkCore::new_headless for:
RtOffscreen owns a headless core + an RtStage whose blit target is a
private sRGB image, read back to tightly packed RGBA8 pixels — no
window, no compositor, any graphics-capable device. Renders in 8-spp
chunks (one submit each) to stay under GPU watchdogs, resetting the
accumulation per render.

rt.wgsl grows a `spp` param: an in-dispatch sample loop, so offscreen
needs a handful of submits instead of one per sample. The interactive
viewport keeps spp=1 (unchanged behavior; sample_index now advances by
spp). GPU end-to-end test included (#[ignore], needs a Vulkan device;
passes on Iris Xe).

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

 src/vk/mod.rs  |   2 +-
 src/vk/rt.rs   | 339 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/vk/rt.wgsl |  72 +++++++-----
 3 files changed, 381 insertions(+), 32 deletions(-)

diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index 9fe5bdc..3a9e3cb 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -41,6 +41,6 @@ mod text;
 pub use core::VkCore;
 pub use image::{free_image, upload_rgba, ImageQuad};
 pub use renderer::{Batch2D, Frame2D, VkRenderer};
-pub use rt::{RtCamera, RtMaterial, RtTriangle};
+pub use rt::{RtCamera, RtMaterial, RtOffscreen, RtTriangle};
 pub use scene::{MeshId, SceneDraw, Vertex3D};
 pub use text::TextSpan;
diff --git a/src/vk/rt.rs b/src/vk/rt.rs
index fa06fd3..1808f6f 100644
--- a/src/vk/rt.rs
+++ b/src/vk/rt.rs
@@ -80,6 +80,8 @@ struct RtParams {
     height: u32,
     sample_index: u32,
     max_bounces: u32,
+    spp: u32,
+    _pad: [u32; 3],
 }
 
 // --- BVH construction (binned SAH) ---
@@ -300,6 +302,8 @@ pub(crate) struct RtStage {
     pane_moved: bool,
     camera: Option<RtCamera>,
     sample_index: u32,
+    /// Samples per dispatch: 1 interactive, higher for offscreen rendering.
+    spp: u32,
     staged: bool,
 }
 
@@ -447,6 +451,7 @@ impl RtStage {
                 pane_moved: false,
                 camera: None,
                 sample_index: 0,
+                spp: 1,
                 staged: false,
             }
         }
@@ -703,6 +708,8 @@ impl RtStage {
             height: self.output_size.1,
             sample_index: self.sample_index,
             max_bounces: MAX_BOUNCES,
+            spp: self.spp,
+            _pad: [0; 3],
         };
         let frame = &mut self.frames[frame_index];
         frame.uniforms.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()
@@ -903,7 +910,7 @@ impl RtStage {
                     .subresource_range(color_range)],
             );
         }
-        self.sample_index += 1;
+        self.sample_index += self.spp;
         true
     }
 
@@ -926,6 +933,297 @@ impl RtStage {
     }
 }
 
+// --- Headless offscreen rendering (thumbnails, previews) ---
+
+/// One-shot path-traced rendering with no window anywhere: a headless
+/// [`super::VkCore`] + an [`RtStage`] whose "backdrop" is a private sRGB
+/// target image, read back to CPU pixels. This is the seam consumers like
+/// the cce-files thumbnailer sit on.
+///
+/// Not `Send`-safe by design intent (owns a device); create it on the worker
+/// thread that renders.
+pub struct RtOffscreen {
+    stage: RtStage,
+    target_image: vk::Image,
+    target_allocation: Option<Allocation>,
+    readback: AllocatedBuffer,
+    size: (u32, u32),
+    cmd: vk::CommandBuffer,
+    fence: vk::Fence,
+    // Declared last: dropped after everything above is destroyed in Drop.
+    core: super::VkCore,
+}
+
+impl RtOffscreen {
+    /// Samples per submit: keeps each dispatch well under GPU watchdog
+    /// timeouts even at large sizes; a render loops submits to reach the
+    /// requested sample count.
+    const CHUNK_SPP: u32 = 8;
+
+    pub fn new() -> Self {
+        let mut core = super::VkCore::new_headless();
+        let device = core.device.clone();
+        let allocator = core.allocator.as_mut().unwrap();
+        let stage = RtStage::new(&device, allocator, 1);
+        unsafe {
+            let cmd = device
+                .allocate_command_buffers(
+                    &vk::CommandBufferAllocateInfo::default()
+                        .command_pool(core.command_pool)
+                        .level(vk::CommandBufferLevel::PRIMARY)
+                        .command_buffer_count(1),
+                )
+                .expect("Failed to allocate RT offscreen command buffer")[0];
+            let fence = device
+                .create_fence(&vk::FenceCreateInfo::default(), None)
+                .expect("Failed to create RT offscreen fence");
+            RtOffscreen {
+                stage,
+                target_image: vk::Image::null(),
+                target_allocation: None,
+                readback: AllocatedBuffer::null(),
+                size: (0, 0),
+                cmd,
+                fence,
+                core,
+            }
+        }
+    }
+
+    /// Replace the scene (same schema as `VkRenderer::set_rt_scene`).
+    pub fn set_scene(&mut self, triangles: &[RtTriangle], materials: &[RtMaterial]) {
+        unsafe {
+            let _ = self.core.device.device_wait_idle();
+        }
+        let device = self.core.device.clone();
+        self.stage
+            .set_scene(&device, self.core.allocator.as_mut().unwrap(), triangles, materials);
+    }
+
+    /// Render `samples` paths per pixel and return tightly packed
+    /// sRGB-encoded RGBA8 pixels (`width * height * 4` bytes). Blocks until
+    /// the GPU finishes; meant for worker threads, not frame loops.
+    pub fn render(
+        &mut self,
+        camera: RtCamera,
+        width: u32,
+        height: u32,
+        samples: u32,
+    ) -> Vec<u8> {
+        let width = width.max(1);
+        let height = height.max(1);
+        let samples = samples.clamp(1, MAX_SAMPLES);
+        let device = self.core.device.clone();
+        self.ensure_target(width, height);
+
+        // Fresh accumulation every render: thumbnails are one-shot.
+        self.stage.sample_index = 0;
+        let extent = vk::Extent2D { width, height };
+        let mut done = 0u32;
+        while done < samples {
+            self.stage.spp = Self::CHUNK_SPP.min(samples - done);
+            self.stage.stage(
+                &device,
+                self.core.allocator.as_mut().unwrap(),
+                (0, 0, width, height),
+                camera,
+            );
+            // stage() resets sample_index when the camera or size changed —
+            // keep our resume point, not the reset, after the first chunk.
+            self.stage.sample_index = done;
+            self.stage.write_frame_uniforms(0);
+            unsafe {
+                device
+                    .begin_command_buffer(self.cmd, &vk::CommandBufferBeginInfo::default())
+                    .unwrap();
+                let recorded =
+                    self.stage.record(&device, self.cmd, 0, self.target_image, extent, true);
+                device.end_command_buffer(self.cmd).unwrap();
+                assert!(recorded, "RT offscreen: nothing recorded (empty scene?)");
+                self.submit_and_wait();
+            }
+            done += Self::CHUNK_SPP.min(samples - done);
+        }
+
+        // Copy the sRGB target (left in TRANSFER_SRC by record) to the
+        // readback buffer and map it.
+        unsafe {
+            device
+                .begin_command_buffer(self.cmd, &vk::CommandBufferBeginInfo::default())
+                .unwrap();
+            device.cmd_copy_image_to_buffer(
+                self.cmd,
+                self.target_image,
+                vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
+                self.readback.buffer,
+                &[vk::BufferImageCopy::default()
+                    .image_subresource(
+                        vk::ImageSubresourceLayers::default()
+                            .aspect_mask(vk::ImageAspectFlags::COLOR)
+                            .layer_count(1),
+                    )
+                    .image_extent(vk::Extent3D { width, height, depth: 1 })],
+            );
+            device.cmd_pipeline_barrier(
+                self.cmd,
+                vk::PipelineStageFlags::TRANSFER,
+                vk::PipelineStageFlags::HOST,
+                vk::DependencyFlags::empty(),
+                &[],
+                &[vk::BufferMemoryBarrier::default()
+                    .src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
+                    .dst_access_mask(vk::AccessFlags::HOST_READ)
+                    .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                    .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                    .buffer(self.readback.buffer)
+                    .size(vk::WHOLE_SIZE)],
+                &[],
+            );
+            device.end_command_buffer(self.cmd).unwrap();
+            self.submit_and_wait();
+        }
+        let len = (width * height * 4) as usize;
+        self.readback.allocation.as_ref().unwrap().mapped_slice().unwrap()[..len].to_vec()
+    }
+
+    unsafe fn submit_and_wait(&mut self) {
+        let device = &self.core.device;
+        let cmds = [self.cmd];
+        device
+            .queue_submit(
+                self.core.queue,
+                &[vk::SubmitInfo::default().command_buffers(&cmds)],
+                self.fence,
+            )
+            .expect("RT offscreen submit failed");
+        device
+            .wait_for_fences(&[self.fence], true, u64::MAX)
+            .expect("RT offscreen fence wait failed");
+        device.reset_fences(&[self.fence]).unwrap();
+    }
+
+    fn ensure_target(&mut self, width: u32, height: u32) {
+        if (width, height) == self.size {
+            return;
+        }
+        let device = self.core.device.clone();
+        unsafe {
+            let _ = device.device_wait_idle();
+        }
+        self.destroy_target();
+        let allocator = self.core.allocator.as_mut().unwrap();
+        unsafe {
+            let image = device
+                .create_image(
+                    &vk::ImageCreateInfo::default()
+                        .image_type(vk::ImageType::TYPE_2D)
+                        .format(vk::Format::R8G8B8A8_SRGB)
+                        .extent(vk::Extent3D { width, height, depth: 1 })
+                        .mip_levels(1)
+                        .array_layers(1)
+                        .samples(vk::SampleCountFlags::TYPE_1)
+                        .tiling(vk::ImageTiling::OPTIMAL)
+                        .usage(
+                            vk::ImageUsageFlags::TRANSFER_DST | vk::ImageUsageFlags::TRANSFER_SRC,
+                        )
+                        .initial_layout(vk::ImageLayout::UNDEFINED),
+                    None,
+                )
+                .expect("Failed to create RT offscreen target");
+            let requirements = device.get_image_memory_requirements(image);
+            let allocation = allocator
+                .allocate(&AllocationCreateDesc {
+                    name: "rt-offscreen-target",
+                    requirements,
+                    location: MemoryLocation::GpuOnly,
+                    linear: false,
+                    allocation_scheme: AllocationScheme::GpuAllocatorManaged,
+                })
+                .expect("Failed to allocate RT offscreen target memory");
+            device
+                .bind_image_memory(image, allocation.memory(), allocation.offset())
+                .expect("Failed to bind RT offscreen target memory");
+            self.target_image = image;
+            self.target_allocation = Some(allocation);
+
+            self.readback = create_cpu_buffer(
+                &device,
+                allocator,
+                (width as vk::DeviceSize) * (height as vk::DeviceSize) * 4,
+                vk::BufferUsageFlags::TRANSFER_DST,
+                "rt-readback",
+            );
+
+            // RtStage::record expects the blit destination in TRANSFER_SRC
+            // (the steady state SceneStage leaves the backdrop in).
+            device
+                .begin_command_buffer(self.cmd, &vk::CommandBufferBeginInfo::default())
+                .unwrap();
+            device.cmd_pipeline_barrier(
+                self.cmd,
+                vk::PipelineStageFlags::TOP_OF_PIPE,
+                vk::PipelineStageFlags::TRANSFER,
+                vk::DependencyFlags::empty(),
+                &[],
+                &[],
+                &[vk::ImageMemoryBarrier::default()
+                    .src_access_mask(vk::AccessFlags::empty())
+                    .dst_access_mask(vk::AccessFlags::TRANSFER_READ)
+                    .old_layout(vk::ImageLayout::UNDEFINED)
+                    .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
+                    .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                    .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                    .image(image)
+                    .subresource_range(
+                        vk::ImageSubresourceRange::default()
+                            .aspect_mask(vk::ImageAspectFlags::COLOR)
+                            .level_count(1)
+                            .layer_count(1),
+                    )],
+            );
+            device.end_command_buffer(self.cmd).unwrap();
+            self.submit_and_wait();
+        }
+        self.size = (width, height);
+    }
+
+    fn destroy_target(&mut self) {
+        unsafe {
+            if self.target_image != vk::Image::null() {
+                self.core.device.destroy_image(self.target_image, None);
+                self.target_image = vk::Image::null();
+            }
+        }
+        if let Some(a) = self.target_allocation.take() {
+            let _ = self.core.allocator.as_mut().unwrap().free(a);
+        }
+        let device = self.core.device.clone();
+        destroy_cpu_buffer(&device, self.core.allocator.as_mut().unwrap(), &mut self.readback);
+        self.size = (0, 0);
+    }
+}
+
+impl Default for RtOffscreen {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl Drop for RtOffscreen {
+    fn drop(&mut self) {
+        unsafe {
+            let _ = self.core.device.device_wait_idle();
+        }
+        self.destroy_target();
+        let device = self.core.device.clone();
+        self.stage.destroy(&device, self.core.allocator.as_mut().unwrap());
+        unsafe {
+            self.core.device.destroy_fence(self.fence, None);
+            // The command buffer dies with the pool in VkCore's Drop.
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1143,4 +1441,43 @@ mod tests {
         let spirv = compile_wgsl(include_str!("rt.wgsl"));
         assert!(!spirv.is_empty());
     }
+
+    /// End-to-end GPU test — needs a Vulkan device, so ignored by default.
+    /// Run with: cargo test --lib vk::rt -- --ignored
+    #[test]
+    #[ignore = "requires a Vulkan device"]
+    fn test_offscreen_render_smoke() {
+        let mut off = RtOffscreen::new();
+        // A red triangle filling the view center, camera looking down -Z.
+        off.set_scene(
+            &[RtTriangle {
+                p0: [-1.0, -1.0, 0.0],
+                p1: [1.0, -1.0, 0.0],
+                p2: [0.0, 1.5, 0.0],
+                material: 0,
+            }],
+            &[RtMaterial { albedo: [0.9, 0.1, 0.1], emission: [0.0; 3] }],
+        );
+        let proj = glam::Mat4::perspective_rh(0.9, 1.0, 0.1, 100.0);
+        let view = glam::Mat4::look_at_rh(
+            glam::Vec3::new(0.0, 0.0, 3.0),
+            glam::Vec3::ZERO,
+            glam::Vec3::Y,
+        );
+        let camera = RtCamera { inv_mvp: (proj * view).inverse().to_cols_array_2d() };
+        let (w, h) = (64u32, 64u32);
+        let px = off.render(camera, w, h, 16);
+        assert_eq!(px.len(), (w * h * 4) as usize);
+        // Center pixel hits the triangle: red-dominant. Corner pixel is sky:
+        // blue >= red. Alpha opaque everywhere.
+        let at = |x: u32, y: u32| {
+            let i = ((y * w + x) * 4) as usize;
+            (px[i], px[i + 1], px[i + 2], px[i + 3])
+        };
+        let (cr, _cg, cb, ca) = at(w / 2, h / 2);
+        assert!(ca == 255, "alpha not opaque: {ca}");
+        assert!(cr > cb, "center not red-dominant: r={cr} b={cb}");
+        let (sr, _sg, sb, _sa) = at(1, 1);
+        assert!(sb >= sr, "corner sky not blue-ish: r={sr} b={sb}");
+    }
 }
diff --git a/src/vk/rt.wgsl b/src/vk/rt.wgsl
index 9be2963..e3cbaf7 100644
--- a/src/vk/rt.wgsl
+++ b/src/vk/rt.wgsl
@@ -17,6 +17,13 @@ struct Params {
     height: u32,
     sample_index: u32,
     max_bounces: u32,
+    // Samples per dispatch: 1 for the interactive viewport (one refinement
+    // step per frame), higher for offscreen/thumbnail rendering so a whole
+    // image needs only a few submits.
+    spp: u32,
+    _pad0: u32,
+    _pad1: u32,
+    _pad2: u32,
 }
 
 @group(0) @binding(0) var<uniform> params: Params;
@@ -204,43 +211,48 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3<u32>) {
         return;
     }
     let idx = gid.y * params.width + gid.x;
-    var rng: u32 = (idx * 9781u) ^ (params.sample_index * 26699u) ^ 0x9e3779b9u;
-
-    // Jittered primary ray, unprojected through inv_mvp (NDC y up, z 0..1).
-    let jx = rand(&rng);
-    let jy = rand(&rng);
-    let ndc_x = (f32(gid.x) + jx) / f32(params.width) * 2.0 - 1.0;
-    let ndc_y = 1.0 - (f32(gid.y) + jy) / f32(params.height) * 2.0;
-    let p_near = params.inv_mvp * vec4<f32>(ndc_x, ndc_y, 0.0, 1.0);
-    let p_far = params.inv_mvp * vec4<f32>(ndc_x, ndc_y, 1.0, 1.0);
-    var ro = p_near.xyz / p_near.w;
-    var rd = normalize(p_far.xyz / p_far.w - ro);
-
-    var radiance = vec3<f32>(0.0);
-    var throughput = vec3<f32>(1.0);
-    for (var bounce: u32 = 0u; bounce < params.max_bounces; bounce = bounce + 1u) {
-        let hit = intersect_scene(ro, rd);
-        if hit.t >= 1e30 {
-            radiance = radiance + throughput * sky(rd);
-            break;
-        }
-        let tri = tris[hit.tri];
-        let mat = materials[bitcast<u32>(tri.p0.w)];
-        radiance = radiance + throughput * mat.emission.rgb;
-        var n = normalize(cross(tri.p1.xyz - tri.p0.xyz, tri.p2.xyz - tri.p0.xyz));
-        if dot(n, rd) > 0.0 {
-            n = -n;
+
+    var total = vec3<f32>(0.0);
+    for (var s: u32 = 0u; s < params.spp; s = s + 1u) {
+        var rng: u32 = (idx * 9781u) ^ ((params.sample_index + s) * 26699u) ^ 0x9e3779b9u;
+
+        // Jittered primary ray, unprojected through inv_mvp (NDC y up, z 0..1).
+        let jx = rand(&rng);
+        let jy = rand(&rng);
+        let ndc_x = (f32(gid.x) + jx) / f32(params.width) * 2.0 - 1.0;
+        let ndc_y = 1.0 - (f32(gid.y) + jy) / f32(params.height) * 2.0;
+        let p_near = params.inv_mvp * vec4<f32>(ndc_x, ndc_y, 0.0, 1.0);
+        let p_far = params.inv_mvp * vec4<f32>(ndc_x, ndc_y, 1.0, 1.0);
+        var ro = p_near.xyz / p_near.w;
+        var rd = normalize(p_far.xyz / p_far.w - ro);
+
+        var radiance = vec3<f32>(0.0);
+        var throughput = vec3<f32>(1.0);
+        for (var bounce: u32 = 0u; bounce < params.max_bounces; bounce = bounce + 1u) {
+            let hit = intersect_scene(ro, rd);
+            if hit.t >= 1e30 {
+                radiance = radiance + throughput * sky(rd);
+                break;
+            }
+            let tri = tris[hit.tri];
+            let mat = materials[bitcast<u32>(tri.p0.w)];
+            radiance = radiance + throughput * mat.emission.rgb;
+            var n = normalize(cross(tri.p1.xyz - tri.p0.xyz, tri.p2.xyz - tri.p0.xyz));
+            if dot(n, rd) > 0.0 {
+                n = -n;
+            }
+            throughput = throughput * mat.albedo.rgb;
+            ro = ro + rd * hit.t + n * 1e-4;
+            rd = cosine_dir(n, rand(&rng), rand(&rng));
         }
-        throughput = throughput * mat.albedo.rgb;
-        ro = ro + rd * hit.t + n * 1e-4;
-        rd = cosine_dir(n, rand(&rng), rand(&rng));
+        total = total + radiance;
     }
 
     var acc = accum[idx];
     if params.sample_index == 0u {
         acc = vec4<f32>(0.0);
     }
-    acc = acc + vec4<f32>(radiance, 1.0);
+    acc = acc + vec4<f32>(total, f32(params.spp));
     accum[idx] = acc;
     let color = acc.rgb / max(acc.a, 1.0);
     textureStore(