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