diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 206d3f4..79b0bfd 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, @@ -190,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 { @@ -197,7 +219,7 @@ impl Config { Ok(Some(text)) => text, Ok(None) => return Config::default(), Err(why) => { - eprintln!("{why}"); + tell!("{why}"); return Config::default(); } }; @@ -213,12 +235,16 @@ impl Config { } config.relays = relays; for cup in config.cups.take_invalid() { - eprintln!("{}: dropped the cup {cup:?}", place()); + tell!("{}: dropped the cup {cup:?}", place()); } config } Err(e) => { - eprintln!("{}: {e}", place()); + tell!("{}: {e}", place()); + match set_aside() { + Ok(to) => tell!("The defaults are used: what could not be read is in {to}"), + Err(why) => tell!("{why}"), + } Config::default() } } @@ -235,7 +261,7 @@ impl Config { if self.character != Some(character) { self.character = Some(character); if let Err(why) = self.save() { - eprintln!("{why}"); + tell!("{why}"); } } } @@ -289,7 +315,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 +328,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/disc.rs b/crates/client/src/disc.rs index 4e0e4fa..00df60e 100644 --- a/crates/client/src/disc.rs +++ b/crates/client/src/disc.rs @@ -43,8 +43,8 @@ pub fn prepare() { // go of the image (`web::release_disc`). if cfg!(target_arch = "wasm32") { match source.hold() { - Ok(size) => eprintln!("Took {} MiB from the disc", size >> 20), - Err(e) => eprintln!("Could not take the files of the disc: {e}"), + Ok(size) => tell!("Took {} MiB from the disc", size >> 20), + Err(e) => tell!("Could not take the files of the disc: {e}"), } return; } @@ -54,18 +54,18 @@ pub fn prepare() { Check::Stale(why) => format!("making the files of the disc again: {why}"), Check::Missing(needs) => format!("making {} more files of the disc", needs.len()), }; - eprintln!("{}...", why); + tell!("{}...", why); let mut told = 0; let made = source.prepare(|done, of| { let tenth = if of == 0 { 10 } else { done * 10 / of }; if tenth > told { told = tenth; - eprintln!(" {}%", tenth * 10); + tell!(" {}%", tenth * 10); } }); match made { - Ok(made) => eprintln!("Made {made} files in {}", paths::disc().display()), - Err(e) => eprintln!("Could not make the files of the disc: {e}"), + Ok(made) => tell!("Made {made} files in {}", paths::disc().display()), + Err(e) => tell!("Could not make the files of the disc: {e}"), } } @@ -84,12 +84,12 @@ pub fn locate(config: &mut Config) { let picked = ask()?; config.iso = Some(picked.clone()); if let Err(why) = config.save() { - eprintln!("{why}"); + tell!("{why}"); } Some(picked) }); let Some(path) = found else { - eprintln!( + tell!( "No Crash Team Racing (USA) disc image. Set CTR_BIN to its path, or \"iso\" in {}\n\ (it is read once, to make the files of the game in {})", crate::paths::config_file().display(), 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. diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 848406a..7c30254 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -14,6 +14,16 @@ // Bevy systems take their parameters and `Query` filters as types, so both lints fire on them. #![allow(clippy::too_many_arguments, clippy::type_complexity)] +/// Like `eprintln!`, which in a browser goes nowhere: there it goes to the console. +macro_rules! tell { + ($($arg:tt)*) => {{ + #[cfg(not(target_arch = "wasm32"))] + eprintln!($($arg)*); + #[cfg(target_arch = "wasm32")] + crate::web::console_warn(&format!($($arg)*)); + }}; +} + mod about; mod accordion; mod animation; @@ -80,6 +90,7 @@ mod suggest; mod toast; mod track_picture; mod tracker; +mod unlit; mod version; mod weapon; mod weather; @@ -247,6 +258,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/maps.rs b/crates/client/src/maps.rs index 142cd40..a3b1c92 100644 --- a/crates/client/src/maps.rs +++ b/crates/client/src/maps.rs @@ -68,7 +68,7 @@ pub fn load() { let (mut id, hash, map) = match read { Ok(read) => read, Err(e) => { - eprintln!("{}: {e}", path.display()); + tell!("{}: {e}", path.display()); continue; } }; @@ -78,14 +78,14 @@ pub fn load() { } else { "a custom track cannot be named for one of the game's tracks" }; - eprintln!("{}: {why}", path.display()); + tell!("{}: {why}", path.display()); continue; } if builtins { match builtin::level_of(&map.name) { Some(level) => id = level.0, None => { - eprintln!("{}: not one of the game's tracks", path.display()); + tell!("{}: not one of the game's tracks", path.display()); continue; } } @@ -103,7 +103,7 @@ pub fn load() { for bytes in crate::web::take_maps() { match Map::from_bytes(&bytes).and_then(|m| Ok((m.id()?, m.hash()?, m))) { Ok((_, _, map)) if builtin::is_builtin_name(&map.name) => { - eprintln!( + tell!( "{}: a custom track cannot be named for one of the game's tracks", map.name ); @@ -114,7 +114,7 @@ pub fn load() { file: File::Held(bytes), map: Box::leak(Box::new(map)), }), - Err(e) => eprintln!("A kept track: {e}"), + Err(e) => tell!("A kept track: {e}"), } } maps.sort_by(|a, b| a.map.name.cmp(&b.map.name)); @@ -321,15 +321,13 @@ pub fn make_builtins() { return; } let Some(image) = crate::disc::image() else { - eprintln!( - "The game's own tracks are missing, and there is no disc image to make them from" - ); + tell!("The game's own tracks are missing, and there is no disc image to make them from"); return; }; - eprintln!("Making the game's own tracks from the disc (this is done once)..."); + tell!("Making the game's own tracks from the disc (this is done once)..."); match map::builtin::export(&image, &dir) { - Ok(made) => eprintln!("Made {made} tracks in {}", dir.display()), - Err(e) => eprintln!("Could not make the game's own tracks: {e}"), + Ok(made) => tell!("Made {made} tracks in {}", dir.display()), + Err(e) => tell!("Could not make the game's own tracks: {e}"), } } @@ -343,7 +341,7 @@ pub fn make_builtins() { let made = match builtin::make_all(&image) { Ok(made) => made, Err(e) => { - eprintln!("Could not make the game's own tracks: {e}"); + tell!("Could not make the game's own tracks: {e}"); return; } }; 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", 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()); + } +} diff --git a/crates/client/src/web.rs b/crates/client/src/web.rs index f4f625b..87facb2 100644 --- a/crates/client/src/web.rs +++ b/crates/client/src/web.rs @@ -80,6 +80,14 @@ extern "C" { #[wasm_bindgen(js_namespace = window, js_name = ctrSaveConfig)] pub fn save_config(text: &str) -> Option; + /// `console.warn`, for what a native build prints (`tell!`). + #[wasm_bindgen(js_namespace = console, js_name = warn)] + pub fn console_warn(text: &str); + + /// 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/build.sh b/web/build.sh index 8606afc..0170e12 100755 --- a/web/build.sh +++ b/web/build.sh @@ -20,12 +20,14 @@ dev) dir=debug ;; *) dir="$profile" ;; esac -cargo build -p client --target wasm32-unknown-unknown --profile "$profile" if [ "$profile" = dist ]; then + features=--no-default-features names=--remove-name-section else + features= names= fi +cargo build -p client --target wasm32-unknown-unknown --profile "$profile" $features wasm-bindgen --target web --no-typescript $names --out-dir target/web/play --out-name client \ "target/wasm32-unknown-unknown/$dir/client.wasm" if [ "$profile" = dist ]; then diff --git a/web/play.html b/web/play.html index da03f64..cbbe1ff 100644 --- a/web/play.html +++ b/web/play.html @@ -192,6 +192,15 @@

CTR-RS