From 697b82e85d0d2ad63c0995bd90b73a7ef62fee3b Mon Sep 17 00:00:00 2001 From: Otto Pirttimaa Date: Wed, 4 Nov 2026 00:40:11 +0200 Subject: [PATCH 1/7] Read the heat pass's depth through a nearest sampler, as WebGL2 reads it as 0 otherwise --- crates/client/src/heat.rs | 11 ++++++++++- crates/client/src/heat.wesl | 20 +++++++++----------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/crates/client/src/heat.rs b/crates/client/src/heat.rs index 119d1ae..c4c497b 100644 --- a/crates/client/src/heat.rs +++ b/crates/client/src/heat.rs @@ -130,7 +130,8 @@ impl Plugin for HeatPlugin { } } -/// The pass reads the depth buffer of the camera that has it. +/// The pass reads the depth buffer of the camera that has it. (WebGL2 could not read a +/// multisampled one: a browser has no msaa, see `config.rs`.) fn readable_depth(add: On>, mut cameras: Query<&mut Camera3d>) { if let Ok(mut camera) = cameras.get_mut(add.entity) { let usages = TextureUsages::from(camera.depth_texture_usages); @@ -290,6 +291,8 @@ struct HeatPipeline { /// With the depth buffer as it is without multisampling, and as it is with. layouts: [BindGroupLayoutDescriptor; 2], sampler: Sampler, + /// Nearest, for the depth buffer without multisampling (see `heat.wesl`). + depth_sampler: Sampler, shader: Handle, vertex: FullscreenShader, /// By the format of the picture and its samples. @@ -312,6 +315,7 @@ fn init_pipeline( sampler(SamplerBindingType::Filtering), uniform_buffer::(true), depth, + sampler(SamplerBindingType::NonFiltering), ), ), ) @@ -330,6 +334,10 @@ fn init_pipeline( ), ], sampler: render_device.create_sampler(&SamplerDescriptor::default()), + depth_sampler: render_device.create_sampler(&SamplerDescriptor { + label: Some("heat_warp_depth_sampler"), + ..default() + }), shader: asset_server.load(concat!("embedded://", env!("CARGO_PKG_NAME"), "/heat.wesl")), vertex: fullscreen_shader.clone(), pipelines: HashMap::new(), @@ -430,6 +438,7 @@ fn pass( &pipeline.sampler, binding, depth_view, + &pipeline.depth_sampler, )), ); let mut render_pass = ctx diff --git a/crates/client/src/heat.wesl b/crates/client/src/heat.wesl index 8b6bbfd..f45c317 100644 --- a/crates/client/src/heat.wesl +++ b/crates/client/src/heat.wesl @@ -20,9 +20,12 @@ struct HeatWarp { @if(MULTISAMPLED) @group(0) @binding(3) var depth_texture: texture_depth_multisampled_2d; // A float texture rather than a depth one (its depth in `r`), as WebGL2 cannot `textureLoad` a -// depth texture. +// depth texture. It is read through a sampler: WebGL2 reads a depth texture as 0 when its own +// filtering is linear, which it is (wgpu cannot set it), and the sampler's nearest overrides that. @if(!MULTISAMPLED) @group(0) @binding(3) var depth_texture: texture_2d; +@if(!MULTISAMPLED) +@group(0) @binding(4) var depth_sampler: sampler; struct Input { @builtin(position) position: vec4, @@ -49,21 +52,16 @@ fn lens_depth(lens: u32) -> f32 { return heat.points[lens * PER_LENS + 1u].w; } -// The depth at the texel `at`: the float texture has it in `r`. +// The scene's depth at uv (the first sample, if it is multisampled). @if(MULTISAMPLED) -fn load_depth(at: vec2) -> f32 { +fn depth_at(uv: vec2) -> f32 { + let size = vec2(textureDimensions(depth_texture)); + let at = clamp(vec2(uv * vec2(size)), vec2(0), size - 1); return textureLoad(depth_texture, at, 0); } @if(!MULTISAMPLED) -fn load_depth(at: vec2) -> f32 { - return textureLoad(depth_texture, at, 0).r; -} - -// The scene's depth at uv (the first sample, if it is multisampled). fn depth_at(uv: vec2) -> f32 { - let size = vec2(textureDimensions(depth_texture)); - let at = clamp(vec2(uv * vec2(size)), vec2(0), size - 1); - return load_depth(at); + return textureSampleLevel(depth_texture, depth_sampler, uv, 0.0).r; } // Whether what is drawn at uv is in front of the lens, drawn after it in retail. From 40090fa324f83fff3ef4077d4e8bca146cbafd0d Mon Sep 17 00:00:00 2001 From: Otto Pirttimaa Date: Wed, 4 Nov 2026 00:40:11 +0200 Subject: [PATCH 2/7] Force MSAA off in the browser, where the heat pass cannot read a multisampled depth buffer --- crates/client/src/config.rs | 11 +++++++++-- crates/client/src/suggest.rs | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 206d3f4..41d8f0e 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -27,7 +27,9 @@ //! [`MAX_RELAYS`]; it holds from the next lobby), and `/net relay clear` to drop them all //! //! A browser has no vsync or fps-max, and no relay-only: it paces the frames to the display -//! itself, and reaches the others through the relay only. +//! itself, and reaches the others through the relay only. Nor has it msaa: the heat shimmer and +//! the warpball's glow (`heat.rs`) read the depth buffer, which WebGL2 cannot when it is +//! multisampled. //! //! The last character driven is kept too, and is the one the client starts with, and the //! keyboard's driving keys, which the pause menu's Controls change (`controls.rs`), and whether @@ -122,6 +124,9 @@ impl Default for Config { impl Config { /// The multisampling of the cameras. pub fn msaa(&self) -> Msaa { + if cfg!(target_arch = "wasm32") { + return Msaa::Off; + } match self.msaa { 2 => Msaa::Sample2, 4 => Msaa::Sample4, @@ -289,7 +294,6 @@ const USAGE: &str = "Usage: /config set nickname [name, up to 12 characters]\n\ /config set volume music|sfx|voice <0-100>"; #[cfg(target_arch = "wasm32")] const USAGE: &str = "Usage: /config set nickname [name, up to 12 characters]\n\ - /config set msaa off|2|4\n\ /config set shader-effects on|off\n\ /config set render-scale <0.25 to 1>\n\ /config set volume music|sfx|voice <0-100>"; @@ -303,6 +307,9 @@ impl Setting { "vsync" | "fps-max" => { return Err("In a browser, the browser sets the frame rate".into()); } + "msaa" => { + return Err("In a browser, anti-aliasing is off for the heat shimmer".into()); + } _ => {} } } diff --git a/crates/client/src/suggest.rs b/crates/client/src/suggest.rs index e1d3f14..4aeb3d1 100644 --- a/crates/client/src/suggest.rs +++ b/crates/client/src/suggest.rs @@ -309,6 +309,8 @@ pub(crate) const COMMANDS: &[Spec] = &[ "How much of the resolution to draw", "<0.25 to 1>", ), + // (A browser has no msaa either, see `config.rs`.) + #[cfg(not(target_arch = "wasm32"))] node( "msaa", "Anti-aliasing samples of the cameras", From ebf8bf5b19ac0c2c52483958d0000acdb59a02bf Mon Sep 17 00:00:00 2001 From: Otto Pirttimaa Date: Wed, 4 Nov 2026 00:40:11 +0200 Subject: [PATCH 3/7] Cut the lighting out of Bevy's StandardMaterial shader, which Firefox on macOS takes ~8 s per pipeline to link --- crates/client/src/main.rs | 2 ++ crates/client/src/unlit.rs | 69 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 crates/client/src/unlit.rs diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 848406a..328e785 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -80,6 +80,7 @@ mod suggest; mod toast; mod track_picture; mod tracker; +mod unlit; mod version; mod weapon; mod weather; @@ -247,6 +248,7 @@ fn main() { .add_plugins(sky::SkyPlugin) .add_plugins(drop_map::DropMapPlugin) .add_plugins(MaterialPlugin::::default()) + .add_plugins(unlit::UnlitPlugin) .insert_resource(Time::::from_hz(GAME_TICK_HZ)) .add_systems( FixedUpdate, diff --git a/crates/client/src/unlit.rs b/crates/client/src/unlit.rs new file mode 100644 index 0000000..99e3528 --- /dev/null +++ b/crates/client/src/unlit.rs @@ -0,0 +1,69 @@ +//! Every material of ours is unlit, but Bevy's `StandardMaterial` draws through a fragment shader +//! with all of Bevy's lighting in it: `unlit` is a flag the shader reads as it runs, not one it is +//! compiled without. On WebGL2 the lights are a uniform array of 204 of them, which Firefox on +//! macOS (through Apple's OpenGL) takes about 8 s to link, for each of the mesh pipelines: most of +//! a minute before the first frame. So the lighting is cut out of Bevy's shader as it is loaded, +//! leaving what it does for an unlit material. A lit material, or a Bevy light, would be drawn +//! unlit. + +use bevy::asset::AssetEventSystems; +use bevy::prelude::*; +use bevy::shader::{Shader, Source}; + +/// Where Bevy keeps the shader (`bevy_pbr`'s `load_shader_library!`). +const PATH: &str = "embedded://bevy_pbr/render/pbr.wesl"; + +/// The choice between the lit and the unlit color in Bevy's shader, and the unlit one alone. +const LIGHTING: &str = + " if (pbr_input.material.flags & STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u { + out.color = apply_pbr_lighting(pbr_input); + } else { + out.color = pbr_input.material.base_color; + } +"; +const UNLIT: &str = " out.color = pbr_input.material.base_color; +"; + +pub struct UnlitPlugin; + +impl Plugin for UnlitPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Startup, check) + .add_systems(PostUpdate, cut_lighting.after(AssetEventSystems)); + } +} + +/// Tells if Bevy no longer has the shader where it is looked for (after an upgrade). +fn check(asset_server: Res) { + if asset_server.get_handle::(PATH).is_none() { + warn!("{PATH} is not loaded: the meshes are drawn with Bevy's lighting in their shader"); + } +} + +/// Cuts the lighting in the frame the shader is loaded, before the render world takes it +/// (`extract_shaders` reads it as it is then). Untracked, as a change would make the render world +/// build its pipelines again. Left as it is if Bevy's shader no longer reads as [`LIGHTING`]. +fn cut_lighting( + mut events: MessageReader>, + mut shaders: ResMut>, +) { + for event in events.read() { + let AssetEvent::Added { id } = event else { + continue; + }; + let Some(shader) = shaders.get_mut_untracked(*id) else { + continue; + }; + if shader.path != PATH { + continue; + } + let Source::Wesl(source) = &shader.source else { + continue; + }; + if source.matches(LIGHTING).count() != 1 { + warn!("{PATH} has changed: the meshes are drawn with Bevy's lighting in their shader"); + continue; + } + shader.source = Source::Wesl(source.replace(LIGHTING, UNLIT).into()); + } +} From d7fb2a83104769a0d2d6479a5de5aef6a2c6d3dc Mon Sep 17 00:00:00 2001 From: Otto Pirttimaa Date: Wed, 4 Nov 2026 00:44:19 +0200 Subject: [PATCH 4/7] Set an unreadable config aside, and offer a restart with default settings after a crash --- crates/client/src/config.rs | 21 +++++++++++++++++++++ crates/client/src/web.rs | 4 ++++ web/play.html | 24 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 41d8f0e..d9d1876 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -195,6 +195,23 @@ fn write(text: &str) -> Result<(), String> { } } +/// Moves the settings to where they are no longer read, nor overwritten by the next ones saved, +/// and says where. +#[cfg(not(target_arch = "wasm32"))] +fn set_aside() -> Result { + let to = path().with_extension("json.broken"); + std::fs::rename(path(), &to).map_err(|e| format!("Could not move {}: {e}", place()))?; + Ok(to.display().to_string()) +} + +#[cfg(target_arch = "wasm32")] +fn set_aside() -> Result { + match crate::web::set_config_aside() { + None => Ok("ctr-rs.config.broken in the browser's storage".to_string()), + Some(e) => Err(format!("Could not move the settings: {e}")), + } +} + impl Config { /// What is kept; the defaults if there is nothing or it cannot be read (it is told of). pub fn load() -> Config { @@ -224,6 +241,10 @@ impl Config { } Err(e) => { eprintln!("{}: {e}", place()); + match set_aside() { + Ok(to) => eprintln!("The defaults are used: what could not be read is in {to}"), + Err(why) => eprintln!("{why}"), + } Config::default() } } diff --git a/crates/client/src/web.rs b/crates/client/src/web.rs index f4f625b..f6a4844 100644 --- a/crates/client/src/web.rs +++ b/crates/client/src/web.rs @@ -80,6 +80,10 @@ extern "C" { #[wasm_bindgen(js_namespace = window, js_name = ctrSaveConfig)] pub fn save_config(text: &str) -> Option; + /// Moves the settings to where they are no longer read; what went wrong if the page could not. + #[wasm_bindgen(js_namespace = window, js_name = ctrSetConfigAside)] + pub fn set_config_aside() -> Option; + /// The maps the page keeps, as the bytes of their files (`Uint8Array`s), once. #[wasm_bindgen(js_namespace = window, js_name = ctrTakeMaps)] fn take_saved_maps() -> js_sys::Array; diff --git a/web/play.html b/web/play.html index da03f64..6923221 100644 --- a/web/play.html +++ b/web/play.html @@ -400,6 +400,21 @@

CTR-RS

return String(e); } }; +// Moves the settings to where the game no longer reads them (and the next ones do not overwrite +// them): a game they make crash, or cannot read, starts again with the defaults. +const CONFIG_ASIDE = `${CONFIG}.broken`; +function setConfigAside() { + try { + const text = localStorage.getItem(CONFIG); + if (text === null) return null; + localStorage.setItem(CONFIG_ASIDE, text); + localStorage.removeItem(CONFIG); + return null; + } catch (e) { + return String(e); + } +} +window.ctrSetConfigAside = setConfigAside; // The game reads the image from here (a wasm program's memory never shrinks, so it is not copied // in), and lets go of it once it has taken what it needs. @@ -472,8 +487,17 @@

CTR-RS

document.getElementById("detail").textContent = message; document.getElementById("detail").hidden = false; document.getElementById("note").hidden = true; + let kept = false; + try { + kept = localStorage.getItem(CONFIG) !== null; + } catch {} offer(starting ? "The game could not start" : "The game crashed", [ ["Restart", () => location.reload()], + ...(kept ? [["Restart with default settings", () => { + const e = setConfigAside(); + if (e !== null) console.warn(`could not set the settings aside: ${e}`); + location.reload(); + }]] : []), ...(starting ? [["Pick another .bin", () => forget().then(() => location.reload())]] : []), ["Copy the error", copyCrash], ]); From 8b9383003b9ef2f0e6019fd620261516bf1598ac Mon Sep 17 00:00:00 2001 From: Otto Pirttimaa Date: Wed, 4 Nov 2026 00:56:00 +0200 Subject: [PATCH 5/7] Fetch and compile the wasm while the disc is picked and read --- web/play.html | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/play.html b/web/play.html index 6923221..cbbe1ff 100644 --- a/web/play.html +++ b/web/play.html @@ -192,6 +192,15 @@

CTR-RS