Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion native/linux/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions native/macos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions native/shared/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions native/shared/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 2 additions & 2 deletions native/shared/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion native/shared/src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
2 changes: 1 addition & 1 deletion native/shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 96 additions & 1 deletion native/shared/src/renderer/material_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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
// =====================================================================
Expand Down
56 changes: 51 additions & 5 deletions native/shared/src/renderer/material_system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, &[]);
}
Expand All @@ -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)]
Expand Down
41 changes: 41 additions & 0 deletions native/shared/src/renderer/material_system_wasm.rs
Original file line number Diff line number Diff line change
@@ -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) },
],
})
}
Loading
Loading