Share map render resources and release uploaded CPU data

This commit is contained in:
chazmm committed 2026-09-06 15:22:58 +01:00
1 parent 5159538b85
commit acbaa9ccda
7 files changed
+384 -56

No files matched your search

+132 -18
View File
@@ -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<BTreeMap<&'static str, (f64, u64)>> = Mutex::new(BTreeMap::new());
pub(crate) struct Scope {
name: &'static str,
start: Option<Instant>,
}
impl Scope {
pub(crate) fn new(name: &'static str) -> Self {
static ENABLED: OnceLock<bool> = 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<Mutex<Vec<[f64; 2]>>>,
render: Arc<Mutex<Vec<[f64; 5]>>>,
}
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<Performance>) {
@@ -49,21 +119,32 @@ fn begin(mut p: ResMut<Performance>) {
}
fn finish(mut p: ResMut<Performance>, mut exit: MessageWriter<AppExit>) {
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::<f64>() / p.samples.len() as f64;
let mean = |column: usize| {
p.samples.iter().map(|s| s[column]).sum::<f64>() / 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::<f64>() / render.len().max(1) as f64;
let render_mean = |column: usize| {
render.iter().map(|s| s[column]).sum::<f64>() / 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<Performance>, mut exit: MessageWriter<AppExit>) {
"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::<BTreeMap<_,_>>(),
"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<Instant>, frame: Instant, prepared: Instant,
samples: Arc<Mutex<Vec<[f64; 2]>>>,
start: Option<Instant>,
frame: Instant,
prepared: Instant,
mark: Instant,
phases: [f64; 3],
samples: Arc<Mutex<Vec<[f64; 5]>>>,
}
fn render_begin(mut p: ResMut<RenderPerformance>) {
p.frame = Instant::now();
p.mark = p.frame;
}
fn render_assets_done(mut p: ResMut<RenderPerformance>) {
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<RenderPerformance>) {
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<RenderPerformance>) {
p.phases[2] = p.mark.elapsed().as_secs_f64() * 1000.;
}
fn render_prepared(mut p: ResMut<RenderPerformance>) {
p.prepared = Instant::now();
}
fn render_finish(mut p: ResMut<RenderPerformance>) {
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],
]);
}
}
+9
View File
@@ -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);
+4
View File
@@ -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,
+172 -34
View File
@@ -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<u32> {
use std::hash::{Hash, Hasher};
let mut buckets: HashMap<(u32, u32, u32, u64), Vec<usize>> = 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<usize> {
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<u32>)> {
let mut lookup = HashMap::new();
let mut groups: Vec<(usize, Vec<u32>)> = 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<Image>,
) {
// 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<Image>>::new();
let mut texture = |id: u32, role: u8| -> Option<Handle<Image>> {
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::<u32>::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<Option<Handle<StandardMaterial>>> =
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<u32> = 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.,
+5 -3
View File
@@ -24,7 +24,7 @@ fn spawn(
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut images: ResMut<Assets<Image>>,
config: Res<crate::config::Config>,
mut config: ResMut<crate::config::Config>,
) {
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 = [