From 730ef66e7d683946646cc7323c05a8f6f54cebc5 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 16 Jul 2026 22:39:05 +0200 Subject: [PATCH 1/2] docs: 2026-07-16 full audit - sync every doc to as-built engine reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight parallel audits of docs/ (+ CLAUDE.md/README/spec) against the code and git history; every verified discrepancy fixed: - HW-GI opt-in (66dad5b) reflected everywhere it was missing: PT docs, perf README, lumen-roadmap, renderer spec. SW GI is the documented shipping default; BLOOM_HW_GI=1 / BLOOM_PT / --pt is the opt-in. - Stale statuses: perf 003/004/007/007a/007b/013 'open' -> landed; 005 'prototype failed' -> revived and landed as EN-044; 014's SDF disk cache 'not landed' -> landed (sdf_cache.rs); 011 notes the ffi_core define_core_ffi! consolidation that replaced per-platform copies. - tickets.md: EN-054 marked shipped (d30fba9), EN-040 registered (it shipped unregistered), EN-021/022/023 'pending merge' cleared, EN-058(c) struck as measured, EN-060/EN-062 numbering note added. - RFC 0001 'Draft' -> implemented (ABI v3) with a new §10 as-built map; RFC 0002 gains as-built notes (setSunDirection does NOT drive the directional light; equirect IBL, CPU-baked LUTs, real signatures). - watchOS docs rewritten: SceneKit 3D + glb loader exist ('No 3D' was false); CLAUDE.md's 'README only' line replaced; visionos added. - Counts and signatures verified against source: ~470 FFI functions (121 physics), joint UBO 1024 not 128, Vertex3D stride 96 with tangent, updateModelAnimation 8-arg with rotY in radians, mixer API (EN-028) + instantiateAnimation (EN-055) documented, web glue is wrapFfiForI64 pass-through (no __perryToJsValue). - Two stale code comments fixed alongside: graph.rs no longer claims the render graph is unwired; pt_pass.rs a-trous comment says six iterations (1/2/4/8/16/1) - PT-3b doc corrected to match. Full audit trail in the session; crash-triage-windows.md, ios-target.md and world-format.md audited clean, no changes needed. --- CLAUDE.md | 11 ++- README.md | 6 +- bloom-renderer-spec-v2.md | 6 +- docs/design-api.md | 7 +- docs/migration-0.5.md | 6 +- docs/perf/003-stochastic-ssr.md | 15 ++-- docs/perf/004-cached-shadow-maps.md | 13 +-- docs/perf/005-depth-prepass.md | 16 +++- docs/perf/006-shadow-pass-culling.md | 17 ++-- docs/perf/007-prep-wgpu-upgrade.md | 6 +- docs/perf/007a-lumen-screen-probes-sw.md | 8 +- docs/perf/007b-lumen-screen-probes-hw.md | 2 +- docs/perf/008-visibility-buffer.md | 3 +- docs/perf/009-gpu-driven-rendering.md | 3 +- docs/perf/010-async-compute.md | 3 +- docs/perf/011-cross-platform-ffi.md | 17 +++- docs/perf/012-remaining-bind-group-caches.md | 3 +- docs/perf/013-lumen-surface-cache.md | 2 +- docs/perf/014-lumen-mesh-sdfs.md | 22 ++--- docs/perf/016-lumen-importance-sampling.md | 2 +- docs/perf/README.md | 23 ++++-- docs/perf/lumen-roadmap.md | 12 ++- docs/physics.md | 34 +++++--- docs/pt/PT-1-progressive-megakernel.md | 3 + docs/pt/PT-3b-svgf-denoiser.md | 3 +- docs/pt/PT-5-integration-and-restir.md | 4 + docs/pt/pt-roadmap.md | 14 +++- docs/rfc/0001-material-render-graph.md | 87 ++++++++++++++++---- docs/rfc/0002-atmospheric-sky.md | 29 +++++++ docs/skeletal-animation.md | 86 +++++++++++++------ docs/tickets.md | 84 +++++++++++++------ docs/watchos-target.md | 66 ++++++++++----- docs/web-target.md | 18 ++-- native/shared/src/renderer/graph.rs | 11 +-- native/shared/src/renderer/pt_pass.rs | 4 +- 35 files changed, 462 insertions(+), 184 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 33b69f1..330b3ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,12 +85,15 @@ native/ Rust implementations (one crate per platform) linux/ Vulkan/OpenGL + X11 + PulseAudio android/ Vulkan/OpenGL ES + NativeActivity + AAudio web/ WebGPU/WebGL + Canvas + Web Audio API (WASM via wasm-pack) - watchos/ Planned — README only, blocked on Perry watchOS target support + visionos/ Metal + UIKit-style shell (wgpu; iOS/tvOS-family port) + watchos/ SwiftUI Canvas (2D) + SceneKit (3D) — no wgpu/Jolt on + the watch; own .glb loader; built via Perry's + watchos-swift-app feature (see docs/watchos-target.md) ``` ## FFI Pattern -Each platform implements ~230 `bloom_*` FFI functions declared in `package.json` under `perry.nativeLibrary.functions`. Native platforms use `#[no_mangle] extern "C"`, web uses `#[wasm_bindgen]`. +Each platform implements ~470 `bloom_*` FFI functions declared in `package.json` under `perry.nativeLibrary.functions`. Native platforms use `#[no_mangle] extern "C"`, web uses `#[wasm_bindgen]`. String parameters are `i64` on native (Perry StringHeader pointers) and NaN-boxed string IDs on web (converted by JS glue layer). @@ -129,7 +132,7 @@ String parameters are `i64` on native (Perry StringHeader pointers) and NaN-boxe - `begin_frame` resets per-frame lighting state; renderer FFI calls before `initWindow` panic with "Engine not initialized". -Physics FFI (~110 of the ~230) is generated by the `define_physics_ffi!` macro in `native/shared/src/physics_jolt.rs`; each platform crate invokes it once to re-export the full surface. On web the same surface is wasm_bindgen wrappers forwarding to `native/web/jolt_bridge.js`, which drives JoltPhysics.js. +Physics FFI (121 of the ~470) is generated by the `define_physics_ffi!` macro in `native/shared/src/physics_jolt.rs`; each platform crate invokes it once to re-export the full surface. On web the same surface is wasm_bindgen wrappers forwarding to `native/web/jolt_bridge.js`, which drives JoltPhysics.js. ## Web/WASM Target @@ -143,7 +146,7 @@ Key features flags in `native/shared/Cargo.toml`: - `jolt` — compiles the C++ Jolt shim via cmake on native; no-op on wasm32 (web uses JoltPhysics.js) - `web` — uses web-time instead of std::time::Instant -The web crate exposes `_str` variants (accepting `&str`) and `_bytes` variants (accepting `&[u8]`) for functions that take strings or file data. The JS glue converts Perry NaN-boxed values via `__perryToJsValue` and fetches assets via sync XHR. +The web crate exposes `_str` variants (accepting `&str`) and `_bytes` variants (accepting `&[u8]`) for functions that take strings or file data. Perry's runtime decodes NaN-boxed FFI args to plain JS values (`wrapFfiForI64`) before the glue sees them; the glue routes string/asset params to the `_str`/`_bytes` variants and fetches assets via sync XHR. ## Key Files diff --git a/README.md b/README.md index 16d7ad2..38bcba0 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,11 @@ setting the bar. ([full design rationale](docs/design-api.md)) | **Audio** | `@bloomengine/engine/audio` | Sound effects + music streaming | | **Models** | `@bloomengine/engine/models` | 3D model loading (glTF, OBJ), skeletal animation | | **Math** | `@bloomengine/engine/math` | Vectors, matrices, quaternions, easing | +| **Scene** | `@bloomengine/engine/scene` | Retained scene graph, frame callbacks, lighting | | **Physics** | `@bloomengine/engine/physics` | Jolt-backed rigid + soft bodies, character, vehicles ([docs](docs/physics.md)) | +| **VFX** | `@bloomengine/engine/vfx` | GPU particle systems + decals | +| **World** | `@bloomengine/engine/world` | `.world.json` loading, validation, instantiation ([docs](docs/world-format.md)) | +| **Mobile** | `@bloomengine/engine/mobile` | Virtual joystick/buttons, touch-input helpers | ## Platforms @@ -116,7 +120,7 @@ setting the bar. ([full design rationale](docs/design-api.md)) | Linux | Vulkan / OpenGL | Keyboard + mouse | | iOS | Metal | Touch + gamepad | | tvOS | Metal | Siri Remote + gamepad | -| watchOS | SwiftUI Canvas (no Metal) | Digital Crown + taps ([docs](docs/watchos-target.md)) | +| watchOS | SwiftUI Canvas (2D) + SceneKit (3D) | Digital Crown + taps ([docs](docs/watchos-target.md)) | | Android | Vulkan / OpenGL ES | Touch + gamepad | | **Web** | **WebGPU / WebGL** | **Keyboard + mouse + touch + gamepad** | diff --git a/bloom-renderer-spec-v2.md b/bloom-renderer-spec-v2.md index 4242f09..6de9ed4 100644 --- a/bloom-renderer-spec-v2.md +++ b/bloom-renderer-spec-v2.md @@ -7,7 +7,7 @@ philosophy lives on in `README.md` and the raylib-modeled `src/` API). Both were removed 2026-07-06; git history has them. -## Status vs. plan (as-built, 2026-07-06) +## Status vs. plan (as-built, 2026-07-16) This document is the *plan*; the code has made choices where the plan offered options, and diverged where reality was cheaper: @@ -26,6 +26,10 @@ offered options, and diverged where reality was cheaper: with material-path casters/receivers, material system with a stable 5-bind-group ABI (`material_abi.wgsl`), CPU+GPU pass profiler with overlay, planar-reflection water, physical-resolution 2D text. +- **GI default:** SW GI (SDF-clipmap / Hi-Z tiers) is the shipping + default; the HW ray-query tier is opt-in (`BLOOM_HW_GI=1` / + `BLOOM_PT` / `--pt` on Windows, 66dad5b) after the measured A/B showed + +20 ms/frame on the 760M for a tonal-only difference (b34c1f3). - **Live status lives in** `docs/perf/lumen-roadmap.md` (GI), `docs/tickets.md` (EN-xxx work items), and per-ticket docs in `docs/perf/`. When this spec and those disagree, those win. diff --git a/docs/design-api.md b/docs/design-api.md index 81375eb..0cdd053 100644 --- a/docs/design-api.md +++ b/docs/design-api.md @@ -99,10 +99,11 @@ Bloom compiles TypeScript through [Perry](../../perry/perry) (our AOT compiler) and hands data across an FFI boundary to platform-specific Rust crates. The boundary has a specific shape, documented in `CLAUDE.md` and `package.json`: -- **~230 `bloom_*` FFI functions** declared in `package.json` under +- **~465 `bloom_*` FFI functions** declared in `package.json` under `perry.nativeLibrary.functions`. - **Native platforms** use `#[no_mangle] extern "C"` — a C ABI. -- **Web** uses `#[wasm_bindgen]` with JS glue that NaN-boxes string IDs. +- **Web** uses `#[wasm_bindgen]`; Perry's runtime decodes NaN-boxed args + (`wrapFfiForI64`) and the JS glue routes strings to `_str` variants. - **String parameters** are `i64` Perry StringHeader pointers on native, NaN- boxed IDs on web. - **Handles** (textures, sounds, models, physics bodies) are all `i64` / plain @@ -139,7 +140,7 @@ single FFI call has to punch back through. interface Vec3 { x: number; y: number; z: number } interface Texture { handle: number; width: number; height: number } interface Sound { handle: number } -interface Model { handle: number } +interface Model { handle: number; meshCount: number; materialCount: number; transform: Mat4 } // Functions operate on data: const tex = loadTexture("assets/hero.png"); diff --git a/docs/migration-0.5.md b/docs/migration-0.5.md index b26e1b5..d6f03ff 100644 --- a/docs/migration-0.5.md +++ b/docs/migration-0.5.md @@ -60,7 +60,9 @@ myAtlas.id myAtlas.handle old exact-dt behavior. See docs/physics.md "Stepping". - Stale handles (use-after-free/destroy) now fail lookups instead of aliasing whatever object reused the slot. -- `*Raw` function variants are documented `@internal` — they exist only - as a compiler workaround and will be removed. +- Most `*Raw` function variants are documented `@internal`. They work + around an aarch64-Android Perry miscompilation of `obj.field` reads + feeding f64 FFI args; a few (e.g. `loadMusicRaw`) are actively + recommended on Android and are staying. - Coordinate system is now documented at the top of the physics and scene modules: right-handed, Y-up, meters, SI units. diff --git a/docs/perf/003-stochastic-ssr.md b/docs/perf/003-stochastic-ssr.md index 2300391..9acc69c 100644 --- a/docs/perf/003-stochastic-ssr.md +++ b/docs/perf/003-stochastic-ssr.md @@ -1,10 +1,10 @@ # 003 — Stochastic SSR + temporal accumulation -**Effort:** ~2 days · **Expected gain:** SSR 4× cheaper per frame · **Status:** open +**Effort:** ~2 days · **Expected gain:** SSR 4× cheaper per frame · **Status:** landed -## Problem +## Problem (historical — pre-landing) -The SSR pass (`SSR_SHADER_WGSL` in `renderer.rs`) marches 32 steps per pixel at +The SSR pass (`SSR_SHADER_WGSL`, now in `renderer/shaders/ssgi.rs`) marched 32 steps per pixel at half-res. Every metallic fragment (and near-metal dielectric at low roughness) does the full march. With Sponza's chrome lamp fittings and polished marble, that's a significant pixel count × 32 steps. @@ -58,7 +58,10 @@ pass is a smaller version of the same idea. existing TAA by writing both current + history. - Preserve the `ssr_enabled` toggle and the quality-preset plumbing. -## Files likely to change +## Files changed (as landed) -- `native/shared/src/renderer.rs` — rewrite SSR_SHADER_WGSL, add history RT, - add (or extend existing) reprojection pass. +- `native/shared/src/renderer/ssr_pass.rs` — stochastic GGX march (8 steps) + with 3×3 pre-filter + neighborhood-clamped temporal history + (`record_ssr_temporal`). +- `native/shared/src/renderer/shaders/ssgi.rs` — `SSR_SHADER_WGSL` lives here + now (the old single `renderer.rs` was split into the `renderer/` module). diff --git a/docs/perf/004-cached-shadow-maps.md b/docs/perf/004-cached-shadow-maps.md index eef7b56..e5e9a0a 100644 --- a/docs/perf/004-cached-shadow-maps.md +++ b/docs/perf/004-cached-shadow-maps.md @@ -1,6 +1,6 @@ # 004 — Cache shadow cascades for static casters -**Effort:** ~2 days · **Expected gain:** Shadow pass → ~0 after first frame (was ~14 ms) · **Status:** open +**Effort:** ~2 days · **Expected gain:** Shadow pass → ~0 after first frame (was ~14 ms) · **Status:** landed ## Problem @@ -73,8 +73,11 @@ this per 128×128 page. For Bloom, per-cascade is enough for now. - Add a `setShadowsAlwaysFresh(bool)` TS toggle as an escape hatch for games that do a lot of dynamic light changes. -## Files likely to change +## Files changed (as landed) -- `native/shared/src/shadows.rs` — add `dirty` flag + invalidation helpers. -- `native/shared/src/renderer.rs` — gate the shadow pass on `dirty`. -- `native/shared/src/scene.rs` — dirty the shadows when a transform changes. +- `native/shared/src/shadows.rs` — `dirty` / `always_fresh` flags, + per-cascade `rendered_cascade_sig`, static caster caches. +- `native/shared/src/renderer/shadow_pass.rs` — the shadow pass gates on + `scene.shadow_version` (the old single `renderer.rs` was split into the + `renderer/` module). +- `native/shared/src/scene.rs` — bumps `shadow_version` when casters change. diff --git a/docs/perf/005-depth-prepass.md b/docs/perf/005-depth-prepass.md index fe58229..9dda8b4 100644 --- a/docs/perf/005-depth-prepass.md +++ b/docs/perf/005-depth-prepass.md @@ -1,6 +1,6 @@ # 005 — Depth prepass for main HDR pass -**Effort:** ~1 day · **Expected gain:** main_hdr 17 ms → ~8 ms · **Status:** deprioritized (see Findings, 2026-04-21) +**Effort:** ~1 day · **Expected gain:** main_hdr 17 ms → ~8 ms · **Status:** landed (revived as EN-044 after the 2026-04-21 prototype failed — see Postscript) ## Problem @@ -79,7 +79,8 @@ Classic **Z-prepass**. Before `main_hdr_pass`: ## Files likely to change -- `native/shared/src/renderer.rs` — new `scene_depth_pipeline`, new +- `native/shared/src/renderer/` (as landed: `scene_pass.rs` + pipeline + creation in `mod.rs`) — new `scene_depth_pipeline`, new `scene_pipeline_equal`, new prepass block in `end_frame_with_scene`. - `native/shared/src/scene.rs` — add a `render_depth_only()` method mirroring `render()`. @@ -152,3 +153,14 @@ That's a multi-day refactor, not the ~1 day this ticket originally estimated. Not worth it while the frame budget is dominated by bloom / TAA / SSGI / final-composite passes (the real targets of tickets 007 and 010). + +## Postscript (2026-07) — revived and landed as EN-044 + +The prepass was later rebuilt along the exact lines the Recommendation +above sketched, and is now live: `renderer/scene_pass.rs` runs an +unconditional `bloom_depth_prepass` render pass (alpha-cutout honored via +the `fs_depth_prepass` fragment entry), with the main pass loading depth +and drawing through `scene_pipeline_prepassed` (`Equal`/`LessEqual`). +Both correctness gaps from the post-mortem — alpha discard and coplanar +layering — are handled. The Findings above stand as the record of why +the first attempt failed. diff --git a/docs/perf/006-shadow-pass-culling.md b/docs/perf/006-shadow-pass-culling.md index e2fe4f2..c58d59c 100644 --- a/docs/perf/006-shadow-pass-culling.md +++ b/docs/perf/006-shadow-pass-culling.md @@ -21,7 +21,9 @@ On the panning-camera cache-miss path at `--quality 2 --fps-only 300`: The ticket's 14 ms → 7-10 ms target was set against the pre-95da6af baseline. `CASCADE_MAP_SIZE` dropped 2048 → 1024 in 95da6af, which already took the pass from 14 ms to ~3 ms on the cache-miss path, so absolute -headroom is small. The proportional cut (34% on the GPU side) matches +headroom is small. (The 1024 drop was itself later reverted — +`shadows.rs:20` is back to `CASCADE_MAP_SIZE = 2048` today; the culling +win measured here is independent of map size.) The proportional cut (34% on the GPU side) matches the ticket's intent. The 10% FPS target in the original acceptance is unreachable at today's baseline because shadow is no longer a 14 ms cost. @@ -79,13 +81,16 @@ test — no need to compute the camera/light union manually. - `extract_frustum_planes` in `scene.rs` already does the Gribb-Hartmann extraction. Reuse. - `aabb_outside_frustum` also already there. Reuse. -- Build the per-cascade draw list in the same loop that's currently at - `renderer.rs` ~line 8250 (look for `ShadowDrawEntry`). The existing loop - builds one shared list — extend to build `[Vec; 3]`. +- Build the per-cascade draw list in the same loop as the existing shadow + draw-list construction (now in `renderer/shadow_pass.rs`; look for + `ShadowDrawEntry`). The existing loop builds one shared list — extend to + build `[Vec; 3]`. - Keep the `SHADOW_MAX_NODES` cap per cascade (currently 1024). - Don't cull by the light's near/far plane — in a static Sponza scene the light volume covers everything anyway. -## Files likely to change +## Files changed (as landed) -- `native/shared/src/renderer.rs` — shadow-pass draw-list construction. +- `native/shared/src/renderer/shadow_pass.rs` — shadow-pass draw-list + construction with per-cascade frustum culling (the old single + `renderer.rs` was split into the `renderer/` module). diff --git a/docs/perf/007-prep-wgpu-upgrade.md b/docs/perf/007-prep-wgpu-upgrade.md index 1e234a2..605df73 100644 --- a/docs/perf/007-prep-wgpu-upgrade.md +++ b/docs/perf/007-prep-wgpu-upgrade.md @@ -1,6 +1,6 @@ # 007-prep — wgpu upgrade (Metal ray-query unlock) -**Effort:** 2-3 days · **Expected gain:** none (enabler) · **Status:** open +**Effort:** 2-3 days · **Expected gain:** none (enabler) · **Status:** landed (wgpu 29) ## Problem @@ -25,7 +25,7 @@ at the time this ticket starts — verify at kickoff). Sweep API churn across: - `native/shared/src/renderer/mod.rs` — largest surface area; will touch most breaking changes (descriptor field renames, feature-flag bits, surface-config shapes, ray-tracing types if they became stable). -- `native/shared/src/renderer/shaders.rs` — naga WGSL parser accepts a slightly +- `native/shared/src/renderer/shaders/` — naga WGSL parser accepts a slightly different shape each release (e.g. attribute syntax, `override` semantics). - Platform crates — window creation / surface config wiring. @@ -53,7 +53,7 @@ No renderer logic changes beyond what the compiler forces. No Lumen code yet. - 8 `Cargo.toml` files (native/shared + 7 platform crates). - 8 `Cargo.lock` files. - `native/shared/src/renderer/mod.rs` — descriptor / feature / surface churn. -- `native/shared/src/renderer/shaders.rs` — WGSL parser churn if any. +- `native/shared/src/renderer/shaders/` — WGSL parser churn if any. - `native/web/src/lib.rs` — `wasm-bindgen` interactions that touch wgpu types. - Platform `lib.rs` / `main.rs` files where surface creation happens. diff --git a/docs/perf/007a-lumen-screen-probes-sw.md b/docs/perf/007a-lumen-screen-probes-sw.md index e430c13..6493495 100644 --- a/docs/perf/007a-lumen-screen-probes-sw.md +++ b/docs/perf/007a-lumen-screen-probes-sw.md @@ -1,6 +1,6 @@ # 007a — Lumen screen probes (SW trace, Hi-Z) -**Effort:** 2-3 days · **Expected gain:** SSGI 2×+ faster · **Status:** open +**Effort:** 2-3 days · **Expected gain:** SSGI 2×+ faster · **Status:** landed Supersedes the old ticket 007. Phase 1a of the [Lumen roadmap](lumen-roadmap.md). Runs in parallel with 007b; both depend on 007-prep. @@ -20,10 +20,10 @@ current ~2.9 M — ~60× fewer rays. ## Approach — file-by-file -### Shaders — `native/shared/src/renderer/shaders.rs` +### Shaders — now `native/shared/src/renderer/shaders/ssgi.rs` -Delete `SSGI_SHADER_WGSL` (lines 2219-2405) and `SSGI_TEMPORAL_SHADER_WGSL` -(lines 2412-2496). Add: +Delete `SSGI_SHADER_WGSL` and `SSGI_TEMPORAL_SHADER_WGSL` (both gone as +landed — replaced by the `SSGI_PROBE_*` shaders). Add: - `PROBE_HELPERS_WGSL` — `oct_encode(dir) -> vec2`, `oct_decode(uv) -> vec3`, `view_pos_from_depth(uv, depth, inv_proj)`, `reconstruct_view_normal(view_pos)` diff --git a/docs/perf/007b-lumen-screen-probes-hw.md b/docs/perf/007b-lumen-screen-probes-hw.md index 4ec0ee0..ab78282 100644 --- a/docs/perf/007b-lumen-screen-probes-hw.md +++ b/docs/perf/007b-lumen-screen-probes-hw.md @@ -1,6 +1,6 @@ # 007b — Lumen screen probes (HW trace, ray-query) -**Effort:** 1-2 weeks · **Expected gain:** off-screen occlusion + bleed · **Status:** open +**Effort:** 1-2 weeks · **Expected gain:** off-screen occlusion + bleed · **Status:** landed (HW trace is opt-in since 66dad5b — `BLOOM_HW_GI=1` / `BLOOM_PT` / `--pt` on Windows; SW is the default tier) Phase 1b of the [Lumen roadmap](lumen-roadmap.md). Depends on 007-prep and 007a; develops in parallel with 007a, merges after 007a lands so the probe diff --git a/docs/perf/008-visibility-buffer.md b/docs/perf/008-visibility-buffer.md index bc4a1ed..eb463a8 100644 --- a/docs/perf/008-visibility-buffer.md +++ b/docs/perf/008-visibility-buffer.md @@ -77,7 +77,8 @@ modes. This is a ~2-day win instead of 2 weeks. ## Files likely to change -- `native/shared/src/renderer.rs` — scene_pipeline, main_hdr_pass, shading +- `native/shared/src/renderer/` (`mod.rs` + `scene_pass.rs`; the old single + `renderer.rs` was split) — scene_pipeline, main_hdr_pass, shading pass, SSR/SSGI/SSAO inputs. - `native/shared/src/scene.rs` — mesh_id assignment, vertex buffer layout. diff --git a/docs/perf/009-gpu-driven-rendering.md b/docs/perf/009-gpu-driven-rendering.md index e390ed9..2f66254 100644 --- a/docs/perf/009-gpu-driven-rendering.md +++ b/docs/perf/009-gpu-driven-rendering.md @@ -68,7 +68,8 @@ feature flags. Check adapter support at device creation. ## Files likely to change -- `native/shared/src/renderer.rs` — shared VB/IB, descriptor buffer, GPU +- `native/shared/src/renderer/mod.rs` (the old single `renderer.rs` was + split into the `renderer/` module) — shared VB/IB, descriptor buffer, GPU cull compute shader, new render pass using `draw_indexed_indirect_count`. - `native/shared/src/scene.rs` — reworking of per-node GPU resources. diff --git a/docs/perf/010-async-compute.md b/docs/perf/010-async-compute.md index 5cacadd..d0485e4 100644 --- a/docs/perf/010-async-compute.md +++ b/docs/perf/010-async-compute.md @@ -66,7 +66,8 @@ This ticket depends on having compute-shader versions of SSAO/SSR/SSGI first ## Files likely to change -- `native/shared/src/renderer.rs` — encoder splitting, queue creation, +- `native/shared/src/renderer/mod.rs` (the old single `renderer.rs` was + split into the `renderer/` module) — encoder splitting, queue creation, fence/semaphore plumbing. - Possibly `native/macos/src/lib.rs` — if wgpu-hal drop is needed for second queue. diff --git a/docs/perf/011-cross-platform-ffi.md b/docs/perf/011-cross-platform-ffi.md index cced7b0..11f35bd 100644 --- a/docs/perf/011-cross-platform-ffi.md +++ b/docs/perf/011-cross-platform-ffi.md @@ -31,7 +31,7 @@ bloom_print_profiler_summary() ``` Each one is a thin wrapper around an existing method on `engine().renderer` -or `engine().profiler`. Shared code (`native/shared/src/renderer.rs`, +or `engine().profiler`. Shared code (`native/shared/src/renderer/mod.rs`, `native/shared/src/profiler.rs`) is platform-agnostic and already exposes everything. @@ -117,3 +117,18 @@ Verification: audit). - The TS API side (`src/core/index.ts` ~240-316) matches the FFI surface 1:1. + +## Superseded by `define_core_ffi!` (as-built today) + +The per-platform hand-copied FFI blocks described above no longer exist: +they drifted badly enough (Android shipped 60 functions behind, Windows +silently no-op'd the scene graph) that the whole non-physics FFI surface +was consolidated into the shared `define_core_ffi!` macro in +`native/shared/src/ffi_core/` (e.g. `bloom_set_quality_preset` lives in +`ffi_core/visual.rs`, profiler getters in `ffi_core/game_loop.rs`). Each +platform crate invokes the macro once in its `lib.rs`. New `bloom_*` +functions are added to the macro — never hand-copied per platform — and +`tools/validate-ffi.js` enforces parity in CI. The sole exception is web, +which still hand-writes `#[wasm_bindgen]` wrappers in +`native/web/src/lib.rs`. The TIMESTAMP_QUERY per-platform feature +requests from this ticket survive unchanged. diff --git a/docs/perf/012-remaining-bind-group-caches.md b/docs/perf/012-remaining-bind-group-caches.md index 233cd95..8124d28 100644 --- a/docs/perf/012-remaining-bind-group-caches.md +++ b/docs/perf/012-remaining-bind-group-caches.md @@ -52,7 +52,8 @@ existing four. ## Files likely to change -- `native/shared/src/renderer.rs` — add 5 more cache fields, build/read +- `native/shared/src/renderer/mod.rs` + `renderer/postfx_chain.rs` (the old + single `renderer.rs` was split) — add 5 more cache fields, build/read them in `end_frame_with_scene`, invalidate in `resize()`. ## Deferred — prototype notes diff --git a/docs/perf/013-lumen-surface-cache.md b/docs/perf/013-lumen-surface-cache.md index 9065c38..0f03809 100644 --- a/docs/perf/013-lumen-surface-cache.md +++ b/docs/perf/013-lumen-surface-cache.md @@ -1,6 +1,6 @@ # 013 — Lumen Surface Cache (Mesh Cards) -**Effort:** 1-2 weeks · **Expected gain:** HW bounce reaches full Lumen quality · **Status:** open +**Effort:** 1-2 weeks · **Expected gain:** HW bounce reaches full Lumen quality · **Status:** landed (V3 — as-built: 64×64 cards in a 4096² atlas, per-frame drained capture queue; see the [README](README.md) 013 row for the landed summary) Phase 2 of the [Lumen roadmap](lumen-roadmap.md). Depends on 007a + 007b. diff --git a/docs/perf/014-lumen-mesh-sdfs.md b/docs/perf/014-lumen-mesh-sdfs.md index fe72852..1f8368d 100644 --- a/docs/perf/014-lumen-mesh-sdfs.md +++ b/docs/perf/014-lumen-mesh-sdfs.md @@ -4,10 +4,11 @@ Phase 3 of the [Lumen roadmap](lumen-roadmap.md). Depends on 013. -**Priority note:** With HW ray-tracing live on macOS / iOS / tvOS / Windows / -Linux after 007b + 013, the SW SDF path is only critical for **Android and -web**. Deprioritise behind 016 (importance sampling) and only land if -Android / web traction demands it. +**Priority note (superseded):** the original call — "SW SDF only matters +for Android/web, deprioritise" — inverted twice: the ticket landed in full +(V1-V15), and HW ray query later became **opt-in** (`BLOOM_HW_GI=1` / +`BLOOM_PT` / `--pt`, 66dad5b; SW stays default per b34c1f3/9d1523f), so the +SDF clipmap tier is now load-bearing on every platform, not just Android/web. ## Problem @@ -108,15 +109,15 @@ for radiance. ## V15 closure (landed state) -Landed as V1-V14 over 15 incremental commits. Summary and deltas vs. the -original plan: +Landed as V1-V15 over 15 incremental commits (V15 was the closure / +VRAM-audit pass). Summary and deltas vs. the original plan: ### What landed | Sub-feature | Plan | Landed | |-------------------------------|-------------------|------------------------------------| | Per-mesh MDFs | GPU jump-flood | Brute-force point-triangle (V1) | -| Disk cache for MDFs | Yes | **Not landed** (in-memory only) | +| Disk cache for MDFs | Yes | Landed post-V15 (`sdf_cache.rs`, issue #22) | | Global SDF clipmap | 4 cascades, sparse | 1 cascade dense, camera-follow (V2 / V5) | | Sphere-trace shader variant | Runtime-selected | Landed, 3-way HW > SDF > Hi-Z (V3) | | Textured SDF hits | — | V4 — broad-phase AABB + card atlas | @@ -188,9 +189,10 @@ access will build cleanly; CI would need host-matching runners. ### Deferred -- **Disk cache for per-mesh SDFs** — builds are fast enough that the - first-frame bake amortises in-session; persisting would help on - cold launches but hasn't been load-bearing on Sponza. +- ~~**Disk cache for per-mesh SDFs**~~ — landed after V15 closure + (`native/shared/src/sdf_cache.rs`, commit 068511e / issue #22): + content-hashed load in `scene.rs` skips the bake on hit, writes are + flushed per frame via `flush_sdf_cache_writes`. - **GPU jump-flood SDF bake** — V1's brute-force point-triangle works at current mesh sizes; jump-flood would matter if meshes scale up significantly. diff --git a/docs/perf/016-lumen-importance-sampling.md b/docs/perf/016-lumen-importance-sampling.md index 99913e0..ca24567 100644 --- a/docs/perf/016-lumen-importance-sampling.md +++ b/docs/perf/016-lumen-importance-sampling.md @@ -82,7 +82,7 @@ rather than in a second probe layer. ## Files changed -- `native/shared/src/renderer/shaders.rs` — probe-trace shader (SW + HW +- `native/shared/src/renderer/shaders/ssgi.rs` — probe-trace shader (SW + HW variants): R2 octahedral jitter (V1), probe_idx decorrelation (V2), prev-frame luma lookup + jitter-scale (V3); probe-resolve shader: delta-driven EMA alpha (V4). diff --git a/docs/perf/README.md b/docs/perf/README.md index a8e64f6..b889045 100644 --- a/docs/perf/README.md +++ b/docs/perf/README.md @@ -14,15 +14,16 @@ visual quality with no reduction in SSAO/SSR/SSGI sample counts, shadow cascade resolution, or effect fidelity. The `--quality 0` regression guard holds at 60.0 fps vsync (3/3 consecutive runs). -**Landed (13 tickets):** 001 TSR half-res, 002 compute GTAO, 003 stochastic -SSR, 004 cached shadow cascades, 006 shadow-pass culling, 007-prep wgpu 29 +**Landed (14 tickets):** 001 TSR half-res, 002 compute GTAO, 003 stochastic +SSR, 004 cached shadow cascades, 005 depth prepass (first prototype failed; +revived and landed as EN-044), 006 shadow-pass culling, 007-prep wgpu 29 upgrade, 007a SSRC SW trace, 007b SSRC HW trace, 013 Mesh Cards surface cache, 014 per-mesh SDFs + WSRC, 016 temporal jitter + variance-adaptive EMA, 011 cross-platform quality/profiler FFI, 017 Lumen rollout cleanup. -**Deferred (5 tickets, each with concrete reopen criteria in the ticket):** -005 depth prepass (prototype failed), 008 visibility buffer, 009 GPU-driven -rendering, 010 async compute, 012 remaining bind-group caches. +**Deferred (4 tickets, each with concrete reopen criteria in the ticket):** +008 visibility buffer, 009 GPU-driven rendering, 010 async compute, +012 remaining bind-group caches. Deferred ≠ abandoned. Each ticket's "Deferred" section documents the specific trigger that would make it worth reopening (scene pushes past @@ -107,7 +108,7 @@ Ordered roughly by ROI / effort. | [002](002-compute-gtao.md) | Compute GTAO + Hi-Z linear-depth pyramid + temporal accumulation | ~2 days | SSAO 186 ms → ~50 ms | landed (compute port + hierarchical linear-depth pyramid + conditional CS march + 4-frame temporal accumulation: SSAO-only 235→29 ms = **8×**, acceptance target met. Default preset 235→84 ms = **2.8×**. SSIM visually indistinguishable from baseline) | | [003](003-stochastic-ssr.md) | Stochastic SSR + temporal accumulation | ~2 days | SSR 4× cheaper per frame | landed (GGX importance-sampled 1-ray/frame + 3×3 pre-filter + neighborhood-clamped reprojection; march 32→8 steps; ssr_pass 9.2→7.7 ms = 17% reduction. SSR was already well below the ticket's 4× target after 001+002 landed — the structural win is replacing the non-physical 5-tap blur with a proper temporal GGX cone. Visually indistinguishable) | | [004](004-cached-shadow-maps.md) | Cache shadow cascades for static casters | ~2 days | Shadow pass → ~0 after first frame | landed (per-scene shadow_version + cascade VP equality gate + unjittered proj for cascade fit; `--no-pan` CLI flag for stationary measurement. Shadow pass 2907 µs → 67 µs GPU on stationary camera = **43×**; FPS 82→106. Cache-miss path (panning camera) unchanged. SSIM visually indistinguishable) | -| [005](005-depth-prepass.md) | Depth prepass for main HDR pass | ~1 day | main_hdr 17 ms → ~8 ms | deprioritized (prototype failed — see ticket) | +| [005](005-depth-prepass.md) | Depth prepass for main HDR pass | ~1 day | main_hdr 17 ms → ~8 ms | landed — the 2026-04-21 prototype failed (Findings in the ticket), then the design was revived as EN-044: alpha-cutout-aware `bloom_depth_prepass` in `renderer/scene_pass.rs` + `scene_pipeline_prepassed` main pass | | [006](006-shadow-pass-culling.md) | Frustum cull casters in shadow pass | ~0.5 day | Shadow pass 14 → 7-10 ms | landed (per-cascade ortho-frustum cull against a world-AABB cached on SceneNode; shadow_pass GPU 3.1 → 2.0 ms on the panning-cache-miss path = **-34%**, CPU 648 → 489 µs = **-24%**. FPS at `--quality 2` 50.3 → 52.8 = +5%. Absolute headroom capped by 95da6af's earlier 2048 → 1024 cascade-map cut — proportional shape matches the ticket's intent. Sun-behind-camera pose (`--yaw π`) diffs within TAA noise floor — shadows of off-screen casters still land on-screen correctly) | | [008](008-visibility-buffer.md) | Visibility buffer replaces 4-MRT G-buffer | ~2 weeks | 75% less bandwidth | deferred — real GPU bandwidth win but invisible behind the vsync cap on Sponza. Reopen when a target scene pushes past 16.7 ms, when integrated / mobile GPUs become the priority, or for overdraw-heavy scenes (foliage, hair, dense transparents). Depth-prepass (005) becomes prerequisite-useful again at that point; ticket 009's unified vertex buffers are a hard prerequisite | | [009](009-gpu-driven-rendering.md) | Indirect multi-draw for scene graph | ~1 week | Removes CPU draw loop | deferred — pure CPU optimization on a GPU-bound benchmark. Render-total CPU is already ~4 ms against a 16.7 ms vsync budget; shaving another ~600 µs via draw-call collapsing won't move FPS on Sponza. Reopen when a CPU-bound scene arrives (10 000+ meshes), when ticket 008 starts (hard prerequisite for its shading pass), or when bindless texture support lands in wgpu | @@ -118,14 +119,18 @@ Ordered roughly by ROI / effort. ### Lumen-style GI multi-phase (see [lumen-roadmap.md](lumen-roadmap.md)) Phases 1a/1b develop in parallel after 007-prep lands. Phase 2 upgrades HW -shading to full Lumen quality. Phase 3 (SDFs) is deprioritized now that HW-RT -is on the critical path. +shading to full Lumen quality. Phase 3 (SDFs) — once deprioritized in favor +of HW-RT — subsequently landed (ticket 014), and the SDF clipmap is now the +default SW trace tier once baked. The priority inverted in July 2026: HW +ray query is **opt-in** (`BLOOM_HW_GI=1` / `BLOOM_PT` / `--pt`, 66dad5b) +after measurement showed merely granting the feature cost +20 ms/frame on +the 760M (b34c1f3); SW GI stays the shipping default (9d1523f). | # | Title | Effort | Expected gain | Status | |---|---|---|---|---| | [007-prep](007-prep-wgpu-upgrade.md) | Bump wgpu 24 → Metal-RT release | 2-3 days | Enabler for HW path | landed (wgpu 24 → 29; ~140 API-migration sites across renderer/shadows/postfx/profiler + macos surface/device setup; `BLOOM_NO_FULLSCREEN=1` env var for bench-friendly windowed mode; Metal ray-query now available via `Features::EXPERIMENTAL_RAY_QUERY`. All 7 quality paths vsync-capped at 800×450 logical; visual diff within noise vs baseline) | | [007a](007a-lumen-screen-probes-sw.md) | Lumen screen probes — SW trace (Hi-Z) | 2-3 days | SSGI 2×+ faster | landed (probe grid 1 per 16×16 tile + 8×8 octahedral atlas per probe; 4 compute/fragment passes — place, trace, temporal EMA, resolve; ping-pong history + dedicated per-frame trace texture so temporal blend avoids any read-write aliasing; `--quality 3 --ssgi 1 --fps-only 300` vsync-capped at 60.0 fps vs 23.6 baseline = **≥2.5× uplift** at Sponza half-res; indirect bounce on shaded column sides + under-arch tint preserved, SSIM visually indistinguishable) | -| [007b](007b-lumen-screen-probes-hw.md) | Lumen screen probes — HW trace (BLAS/TLAS + ray-query) | 1-2 weeks | Off-screen occlusion + bleed | landed across all RT-capable native platforms (macOS, iOS, tvOS, Windows, Linux, Android). wgpu 29 `EXPERIMENTAL_RAY_QUERY` via `ExperimentalFeatures::enabled()` + `Limits::using_minimum_supported_acceleration_structure_values`; per-scene-node BLAS built lazily alongside vertex/index buffers, TLAS + per-instance GI-data buffer rebuilt on `tlas_version` mismatch mirroring ticket 004's shadow cache; HW `rayQueryProceed` trace pipeline selected at runtime when the feature was granted, falls back to SW cleanly on non-RT adapters or with `BLOOM_FORCE_SW_GI=1`. Hit-lighting-lite: per-instance flat albedo × (NdotL × sun + NdotUp × sky) + emissive, with sun and sky colours sourced from the scene's directional light + ambient. Firefly-clamped at luma=10 to match SW. Web stays SW-only (no WebGPU RT spec) | +| [007b](007b-lumen-screen-probes-hw.md) | Lumen screen probes — HW trace (BLAS/TLAS + ray-query) | 1-2 weeks | Off-screen occlusion + bleed | landed across all RT-capable native platforms (macOS, iOS, tvOS, Windows, Linux, Android). wgpu 29 `EXPERIMENTAL_RAY_QUERY` via `ExperimentalFeatures::enabled()` + `Limits::using_minimum_supported_acceleration_structure_values`; per-scene-node BLAS built lazily alongside vertex/index buffers, TLAS + per-instance GI-data buffer rebuilt on `tlas_version` mismatch mirroring ticket 004's shadow cache; HW `rayQueryProceed` trace pipeline selected at runtime when the feature was granted, falls back to SW cleanly on non-RT adapters or with `BLOOM_FORCE_SW_GI=1`. Hit-lighting-lite: per-instance flat albedo × (NdotL × sun + NdotUp × sky) + emissive, with sun and sky colours sourced from the scene's directional light + ambient. Firefly-clamped at luma=10 to match SW. Web stays SW-only (no WebGPU RT spec). Since 66dad5b, Windows requests the ray-query feature only when launched with `BLOOM_HW_GI=1` / `BLOOM_PT` / `--pt` — granting it unconditionally cost +20 ms/frame on the 760M — so the SW tiers are the runtime default | | [013](013-lumen-surface-cache.md) | Surface Cache — Mesh Cards + per-frame card lighting | 1-2 weeks | HW bounce full quality | landed (V3) — 6 signed-axis cards per mesh (±X ±Y ±Z) at 64×64 in a 4096² atlas (682-mesh capacity). Per-frame card-lighting compute pass re-lights every populated slot with shadow-aware sun + analytic sky + emissive: each texel reconstructs its world-space position from per-slot metadata (aabb + mesh transform), selects a cascade via view-space Z, samples the shadow cascade atlas with a comparison sampler, and writes `albedo × (NdotL × sun × shadow + NdotUp × sky) + emissive` into the radiance atlas. Capture pass now emits to two render targets (albedo + emissive) in one draw per axis; slot metadata is a 128 B/entry storage buffer baked at capture. HW probe trace samples the pre-lit radiance atlas at hit — zero shading math in the trace shader, indirect bounce carries sun-shadow occlusion from the card-texel perspective. `SceneGraph::set_mesh_dynamic(handle, bool)` re-queues animated meshes into the capture queue every frame. Capture rate-limited to 20 slots/frame to stay inside Metal's per-encoder budget (dual-attachment passes are ~2× V2's cost); Sponza's 2430-slot backlog clears in ~120 frames. Measured Sponza `--capture 200`: **60 fps vsync-capped (16.7 ms/frame)** | | [014](014-lumen-mesh-sdfs.md) | Per-mesh SDFs + global SDF clipmap + WSRC | 3-4 weeks | SW parity for Android / web | **landed (V1-V15)**. V1-V5: per-mesh UDF → scene clipmap → SDF sphere-trace shader → textured hits → camera-follow clipmap. V6-V7: WSRC miss-path cache for SDF + HW. V8-V11: probe trilinear → octel bilinear → padded-border hardware sampler → true octahedral silhouette wrap. V12: perceptual hysteresis for lighting-change invalidation (1° angular, 5 % luma). V13: 3 stacked cascades (30 / 120 / 500 m) in one `(160, 160, 48)` atlas, smallest-containing-cascade selection at trace time. V14: HW-ray-traced WSRC bake for RT adapters (probe-octel rays read Mesh Cards pre-lit radiance at hit → 2-bounce GI through the cache). V15 closure: VRAM audit (10.8 MB for SDF clipmap + WSRC combined, well under the 128 MB budget; Mesh Cards dominate total GI footprint at 256 MB but they're ticket 013's cost), cross-platform cargo-check (macOS / iOS / tvOS / Web all clean from the macOS host; Windows / Linux / Android need native hosts for their C deps — not a code issue). Deferred to ticket 016: importance-sampled WSRC rebake cadence. See [014-lumen-mesh-sdfs.md §V15 closure](014-lumen-mesh-sdfs.md#v15-closure-landed-state) for the full audit | | [017](017-lumen-cleanup-and-protocol-gaps.md) | Lumen rollout cleanup — regression guard + `--ssgi 0` hang + 016 scope match + untracked state | 1-2 days | protocol compliance | **landed**. Root cause of both perf failures was identical: the Lumen warmup + per-frame block (`rebuild_acceleration_structures`, `capture_pending_mesh_cards`, `bake_pending_sdfs`, `bake_scene_sdf_clipmap`, `bake_wsrc`, `light_mesh_cards`) ran unconditionally in `end_frame_with_scene`. Even at `--quality 0` with `ssgi_enabled = false`, Sponza's 2430-slot card-capture backlog and the per-frame card-lighting compute kept the frame well above the 16.7 ms vsync budget. Fix: frame-graph-level gate on `self.ssgi_enabled` around the full Lumen block. Pending queues stay populated so a runtime `setSsgiEnabled(true)` flip resumes baking where it paused. FPS-harness in `examples/intel-sponza/main.ts` gained a cumulative-dt fallback because the rolling-window FPS sampler returns 0 on sub-1-second runs, which masked a <60 fps failure at quality 0 that was already fixed — reliable `--fps-only 60` metrics now. Measured: `--quality 0 --fps-only 60` → **60-66 fps** (3/3 runs), `--quality 0 --fps-only 300` → **60.0 fps vsync** (3/3), `--quality 0 --ssgi 0 --fps-only 300` → **60.0 fps deterministic** (3/3), main target `--quality 3 --ssgi 1 --fps-only 300` → **60.0 fps vsync** unchanged. Ticket 016 rewritten top-to-bottom so Problem / Approach describe the shipped V1-V4 jitter+variance-EMA path; original CDF-sampling + refinement-layer scope deferred to GitHub issues #19 / #20 (already open). Untracked research dirs (`tools/{babylonjs,threejs,unity,unreal}_reference/`, `tools/dump_dds/target/`, `outdoor.hdr`, `.claude/scheduled_tasks.lock`) resolved via `.gitignore`. `native/web/src/lib.rs` init-guard committed | diff --git a/docs/perf/lumen-roadmap.md b/docs/perf/lumen-roadmap.md index 93258a7..4dca877 100644 --- a/docs/perf/lumen-roadmap.md +++ b/docs/perf/lumen-roadmap.md @@ -30,7 +30,7 @@ mandatory wgpu version bump. The HW path is on the critical path, not deferred. | 1a | [007a](007a-lumen-screen-probes-sw.md) | SSRC probe infrastructure + SW Hi-Z trace. Replaces current per-pixel SSGI. | Parallel with 007b | | 1b | [007b](007b-lumen-screen-probes-hw.md) | BLAS/TLAS lifecycle + `rayQueryProceed` trace variant + hit-lighting-lite shading; runtime fallback to 007a on adapters without RT. | Parallel with 007a, merges after | | 2 | [013](013-lumen-surface-cache.md) | Mesh Cards — 6-axis card capture at model load, per-frame card lighting pass, hits shade from card atlas. Upgrades HW quality to full Lumen. | After 007a+007b | -| 3 | [014](014-lumen-mesh-sdfs.md) | Per-mesh SDFs + global SDF clipmap + WSRC 32×32 clipmap probes. SW-only path reaches feature parity with HW. | Deprioritized — HW covers mac/Win/Linux already | +| 3 | [014](014-lumen-mesh-sdfs.md) | Per-mesh SDFs + global SDF clipmap + WSRC 32×32 clipmap probes. SW-only path reaches feature parity with HW. | Landed (V1-V15) — the SDF clipmap is now the default SW tier once baked | | 5 | [016](016-lumen-importance-sampling.md) | Prev-frame radiance-guided ray direction sampling + hierarchical probe refinement in high-variance tiles. | After 013 | Phase 4 from the original proposal was absorbed into Phase 1 (HW is no longer @@ -48,6 +48,16 @@ deferred), so numbering skips directly from 013/014 to 016. | Android | yes | adapter-gated | Most Android GPUs lack RT; SW expected in practice | | Web | yes | never | No WebGPU RT spec; SW-only permanently | +**As-built (2026-07):** all phases landed (see the [README](README.md) +table). At runtime the probe trace selects a tier per frame: +`hw-ray-query` > `sdf-clipmap` > `hiz-screen` (`renderer/ssgi_pass.rs`). +Contrary to the matrix above, the HW path is now **opt-in**, not +auto-enabled on capable adapters: on Windows the ray-query device feature +is only requested when launched with `BLOOM_HW_GI=1`, `BLOOM_PT`, or +`--pt` (66dad5b) — the measured A/B showed +20 ms/frame on the 760M for a +tonal-only visual difference, so SW GI is the shipping default +(b34c1f3, 9d1523f). + ## Per-phase acceptance protocol Every ticket follows the standard perf-ticket protocol from [README.md](README.md): diff --git a/docs/physics.md b/docs/physics.md index aa7fc3c..0e56735 100644 --- a/docs/physics.md +++ b/docs/physics.md @@ -28,7 +28,7 @@ Jolt replaced the previous Rapier 3D backend — see ["Why Jolt (and not PhysX o ↓ extern "C" through JoltPhysics.js native/shared/src/jolt_sys.rs (standalone WASM module, ~1 MB) ↓ links - native/third_party/bloom_jolt/ ← C++ shim (~1200 LOC) + native/third_party/bloom_jolt/ ← C++ shim (~1800 LOC + 500-line header) ↓ depends on native/third_party/JoltPhysics/ ← Jolt 5.5.0 (git submodule, built via cmake crate) ``` @@ -46,13 +46,14 @@ identical in both cases. (~60 s); subsequent builds link against the cached `libJolt.a` + `libbloom_jolt.a`. - **WASM:** `build.rs` skips cmake when `CARGO_CFG_TARGET_ARCH == "wasm32"`. The web crate's `wasm-bindgen` import of `/jolt_bridge.js` is resolved at `wasm-pack` - time; the bridge file gets bundled into `pkg/snippets/`. The host page loads - JoltPhysics.js from the CDN and hands the module factory to - `bloom.bloom_physics_init_jolt()` before any physics call. + time; the bridge file gets bundled into `pkg/snippets/`. The JS glue + (`bloom_glue.js`) imports `jolt-physics@1.0.0` and hands the module factory + to `bloom.bloom_physics_init_jolt()` before any physics call. - **Feature flag:** physics is behind the `jolt` feature on every platform crate. - macOS defaults to `default = []` (opt-in) because Perry examples that don't - touch physics shouldn't pay the 60 s Jolt first-build cost; the other platforms - default to `default = ["jolt"]`. + Jolt defaults **ON** everywhere — each platform crate ships + `default = ["jolt", "models3d", "image-extras"]` so existing games are + unaffected. Opt *out* with `default-features = false` if a build shouldn't + pay the ~60 s Jolt first-build cost. ## Supported features @@ -69,16 +70,22 @@ Matches or exceeds UE5's built-in physics surface. | 1 | Raycast closest + raycast all | ✅ | ✅ | | 1 | Shape cast (closest hit) | ✅ | native only | | 1 | Overlap sphere / box / point | ✅ | ✅ | -| 1 | Fixed / point / hinge / slider / distance / six-DOF constraints | ✅ | fixed / point / hinge / slider / distance | +| 1 | Fixed / point / hinge / slider / distance constraints | ✅ | ✅ | | 1 | Contact events (added / persisted / removed) via polled queue | ✅ | ✅ | | 1 | 16-layer collision matrix, object-layer filtering | ✅ | ✅ (create-time only) | | 2 | **Character controller** (`CharacterVirtual` — slope + stair handling) | ✅ | ✅ | | 2 | **Soft bodies** — cloth, rope, jelly (per-vertex pinning via `invMass=0`) | ✅ | ✅ | | 2 | **Wheeled vehicles** — 4-wheel, ray collision tester, engine + differential | ✅ | ✅ | +| 2 | **Ragdolls** (EN-025) — built at runtime from the skinned skeleton; capsule-per-bone + limited six-DOF joints | ✅ (via `createRagdoll()` in `bloom/models`, `native/shared/src/ragdoll.rs`) | — | -Gaps vs. full Jolt API: tracked vehicles, motorcycle controller, constraint runtime -enable/disable round-tripping on web, body-lock damping setters on web, raycast -world-space normals (currently returns (0,1,0) — body-lock read is the fix). +Six-DOF constraints exist in the shim (`bj_constraint_six_dof`) but are +internal-only — ragdoll articulation uses a locked-translation wrapper; there +is no public `bloom_physics_*` export or TS API for them. + +Gaps vs. full Jolt API: tracked vehicles, motorcycle controller, public six-DOF +constraints, constraint runtime enable/disable round-tripping on web, body-lock +damping setters on web, raycast world-space normals (currently returns (0,1,0) +— body-lock read is the fix). ## TypeScript API quick-start @@ -319,12 +326,13 @@ native/shared/src/ # define_physics_ffi! macro src/physics/index.ts # TypeScript game-facing API -package.json # Perry FFI manifest (109 bloom_physics_* entries) +package.json # Perry FFI manifest (121 bloom_physics_* entries) native/web/ ├── jolt_bridge.js # JS-side implementation via JoltPhysics.js ├── src/lib.rs # wasm_bindgen imports → JS bridge -└── index.html # loads JoltPhysics.js, calls bloom_physics_init_jolt +└── bloom_glue.js # imports jolt-physics@1.0.0, hands the + # factory to bloom_physics_init_jolt() ``` ## Extending diff --git a/docs/pt/PT-1-progressive-megakernel.md b/docs/pt/PT-1-progressive-megakernel.md index daffd56..3b54def 100644 --- a/docs/pt/PT-1-progressive-megakernel.md +++ b/docs/pt/PT-1-progressive-megakernel.md @@ -62,6 +62,9 @@ light pass needs them, and mode 0 must be switchable back per frame. visible as expected right after a camera cut. - Live mode switching verified: F9 → RT (EMA) mid-game, F9 → off restores the raster path bit-clean (SSGI/SSR resume, 13 → 46 fps). + (Reproducing this today on Windows requires launching with + `BLOOM_HW_GI=1`, `BLOOM_PT`, or `--pt` — since 66dad5b the ray-query + feature is opt-in, and F9 is inert without it.) - **M3 partially open**: point-light NEE + building-base colour bleed + `bloom-reference` RMSE not yet measured (needs a lit world + fixed exposure; folded into PT-2/PT-5 verification). diff --git a/docs/pt/PT-3b-svgf-denoiser.md b/docs/pt/PT-3b-svgf-denoiser.md index 672cd11..c863e0e 100644 --- a/docs/pt/PT-3b-svgf-denoiser.md +++ b/docs/pt/PT-3b-svgf-denoiser.md @@ -33,7 +33,8 @@ temporal accumulation since PT-3 M1. by surface. Blending would leak blade lighting onto the ground (gray-blue mottle); resetting would pin the texel at 1 spp (speckle). Only a stored surface that left the footprint is a disocclusion. -- **Variance-guided à-trous** — five iterations (steps 1/2/4/8/16), B3 +- **Variance-guided à-trous** — six iterations (steps 1/2/4/8/16, then a + closing step-1 pass; see the step array in `pt_pass.rs`), B3 5×5 kernel, `sigma_l = 4·sqrt(gaussian3x3(variance))`, variance filtered alongside with squared weights, depth-gradient edge stop. Young history (< 4 frames) substitutes a spatial variance estimate diff --git a/docs/pt/PT-5-integration-and-restir.md b/docs/pt/PT-5-integration-and-restir.md index 3f18efb..1985f12 100644 --- a/docs/pt/PT-5-integration-and-restir.md +++ b/docs/pt/PT-5-integration-and-restir.md @@ -44,6 +44,10 @@ combat by construction (see above) and converges when things go quiet. CLI flag; all four surfaces share one settings value. - `ray_query=false` → the row renders `N/A (NO RAY TRACING)` and the value is inert (`isPathTracingSupported` gate); boot line says why. +- Since 66dad5b (2026-07-16), Windows only requests the HW ray-query + device feature when launched with `BLOOM_HW_GI=1`, `BLOOM_PT`, or + `--pt`. Without one of those triggers the row reads + `N/A (NO RAY TRACING)` and F9 is inert even on RT-capable hardware. - Mobile profile never applies PT (no headroom, no settings screen). - `TEXTURE_BINDING_ARRAY` absent → card-albedo hit shading fallback (unchanged since PT-2). diff --git a/docs/pt/pt-roadmap.md b/docs/pt/pt-roadmap.md index b42629d..c200dcc 100644 --- a/docs/pt/pt-roadmap.md +++ b/docs/pt/pt-roadmap.md @@ -83,8 +83,9 @@ multi-bounce colour bleed. - **PT-4**: ReSTIR DI — per-pixel weighted reservoirs over light candidates, temporal reuse with M-cap, spatial reuse over 3–5 neighbours, one shadow ray for the winner. **Experimental flag until validated against - bloom-reference.** With today's ≤6 analytic lights plain NEE is nearly as - good; the payoff arrives when emissive particles/muzzle flashes become + bloom-reference.** With today's handful of analytic lights (the engine + caps point lights at 16; arena_02 uses 5) plain NEE is nearly as good; + the payoff arrives when emissive particles/muzzle flashes become lights (Tier 2). Do not oversell it before then. An ML denoiser is explicitly out of scope: nothing in wgpu runs vendor @@ -97,10 +98,17 @@ overdrive. That is the "as far as reasonable" line. | Situation | Behaviour | |---|---| | `ray_query=true` (DX12+DXC, Vulkan RQ, Metal) | all modes available | -| `ray_query=false` | `bloom_set_path_tracing` is a no-op → Lumen; boot line + `bloom_pt_supported()` say why | +| `ray_query=false` | `bloom_set_path_tracing` is a no-op → Lumen; boot line + `bloom_path_tracing_supported()` say why | | `TEXTURE_BINDING_ARRAY` absent | Tier 2 textures fall back to card albedo; everything else unaffected | | Web | never (no WebGPU RT); permanently Lumen SW | +On Windows, `ray_query=true` is no longer automatic on capable hardware: +since 66dad5b the `EXPERIMENTAL_RAY_QUERY` device feature is only requested +when the process is launched with `BLOOM_HW_GI=1`, `BLOOM_PT`, or `--pt` +(`native/windows/src/lib.rs`). Without one of those, the first row behaves +as `ray_query=false` even on an RT-capable GPU. macOS/Linux still request +ray query by default (`BLOOM_FORCE_SW_GI` disables it). + ## Verification protocol (per the hard lessons) - **Binary-colour probes first**: every new pass proves it executes by diff --git a/docs/rfc/0001-material-render-graph.md b/docs/rfc/0001-material-render-graph.md index 44d0ea0..c12e87d 100644 --- a/docs/rfc/0001-material-render-graph.md +++ b/docs/rfc/0001-material-render-graph.md @@ -1,6 +1,6 @@ # RFC 0001 — Material system & render graph -**Status:** Draft +**Status:** Implemented — Phases 1–8 + 10 shipped (ABI v3); Phase 3's pool is built but not wired (still Phase 3b); Phase 9 (water) is game-side. See §10 for as-built divergences. **Author:** Ralph + Claude (pair) **Target version:** 0.x → 0.(x+1), no public breakage for games on the existing `drawModel` / `drawCube` / `loadModel` / `loadModelAnimation` APIs. @@ -556,46 +556,56 @@ committed to `main`. **Acceptance:** RFC reviewed and merged. -### Phase 1 — Shader ABI header +### Phase 1 — Shader ABI header ✅ -- [ ] Write `shared/shaders/material_abi.wgsl` with the definitions in §1. -- [ ] Write `shared/shaders/common/{pbr,shadows,fog,tonemap,sky}.wgsl`. -- [ ] Refactor the three existing 3D shaders in `shaders.rs` to include the +- [x] Write `shared/shaders/material_abi.wgsl` with the definitions in §1. +- [x] Write `shared/shaders/common/{pbr,shadows,fog,tonemap,sky}.wgsl`. +- [x] Refactor the three existing 3D shaders to include the header and use group 0-3. -- [ ] Rust-side: refactor `LightingUniforms`, `Uniforms3D`, etc. so +- [x] Rust-side: refactor `LightingUniforms`, `Uniforms3D`, etc. so `PerFrame`, `PerView`, `PerMaterial`, `PerDraw` are distinct types mirroring the WGSL. -- [ ] Pipeline creation (`pipeline_3d`, `scene_pipeline`) switches to new +- [x] Pipeline creation (`pipeline_3d`, `scene_pipeline`) switches to new layouts. `custom_pipelines` path removed (dead code today anyway). +(As-built: the ABI is at **version 3** — see §10 for what grew past this +spec. The version check lives in `renderer/shader_include.rs` + +`renderer/shader_library.rs`, not the long-gone `shaders.rs`.) + **Acceptance:** - Shooter SELFTEST screenshots (stable seed): identical within 1-bit tolerance before and after. - Perry compilation succeeds; shooter runs; all features unchanged. - No public API changes. -### Phase 2 — Render graph skeleton +### Phase 2 — Render graph skeleton ✅ -- [ ] `renderer/graph.rs`: `PassNode`, `PassInput`, `PassOutput`, +- [x] `renderer/graph.rs`: `PassNode`, `PassInput`, `PassOutput`, scheduler, executor. -- [ ] Port the existing passes of `end_frame_with_scene` one by one to +- [x] Port the existing passes of `end_frame_with_scene` one by one to `PassNode`s. The function becomes `build_graph() + graph.execute()`. -- [ ] Nothing else changes. Pass bodies are moved verbatim into +- [x] Nothing else changes. Pass bodies are moved verbatim into `run:` closures. **Acceptance:** same SELFTEST tolerance, same timings within 2%. -### Phase 3 — Transient resource pool +(As-built: the graph drives the real frame — `mod.rs` marks "RFC 0001 +Phase 2b — complete". Node names and the scheduler shape differ from §2 — +see §10.) + +### Phase 3 — Transient resource pool 🟡 built, not wired -- [ ] `renderer/transient.rs`: `TransientDesc`, pool with reference - counting and aliasing. +- [x] `renderer/transient.rs`: `TransientDesc`, pool with reference + counting (aliasing was dropped — reuse only). - [ ] Ssao, ssgi, ssr, bloom mip chain, TAA history → all declared as transients. - [ ] Resize handling goes through the pool; the renderer's member fields shrink. **Acceptance:** GPU memory usage reduced (target: 20%+ on 1080p scene), -SELFTEST identical. +SELFTEST identical. **Not yet met** — the pool exists with unit tests, but +in the live graph `Transient(u32)` ids are ordering tokens over persistent +renderer fields ("Phase 3b" per `transient.rs`). ### Phase 4 — Translucency buckets + scene snapshot @@ -850,4 +860,51 @@ Proceed with phases 1 through 10 in order. Each phase is a PR against amendments welcomed as we learn. The shooter is the primary test harness. Water is the forcing function. + +--- + +## 10. As-built divergences (audit 2026-07-16) + +The design shipped, but the code moved past this spec in places. The +proposal sections above are left as written; this section is the map from +spec to reality. Ground truth: `native/shared/shaders/material_abi.wgsl` +(ABI **v3**), `renderer/graph.rs`, `renderer/transient.rs`. + +**ABI (§1):** +- `PerView` light arrays grew: `array` and + `array` (froxel-culled), not 4/16. +- `PerFrame` gained `wind: vec4` (EN-013) and `cloud: vec4` + (EN-040) after `taa_jitter`. +- `MaterialFactors._reserved` became `shading_model: vec4` + + `foliage_params: vec4` (EN-012). +- Group 2 extends past binding 11: binding 11 is left for the shader's own + `user_params` declaration, bindings 12-13 are planar reflection (EN-011), + 14-17 are terrain texture arrays (EN-014). +- ABI version bumps: the `// ABI-VERSION: N` check works as described but + lives in `renderer/shader_include.rs` (`abi_version_of`) + + `renderer/shader_library.rs` (`verify_abi_version`). The promised + `docs/migration/abi-vN-to-vN+1.md` files were never created; v1→v3 + changes are documented in the ABI header itself. + +**Render graph (§2):** +- `PassNode` is generic `PassNode<'a, Ctx>` with `run: Box` — no `Renderer`/`NodeContext` split, `Send` dropped, + `PassInput::Transient(u32)` instead of a typed id. +- The scheduler is a plain Kahn topological sort with `after`/`before` + tie-breaks. §2's step 3 (synthesized `CopyToSample` nodes) and step 4 + (merging contiguous passes into one `wgpu::RenderPass`) were never + implemented; the live graph pins order with `with_after`. +- As-built node names: `froxel_assign`, `shadow` (one node, all 3 + cascades), `hdr_scene`, `pt`, `translucent`, `hiz_build`, + `occlusion_capture`, `gtao`, `ssao_blur`, `ssr_march`, `ssr_temporal`, + `ssgi`, `bloom`, `compose`, `postfx_tail`, `auto_exposure`, plus + sub-graphs `material_pass` and `overlay_2d`. (§2.3's `main_hdr`/`ssao`/ + `taa`/`scene_compose`/`tonemap` names never existed as built.) +- `TransientDesc` has no `id` field (separate `TransientId` handle), adds + `mips`/`samples`, and the pool ref-counts and reuses but does **not** + alias. + +**API (§3):** +- `loadMaterial` takes a `MaterialDesc` object (`{shader, bucket}`), not a + JSON path (the JSON loader was deferred, as §5's note says). Glass is the generality check. diff --git a/docs/rfc/0002-atmospheric-sky.md b/docs/rfc/0002-atmospheric-sky.md index 1b6a779..796f03c 100644 --- a/docs/rfc/0002-atmospheric-sky.md +++ b/docs/rfc/0002-atmospheric-sky.md @@ -237,4 +237,33 @@ both shipped 2026-04-26. the smaller tier shown in the LUT table. Tier picked at compile time via `cfg`. 2. **Cubemap re-bake resolution** — 64×6 across all platforms. + *(Superseded — see as-built note: IBL bakes to a 128×64 equirect, + not a cubemap, matching Phase 3's own "chose equirect over cube".)* 3. **API name** — `setProceduralSky(opts?)`. + *(As-built: `setProceduralSky(enabled: boolean, opts?)` — a leading + toggle was added.)* + +## As-built divergences (audit 2026-07-16) + +The LUT stack (names, formats, sizes, platform tiers), the per-frame +sky-view + aerial-perspective compute, the sun disk, the sun-shaft +transmittance tap, and the fog tint all shipped as specified. What differs: + +- **Sun → directional-light coupling was NOT built.** `setSunDirection` + drives the sky-view LUT, the sun disk, the IBL rebake, the sun-shaft + tint, and the fog tint — it does **not** write `lighting_uniforms. + light_dir`/`light_color`. Shadows and the directional light remain + governed solely by `setDirectionalLight`; games must keep the two in + sync themselves. (This also voids the "warms the directional light in + lockstep" acceptance bullet above.) +- `setSunDirection` is **not** gated on the procedural sky being enabled — + no no-op, no debug warning; it always applies. +- The sky render pass is `render_procedural_sky_pass` (selected via + `procedural_sky_enabled`), not `render_atmosphere_pass`; the panorama + path is `render_sky_pass`. +- Transmittance + multi-scattering LUTs are **CPU-baked** at init + (`atmosphere_lut.rs`, threaded on native — mirrors `brdf_lut.rs`), not + compute passes. Only sky-view + aerial-perspective run as GPU compute. +- IBL rebake renders the sky into a single-mip **128×64 equirect** + (`EQUIRECT_FROM_SKY_VIEW` shader), not a 64×64×6 cubemap. +- `groundAlbedo` is a scalar grey (`number`, default 0.1), not a `Color`. diff --git a/docs/skeletal-animation.md b/docs/skeletal-animation.md index 57d4d7d..83123ac 100644 --- a/docs/skeletal-animation.md +++ b/docs/skeletal-animation.md @@ -18,11 +18,11 @@ Bloom Engine supports GPU-accelerated skeletal animation via glTF/GLB models wit The animation system is split into three layers: -1. **Asset loading** (`models.rs`) -- parses glTF/GLB files, extracts skeleton hierarchy, inverse bind matrices, animation channels (translation/rotation/scale keyframes per joint), and skin data (JOINTS_0 + WEIGHTS_0 vertex attributes). +1. **Asset loading** (`models.rs`) -- parses glTF/GLB files, extracts skeleton hierarchy, inverse bind matrices, animation channels (translation/rotation/scale keyframes per joint), and skin data (JOINTS_0 + WEIGHTS_0 vertex attributes). Since EN-055, the parsed clip data (`SkeletonData`, animations, rest rotations) is immutable and `Arc`-shared: `instantiateAnimation(src)` creates a new instance with a fresh mixer and joint state without re-parsing the GLB. -2. **Animation update** (`models.rs`) -- each frame, samples keyframes at the current time, walks the joint hierarchy to compute world transforms, multiplies by inverse bind matrices to produce final joint matrices. These are stored in a pending buffer. +2. **Animation update** (`models.rs` + `anim_mixer.rs`) -- each frame, the per-instance `AnimMixer` (EN-028) advances clip time, crossfades between clips, applies masked additive layers and optional root motion, samples keyframes, walks the joint hierarchy to compute world transforms, and multiplies by inverse bind matrices to produce final joint matrices. These are stored in a pending buffer. (The older raw-`time` sampling path still exists for direct `updateModelAnimation` calls.) -3. **GPU skinning** (`renderer.rs`) -- the WGSL vertex shader applies 4-bone linear blend skinning using joint matrices from a 128-entry uniform buffer, flushed to the GPU in `end_frame()`. +3. **GPU skinning** (`renderer/`) -- the WGSL vertex shader (`renderer/shaders/core.rs`) applies 4-bone linear blend skinning using joint matrices from a 1024-entry uniform buffer, flushed to the GPU in `end_frame()`. A second `joints_prev` buffer at `@group(3) @binding(1)` feeds motion vectors (EN-022). ``` Game Loop Engine GPU @@ -45,7 +45,7 @@ endDrawing → end_frame() ──→ render pass execu Every 3D vertex in Bloom includes joint/weight data, whether skinned or not: ```rust -// native/shared/src/renderer.rs +// native/shared/src/renderer/types.rs #[repr(C)] pub struct Vertex3D { pub position: [f32; 3], // @location(0) — bind-pose position @@ -54,8 +54,9 @@ pub struct Vertex3D { pub uv: [f32; 2], // @location(3) — texture coordinates pub joints: [f32; 4], // @location(4) — bone indices (as floats) pub weights: [f32; 4], // @location(5) — bone weights (sum to 1.0) + pub tangent: [f32; 4], // @location(6) — xyz tangent + w handedness } -// Total stride: 80 bytes per vertex +// Total stride: 96 bytes per vertex ``` For unskinned geometry, `joints` and `weights` are all zeros. The shader checks `total_weight > 0.01` to decide whether to apply skinning. @@ -66,12 +67,12 @@ Joint matrices are stored in a uniform buffer at **bind group 3, binding 0**: ```wgsl struct JointMatrices { - matrices: array, 128>, + matrices: array, 1024>, }; @group(3) @binding(0) var joints: JointMatrices; ``` -The buffer is 8192 bytes (128 matrices x 64 bytes each). Initialized to identity matrices at startup. Updated via `queue.write_buffer()` in `flush_joint_matrices()` during `end_frame()`, right before the render pass. +The buffer is 65536 bytes (1024 matrices x 64 bytes each). Initialized to identity matrices at startup. Updated via `queue.write_buffer()` in `flush_joint_matrices()` during `end_frame()`, right before the render pass. Two additions since this doc was first written: a `joints_prev` uniform at `@group(3) @binding(1)` holds last frame's matrices for motion vectors (EN-022), and the material-system pipeline binds the same joint UBO in its per-draw group at binding 1 (`renderer/material_pipeline.rs`). ### WGSL Vertex Shader (Skinning) @@ -118,7 +119,7 @@ The 3D pipeline uses four bind groups: | 0 | Transform | MVP matrix (4x4 uniform) | | 1 | Lighting | Ambient + directional light uniforms | | 2 | Texture | 2D texture + sampler | -| 3 | Joints | 128 x mat4x4 uniform buffer | +| 3 | Joints | 1024 x mat4x4 uniform buffer (+ `joints_prev` at binding 1) | ### Flush Timing @@ -149,22 +150,51 @@ const animHandle = loadModelAnimation("assets/models/character.glb"); `loadModelAnimation(path)` returns a numeric handle. Parses the glTF skin (skeleton hierarchy + inverse bind matrices) and all animation clips (translation/rotation/scale keyframes per joint). -### Updating +### Instancing (EN-055) + +Crowds should parse each GLB **once** and instantiate per entity: + +```typescript +const src = loadModelAnimation("assets/models/enemy.glb"); // one parse +const anim1 = instantiateAnimation(src); // Arc-shared clips, fresh mixer +const anim2 = instantiateAnimation(src); // each instance animates freely +``` + +`instantiateAnimation(src)` shares the immutable parsed data (skeleton, clips, rest rotations) and gives the new handle its own mixer, joint matrices, and mask cache. The shooter's boot went from ~30 re-parses of seven GLBs to seven parses. + +### Updating — mixer API (preferred) + +The per-instance `AnimMixer` (EN-028, `native/shared/src/anim_mixer.rs`) owns clip time, crossfades, masked layers, and opt-in root motion: + +```typescript +animPlay(anim, moving ? CLIP_WALK : CLIP_IDLE, 0.15); // crossfade; idempotent per frame +const spine = findJoint(anim, "Spine"); // joint index — once, at load +animSetLayer(anim, attacking ? CLIP_ATTACK : -1, 1.0, spine); // masked layer (-1 = off) +animSetRootMotion(anim, true); // opt in (off by default) +animUpdate(anim, dt, scale, px, py, pz, yawRadians); // one call per model per frame +if (animFinished(anim)) { /* non-looping clip ended */ } +const dx = animRootDelta(anim, 0); // root-motion delta, axis 0/1/2 +``` + +See `src/models/index.ts` for the full surface (`animPlay`, `animSetLayer`, `animSetRootMotion`, `animUpdate`, `animFinished`, `animClipDuration`, `animRootDelta`). + +### Updating — raw-time API (legacy, still supported) ```typescript // In your game loop: const time = getTime(); // seconds since start -updateModelAnimation(animHandle, 0, time, 1.0, playerX, playerY, playerZ); +updateModelAnimation(animHandle, 0, time, 1.0, playerX, playerY, playerZ, rotY); ``` -`updateModelAnimation(handle, animIndex, time, scale, px, py, pz)`: +`updateModelAnimation(handle, animIndex, time, scale, px, py, pz, rotY)`: - `handle` -- animation handle from `loadModelAnimation()` - `animIndex` -- which animation clip to play (0-based, order matches GLB) - `time` -- current time in seconds (automatically wraps via modulo with clip duration) - `scale` -- model scale (baked into joint matrices for correct skinned positioning) - `px, py, pz` -- world position (baked into joint matrices) +- `rotY` -- yaw in **radians** (baked into joint matrices; note `drawModelRotated` takes degrees — these two differ deliberately, see the FFI comment in `ffi_core/models.rs`) -This function samples all animation channels at the given time, walks the skeleton hierarchy, and produces final joint matrices that include scale and position. The matrices are staged for GPU upload. +This function samples all animation channels at the given time, walks the skeleton hierarchy, and produces final joint matrices that include scale, position, and yaw. The matrices are staged for GPU upload. ### Rendering @@ -188,7 +218,7 @@ const anim = loadModelAnimation("assets/models/character.glb"); while (!windowShouldClose()) { const t = getTime(); - updateModelAnimation(anim, 0, t, 1.0, 0.0, 0.0, 0.0); + updateModelAnimation(anim, 0, t, 1.0, 0.0, 0.0, 0.0, 0.0); beginDrawing(); clearBackground(Colors.SKYBLUE); @@ -340,7 +370,7 @@ See `scripts/export_mixamo_glb.py` for full documentation. | Problem | Cause | Solution | |---------|-------|----------| -| Character slides across ground | Root joint has translation keys | Root translation locked to rest pose in engine (line 315 of models.rs) | +| Character slides across ground | Root joint has translation keys | Root translation is locked to rest pose by default (see `root_translation_at` in `models.rs`); enable it deliberately with `animSetRootMotion(handle, true)` and consume `animRootDelta` | | Character renders at wrong position | Scale mismatch between `updateModelAnimation` and `drawModel` | Use same scale value in both calls | | Character invisible / at origin | `loadModelAnimation` failed (returned 0) | Check file path; on iOS check `resolve_path()` | | Perry crash on animation call | NaN-boxed pointer from failed load used as handle | Check return value of `loadModelAnimation()` before using | @@ -359,23 +389,26 @@ See `scripts/export_mixamo_glb.py` for full documentation. ### Rust (native layer) - **`native/shared/src/models.rs`** -- Core animation system: - - `ModelAnimation`, `SkeletonData`, `JointData`, `AnimationChannel`, `AnimationData` structs + - `ModelAnimation`, `SkeletonData` (Arc-shared since EN-055), `JointData`, `AnimationChannel`, `AnimationData` structs - `load_gltf_animation()` -- parses GLB skin + animation data - - `update_model_animation()` -- samples keyframes, walks hierarchy, computes joint matrices + - `instantiate_animation()` -- EN-055: Arc-shares parsed clips, fresh per-instance mixer/joint state + - `update_model_animation()` -- delegates clip time to the mixer, walks hierarchy, computes joint matrices - `load_gltf_with_textures()` -- loads skinned mesh with JOINTS_0/WEIGHTS_0 - Matrix/quaternion math: `mat4_from_trs`, `quat_slerp`, `mat4_mul`, `compute_joint_transforms` -- **`native/shared/src/renderer.rs`** -- GPU skinning: - - `Vertex3D` struct with `joints` and `weights` fields - - WGSL shader with `JointMatrices` uniform and 4-bone blend skinning - - `joint_buffer` + `joint_bind_group` at bind group 3 - - `set_joint_matrices()`, `set_joint_matrices_scaled()`, `flush_joint_matrices()` - - `draw_model_mesh_tinted()` -- handles skinned vs unskinned vertex positioning +- **`native/shared/src/anim_mixer.rs`** -- `AnimMixer` (EN-028): crossfades, masked additive layers, opt-in root motion, per-instance clip time. + +- **`native/shared/src/renderer/`** -- GPU skinning (the old single `renderer.rs` was split into this module): + - `types.rs` -- `Vertex3D` struct with `joints`, `weights`, `tangent` fields + - `shaders/core.rs` -- WGSL with `JointMatrices` uniform (1024 entries) + `joints_prev` for motion vectors, 4-bone blend skinning + - `mod.rs` -- `joint_buffer` + `joint_bind_group` at bind group 3; `set_joint_matrices()`, `set_joint_matrices_scaled()`, `flush_joint_matrices()`; `draw_model_mesh_tinted()` for skinned vs unskinned positioning -- **`native/macos/src/lib.rs`** (and `ios/`, `android/`, `windows/`, `linux/`) -- FFI functions: +- **`native/shared/src/ffi_core/models.rs`** -- FFI functions, generated once for every platform by the `define_core_ffi!` macro (each platform crate just invokes the macro in its `lib.rs`; web hand-writes `#[wasm_bindgen]` wrappers): - `bloom_load_model()` -- loads GLB mesh - `bloom_load_model_animation()` -- loads GLB skeleton + animations + - `bloom_instantiate_animation()` -- EN-055 instancing - `bloom_update_model_animation()` -- updates joint matrices from animation + - `bloom_anim_*` -- the mixer surface (play/layer/root-motion/update/…) - `bloom_draw_model()` -- renders model (skinned or static) - `bloom_set_joint_test()` -- debug: manually set a single joint rotation @@ -384,8 +417,11 @@ See `scripts/export_mixamo_glb.py` for full documentation. - **`src/models/index.ts`** -- Public API: - `loadModel(path)` -- returns `Model` with handle - `loadModelAnimation(path)` -- returns numeric animation handle - - `updateModelAnimation(handle, animIndex, time, scale, px, py, pz)` -- updates animation state + - `instantiateAnimation(src)` -- EN-055: cheap per-entity instance of a parsed animation + - `animPlay` / `animSetLayer` / `animSetRootMotion` / `animUpdate` / `animFinished` / `animClipDuration` / `animRootDelta` -- the mixer API (EN-028) + - `updateModelAnimation(handle, animIndex, time, scale, px, py, pz, rotY)` -- legacy raw-time update - `drawModel(model, position, scale, tint)` -- renders + - `findJoint(handle, name)` / `jointWorld(handle, joint)` -- joint queries (EN-033) - `setJointTest(joint, angle)` -- debug function ### Blender Scripts @@ -404,7 +440,7 @@ In debug builds (`#[cfg(debug_assertions)]`), the engine prints detailed animati [anim] Skeleton: 65 joints, 1 roots [anim] joint 0: 'mixamorig:Hips' children=[1, 2, 3] [anim] Animation 'Run': 195 channels mapped, 0 skipped, duration=0.83s, avg 25/ch keyframes -[anim] channels_applied=65, t=0.000, anim_index=0 +[anim] joints=65, t=0.000, anim_index=0 [anim] Joint0 local: t=[0.00,96.47,0.00] r=[0.0000,0.0000,0.0000,1.0000] [anim] Joint0 final diag=[1.0000,1.0000,1.0000] trans=[0.0000,0.0000,0.0000] ``` diff --git a/docs/tickets.md b/docs/tickets.md index 71d3a62..2de245b 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -757,7 +757,8 @@ report: `shooter/docs/audit-round2.md` finding F1. ## EN-021 — SSR + IBL specular exclusive ownership ✅ implemented (PR #78) **Status 2026-07-04.** Landed on `feat/en021-ssr-ibl-ownership` (PR #78, -pending merge): env-cubemap fallback bound into the SSR pass (miss +since merged to main via the round-2 integration PR #83): env-cubemap +fallback bound into the SSR pass (miss returns filtered env instead of black, `env_fallback()` in ssgi.rs; fresnel applied before the facing check so both miss paths agree), and `fs_main_scene` scales `ibl_spec` by the exact complement of the SSR @@ -792,7 +793,8 @@ overlap engine-wide with zero shader work. ## EN-022 — Motion vectors for material-system draws ✅ implemented (PR #82 + shooter PR #4) **Status 2026-07-04.** Landed on `feat/en022-material-velocity` (PR #82, -pending merge) + shooter PR #4: per-slot model history in +since merged to main via the round-2 integration PR #83) + shooter PR #4: +per-slot model history in MaterialSystem (`prev_models`/`cur_models` rotated in `reset_draw_slot(prev_vp)`, slot = submission order), `prev_mvp` reconstructed engine-side (the legacy caller-supplied param is @@ -842,7 +844,17 @@ adapters that genuinely lack RT (most Android, web permanently), and the object-space-AABB bug below is still real there. But it is no longer urgent, and the interim "disable SSGI on SW adapters" is no longer needed on Windows. -**Status 2026-07-04.** `feat/en023-gi-sw-cards` (PR #79, pending merge) +**Update 2026-07-16 — priority inverted again; SW is now the *default* path +everywhere.** The HW-vs-SW A/B finally ran (see the "HW-vs-SW Lumen" sections +near the end of this file): granting ray query cost **+20 ms/frame on the +760M** for a tonal-only difference (b34c1f3), so HW ray query became opt-in +(`BLOOM_HW_GI=1` / `BLOOM_PT` / `--pt`, 66dad5b) and SW GI stays the shipping +default (9d1523f). Consequence for this ticket: the SW colored-bounce data +path is not an Android/web nicety — it is what every player sees. The +remaining SW work items below inherit that priority. + +**Status 2026-07-04.** `feat/en023-gi-sw-cards` (PR #79, since merged to +main via the round-2 integration) fixed the data path: world-space AABBs carried per instance (`world_aabb_min/max` in InstanceGiData, both HW and SDF struct mirrors), smallest-containing-box broad-phase pick (a scene-spanning @@ -1047,8 +1059,9 @@ conforming to terrain slope and building walls, no lighting seams, **Why:** the animation FFI plays exactly one clip per model (`update_model_animation(handle, index, time)`), so every transition -in every game pops. Root motion is unconditionally stripped at import -(`models.rs:531`). This is the widest quality gap between Bloom +in every game pops. Root motion was unconditionally stripped at import +(now opt-in via `animSetRootMotion`; the logic lives around +`models.rs:738-846`). This was the widest quality gap between Bloom content and AAA content — geometry and lighting are fine; the *motion* is 2005. @@ -1368,6 +1381,16 @@ design. +## EN-040 — Unified cloud deck: sky clouds drive ground shadows ✅ *(shipped — 176bb83)* + +One cloud deck owns both the sky's clouds and the ground's moving cloud +shadows, so the two can never disagree. Exposed as +`setCloudShadows(strength, deckHeight, featureScale, driftSpeed)` +(`src/core/index.ts:640` → `bloom_set_cloud_shadows`). This is the deck the +HW-vs-SW GI A/B below turns off for its clouds-off protocol. +*(Entry added retroactively during the 2026-07-16 doc audit — the ticket +shipped without ever being registered here.)* + ## EN-041 — Hierarchical foliage wind ✅ *(shipped 2026-07-12)* The engine swayed **alpha-cut materials only**. So leaf cards fluttered and every @@ -1849,20 +1872,23 @@ mask must be full render res, and any tap-count adaptation must be imperceptible and screenshot-verified (per the twice-earned rule: a shadow perf win bigger than the change justifies = deleted shadows). -## EN-054 — SDF clipmap re-bake still does its whole CPU prep in one frame 🔴 *(2026-07-16 audit)* +## EN-054 — SDF clipmap re-bake still does its whole CPU prep in one frame ✅ *(shipped 2026-07-16 — PR #108 / d30fba9)* -The GPU half is amortised (16 Z-layers/frame); the frame that STARTS a rebake -still runs `scene.build_world_triangles()` synchronously — -`renderer/scene.rs:476-506` re-transforms every vertex of every node into -fresh Vecs with zero caching, then `gi_bake.rs:128-162` counting-sorts every -triangle. 20-60 ms in one frame, triggered every ~10 m of camera travel -(`gi_bake.rs:44-46`); the shooter's 50-68 ms wave-spawn spikes are this, and -the shooter ships SSGI **on**, so it is live in the shipped game. +The GPU half is amortised (16 Z-layers/frame); the frame that STARTED a rebake +used to run `scene.build_world_triangles()` synchronously — +`scene.rs:476-506` (top-level `native/shared/src/scene.rs`) re-transformed +every vertex of every node into fresh Vecs with zero caching, then +`gi_bake.rs:128-162` counting-sorted every triangle. 20-60 ms in one frame, +triggered every ~10 m of camera travel (`gi_bake.rs:44-46`); the shooter's +50-68 ms wave-spawn spikes were this, and the shooter ships SSGI **on**, so +it was live in the shipped game. -Fix (from the July perf audit, still unimplemented): cache the world-triangle -soup keyed on `tlas_version` — the scene is static, so travel-triggered -rebakes should re-bin, not re-gather; then amortise the binning across the 4 -frames the GPU bake already takes. +Fix as shipped: the world-triangle soup is cached in a static +`sdf_tri_cache` keyed on `tlas_version` (`gi_bake.rs:24,96-113`) — the +scene is static, so travel-triggered rebakes re-bin without re-gathering. +(The follow-up idea of also amortising the counting-sort across the 4 +frames the GPU bake takes was not part of d30fba9 — reopen if the re-bin +alone ever shows up in a profile.) ## EN-055 — No animation-instancing API: N enemies = N full GLB re-parses ✅ *(shipped same day — PR #107; and the boot claim was WRONG)* @@ -1882,12 +1908,13 @@ mixer per enemy SLOT (correct — mixer state is per-instance), so its boot parses the same seven GLBs ~30 times: **5.5 s of an 8 s boot** (SH-049 measured 61%). -The struct already separates what could be shared (`skeleton`, `animations`, +The struct already separated what could be shared (`skeleton`, `animations`, `ref_rest_rotations`) from per-instance state (`joint_matrices`, `mixer`, -`joint_world`) — models.rs:116-130. Wanted: `instantiateAnimation(handle)` -(Arc-share the clip data, fresh mixer state), callers stay handle-based. This -is the whole boot-time story; EN-032 (async loading) is adjacent but does not -remove the duplication. +`joint_world`). As shipped: `instantiateAnimation(handle)` Arc-shares the +clip data with fresh mixer state (`models.rs:511` / `Arc` at +`models.rs:65-67`; TS at `src/models/index.ts:819`), callers stay +handle-based. This was the whole boot-time story; EN-032 (async loading) is +adjacent but does not remove the duplication. ## EN-056 — Per-frame upload/allocation tail in the renderer 🟡 *(2026-07-16 audit)* @@ -1948,8 +1975,12 @@ are untracked strays (now at least gitignored + documented, shooter PR #29). Wanted: (a) a **loud boot warning** on DX12 when DXC was requested and FXC was used; (b) fetch-dxc wired into the Windows build/packaging path; -(c) the still-unmeasured HW-vs-SW Lumen frame cost measured once on the dev -box and recorded (docs/perf/014 has only "within ~1.5x" guesses). +(c) ~~the still-unmeasured HW-vs-SW Lumen frame cost measured once on the +dev box and recorded~~ — **done 2026-07-16** (b34c1f3): HW costs ++20 ms/frame on the 760M (14.4 vs 20.3 fps); full protocol and verdict in +the "HW-vs-SW Lumen" sections below. (a) and (b) remain open — though with +HW ray query now opt-in (66dad5b) the silent-FXC failure mode only bites +sessions launched with `BLOOM_HW_GI=1`/`BLOOM_PT`/`--pt`. ## EN-059 — File reads decode UTF-8 text as Latin-1: mojibake reaches the screen 🔴 *(2026-07-16 audit)* @@ -1969,6 +2000,11 @@ scanners: byte-level string handling). ## EN-060 — Grass distance LOD (quality-gated) 🟡 *(2026-07-16 audit)* +> **Numbering note (2026-07-16):** the spatial-audio-v2 work was developed +> on a branch named `feat/en-060-spatial-audio-v2` but shipped as +> **EN-062** (07c2c29, entry below) — the branch name is the only leftover +> of the collision. This EN-060 remains the grass-LOD ticket. + Grass tiles are frustum-culled (`material_system.rs:1776`) but in-frustum tiles draw at full authored density regardless of distance — 120k blades in the shooter, `material_pass` ~1.9 ms. The July audit's "concentric rings / diff --git a/docs/watchos-target.md b/docs/watchos-target.md index a93dc04..db54e68 100644 --- a/docs/watchos-target.md +++ b/docs/watchos-target.md @@ -1,9 +1,11 @@ # watchOS Target Bloom games can run on Apple Watch. Unlike every other Bloom platform, watchOS -has **no Metal/wgpu** available to third-party apps, so the watch target does not -use the wgpu renderer at all. Instead the engine emits a **draw list** that a -SwiftUI `Canvas` rasterizes — the game's imperative draw calls work unchanged. +has **no wgpu and no direct Metal surface** for third-party apps, so the watch +target does not use the wgpu renderer at all. Instead the engine emits a +**draw list** whose 2D commands a SwiftUI `Canvas` rasterizes and whose 3D +commands drive a SceneKit `SceneView` — the game's imperative draw calls work +unchanged. ## Architecture @@ -15,22 +17,42 @@ Game.ts ─(perry --target watchos --features watchos-swift-app)─┐ │ │ snapshot per frame ▼ - BloomWatchApp.swift ── SwiftUI Canvas rasterizes the list + BloomWatchApp.swift ── ZStack of: + • BloomSceneView (SceneKit) — 3D commands (kinds 20-29) + • SwiftUI Canvas — 2D commands, drawn on top │ ▼ - Apple Watch: Canvas + Digital Crown + taps + Apple Watch: SceneKit + Canvas + Digital Crown + taps ``` -`native/watchos/` is a small crate (no wgpu, no Jolt) that turns `bloom_draw_rect`, -`bloom_draw_texture`, text, etc. into a flat draw-command buffer. `BloomWatchApp.swift` -owns the `@main struct App: App`, spawns the game on a background thread, and on -each frame copies the latest draw list and replays it into a SwiftUI `Canvas`. +`native/watchos/` is a crate (no wgpu, no Jolt) that turns `bloom_draw_rect`, +`bloom_draw_texture`, text, etc. into a flat draw-command buffer. +`BloomWatchApp.swift` owns the `@main struct App: App`, spawns the game on a +background thread, and on each frame copies the latest draw list. 2D commands +replay into a SwiftUI `Canvas`; 3D commands (kinds 20-29) are filtered out of +the Canvas and handled by a SceneKit `SceneView` layered underneath. + +## 3D + +3D works through SceneKit (Metal-backed under the hood, no wgpu involved): + +- **Immediate mode** — `drawCube`, `drawSphere` (+ wire variants) map to + `bloom_draw_cube` / `bloom_draw_sphere` etc. +- **Retained scene graph** — the `bloom_scene_*` FFI surface delta-syncs + scene nodes to `SCNNode`s (`contentRoot`/`retainedRoot`/`lightsRoot` + + a camera node in `BloomWatchApp.swift`). +- **Models** — a hand-rolled `.glb` loader (`native/watchos/src/models.rs`), + validated against DamagedHelmet / Buggy / Fox. +- `bloom_watchos_has_3d` reports availability. + +What is *not* built on watchOS is the wgpu renderer and Jolt physics — see +Limitations. ## Building watchOS builds go through Perry. The engine's watch crate (`native/watchos`) and Perry's runtime are tier-3 Rust targets built with nightly `-Z build-std`. See -the Perry [watchOS platform docs](../../perry/perry/docs/src/platforms/watchos.md) +the Perry [watchOS platform docs](../../../perry/perry/docs/src/platforms/watchos.md) for the full toolchain setup; the engine-specific parts are: - Compile the game with **`--features watchos-swift-app`** so the engine's @@ -49,9 +71,9 @@ PERRY_RUNTIME_DIR=/target/aarch64-apple-watchos-sim/release \ ``` > **Deployment-target floor: watchOS 10.0.** `BloomWatchApp.swift` uses -> SwiftUI's `onChange(of:initial:)`, which is watchOS 10+. Builds below 10.0 -> fail to compile the Swift shell, so `PERRY_WATCHOS_MIN` cannot go lower than -> `10.0`. +> SwiftUI's two-parameter `onChange(of:) { old, new in }` overload, which is +> watchOS 10+. Builds below 10.0 fail to compile the Swift shell, so +> `PERRY_WATCHOS_MIN` cannot go lower than `10.0`. ## Game Loop @@ -110,10 +132,14 @@ was hardcoded to English.) ## Limitations -- **No Metal post-processing** — the `bloom_postfx.metal` chromatic-aberration / - film-grain / sun-shaft pass is unavailable (SCNTechnique/SCNRenderer absent - from the watchOS SDK). Games run fine without it. -- **No 3D** — the Canvas rasterizer is 2D only; the wgpu/Jolt paths are not built. +- **No Metal post-processing yet** — the `bloom_postfx.metal` chromatic- + aberration / film-grain / sun-shaft pass is staged but not visible: + `SCNTechnique` attaches fine (and loads from `default.metallib`), but + `SCNRenderer` is absent from the watchOS SDK, so per-frame uniforms can't + be pushed. Games run fine without it. +- **No wgpu renderer / no Jolt physics** — 3D goes through SceneKit (see the + 3D section above); the deferred-MRT pipeline, Lumen GI, and physics are + not available on the watch. - **Small screen & RAM** — design for 40–49 mm faces and keep memory modest. - **Simulator can't run device-arch builds** — the sim is arm64; an arm64_32 build only runs on real pre-S9 hardware. @@ -125,6 +151,6 @@ draw-command buffer, input accumulators, screen size, and the reported language. `bloom_draw_*` append commands; `bloom_watchos_copy_draw_list` snapshots them for Swift each frame. Strings cross the FFI boundary using Perry's 20-byte `StringHeader` layout (both incoming args and returned strings — `read_file` -returns paths/level data this way). Because the renderer is just a draw-list -replayer, the same game code that targets desktop and mobile runs on the watch -unchanged. +returns paths/level data this way). Because the renderer is a draw-list +replayer (Canvas for 2D, SceneKit for 3D), the same game code that targets +desktop and mobile runs on the watch unchanged. diff --git a/docs/web-target.md b/docs/web-target.md index b92c738..f48970e 100644 --- a/docs/web-target.md +++ b/docs/web-target.md @@ -5,11 +5,11 @@ Bloom games can run in the browser via WebAssembly. The web target uses WebGPU ( ## Architecture ``` -Game.ts ─(perry --target wasm)──> game.wasm (game logic in WASM) - │ +Game.ts ─(perry --target wasm)──> game WASM (game logic, base64-embedded + │ in Perry's self-contained HTML) │ FFI imports ("ffi" namespace) ▼ - index.html / JS glue (bridges both WASM modules) + bloom_glue.js (bridges both WASM modules) │ │ wasm-bindgen calls ▼ @@ -19,7 +19,7 @@ Game.ts ─(perry --target wasm)──> game.wasm (game logic in WASM) Browser: + WebGPU + Web Audio + DOM Events ``` -Both game logic and rendering run in WebAssembly. A thin JS glue layer bridges the two modules, handles DOM events, string conversion, asset fetching, and audio output. +Both game logic and rendering run in WebAssembly. A thin JS glue layer (`native/web/bloom_glue.js`, spliced into Perry's self-contained HTML by `splice_game.py`) bridges the two modules, handles DOM events, asset fetching, and audio output. ## Building @@ -36,7 +36,7 @@ Both game logic and rendering run in WebAssembly. A thin JS glue layer bridges t ``` This runs: -1. `wasm-pack build` to compile `native/web/` → `bloom_web.wasm` + JS bindings +1. `wasm-pack build` to compile `native/web/` → `pkg/bloom_web_bg.wasm` + `pkg/bloom_web.js` bindings 2. `wasm-opt -Oz` for binary size optimization (if installed) 3. `perry main.ts --target wasm` to compile game TypeScript → WASM 4. Assembles output directory at `dist/web/` @@ -133,16 +133,16 @@ The wgpu backend supports both WebGPU and WebGL. WebGL is used automatically as ### String Handling -Perry WASM uses NaN-boxed string IDs. The JS glue converts these to actual JS strings via `__perryToJsValue` (exposed by Perry's runtime), then passes them to Bloom's `_str` variants via wasm-bindgen. +Perry WASM uses NaN-boxed values internally, but Perry's runtime wraps the entire `ffi` namespace with `wrapFfiForI64`, which decodes each NaN-boxed argument to a plain JS value before the glue is called. The glue therefore receives ordinary JS strings and simply routes them to Bloom's `_str` variants via wasm-bindgen — there is no manual NaN-boxing or decoding in the glue. ### Two-Module WASM Perry compiles game TypeScript to one WASM module. Bloom's Rust backend compiles to a second WASM module via wasm-pack. The JS glue: 1. Loads bloom_web.wasm and extracts all `bloom_*` exports -2. Wraps them as FFI imports (converting i64 BigInt args to f64) -3. Provides string-param functions with NaN-box → string conversion +2. Wraps every export as an FFI import, passing values straight through (Perry's `wrapFfiForI64` has already decoded them) +3. Overrides string- and asset-param functions to route them to their `_str`/`_bytes` variants 4. Boots Perry WASM with these imports under the `"ffi"` namespace ### Shared Code -67% of Bloom's Rust code is in `native/shared/` — the renderer, audio mixer, text renderer, model loader, scene graph. This code compiles identically for native and WASM. Only the platform layer (~1300 lines in `native/web/src/lib.rs`) is web-specific. +About two-thirds of Bloom's Rust code is in `native/shared/` — the renderer, audio mixer, text renderer, model loader, scene graph. This code compiles identically for native and WASM. Only the platform layer (~3300 lines across `native/web/src/`: `lib.rs`, `input_ffi.rs`, `material_ffi.rs`, `physics_ffi.rs`, `render_settings.rs`) is web-specific. diff --git a/native/shared/src/renderer/graph.rs b/native/shared/src/renderer/graph.rs index b36d785..be805ed 100644 --- a/native/shared/src/renderer/graph.rs +++ b/native/shared/src/renderer/graph.rs @@ -6,13 +6,10 @@ // nodes so a node that reads a resource always runs after the node // that writes it; explicit `after` / `before` hints break ties. // -// This Phase 2 module lands the types, the scheduler, and a set of -// unit tests that validate correct ordering. It is NOT yet wired into -// `end_frame_with_scene` — that's Phase 2b, and is deliberately -// separated because the port of ~15 existing passes is a high-risk -// refactor that needs its own review window. The infrastructure here -// is self-contained and dead-code-free because the tests exercise -// every code path. +// This module landed in two steps: Phase 2 brought the types, the +// scheduler, and ordering unit tests; Phase 2b (complete — see the +// frame-graph construction in `mod.rs`) ported the existing passes, so +// the graph now drives the real frame in `end_frame_with_scene`. use std::collections::{HashMap, HashSet}; diff --git a/native/shared/src/renderer/pt_pass.rs b/native/shared/src/renderer/pt_pass.rs index eac2a9c..2d0b0df 100644 --- a/native/shared/src/renderer/pt_pass.rs +++ b/native/shared/src/renderer/pt_pass.rs @@ -373,8 +373,8 @@ impl Renderer { self.pt_accum_idx = written_idx; // ---- PT-3b: SVGF wavelet filter (realtime mode only) ---- - // Four variance-guided à-trous iterations on the trace grid - // (steps 1/2/4/8), then the full-res upsample+modulate pass. + // Six variance-guided à-trous iterations on the trace grid + // (steps 1/2/4/8/16/1), then the full-res upsample+modulate pass. // After iteration 1 the once-filtered signal is copied back // over the accum buffer: SVGF feeds the first wavelet output // into next frame's colour history (moments stay raw). This is From 715e0cd1118608e82ef4026ef9fae0251e835999 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 16 Jul 2026 22:42:55 +0200 Subject: [PATCH 2/2] docs: EN-063 - build-web red on main since EN-055 (models.rs vs models3d), ticketed --- docs/tickets.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/tickets.md b/docs/tickets.md index 2de245b..d8d7715 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -2081,3 +2081,28 @@ Tests: 10 new (loop lifecycle, stereo crossing, legacy-curve equivalence, equal-power, air absorption, rear cue, pitch rate, doppler zero-crossing count, max-dist cull/return, per-voice occlusion). watchOS stubs regenerated; web target exports the same six calls. First consumer: shooter SH-050. + +## EN-063 — build-web has been red on main since EN-055: models.rs no longer compiles without `models3d` 🔴 *(2026-07-16 evening)* + +Every `Tests` run on main from PR #107 (EN-055, 323a6c9) onward fails the +`build-web` job at its first step, `cargo check --target +wasm32-unknown-unknown --no-default-features --features web` in +`native/shared` — the exact command CLAUDE.md tells every session to run. +PRs #108–#116 all merged over the same inherited red. (Third occurrence of +the pattern PR #105 fixed twice before: a change compiles on the platform +that wrote it and breaks the one that didn't run.) + +Error classes (all in `models.rs`, all `models3d`-off builds): +- `pub use crate::anim_mixer::AnimMixer` (`models.rs:60`) — `anim_mixer` + is `#[cfg(feature = "models3d")]`-gated in `lib.rs`, the re-export isn't. +- `crate::staging::StagedModel` (`models.rs:~1803`) — the type is gated, + the use isn't. +- ~29 bare `gltf::` paths — `gltf` is `dep:gltf` behind `models3d`. + +Fix wanted: make `bloom-shared` compile with `--no-default-features +--features web` again — gate the re-export and the gltf/staging-touching +`ModelManager` internals on `models3d` (the `ffi_core/models.rs` surface +already has both gated variants; it's the module internals that lost their +compileability). Then make the red gate LOUD: build-web has been failing +for ~10 hours and five merged PRs without anyone noticing, which is the +EN-058(a) lesson again — a gate nobody reads is not a gate.