Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2198,6 +2198,21 @@ its two levels: the unchanged trace read the same when alternated).
passed by its trail (the light the lamps left on the sky made up the mean); with the trail gone it reads 0.066 for
0.1. What it was for is open (Known issues: lamps in motion keep half their light).

**The live passes from one place (2026-11-14, after it).** A buffer and a pass more (the locks' carry, above) took
seven edits that had to agree: the declaration in live.wgsl, the bind group layout's entries, the pass list, the
buffers' sizes and order, the bind group's entries, the dispatch grid (in two copies), and the merged layout's table.
Now live.wgsl's declarations are the layout: build.rs reads them with naga (the compiler wgpu uses; a build
dependency, so the browser's build carries none of it) into each binding's name, number, kind and element stride and
the bindings each entry point uses, and `live/layout.rs` holds only what the shader cannot say: what each binding is
bound to and how many elements a buffer of the passes' own holds (`RESOURCES`), and the passes' order and grids
(`PASSES`). `layout::check` holds the table to the shader (a unit test, and every view as it starts). Natively each
pass has a bind group layout and a bind group of its own, of the bindings its entry point uses (one shared group of
every binding was the passes' layout before). Measured on 39 passes: 36 bind at most the browser's eight storage
buffers; `post` binds 13 (most of them its debug view modes'), `compose` 11, `dtemporal` 10, so the browser's layout
is still the merged one, for all passes, until those three are rearranged. Check: render-identity against the
build before, native and merged, nothing changed; the passes after the trace 6.03-6.09 ms on both (Bistro, 1440p at
0.375, alternated).

## 2. Temporal fixes

Targets (2026-11-08): bench-temporal, bench-ghosting and their UE MRQ references move to Bistro night (§ 0 step 7); the
Expand Down
4 changes: 3 additions & 1 deletion docs/RUNTIME.md
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,9 @@ tree (ROADMAP § 6).
the node host as `GameSession` does, and sends the canvas' clicks down the same path. `node tools/build-web.ts
[game.ts] --serve` builds and serves it (needs the wasm-bindgen CLI of the crate's version).
- **The merged binding layout** (`renderer/src/merged.rs`). WebGPU gives a stage 8 storage buffers by default; the
kernel binds 19 and the live passes 25. There is one source: the browser's shaders are written from
kernel binds 19 and the live passes 25. (Natively each live pass has a layout of its own, of the bindings its
entry point uses: `renderer/src/live/layout.rs`, from live.wgsl as build.rs reads it; three passes use more than
eight, so the browser's layout is still one for all, merged.) There is one source: the browser's shaders are written from
`pathtrace.wgsl` and `live.wgsl` as text when a module is compiled, and the native ones are untouched.
- The kernel's seven tables become the arrays of one struct in one buffer; their lengths are in the source, each a
power of two above a floor, so a kernel is compiled again only when a table doubles. The renderer keeps writing
Expand Down
4 changes: 4 additions & 0 deletions renderer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ rustls = { version = "0.23", optional = true, default-features = false, features
[target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2"

# build.rs reads shaders/live.wgsl's bindings with the compiler wgpu uses (its version: 30), when the crate is built.
[build-dependencies]
naga = { version = "30", features = ["wgsl-in"] }

# src/merged.rs's tests read the generated shaders with the compiler wgpu uses: they are valid, and no stage binds more
# storage buffers than a browser allows.
[dev-dependencies]
Expand Down
59 changes: 59 additions & 0 deletions renderer/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//! The live passes' bindings as `shaders/live.wgsl` declares them, read with naga (the compiler wgpu uses) when the
//! crate is built, into `$OUT_DIR/live_layout.rs` (`src/live/layout.rs` includes it): each binding's name, number,
//! kind and element stride, and the bindings each entry point uses. The shader is the one place they are written;
//! what it cannot say (a buffer's size, a pass's grid) is `src/live/layout.rs`'s table. At build time, not at run
//! time: a browser's build has no naga (wgpu hands it the WGSL).

use naga::{AddressSpace, ImageClass, ImageDimension, ScalarKind, StorageAccess, TypeInner};
use std::fmt::Write;

fn main() {
let path = "shaders/live.wgsl";
println!("cargo:rerun-if-changed={path}");
println!("cargo:rerun-if-changed=build.rs");
let src = std::fs::read_to_string(path).expect("shaders/live.wgsl");
let module = naga::front::wgsl::parse_str(&src).unwrap_or_else(|e| panic!("{}", e.emit_to_string(&src)));
let info = naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::all())
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&src)));
let mut out = String::from("// Generated by build.rs from shaders/live.wgsl: do not edit.\n\n");
// Every bound global, in binding order.
let mut bound: Vec<_> = module.global_variables.iter().filter_map(|(h, v)| v.binding.as_ref().map(|b| (b.binding, h, v))).collect();
bound.sort_by_key(|b| b.0);
out += "/// Each binding live.wgsl declares (group 0), in binding order.\npub const BINDINGS: &[Binding] = &[\n";
for (binding, _, v) in &bound {
let name = v.name.as_deref().unwrap_or_else(|| panic!("binding {binding} has no name"));
assert_eq!(v.binding.as_ref().unwrap().group, 0, "live.wgsl binds group 0 only ({name})");
let ty = &module.types[v.ty].inner;
let kind = match (v.space, ty) {
(AddressSpace::Uniform, _) => "Kind::Uniform".to_string(),
(AddressSpace::Storage { access }, TypeInner::Array { size, stride, .. }) => {
let len = match size {
naga::ArraySize::Constant(n) => n.get(),
_ => 0,
};
format!("Kind::Storage {{ read_only: {}, stride: {stride}, len: {len} }}", !access.contains(StorageAccess::STORE))
}
(AddressSpace::Handle, TypeInner::Image { dim, arrayed: false, class: ImageClass::Sampled { kind: ScalarKind::Float, multi: false } }) => {
let dim = match dim {
ImageDimension::D2 => "D2",
ImageDimension::D3 => "D3",
d => panic!("{name}: a {d:?} texture"),
};
format!("Kind::Texture(wgpu::TextureViewDimension::{dim})")
}
(AddressSpace::Handle, TypeInner::Sampler { comparison: false }) => "Kind::Sampler".to_string(),
(space, _) => panic!("{name}: binding {binding} is a {space:?} this build does not read"),
};
writeln!(out, " Binding {{ name: \"{name}\", binding: {binding}, kind: {kind} }},").unwrap();
}
out += "];\n\n/// Each entry point and the bindings it uses.\npub const ENTRY_POINTS: &[(&str, &[u32])] = &[\n";
for (k, e) in module.entry_points.iter().enumerate() {
let uses = info.get_entry_point(k);
let used: Vec<String> = bound.iter().filter(|(_, h, _)| !uses[*h].is_empty()).map(|(b, _, _)| b.to_string()).collect();
writeln!(out, " (\"{}\", &[{}]),", e.name, used.join(", ")).unwrap();
}
out += "];\n";
let dest = std::path::Path::new(&std::env::var("OUT_DIR").unwrap()).join("live_layout.rs");
std::fs::write(dest, out).unwrap();
}
6 changes: 5 additions & 1 deletion renderer/shaders/live.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ struct Pixel { color: vec4<f32>, albedo: vec4<f32>, normal: vec4<f32>, emit: vec

// ---- The live passes' buffers, by what they hold and how long.
//
// The declarations below are the passes' layout: build.rs reads them (with naga) when the renderer is built, and each
// pass binds what its entry point uses; live/layout.rs says what each binding is bound to, how large a buffer of the
// passes' own is, and the passes' order and grids. A buffer more: its declaration here, a line there.
//
// What lasts from one frame to the next has a buffer of its own, in two halves: this frame's, which the passes write
// (`this_half`), and the previous frame's, which they read (`last_half`); live/frame.rs alternates them (`L.state.x`). A half
// is L.size.x * L.size.y elements, indexed by traced sample (by output pixel: `color_hist`).
Expand All @@ -54,7 +58,7 @@ struct Pixel { color: vec4<f32>, albedo: vec4<f32>, normal: vec4<f32>, emit: vec
// color_hist the output grid's colour history: rgb, samples (`upscale`)
// up_state the upscaler's state per traced sample (`compose`): x, y `Flicker`; z, w `Lock` (its life and the
// view depth of what it is for; where in the traced pixel that is, f16 each)
// What lives within a frame shares buffers whose uses do not overlap (the passes run in live/targets.rs `Passes` order):
// What lives within a frame shares buffers whose uses do not overlap (the passes run in live/layout.rs `PASSES` order):
// sample_vel per traced sample: screen velocity (output pixels this frame), view depth, animated shading
// (`compose`; `judge`, `upscale`, depth of field and motion blur read it)
// judged `judge`'s findings per traced sample (`Judged`), in up_state's previous half: `compose` has read it
Expand Down
17 changes: 8 additions & 9 deletions renderer/src/live/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,14 +505,13 @@ impl LiveCore {
state: [1 - self.frame % 2, 0, 0, 0],
};
g.queue.write_buffer(&self.targets.params, 0, bytemuck::bytes_of(&lp));
let bg = &self.sc.bg;
// Translucent particles: drawn over the upscaled image right after `upscale` (in sb when depth of field / motion
// blur follow, else fa: they have no motion vectors, so they are no part of the colour history; UE draws its
// translucency after the upscaler the same way), then their volume voxels trained.
// (Nothing to draw while every translucent particle system is hidden in place.)
let tl = self.sc.up.translucent.as_ref().filter(|_| g.translucent_particles_live(&self.sc.up)).map(|t| {
let pipes = g.translucent_pipelines(&self.sc.up.kernel, true);
let target = if lp.misc2[2] > 0.0 || lp.dof[0] > 0.0 { &self.targets.sb.buffer } else { &self.targets.fa.buffer };
let target = if lp.misc2[2] > 0.0 || lp.dof[0] > 0.0 { &self.targets.buf("sb").buffer } else { &self.targets.buf("fa").buffer };
// Below full resolution the particles are drawn per traced pixel, then laid over the output (`translucent_up`).
let low = (iw, ih) != (w, h);
let tp = gpu::TranslucentParams { size: [w, h, t.first, 1], view: [iw, ih, gpu::VOLUME_TRAIN_CAP, low as u32], misc: [VOLUME_TRAIN_SHARE, translucent_hidden(self.frame) as u32 as f32, 0.0, 0.0] };
Expand Down Expand Up @@ -554,12 +553,12 @@ impl LiveCore {
}
if let Some(p) = &self.profile {
// One compute pass per stage, each timed.
for (k, (name, pipe)) in self.passes.pipes.iter().enumerate() {
for (k, (name, grid, pipe)) in self.passes.pipes.iter().enumerate() {
let q = (k as u32 + 1) * 2;
let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(name), timestamp_writes: Some(wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(q), end_of_pass_write_index: Some(q + 1) }) });
pass.set_pipeline(pipe);
pass.set_bind_group(0, bg, &[]);
let (gw, gh) = pass_grid(name, (w, h), (iw, ih), self.prev_isz);
pass.set_bind_group(0, &self.sc.bgs[k], &[]);
let (gw, gh) = grid.size((w, h), (iw, ih), self.prev_isz);
pass.dispatch_workgroups(gw.div_ceil(8), gh.div_ceil(8), 1);
drop(pass);
if *name == "upscale" && tl.is_some() {
Expand All @@ -576,10 +575,10 @@ impl LiveCore {
} else {
let tw = self.timer.as_ref().zip(timed).map(|(t, slot)| wgpu::ComputePassTimestampWrites { query_set: &t.queries, beginning_of_pass_write_index: Some(slot as u32 * 8 + 2), end_of_pass_write_index: Some(slot as u32 * 8 + 3) });
let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("live post"), timestamp_writes: tw });
for (name, pipe) in &self.passes.pipes {
for (k, (name, grid, pipe)) in self.passes.pipes.iter().enumerate() {
pass.set_pipeline(pipe);
pass.set_bind_group(0, bg, &[]);
let (gw, gh) = pass_grid(name, (w, h), (iw, ih), self.prev_isz);
pass.set_bind_group(0, &self.sc.bgs[k], &[]);
let (gw, gh) = grid.size((w, h), (iw, ih), self.prev_isz);
pass.dispatch_workgroups(gw.div_ceil(8), gh.div_ceil(8), 1);
if *name == "upscale" {
tl_dispatch(&mut pass, false);
Expand Down Expand Up @@ -645,7 +644,7 @@ impl LiveCore {
}
p.frames += 1;
if p.frames == 120 {
let names: Vec<&str> = std::iter::once("trace").chain(self.passes.pipes.iter().map(|(n, _)| *n)).chain(["cache", "particles", "blas", "translucent", "vtrain"]).collect();
let names: Vec<&str> = std::iter::once("trace").chain(self.passes.pipes.iter().map(|(n, ..)| *n)).chain(["cache", "particles", "blas", "translucent", "vtrain"]).collect();
let mut rows: Vec<String> = names.iter().zip(&p.sums).map(|(n, s)| format!("{n} {:.2}", s / p.frames as f64)).collect();
rows.push(format!("gridsim {:.2}", p.grid_sum / p.frames as f64));
rows.push(format!("media {:.2}", p.media_sum / p.frames as f64));
Expand Down
Loading