From 5b156cd47f4584bada4c1797c4f011fed8ad9a10 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 27 Jul 2026 06:22:07 +0200 Subject: [PATCH 1/3] fix(linux): grant the device limits/features the renderer actually needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bloom_init_window on Linux had drifted behind the Windows crate and bloom_shared::attach, which both negotiate these already: - max_storage_buffers_per_shader_stage: the PT kernel binds 9 (accum + moments + reservoir ping-pongs on top of instance/geo data) against wgpu's default of 8. This was a hard crash on startup, not a fallback — every game reaching PT init aborted with "Too many bindings of type StorageBuffers in Stage ShaderStages(COMPUTE), limit is 8, count was 9". - max_sampled_textures/samplers_per_shader_stage: raised to the adapter's limits. Latent until now — the refractive/translucent profile binds 19 sampled textures against a default cap of 16. The identical shortfall is what stopped the shooter opening on macOS (see attach.rs). - PT-2 TEXTURE_BINDING_ARRAY + non-uniform indexing, and the binding-array element budget: never requested here, so textured path-trace hit shading silently compiled the card-only stub. Also add COPY_SRC to the swapchain usage. bloom_take_screenshot reads the swapchain back via copy_texture_to_buffer; without the usage bit that is a validation error that aborts the process (verified by removing it again: non-unwinding panic + core dump on the first takeScreenshot call). Verified on AMD Phoenix1 / RADV / XWayland: 138/138 shared tests pass including the 9 golden-image renders, and the Bloom Shooter runs at 59 FPS with `ssgi trace backend = hw-ray-query`. Co-Authored-By: Claude Opus 5 (1M context) --- native/linux/Cargo.lock | 1 + native/linux/src/lib.rs | 42 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/native/linux/Cargo.lock b/native/linux/Cargo.lock index fd129715..0aa96a1d 100644 --- a/native/linux/Cargo.lock +++ b/native/linux/Cargo.lock @@ -359,6 +359,7 @@ dependencies = [ "libc", "minimp3", "raw-window-handle", + "web-sys", "wgpu", ] diff --git a/native/linux/src/lib.rs b/native/linux/src/lib.rs index 44c78350..726191ac 100644 --- a/native/linux/src/lib.rs +++ b/native/linux/src/lib.rs @@ -572,6 +572,14 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u if !force_sw_gi && supported.contains(rt_mask) { required_features |= rt_mask; } + // PT-2: texture binding array + non-uniform indexing for textured + // path-trace hit shading. Both or neither (the kernel indexes the + // array with a per-thread material id). + let pt_tex_mask = wgpu::Features::TEXTURE_BINDING_ARRAY + | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING; + if supported.contains(pt_tex_mask) { + required_features |= pt_tex_mask; + } let experimental_features = if required_features.intersects(rt_mask) { unsafe { wgpu::ExperimentalFeatures::enabled() } } else { @@ -582,6 +590,34 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u // PerMaterial, PerDraw, SceneInputs). wgpu's default limit is // 4. Vulkan supports at least 7 here, so 5 is universally safe. required_limits.max_bind_groups = 5; + // The refractive/translucent material profile binds up to 19 + // sampled textures in the fragment stage (5 material maps + env/ + // BRDF/3 shadow cascades/env-diffuse + planar reflection + 3 texture + // arrays + the group-4 scene_color/scene_depth/impulse/motion inputs). + // wgpu's default is 16. Raise to whatever the adapter actually + // supports — every real Vulkan/GL GPU exposes ≥128 — so opaque/ + // transparent materials are unaffected and refractive ones link. + let adapter_limits = adapter.limits(); + required_limits.max_sampled_textures_per_shader_stage = required_limits + .max_sampled_textures_per_shader_stage + .max(adapter_limits.max_sampled_textures_per_shader_stage); + required_limits.max_samplers_per_shader_stage = required_limits + .max_samplers_per_shader_stage + .max(adapter_limits.max_samplers_per_shader_stage); + // PT-2: binding arrays have their own element budget, default 0. + // Take whatever the adapter offers; the renderer checks the + // granted value against its fixed array size before compiling + // the textured kernel variant. + if required_features.contains(pt_tex_mask) { + required_limits.max_binding_array_elements_per_shader_stage = + adapter_limits.max_binding_array_elements_per_shader_stage; + } + // PT-4: the path-trace kernel binds 9 storage buffers (accum + + // moments + reservoir ping-pongs on top of instance/geo data); + // the wgpu default limit is 8. + required_limits.max_storage_buffers_per_shader_stage = required_limits + .max_storage_buffers_per_shader_stage + .max(adapter_limits.max_storage_buffers_per_shader_stage.min(16)); if required_features.intersects(rt_mask) { required_limits = required_limits .using_minimum_supported_acceleration_structure_values(); @@ -603,7 +639,11 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u // logical size separately so screenWidth() etc. stay // DPI-independent. let surface_config = wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + // COPY_SRC: bloom_take_screenshot reads the swapchain back; + // without it the readback copy is a validation error that + // aborts the process the first time a game calls it. + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, format, width: phys_w, height: phys_h, From 8d6132de1e7bbd3081d42f302bfe0ffd05e1141d Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 27 Jul 2026 06:22:21 +0200 Subject: [PATCH 2/3] fix(models): drop duplicate exports that broke every bloom/models consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/models/index.ts declared BUCKET_* twice with conflicting values and unloadModel twice. Perry emits one LLVM function per exported const, so clang rejected the module outright ("invalid redefinition of function ...__BUCKET_OPAQUE"). Perry then linked the whole module as empty _perry_init_* stubs, so every game importing bloom/models failed to link with dozens of undefined references (createMesh, drawModelTransform, setAmbientLight, compileMaterialInstancedBucket, ...). The Bloom Shooter could not be built at all. Kept the BUCKET_* block that matches the live wire mapping decoded by Renderer::compile_material_instanced_bucket (0=Opaque, 1=Cutout, 2=Additive, 3=Transparent) — the numbering the shooter's BUCKET_ADDITIVE and BUCKET_CUTOUT call sites depend on. The removed block carried Phase 4b numbering (TRANSPARENT=1, REFRACTIVE=2, ADDITIVE=3, CUTOUT=4), which matched neither the FFI nor the Rust Bucket enum's discriminants. Refractive has no wire value in that FFI; compileRefractiveMaterial covers it, and nothing referenced BUCKET_REFRACTIVE. Both unloadModel definitions were byte-identical; kept the documented one. A scan of src/**/*.ts finds no other duplicate top-level exports. Not Linux-specific — this broke macOS and Windows identically. Co-Authored-By: Claude Opus 5 (1M context) --- src/models/index.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/models/index.ts b/src/models/index.ts index 4c07e6cd..9fed9ca5 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -235,10 +235,6 @@ export function drawModelTransform(model: Model, m16: number[], tint: Color): vo ); } -export function unloadModel(model: Model): void { - bloom_unload_model(model.handle); -} - /** * Return the axis-aligned bounding box of a loaded model in its local * coordinate space. Computed once at load time from mesh vertex positions. @@ -358,15 +354,6 @@ export function compileMaterial(wgslSource: string): number { export const PROFILE_OPAQUE = 0; export const PROFILE_TRANSLUCENT = 1; -/// Material bucket — matches `Bucket` in the engine. Decides which -/// pass the draw lands in and what sort order applies. Refractive -/// also triggers a scene-colour snapshot before the pass runs. -export const BUCKET_OPAQUE = 0; -export const BUCKET_TRANSPARENT = 1; -export const BUCKET_REFRACTIVE = 2; -export const BUCKET_ADDITIVE = 3; -export const BUCKET_CUTOUT = 4; - /// Phase 4b — full-control material compile. Pass PROFILE_* and /// BUCKET_* constants. `readsScene` enables the group-4 SceneInputs /// binding; required for refraction / shoreline / depth-fade effects. @@ -423,6 +410,17 @@ export function compileMaterialInstanced(wgslSource: string): number { declare function bloom_compile_material_instanced_bucket(src: number, bucket: number, readsScene: number): number; +/// Material bucket — the wire values `bloom_compile_material_instanced_bucket` +/// decodes (see `Renderer::compile_material_instanced_bucket`). These are the +/// FFI's own numbering, NOT the discriminants of the Rust `Bucket` enum, and +/// they only cover the buckets the instanced compile can reach: Refractive has +/// no wire value here — use `compileRefractiveMaterial` for that. +/// +/// A second, conflicting BUCKET_* block used to sit further up this file with +/// Phase 4b's numbering (TRANSPARENT=1, REFRACTIVE=2, ADDITIVE=3, CUTOUT=4). +/// Two `export const`s of the same name made Perry emit duplicate LLVM +/// definitions, so clang rejected this whole module and every game importing +/// `bloom/models` failed to link against empty `_perry_init_*` stubs. export const BUCKET_OPAQUE = 0; export const BUCKET_CUTOUT = 1; export const BUCKET_ADDITIVE = 2; From fb79f520416477fca308aa8ea2e3da28b82c02a7 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 27 Jul 2026 06:36:23 +0200 Subject: [PATCH 3/3] fix(linux): honour WM_DELETE_WINDOW so closing the window shuts down cleanly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The X11 backend never put WM_DELETE_WINDOW in the window's WM_PROTOCOLS, so the titlebar ✕ / Alt-F4 / session logout gave the WM no polite way to ask the game to quit. It dropped the X connection instead, and Xlib's default IO-error handler called exit(1) from under the running frame: XIO: fatal IO error 62 (Timer expired) on X server ":0" after 23355 requests (23354 known processed) with 0 events remaining. `windowShouldClose()` never went true, so `closeWindow()` and everything the game does after its loop — audio teardown, save-on-exit — were skipped. Only DestroyNotify was handled, which arrives too late to be useful here. Intern the atoms once at window creation, XSetWMProtocols before mapping, and translate the resulting ClientMessage into `should_close`. Verified with the Bloom Shooter: sending a real WM_DELETE_WINDOW ClientMessage now exits 0 with no XIO error in the log (previously exit 1 + XIO fatal). Co-Authored-By: Claude Opus 5 (1M context) --- native/linux/src/lib.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/native/linux/src/lib.rs b/native/linux/src/lib.rs index 726191ac..46969353 100644 --- a/native/linux/src/lib.rs +++ b/native/linux/src/lib.rs @@ -92,6 +92,13 @@ mod x11_impl { static mut WARP_CENTER_X: i32 = 0; static mut WARP_CENTER_Y: i32 = 0; static mut RELATIVE_MODE: bool = false; + /// WM_PROTOCOLS / WM_DELETE_WINDOW, interned once in `create_window`. + /// Cached because `poll_events` compares against them every event. + static mut WM_PROTOCOLS: x11::xlib::Atom = 0; + static mut WM_DELETE_WINDOW: x11::xlib::Atom = 0; + + pub fn wm_protocols_atom() -> x11::xlib::Atom { unsafe { WM_PROTOCOLS } } + pub fn wm_delete_window_atom() -> x11::xlib::Atom { unsafe { WM_DELETE_WINDOW } } pub fn set_fullscreen(fullscreen: bool) { unsafe { @@ -184,6 +191,20 @@ mod x11_impl { x11::xlib::ButtonPressMask | x11::xlib::ButtonReleaseMask | x11::xlib::PointerMotionMask | x11::xlib::StructureNotifyMask); + // Opt into the WM close protocol before mapping, so the titlebar + // ✕ / Alt-F4 arrives as a ClientMessage we can turn into + // `windowShouldClose()` instead of the WM dropping our X + // connection and Xlib exit(1)-ing the game mid-frame. + WM_PROTOCOLS = x11::xlib::XInternAtom( + DISPLAY, b"WM_PROTOCOLS\0".as_ptr() as *const _, 0); + WM_DELETE_WINDOW = x11::xlib::XInternAtom( + DISPLAY, b"WM_DELETE_WINDOW\0".as_ptr() as *const _, 0); + if WM_DELETE_WINDOW != 0 { + let mut protocols = [WM_DELETE_WINDOW]; + x11::xlib::XSetWMProtocols( + DISPLAY, X11_WINDOW, protocols.as_mut_ptr(), 1); + } + if !headless { x11::xlib::XMapWindow(DISPLAY, X11_WINDOW); } @@ -489,6 +510,23 @@ mod x11_impl { } } } + x11::xlib::ClientMessage => { + // The WM asking us to close (titlebar ✕, Alt-F4, or a + // session logout). Without WM_DELETE_WINDOW in our + // WM_PROTOCOLS the WM instead destroys the connection, + // and Xlib's default IO-error handler calls exit(1) + // from under the game — `windowShouldClose()` never + // goes true, so `closeWindow()` and every shutdown + // path after it (audio teardown, save-on-exit) are + // skipped. Observed as: + // XIO: fatal IO error 62 (Timer expired) + if event.client_message.message_type == wm_protocols_atom() + && event.client_message.data.get_long(0) + == wm_delete_window_atom() as i64 + { + engine().should_close = true; + } + } x11::xlib::DestroyNotify => { engine().should_close = true; }