mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Reduce material duplication, index static shadow casters and cache controller metadata
This commit is contained in:
1 parent
10406cb25d
commit
1c62e56fd6
10 files changed
+860
-35
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Vendored
+9
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user