diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index df561e55..d3386302 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -65,7 +65,11 @@ pub(crate) fn build( ..default() }), ..default() - }).build().disable::(), + }).build().disable::() + // Gameplay and menu navigation both use raw XInput. No game system + // consumes Bevy gamepad events/rumble; its second device backend can + // stall PreUpdate (70.68 ms in the University capture). + .disable::(), ) .insert_resource(bevy::winit::WinitSettings {focused_mode:bevy::winit::UpdateMode::Continuous,unfocused_mode:bevy::winit::UpdateMode::Continuous}) .insert_resource(config) diff --git a/crates/skate-game/src/retail_exposure.rs b/crates/skate-game/src/retail_exposure.rs index 1216dbcb..f53eac8a 100644 --- a/crates/skate-game/src/retail_exposure.rs +++ b/crates/skate-game/src/retail_exposure.rs @@ -19,6 +19,7 @@ use bevy::{ view::ViewTarget, }, }; +use std::{collections::VecDeque, sync::Mutex}; #[derive(Resource, Clone, ExtractResource)] struct Settings { @@ -56,6 +57,7 @@ pub(super) fn install(app: &mut App) { render .add_systems(RenderStartup, initialize) .add_systems(Render, upload.in_set(RenderSystems::PrepareResources)) + .add_systems(Render, prune_bindings.in_set(RenderSystems::PrepareBindGroups)) .add_render_graph_node::>(Core3d, ExposureLabel) .add_render_graph_edges( Core3d, @@ -112,6 +114,10 @@ struct Pipeline { settings: Buffer, state: Buffer, sampler: Sampler, + // The buffers, sampler and layouts are immutable for this Pipeline's life. + // Source views can alternate, resize or belong to different cameras. Keep a + // bounded cache so retired targets cannot accumulate across map/size changes. + bindings: Mutex>, } fn initialize( @@ -189,6 +195,7 @@ fn initialize( min_filter: FilterMode::Linear, ..default() }), + bindings: default(), }); } fn bytes(values: [Vec4; N]) -> Vec { @@ -199,13 +206,29 @@ fn bytes(values: [Vec4; N]) -> Vec { .collect() } fn upload(settings: Res, pipeline: Res, queue: Res) { + let mut data = [0u8; 32]; + for (chunk, value) in data.chunks_exact_mut(4).zip( + [settings.tuning, settings.timing].into_iter().flat_map(|v| v.to_array()), + ) { + chunk.copy_from_slice(&value.to_le_bytes()); + } queue.write_buffer( &pipeline.settings, 0, - &bytes([settings.tuning, settings.timing]), + &data, ); } +fn prune_bindings(pipeline: Res, views: Query<&ViewTarget, With>) { + // Bind groups retain their textures. Release retired camera/resize targets + // before rendering, including when there are no exposure views left. + pipeline.bindings.lock().unwrap().retain(|(id, _, _)| { + views.iter().any(|view| { + *id == view.main_texture_view().id() || *id == view.main_texture_other_view().id() + }) + }); +} + #[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] struct ExposureLabel; #[derive(Default)] @@ -230,21 +253,32 @@ impl ViewNode for ExposureNode { let post = view.post_process_write(); use bevy::render::diagnostic::RecordDiagnostics; let diagnostics = context.diagnostic_recorder(); - let meter = context.render_device().create_bind_group( - "retail meter", - &cache.get_bind_group_layout(&p.compute_layout), - &BindGroupEntries::sequential(( - post.source, - &p.sampler, - p.settings.as_entire_binding(), - p.state.as_entire_binding(), - )), - ); - let output = context.render_device().create_bind_group( - "retail exposed tone", - &cache.get_bind_group_layout(&p.tone_layout), - &BindGroupEntries::sequential((post.source, &p.sampler, p.state.as_entire_binding())), - ); + let mut bindings = p.bindings.lock().unwrap(); + let index = if let Some(index) = bindings.iter().position(|(id, _, _)| *id == post.source.id()) { + index + } else { + let meter = context.render_device().create_bind_group( + "retail meter", + &cache.get_bind_group_layout(&p.compute_layout), + &BindGroupEntries::sequential(( + post.source, + &p.sampler, + p.settings.as_entire_binding(), + p.state.as_entire_binding(), + )), + ); + let output = context.render_device().create_bind_group( + "retail exposed tone", + &cache.get_bind_group_layout(&p.tone_layout), + &BindGroupEntries::sequential((post.source, &p.sampler, p.state.as_entire_binding())), + ); + if bindings.len() == 8 { + bindings.pop_front(); + } + bindings.push_back((post.source.id(), meter, output)); + bindings.len() - 1 + }; + let (_, meter, output) = &bindings[index]; { let mut pass = context .command_encoder() @@ -254,7 +288,7 @@ impl ViewNode for ExposureNode { }); pass.set_pipeline(compute); let span = diagnostics.pass_span(&mut pass, "retail_exposure_meter"); - pass.set_bind_group(0, &meter, &[]); + pass.set_bind_group(0, meter, &[]); pass.dispatch_workgroups(1, 1, 1); span.end(&mut pass); } @@ -272,7 +306,7 @@ impl ViewNode for ExposureNode { }); pass.set_render_pipeline(tone); let span = diagnostics.pass_span(&mut pass, "retail_exposed_tone"); - pass.set_bind_group(0, &output, &[]); + pass.set_bind_group(0, output, &[]); pass.draw(0..3, 0..1); span.end(&mut pass); Ok(()) diff --git a/crates/skate-game/src/session_marker/effect.rs b/crates/skate-game/src/session_marker/effect.rs index f6fc15a0..81742103 100644 --- a/crates/skate-game/src/session_marker/effect.rs +++ b/crates/skate-game/src/session_marker/effect.rs @@ -17,6 +17,11 @@ struct Params { second: Vec4, fade: Vec4, } +impl Params { + fn bits(&self) -> [[u32; 4]; 4] { + [self.scroll, self.first, self.second, self.fade].map(|v| v.to_array().map(f32::to_bits)) + } +} #[derive(Asset, TypePath, AsBindGroup, Clone, Debug)] struct Effect { #[uniform(0)] @@ -47,6 +52,57 @@ mod shader_path_tests { let ShaderRef::Path(path) = Effect::fragment_shader() else { panic!("Expected embedded shader path") }; assert_eq!(path, AssetPath::from(embedded_path!("effect.wgsl")).with_source("embedded")); } + + #[test] + fn hidden_noise_keeps_its_sequence_and_publishes_before_becoming_visible() { + use bevy::ecs::system::RunSystemOnce; + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())) + .init_asset::() + .init_resource::(); + let initial = Params { scroll: Vec4::ZERO, first: Vec4::X, second: Vec4::X, fade: Vec4::ZERO }; + let material = app.world_mut().resource_mut::>().add(Effect { + params: initial.clone(), texture: default(), + }); + let mut noise = Noise::default(); + noise.texture(); + app.insert_resource(Runtime { noise, material: material.clone(), time: 0. }); + app.world_mut().spawn(Window::default()); + let screen = app.world_mut().spawn((Screen, Transform::default(), Visibility::Hidden)).id(); + app.update(); + app.world_mut().resource_mut::>>().clear(); + let mut reference = Noise::default(); + reference.texture(); + let mut reference_time = 0.; + let mut published = initial; + for (dt, progress) in [(0.016, 0.), (0.25, 0.), (0., 0.5), (0., 0.5), (0.02, 0.7), (0.05, 0.), (0., 0.4)] { + app.world_mut().resource_mut::>().advance_by(std::time::Duration::from_secs_f64(dt)); + app.world_mut().resource_mut::().progress = progress; + reference_time += dt; + while reference_time >= 1. / 60. { + reference_time -= 1. / 60.; + reference.advance(); + } + let expected = Params { + scroll: Vec4::from_array(reference.scroll), + first: Vec4::from_array(Noise::weights(reference.phases[0])), + second: Vec4::from_array(Noise::weights(reference.phases[1])), + fade: Vec4::new(progress, 0., 0., 0.), + }; + let modified = progress > 0. && expected.bits() != published.bits(); + app.world_mut().run_system_once(present).unwrap(); + if progress > 0. { published = expected; } + assert_eq!(app.world().resource::>().get(&material).unwrap().params.bits(), published.bits()); + let runtime = app.world().resource::(); + assert_eq!(runtime.noise.scroll.map(f32::to_bits), reference.scroll.map(f32::to_bits)); + assert_eq!(runtime.noise.phases.map(f32::to_bits), reference.phases.map(f32::to_bits)); + assert_eq!(*app.world().get::(screen).unwrap(), if progress > 0. { Visibility::Inherited } else { Visibility::Hidden }); + app.update(); + let modifications = app.world_mut().resource_mut::>>().drain() + .filter(|e| matches!(e, AssetEvent::Modified { id } if *id == material.id())).count(); + assert_eq!(modifications, usize::from(modified)); + } + } } #[derive(Component)] struct Screen; @@ -116,20 +172,28 @@ fn present( runtime.time -= 1. / 60.; runtime.noise.advance(); } - if let Some(material) = materials.get_mut(&runtime.material) { - material.params = Params { + // Advance the native noise stream even while hidden, then publish the current + // values on the first visible frame. Hidden assets need no GPU preparation. + if session.progress > 0. { + let params = Params { scroll: Vec4::from_array(runtime.noise.scroll), first: Vec4::from_array(Noise::weights(runtime.noise.phases[0])), second: Vec4::from_array(Noise::weights(runtime.noise.phases[1])), fade: Vec4::new(session.progress, 0., 0., 0.), }; + if materials.get(&runtime.material).is_some_and(|m| m.params.bits() != params.bits()) { + materials.get_mut(&runtime.material).unwrap().params = params; + } } for (mut transform, mut visibility) in &mut screen { - transform.scale = Vec3::new(window.width(), window.height(), 1.); - *visibility = if session.progress > 0. { + let scale = Vec3::new(window.width(), window.height(), 1.); + if transform.scale != scale { + transform.scale = scale; + } + visibility.set_if_neq(if session.progress > 0. { Visibility::Inherited } else { Visibility::Hidden - }; + }); } } diff --git a/docs/university-performance-assessment.md b/docs/university-performance-assessment.md index 72330d51..b1352e7b 100644 --- a/docs/university-performance-assessment.md +++ b/docs/university-performance-assessment.md @@ -375,3 +375,63 @@ comparison was checked against event-level five-second windows and long slices. This capture supports retaining the exact lighting-update optimization, with no claim of an overall FPS improvement. User visual confirmation remains separate from timing evidence; the agent did not launch gameplay or alter quality settings. + +## Follow-up source audit and optimization order + +The third capture ranks the next work as follows. Times below are inclusive CPU +wall time amortized over main frames, unless labeled GPU; nested and parallel +rows must not be added. The 25 us filter omits short executions. + +| Priority | Area and observed cost | Concrete opportunity / decision | +|---|---|---| +| 1 | Command generation 2.004 ms/frame; main opaque scope 1.772 ms/frame | Draw traversal accounts for 0.418 ms opaque plus 0.141 ms masked. Added `main_opaque_pass_close` and `main_opaque_encoder_finish` scopes to locate the remaining cost before changing batching or submission. | +| 2 | Directional shadow visibility 0.421 ms/frame | Bevy scans eligible meshes for each light/view and tests every cascade. A conservative static-mesh hierarchy or layer-based candidate index could reduce scans without changing final tests. It needs equivalence tests against the existing entity sets across moving cameras, map swaps, dynamic casters and near-plane exceptions. No culling replacement yet. | +| 3 | Controller-event hitch: 70.68 ms in one call | Disable the unused Gilrs backend. Both gameplay and menu navigation use `input::platform::poll`; the repository has no Bevy gamepad-event or rumble consumer. Raw XInput state/capability calls, slot selection, errors and fixed publication remain unchanged. | +| 4 | Raw controller polling 0.242 ms/frame; menu navigation 0.028 ms/frame | Menu and gameplay make separate OS polls. Sharing a snapshot would change their sample time; throttling disconnected slots or caching capabilities changes reconnect/error semantics. Keep this path unchanged under the native-input constraint. | +| 5 | Character preparation 0.064 ms/frame; material bindings 0.034 ms/frame | Retain the existing bit-exact SH update guard. Further gains during changing lighting would require a separate per-character uniform/storage update path, preserving distinct materials and current-frame lighting. That is a renderer design change, not a safe skipped update. | +| 6 | Hidden session effect: material preparation 0.020 ms/frame; specialization checks 0.035 ms/frame | `present` modified its material, scale and visibility every frame even when hidden. Skip hidden material publication; keep noise/time advancement and publish current values before making it visible. Guard identical visible parameters and unchanged scale/visibility. | +| 7 | Render buffer preparation: indirect parameters 0.098 ms/frame; instances 0.086 ms/frame | Existing capacity reservation already prevents routine capacity churn. Static/dynamic partitioning could reduce uploads but needs explicit removal, previous-frame and camera-cut handling. Do not mark moving data static or disable motion history. | +| 8 | Main visibility 0.134 ms/frame; transforms 0.059 ms/frame | Avoid unnecessary component change ticks first (session effect fixed). A shared static hierarchy is a future option; freezing visibility or transforms is not equivalent. | +| 9 | Physics advance 0.152 ms/frame, including collision/solve 0.089 ms/frame | Existing collision hierarchy preserves source ordering. Reusing query scratch is possible but needs per-call ownership/reentrancy and result-order tests. These costs do not justify changing solver behavior or fixed cadence. | +| 10 | Exposure GPU meter 0.00770 ms; tone 0.01548 ms | Preserve both passes every frame. Reuse their CPU bind groups and replace the per-frame 32-byte heap payload with a stack array. No change to dispatch count, uniform bytes or exposure adaptation. | + +The source review also checked map construction/transitions, texture deduplication, +world/sky material writes and existing render-capacity management. The world and +sky material loops run at scene construction or an explicit diagnostic keypress, +not continuously. There is no evidence here for per-frame map parsing, ongoing +texture reloads or pipeline compilation. Loading/memory peaks require a separate +startup/transition capture; steady-state timings cannot rank those costs. + +### Implemented follow-up changes and limits + +Gilrs removal follows the additional consumer audit above. The earlier decision +to leave input unchanged concerned the *used raw XInput path*, which remains +untouched. Removing an unused backend removes this specific stall source; it does +not guarantee that OS/controller stalls can never occur elsewhere. Keyboard and +mouse input plugins remain enabled. + +The session effect retains the same 60 Hz noise advancement, including hidden +frames and multi-step deltas. A headless regression test compares its sequence +with continuous reference advancement through hidden/visible transitions and +checks material modification events and the first visible payload. Visible +parameters use exact float-bit comparison; no epsilon or effect quality reduction +is introduced. Hidden materials may deliberately retain older parameters until +the frame that displays them. + +Exposure caches at most eight `(source TextureViewId, meter bind group, tone bind +group)` entries inside its Pipeline resource. Buffers, sampler and layouts are +immutable for that resource's lifetime, and replacing the resource drops the cache. +Alternating post-process sources have separate entries. Before rendering, prune +entries against both current source views of every exposure camera, so camera +removal/resizing releases retired textures rather than retaining large targets. +Resized/recreated targets have new source identities. The +destination is a render attachment, not a bind-group input, so it does not belong +in the key. Buffer contents still update every frame. GPU execution/resize checks +remain manual because this task does not authorize the agent to start the game. + +The strongest larger opportunity remains reducing CPU rendering work with exact +output equivalence. A blanket 32 m map split would quadruple the estimated groups, +so it is not an appropriate default for this capture. Bindless material access is +a possible later experiment, but must preserve each texture's format, sampler, +role and alpha ordering and cannot be inferred to help from material counts alone. +No FPS gain is claimed for the follow-up changes before a matched user capture. diff --git a/vendor/bevy_core_pipeline/src/core_3d/main_opaque_pass_3d_node.rs b/vendor/bevy_core_pipeline/src/core_3d/main_opaque_pass_3d_node.rs index 25cf2ac7..693a9cd2 100644 --- a/vendor/bevy_core_pipeline/src/core_3d/main_opaque_pass_3d_node.rs +++ b/vendor/bevy_core_pipeline/src/core_3d/main_opaque_pass_3d_node.rs @@ -133,8 +133,18 @@ impl ViewNode for MainOpaquePass3dNode { } pass_span.end(&mut render_pass); - drop(render_pass); - command_encoder.finish() + { + // wgpu may perform substantial validation/encoding when the pass + // closes. Separate that CPU work from draw-list traversal. + #[cfg(feature = "trace")] + let _span = info_span!("main_opaque_pass_close").entered(); + drop(render_pass); + } + { + #[cfg(feature = "trace")] + let _span = info_span!("main_opaque_encoder_finish").entered(); + command_encoder.finish() + } }); Ok(())