From 1c62e56fd62f5ffe7f4eee21d3b272db00d88c16 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 19:59:49 +0100 Subject: [PATCH] Reduce material duplication, index static shadow casters and cache controller metadata --- crates/skate-game/src/input.rs | 5 +- crates/skate-game/src/input/platform.rs | 100 +++++- crates/skate-game/src/retail_render.rs | 5 + crates/skate-game/src/retail_shader_tests.rs | 81 ++++- .../src/retail_shadow_visibility.rs | 331 ++++++++++++++++++ .../src/retail_shadow_visibility_tests.rs | 251 +++++++++++++ crates/skate-game/src/skate_world.rs | 10 +- docs/cpu-followup-optimizations.md | 76 ++++ vendor/README.md | 9 + vendor/bevy_pbr/src/material_bind_groups.rs | 27 +- 10 files changed, 860 insertions(+), 35 deletions(-) create mode 100644 crates/skate-game/src/retail_shadow_visibility.rs create mode 100644 crates/skate-game/src/retail_shadow_visibility_tests.rs create mode 100644 docs/cpu-followup-optimizations.md diff --git a/crates/skate-game/src/input.rs b/crates/skate-game/src/input.rs index 951bbe9c..4539f222 100644 --- a/crates/skate-game/src/input.rs +++ b/crates/skate-game/src/input.rs @@ -34,14 +34,15 @@ impl Plugin for InputPlugin { } } -pub(crate) fn poll_controllers(mut input: ResMut,config:Res,net:Option>,windows:Query<&Window>) { +pub(crate) fn poll_controllers(mut input: ResMut,config:Res,net:Option>,windows:Query<&Window>,mut capabilities:Local<[platform::CapabilityCache;4]>) { let previous = input.status; let focused=windows.iter().any(|w|w.focused); let active=net.is_some_and(|n|n.active()); input.collect(std::array::from_fn(|slot| { if active && ((!focused && config.multiplayer.controller.is_none()) || config.multiplayer.controller.is_some_and(|selected|selected as usize!=slot)) { + capabilities[slot].invalidate(); Err(platform::DeviceError::Disconnected) - } else {platform::poll(slot)} + } else {platform::poll_cached(slot, &mut capabilities[slot])} })); for (index, (&before, &after)) in previous.iter().zip(&input.status).enumerate() { if before != after { diff --git a/crates/skate-game/src/input/platform.rs b/crates/skate-game/src/input/platform.rs index b161f7c7..99ad31d0 100644 --- a/crates/skate-game/src/input/platform.rs +++ b/crates/skate-game/src/input/platform.rs @@ -17,6 +17,34 @@ pub(crate) enum DeviceError { UnsupportedPlatform, } +/// Device identity is metadata; raw input is still sampled every host frame. +/// Refresh periodically as well as after errors, so hot swaps cannot leave a +/// subtype cached indefinitely even if Windows never exposes a disconnect. +#[derive(Default)] +pub(crate) struct CapabilityCache { + value: Option<(u8, std::time::Instant)>, +} +impl CapabilityCache { + pub(crate) fn invalidate(&mut self) { + self.value = None; + } + fn get( + &mut self, + now: std::time::Instant, + read: impl FnOnce() -> Result, + ) -> Result { + if let Some((subtype, expires)) = self.value { + if now < expires { + return Ok(subtype); + } + } + self.value = None; + let subtype = read()?; + self.value = Some((subtype, now + std::time::Duration::from_secs(1))); + Ok(subtype) + } +} + #[cfg(windows)] mod windows { use super::*; @@ -62,26 +90,33 @@ mod windows { fn XInputGetCapabilities(index: u32, flags: u32, capabilities: *mut Capabilities) -> u32; } - pub(super) fn poll(index: u32) -> Result { + pub(super) fn poll( + index: u32, + cache: &mut CapabilityCache, + ) -> Result { let mut state = MaybeUninit::::uninit(); // SAFETY: properly aligned writable storage with the SDK's exact C ABI. let result = unsafe { XInputGetState(index, state.as_mut_ptr()) }; + if result != 0 { + cache.invalidate(); + } if result == 1167 { return Err(DeviceError::Disconnected); } if result != 0 { return Err(DeviceError::State(result)); } - let mut capabilities = MaybeUninit::::uninit(); - // TU3 8296D480 obtains subtype with capability flags=1. A capability - // failure stays explicit; it must not invent the converter's byte 13. - // SAFETY: same output-storage contract as above. - let result = unsafe { XInputGetCapabilities(index, 1, capabilities.as_mut_ptr()) }; - if result != 0 { - return Err(DeviceError::Capabilities(result)); - } - // SAFETY: both successful SDK calls initialized their complete structs. - let (state, capabilities) = unsafe { (state.assume_init(), capabilities.assume_init()) }; + let subtype = cache.get(std::time::Instant::now(), || { + let mut capabilities = MaybeUninit::::uninit(); + // SAFETY: writable storage with the SDK ABI; read only on success. + let result = unsafe { XInputGetCapabilities(index, 1, capabilities.as_mut_ptr()) }; + if result != 0 { + return Err(DeviceError::Capabilities(result)); + } + Ok(unsafe { capabilities.assume_init() }.subtype) + })?; + // SAFETY: successful XInputGetState initialized the complete structure. + let state = unsafe { state.assume_init() }; Ok(DevicePacket { number: state.number, state: XboxState { @@ -90,15 +125,52 @@ mod windows { left: [state.gamepad.left_x, state.gamepad.left_y], right: [state.gamepad.right_x, state.gamepad.right_y], }, - subtype: capabilities.subtype, + subtype, }) } } -pub(crate) fn poll(index: usize) -> Result { +pub(crate) fn poll_cached( + index: usize, + cache: &mut CapabilityCache, +) -> Result { assert!(index < 4); #[cfg(windows)] - return windows::poll(index as u32); + return windows::poll(index as u32, cache); #[cfg(not(windows))] Err(DeviceError::UnsupportedPlatform) } + +#[cfg(test)] +mod cache_tests { + use super::*; + #[test] + fn capability_cache_refreshes_and_never_caches_errors() { + let start = std::time::Instant::now(); + let mut cache = CapabilityCache::default(); + assert_eq!(cache.get(start, || Ok(1)), Ok(1)); + assert_eq!( + cache.get(start + std::time::Duration::from_millis(999), || panic!( + "redundant capability query" + )), + Ok(1) + ); + assert_eq!( + cache.get(start + std::time::Duration::from_secs(1), || Ok(2)), + Ok(2) + ); + cache.invalidate(); + assert_eq!( + cache.get(start, || Err(DeviceError::Capabilities(5))), + Err(DeviceError::Capabilities(5)) + ); + assert_eq!(cache.get(start, || Ok(3)), Ok(3)); + cache.invalidate(); + assert_eq!(cache.get(start, || Ok(4)), Ok(4)); + } +} + +// Preserve the uncached API for menu-only polling. +pub(crate) fn poll(index: usize) -> Result { + poll_cached(index, &mut CapabilityCache::default()) +} diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index b54bd74c..d263c9ea 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -22,6 +22,7 @@ fn retail_shader(path: &str) -> ShaderRef { impl Plugin for RetailRenderPlugin { fn build(&self, app: &mut App) { shadow::install(app); + shadow_visibility::install(app); exposure::install(app); if std::env::var_os("SKATE_WEATHERING_COMPARE").is_some_and(|v| v == "1") { app.add_systems(Update, compare_weathering); @@ -40,8 +41,12 @@ impl Plugin for RetailRenderPlugin { MaterialPlugin::::default(), )); } + fn finish(&self, app: &mut App) { shadow_visibility::finish(app); } } +#[path = "retail_shadow_visibility.rs"] +pub(crate) mod shadow_visibility; + // Diagnostic only: mutate materials once per keypress, never continuously. // Separate bits preserve the original texture-presence flags for restoration. fn compare_weathering( diff --git a/crates/skate-game/src/retail_shader_tests.rs b/crates/skate-game/src/retail_shader_tests.rs index dd390393..ff467b73 100644 --- a/crates/skate-game/src/retail_shader_tests.rs +++ b/crates/skate-game/src/retail_shader_tests.rs @@ -215,6 +215,7 @@ fn gpu_probe(prepass: bool) { }) .await .unwrap(); + allocation_reuses_resident_textures(&device); let module = validate( std::env::var_os("SKATE_SHADER_PROBE_FALLBACK").is_none(), prepass, @@ -316,11 +317,7 @@ struct Out { @builtin(position) position:vec4, @location(0) world:vec4 module: &fragment, entry_point: Some("fragment"), compilation_options: Default::default(), - targets: if prepass { - &[] - } else { - &color_targets - }, + targets: if prepass { &[] } else { &color_targets }, }), multiview: None, cache: None, @@ -582,3 +579,77 @@ struct Out { @builtin(position) position:vec4, @location(0) world:vec4 } }); } + +// Exercise the real allocator against GPU resource identities and retirement. +fn allocation_reuses_resident_textures(device: &wgpu::Device) { + use bevy::render::render_resource::{ + BindGroupLayoutDescriptor, BindingNumber, BindingResources, BindlessDescriptor, + BindlessIndex, BindlessIndexTableDescriptor, BindlessResourceType, + BindlessSlabResourceLimit, OwnedBindingResource, UnpreparedBindGroup, + }; + let render_device = bevy::render::renderer::RenderDevice::from(device.clone()); + let layout = BindGroupLayoutDescriptor::new("allocation regression", &[]); + let descriptor = BindlessDescriptor { + resources: vec![BindlessResourceType::Texture2d; 2].into(), + buffers: vec![].into(), + index_tables: vec![BindlessIndexTableDescriptor { + indices: BindlessIndex(0)..BindlessIndex(2), + binding_number: BindingNumber(0), + }] + .into(), + }; + let mut allocator = bevy::pbr::MaterialBindGroupAllocator::new( + &render_device, + "allocation regression", + Some(descriptor), + layout.clone(), + Some(BindlessSlabResourceLimit::Custom(2)), + ); + let views: Vec = (0..4) + .map(|_| { + device + .create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&Default::default()) + .into() + }) + .collect(); + let group = |a: usize, b: usize| UnpreparedBindGroup { + bindings: BindingResources(vec![ + ( + 0, + OwnedBindingResource::TextureView(wgpu::TextureViewDimension::D2, views[a].clone()), + ), + ( + 1, + OwnedBindingResource::TextureView(wgpu::TextureViewDimension::D2, views[b].clone()), + ), + ]), + }; + let first = allocator.allocate_unprepared(group(0, 1), &layout); + let second = allocator.allocate_unprepared(group(2, 3), &layout); + assert_ne!(first.group, second.group); + allocator.free(first); + let shared = allocator.allocate_unprepared(group(2, 3), &layout); + assert_eq!( + shared.group, second.group, + "reuse resident textures instead of duplicating into an empty earlier slab" + ); + allocator.free(second); + allocator.free(shared); + let fresh = allocator.allocate_unprepared(group(0, 3), &layout); + assert_eq!(fresh.group, first.group); + eprintln!("PROBE allocator resource reuse and retirement passed"); +} diff --git a/crates/skate-game/src/retail_shadow_visibility.rs b/crates/skate-game/src/retail_shadow_visibility.rs new file mode 100644 index 00000000..504a1e6a --- /dev/null +++ b/crates/skate-game/src/retail_shadow_visibility.rs @@ -0,0 +1,331 @@ +//! Conservative spatial acceleration of Bevy directional shadow visibility. +//! Leaves use Bevy's original OBB test; only static map meshes enter the index. +use bevy::{ + camera::{ + primitives::{Aabb, CascadesFrusta, Frustum}, + visibility::{ + CascadesVisibleEntities, NoFrustumCulling, RenderLayers, SetViewVisibility, + VisibilityRange, VisibilitySystems, VisibleEntityRanges, + }, + }, + light::{NotShadowCaster, SimulationLightSystems}, + math::Affine3A, + prelude::*, + transform::TransformSystems, +}; + +#[derive(Component)] +pub(crate) struct StaticShadowCaster; +#[derive(Clone)] +struct Entry { + entity: Entity, + aabb: Aabb, + transform: GlobalTransform, + visible: bool, + layers: RenderLayers, +} +impl Entry { + fn bounds(&self) -> Aabb { + let affine = self.transform.affine(); + Aabb { + center: affine.transform_point3a(self.aabb.center), + half_extents: affine.matrix3.abs() * self.aabb.half_extents + + bevy::math::Vec3A::splat(0.01), + } + } +} +struct Node { + bounds: Aabb, + layers: RenderLayers, + range: std::ops::Range, + children: Option<(usize, usize)>, +} +#[derive(Resource, Default)] +struct Index { + entries: Vec, + nodes: Vec, + initialized: bool, +} +impl Index { + fn rebuild(&mut self, entries: Vec) { + self.entries = entries; + self.nodes.clear(); + if !self.entries.is_empty() { + self.build(0..self.entries.len()); + } + self.initialized = true; + } + fn build(&mut self, range: std::ops::Range) -> usize { + let mut min = bevy::math::Vec3A::splat(f32::INFINITY); + let mut max = -min; + let mut layers = RenderLayers::none(); + for entry in &self.entries[range.clone()] { + layers = layers.union(&entry.layers); + let bounds = entry.bounds(); + min = min.min(bounds.min()); + max = max.max(bounds.max()); + } + let bounds = Aabb { + center: (min + max) * 0.5, + half_extents: (max - min) * 0.5 + bevy::math::Vec3A::splat(0.01), + }; + let node = self.nodes.len(); + self.nodes.push(Node { + bounds, + layers, + range: range.clone(), + children: None, + }); + if range.len() > 16 { + let axis = if bounds.half_extents.x >= bounds.half_extents.y + && bounds.half_extents.x >= bounds.half_extents.z + { + 0 + } else if bounds.half_extents.y >= bounds.half_extents.z { + 1 + } else { + 2 + }; + self.entries[range.clone()].sort_unstable_by(|a, b| { + a.bounds().center[axis].total_cmp(&b.bounds().center[axis]) + }); + let middle = range.start + range.len() / 2; + let left = self.build(range.start..middle); + let right = self.build(middle..range.end); + self.nodes[node].children = Some((left, right)); + } + node + } + fn visit( + &self, + node: usize, + frustum: &Frustum, + layers: &RenderLayers, + output: &mut Vec, + ) { + let node = &self.nodes[node]; + if !layers.intersects(&node.layers) { + return; + } + if node.bounds.center.is_finite() + && node.bounds.half_extents.is_finite() + && !frustum.intersects_obb(&node.bounds, &Affine3A::IDENTITY, false, true) + { + return; + } + if let Some((left, right)) = node.children { + self.visit(left, frustum, layers, output); + self.visit(right, frustum, layers, output); + } else { + for entry in &self.entries[node.range.clone()] { + if entry.visible + && layers.intersects(&entry.layers) + && frustum.intersects_obb(&entry.aabb, &entry.transform.affine(), false, true) + { + output.push(entry.entity); + } + } + } + } +} + +type StaticFilter = ( + With, + With, + Without, + Without, + Without, + Without, +); +fn refresh( + mut index: ResMut, + entries: Query< + ( + Entity, + &Aabb, + &GlobalTransform, + &InheritedVisibility, + Option<&RenderLayers>, + ), + StaticFilter, + >, + changed: Query< + (), + ( + With, + Or<( + Changed, + Changed, + Changed, + Changed, + Added, + )>, + ), + >, + mut removed_layers: RemovedComponents, + mut removed_excluded: RemovedComponents, + mut removed_range: RemovedComponents, + mut removed_no_cull: RemovedComponents, +) { + // Consume every event reader even when an earlier condition is true. + let removed = removed_layers.read().count() + + removed_excluded.read().count() + + removed_range.read().count() + + removed_no_cull.read().count(); + if index.initialized + && index.entries.len() == entries.iter().len() + && changed.is_empty() + && removed == 0 + { + return; + } + index.rebuild( + entries + .iter() + .map(|(entity, aabb, transform, visibility, layers)| Entry { + entity, + aabb: *aabb, + transform: *transform, + visible: visibility.get(), + layers: layers.cloned().unwrap_or_default(), + }) + .collect(), + ); +} +fn check_indexed( + mut commands: Commands, + index: Res, + mut lights: Query< + ( + &DirectionalLight, + &CascadesFrusta, + &mut CascadesVisibleEntities, + Option<&RenderLayers>, + &ViewVisibility, + ), + Without, + >, + dynamic: Query< + ( + Entity, + &InheritedVisibility, + Option<&RenderLayers>, + Option<&Aabb>, + Option<&GlobalTransform>, + Has, + Has, + ), + ( + With, + Without, + Without, + Or<( + Without, + With, + With, + Without, + Without, + )>, + ), + >, + ranges: Option>, +) { + let mut marked = Vec::new(); + for (light, frusta, mut visible, layers, light_visibility) in &mut lights { + visible + .entities + .retain(|view, _| frusta.frusta.contains_key(view)); + for (view, frusta) in &frusta.frusta { + let lists = visible.entities.entry(*view).or_default(); + lists.resize(frusta.len(), Default::default()); + for list in lists.iter_mut() { + list.clear(); + } + if !light.shadows_enabled || !light_visibility.get() { + continue; + } + let layers = layers.cloned().unwrap_or_default(); + for (frustum, list) in frusta.iter().zip(lists.iter_mut()) { + if !index.nodes.is_empty() { + index.visit(0, frustum, &layers, &mut list.entities); + } + } + for (entity, inherited, mask, aabb, transform, range, no_cull) in &dynamic { + if !inherited.get() + || !layers.intersects(mask.unwrap_or_default()) + || (range + && ranges + .as_ref() + .is_some_and(|r| !r.entity_is_in_range_of_view(entity, *view))) + { + continue; + } + for (frustum, list) in frusta.iter().zip(lists.iter_mut()) { + if no_cull + || aabb.zip(transform).is_none_or(|(a, t)| { + frustum.intersects_obb(a, &t.affine(), false, true) + }) + { + list.push(entity); + } + } + } + for list in lists.iter() { + marked.extend(list.iter().copied()); + } + } + } + // Match upstream's deferred writes so point-light checks may run in parallel. + commands.queue(move |world: &mut World| { + for entity in marked { + if let Some(mut visible) = world.get_mut::(entity) { + visible.set_visible(); + } + } + }); +} + +pub(super) fn install(app: &mut App) { + app.init_resource::().add_systems( + PostUpdate, + ( + refresh + .after(VisibilitySystems::CalculateBounds) + .after(TransformSystems::Propagate) + .after(VisibilitySystems::VisibilityPropagate) + .before(check_indexed), + check_indexed + .in_set(SimulationLightSystems::CheckLightVisibility) + .after(VisibilitySystems::CalculateBounds) + .after(TransformSystems::Propagate) + .after(SimulationLightSystems::UpdateLightFrusta) + .after(VisibilitySystems::CheckVisibility) + .before(VisibilitySystems::MarkNewlyHiddenEntitiesInvisible), + ), + ); +} +pub(super) fn finish(app: &mut App) { + app.world_mut() + .schedule_scope(PostUpdate, |world, schedule| { + let removed = schedule + .remove_systems_in_set( + bevy::light::check_dir_light_mesh_visibility, + world, + bevy::ecs::schedule::ScheduleCleanupPolicy::RemoveSystemsOnly, + ) + .expect("replace directional visibility system"); + assert_eq!( + removed, 1, + "exactly one upstream directional visibility system" + ); + }); +} + +#[cfg(test)] +#[path = "retail_shadow_visibility_tests.rs"] +mod tests; + +#[cfg(test)] +pub(crate) fn benchmark_bounds(bounds: Vec) -> serde_json::Value { + tests::benchmark_bounds(bounds) +} diff --git a/crates/skate-game/src/retail_shadow_visibility_tests.rs b/crates/skate-game/src/retail_shadow_visibility_tests.rs new file mode 100644 index 00000000..fbee638d --- /dev/null +++ b/crates/skate-game/src/retail_shadow_visibility_tests.rs @@ -0,0 +1,251 @@ +use super::*; +fn frustum(radius: f32, x: f32) -> Frustum { + Frustum::from_clip_from_world( + &(Mat4::orthographic_rh(-radius, radius, -radius, radius, 0.1, 300.) + * Mat4::look_at_rh(Vec3::new(x, 20., 80.), Vec3::new(x, 0., 0.), Vec3::Y)), + ) +} +#[test] +fn spatial_tree_matches_flat_obb_checks_with_rotations_and_layers() { + let mut world = World::new(); + let mut entries = vec![]; + for i in 0..4096 { + entries.push(Entry { + entity: world.spawn_empty().id(), + aabb: Aabb { + center: bevy::math::Vec3A::ZERO, + half_extents: bevy::math::Vec3A::new(1., 2., 3.), + }, + transform: GlobalTransform::from( + Transform::from_xyz((i % 64) as f32 * 8. - 256., 0., (i / 64) as f32 * 8. - 256.) + .with_rotation(Quat::from_rotation_y(i as f32 * 0.17)) + .with_scale(Vec3::new(1., 2., 0.5)), + ), + visible: i % 9 != 0, + layers: RenderLayers::layer(i % 2), + }); + } + let mut index = Index::default(); + index.rebuild(entries); + for x in [-200., -30., 0., 45., 180.] { + for layer in [0, 1] { + for radius in [5., 40., 150.] { + let f = frustum(radius, x); + let mask = RenderLayers::layer(layer); + let mut expected: Vec<_> = index + .entries + .iter() + .filter(|e| { + e.visible + && e.layers.intersects(&mask) + && f.intersects_obb(&e.aabb, &e.transform.affine(), false, true) + }) + .map(|e| e.entity) + .collect(); + let mut actual = vec![]; + index.visit(0, &f, &mask, &mut actual); + expected.sort(); + actual.sort(); + assert_eq!(actual, expected); + } + } + } +} +#[test] +fn indexed_system_matches_upstream_across_changes_and_removal() { + bevy::tasks::ComputeTaskPool::get_or_init(Default::default); + let mut world = World::new(); + world.init_resource::(); + let view = world.spawn_empty().id(); + let visibility = ViewVisibility::HIDDEN; + let mut frusta = CascadesFrusta::default(); + frusta + .frusta + .insert(view, vec![frustum(10., 0.), frustum(100., 0.)]); + let light = world + .spawn(( + DirectionalLight { + shadows_enabled: true, + ..default() + }, + frusta, + CascadesVisibleEntities::default(), + visibility, + )) + .id(); + world + .get_mut::(light) + .unwrap() + .set_visible(); + let mut entities = vec![]; + for i in 0..80 { + let mut e = world.spawn(( + Mesh3d::default(), + Aabb { + center: bevy::math::Vec3A::ZERO, + half_extents: bevy::math::Vec3A::ONE, + }, + GlobalTransform::from_translation(Vec3::new(i as f32 * 5. - 200., 0., 0.)), + InheritedVisibility::VISIBLE, + )); + if i % 2 == 0 { + e.insert(StaticShadowCaster); + } + if i % 7 == 0 { + e.insert(NoFrustumCulling); + } + if i % 11 == 0 { + e.insert(RenderLayers::layer(28)); + } + entities.push(e.id()); + } + let mut original = bevy::ecs::schedule::Schedule::default(); + original.add_systems(bevy::light::check_dir_light_mesh_visibility); + let mut indexed = bevy::ecs::schedule::Schedule::default(); + indexed.add_systems((refresh, check_indexed).chain()); + let read = |world: &World| { + let v = world.get::(light).unwrap(); + let mut lists: Vec> = v.entities[&view] + .iter() + .map(|e| e.entities.clone()) + .collect(); + for list in &mut lists { + list.sort(); + } + lists + }; + for step in 0..6 { + match step { + 1 => { + world + .entity_mut(entities[0]) + .insert(GlobalTransform::from_translation(Vec3::ZERO)); + } + 2 => { + world.entity_mut(entities[2]).insert(NotShadowCaster); + world + .entity_mut(entities[4]) + .insert(InheritedVisibility::HIDDEN); + } + 3 => { + world.entity_mut(entities[2]).remove::(); + world.entity_mut(entities[11]).remove::(); + } + 4 => { + world + .entity_mut(light) + .get_mut::() + .unwrap() + .shadows_enabled = false; + } + 5 => { + world + .entity_mut(light) + .get_mut::() + .unwrap() + .shadows_enabled = true; + world.despawn(entities[6]); + } + _ => {} + } + original.run(&mut world); + let expected = read(&world); + indexed.run(&mut world); + assert_eq!(read(&world), expected, "step {step}"); + } + for entity in entities { + world.despawn(entity); + } + indexed.run(&mut world); + assert!(world.resource::().entries.is_empty()); +} +#[test] +fn replacement_preserves_schedule_ordering() { + let mut app = App::new(); + app.add_plugins(( + MinimalPlugins, + bevy::asset::AssetPlugin::default(), + bevy::transform::TransformPlugin, + bevy::mesh::MeshPlugin, + bevy::camera::visibility::VisibilityPlugin, + bevy::light::LightPlugin, + )); + install(&mut app); + finish(&mut app); +} + +pub(super) fn benchmark_bounds(bounds: Vec) -> serde_json::Value { + let mut world = World::new(); + let mut index = Index::default(); + let count = bounds.len(); + let start = std::time::Instant::now(); + index.rebuild( + bounds + .into_iter() + .map(|aabb| Entry { + entity: world.spawn_empty().id(), + aabb, + transform: GlobalTransform::IDENTITY, + visible: true, + layers: default(), + }) + .collect(), + ); + let build_ms = start.elapsed().as_secs_f64() * 1000.; + let mut frusta = vec![]; + for offset in [-50., 0., 50.] { + for radius in [5., 10., 30., 100.] { + let center = Vec3::new(330. + offset, 133., -710.); + let light = Vec3::new(-0.60385966, 0.73098797, 0.31782088); + frusta.push(Frustum::from_clip_from_world( + &(Mat4::orthographic_rh(-radius, radius, -radius, radius, 0.1, 400.) + * Mat4::look_at_rh(center + light * 200., center, Vec3::Y)), + )); + } + } + let mask = RenderLayers::default(); + let mut out = Vec::with_capacity(count); + let mut expected = Vec::with_capacity(count); + for f in &frusta { + out.clear(); + expected.clear(); + index.visit(0, f, &mask, &mut out); + for entry in &index.entries { + if f.intersects_obb(&entry.aabb, &entry.transform.affine(), false, true) { + expected.push(entry.entity); + } + } + out.sort(); + expected.sort(); + assert_eq!(out, expected); + } + let repetitions = 100; + let start = std::time::Instant::now(); + for _ in 0..repetitions { + for f in &frusta { + out.clear(); + for entry in &index.entries { + if std::hint::black_box(f).intersects_obb( + &entry.aabb, + &entry.transform.affine(), + false, + true, + ) { + out.push(entry.entity); + } + } + std::hint::black_box(&out); + } + } + let flat_ms = start.elapsed().as_secs_f64() * 1000.; + let start = std::time::Instant::now(); + for _ in 0..repetitions { + for f in &frusta { + out.clear(); + index.visit(0, std::hint::black_box(f), &mask, &mut out); + std::hint::black_box(&out); + } + } + let indexed_ms = start.elapsed().as_secs_f64() * 1000.; + serde_json::json!({"static_batches":count,"queries":repetitions*frusta.len(),"flat_ms":flat_ms,"indexed_ms":indexed_ms,"build_ms":build_ms,"visible_sets_equal":true,"note":"real University material bounds; representative shadow frusta, single-thread query kernel only, not whole-frame FPS"}) +} diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index d1688379..7c86dbb9 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -505,7 +505,13 @@ mod performance_inventory { source_shadow_triangles.sort_unstable(); batched_shadow_triangles.sort_unstable(); assert_eq!(source_shadow_triangles, batched_shadow_triangles); let proxy_vertices: usize = shadows.batches.iter().map(|(_, indices)| indices.iter().collect::>().len()).sum(); - let result = serde_json::json!({"baseline_batches":baseline.len(),"consolidated_batches":groups.len(),"opaque":opaque,"masked":masked,"blended":blended,"fallback":fallback,"triangles":after.len(),"map_lights":map.lights.len(),"triangle_multiset_preserved":true,"replaced_opaque_shadow_casters":replaced_casters,"opaque_shadow_batches":shadows.batches.len(),"shadow_triangle_multiset_preserved":true,"opaque_shadow_triangles":source_shadow_triangles.len(),"proxy_vertices":proxy_vertices,"proxy_vertex_index_bytes":proxy_vertices*24+source_shadow_triangles.len()*12}); + let bounds=groups.iter().filter(|g|g.retail.is_some()).map(|g| { + let mut min=Vec3::splat(f32::INFINITY);let mut max=-min; + for &i in &g.indices {let p=Vec3::from_array(map.geometry.vertices[i as usize].position);min=min.min(p);max=max.max(p);} + bevy::camera::primitives::Aabb::from_min_max(min,max) + }).collect(); + let shadow_index=crate::retail_render::shadow_visibility::benchmark_bounds(bounds); + let result = serde_json::json!({"shadow_index":shadow_index,"baseline_batches":baseline.len(),"consolidated_batches":groups.len(),"opaque":opaque,"masked":masked,"blended":blended,"fallback":fallback,"triangles":after.len(),"map_lights":map.lights.len(),"triangle_multiset_preserved":true,"replaced_opaque_shadow_casters":replaced_casters,"opaque_shadow_batches":shadows.batches.len(),"shadow_triangle_multiset_preserved":true,"opaque_shadow_triangles":source_shadow_triangles.len(),"proxy_vertices":proxy_vertices,"proxy_vertex_index_bytes":proxy_vertices*24+source_shadow_triangles.len()*12}); std::fs::write(std::env::var_os("SKATE_MAP_INVENTORY_OUT").unwrap(),serde_json::to_vec_pretty(&result).unwrap()).unwrap(); } /// Explicit read-only inventory, never initializes Bevy or a GPU. Asset path @@ -694,7 +700,7 @@ pub(crate) fn spawn( } if let Some(material) = retail { let material = retail_materials.add(material); - commands.spawn((Name::new(m.name.clone()), Mesh3d(meshes.add(mesh)), MeshMaterial3d(material), Transform::default())); + commands.spawn((crate::retail_render::shadow_visibility::StaticShadowCaster, Name::new(m.name.clone()), Mesh3d(meshes.add(mesh)), MeshMaterial3d(material), Transform::default())); continue; } // Vertex colours above carry retail decal coordinates, never PBR tint. diff --git a/docs/cpu-followup-optimizations.md b/docs/cpu-followup-optimizations.md new file mode 100644 index 00000000..b1c2cfe7 --- /dev/null +++ b/docs/cpu-followup-optimizations.md @@ -0,0 +1,76 @@ +# CPU follow-up after v8 + +The user confirmed substantially higher FPS in v8 and authorized all three next +targets. The new 20-second v8 capture completed without dropped events and confirmed +bindless materials enabled. Inclusive CPU scopes measured main opaque encoder +finish at 1.47 ms/frame, directional shadow visibility at 0.52 ms/frame, and +controller polling at 0.39 ms/frame. These costs overlap and are not promised savings. + +## Resource reuse in material groups + +The vendored Bevy allocator now chooses the fitting slab requiring the fewest new +resources, preferring an already fuller slab on ties. Previously it chose the +first fitting slab, even if a later slab already contained all the textures. +This targets duplicate texture references and the associated render-command +resource tracking. Capacity remains 64 for retail materials; shader code is +unchanged. Allocation, reference counting, retirement and GPU uploads retain the +existing implementation. Selection performs more work when materials are loaded +or replaced, not on steady-state rendered frames. An FPS benefit still requires +a matched capture; this is not a guarantee of fewer total slabs on every workload. + +The explicit Vulkan test exercises the real allocator with GPU texture identities: +fill two slabs, free the first, reuse the second's resident textures instead of +duplicating them into the first, then retire and reallocate the resources. + +## Static shadow spatial index + +Retail map meshes receive a static-shadow marker. A cached binary bounding-volume +hierarchy rejects groups outside a directional shadow frustum. Each leaf still +uses Bevy's original per-mesh OBB test with near-plane rejection disabled and far +rejection enabled. Node bounds conservatively enclose transformed mesh bounds; +node render-layer unions also reject unrelated lights early. + +The index rebuilds when relevant transforms, bounds, inherited visibility, render +layers or membership change. Camera movement changes queries, not the static tree. +Unmarked/dynamic meshes, missing bounds/transforms, explicit visibility ranges and +NoFrustumCulling use the ordinary per-mesh semantics. Shadow visibility is still +marked through deferred commands, retaining ordering relative to point lights and +newly hidden entities. No geometry, shadow distance, cascade count or visuals change. + +Tests compare results with upstream Bevy across mesh movement, visibility and layer +changes, exclusion removal, disabled lights and entity retirement; another compares +the tree against flat OBB checks over 4,096 rotated/scaled boxes. Schedule replacement +is tested separately. A data-only University benchmark uses real material bounds +and representative shadow frusta; its result is not whole-frame performance. + +## Controller metadata cache + +XInputGetState still runs every host frame for every eligible slot. Raw packets, +packet-number handling, fixed-tick publication and simulation cadence are unchanged. +Only the controller subtype obtained from XInputGetCapabilities is cached. State +errors/disconnects and filtered-out slots invalidate it immediately. Metadata is +also refreshed once per second so a hot swap cannot retain a subtype indefinitely +if Windows never reports a disconnect. Failed refreshes remain errors and are +retried, never stored as successful data. Menu-only polling retains its original API. + +This removes redundant capability queries; the 0.39 ms total polling cost also +includes state queries, so it is not all recoverable by this change. + +## Verification scope + +Headless Vulkan main/shadow tests retain the v8 pipeline-crash regression and 8x +MSAA pixel readback. The game and native gameplay are never launched by the agent. +Existing v8 and recovery binaries remain available. Final frame-time acceptance +requires the same scene/settings and route in the private v9 build and v8. + +Final targeted checks passed: 47 ordinary tests (29 input, 3 shadow index/schedule, +11 world adapter, 2 scene lifecycle, 2 shader/descriptor), plus both GPU probes in +bindless and fallback modes. The real allocator reuse/retirement assertions run +inside those GPU probes. The explicit University inventory also passed geometry +preservation and indexed-versus-flat visibility equality. + +For 4,840 static retail batches and 1,200 representative shadow queries, the final +single-thread kernel benchmark measured 22.1027 ms flat versus 4.1048 ms indexed +(81.4% lower query time), with a 1.8785 ms index build. It excludes ECS refresh, +dynamic casters, deferred visibility writes and the upstream parallel scheduler; +it is not an 81.4% reduction in whole shadow-system time or frame time. diff --git a/vendor/README.md b/vendor/README.md index 6e90551f..20181c4a 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -1,5 +1,14 @@ # Local Bevy patch +## Bindless material resource reuse + +`bevy_pbr/src/material_bind_groups.rs` chooses a fitting slab requiring the fewest +new resource slots instead of first-fit allocation. It prefers already resident +textures, then fuller slabs, while keeping existing allocation/refcount/free logic +and capacity limits. This adds work during material allocation, not each frame. +The game's explicit Vulkan shader probe tests reuse and retirement using real GPU +texture identities. See `docs/cpu-followup-optimizations.md` for scope and limits. + ## Conservative occlusion depth pyramid `bevy_core_pipeline` is vendored from crates.io 0.18.1 with its original licenses. diff --git a/vendor/bevy_pbr/src/material_bind_groups.rs b/vendor/bevy_pbr/src/material_bind_groups.rs index d987725b..efa5a055 100644 --- a/vendor/bevy_pbr/src/material_bind_groups.rs +++ b/vendor/bevy_pbr/src/material_bind_groups.rs @@ -815,19 +815,22 @@ impl MaterialBindGroupBindlessAllocator { /// created, and the material is allocated into it. fn allocate_unprepared( &mut self, - mut unprepared_bind_group: UnpreparedBindGroup, + unprepared_bind_group: UnpreparedBindGroup, ) -> MaterialBindingId { - for (slab_index, slab) in self.slabs.iter_mut().enumerate() { - trace!("Trying to allocate in slab {}", slab_index); - match slab.try_allocate(unprepared_bind_group, self.slab_capacity) { - Ok(slot) => { - return MaterialBindingId { - group: MaterialBindGroupIndex(slab_index as u32), - slot, - }; - } - Err(bind_group) => unprepared_bind_group = bind_group, - } + // Prefer resources already resident in a slab. First-fit can duplicate a + // texture into an earlier slab even when a later slab already binds it, + // increasing per-pass resource tracking and splitting compatible draws. + // Allocation only: no extra scan is added to rendering steady-state. + let best = self.slabs.iter().enumerate().filter_map(|(index, slab)| { + let candidate = slab.check_allocation(&unprepared_bind_group)?; + let used = slab.allocated_resource_count; + (used == 0 || used + candidate.needed_free_slots <= self.slab_capacity) + .then_some(((candidate.needed_free_slots, core::cmp::Reverse(used)), index)) + }).min_by_key(|(score, _)| *score).map(|(_, index)| index); + if let Some(index) = best { + let slot = self.slabs[index].try_allocate(unprepared_bind_group, self.slab_capacity) + .unwrap_or_else(|_| unreachable!("selected slab has sufficient capacity")); + return MaterialBindingId { group: MaterialBindGroupIndex(index as u32), slot }; } let group = MaterialBindGroupIndex(self.slabs.len() as u32);