@bornengine/engine 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -0
  3. package/native/android/Cargo.lock +1848 -0
  4. package/native/android/Cargo.toml +24 -0
  5. package/native/android/src/lib.rs +702 -0
  6. package/native/ios/Cargo.lock +1690 -0
  7. package/native/ios/Cargo.toml +32 -0
  8. package/native/ios/src/lib.rs +1267 -0
  9. package/native/linux/Cargo.lock +3279 -0
  10. package/native/linux/Cargo.toml +29 -0
  11. package/native/linux/src/lib.rs +1331 -0
  12. package/native/macos/Cargo.lock +3310 -0
  13. package/native/macos/Cargo.toml +46 -0
  14. package/native/macos/src/lib.rs +1302 -0
  15. package/native/shared/Cargo.lock +1899 -0
  16. package/native/shared/Cargo.toml +62 -0
  17. package/native/shared/assets/default_font.ttf +0 -0
  18. package/native/shared/build.rs +270 -0
  19. package/native/shared/shaders/common/clouds.wgsl +122 -0
  20. package/native/shared/shaders/common/fog.wgsl +16 -0
  21. package/native/shared/shaders/common/foliage_wind.wgsl +98 -0
  22. package/native/shared/shaders/common/imposter.wgsl +112 -0
  23. package/native/shared/shaders/common/pbr.wgsl +186 -0
  24. package/native/shared/shaders/common/shadows.wgsl +186 -0
  25. package/native/shared/shaders/common/sky.wgsl +8 -0
  26. package/native/shared/shaders/common/tonemap.wgsl +25 -0
  27. package/native/shared/shaders/impulse_field.wgsl +57 -0
  28. package/native/shared/shaders/material_abi.wgsl +383 -0
  29. package/native/shared/shaders/materials/test_minimal.wgsl +42 -0
  30. package/native/shared/src/anim_mixer.rs +61 -0
  31. package/native/shared/src/attach.rs +263 -0
  32. package/native/shared/src/audio/decode.rs +123 -0
  33. package/native/shared/src/audio/mod.rs +863 -0
  34. package/native/shared/src/audio/render.rs +892 -0
  35. package/native/shared/src/audio/spsc.rs +156 -0
  36. package/native/shared/src/audio/stream.rs +226 -0
  37. package/native/shared/src/custom_shaders.rs +104 -0
  38. package/native/shared/src/decals.rs +245 -0
  39. package/native/shared/src/drs.rs +211 -0
  40. package/native/shared/src/engine.rs +261 -0
  41. package/native/shared/src/ffi.rs +116 -0
  42. package/native/shared/src/ffi_core/assets.rs +388 -0
  43. package/native/shared/src/ffi_core/audio_ffi.rs +184 -0
  44. package/native/shared/src/ffi_core/draw.rs +334 -0
  45. package/native/shared/src/ffi_core/game_loop.rs +577 -0
  46. package/native/shared/src/ffi_core/input.rs +234 -0
  47. package/native/shared/src/ffi_core/mod.rs +127 -0
  48. package/native/shared/src/ffi_core/models.rs +1154 -0
  49. package/native/shared/src/ffi_core/ragdoll_ffi.rs +261 -0
  50. package/native/shared/src/ffi_core/scene.rs +626 -0
  51. package/native/shared/src/ffi_core/vfx.rs +212 -0
  52. package/native/shared/src/ffi_core/visual.rs +691 -0
  53. package/native/shared/src/frame_callbacks.rs +122 -0
  54. package/native/shared/src/geometry.rs +236 -0
  55. package/native/shared/src/handles.rs +182 -0
  56. package/native/shared/src/input.rs +448 -0
  57. package/native/shared/src/jolt_sys.rs +822 -0
  58. package/native/shared/src/lib.rs +55 -0
  59. package/native/shared/src/models.rs +1093 -0
  60. package/native/shared/src/models_gltf.rs +1280 -0
  61. package/native/shared/src/particles.rs +391 -0
  62. package/native/shared/src/physics_jolt.rs +1908 -0
  63. package/native/shared/src/picking.rs +298 -0
  64. package/native/shared/src/postfx.rs +345 -0
  65. package/native/shared/src/profiler.rs +492 -0
  66. package/native/shared/src/ragdoll.rs +474 -0
  67. package/native/shared/src/renderer/atmosphere_lut.rs +573 -0
  68. package/native/shared/src/renderer/brdf_lut.rs +154 -0
  69. package/native/shared/src/renderer/draw2d.rs +143 -0
  70. package/native/shared/src/renderer/formats.rs +822 -0
  71. package/native/shared/src/renderer/froxel.rs +421 -0
  72. package/native/shared/src/renderer/gi_bake.rs +653 -0
  73. package/native/shared/src/renderer/graph.rs +462 -0
  74. package/native/shared/src/renderer/hiz.rs +269 -0
  75. package/native/shared/src/renderer/hot_reload.rs +390 -0
  76. package/native/shared/src/renderer/impulse_field.rs +456 -0
  77. package/native/shared/src/renderer/lighting.rs +154 -0
  78. package/native/shared/src/renderer/material_instancing.rs +171 -0
  79. package/native/shared/src/renderer/material_pipeline.rs +700 -0
  80. package/native/shared/src/renderer/material_system.rs +1996 -0
  81. package/native/shared/src/renderer/material_system_tests.rs +601 -0
  82. package/native/shared/src/renderer/material_system_wasm.rs +41 -0
  83. package/native/shared/src/renderer/mod.rs +12556 -0
  84. package/native/shared/src/renderer/model_draw.rs +641 -0
  85. package/native/shared/src/renderer/occlusion.rs +429 -0
  86. package/native/shared/src/renderer/planar_pass.rs +593 -0
  87. package/native/shared/src/renderer/planar_reflection.rs +499 -0
  88. package/native/shared/src/renderer/post_pass.rs +249 -0
  89. package/native/shared/src/renderer/postfx_chain.rs +728 -0
  90. package/native/shared/src/renderer/pt_pass.rs +577 -0
  91. package/native/shared/src/renderer/scene_pass.rs +607 -0
  92. package/native/shared/src/renderer/shader_include.rs +205 -0
  93. package/native/shared/src/renderer/shader_library.rs +135 -0
  94. package/native/shared/src/renderer/shaders/ao.rs +570 -0
  95. package/native/shared/src/renderer/shaders/core.rs +1243 -0
  96. package/native/shared/src/renderer/shaders/env.rs +907 -0
  97. package/native/shared/src/renderer/shaders/gi.rs +810 -0
  98. package/native/shared/src/renderer/shaders/mod.rs +19 -0
  99. package/native/shared/src/renderer/shaders/post.rs +1558 -0
  100. package/native/shared/src/renderer/shaders/pt.rs +1859 -0
  101. package/native/shared/src/renderer/shaders/ssgi.rs +1586 -0
  102. package/native/shared/src/renderer/shadow_pass.rs +731 -0
  103. package/native/shared/src/renderer/ssgi_pass.rs +392 -0
  104. package/native/shared/src/renderer/ssr_pass.rs +188 -0
  105. package/native/shared/src/renderer/texture_store.rs +473 -0
  106. package/native/shared/src/renderer/transient.rs +591 -0
  107. package/native/shared/src/renderer/types.rs +941 -0
  108. package/native/shared/src/renderer/util.rs +152 -0
  109. package/native/shared/src/scene.rs +1362 -0
  110. package/native/shared/src/sdf_cache.rs +274 -0
  111. package/native/shared/src/shadows.rs +1036 -0
  112. package/native/shared/src/staging.rs +102 -0
  113. package/native/shared/src/string_header.rs +266 -0
  114. package/native/shared/src/text_renderer.rs +502 -0
  115. package/native/shared/src/textures.rs +197 -0
  116. package/native/tvos/Cargo.lock +1693 -0
  117. package/native/tvos/Cargo.toml +36 -0
  118. package/native/tvos/metal-patched/Cargo.toml +178 -0
  119. package/native/tvos/metal-patched/LICENSE-APACHE +201 -0
  120. package/native/tvos/metal-patched/LICENSE-MIT +25 -0
  121. package/native/tvos/metal-patched/src/acceleration_structure.rs +667 -0
  122. package/native/tvos/metal-patched/src/acceleration_structure_pass.rs +108 -0
  123. package/native/tvos/metal-patched/src/argument.rs +366 -0
  124. package/native/tvos/metal-patched/src/blitpass.rs +102 -0
  125. package/native/tvos/metal-patched/src/buffer.rs +71 -0
  126. package/native/tvos/metal-patched/src/capturedescriptor.rs +76 -0
  127. package/native/tvos/metal-patched/src/capturemanager.rs +113 -0
  128. package/native/tvos/metal-patched/src/commandbuffer.rs +192 -0
  129. package/native/tvos/metal-patched/src/commandqueue.rs +44 -0
  130. package/native/tvos/metal-patched/src/computepass.rs +107 -0
  131. package/native/tvos/metal-patched/src/constants.rs +152 -0
  132. package/native/tvos/metal-patched/src/counters.rs +119 -0
  133. package/native/tvos/metal-patched/src/depthstencil.rs +190 -0
  134. package/native/tvos/metal-patched/src/device.rs +2134 -0
  135. package/native/tvos/metal-patched/src/drawable.rs +39 -0
  136. package/native/tvos/metal-patched/src/encoder.rs +2041 -0
  137. package/native/tvos/metal-patched/src/heap.rs +281 -0
  138. package/native/tvos/metal-patched/src/indirect_encoder.rs +344 -0
  139. package/native/tvos/metal-patched/src/lib.rs +657 -0
  140. package/native/tvos/metal-patched/src/library.rs +902 -0
  141. package/native/tvos/metal-patched/src/mps.rs +575 -0
  142. package/native/tvos/metal-patched/src/pipeline/compute.rs +475 -0
  143. package/native/tvos/metal-patched/src/pipeline/mod.rs +71 -0
  144. package/native/tvos/metal-patched/src/pipeline/render.rs +762 -0
  145. package/native/tvos/metal-patched/src/renderpass.rs +443 -0
  146. package/native/tvos/metal-patched/src/resource.rs +182 -0
  147. package/native/tvos/metal-patched/src/sampler.rs +165 -0
  148. package/native/tvos/metal-patched/src/sync.rs +178 -0
  149. package/native/tvos/metal-patched/src/texture.rs +352 -0
  150. package/native/tvos/metal-patched/src/types.rs +90 -0
  151. package/native/tvos/metal-patched/src/vertexdescriptor.rs +250 -0
  152. package/native/tvos/src/audio_backend.rs +197 -0
  153. package/native/tvos/src/lib.rs +1891 -0
  154. package/native/visionos/Cargo.lock +1693 -0
  155. package/native/visionos/Cargo.toml +40 -0
  156. package/native/visionos/src/audio_backend.rs +197 -0
  157. package/native/visionos/src/lib.rs +1887 -0
  158. package/native/watchos/Cargo.lock +16 -0
  159. package/native/watchos/Cargo.toml +19 -0
  160. package/native/watchos/shaders/bloom_postfx.metal +99 -0
  161. package/native/watchos/src/BloomWatchApp.swift +1267 -0
  162. package/native/watchos/src/BloomWatchAudio.swift +179 -0
  163. package/native/watchos/src/audio.rs +55 -0
  164. package/native/watchos/src/draw_list.rs +229 -0
  165. package/native/watchos/src/ffi_stubs.rs +915 -0
  166. package/native/watchos/src/ffi_stubs_manual.rs +35 -0
  167. package/native/watchos/src/lib.rs +1124 -0
  168. package/native/watchos/src/models.rs +746 -0
  169. package/native/watchos/src/postfx.rs +95 -0
  170. package/native/watchos/src/scene.rs +534 -0
  171. package/native/watchos/src/textures.rs +184 -0
  172. package/native/web/Cargo.lock +1657 -0
  173. package/native/web/Cargo.toml +43 -0
  174. package/native/web/bloom_glue.js +695 -0
  175. package/native/web/build.sh +131 -0
  176. package/native/web/index.html +35 -0
  177. package/native/web/jolt_bridge.js +1519 -0
  178. package/native/web/src/input_ffi.rs +286 -0
  179. package/native/web/src/lib.rs +1796 -0
  180. package/native/web/src/material_ffi.rs +710 -0
  181. package/native/web/src/parity_ffi.rs +343 -0
  182. package/native/web/src/physics_ffi.rs +643 -0
  183. package/native/web/src/ragdoll_ffi.rs +250 -0
  184. package/native/web/src/render_settings.rs +98 -0
  185. package/native/windows/Cargo.lock +1815 -0
  186. package/native/windows/Cargo.toml +68 -0
  187. package/native/windows/src/lib.rs +1486 -0
  188. package/package.json +4279 -0
  189. package/src/audio/index.ts +315 -0
  190. package/src/core/colors.ts +63 -0
  191. package/src/core/index.ts +1206 -0
  192. package/src/core/keys.ts +63 -0
  193. package/src/core/types.ts +104 -0
  194. package/src/index.ts +171 -0
  195. package/src/math/index.ts +516 -0
  196. package/src/mobile/index.ts +294 -0
  197. package/src/models/index.ts +1258 -0
  198. package/src/physics/index.ts +1134 -0
  199. package/src/scene/index.ts +698 -0
  200. package/src/shapes/index.ts +120 -0
  201. package/src/text/index.ts +48 -0
  202. package/src/textures/index.ts +187 -0
  203. package/src/vfx/index.ts +191 -0
  204. package/src/world/index.ts +24 -0
  205. package/src/world/loader.ts +423 -0
  206. package/src/world/prefab.ts +217 -0
  207. package/src/world/render.ts +172 -0
  208. package/src/world/saver.ts +108 -0
  209. package/src/world/serialize.ts +301 -0
  210. package/src/world/terrain.ts +355 -0
  211. package/src/world/types.ts +160 -0
  212. package/src/world/validate.ts +319 -0
  213. package/src/world/version.ts +114 -0
@@ -0,0 +1,1796 @@
1
+ use bloom_shared::engine::EngineState;
2
+ use bloom_shared::renderer::Renderer;
3
+
4
+ use wasm_bindgen::prelude::*;
5
+ use std::sync::OnceLock;
6
+ use std::sync::atomic::{AtomicBool, Ordering};
7
+
8
+ static mut ENGINE: OnceLock<EngineState> = OnceLock::new();
9
+ static mut LAST_PROJECT: (f64, f64) = (0.0, 0.0);
10
+ static mut LAST_PICK: Option<bloom_shared::picking::PickResult> = None;
11
+ // Guards against double wgpu init when bloom_init_window is called twice
12
+ // (once by the JS orchestrator before Perry boots, and once by Perry's main).
13
+ static INIT_STARTED: AtomicBool = AtomicBool::new(false);
14
+
15
+ fn engine() -> &'static mut EngineState {
16
+ unsafe { ENGINE.get_mut().expect("Engine not initialized") }
17
+ }
18
+
19
+ /// Log to the browser console.
20
+ #[wasm_bindgen]
21
+ extern "C" {
22
+ #[wasm_bindgen(js_namespace = console, js_name = log)]
23
+ pub(crate) fn console_log(s: &str);
24
+ #[wasm_bindgen(js_namespace = console, js_name = warn)]
25
+ pub(crate) fn console_warn(s: &str);
26
+ }
27
+
28
+ /// Display handle for the wgpu-core (WebGL2) path.
29
+ ///
30
+ /// When wgpu falls back to WebGL2 it goes through wgpu-core, whose
31
+ /// `create_surface` passes `raw_display_handle: None` for a canvas target and
32
+ /// then errors with `MissingDisplayHandle` unless the *instance* carries one
33
+ /// (wgpu-core 29 instance.rs:254). The WebGPU backend never looks at it. On the
34
+ /// web a display handle holds nothing, so this is pure ceremony — but without it
35
+ /// the WebGL2 fallback can't create a surface at all.
36
+ #[derive(Debug)]
37
+ struct WebDisplay;
38
+
39
+ impl wgpu::rwh::HasDisplayHandle for WebDisplay {
40
+ fn display_handle(&self) -> Result<wgpu::rwh::DisplayHandle<'_>, wgpu::rwh::HandleError> {
41
+ let raw = wgpu::rwh::RawDisplayHandle::Web(wgpu::rwh::WebDisplayHandle::new());
42
+ // SAFETY: a web display handle carries no pointer and cannot dangle.
43
+ Ok(unsafe { wgpu::rwh::DisplayHandle::borrow_raw(raw) })
44
+ }
45
+ }
46
+
47
+ // ============================================================
48
+ // Window
49
+ // ============================================================
50
+
51
+ /// Returns 1.0 once `ENGINE` has been populated by the async wgpu setup, 0.0
52
+ /// before then. Non-panicking — safe to call at any time. Used by the JS
53
+ /// orchestrator to gate Perry boot until the engine is actually usable.
54
+ #[wasm_bindgen]
55
+ pub fn bloom_is_initialized() -> f64 {
56
+ unsafe { if ENGINE.get().is_some() { 1.0 } else { 0.0 } }
57
+ }
58
+
59
+ /// Host-surface attach (PerryTS/perry#5519). Not applicable on web: the
60
+ /// engine builds its surface from the `bloom-canvas` DOM element in
61
+ /// `bloom_init_window`, and a wasm module can't be handed a native view
62
+ /// pointer. Present for FFI-surface uniformity; always returns 0.0 so web
63
+ /// hosts use the canvas-based `bloom_init_window` path instead.
64
+ #[wasm_bindgen]
65
+ pub fn bloom_attach_native(_handle: f64, _width: f64, _height: f64) -> f64 {
66
+ 0.0
67
+ }
68
+
69
+ #[wasm_bindgen]
70
+ pub fn bloom_init_window(width: f64, height: f64, _title: f64, fullscreen: f64) {
71
+ // Set up panic hook for better error messages in the browser console
72
+ console_error_panic_hook::set_once();
73
+
74
+ // Idempotent: once an init is in flight (or done), later calls are no-ops.
75
+ // Perry's main() typically calls initWindow after the JS orchestrator has
76
+ // already kicked off wgpu setup — the second call must not start a new one.
77
+ if INIT_STARTED.swap(true, Ordering::SeqCst) {
78
+ return;
79
+ }
80
+
81
+ let log_w = width as u32;
82
+ let log_h = height as u32;
83
+ let _fullscreen = fullscreen != 0.0;
84
+
85
+ wasm_bindgen_futures::spawn_local(async move {
86
+ let window = web_sys::window().expect("no global window");
87
+ let document = window.document().expect("no document");
88
+ let canvas = document
89
+ .get_element_by_id("bloom-canvas")
90
+ .expect("no element with id 'bloom-canvas'")
91
+ .dyn_into::<web_sys::HtmlCanvasElement>()
92
+ .expect("element is not a canvas");
93
+
94
+ // HiDPI: size the canvas *backing store* (set_width/set_height,
95
+ // i.e. the WebGPU surface) to logical × devicePixelRatio so a
96
+ // 4K Retina display actually renders at 4K. CSS layout dimensions
97
+ // stay in logical pixels — index.html keeps `width: 100%` on
98
+ // the canvas — so the document layout is unchanged. Clamp to
99
+ // [1, 3] because higher dprs (some phones report 4) are wasted
100
+ // shading work and can crash low-end mobile GPUs.
101
+ let dpr = window.device_pixel_ratio().max(1.0).min(3.0);
102
+ let phys_w = ((log_w as f64) * dpr).round() as u32;
103
+ let phys_h = ((log_h as f64) * dpr).round() as u32;
104
+ canvas.set_width(phys_w);
105
+ canvas.set_height(phys_h);
106
+
107
+ let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
108
+ backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
109
+ ..wgpu::InstanceDescriptor::new_with_display_handle(Box::new(WebDisplay))
110
+ });
111
+
112
+ let surface = instance
113
+ .create_surface(wgpu::SurfaceTarget::Canvas(canvas))
114
+ .expect("Failed to create surface from canvas");
115
+
116
+ let adapter = instance
117
+ .request_adapter(&wgpu::RequestAdapterOptions {
118
+ compatible_surface: Some(&surface),
119
+ power_preference: wgpu::PowerPreference::HighPerformance,
120
+ ..Default::default()
121
+ })
122
+ .await
123
+ .expect("No WebGPU/WebGL adapter found");
124
+
125
+ // BC feature -> cooked BC7 textures upload compressed (else CPU-decode).
126
+ let required_features =
127
+ adapter.features() & wgpu::Features::TEXTURE_COMPRESSION_BC;
128
+ // On GL, `Limits::default()` is unsatisfiable: WebGL2 has no compute, so
129
+ // `max_compute_workgroups_per_dimension` is 0 against a default request of
130
+ // 65535 and device creation fails outright. Ask that backend for exactly what
131
+ // it reports instead.
132
+ //
133
+ // On WebGPU, raise the per-stage resource limits to whatever the adapter
134
+ // offers (EN-063): the material ABI's fragment stage samples 19 textures
135
+ // once the wasm scene-inputs fold-in lands in group 0, and the WebGPU
136
+ // DEFAULT of 16 rejects the pipeline layout — while desktop adapters
137
+ // advertise 48+. requiredLimits is the sanctioned way to claim them
138
+ // (unlike maxBindGroups, which is a hard cap). Everything else keeps the
139
+ // defaults so we notice, rather than silently depend on, exotic limits.
140
+ let required_limits = if adapter.get_info().backend == wgpu::Backend::Gl {
141
+ adapter.limits()
142
+ } else {
143
+ let a = adapter.limits();
144
+ let mut l = wgpu::Limits::default();
145
+ l.max_sampled_textures_per_shader_stage =
146
+ a.max_sampled_textures_per_shader_stage
147
+ .max(l.max_sampled_textures_per_shader_stage);
148
+ l.max_samplers_per_shader_stage =
149
+ a.max_samplers_per_shader_stage.max(l.max_samplers_per_shader_stage);
150
+ l.max_texture_array_layers =
151
+ a.max_texture_array_layers.max(l.max_texture_array_layers);
152
+ l
153
+ };
154
+ let (device, queue) = adapter
155
+ .request_device(
156
+ &wgpu::DeviceDescriptor {
157
+ label: Some("bloom_device"),
158
+ required_features,
159
+ required_limits,
160
+ ..Default::default()
161
+ },
162
+ )
163
+ .await
164
+ .expect("Failed to create device");
165
+
166
+ let surface_caps = surface.get_capabilities(&adapter);
167
+ let format = surface_caps
168
+ .formats
169
+ .iter()
170
+ .find(|f| f.is_srgb())
171
+ .copied()
172
+ .unwrap_or(surface_caps.formats[0]);
173
+
174
+ // COPY_SRC (readback of the swapchain image) is unsupported on a WebGL2
175
+ // surface — asking for it there fails Surface::configure validation — so keep
176
+ // it only where the surface advertises it.
177
+ let usage = (wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC)
178
+ & surface_caps.usages;
179
+
180
+ let surface_config = wgpu::SurfaceConfiguration {
181
+ usage,
182
+ format,
183
+ width: phys_w,
184
+ height: phys_h,
185
+ present_mode: wgpu::PresentMode::Fifo,
186
+ alpha_mode: surface_caps.alpha_modes[0],
187
+ view_formats: vec![],
188
+ desired_maximum_frame_latency: 2,
189
+ };
190
+ surface.configure(&device, &surface_config);
191
+
192
+ let renderer = Renderer::new(device, queue, surface, surface_config, log_w, log_h);
193
+ let engine_state = EngineState::new(renderer);
194
+
195
+ unsafe {
196
+ let _ = ENGINE.set(engine_state);
197
+ }
198
+
199
+ let info = adapter.get_info();
200
+ console_log(&format!(
201
+ "Bloom engine initialized ({:?} on {})",
202
+ info.backend, info.name
203
+ ));
204
+ });
205
+ }
206
+
207
+ #[wasm_bindgen]
208
+ pub fn bloom_close_window() {
209
+ // TODO: Phase 3 — clean up resources if needed
210
+ }
211
+
212
+ /// Web-only resize hook. Called from index.html when a ResizeObserver
213
+ /// or matchMedia(`(resolution: ...)`) signals the canvas's CSS box
214
+ /// changed or devicePixelRatio shifted (e.g. browser zoom). The JS
215
+ /// glue does the dpr math; we just receive both sizes and forward to
216
+ /// the renderer.
217
+ #[wasm_bindgen]
218
+ pub fn bloom_resize(physical_w: f64, physical_h: f64, logical_w: f64, logical_h: f64) {
219
+ let pw = (physical_w as u32).max(1);
220
+ let ph = (physical_h as u32).max(1);
221
+ let lw = (logical_w as u32).max(1);
222
+ let lh = (logical_h as u32).max(1);
223
+ unsafe {
224
+ if let Some(eng) = ENGINE.get_mut() {
225
+ if pw != eng.renderer.physical_width() || ph != eng.renderer.physical_height() {
226
+ eng.renderer.resize(pw, ph, lw, lh);
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ #[wasm_bindgen]
233
+ pub fn bloom_window_should_close() -> f64 {
234
+ // Web windows don't close the same way; always return false
235
+ 0.0
236
+ }
237
+
238
+ #[wasm_bindgen]
239
+ pub fn bloom_toggle_fullscreen() {
240
+ // Handled by JS glue — uses Fullscreen API on the canvas element
241
+ }
242
+
243
+ #[wasm_bindgen]
244
+ pub fn bloom_set_window_title(_title: f64) {
245
+ // TODO: Phase 3 — set document.title (need string conversion)
246
+ }
247
+
248
+ #[wasm_bindgen]
249
+ pub fn bloom_set_window_icon(_path: f64) {
250
+ // TODO: Phase 3 — set favicon (need string conversion + fetch)
251
+ }
252
+
253
+ // ============================================================
254
+ // Drawing
255
+ // ============================================================
256
+
257
+ #[wasm_bindgen]
258
+ pub fn bloom_begin_drawing() {
259
+ // No event polling needed on web — events are injected from JS via bloom_inject_*
260
+ engine().begin_frame();
261
+ }
262
+
263
+ #[wasm_bindgen]
264
+ pub fn bloom_end_drawing() {
265
+ engine().end_frame();
266
+ }
267
+
268
+ #[wasm_bindgen]
269
+ pub fn bloom_clear_background(r: f64, g: f64, b: f64, a: f64) {
270
+ engine().renderer.set_clear_color(r, g, b, a);
271
+ }
272
+
273
+ // ============================================================
274
+ // Timing
275
+ // ============================================================
276
+
277
+ #[wasm_bindgen]
278
+ pub fn bloom_set_target_fps(fps: f64) {
279
+ engine().target_fps = fps;
280
+ }
281
+
282
+ #[wasm_bindgen]
283
+ pub fn bloom_set_direct_2d_mode(on: f64) {
284
+ engine().direct_2d_mode = on > 0.5;
285
+ }
286
+
287
+ #[wasm_bindgen]
288
+ pub fn bloom_get_delta_time() -> f64 {
289
+ engine().delta_time
290
+ }
291
+
292
+ #[wasm_bindgen]
293
+ pub fn bloom_get_fps() -> f64 {
294
+ engine().get_fps()
295
+ }
296
+
297
+ #[wasm_bindgen]
298
+ pub fn bloom_get_screen_width() -> f64 {
299
+ engine().screen_width()
300
+ }
301
+
302
+ #[wasm_bindgen]
303
+ pub fn bloom_get_screen_height() -> f64 {
304
+ engine().screen_height()
305
+ }
306
+
307
+ #[wasm_bindgen]
308
+ pub fn bloom_get_time() -> f64 {
309
+ engine().get_time()
310
+ }
311
+
312
+ // Input FFI (keyboard/mouse/gamepad/touch + JS-event injection) lives
313
+ // in input_ffi.rs (2000-line file policy).
314
+ mod input_ffi;
315
+ mod material_ffi;
316
+ pub use input_ffi::*;
317
+
318
+ // EN-063 — web FFI parity for full-3D games: mesh/instance/texture-array
319
+ // scratch builders, staged-model bytes, env-HDR bytes, profiler text,
320
+ // splat impulses, host stubs (parity_ffi) and ragdolls via the Jolt JS
321
+ // bridge (ragdoll_ffi).
322
+ #[cfg(feature = "models3d")]
323
+ mod parity_ffi;
324
+ #[cfg(feature = "models3d")]
325
+ pub use parity_ffi::*;
326
+ #[cfg(all(feature = "jolt", feature = "models3d"))]
327
+ mod ragdoll_ffi;
328
+ #[cfg(all(feature = "jolt", feature = "models3d"))]
329
+ pub use ragdoll_ffi::*;
330
+
331
+ // ============================================================
332
+ // 2D Drawing - Shapes
333
+ // ============================================================
334
+
335
+ #[wasm_bindgen]
336
+ pub fn bloom_draw_line(x1: f64, y1: f64, x2: f64, y2: f64, thickness: f64, r: f64, g: f64, b: f64, a: f64) {
337
+ engine().renderer.draw_line(x1, y1, x2, y2, thickness, r, g, b, a);
338
+ }
339
+
340
+ #[wasm_bindgen]
341
+ pub fn bloom_draw_rect(x: f64, y: f64, w: f64, h: f64, r: f64, g: f64, b: f64, a: f64) {
342
+ engine().renderer.draw_rect(x, y, w, h, r, g, b, a);
343
+ }
344
+
345
+ #[wasm_bindgen]
346
+ pub fn bloom_draw_rect_lines(x: f64, y: f64, w: f64, h: f64, thickness: f64, r: f64, g: f64, b: f64, a: f64) {
347
+ engine().renderer.draw_rect_lines(x, y, w, h, thickness, r, g, b, a);
348
+ }
349
+
350
+ #[wasm_bindgen]
351
+ pub fn bloom_draw_circle(cx: f64, cy: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) {
352
+ engine().renderer.draw_circle(cx, cy, radius, r, g, b, a);
353
+ }
354
+
355
+ #[wasm_bindgen]
356
+ pub fn bloom_draw_circle_lines(cx: f64, cy: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) {
357
+ engine().renderer.draw_circle_lines(cx, cy, radius, r, g, b, a);
358
+ }
359
+
360
+ #[wasm_bindgen]
361
+ pub fn bloom_draw_triangle(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64, r: f64, g: f64, b: f64, a: f64) {
362
+ engine().renderer.draw_triangle(x1, y1, x2, y2, x3, y3, r, g, b, a);
363
+ }
364
+
365
+ #[wasm_bindgen]
366
+ pub fn bloom_draw_poly(cx: f64, cy: f64, sides: f64, radius: f64, rotation: f64, r: f64, g: f64, b: f64, a: f64) {
367
+ engine().renderer.draw_poly(cx, cy, sides, radius, rotation, r, g, b, a);
368
+ }
369
+
370
+ // ============================================================
371
+ // Camera 2D
372
+ // ============================================================
373
+
374
+ #[wasm_bindgen]
375
+ pub fn bloom_begin_mode_2d(offset_x: f64, offset_y: f64, target_x: f64, target_y: f64, rotation: f64, zoom: f64) {
376
+ engine().renderer.begin_mode_2d(
377
+ offset_x as f32, offset_y as f32,
378
+ target_x as f32, target_y as f32,
379
+ rotation as f32, zoom as f32,
380
+ );
381
+ }
382
+
383
+ #[wasm_bindgen]
384
+ pub fn bloom_end_mode_2d() {
385
+ engine().renderer.end_mode_2d();
386
+ }
387
+
388
+ // ============================================================
389
+ // Camera 3D and 3D Drawing
390
+ // ============================================================
391
+
392
+ #[wasm_bindgen]
393
+ pub fn bloom_begin_mode_3d(
394
+ pos_x: f64, pos_y: f64, pos_z: f64,
395
+ target_x: f64, target_y: f64, target_z: f64,
396
+ up_x: f64, up_y: f64, up_z: f64,
397
+ fovy: f64, projection: f64,
398
+ ) {
399
+ engine().renderer.begin_mode_3d(
400
+ pos_x as f32, pos_y as f32, pos_z as f32,
401
+ target_x as f32, target_y as f32, target_z as f32,
402
+ up_x as f32, up_y as f32, up_z as f32,
403
+ fovy as f32, projection as f32,
404
+ );
405
+ }
406
+
407
+ #[wasm_bindgen]
408
+ pub fn bloom_end_mode_3d() {
409
+ engine().renderer.end_mode_3d();
410
+ }
411
+
412
+ #[wasm_bindgen]
413
+ pub fn bloom_draw_cube(x: f64, y: f64, z: f64, w: f64, h: f64, d: f64, r: f64, g: f64, b: f64, a: f64) {
414
+ engine().renderer.draw_cube(x, y, z, w, h, d, r, g, b, a);
415
+ }
416
+
417
+ #[wasm_bindgen]
418
+ pub fn bloom_draw_cube_wires(x: f64, y: f64, z: f64, w: f64, h: f64, d: f64, r: f64, g: f64, b: f64, a: f64) {
419
+ engine().renderer.draw_cube_wires(x, y, z, w, h, d, r, g, b, a);
420
+ }
421
+
422
+ #[wasm_bindgen]
423
+ pub fn bloom_draw_sphere(cx: f64, cy: f64, cz: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) {
424
+ engine().renderer.draw_sphere(cx, cy, cz, radius, r, g, b, a);
425
+ }
426
+
427
+ #[wasm_bindgen]
428
+ pub fn bloom_draw_sphere_wires(cx: f64, cy: f64, cz: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) {
429
+ engine().renderer.draw_sphere_wires(cx, cy, cz, radius, r, g, b, a);
430
+ }
431
+
432
+ #[wasm_bindgen]
433
+ pub fn bloom_draw_cylinder(x: f64, y: f64, z: f64, radius_top: f64, radius_bottom: f64, height: f64, r: f64, g: f64, b: f64, a: f64) {
434
+ engine().renderer.draw_cylinder(x, y, z, radius_top, radius_bottom, height, r, g, b, a);
435
+ }
436
+
437
+ #[wasm_bindgen]
438
+ pub fn bloom_draw_plane(cx: f64, cy: f64, cz: f64, w: f64, d: f64, r: f64, g: f64, b: f64, a: f64) {
439
+ engine().renderer.draw_plane(cx, cy, cz, w, d, r, g, b, a);
440
+ }
441
+
442
+ #[wasm_bindgen]
443
+ pub fn bloom_draw_grid(slices: f64, spacing: f64) {
444
+ engine().renderer.draw_grid(slices as i32, spacing);
445
+ }
446
+
447
+ #[wasm_bindgen]
448
+ pub fn bloom_draw_ray(origin_x: f64, origin_y: f64, origin_z: f64, dir_x: f64, dir_y: f64, dir_z: f64, r: f64, g: f64, b: f64, a: f64) {
449
+ engine().renderer.draw_ray(origin_x, origin_y, origin_z, dir_x, dir_y, dir_z, r, g, b, a);
450
+ }
451
+
452
+ // ============================================================
453
+ // Text
454
+ // ============================================================
455
+
456
+ // The original FFI functions accept f64 (NaN-boxed string handles from Perry WASM).
457
+ // The JS glue intercepts these and calls the _str variants below with actual strings.
458
+ #[wasm_bindgen]
459
+ pub fn bloom_draw_text(_text: f64, _x: f64, _y: f64, _size: f64, _r: f64, _g: f64, _b: f64, _a: f64) {
460
+ // No-op: JS glue calls bloom_draw_text_str instead
461
+ }
462
+
463
+ #[wasm_bindgen]
464
+ pub fn bloom_draw_text_str(text: &str, x: f64, y: f64, size: f64, r: f64, g: f64, b: f64, a: f64) {
465
+ let eng = engine();
466
+ let mut text_renderer = std::mem::replace(&mut eng.text, bloom_shared::text_renderer::TextRenderer::empty());
467
+ text_renderer.draw_text(&mut eng.renderer, text, x, y, size as u32, r, g, b, a);
468
+ eng.text = text_renderer;
469
+ }
470
+
471
+ #[wasm_bindgen]
472
+ pub fn bloom_measure_text(_text: f64, _size: f64) -> f64 { 0.0 }
473
+
474
+ #[wasm_bindgen]
475
+ pub fn bloom_measure_text_str(text: &str, size: f64) -> f64 {
476
+ engine().text.measure_text(text, size as u32)
477
+ }
478
+
479
+ #[wasm_bindgen]
480
+ pub fn bloom_load_font(_path: f64, _size: f64) -> f64 { 0.0 }
481
+
482
+ /// Load a font from raw bytes (fetched by JS glue via fetch()).
483
+ #[wasm_bindgen]
484
+ pub fn bloom_load_font_bytes(data: &[u8]) -> f64 {
485
+ engine().text.load_font(data) as f64
486
+ }
487
+
488
+ #[wasm_bindgen]
489
+ pub fn bloom_unload_font(font_handle: f64) {
490
+ engine().text.unload_font(font_handle as usize);
491
+ }
492
+
493
+ #[wasm_bindgen]
494
+ pub fn bloom_draw_text_ex(_font_handle: f64, _text: f64, _x: f64, _y: f64, _size: f64, _spacing: f64, _r: f64, _g: f64, _b: f64, _a: f64) {
495
+ // No-op: JS glue calls bloom_draw_text_ex_str instead
496
+ }
497
+
498
+ #[wasm_bindgen]
499
+ pub fn bloom_draw_text_ex_str(font_handle: f64, text: &str, x: f64, y: f64, size: f64, spacing: f64, r: f64, g: f64, b: f64, a: f64) {
500
+ let eng = engine();
501
+ let mut text_renderer = std::mem::replace(&mut eng.text, bloom_shared::text_renderer::TextRenderer::empty());
502
+ text_renderer.draw_text_ex(&mut eng.renderer, font_handle as usize, text, x, y, size as u32, spacing as f32, r, g, b, a);
503
+ eng.text = text_renderer;
504
+ }
505
+
506
+ #[wasm_bindgen]
507
+ pub fn bloom_measure_text_ex(_font_handle: f64, _text: f64, _size: f64, _spacing: f64) -> f64 { 0.0 }
508
+
509
+ #[wasm_bindgen]
510
+ pub fn bloom_measure_text_ex_str(font_handle: f64, text: &str, size: f64, spacing: f64) -> f64 {
511
+ engine().text.measure_text_ex(font_handle as usize, text, size as u32, spacing as f32)
512
+ }
513
+
514
+ // --- Texture loading from bytes (fetched by JS glue) ---
515
+
516
+ /// Load a texture from raw image bytes (PNG/JPEG/etc.), fetched by JS glue via fetch().
517
+ #[wasm_bindgen]
518
+ pub fn bloom_load_texture_bytes(data: &[u8]) -> f64 {
519
+ let eng = engine();
520
+ let renderer_ptr = &mut eng.renderer as *mut bloom_shared::renderer::Renderer;
521
+ eng.textures.load_texture(unsafe { &mut *renderer_ptr }, data)
522
+ }
523
+
524
+ // ============================================================
525
+ // Textures
526
+ // ============================================================
527
+
528
+ #[wasm_bindgen]
529
+ pub fn bloom_load_texture(_path: f64) -> f64 {
530
+ // TODO: Phase 3 — need fetch() for texture file + string conversion
531
+ 0.0
532
+ }
533
+
534
+ #[wasm_bindgen]
535
+ pub fn bloom_unload_texture(handle: f64) {
536
+ let eng = engine();
537
+ let renderer_ptr = &mut eng.renderer as *mut Renderer;
538
+ eng.textures.unload_texture(handle, unsafe { &mut *renderer_ptr });
539
+ }
540
+
541
+ #[wasm_bindgen]
542
+ pub fn bloom_draw_texture(handle: f64, x: f64, y: f64, tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64) {
543
+ let eng = engine();
544
+ if let Some(tex) = eng.textures.get(handle) {
545
+ let bind_group_idx = tex.bind_group_idx;
546
+ eng.renderer.draw_texture(bind_group_idx, x, y, tint_r, tint_g, tint_b, tint_a);
547
+ }
548
+ }
549
+
550
+ #[wasm_bindgen]
551
+ pub fn bloom_draw_texture_pro(
552
+ handle: f64,
553
+ src_x: f64, src_y: f64, src_w: f64, src_h: f64,
554
+ dst_x: f64, dst_y: f64, dst_w: f64, dst_h: f64,
555
+ origin_x: f64, origin_y: f64, rotation: f64,
556
+ tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64,
557
+ ) {
558
+ let eng = engine();
559
+ if let Some(tex) = eng.textures.get(handle) {
560
+ let bind_group_idx = tex.bind_group_idx;
561
+ eng.renderer.draw_texture_pro(
562
+ bind_group_idx,
563
+ src_x, src_y, src_w, src_h,
564
+ dst_x, dst_y, dst_w, dst_h,
565
+ origin_x, origin_y, rotation,
566
+ tint_r, tint_g, tint_b, tint_a,
567
+ );
568
+ }
569
+ }
570
+
571
+ #[wasm_bindgen]
572
+ pub fn bloom_draw_texture_rec(
573
+ handle: f64,
574
+ src_x: f64, src_y: f64, src_w: f64, src_h: f64,
575
+ dst_x: f64, dst_y: f64,
576
+ tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64,
577
+ ) {
578
+ let eng = engine();
579
+ if let Some(tex) = eng.textures.get(handle) {
580
+ let bind_group_idx = tex.bind_group_idx;
581
+ eng.renderer.draw_texture_rec(
582
+ bind_group_idx,
583
+ src_x, src_y, src_w, src_h,
584
+ dst_x, dst_y,
585
+ tint_r, tint_g, tint_b, tint_a,
586
+ );
587
+ }
588
+ }
589
+
590
+ #[wasm_bindgen]
591
+ pub fn bloom_get_texture_width(handle: f64) -> f64 {
592
+ engine().textures.get(handle).map(|t| t.width as f64).unwrap_or(0.0)
593
+ }
594
+
595
+ #[wasm_bindgen]
596
+ pub fn bloom_get_texture_height(handle: f64) -> f64 {
597
+ engine().textures.get(handle).map(|t| t.height as f64).unwrap_or(0.0)
598
+ }
599
+
600
+ #[wasm_bindgen]
601
+ pub fn bloom_load_image(_path: f64) -> f64 { 0.0 }
602
+
603
+ #[wasm_bindgen]
604
+ pub fn bloom_load_image_bytes(data: &[u8]) -> f64 {
605
+ engine().textures.load_image(data)
606
+ }
607
+
608
+ #[wasm_bindgen]
609
+ pub fn bloom_image_resize(handle: f64, w: f64, h: f64) {
610
+ engine().textures.image_resize(handle, w as u32, h as u32);
611
+ }
612
+
613
+ #[wasm_bindgen]
614
+ pub fn bloom_image_crop(handle: f64, x: f64, y: f64, w: f64, h: f64) {
615
+ engine().textures.image_crop(handle, x as u32, y as u32, w as u32, h as u32);
616
+ }
617
+
618
+ #[wasm_bindgen]
619
+ pub fn bloom_image_flip_h(handle: f64) {
620
+ engine().textures.image_flip_h(handle);
621
+ }
622
+
623
+ #[wasm_bindgen]
624
+ pub fn bloom_image_flip_v(handle: f64) {
625
+ engine().textures.image_flip_v(handle);
626
+ }
627
+
628
+ #[wasm_bindgen]
629
+ pub fn bloom_load_texture_from_image(handle: f64) -> f64 {
630
+ let eng = engine();
631
+ let renderer_ptr = &mut eng.renderer as *mut Renderer;
632
+ eng.textures.load_texture_from_image(handle, unsafe { &mut *renderer_ptr })
633
+ }
634
+
635
+ #[wasm_bindgen]
636
+ pub fn bloom_gen_texture_mipmaps(_handle: f64) {
637
+ // Mipmap generation is handled by the GPU texture creation pipeline
638
+ }
639
+
640
+ #[wasm_bindgen]
641
+ pub fn bloom_set_texture_filter(handle: f64, mode: f64) {
642
+ let eng = engine();
643
+ if let Some(tex) = eng.textures.get(handle) {
644
+ let bind_group_idx = tex.bind_group_idx;
645
+ eng.renderer.set_texture_filter(bind_group_idx, mode > 0.5);
646
+ }
647
+ }
648
+
649
+ // ============================================================
650
+ // Models
651
+ // ============================================================
652
+
653
+ #[wasm_bindgen]
654
+ pub fn bloom_load_model(_path: f64) -> f64 { 0.0 }
655
+
656
+ #[wasm_bindgen]
657
+ pub fn bloom_load_model_bytes(data: &[u8]) -> f64 {
658
+ let eng = engine();
659
+ eng.models.load_model_with_textures(data, &mut eng.renderer)
660
+ }
661
+
662
+ #[wasm_bindgen]
663
+ pub fn bloom_draw_model(handle: f64, x: f64, y: f64, z: f64, scale: f64, r: f64, g: f64, b: f64, a: f64) {
664
+ let eng = engine();
665
+ if let Some(model) = eng.models.get(handle) {
666
+ let position = [x as f32, y as f32, z as f32];
667
+ let scale = scale as f32;
668
+ let tint = [(r / 255.0) as f32, (g / 255.0) as f32, (b / 255.0) as f32, (a / 255.0) as f32];
669
+ let handle_bits = handle.to_bits();
670
+ if eng.renderer.cache_model_if_static(handle_bits, &model.meshes) {
671
+ // Skinned models cache too (bind-pose VB with raw joint indices,
672
+ // skinned in the scene VS) — they MUST take the skinned cached
673
+ // draw, which pops the staged pose ONCE for the whole model and
674
+ // shares the joint offset across every primitive. Routing them
675
+ // through the unskinned draw (as this did) leaves each primitive
676
+ // reading joint offset 0 — another model's matrices — so every
677
+ // character animated wrongly on web while native was correct.
678
+ // Mirrors the shared macro in ffi_core/models.rs.
679
+ if eng.renderer.is_model_skinned(handle_bits) {
680
+ eng.renderer.draw_model_cached_skinned(handle_bits, position, scale, tint);
681
+ } else {
682
+ eng.renderer.draw_model_cached(handle_bits, position, scale, tint);
683
+ }
684
+ }
685
+ }
686
+ }
687
+
688
+ /// EN-039 — immediate draws that can pitch and roll, not just yaw.
689
+ ///
690
+ /// The web crate was the only platform without it, which failed ffi-parity:
691
+ /// every manifest function must be reachable from every platform, and this one
692
+ /// is NOT a legitimate gap. The documented WEB_GAP_ALLOWLIST entries are all
693
+ /// pointer-taking calls blocked on the Perry-WASM linear-memory bridge — this
694
+ /// takes the matrix as 16 loose f64 scalars precisely so it can cross any FFI,
695
+ /// so allowlisting it would have been recording a TODO for work that is four
696
+ /// lines of typing.
697
+ ///
698
+ /// Mirrors the shared macro (ffi_core/models.rs) exactly, including the skinned
699
+ /// early-out: a skinned model's pose is driven by the anim palette, so drawing
700
+ /// it through the static cache would fight the skinning.
701
+ #[wasm_bindgen]
702
+ #[allow(clippy::too_many_arguments)]
703
+ pub fn bloom_draw_model_transform16(
704
+ handle: f64,
705
+ m0: f64, m1: f64, m2: f64, m3: f64,
706
+ m4: f64, m5: f64, m6: f64, m7: f64,
707
+ m8: f64, m9: f64, m10: f64, m11: f64,
708
+ m12: f64, m13: f64, m14: f64, m15: f64,
709
+ color_packed_argb: f64,
710
+ ) {
711
+ let bits = color_packed_argb as u32;
712
+ let a = ((bits >> 24) & 0xff) as f32 / 255.0;
713
+ let r = ((bits >> 16) & 0xff) as f32 / 255.0;
714
+ let g = ((bits >> 8) & 0xff) as f32 / 255.0;
715
+ let b = ( bits & 0xff) as f32 / 255.0;
716
+ let s = [
717
+ m0, m1, m2, m3,
718
+ m4, m5, m6, m7,
719
+ m8, m9, m10, m11,
720
+ m12, m13, m14, m15,
721
+ ];
722
+ // Column-major, same unpacking as the shared macro.
723
+ let mut mat = [[0.0f32; 4]; 4];
724
+ for col in 0..4 {
725
+ for row in 0..4 {
726
+ mat[col][row] = s[col * 4 + row] as f32;
727
+ }
728
+ }
729
+ let eng = engine();
730
+ if let Some(model) = eng.models.get(handle) {
731
+ let handle_bits = handle.to_bits();
732
+ if eng.renderer.cache_model_if_static(handle_bits, &model.meshes) {
733
+ if eng.renderer.is_model_skinned(handle_bits) {
734
+ return;
735
+ }
736
+ eng.renderer.draw_model_cached_transform(handle_bits, mat, [r, g, b, a]);
737
+ }
738
+ }
739
+ }
740
+
741
+ #[wasm_bindgen]
742
+ pub fn bloom_draw_model_rotated(
743
+ handle: f64, x: f64, y: f64, z: f64,
744
+ scale: f64, rot_y: f64,
745
+ color_packed_argb: f64,
746
+ ) {
747
+ let bits = color_packed_argb as u32;
748
+ let a = ((bits >> 24) & 0xff) as f32 / 255.0;
749
+ let r = ((bits >> 16) & 0xff) as f32 / 255.0;
750
+ let g = ((bits >> 8) & 0xff) as f32 / 255.0;
751
+ let b = ( bits & 0xff) as f32 / 255.0;
752
+ let eng = engine();
753
+ if let Some(model) = eng.models.get(handle) {
754
+ let position = [x as f32, y as f32, z as f32];
755
+ let scale = scale as f32;
756
+ let tint = [r, g, b, a];
757
+ let handle_bits = handle.to_bits();
758
+ // Static models go through the cached scene pipeline (alpha cutout,
759
+ // normal/MR maps, foliage wind + transmission, cutout shadows,
760
+ // planar reflections) — the old immediate path had none of that and
761
+ // rendered cutout foliage as opaque cards. Skinned models take the
762
+ // skinned cached draw and IGNORE the rotation — their joint matrices
763
+ // bake orientation, exactly as the old immediate fallback behaved.
764
+ //
765
+ // This mirrors the shared macro (ffi_core/models.rs). It was still on
766
+ // the immediate path here: round 5 fixed native and the web copy was
767
+ // never updated, so every tree in a browser rendered as torn opaque
768
+ // sheets — the very artifact that fix was written for.
769
+ if eng.renderer.cache_model_if_static(handle_bits, &model.meshes) {
770
+ if eng.renderer.is_model_skinned(handle_bits) {
771
+ eng.renderer.draw_model_cached_skinned(handle_bits, position, scale, tint);
772
+ } else {
773
+ eng.renderer.draw_model_cached_rotated(
774
+ handle_bits, position, scale, rot_y as f32, tint,
775
+ );
776
+ }
777
+ }
778
+ }
779
+ }
780
+
781
+ #[wasm_bindgen]
782
+ pub fn bloom_unload_model(handle: f64) {
783
+ let eng = engine();
784
+ // Evict cached GPU meshes (keyed by handle bits) before the handle dies
785
+ // — without this the buffers leak and a slot-reusing future model would
786
+ // render the stale geometry. Mirrors the shared macro.
787
+ eng.renderer.evict_model_cache(handle.to_bits());
788
+ eng.models.unload_model(handle);
789
+ }
790
+
791
+ #[wasm_bindgen]
792
+ pub fn bloom_gen_mesh_cube(w: f64, h: f64, d: f64) -> f64 {
793
+ engine().models.gen_mesh_cube(w as f32, h as f32, d as f32)
794
+ }
795
+
796
+ #[wasm_bindgen]
797
+ pub fn bloom_gen_mesh_heightmap(image_handle: f64, size_x: f64, size_y: f64, size_z: f64) -> f64 {
798
+ let eng = engine();
799
+ if let Some(img) = eng.textures.images.get(image_handle) {
800
+ let data = img.data.clone();
801
+ let w = img.width;
802
+ let h = img.height;
803
+ eng.models.gen_mesh_heightmap(&data, w, h, size_x as f32, size_y as f32, size_z as f32)
804
+ } else {
805
+ 0.0
806
+ }
807
+ }
808
+
809
+ #[wasm_bindgen]
810
+ pub fn bloom_load_shader(_source: f64) -> f64 {
811
+ // TODO: Phase 4 — need string conversion from NaN-boxed f64 handle
812
+ 0.0
813
+ }
814
+
815
+ #[wasm_bindgen]
816
+ pub fn bloom_create_mesh(_vertex_ptr: f64, _vertex_count: f64, _index_ptr: f64, _index_count: f64) -> f64 {
817
+ // TODO: Phase 4 — need to handle pointer passing from WASM linear memory
818
+ 0.0
819
+ }
820
+
821
+ // ============================================================
822
+ // Lighting
823
+ // ============================================================
824
+
825
+ #[wasm_bindgen]
826
+ pub fn bloom_set_ambient_light(r: f64, g: f64, b: f64, intensity: f64) {
827
+ engine().renderer.set_ambient_light(r, g, b, intensity);
828
+ }
829
+
830
+ #[wasm_bindgen]
831
+ pub fn bloom_set_directional_light(dx: f64, dy: f64, dz: f64, r: f64, g: f64, b: f64, intensity: f64) {
832
+ engine().renderer.set_directional_light(dx, dy, dz, r, g, b, intensity);
833
+ }
834
+
835
+ #[wasm_bindgen]
836
+ pub fn bloom_set_procedural_sky(enabled: f64, rayleigh_density: f64, mie_density: f64, ground_albedo: f64) {
837
+ engine().renderer.set_procedural_sky(
838
+ enabled != 0.0,
839
+ rayleigh_density as f32,
840
+ mie_density as f32,
841
+ ground_albedo as f32,
842
+ );
843
+ }
844
+
845
+ #[wasm_bindgen]
846
+ pub fn bloom_set_sun_direction(dx: f64, dy: f64, dz: f64, intensity: f64) {
847
+ engine().renderer.set_sun_direction(dx as f32, dy as f32, dz as f32, intensity as f32);
848
+ }
849
+
850
+ #[wasm_bindgen]
851
+ pub fn bloom_set_joint_test(joint_index: f64, angle: f64) {
852
+ engine().renderer.set_joint_test(joint_index as usize, angle as f32);
853
+ }
854
+
855
+ #[wasm_bindgen]
856
+ pub fn bloom_add_directional_light(
857
+ dx: f64, dy: f64, dz: f64,
858
+ r: f64, g: f64, b: f64,
859
+ intensity: f64,
860
+ ) {
861
+ engine().renderer.add_directional_light(
862
+ dx as f32, dy as f32, dz as f32,
863
+ r as f32, g as f32, b as f32,
864
+ intensity as f32,
865
+ );
866
+ }
867
+
868
+ #[wasm_bindgen]
869
+ pub fn bloom_add_point_light(
870
+ x: f64, y: f64, z: f64, range: f64,
871
+ r: f64, g: f64, b: f64,
872
+ intensity: f64,
873
+ ) {
874
+ engine().renderer.add_point_light(
875
+ x as f32, y as f32, z as f32, range as f32,
876
+ r as f32, g as f32, b as f32,
877
+ intensity as f32,
878
+ );
879
+ }
880
+
881
+ // ============================================================
882
+ // Audio
883
+ // ============================================================
884
+
885
+ #[wasm_bindgen]
886
+ pub fn bloom_init_audio() {
887
+ // Audio initialization is handled by JS glue (Web Audio API AudioContext).
888
+ // The Rust AudioMixer is already initialized as part of EngineState.
889
+ }
890
+
891
+ #[wasm_bindgen]
892
+ pub fn bloom_close_audio() {
893
+ // AudioContext cleanup handled by JS glue.
894
+ }
895
+
896
+ #[wasm_bindgen]
897
+ pub fn bloom_load_sound(_path: f64) -> f64 { 0.0 }
898
+
899
+ /// Load a sound from raw file bytes (WAV or OGG). Fetched by JS glue via fetch().
900
+ #[wasm_bindgen]
901
+ pub fn bloom_load_sound_bytes(data: &[u8]) -> f64 {
902
+ if let Some(sound) = bloom_shared::audio::parse_wav(data)
903
+ .or_else(|| bloom_shared::audio::parse_ogg(data))
904
+ {
905
+ engine().audio.load_sound(sound)
906
+ } else {
907
+ 0.0
908
+ }
909
+ }
910
+
911
+ #[wasm_bindgen]
912
+ pub fn bloom_play_sound(handle: f64) {
913
+ engine().audio.play_sound(handle);
914
+ }
915
+
916
+ #[wasm_bindgen]
917
+ pub fn bloom_stop_sound(handle: f64) {
918
+ engine().audio.stop_sound(handle);
919
+ }
920
+
921
+ #[wasm_bindgen]
922
+ pub fn bloom_set_sound_volume(handle: f64, volume: f64) {
923
+ engine().audio.set_sound_volume(handle, volume as f32);
924
+ }
925
+
926
+ #[wasm_bindgen]
927
+ pub fn bloom_set_master_volume(volume: f64) {
928
+ engine().audio.set_master_volume(volume as f32);
929
+ }
930
+
931
+ #[wasm_bindgen]
932
+ pub fn bloom_play_sound_3d(handle: f64, x: f64, y: f64, z: f64) {
933
+ engine().audio.play_sound_3d(handle, x as f32, y as f32, z as f32);
934
+ }
935
+
936
+ #[wasm_bindgen]
937
+ pub fn bloom_set_listener_position(x: f64, y: f64, z: f64, fx: f64, fy: f64, fz: f64) {
938
+ engine().audio.set_listener_position(x as f32, y as f32, z as f32, fx as f32, fy as f32, fz as f32);
939
+ }
940
+
941
+ // ---- EN-062: live spatial voices (see shared audio_ffi.rs for docs) --------
942
+
943
+ #[wasm_bindgen]
944
+ pub fn bloom_play_sound_3d_ex(
945
+ handle: f64, x: f64, y: f64, z: f64,
946
+ looping: f64, ref_dist: f64, max_dist: f64, rolloff: f64,
947
+ ) -> f64 {
948
+ engine().audio.play_sound_3d_ex(
949
+ handle, x as f32, y as f32, z as f32,
950
+ looping != 0.0, ref_dist as f32, max_dist as f32, rolloff as f32)
951
+ }
952
+
953
+ #[wasm_bindgen]
954
+ pub fn bloom_voice_set_position(voice: f64, x: f64, y: f64, z: f64) {
955
+ engine().audio.set_voice_position(voice, x as f32, y as f32, z as f32);
956
+ }
957
+
958
+ #[wasm_bindgen]
959
+ pub fn bloom_voice_stop(voice: f64) {
960
+ engine().audio.stop_voice(voice);
961
+ }
962
+
963
+ #[wasm_bindgen]
964
+ pub fn bloom_voice_set_volume(voice: f64, volume: f64) {
965
+ engine().audio.set_voice_volume(voice, volume as f32);
966
+ }
967
+
968
+ #[wasm_bindgen]
969
+ pub fn bloom_voice_set_pitch(voice: f64, pitch: f64) {
970
+ engine().audio.set_voice_pitch(voice, pitch as f32);
971
+ }
972
+
973
+ #[wasm_bindgen]
974
+ pub fn bloom_voice_set_lowpass(voice: f64, cutoff: f64) {
975
+ engine().audio.set_voice_lowpass(voice, cutoff as f32);
976
+ }
977
+
978
+ #[wasm_bindgen]
979
+ pub fn bloom_load_music(_path: f64) -> f64 { 0.0 }
980
+
981
+ /// Load music from raw file bytes (WAV or OGG). Fetched by JS glue via fetch().
982
+ #[wasm_bindgen]
983
+ pub fn bloom_load_music_bytes(data: &[u8]) -> f64 {
984
+ if let Some(sound) = bloom_shared::audio::parse_wav(data)
985
+ .or_else(|| bloom_shared::audio::parse_ogg(data))
986
+ {
987
+ engine().audio.load_music(sound)
988
+ } else {
989
+ 0.0
990
+ }
991
+ }
992
+
993
+ /// Mix audio into an interleaved stereo f32 buffer.
994
+ /// Called by JS Web Audio ScriptProcessorNode/AudioWorklet each audio frame.
995
+ #[wasm_bindgen]
996
+ pub fn bloom_audio_mix(output: &mut [f32]) {
997
+ engine().audio.mix_output(output);
998
+ }
999
+
1000
+ #[wasm_bindgen]
1001
+ pub fn bloom_play_music(handle: f64) {
1002
+ engine().audio.play_music(handle);
1003
+ }
1004
+
1005
+ #[wasm_bindgen]
1006
+ pub fn bloom_stop_music(handle: f64) {
1007
+ engine().audio.stop_music(handle);
1008
+ }
1009
+
1010
+ #[wasm_bindgen]
1011
+ pub fn bloom_update_music_stream(handle: f64) {
1012
+ engine().audio.update_music_stream(handle);
1013
+ }
1014
+
1015
+ #[wasm_bindgen]
1016
+ pub fn bloom_set_music_volume(handle: f64, volume: f64) {
1017
+ engine().audio.set_music_volume(handle, volume as f32);
1018
+ }
1019
+
1020
+ #[wasm_bindgen]
1021
+ pub fn bloom_is_music_playing(handle: f64) -> f64 {
1022
+ if engine().audio.is_music_playing(handle) { 1.0 } else { 0.0 }
1023
+ }
1024
+
1025
+ // ============================================================
1026
+ // Scene graph (retained mode)
1027
+ // ============================================================
1028
+
1029
+ #[wasm_bindgen]
1030
+ pub fn bloom_scene_create_node() -> f64 {
1031
+ engine().scene.create_node()
1032
+ }
1033
+
1034
+ #[wasm_bindgen]
1035
+ pub fn bloom_scene_destroy_node(handle: f64) {
1036
+ engine().scene.destroy_node(handle);
1037
+ }
1038
+
1039
+ #[wasm_bindgen]
1040
+ pub fn bloom_scene_set_visible(handle: f64, visible: f64) {
1041
+ engine().scene.set_visible(handle, visible != 0.0);
1042
+ }
1043
+
1044
+ #[wasm_bindgen]
1045
+ pub fn bloom_scene_set_cast_shadow(handle: f64, cast: f64) {
1046
+ engine().scene.set_cast_shadow(handle, cast != 0.0);
1047
+ }
1048
+
1049
+ #[wasm_bindgen]
1050
+ pub fn bloom_scene_set_receive_shadow(handle: f64, receive: f64) {
1051
+ engine().scene.set_receive_shadow(handle, receive != 0.0);
1052
+ }
1053
+
1054
+ #[wasm_bindgen]
1055
+ pub fn bloom_scene_set_parent(handle: f64, parent: f64) {
1056
+ engine().scene.set_parent(handle, parent);
1057
+ }
1058
+
1059
+ #[wasm_bindgen]
1060
+ pub fn bloom_scene_set_transform(_handle: f64, _matrix_ptr: f64) {
1061
+ // Pointer-taking form — unreachable from Perry on wasm (a `number[]`
1062
+ // cannot cross an i64 param), like the other pointer stubs here. The
1063
+ // 16-scalar `_transform16` below is the live path; it used to carry
1064
+ // THIS name, which meant setSceneNodeTransform resolved to nothing and
1065
+ // was auto-stubbed by Perry's FFI proxy — a silent no-op on web while
1066
+ // the working implementation sat one rename away.
1067
+ }
1068
+
1069
+ #[wasm_bindgen]
1070
+ #[allow(clippy::too_many_arguments)]
1071
+ pub fn bloom_scene_set_transform16(
1072
+ handle: f64,
1073
+ m00: f64, m01: f64, m02: f64, m03: f64,
1074
+ m10: f64, m11: f64, m12: f64, m13: f64,
1075
+ m20: f64, m21: f64, m22: f64, m23: f64,
1076
+ m30: f64, m31: f64, m32: f64, m33: f64,
1077
+ ) {
1078
+ // On web we pass the 16 matrix elements as individual f64 args
1079
+ // (no raw pointer passing from WASM)
1080
+ let mat = [
1081
+ [m00 as f32, m01 as f32, m02 as f32, m03 as f32],
1082
+ [m10 as f32, m11 as f32, m12 as f32, m13 as f32],
1083
+ [m20 as f32, m21 as f32, m22 as f32, m23 as f32],
1084
+ [m30 as f32, m31 as f32, m32 as f32, m33 as f32],
1085
+ ];
1086
+ engine().scene.set_transform(handle, mat);
1087
+ }
1088
+
1089
+ #[wasm_bindgen]
1090
+ pub fn bloom_scene_update_geometry(
1091
+ _handle: f64,
1092
+ _vert_ptr: f64,
1093
+ _vert_count: f64,
1094
+ _idx_ptr: f64,
1095
+ _idx_count: f64,
1096
+ ) {
1097
+ // TODO: Phase 4 — need to handle pointer/buffer passing from WASM linear memory.
1098
+ // On web, vertex and index data will need to be passed via a different mechanism
1099
+ // (e.g., typed arrays through JS interop).
1100
+ }
1101
+
1102
+ #[wasm_bindgen]
1103
+ pub fn bloom_scene_set_material_color(handle: f64, r: f64, g: f64, b: f64, a: f64) {
1104
+ engine().scene.set_material_color(handle, r as f32, g as f32, b as f32, a as f32);
1105
+ }
1106
+
1107
+ #[wasm_bindgen]
1108
+ pub fn bloom_scene_set_material_pbr(handle: f64, roughness: f64, metalness: f64) {
1109
+ engine().scene.set_material_pbr(handle, roughness as f32, metalness as f32);
1110
+ }
1111
+
1112
+ #[wasm_bindgen]
1113
+ pub fn bloom_scene_set_material_texture(handle: f64, texture_idx: f64) {
1114
+ engine().scene.set_material_texture(handle, texture_idx as u32);
1115
+ }
1116
+
1117
+ #[wasm_bindgen]
1118
+ pub fn bloom_scene_node_count() -> f64 {
1119
+ engine().scene.node_count() as f64
1120
+ }
1121
+
1122
+ #[wasm_bindgen]
1123
+ pub fn bloom_scene_node_vertex_count(handle: f64) -> f64 {
1124
+ match engine().scene.nodes.get(handle) {
1125
+ Some(node) => node.vertices.len() as f64,
1126
+ None => -1.0,
1127
+ }
1128
+ }
1129
+
1130
+ #[wasm_bindgen]
1131
+ pub fn bloom_scene_node_index_count(handle: f64) -> f64 {
1132
+ match engine().scene.nodes.get(handle) {
1133
+ Some(node) => node.indices.len() as f64,
1134
+ None => -1.0,
1135
+ }
1136
+ }
1137
+
1138
+ #[wasm_bindgen]
1139
+ pub fn bloom_scene_attach_model(node_handle: f64, model_handle: f64, mesh_index: f64) {
1140
+ let eng = engine();
1141
+ let mi = mesh_index as usize;
1142
+
1143
+ let model_data = match eng.models.models.get(model_handle) {
1144
+ Some(md) => md,
1145
+ None => return,
1146
+ };
1147
+ if mi >= model_data.meshes.len() { return; }
1148
+ let mesh = &model_data.meshes[mi];
1149
+
1150
+ let vertices = mesh.vertices.clone();
1151
+ let indices = mesh.indices.clone();
1152
+ let base_color_tex = mesh.texture_idx;
1153
+ let normal_tex = mesh.normal_texture_idx;
1154
+ let mr_tex = mesh.metallic_roughness_texture_idx;
1155
+ let emissive_tex = mesh.emissive_texture_idx;
1156
+ let emissive_factor = mesh.emissive_factor;
1157
+ let roughness_factor = mesh.roughness_factor;
1158
+ let metallic_factor = mesh.metallic_factor;
1159
+ let alpha_cutoff = mesh.alpha_cutoff;
1160
+ eng.scene.update_geometry(node_handle, vertices, indices);
1161
+
1162
+ if let Some(tex_idx) = base_color_tex {
1163
+ eng.scene.set_material_texture(node_handle, tex_idx);
1164
+ }
1165
+ if let Some(tex_idx) = normal_tex {
1166
+ eng.scene.set_material_normal_texture(node_handle, tex_idx);
1167
+ }
1168
+ if let Some(tex_idx) = mr_tex {
1169
+ eng.scene.set_material_metallic_roughness_texture(node_handle, tex_idx);
1170
+ }
1171
+ if let Some(tex_idx) = emissive_tex {
1172
+ eng.scene.set_material_emissive_texture(node_handle, tex_idx);
1173
+ }
1174
+ eng.scene.set_material_emissive_factor(
1175
+ node_handle,
1176
+ emissive_factor[0],
1177
+ emissive_factor[1],
1178
+ emissive_factor[2],
1179
+ );
1180
+ // Carry the glTF material factors + MASK cutoff too (mirrors the shared
1181
+ // macro) — dropping them left attached foliage opaque (solid cards) and
1182
+ // every attached mesh at the default roughness 0.8 regardless of its
1183
+ // authored material.
1184
+ eng.scene.set_material_pbr(node_handle, roughness_factor, metallic_factor);
1185
+ eng.scene.set_material_alpha_cutoff(node_handle, alpha_cutoff);
1186
+ }
1187
+
1188
+ // ============================================================
1189
+ // Geometry generation
1190
+ // ============================================================
1191
+
1192
+ #[wasm_bindgen]
1193
+ pub fn bloom_scene_extrude_polygon(
1194
+ _handle: f64,
1195
+ _polygon_ptr: f64,
1196
+ _polygon_count: f64,
1197
+ _depth: f64,
1198
+ ) {
1199
+ // TODO: Phase 4 — need to handle pointer/buffer passing from WASM linear memory
1200
+ }
1201
+
1202
+ #[wasm_bindgen]
1203
+ pub fn bloom_scene_subtract_box(
1204
+ handle: f64,
1205
+ min_x: f64, min_y: f64, min_z: f64,
1206
+ max_x: f64, max_y: f64, max_z: f64,
1207
+ ) {
1208
+ let eng = engine();
1209
+ if let Some(node) = eng.scene.nodes.get(handle) {
1210
+ let current = bloom_shared::geometry::GeometryData {
1211
+ vertices: node.vertices.clone(),
1212
+ indices: node.indices.clone(),
1213
+ };
1214
+ let result = bloom_shared::geometry::subtract_box(
1215
+ &current,
1216
+ [min_x as f32, min_y as f32, min_z as f32],
1217
+ [max_x as f32, max_y as f32, max_z as f32],
1218
+ );
1219
+ eng.scene.update_geometry(handle, result.vertices, result.indices);
1220
+ }
1221
+ }
1222
+
1223
+ // ============================================================
1224
+ // Shadow mapping
1225
+ // ============================================================
1226
+
1227
+ #[wasm_bindgen]
1228
+ pub fn bloom_enable_shadows() {
1229
+ engine().renderer.shadow_map.enable();
1230
+ }
1231
+
1232
+ #[wasm_bindgen]
1233
+ pub fn bloom_disable_shadows() {
1234
+ engine().renderer.shadow_map.disable();
1235
+ }
1236
+
1237
+ // ============================================================
1238
+ // Post-processing
1239
+ // ============================================================
1240
+
1241
+ #[wasm_bindgen]
1242
+ pub fn bloom_enable_postfx() {
1243
+ let eng = engine();
1244
+ let w = eng.renderer.width();
1245
+ let h = eng.renderer.height();
1246
+ let fmt = eng.renderer.surface_format();
1247
+ eng.postfx = Some(bloom_shared::postfx::PostFxPipeline::new(
1248
+ &eng.renderer.device, w, h, fmt,
1249
+ ));
1250
+ }
1251
+
1252
+ #[wasm_bindgen]
1253
+ pub fn bloom_disable_postfx() {
1254
+ engine().postfx = None;
1255
+ }
1256
+
1257
+ #[wasm_bindgen]
1258
+ pub fn bloom_postfx_set_selected(handle: f64) {
1259
+ if let Some(pfx) = &mut engine().postfx {
1260
+ if handle == 0.0 {
1261
+ pfx.set_selected(Vec::new());
1262
+ } else {
1263
+ pfx.set_selected(vec![handle]);
1264
+ }
1265
+ }
1266
+ }
1267
+
1268
+ #[wasm_bindgen]
1269
+ pub fn bloom_postfx_set_hovered(handle: f64) {
1270
+ if let Some(pfx) = &mut engine().postfx {
1271
+ pfx.set_hovered(handle);
1272
+ }
1273
+ }
1274
+
1275
+ #[wasm_bindgen]
1276
+ pub fn bloom_postfx_set_outline_color(r: f64, g: f64, b: f64, a: f64) {
1277
+ if let Some(pfx) = &mut engine().postfx {
1278
+ pfx.outline_params.color_selected = [r as f32, g as f32, b as f32, a as f32];
1279
+ }
1280
+ }
1281
+
1282
+ #[wasm_bindgen]
1283
+ pub fn bloom_postfx_set_outline_thickness(thickness: f64) {
1284
+ if let Some(pfx) = &mut engine().postfx {
1285
+ pfx.outline_params.thickness[0] = thickness as f32;
1286
+ }
1287
+ }
1288
+
1289
+ // ============================================================
1290
+ // Picking
1291
+ // ============================================================
1292
+
1293
+ #[wasm_bindgen]
1294
+ pub fn bloom_scene_pick(screen_x: f64, screen_y: f64) -> f64 {
1295
+ let eng = engine();
1296
+ let inv_vp = eng.renderer.inverse_vp_matrix();
1297
+ let cam_pos = eng.renderer.camera_pos();
1298
+ let w = eng.renderer.width() as f32;
1299
+ let h = eng.renderer.height() as f32;
1300
+
1301
+ let (origin, direction) = bloom_shared::picking::screen_to_ray(
1302
+ screen_x as f32, screen_y as f32,
1303
+ w, h, &inv_vp, &cam_pos,
1304
+ );
1305
+
1306
+ let result = bloom_shared::picking::raycast_scene(&eng.scene, &origin, &direction);
1307
+ let hit = result.hit;
1308
+ unsafe { LAST_PICK = Some(result); }
1309
+ if hit { 1.0 } else { 0.0 }
1310
+ }
1311
+
1312
+ #[wasm_bindgen]
1313
+ pub fn bloom_pick_hit_handle() -> f64 {
1314
+ unsafe { LAST_PICK.as_ref().map(|r| r.handle).unwrap_or(0.0) }
1315
+ }
1316
+
1317
+ #[wasm_bindgen]
1318
+ pub fn bloom_pick_hit_distance() -> f64 {
1319
+ unsafe { LAST_PICK.as_ref().map(|r| r.distance as f64).unwrap_or(0.0) }
1320
+ }
1321
+
1322
+ #[wasm_bindgen]
1323
+ pub fn bloom_pick_hit_x() -> f64 {
1324
+ unsafe { LAST_PICK.as_ref().map(|r| r.point[0] as f64).unwrap_or(0.0) }
1325
+ }
1326
+
1327
+ #[wasm_bindgen]
1328
+ pub fn bloom_pick_hit_y() -> f64 {
1329
+ unsafe { LAST_PICK.as_ref().map(|r| r.point[1] as f64).unwrap_or(0.0) }
1330
+ }
1331
+
1332
+ #[wasm_bindgen]
1333
+ pub fn bloom_pick_hit_z() -> f64 {
1334
+ unsafe { LAST_PICK.as_ref().map(|r| r.point[2] as f64).unwrap_or(0.0) }
1335
+ }
1336
+
1337
+ #[wasm_bindgen]
1338
+ pub fn bloom_pick_hit_normal_x() -> f64 {
1339
+ unsafe { LAST_PICK.as_ref().map(|r| r.normal[0] as f64).unwrap_or(0.0) }
1340
+ }
1341
+
1342
+ #[wasm_bindgen]
1343
+ pub fn bloom_pick_hit_normal_y() -> f64 {
1344
+ unsafe { LAST_PICK.as_ref().map(|r| r.normal[1] as f64).unwrap_or(0.0) }
1345
+ }
1346
+
1347
+ #[wasm_bindgen]
1348
+ pub fn bloom_pick_hit_normal_z() -> f64 {
1349
+ unsafe { LAST_PICK.as_ref().map(|r| r.normal[2] as f64).unwrap_or(0.0) }
1350
+ }
1351
+
1352
+ // ============================================================
1353
+ // 3D -> 2D Projection
1354
+ // ============================================================
1355
+
1356
+ #[wasm_bindgen]
1357
+ pub fn bloom_project_to_screen(wx: f64, wy: f64, wz: f64) -> f64 {
1358
+ let eng = engine();
1359
+ let vp = eng.renderer.vp_matrix();
1360
+ let w = eng.renderer.width() as f32;
1361
+ let h = eng.renderer.height() as f32;
1362
+
1363
+ let x = wx as f32;
1364
+ let y = wy as f32;
1365
+ let z = wz as f32;
1366
+ let clip_x = vp[0][0]*x + vp[1][0]*y + vp[2][0]*z + vp[3][0];
1367
+ let clip_y = vp[0][1]*x + vp[1][1]*y + vp[2][1]*z + vp[3][1];
1368
+ let clip_w = vp[0][3]*x + vp[1][3]*y + vp[2][3]*z + vp[3][3];
1369
+
1370
+ if clip_w <= 0.0 {
1371
+ unsafe { LAST_PROJECT = (-9999.0, -9999.0); }
1372
+ return -9999.0;
1373
+ }
1374
+
1375
+ let ndc_x = clip_x / clip_w;
1376
+ let ndc_y = clip_y / clip_w;
1377
+ let screen_x = ((ndc_x + 1.0) * 0.5 * w) as f64;
1378
+ let screen_y = ((1.0 - ndc_y) * 0.5 * h) as f64;
1379
+
1380
+ unsafe { LAST_PROJECT = (screen_x, screen_y); }
1381
+ screen_x
1382
+ }
1383
+
1384
+ #[wasm_bindgen]
1385
+ pub fn bloom_project_screen_y() -> f64 {
1386
+ unsafe { LAST_PROJECT.1 }
1387
+ }
1388
+
1389
+ // ============================================================
1390
+ // File I/O
1391
+ // ============================================================
1392
+
1393
+ #[wasm_bindgen]
1394
+ pub fn bloom_write_file(_path: f64, _data: f64) -> f64 {
1395
+ // Handled by JS glue — uses localStorage
1396
+ 0.0
1397
+ }
1398
+
1399
+ #[wasm_bindgen]
1400
+ pub fn bloom_file_exists(_path: f64) -> f64 {
1401
+ // Handled by JS glue — checks localStorage
1402
+ 0.0
1403
+ }
1404
+
1405
+ #[wasm_bindgen]
1406
+ pub fn bloom_read_file(_path: f64) -> f64 {
1407
+ // Handled by JS glue — reads from localStorage
1408
+ 0.0
1409
+ }
1410
+
1411
+ // ============================================================
1412
+ // Cursor
1413
+ // ============================================================
1414
+
1415
+ #[wasm_bindgen]
1416
+ pub fn bloom_disable_cursor() {
1417
+ let input = &mut engine().input;
1418
+ input.cursor_disabled = true;
1419
+ input.clear_mouse_delta();
1420
+ // JS glue also calls canvas.requestPointerLock()
1421
+ }
1422
+
1423
+ #[wasm_bindgen]
1424
+ pub fn bloom_enable_cursor() {
1425
+ engine().input.cursor_disabled = false;
1426
+ // JS glue also calls document.exitPointerLock()
1427
+ }
1428
+
1429
+ // ============================================================
1430
+ // Frame callbacks & game loop
1431
+ // ============================================================
1432
+
1433
+ #[wasm_bindgen]
1434
+ pub fn bloom_register_frame_callback(_priority: f64, _callback: f64) -> f64 {
1435
+ // On web, frame callbacks are managed by the JS glue layer (bloom_glue.js)
1436
+ // since the callback is a Perry WASM closure that can only be invoked
1437
+ // through Perry's runtime. The JS glue intercepts this call.
1438
+ 0.0
1439
+ }
1440
+
1441
+ #[wasm_bindgen]
1442
+ pub fn bloom_unregister_frame_callback(_id: f64) {
1443
+ // Managed by JS glue layer
1444
+ }
1445
+
1446
+ /// Emscripten-style game loop entry point.
1447
+ /// On native: blocks in a while loop calling begin_drawing/callback/end_drawing.
1448
+ /// On web: returns immediately. The JS glue layer (bloom_glue.js) intercepts
1449
+ /// this call and drives the game loop via requestAnimationFrame.
1450
+ #[wasm_bindgen]
1451
+ pub fn bloom_run_game(_callback: f64) {
1452
+ // On web, this is a no-op — the JS glue intercepts the FFI call
1453
+ // before it reaches here and sets up the rAF loop with the callback.
1454
+ // The game's while(!windowShouldClose()) loop should exit after this
1455
+ // (bloom_glue.js makes windowShouldClose return 1.0 once runGame is called).
1456
+ }
1457
+
1458
+ // ============================================================
1459
+ // Staging (async asset loading)
1460
+ // ============================================================
1461
+
1462
+ #[wasm_bindgen]
1463
+ pub fn bloom_stage_texture(_path: f64) -> f64 {
1464
+ // TODO: Phase 4 — fetch() texture then decode_and_stage_texture
1465
+ 0.0
1466
+ }
1467
+
1468
+ #[wasm_bindgen]
1469
+ pub fn bloom_stage_model(_path: f64) -> f64 {
1470
+ // TODO: Phase 4 — fetch() model then stage
1471
+ 0.0
1472
+ }
1473
+
1474
+ #[wasm_bindgen]
1475
+ pub fn bloom_stage_sound(_path: f64) -> f64 {
1476
+ // TODO: Phase 4 — fetch() sound then stage
1477
+ 0.0
1478
+ }
1479
+
1480
+ #[wasm_bindgen]
1481
+ pub fn bloom_commit_texture(staging_handle: f64) -> f64 {
1482
+ let staged = match bloom_shared::staging::take_texture(staging_handle) {
1483
+ Some(s) => s,
1484
+ None => return 0.0,
1485
+ };
1486
+ let eng = engine();
1487
+ let bind_group_idx = eng.renderer.register_texture(staged.width, staged.height, &staged.data);
1488
+ eng.textures.textures.alloc(bloom_shared::textures::TextureData {
1489
+ bind_group_idx, width: staged.width, height: staged.height,
1490
+ })
1491
+ }
1492
+
1493
+ #[wasm_bindgen]
1494
+ pub fn bloom_commit_model(staging_handle: f64) -> f64 {
1495
+ let staged = match bloom_shared::staging::take_model(staging_handle) {
1496
+ Some(s) => s,
1497
+ None => return 0.0,
1498
+ };
1499
+ let eng = engine();
1500
+ // Normal maps must go through the kind-aware registration (linear space
1501
+ // + LEADR mips) or the committed model shades visibly flatter than the
1502
+ // same GLB through loadModel. Kept in lockstep with the native
1503
+ // bloom_commit_model in ffi_core/models.rs.
1504
+ let mut tex_map: Vec<u32> = Vec::with_capacity(staged.textures.len());
1505
+ for tex in &staged.textures {
1506
+ tex_map.push(eng.renderer.register_texture_kind(
1507
+ tex.width, tex.height, &tex.data, tex.is_normal));
1508
+ }
1509
+ let mut model = staged.model;
1510
+ // Remap EVERY texture slot, not just the base colour — dropping the
1511
+ // normal/MR/emissive/occlusion references silently strips the character
1512
+ // maps on any model loaded through the staged path (the round-5 "stale
1513
+ // aux-texture remap" bug, which this web copy used to reproduce).
1514
+ let remap = |idx: &mut Option<u32>| {
1515
+ if let Some(i) = *idx {
1516
+ let s = i as usize;
1517
+ *idx = if s > 0 && s <= tex_map.len() {
1518
+ Some(tex_map[s - 1])
1519
+ } else {
1520
+ None
1521
+ };
1522
+ }
1523
+ };
1524
+ for mesh in &mut model.meshes {
1525
+ remap(&mut mesh.texture_idx);
1526
+ remap(&mut mesh.normal_texture_idx);
1527
+ remap(&mut mesh.metallic_roughness_texture_idx);
1528
+ remap(&mut mesh.emissive_texture_idx);
1529
+ remap(&mut mesh.occlusion_texture_idx);
1530
+ }
1531
+ eng.models.models.alloc(model)
1532
+ }
1533
+
1534
+ #[wasm_bindgen]
1535
+ pub fn bloom_commit_sound(staging_handle: f64) -> f64 {
1536
+ match bloom_shared::staging::take_sound(staging_handle) {
1537
+ Some(sd) => engine().audio.load_sound(sd),
1538
+ None => 0.0,
1539
+ }
1540
+ }
1541
+
1542
+ #[wasm_bindgen]
1543
+ pub fn bloom_commit_music(staging_handle: f64) -> f64 {
1544
+ match bloom_shared::staging::take_sound(staging_handle) {
1545
+ Some(sd) => engine().audio.load_music(sd),
1546
+ None => 0.0,
1547
+ }
1548
+ }
1549
+
1550
+ // ============================================================
1551
+ // Platform detection
1552
+ // ============================================================
1553
+
1554
+ #[wasm_bindgen]
1555
+ pub fn bloom_get_platform() -> f64 {
1556
+ 7.0 // Web platform ID
1557
+ }
1558
+
1559
+ #[wasm_bindgen]
1560
+ pub fn bloom_get_language() -> f64 {
1561
+ // navigator.language (e.g. "en-US" / "zh-Hans") -> packed 2-letter code.
1562
+ let lang = web_sys::window().and_then(|w| w.navigator().language()).unwrap_or_default();
1563
+ let b = lang.to_ascii_lowercase().into_bytes();
1564
+ if b.len() >= 2 { (b[0] as f64) * 256.0 + (b[1] as f64) } else { 25966.0 }
1565
+ }
1566
+
1567
+ #[wasm_bindgen]
1568
+ pub fn bloom_is_any_input_pressed() -> f64 {
1569
+ if engine().input.is_any_input_pressed() { 1.0 } else { 0.0 }
1570
+ }
1571
+
1572
+ #[wasm_bindgen]
1573
+ pub fn bloom_get_crown_rotation() -> f64 {
1574
+ engine().input.consume_crown_rotation()
1575
+ }
1576
+
1577
+
1578
+ // Scene graph QoL — Q4/Q5/Q6/Q7
1579
+ #[wasm_bindgen]
1580
+ pub fn bloom_scene_get_transform(handle: f64, index: f64) -> f64 {
1581
+ let mat = engine().scene.get_transform(handle);
1582
+ let i = index as usize;
1583
+ let col = i / 4;
1584
+ let row = i % 4;
1585
+ if col < 4 && row < 4 { mat[col][row] as f64 } else { 0.0 }
1586
+ }
1587
+ #[wasm_bindgen]
1588
+ pub fn bloom_scene_get_bounds_min_x(handle: f64) -> f64 { engine().scene.get_bounds(handle).0[0] as f64 }
1589
+ #[wasm_bindgen]
1590
+ pub fn bloom_scene_get_bounds_min_y(handle: f64) -> f64 { engine().scene.get_bounds(handle).0[1] as f64 }
1591
+ #[wasm_bindgen]
1592
+ pub fn bloom_scene_get_bounds_min_z(handle: f64) -> f64 { engine().scene.get_bounds(handle).0[2] as f64 }
1593
+ #[wasm_bindgen]
1594
+ pub fn bloom_scene_get_bounds_max_x(handle: f64) -> f64 { engine().scene.get_bounds(handle).1[0] as f64 }
1595
+ #[wasm_bindgen]
1596
+ pub fn bloom_scene_get_bounds_max_y(handle: f64) -> f64 { engine().scene.get_bounds(handle).1[1] as f64 }
1597
+ #[wasm_bindgen]
1598
+ pub fn bloom_scene_get_bounds_max_z(handle: f64) -> f64 { engine().scene.get_bounds(handle).1[2] as f64 }
1599
+ #[wasm_bindgen]
1600
+ pub fn bloom_scene_set_user_data(handle: f64, data: f64) { engine().scene.set_user_data(handle, data as i64); }
1601
+ #[wasm_bindgen]
1602
+ pub fn bloom_scene_get_user_data(handle: f64) -> f64 { engine().scene.get_user_data(handle) as f64 }
1603
+
1604
+ // Q1: Render texture FFI (stub)
1605
+ #[wasm_bindgen]
1606
+ pub fn bloom_load_render_texture(width: f64, height: f64) -> f64 {
1607
+ engine().textures.load_render_texture(width as u32, height as u32)
1608
+ }
1609
+ #[wasm_bindgen]
1610
+ pub fn bloom_unload_render_texture(handle: f64) { engine().textures.unload_render_texture(handle); }
1611
+ #[wasm_bindgen]
1612
+ pub fn bloom_begin_texture_mode(_handle: f64) { /* stub */ }
1613
+ #[wasm_bindgen]
1614
+ pub fn bloom_end_texture_mode() { /* stub */ }
1615
+ #[wasm_bindgen]
1616
+ pub fn bloom_get_render_texture_texture(handle: f64) -> f64 { engine().textures.get_render_texture_texture(handle) }
1617
+
1618
+ // Q8: Water material
1619
+ #[wasm_bindgen]
1620
+ pub fn bloom_scene_set_material_water(handle: f64, wave_amp: f64, wave_speed: f64, r: f64, g: f64, b: f64, a: f64) {
1621
+ engine().scene.set_material_water(handle, wave_amp as f32, wave_speed as f32, r as f32, g as f32, b as f32, a as f32);
1622
+ }
1623
+
1624
+ // Q9: Spline ribbon mesh
1625
+ #[wasm_bindgen]
1626
+ pub fn bloom_gen_mesh_spline_ribbon(points_ptr: *const u8, point_count: f64, widths_ptr: *const u8, width_count: f64) -> f64 {
1627
+ let n = point_count as usize;
1628
+ let wn = width_count as usize;
1629
+ let points = unsafe { std::slice::from_raw_parts(points_ptr as *const f32, n * 3) };
1630
+ let widths = unsafe { std::slice::from_raw_parts(widths_ptr as *const f32, wn) };
1631
+ engine().models.gen_mesh_spline_ribbon(points, widths)
1632
+ }
1633
+
1634
+ // Q6: Multi-hit picking
1635
+ static mut LAST_PICK_ALL: Vec<bloom_shared::picking::PickResult> = Vec::new();
1636
+
1637
+ #[wasm_bindgen]
1638
+ pub fn bloom_scene_pick_all(screen_x: f64, screen_y: f64, max_results: f64) -> f64 {
1639
+ let eng = engine();
1640
+ let inv_vp = eng.renderer.inverse_vp_matrix();
1641
+ let cam_pos = eng.renderer.camera_pos();
1642
+ let w = eng.renderer.width() as f32;
1643
+ let h = eng.renderer.height() as f32;
1644
+ let (origin, direction) = bloom_shared::picking::screen_to_ray(
1645
+ screen_x as f32, screen_y as f32, w, h, &inv_vp, &cam_pos,
1646
+ );
1647
+ let results = bloom_shared::picking::raycast_scene_all(&eng.scene, &origin, &direction, max_results as usize);
1648
+ let count = results.len();
1649
+ unsafe { LAST_PICK_ALL = results; }
1650
+ count as f64
1651
+ }
1652
+ #[wasm_bindgen]
1653
+ pub fn bloom_pick_all_handle(index: f64) -> f64 {
1654
+ let i = index as usize;
1655
+ unsafe { LAST_PICK_ALL.get(i).map(|r| r.handle).unwrap_or(0.0) }
1656
+ }
1657
+ #[wasm_bindgen]
1658
+ pub fn bloom_pick_all_distance(index: f64) -> f64 {
1659
+ let i = index as usize;
1660
+ unsafe { LAST_PICK_ALL.get(i).map(|r| r.distance as f64).unwrap_or(0.0) }
1661
+ }
1662
+
1663
+ // Q2: Cursor shape
1664
+ #[wasm_bindgen]
1665
+ pub fn bloom_set_cursor_shape(shape: f64) {
1666
+ engine().input.cursor_shape = shape as u32;
1667
+ }
1668
+
1669
+ // E4: Clipboard (stub)
1670
+ #[wasm_bindgen]
1671
+ pub fn bloom_set_clipboard_text(_text_ptr: *const u8) {}
1672
+ #[wasm_bindgen]
1673
+ pub fn bloom_get_clipboard_text() -> f64 { 0.0 }
1674
+
1675
+ // E5b: File dialogs (stub)
1676
+ #[wasm_bindgen]
1677
+ pub fn bloom_open_file_dialog(_filter_ptr: *const u8, _title_ptr: *const u8) -> f64 { 0.0 }
1678
+ #[wasm_bindgen]
1679
+ pub fn bloom_save_file_dialog(_default_name_ptr: *const u8, _title_ptr: *const u8) -> f64 { 0.0 }
1680
+
1681
+ // ============================================================
1682
+ // Render quality toggles (individual + preset) — ticket 011
1683
+ // Mirror of the macOS FFI surface added in commit 95da6af, exposed to
1684
+ // the browser via wasm_bindgen. Without these the TS API's
1685
+ // setQualityPreset / setShadowsEnabled / etc. would fail with
1686
+ // "bloom_set_quality_preset is not a function" on the web target.
1687
+ // ============================================================
1688
+
1689
+ #[wasm_bindgen]
1690
+ pub fn bloom_set_quality_preset(preset: f64) {
1691
+ engine().renderer.apply_quality_preset(preset as u32);
1692
+ }
1693
+ #[wasm_bindgen]
1694
+ pub fn bloom_set_shadows_enabled(on: f64) {
1695
+ engine().renderer.set_shadows_enabled(on != 0.0);
1696
+ }
1697
+ #[wasm_bindgen]
1698
+ pub fn bloom_set_shadows_always_fresh(on: f64) {
1699
+ engine().renderer.set_shadows_always_fresh(on != 0.0);
1700
+ }
1701
+ #[wasm_bindgen]
1702
+ pub fn bloom_set_bloom_enabled(on: f64) {
1703
+ engine().renderer.set_bloom_enabled(on != 0.0);
1704
+ }
1705
+ #[wasm_bindgen]
1706
+ pub fn bloom_set_ssao_enabled(on: f64) {
1707
+ engine().renderer.set_ssao_enabled(on != 0.0);
1708
+ }
1709
+ #[wasm_bindgen]
1710
+ pub fn bloom_set_ssao_intensity(value: f64) {
1711
+ engine().renderer.set_ssao_strength(value as f32);
1712
+ }
1713
+ #[wasm_bindgen]
1714
+ pub fn bloom_set_ssao_radius(world_radius: f64) {
1715
+ engine().renderer.set_ssao_radius(world_radius as f32);
1716
+ }
1717
+ #[wasm_bindgen]
1718
+ pub fn bloom_set_wind(dir_x: f64, dir_z: f64, amplitude: f64, frequency: f64) {
1719
+ engine().renderer.set_wind(dir_x as f32, dir_z as f32, amplitude as f32, frequency as f32);
1720
+ }
1721
+ #[wasm_bindgen]
1722
+ pub fn bloom_launch_process(_cmd: f64, _args: f64, _cwd: f64) -> f64 {
1723
+ // A web page does not get to launch processes.
1724
+ 0.0
1725
+ }
1726
+ #[wasm_bindgen]
1727
+ pub fn bloom_set_output_scale(scale: f64) {
1728
+ engine().renderer.set_output_scale(scale as f32);
1729
+ }
1730
+ #[wasm_bindgen]
1731
+ pub fn bloom_get_output_scale() -> f64 {
1732
+ engine().renderer.output_scale() as f64
1733
+ }
1734
+ #[wasm_bindgen]
1735
+ pub fn bloom_set_model_foliage_wind(model: f64, amount: f64) {
1736
+ engine().renderer.set_model_foliage_wind(model.to_bits(), amount as f32);
1737
+ }
1738
+ #[wasm_bindgen]
1739
+ pub fn bloom_set_foliage_shadow_motion(on: f64) {
1740
+ engine().renderer.set_foliage_shadow_motion(on > 0.5);
1741
+ }
1742
+ #[wasm_bindgen]
1743
+ pub fn bloom_set_cloud_shadows(strength: f64, deck_height: f64, feature_scale: f64, drift_speed: f64) {
1744
+ engine().renderer.set_cloud_shadows(
1745
+ strength as f32, deck_height as f32, feature_scale as f32, drift_speed as f32);
1746
+ }
1747
+ #[wasm_bindgen]
1748
+ pub fn bloom_set_ssr_enabled(on: f64) {
1749
+ engine().renderer.set_ssr_enabled(on != 0.0);
1750
+ }
1751
+ #[wasm_bindgen]
1752
+ pub fn bloom_set_motion_blur_enabled(on: f64) {
1753
+ engine().renderer.set_motion_blur_enabled(on != 0.0);
1754
+ }
1755
+ #[wasm_bindgen]
1756
+ pub fn bloom_set_sss_enabled(on: f64) {
1757
+ engine().renderer.set_sss_enabled(on != 0.0);
1758
+ }
1759
+
1760
+ // ============================================================
1761
+ // Profiler — CPU phase timings (always available). TIMESTAMP_QUERY
1762
+ // is not part of the WebGPU spec as of wgpu 29, so the GPU path
1763
+ // returns 0 on web regardless of adapter — the profiler stays
1764
+ // CPU-only, which is still useful for frame-phase profiling.
1765
+ // ============================================================
1766
+
1767
+ #[wasm_bindgen]
1768
+ pub fn bloom_set_profiler_enabled(on: f64) {
1769
+ engine().profiler.set_enabled(on != 0.0);
1770
+ }
1771
+ #[wasm_bindgen]
1772
+ pub fn bloom_get_profiler_frame_cpu_us() -> f64 {
1773
+ engine().profiler.avg_frame_cpu_us()
1774
+ }
1775
+ #[wasm_bindgen]
1776
+ pub fn bloom_get_profiler_frame_gpu_us() -> f64 {
1777
+ engine().profiler.avg_frame_gpu_us()
1778
+ }
1779
+ #[wasm_bindgen]
1780
+ pub fn bloom_print_profiler_summary() {
1781
+ // No stdout in the browser — route through the existing
1782
+ // console_log binding so the summary lands in devtools.
1783
+ console_log(&engine().profiler.summary());
1784
+ }
1785
+ // ============================================================
1786
+
1787
+ // Physics (Jolt via JoltPhysics.js) — the whole bloom_physics_* surface
1788
+ // lives in physics_ffi.rs, kept in lockstep with jolt_bridge.js and the
1789
+ // package.json manifest (tools/validate-ffi.js checks the name coverage).
1790
+ mod physics_ffi;
1791
+ pub use physics_ffi::*;
1792
+
1793
+ // Renderer settings + LOD surface live in render_settings.rs (2000-line
1794
+ // file policy); same shared engine methods the native macro wraps.
1795
+ mod render_settings;
1796
+ pub use render_settings::*;