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

commit38e45fbf4a91a76a3fbdd14354dffbbc052eaa52
parentc6b8b454f3
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 14:57
feat(vk): the 3D pass's light and the tracer's environment are settable

VkRenderer::set_scene_light sets the direction toward the raster flat
shading's light (a uniform where it was a constant in scene3d.wgsl).
RtEnvironment - sun direction, sun radiance, zenith and nadir sky
colours - is the path tracer's light, set through
VkRenderer::set_rt_environment / RtOffscreen::set_environment; a change
restarts the accumulation. Both default to what they always were, so a
host that sets neither sees no change.

test_offscreen_environment_lights_the_scene (ignored; needs Vulkan).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/vk/mod.rs         |  2 +-
 src/vk/renderer.rs    | 26 +++++++++++++-
 src/vk/rt.rs          | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/vk/rt_common.wgsl | 16 ++++++---
 src/vk/scene.rs       | 19 ++++++++--
 src/vk/scene3d.wgsl   |  8 +++--
 6 files changed, 158 insertions(+), 11 deletions(-)

diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index 1d23520..41bd80e 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -55,7 +55,7 @@ pub use image::{
     upload_rgba_mipmapped, ImageQuad, PixelFormat,
 };
 pub use renderer::{Batch2D, Frame2D, PlatePush, VkRenderer, MAX_PLATE_FEATURES};
-pub use rt::{RtCamera, RtImage, RtImagePixels, RtMaterial, RtOffscreen, RtTriangle};
+pub use rt::{RtCamera, RtEnvironment, RtImage, RtImagePixels, RtMaterial, RtOffscreen, RtTriangle};
 pub use scene::{MeshId, SceneDraw, SceneImage, Vertex3D};
 pub use text::TextSpan;
 
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 63849eb..d646c34 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -19,7 +19,7 @@ use crate::engine::Vertex;
 
 use super::core::SurfaceLost;
 use super::image::{ImageQuad, ImageStage};
-use super::rt::{RtCamera, RtImage, RtImageSource, RtMaterial, RtStage, RtTriangle};
+use super::rt::{RtCamera, RtEnvironment, RtImage, RtImageSource, RtMaterial, RtStage, RtTriangle};
 use super::scene::{MeshId, SceneDraw, SceneImage, SceneStage, Vertex3D};
 use super::text::{TextSpan, TextStage};
 
@@ -399,6 +399,8 @@ pub struct VkRenderer {
     rt: Option<RtStage>,
     /// See [`VkRenderer::set_rt_background`].
     rt_background: Option<[f32; 3]>,
+    /// See [`VkRenderer::set_rt_environment`].
+    rt_environment: RtEnvironment,
 
     desired_extent: vk::Extent2D,
     corner_radius_px: f32,
@@ -1095,6 +1097,7 @@ impl VkRenderer {
             image,
             rt: None,
             rt_background: None,
+            rt_environment: RtEnvironment::default(),
             desired_extent: vk::Extent2D { width: width.max(1), height: height.max(1) },
             corner_radius_px,
             swapchain_dirty: false,
@@ -1680,6 +1683,26 @@ impl VkRenderer {
         );
     }
 
+    /// The direction TOWARD the 3D pass's light, in world space (any
+    /// length; zero is ignored). The flat shading reads it, and a host that
+    /// bakes smooth shading (`SceneDraw::prelit`) should light by the same
+    /// one. Until a host sets it the light is the one this pass always had.
+    /// A traced pane has its own, in [`RtEnvironment`]; a host that wants
+    /// the two views to agree hands both the same direction.
+    pub fn set_scene_light(&mut self, toward: [f32; 3]) {
+        let v = glam::Vec3::from_array(toward);
+        if v.length_squared() > 1e-12 {
+            self.scene.light = v.normalize().to_array();
+        }
+    }
+
+    /// The traced pane's sky and sun — see [`RtEnvironment`]. Kept here and
+    /// handed over at each `stage_rt`, like the background; a change
+    /// restarts the accumulation.
+    pub fn set_rt_environment(&mut self, environment: RtEnvironment) {
+        self.rt_environment = environment;
+    }
+
     /// 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.
@@ -1699,6 +1722,7 @@ impl VkRenderer {
     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.set_environment(self.rt_environment);
             rt.stage(
                 &self.core.device,
                 self.core.allocator.as_mut().unwrap(),
diff --git a/src/vk/rt.rs b/src/vk/rt.rs
index 70b463b..361162b 100644
--- a/src/vk/rt.rs
+++ b/src/vk/rt.rs
@@ -126,6 +126,45 @@ struct RtParams {
     img_u: [f32; 4],
     img_v: [f32; 4],
     background: [f32; 4],
+    // The environment, each xyz in a vec4 (w unused): toward the sun
+    // (unit), the sun's radiance, the sky overhead, the sky below.
+    sun_dir: [f32; 4],
+    sun_color: [f32; 4],
+    sky_zenith: [f32; 4],
+    sky_nadir: [f32; 4],
+}
+
+/// The traced scene's light: a sky that grades from `sky_nadir` straight
+/// down to `sky_zenith` straight up, and a sun — a bright lobe toward
+/// `sun_direction` of `sun_color` radiance. It is the tracer's ONLY light
+/// (nothing in a scene emits unless its material does). Colours are linear
+/// RGB and may exceed 1. [`Default`] is the studio sky the tracer always
+/// had.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct RtEnvironment {
+    /// Toward the sun, world space; any length (zero is straight up).
+    pub sun_direction: [f32; 3],
+    pub sun_color: [f32; 3],
+    pub sky_zenith: [f32; 3],
+    pub sky_nadir: [f32; 3],
+}
+
+impl Default for RtEnvironment {
+    fn default() -> Self {
+        Self {
+            sun_direction: [0.45, 0.75, 0.35],
+            sun_color: [8.0, 7.6, 6.8],
+            sky_zenith: [0.72, 0.82, 0.98],
+            sky_nadir: [0.32, 0.31, 0.35],
+        }
+    }
+}
+
+impl RtEnvironment {
+    fn sun_unit(&self) -> [f32; 3] {
+        let v = glam::Vec3::from_array(self.sun_direction);
+        if v.length_squared() > 1e-12 { v.normalize().to_array() } else { [0.0, 1.0, 0.0] }
+    }
 }
 
 // --- BVH construction (binned SAH) ---
@@ -871,6 +910,12 @@ pub(crate) struct RtStage {
     /// What a camera ray that meets nothing shows, linear RGB; None is the
     /// sky. See [`RtStage::set_background`].
     background: Option<[f32; 3]>,
+    /// The sky and sun. See [`RtStage::set_environment`].
+    environment: RtEnvironment,
+}
+
+fn v4([x, y, z]: [f32; 3]) -> [f32; 4] {
+    [x, y, z, 0.0]
 }
 
 impl RtStage {
@@ -1131,6 +1176,7 @@ impl RtStage {
                 spp: 1,
                 staged: false,
                 background: None,
+                environment: RtEnvironment::default(),
             }
         }
     }
@@ -1147,6 +1193,15 @@ impl RtStage {
         }
     }
 
+    /// The sky and sun the scene is lit by. A change restarts the
+    /// accumulation.
+    pub(crate) fn set_environment(&mut self, environment: RtEnvironment) {
+        if self.environment != environment {
+            self.environment = environment;
+            self.sample_index = 0;
+        }
+    }
+
     fn write_image_descriptor(
         device: &ash::Device,
         set: vk::DescriptorSet,
@@ -1863,6 +1918,10 @@ impl RtStage {
                 Some([r, g, b]) => [r, g, b, 1.0],
                 None => [0.0; 4],
             },
+            sun_dir: v4(self.environment.sun_unit()),
+            sun_color: v4(self.environment.sun_color),
+            sky_zenith: v4(self.environment.sky_zenith),
+            sky_nadir: v4(self.environment.sky_nadir),
         };
         let frame = &mut self.frames[frame_index];
         frame.uniforms.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()
@@ -2185,6 +2244,11 @@ impl RtOffscreen {
         self.stage.set_background(background);
     }
 
+    /// The sky and sun — see [`RtEnvironment`].
+    pub fn set_environment(&mut self, environment: RtEnvironment) {
+        self.stage.set_environment(environment);
+    }
+
     /// 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);
@@ -2806,4 +2870,38 @@ mod tests {
         off.set_background(None);
         assert_eq!(render(&mut off).0, sky, "None is the sky");
     }
+
+    /// The environment is the scene's light: a black one leaves a grey
+    /// triangle black, a sun in front of it lights it and the same sun
+    /// behind it does not, and the sky's colours are what a miss shows.
+    #[test]
+    #[ignore = "requires a Vulkan device"]
+    fn test_offscreen_environment_lights_the_scene() {
+        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 mut render = |env: RtEnvironment| {
+            off.set_environment(env);
+            let px = off.render(camera, w, h, 32);
+            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 dark = RtEnvironment { sun_direction: [0.0, 0.0, 1.0], sun_color: [0.0; 3], sky_zenith: [0.0; 3], sky_nadir: [0.0; 3] };
+        assert_eq!(render(dark), ([0, 0, 0], [0, 0, 0]), "no light, nothing seen");
+        let front = render(RtEnvironment { sun_color: [40.0; 3], ..dark }).1;
+        let behind = render(RtEnvironment { sun_direction: [0.0, 0.0, -1.0], sun_color: [40.0; 3], ..dark }).1;
+        assert!(front[0] > 100, "a sun in front lights the face: {front:?}");
+        assert!(behind[0] < front[0] / 4, "a sun behind it does not: {behind:?} against {front:?}");
+        let (corner, _) = render(RtEnvironment { sky_zenith: [0.0, 1.0, 0.0], sky_nadir: [0.0, 1.0, 0.0], ..dark });
+        assert!(corner[1] > 200 && corner[0] < 10, "a miss shows the sky's colour: {corner:?}");
+    }
 }
diff --git a/src/vk/rt_common.wgsl b/src/vk/rt_common.wgsl
index b0f5c1d..53b7e2f 100644
--- a/src/vk/rt_common.wgsl
+++ b/src/vk/rt_common.wgsl
@@ -44,6 +44,12 @@ struct Params {
     // the light, so the backdrop changes what is seen behind the scene and
     // not how the scene is lit.
     background: vec4<f32>,
+    // The environment (`RtEnvironment`), xyz each: toward the sun (unit),
+    // the sun's radiance, the sky overhead, the sky below. Linear.
+    sun_dir: vec4<f32>,
+    sun_color: vec4<f32>,
+    sky_zenith: vec4<f32>,
+    sky_nadir: vec4<f32>,
 }
 
 @group(0) @binding(0) var<uniform> params: Params;
@@ -94,13 +100,13 @@ struct HitInfo {
     tri: u32,
 }
 
-// A soft studio sky: vertical gradient plus one warm key light. This is the
-// only light source until emissive geometry shows up in scenes.
+// The environment (`RtEnvironment`): a vertical gradient plus one sun lobe.
+// The scene's only light unless a material emits. Its default is the soft
+// studio sky this was before it was a parameter.
 fn sky(rd: vec3<f32>) -> vec3<f32> {
     let t = clamp(rd.y * 0.5 + 0.5, 0.0, 1.0);
-    var s = mix(vec3<f32>(0.32, 0.31, 0.35), vec3<f32>(0.72, 0.82, 0.98), t);
-    let sun = normalize(vec3<f32>(0.45, 0.75, 0.35));
-    s = s + vec3<f32>(1.0, 0.95, 0.85) * pow(max(dot(rd, sun), 0.0), 48.0) * 8.0;
+    var s = mix(params.sky_nadir.rgb, params.sky_zenith.rgb, t);
+    s = s + params.sun_color.rgb * pow(max(dot(rd, params.sun_dir.xyz), 0.0), 48.0);
     return s;
 }
 
diff --git a/src/vk/scene.rs b/src/vk/scene.rs
index f69137e..6b14e4f 100644
--- a/src/vk/scene.rs
+++ b/src/vk/scene.rs
@@ -60,8 +60,9 @@ pub struct SceneDraw {
     /// FILL draws only: the vertex colours already carry their lighting, so
     /// the fragment shader skips its derivative-normal flat shading and
     /// draws them as they are. A host that wants SMOOTH shading bakes it —
-    /// the light is fixed in world space (see `scene3d.wgsl`), so lighting
-    /// per vertex from interpolated normals is exact for a static light,
+    /// the light is fixed in world space (`VkRenderer::set_scene_light`,
+    /// which the host should light by), so lighting per vertex from
+    /// interpolated normals is exact for a static light,
     /// and the vertex format needs no normal. False for an ordinary draw.
     pub prelit: bool,
     /// FILL draws only: draw through the SEE-THROUGH twin of the fill
@@ -122,8 +123,15 @@ struct SceneUniforms {
     /// 1.0 on `SceneDraw::prelit` draws: the flat shading is skipped too.
     prelit: f32,
     _pad: [f32; 1],
+    /// xyz: toward the light, world space, unit length; w unused.
+    light: [f32; 4],
 }
 
+/// The direction toward the raster pass's light until a host sets one —
+/// the light this pass always had, (-0.55, 0.45, 0.7) as the shader dotted
+/// it with its INWARD derivative normal, said the right way round.
+pub(crate) const DEFAULT_SCENE_LIGHT: [f32; 3] = [0.55, -0.45, -0.7];
+
 const UNIFORM_SIZE: vk::DeviceSize = std::mem::size_of::<SceneUniforms>() as vk::DeviceSize;
 
 struct Mesh {
@@ -186,6 +194,9 @@ pub(crate) struct SceneStage {
     staged: Option<StagedScene>,
     /// True once the backdrop holds rendered content worth copying to screen.
     pub(crate) backdrop_valid: bool,
+    /// Toward the flat shading's light, unit length — see
+    /// `VkRenderer::set_scene_light`.
+    pub(crate) light: [f32; 3],
 }
 
 impl SceneStage {
@@ -651,6 +662,7 @@ impl SceneStage {
                 frames,
                 staged: None,
                 backdrop_valid: false,
+                light: glam::Vec3::from_array(DEFAULT_SCENE_LIGHT).normalize().to_array(),
             };
             stage.resize(device, allocator, extent);
             stage
@@ -941,6 +953,7 @@ impl SceneStage {
         }
         let window_size = [self.extent.width as f32, self.extent.height as f32];
         let corner_shape = crate::layout::corner_shape();
+        let light = [self.light[0], self.light[1], self.light[2], 0.0];
         let mapped = frame.uniforms.allocation.as_mut().unwrap().mapped_slice_mut().unwrap();
         for (i, draw) in staged.draws.iter().enumerate() {
             let uniforms = SceneUniforms {
@@ -953,6 +966,7 @@ impl SceneStage {
                 is_wire: if draw.wireframe { 1.0 } else { 0.0 },
                 prelit: if draw.prelit { 1.0 } else { 0.0 },
                 _pad: [0.0; 1],
+                light,
             };
             let offset = (self.uniform_stride as usize) * i;
             mapped[offset..offset + UNIFORM_SIZE as usize]
@@ -969,6 +983,7 @@ impl SceneStage {
                 is_wire: 0.0,
                 prelit: 1.0,
                 _pad: [0.0; 1],
+                light,
             };
             let offset = (self.uniform_stride as usize) * (staged.draws.len() + j);
             mapped[offset..offset + UNIFORM_SIZE as usize]
diff --git a/src/vk/scene3d.wgsl b/src/vk/scene3d.wgsl
index 14a54ce..ae87e66 100644
--- a/src/vk/scene3d.wgsl
+++ b/src/vk/scene3d.wgsl
@@ -19,6 +19,9 @@ struct Uniforms {
     // smooth shading from vertex normals against the same world light):
     // skip the flat shading below so it is not applied twice.
     prelit: f32,
+    // xyz: the direction TOWARD the light, in world space
+    // (`VkRenderer::set_scene_light`). Unit length.
+    light: vec4<f32>,
 }
 
 @group(0) @binding(0) var<uniform> uniforms: Uniforms;
@@ -106,8 +109,9 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f {
         // side to a world azimuth the orbit visibly sweeps across; the wrap
         // term keeps a soft floor without |dot|'s ambiguity (the fill pass
         // culls to front faces, so the derivative normal's sign is stable).
-        let l = normalize(vec3f(-0.55, 0.45, 0.7));
-        let d = clamp(dot(n, l) * 0.5 + 0.5, 0.0, 1.0);
+        // `n` is screen-right x framebuffer-DOWN, which for a visible
+        // face points INTO the surface, so the outward normal is `-n`.
+        let d = clamp(dot(-n, uniforms.light.xyz) * 0.5 + 0.5, 0.0, 1.0);
         rgb = rgb * (0.55 + 0.45 * d);
     }
     return vec4f(rgb, cov * uniforms.opacity);