GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(rt): a background colour for what a camera ray misses
VkRenderer::set_rt_background / RtOffscreen::set_background take a
linear colour that a camera ray meeting nothing shows, in place of the
sky; None keeps the sky. Bounces that leave the scene still meet the
sky, the tracer's one light, so the backdrop changes and the lighting
does not. Keyed on `straight` (every sample's camera ray), not
`primary` (the one sample per dispatch that writes features), which
left 7 of 8 offscreen samples on the sky.
test_offscreen_background_is_what_a_miss_shows (ignored; needs Vulkan).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/vk/renderer.rs | 16 +++++++++++++
src/vk/rt.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++
src/vk/rt_common.wgsl | 14 +++++++++++-
3 files changed, 92 insertions(+), 1 deletion(-)
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 84d7bff..63849eb 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -397,6 +397,8 @@ pub struct VkRenderer {
/// Built lazily on the first `set_rt_scene`, so ordinary UI apps never
/// compile the path-tracer pipeline.
rt: Option<RtStage>,
+ /// See [`VkRenderer::set_rt_background`].
+ rt_background: Option<[f32; 3]>,
desired_extent: vk::Extent2D,
corner_radius_px: f32,
@@ -1092,6 +1094,7 @@ impl VkRenderer {
scene,
image,
rt: None,
+ rt_background: None,
desired_extent: vk::Extent2D { width: width.max(1), height: height.max(1) },
corner_radius_px,
swapchain_dirty: false,
@@ -1677,12 +1680,25 @@ impl VkRenderer {
);
}
+ /// What a camera ray that meets nothing shows in the traced pane: a
+ /// colour in LINEAR RGB (what the raster pass's vertex colours are), or
+ /// None for the sky, which is what every miss showed until 2026-10-02.
+ /// Only the camera ray: a bounce that leaves the scene still meets the
+ /// sky, the tracer's one light, so a backdrop changes what is seen
+ /// behind the scene and not how it is lit. Kept here and handed over at
+ /// each `stage_rt`, so it may be set before the first scene; a change
+ /// restarts the accumulation.
+ pub fn set_rt_background(&mut self, color: Option<[f32; 3]>) {
+ self.rt_background = color;
+ }
+
/// Stage one progressive path-tracing pass into the viewport pane
/// (physical pixels) for the next `draw_frame`. Call it every frame while
/// RT mode is on: each frame adds a sample; a camera/pane/scene change
/// restarts the accumulation. No-op until `set_rt_scene` has run.
pub fn stage_rt(&mut self, pane: (u32, u32, u32, u32), camera: RtCamera) {
if let Some(rt) = self.rt.as_mut() {
+ rt.set_background(self.rt_background);
rt.stage(
&self.core.device,
self.core.allocator.as_mut().unwrap(),
diff --git a/src/vk/rt.rs b/src/vk/rt.rs
index a6dd2af..70b463b 100644
--- a/src/vk/rt.rs
+++ b/src/vk/rt.rs
@@ -125,6 +125,7 @@ struct RtParams {
img_origin: [f32; 4],
img_u: [f32; 4],
img_v: [f32; 4],
+ background: [f32; 4],
}
// --- BVH construction (binned SAH) ---
@@ -867,6 +868,9 @@ pub(crate) struct RtStage {
/// Samples per dispatch: 1 interactive, higher for offscreen rendering.
spp: u32,
staged: bool,
+ /// What a camera ray that meets nothing shows, linear RGB; None is the
+ /// sky. See [`RtStage::set_background`].
+ background: Option<[f32; 3]>,
}
impl RtStage {
@@ -1126,10 +1130,23 @@ impl RtStage {
sample_index: 0,
spp: 1,
staged: false,
+ background: None,
}
}
}
+ /// What a camera ray that meets nothing shows: a colour (linear RGB),
+ /// or None for the sky. The sky stays the light either way — a bounce
+ /// that leaves the scene still meets it — so this is the backdrop and
+ /// not the lighting, as an app's raster background colour is. A change
+ /// restarts the accumulation.
+ pub(crate) fn set_background(&mut self, background: Option<[f32; 3]>) {
+ if self.background != background {
+ self.background = background;
+ self.sample_index = 0;
+ }
+ }
+
fn write_image_descriptor(
device: &ash::Device,
set: vk::DescriptorSet,
@@ -1842,6 +1859,10 @@ impl RtStage {
img_origin,
img_u,
img_v,
+ background: match self.background {
+ Some([r, g, b]) => [r, g, b, 1.0],
+ None => [0.0; 4],
+ },
};
let frame = &mut self.frames[frame_index];
frame.uniforms.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()
@@ -2158,6 +2179,12 @@ impl RtOffscreen {
}
}
+ /// What a camera ray that meets nothing shows — see
+ /// `VkRenderer::set_rt_background`.
+ pub fn set_background(&mut self, background: Option<[f32; 3]>) {
+ self.stage.set_background(background);
+ }
+
/// Replace the scene (same schema as `VkRenderer::set_rt_scene`).
pub fn set_scene(&mut self, triangles: &[RtTriangle], materials: &[RtMaterial]) {
self.set_scene_with_image(triangles, materials, None);
@@ -2743,4 +2770,40 @@ mod tests {
let (sr, _sg, sb, _sa) = at(1, 1);
assert!(sb >= sr, "corner sky not blue-ish: r={sr} b={sb}");
}
+
+ /// A background colour is what a camera ray that meets nothing shows —
+ /// every sample's, not only the one that writes the denoiser's features
+ /// (a render takes several per dispatch) — while the sky still lights
+ /// what is hit; and None is the sky again.
+ #[test]
+ #[ignore = "requires a Vulkan device"]
+ fn test_offscreen_background_is_what_a_miss_shows() {
+ let mut off = RtOffscreen::new();
+ 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.8, 0.8, 0.8], 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) = (32u32, 32u32);
+ let render = |off: &mut RtOffscreen| {
+ let px = off.render(camera, w, h, 16);
+ let at = |x: u32, y: u32| {
+ let i = ((y * w + x) * 4) as usize;
+ [px[i], px[i + 1], px[i + 2]]
+ };
+ (at(1, 1), at(w / 2, h / 2))
+ };
+ let (sky, lit) = render(&mut off);
+
+ off.set_background(Some([0.0, 0.0, 0.0]));
+ let (corner, centre) = render(&mut off);
+ assert_eq!(corner, [0, 0, 0], "the corner is the backdrop, in every sample");
+ assert!(centre.iter().all(|&c| c > 60), "the sky still lights the triangle: {centre:?}");
+ assert!(centre.iter().zip(lit).all(|(&a, b)| a.abs_diff(b) < 24), "and lights it as before: {centre:?} against {lit:?}");
+
+ off.set_background(None);
+ assert_eq!(render(&mut off).0, sky, "None is the sky");
+ }
}
diff --git a/src/vk/rt_common.wgsl b/src/vk/rt_common.wgsl
index 57f9431..b0f5c1d 100644
--- a/src/vk/rt_common.wgsl
+++ b/src/vk/rt_common.wgsl
@@ -38,6 +38,12 @@ struct Params {
img_u: vec4<f32>,
// xyz = the left edge, top to bottom; w = the texture's height.
img_v: vec4<f32>,
+ // What a camera ray that meets nothing shows: rgb linear, w = 1 when
+ // set. w = 0 shows the sky, as every miss did before. Only the camera
+ // ray — a bounce that leaves the scene still meets the sky, which is
+ // the light, so the backdrop changes what is seen behind the scene and
+ // not how the scene is lit.
+ background: vec4<f32>,
}
@group(0) @binding(0) var<uniform> params: Params;
@@ -156,7 +162,13 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3<u32>) {
features[2u * idx] = vec4<f32>(0.0, 0.0, 0.0, 1e30);
features[2u * idx + 1u] = vec4<f32>(1.0, 1.0, 1.0, 0.0);
}
- radiance = radiance + throughput * sky(rd);
+ // `straight`, not `primary`: every sample's camera ray,
+ // where `primary` is the one sample that writes features.
+ if straight && params.background.w > 0.5 {
+ radiance = radiance + throughput * params.background.rgb;
+ } else {
+ radiance = radiance + throughput * sky(rd);
+ }
break;
}
let tri = tris[hit.tri];