diff --git a/Cargo.toml b/Cargo.toml index b60f3173..987f0aa6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ skate-core = { path = "crates/skate-core" } skate-data = { path = "crates/skate-data" } [profile.dev] -opt-level = 1 +opt-level = 3 [profile.dev.package."*"] opt-level = 3 diff --git a/crates/skate-game/src/performance.rs b/crates/skate-game/src/performance.rs index bbd23e1e..0d0e29ec 100644 --- a/crates/skate-game/src/performance.rs +++ b/crates/skate-game/src/performance.rs @@ -1,8 +1,36 @@ //! Opt-in repeatable frame/physics timing: SKATE_PERF_REPORT=path.json. use bevy::prelude::*; -use std::{path::PathBuf, time::Instant}; -use std::sync::{Arc, Mutex}; use bevy::render::{Render, RenderApp, RenderSystems}; +use std::sync::{Arc, Mutex}; +use std::{collections::BTreeMap, sync::OnceLock}; +use std::{path::PathBuf, time::Instant}; + +static SECTIONS: Mutex> = Mutex::new(BTreeMap::new()); +pub(crate) struct Scope { + name: &'static str, + start: Option, +} +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), + } + } +} +impl Drop for Scope { + fn drop(&mut self) { + if let Some(start) = self.start { + let mut sections = SECTIONS.lock().unwrap(); + let entry = sections.entry(self.name).or_default(); + entry.0 += start.elapsed().as_secs_f64() * 1000.; + entry.1 += 1; + } + } +} #[derive(Resource)] pub(crate) struct Performance { @@ -13,7 +41,7 @@ pub(crate) struct Performance { physics_ms: f64, ticks: u32, samples: Vec<[f64; 4]>, - render: Arc>>, + render: Arc>>, } impl Performance { pub(crate) fn physics(&mut self, elapsed: std::time::Duration) { @@ -25,21 +53,63 @@ impl Performance { 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 { return; }; + let Some(path) = std::env::var_os("SKATE_PERF_REPORT") else { + return; + }; let now = Instant::now(); let render = Arc::new(Mutex::new(Vec::new())); if let Some(render_app) = app.get_sub_app_mut(RenderApp) { - render_app.insert_resource(RenderPerformance { start: None, frame: now, prepared: now, samples: render.clone() }) + 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_prepared.after(RenderSystems::Prepare).before(RenderSystems::Render)) + .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)); } // Benchmark measurements must not depend on whether the window has focus. app.insert_resource(bevy::winit::WinitSettings::continuous()); app.insert_resource(Performance { - path: path.into(), start: None, frame_start: now, previous: now, - physics_ms: 0., ticks: 0, samples: Vec::new(), render, - }).add_systems(First, begin).add_systems(Last, finish); + path: path.into(), + start: None, + frame_start: now, + previous: now, + physics_ms: 0., + ticks: 0, + samples: Vec::new(), + render, + }) + .add_systems(First, begin) + .add_systems(Last, finish); } } fn begin(mut p: ResMut) { @@ -49,21 +119,32 @@ fn begin(mut p: ResMut) { } 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 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)]; + let sample = [ + frame, + now.duration_since(p.frame_start).as_secs_f64() * 1000., + p.physics_ms, + f64::from(p.ticks), + ]; p.samples.push(sample); } 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 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 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], @@ -72,32 +153,65 @@ fn finish(mut p: ResMut, mut exit: MessageWriter) { "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, }); 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()); } + Ok(()) => { + eprintln!("SKATE_PERF_REPORT {}", p.path.display()); + exit.write(AppExit::Success); + } + Err(e) => { + eprintln!("Performance report: {e}"); + exit.write(AppExit::error()); + } } } } #[derive(Resource)] struct RenderPerformance { - start: Option, frame: Instant, prepared: Instant, - samples: Arc>>, + start: Option, + frame: Instant, + prepared: Instant, + mark: Instant, + phases: [f64; 3], + samples: Arc>>, } 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) { let now = Instant::now(); - if now.duration_since(*p.start.get_or_insert(now)).as_secs_f64() > 10. { + 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], ]); } } diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index 14d648cb..6c547855 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -29,12 +29,15 @@ pub(super) fn advance( //input reset82DB8998, which would immediately clear that mode again. } physics.board.clear_forces(); + let query_timer = crate::performance::Scope::new("wheel_and_foot_queries"); //World8275EA20 starts both batches before actor SetUpPhysics. Foot //results stay pending while PlayerInput consumes the preceding records. physics .riding .start_wheel_queries(&physics.board, &physics.world)?; let skeleton_queries = super::foot_ik_queries::query(&physics.world, &skater.skeleton)?; + drop(query_timer); + let graph_timer = crate::performance::Scope::new("animation_graph"); let animation = animation_phase::advance( physics, skater, @@ -43,6 +46,8 @@ pub(super) fn advance( &physics.animation_profile, ) .map_err(|e| format!("Animation tick{}: {e}", physics.ticks))?; + drop(graph_timer); + let input_timer = crate::performance::Scope::new("input_and_state"); //ForcePhysics Begin82BB2868 writes the live Skeleton16420 mode once. //Consume the graph request so a later physical reset can retain its own0. if let Some(mode) = skater.animation.motion.riding.force_mode.take() { @@ -118,7 +123,11 @@ pub(super) fn advance( //World8275ECA4 ends skeleton tests after state/forces and before solving. //Teleport resets previous observations, but preserves this pending batch. skeleton_queries.publish(&mut skater.player_input.player); + drop(input_timer); + let solve_timer = crate::performance::Scope::new("solve_total"); solve::advance(physics, skater, skater.ground.steering.targets)?; + drop(solve_timer); + let _output_timer = crate::performance::Scope::new("outputs_and_camera"); //ProcessOutput82DB6EE8 resets the packet before its component publishers. //All consumers of the preceding output have completed this frame's input. super::player_input::reset_outputs(&mut skater.player_input.physical); diff --git a/crates/skate-game/src/physics/solve.rs b/crates/skate-game/src/physics/solve.rs index a3b3a130..cbe86f04 100644 --- a/crates/skate-game/src/physics/solve.rs +++ b/crates/skate-game/src/physics/solve.rs @@ -21,10 +21,13 @@ pub(super) fn advance( // Skeleton82BE5094 passes false to82768728: its edge threshold is -1, // whereas the board requests .999. GroundPipeline supplies the remaining // shared values. Do not let the second query overwrite the first's rows. + let board_timer = crate::performance::Scope::new("board_world_contacts"); let mut contacts = physics .world .query_primitives(&board_volumes, physics.query, physics.retention) .to_vec(); + drop(board_timer); + let skeleton_timer = crate::performance::Scope::new("skeleton_world_contacts"); let mut skeleton_query = physics.query; skeleton_query.edge_cos_bend_normal_threshold = -1.0; contacts.extend_from_slice(physics.world.query_primitives( @@ -32,6 +35,7 @@ pub(super) fn advance( skeleton_query, physics.retention, )); + drop(skeleton_timer); assembly_contacts::append( &mut contacts, &board_volumes, diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index b5322d6f..eba147d3 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -341,6 +341,77 @@ pub(crate) fn collision_world( BoardWorld::with_query_metadata(triangles, metadata).map_err(str::to_owned) } +/// Reuse identical render resources without changing authored triangles. +fn render_texture_ids(textures: &[skate_data::skate_map::Texture]) -> Vec { + use std::hash::{Hash, Hasher}; + let mut buckets: HashMap<(u32, u32, u32, u64), Vec> = HashMap::new(); + let mut ids = vec![0]; + for (i, t) in textures.iter().enumerate() { + let mut hash = std::collections::hash_map::DefaultHasher::new(); + t.rgba.hash(&mut hash); + let bucket = buckets + .entry((t.width, t.height, t.color_space, hash.finish())) + .or_default(); + let same = bucket.iter().copied().find(|&j| textures[j].rgba == t.rgba); + let canonical = same.unwrap_or_else(|| { + bucket.push(i); + i + }); + ids.push(canonical as u32 + 1); + } + ids +} + +fn render_material_ids( + materials: &[skate_data::skate_map::Material], + texture_ids: &[u32], +) -> Vec { + let mut unique = HashMap::new(); + materials + .iter() + .enumerate() + .map(|(i, m)| { + // Only fields consumed by this renderer belong in the identity. Source + // names and collision surface metadata do not change a PBR material. + let textures = m.textures.map(|id| texture_ids[id as usize]); + let key = [ + textures[0], + textures[1], + textures[2], + textures[3], + textures[4], + m.color[0].to_bits(), + m.color[1].to_bits(), + m.color[2].to_bits(), + m.roughness.to_bits(), + m.emissive.to_bits(), + m.indirect_strength.to_bits(), + m.alpha_mode, + m.alpha_cutoff.to_bits(), + ]; + *unique.entry(key).or_insert(i) + }) + .collect() +} + +fn render_groups( + geometry: &skate_data::skate_map::Geometry, + material_ids: &[usize], +) -> Vec<(usize, Vec)> { + let mut lookup = HashMap::new(); + let mut groups: Vec<(usize, Vec)> = Vec::new(); + for tri in geometry.indices.chunks_exact(3) { + let material = material_ids[geometry.vertices[tri[0] as usize].material as usize - 1]; + let group = *lookup.entry(material).or_insert_with(|| { + let index = groups.len(); + groups.push((material, Vec::new())); + index + }); + groups[group].1.extend_from_slice(tri); + } + groups +} + pub(crate) fn spawn( map: &SkateMap, commands: &mut Commands, @@ -349,8 +420,10 @@ pub(crate) fn spawn( images: &mut Assets, ) { // Texture roles have different transfer functions even when sharing a record. + let texture_ids = render_texture_ids(&map.textures); let mut cache = HashMap::<(u32, u8), Handle>::new(); let mut texture = |id: u32, role: u8| -> Option> { + let id = texture_ids[id as usize]; if id == 0 { return None; } @@ -390,7 +463,7 @@ pub(crate) fn spawn( TextureDimension::D2, bytes, format, - RenderAssetUsages::default(), + RenderAssetUsages::RENDER_WORLD, ); let mut sampler = bevy::image::ImageSamplerDescriptor::linear(); if role != 1 { @@ -403,15 +476,18 @@ pub(crate) fn spawn( .clone(), ) }; - let mut groups = vec![Vec::::new(); map.materials.len()]; - for tri in map.geometry.indices.chunks_exact(3) { - groups[map.geometry.vertices[tri[0] as usize].material as usize - 1].extend_from_slice(tri); - } - for (m, indices) in map.materials.iter().zip(groups) { - if indices.is_empty() { - continue; - } - // Reindex each material once, preserving authored normals and both UV sets. + let material_ids = render_material_ids(&map.materials, &texture_ids); + let groups = render_groups(&map.geometry, &material_ids); + eprintln!( + "SKATE_RENDER_BATCHES count={} triangles={}", + groups.len(), + map.geometry.indices.len() / 3 + ); + let mut material_handles: Vec>> = + vec![None; map.materials.len()]; + for (material_index, indices) in groups { + let m = &map.materials[material_index]; + // Reindex each batch, preserving authored normals and both UV sets. let mut remap = HashMap::new(); let mut vertices = Vec::new(); let local: Vec = indices @@ -426,7 +502,7 @@ pub(crate) fn spawn( .collect(); let mut mesh = Mesh::new( bevy::mesh::PrimitiveTopology::TriangleList, - RenderAssetUsages::default(), + RenderAssetUsages::RENDER_WORLD, ) .with_inserted_attribute( Mesh::ATTRIBUTE_POSITION, @@ -464,29 +540,33 @@ pub(crate) fn spawn( warn!("SKATE material {} tangent generation: {error}", m.name); } } - let orm = texture(m.textures[3], 2); - let material = materials.add(StandardMaterial { - base_color: Color::linear_rgb(m.color[0], m.color[1], m.color[2]), - base_color_texture: texture(m.textures[0], 0), - normal_map_texture: texture(m.textures[2], 2), - metallic_roughness_texture: orm.clone(), - occlusion_texture: orm, - metallic: if m.textures[3] != 0 { 1. } else { 0. }, - perceptual_roughness: m.roughness, - emissive: LinearRgba::rgb( - m.color[0] * m.emissive, - m.color[1] * m.emissive, - m.color[2] * m.emissive, - ), - emissive_texture: texture(m.textures[4], 0), - alpha_mode: match m.alpha_mode { - 1 => AlphaMode::Mask(m.alpha_cutoff), - 2 => AlphaMode::Blend, - _ => AlphaMode::Opaque, - }, - lightmap_exposure: m.indirect_strength, - ..default() - }); + let material = material_handles[material_index] + .get_or_insert_with(|| { + let orm = texture(m.textures[3], 2); + materials.add(StandardMaterial { + base_color: Color::linear_rgb(m.color[0], m.color[1], m.color[2]), + base_color_texture: texture(m.textures[0], 0), + normal_map_texture: texture(m.textures[2], 2), + metallic_roughness_texture: orm.clone(), + occlusion_texture: orm, + metallic: if m.textures[3] != 0 { 1. } else { 0. }, + perceptual_roughness: m.roughness, + emissive: LinearRgba::rgb( + m.color[0] * m.emissive, + m.color[1] * m.emissive, + m.color[2] * m.emissive, + ), + emissive_texture: texture(m.textures[4], 0), + alpha_mode: match m.alpha_mode { + 1 => AlphaMode::Mask(m.alpha_cutoff), + 2 => AlphaMode::Blend, + _ => AlphaMode::Opaque, + }, + lightmap_exposure: m.indirect_strength, + ..default() + }) + }) + .clone(); let mut entity = commands.spawn(( Name::new(m.name.clone()), Mesh3d(meshes.add(mesh)), @@ -563,6 +643,64 @@ mod tests { fn demo() -> SkateMap { SkateMap::parse(include_bytes!("../../../maps/format-demo.skate")).unwrap() } + #[test] + fn render_batches_preserve_complete_triangles_and_materials() { + let map = demo(); + let material_ids = render_material_ids(&map.materials, &render_texture_ids(&map.textures)); + let chunks = render_groups(&map.geometry, &material_ids); + assert_eq!(chunks.len(), 1); + let mut original: Vec<_> = map + .geometry + .indices + .chunks_exact(3) + .map(|t| t.to_vec()) + .collect(); + let mut partitioned = Vec::new(); + for (material, indices) in chunks { + for tri in indices.chunks_exact(3) { + assert_eq!( + material + 1, + map.geometry.vertices[tri[0] as usize].material as usize + ); + partitioned.push(tri.to_vec()); + } + } + original.sort(); + partitioned.sort(); + assert_eq!(partitioned, original); + } + #[test] + fn render_material_sharing_requires_identical_rendered_fields() { + let mut map = demo(); + let mut copy = demo().materials.remove(0); + copy.name = "another source object".into(); + copy.audio = 42; + map.materials.push(copy); + let texture_ids = render_texture_ids(&map.textures); + assert_eq!(render_material_ids(&map.materials, &texture_ids), [0, 0]); + map.materials[1].roughness = 0.1234; + assert_eq!(render_material_ids(&map.materials, &texture_ids), [0, 1]); + map.materials[1].roughness = map.materials[0].roughness; + map.materials[1].textures[1] = 0; + assert_eq!(render_material_ids(&map.materials, &texture_ids), [0, 1]); + } + #[test] + fn texture_sharing_preserves_pixels_dimensions_and_color_space() { + let texture = |name: &str, color_space, pixel| skate_data::skate_map::Texture { + name: name.into(), + width: 1, + height: 1, + color_space, + rgba: vec![pixel, 0, 0, 255], + }; + let textures = vec![ + texture("first", 1, 30), + texture("duplicate", 1, 30), + texture("linear", 0, 30), + texture("different", 1, 31), + ]; + assert_eq!(render_texture_ids(&textures), [0, 1, 1, 3, 4]); + } fn material() -> RetailContactMaterial { RetailContactMaterial { static_friction: 0., diff --git a/crates/skate-game/src/world.rs b/crates/skate-game/src/world.rs index ec4a1b4e..de768490 100644 --- a/crates/skate-game/src/world.rs +++ b/crates/skate-game/src/world.rs @@ -24,7 +24,7 @@ fn spawn( mut meshes: ResMut>, mut materials: ResMut>, mut images: ResMut>, - config: Res, + mut config: ResMut, ) { commands .spawn((PlayerRoot, Transform::default(), Visibility::default())) @@ -33,8 +33,10 @@ fn spawn( GltfAssetLabel::Scene(0).from_asset(manifest.0.character_scene.clone()), ))); }); - if let Some(map) = &config.map { - crate::skate_world::spawn(map, &mut commands, &mut meshes, &mut materials, &mut images); + if let Some(map) = config.map.take() { + // Physics has already consumed the package. Render assets own their + // uploaded data; keeping another full city package wastes gigabytes. + crate::skate_world::spawn(&map, &mut commands, &mut meshes, &mut materials, &mut images); return; } let colors = [ diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 00000000..c5e32f77 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,61 @@ +# Large map performance + +The September 6, 2026 performance pass preserves the full render geometry and +collision data. It does not add LODs or simplify physics. + +Changes: + +- A bounds hierarchy selects collision clusters instead of scanning all clusters. + Results retain their original order, including equal-distance hits. +- Conservative triangle bounds reject impossible line and primitive contacts + before the expensive narrowphase. Predictive separation and fatness are included. +- Byte-identical textures and equivalent rendered materials share resources and + batches. Texture color space and all currently rendered material fields remain + part of their identity. Every source render triangle is retained. +- Render meshes and textures release their CPU copies after GPU upload. The + decoded map package is released after physics and rendering have consumed it. +- The normal development build now optimizes project code at level 3, matching + the existing dependency optimization level. + +## Repeatable measurement + +From the project directory in PowerShell: + +```powershell +./Build.ps1 +$env:SKATE_PERF_REPORT = 'logs/performance.json' +./bin/skate-game.exe --assets assets --map maps/Skate_2_New_San_Vanelona.skate +Remove-Item Env:SKATE_PERF_REPORT +``` + +The benchmark runs continuously regardless of window focus, waits 10 seconds +for warmup, records roughly 15 seconds, writes JSON, and exits. Leave the player +stationary for spawn comparisons. The environment variable enables instrumentation; +normal launches do not collect reports or change their window-focus behavior. +Frame intervals include renderer synchronization. Main schedule and render timings +overlap and must not be added together. Detailed physics section timings include +warmup and nested scopes; these must not be summed either. + +Local San Vanelona measurements at 1280 x 800, stationary spawn, RTX 5090: + +| Metric | Before | After | +| --- | ---: | ---: | +| Average FPS | 43.6 | 50.7 | +| Main schedule per frame | 10.56 ms | 2.39 ms | +| Physics per tick | 6.44 ms | 0.74 ms | +| Frame interval p95 | 128.1 ms | 94.5 ms | + +Sources are local ignored reports `logs/perf-baseline-continuous.json` and +`logs/perf-render-detail.json`. Repeated optimized runs averaged 49–52 FPS. +Process working set fell from about 10 GB to roughly 1–2.3 GB after loading. +These are spawn measurements, not a reproduction of the reported 10 FPS camera +position. Render preparation remains the largest measured cost, and frame spikes +remain. Low total CPU/GPU utilization does not exclude a CPU render bottleneck. + +Spatial render splitting was also tested, but increased entity/batch overhead and +reduced FPS on this map, so it is not included. + +Validation includes the full skate-core suite and game suite (43 passed, 21 existing +ignored tests), differential collision queries against the unfiltered implementation +across 420 varied poses, material/texture identity checks, and exact render triangle +retention tests. Startup capture checks rendering/integration, not retail parity.