EN-063: web target runs full-3D games (the shooter port)#118
EN-063: web target runs full-3D games (the shooter port)#118proggeramlug wants to merge 2 commits into
Conversation
The web crate was a near-complete FFI port whose gaps only showed the moment a real 3D game booted. Everything native is byte-identical except one deliberate fix (closeWindow, below). Web FFI parity (native/web/parity_ffi.rs + ragdoll_ffi.rs): - The scratch-stream builders every TS wrapper actually uses: mesh scratch, create_mesh_scratch, instance-buffer scratch, spline ribbon, texture-array scratch, set_material_params_scratch. Without them every generated mesh silently came back 0 on web. - Texture-array-from-files split for a host with no filesystem: glue fetches each path, bloom_texture_array_files_push decodes engine-side (same image codecs as native), commit assembles. - bloom_stage_model_bytes: the sync web half of staged model loading. - Ragdolls: the shared plan/attach/apply math compiles on wasm (gate lifted - it never needed Jolt types); the body/constraint half rides jolt_bridge.js, incl. a new constraintSixDofLockedTranslation. - splat_impulse, scene_set_gi_only, env-HDR-from-bytes (+ the shared renderer twin set_env_clear_from_hdr_bytes), profiler text FFIs, present-mode/screenshot stubs, mouse/touch/wheel/char injection. - commit_model synced with native: kind-aware register_texture_kind + the FULL aux-texture remap (the web copy still had the round-5 bug that strips normal/MR/emissive maps). - WebGPU device now requests the adapter's per-stage texture/sampler limits (the material ABI samples 19 textures; the default 16 made every user material invalid, and one invalid pipeline poisons the whole frame's command buffer - black screen, zero errors). Scene-inputs bind group folded into per_frame on wasm32 only: browsers hard-cap maxBindGroups at 4, the material ABI used 5. The expanded WGSL's @group(4) declarations are rewritten to group 0 bindings 1..7 at compile_material time; layout, bind-group builder, and the dispatch site agree via WASM_SCENE_INPUTS_BASE. Browser WGSL strictness (Tint enforces what naga waves through), all pixel-identical: textureSampleLevel(0) on mipless render targets, textureSampleCompareLevel for the shadow cascades, dpdx/dpdy hoisted out of non-uniform control flow (SSR early-outs, scene compute_tbn). jolt_bridge.js vs npm JoltPhysics.js builds (written against an older API generation): CharacterVirtual is the 4-arg ctor there - the 5-arg number-userData form binds 0 as the PhysicsSystem and dies later with "null function"; query filters come from the JoltInterface getters; closest-hit CastRay only exists in collector form; heightfield samples must be written into the emscripten heap; u64 userData accepts Number, not BigInt. Filters are now cached per (world, layer) - the character path leaked three emscripten objects per frame. bloom_glue.js: engine pre-init + WebGPU adapter probe before the game boots (Perry main is sync, wgpu init is async), asset prefetch cache from assets_manifest.json (manifest is authoritative for fileExists - no blocking-404 storms), readFile falls back to the served asset tree, keyboard map corrected to the ACTUAL Key enum (it was GLFW codes - Enter/Escape/arrows/F-keys never matched, menus were dead while WASD worked), queue+flush input with one-frame deferred key-ups, pointer lock with ESC-exit translated to an injected Key.ESCAPE, mouse/touch/ wheel wiring, per-FFI error attribution. closeWindow() now sets should_close on windows/linux/macos - it was an empty stub, so a game's quit path kept running headless. The shooter's runGame refactor turns its `break` exits into closeWindow() calls; verified native with a COMBATSHOT run (identical numbers, clean exit). package.json web target uses the Perry 0.5 schema: prebuilt -> ./native/web/pkg/ (the old crate/buildTool/output shape fails "crate and lib must be declared together"; the ./ prefix is load- bearing - a bare path parses as a node-module reference). stageModelsSync in the TS API: Perry's parallelMap workers are their own WASM instances with no engine FFI on web.
📝 WalkthroughWalkthroughThe PR adds wasm32 renderer, model, profiler, input, physics, and ragdoll integration; updates browser startup and asset handling; changes shader sampling and derivative flow; adds byte-backed asset APIs; and makes native close-window calls signal engine shutdown. ChangesWeb runtime and parity
Web physics and ragdolls
Renderer ABI and shader sampling
Cross-platform APIs and shutdown
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@native/web/jolt_bridge.js`:
- Around line 657-662: Update the ray and overlap query paths around the
closest-hit, all-hit, and overlap methods to honor the supplied layerMask
instead of discarding it or hardcoding layer 1. Build and reuse the appropriate
object/body filter from layerMask for every query, including the locations
corresponding to CastRay and overlap calls, while preserving each query’s
existing hit-collection behavior.
- Around line 1088-1108: Update destroyWorld to clear all queryFilterCache
entries matching the destroyed world’s worldH, calling J.destroy on each cached
bp, obj, body, and shape before removing the entry. Keep unrelated worlds’
cached filters intact and perform cleanup before releasing the world’s Jolt
resources.
- Around line 927-950: Update the SixDOF constraint creation flow around the
local settings object and ctx.release(): destroy the temporary
J.SixDOFConstraintSettings instance with J.destroy(settings), and move
ctx.release() into a finally block so it executes exactly once on success or
when Create() or registerConstraint() throws. Apply the same cleanup and
finally-based release pattern to the matching heightfield helper.
In `@native/web/src/parity_ffi.rs`:
- Around line 30-343: The web FFI manifest is missing declarations for newly
exported texture-array, staged-model, HDR environment, and input-injection
functions. Update package.json to add entries for
bloom_texture_array_files_reset, bloom_texture_array_files_push,
bloom_texture_array_files_commit, bloom_stage_model_bytes,
bloom_set_env_clear_from_hdr_bytes, and every bloom_inject_* wrapper in
native/web/src/input_ffi.rs:188-240; no direct source changes are needed in
either Rust file.
In `@native/web/src/ragdoll_ffi.rs`:
- Around line 63-146: Make the ragdoll setup transactional before the
body-creation loop: validate the `rag` handle and required animation before
creating physics objects. Treat any failed `jb_body_create` or
`jb_constraint_six_dof` result as a setup failure, destroy every body and
constraint created during this call, and return `0.0`; only call `r.attach`
after all handles are valid so no partial ragdoll becomes active.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: cc3de5bc-6241-4adf-a3fb-d94d0bc53645
📒 Files selected for processing (24)
native/linux/src/lib.rsnative/macos/src/lib.rsnative/shared/src/engine.rsnative/shared/src/lib.rsnative/shared/src/renderer/material_pipeline.rsnative/shared/src/renderer/material_system.rsnative/shared/src/renderer/mod.rsnative/shared/src/renderer/shaders/ao.rsnative/shared/src/renderer/shaders/core.rsnative/shared/src/renderer/shaders/post.rsnative/shared/src/renderer/shaders/ssgi.rsnative/shared/src/textures.rsnative/web/bloom_glue.jsnative/web/jolt_bridge.jsnative/web/src/input_ffi.rsnative/web/src/lib.rsnative/web/src/parity_ffi.rsnative/web/src/physics_ffi.rsnative/web/src/ragdoll_ffi.rsnative/windows/src/lib.rspackage.jsonsrc/index.tssrc/math/index.tssrc/models/index.ts
| const qf = queryFilters(w, worldH, 1); | ||
| const rcSettings = new J.RayCastSettings(); | ||
| const collector = new J.CastRayClosestHitCollisionCollector(); | ||
| w.system.GetNarrowPhaseQuery().CastRay(ray, rcSettings, collector, qf.bp, qf.obj, qf.body, qf.shape); | ||
| const hit = collector.HadHit(); | ||
| void layerMask; // layer filtering is handled by ObjectLayerFilter; Tier 2 adds mask support |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Honor layerMask instead of hardcoding the pass-all layer.
All ray and overlap queries now use filters for layer 1 while explicitly discarding layerMask. Callers therefore receive bodies from excluded layers. Build an object/body filter from the supplied mask and use it consistently across closest-hit, all-hit, and overlap queries.
Also applies to: 694-697, 755-755, 768-768, 789-789
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@native/web/jolt_bridge.js` around lines 657 - 662, Update the ray and overlap
query paths around the closest-hit, all-hit, and overlap methods to honor the
supplied layerMask instead of discarding it or hardcoding layer 1. Build and
reuse the appropriate object/body filter from layerMask for every query,
including the locations corresponding to CastRay and overlap calls, while
preserving each query’s existing hit-collection behavior.
| try { | ||
| const settings = new J.SixDOFConstraintSettings(); | ||
| settings.mSpace = worldSpace ? J.EConstraintSpace_WorldSpace : J.EConstraintSpace_LocalToBodyCOM; | ||
| settings.mPosition1 = rvec3(ax, ay, az); | ||
| settings.mPosition2 = rvec3(bx, by, bz); | ||
| const AX = [ | ||
| [J.SixDOFConstraintSettings_EAxis_TranslationX, 1, -1], | ||
| [J.SixDOFConstraintSettings_EAxis_TranslationY, 1, -1], | ||
| [J.SixDOFConstraintSettings_EAxis_TranslationZ, 1, -1], | ||
| [J.SixDOFConstraintSettings_EAxis_RotationX, rxMin, rxMax], | ||
| [J.SixDOFConstraintSettings_EAxis_RotationY, ryMin, ryMax], | ||
| [J.SixDOFConstraintSettings_EAxis_RotationZ, rzMin, rzMax], | ||
| ]; | ||
| for (const [axis, lo, hi] of AX) { | ||
| if (lo >= hi) settings.MakeFixedAxis(axis); | ||
| else settings.SetLimitedAxis(axis, lo, hi); | ||
| } | ||
| const c = settings.Create(ctx.body1, ctx.body2); | ||
| ctx.release(); | ||
| return registerConstraint(state.bodies.get(bodyA).world, c); | ||
| } catch (e) { | ||
| ctx.release(); | ||
| console.warn('[jolt_bridge] SixDOF constraint unavailable:', e); | ||
| return 0; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show the relevant regions with line numbers
sed -n '900,980p' native/web/jolt_bridge.js
printf '\n----\n'
sed -n '1020,1085p' native/web/jolt_bridge.js
printf '\n==== search for J.destroy / release patterns ====\n'
rg -n "J\.destroy\(|ctx\.release\(|new J\.[A-Za-z0-9_]+Settings\(|Create\(ctx\.body1, ctx\.body2\)" native/web/jolt_bridge.jsRepository: Bloom-Engine/engine
Length of output: 8554
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find similar helper functions in the file and display surrounding code
rg -n "Create\(ctx\.body1, ctx\.body2\)|ctx\.release\(\)|J\.destroy\(settings\)|new J\.[A-Za-z0-9_]+Settings\(" native/web/jolt_bridge.js
printf '\n---- around other Create helpers ----\n'
for n in $(rg -n "Create\(ctx\.body1, ctx\.body2\)" native/web/jolt_bridge.js | cut -d: -f1); do
start=$((n-20))
end=$((n+25))
sed -n "${start},${end}p" native/web/jolt_bridge.js
printf '\n----\n'
doneRepository: Bloom-Engine/engine
Length of output: 14911
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the constraint context type and its release method
rg -n "release\s*[:=]\s*function|release\(\)\s*{" native/web/jolt_bridge.js
rg -n "getConstraintContext|ConstraintContext|ctx =" native/web/jolt_bridge.js
printf '\n==== surrounding code for the referenced regions ====\n'
sed -n '860,980p' native/web/jolt_bridge.js
printf '\n----\n'
sed -n '1010,1085p' native/web/jolt_bridge.jsRepository: Bloom-Engine/engine
Length of output: 303
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find the constraint body resolver and any release implementation using simpler searches
rg -n "resolveConstraintBodies|ReleaseLock|release: \(\) =>|release:.*=>" native/web/jolt_bridge.js
printf '\n---- surrounding resolver code ----\n'
sed -n '760,835p' native/web/jolt_bridge.jsRepository: Bloom-Engine/engine
Length of output: 5027
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# List one-off wrapper allocations that are explicitly destroyed vs left for GC
rg -n "new J\.[A-Za-z0-9_]+Settings\(|J\.destroy\(.*Settings\)|J\.destroy\(" native/web/jolt_bridge.jsRepository: Bloom-Engine/engine
Length of output: 1635
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find the resolver and the exact release semantics
rg -n "resolveConstraintBodies|ReleaseLock|release: \(\) =>|release:\s*\(\)\s*=>" native/web/jolt_bridge.js
printf '\n---- surrounding resolver code ----\n'
sed -n '760,835p' native/web/jolt_bridge.jsRepository: Bloom-Engine/engine
Length of output: 5027
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Compare explicit destruction patterns for settings wrappers in this file
rg -n "new J\.[A-Za-z0-9_]+Settings\(|J\.destroy\(.*Settings\)|J\.destroy\(" native/web/jolt_bridge.jsRepository: Bloom-Engine/engine
Length of output: 1635
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '420,455p' native/web/jolt_bridge.js
printf '\n----\n'
sed -n '652,684p' native/web/jolt_bridge.js
printf '\n----\n'
sed -n '1160,1182p' native/web/jolt_bridge.jsRepository: Bloom-Engine/engine
Length of output: 3762
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1036,1078p' native/web/jolt_bridge.jsRepository: Bloom-Engine/engine
Length of output: 1951
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Look for manual destruction of settings-like wrappers across the repo
rg -n "J\.destroy\([^)]*settings[^)]*\)|J\.destroy\([^)]*Settings[^)]*\)" .
printf '\n---- all constraint helpers with cleanup ----\n'
rg -n "Create\(ctx\.body1, ctx\.body2\)|ctx\.release\(\)" native/web/jolt_bridge.jsRepository: Bloom-Engine/engine
Length of output: 990
🌐 Web query:
JoltPhysics.js J.destroy settings wrapper create destroy manual ownership docs
💡 Result:
In JoltPhysics.js, because WebAssembly and Emscripten do not provide automatic garbage collection for C++ objects, you must manually manage memory for any object created using the 'new' keyword [1][2]. The 'Jolt.destroy(object)' function is the standard mechanism to free the memory of these objects [1][2]. Key Principles for Memory Management: 1. Manual Destruction: Any object created with 'new Jolt.XXX' must eventually be manually destroyed using 'Jolt.destroy(instance)' if it is not otherwise managed [1][2]. 2. Reference Counting: Many Jolt classes (inheriting from RefTarget) use internal reference counting [2][3]. - If you create a reference-counted object and pass it to another object (e.g., passing a ShapeSettings to a CompoundShapeSettings or a Shape to a Body), the receiving object typically takes ownership by incrementing the reference count [1][2]. In such cases, you do not need to call AddRef or maintain your own reference, and you can generally destroy your initial instance after passing it to the owner [1][2][4]. - If you need to manually control the lifetime, you can use 'object.AddRef' to increment the count and 'object.Release' to decrement it [1][2]. When the count reaches zero, the object is destroyed [1][2]. 3. Special Case: The 'Body' Class: You should not use 'Jolt.destroy' directly on Body objects. Instead, they must be destroyed through the BodyInterface using 'bodyInterface.DestroyBody(body.GetID)', which internally handles the necessary cleanup [5][1][6]. 4. Practical Workflow: - For simple data objects (like Vec3, Quat, or settings objects), call 'Jolt.destroy(object)' immediately after passing them to the required API or finishing their use [5][2][4]. - Follow the patterns in the official repository's 'proper_cleanup.html' example to ensure all interfaces, allocators, and objects are cleaned up in the correct order at the end of the application lifecycle [5][2]. Top Results: [5][1][2]
Citations:
- 1: https://www.npmjs.com/package/jolt-physics
- 2: https://registry.npmjs.org/jolt-physics
- 3: https://jrouwe.github.io/JoltPhysicsDocs/5.2.0/index.html
- 4: Cleaning up plane shape jrouwe/JoltPhysics.js#202
- 5: https://github.com/jrouwe/JoltPhysics.js/blob/main/Examples/proper_cleanup.html
- 6: https://jrouwe.github.io/JoltPhysics/
Destroy the temporary settings objects and move lock release into finally J.destroy(settings) is missing here and in the matching heightfield helper, and ctx.release() should run once from finally so the body locks are always released exactly once if Create() or registerConstraint() throws.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@native/web/jolt_bridge.js` around lines 927 - 950, Update the SixDOF
constraint creation flow around the local settings object and ctx.release():
destroy the temporary J.SixDOFConstraintSettings instance with
J.destroy(settings), and move ctx.release() into a finally block so it executes
exactly once on success or when Create() or registerConstraint() throws. Apply
the same cleanup and finally-based release pattern to the matching heightfield
helper.
| const queryFilterCache = new Map(); // "worldH:layer" → { bp, obj, body, shape } | ||
| function queryFilters(w, worldH, layer) { | ||
| const key = worldH + ':' + (layer | 0); | ||
| let f = queryFilterCache.get(key); | ||
| if (f) return f; | ||
| const J = JoltModule; | ||
| let bp, obj; | ||
| if (typeof w.system.GetDefaultBroadPhaseLayerFilter === 'function') { | ||
| bp = w.system.GetDefaultBroadPhaseLayerFilter(layer); | ||
| obj = w.system.GetDefaultLayerFilter(layer); | ||
| } else { | ||
| // npm/CDN builds: the canonical form from the JoltPhysics.js examples — | ||
| // the filter getters live on the JoltInterface, and constructing the | ||
| // Default* filters from the raw tables instead trips a null virtual | ||
| // inside ExtendedUpdate. | ||
| bp = new J.DefaultBroadPhaseLayerFilter(w.jolt.GetObjectVsBroadPhaseLayerFilter(), layer); | ||
| obj = new J.DefaultObjectLayerFilter(w.jolt.GetObjectLayerPairFilter(), layer); | ||
| } | ||
| f = { bp, obj, body: new J.BodyFilter(), shape: new J.ShapeFilter() }; | ||
| queryFilterCache.set(key, f); | ||
| return f; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
ast-grep outline native/web/jolt_bridge.js --items all --match 'destroyWorld|queryFilters'
rg -n -C5 '\b(queryFilterCache\.(delete|clear)|destroyWorld\s*\()' native/web/jolt_bridge.jsRepository: Bloom-Engine/engine
Length of output: 605
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- destroyWorld region ---'
sed -n '218,320p' native/web/jolt_bridge.js
echo
echo '--- queryFilters region ---'
sed -n '1080,1135p' native/web/jolt_bridge.jsRepository: Bloom-Engine/engine
Length of output: 6179
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n -C3 'queryFilterCache|J\.destroy\(|destroyWorld\s*\(' native/web/jolt_bridge.jsRepository: Bloom-Engine/engine
Length of output: 1905
Clear queryFilterCache in destroyWorld destroyWorld tears down bodies, constraints, and w.jolt, but leaves the per-world queryFilterCache entries behind. Evict matching keys and J.destroy the cached bp, obj, body, and shape objects to avoid leaking native memory on repeated world creation/destruction.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@native/web/jolt_bridge.js` around lines 1088 - 1108, Update destroyWorld to
clear all queryFilterCache entries matching the destroyed world’s worldH,
calling J.destroy on each cached bp, obj, body, and shape before removing the
entry. Keep unrelated worlds’ cached filters intact and perform cleanup before
releasing the world’s Jolt resources.
| #[wasm_bindgen] | ||
| pub fn bloom_mesh_scratch_reset() { | ||
| engine().models.mesh_scratch_reset(); | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_mesh_scratch_push_f32(v: f64) { | ||
| engine().models.mesh_scratch_push_f32(v as f32); | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_mesh_scratch_push_u32(v: f64) { | ||
| engine().models.mesh_scratch_push_u32(v as u32); | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_create_mesh_scratch(vertex_count: f64, index_count: f64) -> f64 { | ||
| engine() | ||
| .models | ||
| .create_mesh_from_scratch(vertex_count as u32, index_count as u32) | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_gen_mesh_spline_ribbon_scratch(point_count: f64, width_count: f64) -> f64 { | ||
| let n = point_count as usize; | ||
| let wn = width_count as usize; | ||
| let (points, widths) = { | ||
| let scratch = engine().models.scratch_floats(); | ||
| if n < 2 || wn == 0 || scratch.len() < n * 3 + wn { | ||
| return 0.0; | ||
| } | ||
| ( | ||
| scratch[..n * 3].to_vec(), | ||
| scratch[n * 3..n * 3 + wn].to_vec(), | ||
| ) | ||
| }; | ||
| engine().models.gen_mesh_spline_ribbon(&points, &widths) | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_create_instance_buffer_scratch(instance_count: f64) -> f64 { | ||
| let eng = engine(); | ||
| let count = instance_count as u32; | ||
| let need = (count as usize) * 9; | ||
| if count == 0 || eng.models.scratch_f32.len() < need { | ||
| return 0.0; | ||
| } | ||
| let data: Vec<f32> = eng.models.scratch_f32[..need].to_vec(); | ||
| eng.models.mesh_scratch_reset(); | ||
| eng.renderer.create_instance_buffer(&data, count) as f64 | ||
| } | ||
|
|
||
| /// Params arrive via the mesh scratch (reset + push_f32 × N) — the idiom the | ||
| /// TS `setMaterialParams` wrapper uses everywhere (Perry 0.5.x rejects JS | ||
| /// arrays in pointer params). Forwards to the existing floats path. | ||
| #[wasm_bindgen] | ||
| pub fn bloom_set_material_params_scratch(handle: f64, param_count: f64) { | ||
| let count = param_count as usize; | ||
| let params: Vec<f32> = { | ||
| let eng = engine(); | ||
| if eng.models.scratch_f32.len() < count { | ||
| return; | ||
| } | ||
| let p = eng.models.scratch_f32[..count].to_vec(); | ||
| eng.models.mesh_scratch_reset(); | ||
| p | ||
| }; | ||
| crate::material_ffi::bloom_set_material_params_floats(handle, ¶ms); | ||
| } | ||
|
|
||
| // ============================================================ | ||
| // Texture arrays (mirrors ffi_core/game_loop.rs) | ||
| // ============================================================ | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_create_texture_array_scratch( | ||
| width: f64, | ||
| height: f64, | ||
| layer_count: f64, | ||
| format: f64, | ||
| mip_levels: f64, | ||
| ) -> f64 { | ||
| 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(bloom_shared::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<u8> = { | ||
| let eng = engine(); | ||
| if eng.models.scratch_u32.len() < texels { | ||
| // Short buffer: refuse rather than upload uninitialised memory. | ||
| 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 | ||
| } | ||
|
|
||
| // The from-files path can't read files on wasm, so it is split: JS glue | ||
| // fetches each path in the comma-separated list and pushes the raw file | ||
| // bytes here (decoded engine-side, same as native's `image::open`), then | ||
| // commits. Layers must share dimensions; first file's size wins. | ||
| thread_local! { | ||
| static TEXARRAY_FILES: RefCell<Vec<(Vec<u8>, u32, u32)>> = const { RefCell::new(Vec::new()) }; | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_texture_array_files_reset() { | ||
| TEXARRAY_FILES.with(|f| f.borrow_mut().clear()); | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_texture_array_files_push(data: &[u8]) -> f64 { | ||
| match bloom_shared::textures::TextureManager::decode_rgba8(data) { | ||
| Some((bytes, w, h)) => { | ||
| TEXARRAY_FILES.with(|f| f.borrow_mut().push((bytes, w, h))); | ||
| 1.0 | ||
| } | ||
| None => 0.0, | ||
| } | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_texture_array_files_commit(format: f64, mip_levels: f64) -> f64 { | ||
| let decoded = TEXARRAY_FILES.with(|f| f.borrow_mut().split_off(0)); | ||
| 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 { | ||
| crate::console_warn(&format!( | ||
| "[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 | ||
| } | ||
|
|
||
| // ============================================================ | ||
| // Staged model loading (bytes half; JS glue fetches the file) | ||
| // ============================================================ | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_stage_model_bytes(data: &[u8]) -> f64 { | ||
| match bloom_shared::models::load_gltf_staged(data) { | ||
| Some(staged) => bloom_shared::staging::stage_model(staged), | ||
| None => 0.0, | ||
| } | ||
| } | ||
|
|
||
| // ============================================================ | ||
| // Environment / water / scene odds and ends | ||
| // ============================================================ | ||
|
|
||
| #[cfg(feature = "image-extras")] | ||
| #[wasm_bindgen] | ||
| pub fn bloom_set_env_clear_from_hdr_bytes(data: &[u8]) { | ||
| engine().renderer.set_env_clear_from_hdr_bytes(data); | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_splat_impulse(x: f64, z: f64, radius: f64, strength: f64) { | ||
| engine().renderer.impulse_field.submit_splat( | ||
| x as f32, | ||
| z as f32, | ||
| radius as f32, | ||
| strength as f32, | ||
| ); | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_scene_set_gi_only(handle: f64, gi_only: f64) { | ||
| engine().scene.set_gi_only(handle, gi_only != 0.0); | ||
| } | ||
|
|
||
| // ============================================================ | ||
| // Profiler text (mirrors ffi_core/game_loop.rs; GPU column is 0 on | ||
| // web — no TIMESTAMP_QUERY in the WebGPU spec at wgpu 29) | ||
| // ============================================================ | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_profiler_overlay_text() -> String { | ||
| let snap = engine().profiler.snapshot(); | ||
| let mut s = String::with_capacity(snap.len() * 48); | ||
| for (label, cpu, gpu) in &snap { | ||
| s.push_str(label); | ||
| s.push('|'); | ||
| s.push_str(&format!("{:.2}", cpu)); | ||
| s.push('|'); | ||
| match gpu { | ||
| Some(g) => s.push_str(&format!("{:.2}", g)), | ||
| None => s.push_str("-1"), | ||
| } | ||
| s.push('\n'); | ||
| } | ||
| s | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_profiler_frame_history() -> String { | ||
| let hist = engine().profiler.frame_history(); | ||
| let mut s = String::with_capacity(hist.len() * 24); | ||
| for (cpu, gpu) in &hist { | ||
| s.push_str(&format!("{:.2}|{:.2}\n", cpu, gpu)); | ||
| } | ||
| s | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_profiler_row_count() -> f64 { | ||
| engine().profiler.snapshot().len() as f64 | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_profiler_row_label(i: f64) -> String { | ||
| engine() | ||
| .profiler | ||
| .snapshot() | ||
| .get(i as usize) | ||
| .map(|(label, _, _)| label.to_string()) | ||
| .unwrap_or_default() | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_profiler_row_cpu_us(i: f64) -> f64 { | ||
| engine() | ||
| .profiler | ||
| .snapshot() | ||
| .get(i as usize) | ||
| .map(|r| r.1) | ||
| .unwrap_or(0.0) | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_profiler_row_gpu_us(i: f64) -> f64 { | ||
| match engine().profiler.snapshot().get(i as usize) { | ||
| Some((_, _, Some(g))) => *g, | ||
| _ => -1.0, | ||
| } | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_profiler_hist_count() -> f64 { | ||
| engine().profiler.frame_history().len() as f64 | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_profiler_hist_cpu_us(i: f64) -> f64 { | ||
| engine() | ||
| .profiler | ||
| .frame_history() | ||
| .get(i as usize) | ||
| .map(|(cpu, _)| *cpu) | ||
| .unwrap_or(0.0) | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| pub fn bloom_profiler_hist_gpu_us(i: f64) -> f64 { | ||
| engine() | ||
| .profiler | ||
| .frame_history() | ||
| .get(i as usize) | ||
| .map(|(_, gpu)| *gpu) | ||
| .unwrap_or(0.0) | ||
| } | ||
|
|
||
| // ============================================================ | ||
| // Host stubs — features a browser page cannot provide, exported so | ||
| // the calls stay cheap typed no-ops | ||
| // ============================================================ | ||
|
|
||
| /// The browser owns presentation (rAF + compositor); Fifo-equivalent | ||
| /// behaviour is all a canvas surface can do, so the mode is fixed. | ||
| #[wasm_bindgen] | ||
| pub fn bloom_set_present_mode(_mode: f64) {} | ||
|
|
||
| /// Screenshot readback needs a blocking `device.poll(Wait)`, which a | ||
| /// single-threaded wasm host cannot do. Right-click-save or the DOM | ||
| /// `canvas.toBlob` path (from JS) are the web equivalents. | ||
| #[wasm_bindgen] | ||
| pub fn bloom_take_screenshot(_path: f64) {} |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd package.json | while read -r f; do echo "=== $f ==="; jq -r '.perry.nativeLibrary.functions[]?' "$f" 2>/dev/null; done > /tmp/declared_fns.txt
cat /tmp/declared_fns.txt | sort -u > /tmp/declared_sorted.txt
for fn in bloom_mesh_scratch_reset bloom_mesh_scratch_push_f32 bloom_mesh_scratch_push_u32 \
bloom_create_mesh_scratch bloom_gen_mesh_spline_ribbon_scratch bloom_create_instance_buffer_scratch \
bloom_set_material_params_scratch bloom_create_texture_array_scratch bloom_texture_array_files_reset \
bloom_texture_array_files_push bloom_texture_array_files_commit bloom_stage_model_bytes \
bloom_set_env_clear_from_hdr_bytes bloom_splat_impulse bloom_scene_set_gi_only \
bloom_profiler_overlay_text bloom_profiler_frame_history bloom_profiler_row_count \
bloom_profiler_row_label bloom_profiler_row_cpu_us bloom_profiler_row_gpu_us \
bloom_profiler_hist_count bloom_profiler_hist_cpu_us bloom_profiler_hist_gpu_us \
bloom_set_present_mode bloom_take_screenshot bloom_inject_mouse_move bloom_inject_mouse_delta \
bloom_inject_mouse_button_down bloom_inject_mouse_button_up bloom_inject_mouse_wheel \
bloom_inject_touch bloom_inject_touch_release bloom_inject_char; do
grep -qx "$fn" /tmp/declared_sorted.txt && echo "OK: $fn" || echo "MISSING: $fn"
doneRepository: Bloom-Engine/engine
Length of output: 1394
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== package.json files =="
fd -a '^package\.json$' .
echo
echo "== perry.nativeLibrary.functions declarations =="
rg -n --hidden --glob 'package.json' '"nativeLibrary"|perry\.nativeLibrary\.functions|bloom_(mesh|create|gen|texture_array|stage_model|set_env|splat|scene|profiler|set_present_mode|take_screenshot|inject_)' .
echo
echo "== native/web/src/parity_ffi.rs exports =="
rg -n '^\s*#\[wasm_bindgen\]\s*$|^\s*pub fn bloom_' native/web/src/parity_ffi.rs
echo
echo "== native/web/src/input_ffi.rs exports =="
rg -n '^\s*#\[wasm_bindgen\]\s*$|^\s*pub fn bloom_' native/web/src/input_ffi.rsRepository: Bloom-Engine/engine
Length of output: 11796
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "package.json files:"
fd -a '^package\.json$' .
echo
echo "manifest key locations:"
rg -n --hidden --glob 'package.json' 'perry\.nativeLibrary\.functions|nativeLibrary|bloom_mesh_scratch_reset|bloom_inject_mouse_move' .Repository: Bloom-Engine/engine
Length of output: 1545
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== root package.json: nativeLibrary section =="
sed -n '98,220p' package.json
echo
echo "== root package.json: relevant export entries around parity/input =="
sed -n '980,1160p' package.json
echo
sed -n '1518,1655p' package.json
echo
sed -n '1968,2020p' package.json
echo
sed -n '2368,2415p' package.jsonRepository: Bloom-Engine/engine
Length of output: 11867
🏁 Script executed:
#!/bin/bash
set -euo pipefail
names=(
bloom_mesh_scratch_reset
bloom_mesh_scratch_push_f32
bloom_mesh_scratch_push_u32
bloom_create_mesh_scratch
bloom_gen_mesh_spline_ribbon_scratch
bloom_create_instance_buffer_scratch
bloom_set_material_params_scratch
bloom_create_texture_array_scratch
bloom_texture_array_files_reset
bloom_texture_array_files_push
bloom_texture_array_files_commit
bloom_stage_model_bytes
bloom_set_env_clear_from_hdr_bytes
bloom_splat_impulse
bloom_scene_set_gi_only
bloom_profiler_overlay_text
bloom_profiler_frame_history
bloom_profiler_row_count
bloom_profiler_row_label
bloom_profiler_row_cpu_us
bloom_profiler_row_gpu_us
bloom_profiler_hist_count
bloom_profiler_hist_cpu_us
bloom_profiler_hist_gpu_us
bloom_set_present_mode
bloom_take_screenshot
bloom_inject_mouse_move
bloom_inject_mouse_delta
bloom_inject_mouse_button_down
bloom_inject_mouse_button_up
bloom_inject_mouse_wheel
bloom_inject_touch
bloom_inject_touch_release
bloom_inject_char
)
for n in "${names[@]}"; do
if rg -n --hidden --glob 'package.json' "\"name\": \"$n\"" . >/dev/null; then
echo "PRESENT $n"
else
echo "MISSING $n"
fi
doneRepository: Bloom-Engine/engine
Length of output: 1360
Add the remaining web FFI manifest entries. native/web/src/parity_ffi.rs#L159-L236 still adds bloom_texture_array_files_reset/push/commit, bloom_stage_model_bytes, and bloom_set_env_clear_from_hdr_bytes without package.json entries; native/web/src/input_ffi.rs#L179-L237 adds the new bloom_inject_* wrappers as well. The other new exports in this patch are already declared.
📍 Affects 2 files
native/web/src/parity_ffi.rs#L30-L343(this comment)native/web/src/input_ffi.rs#L188-L240
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@native/web/src/parity_ffi.rs` around lines 30 - 343, The web FFI manifest is
missing declarations for newly exported texture-array, staged-model, HDR
environment, and input-injection functions. Update package.json to add entries
for bloom_texture_array_files_reset, bloom_texture_array_files_push,
bloom_texture_array_files_commit, bloom_stage_model_bytes,
bloom_set_env_clear_from_hdr_bytes, and every bloom_inject_* wrapper in
native/web/src/input_ffi.rs:188-240; no direct source changes are needed in
either Rust file.
Source: Coding guidelines
| // --- bodies (layer 1 = MOVING, motion type 2 = DYNAMIC) | ||
| let mut bodies: Vec<f64> = Vec::with_capacity(builds.len()); | ||
| for b in builds.iter() { | ||
| let shape = jb_shape_capsule(b.half_height as f64, b.radius as f64); | ||
| if shape == 0.0 { | ||
| bodies.push(0.0); | ||
| continue; | ||
| } | ||
| let q = bloom_shared::ragdoll::quat_from_mat(&b.world); | ||
| let body = jb_body_create( | ||
| world, | ||
| shape, | ||
| 2.0, | ||
| b.world[3][0] as f64, | ||
| b.world[3][1] as f64, | ||
| b.world[3][2] as f64, | ||
| q[0] as f64, | ||
| q[1] as f64, | ||
| q[2] as f64, | ||
| q[3] as f64, | ||
| 1.0, | ||
| ); | ||
| if body != 0.0 { | ||
| jb_body_set_friction(body, 0.8); // corpses do not skate | ||
| jb_body_set_restitution(body, 0.02); // they do not bounce | ||
| jb_body_set_linear_damping(body, 0.20); | ||
| // Angular damping does most of the work of making this read as a | ||
| // body rather than a rag. | ||
| jb_body_set_angular_damping(body, 0.75); | ||
| } | ||
| bodies.push(body); | ||
| } | ||
|
|
||
| // --- joints. Tight-ish; twist (y) held hardest — an over-twisted limb is | ||
| // the single most obviously WRONG thing a ragdoll can do. | ||
| let rot_limits: [f64; 6] = [-0.8, 0.8, -0.25, 0.25, -0.8, 0.8]; | ||
| let mut constraints: Vec<f64> = 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 = jb_constraint_six_dof( | ||
| pa, | ||
| pb, | ||
| b.anchor[0] as f64, | ||
| b.anchor[1] as f64, | ||
| b.anchor[2] as f64, | ||
| b.anchor[0] as f64, | ||
| b.anchor[1] as f64, | ||
| b.anchor[2] as f64, | ||
| rot_limits[0], | ||
| rot_limits[1], | ||
| rot_limits[2], | ||
| rot_limits[3], | ||
| rot_limits[4], | ||
| rot_limits[5], | ||
| 1.0, // 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 |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== File outline ==\n'
ast-grep outline native/web/src/ragdoll_ffi.rs --view expanded || true
printf '\n== Relevant lines in native/web/src/ragdoll_ffi.rs ==\n'
nl -ba native/web/src/ragdoll_ffi.rs | sed -n '1,260p'
printf '\n== Search for ragdoll attach / cleanup ==\n'
rg -n "fn attach|struct Ragdoll|destroy|jb_body_destroy|jb_constraint_destroy|ragdoll" native/web/src native -g '!**/target/**' || trueRepository: Bloom-Engine/engine
Length of output: 576
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== ragdoll attach implementation ==\n'
rg -n "fn attach\\(|impl .*attach|struct Ragdoll|destroy.*body|destroy.*constraint|jb_body_destroy|jb_constraint_destroy" native/web/src native -g '!**/target/**' || true
printf '\n== file list around ragdoll code ==\n'
fd -a "ragdoll" native/web/src native -t f || trueRepository: Bloom-Engine/engine
Length of output: 3997
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== native/web/src/ragdoll_ffi.rs (lines 1-280) ==\n'
sed -n '1,280p' native/web/src/ragdoll_ffi.rs | cat -n
printf '\n== native/shared/src/ragdoll.rs (attach/release-related sections) ==\n'
sed -n '1,360p' native/shared/src/ragdoll.rs | cat -n
printf '\n== native/shared/src/ffi_core/ragdoll_ffi.rs (activation/release sections) ==\n'
sed -n '1,320p' native/shared/src/ffi_core/ragdoll_ffi.rs | cat -nRepository: Bloom-Engine/engine
Length of output: 40111
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== native/shared/src/ragdoll.rs (190-260) ==\n'
sed -n '190,260p' native/shared/src/ragdoll.rs | cat -n
printf '\n== native/web/src/ragdoll_ffi.rs (1-280) ==\n'
sed -n '1,280p' native/web/src/ragdoll_ffi.rs | cat -n
printf '\n== native/shared/src/ffi_core/ragdoll_ffi.rs (200-260) ==\n'
sed -n '200,260p' native/shared/src/ffi_core/ragdoll_ffi.rs | cat -nRepository: Bloom-Engine/engine
Length of output: 15826
Make ragdoll activation transactional. Validate rag before creating bodies/constraints, and destroy any handles created in this call if a body or joint creation fails. As written, a bad handle leaks physics objects, and zero-valued bodies/constraints still produce an active partial ragdoll.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@native/web/src/ragdoll_ffi.rs` around lines 63 - 146, Make the ragdoll setup
transactional before the body-creation loop: validate the `rag` handle and
required animation before creating physics objects. Treat any failed
`jb_body_create` or `jb_constraint_six_dof` result as a setup failure, destroy
every body and constraint created during this call, and return `0.0`; only call
`r.attach` after all handles are valid so no partial ragdoll becomes active.
The web crate hand-mirrors the shared define_core_ffi! macro, and
"name coverage only" in the validator let real drift accumulate. Found
by porting a 3D game: trees rendered as torn opaque sheets and every
character animated wrongly, while native was correct.
Renders (the user-visible three):
- bloom_draw_model_rotated still used the OLD immediate path
(draw_model_mesh_tinted_rotated): no alpha cutout, no materials, no
wind, CPU-transformed verts. Round 5 moved native to the cached scene
pipeline precisely because that path renders cutout foliage as opaque
cards; the web copy never got it. Now mirrors native, including the
skinned branch.
- bloom_draw_model never checked is_model_skinned, so skinned models took
the UNSKINNED cached draw: each primitive read joint offset 0 — another
model's matrices — instead of the model's own staged pose. That is why
characters didn't animate correctly on web.
- Non-sRGB surfaces (every WebGPU canvas offers only bgra8/rgba8unorm)
displayed the whole game about a stop dark: the tonemapper writes LINEAR
and relies on hardware encode-on-store into an sRGB target, which native
surfaces provide. The renderer now separates `output_format` (what frames
are RENDERED in — an sRGB VIEW of the swapchain, registered via
view_formats) from surface_config.format (what the surface is CONFIGURED
with). Native is unchanged: there output_format == surface_config.format.
Also drifted (found by an audit of all 330 native vs 376 web exports):
- bloom_get_gamepad_axis / is_gamepad_button_{pressed,down,released} /
is_gamepad_available / get_gamepad_axis_count carried a leading `gamepad`
param the manifest never declared: the game's single argument landed in
it, the real one arrived undefined -> NaN -> `as usize` = 0. Every axis
read axis 0, every button read button 0.
- bloom_get_touch_x/_y dropped their index and hardcoded slot 0, so every
finger reported the first finger's coordinates while is_touch_active(i)
honoured the index — both thumbsticks tracked one thumb.
- bloom_inject_key_down/_up wrote keys_down directly instead of staging via
inject_key_* , so the edge was folded into `prev` before begin_frame
computed it and isKeyPressed never fired.
- bloom_scene_attach_model dropped set_material_pbr + alpha_cutoff:
attached foliage opaque, everything pinned at roughness 0.8.
- bloom_scene_set_transform16's 17-arg body was wired to the 2-arg name
bloom_scene_set_transform, so setSceneNodeTransform resolved to nothing
and Perry's FFI proxy auto-stubbed it — a silent no-op with the working
implementation one rename away.
- bloom_update_model_animation was an empty stub carrying the pre-fix 9-arg
(rot_sin, rot_cos) signature native abandoned.
- bloom_unload_model skipped evict_model_cache: leaked GPU buffers, and a
slot-reusing model rendered stale geometry.
- bloom_physics_vehicle_create declared a 38th param (trailing, so nothing
shifted; it arrived NaN).
Root cause, fixed: tools/validate-ffi.js checked arity on all 7 native
platforms but web was "name coverage only". It now checks arity for the 452
all-f64 mirror functions — signatures whose web ABI is deliberately
different (_str/_bytes/_floats slices) are skipped, which is exactly where
drift is a bug rather than a design. The gap allowlist was stale too (it
listed 29 functions this branch implements); pruned 41 -> 13 honest gaps.
Engine diagnostics now reach the browser: log_error's eprintln! is a black
hole on wasm, so every engine warning — missing texture-array layers,
surface-acquire failures, feature-off warnings — was silently dropped on
the one platform whose users cannot attach a debugger. Routed to
console.error.
WGSL: the depth prepass is skipped on wasm (its Equal-test pairing needs
bit-identical depths from two pipelines, which Tint does not guarantee
through the foliage-wind chain); game materials get @invariant on their
clip position.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
native/shared/src/renderer/scene_pass.rs (1)
78-88: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAvoid creating unused prepass pipelines on Web.
Since
scene_depth_pipelineandscene_pipeline_prepassedare intentionally skipped during the render loop onwasm32, their initialization inmod.rs(which appears to be unconditional based on the context snippets) is wasted effort. Compiling heavy unused WGSL shaders into pipelines on the web adds significant startup latency and wastes memory.Consider applying
#[cfg(not(target_arch = "wasm32"))]to the corresponding pipeline fields inRendererand skipping their creation entirely on the web target.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@native/shared/src/renderer/scene_pass.rs` around lines 78 - 88, Conditionally compile the prepass-only Renderer fields scene_depth_pipeline and scene_pipeline_prepassed, along with their initialization in Renderer construction, using #[cfg(not(target_arch = "wasm32"))]. Keep all non-WASM render-loop usage gated consistently so Web builds neither create nor reference these unused pipelines.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tools/validate-ffi.js`:
- Around line 169-184: Strip line and block comments from the Rust source and
Jolt bridge contents before the regex scans in the validation flow. Use the
cleaned Rust text for both bloom function matches and signature parsing, and the
cleaned bridge text for physics export detection, preserving string literals as
needed so commented-out functions and exports are ignored while parameter
comments do not affect allF64 or arity.
---
Nitpick comments:
In `@native/shared/src/renderer/scene_pass.rs`:
- Around line 78-88: Conditionally compile the prepass-only Renderer fields
scene_depth_pipeline and scene_pipeline_prepassed, along with their
initialization in Renderer construction, using #[cfg(not(target_arch =
"wasm32"))]. Keep all non-WASM render-loop usage gated consistently so Web
builds neither create nor reference these unused pipelines.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: e2414a57-7674-44c4-93d6-2d56e07f33f3
⛔ Files ignored due to path filters (2)
native/web/Cargo.lockis excluded by!**/*.locknative/windows/Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (9)
native/shared/Cargo.tomlnative/shared/src/ffi.rsnative/shared/src/renderer/mod.rsnative/shared/src/renderer/scene_pass.rsnative/web/src/input_ffi.rsnative/web/src/lib.rsnative/web/src/material_ffi.rsnative/web/src/physics_ffi.rstools/validate-ffi.js
| const webSrc = readDirRust(path.join(ROOT, 'native/web/src')); | ||
| const names = new Set(); | ||
| // name -> {arity, allF64} for the Rust exports (the jolt bridge's JS | ||
| // functions are name-only; physics arity is covered by its own manifest | ||
| // generation). | ||
| const webSigs = new Map(); | ||
| for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)\s*\(([^)]*)\)/g)) { | ||
| const [, fname, params] = m; | ||
| names.add(fname); | ||
| const parts = params.split(',').map((s) => s.trim()).filter(Boolean); | ||
| const allF64 = parts.every((p) => /:\s*f64$/.test(p)); | ||
| webSigs.set(fname, { arity: parts.length, allF64 }); | ||
| } | ||
| for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)/g)) names.add(m[1]); | ||
| const bridge = fs.readFileSync(path.join(ROOT, 'native/web/jolt_bridge.js'), 'utf8'); | ||
| for (const m of bridge.matchAll(/(bloom_physics_[a-z0-9_]+)\s*[:(=]/g)) names.add(m[1]); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Strip comments before parsing to prevent regex misfires.
The current raw string parsing has three failure modes related to source code comments:
- False CI failures: A commented-out function (e.g.,
// pub fn bloom_old(...)) will be matched and can overwrite the correct signature in thewebSigsmap if it appears later in the file. - Silent validation bypasses: Trailing comments within the parameter list (e.g.,
x: f64 // parameter desc) will cause theallF64check to fail because the captured string ends with the comment text, notf64. - Masked missing exports: Commented-out lines in
jolt_bridge.jswill incorrectly add names to thenamesset, making the validator believe a missing export is implemented.
I recommend stripping line and block comments from the source strings before running the regex matches.
💻 Proposed fix
- const webSrc = readDirRust(path.join(ROOT, 'native/web/src'));
+ const webSrc = readDirRust(path.join(ROOT, 'native/web/src'))
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/\/\/.*/g, '');
const names = new Set();
// name -> {arity, allF64} for the Rust exports (the jolt bridge's JS
// functions are name-only; physics arity is covered by its own manifest
// generation).
const webSigs = new Map();
for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)\s*\(([^)]*)\)/g)) {
const [, fname, params] = m;
names.add(fname);
const parts = params.split(',').map((s) => s.trim()).filter(Boolean);
const allF64 = parts.every((p) => /:\s*f64$/.test(p));
webSigs.set(fname, { arity: parts.length, allF64 });
}
for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)/g)) names.add(m[1]);
- const bridge = fs.readFileSync(path.join(ROOT, 'native/web/jolt_bridge.js'), 'utf8');
+ const bridge = fs.readFileSync(path.join(ROOT, 'native/web/jolt_bridge.js'), 'utf8')
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/\/\/.*/g, '');
for (const m of bridge.matchAll(/(bloom_physics_[a-z0-9_]+)\s*[:(=]/g)) names.add(m[1]);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const webSrc = readDirRust(path.join(ROOT, 'native/web/src')); | |
| const names = new Set(); | |
| // name -> {arity, allF64} for the Rust exports (the jolt bridge's JS | |
| // functions are name-only; physics arity is covered by its own manifest | |
| // generation). | |
| const webSigs = new Map(); | |
| for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)\s*\(([^)]*)\)/g)) { | |
| const [, fname, params] = m; | |
| names.add(fname); | |
| const parts = params.split(',').map((s) => s.trim()).filter(Boolean); | |
| const allF64 = parts.every((p) => /:\s*f64$/.test(p)); | |
| webSigs.set(fname, { arity: parts.length, allF64 }); | |
| } | |
| for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)/g)) names.add(m[1]); | |
| const bridge = fs.readFileSync(path.join(ROOT, 'native/web/jolt_bridge.js'), 'utf8'); | |
| for (const m of bridge.matchAll(/(bloom_physics_[a-z0-9_]+)\s*[:(=]/g)) names.add(m[1]); | |
| const webSrc = readDirRust(path.join(ROOT, 'native/web/src')) | |
| .replace(/\/\*[\s\S]*?\*\//g, '') | |
| .replace(/\/\/.*/g, ''); | |
| const names = new Set(); | |
| // name -> {arity, allF64} for the Rust exports (the jolt bridge's JS | |
| // functions are name-only; physics arity is covered by its own manifest | |
| // generation). | |
| const webSigs = new Map(); | |
| for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)\s*\(([^)]*)\)/g)) { | |
| const [, fname, params] = m; | |
| names.add(fname); | |
| const parts = params.split(',').map((s) => s.trim()).filter(Boolean); | |
| const allF64 = parts.every((p) => /:\s*f64$/.test(p)); | |
| webSigs.set(fname, { arity: parts.length, allF64 }); | |
| } | |
| for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)/g)) names.add(m[1]); | |
| const bridge = fs.readFileSync(path.join(ROOT, 'native/web/jolt_bridge.js'), 'utf8') | |
| .replace(/\/\*[\s\S]*?\*\//g, '') | |
| .replace(/\/\/.*/g, ''); | |
| for (const m of bridge.matchAll(/(bloom_physics_[a-z0-9_]+)\s*[:(=]/g)) names.add(m[1]); |
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] 182-182: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use.
Context: fs.readFileSync(path.join(ROOT, 'native/web/jolt_bridge.js'), 'utf8')
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
(detect-non-literal-fs-filename)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tools/validate-ffi.js` around lines 169 - 184, Strip line and block comments
from the Rust source and Jolt bridge contents before the regex scans in the
validation flow. Use the cleaned Rust text for both bloom function matches and
signature parsing, and the cleaned bridge text for physics export detection,
preserving string literals as needed so commented-out functions and exports are
ignored while parameter comments do not affect allF64 or arity.
|
Heads-up, two things from the EN-063 build-web fix (PR #119):
|
The web crate could already compile the whole renderer for wasm32; what it could not do was run a real 3D game. This lands everything the Bloom Shooter port needed, with native paths unchanged except two deliberate fixes (
closeWindow, andlog_errorreaching the browser console).The three the player actually sees
bgra8/rgba8unorm, but the tonemapper writes LINEAR and relies on hardware encode-on-store into an sRGB target — which native surfaces provide. The renderer now separatesoutput_format(what frames are RENDERED in — an sRGB view of the swapchain, registered viaview_formats) fromsurface_config.format(what the surface is CONFIGURED with). Native is untouched: there they're equal.bloom_draw_model_rotatedstill used the OLD immediate path (draw_model_mesh_tinted_rotated) — no alpha cutout, no materials, no wind. Round 5 moved native to the cached scene pipeline precisely because that path renders cutout foliage as opaque cards; the web copy never got the fix.bloom_draw_modelnever checkedis_model_skinned, so skinned models took the UNSKINNED cached draw and each primitive read joint offset 0 — another model's matrices — instead of its own staged pose.The same drift class, found by auditing all 330 native vs 376 web exports
bloom_get_gamepad_axis,is_gamepad_button_*,is_gamepad_available,get_gamepad_axis_countgamepadparam the manifest never declared → the game's one argument landed in it, the real one arrivedundefined→NaN→as usize= 0. Every axis read axis 0; every button read button 0.bloom_get_touch_x/_yis_touch_active(i)honoured the index. Both thumbsticks tracked one thumb.bloom_inject_key_down/_upkeys_downdirectly instead of staging viainject_key_*→ the edge was folded intoprevbeforebegin_framecomputed it, soisKeyPressednever fired.bloom_scene_attach_modelset_material_pbr+alpha_cutoff→ attached foliage opaque, everything pinned at roughness 0.8.bloom_scene_set_transform16bloom_scene_set_transform→setSceneNodeTransformresolved to nothing and Perry auto-stubbed it. A silent no-op with the working implementation one rename away.bloom_update_model_animation(rot_sin, rot_cos)signature native abandoned.bloom_unload_modelevict_model_cache→ leaked GPU buffers; a slot-reusing model rendered stale geometry.bloom_commit_modelregister_texture+ base-colour-only remap → stripped normal/MR/emissive maps (the round-5 bug, still live here).bloom_physics_vehicle_createRoot cause, fixed:
tools/validate-ffi.jschecked arity on all 7 native platforms but web was "name coverage only for now". It now checks arity for the 452 all-f64 mirror functions — signatures whose web ABI is deliberately different (_str/_bytes/_floatsslices) are skipped, which is exactly where drift is a bug rather than a design. The gap allowlist was stale too (it listed 29 functions this branch implements): 41 → 13 honest gaps.Also here
parity_ffi.rs,ragdoll_ffi.rs): scratch mesh/instance/texture-array builders, staged-model bytes, texture-array-from-files (JS fetches, engine decodes), ragdolls (shared pose math + a new six-DOF constraint on the Jolt bridge), splat impulses, GI-only flags, env-HDR bytes, profiler text, mouse/touch/wheel/char injection.per_frameon wasm32 (@group(4)rewritten at compile time;WASM_SCENE_INPUTS_BASEkeeps layout/builder/dispatch in agreement). One invalid pipeline poisons the frame's whole command buffer: black canvas, zero errors.textureSampleLevel(0)on mipless RTs,textureSampleCompareLevelfor shadow cascades, derivatives hoisted out of non-uniform flow. Pixel-identical, naga-validated. The depth prepass is skipped on wasm — its Equal-test pairing needs bit-identical depths from two pipelines, which Tint doesn't guarantee through the foliage-wind chain.jolt_bridge.jsvs npm JoltPhysics.js (written against an older API generation, each failure silent and fatal): CharacterVirtual's 4-arg ctor (the 5-arg number form binds0as the PhysicsSystem and dies frames later inExtendedUpdatewith "null function"); query filters from the JoltInterface getters, cached per (world, layer) — the character path leaked three emscripten objects per frame; collector-form closest-hitCastRay; heightfield samples written into the emscripten heap; u64 userData as Number.bloom_glue.js: engine pre-init + adapter probe before Perry boots, asset prefetch with progress (assets_manifest.jsonauthoritative forfileExists— no blocking-404 storms), keyboard map corrected to the actualKeyenum (it carried GLFW codes — menus were dead while WASD worked), queue+flush input with deferred key-ups, pointer lock with ESC-exit → injectedKey.ESCAPE, per-FFI error attribution.closeWindow()setsshould_closeon windows/linux/macos — it was an empty stub, so a game's quit path kept running headless.log_error'seprintln!is a black hole on wasm, so every engine warning was dropped on the one platform whose users can't attach a debugger. Routed toconsole.error.prebuiltschema (./native/web/pkg/— the./is load-bearing).Verification
cargo check --release(windows) +cargo check --target wasm32-unknown-unknown+ wasm-pack release: clean.bun tools/validate-ffi.js: 0 failures, 0 warnings (was: web unchecked for arity).Requires the Perry wasm-codegen fixes on
fix/wasm-new-array-sized(local branch) to compile a working game wasm.Summary by CodeRabbit
stageModelsSyncfor synchronous model staging.set_env_clear_from_hdr_bytes).