diff --git a/native/linux/src/lib.rs b/native/linux/src/lib.rs index e971911..44c7835 100644 --- a/native/linux/src/lib.rs +++ b/native/linux/src/lib.rs @@ -664,7 +664,11 @@ pub extern "C" fn bloom_resize(phys_w: f64, phys_h: f64, log_w: f64, log_h: f64) pub extern "C" fn bloom_attach_hwnd(_hwnd_bits: f64, _width: f64, _height: f64) {} #[no_mangle] -pub extern "C" fn bloom_close_window() {} +pub extern "C" fn bloom_close_window() { + // Request a clean exit — the game loop's next windowShouldClose() check + // reports true (mirrors the Windows implementation; was an empty stub). + engine().should_close = true; +} #[no_mangle] pub extern "C" fn bloom_window_should_close() -> f64 { diff --git a/native/macos/src/lib.rs b/native/macos/src/lib.rs index 648126c..8bb028f 100644 --- a/native/macos/src/lib.rs +++ b/native/macos/src/lib.rs @@ -502,6 +502,9 @@ pub extern "C" fn bloom_close_window() { unsafe { WINDOW = None; } + // Dropping the NSWindow alone never flipped the loop's exit flag — a + // game's quit path calling closeWindow() would keep running headless. + engine().should_close = true; } #[no_mangle] diff --git a/native/shared/Cargo.lock b/native/shared/Cargo.lock index 6d8403a..cf4a300 100644 --- a/native/shared/Cargo.lock +++ b/native/shared/Cargo.lock @@ -110,6 +110,7 @@ dependencies = [ "notify", "pollster", "raw-window-handle", + "web-sys", "web-time", "wgpu", ] diff --git a/native/shared/Cargo.toml b/native/shared/Cargo.toml index 917d4dc..691ca00 100644 --- a/native/shared/Cargo.toml +++ b/native/shared/Cargo.toml @@ -50,5 +50,13 @@ web-time = { version = "0.2", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] notify = { version = "6", optional = true } +# EN-063 — the engine's error channel on web. `log_error`'s eprintln! is a +# black hole in a browser (no stderr), so every engine warning and error — +# missing texture-array layers, surface-acquire failures, feature-off +# warnings — was silently dropped on the one platform whose users can't +# attach a debugger. Route them to console.error instead. +[target.'cfg(target_arch = "wasm32")'.dependencies] +web-sys = { version = "0.3", features = ["console"] } + [dev-dependencies] pollster = "0.4" diff --git a/native/shared/src/engine.rs b/native/shared/src/engine.rs index a640230..e72a692 100644 --- a/native/shared/src/engine.rs +++ b/native/shared/src/engine.rs @@ -39,7 +39,7 @@ pub struct EngineState { pub decals: crate::decals::DecalManager, /// EN-025 — ragdoll slots. Bodies live in the Jolt world; this owns the /// bone->body mapping that turns them back into a skinned pose. - #[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] + #[cfg(all(feature = "models3d", feature = "jolt"))] pub ragdolls: crate::ragdoll::RagdollManager, // Timing @@ -100,7 +100,7 @@ impl EngineState { drs: DrsController::new(), particles: crate::particles::ParticleManager::new(), decals: crate::decals::DecalManager::new(), - #[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] + #[cfg(all(feature = "models3d", feature = "jolt"))] ragdolls: crate::ragdoll::RagdollManager::new(), target_fps: 60.0, delta_time: 0.0, diff --git a/native/shared/src/ffi.rs b/native/shared/src/ffi.rs index 117c78c..1163e03 100644 --- a/native/shared/src/ffi.rs +++ b/native/shared/src/ffi.rs @@ -104,6 +104,13 @@ pub fn log_error(msg: &str) { } return; } - #[cfg(not(target_os = "android"))] + // EN-063 — a browser has no stderr, so eprintln! drops the message + // entirely. Every engine diagnostic was invisible on web until this. + #[cfg(target_arch = "wasm32")] + { + web_sys::console::error_1(&msg.into()); + return; + } + #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))] eprintln!("{msg}"); } diff --git a/native/shared/src/lib.rs b/native/shared/src/lib.rs index f12fbe6..f4ea4be 100644 --- a/native/shared/src/lib.rs +++ b/native/shared/src/lib.rs @@ -29,7 +29,7 @@ pub mod staging; pub mod profiler; pub mod particles; pub mod decals; -#[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] +#[cfg(all(feature = "models3d", feature = "jolt"))] pub mod ragdoll; pub mod sdf_cache; // Jolt C ABI + Rust wrapper live on native only. On wasm32 the web crate diff --git a/native/shared/src/renderer/material_pipeline.rs b/native/shared/src/renderer/material_pipeline.rs index 9dc4d6c..bc1c438 100644 --- a/native/shared/src/renderer/material_pipeline.rs +++ b/native/shared/src/renderer/material_pipeline.rs @@ -40,6 +40,29 @@ impl MaterialAbiLayouts { } } +/// EN-063 — wasm32 ABI contract. WebGPU in the browser caps +/// `maxBindGroups` at 4, so the SceneInputs group (group 4 on native) +/// cannot exist on the web: one reads_scene pipeline would fail +/// creation and poison the whole frame's command buffer. On wasm32 +/// the seven scene-input bindings fold into the per_frame group +/// (group 0) at bindings `WASM_SCENE_INPUTS_BASE .. +6`, in the same +/// order and with the same types as `create_scene_inputs_layout`. +/// per_frame's only native binding is 0 (the PerFrame UBO), so the +/// folded block starts at 1. +/// +/// Three places must agree on this contract and all read this +/// constant: +/// 1. the shader-source rewrite in `compile_material` +/// (`rewrite_scene_inputs_for_wasm`), +/// 2. the wasm32 `create_per_frame_layout` below, +/// 3. `MaterialSystem`'s per_frame bind-group builder +/// (`build_per_frame_bg_wasm` in material_system.rs). +/// +/// Native targets keep the five-group layout bit-identically. +#[cfg(target_arch = "wasm32")] +pub const WASM_SCENE_INPUTS_BASE: u32 = 1; + +#[cfg(not(target_arch = "wasm32"))] fn create_per_frame_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("abi_per_frame"), @@ -56,6 +79,30 @@ fn create_per_frame_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { }) } +/// EN-063 — wasm32 variant: the PerFrame UBO at binding 0 plus the +/// seven folded SceneInputs entries (same types, same order as +/// `create_scene_inputs_layout`) at `WASM_SCENE_INPUTS_BASE..+6`. +/// Every bind group created against this layout must supply all +/// eight entries — see `build_per_frame_bg_wasm` in +/// material_system.rs, the single creation site. +#[cfg(target_arch = "wasm32")] +fn create_per_frame_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + const B: u32 = WASM_SCENE_INPUTS_BASE; + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("abi_per_frame"), + entries: &[ + entry_ubo(0, wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT), + entry_tex_f(B, wgpu::ShaderStages::FRAGMENT), + entry_samp(B + 1, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Filtering), + entry_tex_depth(B + 2, wgpu::ShaderStages::FRAGMENT), + entry_samp(B + 3, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::NonFiltering), + entry_tex_f_nonfilt(B + 4, wgpu::ShaderStages::FRAGMENT), + entry_samp(B + 5, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::NonFiltering), + entry_tex_f(B + 6, wgpu::ShaderStages::FRAGMENT), + ], + }) +} + fn create_per_view_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { // Mirror of the ABI header: UBO at 0, env colour + sampler at 1+2, // env diffuse at 3, BRDF LUT + sampler at 4+5, three cascades at @@ -363,6 +410,15 @@ pub fn compile_material( let source = BakedSource { entries: &entries }; let expanded = process(&source, desc.entry_path)?; + // EN-063 — WebGPU in the browser caps maxBindGroups at 4, so no + // shader module may declare group 4 on wasm32. Rewrite the seven + // engine-owned SceneInputs declarations from material_abi.wgsl + // into the per_frame group (group 0) at WASM_SCENE_INPUTS_BASE..+6. + // String replacement is safe here: the declarations exist only in + // engine-owned material_abi.wgsl with exactly this formatting. + #[cfg(target_arch = "wasm32")] + let expanded = rewrite_scene_inputs_for_wasm(expanded); + // 2. Create shader module. wgpu's WGSL parser surfaces errors as // panics through the default handler; we catch them by // pushing the scope and popping on failure. @@ -383,7 +439,10 @@ pub fn compile_material( Some(&layouts.per_material), Some(&layouts.per_draw), ]; - if desc.reads_scene { + // EN-063 — on wasm32 the scene inputs live inside per_frame + // (group 0, bindings WASM_SCENE_INPUTS_BASE..+6), so the pipeline + // layout stays at 4 groups: the browser caps maxBindGroups at 4. + if desc.reads_scene && cfg!(not(target_arch = "wasm32")) { bg_layouts.push(Some(&layouts.scene_inputs)); } let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -542,6 +601,42 @@ pub fn compile_material( }) } +/// EN-063 — fold the SceneInputs declarations into group 0 for wasm32. +/// +/// Rewrites each of the seven exact strings `@group(4) @binding(N)` +/// (N = 0..6) that material_abi.wgsl declares to +/// `@group(0) @binding(WASM_SCENE_INPUTS_BASE + N)`. The rewrite is +/// unconditional (not gated on reads_scene): every material includes +/// material_abi.wgsl, and a browser shader module must never declare +/// group 4 at all. Materials that don't use the scene inputs simply +/// leave the group-0 bindings statically unused, which wgpu ignores +/// at pipeline-layout validation exactly as it did for group 4. +#[cfg(target_arch = "wasm32")] +fn rewrite_scene_inputs_for_wasm(expanded: String) -> String { + let had_group4 = expanded.contains("@group(4)"); + let mut out = expanded; + let mut replaced: u32 = 0; + for n in 0..7u32 { + let from = format!("@group(4) @binding({n})"); + let to = format!("@group(0) @binding({})", WASM_SCENE_INPUTS_BASE + n); + replaced += out.matches(from.as_str()).count() as u32; + out = out.replace(from.as_str(), to.as_str()); + } + if had_group4 { + debug_assert_eq!( + replaced, 7, + "material_abi.wgsl scene-input declarations changed; \ + update the EN-063 wasm32 group-4 fold to match" + ); + debug_assert!( + !out.contains("@group(4)"), + "a @group(4) binding survived the EN-063 wasm32 fold — \ + group 4 exceeds the browser's maxBindGroups" + ); + } + out +} + // ===================================================================== // Baked library snapshot // ===================================================================== diff --git a/native/shared/src/renderer/material_system.rs b/native/shared/src/renderer/material_system.rs index 1cae954..fb6604d 100644 --- a/native/shared/src/renderer/material_system.rs +++ b/native/shared/src/renderer/material_system.rs @@ -389,6 +389,10 @@ impl MaterialSystem { usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); + // EN-063 — on wasm32 the per_frame group also carries the seven + // folded SceneInputs bindings, whose stub resources are only + // created further down; the wasm32 bind group is built there. + #[cfg(not(target_arch = "wasm32"))] let per_frame_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("material_per_frame_bg"), layout: &layouts.per_frame, @@ -635,6 +639,24 @@ impl MaterialSystem { }); let scene_stub_depth_view = scene_stub_depth.create_view(&Default::default()); + // EN-063 — wasm32 per_frame bind group: the UBO plus the seven + // folded SceneInputs slots, initially bound to the same stub + // resources `update_scene_inputs` falls back to. Rebuilt with + // the real snapshot views each frame by `update_scene_inputs`. + #[cfg(target_arch = "wasm32")] + let per_frame_bg = super::material_system_wasm::build_per_frame_bg_wasm( + device, + &layouts.per_frame, + &per_frame_buffer, + &scene_stub_view, // scene_color_tex stub + &scene_color_sampler, + &scene_stub_depth_view, // scene_depth_tex stub + &scene_depth_sampler, + &scene_stub_view, // impulse_tex stub + &scene_depth_sampler, // impulse_samp — NonFiltering, matches layout + &scene_stub_view, // motion_vectors stub + ); + Self { layouts, pipelines: Vec::new(), @@ -1871,6 +1893,28 @@ impl MaterialSystem { ], }); self.scene_inputs_bg = Some(bg); + + // EN-063 — on wasm32 the scene inputs are folded into the + // per_frame group (group 0), so the real snapshot views have + // to land there: rebuild per_frame_bg with the same seven + // resources bound above, at WASM_SCENE_INPUTS_BASE..+6. The + // opaque pass re-binds this group too, which is harmless — + // opaque materials never statically use the scene-input slots. + #[cfg(target_arch = "wasm32")] + { + self.per_frame_bg = super::material_system_wasm::build_per_frame_bg_wasm( + device, + &self.layouts.per_frame, + &self.per_frame_buffer, + scene_color_view, + &self._scene_color_sampler, + depth_view, + &self._scene_depth_sampler, + imp_view, + imp_samp, + &self._scene_stub_view, // motion_vectors stub + ); + } } /// Sort the translucent bucket back-to-front by view depth. Call @@ -1912,7 +1956,10 @@ impl MaterialSystem { pass.set_bind_group(0, &self.per_frame_bg, &[]); pass.set_bind_group(1, &self.per_view_bg, &[]); pass.set_bind_group(2, self.per_material_bg_for(cmd.material), &[]); - if mat.reads_scene { + // EN-063 — on wasm32 there is no group 4: the scene + // inputs are folded into per_frame (group 0), already + // bound above with the frame's snapshot views. + if mat.reads_scene && cfg!(not(target_arch = "wasm32")) { if let Some(bg) = self.scene_inputs_bg.as_ref() { pass.set_bind_group(4, bg, &[]); } @@ -1938,11 +1985,10 @@ impl MaterialSystem { } } -// ===================================================================== -// Tests -// ===================================================================== - +// `build_per_frame_bg_wasm` (the wasm32-only per_frame bind-group builder) +// lives in `material_system_wasm.rs` now — see the 2000-line policy note there. +// ---- Tests ---------------------------------------------------------- // GPU-backed tests live in material_system_tests.rs (2000-line file // policy); the #[path] keeps them a private child module of this one. #[cfg(test)] diff --git a/native/shared/src/renderer/material_system_wasm.rs b/native/shared/src/renderer/material_system_wasm.rs new file mode 100644 index 0000000..464379c --- /dev/null +++ b/native/shared/src/renderer/material_system_wasm.rs @@ -0,0 +1,41 @@ +//! wasm32-only material bind-group helpers, split out of +//! `material_system.rs` to keep that file under the 2000-line policy (the +//! same reason `material_system_tests.rs` is a `#[path]` child). The whole +//! module is gated `#[cfg(target_arch = "wasm32")]` at its declaration in +//! `renderer/mod.rs`, so nothing here compiles on native. + +/// EN-063 — wasm32-only per_frame bind group builder: the PerFrame UBO +/// at binding 0 plus the seven folded SceneInputs resources at +/// `WASM_SCENE_INPUTS_BASE..+6` (order and types mirror +/// `update_scene_inputs` / `create_scene_inputs_layout` exactly). The +/// single creation path for every bind group made against the wasm32 +/// `abi_per_frame` layout — that layout requires all eight entries. +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_per_frame_bg_wasm( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + per_frame_buffer: &wgpu::Buffer, + scene_color_view: &wgpu::TextureView, + scene_color_samp: &wgpu::Sampler, + scene_depth_view: &wgpu::TextureView, + scene_depth_samp: &wgpu::Sampler, + impulse_view: &wgpu::TextureView, + impulse_samp: &wgpu::Sampler, + motion_vectors_view: &wgpu::TextureView, +) -> wgpu::BindGroup { + use super::material_pipeline::WASM_SCENE_INPUTS_BASE as B; + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("material_per_frame_bg"), + layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: per_frame_buffer.as_entire_binding() }, + wgpu::BindGroupEntry { binding: B, resource: wgpu::BindingResource::TextureView(scene_color_view) }, + wgpu::BindGroupEntry { binding: B + 1, resource: wgpu::BindingResource::Sampler(scene_color_samp) }, + wgpu::BindGroupEntry { binding: B + 2, resource: wgpu::BindingResource::TextureView(scene_depth_view) }, + wgpu::BindGroupEntry { binding: B + 3, resource: wgpu::BindingResource::Sampler(scene_depth_samp) }, + wgpu::BindGroupEntry { binding: B + 4, resource: wgpu::BindingResource::TextureView(impulse_view) }, + wgpu::BindGroupEntry { binding: B + 5, resource: wgpu::BindingResource::Sampler(impulse_samp) }, + wgpu::BindGroupEntry { binding: B + 6, resource: wgpu::BindingResource::TextureView(motion_vectors_view) }, + ], + }) +} diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index 115d913..adefe7b 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -25,6 +25,10 @@ pub mod shader_include; pub mod shader_library; pub mod material_pipeline; pub mod material_system; +// wasm32-only material bind-group helpers, split out to keep material_system.rs +// under the 2000-line policy (EN-063). +#[cfg(target_arch = "wasm32")] +mod material_system_wasm; pub mod planar_reflection; pub mod graph; pub mod transient; @@ -869,6 +873,12 @@ pub struct Renderer { pub surface: Option>, headless_target: Option, pub surface_config: wgpu::SurfaceConfiguration, + /// EN-063 — the format frames are rendered in: equals + /// `surface_config.format` on native (already sRGB), or its sRGB view + /// variant on hosts whose surfaces are non-sRGB (WebGPU canvases). Every + /// pipeline that targets the swapchain, and the per-frame surface view, + /// use THIS; `surface_config.format` is only for `surface.configure`. + pub output_format: wgpu::TextureFormat, // Logical (points / CSS px) size — what user code addresses via // `screenWidth`/HUD coords. Physical render target size is stored @@ -2027,6 +2037,29 @@ impl Renderer { logical_width: u32, logical_height: u32, ) -> Self { + // EN-063 — the format frames are RENDERED in, as opposed to the + // format the surface is CONFIGURED with. The tonemapper writes + // LINEAR values and relies on hardware encode-on-store into an sRGB + // target — which every native platform's surface provides. Browsers + // don't: WebGPU canvases only offer bgra8/rgba8unorm, and rendering + // linear into them displayed the whole game roughly a stop dark. So + // when the surface format is non-sRGB, render through an sRGB VIEW + // of the swapchain image (registered via `view_formats`) — same + // hardware encode, no shader changes, and native platforms are + // untouched (their surface format already is sRGB, so + // output_format == surface_config.format there). + let mut surface_config = surface_config; + let output_format = match surface_config.format { + wgpu::TextureFormat::Bgra8Unorm => wgpu::TextureFormat::Bgra8UnormSrgb, + wgpu::TextureFormat::Rgba8Unorm => wgpu::TextureFormat::Rgba8UnormSrgb, + f => f, + }; + if output_format != surface_config.format + && !surface_config.view_formats.contains(&output_format) + { + surface_config.view_formats.push(output_format); + } + // Ticket 007b: HW ray-tracing availability is a device-feature // query, set by whichever platform crate constructed this // device. Renderer internals branch on this flag when picking @@ -2613,7 +2646,7 @@ impl Renderer { module: &shader_2d, entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { - format: surface_config.format, + format: output_format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], @@ -3923,7 +3956,7 @@ impl Renderer { module: &composite_shader, entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { - format: surface_config.format, + format: output_format, blend: None, write_mask: wgpu::ColorWrites::ALL, })], @@ -6912,7 +6945,7 @@ impl Renderer { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: surface_config.format, + format: output_format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING, @@ -6925,6 +6958,7 @@ impl Renderer { queue, surface, surface_config, + output_format, logical_width, logical_height, pipeline_2d, @@ -7542,7 +7576,7 @@ impl Renderer { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: self.surface_config.format, + format: self.output_format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING, @@ -7692,14 +7726,14 @@ impl Renderer { // pass only pay for slot A. if self.composite_ldr_rt_a.is_some() { let (t, v) = post_pass::create_composite_ldr_rt( - &self.device, width, height, self.surface_config.format, + &self.device, width, height, self.output_format, ); self.composite_ldr_rt_a = Some(t); self.composite_ldr_rt_a_view = Some(v); } if self.composite_ldr_rt_b.is_some() { let (t, v) = post_pass::create_composite_ldr_rt( - &self.device, width, height, self.surface_config.format, + &self.device, width, height, self.output_format, ); self.composite_ldr_rt_b = Some(t); self.composite_ldr_rt_b_view = Some(v); @@ -9273,6 +9307,30 @@ impl Renderer { self.load_env_from_hdr(w, h, &rgb_f32); } + /// In-memory twin of `set_env_clear_from_hdr_file` for hosts without a + /// filesystem (web fetches the .hdr bytes in JS glue). Same decode, same + /// `load_env_from_hdr` hand-off; no `not(wasm32)` gate because nothing + /// here touches the OS. + #[cfg(feature = "image-extras")] + pub fn set_env_clear_from_hdr_bytes(&mut self, data: &[u8]) { + use image::ImageDecoder; + let decoder = match image::codecs::hdr::HdrDecoder::new(std::io::Cursor::new(data)) { + Ok(d) => d, + Err(_) => return, + }; + let (w, h) = decoder.dimensions(); + let byte_len = (w as usize) * (h as usize) * 3 * 4; + let mut buf = vec![0u8; byte_len]; + if decoder.read_image(&mut buf).is_err() { + return; + } + let rgb_f32: Vec = buf + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + self.load_env_from_hdr(w, h, &rgb_f32); + } + /// `width * height * 3` packed f32 RGB triples in linear space — /// the output of `image::codecs::hdr::HdrDecoder::read_image()` /// laid out row-major. Replaces any previously-loaded env. @@ -10332,7 +10390,10 @@ impl Renderer { view = rt_view.clone(); owned_depth_view = rt_depth.as_ref().unwrap().clone(); } else { - view = self.frame_texture(surface_output.as_ref().unwrap()).create_view(&wgpu::TextureViewDescriptor::default()); + view = self.frame_texture(surface_output.as_ref().unwrap()).create_view(&wgpu::TextureViewDescriptor { + format: Some(self.output_format), + ..Default::default() + }); owned_depth_view = self.depth_texture.create_view(&wgpu::TextureViewDescriptor::default()); } @@ -10528,7 +10589,14 @@ impl Renderer { let view = if let Some(ref rt_view) = rt_color { rt_view.clone() } else { - self.frame_texture(output.as_ref().unwrap()).create_view(&wgpu::TextureViewDescriptor::default()) + // EN-063 — name the format explicitly: the web surface view must be + // created as `output_format` (the swapchain's view format), not the + // texture's own, or the final blit format-mismatches on wasm. On + // native the two are equal, so this is a no-op there. + self.frame_texture(output.as_ref().unwrap()).create_view(&wgpu::TextureViewDescriptor { + format: Some(self.output_format), + ..Default::default() + }) }; // Restore so the views persist until end_texture_mode clears them. self.rt_color_view = rt_color; @@ -12257,7 +12325,9 @@ impl Renderer { } pub fn surface_format(&self) -> wgpu::TextureFormat { - self.surface_config.format + // The RENDER format (sRGB view on web), not the configure format — + // post passes and user post-pass pipelines target frame views. + self.output_format } /// Capture the current framebuffer as RGBA pixels. @@ -12462,7 +12532,7 @@ impl Renderer { module: &shader_module, entry_point: Some("fs_main_3d"), targets: &[Some(wgpu::ColorTargetState { - format: self.surface_config.format, + format: self.output_format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], @@ -12626,7 +12696,7 @@ impl Renderer { &mut self, wgsl_source: &str, ) -> Result { let pipeline = post_pass::compile_post_pass( - &self.device, wgsl_source, self.surface_config.format, + &self.device, wgsl_source, self.output_format, )?; // Slot A is needed the moment we have any post-pass at all @@ -12637,7 +12707,7 @@ impl Renderer { &self.device, self.surface_config.width, self.surface_config.height, - self.surface_config.format, + self.output_format, ); self.composite_ldr_rt_a = Some(t); self.composite_ldr_rt_a_view = Some(v); @@ -12650,7 +12720,7 @@ impl Renderer { &self.device, self.surface_config.width, self.surface_config.height, - self.surface_config.format, + self.output_format, ); self.composite_ldr_rt_b = Some(t); self.composite_ldr_rt_b_view = Some(v); diff --git a/native/shared/src/renderer/scene_pass.rs b/native/shared/src/renderer/scene_pass.rs index d5aad66..719b7e5 100644 --- a/native/shared/src/renderer/scene_pass.rs +++ b/native/shared/src/renderer/scene_pass.rs @@ -75,6 +75,17 @@ impl Renderer { occlusion_query_set: None, multiview_mask: None, }); + // EN-063 — on wasm the prepass DRAWS are skipped (the pass still + // owns the depth clear). The prepass/main pairing requires the two + // pipelines to produce bit-identical depths; Tint (the browser's + // WGSL compiler) does not preserve that even under @invariant once + // the foliage-wind displacement is in the chain, and the main + // pass's Equal test then discards whole leaf cards (white torn + // canopies, streaks). The main pass runs the classic Less+write + // pipeline on wasm instead — overdraw over early-Z, correctness + // over speed. + #[cfg(not(target_arch = "wasm32"))] + { pass.set_pipeline(&self.scene_depth_pipeline); pass.set_bind_group(1, &self.lighting_bind_group, &[]); pass.set_bind_group(3, &self.joint_bind_group, &[]); @@ -103,6 +114,7 @@ impl Renderer { } } } + } } profiler.end("depth_prepass"); @@ -230,7 +242,11 @@ impl Renderer { // write, Equal test), because the depth prepass above already stored // their exact depth. That is what lets the hardware early-Z reject the // occluded leaf cards instead of shading every one of them. + #[cfg(not(target_arch = "wasm32"))] pass.set_pipeline(&self.scene_pipeline_prepassed); + // wasm: no prepass priming (see above) — classic Less + write. + #[cfg(target_arch = "wasm32")] + pass.set_pipeline(&self.scene_pipeline); pass.set_bind_group(1, &self.lighting_bind_group, &[]); pass.set_bind_group(3, &self.joint_bind_group, &[]); diff --git a/native/shared/src/renderer/shaders/ao.rs b/native/shared/src/renderer/shaders/ao.rs index 89582da..8a974e2 100644 --- a/native/shared/src/renderer/shaders/ao.rs +++ b/native/shared/src/renderer/shaders/ao.rs @@ -526,7 +526,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let texel = u.params.xy; let d_sigma = u.params.z; - let center_depth = textureSample(depth_tex, depth_samp, in.uv); + let center_depth = textureSampleLevel(depth_tex, depth_samp, in.uv, 0u); // Sky pixels: no occlusion, no contact shadow. if (center_depth >= 0.9999) { @@ -546,8 +546,8 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let offset = vec2(f32(dx), f32(dy)) * texel; let s_uv = in.uv + offset; - let s_ao = textureSample(ao_tex, ao_samp, s_uv).r; - let s_depth = textureSample(depth_tex, depth_samp, s_uv); + let s_ao = textureSampleLevel(ao_tex, ao_samp, s_uv, 0.0).r; + let s_depth = textureSampleLevel(depth_tex, depth_samp, s_uv, 0u); let gx = GAUSS5[dx + 2]; let gy = GAUSS5[dy + 2]; @@ -562,7 +562,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { } } - let center = textureSample(ao_tex, ao_samp, in.uv); + let center = textureSampleLevel(ao_tex, ao_samp, in.uv, 0.0); let ao_blurred = select(center.r, ao_sum / weight_sum, weight_sum > 0.0001); return vec4(ao_blurred, center.g, 0.0, 1.0); } diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index a7d8e89..f9dfd85 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -458,11 +458,11 @@ fn vs_main_scene(in: VertexInputScene) -> VertexOutputScene { // results to pre-baked tangents for continuous UV mappings, which is // the common case for PBR assets. We use this as a fallback when the // mesh has no TANGENT accessor (very common — e.g., DamagedHelmet). -fn compute_tbn(world_pos: vec3, n: vec3, uv: vec2) -> mat3x3 { - let dp1 = dpdx(world_pos); - let dp2 = dpdy(world_pos); - let duv1 = dpdx(uv); - let duv2 = dpdy(uv); +// The four screen-space derivatives are taken by the CALLER in uniform +// control flow and passed in: this function is reached from the per-fragment +// "mesh has no tangents" branch, and WGSL's uniformity analysis (enforced by +// Tint on WebGPU) rejects dpdx/dpdy inside non-uniform flow. +fn compute_tbn(dp1: vec3, dp2: vec3, duv1: vec2, duv2: vec2, n: vec3) -> mat3x3 { let dp2perp = cross(dp2, n); let dp1perp = cross(n, dp1); let t = dp2perp * duv1.x + dp1perp * duv2.x; @@ -554,11 +554,11 @@ fn sample_cascade(cascade: i32, shadow_uv: vec2, depth_ref: f32) -> f32 { let off = poisson[i] * texel * radius; let uv = shadow_uv + off; if (cascade == 0) { - sum += textureSampleCompare(shadow_tex_0, shadow_samp, uv, depth_ref); + sum += textureSampleCompareLevel(shadow_tex_0, shadow_samp, uv, depth_ref); } else if (cascade == 1) { - sum += textureSampleCompare(shadow_tex_1, shadow_samp, uv, depth_ref); + sum += textureSampleCompareLevel(shadow_tex_1, shadow_samp, uv, depth_ref); } else { - sum += textureSampleCompare(shadow_tex_2, shadow_samp, uv, depth_ref); + sum += textureSampleCompareLevel(shadow_tex_2, shadow_samp, uv, depth_ref); } } return sum / 16.0; @@ -795,6 +795,13 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { let baked_variance = nm_sample4.w; let toksvig_len2 = clamp(dot(nm_raw, nm_raw), 0.01, 1.0); let nm_sample = nm_raw * inverseSqrt(toksvig_len2); + // Derivatives for the no-tangent TBN fallback, taken here in uniform + // control flow (inside the branch below they would fail WGSL uniformity + // analysis on WebGPU). + let tbn_dp1 = dpdx(in.world_pos); + let tbn_dp2 = dpdy(in.world_pos); + let tbn_duv1 = dpdx(in.uv); + let tbn_duv2 = dpdy(in.uv); let tlen2 = dot(in.tangent.xyz, in.tangent.xyz); if (tlen2 > 0.0001) { let t = normalize(in.tangent.xyz); @@ -802,7 +809,7 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { let b = cross(n, t_ortho) * in.tangent.w; n = normalize(t_ortho * nm_sample.x + b * nm_sample.y + n * nm_sample.z); } else { - let tbn = compute_tbn(in.world_pos, n, in.uv); + let tbn = compute_tbn(tbn_dp1, tbn_dp2, tbn_duv1, tbn_duv2, n); n = normalize(tbn * nm_sample); } diff --git a/native/shared/src/renderer/shaders/post.rs b/native/shared/src/renderer/shaders/post.rs index 8996ff6..6911bc9 100644 --- a/native/shared/src/renderer/shaders/post.rs +++ b/native/shared/src/renderer/shaders/post.rs @@ -239,7 +239,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let aperture = u.params.y; let max_blur = u.params.z; - let center_depth_raw = textureSample(depth_tex, depth_samp, in.uv); + let center_depth_raw = textureSampleLevel(depth_tex, depth_samp, in.uv, 0u); // Sky pixels (depth ~1.0) get maximum blur — they are at infinity. var view_z: f32; @@ -259,26 +259,26 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // If CoC is negligibly small, return the source pixel unchanged. let threshold = 0.0005; if (coc < threshold) { - return textureSample(color_tex, color_samp, in.uv); + return textureSampleLevel(color_tex, color_samp, in.uv, 0.0); } // Gather 16 Poisson disc samples scaled by CoC. var color_sum = vec3(0.0); var weight_sum = 0.0; - let center_color = textureSample(color_tex, color_samp, in.uv).rgb; + let center_color = textureSampleLevel(color_tex, color_samp, in.uv, 0.0).rgb; for (var i = 0u; i < 16u; i = i + 1u) { let offset = POISSON_16[i] * coc; let sample_uv = in.uv + offset; - let sample_color = textureSample(color_tex, color_samp, sample_uv).rgb; + let sample_color = textureSampleLevel(color_tex, color_samp, sample_uv, 0.0).rgb; // Read the depth at the sample location to compute its own CoC. // This prevents sharp foreground objects from bleeding into // blurred background — only samples that are themselves blurry // (or at least as blurry as this pixel) contribute fully. - let sample_depth_raw = textureSample(depth_tex, depth_samp, sample_uv); + let sample_depth_raw = textureSampleLevel(depth_tex, depth_samp, sample_uv, 0u); var sample_z: f32; if (sample_depth_raw >= 0.9999) { sample_z = 1000.0; @@ -343,7 +343,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let strength = u.params.x; let max_blur = u.params.y; - let vel_raw = textureSample(velocity_tex, velocity_samp, in.uv).rg; + let vel_raw = textureSampleLevel(velocity_tex, velocity_samp, in.uv, 0.0).rg; // Scale velocity by strength and clamp to max_blur radius. var vel = vel_raw * strength; let vel_len = length(vel); @@ -353,7 +353,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // If velocity is negligible, return source pixel unchanged. if (vel_len < 0.0001) { - return textureSample(color_tex, color_samp, in.uv); + return textureSampleLevel(color_tex, color_samp, in.uv, 0.0); } // 8-tap directional blur with tent (linear) weighting. @@ -367,7 +367,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let t = (f32(i) + 0.5) / f32(n_samples) - 0.5; // range [-0.5, 0.5) let sample_uv = in.uv + vel * t; let w = 1.0 - abs(t * 2.0); // tent weight: 1.0 at center, 0 at edges - color_sum += textureSample(color_tex, color_samp, sample_uv).rgb * w; + color_sum += textureSampleLevel(color_tex, color_samp, sample_uv, 0.0).rgb * w; weight_sum += w; } @@ -424,12 +424,12 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let width = u.params.y; let falloff = u.params.z; - let center_color = textureSample(color_tex, color_samp, in.uv); + let center_color = textureSampleLevel(color_tex, color_samp, in.uv, 0.0); // Sky pixels (raw depth ~1.0) skip SSS entirely — they have no // geometry to scatter through. This also avoids depth-edge halos // at the horizon. - let center_depth = textureSample(depth_tex, depth_samp, in.uv); + let center_depth = textureSampleLevel(depth_tex, depth_samp, in.uv, 0u); if (center_depth >= 0.9999) { return center_color; } @@ -453,9 +453,9 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // Bilateral depth weight — each channel uses its own tap UV, // so we sample depth at each channel's location independently. - let d_r = textureSample(depth_tex, depth_samp, tap_r); - let d_g = textureSample(depth_tex, depth_samp, tap_g); - let d_b = textureSample(depth_tex, depth_samp, tap_b); + let d_r = textureSampleLevel(depth_tex, depth_samp, tap_r, 0u); + let d_g = textureSampleLevel(depth_tex, depth_samp, tap_g, 0u); + let d_b = textureSampleLevel(depth_tex, depth_samp, tap_b, 0u); let w_r = exp(-abs(d_r - center_depth) * falloff); let w_g = exp(-abs(d_g - center_depth) * falloff); @@ -466,9 +466,9 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let dist2 = dot(DISC_9[i], DISC_9[i]); let gauss = exp(-dist2 * 2.0); // sigma ≈ 0.7 in disc-space - let c_r = textureSample(color_tex, color_samp, tap_r).r; - let c_g = textureSample(color_tex, color_samp, tap_g).g; - let c_b = textureSample(color_tex, color_samp, tap_b).b; + let c_r = textureSampleLevel(color_tex, color_samp, tap_r, 0.0).r; + let c_g = textureSampleLevel(color_tex, color_samp, tap_g, 0.0).g; + let c_b = textureSampleLevel(color_tex, color_samp, tap_b, 0.0).b; sum_r += c_r * w_r * gauss; sum_g += c_g * w_g * gauss; @@ -555,10 +555,10 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // at its pass; SSGI is raw indirect radiance, multiplied here by // the receiver albedo so dark materials absorb correctly. Bloom // is scaled by the user-tuned intensity. - let hdr = textureSample(hdr_tex, hdr_samp, in.uv).rgb; - let ssr = textureSample(ssr_tex, ssr_samp, in.uv).rgb; - let ssgi = textureSample(ssgi_tex, ssgi_samp, in.uv).rgb; - let albedo_sample = textureSample(albedo_tex, albedo_samp, in.uv); + let hdr = textureSampleLevel(hdr_tex, hdr_samp, in.uv, 0.0).rgb; + let ssr = textureSampleLevel(ssr_tex, ssr_samp, in.uv, 0.0).rgb; + let ssgi = textureSampleLevel(ssgi_tex, ssgi_samp, in.uv, 0.0).rgb; + let albedo_sample = textureSampleLevel(albedo_tex, albedo_samp, in.uv, 0.0); let albedo = albedo_sample.rgb; // albedo.a carries `1 - shadow_factor` from the scene pass — how // much of this pixel's illumination is indirect (IBL + bounce) vs. @@ -567,11 +567,11 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // indirect-dominated portion. Sky pixels carry 0 here so fog / AO // don't touch them. let indirect_weight = albedo_sample.a; - let bloom = textureSample(bloom_tex, bloom_samp, in.uv).rgb; + let bloom = textureSampleLevel(bloom_tex, bloom_samp, in.uv, 0.0).rgb; var color = hdr + ssr + ssgi * albedo + bloom * u.misc.x; // World-space position from depth for fog ray march. - let depth = textureSample(depth_tex, depth_samp, in.uv); + let depth = textureSampleLevel(depth_tex, depth_samp, in.uv, 0u); let ndc = vec4(in.uv.x * 2.0 - 1.0, (1.0 - in.uv.y) * 2.0 - 1.0, depth, 1.0); let world_h = u.inv_vp * ndc; let world = world_h.xyz / world_h.w; @@ -660,7 +660,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { if (pos.x < 0.0 || pos.x > 1.0 || pos.y < 0.0 || pos.y > 1.0) { continue; } - let d = textureSample(depth_tex, depth_samp, pos); + let d = textureSampleLevel(depth_tex, depth_samp, pos, 0u); let sky = smoothstep(0.998, 1.0, d); accum = accum + sky * weight; weight = weight * decay; @@ -804,12 +804,12 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let current = current_sample.rgb; let current_w = current_sample.a; - let depth = textureSample(depth_tex, depth_samp, in.uv); + let depth = textureSampleLevel(depth_tex, depth_samp, in.uv, 0u); let ndc = vec4(in.uv.x * 2.0 - 1.0, (1.0 - in.uv.y) * 2.0 - 1.0, depth, 1.0); let world_h = u.inv_vp * ndc; let world = world_h.xyz / world_h.w; - let vel = textureSample(velocity_tex, velocity_samp, in.uv).rg; + let vel = textureSampleLevel(velocity_tex, velocity_samp, in.uv, 0.0).rg; let vel_len = length(vel); var prev_uv: vec2; if (vel_len > 0.00001) { @@ -838,7 +838,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { var history = current; var history_w = current_w; if (prev_uv.x >= 0.0 && prev_uv.x <= 1.0 && prev_uv.y >= 0.0 && prev_uv.y <= 1.0) { - let h_sample = textureSample(history_tex, history_samp, prev_uv); + let h_sample = textureSampleLevel(history_tex, history_samp, prev_uv, 0.0); history = h_sample.rgb; history_w = h_sample.a; } @@ -857,7 +857,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { 1.0 / f32(textureDimensions(composed_tex).y)); // Neighborhood statistics track the same unjittered position, or the // clamp window would wobble against the sample it bounds. - let center_rgb = textureSample(composed_tex, composed_samp, src_uv).rgb; + let center_rgb = textureSampleLevel(composed_tex, composed_samp, src_uv, 0.0).rgb; var m1 = rgb_to_ycocg(center_rgb); var m2 = m1 * m1; let n_samples = 9.0; @@ -865,7 +865,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { for (var x = -1; x <= 1; x = x + 1) { if (x == 0 && y == 0) { continue; } let s_uv = src_uv + vec2(f32(x), f32(y)) * texel; - let s_rgb = textureSample(composed_tex, composed_samp, s_uv).rgb; + let s_rgb = textureSampleLevel(composed_tex, composed_samp, s_uv, 0.0).rgb; let s = rgb_to_ycocg(s_rgb); m1 = m1 + s; m2 = m2 + s * s; diff --git a/native/shared/src/renderer/shaders/ssgi.rs b/native/shared/src/renderer/shaders/ssgi.rs index d1a3fe3..2e6b6d0 100644 --- a/native/shared/src/renderer/shaders/ssgi.rs +++ b/native/shared/src/renderer/shaders/ssgi.rs @@ -1287,7 +1287,7 @@ fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut { @fragment fn fs_main(in: VsOut) -> @location(0) vec4 { - let current_raw = textureSample(current_tex, current_samp, in.uv); + let current_raw = textureSampleLevel(current_tex, current_samp, in.uv, 0.0); // 3×3 box pre-filter + neighborhood min/max. One texel spread // across 9 samples hides single-pixel glossy-ray sparkles in a @@ -1299,7 +1299,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { var prefilt = vec4(0.0); for (var y = -1; y <= 1; y++) { for (var x = -1; x <= 1; x++) { - let s = textureSample(current_tex, current_samp, in.uv + vec2(f32(x), f32(y)) * texel); + let s = textureSampleLevel(current_tex, current_samp, in.uv + vec2(f32(x), f32(y)) * texel, 0.0); nmin = min(nmin, s); nmax = max(nmax, s); prefilt = prefilt + s; @@ -1310,12 +1310,12 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // Velocity is full-res; UV mapping handles the half-res delta. // NDC-space velocity + UV Y-flip → `uv + vel.y` for the Y axis, // matching TAA + SSAO + the sibling SSGI temporal pass. - let vel = textureSample(velocity_tex, velocity_samp, in.uv).xy; + let vel = textureSampleLevel(velocity_tex, velocity_samp, in.uv, 0.0).xy; let prev_uv = vec2(in.uv.x - vel.x, in.uv.y + vel.y); let off_screen = prev_uv.x < 0.0 || prev_uv.x > 1.0 || prev_uv.y < 0.0 || prev_uv.y > 1.0; if (off_screen) { return current; } - let history_raw = textureSample(history_tex, history_samp, prev_uv); + let history_raw = textureSampleLevel(history_tex, history_samp, prev_uv, 0.0); // Scrub NaN/Inf from the history read. Until a clean SSR frame // finishes draining the ping-pong pair, any poisoned history // pixel would otherwise survive the clamp (clamp(NaN, a, b) is @@ -1452,7 +1452,15 @@ fn importance_sample_ggx(xi: vec2, n: vec3, roughness: f32) -> vec3 @location(0) vec4 { - let depth = textureSample(depth_tex, depth_samp, in.uv); + let depth = textureSampleLevel(depth_tex, depth_samp, in.uv, 0i); + // Derivatives must come from uniform control flow (WGSL uniformity + // analysis; Tint/WebGPU enforces what naga lets slide) — take them + // BEFORE the early returns. A few ALU on sky pixels, and the quad + // derivatives are actually well-defined now instead of reading + // helper-lane garbage at the sky/geometry boundary. + let view_pos = view_pos_from_depth(in.uv, depth); + let dx = dpdx(view_pos); + let dy = dpdy(view_pos); if (depth >= 0.9999) { return vec4(0.0); } // sky // SSR for every smooth-enough surface — the metals-only gate is gone. @@ -1466,16 +1474,13 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // spec by design and now get the coherent hit-or-env behaviour too. // Very rough surfaces still fade to pure IBL where one-ray-per-pixel // SSR noise would dominate even after temporal accumulation. - let mat = textureSample(mat_tex, mat_samp, in.uv).rg; + let mat = textureSampleLevel(mat_tex, mat_samp, in.uv, 0.0).rg; let metallic = mat.r; let roughness = mat.g; - let albedo = textureSample(albedo_tex, albedo_samp, in.uv).rgb; + let albedo = textureSampleLevel(albedo_tex, albedo_samp, in.uv, 0.0).rgb; let roughness_fade = 1.0 - smoothstep(0.5, 0.85, roughness); if (roughness_fade <= 0.001) { return vec4(0.0); } - let view_pos = view_pos_from_depth(in.uv, depth); - let dx = dpdx(view_pos); - let dy = dpdy(view_pos); let n = normalize(cross(dx, dy)); let v = normalize(-view_pos); @@ -1570,7 +1575,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // self-compare is applied to the HDR tap in case upstream writes a // bad sample (autoexposure ratios, rare shader ops on degenerate // triangles, etc). - let raw = textureSample(hdr_tex, hdr_samp, hit_uv).rgb; + let raw = textureSampleLevel(hdr_tex, hdr_samp, hit_uv, 0.0).rgb; let reflected = select(vec3(0.0), raw, raw == raw); let out = reflected * fresnel * roughness_fade * u.params.x * fade; let out_safe = select(vec3(0.0), out, out == out); diff --git a/native/shared/src/textures.rs b/native/shared/src/textures.rs index a7dbb30..9688af1 100644 --- a/native/shared/src/textures.rs +++ b/native/shared/src/textures.rs @@ -64,6 +64,16 @@ impl TextureManager { } } + /// Decode an in-memory image (PNG/JPEG/…, whatever `image` is built with) + /// to raw RGBA8. Shared by the web texture-array-from-files path, which + /// fetches file bytes in JS and needs the same decode the native + /// from-files FFI gets from `image::open`. + pub fn decode_rgba8(file_data: &[u8]) -> Option<(Vec, u32, u32)> { + let img = image::load_from_memory(file_data).ok()?.to_rgba8(); + let (w, h) = img.dimensions(); + Some((img.into_raw(), w, h)) + } + pub fn load_texture(&mut self, renderer: &mut Renderer, file_data: &[u8]) -> f64 { // Cooked textures (bloom-cook BC7 DDS) take the compressed path: // direct mip-chain upload where the adapter has BC support, CPU diff --git a/native/web/Cargo.lock b/native/web/Cargo.lock index da71918..0bf658b 100644 --- a/native/web/Cargo.lock +++ b/native/web/Cargo.lock @@ -101,6 +101,7 @@ dependencies = [ "lewton", "libc", "raw-window-handle", + "web-sys", "web-time", "wgpu", ] diff --git a/native/web/bloom_glue.js b/native/web/bloom_glue.js index d4aa60c..9641bb5 100644 --- a/native/web/bloom_glue.js +++ b/native/web/bloom_glue.js @@ -7,12 +7,19 @@ * * Responsibilities: * 1. Load the Bloom rendering engine WASM (`pkg/bloom_web.js`, wasm-pack). - * 2. Initialise JoltPhysics.js (best-effort). - * 3. Publish `globalThis.__ffiImports` — the `bloom_*` functions the Perry + * 2. Initialise JoltPhysics.js (best-effort; local vendored copy, then CDN). + * 3. Prefetch the game's asset tree (assets_manifest.json, if the build + * emitted one) with a progress readout, so the game's synchronous loads + * hit a warm cache instead of issuing one blocking XHR per file. + * 4. Publish `globalThis.__ffiImports` — the `bloom_*` functions the Perry * game WASM imports under its `"ffi"` namespace. - * 4. Wire DOM input, HiDPI canvas sizing, Web Audio, and the rAF game loop. - * 5. Resolve `window.__bloomReady` so the gated `bootPerryWasm(...)` call in - * the page can instantiate the game WASM *after* the engine + FFI are live. + * 5. Pre-initialise the wgpu window and wait for the async device setup, + * so the game's synchronous `main()` never races "Engine not + * initialized" (Perry main is sync; wgpu init is async). + * 6. Wire DOM input (keyboard/mouse/touch/pointer-lock), HiDPI canvas + * sizing, Web Audio, and the rAF game loop. + * 7. Resolve `window.__bloomReady` so the gated `bootPerryWasm(...)` call + * in the page can instantiate the game WASM *after* all of the above. * * --- The FFI value contract (important) --- * Perry's WASM runtime (`wasm_runtime.js`, embedded in the page) wraps the @@ -33,6 +40,19 @@ let gameCallback = null; // Perry closure handle captured from bloom_run_game let gameRunning = false; let rafId = null; +// --- Asset prefetch cache --- +// path → Uint8Array. Filled from assets_manifest.json before the game boots; +// syncFetchBytes serves from it and only falls back to a blocking XHR for +// paths the manifest didn't know about. +const assetCache = new Map(); +let manifestPaths = null; // Set when a manifest was loaded + +// --- Pointer lock state --- +// The game expresses intent through bloom_disable_cursor/enable_cursor; the +// browser only grants pointer lock inside a user gesture, and revokes it on +// ESC without delivering the keydown. Both mismatches are reconciled here. +let wantPointerLock = false; + /** * Idempotent engine bootstrap. Safe to call multiple times; only the first * call performs work. Returns the resolved Bloom wasm-bindgen module. @@ -41,24 +61,40 @@ export async function bootBloomGame() { if (booted) return bloomModule; booted = true; + const loading = document.getElementById('loading'); + const setStatus = (text) => { if (loading) loading.textContent = text; }; + // 1. Load Bloom engine WASM. + setStatus('Loading engine…'); await init(); bloomModule = bloom; // 2. Initialise Jolt physics (WASM build of Jolt). The jolt_bridge.js module // wasm-bindgen bundled into pkg/snippets/ owns all physics state; we reach // it via the Rust-exported helper so bloom_physics_* talk to one instance. - // Override globalThis.__joltFactory to self-host instead of the CDN. + // Resolution order: explicit override, a vendored local copy (offline + // builds), then the CDN. try { - const factory = globalThis.__joltFactory - ?? (await import('https://cdn.jsdelivr.net/npm/jolt-physics@1.0.0/+esm')).default; + let factory = globalThis.__joltFactory; + if (!factory) { + try { + factory = (await import(new URL('./jolt-physics.mjs', import.meta.url))).default; + } catch { + factory = (await import('https://cdn.jsdelivr.net/npm/jolt-physics@1.0.0/+esm')).default; + } + } await bloom.bloom_physics_init_jolt(factory); console.log('[bloom] Jolt physics ready'); } catch (e) { console.warn('[bloom] Jolt init failed:', e, '- bloom_physics_* calls will be no-ops'); } - // 3. Publish the FFI surface for the Perry game WASM. + // 3. Prefetch the asset tree, if the build emitted a manifest. Progress is + // the one thing a frozen synchronous boot cannot show, so it is shown + // HERE, before the game WASM gets control. + await prefetchAssets(setStatus); + + // 4. Publish the FFI surface for the Perry game WASM. const ffi = buildFfiImports(); if (typeof globalThis.__ffiImports === 'undefined') { globalThis.__ffiImports = ffi; @@ -66,17 +102,82 @@ export async function bootBloomGame() { Object.assign(globalThis.__ffiImports, ffi); } - // 4. DOM wiring (input + HiDPI canvas sizing). + // 5. DOM wiring (input + HiDPI canvas sizing + audio unlock). setupDomBridge(); - // 5. Hide the loading indicator, if present. - const loading = document.getElementById('loading'); - if (loading) loading.style.display = 'none'; + // 6. Pre-initialise the window and WAIT for the async wgpu setup. Perry's + // main() is synchronous and calls engine functions immediately after its + // own initWindow (which is an idempotent no-op once this ran) — booting + // the game before the device exists panics with "Engine not initialized". + setStatus('Starting renderer…'); + // Probe for an actual WebGPU adapter first: having the API surface + // (navigator.gpu) is not having an adapter — Chrome ships the API even + // when acceleration is off or the GPU is blocklisted, and wgpu would then + // panic inside the wasm. Fail here with an actionable message instead. + if (!navigator.gpu) { + throw new Error('This browser has no WebGPU support. Use a current ' + + 'Chrome/Edge (113+) or Firefox (141+).'); + } + const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }) + .catch(() => null); + if (!adapter) { + throw new Error('No WebGPU adapter available. Check chrome://gpu — ' + + 'hardware acceleration must be enabled and the GPU not blocklisted.'); + } + const canvas = document.getElementById('bloom-canvas'); + const w = (canvas && canvas.clientWidth) || window.innerWidth || 1280; + const h = (canvas && canvas.clientHeight) || window.innerHeight || 720; + bloom.bloom_init_window(w, h, 0, 0); + const deadline = performance.now() + 15000; + while (bloom.bloom_is_initialized() < 0.5) { + if (performance.now() > deadline) { + throw new Error( + 'WebGPU device setup timed out. Check chrome://gpu — a WebGPU adapter ' + + 'is required (hardware acceleration on, GPU not blocklisted).'); + } + await new Promise((r) => setTimeout(r, 16)); + } + // 7. Hand over to the game. Keep the loading indicator up — the game's own + // synchronous boot (asset decode, pool init) runs next; remove it on the + // first real frame instead. + setStatus('Starting game…'); console.log('[bloom] engine + FFI ready'); return bloomModule; } +/** Fetch assets_manifest.json and warm the cache, 8 files at a time. */ +async function prefetchAssets(setStatus) { + let manifest = null; + try { + const res = await fetch('assets_manifest.json'); + if (res.ok) manifest = await res.json(); + } catch { /* no manifest = per-file sync XHR, same as before */ } + if (!manifest || !Array.isArray(manifest.files)) return; + + manifestPaths = new Set(manifest.files); + const queue = manifest.files.slice(); + const total = queue.length; + let done = 0; + setStatus(`Loading assets… 0/${total}`); + + async function worker() { + for (;;) { + const path = queue.shift(); + if (path === undefined) return; + try { + const res = await fetch(path); + if (res.ok) assetCache.set(path, new Uint8Array(await res.arrayBuffer())); + } catch { /* miss -> the sync XHR fallback will report it */ } + done++; + if (done % 5 === 0 || done === total) setStatus(`Loading assets… ${done}/${total}`); + } + } + const workers = []; + for (let i = 0; i < 8; i++) workers.push(worker()); + await Promise.all(workers); +} + /** * Build the `bloom_*` FFI import object. Defaults pass straight through to the * matching wasm-bindgen export (correct for all numeric-in/out and string-out @@ -114,10 +215,30 @@ function buildFfiImports() { const strFn = bloom[name + '_str']; if (strFn) imports[name] = (source) => strFn(String(source)); } - // compile_material_from_file: no web filesystem — fetch the source, compile it. - imports.bloom_compile_material_from_file = (path, _bucketKind) => { + imports.bloom_compile_material_instanced_bucket = (source, bucket, readsScene) => + bloom.bloom_compile_material_instanced_bucket(String(source), bucket, readsScene); + + // compile_material_from_file: no web filesystem — fetch the source and + // compile it into the requested bucket (0 opaque | 1 transparent | + // 2 refractive | 3 additive | 4 cutout — the TS wrapper's encoding). + imports.bloom_compile_material_from_file = (path, bucketKind) => { const src = syncFetchText(String(path)); - return src != null ? bloom.bloom_compile_material_str(src) : 0; + if (src == null) return 0; + switch (bucketKind | 0) { + case 1: return bloom.bloom_compile_material_transparent_str(src); + case 2: return bloom.bloom_compile_material_refractive_str(src); + case 3: return bloom.bloom_compile_material_additive_str(src); + case 4: return bloom.bloom_compile_material_cutout_str(src); + default: return bloom.bloom_compile_material_str(src); + } + }; + + // Material params via the legacy pointer signature: Perry's wrapFfiForI64 + // hands us the array as a plain JS array, so route it to the floats entry. + imports.bloom_set_material_params = (handle, params, _count) => { + if (params && typeof params.length === 'number') { + bloom.bloom_set_material_params_floats(handle, Float32Array.from(params)); + } }; // --- Asset loading: fetch the file synchronously, hand bytes to _bytes --- @@ -129,6 +250,8 @@ function buildFfiImports() { bloom_load_model: 'bloom_load_model_bytes', bloom_load_model_animation: 'bloom_load_model_animation_bytes', bloom_load_image: 'bloom_load_image_bytes', + bloom_stage_model: 'bloom_stage_model_bytes', + bloom_set_env_clear_from_hdr: 'bloom_set_env_clear_from_hdr_bytes', }; for (const [name, bytesName] of Object.entries(byteLoaders)) { const bytesFn = bloom[bytesName]; @@ -139,6 +262,21 @@ function buildFfiImports() { }; } + // Texture array from a comma-separated path list: fetch each file's bytes, + // decode + assemble engine-side (same codecs as the native from-files FFI). + imports.bloom_create_texture_array_from_files = (paths, format, mipLevels) => { + if (!bloom.bloom_texture_array_files_push) return 0; + bloom.bloom_texture_array_files_reset(); + for (const raw of String(paths).split(',')) { + const p = raw.trim(); + if (!p) continue; + const data = syncFetchBytes(p); + if (data) bloom.bloom_texture_array_files_push(data); + else console.warn('[texarray] missing layer file:', p); + } + return bloom.bloom_texture_array_files_commit(format, mipLevels); + }; + // --- Window / title --- imports.bloom_init_window = (w, h, title, fullscreen) => { document.title = String(title) || 'Bloom Engine'; @@ -148,32 +286,8 @@ function buildFfiImports() { imports.bloom_set_window_title = (title) => { document.title = String(title); }; // --- Web Audio --- - let audioContext = null; - let audioProcessor = null; - imports.bloom_init_audio = () => { - try { - audioContext = new AudioContext({ sampleRate: 44100 }); - const bufSize = 4096; - audioProcessor = audioContext.createScriptProcessor(bufSize, 0, 2); - audioProcessor.onaudioprocess = (e) => { - const left = e.outputBuffer.getChannelData(0); - const right = e.outputBuffer.getChannelData(1); - const interleaved = new Float32Array(left.length * 2); - bloom.bloom_audio_mix(interleaved); - for (let i = 0; i < left.length; i++) { - left[i] = interleaved[i * 2]; - right[i] = interleaved[i * 2 + 1]; - } - }; - audioProcessor.connect(audioContext.destination); - } catch (e) { - console.warn('[bloom] Web Audio init failed:', e); - } - }; - imports.bloom_close_audio = () => { - if (audioProcessor) { audioProcessor.disconnect(); audioProcessor = null; } - if (audioContext) { audioContext.close(); audioContext = null; } - }; + imports.bloom_init_audio = () => initAudioBridge(); + imports.bloom_close_audio = () => closeAudioBridge(); // --- Fullscreen / cursor --- imports.bloom_toggle_fullscreen = () => { @@ -182,25 +296,35 @@ function buildFfiImports() { else document.exitFullscreen().catch(() => {}); }; imports.bloom_disable_cursor = () => { + wantPointerLock = true; document.getElementById('bloom-canvas')?.requestPointerLock?.(); bloom.bloom_disable_cursor(); }; imports.bloom_enable_cursor = () => { + wantPointerLock = false; document.exitPointerLock?.(); bloom.bloom_enable_cursor(); }; - // --- File I/O via localStorage (no real filesystem on web) --- + // --- File I/O: localStorage first (saves/settings), then the served + // asset tree (read-only data files like world JSON and manifests) --- const LS_PREFIX = 'bloom_fs:'; imports.bloom_write_file = (path, data) => { try { localStorage.setItem(LS_PREFIX + String(path), String(data)); return 1; } catch { return 0; } }; - imports.bloom_file_exists = (path) => - localStorage.getItem(LS_PREFIX + String(path)) !== null ? 1 : 0; + imports.bloom_file_exists = (path) => { + const p = String(path); + if (localStorage.getItem(LS_PREFIX + p) !== null) return 1; + if (manifestPaths) return manifestPaths.has(p) ? 1 : 0; + return syncFetchBytes(p) !== null ? 1 : 0; + }; imports.bloom_read_file = (path) => { - const v = localStorage.getItem(LS_PREFIX + String(path)); - return v === null ? '' : v; // plain string; Perry re-encodes via wrapFfiForI64 + const p = String(path); + const v = localStorage.getItem(LS_PREFIX + p); + if (v !== null) return v; + const text = syncFetchText(p); + return text === null ? '' : text; // plain string; Perry re-encodes via wrapFfiForI64 }; // --- Game loop --- @@ -214,13 +338,37 @@ function buildFfiImports() { startRafLoop(); } }; + // Safety net for a game that still spins `while (!windowShouldClose())`: + // report "should close" once the rAF loop owns frame pacing, so the stray + // loop exits after one iteration instead of hanging the tab. + imports.bloom_window_should_close = () => (gameRunning ? 1 : 0); + imports.bloom_close_window = () => stopGame(); + + // Last: wrap every entry so (a) a throw names the FFI call that produced + // it — Perry's boot catch reports only the message, which for an ABI + // mismatch reads like a riddle — and (b) BigInt args (manifest-declared + // i64 params, e.g. pointer slots) are coerced to Numbers, since every + // implementation here is a plain JS/wasm-bindgen function expecting f64. + for (const [name, fn] of Object.entries(imports)) { + imports[name] = (...args) => { + for (let i = 0; i < args.length; i++) { + if (typeof args[i] === 'bigint') args[i] = Number(args[i]); + } + try { + return fn(...args); + } catch (e) { + console.error('[bloom ffi]', name, 'threw:', e, e?.stack ?? ''); + throw e; + } + }; + } return imports; } /** * Drive the captured Perry game closure once per animation frame: - * begin_drawing → callback(dt) → end_drawing. + * flush queued input → begin_drawing → callback(dt) → end_drawing. * * The closure is invoked through Perry's `callWasmClosure`, a global helper its * runtime exposes that resolves the closure's function-table index + captures @@ -228,12 +376,23 @@ function buildFfiImports() { * runtime classic