From 1150c6b1e39150724250c56c67167798ca87da97 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 10:16:49 +0200 Subject: [PATCH 01/17] feat(EN-026/027/028/033): particles, decals, animation mixer, bone sockets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AAA gap audit found the renderer is past diminishing returns while the things a player actually *feels* — motion, impacts, marks on the world — had no engine support at all. These are the four systems that gate the game-side feel work. EN-028 animation mixer (models.rs) Single-clip sampling made every transition pop, forced a character to stop moving in order to attack, and threw away authored locomotion arcs. Adds a base track that crossfades (smoothstepped, hemisphere-corrected nlerp), one masked layer so an attack can drive the spine while the legs keep walking, and opt-in root motion with correct delta across the loop wrap. The engine now owns clip time — a crossfade is impossible otherwise, since the outgoing clip has to keep advancing. EN-033 bone sockets (models.rs) joint_matrices is skinning-space and useless for attaching props. Keep the pre-inverse-bind world transforms too and expose them, so a weapon can ride the hand and a muzzle flash can spawn at the real barrel. EN-026 particles (particles.rs) The engine had no particle system; the game faked it with a 16-slot pool. Sim is native because the alternative isn't: instance data crosses the FFI one float at a time, so 2k particles would be ~24k calls/frame against a current budget of ~240. Game/engine traffic is now O(spawn events). SoA pool, swap-remove, gravity/drag/floor-bounce, curves over normalized age. EN-027 decals (decals.rs) Oriented sticker quads through the existing instanced cutout path rather than a new deferred pass — the honest tradeoff is they can't wrap a corner, which no one will see on bullet holes and blood. Normal is packed as two angles to fit the instance stride, leaving room for the atlas frame. Plumbing: InstanceData3D's 3 pad floats were already being uploaded, so they are now exposed as @location(11) instance_extra at zero cost (no stride change, no extra bandwidth); adds dynamic (untiled, rewritable) instance buffers, since the static path reorders instances into XZ tiles — right for grass, wrong for anything that moves. 44 new FFI symbols across all platforms (incl. real web ports, watchOS stubs); validate-ffi clean. 8 new unit tests; 108/108 shared tests pass. --- docs/tickets.md | 387 ++++++++++++++- native/shared/src/decals.rs | 245 +++++++++ native/shared/src/engine.rs | 7 + native/shared/src/ffi_core/mod.rs | 2 + native/shared/src/ffi_core/models.rs | 157 ++++++ native/shared/src/ffi_core/vfx.rs | 203 ++++++++ native/shared/src/lib.rs | 2 + native/shared/src/models.rs | 467 +++++++++++++++--- native/shared/src/particles.rs | 391 +++++++++++++++ .../src/renderer/material_instancing.rs | 48 ++ native/shared/src/renderer/types.rs | 10 +- native/watchos/src/ffi_stubs.rs | 59 +++ native/web/src/material_ffi.rs | 179 +++++++ package.json | 113 ++++- src/models/index.ts | 79 +++ src/vfx/index.ts | 191 +++++++ 16 files changed, 2469 insertions(+), 71 deletions(-) create mode 100644 native/shared/src/decals.rs create mode 100644 native/shared/src/ffi_core/vfx.rs create mode 100644 native/shared/src/particles.rs create mode 100644 src/vfx/index.ts diff --git a/docs/tickets.md b/docs/tickets.md index 87abfb6..0535cbb 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -8,7 +8,12 @@ broader RFC --- -## EN-001 — Instanced-draw FFI 🟢 +## EN-001 — Instanced-draw FFI ✅ shipped + +> **Status:** landed — instance buffers + instanced material draws +> (`renderer/material_instancing.rs`, `bloom_create_instance_buffer`, +> `submit_material_draw_instanced`); shooter runs 20k grass instances +> in one draw, tile-culled since aeb3228. **Why:** the shooter currently bakes 5000 grass blades into one big static mesh and draws it with a single `drawMeshWithMaterial`. @@ -47,7 +52,11 @@ time so the right vertex layout is used. --- -## EN-002 — `drawModel` rotation parameter 🟢 +## EN-002 — `drawModel` rotation parameter ✅ shipped + +> **Status:** landed — `drawModelRotated` (degrees), later moved onto +> the cached scene pipeline so cutout/materials apply; used by the +> shooter's 88-tree forest. **Why:** the shooter just shipped `drawMeshWithMaterial`-based tree sway because `drawModel` has no rotation arg, so we couldn't tilt @@ -245,7 +254,11 @@ SH-020..SH-024 tickets. --- -## EN-010 — Alpha-cutout bucket 🟢 +## EN-010 — Alpha-cutout bucket ✅ engine side shipped + +> **Status:** cutout bucket landed in the material pipeline (leaf-card +> trees render through it since the round-5 texture work). Game-side +> SH-020 (converting 2 of 4 tree variants) still pending. **Why:** today's render buckets are Opaque (full G-buffer), Transparent (back-to-front blend), Refractive (Transparent + @@ -289,7 +302,13 @@ emit doc update. --- -## EN-011 — Planar reflection capture 🟢 +## EN-011 — Planar reflection capture ✅ shipped + +> **Status:** landed — `renderer/planar_reflection.rs` / +> `planar_pass.rs` + `setMaterialReflectionProbe`; the river reflects +> the bank. Mirrored-frustum culling, batched uniforms, cached probe +> bind group, and `setMaterialProbeVisible` (grass exclusion) landed +> in the 2026-07 perf round. **Why:** the water material today reads `env_tex` (the static HDR panorama) for sky reflection. That's correct for sky but means a @@ -385,7 +404,10 @@ math go away from each. --- -## EN-013 — Global wind UBO in PerFrame 🟢 +## EN-013 — Global wind UBO in PerFrame ✅ shipped + +> **Status:** landed — `frame.wind` + `setWind()`; shooter grass and +> trees ride one UBO. **Why:** every foliage material today re-declares its own `wind: vec4` in a per-material UBO (GrassParams, TreeParams). When @@ -415,7 +437,11 @@ overrides. --- -## EN-014 — Texture-array binding pattern for splat-mapped terrain 🟡 +## EN-014 — Texture-array binding pattern for splat-mapped terrain ✅ shipped + +> **Status:** landed — texture-array bindings in the material ABI +> (`material_abi.wgsl`, splat-terrain support in the material system). +> Game-side SH-009 (4-layer PBR terrain) is unblocked and pending. **Why:** SH-009 wants 4 PBR layers (grass / dry-grass / dirt / rock) blended in the fragment by weight masks. The current @@ -503,7 +529,10 @@ cost as today's 120. --- -## EN-016 — Custom-material shadow-receive helper 🟢 +## EN-016 — Custom-material shadow-receive helper ✅ shipped + +> **Status:** landed — `sample_sun_shadow` / `sample_sun_shadow_n` +> helpers; consumed by grass, terrain, tree, and water materials. **Why:** game materials (`grass.wgsl`, `tree.wgsl`, `terrain.wgsl`, `water.wgsl`) all want to sample the directional shadow cascades. @@ -532,7 +561,11 @@ above. --- -## EN-017 — Post-pass slot for game-side full-screen FX 🟡 +## EN-017 — Post-pass slot for game-side full-screen FX ✅ shipped + +> **Status:** landed — `bloom_add_post_pass` game-injected fullscreen +> passes. First consumers: shooter SH-029 damage-flash / low-health +> grading (the original SH-019 underwater use case was closed). **Why:** SH-019 wants an underwater colour tint when the camera Y falls below the water surface. The engine ships several built-in @@ -857,3 +890,341 @@ bug), which was a straight bug — this one is a product call. **Found by** the macOS/iOS parity audit, 2026-07-11. +--- + +# AAA-feel enablers (2026-07-12 gap audit) + +EN-025 onward come out of the full shooter/engine/editor AAA gap +audit. The renderer is past diminishing returns; these are the engine +systems whose absence is most visible in actual gameplay footage: +animation depth, VFX, decals, audio DSP, UI, and input. Game-side +counterparts are shooter SH-025..SH-044 (`bloom/shooter/docs/tickets.md`), +which also carries the round ordering. + +--- + +## EN-025 — Ragdoll FFI 🟡 + +**Why:** Jolt ships `Ragdoll.cpp` in the vendored submodule but no +`bloom_physics_ragdoll_*` FFI exists — the shooter's enemies die by +clamping the last animation frame and sinking through the floor. +Ragdoll handoff is the single strongest "physical world" signal in an +action game, and the hard part (the solver) is already linked into +every build. + +**API sketch:** + +```ts +// Build once per model kind: bones as capsules between joint pairs, +// constraints from the skeleton hierarchy. +const rag = physicsCreateRagdoll(modelHandle, { + boneRadiusScale: 0.4, // capsule radius from bone length + maxBodies: 16, // merge tiny finger/tail chains +}); +// On death: seed from the current animated pose + a kill impulse. +physicsActivateRagdoll(rag, modelHandle, hitDirX, hitDirY, hitDirZ, impulse); +// Per frame while active: physics drives the skin. +physicsFetchRagdollPose(rag, modelHandle); // writes joint matrices +physicsDeactivateRagdoll(rag); // back to the pool +``` + +**Open questions:** +- Auto-shape generation from the skeleton (capsules per joint pair, + mass from bone length) vs authored shapes? (V1: auto with a scale + knob; the alien skeletons are simple.) +- Sleep/despawn policy — deactivate when velocity < ε for N frames. + +**Scope:** medium-large (~1 week) — C shim over Jolt's Ragdoll + +skeleton mapping + the pose write-back into the existing joint UBO +path (GPU skinning already consumes joint matrices; this only changes +who computes them). + +**Acceptance:** shooter SH-031 — a killed enemy crumples over terrain +edges, reacts to the killing impulse, never clips the heightfield; +4 simultaneous ragdolls cost < 0.5 ms CPU. + +--- + +## EN-026 — Particle / VFX system 🟡 + +**Why:** the engine has **no particle system at all** (spec Phase I, +unbuilt) — the shooter fakes sparks with a 16-slot pool of additive +material draws. Muzzle smoke, blood, dust, shells, explosions, and +ambient motes all want one system. This is the most visible missing +engine feature in combat footage. + +**Design (V1 — CPU sim, GPU instanced):** +- Emitter descriptor: spawn rate / burst count, lifetime ± variance, + initial velocity cone, gravity + drag, size/alpha/color over life + (4-key curves), optional atlas frame animation, bucket (additive | + cutout), soft-depth-fade distance. +- CPU sim over a global pool (4–8k particles), one instance buffer + write per frame (the EN-001 instancing path is exactly the right + infrastructure), camera-facing quads in the shader. +- Sort: additive needs none; per-emitter sort for the rare + alpha-blended case. +- Soft particles: sample scene depth (the refractive bucket already + binds it) and fade near intersections. + +**API sketch:** + +```ts +const em = createParticleEmitter({ /* descriptor above */ }); +emitterBurst(em, x, y, z, count); // impact/muzzle one-shots +setEmitterPosition(em, x, y, z); // continuous (smoke trail) +setEmitterActive(em, on); +``` + +**Open questions:** GPU sim (compute) is V2 — only needed past ~50k +particles; V1's budget target is 2k live particles < 0.3 ms GPU on +the 760M-class iGPU. + +**Scope:** medium-large (~1–1.5 weeks). + +**Acceptance:** shooter SH-033 ships muzzle smoke, blood, shells, +dust, and an explosion set entirely on this API within its GPU +budget; mobile halves pool sizes via the same descriptors. + +--- + +## EN-027 — Deferred decal system 🟡 + +**Why:** the world takes no marks — no bullet holes, scorch, or blood +splats. The impulse field is a material *input* (ripples/wet), not +projected decals. A deferred renderer makes decals cheap: project a +box, rewrite G-buffer albedo/normal/roughness before lighting. + +**Design (V1):** +- Decal = oriented box (position, normal-aligned rotation, size), + atlas UV rect, albedo/normal/roughness contribution weights, + lifetime + fade. +- Rendered as instanced boxes after the opaque G-buffer pass, + reading depth to reconstruct the surface point, discarding outside + the box, angle-fade past ~60° to kill stretching. +- Fixed pool (e.g. 256) with ring reuse; per-decal fade-out. +- Skinned meshes excluded in V1 (blood on enemies is SH-033's + particle tint job). + +**API sketch:** + +```ts +spawnDecal(x,y,z, nx,ny,nz, size, rotRad, atlasU0,V0,U1,V1, lifetimeS); +``` + +**Scope:** medium (~1 week) — one new pass + atlas loader + pool. + +**Acceptance:** shooter SH-033 — 64 bullet holes visible at once, +conforming to terrain slope and building walls, no lighting seams, +< 0.2 ms GPU at full pool. + +--- + +## EN-028 — Animation blending, masks, root motion 🟡 + +**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 +content and AAA content — geometry and lighting are fine; the +*motion* is 2005. + +**Pieces (shippable in order):** +1. **Crossfade:** `playModelAnimation(handle, clip, fadeSeconds)` — + engine keeps a 2-slot mixer per model (current + previous clip, + lerped by fade progress at pose level). Covers 80% of the pops. +2. **Locomotion blend:** `setModelLocomotion(handle, clipA, clipB, + t)` — two-clip continuous blend (idle↔walk↔run by speed), with + phase-matching so feet don't slide during the blend. +3. **Upper-body masks:** joint-range (or named-group) mask so an + attack clip drives the spine-up while locomotion keeps the legs. +4. **Root motion opt-in:** converter/import flag to *keep* root + translation + an FFI to read the per-frame root delta + (`getModelRootDelta(handle)`) so the game can feed it to the + character controller. Default stays stripped (back-compat). + +**Scope:** medium overall; piece 1 alone is small (~2 days) and +unlocks most of shooter SH-030/SH-034. + +**Acceptance:** shooter transitions (walk↔attack↔pain↔die) show no +pops at 0.15 s fades; a marauder attacks while moving; the dragoon +pounce follows its authored root arc instead of hand-tuned kinematics. + +--- + +## EN-029 — Audio buses, reverb send, occlusion filter 🟡 + +**Why:** the mixer is master + per-voice gain — no submixes, no DSP. +AAA "gunfeel" is half audio: a weapon tail through a reverb send, a +mix that ducks around damage, distance/occlusion filtering. The +render thread is already lock-free SPSC; these are additions to that +graph, not a rewrite. + +**Pieces:** +1. **Fixed bus graph V1:** master → { music, sfx, ui }. Per-bus gain + + `duckBus(bus, amountDb, attackS, releaseS)` (sidechain-style + momentary duck). +2. **One reverb send:** Freeverb/Schroeder on the render thread; + per-voice send amount; global room params (`setReverbParams(size, + damp, wet)`) so the game can morph zones. +3. **Per-voice one-pole low-pass:** `setSoundLowpass(voice, cutoffHz)` + — the occlusion/distance-muffle primitive; the game decides when + (raycasts are game-side). +4. Bus assignment at play time: `playSound3DOnBus(...)` or a default + + setter. + +All parameters flow through the existing SPSC control messages — no +locks on the audio thread. + +**Scope:** medium (~1 week for 1–3). + +**Acceptance:** shooter SH-035 — music audibly ducks on damage, +fights near the building sound enclosed, a shrieking enemy behind the +building is muffled; zero underruns/glitches at 48 kHz with 32 voices. + +--- + +## EN-030 — UI widget layer 🟢 *(TS-side, no Rust needed)* + +**Why:** the engine offers immediate-mode shapes + text only. Every +game needs pause/settings/menus, and the editor hand-rolls its own +panels. A retained-lite widget layer over the existing `draw2d` + +text + input FFIs closes both — **entirely in TypeScript** as a +`bloom/ui` module; no engine-native work. + +**Scope (V1):** +- Widgets: panel, label, button, slider, toggle, dropdown, key-capture + field (for rebinding). +- Layout: vertical/horizontal stacks with padding/anchors — no + general constraint solver. +- Focus model: one focus ring driven by mouse hover, D-pad/arrows, + and touch; activate on click/A/tap. This is the piece that makes + gamepad menus (shooter SH-039) possible. +- Theming: flat colors + 9-patch optional; respects the logical-space + scaling pattern the shooter already uses for HiDPI/mobile. +- Input capture: when a UI tree is active it consumes input so the + game doesn't fire while clicking Resume. + +**Acceptance:** shooter SH-038 builds title/pause/settings on it, +navigable by mouse, keyboard, pad, and touch; the editor can adopt +widgets incrementally. + +--- + +## EN-031 — Gamepad backend verification + rumble 🟢 + +**Why:** the FFI surface exists (`bloom_is_gamepad_available`, +`bloom_get_gamepad_axis`, `bloom_is_gamepad_button_*`, +`bloom_get_gamepad_axis_count` — package.json manifest) alongside +`bloom_inject_gamepad_*` twins, which suggests the desktop backends +may only ever have been fed by injection (web/tests) rather than +polling real hardware. Nothing ships until a pad physically works. + +**Scope:** small-medium. + +- Audit each native backend: Windows (XInput), macOS/iOS + (GameController framework), Linux (evdev/SDL-free), web (Gamepad + API — likely already live). +- Wire whichever are dead; normalize axis ranges/deadzones and the + button index map across platforms (document the canonical layout). +- **Add rumble:** `bloom_gamepad_rumble(lowFreq, highFreq, durationMs)` + — cheap on XInput/GameController and a big feel win for SH-029. + +**Acceptance:** a wired/BT pad drives the shooter on Windows and +iPhone; axis/button indices match the documented map on all +platforms; rumble fires on damage. + +--- + +## EN-032 — Async model + world loading 🟡 + +**Why:** every load is synchronous on the main thread — `loadWorld` +is readFile → parse → validate in one blocking call, and model/texture +loads block too. One arena hides this; level switching (shooter +SH-040), bigger worlds, and any future streaming need the machinery. + +**Scope:** medium. + +- `loadModelAsync(path) -> handle` immediately; `isModelReady(handle)` + poll; draws of not-ready handles no-op (or draw a bounds proxy). +- Background thread does file IO + parse + GPU upload staging; main + thread finalizes (wgpu queue submission) in bounded per-frame slices. +- World: `loadWorldAsync(path)` with a progress query so games can + render a loading screen; instantiation itself amortized over frames. +- This is deliberately *not* world-partition streaming — just the + substrate it would need (tracked as a deferred item below). + +**Acceptance:** shooter switches arenas behind an animated loading +screen with no main-thread stall > 100 ms; title screen appears +< 1 s after launch with assets streaming in behind it. + +--- + +## EN-033 — Bone-socket world-transform query 🟢 + +**Why:** games can't attach anything to a skeleton — the shooter's +weapon can't ride the player's hand, muzzle points can't track fire +animations. The skin matrices are already computed every frame; this +is a read-back, not a feature. + +**API sketch:** + +```ts +// After updateModelAnimation: joint's world transform under the given +// model transform (pos + quat, or 12 floats). Numeric FFI per +// perry-quirks #5. +getModelJointWorld(handle, jointIndex, posX,posY,posZ, yawRad, scale) + -> writes into a flat out-array FFI (bloom_get_model_joint_world_*) +``` + +Plus `findModelJoint(handle, name) -> index` at load time (string ok — +load-time only). + +**Scope:** small (~1–2 days). + +**Acceptance:** shooter SH-027 v2 — the rifle rides the hand joint +through walk/attack clips; muzzle flash tracks the true muzzle. + +--- + +## EN-034 — Spot lights 🟢 + +**Why:** the light schema is directional + point only +(`src/world/types.ts`). Spots are the workhorse light of interiors, +muzzle-light shaping, and flashlights; the froxel clustering path +already handles points — a cone test is incremental. + +**Scope:** small-medium — `LightData kind:"spot"` (schema v3 with +migration, editor light-tool angle handles), cone attenuation in the +clustered lighting path. Shadowed spots explicitly deferred. + +**Acceptance:** editor places a spot with direction + inner/outer +angles; engine renders correct cone falloff; N spots cluster like +points. + +--- + +## Deferred infrastructure tracks 🔴 + +Recorded so the list is complete; each is real but none blocks the +current shooter roadmap. Most are specced in +`bloom-renderer-spec-v2.md` — this is the priority call, not new +design. + +- **EN-035 — Job system.** No general-purpose task pool exists (audio + + Jolt thread internally; render submission is single-threaded). + Becomes the bottleneck when draw counts or anim/ragdoll counts grow + 10×. +- **EN-036 — Wire the render graph.** `renderer/graph.rs` is built and + unit-tested but the live frame is hand-ordered in `mod.rs`. Wiring + it buys automatic barriers/aliasing and makes pass insertion + (EN-026/EN-027) cheaper — do it opportunistically with one of those. +- **EN-037 — World streaming / partition.** Chunked worlds, HLOD, + cell load/unload on EN-032's substrate. Only if a game outgrows + arena scale. +- **Spec-v2 renderer tracks** (VSM, froxel volumetrics, virtualized + geometry, DLSS/FSR SDK integration): explicitly parked — the + current CSM/TSR stack is not the gap. Revisit after the feel + rounds ship. +- **Netcode:** absent entirely; out of scope by decision, not + oversight. Revisit only with a design that needs it. + diff --git a/native/shared/src/decals.rs b/native/shared/src/decals.rs new file mode 100644 index 0000000..d119afd --- /dev/null +++ b/native/shared/src/decals.rs @@ -0,0 +1,245 @@ +//! EN-027 — surface decals (bullet holes, scorch, blood splats). +//! +//! Textbook AAA decals are a deferred pass: project a box, read depth, +//! rewrite the G-buffer before lighting. That is the right long-term shape and +//! it is what a future revision should do. It is also a new pass wired into a +//! hand-ordered 559 KB frame, for a feature whose entire visible job here is +//! "the wall remembers the bullet". +//! +//! So this version is a *sticker*: an oriented quad, pushed a couple of +//! millimetres along the surface normal, drawn through the existing instanced +//! cutout material path. It depth-tests against the world, receives no +//! lighting of its own (the material multiplies the surface's own shading), and +//! costs one draw call for the whole ring. It cannot wrap around a corner — +//! that is the honest limitation, and for bullet holes on flat stone and blood +//! on flat ground nobody will ever see it. +//! +//! Packing note: the instance stride only carries a single Y rotation, but a +//! decal needs an arbitrary orientation. A unit normal is two angles, so it +//! fits in the two spare `extra` slots, leaving `rot_y` free to mean roll +//! about the normal and `extra.x` to carry the atlas frame. + +/// A decal in flight. Fades out over the last `fade` seconds of its life so it +/// does not pop. +struct Decal { + pos: [f32; 3], + /// Surface normal, stored as azimuth/elevation to fit the instance stride. + az: f32, + el: f32, + roll: f32, + size: f32, + color: [f32; 4], + frame: f32, + age: f32, + life: f32, + fade: f32, +} + +/// Look + lifetime for the *next* spawns. Set once per decal type (bullet +/// hole, scorch, blood) rather than passed on every hit — a spawn is then 8 +/// f64 args, which is exactly the ARM64 register ceiling the FFI has to +/// respect. +#[derive(Clone, Copy)] +pub struct DecalStyle { + pub frame: f32, + pub color: [f32; 4], + pub life: f32, + pub fade: f32, +} + +impl Default for DecalStyle { + fn default() -> Self { + Self { frame: 0.0, color: [1.0; 4], life: 0.0, fade: 0.0 } + } +} + +pub struct DecalManager { + decals: Vec, + capacity: usize, + /// Ring cursor: once full, the oldest decal is the one overwritten. + next: usize, + pub instance_buffer: u32, + pub style: DecalStyle, + packed: Vec, + pub live: u32, +} + +impl DecalManager { + pub fn new() -> Self { + Self { + decals: Vec::new(), + capacity: 0, + next: 0, + instance_buffer: 0, + style: DecalStyle::default(), + packed: Vec::new(), + live: 0, + } + } + + pub fn init(&mut self, capacity: usize, instance_buffer: u32) { + self.capacity = capacity; + self.instance_buffer = instance_buffer; + self.decals = Vec::with_capacity(capacity); + self.packed = vec![0.0; capacity * 12]; + self.next = 0; + self.live = 0; + } + + /// Place a decal using the current `style`. `n` is the surface normal (need + /// not be normalized). + pub fn spawn_styled(&mut self, pos: [f32; 3], n: [f32; 3], size: f32, roll: f32) { + let st = self.style; + self.spawn(pos, n, size, roll, st.frame, st.color, st.life, st.fade); + } + + /// Place a decal. `n` is the surface normal (need not be normalized); + /// `life <= 0` means permanent (well — until the ring wraps). + #[allow(clippy::too_many_arguments)] + pub fn spawn( + &mut self, + pos: [f32; 3], + n: [f32; 3], + size: f32, + roll: f32, + frame: f32, + color: [f32; 4], + life: f32, + fade: f32, + ) { + if self.capacity == 0 { return; } + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + let nn = if len > 1e-5 { [n[0] / len, n[1] / len, n[2] / len] } else { [0.0, 1.0, 0.0] }; + // Spherical encode. el is the angle off +Y; az is the heading in XZ. + let el = nn[1].clamp(-1.0, 1.0).acos(); + let az = nn[2].atan2(nn[0]); + + let d = Decal { + // Lift off the surface: z-fighting on a coplanar quad is guaranteed + // otherwise, and 2 mm is under the depth precision of anything the + // player can stand close enough to notice. + pos: [ + pos[0] + nn[0] * 0.002, + pos[1] + nn[1] * 0.002, + pos[2] + nn[2] * 0.002, + ], + az, el, roll, + size, + color, + frame, + age: 0.0, + life: if life <= 0.0 { f32::MAX } else { life }, + fade: fade.max(0.0), + }; + + if self.decals.len() < self.capacity { + self.decals.push(d); + } else { + self.decals[self.next] = d; + self.next = (self.next + 1) % self.capacity; + } + } + + /// Age everything, drop the expired, repack. Returns the live count. + pub fn update(&mut self, dt: f32) -> u32 { + let mut i = 0usize; + while i < self.decals.len() { + self.decals[i].age += dt; + if self.decals[i].age >= self.decals[i].life { + self.decals.swap_remove(i); + // The ring cursor indexes into a Vec that just shrank; clamp it + // or the next spawn writes out of bounds. + if self.next >= self.decals.len().max(1) { self.next = 0; } + continue; + } + i += 1; + } + + for (i, d) in self.decals.iter().enumerate() { + // Fade only over the tail of the lifetime. + let alpha = if d.fade > 0.0 && d.life != f32::MAX { + let remaining = d.life - d.age; + (remaining / d.fade).clamp(0.0, 1.0) + } else { 1.0 }; + let o = i * 12; + self.packed[o] = d.pos[0]; + self.packed[o + 1] = d.pos[1]; + self.packed[o + 2] = d.pos[2]; + self.packed[o + 3] = d.roll; + self.packed[o + 4] = d.size; + self.packed[o + 5] = d.color[0]; + self.packed[o + 6] = d.color[1]; + self.packed[o + 7] = d.color[2]; + self.packed[o + 8] = d.color[3] * alpha; + self.packed[o + 9] = d.frame; // extra.x + self.packed[o + 10] = d.az; // extra.y + self.packed[o + 11] = d.el; // extra.z + } + self.live = self.decals.len() as u32; + self.live + } + + pub fn packed(&self) -> &[f32] { &self.packed } + + pub fn clear(&mut self) { + self.decals.clear(); + self.next = 0; + self.live = 0; + } +} + +impl Default for DecalManager { + fn default() -> Self { Self::new() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ring_wraps_without_growing() { + let mut m = DecalManager::new(); + m.init(4, 1); + for i in 0..10 { + m.spawn([i as f32, 0.0, 0.0], [0.0, 1.0, 0.0], 0.2, 0.0, 0.0, [1.0; 4], 0.0, 0.0); + } + assert_eq!(m.update(0.016), 4); + } + + #[test] + fn expired_decals_are_reclaimed() { + let mut m = DecalManager::new(); + m.init(8, 1); + m.spawn([0.0; 3], [0.0, 1.0, 0.0], 0.2, 0.0, 0.0, [1.0; 4], 1.0, 0.2); + assert_eq!(m.update(0.5), 1); + assert_eq!(m.update(0.6), 0); + } + + #[test] + fn permanent_decals_survive() { + let mut m = DecalManager::new(); + m.init(8, 1); + m.spawn([0.0; 3], [0.0, 1.0, 0.0], 0.2, 0.0, 0.0, [1.0; 4], 0.0, 0.0); + for _ in 0..100 { m.update(1.0); } + assert_eq!(m.live, 1); + } + + /// The normal must survive the spherical round-trip, or every decal on a + /// wall silently lies flat on the floor. + #[test] + fn normal_encoding_round_trips() { + let mut m = DecalManager::new(); + m.init(4, 1); + let n = [0.0f32, 0.0, 1.0]; + m.spawn([0.0; 3], n, 1.0, 0.0, 0.0, [1.0; 4], 0.0, 0.0); + m.update(0.0); + let az = m.packed[10]; + let el = m.packed[11]; + let rx = el.sin() * az.cos(); + let ry = el.cos(); + let rz = el.sin() * az.sin(); + assert!((rx - n[0]).abs() < 1e-4, "x {rx}"); + assert!((ry - n[1]).abs() < 1e-4, "y {ry}"); + assert!((rz - n[2]).abs() < 1e-4, "z {rz}"); + } +} diff --git a/native/shared/src/engine.rs b/native/shared/src/engine.rs index c341331..58c3777 100644 --- a/native/shared/src/engine.rs +++ b/native/shared/src/engine.rs @@ -32,6 +32,11 @@ pub struct EngineState { pub profiler: Profiler, pub screenshot_pending: bool, pub drs: DrsController, + /// EN-026 / EN-027 — particle pools and the decal ring. Both are pure CPU + /// state that feeds dynamic instance buffers; they own no GPU resources of + /// their own beyond the buffer handles they were given at creation. + pub particles: crate::particles::ParticleManager, + pub decals: crate::decals::DecalManager, // Timing pub target_fps: f64, @@ -89,6 +94,8 @@ impl EngineState { profiler, screenshot_pending: false, drs: DrsController::new(), + particles: crate::particles::ParticleManager::new(), + decals: crate::decals::DecalManager::new(), target_fps: 60.0, delta_time: 0.0, last_frame_time: now, diff --git a/native/shared/src/ffi_core/mod.rs b/native/shared/src/ffi_core/mod.rs index 4b47c23..5a9b3b3 100644 --- a/native/shared/src/ffi_core/mod.rs +++ b/native/shared/src/ffi_core/mod.rs @@ -66,6 +66,7 @@ mod audio_ffi; mod models; mod scene; mod visual; +mod vfx; /// Expand the full shared (non-physics) FFI surface. Composed from the /// per-subsystem section macros in this directory; platform crates invoke @@ -83,6 +84,7 @@ macro_rules! define_core_ffi { $crate::__bloom_ffi_models!(); $crate::__bloom_ffi_scene!(); $crate::__bloom_ffi_visual!(); + $crate::__bloom_ffi_vfx!(); }; } diff --git a/native/shared/src/ffi_core/models.rs b/native/shared/src/ffi_core/models.rs index da00527..415c411 100644 --- a/native/shared/src/ffi_core/models.rs +++ b/native/shared/src/ffi_core/models.rs @@ -845,5 +845,162 @@ macro_rules! __bloom_ffi_models { $crate::ffi::feature_off_warn_once("bloom_set_material_params", "models3d"); } + // ================================================================== + // EN-028 — animation mixer (crossfade + masked layer + root motion) + // EN-033 — bone sockets + // + // All numeric ABI, <= 8 f64 args per call (the 9th arg garbles on + // Perry/ARM64 — see bloom_update_model_animation's note). + // ================================================================== + + // bloom_anim_play [gated: models3d] + #[cfg(feature = "models3d")] + #[no_mangle] + pub extern "C" fn bloom_anim_play(handle: f64, clip: f64, fade: f64, speed: f64, looping: f64) { + $crate::ffi::guard("bloom_anim_play", move || { + engine().models.anim_play( + handle, clip as usize, fade as f32, speed as f32, looping != 0.0); + }) + } + #[cfg(not(feature = "models3d"))] + #[no_mangle] + pub extern "C" fn bloom_anim_play(_h: f64, _c: f64, _f: f64, _s: f64, _l: f64) { + $crate::ffi::feature_off_warn_once("bloom_anim_play", "models3d"); + } + + // bloom_anim_set_layer [gated: models3d] + #[cfg(feature = "models3d")] + #[no_mangle] + pub extern "C" fn bloom_anim_set_layer(handle: f64, clip: f64, weight: f64, mask_root: f64, speed: f64, looping: f64) { + $crate::ffi::guard("bloom_anim_set_layer", move || { + engine().models.anim_set_layer( + handle, clip as i32, weight as f32, mask_root as i32, + speed as f32, looping != 0.0); + }) + } + #[cfg(not(feature = "models3d"))] + #[no_mangle] + pub extern "C" fn bloom_anim_set_layer(_h: f64, _c: f64, _w: f64, _m: f64, _s: f64, _l: f64) { + $crate::ffi::feature_off_warn_once("bloom_anim_set_layer", "models3d"); + } + + // bloom_anim_set_root_motion [gated: models3d] + #[cfg(feature = "models3d")] + #[no_mangle] + pub extern "C" fn bloom_anim_set_root_motion(handle: f64, on: f64) { + $crate::ffi::guard("bloom_anim_set_root_motion", move || { + engine().models.anim_set_root_motion(handle, on != 0.0); + }) + } + #[cfg(not(feature = "models3d"))] + #[no_mangle] + pub extern "C" fn bloom_anim_set_root_motion(_h: f64, _o: f64) { + $crate::ffi::feature_off_warn_once("bloom_anim_set_root_motion", "models3d"); + } + + // bloom_anim_update [gated: models3d] + // Advances every clock on the model by dt, rebuilds the blended pose, + // and uploads the joint matrices — the mixer-driven replacement for + // bloom_update_model_animation (which stays for single-clip callers). + #[cfg(feature = "models3d")] + #[no_mangle] + pub extern "C" fn bloom_anim_update(handle: f64, dt: f64, scale: f64, px: f64, py: f64, pz: f64, rot_y: f64) { + $crate::ffi::guard("bloom_anim_update", move || { + let rot_y_f = rot_y as f32; + let rot_sin = rot_y_f.sin(); + let rot_cos = rot_y_f.cos(); + let eng = engine(); + eng.models.advance_and_update(handle, dt as f32); + if let Some(anim) = eng.models.get_animation(handle) { + if !anim.joint_matrices.is_empty() { + eng.renderer.set_joint_matrices_scaled( + &anim.joint_matrices, scale as f32, + [px as f32, py as f32, pz as f32], rot_sin, rot_cos); + } + } + }) + } + #[cfg(not(feature = "models3d"))] + #[no_mangle] + pub extern "C" fn bloom_anim_update(_h: f64, _d: f64, _s: f64, _x: f64, _y: f64, _z: f64, _r: f64) { + $crate::ffi::feature_off_warn_once("bloom_anim_update", "models3d"); + } + + // bloom_anim_finished [gated: models3d] + #[cfg(feature = "models3d")] + #[no_mangle] + pub extern "C" fn bloom_anim_finished(handle: f64) -> f64 { + $crate::ffi::guard("bloom_anim_finished", move || { + if engine().models.anim_finished(handle) { 1.0 } else { 0.0 } + }) + } + #[cfg(not(feature = "models3d"))] + #[no_mangle] + pub extern "C" fn bloom_anim_finished(_h: f64) -> f64 { 1.0 } + + // bloom_anim_clip_duration [gated: models3d] + #[cfg(feature = "models3d")] + #[no_mangle] + pub extern "C" fn bloom_anim_clip_duration(handle: f64, clip: f64) -> f64 { + $crate::ffi::guard("bloom_anim_clip_duration", move || { + engine().models.anim_clip_duration(handle, clip as usize) as f64 + }) + } + #[cfg(not(feature = "models3d"))] + #[no_mangle] + pub extern "C" fn bloom_anim_clip_duration(_h: f64, _c: f64) -> f64 { 0.0 } + + // bloom_anim_root_delta [gated: models3d] axis: 0=x 1=y 2=z + #[cfg(feature = "models3d")] + #[no_mangle] + pub extern "C" fn bloom_anim_root_delta(handle: f64, axis: f64) -> f64 { + $crate::ffi::guard("bloom_anim_root_delta", move || { + let d = engine().models.anim_root_delta(handle); + let i = axis as usize; + if i < 3 { d[i] as f64 } else { 0.0 } + }) + } + #[cfg(not(feature = "models3d"))] + #[no_mangle] + pub extern "C" fn bloom_anim_root_delta(_h: f64, _a: f64) -> f64 { 0.0 } + + // bloom_model_find_joint [gated: models3d] + // Load-time only (it parses a string) — cache the index, per + // perry-quirks #5. + #[cfg(feature = "models3d")] + #[no_mangle] + pub extern "C" fn bloom_model_find_joint(handle: f64, name_ptr: *const u8) -> f64 { + $crate::ffi::guard("bloom_model_find_joint", move || { + let name = $crate::string_header::str_from_header(name_ptr); + engine().models.find_joint(handle, &name) as f64 + }) + } + #[cfg(not(feature = "models3d"))] + #[no_mangle] + pub extern "C" fn bloom_model_find_joint(_h: f64, _n: *const u8) -> f64 { -1.0 } + + // bloom_model_joint_world [gated: models3d] + // Model-space 4x4 of a joint, column-major, component 0..15. + // Translation is components 12/13/14. The caller applies the same + // (scale, position, yaw) it passed to bloom_anim_update to lift this + // into world space — the engine deliberately does not, so a socket + // costs no extra state. + #[cfg(feature = "models3d")] + #[no_mangle] + pub extern "C" fn bloom_model_joint_world(handle: f64, joint: f64, comp: f64) -> f64 { + $crate::ffi::guard("bloom_model_joint_world", move || { + let j = joint as i64; + let c = comp as usize; + if j < 0 || c > 15 { return 0.0; } + match engine().models.joint_world(handle, j as usize) { + Some(m) => m[c / 4][c % 4] as f64, + None => 0.0, + } + }) + } + #[cfg(not(feature = "models3d"))] + #[no_mangle] + pub extern "C" fn bloom_model_joint_world(_h: f64, _j: f64, _c: f64) -> f64 { 0.0 } + }; } diff --git a/native/shared/src/ffi_core/vfx.rs b/native/shared/src/ffi_core/vfx.rs new file mode 100644 index 0000000..a2a6437 --- /dev/null +++ b/native/shared/src/ffi_core/vfx.rs @@ -0,0 +1,203 @@ +//! EN-026 particles + EN-027 decals. +//! +//! Section of [`define_core_ffi!`](crate::define_core_ffi) — see +//! `ffi_core/mod.rs` for the architecture and the invoking-crate contract. +//! +//! Both subsystems follow the same shape: the engine simulates and owns a +//! dynamic instance buffer; the game supplies the material + quad mesh and +//! issues one instanced draw per system. The FFI traffic is therefore +//! proportional to *events* (a burst, an impact), never to the number of live +//! particles — which is the whole reason the sim is native (a 2 000-particle +//! pool pushed float-by-float across the FFI would be ~24 000 calls a frame). + +#[doc(hidden)] +#[macro_export] +macro_rules! __bloom_ffi_vfx { + () => { + + // ---- EN-026 particles ------------------------------------------ + + // bloom_particles_create — pool + dynamic instance buffer. + // Returns a 1-based system handle (0 = failure). + #[no_mangle] + pub extern "C" fn bloom_particles_create(capacity: f64) -> f64 { + $crate::ffi::guard("bloom_particles_create", move || { + let cap = (capacity as usize).clamp(1, 100_000); + let eng = engine(); + let ib = eng.renderer.material_system.create_dynamic_instance_buffer( + &eng.renderer.device, cap as u32); + eng.particles.create(cap, ib) as f64 + }) + } + + // bloom_particles_configure — behaviour, pushed through the mesh + // scratch (same idiom as create_instance_buffer_scratch: Perry 0.5.x + // rejects JS arrays in i64 pointer params). Startup-only, so the + // per-float call cost does not matter. See particles.rs for the slot + // order. + #[no_mangle] + pub extern "C" fn bloom_particles_configure(sys: f64) { + $crate::ffi::guard("bloom_particles_configure", move || { + let eng = engine(); + #[cfg(feature = "models3d")] + { + let params: Vec = eng.models.scratch_f32.clone(); + eng.models.mesh_scratch_reset(); + if let Some(s) = eng.particles.get_mut(sys as u32) { + s.configure_from_slice(¶ms); + } + } + #[cfg(not(feature = "models3d"))] + { let _ = sys; } + }) + } + + // bloom_particles_emit — one burst. 8 args (the ARM64 ceiling). + #[no_mangle] + pub extern "C" fn bloom_particles_emit( + sys: f64, x: f64, y: f64, z: f64, + dx: f64, dy: f64, dz: f64, count: f64, + ) { + $crate::ffi::guard("bloom_particles_emit", move || { + if let Some(s) = engine().particles.get_mut(sys as u32) { + s.emit( + [x as f32, y as f32, z as f32], + [dx as f32, dy as f32, dz as f32], + (count as usize).min(4096), + ); + } + }) + } + + // bloom_particles_update — integrate + upload. Returns the live count, + // which is what the caller passes as instanceCount to the draw. + #[no_mangle] + pub extern "C" fn bloom_particles_update(sys: f64, dt: f64) -> f64 { + $crate::ffi::guard("bloom_particles_update", move || { + let eng = engine(); + let (live, ib) = match eng.particles.get_mut(sys as u32) { + Some(s) => (s.update(dt as f32), s.instance_buffer), + None => return 0.0, + }; + if live > 0 { + // Re-borrow: the packed slice and the renderer are disjoint + // fields, but the borrow checker cannot see that through + // two method calls. + let packed: Vec = match eng.particles.get_mut(sys as u32) { + Some(s) => s.packed()[..(live as usize) * 12].to_vec(), + None => return 0.0, + }; + eng.renderer.material_system.update_instance_buffer( + &eng.renderer.queue, ib, &packed, live); + } + live as f64 + }) + } + + // bloom_particles_instance_buffer — the handle to hand to + // drawMeshWithMaterialInstanced. + #[no_mangle] + pub extern "C" fn bloom_particles_instance_buffer(sys: f64) -> f64 { + $crate::ffi::guard("bloom_particles_instance_buffer", move || { + engine().particles.get_mut(sys as u32) + .map(|s| s.instance_buffer as f64) + .unwrap_or(0.0) + }) + } + + #[no_mangle] + pub extern "C" fn bloom_particles_clear(sys: f64) { + $crate::ffi::guard("bloom_particles_clear", move || { + if let Some(s) = engine().particles.get_mut(sys as u32) { s.clear(); } + }) + } + + #[no_mangle] + pub extern "C" fn bloom_particles_live(sys: f64) -> f64 { + $crate::ffi::guard("bloom_particles_live", move || { + engine().particles.get_mut(sys as u32) + .map(|s| s.live as f64) + .unwrap_or(0.0) + }) + } + + // ---- EN-027 decals --------------------------------------------- + + // bloom_decals_init — ring capacity + its dynamic instance buffer. + #[no_mangle] + pub extern "C" fn bloom_decals_init(capacity: f64) -> f64 { + $crate::ffi::guard("bloom_decals_init", move || { + let cap = (capacity as usize).clamp(1, 8192); + let eng = engine(); + let ib = eng.renderer.material_system.create_dynamic_instance_buffer( + &eng.renderer.device, cap as u32); + eng.decals.init(cap, ib); + ib as f64 + }) + } + + // bloom_decals_spawn — position + normal + size + roll. Colour, atlas + // frame and lifetime come from bloom_decals_set_style, which the caller + // sets once per decal *type* rather than paying for them on every hit. + #[no_mangle] + pub extern "C" fn bloom_decals_spawn( + x: f64, y: f64, z: f64, + nx: f64, ny: f64, nz: f64, + size: f64, roll: f64, + ) { + $crate::ffi::guard("bloom_decals_spawn", move || { + engine().decals.spawn_styled( + [x as f32, y as f32, z as f32], + [nx as f32, ny as f32, nz as f32], + size as f32, roll as f32, + ); + }) + } + + // bloom_decals_set_style — frame, rgba, life, fade for subsequent + // spawns. 7 args. + #[no_mangle] + pub extern "C" fn bloom_decals_set_style( + frame: f64, r: f64, g: f64, b: f64, a: f64, life: f64, fade: f64, + ) { + $crate::ffi::guard("bloom_decals_set_style", move || { + engine().decals.style = $crate::decals::DecalStyle { + frame: frame as f32, + color: [r as f32, g as f32, b as f32, a as f32], + life: life as f32, + fade: fade as f32, + }; + }) + } + + #[no_mangle] + pub extern "C" fn bloom_decals_update(dt: f64) -> f64 { + $crate::ffi::guard("bloom_decals_update", move || { + let eng = engine(); + let live = eng.decals.update(dt as f32); + let ib = eng.decals.instance_buffer; + if live > 0 { + let packed: Vec = eng.decals.packed()[..(live as usize) * 12].to_vec(); + eng.renderer.material_system.update_instance_buffer( + &eng.renderer.queue, ib, &packed, live); + } + live as f64 + }) + } + + #[no_mangle] + pub extern "C" fn bloom_decals_instance_buffer() -> f64 { + $crate::ffi::guard("bloom_decals_instance_buffer", move || { + engine().decals.instance_buffer as f64 + }) + } + + #[no_mangle] + pub extern "C" fn bloom_decals_clear() { + $crate::ffi::guard("bloom_decals_clear", move || { + engine().decals.clear(); + }) + } + + }; +} diff --git a/native/shared/src/lib.rs b/native/shared/src/lib.rs index ed66818..636b2c7 100644 --- a/native/shared/src/lib.rs +++ b/native/shared/src/lib.rs @@ -24,6 +24,8 @@ pub mod postfx; pub mod custom_shaders; pub mod staging; pub mod profiler; +pub mod particles; +pub mod decals; pub mod sdf_cache; // Jolt C ABI + Rust wrapper live on native only. On wasm32 the web crate // routes bloom_physics_* calls through wasm_bindgen to JoltPhysics.js; diff --git a/native/shared/src/models.rs b/native/shared/src/models.rs index 652322b..1fcdca3 100644 --- a/native/shared/src/models.rs +++ b/native/shared/src/models.rs @@ -56,6 +56,62 @@ pub struct SkeletonData { pub root_joints: Vec, } +/// EN-028 — per-model animation mixer. +/// +/// Three things a single-clip sampler cannot do, all of which read as +/// "cheap" on screen: transitions pop, an attacking character has to stop +/// walking, and authored locomotion arcs (a pounce, a lunge) get replaced +/// by hand-tuned kinematics because the root is nailed to the rest pose. +/// +/// Layout: a base track that crossfades from `prev` to `cur` over +/// `fade_dur`, plus one optional additive-by-mask layer (an attack driving +/// the spine-up while the legs keep walking). All clocks advance in +/// `advance_animation`, so the game hands over a dt and never tracks clip +/// time itself. +#[derive(Clone)] +pub struct AnimMixer { + pub cur_clip: usize, + pub cur_time: f32, + pub cur_speed: f32, + pub cur_loop: bool, + /// Clip we are fading *out* of. `fade_dur <= 0` means no fade in flight. + pub prev_clip: usize, + pub prev_time: f32, + pub prev_speed: f32, + pub prev_loop: bool, + pub fade_t: f32, + pub fade_dur: f32, + /// Masked layer. `layer_clip < 0` = inactive. + pub layer_clip: i32, + pub layer_time: f32, + pub layer_speed: f32, + pub layer_loop: bool, + pub layer_weight: f32, + /// Root joint of the masked subtree (e.g. the spine). Every joint at or + /// below it takes the layer pose; everything else keeps the base pose. + pub layer_mask_root: i32, + /// Opt-in root motion. Off by default so existing games are unchanged. + pub root_motion: bool, + pub root_delta: [f32; 3], + /// True once a non-looping `cur_clip` has played past its duration. + pub finished: bool, + pub started: bool, +} + +impl Default for AnimMixer { + fn default() -> Self { + Self { + cur_clip: 0, cur_time: 0.0, cur_speed: 1.0, cur_loop: true, + prev_clip: 0, prev_time: 0.0, prev_speed: 1.0, prev_loop: true, + fade_t: 0.0, fade_dur: 0.0, + layer_clip: -1, layer_time: 0.0, layer_speed: 1.0, layer_loop: false, + layer_weight: 0.0, layer_mask_root: -1, + root_motion: false, root_delta: [0.0; 3], + finished: false, started: false, + } + } +} + pub struct ModelAnimation { pub skeleton: Option, pub animations: Vec, @@ -63,6 +119,15 @@ pub struct ModelAnimation { /// Reference rest-pose rotations (from first animation, sampled at t=0). /// Used for retargeting when multiple armatures have different rest orientations. pub ref_rest_rotations: Option>, + /// EN-028 mixer state. + pub mixer: AnimMixer, + /// EN-033 — joint world transforms *before* the inverse-bind multiply. + /// `joint_matrices` is skinning-space and useless for attaching props; + /// this is the model-space transform a socket actually wants. + pub joint_world: Vec<[[f32; 4]; 4]>, + /// Cached per-joint layer weights, rebuilt when `layer_mask_root` changes. + pub mask_weights: Vec, + pub mask_cached_root: i32, } pub struct ModelManager { @@ -486,86 +551,362 @@ impl ModelManager { None => return, }; if anim_index >= model_anim.animations.len() { return; } - + #[cfg(debug_assertions)] let joint_count = skeleton.joints.len(); - if model_anim.joint_matrices.len() != joint_count { - model_anim.joint_matrices = vec![mat4_identity(); joint_count]; - } - - // Initialize from rest-pose transforms (fallback for non-animated joints) - let mut local_translations: Vec<[f32; 3]> = skeleton.joints.iter() - .map(|j| j.rest_translation).collect(); - let mut local_rotations: Vec<[f32; 4]> = skeleton.joints.iter() - .map(|j| j.rest_rotation).collect(); - let mut local_scales: Vec<[f32; 3]> = skeleton.joints.iter() - .map(|j| j.rest_scale).collect(); - let anim = &model_anim.animations[anim_index]; - let t = if anim.duration > 0.0 { time % anim.duration } else { 0.0 }; + let pose = sample_local_pose(skeleton, &model_anim.animations[anim_index], time, true); + model_anim.apply_pose(&pose); #[cfg(debug_assertions)] - let mut channels_applied = 0usize; - for channel in &anim.channels { - let ji = channel.joint_index; - if ji >= joint_count { continue; } - #[cfg(debug_assertions)] - { channels_applied += 1; } - - if !channel.translations.is_empty() && !channel.timestamps.is_empty() { - local_translations[ji] = sample_vec3(&channel.timestamps, &channel.translations, t); - } - if !channel.rotations.is_empty() { - let rot_ts = if !channel.rotation_timestamps.is_empty() { &channel.rotation_timestamps } else { &channel.timestamps }; - if !rot_ts.is_empty() { - local_rotations[ji] = sample_quat(rot_ts, &channel.rotations, t); + { + static mut DEBUG_PRINTED: bool = false; + unsafe { + if !DEBUG_PRINTED && joint_count > 0 { + DEBUG_PRINTED = true; + eprintln!("[anim] joints={}, t={:.3}, anim_index={}", joint_count, time, anim_index); } } - if !channel.scales.is_empty() { - let scale_ts = if !channel.scale_timestamps.is_empty() { &channel.scale_timestamps } else { &channel.timestamps }; - if !scale_ts.is_empty() { - local_scales[ji] = sample_vec3(scale_ts, &channel.scales, t); - } + } + } + } + + // ---- EN-028 mixer ----------------------------------------------------- + + /// Start a transition to `clip`. Re-requesting the clip already playing is + /// a no-op, so game code can call this unconditionally every frame ("I + /// want to be walking") instead of tracking edges itself — which is how + /// this gets used in practice and where the pops came from before. + pub fn anim_play(&mut self, handle: f64, clip: usize, fade: f32, speed: f32, looping: bool) { + if let Some(ma) = self.animations.get_mut(handle) { + if clip >= ma.animations.len() { return; } + let m = &mut ma.mixer; + if m.started && m.cur_clip == clip && m.fade_dur <= 0.0 { + m.cur_speed = speed; + m.cur_loop = looping; + return; + } + if m.started && fade > 0.0 { + m.prev_clip = m.cur_clip; + m.prev_time = m.cur_time; + m.prev_speed = m.cur_speed; + m.prev_loop = m.cur_loop; + m.fade_dur = fade; + m.fade_t = 0.0; + } else { + m.fade_dur = 0.0; + m.fade_t = 0.0; + } + m.cur_clip = clip; + m.cur_time = 0.0; + m.cur_speed = speed; + m.cur_loop = looping; + m.finished = false; + m.started = true; + } + } + + /// Masked layer: `clip` drives every joint at or below `mask_root`, + /// blended in by `weight`. weight <= 0 (or clip < 0) turns it off. + pub fn anim_set_layer(&mut self, handle: f64, clip: i32, weight: f32, mask_root: i32, speed: f32, looping: bool) { + if let Some(ma) = self.animations.get_mut(handle) { + let m = &mut ma.mixer; + let off = clip < 0 || weight <= 0.0 || (clip as usize) >= ma.animations.len(); + if off { + m.layer_clip = -1; + m.layer_weight = 0.0; + return; + } + if m.layer_clip != clip { + m.layer_time = 0.0; + m.layer_clip = clip; + } + m.layer_weight = weight.clamp(0.0, 1.0); + m.layer_mask_root = mask_root; + m.layer_speed = speed; + m.layer_loop = looping; + } + } + + pub fn anim_set_root_motion(&mut self, handle: f64, on: bool) { + if let Some(ma) = self.animations.get_mut(handle) { + ma.mixer.root_motion = on; + ma.mixer.root_delta = [0.0; 3]; + } + } + + pub fn anim_finished(&self, handle: f64) -> bool { + self.animations.get(handle).map(|m| m.mixer.finished).unwrap_or(true) + } + + pub fn anim_clip_duration(&self, handle: f64, clip: usize) -> f32 { + self.animations.get(handle) + .and_then(|m| m.animations.get(clip)) + .map(|a| a.duration) + .unwrap_or(0.0) + } + + pub fn anim_root_delta(&self, handle: f64) -> [f32; 3] { + self.animations.get(handle).map(|m| m.mixer.root_delta).unwrap_or([0.0; 3]) + } + + pub fn find_joint(&self, handle: f64, name: &str) -> i32 { + if let Some(ma) = self.animations.get(handle) { + if let Some(sk) = &ma.skeleton { + for (i, j) in sk.joints.iter().enumerate() { + if j.name == name { return i as i32; } + } + // Fall back to a case-insensitive contains match: exporters + // decorate names ("mixamorig:Hand_R", "Bip01 R Hand") often + // enough that an exact match is the exception, not the rule. + let want = name.to_ascii_lowercase(); + for (i, j) in sk.joints.iter().enumerate() { + if j.name.to_ascii_lowercase().contains(&want) { return i as i32; } } } + } + -1 + } - // Lock root translation to rest pose (strip all root motion) - local_translations[0] = skeleton.joints[0].rest_translation; + /// Model-space transform of a joint (EN-033 sockets). Valid after the + /// frame's `advance_and_update`. + pub fn joint_world(&self, handle: f64, joint: usize) -> Option<[[f32; 4]; 4]> { + self.animations.get(handle) + .and_then(|m| m.joint_world.get(joint)) + .copied() + } - // Build world transforms by walking the hierarchy from roots - let mut world_transforms = vec![mat4_identity(); joint_count]; + /// Advance every mixer clock by `dt` and rebuild the pose. One call per + /// model per frame; the game never touches clip time. + pub fn advance_and_update(&mut self, handle: f64, dt: f32) { + if let Some(ma) = self.animations.get_mut(handle) { + if ma.skeleton.is_none() || ma.animations.is_empty() { return; } + if !ma.mixer.started { ma.mixer.started = true; } + + // --- advance clocks + let cur_dur = ma.animations.get(ma.mixer.cur_clip).map(|a| a.duration).unwrap_or(0.0); + let t_before = ma.mixer.cur_time; + let t_raw = ma.mixer.cur_time + dt * ma.mixer.cur_speed; + let mut wrapped = false; + ma.mixer.cur_time = if cur_dur <= 0.0 { + 0.0 + } else if ma.mixer.cur_loop { + if t_raw >= cur_dur { wrapped = true; } + t_raw.rem_euclid(cur_dur) + } else if t_raw >= cur_dur { + ma.mixer.finished = true; + cur_dur + } else { + t_raw + }; - let root_joints = skeleton.root_joints.clone(); - for &root in &root_joints { - compute_joint_transforms( - skeleton, root, &mat4_identity(), - &local_translations, &local_rotations, &local_scales, - &mut world_transforms, - ); + if ma.mixer.fade_dur > 0.0 { + let prev_dur = ma.animations.get(ma.mixer.prev_clip).map(|a| a.duration).unwrap_or(0.0); + let pt = ma.mixer.prev_time + dt * ma.mixer.prev_speed; + ma.mixer.prev_time = if prev_dur <= 0.0 { + 0.0 + } else if ma.mixer.prev_loop { + pt.rem_euclid(prev_dur) + } else { + pt.min(prev_dur) + }; + ma.mixer.fade_t += dt; + if ma.mixer.fade_t >= ma.mixer.fade_dur { + ma.mixer.fade_dur = 0.0; + ma.mixer.fade_t = 0.0; + } } - // Multiply by inverse bind matrices to get final joint matrices - for i in 0..joint_count { - model_anim.joint_matrices[i] = mat4_mul(&world_transforms[i], &skeleton.joints[i].inverse_bind); + if ma.mixer.layer_clip >= 0 { + let li = ma.mixer.layer_clip as usize; + let ldur = ma.animations.get(li).map(|a| a.duration).unwrap_or(0.0); + let lt = ma.mixer.layer_time + dt * ma.mixer.layer_speed; + ma.mixer.layer_time = if ldur <= 0.0 { + 0.0 + } else if ma.mixer.layer_loop { + lt.rem_euclid(ldur) + } else { + lt.min(ldur) + }; } - #[cfg(debug_assertions)] - { - static mut DEBUG_PRINTED: bool = false; - unsafe { - if !DEBUG_PRINTED { - DEBUG_PRINTED = true; - eprintln!("[anim] channels_applied={}, t={:.3}, anim_index={}", channels_applied, t, anim_index); - eprintln!("[anim] Joint0 local: t=[{:.2},{:.2},{:.2}] r=[{:.4},{:.4},{:.4},{:.4}]", - local_translations[0][0], local_translations[0][1], local_translations[0][2], - local_rotations[0][0], local_rotations[0][1], local_rotations[0][2], local_rotations[0][3]); - let m = &model_anim.joint_matrices[0]; - eprintln!("[anim] Joint0 final diag=[{:.4},{:.4},{:.4}] trans=[{:.4},{:.4},{:.4}]", - m[0][0], m[1][1], m[2][2], m[3][0], m[3][1], m[3][2]); - } + // --- sample + blend the base track + let skel = ma.skeleton.as_ref().unwrap(); + let strip_root = !ma.mixer.root_motion; + let mut pose = sample_local_pose(skel, &ma.animations[ma.mixer.cur_clip], ma.mixer.cur_time, strip_root); + + if ma.mixer.fade_dur > 0.0 { + let w = (ma.mixer.fade_t / ma.mixer.fade_dur).clamp(0.0, 1.0); + // Smoothstep the fade — a linear pose blend still reads as a + // slope discontinuity at both ends of the transition. + let w = w * w * (3.0 - 2.0 * w); + let prev = sample_local_pose(skel, &ma.animations[ma.mixer.prev_clip], ma.mixer.prev_time, strip_root); + blend_pose(&mut pose, &prev, 1.0 - w, None); + } + + // --- root motion: delta of the root joint's authored translation + if ma.mixer.root_motion && !skel.joints.is_empty() { + let cd = cur_dur; + let anim = &ma.animations[ma.mixer.cur_clip]; + let p_now = root_translation_at(skel, anim, ma.mixer.cur_time); + let p_old = root_translation_at(skel, anim, t_before); + let d = if wrapped && cd > 0.0 { + let p_end = root_translation_at(skel, anim, cd); + let p_start = root_translation_at(skel, anim, 0.0); + [ + (p_end[0] - p_old[0]) + (p_now[0] - p_start[0]), + (p_end[1] - p_old[1]) + (p_now[1] - p_start[1]), + (p_end[2] - p_old[2]) + (p_now[2] - p_start[2]), + ] + } else { + [p_now[0] - p_old[0], p_now[1] - p_old[1], p_now[2] - p_old[2]] + }; + ma.mixer.root_delta = d; + // The delta is handed to the character controller, so the pose + // itself must not also carry it or the model double-moves. + pose.0[0] = skel.joints[0].rest_translation; + } else { + ma.mixer.root_delta = [0.0; 3]; + } + + // --- masked layer over the top + if ma.mixer.layer_clip >= 0 && ma.mixer.layer_weight > 0.0 { + let root = ma.mixer.layer_mask_root; + if ma.mask_cached_root != root { + ma.mask_weights = build_mask_weights(skel, root); + ma.mask_cached_root = root; } + let li = ma.mixer.layer_clip as usize; + let lpose = sample_local_pose(skel, &ma.animations[li], ma.mixer.layer_time, true); + let w = ma.mixer.layer_weight; + let mask = ma.mask_weights.clone(); + blend_pose(&mut pose, &lpose, w, Some(&mask)); } + + ma.apply_pose(&pose); + } + } +} + +type LocalPose = (Vec<[f32; 3]>, Vec<[f32; 4]>, Vec<[f32; 3]>); + +impl ModelAnimation { + /// Local TRS pose -> world transforms -> skinning matrices. Keeps the + /// world transforms around too, because that is what sockets read. + fn apply_pose(&mut self, pose: &LocalPose) { + let skeleton = match &self.skeleton { Some(s) => s, None => return }; + let joint_count = skeleton.joints.len(); + if self.joint_matrices.len() != joint_count { + self.joint_matrices = vec![mat4_identity(); joint_count]; + } + if self.joint_world.len() != joint_count { + self.joint_world = vec![mat4_identity(); joint_count]; } + let mut world = vec![mat4_identity(); joint_count]; + for &root in &skeleton.root_joints { + compute_joint_transforms(skeleton, root, &mat4_identity(), &pose.0, &pose.1, &pose.2, &mut world); + } + for i in 0..joint_count { + self.joint_matrices[i] = mat4_mul(&world[i], &skeleton.joints[i].inverse_bind); + } + self.joint_world.copy_from_slice(&world); + } +} + +/// Sample one clip into a local TRS pose, rest pose as the fallback for +/// joints the clip does not animate. +fn sample_local_pose(skeleton: &SkeletonData, anim: &AnimationData, time: f32, strip_root: bool) -> LocalPose { + let joint_count = skeleton.joints.len(); + let mut t: Vec<[f32; 3]> = skeleton.joints.iter().map(|j| j.rest_translation).collect(); + let mut r: Vec<[f32; 4]> = skeleton.joints.iter().map(|j| j.rest_rotation).collect(); + let mut s: Vec<[f32; 3]> = skeleton.joints.iter().map(|j| j.rest_scale).collect(); + + let time = if anim.duration > 0.0 { time.rem_euclid(anim.duration) } else { 0.0 }; + + for channel in &anim.channels { + let ji = channel.joint_index; + if ji >= joint_count { continue; } + if !channel.translations.is_empty() && !channel.timestamps.is_empty() { + t[ji] = sample_vec3(&channel.timestamps, &channel.translations, time); + } + if !channel.rotations.is_empty() { + let ts = if !channel.rotation_timestamps.is_empty() { &channel.rotation_timestamps } else { &channel.timestamps }; + if !ts.is_empty() { r[ji] = sample_quat(ts, &channel.rotations, time); } + } + if !channel.scales.is_empty() { + let ts = if !channel.scale_timestamps.is_empty() { &channel.scale_timestamps } else { &channel.timestamps }; + if !ts.is_empty() { s[ji] = sample_vec3(ts, &channel.scales, time); } + } + } + + if strip_root && joint_count > 0 { + t[0] = skeleton.joints[0].rest_translation; + } + (t, r, s) +} + +/// The root joint's authored translation at `time` — the raw channel value, +/// *not* the rest-locked one, which is the whole point of root motion. +fn root_translation_at(skeleton: &SkeletonData, anim: &AnimationData, time: f32) -> [f32; 3] { + if skeleton.joints.is_empty() { return [0.0; 3]; } + let time = if anim.duration > 0.0 { time.clamp(0.0, anim.duration) } else { 0.0 }; + for channel in &anim.channels { + if channel.joint_index == 0 && !channel.translations.is_empty() && !channel.timestamps.is_empty() { + return sample_vec3(&channel.timestamps, &channel.translations, time); + } + } + skeleton.joints[0].rest_translation +} + +/// `dst = lerp(dst, src, w * mask[j])`. Rotations use nlerp with a +/// hemisphere fix — without the dot-sign flip, two clips whose quaternions +/// land on opposite hemispheres blend the *long* way round and the limb +/// visibly swings through the body. +fn blend_pose(dst: &mut LocalPose, src: &LocalPose, w: f32, mask: Option<&[f32]>) { + let n = dst.0.len().min(src.0.len()); + for j in 0..n { + let jw = match mask { + Some(m) => w * m.get(j).copied().unwrap_or(0.0), + None => w, + }; + if jw <= 0.0 { continue; } + let jw = jw.min(1.0); + for k in 0..3 { + dst.0[j][k] = dst.0[j][k] + (src.0[j][k] - dst.0[j][k]) * jw; + dst.2[j][k] = dst.2[j][k] + (src.2[j][k] - dst.2[j][k]) * jw; + } + let a = dst.1[j]; + let mut b = src.1[j]; + let dot = a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3]; + if dot < 0.0 { b = [-b[0], -b[1], -b[2], -b[3]]; } + let mut q = [ + a[0] + (b[0] - a[0]) * jw, + a[1] + (b[1] - a[1]) * jw, + a[2] + (b[2] - a[2]) * jw, + a[3] + (b[3] - a[3]) * jw, + ]; + let len = (q[0]*q[0] + q[1]*q[1] + q[2]*q[2] + q[3]*q[3]).sqrt(); + if len > 1e-6 { + q = [q[0]/len, q[1]/len, q[2]/len, q[3]/len]; + } else { + q = a; + } + dst.1[j] = q; + } +} + +/// 1.0 for every joint at or below `root`, 0.0 elsewhere. `root < 0` means +/// "whole skeleton" so a layer with no mask is a plain full-body override. +fn build_mask_weights(skeleton: &SkeletonData, root: i32) -> Vec { + let n = skeleton.joints.len(); + if root < 0 || (root as usize) >= n { return vec![1.0; n]; } + let mut w = vec![0.0f32; n]; + let mut stack = vec![root as usize]; + while let Some(j) = stack.pop() { + if j >= n || w[j] > 0.0 { continue; } + w[j] = 1.0; + for &c in &skeleton.joints[j].children { stack.push(c); } } + w } // ============================================================ @@ -1071,6 +1412,10 @@ fn load_gltf_animation(data: &[u8]) -> Option { animations, joint_matrices: vec![mat4_identity(); joint_count], ref_rest_rotations, + mixer: AnimMixer::default(), + joint_world: vec![mat4_identity(); joint_count], + mask_weights: vec![0.0; joint_count], + mask_cached_root: -1, }) } diff --git a/native/shared/src/particles.rs b/native/shared/src/particles.rs new file mode 100644 index 0000000..bd89607 --- /dev/null +++ b/native/shared/src/particles.rs @@ -0,0 +1,391 @@ +//! EN-026 — CPU-simulated, GPU-instanced particle system. +//! +//! Why this lives in the engine rather than in game code: the game can already +//! build an instance buffer and draw it, but only by pushing every float +//! across the FFI one call at a time (`bloom_mesh_scratch_push_f32`). At 2 000 +//! live particles that is ~24 000 FFI calls per frame — the shooter's entire +//! current per-frame FFI budget is ~240. So the *simulation* has to sit on the +//! native side, and the game/engine traffic becomes O(spawn events) instead of +//! O(particles): a burst is one call. +//! +//! What the engine owns: the pool, the integrator, and the packed instance +//! buffer. What the game owns: the material (so the look, the atlas and the +//! blend mode stay authorable in WGSL) and the draw call. A system is created +//! per *look* — smoke, sparks, blood, shells — because each wants its own +//! texture and blend bucket anyway, and one draw per look is cheap. +//! +//! Sim is deliberately simple and closed-form-ish: position, velocity, +//! gravity, linear drag, and a lifetime; size and colour are curves over +//! normalized age evaluated in the shader from `extra.x`. No collision, no +//! sorting (additive needs none, and the cutout bucket is depth-tested). + +/// Everything about how one system's particles are born, move, and look. +/// Uploaded once from TS as a flat float array — see `configure_from_slice`. +#[derive(Clone, Copy)] +pub struct ParticleConfig { + pub life: f32, + pub life_var: f32, + /// Initial speed along the emit direction. + pub speed: f32, + pub speed_var: f32, + /// Half-angle of the emission cone, radians. `PI` = fully spherical. + pub spread: f32, + /// Constant acceleration (usually negative Y). + pub gravity: f32, + /// Linear drag coefficient (per second). 0 = vacuum. + pub drag: f32, + pub size0: f32, + pub size1: f32, + pub size_var: f32, + pub color0: [f32; 4], + pub color1: [f32; 4], + /// Roll speed, radians/sec (billboard spin). + pub spin: f32, + pub spin_var: f32, + /// Spawn positions are jittered inside a sphere of this radius. + pub pos_jitter: f32, + /// > 0 stretches the billboard along its velocity by this many seconds of + /// travel — the difference between a round spark and a tracer streak. + pub stretch: f32, + /// Fraction of the emitter's own velocity the particle inherits. + pub inherit: f32, + /// Number of atlas frames; the shader gets a frame index in `extra.y`. + pub frames: f32, + /// Bounce off the y = `floor_y` plane instead of passing through it. + /// `restitution` <= 0 disables (the default). + pub floor_y: f32, + pub restitution: f32, +} + +impl Default for ParticleConfig { + fn default() -> Self { + Self { + life: 1.0, life_var: 0.0, + speed: 1.0, speed_var: 0.0, + spread: 0.3, + gravity: -9.81, + drag: 0.0, + size0: 0.2, size1: 0.2, size_var: 0.0, + color0: [1.0; 4], color1: [1.0, 1.0, 1.0, 0.0], + spin: 0.0, spin_var: 0.0, + pos_jitter: 0.0, + stretch: 0.0, + inherit: 0.0, + frames: 1.0, + floor_y: 0.0, restitution: 0.0, + } + } +} + +/// Structure-of-arrays pool. Dead particles are swap-removed from the live +/// prefix, so the live set is always `[0, live)` and the instance write is one +/// contiguous memcpy with no compaction pass. +pub struct ParticleSystem { + pub capacity: usize, + pub live: usize, + pub cfg: ParticleConfig, + /// GPU instance buffer handle (dynamic, capacity-sized). + pub instance_buffer: u32, + + px: Vec, py: Vec, pz: Vec, + vx: Vec, vy: Vec, vz: Vec, + age: Vec, life: Vec, + rot: Vec, spin: Vec, + size: Vec, seed: Vec, + + /// Packed 12-float-per-instance staging buffer, reused every frame. + packed: Vec, + rng: u32, +} + +impl ParticleSystem { + pub fn new(capacity: usize, instance_buffer: u32) -> Self { + let z = || vec![0.0f32; capacity]; + Self { + capacity, + live: 0, + cfg: ParticleConfig::default(), + instance_buffer, + px: z(), py: z(), pz: z(), + vx: z(), vy: z(), vz: z(), + age: z(), life: z(), + rot: z(), spin: z(), + size: z(), seed: z(), + packed: vec![0.0; capacity * 12], + rng: 0x9E3779B9, + } + } + + #[inline] + fn rand(&mut self) -> f32 { + // xorshift32 — deterministic per system, which keeps a replayed burst + // identical frame-to-frame for screenshot diffing. + let mut x = self.rng; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + self.rng = x; + (x & 0x00FF_FFFF) as f32 / 16_777_215.0 + } + + /// Symmetric ±1 noise. + #[inline] + fn rand_s(&mut self) -> f32 { self.rand() * 2.0 - 1.0 } + + pub fn configure_from_slice(&mut self, p: &[f32]) { + let g = |i: usize| -> f32 { p.get(i).copied().unwrap_or(0.0) }; + self.cfg = ParticleConfig { + life: g(0).max(0.01), life_var: g(1), + speed: g(2), speed_var: g(3), + spread: g(4), + gravity: g(5), + drag: g(6), + size0: g(7), size1: g(8), size_var: g(9), + color0: [g(10), g(11), g(12), g(13)], + color1: [g(14), g(15), g(16), g(17)], + spin: g(18), spin_var: g(19), + pos_jitter: g(20), + stretch: g(21), + inherit: g(22), + frames: g(23).max(1.0), + floor_y: g(24), + restitution: g(25), + }; + } + + /// Spawn `count` particles at `pos`, biased along `dir`. A zero `dir` + /// means "no preferred direction" and emits into the full sphere, which is + /// what a blood burst or an explosion core wants; a surface normal gives a + /// cone, which is what an impact wants. + pub fn emit(&mut self, pos: [f32; 3], dir: [f32; 3], count: usize) { + let c = self.cfg; + let dlen = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt(); + let base = if dlen > 1e-5 { + [dir[0] / dlen, dir[1] / dlen, dir[2] / dlen] + } else { + [0.0, 1.0, 0.0] + }; + // Basis around the emit direction, for the cone sample. + let up = if base[1].abs() > 0.99 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] }; + let right = normalize(cross(up, base)); + let realup = cross(base, right); + + let spread = if dlen > 1e-5 { c.spread } else { std::f32::consts::PI }; + + for _ in 0..count { + if self.live >= self.capacity { + // Full: overwrite the oldest-looking slot rather than dropping + // the spawn. A burst you asked for should always be visible; + // it is the *stale* particle that is expendable. + let victim = (self.rand() * self.capacity as f32) as usize % self.capacity.max(1); + self.age[victim] = self.life[victim]; + self.kill(victim); + } + let i = self.live; + self.live += 1; + + // Cone sample: cosine-ish, biased toward the axis. + let a = self.rand() * std::f32::consts::TAU; + let t = self.rand().sqrt() * spread; + let (st, ct) = (t.sin(), t.cos()); + let d = [ + base[0] * ct + (right[0] * a.cos() + realup[0] * a.sin()) * st, + base[1] * ct + (right[1] * a.cos() + realup[1] * a.sin()) * st, + base[2] * ct + (right[2] * a.cos() + realup[2] * a.sin()) * st, + ]; + let sp = (c.speed + self.rand_s() * c.speed_var).max(0.0); + + let jx = self.rand_s() * c.pos_jitter; + let jy = self.rand_s() * c.pos_jitter; + let jz = self.rand_s() * c.pos_jitter; + + self.px[i] = pos[0] + jx; + self.py[i] = pos[1] + jy; + self.pz[i] = pos[2] + jz; + self.vx[i] = d[0] * sp; + self.vy[i] = d[1] * sp; + self.vz[i] = d[2] * sp; + self.age[i] = 0.0; + self.life[i] = (c.life + self.rand_s() * c.life_var).max(0.02); + self.rot[i] = self.rand() * std::f32::consts::TAU; + self.spin[i] = c.spin + self.rand_s() * c.spin_var; + self.size[i] = 1.0 + self.rand_s() * c.size_var; + self.seed[i] = self.rand(); + } + } + + #[inline] + fn kill(&mut self, i: usize) { + let last = self.live - 1; + if i != last { + self.px.swap(i, last); self.py.swap(i, last); self.pz.swap(i, last); + self.vx.swap(i, last); self.vy.swap(i, last); self.vz.swap(i, last); + self.age.swap(i, last); self.life.swap(i, last); + self.rot.swap(i, last); self.spin.swap(i, last); + self.size.swap(i, last); self.seed.swap(i, last); + } + self.live = last; + } + + /// Integrate one step and repack. Returns the live count to draw. + pub fn update(&mut self, dt: f32) -> u32 { + let c = self.cfg; + let dt = dt.clamp(0.0, 0.1); // a hitch must not teleport the sim + + let mut i = 0usize; + while i < self.live { + self.age[i] += dt; + if self.age[i] >= self.life[i] { + self.kill(i); + continue; // a live particle was swapped into i — re-test it + } + // Semi-implicit Euler + exponential drag. + self.vy[i] += c.gravity * dt; + if c.drag > 0.0 { + let k = (1.0 - c.drag * dt).max(0.0); + self.vx[i] *= k; self.vy[i] *= k; self.vz[i] *= k; + } + self.px[i] += self.vx[i] * dt; + self.py[i] += self.vy[i] * dt; + self.pz[i] += self.vz[i] * dt; + + if c.restitution > 0.0 && self.py[i] < c.floor_y { + self.py[i] = c.floor_y; + self.vy[i] = -self.vy[i] * c.restitution; + // Kill the horizontal skid too, or shells slide forever. + self.vx[i] *= 0.6; + self.vz[i] *= 0.6; + if self.vy[i].abs() < 0.4 { self.vy[i] = 0.0; } + } + + self.rot[i] += self.spin[i] * dt; + i += 1; + } + + // Pack. Layout must match InstanceData3D: pos.xyz, rot_y, scale, + // tint.rgba, extra.xyz. + for i in 0..self.live { + let t = (self.age[i] / self.life[i]).clamp(0.0, 1.0); + let size = lerp(c.size0, c.size1, t) * self.size[i]; + let col = [ + lerp(c.color0[0], c.color1[0], t), + lerp(c.color0[1], c.color1[1], t), + lerp(c.color0[2], c.color1[2], t), + lerp(c.color0[3], c.color1[3], t), + ]; + let frame = if c.frames > 1.0 { + (t * c.frames).floor().min(c.frames - 1.0) + } else { 0.0 }; + // Velocity-stretch length in metres — the shader elongates the + // quad along the projected velocity by this much. + let stretch = if c.stretch > 0.0 { + let v = (self.vx[i] * self.vx[i] + self.vy[i] * self.vy[i] + self.vz[i] * self.vz[i]).sqrt(); + v * c.stretch + } else { 0.0 }; + + let o = i * 12; + self.packed[o] = self.px[i]; + self.packed[o + 1] = self.py[i]; + self.packed[o + 2] = self.pz[i]; + self.packed[o + 3] = self.rot[i]; + self.packed[o + 4] = size; + self.packed[o + 5] = col[0]; + self.packed[o + 6] = col[1]; + self.packed[o + 7] = col[2]; + self.packed[o + 8] = col[3]; + self.packed[o + 9] = t; // extra.x — normalized age + self.packed[o + 10] = frame; // extra.y — atlas frame + self.packed[o + 11] = stretch; // extra.z — stretch metres + } + self.live as u32 + } + + pub fn packed(&self) -> &[f32] { &self.packed } + + pub fn clear(&mut self) { self.live = 0; } +} + +#[inline] fn lerp(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t } +#[inline] fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0]] +} +#[inline] fn normalize(v: [f32; 3]) -> [f32; 3] { + let l = (v[0]*v[0] + v[1]*v[1] + v[2]*v[2]).sqrt(); + if l > 1e-6 { [v[0]/l, v[1]/l, v[2]/l] } else { [1.0, 0.0, 0.0] } +} + +/// Registry of all systems. Handles are 1-based; 0 is "no system". +pub struct ParticleManager { + pub systems: Vec>, +} + +impl ParticleManager { + pub fn new() -> Self { Self { systems: Vec::new() } } + + pub fn create(&mut self, capacity: usize, instance_buffer: u32) -> u32 { + self.systems.push(Some(ParticleSystem::new(capacity, instance_buffer))); + self.systems.len() as u32 + } + + pub fn get_mut(&mut self, handle: u32) -> Option<&mut ParticleSystem> { + if handle == 0 { return None; } + self.systems.get_mut(handle as usize - 1)?.as_mut() + } +} + +impl Default for ParticleManager { + fn default() -> Self { Self::new() } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sys() -> ParticleSystem { ParticleSystem::new(64, 1) } + + #[test] + fn emit_then_expire() { + let mut s = sys(); + s.configure_from_slice(&[0.5, 0.0, 1.0, 0.0, 0.3, 0.0, 0.0, 1.0, 1.0, 0.0]); + s.emit([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 10); + assert_eq!(s.live, 10); + assert_eq!(s.update(0.1), 10); + // Past the 0.5 s lifetime everything must be reclaimed — the swap-remove + // loop has to re-test the swapped-in particle or it leaks half the pool. + for _ in 0..10 { s.update(0.1); } + assert_eq!(s.live, 0); + } + + #[test] + fn capacity_is_never_exceeded() { + let mut s = sys(); + s.configure_from_slice(&[10.0]); + s.emit([0.0; 3], [0.0, 1.0, 0.0], 500); + assert_eq!(s.live, 64); + assert_eq!(s.update(0.016), 64); + } + + #[test] + fn gravity_pulls_down() { + let mut s = sys(); + // life 10, speed 0, gravity -10 + s.configure_from_slice(&[10.0, 0.0, 0.0, 0.0, 0.0, -10.0]); + s.emit([0.0, 5.0, 0.0], [0.0, 1.0, 0.0], 1); + s.update(0.1); + assert!(s.py[0] < 5.0, "expected fall, got y={}", s.py[0]); + } + + #[test] + fn floor_bounce_reverses_velocity() { + let mut s = sys(); + let mut p = vec![0.0f32; 26]; + p[0] = 10.0; // life + p[5] = -10.0; // gravity + p[24] = 0.0; // floor_y + p[25] = 0.5; // restitution + s.configure_from_slice(&p); + s.emit([0.0, 0.1, 0.0], [0.0, -1.0, 0.0], 1); + for _ in 0..20 { s.update(0.016); } + assert!(s.py[0] >= 0.0, "particle sank through the floor: y={}", s.py[0]); + } +} diff --git a/native/shared/src/renderer/material_instancing.rs b/native/shared/src/renderer/material_instancing.rs index 6a7bd7c..132686f 100644 --- a/native/shared/src/renderer/material_instancing.rs +++ b/native/shared/src/renderer/material_instancing.rs @@ -110,6 +110,54 @@ impl MaterialSystem { self.instance_buffers.len() as u32 } + /// EN-026 — a *dynamic* instance buffer: fixed capacity, rewritten every + /// frame, never tiled. + /// + /// The static path above reorders instances into XZ tiles so the + /// dispatcher can frustum-cull them, which is right for a 20k-blade grass + /// field that never moves and wrong for particles, which move every frame + /// and would have to be re-tiled (a sort) each time. Here the caller + /// simply writes the live prefix of the buffer and draws that many + /// instances. + pub fn create_dynamic_instance_buffer( + &mut self, + device: &wgpu::Device, + capacity: u32, + ) -> u32 { + let size = (capacity.max(1) as u64) * 48; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("dynamic_instance_buffer"), + size, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.instance_buffers.push(Some(InstanceBuffer { + buffer, + count: capacity, + tiles: Vec::new(), + })); + self.instance_buffers.len() as u32 + } + + /// EN-026 — overwrite the first `count` instances of a dynamic buffer. + /// `packed` is already at the 12-float GPU stride (pos.xyz, rot_y, scale, + /// tint.rgba, extra.xyz), so this is a straight memcpy into GPU memory + /// with no per-instance work on the CPU. + pub fn update_instance_buffer( + &mut self, + queue: &wgpu::Queue, + handle: u32, + packed: &[f32], + count: u32, + ) { + if handle == 0 || count == 0 { return; } + let idx = handle as usize - 1; + let Some(Some(ib)) = self.instance_buffers.get(idx) else { return }; + let n = (count as usize).min(packed.len() / 12); + if n == 0 { return; } + queue.write_buffer(&ib.buffer, 0, bytemuck::cast_slice(&packed[..n * 12])); + } + /// EN-001 — drop an instance buffer slot. The slot is left as /// `None` so previously-issued handles never alias a future /// allocation. No-op for `handle == 0` or out-of-range handles. diff --git a/native/shared/src/renderer/types.rs b/native/shared/src/renderer/types.rs index 2070a21..2e75e91 100644 --- a/native/shared/src/renderer/types.rs +++ b/native/shared/src/renderer/types.rs @@ -237,7 +237,14 @@ pub struct InstanceData3D { pub rot_y: f32, // Y-axis rotation in radians pub scale: f32, // uniform scale multiplier (1.0 = no scale) pub tint: [f32; 4], // RGBA tint multiplier (1,1,1,1 = no tint) - pub _pad: [f32; 3], // pad to 16-byte alignment (vec4 boundary) + /// EN-026 — was pure padding to the 16-byte boundary; now carried to the + /// shader as `@location(11) instance_extra: vec3`. The three floats + /// were already being uploaded, so exposing them costs nothing: no stride + /// change, no extra bandwidth. Particles use them for (atlas frame, + /// velocity-stretch length, random seed); anything else can leave them 0 + /// and simply not declare location 11 — a vertex buffer may carry + /// attributes the shader does not consume. + pub extra: [f32; 3], } impl InstanceData3D { @@ -250,6 +257,7 @@ impl InstanceData3D { wgpu::VertexAttribute { offset: 12, shader_location: 8, format: wgpu::VertexFormat::Float32 }, // rot_y wgpu::VertexAttribute { offset: 16, shader_location: 9, format: wgpu::VertexFormat::Float32 }, // scale wgpu::VertexAttribute { offset: 20, shader_location: 10, format: wgpu::VertexFormat::Float32x4 }, // tint + wgpu::VertexAttribute { offset: 36, shader_location: 11, format: wgpu::VertexFormat::Float32x3 }, // extra (EN-026) ], } } diff --git a/native/watchos/src/ffi_stubs.rs b/native/watchos/src/ffi_stubs.rs index 6c235c5..8ebfee1 100644 --- a/native/watchos/src/ffi_stubs.rs +++ b/native/watchos/src/ffi_stubs.rs @@ -768,3 +768,62 @@ #[no_mangle] pub extern "C" fn bloom_physics_vehicle_get_wheel_angular_velocity(_p0: f64, _p1: f64) -> f64 { 0.0 } + +// EN-028 / EN-033 / EN-026 / EN-027 — watchOS is a stub platform (no 3D +// renderer), so these keep the symbol surface complete without behaviour. +#[no_mangle] pub extern "C" fn bloom_anim_play(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64) { +} +#[no_mangle] pub extern "C" fn bloom_anim_set_layer(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64) { +} +#[no_mangle] pub extern "C" fn bloom_anim_set_root_motion(_p0: f64, _p1: f64) { +} +#[no_mangle] pub extern "C" fn bloom_anim_update(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64) { +} +#[no_mangle] pub extern "C" fn bloom_anim_finished(_p0: f64) -> f64 { + 1.0 +} +#[no_mangle] pub extern "C" fn bloom_anim_clip_duration(_p0: f64, _p1: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_anim_root_delta(_p0: f64, _p1: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_model_find_joint(_p0: f64, _p1: i64) -> f64 { + -1.0 +} +#[no_mangle] pub extern "C" fn bloom_model_joint_world(_p0: f64, _p1: f64, _p2: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_particles_create(_p0: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_particles_configure(_p0: f64) { +} +#[no_mangle] pub extern "C" fn bloom_particles_emit(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64, _p7: f64) { +} +#[no_mangle] pub extern "C" fn bloom_particles_update(_p0: f64, _p1: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_particles_instance_buffer(_p0: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_particles_clear(_p0: f64) { +} +#[no_mangle] pub extern "C" fn bloom_particles_live(_p0: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_decals_init(_p0: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_decals_spawn(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64, _p7: f64) { +} +#[no_mangle] pub extern "C" fn bloom_decals_set_style(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64) { +} +#[no_mangle] pub extern "C" fn bloom_decals_update(_p0: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_decals_instance_buffer() -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_decals_clear() { +} diff --git a/native/web/src/material_ffi.rs b/native/web/src/material_ffi.rs index ce737c6..a049036 100644 --- a/native/web/src/material_ffi.rs +++ b/native/web/src/material_ffi.rs @@ -454,3 +454,182 @@ pub fn bloom_get_model_material_count(handle: f64) -> f64 { None => 0.0, } } + +// ============================================================ +// EN-028 animation mixer / EN-033 sockets / EN-026 particles / +// EN-027 decals — web ports. +// +// The mixer and both VFX systems are pure CPU state in bloom-shared, so the +// web crate gets the real behaviour, not a stub: same ModelManager, same +// ParticleManager, same DecalManager. The only web-specific care is that the +// particle/decal *upload* path goes through the same material-system dynamic +// instance buffer, which WebGPU supports. +// ============================================================ + +#[wasm_bindgen] +pub fn bloom_anim_play(handle: f64, clip: f64, fade: f64, speed: f64, looping: f64) { + engine().models.anim_play(handle, clip as usize, fade as f32, speed as f32, looping != 0.0); +} + +#[wasm_bindgen] +pub fn bloom_anim_set_layer(handle: f64, clip: f64, weight: f64, mask_root: f64, speed: f64, looping: f64) { + engine().models.anim_set_layer(handle, clip as i32, weight as f32, mask_root as i32, speed as f32, looping != 0.0); +} + +#[wasm_bindgen] +pub fn bloom_anim_set_root_motion(handle: f64, on: f64) { + engine().models.anim_set_root_motion(handle, on != 0.0); +} + +#[wasm_bindgen] +pub fn bloom_anim_update(handle: f64, dt: f64, scale: f64, px: f64, py: f64, pz: f64, rot_y: f64) { + let rot_y_f = rot_y as f32; + let eng = engine(); + eng.models.advance_and_update(handle, dt as f32); + if let Some(anim) = eng.models.get_animation(handle) { + if !anim.joint_matrices.is_empty() { + eng.renderer.set_joint_matrices_scaled( + &anim.joint_matrices, scale as f32, + [px as f32, py as f32, pz as f32], rot_y_f.sin(), rot_y_f.cos()); + } + } +} + +#[wasm_bindgen] +pub fn bloom_anim_finished(handle: f64) -> f64 { + if engine().models.anim_finished(handle) { 1.0 } else { 0.0 } +} + +#[wasm_bindgen] +pub fn bloom_anim_clip_duration(handle: f64, clip: f64) -> f64 { + engine().models.anim_clip_duration(handle, clip as usize) as f64 +} + +#[wasm_bindgen] +pub fn bloom_anim_root_delta(handle: f64, axis: f64) -> f64 { + let d = engine().models.anim_root_delta(handle); + let i = axis as usize; + if i < 3 { d[i] as f64 } else { 0.0 } +} + +#[wasm_bindgen] +pub fn bloom_model_find_joint(handle: f64, name: String) -> f64 { + engine().models.find_joint(handle, &name) as f64 +} + +#[wasm_bindgen] +pub fn bloom_model_joint_world(handle: f64, joint: f64, comp: f64) -> f64 { + let j = joint as i64; + let c = comp as usize; + if j < 0 || c > 15 { return 0.0; } + match engine().models.joint_world(handle, j as usize) { + Some(m) => m[c / 4][c % 4] as f64, + None => 0.0, + } +} + +#[wasm_bindgen] +pub fn bloom_particles_create(capacity: f64) -> f64 { + let cap = (capacity as usize).clamp(1, 100_000); + let eng = engine(); + let ib = eng.renderer.material_system.create_dynamic_instance_buffer(&eng.renderer.device, cap as u32); + eng.particles.create(cap, ib) as f64 +} + +#[wasm_bindgen] +pub fn bloom_particles_configure(sys: f64) { + let eng = engine(); + let params: Vec = eng.models.scratch_f32.clone(); + eng.models.mesh_scratch_reset(); + if let Some(s) = eng.particles.get_mut(sys as u32) { + s.configure_from_slice(¶ms); + } +} + +#[wasm_bindgen] +pub fn bloom_particles_emit(sys: f64, x: f64, y: f64, z: f64, dx: f64, dy: f64, dz: f64, count: f64) { + if let Some(s) = engine().particles.get_mut(sys as u32) { + s.emit([x as f32, y as f32, z as f32], [dx as f32, dy as f32, dz as f32], (count as usize).min(4096)); + } +} + +#[wasm_bindgen] +pub fn bloom_particles_update(sys: f64, dt: f64) -> f64 { + let eng = engine(); + let (live, ib) = match eng.particles.get_mut(sys as u32) { + Some(s) => (s.update(dt as f32), s.instance_buffer), + None => return 0.0, + }; + if live > 0 { + let packed: Vec = match eng.particles.get_mut(sys as u32) { + Some(s) => s.packed()[..(live as usize) * 12].to_vec(), + None => return 0.0, + }; + eng.renderer.material_system.update_instance_buffer(&eng.renderer.queue, ib, &packed, live); + } + live as f64 +} + +#[wasm_bindgen] +pub fn bloom_particles_instance_buffer(sys: f64) -> f64 { + engine().particles.get_mut(sys as u32).map(|s| s.instance_buffer as f64).unwrap_or(0.0) +} + +#[wasm_bindgen] +pub fn bloom_particles_clear(sys: f64) { + if let Some(s) = engine().particles.get_mut(sys as u32) { s.clear(); } +} + +#[wasm_bindgen] +pub fn bloom_particles_live(sys: f64) -> f64 { + engine().particles.get_mut(sys as u32).map(|s| s.live as f64).unwrap_or(0.0) +} + +#[wasm_bindgen] +pub fn bloom_decals_init(capacity: f64) -> f64 { + let cap = (capacity as usize).clamp(1, 8192); + let eng = engine(); + let ib = eng.renderer.material_system.create_dynamic_instance_buffer(&eng.renderer.device, cap as u32); + eng.decals.init(cap, ib); + ib as f64 +} + +#[wasm_bindgen] +pub fn bloom_decals_spawn(x: f64, y: f64, z: f64, nx: f64, ny: f64, nz: f64, size: f64, roll: f64) { + engine().decals.spawn_styled( + [x as f32, y as f32, z as f32], + [nx as f32, ny as f32, nz as f32], + size as f32, roll as f32); +} + +#[wasm_bindgen] +pub fn bloom_decals_set_style(frame: f64, r: f64, g: f64, b: f64, a: f64, life: f64, fade: f64) { + engine().decals.style = bloom_shared::decals::DecalStyle { + frame: frame as f32, + color: [r as f32, g as f32, b as f32, a as f32], + life: life as f32, + fade: fade as f32, + }; +} + +#[wasm_bindgen] +pub fn bloom_decals_update(dt: f64) -> f64 { + let eng = engine(); + let live = eng.decals.update(dt as f32); + let ib = eng.decals.instance_buffer; + if live > 0 { + let packed: Vec = eng.decals.packed()[..(live as usize) * 12].to_vec(); + eng.renderer.material_system.update_instance_buffer(&eng.renderer.queue, ib, &packed, live); + } + live as f64 +} + +#[wasm_bindgen] +pub fn bloom_decals_instance_buffer() -> f64 { + engine().decals.instance_buffer as f64 +} + +#[wasm_bindgen] +pub fn bloom_decals_clear() { + engine().decals.clear(); +} diff --git a/package.json b/package.json index 98644cb..ac5eac7 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "./mobile": "./src/mobile/index.ts", "./scene": "./src/scene/index.ts", "./physics": "./src/physics/index.ts", - "./world": "./src/world/index.ts" + "./world": "./src/world/index.ts", + "./vfx": "./src/vfx/index.ts" }, "files": [ "src/", @@ -1161,6 +1162,116 @@ ], "returns": "void" }, + { + "name": "bloom_anim_play", + "params": ["f64", "f64", "f64", "f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_anim_set_layer", + "params": ["f64", "f64", "f64", "f64", "f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_anim_set_root_motion", + "params": ["f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_anim_update", + "params": ["f64", "f64", "f64", "f64", "f64", "f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_anim_finished", + "params": ["f64"], + "returns": "f64" + }, + { + "name": "bloom_anim_clip_duration", + "params": ["f64", "f64"], + "returns": "f64" + }, + { + "name": "bloom_anim_root_delta", + "params": ["f64", "f64"], + "returns": "f64" + }, + { + "name": "bloom_model_find_joint", + "params": ["f64", "string"], + "returns": "f64" + }, + { + "name": "bloom_model_joint_world", + "params": ["f64", "f64", "f64"], + "returns": "f64" + }, + { + "name": "bloom_particles_create", + "params": ["f64"], + "returns": "f64" + }, + { + "name": "bloom_particles_configure", + "params": ["f64"], + "returns": "void" + }, + { + "name": "bloom_particles_emit", + "params": ["f64", "f64", "f64", "f64", "f64", "f64", "f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_particles_update", + "params": ["f64", "f64"], + "returns": "f64" + }, + { + "name": "bloom_particles_instance_buffer", + "params": ["f64"], + "returns": "f64" + }, + { + "name": "bloom_particles_clear", + "params": ["f64"], + "returns": "void" + }, + { + "name": "bloom_particles_live", + "params": ["f64"], + "returns": "f64" + }, + { + "name": "bloom_decals_init", + "params": ["f64"], + "returns": "f64" + }, + { + "name": "bloom_decals_spawn", + "params": ["f64", "f64", "f64", "f64", "f64", "f64", "f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_decals_set_style", + "params": ["f64", "f64", "f64", "f64", "f64", "f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_decals_update", + "params": ["f64"], + "returns": "f64" + }, + { + "name": "bloom_decals_instance_buffer", + "params": [], + "returns": "f64" + }, + { + "name": "bloom_decals_clear", + "params": [], + "returns": "void" + }, { "name": "bloom_create_mesh", "params": [ diff --git a/src/models/index.ts b/src/models/index.ts index c67dd53..949f82d 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -685,6 +685,85 @@ export function updateModelAnimation(handle: number, animIndex: number, time: nu bloom_update_model_animation(handle, animIndex, time, scale, px, py, pz, rotY); } +// ---- EN-028: animation mixer ----------------------------------------------- +// The single-clip `updateModelAnimation` above stays for callers that drive +// their own clip clock. The mixer below owns the clock instead, which is what +// makes crossfades possible at all: a fade needs the *outgoing* clip to keep +// advancing, and a caller that only passes one time value cannot express that. +// +// Typical use, per model per frame: +// animPlay(h, moving ? CLIP_WALK : CLIP_IDLE, 0.15); // idempotent +// animSetLayer(h, attacking ? CLIP_ATTACK : -1, 1, spineJoint); +// animUpdate(h, dt, scale, x, y, z, yaw); + +declare function bloom_anim_play(handle: number, clip: number, fade: number, speed: number, looping: number): void; +declare function bloom_anim_set_layer(handle: number, clip: number, weight: number, maskRoot: number, speed: number, looping: number): void; +declare function bloom_anim_set_root_motion(handle: number, on: number): void; +declare function bloom_anim_update(handle: number, dt: number, scale: number, px: number, py: number, pz: number, rotY: number): void; +declare function bloom_anim_finished(handle: number): number; +declare function bloom_anim_clip_duration(handle: number, clip: number): number; +declare function bloom_anim_root_delta(handle: number, axis: number): number; +declare function bloom_model_find_joint(handle: number, name: number): number; +declare function bloom_model_joint_world(handle: number, joint: number, comp: number): number; + +/// Transition the base track to `clip` over `fade` seconds. Safe to call every +/// frame with the clip you *want* — re-requesting the clip already playing is +/// a no-op, so callers don't have to track edges. +export function animPlay(handle: number, clip: number, fade: number = 0.15, speed: number = 1.0, looping: boolean = true): void { + bloom_anim_play(handle, clip, fade, speed, looping ? 1 : 0); +} + +/// Drive the subtree below `maskRoot` (a joint index — see `findJoint`) from a +/// second clip at `weight`. Pass clip = -1 to switch the layer off. This is how +/// a character attacks while still walking. +export function animSetLayer(handle: number, clip: number, weight: number, maskRoot: number, speed: number = 1.0, looping: boolean = false): void { + bloom_anim_set_layer(handle, clip, weight, maskRoot, speed, looping ? 1 : 0); +} + +/// Opt in to authored root motion. Off by default: with it on, the pose stops +/// carrying the root translation and you must feed `animRootDelta` to your +/// character controller, or the model animates in place. +export function animSetRootMotion(handle: number, on: boolean): void { + bloom_anim_set_root_motion(handle, on ? 1 : 0); +} + +/// Advance all clocks on this model and upload the blended pose. One call per +/// model per frame, in place of `updateModelAnimation`. +export function animUpdate(handle: number, dt: number, scale: number, px: number, py: number, pz: number, rotY: number): void { + bloom_anim_update(handle, dt, scale, px, py, pz, rotY); +} + +/// True once a non-looping clip has run past its end — the death/attack +/// one-shot query. +export function animFinished(handle: number): boolean { + return bloom_anim_finished(handle) !== 0; +} + +export function animClipDuration(handle: number, clip: number): number { + return bloom_anim_clip_duration(handle, clip); +} + +/// Root-motion translation applied by the last `animUpdate`, in model space. +export function animRootDelta(handle: number, axis: number): number { + return bloom_anim_root_delta(handle, axis); +} + +// ---- EN-033: bone sockets --------------------------------------------------- + +/// Joint index by name (exact, else case-insensitive substring). Call once at +/// load and cache — it parses a string, which must never happen per-frame +/// (perry-quirks #5). Returns -1 if not found. +export function findJoint(handle: number, name: string): number { + return bloom_model_find_joint(handle, name as any); +} + +/// One component of a joint's model-space 4x4 (column-major, 0..15). +/// Translation is 12/13/14. Model-space, not world: apply the same scale / +/// position / yaw you passed to `animUpdate` to place it in the world. +export function jointWorld(handle: number, joint: number, comp: number): number { + return bloom_model_joint_world(handle, joint, comp); +} + // Upload a mesh via the scratch buffer (array-free). Perry 0.5.1171 rejects // passing a `number[]` to a native `i64` pointer param (strict safe-integer // check), so we push each vertex float + index scalar through the all-f64 diff --git a/src/vfx/index.ts b/src/vfx/index.ts new file mode 100644 index 0000000..d2f3993 --- /dev/null +++ b/src/vfx/index.ts @@ -0,0 +1,191 @@ +// EN-026 particles + EN-027 decals. +// +// Both are engine-simulated and game-drawn. You supply the material (so the +// look stays authorable in WGSL) and the quad mesh; the engine owns the pool +// and rewrites a dynamic instance buffer every frame. Per-frame FFI traffic is +// therefore one `update` + one `draw` per system, regardless of how many +// thousand particles are live. +// +// The instanced vertex shader receives, per instance: +// +// @location(7) instance_pos: vec3 world position +// @location(8) instance_rot_y: f32 roll (particles) / roll about +// the surface normal (decals) +// @location(9) instance_scale: f32 size in metres +// @location(10) instance_tint: vec4 rgba, alpha already faded +// @location(11) instance_extra: vec3 particles: (age01, frame, stretch) +// decals: (frame, azimuth, elevation) +// +// For decals the normal is packed as two angles, so reconstruct it with: +// let n = vec3(sin(el)*cos(az), cos(el), sin(el)*sin(az)); + +declare function bloom_particles_create(capacity: number): number; +declare function bloom_particles_configure(sys: number): void; +declare function bloom_particles_emit(sys: number, x: number, y: number, z: number, dx: number, dy: number, dz: number, count: number): void; +declare function bloom_particles_update(sys: number, dt: number): number; +declare function bloom_particles_instance_buffer(sys: number): number; +declare function bloom_particles_clear(sys: number): void; +declare function bloom_particles_live(sys: number): number; + +declare function bloom_decals_init(capacity: number): number; +declare function bloom_decals_spawn(x: number, y: number, z: number, nx: number, ny: number, nz: number, size: number, roll: number): void; +declare function bloom_decals_set_style(frame: number, r: number, g: number, b: number, a: number, life: number, fade: number): void; +declare function bloom_decals_update(dt: number): number; +declare function bloom_decals_instance_buffer(): number; +declare function bloom_decals_clear(): void; + +declare function bloom_mesh_scratch_reset(): void; +declare function bloom_mesh_scratch_push_f32(v: number): void; + +/// How one system's particles are born, move and look. Every field has a +/// sane default; pass only what you care about. +export interface ParticleConfig { + /// Seconds. `lifeVar` jitters it symmetrically. + life?: number; + lifeVar?: number; + /// Initial speed along the emit direction, m/s. + speed?: number; + speedVar?: number; + /// Half-angle of the emission cone, radians. Emitting with a zero direction + /// ignores this and sprays into the full sphere. + spread?: number; + /// Constant acceleration, m/s². Default is real gravity. + gravity?: number; + /// Linear drag per second. Smoke wants ~2; a spark wants ~0. + drag?: number; + /// Size in metres at birth and at death — smoke grows, sparks shrink. + size0?: number; + size1?: number; + sizeVar?: number; + /// Colour at birth and at death. Put the fade-out in `color1`'s alpha. + color0?: [number, number, number, number]; + color1?: [number, number, number, number]; + /// Billboard roll, rad/s. + spin?: number; + spinVar?: number; + /// Spawn jitter radius, metres. + posJitter?: number; + /// > 0 stretches the quad along velocity by this many seconds of travel. + /// This is the difference between a round spark and a tracer streak. + stretch?: number; + /// Fraction of emitter velocity inherited (reserved; the emitter is + /// stateless today). + inherit?: number; + /// Atlas frames. The shader gets a frame index in `extra.y`. + frames?: number; + /// Bounce plane. `restitution` <= 0 (the default) means no collision; + /// shells want ~0.35 so they clatter instead of sinking. + floorY?: number; + restitution?: number; +} + +/// Allocate a pool. One system per *look* (smoke, sparks, blood) — each wants +/// its own material and blend bucket anyway, and one draw per look is cheap. +export function createParticleSystem(capacity: number, cfg: ParticleConfig): number { + const sys = bloom_particles_create(capacity); + if (sys > 0) configureParticleSystem(sys, cfg); + return sys; +} + +/// Re-tune a system at runtime. Config crosses via the mesh scratch (Perry +/// rejects JS arrays in pointer params); it is a startup-shaped call, not a +/// per-frame one. +export function configureParticleSystem(sys: number, cfg: ParticleConfig): void { + const c0 = cfg.color0 !== undefined ? cfg.color0 : [1, 1, 1, 1]; + const c1 = cfg.color1 !== undefined ? cfg.color1 : [1, 1, 1, 0]; + const p: number[] = new Array(26); + p[0] = cfg.life !== undefined ? cfg.life : 1.0; + p[1] = cfg.lifeVar !== undefined ? cfg.lifeVar : 0.0; + p[2] = cfg.speed !== undefined ? cfg.speed : 1.0; + p[3] = cfg.speedVar !== undefined ? cfg.speedVar : 0.0; + p[4] = cfg.spread !== undefined ? cfg.spread : 0.3; + p[5] = cfg.gravity !== undefined ? cfg.gravity : -9.81; + p[6] = cfg.drag !== undefined ? cfg.drag : 0.0; + p[7] = cfg.size0 !== undefined ? cfg.size0 : 0.2; + p[8] = cfg.size1 !== undefined ? cfg.size1 : 0.2; + p[9] = cfg.sizeVar !== undefined ? cfg.sizeVar : 0.0; + p[10] = c0[0]; p[11] = c0[1]; p[12] = c0[2]; p[13] = c0[3]; + p[14] = c1[0]; p[15] = c1[1]; p[16] = c1[2]; p[17] = c1[3]; + p[18] = cfg.spin !== undefined ? cfg.spin : 0.0; + p[19] = cfg.spinVar !== undefined ? cfg.spinVar : 0.0; + p[20] = cfg.posJitter !== undefined ? cfg.posJitter : 0.0; + p[21] = cfg.stretch !== undefined ? cfg.stretch : 0.0; + p[22] = cfg.inherit !== undefined ? cfg.inherit : 0.0; + p[23] = cfg.frames !== undefined ? cfg.frames : 1.0; + p[24] = cfg.floorY !== undefined ? cfg.floorY : 0.0; + p[25] = cfg.restitution !== undefined ? cfg.restitution : 0.0; + + bloom_mesh_scratch_reset(); + for (let i = 0; i < 26; i++) bloom_mesh_scratch_push_f32(p[i]); + bloom_particles_configure(sys); +} + +/// Spawn a burst. `dir` biases the cone — pass a surface normal for an impact, +/// or (0,0,0) for an omnidirectional pop. +export function emitParticles( + sys: number, + x: number, y: number, z: number, + dx: number, dy: number, dz: number, + count: number, +): void { + bloom_particles_emit(sys, x, y, z, dx, dy, dz, count); +} + +/// Integrate + upload. Returns the live count — pass it straight to +/// `drawMeshWithMaterialInstanced` as the instance count. +export function updateParticles(sys: number, dt: number): number { + return bloom_particles_update(sys, dt); +} + +export function particleInstanceBuffer(sys: number): number { + return bloom_particles_instance_buffer(sys); +} + +export function clearParticles(sys: number): void { + bloom_particles_clear(sys); +} + +export function particleCount(sys: number): number { + return bloom_particles_live(sys); +} + +// ---- Decals ----------------------------------------------------------------- + +/// Allocate the decal ring. One ring for the whole game: decals are all the +/// same draw (an oriented quad against an atlas), so they share a buffer and +/// the style selects which atlas cell a given spawn uses. +export function initDecals(capacity: number): number { + return bloom_decals_init(capacity); +} + +/// Look + lifetime of subsequent `spawnDecal` calls. `life <= 0` = permanent +/// (until the ring wraps); `fade` is how many trailing seconds it fades over. +export function setDecalStyle( + frame: number, + r: number, g: number, b: number, a: number, + life: number, fade: number, +): void { + bloom_decals_set_style(frame, r, g, b, a, life, fade); +} + +/// Stick a decal to a surface. `n` is the surface normal (from your raycast); +/// `roll` spins it about that normal so repeated hits don't look stamped. +export function spawnDecal( + x: number, y: number, z: number, + nx: number, ny: number, nz: number, + size: number, roll: number, +): void { + bloom_decals_spawn(x, y, z, nx, ny, nz, size, roll); +} + +export function updateDecals(dt: number): number { + return bloom_decals_update(dt); +} + +export function decalInstanceBuffer(): number { + return bloom_decals_instance_buffer(); +} + +export function clearDecals(): void { + bloom_decals_clear(); +} From 04d6bd476f660202aad6f48595dfdc36e851cd23 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 10:24:13 +0200 Subject: [PATCH 02/17] feat(EN-029/031): audio buses + reverb + occlusion low-pass; gamepad rumble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EN-029 — the mixer was master gain and per-voice gain. That plays sounds; it does not make a space sound like a place. Adds the three moves a shooter actually needs: - a fixed 3-bus graph (SFX / music / UI) with a duck envelope, because "drop the music when the player is hit" is the most-used mix move in the genre and it needs music separable from SFX; - a Schroeder reverb (4 combs -> 2 allpasses per channel) on a per-sound send, which is what gives a weapon its tail and what makes a fight near a wall sound indoors; - a per-voice one-pole low-pass, which is the occlusion primitive: the game raycasts, the mixer muffles. Muffling reads as geometry in a way that simply lowering the volume never does. Routing is a property of the sound, not of each play call (a footstep is always SFX, a menu blip is always UI), so no call site changes. wet=0 bypasses the reverb path entirely — it costs nothing until a game asks for it. No allocation on the audio thread: the send accumulator is preallocated and the delay lines are fixed. EN-031 — Windows XInput polling turned out to be fully wired already, with a canonical button map; the ticket's suspicion that it was never fed by real hardware was wrong (corrected in the ticket). What was genuinely missing is rumble, so: shared (low, high, seconds) state that the FFI writes and each platform's poll drives, with the countdown and change-detection on the Windows side so we only hit XInputSetState when the commanded level moves. 7 new FFI symbols; validate-ffi clean. 6 new audio tests (bus isolation, duck, low-pass kills a Nyquist tone, reverb tail, wet=0 bypass); 113/113 pass. --- native/shared/src/audio/mod.rs | 167 ++++++++++++- native/shared/src/audio/render.rs | 313 ++++++++++++++++++++++-- native/shared/src/ffi_core/audio_ffi.rs | 57 +++++ native/shared/src/ffi_core/input.rs | 18 ++ native/shared/src/input.rs | 7 + native/watchos/src/ffi_stubs.rs | 18 ++ native/web/src/input_ffi.rs | 18 +- native/web/src/material_ffi.rs | 33 +++ native/windows/src/lib.rs | 30 +++ package.json | 35 +++ src/audio/index.ts | 63 +++++ 11 files changed, 737 insertions(+), 22 deletions(-) diff --git a/native/shared/src/audio/mod.rs b/native/shared/src/audio/mod.rs index 1f36d32..ea73fdb 100644 --- a/native/shared/src/audio/mod.rs +++ b/native/shared/src/audio/mod.rs @@ -80,6 +80,9 @@ pub struct AudioMixer { /// Default volume per sound handle, applied to future plays. sound_volumes: Vec<(f64, f32)>, master_volume: f32, + /// EN-029 — per-sound routing: (bus, reverb send, low-pass cutoff Hz). + /// A property of the sound, not of each play call. + routes: std::collections::HashMap, tx: spsc::Producer, /// Present until the platform takes it for its audio thread; used for /// inline mixing on single-threaded targets (web). @@ -105,6 +108,7 @@ impl AudioMixer { music: HandleRegistry::new(), sound_volumes: Vec::new(), master_volume: 1.0, + routes: std::collections::HashMap::new(), tx, renderer: Some(AudioRenderer::new(rx)), } @@ -140,20 +144,75 @@ impl AudioMixer { pub fn play_sound(&mut self, handle: f64) { let Some(data) = self.sounds.get(handle).cloned() else { return }; let volume = self.get_sound_volume(handle); - self.send(Cmd::PlaySound { sound_id: handle.to_bits(), data, volume, spatial: None }); + let (bus, send, lowpass) = self.routing(handle); + self.send(Cmd::PlaySound { + sound_id: handle.to_bits(), data, volume, spatial: None, + bus, send, lowpass, + }); } pub fn play_sound_3d(&mut self, handle: f64, x: f32, y: f32, z: f32) { let Some(data) = self.sounds.get(handle).cloned() else { return }; let volume = self.get_sound_volume(handle); + let (bus, send, lowpass) = self.routing(handle); self.send(Cmd::PlaySound { sound_id: handle.to_bits(), data, volume, spatial: Some([x, y, z]), + bus, send, lowpass, }); } + // ---- EN-029 routing ------------------------------------------------ + // + // Routing is a property of the *sound*, not of the individual play call: + // a footstep is always on the SFX bus, a menu blip is always UI. Setting + // it once at load keeps the per-shot call sites unchanged. + + fn routing(&self, handle: f64) -> (u8, f32, f32) { + match self.routes.get(&handle.to_bits()) { + Some(r) => *r, + None => (render::bus::SFX, 0.0, 0.0), + } + } + + /// Assign a sound to a mix bus (see `render::bus`). + pub fn set_sound_bus(&mut self, handle: f64, bus: u8) { + let e = self.routes.entry(handle.to_bits()).or_insert((render::bus::SFX, 0.0, 0.0)); + e.0 = bus; + } + + /// Reverb send for this sound, 0..1. This is what gives a gunshot its tail. + pub fn set_sound_reverb_send(&mut self, handle: f64, send: f32) { + let send = send.clamp(0.0, 1.0); + let e = self.routes.entry(handle.to_bits()).or_insert((render::bus::SFX, 0.0, 0.0)); + e.1 = send; + // Also steer voices already in flight, so a zone change is audible on + // the tail that is sounding right now rather than only the next one. + self.send(Cmd::SetSoundSend { sound_id: handle.to_bits(), send }); + } + + /// Low-pass cutoff in Hz for this sound; 0 = bypass. The occlusion knob. + pub fn set_sound_lowpass(&mut self, handle: f64, cutoff: f32) { + let cutoff = cutoff.max(0.0); + let e = self.routes.entry(handle.to_bits()).or_insert((render::bus::SFX, 0.0, 0.0)); + e.2 = cutoff; + self.send(Cmd::SetSoundLowpass { sound_id: handle.to_bits(), cutoff }); + } + + pub fn set_bus_gain(&mut self, bus: u8, gain: f32) { + self.send(Cmd::SetBusGain { bus, gain }); + } + + pub fn duck_bus(&mut self, bus: u8, amount: f32, attack: f32, release: f32, hold: f32) { + self.send(Cmd::DuckBus { bus, amount, attack, release, hold }); + } + + pub fn set_reverb(&mut self, size: f32, damp: f32, wet: f32) { + self.send(Cmd::SetReverbParams { size, damp, wet }); + } + pub fn stop_sound(&mut self, handle: f64) { self.send(Cmd::StopSound { sound_id: handle.to_bits() }); } @@ -348,6 +407,112 @@ mod tests { assert!(out.iter().any(|&s| s != 0.0), "voice produced no output"); } + // ---- EN-029 ------------------------------------------------------- + + fn peak(buf: &[f32]) -> f32 { + buf.iter().fold(0.0f32, |m, s| m.max(s.abs())) + } + + #[test] + fn bus_gain_scales_only_its_own_bus() { + let mut a = AudioMixer::new(); + let h = a.load_sound(tone(4096)); + a.play_sound(h); + let mut loud = [0.0f32; 256]; + a.mix_output(&mut loud); + + let mut b = AudioMixer::new(); + let h2 = b.load_sound(tone(4096)); + b.set_bus_gain(render::bus::SFX, 0.25); + b.play_sound(h2); + let mut quiet = [0.0f32; 256]; + b.mix_output(&mut quiet); + + assert!(peak(&quiet) < peak(&loud) * 0.5, + "SFX bus gain did not attenuate: {} vs {}", peak(&quiet), peak(&loud)); + + // A sound on a *different* bus must be untouched by that gain. + let mut c = AudioMixer::new(); + let h3 = c.load_sound(tone(4096)); + c.set_sound_bus(h3, render::bus::UI); + c.set_bus_gain(render::bus::SFX, 0.0); + c.play_sound(h3); + let mut ui = [0.0f32; 256]; + c.mix_output(&mut ui); + assert!(peak(&ui) > 0.1, "muting SFX also muted the UI bus"); + } + + #[test] + fn duck_pulls_the_bus_down_then_recovers() { + let mut a = AudioMixer::new(); + let h = a.load_sound(tone(1 << 16)); + a.play_sound(h); + let mut out = [0.0f32; 512]; + a.mix_output(&mut out); + let dry = peak(&out); + + // Duck hard, effectively instantly, and hold well past the block. + a.duck_bus(render::bus::SFX, 0.9, 0.0001, 0.5, 1.0); + let mut ducked = [0.0f32; 512]; + a.mix_output(&mut ducked); + assert!(peak(&ducked) < dry * 0.5, + "duck had no effect: {} vs {}", peak(&ducked), dry); + } + + #[test] + fn lowpass_attenuates_a_nyquist_tone() { + // tone() alternates +0.5/-0.5 every sample: that is exactly Nyquist, + // the highest frequency representable. A low cutoff must crush it. + let mut a = AudioMixer::new(); + let h = a.load_sound(tone(4096)); + a.set_sound_lowpass(h, 200.0); + a.play_sound(h); + let mut out = [0.0f32; 512]; + a.mix_output(&mut out); + assert!(peak(&out) < 0.1, + "low-pass did not attenuate a Nyquist tone: peak {}", peak(&out)); + } + + /// Mix `blocks` × 256-sample blocks, returning the peak seen *after* the + /// first block. The shortest comb delay is 1116 samples (~25 ms), so a + /// reverb tail cannot appear inside one short block — you have to run the + /// mixer past the delay length before asking whether it rang. + fn peak_after_first_block(a: &mut AudioMixer, blocks: usize) -> f32 { + let mut first = [0.0f32; 256]; + a.mix_output(&mut first); + let mut p = 0.0f32; + for _ in 1..blocks { + let mut out = [0.0f32; 256]; + a.mix_output(&mut out); + p = p.max(peak(&out)); + } + p + } + + #[test] + fn reverb_rings_after_the_source_stops() { + let mut a = AudioMixer::new(); + // A short click, fully sent to a long, bright reverb. + let h = a.load_sound(tone(8)); + a.set_reverb(0.9, 0.1, 1.0); + a.set_sound_reverb_send(h, 1.0); + a.play_sound(h); + // 40 blocks × 128 frames = 5120 frames, comfortably past the 1356-sample + // longest comb. + let tail = peak_after_first_block(&mut a, 40); + assert!(tail > 0.0, "reverb produced no tail after the source ended"); + } + + #[test] + fn reverb_is_bypassed_when_wet_is_zero() { + let mut a = AudioMixer::new(); + let h = a.load_sound(tone(8)); + a.set_sound_reverb_send(h, 1.0); // sending, but nothing returns + a.play_sound(h); + let tail = peak_after_first_block(&mut a, 40); + assert_eq!(tail, 0.0, "wet=0 must cost nothing and return nothing"); + } + #[test] fn music_playing_flag_round_trip() { let mut a = AudioMixer::new(); diff --git a/native/shared/src/audio/render.rs b/native/shared/src/audio/render.rs index af5f5c1..8000115 100644 --- a/native/shared/src/audio/render.rs +++ b/native/shared/src/audio/render.rs @@ -27,6 +27,11 @@ pub enum Cmd { volume: f32, /// Some(world position) for spatial sounds. spatial: Option<[f32; 3]>, + /// EN-029 — mix bus (see [`Bus`]), reverb send (0..1) and low-pass + /// cutoff in Hz (<= 0 or >= NYQUIST = bypass). + bus: u8, + send: f32, + lowpass: f32, }, StopSound { sound_id: u64 }, SetSoundVolume { sound_id: u64, volume: f32 }, @@ -41,6 +46,77 @@ pub enum Cmd { SetMusicVolume { music_id: u64, volume: f32 }, SetMaster(f32), SetListener { pos: [f32; 3], forward: [f32; 3] }, + + // ---- EN-029 ------------------------------------------------------- + SetBusGain { bus: u8, gain: f32 }, + /// Momentary sidechain-style duck: pull `bus` down by `amount` (linear, + /// 0..1) over `attack` seconds, hold, then release over `release`. + DuckBus { bus: u8, amount: f32, attack: f32, release: f32, hold: f32 }, + SetReverbParams { size: f32, damp: f32, wet: f32 }, + /// Per-playing-voice sends — this is the occlusion primitive. The game + /// raycasts and decides; the mixer just filters. + SetSoundLowpass { sound_id: u64, cutoff: f32 }, + SetSoundSend { sound_id: u64, send: f32 }, +} + +/// Mix buses. Kept tiny and fixed: a general submix graph is a lot of +/// machinery for a game that needs exactly "duck the music when I'm hit" and +/// "don't let UI beeps ride the reverb". +pub mod bus { + pub const SFX: u8 = 0; + pub const MUSIC: u8 = 1; + pub const UI: u8 = 2; + pub const COUNT: usize = 3; +} + +/// A duck envelope per bus. `gain` is the static level the game set; `duck` is +/// the momentary attenuation on top of it, and it is the one that moves. +#[derive(Clone, Copy)] +struct BusState { + gain: f32, + /// Current attenuation, 0 = untouched, 1 = fully ducked. + duck: f32, + /// Where `duck` is heading while the hold lasts. + duck_target: f32, + attack: f32, + release: f32, + hold_left: f32, +} + +impl Default for BusState { + fn default() -> Self { + Self { gain: 1.0, duck: 0.0, duck_target: 0.0, attack: 0.01, release: 0.3, hold_left: 0.0 } + } +} + +impl BusState { + fn set_duck(&mut self, amount: f32, attack: f32, release: f32, hold: f32) { + self.duck_target = amount.clamp(0.0, 1.0); + self.attack = attack.max(0.0); + self.release = release.max(0.0); + self.hold_left = hold.max(0.0); + } + + /// Advance the duck envelope by one mix block. + fn advance(&mut self, dt: f32) { + let target = if self.hold_left > 0.0 { + self.hold_left -= dt; + self.duck_target + } else { + 0.0 + }; + let rate = if target > self.duck { self.attack } else { self.release }; + if rate <= 0.0 { + self.duck = target; + } else { + // One-pole toward the target — exponential, so a long block can + // never overshoot the destination and ring. + let k = (dt / rate).clamp(0.0, 1.0); + self.duck += (target - self.duck) * k; + } + } + + fn current(&self) -> f32 { (self.gain * (1.0 - self.duck)).clamp(0.0, 4.0) } } struct Voice { @@ -49,6 +125,12 @@ struct Voice { position: usize, volume: f32, spatial: Option<[f32; 3]>, + bus: u8, + send: f32, + /// Low-pass cutoff, Hz. <= 0 = bypass. + lowpass: f32, + /// One-pole filter memory, per output channel. + lp_z: [f32; 2], } /// How a music voice gets its samples. @@ -82,6 +164,78 @@ struct MusicVoice { consumed: usize, } +/// EN-029 — a Schroeder reverb: parallel comb filters (the density) into +/// series allpasses (the diffusion). Freeverb's topology, trimmed to 4+2 per +/// channel because this runs on the audio thread of a game, not a DAW. +/// +/// Delay lengths are the classic 44.1 kHz tunings. At 48 kHz the room comes +/// out ~9% smaller, which is inaudible for a gunshot tail and not worth +/// resampling the delay lines for. +struct Reverb { + combs: [Vec; 4], + comb_idx: [usize; 4], + comb_z: [f32; 4], + allpass: [Vec; 2], + ap_idx: [usize; 2], + /// 0..1 — feedback in the combs. Bigger = longer tail. + size: f32, + /// 0..1 — high-frequency absorption in the tail. + damp: f32, + /// 0..1 — how much of the wet signal reaches the output. + wet: f32, +} + +impl Reverb { + const COMB_LEN: [usize; 4] = [1116, 1188, 1277, 1356]; + const AP_LEN: [usize; 2] = [556, 441]; + + fn new() -> Self { + Self { + combs: [ + vec![0.0; Self::COMB_LEN[0]], + vec![0.0; Self::COMB_LEN[1]], + vec![0.0; Self::COMB_LEN[2]], + vec![0.0; Self::COMB_LEN[3]], + ], + comb_idx: [0; 4], + comb_z: [0.0; 4], + allpass: [vec![0.0; Self::AP_LEN[0]], vec![0.0; Self::AP_LEN[1]]], + ap_idx: [0; 2], + size: 0.7, + damp: 0.5, + wet: 0.0, + } + } + + /// One mono sample in, one wet sample out. + fn process(&mut self, input: f32) -> f32 { + let feedback = 0.7 + self.size * 0.28; // 0.70..0.98 + let damp = self.damp.clamp(0.0, 1.0); + + let mut out = 0.0; + for c in 0..4 { + let i = self.comb_idx[c]; + let delayed = self.combs[c][i]; + out += delayed; + // Lowpass in the feedback path = the damping. + self.comb_z[c] = delayed * (1.0 - damp) + self.comb_z[c] * damp; + self.combs[c][i] = input + self.comb_z[c] * feedback; + self.comb_idx[c] = (i + 1) % Self::COMB_LEN[c]; + } + out *= 0.25; + + for a in 0..2 { + let i = self.ap_idx[a]; + let delayed = self.allpass[a][i]; + let v = out - delayed * 0.5; + self.allpass[a][i] = v; + out = delayed + v * 0.5; + self.ap_idx[a] = (i + 1) % Self::AP_LEN[a]; + } + out + } +} + pub struct AudioRenderer { rx: Consumer, voices: Vec, @@ -89,6 +243,15 @@ pub struct AudioRenderer { master: f32, listener_pos: [f32; 3], listener_forward: [f32; 3], + + // EN-029 + buses: [BusState; bus::COUNT], + reverb_l: Reverb, + reverb_r: Reverb, + /// Reverb input accumulator, one slot per output sample. Preallocated so + /// the audio thread never allocates. + send_buf: Vec, + sample_rate: f32, } impl AudioRenderer { @@ -100,13 +263,25 @@ impl AudioRenderer { master: 1.0, listener_pos: [0.0; 3], listener_forward: [0.0, 0.0, -1.0], + buses: [BusState::default(); bus::COUNT], + reverb_l: Reverb::new(), + reverb_r: Reverb::new(), + send_buf: vec![0.0; 8192], + sample_rate: 44_100.0, } } + pub fn set_sample_rate(&mut self, sr: f32) { + if sr > 1000.0 { self.sample_rate = sr; } + } + fn apply(&mut self, cmd: Cmd) { match cmd { - Cmd::PlaySound { sound_id, data, volume, spatial } => { - self.voices.push(Voice { sound_id, data, position: 0, volume, spatial }); + Cmd::PlaySound { sound_id, data, volume, spatial, bus, send, lowpass } => { + self.voices.push(Voice { + sound_id, data, position: 0, volume, spatial, + bus, send, lowpass, lp_z: [0.0; 2], + }); } Cmd::StopSound { sound_id } => { self.voices.retain(|v| v.sound_id != sound_id); @@ -155,6 +330,33 @@ impl AudioRenderer { self.listener_pos = pos; self.listener_forward = forward; } + Cmd::SetBusGain { bus, gain } => { + if (bus as usize) < bus::COUNT { + self.buses[bus as usize].gain = gain.max(0.0); + } + } + Cmd::DuckBus { bus, amount, attack, release, hold } => { + if (bus as usize) < bus::COUNT { + self.buses[bus as usize].set_duck(amount, attack, release, hold); + } + } + Cmd::SetReverbParams { size, damp, wet } => { + for r in [&mut self.reverb_l, &mut self.reverb_r] { + r.size = size.clamp(0.0, 1.0); + r.damp = damp.clamp(0.0, 1.0); + r.wet = wet.clamp(0.0, 1.0); + } + } + Cmd::SetSoundLowpass { sound_id, cutoff } => { + for v in &mut self.voices { + if v.sound_id == sound_id { v.lowpass = cutoff; } + } + } + Cmd::SetSoundSend { sound_id, send } => { + for v in &mut self.voices { + if v.sound_id == sound_id { v.send = send.clamp(0.0, 1.0); } + } + } } } @@ -171,6 +373,28 @@ impl AudioRenderer { *sample = 0.0; } + // EN-029 — advance the per-bus duck envelopes once per block. Block + // granularity is ~1-10 ms, far finer than any duck the ear resolves. + let block_dt = (output.len() as f32 / 2.0) / self.sample_rate; + for b in self.buses.iter_mut() { + b.advance(block_dt); + } + let bus_gain = [ + self.buses[bus::SFX as usize].current(), + self.buses[bus::MUSIC as usize].current(), + self.buses[bus::UI as usize].current(), + ]; + + // Reverb send accumulator. Grows once if a host ever hands us a bigger + // block than we sized for; steady-state it never allocates. + if self.send_buf.len() < output.len() { + self.send_buf.resize(output.len(), 0.0); + } + let reverb_active = self.reverb_l.wet > 0.0; + if reverb_active { + for s in self.send_buf[..output.len()].iter_mut() { *s = 0.0; } + } + // Spatial audio: listener-relative parameters, computed once. let [lx, ly, lz] = self.listener_pos; let [lfx, _lfy, lfz] = self.listener_forward; // "right" math projects out Y @@ -179,9 +403,15 @@ impl AudioRenderer { let lrz = -lfx; let lr_len = (lrx * lrx + lrz * lrz).sqrt().max(0.001); let master = self.master; + let sample_rate = self.sample_rate; + + // Split the borrow: the voice loop needs `voices` and `send_buf` + // mutably at once, and the compiler can only see they're disjoint if + // we name them separately. + let Self { voices, send_buf, reverb_l, reverb_r, music, .. } = self; // Sound effects - self.voices.retain_mut(|v| { + voices.retain_mut(|v| { let sound = &v.data; let (gain_l, gain_r) = if let Some([sx, sy, sz]) = v.spatial { @@ -198,34 +428,77 @@ impl AudioRenderer { (1.0, 1.0) }; - let vol_l = v.volume * master * gain_l; - let vol_r = v.volume * master * gain_r; + let bg = bus_gain[(v.bus as usize).min(bus::COUNT - 1)]; + let vol_l = v.volume * master * bg * gain_l; + let vol_r = v.volume * master * bg * gain_r; + + // One-pole low-pass coefficient. This is the occlusion knob: a + // muffled source is a source behind a wall, and it reads far more + // like geometry than simply turning the volume down does. + let lp_a = if v.lowpass > 0.0 && v.lowpass < sample_rate * 0.5 { + let x = (-2.0 * std::f32::consts::PI * v.lowpass / sample_rate).exp(); + Some(x) + } else { + None + }; + let send = v.send; + let mut i = 0; while i < output.len() && v.position < sound.samples.len() { - if sound.channels == 1 { - let sample = sound.samples[v.position]; - output[i] += sample * vol_l; - if i + 1 < output.len() { - output[i + 1] += sample * vol_r; - } + let (mut sl, mut sr) = if sound.channels == 1 { + let s = sound.samples[v.position]; v.position += 1; - i += 2; + (s, s) } else { - output[i] += sound.samples[v.position] * vol_l; + let l = sound.samples[v.position]; v.position += 1; - if i + 1 < output.len() && v.position < sound.samples.len() { - output[i + 1] += sound.samples[v.position] * vol_r; + let r = if v.position < sound.samples.len() { + let r = sound.samples[v.position]; v.position += 1; - } - i += 2; + r + } else { l }; + (l, r) + }; + + if let Some(a) = lp_a { + v.lp_z[0] = sl * (1.0 - a) + v.lp_z[0] * a; + v.lp_z[1] = sr * (1.0 - a) + v.lp_z[1] * a; + sl = v.lp_z[0]; + sr = v.lp_z[1]; + } + + let ol = sl * vol_l; + let or = sr * vol_r; + output[i] += ol; + if i + 1 < output.len() { output[i + 1] += or; } + + if reverb_active && send > 0.0 { + send_buf[i] += ol * send; + if i + 1 < output.len() { send_buf[i + 1] += or * send; } } + i += 2; } v.position < sound.samples.len() }); - // Music - self.music.retain_mut(|m| { - let vol = m.volume * master; + // Wet return. Processed after the dry voices so every send this block + // contributed is in the tail. + if reverb_active { + let wet = reverb_l.wet; + let mut i = 0; + while i + 1 < output.len() { + output[i] += reverb_l.process(send_buf[i]) * wet; + output[i + 1] += reverb_r.process(send_buf[i + 1]) * wet; + i += 2; + } + } + + // Music — on its own bus, which is the whole point: "duck the music + // when the player takes a hit" is the single most-used mix move in the + // genre and it needs music to be separable from SFX. + let music_gain = bus_gain[bus::MUSIC as usize]; + music.retain_mut(|m| { + let vol = m.volume * master * music_gain; let mut i = 0; let mut finished = false; match &mut m.samples { diff --git a/native/shared/src/ffi_core/audio_ffi.rs b/native/shared/src/ffi_core/audio_ffi.rs index a0d4d19..b25508e 100644 --- a/native/shared/src/ffi_core/audio_ffi.rs +++ b/native/shared/src/ffi_core/audio_ffi.rs @@ -58,5 +58,62 @@ macro_rules! __bloom_ffi_audio_ffi { }) } + // ---- EN-029: buses, reverb send, occlusion low-pass ------------- + // + // The mixer was master + per-voice gain and nothing else. These three + // additions are what separate "sounds are playing" from "the space + // sounds like a place": a bus you can duck, a tail you can send to, and + // a filter that makes a wall audible. + + // bloom_set_sound_bus — 0 = SFX, 1 = music, 2 = UI. + #[no_mangle] + pub extern "C" fn bloom_set_sound_bus(handle: f64, bus: f64) { + $crate::ffi::guard("bloom_set_sound_bus", move || { + engine().audio.set_sound_bus(handle, bus as u8); + }) + } + + // bloom_set_sound_reverb_send — 0..1. + #[no_mangle] + pub extern "C" fn bloom_set_sound_reverb_send(handle: f64, send: f64) { + $crate::ffi::guard("bloom_set_sound_reverb_send", move || { + engine().audio.set_sound_reverb_send(handle, send as f32); + }) + } + + // bloom_set_sound_lowpass — cutoff Hz; 0 = bypass. + #[no_mangle] + pub extern "C" fn bloom_set_sound_lowpass(handle: f64, cutoff: f64) { + $crate::ffi::guard("bloom_set_sound_lowpass", move || { + engine().audio.set_sound_lowpass(handle, cutoff as f32); + }) + } + + // bloom_set_bus_gain + #[no_mangle] + pub extern "C" fn bloom_set_bus_gain(bus: f64, gain: f64) { + $crate::ffi::guard("bloom_set_bus_gain", move || { + engine().audio.set_bus_gain(bus as u8, gain as f32); + }) + } + + // bloom_duck_bus — momentary attenuation with attack/hold/release. + #[no_mangle] + pub extern "C" fn bloom_duck_bus(bus: f64, amount: f64, attack: f64, release: f64, hold: f64) { + $crate::ffi::guard("bloom_duck_bus", move || { + engine().audio.duck_bus( + bus as u8, amount as f32, attack as f32, release as f32, hold as f32); + }) + } + + // bloom_set_reverb — size / damp / wet, all 0..1. wet = 0 bypasses the + // whole reverb path, so it costs nothing until a game asks for it. + #[no_mangle] + pub extern "C" fn bloom_set_reverb(size: f64, damp: f64, wet: f64) { + $crate::ffi::guard("bloom_set_reverb", move || { + engine().audio.set_reverb(size as f32, damp as f32, wet as f32); + }) + } + }; } diff --git a/native/shared/src/ffi_core/input.rs b/native/shared/src/ffi_core/input.rs index 1259adb..27ce417 100644 --- a/native/shared/src/ffi_core/input.rs +++ b/native/shared/src/ffi_core/input.rs @@ -98,6 +98,24 @@ macro_rules! __bloom_ffi_input { }) } + // bloom_gamepad_rumble [EN-031] + // low/high motor 0..1, duration in seconds. The platform's input poll + // consumes this and drives its own vibration API; on platforms with no + // vibration it is simply ignored (the state is written, nobody reads + // it) — which is why the symbol exists everywhere and the behaviour + // does not have to. + #[no_mangle] + pub extern "C" fn bloom_gamepad_rumble(low: f64, high: f64, seconds: f64) { + $crate::ffi::guard("bloom_gamepad_rumble", move || { + let inp = &mut engine().input; + inp.rumble = [ + (low as f32).clamp(0.0, 1.0), + (high as f32).clamp(0.0, 1.0), + (seconds as f32).clamp(0.0, 10.0), + ]; + }) + } + // bloom_is_gamepad_button_pressed [source: macos] #[no_mangle] pub extern "C" fn bloom_is_gamepad_button_pressed(btn: f64) -> f64 { diff --git a/native/shared/src/input.rs b/native/shared/src/input.rs index 7a8d906..527cdbe 100644 --- a/native/shared/src/input.rs +++ b/native/shared/src/input.rs @@ -58,6 +58,12 @@ pub struct InputState { gamepad_buttons_released: [bool; MAX_GAMEPAD_BUTTONS], prev_gamepad_buttons: [bool; MAX_GAMEPAD_BUTTONS], pub gamepad_axis_count: usize, + /// EN-031 rumble: (low-frequency motor, high-frequency motor, seconds + /// left), all 0 = motors off. The FFI writes this; each platform's input + /// poll drives its own vibration API from it and decrements the timer. + /// Keeping the *state* shared and only the driving per-platform is what + /// stops the six platform crates from drifting again. + pub rumble: [f32; 3], // Touch pub touch_points: [TouchPoint; MAX_TOUCH_POINTS], @@ -103,6 +109,7 @@ impl InputState { gamepad_buttons_released: [false; MAX_GAMEPAD_BUTTONS], prev_gamepad_buttons: [false; MAX_GAMEPAD_BUTTONS], gamepad_axis_count: 0, + rumble: [0.0; 3], touch_points: [EMPTY_TOUCH; MAX_TOUCH_POINTS], touch_count: 0, touch_pending_release: [false; MAX_TOUCH_POINTS], diff --git a/native/watchos/src/ffi_stubs.rs b/native/watchos/src/ffi_stubs.rs index 8ebfee1..35e2681 100644 --- a/native/watchos/src/ffi_stubs.rs +++ b/native/watchos/src/ffi_stubs.rs @@ -827,3 +827,21 @@ } #[no_mangle] pub extern "C" fn bloom_decals_clear() { } + +// EN-029 — audio bus/reverb/filter surface (watchOS stub platform). +#[no_mangle] pub extern "C" fn bloom_set_sound_bus(_p0: f64, _p1: f64) { +} +#[no_mangle] pub extern "C" fn bloom_set_sound_reverb_send(_p0: f64, _p1: f64) { +} +#[no_mangle] pub extern "C" fn bloom_set_sound_lowpass(_p0: f64, _p1: f64) { +} +#[no_mangle] pub extern "C" fn bloom_set_bus_gain(_p0: f64, _p1: f64) { +} +#[no_mangle] pub extern "C" fn bloom_duck_bus(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64) { +} +#[no_mangle] pub extern "C" fn bloom_set_reverb(_p0: f64, _p1: f64, _p2: f64) { +} + +// EN-031 — gamepad rumble (no vibration motor on watchOS). +#[no_mangle] pub extern "C" fn bloom_gamepad_rumble(_p0: f64, _p1: f64, _p2: f64) { +} diff --git a/native/web/src/input_ffi.rs b/native/web/src/input_ffi.rs index 6c105a7..a4db50e 100644 --- a/native/web/src/input_ffi.rs +++ b/native/web/src/input_ffi.rs @@ -1,4 +1,4 @@ -//! Input FFI surface for web: keyboard/mouse/gamepad/touch getters plus +//! Input FFI surface for web: keyboard/mouse/gamepad/touch getters plus //! the injection entry points the JS glue calls from DOM event //! listeners. Split from lib.rs (2000-line file policy). @@ -200,3 +200,19 @@ pub fn bloom_inject_gamepad_button_up(button: f64) { engine().input.set_gamepad_button_up(button as usize); } + +/// EN-031 — gamepad rumble. The Gamepad API exposes vibrationActuator, but +/// only behind a user-gesture requirement and with patchy support, so the web +/// port records the request (keeping the symbol and the state consistent with +/// native) without driving a motor. Wire `playEffect` here when the browser +/// story settles. +#[wasm_bindgen] +pub fn bloom_gamepad_rumble(low: f64, high: f64, seconds: f64) { + let inp = &mut engine().input; + inp.rumble = [ + (low as f32).clamp(0.0, 1.0), + (high as f32).clamp(0.0, 1.0), + (seconds as f32).clamp(0.0, 10.0), + ]; +} + diff --git a/native/web/src/material_ffi.rs b/native/web/src/material_ffi.rs index a049036..250aaeb 100644 --- a/native/web/src/material_ffi.rs +++ b/native/web/src/material_ffi.rs @@ -633,3 +633,36 @@ pub fn bloom_decals_instance_buffer() -> f64 { pub fn bloom_decals_clear() { engine().decals.clear(); } + +// EN-029 — audio buses / reverb / occlusion low-pass. All CPU DSP in +// bloom-shared's AudioMixer, so web gets the real thing. + +#[wasm_bindgen] +pub fn bloom_set_sound_bus(handle: f64, bus: f64) { + engine().audio.set_sound_bus(handle, bus as u8); +} + +#[wasm_bindgen] +pub fn bloom_set_sound_reverb_send(handle: f64, send: f64) { + engine().audio.set_sound_reverb_send(handle, send as f32); +} + +#[wasm_bindgen] +pub fn bloom_set_sound_lowpass(handle: f64, cutoff: f64) { + engine().audio.set_sound_lowpass(handle, cutoff as f32); +} + +#[wasm_bindgen] +pub fn bloom_set_bus_gain(bus: f64, gain: f64) { + engine().audio.set_bus_gain(bus as u8, gain as f32); +} + +#[wasm_bindgen] +pub fn bloom_duck_bus(bus: f64, amount: f64, attack: f64, release: f64, hold: f64) { + engine().audio.duck_bus(bus as u8, amount as f32, attack as f32, release as f32, hold as f32); +} + +#[wasm_bindgen] +pub fn bloom_set_reverb(size: f64, damp: f64, wet: f64) { + engine().audio.set_reverb(size as f32, damp as f32, wet as f32); +} diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index cfd6e52..b9b3d89 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -841,11 +841,41 @@ fn poll_xinput_gamepad() { eng.input.set_gamepad_button_up(idx); } } + + // EN-031 — rumble. The FFI writes (low, high, seconds) into shared + // input state; we own the motors and the countdown. Only push a new + // XInputSetState when the commanded level actually changes, since the + // call goes out over USB/BT and doing it every frame is wasteful. + let dt = eng.delta_time as f32; + let (lo, hi, mut left) = (eng.input.rumble[0], eng.input.rumble[1], eng.input.rumble[2]); + let (want_lo, want_hi) = if left > 0.0 { (lo, hi) } else { (0.0, 0.0) }; + unsafe { + if (want_lo, want_hi) != LAST_RUMBLE { + let mut vib = XINPUT_VIBRATION { + wLeftMotorSpeed: (want_lo * 65535.0) as u16, + wRightMotorSpeed: (want_hi * 65535.0) as u16, + }; + let _ = XInputSetState(0, &mut vib); + LAST_RUMBLE = (want_lo, want_hi); + } + } + if left > 0.0 { + left = (left - dt).max(0.0); + eng.input.rumble[2] = left; + } } else { eng.input.gamepad_available = false; + // Pad unplugged mid-rumble: forget the commanded level so a + // reconnecting pad doesn't inherit a stale "still buzzing" state. + unsafe { LAST_RUMBLE = (0.0, 0.0); } } } +/// Last vibration level actually pushed to the pad, so we only call +/// XInputSetState on change. +#[cfg(windows)] +static mut LAST_RUMBLE: (f32, f32) = (0.0, 0.0); + #[no_mangle] pub extern "C" fn bloom_begin_drawing() { #[cfg(windows)] diff --git a/package.json b/package.json index ac5eac7..6a0a53f 100644 --- a/package.json +++ b/package.json @@ -1771,11 +1771,46 @@ ], "returns": "f64" }, + { + "name": "bloom_set_sound_bus", + "params": ["f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_set_sound_reverb_send", + "params": ["f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_set_sound_lowpass", + "params": ["f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_set_bus_gain", + "params": ["f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_duck_bus", + "params": ["f64", "f64", "f64", "f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_set_reverb", + "params": ["f64", "f64", "f64"], + "returns": "void" + }, { "name": "bloom_is_gamepad_available", "params": [], "returns": "f64" }, + { + "name": "bloom_gamepad_rumble", + "params": ["f64", "f64", "f64"], + "returns": "void" + }, { "name": "bloom_get_gamepad_axis", "params": [ diff --git a/src/audio/index.ts b/src/audio/index.ts index ca30a73..f1dfefd 100644 --- a/src/audio/index.ts +++ b/src/audio/index.ts @@ -184,3 +184,66 @@ export function commitMusic(stagingHandle: number): Music { const handle = bloom_commit_music(stagingHandle); return { handle }; } + +// ---- EN-029: mix buses, reverb send, occlusion low-pass --------------------- +// +// The mixer used to be master gain + per-voice gain. That plays sounds; it +// does not make a space sound like a place. Three additions cover the moves a +// shooter actually needs: +// +// - a bus you can duck ("drop the music when the player is hit") +// - a tail you can send to ("this gunshot is indoors") +// - a filter per source ("that shriek is behind the building") +// +// Routing is per *sound*, set once at load — a footstep is always SFX, a menu +// blip is always UI — so the per-shot call sites stay unchanged. + +declare function bloom_set_sound_bus(handle: number, bus: number): void; +declare function bloom_set_sound_reverb_send(handle: number, send: number): void; +declare function bloom_set_sound_lowpass(handle: number, cutoff: number): void; +declare function bloom_set_bus_gain(bus: number, gain: number): void; +declare function bloom_duck_bus(bus: number, amount: number, attack: number, release: number, hold: number): void; +declare function bloom_set_reverb(size: number, damp: number, wet: number): void; + +export const BUS_SFX = 0; +export const BUS_MUSIC = 1; +export const BUS_UI = 2; + +/// Route a sound to a mix bus. Music loaded via `loadMusic` is already on +/// BUS_MUSIC; this is for sound effects that belong somewhere other than SFX +/// (menu clicks → BUS_UI, so they never duck with the rest of the mix). +export function setSoundBus(sound: Sound, bus: number): void { + bloom_set_sound_bus(sound.handle, bus); +} + +/// How much of this sound feeds the reverb, 0..1. This is what gives a weapon +/// its tail — and raising it near walls is what makes a fight "indoors". +export function setSoundReverbSend(sound: Sound, send: number): void { + bloom_set_sound_reverb_send(sound.handle, send); +} + +/// Low-pass this sound at `cutoffHz` (0 = bypass). The occlusion primitive: +/// the game raycasts to the emitter and muffles what it can't see. Muffling +/// reads as geometry in a way that simply lowering the volume never does. +export function setSoundLowpass(sound: Sound, cutoffHz: number): void { + bloom_set_sound_lowpass(sound.handle, cutoffHz); +} + +export function setBusGain(bus: number, gain: number): void { + bloom_set_bus_gain(bus, gain); +} + +/// Momentarily pull a bus down — `amount` 0..1 — over `attack` seconds, hold +/// it for `hold`, then recover over `release`. Call it again to re-trigger; +/// the hold restarts, so repeated hits keep the music down. +export function duckBus(bus: number, amount: number, attack: number, release: number, hold: number): void { + bloom_duck_bus(bus, amount, attack, release, hold); +} + +/// Global reverb: `size` 0..1 (tail length), `damp` 0..1 (HF absorption), +/// `wet` 0..1 (how much returns to the mix). wet = 0 bypasses the entire +/// reverb path, so it is free until you ask for it — ramp it up as the player +/// moves indoors. +export function setReverb(size: number, damp: number, wet: number): void { + bloom_set_reverb(size, damp, wet); +} From b5dc9b11ba8006b1ae062d824bf90b295496267e Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 11:32:49 +0200 Subject: [PATCH 03/17] fix(EN-014): set_user_params silently unbound a material's texture array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the VFX round, the hard way. This sequence — setMaterialTextureArray(m, ALBEDO, arr); // link the art setMaterialParams(m, [...]); // ...and silently lose it left the material sampling the 1x1 stub: set_user_params rebuilt the per-material bind group with default_array_view HARDCODED on bindings 14/15/16 instead of resolving the links the way every other BG-build path does. Nothing errored and no validation layer complained; the pixels simply never appeared. It cost an afternoon: the shooter's particles and decals were simulating perfectly (pools full, instance buffers written, draws dispatched) and rendering nothing, because every textureSample of the atlas came back empty. Any game hitting the natural "bind the art, then configure it" order would have been bitten — SH-009's splat terrain next. Adds an end-to-end regression test: bind a green array, set params, render, read the pixel back. Asserting the *link* survives is not enough — the link always survived; it was the bind group that lost it — so the test renders and checks the colour. 114/114 shared tests pass. Also: - compile_material_instanced_bucket() gains `reads_scene`, without which a soft particle's scene_depth_tex sample is not in the pipeline layout and the shader fails validation at pipeline-create time (not at write time), which is a confusing way to find out. - bloom_create_texture_array_from_files: build an array by naming the files and letting the engine decode them. The byte-array path asks the caller to marshal every texel across the FFI — a 6-layer 256^2 array is 1.5M numbers, exactly the call shape Perry's array bridge handles worst. - gamepadRumble() TS binding for EN-031. --- native/shared/src/ffi_core/game_loop.rs | 49 +++++ native/shared/src/ffi_core/models.rs | 21 ++ native/shared/src/renderer/material_system.rs | 14 +- .../src/renderer/material_system_tests.rs | 194 ++++++++++++++++++ native/shared/src/renderer/mod.rs | 31 ++- native/watchos/src/ffi_stubs.rs | 8 + native/web/src/material_ffi.rs | 8 + package.json | 10 + src/core/index.ts | 12 ++ src/models/index.ts | 56 +++++ tools/validate-ffi.js | 3 + 11 files changed, 399 insertions(+), 7 deletions(-) diff --git a/native/shared/src/ffi_core/game_loop.rs b/native/shared/src/ffi_core/game_loop.rs index 0c67d86..bd8a3da 100644 --- a/native/shared/src/ffi_core/game_loop.rs +++ b/native/shared/src/ffi_core/game_loop.rs @@ -161,6 +161,55 @@ macro_rules! __bloom_ffi_game_loop { }) } + // bloom_create_texture_array_from_files [EN-014 V3] + // + // The byte-array path above asks the game to marshal every texel across + // the FFI — a 6-layer 256² array is 1.5 M numbers, which is exactly the + // shape of call Perry's array bridge is worst at. Decoding on this side + // from a path list is both faster and the API every actual consumer + // wants (VFX atlas layers, splat-terrain layers). + // + // `paths` is comma-separated, and is parsed HERE, at load time — never + // on a per-frame path (perry-quirks #5). Layers must share dimensions; + // the first file's size wins and any mismatch is skipped with a warning. + #[no_mangle] + pub extern "C" fn bloom_create_texture_array_from_files( + paths_ptr: *const u8, format: f64, mip_levels: f64, + ) -> f64 { + $crate::ffi::guard("bloom_create_texture_array_from_files", move || { + let list = $crate::string_header::str_from_header(paths_ptr); + let mut decoded: Vec<(Vec, u32, u32)> = Vec::new(); + for p in list.split(',') { + let p = p.trim(); + if p.is_empty() { continue; } + let resolved = bloom_resolve_asset_path(p); + match image::open(resolved.as_ref()) { + Ok(img) => { + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + decoded.push((rgba.into_raw(), w, h)); + } + Err(e) => { + eprintln!("[texarray] failed to load {}: {}", p, e); + } + } + } + if decoded.is_empty() { return 0.0; } + let (w, h) = (decoded[0].1, decoded[0].2); + let mut layers: Vec<(&[u8], u32, u32)> = Vec::with_capacity(decoded.len()); + for (bytes, lw, lh) in decoded.iter() { + if *lw != w || *lh != h { + eprintln!("[texarray] layer size {}x{} != {}x{}; skipped", lw, lh, w, h); + continue; + } + layers.push((bytes.as_slice(), w, h)); + } + if layers.is_empty() { return 0.0; } + engine().renderer.create_texture_array_ex( + &layers, format as u32, mip_levels as u32) as f64 + }) + } + // bloom_clear_post_pass [source: macos] #[no_mangle] pub extern "C" fn bloom_clear_post_pass() { diff --git a/native/shared/src/ffi_core/models.rs b/native/shared/src/ffi_core/models.rs index 415c411..feaa707 100644 --- a/native/shared/src/ffi_core/models.rs +++ b/native/shared/src/ffi_core/models.rs @@ -259,6 +259,27 @@ macro_rules! __bloom_ffi_models { }) } + // bloom_compile_material_instanced_bucket [EN-026/027] + // bucket: 0 = opaque, 1 = cutout, 2 = additive, 3 = transparent. + // reads_scene: bind the scene colour/depth snapshot (soft particles). + // The plain instanced compile above is hardcoded to opaque, which is + // right for grass and wrong for the two things that most want + // instancing: particles (additive) and decals (cutout). + #[no_mangle] + pub extern "C" fn bloom_compile_material_instanced_bucket( + source_ptr: *const u8, bucket: f64, reads_scene: f64, + ) -> f64 { + $crate::ffi::guard("bloom_compile_material_instanced_bucket", move || { + let source = $crate::string_header::str_from_header(source_ptr); + match engine().renderer.compile_material_instanced_bucket( + source, bucket as u32, reads_scene != 0.0) + { + Ok(handle) => handle as f64, + Err(e) => { eprintln!("[material] instanced compile failed: {:?}", e); 0.0 } + } + }) + } + // bloom_submit_material_draw_instanced [source: linux; gated: models3d] #[cfg(feature = "models3d")] #[no_mangle] diff --git a/native/shared/src/renderer/material_system.rs b/native/shared/src/renderer/material_system.rs index 1ef9f1a..f6e8074 100644 --- a/native/shared/src/renderer/material_system.rs +++ b/native/shared/src/renderer/material_system.rs @@ -866,6 +866,13 @@ impl MaterialSystem { .get(idx) .and_then(|b| b.as_ref()) .unwrap_or(&self._default_material_factors_buffer); + // EN-014 BUG FIX: this used to hardcode the 1×1 stub array on + // bindings 14/15/16, so setting user params AFTER linking a texture + // array silently unbound the array — the material kept sampling the + // stub and every fetch came back empty. Nothing errored; the pixels + // simply never appeared. Resolve the links like every other + // BG-build path does. + let [albedo_view, normal_view, mr_view] = self.resolve_array_views(idx); let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("material_per_material_bg_user"), layout: &self.layouts.per_material, @@ -884,10 +891,9 @@ impl MaterialSystem { wgpu::BindGroupEntry { binding: 11, resource: buf.as_entire_binding() }, wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::TextureView(&self.default_black_view) }, wgpu::BindGroupEntry { binding: 13, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - // EN-014 — default stub array on all 3 slots. - wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(&self.default_array_view) }, - wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(&self.default_array_view) }, - wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(&self.default_array_view) }, + wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(albedo_view) }, + wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(normal_view) }, + wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(mr_view) }, wgpu::BindGroupEntry { binding: 17, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, ], }); diff --git a/native/shared/src/renderer/material_system_tests.rs b/native/shared/src/renderer/material_system_tests.rs index 5bee241..356f1ce 100644 --- a/native/shared/src/renderer/material_system_tests.rs +++ b/native/shared/src/renderer/material_system_tests.rs @@ -364,6 +364,199 @@ fn fs_main(_in: VsOut) -> TranslucentOut { let f = (1.0 + (frac as f32) / 1024.0) * (2.0f32).powi(exp as i32 - 15); if sign == 1 { -f } else { f } } + + /// Samples layer 0 of the albedo texture array and writes it straight out. + /// If the array is bound, this is the array's colour; if the stub is bound, + /// it is the stub's. + const ARRAY_SAMPLING_WGSL: &str = r#" +#include "material_abi.wgsl" + +struct VsOut { + @builtin(position) clip_position: vec4, +}; + +@vertex +fn vs_main(in: VertexInput) -> VsOut { + var out: VsOut; + out.clip_position = draw.mvp * vec4(in.position, 1.0); + return out; +} + +@fragment +fn fs_main(_in: VsOut) -> TranslucentOut { + var out: TranslucentOut; + let c = textureSampleLevel(albedo_array, albedo_array_samp, vec2(0.5, 0.5), 0, 0.0); + out.hdr = vec4(c.rgb, 1.0); + return out; +} +"#; + + /// EN-014 regression, end-to-end. + /// + /// `set_user_params` used to rebuild the per-material bind group with the + /// 1×1 stub array HARDCODED on bindings 14/15/16, so this sequence — + /// + /// set_material_texture_array(m, ALBEDO, arr); // link the art + /// set_material_params(m, [...]); // ...and silently lose it + /// + /// — left the material sampling the stub. Nothing errored, no validation + /// complained; the pixels just never appeared. It cost an afternoon in the + /// VFX round, with particles and decals both simulating perfectly and + /// rendering nothing. + /// + /// Asserting the *link* survives is not enough (it always did — it was the + /// bind group that lost it), so this renders a quad that samples the array + /// and reads the pixel back. Green means the array is bound; the stub is + /// not green. + #[test] + fn set_user_params_preserves_a_linked_texture_array() { + let Some((device, queue)) = try_create_device() else { return; }; + let joint_buf = make_joint_buffer(&device); + let mut sys = MaterialSystem::new(&device, &queue, &joint_buf); + + let handle = sys.compile( + &device, + ARRAY_SAMPLING_WGSL, + FragmentProfile::Translucent, + Bucket::Transparent, + false, + false, + wgpu::TextureFormat::Rgba16Float, + wgpu::TextureFormat::Rg8Unorm, + wgpu::TextureFormat::Rg16Float, + wgpu::TextureFormat::Rgba8Unorm, + formats::DEPTH_FORMAT, + ).expect("array-sampling material compiles"); + + // One 2×2 layer of pure green (linear Rgba8 → format code 1). + let px: [u8; 2 * 2 * 4] = [ + 0, 255, 0, 255, 0, 255, 0, 255, + 0, 255, 0, 255, 0, 255, 0, 255, + ]; + let arr = sys.create_texture_array_ex(&device, &queue, &[(&px[..], 2, 2)], 1, 1); + assert!(arr != 0, "texture array allocates"); + + // Link the array, THEN set params — the order that used to break. + // (Clone the stub probe view: set_material_texture_array borrows `sys` + // mutably and would otherwise conflict with holding a & into it.) + let probe_view = sys.default_black_view.clone(); + sys.set_material_texture_array(&device, handle, 0, arr, &probe_view); + sys.set_user_params(&device, &queue, handle, &[0u8; 16]).expect("params set"); + + // Render it. + let pf = PerFrameUniforms { + time: 0.0, delta_time: 0.0, frame_index: 0, _pad0: 0, + screen_resolution: [64.0, 64.0], render_resolution: [64.0, 64.0], + taa_jitter: [0.0; 2], _pad1: [0.0; 2], wind: [0.0; 4], + }; + let pv = bytemuck::Zeroable::zeroed(); + sys.update_frame_uniforms(&queue, &pf, &pv); + sys.reset_draw_slot(crate::renderer::IDENTITY_MAT4); + + let identity = crate::renderer::IDENTITY_MAT4; + let (vb, ib, icount) = make_fullscreen_tri(&device, &queue); + sys.submit_draw( + &device, &queue, &joint_buf, + handle, 1, 0, identity, identity, identity, [1.0; 4], [0; 4], + ); + + let (rt_w, rt_h) = (64u32, 64u32); + let hdr_rt = device.create_texture(&wgpu::TextureDescriptor { + label: Some("test_arr_hdr"), + size: wgpu::Extent3d { width: rt_w, height: rt_h, depth_or_array_layers: 1 }, + mip_level_count: 1, sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let hdr_view = hdr_rt.create_view(&Default::default()); + let depth_rt = device.create_texture(&wgpu::TextureDescriptor { + label: Some("test_arr_depth"), + size: wgpu::Extent3d { width: rt_w, height: rt_h, depth_or_array_layers: 1 }, + mip_level_count: 1, sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: formats::DEPTH_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let depth_view = depth_rt.create_view(&Default::default()); + + let mut encoder = device.create_command_encoder(&Default::default()); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("test_arr_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &hdr_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + // Clear to RED: if the draw never lands, or the stub + // (white/black) is sampled, the assert below fails loudly + // instead of accidentally passing. + load: wgpu::LoadOp::Clear(wgpu::Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + sys.dispatch_translucent(&mut pass, |mh, _idx| { + if mh == 1 { Some((&vb, &ib, icount)) } else { None } + }); + } + + let bpr = ((rt_w * 8) + 255) & !255; + let staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("test_arr_staging"), + size: (bpr * rt_h) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &hdr_rt, mip_level: 0, + origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &staging, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, bytes_per_row: Some(bpr), rows_per_image: Some(rt_h), + }, + }, + wgpu::Extent3d { width: rt_w, height: rt_h, depth_or_array_layers: 1 }, + ); + queue.submit(std::iter::once(encoder.finish())); + + let slice = staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + rx.recv().expect("map sender").expect("map failed"); + let data = slice.get_mapped_range(); + + let texel = ((rt_h / 2) * bpr) as usize + ((rt_w / 2) as usize) * 8; + let r = f16_to_f32(u16::from_le_bytes([data[texel], data[texel + 1]])); + let g = f16_to_f32(u16::from_le_bytes([data[texel + 2], data[texel + 3]])); + drop(data); + staging.unmap(); + + assert!( + g > 0.9 && r < 0.1, + "material sampled the stub array, not the linked one \ + (expected green, got r={r:.2} g={g:.2}) — set_user_params clobbered \ + the texture-array binding", + ); + } } #[cfg(test)] @@ -402,4 +595,5 @@ mod translucent_sort_tests { let order: Vec = ms_cmds.iter().map(|c| c.material).collect(); assert_eq!(order, vec![2, 3, 4, 1, 5]); } + } diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index a3d6ef9..d22f8dd 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -11055,12 +11055,37 @@ impl Renderer { pub fn compile_material_instanced( &mut self, wgsl_source: &str, ) -> Result { + self.compile_material_instanced_bucket(wgsl_source, 0, false) + } + + /// EN-026/027 — instanced compile into a chosen bucket. + /// + /// The original instanced path was hardcoded to Opaque, which is right for + /// grass and wrong for the two things that most want instancing: particles + /// (additive, thousands of quads) and decals (cutout, alpha-tested against + /// the atlas). `bucket`: 0 = opaque, 1 = cutout, 2 = additive, + /// 3 = transparent. + /// + /// `reads_scene` binds the scene colour/depth snapshot group. Soft + /// particles NEED it — a billboard that intersects the ground shows a hard + /// straight seam otherwise, which is the single biggest tell that a "puff" + /// is a flat card — and without this flag the group is absent from the + /// pipeline layout and the shader fails validation at create time. + pub fn compile_material_instanced_bucket( + &mut self, wgsl_source: &str, bucket: u32, reads_scene: bool, + ) -> Result { + let (profile, bucket) = match bucket { + 1 => (material_pipeline::FragmentProfile::Opaque, material_pipeline::Bucket::Cutout), + 2 => (material_pipeline::FragmentProfile::Translucent, material_pipeline::Bucket::Additive), + 3 => (material_pipeline::FragmentProfile::Translucent, material_pipeline::Bucket::Transparent), + _ => (material_pipeline::FragmentProfile::Opaque, material_pipeline::Bucket::Opaque), + }; self.material_system.compile( &self.device, wgsl_source, - material_pipeline::FragmentProfile::Opaque, - material_pipeline::Bucket::Opaque, - false, + profile, + bucket, + reads_scene, true, // wants_instancing formats::HDR_FORMAT, formats::MATERIAL_FORMAT, diff --git a/native/watchos/src/ffi_stubs.rs b/native/watchos/src/ffi_stubs.rs index 35e2681..1094a04 100644 --- a/native/watchos/src/ffi_stubs.rs +++ b/native/watchos/src/ffi_stubs.rs @@ -845,3 +845,11 @@ // EN-031 — gamepad rumble (no vibration motor on watchOS). #[no_mangle] pub extern "C" fn bloom_gamepad_rumble(_p0: f64, _p1: f64, _p2: f64) { } + +#[no_mangle] pub extern "C" fn bloom_compile_material_instanced_bucket(_p0: i64, _p1: f64, _p2: f64) -> f64 { + 0.0 +} + +#[no_mangle] pub extern "C" fn bloom_create_texture_array_from_files(_p0: i64, _p1: f64, _p2: f64) -> f64 { + 0.0 +} diff --git a/native/web/src/material_ffi.rs b/native/web/src/material_ffi.rs index 250aaeb..f220414 100644 --- a/native/web/src/material_ffi.rs +++ b/native/web/src/material_ffi.rs @@ -666,3 +666,11 @@ pub fn bloom_duck_bus(bus: f64, amount: f64, attack: f64, release: f64, hold: f6 pub fn bloom_set_reverb(size: f64, damp: f64, wet: f64) { engine().audio.set_reverb(size as f32, damp as f32, wet as f32); } + +#[wasm_bindgen] +pub fn bloom_compile_material_instanced_bucket(source: String, bucket: f64, reads_scene: f64) -> f64 { + match engine().renderer.compile_material_instanced_bucket(&source, bucket as u32, reads_scene != 0.0) { + Ok(handle) => handle as f64, + Err(e) => { web_sys::console::error_1(&format!("[material] instanced compile failed: {:?}", e).into()); 0.0 } + } +} diff --git a/package.json b/package.json index 6a0a53f..6b034cd 100644 --- a/package.json +++ b/package.json @@ -1207,6 +1207,16 @@ "params": ["f64", "f64", "f64"], "returns": "f64" }, + { + "name": "bloom_create_texture_array_from_files", + "params": ["string", "f64", "f64"], + "returns": "f64" + }, + { + "name": "bloom_compile_material_instanced_bucket", + "params": ["string", "f64", "f64"], + "returns": "f64" + }, { "name": "bloom_particles_create", "params": ["f64"], diff --git a/src/core/index.ts b/src/core/index.ts index 0eaf4ca..7995fbf 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -815,6 +815,18 @@ export function isGamepadButtonDown(button: number): boolean { return bloom_is_gamepad_button_down(button) !== 0; } +declare function bloom_gamepad_rumble(low: number, high: number, seconds: number): void; + +/// EN-031 — vibrate the pad. `low` drives the heavy (low-frequency) motor and +/// `high` the light one, both 0..1; `seconds` is how long before it stops on +/// its own, so callers never have to remember to switch it off. +/// +/// A no-op on platforms with no motor, and silently ignored if no pad is +/// connected. +export function gamepadRumble(low: number, high: number, seconds: number): void { + bloom_gamepad_rumble(low, high, seconds); +} + export function isGamepadButtonReleased(button: number): boolean { return bloom_is_gamepad_button_released(button) !== 0; } diff --git a/src/models/index.ts b/src/models/index.ts index 949f82d..20e4857 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -366,6 +366,29 @@ export function compileMaterialInstanced(wgslSource: string): number { return bloom_compile_material_instanced(wgslSource as any); } +declare function bloom_compile_material_instanced_bucket(src: number, bucket: number, readsScene: number): number; + +export const BUCKET_OPAQUE = 0; +export const BUCKET_CUTOUT = 1; +export const BUCKET_ADDITIVE = 2; +export const BUCKET_TRANSPARENT = 3; + +/// EN-026/027 — instanced compile into a chosen bucket. `compileMaterialInstanced` +/// is opaque-only, which suits grass and suits nothing that blends: particles +/// want BUCKET_ADDITIVE, decals want BUCKET_CUTOUT (alpha-tested against the +/// atlas so they still write depth and receive shadow). +/// +/// Set `readsScene` if the shader samples `scene_color_tex` / `scene_depth_tex` +/// — soft particles need it, and WITHOUT it the scene bind group is simply +/// absent from the pipeline layout and the shader fails validation when the +/// pipeline is created (not when it is written), which is a confusing way to +/// find out. +export function compileMaterialInstancedBucket( + wgslSource: string, bucket: number, readsScene: boolean = false, +): number { + return bloom_compile_material_instanced_bucket(wgslSource as any, bucket, readsScene ? 1 : 0); +} + /// EN-001 — upload a flat per-instance buffer to the GPU. `data` is /// laid out as 9 floats per instance: /// [pos.x, pos.y, pos.z, rot_y, scale, tint.r, tint.g, tint.b, tint.a] @@ -535,6 +558,39 @@ export function createTextureArrayEx( return bloom_create_texture_array_ex(bytes as any, dataLen, width, height, layerCount, format, mipLevels); } +declare function bloom_create_texture_array_from_files(paths: number, format: number, mipLevels: number): number; + +/// EN-014 V3 — build a texture array by naming the files, letting the engine +/// decode them. +/// +/// Prefer this to `createTextureArray`: that one asks the caller to marshal +/// every texel across the FFI (a 6-layer 256² array is 1.5 M numbers), which is +/// both slow and exactly the call shape Perry's array bridge handles worst. +/// Here the only thing crossing is a path list, parsed once at load — never on +/// a frame path (perry-quirks #5). +/// +/// All layers must share dimensions; the first file's size wins and mismatched +/// ones are skipped with a warning. Layer index = position in the list, so the +/// ORDER IS AN ABI — shaders index by it. Returns 0 if nothing decoded. +/// +/// Not available on web (no filesystem in wasm); use `createTextureArrayEx` +/// there. +export function createTextureArrayFromFiles( + paths: string[], + format: number = TEX_ARRAY_FORMAT_SRGB, + mipLevels: number = 1, +): number { + // Join rather than pass an array: one string is one pointer, and the engine + // splits it. Building the joined string with an explicit loop keeps clear of + // Perry's `.push()`/`.length` foot-gun. + let joined = ''; + for (let i = 0; i < paths.length; i = i + 1) { + if (i > 0) joined = joined + ','; + joined = joined + paths[i]; + } + return bloom_create_texture_array_from_files(joined as any, format, mipLevels); +} + /// EN-014 — link a texture-array handle to a material at one of three /// slots: `TEXTURE_ARRAY_ALBEDO` (binding 14), `TEXTURE_ARRAY_NORMAL` /// (binding 15), `TEXTURE_ARRAY_MR` (binding 16). Pass `array = 0` to diff --git a/tools/validate-ffi.js b/tools/validate-ffi.js index 6d85141..8ec1a6c 100644 --- a/tools/validate-ffi.js +++ b/tools/validate-ffi.js @@ -169,6 +169,9 @@ for (const platform of PLATFORMS) { // pointer-taking geometry (cross-module WASM memory TODO) and // filesystem captures (no fs on wasm; need _bytes/_str designs). const WEB_GAP_ALLOWLIST = new Set([ + // EN-014 V3 — decodes image files from disk; wasm has no fs. The + // byte-array path (bloom_create_texture_array_ex) is the web route. + 'bloom_create_texture_array_from_files', 'bloom_scene_set_lod', // Perry-WASM linear-memory bridge TODO 'bloom_take_screenshot', // no fs — needs a bytes-returning design 'bloom_set_env_clear_from_hdr', // no fs — needs a _bytes variant From 33067c510b2f3de4bd0719b870bd727c4d845945 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 11:35:37 +0200 Subject: [PATCH 04/17] docs: file EN-038 (takeScreenshot dead on Windows) + EN-039 (full-transform immediate draw) Claude-Session: https://claude.ai/code/session_01V3MihNxaRwMR8GjMPvMkRb --- docs/tickets.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/tickets.md b/docs/tickets.md index 0535cbb..587ff18 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -1203,6 +1203,52 @@ points. --- +## EN-038 — `bloom_take_screenshot` never fires on Windows 🔴 + +**Symptom.** `takeScreenshot('x.png')` from TS produces nothing: no file, and — +decisively — not even the unconditional `eprintln!("bloom: screenshot requested +-> '{}'")` at the top of the FFI, nor the `screenshot readback branch running` +log in `end_frame`. So the native function is never entered at all; this is not +a path/permissions problem or a failed readback. + +**Repro (2026-07-12, shooter @ AAA round 1).** Call `takeScreenshot` from the +game loop at a fixed frame. A `console.log` immediately before it prints; the +engine's own log line never does. Three separate frames, plain relative path, +same result. + +**Why it matters.** Every screenshot-based harness in the shooter +(`SELFTEST`, `PERFTEST`'s in-engine captures) is silently capturing nothing, and +has been reporting success. Visual verification had to fall back to a +window-rect desktop grab (`shooter/tools/shot-window.ps1`). + +**Suspects, in order.** (1) Perry's FFI dispatch for a void-returning +string-param extern — note the manifest declares `params: ["string"]` and the +call site is inside a nested block; (2) a name collision with a platform-crate +symbol; (3) dead-code elimination of a call whose return is unused. + +**Next step.** Bisect with a minimal Perry program that calls only +`bloom_take_screenshot`, then compare the generated call against a +known-working void/string FFI (`bloom_load_model` returns f64, so pick +`bloom_set_env_clear_from_hdr` — same void+string shape — as the control). + +--- + +## EN-039 — immediate draw with a full transform 🟢 + +**Why.** `drawModelRotated` takes a single Y rotation, so an immediate-mode draw +cannot pitch or roll. The shooter's weapon therefore cannot tilt with the +player's aim — it stays level while the camera looks up or down. Any held prop, +thrown object, or debris chunk hits the same wall. + +**API sketch.** `drawModelTransform(handle, m16)` taking a column-major 4×4 +(the scene graph already has `bloom_scene_set_transform16`; this is the +immediate-mode twin). Perry can't pass 16 f64 args, so route it through the mesh +scratch like the other array-shaped FFIs. + +**Acceptance:** shooter SH-027 v2 — the gun points where the camera points. + +--- + ## Deferred infrastructure tracks 🔴 Recorded so the list is complete; each is real but none blocks the From d1e74e1f43db7e9dba7d101ab7ee18424972b19a Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 13:18:20 +0200 Subject: [PATCH 05/17] feat(EN-025): ragdolls Jolt has shipped Ragdoll.cpp in the vendored submodule since day one; what was missing was any way to reach it. This is that -- but it does NOT use Jolt's Ragdoll class, which wants a RagdollSettings asset authored alongside the skeleton. We don't have one, and asking every game to produce one would be the real cost. Instead the ragdoll is built at runtime from the skeleton we already have: one capsule per bone, one LIMITED six-DOF joint per articulation. No new authoring step, and it works for any skinned model a game loads. THE COORDINATE PROBLEM. Physics runs in world space, skinning in model space. The bridge is the model transform M the game was drawing with at the moment of death, and the decision that makes the rest simple is that M is FROZEN at activation: the corpse's motion belongs to the bodies now, not to the dead thing's old position. Each bodied joint stores its transform in its body's frame at activation, and every frame after, the pose is recovered by pushing the simulated body back through that offset. Joints with no body of their own ride their nearest bodied ancestor at rest pose -- which is what maxBodies actually buys: a coarse skeleton that behaves, instead of a fine one that jitters. Bone selection is breadth-first from the roots, so the budget is spent on the spine and the limbs -- the things whose motion you read -- and runs out before it reaches the fingers. The joint LIMITS are the whole difference between a corpse and a novelty. The first pass used generous ones and the bodies settled into a puddle; they are now +/-0.8 rad bend and +/-0.25 twist, because an over-twisted limb is the single most obviously wrong thing a ragdoll can do. Angular damping does the rest: at 0.75 the limbs stop windmilling after the thing lands. Adds constraint_six_dof_locked_translation (translation pinned, rotation limited -- a shoulder does not slide off the torso and an elbow does not bend backwards) and body_transform, since the existing getters hand back one axis at a time. 5 FFI symbols; validate-ffi clean. 2 new tests (rigid-inverse round trip, bone-frame orthonormality); 116/116 shared tests pass. --- native/shared/src/engine.rs | 6 + native/shared/src/ffi_core/mod.rs | 2 + native/shared/src/ffi_core/ragdoll_ffi.rs | 245 +++++++++++ native/shared/src/lib.rs | 2 + native/shared/src/models.rs | 6 + native/shared/src/physics_jolt.rs | 42 ++ native/shared/src/ragdoll.rs | 474 ++++++++++++++++++++++ native/watchos/src/ffi_stubs.rs | 7 + package.json | 25 ++ src/models/index.ts | 60 +++ tools/validate-ffi.js | 8 + 11 files changed, 877 insertions(+) create mode 100644 native/shared/src/ffi_core/ragdoll_ffi.rs create mode 100644 native/shared/src/ragdoll.rs diff --git a/native/shared/src/engine.rs b/native/shared/src/engine.rs index 58c3777..967faf6 100644 --- a/native/shared/src/engine.rs +++ b/native/shared/src/engine.rs @@ -37,6 +37,10 @@ pub struct EngineState { /// their own beyond the buffer handles they were given at creation. pub particles: crate::particles::ParticleManager, pub decals: crate::decals::DecalManager, + /// EN-025 — ragdoll slots. Bodies live in the Jolt world; this owns the + /// bone->body mapping that turns them back into a skinned pose. + #[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] + pub ragdolls: crate::ragdoll::RagdollManager, // Timing pub target_fps: f64, @@ -96,6 +100,8 @@ impl EngineState { drs: DrsController::new(), particles: crate::particles::ParticleManager::new(), decals: crate::decals::DecalManager::new(), + #[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] + ragdolls: crate::ragdoll::RagdollManager::new(), target_fps: 60.0, delta_time: 0.0, last_frame_time: now, diff --git a/native/shared/src/ffi_core/mod.rs b/native/shared/src/ffi_core/mod.rs index 5a9b3b3..98570e2 100644 --- a/native/shared/src/ffi_core/mod.rs +++ b/native/shared/src/ffi_core/mod.rs @@ -67,6 +67,7 @@ mod models; mod scene; mod visual; mod vfx; +mod ragdoll_ffi; /// Expand the full shared (non-physics) FFI surface. Composed from the /// per-subsystem section macros in this directory; platform crates invoke @@ -85,6 +86,7 @@ macro_rules! define_core_ffi { $crate::__bloom_ffi_scene!(); $crate::__bloom_ffi_visual!(); $crate::__bloom_ffi_vfx!(); + $crate::__bloom_ffi_ragdoll!(); }; } diff --git a/native/shared/src/ffi_core/ragdoll_ffi.rs b/native/shared/src/ffi_core/ragdoll_ffi.rs new file mode 100644 index 0000000..e59996d --- /dev/null +++ b/native/shared/src/ffi_core/ragdoll_ffi.rs @@ -0,0 +1,245 @@ +//! EN-025 — ragdoll FFI. +//! +//! Section of [`define_core_ffi!`](crate::define_core_ffi). +//! +//! The shape of this API is deliberate. A ragdoll is not something a game +//! *configures*; it is something a game *triggers*, once, at the moment of +//! death, and then forgets about. So: one call to build it from a skeleton the +//! engine already has, one call to fire it with the killing impulse, one call +//! per frame to pull the pose back out, one to put it away. +//! +//! Everything else — which bones get bodies, how the capsules are sized, how the +//! joints are limited, how the simulated bodies map back onto skinning matrices +//! — is the engine's problem, because getting any of it wrong produces a corpse +//! that looks *nearly* right, which is worse than none. + +#[doc(hidden)] +#[macro_export] +macro_rules! __bloom_ffi_ragdoll { + () => { + + // bloom_ragdoll_create — allocate a slot. Returns a 1-based handle. + #[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] + #[no_mangle] + pub extern "C" fn bloom_ragdoll_create() -> f64 { + $crate::ffi::guard("bloom_ragdoll_create", move || { + engine().ragdolls.create() as f64 + }) + } + #[cfg(not(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32"))))] + #[no_mangle] + pub extern "C" fn bloom_ragdoll_create() -> f64 { 0.0 } + + // bloom_ragdoll_activate [EN-025] + // + // Build the bodies from the model's CURRENT pose and let go. Call it on + // the frame the thing dies, with the same (scale, position, yaw) you + // last passed to bloom_anim_update — that transform is what bridges + // model space and world space, and it is frozen here: the corpse's + // motion belongs to the bodies now, not to the dead enemy's position. + // + // 8 args (the ARM64 ceiling): the impulse direction and magnitude are + // set separately by bloom_ragdoll_push, so the killing blow's shove is + // a distinct decision from the corpse's construction. + #[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] + #[no_mangle] + pub extern "C" fn bloom_ragdoll_activate( + rag: f64, anim: f64, world: f64, + scale: f64, px: f64, py: f64, pz: f64, rot_y: f64, + ) -> f64 { + $crate::ffi::guard("bloom_ragdoll_activate", move || { + let eng = engine(); + + let (builds, layer) = { + let Some(a) = eng.models.get_animation(anim) else { return 0.0 }; + // 12 bodies is the sweet spot for these skeletons: spine + + // limbs. Past that you start buying fingers, which cost + // solver time and buy jitter. + let builds = $crate::ragdoll::plan( + a, scale as f32, + [px as f32, py as f32, pz as f32], + rot_y as f32, + 12, // max bodies + // Chunkier capsules. Thin ones interpenetrate and let + // the corpse fold flat through itself; a limb should + // have some volume to rest ON. + 0.38, // capsule radius as a fraction of bone length + ); + (builds, 1u32) // MOVING layer + }; + if builds.is_empty() { return 0.0; } + + // --- bodies + let mut bodies: Vec = Vec::with_capacity(builds.len()); + for b in builds.iter() { + let shape = eng.jolt.create_capsule_shape(b.half_height, b.radius); + if shape == 0.0 { bodies.push(0.0); continue; } + // Quaternion from the capsule's world basis. + let q = $crate::ragdoll::quat_from_mat(&b.world); + let body = eng.jolt.create_body( + world, shape, + 2, // DYNAMIC + b.world[3][0], b.world[3][1], b.world[3][2], + q[0], q[1], q[2], q[3], + 0.0, 0.0, 0.0, // linear velocity + 0.0, 0.0, 0.0, // angular velocity + layer, + false, // sensor + true, // allow sleeping — corpses settle + false, // ccd: not worth it here + true, // start awake + 0.8, // friction: corpses do not skate + 0.02, // restitution: they do not bounce + // Angular damping does most of the work of making this + // read as a body rather than a rag: without it the limbs + // keep windmilling long after the thing has landed. + 0.20, 0.75, // lin / ang damping + 1.0, // gravity factor + 0.0, 0.0, 0.0, 0.0, // mass override + inertia + 0, + ); + bodies.push(body); + } + + // --- joints + // + // Tight-ish, and deliberately so. Generous limits let the limbs + // splay and the corpse settles into a puddle rather than a body; + // the first pass used ±1.2 rad and looked exactly like that. + // Twist (y) is held hardest, because an over-twisted limb is the + // single most obviously WRONG thing a ragdoll can do. + let rot_limits: [f32; 6] = [ + -0.8, 0.8, // x — bend + -0.25, 0.25, // y — twist + -0.8, 0.8, // z — bend + ]; + let mut constraints: Vec = Vec::new(); + for (i, b) in builds.iter().enumerate() { + if b.parent_bone == usize::MAX { continue; } + let pa = bodies.get(b.parent_bone).copied().unwrap_or(0.0); + let pb = bodies.get(i).copied().unwrap_or(0.0); + if pa == 0.0 || pb == 0.0 { continue; } + let c = eng.jolt.constraint_six_dof_locked_translation( + pa, pb, + b.anchor[0], b.anchor[1], b.anchor[2], + b.anchor[0], b.anchor[1], b.anchor[2], + rot_limits, + true, // anchors given in world space + ); + if c != 0.0 { constraints.push(c); } + } + + let Some(a) = eng.models.get_animation(anim) else { return 0.0 }; + let Some(r) = eng.ragdolls.get_mut(rag as u32) else { return 0.0 }; + r.attach(&builds, &bodies, constraints, a, + scale as f32, [px as f32, py as f32, pz as f32], rot_y as f32); + 1.0 + }) + } + #[cfg(not(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32"))))] + #[no_mangle] + pub extern "C" fn bloom_ragdoll_activate(_r: f64, _a: f64, _w: f64, _s: f64, _x: f64, _y: f64, _z: f64, _ry: f64) -> f64 { 0.0 } + + // bloom_ragdoll_push — the killing blow. Applied to every body, so the + // whole corpse is thrown rather than one limb being yanked off. + #[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] + #[no_mangle] + pub extern "C" fn bloom_ragdoll_push(rag: f64, dx: f64, dy: f64, dz: f64, impulse: f64) { + $crate::ffi::guard("bloom_ragdoll_push", move || { + let eng = engine(); + let Some(r) = eng.ragdolls.get(rag as u32) else { return }; + if !r.active { return } + let bodies = r.bodies(); + if bodies.is_empty() { return } + // Spread the impulse over the bodies so a 12-bone corpse and a + // 4-bone one take off at the same speed. + let per = (impulse as f32) / (bodies.len() as f32); + for b in bodies { + eng.jolt.body_add_impulse( + b, dx as f32 * per, dy as f32 * per, dz as f32 * per); + } + }) + } + #[cfg(not(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32"))))] + #[no_mangle] + pub extern "C" fn bloom_ragdoll_push(_r: f64, _x: f64, _y: f64, _z: f64, _i: f64) {} + + // bloom_ragdoll_update — read the simulated bodies back into the model's + // joint matrices and upload them. Call once per frame per active + // ragdoll, then drawModel() as usual. `dt` only ages the ragdoll (the + // physics world is stepped by the game). + #[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] + #[no_mangle] + pub extern "C" fn bloom_ragdoll_update(rag: f64, anim: f64, dt: f64) -> f64 { + $crate::ffi::guard("bloom_ragdoll_update", move || { + let eng = engine(); + + let (bodies, scale, pos, rot) = { + let Some(r) = eng.ragdolls.get(rag as u32) else { return 0.0 }; + if !r.active { return 0.0 } + let (s, p, ry) = r.upload_params(); + (r.bodies(), s, p, ry) + }; + + let mut world: Vec<[[f32; 4]; 4]> = Vec::with_capacity(bodies.len()); + for b in bodies.iter() { + match eng.jolt.body_transform(*b) { + Some((p, q)) => world.push($crate::ragdoll::from_pos_quat(p, q)), + None => world.push([[1.0,0.0,0.0,0.0],[0.0,1.0,0.0,0.0],[0.0,0.0,1.0,0.0],[0.0,0.0,0.0,1.0]]), + } + } + + { + let Some(r) = eng.ragdolls.get_mut(rag as u32) else { return 0.0 }; + r.age += dt as f32; + } + let age = eng.ragdolls.get(rag as u32).map(|r| r.age).unwrap_or(0.0); + + // Split borrow: apply() needs &mut ModelAnimation and &Ragdoll. + let ragdoll_ptr: *const $crate::ragdoll::Ragdoll = + match eng.ragdolls.get(rag as u32) { Some(r) => r, None => return 0.0 }; + if let Some(a) = eng.models.get_animation_mut(anim) { + unsafe { (*ragdoll_ptr).apply(a, &world) }; + } + + if let Some(a) = eng.models.get_animation(anim) { + if !a.joint_matrices.is_empty() { + let (s, c) = (rot.sin(), rot.cos()); + eng.renderer.set_joint_matrices_scaled( + &a.joint_matrices, scale, pos, s, c); + } + } + age as f64 + }) + } + #[cfg(not(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32"))))] + #[no_mangle] + pub extern "C" fn bloom_ragdoll_update(_r: f64, _a: f64, _d: f64) -> f64 { 0.0 } + + // bloom_ragdoll_release — destroy the bodies and constraints, free the + // slot for reuse. A pooled ragdoll that is never released leaks bodies + // into the physics world, which is a slow, invisible death. + #[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] + #[no_mangle] + pub extern "C" fn bloom_ragdoll_release(rag: f64) { + $crate::ffi::guard("bloom_ragdoll_release", move || { + let eng = engine(); + let (bodies, cons) = { + let Some(r) = eng.ragdolls.get(rag as u32) else { return }; + (r.bodies(), r.constraint_handles().to_vec()) + }; + // Constraints first: destroying a body out from under a live + // constraint is how you get a use-after-free in the solver. + for c in cons { eng.jolt.constraint_destroy(c); } + for b in bodies { eng.jolt.destroy_body(b); } + if let Some(r) = eng.ragdolls.get_mut(rag as u32) { + *r = $crate::ragdoll::Ragdoll::new(); + } + }) + } + #[cfg(not(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32"))))] + #[no_mangle] + pub extern "C" fn bloom_ragdoll_release(_r: f64) {} + + }; +} diff --git a/native/shared/src/lib.rs b/native/shared/src/lib.rs index 636b2c7..570379f 100644 --- a/native/shared/src/lib.rs +++ b/native/shared/src/lib.rs @@ -26,6 +26,8 @@ pub mod staging; pub mod profiler; pub mod particles; pub mod decals; +#[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] +pub mod ragdoll; pub mod sdf_cache; // Jolt C ABI + Rust wrapper live on native only. On wasm32 the web crate // routes bloom_physics_* calls through wasm_bindgen to JoltPhysics.js; diff --git a/native/shared/src/models.rs b/native/shared/src/models.rs index 1fcdca3..f287641 100644 --- a/native/shared/src/models.rs +++ b/native/shared/src/models.rs @@ -245,6 +245,12 @@ impl ModelManager { } } + /// EN-025 — the ragdoll writes joint matrices directly, bypassing the + /// sampler entirely: once a thing is dead, physics owns its pose. + pub fn get_animation_mut(&mut self, handle: f64) -> Option<&mut ModelAnimation> { + self.animations.get_mut(handle) + } + pub fn get_animation(&self, handle: f64) -> Option<&ModelAnimation> { self.animations.get(handle) } diff --git a/native/shared/src/physics_jolt.rs b/native/shared/src/physics_jolt.rs index 1c4a050..1b2258d 100644 --- a/native/shared/src/physics_jolt.rs +++ b/native/shared/src/physics_jolt.rs @@ -940,6 +940,48 @@ impl JoltPhysics { if c == BJ_INVALID { 0.0 } else { self.constraints.alloc((w, c)) } } + /// EN-025 — six-DOF, which is what a ragdoll joint actually is: translation + /// locked (a shoulder does not slide off the torso) and rotation limited (an + /// elbow does not bend backwards). An unlimited ball joint is cheaper and + /// gives you spaghetti; the limits are the whole difference between a corpse + /// and a novelty. + /// + /// `rot_limits` is [xmin, xmax, ymin, ymax, zmin, zmax] in radians. + #[allow(clippy::too_many_arguments)] + pub fn constraint_six_dof_locked_translation( + &mut self, body_a: f64, body_b: f64, + ax: f32, ay: f32, az: f32, bx: f32, by: f32, bz: f32, + rot_limits: [f32; 6], world_space: bool, + ) -> f64 { + let (w, anchors) = match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { + Some(v) => v, None => return 0.0, + }; + // min >= max means LOCKED (see the shim header), so this pins all three + // translation axes. + let trans: [f32; 6] = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0]; + let c = unsafe { + bj_constraint_six_dof(w, &anchors, trans.as_ptr(), rot_limits.as_ptr()) + }; + if c == BJ_INVALID { 0.0 } else { self.constraints.alloc((w, c)) } + } + + /// Body world transform as (position, quaternion) — the ragdoll needs the + /// full frame, and the existing getters hand back one axis at a time. + pub fn body_transform(&self, h: f64) -> Option<([f32; 3], [f32; 4])> { + let p = [ + self.body_get_position_axis(h, 0) as f32, + self.body_get_position_axis(h, 1) as f32, + self.body_get_position_axis(h, 2) as f32, + ]; + let q = [ + self.body_get_rotation_axis(h, 0) as f32, + self.body_get_rotation_axis(h, 1) as f32, + self.body_get_rotation_axis(h, 2) as f32, + self.body_get_rotation_axis(h, 3) as f32, + ]; + Some((p, q)) + } + #[allow(clippy::too_many_arguments)] pub fn constraint_hinge( &mut self, body_a: f64, body_b: f64, diff --git a/native/shared/src/ragdoll.rs b/native/shared/src/ragdoll.rs new file mode 100644 index 0000000..320cf2a --- /dev/null +++ b/native/shared/src/ragdoll.rs @@ -0,0 +1,474 @@ +//! EN-025 — ragdolls. +//! +//! Jolt has shipped `Ragdoll.cpp` in the vendored submodule since day one; what +//! was missing was any way to reach it. This is that. It does NOT use Jolt's +//! `Ragdoll` class, though — that wants a `RagdollSettings` asset authored +//! alongside the skeleton, and we don't have one. Instead it builds the ragdoll +//! from the skeleton we DO have, at runtime: one capsule per bone, one limited +//! six-DOF joint per articulation. Same result, no new authoring step, and it +//! works for any skinned model the game already loads. +//! +//! # The coordinate problem, and how it is solved +//! +//! Physics runs in world space. Skinning runs in model space. The bridge is the +//! model transform `M` (scale, position, yaw) the game was drawing with at the +//! moment of death — and the key decision here is that **`M` is frozen at +//! activation**. The corpse does not follow the enemy's old position, because +//! the enemy is gone; the bodies carry the motion now, and `M` is only a fixed +//! frame to express them in. +//! +//! So for each bodied joint we store, at activation, the joint's transform in +//! the *body's* frame: +//! +//! ```text +//! offset_j = (M⁻¹ · B_j(0))⁻¹ · jointWorld_j(0) +//! ``` +//! +//! and every frame after, recover the pose by pushing the simulated body back +//! through it: +//! +//! ```text +//! jointWorld_j(t) = (M⁻¹ · B_j(t)) · offset_j +//! ``` +//! +//! Joints with no body of their own (fingers, tails, the long chains that would +//! cost bodies and buy nothing) simply ride their nearest bodied ancestor at +//! their rest pose. That is what `maxBodies` is really buying: a coarse +//! skeleton that behaves, instead of a fine one that jitters. + +use crate::models::{ModelAnimation, SkeletonData}; + +/// One simulated bone. +struct Bone { + /// Joint this body drives. + joint: usize, + /// Physics body handle (as the f64 the physics registry uses). + body: f64, + /// Joint transform expressed in the body's frame — see the module note. + offset: [[f32; 4]; 4], +} + +pub struct Ragdoll { + pub active: bool, + bones: Vec, + constraints: Vec, + /// Model transform frozen at activation, and its inverse. + model: [[f32; 4]; 4], + inv_model: [[f32; 4]; 4], + /// The (scale, pos, rot) that `model` was built from — the joint upload + /// needs them again in exactly that form. + scale: f32, + pos: [f32; 3], + rot: f32, + /// Seconds since activation, so the game can settle/despawn on a timer. + pub age: f32, +} + +impl Ragdoll { + pub fn new() -> Self { + Self { + active: false, + bones: Vec::new(), + constraints: Vec::new(), + model: mat4_identity(), + inv_model: mat4_identity(), + scale: 1.0, + pos: [0.0; 3], + rot: 0.0, + age: 0.0, + } + } +} + +/// Which joints get a body. +/// +/// Breadth-first from the roots, taking any joint whose bone (parent → self) is +/// long enough to be worth simulating, until `max_bodies` is reached. BFS is the +/// point: it spends the budget on the spine and the limbs — the things whose +/// motion you actually read — and runs out before it reaches the fingers. +fn select_bones(skel: &SkeletonData, min_len: f32, max_bodies: usize, + joint_world: &[[[f32; 4]; 4]]) -> Vec { + let n = skel.joints.len(); + let mut parent = vec![usize::MAX; n]; + for (i, j) in skel.joints.iter().enumerate() { + for &c in &j.children { + if c < n { parent[c] = i; } + } + } + + let mut order: Vec = Vec::new(); + let mut queue: std::collections::VecDeque = skel.root_joints.iter().copied().collect(); + while let Some(j) = queue.pop_front() { + order.push(j); + for &c in &skel.joints[j].children { + if c < n { queue.push_back(c); } + } + } + + let mut picked = Vec::new(); + for &j in &order { + if picked.len() >= max_bodies { break; } + let p = parent[j]; + if p == usize::MAX { continue; } // roots carry no bone + let a = translation(&joint_world[p]); + let b = translation(&joint_world[j]); + let len = dist(a, b); + if len >= min_len { + picked.push(j); + } + } + picked +} + +pub struct RagdollBuild { + pub joint: usize, + /// Capsule half-height and radius, in WORLD units (model scale applied). + pub half_height: f32, + pub radius: f32, + /// World transform of the capsule at activation (column-major 4x4). + pub world: [[f32; 4]; 4], + /// Parent bone index within the build list, or usize::MAX for none. + pub parent_bone: usize, + /// World-space anchor for the joint to the parent bone. + pub anchor: [f32; 3], +} + +/// Compute everything the physics side needs to create the bodies. Kept separate +/// from body creation so this module never has to know about JoltPhysics — the +/// FFI layer owns that, and this stays testable. +#[allow(clippy::too_many_arguments)] +pub fn plan( + anim: &ModelAnimation, + scale: f32, pos: [f32; 3], rot: f32, + max_bodies: usize, + radius_scale: f32, +) -> Vec { + let Some(skel) = &anim.skeleton else { return Vec::new() }; + if anim.joint_world.len() != skel.joints.len() { return Vec::new() } + + let model = compose(scale, pos, rot); + let picked = select_bones(skel, 0.06, max_bodies, &anim.joint_world); + + // joint -> index in `picked`, so a bone can find its parent bone. + let mut bone_of = vec![usize::MAX; skel.joints.len()]; + for (bi, &j) in picked.iter().enumerate() { bone_of[j] = bi; } + + let n = skel.joints.len(); + let mut parent = vec![usize::MAX; n]; + for (i, j) in skel.joints.iter().enumerate() { + for &c in &j.children { if c < n { parent[c] = i; } } + } + + let mut out = Vec::with_capacity(picked.len()); + for &j in &picked { + let p = parent[j]; + let a_local = translation(&anim.joint_world[p]); + let b_local = translation(&anim.joint_world[j]); + + // World-space endpoints of the bone. + let a = xform_point(&model, a_local); + let b = xform_point(&model, b_local); + let len = dist(a, b); + if len < 1e-4 { continue; } + + // Capsule spans the bone: centre at the midpoint, +Y down its length + // (Jolt capsules are Y-aligned), radius a fraction of the length. + let mid = [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5]; + let dir = normalize([b[0] - a[0], b[1] - a[1], b[2] - a[2]]); + let radius = (len * radius_scale).clamp(0.02, len * 0.45); + let half_height = (len * 0.5 - radius).max(0.01); + + // Rotation taking +Y onto the bone direction. + let world = compose_from_dir(mid, dir); + + // Find the nearest ANCESTOR that has a body — not necessarily the + // immediate parent joint, since that one may not have been picked. + let mut anc = p; + while anc != usize::MAX && bone_of[anc] == usize::MAX { + anc = parent[anc]; + } + let parent_bone = if anc == usize::MAX { usize::MAX } else { bone_of[anc] }; + + out.push(RagdollBuild { + joint: j, + half_height, + radius, + world, + parent_bone, + // Joint sits at the bone's proximal end — that IS the articulation. + anchor: a, + }); + } + out +} + +impl Ragdoll { + /// Called by the FFI once the bodies exist. `bodies[i]` pairs with + /// `builds[i]`. + pub fn attach(&mut self, builds: &[RagdollBuild], bodies: &[f64], + constraints: Vec, + anim: &ModelAnimation, + scale: f32, pos: [f32; 3], rot: f32) { + self.model = compose(scale, pos, rot); + self.inv_model = invert_rigid(&self.model); + self.scale = scale; + self.pos = pos; + self.rot = rot; + self.constraints = constraints; + self.bones.clear(); + self.age = 0.0; + + for (i, b) in builds.iter().enumerate() { + if i >= bodies.len() || bodies[i] == 0.0 { continue; } + // offset = (M⁻¹ · B(0))⁻¹ · jointWorld(0) + let body_model = mat4_mul(&self.inv_model, &b.world); + let offset = mat4_mul(&invert_rigid(&body_model), &anim.joint_world[b.joint]); + self.bones.push(Bone { joint: b.joint, body: bodies[i], offset }); + } + self.active = true; + } + + pub fn bodies(&self) -> Vec { self.bones.iter().map(|b| b.body).collect() } + pub fn constraint_handles(&self) -> &[f64] { &self.constraints } + + pub fn upload_params(&self) -> (f32, [f32; 3], f32) { (self.scale, self.pos, self.rot) } + + /// Rebuild `anim.joint_matrices` from the simulated bodies. + /// `body_world[i]` is the world transform of `self.bones[i]`'s body. + pub fn apply(&self, anim: &mut ModelAnimation, body_world: &[[[f32; 4]; 4]]) { + let Some(skel) = &anim.skeleton else { return }; + let n = skel.joints.len(); + if anim.joint_world.len() != n { return } + + // 1. Bodied joints: push the body back through the stored offset. + let mut have = vec![false; n]; + let mut world = vec![mat4_identity(); n]; + for (i, bone) in self.bones.iter().enumerate() { + if i >= body_world.len() { break; } + let body_model = mat4_mul(&self.inv_model, &body_world[i]); + world[bone.joint] = mat4_mul(&body_model, &bone.offset); + have[bone.joint] = true; + } + + // 2. Everything else rides its parent at the rest pose. Walk from the + // roots so a parent is always resolved before its children. + let mut stack: Vec<(usize, [[f32; 4]; 4])> = + skel.root_joints.iter().map(|&r| (r, mat4_identity())).collect(); + while let Some((j, parent_world)) = stack.pop() { + if j >= n { continue; } + if !have[j] { + let local = mat4_from_trs( + &skel.joints[j].rest_translation, + &skel.joints[j].rest_rotation, + &skel.joints[j].rest_scale, + ); + world[j] = mat4_mul(&parent_world, &local); + } + for &c in &skel.joints[j].children { + stack.push((c, world[j])); + } + } + + for i in 0..n { + anim.joint_matrices[i] = mat4_mul(&world[i], &skel.joints[i].inverse_bind); + } + anim.joint_world.copy_from_slice(&world); + } +} + +// ---- registry ---------------------------------------------------------------- + +pub struct RagdollManager { + pub slots: Vec>, +} + +impl RagdollManager { + pub fn new() -> Self { Self { slots: Vec::new() } } + pub fn create(&mut self) -> u32 { + self.slots.push(Some(Ragdoll::new())); + self.slots.len() as u32 + } + pub fn get_mut(&mut self, h: u32) -> Option<&mut Ragdoll> { + if h == 0 { return None } + self.slots.get_mut(h as usize - 1)?.as_mut() + } + pub fn get(&self, h: u32) -> Option<&Ragdoll> { + if h == 0 { return None } + self.slots.get(h as usize - 1)?.as_ref() + } +} + +impl Default for RagdollManager { + fn default() -> Self { Self::new() } +} + +// ---- math -------------------------------------------------------------------- + +fn mat4_identity() -> [[f32; 4]; 4] { + [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]] +} + +fn mat4_mul(a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]) -> [[f32; 4]; 4] { + let mut o = [[0.0f32; 4]; 4]; + for col in 0..4 { + for row in 0..4 { + o[col][row] = a[0][row] * b[col][0] + a[1][row] * b[col][1] + + a[2][row] * b[col][2] + a[3][row] * b[col][3]; + } + } + o +} + +fn translation(m: &[[f32; 4]; 4]) -> [f32; 3] { [m[3][0], m[3][1], m[3][2]] } + +fn xform_point(m: &[[f32; 4]; 4], p: [f32; 3]) -> [f32; 3] { + [ + m[0][0] * p[0] + m[1][0] * p[1] + m[2][0] * p[2] + m[3][0], + m[0][1] * p[0] + m[1][1] * p[1] + m[2][1] * p[2] + m[3][1], + m[0][2] * p[0] + m[1][2] * p[1] + m[2][2] * p[2] + m[3][2], + ] +} + +fn dist(a: [f32; 3], b: [f32; 3]) -> f32 { + let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() +} + +fn normalize(v: [f32; 3]) -> [f32; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + if l > 1e-6 { [v[0] / l, v[1] / l, v[2] / l] } else { [0.0, 1.0, 0.0] } +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +/// Model transform: translate · rotateY · scale. +pub fn compose(scale: f32, pos: [f32; 3], rot: f32) -> [[f32; 4]; 4] { + let (s, c) = (rot.sin(), rot.cos()); + [ + [c * scale, 0.0, -s * scale, 0.0], + [0.0, scale, 0.0, 0.0], + [s * scale, 0.0, c * scale, 0.0], + [pos[0], pos[1], pos[2], 1.0], + ] +} + +/// A rigid frame whose +Y points along `dir`, centred at `p`. +fn compose_from_dir(p: [f32; 3], dir: [f32; 3]) -> [[f32; 4]; 4] { + let up = dir; + // Any axis not parallel to `up` works as the seed for the basis. + let seed = if up[1].abs() > 0.99 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] }; + let right = normalize(cross(seed, up)); + let fwd = cross(up, right); + [ + [right[0], right[1], right[2], 0.0], + [up[0], up[1], up[2], 0.0], + [fwd[0], fwd[1], fwd[2], 0.0], + [p[0], p[1], p[2], 1.0], + ] +} + +/// Inverse of a transform with uniform scale + rotation + translation. +/// (A general inverse would be wasted here and less numerically pleasant.) +fn invert_rigid(m: &[[f32; 4]; 4]) -> [[f32; 4]; 4] { + // Recover uniform scale from the first basis vector. + let s2 = m[0][0] * m[0][0] + m[0][1] * m[0][1] + m[0][2] * m[0][2]; + let s = s2.sqrt().max(1e-8); + let inv_s = 1.0 / s; + // R⁻¹ = Rᵀ, with the scale divided out twice (once for the transpose's own + // scale, once for the inverse scale). + let r = [ + [m[0][0] * inv_s * inv_s, m[1][0] * inv_s * inv_s, m[2][0] * inv_s * inv_s], + [m[0][1] * inv_s * inv_s, m[1][1] * inv_s * inv_s, m[2][1] * inv_s * inv_s], + [m[0][2] * inv_s * inv_s, m[1][2] * inv_s * inv_s, m[2][2] * inv_s * inv_s], + ]; + let t = [m[3][0], m[3][1], m[3][2]]; + let it = [ + -(r[0][0] * t[0] + r[1][0] * t[1] + r[2][0] * t[2]), + -(r[0][1] * t[0] + r[1][1] * t[1] + r[2][1] * t[2]), + -(r[0][2] * t[0] + r[1][2] * t[1] + r[2][2] * t[2]), + ]; + [ + [r[0][0], r[0][1], r[0][2], 0.0], + [r[1][0], r[1][1], r[1][2], 0.0], + [r[2][0], r[2][1], r[2][2], 0.0], + [it[0], it[1], it[2], 1.0], + ] +} + +fn mat4_from_trs(t: &[f32; 3], q: &[f32; 4], s: &[f32; 3]) -> [[f32; 4]; 4] { + let (x, y, z, w) = (q[0], q[1], q[2], q[3]); + let (x2, y2, z2) = (x + x, y + y, z + z); + let (xx, xy, xz) = (x * x2, x * y2, x * z2); + let (yy, yz, zz) = (y * y2, y * z2, z * z2); + let (wx, wy, wz) = (w * x2, w * y2, w * z2); + [ + [(1.0 - (yy + zz)) * s[0], (xy + wz) * s[0], (xz - wy) * s[0], 0.0], + [(xy - wz) * s[1], (1.0 - (xx + zz)) * s[1], (yz + wx) * s[1], 0.0], + [(xz + wy) * s[2], (yz - wx) * s[2], (1.0 - (xx + yy)) * s[2], 0.0], + [t[0], t[1], t[2], 1.0], + ] +} + +/// Build a world transform from a physics body's position + quaternion. +pub fn from_pos_quat(p: [f32; 3], q: [f32; 4]) -> [[f32; 4]; 4] { + mat4_from_trs(&p, &q, &[1.0, 1.0, 1.0]) +} + +/// Quaternion (x, y, z, w) from an orthonormal rotation matrix. Shepperd's +/// method — pick the largest diagonal term so the division never blows up on a +/// 180° rotation, which the naive `w = sqrt(1+trace)/2` form does. +pub fn quat_from_mat(m: &[[f32; 4]; 4]) -> [f32; 4] { + let (m00, m01, m02) = (m[0][0], m[0][1], m[0][2]); + let (m10, m11, m12) = (m[1][0], m[1][1], m[1][2]); + let (m20, m21, m22) = (m[2][0], m[2][1], m[2][2]); + let trace = m00 + m11 + m22; + + if trace > 0.0 { + let s = (trace + 1.0).sqrt() * 2.0; + [(m12 - m21) / s, (m20 - m02) / s, (m01 - m10) / s, 0.25 * s] + } else if m00 > m11 && m00 > m22 { + let s = (1.0 + m00 - m11 - m22).sqrt() * 2.0; + [0.25 * s, (m10 + m01) / s, (m20 + m02) / s, (m12 - m21) / s] + } else if m11 > m22 { + let s = (1.0 + m11 - m00 - m22).sqrt() * 2.0; + [(m10 + m01) / s, 0.25 * s, (m21 + m12) / s, (m20 - m02) / s] + } else { + let s = (1.0 + m22 - m00 - m11).sqrt() * 2.0; + [(m20 + m02) / s, (m21 + m12) / s, 0.25 * s, (m01 - m10) / s] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invert_rigid_round_trips() { + let m = compose(2.5, [1.0, -3.0, 4.0], 0.7); + let i = invert_rigid(&m); + let id = mat4_mul(&m, &i); + for c in 0..4 { + for r in 0..4 { + let want = if c == r { 1.0 } else { 0.0 }; + assert!((id[c][r] - want).abs() < 1e-3, + "M·M⁻¹ is not identity at [{c}][{r}]: {}", id[c][r]); + } + } + } + + #[test] + fn compose_from_dir_points_y_along_the_bone() { + let dir = normalize([0.3, -0.9, 0.2]); + let m = compose_from_dir([1.0, 2.0, 3.0], dir); + // Column 1 is the +Y basis vector. + assert!((m[1][0] - dir[0]).abs() < 1e-5); + assert!((m[1][1] - dir[1]).abs() < 1e-5); + assert!((m[1][2] - dir[2]).abs() < 1e-5); + // And the frame stays orthonormal, or the capsule shears. + let right = [m[0][0], m[0][1], m[0][2]]; + let dot = right[0] * dir[0] + right[1] * dir[1] + right[2] * dir[2]; + assert!(dot.abs() < 1e-5, "basis is not orthogonal: {dot}"); + } +} diff --git a/native/watchos/src/ffi_stubs.rs b/native/watchos/src/ffi_stubs.rs index 1094a04..99dc3d3 100644 --- a/native/watchos/src/ffi_stubs.rs +++ b/native/watchos/src/ffi_stubs.rs @@ -853,3 +853,10 @@ #[no_mangle] pub extern "C" fn bloom_create_texture_array_from_files(_p0: i64, _p1: f64, _p2: f64) -> f64 { 0.0 } + +// EN-025 — ragdolls (no 3D/physics on watchOS). +#[no_mangle] pub extern "C" fn bloom_ragdoll_create() -> f64 { 0.0 } +#[no_mangle] pub extern "C" fn bloom_ragdoll_activate(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64, _p7: f64) -> f64 { 0.0 } +#[no_mangle] pub extern "C" fn bloom_ragdoll_push(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64) {} +#[no_mangle] pub extern "C" fn bloom_ragdoll_update(_p0: f64, _p1: f64, _p2: f64) -> f64 { 0.0 } +#[no_mangle] pub extern "C" fn bloom_ragdoll_release(_p0: f64) {} diff --git a/package.json b/package.json index 6b034cd..690f328 100644 --- a/package.json +++ b/package.json @@ -1217,6 +1217,31 @@ "params": ["string", "f64", "f64"], "returns": "f64" }, + { + "name": "bloom_ragdoll_create", + "params": [], + "returns": "f64" + }, + { + "name": "bloom_ragdoll_activate", + "params": ["f64", "f64", "f64", "f64", "f64", "f64", "f64", "f64"], + "returns": "f64" + }, + { + "name": "bloom_ragdoll_push", + "params": ["f64", "f64", "f64", "f64", "f64"], + "returns": "void" + }, + { + "name": "bloom_ragdoll_update", + "params": ["f64", "f64", "f64"], + "returns": "f64" + }, + { + "name": "bloom_ragdoll_release", + "params": ["f64"], + "returns": "void" + }, { "name": "bloom_particles_create", "params": ["f64"], diff --git a/src/models/index.ts b/src/models/index.ts index 20e4857..24e6ee2 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1044,3 +1044,63 @@ export function drawImposterAtlas( 1.0, 1.0, 1.0, 1.0, ); } + +// ---- EN-025: ragdolls ------------------------------------------------------- +// +// A ragdoll is not something you configure; it is something you TRIGGER, once, +// at the moment of death, and then forget about. So the API is four calls: +// build it, shove it, read the pose, put it away. +// +// The engine builds the bodies from the skeleton it already has — one capsule +// per bone, one limited six-DOF joint per articulation — so there is no ragdoll +// asset to author and it works for any skinned model the game loads. + +declare function bloom_ragdoll_create(): number; +declare function bloom_ragdoll_activate(rag: number, anim: number, world: number, scale: number, px: number, py: number, pz: number, rotY: number): number; +declare function bloom_ragdoll_push(rag: number, dx: number, dy: number, dz: number, impulse: number): void; +declare function bloom_ragdoll_update(rag: number, anim: number, dt: number): number; +declare function bloom_ragdoll_release(rag: number): void; + +/// Allocate a ragdoll slot. Pool these — one per corpse you intend to have on +/// screen at once — and reuse them; a slot is cheap, but the bodies it holds +/// are not. +export function createRagdoll(): number { + return bloom_ragdoll_create(); +} + +/// Build the bodies from the model's CURRENT pose and let go. +/// +/// Pass the same `(scale, position, yaw)` you last handed `animUpdate`. That +/// transform is the bridge between model space (where skinning lives) and world +/// space (where physics lives), and it is FROZEN here — from now on the corpse's +/// motion belongs to the bodies, not to the dead thing's old position. +/// +/// Returns false if the model has no skeleton, or no bones long enough to +/// simulate. +export function activateRagdoll( + rag: number, anim: number, world: number, + scale: number, px: number, py: number, pz: number, rotY: number, +): boolean { + return bloom_ragdoll_activate(rag, anim, world, scale, px, py, pz, rotY) !== 0; +} + +/// The killing blow. Applied across all the bodies, so the corpse is thrown as +/// a whole rather than having one limb yanked off. +export function pushRagdoll(rag: number, dx: number, dy: number, dz: number, impulse: number): void { + bloom_ragdoll_push(rag, dx, dy, dz, impulse); +} + +/// Pull the simulated pose back into the model's joint matrices and upload it. +/// Call once per frame per active ragdoll, then `drawModel()` as usual — no +/// `animUpdate`, because physics owns the pose now. Returns the ragdoll's age in +/// seconds, which is what you settle and despawn on. +export function updateRagdoll(rag: number, anim: number, dt: number): number { + return bloom_ragdoll_update(rag, anim, dt); +} + +/// Destroy the bodies and constraints and free the slot for reuse. A pooled +/// ragdoll that is never released leaks bodies into the physics world — a slow, +/// invisible death. +export function releaseRagdoll(rag: number): void { + bloom_ragdoll_release(rag); +} diff --git a/tools/validate-ffi.js b/tools/validate-ffi.js index 8ec1a6c..8e7202a 100644 --- a/tools/validate-ffi.js +++ b/tools/validate-ffi.js @@ -172,6 +172,14 @@ for (const platform of PLATFORMS) { // EN-014 V3 — decodes image files from disk; wasm has no fs. The // byte-array path (bloom_create_texture_array_ex) is the web route. 'bloom_create_texture_array_from_files', + // EN-025 — ragdolls are built on the native Jolt Rust wrapper + // (physics_jolt.rs). Web routes bloom_physics_* through JoltPhysics.js + // instead, so there is no Rust-side world to create bodies in. + 'bloom_ragdoll_create', + 'bloom_ragdoll_activate', + 'bloom_ragdoll_push', + 'bloom_ragdoll_update', + 'bloom_ragdoll_release', 'bloom_scene_set_lod', // Perry-WASM linear-memory bridge TODO 'bloom_take_screenshot', // no fs — needs a bytes-returning design 'bloom_set_env_clear_from_hdr', // no fs — needs a _bytes variant From 176bb83b18fb23845cc0937db4b595cfa0570079 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 14:03:36 +0200 Subject: [PATCH 06/17] =?UTF-8?q?feat(EN-040):=20one=20cloud=20deck=20?= =?UTF-8?q?=E2=80=94=20the=20sky's=20clouds=20and=20the=20ground's=20shado?= =?UTF-8?q?ws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These were two unrelated noise fields that had never been reconciled, and the result does not survive looking at: - The sky's puffs were fBm over a plane pinned to the CAMERA, so they slid along with the player instead of hanging over the world. - The ground's "cloud shadow" was a DIFFERENT, much finer field on a different noise, drifting ~80x faster (20 m/s against the sky's 0.25). So the shadow racing across the grass had no cloud above it, and the cloud overhead cast nothing. Worse, only materials that happened to carry a copy of the ground function darkened at all -- a shadow could cross a field and ignore the forest standing in the middle of it, because the built-in scene shader had no cloud term whatsoever. common/clouds.wgsl is now the single field, in WORLD space. The deck is a plane at a given height; a view ray and a sun ray are both intersected with it, and whatever they hit is the same cloud. Look up at a cloud, and the shadow you are standing in is its shadow. THE COUPLING IS THE FEATURE. deck_height and feature_scale are not independent knobs for "cloud size overhead" and "shadow size underfoot" -- they are the same cloud seen from two directions. Sky puff size = (deck - eye) x scale. Raising the deck shrinks the puffs overhead without changing their shadows; lowering the scale grows the cloud both above and below. Callers who want to tune one WILL reach for the other, and the doc comment says so. The shadow multiplies DIRECT sun only. Folding it into ambient as well is what makes cloud shadows read as flat grey paint instead of shade -- a cloud blocks the sun, it does not stop the sky from being blue. Off by default (strength 0): the sky still draws clouds, but silently darkening every existing game's terrain is not the engine's call. set_cloud_shadows opts a world in. Drift direction comes from set_wind, so the deck travels the way the foliage beneath it is leaning. The shader is prepended verbatim to SCENE_SHADER and PROCEDURAL_SKY_SHADER_WGSL (raw consts, no preprocessor) and #include-able by materials -- one file either way, so they cannot drift apart again. Sky uniforms grow the camera position (packed into the spare .w lanes of the basis vectors -- a sky at infinity never needed it; a world-anchored deck does) and the cloud params. 1 FFI symbol; validate-ffi clean (6 pre-existing watchos/web gaps unchanged). 116/116 shared tests pass. --- native/shared/shaders/common/clouds.wgsl | 122 ++++++++++++++++++ native/shared/shaders/material_abi.wgsl | 7 + native/shared/src/ffi_core/visual.rs | 16 +++ .../shared/src/renderer/material_pipeline.rs | 1 + native/shared/src/renderer/material_system.rs | 4 + .../src/renderer/material_system_tests.rs | 2 + native/shared/src/renderer/mod.rs | 96 ++++++++++++-- native/shared/src/renderer/shader_library.rs | 1 + native/shared/src/renderer/shaders/core.rs | 20 ++- native/shared/src/renderer/shaders/env.rs | 72 ++++------- native/shared/src/renderer/types.rs | 6 + native/watchos/src/ffi_stubs.rs | 2 + native/web/src/lib.rs | 5 + package.json | 10 ++ src/core/index.ts | 28 ++++ 15 files changed, 332 insertions(+), 60 deletions(-) create mode 100644 native/shared/shaders/common/clouds.wgsl diff --git a/native/shared/shaders/common/clouds.wgsl b/native/shared/shaders/common/clouds.wgsl new file mode 100644 index 0000000..3264952 --- /dev/null +++ b/native/shared/shaders/common/clouds.wgsl @@ -0,0 +1,122 @@ +// One cloud deck — shared by the sky that draws the clouds and the ground that +// takes their shadow. +// +// WHY THIS IS ONE FILE. These used to be two unrelated noise fields that had +// never been reconciled, and the result did not survive looking at: +// +// - The sky's puffs were fBm over a plane pinned to the CAMERA, so they slid +// along with the player instead of hanging over the world. +// - The ground's "cloud shadow" was a *different*, much finer field on a +// different noise, drifting ~80x faster (20 m/s against the sky's 0.25). +// +// So the shadow racing across the grass had no cloud above it, and the cloud +// overhead cast nothing. Worse, only the materials that happened to carry a copy +// of the ground function darkened at all: in the shooter that was the grass, but +// not the terrain under it, nor the trees standing in it — a cloud shadow that +// crosses the field and ignores the forest in the middle of it. +// +// Now there is ONE field, in WORLD space, and the shadow you are standing in +// belongs to the cloud you can look up and see. +// +// THE MODEL. The deck is a horizontal plane at `p.y` metres. A view ray and a +// sun ray are both intersected with it; whatever they hit is the same cloud. +// That is the entire trick, and it is why the apparent size of a cloud in the +// sky and the size of its shadow on the ground are no longer independently +// tunable — they are the same number seen from two directions, which is the +// point. + +fn cloud_hash(p: vec2) -> f32 { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +fn cloud_noise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let uu = f * f * (3.0 - 2.0 * f); + let a = cloud_hash(i); + let b = cloud_hash(i + vec2(1.0, 0.0)); + let c = cloud_hash(i + vec2(0.0, 1.0)); + let d = cloud_hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, uu.x), mix(c, d, uu.x), uu.y); +} + +fn cloud_fbm(p0: vec2) -> f32 { + var s = 0.0; + var amp = 0.5; + var q = p0; + for (var i = 0; i < 5; i = i + 1) { + s = s + amp * cloud_noise(q); + q = q * 2.03; + amp = amp * 0.5; + } + return s; +} + +// Cloud params, passed by each caller from whichever uniform block it happens to +// have (the sky pass and the material ABI do not share one, so this file takes +// them explicitly rather than reaching for a global): +// +// x = shadow strength 0 = the world ignores the clouds entirely (default) +// y = deck height world metres +// z = feature scale noise units per metre — 1/z is the cloud size +// w = drift speed metres/second +// +// The drift DIRECTION is the wind vector — the same one that bends the grass — +// so the clouds travel the way the foliage under them is leaning. A game that +// never set a wind still gets a slow default heading rather than a frozen sky. +fn cloud_drift(wind_xy: vec2, t: f32, speed: f32) -> vec2 { + var d = wind_xy; + if (dot(d, d) < 1e-6) { d = vec2(1.0, 0.25); } + return normalize(d) * speed * t; +} + +// Coverage of the deck at a point ON the deck. 0 = clear sky, 1 = solid cloud. +fn cloud_density(deck_xz: vec2, wind_xy: vec2, t: f32, cp: vec4) -> f32 { + let p = (deck_xz - cloud_drift(wind_xy, t, cp.w)) * cp.z; + // The threshold is high on purpose: it buys mostly-open sky with the + // occasional real cloud, instead of a permanent grey smear. A low threshold + // here is what makes a procedural deck read as fog. + return smoothstep(0.56, 1.04, cloud_fbm(p)); +} + +// Where a ray from `origin` along `dir` pierces the deck. +fn cloud_deck_hit(origin: vec3, dir: vec3, deck_y: f32) -> vec2 { + let t = (deck_y - origin.y) / dir.y; + return origin.xz + dir.xz * t; +} + +// --- the two consumers ------------------------------------------------------- + +// SKY: coverage along a view ray. Returns (coverage, sun-alignment) — the caller +// colours the cloud from the second component so puffs facing the sun burn white +// and the ones facing away stay cool grey. +fn cloud_cover_view(cam: vec3, dir: vec3, sun_dir: vec3, + wind_xy: vec2, t: f32, cp: vec4) -> vec2 { + if (dir.y <= 0.02) { return vec2(0.0, 0.0); } + var cov = cloud_density(cloud_deck_hit(cam, dir, cp.y), wind_xy, t, cp); + // Toward the horizon a view ray runs so far through the deck that modelling + // it as an infinitely thin plane stops being defensible — fade out instead + // of drawing the smear the maths would give. + let horizon_fade = smoothstep(0.03, 0.24, dir.y); + // Thin the deck right around the sun so the disk still burns through. + let near_sun = smoothstep(0.90, 0.999, dot(dir, sun_dir)); + cov = cov * horizon_fade * (1.0 - near_sun * 0.8) * 0.9; + let sun_amt = clamp(dot(dir, sun_dir) * 0.5 + 0.5, 0.0, 1.0); + return vec2(cov, sun_amt); +} + +// GROUND: how much sun a world point keeps. 1.0 = full sun, lower = under cloud. +// Multiply this into direct sunlight only — a cloud blocks the sun, it does not +// stop the sky from being blue, and folding it into ambient too is what makes +// cloud shadows read as flat grey paint rather than as shade. +fn cloud_shadow_at(world_pos: vec3, sun_dir: vec3, + wind_xy: vec2, t: f32, cp: vec4) -> f32 { + if (cp.x <= 0.0) { return 1.0; } + // Sun on the horizon: the shadow ray runs nearly parallel to the deck and + // the intersection shoots off to infinity, so the noise lookup lands a + // kilometre away and swims. Fade the whole effect out as the sun sets. + let up = sun_dir.y; + if (up <= 0.02) { return 1.0; } + let cov = cloud_density(cloud_deck_hit(world_pos, sun_dir, cp.y), wind_xy, t, cp); + return 1.0 - cov * cp.x * smoothstep(0.02, 0.20, up); +} diff --git a/native/shared/shaders/material_abi.wgsl b/native/shared/shaders/material_abi.wgsl index 7921db2..7428ec7 100644 --- a/native/shared/shaders/material_abi.wgsl +++ b/native/shared/shaders/material_abi.wgsl @@ -36,6 +36,13 @@ struct PerFrame { // (~0.1 m typical for grass); w = frequency in Hz (~1.0 typical). // Foliage / cloth materials sample this in their vertex stage. wind: vec4, + + // Cloud deck. x = shadow strength (0 = the world ignores the clouds), + // y = deck height in metres, z = feature scale, w = drift speed in m/s. + // Feed it to `cloud_shadow_at` from common/clouds.wgsl and multiply the + // result into DIRECT sunlight only — this is the same deck the sky pass + // draws, so a shadow here has a cloud above it. + cloud: vec4, }; @group(0) @binding(0) var frame: PerFrame; diff --git a/native/shared/src/ffi_core/visual.rs b/native/shared/src/ffi_core/visual.rs index 4e6cbd7..1f985ab 100644 --- a/native/shared/src/ffi_core/visual.rs +++ b/native/shared/src/ffi_core/visual.rs @@ -472,6 +472,22 @@ macro_rules! __bloom_ffi_visual { }) } + // bloom_set_cloud_shadows [EN-040] + // + // Opt the world into the deck the sky is already drawing. strength 0 + // (the default) = sky-only clouds, which is what every game that never + // calls this keeps. + #[no_mangle] + pub extern "C" fn bloom_set_cloud_shadows( + strength: f64, deck_height: f64, feature_scale: f64, drift_speed: f64, + ) { + $crate::ffi::guard("bloom_set_cloud_shadows", move || { + engine().renderer.set_cloud_shadows( + strength as f32, deck_height as f32, + feature_scale as f32, drift_speed as f32); + }) + } + // bloom_set_ssr_enabled [source: macos] #[no_mangle] pub extern "C" fn bloom_set_ssr_enabled(on: f64) { diff --git a/native/shared/src/renderer/material_pipeline.rs b/native/shared/src/renderer/material_pipeline.rs index d299e66..9dc4d6c 100644 --- a/native/shared/src/renderer/material_pipeline.rs +++ b/native/shared/src/renderer/material_pipeline.rs @@ -558,6 +558,7 @@ const BAKED_ENTRIES_SNAPSHOT: &[(&str, &str)] = &[ ("common/fog.wgsl", include_str!("../../../shared/shaders/common/fog.wgsl")), ("common/tonemap.wgsl", include_str!("../../../shared/shaders/common/tonemap.wgsl")), ("common/sky.wgsl", include_str!("../../../shared/shaders/common/sky.wgsl")), + ("common/clouds.wgsl", include_str!("../../../shared/shaders/common/clouds.wgsl")), ("materials/test_minimal.wgsl", include_str!("../../../shared/shaders/materials/test_minimal.wgsl")), ]; diff --git a/native/shared/src/renderer/material_system.rs b/native/shared/src/renderer/material_system.rs index f6e8074..1cae954 100644 --- a/native/shared/src/renderer/material_system.rs +++ b/native/shared/src/renderer/material_system.rs @@ -35,6 +35,10 @@ pub struct PerFrameUniforms { pub _pad1: [f32; 2], /// Global wind: x=dir_x, y=dir_z, z=amplitude, w=frequency. pub wind: [f32; 4], + /// Cloud deck: x = shadow strength, y = deck height (m), z = feature scale, + /// w = drift speed (m/s). Materials feed this to `cloud_shadow_at` from + /// common/clouds.wgsl — the same deck the sky pass draws. + pub cloud: [f32; 4], } #[repr(C)] diff --git a/native/shared/src/renderer/material_system_tests.rs b/native/shared/src/renderer/material_system_tests.rs index 356f1ce..eda49f1 100644 --- a/native/shared/src/renderer/material_system_tests.rs +++ b/native/shared/src/renderer/material_system_tests.rs @@ -168,6 +168,7 @@ fn fs_main(_in: VsOut) -> TranslucentOut { time: 0.0, delta_time: 0.0, frame_index: 0, _pad0: 0, screen_resolution: [64.0, 64.0], render_resolution: [64.0, 64.0], taa_jitter: [0.0; 2], _pad1: [0.0; 2], wind: [0.0; 4], + cloud: [0.0; 4], }; let pv = bytemuck::Zeroable::zeroed(); sys.update_frame_uniforms(&queue, &pf, &pv); @@ -448,6 +449,7 @@ fn fs_main(_in: VsOut) -> TranslucentOut { time: 0.0, delta_time: 0.0, frame_index: 0, _pad0: 0, screen_resolution: [64.0, 64.0], render_resolution: [64.0, 64.0], taa_jitter: [0.0; 2], _pad1: [0.0; 2], wind: [0.0; 4], + cloud: [0.0; 4], }; let pv = bytemuck::Zeroable::zeroed(); sys.update_frame_uniforms(&queue, &pf, &pv); diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index d22f8dd..e84aafc 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -140,13 +140,28 @@ struct SkyUniforms { /// Camera right vector × tan(fovy/2) × aspect — pre-scaled so the /// fragment shader just multiplies by NDC.x to get the horizontal /// offset from the forward direction. + /// `.w` = camera world position X (see below). right: [f32; 4], - /// Camera up vector × tan(fovy/2). + /// Camera up vector × tan(fovy/2). `.w` = camera world position Y. up: [f32; 4], - /// Camera forward unit vector. + /// Camera forward unit vector. `.w` = camera world position Z. + /// + /// The camera POSITION rides in the spare `.w` lanes of the three basis + /// vectors. The sky pass never needed it — a sky at infinity only cares + /// which way you are looking — but the cloud deck is anchored in world + /// space so that its shadow lands under it, and that makes the camera's + /// position load-bearing. Packing it here keeps the bind group unchanged. forward: [f32; 4], - /// x = intensity multiplier; yzw padding. + /// x = intensity multiplier, y = elapsed seconds (the cloud deck's clock); + /// zw padding. intensity: [f32; 4], + /// Cloud deck: x = shadow strength, y = deck height (m), z = feature scale + /// (noise units per metre), w = drift speed (m/s). Same vec4 the world + /// materials get, so the sky and the ground cannot disagree. + cloud: [f32; 4], + /// xy = wind direction in the XZ plane — the clouds drift downwind, the + /// same way the grass is leaning. + wind: [f32; 4], } // EN-005 Phase 2 — uniforms for the procedural sky path. @@ -1451,6 +1466,11 @@ pub struct Renderer { current_inv_proj_matrix: [[f32; 4]; 4], current_inv_vp_matrix: [[f32; 4]; 4], current_camera_pos: [f32; 3], + /// Cloud deck: [shadow strength, deck height (m), feature scale, drift speed]. + /// Strength 0 (the default) means the world ignores the clouds entirely, so + /// every existing game keeps the look it shipped with; the sky still draws + /// them. See `common/clouds.wgsl`. + cloud_params: [f32; 4], uniform_3d_layout: wgpu::BindGroupLayout, // State @@ -6433,6 +6453,11 @@ impl Renderer { current_inv_proj_matrix: IDENTITY_MAT4, current_inv_vp_matrix: IDENTITY_MAT4, current_camera_pos: [0.0, 0.0, 0.0], + // Deck at 420 m, ~285 m puffs, 8 m/s. Shadow strength OFF by + // default — opting a world's ground into the clouds is a look + // decision, and silently darkening every existing game's terrain + // is not the engine's call to make. + cloud_params: [0.0, 420.0, 0.0035, 8.0], uniform_3d_layout, render_mode: RenderMode::ScreenSpace, pending_skin_groups: Vec::with_capacity(8), @@ -6875,6 +6900,38 @@ impl Renderer { self.wind = [dir_x, dir_z, amplitude, frequency]; } + /// Cloud deck — the clouds the sky draws and the shadows they cast, from one + /// field (`common/clouds.wgsl`). + /// + /// `strength` is the only argument most callers need: 0 (the default) means + /// the world ignores the clouds and only the sky shows them; ~0.45 dims a + /// shadowed surface to a bit over half its direct sunlight, which is about + /// right for a bright day. It multiplies DIRECT sun only — a cloud blocks the + /// sun, it does not stop the sky from being blue. + /// + /// `deck_height` and `feature_scale` are NOT independent knobs for "cloud size + /// overhead" and "shadow size underfoot" — they are the same cloud seen from + /// two directions. Raising the deck makes the puffs look smaller overhead + /// without changing their shadows; lowering `feature_scale` makes the clouds + /// themselves bigger, both above and below. That coupling is the feature. + /// + /// Drift direction comes from [`Renderer::set_wind`], so the deck travels the + /// way the foliage beneath it is leaning. + pub fn set_cloud_shadows( + &mut self, + strength: f32, + deck_height: f32, + feature_scale: f32, + drift_speed: f32, + ) { + self.cloud_params = [ + strength.clamp(0.0, 1.0), + deck_height.max(1.0), + feature_scale.max(1e-6), + drift_speed, + ]; + } + /// Toggle TAA on/off. Off = no jitter, no history blend, no /// extra texture writes. Until `set_render_scale` is called /// explicitly this also flips `render_scale` between 0.5 (on) @@ -8153,21 +8210,30 @@ impl Renderer { let p = self.current_proj_matrix; let tan_half = if p[1][1].abs() > 1e-6 { 1.0 / p[1][1] } else { 1.0 }; + // Camera position in the spare .w lanes and the clock in intensity.y, so + // this path fills the same buffer layout as the procedural one. The + // panorama sky itself draws no clouds — a static HDR already has whatever + // clouds it was captured with — but the buffer is shared, so it is filled + // honestly rather than with zeroes that would mean something elsewhere. + let cam = self.current_camera_pos; + let time = self.lighting_uniforms.wind[3]; let uniforms = SkyUniforms { right: [ right_world[0] * tan_half * aspect, right_world[1] * tan_half * aspect, right_world[2] * tan_half * aspect, - 0.0, + cam[0], ], up: [ up_world[0] * tan_half, up_world[1] * tan_half, up_world[2] * tan_half, - 0.0, + cam[1], ], - forward: [forward_world[0], forward_world[1], forward_world[2], 0.0], - intensity: [intensity, 0.0, 0.0, 0.0], + forward: [forward_world[0], forward_world[1], forward_world[2], cam[2]], + intensity: [intensity, time, 0.0, 0.0], + cloud: self.cloud_params, + wind: self.lighting_uniforms.wind, }; self.queue .write_buffer(&self.sky_uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); @@ -8609,17 +8675,23 @@ impl Renderer { let p = self.current_proj_matrix; let tan_half = if p[1][1].abs() > 1e-6 { 1.0 / p[1][1] } else { 1.0 }; + // Camera position rides in the spare .w lanes: the cloud deck is anchored + // in WORLD space (so its shadow lands under it), which means the sky pass + // needs to know where the camera IS and not merely where it is looking. + let cam = self.current_camera_pos; let uniforms = SkyUniforms { right: [ right_world[0] * tan_half * aspect, right_world[1] * tan_half * aspect, right_world[2] * tan_half * aspect, - 0.0, + cam[0], ], - up: [up_world[0] * tan_half, up_world[1] * tan_half, up_world[2] * tan_half, 0.0], - forward: [forward_world[0], forward_world[1], forward_world[2], 0.0], - // .y carries current time (seconds) so the cloud layer can drift. + up: [up_world[0] * tan_half, up_world[1] * tan_half, up_world[2] * tan_half, cam[1]], + forward: [forward_world[0], forward_world[1], forward_world[2], cam[2]], + // .y carries current time (seconds) so the cloud deck can drift. intensity: [intensity, self.lighting_uniforms.wind[3], 0.0, 0.0], + cloud: self.cloud_params, + wind: self.lighting_uniforms.wind, }; self.queue.write_buffer(&self.sky_uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); @@ -11531,6 +11603,7 @@ impl Renderer { // here each frame so it's current before the per-frame lighting upload. self.lighting_uniforms.wind = [self.wind[0], self.wind[1], self.wind[2], time_seconds]; + self.lighting_uniforms.cloud = self.cloud_params; let screen_w = self.surface_config.width as f32; let screen_h = self.surface_config.height as f32; let (rw, rh) = self.render_extent(); @@ -11544,6 +11617,7 @@ impl Renderer { taa_jitter: [0.0, 0.0], _pad1: [0.0, 0.0], wind: self.wind, + cloud: self.cloud_params, }; let per_view = material_system::PerViewUniforms { view: self.current_view_matrix, diff --git a/native/shared/src/renderer/shader_library.rs b/native/shared/src/renderer/shader_library.rs index 3b1a235..7c8f320 100644 --- a/native/shared/src/renderer/shader_library.rs +++ b/native/shared/src/renderer/shader_library.rs @@ -20,6 +20,7 @@ const ENTRIES: &[(&str, &str)] = &[ ("common/fog.wgsl", include_str!("../../../shared/shaders/common/fog.wgsl")), ("common/tonemap.wgsl", include_str!("../../../shared/shaders/common/tonemap.wgsl")), ("common/sky.wgsl", include_str!("../../../shared/shaders/common/sky.wgsl")), + ("common/clouds.wgsl", include_str!("../../../shared/shaders/common/clouds.wgsl")), ("materials/test_minimal.wgsl", include_str!("../../../shared/shaders/materials/test_minimal.wgsl")), ("impulse_field.wgsl", include_str!("../../../shared/shaders/impulse_field.wgsl")), ]; diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index c0b57e0..b0de687 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -210,7 +210,14 @@ fn fs_main_3d(in: VertexOutput3D) -> Fs3DOut { } "; -pub(in crate::renderer) const SCENE_SHADER: &str = " +// The cloud deck (common/clouds.wgsl) is prepended verbatim: this shader is a +// raw source const and does not run through the material preprocessor. Same +// file the sky pass and the world materials use, so a cloud shadow crossing +// the terrain also crosses the trees standing in it — which is the whole +// reason to share it. +pub(in crate::renderer) const SCENE_SHADER: &str = concat!( + include_str!("../../../shaders/common/clouds.wgsl"), + r#" struct Uniforms3D { mvp: mat4x4, model: mat4x4, @@ -248,6 +255,7 @@ struct Lighting { shadow_cascade_splits: vec4, shadow_view_matrix: mat4x4, wind: vec4, // xy=dir, z=amplitude, w=time (foliage sway) + cloud: vec4, // x=shadow strength, y=deck height, z=scale, w=drift m/s }; struct MaterialFactors { @@ -883,8 +891,14 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { // Never fully zero direct light — a 10% floor simulates // ambient bounce from surrounding surfaces and keeps shadows // from going pitch-black regardless of IBL intensity. - let direct_shadow = mix(0.03, 1.0, shadow_factor); + let direct_shadow_raw = mix(0.03, 1.0, shadow_factor); let legacy_dir = normalize(lighting.light_dir.xyz); + // Cloud deck (common/clouds.wgsl). Folded into the SUN shadow only: a cloud + // blocks the sun, it does not stop the sky from being blue. Multiplying it + // into ambient as well is what makes cloud shadows read as flat grey paint + // instead of shade. Costs nothing when strength is 0 (the default). + let direct_shadow = direct_shadow_raw * cloud_shadow_at( + in.world_pos, legacy_dir, lighting.wind.xy, lighting.wind.w, lighting.cloud); if (alpha_cutoff > 0.0) { // Foliage wrap-lambert (energy-conserving wrap, w = 0.45): a leaf // turning from the sun rolls off softly — light transmits and @@ -1168,5 +1182,5 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { vec4(base_color, 1.0 - shadow_factor), ); } -"; +"#); diff --git a/native/shared/src/renderer/shaders/env.rs b/native/shared/src/renderer/shaders/env.rs index 8b3719e..d084d00 100644 --- a/native/shared/src/renderer/shaders/env.rs +++ b/native/shared/src/renderer/shaders/env.rs @@ -657,12 +657,25 @@ fn fs_main(@builtin(position) frag_pos: vec4) -> @location(0) vec4 { } "; -pub(in crate::renderer) const PROCEDURAL_SKY_SHADER_WGSL: &str = " +// The cloud deck is prepended verbatim rather than `#include`d: this shader is a +// raw source const compiled straight to wgpu, and does not run through the +// material preprocessor. Same file either way, so the sky and the ground cannot +// drift apart — which is exactly how they got out of sync in the first place. +pub(in crate::renderer) const PROCEDURAL_SKY_SHADER_WGSL: &str = concat!( + include_str!("../../../shaders/common/clouds.wgsl"), + r#" struct SkyUniforms { + // xyz = camera basis (pre-scaled by tan(fovy/2) and aspect). + // w = camera world position, one component per row — the cloud deck is + // anchored in WORLD space, so the sky pass needs the camera's + // position and not merely its orientation. Packed into the spare + // .w lanes to avoid growing the bind group. right: vec4, up: vec4, forward: vec4, - intensity: vec4, // x = scene-wide intensity multiplier + intensity: vec4, // x = scene-wide intensity multiplier, y = time (s) + cloud: vec4, // x = shadow strength, y = deck height, z = scale, w = drift speed + wind: vec4, // xy = wind direction in the XZ plane }; struct SunUniforms { @@ -706,48 +719,9 @@ fn dir_to_sky_uv(dir: vec3) -> vec2 { return vec2(u_norm, v_norm); } -// --- Procedural cloud layer (value-noise fBm) --------------------------- -fn cloud_hash(p: vec2) -> f32 { - return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); -} -fn cloud_noise(p: vec2) -> f32 { - let i = floor(p); - let f = fract(p); - let uu = f * f * (3.0 - 2.0 * f); - let a = cloud_hash(i); - let b = cloud_hash(i + vec2(1.0, 0.0)); - let c = cloud_hash(i + vec2(0.0, 1.0)); - let d = cloud_hash(i + vec2(1.0, 1.0)); - return mix(mix(a, b, uu.x), mix(c, d, uu.x), uu.y); -} -fn cloud_fbm(p0: vec2) -> f32 { - var s = 0.0; - var amp = 0.5; - var q = p0; - for (var i = 0; i < 5; i = i + 1) { - s = s + amp * cloud_noise(q); - q = q * 2.03; - amp = amp * 0.5; - } - return s; -} -// Analytic cloud cover for a view ray. Projects the ray onto a virtual cloud -// plane (perspective convergence toward the horizon), samples fBm for puffy -// coverage, fades near the horizon, and thins around the sun so the disk shows -// through. Returns (coverage, sunlit-amount). -fn cloud_cover(dir: vec3, sun_dir: vec3, time: f32) -> vec2 { - if (dir.y <= 0.02) { return vec2(0.0, 0.0); } - let p = (dir.xz / dir.y) * 2.0; - // Slow wind drift + a slower second octave shift so the puffs also evolve. - let drift = vec2(time * 0.006, time * 0.0025); - var cov = cloud_fbm(p * 0.55 + vec2(23.0, 11.0) + drift); - cov = smoothstep(0.56, 1.04, cov); - let horizon_fade = smoothstep(0.03, 0.24, dir.y); - let near_sun = smoothstep(0.90, 0.999, dot(dir, sun_dir)); - cov = cov * horizon_fade * (1.0 - near_sun * 0.8) * 0.9; - let sun_amt = clamp(dot(dir, sun_dir) * 0.5 + 0.5, 0.0, 1.0); - return vec2(cov, sun_amt); -} +// The cloud field itself (cloud_fbm / cloud_cover_view / cloud_shadow_at) is +// prepended from common/clouds.wgsl — the same file the world materials include +// for their shadow term. fn sample_transmittance(r: f32, mu: f32) -> vec3 { let v = clamp((r - GROUND_R) / (ATMOS_TOP - GROUND_R), 0.0, 1.0); @@ -791,7 +765,13 @@ fn sky_fs(in: VsOut) -> SkyOut { // Procedural cloud layer, composited over the scaled sky radiance. Cloud // colour is absolute HDR (puffy white in sun, cool grey in shadow) so the // clouds read brighter than the sky behind them regardless of env intensity. - let cc = cloud_cover(dir, sun_dir, u.intensity.y); + // + // The deck is sampled in WORLD space from the camera's actual position, so + // the cloud overhead is the one whose shadow the ground is sampling. It also + // means the clouds now hold still as the player walks under them, instead of + // sliding along pinned to the camera. + let cam = vec3(u.right.w, u.up.w, u.forward.w); + let cc = cloud_cover_view(cam, dir, sun_dir, u.wind.xy, u.intensity.y, u.cloud); if (cc.x > 0.0) { let lit = cc.y * cc.y; let cloud_col = mix(vec3(0.62, 0.66, 0.76), vec3(2.6, 2.5, 2.35), lit); @@ -816,7 +796,7 @@ fn sky_fs(in: VsOut) -> SkyOut { out.albedo = vec4(0.0, 0.0, 0.0, 0.0); return out; } -"; +"#); /// EN-011 — single-target reflection shader for rendering cached models /// (trees, house, etc.) into a planar-reflection probe with a mirrored diff --git a/native/shared/src/renderer/types.rs b/native/shared/src/renderer/types.rs index 2e75e91..f349d4b 100644 --- a/native/shared/src/renderer/types.rs +++ b/native/shared/src/renderer/types.rs @@ -139,6 +139,11 @@ pub(super) struct LightingUniforms { /// z = amplitude, w = elapsed time (seconds) for the sway phase. /// Appended last so existing field offsets stay stable. pub(super) wind: [f32; 4], + /// Cloud deck for the built-in scene shader: x = shadow strength, + /// y = deck height (m), z = feature scale, w = drift speed (m/s). + /// Strength 0 = the scene ignores the clouds. Appended last so existing + /// field offsets stay stable. + pub(super) cloud: [f32; 4], } impl LightingUniforms { @@ -159,6 +164,7 @@ impl LightingUniforms { shadow_cascade_splits: [8.0, 25.0, 80.0, 0.0], shadow_view_matrix: IDENTITY_MAT4, wind: [0.0, 0.0, 0.0, 0.0], + cloud: [0.0, 420.0, 0.0035, 8.0], } } } diff --git a/native/watchos/src/ffi_stubs.rs b/native/watchos/src/ffi_stubs.rs index 99dc3d3..5941602 100644 --- a/native/watchos/src/ffi_stubs.rs +++ b/native/watchos/src/ffi_stubs.rs @@ -245,6 +245,8 @@ } #[no_mangle] pub extern "C" fn bloom_set_wind(_p0: f64, _p1: f64, _p2: f64, _p3: f64) { } +#[no_mangle] pub extern "C" fn bloom_set_cloud_shadows(_p0: f64, _p1: f64, _p2: f64, _p3: f64) { +} #[no_mangle] pub extern "C" fn bloom_set_ssr_enabled(_p0: f64) { } #[no_mangle] pub extern "C" fn bloom_set_motion_blur_enabled(_p0: f64) { diff --git a/native/web/src/lib.rs b/native/web/src/lib.rs index 0dd86ad..a7ea4af 100644 --- a/native/web/src/lib.rs +++ b/native/web/src/lib.rs @@ -1534,6 +1534,11 @@ pub fn bloom_set_wind(dir_x: f64, dir_z: f64, amplitude: f64, frequency: f64) { engine().renderer.set_wind(dir_x as f32, dir_z as f32, amplitude as f32, frequency as f32); } #[wasm_bindgen] +pub fn bloom_set_cloud_shadows(strength: f64, deck_height: f64, feature_scale: f64, drift_speed: f64) { + engine().renderer.set_cloud_shadows( + strength as f32, deck_height as f32, feature_scale as f32, drift_speed as f32); +} +#[wasm_bindgen] pub fn bloom_set_ssr_enabled(on: f64) { engine().renderer.set_ssr_enabled(on != 0.0); } diff --git a/package.json b/package.json index 690f328..b566ee7 100644 --- a/package.json +++ b/package.json @@ -1650,6 +1650,16 @@ ], "returns": "void" }, + { + "name": "bloom_set_cloud_shadows", + "params": [ + "f64", + "f64", + "f64", + "f64" + ], + "returns": "void" + }, { "name": "bloom_set_ssr_enabled", "params": [ diff --git a/src/core/index.ts b/src/core/index.ts index 7995fbf..ee8ce52 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -55,6 +55,7 @@ declare function bloom_clear_all_post_passes(): void; declare function bloom_set_ssao_intensity(intensity: number): void; declare function bloom_set_ssao_radius(worldRadius: number): void; declare function bloom_set_wind(dirX: number, dirZ: number, amplitude: number, frequency: number): void; +declare function bloom_set_cloud_shadows(strength: number, deckHeight: number, featureScale: number, driftSpeed: number): void; declare function bloom_set_ssr_enabled(on: number): void; declare function bloom_set_motion_blur_enabled(on: number): void; declare function bloom_set_sss_enabled(on: number): void; @@ -558,6 +559,33 @@ export function setWind(dirX: number, dirZ: number, amplitude: number, frequency bloom_set_wind(dirX, dirZ, amplitude, frequency); } +/// Cloud deck — the clouds the sky draws and the shadows they cast, from ONE +/// field. Look up at a cloud, and the shadow you are standing in is its shadow. +/// +/// `strength` is the only argument most callers need. 0 (the default) leaves the +/// world unshadowed and the clouds sky-only; ~0.45 dims a shadowed surface to a +/// bit over half its direct sunlight, which is about right for a bright day. It +/// scales DIRECT sun only — a cloud blocks the sun, it does not stop the sky +/// from being blue. +/// +/// `deckHeight` and `featureScale` are not independent knobs for "cloud size +/// overhead" and "shadow size underfoot": they are the same cloud seen from two +/// directions. Raise the deck and the puffs look smaller overhead while their +/// shadows stay the same size; lower `featureScale` and the clouds get bigger +/// both above and below. +/// +/// Drift direction comes from `setWind`, so the deck travels the way the foliage +/// beneath it is leaning. +/// (No default parameter values: Perry 0.5.x silently drops the call.) +export function setCloudShadows( + strength: number, + deckHeight: number, + featureScale: number, + driftSpeed: number, +): void { + bloom_set_cloud_shadows(strength, deckHeight, featureScale, driftSpeed); +} + /** Toggle screen-space reflections. Default on. */ export function setSsrEnabled(on: boolean): void { bloom_set_ssr_enabled(on ? 1 : 0); From 874df880835e4a82ee80eff6e43891aac76f280a Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 15:09:35 +0200 Subject: [PATCH 07/17] feat(EN-041): hierarchical foliage wind + EN-042 (shadow budget silently drops casters) The engine swayed ALPHA-CUT materials only. Leaf cards fluttered; every tree trunk in every game stood perfectly rigid -- a forest of poles with twitching hair. And the shadow shaders applied no wind whatsoever, so the leaves moved in the light while their shadows stayed nailed to the ground. common/foliage_wind.wgsl is now one field shared by the scene pass and both shadow shaders. Three layers, because a tree does not move as one thing: trunk bend (proportional to height SQUARED -- a cantilever; the base is planted and the crown swings, and this is the motion you actually read from 30 m), branch sway (proportional to reach out from the trunk axis, phase-offset by azimuth so opposite limbs do not swing together), and leaf flutter (cutout cards only, fast and small). WHERE THE WEIGHTS COME FROM. SH-013 planned to author them into vertex colours. That is right for hand-modelled trees, but COLOR_0 is already spent as an albedo multiplier -- putting wind weights there would tint every trunk -- and ours are procedurally generated, so the regions are derivable exactly from where a vertex sits relative to the model origin. No new attribute, no GLB re-bake, works on any model the game flags. The offset is computed in WORLD space and mapped back through the model's inverse linear part (exact for rotation + uniform scale: M^-1 = M^T / s^2). Displacing along a local axis instead would let each tree's per-instance yaw rotate the wind with it, and a stand of trees would bend a dozen different directions. Prev-frame offset too, so TAA gets a real velocity for a moving leaf instead of 0. EN-042, found on the way. Swaying shadows need the caster in the DYNAMIC set, and that set holds 64. The forest is 88 trees x 4 primitives = 352. Enabling it overflowed the budget and the overflow was dropped WITHOUT A WORD -- and it measured FASTER (34 -> 40 fps), because what it had actually done was silently delete every tree shadow and the player's own shadow from under their feet. A perf win that is really a correctness loss is the worst failure mode there is. Foliage is now promoted to the dynamic set only while slots remain (24 max), so characters always keep theirs; the rest of the forest stays rigid, which is invisible. Filed EN-042 for the real fix. 2 FFI symbols; validate-ffi clean. 116/116 shared tests pass. --- docs/tickets.md | 47 +++++++++ .../shared/shaders/common/foliage_wind.wgsl | 98 +++++++++++++++++++ native/shared/src/ffi_core/visual.rs | 23 +++++ native/shared/src/renderer/mod.rs | 41 ++++++++ native/shared/src/renderer/model_draw.rs | 7 +- native/shared/src/renderer/shaders/core.rs | 35 ++++--- native/shared/src/renderer/shadow_pass.rs | 54 +++++++++- native/shared/src/renderer/types.rs | 6 ++ native/shared/src/shadows.rs | 36 +++++-- native/watchos/src/ffi_stubs.rs | 4 + native/web/src/lib.rs | 8 ++ package.json | 15 +++ src/index.ts | 1 + src/models/index.ts | 26 +++++ 14 files changed, 373 insertions(+), 28 deletions(-) create mode 100644 native/shared/shaders/common/foliage_wind.wgsl diff --git a/docs/tickets.md b/docs/tickets.md index 587ff18..6edf5fa 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -1274,3 +1274,50 @@ design. - **Netcode:** absent entirely; out of scope by decision, not oversight. Revisit only with a design that needs it. + + +## EN-041 — Hierarchical foliage wind ✅ *(shipped 2026-07-12)* + +The engine swayed **alpha-cut materials only**. So leaf cards fluttered and every +tree trunk in every game stood perfectly rigid — a forest of poles with twitching +hair — and the shadow shaders applied no wind at all, so the leaves moved while +their shadows stayed nailed to the ground. + +`common/foliage_wind.wgsl` is now one field shared by the scene pass and both +shadow shaders. Three layers, because a tree does not move as one thing: trunk +bend (∝ height², a cantilever — the motion you read at 30 m), branch sway (∝ reach +from the trunk axis), leaf flutter (cutout cards only). The weights are DERIVED +from the vertex's position relative to the model origin rather than authored into +vertex colours — COLOR_0 is already spent on albedo tint, and for procedurally +generated trees the regions are known exactly anyway. So: no new vertex attribute, +no GLB re-bake. + +`set_model_foliage_wind(model, amount)` opts a model in; everything else stays +rigid. The offset is computed in WORLD space and mapped back through the model's +inverse linear part — displacing along a local axis would let each tree's +per-instance yaw rotate the wind with it, and a stand of trees would bend a dozen +different ways. Prev-frame offset too, so TAA gets a real velocity for a moving +leaf instead of 0. + +--- + +## EN-042 — Dynamic shadow-caster budget is 64, and overflow is silently dropped 🔴 + +**Found while shipping EN-041.** `SHADOW_MAX_DYNAMIC = 64`. A caster that moves +every frame cannot reuse the cached static depth, so it must go in the dynamic +set — and the shooter's forest alone is 88 trees × 4 primitives = **352**. + +Turning on `set_foliage_shadow_motion` therefore overflows the budget, and the +overflow is **dropped without a word**. The measured result was not a slowdown but +a *speedup* (34 → 40 fps) — because it had silently deleted every tree shadow AND +**the player's own shadow from under their feet**. A perf win that is really a +correctness loss is the worst possible failure mode. + +Mitigated for now: foliage is promoted to the dynamic set only while slots remain +(`MAX_FOLIAGE_DYNAMIC = 24`), so characters always keep theirs and the rest of the +forest just stays rigid. The real fix is a budget that scales, or a foliage path +that refreshes cached static depth on a slow cadence instead of per frame. + +**Acceptance:** the dynamic set cannot silently drop a caster — it either fits, or +the engine degrades a *chosen* class (foliage) rather than whatever happened to be +queued last. diff --git a/native/shared/shaders/common/foliage_wind.wgsl b/native/shared/shaders/common/foliage_wind.wgsl new file mode 100644 index 0000000..f79f8c4 --- /dev/null +++ b/native/shared/shaders/common/foliage_wind.wgsl @@ -0,0 +1,98 @@ +// Hierarchical foliage wind — one field, shared by the scene pass that draws the +// tree and the shadow pass that casts it. +// +// WHY THREE LAYERS. A tree does not move as one thing. The trunk leans slowly +// under the whole wind load, the branches swing at their own rate, and the +// leaves flutter fast at the tips. Driving all of it from a single sine is what +// makes procedural foliage read as rippling cloth instead of wood. +// +// What was here before did less than that: the engine swayed *alpha-cut +// materials only*, so leaf cards fluttered and every trunk in every game was +// perfectly rigid — and the shadow shaders applied no wind at all, so the leaves +// moved while their shadows stayed nailed to the ground. +// +// WHERE THE WEIGHTS COME FROM. SH-013 planned to author them into vertex colours +// (R = bend, G = branch, B = flutter). That is the right answer for hand-modelled +// trees, but ours are procedurally generated, so the regions are known exactly and +// can simply be derived from where the vertex sits relative to the tree's base: +// +// trunk bend proportional to height^2 -- a cantilever: the crown travels, the roots do not +// branch sway proportional to reach out from the trunk axis +// leaf flutter cutout cards only, small and fast +// +// So: no new vertex attribute, no GLB re-bake, no COLOR_0 (which the scene shader +// already spends on albedo tint), and it works on any foliage model the game flags. +// +// The offset is returned in WORLD space and added after the model transform. +// Displacing in local space would let each tree's per-instance yaw rotate the +// wind with it, and a stand of trees would bend in a dozen different directions. + +// `rel` is the vertex's WORLD-space offset from its model origin (so it is +// already scaled and rotated -- the coefficients below are in metres and mean the +// same thing for a sapling and a giant). +// `wind` is the global wind vec4: xy = direction in the XZ plane, z = amplitude, +// w = elapsed seconds. +// `amount` scales the whole effect per draw; 0 = this is not foliage, don't move +// it. `is_leaf` is 1.0 for cutout cards, 0.0 for wood. +fn foliage_wind_world(rel: vec3, model_origin: vec3, + wind: vec4, amount: f32, is_leaf: f32) -> vec3 { + if (amount <= 0.0 || wind.z <= 0.0) { return vec3(0.0, 0.0, 0.0); } + + let dir = vec3(wind.x, 0.0, wind.y); + let amp = wind.z * amount; + let t = wind.w; + + // Per-tree phase from the model's world origin. Without it a stand of trees + // sways in lockstep, which is the single most obvious tell of fake foliage. + let ph = model_origin.x * 0.137 + model_origin.z * 0.241; + + let h = max(rel.y, 0.0); + let reach = length(rel.xz); + + // 1. TRUNK BEND — cantilever, so travel grows with the SQUARE of height: the + // base is planted, the crown swings. Slow (~0.15 Hz). This is the motion + // you read from 30 m away, and it is the one that did not exist at all. + // Coefficients are tuned so amount = 1.0 is a real tree in a lazy breeze: + // with the shooter's wind amplitude (0.10) a 4 m crown travels ~35 cm. + let bend = amp * 0.22 * h * h * sin(t * 0.95 + ph); + + // 2. BRANCH SWAY — how far the vertex reaches out from the trunk axis. Medium + // (~0.4 Hz), phase-offset by azimuth so opposite limbs do not swing together. + let azim = atan2(rel.z, rel.x); + // Gated by height as well as reach: without it the flare at the base of the + // trunk has some reach and would shuffle its own roots. + let swing = amp * 0.70 * reach * clamp(h * 0.5, 0.0, 1.0) + * sin(t * 2.4 + ph + azim * 1.7); + + // 3. LEAF FLUTTER — cutout cards only. Fast (~1 Hz), small, and keyed on the + // vertex's own position so neighbouring cards break up rather than shimmer + // as a sheet. + let fl = amp * 0.60 * is_leaf * sin(t * 6.0 + ph + h * 3.1 + reach * 5.0); + + var o = dir * (bend + swing + fl); + // Leaves twist rather than only sliding downwind; and a leaning crown dips a + // little, because the tip is swinging on an arc, not a rail. + o.y = o.y + fl * 0.45 - bend * 0.15; + return o; +} + +// Convenience wrapper: local-space vertex in, wind-displaced local-space vertex +// out. Both the scene pass and the shadow pass want exactly this, and they must +// agree exactly or a tree detaches from its own shadow. +// +// The offset is computed in WORLD space (see above) and then brought back into +// local space by inverting the model's linear part. That inverse is exact for the +// transforms these draws use -- rotation plus UNIFORM scale -- because then +// M^-1 = M^T / s^2. +fn foliage_wind_local(local_pos: vec3, model: mat4x4, + wind: vec4, amount: f32, is_leaf: f32) -> vec3 { + if (amount <= 0.0 || wind.z <= 0.0) { return local_pos; } + let origin = model[3].xyz; + let rel = (model * vec4(local_pos, 1.0)).xyz - origin; + let wo = foliage_wind_world(rel, origin, wind, amount, is_leaf); + let c0 = model[0].xyz; + let s2 = max(dot(c0, c0), 1e-8); + return local_pos + vec3(dot(model[0].xyz, wo), + dot(model[1].xyz, wo), + dot(model[2].xyz, wo)) / s2; +} diff --git a/native/shared/src/ffi_core/visual.rs b/native/shared/src/ffi_core/visual.rs index 1f985ab..d716dc3 100644 --- a/native/shared/src/ffi_core/visual.rs +++ b/native/shared/src/ffi_core/visual.rs @@ -472,6 +472,29 @@ macro_rules! __bloom_ffi_visual { }) } + // bloom_set_model_foliage_wind [EN-041] + // + // Mark a cached model as a plant so the wind bends it. amount ~1.0 for a + // tree. The engine used to sway alpha-cut materials only, so leaf cards + // fluttered and every trunk stood rigid. + #[no_mangle] + pub extern "C" fn bloom_set_model_foliage_wind(model: f64, amount: f64) { + $crate::ffi::guard("bloom_set_model_foliage_wind", move || { + engine().renderer.set_model_foliage_wind(model.to_bits(), amount as f32); + }) + } + + // bloom_set_foliage_shadow_motion [EN-041] + // + // Let foliage sway in the shadow pass too, so the canopy dapple moves. + // NOT free: a moving caster cannot reuse the cached static shadow depth. + #[no_mangle] + pub extern "C" fn bloom_set_foliage_shadow_motion(on: f64) { + $crate::ffi::guard("bloom_set_foliage_shadow_motion", move || { + engine().renderer.set_foliage_shadow_motion(on > 0.5); + }) + } + // bloom_set_cloud_shadows [EN-040] // // Opt the world into the deck the sky is already drawing. strength 0 diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index e84aafc..2c2cda8 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -1471,6 +1471,15 @@ pub struct Renderer { /// every existing game keeps the look it shipped with; the sky still draws /// them. See `common/clouds.wgsl`. cloud_params: [f32; 4], + /// Per-model foliage wind amount, keyed by cached-model handle. Absent or 0 + /// = not a plant, don't move it. See `common/foliage_wind.wgsl`. + foliage_wind: std::collections::HashMap, + /// Whether foliage casters sway in the SHADOW pass too. Off by default and + /// deliberately so: a swaying caster can no longer use the cached static + /// shadow depth, so every tree re-renders into every cascade every frame. + /// That is a real cost, and it buys canopy dapple that MOVES — worth it on a + /// machine with headroom, not worth a frame-rate cliff on one without. + foliage_shadow_motion: bool, uniform_3d_layout: wgpu::BindGroupLayout, // State @@ -6458,6 +6467,8 @@ impl Renderer { // decision, and silently darkening every existing game's terrain // is not the engine's call to make. cloud_params: [0.0, 420.0, 0.0035, 8.0], + foliage_wind: std::collections::HashMap::new(), + foliage_shadow_motion: false, uniform_3d_layout, render_mode: RenderMode::ScreenSpace, pending_skin_groups: Vec::with_capacity(8), @@ -6900,6 +6911,35 @@ impl Renderer { self.wind = [dir_x, dir_z, amplitude, frequency]; } + /// Mark a cached model as foliage, so the scene (and optionally shadow) pass + /// bends it in the wind. `amount` scales the whole effect: ~1.0 for a tree, + /// smaller for a stiff shrub, 0 to turn it off again. + /// + /// The wind is hierarchical (`common/foliage_wind.wgsl`) — trunk bend, + /// branch sway, leaf flutter — and the layer weights come from where each + /// vertex sits relative to the model origin, so nothing needs authoring into + /// the mesh. Direction/strength/rate come from [`Renderer::set_wind`]. + /// + /// Before this the engine swayed alpha-cut materials only, which meant leaf + /// cards fluttered and every trunk stood perfectly rigid. + pub fn set_model_foliage_wind(&mut self, handle_bits: u64, amount: f32) { + if amount <= 0.0 { + self.foliage_wind.remove(&handle_bits); + } else { + self.foliage_wind.insert(handle_bits, amount); + } + } + + /// Let foliage sway in the SHADOW pass as well, so a bending tree and its + /// shadow bend together and the canopy dapple actually moves. + /// + /// Off by default because it is not free: a caster that moves every frame + /// cannot reuse the cached static shadow depth, so every tree re-renders + /// into every cascade, every frame. Turn it on if the frame budget allows. + pub fn set_foliage_shadow_motion(&mut self, on: bool) { + self.foliage_shadow_motion = on; + } + /// Cloud deck — the clouds the sky draws and the shadows they cast, from one /// field (`common/clouds.wgsl`). /// @@ -11604,6 +11644,7 @@ impl Renderer { self.lighting_uniforms.wind = [self.wind[0], self.wind[1], self.wind[2], time_seconds]; self.lighting_uniforms.cloud = self.cloud_params; + self.lighting_uniforms.frame_misc = [delta_time, 0.0, 0.0, 0.0]; let screen_w = self.surface_config.width as f32; let screen_h = self.surface_config.height as f32; let (rw, rh) = self.render_extent(); diff --git a/native/shared/src/renderer/model_draw.rs b/native/shared/src/renderer/model_draw.rs index 5312250..875bf66 100644 --- a/native/shared/src/renderer/model_draw.rs +++ b/native/shared/src/renderer/model_draw.rs @@ -12,6 +12,8 @@ impl Renderer { Some(Some(meshes)) => meshes.len(), _ => return, }; + // Foliage wind amount for this model (0 = not a plant). Rides in misc.z. + let foliage = self.foliage_wind.get(&handle_bits).copied().unwrap_or(0.0); for mesh_idx in 0..mesh_count { let slot = self.next_model_uniform_slot; @@ -31,7 +33,7 @@ impl Renderer { self.stage_model_uniform(slot, &Uniforms3D { mvp: model_mvp, model: model_matrix, prev_mvp: model_mvp, model_tint: tint, - misc: [0.0; 4], + misc: [0.0, 0.0, foliage, 0.0], }); self.model_draw_commands.push(CachedModelDraw { @@ -67,6 +69,7 @@ impl Renderer { _ => return, }; + let foliage = self.foliage_wind.get(&handle_bits).copied().unwrap_or(0.0); let (s, c) = rot_y.sin_cos(); // Column-major rotY (matches mat4_translate / mat4_scale layout). let rot: [[f32; 4]; 4] = [ @@ -89,7 +92,7 @@ impl Renderer { self.stage_model_uniform(slot, &Uniforms3D { mvp: model_mvp, model: model_matrix, prev_mvp: model_mvp, model_tint: tint, - misc: [0.0; 4], + misc: [0.0, 0.0, foliage, 0.0], }); self.model_draw_commands.push(CachedModelDraw { diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index b0de687..17d72a8 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -217,6 +217,7 @@ fn fs_main_3d(in: VertexOutput3D) -> Fs3DOut { // reason to share it. pub(in crate::renderer) const SCENE_SHADER: &str = concat!( include_str!("../../../shaders/common/clouds.wgsl"), + include_str!("../../../shaders/common/foliage_wind.wgsl"), r#" struct Uniforms3D { mvp: mat4x4, @@ -256,6 +257,7 @@ struct Lighting { shadow_view_matrix: mat4x4, wind: vec4, // xy=dir, z=amplitude, w=time (foliage sway) cloud: vec4, // x=shadow strength, y=deck height, z=scale, w=drift m/s + frame_misc: vec4, // x=delta_time (prev-frame wind, for motion vectors) }; struct MaterialFactors { @@ -393,24 +395,31 @@ fn vs_main_scene(in: VertexInputScene) -> VertexOutputScene { } var out: VertexOutputScene; var local = in.position; - // Foliage wind sway — only for alpha-cut materials (leaf cards), so the - // opaque trunk and the rest of the world stay rigid. Sway grows with the - // vertex's height up the plant and its phase varies by world position so - // cards don't move in lockstep. lighting.wind = (dir.x, dir.z, amp, time). - if (material.metal_rough.w > 0.0 && lighting.wind.z > 0.0) { - let wp0 = (u.model * vec4(in.position, 1.0)).xyz; - let t = lighting.wind.w; - let sway = lighting.wind.z * (0.25 + max(in.position.y, 0.0) * 0.16); - let phase = t * 1.6 + wp0.x * 0.5 + wp0.z * 0.5; - local.x = local.x + lighting.wind.x * sway * sin(phase); - local.z = local.z + lighting.wind.y * sway * sin(phase * 1.3 + 1.1); - local.y = local.y + sway * 0.12 * sin(phase * 0.9 + 2.0); + // Hierarchical foliage wind (common/foliage_wind.wgsl). u.misc.z is the + // per-draw foliage amount — 0 for everything that is not a plant, so the + // world does not sway. This replaces a sway that only ever moved ALPHA-CUT + // materials, which meant leaf cards fluttered and every trunk was rigid. + // + // is_leaf comes from the alpha cutoff, so cards get the fast flutter layer + // and wood does not. + var prev_local = local; + if (u.misc.z > 0.0 && lighting.wind.z > 0.0) { + // is_leaf from the alpha cutoff: cards get the fast flutter layer, wood + // does not. Same helper the shadow pass calls, so the tree and its shadow + // bend together. + let is_leaf = select(0.0, 1.0, material.metal_rough.w > 0.0); + local = foliage_wind_local(in.position, u.model, lighting.wind, u.misc.z, is_leaf); + // Last frame's offset too, so TAA gets a real velocity for a moving leaf + // instead of 0 and stops smearing the canopy into the sky behind it. + var w_prev = lighting.wind; + w_prev.w = lighting.wind.w - lighting.frame_misc.x; + prev_local = foliage_wind_local(in.position, u.model, w_prev, u.misc.z, is_leaf); } let pos4 = vec4(local, 1.0); let curr = u.mvp * pos4; out.clip_position = curr; out.curr_clip = curr; - out.prev_clip = u.prev_mvp * pos4; + out.prev_clip = u.prev_mvp * vec4(prev_local, 1.0); let world4 = u.model * pos4; out.world_pos = world4.xyz; out.normal = normalize((u.model * vec4(in.normal, 0.0)).xyz); diff --git a/native/shared/src/renderer/shadow_pass.rs b/native/shared/src/renderer/shadow_pass.rs index 5610fd7..b25b089 100644 --- a/native/shared/src/renderer/shadow_pass.rs +++ b/native/shared/src/renderer/shadow_pass.rs @@ -140,6 +140,10 @@ impl Renderer { // 0.0 for everything else — including immediate-batch // skinned segments, whose vertex joints are pre-offset. joint_offset: f32, + // Foliage wind amount for this caster (0 = rigid). Non-zero makes the + // caster MOVE, which is why it also forces `dynamic` — a swaying tree + // cannot reuse its cached static shadow depth. + foliage: f32, } fn entry_sig(kind: u8, id: u64, idx: u64, transform: &[[f32; 4]; 4]) -> u64 { let mut h = FNV_OFFSET; @@ -190,6 +194,7 @@ impl Renderer { sig: entry_sig(0, i as u64, node.gpu_index_count as u64, &node.transform), dynamic: false, joint_offset: 0.0, + foliage: 0.0, }); } @@ -223,6 +228,7 @@ impl Renderer { sig: nonce, dynamic: true, joint_offset: 0.0, + foliage: 0.0, }); } else { let num_calls = self.draw_calls_3d.len(); @@ -248,11 +254,18 @@ impl Renderer { sig: if call.has_skinned { nonce } else { call.content_hash }, dynamic: true, joint_offset: 0.0, + foliage: 0.0, }); } } } + // Foliage promoted to the dynamic set this frame. Capped well below + // SHADOW_MAX_DYNAMIC so the characters — whose shadows are the ones a + // player actually looks at — always keep their slots. + const MAX_FOLIAGE_DYNAMIC: u32 = 24; + let mut foliage_dynamic: u32 = 0; + // Cached models (drawModel: trees, characters, etc.) — each is a // GpuMesh plus its object→world matrix. World AABB from the // cache-time local AABB so per-cascade culling rejects casters @@ -291,8 +304,31 @@ impl Renderer { sig: nonce, dynamic: true, joint_offset: cmd.joint_offset, + foliage: 0.0, }); } else { + // Only sway the shadow if the game asked for it AND there is + // room in the dynamic-caster budget. + // + // That second condition is not paranoia. A swaying caster + // cannot reuse the cached static depth, so it must move to + // the DYNAMIC set — and that set holds SHADOW_MAX_DYNAMIC + // (64) entries. The shooter's forest alone is 88 trees x 4 + // primitives = 352. Marking them all dynamic overflows the + // budget, and the overflow is dropped — which does not merely + // cost frames, it silently DELETES shadows. Measured: turning + // this on removed every tree shadow AND the player's own + // shadow from under their feet, while reporting a higher fps. + // + // So: sway as many as fit, leave the rest rigid. A slightly + // stale canopy shadow is invisible; a missing one is not. + let fol = if self.foliage_shadow_motion + && foliage_dynamic < MAX_FOLIAGE_DYNAMIC + { + let f = self.foliage_wind.get(&cmd.cache_handle).copied().unwrap_or(0.0); + if f > 0.0 { foliage_dynamic += 1; } + f + } else { 0.0 }; let (wmin, wmax) = transform_aabb(&cmd.model, mesh.local_min, mesh.local_max); shadow_nodes.push(ShadowDrawEntry { @@ -305,9 +341,16 @@ impl Renderer { wmax, cutout_idx, skinned: false, - sig: entry_sig(1, cmd.cache_handle, cmd.mesh_idx as u64, &cmd.model), - dynamic: false, + // A swaying caster changes shape every frame, so it + // cannot share the cached static depth: signature goes + // to the per-frame nonce and it renders as dynamic. + // That is exactly the cost `foliage_shadow_motion` + // gates, which is why it defaults off. + sig: if fol > 0.0 { nonce } + else { entry_sig(1, cmd.cache_handle, cmd.mesh_idx as u64, &cmd.model) }, + dynamic: fol > 0.0, joint_offset: 0.0, + foliage: fol, }); } } @@ -351,6 +394,7 @@ impl Renderer { sig: entry_sig(2, cmd.mesh_handle, cmd.mesh_idx as u64, &cmd.model), dynamic: false, joint_offset: 0.0, + foliage: 0.0, }); } } @@ -429,7 +473,8 @@ impl Renderer { let uniforms = crate::shadows::ShadowUniforms { light_vp: cascade_vp, model: shadow_nodes[ei].transform, - misc: [shadow_nodes[ei].joint_offset, 0.0, 0.0, 0.0], + misc: [shadow_nodes[ei].joint_offset, 0.0, shadow_nodes[ei].foliage, 0.0], + wind: self.lighting_uniforms.wind, }; let off = slot * stride; uniform_data[off..off + std::mem::size_of::()] @@ -524,7 +569,8 @@ impl Renderer { let uniforms = crate::shadows::ShadowUniforms { light_vp: cascade_vp, model: shadow_nodes[ei].transform, - misc: [shadow_nodes[ei].joint_offset, 0.0, 0.0, 0.0], + misc: [shadow_nodes[ei].joint_offset, 0.0, shadow_nodes[ei].foliage, 0.0], + wind: self.lighting_uniforms.wind, }; let off = slot * stride; uniform_data[off..off + std::mem::size_of::()] diff --git a/native/shared/src/renderer/types.rs b/native/shared/src/renderer/types.rs index f349d4b..ab3ab55 100644 --- a/native/shared/src/renderer/types.rs +++ b/native/shared/src/renderer/types.rs @@ -144,6 +144,11 @@ pub(super) struct LightingUniforms { /// Strength 0 = the scene ignores the clouds. Appended last so existing /// field offsets stay stable. pub(super) cloud: [f32; 4], + /// x = delta_time (seconds). The scene VS needs LAST frame's wind offset to + /// emit a correct motion vector for swaying foliage — without it TAA sees + /// velocity 0 on every moving leaf and ghosts them. Appended last so existing + /// field offsets stay stable. + pub(super) frame_misc: [f32; 4], } impl LightingUniforms { @@ -165,6 +170,7 @@ impl LightingUniforms { shadow_view_matrix: IDENTITY_MAT4, wind: [0.0, 0.0, 0.0, 0.0], cloud: [0.0, 420.0, 0.0035, 8.0], + frame_misc: [0.0; 4], } } } diff --git a/native/shared/src/shadows.rs b/native/shared/src/shadows.rs index 1e5c44d..6537ddf 100644 --- a/native/shared/src/shadows.rs +++ b/native/shared/src/shadows.rs @@ -34,11 +34,14 @@ pub const SHADOW_MAX_NODES: u32 = 1024; pub const SHADOW_MAX_DYNAMIC: u32 = 64; /// Depth-only shader for shadow pass. -pub const SHADOW_SHADER: &str = " +pub const SHADOW_SHADER: &str = concat!( + include_str!("../shaders/common/foliage_wind.wgsl"), + r#" struct ShadowUniforms { light_vp: mat4x4, model: mat4x4, - misc: vec4, // x = joint offset (used by the skinned variant) + misc: vec4, // x = joint offset (skinned variant), z = foliage wind amount + wind: vec4, // xy = dir, z = amplitude, w = time }; @group(0) @binding(0) var shadow_u: ShadowUniforms; @@ -54,21 +57,26 @@ struct ShadowVertexInput { @vertex fn vs_shadow(in: ShadowVertexInput) -> @builtin(position) vec4 { - let world_pos = shadow_u.model * vec4(in.position, 1.0); + // is_leaf = 0: this pipeline draws the opaque casters (trunks, branches). + let p = foliage_wind_local(in.position, shadow_u.model, shadow_u.wind, shadow_u.misc.z, 0.0); + let world_pos = shadow_u.model * vec4(p, 1.0); return shadow_u.light_vp * world_pos; } -"; +"#); /// Alpha-tested shadow shader for cutout foliage (trees, grass, leaves). Same /// depth-only output as SHADOW_SHADER but samples the caster's base-colour /// alpha and discards below the material cutoff, so cutout cards cast their /// real shape (dappled light) instead of an opaque billboard blob. Used by a /// dedicated pipeline; the opaque shadow path stays untouched. -pub const SHADOW_SHADER_CUTOUT: &str = " +pub const SHADOW_SHADER_CUTOUT: &str = concat!( + include_str!("../shaders/common/foliage_wind.wgsl"), + r#" struct ShadowUniforms { light_vp: mat4x4, model: mat4x4, - misc: vec4, // x = joint offset (used by the skinned variant) + misc: vec4, // x = joint offset (skinned variant), z = foliage wind amount + wind: vec4, // xy = dir, z = amplitude, w = time }; @group(0) @binding(0) var shadow_u: ShadowUniforms; @@ -90,10 +98,14 @@ struct VsOut { @location(0) uv: vec2, }; + @vertex fn vs_shadow_cutout(in: ShadowVertexInput) -> VsOut { var o: VsOut; - let world_pos = shadow_u.model * vec4(in.position, 1.0); + // is_leaf = 1: this pipeline draws the cutout cards, so they get the fast + // flutter layer -- and their shadows now flutter with them. + let p = foliage_wind_local(in.position, shadow_u.model, shadow_u.wind, shadow_u.misc.z, 1.0); + let world_pos = shadow_u.model * vec4(p, 1.0); o.pos = shadow_u.light_vp * world_pos; o.uv = in.uv; return o; @@ -104,7 +116,7 @@ fn fs_shadow_cutout(in: VsOut) { let a = textureSample(base_tex, base_samp, in.uv).a; if (a < cut.cutoff.x) { discard; } } -"; +"#); /// Skinned shadow shader for animated characters (player, enemies). Their /// vertices are *rest-pose* (cached model VBs with raw joint indices, or the @@ -168,8 +180,14 @@ pub struct ShadowUniforms { /// x = joint-buffer base offset for skinned CACHED casters (their /// VBs keep raw joint indices; `vs_shadow_skinned` adds this before /// indexing). 0 for everything else, including the immediate batch - /// whose vertex joints are pre-offset CPU-side. yzw unused. + /// whose vertex joints are pre-offset CPU-side. + /// z = foliage wind amount for this caster (0 = rigid). A tree that bends in + /// the scene pass but not in the shadow pass detaches from its own shadow. + /// yw unused. pub misc: [f32; 4], + /// Global wind: xy = direction in XZ, z = amplitude, w = elapsed seconds. + /// The shadow pass needs it for the same reason the scene pass does. + pub wind: [f32; 4], } /// Shadow map resources for cascaded shadow mapping. diff --git a/native/watchos/src/ffi_stubs.rs b/native/watchos/src/ffi_stubs.rs index 5941602..587a145 100644 --- a/native/watchos/src/ffi_stubs.rs +++ b/native/watchos/src/ffi_stubs.rs @@ -247,6 +247,10 @@ } #[no_mangle] pub extern "C" fn bloom_set_cloud_shadows(_p0: f64, _p1: f64, _p2: f64, _p3: f64) { } +#[no_mangle] pub extern "C" fn bloom_set_model_foliage_wind(_p0: f64, _p1: f64) { +} +#[no_mangle] pub extern "C" fn bloom_set_foliage_shadow_motion(_p0: f64) { +} #[no_mangle] pub extern "C" fn bloom_set_ssr_enabled(_p0: f64) { } #[no_mangle] pub extern "C" fn bloom_set_motion_blur_enabled(_p0: f64) { diff --git a/native/web/src/lib.rs b/native/web/src/lib.rs index a7ea4af..81f622d 100644 --- a/native/web/src/lib.rs +++ b/native/web/src/lib.rs @@ -1534,6 +1534,14 @@ pub fn bloom_set_wind(dir_x: f64, dir_z: f64, amplitude: f64, frequency: f64) { engine().renderer.set_wind(dir_x as f32, dir_z as f32, amplitude as f32, frequency as f32); } #[wasm_bindgen] +pub fn bloom_set_model_foliage_wind(model: f64, amount: f64) { + engine().renderer.set_model_foliage_wind(model.to_bits(), amount as f32); +} +#[wasm_bindgen] +pub fn bloom_set_foliage_shadow_motion(on: f64) { + engine().renderer.set_foliage_shadow_motion(on > 0.5); +} +#[wasm_bindgen] pub fn bloom_set_cloud_shadows(strength: f64, deck_height: f64, feature_scale: f64, drift_speed: f64) { engine().renderer.set_cloud_shadows( strength as f32, deck_height as f32, feature_scale as f32, drift_speed as f32); diff --git a/package.json b/package.json index b566ee7..3c73137 100644 --- a/package.json +++ b/package.json @@ -1650,6 +1650,21 @@ ], "returns": "void" }, + { + "name": "bloom_set_model_foliage_wind", + "params": [ + "f64", + "f64" + ], + "returns": "void" + }, + { + "name": "bloom_set_foliage_shadow_motion", + "params": [ + "f64" + ], + "returns": "void" + }, { "name": "bloom_set_cloud_shadows", "params": [ diff --git a/src/index.ts b/src/index.ts index 826cc6d..35353bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,7 @@ export { export { loadModel, drawModel, drawModelRotated, unloadModel, getModelBounds, genMeshSplineRibbon, + setModelFoliageWind, setFoliageShadowMotion, drawCube, drawCubeWires, drawSphere, drawSphereWires, drawCylinder, drawPlane, drawGrid, drawRay, genMeshCube, genMeshHeightmap, loadShader, compileMaterial, drawMeshWithMaterial, diff --git a/src/models/index.ts b/src/models/index.ts index 24e6ee2..acf880c 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -5,6 +5,8 @@ import { Color, Model, Vec3, Mat4, BoundingBox } from '../core/types'; declare function bloom_load_model(path: number): number; declare function bloom_draw_model(handle: number, x: number, y: number, z: number, scale: number, r: number, g: number, b: number, a: number): void; declare function bloom_draw_model_rotated(handle: number, x: number, y: number, z: number, scale: number, rotY: number, colorPackedArgb: number): void; +declare function bloom_set_model_foliage_wind(handle: number, amount: number): void; +declare function bloom_set_foliage_shadow_motion(on: number): void; declare function bloom_unload_model(handle: number): void; declare function bloom_draw_cube(x: number, y: number, z: number, w: number, h: number, d: number, r: number, g: number, b: number, a: number): void; declare function bloom_draw_cube_wires(x: number, y: number, z: number, w: number, h: number, d: number, r: number, g: number, b: number, a: number): void; @@ -1104,3 +1106,27 @@ export function updateRagdoll(rag: number, anim: number, dt: number): number { export function releaseRagdoll(rag: number): void { bloom_ragdoll_release(rag); } + +/// Mark a model as foliage, so the wind actually bends it. +/// +/// `amount` scales the whole effect: ~1.0 for a tree, less for a stiff shrub, +/// 0 to turn it off. Direction, strength and rate come from `setWind`. +/// +/// The wind is hierarchical (trunk bend, branch sway, leaf flutter) and the +/// layer weights are derived from where each vertex sits relative to the model +/// origin — nothing has to be authored into the mesh. Before this the engine +/// swayed alpha-cut materials only, which meant leaf cards fluttered while every +/// trunk in the scene stood perfectly rigid. +export function setModelFoliageWind(model: Model, amount: number): void { + bloom_set_model_foliage_wind(model.handle, amount); +} + +/// Let foliage sway in the SHADOW pass too, so a bending tree and its shadow bend +/// together and the canopy dapple on the ground actually moves. +/// +/// Off by default, and not free: a caster that moves every frame cannot reuse the +/// cached static shadow depth, so every plant re-renders into every cascade every +/// frame. Measure before leaving it on. +export function setFoliageShadowMotion(on: boolean): void { + bloom_set_foliage_shadow_motion(on ? 1 : 0); +} From 461ad9b07b23f62419979868aa9f60da6de3d7ff Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 16:38:17 +0200 Subject: [PATCH 08/17] fix(EN-043): a moving caster no longer invalidates the whole static shadow cache The static-cascade shadow cache -- the perf round's single biggest win (shadow_pass 7.2 ms -> 0.1-1.7 ms) -- had quietly stopped working. shadow_pass GPU was back to 6.9 ms and the shooter's title screen had fallen from 50.7 to 33.5 fps. CAUSE. A cached, non-skinned caster whose transform changed since last frame stayed in the STATIC set, but its content signature changed -- and a changed signature invalidates the cascade's cached depth. So every tree, wall and terrain tile in the world re-rendered into all three cascades, every single frame, because something small was moving. The cache was working exactly as designed and being defeated by one bobbing object. FIX. A caster that moves is DYNAMIC, by definition. Hash each caster's transform, compare against last frame's, and promote the movers: a dynamic caster draws on top of the cached static depth and never invalidates it. One draw instead of a thousand. shadow_pass GPU 6954 us -> 182 us (38x) title screen 33.5 -> 44.7 fps shadows verified visually: player, canopy and building shadows all present. THE TRAP INSIDE THE FIX, and it is worth writing down. The first cut keyed casters on (model_handle, mesh_idx). The forest is 88 trees sharing THREE model handles -- so all 88 collided on one key, each was compared against some other tree's transform, every one of them was declared a mover, the whole forest went dynamic, it overflowed the 64-slot dynamic budget (EN-042), and EVERY SHADOW IN THE GAME VANISHED. While the fps went up. That is the second time this session a measured "win" turned out to be a correctness loss wearing a win's clothes, and both times the tell was the same: the number improved more than the change could possibly justify. The key now carries the Nth-draw-of-this-handle occurrence index, so occurrence N is the same tree every frame. 116/116 shared tests pass. --- docs/tickets.md | 33 ++++++++++ native/shared/src/renderer/mod.rs | 8 +++ native/shared/src/renderer/shadow_pass.rs | 75 +++++++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/docs/tickets.md b/docs/tickets.md index 6edf5fa..bc35b47 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -1321,3 +1321,36 @@ that refreshes cached static depth on a slow cadence instead of per frame. **Acceptance:** the dynamic set cannot silently drop a caster — it either fits, or the engine degrades a *chosen* class (foliage) rather than whatever happened to be queued last. + + +## EN-043 — A moving cached caster invalidated the ENTIRE static shadow cache ✅ *(fixed 2026-07-12)* + +The static-cascade shadow cache (the perf round's biggest win, shadow_pass 7.2 ms +→ 0.1–1.7 ms) had quietly stopped working. Measured on the shooter's title screen: +**shadow_pass GPU back up to 6.9 ms**, and the title down from 50.7 to 33.5 fps. + +**Cause.** A cached, non-skinned caster whose transform changed since last frame +stayed in the STATIC set — but its content signature changed, which invalidated the +cascade's cached depth. So every tree, wall and terrain tile in the world +re-rendered into all three cascades, every frame, because *something small was +moving*. The cache was working exactly as designed and being defeated by one +bobbing object. + +**Fix.** A caster that moves is DYNAMIC, by definition. Track each caster's +transform hash against last frame's; if it changed, promote it to the dynamic set, +where it draws on top of the cached static depth and never invalidates it. One draw +instead of a thousand. + +**shadow_pass GPU 6954 µs → 182 µs (38×). Title screen 33.5 → 44.7 fps.** + +**The trap inside the fix, which cost a round.** The first cut keyed casters on +`(model_handle, mesh_idx)`. The forest is **88 trees sharing three model handles**, +so every tree collided on one key, each was compared against some other tree's +transform, and all 88 were declared movers — the whole forest went dynamic, +overflowed the 64-slot budget (EN-042), and **every shadow in the game vanished +while the fps went UP**. A perf win that is really a correctness loss, again. The +key now includes the Nth-draw-of-this-handle occurrence index, so occurrence N is +the same tree every frame. + +Related: EN-042 (the dynamic budget silently drops overflow) is what turned a keying +bug into invisible shadows rather than a loud failure. It is still worth fixing. diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index 2c2cda8..118e5c7 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -1471,6 +1471,13 @@ pub struct Renderer { /// every existing game keeps the look it shipped with; the sky still draws /// them. See `common/clouds.wgsl`. cloud_params: [f32; 4], + /// EN-043 — last frame's transform hash per static shadow caster, keyed by its + /// stable identity. A caster whose transform CHANGED since last frame is + /// promoted to the dynamic set instead of being left in the static set with a + /// different content signature — which would invalidate the whole cascade's + /// cached depth and re-render every tree in the world, every frame, because one + /// pickup was bobbing. + shadow_caster_tf: std::collections::HashMap, /// Per-model foliage wind amount, keyed by cached-model handle. Absent or 0 /// = not a plant, don't move it. See `common/foliage_wind.wgsl`. foliage_wind: std::collections::HashMap, @@ -6467,6 +6474,7 @@ impl Renderer { // decision, and silently darkening every existing game's terrain // is not the engine's call to make. cloud_params: [0.0, 420.0, 0.0035, 8.0], + shadow_caster_tf: std::collections::HashMap::new(), foliage_wind: std::collections::HashMap::new(), foliage_shadow_motion: false, uniform_3d_layout, diff --git a/native/shared/src/renderer/shadow_pass.rs b/native/shared/src/renderer/shadow_pass.rs index b25b089..1c3fef7 100644 --- a/native/shared/src/renderer/shadow_pass.rs +++ b/native/shared/src/renderer/shadow_pass.rs @@ -22,6 +22,16 @@ impl Renderer { // the main pass samples from them as if we had redrawn. profiler.begin("shadow_pass"); if self.shadow_map.enabled { + // EN-043 — take last frame's caster transforms out of `self` up front: the + // caster lists below hold immutable borrows of self.model_gpu_cache for the + // rest of the function, so this map cannot be touched through `self` again + // until they are dead. + let prev_caster_tf = std::mem::take(&mut self.shadow_caster_tf); + let mut caster_tf_now: std::collections::HashMap = + std::collections::HashMap::with_capacity(prev_caster_tf.len() + 64); + // Nth-draw-of-this-(handle, mesh) counter — see entry_key. + let mut caster_occ: std::collections::HashMap = + std::collections::HashMap::with_capacity(64); // Compute cascade VPs from the primary directional light and camera. let light_dir = [ self.lighting_uniforms.light_dir[0], @@ -144,6 +154,9 @@ impl Renderer { // caster MOVE, which is why it also forces `dynamic` — a swaying tree // cannot reuse its cached static shadow depth. foliage: f32, + // EN-043 — stable identity (NOT including the transform), so a caster + // that moved can be told apart from a caster that appeared. + key: u64, } fn entry_sig(kind: u8, id: u64, idx: u64, transform: &[[f32; 4]; 4]) -> u64 { let mut h = FNV_OFFSET; @@ -152,6 +165,29 @@ impl Renderer { h = fnv1a_bytes(h, &idx.to_le_bytes()); fnv1a_bytes(h, bytemuck::bytes_of(transform)) } + // EN-043 — a caster's IDENTITY, without its transform, so "this caster + // moved" can be told apart from "a different caster appeared". + // + // `occ` is load-bearing, and the reason this is not just (handle, mesh_idx): + // the forest is 88 trees sharing THREE model handles, so every tree would + // collide on one key, each would be compared against some other tree's + // transform, and all 88 would be declared movers. That is not theoretical — + // it is what the first cut did: the whole forest went dynamic, overflowed + // the 64-slot budget, and every shadow in the game vanished while the fps + // went UP. Exactly the EN-042 trap. + // + // `occ` is the Nth draw of this (handle, mesh) this frame; the game submits + // its draws in a stable order, so occurrence N is the same tree every frame. + fn entry_key(kind: u8, id: u64, idx: u64, occ: u32) -> u64 { + let mut h = FNV_OFFSET; + h = fnv1a_bytes(h, &[kind]); + h = fnv1a_bytes(h, &id.to_le_bytes()); + h = fnv1a_bytes(h, &idx.to_le_bytes()); + fnv1a_bytes(h, &occ.to_le_bytes()) + } + fn tf_hash(transform: &[[f32; 4]; 4]) -> u64 { + fnv1a_bytes(FNV_OFFSET, bytemuck::bytes_of(transform)) + } // Per-frame nonce for animated casters' signatures. Bumped // whenever shadows render — skinned CACHED model draws need it // even when the immediate batch is empty, which is the norm now @@ -195,6 +231,9 @@ impl Renderer { dynamic: false, joint_offset: 0.0, foliage: 0.0, + key: { let k0 = entry_key(0, i as u64, node.gpu_index_count as u64, 0); + let o = caster_occ.entry(k0).or_insert(0); let v = *o; *o += 1; + entry_key(0, i as u64, node.gpu_index_count as u64, v) }, }); } @@ -229,6 +268,7 @@ impl Renderer { dynamic: true, joint_offset: 0.0, foliage: 0.0, + key: 0, }); } else { let num_calls = self.draw_calls_3d.len(); @@ -255,6 +295,7 @@ impl Renderer { dynamic: true, joint_offset: 0.0, foliage: 0.0, + key: 0, }); } } @@ -305,6 +346,7 @@ impl Renderer { dynamic: true, joint_offset: cmd.joint_offset, foliage: 0.0, + key: 0, }); } else { // Only sway the shadow if the game asked for it AND there is @@ -351,6 +393,9 @@ impl Renderer { dynamic: fol > 0.0, joint_offset: 0.0, foliage: fol, + key: { let k0 = entry_key(1, cmd.cache_handle, cmd.mesh_idx as u64, 0); + let o = caster_occ.entry(k0).or_insert(0); let v = *o; *o += 1; + entry_key(1, cmd.cache_handle, cmd.mesh_idx as u64, v) }, }); } } @@ -395,11 +440,40 @@ impl Renderer { dynamic: false, joint_offset: 0.0, foliage: 0.0, + key: { let k0 = entry_key(2, cmd.mesh_handle, cmd.mesh_idx as u64, 0); + let o = caster_occ.entry(k0).or_insert(0); let v = *o; *o += 1; + entry_key(2, cmd.mesh_handle, cmd.mesh_idx as u64, v) }, }); } } } + // EN-043 — promote MOVERS to the dynamic set. + // + // A non-skinned cached caster whose transform changed since last frame used + // to stay in the STATIC set with a different content signature. That + // invalidated the cascade's cached depth, so every tree, wall and terrain + // tile in the world re-rendered into all three cascades — every frame — + // because one pickup was bobbing. Measured on the shooter's title screen: + // shadow_pass GPU 6.0-7.0 ms against the 0.1-1.7 ms the cache was built to + // deliver. + // + // A caster that moves is DYNAMIC, by definition. Dynamic casters draw on + // top of the cached static depth every frame and never invalidate it, which + // is exactly what a moving object needs and costs one draw instead of a + // thousand. + for e in shadow_nodes.iter_mut() { + if e.dynamic { continue; } + let tf = tf_hash(&e.transform); + caster_tf_now.insert(e.key, tf); + if let Some(&prev) = prev_caster_tf.get(&e.key) { + if prev != tf { + e.dynamic = true; + e.sig = nonce; + } + } + } + let cascade_planes: [[[f32; 4]; 6]; crate::shadows::NUM_CASCADES] = std::array::from_fn(|c| { crate::scene::extract_frustum_planes(&self.shadow_map.light_vps[c]) @@ -638,6 +712,7 @@ impl Renderer { // Cache bookkeeping — next frame skips every cascade whose VP // and caster content stay put. + self.shadow_caster_tf = caster_tf_now; self.shadow_map.rendered_light_vps = Some(self.shadow_map.light_vps); self.shadow_map.rendered_light_dir = Some(light_dir); self.shadow_map.rendered_scene_version = scene_ver; From 6f2a5d1cc0eb546517f726905107fb078919673d Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 17:38:53 +0200 Subject: [PATCH 09/17] =?UTF-8?q?feat(EN-044):=20depth=20prepass=20for=20c?= =?UTF-8?q?ached=20models=20=E2=80=94=20main=5Fhdr=207.4ms=20->=202.1ms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fragment shader that can `discard` cannot early-Z WRITE: the GPU must run it in full before it knows whether the pixel survives. The scene shader discards (alpha-cutout foliage), so an 88-tree forest of overlapping leaf cards shaded the entire 5-target MRT several layers deep and threw most of it away. Measured: the forest alone was 5.6 ms of a 7.4 ms main_hdr_pass, and simply not drawing it took the title screen from 46.7 fps to the 60 fps vsync cap. A depth-only prepass now runs the same cached-model draws first -- same vertex stage, so the foliage wind displaces identically and the depths agree; alpha cutout honoured, so the cards keep their real silhouette -- and the main pass draws them through a pipeline with depth writes OFF and an Equal test. TAKING THE WRITES AWAY IS THE WHOLE POINT. Priming depth alone did nothing: the prepass cost 1.4 ms and saved exactly zero, because with depth writes still enabled the discarding shader forced late-Z and nothing was rejected. Drop the writes (the prepass already stored the exact depth) and the hardware early-Z rejects the occluded leaves before the shader ever runs. main_hdr_pass 7.43 ms -> 2.14 ms depth_prepass -- -> 1.37 ms title screen 33.5 -> 56.6 fps gameplay ~33 -> ~40 fps THE BUG THIS UNCOVERED. The sky pipeline was depth_write: true, depth_compare: Always, and the sky draws FIRST inside the HDR pass -- so it stamped depth = 1.0 across the whole screen. That had always been harmless, because the buffer had just been cleared to 1.0 anyway. It became instantly destructive the moment something wrote real geometry depth before it: the sky wiped the lot, the Equal test failed everywhere, and the entire forest and the player VANISHED while the profiler reported a beautiful 60 fps. Third time this session that a "win" was a correctness loss; third time a screenshot caught it. The sky never needed that write. @invariant on the scene VS position is also in place -- genuinely required for an Equal depth test across two pipelines -- though it was not the cause here. 116/116 shared tests pass. Combat verified: enemies, player, shadows, VFX all render. --- docs/tickets.md | 35 +++++ native/shared/src/renderer/mod.rs | 142 ++++++++++++++++++++- native/shared/src/renderer/scene_pass.rs | 83 +++++++++++- native/shared/src/renderer/shaders/core.rs | 30 ++++- 4 files changed, 285 insertions(+), 5 deletions(-) diff --git a/docs/tickets.md b/docs/tickets.md index bc35b47..0834a18 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -1354,3 +1354,38 @@ the same tree every frame. Related: EN-042 (the dynamic budget silently drops overflow) is what turned a keying bug into invisible shadows rather than a loud failure. It is still worth fixing. + + +## EN-044 — Depth prepass for cached models ✅ *(shipped 2026-07-12)* + +The scene fragment shader can `discard` (alpha-cutout foliage), and **a shader that +may discard cannot early-Z write** — the GPU has to run it in full before it knows +whether the pixel survives. So an 88-tree forest of overlapping leaf cards shaded +the whole 5-target MRT several layers deep and threw most of it away. Measured: the +forest alone was **5.6 ms of a 7.4 ms `main_hdr_pass`**, and simply not drawing it +took the title screen from 46.7 fps to the 60 fps vsync cap. + +Now a depth-only prepass runs the same cached-model draws first (same vertex stage, +so the foliage wind displaces identically; alpha cutout honoured so cards keep their +real silhouette), and the main pass draws them through a pipeline with **depth +writes OFF and an `Equal` test**. Taking the writes away is the whole point: with +writes on, a discarding shader forces late-Z and nothing is rejected. Without them, +the hardware early-Z rejects the occluded leaves before the shader runs. + +| pass | before | after | +|---|---|---| +| `main_hdr_pass` | 7.43 ms | **2.14 ms** | +| `depth_prepass` | — | 1.37 ms | +| **title screen** | **33.5 fps** | **56.6 fps** | + +**The bug this uncovered, which is the interesting part.** The sky pipeline was +`depth_write: true, depth_compare: Always`, and the sky is drawn *first* inside the +HDR pass. That stamped depth = 1.0 across the entire screen. It had always been +harmless — the buffer had just been cleared to 1.0 anyway — and it became instantly +destructive the moment a prepass wrote real geometry depth *before* it: the sky +wiped the lot, the `Equal` test failed everywhere, and **the entire forest and the +player vanished**. The sky never needed that write. It is off now. + +(First suspect was depth invariance — two pipelines compiling the same vertex shader +differently. `@invariant` on the position is in place and is genuinely required for +an `Equal` test, but it was not the cause.) diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index 118e5c7..c8b5e48 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -1609,6 +1609,10 @@ pub struct Renderer { // mapping). Distinct from pipeline_3d so immediate-mode draws // don't have to carry tangent vertex data or normal-map bindings. pub scene_pipeline: wgpu::RenderPipeline, + /// EN-044 — depth-only prepass over the same cached-model draws. + pub scene_depth_pipeline: wgpu::RenderPipeline, + /// EN-044 — cached-model pipeline for the prepassed path (no depth write, Equal). + pub scene_pipeline_prepassed: wgpu::RenderPipeline, pub scene_material_layout: wgpu::BindGroupLayout, /// Froxel light clustering (task #23). `Some` when the device has /// fragment-stage storage buffers (everything but WebGL2); the @@ -2607,7 +2611,15 @@ impl Renderer { // frame; the 3D opaque pass will overwrite where it draws. depth_stencil: Some(wgpu::DepthStencilState { format: DEPTH_FORMAT, - depth_write_enabled: Some(true), + // EN-044 — the sky must NOT write depth. It is drawn first, with + // `Always`, so it used to stamp depth = 1.0 across the whole screen. + // That was harmless while the buffer had just been cleared to 1.0 + // anyway — and instantly destructive once a depth PREPASS started + // writing real geometry depth before it. The sky wiped the lot, the + // main pass's Equal test then failed everywhere, and the entire + // forest and player vanished. It never needed the write: where no + // geometry is drawn the depth is already 1.0. + depth_write_enabled: Some(false), depth_compare: Some(wgpu::CompareFunction::Always), stencil: wgpu::StencilState::default(), bias: wgpu::DepthBiasState::default(), @@ -2843,7 +2855,15 @@ impl Renderer { }, depth_stencil: Some(wgpu::DepthStencilState { format: DEPTH_FORMAT, - depth_write_enabled: Some(true), + // EN-044 — the sky must NOT write depth. It is drawn first, with + // `Always`, so it used to stamp depth = 1.0 across the whole screen. + // That was harmless while the buffer had just been cleared to 1.0 + // anyway — and instantly destructive once a depth PREPASS started + // writing real geometry depth before it. The sky wiped the lot, the + // main pass's Equal test then failed everywhere, and the entire + // forest and player vanished. It never needed the write: where no + // geometry is drawn the depth is already 1.0. + depth_write_enabled: Some(false), depth_compare: Some(wgpu::CompareFunction::Always), stencil: wgpu::StencilState::default(), bias: wgpu::DepthBiasState::default(), @@ -3400,6 +3420,122 @@ impl Renderer { cache: None, }); + // EN-044 — depth prepass pipeline. Identical layout and vertex stage to + // scene_pipeline (the wind must displace the same way, or the depths would + // not match); no colour targets, and a fragment stage that only honours the + // alpha cutout. Cheap: one depth write per fragment, no MRT, no lighting. + let scene_depth_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("scene_depth_prepass_pipeline"), + layout: Some(&scene_pipeline_layout), + vertex: wgpu::VertexState { + module: &scene_shader, + entry_point: Some("vs_main_scene"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &scene_shader, + entry_point: Some("fs_depth_prepass"), + targets: &[], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + // EN-044 — the cached-model pipeline for the PREPASSED path. + // + // depth_write is OFF, and that is the entire point. A fragment shader that + // can `discard` (alpha-cutout foliage) combined with depth WRITES forces the + // hardware into late-Z: it cannot know whether to write depth until the + // shader has run, so it runs the shader for every fragment, occluded or not. + // That is why simply priming depth changed nothing — the prepass cost 1.4 ms + // and saved zero. + // + // Take the writes away (the prepass already stored the exact depth) and the + // hardware is free to early-Z *test*, which throws out the occluded leaves + // before the 5-target MRT shader ever runs. `Equal` because the visible + // surface's depth is bit-identical to what the prepass wrote — same vertex + // stage, same uniforms, same wind. + // + // scene_pipeline (Less, write) stays for the retained scene-graph nodes, + // which are not in the prepass. + let scene_pipeline_prepassed = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("scene_pipeline_prepassed"), + layout: Some(&scene_pipeline_layout), + vertex: wgpu::VertexState { + module: &scene_shader, + entry_point: Some("vs_main_scene"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &scene_shader, + entry_point: Some("fs_main_scene"), + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: MATERIAL_FORMAT, + // Replace blend so the material slot reflects + // the topmost-fragment material, not blended. + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Equal), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + // Default flat-normal 1×1 texture for meshes that have tangents // but no normal map. Encodes (0, 0, 1) in tangent space: // RGB = (0.5, 0.5, 1.0) * 255 = (128, 128, 255) @@ -6538,6 +6674,8 @@ impl Renderer { aerial_perspective_sampler, env_diffuse_texture: None, scene_pipeline, + scene_depth_pipeline, + scene_pipeline_prepassed, froxel, scene_material_layout, _scene_env_default_texture: scene_env_default_texture, diff --git a/native/shared/src/renderer/scene_pass.rs b/native/shared/src/renderer/scene_pass.rs index 0985e67..d5aad66 100644 --- a/native/shared/src/renderer/scene_pass.rs +++ b/native/shared/src/renderer/scene_pass.rs @@ -36,6 +36,76 @@ impl Renderer { self.dispatch_aerial_perspective_lut(); } + // EN-044 — DEPTH PREPASS over the cached-model draws. + // + // The scene fragment shader can `discard` (alpha-cutout foliage), and a shader + // that may discard cannot early-Z *write*: the GPU must run it in full before it + // knows whether the pixel survives. So an 88-tree forest of overlapping leaf + // cards shaded the whole 5-target MRT several layers deep and threw most of it + // away. Measured: the forest alone was 5.6 ms of a 7.4 ms main_hdr_pass, and + // dropping it took the title screen from 46.7 fps to the 60 fps vsync cap. + // + // Priming depth first turns that around. The prepass writes depth only (no MRT, + // no lighting, alpha cutout honoured so cards keep their real silhouette), and + // the main pass then early-Z *rejects* the occluded leaves before its shader + // ever runs. The main pass tests LessEqual, not Less — the visible surface + // arrives with a depth exactly equal to the one the prepass stored, and `Less` + // would throw it away. + // + // Same vertex stage, so the foliage wind displaces identically in both and the + // depths agree to the bit. + // Runs even with no cached models, because it now owns the depth CLEAR that + // main_hdr_pass used to do — skipping it would hand the main pass a depth + // buffer full of last frame's garbage. + profiler.begin("depth_prepass"); + { + let prepass_ts = profiler.pass_timestamp_writes("depth_prepass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_depth_prepass"), + color_attachments: &[], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: prepass_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.scene_depth_pipeline); + pass.set_bind_group(1, &self.lighting_bind_group, &[]); + pass.set_bind_group(3, &self.joint_bind_group, &[]); + let cam_vp = mat4_multiply( + self.current_proj_matrix_unjittered, + self.current_view_matrix, + ); + let cam_planes = crate::scene::extract_frustum_planes(&cam_vp); + for cmd in &self.model_draw_commands { + if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) { + if cmd.mesh_idx < meshes.len() { + let mesh = &meshes[cmd.mesh_idx]; + let (wmin, wmax) = cmd.bounds_override.unwrap_or_else(|| { + transform_aabb(&cmd.model, mesh.local_min, mesh.local_max) + }); + if wmin[0] <= wmax[0] + && crate::scene::aabb_outside_frustum(&cam_planes, wmin, wmax) + { + continue; + } + pass.set_bind_group(0, &self.model_uniform_bind_groups[cmd.uniform_slot], &[]); + pass.set_bind_group(2, &mesh.material_bg, &[]); + pass.set_vertex_buffer(0, mesh.vb.slice(..)); + pass.set_index_buffer(mesh.ib.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..mesh.index_count, 0, 0..1); + } + } + } + } + profiler.end("depth_prepass"); + profiler.begin("main_hdr_pass"); { // HDR clear: the user's clear_color is in 0-1 srgb-ish @@ -99,7 +169,9 @@ impl Renderer { depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &self.depth_view, depth_ops: Some(wgpu::Operations { - load: wgpu::LoadOp::Clear(1.0), + // EN-044 — LOAD, not Clear: the depth prepass just primed this + // buffer, and clearing it here would throw that away. + load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }), stencil_ops: None, @@ -154,7 +226,11 @@ impl Renderer { // Cached models + retained scene graph — both via scene_pipeline. let has_cached_models = !self.model_draw_commands.is_empty(); if has_cached_models || scene.node_count() > 0 { - pass.set_pipeline(&self.scene_pipeline); + // EN-044 — cached models go through the PREPASSED pipeline (no depth + // write, Equal test), because the depth prepass above already stored + // their exact depth. That is what lets the hardware early-Z reject the + // occluded leaf cards instead of shading every one of them. + pass.set_pipeline(&self.scene_pipeline_prepassed); pass.set_bind_group(1, &self.lighting_bind_group, &[]); pass.set_bind_group(3, &self.joint_bind_group, &[]); @@ -197,6 +273,9 @@ impl Renderer { } } + // Retained scene-graph nodes are not in the prepass, so they still need + // the depth-writing pipeline. + pass.set_pipeline(&self.scene_pipeline); scene.render(&mut pass); } } diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index 17d72a8..dc07476 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -276,7 +276,16 @@ struct VertexInputScene { }; struct VertexOutputScene { - @builtin(position) clip_position: vec4, + // EN-044 — @invariant is load-bearing. The depth prepass and the main pass run + // the SAME vertex entry point, but through different pipelines: the prepass's + // fragment stage consumes almost none of the varyings, so the compiler is free + // to optimise the position maths differently (fma contraction, reassociation) + // and the two depths stop being bit-identical. The main pass then tests Equal + // against a depth that is one ulp off, every fragment fails, and the entire + // forest and the player VANISH — which is exactly what happened, and it looked + // like a 60 fps win. @invariant forbids that: the position must be computed + // identically in every pipeline that uses this shader. + @invariant @builtin(position) clip_position: vec4, @location(0) normal: vec3, @location(1) color: vec4, @location(2) uv: vec2, @@ -726,6 +735,25 @@ struct SceneOut { @location(3) albedo: vec4, }; +// EN-044 — depth prepass. Same vertex stage as the main pass (so the foliage wind +// displaces identically and the depths match), and a fragment stage that does +// nothing but honour the alpha cutout. +// +// WHY THIS EARNS ITS PASS. The scene fragment shader can `discard` (alpha-cutout +// foliage), and a shader that may discard cannot early-Z *write* — the GPU has to +// run the whole thing before it knows if the pixel survives. So every leaf card in +// an 88-tree forest shaded the full 5-target MRT, several layers deep, and threw +// most of it away. Priming depth first lets the main pass early-Z *reject* those +// fragments before the shader ever runs. +@fragment +fn fs_depth_prepass(in: VertexOutputScene) { + let alpha_cutoff = material.metal_rough.w; + if (alpha_cutoff > 0.0) { + let a = textureSample(base_color_tex, base_color_samp, in.uv).a * in.color.a; + if (a < alpha_cutoff) { discard; } + } +} + @fragment fn fs_main_scene(in: VertexOutputScene) -> SceneOut { var n = normalize(in.normal); From 03c72a5ce474023a5e78f7d4881e41715e2a5845 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 17:41:55 +0200 Subject: [PATCH 10/17] fix(EN-042): the dynamic shadow budget no longer silently drops the player's shadow SHADOW_MAX_DYNAMIC was 64, and the overflow was dropped in QUEUE ORDER, silently. Fine while "dynamic" meant a handful of characters. A trap the moment a forest could go dynamic -- 88 trees x 4 primitives = 352 casters -- and it cost this project twice in a single session: - enabling swaying foliage shadows measured 34 -> 40 fps, because it had deleted every tree shadow AND the player's own shadow from under their feet; - the first cut of EN-043 measured 42 fps, for exactly the same reason. Both looked like wins. Neither was. A budget that silently drops whatever happened to be queued last is a landmine, and the fact that it makes the frame rate go UP is what makes it dangerous. Fixed two ways. The budget is 256 (of 1024 slots per cascade). And the drop is now RANKED rather than accidental: characters first -- the shadow a player actually looks at -- then other movers, then foliage, because a swaying canopy shadow is soft and dappled and the most forgiving thing in the frame. If a shadow has to be lost, it is a chosen one. Also reconciles the ticket doc: EN-025/026/027/028/029/030/031 all shipped this round but still read as open. EN-012 and EN-015 annotated with what measurement actually showed (the shooter declined the foliage shading model because its V1 transmission drops the dot(-N,L) gate; imposters were deprioritised because the forest turned out to be overdraw, not geometry). 116/116 shared tests pass. Player and canopy shadows verified present. --- docs/tickets.md | 40 ++++++++++++++++++----- native/shared/src/renderer/shadow_pass.rs | 22 +++++++++++-- native/shared/src/shadows.rs | 19 +++++++---- 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/docs/tickets.md b/docs/tickets.md index 0834a18..c78b5ae 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -353,7 +353,7 @@ bank, with the reflection wobbling on the wave normal. --- -## EN-012 — Foliage shading model in PBR ABI 🟡 +## EN-012 — Foliage shading model in PBR ABI 🟡 *(V1 landed; see shooter SH-023 — its transmission drops the dot(-N,L) gate and it excludes ambient/IBL, so the shooter declined to adopt it. A V2 needs both.)* **Why:** SH-011 (grass) and SH-012 (tree) both bolt local wrap-lambert + transmission terms into bespoke material shaders. @@ -487,7 +487,7 @@ sampler call per layer-class instead of 4. --- -## EN-015 — Imposter / billboard system 🟡 +## EN-015 — Imposter / billboard system 🟡 *(deprioritised — the shooter's 88-tree forest was measured as pure alpha-cutout OVERDRAW, not geometry; EN-044's depth prepass solved it. Imposters remain a >500-instance tool.)* **Why:** the shooter ships 120 trees today; opening the playfield or moving toward "stand on a hill, see a forest stretching to the @@ -903,7 +903,7 @@ which also carries the round ordering. --- -## EN-025 — Ragdoll FFI 🟡 +## EN-025 — Ragdoll FFI ✅ *(shipped 2026-07-12)* **Why:** Jolt ships `Ragdoll.cpp` in the vendored submodule but no `bloom_physics_ragdoll_*` FFI exists — the shooter's enemies die by @@ -945,7 +945,7 @@ edges, reacts to the killing impulse, never clips the heightfield; --- -## EN-026 — Particle / VFX system 🟡 +## EN-026 — Particle / VFX system ✅ *(shipped)* **Why:** the engine has **no particle system at all** (spec Phase I, unbuilt) — the shooter fakes sparks with a 16-slot pool of additive @@ -987,7 +987,7 @@ budget; mobile halves pool sizes via the same descriptors. --- -## EN-027 — Deferred decal system 🟡 +## EN-027 — Deferred decal system ✅ *(shipped)* **Why:** the world takes no marks — no bullet holes, scorch, or blood splats. The impulse field is a material *input* (ripples/wet), not @@ -1019,7 +1019,7 @@ conforming to terrain slope and building walls, no lighting seams, --- -## EN-028 — Animation blending, masks, root motion 🟡 +## EN-028 — Animation blending, masks, root motion ✅ *(shipped — AnimMixer)* **Why:** the animation FFI plays exactly one clip per model (`update_model_animation(handle, index, time)`), so every transition @@ -1051,7 +1051,7 @@ pounce follows its authored root arc instead of hand-tuned kinematics. --- -## EN-029 — Audio buses, reverb send, occlusion filter 🟡 +## EN-029 — Audio buses, reverb send, occlusion filter ✅ *(shipped)* **Why:** the mixer is master + per-voice gain — no submixes, no DSP. AAA "gunfeel" is half audio: a weapon tail through a reverb send, a @@ -1083,7 +1083,7 @@ building is muffled; zero underruns/glitches at 48 kHz with 32 voices. --- -## EN-030 — UI widget layer 🟢 *(TS-side, no Rust needed)* +## EN-030 — UI widget layer ✅ *(shipped — TS-side, `src/menu.ts` in the shooter)* **Why:** the engine offers immediate-mode shapes + text only. Every game needs pause/settings/menus, and the editor hand-rolls its own @@ -1110,7 +1110,7 @@ widgets incrementally. --- -## EN-031 — Gamepad backend verification + rumble 🟢 +## EN-031 — Gamepad backend verification + rumble ✅ *(shipped — XInput polling was already wired; rumble added)* **Why:** the FFI surface exists (`bloom_is_gamepad_available`, `bloom_get_gamepad_axis`, `bloom_is_gamepad_button_*`, @@ -1389,3 +1389,25 @@ player vanished**. The sky never needed that write. It is off now. (First suspect was depth invariance — two pipelines compiling the same vertex shader differently. `@invariant` on the position is in place and is genuinely required for an `Equal` test, but it was not the cause.) + + +--- + +## EN-042 — Dynamic shadow-caster budget ✅ *(fixed 2026-07-12)* + +`SHADOW_MAX_DYNAMIC` was **64**, and the overflow was dropped **in queue order, +silently**. That was fine while "dynamic" meant a handful of characters. It became a +trap the moment a *forest* could go dynamic — 88 trees × 4 primitives = 352 casters — +and it cost this project twice in one session: + +- enabling swaying foliage shadows measured **34 → 40 fps**, because it had deleted + every tree shadow **and the player's own shadow**; +- the first cut of EN-043 measured **42 fps** for the same reason. + +Both looked like wins. Neither was. A budget that silently drops whatever happened to +be queued last is a landmine. + +**Fixed two ways.** The budget is **256** (from 1024 slots/cascade). And the drop is +**ranked**, not accidental: characters first (the shadow a player actually looks at), +then other movers, then foliage — a swaying canopy shadow is soft, dappled and the +most forgiving thing in the frame. If a shadow must be lost, it is now a chosen one. diff --git a/native/shared/src/renderer/shadow_pass.rs b/native/shared/src/renderer/shadow_pass.rs index 1c3fef7..baef067 100644 --- a/native/shared/src/renderer/shadow_pass.rs +++ b/native/shared/src/renderer/shadow_pass.rs @@ -633,10 +633,28 @@ impl Renderer { ); if dyn_now { let dyn_base = cascade_base + stride * max_static; - let dyn_entries: Vec = entries.iter().copied() + // EN-042 — the dynamic budget can overflow, and the overflow IS + // dropped. Which caster gets dropped must not be an accident of + // queue order. It was, and it cost this project twice: both times + // the thing that silently vanished was the player's own shadow, and + // both times the frame rate went UP and looked like a win. + // + // Rank them, so if we must lose a shadow we lose one nobody misses: + // characters first (the shadow a player actually looks at), then + // other movers, then foliage — a swaying canopy shadow is soft and + // dappled and the most forgiving thing in the frame. + let mut dyn_entries: Vec = entries.iter().copied() .filter(|&ei| shadow_nodes[ei].dynamic) - .take(max_dynamic) .collect(); + if dyn_entries.len() > max_dynamic { + dyn_entries.sort_by_key(|&ei| { + let e = &shadow_nodes[ei]; + if e.skinned { 0u8 } + else if e.foliage > 0.0 { 2u8 } + else { 1u8 } + }); + dyn_entries.truncate(max_dynamic); + } let mut uniform_data: Vec = vec![0u8; stride * dyn_entries.len().max(1)]; for (slot, &ei) in dyn_entries.iter().enumerate() { diff --git a/native/shared/src/shadows.rs b/native/shared/src/shadows.rs index 6537ddf..8c45ccd 100644 --- a/native/shared/src/shadows.rs +++ b/native/shared/src/shadows.rs @@ -26,12 +26,19 @@ pub const SHADOW_FAR: f32 = 100.0; pub const SHADOW_UNIFORM_STRIDE: u32 = 256; pub const SHADOW_MAX_NODES: u32 = 1024; /// Slots at the TAIL of each cascade's uniform region reserved for -/// dynamic (immediate-batch) casters, which re-render every frame while -/// static casters keep their cached depth. Disjoint slot ranges keep the -/// every-frame dynamic writes from clobbering the uniforms the static -/// render was encoded against (all `write_buffer`s land at submit, -/// before any pass executes). -pub const SHADOW_MAX_DYNAMIC: u32 = 64; +/// dynamic casters, which re-render every frame while static casters keep their +/// cached depth. Disjoint slot ranges keep the every-frame dynamic writes from +/// clobbering the uniforms the static render was encoded against (all +/// `write_buffer`s land at submit, before any pass executes). +/// +/// EN-042 — raised from 64. Sixty-four was fine while "dynamic" meant a handful of +/// characters, and became a trap the moment a *forest* could go dynamic: 88 trees x +/// 4 primitives is 352 casters, the overflow was dropped in queue order, and what +/// disappeared was whatever happened to be last — twice this session, that was the +/// player's own shadow from under their feet. 256 covers a moving crowd; the drop is +/// also RANKED now (see shadow_pass.rs) so an overflow costs a canopy shadow rather +/// than a character's. +pub const SHADOW_MAX_DYNAMIC: u32 = 256; /// Depth-only shader for shadow pass. pub const SHADOW_SHADER: &str = concat!( From 99400fe14024dd08921123505a4a57e414b142c4 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 20:23:37 +0200 Subject: [PATCH 11/17] fix(EN-045): the static shadow cache only ever worked on a stationary camera shadow_pass GPU was 0.12 ms on the title screen and 3.2 ms in a moving fight. A 27x gap that nobody had looked at -- because the cache had been MEASURED on the title screen. CAUSE. A cascade keeps its cached static depth only while its VP is unchanged. compute_cascade_vps has exactly the machinery for that: an `accepted_fit` inflated by REFIT_SLACK, so a cascade keeps its VP while the camera travels within the slack. It was gated on `c > 0`. Cascade 0 -- the NEAR cascade, the one holding the player and everything they are standing next to -- re-fit every frame. Its VP therefore changed on every frame the camera moved, which is all of gameplay, and every static caster in it re-rendered every frame. FIX. Cascade 0 gets the slack too. It costs ~15% of near-field shadow resolution and buys a cache that survives ~15 frames of walking instead of zero. shadow_pass GPU (combat) 3.58 ms -> 0.53 ms gameplay 42-44 -> 53-56 fps Near-field quality verified by screenshot: the player's own shadow is still crisp. ALSO, EN-043 follow-up: the caster identity has to be ORDER-INDEPENDENT. The first version keyed on "the Nth draw of this model handle", which was fine until the game started drawing its forest FRONT-TO-BACK -- the sort order changes as the camera moves, so occurrence N became a different tree every frame, dozens of stationary trees were misread as movers, and the dynamic caster set blew past 32 in combat. Two of my own changes interacting. The key is now one hash of identity AND transform, tested for membership in last frame's set: "was this exact caster, at this exact transform, here last frame?" A set test does not care about draw order. A LESSON ABOUT BENCHMARKS. The cache was landed, measured and celebrated on a screen where the camera does not move -- the one condition under which its central assumption always holds. Measure the thing you actually ship. 116/116 shared tests pass. --- docs/tickets.md | 44 +++++++++++++++ native/shared/src/renderer/mod.rs | 4 +- native/shared/src/renderer/shadow_pass.rs | 67 ++++++++++------------- native/shared/src/shadows.rs | 24 ++++++-- 4 files changed, 93 insertions(+), 46 deletions(-) diff --git a/docs/tickets.md b/docs/tickets.md index c78b5ae..ede28dd 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -1411,3 +1411,47 @@ be queued last is a landmine. **ranked**, not accidental: characters first (the shadow a player actually looks at), then other movers, then foliage — a swaying canopy shadow is soft, dappled and the most forgiving thing in the frame. If a shadow must be lost, it is now a chosen one. + + +## EN-045 — The static shadow cache only ever worked on a stationary camera ✅ *(fixed 2026-07-12)* + +`shadow_pass` GPU was **0.12 ms on the title screen and 3.2 ms in a moving fight** — +a 27× gap that nobody had looked at, because the cache had been *measured on the +title screen*. + +**Cause.** A cascade keeps its cached static depth only while its VP is unchanged. +`compute_cascade_vps` has exactly the machinery for that — an `accepted_fit` with +`REFIT_SLACK`, so the cascade keeps its VP while the camera travels within slack — +and it was gated on **`c > 0`**. Cascade 0, the NEAR cascade holding the player and +everything they are standing next to, re-fit **every frame**. So its VP changed on +every frame the camera moved, which is all of gameplay, and every static caster in +it re-rendered every frame. + +**Fix.** Cascade 0 gets the slack too. It costs ~15% of near-field shadow resolution +and buys a cache that survives ~15 frames of walking instead of zero. + +| | before | after | +|---|---|---| +| `shadow_pass` GPU (combat) | 3.58 ms | **0.53 ms** | +| gameplay | 42–44 fps | **53–56 fps** | + +Near-field shadow quality verified by screenshot: the player's own shadow is still +crisp and correctly shaped. + +**A lesson about benchmarks.** The cache was landed, measured, and celebrated on a +screen where the camera does not move — the one condition under which its central +assumption always holds. Measure the thing you actually ship. + +--- + +## EN-043 follow-up — the caster identity must be ORDER-INDEPENDENT + +The first version keyed a caster on *"the Nth draw of this model handle"*. That held +until the game started drawing its forest **front-to-back**: the sort order changes +as the camera moves, so occurrence N became a different tree every frame, dozens of +perfectly stationary trees were misread as movers, and the dynamic caster set blew +past 32 in combat. + +The key is now a single hash of **identity AND transform**, tested for membership in +last frame's set. "Was this exact caster, at this exact transform, here last frame?" +A set membership test does not care what order the draws arrive in. diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index c8b5e48..2850c11 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -1477,7 +1477,7 @@ pub struct Renderer { /// different content signature — which would invalidate the whole cascade's /// cached depth and re-render every tree in the world, every frame, because one /// pickup was bobbing. - shadow_caster_tf: std::collections::HashMap, + shadow_caster_tf: std::collections::HashSet, /// Per-model foliage wind amount, keyed by cached-model handle. Absent or 0 /// = not a plant, don't move it. See `common/foliage_wind.wgsl`. foliage_wind: std::collections::HashMap, @@ -6610,7 +6610,7 @@ impl Renderer { // decision, and silently darkening every existing game's terrain // is not the engine's call to make. cloud_params: [0.0, 420.0, 0.0035, 8.0], - shadow_caster_tf: std::collections::HashMap::new(), + shadow_caster_tf: std::collections::HashSet::new(), foliage_wind: std::collections::HashMap::new(), foliage_shadow_motion: false, uniform_3d_layout, diff --git a/native/shared/src/renderer/shadow_pass.rs b/native/shared/src/renderer/shadow_pass.rs index baef067..e149dd3 100644 --- a/native/shared/src/renderer/shadow_pass.rs +++ b/native/shared/src/renderer/shadow_pass.rs @@ -26,12 +26,9 @@ impl Renderer { // caster lists below hold immutable borrows of self.model_gpu_cache for the // rest of the function, so this map cannot be touched through `self` again // until they are dead. - let prev_caster_tf = std::mem::take(&mut self.shadow_caster_tf); - let mut caster_tf_now: std::collections::HashMap = - std::collections::HashMap::with_capacity(prev_caster_tf.len() + 64); - // Nth-draw-of-this-(handle, mesh) counter — see entry_key. - let mut caster_occ: std::collections::HashMap = - std::collections::HashMap::with_capacity(64); + let prev_caster_ids = std::mem::take(&mut self.shadow_caster_tf); + let mut caster_ids_now: std::collections::HashSet = + std::collections::HashSet::with_capacity(prev_caster_ids.len() + 64); // Compute cascade VPs from the primary directional light and camera. let light_dir = [ self.lighting_uniforms.light_dir[0], @@ -165,28 +162,26 @@ impl Renderer { h = fnv1a_bytes(h, &idx.to_le_bytes()); fnv1a_bytes(h, bytemuck::bytes_of(transform)) } - // EN-043 — a caster's IDENTITY, without its transform, so "this caster - // moved" can be told apart from "a different caster appeared". + // EN-043 — "was this exact caster, at this exact transform, here last frame?" // - // `occ` is load-bearing, and the reason this is not just (handle, mesh_idx): - // the forest is 88 trees sharing THREE model handles, so every tree would - // collide on one key, each would be compared against some other tree's - // transform, and all 88 would be declared movers. That is not theoretical — - // it is what the first cut did: the whole forest went dynamic, overflowed - // the 64-slot budget, and every shadow in the game vanished while the fps - // went UP. Exactly the EN-042 trap. + // One combined hash of identity AND transform, looked up in a SET. If it is + // in last frame's set, the caster has not moved and stays static. If it is + // not, it either moved or is new — either way it goes in the dynamic set, + // where it draws on top of the cached static depth instead of invalidating it. // - // `occ` is the Nth draw of this (handle, mesh) this frame; the game submits - // its draws in a stable order, so occurrence N is the same tree every frame. - fn entry_key(kind: u8, id: u64, idx: u64, occ: u32) -> u64 { + // ORDER-INDEPENDENT, and that is the whole point of the rewrite. The first + // version keyed on "the Nth draw of this model handle", which was fine until + // the game started drawing its forest FRONT-TO-BACK: the sort order changes + // as the camera moves, so occurrence N became a different tree every frame, + // dozens of perfectly stationary trees were misread as movers, and the + // dynamic set blew past 32 casters in combat. A set membership test does not + // care what order the draws arrive in. + fn caster_id(kind: u8, id: u64, idx: u64, transform: &[[f32; 4]; 4]) -> u64 { let mut h = FNV_OFFSET; h = fnv1a_bytes(h, &[kind]); h = fnv1a_bytes(h, &id.to_le_bytes()); h = fnv1a_bytes(h, &idx.to_le_bytes()); - fnv1a_bytes(h, &occ.to_le_bytes()) - } - fn tf_hash(transform: &[[f32; 4]; 4]) -> u64 { - fnv1a_bytes(FNV_OFFSET, bytemuck::bytes_of(transform)) + fnv1a_bytes(h, bytemuck::bytes_of(transform)) } // Per-frame nonce for animated casters' signatures. Bumped // whenever shadows render — skinned CACHED model draws need it @@ -231,9 +226,7 @@ impl Renderer { dynamic: false, joint_offset: 0.0, foliage: 0.0, - key: { let k0 = entry_key(0, i as u64, node.gpu_index_count as u64, 0); - let o = caster_occ.entry(k0).or_insert(0); let v = *o; *o += 1; - entry_key(0, i as u64, node.gpu_index_count as u64, v) }, + key: caster_id(0, i as u64, node.gpu_index_count as u64, &node.transform), }); } @@ -393,9 +386,7 @@ impl Renderer { dynamic: fol > 0.0, joint_offset: 0.0, foliage: fol, - key: { let k0 = entry_key(1, cmd.cache_handle, cmd.mesh_idx as u64, 0); - let o = caster_occ.entry(k0).or_insert(0); let v = *o; *o += 1; - entry_key(1, cmd.cache_handle, cmd.mesh_idx as u64, v) }, + key: caster_id(1, cmd.cache_handle, cmd.mesh_idx as u64, &cmd.model), }); } } @@ -440,9 +431,7 @@ impl Renderer { dynamic: false, joint_offset: 0.0, foliage: 0.0, - key: { let k0 = entry_key(2, cmd.mesh_handle, cmd.mesh_idx as u64, 0); - let o = caster_occ.entry(k0).or_insert(0); let v = *o; *o += 1; - entry_key(2, cmd.mesh_handle, cmd.mesh_idx as u64, v) }, + key: caster_id(2, cmd.mesh_handle, cmd.mesh_idx as u64, &cmd.model), }); } } @@ -464,13 +453,13 @@ impl Renderer { // thousand. for e in shadow_nodes.iter_mut() { if e.dynamic { continue; } - let tf = tf_hash(&e.transform); - caster_tf_now.insert(e.key, tf); - if let Some(&prev) = prev_caster_tf.get(&e.key) { - if prev != tf { - e.dynamic = true; - e.sig = nonce; - } + caster_ids_now.insert(e.key); + // Not here last frame at this exact transform => it moved (or is new). + // Either way: dynamic. A first-frame caster costs one extra dynamic draw + // and settles into the static set next frame. + if !prev_caster_ids.is_empty() && !prev_caster_ids.contains(&e.key) { + e.dynamic = true; + e.sig = nonce; } } @@ -730,7 +719,7 @@ impl Renderer { // Cache bookkeeping — next frame skips every cascade whose VP // and caster content stay put. - self.shadow_caster_tf = caster_tf_now; + self.shadow_caster_tf = caster_ids_now; self.shadow_map.rendered_light_vps = Some(self.shadow_map.light_vps); self.shadow_map.rendered_light_dir = Some(light_dir); self.shadow_map.rendered_scene_version = scene_ver; diff --git a/native/shared/src/shadows.rs b/native/shared/src/shadows.rs index 8c45ccd..6e5b206 100644 --- a/native/shared/src/shadows.rs +++ b/native/shared/src/shadows.rs @@ -838,7 +838,19 @@ impl ShadowMap { // byte-identical. The per-cascade shadow cache compares VPs // exactly, so a kept VP means the cascade's cached depth // stays valid while the camera travels within the slack. - if c > 0 { + // EN-045 — cascade 0 gets the slack too. + // + // It was excluded, and that quietly made the whole static-shadow cache a + // title-screen feature. Cascade 0 is the NEAR cascade: it holds the + // player and everything they are standing next to. Re-fitting it every + // frame means its VP changes every frame the camera moves — which is all + // of gameplay — so its cached depth was thrown away and every static + // caster in it re-rendered, every frame. Measured: shadow_pass 0.12 ms on + // the stationary title screen, 3.2 ms in a moving fight. + // + // The slack costs ~15% of near-field shadow resolution and buys a cache + // that survives ~15 frames of walking instead of zero. + { if let Some(acc) = self.accepted_fit[c] { let ls_x = dot3(center, right); let ls_y = dot3(center, ortho_up); @@ -881,7 +893,7 @@ impl ShadowMap { } } } - let radius = if c > 0 { radius * REFIT_SLACK } else { radius }; + let radius = radius * REFIT_SLACK; // Quantize radius so subpixel camera movement can't shift // the texel grid. let radius = (radius * 16.0).ceil() / 16.0; @@ -979,9 +991,11 @@ impl ShadowMap { self.light_vps[c] = crate::renderer::mat4_multiply(light_proj, snapped_view); - // Record the accepted fit so subsequent frames can keep this - // VP while their requirements stay inside it (cascades ≥ 1). - if c > 0 { + // Record the accepted fit so subsequent frames can keep this VP while + // their requirements stay inside it. EN-045 — cascade 0 included now; + // excluding it was what made the static-shadow cache a title-screen + // feature, because cascade 0's VP changed on every frame the camera moved. + { self.accepted_fit[c] = Some(AcceptedFit { ls_x: dot3(snapped_center, right), ls_y: dot3(snapped_center, ortho_up), From 36ffe1e960645b45173a347dc497442b30bc3c73 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 20:49:17 +0200 Subject: [PATCH 12/17] =?UTF-8?q?feat(EN-046):=20output=20(swapchain)=20sc?= =?UTF-8?q?ale=20=E2=80=94=20the=20knob=20render=5Fscale=20could=20never?= =?UTF-8?q?=20be?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_render_scale shrinks the G-buffer and everything drawn at render resolution, then TSR upscales to the swapchain. It does NOTHING for the cost of that upscale, or for the final composite -- and on a 4K display those two passes were measured at 3.10 ms + 2.40 ms, about a third of the entire frame. No amount of render-scale would touch them, which is why the frame had a floor it would not go below. set_output_scale(s) configures the SWAPCHAIN at s x the window's real size and lets the presentation engine stretch it back up. It is the only knob that reaches that fixed tail. output 1.0 (native 4K): taa 3.06 ms + composite 2.37 ms -> ~53 fps gameplay output 0.8: taa 1.46 ms + composite 1.04 ms -> LOCKED 60.0 fps output 0.6: taa 0.28 ms + composite 0.22 ms -> 60 (capped) At 0.8 the worst frame also falls from 36 ms to 17.9 ms -- the spikes go with it. Defaults to 1.0, so no existing game changes behaviour. The renderer remembers the window's native size, so the scale can be changed at runtime without waiting for the platform to tell it about a resize. This one is meant to be handed to players. At 4K it is the difference between a locked frame rate and a sharp image, and which of those somebody wants is not the engine's call, and not the game's either. 1 FFI symbol pair; validate-ffi clean (6 pre-existing watchos/web gaps unchanged). 116/116 shared tests pass. Verified: the swapchain stretches to the full window with no letterbox and no crop. --- docs/tickets.md | 25 +++++++++++++++ native/shared/src/ffi_core/visual.rs | 19 +++++++++++ native/shared/src/renderer/mod.rs | 47 ++++++++++++++++++++++++++++ native/watchos/src/ffi_stubs.rs | 3 ++ native/web/src/lib.rs | 8 +++++ package.json | 12 +++++++ src/core/index.ts | 19 +++++++++++ 7 files changed, 133 insertions(+) diff --git a/docs/tickets.md b/docs/tickets.md index ede28dd..40fb1e6 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -1455,3 +1455,28 @@ past 32 in combat. The key is now a single hash of **identity AND transform**, tested for membership in last frame's set. "Was this exact caster, at this exact transform, here last frame?" A set membership test does not care what order the draws arrive in. + + +## EN-046 — Output (swapchain) scale ✅ *(shipped 2026-07-12)* + +`set_render_scale` shrinks the G-buffer and everything drawn at render resolution, +then TSR upscales to the swapchain. It does **nothing** for the cost of that upscale +or the final composite — and on a 4K display those two passes were measured at +**3.10 ms + 2.40 ms**, about a third of the whole frame. + +`set_output_scale(s)` configures the **swapchain itself** at `s ×` the window's real +size; the presentation engine stretches it back up. It is the only knob that touches +that fixed tail. + +| output scale | `taa_pass` | `final_composite` | gameplay | +|---|---|---|---| +| 1.0 (native 4K) | 3.06 ms | 2.37 ms | ~53 fps | +| 0.8 | 1.46 ms | 1.04 ms | **locked 60.0 fps** (max frame 17.9 ms) | +| 0.6 | 0.28 ms | 0.22 ms | 60 (capped) | + +Default 1.0, so no existing game changes. The renderer remembers the window's native +size, so the scale can be changed at runtime without the platform telling it again. + +**Expose this to players.** At 4K it is the difference between a locked frame rate +and a sharp one, and which of those someone wants is not the engine's call — nor the +game's. diff --git a/native/shared/src/ffi_core/visual.rs b/native/shared/src/ffi_core/visual.rs index d716dc3..264fb18 100644 --- a/native/shared/src/ffi_core/visual.rs +++ b/native/shared/src/ffi_core/visual.rs @@ -495,6 +495,25 @@ macro_rules! __bloom_ffi_visual { }) } + // bloom_set_output_scale [EN-046] + // + // Shrink the SWAPCHAIN, not the G-buffer. This is the only knob that touches + // the fixed cost of the TSR upscale + final composite, which is what actually + // dominates a 4K frame — render_scale does not. + #[no_mangle] + pub extern "C" fn bloom_set_output_scale(scale: f64) { + $crate::ffi::guard("bloom_set_output_scale", move || { + engine().renderer.set_output_scale(scale as f32); + }) + } + + #[no_mangle] + pub extern "C" fn bloom_get_output_scale() -> f64 { + $crate::ffi::guard("bloom_get_output_scale", move || { + engine().renderer.output_scale() as f64 + }) + } + // bloom_set_cloud_shadows [EN-040] // // Opt the world into the deck the sky is already drawing. strength 0 diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index 2850c11..a59c502 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -1026,6 +1026,22 @@ pub struct Renderer { /// full surface for composite. At 0.5 = quarter-pixel shading /// (former TSR default). At 1.0 = native. pub render_scale: f32, + /// EN-046 — OUTPUT scale: the swapchain is configured at this fraction of the + /// window's real size, and the presentation engine stretches it back up. + /// + /// This is a DIFFERENT lever from `render_scale`, and confusing the two is easy. + /// `render_scale` shrinks the G-buffer and everything that runs at render + /// resolution, then TSR upscales to the swapchain. `output_scale` shrinks the + /// swapchain ITSELF — so it is the only thing that touches the fixed cost of the + /// upscale and the final composite, which on a 4K display was measured at + /// 3.1 ms + 2.4 ms and did not care what render_scale was set to. + /// + /// 1.0 = native. Games that never call it are unaffected. + pub output_scale: f32, + /// The window's real physical size, before `output_scale` is applied. Kept so + /// the scale can be changed at runtime without the platform telling us again. + native_width: u32, + native_height: u32, /// Set once `set_render_scale` is called explicitly. While false, /// `set_taa_enabled` keeps the legacy coupling (TAA on = 0.5, /// TAA off = 1.0). Once the user opts into explicit control, the @@ -6411,6 +6427,10 @@ impl Renderer { taa_frame_index: 0, taa_enabled: true, render_scale: 0.5, + // 1.0 = native output. Games that never touch it are unaffected. + output_scale: 1.0, + native_width: 0, + native_height: 0, render_scale_explicit: false, prev_vp_matrix: IDENTITY_MAT4, prev_proj_matrix_unjittered: IDENTITY_MAT4, @@ -6784,6 +6804,24 @@ impl Renderer { /// from `render_scale`. At 0.5 this is half the surface size and /// the TAA pass (or upscale pass) brings it back to the full /// surface for composite; at 1.0 it matches the surface. + /// EN-046 — set the output (swapchain) scale. See the field docs: this is the + /// only knob that touches the fixed cost of the TSR upscale and the final + /// composite, which is what actually dominates a 4K frame. + /// + /// Re-applies immediately against the last known window size. + pub fn set_output_scale(&mut self, scale: f32) { + let s = scale.clamp(0.25, 1.0); + if (s - self.output_scale).abs() < 1e-4 { return; } + self.output_scale = s; + let (w, h) = (self.native_width, self.native_height); + let (lw, lh) = (self.logical_width, self.logical_height); + if w > 0 && h > 0 { + self.resize(w, h, lw, lh); + } + } + + pub fn output_scale(&self) -> f32 { self.output_scale } + pub fn render_extent(&self) -> (u32, u32) { let sw = self.surface_config.width as f32; let sh = self.surface_config.height as f32; @@ -6794,7 +6832,16 @@ impl Renderer { ) } + /// Platform entry point: the window changed size. Records the native size, then + /// applies `output_scale` on top of it. pub fn resize(&mut self, width: u32, height: u32, logical_width: u32, logical_height: u32) { + if width > 0 && height > 0 { + self.native_width = width; + self.native_height = height; + } + let s = self.output_scale.clamp(0.25, 1.0); + let width = (((width as f32) * s).round() as u32).max(1); + let height = (((height as f32) * s).round() as u32).max(1); if width > 0 && height > 0 { // Cascade fit depends on the projection aspect ratio, so a // resize can shift the VPs even with the camera stationary. diff --git a/native/watchos/src/ffi_stubs.rs b/native/watchos/src/ffi_stubs.rs index 587a145..653f208 100644 --- a/native/watchos/src/ffi_stubs.rs +++ b/native/watchos/src/ffi_stubs.rs @@ -247,6 +247,9 @@ } #[no_mangle] pub extern "C" fn bloom_set_cloud_shadows(_p0: f64, _p1: f64, _p2: f64, _p3: f64) { } +#[no_mangle] pub extern "C" fn bloom_set_output_scale(_p0: f64) { +} +#[no_mangle] pub extern "C" fn bloom_get_output_scale() -> f64 { 1.0 } #[no_mangle] pub extern "C" fn bloom_set_model_foliage_wind(_p0: f64, _p1: f64) { } #[no_mangle] pub extern "C" fn bloom_set_foliage_shadow_motion(_p0: f64) { diff --git a/native/web/src/lib.rs b/native/web/src/lib.rs index 81f622d..a386883 100644 --- a/native/web/src/lib.rs +++ b/native/web/src/lib.rs @@ -1534,6 +1534,14 @@ pub fn bloom_set_wind(dir_x: f64, dir_z: f64, amplitude: f64, frequency: f64) { engine().renderer.set_wind(dir_x as f32, dir_z as f32, amplitude as f32, frequency as f32); } #[wasm_bindgen] +pub fn bloom_set_output_scale(scale: f64) { + engine().renderer.set_output_scale(scale as f32); +} +#[wasm_bindgen] +pub fn bloom_get_output_scale() -> f64 { + engine().renderer.output_scale() as f64 +} +#[wasm_bindgen] pub fn bloom_set_model_foliage_wind(model: f64, amount: f64) { engine().renderer.set_model_foliage_wind(model.to_bits(), amount as f32); } diff --git a/package.json b/package.json index 3c73137..e1da14c 100644 --- a/package.json +++ b/package.json @@ -1650,6 +1650,18 @@ ], "returns": "void" }, + { + "name": "bloom_set_output_scale", + "params": [ + "f64" + ], + "returns": "void" + }, + { + "name": "bloom_get_output_scale", + "params": [], + "returns": "f64" + }, { "name": "bloom_set_model_foliage_wind", "params": [ diff --git a/src/core/index.ts b/src/core/index.ts index ee8ce52..dd5064c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -27,6 +27,8 @@ declare function bloom_set_auto_exposure(on: number): void; declare function bloom_set_taa_enabled(on: number): void; declare function bloom_set_occlusion_culling(on: number): void; declare function bloom_set_render_scale(scale: number): void; +declare function bloom_set_output_scale(scale: number): void; +declare function bloom_get_output_scale(): number; declare function bloom_get_render_scale(): number; declare function bloom_set_upscale_mode(mode: number): void; declare function bloom_set_cas_strength(strength: number): void; @@ -339,6 +341,23 @@ export function setTaaEnabled(on: boolean): void { export function setRenderScale(scale: number): void { bloom_set_render_scale(Math.min(1.0, Math.max(0.5, scale))); } + +/// OUTPUT scale — configure the swapchain at this fraction of the window's real +/// size and let the display stretch it back up. +/// +/// This is NOT `setRenderScale`, and the difference is the whole point. +/// `setRenderScale` shrinks the G-buffer and everything that runs at render +/// resolution, then TSR upscales to the swapchain. `setOutputScale` shrinks the +/// swapchain ITSELF — so it is the only knob that touches the fixed cost of that +/// upscale and the final composite. On a 4K display those two passes were measured +/// at 3.1 ms + 2.4 ms and did not care what the render scale was. +/// +/// 1.0 = native. Expose it to players: at 4K it is the difference between a locked +/// frame rate and a pretty one, and which of those they want is not the game's call. +export function setOutputScale(scale: number): void { + bloom_set_output_scale(Math.min(1.0, Math.max(0.25, scale))); +} +export function getOutputScale(): number { return bloom_get_output_scale(); } export function getRenderScale(): number { return bloom_get_render_scale(); } /** Upscale filter when render_scale < 1 and TAA is off. "bilinear" = cheap/soft, "catmull-rom" = sharper (default). */ From 2fee257564b77fdaa7b10bbca1f00e8464b8840c Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 20:56:44 +0200 Subject: [PATCH 13/17] docs: reconcile the duplicated EN-042 header (filed open, then fixed later in the same file) --- docs/tickets.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tickets.md b/docs/tickets.md index 40fb1e6..b09489b 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -1301,7 +1301,7 @@ leaf instead of 0. --- -## EN-042 — Dynamic shadow-caster budget is 64, and overflow is silently dropped 🔴 +## EN-042 — Dynamic shadow-caster budget is 64, and overflow is silently dropped ✅ *(fixed — see the resolution at the end of this file)* **Found while shipping EN-041.** `SHADOW_MAX_DYNAMIC = 64`. A caster that moves every frame cannot reuse the cached static depth, so it must go in the dynamic From 59df2f321c2c381dd4998459a3380eb4849abf9d Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 22:43:37 +0200 Subject: [PATCH 14/17] fix(EN-047): saveWorld destroyed the world it saved, and reported success THE EDITOR COULD NOT SAVE. Saving a world emptied it. saveWorld used JSON.stringify(world, null, 2). On Perry 0.5.x that CORRUPTS a large object graph that came from JSON.parse. Minimal repro, no engine code involved: const text = readFile('assets/worlds/arena_02.world.json'); // 324 KB, writes back fine const o = JSON.parse(text); // fine const re = JSON.stringify(o, null, 2); // -> `re` contains 5,296 characters above U+00FF, in a document whose source is // almost pure ASCII. It is garbage. The corruption is invisible from TypeScript. It only surfaces at the FFI boundary: str_from_header fails its UTF-8 check, returns "", and bloom_write_file then writes a ZERO-BYTE FILE AND RETURNS SUCCESS. The editor's save path deleted the file and told you it had saved it. Every world it ever "saved" was destroyed by saving. Ruled out by probe, one at a time: size (a FRESH 1 MB world-shaped object stringifies and writes perfectly), floats, nulls, Record keys, non-ASCII, and even a manual deep clone of the parsed graph. It is specifically the graph that came from JSON.parse. TWO FIXES, BOTH NEEDED. 1. src/world/serialize.ts -- a hand-written emitter that walks the schema by LITERAL KEY and builds the document by concatenation. This is the same discipline the shooter's settings.ts already adopted, for the same reason ("Perry's object handling is only trustworthy with literal keys"). saveWorld and savePrefab no longer go near JSON.stringify. Being schema-explicit is a feature, not just a workaround: a field the serializer has never heard of cannot be silently dropped. 2. try_str_from_header -- an FFI string that fails ABI validation is no longer quietly replaced with "". bloom_write_file now FAILS rather than writing an empty file and claiming success. An empty string and a failed string are not the same thing, and an FFI that PERSISTS its input has to know which one it is holding. This is the part that turned a Perry bug into silent data loss. Verified: loadWorld -> saveWorld -> loadWorld on arena_02 round-trips 168 entities, 5 lights, 1 water volume, 16,384 terrain heights and the world name, with field-level spot checks matching. 204,270 bytes written where it used to write 0. 116/116 shared tests pass; editor 87/87. --- docs/tickets.md | 46 +++++ native/shared/src/ffi_core/assets.rs | 9 +- native/shared/src/string_header.rs | 31 +++ src/world/saver.ts | 9 +- src/world/serialize.ts | 285 +++++++++++++++++++++++++++ 5 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 src/world/serialize.ts diff --git a/docs/tickets.md b/docs/tickets.md index b09489b..2b9a747 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -1480,3 +1480,49 @@ size, so the scale can be changed at runtime without the platform telling it aga **Expose this to players.** At 4K it is the difference between a locked frame rate and a sharp one, and which of those someone wants is not the engine's call — nor the game's. + + +--- + +## EN-047 — `saveWorld` destroyed the world it saved, and reported success ✅ *(fixed 2026-07-12)* + +**The editor could not save. Saving a world emptied it.** + +`saveWorld` used `JSON.stringify(world, null, 2)`. On Perry 0.5.x that **corrupts a +large object graph that came from `JSON.parse`**. Minimal repro, no engine code: + +```ts +const text = readFile('assets/worlds/arena_02.world.json'); // 324 KB — writes back fine +const o = JSON.parse(text); // fine +const re = JSON.stringify(o, null, 2); +// -> `re` holds 5,296 characters above U+00FF, in a document whose source is +// almost pure ASCII. It is garbage. +``` + +The corruption is invisible in TS. It only surfaces at the FFI: `str_from_header` +fails its UTF-8 check, returns `""`, and `bloom_write_file` **writes a zero-byte file +and returns SUCCESS**. So the editor's save path deleted the file and said "saved". + +Ruled out by probe: size (a *fresh* 1 MB world-shaped object stringifies fine), +floats, nulls, `Record` keys, non-ASCII, and a manual deep clone. It is specifically +the parsed graph. + +**Two fixes, and both are needed.** + +1. **`src/world/serialize.ts`** — a hand-written emitter that walks the schema by + literal key and builds the document by concatenation. Same discipline the + shooter's `settings.ts` already adopted for the same reason. `saveWorld` and + `savePrefab` no longer touch `JSON.stringify`. +2. **`try_str_from_header`** — an FFI string that fails ABI validation is no longer + silently substituted with `""`. `bloom_write_file` now **fails** instead of + writing an empty file and claiming success. An empty string and a failed string + are not the same thing, and any FFI that *persists* its input must know which it + is holding. + +Verified: `loadWorld` → `saveWorld` → `loadWorld` on arena_02 round-trips 168 +entities, 5 lights, 1 water volume, 16,384 terrain heights and the world name, with +field-level checks matching. 204,270 bytes written, where it used to write 0. + +**Still latent:** `JSON.parse(JSON.stringify(x))` is used as a deep-clone idiom in the +editor (prefab cloning). It is safe at the sizes prefabs run to, and it is a landmine +at scale. Anything that clones a *world* that way must not. diff --git a/native/shared/src/ffi_core/assets.rs b/native/shared/src/ffi_core/assets.rs index 05a3b55..4161399 100644 --- a/native/shared/src/ffi_core/assets.rs +++ b/native/shared/src/ffi_core/assets.rs @@ -198,7 +198,14 @@ macro_rules! __bloom_ffi_assets { $crate::ffi::guard("bloom_write_file", move || { let path = $crate::string_header::str_from_header(path_ptr); let path: &str = &bloom_resolve_asset_path(path); - let data = $crate::string_header::str_from_header(data_ptr); + // A string that failed ABI validation must NOT be written as "" and + // reported as a success. That is not a save, it is a deletion with a + // thumbs-up, and it is exactly what happened to every world the + // editor ever saved. + let data = match $crate::string_header::try_str_from_header(data_ptr) { + Some(d) => d, + None => return 0.0, + }; match std::fs::write(path, data.as_bytes()) { Ok(_) => 1.0, Err(_) => 0.0, diff --git a/native/shared/src/string_header.rs b/native/shared/src/string_header.rs index 84908ba..0eac8fb 100644 --- a/native/shared/src/string_header.rs +++ b/native/shared/src/string_header.rs @@ -92,6 +92,37 @@ fn abi_mismatch_warn_once(what: &str) { /// /// Never causes undefined behavior: null/garbage pointers, implausible /// headers, and invalid UTF-8 all yield `""` plus a one-time diagnostic. +/// Like [`str_from_header`], but says whether it FAILED rather than papering over it +/// with an empty string. +/// +/// The distinction is not academic. `bloom_write_file` used `str_from_header`, got +/// `""` back when a string failed ABI validation, wrote a ZERO-BYTE FILE, and +/// returned SUCCESS. The editor's save path therefore destroyed every world it +/// saved and reported that it had saved it. An empty string and a failed string are +/// not the same thing, and any FFI that *persists* its input has to know which it +/// is holding. +pub fn try_str_from_header(ptr: *const u8) -> Option<&'static str> { + if ptr.is_null() || (ptr as usize) < 0x1000 { + return Some(""); + } + unsafe { + let header = &*(ptr as *const StringHeader); + if !header_looks_valid(header) { + abi_mismatch_warn_once("header invariants violated"); + return None; + } + let len = header.byte_len as usize; + let data = ptr.add(std::mem::size_of::()); + match std::str::from_utf8(std::slice::from_raw_parts(data, len)) { + Ok(s) => Some(s), + Err(_) => { + abi_mismatch_warn_once("payload is not UTF-8"); + None + } + } + } +} + pub fn str_from_header(ptr: *const u8) -> &'static str { if ptr.is_null() || (ptr as usize) < 0x1000 { return ""; diff --git a/src/world/saver.ts b/src/world/saver.ts index 1ab73ab..b778b59 100644 --- a/src/world/saver.ts +++ b/src/world/saver.ts @@ -9,6 +9,7 @@ import { writeFile } from '../core/index'; import { WORLD_SCHEMA_VERSION, WorldData, PrefabData } from './types'; import { validateWorld, validatePrefab, formatValidationErrors } from './validate'; +import { serializeWorld, serializePrefab } from './serialize'; export interface SaveResult { ok: boolean; @@ -28,7 +29,11 @@ export function saveWorld(path: string, world: WorldData): SaveResult { return { ok: false, errors: check.errors }; } - const json = JSON.stringify(world, null, 2); + // NOT JSON.stringify — see serialize.ts. On Perry 0.5.x it corrupts a large object + // graph that came from JSON.parse, the corrupt string fails the FFI's UTF-8 check, + // and writeFile then wrote a ZERO-BYTE FILE AND RETURNED SUCCESS. Saving a world + // destroyed it, silently. + const json = serializeWorld(world); const ok = writeFile(path, json); if (!ok) { return { ok: false, errors: ['writeFile failed for path: ' + path] }; @@ -45,7 +50,7 @@ export function savePrefab(path: string, prefab: PrefabData): SaveResult { return { ok: false, errors: check.errors }; } - const json = JSON.stringify(prefab, null, 2); + const json = serializePrefab(prefab); const ok = writeFile(path, json); if (!ok) { return { ok: false, errors: ['writeFile failed for path: ' + path] }; diff --git a/src/world/serialize.ts b/src/world/serialize.ts new file mode 100644 index 0000000..f26a649 --- /dev/null +++ b/src/world/serialize.ts @@ -0,0 +1,285 @@ +// Hand-written JSON emitter for WorldData / PrefabData. +// +// WHY THIS EXISTS, AND WHY IT IS NOT `JSON.stringify`. +// +// `JSON.stringify` CORRUPTS a large object graph that came from `JSON.parse` on +// Perry 0.5.x. Minimal repro, no engine code involved: +// +// const text = readFile('assets/worlds/arena_02.world.json'); // 324 KB, fine +// const o = JSON.parse(text); // fine +// const re = JSON.stringify(o, null, 2); +// // -> `re` contains 5,296 characters above U+00FF, in a JSON document whose +// // source is almost pure ASCII. It is garbage. +// +// The corruption is invisible until the string crosses the FFI: the engine's +// `str_from_header` fails its UTF-8 check, returns "", and `writeFile` then writes +// a ZERO-BYTE FILE AND REPORTS SUCCESS. +// +// So `saveWorld` did not save. It emptied the file and said "ok". Every world the +// editor has ever "saved" was destroyed by saving it — which is why the editor has +// only ever been used to look at worlds that some other tool produced. +// +// It is not size (a fresh 1 MB world-shaped object stringifies fine), not floats, +// not nulls, not `Record` keys, not non-ASCII — every one of those was ruled out by +// probe. It is specifically the parsed graph. A manual deep clone does not escape it. +// +// So the save path does not get to use `JSON.stringify`. This emitter walks the +// schema by LITERAL KEY and builds the document by concatenation, which is the same +// discipline the shooter's `settings.ts` already adopted ("Perry's object handling +// is only trustworthy with literal keys") and which the probes show is reliable. +// +// Being schema-explicit is not only a workaround, it is a feature: an unknown field +// cannot be silently dropped by a serializer that never knew about it — the schema +// and the writer change together, or validation fails. + +import { + WorldData, PrefabData, PrefabChild, EntityData, LightData, + WaterVolume, RiverSpline, TerrainData, TerrainLayer, EnvironmentData, + TransformData, Vec3Lit, Vec4Lit, +} from './types'; + +// --- primitives -------------------------------------------------------------- + +/// JSON string escaping. Control characters must be escaped or the document is +/// invalid; everything else is emitted as-is (the file is UTF-8). +function str(s: string): string { + let out = '"'; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c === 34) out = out + '\\"'; + else if (c === 92) out = out + '\\\\'; + else if (c === 10) out = out + '\\n'; + else if (c === 13) out = out + '\\r'; + else if (c === 9) out = out + '\\t'; + else if (c === 8) out = out + '\\b'; + else if (c === 12) out = out + '\\f'; + else if (c < 32) { + // \u00XX — the only escapes JSON *requires*. + let h = c.toString(16); + while (h.length < 4) h = '0' + h; + out = out + '\\u' + h; + } else { + out = out + s.charAt(i); + } + } + return out + '"'; +} + +/// Numbers. NaN and Infinity are not JSON; emitting them produces a file that will +/// not parse, so they become 0 rather than a document nobody can open. +function num(n: number): string { + if (n !== n) return '0'; // NaN + if (n === Infinity || n === -Infinity) return '0'; + // Integers stay integers — `1` not `1.0` — so a hand-written world file and a + // round-tripped one look the same in a diff. + if (n === Math.floor(n) && Math.abs(n) < 1e15) return '' + n; + return '' + n; +} + +function bool(b: boolean): string { return b ? 'true' : 'false'; } + +function vec3(v: Vec3Lit): string { + return '[' + num(v[0]) + ', ' + num(v[1]) + ', ' + num(v[2]) + ']'; +} +function vec4(v: Vec4Lit): string { + return '[' + num(v[0]) + ', ' + num(v[1]) + ', ' + num(v[2]) + ', ' + num(v[3]) + ']'; +} +function nums(a: number[]): string { + let out = '['; + for (let i = 0; i < a.length; i++) { + if (i > 0) out = out + ', '; + out = out + num(a[i]); + } + return out + ']'; +} +function strs(a: string[]): string { + let out = '['; + for (let i = 0; i < a.length; i++) { + if (i > 0) out = out + ', '; + out = out + str(a[i]); + } + return out + ']'; +} + +function ind(n: number): string { + let s = ''; + for (let i = 0; i < n; i++) s = s + ' '; + return s; +} + +/// A `Record`. Keys come from the data, so this is the one place we +/// cannot use literal keys — but `Object.keys` on a *fresh* object is sound, and +/// callers hand us the parsed map directly. +function record(r: Record, depth: number): string { + const keys = Object.keys(r); + if (keys.length === 0) return '{}'; + let out = '{\n'; + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + out = out + ind(depth + 1) + str(k) + ': ' + str(r[k]); + if (i < keys.length - 1) out = out + ','; + out = out + '\n'; + } + return out + ind(depth) + '}'; +} + +// --- schema ------------------------------------------------------------------ + +function transform(t: TransformData, d: number): string { + return '{\n' + + ind(d + 1) + '"position": ' + vec3(t.position) + ',\n' + + ind(d + 1) + '"rotation": ' + vec3(t.rotation) + ',\n' + + ind(d + 1) + '"scale": ' + vec3(t.scale) + '\n' + + ind(d) + '}'; +} + +function entity(e: EntityData, d: number): string { + let s = '{\n'; + s = s + ind(d + 1) + '"id": ' + str(e.id) + ',\n'; + s = s + ind(d + 1) + '"name": ' + str(e.name) + ',\n'; + s = s + ind(d + 1) + '"modelRef": ' + (e.modelRef === null ? 'null' : str(e.modelRef)) + ',\n'; + s = s + ind(d + 1) + '"prefabRef": ' + (e.prefabRef === null ? 'null' : str(e.prefabRef)) + ',\n'; + s = s + ind(d + 1) + '"transform": ' + transform(e.transform, d + 1) + ',\n'; + s = s + ind(d + 1) + '"tint": ' + (e.tint === null ? 'null' : vec4(e.tint)) + ',\n'; + s = s + ind(d + 1) + '"tags": ' + strs(e.tags) + ',\n'; + s = s + ind(d + 1) + '"userData": ' + record(e.userData, d + 1) + '\n'; + return s + ind(d) + '}'; +} + +function light(l: LightData, d: number): string { + return '{\n' + + ind(d + 1) + '"id": ' + str(l.id) + ',\n' + + ind(d + 1) + '"name": ' + str(l.name) + ',\n' + + ind(d + 1) + '"kind": ' + str(l.kind) + ',\n' + + ind(d + 1) + '"position": ' + vec3(l.position) + ',\n' + + ind(d + 1) + '"color": ' + vec3(l.color) + ',\n' + + ind(d + 1) + '"intensity": ' + num(l.intensity) + ',\n' + + ind(d + 1) + '"range": ' + num(l.range) + '\n' + + ind(d) + '}'; +} + +function water(w: WaterVolume, d: number): string { + return '{\n' + + ind(d + 1) + '"id": ' + str(w.id) + ',\n' + + ind(d + 1) + '"kind": ' + str(w.kind) + ',\n' + + ind(d + 1) + '"center": ' + vec3(w.center) + ',\n' + + ind(d + 1) + '"size": ' + vec3(w.size) + ',\n' + + ind(d + 1) + '"surfaceHeight": ' + num(w.surfaceHeight) + ',\n' + + ind(d + 1) + '"color": ' + vec4(w.color) + ',\n' + + ind(d + 1) + '"waveAmplitude": ' + num(w.waveAmplitude) + ',\n' + + ind(d + 1) + '"waveSpeed": ' + num(w.waveSpeed) + '\n' + + ind(d) + '}'; +} + +function river(r: RiverSpline, d: number): string { + let pts = '['; + for (let i = 0; i < r.controlPoints.length; i++) { + if (i > 0) pts = pts + ', '; + pts = pts + vec3(r.controlPoints[i]); + } + pts = pts + ']'; + return '{\n' + + ind(d + 1) + '"id": ' + str(r.id) + ',\n' + + ind(d + 1) + '"controlPoints": ' + pts + ',\n' + + ind(d + 1) + '"widths": ' + nums(r.widths) + ',\n' + + ind(d + 1) + '"depth": ' + num(r.depth) + ',\n' + + ind(d + 1) + '"flowSpeed": ' + num(r.flowSpeed) + ',\n' + + ind(d + 1) + '"color": ' + vec4(r.color) + '\n' + + ind(d) + '}'; +} + +function terrainLayer(l: TerrainLayer, d: number): string { + return '{\n' + + ind(d + 1) + '"id": ' + str(l.id) + ',\n' + + ind(d + 1) + '"textureRef": ' + str(l.textureRef) + ',\n' + + ind(d + 1) + '"weights": ' + nums(l.weights) + ',\n' + + ind(d + 1) + '"tileScale": ' + num(l.tileScale) + '\n' + + ind(d) + '}'; +} + +function terrain(t: TerrainData, d: number): string { + let layers = '[]'; + if (t.layers.length > 0) { + layers = '[\n'; + for (let i = 0; i < t.layers.length; i++) { + layers = layers + ind(d + 2) + terrainLayer(t.layers[i], d + 2); + if (i < t.layers.length - 1) layers = layers + ','; + layers = layers + '\n'; + } + layers = layers + ind(d + 1) + ']'; + } + return '{\n' + + ind(d + 1) + '"width": ' + num(t.width) + ',\n' + + ind(d + 1) + '"depth": ' + num(t.depth) + ',\n' + + ind(d + 1) + '"cellSize": ' + num(t.cellSize) + ',\n' + + ind(d + 1) + '"origin": ' + vec3(t.origin) + ',\n' + + ind(d + 1) + '"heights": ' + nums(t.heights) + ',\n' + + ind(d + 1) + '"layers": ' + layers + '\n' + + ind(d) + '}'; +} + +function environment(e: EnvironmentData, d: number): string { + return '{\n' + + ind(d + 1) + '"skyColor": ' + vec3(e.skyColor) + ',\n' + + ind(d + 1) + '"ambientColor": ' + vec3(e.ambientColor) + ',\n' + + ind(d + 1) + '"ambientIntensity": ' + num(e.ambientIntensity) + ',\n' + + ind(d + 1) + '"sunDirection": ' + vec3(e.sunDirection) + ',\n' + + ind(d + 1) + '"sunColor": ' + vec3(e.sunColor) + ',\n' + + ind(d + 1) + '"sunIntensity": ' + num(e.sunIntensity) + ',\n' + + ind(d + 1) + '"fogStart": ' + num(e.fogStart) + ',\n' + + ind(d + 1) + '"fogEnd": ' + num(e.fogEnd) + ',\n' + + ind(d + 1) + '"fogColor": ' + vec3(e.fogColor) + ',\n' + + ind(d + 1) + '"shadowsEnabled": ' + bool(e.shadowsEnabled) + '\n' + + ind(d) + '}'; +} + +function arr(items: T[], d: number, fn: (x: T, d: number) => string): string { + if (items.length === 0) return '[]'; + let out = '[\n'; + for (let i = 0; i < items.length; i++) { + out = out + ind(d + 1) + fn(items[i], d + 1); + if (i < items.length - 1) out = out + ','; + out = out + '\n'; + } + return out + ind(d) + ']'; +} + +// --- entry points ------------------------------------------------------------ + +export function serializeWorld(w: WorldData): string { + let s = '{\n'; + s = s + ind(1) + '"schemaVersion": ' + num(w.schemaVersion) + ',\n'; + s = s + ind(1) + '"name": ' + str(w.name) + ',\n'; + s = s + ind(1) + '"id": ' + str(w.id) + ',\n'; + s = s + ind(1) + '"bounds": {\n' + + ind(2) + '"min": ' + vec3(w.bounds.min) + ',\n' + + ind(2) + '"max": ' + vec3(w.bounds.max) + '\n' + + ind(1) + '},\n'; + s = s + ind(1) + '"environment": ' + environment(w.environment, 1) + ',\n'; + s = s + ind(1) + '"terrain": ' + (w.terrain === null ? 'null' : terrain(w.terrain, 1)) + ',\n'; + s = s + ind(1) + '"entities": ' + arr(w.entities, 1, entity) + ',\n'; + s = s + ind(1) + '"lights": ' + arr(w.lights, 1, light) + ',\n'; + s = s + ind(1) + '"water": ' + arr(w.water, 1, water) + ',\n'; + s = s + ind(1) + '"rivers": ' + arr(w.rivers, 1, river) + ',\n'; + s = s + ind(1) + '"metadata": ' + record(w.metadata, 1) + '\n'; + return s + '}\n'; +} + +function prefabChild(c: PrefabChild, d: number): string { + let s = '{\n'; + s = s + ind(d + 1) + '"id": ' + str(c.id) + ',\n'; + s = s + ind(d + 1) + '"modelRef": ' + (c.modelRef === null ? 'null' : str(c.modelRef)) + ',\n'; + s = s + ind(d + 1) + '"prefabRef": ' + (c.prefabRef === null ? 'null' : str(c.prefabRef)) + ',\n'; + s = s + ind(d + 1) + '"transform": ' + transform(c.transform, d + 1) + ',\n'; + s = s + ind(d + 1) + '"tint": ' + (c.tint === null ? 'null' : vec4(c.tint)) + ',\n'; + s = s + ind(d + 1) + '"tags": ' + strs(c.tags) + '\n'; + return s + ind(d) + '}'; +} + +export function serializePrefab(p: PrefabData): string { + let s = '{\n'; + s = s + ind(1) + '"id": ' + str(p.id) + ',\n'; + s = s + ind(1) + '"name": ' + str(p.name) + ',\n'; + s = s + ind(1) + '"children": ' + arr(p.children, 1, prefabChild) + '\n'; + return s + '}\n'; +} From ed232f27e4f83fd245117ef498eb9f33d3798ed3 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 23:11:54 +0200 Subject: [PATCH 15/17] =?UTF-8?q?feat(EN-048):=20launchProcess=20=E2=80=94?= =?UTF-8?q?=20Perry's=20child=5Fprocess.spawn=20does=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `import { spawn } from 'child_process'` COMPILES on Perry and then does nothing at all: undefined pid, no process. So no Bloom tool could run another program, and play-in-editor -- save the level, run the game on it -- had nowhere to go. launchProcess(cmd, args[], cwd) goes through std::process::Command, fully detached (never waited on, stdio to null). A GUI must not block on, or die with, the thing it launched: close the game, and you are back in the editor with your undo history. TWO TRAPS, BOTH HIT ON THE WAY. 1. Rust's Command::current_dir sets the CHILD's working directory. It does NOT affect how the program is FOUND -- that happens in the parent's context. So launching "main.exe" with cwd "" fails with "program not found" while main.exe is sitting right there in . A bare command name is now resolved against cwd before spawning. 2. Args cross the FFI as a newline-separated string and are re-split into a real argv. No shell is involved, which is also why there is nothing to inject into. Verified end to end: the editor saved arena_02 to a scratch world (204,275 bytes), resolved .\main.exe against the project dir, and launched the game on it -- pid 16584, rendering the level the editor had just written. 116/116 shared tests. validate-ffi clean (same 6 pre-existing watchos/web gaps). --- docs/tickets.md | 21 +++++++++++ native/shared/src/ffi_core/assets.rs | 53 ++++++++++++++++++++++++++++ native/watchos/src/ffi_stubs.rs | 1 + native/web/src/lib.rs | 5 +++ package.json | 9 +++++ src/core/index.ts | 22 ++++++++++++ 6 files changed, 111 insertions(+) diff --git a/docs/tickets.md b/docs/tickets.md index 2b9a747..58cfaf1 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -1526,3 +1526,24 @@ field-level checks matching. 204,270 bytes written, where it used to write 0. **Still latent:** `JSON.parse(JSON.stringify(x))` is used as a deep-clone idiom in the editor (prefab cloning). It is safe at the sizes prefabs run to, and it is a landmine at scale. Anything that clones a *world* that way must not. + + +## EN-048 — `launchProcess` ✅ *(shipped 2026-07-12)* + +Perry's `child_process.spawn` **compiles and then does nothing**: it returns a child +with an undefined pid and no process is started. So no Bloom tool could run another +program — which is all play-in-editor is (save the level, run the game on it). + +`launchProcess(cmd, args[], cwd)` shells out via `std::process::Command`, fully +detached (never waited on, stdio to null): a GUI must not block on, or die with, the +thing it launched. + +**Two traps, both hit:** + +1. **Rust's `Command::current_dir` sets the CHILD's working directory — it does not + affect how the program is FOUND**, which happens in the parent's context. So + launching `"main.exe"` with `cwd: ""` fails with *"program not found"* + even though `main.exe` is sitting right there in ``. A bare command name + is now resolved against `cwd` before spawning. +2. Args cross as a newline-separated string and are re-split into a real argv. There + is no shell involved, which is also why there is nothing to inject into. diff --git a/native/shared/src/ffi_core/assets.rs b/native/shared/src/ffi_core/assets.rs index 4161399..1c292af 100644 --- a/native/shared/src/ffi_core/assets.rs +++ b/native/shared/src/ffi_core/assets.rs @@ -213,6 +213,59 @@ macro_rules! __bloom_ffi_assets { }) } + // bloom_launch_process [EN-048] + // + // Perry's `child_process.spawn` COMPILES and then does nothing — it returns a + // child with an undefined pid and no process is started. So a tool that wants + // to launch another program (the editor's play-in-editor: save the level, run + // the game on it) has nowhere to go. + // + // Fire-and-forget by design. The caller is a GUI that must not block on, or + // die with, the thing it launched: close the game, and you are back in the + // editor with your undo history intact. + // + // `args` is newline-separated. Not shell-escaped and not shell-interpreted — + // there is no shell here, which is also why there is nothing to inject into. + #[no_mangle] + pub extern "C" fn bloom_launch_process( + cmd_ptr: *const u8, args_ptr: *const u8, cwd_ptr: *const u8, + ) -> f64 { + $crate::ffi::guard("bloom_launch_process", move || { + let cmd = $crate::string_header::str_from_header(cmd_ptr); + if cmd.is_empty() { return 0.0; } + let args = $crate::string_header::str_from_header(args_ptr); + let cwd = $crate::string_header::str_from_header(cwd_ptr); + + // Resolve the program against `cwd` when it is a bare name. + // + // Rust's `Command::current_dir` sets the CHILD's working directory -- + // it does NOT affect how the program is FOUND, which happens in the + // parent's context. So launching "main.exe" with cwd "" + // fails with "program not found" even though main.exe is sitting + // right there in . Which is exactly what it did. + let bare = !cmd.chars().any(|ch| ch == '/' || ch == std::path::MAIN_SEPARATOR); + let resolved: std::path::PathBuf = if !cwd.is_empty() && bare { + std::path::Path::new(cwd).join(cmd) + } else { + std::path::PathBuf::from(cmd) + }; + let mut c = std::process::Command::new(&resolved); + for a in args.split('\n') { + if !a.is_empty() { c.arg(a); } + } + if !cwd.is_empty() { c.current_dir(cwd); } + // Detach: we never wait on it, and we do not want its output in ours. + c.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + let r = c.spawn(); + match r { + Ok(child) => child.id() as f64, + Err(_) => 0.0, + } + }) + } + // bloom_file_exists [source: macos] #[no_mangle] pub extern "C" fn bloom_file_exists(path_ptr: *const u8) -> f64 { diff --git a/native/watchos/src/ffi_stubs.rs b/native/watchos/src/ffi_stubs.rs index 653f208..d9b1e3e 100644 --- a/native/watchos/src/ffi_stubs.rs +++ b/native/watchos/src/ffi_stubs.rs @@ -247,6 +247,7 @@ } #[no_mangle] pub extern "C" fn bloom_set_cloud_shadows(_p0: f64, _p1: f64, _p2: f64, _p3: f64) { } +#[no_mangle] pub extern "C" fn bloom_launch_process(_p0: i64, _p1: i64, _p2: i64) -> f64 { 0.0 } #[no_mangle] pub extern "C" fn bloom_set_output_scale(_p0: f64) { } #[no_mangle] pub extern "C" fn bloom_get_output_scale() -> f64 { 1.0 } diff --git a/native/web/src/lib.rs b/native/web/src/lib.rs index a386883..adbd95f 100644 --- a/native/web/src/lib.rs +++ b/native/web/src/lib.rs @@ -1534,6 +1534,11 @@ pub fn bloom_set_wind(dir_x: f64, dir_z: f64, amplitude: f64, frequency: f64) { engine().renderer.set_wind(dir_x as f32, dir_z as f32, amplitude as f32, frequency as f32); } #[wasm_bindgen] +pub fn bloom_launch_process(_cmd: f64, _args: f64, _cwd: f64) -> f64 { + // A web page does not get to launch processes. + 0.0 +} +#[wasm_bindgen] pub fn bloom_set_output_scale(scale: f64) { engine().renderer.set_output_scale(scale as f32); } diff --git a/package.json b/package.json index e1da14c..949cc09 100644 --- a/package.json +++ b/package.json @@ -1650,6 +1650,15 @@ ], "returns": "void" }, + { + "name": "bloom_launch_process", + "params": [ + "string", + "string", + "string" + ], + "returns": "f64" + }, { "name": "bloom_set_output_scale", "params": [ diff --git a/src/core/index.ts b/src/core/index.ts index dd5064c..f31ea58 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -28,6 +28,7 @@ declare function bloom_set_taa_enabled(on: number): void; declare function bloom_set_occlusion_culling(on: number): void; declare function bloom_set_render_scale(scale: number): void; declare function bloom_set_output_scale(scale: number): void; +declare function bloom_launch_process(cmd: string, args: string, cwd: string): number; declare function bloom_get_output_scale(): number; declare function bloom_get_render_scale(): number; declare function bloom_set_upscale_mode(mode: number): void; @@ -1132,3 +1133,24 @@ export function getWorldToScreen2D(position: { x: number; y: number }, camera: C y: (sin * dx + cos * dy) * camera.zoom + camera.offset.y, }; } + + +/// Launch another program, fire and forget. Returns its pid, or 0 on failure. +/// +/// Perry's `child_process.spawn` COMPILES and then does nothing — undefined pid, no +/// process. So this is the only way for a Bloom tool to run another program (the +/// editor's play-in-editor: save the level, run the game on it). +/// +/// The child is fully detached: we never wait on it and its stdio goes nowhere. A +/// GUI must not block on, or die with, the thing it launched. +/// +/// `args` is passed as a real argv, not a command line — there is no shell here, +/// which is also why there is nothing to inject into. +export function launchProcess(cmd: string, args: string[], cwd: string): number { + let joined = ''; + for (let i = 0; i < args.length; i++) { + if (i > 0) joined = joined + '\n'; + joined = joined + args[i]; + } + return bloom_launch_process(cmd, joined, cwd); +} From 193c5b3ecfedfc0d95d5731a62ee759baf5faab9 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 13 Jul 2026 07:47:28 +0200 Subject: [PATCH 16/17] world: authorable terrain splat (EN-049) + a Perry miscompile (EN-050/051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TerrainLayer.weights` existed in the schema, was written by nothing and read by nothing. The editor now paints them and the shooter renders them. - `buildHeightmapMesh` tints vertex colour by the splat weights, so the editor gets a paint preview for free (mask colours — coverage, not material). - `createTerrainLayer` / `quantizeWeight` / `terrainLayerMaskColor`. - EN-049 `createTextureArrayFromTexels`: a texture array built from DATA. The byte-array FFI takes a raw pointer, and Perry cannot pass an array into an i64 param ("Expected safe integer for native i64 parameter") — so it was uncallable and every caller had fallen back to `createTextureArrayFromFiles`, which is right for art and useless for a splat map computed at load. Payload goes through the mesh scratch buffer as packed u32 texels, the same fix `createMesh` uses. EN-050 — `clamp` in world/terrain.ts, whose body was a single nested-ternary return, evaluated to `lo` for EVERY input when called from `quantizeWeight`, while `sampleHeight` in the same file called the same helper correctly. Every splat weight quantised to 0 and painted terrain loaded unpainted. Fixed with `if` statements; pinned by the editor's `testSplatPaintPartition`. EN-051 — `easeInOutQuad` never receives its argument and returns a constant. Left broken and ticketed: nothing calls it, and contorting a function around a bug I cannot explain would only hide it. `easeInOutCubic` is the same shape and is fine, so the shape is NOT the trigger. Claude-Session: https://claude.ai/code/session_01V3MihNxaRwMR8GjMPvMkRb --- docs/tickets.md | 71 ++++++++ native/shared/src/ffi_core/game_loop.rs | 58 +++++++ package.json | 214 ++++++++++++++++++++---- src/math/index.ts | 18 +- src/models/index.ts | 30 ++++ src/world/serialize.ts | 9 +- src/world/terrain.ts | 115 ++++++++++++- 7 files changed, 472 insertions(+), 43 deletions(-) diff --git a/docs/tickets.md b/docs/tickets.md index 58cfaf1..9b7ed33 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -1547,3 +1547,74 @@ thing it launched. is now resolved against `cwd` before spawning. 2. Args cross as a newline-separated string and are re-split into a real argv. There is no shell involved, which is also why there is nothing to inject into. + +## EN-049 — `createTextureArrayFromTexels`: a texture array from DATA, not files ✅ + +`createTextureArray` / `createTextureArrayEx` take a `*const u8`, so the manifest has +to declare that param `i64` — and **Perry cannot pass a `number[]` into an i64 param** +(`TypeError: Expected safe integer for native i64 parameter`). From Perry, both are +uncallable. Every real caller therefore ended up on `createTextureArrayFromFiles`, +which is right for ART and useless for DATA: a terrain splat map is computed at load +out of the world file and there is no file to name. + +Same fix the mesh path has used all along (`bloom_create_mesh_scratch`, whose comment +already says *"Perry 0.5.x rejects `number[]` in an `i64` pointer param"*): push the +payload through the scratch buffer, then call with the dimensions. + +```ts +createTextureArrayFromTexels(texels, texelCount, w, h, layerCount, format, mipLevels) +``` + +`texels` is one **packed u32 per texel** (`r | g<<8 | b<<16 | a<<24`), so a 128² map +costs 16,384 FFI calls rather than 65,536. Load-time only — it is linear in the texel +count. + +Shipped as the transport for the shooter's authored splat terrain (editor PLAN §D). + +**Platform gap:** web and watchOS do not implement the scratch buffer at all, so +`validate-ffi` reports this function as unexported there — the same pre-existing gap +already carried by `bloom_scene_update_geometry_scratch` and +`bloom_gen_mesh_spline_ribbon_scratch`. Failures went 6 → 8 for that reason. + +## EN-050 — `clamp` in `world/terrain.ts` miscompiled; splat weights all read 0 ✅ + +`clamp(v, lo, hi)`, whose body was a single nested-ternary return, evaluated to `lo` +for **every** input when called from `quantizeWeight` in the same module — while +`sampleHeight`, in the same file, called the same helper correctly. + +Every splat weight therefore quantised to `0`, and a painted terrain loaded +unpainted. Fixed by writing `clamp` with `if` statements; verified end-to-end (the +shooter renders authored paint) and pinned by the editor self-test +`testSplatPaintPartition`. + +Root cause is a Perry codegen bug, not ours. Reduced repro, and the five probe +designs that gave *wrong* answers on the way to it: shooter `docs/perry-quirks.md` +#8. + +## EN-051 — `easeInOutQuad` never receives its argument 🔴 + +```ts +export function easeInOutQuad(t: number): number { + if (t < 0.5) return 2 * t * t; // false for EVERY input + return (4 - 2 * t) * t - 1; +} +``` + +The parameter does not arrive; the function returns a constant for all `t`. Adding a +`console.log(t)` to the body makes it correct — the signature of a codegen / +optimisation bug. Rewriting with `if` statements, reordering the expression, and +binding `t` to a local all fail to fix it. `easeInOutCubic`, directly below it and +the same shape, is fine. + +**Left broken deliberately.** Nothing in the shooter or the editor calls it, and +cargo-culting a workaround into a function I do not understand would only hide it. +It is a public export, so a game that uses it gets silently wrong easing — that is +the cost of leaving it, and it is why this is filed rather than quietly patched. + +Needs a Perry-side fix (or a minimal repro filed upstream). See shooter +`docs/perry-quirks.md` #8, Case B. + +**The rest of `src/` is not cleared.** A sweep found the single-ternary shape in the +two editor `clamp`s, `easeInOutCubic`, and three shooter helpers; those were rewritten +defensively, but only `clamp`/`quantizeWeight` and the easings were actually +*verified*. The shape is a smell, not a diagnosis. diff --git a/native/shared/src/ffi_core/game_loop.rs b/native/shared/src/ffi_core/game_loop.rs index bd8a3da..695573e 100644 --- a/native/shared/src/ffi_core/game_loop.rs +++ b/native/shared/src/ffi_core/game_loop.rs @@ -161,6 +161,64 @@ macro_rules! __bloom_ffi_game_loop { }) } + // bloom_create_texture_array_scratch [EN-049] + // + // The byte-array path above takes a `*const u8`, which the manifest has + // to declare as `i64` — and Perry cannot put a `number[]` in an i64 + // param (it raises "Expected safe integer for native i64 parameter"). + // So from Perry, `bloom_create_texture_array_ex` is only callable with a + // pointer nobody has. Every real caller ended up on the from_files path, + // which is fine for ART and useless for DATA: a splat map is computed at + // load from the world file, and there is no file to name. + // + // Same fix the mesh path already uses (`bloom_create_mesh_scratch`): + // push the payload through the scratch buffer, then call this with the + // dimensions. Texels are pushed as PACKED u32 (one per texel, RGBA in + // little-endian byte order = R | G<<8 | B<<16 | A<<24), so a 128² splat + // is 16,384 pushes rather than 65,536. + #[no_mangle] + pub extern "C" fn bloom_create_texture_array_scratch( + width: f64, + height: f64, + layer_count: f64, + format: f64, + mip_levels: f64, + ) -> f64 { + $crate::ffi::guard("bloom_create_texture_array_scratch", move || { + let w = width as u32; + let h = height as u32; + if w == 0 || h == 0 { return 0.0; } + let layers_count = (layer_count as u32) + .min($crate::renderer::material_system::MAX_TEXTURE_ARRAY_LAYERS); + if layers_count == 0 { return 0.0; } + + let texels = (w as usize) * (h as usize) * (layers_count as usize); + // Scoped so the scratch borrow ends before the renderer borrow. + let bytes: Vec = { + let eng = engine(); + if eng.models.scratch_u32.len() < texels { + // Short buffer: refuse rather than upload uninitialised + // memory as a texture. Silent garbage here surfaces as a + // terrain painted in noise, three layers from the cause. + return 0.0; + } + eng.models.scratch_u32[..texels] + .iter() + .flat_map(|p| p.to_le_bytes()) + .collect() + }; + let layer_size = (w as usize) * (h as usize) * 4; + let mut layers: Vec<(&[u8], u32, u32)> = Vec::with_capacity(layers_count as usize); + for i in 0..(layers_count as usize) { + let start = i * layer_size; + let end = start + layer_size; + if end > bytes.len() { break; } + layers.push((&bytes[start..end], w, h)); + } + engine().renderer.create_texture_array_ex(&layers, format as u32, mip_levels as u32) as f64 + }) + } + // bloom_create_texture_array_from_files [EN-014 V3] // // The byte-array path above asks the game to marshal every texel across diff --git a/package.json b/package.json index 949cc09..36663d5 100644 --- a/package.json +++ b/package.json @@ -1063,6 +1063,17 @@ ], "returns": "f64" }, + { + "name": "bloom_create_texture_array_scratch", + "params": [ + "f64", + "f64", + "f64", + "f64", + "f64" + ], + "returns": "f64" + }, { "name": "bloom_set_material_texture_array", "params": [ @@ -1164,57 +1175,104 @@ }, { "name": "bloom_anim_play", - "params": ["f64", "f64", "f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64", + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_anim_set_layer", - "params": ["f64", "f64", "f64", "f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64", + "f64", + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_anim_set_root_motion", - "params": ["f64", "f64"], + "params": [ + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_anim_update", - "params": ["f64", "f64", "f64", "f64", "f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_anim_finished", - "params": ["f64"], + "params": [ + "f64" + ], "returns": "f64" }, { "name": "bloom_anim_clip_duration", - "params": ["f64", "f64"], + "params": [ + "f64", + "f64" + ], "returns": "f64" }, { "name": "bloom_anim_root_delta", - "params": ["f64", "f64"], + "params": [ + "f64", + "f64" + ], "returns": "f64" }, { "name": "bloom_model_find_joint", - "params": ["f64", "string"], + "params": [ + "f64", + "string" + ], "returns": "f64" }, { "name": "bloom_model_joint_world", - "params": ["f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64" + ], "returns": "f64" }, { "name": "bloom_create_texture_array_from_files", - "params": ["string", "f64", "f64"], + "params": [ + "string", + "f64", + "f64" + ], "returns": "f64" }, { "name": "bloom_compile_material_instanced_bucket", - "params": ["string", "f64", "f64"], + "params": [ + "string", + "f64", + "f64" + ], "returns": "f64" }, { @@ -1224,77 +1282,141 @@ }, { "name": "bloom_ragdoll_activate", - "params": ["f64", "f64", "f64", "f64", "f64", "f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64" + ], "returns": "f64" }, { "name": "bloom_ragdoll_push", - "params": ["f64", "f64", "f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64", + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_ragdoll_update", - "params": ["f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64" + ], "returns": "f64" }, { "name": "bloom_ragdoll_release", - "params": ["f64"], + "params": [ + "f64" + ], "returns": "void" }, { "name": "bloom_particles_create", - "params": ["f64"], + "params": [ + "f64" + ], "returns": "f64" }, { "name": "bloom_particles_configure", - "params": ["f64"], + "params": [ + "f64" + ], "returns": "void" }, { "name": "bloom_particles_emit", - "params": ["f64", "f64", "f64", "f64", "f64", "f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_particles_update", - "params": ["f64", "f64"], + "params": [ + "f64", + "f64" + ], "returns": "f64" }, { "name": "bloom_particles_instance_buffer", - "params": ["f64"], + "params": [ + "f64" + ], "returns": "f64" }, { "name": "bloom_particles_clear", - "params": ["f64"], + "params": [ + "f64" + ], "returns": "void" }, { "name": "bloom_particles_live", - "params": ["f64"], + "params": [ + "f64" + ], "returns": "f64" }, { "name": "bloom_decals_init", - "params": ["f64"], + "params": [ + "f64" + ], "returns": "f64" }, { "name": "bloom_decals_spawn", - "params": ["f64", "f64", "f64", "f64", "f64", "f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_decals_set_style", - "params": ["f64", "f64", "f64", "f64", "f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_decals_update", - "params": ["f64"], + "params": [ + "f64" + ], "returns": "f64" }, { @@ -1854,32 +1976,54 @@ }, { "name": "bloom_set_sound_bus", - "params": ["f64", "f64"], + "params": [ + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_set_sound_reverb_send", - "params": ["f64", "f64"], + "params": [ + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_set_sound_lowpass", - "params": ["f64", "f64"], + "params": [ + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_set_bus_gain", - "params": ["f64", "f64"], + "params": [ + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_duck_bus", - "params": ["f64", "f64", "f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64", + "f64", + "f64" + ], "returns": "void" }, { "name": "bloom_set_reverb", - "params": ["f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64" + ], "returns": "void" }, { @@ -1889,7 +2033,11 @@ }, { "name": "bloom_gamepad_rumble", - "params": ["f64", "f64", "f64"], + "params": [ + "f64", + "f64", + "f64" + ], "returns": "void" }, { diff --git a/src/math/index.ts b/src/math/index.ts index ced45d2..390378f 100644 --- a/src/math/index.ts +++ b/src/math/index.ts @@ -154,11 +154,25 @@ export function randomInt(min: number, max: number): number { export function easeInQuad(t: number): number { return t * t; } export function easeOutQuad(t: number): number { return t * (2 - t); } -export function easeInOutQuad(t: number): number { return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; } +// BROKEN under Perry — EN-051. The parameter never arrives: `t < 0.5` is false +// for every input, so this returns a constant. Adding a `console.log(t)` to the +// body makes it correct, which is the signature of a codegen bug, not a logic +// one. Rewriting with `if`, reordering the expression, and binding `t` to a +// local were all tried and none of them fix it. `easeInOutCubic` below is the +// same shape and is fine, so the shape is not the trigger. +// +// Left in its honest form rather than contorted around a bug I cannot explain. +// Nothing in the shooter or the editor calls it. See shooter +// docs/perry-quirks.md #8, Case B. +export function easeInOutQuad(t: number): number { + if (t < 0.5) return 2 * t * t; + return (4 - 2 * t) * t - 1; +} export function easeInCubic(t: number): number { return t * t * t; } export function easeOutCubic(t: number): number { const t1 = t - 1; return t1 * t1 * t1 + 1; } export function easeInOutCubic(t: number): number { - return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + if (t < 0.5) return 4 * t * t * t; + return 1 - Math.pow(-2 * t + 2, 3) / 2; } export function easeInElastic(t: number): number { if (t === 0 || t === 1) return t; diff --git a/src/models/index.ts b/src/models/index.ts index acf880c..346aefb 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -560,6 +560,36 @@ export function createTextureArrayEx( return bloom_create_texture_array_ex(bytes as any, dataLen, width, height, layerCount, format, mipLevels); } +declare function bloom_create_texture_array_scratch( + width: number, height: number, layerCount: number, format: number, mipLevels: number, +): number; + +/// EN-049 — build a texture array from data you computed, not from files. +/// +/// `createTextureArray`/`createTextureArrayEx` take a `*const u8`, which the +/// manifest must declare `i64` — and Perry cannot pass a `number[]` to an i64 +/// param ("Expected safe integer for native i64 parameter"). They are, from +/// Perry, uncallable. That is fine for ART, which has files to name +/// (`createTextureArrayFromFiles`), and useless for DATA: a terrain splat map is +/// computed at load out of the world file and there is no file to point at. +/// +/// So the payload goes through the mesh scratch buffer, exactly as +/// `updateSceneNodeGeometry` already does for vertices. `texels` is one PACKED +/// u32 per texel — `r | g<<8 | b<<16 | a<<24`, each channel 0..255 — so a 128² +/// map is 16,384 FFI calls, not 65,536. Layers are back-to-back, layer 0 first. +/// +/// Load-time only. It is linear in the texel count and crosses the FFI once per +/// texel; do not put it on a frame path. +export function createTextureArrayFromTexels( + texels: number[], texelCount: number, + width: number, height: number, layerCount: number, + format: number = TEX_ARRAY_FORMAT_LINEAR, mipLevels: number = 1, +): number { + bloom_mesh_scratch_reset(); + for (let i = 0; i < texelCount; i = i + 1) bloom_mesh_scratch_push_u32(texels[i]); + return bloom_create_texture_array_scratch(width, height, layerCount, format, mipLevels); +} + declare function bloom_create_texture_array_from_files(paths: number, format: number, mipLevels: number): number; /// EN-014 V3 — build a texture array by naming the files, letting the engine diff --git a/src/world/serialize.ts b/src/world/serialize.ts index f26a649..615a5d3 100644 --- a/src/world/serialize.ts +++ b/src/world/serialize.ts @@ -76,7 +76,14 @@ function num(n: number): string { return '' + n; } -function bool(b: boolean): string { return b ? 'true' : 'false'; } +// Written with `if` as a precaution, not a fix for an observed bug: a helper whose +// entire body is one ternary return is a shape Perry has miscompiled elsewhere +// (perry-quirks #8). `bool()` itself was verified CORRECT in both forms — a world +// saved with `shadowsEnabled: false` does write `false`. +function bool(b: boolean): string { + if (b) return 'true'; + return 'false'; +} function vec3(v: Vec3Lit): string { return '[' + num(v[0]) + ', ' + num(v[1]) + ', ' + num(v[2]) + ']'; diff --git a/src/world/terrain.ts b/src/world/terrain.ts index 133ff04..be95704 100644 --- a/src/world/terrain.ts +++ b/src/world/terrain.ts @@ -16,12 +16,47 @@ // by the brush tool to find where the user is painting. // defaultTerrain — a flat 128x128 terrain centered at origin. -import { TerrainData, Vec3Lit } from './types'; +import { TerrainData, TerrainLayer, Vec3Lit } from './types'; // Vertex stride in floats (matches scene graph expectation). // See `bloom/engine/src/scene/index.ts` updateSceneNodeGeometry docs. const STRIDE = 12; + +// ---- splat mask preview ----------------------------------------------------- + +// Mask colours for the first eight splat layers, used to tint the heightmap +// mesh's vertex colours so a painted layer is VISIBLE while you paint it. +// +// This is a MASK preview, not a material preview: it shows you *where* layer 2 +// is, not what layer 2's texture looks like. A game renders the real textures +// from `TerrainLayer.textureRef` (the shooter samples them triplanar, blended by +// exactly these weights); nothing but the editor viewport ever sees these +// colours. They are ordered to read naturally for the common +// grass/dry/dirt/rock set, so the preview is not actively misleading. +const MASK_PALETTE: number[] = [ + 0.36, 0.60, 0.24, // 0 — green (lush grass) + 0.72, 0.68, 0.30, // 1 — olive (dry grass) + 0.55, 0.40, 0.26, // 2 — brown (dirt) + 0.58, 0.58, 0.60, // 3 — grey (rock) + 0.80, 0.74, 0.55, // 4 — sand + 0.90, 0.92, 0.95, // 5 — snow + 0.30, 0.34, 0.42, // 6 — slate + 0.62, 0.30, 0.28, // 7 — clay +]; + +// Unpainted ground. Also the colour of a terrain with no layers at all, which +// is every terrain until someone opens the paint tool. +const BARE_R = 0.55; +const BARE_G = 0.60; +const BARE_B = 0.50; + +/// The mask colour the editor viewport uses for splat layer `i`. Wraps past 8. +export function terrainLayerMaskColor(i: number): Vec3Lit { + const k = (i % 8) * 3; + return [MASK_PALETTE[k], MASK_PALETTE[k + 1], MASK_PALETTE[k + 2]]; +} + // Default grid size for new worlds. Large enough for meaningful terrain, // small enough to rebuild in <1ms on any modern CPU. const DEFAULT_WIDTH = 128; @@ -49,11 +84,20 @@ export function buildHeightmapMesh(t: TerrainData): { vertices: number[]; indice const vertices: number[] = new Array(vertexCount * STRIDE); const indices: number[] = new Array((width - 1) * (depth - 1) * 6); - // Default vertex color (grayscale stone). Editor / game can tint per-layer - // via the splat weights once that pipeline lands. - const cr = 0.55; - const cg = 0.60; - const cb = 0.50; + // Splat preview. Vertex colour = the bare-ground grey, with each painted + // layer's mask colour mixed in by its weight at this cell. A cell nobody has + // painted keeps the grey exactly, so an unpainted terrain looks precisely as + // it did before painting existed. + // + // Hoisted out of the vertex loop: `layers` is up to eight parallel arrays and + // resolving `t.layers[l].weights` 16,384 times is 16,384 property chains. + const layerCount = t.layers.length; + const layerWeights = new Array(layerCount); + const layerColor = new Array(layerCount); + for (let l = 0; l < layerCount; l++) { + layerWeights[l] = t.layers[l].weights; + layerColor[l] = terrainLayerMaskColor(l); + } const ca = 1.0; // Vertex pass. @@ -62,6 +106,23 @@ export function buildHeightmapMesh(t: TerrainData): { vertices: number[]; indice const idx = z * width + x; const h = heights[idx]; + let cr = BARE_R; + let cg = BARE_G; + let cb = BARE_B; + for (let l = 0; l < layerCount; l++) { + const ws = layerWeights[l]; + // A layer whose weights array is short (hand-edited file, or a layer + // added before the grid was resized) contributes nothing rather than + // reading undefined and poisoning the whole vertex with NaN. + if (idx >= ws.length) continue; + const wgt = ws[idx]; + if (wgt <= 0.0) continue; + const c = layerColor[l]; + cr = cr + (c[0] - cr) * wgt; + cg = cg + (c[1] - cg) * wgt; + cb = cb + (c[2] - cb) * wgt; + } + // World-space position. const wx = originX + x * cellSize; const wy = originY + h; @@ -120,6 +181,7 @@ export function buildHeightmapMesh(t: TerrainData): { vertices: number[]; indice return { vertices: vertices, indices: indices }; } + // Bilinear sample of the terrain at world-space (wx, wz). Returns the world // Y of the surface at that point, including the terrain's origin offset. // Points outside the grid clamp to the nearest edge cell. @@ -249,6 +311,45 @@ export function defaultTerrain(): TerrainData { }; } +/// A new, fully-unpainted splat layer sized to `t`'s grid. +/// +/// Weights start at zero everywhere, which means "this layer is nowhere" — the +/// terrain keeps whatever it looked like before the layer was added. Adding a +/// layer is therefore always a no-op until you paint with it, which is the only +/// behaviour that makes "add layer" a safe button to press. +export function createTerrainLayer(t: TerrainData, id: string, textureRef: string, tileScale: number): TerrainLayer { + const n = t.width * t.depth; + const w = new Array(n); + for (let i = 0; i < n; i++) w[i] = 0; + return { id: id, textureRef: textureRef, weights: w, tileScale: tileScale }; +} + +/// Quantize a splat weight for storage. +/// +/// Weights are consumed as an 8-bit texture, so anything past ~3 decimals is +/// precision that cannot survive the trip to the GPU — but it CAN survive the +/// trip to the JSON file, where `0.5019607843137255` costs 18 bytes and there +/// are `width * depth * layers` of them. A 128² four-layer terrain is 65,536 +/// weights; at full precision that is a megabyte of noise in the diff. +export function quantizeWeight(w: number): number { + const c = clamp(w, 0, 1); + return Math.round(c * 1000) / 1000; +} + +// Written with `if` statements, NOT the obvious `v < lo ? lo : (v > hi ? hi : v)`. +// +// Perry miscompiles a module-private helper whose body is a single nested-ternary +// return: called from another function in the same module it evaluates to the +// FIRST branch regardless of the condition — `clamp(0.5, 0, 1)` returns 0. The +// same helper written with `if` statements is correct, and so is the same ternary +// inlined at the call site. See the shooter's docs/perry-quirks.md #8. +// +// Pinned by the editor self-test `testSplatPaintPartition` ("weights quantize to +// 3dp"), which fails outright if this is "simplified" back to a ternary — at +// which point every splat weight silently becomes 0 and every painted terrain +// loads unpainted. function clamp(v: number, lo: number, hi: number): number { - return v < lo ? lo : (v > hi ? hi : v); + if (v < lo) return lo; + if (v > hi) return hi; + return v; } From c1b298008130204945cdea84f80c83efbefbec9b Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 13 Jul 2026 13:33:24 +0200 Subject: [PATCH 17/17] fix(dx12): compile with DXC so hardware ray query is actually reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boot line has been saying `ray_query=false` on every Windows machine, and Lumen has been silently running its software trace. The GPU was never the problem. wgpu's DX12 backend reports the adapter's shader model as min(device, compiler). Its fallback compiler is FXC, which caps that at 5.1 — and EXPERIMENTAL_RAY_QUERY is gated on shader model >= 6.5 (wgpu-hal dx12/adapter.rs: `supports_ray_tracing` requires RaytracingTier 1.1 AND SM 6.5). So with FXC, hardware ray query cannot be exposed on DX12 on ANY GPU, however capable. wgpu's default compiler setting (`Auto`) looks for DXC on PATH, does not find it, and falls back to FXC without saying anything. Point the DX12 backend at DXC (`DynamicDxc`), and ship a script that copies `dxcompiler.dll` + `dxil.dll` from the Windows SDK next to the binary. `static-dxc` would avoid the DLLs but needs MSVC's ATL, which is not in a default toolchain install. If the DLLs are absent, wgpu falls back to FXC on its own — we lose HW ray query exactly as before, and nothing else breaks. Also: pick the adapter deliberately. We took whatever `request_adapter` returned and never looked at whether another candidate could trace rays. Now every surface-capable adapter is logged with its ray_query support, and one that supports it is preferred. BLOOM_FORCE_SW_GI still forces the software path. Measured on a Radeon 760M (RDNA3, DX12): before: ray_query=false, ssgi trace backend = sdf-clipmap after: ray_query=true, ssgi trace backend = hw-ray-query This also undercuts EN-023's premise ("the dev box's 760M reports none"), noted in the ticket. The software path's object-space-AABB bug is still real for adapters that genuinely lack RT (Android, web), but it is no longer what stands between this hardware and coloured bounce. NOT YET MEASURED: the frame-time cost of the HW path versus the SW fallback. That needs the perf protocol on a fight, not the title screen. --- docs/tickets.md | 17 +++++++++ native/windows/src/lib.rs | 74 ++++++++++++++++++++++++++++++++++++--- tools/fetch-dxc.ps1 | 48 +++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 tools/fetch-dxc.ps1 diff --git a/docs/tickets.md b/docs/tickets.md index 9b7ed33..994eb71 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -818,6 +818,23 @@ visualization). ## EN-023 — GI software path: colored bounce is unreachable 🟡 partially landed (PR #79), still open +**Update 2026-07-13 — the premise below is wrong, and it changes the priority.** +The dev box's Radeon 760M *does* support hardware ray query. It reported +`ray_query=false` because **wgpu's DX12 backend was compiling shaders with +FXC**, which caps the reported shader model at 5.1 — and +`EXPERIMENTAL_RAY_QUERY` is gated on shader model ≥ 6.5 (wgpu-hal +`dx12/adapter.rs`: `supports_ray_tracing`). With FXC, ray query is unreachable +on DX12 **on every GPU**, so every Windows machine was silently running the SW +path. Switching the DX12 backend to DXC (`Dx12Compiler::DynamicDxc`, DLLs +copied by `tools/fetch-dxc.ps1`) flips the boot line to `ray_query=true` and the +trace backend to `hw-ray-query`. + +So EN-023 is no longer the thing standing between this hardware and coloured +bounce — the HW path was always available. The SW path still matters for +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) fixed the data path: world-space AABBs carried per instance (`world_aabb_min/max` in InstanceGiData, both HW and SDF struct diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index b9b3d89..7a42feb 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -557,8 +557,31 @@ unsafe fn init_engine_for_hwnd( phys_w: u32, phys_h: u32, ) { + // Compile shaders with DXC, not FXC. + // + // This is not a nicety. wgpu's DX12 backend reports the adapter's + // shader model as min(device, compiler), and FXC — its default — caps + // that at 5.1. Hardware ray query requires 6.5 (wgpu-hal + // dx12/adapter.rs: `supports_ray_tracing`), so with FXC, + // EXPERIMENTAL_RAY_QUERY is never exposed on DX12 on *any* GPU, no + // matter how capable. Lumen then silently takes its software path and + // the frame quietly loses its hardware-traced GI. That is what the + // `ray_query=false` in the boot line has been telling us. + // + // DXC is loaded at runtime from `dxcompiler.dll` + `dxil.dll`. wgpu's + // `static-dxc` feature would avoid the DLLs but needs MSVC's ATL, + // which is not part of a default toolchain install. Both DLLs ship + // with the Windows SDK; `tools/fetch-dxc.ps1` copies them next to the + // binary. If they are missing, wgpu falls back to FXC on its own — we + // lose HW ray query, exactly as before, and nothing else breaks. + let mut backend_options = wgpu::BackendOptions::default(); + backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::DynamicDxc { + dxc_path: String::from("dxcompiler.dll"), + }; + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: wgpu::Backends::DX12 | wgpu::Backends::VULKAN, + backend_options, ..wgpu::InstanceDescriptor::new_without_display_handle() }); @@ -575,11 +598,52 @@ unsafe fn init_engine_for_hwnd( }).expect("Failed to create surface") }; - let adapter = pollster_block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { - compatible_surface: Some(&surface), - power_preference: wgpu::PowerPreference::HighPerformance, - ..Default::default() - })).expect("No adapter found"); + // Pick the adapter that can actually trace rays. + // + // We used to take whatever `request_adapter` handed back, which on + // Windows means DX12 — and wgpu's DX12 backend only reports + // EXPERIMENTAL_RAY_QUERY when the driver advertises D3D12 raytracing + // tier 1.1. The same GPU under Vulkan can expose VK_KHR_ray_query + // when DX12 does not. The result was silent: Lumen's hardware trace + // was never selected, the software SDF path ran instead, and nobody + // saw a reason why. So enumerate the candidates, say out loud what + // each one offers, and prefer one that supports ray query. + // + // BLOOM_FORCE_SW_GI keeps its meaning: it also stops us from picking + // a backend *for* ray tracing, so the software path can be tested on + // hardware that would otherwise take the fast route. + let want_rt = !std::env::var("BLOOM_FORCE_SW_GI") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + let candidates = pollster_block_on( + instance.enumerate_adapters(wgpu::Backends::DX12 | wgpu::Backends::VULKAN), + ); + let mut rt_adapter: Option = None; + for cand in candidates { + let info = cand.get_info(); + let surf_ok = cand.is_surface_supported(&surface); + let has_rt = cand + .features() + .contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY); + eprintln!( + "bloom: candidate '{}' ({:?}), ray_query={}, surface={}", + info.name, info.backend, has_rt, surf_ok, + ); + if want_rt && has_rt && surf_ok && rt_adapter.is_none() { + rt_adapter = Some(cand); + } + } + + let adapter = match rt_adapter { + Some(a) => a, + None => pollster_block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + compatible_surface: Some(&surface), + power_preference: wgpu::PowerPreference::HighPerformance, + ..Default::default() + })) + .expect("No adapter found"), + }; { // One line of boot truth: which GPU we got and whether the diff --git a/tools/fetch-dxc.ps1 b/tools/fetch-dxc.ps1 new file mode 100644 index 0000000..7b3a3c6 --- /dev/null +++ b/tools/fetch-dxc.ps1 @@ -0,0 +1,48 @@ +# Put the DirectX Shader Compiler next to a Bloom binary. +# +# powershell -File tools/fetch-dxc.ps1 -Dest C:\path\to\game +# +# Why this exists: wgpu's DX12 backend reports the adapter's shader model as +# min(device, compiler). Its fallback compiler, FXC, caps that at 5.1 - and +# hardware ray query needs 6.5. So without DXC, EXPERIMENTAL_RAY_QUERY is never +# exposed on DX12 on ANY GPU, Lumen silently drops to its software trace, and +# the only symptom is "ray_query=false" in the boot line. +# +# wgpu loads dxcompiler.dll by name, so the DLL must sit beside the binary (or +# on PATH). dxil.dll signs the compiled shaders; without it the driver rejects +# them. Both ship with the Windows SDK, so copy rather than download. +# +# If the SDK is absent, take them from +# https://github.com/microsoft/DirectXShaderCompiler/releases (v1.8.2502+). +# Missing DLLs are not fatal: wgpu falls back to FXC and you are back on the +# software GI path. +# +# ASCII only on purpose - PowerShell reads .ps1 as ANSI, and a stray em-dash +# is a parse error. +param( + [Parameter(Mandatory = $true)][string]$Dest +) +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $Dest)) { throw "destination not found: $Dest" } + +$sdkBin = 'C:\Program Files (x86)\Windows Kits\10\bin' +if (-not (Test-Path $sdkBin)) { throw "Windows SDK not found at $sdkBin" } + +# Newest SDK version that ships an x64 dxcompiler.dll. +$src = Get-ChildItem $sdkBin -Directory | + Sort-Object Name -Descending | + ForEach-Object { Join-Path $_.FullName 'x64' } | + Where-Object { Test-Path (Join-Path $_ 'dxcompiler.dll') } | + Select-Object -First 1 + +if (-not $src) { throw "no x64 dxcompiler.dll under $sdkBin" } + +foreach ($dll in @('dxcompiler.dll', 'dxil.dll')) { + $from = Join-Path $src $dll + if (-not (Test-Path $from)) { throw "missing $dll in $src" } + Copy-Item $from -Destination $Dest -Force + Write-Host "copied $dll -> $Dest" +} + +Write-Host "DXC in place. Boot line should now report ray_query=true on an RT-capable GPU."