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

commit0e74e7072b268b553fe586a84c3c481cdc586c0c
parent518a1afc6c
authorClaude <noreply@anthropic.com>
date2026-10-05 06:37
W4b: 3D scenes draw in the browser, through a portable Stage3D

What an app stages a 3D scene through is now a trait,
draw::scene::Stage3D (create_mesh, update_mesh, stage_scene,
stage_scene_images, set_scene_light), implemented by VkRenderer (each
method its inherent one) and WebRenderer. The scene's types, its
uniform blocks, image quads and wire-base depth bias move to
draw::scene, and scene3d.wgsl / scene3d_image.wgsl to draw/, shared by
both renderers; vk and engine re-export them.

Two portable Application hooks take a &mut dyn Stage3D: init_3d (once
per renderer) and stage_3d (every frame, before the draw). Natively
renderer_init and stage_renderer forward to them by default, so an app
overriding the native hooks is untouched and one on the portable pair
runs on both shells; the browser shell calls them.

web/scene.rs is the Vulkan SceneStage's port: a full-size backdrop with
a depth buffer, copied under a UI pass that loads it and samples it for
blur. Depth bias is pipeline state in WebGPU and lines are one pixel,
so the wire-carrying fill is its own pipeline at wire_base_bias(1.0),
what a Vulkan device without wideLines uses. scene3d.wgsl takes its
derivatives at the top of fs_main, since WebGPU rejects one under a
branch on a varying; natively the probe is pixel-identical across it.

examples/probe3d/scene.rs, on the portable hooks, covers every path
of the pass; probe3d_native vs scripts/web-probe/probe3d (lavapipe vs
SwiftShader): 195 px differ by more than 8 levels, all on 1 px wires,
everything else within 2. Native is unchanged: the same two known test
failures, the plate golden byte-identical, the 24-step run identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ

 CLAUDE.md                           |  32 +++-
 Cargo.toml                          |  10 ++
 examples/probe3d/scene.rs           | 252 +++++++++++++++++++++++++++
 examples/probe3d_native.rs          |   9 +
 examples/probe3d_web.rs             |  27 +++
 scripts/check-wasm                  |   2 +-
 scripts/web-probe/browser.mjs       |   9 +-
 scripts/web-probe/capture-app.mjs   |  20 +++
 scripts/web-probe/demo.html         |  13 +-
 scripts/web-probe/probe3d           |  26 +++
 src/backend/app.rs                  |  34 +++-
 src/draw/mod.rs                     |   1 +
 src/draw/scene.rs                   | 210 ++++++++++++++++++++++
 src/{vk => draw}/scene3d.wgsl       |   8 +-
 src/{vk => draw}/scene3d_image.wgsl |   0
 src/draw/shaders.rs                 |   9 +-
 src/engine.rs                       |   3 +-
 src/vk/renderer.rs                  |  24 ++-
 src/vk/scene.rs                     | 180 ++-----------------
 src/web/mod.rs                      |   1 +
 src/web/renderer.rs                 |  96 ++++++++--
 src/web/scene.rs                    | 339 ++++++++++++++++++++++++++++++++++++
 src/web/shell.rs                    |  12 +-
 23 files changed, 1108 insertions(+), 209 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 0cce10c..ff7c48c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -136,6 +136,32 @@ bit (lavapipe vs SwiftShader, 2026-10-05). A reference written for a length that
 a multiple of four floats must know that `arrayLength` counts the 16-byte padding, on
 both devices.
 
+**3D scenes draw in the browser too** (`Stage3D`, since 2026-10-05). What an app stages a
+scene through is a trait, `draw::scene::Stage3D` (`create_mesh`, `update_mesh`,
+`stage_scene`, `stage_scene_images`, `set_scene_light`), implemented by `VkRenderer` (each
+method its inherent one) and `WebRenderer`; the scene's types (`Vertex3D`, `MeshId`,
+`SceneDraw`, `SceneImage`), its uniform blocks (`scene_uniforms`), image quads and the
+wire-base depth bias (`wire_base_bias`) moved to `draw::scene`, and `scene3d.wgsl` /
+`scene3d_image.wgsl` to `draw/`, shared by both renderers; `vk` and `engine` re-export them.
+Two portable `Application` hooks take a `&mut dyn Stage3D`: **`init_3d`** (once per
+renderer — make meshes) and **`stage_3d`** (every frame, just before the draw; true asks
+for another frame). Natively `renderer_init` and `stage_renderer` forward to them by
+default, as `new` forwards to `create`, so an app that overrides the native hooks (the
+designer) is untouched, and one that moves to the portable pair runs on both shells. The
+WebGPU pass (`web/scene.rs`) is the Vulkan `SceneStage`'s port: a full-size backdrop in the
+canvas's sRGB view format with a depth32 buffer, copied into the canvas under a UI pass
+that LOADS it and samples it for blur — and kept, as on Vulkan, until the next staged
+scene. Depth bias is pipeline state in WebGPU, and a WebGPU line is one pixel (no
+wideLines), so the biased fill is a pipeline of its own at `wire_base_bias(1.0)` — what a
+Vulkan device without wideLines uses. `scene3d.wgsl` takes its derivatives at the top of
+`fs_main` (WebGPU rejects one under a branch on a varying); natively pixel-identical.
+`examples/probe3d/scene.rs` is the check, on the portable hooks — background quad, flat and
+prelit fills, a wire-carrying fill and its wires, a see-through fill and its edges, an
+image in the scene before the translucent draw, a host light, frost over the pane — run by
+`probe3d_native` and `scripts/web-probe/probe3d`: 2026-10-05, lavapipe vs SwiftShader,
+195 px differ by more than 8 levels, all on 1 px wires (where along its length a line
+steps a row is the rasterizer's), everything else within 2.
+
 **The reference app runs on both, through one input script.** `examples/demo_web.rs` is
 `src/main.rs`'s `DemoApp` (included by `#[path]`, hence `pub(crate)`) in a page;
 `scripts/web-probe/demo <dir>` builds it, serves it with the machine's fonts and replays the
@@ -209,6 +235,9 @@ Key methods (see the trait def in `backend/app.rs`):
 - **Input**: `handle_pointer_move`, `handle_mouse_input`, `handle_mouse_wheel`,
   `handle_key_input` — most return an optional `Message`. `needs_rebuild: &mut bool` is how a
   handler requests a redraw; the loop is demand-driven and idles when nothing sets it.
+- **3D**: `init_3d(stage)` / `stage_3d(stage, size, scale)` — the portable pair, through
+  `Stage3D` (see "3D scenes draw in the browser too"); the native `renderer_init` /
+  `stage_renderer` take the `VkRenderer` itself and forward to them by default.
 - `ui_context()` / `ui_context_mut()` expose the widget tree (`UiContext`) for apps built on the
   retained widget system rather than immediate drawing.
 - **Undo/redo**: the runner owns the routing. A press matching the `undo` / `redo` chord
@@ -1248,7 +1277,8 @@ cce-system-interface) to confirm behavior, not just the test suite.
   Vulkan path: an sRGB VIEW of the canvas's unorm format, the parameter block as a
   dynamic-offset uniform, a 1x1 backdrop, the blur snapshot as end-pass / copy / resume,
   every frame drawn whole. And `shell.rs`, the browser shell: `run`, `Fonts`, `Sizing`,
-  `capture` (see "And an `Application` runs in a page" above); `compute.rs`, the async
+  `capture` (see "And an `Application` runs in a page" above); `scene.rs`, the 3D pass;
+  `compute.rs`, the async
   `ComputeDevice`; and `request_device`, the adapter and device every one of them asks
   for (with the limits a caller names raised to the adapter's).
 - `protocol.rs` — inline-generated Wayland protocol bindings.
diff --git a/Cargo.toml b/Cargo.toml
index c2ad963..3086395 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -174,6 +174,11 @@ web-sys = { version = "0.3", features = [
     "GpuComputePassDescriptor",
     "GpuErrorFilter",
     "GpuError",
+    "GpuDepthStencilState",
+    "GpuCompareFunction",
+    "GpuCullMode",
+    "GpuFrontFace",
+    "GpuRenderPassDepthStencilAttachment",
 ] }
 
 # The renderer probe: one scene through both renderers (examples/probe/).
@@ -182,6 +187,11 @@ web-sys = { version = "0.3", features = [
 name = "probe_web"
 crate-type = ["cdylib"]
 
+# The 3D probe's browser half (examples/probe3d/); empty natively.
+[[example]]
+name = "probe3d_web"
+crate-type = ["cdylib"]
+
 # The compute probe's browser half (examples/compute_probe/); empty natively.
 [[example]]
 name = "compute_web"
diff --git a/examples/probe3d/scene.rs b/examples/probe3d/scene.rs
new file mode 100644
index 0000000..028c116
--- /dev/null
+++ b/examples/probe3d/scene.rs
@@ -0,0 +1,252 @@
+//! The 3D probe's scene: one app whose frame is a 3D pane under a strip of
+//! UI, staged through the portable `Application::init_3d` / `stage_3d`, so
+//! the same code runs on the Vulkan renderer (`probe3d_native`) and the
+//! WebGPU one (`probe3d_web`) and the two can be compared pixel for pixel.
+//!
+//! It reaches every path the scene pass has: the screen-space background
+//! quad (the z = 9.99 sentinel), flat-shaded fills (the derivative normal),
+//! a prelit fill, a fill carrying a wire overlay (the depth-biased fill and
+//! the line pipeline, wires tinted), a see-through translucent fill and the
+//! wires riding it, an image standing in the scene before the translucent
+//! draw, a light the host sets, and a frosted plate over the pane whose
+//! blur samples the backdrop the scene left.
+
+use cce_ui::engine::{
+    AppSender, Application, LogicalPosition, LogicalSize, MeshId, SceneDraw, SceneImage, Stage3D, Vertex3D, WindowSettings,
+};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{DisplayList, PaintCtx, PlateSpec};
+use cce_ui::scene::{Frost, Material};
+use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
+use glam::{Mat4, Vec3};
+
+pub const W: u32 = 1280;
+pub const H: u32 = 800;
+/// The UI strip's width; the 3D pane is the rest of the window.
+const STRIP: f32 = 300.0;
+
+pub struct Probe3d {
+    image: u32,
+    meshes: Option<Meshes>,
+}
+
+struct Meshes {
+    background: MeshId,
+    cube: MeshId,
+    prelit: MeshId,
+    sphere: MeshId,
+    sphere_wires: MeshId,
+    glass: MeshId,
+    glass_wires: MeshId,
+}
+
+fn r(x: f32, y: f32, w: f32, h: f32) -> Rect {
+    Rect { x, y, width: w, height: h }
+}
+
+fn v(p: Vec3, c: [f32; 3]) -> Vertex3D {
+    Vertex3D { position: p.to_array(), color: c }
+}
+
+/// A box's triangles, counter-clockwise seen from outside, a colour a face.
+fn cuboid(center: Vec3, half: Vec3, colors: [[f32; 3]; 6]) -> Vec<Vertex3D> {
+    let c = |x: f32, y: f32, z: f32| center + half * Vec3::new(x, y, z);
+    let faces = [
+        [c(1., -1., -1.), c(1., 1., -1.), c(1., 1., 1.), c(1., -1., 1.)],     // +x
+        [c(-1., -1., 1.), c(-1., 1., 1.), c(-1., 1., -1.), c(-1., -1., -1.)], // -x
+        [c(-1., 1., -1.), c(-1., 1., 1.), c(1., 1., 1.), c(1., 1., -1.)],     // +y
+        [c(-1., -1., 1.), c(-1., -1., -1.), c(1., -1., -1.), c(1., -1., 1.)], // -y
+        [c(-1., -1., 1.), c(1., -1., 1.), c(1., 1., 1.), c(-1., 1., 1.)],     // +z
+        [c(1., -1., -1.), c(-1., -1., -1.), c(-1., 1., -1.), c(1., 1., -1.)], // -z
+    ];
+    let mut out = Vec::new();
+    for (f, q) in faces.iter().enumerate() {
+        for i in [0, 1, 2, 0, 2, 3] {
+            out.push(v(q[i], colors[f]));
+        }
+    }
+    out
+}
+
+/// A box's twelve edges as vertex pairs.
+fn cuboid_edges(center: Vec3, half: Vec3, color: [f32; 3]) -> Vec<Vertex3D> {
+    let mut out = Vec::new();
+    for a in 0..8u32 {
+        for b in a + 1..8 {
+            if (a ^ b).count_ones() == 1 {
+                for k in [a, b] {
+                    let s = |bit: u32| if k & bit != 0 { 1.0 } else { -1.0 };
+                    out.push(v(center + half * Vec3::new(s(1), s(2), s(4)), color));
+                }
+            }
+        }
+    }
+    out
+}
+
+/// A UV sphere's triangles (outward, counter-clockwise) and its latitude and
+/// longitude lines as vertex pairs.
+fn sphere(center: Vec3, radius: f32, stacks: u32, slices: u32) -> (Vec<Vertex3D>, Vec<Vertex3D>) {
+    let p = |i: u32, j: u32| {
+        let th = std::f32::consts::PI * i as f32 / stacks as f32;
+        let ph = std::f32::consts::TAU * j as f32 / slices as f32;
+        center + radius * Vec3::new(th.sin() * ph.cos(), th.cos(), th.sin() * ph.sin())
+    };
+    let color = |q: Vec3| {
+        let n = (q - center) / radius;
+        [0.35 + 0.3 * n.x, 0.45 + 0.25 * n.y, 0.65 + 0.2 * n.z]
+    };
+    let mut tris = Vec::new();
+    let mut lines = Vec::new();
+    for i in 0..stacks {
+        for j in 0..slices {
+            let (a, b, c, d) = (p(i, j), p(i + 1, j), p(i + 1, j + 1), p(i, j + 1));
+            for q in [a, c, b, a, d, c] {
+                tris.push(v(q, color(q)));
+            }
+            for q in [a, b, a, d] {
+                lines.push(v(q, color(q)));
+            }
+        }
+    }
+    (tris, lines)
+}
+
+impl Probe3d {
+    fn camera(size: LogicalSize, scale: f64) -> (Mat4, (u32, u32, u32, u32)) {
+        let s = scale as f32;
+        let (pw, ph) = ((size.width - STRIP) * s, size.height * s);
+        let proj = Mat4::perspective_rh(40f32.to_radians(), pw / ph, 0.1, 100.0);
+        let view = Mat4::look_at_rh(Vec3::new(3.2, 2.4, 5.6), Vec3::new(0.0, 0.2, 0.0), Vec3::Y);
+        // The scissor is the pane; the projection is of the pane, offset into
+        // it by shifting NDC x (the pass draws in window space).
+        let ndc_w = pw / (size.width * s);
+        let shift = Mat4::from_translation(Vec3::new(1.0 - ndc_w, 0.0, 0.0)) * Mat4::from_scale(Vec3::new(ndc_w, 1.0, 1.0));
+        (shift * proj * view, ((STRIP * s) as u32, 0, pw as u32, ph as u32))
+    }
+}
+
+impl Application for Probe3d {
+    type Message = ();
+
+    fn create(_sender: AppSender<()>) -> Self {
+        let (iw, ih) = (64u32, 40u32);
+        let mut px = Vec::with_capacity((iw * ih * 4) as usize);
+        for y in 0..ih {
+            for x in 0..iw {
+                let a = if (x / 8 + y / 8) % 2 == 0 { 255 } else { 120 };
+                px.extend([(x * 255 / (iw - 1)) as u8, (y * 255 / (ih - 1)) as u8, 200, a]);
+            }
+        }
+        Probe3d { image: cce_ui::draw::upload_rgba(px, iw, ih), meshes: None }
+    }
+
+    fn settings(&self) -> WindowSettings {
+        WindowSettings { title: "probe3d".into(), app_id: "cce-probe3d".into(), width: W, height: H, fullscreen: false, min_size: None }
+    }
+
+    fn load_system_fonts(&self) -> bool {
+        true
+    }
+
+    fn display_list_text(&self) -> bool {
+        true
+    }
+
+    fn init_3d(&mut self, stage: &mut dyn Stage3D) {
+        let bg = |x: f32, y: f32, c: [f32; 3]| Vertex3D { position: [x, y, 9.99], color: c };
+        let (top, bottom) = ([0.10, 0.12, 0.20], [0.30, 0.26, 0.22]);
+        let background = stage.create_mesh(&[
+            bg(-1.0, -1.0, bottom),
+            bg(1.0, -1.0, bottom),
+            bg(1.0, 1.0, top),
+            bg(-1.0, -1.0, bottom),
+            bg(1.0, 1.0, top),
+            bg(-1.0, 1.0, top),
+        ]);
+        let shades = [[0.8, 0.3, 0.2], [0.7, 0.35, 0.25], [0.9, 0.5, 0.3], [0.5, 0.2, 0.15], [0.85, 0.4, 0.3], [0.6, 0.25, 0.2]];
+        let cube = stage.create_mesh(&cuboid(Vec3::new(-1.6, 0.0, 0.2), Vec3::splat(0.6), shades));
+        let floor = [[0.30, 0.32, 0.30]; 6];
+        let mut prelit_verts = cuboid(Vec3::new(0.0, -0.75, 0.0), Vec3::new(3.0, 0.05, 2.4), floor);
+        for (i, p) in prelit_verts.iter_mut().enumerate() {
+            let k = 0.7 + 0.3 * ((i / 6) as f32 / 5.0);
+            p.color = [p.color[0] * k, p.color[1] * k, p.color[2] * k];
+        }
+        let prelit = stage.create_mesh(&prelit_verts);
+        let (tris, lines) = sphere(Vec3::new(0.4, 0.3, -0.4), 0.9, 12, 20);
+        let sphere = stage.create_mesh(&tris);
+        let sphere_wires = stage.create_mesh(&lines);
+        let glass_c = Vec3::new(1.5, 0.1, 1.2);
+        let glass = stage.create_mesh(&cuboid(glass_c, Vec3::splat(0.55), [[0.3, 0.7, 0.9]; 6]));
+        let glass_wires = stage.create_mesh(&cuboid_edges(glass_c, Vec3::splat(0.55), [0.9, 0.95, 1.0]));
+        stage.set_scene_light([0.6, 0.7, 0.4]);
+        self.meshes = Some(Meshes { background, cube, prelit, sphere, sphere_wires, glass, glass_wires });
+    }
+
+    fn stage_3d(&mut self, stage: &mut dyn Stage3D, size: LogicalSize, scale: f64) -> bool {
+        let Some(m) = &self.meshes else { return false };
+        let (mvp, scissor) = Self::camera(size, scale);
+        let mvp = mvp.to_cols_array_2d();
+        let draw = |mesh| SceneDraw {
+            mesh,
+            mvp,
+            wireframe: false,
+            wire_tint: [0.0; 4],
+            opacity: 1.0,
+            line_width: 1.0,
+            wire_base_width: 0.0,
+            prelit: false,
+            see_through: false,
+        };
+        let draws = vec![
+            draw(m.background),
+            SceneDraw { prelit: true, ..draw(m.prelit) },
+            draw(m.cube),
+            SceneDraw { wire_base_width: 1.0, ..draw(m.sphere) },
+            SceneDraw { wireframe: true, wire_tint: [1.0, 1.0, 1.0, 0.6], ..draw(m.sphere_wires) },
+            SceneDraw { see_through: true, opacity: 0.45, ..draw(m.glass) },
+            SceneDraw { wireframe: true, see_through: true, ..draw(m.glass_wires) },
+        ];
+        stage.stage_scene(scissor, draws);
+        stage.stage_scene_images(vec![SceneImage {
+            image: self.image,
+            corners: [[-2.6, 1.6, -1.6], [-0.6, 1.6, -1.6], [-0.6, 0.35, -1.6], [-2.6, 0.35, -1.6]],
+            mvp,
+            opacity: 0.9,
+            before: 5,
+        }]);
+        false
+    }
+
+    fn update(&mut self, _: (), _: &mut bool, _: &mut bool) {}
+    fn tick(&mut self, _: f32, _: &mut bool) {}
+
+    fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
+        let (_, h) = (size.width, size.height);
+        let mut pc = PaintCtx::new();
+        // The strip: a pane plate the UI stands on. The 3D pane is left
+        // unpainted, so the backdrop shows there.
+        pc.plate_spec(&PlateSpec {
+            rect: r(0.0, 0.0, STRIP, h),
+            material: Material::pane(),
+            window_corners: (true, false, false, true),
+            depth: cce_ui::layout::bevel_width(),
+        });
+        pc.text_with("3D probe".to_string(), 24.0, 24.0, 20.0, [230, 230, 236], Some("DejaVu Sans".into()), None);
+        // A frosted plate over the pane: its blur samples the backdrop.
+        let m = Material::opaque([0.0015, 0.0015, 0.0024, 0.25])
+            .with_frost(Frost::Frosted { compression: 0.6, refraction: 0.0, radius: Frost::DEFAULT_RADIUS });
+        pc.plate_spec(&PlateSpec { rect: r(STRIP + 40.0, h - 170.0, 420.0, 130.0), material: m, window_corners: (false, false, false, false), depth: 8.0 });
+        pc.text_with("frosted over the scene".to_string(), STRIP + 64.0, h - 120.0, 16.0, [240, 240, 246], Some("DejaVu Sans".into()), None);
+        Some(pc.finish())
+    }
+
+    fn handle_pointer_move(&mut self, _: LogicalPosition, _: &mut bool) {}
+    fn handle_mouse_input(&mut self, _: MouseButton, _: ElementState, _: LogicalPosition, _: &mut bool) -> Option<()> {
+        None
+    }
+    fn handle_mouse_wheel(&mut self, _: &MouseScrollDelta, _: LogicalPosition, _: &mut bool) {}
+    fn handle_key_input(&mut self, _: &KeyEvent, _: &mut bool) -> Option<()> {
+        None
+    }
+}
diff --git a/examples/probe3d_native.rs b/examples/probe3d_native.rs
new file mode 100644
index 0000000..5d3935f
--- /dev/null
+++ b/examples/probe3d_native.rs
@@ -0,0 +1,9 @@
+//! The 3D probe (`probe3d/scene.rs`) on the Vulkan renderer, in the Wayland
+//! runner: screenshot it and compare it with `probe3d_web`'s frame.
+
+#[path = "probe3d/scene.rs"]
+mod scene;
+
+fn main() {
+    cce_ui::engine::run::<scene::Probe3d>();
+}
diff --git a/examples/probe3d_web.rs b/examples/probe3d_web.rs
new file mode 100644
index 0000000..139e41e
--- /dev/null
+++ b/examples/probe3d_web.rs
@@ -0,0 +1,27 @@
+//! The 3D probe (`probe3d/scene.rs`) on the WebGPU renderer, in a browser:
+//! the browser shell runs it (`web::run`), and `capture()` reads a frame
+//! back. A cdylib for wasm-bindgen, served by `scripts/web-probe/probe3d`;
+//! natively it is empty.
+
+#[cfg(target_arch = "wasm32")]
+#[path = "probe3d/scene.rs"]
+mod scene;
+
+#[cfg(target_arch = "wasm32")]
+mod web {
+    use cce_ui::web::{Fonts, Sizing};
+    use wasm_bindgen::prelude::*;
+
+    #[wasm_bindgen]
+    pub async fn start(canvas: web_sys::HtmlCanvasElement, fonts: js_sys::Array, _families: String, _fill: bool) -> Result<(), JsValue> {
+        std::panic::set_hook(Box::new(|info| web_sys::console::error_1(&info.to_string().into())));
+        let fonts = Fonts::new(fonts.iter().map(|f| js_sys::Uint8Array::new(&f).to_vec()).collect());
+        cce_ui::web::run::<super::scene::Probe3d>(canvas, fonts, Sizing::Page).await
+    }
+
+    #[wasm_bindgen]
+    pub async fn capture() -> Result<js_sys::Uint8Array, JsValue> {
+        let frame = cce_ui::web::capture().await?;
+        Ok(js_sys::Uint8Array::from(&frame.rgba[..]))
+    }
+}
diff --git a/scripts/check-wasm b/scripts/check-wasm
index 7bb42a6..4c47bb9 100755
--- a/scripts/check-wasm
+++ b/scripts/check-wasm
@@ -18,5 +18,5 @@ if ! rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknow
 fi
 cargo check --lib --target wasm32-unknown-unknown
 cargo check --lib --target wasm32-unknown-unknown --features markdown,doc_editor
-cargo check --example probe_web --example demo_web --example compute_web --target wasm32-unknown-unknown
+cargo check --example probe_web --example demo_web --example compute_web --example probe3d_web --target wasm32-unknown-unknown
 echo "check-wasm: ok"
diff --git a/scripts/web-probe/browser.mjs b/scripts/web-probe/browser.mjs
index b38a467..68a1f2a 100644
--- a/scripts/web-probe/browser.mjs
+++ b/scripts/web-probe/browser.mjs
@@ -13,9 +13,10 @@ const require = createRequire(import.meta.url);
 const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
 const types = { '.html': 'text/html', '.js': 'text/javascript', '.wasm': 'application/wasm', '.json': 'application/json' };
 
-/// Open `page` (a file in `root`) at `width` x `height`. Resolves to
-/// { page, logs, close }; `logs` collects the page's console and errors.
-export async function open(root, file, width, height) {
+/// Open `page` (a file in `root`) at `width` x `height`, with `query` (a
+/// URL query string, `?…`) if given. Resolves to { page, logs, close };
+/// `logs` collects the page's console and errors.
+export async function open(root, file, width, height, query = '') {
   const server = http.createServer((q, r) => {
     const p = decodeURIComponent(q.url.split('?')[0]);
     const f = path.join(root, p === '/' ? file : p);
@@ -38,6 +39,6 @@ export async function open(root, file, width, height) {
   const logs = [];
   page.on('console', m => logs.push(`[${m.type()}] ${m.text()}`));
   page.on('pageerror', e => logs.push(`[pageerror] ${e}`));
-  await page.goto(`http://127.0.0.1:${server.address().port}/`);
+  await page.goto(`http://127.0.0.1:${server.address().port}/${query}`);
   return { page, logs, close: async () => { await browser.close(); server.close(); } };
 }
diff --git a/scripts/web-probe/capture-app.mjs b/scripts/web-probe/capture-app.mjs
new file mode 100644
index 0000000..7b62ee6
--- /dev/null
+++ b/scripts/web-probe/capture-app.mjs
@@ -0,0 +1,20 @@
+// capture-app.mjs <site-dir> <app> <out.rgba> [settle-ms]: open demo.html
+// with ?app=<app> (browser.mjs), let the app run for settle-ms (default
+// 3000), and write the frame `capture()` reads back from the GPU to
+// <out.rgba> (premultiplied RGBA8, 1280x800).
+import fs from 'fs';
+import { open } from './browser.mjs';
+
+const [root, app, out, settle] = process.argv.slice(2);
+if (!root || !app || !out) { console.error('usage: capture-app.mjs <site-dir> <app> <out.rgba> [settle-ms]'); process.exit(2); }
+const { page, logs, close } = await open(root, 'demo.html', 1280, 800, `?app=${app}`);
+let ok = true;
+try {
+  await page.waitForFunction(() => window.demoReady === true, null, { timeout: 120000 });
+  await page.waitForTimeout(Number(settle || 3000));
+  const b64 = await page.evaluate(() => window.capture());
+  fs.writeFileSync(out, Buffer.from(b64, 'base64'));
+} catch (e) { ok = false; logs.push('[capture] ' + String(e)); }
+for (const l of logs) console.log(l);
+await close();
+process.exit(ok ? 0 : 1);
diff --git a/scripts/web-probe/demo.html b/scripts/web-probe/demo.html
index a88f642..a01c1b9 100644
--- a/scripts/web-probe/demo.html
+++ b/scripts/web-probe/demo.html
@@ -1,14 +1,17 @@
 <!doctype html>
 <meta charset="utf-8">
 <title>cce-ui</title>
-<!-- The reference app on the browser shell (examples/demo_web.rs). The page
-     owns the canvas's size (fill = Sizing::Page). window.capture() reads the
-     next frame back from the GPU, base64 RGBA8: a headless Chromium leaves
-     a WebGPU canvas out of its screenshots. -->
+<!-- An app on the browser shell: examples/demo_web.rs, or the example the
+     query names (?app=probe3d_web) — any that exports start(canvas, fonts,
+     families, fill) and capture(). The page owns the canvas's size (fill =
+     Sizing::Page). window.capture() reads the next frame back from the GPU,
+     base64 RGBA8: a headless Chromium leaves a WebGPU canvas out of its
+     screenshots. -->
 <style>html,body{margin:0;background:#3f3f3f;overflow:hidden}canvas{display:block;width:100vw;height:100vh}</style>
 <canvas id="c"></canvas>
 <script type="module">
-import init, { start, capture } from './demo_web.js';
+const app = new URLSearchParams(location.search).get('app') || 'demo_web';
+const { default: init, start, capture } = await import(`./${app}.js`);
 await init();
 const { files, families } = await (await fetch('fonts.json')).json();
 const fonts = [];
diff --git a/scripts/web-probe/probe3d b/scripts/web-probe/probe3d
new file mode 100755
index 0000000..e117efe
--- /dev/null
+++ b/scripts/web-probe/probe3d
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# web-probe/probe3d <out.rgba> — run the 3D probe (examples/probe3d/scene.rs)
+# on the browser shell and WebGPU renderer in headless Chromium, and write a
+# frame read back from the GPU: 1280x800 premultiplied RGBA8.
+#
+# Its other half is `cargo run --example probe3d_native`, screenshotted in a
+# compositor; compare.py composites this frame over that compositor's
+# background and diffs the two. The scene names DejaVu Sans, handed over from
+# $PROBE_FONTS_DIR (default /usr/share/fonts/truetype/dejavu). Needs what
+# `run` needs.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+out=$(realpath -m "${1:?usage: web-probe/probe3d <out.rgba>}")
+fonts=${PROBE_FONTS_DIR:-/usr/share/fonts/truetype/dejavu}
+cd "$here/../.."
+cargo build --release --target wasm32-unknown-unknown --example probe3d_web
+site=$(mktemp -d)
+trap 'rm -rf "$site"' EXIT
+wasm-bindgen --target web --no-typescript --out-dir "$site" \
+    target/wasm32-unknown-unknown/release/examples/probe3d_web.wasm
+cp "$here/demo.html" "$site/demo.html"
+mkdir "$site/fonts"
+cp "$fonts"/*.ttf "$site/fonts/"
+(cd "$site/fonts" && printf '%s\n' *.ttf | python3 -c 'import json,sys; print(json.dumps({"files": sys.stdin.read().split(), "families": ""}))') > "$site/fonts.json"
+node "$here/capture-app.mjs" "$site" probe3d_web "$out"
+echo "web-probe: wrote $out"
diff --git a/src/backend/app.rs b/src/backend/app.rs
index 7c2bc4a..cdcfe97 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -13,6 +13,7 @@ use crate::vk::VkRenderer;
 use super::window_runner::EngineState;
 use cursor_icon::CursorIcon;
 use super::tessellate::Vertex;
+pub use crate::draw::scene::Stage3D;
 
 #[derive(Debug, Clone)]
 pub struct WindowSettings {
@@ -473,11 +474,16 @@ pub trait Application: Sized + 'static {
         false
     }
 
-    /// Called once, right after the renderer is created and before the first
-    /// frame: create persistent renderer resources here (3D meshes via
-    /// [`VkRenderer::create_mesh`]). Most 2D apps never need this.
+    /// Called once per renderer, right after it is created and before its
+    /// first frame (a reconnect's replacement too — see "`renderer_init`"
+    /// in CLAUDE.md): create persistent renderer resources here (3D meshes
+    /// via [`VkRenderer::create_mesh`]). Most 2D apps never need this. The
+    /// default hands the renderer to the portable [`init_3d`](Self::init_3d),
+    /// so an app written against that runs here unchanged.
     #[cfg(not(target_arch = "wasm32"))]
-    fn renderer_init(&mut self, _renderer: &mut VkRenderer) {}
+    fn renderer_init(&mut self, renderer: &mut VkRenderer) {
+        self.init_3d(renderer);
+    }
 
     /// Direct renderer staging, called every frame after the engine's own text
     /// prep and immediately before the frame is drawn: stage 3D scene panes
@@ -486,9 +492,25 @@ pub trait Application: Sized + 'static {
     /// from [`display_list_text`](Application::display_list_text) fully owns
     /// the renderer's text state, the engine never touches it). Return `true`
     /// to request another frame immediately (e.g. while a path tracer is still
-    /// accumulating samples).
+    /// accumulating samples). The default is the portable
+    /// [`stage_3d`](Self::stage_3d).
     #[cfg(not(target_arch = "wasm32"))]
-    fn stage_renderer(&mut self, _renderer: &mut VkRenderer, _size: LogicalSize, _scale: f64) -> bool {
+    fn stage_renderer(&mut self, renderer: &mut VkRenderer, size: LogicalSize, scale: f64) -> bool {
+        self.stage_3d(renderer, size, scale)
+    }
+
+    /// The portable [`renderer_init`](Self::renderer_init): once per
+    /// renderer, before its first frame, through [`Stage3D`] — the 3D half
+    /// every renderer has, the Vulkan one natively and the WebGPU one in a
+    /// browser. Make the app's meshes here. Called by the browser shell, and
+    /// natively by `renderer_init`'s default.
+    fn init_3d(&mut self, _stage: &mut dyn Stage3D) {}
+
+    /// The portable [`stage_renderer`](Self::stage_renderer): every frame,
+    /// just before it is drawn, stage the scene through [`Stage3D`]. `size`
+    /// is the window's logical size and `scale` its pixel ratio; a scissor is
+    /// physical px. Return `true` to ask for another frame at once.
+    fn stage_3d(&mut self, _stage: &mut dyn Stage3D, _size: LogicalSize, _scale: f64) -> bool {
         false
     }
 
diff --git a/src/draw/mod.rs b/src/draw/mod.rs
index b788995..50270ce 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 scene;
 pub mod shaders;
 
 pub use images::{
diff --git a/src/draw/scene.rs b/src/draw/scene.rs
new file mode 100644
index 0000000..9124662
--- /dev/null
+++ b/src/draw/scene.rs
@@ -0,0 +1,210 @@
+//! The 3D scene a renderer draws beneath the UI, with no renderer in it: the
+//! mesh vertex ([`Vertex3D`]), a staged draw ([`SceneDraw`]) and image
+//! ([`SceneImage`]), the uniform block and image quads both renderers lay out
+//! the same way, and [`Stage3D`] — what an app stages a scene through, on the
+//! Vulkan renderer natively and the WebGPU one in a browser.
+//!
+//! The pass: meshes drawn with a depth buffer into a full-size BACKDROP
+//! image, scissored to a pane, which the renderer copies beneath the UI pass
+//! and the 2D shader's blur plates sample. A staged scene is drawn once; the
+//! backdrop it leaves is shown under every later frame until the next one.
+//! `scene3d.wgsl` / `scene3d_image.wgsl` (in `draw/`) are the shaders.
+
+/// Layout-identical to the app's `geometry::Vertex3D` (bytemuck-castable at cutover).
+#[repr(C)]
+#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+pub struct Vertex3D {
+    pub position: [f32; 3],
+    pub color: [f32; 3],
+}
+
+/// A mesh a renderer holds, as [`Stage3D::create_mesh`] named it. Meaningful
+/// only to the renderer that made it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MeshId(pub(crate) usize);
+
+/// One draw in the staged scene: a mesh under an mvp. The window-size/radius
+/// tail of shader_3d's uniform block is filled in by the renderer.
+pub struct SceneDraw {
+    pub mesh: MeshId,
+    pub mvp: [[f32; 4]; 4],
+    /// Rasterize through the line pipeline (LINE_LIST topology): the mesh
+    /// must be an EDGE mesh (vertex pairs), not the triangle fill mesh.
+    pub wireframe: bool,
+    /// rgb + mix: the fragment color is mixed toward `wire_tint.rgb` by
+    /// `wire_tint[3]`. Zero = vertex colors untouched (the default draw).
+    /// A wireframe pass overlaid on its own filled mesh needs this — the
+    /// lines inherit the mesh's colors and would otherwise vanish into the
+    /// identical fill beneath.
+    pub wire_tint: [f32; 4],
+    /// Whole-draw alpha multiplier (1.0 = opaque). The pass blends with
+    /// straight alpha, so translucent draws show whatever rendered beneath.
+    pub opacity: f32,
+    /// Rasterized line width in framebuffer pixels for wireframe draws
+    /// (ignored on fills). Clamped to the device's wideLines cap — 1.0
+    /// everywhere when the feature is absent.
+    pub line_width: f32,
+    /// FILL draws only: the width of a wire pass that will ride on this
+    /// fill (0 = none). The fill is pushed back by its own slope-scaled
+    /// polygon offset sized to that width, so the coplanar wires win the
+    /// depth test solidly: a w-px line samples the fill's plane up to
+    /// (w/2 + 0.5) px off the true edge, and biasing the LINE can't cover
+    /// that (its own depth slope is along-axis — near zero for
+    /// contour-following wires) while the fill's slope is exactly the
+    /// quantity needed.
+    pub wire_base_width: f32,
+    /// 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 (`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
+    /// pipeline — no face culling and no depth writes (the depth TEST stays
+    /// on, so what is drawn before the fill still hides it) — so a
+    /// translucent mesh shows its own far side and everything behind it,
+    /// wires included, since nothing it draws can occlude them. Blending is
+    /// then order-dependent: the host should submit the triangles back to
+    /// front for the current eye. False for an ordinary fill.
+    pub see_through: bool,
+}
+
+/// A user image standing in the 3D scene: a textured quad, unlit, depth
+/// tested against the meshes and seen from both sides.
+///
+/// Staged with `VkRenderer::stage_scene_images`, beside the scene's
+/// `SceneDraw`s rather than as one of them: a mesh draw names a mesh and an
+/// image draw names four corners and a texture, and the two share nothing but
+/// the pass.
+pub struct SceneImage {
+    /// Id from `upload_rgba`. A draw whose upload has not landed is skipped.
+    pub image: u32,
+    /// The quad's corners in the space `mvp` transforms, in the image's own
+    /// order: top-left, top-right, bottom-right, bottom-left.
+    pub corners: [[f32; 3]; 4],
+    pub mvp: [[f32; 4]; 4],
+    /// Whole-draw alpha multiplier over the image's own alpha.
+    pub opacity: f32,
+    /// Draw order: this image renders before the `SceneDraw` at this index
+    /// of the staged list, `u32::MAX` after them all. The pass blends in
+    /// submission order, so an image goes after the opaque things it may
+    /// show through to and before the translucent ones that may cover it.
+    pub before: u32,
+}
+
+/// One corner of a `SceneImage` quad: `scene3d_image.wgsl`'s vertex.
+#[repr(C)]
+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+pub(crate) struct ImageVertex3D {
+    position: [f32; 3],
+    uv: [f32; 2],
+}
+
+/// `scene3d.wgsl`'s uniform block (`scene3d_image.wgsl` reads its head).
+#[repr(C)]
+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+pub(crate) struct SceneUniforms {
+    mvp: [[f32; 4]; 4],
+    window_size: [f32; 2],
+    window_radius: f32,
+    corner_shape: f32,
+    wire_tint: [f32; 4],
+    opacity: f32,
+    /// 1.0 on wireframe draws: the fragment shader skips the derivative-
+    /// normal flat shading, whose screen-space derivatives are degenerate on
+    /// line fragments (along-axis only) and light the wires with noise.
+    is_wire: f32,
+    /// 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];
+
+/// What an app stages a 3D scene through: the renderer's half of the
+/// pass, the same on every renderer (`vk::VkRenderer`, `web::WebRenderer`).
+/// An app takes one in `Application::init_3d` (make its meshes) and
+/// `Application::stage_3d` (stage this frame's scene).
+pub trait Stage3D {
+    /// Upload a mesh: a triangle list for a fill draw, vertex PAIRS for a
+    /// wireframe one.
+    fn create_mesh(&mut self, verts: &[Vertex3D]) -> MeshId;
+    /// Replace a mesh's vertices (rare: a settings change, a rebuild).
+    fn update_mesh(&mut self, id: MeshId, verts: &[Vertex3D]);
+    /// Stage the next frame's scene: `draws` in order, scissored to
+    /// `scissor` (x, y, w, h, physical px).
+    fn stage_scene(&mut self, scissor: (u32, u32, u32, u32), draws: Vec<SceneDraw>);
+    /// Images standing in the staged scene (after [`stage_scene`](Self::stage_scene)).
+    fn stage_scene_images(&mut self, images: Vec<SceneImage>);
+    /// The direction TOWARD the flat shading's light, in world space; set
+    /// it once, and light a `prelit` mesh by the same vector.
+    fn set_scene_light(&mut self, toward: [f32; 3]);
+}
+
+/// A staged scene's uniform blocks, in the order its draws use them: one per
+/// mesh draw, then one per image.
+pub(crate) fn scene_uniforms(
+    draws: &[SceneDraw],
+    images: &[SceneImage],
+    window_size: [f32; 2],
+    corner_radius_px: f32,
+    light: [f32; 3],
+) -> Vec<SceneUniforms> {
+    let corner_shape = crate::layout::corner_shape();
+    let light = [light[0], light[1], light[2], 0.0];
+    let block = |mvp, wire_tint, opacity, is_wire, prelit| SceneUniforms {
+        mvp,
+        window_size,
+        window_radius: corner_radius_px,
+        corner_shape,
+        wire_tint,
+        opacity,
+        is_wire,
+        prelit,
+        _pad: [0.0; 1],
+        light,
+    };
+    let mut out = Vec::with_capacity(draws.len() + images.len());
+    for d in draws {
+        out.push(block(d.mvp, d.wire_tint, d.opacity, if d.wireframe { 1.0 } else { 0.0 }, if d.prelit { 1.0 } else { 0.0 }));
+    }
+    for i in images {
+        out.push(block(i.mvp, [0.0; 4], i.opacity, 0.0, 1.0));
+    }
+    out
+}
+
+/// Six vertices per image, two triangles over its corners in staged order.
+pub(crate) fn image_quads_3d(images: &[SceneImage]) -> Vec<ImageVertex3D> {
+    let mut verts = Vec::with_capacity(images.len() * 6);
+    for image in images {
+        let [tl, tr, br, bl] = image.corners;
+        let v = |position: [f32; 3], uv: [f32; 2]| ImageVertex3D { position, uv };
+        verts.extend([
+            v(tl, [0.0, 0.0]),
+            v(bl, [0.0, 1.0]),
+            v(tr, [1.0, 0.0]),
+            v(tr, [1.0, 0.0]),
+            v(bl, [0.0, 1.0]),
+            v(br, [1.0, 1.0]),
+        ]);
+    }
+    verts
+}
+
+/// A fill carrying a wire overlay is pushed back by this slope-scaled depth
+/// bias for wires `w` px wide (constant, slope): the slope term covers the
+/// wires' across-width sampling offset (w/2 px) and the NEIGHBOR facet's
+/// plane — a wire lies on edge A|B and its fragments carry A's plane depth,
+/// while the fill under the far half of the wire is B's plane, which on a
+/// convex surface tilts closer — hence the extra pixel of headroom.
+pub(crate) fn wire_base_bias(w: f32) -> (f32, f32) {
+    (2.0, 1.5 + w)
+}
diff --git a/src/vk/scene3d.wgsl b/src/draw/scene3d.wgsl
similarity index 92%
rename from src/vk/scene3d.wgsl
rename to src/draw/scene3d.wgsl
index ae87e66..eec9dd8 100644
--- a/src/vk/scene3d.wgsl
+++ b/src/draw/scene3d.wgsl
@@ -84,6 +84,12 @@ fn vs_main(
 
 @fragment
 fn fs_main(in: VertexOutput) -> @location(0) vec4f {
+    // The world position's screen-space derivatives, for the flat shading
+    // below — taken here, in uniform control flow: WebGPU rejects a
+    // derivative under a branch on a varying (`in.lit`), and the values are
+    // the same wherever in the invocation they are read.
+    let world_dx = dpdx(in.world);
+    let world_dy = dpdy(in.world);
     // ~1px feather along the squircle window corner (the pass clears to
     // transparent and blends with straight alpha, so partial coverage fades
     // the scene out exactly at the silhouette).
@@ -101,7 +107,7 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f {
     // wireframe overlay whose fill occludes the back wires) reads as glued to
     // the camera without it. Two-sided so unculled back faces stay sane.
     if (in.lit > 0.5 && uniforms.is_wire < 0.5 && uniforms.prelit < 0.5) {
-        let n = normalize(cross(dpdx(in.world), dpdy(in.world)));
+        let n = normalize(cross(world_dx, world_dy));
         // A strongly AZIMUTHAL light, wrap-shaded. A near-vertical light (or a
         // two-sided |dot|) yields a latitude-dominated / 180-degree-symmetric
         // brightness pattern — invariant under a yaw orbit, which reads as the
diff --git a/src/vk/scene3d_image.wgsl b/src/draw/scene3d_image.wgsl
similarity index 100%
rename from src/vk/scene3d_image.wgsl
rename to src/draw/scene3d_image.wgsl
diff --git a/src/draw/shaders.rs b/src/draw/shaders.rs
index 3fc8d33..4568d0c 100644
--- a/src/draw/shaders.rs
+++ b/src/draw/shaders.rs
@@ -1,4 +1,5 @@
-//! The 2D and glyph shaders, shared by every renderer: one WGSL source each.
+//! The 2D, glyph and 3D scene shaders, shared by every renderer: one WGSL
+//! source each.
 //!
 //! The Vulkan renderer compiles them to SPIR-V through naga. WebGPU takes WGSL
 //! as it is, with one difference: it has no push constants, so the per-batch
@@ -13,6 +14,12 @@ pub const SHADER2D: &str = include_str!("shader2d.wgsl");
 /// Text and image quads.
 pub const GLYPH: &str = include_str!("glyph.wgsl");
 
+/// The 3D scene pass's meshes ([`super::scene`]).
+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 one line that differs between the Vulkan and the WebGPU 2D shader.
 const PUSH_BLOCK: &str = "var<push_constant> rrect_clip: RRectClip;";
 const UNIFORM_BLOCK: &str = "@group(1) @binding(0) var<uniform> rrect_clip: RRectClip;";
diff --git a/src/engine.rs b/src/engine.rs
index f0571ae..09c9092 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -3,8 +3,9 @@
 //! native shell's entry point and Wayland-typed items after it.
 
 pub use crate::backend::app::{
-    Application, AppSender, LogicalPosition, LogicalSize, RenderContext, WindowAction, WindowSettings,
+    Application, AppSender, LogicalPosition, LogicalSize, RenderContext, Stage3D, WindowAction, WindowSettings,
 };
+pub use crate::draw::scene::{MeshId, SceneDraw, SceneImage, Vertex3D};
 pub use crate::backend::driver::PressedKey;
 pub use crate::backend::tessellate::{
     Vertex, LineCap,
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 9a1c917..d032430 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -450,12 +450,12 @@ pub(crate) fn glyph_spirv() -> &'static [u32] {
 
 pub(crate) fn scene3d_spirv() -> &'static [u32] {
     static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
-    SPIRV.get_or_init(|| compile_wgsl(include_str!("scene3d.wgsl")))
+    SPIRV.get_or_init(|| compile_wgsl(crate::draw::shaders::SCENE3D))
 }
 
 pub(crate) fn scene3d_image_spirv() -> &'static [u32] {
     static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
-    SPIRV.get_or_init(|| compile_wgsl(include_str!("scene3d_image.wgsl")))
+    SPIRV.get_or_init(|| compile_wgsl(crate::draw::shaders::SCENE3D_IMAGE))
 }
 
 /// Like [`compile_wgsl`], but with naga's RAY_QUERY capability and SPIR-V 1.4
@@ -2328,6 +2328,26 @@ fn present_debug() -> bool {
     *FLAG.get_or_init(|| std::env::var_os("CCE_PRESENT_DEBUG").is_some())
 }
 
+/// The 3D half of the renderer, as an app stages it through
+/// `Application::init_3d` / `stage_3d` — each method is the inherent one.
+impl crate::draw::scene::Stage3D for VkRenderer {
+    fn create_mesh(&mut self, verts: &[Vertex3D]) -> MeshId {
+        VkRenderer::create_mesh(self, verts)
+    }
+    fn update_mesh(&mut self, id: MeshId, verts: &[Vertex3D]) {
+        VkRenderer::update_mesh(self, id, verts)
+    }
+    fn stage_scene(&mut self, scissor: (u32, u32, u32, u32), draws: Vec<SceneDraw>) {
+        VkRenderer::stage_scene(self, scissor, draws)
+    }
+    fn stage_scene_images(&mut self, images: Vec<SceneImage>) {
+        VkRenderer::stage_scene_images(self, images)
+    }
+    fn set_scene_light(&mut self, toward: [f32; 3]) {
+        VkRenderer::set_scene_light(self, toward)
+    }
+}
+
 impl Drop for VkRenderer {
     fn drop(&mut self) {
         unsafe {
diff --git a/src/vk/scene.rs b/src/vk/scene.rs
index 6b14e4f..4e3c072 100644
--- a/src/vk/scene.rs
+++ b/src/vk/scene.rs
@@ -16,121 +16,8 @@ use gpu_allocator::MemoryLocation;
 
 use super::renderer::{create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
 
-/// Layout-identical to the app's `geometry::Vertex3D` (bytemuck-castable at cutover).
-#[repr(C)]
-#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
-pub struct Vertex3D {
-    pub position: [f32; 3],
-    pub color: [f32; 3],
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct MeshId(usize);
-
-/// One draw in the staged scene: a mesh under an mvp. The window-size/radius
-/// tail of shader_3d's uniform block is filled in by the renderer.
-pub struct SceneDraw {
-    pub mesh: MeshId,
-    pub mvp: [[f32; 4]; 4],
-    /// Rasterize through the line pipeline (LINE_LIST topology): the mesh
-    /// must be an EDGE mesh (vertex pairs), not the triangle fill mesh.
-    pub wireframe: bool,
-    /// rgb + mix: the fragment color is mixed toward `wire_tint.rgb` by
-    /// `wire_tint[3]`. Zero = vertex colors untouched (the default draw).
-    /// A wireframe pass overlaid on its own filled mesh needs this — the
-    /// lines inherit the mesh's colors and would otherwise vanish into the
-    /// identical fill beneath.
-    pub wire_tint: [f32; 4],
-    /// Whole-draw alpha multiplier (1.0 = opaque). The pass blends with
-    /// straight alpha, so translucent draws show whatever rendered beneath.
-    pub opacity: f32,
-    /// Rasterized line width in framebuffer pixels for wireframe draws
-    /// (ignored on fills). Clamped to the device's wideLines cap — 1.0
-    /// everywhere when the feature is absent.
-    pub line_width: f32,
-    /// FILL draws only: the width of a wire pass that will ride on this
-    /// fill (0 = none). The fill is pushed back by its own slope-scaled
-    /// polygon offset sized to that width, so the coplanar wires win the
-    /// depth test solidly: a w-px line samples the fill's plane up to
-    /// (w/2 + 0.5) px off the true edge, and biasing the LINE can't cover
-    /// that (its own depth slope is along-axis — near zero for
-    /// contour-following wires) while the fill's slope is exactly the
-    /// quantity needed.
-    pub wire_base_width: f32,
-    /// 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 (`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
-    /// pipeline — no face culling and no depth writes (the depth TEST stays
-    /// on, so what is drawn before the fill still hides it) — so a
-    /// translucent mesh shows its own far side and everything behind it,
-    /// wires included, since nothing it draws can occlude them. Blending is
-    /// then order-dependent: the host should submit the triangles back to
-    /// front for the current eye. False for an ordinary fill.
-    pub see_through: bool,
-}
-
-/// A user image standing in the 3D scene: a textured quad, unlit, depth
-/// tested against the meshes and seen from both sides.
-///
-/// Staged with `VkRenderer::stage_scene_images`, beside the scene's
-/// `SceneDraw`s rather than as one of them: a mesh draw names a mesh and an
-/// image draw names four corners and a texture, and the two share nothing but
-/// the pass.
-pub struct SceneImage {
-    /// Id from `upload_rgba`. A draw whose upload has not landed is skipped.
-    pub image: u32,
-    /// The quad's corners in the space `mvp` transforms, in the image's own
-    /// order: top-left, top-right, bottom-right, bottom-left.
-    pub corners: [[f32; 3]; 4],
-    pub mvp: [[f32; 4]; 4],
-    /// Whole-draw alpha multiplier over the image's own alpha.
-    pub opacity: f32,
-    /// Draw order: this image renders before the `SceneDraw` at this index
-    /// of the staged list, `u32::MAX` after them all. The pass blends in
-    /// submission order, so an image goes after the opaque things it may
-    /// show through to and before the translucent ones that may cover it.
-    pub before: u32,
-}
-
-/// One corner of a `SceneImage` quad.
-#[repr(C)]
-#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
-struct ImageVertex3D {
-    position: [f32; 3],
-    uv: [f32; 2],
-}
-
-/// shader_3d.wgsl's uniform block.
-#[repr(C)]
-#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
-struct SceneUniforms {
-    mvp: [[f32; 4]; 4],
-    window_size: [f32; 2],
-    window_radius: f32,
-    corner_shape: f32,
-    wire_tint: [f32; 4],
-    opacity: f32,
-    /// 1.0 on wireframe draws: the fragment shader skips the derivative-
-    /// normal flat shading, whose screen-space derivatives are degenerate on
-    /// line fragments (along-axis only) and light the wires with noise.
-    is_wire: f32,
-    /// 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];
+pub use crate::draw::scene::{MeshId, SceneDraw, SceneImage, Vertex3D};
+use crate::draw::scene::{image_quads_3d, scene_uniforms, wire_base_bias, ImageVertex3D, SceneUniforms, DEFAULT_SCENE_LIGHT};
 
 const UNIFORM_SIZE: vk::DeviceSize = std::mem::size_of::<SceneUniforms>() as vk::DeviceSize;
 
@@ -952,58 +839,15 @@ impl SceneStage {
             Self::write_descriptor(device, frame);
         }
         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 {
-                mvp: draw.mvp,
-                window_size,
-                window_radius: corner_radius_px,
-                corner_shape,
-                wire_tint: draw.wire_tint,
-                opacity: draw.opacity,
-                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 blocks = scene_uniforms(&staged.draws, &staged.images, window_size, corner_radius_px, self.light);
+        for (i, uniforms) in blocks.iter().enumerate() {
             let offset = (self.uniform_stride as usize) * i;
-            mapped[offset..offset + UNIFORM_SIZE as usize]
-                .copy_from_slice(bytemuck::bytes_of(&uniforms));
-        }
-        for (j, image) in staged.images.iter().enumerate() {
-            let uniforms = SceneUniforms {
-                mvp: image.mvp,
-                window_size,
-                window_radius: corner_radius_px,
-                corner_shape,
-                wire_tint: [0.0; 4],
-                opacity: image.opacity,
-                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]
-                .copy_from_slice(bytemuck::bytes_of(&uniforms));
+            mapped[offset..offset + UNIFORM_SIZE as usize].copy_from_slice(bytemuck::bytes_of(uniforms));
         }
         frame.draw_count = staged.draws.len() as u32;
 
-        let mut verts: Vec<ImageVertex3D> = Vec::with_capacity(staged.images.len() * 6);
-        for image in &staged.images {
-            let [tl, tr, br, bl] = image.corners;
-            let v = |position: [f32; 3], uv: [f32; 2]| ImageVertex3D { position, uv };
-            verts.extend([
-                v(tl, [0.0, 0.0]),
-                v(bl, [0.0, 1.0]),
-                v(tr, [1.0, 0.0]),
-                v(tr, [1.0, 0.0]),
-                v(bl, [0.0, 1.0]),
-                v(br, [1.0, 1.0]),
-            ]);
-        }
+        let verts = image_quads_3d(&staged.images);
         let bytes: &[u8] = bytemuck::cast_slice(&verts);
         if bytes.len() as vk::DeviceSize > frame.image_verts.size {
             let mut old = std::mem::replace(&mut frame.image_verts, AllocatedBuffer::null());
@@ -1145,15 +989,11 @@ impl SceneStage {
                     device.cmd_set_line_width(cmd, draw.line_width.clamp(1.0, self.max_line_width));
                     device.cmd_set_depth_bias(cmd, 0.0, 0.0, 0.0);
                 } else if draw.wire_base_width > 0.0 {
-                    // Push this fill behind its coming wire overlay. The
-                    // slope term must cover not just the wires' across-width
-                    // sampling offset (w/2 px) but the NEIGHBOR facet's
-                    // plane: a wire lies on edge A|B and its fragments carry
-                    // A's plane depth, while the fill under the far half of
-                    // the wire is B's plane, which on a convex surface tilts
-                    // closer — hence the extra pixel of slope headroom.
+                    // Push this fill behind its coming wire overlay (see
+                    // `wire_base_bias`).
                     let w = draw.wire_base_width.clamp(1.0, self.max_line_width);
-                    device.cmd_set_depth_bias(cmd, 2.0, 0.0, 1.5 + w);
+                    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);
                 }
diff --git a/src/web/mod.rs b/src/web/mod.rs
index 4f375d5..97041ac 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -11,6 +11,7 @@
 
 mod compute;
 mod renderer;
+mod scene;
 mod shell;
 
 pub use compute::ComputeDevice;
diff --git a/src/web/renderer.rs b/src/web/renderer.rs
index 2bd2e8c..c4df5c6 100644
--- a/src/web/renderer.rs
+++ b/src/web/renderer.rs
@@ -26,6 +26,9 @@
 
 use std::collections::HashMap;
 
+use super::scene::WebScene;
+use crate::draw::scene::{MeshId, SceneDraw, SceneImage, Stage3D, Vertex3D};
+
 use wasm_bindgen::{JsCast, JsValue};
 use web_sys::{
     gpu_buffer_usage as buffer_usage, gpu_map_mode as map_mode, gpu_shader_stage as shader_stage,
@@ -68,15 +71,15 @@ struct WebImage {
 }
 
 /// A grow-only GPU buffer: replaced by a larger one when a frame needs more.
-struct Growable {
-    buffer: GpuBuffer,
+pub(super) struct Growable {
+    pub(super) buffer: GpuBuffer,
     size: u32,
     usage: u32,
     label: &'static str,
 }
 
 impl Growable {
-    fn new(device: &GpuDevice, size: u32, usage: u32, label: &'static str) -> Result<Self, JsValue> {
+    pub(super) fn new(device: &GpuDevice, size: u32, usage: u32, label: &'static str) -> Result<Self, JsValue> {
         let size = size.max(256).next_power_of_two();
         let desc = GpuBufferDescriptor::new(size, usage | buffer_usage::COPY_DST);
         desc.set_label(label);
@@ -85,7 +88,7 @@ impl Growable {
 
     /// Make room for `needed` bytes; true when the buffer was replaced (any
     /// bind group naming it must be rebuilt).
-    fn ensure(&mut self, device: &GpuDevice, needed: u32) -> Result<bool, JsValue> {
+    pub(super) fn ensure(&mut self, device: &GpuDevice, needed: u32) -> Result<bool, JsValue> {
         if needed <= self.size {
             return Ok(false);
         }
@@ -94,7 +97,7 @@ impl Growable {
         Ok(true)
     }
 
-    fn write(&self, queue: &GpuQueue, bytes: &[u8]) -> Result<(), JsValue> {
+    pub(super) fn write(&self, queue: &GpuQueue, bytes: &[u8]) -> Result<(), JsValue> {
         if !bytes.is_empty() {
             queue.write_buffer_with_u32_and_u8_slice(&self.buffer, 0, bytes)?;
         }
@@ -164,6 +167,10 @@ pub struct WebRenderer {
     /// `@group(0)` over the empty backdrop, and over the blur snapshot.
     group_0: GpuBindGroup,
     snapshot: Option<(GpuTexture, GpuBindGroup, u32, u32)>,
+    /// The 3D scene pass, and `@group(0)` over its backdrop (rebuilt with
+    /// the backdrop) — what the 2D pass binds while a scene is shown.
+    scene: WebScene,
+    scene_group_0: Option<GpuBindGroup>,
     blocks: Growable,
     group_1: GpuBindGroup,
     vertices: Growable,
@@ -185,14 +192,14 @@ pub struct WebRenderer {
     capture_requested: bool,
 }
 
-fn shader_module(device: &GpuDevice, code: &str, label: &str) -> web_sys::GpuShaderModule {
+pub(super) fn shader_module(device: &GpuDevice, code: &str, label: &str) -> web_sys::GpuShaderModule {
     let desc = GpuShaderModuleDescriptor::new(code);
     desc.set_label(label);
     device.create_shader_module(&desc)
 }
 
 /// `wgpu::BlendState::ALPHA_BLENDING`, as both Vulkan pipelines blend.
-fn alpha_blending() -> GpuBlendState {
+pub(super) fn alpha_blending() -> GpuBlendState {
     let color = GpuBlendComponent::new();
     color.set_src_factor(GpuBlendFactor::SrcAlpha);
     color.set_dst_factor(GpuBlendFactor::OneMinusSrcAlpha);
@@ -242,14 +249,14 @@ fn sampler(device: &GpuDevice, filter: GpuFilterMode, mipmap: GpuMipmapFilterMod
     device.create_sampler_with_descriptor(&desc)
 }
 
-fn texture(device: &GpuDevice, format: GpuTextureFormat, w: u32, h: u32, usage: u32, label: &str) -> Result<GpuTexture, JsValue> {
+pub(super) fn texture(device: &GpuDevice, format: GpuTextureFormat, w: u32, h: u32, usage: u32, label: &str) -> Result<GpuTexture, JsValue> {
     let size = [js_sys::Number::from(w.max(1)), js_sys::Number::from(h.max(1))];
     let desc = GpuTextureDescriptor::new(format, &size, usage);
     desc.set_label(label);
     device.create_texture(&desc)
 }
 
-fn whole_view(texture: &GpuTexture) -> Result<GpuTextureView, JsValue> {
+pub(super) fn whole_view(texture: &GpuTexture) -> Result<GpuTextureView, JsValue> {
     texture.create_view()
 }
 
@@ -301,7 +308,7 @@ impl WebRenderer {
             other => other,
         };
         let config = GpuCanvasConfiguration::new(&device, canvas_format);
-        config.set_usage(texture_usage::RENDER_ATTACHMENT | texture_usage::COPY_SRC);
+        config.set_usage(texture_usage::RENDER_ATTACHMENT | texture_usage::COPY_SRC | texture_usage::COPY_DST);
         config.set_view_formats(&[js_sys::JsString::from(JsValue::from(view_format))]);
         config.set_alpha_mode(GpuCanvasAlphaMode::Premultiplied);
         context.configure(&config)?;
@@ -408,6 +415,7 @@ impl WebRenderer {
         )?;
         let atlas_sampler = sampler(&device, GpuFilterMode::Nearest, GpuMipmapFilterMode::Nearest);
         let atlas_group = texture_group(&device, &glyph_layout, &whole_view(&atlas_texture)?, &atlas_sampler);
+        let scene = WebScene::new(&device, view_format, &glyph_layout)?;
         let glyph_vertices = Growable::new(&device, 64 * 1024, buffer_usage::VERTEX, "glyph-vertices")?;
 
         // Images: sRGB, linear (as the Vulkan image stage).
@@ -434,6 +442,8 @@ impl WebRenderer {
             backdrop_sampler,
             group_0,
             snapshot: None,
+            scene,
+            scene_group_0: None,
             blocks,
             group_1,
             vertices,
@@ -598,6 +608,23 @@ impl WebRenderer {
         self.process_images()?;
         let target = self.context.get_current_texture()?;
         let (w, h) = (target.width(), target.height());
+        // The scene's backdrop follows the canvas; a resized one holds no
+        // scene until the next is drawn into it, as on Vulkan.
+        if self.scene.has_staged() || self.scene.target.is_some() {
+            let had = self.scene.target.as_ref().map(|t| (t.width, t.height));
+            self.scene.fit(&self.device, w, h)?;
+            if had != Some((w, h)) {
+                let t = self.scene.target.as_ref().expect("fit made one");
+                self.scene_group_0 = Some(Self::group_0(
+                    &self.device,
+                    &self.layout_0,
+                    &t.view,
+                    &self.backdrop_sampler,
+                    &self.window_info,
+                    &self.plate_features,
+                ));
+            }
+        }
         let view_desc = GpuTextureViewDescriptor::new();
         view_desc.set_format(self.view_format);
         let view = target.create_view_with_descriptor(&view_desc)?;
@@ -656,9 +683,25 @@ impl WebRenderer {
         // batch needs one.
         let snapshot = if batches.iter().any(|b| b.blur_behind) { Some(self.snapshot_group(w, h)?) } else { None };
 
-        // Record.
+        // Record. The 3D pass first, into the backdrop; with a scene shown,
+        // the backdrop is copied into the canvas and the UI pass loads it
+        // (rather than clearing) and samples it for its blur plates.
         let encoder = self.device.create_command_encoder();
-        let mut pass = Self::begin_pass(&encoder, &view, Some(frame.clear_color))?;
+        let images_for_scene = &self.images;
+        self.scene.record(&self.device, &self.queue, &encoder, &|id| images_for_scene.get(&id).map(|i| i.group.clone()))?;
+        let base_group_0 = match (&self.scene_group_0, &self.scene.target) {
+            (Some(group), Some(t)) if self.scene.backdrop_valid => {
+                encoder.copy_texture_to_texture_with_gpu_extent_3d_dict(
+                    &GpuTexelCopyTextureInfo::new(&t.backdrop),
+                    &GpuTexelCopyTextureInfo::new(&target),
+                    &extent(w, h),
+                )?;
+                Some(group.clone())
+            }
+            _ => None,
+        };
+        let mut pass = Self::begin_pass(&encoder, &view, if base_group_0.is_some() { None } else { Some(frame.clear_color) })?;
+        let base_group_0 = base_group_0.unwrap_or_else(|| self.group_0.clone());
         let clamp_scissor = |pass: &GpuRenderPassEncoder, (x, y, sw, sh): (u32, u32, u32, u32)| {
             let x = x.min(w);
             let y = y.min(h);
@@ -681,7 +724,7 @@ impl WebRenderer {
         // The `@group(0)` vertex draws bind: the empty backdrop until the
         // first blur snapshot, the snapshot after. Consecutive blur plates
         // share one snapshot; only a non-blur draw invalidates it.
-        let mut active_group_0 = self.group_0.clone();
+        let mut active_group_0 = base_group_0.clone();
         let mut snapshot_fresh = false;
 
         for (bi, batch) in batches.iter().enumerate() {
@@ -764,10 +807,10 @@ impl WebRenderer {
             pass.set_vertex_buffer_with_u32(0, Some(&self.glyph_vertices.buffer), 0);
             pass.draw_with_instance_count_and_first_vertex(glyph_count, 1, 0);
         }
-        // Overlays last, with the default backdrop and a zero parameter block.
+        // Overlays last, with the frame's backdrop and a zero parameter block.
         if overlay_count > 0 {
             pass.set_pipeline(&self.pipeline_2d);
-            pass.set_bind_group(0, Some(&self.group_0));
+            pass.set_bind_group(0, Some(&base_group_0));
             pass.set_bind_group_with_u32_slice_and_u32_and_dynamic_offsets_data_length(
                 1,
                 Some(&self.group_1),
@@ -796,3 +839,26 @@ impl WebRenderer {
         Ok(())
     }
 }
+
+/// The 3D half of the renderer (see `draw::scene::Stage3D`): the scene pass
+/// in `web::scene`.
+impl Stage3D for WebRenderer {
+    fn create_mesh(&mut self, verts: &[Vertex3D]) -> MeshId {
+        self.scene.create_mesh(&self.device, &self.queue, verts)
+    }
+    fn update_mesh(&mut self, id: MeshId, verts: &[Vertex3D]) {
+        self.scene.update_mesh(&self.device, &self.queue, id, verts)
+    }
+    fn stage_scene(&mut self, scissor: (u32, u32, u32, u32), draws: Vec<SceneDraw>) {
+        self.scene.stage(scissor, draws)
+    }
+    fn stage_scene_images(&mut self, images: Vec<SceneImage>) {
+        self.scene.stage_images(images)
+    }
+    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();
+        }
+    }
+}
diff --git a/src/web/scene.rs b/src/web/scene.rs
new file mode 100644
index 0000000..2b38b2b
--- /dev/null
+++ b/src/web/scene.rs
@@ -0,0 +1,339 @@
+//! The 3D scene pass on WebGPU: the Vulkan `SceneStage`'s port, drawing the
+//! same staged [`SceneDraw`]s and [`SceneImage`]s from the same shaders
+//! (`draw::shaders::SCENE3D` / `SCENE3D_IMAGE`) and uniform blocks
+//! (`draw::scene::scene_uniforms`) into a full-size backdrop texture with a
+//! depth buffer, which the renderer copies beneath the UI pass and its blur
+//! plates sample — the Vulkan renderer's arrangement, step for step.
+//!
+//! What WebGPU makes different:
+//!
+//! - **Depth bias is pipeline state**, where Vulkan sets it per draw. It
+//!   takes one value here — a WebGPU line is one pixel wide (no wideLines),
+//!   so a fill carrying wires is pushed back by `wire_base_bias(1.0)`, as a
+//!   Vulkan device without wideLines pushes it — so the biased fill is a
+//!   pipeline of its own: four fills (opaque or see-through, biased or not),
+//!   two line pipelines (see-through wires write depth), the image pipeline.
+//! - **No Y flip.** The shaders were written for WebGPU's conventions (the
+//!   Vulkan renderer flips its viewport to match), so the viewport is the
+//!   plain one and counter-clockwise is front, as there.
+//! - **The backdrop is the canvas's sRGB view format**, copied into the
+//!   canvas texture (formats that differ only in sRGB-ness copy).
+
+use wasm_bindgen::JsValue;
+use web_sys::{
+    gpu_buffer_usage as buffer_usage, gpu_shader_stage as shader_stage, gpu_texture_usage as texture_usage, GpuBindGroup,
+    GpuBindGroupDescriptor, GpuBindGroupEntry, GpuBindGroupLayout, GpuBindGroupLayoutDescriptor, GpuBindGroupLayoutEntry,
+    GpuBufferBinding, GpuBufferBindingLayout, GpuBufferBindingType, GpuColorTargetState, GpuCommandEncoder, GpuCompareFunction,
+    GpuCullMode, GpuDepthStencilState, GpuDevice, GpuFragmentState, GpuFrontFace, GpuLoadOp, GpuPipelineLayoutDescriptor,
+    GpuPrimitiveState, GpuPrimitiveTopology, GpuQueue, GpuRenderPassColorAttachment, GpuRenderPassDepthStencilAttachment,
+    GpuRenderPassDescriptor, GpuRenderPipeline, GpuRenderPipelineDescriptor, GpuStoreOp, GpuTexture, GpuTextureFormat,
+    GpuTextureView, GpuVertexAttribute, GpuVertexBufferLayout, GpuVertexFormat, GpuVertexState,
+};
+
+use super::renderer::{alpha_blending, shader_module, texture, whole_view, Growable};
+use crate::draw::scene::{
+    image_quads_3d, scene_uniforms, wire_base_bias, ImageVertex3D, MeshId, SceneDraw, SceneImage, SceneUniforms, Vertex3D,
+    DEFAULT_SCENE_LIGHT,
+};
+use crate::draw::shaders::{SCENE3D, SCENE3D_IMAGE};
+
+/// One draw's uniform block sits at a multiple of this (the alignment every
+/// WebGPU device takes for a dynamic offset).
+const STRIDE: u32 = 256;
+const UNIFORM_SIZE: u32 = std::mem::size_of::<SceneUniforms>() as u32;
+const DEPTH: GpuTextureFormat = GpuTextureFormat::Depth32float;
+
+struct Mesh {
+    buffer: Growable,
+    count: u32,
+}
+
+struct Staged {
+    scissor: (u32, u32, u32, u32),
+    draws: Vec<SceneDraw>,
+    images: Vec<SceneImage>,
+}
+
+/// The backdrop and depth textures, at the canvas's size.
+pub(crate) struct Target {
+    pub backdrop: GpuTexture,
+    pub view: GpuTextureView,
+    depth_view: GpuTextureView,
+    pub width: u32,
+    pub height: u32,
+}
+
+pub(crate) struct WebScene {
+    format: GpuTextureFormat,
+    uniform_layout: GpuBindGroupLayout,
+    /// [opaque, opaque biased, see-through, see-through biased].
+    fills: [GpuRenderPipeline; 4],
+    /// [no depth writes, depth writes (the wires of a see-through fill)].
+    lines: [GpuRenderPipeline; 2],
+    image: GpuRenderPipeline,
+    uniforms: Growable,
+    uniform_group: GpuBindGroup,
+    image_verts: Growable,
+    meshes: Vec<Mesh>,
+    staged: Option<Staged>,
+    pub(crate) light: [f32; 3],
+    pub(crate) target: Option<Target>,
+    /// The backdrop holds a rendered scene worth showing under the UI.
+    pub(crate) backdrop_valid: bool,
+}
+
+fn uniform_group(device: &GpuDevice, layout: &GpuBindGroupLayout, uniforms: &Growable) -> GpuBindGroup {
+    let binding = GpuBufferBinding::new(&uniforms.buffer);
+    binding.set_size(UNIFORM_SIZE);
+    let entries = [GpuBindGroupEntry::new_with_gpu_buffer_binding(0, &binding)];
+    device.create_bind_group(&GpuBindGroupDescriptor::new(&entries, layout))
+}
+
+impl WebScene {
+    /// The pass's pipelines, drawing into `format` (the canvas's sRGB view
+    /// format); `image_layout` is the renderer's (texture, sampler) layout,
+    /// so a scene image draws with the group the 2D pass draws it with.
+    pub(crate) fn new(device: &GpuDevice, format: GpuTextureFormat, image_layout: &GpuBindGroupLayout) -> Result<Self, JsValue> {
+        let entry = GpuBindGroupLayoutEntry::new(0, shader_stage::VERTEX | shader_stage::FRAGMENT);
+        let buffer = GpuBufferBindingLayout::new();
+        buffer.set_type(GpuBufferBindingType::Uniform);
+        buffer.set_has_dynamic_offset(true);
+        buffer.set_min_binding_size(UNIFORM_SIZE);
+        entry.set_buffer(&buffer);
+        let uniform_layout = device.create_bind_group_layout(&GpuBindGroupLayoutDescriptor::new(&[entry]))?;
+        let mesh_layout = device.create_pipeline_layout(&GpuPipelineLayoutDescriptor::new(&[js_sys::JsOption::wrap(uniform_layout.clone())]));
+        let image_pipeline_layout = device.create_pipeline_layout(&GpuPipelineLayoutDescriptor::new(&[
+            js_sys::JsOption::wrap(uniform_layout.clone()),
+            js_sys::JsOption::wrap(image_layout.clone()),
+        ]));
+
+        let module = shader_module(device, SCENE3D, "scene3d");
+        let mesh_attrs = [
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x3, 0, 0),
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x3, 12, 1),
+        ];
+        let mesh_buffers =
+            [js_sys::JsOption::wrap(GpuVertexBufferLayout::new(std::mem::size_of::<Vertex3D>() as u32, &mesh_attrs))];
+        let target = GpuColorTargetState::new(format);
+        target.set_blend(&alpha_blending());
+        let targets = [js_sys::JsOption::wrap(target)];
+
+        // One pipeline of the pass: its topology, culling, depth writes and
+        // compare, and the slope-scaled bias (constant, slope) it carries.
+        let pipeline = |layout: &web_sys::GpuPipelineLayout,
+                        module: &web_sys::GpuShaderModule,
+                        buffers: &[js_sys::JsOption<GpuVertexBufferLayout>],
+                        topology: GpuPrimitiveTopology,
+                        cull: GpuCullMode,
+                        write: bool,
+                        compare: GpuCompareFunction,
+                        bias: (f32, f32),
+                        label: &str|
+         -> Result<GpuRenderPipeline, JsValue> {
+            let vertex = GpuVertexState::new(module);
+            vertex.set_entry_point("vs_main");
+            vertex.set_buffers(buffers);
+            let fragment = GpuFragmentState::new(module, &targets);
+            fragment.set_entry_point("fs_main");
+            let primitive = GpuPrimitiveState::new();
+            primitive.set_topology(topology);
+            primitive.set_cull_mode(cull);
+            primitive.set_front_face(GpuFrontFace::Ccw);
+            let depth = GpuDepthStencilState::new(DEPTH);
+            depth.set_depth_write_enabled(write);
+            depth.set_depth_compare(compare);
+            depth.set_depth_bias(bias.0 as i32);
+            depth.set_depth_bias_slope_scale(bias.1);
+            let desc = GpuRenderPipelineDescriptor::new(layout, &vertex);
+            desc.set_fragment(&fragment);
+            desc.set_primitive(&primitive);
+            desc.set_depth_stencil(&depth);
+            desc.set_label(label);
+            device.create_render_pipeline(&desc)
+        };
+        use GpuCompareFunction::{Less, LessEqual};
+        use GpuPrimitiveTopology::{LineList, TriangleList};
+        let none = (0.0, 0.0);
+        let biased = wire_base_bias(1.0);
+        let fills = [
+            pipeline(&mesh_layout, &module, &mesh_buffers, TriangleList, GpuCullMode::Back, true, Less, none, "scene-fill")?,
+            pipeline(&mesh_layout, &module, &mesh_buffers, TriangleList, GpuCullMode::Back, true, Less, biased, "scene-fill-biased")?,
+            pipeline(&mesh_layout, &module, &mesh_buffers, TriangleList, GpuCullMode::None, false, Less, none, "scene-see-through")?,
+            pipeline(&mesh_layout, &module, &mesh_buffers, TriangleList, GpuCullMode::None, false, Less, biased, "scene-see-through-biased")?,
+        ];
+        let lines = [
+            pipeline(&mesh_layout, &module, &mesh_buffers, LineList, GpuCullMode::None, false, LessEqual, none, "scene-wires")?,
+            pipeline(&mesh_layout, &module, &mesh_buffers, LineList, GpuCullMode::None, true, LessEqual, none, "scene-wires-see-through")?,
+        ];
+        let image_module = shader_module(device, SCENE3D_IMAGE, "scene3d-image");
+        let image_attrs = [
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x3, 0, 0),
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x2, 12, 1),
+        ];
+        let image_buffers =
+            [js_sys::JsOption::wrap(GpuVertexBufferLayout::new(std::mem::size_of::<ImageVertex3D>() as u32, &image_attrs))];
+        let image = pipeline(&image_pipeline_layout, &image_module, &image_buffers, TriangleList, GpuCullMode::None, true, Less, none, "scene-image")?;
+
+        let uniforms = Growable::new(device, STRIDE * 16, buffer_usage::UNIFORM, "scene-uniforms")?;
+        let uniform_group = uniform_group(device, &uniform_layout, &uniforms);
+        let image_verts = Growable::new(device, 1024, buffer_usage::VERTEX, "scene-image-quads")?;
+        let l = glam::Vec3::from_array(DEFAULT_SCENE_LIGHT).normalize().to_array();
+        Ok(Self {
+            format,
+            uniform_layout,
+            fills,
+            lines,
+            image,
+            uniforms,
+            uniform_group,
+            image_verts,
+            meshes: Vec::new(),
+            staged: None,
+            light: l,
+            target: None,
+            backdrop_valid: false,
+        })
+    }
+
+    pub(crate) fn create_mesh(&mut self, device: &GpuDevice, queue: &GpuQueue, verts: &[Vertex3D]) -> MeshId {
+        let bytes: &[u8] = bytemuck::cast_slice(verts);
+        let buffer = Growable::new(device, (bytes.len() as u32).max(64), buffer_usage::VERTEX, "mesh")
+            .expect("a vertex buffer the device can make");
+        let _ = buffer.write(queue, bytes);
+        self.meshes.push(Mesh { buffer, count: verts.len() as u32 });
+        MeshId(self.meshes.len() - 1)
+    }
+
+    pub(crate) fn update_mesh(&mut self, device: &GpuDevice, queue: &GpuQueue, id: MeshId, verts: &[Vertex3D]) {
+        let Some(mesh) = self.meshes.get_mut(id.0) else { return };
+        let bytes: &[u8] = bytemuck::cast_slice(verts);
+        if mesh.buffer.ensure(device, bytes.len() as u32).is_err() {
+            return;
+        }
+        let _ = mesh.buffer.write(queue, bytes);
+        mesh.count = verts.len() as u32;
+    }
+
+    pub(crate) fn stage(&mut self, scissor: (u32, u32, u32, u32), draws: Vec<SceneDraw>) {
+        self.staged = Some(Staged { scissor, draws, images: Vec::new() });
+    }
+
+    pub(crate) fn has_staged(&self) -> bool {
+        self.staged.is_some()
+    }
+
+    pub(crate) fn stage_images(&mut self, images: Vec<SceneImage>) {
+        if let Some(staged) = &mut self.staged {
+            staged.images = images;
+        }
+    }
+
+    /// Size the backdrop and depth to the canvas. A new backdrop holds no
+    /// scene: `backdrop_valid` falls until a scene is drawn into it.
+    pub(crate) fn fit(&mut self, device: &GpuDevice, w: u32, h: u32) -> Result<(), JsValue> {
+        if self.target.as_ref().is_some_and(|t| (t.width, t.height) == (w, h)) {
+            return Ok(());
+        }
+        let backdrop = texture(
+            device,
+            self.format,
+            w,
+            h,
+            texture_usage::RENDER_ATTACHMENT | texture_usage::TEXTURE_BINDING | texture_usage::COPY_SRC,
+            "scene-backdrop",
+        )?;
+        let depth = texture(device, DEPTH, w, h, texture_usage::RENDER_ATTACHMENT, "scene-depth")?;
+        if let Some(old) = self.target.take() {
+            old.backdrop.destroy();
+        }
+        self.target = Some(Target { view: whole_view(&backdrop)?, backdrop, depth_view: whole_view(&depth)?, width: w, height: h });
+        self.backdrop_valid = false;
+        Ok(())
+    }
+
+    /// Record the staged scene into the backdrop (after [`fit`](Self::fit)),
+    /// consuming it. False when nothing was staged.
+    pub(crate) fn record(
+        &mut self,
+        device: &GpuDevice,
+        queue: &GpuQueue,
+        encoder: &GpuCommandEncoder,
+        image_group: &dyn Fn(u32) -> Option<GpuBindGroup>,
+    ) -> Result<bool, JsValue> {
+        let Some(staged) = self.staged.take() else { return Ok(false) };
+        let Some(target) = &self.target else { return Ok(false) };
+        let (w, h) = (target.width, target.height);
+
+        let blocks = scene_uniforms(&staged.draws, &staged.images, [w as f32, h as f32], 0.0, self.light);
+        let mut bytes = vec![0u8; blocks.len().max(1) * STRIDE as usize];
+        for (i, b) in blocks.iter().enumerate() {
+            let at = i * STRIDE as usize;
+            bytes[at..at + UNIFORM_SIZE as usize].copy_from_slice(bytemuck::bytes_of(b));
+        }
+        if self.uniforms.ensure(device, bytes.len() as u32)? {
+            self.uniform_group = uniform_group(device, &self.uniform_layout, &self.uniforms);
+        }
+        self.uniforms.write(queue, &bytes)?;
+        let quads = image_quads_3d(&staged.images);
+        let quad_bytes: &[u8] = bytemuck::cast_slice(&quads);
+        self.image_verts.ensure(device, quad_bytes.len() as u32)?;
+        self.image_verts.write(queue, quad_bytes)?;
+
+        let color = GpuRenderPassColorAttachment::new_with_gpu_texture_view(GpuLoadOp::Clear, GpuStoreOp::Store, &target.view);
+        color.set_clear_value(&[0.0, 0.0, 0.0, 0.0].map(js_sys::Number::from));
+        let depth = GpuRenderPassDepthStencilAttachment::new_with_gpu_texture_view(&target.depth_view);
+        depth.set_depth_load_op(GpuLoadOp::Clear);
+        depth.set_depth_clear_value(1.0);
+        depth.set_depth_store_op(GpuStoreOp::Discard);
+        let desc = GpuRenderPassDescriptor::new(&[js_sys::JsOption::wrap(color)]);
+        desc.set_depth_stencil_attachment(&depth);
+        let pass = encoder.begin_render_pass(&desc)?;
+        let (sx, sy, sw, sh) = staged.scissor;
+        let (sx, sy) = (sx.min(w), sy.min(h));
+        pass.set_scissor_rect(sx, sy, sw.min(w - sx), sh.min(h - sy));
+
+        let offset = |slot: usize| [(slot as u32) * STRIDE];
+        // The images due before mesh draw `at` (past the last draw, every
+        // one left), in staged order.
+        let draw_images = |at: usize| -> Result<(), JsValue> {
+            for (j, image) in staged.images.iter().enumerate() {
+                if (image.before as usize).min(staged.draws.len()) != at {
+                    continue;
+                }
+                let Some(group) = image_group(image.image) else { continue };
+                pass.set_pipeline(&self.image);
+                pass.set_bind_group_with_u32_slice_and_u32_and_dynamic_offsets_data_length(
+                    0,
+                    Some(&self.uniform_group),
+                    &offset(staged.draws.len() + j),
+                    0,
+                    1,
+                )?;
+                pass.set_bind_group(1, Some(&group));
+                pass.set_vertex_buffer_with_u32(0, Some(&self.image_verts.buffer), 0);
+                pass.draw_with_instance_count_and_first_vertex(6, 1, (j * 6) as u32);
+            }
+            Ok(())
+        };
+        for (i, draw) in staged.draws.iter().enumerate() {
+            draw_images(i)?;
+            let Some(mesh) = self.meshes.get(draw.mesh.0) else { continue };
+            if mesh.count == 0 {
+                continue;
+            }
+            let pipeline = if draw.wireframe {
+                &self.lines[draw.see_through as usize]
+            } else {
+                &self.fills[2 * draw.see_through as usize + (draw.wire_base_width > 0.0) as usize]
+            };
+            pass.set_pipeline(pipeline);
+            pass.set_bind_group_with_u32_slice_and_u32_and_dynamic_offsets_data_length(0, Some(&self.uniform_group), &offset(i), 0, 1)?;
+            pass.set_vertex_buffer_with_u32(0, Some(&mesh.buffer.buffer), 0);
+            pass.draw(mesh.count);
+        }
+        draw_images(staged.draws.len())?;
+        pass.end();
+        self.backdrop_valid = true;
+        Ok(true)
+    }
+}
diff --git a/src/web/shell.rs b/src/web/shell.rs
index 00b7096..e471b49 100644
--- a/src/web/shell.rs
+++ b/src/web/shell.rs
@@ -85,13 +85,16 @@ pub async fn run<A: Application>(canvas: HtmlCanvasElement, fonts: Fonts, sizing
     crate::page_fonts::provide(fonts.files, fonts.serif, fonts.sans_serif, fonts.monospace);
     let fs = crate::create_font_system();
 
-    let renderer = WebRenderer::new(canvas.clone()).await?;
+    let mut renderer = WebRenderer::new(canvas.clone()).await?;
     // The scale is known before the app is built, as the Wayland shell sets
     // it before `create`: a widget may read it as it is made.
     let dpr = device_pixel_ratio();
     crate::scale::set_scale_factor(dpr as f32);
     let (tx, rx) = std::sync::mpsc::channel();
-    let app = A::create(AppSender::from(tx));
+    let mut app = A::create(AppSender::from(tx));
+    // The app's persistent GPU resources, as the Wayland shell's
+    // `renderer_init` makes them: once, before the first frame.
+    app.init_3d(&mut renderer);
     let settings = app.settings();
     crate::scale::set_app_id(settings.app_id.clone());
     if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
@@ -333,6 +336,11 @@ impl<A: Application> Shell for WebShell<A> {
             let spans = frame.text_spans(&self.items);
             self.renderer.prepare_text(&mut self.fs, &mut self.swash, &spans);
         }
+        // The app's 3D scene, staged last, as the Wayland shell's
+        // `stage_renderer`; true asks for another frame.
+        if self.app.stage_3d(&mut self.renderer, size, self.scale) {
+            self.redraw = true;
+        }
         let waiting = CAPTURES.with(|c| std::mem::take(&mut *c.borrow_mut()));
         if !waiting.is_empty() {
             self.renderer.capture_next_frame();