diff --git a/BUILD_DEV.bat b/BUILD_DEV.bat new file mode 100644 index 00000000..658fea2e --- /dev/null +++ b/BUILD_DEV.bat @@ -0,0 +1,6 @@ +@echo off +setlocal +cd /d "%~dp0" +set "PATH=%USERPROFILE%\.cargo\bin;%PATH%" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Build.ps1" -Dev +if errorlevel 1 pause diff --git a/Cargo.lock b/Cargo.lock index a6cb81c6..820356ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4775,7 +4775,6 @@ dependencies = [ "skate-dynamics", "skate-mods", "skate-net", - "tracing-log", "wgpu", ] diff --git a/PLAY.bat b/PLAY.bat index dda8bc4e..1b8850c2 100644 --- a/PLAY.bat +++ b/PLAY.bat @@ -1,6 +1,10 @@ @echo off cd /d "%~dp0" -if "%~1"=="" ( +if /i "%~1"=="-trace" ( + if not "%~2"=="" set "SKATE_TRACE_MAP=%~2" + if not "%~3"=="" set "SKATE_TRACE_FILE=%~3" + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Launch.ps1" -Trace +) else if "%~1"=="" ( powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Launch.ps1" ) else ( powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Launch.ps1" -Map "%~1" diff --git a/TRACE_PLAY.bat b/TRACE_PLAY.bat new file mode 100644 index 00000000..43690ac6 --- /dev/null +++ b/TRACE_PLAY.bat @@ -0,0 +1,5 @@ +@echo off +cd /d "%~dp0" +set "SKATE_TRACE_FILE=trace-downtown-lag.json" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Launch.ps1" -Trace +if errorlevel 1 pause diff --git a/crates/skate-core/src/camera/manual_cam.rs b/crates/skate-core/src/camera/manual_cam.rs new file mode 100644 index 00000000..30acea71 --- /dev/null +++ b/crates/skate-core/src/camera/manual_cam.rs @@ -0,0 +1,302 @@ +//! CameraPresentation manual cam (`SetManualCamMode` / `UpdateManualCam`). +use super::manager_frame::CameraFrame; +use crate::input::gameplay_map::GameplayActions; +use crate::math::Basis3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManualCamSettings { + /// Settings-object +100 deadzone for cubed analog input. + pub deadzone: f32, + /// Settings-object +76 move scale (m/s). + pub move_speed: f32, + /// Settings-object +124 look scale (rad/s). + pub look_speed: f32, + /// Trigger vertical speed (m/s). + pub vertical_speed: f32, + /// L3 / stick-click fast multiplier. + pub fast_multiplier: f32, +} + +impl Default for ManualCamSettings { + fn default() -> Self { + Self { + deadzone: 0.01, + move_speed: 8.0, + look_speed: 1.75, + vertical_speed: 6.0, + fast_multiplier: 3.0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManualCamMatrix { + pub right: [f32; 3], + pub up: [f32; 3], + pub at: [f32; 3], + pub position: [f32; 3], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManualCam { + pub active: bool, + pub matrix: ManualCamMatrix, + speed_multiplier: f32, +} + +impl Default for ManualCam { + fn default() -> Self { + Self { + active: false, + matrix: ManualCamMatrix { + right: [1.0, 0.0, 0.0], + up: [0.0, 1.0, 0.0], + at: [0.0, 0.0, 1.0], + position: [0.0, 0.0, 0.0], + }, + speed_multiplier: 1.0, + } + } +} + +impl ManualCam { + /// `CameraPresentation+0xF50` — manual cam active flag. + pub fn manual_cam_active(&self) -> bool { + self.active + } + + /// `SetManualCamMode`: enabling snapshots `GetMatrix` into `+3696`. + pub fn set_manual_cam_mode(&mut self, enable: bool, normal: Option<&CameraFrame>) { + if enable { + if !self.active { + if let Some(frame) = normal { + self.matrix = matrix_from_frame(frame); + self.speed_multiplier = 1.0; + self.active = true; + } + } + } else if self.active { + self.active = false; + } + } + + /// `GetMatrix`: manual matrix at `+3696` when the flag is set, else the normal frame. + pub fn get_matrix(&self, normal: &CameraFrame) -> CameraFrame { + if self.active { + self.to_camera_frame(normal) + } else { + *normal + } + } + + pub fn spawn_matrix(&self) -> [[f32; 4]; 4] { + let m = self.matrix; + [ + [m.right[0], m.right[1], m.right[2], 0.0], + [m.up[0], m.up[1], m.up[2], 0.0], + [m.at[0], m.at[1], m.at[2], 0.0], + [m.position[0], m.position[1], m.position[2], 1.0], + ] + } + + pub fn to_camera_frame(&self, source: &CameraFrame) -> CameraFrame { + let [right, up, at] = [self.matrix.right, self.matrix.up, self.matrix.at]; + CameraFrame { + basis: Basis3 { columns: [right, up, at] }, + position: [ + self.matrix.position[0], + self.matrix.position[1], + self.matrix.position[2], + 0.0, + ], + previous_basis: source.previous_basis, + previous_position: source.previous_position, + linear_velocity: [0.0; 4], + angular_velocity: [0.0; 4], + shake_translation: [0.0; 4], + discontinuity: false, + field_of_view_degrees: source.field_of_view_degrees, + opacity: source.opacity, + blur: source.blur, + } + } + + /// `UpdateManualCam` using stock gameplay analog getters (actions 64+). + pub fn update_manual_cam( + &mut self, + actions: &GameplayActions, + settings: ManualCamSettings, + dt: f32, + ) { + if !self.active || dt <= 0.0 { + return; + } + let values = actions.values(); + let strafe = cubic(values[0], settings.deadzone); + let forward = cubic(values[1], settings.deadzone); + let fast = values[2] > 0.5; + let look_x = cubic(-values[3], settings.deadzone); + let look_y = cubic(-values[4], settings.deadzone); + let vertical = values[7] - values[6]; + + let target_speed = if fast { settings.fast_multiplier } else { 1.0 }; + self.speed_multiplier = smooth_speed(self.speed_multiplier, target_speed, dt); + let speed = settings.move_speed * self.speed_multiplier * dt; + + let mut right = self.matrix.right; + let mut at = self.matrix.at; + let mut pos = self.matrix.position; + + if forward.abs() > 0.0 { + pos = add(pos, scale(at, forward * speed)); + } + if strafe.abs() > 0.0 { + let side = normalize(cross(at, [0.0, 1.0, 0.0])); + pos = add(pos, scale(side, strafe * speed)); + } + if vertical.abs() > 0.0 { + pos[1] += vertical * settings.vertical_speed * dt; + } + + let look = settings.look_speed * dt; + if look_x.abs() > 0.0 { + let yaw = rotate_axis([0.0, 1.0, 0.0], look_x * look); + right = mul_mat_vec(yaw, right); + at = mul_mat_vec(yaw, at); + } + if look_y.abs() > 0.0 { + let pitch = rotate_axis(right, look_y * look); + at = mul_mat_vec(pitch, at); + } + + at = normalize(at); + right = normalize(cross([0.0, 1.0, 0.0], at)); + if length(right) < 1.0e-4 { + right = normalize(self.matrix.right); + } + let up = normalize(cross(at, right)); + right = normalize(cross(up, at)); + + self.matrix = ManualCamMatrix { right, up, at, position: pos }; + } +} + +fn matrix_from_frame(frame: &CameraFrame) -> ManualCamMatrix { + let [right, up, at] = frame.basis.columns; + ManualCamMatrix { + right, + up, + at, + position: [frame.position[0], frame.position[1], frame.position[2]], + } +} + +fn cubic(value: f32, deadzone: f32) -> f32 { + let cubed = value * value * value; + if cubed.abs() > deadzone { cubed } else { 0.0 } +} + +fn smooth_speed(current: f32, target: f32, dt: f32) -> f32 { + // Native +3888 smoothing uses ~0.1 blend per frame at 60 Hz. + let blend = (0.1 * dt * 60.0).clamp(0.0, 1.0); + current + (target - current) * blend +} + +fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +fn scale(v: [f32; 3], s: f32) -> [f32; 3] { + [v[0] * s, v[1] * s, v[2] * s] +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +fn length(v: [f32; 3]) -> f32 { + dot(v, v).sqrt() +} + +fn normalize(v: [f32; 3]) -> [f32; 3] { + let len = length(v); + if len > 1.0e-6 { scale(v, 1.0 / len) } else { v } +} + +fn rotate_axis(axis: [f32; 3], angle: f32) -> [[f32; 3]; 3] { + let axis = normalize(axis); + let (x, y, z) = (axis[0], axis[1], axis[2]); + let (s, c) = angle.sin_cos(); + let t = 1.0 - c; + [ + [t * x * x + c, t * x * y - s * z, t * x * z + s * y], + [t * x * y + s * z, t * y * y + c, t * y * z - s * x], + [t * x * z - s * y, t * y * z + s * x, t * z * z + c], + ] +} + +fn mul_mat_vec(m: [[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] { + [ + m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], + m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], + m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cubic_respects_deadzone() { + assert_eq!(cubic(0.5, 0.01), 0.125); + assert_eq!(cubic(0.05, 0.01), 0.0); + } + + #[test] + fn enter_snapshots_frame_basis() { + let frame = CameraFrame { + basis: Basis3 { + columns: [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]], + }, + position: [3.0, 4.0, 5.0, 0.0], + previous_basis: Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + previous_position: [0.0; 4], + linear_velocity: [0.0; 4], + angular_velocity: [0.0; 4], + shake_translation: [0.0; 4], + discontinuity: false, + field_of_view_degrees: 55.0, + opacity: 1.0, + blur: 1.0, + }; + let mut manual = ManualCam::default(); + manual.set_manual_cam_mode(true, Some(&frame)); + assert!(manual.active); + assert_eq!(manual.matrix.position, [3.0, 4.0, 5.0]); + assert!((manual.matrix.at[0] - 1.0).abs() < 0.001); + } + + #[test] + fn forward_moves_along_at() { + let mut manual = ManualCam::default(); + manual.active = true; + manual.matrix.at = [0.0, 0.0, 1.0]; + let actions = GameplayActions::from_values([ + 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, + ]); + manual.update_manual_cam(&actions, ManualCamSettings::default(), 1.0); + assert!(manual.matrix.position[2] > 0.0); + } +} diff --git a/crates/skate-core/src/camera/mod.rs b/crates/skate-core/src/camera/mod.rs index 1280f414..f533b9b6 100644 --- a/crates/skate-core/src/camera/mod.rs +++ b/crates/skate-core/src/camera/mod.rs @@ -7,6 +7,7 @@ mod avoidance; mod direction; mod drop_predictor; mod look_input; +mod manual_cam; mod manager_subject; mod manager; mod manager_output; @@ -50,6 +51,7 @@ pub use avoidance::{AvoidancePath, AvoidanceSettings, AvoidanceSubject, RigAvoid pub use direction::direction_to_angles; pub use drop_predictor::{DropCollisionProvider, DropPredictor, DropSettings}; pub use look_input::{LookInput, LookSettings}; +pub use manual_cam::{ManualCam, ManualCamMatrix, ManualCamSettings}; pub use manager_subject::ManagerSubject; pub use manager::{CameraMan, ManagerSettings}; pub use compass::{Compass, CompassInputs, CompassPoseInputs, CompassSettings}; diff --git a/crates/skate-game/Cargo.toml b/crates/skate-game/Cargo.toml index ec65f3fb..c661f0a8 100644 --- a/crates/skate-game/Cargo.toml +++ b/crates/skate-game/Cargo.toml @@ -24,7 +24,6 @@ dev-dynamic = ["bevy/dynamic_linking"] [dependencies] bevy = { workspace = true, features = ["jpeg", "trace", "debug", "bevy_audio", "wav"] } -tracing-log = "0.2" skate-core.workspace = true skate-data.workspace = true half = "=2.7.1" diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index f36ea17d..a5189a4b 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -10,11 +10,30 @@ use bevy::{ prelude::*, render::{ RenderPlugin, - settings::{Backends, InstanceFlags, RenderCreation, WgpuSettings}, + settings::{Backends, InstanceFlags, RenderCreation, WgpuFeatures, WgpuSettings}, }, }; use skate_data::GameAssets; +/// Bevy's defaults plus, on request, the query features that make +/// `RenderDiagnosticsPlugin` report per-pass GPU time. +/// +/// Opt-in: a required feature the adapter lacks aborts device creation, and the +/// queries are not free. Without them a pass's cost can only be inferred from +/// invocation counts, which says nothing about how long the pass took. Set +/// `SKATE_GPU_TIMING=1` to get `render/**/elapsed_gpu`. +fn wgpu_features() -> WgpuFeatures { + let default = WgpuSettings::default().features; + if std::env::var_os("SKATE_GPU_TIMING").is_some_and(|v| v != "0") { + default + | WgpuFeatures::TIMESTAMP_QUERY + | WgpuFeatures::TIMESTAMP_QUERY_INSIDE_ENCODERS + | WgpuFeatures::PIPELINE_STATISTICS_QUERY + } else { + default + } +} + #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] pub(crate) enum FrameSet { Assets, @@ -61,6 +80,7 @@ pub(crate) fn build( // Existing machine's validation layer rejects wgpu atomic shaders. // This workaround belongs only to the rendering adapter. instance_flags: InstanceFlags::empty(), + features: wgpu_features(), ..default() }), ..default() @@ -121,6 +141,7 @@ pub(crate) fn build( app.add_plugins(crate::updater::UpdaterPlugin); app.add_plugins(crate::multiplayer::MultiplayerPlugin); app.add_plugins(crate::scoring_hud::ScoringHudPlugin); + app.add_plugins(crate::debug_cam::DebugCamPlugin); app.add_systems(Last, crate::crash_context::sample); crate::profiling::install(&mut app); app diff --git a/crates/skate-game/src/camera.rs b/crates/skate-game/src/camera.rs index efd68103..43f0be88 100644 --- a/crates/skate-game/src/camera.rs +++ b/crates/skate-game/src/camera.rs @@ -55,13 +55,22 @@ pub(crate) fn present(mut runtime: ResMut, windows: Query<&Window customiser: Option>, mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With>) { if let Ok(window) = windows.single() { - runtime.set_aspect_ratio(window.width() / window.height()); + // A minimized window reports 0x0, and 0/0 is NaN. That poisons the + // projection's field of view and trips the non-finite frame check, so + // keep the last good ratio until the window has area again. + let ratio = window.width() / window.height(); + if ratio.is_finite() && ratio > 0. { + runtime.set_aspect_ratio(ratio); + } } let Some((previous, current, alpha)) = history.view(&replay, time.overstep_fraction()) else { return; }; + let manual_pose = crate::debug_cam::DebugCam::pose(&runtime); for (mut camera, mut transform, mut projection) in &mut cameras { *transform = crate::presentation::blend(previous.camera, current.camera, alpha); if replay.active { if let Some(free) = replay.free_camera { *transform = free; } + } else if let Some(pose) = manual_pose { + *transform = pose; } if let Projection::Perspective(p) = &mut *projection { p.fov = previous.fov + (current.fov - previous.fov) * alpha; diff --git a/crates/skate-game/src/camera/runtime.rs b/crates/skate-game/src/camera/runtime.rs index 553b103a..193d5718 100644 --- a/crates/skate-game/src/camera/runtime.rs +++ b/crates/skate-game/src/camera/runtime.rs @@ -3,8 +3,8 @@ //! once per completed physical/animation publication; rendering only presents it. use std::path::Path; use bevy::prelude::Resource; -use skate_core::{camera::{CameraFrame, CameraMan, CompassSettings, ManagerSettings, - MovingObstacleProvider, ShakeSamples, SimulationRateRequest, SlowMotionSettings}, +use skate_core::{camera::{CameraFrame, CameraMan, CompassSettings, ManualCam, ManualCamSettings, + ManagerSettings, MovingObstacleProvider, ShakeSamples, SimulationRateRequest, SlowMotionSettings}, physics::board_world::BoardWorld, point_graph::PointGraph}; use skate_data::collections::Collections; use super::{collision::CameraCollision, graph::CameraGraph, graph_subject::CameraGraphEnvironment, @@ -29,6 +29,8 @@ pub(crate) struct CameraRuntime { /// Native message order, including End followed by Begin in one graph tick. /// The simulation schedule drains these after advance. pub simulation_rate_requests: Vec, + pub manual_cam: ManualCam, + pub manual_cam_settings: ManualCamSettings, } #[cfg(test)] @@ -58,14 +60,30 @@ impl CameraRuntime { compass_settings: settings::compass_settings(&data)?, shakes: [samples("1.shk")?, samples("2.shk")?], trajectories: core::array::from_fn(|_| TrajectoryResult::new()), frame: None, latest_subject: None, - simulation_rate_requests: Vec::new() }) + simulation_rate_requests: Vec::new(), + manual_cam: ManualCam::default(), + manual_cam_settings: settings::manual_cam_settings(&data)? }) } + /// Ignores a degenerate ratio rather than storing it. + /// + /// A minimized window reports 0x0, so the caller's `width / height` is + /// `0.0 / 0.0` — NaN. Storing that makes the manager derive a NaN field of + /// view, which fails the non-finite frame check on every subsequent frame + /// even after the window is restored. Keeping the last good ratio is + /// correct: nothing is visible while minimized. pub fn set_aspect_ratio(&mut self, value: f32) { - self.manager.state.aspect_ratio = value; + if value.is_finite() && value > 0.0 { + self.manager.state.aspect_ratio = value; + } } pub fn selected_shot(&self) -> &str { &self.manager.shots.current().name } + /// `GetMatrix` for presentation capture when manual cam may be active. + pub fn presentation_frame(&self) -> Option { + self.frame.map(|frame| self.manual_cam.get_matrix(&frame)) + } + pub fn advance(&mut self, dt: f32, snapshot: CameraSubjectSnapshot, world: &BoardWorld, query_gravity: [f32; 4], environment: &CameraGraphEnvironment, moving: &mut impl MovingObstacleProvider) -> Result { diff --git a/crates/skate-game/src/camera/settings.rs b/crates/skate-game/src/camera/settings.rs index b7ab7144..01c0609d 100644 --- a/crates/skate-game/src/camera/settings.rs +++ b/crates/skate-game/src/camera/settings.rs @@ -3,8 +3,9 @@ use skate_core::{ camera::{ AnchorTrackingSettings, AngleTrackingSettings, AvoidanceSettings, DistanceTrackingSettings, - DropSettings, FrameSettings, LookSettings, OrientationSettings, OrientationTrackerSettings, - RigPositioningSettings, RigSettings, ShakeSettings, ManagerSettings, CompassSettings, + DropSettings, FrameSettings, LookSettings, ManualCamSettings, OrientationSettings, + OrientationTrackerSettings, RigPositioningSettings, RigSettings, ShakeSettings, + ManagerSettings, CompassSettings, }, point_graph::PointGraph, }; @@ -54,6 +55,17 @@ pub(crate) fn drop_settings(data: &Collections) -> Result }) } +pub(crate) fn manual_cam_settings(data: &Collections) -> Result { + let mut settings = ManualCamSettings::default(); + if let Ok(speed) = data.float("camera", "freecam", "FreeCamHeadingSpeed") { + settings.move_speed = speed.max(1.0) * 4.0; + } + if let Ok(speed) = data.float("camera", "freecam", "FreeCamElevationSpeed") { + settings.look_speed = speed.max(0.1); + } + Ok(settings) +} + pub(crate) fn look_settings(data: &Collections) -> Result { // Settings object952/960/964/972 -> parameter936/944/948/956. Ok(LookSettings { diff --git a/crates/skate-game/src/camera/tests/runtime.rs b/crates/skate-game/src/camera/tests/runtime.rs index 0a28b2be..0b61665c 100644 --- a/crates/skate-game/src/camera/tests/runtime.rs +++ b/crates/skate-game/src/camera/tests/runtime.rs @@ -8,3 +8,25 @@ fn private_stock_camera_loads_every_graph_operation_and_shot_dependency() { let camera = CameraRuntime::load(root).expect("complete normal stock camera assets"); assert!(camera.frame.is_none()); } + +/// Minimizing the window makes the caller divide 0.0 by 0.0, and a stored NaN +/// aspect ratio produces a NaN field of view that keeps failing the non-finite +/// frame check after the window is restored. +#[test] +#[ignore = "requires the user's converted stock camera and graph assets"] +fn a_minimized_window_cannot_poison_the_aspect_ratio() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let mut camera = + CameraRuntime::load(std::path::Path::new(&root)).expect("stock camera assets"); + camera.set_aspect_ratio(16.0 / 9.0); + let good = camera.manager.state.aspect_ratio; + for degenerate in [f32::NAN, 0.0, -1.5, f32::INFINITY] { + camera.set_aspect_ratio(degenerate); + assert_eq!( + camera.manager.state.aspect_ratio, good, + "{degenerate} should have been rejected" + ); + } + camera.set_aspect_ratio(4.0 / 3.0); + assert_eq!(camera.manager.state.aspect_ratio, 4.0 / 3.0); +} diff --git a/crates/skate-game/src/customiser_parts.rs b/crates/skate-game/src/customiser_parts.rs index 238f8337..a76a529f 100644 --- a/crates/skate-game/src/customiser_parts.rs +++ b/crates/skate-game/src/customiser_parts.rs @@ -438,8 +438,10 @@ impl Parts { retail: material.extension.retail.clone(), retail_mask: material.extension.retail_mask.clone(), enabled: if source.opacity.is_some() {Vec4::Y} else {Vec4::ZERO}, ..default() }; - if extension.retail.tint.w > 0. { - extension.retail.tint = Vec4::from_array(material.base.base_color.to_linear().to_f32_array()); + if extension.retail.options.z < 0. || extension.retail.tint.w > 0. { + let mut tint = Vec4::from_array(material.base.base_color.to_linear().to_f32_array()); + tint.w = 1.; + extension.retail.tint = tint; } if let Some((tid,t)) = stamp["id"].as_str().and_then(|id| self.library.tattoos.get(id).map(|t|(id,t))) { let key=match stamp["side"].as_u64().unwrap_or(0) {1=>"StampUVConstraintQ4",2=>"StampUVConstraintQ1",3=>"StampUVConstraintQ2",_=>"StampUVConstraintQ3"}; @@ -482,11 +484,20 @@ impl Parts { let rough = m.rough.as_ref().map(|p| load(p, true)); let opacity = m.opacity.as_ref().map(|p| load(p, true)); let retail_mask = m.lighting.as_ref().and_then(|l| l.specular.as_ref()).map(|p| load(p, true)); - let retail = m.lighting.as_ref().filter(|l| l.params.len() == 9).map(|l| crate::retail_character::CharacterParams { - tint: Vec4::from_array(Color::srgb(m.tint[0],m.tint[1],m.tint[2]).to_linear().to_f32_array()), - options: Vec4::new(f32::from(normal.is_some()), f32::from(retail_mask.is_some()), -1., f32::from(l.is_hair())), - rows: std::array::from_fn(|i| Vec4::from_array(l.params[i])), - ..default() + let retail = m.lighting.as_ref().filter(|l| l.params.len() == 9).map(|l| { + let mut tint = Vec4::from_array(Color::srgb(m.tint[0], m.tint[1], m.tint[2]).to_linear().to_f32_array()); + tint.w = 1.; + crate::retail_character::CharacterParams { + tint, + options: Vec4::new( + f32::from(normal.is_some()), + f32::from(retail_mask.is_some()), + -1., + f32::from(l.is_hair()), + ), + rows: std::array::from_fn(|i| Vec4::from_array(l.params[i])), + ..default() + } }).unwrap_or_default(); let material = materials.add(SkaterMaterial { base: StandardMaterial { diff --git a/crates/skate-game/src/debug_cam.rs b/crates/skate-game/src/debug_cam.rs new file mode 100644 index 00000000..e7f2757c --- /dev/null +++ b/crates/skate-game/src/debug_cam.rs @@ -0,0 +1,234 @@ +//! Pad chords + intents only — mirrors `skate3_debug_cam.cpp`. +//! `SetManualCamMode` / `UpdateManualCam` / `GetMatrix` live in `ManualCam`. +use bevy::prelude::*; +use crate::app::SimulationSet; + +const DPAD_RIGHT: u16 = 0x0008; +const R3: u16 = 0x0080; +const B: u16 = 0x2000; + +#[derive(Resource, Default)] +pub(crate) struct DebugCam { + want_toggle: bool, + want_spawn: bool, + previous_buttons: u16, + release_controls: bool, +} + +impl DebugCam { + pub fn active(camera: &crate::camera::CameraRuntime) -> bool { + camera.manual_cam.manual_cam_active() + } + + pub fn suppress_gameplay(&self, camera: &crate::camera::CameraRuntime) -> bool { + camera.manual_cam.manual_cam_active() || self.release_controls + } + + pub fn pose(camera: &crate::camera::CameraRuntime) -> Option { + let Some(base) = camera.frame else { return None; }; + if !camera.manual_cam.manual_cam_active() { + return None; + } + let frame = camera.manual_cam.get_matrix(&base); + let [right, up, at] = frame.basis.columns.map(Vec3::from_array); + Some(Transform { + translation: Vec3::new(frame.position[0], frame.position[1], frame.position[2]), + rotation: Quat::from_mat3(&Mat3::from_cols(-right, up, -at)).normalize(), + scale: Vec3::ONE, + }) + } +} + +pub(crate) struct DebugCamPlugin; +impl Plugin for DebugCamPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems( + FixedUpdate, + on_camera_input + .in_set(SimulationSet::Input) + .before(crate::input::publish_actions), + ) + .add_systems(Startup, setup_overlay) + .add_systems(Update, update_overlay); + } +} + +fn chord(buttons: u16, mask: u16, previous: u16) -> bool { + buttons & mask == mask && previous & mask != mask +} + +fn gameplay_blocked( + menu: Option>, + replay: &crate::replay::Replay, + customiser: Option>, + travel: Option>, + mods: Option>, + custom_models: Option>, +) -> bool { + replay.active + || !crate::graphics_menu::gameplay_active(menu) + || customiser.is_some_and(|c| c.open) + || travel.is_some_and(|t| t.open) + || mods.is_some_and(|m| m.open) + || custom_models.is_some_and(|m| m.open) +} + +fn idle(buttons: u16, triggers: [f32; 2], sticks: [[f32; 2]; 2]) -> bool { + buttons == 0 + && triggers.iter().all(|v| *v < 0.05) + && sticks.iter().flatten().all(|v| v.abs() < 0.18) +} + +/// `PollCameraControllerChords` + `DebugCam_OnCameraInput` at `CameraPresentation::Input`. +fn on_camera_input( + mut debug: ResMut, + mut camera: ResMut, + mut skater: ResMut, + input: Res, + replay: Res, + menu: Option>, + customiser: Option>, + travel: Option>, + mods: Option>, + custom_models: Option>, + time: Res>, +) { + if replay.active && camera.manual_cam.manual_cam_active() { + debug.want_toggle = true; + } + + let raw = input.raw_input(); + if !gameplay_blocked(menu, &replay, customiser, travel, mods, custom_models) { + if chord(raw.buttons, DPAD_RIGHT | R3, debug.previous_buttons) { + debug.want_toggle = true; + } + if camera.manual_cam.manual_cam_active() && chord(raw.buttons, B, debug.previous_buttons) { + debug.want_spawn = true; + } + } + debug.previous_buttons = raw.buttons; + + let cur = camera.manual_cam.manual_cam_active(); + let want_toggle = debug.want_toggle; + let want_spawn = debug.want_spawn; + debug.want_toggle = false; + debug.want_spawn = false; + + if want_toggle { + let frame = camera.frame.clone(); + if cur { + camera.manual_cam.set_manual_cam_mode(false, frame.as_ref()); + debug.release_controls = true; + } else if frame.is_some() { + camera.manual_cam.set_manual_cam_mode(true, frame.as_ref()); + debug.release_controls = true; + } + } + if want_spawn && camera.manual_cam.manual_cam_active() { + let matrix = camera.manual_cam.spawn_matrix(); + let frame = camera.frame.clone(); + camera.manual_cam.set_manual_cam_mode(false, frame.as_ref()); + debug.release_controls = true; + if let Err(error) = skater.travel_to(matrix) { + bevy::log::warn!("debug cam spawn: {error}"); + } + } + + if camera.manual_cam.manual_cam_active() { + let settings = camera.manual_cam_settings; + let actions = manual_actions_from_raw(raw); + camera + .manual_cam + .update_manual_cam(&actions, settings, time.delta_secs()); + } + + if debug.release_controls + && !camera.manual_cam.manual_cam_active() + && idle(raw.buttons, raw.triggers, [raw.left, raw.right]) + { + debug.release_controls = false; + } +} + +fn manual_actions_from_raw( + raw: crate::input::RawInput, +) -> skate_core::input::gameplay_map::GameplayActions { + use skate_core::input::gameplay_map::GameplayActions; + GameplayActions::from_values([ + raw.left[0], + raw.left[1], + f32::from(raw.buttons & 0x0040 != 0), + raw.right[0], + raw.right[1], + 0.0, + raw.triggers[0], + raw.triggers[1], + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ]) +} + +#[derive(Component)] +struct DebugCamOverlay; + +fn setup_overlay(mut commands: Commands) { + commands.spawn(( + DebugCamOverlay, + GlobalZIndex(6), + Node { + display: Display::None, + position_type: PositionType::Absolute, + bottom: px(24), + left: percent(8), + width: percent(84), + padding: UiRect::all(px(12)), + ..default() + }, + BackgroundColor(Color::srgba(0.02, 0.035, 0.05, 0.92)), + Text::new( + "DEBUG CAM | Left stick Move | Right stick Look | Triggers Up / down | L3 Fast | B Spawn at camera | D-pad Right + R3 Exit", + ), + TextFont { + font_size: 14., + ..default() + }, + TextColor(Color::srgb(0.75, 0.82, 0.86)), + )); +} + +fn update_overlay( + camera: Res, + mut roots: Query<&mut Node, With>, +) { + for mut node in &mut roots { + node.display = if camera.manual_cam.manual_cam_active() { + Display::Flex + } else { + Display::None + }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chord_needs_both_buttons_and_is_edge_only() { + assert!(chord(DPAD_RIGHT | R3, DPAD_RIGHT | R3, 0)); + assert!(!chord(DPAD_RIGHT | R3, DPAD_RIGHT | R3, DPAD_RIGHT | R3)); + assert!(!chord(DPAD_RIGHT, DPAD_RIGHT | R3, 0)); + assert!(chord(DPAD_RIGHT | R3, DPAD_RIGHT | R3, DPAD_RIGHT)); + assert!(!chord(B, B, B)); + assert!(chord(B, B, 0)); + } +} diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index 8540339f..5a3b78b0 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -2,15 +2,12 @@ use crate::difficulty::Difficulty; use bevy::{ camera::RenderTarget, - core_pipeline::prepass::DepthPrepass, image::ImageSampler, prelude::*, render::{ - experimental::occlusion_culling::OcclusionCulling, render_resource::{Extent3d, TextureFormat}, - renderer::RenderAdapter, }, - window::{PresentMode, PrimaryWindow}, + window::{MonitorSelection, PresentMode, PrimaryWindow, WindowMode}, }; use serde::{Deserialize, Serialize}; use std::{ @@ -38,9 +35,7 @@ struct GraphicsSettings { width: u32, height: u32, scale: u32, - samples: u32, fps: u32, - occlusion: bool, hour: f32, day_speed: u32, ambient_level: Option, @@ -51,9 +46,7 @@ impl Default for GraphicsSettings { width: 1280, height: 800, scale: 100, - samples: 4, fps: 0, - occlusion: true, hour: 12., day_speed: 60, ambient_level: None, @@ -71,9 +64,6 @@ impl GraphicsSettings { if !SCALES.contains(&self.scale) { self.scale = 100; } - if ![1, 2, 4, 8].contains(&self.samples) { - self.samples = 4; - } if !LIMITS.contains(&self.fps) { self.fps = 0; } @@ -89,7 +79,6 @@ pub(crate) struct Menu { selected: usize, settings: GraphicsSettings, path: PathBuf, - supported_msaa: Vec, difficulty: Difficulty, status: String, maps: Vec, @@ -145,6 +134,7 @@ impl Plugin for GraphicsMenuPlugin { app.insert_resource(FramePacer(Instant::now())) .add_systems(PostStartup, setup.in_set(PresentationSetup)) .add_systems(PreUpdate, interact.in_set(MenuInput).after(bevy::input::InputSystems)) + .add_systems(PreUpdate, toggle_fullscreen.after(bevy::input::InputSystems)) .add_systems(Update, (crate::map_render::advance_day, apply, labels).chain()) .add_systems(PostUpdate, crate::map_render::position_celestial_bodies.before(bevy::transform::TransformSystems::Propagate)) .add_systems(Last, pace); @@ -156,7 +146,6 @@ fn setup( mut images: ResMut>, mut window: Single<&mut Window, With>, cameras: Query>, - adapter: Res, mut time: ResMut>, ) { let path = config @@ -172,33 +161,6 @@ fn setup( Err(_) => GraphicsSettings::default(), } .validated(); - let supported_msaa: Vec<_> = [1, 2, 4, 8] - .into_iter() - .filter(|&samples| { - [ - TextureFormat::Rgba16Float, - TextureFormat::Rgba8UnormSrgb, - TextureFormat::Depth32Float, - ] - .into_iter() - .all(|format| { - adapter - .get_texture_format_features(format) - .flags - .sample_count_supported(samples) - }) - }) - .collect(); - let mut settings = settings; - // Reproducible A/B override; normal launches use the saved menu setting. - match std::env::var("SKATE_OCCLUSION").as_deref() { - Ok("0") => settings.occlusion = false, - Ok("1") => settings.occlusion = true, - _ => {} - } - if !supported_msaa.contains(&settings.samples) { - settings.samples = 1; - } window .resolution .set_physical_resolution(settings.width, settings.height); @@ -210,7 +172,8 @@ fn setup( for camera in &cameras { commands.entity(camera).insert(( RenderTarget::Image(target.clone().into()), - msaa(settings.samples), + // Fixed policy, not a setting: see `render_capacity`. + Msaa::Off, )); // Render the world before the presentation camera consumes its image. commands.entity(camera).insert(Camera { @@ -239,7 +202,7 @@ fn setup( BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| { panel.spawn((Text::new("GAME MENU"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE))); panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); - for i in 0..17 { + for i in 0..15 { panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(26),padding:UiRect::all(px(3)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()}, BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| { row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE))); @@ -258,7 +221,6 @@ fn setup( selected: 0, settings, path, - supported_msaa, difficulty: config.difficulty, status: String::new(), maps, @@ -268,14 +230,6 @@ fn setup( daylight: false, }); } -fn msaa(samples: u32) -> Msaa { - match samples { - 2 => Msaa::Sample2, - 4 => Msaa::Sample4, - 8 => Msaa::Sample8, - _ => Msaa::Off, - } -} fn cycle(values: &[T], value: T, direction: i32) -> T { let index = values.iter().position(|x| *x == value).unwrap_or(0) as i32; values[(index + direction).rem_euclid(values.len() as i32) as usize] @@ -341,7 +295,12 @@ pub(crate) fn interact( if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 { action = Some((menu.selected, -1)); } - if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) || nav.pressed & (8 | 0x1000) != 0 { + if keys.just_pressed(KeyCode::ArrowRight) + || (keys.just_pressed(KeyCode::Enter) + && !keys.pressed(KeyCode::AltLeft) + && !keys.pressed(KeyCode::AltRight)) + || nav.pressed & (8 | 0x1000) != 0 + { action = Some((menu.selected, 1)); } for (interaction, row) in &buttons { @@ -423,13 +382,8 @@ pub(crate) fn interact( (menu.settings.width, menu.settings.height) = size; } 1 => menu.settings.scale = cycle(SCALES, menu.settings.scale, direction), - 2 => { - menu.settings.samples = - cycle(&menu.supported_msaa, menu.settings.samples, direction) - } - 3 => menu.settings.fps = cycle(LIMITS, menu.settings.fps, direction), - 4 => menu.settings.occlusion = !menu.settings.occlusion, - 5 => { + 2 => menu.settings.fps = cycle(LIMITS, menu.settings.fps, direction), + 3 => { menu.difficulty = cycle(&Difficulty::ALL, menu.difficulty, direction); physics.set_difficulty(menu.difficulty); config.difficulty = menu.difficulty; @@ -438,13 +392,13 @@ pub(crate) fn interact( Err(e) => format!("Applied, but could not save: {e}"), }; } - 6 => { + 4 => { menu.selected_map = (menu.selected_map as i32 + direction) .rem_euclid(menu.maps.len() as i32) as usize; menu.status = "Choose Load map to switch".into(); } - 7 => { + 5 => { if net.active() { menu.status = "Leave multiplayer before switching maps".into(); } else { @@ -453,24 +407,24 @@ pub(crate) fn interact( menu.status = "Loading map...".into(); } } - 8 => menu.open = false, - 9 => { + 6 => menu.open = false, + 7 => { exit.write(AppExit::Success); } - 10 => { custom_models.request_stock(); customiser.begin(); }, - 11 => { + 8 => { custom_models.request_stock(); customiser.begin(); }, + 9 => { menu.multiplayer = true; menu.selected = 0; } - 12 => custom_models.begin(), - 13 => menu.status = updater.open(false), - 14 => travel.open = true, - 15 => { menu.daylight = true; menu.selected = 0; menu.status = "Custom maps: change time, cycle speed and ambient light. Retail lighting stays authored.".into(); }, - 16 => mods.begin(), + 10 => custom_models.begin(), + 11 => menu.status = updater.open(false), + 12 => travel.open = true, + 13 => { menu.daylight = true; menu.selected = 0; menu.status = "Custom maps: change time, cycle speed and ambient light. Retail lighting stays authored.".into(); }, + 14 => mods.begin(), _ => {} } } - if (row < 5 && !menu.multiplayer && !menu.daylight && !day_action) || (day_action && row < 3) { + if (row < 3 && !menu.multiplayer && !menu.daylight && !day_action) || (day_action && row < 3) { let save = (|| -> Result<(), String> { std::fs::create_dir_all(menu.path.parent().unwrap()).map_err(|e| e.to_string())?; std::fs::write( @@ -491,48 +445,44 @@ pub(crate) fn interact( time.unpause(); } } +fn toggle_fullscreen( + keys: Res>, + menu: Res, + mut window: Single<&mut Window, With>, +) { + let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight); + if !alt || !keys.just_pressed(KeyCode::Enter) { + return; + } + match window.mode { + WindowMode::Windowed => { + window.mode = WindowMode::BorderlessFullscreen(MonitorSelection::Primary); + } + WindowMode::BorderlessFullscreen(_) | WindowMode::Fullscreen(_, _) => { + window.mode = WindowMode::Windowed; + window + .resolution + .set_physical_resolution(menu.settings.width, menu.settings.height); + } + _ => {} + } +} fn apply( - mut commands: Commands, menu: Res, mut window: Single<&mut Window, With>, target: Res, mut images: ResMut>, - mut cameras: Query<(Entity, &mut Msaa), With>, mut previous: Local>, ) { - if previous - .as_ref() - .is_none_or(|p| p.width != menu.settings.width || p.height != menu.settings.height) + if window.mode == WindowMode::Windowed + && previous + .as_ref() + .is_none_or(|p| p.width != menu.settings.width || p.height != menu.settings.height) { window .resolution .set_physical_resolution(menu.settings.width, menu.settings.height); } - if previous - .as_ref() - .is_none_or(|p| p.samples != menu.settings.samples) - { - for (_, mut samples) in &mut cameras { - *samples = msaa(menu.settings.samples); - } - } - if previous - .as_ref() - .is_none_or(|p| p.occlusion != menu.settings.occlusion) - { - for (entity, _) in &cameras { - if menu.settings.occlusion { - commands - .entity(entity) - .insert((DepthPrepass, OcclusionCulling)); - } else { - commands - .entity(entity) - .remove::<(DepthPrepass, OcclusionCulling)>(); - } - } - info!("GPU occlusion culling: {}", menu.settings.occlusion); - } let size = menu.settings.internal_size(window.physical_size()); if let Some(image) = images.get(&target.0) { if image.size() != size { @@ -635,14 +585,6 @@ fn labels( s.scale, size.x, size.y ), 2 => format!( - "MSAA {}", - if s.samples == 1 { - "Off".into() - } else { - format!("{}x", s.samples) - } - ), - 3 => format!( "FPS limit {}", if s.fps == 0 { "Unlimited".into() @@ -650,24 +592,20 @@ fn labels( s.fps.to_string() } ), + 3 => format!("Difficulty {}", menu.difficulty.label()), 4 => format!( - "Occlusion culling {}", - if s.occlusion { "On" } else { "Off" } - ), - 5 => format!("Difficulty {}", menu.difficulty.label()), - 6 => format!( "Map {}", menu.maps[menu.selected_map].label ), - 7 => if transition.busy() { "Loading map...".into() } else { "Load map".into() }, - 8 => "Resume".into(), - 9 => "Quit game".into(), - 10 => "Character customiser".into(), - 12 => "Custom models".into(), - 13 => "Updates".into(), - 14 => "Teleport…".into(), - 15 => "Day & night…".into(), - 16 => "Mods…".into(), + 5 => if transition.busy() { "Loading map...".into() } else { "Load map".into() }, + 6 => "Resume".into(), + 7 => "Quit game".into(), + 8 => "Character customiser".into(), + 10 => "Custom models".into(), + 11 => "Updates".into(), + 12 => "Teleport…".into(), + 13 => "Day & night…".into(), + 14 => "Mods…".into(), _ => "Multiplayer".into(), } }; @@ -728,7 +666,7 @@ mod tests { .insert_resource(images) .insert_resource(Menu { open: false, selected: 0, settings: GraphicsSettings::default(), - difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(), + difficulty: Difficulty::Easy, path: PathBuf::new(), status: String::new(), multiplayer: false, browser: false, daylight: false, maps: vec![crate::map_library::Entry { label: "Test world".into(), path: None }], selected_map: 0, }) @@ -747,17 +685,13 @@ mod tests { app.world_mut().spawn((Window::default(), PrimaryWindow)); let camera = app.world_mut().spawn((Camera3d::default(), Msaa::Off)).id(); app.update(); - assert!(app.world().entity(camera).contains::()); - assert!(app.world().entity(camera).contains::()); + // MSAA and occlusion culling are no longer settings, so the only thing + // `apply` still adapts is the internal render target size. { let mut menu = app.world_mut().resource_mut::(); - menu.settings.occlusion = false; - menu.settings.samples = 1; menu.settings.scale = 67; } app.update(); - assert!(!app.world().entity(camera).contains::()); - assert!(!app.world().entity(camera).contains::()); assert_eq!(*app.world().get::(camera).unwrap(), Msaa::Off); assert_eq!( app.world() @@ -767,20 +701,11 @@ mod tests { .size(), UVec2::new(857, 536) ); - { - let mut menu = app.world_mut().resource_mut::(); - menu.settings.occlusion = true; - menu.settings.samples = 8; - } - app.update(); - assert!(app.world().entity(camera).contains::()); - assert!(app.world().entity(camera).contains::()); - assert_eq!(*app.world().get::(camera).unwrap(), Msaa::Sample8); } #[test] fn invalid_saved_values_fall_back() { let settings: GraphicsSettings = - serde_json::from_str(r#"{"width":0,"height":999999,"scale":0,"samples":3,"fps":1}"#) + serde_json::from_str(r#"{"width":0,"height":999999,"scale":0,"fps":1}"#) .unwrap(); assert_eq!(settings.validated(), GraphicsSettings::default()); } diff --git a/crates/skate-game/src/input.rs b/crates/skate-game/src/input.rs index 4539f222..2fa9ed0d 100644 --- a/crates/skate-game/src/input.rs +++ b/crates/skate-game/src/input.rs @@ -8,7 +8,7 @@ mod gesture_mapping_data; pub(crate) mod gesture_mapping; pub(crate) mod gesture_input; pub(crate) mod platform; -pub(crate) use controllers::{ControllerInput, ControllerStatus}; +pub(crate) use controllers::{ControllerInput, ControllerStatus, RawInput}; use skate_core::input::tick::TickInput; #[derive(Resource, Clone, Copy, Debug)] @@ -57,14 +57,27 @@ pub(crate) fn poll_controllers(mut input: ResMut,config:Res, mut published: ResMut, - menu:Option>, + menu: Option>, + debug: Res, + camera: Res, ) { - if !crate::graphics_menu::gameplay_active(menu) {input.discard_gameplay();} + if !crate::graphics_menu::gameplay_active(menu) || debug.suppress_gameplay(&camera) { + input.discard_gameplay(); + } input.publish_actions(); - published.0 = input.tick_input(); + let tick = input.tick_input(); + published.0 = if debug.suppress_gameplay(&camera) { + TickInput::new( + tick.tick(), + skate_core::input::gameplay_map::GameplayActions::from_values([0.0; 18]), + tick.controller_available(), + ) + } else { + tick + }; } #[cfg(test)] diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 569091ea..c740c333 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -41,8 +41,11 @@ mod teleport_menu; mod render_capacity; mod retail_render; mod retail_character; +mod retail_exposure; mod retail_irradiance; +mod retail_sky; mod presentation; +mod debug_cam; mod replay; mod world; mod grind_world; diff --git a/crates/skate-game/src/map_render.rs b/crates/skate-game/src/map_render.rs index 68395f36..6e4aad0f 100644 --- a/crates/skate-game/src/map_render.rs +++ b/crates/skate-game/src/map_render.rs @@ -1,415 +1,273 @@ -//! CPU scene construction can run off-thread using the live asset allocators. -//! Only publishing assets/entities and retiring the previous scene touch the ECS. -use bevy::{asset::{AssetHandleProvider, AssetId}, ecs::world::CommandQueue, prelude::*}; -use crate::retail_render::{RetailSkyMaterial, RetailWorldMaterial}; +//! Off-thread scene preparation and transactional publish. +//! +//! Assets are built on a worker with *reserved* handles, then inserted on the +//! main thread in one pass. Nothing touches live ECS until commit, so a failed +//! load cannot leave a half-replaced world (RFC 1 §10.2). +//! +//! Retirement is the leak boundary: `MapAssets` owns every asset the scene +//! created, and dropping a map releases exactly those. Unlike the previous +//! design this does not hardcode the list of asset types — each staged group +//! carries its own release behaviour, so adding a renderer asset type needs no +//! change here. +use bevy::{ + asset::{Asset, AssetId, Assets, AssetHandleProvider}, + ecs::world::CommandQueue, + prelude::*, +}; +use skate_data::skate_map::SkateMap; +use std::path::Path; +/// Marks everything the current map owns. Retirement despawns exactly this set, +/// which is why the character and other persistent entities survive a map change. #[derive(Component)] pub(crate) struct MapEntity; +/// Somewhere to put an asset that may not be the live `Assets` collection. pub(crate) trait AssetSink { fn add(&mut self, asset: A) -> Handle; } -impl AssetSink for Assets { - fn add(&mut self, asset: A) -> Handle { Assets::add(self, asset) } + +/// Erased release behaviour for one staged asset type. +trait OwnedAssets: Send + Sync { + fn release(&mut self, world: &mut World); } +/// Assets built off-thread against reserved handles. +/// +/// `reserve_handle` hands out a real, strong handle without needing `&mut +/// Assets`, which is what makes worker-thread construction possible at all. pub(crate) struct StagedAssets { provider: AssetHandleProvider, - values: Vec<(Handle, A)>, + pending: Vec<(Handle, A)>, + owned: Vec>, } + impl StagedAssets { - pub fn iter_mut(&mut self) -> impl Iterator, &mut A)> { - self.values.iter_mut().map(|(h, a)| (h.id(), a)) - } fn new(world: &World) -> Self { - Self { provider: world.resource::>().get_handle_provider(), values: Vec::new() } + Self { + provider: world.resource::>().get_handle_provider(), + pending: Vec::new(), + owned: Vec::new(), + } } - fn publish(self, world: &mut World) -> Vec> { + + fn publish(&mut self, world: &mut World) { let mut assets = world.resource_mut::>(); - self.values.into_iter().map(|(handle, asset)| { - let id = handle.id(); - // Reserved from this Assets' allocator; the staged strong handle is - // alive throughout preparation, so its generation cannot be reused. - assets.insert(id, asset).expect("reserved map asset generation"); - id - }).collect() + for (handle, asset) in self.pending.drain(..) { + self.owned.push(handle.id()); + // The handle came from this same provider moments ago, so a stale + // generation would mean the reservation logic is broken, not that a + // map failed to load. Say so rather than dropping geometry silently. + if let Err(error) = assets.insert(handle.id(), asset) { + error!( + "staged {} asset rejected on publish: {error}", + std::any::type_name::() + ); + } + } } } + impl AssetSink for StagedAssets { fn add(&mut self, asset: A) -> Handle { let handle = self.provider.reserve_handle().typed::(); - self.values.push((handle.clone(), asset)); + self.pending.push((handle.clone(), asset)); handle } } -/// Deferred spawns allocate their ECS identities only when the scene commits. -#[derive(Default)] -pub(crate) struct SceneCommands(CommandQueue); -pub(crate) struct SceneEntity<'a> { - commands: &'a mut CommandQueue, - id: std::sync::Arc>, -} -impl SceneCommands { - pub fn spawn(&mut self, bundle: B) -> SceneEntity<'_> { - let id = std::sync::Arc::new(std::sync::OnceLock::new()); - let spawned = id.clone(); - self.0.push(move |world: &mut World| { - spawned.set(world.spawn((MapEntity, bundle)).id()).unwrap(); - }); - SceneEntity { commands: &mut self.0, id } - } - pub fn insert_resource(&mut self, resource: R) { - self.0.push(move |world: &mut World| { world.insert_resource(resource); }); - } -} -impl SceneEntity<'_> { - pub fn insert(&mut self, bundle: B) { - let id = self.id.clone(); - self.commands.push(move |world: &mut World| { - world.entity_mut(*id.get().expect("scene spawn precedes insert")).insert(bundle); - }); - } -} - -pub(crate) struct PreparedScene { - commands: SceneCommands, - meshes: StagedAssets, - materials: StagedAssets, - retail: StagedAssets, - sky: StagedAssets, - images: StagedAssets, -} -impl PreparedScene { - pub fn new(world: &World) -> Self { - Self { commands: Default::default(), meshes: StagedAssets::new(world), - materials: StagedAssets::new(world), retail: StagedAssets::new(world), - sky: StagedAssets::new(world), images: StagedAssets::new(world) } - } - pub fn prepare(&mut self, map: Option<&skate_data::skate_map::SkateMap>, root: &std::path::Path) { - // Every scene starts with the same environment defaults. Map-specific - // resources below overwrite these, including after a native scene. - self.commands.insert_resource(ClearColor(Color::srgb(0.065, 0.08, 0.10))); - self.commands.insert_resource(GlobalAmbientLight { - color: Color::WHITE, brightness: 350., ..default() - }); - if let Some(map) = map { - crate::skate_world::spawn(map, &mut self.commands, &mut self.meshes, - &mut self.materials, &mut self.retail, &mut self.images, &crate::retail_render::MaterialTuning::load(root)); - if crate::retail_render::RetailScene::for_map(map) { - crate::retail_render::spawn_backdrop(&map.name, root, &mut self.commands, &mut self.meshes, &mut self.materials, &mut self.retail, &mut self.images); - crate::retail_render::spawn_sky(&map.name, root, &mut self.commands, - &mut self.meshes, &mut self.images, &mut self.sky, &mut self.retail); - } else { - custom_lighting(map, &mut self.commands); - celestial_bodies(&mut self.commands, &mut self.meshes, &mut self.materials, &mut self.images); - } - } else { - crate::world::spawn_test_world(&mut self.commands, &mut self.meshes, &mut self.materials); +impl OwnedAssets for StagedAssets { + fn release(&mut self, world: &mut World) { + let mut assets = world.resource_mut::>(); + for id in self.owned.drain(..) { + assets.remove(id); } - } - pub fn publish(mut self, world: &mut World) { - let _span = info_span!("publish_map_assets").entered(); - let owned = MapAssets { - meshes: self.meshes.publish(world), materials: self.materials.publish(world), - retail: self.retail.publish(world), sky: self.sky.publish(world), - images: self.images.publish(world), - }; - self.commands.0.apply(world); - world.insert_resource(owned); + self.pending.clear(); } } +/// Deferred spawns. Every entity created through this gets `MapEntity`, so +/// retirement cannot miss one by omission. +#[derive(Default)] +pub(crate) struct SceneCommands { + queue: CommandQueue, +} + +impl SceneCommands { + pub(crate) fn spawn(&mut self, bundle: B) { + self.queue.push(move |world: &mut World| { + world.spawn((bundle, MapEntity)); + }); + } +} + +/// Assets owned by the live scene, released on the next map change. #[derive(Resource, Default)] pub(crate) struct MapAssets { - meshes: Vec>, - materials: Vec>, - retail: Vec>, - sky: Vec>, - images: Vec>, + groups: Vec>, } -/// Scene-owned environment; retired with its sun when switching maps. +impl MapAssets { + /// Despawn the current map and release its assets. Safe to call with no map + /// loaded, which is the case on the very first transition. + pub(crate) fn retire(world: &mut World) { + let entities: Vec = world + .query_filtered::>() + .iter(world) + .collect(); + for entity in entities { + world.despawn(entity); + } + if let Some(mut assets) = world.remove_resource::() { + for group in &mut assets.groups { + group.release(world); + } + } + } +} + +/// A whole scene, built without touching the live world. +pub(crate) struct PreparedScene { + meshes: StagedAssets, + images: StagedAssets, + standard: StagedAssets, + world_materials: StagedAssets, + sky: StagedAssets, + params: StagedAssets, + commands: SceneCommands, + pub stats: crate::skate_world::SceneStats, + pub retail: bool, +} + +impl PreparedScene { + pub(crate) fn new(world: &World) -> Self { + Self { + meshes: StagedAssets::new(world), + images: StagedAssets::new(world), + standard: StagedAssets::new(world), + world_materials: StagedAssets::new(world), + sky: StagedAssets::new(world), + params: StagedAssets::new(world), + commands: SceneCommands::default(), + stats: default(), + retail: false, + } + } + + /// Worker-thread body. Builds all geometry, materials and images for the map. + pub(crate) fn prepare(&mut self, map: Option<&SkateMap>, asset_root: &Path) { + let Some(map) = map else { + crate::world::spawn_test_world(&mut self.commands, &mut self.meshes, &mut self.standard); + return; + }; + self.retail = crate::retail_render::RetailScene::for_map(map); + let tuning = crate::retail_render::MaterialTuning::load(asset_root); + // The sky is loaded before the world because its authored fog frame and + // sun direction are encoded into the per-material storage buffer while + // the material table is built, not patched in afterwards. + let sky = self.retail.then(|| { + crate::retail_sky::SkyPackage::load(asset_root, &map.name) + .inspect_err(|error| warn!("Retail sky unavailable for {}: {error}", map.name)) + .ok() + }).flatten(); + let environment = sky + .as_ref() + .map(crate::retail_sky::SkyPackage::environment) + .unwrap_or_default(); + self.stats = crate::skate_world::spawn( + map, + &tuning, + &environment, + &mut self.commands, + &mut self.meshes, + &mut self.world_materials, + &mut self.images, + &mut self.params, + ); + if let Some(sky) = sky { + sky.spawn( + &mut self.commands, + &mut self.meshes, + &mut self.images, + &mut self.sky, + ); + } + } + + /// Main-thread commit. Assets first, then entities: a spawned entity must + /// never reference a handle whose asset has not been inserted yet. + pub(crate) fn publish(&mut self, world: &mut World) { + self.meshes.publish(world); + self.images.publish(world); + self.standard.publish(world); + self.world_materials.publish(world); + self.sky.publish(world); + self.params.publish(world); + std::mem::take(&mut self.commands.queue).apply(world); + world.insert_resource(MapAssets { + groups: vec![ + Box::new(std::mem::replace(&mut self.meshes, StagedAssets::new(world))), + Box::new(std::mem::replace(&mut self.images, StagedAssets::new(world))), + Box::new(std::mem::replace(&mut self.standard, StagedAssets::new(world))), + Box::new(std::mem::replace(&mut self.world_materials, StagedAssets::new(world))), + Box::new(std::mem::replace(&mut self.sky, StagedAssets::new(world))), + Box::new(std::mem::replace(&mut self.params, StagedAssets::new(world))), + ], + }); + world.insert_resource(crate::map_transition::MapRenderBudget { + draws: self.stats.draws, + triangles: self.stats.triangles, + leaves: self.stats.leaves, + slabs: self.stats.slabs, + }); + } +} + +// --------------------------------------------------------------------------- +// Day/night for non-retail maps +// +// Retail maps are lightmap-baked and keep their authored lighting, so these only +// drive the procedural test/custom world. +// --------------------------------------------------------------------------- + #[derive(Component)] pub(crate) struct DayEnvironment { - values: Vec, - sky: Option, - sun_direction: Vec3, -} -fn orbit(azimuth: f32, hour: f32) -> Vec3 { - let angle = (hour - 6.) * std::f32::consts::PI / 12.; - Vec3::new(azimuth.cos() * angle.cos(), angle.sin(), azimuth.sin() * angle.cos()) + pub hour: f32, + pub speed: f32, } +/// Sun or moon disc, positioned opposite each other on the same arc. #[derive(Component)] -pub(crate) struct CelestialBody { moon: bool } -fn celestial_bodies(commands: &mut SceneCommands, meshes: &mut StagedAssets, materials: &mut StagedAssets, images: &mut StagedAssets) { - let disc = meshes.add(Rectangle::new(2., 2.).into()); - for moon in [false, true] { - let texture = images.add(celestial_texture(moon)); - commands.spawn(( - Name::new(if moon { "Custom moon" } else { "Custom sun" }), - CelestialBody { moon }, Mesh3d(disc.clone()), - MeshMaterial3d(materials.add(StandardMaterial { - base_color_texture: Some(texture), alpha_mode: AlphaMode::Blend, - unlit: true, cull_mode: None, ..default() - })), - Transform::default(), Visibility::Hidden, - bevy::light::NotShadowCaster, bevy::light::NotShadowReceiver, - )); - } -} -/// Generated once during off-thread map preparation, never during gameplay. -/// A transparent border contains the corona/halo without a hard square edge. -fn celestial_texture(moon: bool) -> Image { - const SIZE: u32 = 256; - let mut pixels = Vec::with_capacity((SIZE * SIZE * 4) as usize); - let craters = [(-0.35f32, 0.2f32, 0.22f32), (0.28, -0.3, 0.16), - (0.35, 0.4, 0.12), (-0.12, -0.58, 0.1), (-0.5, -0.28, 0.13), - (0.05, 0.15, 0.08), (0.62, 0.02, 0.1), (-0.15, 0.62, 0.09)]; - for y in 0..SIZE { - for x in 0..SIZE { - let u = (x as f32 + 0.5) * 2. / SIZE as f32 - 1.; - let v = (y as f32 + 0.5) * 2. / SIZE as f32 - 1.; - let r = u.hypot(v); - let q = r / 0.35; - let edge = ((1. - q) * 60.).clamp(0., 1.); - let (rgb, alpha) = if q <= 1. { - if moon { - let px = u / 0.35; - let py = v / 0.35; - let mut tone = 0.73 + 0.055 * (px * 19. + (py * 13.).sin()).sin() - + 0.035 * (py * 37. + px * 29.).cos(); - for (cx, cy, radius) in craters { - let d = (px - cx).hypot(py - cy) / radius; - tone -= 0.19 * (-d * d * 2.).exp(); - tone += 0.12 * (-((d - 1.) * 7.).powi(2)).exp(); - } - tone *= 0.65 + 0.35 * (1. - q * q).max(0.).sqrt(); - ([tone * 0.94, tone * 0.98, tone * 1.07], edge) - } else { - ([1., 0.96 - q * 0.13, 0.78 - q * 0.32], edge) - } - } else { - let angle = v.atan2(u); - let rays = if moon { 1. } else { - 0.65 + 0.2 * (angle * 12. + r * 9.).sin().powi(2) - + 0.15 * (angle * 23. - r * 6.).cos().powi(2) - }; - let glow = (-(q - 1.) * if moon { 4. } else { 2.4 }).exp() - * (1. - r).max(0.).powi(2) * rays; - (if moon { [0.55, 0.68, 1.] } else { [1., 0.63, 0.16] }, - glow * if moon { 0.36 } else { 0.85 }) - }; - for c in rgb { pixels.push((c.clamp(0., 1.) * 255.) as u8); } - pixels.push((alpha.clamp(0., 1.) * 255.) as u8); - } - } - Image::new( - bevy::render::render_resource::Extent3d { width: SIZE, height: SIZE, depth_or_array_layers: 1 }, - bevy::render::render_resource::TextureDimension::D2, pixels, - bevy::render::render_resource::TextureFormat::Rgba8UnormSrgb, - bevy::asset::RenderAssetUsages::RENDER_WORLD, - ) -} -/// Follow the final gameplay camera before transform propagation, with no -/// parallax. Ordinary depth testing lets buildings and terrain obscure them. -pub(crate) fn position_celestial_bodies( - time: Res>, - environment: Query<&DayEnvironment>, - camera: Query<(&Transform, &Projection), (With, Without)>, - mut bodies: Query<(&CelestialBody, &mut Transform, &mut Visibility), Without>, -) { - let (Ok(environment), Ok((camera, projection))) = (environment.single(), camera.single()) else { return; }; - let far = match projection { Projection::Perspective(p) => p.far, Projection::Orthographic(p) => p.far, _ => 1000. }; - let distance = if far.is_finite() { far * 0.98 } else { 10_000. }; - for (body, mut transform, mut visibility) in &mut bodies { - let direction = environment.sun_direction * if body.moon { -1. } else { 1. }; - *visibility = if direction.y > 0. { Visibility::Inherited } else { Visibility::Hidden }; - *transform = Transform::from_translation(camera.translation + direction * distance) - .looking_at(camera.translation, if direction.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y }) - .with_scale(Vec3::splat(distance * if body.moon { 0.13 } else { 0.115 })); - if !body.moon { - transform.rotate_local_z(time.elapsed_secs() * 0.025); - transform.scale *= 1. + 0.012 * (time.elapsed_secs() * 0.8).sin(); - } - } -} -fn daylight_values(e: &[f32], hour: f32) -> (Color, f32, f32, Transform, f32) { - let angle = (hour - 6.) * std::f32::consts::PI / 12.; - let elevation = angle.sin(); - let daylight = elevation.max(0.); - let extended = e.len() == 45; - let night = elevation < 0.; - let rgb = |i: usize| Color::linear_rgb(e[i].max(0.), e[i + 1].max(0.), e[i + 2].max(0.)); - let color = if extended { rgb(if night { 35 } else { 32 }) } else if night { Color::srgb(0.55, 0.65, 1.) } else { Color::WHITE }; - let strength = if extended { e[if night { 39 } else { 38 }].max(0.) } else if night { 0.025 } else { 1. }; - let ambient = if extended { e[41] + (e[40] - e[41]) * daylight } else { 0.025 + 0.295 * daylight }; - // Signed horizontal motion carries the sun east-to-west. The moon follows - // the opposite hemisphere; both fade to zero at the horizon. - let horizontal = angle.cos() * if night { -1. } else { 1. }; - let direction = Vec3::new(e[11].cos() * horizontal, elevation.abs().max(0.001), e[11].sin() * horizontal).normalize(); - (color, 10_000. * strength * elevation.abs(), ambient.max(0.) * 1000., - Transform::default().looking_to(-direction, if direction.y > 0.99 { Vec3::Z } else { Vec3::Y }), daylight) -} -fn custom_lighting(map: &skate_data::skate_map::SkateMap, commands: &mut SceneCommands) { - let (color, illuminance, brightness, transform, _) = daylight_values(&map.environment, map.environment[10].rem_euclid(24.)); - commands.insert_resource(GlobalAmbientLight { color: Color::WHITE, brightness, ..default() }); - commands.spawn(( - Name::new("Custom map sun/moon"), - DayEnvironment { values: map.environment.to_vec(), sky: None, sun_direction: orbit(map.environment[11], map.environment[10]) }, - DirectionalLight { color, illuminance, shadows_enabled: true, affects_lightmapped_mesh_diffuse: false, ..default() }, - transform, - bevy::light::CascadeShadowConfigBuilder { maximum_distance: 100., first_cascade_far_bound: 10., ..default() }.build(), - )); +pub(crate) struct CelestialBody { + pub moon: bool, } + pub(crate) fn advance_day( - time: Res>, mut menu: ResMut, + time: Res>, mut lights: Query<(&mut DayEnvironment, &mut DirectionalLight, &mut Transform)>, - mut ambient: ResMut, mut clear: ResMut, ) { - if lights.is_empty() { return; } - let hour = menu.advance_day(time.delta_secs()); for (mut environment, mut light, mut transform) in &mut lights { - let base = *environment.sky.get_or_insert(clear.0); - let (color, illuminance, brightness, pose, daylight) = daylight_values(&environment.values, hour); - environment.sun_direction = orbit(environment.values[11], hour); - light.color = color; - light.illuminance = illuminance; - *transform = pose; - ambient.brightness = menu.ambient_brightness(brightness); - clear.0 = Color::srgb(0.003, 0.005, 0.015).mix(&base, daylight.sqrt()); - } -} -impl MapAssets { - pub fn retire(world: &mut World) { - let entities: Vec<_> = world.query_filtered::>().iter(world).collect(); - for entity in entities { world.despawn(entity); } - if let Some(owned) = world.remove_resource::() { - fn remove(world: &mut World, ids: Vec>) { - let mut assets = world.resource_mut::>(); - for id in ids { assets.remove(id); } - } - remove(world, owned.meshes); - remove(world, owned.materials); - remove(world, owned.retail); - remove(world, owned.sky); - remove(world, owned.images); - } + environment.hour = + (environment.hour + time.delta_secs() * environment.speed / 3600.0).rem_euclid(24.0); + let angle = (environment.hour / 24.0) * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2; + let direction = Vec3::new(angle.cos(), angle.sin(), 0.25).normalize(); + *transform = Transform::from_translation(direction * 100.0) + .looking_at(Vec3::ZERO, Vec3::Y); + // Fade out below the horizon rather than snapping to black. + light.illuminance = 11_000.0 * direction.y.max(0.0).powf(0.4); } } -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn daylight_orbit_and_authored_zero_intensity() { - let mut e = vec![0.; 12]; - let (_, noon, ambient_day, pose, _) = daylight_values(&e, 12.); - let (_, midnight, ambient_night, _, _) = daylight_values(&e, 0.); - assert!(noon > midnight && ambient_day > ambient_night); - assert!(pose.rotation.is_finite()); - let (_, _, _, morning, _) = daylight_values(&e, 9.); - let (_, _, _, evening, _) = daylight_values(&e, 15.); - assert!(morning.forward().x * evening.forward().x < 0.); - e.resize(45, 0.); - assert_eq!(daylight_values(&e, 12.).1, 0.); - assert_eq!(daylight_values(&e, 0.).1, 0.); - } - #[test] - #[ignore = "requires user-supplied custom map; CPU preparation only"] - fn supplied_custom_map_prepares_and_retires() { - let path = std::env::var("SKATE_TEST_CUSTOM_MAP").unwrap(); - let root = std::env::var("SKATE_TEST_CUSTOM_ASSETS").unwrap(); - let map = skate_data::skate_map::SkateMap::load(std::path::Path::new(&path)).unwrap(); - crate::skate_world::validate_runtime(&map).unwrap(); - assert!(!crate::retail_render::RetailScene::for_map(&map)); - let mut world = world(); - let mut scene = PreparedScene::new(&world); - scene.prepare(Some(&map), std::path::Path::new(&root)); - scene.publish(&mut world); - assert_eq!(world.query::<&DirectionalLight>().iter(&world).count(), 1); - MapAssets::retire(&mut world); - assert_eq!(world.query_filtered::>().iter(&world).count(), 0); - } - #[test] - fn custom_lighting_switches_without_accumulation_and_keeps_pbr() { - let mut world = world(); - let mut map = skate_data::skate_map::SkateMap::parse(include_bytes!("../../../maps/format-demo.skate")).unwrap(); - map.name = "university".into(); // Names cannot select retail rendering. - map.materials[0].retail_definition = None; - map.extensions.clear(); - for retail in [false, true, false, true] { - map.extensions.clear(); - if retail { - // Legacy retail imports can retain provenance without definitions. - map.extensions.push(skate_data::skate_map::Extension { - tag: *b"WMET", schema: 1, payload: b"{}".to_vec(), - }); - } - assert_eq!(crate::retail_render::RetailScene::for_map(&map), retail); - let mut scene = PreparedScene::new(&world); - scene.prepare(Some(&map), std::path::Path::new("unused")); - scene.publish(&mut world); - assert_eq!(world.query::<&DirectionalLight>().iter(&world).count(), usize::from(!retail)); - assert_eq!(world.query::<&CelestialBody>().iter(&world).count(), if retail { 0 } else { 2 }); - // Only sky discs are unlit; world surfaces retain the PBR path. - let body_materials: Vec<_> = world.query_filtered::<&MeshMaterial3d, With>() - .iter(&world).map(|m| m.0.id()).collect(); - assert!(world.resource::>().iter().all(|(id, m)| body_materials.contains(&id) || !m.unlit)); - MapAssets::retire(&mut world); - assert_eq!(world.query::<&DirectionalLight>().iter(&world).count(), 0); - } - } - - fn world() -> World { - let mut world = World::new(); - world.init_resource::>(); - world.init_resource::>(); - world.init_resource::>(); - world.init_resource::>(); - world.init_resource::>(); - world - } - - #[test] - fn repeated_scene_retirement_releases_owned_assets_and_preserves_character() { - let mut world = world(); - let character_mesh = world.resource_mut::>().add(Cuboid::default()); - let character = world.spawn((crate::world::PlayerRoot, Mesh3d(character_mesh.clone()))).id(); - let map = skate_data::skate_map::SkateMap::parse(include_bytes!("../../../maps/format-demo.skate")).unwrap(); - for iteration in 0..6 { - let mut scene = PreparedScene::new(&world); - scene.prepare((iteration % 2 == 0).then_some(&map), std::path::Path::new("unused")); - // Preparation has not published any live entities or assets. - assert_eq!(world.resource::>().len(), 1); - scene.publish(&mut world); - assert!(world.query_filtered::>().iter(&world).count() > 0); - let old: Vec<_> = world.query_filtered::>().iter(&world).collect(); - MapAssets::retire(&mut world); - assert!(old.into_iter().all(|id| world.get_entity(id).is_err())); - assert!(world.get_entity(character).is_ok()); - assert_eq!(world.resource::>().len(), 1); - assert!(world.resource::>().contains(&character_mesh)); - assert_eq!(world.resource::>().len(), 0); - assert_eq!(world.resource::>().len(), 0); - assert_eq!(world.resource::>().len(), 0); - assert_eq!(world.resource::>().len(), 0); - } - } - - #[test] - fn abandoned_preparation_does_not_replace_live_scene() { - let mut world = world(); - let mut active = PreparedScene::new(&world); - active.prepare(None, std::path::Path::new("unused")); - active.publish(&mut world); - let entities: Vec<_> = world.query_filtered::>().iter(&world).collect(); - let meshes = world.resource::>().len(); - let mut abandoned = PreparedScene::new(&world); - abandoned.prepare(None, std::path::Path::new("unused")); - drop(abandoned); - assert_eq!(world.resource::>().len(), meshes); - assert!(entities.into_iter().all(|id| world.get_entity(id).is_ok())); +pub(crate) fn position_celestial_bodies( + environments: Query<&DayEnvironment>, + mut bodies: Query<(&CelestialBody, &mut Transform, &mut Visibility)>, +) { + let Ok(environment) = environments.single() else { return }; + let angle = (environment.hour / 24.0) * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2; + for (body, mut transform, mut visibility) in &mut bodies { + let phase = if body.moon { angle + std::f32::consts::PI } else { angle }; + let direction = Vec3::new(phase.cos(), phase.sin(), 0.25).normalize(); + transform.translation = direction * 400.0; + *visibility = if direction.y > -0.05 { Visibility::Inherited } else { Visibility::Hidden }; } } diff --git a/crates/skate-game/src/map_transition.rs b/crates/skate-game/src/map_transition.rs index d57f9e57..00356dda 100644 --- a/crates/skate-game/src/map_transition.rs +++ b/crates/skate-game/src/map_transition.rs @@ -12,6 +12,21 @@ pub(crate) struct MapTransitionSet; #[derive(Message)] pub(crate) struct WorldChanged { pub generation: u64 } +/// What the prepared scene actually cost, recorded when geometry is published. +/// +/// This used to gate MSAA and occlusion culling against a batch count, because +/// draw count varied with material count and could reach thousands. Draw count +/// is now a property of the spatial partition we choose, so there is nothing +/// left to adapt to: the render options are fixed policy (RFC 1 D7) and this is +/// a plain report for the perf harness and the debug overlay. +#[derive(Resource, Default, Clone, Copy, Debug)] +pub(crate) struct MapRenderBudget { + pub draws: usize, + pub triangles: usize, + pub leaves: usize, + pub slabs: usize, +} + #[cfg(test)] #[path = "tests/map_transition.rs"] mod tests; @@ -80,7 +95,10 @@ impl Plugin for MapTransitionPlugin { fn build(&self, app: &mut App) { let config = app.world().resource::(); let current = CurrentMap::from_package(config.map_path.clone(), config.map.as_ref()); - app.insert_resource(current).init_resource::().add_message::() + app.insert_resource(current) + .init_resource::() + .init_resource::() + .add_message::() .add_systems(PreUpdate, poll.in_set(MapTransitionSet).after(crate::graphics_menu::MenuInput) .before(crate::input::poll_controllers)) .configure_sets(FixedUpdate, ( diff --git a/crates/skate-game/src/modding/mod.rs b/crates/skate-game/src/modding/mod.rs index b2734e04..78531661 100644 --- a/crates/skate-game/src/modding/mod.rs +++ b/crates/skate-game/src/modding/mod.rs @@ -214,6 +214,21 @@ pub(crate) fn player_attached(mods: &Mods) -> bool { mods.attach.is_some() } +impl Mods { + fn runtime_busy(&self) -> bool { + self.manager.packages.values().any(|p| p.running()) + || !self.manager.commands.is_empty() + || !self.manager.retired.is_empty() + || !self.bodies.is_empty() + || !self.graphics.is_empty() + || !self.overlays.is_empty() + || !self.joints.is_empty() + || self.attach.is_some() + || !self.canvases.is_empty() + || !self.skater_proxies.is_empty() + } +} + fn body_snapshot_for(mods: &Mods, owner: &str) -> serde_json::Value { let mut bodies = serde_json::Map::new(); let mut reverse = BTreeMap::::new(); @@ -345,9 +360,6 @@ fn ensure_ground(world: &World, mods: &mut Mods) -> Result<(), String> { fn maintenance(world: &mut World) { world.resource_scope(|world, mut mods: Mut| { - let camera = camera_position(world); - let snap = snapshot_ro(world, &mods, camera); - mods.manager.snapshot = snap; let generation = world .resource::() .generation; @@ -355,11 +367,24 @@ fn maintenance(world: &mut World) { clear_runtime(world, &mut mods); mods.generation = generation; mods.manager.commands.clear(); - let map = mods.manager.snapshot["map"].clone(); - mods.manager - .dispatch("on_event", json!({"name":"world_changed","map":map})); + if mods.runtime_busy() { + let camera = camera_position(world); + let snap = snapshot_ro(world, &mods, camera); + mods.manager.snapshot = snap; + let map = mods.manager.snapshot["map"].clone(); + mods.manager + .dispatch("on_event", json!({"name":"world_changed","map":map})); + } + mods.manager.scan(false); + return; } mods.manager.scan(false); + if !mods.runtime_busy() { + return; + } + let camera = camera_position(world); + let snap = snapshot_ro(world, &mods, camera); + mods.manager.snapshot = snap; apply(world, &mut mods); }); } @@ -431,7 +456,11 @@ fn fixed(world: &mut World) { world.resource_scope(|world, mut mods: Mut| { bridge::take_reactions(&mut mods, &mut world.resource_mut::()); let camera = camera_position(world); - mods.manager.snapshot = snapshot_ro(world, &mods, camera); + if mods.runtime_busy() + || mods.manager.packages.values().any(|package| package.running()) + { + mods.manager.snapshot = snapshot_ro(world, &mods, camera); + } if let Err(e) = ensure_ground(world, &mut mods) { warn!("dynamics ground: {e}"); } @@ -567,15 +596,15 @@ fn fixed(world: &mut World) { } fn update(world: &mut World) { - let camera = camera_position(world); - let snap = { - let mods = world.resource::(); - snapshot_ro(world, mods, camera) - }; - let paused = - snap["paused"].as_bool().unwrap_or(true) || snap["replay"].as_bool().unwrap_or(false); - let dt = world.resource::>().delta_secs_f64().min(0.25); world.resource_scope(|world, mut mods: Mut| { + if !mods.runtime_busy() { + return; + } + let camera = camera_position(world); + let snap = snapshot_ro(world, &mods, camera); + let paused = + snap["paused"].as_bool().unwrap_or(true) || snap["replay"].as_bool().unwrap_or(false); + let dt = world.resource::>().delta_secs_f64().min(0.25); mods.manager.snapshot = snap; if !paused { mods.manager.dispatch("on_update", json!({"dt": dt})); @@ -1255,12 +1284,13 @@ fn present_camera(world: &mut World) { .iter(world).next(); let Some(camera) = camera else { return; }; let replay = world.resource::().active; + let debug_cam = crate::debug_cam::DebugCam::active(world.resource::()); let customizing = world.get_resource::().is_some_and(|c| c.open); let paused = world.resource::().open; let dt = if paused {0.0} else {world.resource::>().delta_secs().clamp(0.0,0.1)}; let alpha = if paused {1.0} else {world.resource::>().overstep_fraction()}; world.resource_scope(|world, mut mods: Mut| { - let suppress = replay || customizing; + let suppress = replay || customizing || debug_cam; if mods.camera.rig.is_none() || suppress { if let Some(near) = mods.camera.saved_near.take() { if let Some(mut projection) = world.get_mut::(camera) { diff --git a/crates/skate-game/src/modding/replication.rs b/crates/skate-game/src/modding/replication.rs index 5cc443c1..2fde195b 100644 --- a/crates/skate-game/src/modding/replication.rs +++ b/crates/skate-game/src/modding/replication.rs @@ -1,6 +1,8 @@ //! Generic, package-verified body/scene/node replication. No remote Lua runs, //! no platform-specific path exists, and keys are scoped by authenticated peer. -//! The existing APPLICATION transport provides sequencing, ACK/retry and late join. +//! APPLICATION is a live snapshot: pose, collider fragments, and scenes stay +//! published until the local object is removed. Sequencing, ACK/retry and late +//! join are provided by the existing transport. mod wire; use super::{Mods,graphics}; use bevy::prelude::*; @@ -11,6 +13,7 @@ use wire::*; type Slot=(u64,String,String); const RECORD_BUDGET:usize=224; // leave room for ordinary sdk.net state +const STALE_BODY_GRACE:Duration=Duration::from_millis(300); const MAX_REMOTE_BODIES:usize=32; const MAX_REMOTE_GRAPHICS:usize=64; const MAX_REMOTE_NODES:usize=128; @@ -32,7 +35,8 @@ impl Default for State { let now=Instant::now(); Self { epoch:(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as u64).max(1), connection:0,started:now,last_send:now-Duration::from_secs(1),definitions:BTreeMap::new(), - bodies:BTreeMap::new(),graphics:BTreeMap::new(),node_sequences:BTreeMap::new(),attachments:BTreeMap::new(), + bodies:BTreeMap::new(),graphics:BTreeMap::new(), + node_sequences:BTreeMap::new(),attachments:BTreeMap::new(), warnings:BTreeSet::new(),last_problem:String::new(),status:String::new() } } } @@ -143,17 +147,24 @@ fn outgoing(mods:&Mods,state:&mut State) -> BTreeMap> { } let Some(definition)=state.definitions.get(&id) else {continue;}; if definition.chunks.is_empty() {continue;} + let definition_hash=definition.hash; + let definition_chunks=definition.chunks.clone(); let Some(body)=mods.world.read(id) else {continue;}; let pose=BodyPose {p:body.position,q:body.rotation,v:body.linvel,w:body.angvel, com:mods.world.local_center(id).unwrap_or([0.;3]),stamp}; if !pose.valid() {issue(state,format!("{package}/{key}: non-finite or out-of-range body pose"));continue;} let mut header=make(BODY,package,key,fp,state.epoch,id,serde_json::to_vec(&pose).unwrap()); - header.revision=definition.hash;header.count=definition.chunks.len() as u16; - let mut packets=vec![header.clone()]; - for (index,chunk) in definition.chunks.iter().enumerate() { - let mut part=header.clone();part.kind=DEFINITION;part.index=index as u16;part.payload=chunk.clone();packets.push(part); + header.revision=definition_hash;header.count=definition_chunks.len() as u16; + // Pose updates are tiny and must win the record budget every tick. + push_group(&mut out,vec![header.clone()],state,&format!("{package}/{key} pose")); + // Geometry is durable APPLICATION state, same as pose. Keys omitted from + // this map are tombstoned, and remotes assemble only from the live snapshot, + // so a one-shot announce cannot survive loss, credit windows, or late join. + let mut fragments=Vec::new(); + for (index,chunk) in definition_chunks.iter().enumerate() { + let mut part=header.clone();part.kind=DEFINITION;part.index=index as u16;part.payload=chunk.clone();fragments.push(part); } - push_group(&mut out,packets,state,&format!("{package}/{key}")); + push_group(&mut out,fragments,state,&format!("{package}/{key} definition")); } for ((package,key),g) in mods.graphics.iter().filter(|((o,_),_)|!o.starts_with('@')) { let Some(&fp)=packages.get(package) else {continue;}; @@ -180,8 +191,11 @@ fn outgoing(mods:&Mods,state:&mut State) -> BTreeMap> { fn remove_body(mods:&mut Mods,state:&mut State,slot:&Slot) { if let Some(replica)=state.bodies.remove(slot) { let key=(owner(slot.0,&slot.1),slot.2.clone()); - if mods.bodies.get(&key)==Some(&replica.id) {mods.bodies.remove(&key);} - mods.world.remove(replica.id); + // Never delete a dynamics id that was reused by a different owner/key. + if mods.bodies.get(&key)==Some(&replica.id) { + mods.bodies.remove(&key); + mods.world.remove(replica.id); + } } } fn clear_remote(world:&mut World,mods:&mut Mods,state:&mut State) { @@ -234,15 +248,22 @@ fn incoming(world:&mut World,mods:&mut Mods,state:&mut State,records:Vec<(u64,St } } else if let Some(replica)=state.bodies.get_mut(&slot) {replica.receive(pose,seq);} } - let stale:Vec<_>=state.bodies.keys().filter(|slot|!live_bodies.contains(*slot)).cloned().collect(); + let stale:Vec<_>=state.bodies.keys().filter(|slot| { + !live_bodies.contains(*slot) + && state.bodies.get(slot).is_some_and(|r| r.received.elapsed()>=STALE_BODY_GRACE) + }).cloned().collect(); for slot in stale {remove_body(mods,state,&slot);} let mut live_graphics=BTreeSet::new();let mut graphics_count=BTreeMap::::new(); for &&(peer,_,ref packet) in &accepted { if packet.kind!=GRAPHIC {continue;} let count=graphics_count.entry(peer).or_default();if *count>=MAX_REMOTE_GRAPHICS {continue;}*count+=1; - let Ok(record)=serde_json::from_slice::(&packet.payload) else {continue;}; - if !record.definition.validate() || !record.transform.validate() {continue;} + let Ok(record)=serde_json::from_slice::(&packet.payload) else { + issue(state,format!("peer {peer} scene {}/{}: unreadable graphics record",packet.owner,packet.key));continue; + }; + if !record.definition.validate() || !record.transform.validate() { + issue(state,format!("peer {peer} scene {}/{}: invalid graphics descriptor",packet.owner,packet.key));continue; + } let slot=(peer,packet.owner.clone(),packet.key.clone()); let o=owner(peer,&packet.owner);let key=(o.clone(),packet.key.clone()); let bound=record.definition.body.as_ref().is_none_or(|body| { @@ -313,22 +334,32 @@ pub(crate) fn sync(world:&mut World) { if state.connection!=local { clear_remote(world,&mut mods,&mut state);state=State::default();state.connection=local;mods.dyn_published.clear(); } - if state.last_send.elapsed()>=Duration::from_millis(49) { + let definition_pending=mods.bodies.iter().filter(|((owner,_),_)|!owner.starts_with('@')).any(|(_, &id)| { + mods.world.definition_revision(id).is_some_and(|revision| { + state.definitions.get(&id).is_none_or(|d|d.revision!=revision) + }) + }); + if definition_pending || state.last_send.elapsed()>=Duration::from_millis(49) { let desired=outgoing(&mods,&mut state); + let desired_keys: BTreeSet = desired.keys().cloned().collect(); let mut net=world.resource_mut::(); - let mut published=BTreeSet::new(); - for (key,value) in desired { - if net.publish_application(&key,value) {published.insert(key);} - else {issue(&mut state,format!("transport rejected '{key}': application key/value budget reached"));} + for key in mods.dyn_published.difference(&desired_keys) { + net.publish_application(key,Vec::new()); } - for key in mods.dyn_published.difference(&published) {net.publish_application(key,Vec::new());} - mods.dyn_published=published;state.last_send=Instant::now(); + for (key,value) in desired { + if !net.publish_application(&key,value) { + issue(&mut state,format!("transport rejected '{key}': application key/value budget reached")); + } + } + mods.dyn_published=desired_keys;state.last_send=Instant::now(); } incoming(world,&mut mods,&mut state,records); sample(&mut mods,&state); graphics::sync(world,&mut mods); let local_bodies=mods.bodies.iter().filter(|((owner,_),_)|!owner.starts_with('@')).count(); - state.status=format!("Solid bridge: {} remote / {} scenes / {} local publishing{}",state.bodies.len(),state.graphics.len(),local_bodies, + let collider_records=state.definitions.values().map(|d|d.chunks.len()).sum::(); + state.status=format!("Solid bridge: {} remote / {} scenes / {} local publishing / {} collider records{}", + state.bodies.len(),state.graphics.len(),local_bodies,collider_records, if state.last_problem.is_empty() {String::new()} else {format!(" | last issue: {}",state.last_problem)}); mods.replication=state; }); @@ -364,4 +395,7 @@ mod tests { let replica=Replica {id:1,epoch:1,instance:1,revision:0,seq:1,pose,received:Instant::now()-Duration::from_secs(1),error_position:Vec3::ZERO,error_axis:Vec3::ZERO}; let (t,v,w)=replica.sample();assert!((t.translation.x-10.).abs()<1e-5);assert_eq!(v,Vec3::ZERO);assert_eq!(w,Vec3::ZERO); } + #[test] fn one_max_size_body_and_scene_fit_the_record_budget() { + assert!(1 + 1 + MAX_CHUNKS + 1 + 4 <= RECORD_BUDGET); + } } diff --git a/crates/skate-game/src/modding/replication/wire.rs b/crates/skate-game/src/modding/replication/wire.rs index a7f9aefd..5a25eb34 100644 --- a/crates/skate-game/src/modding/replication/wire.rs +++ b/crates/skate-game/src/modding/replication/wire.rs @@ -120,6 +120,15 @@ mod tests { assert_eq!(assemble(&body,[&b,&a].into_iter()).unwrap(),data); b.instance+=1;assert!(assemble(&body,[&a,&b].into_iter()).is_none()); } + #[test] fn a_partial_live_snapshot_does_not_assemble() { + let data=vec![7;CHUNK*8+3]; + let mut body=envelope();body.count=((data.len()+CHUNK-1)/CHUNK) as u16;body.revision=skate_net::hash(&data); + let fragments:Vec<_>=data.chunks(CHUNK).enumerate().map(|(index,chunk)| { + let mut part=body.clone();part.kind=DEFINITION;part.index=index as u16;part.payload=chunk.to_vec();part + }).collect(); + assert!(assemble(&body,fragments.iter().take(fragments.len()-1)).is_none()); + assert_eq!(assemble(&body,fragments.iter()).unwrap(),data); + } #[test] fn limits_are_enforced_before_allocation() { let mut e=envelope();e.count=MAX_CHUNKS as u16+1;assert!(e.encode().is_none()); e.count=1;e.payload=vec![0;1024];assert!(e.encode().is_none()); diff --git a/crates/skate-game/src/multiplayer.rs b/crates/skate-game/src/multiplayer.rs index 3bba86a1..7711d58d 100644 --- a/crates/skate-game/src/multiplayer.rs +++ b/crates/skate-game/src/multiplayer.rs @@ -264,6 +264,9 @@ impl Multiplayer { } } } +#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct RemoteRenderSet; + pub(crate) struct MultiplayerPlugin; impl Plugin for MultiplayerPlugin { fn build(&self, app: &mut App) { diff --git a/crates/skate-game/src/multiplayer/render.rs b/crates/skate-game/src/multiplayer/render.rs index eb0df094..5fc94e7f 100644 --- a/crates/skate-game/src/multiplayer/render.rs +++ b/crates/skate-game/src/multiplayer/render.rs @@ -96,7 +96,10 @@ impl Plugin for RemoteRenderPlugin { }) .add_systems( Update, - (spawn, bind, present).chain().after(crate::modding::bridge::sync_network), + (spawn, bind, present) + .chain() + .in_set(RemoteRenderSet) + .after(crate::modding::bridge::sync_network), ); } } @@ -247,7 +250,9 @@ fn bind( mut materials: ResMut>, pieces: Query<&OutfitPiece>, mut morphs: Query<(Entity, &mut MorphWeights)>, + lighting: Option>, ) { + let default_sh = lighting.as_ref().map(|lighting| lighting.default_sh()); let initial_poses: BTreeMap<_, _> = skins .actors .iter() @@ -321,6 +326,15 @@ fn bind( continue; }; let handle = materials.add(material); + if let (Some(lighting), Some(sh)) = (lighting.as_ref(), default_sh) { + if let Some(material) = materials.get_mut(&handle) { + crate::retail_character::seed_customiser_retail( + material, + lighting.light, + sh, + ); + } + } for (e, _) in &meshes { if parents.iter_ancestors(e).any(|p| p == scene) { commands diff --git a/crates/skate-game/src/performance.rs b/crates/skate-game/src/performance.rs index bc07002b..9ce12e9f 100644 --- a/crates/skate-game/src/performance.rs +++ b/crates/skate-game/src/performance.rs @@ -1,291 +1,476 @@ -//! Opt-in repeatable frame/physics timing: SKATE_PERF_REPORT=path.json. +//! Frame timing harness. Active only when `SKATE_PERF_REPORT` names an output +//! path: warms up, samples a fixed window, writes JSON, then exits. +//! +//! The `Performance` resource is absent in normal play, which is why callers +//! take it as `Option>` — instrumentation must never cost anything in +//! a shipping session. use bevy::prelude::*; -use bevy::render::{Render, RenderApp, RenderSystems}; -use std::sync::{Arc, Mutex}; -use std::{collections::BTreeMap, sync::OnceLock}; -use std::{path::PathBuf, time::Instant}; +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; -static EPOCH: OnceLock = OnceLock::new(); -static SLOW_SECTIONS: Mutex> = Mutex::new(Vec::new()); -fn timestamp(now: Instant) -> f64 { - EPOCH.get().map_or(0., |epoch| now.duration_since(*epoch).as_secs_f64() * 1000.) +/// Seconds discarded before sampling, so shader compilation, asset streaming and +/// the map publish settle do not pollute the window. +const WARMUP: f32 = 10.0; +/// Seconds of samples retained. +const SAMPLE: f32 = 15.0; +/// A frame slower than this is counted separately: at 300 FPS the budget is +/// 3.33 ms, so 8 ms is an unambiguous hitch rather than jitter. +const HITCH_MS: f32 = 8.0; + +/// Draw statistics for one frame, owned by the main world. +/// +/// Populated from the render world (see `render_stats`), which is a frame behind +/// by construction; for a 15-second average that skew is irrelevant. +#[derive(Resource, Default, Clone, Copy)] +pub(crate) struct DrawStats { + pub world_draws: u32, + pub world_triangles: u32, + pub mod_draws: u32, + pub mod_triangles: u32, + pub other_draws: u32, +} +impl DrawStats { + pub(crate) fn total_draws(&self) -> u32 { + self.world_draws + self.mod_draws + self.other_draws + } } -static SECTIONS: Mutex> = Mutex::new(BTreeMap::new()); -pub(crate) struct Scope { - name: &'static str, - start: Option, +/// CPU cost of the render schedule, split at phase boundaries. +/// +/// Bevy pipelines the render sub-app against the next frame's main schedule, so +/// when `frame_ms` exceeds `main_schedule_ms` the render app is the critical +/// path and this is the only way to see which phase owns it. The main world and +/// the render world hold clones of the same `Arc`, which is why the counters are +/// atomics rather than a plain resource: they are written in the render world +/// and read in the main world with no ordering guarantee between the two. +#[derive(Resource, Clone, Default)] +pub(crate) struct RenderPhases(Arc); + +#[derive(Default)] +struct Phases { + started: AtomicU64, + extract_ns: AtomicU64, + assets_ns: AtomicU64, + views_ns: AtomicU64, + queue_ns: AtomicU64, + prepare_ns: AtomicU64, + total_ns: AtomicU64, + frames: AtomicU64, } -impl Scope { - pub(crate) fn new(name: &'static str) -> Self { - static ENABLED: OnceLock = OnceLock::new(); - Self { - name, - start: ENABLED - .get_or_init(|| std::env::var_os("SKATE_PERF_REPORT").is_some()) - .then(Instant::now), + +/// Wall-clock nanoseconds since an arbitrary fixed origin. +/// +/// `Instant` cannot live in an atomic, and the render world needs to hand a +/// timestamp to systems that run later in the same schedule. +fn now_ns() -> u64 { + use std::sync::LazyLock; + static ORIGIN: LazyLock = LazyLock::new(Instant::now); + ORIGIN.elapsed().as_nanos() as u64 +} + +impl RenderPhases { + /// Mean milliseconds per frame for each phase, in report order. + fn means(&self) -> [f32; 6] { + let frames = self.0.frames.load(Ordering::Relaxed).max(1) as f32; + let mean = |counter: &AtomicU64| { + counter.load(Ordering::Relaxed) as f32 / frames / 1.0e6 + }; + [ + mean(&self.0.extract_ns), + mean(&self.0.assets_ns), + mean(&self.0.views_ns), + mean(&self.0.queue_ns), + mean(&self.0.prepare_ns), + mean(&self.0.total_ns), + ] + } + + fn reset(&self) { + for counter in [ + &self.0.extract_ns, + &self.0.assets_ns, + &self.0.views_ns, + &self.0.queue_ns, + &self.0.prepare_ns, + &self.0.total_ns, + &self.0.frames, + ] { + counter.store(0, Ordering::Relaxed); } } } -impl Drop for Scope { - fn drop(&mut self) { - if let Some(start) = self.start { - let elapsed = start.elapsed().as_secs_f64() * 1000.; - if elapsed >= 2. { - let mut slow = SLOW_SECTIONS.lock().unwrap(); - if slow.len() < 256 { slow.push((self.name, timestamp(start), elapsed)); } - } - let mut sections = SECTIONS.lock().unwrap(); - let entry = sections.entry(self.name).or_default(); - entry.0 += elapsed; - entry.1 += 1; - } - } + +/// Accumulates the span from the recorded phase start to now, then re-arms the +/// start for the next phase. +fn mark(phases: &RenderPhases, counter: &AtomicU64) { + let now = now_ns(); + let start = phases.0.started.swap(now, Ordering::Relaxed); + counter.fetch_add(now.saturating_sub(start), Ordering::Relaxed); +} + +#[derive(Default, Clone, Copy)] +struct Frame { + total_ms: f32, + main_ms: f32, + physics_ms: f32, + draws: u32, + triangles: u32, } #[derive(Resource)] pub(crate) struct Performance { - path: PathBuf, - start: Option, - frame_start: Instant, - previous: Instant, - physics_ms: f64, - ticks: u32, - samples: Vec<[f64; 4]>, - sample_times: Vec, - render: Arc>>, + output: PathBuf, + started: Instant, + frame_started: Option, + main_elapsed: Duration, + physics_elapsed: Duration, + samples: Vec, + sampling: bool, + finished: bool, } + impl Performance { - pub(crate) fn physics(&mut self, elapsed: std::time::Duration) { - self.physics_ms += elapsed.as_secs_f64() * 1000.; - self.ticks += 1; + fn new(output: PathBuf) -> Self { + Self { + output, + started: Instant::now(), + frame_started: None, + main_elapsed: Duration::ZERO, + physics_elapsed: Duration::ZERO, + // 15 s at an optimistic 600 FPS; growth beyond this is harmless. + samples: Vec::with_capacity(9_000), + sampling: false, + finished: false, + } + } + + /// Physics tick cost, reported by `physics::advance`. FixedUpdate may run + /// zero or several times per frame, so this accumulates until the frame ends. + pub(crate) fn physics(&mut self, elapsed: Duration) { + self.physics_elapsed += elapsed; } } pub(crate) struct PerformancePlugin; impl Plugin for PerformancePlugin { fn build(&self, app: &mut App) { - let Some(path) = std::env::var_os("SKATE_PERF_REPORT") else { + let Some(output) = std::env::var_os("SKATE_PERF_REPORT").map(PathBuf::from) else { return; }; - let now = Instant::now(); - let _ = EPOCH.set(now); - let render = Arc::new(Mutex::new(Vec::with_capacity(16384))); - if let Some(render_app) = app.get_sub_app_mut(RenderApp) { - render_app - .insert_resource(RenderPerformance { - start: None, - frame: now, - prepared: now, - mark: now, - phases: [0.; 3], - samples: render.clone(), - }) - .add_systems(Render, render_begin.before(RenderSystems::ExtractCommands)) - .add_systems( - Render, - render_assets_done - .after(RenderSystems::PrepareMeshes) - .after(RenderSystems::PrepareAssets) - .before(RenderSystems::ManageViews), - ) - .add_systems( - Render, - render_views_done - .after(RenderSystems::ManageViews) - .before(RenderSystems::Queue), - ) - .add_systems( - Render, - render_queue_done - .after(RenderSystems::Queue) - .before(RenderSystems::PhaseSort), - ) - .add_systems( - Render, - render_prepared - .after(RenderSystems::Prepare) - .before(RenderSystems::Render), - ) - .add_systems(Render, render_finish.after(RenderSystems::PostCleanup)); - render_app - .init_resource::() - .add_systems( - Render, - mesh_binding_begin - .in_set(RenderSystems::PrepareBindGroups) - .before(bevy::pbr::prepare_mesh_bind_groups), - ) - .add_systems( - Render, - mesh_binding_end - .in_set(RenderSystems::PrepareBindGroups) - .after(bevy::pbr::prepare_mesh_bind_groups), - ); - } - // Benchmark measurements must not depend on whether the window has focus. - app.insert_resource(bevy::winit::WinitSettings::continuous()); - app.add_systems(Startup, report_adapter); - if std::env::var_os("SKATE_PERF_CAMERA_SWEEP").is_some() { - app.add_systems( - Update, - sweep_camera.after(crate::app::FrameSet::Verification), - ); - } - app.insert_resource(Performance { - path: path.into(), - start: None, - frame_start: now, - previous: now, - physics_ms: 0., - ticks: 0, - samples: Vec::with_capacity(16384), - sample_times: Vec::with_capacity(16384), - render, - }) - .add_systems(First, begin) - .add_systems(Last, finish); + info!("SKATE_PERF starting: warmup={WARMUP}s sample={SAMPLE}s output={output:?}"); + let phases = RenderPhases::default(); + app.insert_resource(Performance::new(output)) + .init_resource::() + .insert_resource(phases.clone()) + // Per-pass GPU timings, which is the only way to attribute frame cost + // to the GPU rather than to render-schedule CPU work. + .add_plugins(bevy::render::diagnostic::RenderDiagnosticsPlugin) + .add_systems(First, frame_begin) + .add_systems(Last, frame_end); + + use bevy::render::{Render, RenderApp, RenderSystems}; + let Some(render) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render.insert_resource(phases); + render.add_systems( + Render, + ( + begin_render_frame.before(RenderSystems::ExtractCommands), + after_extract + .after(RenderSystems::ExtractCommands) + .before(RenderSystems::PrepareAssets), + after_assets + .after(RenderSystems::PrepareMeshes) + .after(RenderSystems::PrepareAssets) + .before(RenderSystems::ManageViews), + after_views + .after(RenderSystems::ManageViews) + .before(RenderSystems::Queue), + after_queue + .after(RenderSystems::QueueSweep) + .before(RenderSystems::PhaseSort), + after_prepare + .after(RenderSystems::PrepareBindGroups) + .before(RenderSystems::Render), + end_render_frame.after(RenderSystems::Cleanup), + ), + ); } } -#[derive(Resource, Default)] -struct MeshBindingTimer(Option); -fn mesh_binding_begin(mut timer: ResMut) { - timer.0 = Some(Scope::new("mesh_bind_groups")); +fn begin_render_frame(phases: Res) { + phases.0.started.store(now_ns(), Ordering::Relaxed); } -fn mesh_binding_end(mut timer: ResMut) { - timer.0.take(); + +fn after_extract(phases: Res) { + mark(&phases, &phases.0.extract_ns); } -fn report_adapter( - device: Res, - adapter: Res, + +fn after_assets(phases: Res) { + mark(&phases, &phases.0.assets_ns); +} + +fn after_views(phases: Res) { + mark(&phases, &phases.0.views_ns); +} + +fn after_queue(phases: Res) { + mark(&phases, &phases.0.queue_ns); +} + +fn after_prepare(phases: Res) { + mark(&phases, &phases.0.prepare_ns); +} + +/// Closes the frame: the residual span is the render graph plus cleanup, and the +/// frame counter is what turns every accumulator into a per-frame mean. +fn end_render_frame(phases: Res) { + mark(&phases, &phases.0.total_ns); + phases.0.frames.fetch_add(1, Ordering::Relaxed); +} + +fn frame_begin(mut performance: ResMut) { + performance.frame_started = Some(Instant::now()); + performance.main_elapsed = Duration::ZERO; + performance.physics_elapsed = Duration::ZERO; +} + +fn frame_end( + mut performance: ResMut, + time: Res>, + draws: Res, + phases: Res, + diagnostics: Res, + mut exit: MessageWriter, ) { - eprintln!( - "SKATE_GPU adapter={:?} features={:?} limits={:?}", - &**adapter, - device.features(), - device.limits() - ); -} - -// Rendering-only benchmark: exercise changing visibility without steering the -// skater or feeding synthetic inputs into the simulation camera. -fn sweep_camera(time: Res>, mut cameras: Query<&mut Transform, With>) { - for mut transform in &mut cameras { - transform.rotate_y(time.elapsed_secs() * std::f32::consts::TAU / 12.); + if performance.finished { + return; } -} -fn begin(mut p: ResMut) { - p.frame_start = Instant::now(); - p.physics_ms = 0.; - p.ticks = 0; -} -fn finish(mut p: ResMut, mut exit: MessageWriter) { - let now = Instant::now(); - let elapsed = now - .duration_since(*p.start.get_or_insert(now)) - .as_secs_f64(); - let frame = now.duration_since(p.previous).as_secs_f64() * 1000.; - p.previous = now; - // Exclude initialization and shader warmup. Frame interval includes render - // synchronization; CPU schedule time is First..Last of the main world. - if elapsed > 10. { - let sample = [ - frame, - now.duration_since(p.frame_start).as_secs_f64() * 1000., - p.physics_ms, - f64::from(p.ticks), - ]; - p.samples.push(sample); - p.sample_times.push(timestamp(now)); - } - if elapsed > 25. && !p.samples.is_empty() { - let mean = |column: usize| { - p.samples.iter().map(|s| s[column]).sum::() / p.samples.len() as f64 - }; - let mut frames: Vec<_> = p.samples.iter().map(|s| s[0]).collect(); - frames.sort_by(f64::total_cmp); - let render = p.render.lock().unwrap(); - let render_mean = |column: usize| { - render.iter().map(|s| s[column]).sum::() / render.len().max(1) as f64 - }; - let report = serde_json::json!({ - "frames": frames.len(), "fps": 1000. / mean(0), - "frame_ms_mean": mean(0), "frame_ms_median": frames[frames.len()/2], - "frame_ms_p95": frames[(frames.len()-1)*95/100], - "main_schedule_ms_mean": mean(1), "physics_ms_per_frame": mean(2), - "physics_ticks_per_frame": mean(3), - "physics_ms_per_tick": mean(2) / mean(3).max(f64::EPSILON), - "render_prepare_ms_mean": render_mean(0), "render_submit_ms_mean": render_mean(1), - "render_assets_ms_mean": render_mean(2), "render_views_ms_mean": render_mean(3), "render_queue_ms_mean": render_mean(4), - "sections_ms_per_call": SECTIONS.lock().unwrap().iter().map(|(&k, &(ms, n))| (k, ms / n as f64)).collect::>(), - "samples": p.samples, - "sample_elapsed_ms": p.sample_times, - "render_samples": &*render, - "render_sample_columns": ["prepare_ms", "submit_ms", "assets_ms", "views_ms", "queue_ms", "elapsed_ms", "waiting_pipelines"], - "slow_sections": &*SLOW_SECTIONS.lock().unwrap(), - "slow_section_columns": ["section", "start_elapsed_ms", "duration_ms"], - "frame_ms_max": frames[frames.len()-1], - "frame_ms_p99": frames[(frames.len()-1)*99/100], - "frames_over_8ms": frames.iter().filter(|&&ms| ms > 8.).count(), - }); - match std::fs::write(&p.path, serde_json::to_vec_pretty(&report).unwrap()) { - Ok(()) => { - eprintln!("SKATE_PERF_REPORT {}", p.path.display()); - exit.write(AppExit::Success); - } - Err(e) => { - eprintln!("Performance report: {e}"); - exit.write(AppExit::error()); - } + let elapsed = performance.started.elapsed().as_secs_f32(); + if !performance.sampling { + if elapsed < WARMUP { + return; } + performance.sampling = true; + // Warmup includes shader compilation and asset streaming, whose cost + // would otherwise dominate the phase accumulators. + phases.reset(); + info!("SKATE_PERF warmup complete, sampling {SAMPLE}s"); + } + + // Wall-clock delta rather than the frame_begin instant: it includes the + // presentation wait, which is what the player actually experiences. + let total_ms = time.delta().as_secs_f32() * 1000.0; + let main_ms = performance + .frame_started + .map_or(0.0, |start| start.elapsed().as_secs_f32() * 1000.0); + let physics_ms = performance.physics_elapsed.as_secs_f32() * 1000.0; + performance.samples.push(Frame { + total_ms, + main_ms, + physics_ms, + draws: draws.total_draws(), + triangles: draws.world_triangles + draws.mod_triangles, + }); + + if elapsed < WARMUP + SAMPLE { + return; + } + performance.finished = true; + let mut report = summarise(&performance.samples, &draws); + report.render_phase_ms = phases.means(); + // Every render diagnostic, so GPU pass costs land in the report without + // this module needing to know the pass names the render graph happens to use. + report.gpu = diagnostics + .iter() + .filter(|d| d.path().as_str().starts_with("render/")) + .filter_map(|d| d.smoothed().map(|v| (d.path().to_string(), v))) + .filter(|(_, v)| *v > 0.0) + .collect(); + report.gpu.sort_by(|a, b| b.1.total_cmp(&a.1)); + match write_report(&performance.output, &report) { + Ok(()) => info!("SKATE_PERF_REPORT written to {:?}", performance.output), + Err(error) => error!("SKATE_PERF_REPORT could not be written: {error}"), + } + let [extract, assets, views, queue, prepare, graph] = report.render_phase_ms; + eprintln!( + "SKATE_PERF fps={:.1} frame_ms_mean={:.3} p99={:.3} main={:.3} draws={} triangles={}", + report.fps, + report.frame_ms_mean, + report.frame_ms_p99, + report.main_schedule_ms_mean, + report.draw_calls, + report.triangles + ); + eprintln!( + "SKATE_PERF_RENDER extract={extract:.3} assets={assets:.3} views={views:.3} \ + queue={queue:.3} prepare={prepare:.3} graph={graph:.3}" + ); + // Times first and in full: sorting the whole set by value buries + // sub-millisecond durations under invocation counts in the millions. + for (name, value) in report.gpu.iter().filter(|(n, _)| n.ends_with("elapsed_gpu")) { + eprintln!("SKATE_PERF_GPU_MS {name} {value:.4}"); + } + for (name, value) in report + .gpu + .iter() + .filter(|(n, _)| n.ends_with("invocations")) + .take(8) + { + eprintln!("SKATE_PERF_GPU {name} {value:.0}"); + } + exit.write(AppExit::Success); +} + +struct Report { + frames: usize, + fps: f32, + frame_ms_mean: f32, + frame_ms_median: f32, + frame_ms_p95: f32, + frame_ms_p99: f32, + frame_ms_max: f32, + frames_over_hitch: usize, + main_schedule_ms_mean: f32, + physics_ms_mean: f32, + draw_calls: u32, + triangles: u32, + /// extract, assets, views, queue, prepare, graph — see `RenderPhases`. + render_phase_ms: [f32; 6], + /// Render diagnostics, highest first. Includes GPU pass timings when the + /// adapter supports timestamp queries. + gpu: Vec<(String, f64)>, + samples: Vec, +} + +fn summarise(samples: &[Frame], draws: &DrawStats) -> Report { + let frames = samples.len(); + let mut sorted: Vec = samples.iter().map(|f| f.total_ms).collect(); + sorted.sort_by(|a, b| a.total_cmp(b)); + let mean = |values: &[f32]| -> f32 { + if values.is_empty() { 0.0 } else { values.iter().sum::() / values.len() as f32 } + }; + // Nearest-rank percentile. Exact interpolation is not worth it here: the + // sample count is in the thousands and the budget is stated in whole ms. + let percentile = |q: f32| -> f32 { + if sorted.is_empty() { + return 0.0; + } + // Nearest-rank: the smallest sample at or below which `q` of the frames + // fall, i.e. rank `ceil(q*N)` counting from one. Reporting "95% of frames + // were at or under this" only means that under this definition. + let rank = (q * sorted.len() as f32).ceil() as usize; + sorted[rank.clamp(1, sorted.len()) - 1] + }; + let frame_ms_mean = mean(&sorted); + Report { + frames, + fps: if frame_ms_mean > 0.0 { 1000.0 / frame_ms_mean } else { 0.0 }, + frame_ms_mean, + frame_ms_median: percentile(0.50), + frame_ms_p95: percentile(0.95), + frame_ms_p99: percentile(0.99), + frame_ms_max: sorted.last().copied().unwrap_or(0.0), + frames_over_hitch: samples.iter().filter(|f| f.total_ms > HITCH_MS).count(), + main_schedule_ms_mean: mean(&samples.iter().map(|f| f.main_ms).collect::>()), + physics_ms_mean: mean(&samples.iter().map(|f| f.physics_ms).collect::>()), + draw_calls: draws.total_draws(), + triangles: draws.world_triangles + draws.mod_triangles, + render_phase_ms: [0.0; 6], + gpu: Vec::new(), + samples: samples.to_vec(), } } -#[derive(Resource)] -struct RenderPerformance { - start: Option, - frame: Instant, - prepared: Instant, - mark: Instant, - phases: [f64; 3], - samples: Arc>>, +fn write_report(path: &std::path::Path, report: &Report) -> std::io::Result<()> { + let json = serde_json::json!({ + "frames": report.frames, + "fps": report.fps, + "frame_ms_mean": report.frame_ms_mean, + "frame_ms_median": report.frame_ms_median, + "frame_ms_p95": report.frame_ms_p95, + "frame_ms_p99": report.frame_ms_p99, + "frame_ms_max": report.frame_ms_max, + "frames_over_8ms": report.frames_over_hitch, + "main_schedule_ms_mean": report.main_schedule_ms_mean, + "physics_ms_mean": report.physics_ms_mean, + "draw_calls": report.draw_calls, + "triangles": report.triangles, + "render_extract_ms_mean": report.render_phase_ms[0], + "render_assets_ms_mean": report.render_phase_ms[1], + "render_views_ms_mean": report.render_phase_ms[2], + "render_queue_ms_mean": report.render_phase_ms[3], + "render_prepare_ms_mean": report.render_phase_ms[4], + "render_graph_ms_mean": report.render_phase_ms[5], + "render_diagnostics": report.gpu.iter() + .map(|(name, value)| serde_json::json!({ "name": name, "ms": value })) + .collect::>(), + "warmup_seconds": WARMUP, + "sample_seconds": SAMPLE, + "samples": report.samples.iter().map(|f| serde_json::json!({ + "frame_ms": f.total_ms, + "main_ms": f.main_ms, + "physics_ms": f.physics_ms, + "draws": f.draws, + "triangles": f.triangles, + })).collect::>(), + }); + std::fs::write(path, serde_json::to_vec_pretty(&json)?) } -fn render_begin(mut p: ResMut) { - p.frame = Instant::now(); - p.mark = p.frame; -} -fn render_assets_done(mut p: ResMut) { - let now = Instant::now(); - p.phases[0] = now.duration_since(p.mark).as_secs_f64() * 1000.; - p.mark = now; -} -fn render_views_done(mut p: ResMut) { - let now = Instant::now(); - p.phases[1] = now.duration_since(p.mark).as_secs_f64() * 1000.; - p.mark = now; -} -fn render_queue_done(mut p: ResMut) { - p.phases[2] = p.mark.elapsed().as_secs_f64() * 1000.; -} -fn render_prepared(mut p: ResMut) { - p.prepared = Instant::now(); -} -fn render_finish(mut p: ResMut, pipelines: Res) { - let now = Instant::now(); - if now - .duration_since(*p.start.get_or_insert(now)) - .as_secs_f64() - > 10. - { - p.samples.lock().unwrap().push([ - p.prepared.duration_since(p.frame).as_secs_f64() * 1000., - now.duration_since(p.prepared).as_secs_f64() * 1000., - p.phases[0], - p.phases[1], - p.phases[2], - timestamp(now), - pipelines.waiting_pipelines().count() as f64, - ]); + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(total_ms: f32) -> Frame { + Frame { total_ms, ..default() } + } + + #[test] + fn percentiles_follow_sample_order() { + // Values 1..=100, so the Nth smallest sample is exactly N ms and every + // rank is readable directly. + let samples: Vec = (1..=100).map(|ms| frame(ms as f32)).collect(); + let report = summarise(&samples, &DrawStats::default()); + assert_eq!(report.frames, 100); + assert_eq!(report.frame_ms_median, 50.0); + assert_eq!(report.frame_ms_p95, 95.0); + assert_eq!(report.frame_ms_p99, 99.0); + assert_eq!(report.frame_ms_max, 100.0); + // A single sample is every percentile of itself. + let one = summarise(&[frame(7.0)], &DrawStats::default()); + assert_eq!( + (one.frame_ms_median, one.frame_ms_p95, one.frame_ms_p99), + (7.0, 7.0, 7.0) + ); + } + + #[test] + fn fps_is_the_reciprocal_of_mean_frame_time() { + // 300 FPS is the contract; 3.333 ms per frame must read back as ~300. + let samples = vec![frame(10.0 / 3.0); 64]; + let report = summarise(&samples, &DrawStats::default()); + assert!((report.fps - 300.0).abs() < 1.0, "fps={}", report.fps); + } + + #[test] + fn hitches_are_counted_against_the_8ms_threshold() { + let samples = vec![frame(3.0), frame(9.0), frame(8.0), frame(20.0)]; + let report = summarise(&samples, &DrawStats::default()); + assert_eq!(report.frames_over_hitch, 2); + } + + #[test] + fn empty_sample_set_does_not_panic() { + let report = summarise(&[], &DrawStats::default()); + assert_eq!(report.frames, 0); + assert_eq!(report.fps, 0.0); } } diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 021e39b9..c061011e 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -35,6 +35,7 @@ mod solve; pub(crate) mod solid_contacts; pub(crate) mod network; pub(crate) use skater::SkaterRuntime; +pub(crate) use input_phase::{facing_from_visual, horizontal_spawn}; mod animation_feedback; mod animation_feedback_settings; mod animation_input; @@ -409,7 +410,7 @@ impl Plugin for PhysicsPlugin { } } -fn advance( +pub(crate) fn advance( mut physics: ResMut, mut skater: ResMut, mut controls: ResMut, diff --git a/crates/skate-game/src/physics/input_phase.rs b/crates/skate-game/src/physics/input_phase.rs index 1e2c66da..a0e02633 100644 --- a/crates/skate-game/src/physics/input_phase.rs +++ b/crates/skate-game/src/physics/input_phase.rs @@ -2,6 +2,7 @@ //! PlayerInput retains the original ordering; callbacks split host ownership. #[path = "input_teleport.rs"] mod teleport; +pub(crate) use teleport::{facing_from_visual, horizontal_spawn}; use super::{ GamePhysics, SkaterRuntime, animated_skeleton::AnimatedSkeleton, diff --git a/crates/skate-game/src/physics/input_teleport.rs b/crates/skate-game/src/physics/input_teleport.rs index 4a61e90f..abac36fd 100644 --- a/crates/skate-game/src/physics/input_teleport.rs +++ b/crates/skate-game/src/physics/input_teleport.rs @@ -19,15 +19,22 @@ impl Callbacks<'_, '_> { self.life .skeleton_controller .request_ground(self.collision_mode)?; - let target = horizontal_spawn(requested); + // The requested matrix is world facing. reset_physical still applies the + // live switch flip (Processed2468 bit20), so pre-flip here to cancel it. + let target = match_board_stance(horizontal_spawn(requested), p.flags_2468); bevy::log::info!( on_board = p.byte_1600 & 1 != 0, requested_x = requested[3][0], requested_y = requested[3][1], requested_z = requested[3][2], + requested_at_x = requested[2][0], + requested_at_z = requested[2][2], applied_x = target[3][0], applied_y = target[3][1], applied_z = target[3][2], + applied_at_x = target[2][0], + applied_at_z = target[2][2], + switch = p.flags_2468 & 0x0010_0000 != 0, "player teleport spawn" ); player.flags_1296 = ((player.flags_1296 & 0xffef_ffff) & 0x81ff_ffff) | 0x6000_0000; @@ -144,18 +151,108 @@ fn affine(m: NativeMatrix) -> RetailAffineTransform { translation: Vector3::new(m[3][0], m[3][1], m[3][2]), } } +const SWITCH: u32 = 0x0010_0000; +const MIN_HEADING: f32 = f32::from_bits(0x3a83_126f); // native .001 reject + ///82DB8A10..8BB4 flattens At before normalization, rejects length<=.001, -///then adds the original .1m world-Y teleport clearance. -fn horizontal_spawn(requested: NativeMatrix) -> NativeMatrix { +///then adds the original .1m world-Y teleport clearance. If At is nearly +///vertical, recover yaw from Ri so a downward look still has a heading. +pub(crate) fn horizontal_spawn(requested: NativeMatrix) -> NativeMatrix { let mut result = IDENTITY; - let x = requested[2][0]; - let z = requested[2][2]; - let length = (x * x + z * z).sqrt(); - if length > f32::from_bits(0x3a83_126f) { + let mut x = requested[2][0]; + let mut z = requested[2][2]; + let mut length = (x * x + z * z).sqrt(); + if length <= MIN_HEADING { + // At = flatten(Ri) × Up => (-Ri.z, 0, Ri.x) + x = -requested[0][2]; + z = requested[0][0]; + length = (x * x + z * z).sqrt(); + } + if length > MIN_HEADING { result[2] = [x / length, 0.0, z / length, 0.0]; result[0] = [result[2][2], 0.0, -result[2][0], 0.0]; + result[1] = [0.0, 1.0, 0.0, 0.0]; } result[3] = requested[3]; result[3][1] += 0.1; result } + +/// Processed2468 bit20 flips Ri/At on SetTransform. Invert first so a world +/// facing survives reset, whether the player is regular or switch. +pub(crate) fn match_board_stance(mut transform: NativeMatrix, flags_2468: u32) -> NativeMatrix { + if flags_2468 & SWITCH != 0 { + flip_heading(&mut transform); + } + transform +} + +pub(crate) fn flip_heading(transform: &mut NativeMatrix) { + for i in [0, 2] { + transform[i] = transform[i].map(|v| -v); + } +} + +/// World-space skater facing for a teleport. Switch/mirror bits unflip the +/// live visual axes so the stored At is the direction they were facing. +pub(crate) fn facing_from_visual( + mut transform: NativeMatrix, + flags_2468: u32, + flags_2476: u32, + on_board: bool, +) -> NativeMatrix { + if on_board && flags_2468 & SWITCH != 0 || !on_board && flags_2476 & 4 != 0 { + flip_heading(&mut transform); + } + transform +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flattens_horizontal_at_and_builds_right() { + let mut requested = IDENTITY; + requested[2] = [3.0, 1.0, 4.0, 0.0]; + requested[3] = [10.0, 2.0, 30.0, 1.0]; + let spawn = horizontal_spawn(requested); + assert!((spawn[2][0] - 0.6).abs() < 1e-5); + assert_eq!(spawn[2][1], 0.0); + assert!((spawn[2][2] - 0.8).abs() < 1e-5); + assert_eq!(spawn[0], [0.8, 0.0, -0.6, 0.0]); + assert_eq!(spawn[3][1], 2.1); + } + + #[test] + fn recovers_yaw_from_right_when_looking_down() { + let mut requested = IDENTITY; + requested[0] = [0.0, 0.0, -1.0, 0.0]; + requested[1] = [-1.0, 0.0, 0.0, 0.0]; + requested[2] = [0.0, -1.0, 0.0, 0.0]; + let spawn = horizontal_spawn(requested); + assert!((spawn[2][0] - 1.0).abs() < 1e-5); + assert!(spawn[2][2].abs() < 1e-5); + } + + #[test] + fn switch_stance_cancels_across_reset() { + let mut facing = IDENTITY; + facing[0] = [0.0, 0.0, -1.0, 0.0]; + facing[2] = [1.0, 0.0, 0.0, 0.0]; + let prepared = match_board_stance(facing, SWITCH); + let applied = match_board_stance(prepared, SWITCH); + assert_eq!(applied[2], facing[2]); + assert_eq!(applied[0], facing[0]); + assert_eq!(match_board_stance(facing, 0)[2], facing[2]); + } + + #[test] + fn visual_switch_unflips_to_facing() { + let mut visual = IDENTITY; + visual[2] = [-1.0, 0.0, 0.0, 0.0]; + visual[0] = [0.0, 0.0, 1.0, 0.0]; + let facing = facing_from_visual(visual, SWITCH, 0, true); + assert_eq!(facing[2], [1.0, 0.0, 0.0, 0.0]); + } +} diff --git a/crates/skate-game/src/physics/skeleton_feedback.rs b/crates/skate-game/src/physics/skeleton_feedback.rs index 3a4ca8eb..12150c7b 100644 --- a/crates/skate-game/src/physics/skeleton_feedback.rs +++ b/crates/skate-game/src/physics/skeleton_feedback.rs @@ -256,7 +256,36 @@ mod tests { #[test] fn vehicle_contact_uses_point_velocity_for_group_eight() { - let mut body = skate_core::physics::assembly::BodySnapshot::default(); + // `BodySnapshot` has no `Default`: every field is a native body value, + // so an implicit zero would be a body with no basis. Spell out a resting + // unit body the way the other physics tests do. + let identity = skate_core::math::Basis3 { + columns: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]], + }; + let mut body = skate_core::physics::assembly::BodySnapshot { + state_flags: 4, + rates: skate_core::physics::rigid_body::RetailBodyRates { + orientation: skate_core::physics::rigid_body::RetailQuaternion::IDENTITY, + basis: identity, + world_inverse_inertia: identity, + position: skate_core::math::Vector3::ZERO, + linear_velocity: skate_core::math::Vector3::ZERO, + angular_velocity: skate_core::math::Vector3::ZERO, + force_acceleration: skate_core::math::Vector3::ZERO, + torque_acceleration: skate_core::math::Vector3::ZERO, + kinetic_energy: 0., + cool_down: 0, + }, + inertia: skate_core::physics::rigid_body::RetailInertiaDynamics { + inverse_tensor: skate_core::math::Vector3::new(1., 1., 1.), + inverse_mass: 1., + spherical: 0., + maximum_linear_velocity: f32::MAX, + maximum_angular_velocity: f32::MAX, + linear_drag: 0., + angular_drag: 0., + }, + }; body.rates.position = skate_core::math::Vector3::new(0., 0., 0.); body.rates.linear_velocity = skate_core::math::Vector3::new(1., 0., 0.); body.rates.angular_velocity = skate_core::math::Vector3::new(0., 2., 0.); diff --git a/crates/skate-game/src/presentation.rs b/crates/skate-game/src/presentation.rs index 5ed360c5..030160e2 100644 --- a/crates/skate-game/src/presentation.rs +++ b/crates/skate-game/src/presentation.rs @@ -43,8 +43,10 @@ impl Presentation { fn capture(skater: Res, camera: Res, time: Res>, mut history: ResMut, mut replay: ResMut) { - if skater.pose_generation == history.generation { return; } - let Some(frame) = camera.frame else { return; }; + if skater.pose_generation == history.generation && !camera.manual_cam.manual_cam_active() { + return; + } + let Some(frame) = camera.presentation_frame() else { return; }; let next = Snapshot { root: Transform::from_matrix(crate::animation::native_matrix( skater.animated_skeleton.roots.animation_to_world)), diff --git a/crates/skate-game/src/profiling.rs b/crates/skate-game/src/profiling.rs index 91accae0..3549726e 100644 --- a/crates/skate-game/src/profiling.rs +++ b/crates/skate-game/src/profiling.rs @@ -1,658 +1,291 @@ -//! Bounded, local-only CPU timeline. GPU diagnostics are delayed duration counters. -use bevy::{ - log::{ - tracing::{ - self, Subscriber, - span::{Attributes, Id}, - }, - tracing_subscriber::{ - self as subscriber, Layer, - layer::{Context, SubscriberExt}, - registry::LookupSpan, - }, - }, - prelude::*, +//! Logging and Chrome-trace capture. +//! +//! `app.rs` disables Bevy's `LogPlugin`, so this module owns the global tracing +//! subscriber for the whole process. `init()` must therefore run before anything +//! logs, which is why `main` calls it first. +//! +//! Trace capture is opt-in via `--trace `, with `--trace-wait` to arm it +//! for F9/F10 instead of recording from startup, and `--trace-seconds N` to stop +//! automatically. Bevy's `trace` feature supplies the spans we record. +use bevy::log::tracing::{Id, Subscriber, span::Attributes}; +use bevy::log::tracing_subscriber::{ + EnvFilter, Layer, layer::Context, layer::SubscriberExt, registry::LookupSpan, + util::SubscriberInitExt, }; -use serde_json::{Value, json}; +use bevy::prelude::*; use std::{ - cell::RefCell, - collections::HashMap, - fs::OpenOptions, + fs::File, io::{BufWriter, Write}, path::PathBuf, sync::{ - Arc, OnceLock, + Mutex, OnceLock, atomic::{AtomicBool, AtomicU64, Ordering}, - mpsc::{SyncSender, sync_channel}, }, - thread::JoinHandle, - time::{Duration, Instant}, + time::Instant, }; -const MAX_BYTES: usize = 128 * 1024 * 1024; -static CAPTURE: OnceLock> = OnceLock::new(); -static NEXT_THREAD: AtomicU64 = AtomicU64::new(1); -thread_local! { - static THREAD: u64 = NEXT_THREAD.fetch_add(1, Ordering::Relaxed); - static ENTERED: RefCell> = const { RefCell::new(Vec::new()) }; -} -#[derive(Debug, Default)] +/// Set once by `init()` so `install()` can reach the same capture state without +/// threading it through `main`'s return type. +static CAPTURE: OnceLock<&'static Capture> = OnceLock::new(); + struct Options { - path: Option, - seconds: u64, - delay: u64, + output: PathBuf, wait: bool, gpu: bool, - min_us: u64, -} -impl Options { - fn parse(args: impl Iterator) -> Result { - let mut result = Self { - seconds: 30, - min_us: 25, - ..Self::default() - }; - let mut args = args; - let mut modifiers = false; - while let Some(arg) = args.next() { - match arg.to_str() { - Some("--trace") => { - if result.path.is_some() { - return Err("Duplicate --trace".into()); - } - result.path = Some( - args.next() - .ok_or("--trace requires a new output JSON path")? - .into(), - ); - } - Some("--trace-min-us") => { - modifiers = true; - result.min_us = args - .next() - .and_then(|a| a.to_str().and_then(|v| v.parse().ok())) - .filter(|v| *v <= 1000) - .ok_or("--trace-min-us requires 0..1000 microseconds")?; - } - Some("--trace-seconds" | "--trace-delay") => { - modifiers = true; - let value = args - .next() - .and_then(|a| a.to_str().and_then(|v| v.parse::().ok())) - .ok_or("Trace duration must be an integer")?; - if value > 600 || (arg == "--trace-seconds" && value == 0) { - return Err("Trace seconds: 1..600; delay: 0..600".into()); - } - if arg == "--trace-seconds" { - result.seconds = value; - } else { - result.delay = value; - } - } - Some("--trace-wait") => { - modifiers = true; - result.wait = true; - } - Some("--trace-gpu") => { - modifiers = true; - result.gpu = true; - } - // Consume values of other CLI options; never interpret them as trace flags. - Some("--net-local") => { - args.next(); - args.next(); - } - Some( - "--net-host" | "--net-session" | "--spawn-offset" | "--player-title" - | "--appearance" | "--controller" | "--assets" | "--map" | "--teleport" - | "--difficulty" | "--verify", - ) => { - args.next(); - } - _ => {} - } - } - if modifiers && result.path.is_none() { - return Err("Trace modifiers require --trace FILE.json".into()); - } - if result.wait && result.delay != 0 { - return Err("Choose --trace-wait or --trace-delay".into()); - } - Ok(result) - } -} -struct Capture { - origin: Instant, - active: AtomicBool, - start: AtomicBool, - stop: AtomicBool, - dropped: AtomicU64, - sender: SyncSender, - gpu: bool, - min_us: u64, - filtered: AtomicU64, -} -impl Capture { - fn retain_duration(&self, micros: u64) -> bool { - if micros >= self.min_us { - return true; - } - self.filtered.fetch_add(1, Ordering::Relaxed); - false - } - fn send(&self, value: Value) { - if self.sender.try_send(value).is_err() { - self.dropped.fetch_add(1, Ordering::Relaxed); - } - } - fn counter(&self, name: &str, args: Value) { - if self.active.load(Ordering::Relaxed) { - self.send(json!({"name":name,"ph":"C","pid":1,"tid":0,"ts":self.origin.elapsed().as_micros() as u64,"args":args})); - } - } -} -pub(crate) struct Guard(Arc, Option>>); -impl Drop for Guard { - fn drop(&mut self) { - self.0.stop.store(true, Ordering::Relaxed); - if let Some(writer) = self.1.take() { - if !matches!(writer.join(), Ok(Ok(()))) { - eprintln!("TRACE export failed; capture may be incomplete"); - } - } - } + seconds: Option, } -pub(crate) fn init() -> Result, String> { - let options = Options::parse(std::env::args_os().skip(1))?; - init_options(options) -} -fn init_options(options: Options) -> Result, String> { - let mut guard = None; - let layer = if let Some(path) = options.path { - // create_new refuses to destroy a previous recording. - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - .map_err( - |_| "Cannot create trace: use a new filename in an existing writable directory", - )?; - let (sender, receiver) = sync_channel::(8192); - let capture = Arc::new(Capture { - origin: Instant::now(), - active: AtomicBool::new(!options.wait && options.delay == 0), - start: AtomicBool::new(false), - stop: AtomicBool::new(false), - dropped: AtomicU64::new(0), - sender, - gpu: options.gpu, - min_us: options.min_us, - filtered: AtomicU64::new(0), - }); - CAPTURE - .set(capture.clone()) - .map_err(|_| "Trace already initialized")?; - let state = capture.clone(); - let writer = std::thread::Builder::new().name("trace-export".into()).spawn(move || { - let result = (|| -> std::io::Result<()> { - let mut writer = BufWriter::new(file); - writer.write_all(b"{\"traceEvents\":[\n")?; - let mut bytes = 0; - let mut started = state.active.load(Ordering::Relaxed).then(Instant::now); - let mut reason = "stopped"; - loop { - if state.stop.load(Ordering::Relaxed) { break; } - if started.is_none() && (state.start.swap(false, Ordering::Relaxed) || (!options.wait && state.origin.elapsed().as_secs() >= options.delay)) { - started = Some(Instant::now()); - state.active.store(true, Ordering::Relaxed); - } - if started.is_some_and(|s| s.elapsed() >= Duration::from_secs(options.seconds)) { reason = "duration"; break; } - if let Ok(event) = receiver.recv_timeout(Duration::from_millis(20)) { - let line = serde_json::to_vec(&event)?; - if bytes + line.len() + 2 > MAX_BYTES { reason = "size_limit"; break; } - writer.write_all(&line)?; - writer.write_all(b",\n")?; - bytes += line.len() + 2; - } +fn options() -> Result, String> { + let args: Vec = std::env::args().collect(); + let Some(index) = args.iter().position(|a| a == "--trace") else { + // The modifiers are meaningless alone; catching that here avoids a run + // that silently produces no trace. + for stray in ["--trace-wait", "--trace-gpu", "--trace-seconds"] { + if args.iter().any(|a| a == stray) { + return Err(format!("{stray} requires --trace ")); } - state.active.store(false, Ordering::Relaxed); - // Drain the bounded queue on normal stop so end-of-frame events survive. - while let Ok(event) = receiver.try_recv() { - let line = serde_json::to_vec(&event)?; - if bytes + line.len() + 2 > MAX_BYTES { reason = "size_limit"; break; } - writer.write_all(&line)?; writer.write_all(b",\n")?; bytes += line.len() + 2; - } - let footer = json!({"name":"capture_complete","ph":"i","s":"g","pid":1,"tid":0,"ts":state.origin.elapsed().as_micros() as u64,"args":{"reason":reason,"dropped_events":state.dropped.load(Ordering::Relaxed),"filtered_short_spans":state.filtered.load(Ordering::Relaxed),"min_span_us":state.min_us}}); - serde_json::to_writer(&mut writer, &footer)?; - writer.write_all(b"],\"displayTimeUnit\":\"ms\"}\n")?; - writer.flush()?; - eprintln!("TRACE complete ({reason}); open the requested JSON in https://ui.perfetto.dev"); - Ok(()) - })(); - state.active.store(false, Ordering::Relaxed); - if result.is_err() { eprintln!("TRACE export failed; check free disk space and destination access"); } - result - }).map_err(|_| "Cannot start trace writer")?; - capture.send(json!({"name":"build","ph":"i","s":"g","ts":0,"pid":1,"tid":0,"args":{"build_id":env!("SKATE_BUILD_ID"),"version":env!("CARGO_PKG_VERSION"),"revision":env!("SKATE_RELEASE_REVISION"),"bevy":"0.18.1","wgpu":"27.0.1","debug_assertions":cfg!(debug_assertions),"gpu_requested":options.gpu,"min_span_us":options.min_us,"seconds":options.seconds,"delay":options.delay,"wait":options.wait}})); - eprintln!( - "TRACE armed; F9 starts waiting capture, F10 stops/exports. Recording does not stop gameplay." - ); - guard = Some(Guard(capture.clone(), Some(writer))); - Some( - Timeline(capture).with_filter(subscriber::filter::FilterFn::new(|m| { - m.is_span() - && (m.target().starts_with("bevy_") || m.target().starts_with("skate3rust")) - })), - ) - } else { - None + } + return Ok(None); }; - // Per-layer filters keep spans completely disabled in normal play. No environment - // filter is applied to the recorder; RUST_LOG cannot silently remove its systems. - let filter = subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| subscriber::EnvFilter::new("info,wgpu=error,naga=warn")); - let logs = subscriber::fmt::layer() - .with_writer(std::io::stderr) - .with_filter(subscriber::filter::FilterFn::new(|m| m.is_event())) - .with_filter(filter); - tracing::subscriber::set_global_default(subscriber::registry().with(layer).with(logs)) - .map_err(|_| "Cannot install profiling/log subscriber")?; - let _ = tracing_log::LogTracer::init(); - Ok(guard) + let output = args + .get(index + 1) + .filter(|a| !a.starts_with("--")) + .ok_or_else(|| "--trace requires an output path".to_string())?; + let seconds = match args.iter().position(|a| a == "--trace-seconds") { + Some(at) => Some( + args.get(at + 1) + .ok_or_else(|| "--trace-seconds requires a value".to_string())? + .parse::() + .map_err(|e| format!("--trace-seconds is not a number: {e}"))?, + ), + None => None, + }; + if std::env::var_os("SKATE_PERF_REPORT").is_some() { + return Err("--trace and SKATE_PERF_REPORT are mutually exclusive: \ + tracing changes the frame cost the perf report is measuring" + .into()); + } + Ok(Some(Options { + output: PathBuf::from(output), + wait: args.iter().any(|a| a == "--trace-wait"), + gpu: args.iter().any(|a| a == "--trace-gpu"), + seconds, + })) } -struct Timeline(Arc); -#[derive(Default)] -struct SystemName(String); -#[derive(Default)] -struct ScheduleName(String); -impl tracing::field::Visit for ScheduleName { - fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { - if field.name() != "name" { +/// Chrome trace sink. Events are appended as they happen; the JSON array is +/// closed when the guard drops. +struct Capture { + started: Instant, + recording: AtomicBool, + /// Distinct from `recording`: once finished we must not reopen the array. + closed: AtomicBool, + events: Mutex>>, + written: AtomicU64, +} + +impl Capture { + fn write(&self, phase: &str, name: &str, thread: u64) { + if !self.recording.load(Ordering::Relaxed) { + return; + } + let micros = self.started.elapsed().as_micros(); + let mut sink = self.events.lock().unwrap_or_else(|e| e.into_inner()); + let Some(file) = sink.as_mut() else { return }; + let first = self.written.fetch_add(1, Ordering::Relaxed) == 0; + let separator = if first { "" } else { ",\n" }; + // Escaping: span names are compile-time literals in this codebase, so a + // quote would be a programming error rather than untrusted input. + let _ = write!( + file, + "{separator}{{\"ph\":\"{phase}\",\"name\":\"{name}\",\"cat\":\"skate\",\ + \"pid\":1,\"tid\":{thread},\"ts\":{micros}}}" + ); + } + + fn metadata(&self, name: &str, value: &str) { + let mut sink = self.events.lock().unwrap_or_else(|e| e.into_inner()); + let Some(file) = sink.as_mut() else { return }; + let first = self.written.fetch_add(1, Ordering::Relaxed) == 0; + let separator = if first { "" } else { ",\n" }; + let _ = write!( + file, + "{separator}{{\"ph\":\"M\",\"name\":\"{name}\",\"cat\":\"skate\",\ + \"pid\":1,\"tid\":0,\"ts\":0,\"args\":{{\"value\":\"{value}\"}}}}" + ); + } + + fn finish(&self) { + if self.closed.swap(true, Ordering::SeqCst) { return; } - let name = format!("{value:?}"); - if matches!( - name.as_str(), - "Main" - | "First" - | "PreUpdate" - | "StateTransition" - | "RunFixedMainLoop" - | "FixedMain" - | "FixedFirst" - | "FixedPreUpdate" - | "FixedUpdate" - | "FixedPostUpdate" - | "FixedLast" - | "Update" - | "SpawnScene" - | "PostUpdate" - | "Last" - | "ExtractSchedule" - | "Render" - | "Startup" - | "PreStartup" - | "PostStartup" - ) { - self.0 = name; + self.recording.store(false, Ordering::SeqCst); + let mut sink = self.events.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(mut file) = sink.take() { + let _ = write!(file, "\n]\n"); + let _ = file.flush(); } } } -impl tracing::field::Visit for SystemName { - fn record_debug(&mut self, _: &tracing::field::Field, _: &dyn std::fmt::Debug) {} - fn record_str(&mut self, field: &tracing::field::Field, value: &str) { - if field.name() == "name" && value.len() <= 512 && !value.contains(['/', '\\', '\n']) { - self.0 = value.into(); - } - } + +thread_local! { + /// Chrome groups rows by `tid`. Real thread ids are not portably numeric, so + /// hand out our own dense ids in first-touch order. + static THREAD_ID: u64 = NEXT_THREAD.fetch_add(1, Ordering::Relaxed); } -impl LookupSpan<'a>> Layer for Timeline { - fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { - if attrs.metadata().target().starts_with("bevy_ecs") - && attrs.metadata().name() == "schedule" - { - let mut name = ScheduleName::default(); - attrs.record(&mut name); - if let Some(span) = ctx.span(id) { - span.extensions_mut().insert(SystemName(name.0)); - } - } - // Only Bevy ECS system labels may supply dynamic text. All other fields, - // source locations, thread names and log events are deliberately omitted. - if attrs.metadata().target().starts_with("bevy_ecs") - && matches!(attrs.metadata().name(), "system" | "system_commands") - { - let mut name = SystemName::default(); - attrs.record(&mut name); - if let Some(span) = ctx.span(id) { - span.extensions_mut().insert(name); - } - } - } - fn on_enter(&self, id: &Id, _: Context<'_, S>) { - if self.0.active.load(Ordering::Relaxed) { - ENTERED.with(|stack| stack.borrow_mut().push((id.into_u64(), Instant::now()))); +static NEXT_THREAD: AtomicU64 = AtomicU64::new(1); + +struct ChromeLayer { + capture: &'static Capture, +} + +impl Layer for ChromeLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_new_span(&self, _attrs: &Attributes<'_>, _id: &Id, _ctx: Context<'_, S>) {} + + fn on_enter(&self, id: &Id, ctx: Context<'_, S>) { + if let Some(span) = ctx.span(id) { + let thread = THREAD_ID.with(|id| *id); + self.capture.write("B", span.name(), thread); } } + fn on_exit(&self, id: &Id, ctx: Context<'_, S>) { - let start = ENTERED.with(|stack| { - let mut stack = stack.borrow_mut(); - let index = stack.iter().rposition(|(key, _)| *key == id.into_u64())?; - Some(stack.remove(index).1) - }); - if let Some(start) = start.filter(|_| self.0.active.load(Ordering::Relaxed)) { - let duration = start.elapsed().as_micros() as u64; - if !self.0.retain_duration(duration) { - return; - } - if let Some(span) = ctx.span(id) { - let ext = span.extensions(); - let label = ext - .get::() - .filter(|s| !s.0.is_empty()) - .map(|s| s.0.as_str()) - .unwrap_or(span.metadata().name()); - THREAD.with(|tid| self.0.send(json!({"name":label,"cat":span.metadata().name(),"ph":"X","pid":1,"tid":tid,"ts":start.duration_since(self.0.origin).as_micros() as u64,"dur":duration}))); - } + if let Some(span) = ctx.span(id) { + let thread = THREAD_ID.with(|id| *id); + self.capture.write("E", span.name(), thread); } } } -#[derive(Resource, Default)] -struct Frames { - last: Option, - fixed: u32, - frame: u64, - diagnostics: HashMap, +/// Held by `main` for the process lifetime; closes the JSON array on drop so a +/// trace remains loadable even after a clean exit or an early return. +pub(crate) struct Guard { + capture: Option<&'static Capture>, } -pub(crate) fn install(app: &mut App) { - let Some(capture) = CAPTURE.get() else { - return; - }; - if capture.gpu { - app.add_plugins(bevy::render::diagnostic::RenderDiagnosticsPlugin); - } - if let Some(render) = app.get_sub_app_mut(bevy::render::RenderApp) { - render.add_systems( - bevy::render::Render, - render_counters.after(bevy::render::RenderSystems::Cleanup), - ); - } - app.init_resource::() - .add_systems(First, begin_frame) - .add_systems(FixedFirst, |mut frames: ResMut| { - frames.fixed += 1; - }) - .add_systems(Last, end_frame); -} -fn render_counters( - mut frame: Local, - meshes: Res>, - images: Res>, - pipelines: Res, -) { - *frame += 1; - let capture = CAPTURE.get().unwrap(); - if *frame % 60 == 1 && capture.active.load(Ordering::Relaxed) { - capture.counter("render_resources",json!({"prepared_meshes":meshes.iter().count(),"prepared_images":images.iter().count(),"waiting_pipelines":pipelines.waiting_pipelines().count()})); - } -} -pub(crate) fn map_metadata(config: &crate::config::Config) { - let Some(capture) = CAPTURE.get() else { - return; - }; - capture.send(json!({"name":"loaded_map","ph":"i","s":"g","pid":1,"tid":0,"ts":capture.origin.elapsed().as_micros() as u64,"args":{"fingerprint":format!("{:016x}",config.map_fingerprint),"difficulty":config.difficulty.key(),"geometry":config.map.as_ref().map(|m|json!({"version":m.version,"triangles":m.geometry.indices.len()/3,"materials":m.materials.len(),"textures":m.textures.len(),"rail_records":m.rails.len()}))}})); -} -fn begin_frame(mut frames: ResMut) { - let capture = CAPTURE.get().unwrap(); - let now = Instant::now(); - if let Some(last) = frames.last.replace(now) { - capture.counter( - "frame_interval_ms", - json!({"value":now.duration_since(last).as_secs_f64()*1000.}), - ); - } - frames.fixed = 0; - frames.frame += 1; -} -fn end_frame( - mut frames: ResMut, - keys: Res>, - config: Res, - fixed: Res>, - meshes: Res>, - images: Res>, - entities: Query, - diagnostics: Res, - device: Option>, - windows: Query<&Window>, - cameras: Query<(&Camera, &Msaa)>, - menu: Option>, - map: Res, - transition: Res, - adapter: Option>, -) { - let capture = CAPTURE.get().unwrap(); - if keys.just_pressed(KeyCode::F9) { - capture.start.store(true, Ordering::Relaxed); - } - if keys.just_pressed(KeyCode::F10) { - capture.stop.store(true, Ordering::Relaxed); - } - if !capture.active.load(Ordering::Relaxed) { - return; - } - capture.counter("frame", json!({"number":frames.frame,"fixed_schedule_iterations":frames.fixed,"fixed_period_ms":fixed.timestep().as_secs_f64()*1000.,"cpu_main_until_trace_sample_ms":frames.last.unwrap().elapsed().as_secs_f64()*1000.})); - if frames.frame % 60 == 1 { - if let Some(adapter) = adapter { - capture.send(json!({"name":"adapter","ph":"i","s":"g","pid":1,"tid":0,"ts":capture.origin.elapsed().as_micros() as u64,"args":{"vendor":adapter.vendor,"device":adapter.device,"backend":format!("{:?}",adapter.backend)}})); +impl Drop for Guard { + fn drop(&mut self) { + if let Some(capture) = self.capture { + capture.finish(); + info!("SKATE_TRACE finished"); } - capture.send(json!({"name":"game_configuration","ph":"i","s":"g","pid":1,"tid":0,"ts":capture.origin.elapsed().as_micros() as u64,"args":{"graphics":menu.as_ref().map(|m|m.diagnostic_settings()),"paused":menu.as_ref().is_some_and(|m|m.open),"map_loading":transition.busy(),"map_generation":map.generation}})); - capture.counter( - "assets", - json!({"meshes":meshes.len(),"images":images.len(),"entities":entities.iter().count()}), - ); - capture.send(json!({"name":"configuration","ph":"i","s":"g","pid":1,"tid":0,"ts":capture.origin.elapsed().as_micros() as u64,"args":{"map_fingerprint":format!("{:016x}",config.map_fingerprint),"difficulty":config.difficulty.key(),"retail_bindless":device.as_ref().map(|d|bevy::pbr::material_uses_bindless_resources::(d)),"timestamp_queries":device.as_ref().is_some_and(|d|d.features().contains(bevy::render::settings::WgpuFeatures::TIMESTAMP_QUERY)),"windows":windows.iter().map(|w|json!({"width":w.physical_width(),"height":w.physical_height(),"present_mode":format!("{:?}",w.present_mode)})).collect::>(),"cameras":cameras.iter().map(|(c,m)|json!({"size":c.physical_target_size().map(|s|[s.x,s.y]),"msaa":m.samples()})).collect::>()}})); } - for diagnostic in diagnostics.iter() { - let path = diagnostic.path().as_str(); - // Built-in render paths only. Do not export arbitrary plugin diagnostic labels. - if !path.starts_with("render/") || path.len() > 1024 || path.contains(['\\', ':']) { - continue; - } - let Some(measurement) = diagnostic.measurement() else { - continue; - }; - if frames - .diagnostics - .get(path) - .is_some_and(|(_, time)| *time == measurement.time) +} + +pub(crate) fn init() -> Result { + // `log`-crate records (wgpu, naga, winit) are routed into tracing by + // `SubscriberInitExt::init` below, which installs a `LogTracer` itself. + // Installing one here as well makes that call fail with `SetLoggerError`, + // and `init` unwraps, so doing this "defensively" panics before the window + // ever opens. + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,wgpu=warn,naga=warn,bevy_render=info")); + let stderr = bevy::log::tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_target(false); + + let Some(options) = options()? else { + // `try_init` rather than `init`: losing the race for the global + // subscriber costs log formatting, and killing the process over log + // formatting is never the right trade. + if let Err(error) = bevy::log::tracing_subscriber::registry() + .with(filter) + .with(stderr) + .try_init() { - continue; - } - if frames.diagnostics.len() >= 2048 && !frames.diagnostics.contains_key(path) { - continue; - } - let next = frames.diagnostics.len(); - let entry = frames - .diagnostics - .entry(path.into()) - .or_insert((next, measurement.time)); - entry.1 = measurement.time; - // View names may originate in mods. Keep a stable numeric series ID and - // only allowlisted engine pass/measurement labels in the exported name. - if let Some(label) = render_label(path, entry.0) { - capture.counter(&label, json!({"value":measurement.value})); + eprintln!("SKATE_TRACE could not install the log subscriber: {error}"); } + return Ok(Guard { capture: None }); + }; + + let mut file = BufWriter::new( + File::create(&options.output) + .map_err(|e| format!("Could not create trace {:?}: {e}", options.output))?, + ); + write!(file, "[\n").map_err(|e| format!("Could not write trace header: {e}"))?; + + // Leaked deliberately: the layer and the Bevy systems both need a 'static + // borrow, and it lives until process exit regardless. + let capture: &'static Capture = Box::leak(Box::new(Capture { + started: Instant::now(), + recording: AtomicBool::new(!options.wait), + closed: AtomicBool::new(false), + events: Mutex::new(Some(file)), + written: AtomicU64::new(0), + })); + let _ = CAPTURE.set(capture); + + // Here a failure does matter: without this subscriber no spans reach the + // capture, so the trace would be an empty file rather than a bad one. + bevy::log::tracing_subscriber::registry() + .with(filter) + .with(stderr) + .with(ChromeLayer { capture }) + .try_init() + .map_err(|e| format!("Could not install the trace subscriber: {e}"))?; + + if options.gpu { + // RFC 3 lists GPU timestamps as an instrumentation gap. Refusing to + // pretend is better than emitting a trace with no GPU rows in it. + warn!("--trace-gpu is not wired yet; capturing CPU spans only"); } + if options.wait { + info!("SKATE_TRACE armed: F9 starts capture, F10 stops it"); + } else { + info!("SKATE_TRACE recording to {:?}", options.output); + } + if let Some(seconds) = options.seconds { + info!("SKATE_TRACE will stop automatically after {seconds}s of capture"); + } + STOP_AFTER + .set(options.seconds) + .map_err(|_| "trace options initialised twice".to_string())?; + Ok(Guard { capture: Some(capture) }) } -fn render_label(path: &str, id: usize) -> Option { - let field = path.rsplit('/').next()?; - if !matches!( - field, - "elapsed_cpu" - | "elapsed_gpu" - | "vertex_shader_invocations" - | "clipper_invocations" - | "clipper_primitives_out" - | "fragment_shader_invocations" - | "compute_shader_invocations" - ) { - return None; - } - let pass = path - .split('/') - .find(|s| { - matches!( - *s, - "main_opaque_pass_3d" - | "main_transparent_pass_3d" - | "main_transmissive_pass_3d" - | "early_mesh_preprocessing" - | "late_mesh_preprocessing" - | "retail_exposure_meter" - | "retail_exposed_tone" - | "prepass" - | "early_prepass" - | "late_prepass" - | "tonemapping" - | "upscaling" - | "depth_downsample" - | "ui" - ) - }) - .unwrap_or("other_pass"); - Some(format!("render/{id}/{pass}/{field}")) +static STOP_AFTER: OnceLock> = OnceLock::new(); + +/// Records the map identity into the trace so a capture is self-describing. +pub(crate) fn map_metadata(config: &crate::config::Config) { + let Some(capture) = CAPTURE.get() else { return }; + let name = config + .map_path + .as_deref() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "test-world".into()); + capture.metadata("map", &name); + capture.metadata("map_fingerprint", &format!("{:016x}", config.map_fingerprint)); + capture.metadata("difficulty", config.difficulty.key()); } -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn disabled_subscriber_rejects_spans() { - let logs = subscriber::fmt::layer() - .with_filter(subscriber::filter::FilterFn::new(|m| m.is_event())); - tracing::subscriber::with_default(subscriber::registry().with(logs), || { - assert!(tracing::info_span!("disabled_probe").is_disabled()); - }); +pub(crate) fn install(app: &mut App) { + if CAPTURE.get().is_none() { + return; } - #[test] - fn full_queue_drops_without_blocking() { - let (sender, _receiver) = sync_channel(1); - let capture = Capture { - origin: Instant::now(), - active: AtomicBool::new(true), - start: AtomicBool::new(false), - stop: AtomicBool::new(false), - dropped: AtomicU64::new(0), - sender, - gpu: false, - min_us: 25, - filtered: AtomicU64::new(0), - }; - for _ in 0..3 { - capture.counter("test", json!({"value":1})); + app.add_systems(Update, controls); +} + +fn controls(keys: Res>, time: Res>, mut started: Local>) { + let Some(capture) = CAPTURE.get() else { return }; + if keys.just_pressed(KeyCode::F9) && !capture.recording.load(Ordering::Relaxed) { + capture.recording.store(true, Ordering::SeqCst); + *started = Some(time.elapsed_secs()); + info!("SKATE_TRACE capture started"); + } + if keys.just_pressed(KeyCode::F10) && capture.recording.load(Ordering::Relaxed) { + capture.finish(); + info!("SKATE_TRACE capture stopped"); + } + if capture.recording.load(Ordering::Relaxed) { + let begin = started.get_or_insert(time.elapsed_secs()); + if let Some(limit) = STOP_AFTER.get().copied().flatten() { + if time.elapsed_secs() - *begin >= limit { + capture.finish(); + info!("SKATE_TRACE capture stopped after {limit}s"); + } } - assert_eq!(capture.dropped.load(Ordering::Relaxed), 2); - assert!(!capture.retain_duration(24)); - assert!(capture.retain_duration(25)); - assert_eq!(capture.filtered.load(Ordering::Relaxed), 1); - } - fn parse(args: &[&str]) -> Result { - Options::parse(args.iter().map(std::ffi::OsString::from)) - } - #[test] - fn bounds_and_dependencies() { - for args in [ - vec!["--trace-seconds", "0"], - vec!["--trace", "a", "--trace-seconds", "601"], - vec!["--trace", "a", "--trace-delay", "-1"], - vec!["--trace-gpu"], - vec!["--trace", "a", "--trace-wait", "--trace-delay", "1"], - vec!["--trace", "a", "--trace", "b"], - vec!["--trace", "a", "--trace-min-us", "1001"], - vec!["--trace-min-us", "25"], - ] { - assert!(parse(&args).is_err()); - } - let options = parse(&["--trace", "a", "--trace-seconds", "600", "--trace-wait"]).unwrap(); - assert!(options.wait); - assert_eq!(options.seconds, 600); - assert_eq!(options.min_us, 25); - assert!( - parse(&["--player-title", "--trace"]) - .unwrap() - .path - .is_none() - ); - } - #[test] - fn viewer_export_wait_stop_and_privacy() { - let path = - std::env::temp_dir().join(format!("skate-trace-test-{}.json", std::process::id())); - let _ = std::fs::remove_file(&path); - let guard = init_options(Options { - path: Some(path.clone()), - seconds: 1, - wait: true, - ..Options::default() - }) - .unwrap() - .unwrap(); - assert!(!guard.0.active.load(Ordering::Relaxed)); - guard.0.start.store(true, Ordering::Relaxed); - let deadline = Instant::now() + Duration::from_secs(2); - while !guard.0.active.load(Ordering::Relaxed) && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(5)); - } - assert!(guard.0.active.load(Ordering::Relaxed)); - // Headless ECS-only app proves real system labels survive release builds. - fn named_trace_test_system() {} - let mut app = App::new(); - app.add_systems(Update, named_trace_test_system); - app.update(); - tracing::info_span!( - "safe_test_span", - secret = "CANARY_SECRET", - path = "C:\\CANARY_PERSONAL\\asset" - ) - .in_scope(|| tracing::info!("CANARY_LOG")); - drop(guard); - let text = std::fs::read_to_string(&path).unwrap(); - let parsed: Value = serde_json::from_str(&text).unwrap(); - assert!( - parsed["traceEvents"] - .as_array() - .unwrap() - .iter() - .any(|e| e["name"] == "safe_test_span" && e["ph"] == "X") - ); - assert_eq!( - parsed["traceEvents"].as_array().unwrap().last().unwrap()["name"], - "capture_complete" - ); - assert!(!text.contains("CANARY")); - assert!(!text.contains("Enable the debug feature")); - assert!(parsed["traceEvents"].as_array().unwrap().iter().any(|e| { - e["cat"] == "system" - && e["name"] - .as_str() - .is_some_and(|n| n.ends_with("named_trace_test_system")) - })); - assert!( - parsed["traceEvents"] - .as_array() - .unwrap() - .iter() - .any(|e| e["cat"] == "schedule" && e["name"] == "Update") - ); - assert_eq!( - render_label("render/CANARY_SECRET/main_opaque_pass_3d/elapsed_gpu", 7).unwrap(), - "render/7/main_opaque_pass_3d/elapsed_gpu" - ); - std::fs::remove_file(path).unwrap(); } } diff --git a/crates/skate-game/src/render_capacity.rs b/crates/skate-game/src/render_capacity.rs index ee1143ed..eae098ee 100644 --- a/crates/skate-game/src/render_capacity.rs +++ b/crates/skate-game/src/render_capacity.rs @@ -1,77 +1,30 @@ -//! Keep instance-buffer identities stable as views discover more geometry. -use bevy::{ - pbr::{MeshInputUniform, MeshPipeline, MeshUniform}, - prelude::*, - render::{ - Render, RenderApp, RenderSystems, - batching::gpu_preprocessing::{BatchedInstanceBuffers, write_batched_instance_buffers}, - renderer::RenderDevice, - }, -}; +//! Render capacity policy. +//! +//! RFC 1 D7 drops MSAA entirely and removes the auto-downgrade ladder `main` +//! carried. That ladder existed because draw count was unbounded, so the +//! renderer had to guess when the scene was too heavy and quietly reduce +//! quality. With draw count now a chosen constant (RFC 1 §6) the guessing is +//! both unnecessary and harmful: a silent quality drop hides exactly the +//! regressions the performance contract is meant to catch. +//! +//! So this module enforces one policy and reports it, rather than adapting. +use bevy::prelude::*; pub(crate) struct RenderCapacityPlugin; impl Plugin for RenderCapacityPlugin { fn build(&self, app: &mut App) { - if let Some(render) = app.get_sub_app_mut(RenderApp) { - render.add_systems( - Render, - reserve_instances - .in_set(RenderSystems::PrepareResourcesFlush) - .before(write_batched_instance_buffers::), - ); - } + app.add_systems(Update, enforce_msaa_off); } } -fn capacity(required: usize, limit: usize) -> usize { - if required == 0 { - return 0; - } - required - .checked_next_power_of_two() - .unwrap_or(required) - .min(limit) - .max(required) -} - -fn reserve_instances( - device: Res, - buffers: Option>>, -) { - let Some(mut buffers) = buffers else { - return; - }; - let limits = device.limits(); - let maximum = limits - .max_buffer_size - .min(u64::from(limits.max_storage_buffer_binding_size)) - / std::mem::size_of::() as u64; - for phase in buffers.phase_instance_buffers.values_mut() { - let required = phase.data_buffer.len(); - if required != 0 { - // Bevy's subsequent write_buffer preserves the logical count and - // initializes only the actual instances through GPU preprocessing. - phase - .data_buffer - .reserve(capacity(required, maximum as usize), &device); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn growth_is_bounded_and_never_smaller_than_required() { - assert_eq!(capacity(0, 100), 0); - assert_eq!(capacity(33, 100), 64); - assert_eq!(capacity(64, 100), 64); - assert_eq!(capacity(65, 100), 100); - // Leave unsupported requests to Bevy's validation; never truncate data. - assert_eq!(capacity(101, 100), 101); - for n in 1..10000 { - assert!(capacity(n, 10000) >= n); - assert!(capacity(n, 10000) <= 10000); +/// Cameras get `Msaa` from Bevy's required components with a multisampled +/// default, so this corrects them rather than inserting. Runs every frame +/// because cameras are respawned across map transitions and by the debug camera. +fn enforce_msaa_off(mut cameras: Query<(Entity, &mut Msaa)>) { + for (entity, mut msaa) in &mut cameras { + if *msaa != Msaa::Off { + debug!("RENDER_CAPACITY forcing Msaa::Off on {entity} (was {msaa:?})"); + *msaa = Msaa::Off; } } } diff --git a/crates/skate-game/src/retail_backdrop.rs b/crates/skate-game/src/retail_backdrop.rs deleted file mode 100644 index a0dfe1d2..00000000 --- a/crates/skate-game/src/retail_backdrop.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Authored presentation supplements outside the static district geometry. -use bevy::prelude::*; -use skate_data::skate_map::SkateMap; -use std::path::Path; - -pub(crate) fn spawn_backdrop( - name: &str, - asset_root: &Path, - commands: &mut crate::map_render::SceneCommands, - meshes: &mut impl crate::map_render::AssetSink, - materials: &mut impl crate::map_render::AssetSink, - retail_materials: &mut impl crate::map_render::AssetSink, - images: &mut impl crate::map_render::AssetSink, -) { - for folder in ["native-backdrops", "native-props"] { - spawn_package(name, asset_root, folder, commands, meshes, materials, retail_materials, images); - } -} - -fn spawn_package( - name: &str, - asset_root: &Path, - folder: &str, - commands: &mut crate::map_render::SceneCommands, - meshes: &mut impl crate::map_render::AssetSink, - materials: &mut impl crate::map_render::AssetSink, - retail_materials: &mut impl crate::map_render::AssetSink, - images: &mut impl crate::map_render::AssetSink, -) { - let path = asset_root.join("private").join(folder).join(format!("{name}.skate")); - if !path.is_file() { - return; - } - let map = match std::fs::read(&path).map_err(|e| e.to_string()) - .and_then(|data| SkateMap::parse_render_only(&data)) { - Ok(map) => map, - Err(error) => { - error!("SKATE_BACKDROP: {}: {error}", path.display()); - return; - } - }; - // This package contributes presentation only; never route it into physics. - if map.name != name || !map.geometry.collision.is_empty() - || !map.lights.is_empty() || !map.doors.is_empty() || !map.rails.is_empty() - { - error!("SKATE_BACKDROP: invalid render-only package {}", path.display()); - return; - } - info!("SKATE_BACKDROP: {name} {folder} triangles={}", map.geometry.indices.len() / 3); - crate::skate_world::spawn(&map, commands, meshes, materials, retail_materials, images, &super::MaterialTuning::load(asset_root)); -} diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs index 75138477..75516c73 100644 --- a/crates/skate-game/src/retail_character.rs +++ b/crates/skate-game/src/retail_character.rs @@ -22,7 +22,11 @@ impl Plugin for CharacterLightingPlugin { app.add_plugins(MaterialPlugin::::default()) .add_systems(Startup, load) .add_systems(PreUpdate, load.after(crate::map_transition::MapTransitionSet).run_if(crate::retail_render::world_changed)) - .add_systems(Update, (bind, shadow_views, update).chain()); + .add_systems(Update, (bind, shadow_views).chain()) + .add_systems( + Update, + update.after(crate::multiplayer::RemoteRenderSet), + ); } } #[derive(Clone, Debug, Deserialize)] @@ -48,12 +52,18 @@ struct LightingData { native: HashMap>, } #[derive(Resource)] -struct Lighting { +pub(crate) struct Lighting { data: LightingData, probes: Irradiance, - light: Vec4, + pub(crate) light: Vec4, display_sh: Option<[Vec4; 9]>, } + +impl Lighting { + pub(crate) fn default_sh(&self) -> [Vec4; 9] { + std::array::from_fn(|i| Vec3::from_array(self.data.default_sh[i]).extend(0.)) + } +} #[derive(Clone, Debug, Default, ShaderType)] pub(crate) struct CharacterParams { pub light: Vec4, @@ -101,7 +111,7 @@ struct OriginalCharacterMaterial { material: Handle, layers: Option, } -fn load(mut commands: Commands, config: Res, sources: Query>, mut shadow: ResMut, +fn load(mut commands: Commands, config: Res, sources: Query>, mut shadow: ResMut, retail: Res, originals: Query<(Entity, &OriginalCharacterMaterial)>, cameras: Query<(Entity, &RenderLayers), With>, @@ -109,17 +119,22 @@ fn load(mut commands: Commands, config: Res, sources: Que ) { for (_, material) in customiser.iter_mut() { material.extension.retail.light = Vec4::ZERO; } commands.remove_resource::(); - for e in &sources { commands.entity(e).despawn(); } - *shadow = Default::default(); + for e in &sources { commands.entity(e).try_despawn(); } + // Only the floor: the clock and ocean PCA in this resource are rewritten + // every frame and are not this system's to reset. + shadow.shadow = Vec4::ZERO; + // A map change runs this alongside the transition that despawns the skater it + // is restoring, and the despawn lands first. Restoring a material on an + // entity that is already gone is the expected case, not an error. for (entity, original) in &originals { let mut entity = commands.entity(entity); - entity.remove::<(MeshMaterial3d, OriginalCharacterMaterial)>() - .insert(MeshMaterial3d(original.material.clone())); - if let Some(layers) = &original.layers { entity.insert(layers.clone()); } - else { entity.remove::(); } + entity.try_remove::<(MeshMaterial3d, OriginalCharacterMaterial)>() + .try_insert(MeshMaterial3d(original.material.clone())); + if let Some(layers) = &original.layers { entity.try_insert(layers.clone()); } + else { entity.try_remove::(); } } for (entity, layers) in &cameras { - commands.entity(entity).insert(layers.clone().without(28)); + commands.entity(entity).try_insert(layers.clone().without(28)); } if !retail.0 { return; } let Some(map_path) = &config.map_path else { @@ -330,10 +345,12 @@ fn bind( }), alpha: m.alpha_mode, }); + // Skater meshes come and go with the outfit and the map, so binding one + // that has since been despawned is routine rather than a fault. commands .entity(entity) - .remove::>() - .insert(( + .try_remove::>() + .try_insert(( OriginalCharacterMaterial { material: handle.0.clone(), layers: layers.cloned() }, MeshMaterial3d(material), RenderLayers::from_layers(&[0, 28]), @@ -365,7 +382,7 @@ fn update( parents: Query<&ChildOf>, mut materials: ResMut>, mut customiser: ResMut>, - mut shadow: ResMut, + mut shadow: ResMut, time: Res