diff --git a/docs/tickets.md b/docs/tickets.md index 87abfb6..994eb71 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 @@ -334,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. @@ -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 @@ -461,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 @@ -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 @@ -785,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 @@ -857,3 +907,731 @@ 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 βœ… *(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 +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 βœ… *(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 +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 βœ… *(shipped)* + +**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 βœ… *(shipped β€” AnimMixer)* + +**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 βœ… *(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 +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 βœ… *(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 +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 βœ… *(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_*`, +`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. + +--- + +## 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 +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. + + + +## 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 βœ… *(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 +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. + + +## 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. + + +## 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.) + + +--- + +## 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. + + +## 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. + + +## 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. + + +--- + +## 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. + + +## 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. + +## 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/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/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/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/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/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..967faf6 100644 --- a/native/shared/src/engine.rs +++ b/native/shared/src/engine.rs @@ -32,6 +32,15 @@ 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, + /// 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, @@ -89,6 +98,10 @@ impl EngineState { profiler, screenshot_pending: false, 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/assets.rs b/native/shared/src/ffi_core/assets.rs index 05a3b55..1c292af 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, @@ -206,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/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/game_loop.rs b/native/shared/src/ffi_core/game_loop.rs index 0c67d86..695573e 100644 --- a/native/shared/src/ffi_core/game_loop.rs +++ b/native/shared/src/ffi_core/game_loop.rs @@ -161,6 +161,113 @@ 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 + // 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/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/ffi_core/mod.rs b/native/shared/src/ffi_core/mod.rs index 4b47c23..98570e2 100644 --- a/native/shared/src/ffi_core/mod.rs +++ b/native/shared/src/ffi_core/mod.rs @@ -66,6 +66,8 @@ mod audio_ffi; 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 @@ -83,6 +85,8 @@ macro_rules! define_core_ffi { $crate::__bloom_ffi_models!(); $crate::__bloom_ffi_scene!(); $crate::__bloom_ffi_visual!(); + $crate::__bloom_ffi_vfx!(); + $crate::__bloom_ffi_ragdoll!(); }; } diff --git a/native/shared/src/ffi_core/models.rs b/native/shared/src/ffi_core/models.rs index da00527..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] @@ -845,5 +866,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/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/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/ffi_core/visual.rs b/native/shared/src/ffi_core/visual.rs index 4e6cbd7..264fb18 100644 --- a/native/shared/src/ffi_core/visual.rs +++ b/native/shared/src/ffi_core/visual.rs @@ -472,6 +472,64 @@ 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_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 + // (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/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/shared/src/lib.rs b/native/shared/src/lib.rs index ed66818..570379f 100644 --- a/native/shared/src/lib.rs +++ b/native/shared/src/lib.rs @@ -24,6 +24,10 @@ pub mod postfx; pub mod custom_shaders; 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 652322b..f287641 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 { @@ -180,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) } @@ -486,86 +557,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 +1418,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/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/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/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 1ef9f1a..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)] @@ -866,6 +870,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 +895,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..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); @@ -364,6 +365,200 @@ 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], + cloud: [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 +597,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..a59c502 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. @@ -1011,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 @@ -1451,6 +1482,27 @@ 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], + /// 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::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, + /// 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 @@ -1573,6 +1625,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 @@ -2571,7 +2627,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(), @@ -2807,7 +2871,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(), @@ -3364,6 +3436,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) @@ -6239,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, @@ -6433,6 +6625,14 @@ 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], + shadow_caster_tf: std::collections::HashSet::new(), + foliage_wind: std::collections::HashMap::new(), + foliage_shadow_motion: false, uniform_3d_layout, render_mode: RenderMode::ScreenSpace, pending_skin_groups: Vec::with_capacity(8), @@ -6494,6 +6694,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, @@ -6602,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; @@ -6612,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. @@ -6875,6 +7104,67 @@ 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`). + /// + /// `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 +8443,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 +8908,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)); @@ -11055,12 +11360,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, @@ -11506,6 +11836,8 @@ 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; + 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(); @@ -11519,6 +11851,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/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/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/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..dc07476 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -210,7 +210,15 @@ 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"), + include_str!("../../../shaders/common/foliage_wind.wgsl"), + r#" struct Uniforms3D { mvp: mat4x4, model: mat4x4, @@ -248,6 +256,8 @@ 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 + frame_misc: vec4, // x=delta_time (prev-frame wind, for motion vectors) }; struct MaterialFactors { @@ -266,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, @@ -385,24 +404,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); @@ -709,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); @@ -883,8 +928,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 +1219,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/shadow_pass.rs b/native/shared/src/renderer/shadow_pass.rs index 5610fd7..e149dd3 100644 --- a/native/shared/src/renderer/shadow_pass.rs +++ b/native/shared/src/renderer/shadow_pass.rs @@ -22,6 +22,13 @@ 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_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], @@ -140,6 +147,13 @@ 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, + // 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; @@ -148,6 +162,27 @@ impl Renderer { h = fnv1a_bytes(h, &idx.to_le_bytes()); fnv1a_bytes(h, bytemuck::bytes_of(transform)) } + // EN-043 β€” "was this exact caster, at this exact transform, here last frame?" + // + // 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. + // + // 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, 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 @@ -190,6 +225,8 @@ 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, + key: caster_id(0, i as u64, node.gpu_index_count as u64, &node.transform), }); } @@ -223,6 +260,8 @@ impl Renderer { sig: nonce, dynamic: true, joint_offset: 0.0, + foliage: 0.0, + key: 0, }); } else { let num_calls = self.draw_calls_3d.len(); @@ -248,11 +287,19 @@ impl Renderer { sig: if call.has_skinned { nonce } else { call.content_hash }, dynamic: true, joint_offset: 0.0, + foliage: 0.0, + key: 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 +338,32 @@ impl Renderer { sig: nonce, 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 + // 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 +376,17 @@ 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, + key: caster_id(1, cmd.cache_handle, cmd.mesh_idx as u64, &cmd.model), }); } } @@ -351,11 +430,39 @@ 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, + key: caster_id(2, cmd.mesh_handle, cmd.mesh_idx as u64, &cmd.model), }); } } } + // 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; } + 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; + } + } + 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]) @@ -429,7 +536,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::()] @@ -514,17 +622,36 @@ 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() { 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::()] @@ -592,6 +719,7 @@ impl Renderer { // Cache bookkeeping β€” next frame skips every cascade whose VP // and caster content stay put. + 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/renderer/types.rs b/native/shared/src/renderer/types.rs index 2070a21..ab3ab55 100644 --- a/native/shared/src/renderer/types.rs +++ b/native/shared/src/renderer/types.rs @@ -139,6 +139,16 @@ 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], + /// 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 { @@ -159,6 +169,8 @@ 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], + frame_misc: [0.0; 4], } } } @@ -237,7 +249,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 +269,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/shared/src/shadows.rs b/native/shared/src/shadows.rs index 1e5c44d..6e5b206 100644 --- a/native/shared/src/shadows.rs +++ b/native/shared/src/shadows.rs @@ -26,19 +26,29 @@ 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 = " +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 +64,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 +105,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 +123,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 +187,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. @@ -813,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); @@ -856,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; @@ -954,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), 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/native/watchos/src/ffi_stubs.rs b/native/watchos/src/ffi_stubs.rs index 6c235c5..d9b1e3e 100644 --- a/native/watchos/src/ffi_stubs.rs +++ b/native/watchos/src/ffi_stubs.rs @@ -245,6 +245,16 @@ } #[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_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 } +#[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) { @@ -768,3 +778,95 @@ #[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() { +} + +// 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) { +} + +#[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 +} + +// 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/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/lib.rs b/native/web/src/lib.rs index 0dd86ad..adbd95f 100644 --- a/native/web/src/lib.rs +++ b/native/web/src/lib.rs @@ -1534,6 +1534,32 @@ 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); +} +#[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); +} +#[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); +} +#[wasm_bindgen] pub fn bloom_set_ssr_enabled(on: f64) { engine().renderer.set_ssr_enabled(on != 0.0); } diff --git a/native/web/src/material_ffi.rs b/native/web/src/material_ffi.rs index ce737c6..f220414 100644 --- a/native/web/src/material_ffi.rs +++ b/native/web/src/material_ffi.rs @@ -454,3 +454,223 @@ 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(); +} + +// 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); +} + +#[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/native/windows/src/lib.rs b/native/windows/src/lib.rs index cfd6e52..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 @@ -841,11 +905,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 98644cb..36663d5 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/", @@ -1062,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": [ @@ -1161,6 +1173,262 @@ ], "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_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_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" + ], + "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": [ @@ -1504,6 +1772,52 @@ ], "returns": "void" }, + { + "name": "bloom_launch_process", + "params": [ + "string", + "string", + "string" + ], + "returns": "f64" + }, + { + "name": "bloom_set_output_scale", + "params": [ + "f64" + ], + "returns": "void" + }, + { + "name": "bloom_get_output_scale", + "params": [], + "returns": "f64" + }, + { + "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": [ + "f64", + "f64", + "f64", + "f64" + ], + "returns": "void" + }, { "name": "bloom_set_ssr_enabled", "params": [ @@ -1660,11 +1974,72 @@ ], "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); +} diff --git a/src/core/index.ts b/src/core/index.ts index 0eaf4ca..f31ea58 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -27,6 +27,9 @@ 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_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; declare function bloom_set_cas_strength(strength: number): void; @@ -55,6 +58,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; @@ -338,6 +342,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). */ @@ -558,6 +579,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); @@ -815,6 +863,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; } @@ -1073,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); +} 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/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 c67dd53..346aefb 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; @@ -366,6 +368,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 +560,69 @@ 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 +/// 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 @@ -685,6 +773,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 @@ -909,3 +1076,87 @@ 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); +} + +/// 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); +} 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(); +} 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..615a5d3 --- /dev/null +++ b/src/world/serialize.ts @@ -0,0 +1,292 @@ +// 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; +} + +// 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]) + ']'; +} +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'; +} 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; } 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." diff --git a/tools/validate-ffi.js b/tools/validate-ffi.js index 6d85141..8e7202a 100644 --- a/tools/validate-ffi.js +++ b/tools/validate-ffi.js @@ -169,6 +169,17 @@ 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', + // 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