Reduce material duplication, index static shadow casters and cache controller metadata

This commit is contained in:
chazmm committed 2026-09-09 19:59:49 +01:00
1 parent 10406cb25d
commit 1c62e56fd6
10 files changed
+860 -35

No files matched your search

+3 -2
View File
@@ -34,14 +34,15 @@ impl Plugin for InputPlugin {
}
}
pub(crate) fn poll_controllers(mut input: ResMut<ControllerInput>,config:Res<crate::config::Config>,net:Option<Res<crate::multiplayer::Multiplayer>>,windows:Query<&Window>) {
pub(crate) fn poll_controllers(mut input: ResMut<ControllerInput>,config:Res<crate::config::Config>,net:Option<Res<crate::multiplayer::Multiplayer>>,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 {
+86 -14
View File
@@ -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<u8, DeviceError>,
) -> Result<u8, DeviceError> {
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<DevicePacket, DeviceError> {
pub(super) fn poll(
index: u32,
cache: &mut CapabilityCache,
) -> Result<DevicePacket, DeviceError> {
let mut state = MaybeUninit::<State>::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::<Capabilities>::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::<Capabilities>::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<DevicePacket, DeviceError> {
pub(crate) fn poll_cached(
index: usize,
cache: &mut CapabilityCache,
) -> Result<DevicePacket, DeviceError> {
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<DevicePacket, DeviceError> {
poll_cached(index, &mut CapabilityCache::default())
}
+5
View File
@@ -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::<RetailSkyMaterial>::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(
+76 -5
View File
@@ -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<f32>, @location(0) world:vec4<f32>
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<f32>, @location(0) world:vec4<f32>
}
});
}
// 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<bevy::render::render_resource::TextureView> = (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");
}
@@ -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<usize>,
children: Option<(usize, usize)>,
}
#[derive(Resource, Default)]
struct Index {
entries: Vec<Entry>,
nodes: Vec<Node>,
initialized: bool,
}
impl Index {
fn rebuild(&mut self, entries: Vec<Entry>) {
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>) -> 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<Entity>,
) {
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<StaticShadowCaster>,
With<Mesh3d>,
Without<NotShadowCaster>,
Without<DirectionalLight>,
Without<VisibilityRange>,
Without<NoFrustumCulling>,
);
fn refresh(
mut index: ResMut<Index>,
entries: Query<
(
Entity,
&Aabb,
&GlobalTransform,
&InheritedVisibility,
Option<&RenderLayers>,
),
StaticFilter,
>,
changed: Query<
(),
(
With<StaticShadowCaster>,
Or<(
Changed<Aabb>,
Changed<GlobalTransform>,
Changed<InheritedVisibility>,
Changed<RenderLayers>,
Added<StaticShadowCaster>,
)>,
),
>,
mut removed_layers: RemovedComponents<RenderLayers>,
mut removed_excluded: RemovedComponents<NotShadowCaster>,
mut removed_range: RemovedComponents<VisibilityRange>,
mut removed_no_cull: RemovedComponents<NoFrustumCulling>,
) {
// 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<Index>,
mut lights: Query<
(
&DirectionalLight,
&CascadesFrusta,
&mut CascadesVisibleEntities,
Option<&RenderLayers>,
&ViewVisibility,
),
Without<SpotLight>,
>,
dynamic: Query<
(
Entity,
&InheritedVisibility,
Option<&RenderLayers>,
Option<&Aabb>,
Option<&GlobalTransform>,
Has<VisibilityRange>,
Has<NoFrustumCulling>,
),
(
With<Mesh3d>,
Without<NotShadowCaster>,
Without<DirectionalLight>,
Or<(
Without<StaticShadowCaster>,
With<VisibilityRange>,
With<NoFrustumCulling>,
Without<Aabb>,
Without<GlobalTransform>,
)>,
),
>,
ranges: Option<Res<VisibleEntityRanges>>,
) {
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::<ViewVisibility>(entity) {
visible.set_visible();
}
}
});
}
pub(super) fn install(app: &mut App) {
app.init_resource::<Index>().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<Aabb>) -> serde_json::Value {
tests::benchmark_bounds(bounds)
}
@@ -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::<Index>();
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::<ViewVisibility>(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::<CascadesVisibleEntities>(light).unwrap();
let mut lists: Vec<Vec<Entity>> = 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::<NotShadowCaster>();
world.entity_mut(entities[11]).remove::<RenderLayers>();
}
4 => {
world
.entity_mut(light)
.get_mut::<DirectionalLight>()
.unwrap()
.shadows_enabled = false;
}
5 => {
world
.entity_mut(light)
.get_mut::<DirectionalLight>()
.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::<Index>().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<Aabb>) -> 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"})
}
+8 -2
View File
@@ -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::<std::collections::HashSet<_>>().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.
+76
View File
@@ -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.
+9
View File
@@ -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.
+15 -12
View File
@@ -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);