diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c5f454e..e2f0ca6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2288,6 +2288,29 @@ orders): native hardware traversal equal (Bistro 8.28-8.97 ms against 8.30-8.96, one-field accessors nor main's parameter order moved the merged +0.2, which comes and goes with the layout: the compiler's arrangement around the accessors, +-0.5 %. +**What the kernel trusts, held by the data (2026-11-14, after it).** The kernel follows indices from buffers and +checks none; the loop guard was the only thing between bad data and a dispatch that never ends. Researched against +pbrt-v4, Embree, Cycles, three-mesh-bvh, Falcor, the DXR and Vulkan specs and wgpu: they hold the invariants in the +builders (children after the parent, a depth cap tied to the stack, forced or median leaves), clean the input where +it enters (Embree and Cycles drop non-finite primitives; DXR makes them inactive), and keep the structural checks to +debug builds and tests; none checks a tree before every upload, none caps a walk's iterations. The same here: +- bvh.rs: no deeper than the kernel's stack (48; where the levels left could not finish a split, the median); + items whose box is not finite in no leaf, after every leaf's range; `check_tree` (the units' check, moved here) + on every tree in tests and debug builds; each build reports its depth (`bvh_depth`). Bistro 18, Kabukichō 26, the + district 33: no tree of today's scenes changes. +- ltree.rs: `LightTree::check` (children after the parent, leaves inside the entries, every entry's code to its + leaf) after every build, growth and refit in tests and debug builds. +- Where data enters: geometry positions finite (`World::set_geometry`), a volume's majorant a finite number > 0 (Rust + and packages/scene: `Infinity` passed both and its delta tracking never stepped), a world matrix that is not finite + places its object at a point (as a hidden instance), the hardware acceleration structures' vertices finite + (`scene::blas_tri`). +- Garbage-input tests for each builder (NaN, infinite and huge values, coincident and collinear items). +docs/RUNTIME.md "What the kernel trusts" has the table. Checks: `cargo test --release` with the loop guard, 138 passed; +render-identity against main, guarded natively, merged and in software traversal, then native without it: 0 of 190 +changed in each (171-175 byte-identical, the rest within their run-to-run spread); `tools/web-hello.ts` in Chrome: +pass. The district's tree builds in 333 ms against main's 321 (one load each; an earlier load of the same build +338). No shader changed. + ## 2. Temporal fixes Targets (2026-11-08): bench-temporal, bench-ghosting and their UE MRQ references move to Bistro night (§ 0 step 7); the diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index 39c00ce..b817b5c 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -772,7 +772,8 @@ tree (ROADMAP § 6). not bit-identical to the kernel's without the guard: the compiler arranges the arithmetic of the functions it is in differently (hello world, one sample: 354 of 43,200 numbers differ, by at most 2 × 10⁻⁷ of their value). What it stops it counts: the count is read back without waiting (`LiveCore::guard_trips`, the page's `guardTrips()`, the frame log), an offline - render in which it moved is an error, and anything but 0 means wrong pixels. Natively `THREERT_LOOP_GUARD=` + render in which it moved is an error, and anything but 0 means wrong pixels, and a bug in what the kernel was given + (the guarantee is the data's: "What the kernel trusts"). Natively `THREERT_LOOP_GUARD=` sets it; with nothing set a kernel with units has it (65,536; `THREERT_LOOP_GUARD=0` takes it out) and every other kernel has none, as before ("Software traversal with units"). The live passes need none: their loops are counted loops over constants and clamped parameters. @@ -1265,6 +1266,43 @@ kernel is not given is an error. - The repository's Rust is pinned (`rust-toolchain.toml`, 1.100.0): wesl-rs 0.6 asks for 1.97.1 or newer, and the determinism checks' recorded hashes are of one compiler. +## What the kernel trusts + +The kernel follows indices it reads from buffers and loops over counts it is given; it checks none of them (a +check on every read would cost the trace, and naga's and Tint's own guards do not help: a clamped index can land on +a real node and close a cycle, and their loop bound is 2^64 iterations). That a walk ends and reads only what is +there is the data's to guarantee, as in the references (pbrt-v4, Embree, Cycles, three-mesh-bvh, Falcor; DXR and +Vulkan for what a driver is given): the builders hold the invariants by how they build, what enters the engine is +made finite where it enters, and the structural checks run in tests and debug builds. The loop guard ("The engine +in a browser") is the last line, not the guarantee: a count above zero is a bug in what follows here. + +| what the kernel walks | built by | what holds it | +|---|---|---| +| the still scene's tree (software traversal), a procedural object's boxes' tree | bvh.rs `build_tris`, `build_boxes` | children after their parent and inside the tree (so a walk that goes down and pops what it pushed reaches each node at most once and ends, whatever the boxes say); every node reached once; leaves of 1 to 16 items inside the items; boxes finite (an item whose box is not is in no leaf, after every leaf's range, its index kept); no deeper than the kernel's stack (48: where the levels left could not finish the split the surface-area heuristic wants, the median); `check_tree` in tests and debug builds | +| the units' trees, the top tree | units.rs | the same, checked at every build and edit (an incremental path, cheap per unit) | +| the light tree | ltree.rs | children after their parent; median splits, so the depth is log2 of the entries, within the path codes' 26 bits; leaves of 1 to 4 entries (the child word's two bits); every entry's code leads to its leaf; `LightTree::check` after every build, growth and refit in tests and debug builds. Its walks end by their bounds whatever the data (`LT_PATH_BITS`, the code's depth, four entries) | +| the emitter CDF's searches | lib.rs | binary searches end on any data (the interval shrinks); the guide table's entries are the CDF's | +| counts (lights, emitters, directional lights, media, procedural objects, bounces) | lib.rs `prepare`, live/edits.rs | the lengths of the arrays uploaded with them; media at most `MAX_MEDIA` | +| a medium's delta tracking | world.rs `set_object`, packages/scene `VolumeMesh` | the majorant a finite number > 0 (an infinite one never steps: the walk through the volume would not end) | + +Where what is built from enters, it is finite: +- **geometry**: `World::set_geometry` refuses positions that are not finite (as it refuses indices out of range); + procedural boxes must be finite with min <= max (procasset.rs `check_boxes`); density grids and their sources + finite (as before); +- **transforms** are kept as set (a script may set NaN), and a world matrix that is not finite places its object at + a point (`World::world_matrix`), as a hidden instance is placed (`scene::instance_world`): what it draws is not + seen, and nothing built from it gets a number that is not finite; +- **the hardware acceleration structures' vertices**: a triangle with a vertex that is not finite (what overflows on + the way) is given to them as three vertices at the origin (`scene::blas_tri`): DXR and Vulkan leave such a vertex + undefined behaviour and define a degenerate triangle as never hit; its index stays. + +Measured (2026-11-14, software traversal's still tree, `bvh_depth` in a session's load line): Bistro 18 levels, +Kabukichō 26, the Kabukichō district 33: the stack's 48 changes none of them. A tree of boxes spaced in powers of two +is as deep as SAH makes it unheld, and no deeper than asked held (bvh.rs `a_tree_is_no_deeper_than_its_stack`). +Garbage in (NaN and infinite vertices, values near f32's limit, a thousand copies of one triangle, a needle; +emitters likewise) makes trees the checks pass and the kernel's walk ends on (`garbage_in_makes_a_tree_the_kernel_walks`, +`garbage_in_makes_a_tree_the_kernel_reads`). + ## Android (Adreno): what a phone's Vulkan driver needed The engine's library builds for `aarch64-linux-android` and runs under an Android client on Vulkan with ray queries. diff --git a/packages/scene/src/core.ts b/packages/scene/src/core.ts index d6a48d4..8f29f4a 100644 --- a/packages/scene/src/core.ts +++ b/packages/scene/src/core.ts @@ -1560,7 +1560,7 @@ export class VolumeMesh extends Object3D { } constructor(o: { shape?: "box" | "sphere"; volume: VolumeBinding; majorant: number; anisotropy?: number; homogeneous?: boolean; grid?: DensityGrid | null }) { super(); - if (!(o.majorant > 0)) throw new Error("VolumeMesh: majorant must be > 0 (1/m)"); + if (!(o.majorant > 0 && Number.isFinite(o.majorant))) throw new Error("VolumeMesh: majorant must be a finite number > 0 (1/m)"); this.grid = o.grid ?? null; if (this.grid) { if ((o.shape ?? "box") !== "box") throw new Error("VolumeMesh: a density grid needs a box volume"); diff --git a/packages/scene/test/engine.test.ts b/packages/scene/test/engine.test.ts index 0b064d2..3e5c1c9 100644 --- a/packages/scene/test/engine.test.ts +++ b/packages/scene/test/engine.test.ts @@ -389,6 +389,8 @@ test("volume functions: one that reads the time is animated, unless it says othe assert.deepEqual([still, drift, helper, custom, held].map((f) => f.animated), [false, true, true, true, false]); const scene = new Scene(); scene.add(new VolumeMesh({ volume: still.with(), majorant: 1 }), new VolumeMesh({ volume: drift.with(), majorant: 1 })); + // (Delta tracking steps by -ln(1 - u) / majorant: an infinite one would never step.) + for (const bad of [Infinity, NaN, 0, -1]) assert.throws(() => new VolumeMesh({ volume: still.with(), majorant: bad }), /finite number > 0/); const cam = new PerspectiveCamera(); scene.add(cam); const { calls } = bind(scene, cam); diff --git a/renderer/src/bvh.rs b/renderer/src/bvh.rs index 662bdaa..b102aae 100644 --- a/renderer/src/bvh.rs +++ b/renderer/src/bvh.rs @@ -1,4 +1,5 @@ -//! Binned-SAH BVH over triangles. Leaves reference contiguous ranges of the reordered triangle list. +//! Binned-SAH BVH over triangles (and over boxes). Leaves reference contiguous ranges of the reordered list. What a +//! tree this module makes holds to, and why the kernel's walk ends on it: `build_tris`. use glam::Vec3A as V; @@ -37,12 +38,22 @@ impl Aabb { pub struct Bvh { pub nodes: Vec, - /// order[i] = original triangle index stored at position i. + /// order[i] = original item (triangle or box) stored at position i. Items whose box is not finite are in no leaf: + /// they come after every leaf's range, so they keep a position (the kernel's arrays hold them; a triangle's index + /// is its position) and no walk reaches them. pub order: Vec, + /// Levels below the root (0: the root is a leaf). At most the `max_depth` it was built for. + pub depth: usize, } const BINS: usize = 16; const MAX_LEAF: usize = 4; +/// The most items a leaf the surface-area heuristic prefers to a split may hold. +const SAH_LEAF: usize = 16; +/// The kernel's stack for this module's trees (pathtrace.wgsl `trace`, `trace_procs`: `array`). A walk holds +/// at most one entry a level of its tree, so a tree no deeper than this is walked whole; a push past it would be +/// dropped (geometry missed, never a hang). +pub const STACK: usize = 48; /// The tree over no triangle, as the kernel can walk it. A node with `count: 0` is an interior node to the kernel /// (pathtrace.wgsl `trace`): the one-node tree this module used to make for nothing had itself for a left child and, @@ -53,43 +64,86 @@ const MAX_LEAF: usize = 4; /// determinant is 0). One node read, no child, no box. pub const EMPTY: GpuNode = GpuNode { bmin: [0.0; 3], left_or_first: 0, bmax: [0.0; 3], count: 1 }; +/// The tree over triangles the kernel's software traversal walks (the still scene's), no deeper than `STACK`. pub fn build(tris: &[[V; 3]]) -> Bvh { + build_tris(tris, STACK) +} + +/// The tree over triangles, no deeper than `max_depth` (docs/RUNTIME.md "What the kernel trusts": what every tree +/// this module makes is): +/// - children after their parent: an interior node's children are the nodes at `left` and `left + 1`, both after it +/// and inside the tree, so a walk that goes only down and pops what it pushed reaches each node at most once and +/// ends, whatever the boxes say; +/// - every node reached exactly once from the root; leaves of 1 to `SAH_LEAF` items, inside the items; +/// - every box finite: an item whose box is not (a vertex NaN or infinite) is in no leaf (`Bvh::order`); +/// - no deeper than `max_depth`: binned SAH is not balanced, so where the levels left could not finish a split it +/// makes (both sides no deeper than `max_depth` by halving), the node is split at the median instead. +/// +/// In tests and debug builds `check_tree` holds every tree it returns to this. +pub fn build_tris(tris: &[[V; 3]], max_depth: usize) -> Bvh { if tris.is_empty() { - return Bvh { nodes: vec![EMPTY], order: vec![] }; + return Bvh { nodes: vec![EMPTY], order: vec![], depth: 0 }; } let n = tris.len(); let mut boxes = Vec::with_capacity(n); let mut centroids = Vec::with_capacity(n); for t in tris { + // (Whether a triangle is finite is read from its vertices, not from its box: a min or max with a NaN gives the + // NaN on one CPU and the other number on another (NEON / SSE), so the box can hide it.) let mut b = Aabb::EMPTY; - for p in t { - b.grow(*p); + if t.iter().all(|p| p.is_finite()) { + for p in t { + b.grow(*p); + } } boxes.push(b); centroids.push((t[0] + t[1] + t[2]) / 3.0); } - build_from(boxes, centroids) + let b = build_from(boxes, centroids, max_depth); + // (Every triangle's box not finite: the tree over nothing.) + if b.nodes.len() == 1 && b.nodes[0].count == 0 { + return Bvh { nodes: vec![EMPTY], order: b.order, depth: 0 }; + } + b } -/// The same BVH over boxes (min xyz, max xyz each; procedural geometry, software traversal), split at their centres. -/// Over no box it is one node the kernel cannot walk (`EMPTY`'s comment; a leaf would name a box that is not there): -/// software traversal leaves objects without boxes out of what the kernel is given (lib.rs `prepare`). +/// The same BVH over boxes (min xyz, max xyz each; procedural geometry, software traversal), split at their centres, +/// no deeper than `STACK`. Over no box it is one node the kernel cannot walk (`EMPTY`'s comment; a leaf would name a +/// box that is not there): software traversal leaves objects without boxes out of what the kernel is given (lib.rs +/// `prepare`), and boxes that are not finite are refused where they are given (world.rs `set_proc_geometry`). pub fn build_boxes(b: &[f32]) -> Bvh { - let boxes: Vec = b.chunks_exact(6).map(|c| Aabb { min: V::new(c[0], c[1], c[2]), max: V::new(c[3], c[4], c[5]) }).collect(); + let boxes: Vec = b.chunks_exact(6).map(|c| if c.iter().all(|v| v.is_finite()) { Aabb { min: V::new(c[0], c[1], c[2]), max: V::new(c[3], c[4], c[5]) } } else { Aabb::EMPTY }).collect(); let centroids = boxes.iter().map(|a| (a.min + a.max) * 0.5).collect(); - build_from(boxes, centroids) + build_from(boxes, centroids, STACK) +} + +/// Levels of halving `count` items take to reach leaves of `MAX_LEAF`. +fn levels(count: usize) -> usize { + count.div_ceil(MAX_LEAF).next_power_of_two().trailing_zeros() as usize +} + +impl Aabb { + fn finite(&self) -> bool { + self.min.is_finite() && self.max.is_finite() && self.min.cmple(self.max).all() + } } -fn build_from(boxes: Vec, centroids: Vec) -> Bvh { - let n = boxes.len(); - let mut order: Vec = (0..n as u32).collect(); +/// Over `boxes`, those not finite (`Aabb::EMPTY` for an item the caller found not finite) in no leaf. +fn build_from(boxes: Vec, centroids: Vec, max_depth: usize) -> Bvh { + // Items with a box that is not finite go last, in no leaf. + let mut order: Vec = (0..boxes.len() as u32).filter(|&i| boxes[i as usize].finite()).collect(); + let n = order.len(); + order.extend((0..boxes.len() as u32).filter(|&i| !boxes[i as usize].finite())); let mut nodes = vec![GpuNode { bmin: [0.0; 3], left_or_first: 0, bmax: [0.0; 3], count: 0 }]; if n == 0 { - return Bvh { nodes, order }; + return Bvh { nodes, order, depth: 0 }; } - // (node index, start, end) - let mut stack = vec![(0usize, 0usize, n)]; - while let Some((ni, start, end)) = stack.pop() { + assert!(levels(n) <= max_depth, "{n} items do not fit a tree {max_depth} deep"); + let mut deepest = 0; + // (node index, start, end, depth) + let mut stack = vec![(0usize, 0usize, n, 0usize)]; + while let Some((ni, start, end, depth)) = stack.pop() { + deepest = deepest.max(depth); let mut bounds = Aabb::EMPTY; let mut cb = Aabb::EMPTY; for &t in &order[start..end] { @@ -135,18 +189,20 @@ fn build_from(boxes: Vec, centroids: Vec) -> Bvh { acc.merge(&bin_box[i]); c += bin_cnt[i]; let cost = left_area[i - 1] * left_cnt[i - 1] as f32 + acc.area() * c as f32; - if left_cnt[i - 1] > 0 && c > 0 && cost < best.0 { + // (A split the levels left could not finish is not taken: the median split below is.) + let fits = depth + 1 + levels(left_cnt[i - 1].max(c)) <= max_depth; + if left_cnt[i - 1] > 0 && c > 0 && cost < best.0 && fits { best = (cost, axis, i); } } } let leaf_cost = bounds.area() * count as f32; let splittable = best.0.is_finite(); - if (splittable && best.0 >= leaf_cost && count <= 16) || (!splittable && count <= 16) { + if count <= SAH_LEAF && (!splittable || best.0 >= leaf_cost) { make_leaf(&mut nodes); continue; } - let mid = if best.0.is_finite() { + let mid = if splittable { let (_, axis, split) = best; let scale = BINS as f32 / ext[axis]; let slice = &mut order[start..end]; @@ -159,18 +215,72 @@ fn build_from(boxes: Vec, centroids: Vec) -> Bvh { } } start + i - } else { + } else if (0..3).all(|k| ext[k] <= 1e-9) { // Degenerate centroids: split in the middle. start + count / 2 + } else { + // No split the levels left could finish: the median along the centroids' widest axis, which halves. + let axis = (0..3).max_by(|&x, &y| ext[x].total_cmp(&ext[y])).unwrap_or(0); + let half = count / 2; + order[start..end].select_nth_unstable_by(half, |p, q| centroids[*p as usize][axis].total_cmp(¢roids[*q as usize][axis])); + start + half }; let left = nodes.len(); nodes.push(GpuNode { bmin: [0.0; 3], left_or_first: 0, bmax: [0.0; 3], count: 0 }); nodes.push(GpuNode { bmin: [0.0; 3], left_or_first: 0, bmax: [0.0; 3], count: 0 }); nodes[ni] = GpuNode { bmin: bounds.min.into(), left_or_first: left as u32, bmax: bounds.max.into(), count: 0 }; - stack.push((left + 1, mid, end)); - stack.push((left, start, mid)); + stack.push((left + 1, mid, end, depth + 1)); + stack.push((left, start, mid, depth + 1)); + } + #[cfg(any(test, debug_assertions))] + { + let checked = check_tree(&nodes, max_depth, |first, count| if count as usize <= SAH_LEAF && first as usize + count as usize <= n { Ok(()) } else { Err(format!("items {first}..{} of {n}", first as u64 + count as u64)) }); + assert_eq!(checked, Ok(deepest), "bvh: a tree the kernel cannot walk as this module says"); } - Bvh { nodes, order } + Bvh { nodes, order, depth: deepest } +} + +/// Checks that `nodes` is a tree the kernel's walk ends on, from node 0 (`build_tris`' list): every interior node's +/// children are the two nodes at `left` and `left + 1`, `left` greater than the node's own index and inside the +/// tree; every node is reached exactly once; every box is finite; every leaf passes `leaf(first, count)`; nothing is +/// deeper than `max_depth`. Returns the depth (levels below the root). (The units' trees are held to it at every +/// build, units.rs; this module's in tests and debug builds.) +pub fn check_tree(nodes: &[GpuNode], max_depth: usize, leaf: impl Fn(u32, u32) -> Result<(), String>) -> Result { + if nodes.is_empty() { + return Err("a tree without nodes".into()); + } + let finite = |n: &GpuNode| n.bmin.iter().chain(&n.bmax).all(|v| v.is_finite()) && (0..3).all(|k| n.bmin[k] <= n.bmax[k]); + let mut seen = vec![false; nodes.len()]; + let mut stack = vec![(0usize, 0usize)]; + let (mut deepest, mut reached) = (0, 0); + while let Some((i, depth)) = stack.pop() { + if std::mem::replace(&mut seen[i], true) { + return Err(format!("node {i} is reached twice")); + } + reached += 1; + deepest = deepest.max(depth); + let n = &nodes[i]; + if n.count > 0 { + leaf(n.left_or_first, n.count).map_err(|e| format!("leaf {i}: {e}"))?; + continue; + } + if !finite(n) { + return Err(format!("node {i}'s box is not finite")); + } + let l = n.left_or_first as usize; + if l <= i || l + 1 >= nodes.len() { + return Err(format!("node {i}'s children {l}, {} are not after it and inside the tree's {} nodes", l + 1, nodes.len())); + } + if depth + 1 > max_depth { + return Err(format!("deeper than {max_depth}")); + } + stack.push((l, depth + 1)); + stack.push((l + 1, depth + 1)); + } + if reached != nodes.len() { + return Err(format!("{} of {} nodes are not reached", nodes.len() - reached, nodes.len())); + } + Ok(deepest) } #[cfg(test)] @@ -297,6 +407,61 @@ mod tests { /// over no triangle (a scene with nothing in it, or with procedural geometry only), one, a leaf's worth, many. /// The empty scene's vertex buffer holds zeros (gpu.rs `upload`: 512 bytes, so triangles 0..2 are there, without /// area). + /// Garbage in (as Embree's builders are tested): every tree `build_tris` makes of it is one `check_tree` passes + /// (the builder checks itself in tests), that the kernel's walk ends on within its buffers, with the items whose + /// box is not finite after every leaf's range. NaN and infinite vertices among good ones, all of them bad, values + /// near f32's limit, a thousand copies of one triangle (centroids that coincide), a needle (centroids on a line). + #[test] + fn garbage_in_makes_a_tree_the_kernel_walks() { + let mut rnd = stream(11); + let mut p = |s: f32| V::new(rnd() - 0.5, rnd() - 0.5, rnd() - 0.5) * s; + let good: Vec<[V; 3]> = (0..600).map(|_| { let c = p(20.0); [c + p(1.0), c + p(1.0), c + p(1.0)] }).collect(); + let (nan, inf) = (V::new(f32::NAN, 0.0, 0.0), V::new(0.0, f32::INFINITY, 0.0)); + let mut mixed = good.clone(); + for k in (0..mixed.len()).step_by(7) { + mixed[k][k % 3] = if k % 2 == 0 { nan } else { inf }; + } + let cases: Vec<(&str, Vec<[V; 3]>)> = vec![ + ("mixed", mixed), + ("all bad", vec![[nan, V::ZERO, V::ONE], [V::ZERO, inf, V::ONE], [V::splat(f32::NEG_INFINITY), V::ZERO, V::ONE]]), + ("near the limit", (0..200).map(|k| { let c = V::splat(3.0e38 * if k % 2 == 0 { 1.0 } else { -1.0 }); [c, c * 0.5, V::ZERO] }).collect()), + ("one triangle a thousand times", vec![good[0]; 1000]), + ("a needle", (0..500).map(|k| { let c = V::new(k as f32 * 1e-3, 0.0, 0.0); [c, c + V::Y * 1e-4, c + V::Z * 1e-4] }).collect()), + ]; + for (what, tris) in cases { + let b = build_tris(&tris, STACK); + let bad: Vec = (0..tris.len() as u32).filter(|&i| !tris[i as usize].iter().all(|v| v.is_finite())).collect(); + let firsts = b.nodes.iter().filter(|n| n.count > 0).map(|n| n.left_or_first + n.count).max().unwrap_or(0) as usize; + if bad.len() < tris.len() { + assert!(firsts <= tris.len() - bad.len(), "{what}: a leaf reaches the items left out"); + } + let mut tail = b.order[tris.len() - bad.len()..].to_vec(); + tail.sort(); + assert_eq!(tail, bad, "{what}: the items left out come last"); + for k in 0..200 { + let o = V::new(k as f32 - 100.0, 7.0, 3.0); + walk(&b.nodes, tris.len().max(3), o, (V::new(0.3, -1.0, 0.2) - o * 0.001).normalize()).unwrap_or_else(|e| panic!("{what}: {e}")); + } + } + } + + /// Binary SAH is not balanced: boxes spaced in powers of two make it split one off at a time. Held to a depth, the + /// builder splits at the median where the levels left could not finish the split SAH wants: the tree is no deeper + /// than asked, and unchanged where it fits (the same scene with room enough is the same tree). + #[test] + fn a_tree_is_no_deeper_than_its_stack() { + let tris: Vec<[V; 3]> = (0..120).map(|k| { let c = V::new(2f32.powi(k), 0.0, 0.0); [c, c + V::Y, c + V::Z] }).collect(); + let free = build_tris(&tris, 1000); + assert!(free.depth > 20, "the spacing makes SAH deep ({})", free.depth); + for cap in [8, 12, 20] { + let held = build_tris(&tris, cap); + assert!(held.depth <= cap); + } + let roomy = build_tris(&tris, free.depth); + assert_eq!((roomy.depth, roomy.order.clone()), (free.depth, free.order.clone()), "where the tree fits, the cap changes nothing"); + assert_eq!(bytemuck::cast_slice::(&roomy.nodes), bytemuck::cast_slice::(&free.nodes)); + } + /// The tree over no box is not one the kernel can walk (it reads past it): why `prepare` leaves such objects out. #[test] fn a_tree_over_no_box_is_left_out() { diff --git a/renderer/src/gpu.rs b/renderer/src/gpu.rs index fc999e3..3f1531f 100644 --- a/renderer/src/gpu.rs +++ b/renderer/src/gpu.rs @@ -1985,7 +1985,7 @@ impl Gpu { .chunks(3) .flat_map(|t| { let v0 = [t[0][0], t[0][1], t[0][2]]; - [v0, [v0[0] + t[1][0], v0[1] + t[1][1], v0[2] + t[1][2]], [v0[0] + t[2][0], v0[1] + t[2][1], v0[2] + t[2][2]]] + crate::scene::blas_tri([v0, [v0[0] + t[1][0], v0[1] + t[1][1], v0[2] + t[1][2]], [v0[0] + t[2][0], v0[1] + t[2][1], v0[2] + t[2][2]]]) }) .collect(); let vbuf = buf_room("blas_verts", bytemuck::cast_slice(&verts), room.tris * 36, wgpu::BufferUsages::BLAS_INPUT | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::STORAGE); diff --git a/renderer/src/lib.rs b/renderer/src/lib.rs index b731de8..367c70d 100644 --- a/renderer/src/lib.rs +++ b/renderer/src/lib.rs @@ -434,9 +434,11 @@ pub fn prepare_as(hwrt: bool, units: bool, sc: &mut scene::Scene, timings: &mut let bvh = if hwrt { let mut order: Vec = (0..sc.tris.len() as u32).collect(); order.sort_by_key(|&i| group_of(sc.tri_flags[i as usize])); - bvh::Bvh { nodes: vec![bvh::GpuNode { bmin: [0.0; 3], left_or_first: 0, bmax: [0.0; 3], count: 0 }], order } + bvh::Bvh { nodes: vec![bvh::GpuNode { bmin: [0.0; 3], left_or_first: 0, bmax: [0.0; 3], count: 0 }], order, depth: 0 } } else { - bvh::build(&sc.tris) + let b = bvh::build(&sc.tris); + timings.push(("bvh_depth".into(), b.depth as f64)); + b }; let mut groups = [0usize; GROUPS]; for f in &sc.tri_flags { diff --git a/renderer/src/live/edits.rs b/renderer/src/live/edits.rs index 8fbf505..61399ba 100644 --- a/renderer/src/live/edits.rs +++ b/renderer/src/live/edits.rs @@ -1126,7 +1126,7 @@ impl LiveCore { let (e1, e2) = (pw[1] - pw[0], pw[2] - pw[0]); verts[k * 3..k * 3 + 3].copy_from_slice(&[[pw[0].x, pw[0].y, pw[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); attrs[k] = scene::GpuTriAttr { x: [a.x[0], f32::from_bits(d.object), a.x[2], attrs[k].x[3]], ..a }; - blas[k * 3..k * 3 + 3].copy_from_slice(&pw.map(|v| v.to_array())); + blas[k * 3..k * 3 + 3].copy_from_slice(&scene::blas_tri(pw.map(|v| v.to_array()))); self.sc.tris[(d.first + j as u32) as usize] = pw.map(glam::Vec3A::from); (lo, hi) = (lo.min(k), hi.max(k + 1)); } @@ -1240,7 +1240,7 @@ impl LiveCore { // The flag word keeps TRI_DYNAMIC: the kernel takes a deforming mesh's motion from `prev_verts`. v.extend_from_slice(&[[q.p[0].x, q.p[0].y, q.p[0].z, f32::from_bits(scene::TRI_DYNAMIC)], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); at.push(scene::tri_attr(q.p, q.n, q.uv, q.tangent, mat)); - b.extend(q.p.map(|p| p.to_array())); + b.extend(scene::blas_tri(q.p.map(|p| p.to_array()))); t.push(q.p.map(glam::Vec3A::from)); } Ok(Some((v, at, b, t))) @@ -1429,7 +1429,7 @@ impl LiveCore { v[j * 3..j * 3 + 3].copy_from_slice(&[[p[0].x, p[0].y, p[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); // (x.w: an emitter's light-tree code, which stays with its triangle.) a[j] = scene::GpuTriAttr { x: [at.x[0], f32::from_bits(d.object), at.x[2], a[j].x[3]], ..at }; - b[j * 3..j * 3 + 3].copy_from_slice(&p.map(|q| q.to_array())); + b[j * 3..j * 3 + 3].copy_from_slice(&scene::blas_tri(p.map(|q| q.to_array()))); t[j] = p.map(glam::Vec3A::from); } Ok(true) @@ -1451,7 +1451,7 @@ impl LiveCore { let (e1, e2) = (p[1] - p[0], p[2] - p[0]); verts[k * 3..k * 3 + 3].copy_from_slice(&[[p[0].x, p[0].y, p[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); attrs[k] = scene::GpuTriAttr { x: [a.x[0], f32::from_bits(d.object), a.x[2], attrs[k].x[3]], ..a }; - blas[k * 3..k * 3 + 3].copy_from_slice(&p.map(|v| v.to_array())); + blas[k * 3..k * 3 + 3].copy_from_slice(&scene::blas_tri(p.map(|v| v.to_array()))); sc.tris[(d.first + j as u32) as usize] = p.map(glam::Vec3A::from); (lo, hi) = (lo.min(k), hi.max(k + 1)); } diff --git a/renderer/src/ltree.rs b/renderer/src/ltree.rs index 9fbdecf..c8366c3 100644 --- a/renderer/src/ltree.rs +++ b/renderer/src/ltree.rs @@ -151,6 +151,73 @@ fn union_sphere((c1, r1): ([f32; 3], f32), (c2, r2): ([f32; 3], f32)) -> [f32; 2 } impl LightTree { + /// Checks the tree as the kernel reads it (pathtrace.wgsl `emitter_draw`, `emitter_sel`): from node 0, every + /// interior node's children are the nodes at its child word and the next, after it and inside the tree; every + /// node is reached exactly once, no deeper than `PATH_BITS`; every leaf's 1 to 4 entries (its word) are its range + /// (`ranges`) and inside the entries, the leaves together holding every entry once (the empty slot of emitters + /// added in place, a leaf of power 0 over nothing, apart); and every entry's code leads from the root, level by + /// level, to the leaf that holds it (what `emitter_sel` follows). Returns the depth. Run in tests and debug + /// builds after every build, growth and refit (refits keep the topology: what they could break is the numbers). + pub fn check(&self) -> Result { + let (nodes, entries) = (self.nodes.len(), self.order.len()); + if nodes == 0 || self.ranges.len() != nodes || self.code.len() != entries { + return Err(format!("{nodes} nodes, {} ranges, {entries} entries, {} codes", self.ranges.len(), self.code.len())); + } + let word = |i: usize| self.nodes[i][3].to_bits(); + let leaf = |w: u32| (w & LEAF != 0).then(|| (w & 0x1fff_ffff, ((w >> 29) & 3) + 1)); + let (mut seen, mut held) = (vec![false; nodes], vec![0u8; entries]); + let (mut stack, mut deepest) = (vec![(0usize, 0u32)], 0); + while let Some((i, depth)) = stack.pop() { + if std::mem::replace(&mut seen[i], true) { + return Err(format!("node {i} is reached twice")); + } + if depth > PATH_BITS { + return Err(format!("node {i} is deeper than {PATH_BITS}")); + } + deepest = deepest.max(depth); + let (lo, hi) = self.ranges[i]; + match leaf(word(i)) { + Some(_) if lo == hi => {} + Some((first, count)) => { + if (first, first + count) != (lo, hi) || hi as usize > entries { + return Err(format!("leaf {i}: entries {first}..{} in its word, {lo}..{hi} in its range, of {entries}", first + count)); + } + (lo..hi).for_each(|k| held[k as usize] += 1); + } + None => { + let l = word(i) as usize; + if l <= i || l + 1 >= nodes { + return Err(format!("node {i}'s children {l}, {} are not after it and inside the tree's {nodes} nodes", l + 1)); + } + stack.extend([(l, depth + 1), (l + 1, depth + 1)]); + } + } + } + if let Some(i) = seen.iter().position(|s| !s) { + return Err(format!("node {i} is not reached")); + } + if let Some(k) = held.iter().position(|&h| h != 1) { + return Err(format!("entry {k} is in {} leaves", held[k])); + } + for (k, &c) in self.code.iter().enumerate() { + let depth = (c >> PATH_BITS).wrapping_sub(1); + if depth > PATH_BITS { + return Err(format!("entry {k}'s code {c:#x}: depth {depth}")); + } + let mut i = 0usize; + for level in 0..depth { + if leaf(word(i)).is_some() { + return Err(format!("entry {k}'s code goes past leaf {i}")); + } + i = word(i) as usize + ((c >> level) & 1) as usize; + } + if !matches!(leaf(word(i)), Some((first, count)) if (first..first + count).contains(&(k as u32))) { + return Err(format!("entry {k}'s code leads to node {i}, not its leaf")); + } + } + Ok(deepest as usize) + } + /// Node powers again from the entries' weights (power / material edits; `weight(k)` of entry k = position in /// `order`): same topology, sums bottom-up (children come after their parent). pub fn refit(&mut self, weight: &dyn Fn(usize) -> f32) { @@ -164,6 +231,7 @@ impl LightTree { }; } self.power = self.nodes.first().map_or(0.0, |b| b[2]); + self.checked(); } /// The moving emitters moved: their leaves' spheres from their triangles now (`verts(k)`: entry k's vertices), @@ -192,6 +260,15 @@ impl LightTree { self.nodes[i][0] = s[0]; self.nodes[i][1] = s[1]; } + self.checked(); + } + + /// `check` in tests and debug builds (bvh.rs `build_tris` says why not always). + fn checked(&self) { + #[cfg(any(test, debug_assertions))] + if let Err(e) = self.check() { + panic!("light tree: {e}"); + } } /// The subtree of emitters added in place grown again over `items` (`build_slotted`; none: empty, power 0): @@ -216,6 +293,7 @@ impl LightTree { } self.nodes[0][2] = self.nodes[1][2] + self.nodes[2][2]; self.power = self.nodes[0][2]; + self.checked(); } /// Grows a subtree over `items` (entries `first..first + items.len()`, in the order it leaves them) from node @@ -326,6 +404,7 @@ fn build_with(tris: &[EmitterTri], tri_verts: &[[f32; 4]], weight: &dyn Fn(usize tree.ranges[0] = (0, (ns + nm) as u32); } tree.power = tree.nodes[0][2]; + tree.checked(); Some(tree) } @@ -337,6 +416,31 @@ mod tests { (EmitterTri { tri: i, mat: 0, obj: 0, area: 0.5, c: [x + 1.0 / 3.0, 1.0 / 3.0, 0.0], moves: false, nominal: false, in_tree: false }, [[x, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]]) } + /// Garbage in: emitters whose triangles are not finite, that coincide, or carry weights near f32's limit make a + /// tree `check` passes (the builder checks itself in tests), with and without the slot for emitters added in + /// place and moving ones, and the slot grown over garbage too. + #[test] + fn garbage_in_makes_a_tree_the_kernel_reads() { + let n = 700u32; + let (mut tris, verts): (Vec, Vec<[[f32; 4]; 3]>) = (0..n).map(|i| tri(i, (i % 50) as f32)).unzip(); + let mut verts: Vec<[f32; 4]> = verts.into_iter().flatten().collect(); + for k in (0..n as usize).step_by(9) { + verts[k * 3][k % 3] = if k % 2 == 0 { f32::NAN } else { f32::INFINITY }; + tris[k].c = [f32::NAN, 0.0, 0.0]; + } + for k in (0..n as usize).step_by(13) { + tris[k].moves = k % 26 == 0; + } + let w = |i: usize| if i % 11 == 0 { 3.0e38 } else { 1.0 }; + for slot in [false, true] { + let mut t = build_with(&tris, &verts, &w, slot).expect("a tree"); + t.check().unwrap(); + t.refit(&|k| if k % 5 == 0 { f32::NAN } else { 2.0 }); + t.set_dynamic((0..300).map(|k| DynItem { e: k, lo: [f32::NAN; 3], hi: [0.0; 3], c: [(k % 3) as f32, f32::INFINITY, 0.0], w: 1.0 }).collect()); + t.check().unwrap(); + } + } + #[test] fn paths_lead_to_the_leaves_holding_their_entries_and_powers_sum() { let n = 1000; diff --git a/renderer/src/scene.rs b/renderer/src/scene.rs index 461360d..892b645 100644 --- a/renderer/src/scene.rs +++ b/renderer/src/scene.rs @@ -189,6 +189,20 @@ pub fn proc_object(w: &World, id: u32, m: Mat4, object: u32, materials: &HashMap Ok(Some(ProcObject { node: id, name: n.name.clone(), world: m, object, func, blob, materials, cast_shadow: n.cast_shadow, lo, hi, sub: None, hidden: false })) } +/// A triangle as the hardware traversal's acceleration structures take it: as it is, or, with a vertex that is not +/// finite, three vertices at the origin. DXR and Vulkan leave a non-finite vertex undefined behaviour (only NaN in x +/// is defined, as an inactive primitive), and a degenerate triangle is one they define as never hit; the triangle +/// keeps its index. World positions are finite where they enter (world.rs: geometry checked, `world_matrix` and +/// `instance_world` place what is not at a point): this is for what overflows on the way (docs/RUNTIME.md "What the +/// kernel trusts"). Software traversal's tree leaves such a triangle out by itself (bvh.rs `build_tris`). +pub fn blas_tri(t: [[f32; 3]; 3]) -> [[f32; 3]; 3] { + if t.iter().flatten().all(|v| v.is_finite()) { + t + } else { + [[0.0; 3]; 3] + } +} + /// Instance `i` of an instanced object at node matrix `m`: its world matrix and whether it is hidden: past the drawn /// `count`, or a matrix that is not finite or not invertible (three.js code hides instances by scaling them to 0). A /// hidden instance is placed at a point (scale 1e-6, at its translation when finite), so it costs the trace nothing, diff --git a/renderer/src/units.rs b/renderer/src/units.rs index c47475b..0246ea3 100644 --- a/renderer/src/units.rs +++ b/renderer/src/units.rs @@ -23,7 +23,7 @@ //! is not balanced: its tree over 5.86 M triangles (the catalog's largest model) is 42 deep, twice a balanced one's //! 21, and over 30 k to 500 k triangles 17 to 22. The still scene's walk uses 48 entries for its one tree, as before. -use crate::bvh::{self, GpuNode}; +use crate::bvh::{self, check_tree, GpuNode}; use crate::scene::GpuInstance; use glam::{Mat4, Vec3A}; @@ -31,7 +31,7 @@ use glam::{Mat4, Vec3A}; /// levels, and used by the still walk before them. pub const STACK: usize = 64; /// The still walk's stack in a kernel without units, and how much of the module's the still walk uses in one with. -pub const STILL_STACK: usize = 48; +pub const STILL_STACK: usize = bvh::STACK; /// The deepest top tree (levels below the root): a built tree is no deeper than 18, whatever its units' boxes /// (`build_sah`), and the top tree is one, or a root over two (`Kept`). pub const TOP_DEPTH: usize = 19; @@ -56,51 +56,6 @@ pub struct MeshTree { pub hi: Vec3A, } -fn finite(n: &GpuNode) -> bool { - n.bmin.iter().chain(&n.bmax).all(|v| v.is_finite()) && (0..3).all(|k| n.bmin[k] <= n.bmax[k]) -} - -/// Checks that `nodes` is a tree the kernel's walk ends on, from node 0: every interior node's children are the two -/// nodes at `left` and `left + 1`, `left` greater than the node's own index (so nothing is entered twice) and inside -/// the tree; every node is reached exactly once; every box is finite; every leaf passes `leaf(first, count)`; nothing -/// is deeper than `max_depth`. Returns the depth (levels below the root). -fn check_tree(nodes: &[GpuNode], max_depth: usize, leaf: impl Fn(u32, u32) -> Result<(), String>) -> Result { - if nodes.is_empty() { - return Err("a tree without nodes".into()); - } - let mut seen = vec![false; nodes.len()]; - let mut stack = vec![(0usize, 0usize)]; - let (mut deepest, mut reached) = (0, 0); - while let Some((i, depth)) = stack.pop() { - if std::mem::replace(&mut seen[i], true) { - return Err(format!("node {i} is reached twice")); - } - reached += 1; - deepest = deepest.max(depth); - let n = &nodes[i]; - if n.count > 0 { - leaf(n.left_or_first, n.count).map_err(|e| format!("leaf {i}: {e}"))?; - continue; - } - if !finite(n) { - return Err(format!("node {i}'s box is not finite")); - } - let l = n.left_or_first as usize; - if l <= i || l + 1 >= nodes.len() { - return Err(format!("node {i}'s children {l}, {} are not after it and inside the tree's {} nodes", l + 1, nodes.len())); - } - if depth + 1 > max_depth { - return Err(format!("deeper than {max_depth}")); - } - stack.push((l, depth + 1)); - stack.push((l + 1, depth + 1)); - } - if reached != nodes.len() { - return Err(format!("{} of {} nodes are not reached", nodes.len() - reached, nodes.len())); - } - Ok(deepest) -} - /// A mesh tree over `tris` triangles, as the kernel may walk it: `check_tree`, leaves of 1 to `MAX_LEAF` triangles /// inside the mesh, no deeper than `MESH_DEPTH`. Its depth. pub fn check_mesh(nodes: &[GpuNode], tris: usize) -> Result { diff --git a/renderer/src/world.rs b/renderer/src/world.rs index 8af46ad..655498a 100644 --- a/renderer/src/world.rs +++ b/renderer/src/world.rs @@ -444,6 +444,10 @@ impl World { if g.groups.iter().any(|&[start, count, _]| start % 3 != 0 || count % 3 != 0 || start as u64 + count as u64 > g.indices.len() as u64) { return Err(anyhow!("geometry {id}: groups must be whole triangles within the indices")); } + // (What the kernel's traversals are built from: docs/RUNTIME.md "What the kernel trusts".) + if g.positions.iter().any(|v| !v.is_finite()) { + return Err(anyhow!("geometry {id}: a position is not finite")); + } // A geometry only meshes with their own BLAS use is replaced in place; one a flattened mesh uses rebuilds. A new // id no object uses yet: nothing to look for (the scan over every object cost ~40 µs a geometry at 10 k objects). let new = self.geometries.insert(id, Arc::new(g)).is_none(); @@ -523,6 +527,13 @@ impl World { if let Kind::Model { uri, .. } = &kind { check_uri(uri)?; } + // Delta tracking steps by -ln(1 - u) / majorant (pathtrace.wgsl `media_track`, `media_tr`): an infinite one never + // steps, and the walk through the volume would not end. + if let Kind::Volume { majorant, .. } = &kind { + if !(majorant.is_finite() && *majorant > 0.0) { + return Err(anyhow!("object {name}: a volume's majorant must be a finite number > 0 (1/m), not {majorant}")); + } + } // A mesh given another geometry: promoted to its own BLAS if it was not (this swap rebuilds once, the next apply // in place); a promotion stays. if let (Some(Kind::Mesh { geometry: old, own_blas: was, .. }), Kind::Mesh { geometry, own_blas, .. }) = (self.objects.get(&id).map(|o| &o.kind), &mut kind) { @@ -978,7 +989,22 @@ impl World { // ------------------------------------------------------------------------------------------ queries + /// Object `id`'s world matrix: its transform and its parents'. One that is not finite (a NaN or infinite position, + /// rotation or scale anywhere up the chain, as a script may set) places the object at a point, scale 1e-6 at its + /// translation where that is finite, as `scene::instance_world` places a hidden instance: what it draws is not + /// seen, and nothing built from it (the acceleration structures, the software traversal's trees, the light tree) + /// is given a number that is not finite (docs/RUNTIME.md "What the kernel trusts"). pub fn world_matrix(&self, id: u32) -> Mat4 { + let m = self.world_matrix_as_set(id); + if m.is_finite() { + return m; + } + let at = if m.w_axis.is_finite() { m.w_axis.truncate() } else { Vec3::ZERO }; + Mat4::from_scale_rotation_translation(Vec3::splat(1e-6), Quat::IDENTITY, at) + } + + /// The product of the transforms as they were set, finite or not. + fn world_matrix_as_set(&self, id: u32) -> Mat4 { let mut m = Mat4::IDENTITY; let mut cur = Some(id); let mut guard = 0; @@ -1174,6 +1200,34 @@ impl World { #[cfg(test)] mod tests { + /// What the kernel's traversals are built from is finite where it enters (docs/RUNTIME.md "What the kernel + /// trusts"): a geometry's positions and a volume's majorant are checked; a transform is kept as set, and an object + /// whose world matrix is not finite (its own or a parent's) is placed at a point, at its translation where that is + /// finite, as a hidden instance is. + #[test] + fn what_the_kernel_walks_is_finite_where_it_enters() { + let mut w = World::default(); + let tri = |x: f32| Geometry { positions: vec![x, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0], normals: vec![0.0; 9], uvs: vec![0.0; 6], indices: vec![0, 1, 2], groups: vec![], colors: vec![] }; + assert!(w.set_geometry(1, tri(f32::NAN)).unwrap_err().to_string().contains("not finite")); + assert!(w.set_geometry(1, tri(f32::INFINITY)).is_err()); + w.set_geometry(1, tri(0.0)).unwrap(); + let volume = |majorant: f32| Kind::Volume { function: "f".into(), params: vec![], shape: 0, majorant, g: 0.0, homogeneous: false, animated: false, grid: None }; + for bad in [f32::INFINITY, f32::NAN, 0.0, -1.0] { + assert!(w.set_object(2, "fog".into(), None, volume(bad), false, false).is_err(), "majorant {bad}"); + } + w.set_object(2, "fog".into(), None, volume(0.5), false, false).unwrap(); + w.set_object(3, "parent".into(), None, Kind::Group, false, true).unwrap(); + w.set_object(4, "child".into(), Some(3), Kind::Group, false, true).unwrap(); + w.set_transform(4, Vec3::new(1.0, 2.0, 3.0), Quat::IDENTITY, Vec3::ONE).unwrap(); + assert_eq!(w.world_matrix(4).w_axis.truncate(), Vec3::new(1.0, 2.0, 3.0)); + w.set_transform(3, Vec3::ZERO, Quat::IDENTITY, Vec3::new(f32::NAN, 1.0, 1.0)).unwrap(); + let m = w.world_matrix(4); + assert!(m.is_finite() && m.x_axis.length() < 1e-5, "a NaN scale up the chain: a point ({m})"); + w.set_transform(4, Vec3::new(f32::INFINITY, 0.0, 0.0), Quat::IDENTITY, Vec3::ONE).unwrap(); + assert!(w.world_matrix(4).is_finite()); + assert_eq!(w.objects[&4].position.x, f32::INFINITY, "the transform kept as set"); + } + /// Density grids: sizes and data are checked; a new grid, another size or another program rebuilds the scene, new /// data for the same grid is uploaded in place (a grid sent again without data keeps what it had); sources go only /// to volumes with a grid, 12 finite floats each, at most GRID_MAX_SOURCES pending.