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

commitb28d1e46f3657fe1fa81fe221203382061bf5c70
parente9dcccc973
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 14:46
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];