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