GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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(