GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(scene): a lit, textured mesh path beside Vertex3D (draw::lit)
Model files bring normals, texture coordinates and textures, and the scene
pass could show them only as flat or host-baked colours. A second mesh kind
does: LitVertex (position, normal, uv, colour) drawn by scene3d_lit.wgsl
with a LitMaterial (base colour, base-colour image id, metallic,
roughness) under a LitLight (key, fill, sky/ground ambient). Lambert and
GGX/Smith/Schlick per light, the hemisphere as diffuse ambient and as what
a metal reflects; the diffuse has no 1/pi, the scale hosts' baked light
always had, so a matte lit draw matches a baked one.
Additive: Stage3D::lit() defaults to None, so the WebGPU renderer needs no
change and hosts check; VkRenderer answers Some (LitStage3D: create /
update_lit_mesh, set_lit_light, stage_lit). LitDraw::before interleaves
lit draws with SceneDraws as SceneImage::before does.
The Vulkan stage: a lit pipeline on the image pipeline's layout (no
culling: a back face lights by its flipped normal; dynamic depth bias for
wire_base_width), lit uniforms in the per-frame buffer after the scene's and
the images' (the slot stride is now the larger block, 224 bytes), and a 1x1
white image, uploaded with the first lit mesh, bound for an untextured draw
or one whose image is not resident. The sampler clamps, so the shader wraps
uvs with fract and samples with the unwrapped uvs' gradients, which keeps
the wrap from picking the smallest mip.
probe3d draws pixel-identical before and after (0 of 2560x1600 px, scale-2
shadow), so the flat path is untouched; full suite passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 29 ++++++
build.rs | 2 +-
src/draw/lit.rs | 231 ++++++++++++++++++++++++++++++++++++++++++++++
src/draw/mod.rs | 1 +
src/draw/scene.rs | 7 ++
src/draw/scene3d_lit.wgsl | 166 +++++++++++++++++++++++++++++++++
src/draw/shaders.rs | 3 +
src/engine.rs | 1 +
src/vk/renderer.rs | 49 +++++++++-
src/vk/scene.rs | 202 +++++++++++++++++++++++++++++++++++++++-
10 files changed, 684 insertions(+), 7 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 3f2e81a..1f57aa5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -240,6 +240,35 @@ and scaled into place by its mvp, which the old shader shredded and this one dra
(Vulkan, scale-2 shadow; the WebGPU half shares the shader and `scene_uniforms` and was
not built — still no wasm32 target here).
+**A mesh can be lit and textured** (since 2026-10-07, `draw::lit`). Beside the
+`Vertex3D` path, which is a position and a colour shaded flat or pre-lit by the host,
+the scene pass has a second mesh kind for model files: a `LitVertex` (position,
+normal, uv, colour), drawn by `scene3d_lit.wgsl` with a `LitMaterial` (base colour,
+optional base-colour image id, metallic, roughness) under one `LitLight` (key, fill,
+sky/ground ambient). Lambert plus GGX/Smith/Schlick per light, the hemisphere as the
+diffuse ambient and as what a metal reflects; the diffuse has no 1/pi, the scale
+hosts' baked light always had, so a matte lit draw matches a baked one. It is reached
+through `Stage3D::lit()`, which **defaults to `None`**: the Vulkan renderer answers
+`Some(self)` (`LitStage3D`: `create_lit_mesh`, `update_lit_mesh`, `set_lit_light`,
+`stage_lit`), the WebGPU one does not yet, so it needed no change and a host checks.
+`LitDraw::before` interleaves lit draws with the staged `SceneDraw`s as
+`SceneImage::before` does (a host's background and grid first, wire overlays after).
+What the Vulkan stage does (`vk/scene.rs`): a lit pipeline on the image pipeline's
+layout (set 0 the per-frame dynamic uniforms, set 1 an image's descriptor set), no
+culling (the shader flips a back face's normal), dynamic depth bias for
+`wire_base_width`; lit uniform blocks share the per-frame buffer after the scene's and
+the images', so the slot stride is now the larger of the two blocks (`SLOT_SIZE`, 224
+bytes against the scene block's 128 — nothing that does not use lit draws changes but
+that stride); an untextured draw, or one whose image is not resident, binds a 1x1 white
+image the renderer uploads with the first lit mesh. Textures are ordinary image ids:
+RGBA8 sRGB, so texels reach the shader linear; they die with the renderer (re-upload
+in `init_3d`). The image sampler clamps, so the shader wraps uvs with `fract` and
+samples with the UNWRAPPED uvs' gradients (`textureSampleGrad`), or the wrap would
+pick the smallest mip and draw a seam. cce-model is the consumer. Verified 2026-10-07
+in a scale-2 shadow: a textured globe, a gold metal sphere and a rough red cube, the
+highlights moving with the camera; and `probe3d` drawn pixel-identical before and
+after the change (the flat path untouched).
+
**And so does the path tracer** (since 2026-10-05). `Stage3D` carries the tracer's half
too — `set_rt_scene` / `set_rt_scene_with_image`, `set_rt_environment`,
`set_rt_background`, `stage_rt`, `rt_accumulating` — and what it traces from moved to
diff --git a/build.rs b/build.rs
index c94eb04..633a805 100644
--- a/build.rs
+++ b/build.rs
@@ -15,7 +15,7 @@
use std::path::Path;
-const SHADERS: [&str; 4] = ["shader2d", "glyph", "scene3d", "scene3d_image"];
+const SHADERS: [&str; 5] = ["shader2d", "glyph", "scene3d", "scene3d_image", "scene3d_lit"];
fn main() {
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR");
diff --git a/src/draw/lit.rs b/src/draw/lit.rs
new file mode 100644
index 0000000..853d376
--- /dev/null
+++ b/src/draw/lit.rs
@@ -0,0 +1,231 @@
+//! The lit, textured mesh path of the 3D scene pass, beside the
+//! `Vertex3D` one and drawn in the same pass.
+//!
+//! A [`Vertex3D`](super::scene::Vertex3D) is a position and a colour, shaded
+//! flat or pre-lit by the host. A [`LitVertex`] carries what a model file
+//! carries — a normal, a texture coordinate and a colour — and is lit in the
+//! fragment shader (`scene3d_lit.wgsl`): a base colour (the material's,
+//! times the vertex colour, times a texel of its base-colour image), a
+//! metallic-roughness specular from GGX, a key and a fill light and a
+//! sky/ground ambient, all view-dependent, so a highlight moves as the
+//! camera does. It exists for files that bring their own normals and
+//! textures (glTF, OBJ with an MTL `map_Kd`), which the flat path could
+//! only show in a factor colour.
+//!
+//! It is ADDITIVE: [`Stage3D::lit`](super::scene::Stage3D::lit) answers
+//! `None` unless a renderer implements it (the Vulkan one does; the WebGPU
+//! one does not yet), so a host checks and keeps the flat path otherwise,
+//! and nothing that does not ask for it changed.
+//!
+//! Textures are ordinary image ids (`upload_rgba`): RGBA8 sRGB, so a texel
+//! reaches the shader linear, as an albedo must. They die with the renderer
+//! like every image id; a host re-uploads them in `init_3d`. The image
+//! sampler clamps, so the shader wraps texture coordinates itself (glTF
+//! repeats by default), sampling with the unwrapped coordinates' gradients
+//! so the wrap leaves no seam.
+
+/// One corner of a lit triangle. `uv` is in image convention: (0, 0) the
+/// top-left texel, as glTF has it (an OBJ `vt` needs its v flipped).
+#[repr(C)]
+#[derive(Debug, Clone, Copy, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
+pub struct LitVertex {
+ pub position: [f32; 3],
+ /// Unit length; the shader flips it for a face seen from behind, so open
+ /// and single-sided meshes light from both sides.
+ pub normal: [f32; 3],
+ pub uv: [f32; 2],
+ /// Linear RGB, multiplying the material's base colour: white for none.
+ pub color: [f32; 3],
+}
+
+/// A lit mesh a renderer holds, as [`LitStage3D::create_lit_mesh`] named it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct LitMeshId(pub(crate) usize);
+
+/// How a lit surface answers the light (glTF's metallic-roughness model).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct LitMaterial {
+ /// Linear RGB.
+ pub base_color: [f32; 3],
+ /// An image id (`upload_rgba`) whose texels multiply `base_color`, or
+ /// `None`. A texture whose upload has not landed draws as `None`.
+ pub texture: Option<u32>,
+ /// 0 a dielectric (plastic, paint, wood), 1 a metal.
+ pub metallic: f32,
+ /// 0 a mirror, 1 fully rough.
+ pub roughness: f32,
+}
+
+impl Default for LitMaterial {
+ fn default() -> Self {
+ Self { base_color: [1.0; 3], texture: None, metallic: 0.0, roughness: 0.8 }
+ }
+}
+
+/// The light every lit draw is shaded by: a key and a fill (directions
+/// TOWARD them, world space, any length; colours linear and may exceed 1),
+/// and an ambient that blends from `ground` (a surface facing down) to `sky`
+/// (facing up), which also stands in for the environment a metal reflects.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct LitLight {
+ pub key_toward: [f32; 3],
+ pub key_color: [f32; 3],
+ pub fill_toward: [f32; 3],
+ pub fill_color: [f32; 3],
+ pub sky: [f32; 3],
+ pub ground: [f32; 3],
+}
+
+impl Default for LitLight {
+ fn default() -> Self {
+ Self {
+ key_toward: [-0.35, 0.75, 0.55],
+ key_color: [0.95; 3],
+ fill_toward: [0.75, 0.1, -0.1],
+ fill_color: [0.25; 3],
+ sky: [0.2; 3],
+ ground: [0.06; 3],
+ }
+ }
+}
+
+/// One lit draw in the staged scene.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct LitDraw {
+ pub mesh: LitMeshId,
+ pub mvp: [[f32; 4]; 4],
+ /// The camera's position in the space the mesh is in, for the specular.
+ pub eye: [f32; 3],
+ pub material: LitMaterial,
+ /// Whole-draw alpha multiplier (1 = opaque), as `SceneDraw::opacity`.
+ pub opacity: f32,
+ /// The width of a wire pass that will ride on this fill (0 = none), as
+ /// `SceneDraw::wire_base_width`: the fill is pushed back so the wires
+ /// win the depth test.
+ pub wire_base_width: f32,
+ /// Draw order: this draw renders before the `SceneDraw` at this index
+ /// of the staged list, `u32::MAX` after them all — as
+ /// `SceneImage::before`. A host drawing a background and a floor as
+ /// scene draws and wires over the model puts the model between.
+ pub before: u32,
+}
+
+/// What a renderer offers for lit meshes, reached through
+/// [`Stage3D::lit`](super::scene::Stage3D::lit).
+pub trait LitStage3D {
+ fn create_lit_mesh(&mut self, verts: &[LitVertex]) -> LitMeshId;
+ /// Replace a lit mesh's vertices (a new model in the same slot).
+ fn update_lit_mesh(&mut self, id: LitMeshId, verts: &[LitVertex]);
+ /// The light every lit draw is shaded by, from now on.
+ fn set_lit_light(&mut self, light: LitLight);
+ /// This frame's lit draws, after [`Stage3D::stage_scene`](super::scene::Stage3D::stage_scene)
+ /// (nothing is drawn when no scene is staged).
+ fn stage_lit(&mut self, draws: Vec<LitDraw>);
+}
+
+/// `scene3d_lit.wgsl`'s uniform block. Its head is the scene block's (mvp,
+/// window size, corner radius and shape), so the corner cut is shared.
+#[repr(C)]
+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+pub(crate) struct LitUniforms {
+ mvp: [[f32; 4]; 4],
+ window_size: [f32; 2],
+ window_radius: f32,
+ corner_shape: f32,
+ /// xyz the eye, w unused.
+ eye: [f32; 4],
+ /// rgb the base colour, a the draw's opacity.
+ base: [f32; 4],
+ /// metallic, roughness, 1 when textured, unused.
+ surface: [f32; 4],
+ key_toward: [f32; 4],
+ key_color: [f32; 4],
+ fill_toward: [f32; 4],
+ fill_color: [f32; 4],
+ sky: [f32; 4],
+ ground: [f32; 4],
+}
+
+pub(crate) const LIT_UNIFORM_SIZE: usize = std::mem::size_of::<LitUniforms>();
+
+fn unit4(v: [f32; 3]) -> [f32; 4] {
+ let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
+ if l > 0.0 {
+ [v[0] / l, v[1] / l, v[2] / l, 0.0]
+ } else {
+ [0.0, 1.0, 0.0, 0.0]
+ }
+}
+
+fn pad(v: [f32; 3]) -> [f32; 4] {
+ [v[0], v[1], v[2], 0.0]
+}
+
+/// The staged lit draws' uniform blocks, in order. A texture not resident
+/// in the renderer is bound as its white fallback, so "textured" with no
+/// texture yet draws the plain base colour.
+pub(crate) fn lit_uniforms(
+ draws: &[LitDraw],
+ light: &LitLight,
+ window_size: [f32; 2],
+ corner_radius_px: f32,
+) -> Vec<LitUniforms> {
+ let corner_shape = crate::layout::corner_shape();
+ draws
+ .iter()
+ .map(|d| {
+ let m = &d.material;
+ LitUniforms {
+ mvp: d.mvp,
+ window_size,
+ window_radius: corner_radius_px,
+ corner_shape,
+ eye: pad(d.eye),
+ base: [m.base_color[0], m.base_color[1], m.base_color[2], d.opacity],
+ surface: [
+ m.metallic.clamp(0.0, 1.0),
+ m.roughness.clamp(0.04, 1.0),
+ if m.texture.is_some() { 1.0 } else { 0.0 },
+ 0.0,
+ ],
+ key_toward: unit4(light.key_toward),
+ key_color: pad(light.key_color),
+ fill_toward: unit4(light.fill_toward),
+ fill_color: pad(light.fill_color),
+ sky: pad(light.sky),
+ ground: pad(light.ground),
+ }
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_uniform_block_is_what_the_shader_reads() {
+ // mvp, then window size/radius/shape, then nine vec4s.
+ assert_eq!(LIT_UNIFORM_SIZE, 64 + 16 + 9 * 16);
+ assert_eq!(std::mem::size_of::<LitVertex>(), 11 * 4);
+ }
+
+ #[test]
+ fn a_textured_draw_says_so_and_lights_are_normalized() {
+ let draw = LitDraw {
+ mesh: LitMeshId(0),
+ mvp: [[0.0; 4]; 4],
+ eye: [0.0, 0.0, 5.0],
+ material: LitMaterial { texture: Some(7), ..LitMaterial::default() },
+ opacity: 1.0,
+ wire_base_width: 0.0,
+ before: u32::MAX,
+ };
+ let light = LitLight { key_toward: [0.0, 3.0, 0.0], ..LitLight::default() };
+ let plain = LitDraw { material: LitMaterial::default(), ..draw };
+ let blocks = lit_uniforms(&[draw, plain], &light, [100.0, 100.0], 0.0);
+ assert_eq!(blocks[0].surface[2], 1.0);
+ assert_eq!(blocks[1].surface[2], 0.0);
+ assert_eq!(blocks[0].key_toward, [0.0, 1.0, 0.0, 0.0]);
+ }
+}
diff --git a/src/draw/mod.rs b/src/draw/mod.rs
index 4154786..524fc23 100644
--- a/src/draw/mod.rs
+++ b/src/draw/mod.rs
@@ -14,6 +14,7 @@ use cosmic_text::Buffer as TextBuffer;
pub mod glyphs;
pub mod images;
+pub mod lit;
pub mod rt;
pub mod scene;
pub mod shaders;
diff --git a/src/draw/scene.rs b/src/draw/scene.rs
index 942d599..ae14980 100644
--- a/src/draw/scene.rs
+++ b/src/draw/scene.rs
@@ -224,6 +224,13 @@ pub trait Stage3D {
/// True while another staged frame would still refine the traced image
/// — the app's cue to keep asking for frames.
fn rt_accumulating(&self) -> bool;
+
+ /// The lit, textured mesh path (`draw::lit`), when this renderer has
+ /// it: `None` by default, so a renderer without it needs no change and
+ /// a host keeps its flat path. The Vulkan renderer answers `Some`.
+ fn lit(&mut self) -> Option<&mut dyn super::lit::LitStage3D> {
+ None
+ }
}
/// A staged scene's uniform blocks, in the order its draws use them: one per
diff --git a/src/draw/scene3d_lit.wgsl b/src/draw/scene3d_lit.wgsl
new file mode 100644
index 0000000..e1b80ba
--- /dev/null
+++ b/src/draw/scene3d_lit.wgsl
@@ -0,0 +1,166 @@
+// A lit, textured mesh in the 3D scene pass: `LitDraw` (draw/lit.rs). The
+// uniform block's head (mvp, window size, corner radius and shape) is
+// scene3d.wgsl's, so the window-corner cut below is the same curve; the
+// rest is the draw's material and the scene's light. Group 1 is a user
+// image's descriptor set, as `SceneImage` binds it: the base-colour texture,
+// or a 1x1 white one for an untextured draw (a set must be bound either way).
+//
+// Shading is the metallic-roughness model at its plainest: Lambert diffuse
+// and a GGX / Smith / Schlick specular for each of two directional lights,
+// plus a sky/ground hemisphere that is both the diffuse ambient and what a
+// metal sees reflected. The diffuse term is albedo x light x N.L with no
+// 1/pi, the scale the host's baked lighting has always used, and the
+// specular is scaled to match, so a matte lit draw looks like a baked one.
+struct Uniforms {
+ mvp: mat4x4<f32>,
+ window_size: vec2<f32>,
+ window_radius: f32,
+ corner_shape: f32,
+ eye: vec4<f32>,
+ // rgb the base colour, a the draw's opacity.
+ base: vec4<f32>,
+ // metallic, roughness, textured (0/1), unused.
+ surface: vec4<f32>,
+ key_toward: vec4<f32>,
+ key_color: vec4<f32>,
+ fill_toward: vec4<f32>,
+ fill_color: vec4<f32>,
+ sky: vec4<f32>,
+ ground: vec4<f32>,
+}
+
+@group(0) @binding(0) var<uniform> u: Uniforms;
+@group(1) @binding(0) var t_base: texture_2d<f32>;
+@group(1) @binding(1) var s_base: sampler;
+
+const PI: f32 = 3.14159265;
+
+// scene3d.wgsl's window_corner_distance, kept in lockstep with it.
+fn window_corner_distance(pos: vec2<f32>) -> f32 {
+ let w = u.window_size.x;
+ let h = u.window_size.y;
+ let r = u.window_radius;
+
+ if (pos.x < 0.0 || pos.x > w || pos.y < 0.0 || pos.y > h) {
+ return 1e5;
+ }
+ if (r <= 0.0) {
+ return -1e5;
+ }
+ let q = abs(pos - vec2f(w * 0.5, h * 0.5)) - vec2f(w * 0.5 - r, h * 0.5 - r);
+ if (q.x > 0.0 && q.y > 0.0) {
+ let shape = u.corner_shape;
+ if (shape > 2.001) {
+ let lp = max(pow(pow(q.x, shape) + pow(q.y, shape), 1.0 / shape), 1e-4);
+ let g = vec2f(pow(q.x / lp, shape - 1.0), pow(q.y / lp, shape - 1.0));
+ return (lp - r) / max(length(g), 1e-4);
+ }
+ return length(q) - r;
+ }
+ return -1e5;
+}
+
+struct VertexOutput {
+ @builtin(position) position: vec4f,
+ @location(0) world: vec3f,
+ @location(1) normal: vec3f,
+ @location(2) uv: vec2f,
+ @location(3) color: vec3f,
+};
+
+@vertex
+fn vs_main(
+ @location(0) position: vec3f,
+ @location(1) normal: vec3f,
+ @location(2) uv: vec2f,
+ @location(3) color: vec3f,
+) -> VertexOutput {
+ var out: VertexOutput;
+ out.position = u.mvp * vec4f(position, 1.0);
+ out.world = position;
+ out.normal = normal;
+ out.uv = uv;
+ out.color = color;
+ return out;
+}
+
+// The GGX distribution, for alpha = roughness^2.
+fn ggx(n_h: f32, a: f32) -> f32 {
+ let a2 = a * a;
+ let d = n_h * n_h * (a2 - 1.0) + 1.0;
+ return a2 / (PI * d * d);
+}
+
+// Smith-Schlick geometry term for both directions.
+fn smith(n_v: f32, n_l: f32, roughness: f32) -> f32 {
+ let k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
+ return (n_v / (n_v * (1.0 - k) + k)) * (n_l / (n_l * (1.0 - k) + k));
+}
+
+fn schlick(f0: vec3f, cos_theta: f32) -> vec3f {
+ return f0 + (vec3f(1.0) - f0) * pow(1.0 - cos_theta, 5.0);
+}
+
+// One directional light's contribution.
+fn shade(n: vec3f, v: vec3f, l: vec3f, color: vec3f, diffuse: vec3f, f0: vec3f, roughness: f32) -> vec3f {
+ let n_l = dot(n, l);
+ if (n_l <= 0.0) {
+ return vec3f(0.0);
+ }
+ let h = normalize(l + v);
+ let n_v = max(dot(n, v), 1e-4);
+ let n_h = max(dot(n, h), 0.0);
+ let v_h = max(dot(v, h), 0.0);
+ let a = roughness * roughness;
+ let spec = ggx(n_h, a) * smith(n_v, n_l, roughness) * schlick(f0, v_h) / (4.0 * n_v * n_l + 1e-4);
+ // x pi: the diffuse carries no 1/pi, so the specular is scaled alike.
+ return color * n_l * (diffuse + spec * PI);
+}
+
+@fragment
+fn fs_main(in: VertexOutput, @builtin(front_facing) front: bool) -> @location(0) vec4f {
+ // The sampler clamps, so the coordinates are wrapped here (glTF repeats
+ // by default), with the UNWRAPPED coordinates' gradients: fract jumps
+ // where the wrap falls, and its own gradients there would pick the
+ // smallest mip and draw a seam. Sampled ahead of any discard: implicit
+ // derivatives are undefined after non-uniform control flow.
+ let gx = dpdx(in.uv);
+ let gy = dpdy(in.uv);
+ let texel = textureSampleGrad(t_base, s_base, fract(in.uv), gx, gy);
+ let textured = u.surface.z > 0.5;
+
+ var albedo = u.base.rgb * in.color;
+ var alpha = u.base.a;
+ if (textured) {
+ albedo = albedo * texel.rgb;
+ alpha = alpha * texel.a;
+ }
+ let cov = 1.0 - smoothstep(-0.5, 0.5, window_corner_distance(in.position.xy));
+ alpha = alpha * cov;
+ if (alpha <= 0.002) {
+ discard;
+ }
+
+ // Both sides lit: a face seen from behind is shaded by its back.
+ var n = normalize(in.normal);
+ if (!front) {
+ n = -n;
+ }
+ let v = normalize(u.eye.xyz - in.world);
+ let metallic = u.surface.x;
+ let roughness = u.surface.y;
+ let diffuse = albedo * (1.0 - metallic);
+ let f0 = mix(vec3f(0.04), albedo, metallic);
+
+ let ambient = mix(u.ground.rgb, u.sky.rgb, 0.5 + 0.5 * n.y);
+ let r = reflect(-v, n);
+ let seen = mix(u.ground.rgb, u.sky.rgb, 0.5 + 0.5 * r.y);
+ let n_v = max(dot(n, v), 1e-4);
+ // Schlick with roughness: a rough surface reflects less at grazing.
+ let fr = f0 + (max(vec3f(1.0 - roughness), f0) - f0) * pow(1.0 - n_v, 5.0);
+
+ var color = diffuse * ambient + seen * fr;
+ color += shade(n, v, u.key_toward.xyz, u.key_color.rgb, diffuse, f0, roughness);
+ color += shade(n, v, u.fill_toward.xyz, u.fill_color.rgb, diffuse, f0, roughness);
+ return vec4f(color, alpha);
+}
diff --git a/src/draw/shaders.rs b/src/draw/shaders.rs
index 8ad7000..f10eedf 100644
--- a/src/draw/shaders.rs
+++ b/src/draw/shaders.rs
@@ -20,6 +20,9 @@ pub const SCENE3D: &str = include_str!("scene3d.wgsl");
/// The 3D scene pass's images: textured quads under the meshes' uniforms.
pub const SCENE3D_IMAGE: &str = include_str!("scene3d_image.wgsl");
+/// The lit, textured mesh path of the scene pass (`draw::lit`).
+pub const SCENE3D_LIT: &str = include_str!("scene3d_lit.wgsl");
+
/// The path tracer ([`super::rt`]): its shared core, then one trace tier
/// after it — the compute BVH traversal, or (Vulkan only) hardware ray
/// queries — and the à-trous denoiser that runs over its output.
diff --git a/src/engine.rs b/src/engine.rs
index c596e28..0f58940 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -5,6 +5,7 @@
pub use crate::backend::app::{
Application, AppSender, LogicalPosition, LogicalSize, RenderContext, Stage3D, WindowAction, WindowSettings,
};
+pub use crate::draw::lit::{LitDraw, LitLight, LitMaterial, LitMeshId, LitStage3D, LitVertex};
pub use crate::draw::rt::{PreparedRtScene, RtCamera, RtEnvironment, RtImage, RtMaterial, RtTriangle};
pub use crate::draw::scene::{MeshId, SceneDraw, SceneImage, Vertex3D};
pub use crate::backend::driver::PressedKey;
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index ed0eb88..2500640 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -529,6 +529,7 @@ precompiled_spirv!(shader2d_spirv, "shader2d.spv");
precompiled_spirv!(glyph_spirv, "glyph.spv");
precompiled_spirv!(scene3d_spirv, "scene3d.spv");
precompiled_spirv!(scene3d_image_spirv, "scene3d_image.spv");
+precompiled_spirv!(scene3d_lit_spirv, "scene3d_lit.spv");
/// Like [`compile_wgsl`], but with naga's RAY_QUERY capability and SPIR-V 1.4
/// (required by SPV_KHR_ray_query). Only used on devices where the ray-query
@@ -1783,6 +1784,33 @@ impl VkRenderer {
self.scene.stage_images(images);
}
+ /// Upload a lit mesh (`draw::lit`). The first one also uploads the 1x1
+ /// white image an untextured lit draw binds.
+ pub fn create_lit_mesh(&mut self, verts: &[crate::draw::lit::LitVertex]) -> crate::draw::lit::LitMeshId {
+ if self.scene.lit_fallback_image.is_none() {
+ self.scene.lit_fallback_image = Some(self.upload_rgba_now(&[255, 255, 255, 255], 1, 1));
+ }
+ self.scene.create_lit_mesh(&self.core.device, self.core.allocator.as_mut().unwrap(), verts)
+ }
+
+ /// Replace a lit mesh's vertices; waits for the GPU first, as `update_mesh`.
+ pub fn update_lit_mesh(&mut self, id: crate::draw::lit::LitMeshId, verts: &[crate::draw::lit::LitVertex]) {
+ unsafe {
+ let _ = self.core.device.device_wait_idle();
+ }
+ self.scene.update_lit_mesh(&self.core.device, self.core.allocator.as_mut().unwrap(), id, verts);
+ }
+
+ /// The light lit draws are shaded by.
+ pub fn set_lit_light(&mut self, light: crate::draw::lit::LitLight) {
+ self.scene.lit_light = light;
+ }
+
+ /// This frame's lit draws, after `stage_scene`.
+ pub fn stage_lit(&mut self, draws: Vec<crate::draw::lit::LitDraw>) {
+ self.scene.stage_lit(draws);
+ }
+
/// Replace the path tracer's scene (triangles in the space the camera's
/// `inv_mvp` unprojects into). Builds the BVH on the CPU and uploads it;
/// waits for the GPU to go idle first — scene replacement is rare
@@ -2783,6 +2811,24 @@ impl crate::draw::scene::Stage3D for VkRenderer {
fn rt_accumulating(&self) -> bool {
VkRenderer::rt_accumulating(self)
}
+ fn lit(&mut self) -> Option<&mut dyn crate::draw::lit::LitStage3D> {
+ Some(self)
+ }
+}
+
+impl crate::draw::lit::LitStage3D for VkRenderer {
+ fn create_lit_mesh(&mut self, verts: &[crate::draw::lit::LitVertex]) -> crate::draw::lit::LitMeshId {
+ VkRenderer::create_lit_mesh(self, verts)
+ }
+ fn update_lit_mesh(&mut self, id: crate::draw::lit::LitMeshId, verts: &[crate::draw::lit::LitVertex]) {
+ VkRenderer::update_lit_mesh(self, id, verts)
+ }
+ fn set_lit_light(&mut self, light: crate::draw::lit::LitLight) {
+ VkRenderer::set_lit_light(self, light)
+ }
+ fn stage_lit(&mut self, draws: Vec<crate::draw::lit::LitDraw>) {
+ VkRenderer::stage_lit(self, draws)
+ }
}
impl Drop for VkRenderer {
@@ -2923,11 +2969,12 @@ mod tests {
/// longer what this crate means by the shader; compare word for word.
#[test]
fn precompiled_spirv_matches_runtime_compile() {
- let cases: [(&str, &str, fn() -> &'static [u32]); 4] = [
+ let cases: [(&str, &str, fn() -> &'static [u32]); 5] = [
("shader2d", crate::draw::shaders::SHADER2D, super::shader2d_spirv),
("glyph", crate::draw::shaders::GLYPH, super::glyph_spirv),
("scene3d", crate::draw::shaders::SCENE3D, super::scene3d_spirv),
("scene3d_image", crate::draw::shaders::SCENE3D_IMAGE, super::scene3d_image_spirv),
+ ("scene3d_lit", crate::draw::shaders::SCENE3D_LIT, super::scene3d_lit_spirv),
];
for (name, source, precompiled) in cases {
assert!(
diff --git a/src/vk/scene.rs b/src/vk/scene.rs
index 25a7c68..9ca2ed3 100644
--- a/src/vk/scene.rs
+++ b/src/vk/scene.rs
@@ -17,9 +17,16 @@ use gpu_allocator::MemoryLocation;
use super::renderer::{create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
pub use crate::draw::scene::{MeshId, SceneDraw, SceneImage, Vertex3D};
+use crate::draw::lit::{lit_uniforms, LitDraw, LitLight, LitMeshId, LitVertex, LIT_UNIFORM_SIZE};
use crate::draw::scene::{image_quads_3d, scene_uniforms, wire_base_bias, ImageVertex3D, SceneUniforms, DEFAULT_SCENE_LIGHT, UNIT_INSTANCE};
const UNIFORM_SIZE: vk::DeviceSize = std::mem::size_of::<SceneUniforms>() as vk::DeviceSize;
+/// One uniform slot holds either a scene block or a lit one.
+const SLOT_SIZE: vk::DeviceSize = if (LIT_UNIFORM_SIZE as vk::DeviceSize) > UNIFORM_SIZE {
+ LIT_UNIFORM_SIZE as vk::DeviceSize
+} else {
+ UNIFORM_SIZE
+};
struct Mesh {
buffer: AllocatedBuffer,
@@ -30,6 +37,7 @@ struct StagedScene {
scissor: (u32, u32, u32, u32),
draws: Vec<SceneDraw>,
images: Vec<SceneImage>,
+ lit: Vec<LitDraw>,
}
struct SceneFrame {
@@ -65,6 +73,17 @@ pub(crate) struct SceneStage {
image_pipeline_layout: vk::PipelineLayout,
image_set_layout: vk::DescriptorSetLayout,
image_shader_module: vk::ShaderModule,
+ /// The `LitDraw` pipeline (`draw::lit`): set 0 the scene's uniforms
+ /// (a lit block in the slot), set 1 the base-colour image's set, as the
+ /// image pipeline has it. No culling: the shader lights a back face by
+ /// its flipped normal.
+ lit_pipeline: vk::Pipeline,
+ lit_shader_module: vk::ShaderModule,
+ lit_meshes: Vec<Mesh>,
+ pub(crate) lit_light: LitLight,
+ /// A 1x1 white image the renderer uploads, bound for an untextured lit
+ /// draw (and one whose texture is not resident): set 1 must be bound.
+ pub(crate) lit_fallback_image: Option<u32>,
format: vk::Format,
extent: vk::Extent2D,
@@ -500,7 +519,74 @@ impl SceneStage {
.expect("Failed to create 3D image pipeline")[0]
};
- let uniform_stride = UNIFORM_SIZE.next_multiple_of(min_uniform_align.max(1));
+ // The lit pipeline: the fill's pass, blend, depth and dynamic
+ // depth-bias state, the image pipeline's layout, no culling, and
+ // a `LitVertex`.
+ let lit_shader_module = device
+ .create_shader_module(
+ &vk::ShaderModuleCreateInfo::default().code(super::renderer::scene3d_lit_spirv()),
+ None,
+ )
+ .expect("Failed to create 3D lit shader module");
+ let lit_pipeline = {
+ let stages = [
+ vk::PipelineShaderStageCreateInfo::default()
+ .stage(vk::ShaderStageFlags::VERTEX)
+ .module(lit_shader_module)
+ .name(c"vs_main"),
+ vk::PipelineShaderStageCreateInfo::default()
+ .stage(vk::ShaderStageFlags::FRAGMENT)
+ .module(lit_shader_module)
+ .name(c"fs_main"),
+ ];
+ let vertex_bindings = [vk::VertexInputBindingDescription::default()
+ .binding(0)
+ .stride(std::mem::size_of::<LitVertex>() as u32)
+ .input_rate(vk::VertexInputRate::VERTEX)];
+ let attribute = |location: u32, format: vk::Format, offset: u32| {
+ vk::VertexInputAttributeDescription::default()
+ .location(location)
+ .binding(0)
+ .format(format)
+ .offset(offset)
+ };
+ let vertex_attributes = [
+ attribute(0, vk::Format::R32G32B32_SFLOAT, 0),
+ attribute(1, vk::Format::R32G32B32_SFLOAT, 12),
+ attribute(2, vk::Format::R32G32_SFLOAT, 24),
+ attribute(3, vk::Format::R32G32B32_SFLOAT, 32),
+ ];
+ let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
+ .vertex_binding_descriptions(&vertex_bindings)
+ .vertex_attribute_descriptions(&vertex_attributes);
+ let rasterization = vk::PipelineRasterizationStateCreateInfo::default()
+ .polygon_mode(vk::PolygonMode::FILL)
+ .cull_mode(vk::CullModeFlags::NONE)
+ .front_face(vk::FrontFace::COUNTER_CLOCKWISE)
+ .depth_bias_enable(true)
+ .line_width(1.0);
+ device
+ .create_graphics_pipelines(
+ vk::PipelineCache::null(),
+ &[vk::GraphicsPipelineCreateInfo::default()
+ .stages(&stages)
+ .vertex_input_state(&vertex_input)
+ .input_assembly_state(&input_assembly)
+ .viewport_state(&viewport_state)
+ .rasterization_state(&rasterization)
+ .multisample_state(&multisample)
+ .depth_stencil_state(&depth_stencil)
+ .color_blend_state(&color_blend)
+ .dynamic_state(&dynamic_state)
+ .layout(image_pipeline_layout)
+ .render_pass(render_pass)
+ .subpass(0)],
+ None,
+ )
+ .expect("Failed to create 3D lit pipeline")[0]
+ };
+
+ let uniform_stride = SLOT_SIZE.next_multiple_of(min_uniform_align.max(1));
let pool_sizes = [vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
@@ -571,6 +657,11 @@ impl SceneStage {
image_pipeline_layout,
image_set_layout,
image_shader_module,
+ lit_pipeline,
+ lit_shader_module,
+ lit_meshes: Vec::new(),
+ lit_light: LitLight::default(),
+ lit_fallback_image: None,
format,
extent: vk::Extent2D { width: 0, height: 0 },
backdrop_image: vk::Image::null(),
@@ -854,7 +945,53 @@ impl SceneStage {
}
pub(crate) fn stage(&mut self, scissor: (u32, u32, u32, u32), draws: Vec<SceneDraw>) {
- self.staged = Some(StagedScene { scissor, draws, images: Vec::new() });
+ self.staged = Some(StagedScene { scissor, draws, images: Vec::new(), lit: Vec::new() });
+ }
+
+ /// The staged scene's lit draws; nothing when no scene is staged.
+ pub(crate) fn stage_lit(&mut self, draws: Vec<LitDraw>) {
+ if let Some(staged) = &mut self.staged {
+ staged.lit = draws;
+ }
+ }
+
+ pub(crate) fn create_lit_mesh(&mut self, device: &ash::Device, allocator: &mut Allocator, verts: &[LitVertex]) -> LitMeshId {
+ let bytes: &[u8] = bytemuck::cast_slice(verts);
+ let mut buffer = create_cpu_buffer(
+ device,
+ allocator,
+ (bytes.len() as vk::DeviceSize).max(64),
+ vk::BufferUsageFlags::VERTEX_BUFFER,
+ "lit-mesh",
+ );
+ if !bytes.is_empty() {
+ buffer.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..bytes.len()].copy_from_slice(bytes);
+ }
+ self.lit_meshes.push(Mesh { buffer, count: verts.len() as u32 });
+ LitMeshId(self.lit_meshes.len() - 1)
+ }
+
+ /// Replace a lit mesh's vertices. Caller must have the device idle, as
+ /// for `update_mesh`.
+ pub(crate) fn update_lit_mesh(&mut self, device: &ash::Device, allocator: &mut Allocator, id: LitMeshId, verts: &[LitVertex]) {
+ let mesh = &mut self.lit_meshes[id.0];
+ let bytes: &[u8] = bytemuck::cast_slice(verts);
+ let needed = bytes.len() as vk::DeviceSize;
+ if needed > mesh.buffer.size {
+ let mut old = std::mem::replace(&mut mesh.buffer, AllocatedBuffer::null());
+ destroy_cpu_buffer(device, allocator, &mut old);
+ mesh.buffer = create_cpu_buffer(
+ device,
+ allocator,
+ needed.next_power_of_two(),
+ vk::BufferUsageFlags::VERTEX_BUFFER,
+ "lit-mesh",
+ );
+ }
+ if !bytes.is_empty() {
+ mesh.buffer.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..bytes.len()].copy_from_slice(bytes);
+ }
+ mesh.count = verts.len() as u32;
}
/// The staged scene's images; nothing when no scene is staged.
@@ -878,8 +1015,8 @@ impl SceneStage {
return;
};
let frame = &mut self.frames[frame_index];
- // One slot per mesh draw, then one per image.
- let slots = staged.draws.len() + staged.images.len();
+ // One slot per mesh draw, then one per image, then one per lit draw.
+ let slots = staged.draws.len() + staged.images.len() + staged.lit.len();
let needed = self.uniform_stride * slots.max(1) as vk::DeviceSize;
if needed > frame.uniforms.size {
let mut old = std::mem::replace(&mut frame.uniforms, AllocatedBuffer::null());
@@ -900,6 +1037,11 @@ impl SceneStage {
let offset = (self.uniform_stride as usize) * i;
mapped[offset..offset + UNIFORM_SIZE as usize].copy_from_slice(bytemuck::bytes_of(uniforms));
}
+ let lit = lit_uniforms(&staged.lit, &self.lit_light, window_size, corner_radius_px);
+ for (k, uniforms) in lit.iter().enumerate() {
+ let offset = (self.uniform_stride as usize) * (blocks.len() + k);
+ mapped[offset..offset + LIT_UNIFORM_SIZE].copy_from_slice(bytemuck::bytes_of(uniforms));
+ }
frame.draw_count = staged.draws.len() as u32;
let verts = image_quads_3d(&staged.images);
@@ -1021,7 +1163,54 @@ impl SceneStage {
device.cmd_draw(cmd, 6, 1, (j * 6) as u32, 0);
}
};
+ // The lit draws due before mesh draw `at`, likewise.
+ let lit_base = staged.draws.len() + staged.images.len();
+ let fallback = self.lit_fallback_image.and_then(|id| images.descriptor_set(id));
+ let draw_lit = |at: usize, bound: &mut vk::Pipeline| {
+ for (k, lit) in staged.lit.iter().enumerate() {
+ let due = (lit.before as usize).min(staged.draws.len());
+ if due != at {
+ continue;
+ }
+ let mesh = &self.lit_meshes[lit.mesh.0];
+ if mesh.count == 0 {
+ continue;
+ }
+ let set = lit.material.texture.and_then(|id| images.descriptor_set(id)).or(fallback);
+ let Some(set) = set else { continue };
+ if *bound != self.lit_pipeline {
+ device.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, self.lit_pipeline);
+ *bound = self.lit_pipeline;
+ }
+ if lit.wire_base_width > 0.0 {
+ let w = lit.wire_base_width.clamp(1.0, self.max_line_width);
+ let (constant, slope) = wire_base_bias(w);
+ device.cmd_set_depth_bias(cmd, constant, 0.0, slope);
+ } else {
+ device.cmd_set_depth_bias(cmd, 0.0, 0.0, 0.0);
+ }
+ device.cmd_bind_descriptor_sets(
+ cmd,
+ vk::PipelineBindPoint::GRAPHICS,
+ self.image_pipeline_layout,
+ 0,
+ &[frame.descriptor_set],
+ &[(self.uniform_stride as u32) * (lit_base + k) as u32],
+ );
+ device.cmd_bind_descriptor_sets(
+ cmd,
+ vk::PipelineBindPoint::GRAPHICS,
+ self.image_pipeline_layout,
+ 1,
+ &[set],
+ &[],
+ );
+ device.cmd_bind_vertex_buffers(cmd, 0, &[mesh.buffer.buffer], &[0]);
+ device.cmd_draw(cmd, mesh.count, 1, 0, 0);
+ }
+ };
for (i, draw) in staged.draws.iter().enumerate() {
+ draw_lit(i, &mut bound);
draw_images(i, &mut bound);
let mesh = &self.meshes[draw.mesh.0];
if mesh.count == 0 {
@@ -1075,6 +1264,7 @@ impl SceneStage {
device.cmd_bind_vertex_buffers(cmd, 0, &[mesh.buffer.buffer, instance_buffer], &[0, 0]);
device.cmd_draw(cmd, mesh.count, instance_count, 0, 0);
}
+ draw_lit(staged.draws.len(), &mut bound);
draw_images(staged.draws.len(), &mut bound);
device.cmd_end_render_pass(cmd);
}
@@ -1091,7 +1281,7 @@ impl SceneStage {
let mut quads = std::mem::replace(&mut frame.image_verts, AllocatedBuffer::null());
destroy_cpu_buffer(device, allocator, &mut quads);
}
- for mesh in &mut self.meshes {
+ for mesh in self.meshes.iter_mut().chain(self.lit_meshes.iter_mut()) {
let mut buffer = std::mem::replace(&mut mesh.buffer, AllocatedBuffer::null());
destroy_cpu_buffer(device, allocator, &mut buffer);
}
@@ -1102,6 +1292,8 @@ impl SceneStage {
if let Some(p) = self.wireframe_pipeline.take() {
device.destroy_pipeline(p, None);
}
+ device.destroy_pipeline(self.lit_pipeline, None);
+ device.destroy_shader_module(self.lit_shader_module, None);
device.destroy_pipeline(self.image_pipeline, None);
device.destroy_pipeline_layout(self.image_pipeline_layout, None);
device.destroy_descriptor_set_layout(self.image_set_layout, None);